优化: 批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:
@@ -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