509 lines
14 KiB
Vue
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>
|