From ef961d322bc24c5a49efe5a9ff8f74bbbfc2f680 Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Sat, 5 Sep 2026 09:38:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=E5=AE=9E=E7=8E=B0=E4=B8=AD?= =?UTF-8?q?=E8=8B=B1=E6=96=87=E5=88=87=E6=8D=A2=E4=B8=8E=E6=8B=BC=E5=86=99?= =?UTF-8?q?=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + frontend/README.md | 6 +- .../src/components/common/CommandPalette.vue | 43 +++--- .../src/components/common/PrimarySidebar.vue | 25 ++-- .../components/common/SecondarySidebar.vue | 13 +- frontend/src/components/common/StatusBar.vue | 31 +++-- frontend/src/components/common/TitleBar.vue | 21 +-- frontend/src/features/agent/AgentView.vue | 51 +++---- frontend/src/features/agent/RunListPanel.vue | 7 +- frontend/src/features/agent/ToolOption.vue | 3 +- frontend/src/features/agent/labels.ts | 34 ++++- frontend/src/features/chat/ChatView.vue | 29 ++-- .../features/chat/ConversationListPanel.vue | 7 +- frontend/src/features/editor/EditorHeader.vue | 20 +-- .../src/features/editor/EditorPane.spec.ts | 15 ++ frontend/src/features/editor/EditorPane.vue | 6 +- .../editor/VisualMarkdownEditor.spec.ts | 16 +++ .../features/editor/VisualMarkdownEditor.vue | 128 ++++++++++-------- frontend/src/features/mcp/McpServersView.vue | 67 ++++----- frontend/src/features/media/MediaView.vue | 79 ++++++----- .../src/features/plugins/PluginMcpPanel.vue | 67 ++++----- frontend/src/features/plugins/PluginsView.vue | 21 +-- .../features/search/SearchFiltersPanel.vue | 16 ++- frontend/src/features/search/SearchView.vue | 23 ++-- .../features/settings/LocalModelSettings.vue | 55 ++++---- .../settings/ModelRoutingSettings.vue | 61 +++++---- .../src/features/settings/ProviderForm.vue | 47 +++---- .../settings/ProviderPresetSelector.vue | 9 +- .../features/settings/RequestJsonEditor.vue | 29 ++-- .../src/features/settings/SettingsView.vue | 53 ++++---- frontend/src/features/settings/UsageCard.vue | 31 +++-- frontend/src/features/skills/SkillsView.vue | 21 +-- .../src/features/tasks/TaskFiltersPanel.vue | 5 +- frontend/src/features/tasks/TasksView.vue | 21 +-- frontend/src/features/themes/ThemesView.vue | 21 +-- frontend/src/features/vault/VaultEntry.vue | 19 +-- .../src/features/workspace/FileTreePanel.vue | 19 +-- .../src/features/workspace/WorkspaceView.vue | 5 +- frontend/src/i18n.spec.ts | 41 ++++++ frontend/src/i18n.ts | 28 ++++ frontend/src/main.ts | 10 ++ frontend/src/router/index.ts | 23 +++- frontend/src/stores/settings.ts | 3 +- frontend/src/stores/theme.ts | 11 +- 44 files changed, 722 insertions(+), 519 deletions(-) create mode 100644 frontend/src/i18n.spec.ts create mode 100644 frontend/src/i18n.ts diff --git a/README.md b/README.md index f6ad01d..3867158 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ NotesAgent/ - 多模态:API 优先,未配置或响应无效时回退本地;`local_only` 禁止远程调用。任务、修订、事件、来源和回退原因写入 SQLite。 - 模型运行:默认 CPU,可选 CUDA 12.8 组件;固定模型 revision,按需启动独立子进程,交互检索优先排队,CUDA 初始化或显存失败时用同一冻结配置在 CPU 重试一次。 - 可观测性:输入、输出、缓存命中、推理 Token 与音频用量卡片;本地运行诊断保留最近 200 条,不保存正文、文件路径、密钥或异常全文。 +- 界面偏好:设置页可即时切换全局中文/英文界面,并控制由系统词典提供的编辑器拼写检查;偏好目前保存于 Web 端设备配置,后续由 Tauri 配置存储接管。 ## 本地模型 diff --git a/frontend/README.md b/frontend/README.md index 55acd8c..e0d7a0b 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -25,7 +25,7 @@ pnpm dev | `/extensions/mcp` | stdio、Streamable HTTP、旧 SSE Server 配置与工具发现 | | `/extensions/plugins` | Plugin Host、Command、Settings、Secret 与 MCP 状态 | | `/themes` | 内置 Design Token 主题和编辑器显示偏好 | -| `/settings` | Provider、模型路由、本地模型、CPU/CUDA 组件、请求 JSON、用量与诊断 | +| `/settings` | Provider、模型路由、本地模型、CPU/CUDA 组件、请求 JSON、用量与诊断;全局中英文和拼写检查设置 | ## 技术结构 @@ -41,6 +41,8 @@ pnpm dev 编辑器使用 Milkdown/Crepe 与 CodeMirror 6;Markdown 展示使用 marked、DOMPurify 和 Shiki。Provider logo 位于 `src/assets/providers`,授权与来源说明随目录保存。 +语言设置会即时更新主导航、页面标题和各功能页面,并同步更新文档与编辑器的 `lang`。拼写检查使用浏览器或桌面 WebView 提供的本地词典,开关会即时作用于可视化 Markdown、源码编辑器以及普通文本输入;JSON、密码等结构化或敏感输入保持关闭。 + ## 数据边界 - 笔记、附件、搜索历史、会话、任务、Trace、模型配置和多模态结果都通过 FastAPI 读写。 @@ -66,7 +68,7 @@ pnpm type-check pnpm build ``` -阶段 F 合并基线为 29 个测试文件、103 项测试通过,TypeScript 类型检查与 Vite 生产构建通过;构建仍有既有大 bundle 提示。产物位于 `dist`,不提交 Git。 +当前基线为 30 个测试文件、106 项测试通过,TypeScript 类型检查与 Vite 生产构建通过;构建仍有既有大 bundle 提示。产物位于 `dist`,不提交 Git。 ## 开发约定 diff --git a/frontend/src/components/common/CommandPalette.vue b/frontend/src/components/common/CommandPalette.vue index b5a99f6..f2efead 100644 --- a/frontend/src/components/common/CommandPalette.vue +++ b/frontend/src/components/common/CommandPalette.vue @@ -8,6 +8,7 @@ import * as workspaceService from '@/services/workspaceService' import * as pluginService from '@/services/pluginService' import type { PluginCommand, PluginCommandEffect } from '@/contracts' import { usePluginStore } from '@/stores/plugin' +import { t } from '@/i18n' const router = useRouter() const editorStore = useEditorStore() @@ -25,15 +26,15 @@ const selectionSnapshot = ref(null) interface Command { id: string; label: string; hint: string; run: () => void | Promise } const builtinCommands = computed(() => [ - { id: 'workspace', label: '打开工作区', hint: '导航', run: () => router.push('/workspace') }, - { id: 'search', label: '全局搜索', hint: '导航', run: () => router.push('/search') }, - { id: 'chat', label: '打开 AI 对话', hint: '导航', run: () => router.push('/chat') }, - { id: 'agent', label: '创建智能体运行', hint: '导航', run: () => router.push('/agent/runs') }, - { id: 'settings', label: '打开设置', hint: '导航', run: () => router.push('/settings') }, - { id: 'mode', label: `切换为${editorStore.mode === 'source' ? '写作' : '源码'}模式`, hint: '编辑器', run: () => editorStore.toggleMode() }, - { id: 'save', label: '保存当前笔记', hint: '编辑器', run: () => editorStore.save() }, - { id: 'theme', label: `切换为${themeStore.isDark ? '浅色' : '深色'}主题`, hint: '外观', run: () => themeStore.toggleTheme() }, - { id: 'new-note', label: '创建笔记', hint: '工作区', run: createNote }, + { id: 'workspace', label: t('打开工作区', 'Open workspace'), hint: t('导航', 'Navigation'), run: () => router.push('/workspace') }, + { id: 'search', label: t('全局搜索', 'Global search'), hint: t('导航', 'Navigation'), run: () => router.push('/search') }, + { id: 'chat', label: t('打开 AI 对话', 'Open AI chat'), hint: t('导航', 'Navigation'), run: () => router.push('/chat') }, + { id: 'agent', label: t('创建智能体运行', 'Create agent run'), hint: t('导航', 'Navigation'), run: () => router.push('/agent/runs') }, + { id: 'settings', label: t('打开设置', 'Open settings'), hint: t('导航', 'Navigation'), run: () => router.push('/settings') }, + { id: 'mode', label: editorStore.mode === 'source' ? t('切换为写作模式', 'Switch to writing mode') : t('切换为源码模式', 'Switch to source mode'), hint: t('编辑器', 'Editor'), run: () => editorStore.toggleMode() }, + { id: 'save', label: t('保存当前笔记', 'Save current note'), hint: t('编辑器', 'Editor'), run: () => editorStore.save() }, + { id: 'theme', label: themeStore.isDark ? t('切换为浅色主题', 'Switch to light theme') : t('切换为深色主题', 'Switch to dark theme'), hint: t('外观', 'Appearance'), run: () => themeStore.toggleTheme() }, + { id: 'new-note', label: t('创建笔记', 'Create note'), hint: t('工作区', 'Workspace'), run: createNote }, ]) const commands = computed(() => [ @@ -78,12 +79,12 @@ async function execute(command: Command | undefined) { try { await command.run() } catch (error) { - commandNotice.value = error instanceof Error ? error.message : '命令执行失败' + commandNotice.value = error instanceof Error ? error.message : t('命令执行失败', 'Command failed') } } async function createNote() { - const rawName = window.prompt('笔记名称')?.trim() + const rawName = window.prompt(t('笔记名称', 'Note name'))?.trim() if (!rawName) return const name = rawName.endsWith('.md') ? rawName : `${rawName}.md` const file = await workspaceService.createFile('/', name, `# ${rawName}\n\n`) @@ -97,7 +98,7 @@ async function loadPluginCommands() { try { pluginCommands.value = await pluginService.listPluginCommands('command_palette') } catch (error) { - commandError.value = error instanceof Error ? error.message : 'Plugin 命令加载失败' + commandError.value = error instanceof Error ? error.message : t('Plugin 命令加载失败', 'Failed to load plugin commands') } } @@ -109,7 +110,7 @@ async function executePluginCommand(command: PluginCommand) { if (hasRequiredArguments(command)) { pluginStore.selectPlugin(command.plugin_id) await router.push('/extensions/plugins') - commandNotice.value = '请在 Plugin 详情页填写参数后执行“' + command.title + '”。' + commandNotice.value = `${t('请在 Plugin 详情页填写参数后执行', 'Enter parameters on the Plugin details page, then run')} “${command.title}”.` return } const result = await pluginService.executePluginCommand(command.command_id, {}, { @@ -135,11 +136,11 @@ async function applyPluginEffect(effect: PluginCommandEffect) { if (effect.type === 'refresh') { if (effect.payload.scope === 'plugins') await pluginStore.loadPlugins() if (effect.payload.scope === 'commands') await loadPluginCommands() - commandNotice.value = '相关数据已刷新。' + commandNotice.value = t('相关数据已刷新。', 'Related data refreshed.') return } - if (effect.type === 'job') { commandNotice.value = '后台任务已创建:' + effect.payload.job_id; return } - commandNotice.value = 'Plugin 命令执行完成。' + if (effect.type === 'job') { commandNotice.value = t('后台任务已创建:', 'Background job created: ') + effect.payload.job_id; return } + commandNotice.value = t('Plugin 命令执行完成。', 'Plugin command completed.') } function handleKeydown(event: KeyboardEvent) { @@ -157,20 +158,20 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown)) - -
+ +
diff --git a/frontend/src/features/media/MediaView.vue b/frontend/src/features/media/MediaView.vue index 11325d7..93c67ed 100644 --- a/frontend/src/features/media/MediaView.vue +++ b/frontend/src/features/media/MediaView.vue @@ -2,6 +2,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue' import { useRoute } from 'vue-router' import { mediaService, createMediaSubmission, type MediaJob } from '@/services/mediaService' +import { localeTag, t } from '@/i18n' const route = useRoute() const submission = createMediaSubmission() @@ -11,6 +12,7 @@ const selected = ref(null) const file = ref(null) const reference = ref(null) const matchResult = ref('') +const terminologyPlaceholder = computed(() => t('{"错误术语": "正确术语"}', '{"incorrect term": "correct term"}')) const localOnly = ref(false) const diarization = ref(true) const terminology = ref('') @@ -18,17 +20,22 @@ const busy = ref(false) const error = ref('') const notice = ref('') const dirty = ref(false) -const title = ref('课堂转写') +const title = ref(t('课堂转写', 'Class transcript')) const player = ref(null) const position = ref(0) const speed = ref(1) const history = ref([]) let timer: ReturnType | undefined let stopped = false -const labels = {queued: '排队中', running: '转写中', processing: '处理中', completed: '已完成', failed: '失败', cancelled: '已取消'} +const labels = computed(() => ({queued: t('排队中', 'Queued'), running: t('转写中', 'Transcribing'), processing: t('处理中', 'Processing'), completed: t('已完成', 'Completed'), failed: t('失败', 'Failed'), cancelled: t('已取消', 'Cancelled')})) const speakers = computed(() => [...new Set(selected.value?.segments.map(s => s.speaker).filter((s): s is string => !!s) || [])]) const active = (job: MediaJob) => ['queued', 'running', 'processing'].includes(job.status) const stamp = (seconds: number) => `${Math.floor(seconds / 60).toString().padStart(2, '0')}:${Math.floor(seconds % 60).toString().padStart(2, '0')}` +const warningLabel = (warning: string) => ({ + DIARIZATION_UNAVAILABLE: t('当前无法分离说话人', 'Speaker identification is unavailable'), + WORD_TIMESTAMPS_UNAVAILABLE: t('未提供逐字时间戳', 'Word-level timestamps are unavailable'), + DIARIZATION_SEGMENT_LEVEL: t('说话人按音频段估计,同段多人或重叠发言需人工校对', 'Speakers are estimated per segment; multiple or overlapping speakers require manual correction'), +} as Record)[warning] || warning async function refresh() { try { @@ -38,7 +45,7 @@ async function refresh() { if (!stopped) timer = setTimeout(refresh, 2000) } async function choose(job: MediaJob) { - if (dirty.value && !window.confirm('当前校对尚未保存,切换后放弃修改?')) return + if (dirty.value && !window.confirm(t('当前校对尚未保存,切换后放弃修改?', 'The current corrections are unsaved. Discard them and switch?'))) return selected.value = JSON.parse(JSON.stringify(job)); dirty.value = false; history.value = [] } async function action(work: () => Promise) { @@ -52,7 +59,7 @@ async function submit() { let terms = {} if (terminology.value.trim()) { terms = JSON.parse(terminology.value) - if (!terms || typeof terms !== 'object' || Array.isArray(terms) || Object.values(terms).some(v => typeof v !== 'string')) throw new Error('术语表需要 JSON 对象,值为替换后的文本。') + if (!terms || typeof terms !== 'object' || Array.isArray(terms) || Object.values(terms).some(v => typeof v !== 'string')) throw new Error(t('术语表需要 JSON 对象,值为替换后的文本。', 'The terminology map must be a JSON object whose values are replacement text.')) } selected.value = await submission.submit(file.value!, {local_only: localOnly.value, diarization: diarization.value, terminology: terms}) @@ -65,10 +72,10 @@ async function purge() { if (!selected.value) return await action(async () => { const impact = await mediaService.impact(selected.value!.attachment_id) - if (!window.confirm(`${impact.message}\n将保留 ${impact.retained_note_ids.length} 篇已保存笔记。确定清理?`)) return + if (!window.confirm(`${impact.message}\n${t('将保留', 'Will retain')} ${impact.retained_note_ids.length} ${t('篇已保存笔记。确定清理?', 'saved notes. Continue cleanup?')}`)) return await mediaService.purge(selected.value!.attachment_id) selected.value = await mediaService.get(selected.value!.job_id) - dirty.value = false; history.value = []; notice.value = '附件与转写内容已清理' + dirty.value = false; history.value = []; notice.value = t('附件与转写内容已清理', 'Attachment and transcript content were removed') }) } async function compareSpeaker() { @@ -79,10 +86,10 @@ async function compareSpeaker() { const sample = await mediaService.upload(file.value!); temporary.push(sample.attachment_id) const known = await mediaService.upload(reference.value!); temporary.push(known.attachment_id) const result = await mediaService.match(sample.attachment_id, known.attachment_id, localOnly.value) - matchResult.value = `相似度 ${result.score.toFixed(3)} · ${result.source === 'local' ? '本地模型' : 'API'}${result.fallback_reason ? ` · 回退:${result.fallback_reason}` : ''}` + matchResult.value = `${t('相似度', 'Similarity')} ${result.score.toFixed(3)} · ${result.source === 'local' ? t('本地模型', 'Local model') : 'API'}${result.fallback_reason ? ` · ${t('回退:', 'Fallback: ')}${result.fallback_reason}` : ''}` } finally { const cleanup = await Promise.allSettled(temporary.map(id => mediaService.purge(id))) - if (cleanup.some(result => result.status === 'rejected')) notice.value = '部分临时参考附件清理失败,请检查后端连接。' + if (cleanup.some(result => result.status === 'rejected')) notice.value = t('部分临时参考附件清理失败,请检查后端连接。', 'Some temporary reference files could not be removed. Check the backend connection.') } }) } @@ -98,52 +105,52 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })