Files
NotesAgentic/frontend/src/stores/theme.ts
T
saint f273fef235 fix(frontend): 修复 PR #18 审阅问题并补充回归测试
审阅意见逐项修复:

1. 主题包安装丢弃用户 CSS
   inspectThemePackage 之前只解析 YAML 清单,ThemesView 安装时另外
   生成一套硬编码调色板,用户提供的 CSS 被整份丢掉。现在定义单文件
   格式(YAML 清单 + `---` + CSS),parseThemePackage 取出真实 CSS
   并原样安装;CSS 安全校验提前到预览阶段;按内容识别并拒绝 ZIP。

2. 主题恢复竞态导致页面无 data-theme
   initTheme 之前没有 await loadCustomThemes,自定义主题还没进
   allThemes,applyTheme 找不到主题直接 return。现在先同步落一个
   内置主题兜底(不写 localStorage,避免冲掉用户存的自定义主题 id),
   加载完成后再切到真正保存的那个;主题失效或列表加载失败时回退并
   通过 themeLoadWarning 告知用户,不再静默。

3. Trace 建树依赖事件相邻顺序
   后端真实顺序是 ModelCallStarted → ModelCallCompleted → Usage →
   ToolCall/ToolResult,工具在模型调用完成后才执行且并发跑,相邻性
   不可用。改为按 model_call_id / parent_model_call_id / tool_call_id
   关联;ToolResult 回填 ToolCall 的状态与耗时,结束后不再显示
   running;SSE 断点恢复的孤立事件退回顶层而不是丢弃。

4. Trace 叶子节点无法查看数据
   行的 click 是 `children.length && toggleExpand`,而详情 v-if 又
   要求 `children.length === 0`,两个条件互斥。拆成 expandedNodes
   与 detailNodes 两个状态集合;展开箭头改为独立按钮,行支持键盘
   与 aria-expanded;引用节点补「定位」按钮。同时修正 Usage 卡片
   字段(后端只发累计 token_usage)。

5. 引用定位逻辑三处重复且各自有缺陷
   抽出 navigateToCitation(依赖注入,可独立测试)+ useCitationNavigation。
   调用顺序固化:必须先 await loadFile 再 highlightBlock,否则
   editor store 的 loadFile 末尾会把高亮清掉;loadFile 失败时不跳转。
   AgentView / ChatView / AppShell 统一走这一处。

6. 插件命令 UI 重复实现
   抽出 PluginCommandPanel 复用 PluginMcpPanel 的 schema 驱动表单,
   删除 PluginsView 里的劣化副本。effect 现在真的执行 navigate /
   refresh(此前只拼成文本显示);补上必填校验与布尔字段初始值,
   修正「显示否但不提交该键」的不一致。

补充回归测试 64 项(相关 spec 由 25 项增至 89 项),并对 2、3、4 三项
缺陷做了变异验证:把修复回退成原写法后对应测试确实失败。
涉及 traceService / theme store / themePackageService / pluginCommandForm /
useCitationNavigation / TraceTimeline,其中后三个为新增文件。

vue-tsc -b、vitest(32 文件 182 项)、vite build 全部通过。
2026-09-05 10:04:17 +08:00

