feat(chat): ChatTimeline 双栏化(左会话列表)+ 路由可选参 + badge 账号级入口
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 } })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user