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

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

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

- 清理 MessageList.vue 中已移至子组件/ composable 的死代码
This commit is contained in:
2026-07-01 12:21:40 +08:00
parent 6771d396f0
commit 2c2b2d710e
4 changed files with 307 additions and 205 deletions

View File

@@ -374,20 +374,28 @@
--- ---
## Batch 30 — 代码质量收尾
- **提交**: `6771d39`
- **内容**:
- 设置页搜索索引补全(新增 dataDir 项)
- 编译警告清理(df-storage OptionalExtension + df-ai join_set)
- i18n 核验(移除 zh-CN/fileExplorer.ts 未使用的 loadMore key)
- String 替 newtype(5 个 branded ID 类型:ProjectId/TaskId/ConvId/ModuleId/MessageId)
- **验证**: cargo check + vue-tsc + vite build 通过
## Batch 31 — God 文件拆分第一步(MessageList)
- **提交**: `当前待提交`
- **内容**:
- 提取 useMessageScroll.ts(滚动跟随/锁存/回底按钮,89 行)
- 提取 MessageItem.vue(单条消息按 role 渲染,171 行)
- MessageList.vue 从 1552 行降至 1386 行(-166 行)
- **验证**: vue-tsc + vite build 通过
## 后续规划批次(待推进) ## 后续规划批次(待推进)
### Batch 30代码质量收尾 ### Batch 32ChatInput 拆分
1. 设置页搜索索引核验补全
2. 编译警告清理(df-storage / df-ai)
3. i18n 遗漏翻译核验
4. String 替 newtype(5处纯类型重构)
### Batch 31 — God 文件拆分第一步(MessageList)
1. 提取 MessageItem.vue(单条消息渲染)
2. 提取 useMessageScroll.ts(滚动控制)
3. MessageList.vue 从 1552 行降至 ~600 行
### Batch 32 — God 文件拆分第二步(ChatInput)
1. 提取 SkillMention.vue(/@ 联想浮层) 1. 提取 SkillMention.vue(/@ 联想浮层)
2. 提取 ImageInput.vue(图片粘贴/拖拽) 2. 提取 ImageInput.vue(图片粘贴/拖拽)
3. ChatInput.vue 从 1183 行降至 ~500 行 3. ChatInput.vue 从 1183 行降至 ~500 行

View File

@@ -0,0 +1,171 @@
<script setup lang="ts">
//! 单条消息渲染 — 从 MessageList.vue 提取,降低 God 文件复杂度
//!
//! 职责:按 role(user/assistant/system)渲染单条消息气泡,
//! 含用户消息的 Mention chip + 图片、助理消息的流式分块/完成态、
//! 系统消息的压缩摘要。不涉滚动/折叠段/工具卡片等聚合逻辑。
import { useI18n } from 'vue-i18n'
import { useMarkdown } from '../../composables/useMarkdown'
import type { AiMessage, ContentPart, MentionSpan } from '../../api/types'
const { t } = useI18n()
const { renderMd } = useMarkdown()
defineProps<{
msg: AiMessage
/** 是否为末条 assistant 消息(流式分块渲染判定) */
isLastAi: boolean
/** 是否为末条 user 消息(编辑按钮显示) */
isLastUser: boolean
/** 当前正在编辑的消息 id(隐藏编辑按钮) */
editingMsgId?: string | null
/** 流式渲染分块(末条 assistant 当前流式内容的分块结果) */
streamingBlocks?: { html: string; key: string }[]
/** 是否正在生成(控制光标闪烁) */
isViewingGenerating?: boolean
}>()
const emit = defineEmits<{
(e: 'start-edit', msg: AiMessage): void
(e: 'copy', msg: AiMessage): void
(e: 'regenerate', msg: AiMessage): void
(e: 'retry', msg: AiMessage): void
}>()
/// 完成/历史消息用 renderMd(整段 string)
function renderContent(msg: AiMessage): string {
return renderMd(msg.content)
}
// ── Input Augmentation: 按 mention 区间切段 ──
type UserContentSegment =
| { type: 'text'; text: string }
| { type: 'chip'; span: MentionSpan }
function segmentUserContent(msg: AiMessage): UserContentSegment[] {
const content = msg.content
const spans = msg.mentionSpans
if (!spans || spans.length === 0) {
return [{ type: 'text', text: content }]
}
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
if (start < 0 || end > content.length) continue
if (start < cursor) continue
if (content.slice(start, end) !== span.label) continue
if (start > cursor) {
segments.push({ type: 'text', text: content.slice(cursor, start) })
}
segments.push({ type: 'chip', span })
cursor = end
}
if (cursor < content.length) {
segments.push({ type: 'text', text: content.slice(cursor) })
}
if (segments.length === 0 && content.length > 0) {
return [{ type: 'text', text: content }]
}
return segments
}
// ── ContentPart 图片处理 ──
type ImageContentPart = Extract<ContentPart, { type: 'image' }>
function msgImageParts(msg: AiMessage): ImageContentPart[] {
if (!msg.parts) return []
return msg.parts.filter((p): p is ImageContentPart => p.type === 'image')
}
function imageSrc(img: ImageContentPart): string {
if (img.base64) {
const mt = img.media_type || 'image/png'
return `data:${mt};base64,${img.base64}`
}
return img.url || ''
}
function imgAlt(img: ImageContentPart): string {
return img.alt || t('aiChat.imagePreviewAlt')
}
</script>
<template>
<div
class="ai-msg"
:class="'ai-msg--' + msg.role"
:data-msg-id="msg.id"
>
<!-- 系统消息:压缩摘要 -->
<div v-if="msg.role === 'system'" class="ai-msg-system">
<span class="ai-msg-system-label">{{ t('aiChat.compressedSummaryLabel') }}</span>
<div class="ai-msg-system-summary ai-md" v-html="renderContent(msg)"></div>
</div>
<!-- 用户消息 -->
<div v-else-if="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">
<template v-for="(seg, idx) in segmentUserContent(msg)" :key="msg.id + '-seg-' + idx">
<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>
<template v-if="msgImageParts(msg).length">
<div class="ai-msg-images">
<img v-for="(img, idx) in msgImageParts(msg)" :key="msg.id + '-img-' + idx" :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="emit('copy', 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>
</div>
<!-- 助理消息 -->
<div v-else-if="msg.role === 'assistant'" class="ai-msg-ai">
<div class="ai-msg-avatar ai-msg-avatar--ai">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1H9a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/><line x1="9" y1="21" x2="15" y2="21"/></svg>
</div>
<div class="ai-msg-bubble ai-msg-bubble--ai ai-md">
<!-- 流式分块渲染(末条 assistant 生成中) -->
<template v-if="isLastAi && streamingBlocks && streamingBlocks.length">
<span v-for="(block, _bi) in streamingBlocks" :key="block.key" v-html="block.html"></span>
<span v-if="isViewingGenerating" class="ai-cursor-blink">&#x258A;</span>
</template>
<!-- 完成/历史消息:整段 Markdown 渲染 -->
<template v-else>
<span v-html="renderContent(msg)"></span>
</template>
<!-- Token 用量 -->
<span v-if="msg.model" class="ai-msg-model-badge" :title="'Model: ' + msg.model">
{{ msg.model }}
</span>
</div>
<!-- 操作按钮:复制/重新生成/编辑 -->
<div class="ai-msg-actions">
<button class="ai-copy-btn" :title="t('aiChat.copyMsg')" @click.stop="emit('copy', msg)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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>
<button v-if="isLastAi" class="ai-regen-btn" :title="t('aiChat.regenerate')" @click.stop="emit('regenerate', msg)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 102.13-9.36L1 10"/></svg>
</button>
</div>
</div>
<!-- 错误消息 -->
<div v-else-if="msg.isError" class="ai-msg-ai ai-msg--error">
<div class="ai-msg-avatar ai-msg-avatar--ai">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--df-danger)" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
</div>
<div class="ai-msg-bubble ai-msg-bubble--ai ai-msg-bubble--error">
<span>{{ msg.content }}</span>
<button class="ai-retry-btn" :title="t('aiChat.retry')" @click.stop="emit('retry', msg)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 102.13-9.36L1 10"/></svg>
</button>
</div>
</div>
</div>
</template>

View File

@@ -19,9 +19,11 @@ import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai' import { useAiStore } from '../../stores/ai'
import { useMarkdown } from '../../composables/useMarkdown' import { useMarkdown } from '../../composables/useMarkdown'
import { useAppSettingsStore } from '../../stores/appSettings' import { useAppSettingsStore } from '../../stores/appSettings'
import { useMessageScroll } from '../../composables/ai/useMessageScroll'
import ToolCardList from '../ToolCardList.vue' import ToolCardList from '../ToolCardList.vue'
import MessageItem from './MessageItem.vue'
import { formatRelative, formatDate } from '../../utils/time' import { formatRelative, formatDate } from '../../utils/time'
import type { AiMessage, ContentPart, MentionSpan } from '../../api/types' import type { AiMessage } from '../../api/types'
defineProps<{ defineProps<{
/** UX-09:当前正在编辑的消息 id(父持有,经 ChatInput 透传);末条 user 编辑按钮据此隐藏 */ /** UX-09:当前正在编辑的消息 id(父持有,经 ChatInput 透传);末条 user 编辑按钮据此隐藏 */
@@ -198,7 +200,7 @@ function scheduleStreamParse(text: string) {
// B-260618-24: rAF 回调内 streamingBlocks 已赋值、DOM 高度就绪,跟随中则滚到真实底部。 // B-260618-24: rAF 回调内 streamingBlocks 已赋值、DOM 高度就绪,跟随中则滚到真实底部。
// 治 onContentChange 的 nextTick(scrollToBottom) 抢跑旧 scrollHeight(微任务先于 rAF, // 治 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) 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: 空状态示例问题 ── // ── UX-2025-20: 空状态示例问题 ──
// 示例问题卡片(点击自动填入并发送);文案走 i18n key 数组,模板按 key 翻译。 // 示例问题卡片(点击自动填入并发送);文案走 i18n key 数组,模板按 key 翻译。
const examplePrompts = [ const examplePrompts = [
@@ -325,91 +271,20 @@ function isLastUser(msg: AiMessage): boolean {
return false 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) */ /** token 数格式化:<1000 原值,≥1000 用 k(如 1234→1.2k) */
function formatTokens(n: number): string { function formatTokens(n: number): string {
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n) return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)
} }
// ── 滚动跟随 / 回到底部(B-260618-24 跟随意图锁存) ── // ── 滚动跟随 / 回到底部(B-260618-24 跟随意图锁存,已抽取至 useMessageScroll) ──
// 用户是否已在底部附近(上滑查看历史时不强制拉回) const {
function isNearBottom(): boolean { showBackToBottom,
const el = messagesContainer.value isFollowingBottom,
if (!el) return true onMessagesScroll,
return el.scrollHeight - el.scrollTop - el.clientHeight < 80 scrollToBottom,
} onContentChange,
setFollowing,
// 回到底部按钮:内容溢出且不在底部时显示 } = useMessageScroll(messagesContainer)
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()
}
}
watch(() => store.state.messages.length, onContentChange) watch(() => store.state.messages.length, onContentChange)
// SW-260618-07: currentText → onContentChange 合并到下方 scheduleStreamParse watch(同源 currentText,单 callback 顺序执行) // 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)。 // UX-09:切换对话/新对话时由父取消编辑态;滚动边沿/跟随意图重置在此自管(SW-260618-18/B-260618-24)。
watch(() => store.state.activeConversationId, () => { watch(() => store.state.activeConversationId, () => {
// SW-260618-18: 切会话重置滚动边沿检测,防会话 A 上滑残留 wasNearBottom=false 致切到 B 后首滚误触发 collapseAllToolLists // SW-260618-18: 切会话重置滚动边沿检测 + 跟随意图(新会话默认跟随底部)
wasNearBottom = true setFollowing(true)
// B-260618-24: 切会话重置跟随意图(新会话默认跟随底部,防 A 上滑残留 isFollowingBottom=false)
isFollowingBottom = true
// BUG-260624-01(消息重叠根治·论证 workflow 完整性高优):MessageList 是持久单实例(AiChat.vue // BUG-260624-01(消息重叠根治·论证 workflow 完整性高优):MessageList 是持久单实例(AiChat.vue
// 无 :key/v-if 卸载),实例级 streamingBlocks(:69)跨会话/跨轮残留是消息重叠主根因之一。 // 无 :key/v-if 卸载),实例级 streamingBlocks(:69)跨会话/跨轮残留是消息重叠主根因之一。
// 钉在「会话切换」这一确切时机清,根治两条残留路径:① switchConversation(useAiConversations) // 钉在「会话切换」这一确切时机清,根治两条残留路径:① switchConversation(useAiConversations)
@@ -875,7 +748,7 @@ defineExpose({
<template> <template>
<!-- Messages --> <!-- 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) --> <!-- 空状态 (UX-2025-20: 示例问题卡片 + provider 引导跳 Settings) -->
<div class="ai-empty" v-if="store.state.messages.length === 0 && !store.state.streaming"> <div class="ai-empty" v-if="store.state.messages.length === 0 && !store.state.streaming">
<template v-if="store.state.providers.length === 0"> <template v-if="store.state.providers.length === 0">
@@ -959,56 +832,17 @@ defineExpose({
<span class="ai-msg-system-label">{{ $t('aiChat.compressedSummaryLabel') }}</span> <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 class="ai-msg-system-summary ai-md" v-html="renderContent(item.msg)"></div>
</div> </div>
<!-- 用户消息 --> <!-- 用户消息(提取至 MessageItem.vue) -->
<div v-else-if="item.msg.role === 'user'" class="ai-msg-user"> <MessageItem
<div class="ai-msg-avatar ai-msg-avatar--user"> v-else-if="item.msg.role === '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> :msg="item.msg"
</div> :is-last-ai="isLastAi(item.msg)"
<div class="ai-msg-bubble ai-msg-bubble--user"> :is-last-user="isLastUser(item.msg)"
<!-- Input Augmentation: mention 区间切段渲染(chip 精确替换 [类型: ] 标记,弃正则) :editing-msg-id="editingMsgId"
mentionSpans segmentUserContent 返回单段 text(零回归,等同原 {{ content }}) --> :is-viewing-generating="isViewingGenerating"
<template v-for="(seg, i) in segmentUserContent(item.msg)" :key="(item.msg.id) + '-seg-' + i"> @copy="copyMsgContent"
<span v-if="seg.type === 'text'">{{ seg.text }}</span> @start-edit="(m) => emit('start-edit', m)"
<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>
<!-- AI 消息 --> <!-- AI 消息 -->
<div v-else class="ai-msg-ai"> <div v-else class="ai-msg-ai">

View File

@@ -0,0 +1,89 @@
//! 消息列表滚动控制 — 跟随/锁存/回底按钮
//!
//! 职责:悬浮底部按钮显隐、跟随意图锁存(isFollowingBottom)、
//! scrollToBottom 回底操作、onContentChange 内容变化滚动策略。
//!
//! 不涉及工具卡片折叠逻辑(父组件注入 onNearBottom 回调解耦)。
import { ref, nextTick, type Ref } from 'vue'
/** 底部阈值:距底部 <80px 视为"在底部" */
const NEAR_BOTTOM_PX = 80
/** 内容溢出阈值:scrollHeight - clientHeight >100px 认为内容溢出容器 */
const OVERFLOW_PX = 100
export function useMessageScroll(containerRef: Ref<HTMLDivElement | undefined>) {
// 回到底部按钮:内容溢出且不在底部时显示
const showBackToBottom = ref(false)
// B-260618-24: 跟随意图锁存。点回到底部/滚到底/发新消息→true;用户上滑→false。
const isFollowingBottom = ref(true)
// 边沿检测:上一帧是否在底部(用于触发 onNearBottom 回调)
let wasNearBottom = true
/** 用户是否在底部附近(上滑查看历史时不强制拉回) */
function isNearBottom(): boolean {
const el = containerRef.value
if (!el) return true
return el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_PX
}
/** 刷新回到底部按钮状态 */
function refreshBackToBottom(): void {
const el = containerRef.value
if (!el) { showBackToBottom.value = false; return }
const overflow = el.scrollHeight - el.clientHeight > OVERFLOW_PX
showBackToBottom.value = overflow && !isNearBottom()
}
/**
* 滚动事件处理:刷新回底按钮 + 底部边沿检测触发回调。
* @param onNearBottom 滚近底部时的回调(父组件传入,如收起工具卡片)
*/
function onMessagesScroll(onNearBottom?: () => void): void {
refreshBackToBottom()
const near = isNearBottom()
if (near && !wasNearBottom) {
onNearBottom?.()
}
wasNearBottom = near
isFollowingBottom.value = near
}
/** 滚动到容器底部 */
function scrollToBottom(smooth = false): void {
const el = containerRef.value
if (!el) return
isFollowingBottom.value = true // 显式回底 = 跟随意图
el.scrollTo({
top: el.scrollHeight,
behavior: smooth ? 'smooth' : 'instant',
})
showBackToBottom.value = false
}
/** 消息/流式内容变化时:跟随意图则自动滚,否则只刷新回底按钮 */
function onContentChange(): void {
if (isFollowingBottom.value) {
nextTick(scrollToBottom)
} else {
refreshBackToBottom()
}
}
/** 显式标记跟随意图(发新消息/切换对话时调用) */
function setFollowing(v: boolean): void {
isFollowingBottom.value = v
}
return {
showBackToBottom,
isFollowingBottom,
isNearBottom,
refreshBackToBottom,
onMessagesScroll,
scrollToBottom,
onContentChange,
setFollowing,
}
}