From 85539393679e89fff435dbe77aa399935b31f9e0 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sat, 13 Jun 2026 23:54:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E7=9C=8B=E6=9D=BF=20zh/en=20?= =?UTF-8?q?=E5=8F=8C=E8=AF=AD=20i18n=20[tsk=5F6-VbN2XHWT4s]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 采用 main 工作树中已完成的完整 i18n 实现(293 个 t() 调用、188 处 I18N、全 UI 接入),此前一直未提交进任何分支。原 i18n 任务 B4 多次因环境(worker 崩/TLS 证书/abort)失败被 requeue 重做,改为直接采用这份现成成果,B4 收口。board 此前已在此版本上运行。 Co-Authored-By: Claude Opus 4.8 --- web/app.js | 980 +++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 687 insertions(+), 293 deletions(-) diff --git a/web/app.js b/web/app.js index 78587bb..72435dd 100644 --- a/web/app.js +++ b/web/app.js @@ -3,6 +3,334 @@ ════════════════════════════════════════════════════════════ */ 'use strict'; +// ══════════════════════════════════════════════════════════════ +// i18n · zh / en 双语(任务内容/项目名/转录等用户数据不翻译) +// ══════════════════════════════════════════════════════════════ +const I18N = { + zh: { + logoSub: '多项目任务调度台', + projects: '项目', newProject: '+ 新建', + connecting: '连接中…', liveConn: '实时连接', + disconnected: '已断开 · {n}s 后重连', + badgeGate: '待审批', badgeReady: '待执行', badgeRun: '执行中', badgeBlocked: '被阻塞', badgeTotal: '总量', + badgeGateTip: '等待你裁决的审批闸', + badgeReadyTip: '可执行(依赖已满足)+ 排队中', + badgeRunTip: 'agent 正在执行', + badgeBlockedTip: '被依赖阻塞(依赖完成后自动放行)', + badgeTotalTip: '未完成任务总量(不含 done / 已拆解容器 / 取消)', + sync: '同步 todo', config: '配置', configTip: '项目配置(并发 / 工作模式 / todo 同步)', + maxConcurrency: '最大并发', workMode: '工作模式', current: '当前', + save: '保存配置', collapse: '收起', + projLogo: 'Logo', logoPh: '图片 URL 或仓库内相对路径;留空=自动', + verifyCmd: '校验命令', verifyPh: 'npm test · go test ./...(可空)', + model: '项目 Model', modelPh: 'claude-opus-4-5(可空,留空用全局默认)', + autonomy: { manual: 'manual · 手动', 'auto-easy': 'auto-easy · 自动执行 Easy', 'auto-approved': 'auto-approved · 自动执行已批准' }, + autonomyShort: { manual: '手动', 'auto-easy': '自动 · Easy', 'auto-approved': '自动 · 已批准' }, + agentSection: 'Agent 执行', agentEmpty: '无 agent 在执行', running: 'RUNNING', since: '开始', + usageUnavail: '额度信息不可用', quota: '额度', quotaWeek: '周', quotaReset: '{t} 后重置', + gateSection: '审批闸 · 等待裁决', gatePreview: '⛶ 预览', + gateAcceptMerge: '✓ 通过并合并', gateAccept: '✓ 接受', gateOnlyPass: '仅通过', + gateMergeNote: '通过 = 自动把任务分支合并到 {branch}(仅本地合并,不 push)', + gateReject: '✗ 拒绝', rejectPlaceholder: '驳回意见(必填)——说明问题与改进方向', + confirmReject: '确认驳回', + taskSection: '任务树', newTask: '+ 新建任务', + titleLabel: '标题', titlePlaceholder: '要做什么', + complexity: '复杂度', cplxAuto: 'AUTO', + parentTask: '父任务', topLevel: '(顶层)', priority: '优先级', + p0: 'P0 · 高', p1: 'P1 · 中', p2: 'P2 · 低', + create: '创建任务', cancel: '取消', + searchPh: '搜索标题…', clearFilter: '✕ 清除筛选', matchCount: '{n} 项命中 / {total}', + events: '事件流', noEvents: '暂无事件', + sideEmpty: '暂无项目,点「+ 新建」登记第一个仓库', + noProject: '未选择项目', projActive: '活跃', projPaused: '暂停', + cfgCurrent: '当前:并发 {c} · 模式 {m}', + syncMeta: '{t}同步', noTodoTip: '未发现 todo.json — 约定路径 /todo/todo.json', + syncTip: '从 /todo/todo.json 同步任务', + archiveSection: '已归档', archiveTip: '点击查看完整详情', + archiveSubs: '含 {n} 子任务', archiveModalTitle: '归档详情', + archiveDelete: '🗑 删除归档', closeTip: '关闭(Esc)', + pagePrev: '‹ 上一页', pageNext: '下一页 ›', pageOf: '第 {a} / {b} 页', perPage: '每页', + attrsLabel: '属性', runsLabel: '执行历史({n} 次 run)', + resultLabel: '执行结果', approvalsLabel: '审批记录', timelineLabel: '状态流转时间线', + branch: '分支', accepted: '接受', rejected: '驳回', approver: '审批人', + runKind: { executor: '执行', reviewer: 'code review', security: '安全审计', planner: '规划' }, + runSt: { succeeded: '成功', failed: '失败', started: '进行中', cancelled: '取消' }, + planDocLabel: 'PLAN · 分析与拆解', specDocLabel: 'SPEC · 改动方案', opsDocLabel: 'OPERATIONS · 执行的操作', + planDetailLabel: 'PLAN · 分析拆解', specDetailLabel: 'SPEC · 方案', opsDetailLabel: 'OPERATIONS · 操作', + resultDetailLabel: 'RESULT · 执行结果', diffLabel: 'DIFF 摘要', + mergeStatus: '合并状态', + mergeFailedBanner: '⚠️ 自动合并失败(冲突)→ 已建最高优先级补救任务', + mergeRemNote: '补救任务解决冲突并合并后,本任务将自动标记完成,无需手动「仅通过」。', + reviewLabel: 'CODE REVIEW', securityLabel: '安全审计', + verdictApprove: '建议通过', verdictReject: '建议拒绝', verdictNone: '未复审', + pendingDoc: '待 Claude Code 产出(经 MCP 写入并提交评审)', + approvalsHistLabel: '审批历史', + depsLabel: 'DEPS · 依赖(全部完成才可执行)', + depEmpty: '暂无依赖', depEdit: '✎ 编辑', + depsEditLabel: 'DEPS · 编辑依赖(勾选当前项目其它任务;已排除自身与会成环者)', + depNoOthers: '本项目暂无其它可选任务', saveDeps: '保存依赖({n})', + noOutput: '暂无产出与历史 —— 状态:{st}', + foldExpand: '展开({n} 字符)', + transcriptView: '▶ 查看转录', transcriptHide: '▼ 收起转录', + transcriptLoading: '加载中…', transcriptEmpty: '(无可展示的转录内容)', + transcriptBadgeA: 'assistant', transcriptBadgeR: '结论', + transcriptBadgeW: '模型回退', transcriptBadgeT: '工具调用', + transcriptBadgeTR: '工具结果', transcriptBadgeTRErr: '工具结果 · 报错', + transcriptTrunc: '…(已截断)', fullRead: '全屏阅读', + treeNoProject: '先在左侧新建或选择一个项目', + treeEmpty: '还没有任务 —— 点上方「+ 新建任务」开始', + treeAllDone: '全部任务已完成归档(见页面底部归档区)', + treeNoMatch: '没有任务命中当前筛选 —— 放宽条件或「清除筛选」', + depsWait: '等依赖 {n}', + metricsSection: '指标 · 健康度与成本', metricsTasks: '· {n} 任务', + metricDur: '平均执行时长', metricDurSub: 'P50 {p50} · P95 {p95} · n={n}', metricDurNone: '暂无已结束执行', + metricSucc: '执行成功率', metricSuccSub: '{ok}/{total} 成功 · 失败 {fail}', metricSuccNone: '暂无执行', + metricReview: '复审通过率', metricReviewSub: '复审 {ra}/{rt} · 安全 {sr} ({sa}/{st2})', + metricRetry: '重试率', metricRetrySub: '重试 {r} 次 · 需人工 {n}', + metricCost: '额度消耗', metricCostSub: '执行 {dur} · {est}', metricEstTag: '估算', + metricModelHead: '按模型分布', metricEst: '(时长×档位 粗估)', + fgroups: { todo: '待办', doing: '进行中', container: '容器', gate: '待审批', bad: '异常', hold: '挂起' }, + status: { init: '新建', analyzing: '分析拆解中', plan_review: '待确认拆解', decomposed: '已拆解', speccing: '写方案中', spec_review: '待确认方案', ready: '可执行', blocked: '被阻塞', queued: '排队中', executing: '执行中', exec_review: '待审/合', failed: '失败', needs_attention: '需人工', done: '完成', paused: '暂停', cancelled: '取消' }, + gates: { plan: '拆解评审', spec: '方案评审', exec: '结果评审' }, + eventTypes: { 'task.created': '任务创建', 'task.updated': '任务更新', 'status.changed': '状态变更', 'approval.requested': '请求审批', 'approval.granted': '审批通过', 'approval.rejected': '审批驳回', 'run.started': '运行开始', 'run.finished': '运行结束', 'project.synced': 'todo 同步', 'merge.failed': '合并失败', 'merge.remediated': '补救完成 → 完成' }, + tlAuto: { 'deps-met': '依赖满足自动放行', 'deps-reconcile': '启动对账', 'deps-unmet': '依赖未满足落位', 'children-done': '子任务全完成自动收口', 'import-done': '导入历史完成', 'daemon-restart': '重启恢复', 'merge-remediated': '补救完成自动收口' }, + tlOrchestrator: '编排器', tlImporter: '导入器', + tlApproveGranted: '审批通过({gate})', tlApproveRejected: '审批驳回({gate}){reason}', + tlRunStarted: 'run 开始({kind})', tlRunFinished: 'run 结束({st})', + tlMergeFailed: '合并失败{files}{task}', tlMergeOk: '补救完成 → 完成{task}', + tlMergeFailFiles: '(冲突文件:{files})', tlMergeTask: ' → 补救任务「{title}」', + tlMergeOkTask: '(补救任务「{title}」已合并)', + evStatusChange: '{name}:{from} → {to}', evProjectReg: '项目「{name}」已登记', + evTaskCreated: '{title}({cplx})', evMergeFail: '{name}:合并失败{files} → 补救任务', + evMergeOk: '{name}:补救完成 → 完成', evTaskUpdated: '{name} · 字段 {field}', + evSynced: '新建 {created} · 推进 done {done} · 跳过 {skipped}', + conflictFiles: '(冲突文件:{files})', + toastCreated: '任务已创建', toastAccepted: '已接受', + toastAcceptedNoMerge: '已通过(未合并,分支保留)', + toastRejected: '已驳回,任务退回返工', + toastSaved: '配置已保存', + toastSynced: '同步完成:新建 {created} · 推进 done {done} · 跳过 {skipped}', + toastWsSynced: 'todo 同步:新建 {created} · 推进 done {done} · 跳过 {skipped}', + toastCancelled: '任务已取消', toastDeleted: '任务已删除', + toastDeletedWithSubs: '任务已删除(含 {n} 子孙)', + toastArchiveDeleted: '归档任务已删除', toastArchiveDeletedSubs: '归档任务已删除(含 {n} 子孙)', + toastDepsUpdated: '依赖已更新({n} 项,按依赖重算 ready/blocked)', + toastCplxSet: '复杂度已改为 {c}(状态按新复杂度重置)', + toastProjectCreated: '项目「{name}」已创建', + toastTaskNoProject: '请先选择项目', toastTitleRequired: '标题必填', + toastNameRequired: '名称与仓库路径必填', + toastTaskNotFound: '任务不在当前项目', + toastConcInt: '最大并发必须是 ≥1 的整数', + toastRejectRequired: '驳回意见不能为空', + reorderSaved: '项目顺序已保存', + cancelConfirm: '确定取消任务「{title}」{hint}?\n取消后任务进入归档,不影响子任务执行。', + cancelHint: '(含 {n} 个直接子任务,子任务保持原状)', + deleteConfirm: '彻底删除「{title}」{hint}?\n此操作不可恢复,所有相关数据(执行历史、审批记录)将一并删除。', + deleteHint: '及其 {n} 个子孙任务', + cancelDetailMsg: '确定取消任务「{title}」?(将进入归档,子任务保持原状)', + deleteDetailMsg: '彻底删除「{title}」{hint}?此操作不可恢复!', + deleteDetailHint: '(含 {n} 个直接子任务,及其全部子孙)', + cancelBtn: '取消任务', cancelConfirmBtn: '确认取消', cancelAbortBtn: '算了', + deleteBtn: '🗑 删除', deleteBtnHint: '🗑 删除(含 {n} 子任务)', + deleteConfirmBtn: '确认删除', deleteAbortBtn: '算了', + archiveDeleteConfirm: '彻底删除「{title}」{hint}?\n此操作不可恢复,所有相关数据将一并删除。', + archiveDeleteHint: '及其 {n} 个子孙任务', + lang: '中文', langTip: '切换语言 / Switch language', + scheduleScore: '调度 score 优先', models: '模型', + }, + en: { + logoSub: 'MULTI-PROJECT ORCHESTRATOR', + projects: 'PROJECTS', newProject: '+ New', + connecting: 'Connecting…', liveConn: 'Live', + disconnected: 'Disconnected · retry in {n}s', + badgeGate: 'pending review', badgeReady: 'to run', badgeRun: 'running', badgeBlocked: 'blocked', badgeTotal: 'total', + badgeGateTip: 'Approval gates awaiting your decision', + badgeReadyTip: 'Ready (deps met) + queued', + badgeRunTip: 'Agents currently executing', + badgeBlockedTip: 'Blocked (auto-released when deps complete)', + badgeTotalTip: 'Open tasks (excl. done / decomposed containers / cancelled)', + sync: 'Sync todo', config: 'Config', configTip: 'Project config (concurrency / autonomy / todo sync)', + maxConcurrency: 'Max concurrency', workMode: 'Autonomy', current: 'Current', + save: 'Save config', collapse: 'Close', + projLogo: 'Logo', logoPh: 'Image URL or repo-relative path; empty = auto', + verifyCmd: 'Verify command', verifyPh: 'npm test · go test ./... (optional)', + model: 'Project Model', modelPh: 'claude-opus-4-5 (optional, empty = global default)', + autonomy: { manual: 'manual · manual', 'auto-easy': 'auto-easy · Auto Easy', 'auto-approved': 'auto-approved · Auto approved' }, + autonomyShort: { manual: 'Manual', 'auto-easy': 'Auto · Easy', 'auto-approved': 'Auto · Approved' }, + agentSection: 'Agent runs', agentEmpty: 'No agents running', running: 'RUNNING', since: '', + usageUnavail: 'Usage info unavailable', quota: 'Quota', quotaWeek: 'week', quotaReset: 'resets in {t}', + gateSection: 'Approval gates · awaiting decision', gatePreview: '⛶ Preview', + gateAcceptMerge: '✓ Accept & merge', gateAccept: '✓ Accept', gateOnlyPass: 'Pass only', + gateMergeNote: 'Accept = merge task branch into {branch} (local only, no push)', + gateReject: '✗ Reject', rejectPlaceholder: 'Rejection feedback (required) — describe issues', + confirmReject: 'Confirm reject', + taskSection: 'Task tree', newTask: '+ New task', + titleLabel: 'Title', titlePlaceholder: 'What needs doing', + complexity: 'Complexity', cplxAuto: 'AUTO', + parentTask: 'Parent', topLevel: '(top level)', priority: 'Priority', + p0: 'P0 · high', p1: 'P1 · medium', p2: 'P2 · low', + create: 'Create task', cancel: 'Cancel', + searchPh: 'Search titles…', clearFilter: '✕ Clear filters', matchCount: '{n} / {total} matches', + events: 'Events', noEvents: 'No events yet', + sideEmpty: 'No projects — click "+ New" to register a repo', + noProject: 'No project selected', projActive: 'Active', projPaused: 'Paused', + cfgCurrent: 'Current: concurrency {c} · mode {m}', + syncMeta: 'synced {t}', noTodoTip: 'todo.json not found — expected at /todo/todo.json', + syncTip: 'Sync from /todo/todo.json', + archiveSection: 'Archive', archiveTip: 'Click for full detail', + archiveSubs: '{n} subtasks', archiveModalTitle: 'Archive detail', + archiveDelete: '🗑 Delete', closeTip: 'Close (Esc)', + pagePrev: '‹ Prev', pageNext: 'Next ›', pageOf: 'Page {a} / {b}', perPage: 'Per page', + attrsLabel: 'ATTRIBUTES', runsLabel: 'RUN HISTORY ({n} runs)', + resultLabel: 'RESULT', approvalsLabel: 'APPROVALS', timelineLabel: 'STATUS TIMELINE', + branch: 'Branch', accepted: 'accepted', rejected: 'rejected', approver: 'Approver', + runKind: { executor: 'execute', reviewer: 'code review', security: 'security audit', planner: 'plan' }, + runSt: { succeeded: 'succeeded', failed: 'failed', started: 'in progress', cancelled: 'cancelled' }, + planDocLabel: 'PLAN · Analysis & breakdown', specDocLabel: 'SPEC · Change spec', opsDocLabel: 'OPERATIONS · Planned ops', + planDetailLabel: 'PLAN · Analysis', specDetailLabel: 'SPEC · Proposal', opsDetailLabel: 'OPERATIONS · Actions', + resultDetailLabel: 'RESULT · Execution result', diffLabel: 'DIFF SUMMARY', + mergeStatus: 'Merge status', + mergeFailedBanner: '⚠️ Auto-merge failed (conflict) → remediation task created', + mergeRemNote: 'Once the remediation task resolves conflicts and merges, this task will be auto-completed.', + reviewLabel: 'CODE REVIEW', securityLabel: 'SECURITY AUDIT', + verdictApprove: 'Recommend approve', verdictReject: 'Recommend reject', verdictNone: 'Not reviewed', + pendingDoc: 'Awaiting Claude Code output (written via MCP, then submitted for review)', + approvalsHistLabel: 'Approval history', + depsLabel: 'DEPS (all must complete to run)', + depEmpty: 'No dependencies', depEdit: '✎ Edit', + depsEditLabel: 'DEPS · Edit dependencies (select other tasks; self and cycle candidates excluded)', + depNoOthers: 'No other tasks available in this project', saveDeps: 'Save deps ({n})', + noOutput: 'No output yet — status: {st}', + foldExpand: 'Expand ({n} chars)', + transcriptView: '▶ View transcript', transcriptHide: '▼ Hide transcript', + transcriptLoading: 'Loading…', transcriptEmpty: '(No transcript content)', + transcriptBadgeA: 'assistant', transcriptBadgeR: 'result', + transcriptBadgeW: 'model fallback', transcriptBadgeT: 'tool call', + transcriptBadgeTR: 'tool result', transcriptBadgeTRErr: 'tool result · error', + transcriptTrunc: '…(truncated)', fullRead: 'Read fullscreen', + treeNoProject: 'Create or select a project on the left', + treeEmpty: 'No tasks yet — click "+ New task" above', + treeAllDone: 'All tasks archived (see archive section below)', + treeNoMatch: 'No tasks match current filters — relax filters or "Clear filters"', + depsWait: 'deps {n}', + metricsSection: 'Metrics · Health & cost', metricsTasks: '· {n} tasks', + metricDur: 'Avg run time', metricDurSub: 'P50 {p50} · P95 {p95} · n={n}', metricDurNone: 'No completed runs yet', + metricSucc: 'Success rate', metricSuccSub: '{ok}/{total} succeeded · {fail} failed', metricSuccNone: 'No runs yet', + metricReview: 'Review pass rate', metricReviewSub: 'Review {ra}/{rt} · Security {sr} ({sa}/{st2})', + metricRetry: 'Retry rate', metricRetrySub: '{r} retries · {n} needs attention', + metricCost: 'Cost units', metricCostSub: 'Run time {dur} · {est}', metricEstTag: 'estimate', + metricModelHead: 'By model', metricEst: '(time × tier estimate)', + fgroups: { todo: 'To do', doing: 'In progress', container: 'Container', gate: 'Pending review', bad: 'Issues', hold: 'On hold' }, + status: { init: 'New', analyzing: 'Analyzing', plan_review: 'Plan review', decomposed: 'Decomposed', speccing: 'Drafting spec', spec_review: 'Spec review', ready: 'Ready', blocked: 'Blocked', queued: 'Queued', executing: 'Executing', exec_review: 'Review & merge', failed: 'Failed', needs_attention: 'Needs attention', done: 'Done', paused: 'Paused', cancelled: 'Cancelled' }, + gates: { plan: 'PLAN REVIEW', spec: 'SPEC REVIEW', exec: 'EXEC REVIEW' }, + eventTypes: { 'task.created': 'Task created', 'task.updated': 'Task updated', 'status.changed': 'Status changed', 'approval.requested': 'Approval requested', 'approval.granted': 'Approval granted', 'approval.rejected': 'Approval rejected', 'run.started': 'Run started', 'run.finished': 'Run finished', 'project.synced': 'Todo synced', 'merge.failed': 'Merge failed', 'merge.remediated': 'Remediation done → complete' }, + tlAuto: { 'deps-met': 'deps met, auto-released', 'deps-reconcile': 'reconcile started', 'deps-unmet': 'deps unmet, blocked', 'children-done': 'all children done, auto-closed', 'import-done': 'import history done', 'daemon-restart': 'restored on restart', 'merge-remediated': 'remediation done, auto-closed' }, + tlOrchestrator: 'orchestrator', tlImporter: 'importer', + tlApproveGranted: 'Approved ({gate})', tlApproveRejected: 'Rejected ({gate}){reason}', + tlRunStarted: 'Run started ({kind})', tlRunFinished: 'Run finished ({st})', + tlMergeFailed: 'Merge failed{files}{task}', tlMergeOk: 'Remediation done → complete{task}', + tlMergeFailFiles: ' (conflicts: {files})', tlMergeTask: ' → remediation: "{title}"', + tlMergeOkTask: ' (remediation "{title}" merged)', + evStatusChange: '{name}: {from} → {to}', evProjectReg: 'Project "{name}" registered', + evTaskCreated: '{title} ({cplx})', evMergeFail: '{name}: merge failed{files} → remediation', + evMergeOk: '{name}: remediation done → complete', evTaskUpdated: '{name} · field {field}', + evSynced: 'Created {created} · advanced done {done} · skipped {skipped}', + conflictFiles: ' (conflicts: {files})', + toastCreated: 'Task created', toastAccepted: 'Accepted', + toastAcceptedNoMerge: 'Accepted (no merge, branch kept)', + toastRejected: 'Rejected — task sent back', + toastSaved: 'Config saved', + toastSynced: 'Synced: {created} created · {done} advanced · {skipped} skipped', + toastWsSynced: 'Todo sync: {created} created · {done} advanced · {skipped} skipped', + toastCancelled: 'Task cancelled', toastDeleted: 'Task deleted', + toastDeletedWithSubs: 'Task deleted ({n} descendants)', + toastArchiveDeleted: 'Archive deleted', toastArchiveDeletedSubs: 'Archive deleted ({n} descendants)', + toastDepsUpdated: 'Dependencies updated ({n} items, ready/blocked recalculated)', + toastCplxSet: 'Complexity set to {c} (status reset per new complexity)', + toastProjectCreated: 'Project "{name}" created', + toastTaskNoProject: 'Select a project first', toastTitleRequired: 'Title is required', + toastNameRequired: 'Name and repo path are required', + toastTaskNotFound: 'Task not found in current project', + toastConcInt: 'Max concurrency must be an integer ≥1', + toastRejectRequired: 'Rejection feedback is required', + reorderSaved: 'Project order saved', + cancelConfirm: 'Cancel task "{title}"{hint}?\nThis moves the task to archive; subtasks are unaffected.', + cancelHint: ' (includes {n} direct subtasks)', + deleteConfirm: 'Permanently delete "{title}"{hint}?\nThis cannot be undone — all data (runs, approvals) will be removed.', + deleteHint: ' and {n} descendants', + cancelDetailMsg: 'Cancel task "{title}"? (moves to archive; subtasks unaffected)', + deleteDetailMsg: 'Permanently delete "{title}"{hint}? Cannot be undone!', + deleteDetailHint: ' (includes {n} direct subtasks and all descendants)', + cancelBtn: 'Cancel task', cancelConfirmBtn: 'Confirm cancel', cancelAbortBtn: 'Never mind', + deleteBtn: '🗑 Delete', deleteBtnHint: '🗑 Delete ({n} subtasks)', + deleteConfirmBtn: 'Confirm delete', deleteAbortBtn: 'Never mind', + archiveDeleteConfirm: 'Permanently delete "{title}"{hint}?\nThis cannot be undone — all data will be removed.', + archiveDeleteHint: ' and {n} descendants', + lang: 'EN', langTip: '切换语言 / Switch language', + scheduleScore: 'score-first scheduling', models: 'Models', + }, +}; + +// ── i18n 状态与核心函数 ── +const i18nState = { lang: localStorage.getItem('maestro_lang') || 'zh' }; + +/** 取翻译字符串,支持 {varName} 插值 */ +function t(key, vars) { + const d = I18N[i18nState.lang] || I18N.zh; + let val = d[key]; + if (val == null) val = I18N.zh[key]; + if (val == null) return key; + if (vars && typeof val === 'string') { + val = val.replace(/\{(\w+)\}/g, (_, k) => vars[k] != null ? String(vars[k]) : '{' + k + '}'); + } + return val; +} + +/** 取嵌套翻译(如 status.init、fgroups.todo) */ +function tSub(groupKey, subKey, fallback) { + const d = I18N[i18nState.lang] || I18N.zh; + const grp = d[groupKey]; + if (grp && grp[subKey] != null) return grp[subKey]; + const fb = I18N.zh[groupKey]; + return (fb && fb[subKey] != null) ? fb[subKey] : (fallback || subKey); +} + +/** 切换语言并刷新全部 UI */ +function setLang(lang) { + if (!I18N[lang]) return; + i18nState.lang = lang; + localStorage.setItem('maestro_lang', lang); + document.documentElement.lang = lang === 'zh' ? 'zh-CN' : 'en'; + applyStaticI18n(); + renderAll(); +} + +/** 更新 HTML 中的 data-i18n / data-i18n-title / data-i18n-ph 静态元素 */ +function applyStaticI18n() { + document.querySelectorAll('[data-i18n]').forEach((el) => { + el.textContent = t(el.dataset.i18n); + }); + document.querySelectorAll('[data-i18n-title]').forEach((el) => { + el.title = t(el.dataset.i18nTitle); + }); + document.querySelectorAll('[data-i18n-ph]').forEach((el) => { + el.placeholder = t(el.dataset.i18nPh); + }); + document.documentElement.lang = i18nState.lang === 'zh' ? 'zh-CN' : 'en'; + const lb = document.getElementById('langBtn'); + if (lb) lb.textContent = t('lang'); +} + +// ── 状态/闸/事件标签(读当前语言) ── +function statusLabel(st) { return tSub('status', st) || st; } +function gateLabel(g) { return tSub('gates', g) || g; } +function autonomyLabel(a) { return tSub('autonomy', a) || a; } +function autonomyShort(a) { return tSub('autonomyShort', a) || a; } +function eventLabel(tp) { return tSub('eventTypes', tp) || tp; } +function fgroupLabel(key) { return tSub('fgroups', key) || key; } +function runKindLabel(k) { return tSub('runKind', k) || k; } +function runStLabel(s) { return tSub('runSt', s) || s; } + // ── SVG 图标集(v1.1 双色调,24×24,2px 圆头;currentColor=基础描边,--icon-accent=信号色) ── const ICONS = { task: ``, @@ -25,6 +353,7 @@ const ICONS = { function icon(name) { return ICONS[name] || ''; } // ── 模型常量(与 src/model/status.ts / complexity.ts 对齐) ── +// STATUS_LABEL 保留为 zh 默认,运行时通过 statusLabel() 读当前语言 const STATUS_LABEL = { init: '新建', analyzing: '分析拆解中', plan_review: '待确认拆解', decomposed: '已拆解', speccing: '写方案中', spec_review: '待确认方案', @@ -40,8 +369,10 @@ const STATUS_GROUP = { done: 'done', cancelled: 'dead', }; const GATE_OF = { plan_review: 'plan', spec_review: 'spec', exec_review: 'exec' }; +// GATE_LABEL 保留 zh 默认,运行时通过 gateLabel() 读当前语言 const GATE_LABEL = { plan: '拆解评审', spec: '方案评审', exec: '结果评审' }; const CPLX_LABEL = { hard: 'HARD', medium: 'MED', easy: 'EASY' }; +// EVENT_LABEL 保留 zh 默认,运行时通过 eventLabel() 读当前语言 const EVENT_LABEL = { 'task.created': '任务创建', 'task.updated': '任务更新', 'status.changed': '状态变更', 'approval.requested': '请求审批', 'approval.granted': '审批通过', 'approval.rejected': '审批驳回', @@ -55,17 +386,18 @@ const EVENT_CLASS = { 'project.synced': 'ev-created', 'merge.failed': 'ev-rejected', 'merge.remediated': 'ev-approve', }; +// AUTONOMY_LABEL 保留 zh 默认,运行时通过 autonomyShort() 读当前语言 const AUTONOMY_LABEL = { manual: '手动', 'auto-easy': '自动执行 Easy', 'auto-approved': '自动执行已批准', }; -// 任务树筛选:状态按状态机分组(组可整组开关,组内可单选) +// 任务树筛选:使用 i18n key 作为组名(渲染时通过 fgroupLabel() 翻译) const FILTER_GROUPS = [ - ['待办', ['init', 'ready', 'blocked']], - ['进行中', ['analyzing', 'speccing', 'queued', 'executing']], // 已拆解是容器态,不算进行中 - ['容器', ['decomposed']], - ['待审批', ['plan_review', 'spec_review', 'exec_review']], - ['异常', ['failed', 'needs_attention']], - ['挂起', ['paused']], + ['todo', ['init', 'ready', 'blocked']], + ['doing', ['analyzing', 'speccing', 'queued', 'executing']], + ['container', ['decomposed']], + ['gate', ['plan_review', 'spec_review', 'exec_review']], + ['bad', ['failed', 'needs_attention']], + ['hold', ['paused']], ]; // 归档态:不进任务树,沉到页面底部的归档区(时间倒序分页) const ARCHIVED = new Set(['done', 'cancelled']); @@ -112,12 +444,13 @@ function fmtRel(iso) { const ms = Date.now() - new Date(iso).getTime(); if (!Number.isFinite(ms)) return String(iso); const s = Math.floor(ms / 1000); - if (s < 60) return '刚刚'; + const zh = i18nState.lang === 'zh'; + if (s < 60) return zh ? '刚刚' : 'just now'; const m = Math.floor(s / 60); - if (m < 60) return `${m} 分钟前`; + if (m < 60) return zh ? `${m} 分钟前` : `${m}m ago`; const h = Math.floor(m / 60); - if (h < 24) return `${h} 小时前`; - return `${Math.floor(h / 24)} 天前`; + if (h < 24) return zh ? `${h} 小时前` : `${h}h ago`; + return zh ? `${Math.floor(h / 24)} 天前` : `${Math.floor(h / 24)}d ago`; } /** 模型名缩写显示:claude-fable-5 → fable-5 */ @@ -172,15 +505,15 @@ function usageSeg(label, w) { const until = fmtUntil(w.resetsAt); return `${label} ` + `${usageBar(pct)} ${pct}%` + - `${until ? `(${until} 后重置)` : ''}`; + `${until ? `${t('quotaReset', { t: until })}` : ''}`; } /** Agent 面板顶部的订阅额度行(执行 agent 烧的就是这个池子);usage=null → 灰字降级 */ function usageLine(usage) { if (!usage || (!usage.session && !usage.weekly)) { - return `
额度信息不可用
`; + return `
${t('usageUnavail')}
`; } - return `
额度 ${usageSeg('5h', usage.session)} · ${usageSeg('周', usage.weekly)}
`; + return `
${t('quota')} ${usageSeg('5h', usage.session)} · ${usageSeg(t('quotaWeek'), usage.weekly)}
`; } // ── 轻量 Markdown 渲染(零依赖;先整体转义再做结构转换,杜绝注入) ── @@ -347,6 +680,7 @@ function restoreDrafts(snap) { function renderAll() { const snap = snapshotDrafts(); + applyStaticI18n(); renderSidebar(); renderTopbar(); renderAgents(); @@ -365,7 +699,7 @@ function renderAll() { function renderSidebar() { const ul = $('#projectList'); if (!S.projects.length) { - ul.innerHTML = `
  • 暂无项目
    点「+ 新建」登记第一个仓库
  • `; + ul.innerHTML = `
  • ${esc(t('sideEmpty'))}
  • `; return; } ul.innerHTML = S.projects.map((p) => { @@ -387,9 +721,9 @@ function renderSidebar() { function renderTopbar() { const p = S.projects.find((x) => x.id === S.currentProjectId); - $('#projTitle').textContent = p ? p.name : '未选择项目'; + $('#projTitle').textContent = p ? p.name : t('noProject'); $('#projMeta').textContent = p - ? `${p.repoPath} · 分支 ${p.defaultBranch} · ${p.status === 'active' ? '活跃' : '暂停'} · 任务 ${S.tasks.length}` + ? `${p.repoPath} · ${t('branch')} ${p.defaultBranch} · ${p.status === 'active' ? t('projActive') : t('projPaused')} · ${S.tasks.length}` : ''; // 徽章组(全部不含已完成:done / 已拆解容器 / 取消): // 待审批 / 待执行(ready 叶子依赖满足 + 排队中) / 执行中 / 被阻塞 / 总量 @@ -416,18 +750,18 @@ function renderTopbar() { setBdg('#bdgBlocked', nBlocked); setBdg('#bdgTotal', alive.length); + // badge 标签(label + title 通过 data-i18n / data-i18n-title 已静态绑定,这里刷新一次) + applyStaticI18n(); + // 同步 todo / 项目配置入口 $('#topActions').hidden = !p; if (p) { const sb = $('#btnSync'); const noTodo = p.hasTodoJson === false; // 字段未交付(undefined)时不禁用,错误由 toast 兜底 sb.disabled = noTodo; - sb.title = noTodo - ? '未发现 todo.json —— 约定路径 /todo/todo.json' - : '从 /todo/todo.json 同步任务'; - $('#syncMeta').textContent = p.lastSyncAt ? `${fmtRel(p.lastSyncAt)}同步` : ''; - $('#cfgCurrent').textContent = - `当前:并发 ${p.concurrency ?? '—'} · 模式 ${AUTONOMY_LABEL[p.autonomy] || p.autonomy || '—'}`; + sb.title = noTodo ? t('noTodoTip') : t('syncTip'); + $('#syncMeta').textContent = p.lastSyncAt ? t('syncMeta', { t: fmtRel(p.lastSyncAt) }) : ''; + $('#cfgCurrent').textContent = t('cfgCurrent', { c: p.concurrency ?? '—', m: autonomyShort(p.autonomy) || p.autonomy || '—' }); } else { $('#configPanel').hidden = true; } @@ -441,10 +775,11 @@ function renderAgents() { $('#agentTotal').textContent = total ? `· ${total}` : ''; const usage = usageLine(a ? a.usage : null); if (!total) { - body.innerHTML = `${usage}
    无 agent 在执行(自动执行将在 Phase 2 启用)
    `; + body.innerHTML = `${usage}
    ${t('agentEmpty')}
    `; return; } const groups = (a.agents || []).filter((g) => g.active && g.active.length); + const sinceLabel = t('since'); body.innerHTML = ` ${usage}
    @@ -453,16 +788,16 @@ function renderAgents() { ${groups.map((g) => `
    ${esc(g.projectName)} - 并发 ${esc(String(g.concurrency ?? '—'))} · ${esc(AUTONOMY_LABEL[g.autonomy] || g.autonomy || '—')} - 调度 score 优先 - ${g.models ? `模型 E:${esc(shortModel(g.models.easy))} M:${esc(shortModel(g.models.medium))} H:${esc(shortModel(g.models.hard))}` : ''} + ${esc(String(g.concurrency ?? '—'))} · ${esc(autonomyShort(g.autonomy) || g.autonomy || '—')} + ${t('scheduleScore')} + ${g.models ? `${t('models')} E:${esc(shortModel(g.models.easy))} M:${esc(shortModel(g.models.medium))} H:${esc(shortModel(g.models.hard))}` : ''}
    ${g.active.map((r) => `
    ${esc(r.taskTitle)} ${esc(r.kind || '')} - ${fmtRel(r.startedAt)}开始 + ${fmtRel(r.startedAt)}${sinceLabel}
    `).join('')}
    `).join('')}
    @@ -470,38 +805,40 @@ function renderAgents() { } // ── 渲染:审批闸 ── -function gateDocs(t) { - const gate = GATE_OF[t.status]; +function gateDocs(task) { + const gate = GATE_OF[task.status]; const blocks = []; + const emptyNote = i18nState.lang === 'zh' ? '(未填写)' : '(empty)'; const doc = (label, text) => blocks.push( `
    ${label}
    ` + - (text ? `
    ${mdToHtml(text)}
    ` : `
    (未填写)
    `)); - if (gate === 'plan') doc('PLAN · 分析与拆解', t.plan); - if (gate === 'spec') doc('SPEC · 改动方案', t.spec); + (text ? `
    ${mdToHtml(text)}
    ` : `
    ${emptyNote}
    `)); + if (gate === 'plan') doc(t('planDocLabel'), task.plan); + if (gate === 'spec') doc(t('specDocLabel'), task.spec); if (gate === 'exec') { - if (t.result) { - const r = t.result; + if (task.result) { + const r = task.result; if (r.mergeTaskId) { // 合并失败:已建补救任务在解冲突,原任务停在审核闸 const rem = S.tasks.find((x) => x.id === r.mergeTaskId); const remDone = rem && (rem.status === 'done' || rem.status === 'cancelled'); - blocks.push(`
    合并状态
    -
    ⚠️ 自动合并失败(冲突)→ 已建最高优先级补救任务 - ${rem ? `「${esc(rem.title)}」(${STATUS_LABEL[rem.status] || esc(rem.status)})` : `${esc(r.mergeTaskId)}`} - ${remDone ? '' : '
    补救任务解决冲突并合并后,本任务将自动标记完成,无需手动「仅通过」。'}
    `); + blocks.push(`
    ${t('mergeStatus')}
    +
    ${t('mergeFailedBanner')} + ${rem ? `「${esc(rem.title)}」(${statusLabel(rem.status)})` : `${esc(r.mergeTaskId)}`} + ${remDone ? '' : `
    ${t('mergeRemNote')}`}
    `); } blocks.push(reviewReportBlocks(r)); // CODE REVIEW + 安全审计 两节(各带 verdict 徽章),置于 DIFF 摘要上方 - blocks.push(`
    RESULT · 执行结果
    + blocks.push(`
    ${t('resultDetailLabel')}
    - ${r.branch ? `
    ${icon('git')} 分支
    ${esc(r.branch)}
    ` : ''} + ${r.branch ? `
    ${icon('git')} ${t('branch')}
    ${esc(r.branch)}
    ` : ''} ${r.worktree ? `
    worktree
    ${esc(r.worktree)}
    ` : ''} ${r.prUrl ? `
    PR
    ${esc(r.prUrl)}
    ` : ''}
    `); - if (r.diffSummary) blocks.push(`
    DIFF 摘要
    ${esc(r.diffSummary)}
    `); + if (r.diffSummary) blocks.push(`
    ${t('diffLabel')}
    ${esc(r.diffSummary)}
    `); } else { - blocks.push(`
    (无执行结果记录)
    `); + const noResult = i18nState.lang === 'zh' ? '(无执行结果记录)' : '(no result recorded)'; + blocks.push(`
    ${noResult}
    `); } - if (t.operations) doc('OPERATIONS · 执行的操作', t.operations); - else if (t.spec) doc('SPEC · 改动方案', t.spec); + if (task.operations) doc(t('opsDocLabel'), task.operations); + else if (task.spec) doc(t('specDocLabel'), task.spec); } return blocks.join(''); } @@ -512,29 +849,43 @@ function renderGates() { sec.hidden = false; const proj = S.projects.find((x) => x.id === S.currentProjectId); const defBranch = proj ? proj.defaultBranch : 'main'; - $('#gateList').innerHTML = S.approvals.map((t) => { - const gate = GATE_OF[t.status]; - const rejOpen = S.rejectOpen.has(t.id); + // Update gate section head text + const gateHead = sec.querySelector('.gate-head'); + if (gateHead) { + const headMark = gateHead.querySelector('.head-mark'); + const ico = gateHead.querySelector('.icon'); + gateHead.innerHTML = ''; + if (headMark) gateHead.appendChild(headMark); + if (ico) gateHead.appendChild(ico); + gateHead.appendChild(document.createTextNode(' ' + t('gateSection'))); + } + $('#gateList').innerHTML = S.approvals.map((ap) => { + const gate = GATE_OF[ap.status]; + const rejOpen = S.rejectOpen.has(ap.id); + const mergeNote = gate === 'exec' ? `${t('gateMergeNote', { branch: esc(defBranch) })}` : ''; + const onlyPassBtn = gate === 'exec' + ? `` + : ''; return `
    - ${GATE_LABEL[gate]} - ${esc(t.title)} - ${cplxBadge(t.complexity)} - ${statusChip(t.status)} - + ${gateLabel(gate)} + ${esc(ap.title)} + ${cplxBadge(ap.complexity)} + ${statusChip(ap.status)} +
    -
    ${gateDocs(t)}
    +
    ${gateDocs(ap)}
    - ${gate === 'exec' ? `` : ''} - - ${gate === 'exec' ? `通过 = 自动把任务分支合并到 ${esc(defBranch)}(仅本地合并,不 push)` : ''} + ${onlyPassBtn} + + ${mergeNote}
    ${rejOpen ? `
    - - - + + +
    ` : ''}
    `; }).join(''); @@ -558,7 +909,7 @@ function renderMetrics() { return; } sec.hidden = false; - $('#metricsScope').textContent = `· ${m.taskCount} 任务`; + $('#metricsScope').textContent = t('metricsTasks', { n: m.taskCount }); const d = m.duration; const r = m.runs; @@ -573,26 +924,27 @@ function renderMetrics() { }; const cards = [ - metricCard('平均执行时长', fmtDur(d.avgMs), - d.count ? `P50 ${fmtDur(d.p50Ms)} · P95 ${fmtDur(d.p95Ms)} · n=${d.count}` : '暂无已结束执行'), - metricCard('执行成功率', fmtRate(r.successRate), - r.total ? `${r.succeeded}/${r.total} 成功 · 失败 ${r.failed}` : '暂无执行', rateCls(r.successRate)), - metricCard('复审通过率', fmtRate(rv.reviewRate), - `复审 ${rv.reviewApprove}/${rv.reviewTotal} · 安全 ${fmtRate(rv.securityRate)} (${rv.securityApprove}/${rv.securityTotal})`, + metricCard(t('metricDur'), fmtDur(d.avgMs), + d.count ? t('metricDurSub', { p50: fmtDur(d.p50Ms), p95: fmtDur(d.p95Ms), n: d.count }) : t('metricDurNone')), + metricCard(t('metricSucc'), fmtRate(r.successRate), + r.total ? t('metricSuccSub', { ok: r.succeeded, total: r.total, fail: r.failed }) : t('metricSuccNone'), + rateCls(r.successRate)), + metricCard(t('metricReview'), fmtRate(rv.reviewRate), + t('metricReviewSub', { ra: rv.reviewApprove, rt: rv.reviewTotal, sr: fmtRate(rv.securityRate), sa: rv.securityApprove, st2: rv.securityTotal }), rateCls(rv.reviewRate)), - metricCard('重试率', fmtRate(rt.retryRate), - `重试 ${rt.retries} 次 · 需人工 ${rt.needsAttention}`, rt.needsAttention > 0 ? 'warn' : ''), + metricCard(t('metricRetry'), fmtRate(rt.retryRate), + t('metricRetrySub', { r: rt.retries, n: rt.needsAttention }), rt.needsAttention > 0 ? 'warn' : ''), ]; // 按模型额度分布(估算) const totalUnits = (m.byModel || []).reduce((a, x) => a + Number(x.estCostUnits || 0), 0); const totalDur = (m.byModel || []).reduce((a, x) => a + Number(x.durationMs || 0), 0); - cards.push(metricCard('额度消耗', `${totalUnits.toFixed(1)}u`, - `执行 ${fmtDur(totalDur)} · 估算`, 'est')); + cards.push(metricCard(t('metricCost'), `${totalUnits.toFixed(1)}u`, + t('metricCostSub', { dur: fmtDur(totalDur), est: `${t('metricEstTag')}` }), 'est')); const dist = (m.byModel || []).length ? `
    -
    按模型分布 (时长×档位 粗估)
    +
    ${t('metricModelHead')} ${t('metricEst')}
    ${m.byModel.map((x) => { const w = totalUnits > 0 ? Math.round(Number(x.estCostUnits) / totalUnits * 100) : 0; return `
    @@ -604,6 +956,10 @@ function renderMetrics() {
    ` : ''; + // Update section head + const metSec = $('#metricsSection'); + const metHead = metSec && metSec.querySelector('.sec-head'); + if (metHead) metHead.innerHTML = `${t('metricsSection')} ${t('metricsTasks', { n: m.taskCount })}`; $('#metricsBody').innerHTML = `
    ${cards.join('')}
    ${dist}`; } @@ -631,17 +987,21 @@ function renderArchive() { if (a.page > pages) a.page = pages; const slice = items.slice((a.page - 1) * a.size, a.page * a.size); - $('#archiveCount').textContent = items.length; - $('#archiveList').innerHTML = slice.map((t) => { - const n = subCount(t); + // Update archive section head + const archSec = $('#archiveSection'); + const archHead = archSec && archSec.querySelector('.sec-head'); + if (archHead) archHead.innerHTML = `${t('archiveSection')} · ${items.length}`; + + $('#archiveList').innerHTML = slice.map((task) => { + const n = subCount(task); return ` -
    - ${esc(t.title)}${esc(t.id.slice(-6))} - ${n ? `含 ${n} 子任务` : ''} +
    + ${esc(task.title)}${esc(task.id.slice(-6))} + ${n ? `${t('archiveSubs', { n })}` : ''} - ${cplxBadge(t.complexity)} - ${statusChip(t.status)} - ${fmtRel(t.updatedAt)} + ${cplxBadge(task.complexity)} + ${statusChip(task.status)} + ${fmtRel(task.updatedAt)}
    `; }).join(''); // 底部:翻页 + 每页条数 chip 组(替代原生下拉) @@ -650,10 +1010,10 @@ function renderArchive() { ).join(''); $('#archivePager').innerHTML = ` ${pages > 1 ? ` - - 第 ${a.page} / ${pages} 页 - ` : ''} - 每页${sizeChips}`; + + ${t('pageOf', { a: a.page, b: pages })} + ` : ''} + ${t('perPage')}${sizeChips}`; } // ── 归档详情对话框(只读:全属性/执行历史/审批/状态流转时间线) ── @@ -665,17 +1025,16 @@ function fmtDuration(startIso, endIso) { const ms = new Date(endIso) - new Date(startIso); if (!Number.isFinite(ms) || ms < 0) return '—'; const s = Math.round(ms / 1000); - if (s < 60) return `${s} 秒`; - return `${Math.floor(s / 60)} 分 ${s % 60} 秒`; + const zh = i18nState.lang === 'zh'; + if (s < 60) return zh ? `${s} 秒` : `${s}s`; + return zh ? `${Math.floor(s / 60)} 分 ${s % 60} 秒` : `${Math.floor(s / 60)}m ${s % 60}s`; } /** 事件 → 时间线条目(人或事:by/auto/actor/原因) */ function tlActor(p) { const who = []; - if (p.by) who.push(p.by === 'orchestrator' ? '编排器' : p.by); - if (p.actor) who.push(p.actor === 'importer' ? '导入器' : p.actor); - if (p.auto) who.push({ 'deps-met': '依赖满足自动放行', 'deps-reconcile': '启动对账', 'deps-unmet': '依赖未满足落位', - 'children-done': '子任务全完成自动收口', 'import-done': '导入历史完成', 'daemon-restart': '重启恢复', - 'merge-remediated': '补救完成自动收口' }[p.auto] || p.auto); + if (p.by) who.push(p.by === 'orchestrator' ? t('tlOrchestrator') : p.by); + if (p.actor) who.push(p.actor === 'importer' ? t('tlImporter') : p.actor); + if (p.auto) who.push(tSub('tlAuto', p.auto, p.auto)); return who.join(' · '); } @@ -688,79 +1047,79 @@ async function openArchiveDetail(id) { } catch (e) { toast(e.message); } } -function renderArchiveModal(t, runs, events) { +function renderArchiveModal(task, runs, events) { const root = $('#archiveModalRoot'); const byId = new Map(S.tasks.map((x) => [x.id, x])); - const parent = t.parentId ? byId.get(t.parentId) : null; - const r = t.result; - const RUN_KIND = { executor: '执行', reviewer: 'code review', security: '安全审计', planner: '规划' }; - const RUN_ST = { succeeded: '成功', failed: '失败', started: '进行中', cancelled: '取消' }; + const parent = task.parentId ? byId.get(task.parentId) : null; + const r = task.result; + const depSep = i18nState.lang === 'zh' ? '、' : ', '; const kv = (dt, dd) => `
    ${dt}
    ${dd}
    `; const attrs = `
    - ${kv('id', esc(t.id))} - ${kv('复杂度', `${CPLX_LABEL[t.complexity] || esc(t.complexity)}`)} - ${kv('优先级', `P${esc(String(t.priority))}`)} - ${kv('状态', STATUS_LABEL[t.status] || esc(t.status))} - ${kv('深度', esc(String(t.depth)))} - ${parent ? kv('父任务', esc(parent.title)) : ''} - ${t.deps && t.deps.length ? kv('依赖', t.deps.map((d) => esc(byId.get(d)?.title || d)).join('、')) : ''} - ${kv('创建时间', fmtFull(t.createdAt))} - ${kv('最后更新', fmtFull(t.updatedAt))} + ${kv('id', esc(task.id))} + ${kv(t('complexity'), `${CPLX_LABEL[task.complexity] || esc(task.complexity)}`)} + ${kv(t('priority'), `P${esc(String(task.priority))}`)} + ${kv(t('status', {}), statusLabel(task.status))} + ${kv('depth', esc(String(task.depth)))} + ${parent ? kv(t('parentTask'), esc(parent.title)) : ''} + ${task.deps && task.deps.length ? kv('deps', task.deps.map((d) => esc(byId.get(d)?.title || d)).join(depSep)) : ''} + ${kv(i18nState.lang === 'zh' ? '创建时间' : 'Created', fmtFull(task.createdAt))} + ${kv(i18nState.lang === 'zh' ? '最后更新' : 'Updated', fmtFull(task.updatedAt))}
    `; - const docs = [['plan', 'PLAN · 分析拆解'], ['spec', 'SPEC · 方案'], ['operations', 'OPERATIONS · 操作']] - .filter(([f]) => t[f]) - .map(([f, label]) => `
    ${label}
    ${mdToHtml(t[f])}
    `) + const docs = [['plan', t('planDetailLabel')], ['spec', t('specDetailLabel')], ['operations', t('opsDetailLabel')]] + .filter(([f]) => task[f]) + .map(([f, label]) => `
    ${label}
    ${mdToHtml(task[f])}
    `) .join(''); - const runRows = runs.length ? `
    执行历史(${runs.length} 次 run)
    + const runRows = runs.length ? `
    ${t('runsLabel', { n: runs.length })}
    ${runs.map((x) => `
    - ${RUN_KIND[x.kind] || esc(x.kind)} - ${RUN_ST[x.status] || esc(x.status)} + ${runKindLabel(x.kind)} + ${runStLabel(x.status)} ${fmtFull(x.startedAt)} → ${x.endedAt ? fmtFull(x.endedAt) : '—'} · ${fmtDuration(x.startedAt, x.endedAt)} - ${x.transcriptRef ? `` : ''} + ${x.transcriptRef ? `` : ''}
    - ${x.error ? `
    错误:${esc(x.error)}
    ` : ''} + ${x.error ? `
    ${i18nState.lang === 'zh' ? '错误:' : 'Error: '}${esc(x.error)}
    ` : ''} ${x.transcriptRef ? `` : ''}
    `).join('')}
    ` : ''; const resultBlock = r ? `${reviewReportBlocks(r)} -
    执行结果
    +
    ${t('resultLabel')}
    - ${r.branch ? kv(`${icon('git')} 分支`, esc(r.branch)) : ''} + ${r.branch ? kv(`${icon('git')} ${t('branch')}`, esc(r.branch)) : ''} ${r.worktree ? kv('worktree', esc(r.worktree)) : ''} - ${r.prUrl ? kv('合并', esc(r.prUrl)) : ''} + ${r.prUrl ? kv('PR', esc(r.prUrl)) : ''} ${r.commits && r.commits.length ? kv('commits', r.commits.map(esc).join('
    ')) : ''}
    ${r.diffSummary ? `
    ${esc(r.diffSummary)}
    ` : ''}` : ''; - const approvalRows = t.approvals && t.approvals.length ? `
    审批记录
    -
    ${t.approvals.map((a) => kv( - `${GATE_LABEL[a.gate] || esc(a.gate)} · ${a.action === 'accept' ? '接受' : '驳回'}`, - `审批人 ${esc(a.actor)} · ${fmtFull(a.at)}${a.reason ? `
    ${icon('reason')} 意见:${esc(a.reason)}` : ''}`, + const approvalRows = task.approvals && task.approvals.length ? `
    ${t('approvalsLabel')}
    +
    ${task.approvals.map((a) => kv( + `${gateLabel(a.gate)} · ${a.action === 'accept' ? t('accepted') : t('rejected')}`, + `${t('approver')} ${esc(a.actor)} · ${fmtFull(a.at)}${a.reason ? `
    ${icon('reason')} ${esc(a.reason)}` : ''}`, )).join('')}
    ` : ''; - const timeline = events.length ? `
    状态流转时间线
    + const timeline = events.length ? `
    ${t('timelineLabel')}
    ${events.map((e) => { const p = e.payload || {}; - let text = EVENT_LABEL[e.type] || e.type; - if (e.type === 'status.changed') text = `${STATUS_LABEL[p.from] || p.from} → ${STATUS_LABEL[p.to] || p.to}`; - if (e.type === 'approval.granted') text = `审批通过(${GATE_LABEL[p.gate] || p.gate})`; - if (e.type === 'approval.rejected') text = `审批驳回(${GATE_LABEL[p.gate] || p.gate})${p.reason ? ':' + p.reason : ''}`; - if (e.type === 'run.started') text = `run 开始(${RUN_KIND[p.kind] || p.kind || ''})`; - if (e.type === 'run.finished') text = `run 结束(${RUN_ST[p.status] || p.status || ''})`; + let text = eventLabel(e.type); + if (e.type === 'status.changed') text = `${statusLabel(p.from)} → ${statusLabel(p.to)}`; + if (e.type === 'approval.granted') text = t('tlApproveGranted', { gate: gateLabel(p.gate) }); + if (e.type === 'approval.rejected') text = t('tlApproveRejected', { gate: gateLabel(p.gate), reason: p.reason ? `:${p.reason}` : '' }); + if (e.type === 'run.started') text = t('tlRunStarted', { kind: runKindLabel(p.kind) || p.kind || '' }); + if (e.type === 'run.finished') text = t('tlRunFinished', { st: runStLabel(p.status) || p.status || '' }); if (e.type === 'merge.failed') { - const files = (p.conflictFiles && p.conflictFiles.length) ? `(冲突文件:${p.conflictFiles.join('、')})` : ''; + const files = (p.conflictFiles && p.conflictFiles.length) + ? t('tlMergeFailFiles', { files: p.conflictFiles.join(depSep) }) : ''; const remT = p.remediationTaskId ? (byId.get(p.remediationTaskId)?.title || p.remediationTaskId) : ''; - text = `合并失败${files}${remT ? ` → 补救任务「${remT}」` : ''}`; + text = t('tlMergeFailed', { files, task: remT ? t('tlMergeTask', { title: remT }) : '' }); } if (e.type === 'merge.remediated') { const remT = p.remediationTaskId ? (byId.get(p.remediationTaskId)?.title || p.remediationTaskId) : ''; - text = `补救完成 → 完成${remT ? `(补救任务「${remT}」已合并)` : ''}`; + text = t('tlMergeOk', { task: remT ? t('tlMergeOkTask', { title: remT }) : '' }); } const who = tlActor(p); return `
    @@ -775,53 +1134,54 @@ function renderArchiveModal(t, runs, events) {
    - 归档详情 - ${esc(t.title)} - ${cplxBadge(t.complexity)} - ${statusChip(t.status)} + ${t('archiveModalTitle')} + ${esc(task.title)} + ${cplxBadge(task.complexity)} + ${statusChip(task.status)} - +
    ${attrs}${docs}${runRows}${resultBlock}${approvalRows}${timeline}
    - - + +
    `; } // 过长内容折叠为
    ,短内容直接
     function foldable(text, truncated, threshold = 400) {
    -  const body = esc(text) + (truncated ? esc('\n…(已截断)') : '');
    +  const body = esc(text) + (truncated ? esc('\n' + t('transcriptTrunc')) : '');
       if ((text || '').length <= threshold) return `
    ${body}
    `; - return `
    展开(${(text || '').length} 字符)
    ${body}
    `; + return `
    ${t('foldExpand', { n: (text || '').length })}
    ${body}
    `; } // 结构化转录消息流 → HTML(按 type 分类;文本/结论复用 mdToHtml,工具调用/结果折叠) function renderTranscript(messages) { - if (!messages || !messages.length) return '
    (无可展示的转录内容)
    '; + if (!messages || !messages.length) return `
    ${t('transcriptEmpty')}
    `; + const trunc = `
    ${t('transcriptTrunc')}
    `; return `
    ${messages.map((m) => { if (m.type === 'text') { - return `
    assistant -
    ${mdToHtml(m.text || '')}
    ${m.truncated ? '
    …(已截断)
    ' : ''}
    `; + return `
    ${t('transcriptBadgeA')} +
    ${mdToHtml(m.text || '')}
    ${m.truncated ? trunc : ''}
    `; } if (m.type === 'result') { - return `
    结论 -
    ${mdToHtml(m.text || '')}
    ${m.truncated ? '
    …(已截断)
    ' : ''}
    `; + return `
    ${t('transcriptBadgeR')} +
    ${mdToHtml(m.text || '')}
    ${m.truncated ? trunc : ''}
    `; } if (m.type === 'fallback') { - return `
    模型回退 + return `
    ${t('transcriptBadgeW')} ${esc(m.from)} → ${esc(m.to)}
    `; } if (m.type === 'tool_use') { - return `
    工具调用 + return `
    ${t('transcriptBadgeT')} ${esc(m.name)}${m.input ? foldable(m.input, m.truncated) : ''}
    `; } if (m.type === 'tool_result') { return `
    - 工具结果${m.isError ? ' · 报错' : ''}${foldable(m.text || '', m.truncated)}
    `; + ${m.isError ? t('transcriptBadgeTRErr') : t('transcriptBadgeTR')}${foldable(m.text || '', m.truncated)}
    `; } return ''; }).join('')}
    `; @@ -835,32 +1195,34 @@ function renderPreview() { // 任务已离开闸(被裁决/状态变化)→ 自动关闭 if (!t || !GATE_OF[t.status]) { S.previewId = null; S.previewReject = false; root.hidden = true; root.innerHTML = ''; return; } const gate = GATE_OF[t.status]; + const pp = S.projects.find((x) => x.id === S.currentProjectId); + const defBranch = pp ? pp.defaultBranch : 'main'; + const onlyPassBtn = gate === 'exec' + ? `` + : ''; root.hidden = false; root.innerHTML = `
    - ${GATE_LABEL[gate]} + ${gateLabel(gate)} ${esc(t.title)} ${cplxBadge(t.complexity)} ${statusChip(t.status)} - +
    ${gateDocs(t)}
    - ${gate === 'exec' ? `` : ''} - - ${gate === 'exec' ? (() => { - const pp = S.projects.find((x) => x.id === S.currentProjectId); - return `通过 = 合并到 ${esc(pp ? pp.defaultBranch : 'main')}(仅本地,不 push)`; - })() : ''} - id ${esc(t.id)} · 更新 ${fmtTime(t.updatedAt)} + ${onlyPassBtn} + + ${gate === 'exec' ? `${t('gateMergeNote', { branch: esc(defBranch) })}` : ''} + id ${esc(t.id)} · ${fmtTime(t.updatedAt)} ${S.previewReject ? `
    - - - + + +
    ` : ''}
    `; @@ -885,25 +1247,26 @@ function cplxBadge(c, taskId) { `; } function statusChip(st) { - return `${STATUS_LABEL[st] || esc(st)}`; + return `${statusLabel(st)}`; } // 复审结论徽章:approve=建议通过(绿) / reject=建议拒绝(红) / 其他=未复审(灰) function verdictBadge(v) { - if (v === 'approve') return `建议通过`; - if (v === 'reject') return `建议拒绝`; - return `未复审`; + if (v === 'approve') return `${t('verdictApprove')}`; + if (v === 'reject') return `${t('verdictReject')}`; + return `${t('verdictNone')}`; } // 复审报告区块:「CODE REVIEW」「安全审计」两节,各自 verdict 徽章(通过绿/拒绝红/未复审灰) function reviewReportBlocks(r) { if (!r) return ''; + const noReview = i18nState.lang === 'zh' ? '(无复审记录)' : '(no review record)'; const sec = (label, summary, verdict) => { const head = `
    ${label} ${verdictBadge(verdict)}
    `; if (summary) return `${head}
    ${mdToHtml(summary)}
    `; - return `${head}
    (无复审记录)
    `; + return `${head}
    ${noReview}
    `; }; - return sec('CODE REVIEW', r.summary, r.verdict) + sec('安全审计', r.securitySummary, r.securityVerdict); + return sec(t('reviewLabel'), r.summary, r.verdict) + sec(t('securityLabel'), r.securitySummary, r.securityVerdict); } function childrenMap(list = S.tasks) { @@ -989,42 +1352,44 @@ function renderFilterBar() { $('#filterCplx').innerHTML = ['hard', 'medium', 'easy'].map((c) => ``, ).join(''); - $('#filterStatus').innerHTML = FILTER_GROUPS.map(([name, sts], gi) => { + $('#filterStatus').innerHTML = FILTER_GROUPS.map(([key, sts], gi) => { // 组选与单选分离:组 chip 只反映组选择,成员 chip 只反映单选;单状态组只渲染状态 chip 本身 const members = sts.map((s) => - ``, + ``, ).join(''); if (sts.length === 1) return `${members}`; const gCls = f.statusGroups.has(gi) ? 'on' : ''; + const groupName = fgroupLabel(key); + const groupTip = i18nState.lang === 'zh' ? '按整组筛选' : 'Filter by group'; return ` - + ${members} `; }).join(''); const active = filterActive(); $('#filterClear').hidden = !active; - $('#filterCount').textContent = active ? `命中 ${S.matchCount} / ${S.tasks.length}` : ''; + $('#filterCount').textContent = active ? t('matchCount', { n: S.matchCount, total: S.tasks.length }) : ''; } function renderTree() { const root = $('#taskTree'); if (!S.currentProjectId) { - root.innerHTML = `
    NO PROJECT先在左侧新建或选择一个项目
    `; + root.innerHTML = `
    NO PROJECT${t('treeNoProject')}
    `; return; } if (!S.tasks.length) { - root.innerHTML = `
    EMPTY还没有任务 —— 点上方「+ 新建任务」开始
    `; + root.innerHTML = `
    EMPTY${t('treeEmpty')}
    `; return; } const alive = aliveTasks(); if (!alive.length) { - root.innerHTML = `
    ALL DONE全部任务已完成归档(见页面底部归档区)
    `; + root.innerHTML = `
    ALL DONE${t('treeAllDone')}
    `; return; } const m = childrenMap(alive); const vis = computeVisible(); if (vis && !vis.visible.size) { - root.innerHTML = `
    NO MATCH没有任务命中当前筛选 —— 放宽条件或「清除筛选」
    `; + root.innerHTML = `
    NO MATCH${t('treeNoMatch')}
    `; return; } const byId = new Map(S.tasks.map((x) => [x.id, x])); @@ -1049,7 +1414,8 @@ function renderTree() { if (t.status !== 'blocked' || !(t.deps || []).length) return ''; const unmet = t.deps.filter((d) => byId.get(d)?.status !== 'done'); if (!unmet.length) return ''; - return `${icon('deps')} 等依赖 ${unmet.length}`; + const depSep2 = i18nState.lang === 'zh' ? '、' : ', '; + return `${icon('deps')} ${t('depsWait', { n: unmet.length })}`; })()} P${t.priority} ${cplxBadge(t.complexity, t.id)} @@ -1065,39 +1431,39 @@ function renderTree() { } // ── 渲染:任务详情 ── -function writableField(t) { - if (t.status === 'analyzing') return { field: 'plan', label: '分析与拆解(plan)', next: 'plan_review' }; - if (t.status === 'speccing') return { field: 'spec', label: '改动方案(spec)', next: 'spec_review' }; - if (t.status === 'ready') return { field: 'operations', label: '将执行的操作(operations)', next: null }; +function writableField(task) { + if (task.status === 'analyzing') return { field: 'plan', label: t('planDetailLabel'), next: 'plan_review' }; + if (task.status === 'speccing') return { field: 'spec', label: t('specDetailLabel'), next: 'spec_review' }; + if (task.status === 'ready') return { field: 'operations', label: t('opsDetailLabel'), next: null }; return null; } -function renderDetail(t) { +function renderDetail(task) { const parts = []; - const w = writableField(t); + const w = writableField(task); // 产出全部只读展示:写产出与提交评审由 Claude Code 经 MCP 完成,看板只留用户动作(审批/配置/同步) - const docs = [['plan', 'PLAN · 分析拆解'], ['spec', 'SPEC · 方案'], ['operations', 'OPERATIONS · 操作']]; + const docs = [['plan', t('planDetailLabel')], ['spec', t('specDetailLabel')], ['operations', t('opsDetailLabel')]]; for (const [f, label] of docs) { - if (!t[f]) continue; - parts.push(`
    ${label}
    ${mdToHtml(t[f])}
    `); + if (!task[f]) continue; + parts.push(`
    ${label}
    ${mdToHtml(task[f])}
    `); } // 当前阶段应产出但还没有内容 → 占位提示 - if (w && !t[w.field]) { - parts.push(`
    ${w.label}
    待 Claude Code 产出(经 MCP 写入并提交评审)
    `); + if (w && !task[w.field]) { + parts.push(`
    ${w.label}
    ${t('pendingDoc')}
    `); } // result - if (t.result) { - const r = t.result; + if (task.result) { + const r = task.result; parts.push(`
    ${reviewReportBlocks(r)} -
    RESULT · 执行结果
    +
    ${t('resultDetailLabel')}
    -
    ${icon('git')} 分支
    ${esc(r.branch || '—')}
    +
    ${icon('git')} ${t('branch')}
    ${esc(r.branch || '—')}
    worktree
    ${esc(r.worktree || '—')}
    - ${r.prUrl ? `
    合并
    ${esc(r.prUrl)}
    ` : ''} + ${r.prUrl ? `
    PR
    ${esc(r.prUrl)}
    ` : ''} ${r.commits && r.commits.length ? `
    commits
    ${r.commits.map(esc).join('
    ')}
    ` : ''}
    ${r.diffSummary ? `
    ${esc(r.diffSummary)}
    ` : ''} @@ -1105,13 +1471,13 @@ function renderDetail(t) { } // 审批历史 - if (t.approvals && t.approvals.length) { + if (task.approvals && task.approvals.length) { parts.push(` -
    审批历史
    - ${t.approvals.map((a) => ` +
    ${t('approvalsHistLabel')}
    + ${task.approvals.map((a) => `
    - ${a.action === 'accept' ? '✓ 通过' : '✗ 驳回'} - ${GATE_LABEL[a.gate] || esc(a.gate)} · ${esc(a.actor)} + ${a.action === 'accept' ? `✓ ${t('accepted')}` : `✗ ${t('rejected')}`} + ${gateLabel(a.gate)} · ${esc(a.actor)} ${fmtTime(a.at)} ${a.reason ? `${icon('reason')}${esc(a.reason)}` : ''}
    `).join('')} @@ -1119,55 +1485,63 @@ function renderDetail(t) { } // 依赖列表:标题 + 状态 chip,未完成的高亮(全部 done 本任务才可被领取);可编辑状态支持增删 - const depsEditable = STRUCT_EDITABLE.has(t.status); - if (S.depsEditFor === t.id) { - parts.push(depsEditorBlock(t)); - } else if ((t.deps && t.deps.length) || depsEditable) { - const items = (t.deps || []).map((d) => { + const depsEditable = STRUCT_EDITABLE.has(task.status); + if (S.depsEditFor === task.id) { + parts.push(depsEditorBlock(task)); + } else if ((task.deps && task.deps.length) || depsEditable) { + const depGotoTip = i18nState.lang === 'zh' ? '点击定位到该任务' : 'Click to locate task'; + const items = (task.deps || []).map((d) => { const dt = S.tasks.find((x) => x.id === d); const ok = dt && dt.status === 'done'; - return `
    + return `
    ${ok ? '✓' : icon('deps')} ${dt ? statusChip(dt.status) : ''} ${esc(dt ? dt.title : d)} ${esc(d.slice(-6))}
    `; - }).join('') || `
    暂无依赖
    `; + }).join('') || `
    ${t('depEmpty')}
    `; + const depEditTip = i18nState.lang === 'zh' ? '增删本任务的依赖' : 'Edit task dependencies'; const editBtn = depsEditable - ? `` + ? `` : ''; - parts.push(`
    DEPS · 依赖(全部完成才可执行)${editBtn}
    ${items}
    `); + parts.push(`
    ${t('depsLabel')}${editBtn}
    ${items}
    `); } - if (!parts.length) parts.push(`
    暂无产出与历史 —— 状态:${STATUS_LABEL[t.status]}
    `); + if (!parts.length) parts.push(`
    ${t('noOutput', { st: statusLabel(task.status) })}
    `); - parts.push(`
    id ${esc(t.id)} · 深度 ${t.depth} · 创建 ${fmtTime(t.createdAt)} · 更新 ${fmtTime(t.updatedAt)}
    `); + const depLabelMeta = i18nState.lang === 'zh' ? '深度' : 'depth'; + parts.push(`
    id ${esc(task.id)} · ${depLabelMeta} ${task.depth} · ${fmtTime(task.createdAt)} · ${fmtTime(task.updatedAt)}
    `); // ── 取消 / 删除操作区 ── const m = childrenMap(S.tasks); - const childCount = (m.get(t.id) || []).length; - const cancelSection = !ARCHIVED.has(t.status) ? (() => { - if (S.confirmCancel === t.id) { + const childCount = (m.get(task.id) || []).length; + const cancelTip = i18nState.lang === 'zh' ? '取消任务(进入 cancelled 状态,计入归档)' : 'Cancel task (enters cancelled, archived)'; + const deleteTip = i18nState.lang === 'zh' ? '彻底删除任务(不可恢复)' : 'Permanently delete task (cannot be undone)'; + const cancelSection = !ARCHIVED.has(task.status) ? (() => { + if (S.confirmCancel === task.id) { + const msg = t('cancelDetailMsg', { title: task.title }); return `
    - 确定取消任务「${esc(t.title)}」?(将进入归档,子任务保持原状) - - + ${esc(msg)} + +
    `; } - return ``; + return ``; })() : ''; const deleteSection = (() => { - if (S.confirmDelete === t.id) { - const cascadeNote = childCount ? `(含 ${childCount} 个直接子任务,及其全部子孙)` : ''; + if (S.confirmDelete === task.id) { + const hint = childCount ? t('deleteDetailHint', { n: childCount }) : ''; + const msg = t('deleteDetailMsg', { title: task.title, hint }); return `
    - 彻底删除「${esc(t.title)}」${esc(cascadeNote)}?此操作不可恢复! - - + ${esc(msg)} + +
    `; } - const cascadeHint = childCount ? `(含 ${childCount} 子任务)` : ''; - return ``; + return childCount + ? `` + : ``; })(); if (cancelSection || deleteSection) { parts.push(`
    ${cancelSection}${deleteSection}
    `); @@ -1176,8 +1550,8 @@ function renderDetail(t) { return `
    ${parts.join('')}
    `; } -/** 选某任务为 t 的依赖会成环的候选集合:所有(传递)依赖 t 的任务 id */ -function depsWouldCycle(t) { +/** 选某任务为 task 的依赖会成环的候选集合:所有(传递)依赖 task 的任务 id */ +function depsWouldCycle(task) { const byId = new Map(S.tasks.map((x) => [x.id, x])); const reaches = (id, target, seen) => { const node = byId.get(id); @@ -1190,63 +1564,74 @@ function depsWouldCycle(t) { }; const bad = new Set(); for (const x of S.tasks) { - if (x.id === t.id) continue; - if (reaches(x.id, t.id, new Set())) bad.add(x.id); + if (x.id === task.id) continue; + if (reaches(x.id, task.id, new Set())) bad.add(x.id); } return bad; } /** 依赖编辑器:多选当前项目其它任务(排除自己与会成环者),保存走 PATCH deps */ -function depsEditorBlock(t) { +function depsEditorBlock(task) { const sel = S.depsEditSel || new Set(); - const bad = depsWouldCycle(t); + const bad = depsWouldCycle(task); const candidates = S.tasks - .filter((x) => x.id !== t.id && !ARCHIVED.has(x.status) && !bad.has(x.id)) + .filter((x) => x.id !== task.id && !ARCHIVED.has(x.status) && !bad.has(x.id)) .sort((a, b) => (a.depth - b.depth) || a.title.localeCompare(b.title)); const rows = candidates.map((x) => { const on = sel.has(x.id); - return `
    + return `
    ${on ? '☑' : '☐'} ${statusChip(x.status)} ${esc(x.title)} ${esc(x.id.slice(-6))}
    `; - }).join('') || `
    本项目暂无其它可选任务
    `; - return `
    DEPS · 编辑依赖(勾选当前项目其它任务;已排除自身与会成环者)
    + }).join('') || `
    ${t('depNoOthers')}
    `; + return `
    ${t('depsEditLabel')}
    ${rows}
    - - + +
    `; } // ── 渲染:事件流 ── function eventDetail(e) { - const t = S.tasks.find((x) => x.id === e.taskId); - const name = t ? t.title : (e.taskId ? e.taskId.slice(-6) : ''); + const task = S.tasks.find((x) => x.id === e.taskId); + const name = task ? task.title : (e.taskId ? e.taskId.slice(-6) : ''); const p = e.payload || {}; + const depSep = i18nState.lang === 'zh' ? '、' : ', '; if (e.type === 'status.changed') { - return `${name}:${STATUS_LABEL[p.from] || p.from} → ${STATUS_LABEL[p.to] || p.to}`; + const sep = i18nState.lang === 'zh' ? ':' : ': '; + return `${name}${sep}${statusLabel(p.from)} → ${statusLabel(p.to)}`; } if (e.type === 'approval.granted' || e.type === 'approval.rejected') { const r = p.reason ? `(${p.reason})` : ''; - return `${name} · ${GATE_LABEL[p.gate] || p.gate}${r}`; + return `${name} · ${gateLabel(p.gate)}${r}`; } if (e.type === 'task.created') { - if (p.kind === 'project') return `项目「${p.name || ''}」已登记`; + if (p.kind === 'project') { + return i18nState.lang === 'zh' ? `项目「${p.name || ''}」已登记` : `Project "${p.name || ''}" registered`; + } return `${p.title || name}(${CPLX_LABEL[p.complexity] || ''})`; } if (e.type === 'merge.failed') { - const files = (p.conflictFiles && p.conflictFiles.length) ? `(冲突文件:${p.conflictFiles.join('、')})` : ''; - return `${name}:合并失败${files} → 补救任务`; + const files = (p.conflictFiles && p.conflictFiles.length) + ? (i18nState.lang === 'zh' ? `(冲突文件:${p.conflictFiles.join(depSep)})` : ` (conflicts: ${p.conflictFiles.join(', ')})`) : ''; + return i18nState.lang === 'zh' ? `${name}:合并失败${files} → 补救任务` : `${name}: merge failed${files} → remediation`; + } + if (e.type === 'merge.remediated') { + return i18nState.lang === 'zh' ? `${name}:补救完成 → 完成` : `${name}: remediation done → complete`; + } + if (e.type === 'task.updated') { + return i18nState.lang === 'zh' ? `${name} · 字段 ${p.field || ''}` : `${name} · field ${p.field || ''}`; } - if (e.type === 'merge.remediated') return `${name}:补救完成 → 完成`; - if (e.type === 'task.updated') return `${name} · 字段 ${p.field || ''}`; if (e.type === 'project.synced') { - return `新建 ${p.created ?? 0} · 推进 done ${p.doneAdvanced ?? 0} · 跳过 ${p.skipped ?? 0}`; + return i18nState.lang === 'zh' + ? `新建 ${p.created ?? 0} · 推进 done ${p.doneAdvanced ?? 0} · 跳过 ${p.skipped ?? 0}` + : `Created ${p.created ?? 0} · advanced ${p.doneAdvanced ?? 0} · skipped ${p.skipped ?? 0}`; } if (e.type === 'run.started' || e.type === 'run.finished') { - return `${name} · ${p.kind || ''} ${p.status || ''}`; + return `${name} · ${runKindLabel(p.kind) || p.kind || ''} ${p.status || ''}`; } return name; } @@ -1254,13 +1639,13 @@ function eventDetail(e) { function renderEvents() { const ul = $('#eventList'); if (!S.events.length) { - ul.innerHTML = `
  • 暂无事件
  • `; + ul.innerHTML = `
  • ${t('noEvents')}
  • `; return; } ul.innerHTML = S.events.slice(0, 80).map((e) => `
  • - ${icon('event')}${EVENT_LABEL[e.type] || esc(e.type)} + ${icon('event')}${eventLabel(e.type)} ${fmtTime(e.at)}
    ${eventDetail(e)}
    @@ -1271,8 +1656,8 @@ function renderEvents() { function renderParentOptions() { const sel = document.querySelector('#newTaskPanel select[name=parentId]'); const cur = sel.value; - sel.innerHTML = `` + - S.tasks.map((t) => ``).join(''); + sel.innerHTML = `` + + S.tasks.map((task) => ``).join(''); sel.value = cur; } @@ -1348,7 +1733,7 @@ document.addEventListener('click', (ev) => { const noMerge = el.dataset.merge === 'false'; act(() => api(`/api/tasks/${id}/decide`, { method: 'POST', body: JSON.stringify(noMerge ? { action: 'accept', merge: false } : { action: 'accept' }), - }), noMerge ? '已通过(未合并,分支保留)' : '已接受'); + }), noMerge ? t('toastAcceptedNoMerge') : t('toastAccepted')); break; } @@ -1381,17 +1766,17 @@ document.addEventListener('click', (ev) => { case 'gate-reject-confirm': { const ta = document.getElementById(el.dataset.ta || `rej-${id}`); const reason = ta ? ta.value.trim() : ''; - if (!reason) { toast('驳回意见不能为空'); if (ta) ta.focus(); return; } + if (!reason) { toast(t('toastRejectRequired')); if (ta) ta.focus(); return; } S.rejectOpen.delete(id); act(() => api(`/api/tasks/${id}/decide`, { method: 'POST', body: JSON.stringify({ action: 'reject', reason }), - }), '已驳回,任务退回返工'); + }), t('toastRejected')); break; } case 'goto-task': { const target = S.tasks.find((x) => x.id === id); - if (!target) { toast('任务不在当前项目'); break; } + if (!target) { toast(t('toastTaskNotFound')); break; } // 被筛选隐藏时清除筛选,保证可见 const vis = computeVisible(); if (vis && !vis.visible.has(id)) { @@ -1423,11 +1808,11 @@ document.addEventListener('click', (ev) => { ev.stopPropagation(); const box = document.getElementById(`transcript-${id}`); if (!box) break; - if (!box.hidden) { box.hidden = true; el.textContent = '▶ 查看转录'; break; } + if (!box.hidden) { box.hidden = true; el.textContent = t('transcriptView'); break; } box.hidden = false; - el.textContent = '▼ 收起转录'; + el.textContent = t('transcriptHide'); if (!box.dataset.loaded) { - box.innerHTML = '
    加载中…
    '; + box.innerHTML = `
    ${t('transcriptLoading')}
    `; api(`/api/runs/${id}/transcript`) .then((data) => { box.dataset.loaded = '1'; box.innerHTML = renderTranscript(data.messages); }) .catch((e) => { box.innerHTML = `
    ${esc(e.message)}
    `; }); @@ -1440,20 +1825,20 @@ document.addEventListener('click', (ev) => { break; case 'archive-delete': { - const t = S.tasks.find((x) => x.id === id); - const m = childrenMap(S.tasks); + const archTask = S.tasks.find((x) => x.id === id); + const mDel = childrenMap(S.tasks); let descCount = 0; - const countDesc = (tid) => { for (const c of m.get(tid) || []) { descCount++; countDesc(c.id); } }; + const countDesc = (tid) => { for (const c of mDel.get(tid) || []) { descCount++; countDesc(c.id); } }; countDesc(id); - const title = t ? t.title : id; - const hint = descCount ? `及其 ${descCount} 个子孙任务` : ''; - if (!confirm(`彻底删除「${title}」${hint}?\n此操作不可恢复,所有相关数据将一并删除。`)) break; + const title = archTask ? archTask.title : id; + const hint = descCount ? t('archiveDeleteHint', { n: descCount }) : ''; + if (!confirm(t('archiveDeleteConfirm', { title, hint }))) break; $('#archiveModalRoot').hidden = true; $('#archiveModalRoot').innerHTML = ''; act(async () => { await api(`/api/tasks/${id}`, { method: 'DELETE' }); S.expanded.delete(id); - }, `归档任务已删除${descCount ? `(含 ${descCount} 子孙)` : ''}`); + }, descCount ? t('toastArchiveDeletedSubs', { n: descCount }) : t('toastArchiveDeleted')); break; } @@ -1482,7 +1867,7 @@ document.addEventListener('click', (ev) => { S.cplxMenuFor = null; act(() => api(`/api/tasks/${id}`, { method: 'PATCH', body: JSON.stringify({ complexity: c }), - }), `复杂度已改为 ${CPLX_LABEL[c]}(状态按新复杂度重置)`); + }), t('toastCplxSet', { c: CPLX_LABEL[c] })); break; } @@ -1510,7 +1895,7 @@ document.addEventListener('click', (ev) => { S.depsEditFor = null; S.depsEditSel = null; act(() => api(`/api/tasks/${id}`, { method: 'PATCH', body: JSON.stringify({ deps }), - }), `依赖已更新(${deps.length} 项,按依赖重算 ready/blocked)`); + }), t('toastDepsUpdated', { n: deps.length })); break; } @@ -1550,7 +1935,7 @@ document.addEventListener('click', (ev) => { S.syncReqAt = Date.now(); api(`/api/projects/${S.currentProjectId}/sync`, { method: 'POST', body: JSON.stringify({}) }) .then((r) => { - toast(`同步完成:新建 ${r.created ?? 0} · 推进 done ${r.doneAdvanced ?? 0} · 跳过 ${r.skipped ?? 0}`, 'ok'); + toast(t('toastSynced', { created: r.created ?? 0, done: r.doneAdvanced ?? 0, skipped: r.skipped ?? 0 }), 'ok'); (r.warnings || []).forEach((wmsg) => toast(String(wmsg), 'warn')); return fullRefresh(); }) @@ -1577,7 +1962,7 @@ document.addEventListener('click', (ev) => { case 'save-config': { if (!S.currentProjectId) break; const cc = Number($('#cfgConcurrency').value); - if (!Number.isInteger(cc) || cc < 1) { toast('最大并发必须是 ≥1 的整数'); break; } + if (!Number.isInteger(cc) || cc < 1) { toast(t('toastConcInt')); break; } const autonomy = $('#cfgAutonomy').value; const logo = $('#cfgLogo').value.trim(); const verifyCmd = $('#cfgVerifyCmd').value.trim() || null; @@ -1589,40 +1974,40 @@ document.addEventListener('click', (ev) => { await loadProjects(); // 刷新当前值显示 + logo renderSidebar(); $('#configPanel').hidden = true; // 保存成功自动收起 - }, '配置已保存'); + }, t('toastSaved')); break; } // ── 任务行:取消(带确认) ── case 'row-cancel': { ev.stopPropagation(); - const t = S.tasks.find((x) => x.id === id); - if (!t) break; - const m = childrenMap(S.tasks); - const childCount = (m.get(id) || []).length; - const hint = childCount ? `(含 ${childCount} 个直接子任务,子任务保持原状)` : ''; - if (!confirm(`确定取消任务「${t.title}」${hint}?\n取消后任务进入归档,不影响子任务执行。`)) break; - act(() => api(`/api/tasks/${id}/cancel`, { method: 'POST', body: '{}' }), '任务已取消'); + const cancelTask = S.tasks.find((x) => x.id === id); + if (!cancelTask) break; + const mCancel = childrenMap(S.tasks); + const cancelChildCount = (mCancel.get(id) || []).length; + const cancelHintStr = cancelChildCount ? t('cancelHint', { n: cancelChildCount }) : ''; + if (!confirm(t('cancelConfirm', { title: cancelTask.title, hint: cancelHintStr }))) break; + act(() => api(`/api/tasks/${id}/cancel`, { method: 'POST', body: '{}' }), t('toastCancelled')); break; } // ── 任务行:删除(带级联确认) ── case 'row-delete': { ev.stopPropagation(); - const t = S.tasks.find((x) => x.id === id); - const m = childrenMap(S.tasks); + const delTask = S.tasks.find((x) => x.id === id); + const mRowDel = childrenMap(S.tasks); // 递归统计子孙数 let descCount = 0; - const countDesc = (tid) => { for (const c of m.get(tid) || []) { descCount++; countDesc(c.id); } }; - countDesc(id); - const title = t ? t.title : id; - const hint = descCount ? `及其 ${descCount} 个子孙任务` : ''; - if (!confirm(`彻底删除「${title}」${hint}?\n此操作不可恢复,所有相关数据(执行历史、审批记录)将一并删除。`)) break; + const countDescR = (tid) => { for (const c of mRowDel.get(tid) || []) { descCount++; countDescR(c.id); } }; + countDescR(id); + const delTitle = delTask ? delTask.title : id; + const delHintStr = descCount ? t('deleteHint', { n: descCount }) : ''; + if (!confirm(t('deleteConfirm', { title: delTitle, hint: delHintStr }))) break; act(async () => { await api(`/api/tasks/${id}`, { method: 'DELETE' }); S.expanded.delete(id); S.collapsed.delete(id); - }, `任务已删除${descCount ? `(含 ${descCount} 子孙)` : ''}`); + }, descCount ? t('toastDeletedWithSubs', { n: descCount }) : t('toastDeleted')); break; } @@ -1643,7 +2028,7 @@ document.addEventListener('click', (ev) => { case 'task-cancel-confirm': { ev.stopPropagation(); S.confirmCancel = null; - act(() => api(`/api/tasks/${id}/cancel`, { method: 'POST', body: '{}' }), '任务已取消'); + act(() => api(`/api/tasks/${id}/cancel`, { method: 'POST', body: '{}' }), t('toastCancelled')); break; } @@ -1669,7 +2054,12 @@ document.addEventListener('click', (ev) => { await api(`/api/tasks/${delId}`, { method: 'DELETE' }); S.expanded.delete(delId); S.collapsed.delete(delId); - }, '任务已删除'); + }, t('toastDeleted')); + break; + } + + case 'toggle-lang': { + setLang(i18nState.lang === 'zh' ? 'en' : 'zh'); break; } @@ -1719,7 +2109,7 @@ $('#projectList').addEventListener('drop', (ev) => { await api('/api/projects/reorder', { method: 'POST', body: JSON.stringify({ order: ids }) }); await loadProjects(); renderSidebar(); - }, '项目顺序已保存'); + }, t('reorderSaved')); }); // ── 表单提交 ── @@ -1728,7 +2118,7 @@ $('#newProjectForm').addEventListener('submit', (ev) => { const f = ev.target; const name = f.name.value.trim(); const repoPath = f.repoPath.value.trim(); - if (!name || !repoPath) { toast('名称与仓库路径必填'); return; } + if (!name || !repoPath) { toast(t('toastNameRequired')); return; } const body = { name, repoPath }; if (f.defaultBranch.value.trim()) body.defaultBranch = f.defaultBranch.value.trim(); if (f.verifyCmd.value.trim()) body.verifyCmd = f.verifyCmd.value.trim(); @@ -1738,15 +2128,15 @@ $('#newProjectForm').addEventListener('submit', (ev) => { f.reset(); $('#modalRoot').hidden = true; await loadProjects(); - }, `项目「${name}」已创建`); + }, t('toastProjectCreated', { name })); }); $('#newTaskPanel').addEventListener('submit', (ev) => { ev.preventDefault(); const f = ev.target; - if (!S.currentProjectId) { toast('请先选择项目'); return; } + if (!S.currentProjectId) { toast(t('toastTaskNoProject')); return; } const title = f.title.value.trim(); - if (!title) { toast('标题必填'); return; } + if (!title) { toast(t('toastTitleRequired')); return; } const body = { title, complexity: f.complexity.value, @@ -1758,7 +2148,7 @@ $('#newTaskPanel').addEventListener('submit', (ev) => { method: 'POST', body: JSON.stringify(body), }); f.title.value = ''; - }, '任务已创建'); + }, t('toastCreated')); }); // 关键字筛选(轻防抖) @@ -1799,7 +2189,7 @@ function connectWs() { ws.onopen = () => { wsAttempt = 0; - setWsState(true, '实时连接'); + setWsState(true, t('liveConn')); fullRefresh(); // 重连后补齐错过的状态 }; @@ -1810,7 +2200,7 @@ function connectWs() { // 本端刚发起的 sync 由 POST 响应负责 toast,避免重复 if (Date.now() - S.syncReqAt > 3000) { const p = evt.payload || {}; - toast(`todo 同步:新建 ${p.created ?? 0} · 推进 done ${p.doneAdvanced ?? 0} · 跳过 ${p.skipped ?? 0}`, 'ok'); + toast(t('toastWsSynced', { created: p.created ?? 0, done: p.doneAdvanced ?? 0, skipped: p.skipped ?? 0 }), 'ok'); } fullRefresh(); return; @@ -1828,7 +2218,7 @@ function connectWs() { const retry = () => { wsAttempt += 1; const delay = Math.min(30000, 1000 * Math.pow(2, wsAttempt - 1)); - setWsState(false, `已断开 · ${Math.round(delay / 1000)}s 后重连`); + setWsState(false, t('disconnected', { n: Math.round(delay / 1000) })); setTimeout(connectWs, delay); }; ws.onclose = retry; @@ -1839,4 +2229,8 @@ function connectWs() { setInterval(() => { renderAgents(); renderTopbar(); }, 30000); // ── 启动 ── +// 初始化 i18n(从 localStorage 恢复语言,更新静态元素) +applyStaticI18n(); +// 更新连接状态文字(此时还未连接) +$('#wsText').textContent = t('connecting'); fullRefresh().then(connectWs);