优化: 批A前端(timer所有权+会话族+审批conv-scoped+FilePreview+失败反馈)
A1-B1 timer所有权:新建 useTimerOwnership composable(每实例独立ref+onUnmounted清理)+ useToast _timer 下沉 + Ideas debounce 补清理(治跨实例串扰) A1-B2 FilePreview:reqSeq 双计数器守卫(loadFile/loadDiff 最新seq才写,防乱序覆盖)+ mermaid securityLevel strict + filePath 比对(防跨文件SVG注入) A1-B3 会话族:load_more 滚顶加载接线(switch透传has_more/earliest_seq+prepend去重+scrollTop恢复)+ switch失败保留视图+报错(仅对话不存在才create-new)+ new/delete失败反馈(withConvOp helper收敛)+ delete清ai_messages孤儿 A1-B5前端契约:isToolFailure 三处加 success===false 判定(useToolCard/ToolResultBody/ToolCard,git只读失败不再绿框) A2-B10 审批conv-scoped:pendingApprovals 补conversationId + cleanup 按convId filter + 移除全局清(AiError不再误清其他conv)+ :676 dir auth filter方向修 usage打标前端:is_estimated 字段+事件透传+MessageList『估算』角标(详情面板说明) A2-B9前端:clearChat try/catch 错误气泡
This commit is contained in:
@@ -213,6 +213,8 @@ const isFailed = computed(() => {
|
||||
if (r && typeof r.exit_code === 'number') return r.exit_code !== 0
|
||||
if (r) return true
|
||||
}
|
||||
// A1-B5:git 只读工具失败返 {success:false,error}(结构化非 Err),通用分支识别
|
||||
if (r && r.success === false) return true
|
||||
if (!r) {
|
||||
const raw = typeof tc.result === 'string' ? tc.result : ''
|
||||
if (!raw) return false
|
||||
@@ -251,7 +253,7 @@ const diffLines = computed(() => parseDiffLines(props.tc.diff))
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 4px 10px 8px 42px; font-size: 11px; color: var(--df-danger);
|
||||
}
|
||||
.ai-tool-rejected-icon { font-weight: 700; }
|
||||
.ai-tool-rejected-icon { font-weight: 500; }
|
||||
|
||||
/* -- 卡片折叠态(running/pending 始终展开,completed/rejected 默认折叠为单行 header) -- */
|
||||
.ai-tool-card--collapsed .ai-tool-header { cursor: pointer; }
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<!-- search 模式:命中行渲染(复用 grep content 样式;read_file matches 仅 {line,content} 无 file) -->
|
||||
<!-- BUG-260626-01:行号 gutter 与内容同一 flex row(IDE 风格),不再 head/content 分两行块级堆叠 -->
|
||||
<!-- 行号 gutter 与内容同一 flex row(IDE 风格),不再 head/content 分两行块级堆叠 -->
|
||||
<div v-if="parsed?.search && parsed?.matches?.length" class="ai-tool-grep-matches">
|
||||
<div v-for="(m, i) in parsed?.matches" :key="i" class="ai-tool-grep-match">
|
||||
<div class="ai-tool-grep-match-main">
|
||||
@@ -98,7 +98,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- content 模式:命中行(文件 | 行号 gutter | 内容 同行),支持上下文折叠 -->
|
||||
<!-- BUG-260626-01:文件名/行号/内容同一 flex row,不再 head 块 + content 块分两行 -->
|
||||
<!-- 文件名/行号/内容同一 flex row,不再 head 块 + content 块分两行 -->
|
||||
<div v-else class="ai-tool-grep-matches">
|
||||
<div v-for="(m, i) in parsed?.matches" :key="i" class="ai-tool-grep-match">
|
||||
<div class="ai-tool-grep-match-main">
|
||||
@@ -277,6 +277,9 @@ const isFailed = computed(() => {
|
||||
if (r && typeof r.exit_code === 'number') return r.exit_code !== 0
|
||||
if (r) return true
|
||||
}
|
||||
// A1-B5: 可解析 JSON 且显式 success===false(git status/diff/log 只读工具失败返
|
||||
// {success:false,error} 合法 JSON 对象非 Err)→ 判失败,防绿框假成功
|
||||
if (r && r.success === false) return true
|
||||
if (!r) {
|
||||
const raw = typeof tc.result === 'string' ? tc.result : ''
|
||||
if (!raw) return false
|
||||
@@ -415,7 +418,7 @@ watch(() => props.tc.status, (s) => {
|
||||
.ai-tool-grep-match:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
/* BUG-260626-01:命中行主体 = 单行 flex row(文件名 | 行号 gutter | 内容 并排),
|
||||
/* 命中行主体 = 单行 flex row(文件名 | 行号 gutter | 内容 并排),
|
||||
取代原 head 块 + content 块的垂直两行堆叠(行号内容分行根因)。 */
|
||||
.ai-tool-grep-match-main {
|
||||
display: flex;
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useAiStore } from '../../stores/ai'
|
||||
import { useMarkdown } from '../../composables/useMarkdown'
|
||||
import { useMessageScroll } from '../../composables/ai/useMessageScroll'
|
||||
import { useStreamRenderer } from '../../composables/ai/useStreamRenderer'
|
||||
import { loadMoreHistory, getLoadMoreState } from '../../composables/ai/useAiConversations'
|
||||
import ToolCardList from '../ToolCardList.vue'
|
||||
import MessageItem from './MessageItem.vue'
|
||||
import EmptyState from './EmptyState.vue'
|
||||
@@ -86,7 +87,7 @@ function isLastAi(msg: AiMessage): boolean {
|
||||
*/
|
||||
function shouldRenderMsg(msg: AiMessage): boolean {
|
||||
if (msg.role === 'assistant'
|
||||
&& !msg.content
|
||||
&& !msg.content?.trim() // 根因修复:!content 只拦空串漏 whitespace(空气泡)
|
||||
&& !msg.isError
|
||||
&& !(msg.toolCalls && msg.toolCalls.length)
|
||||
&& !(isLastAi(msg) && store.state.streaming)) {
|
||||
@@ -130,6 +131,12 @@ function tokenOutOf(m: AiMessage): number {
|
||||
function tokenReasonOf(m: AiMessage): number {
|
||||
return m.tokenUsage?.reasoning ?? m.reasoning_tokens ?? 0
|
||||
}
|
||||
/** G4.3:该消息 token 用量是否为估算值(后端 AiCompleted.is_estimated 透传:
|
||||
* round_usage.prompt_tokens==0 时用 estimated_prompt 兜底)。true 时主显示/详情标注『估算』,
|
||||
* 让用户区分估算值 vs 真实用量。 */
|
||||
function tokenEstimated(m: AiMessage): boolean {
|
||||
return m.tokenUsage?.is_estimated === true
|
||||
}
|
||||
/** 计算 cache 命中率(0-100),hit+miss=0 时返回 null(无 cache 数据) */
|
||||
function cacheHitRate(m: AiMessage): number | null {
|
||||
const hit = tokenCacheOf(m)
|
||||
@@ -144,7 +151,7 @@ function toggleTokenPopover(m: AiMessage, e: Event): void {
|
||||
tokenPopoverMsgId.value = tokenPopoverMsgId.value === id ? null : id
|
||||
}
|
||||
|
||||
// ── 滚动跟随 / 回到底部(B-260618-24 跟随意图锁存,已抽取至 useMessageScroll) ──
|
||||
// ── 滚动跟随 / 回到底部(跟随意图锁存,已抽取至 useMessageScroll) ──
|
||||
const {
|
||||
showBackToBottom,
|
||||
isFollowingBottom,
|
||||
@@ -171,6 +178,33 @@ const {
|
||||
scrollToBottom,
|
||||
})
|
||||
|
||||
// ── G3.5:滚顶加载更早历史(load_more 分页) ──
|
||||
// 后端 ai_conversation_switch 已返 has_more/earliest_seq 游标(useAiConversations 模块持有,
|
||||
// 本组件经 getLoadMoreState 读同一引用)。滚顶到阈值触发 loadMoreHistory → IPC → prepend +
|
||||
// 按 id 去重 + scrollTop 恢复(视口停留在原消息处不闪跳)。最小接线,不重构本组件。
|
||||
const loadMoreCursor = getLoadMoreState()
|
||||
const LOAD_MORE_TOP_PX = 60
|
||||
|
||||
async function handleMessagesScroll(): Promise<void> {
|
||||
// 原 @scroll 职责(回底按钮显隐 + 底部边沿检测回调收起旧工具卡)保持
|
||||
onMessagesScroll(() => collapseAllToolLists(buildActiveToolIds(store.state.messages)))
|
||||
// G3.5:滚顶阈值触发加载更早历史
|
||||
const cur = loadMoreCursor
|
||||
if (!cur.hasMore || cur.loading || cur.earliestSeq == null) return
|
||||
const el = messagesContainer.value
|
||||
if (!el) return
|
||||
if (el.scrollTop > LOAD_MORE_TOP_PX) return
|
||||
const prevScrollHeight = el.scrollHeight
|
||||
const inserted = await loadMoreHistory()
|
||||
if (!inserted) return
|
||||
const container = messagesContainer.value
|
||||
if (!container) return
|
||||
await nextTick()
|
||||
// 恢复滚动:内容增高差 = 新 scrollHeight - 旧 scrollHeight,视口停留在原消息处不闪跳
|
||||
const diff = container.scrollHeight - prevScrollHeight
|
||||
container.scrollTop = Math.max(diff, 0)
|
||||
}
|
||||
|
||||
watch(() => store.state.messages.length, onContentChange)
|
||||
// SW-260618-07: currentText → onContentChange 合并到下方 scheduleStreamParse watch(同源 currentText,单 callback 顺序执行)
|
||||
|
||||
@@ -182,7 +216,7 @@ watch(() => store.state.messages.length, onContentChange)
|
||||
// SW-260618-07: 合并 currentText 双 watch(原 :2040 onContentChange 滚动 + 本 watch scheduleStreamParse 分块)。
|
||||
// 顺序:onContentChange(滚动跟随)先 → scheduleStreamParse(rAF 分块渲染)后,保持原注册顺序。
|
||||
watch(() => store.state.currentText, (text) => {
|
||||
// B-260618-24: 流式滚动跟随由 scheduleStreamParse 的 rAF 回调承接(DOM 高度就绪后滚),
|
||||
// 流式滚动跟随由 scheduleStreamParse 的 rAF 回调承接(DOM 高度就绪后滚),
|
||||
// 不再用 onContentChange 的 nextTick(微任务先于 rAF,滚到旧 scrollHeight 致错位抖动)。
|
||||
// 仅流式渲染触发;非流式 currentText 变化(罕见)走 onContentChange 兜底。
|
||||
if (store.state.streaming && text) scheduleStreamParse(text)
|
||||
@@ -617,6 +651,33 @@ function shouldCollapseLong(msg: AiMessage): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 工具结果折叠防御(对齐 miniapp apps/df-miniapp/src/pages/chat/index.vue:344-354
|
||||
* isToolResultJson):assistant content 为纯 JSON 工具结果(trim 后以 {/[ 开头且 JSON.parse
|
||||
* 成功)时,弱化为灰小字折叠块(.ai-msg-json),不渲染 markdown 大气泡刷屏。
|
||||
* 仅完成/历史态生效:isLastAi && streaming 时 content 是流式累积的截断 JSON(JSON.parse
|
||||
* 必失败/误判),仍走 currentText 分块渲染,本检测对流式态短路。
|
||||
*/
|
||||
function isToolResultJson(content: string | undefined | null): boolean {
|
||||
if (!content) return false
|
||||
const t = content.trim()
|
||||
if (!(t.startsWith('{') || t.startsWith('['))) return false
|
||||
try {
|
||||
JSON.parse(t)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
/// 展开的 JSON 折叠块 id 集合(点击切换);默认折叠(max-height 80px 裁剪,可滚动)
|
||||
const expandedJsonIds = ref<Set<string>>(new Set())
|
||||
function toggleJson(id: string): void {
|
||||
const next = new Set(expandedJsonIds.value)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
expandedJsonIds.value = next
|
||||
}
|
||||
|
||||
type RenderItem =
|
||||
| { kind: 'msg'; key: string; msg: AiMessage }
|
||||
| { kind: 'sep'; key: string; seg: MessageSegment & { key: string } }
|
||||
@@ -639,7 +700,7 @@ const renderItems = computed<RenderItem[]>(() => {
|
||||
return items
|
||||
})
|
||||
|
||||
// UX-09:切换对话/新对话时由父取消编辑态;滚动边沿/跟随意图重置在此自管(SW-260618-18/B-260618-24)。
|
||||
// UX-09:切换对话/新对话时由父取消编辑态;滚动边沿/跟随意图重置在此自管(SW-260618-18)。
|
||||
watch(() => store.state.activeConversationId, () => {
|
||||
// SW-260618-18: 切会话重置滚动边沿检测 + 跟随意图(新会话默认跟随底部)
|
||||
setFollowing(true)
|
||||
@@ -681,7 +742,7 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<!-- Messages -->
|
||||
<div class="ai-messages" ref="messagesContainer" @scroll="onMessagesScroll(() => collapseAllToolLists(buildActiveToolIds(store.state.messages)))">
|
||||
<div class="ai-messages" ref="messagesContainer" @scroll="handleMessagesScroll">
|
||||
<!-- 空状态 (UX-2025-20: 示例问题卡片 + 无 provider 引导跳 Settings) — 提取至 EmptyState.vue -->
|
||||
<EmptyState
|
||||
v-if="store.state.messages.length === 0 && !store.state.streaming"
|
||||
@@ -755,9 +816,19 @@ defineExpose({
|
||||
</div>
|
||||
<div class="ai-msg-content">
|
||||
<!-- 文本内容(流式或固定,Markdown 渲染) -->
|
||||
<!-- UX-2025-01:流式分块v-for,已完成块DOM稳定不重建→选文字保持 -->
|
||||
<!-- JSON 工具结果折叠防御(对齐 miniapp apps/df-miniapp/src/pages/chat/index.vue:344-354):
|
||||
assistant content 为纯 JSON 工具结果时弱化为灰小字折叠块(.ai-msg-json),
|
||||
不渲染 markdown 大气泡刷屏。仅完成/历史态生效:isLastAi && streaming 时 content
|
||||
是流式累积的截断 JSON(JSON.parse 必失败/误判),仍走下方流式分块渲染。
|
||||
UX-2025-01:流式分块v-for,已完成块DOM稳定不重建→选文字保持 -->
|
||||
<div
|
||||
v-if="item.msg.content || (isLastAi(item.msg) && store.state.streaming && store.state.currentText)"
|
||||
v-if="isToolResultJson(item.msg.content) && !(isLastAi(item.msg) && store.state.streaming)"
|
||||
class="ai-msg-json"
|
||||
:class="{ 'ai-msg-json--expanded': expandedJsonIds.has(item.msg.id) }"
|
||||
@click="toggleJson(item.msg.id)"
|
||||
><pre>{{ item.msg.content }}</pre></div>
|
||||
<div
|
||||
v-else-if="(item.msg.content && item.msg.content.trim()) || (isLastAi(item.msg) && store.state.streaming && store.state.currentText)"
|
||||
class="ai-msg-bubble ai-msg-bubble--ai ai-md"
|
||||
:class="{ 'ai-msg-bubble--error': item.msg.isError, 'ai-msg-bubble--collapsed': shouldCollapseLong(item.msg) }"
|
||||
:key="'md-' + item.msg.id + '-' + (isLastAi(item.msg) ? _mdRenderKey : 0)"
|
||||
@@ -827,7 +898,7 @@ defineExpose({
|
||||
:aria-expanded="tokenPopoverMsgId === item.msg.id"
|
||||
@click="toggleTokenPopover(item.msg, $event)"
|
||||
>
|
||||
<span>{{ formatTokens(tokenInOf(item.msg)) }} in</span>
|
||||
<span>{{ formatTokens(tokenInOf(item.msg)) }} in<template v-if="tokenEstimated(item.msg)"><span class="ai-token-est">(估算)</span></template></span>
|
||||
<span class="ai-token-sep">·</span>
|
||||
<span>{{ formatTokens(tokenCacheOf(item.msg)) }} cache</span>
|
||||
<span class="ai-token-sep">·</span>
|
||||
@@ -843,7 +914,7 @@ defineExpose({
|
||||
class="ai-token-popover"
|
||||
@click.stop
|
||||
>
|
||||
<div class="ai-token-popover-title">Token 用量详情</div>
|
||||
<div class="ai-token-popover-title">Token 用量详情<template v-if="tokenEstimated(item.msg)"><span class="ai-token-est">(估算)</span></template></div>
|
||||
<div class="ai-token-popover-row">
|
||||
<span class="ai-token-popover-label">输入(未命中,全价)</span>
|
||||
<span class="ai-token-popover-val">{{ formatTokens(tokenInOf(item.msg)) }} ({{ tokenInOf(item.msg) }})</span>
|
||||
@@ -868,6 +939,10 @@ defineExpose({
|
||||
<span class="ai-token-popover-label">模型</span>
|
||||
<span class="ai-token-popover-val">{{ item.msg.model }}</span>
|
||||
</div>
|
||||
<div v-if="tokenEstimated(item.msg)" class="ai-token-popover-row">
|
||||
<span class="ai-token-popover-label">用量说明</span>
|
||||
<span class="ai-token-popover-val">prompt_tokens 缺失,按估算展示</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1066,6 +1141,38 @@ defineExpose({
|
||||
background: var(--df-bg-card-hover);
|
||||
}
|
||||
|
||||
/* ── JSON 工具结果折叠防御(对齐 miniapp .msg-toolresult 视觉:灰 #b0b0b0 / 11px /
|
||||
等宽 / max-height 80px 裁剪):assistant content 为纯 JSON 工具结果时弱化为折叠块,
|
||||
不渲染 markdown 大气泡刷屏。默认 max-height 80px + overflow-y:auto 可滚动,
|
||||
点击整块切换 --expanded(移除高度限制)展开/收起完整内容。 ── */
|
||||
.ai-msg-json {
|
||||
max-height: 80px;
|
||||
overflow-y: auto;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--df-radius);
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: #b0b0b0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.ai-msg-json pre {
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.ai-msg-json--expanded {
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
.ai-msg-json:hover {
|
||||
border-color: var(--df-accent);
|
||||
}
|
||||
|
||||
/* ── Input Augmentation: 用户气泡内 mention chip(圆角徽标,复用 ChatInput .ai-skill-chip-name 风格) ──
|
||||
用户气泡背景 = --df-accent(主题强调色,如深色蓝/紫),chip 用半透明白底+深字 保证对比度;
|
||||
按 kind 区分颜色变体(project/task/idea/skill),与 @ mention 浮层 .ai-mention-item-type--* 视觉呼应。 */
|
||||
@@ -1261,6 +1368,11 @@ defineExpose({
|
||||
.ai-token-sep {
|
||||
opacity: 0.5;
|
||||
}
|
||||
/* G4.3:估算值标注(估算非真实,弱化 dim + warning 色调,不喧宾夺主) */
|
||||
.ai-token-est {
|
||||
color: var(--df-warning, #f0c75e);
|
||||
opacity: 0.85;
|
||||
}
|
||||
/* 详情面板:复用 TopBar popout 风格(absolute / right 0 / bg-card / border / shadow) */
|
||||
.ai-token-popover {
|
||||
position: absolute;
|
||||
|
||||
@@ -72,8 +72,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Markdown 渲染(marked + DOMPurify + 代码高亮) -->
|
||||
<div v-else-if="isMarkdown" class="preview-md ai-md" v-html="renderedMd"></div>
|
||||
<!-- Markdown 渲染(marked + DOMPurify + 代码高亮;mermaid 块渲染后转成图) -->
|
||||
<div v-else-if="isMarkdown" ref="previewMdRef" class="preview-md ai-md" v-html="renderedMd"></div>
|
||||
|
||||
<!-- 文本/代码(highlight.js 语法高亮 + 行号;非 diff 模式时显示) -->
|
||||
<div v-else class="preview-code-scroll">
|
||||
@@ -87,7 +87,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed, onUnmounted } from 'vue'
|
||||
import { ref, watch, computed, onUnmounted, nextTick } from 'vue'
|
||||
import { moduleApi } from '@/api/module'
|
||||
import hljs from 'highlight.js/lib/common'
|
||||
import { useMarkdown, useRendered } from '@/composables/useMarkdown'
|
||||
@@ -127,6 +127,14 @@ const showDiff = ref(false)
|
||||
const diffContent = ref('')
|
||||
const diffLoading = ref(false)
|
||||
|
||||
/** 请求序号守卫:loadFile/loadDiff 各自单调递增,await 返回后校验仍是"最新 seq"才写状态,
|
||||
* 否则丢弃(快速切文件/切视图时旧响应晚到不覆盖新内容)。
|
||||
* 注:loadFile 与 loadDiff 分用两个计数器 —— 若共用一个,loadDiff 自增会让在途的 loadFile
|
||||
* 变为 stale,其 finally 不再复位 loading,导致内容加载态卡死。分开后各自独立互不干扰;
|
||||
* 文件切换(watch)时额外自增 diffReqSeq,使在途 diff 请求对旧文件失效。 */
|
||||
let fileReqSeq = 0 // loadFile 请求序号(单调递增)
|
||||
let diffReqSeq = 0 // loadDiff 请求序号(单调递增)
|
||||
|
||||
interface DiffLine {
|
||||
type: 'add' | 'del' | 'ctx' | 'hdr'
|
||||
prefix: string
|
||||
@@ -179,14 +187,17 @@ function toggleDiff() {
|
||||
|
||||
async function loadDiff() {
|
||||
if (!props.moduleId || !props.filePath) return
|
||||
const seq = ++diffReqSeq // 捕获本次请求序号
|
||||
diffLoading.value = true
|
||||
try {
|
||||
const res = await moduleApi.getModuleFileDiff(props.moduleId, props.filePath)
|
||||
if (seq !== diffReqSeq) return // 旧响应晚到,丢弃不覆盖
|
||||
diffContent.value = res.diff || ''
|
||||
} catch {
|
||||
if (seq !== diffReqSeq) return
|
||||
diffContent.value = ''
|
||||
} finally {
|
||||
diffLoading.value = false
|
||||
if (seq === diffReqSeq) diffLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,6 +233,48 @@ const isMarkdown = computed(() => {
|
||||
/** Markdown 渲染(复用 useRendered,含 marked + DOMPurify + 代码高亮)。 */
|
||||
const { rendered: renderedMd, ensureLoaded: ensureMdLoaded } = useRendered(() => content.value)
|
||||
|
||||
// ═══ Mermaid 渲染:markdown 里的 ```mermaid 块渲染为图(按需动态 import,不增主 bundle)═══
|
||||
const previewMdRef = ref<HTMLElement | null>(null)
|
||||
let mermaidInstance: any = null
|
||||
let mermaidSeq = 0
|
||||
async function renderMermaidBlocks() {
|
||||
const el = previewMdRef.value
|
||||
if (!el) return
|
||||
const targetPath = props.filePath // 捕获渲染目标文件,防跨文件滞后注入
|
||||
const blocks = el.querySelectorAll<HTMLElement>('pre code.language-mermaid')
|
||||
if (blocks.length === 0) return
|
||||
if (!mermaidInstance) {
|
||||
const mod = await import('mermaid')
|
||||
mermaidInstance = mod.default
|
||||
// securityLevel 'strict':mermaid 自行转义渲染输出(禁 raw HTML/URL 注入),防 XSS。
|
||||
// 代价:node 内嵌 HTML label(如 <br/>/HTML 实体)在 'strict' 下被当纯文本显示,
|
||||
// 原 'loose' 能渲染的这类图例会回归为文本(安全优先,属预期收紧)。
|
||||
mermaidInstance.initialize({ startOnLoad: false, theme: 'dark', securityLevel: 'strict' })
|
||||
}
|
||||
// 懒加载 mermaid 期间文件可能已切换(旧 DOM 已卸载),直接丢弃本轮
|
||||
if (props.filePath !== targetPath) return
|
||||
for (const block of Array.from(blocks)) {
|
||||
if (block.closest('.mermaid-rendered')) continue
|
||||
const code = (block.textContent ?? '').trim()
|
||||
if (!code) continue
|
||||
try {
|
||||
const { svg } = await mermaidInstance.render(`mermaid-file-${++mermaidSeq}`, code)
|
||||
if (props.filePath !== targetPath) return // 渲染期间文件已切换,丢弃旧图不注入
|
||||
const holder = document.createElement('div')
|
||||
holder.className = 'mermaid-rendered'
|
||||
holder.innerHTML = svg
|
||||
block.closest('pre')?.replaceWith(holder)
|
||||
} catch (e) {
|
||||
if (props.filePath !== targetPath) return
|
||||
console.error('[FilePreview] mermaid 渲染失败:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
watch(renderedMd, async () => {
|
||||
await nextTick()
|
||||
await renderMermaidBlocks()
|
||||
}, { immediate: true })
|
||||
|
||||
const isImage = computed(() => {
|
||||
if (!props.filePath) return false
|
||||
const lower = props.filePath.toLowerCase()
|
||||
@@ -230,6 +283,7 @@ const isImage = computed(() => {
|
||||
|
||||
/** 拉文件内容 + 高亮渲染。 */
|
||||
async function loadFile() {
|
||||
const seq = ++fileReqSeq // 捕获本次请求序号(文件切换会使旧请求失效)
|
||||
if (!props.filePath) {
|
||||
content.value = ''
|
||||
htmlContent.value = ''
|
||||
@@ -248,6 +302,7 @@ async function loadFile() {
|
||||
}
|
||||
try {
|
||||
const res = await moduleApi.readModuleFile(props.moduleId, props.filePath)
|
||||
if (seq !== fileReqSeq) return // 旧响应晚到,丢弃不覆盖新文件内容
|
||||
fileSize.value = res.size
|
||||
truncated.value = res.truncated
|
||||
isBinary.value = res.is_binary
|
||||
@@ -261,10 +316,11 @@ async function loadFile() {
|
||||
htmlContent.value = ''
|
||||
try {
|
||||
const { convertFileSrc } = await import('@tauri-apps/api/core')
|
||||
if (seq !== fileReqSeq) return // import 期间文件已切换,不写入旧图
|
||||
const abs = joinPath(props.moduleRootPath, props.filePath)
|
||||
imageUrl.value = convertFileSrc(abs)
|
||||
} catch {
|
||||
imageUrl.value = null
|
||||
if (seq === fileReqSeq) imageUrl.value = null
|
||||
}
|
||||
} else {
|
||||
content.value = res.content
|
||||
@@ -283,9 +339,10 @@ async function loadFile() {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (seq !== fileReqSeq) return // 旧请求报错也不覆盖新状态
|
||||
error.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (seq === fileReqSeq) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,8 +378,10 @@ function gitStatusLabel(status: string): string {
|
||||
}
|
||||
|
||||
watch(() => [props.moduleId, props.filePath], () => {
|
||||
diffReqSeq++ // 文件切换,使在途 diff 请求失效(旧文件 diff 晚到不写入)
|
||||
showDiff.value = false
|
||||
diffContent.value = ''
|
||||
diffLoading.value = false
|
||||
loadFile()
|
||||
}, { immediate: true })
|
||||
|
||||
@@ -438,6 +497,14 @@ onUnmounted(() => {
|
||||
border-radius: var(--df-radius-sm, 4px);
|
||||
}
|
||||
|
||||
/* Markdown 视图:独立滚动容器(preview-body 是 overflow:hidden,不设 overflow 会被裁剪无法滚) */
|
||||
.preview-md {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.preview-code {
|
||||
margin: 0;
|
||||
padding: 14px 16px;
|
||||
|
||||
Reference in New Issue
Block a user