From 49c856a69cd1870735d0241c4e26e11d1c50235e Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Sun, 30 Aug 2026 22:59:58 +0800 Subject: [PATCH] =?UTF-8?q?chore(frontend):=20=E8=A1=A5=E5=85=85=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E4=B8=8E=E6=9C=8D=E5=8A=A1=E8=BE=B9=E7=95=8C=E6=B3=A8?= =?UTF-8?q?=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/features/editor/VisualMarkdownEditor.vue | 3 +++ frontend/src/services/agentService.ts | 2 ++ frontend/src/services/apiClient.ts | 2 ++ frontend/src/services/sseClient.ts | 3 +++ frontend/src/services/workspaceService.ts | 7 +++++-- frontend/src/stores/agent.ts | 2 ++ frontend/src/stores/chat.ts | 3 +++ frontend/src/stores/editor.ts | 4 ++++ frontend/src/stores/theme.ts | 2 ++ frontend/src/stores/workspace.ts | 1 + frontend/src/utils/markdown.ts | 4 ++++ 11 files changed, 31 insertions(+), 2 deletions(-) diff --git a/frontend/src/features/editor/VisualMarkdownEditor.vue b/frontend/src/features/editor/VisualMarkdownEditor.vue index ca28e6d..c97d950 100644 --- a/frontend/src/features/editor/VisualMarkdownEditor.vue +++ b/frontend/src/features/editor/VisualMarkdownEditor.vue @@ -39,6 +39,7 @@ type ToolbarCommand = 'bold' | 'italic' | 'ordered-list' | 'bullet-list' | 'inli function runCommand(command: ToolbarCommand) { const editor = crepe?.editor if (!editor) return + // 顶部工具栏复用 Milkdown 命令,因此选区与浮动工具栏共享同一文档事务。 const actions = { bold: callCommand(toggleStrongCommand.key), italic: callCommand(toggleEmphasisCommand.key), @@ -55,6 +56,7 @@ function runCommand(command: ToolbarCommand) { function applyLink() { if (!crepe) return + // TODO(editor): 用受控 Element Plus 对话框替换 prompt,补充 URL 校验和键盘焦点管理。 const href = window.prompt('请输入链接地址', 'https://')?.trim() if (!href) return @@ -162,6 +164,7 @@ onMounted(async () => { crepe.editor.use(fontSizeMarkdownPlugin) crepe.on((listener) => { listener.markdownUpdated((_ctx, markdown, previousMarkdown) => { + // 忽略编辑器初始化/回显事件,防止无内容变化时触发自动保存循环。 if (markdown === previousMarkdown || markdown === editorStore.content) return editorStore.updateContent(markdown) editorStore.scheduleAutoSave(settingsStore.autoSaveInterval) diff --git a/frontend/src/services/agentService.ts b/frontend/src/services/agentService.ts index b610ced..d6a5f41 100644 --- a/frontend/src/services/agentService.ts +++ b/frontend/src/services/agentService.ts @@ -3,6 +3,7 @@ import { SseClient } from './sseClient' import type { AgentRun, AgentEvent, ApiAgentRun, OperationResponse, PageMeta, ToolDefinition, PermissionRequest } from '@/contracts' function toAgentRun(run: ApiAgentRun): AgentRun { + // API 的 token_usage 是累计值,UI 模型预留了输入/输出拆分字段。 return { run_id: run.run_id, status: run.status, @@ -67,6 +68,7 @@ export function streamAgentEvents( onOpen?: () => void } ): SseClient { + // 将通用 SSE 包装成领域事件,Store 无需了解传输层 envelope。 const client = new SseClient({ url: `/api/agent/runs/${runId}/events`, method: 'GET', diff --git a/frontend/src/services/apiClient.ts b/frontend/src/services/apiClient.ts index df13010..244e77d 100644 --- a/frontend/src/services/apiClient.ts +++ b/frontend/src/services/apiClient.ts @@ -1,5 +1,6 @@ import type { ApiError, ErrorResponse } from '@/contracts' +// 所有 HTTP 请求都经过此边界,以统一地址、请求追踪和错误契约。 const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? import.meta.env.VITE_API_BASE ?? '' export function resolveApiUrl(path: string): string { @@ -63,6 +64,7 @@ async function request(path: string, options: RequestOptions = {}): Promise { } export function moveFile(sourcePath: string, targetPath: string): Promise { + // Mock 文件树由 Store 同步更新;真实实现必须在 Host 侧执行原子移动。 + void sourcePath + void targetPath return Promise.resolve() } diff --git a/frontend/src/stores/agent.ts b/frontend/src/stores/agent.ts index be8be15..4b2aae5 100644 --- a/frontend/src/stores/agent.ts +++ b/frontend/src/stores/agent.ts @@ -53,6 +53,7 @@ export const useAgentStore = defineStore('agent', () => { } function processEvent(event: AgentEvent) { + // 服务端会先回放历史再发送实时事件,以 run_id + sequence 去重保证幂等。 if (events.value.some((item) => item.run_id === event.run_id && item.sequence === event.sequence)) return events.value.push(event) events.value.sort((a, b) => a.sequence - b.sequence) @@ -100,6 +101,7 @@ export const useAgentStore = defineStore('agent', () => { } function subscribe(runId: string) { + // 任一时刻只保留当前运行的事件流,防止切换详情后旧事件污染新页面。 eventStream?.cancel() isRunning.value = true error.value = null diff --git a/frontend/src/stores/chat.ts b/frontend/src/stores/chat.ts index c740ec3..fc511a6 100644 --- a/frontend/src/stores/chat.ts +++ b/frontend/src/stores/chat.ts @@ -16,6 +16,8 @@ export const useChatStore = defineStore('chat', () => { const selectedModel = ref('mock-1') let sseClient: SseClient | null = null + // TODO(chat): 会话持久化接口完成后移除 mockConversations/mockMessages 数据源。 + const activeConversation = computed(() => conversations.value.find((c) => c.conversation_id === activeConversationId.value) || null ) @@ -56,6 +58,7 @@ export const useChatStore = defineStore('chat', () => { inputText.value = '' isStreaming.value = true + // 先插入占位消息,随后将 SSE 增量原位合并,避免每个 token 重建消息列表。 const aiMsg: ChatMessage = { message_id: `msg-${Date.now() + 1}`, conversation_id: conversationId, diff --git a/frontend/src/stores/editor.ts b/frontend/src/stores/editor.ts index 0c60595..0c64cea 100644 --- a/frontend/src/stores/editor.ts +++ b/frontend/src/stores/editor.ts @@ -47,6 +47,7 @@ export const useEditorStore = defineStore('editor', () => { async function save() { if (!currentFilePath.value) return if (pendingSave) return pendingSave + // 保存路径与正文都取快照;请求完成时用户可能已继续输入或切换文件。 const targetPath = currentFilePath.value const snapshot = content.value saveStatus.value = 'saving' @@ -82,6 +83,7 @@ export const useEditorStore = defineStore('editor', () => { if (saveStatus.value === 'dirty' || saveStatus.value === 'save_failed') { throw new Error('当前文件保存失败,已阻止切换以避免内容丢失。') } + // 版本号使较慢的旧读取不能覆盖用户后选择的新文件。 const version = ++loadVersion const previousStatus = saveStatus.value saveStatus.value = 'saving' @@ -117,6 +119,8 @@ export const useEditorStore = defineStore('editor', () => { } } + // TODO(editor): 桌面文件监听接入后提供冲突对比/合并界面,而非只阻止切换。 + function closeFile() { loadVersion++ if (saveTimer) clearTimeout(saveTimer) diff --git a/frontend/src/stores/theme.ts b/frontend/src/stores/theme.ts index ffd0f23..73481b3 100644 --- a/frontend/src/stores/theme.ts +++ b/frontend/src/stores/theme.ts @@ -49,6 +49,7 @@ export const useThemeStore = defineStore('theme', () => { } function initTheme() { + // 先恢复外观再开放 watch 持久化,避免 immediate watcher 覆盖本地设置。 const savedAppearance = localStorage.getItem('editor-appearance') if (savedAppearance) { try { @@ -90,6 +91,7 @@ export const useThemeStore = defineStore('theme', () => { })) watch(resolvedCodeBlockTheme, (theme) => { + // CSS 与 Shiki 共用该属性,确保代码块背景和 token 配色始终成套切换。 document.documentElement.setAttribute('data-code-theme', theme) }, { immediate: true }) diff --git a/frontend/src/stores/workspace.ts b/frontend/src/stores/workspace.ts index de6f5d2..d72c4a5 100644 --- a/frontend/src/stores/workspace.ts +++ b/frontend/src/stores/workspace.ts @@ -118,6 +118,7 @@ export const useWorkspaceStore = defineStore('workspace', () => { function renamePath(oldPath: string, newPath: string, newName: string) { const node = findNodeByPath(fileTree.value, oldPath) if (!node) return + // 文件夹重命名必须同步改写所有后代、标签页和当前文件路径。 const updateNodePath = (current: FileNode) => { if (current.path === oldPath) current.name = newName if (current.path === oldPath || current.path.startsWith(`${oldPath}/`)) { diff --git a/frontend/src/utils/markdown.ts b/frontend/src/utils/markdown.ts index 6592fcc..3751939 100644 --- a/frontend/src/utils/markdown.ts +++ b/frontend/src/utils/markdown.ts @@ -16,6 +16,7 @@ import githubLight from '@shikijs/themes/github-light' marked.setOptions({ gfm: true, breaks: true }) +// Highlighter 是昂贵的单例;复用初始化 Promise,避免每个代码块重复加载语法与主题。 const highlighter = createHighlighterCore({ themes: [githubLight, githubDark], langs: [markdown, html, css, javascript, typescript, json, python, shell, sql], @@ -47,5 +48,8 @@ export async function renderMarkdown(source: string): Promise { code.parentElement?.replaceWith(fragment) } + // Markdown 可能来自模型或外部笔记,高亮完成后仍必须在最终出口统一净化。 return DOMPurify.sanitize(documentNode.body.innerHTML, { USE_PROFILES: { html: true } }) } + +// TODO(performance): 编辑器首屏稳定后评估将 Shiki 延迟加载或迁移到 Web Worker。