From 7dde7d05f046b5533f2e4892b8226b35103b4344 Mon Sep 17 00:00:00 2001 From: mcocdaa <2021137961@qq.com> Date: Sat, 17 Oct 2026 11:02:39 +0200 Subject: [PATCH] feat(i18n): complete bilingual zh/en localization across secondary views, drawers, and project tabs --- frontend/src/api/client.ts | 7 +- frontend/src/api/plugin-stream.ts | 12 +- frontend/src/components/ActionEditDrawer.vue | 37 ++-- frontend/src/components/AgendaDetail.vue | 20 +- frontend/src/components/AgendaQueue.vue | 18 +- frontend/src/components/AgendaWorkbench.vue | 2 +- .../src/components/CompletedMeetingChain.vue | 61 +++--- .../src/components/DecisionDetailDrawer.vue | 120 +++++++----- frontend/src/components/FocusMode.vue | 14 +- frontend/src/components/MarkdownEditor.vue | 15 +- frontend/src/components/MarkdownView.vue | 7 +- .../src/components/MeetingCommentsPanel.vue | 53 +++--- .../src/components/MeetingCreateDrawer.vue | 47 ++--- .../components/MeetingParticipantEditor.vue | 17 +- frontend/src/components/MentionTextarea.vue | 5 +- frontend/src/components/PluginEditorSlot.vue | 11 +- .../src/components/ProjectActivityTab.vue | 64 ++++--- .../src/components/ProjectCreatePanel.vue | 47 ++--- frontend/src/components/ProjectOverview.vue | 71 +++++-- .../src/components/ProjectQuestionsTab.vue | 89 ++++----- frontend/src/components/ProjectRecordTabs.vue | 87 ++++----- .../src/components/ProjectUpdateComposer.vue | 16 +- frontend/src/components/SeriesEditDrawer.vue | 122 ++++++------ .../src/components/VersionConflictDialog.vue | 13 +- frontend/src/composables/useUserNameMap.ts | 5 +- frontend/src/utils/activity.ts | 65 ++++++- frontend/src/utils/time.ts | 4 +- frontend/src/views/ActionsView.vue | 4 +- frontend/src/views/AdminPluginsView.vue | 89 ++++++--- frontend/src/views/AdminUsersView.vue | 127 +++++++------ frontend/src/views/DecisionsView.vue | 2 +- frontend/src/views/LoginView.vue | 48 +++-- frontend/src/views/MeetingWorkspaceView.vue | 24 +-- frontend/src/views/ProjectDetailView.vue | 177 +++++++++--------- frontend/src/views/RegisterView.vue | 61 +++--- .../frontend/assistant-ui.js | 66 +++++-- 36 files changed, 959 insertions(+), 668 deletions(-) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index f1a5b35..1b02524 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,3 +1,5 @@ +import { locale } from '../i18n' + export class ApiError extends Error { constructor( public readonly status: number, @@ -10,7 +12,10 @@ export class ApiError extends Error { } } -export function apiErrorMessage(payload: unknown, fallback = '请求失败,请稍后重试'): string { +export function apiErrorMessage( + payload: unknown, + fallback = locale.value === 'en' ? 'Request failed, please try again later' : '请求失败,请稍后重试', +): string { if ( payload && typeof payload === 'object' diff --git a/frontend/src/api/plugin-stream.ts b/frontend/src/api/plugin-stream.ts index 99ba8a8..1c279e2 100644 --- a/frontend/src/api/plugin-stream.ts +++ b/frontend/src/api/plugin-stream.ts @@ -1,3 +1,6 @@ +import { locale } from '../i18n' +import { apiErrorMessage, dispatchAuthExpired } from './client' + function consumeEvent(block: string, onDelta: (text: string) => void): boolean { const lines = block.split('\n') const event = lines.find((line) => line.startsWith('event:'))?.slice(6).trim() ?? 'message' @@ -11,7 +14,7 @@ function consumeEvent(block: string, onDelta: (text: string) => void): boolean { try { payload = JSON.parse(data) } catch { - throw new Error('AI 工作简报返回格式无效') + throw new Error(locale.value === 'en' ? 'Invalid AI work brief response format' : 'AI 工作简报返回格式无效') } if (event === 'delta') { if (payload && typeof payload === 'object' && 'text' in payload && typeof payload.text === 'string') { @@ -23,12 +26,11 @@ function consumeEvent(block: string, onDelta: (text: string) => void): boolean { if (payload && typeof payload === 'object' && 'message' in payload && typeof payload.message === 'string') { throw new Error(payload.message) } - throw new Error('AI 工作简报生成失败,请稍后重试') + throw new Error(locale.value === 'en' ? 'Failed to generate AI work brief, please try again later' : 'AI 工作简报生成失败,请稍后重试') } return event === 'done' } -import { apiErrorMessage, dispatchAuthExpired } from './client' export async function streamPluginAction( actionId: string, onDelta: (text: string) => void, @@ -44,9 +46,9 @@ export async function streamPluginAction( if (!response.ok) { const payload = await response.json().catch(() => null) if (response.status === 401) dispatchAuthExpired() - throw new Error(apiErrorMessage(payload, 'AI 工作简报请求失败,请稍后重试')) + throw new Error(apiErrorMessage(payload, locale.value === 'en' ? 'AI work brief request failed, please try again later' : 'AI 工作简报请求失败,请稍后重试')) } - if (!response.body) throw new Error('AI 工作简报未返回流式内容') + if (!response.body) throw new Error(locale.value === 'en' ? 'AI work brief returned no stream content' : 'AI 工作简报未返回流式内容') const reader = response.body.getReader() const decoder = new TextDecoder() diff --git a/frontend/src/components/ActionEditDrawer.vue b/frontend/src/components/ActionEditDrawer.vue index af08b8c..d0718a9 100644 --- a/frontend/src/components/ActionEditDrawer.vue +++ b/frontend/src/components/ActionEditDrawer.vue @@ -6,8 +6,11 @@ import { api } from '../api/client' import type { UserRef } from '../api/contracts' import type { ActionPriority, ActionStatus } from '../domain/outcomes' import type { ProjectActionSummary } from '../domain/projects' +import { useI18n } from '../i18n' import { errorMessage } from '../utils/errors' -import { ACTION_STATUS_LABELS, PRIORITY_LABELS } from '../utils/labels' +import { ACTION_STATUS_LABELS, PRIORITY_LABELS, actionStatusLabel, priorityLabel } from '../utils/labels' + +const { tr } = useI18n() const props = defineProps<{ show: boolean @@ -30,10 +33,10 @@ const memberOptions = computed(() => props.members.map((member) => ({ label: member.display_name || member.username, value: member.id, }))) -const priorityOptions = (Object.keys(PRIORITY_LABELS) as ActionPriority[]) - .map((value) => ({ label: PRIORITY_LABELS[value], value })) -const statusOptions = (Object.keys(ACTION_STATUS_LABELS) as ActionStatus[]) - .map((value) => ({ label: ACTION_STATUS_LABELS[value], value })) +const priorityOptions = computed(() => (Object.keys(PRIORITY_LABELS) as ActionPriority[]) + .map((value) => ({ label: priorityLabel(value), value }))) +const statusOptions = computed(() => (Object.keys(ACTION_STATUS_LABELS) as ActionStatus[]) + .map((value) => ({ label: actionStatusLabel(value), value }))) watch(() => [props.show, props.action?.id], () => { const action = props.action @@ -72,7 +75,7 @@ async function save() { }) emit('saved', saved) } catch (reason) { - error.value = errorMessage(reason, '行动项保存失败') + error.value = errorMessage(reason, tr('行动项保存失败', 'Failed to save action item')) } finally { saving.value = false } @@ -87,41 +90,41 @@ async function save() { :mask-closable="!saving" @update:show="(value: boolean) => { if (!value) close() }" > - + - - + + - + - - + + - + - + diff --git a/frontend/src/components/AgendaDetail.vue b/frontend/src/components/AgendaDetail.vue index 7cc6187..06c74f7 100644 --- a/frontend/src/components/AgendaDetail.vue +++ b/frontend/src/components/AgendaDetail.vue @@ -216,7 +216,7 @@ async function persistIfDirty(expectedVersion = currentVersion.value): Promise row.id === migrateTargetId.value) if (!target) { - migrateError.value = '请选择目标议题' + migrateError.value = tr('请选择目标议题', 'Please select a target agenda item') return } migrateSaving.value = true @@ -341,7 +341,7 @@ async function submitMigrate() { migrateOpen.value = false emit('changed') } catch (caught) { - migrateError.value = errorMessage(caught, '产出迁移失败') + migrateError.value = errorMessage(caught, tr('产出迁移失败', 'Failed to migrate outcomes')) } finally { migrateSaving.value = false } @@ -361,7 +361,7 @@ async function convertToQuestion() { }) emit('changed') } catch (caught) { - error.value = errorMessage(caught, '转为开放问题失败') + error.value = errorMessage(caught, tr('转为开放问题失败', 'Failed to convert to open question')) } finally { converting.value = false } @@ -376,7 +376,7 @@ async function openCopy() { const page = await api>(`/api/meetings?project_id=${props.meeting.project.id}&limit=200`) copyOptions.value = (page.items ?? []).filter((row) => row.id !== props.meeting.id) } catch (caught) { - copyError.value = errorMessage(caught, '目标会议加载失败') + copyError.value = errorMessage(caught, tr('目标会议加载失败', 'Failed to load target meetings')) } finally { copyLoading.value = false } @@ -385,7 +385,7 @@ async function openCopy() { async function submitCopy() { if (copySaving.value) return if (!copyTargetId.value) { - copyError.value = '请选择目标会议' + copyError.value = tr('请选择目标会议', 'Please select a target meeting') return } copySaving.value = true @@ -408,7 +408,7 @@ async function submitCopy() { copyOpen.value = false emit('changed') } catch (caught) { - copyError.value = errorMessage(caught, '议题复制失败') + copyError.value = errorMessage(caught, tr('议题复制失败', 'Failed to copy agenda item')) } finally { copySaving.value = false } @@ -431,7 +431,7 @@ async function toggleActionStatus(action: ActionItem, newStatus?: ActionStatus) action.version = updated.version emit('changed') } catch (caught) { - error.value = errorMessage(caught, '更新行动项失败') + error.value = errorMessage(caught, tr('更新行动项失败', 'Failed to update action item')) } } diff --git a/frontend/src/components/AgendaQueue.vue b/frontend/src/components/AgendaQueue.vue index 552087a..a6a1afc 100644 --- a/frontend/src/components/AgendaQueue.vue +++ b/frontend/src/components/AgendaQueue.vue @@ -69,7 +69,7 @@ async function dropOn(targetId: string) { emit('changed') } catch (caught) { ordered.value = previous - error.value = errorMessage(caught, '议题排序失败') + error.value = errorMessage(caught, tr('议题排序失败', 'Failed to reorder agenda items')) emit('changed') } finally { saving.value = false @@ -91,7 +91,7 @@ async function addAgenda() { emit('select', item.id) emit('changed') } catch (caught) { - error.value = errorMessage(caught, '议题添加失败') + error.value = errorMessage(caught, tr('议题添加失败', 'Failed to add agenda item')) } finally { saving.value = false } @@ -106,7 +106,7 @@ async function command(item: AgendaItem, action: 'cancel') { guardedId.value = '' emit('changed') } catch (caught) { - error.value = errorMessage(caught, '议题操作失败') + error.value = errorMessage(caught, tr('议题操作失败', 'Agenda action failed')) } finally { saving.value = false } @@ -121,7 +121,7 @@ async function skip(item: AgendaItem) { skipTargetId.value = '' emit('changed') } catch (caught) { - error.value = errorMessage(caught, '议题操作失败') + error.value = errorMessage(caught, tr('议题操作失败', 'Agenda action failed')) } finally { saving.value = false } @@ -140,8 +140,8 @@ async function remove(item: AgendaItem) { } catch (caught) { if (caught instanceof ApiError && caught.code === 'agenda_has_outcomes') { guardedId.value = item.id - error.value = '议题已有产出,请先迁移产出,或将议题标记为取消。' - } else error.value = errorMessage(caught, '议题删除失败') + error.value = tr('议题已有产出,请先迁移产出,或将议题标记为取消。', 'This agenda item already has outcomes. Please migrate its outcomes or cancel the item first.') + } else error.value = errorMessage(caught, tr('议题删除失败', 'Failed to delete agenda item')) } finally { saving.value = false } @@ -158,7 +158,7 @@ async function openMove(item: AgendaItem) { const page = await api>(`/api/meetings?project_id=${props.meeting.project.id}&limit=200`) moveOptions.value = (page.items ?? []).filter((row) => row.id !== props.meeting.id) } catch (caught) { - moveError.value = errorMessage(caught, '目标会议加载失败') + moveError.value = errorMessage(caught, tr('目标会议加载失败', 'Failed to load target meetings')) } finally { moveLoading.value = false } @@ -167,7 +167,7 @@ async function openMove(item: AgendaItem) { async function submitMove() { if (!moveTarget.value || moveSaving.value) return if (!moveForm.value.target_meeting_id) { - moveError.value = '请选择目标会议' + moveError.value = tr('请选择目标会议', 'Please select a target meeting') return } moveSaving.value = true @@ -192,7 +192,7 @@ async function submitMove() { moveTarget.value = null emit('changed') } catch (caught) { - moveError.value = errorMessage(caught, '议题移动失败') + moveError.value = errorMessage(caught, tr('议题移动失败', 'Failed to move agenda item')) } finally { moveSaving.value = false } diff --git a/frontend/src/components/AgendaWorkbench.vue b/frontend/src/components/AgendaWorkbench.vue index 9ac2913..faa86a5 100644 --- a/frontend/src/components/AgendaWorkbench.vue +++ b/frontend/src/components/AgendaWorkbench.vue @@ -46,7 +46,7 @@ async function openAgenda(itemId: string) { selectedId.value = itemId emit('reload') } catch (caught) { - openError.value = errorMessage(caught, '议题开始失败') + openError.value = errorMessage(caught, tr('议题开始失败', 'Failed to start agenda item')) } finally { openingId.value = '' } diff --git a/frontend/src/components/CompletedMeetingChain.vue b/frontend/src/components/CompletedMeetingChain.vue index e05e30c..26ea729 100644 --- a/frontend/src/components/CompletedMeetingChain.vue +++ b/frontend/src/components/CompletedMeetingChain.vue @@ -2,6 +2,7 @@ import { computed, ref } from 'vue' import { NCard, NCollapse, NCollapseItem } from 'naive-ui' import StatusPill from './StatusPill.vue' +import { useI18n } from '../i18n' import { errorMessage } from '../utils/errors' import { api } from '../api/client' @@ -12,6 +13,8 @@ import AttachmentPanel from './AttachmentPanel.vue' import MarkdownEditor from './MarkdownEditor.vue' import MarkdownView from './MarkdownView.vue' +const { tr } = useI18n() + const props = defineProps<{ meeting: Meeting; canContribute: boolean }>() const emit = defineEmits<{ reload: [] }>() const amendmentOpen = ref(false) @@ -103,21 +106,21 @@ const actualMeetingWindow = computed(() => { const startedAt = nullableText(snapshotMeeting.value.started_at) ?? props.meeting.started_at ?? null const completedAt = nullableText(snapshotMeeting.value.completed_at) ?? props.meeting.completed_at ?? null const parts: string[] = [] - if (startedAt) parts.push(`开始 ${formatDateTime(startedAt)}`) - if (completedAt) parts.push(`完成 ${formatDateTime(completedAt)}`) + if (startedAt) parts.push(`${tr('开始', 'Started')} ${formatDateTime(startedAt)}`) + if (completedAt) parts.push(`${tr('完成', 'Completed')} ${formatDateTime(completedAt)}`) return parts.join(' · ') }) const snapshotAgenda = computed(() => records(snapshot.value.agenda_items).map((item) => { const id = text(item.id) return { - ...group(item, id, `completed-agenda-${id}`, text(item.title) || '未命名议题', nullableText(item.status)), + ...group(item, id, `completed-agenda-${id}`, text(item.title) || tr('未命名议题', 'Untitled Agenda Item'), nullableText(item.status)), notesMarkdown: text(item.notes_markdown), estimatedMinutes: nullableNumber(item.estimated_minutes), actualDurationSeconds: nullableNumber(item.actual_duration_seconds), } })) const meetingOutcomes = computed(() => { - const value = group(snapshot.value, 'meeting-outcomes', 'completed-meeting-outcomes', '会议级产出', null, 'meeting_decisions', 'meeting_actions', 'meeting_open_questions') + const value = group(snapshot.value, 'meeting-outcomes', 'completed-meeting-outcomes', tr('会议级产出', 'Meeting-Level Outcomes'), null, 'meeting_decisions', 'meeting_actions', 'meeting_open_questions') return value.decisions.length || value.actions.length || value.openQuestions.length ? value : null }) const outcomeGroups = computed(() => meetingOutcomes.value ? [...snapshotAgenda.value, meetingOutcomes.value] : snapshotAgenda.value) @@ -133,7 +136,7 @@ async function addAmendment() { reason.value = '' content.value = '' emit('reload') - } catch (caught) { error.value = errorMessage(caught, '更正保存失败') } + } catch (caught) { error.value = errorMessage(caught, tr('更正保存失败', 'Failed to save amendment')) } finally { saving.value = false } } @@ -142,63 +145,63 @@ async function addAmendment() {
-

Trusted record

会议完成链条

-
+

Trusted record

{{ tr('会议完成链条', 'Completed Meeting Record') }}

+
-

快照 #{{ meeting.current_snapshot?.completion_number ?? '—' }} · 原始记录保持只读

-

实际会议时长:{{ formatDuration(actualMeetingDurationSeconds) }}

+

{{ tr(`快照 #${meeting.current_snapshot?.completion_number ?? '—'} · 原始记录保持只读`, `Snapshot #${meeting.current_snapshot?.completion_number ?? '—'} · Original record is read-only`) }}

+

{{ tr('实际会议时长:', 'Actual Duration: ') }}{{ formatDuration(actualMeetingDurationSeconds) }}

{{ actualMeetingWindow }}

- +
-

添加更正

更正会作为独立历史记录追加,不会修改完成快照。

+

{{ tr('添加更正', 'Add Amendment') }}

{{ tr('更正会作为独立历史记录追加,不会修改完成快照。', 'Amendments are appended as separate audit records without altering the completion snapshot.') }}

{{ error }}

-

议题记录与产出

+

{{ tr('议题记录与产出', 'Agenda Notes & Outcomes') }}

-

决策 {{ item.decisions.length }}

+

{{ tr('决策', 'Decisions') }} {{ item.decisions.length }}

-
{{ decision.title || '未命名决策' }}
- -
依据
+
{{ decision.title || tr('未命名决策', 'Untitled Decision') }}
+ +
{{ tr('依据', 'Rationale') }}
-

行动项 {{ item.actions.length }}

+

{{ tr('行动项', 'Action Items') }} {{ item.actions.length }}

-
{{ action.content || '未记录行动内容' }}
-

优先级:{{ priorityLabel(action.priority) || '未设置' }}

+
{{ action.content || tr('未记录行动内容', 'No action content') }}
+

{{ tr('优先级:', 'Priority: ') }}{{ priorityLabel(action.priority) || tr('未设置', 'Unset') }}

-

开放问题 {{ item.openQuestions.length }}

+

{{ tr('开放问题', 'Open Questions') }} {{ item.openQuestions.length }}

-
问题
- +
{{ tr('问题', 'Question') }}
+
-

{{ item.status ? '本议题未记录产出' : '本次会议未记录会议级产出' }}

+

{{ item.status ? tr('本议题未记录产出', 'No outcomes recorded for this topic') : tr('本次会议未记录会议级产出', 'No meeting-level outcomes recorded') }}

-

快照中没有议题

+

{{ tr('快照中没有议题', 'No agenda items in snapshot') }}

-

材料

-

更正历史

{{ item.reason }}{{ item.created_by.display_name }} · {{ formatDateTime(item.created_at) }}

尚未添加更正

+

{{ tr('材料', 'Attachments') }}

+

{{ tr('更正历史', 'Amendment History') }}

{{ item.reason }}{{ item.created_by.display_name }} · {{ formatDateTime(item.created_at) }}

{{ tr('尚未添加更正', 'No amendments added yet') }}

diff --git a/frontend/src/components/DecisionDetailDrawer.vue b/frontend/src/components/DecisionDetailDrawer.vue index 2593ddb..04f1c35 100644 --- a/frontend/src/components/DecisionDetailDrawer.vue +++ b/frontend/src/components/DecisionDetailDrawer.vue @@ -6,12 +6,15 @@ import { api } from '../api/client' import type { UserRef } from '../api/contracts' import { session } from '../auth/session' import type { Decision, DecisionReviewer } from '../domain/outcomes' +import { useI18n } from '../i18n' import { errorMessage } from '../utils/errors' -import { REVIEW_STATUS_LABELS } from '../utils/labels' +import { reviewStatusLabel } from '../utils/labels' import { formatDateTime } from '../utils/time' import MarkdownView from './MarkdownView.vue' import StatusPill from './StatusPill.vue' +const { tr } = useI18n() + type SupersedeCandidate = { id: string; title: string; version: number } const props = defineProps<{ @@ -50,10 +53,10 @@ const canSupersede = computed(() => ( const showSupersede = computed(() => canSupersede.value && supersedeCandidates.value.length > 0) const decidedByName = computed(() => { const decision = props.decision - if (!decision) return '尚未定稿' + if (!decision) return tr('尚未定稿', 'Not finalized') if (decision.decided_by) return personName(decision.decided_by) if (decision.decided_by_user_id) return personName(decision.decided_by_user_id) - return '尚未定稿' + return tr('尚未定稿', 'Not finalized') }) const memberOptions = computed(() => props.members.map((member) => ({ label: member.display_name || member.username, @@ -63,16 +66,16 @@ const candidateOptions = computed(() => supersedeCandidates.value.map((candidate label: candidate.title, value: candidate.id, }))) -const reviewStatusOptions = [ - { label: '同意', value: 'approved' }, - { label: '需修改', value: 'changes_requested' }, -] +const reviewStatusOptions = computed(() => [ + { label: tr('同意', 'Approve'), value: 'approved' }, + { label: tr('需修改', 'Request Changes'), value: 'changes_requested' }, +]) function personName(value: UserRef | string | null | undefined): string { if (value && typeof value === 'object') return value.display_name || value.username const id = typeof value === 'string' ? value : null - if (!id) return '未指定' - return props.userNames[id] ?? `用户·${id.slice(0, 8)}` + if (!id) return tr('未指定', 'Unassigned') + return props.userNames[id] ?? tr(`用户·${id.slice(0, 8)}`, `User·${id.slice(0, 8)}`) } function reviewerName(reviewer: DecisionReviewer) { @@ -93,7 +96,7 @@ async function loadCandidates() { )) .map((item) => ({ id: item.id, title: item.title, version: item.version })) } catch (reason) { - error.value = errorMessage(reason, '替代候选加载失败') + error.value = errorMessage(reason, tr('替代候选加载失败', 'Failed to load supersede candidates')) } } @@ -135,7 +138,7 @@ async function finalizeDecision() { }) succeeded() } catch (reason) { - error.value = errorMessage(reason, '决策定稿失败') + error.value = errorMessage(reason, tr('决策定稿失败', 'Failed to finalize decision')) } finally { saving.value = false } @@ -153,7 +156,7 @@ async function withdrawDecision() { }) succeeded() } catch (reason) { - error.value = errorMessage(reason, '决策撤回失败') + error.value = errorMessage(reason, tr('决策撤回失败', 'Failed to withdraw decision')) } finally { saving.value = false } @@ -177,7 +180,7 @@ async function saveEdit() { }) succeeded() } catch (reason) { - error.value = errorMessage(reason, '决策保存失败') + error.value = errorMessage(reason, tr('决策保存失败', 'Failed to save decision')) } finally { saving.value = false } @@ -199,7 +202,7 @@ async function saveReview() { }) succeeded() } catch (reason) { - error.value = errorMessage(reason, '决策评审失败') + error.value = errorMessage(reason, tr('决策评审失败', 'Failed to submit review')) } finally { saving.value = false } @@ -222,7 +225,7 @@ async function saveSupersede() { }) succeeded() } catch (reason) { - error.value = errorMessage(reason, '决策替代失败') + error.value = errorMessage(reason, tr('决策替代失败', 'Failed to supersede decision')) } finally { saving.value = false } @@ -237,103 +240,122 @@ async function saveSupersede() { :mask-closable="!saving" @update:show="(value: boolean) => { if (!value) close() }" > - +
- 创建人:{{ personName(decision.created_by) }} - 定稿人:{{ decidedByName }} - 已被替代 + {{ tr('创建人:', 'Created by: ') }}{{ personName(decision.created_by) }} + {{ tr('定稿人:', 'Finalized by: ') }}{{ decidedByName }} + {{ tr('已被替代', 'Superseded') }}
-

替代了决策 {{ decision.supersedes_decision_id }}

+

{{ tr(`替代了决策 ${decision.supersedes_decision_id}`, `Supersedes decision ${decision.supersedes_decision_id}`) }}

-

决策内容

+

{{ tr('决策内容', 'Decision Statement') }}

-

决策理由

+

{{ tr('决策理由', 'Rationale') }}

-

评审人

+

{{ tr('评审人', 'Reviewers') }}

{{ reviewerName(reviewer) }} - {{ REVIEW_STATUS_LABELS[reviewer.status] }} + {{ reviewStatusLabel(reviewer.status) }} {{ formatDateTime(reviewer.responded_at) }} {{ reviewer.comment }}
-

尚未指定评审人。

+

{{ tr('尚未指定评审人。', 'No reviewers assigned.') }}

- {{ editing ? '收起编辑' : '编辑' }} - + {{ editing ? tr('收起编辑', 'Close Edit') : tr('编辑', 'Edit') }} + - 定稿后决策将生效且不可再编辑,确定定稿吗? + {{ tr('定稿后决策将生效且不可再编辑,确定定稿吗?', 'Once finalized, the decision becomes final and cannot be edited. Finalize now?') }} - 撤回 + {{ tr('撤回', 'Withdraw') }}
- - + + - - + + - - + + - + - 保存修改 + {{ tr('保存修改', 'Save Changes') }}
-

提交评审

+

{{ tr('提交评审', 'Submit Review') }}

- + - - + + - 提交评审 + {{ tr('提交评审', 'Submit Review') }}
-

替代决策

-

替代后本决策标记为已替代,新决策记录替代来源。

+

{{ tr('替代决策', 'Supersede Decision') }}

+

{{ tr('替代后本决策标记为已替代,新决策记录替代来源。', 'This decision will be marked as superseded by the selected final decision.') }}

- 确认替代 + {{ tr('确认替代', 'Confirm Supersede') }}
+ + diff --git a/frontend/src/components/FocusMode.vue b/frontend/src/components/FocusMode.vue index 4785ac5..5eae097 100644 --- a/frontend/src/components/FocusMode.vue +++ b/frontend/src/components/FocusMode.vue @@ -170,7 +170,7 @@ async function persistCurrentNotes(): Promise { emit('reload') return true } catch (caught) { - message.error(errorMessage(caught, '议题笔记保存失败')) + message.error(errorMessage(caught, tr('议题笔记保存失败', 'Failed to save agenda notes'))) return false } finally { saving.value = false @@ -186,13 +186,17 @@ async function completeAndAdvance() { method: 'POST', body: JSON.stringify({ expected_version: localVersion.value }), }) - message.success(`议题「${currentItem.value.title}」已完成`) + message.success( + locale.value === 'en' + ? `Agenda "${currentItem.value.title}" completed` + : `议题「${currentItem.value.title}」已完成`, + ) emit('reload') if (result.next_agenda_item_id) { selectedAgendaId.value = result.next_agenda_item_id } } catch (caught) { - message.error(errorMessage(caught, '完成议题失败')) + message.error(errorMessage(caught, tr('完成议题失败', 'Failed to complete agenda item'))) } finally { advancing.value = false } @@ -231,7 +235,7 @@ async function smartExtractFocusNotes() { ) emit('reload') } catch (caught) { - message.error(errorMessage(caught, 'AI 提炼失败,请检查笔记标签格式')) + message.error(errorMessage(caught, tr('AI 提炼失败,请检查笔记标签格式', 'AI extraction failed, please check notes format'))) } finally { extracting.value = false } @@ -269,7 +273,7 @@ async function toggleActionStatus(action: ActionItem, targetStatus?: ActionStatu ) emit('reload') } catch (caught) { - message.error(errorMessage(caught, '更新行动项失败')) + message.error(errorMessage(caught, tr('更新行动项失败', 'Failed to update action item'))) } } diff --git a/frontend/src/components/MarkdownEditor.vue b/frontend/src/components/MarkdownEditor.vue index 04c140b..00d7cd1 100644 --- a/frontend/src/components/MarkdownEditor.vue +++ b/frontend/src/components/MarkdownEditor.vue @@ -3,18 +3,23 @@ import { Crepe } from '@milkdown/crepe' export type MarkdownEditorHandle = { flush: () => string } import { getMarkdown, replaceAll } from '@milkdown/kit/utils' -import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' +import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' +import { useI18n } from '../i18n' import '@milkdown/crepe/theme/common/style.css' import '@milkdown/crepe/theme/frame.css' +const { tr } = useI18n() + const props = withDefaults(defineProps<{ modelValue: string disabled?: boolean placeholder?: string label?: string registerEditor?: (writer: ((markdown: string) => void) | null) => void -}>(), { disabled: false, placeholder: '输入 Markdown…', label: 'Markdown 编辑器' }) +}>(), { disabled: false }) +const effectivePlaceholder = computed(() => props.placeholder ?? tr('输入 Markdown…', 'Enter Markdown…')) +const effectiveLabel = computed(() => props.label ?? tr('Markdown 编辑器', 'Markdown Editor')) const emit = defineEmits<{ 'update:modelValue': [value: string] }>() const root = ref(null) @@ -52,7 +57,7 @@ onMounted(async () => { [Crepe.Feature.Latex]: false, }, featureConfigs: { - [Crepe.Feature.Placeholder]: { text: props.placeholder, mode: 'block' }, + [Crepe.Feature.Placeholder]: { text: effectivePlaceholder.value, mode: 'block' }, }, }) editor.on((listener) => { @@ -91,7 +96,7 @@ onBeforeUnmount(() => {