diff --git a/docs/assets/demo_meetflow.gif b/docs/assets/demo_meetflow.gif index 7e294af..e39e579 100644 Binary files a/docs/assets/demo_meetflow.gif and b/docs/assets/demo_meetflow.gif differ diff --git a/frontend/src/api/meetings.ts b/frontend/src/api/meetings.ts index 21c4aa8..fc6e6c3 100644 --- a/frontend/src/api/meetings.ts +++ b/frontend/src/api/meetings.ts @@ -96,7 +96,7 @@ export function smartStructureAgendaNotes(rawMarkdown: string, agendaTitle: stri return [ `### ${title} · 讨论要点`, `@决策: 确认「${title}」核心实施方案与演进路径`, - `@行动:[open] 完成「${title}」接口联调与自动化回归测试`, + `@行动:[待办] 完成「${title}」接口联调与自动化回归测试`, `@开放问题: 「${title}」存量数据迁移窗口是否需要灰度验证?`, ].join('\n') } @@ -108,6 +108,7 @@ export function smartStructureAgendaNotes(rawMarkdown: string, agendaTitle: stri const decPrefix = useEn ? '@Decision:' : '@决策:' const actPrefix = useEn ? '@Action:' : '@行动:' + const actStatus = useEn ? '[open]' : '[待办]' const qPrefix = useEn ? '@Question:' : '@开放问题:' for (const originalLine of lines) { @@ -160,7 +161,7 @@ export function smartStructureAgendaNotes(rawMarkdown: string, agendaTitle: stri if (content) { const ownerMatch = content.match(/@([a-zA-Z0-9_-]+)/) const ownerTag = ownerMatch ? `[@${ownerMatch[1]}]` : '' - const tag = `${actPrefix}[open]${ownerTag} ${content}` + const tag = `${actPrefix}${actStatus}${ownerTag} ${content}` if (!existingTags.has(tag)) extractedTags.push(tag) } } else if ( @@ -185,7 +186,7 @@ export function smartStructureAgendaNotes(rawMarkdown: string, agendaTitle: stri if (content) { const ownerMatch = content.match(/@([a-zA-Z0-9_-]+)/) const ownerTag = ownerMatch ? `[@${ownerMatch[1]}]` : '' - const tag = `${actPrefix}[open]${ownerTag} ${content}` + const tag = `${actPrefix}${actStatus}${ownerTag} ${content}` if (!existingTags.has(tag)) extractedTags.push(tag) } } @@ -213,7 +214,7 @@ export function smartStructureAgendaNotes(rawMarkdown: string, agendaTitle: stri '', `@决策: 确认推进「${title}」方案落地`, '', - `@行动:[open] 跟进「${title}」后续实施与联调验证`, + `@行动:[待办] 跟进「${title}」后续实施与联调验证`, '', `@开放问题: 「${title}」跨模块边界细节待最终确认`, ].join('\n') diff --git a/frontend/src/components/AgendaDetail.vue b/frontend/src/components/AgendaDetail.vue index 6e34abb..5f4deb8 100644 --- a/frontend/src/components/AgendaDetail.vue +++ b/frontend/src/components/AgendaDetail.vue @@ -2,7 +2,7 @@ import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' import { NButton, NDrawer, NDrawerContent, NInputNumber, NPopconfirm, NSelect, NTag } from 'naive-ui' import StatusPill from './StatusPill.vue' -import { errorMessage } from '../utils/errors' +import { errorMessage, translateBackendError } from '../utils/errors' import { api, ApiError } from '../api/client' import type { Page, UserRef } from '../api/contracts' @@ -20,7 +20,7 @@ import VersionConflictDialog from './VersionConflictDialog.vue' import { useI18n } from '../i18n' import { formatDateTime, formatDuration, parseUtcTimestamp } from '../utils/time' -const { locale, tr } = useI18n() +const { locale, tr, translateSeededText, translateNotesMarkdown } = useI18n() const props = defineProps<{ meeting: Meeting; item: AgendaItem; canContribute: boolean }>() const emit = defineEmits<{ changed: []; advance: [nextId: string | null] }>() type AgendaAdvanceResult = { next_agenda_item_id: string | null } @@ -32,9 +32,9 @@ type MeetingOption = { id: string; title: string; version: number } function draftFor(item: AgendaItem): AgendaDetailDraft { return { - title: item.title, + title: translateSeededText(item.title, locale.value), agenda_type: item.agenda_type, - notes_markdown: item.notes_markdown, + notes_markdown: translateNotesMarkdown(item.notes_markdown, locale.value), estimated_minutes: item.estimated_minutes, proposer_user_id: item.proposer?.id ?? null, presenter_user_id: item.presenter?.id ?? null, @@ -54,7 +54,14 @@ const dirty = computed(() => draft.title !== accepted.value.title const composer = ref<'decision' | 'action' | 'question' | null>(null) const saving = ref(false) const extractingNotes = ref(false) -const extractStatus = ref('') +const extractCounts = ref<{ decisions: number; actions: number; questions: number } | null>(null) +const extractStatus = computed(() => { + if (!extractCounts.value) return '' + const { decisions, actions, questions } = extractCounts.value + return locale.value === 'en' + ? `✨ Extracted ${decisions} decisions · ${actions} actions · ${questions} open questions` + : `✨ 已智能提炼 ${decisions} 项决策 · ${actions} 项行动 · ${questions} 项开放问题` +}) const error = ref('') const conflict = ref<{ version: number; server: string } | null>(null) const projectMembers = ref([]) @@ -86,7 +93,7 @@ const memberSelectOptions = computed(() => { for (const person of candidates) { if (seen.has(person.id)) continue seen.add(person.id) - options.push({ label: person.display_name, value: person.id }) + options.push({ label: translateSeededText(person.display_name), value: person.id }) } return options }) @@ -101,7 +108,7 @@ const canConvert = computed(() => props.canContribute && !meetingLocked.value && const canCopy = computed(() => props.canContribute && !meetingLocked.value && props.item.status === 'skipped') const migrateOptions = computed(() => props.meeting.agenda_items .filter((row) => row.id !== props.item.id) - .map((row) => ({ label: row.title, value: row.id }))) + .map((row) => ({ label: translateSeededText(row.title), value: row.id }))) const actualDurationText = computed(() => { const seconds = props.item.actual_duration_seconds if (props.item.status !== 'completed' || seconds === null || seconds === undefined) return '' @@ -137,9 +144,20 @@ watch(() => props.item, (item) => { if (switchedItem) { attachmentItems.value = null conflict.value = null + extractCounts.value = null } }, { deep: true }) +watch(locale, (nextLocale) => { + if (error.value) error.value = translateBackendError(error.value) + if (migrateError.value) migrateError.value = translateBackendError(migrateError.value) + if (copyError.value) copyError.value = translateBackendError(copyError.value) + draft.title = translateSeededText(draft.title, nextLocale) + accepted.value.title = translateSeededText(accepted.value.title, nextLocale) + draft.notes_markdown = translateNotesMarkdown(draft.notes_markdown, nextLocale) + accepted.value.notes_markdown = translateNotesMarkdown(accepted.value.notes_markdown, nextLocale) +}) + let clockHandle: number | undefined onMounted(() => { clockHandle = window.setInterval(() => { now.value = Date.now() }, 1_000) @@ -250,7 +268,7 @@ async function save(expectedVersion = currentVersion.value) { async function smartExtractNotes() { if (!props.canContribute || meetingLocked.value || extractingNotes.value || saving.value) return extractingNotes.value = true - extractStatus.value = '' + extractCounts.value = null error.value = '' try { const flushed = typeof notesEditor.value?.flush === 'function' ? notesEditor.value.flush() : undefined @@ -278,9 +296,7 @@ async function smartExtractNotes() { const decisions = res.decisions ?? res.extracted?.decisions ?? 0 const actions = res.actions ?? res.extracted?.actions ?? 0 const questions = res.questions ?? res.extracted?.questions ?? 0 - extractStatus.value = locale.value === 'en' - ? `✨ Extracted ${decisions} decisions · ${actions} actions · ${questions} open questions` - : `✨ 已智能提炼 ${decisions} 项决策 · ${actions} 项行动 · ${questions} 项开放问题` + extractCounts.value = { decisions, actions, questions } emit('changed') } catch (caught) { error.value = errorMessage(caught, tr('AI 提炼失败,请检查议题记录格式', 'AI extraction failed, please check notes format')) @@ -543,7 +559,7 @@ const noteActionTags = computed(() => { @mouseleave="hoveredOutcomeId = null" > {{ decision.is_derived ? tr('来自议题记录', 'From Notes') : tr('决策', 'Decision') }} - {{ decision.title }} + {{ translateSeededText(decision.title) }}
{ class="outcome-action-item" :class="{ 'action-item--done': action.status === 'done', - 'linked-glow': hoveredOutcomeId === action.id || noteActionTags.includes(action.content) + 'linked-glow': hoveredOutcomeId === action.id || noteActionTags.includes(action.content) || noteActionTags.includes(translateSeededText(action.content)) }" @mouseenter="hoveredOutcomeId = action.id" @mouseleave="hoveredOutcomeId = null" @@ -570,9 +586,9 @@ const noteActionTags = computed(() => { - {{ action.content }} + {{ translateSeededText(action.content) }}
- @{{ action.owner.display_name }} + @{{ translateSeededText(action.owner.display_name) }} {{ tr('截止', 'Due') }} {{ action.due_date }}

{{ tr('讨论结果会在这里形成可追踪的链条。', 'Decisions, actions, and questions captured during discussion appear here.') }}

diff --git a/frontend/src/components/AgendaQueue.vue b/frontend/src/components/AgendaQueue.vue index 9c64f4a..f7dd908 100644 --- a/frontend/src/components/AgendaQueue.vue +++ b/frontend/src/components/AgendaQueue.vue @@ -3,14 +3,14 @@ import { computed, ref, watch, h } from 'vue' import { MoreHorizontal } from '@lucide/vue' import { NButton, NDrawer, NDrawerContent, NDropdown, NIcon, NInputNumber, NPopconfirm, NSelect } from 'naive-ui' import { statusLabel } from '../utils/labels' -import { errorMessage } from '../utils/errors' +import { errorMessage, translateBackendError } from '../utils/errors' import { api, ApiError } from '../api/client' import type { Page } from '../api/contracts' import type { AgendaItem, AgendaType, Meeting } from '../domain/meetings' import { useI18n } from '../i18n' -const { tr } = useI18n() +const { locale, tr, translateSeededText } = useI18n() type MeetingOption = { id: string; title: string; version: number } @@ -45,6 +45,11 @@ watch(() => props.meeting.agenda_items, (items) => { ordered.value = [...items].sort((a, b) => a.position - b.position) }, { deep: true }) +watch(locale, () => { + if (error.value) error.value = translateBackendError(error.value) + if (moveError.value) moveError.value = translateBackendError(moveError.value) +}) + function startDrag(id: string) { if (props.canContribute && !meetingLocked.value && !saving.value) draggingId.value = id } @@ -252,7 +257,7 @@ function onMenuSelect(item: AgendaItem, key: string) {

{{ error || openError }}

- +
import { computed, ref, watch } from 'vue' -import { errorMessage } from '../utils/errors' +import { errorMessage, translateBackendError } from '../utils/errors' import { api } from '../api/client' import type { AgendaItem, Meeting } from '../domain/meetings' @@ -8,7 +8,7 @@ import { useI18n } from '../i18n' import AgendaDetail from './AgendaDetail.vue' import AgendaQueue from './AgendaQueue.vue' -const { tr } = useI18n() +const { locale, tr } = useI18n() const props = defineProps<{ meeting: Meeting canContribute: boolean @@ -19,6 +19,10 @@ const detail = ref<{ flushIfDirty: () => Promise } | null>(null) const openingId = ref('') const openError = ref('') +watch(locale, () => { + if (openError.value) openError.value = translateBackendError(openError.value) +}) + watch(() => props.meeting.status, (status, previousStatus) => { if (status !== 'in_progress' || previousStatus === 'in_progress') return selectedId.value = props.meeting.agenda_items.find((item) => item.status === 'in_progress')?.id ?? props.meeting.agenda_items[0]?.id ?? '' diff --git a/frontend/src/components/FocusMode.vue b/frontend/src/components/FocusMode.vue index 5eae097..2fc608d 100644 --- a/frontend/src/components/FocusMode.vue +++ b/frontend/src/components/FocusMode.vue @@ -13,7 +13,7 @@ import OutcomeComposer from './OutcomeComposer.vue' import { useI18n } from '../i18n' import { Check, ChevronLeft, ChevronRight } from '@lucide/vue' -const { locale, tr } = useI18n() +const { locale, tr, translateSeededText, translateNotesMarkdown } = useI18n() const props = defineProps<{ show: boolean @@ -89,12 +89,16 @@ const dirty = ref(false) watch(() => currentItem.value, (item) => { if (item) { - localNotes.value = item.notes_markdown + localNotes.value = translateNotesMarkdown(item.notes_markdown, locale.value) localVersion.value = item.version dirty.value = false } }, { immediate: true }) +watch(locale, (nextLocale) => { + localNotes.value = translateNotesMarkdown(localNotes.value, nextLocale) +}) + function normalizeMarkdownSyntax(md: string): string { return md .replace(/\\([[\]])/g, '$1') @@ -329,17 +333,17 @@ const noteActionTags = computed(() => { {{ liveElapsed || tr('进行中', 'Live') }}
- {{ meeting.project.name }} - {{ meeting.title }} + {{ translateSeededText(meeting.project.name) }} + {{ translateSeededText(meeting.title) }}
{{ currentIndex + 1 }}/{{ totalItems }} - {{ currentItem.title }} + {{ translateSeededText(currentItem.title) }} - {{ tr('主讲:', 'Speaker:') }} {{ currentItem.presenter.display_name }} + {{ tr('主讲:', 'Speaker:') }} {{ translateSeededText(currentItem.presenter.display_name) }}
@@ -408,7 +412,7 @@ const noteActionTags = computed(() => {
-

{{ currentItem.title }}

+

{{ translateSeededText(currentItem.title) }}

{{ currentItem.agenda_type === 'decision' ? tr('决策议题', 'Decision') : currentItem.agenda_type === 'discussion' ? tr('讨论议题', 'Discussion') : tr('信息同步', 'Info Sync') }} @@ -422,7 +426,7 @@ const noteActionTags = computed(() => {
{{ tr('快捷标签:', 'Quick Tags:') }} - +
-
{{ decision.title }}
+
{{ translateSeededText(decision.title) }}
@@ -502,7 +506,7 @@ const noteActionTags = computed(() => { class="outcome-card action-card" :class="{ 'action-card--done': action.status === 'done', - 'linked-glow': hoveredOutcomeId === action.id || noteActionTags.includes(action.content) + 'linked-glow': hoveredOutcomeId === action.id || noteActionTags.includes(action.content) || noteActionTags.includes(translateSeededText(action.content)) }" @mouseenter="hoveredOutcomeId = action.id" @mouseleave="hoveredOutcomeId = null" @@ -522,7 +526,7 @@ const noteActionTags = computed(() => {
- {{ action.content }} + {{ translateSeededText(action.content) }}
@@ -540,7 +544,7 @@ const noteActionTags = computed(() => {
- @{{ action.owner.display_name }} + @{{ translateSeededText(action.owner.display_name) }} {{ tr('截止', 'Due') }} {{ action.due_date }} {{ tr('手记双向映射', 'Two-way Sync') }}
@@ -559,7 +563,7 @@ const noteActionTags = computed(() => { {{ tr('开放问题', 'Open Question') }} {{ tr('来自手记', 'From Notes') }}
-
{{ question.question_markdown }}
+
{{ translateSeededText(question.question_markdown) }}
([]) const attentionLink = (item: AttentionItem) => subjectHref(item.subject_type, item.subject_id) function actorName(item: ProjectActivityItem) { - return item.actor?.display_name || item.actor?.username || tr('系统', 'System') + return translateSeededText(item.actor?.display_name || item.actor?.username || tr('系统', 'System')) } function reasonLabel(reason: string): string { @@ -72,7 +72,7 @@ onMounted(async () => {

{{ tr('项目状态', 'Project State') }}

-

{{ project.summary || tr('尚未填写项目摘要。', 'No project summary provided yet.') }}

+

{{ translateSeededText(project.summary) || tr('尚未填写项目摘要。', 'No project summary provided yet.') }}

{{ tr('健康度', 'Health') }}
@@ -89,7 +89,7 @@ onMounted(async () => {
{{ tr('目标日期', 'Target Date') }}
{{ project.target_date ?? tr('未设置', 'Unset') }}
-
{{ tr('负责人', 'Lead') }}
{{ project.lead?.display_name ?? tr('未指定', 'Unassigned') }}
+
{{ tr('负责人', 'Lead') }}
{{ project.lead ? translateSeededText(project.lead.display_name) : tr('未指定', 'Unassigned') }}
@@ -99,7 +99,7 @@ onMounted(async () => { - {{ project.next_meeting.title }} + {{ translateSeededText(project.next_meeting.title) }} · {{ tr('打开会议', 'Open Meeting') }} @@ -109,7 +109,7 @@ onMounted(async () => {

{{ tr('需要处理', 'Needs Attention') }}

{{ attentionRows.length }} {{ tr('项', 'items') }}
- {{ item.title }}{{ item.reasons.map(reasonLabel).join(' · ') }} + {{ translateSeededText(item.title) }}{{ item.reasons.map(reasonLabel).join(' · ') }}

{{ tr('当前没有需要立即处理的事项。', 'No items require immediate attention.') }}

@@ -117,7 +117,7 @@ onMounted(async () => {

{{ tr('近期行动项', 'Recent Actions') }}

{{ tr('查看全部', 'View All') }}
- {{ item.content }}{{ item.due_date ? tr(`截止 ${item.due_date}`, `Due ${item.due_date}`) : tr('未设截止日期', 'No due date') }} · {{ priorityLabel(item.priority) }} + {{ translateSeededText(item.content) }}{{ item.due_date ? tr(`截止 ${item.due_date}`, `Due ${item.due_date}`) : tr('未设截止日期', 'No due date') }} · {{ priorityLabel(item.priority) }}

{{ tr('没有未完成行动项。', 'No open action items.') }}

@@ -125,7 +125,7 @@ onMounted(async () => {

{{ tr('近期决策', 'Recent Decisions') }}

{{ tr('查看全部', 'View All') }}
- {{ item.title }} + {{ translateSeededText(item.title) }}

{{ tr('尚未形成项目决策。', 'No project decisions recorded yet.') }}

diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts index 0bef2e5..494a80a 100644 --- a/frontend/src/i18n/index.ts +++ b/frontend/src/i18n/index.ts @@ -173,6 +173,234 @@ export function tr(zhText: string, enText: string): string { return locale.value === 'en' ? enText : zhText } +export const SEEDED_ZH_TO_EN: Record = { + 'MeetFlow 核心研发': 'MeetFlow Core R&D', + '团队会议工作区与实时决议双向流转引擎': 'Team meeting workspace and real-time outcome sync engine', + 'Q3 架构演进与双语协作周会': 'Q3 Architecture Evolution & Bilingual Sync', + '确认 Canonical REST API 收敛、专注模式双向同步与群聊集成方案。': + 'Confirm Canonical REST API convergence, Focus Mode two-way sync, and Webhook integration.', + '本次周会确认了 Canonical REST API 统一收敛方案、专注模式双向同步机制及日历/群聊集成节奏。': + 'Confirmed Canonical REST API convergence plan, Focus Mode two-way sync mechanism, and Calendar/Webhook integration schedule.', + '参会人围绕 REST 契约、插件沙箱与专注模式实时联动进行了对齐。': + 'Participants aligned on REST contracts, plugin sandbox, and Focus Mode real-time sync.', + '林宇': 'Lin Yu', + '系统管理员': 'System Admin', + '乔安': 'Qiao An', + '陈晨': 'Chen Chen', + '统一 REST API 与插件契约收敛': 'Unify REST API & Plugin Contract Convergence', + '会议专注模式与笔记双向同步压测': 'Focus Mode & Two-Way Notes Sync Stress Test', + '日历订阅 ICS 签名与群聊推送联调': 'Calendar ICS Signing & Webhook Push Integration', + '### 核心讨论记录': '### Core Discussion Notes', + '- 团队一致决定采用 Canonical REST API 统一接入标准,废弃旧版兼容层': + '- Team decided to standardize on Canonical REST API and deprecate legacy compatibility layer', + '- 请 @lin 在本周五前完成网关鉴权与插件沙箱回归测试': + '- @lin to complete gateway auth and plugin sandbox regression tests by Friday', + '- 存量历史纪要数据迁移窗口是否需要灰度双写验证?': + '- Does the historical meeting notes migration window require canary dual-write verification?', + '团队一致决定采用 Canonical REST API 统一接入标准,废弃旧版兼容层': + 'Team decided to standardize on Canonical REST API and deprecate legacy compatibility layer', + '请 @lin 在本周五前完成网关鉴权与插件沙箱回归测试': + '@lin to complete gateway auth and plugin sandbox regression tests by Friday', + '存量历史纪要数据迁移窗口是否需要灰度双写验证?': + 'Does the historical meeting notes migration window require canary dual-write verification?', + '### 专注模式走查': '### Focus Mode Walkthrough', + '- 专注模式默认开启实时决议与待办分栏联动': + '- Enable live outcomes split-pane sync by default in Focus Mode', + '- 请 @lin 补充专注模式快捷键与状态流转端到端测试': + '- @lin to add end-to-end tests for Focus Mode shortcuts and status transitions', + '- 跨项目开放问题池是否支持一键排入下周周会?': + '- Should the cross-project open question pool support one-click scheduling into next week sync?', + '专注模式默认开启实时决议与待办分栏联动': + 'Enable live outcomes split-pane sync by default in Focus Mode', + '请 @lin 补充专注模式快捷键与状态流转端到端测试': + '@lin to add end-to-end tests for Focus Mode shortcuts and status transitions', + '补充专注模式快捷键与状态流转端到端测试': + 'Add end-to-end tests for Focus Mode shortcuts and status transitions', + '跨项目开放问题池是否支持一键排入下周周会?': + 'Should the cross-project open question pool support one-click scheduling into next week sync?', + '### 日历与群聊推送联调': '### Calendar & Webhook Integration', + '确认采用 HMAC-SHA256 签名保护个人 ICS 订阅源': + 'Confirm HMAC-SHA256 signing for personal ICS subscription feeds', + '完成飞书与钉钉群机器人卡片推送重试验证': + 'Verify Feishu and DingTalk webhook card delivery retries', + '是否开启跨项目 Webhook 汇总通知?': + 'Should cross-project Webhook digest notifications be enabled?', + '林宇 (Lin Yu)': 'Lin Yu', + 'AI 原生会议工作流、双向行动项同步与中英双语界面升级': + 'AI-native meeting workflow, two-way action sync, and bilingual UI upgrade', + '聚焦会前准备、会中专注研讨与会后归档闭环。': + 'Focus on pre-meeting prep, live focused discussion, and post-meeting archive loop.', + 'Q3 架构演进与中英双语体验评审': 'Q3 Architecture Evolution & Bilingual UX Review', + '评审页内 ✨ AI 智能提炼、双向行动项状态机及中英双语无缝切换体验。': + 'Review in-place ✨ AI Smart Extract, two-way action state machine, and seamless bilingual switching.', + '页内 ✨ AI 智能提炼与结构化产出': 'In-place ✨ AI Smart Extract & Structured Outcomes', + '围绕会议记录与 AI 提炼流程达成以下结论:': + 'Key conclusions on meeting notes and AI extraction workflow:', + '团队一致决定采用页内 ✨ AI 智能提炼直接写入当前议题记录,彻底移除冗余弹窗': + 'Unanimously decided to use in-place ✨ AI Smart Extract directly in agenda notes, removing redundant modals', + '采用页内 ✨ AI 智能提炼直接写入当前议题记录,彻底移除冗余弹窗': + 'Use in-place ✨ AI Smart Extract directly in agenda notes, removing redundant modals', + '请 @admin 完善中英双语标签 (@决策/@Decision、@行动/@Action) 解析与单元测试': + 'Please @admin refine bilingual tag (@Decision, @Action) parsing and unit tests', + '完善中英双语标签 (@决策/@Decision、@行动/@Action) 解析与单元测试': + 'Refine bilingual tag (@Decision, @Action) parsing and unit tests', + '请 @admin 完善中英双语结构化标签解析与自动化单元测试': + 'Please @admin refine bilingual structured tag parsing and automated unit tests', + '完善中英双语结构化标签解析与自动化单元测试': + 'Refine bilingual structured tag parsing and automated unit tests', + '后续是否支持自定义 Webhook 卡片签名轮换策略?': + 'Should custom Webhook card signature rotation policies be supported next?', + '沉浸式专注模式与实时倒计时': 'Immersive Focus Mode & Live Countdown', + '专注模式分栏同步展示实时决议与待办卡片': + 'Focus Mode split-pane displays live decisions and action cards in sync', + '校验 960x600 与 1200x800 视口下按钮单行排版': + 'Validate single-line button layout at 960x600 and 1200x800 viewports', + 'iCal 日历订阅与飞书/钉钉群卡片推送': 'iCal Calendar Subscription & Feishu/DingTalk Cards', + '默认启用 RFC 5545 签名日历订阅源': 'Enable RFC 5545 signed calendar subscription feeds by default', + '完成飞书与钉钉交互卡片回归验证': 'Complete Feishu and DingTalk interactive card regression verification', +} + +export const SEEDED_EN_TO_ZH: Record = Object.fromEntries( + Object.entries(SEEDED_ZH_TO_EN).map(([zh, en]) => [en, zh]), +) + +const SORTED_ZH_ENTRIES = Object.entries(SEEDED_ZH_TO_EN).sort((a, b) => b[0].length - a[0].length) +const SORTED_EN_ENTRIES = Object.entries(SEEDED_EN_TO_ZH).sort((a, b) => b[0].length - a[0].length) + +export function translateSeededText(text: string, lang: Locale = locale.value): string { + if (!text) return text + const trimmed = text.trim() + if (lang === 'en') { + if (SEEDED_ZH_TO_EN[trimmed]) return SEEDED_ZH_TO_EN[trimmed] + const discussMatch = trimmed.match(/^###\s+(.+?)\s+·\s+讨论要点$/) + if (discussMatch) { + return `### ${translateSeededText(discussMatch[1], 'en')} · Key Discussion Points` + } + const decDefaultMatch = trimmed.match(/^确认「(.+?)」核心实施方案与演进路径$/) + if (decDefaultMatch) { + return `Confirm core implementation plan and rollout path for "${translateSeededText(decDefaultMatch[1], 'en')}"` + } + const actDefaultMatch = trimmed.match(/^完成「(.+?)」接口联调与自动化回归测试$/) + if (actDefaultMatch) { + return `Complete API integration and automated regression tests for "${translateSeededText(actDefaultMatch[1], 'en')}"` + } + const qDefaultMatch = trimmed.match(/^「(.+?)」存量数据迁移窗口是否需要灰度验证?$/) + if (qDefaultMatch) { + return `Does the legacy data migration window for "${translateSeededText(qDefaultMatch[1], 'en')}" require canary verification?` + } + const decFallbackMatch = trimmed.match(/^确认推进「(.+?)」方案落地$/) + if (decFallbackMatch) { + return `Confirm rollout plan for "${translateSeededText(decFallbackMatch[1], 'en')}"` + } + const actFallbackMatch = trimmed.match(/^跟进「(.+?)」后续实施与联调验证$/) + if (actFallbackMatch) { + return `Follow up on implementation and integration testing for "${translateSeededText(actFallbackMatch[1], 'en')}"` + } + const qFallbackMatch = trimmed.match(/^「(.+?)」跨模块边界细节待最终确认$/) + if (qFallbackMatch) { + return `Cross-module boundary details for "${translateSeededText(qFallbackMatch[1], 'en')}" pending final confirmation` + } + let replaced = text + for (const [zh, en] of SORTED_ZH_ENTRIES) { + if (zh.length >= 4 && replaced.includes(zh)) { + replaced = replaced.split(zh).join(en) + } + } + return replaced + } + + if (SEEDED_EN_TO_ZH[trimmed]) return SEEDED_EN_TO_ZH[trimmed] + const enDiscussMatch = trimmed.match(/^###\s+(.+?)\s+·\s+Key Discussion Points$/) + if (enDiscussMatch) { + return `### ${translateSeededText(enDiscussMatch[1], 'zh')} · 讨论要点` + } + const enDecDefaultMatch = trimmed.match(/^Confirm core implementation plan and rollout path for "(.+?)"$/) + if (enDecDefaultMatch) { + return `确认「${translateSeededText(enDecDefaultMatch[1], 'zh')}」核心实施方案与演进路径` + } + const enActDefaultMatch = trimmed.match(/^Complete API integration and automated regression tests for "(.+?)"$/) + if (enActDefaultMatch) { + return `完成「${translateSeededText(enActDefaultMatch[1], 'zh')}」接口联调与自动化回归测试` + } + const enQDefaultMatch = trimmed.match(/^Does the legacy data migration window for "(.+?)" require canary verification\?$/) + if (enQDefaultMatch) { + return `「${translateSeededText(enQDefaultMatch[1], 'zh')}」存量数据迁移窗口是否需要灰度验证?` + } + const enDecFallbackMatch = trimmed.match(/^Confirm rollout plan for "(.+?)"$/) + if (enDecFallbackMatch) { + return `确认推进「${translateSeededText(enDecFallbackMatch[1], 'zh')}」方案落地` + } + const enActFallbackMatch = trimmed.match(/^Follow up on implementation and integration testing for "(.+?)"$/) + if (enActFallbackMatch) { + return `跟进「${translateSeededText(enActFallbackMatch[1], 'zh')}」后续实施与联调验证` + } + const enQFallbackMatch = trimmed.match(/^Cross-module boundary details for "(.+?)" pending final confirmation$/) + if (enQFallbackMatch) { + return `「${translateSeededText(enQFallbackMatch[1], 'zh')}」跨模块边界细节待最终确认` + } + let replaced = text + for (const [en, zh] of SORTED_EN_ENTRIES) { + if (en.length >= 6 && replaced.includes(en)) { + replaced = replaced.split(en).join(zh) + } + } + return replaced +} + +function normalizeActionBracketToken(token: string, lang: Locale): string { + const lower = token.trim().toLowerCase() + if (lang === 'en') { + if (lower === '待办' || lower === '待开始' || lower === 'todo' || lower === 'open') return 'open' + if (lower === '进行中' || lower === 'doing' || lower === 'progress' || lower === 'in_progress') return 'in_progress' + if (lower === '已完成' || lower === '完成' || lower === 'done' || lower === 'completed') return 'done' + if (lower === '已取消' || lower === '取消' || lower === 'canceled' || lower === 'cancelled') return 'canceled' + return token + } + if (lower === 'open' || lower === 'todo' || lower === '待办' || lower === '待开始') return '待办' + if (lower === 'in_progress' || lower === 'doing' || lower === 'progress' || lower === '进行中') return '进行中' + if (lower === 'done' || lower === 'completed' || lower === '已完成' || lower === '完成') return '已完成' + if (lower === 'canceled' || lower === 'cancelled' || lower === '已取消' || lower === '取消') return '已取消' + return token +} + +export function translateNotesMarkdown(md: string, lang: Locale = locale.value): string { + if (!md) return md + return md + .split('\n') + .map((rawLine) => { + const trimmed = rawLine.trim().replace(/\\([[\]])/g, '$1') + if (!trimmed) return rawLine + + const decMatch = trimmed.match(/^@(?:决策|Decision|decision):\s*(.+)$/) + if (decMatch) { + const prefix = lang === 'en' ? '@Decision:' : '@决策:' + return `${prefix} ${translateSeededText(decMatch[1], lang)}` + } + + const qMatch = trimmed.match(/^@(?:开放问题|问题|Question|question|OpenQuestion):\s*(.+)$/) + if (qMatch) { + const prefix = lang === 'en' ? '@Question:' : '@开放问题:' + return `${prefix} ${translateSeededText(qMatch[1], lang)}` + } + + const actMatch = trimmed.match(/^@(?:行动|Action|action):\s*((?:\[[^\]]+\])*)\s*(.+)$/) + if (actMatch) { + const prefix = lang === 'en' ? '@Action:' : '@行动:' + const rawBrackets = actMatch[1] || '' + const normalizedBrackets = rawBrackets.replace(/\[([^\]]+)\]/g, (_, inner: string) => { + if (inner.startsWith('@') || /^\d{4}-\d{2}-\d{2}$/.test(inner)) { + return `[${inner}]` + } + return `[${normalizeActionBracketToken(inner, lang)}]` + }) + return `${prefix}${normalizedBrackets} ${translateSeededText(actMatch[2], lang)}` + } + + return translateSeededText(rawLine, lang) + }) + .join('\n') +} + export function useI18n() { return { locale, @@ -181,5 +409,7 @@ export function useI18n() { toggleLocale, t, tr, + translateSeededText, + translateNotesMarkdown, } } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 9bb2a3c..e345000 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -279,7 +279,7 @@ input:focus, textarea:focus, select:focus { border-color: var(--green); box-shad .project-context { display: flex; flex-wrap: wrap; gap: 8px 16px; margin-top: 12px; color: #737d8a; font-size: .8rem; }.project-context span { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; } .page-header-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; flex-shrink: 0; } .page-header-actions > *, .page-header-actions .n-button, .row-actions > *, .row-actions .n-button { white-space: nowrap; flex-shrink: 0; } -.project-overview-grid { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(18rem, .9fr); gap: 18px; align-items: stretch; }.project-dashboard-card { min-width: 0; }.project-dashboard-card h2 { margin: 0; }.project-state-summary { margin: 16px 0; color: #4e5c68; line-height: 1.65; }.project-state-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin: 0; }.project-state-metrics div { min-width: 0; }.project-state-metrics dt { color: #7c8792; font-size: .7rem; }.project-state-metrics dd { margin: 4px 0 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .82rem; font-weight: 750; }.project-dashboard-list, .project-record-list, .project-activity-list { display: grid; gap: 8px; }.compact-row-button { width: 100%; border-top: 0; border-right: 0; border-left: 0; background: transparent; text-align: left; cursor: pointer; }.project-record-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: center; padding: 13px 0; color: inherit; text-decoration: none; border-bottom: 1px solid #eceef1; }.project-record-row span { color: #74808c; font-size: .78rem; }.project-activity-tab, .project-record-tabs { display: grid; gap: 18px; }.latest-update { padding: 16px; border-radius: 10px; background: #f7f8fa; }.project-update-composer { display: grid; gap: 12px; padding-top: 4px; }.composer-heading { display: flex; justify-content: space-between; gap: 14px; }.composer-heading h3 { margin: 2px 0 0; }.composer-heading label { display: grid; gap: 4px; color: #77808d; font-size: .72rem; } +.project-overview-grid { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(18rem, .9fr); gap: 18px; align-items: stretch; }.project-dashboard-card { min-width: 0; }.project-dashboard-card > .section-heading { flex-wrap: wrap; gap: 8px 12px; }.project-dashboard-card h2 { margin: 0; }.project-state-summary { margin: 16px 0; color: #4e5c68; line-height: 1.65; }.project-state-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin: 0; }.project-state-metrics div { min-width: 0; }.project-state-metrics dt { color: #7c8792; font-size: .7rem; }.project-state-metrics dd { margin: 4px 0 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .82rem; font-weight: 750; }.project-dashboard-list, .project-record-list, .project-activity-list { display: grid; gap: 8px; }.compact-row-button { width: 100%; border-top: 0; border-right: 0; border-left: 0; background: transparent; text-align: left; cursor: pointer; }.project-record-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: center; padding: 13px 0; color: inherit; text-decoration: none; border-bottom: 1px solid #eceef1; }.project-record-row span { color: #74808c; font-size: .78rem; }.project-activity-tab, .project-record-tabs { display: grid; gap: 18px; }.latest-update { padding: 16px; border-radius: 10px; background: #f7f8fa; }.project-update-composer { display: grid; gap: 12px; padding-top: 4px; }.composer-heading { display: flex; justify-content: space-between; gap: 14px; }.composer-heading h3 { margin: 2px 0 0; }.composer-heading label { display: grid; gap: 4px; color: #77808d; font-size: .72rem; } .mention-textarea { position: relative; }.mention-textarea textarea { width: 100%; min-height: 6rem; }.mention-textarea ul { position: absolute; z-index: 4; left: 0; right: 0; max-height: 12rem; overflow-y: auto; margin: 4px 0; padding: 4px; list-style: none; border: 1px solid #d5dae2; border-radius: 8px; background: #fff; box-shadow: 0 8px 24px rgba(15, 23, 42, .14); }.mention-textarea li { padding: 8px; border-radius: 5px; cursor: pointer; }.mention-textarea li[aria-selected="true"] { background: #eaf3ff; }.meeting-comments { display: grid; gap: 14px; }.comment-list .n-list-item { padding: 0 0 10px; }.comment-list .n-list-item.is-focused .comment-thread { border-color: #79aa96; box-shadow: 0 0 0 3px rgba(11, 106, 88, .12); }.comment-thread { padding: 12px; border: 1px solid #e1e5eb; border-radius: 10px; }.comment-thread.resolved { opacity: .68; }.comment-thread header { display: flex; align-items: center; justify-content: space-between; gap: 8px; }.comment-thread header time { color: var(--muted); font-size: .72rem; }.comment-body { margin: 8px 0 0; white-space: pre-wrap; }.comment-deleted { color: var(--muted); font-style: italic; }.comment-edited { color: var(--muted); font-size: .7rem; }.comment-mentions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }.comment-mentions span { padding: 2px 7px; border-radius: 999px; color: var(--green-dark); background: var(--green-soft); font-size: .7rem; }.comment-replies { display: grid; gap: 6px; justify-items: start; margin-top: 10px; }.comment-reply { display: block; margin: 0 0 0 18px; padding: 9px; border-left: 2px solid #d8dee8; } .compact-row { display: flex; justify-content: space-between; gap: 16px; padding: 11px 0; color: inherit; text-decoration: none; border-bottom: 1px solid #eceef1; }.compact-row span { color: #7d8692; font-size: .78rem; }.series-row { align-items: center; }.series-row > a { display: grid; gap: 3px; min-width: 0; color: inherit; text-decoration: none; }.series-row > .button { flex: 0 0 auto; }.next-meeting-card { display: grid; gap: 7px; padding: 16px; color: inherit; text-decoration: none; border-radius: 10px; background: #eef4f2; }.next-meeting-card time, .next-meeting-card span { color: #667a74; font-size: .78rem; } .tab-content { min-height: 240px; }.tab-content h2 { margin-top: 0; } diff --git a/frontend/src/tests/focus-and-integrations.test.ts b/frontend/src/tests/focus-and-integrations.test.ts index ecae5ef..d0045b0 100644 --- a/frontend/src/tests/focus-and-integrations.test.ts +++ b/frontend/src/tests/focus-and-integrations.test.ts @@ -348,9 +348,19 @@ describe('P0 Features: Bidirectional Actions, Focus Mode, Calendar & Webhooks', ) }) expect((notesTextarea as HTMLTextAreaElement).value).toContain('@决策: 大家一致决定采用 Canonical REST API 统一接入') - expect((notesTextarea as HTMLTextAreaElement).value).toContain('@行动:[open][@lin] 请 @lin 跟进接口联调验证') + expect((notesTextarea as HTMLTextAreaElement).value).toContain('@行动:[待办][@lin] 请 @lin 跟进接口联调验证') expect((notesTextarea as HTMLTextAreaElement).value).toContain('@开放问题: 是否需要做历史数据迁移?') expect(screen.getByTestId('ai-extract-status')).toHaveTextContent('已智能提炼 2 项决策 · 2 项行动 · 1 项开放问题') + + // Toggling locale to 'en' should reactively translate extractStatus and outcome tags + setLocale('en') + await waitFor(() => { + expect(screen.getByTestId('ai-extract-status')).toHaveTextContent('Extracted 2 decisions · 2 actions · 1 open questions') + }) + setLocale('zh') + await waitFor(() => { + expect(screen.getByTestId('ai-extract-status')).toHaveTextContent('已智能提炼 2 项决策 · 2 项行动 · 1 项开放问题') + }) expect(onChanged).toHaveBeenCalled() }) diff --git a/frontend/src/utils/errors.ts b/frontend/src/utils/errors.ts index 07d5d48..cca2091 100644 --- a/frontend/src/utils/errors.ts +++ b/frontend/src/utils/errors.ts @@ -224,8 +224,45 @@ export const EN_ERROR_MAP: Record = { 'Server restarted; AI request was not resent', '服务重启,事件重新排队': 'Server restarted; event was re-queued', + + // Frontend fallback error messages + '议题保存失败': 'Failed to save agenda item', + 'AI 提炼失败,请检查议题记录格式': 'AI extraction failed, please check notes format', + 'AI 提炼失败,请检查笔记标签格式': 'AI extraction failed, please check notes format', + '议题状态更新失败': 'Failed to update agenda status', + '请选择目标议题': 'Please select a target agenda item', + '产出迁移失败': 'Failed to migrate outcomes', + '转为开放问题失败': 'Failed to convert to open question', + '目标会议加载失败': 'Failed to load target meetings', + '请选择目标会议': 'Please select a target meeting', + '议题复制失败': 'Failed to copy agenda item', + '更新行动项失败': 'Failed to update action item', + '结束时间必须晚于开始时间': 'End time must be after start time', + '准备信息保存失败': 'Failed to save preparation info', + '会议加载失败': 'Failed to load meeting', + '会议保存失败': 'Failed to save meeting', + '会议状态更新失败': 'Failed to update meeting status', + '议题刷新失败': 'Failed to refresh agenda', + '会议导出失败': 'Failed to export meeting', + '议题排序失败': 'Failed to reorder agenda items', + '议题添加失败': 'Failed to add agenda item', + '议题操作失败': 'Agenda action failed', + '议题已有产出,请先迁移产出,或将议题标记为取消。': + 'This agenda item already has outcomes. Please migrate its outcomes or cancel the item first.', + '议题删除失败': 'Failed to delete agenda item', + '议题移动失败': 'Failed to move agenda item', + '议题开始失败': 'Failed to start agenda item', + '插件列表加载失败': 'Failed to load plugins', + '插件配置保存失败': 'Failed to save plugin configuration', + '敏感配置清除失败': 'Failed to clear secret configuration', + '插件状态更新失败': 'Failed to update plugin state', + '插件事件重试失败': 'Failed to retry plugin event', } +export const ZH_ERROR_MAP: Record = Object.fromEntries( + Object.entries(EN_ERROR_MAP).map(([zh, en]) => [en, zh]), +) + export const EN_ERROR_CODE_MAP: Record = { invalid_credentials: 'Incorrect username or password', invalid_request: 'Invalid request parameters', @@ -270,9 +307,27 @@ export const EN_ERROR_CODE_MAP: Record = { export function translateBackendError(msg: string, code?: string | null): string { if (!msg) return msg - if (locale.value !== 'en') return msg - const trimmed = msg.trim() + + if (locale.value !== 'en') { + if (ZH_ERROR_MAP[trimmed]) { + return ZH_ERROR_MAP[trimmed] + } + const enChunkMatch = trimmed.match(/^Chunk\s+(\d+)\s+has not been uploaded yet$/) + if (enChunkMatch) { + return `分片 ${enChunkMatch[1]} 尚未上传` + } + const enWebhookStatusMatch = trimmed.match(/^Webhook returned error status:\s*(.+)$/) + if (enWebhookStatusMatch) { + return `Webhook返回错误状态码: ${enWebhookStatusMatch[1]}` + } + const enWebhookErrorMatch = trimmed.match(/^Webhook delivery failed:\s*(.+)$/) + if (enWebhookErrorMatch) { + return `Webhook发送失败: ${enWebhookErrorMatch[1]}` + } + return msg + } + if (EN_ERROR_MAP[trimmed]) { return EN_ERROR_MAP[trimmed] } diff --git a/frontend/src/views/ActionsView.vue b/frontend/src/views/ActionsView.vue index f973b90..023f4ba 100644 --- a/frontend/src/views/ActionsView.vue +++ b/frontend/src/views/ActionsView.vue @@ -11,10 +11,10 @@ import StatusPill from '../components/StatusPill.vue' import { loadUserNames, nameOf, userNames } from '../composables/useUserNameMap' import type { Project } from '../domain/projects' import { useI18n } from '../i18n' -import { errorMessage } from '../utils/errors' +import { errorMessage, translateBackendError } from '../utils/errors' import { formatDateTime } from '../utils/time' -const { tr } = useI18n() +const { locale, tr, translateSeededText } = useI18n() type ActionRow = { id: string @@ -39,6 +39,10 @@ const total = ref(0) const page = ref(1) const loading = ref(true) const error = ref('') + +watch(locale, () => { + if (error.value) error.value = translateBackendError(error.value) +}) const filters = reactive({ owner: 'me', status: 'open', project: '', due: '' }) const editTarget = ref(null) const actionBusy = ref('') @@ -52,7 +56,7 @@ let appliedHighlight = '' const memberOptions = computed(() => Object.entries(userNames.value).map(([id, displayName]) => ({ id, username: displayName, - display_name: displayName, + display_name: translateSeededText(displayName), }))) const pagination = computed(() => ({ page: page.value, pageSize: PAGE_SIZE, itemCount: total.value })) @@ -205,8 +209,8 @@ function actionButtons(row: ActionRow): VNodeChild[] { } const columns = computed>(() => [ - { title: tr('内容', 'Action'), key: 'content', minWidth: 120, ellipsis: { tooltip: true } }, - { title: tr('负责人', 'Owner'), key: 'owner', width: 76, render: (row) => nameOf(row.owner_user_id) }, + { title: tr('内容', 'Action'), key: 'content', minWidth: 120, ellipsis: { tooltip: true }, render: (row) => translateSeededText(row.content) }, + { title: tr('负责人', 'Owner'), key: 'owner', width: 76, render: (row) => translateSeededText(nameOf(row.owner_user_id)) }, { title: tr('优先级', 'Priority'), key: 'priority', diff --git a/frontend/src/views/AdminPluginsView.vue b/frontend/src/views/AdminPluginsView.vue index eeefbb7..47f2023 100644 --- a/frontend/src/views/AdminPluginsView.vue +++ b/frontend/src/views/AdminPluginsView.vue @@ -17,14 +17,14 @@ import { NTag, type DataTableColumns, } from 'naive-ui' -import { computed, h, onMounted, reactive, ref } from 'vue' +import { computed, h, onMounted, reactive, ref, watch } from 'vue' import { api } from '../api/client' import StatusPill from '../components/StatusPill.vue' import { useI18n } from '../i18n' import { errorMessage, translateBackendError } from '../utils/errors' -const { tr } = useI18n() +const { locale, tr } = useI18n() type ConfigField = { key: string; type: 'string' | 'number' | 'boolean' | 'secret'; required?: boolean; label?: string; description?: string } type PluginInfo = { @@ -56,6 +56,10 @@ const unmatchedErrors = computed(() => pluginErrors.value.filter( (item) => !plugins.value.some((plugin) => plugin.id === item.plugin_id), )) +watch(locale, () => { + if (error.value) error.value = translateBackendError(error.value) +}) + function hydrate(plugin: PluginInfo) { const values: Record = {} for (const field of plugin.config_schema?.fields ?? []) { @@ -175,8 +179,38 @@ function secretConfigured(plugin: PluginInfo, key: string) { return !!value && typeof value === 'object' && 'configured' in value && (value as { configured: boolean }).configured } +const FIELD_LABEL_MAP: Record = { + 'API 地址': { zh: 'API 地址', en: 'API URL' }, + '数量': { zh: '数量', en: 'Limit' }, + '包含已完成': { zh: '包含已完成', en: 'Include Completed' }, + '服务地址': { zh: '服务地址', en: 'Base URL' }, + '服务地址 (Base URL)': { zh: '服务地址 (Base URL)', en: 'Base URL' }, + '模型名称': { zh: '模型名称', en: 'Model' }, + '模型名称 (Model)': { zh: '模型名称 (Model)', en: 'Model' }, + '超时时间': { zh: '超时时间', en: 'Timeout (s)' }, + '超时时间 (秒)': { zh: '超时时间 (秒)', en: 'Timeout (s)' }, + 'API 密钥': { zh: 'API 密钥', en: 'API Key' }, + 'API 密钥 (API Key)': { zh: 'API 密钥 (API Key)', en: 'API Key' }, +} + +const FIELD_DESC_MAP: Record = { + 'OpenAI 兼容接口地址': { zh: 'OpenAI 兼容接口地址', en: 'OpenAI-compatible endpoint URL' }, + '模型名称': { zh: '模型名称', en: 'Model identifier' }, + '请求超时时间(秒)': { zh: '请求超时时间(秒)', en: 'Request timeout in seconds' }, +} + function fieldLabel(field: ConfigField): string { - if (field.label) return field.label + if (field.label) { + const mapped = FIELD_LABEL_MAP[field.label] + if (mapped) return tr(mapped.zh, mapped.en) + if (locale.value === 'en') { + if (field.key === 'base_url') return 'Base URL' + if (field.key === 'model') return 'Model' + if (field.key === 'timeout_seconds') return 'Timeout (s)' + if (field.key === 'api_key') return 'API Key' + } + return field.label + } if (field.key === 'base_url') return tr('服务地址 (Base URL)', 'Base URL') if (field.key === 'model') return tr('模型名称 (Model)', 'Model') if (field.key === 'timeout_seconds') return tr('超时时间 (秒)', 'Timeout (s)') @@ -184,6 +218,18 @@ function fieldLabel(field: ConfigField): string { return field.key } +function fieldDescription(field: ConfigField): string { + if (!field.description) return '' + const mapped = FIELD_DESC_MAP[field.description] + if (mapped) return tr(mapped.zh, mapped.en) + if (locale.value === 'en') { + if (field.key === 'base_url') return 'OpenAI-compatible endpoint URL' + if (field.key === 'model') return 'Model identifier' + if (field.key === 'timeout_seconds') return 'Request timeout in seconds' + } + return field.description +} + function pluginDisplayName(plugin: PluginInfo): string { if (plugin.id === 'ai-work-assistant' && plugin.name === 'AI 工作助手') { return tr('AI 工作助手', 'AI Work Assistant') @@ -207,6 +253,9 @@ function pluginDisplayDescription(plugin: PluginInfo): string { 'Export bounded meeting context as Markdown or JSON', ) } + if (plugin.description === '生成会议纪要草稿') { + return tr('生成会议纪要草稿', 'Generate meeting minutes draft') + } return plugin.description || plugin.id } @@ -284,7 +333,7 @@ onMounted(() => { void load() }) :input-props="{ 'aria-label': fieldLabel(field), required: field.required }" @update:value="(value: string) => setDraft(plugin, field.key, value)" /> - + { + if (error.value) error.value = translateBackendError(error.value) +}) const filters = reactive({ project: '', status: '', reviewer: '' }) const target = ref(null) const highlightedId = ref(null) @@ -41,12 +45,12 @@ let loadToken = 0 let appliedHighlight = '' const projectNames = computed(() => Object.fromEntries(projects.value.map((project) => [project.id, project.name]))) -const reviewerOptions = computed(() => Object.entries(userNames.value).map(([id, name]) => ({ label: name, value: id }))) +const reviewerOptions = computed(() => Object.entries(userNames.value).map(([id, name]) => ({ label: translateSeededText(name), value: id }))) /** The global list carries no capabilities payload; the backend stays the write authority (403/409). */ const memberOptions = computed(() => Object.entries(userNames.value).map(([id, displayName]) => ({ id, username: displayName, - display_name: displayName, + display_name: translateSeededText(displayName), }))) const pagination = computed(() => ({ page: page.value, pageSize: PAGE_SIZE, itemCount: total.value })) @@ -130,18 +134,18 @@ function reviewerBadges(row: Decision) { type: REVIEW_STATUS_TYPES[reviewer.status] ?? 'default', class: 'reviewer-badge', }, { - default: () => `${nameOf(reviewer.user_id)}·${reviewStatusLabel(reviewer.status)}`, + default: () => `${translateSeededText(nameOf(reviewer.user_id))}·${reviewStatusLabel(reviewer.status)}`, }))) } const columns = computed>(() => [ - { title: tr('标题', 'Title'), key: 'title', minWidth: 120, ellipsis: { tooltip: true } }, + { title: tr('标题', 'Title'), key: 'title', minWidth: 120, ellipsis: { tooltip: true }, render: (row) => translateSeededText(row.title) }, { title: tr('项目', 'Project'), key: 'project', width: 124, ellipsis: { tooltip: true }, - render: (row) => projectNames.value[row.project_id] ?? row.project_id, + render: (row) => translateSeededText(projectNames.value[row.project_id] ?? row.project_id), }, { title: tr('状态', 'Status'), diff --git a/frontend/src/views/MeetingWorkspaceView.vue b/frontend/src/views/MeetingWorkspaceView.vue index c2b033d..c13b2b6 100644 --- a/frontend/src/views/MeetingWorkspaceView.vue +++ b/frontend/src/views/MeetingWorkspaceView.vue @@ -2,7 +2,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { NButton, NDrawer, NDrawerContent, NForm, NFormItem, NInput, NPopconfirm, NSelect, useMessage, type FormInst, type FormRules } from 'naive-ui' import StatusPill from '../components/StatusPill.vue' -import { errorMessage } from '../utils/errors' +import { errorMessage, translateBackendError } from '../utils/errors' import { onBeforeRouteLeave, useRoute } from 'vue-router' import { api, ApiError } from '../api/client' @@ -29,7 +29,7 @@ import { useI18n } from '../i18n' import { formatDateTime, parseUtcTimestamp, toLocalInput } from '../utils/time' -const { tr } = useI18n() +const { locale, tr, translateSeededText } = useI18n() const route = useRoute() const loading = ref(true) const lifecycleAction = ref(null) @@ -209,9 +209,24 @@ async function savePreparation() { function acceptMeeting(value: Meeting, resetDraft: boolean) { workspace.accept(value, resetDraft) - if (resetDraft) materialItems.value = value.attachments ?? [] + if (resetDraft) { + materialItems.value = value.attachments ?? [] + draft.value.summary_markdown = translateSeededText(draft.value.summary_markdown, locale.value) + acceptedDraft.value.summary_markdown = translateSeededText(acceptedDraft.value.summary_markdown, locale.value) + draft.value.raw_notes_markdown = translateSeededText(draft.value.raw_notes_markdown, locale.value) + acceptedDraft.value.raw_notes_markdown = translateSeededText(acceptedDraft.value.raw_notes_markdown, locale.value) + } } +watch(locale, (nextLocale) => { + if (error.value) error.value = translateBackendError(error.value) + if (preparationError.value) preparationError.value = translateBackendError(preparationError.value) + draft.value.summary_markdown = translateSeededText(draft.value.summary_markdown, nextLocale) + acceptedDraft.value.summary_markdown = translateSeededText(acceptedDraft.value.summary_markdown, nextLocale) + draft.value.raw_notes_markdown = translateSeededText(draft.value.raw_notes_markdown, nextLocale) + acceptedDraft.value.raw_notes_markdown = translateSeededText(acceptedDraft.value.raw_notes_markdown, nextLocale) +}) + /** * Replaces the prep-owned meeting fields after a preparation save without resetting the * workspace draft, so unsaved 会议纪要/原始笔记 stay in the editor and stay dirty. @@ -366,13 +381,13 @@ onBeforeUnmount(() => {

{{ tr('正在打开会议工作区…', 'Opening meeting workspace…') }}