326 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { defineStore } from 'pinia'
import { ref, computed, watch } from 'vue'
import type { ThemeConfig, ThemeManifest, InstalledTheme, ThemePackageInspection } from '@/contracts'
import * as themePkg from '@/services/themePackageService'
const builtinThemes: ThemeConfig[] = [
{ theme_id: 'light', name: '浅色', version: '1.0.0', description: '默认浅色主题', is_dark: false, builtin: true, code_theme: 'github-light' },
{ theme_id: 'dark', name: '深色', version: '1.0.0', description: '默认深色主题', is_dark: true, builtin: true, code_theme: 'github-dark' },
{ theme_id: 'sepia', name: '护眼', version: '1.0.0', description: '护眼暖色调', is_dark: false, builtin: true, code_theme: 'github-light' },
]
export type CodeBlockThemePreference = 'auto' | 'github-light' | 'github-dark'
function isCodeBlockThemePreference(value: unknown): value is CodeBlockThemePreference {
return value === 'auto' || value === 'github-light' || value === 'github-dark'
}
const builtinToInstalled = (t: ThemeConfig): InstalledTheme => ({
theme_id: t.theme_id,
name: t.name,
version: t.version,
author: 'NotesAgent 团队',
description: t.description,
is_dark: t.is_dark,
builtin: true,
enabled: true,
manifest: {
theme_id: t.theme_id,
name: t.name,
version: t.version,
author: 'NotesAgent 团队',
description: t.description,
min_app_version: '0.1.0',
is_dark: t.is_dark,
css_entry: 'builtin',
},
code_theme: t.code_theme,
})
export const useThemeStore = defineStore('theme', () => {
const themes = ref<ThemeConfig[]>([...builtinThemes])
const installedCustomThemes = ref<InstalledTheme[]>([])
const currentThemeId = ref<string>('light')
const fontEditorSize = ref(15)
const fontEditorFamily = ref('system-ui')
const lineHeight = ref(1.7)
const codeBlockTheme = ref<CodeBlockThemePreference>('auto')
const isImporting = ref(false)
const importError = ref<string | null>(null)
// 主题恢复阶段的提示(保存的主题已卸载、主题列表加载失败等),与导入错误分开。
const themeLoadWarning = ref<string | null>(null)
const pendingInspection = ref<ThemePackageInspection | null>(null)
let appearanceHydrated = false
const allThemes = computed<InstalledTheme[]>(() => [
...builtinThemes.map(builtinToInstalled),
...installedCustomThemes.value,
])
const currentTheme = computed(() =>
allThemes.value.find((t) => t.theme_id === currentThemeId.value) || allThemes.value[0]
)
const isDark = computed(() => currentTheme.value?.is_dark || false)
const resolvedCodeBlockTheme = computed<'github-light' | 'github-dark'>(() => {
if (codeBlockTheme.value !== 'auto') return codeBlockTheme.value
return currentTheme.value?.code_theme ?? (isDark.value ? 'github-dark' : 'github-light')
})
/** 应用主题;返回 false 表示该主题当前不存在(未安装或还没加载完)。 */
function applyTheme(themeId: string, options: { persist?: boolean } = {}): boolean {
const theme = allThemes.value.find((t) => t.theme_id === themeId)
if (!theme) return false
currentThemeId.value = themeId
const root = document.documentElement
if (theme.builtin) {
if (theme.is_dark) {
root.setAttribute('data-theme', 'dark')
} else if (themeId === 'sepia') {
root.setAttribute('data-theme', 'sepia')
} else {
root.setAttribute('data-theme', 'light')
}
} else {
root.setAttribute('data-theme', themeId)
}
if (options.persist !== false) localStorage.setItem('theme', themeId)
return true
}
function isBuiltinThemeId(themeId: string): boolean {
return builtinThemes.some((t) => t.theme_id === themeId)
}
function systemThemeId(): string {
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
/**
* 恢复外观与主题。
*
* 自定义主题要等 listInstalledThemes 回来才存在于 allThemes 里,
* 所以恢复已保存主题必须在 loadCustomThemes 之后 —— 否则 applyTheme
* 找不到主题直接 return,页面会停在没有 data-theme 的裸状态。
* 首屏也不能干等接口:先同步落一个内置主题兜底(不写 localStorage
* 以免把用户存的自定义主题 id 冲掉),加载完成后再切到真正保存的那个。
*/
async function initTheme(): Promise<void> {
const savedAppearance = localStorage.getItem('editor-appearance')
if (savedAppearance) {
try {
const value = JSON.parse(savedAppearance) as { size?: number; family?: string; lineHeight?: number; codeBlockTheme?: unknown }
if (value.size) fontEditorSize.value = value.size
if (value.family) fontEditorFamily.value = value.family
if (value.lineHeight) lineHeight.value = value.lineHeight
if (isCodeBlockThemePreference(value.codeBlockTheme)) codeBlockTheme.value = value.codeBlockTheme
} catch { localStorage.removeItem('editor-appearance') }
}
appearanceHydrated = true
persistAppearance()
const saved = localStorage.getItem('theme')
const fallback = systemThemeId()
applyTheme(saved && isBuiltinThemeId(saved) ? saved : fallback, { persist: false })
await loadCustomThemes()
if (!saved) {
applyTheme(fallback)
return
}
if (applyTheme(saved)) return
// 保存的主题已被卸载,或主题列表加载失败:回退并清掉失效记录。
themeLoadWarning.value = `主题「${saved}」已不可用,已回退到默认主题。`
localStorage.removeItem('theme')
applyTheme(fallback)
}
async function loadCustomThemes() {
try {
const list = await themePkg.listInstalledThemes()
installedCustomThemes.value = list
themes.value = [...builtinThemes, ...list.map((t) => ({
theme_id: t.theme_id,
name: t.name,
version: t.version,
description: t.description ?? '',
is_dark: t.is_dark,
builtin: false,
author: t.author,
code_theme: t.code_theme,
}))]
themeLoadWarning.value = null
} catch (error) {
// 只保留内置主题,但要让用户知道自定义主题这次没加载上。
themeLoadWarning.value = error instanceof Error
? `自定义主题加载失败:${error.message}`
: '自定义主题加载失败。'
}
}
function toggleTheme() {
applyTheme(isDark.value ? 'light' : 'dark')
}
function resetToDefault() {
applyTheme('light')
fontEditorSize.value = 15
fontEditorFamily.value = 'system-ui'
lineHeight.value = 1.7
codeBlockTheme.value = 'auto'
}
const persistAppearance = () => localStorage.setItem('editor-appearance', JSON.stringify({
size: fontEditorSize.value,
family: fontEditorFamily.value,
lineHeight: lineHeight.value,
codeBlockTheme: codeBlockTheme.value,
}))
async function inspectThemePackage(packageData: string): Promise<ThemePackageInspection> {
isImporting.value = true
importError.value = null
try {
const result = await themePkg.inspectThemePackage(packageData)
pendingInspection.value = result
if (!result.compatible) {
importError.value = result.warnings[0] ?? '主题包不兼容'
}
return result
} catch (error) {
importError.value = error instanceof Error ? error.message : '导入失败'
throw error
} finally {
isImporting.value = false
}
}
async function installThemeFromInspection(manifest: ThemeManifest, cssContent: string) {
isImporting.value = true
importError.value = null
try {
const installed = await themePkg.installTheme(manifest, cssContent)
const idx = installedCustomThemes.value.findIndex((t) => t.theme_id === installed.theme_id)
if (idx >= 0) installedCustomThemes.value[idx] = installed
else installedCustomThemes.value.push(installed)
const themeConfig: ThemeConfig = {
theme_id: installed.theme_id,
name: installed.name,
version: installed.version,
description: installed.description ?? '',
is_dark: installed.is_dark,
builtin: false,
author: installed.author,
code_theme: installed.code_theme,
}
const existingIdx = themes.value.findIndex((t) => t.theme_id === installed.theme_id)
if (existingIdx >= 0) themes.value[existingIdx] = themeConfig
else themes.value.push(themeConfig)
pendingInspection.value = null
return installed
} catch (error) {
importError.value = error instanceof Error ? error.message : '安装失败'
throw error
} finally {
isImporting.value = false
}
}
async function uninstallTheme(themeId: string) {
await themePkg.uninstallTheme(themeId)
installedCustomThemes.value = installedCustomThemes.value.filter((t) => t.theme_id !== themeId)
themes.value = themes.value.filter((t) => t.theme_id !== themeId || t.builtin)
if (currentThemeId.value === themeId) {
applyTheme('light')
}
}
async function installCommunityTheme(themeId: string) {
isImporting.value = true
importError.value = null
try {
const installed = await themePkg.installCommunityTheme(themeId)
const idx = installedCustomThemes.value.findIndex((t) => t.theme_id === installed.theme_id)
if (idx >= 0) installedCustomThemes.value[idx] = installed
else installedCustomThemes.value.push(installed)
const themeConfig: ThemeConfig = {
theme_id: installed.theme_id,
name: installed.name,
version: installed.version,
description: installed.description ?? '',
is_dark: installed.is_dark,
builtin: false,
author: installed.author,
code_theme: installed.code_theme,
}
const existingIdx = themes.value.findIndex((t) => t.theme_id === installed.theme_id)
if (existingIdx >= 0) themes.value[existingIdx] = themeConfig
else themes.value.push(themeConfig)
return installed
} catch (error) {
importError.value = error instanceof Error ? error.message : '安装失败'
throw error
} finally {
isImporting.value = false
}
}
function isThemeInstalled(themeId: string): boolean {
return themes.value.some((t) => t.theme_id === themeId)
}
watch(resolvedCodeBlockTheme, (theme) => {
document.documentElement.setAttribute('data-code-theme', theme)
}, { immediate: true })
watch(fontEditorSize, (v) => {
document.documentElement.style.setProperty('--font-editor-size', `${v}px`)
if (appearanceHydrated) persistAppearance()
}, { immediate: true })
watch(lineHeight, (v) => {
document.documentElement.style.setProperty('--font-editor-line-height', String(v))
if (appearanceHydrated) persistAppearance()
}, { immediate: true })
watch(fontEditorFamily, (v) => {
document.documentElement.style.setProperty('--font-editor-sans', v)
if (appearanceHydrated) persistAppearance()
}, { immediate: true })
watch(codeBlockTheme, () => {
if (appearanceHydrated) persistAppearance()
})
return {
themes,
installedCustomThemes,
currentThemeId,
currentTheme,
isDark,
fontEditorSize,
fontEditorFamily,
lineHeight,
codeBlockTheme,
resolvedCodeBlockTheme,
isImporting,
importError,
themeLoadWarning,
pendingInspection,
allThemes,
applyTheme,
initTheme,
toggleTheme,
resetToDefault,
loadCustomThemes,
inspectThemePackage,
installThemeFromInspection,
uninstallTheme,
installCommunityTheme,
isThemeInstalled,
}
})