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 // `/` redirects to `/customers`. Chat timeline params are path segments so the
// URL is shareable + survives refresh: // URL is shareable + survives refresh:
// /chat/:channel/:ownerId/:talkerId (1:1 DM) // /chat/:channel/:ownerId/:talkerId? (1:1 DM; talkerId optional → account-level)
// /group/:channel/:ownerId/:roomId (group chat — same component, room_id) // /group/:channel/:ownerId/:roomId? (group chat — same component, room_id)
const routes: RouteRecordRaw[] = [ const routes: RouteRecordRaw[] = [
{ {
path: '/', path: '/',
@@ -58,7 +58,7 @@ const routes: RouteRecordRaw[] = [
meta: { title: '搜索' }, meta: { title: '搜索' },
}, },
{ {
path: '/chat/:channel/:ownerId/:talkerId', path: '/chat/:channel/:ownerId/:talkerId?',
name: 'chat', name: 'chat',
component: () => import('@/views/ChatTimeline.vue'), component: () => import('@/views/ChatTimeline.vue'),
props: true, props: true,
@@ -67,7 +67,7 @@ const routes: RouteRecordRaw[] = [
{ {
// Group chat timeline — same component, but with a `roomId` prop so it // Group chat timeline — same component, but with a `roomId` prop so it
// issues a room_id query instead of a talker_id query. // issues a room_id query instead of a talker_id query.
path: '/group/:channel/:ownerId/:roomId', path: '/group/:channel/:ownerId/:roomId?',
name: 'group-chat', name: 'group-chat',
component: () => import('@/views/ChatTimeline.vue'), component: () => import('@/views/ChatTimeline.vue'),
props: true, props: true,
+74 -1
View File
@@ -6,6 +6,7 @@ import { ArrowLeft } from '@element-plus/icons-vue'
import { getMessages } from '@/api' import { getMessages } from '@/api'
import type { Channel, Message } from '@/api/types' import type { Channel, Message } from '@/api/types'
import MessageBubble from '@/components/MessageBubble.vue' import MessageBubble from '@/components/MessageBubble.vue'
import ChatSidebar from '@/components/ChatSidebar.vue'
import { channelLabel, formatTime, wechatTypeLabel } from '@/utils/format' import { channelLabel, formatTime, wechatTypeLabel } from '@/utils/format'
import { buildMessageSections } from '@/utils/messageFields' 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). */ /** True when this timeline is a group chat (drives the query + title). */
const isGroup = computed(() => !!props.roomId) 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 hasMoreOlder = computed(() => currentPage.value > 1)
const conversationTitle = computed(() => { const conversationTitle = computed(() => {
if (isGroup.value) { if (isGroup.value) {
return `群聊记录 · ${props.roomId}` return `群聊记录 · ${props.roomId}`
} }
if (props.talkerId) {
return `与 ${props.talkerId} 的聊天记录` return `与 ${props.talkerId} 的聊天记录`
}
// 账号级入口(无具体会话):仅展示渠道级标题,避免出现 "与 undefined 的聊天记录"。
return '聊天记录'
}) })
/** 从路由 query 读日期窗(从 StaffList stat modal 带窗跳来时有值);缺失=全量(向后兼容)。 */ /** 从路由 query 读日期窗(从 StaffList stat modal 带窗跳来时有值);缺失=全量(向后兼容)。 */
@@ -105,6 +119,14 @@ function queryFor(pageNum: number) {
* returns asc, so the last page holds the newest slice. * returns asc, so the last page holds the newest slice.
*/ */
async function loadInitial(): Promise<void> { async function loadInitial(): Promise<void> {
// 账号级入口(无具体会话):不拉消息,清空状态,留空态给右侧主区。
if (!hasActive.value) {
messages.value = []
total.value = 0
totalPages.value = 1
currentPage.value = 1
return
}
loading.value = true loading.value = true
errorMsg.value = null errorMsg.value = null
try { 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. // All-fields drawer state for the message detail view.
const drawerOpen = ref(false) const drawerOpen = ref(false)
const drawerMessage = ref<Message | null>(null) const drawerMessage = ref<Message | null>(null)
@@ -195,6 +236,15 @@ onMounted(loadInitial)
<template> <template>
<div class="yk-chat"> <div class="yk-chat">
<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 class="yk-chat__header">
<div> <div>
<div style="display: flex; align-items: center; gap: 8px"> <div style="display: flex; align-items: center; gap: 8px">
@@ -206,6 +256,7 @@ onMounted(loadInitial)
</div> </div>
<div class="yk-chat__sub">{{ subtitle }}</div> <div class="yk-chat__sub">{{ subtitle }}</div>
</div> </div>
<!-- Task 4 在此插日期选择器 + 关键词搜索;本 task 留空 -->
</div> </div>
<el-alert <el-alert
@@ -217,7 +268,8 @@ onMounted(loadInitial)
style="margin-bottom: 0" style="margin-bottom: 0"
/> />
<div v-loading="loading" class="yk-chat__stream" ref="streamRef"> <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"> <div class="yk-chat__load">
<el-button <el-button
v-if="hasMoreOlder" v-if="hasMoreOlder"
@@ -244,6 +296,8 @@ onMounted(loadInitial)
@open-detail="openDetail" @open-detail="openDetail"
/> />
</div> </div>
</div>
</div>
<!-- All-fields drawer for the clicked message --> <!-- All-fields drawer for the clicked message -->
<el-drawer <el-drawer
@@ -313,6 +367,25 @@ onMounted(loadInitial)
</template> </template>
<style scoped> <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 /* Make the stream its own scroll region so scrollToBottom + load-older scroll
* anchoring target the right element. Height reserves space for the app * anchoring target the right element. Height reserves space for the app
* header + chat header + main padding. */ * header + chat header + main padding. */
+3 -23
View File
@@ -2,7 +2,7 @@
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' 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 type { Account, Channel, Page, StaffChatCustomer, StaffChatStats } from '@/api/types'
import YkStaffDetailModal from '@/components/YkStaffDetailModal.vue' import YkStaffDetailModal from '@/components/YkStaffDetailModal.vue'
import { useChannelStore } from '@/stores/channel' import { useChannelStore } from '@/stores/channel'
@@ -250,28 +250,8 @@ function viewGroups(row: Account): void {
} }
async function viewChat(row: Account) { async function viewChat(row: Account) {
// 取该账号最近一条会话,跳 ChatTimeline(降级:云客是会话列表,我们跳最近一条) // 账号级聊天窗口(左侧会话列表 + 右消息流);对齐云客 badge→聊天窗口。
const res = await getMessages({ await router.push({ name: 'chat', params: { channel: row.channel, ownerId: row.account_id } })
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}`,
)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------