feat(staffchat): 消息右列(近14天默认窗+9类类型过滤+msgid定位+导出,复制改造自ChatTimeline)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
浅念
2026-08-25 18:10:30 +08:00
parent 98fb4b8248
commit 3aaf4ec8fa
2 changed files with 659 additions and 7 deletions
+655 -4
View File
@@ -1,13 +1,664 @@
<!-- 占位组件: Task 11 实现聊天窗口(消息流+定位 msgid),签名锁定如下。 -->
<!-- 员工聊天窗口右列消息流(对齐云客 wechat-window 聊天区): 复制改造自
ChatTimeline.vue 的主区(尾页落地分页/气泡/详情 drawer/导出全套),差异——
① 默认近 14 天窗(清空=全部时间) ② 9 类类型下拉(msg_type 服务端过滤)
③ ?msgid= 定位滚动+高亮 ④ 无返回按钮/无 sidebar(左侧由 StaffChatSide+
SessionPane 承担)。
根元素必须保持单 div——父组件 StaffChat 靠 attrs fallthrough 挂 sc-root__chat 列宽。 -->
<script setup lang="ts">
defineProps<{
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getCustomers, getGroups, getMessages } from '@/api'
import { downloadBlob } from '@/api/client'
import type { Channel, Message } from '@/api/types'
import MessageBubble from '@/components/MessageBubble.vue'
import { channelLabel, formatTime, wechatTypeLabel } from '@/utils/format'
import { buildMessageSections } from '@/utils/messageFields'
const props = defineProps<{
ownerId: string
/** single=单聊(talkerId=客户 wxid), group=群聊(talkerId=room_id) */
kind: 'single' | 'group'
talkerId: string
talkerId?: string
}>()
const route = useRoute()
/** 员工聊天窗口是微信侧专属视图(路由/左侧栏均硬编码 wechat)。 */
const CHANNEL: Channel = 'wechat'
const PAGE_SIZE = 50
const messages = ref<Message[]>([])
const loading = ref(false)
const loadingOlder = ref(false)
const errorMsg = ref<string | null>(null)
// Asc-ordered pagination: page 1 = oldest 50, last page = most recent 50.
// We land on the last page (most recent activity) and walk backwards to load
// older history. `currentPage` is the smallest page number currently loaded.
const currentPage = ref(1)
const totalPages = ref(1)
const total = ref(0)
/** 首次加载是否完成(msgid 定位只在已加载数据上尝试,避免误报「不在当前页」)。 */
const initialLoaded = ref(false)
const isGroup = computed(() => props.kind === 'group')
/** 是否已选定具体会话(决定是否拉消息 / 显示空态)。账号级入口(talkerId='')→ 空态。 */
const hasActive = computed(() => !!props.talkerId)
const hasMoreOlder = computed(() => currentPage.value > 1)
/**
* 关键词过滤:仅作用于"当前已加载"的消息(纯前端过滤,不触发重载——输入过程中
* 重载会打断滚动位置;且分页游标按无关键词的 total 计算)。要全量历史搜索用
* 中列 SessionPane 的「聊天记录」模式(服务端 q)。
*/
const visibleMessages = computed(() => {
const k = keyword.value.trim()
if (!k) return messages.value
return messages.value.filter((m) => (m.text ?? '').includes(k))
})
/** 当前会话对方的显示名(客户昵称或群名)— fetch 后替换标题中的原始 ID。 */
const talkerName = ref<string | null>(null)
async function loadTalkerName(): Promise<void> {
talkerName.value = null
if (!hasActive.value) return
try {
if (isGroup.value && props.talkerId) {
// 群名(room_id 精确单行直查,不再拉整页群列表)
const res = await getGroups({ channel: CHANNEL, room_id: props.talkerId, page: 1, page_size: 1 })
talkerName.value = res.items[0]?.room_name || null
} else if (props.talkerId) {
// 客户昵称
const res = await getCustomers({
channel: CHANNEL,
owner_account_id: props.ownerId,
friend_id: props.talkerId,
page: 1,
page_size: 1,
})
const c = res.items[0]
talkerName.value = c?.friend_nickname || c?.friend_remark || null
}
} catch {
// 静默降级:显示原始 ID
}
}
const conversationTitle = computed(() => {
if (!hasActive.value) return '聊天记录'
const displayName = talkerName.value || (props.talkerId as string)
return isGroup.value ? `群聊记录 · ${displayName}` : `与 ${displayName} 的聊天记录`
})
// ── 日期窗:云客对齐,默认近 14 天;清空 = 全部时间(null) ─────────────────────
// route.query 合法 ms start/end 仍优先(带日期窗的跳转入口,如红包明细)。
/** 近 14 天窗:今天往前 13 天的 00:00:00 ~ 今天 23:59:59.999。 */
function defaultRange(): [Date, Date] {
const end = new Date()
end.setHours(23, 59, 59, 999)
const start = new Date()
start.setDate(start.getDate() - 13)
start.setHours(0, 0, 0, 0)
return [start, end]
}
function rangeFromQuery(): [Date, Date] | null {
const s = route.query.start,
e = route.query.end
const sv = Array.isArray(s) ? s[0] : s,
ev = Array.isArray(e) ? e[0] : e
if (sv && ev && /^\d+$/.test(String(sv)) && /^\d+$/.test(String(ev))) {
return [new Date(Number(sv)), new Date(Number(ev))]
}
return null
}
const dateRange = ref<[Date, Date] | null>(rangeFromQuery() ?? defaultRange())
const winStart = computed(() => dateRange.value?.[0].getTime())
const winEnd = computed(() => dateRange.value?.[1].getTime())
const keyword = ref('')
// ── 类型下拉(云客 10 类语义 → 9 项) ────────────────────────────────────────
// 云客另有「小程序名片」类,但镜像数据无 mini_program 样本行(Task 4 判别)——
// 并入「链接/小程序」(查询按 link,数据现实)。后端 Literal 共 11 值,前端只用 9。
const MSG_TYPES = [
{ value: 'text', label: '文本' },
{ value: 'image', label: '图片' },
{ value: 'voice', label: '语音消息' },
{ value: 'voice_call', label: '语音通话' },
{ value: 'video_call', label: '视频通话' },
{ value: 'file', label: '文件' },
{ value: 'link', label: '链接/小程序' },
{ value: 'red_packet', label: '红包' },
{ value: 'transfer', label: '转账' },
] as const
/** '' = 全部类型(不传 msg_type)。 */
const msgType = ref('')
const subtitle = computed(() => {
let s = total.value > 0 ? `共 ${total.value} 条` : '暂无消息'
s += winStart.value && winEnd.value
? ` · 时段:${formatTime(winStart.value)} ~ ${formatTime(winEnd.value)}`
: ' · 时段:全部时间'
return s
})
/** 切日期窗/类型 → 重载(服务端过滤改变 total,分页游标需重算)。 */
function onFilterChange(): void {
loadInitial()
}
const streamRef = ref<HTMLElement | null>(null)
function scrollToBottom(): void {
nextTick(() => {
const el = streamRef.value
if (el) el.scrollTop = el.scrollHeight
})
}
/** Shared query params for both DM and group timelines. */
function queryFor(pageNum: number) {
return {
channel: CHANNEL,
// 群聊不带 owner:chat_group 表按 room_id 去重、owner 被最后写入的员工
// 覆盖,与消息实际归属的 owner 错位会查空(实测 2026-08-22);room 全局
// 唯一且消息只在单一 owner 名下(多 owner 群=0),按 room 查即正确。
...(isGroup.value
? { room_id: props.talkerId as string }
: { owner_account_id: props.ownerId, talker_id: props.talkerId as string }),
...(winStart.value != null && winEnd.value != null ? { start: winStart.value, end: winEnd.value } : {}),
...(msgType.value ? { msg_type: msgType.value } : {}),
page: pageNum,
page_size: PAGE_SIZE,
}
}
/**
* Fetch the last page (most recent messages) on entry. Page 1 is fetched first
* purely to learn `total` so we can compute `lastPage` — the messages API
* returns asc, so the last page holds the newest slice.
*/
async function loadInitial(): Promise<void> {
highlightId.value = null
// 账号级入口(无具体会话):不拉消息,清空状态,留空态给右列。
if (!hasActive.value) {
messages.value = []
total.value = 0
totalPages.value = 1
currentPage.value = 1
pendingMsgid.value = null
initialLoaded.value = true
return
}
loading.value = true
errorMsg.value = null
try {
const first = await getMessages(queryFor(1))
total.value = first.total
totalPages.value = Math.max(1, Math.ceil(first.total / PAGE_SIZE))
if (totalPages.value <= 1) {
messages.value = first.items
} else {
const last = await getMessages(queryFor(totalPages.value))
messages.value = last.items
}
currentPage.value = totalPages.value
scrollToBottom()
} catch (e) {
errorMsg.value = (e as Error).message
messages.value = []
} finally {
loading.value = false
initialLoaded.value = true
}
// DOM 渲染完再尝试 msgid 定位(覆盖 scrollToBottom 的置底)。
nextTick(() => tryLocate())
}
/** Prepend the next older page (currentPage - 1). No-op on page 1. */
async function loadOlder(): Promise<void> {
if (loadingOlder.value || !hasMoreOlder.value) return
loadingOlder.value = true
const prevHeight = streamRef.value?.scrollHeight ?? 0
try {
const prevPage = currentPage.value - 1
const res = await getMessages(queryFor(prevPage))
// Backend returns asc, so res.items are older than everything currently
// shown — prepend them.
messages.value = [...res.items, ...messages.value]
currentPage.value = prevPage
// Keep the user's scroll anchored near where they were.
nextTick(() => {
const el = streamRef.value
if (el) el.scrollTop = el.scrollHeight - prevHeight
// 「加载更多」自动重试 msgid 定位(目标可能在更早页)。
tryLocate()
})
} catch (e) {
ElMessage.error(`加载更多失败:${(e as Error).message}`)
} finally {
loadingOlder.value = false
}
}
/** 导出当前会话/时段聊天记录为自包含 HTML(后端渲染气泡 + 附件内联)。
注: /export/chat-html 不支持 msg_type——类型过滤不作用于导出。 */
async function exportCurrent(): Promise<void> {
if (!hasActive.value) return
try {
await downloadBlob('/export/chat-html', {
channel: CHANNEL,
owner_account_id: props.ownerId,
talker_id: isGroup.value ? undefined : props.talkerId,
room_id: isGroup.value ? props.talkerId : undefined,
start: winStart.value,
end: winEnd.value,
})
} catch (e) {
ElMessage.error(`导出失败:${(e as Error).message}`)
}
}
// ── msgid 定位(?msgid=,红包转账明细跳转入口) ──────────────────────────────
// 消息加载完成后在"当前已加载"消息里找 msg_id:找到→平滑滚动+高亮 2s;
// 没找到→提示点「加载更多」后重试(loadOlder 会自动重试,见上)。换会话时
// StaffChat.onSelectSession 丢弃 msgid,定位意图随会话失效。
const pendingMsgid = ref<string | null>(null)
const highlightId = ref<string | null>(null)
let locateTimer: number | undefined
function currentMsgid(): string | null {
const m = route.query.msgid
const v = Array.isArray(m) ? m[0] : m
return v ? String(v) : null
}
function tryLocate(): void {
const id = pendingMsgid.value
if (!id) return
// 初次加载中/未完成时不尝试(避免空列表误报「不在当前页」)。
if (loading.value || !initialLoaded.value) return
const el = streamRef.value?.querySelector<HTMLElement>(
`[data-msg-id="${CSS.escape(id)}"]`,
)
if (el) {
pendingMsgid.value = null
highlightId.value = id
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
window.clearTimeout(locateTimer)
locateTimer = window.setTimeout(() => {
highlightId.value = null
}, 2000)
} else {
ElMessage.info('定位消息不在当前页,可点击「加载更多」后重试')
}
}
// Reload when the route params change (navigating between conversations).
// dateRange/msgType 变化走 onFilterChange,不在此 watcher 内。
watch(
() => [props.ownerId, props.kind, props.talkerId] as const,
() => {
loadInitial()
loadTalkerName()
},
)
// msgid 只换 query 不换组件:同会话内跳转(如红包明细→本窗)也要响应。
watch(
() => route.query.msgid,
() => {
pendingMsgid.value = currentMsgid()
if (!loading.value && initialLoaded.value) tryLocate()
},
{ immediate: true },
)
onMounted(() => {
loadInitial()
loadTalkerName()
})
onBeforeUnmount(() => {
window.clearTimeout(locateTimer)
})
// All-fields drawer state for the message detail view.
const drawerOpen = ref(false)
const drawerMessage = ref<Message | null>(null)
function openDetail(m: Message): void {
drawerMessage.value = m
drawerOpen.value = true
}
const drawerSections = computed(() =>
drawerMessage.value ? buildMessageSections(drawerMessage.value) : [],
)
const drawerTitle = computed(() => {
const m = drawerMessage.value
if (!m) return '消息详情'
const when = m.timestamp ? formatTime(m.timestamp) : ''
return `消息详情${when ? ` · ${when}` : ''}`
})
</script>
<template>
<div class="yk-empty">聊天窗口 — Task 11 实现</div>
<div class="sc-chat yk-panel-bg yk-panel-sep">
<!-- ── 标题行 ── -->
<div class="sc-chat__head">
<h2 class="sc-chat__title">{{ conversationTitle }}</h2>
<div v-if="hasActive" class="sc-chat__sub">{{ subtitle }}</div>
</div>
<!-- ── 筛选行: 日期窗 / 关键词 / 类型下拉 / 导出 ── -->
<div class="sc-chat__filters">
<el-date-picker
v-model="dateRange"
type="daterange"
size="small"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
clearable
@change="onFilterChange"
/>
<el-input
v-model="keyword"
size="small"
placeholder="搜索当前已加载消息"
clearable
class="sc-chat__keyword"
/>
<!-- value='' 时 EP 走 placeholder 分支而非 option 回显,须显式给 placeholder 才显示「全部类型」 -->
<el-select
v-model="msgType"
size="small"
placeholder="全部类型"
class="sc-chat__type"
@change="onFilterChange"
>
<el-option label="全部类型" value="" />
<el-option v-for="t in MSG_TYPES" :key="t.value" :label="t.label" :value="t.value" />
</el-select>
<el-button
v-if="hasActive"
size="small"
type="primary"
plain
class="sc-chat__export"
@click="exportCurrent"
>导出</el-button>
</div>
<el-alert
v-if="errorMsg"
:title="`聊天记录加载失败:${errorMsg}`"
type="error"
show-icon
:closable="false"
/>
<div v-if="!hasActive" class="yk-empty sc-chat__nopick">选择左侧会话查看消息</div>
<div v-else v-loading="loading" class="sc-chat__stream" ref="streamRef">
<div class="sc-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="sc-chat__end">
—— 已加载全部历史 ——
</span>
</div>
<template v-if="messages.length === 0 && !loading">
<div class="yk-empty">暂无聊天记录</div>
</template>
<template v-else-if="visibleMessages.length === 0 && keyword && !loading">
<div class="yk-empty">当前已加载消息中无匹配</div>
</template>
<div
v-for="m in visibleMessages"
:key="m.msg_id"
class="sc-chat__msg"
:class="{ 'sc-chat__msg--locate': highlightId === m.msg_id }"
:data-msg-id="m.msg_id"
>
<MessageBubble :message="m" @open-detail="openDetail" />
</div>
</div>
<!-- All-fields drawer for the clicked message -->
<el-drawer
v-model="drawerOpen"
:title="drawerTitle"
direction="rtl"
size="560px"
>
<template v-if="drawerMessage">
<div class="yk-drawer__topmeta">
<el-tag size="small" effect="plain">
{{ channelLabel(CHANNEL) }}
</el-tag>
<el-tag
v-if="drawerMessage.type || drawerMessage.type_raw != null"
size="small"
type="info"
effect="plain"
>
{{ drawerMessage.channel === 'wechat' ? wechatTypeLabel(drawerMessage.type_raw) : (drawerMessage.type || '消息') }}
<template v-if="drawerMessage.type_raw != null">
({{ drawerMessage.type_raw }})
</template>
</el-tag>
<el-tag
v-if="drawerMessage.is_send != null"
size="small"
:type="drawerMessage.is_send ? 'success' : 'warning'"
effect="plain"
>
{{ drawerMessage.is_send ? '发出' : '接收' }}
</el-tag>
</div>
<div
v-for="section in drawerSections"
:key="section.title"
class="yk-drawer__section"
>
<el-divider content-position="left">{{ section.title }}</el-divider>
<dl class="yk-drawer__kv">
<div
v-for="f in section.fields"
:key="f.key"
class="yk-drawer__kv-item"
>
<dt>{{ f.label }}</dt>
<dd v-if="f.render === 'image'" class="yk-drawer__img">
<el-image
:src="String(f.value)"
:preview-src-list="[String(f.value)]"
fit="cover"
hide-on-click-modal
/>
</dd>
<dd v-else-if="f.render === 'empty'" class="yk-drawer__empty">—</dd>
<dd v-else-if="f.render === 'object'" class="yk-drawer__pre">
<pre>{{ f.text }}</pre>
</dd>
<dd v-else>{{ f.text }}</dd>
</div>
</dl>
</div>
</template>
</el-drawer>
</div>
</template>
<style scoped>
/* ══ 员工聊天右列(墨玉主题,手法沿用 SessionPane) ══
点阵底/右缘玉绿线走全局 yk-panel-bg/yk-panel-sep(style.css);这里只写布局。
高度由父级 .sc-root__chat(flex:1)决定,自身 height:100% 纵向排:
标题行 → 筛选行 → 消息流(flex:1 滚动)。 */
.sc-chat {
display: flex;
flex-direction: column;
height: 100%;
min-width: 0;
}
/* ── 标题行 ── */
.sc-chat__head {
padding: 14px 16px 10px;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(8px);
border-bottom: 1px solid rgba(23, 29, 43, 0.05);
}
.sc-chat__title {
margin: 0;
font-size: 15px;
font-weight: 600;
color: #2b3240;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sc-chat__sub {
margin-top: 3px;
font-size: 12px;
color: #8a94a4;
}
/* ── 筛选行(可换行,右列宽度有限) ── */
.sc-chat__filters {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
padding: 10px 16px;
background: rgba(255, 255, 255, 0.7);
border-bottom: 1px solid rgba(23, 29, 43, 0.05);
}
.sc-chat__keyword {
flex: 1;
min-width: 130px;
max-width: 220px;
}
.sc-chat__type {
flex: none;
width: 122px;
}
.sc-chat__export {
margin-left: auto;
}
/* ── 空态 ── */
.sc-chat__nopick {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
/* ── 消息流:自身滚动区(scrollToBottom / 上一页锚定 / msgid 定位都指向它) ── */
.sc-chat__stream {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 8px 16px 16px;
}
.sc-chat__load {
display: flex;
justify-content: center;
padding: 4px 0 10px;
}
.sc-chat__end {
font-size: 12px;
color: #8a94a4;
}
/* ── msgid 定位高亮:玉绿描边 2s 后淡出 ── */
.sc-chat__msg {
border-radius: 10px;
transition: background 0.35s ease, box-shadow 0.35s ease;
}
.sc-chat__msg--locate {
background: rgba(13, 159, 110, 0.08);
box-shadow: 0 0 0 1.5px rgba(13, 159, 110, 0.45);
}
/* ── 消息详情 drawer(样式沿用 ChatTimeline) ── */
.yk-drawer__topmeta {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.yk-drawer__kv {
margin: 0 0 8px;
}
.yk-drawer__kv-item {
display: grid;
grid-template-columns: 110px 1fr;
gap: 8px 12px;
padding: 3px 0;
}
.yk-drawer__kv dt {
color: #909399;
font-size: 13px;
word-break: break-all;
}
.yk-drawer__kv dd {
margin: 0;
font-size: 13px;
color: #303133;
word-break: break-all;
min-width: 0;
}
.yk-drawer__img :deep(.el-image) {
width: 64px;
height: 64px;
border-radius: 4px;
border: 1px solid var(--el-border-color-light, #e4e7ed);
}
.yk-drawer__empty {
color: #c0c4cc;
}
.yk-drawer__pre {
margin: 0;
min-width: 0;
}
.yk-drawer__pre pre {
margin: 0;
padding: 8px;
background: var(--el-fill-color-light, #f5f7fa);
border-radius: 4px;
font-size: 12px;
color: #303133;
white-space: pre-wrap;
word-break: break-all;
max-height: 240px;
overflow: auto;
}
</style>
+4 -3
View File
@@ -2,7 +2,7 @@
各 pane 由 Task 10-14 逐个替换占位实现。 -->
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRouter } from 'vue-router'
import { getAccounts } from '@/api'
import type { Account } from '@/api/types'
import StaffChatSide, { TAB_KEYS, type TabKey } from '@/components/staffchat/StaffChatSide.vue'
@@ -13,7 +13,6 @@ import ContactsPane from '@/components/staffchat/ContactsPane.vue'
import StatsPane from '@/components/staffchat/StatsPane.vue'
const props = defineProps<{ ownerId: string; tab?: string; talkerId?: string }>()
const route = useRoute()
const router = useRouter()
// TabKey/TAB_KEYS 从 StaffChatSide 导出(NAVS 单一事实源);VALID 仅做运行时兜底
const VALID: readonly TabKey[] = TAB_KEYS
@@ -48,7 +47,9 @@ function onSelectSession(s: { type: 'friend' | 'group'; talker_id?: string | nul
tab: s.type === 'group' ? 'group' : 'single',
talkerId: s.talker_id ?? s.room_id ?? '',
},
query: route.query.msgid ? { msgid: String(route.query.msgid) } : {},
// 用户主动换会话 → 丢弃 ?msgid 定位意图(定位只对跳转来源的会话有意义,
// 带到新会话必然找不到目标,反而误弹「不在当前页」提示)
query: {},
})
}