feat(staffchat): 消息右列(近14天默认窗+9类类型过滤+msgid定位+导出,复制改造自ChatTimeline)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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: {},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user