重构: MessageList 拆分(提取 MessageItem+useMessageScroll, 1552->1386行)

- 提取 useMessageScroll.ts(滚动跟随/锁存/回底按钮)

- 提取 MessageItem.vue(按 role 渲染单条消息)

- 清理 MessageList.vue 中已移至子组件/ composable 的死代码
This commit is contained in:
lxy
2026-07-01 12:21:40 +08:00
parent 6771d396f0
commit 2c2b2d710e
4 changed files with 307 additions and 205 deletions
+27 -193
View File
@@ -19,9 +19,11 @@ import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { useMarkdown } from '../../composables/useMarkdown'
import { useAppSettingsStore } from '../../stores/appSettings'
import { useMessageScroll } from '../../composables/ai/useMessageScroll'
import ToolCardList from '../ToolCardList.vue'
import MessageItem from './MessageItem.vue'
import { formatRelative, formatDate } from '../../utils/time'
import type { AiMessage, ContentPart, MentionSpan } from '../../api/types'
import type { AiMessage } from '../../api/types'
defineProps<{
/** UX-09:当前正在编辑的消息 id(父持有,经 ChatInput 透传);末条 user 编辑按钮据此隐藏 */
@@ -198,7 +200,7 @@ function scheduleStreamParse(text: string) {
// B-260618-24: rAF 回调内 streamingBlocks 已赋值、DOM 高度就绪,跟随中则滚到真实底部。
// 治 onContentChange 的 nextTick(scrollToBottom) 抢跑旧 scrollHeight(微任务先于 rAF,
// 滚到渲染前高度)致"差一截"。
if (isFollowingBottom) scrollToBottom()
if (isFollowingBottom.value) scrollToBottom()
})
})
}
@@ -210,62 +212,6 @@ function renderContent(msg: AiMessage): string {
return renderMd(msg.content)
}
// ── Input Augmentation: 用户消息按 mention 区间切段(弃正则,纯元数据驱动) ──
// 解决 🔴HIGH-2:正则误匹配(用户手打 [项目: x] 格式 / AI 模仿格式 / 项目名含 ] / 嵌套)。
// 按 msg.mentionSpans(无则纯文本一段)按 start 升序切段:text → chip 替换。
// 每 span 安全校验:content.slice(start, start+length) === span.label 否则降级为 text
// (用户编辑破坏区间,如改了项目名/删了字符致偏移失效)。重叠/越界 span 也降级。
type UserContentSegment =
| { type: 'text'; text: string }
| { type: 'chip'; span: MentionSpan }
function segmentUserContent(msg: AiMessage): UserContentSegment[] {
const content = msg.content
const spans = msg.mentionSpans
// 无 mentionSpans(历史消息/纯文本)→ 一段 text 零回归
if (!spans || spans.length === 0) {
return [{ type: 'text', text: content }]
}
// 按 start 升序排序(稳定;不影响原数组)
const sorted = [...spans].sort((a, b) => a.start - b.start)
const segments: UserContentSegment[] = []
let cursor = 0 // 已消费到的字符偏移
for (const span of sorted) {
const start = span.start
const end = start + span.length
// 越界:起点/终点超出 content 长度 → 降级跳过(区间已失效)
if (start < 0 || end > content.length) continue
// 与前段重叠:起点在已消费 cursor 之前 → 降级跳过(防区间相互覆盖产生错乱)
if (start < cursor) continue
// 安全校验:切片内容必须 === span.label(用户编辑破坏区间则不等 → 降级跳过该 span)
if (content.slice(start, end) !== span.label) continue
// 前导 text(若有)
if (start > cursor) {
segments.push({ type: 'text', text: content.slice(cursor, start) })
}
// chip 段
segments.push({ type: 'chip', span })
cursor = end
}
// 收尾 tail text(末个 span 之后剩余)
if (cursor < content.length) {
segments.push({ type: 'text', text: content.slice(cursor) })
}
// 极端:所有 span 全降级且 content 非空 → segments 可能为空(无前导/tail),补一段完整 text
if (segments.length === 0 && content.length > 0) {
return [{ type: 'text', text: content }]
}
return segments
}
// ── UX-2025-20: 空状态示例问题 ──
// 示例问题卡片(点击自动填入并发送);文案走 i18n key 数组,模板按 key 翻译。
const examplePrompts = [
@@ -325,91 +271,20 @@ function isLastUser(msg: AiMessage): boolean {
return false
}
/** ContentPart 图片片类型(discriminator type==='image' 的联合成员) */
type ImageContentPart = Extract<ContentPart, { type: 'image' }>
/** 取消息的图片 ContentPart 片(仅 type==='image');空数组=无图 */
function msgImageParts(msg: AiMessage): ImageContentPart[] {
if (!msg.parts) return []
return msg.parts.filter((p): p is ImageContentPart => p.type === 'image')
}
/** ContentPart.image → <img src>:base64 模式拼 data URI,url 模式原样 */
function imageSrc(img: ImageContentPart): string {
if (img.base64) {
// media_type 缺失兜底 image/png(provider 转发端点需完整 data URI)
const mt = img.media_type || 'image/png'
return `data:${mt};base64,${img.base64}`
}
return img.url || ''
}
/** ContentPart.image → alt 文案 */
function imgAlt(img: ImageContentPart): string {
return img.alt || t('aiChat.imagePreviewAlt')
}
/** token 数格式化:<1000 原值,≥1000 用 k(如 1234→1.2k) */
function formatTokens(n: number): string {
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)
}
// ── 滚动跟随 / 回到底部(B-260618-24 跟随意图锁存) ──
// 用户是否已在底部附近(上滑查看历史时不强制拉回)
function isNearBottom(): boolean {
const el = messagesContainer.value
if (!el) return true
return el.scrollHeight - el.scrollTop - el.clientHeight < 80
}
// 回到底部按钮:内容溢出且不在底部时显示
const showBackToBottom = ref(false)
// UX-260616-03: 跟随阅读位置自动收起 —— 用户滚近底部(在读最新内容)时,
// 收起不再活跃的旧卡(视为已读)。用 wasNearBottom 边沿检测避免每个 scroll tick 都触发:
// 仅在"远离底部 → 滚近底部"的转换瞬间收起一次,已在底部继续滚动不重复触发。
let wasNearBottom = true
// B-260618-24: 跟随意图锁存。原 onContentChange 仅靠 isNearBottom() 瞬时测量,流式 rAF 高频
// 渲染下单帧增量 >80px 即判不在底 → 跟随意图一帧丢失(点回到底部后又失跟)。锁存解耦
// "意图"与"瞬时测量":点回到底部/滚到底/发新消息→true;用户上滑→false。
let isFollowingBottom = true
function refreshBackToBottom() {
const el = messagesContainer.value
if (!el) { showBackToBottom.value = false; return }
const overflow = el.scrollHeight - el.clientHeight > 100
showBackToBottom.value = overflow && !isNearBottom()
}
function onMessagesScroll() {
refreshBackToBottom()
// UX-260616-03: 滚近底部 → 收起已读旧卡(边沿触发,防抖)
const near = isNearBottom()
if (near && !wasNearBottom) {
collapseAllToolLists(buildActiveToolIds(store.state.messages))
}
wasNearBottom = near
// B-260618-24: 用户主动滚离底部 = 放弃跟随;滚回底部 = 恢复跟随
isFollowingBottom = near
}
function scrollToBottom(smooth = false) {
if (messagesContainer.value) {
isFollowingBottom = true // 显式回底 = 跟随意图
messagesContainer.value.scrollTo({
top: messagesContainer.value.scrollHeight,
behavior: smooth ? 'smooth' : 'instant',
})
showBackToBottom.value = false
}
}
// 消息/流式变化时:跟随意图则自动滚;否则只刷新回底按钮(不强制打断)
// 用 isFollowingBottom 锁存而非 isNearBottom 瞬时测量,避免流式高频下单帧突破阈值误判失跟
function onContentChange() {
if (isFollowingBottom) {
nextTick(scrollToBottom)
} else {
refreshBackToBottom()
}
}
// ── 滚动跟随 / 回到底部(B-260618-24 跟随意图锁存,已抽取至 useMessageScroll) ──
const {
showBackToBottom,
isFollowingBottom,
onMessagesScroll,
scrollToBottom,
onContentChange,
setFollowing,
} = useMessageScroll(messagesContainer)
watch(() => store.state.messages.length, onContentChange)
// SW-260618-07: currentText → onContentChange 合并到下方 scheduleStreamParse watch(同源 currentText,单 callback 顺序执行)
@@ -840,10 +715,8 @@ const renderItems = computed<RenderItem[]>(() => {
// UX-09:切换对话/新对话时由父取消编辑态;滚动边沿/跟随意图重置在此自管(SW-260618-18/B-260618-24)。
watch(() => store.state.activeConversationId, () => {
// SW-260618-18: 切会话重置滚动边沿检测,防会话 A 上滑残留 wasNearBottom=false 致切到 B 后首滚误触发 collapseAllToolLists
wasNearBottom = true
// B-260618-24: 切会话重置跟随意图(新会话默认跟随底部,防 A 上滑残留 isFollowingBottom=false)
isFollowingBottom = true
// SW-260618-18: 切会话重置滚动边沿检测 + 跟随意图(新会话默认跟随底部)
setFollowing(true)
// BUG-260624-01(消息重叠根治·论证 workflow 完整性高优):MessageList 是持久单实例(AiChat.vue
// 无 :key/v-if 卸载),实例级 streamingBlocks(:69)跨会话/跨轮残留是消息重叠主根因之一。
// 钉在「会话切换」这一确切时机清,根治两条残留路径:① switchConversation(useAiConversations)
@@ -875,7 +748,7 @@ defineExpose({
<template>
<!-- Messages -->
<div class="ai-messages" ref="messagesContainer" @scroll="onMessagesScroll">
<div class="ai-messages" ref="messagesContainer" @scroll="onMessagesScroll(() => collapseAllToolLists(buildActiveToolIds(store.state.messages)))">
<!-- 空状态 (UX-2025-20: 示例问题卡片 + provider 引导跳 Settings) -->
<div class="ai-empty" v-if="store.state.messages.length === 0 && !store.state.streaming">
<template v-if="store.state.providers.length === 0">
@@ -959,56 +832,17 @@ defineExpose({
<span class="ai-msg-system-label">{{ $t('aiChat.compressedSummaryLabel') }}</span>
<div class="ai-msg-system-summary ai-md" v-html="renderContent(item.msg)"></div>
</div>
<!-- 用户消息 -->
<div v-else-if="item.msg.role === 'user'" class="ai-msg-user">
<div class="ai-msg-avatar ai-msg-avatar--user">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
</div>
<div class="ai-msg-bubble ai-msg-bubble--user">
<!-- Input Augmentation: mention 区间切段渲染(chip 精确替换 [类型: ] 标记,弃正则)
mentionSpans segmentUserContent 返回单段 text(零回归,等同原 {{ content }}) -->
<template v-for="(seg, i) in segmentUserContent(item.msg)" :key="(item.msg.id) + '-seg-' + i">
<span v-if="seg.type === 'text'">{{ seg.text }}</span>
<span
v-else
class="ai-msg-chip"
:class="'ai-msg-chip--' + seg.span.kind"
>{{ seg.span.label }}</span>
</template>
<!-- F-260614-05 Phase 2b: 多模态图片渲染(随消息气泡挂载/卸载;
B-260618-01 虚拟滚动已移除,气泡内 <img> 随消息恒渲染无裁剪) -->
<template v-if="msgImageParts(item.msg).length">
<div class="ai-msg-images">
<img
v-for="(img, i) in msgImageParts(item.msg)"
:key="(item.msg.id) + '-img-' + i"
:src="imageSrc(img)"
:alt="imgAlt(img)"
class="ai-msg-image"
loading="lazy"
/>
</div>
</template>
<button
class="ai-copy-btn"
:title="$t('aiChat.copyMsg')"
@click.stop="copyMsgContent(item.msg)"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</button>
</div>
<!-- UX-09:末条 user 消息编辑按钮(非生成中非当前编辑态)hover 显示 -->
<button
v-if="isLastUser(item.msg) && !store.state.streaming && !editingMsgId"
class="ai-msg-edit-btn"
:title="$t('aiChat.editMessage')"
@click.stop="emit('start-edit', item.msg)"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<!-- UX-2025-13:消息时间戳(相对时间+hover绝对时间) -->
<span class="ai-msg-time ai-msg-time--user" :title="formatDate(item.msg.timestamp)">{{ formatRelative(item.msg.timestamp) }}</span>
</div>
<!-- 用户消息(提取至 MessageItem.vue) -->
<MessageItem
v-else-if="item.msg.role === 'user'"
:msg="item.msg"
:is-last-ai="isLastAi(item.msg)"
:is-last-user="isLastUser(item.msg)"
:editing-msg-id="editingMsgId"
:is-viewing-generating="isViewingGenerating"
@copy="copyMsgContent"
@start-edit="(m) => emit('start-edit', m)"
/>
<!-- AI 消息 -->
<div v-else class="ai-msg-ai">