feat(chat): ChatTimeline 双栏化(左会话列表)+ 路由可选参 + badge 账号级入口

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
浅念
2026-08-08 15:22:20 +08:00
parent 394bfa8a7c
commit 8f166a9c18
3 changed files with 128 additions and 75 deletions
+4 -4
View File
@@ -2,8 +2,8 @@ import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
// `/` redirects to `/customers`. Chat timeline params are path segments so the
// URL is shareable + survives refresh:
// /chat/:channel/:ownerId/:talkerId (1:1 DM)
// /group/:channel/:ownerId/:roomId (group chat — same component, room_id)
// /chat/:channel/:ownerId/:talkerId? (1:1 DM; talkerId optional → account-level)
// /group/:channel/:ownerId/:roomId? (group chat — same component, room_id)
const routes: RouteRecordRaw[] = [
{
path: '/',
@@ -58,7 +58,7 @@ const routes: RouteRecordRaw[] = [
meta: { title: '搜索' },
},
{
path: '/chat/:channel/:ownerId/:talkerId',
path: '/chat/:channel/:ownerId/:talkerId?',
name: 'chat',
component: () => import('@/views/ChatTimeline.vue'),
props: true,
@@ -67,7 +67,7 @@ const routes: RouteRecordRaw[] = [
{
// Group chat timeline — same component, but with a `roomId` prop so it
// issues a room_id query instead of a talker_id query.
path: '/group/:channel/:ownerId/:roomId',
path: '/group/:channel/:ownerId/:roomId?',
name: 'group-chat',
component: () => import('@/views/ChatTimeline.vue'),
props: true,
+121 -48
View File
@@ -6,6 +6,7 @@ import { ArrowLeft } from '@element-plus/icons-vue'
import { getMessages } from '@/api'
import type { Channel, Message } from '@/api/types'
import MessageBubble from '@/components/MessageBubble.vue'
import ChatSidebar from '@/components/ChatSidebar.vue'
import { channelLabel, formatTime, wechatTypeLabel } from '@/utils/format'
import { buildMessageSections } from '@/utils/messageFields'
@@ -45,13 +46,26 @@ const channelTyped = computed(() => props.channel as Channel)
/** True when this timeline is a group chat (drives the query + title). */
const isGroup = computed(() => !!props.roomId)
/** 当前会话类型(高亮 sidebar + 驱动空态)。账号级入口(无 talkerId/roomId)→ null。 */
const activeType = computed<'friend' | 'group' | null>(() =>
isGroup.value ? 'group' : props.talkerId ? 'friend' : null,
)
/** 当前会话 id(friend→talker_id, group→room_id)。账号级入口→ null。 */
const activeId = computed<string | null>(() => props.talkerId ?? props.roomId ?? null)
/** 是否已选定具体会话(决定是否拉消息 / 显示空态)。 */
const hasActive = computed(() => !!activeId.value)
const hasMoreOlder = computed(() => currentPage.value > 1)
const conversationTitle = computed(() => {
if (isGroup.value) {
return `群聊记录 · ${props.roomId}`
}
return `与 ${props.talkerId} 的聊天记录`
if (props.talkerId) {
return `与 ${props.talkerId} 的聊天记录`
}
// 账号级入口(无具体会话):仅展示渠道级标题,避免出现 "与 undefined 的聊天记录"。
return '聊天记录'
})
/** 从路由 query 读日期窗(从 StaffList stat modal 带窗跳来时有值);缺失=全量(向后兼容)。 */
@@ -105,6 +119,14 @@ function queryFor(pageNum: number) {
* returns asc, so the last page holds the newest slice.
*/
async function loadInitial(): Promise<void> {
// 账号级入口(无具体会话):不拉消息,清空状态,留空态给右侧主区。
if (!hasActive.value) {
messages.value = []
total.value = 0
totalPages.value = 1
currentPage.value = 1
return
}
loading.value = true
errorMsg.value = null
try {
@@ -161,6 +183,25 @@ function goBack(): void {
}
}
/** 点击左侧会话 → 推到对应 DM/群路由(带具体会话 id)。 */
function onSelectSession(s: {
type: 'friend' | 'group'
talker_id?: string | null
room_id?: string | null
}): void {
if (s.type === 'group') {
router.push({
name: 'group-chat',
params: { channel: props.channel, ownerId: props.ownerId, roomId: s.room_id! },
})
} else {
router.push({
name: 'chat',
params: { channel: props.channel, ownerId: props.ownerId, talkerId: s.talker_id! },
})
}
}
// All-fields drawer state for the message detail view.
const drawerOpen = ref(false)
const drawerMessage = ref<Message | null>(null)
@@ -195,54 +236,67 @@ onMounted(loadInitial)
<template>
<div class="yk-chat">
<div class="yk-chat__header">
<div>
<div style="display: flex; align-items: center; gap: 8px">
<el-button text size="small" @click="goBack">
<el-icon><ArrowLeft /></el-icon>
返回
</el-button>
<h2 class="yk-chat__title">{{ conversationTitle }}</h2>
</div>
<div class="yk-chat__sub">{{ subtitle }}</div>
</div>
</div>
<el-alert
v-if="errorMsg"
:title="`聊天记录加载失败:${errorMsg}`"
type="error"
show-icon
:closable="false"
style="margin-bottom: 0"
/>
<div v-loading="loading" class="yk-chat__stream" ref="streamRef">
<div class="yk-chat__load">
<el-button
v-if="hasMoreOlder"
size="small"
plain
:loading="loadingOlder"
@click="loadOlder"
>
加载更多 / 上一页
</el-button>
<span v-else-if="!loading && messages.length > 0" class="yk-chat__end">
—— 已加载全部历史 ——
</span>
</div>
<template v-if="messages.length === 0 && !loading">
<div class="yk-empty">暂无聊天记录</div>
</template>
<MessageBubble
v-for="m in messages"
:key="m.msg_id"
:message="m"
@open-detail="openDetail"
<div class="yk-chat__body">
<ChatSidebar
:channel="channelTyped"
:owner-id="props.ownerId"
:active-type="activeType"
:active-id="activeId"
@select="onSelectSession"
/>
<div class="yk-chat__main">
<div class="yk-chat__header">
<div>
<div style="display: flex; align-items: center; gap: 8px">
<el-button text size="small" @click="goBack">
<el-icon><ArrowLeft /></el-icon>
返回
</el-button>
<h2 class="yk-chat__title">{{ conversationTitle }}</h2>
</div>
<div class="yk-chat__sub">{{ subtitle }}</div>
</div>
<!-- Task 4 在此插日期选择器 + 关键词搜索;本 task 留空 -->
</div>
<el-alert
v-if="errorMsg"
:title="`聊天记录加载失败:${errorMsg}`"
type="error"
show-icon
:closable="false"
style="margin-bottom: 0"
/>
<div v-if="!hasActive" class="yk-empty yk-chat__nopick">选择左侧会话查看消息</div>
<div v-else v-loading="loading" class="yk-chat__stream" ref="streamRef">
<div class="yk-chat__load">
<el-button
v-if="hasMoreOlder"
size="small"
plain
:loading="loadingOlder"
@click="loadOlder"
>
加载更多 / 上一页
</el-button>
<span v-else-if="!loading && messages.length > 0" class="yk-chat__end">
—— 已加载全部历史 ——
</span>
</div>
<template v-if="messages.length === 0 && !loading">
<div class="yk-empty">暂无聊天记录</div>
</template>
<MessageBubble
v-for="m in messages"
:key="m.msg_id"
:message="m"
@open-detail="openDetail"
/>
</div>
</div>
</div>
<!-- All-fields drawer for the clicked message -->
@@ -313,6 +367,25 @@ onMounted(loadInitial)
</template>
<style scoped>
/* 双栏布局:左侧会话列表 + 右侧消息主区。高度预留 app header 之下。 */
.yk-chat__body {
display: flex;
height: calc(100vh - 120px);
min-height: 400px;
}
.yk-chat__main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.yk-chat__nopick {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
/* Make the stream its own scroll region so scrollToBottom + load-older scroll
* anchoring target the right element. Height reserves space for the app
* header + chat header + main padding. */
+3 -23
View File
@@ -2,7 +2,7 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getAccounts, getCustomers, getMessages, getStaffChatStats } from '@/api'
import { getAccounts, getCustomers, getStaffChatStats } from '@/api'
import type { Account, Channel, Page, StaffChatCustomer, StaffChatStats } from '@/api/types'
import YkStaffDetailModal from '@/components/YkStaffDetailModal.vue'
import { useChannelStore } from '@/stores/channel'
@@ -250,28 +250,8 @@ function viewGroups(row: Account): void {
}
async function viewChat(row: Account) {
// 取该账号最近一条会话,跳 ChatTimeline(降级:云客是会话列表,我们跳最近一条)
const res = await getMessages({
channel: row.channel,
owner_account_id: row.account_id,
page: 1,
page_size: 1,
})
const m = res.items[0]
if (!m) {
ElMessage.info('该账号暂无聊天记录')
return
}
const target = m.room_id ?? m.talker_id
if (!target) {
ElMessage.info('最近一条消息无会话标识')
return
}
await router.push(
m.room_id
? `/group/${row.channel}/${row.account_id}/${m.room_id}`
: `/chat/${row.channel}/${row.account_id}/${m.talker_id}`,
)
// 账号级聊天窗口(左侧会话列表 + 右消息流);对齐云客 badge→聊天窗口。
await router.push({ name: 'chat', params: { channel: row.channel, ownerId: row.account_id } })
}
// ---------------------------------------------------------------------------