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
|
// `/` 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,
|
||||||
|
|||||||
@@ -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}`
|
||||||
}
|
}
|
||||||
return `与 ${props.talkerId} 的聊天记录`
|
if (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,54 +236,67 @@ onMounted(loadInitial)
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="yk-chat">
|
<div class="yk-chat">
|
||||||
<div class="yk-chat__header">
|
<div class="yk-chat__body">
|
||||||
<div>
|
<ChatSidebar
|
||||||
<div style="display: flex; align-items: center; gap: 8px">
|
:channel="channelTyped"
|
||||||
<el-button text size="small" @click="goBack">
|
:owner-id="props.ownerId"
|
||||||
<el-icon><ArrowLeft /></el-icon>
|
:active-type="activeType"
|
||||||
返回
|
:active-id="activeId"
|
||||||
</el-button>
|
@select="onSelectSession"
|
||||||
<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__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>
|
</div>
|
||||||
|
|
||||||
<!-- All-fields drawer for the clicked message -->
|
<!-- All-fields drawer for the clicked message -->
|
||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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}`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user