Files
alibb-server/frontend/src/components/ChatList.vue
T
2026-08-27 16:11:49 +08:00

509 lines
14 KiB
Vue

<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, nextTick, ref } from 'vue'
import { ElMessage } from 'element-plus'
import axios from 'axios'
import { listMessages, type ChatMessage, type BeforeCursor } from '@/api/inquiry'
import { extractApiErrorMessage, formatTime } from '@/utils/format'
import { mergeMessages } from '@/utils/messages'
/**
* 微信式聊天列表(Task 5 重构)。
*
* 后端默认返回最新一页(asc 旧→新),本组件:
* - 打开 → 滚到底部显示最新
* - 顶部「加载更早的消息」用 prevCursor 向上翻更旧的历史,prepend 到顶部
* - prepend 后保持视觉滚动位置(跳到「原第一条」的位置,避免视角跳到顶部)
*
* DynamicScroller(vue-virtual-scroller v3)自动测量可变高度(聊天 content 是
* MEDIUMTEXT 可能很长)。`scrollToBottom`/`scrollToItem` 通过 ref 调用。
*
* Race / unmount safety:每次 fetch 用新 AbortController;新 fetch 或 unmount
* 中止进行中的。中止 rejection 被吞掉(不 toast、不清 loading)。
*/
const props = defineProps<{
inquiryId: string
}>()
/** 每页条数(匹配后端约定)。 */
const PAGE_SIZE = 50
const messages = ref<ChatMessage[]>([])
/**
* 向上加载更旧的游标(指向已加载最旧的一条);`null` 表示已到顶,隐藏「加载更早」。
*/
const prevCursor = ref<BeforeCursor | null>(null)
const loading = ref(false) // 首屏加载 spinner(v-loading)
const loadingMore = ref(false) // 向上加载 spinner(顶部按钮)
/** 已到顶(无更旧历史)。 */
const allLoaded = computed(() => prevCursor.value === null)
/** 当前进行中 fetch 的 AbortController。 */
let activeController: AbortController | null = null
/** DynamicScroller 实例 ref,用于 scrollToBottom / scrollToItem。 */
const scroller = ref<{ scrollToBottom?: () => void; scrollToItem?: (i: number) => void } | null>(null)
/**
* 拉一页并合并。首屏(无 cursor)加载后滚到底部;向上翻(prepend)后保持视角。
*
* @param cursor 向上游标;省略为首屏(最新一页)。
*/
async function fetchPage(cursor?: BeforeCursor) {
activeController?.abort()
const controller = new AbortController()
activeController = controller
const isFirst = !cursor
const prevCount = messages.value.length // prepend 后用来恢复 scroll 位置
if (isFirst) loading.value = true
else loadingMore.value = true
try {
const res = await listMessages(
props.inquiryId,
cursor,
PAGE_SIZE,
controller.signal,
)
// mergeMessages dedup by messageId + sort asc,prepend 更旧后仍是旧→新顺序
messages.value = mergeMessages(messages.value, res.items)
prevCursor.value = res.prevCursor
await nextTick()
if (isFirst) {
// 首屏:滚到底部,显示最新一条
scroller.value?.scrollToBottom?.()
} else {
// 向上加载更旧(prepend):跳到「原第一条」的新位置 = 新增条数,保持视角
const added = messages.value.length - prevCount
if (added > 0) scroller.value?.scrollToItem?.(added)
}
} catch (e) {
// 中止(新 fetch / unmount)—— 预期,静默。必须在 toast/state 之前判断。
if (axios.isCancel(e)) return
const message = extractApiErrorMessage(
e,
isFirst
? '加载聊天记录失败,请稍后重试'
: '加载更多失败,请稍后重试',
)
ElMessage.error({ message, grouping: true })
} finally {
if (activeController === controller) {
loading.value = false
loadingMore.value = false
activeController = null
}
}
}
/** 顶部「加载更早」按钮 → 用 prevCursor 拉更旧的一页。 */
function loadMore() {
if (loadingMore.value || allLoaded.value) return
const cursor = prevCursor.value
if (!cursor) return
void fetchPage(cursor)
}
/**
* 气泡下说明:`sender · time`(sender 空时省略)。
*/
function caption(m: ChatMessage): string {
const t = formatTime(m.sendTime)
const sender = m.sender?.trim()
return sender ? `${sender} · ${t}` : t
}
/** 允许渲染 <img> 的图片域名白名单(防 XSS:只信任 alibaba 官方 CDN)。 */
const IMG_HOSTS = ['atmgateway-client.alibaba.com', 'alicdn.com', 'alibaba.com']
/** content 以 img: 前缀开头的是图片/表情消息(扩展 apiMsgToChat 提取的 src)。 */
function isImgMsg(c: string | null): boolean {
return typeof c === 'string' && c.startsWith('img:')
}
/** 从 img:url 提取 src 并校验白名单;非白名单返回空串(不渲染,防 XSS)。 */
function imgSrc(c: string): string {
let src = c.slice(4)
if (src.startsWith('//')) src = 'https:' + src
try {
const u = new URL(src)
if (!IMG_HOSTS.some(h => u.hostname === h || u.hostname.endsWith('.' + h))) return ''
return src
} catch {
return ''
}
}
/** content 以 file: 开头的是文件卡片消息(PDF/Word/视频等),格式 `file:名称|url`。 */
function isFileMsg(c: string | null): boolean {
return typeof c === 'string' && c.startsWith('file:')
}
/** 从 file:名称|url 提取名称 + 校验白名单后的 URL(非白名单 url 置空,不渲染链接防 XSS)。 */
function fileMeta(c: string): { name: string; url: string } {
const rest = c.slice(5)
const idx = rest.indexOf('|')
const name = idx >= 0 ? rest.slice(0, idx) : '文件'
let url = idx >= 0 ? rest.slice(idx + 1) : ''
if (url.startsWith('//')) url = 'https:' + url
try {
const u = new URL(url)
if (!IMG_HOSTS.some(h => u.hostname === h || u.hostname.endsWith('.' + h))) return { name, url: '' }
} catch {
return { name, url: '' }
}
return { name, url }
}
/** content 以 video: 开头的是视频消息,格式 video:url。 */
function isVideoMsg(c: string | null): boolean {
return typeof c === 'string' && c.startsWith('video:')
}
/** 从 video:url 提取 src + 校验白名单(同 img);非白名单返回空。 */
function videoSrc(c: string): string {
let src = c.slice(6)
if (src.startsWith('//')) src = 'https:' + src
try {
const u = new URL(src)
if (!IMG_HOSTS.some(h => u.hostname === h || u.hostname.endsWith('.' + h))) return ''
return src
} catch {
return ''
}
}
/** content 本身就是纯 http(s) URL(非 img:/file:/video: 前缀)→ 渲染成外链。 */
function isUrlMsg(c: string | null): boolean {
if (typeof c !== 'string') return false
return /^https?:\/\/\S+$/i.test(c)
}
function urlHref(c: string): string {
// 只允许 http/https(挡 javascript:/data: 等 XSS scheme)
return /^https?:\/\//i.test(c) ? c : ''
}
onMounted(() => {
void fetchPage()
})
onBeforeUnmount(() => {
activeController?.abort()
})
</script>
<template>
<el-card class="chat-card" shadow="never">
<template #header>
<div class="chat-header">
<span class="card-title">聊天记录</span>
<span class="chat-count">{{ messages.length }} 条</span>
</div>
</template>
<div v-loading="loading" class="chat-body">
<!--
DynamicScroller (vue-virtual-scroller v3) 自动测量每项高度,可变气泡
(长 MEDIUMTEXT)正确渲染。`keyField="messageId"` 匹配 ChatMessage 唯一 id。
`min-item-size` 是首次测量前的悲观下限(px),提升首屏 scroll 稳定性。
方向:items 升序(顶旧底新),ref 用于 scrollToBottom(首屏到底)+
scrollToItem(prepend 后保持视角)。顶部 #before slot 放「加载更早」。
-->
<DynamicScroller
v-if="messages.length > 0"
ref="scroller"
:items="messages"
:min-item-size="60"
key-field="messageId"
class="scroller"
>
<template #before>
<div class="load-more-row">
<el-button
v-if="!allLoaded"
size="small"
:loading="loadingMore"
:disabled="loadingMore"
@click="loadMore"
>
加载更早的消息
</el-button>
<span v-else class="load-more-end">已到顶</span>
</div>
</template>
<template #default="{ item, index, active }">
<DynamicScrollerItem
:item="item"
:active="active"
:data-index="index"
>
<!--
XSS 注意:content 是买家输入文本。只用 {{ }} 插值,绝不用 v-html。
-->
<div
v-if="item.direction === 'system'"
class="system-row"
>
<span class="system-text">{{ item.content }}</span>
<span class="system-time">{{ formatTime(item.sendTime) }}</span>
</div>
<div
v-else
class="bubble-row"
:class="item.direction === 'outgoing' ? 'is-outgoing' : 'is-incoming'"
>
<div class="bubble">
<el-image
v-if="isImgMsg(item.content)"
:src="imgSrc(item.content)"
:preview-src-list="[imgSrc(item.content)]"
preview-teleported
fit="cover"
hide-on-click-modal
class="bubble-image"
/>
<a
v-else-if="isFileMsg(item.content)"
:href="fileMeta(item.content).url || undefined"
target="_blank"
rel="noopener noreferrer"
class="bubble-file"
:class="{ 'is-link': !!fileMeta(item.content).url }"
>📎 {{ fileMeta(item.content).name }}</a>
<video
v-else-if="isVideoMsg(item.content) && videoSrc(item.content)"
:src="videoSrc(item.content)"
controls
preload="metadata"
class="bubble-video"
></video>
<a
v-else-if="isUrlMsg(item.content) && urlHref(item.content)"
:href="urlHref(item.content)"
target="_blank"
rel="noopener noreferrer"
class="bubble-link"
>🔗 {{ item.content }}</a>
<div v-else class="bubble-content">{{ item.content }}</div>
<div
v-if="item.translation"
class="bubble-translation"
>
{{ item.translation }}
</div>
</div>
<div class="bubble-caption">{{ caption(item) }}</div>
</div>
</DynamicScrollerItem>
</template>
</DynamicScroller>
<!-- 空状态(首页返回 [] 或加载中)。 -->
<el-empty
v-else-if="!loading"
description="暂无聊天记录"
:image-size="80"
/>
</div>
</el-card>
</template>
<style scoped>
.chat-card {
display: flex;
flex-direction: column;
}
.chat-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.card-title {
font-weight: 600;
}
.chat-count {
font-size: 12px;
color: var(--el-text-color-secondary);
}
/* 固定高度滚动容器,给 scroller 一个有界框做虚拟化。480px 约可见 6-8 条气泡。 */
.chat-body {
min-height: 200px;
}
.scroller {
height: 480px;
}
.scroller :deep(.vue-recycle-scroller__item-view) {
padding: 4px 0;
}
/* ---- 系统消息:居中灰字,无气泡 ---- */
.system-row {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 6px 0;
}
.system-text {
font-size: 12px;
color: var(--el-text-color-secondary);
background: var(--el-fill-color-light);
padding: 2px 10px;
border-radius: 10px;
max-width: 80%;
text-align: center;
}
.system-time {
font-size: 11px;
color: var(--el-text-color-placeholder);
}
/* ---- 聊天气泡 ---- */
.bubble-row {
display: flex;
flex-direction: column;
padding: 2px 12px;
}
.bubble-row.is-incoming {
align-items: flex-start;
}
.bubble-row.is-outgoing {
align-items: flex-end;
}
.bubble {
max-width: 70%;
padding: 8px 12px;
border-radius: 10px;
font-size: 14px;
line-height: 1.5;
/* 保留 MEDIUMTEXT 换行,不渲染任何 HTML。 */
white-space: pre-wrap;
word-break: break-word;
}
/* 图片/表情消息(img:url,el-image 点击放大预览)*/
.bubble-image {
max-width: 120px;
max-height: 120px;
border-radius: 6px;
display: block;
cursor: zoom-in;
}
.bubble-image :deep(.el-image__inner) {
max-width: 120px;
max-height: 120px;
object-fit: cover;
border-radius: 6px;
cursor: zoom-in;
}
/* 视频消息(video:url,<video controls> 点击播放)*/
.bubble-video {
max-width: 240px;
max-height: 240px;
border-radius: 6px;
display: block;
cursor: pointer;
}
/* 纯 URL 链接消息(渲染成外链)*/
.bubble-link {
display: inline-block;
max-width: 100%;
color: var(--el-color-primary);
text-decoration: underline;
word-break: break-all;
font-size: 13px;
}
.bubble-link:hover {
opacity: 0.8;
}
.is-outgoing .bubble-link {
color: #fff;
}
/* 文件卡片消息(file:名称|url,渲染成可点击链接)*/
.bubble-file {
display: inline-block;
color: inherit;
text-decoration: none;
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 6px;
padding: 6px 10px;
font-size: 13px;
word-break: break-all;
cursor: default;
}
.bubble-file.is-link {
cursor: pointer;
}
.bubble-file.is-link:hover {
opacity: 0.8;
}
.is-outgoing .bubble-file {
border-color: rgba(255, 255, 255, 0.35);
}
.is-incoming .bubble {
background: var(--el-fill-color);
color: var(--el-text-color-primary);
border-top-left-radius: 2px;
}
.is-outgoing .bubble {
background: var(--el-color-primary);
color: #fff;
border-top-right-radius: 2px;
}
.bubble-translation {
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid rgba(0, 0, 0, 0.08);
font-size: 12px;
opacity: 0.85;
}
.is-outgoing .bubble-translation {
border-top-color: rgba(255, 255, 255, 0.25);
}
.bubble-caption {
margin-top: 3px;
font-size: 11px;
color: var(--el-text-color-placeholder);
}
/* ---- 顶部「加载更早」(#before slot)---- */
.load-more-row {
display: flex;
justify-content: center;
align-items: center;
padding: 12px 0;
min-height: 44px;
}
.load-more-end {
font-size: 12px;
color: var(--el-text-color-placeholder);
}
</style>