重构: 前端 composable 拆分+详情页 Markdown 渲染+流式 memo+状态同步+bug 修复

- composables/useMarkdown 抽(AiChat 复用)+ai/* 流式核心+useAiSend catch 回滚 user msg(B-21)+useAiStream 超时文案(B-03)+useAiConversations 切换 token(FR-R1)
- views/TaskDetail 新建(F-02)+描述 md+mdReady 响应式+字段同行布局(B-24/31)
- views/ProjectDetail 多选审批 UI(F-01)+描述 md+mdReady+字段同行(B-25/31)
- views/Ideas+Knowledge 描述 md+mdReady(B-25)
- views/Tasks 项目切换联动(B-29)+task 详情跳转
- components/AiChat toast 失败提示(B-20)+流式块级 memo(ARC-08)+clean UI(AR-7)+stop 兜底(AR-5)
- components/ToolCard 审批卡片可读化(AR-3)
- stores useAiStore barrel(ARC-04)+del settings mock(ARC-01)+approve options(R-PD-5)
- i18n aiTool/nav 中英对称(AR-3/9)+删 nav.decisions 死链(ARC-02)
- router /tasks/:id(F-02)+global.css selection 可见(R-260615-01)+api task detail+App 删 decisions nav
This commit is contained in:
2026-06-15 05:14:56 +08:00
parent 2de0c6ecb7
commit 80b9243528
26 changed files with 997 additions and 207 deletions

View File

@@ -0,0 +1,74 @@
//! Markdown 渲染 composable — 抽自 src/components/AiChat.vue
//!
//! 背景:TaskDetail 任务描述需 Markdown 渲染(## 标题 / - 列表),原渲染逻辑只在
//! AiChat.vue 内联,且与流式核心(splitBlocks/parseBlock/renderStreamingMd)共享
//! _marked/_purify/mdReady。抽出为 composable 复用,AiChat 流式核心保持原状不动。
//!
//! 设计要点:
//! - **模块级单例**:_marked/_purify/mdReady/_mdCache 放模块作用域,所有调用方共享
//! 同一份渲染器实例与缓存。AiChat 的 loadMarkdown() 与流式 parseBlock 共用此实例,
//! 行为与抽前完全一致。
//! - **XSS 防护**:renderMd 输出经 DOMPurify.sanitize,可安全 v-html。
//! - **块级 memo**:历史/完成态整段缓存(_mdCache),文本不变命中跳过 marked 全量解析。
//! - **未就绪兜底**:marked 未加载完成时 escapeHtml + <br> 降级(纯文本安全)。
//!
//! 行为零变化说明:本文件函数体从 AiChat.vue 原样移出,逻辑未改动。
//! AiChat.vue 流式函数(splitBlocks/parseBlock/parseBlockNoCache/renderStreamingMd/
//! scheduleStreamParse)继续调用 useMarkdown 暴露的 mdReady/escapeFallback,
//! 渲染结果不变。
import { ref } from 'vue'
// ═══ 模块级单例:AiChat 流式核心与历史 renderMd 共享同一份渲染器/缓存 ═══
let _marked: typeof import('marked')['marked'] | null = null
let _purify: typeof import('dompurify')['default'] | null = null
const mdReady = ref(false)
// 历史消息整段缓存(文本不变,重渲染命中省去 marked+sanitize 全量解析)
const _mdCache = new Map<string, string>()
export function useMarkdown() {
/** 异步加载 marked + DOMPurify;加载完成后 mdReady 翻转触发重渲染 */
async function loadMarkdown() {
const [{ marked }, dp] = await Promise.all([import('marked'), import('dompurify')])
marked.setOptions({ breaks: true, gfm: true })
_marked = marked
_purify = dp.default
_mdCache.clear() // 渲染器就绪,清兜底缓存触发完整重渲染
mdReady.value = true
}
return {
mdReady,
loadMarkdown,
renderMd,
// 暴露给 AiChat 流式核心(parseBlock/renderStreamingMd)复用,保持单例共享
escapeFallback,
getMarked: () => _marked,
getPurify: () => _purify,
// 仅 AiChat 流式块级 memo 用(独立缓存,不与历史 _mdCache 混)
clearHistoryCache: () => _mdCache.clear(),
}
}
function escapeHtml(text: string): string {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
/// marked 未就绪时的兜底渲染:HTML 转义 + 换行转 <br>(纯文本安全降级)
function escapeFallback(text: string): string {
return escapeHtml(text).replace(/\n/g, '<br>')
}
/// 历史/完成态:整段 marked + sanitize + 缓存(函数体原样移自 AiChat.vue)
function renderMd(text: string): string {
if (!text) return ''
if (!mdReady.value || !_marked || !_purify) {
return escapeFallback(text)
}
const cached = _mdCache.get(text)
if (cached !== undefined) return cached
const html = _purify.sanitize(_marked.parse(text) as string)
if (_mdCache.size > 200) _mdCache.clear()
_mdCache.set(text, html)
return html
}