7884182d2b
新增用户级设置项 globalConcurrency(settings 表,独立于新建项目默认值 来源 concurrency),缺省 0 = 不限,向后兼容无需 DB 迁移。 - store: UserSettings 加 globalConcurrency 字段 + 默认 0;新增 globalInflightCount()(跨所有项目 started run 总数,与 inflightTaskIds 同口径) - orchestrator.claimTick: 叠加全局闸,与 per-project 闸串联(都过才领); 轮初查一次全局在途、轮内手动 ++,达上限即 return 跨所有项目停止领取 - api: PUT /api/settings 支持 globalConcurrency(空/null 归一为 0) - web: 侧栏「全局设置」入口 + 模态,编辑/回显/校验(≥0 整数) - test: 新增全局闸达上限跨项目阻止 / 未达上限正常领取 / 边界 / 轮初短路 用例 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2370 lines
117 KiB
JavaScript
2370 lines
117 KiB
JavaScript
/* ════════════════════════════════════════════════════════════
|
||
MAESTRO 调度台 · 前端逻辑(无框架,直连 daemon REST + WS)
|
||
════════════════════════════════════════════════════════════ */
|
||
'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(可空,留空用全局默认)',
|
||
cfgGates: '硬闸 / 自动放行', cfgChecks: '分项检查(lint / typecheck / build)',
|
||
cfgLint: 'lint 命令', cfgTypecheck: 'typecheck 命令', cfgBuild: 'build 命令', cfgCmdPh: '留空=跳过此项',
|
||
cfgAutoPlan: '自动放行 plan(全 easy 子任务跳过 plan_review)',
|
||
cfgAutoExec: '自动放行 exec(双复审 approve 后自动合并)',
|
||
cfgAgentRules: '项目 Agent 规范(注入执行/复审 prompt)', cfgAgentRulesPh: '项目专属执行约定、风格、禁忌……(可空)',
|
||
cfgBudget: '成本预算', cfgBudgetPh: 'USD 上限(可空=不限)', cfgBudgetDay: '按天', cfgBudgetMonth: '按月',
|
||
cfgModels: '角色模型(留空=默认 opus-4.8;可填模型 id 或 {"easy":..,"hard":..} JSON)',
|
||
cfgMdlExecutor: 'executor', cfgMdlPlanner: 'planner', cfgMdlReviewer: 'reviewer', cfgMdlConflict: 'conflict',
|
||
cfgModelsBad: 'models 角色「{role}」JSON 格式不对',
|
||
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: '⛶ 预览',
|
||
attnLabel: '需人工确认', attnReason: '失败原因', attnNoReason: '失败原因未记录',
|
||
attnRequeue: '↺ 重新排队', attnCancel: '✕ 取消任务',
|
||
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 — 约定路径 <repo>/todo/todo.json',
|
||
syncTip: '从 <repo>/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: '任务已取消', toastRequeued: '任务已重新排队', toastDeleted: '任务已删除',
|
||
toastDeletedWithSubs: '任务已删除(含 {n} 子孙)',
|
||
toastArchiveDeleted: '归档任务已删除', toastArchiveDeletedSubs: '归档任务已删除(含 {n} 子孙)',
|
||
toastDepsUpdated: '依赖已更新({n} 项,按依赖重算 ready/blocked)',
|
||
toastCplxSet: '复杂度已改为 {c}(状态按新复杂度重置)',
|
||
toastProjectCreated: '项目「{name}」已创建',
|
||
toastTaskNoProject: '请先选择项目', toastTitleRequired: '标题必填',
|
||
toastNameRequired: '名称与仓库路径必填',
|
||
toastTaskNotFound: '任务不在当前项目',
|
||
toastConcInt: '最大并发必须是 ≥1 的整数',
|
||
globalSettings: '全局设置', globalConc: '全局并发上限',
|
||
globalConcHint: '跨所有项目的在途任务总数上限;0 = 不限。',
|
||
globalConcInt: '全局并发上限必须是 ≥0 的整数(0 = 不限)',
|
||
toastSettingsSaved: '全局设置已保存',
|
||
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)',
|
||
cfgGates: 'Gates / auto-approve', cfgChecks: 'Per-step checks (lint / typecheck / build)',
|
||
cfgLint: 'lint command', cfgTypecheck: 'typecheck command', cfgBuild: 'build command', cfgCmdPh: 'empty = skip',
|
||
cfgAutoPlan: 'Auto-approve plan (skip plan_review when all-easy)',
|
||
cfgAutoExec: 'Auto-approve exec (auto-merge after dual review approves)',
|
||
cfgAgentRules: 'Project agent rules (injected into exec/review prompts)', cfgAgentRulesPh: 'Project-specific conventions, style, taboos… (optional)',
|
||
cfgBudget: 'Cost budget', cfgBudgetPh: 'USD cap (empty = unlimited)', cfgBudgetDay: 'per day', cfgBudgetMonth: 'per month',
|
||
cfgModels: 'Per-role models (empty = default opus-4.8; model id or {"easy":..,"hard":..} JSON)',
|
||
cfgMdlExecutor: 'executor', cfgMdlPlanner: 'planner', cfgMdlReviewer: 'reviewer', cfgMdlConflict: 'conflict',
|
||
cfgModelsBad: 'models role "{role}" has invalid JSON',
|
||
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',
|
||
attnLabel: 'Needs attention', attnReason: 'Failure reason', attnNoReason: 'No failure reason recorded',
|
||
attnRequeue: '↺ Re-queue', attnCancel: '✕ Cancel task',
|
||
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 <repo>/todo/todo.json',
|
||
syncTip: 'Sync from <repo>/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', toastRequeued: 'Task re-queued', 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',
|
||
globalSettings: 'Settings', globalConc: 'Global concurrency cap',
|
||
globalConcHint: 'Max in-flight tasks across all projects; 0 = unlimited.',
|
||
globalConcInt: 'Global concurrency cap must be an integer ≥0 (0 = unlimited)',
|
||
toastSettingsSaved: 'Settings saved',
|
||
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: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4" y="4" width="16" height="16" rx="2.5"/><polyline points="8 12 11 15 16 9" stroke="var(--icon-accent,#5fdd7d)"/></svg>`,
|
||
project: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 3h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/></svg>`,
|
||
gate: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3L22 21L2 21Z"/><line x1="12" y1="10" x2="12" y2="14" stroke="var(--icon-accent,#b88ef5)"/><line x1="12" y1="17.5" x2="12" y2="17.51" stroke="var(--icon-accent,#b88ef5)"/></svg>`,
|
||
ready: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 4L19 12L7 20Z" stroke="var(--icon-accent,#5fdd7d)"/></svg>`,
|
||
running: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="3" fill="var(--icon-accent,#59c8d8)" stroke="none"/></svg>`,
|
||
sync: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5" stroke="var(--icon-accent,#5fdd7d)"/></svg>`,
|
||
config: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="6" x2="12" y2="6"/><circle cx="15" cy="6" r="2.5" stroke="var(--icon-accent,#5fdd7d)"/><line x1="18" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="5.5" y2="12"/><circle cx="9" cy="12" r="2.5" stroke="var(--icon-accent,#5fdd7d)"/><line x1="12.5" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="12.5" y2="18"/><circle cx="16" cy="18" r="2.5" stroke="var(--icon-accent,#5fdd7d)"/><line x1="19.5" y1="18" x2="21" y2="18"/></svg>`,
|
||
search: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><line x1="15.5" y1="15.5" x2="21" y2="21" stroke="var(--icon-accent,#5fdd7d)"/></svg>`,
|
||
git: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9" stroke="var(--icon-accent,#5fdd7d)"/></svg>`,
|
||
deps: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/><line x1="10" y1="10" x2="14" y2="14" stroke="var(--icon-accent,#f0b429)"/></svg>`,
|
||
reason: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="5 4 5 14 18 14"/><polyline points="13 9 18 14 13 19" stroke="var(--icon-accent,#f0b429)"/></svg>`,
|
||
event: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="2 12 7 12 10 5 14 19 17 12 22 12" stroke="var(--icon-accent,#59c8d8)"/></svg>`,
|
||
flow: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" y1="12" x2="19" y2="12"/><polyline points="13 6 19 12 13 18" stroke="var(--icon-accent,#59c8d8)"/></svg>`,
|
||
add: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="4" x2="12" y2="20" stroke="var(--icon-accent,#5fdd7d)"/><line x1="4" y1="12" x2="20" y2="12" stroke="var(--icon-accent,#5fdd7d)"/></svg>`,
|
||
close: `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="5" y1="5" x2="19" y2="19"/><line x1="19" y1="5" x2="5" y2="19"/></svg>`,
|
||
};
|
||
/** 返回指定图标的内联 SVG HTML(name 不存在时返回空串) */
|
||
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: '待确认方案',
|
||
ready: '可执行', blocked: '被阻塞', queued: '排队中',
|
||
executing: '执行中', exec_review: '待审/合', failed: '失败',
|
||
needs_attention: '需人工', done: '完成', paused: '暂停', cancelled: '取消',
|
||
};
|
||
const STATUS_GROUP = {
|
||
init: 'idle', analyzing: 'work', speccing: 'work',
|
||
plan_review: 'gate', spec_review: 'gate', exec_review: 'gate',
|
||
decomposed: 'container', ready: 'go', queued: 'go', executing: 'run',
|
||
blocked: 'hold', paused: 'hold', failed: 'bad', needs_attention: 'bad',
|
||
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': '审批驳回',
|
||
'run.started': '运行开始', 'run.finished': '运行结束', 'project.synced': 'todo 同步',
|
||
'merge.failed': '合并失败', 'merge.remediated': '补救完成 → 完成',
|
||
};
|
||
const EVENT_CLASS = {
|
||
'task.created': 'ev-created', 'task.updated': 'ev-updated', 'status.changed': 'ev-status',
|
||
'approval.requested': 'ev-gatewait', 'approval.granted': 'ev-approve',
|
||
'approval.rejected': 'ev-rejected', 'run.started': 'ev-run', 'run.finished': 'ev-run',
|
||
'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 = [
|
||
['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']);
|
||
// 允许编辑 complexity / deps 的状态(与 store.ts 的 COMPLEXITY_EDITABLE 对齐:尚未进入执行链路)
|
||
const STRUCT_EDITABLE = new Set(['init', 'analyzing', 'speccing', 'ready', 'plan_review', 'spec_review', 'blocked']);
|
||
|
||
// ── 全局状态 ──
|
||
const S = {
|
||
projects: [],
|
||
currentProjectId: null,
|
||
tasks: [],
|
||
approvals: [],
|
||
events: [], // 新→旧
|
||
collapsed: new Set(),
|
||
expanded: new Set(),
|
||
rejectOpen: new Set(),
|
||
filter: { cplx: new Set(), status: new Set(), statusGroups: new Set(), kw: '' },// 任务树筛选(组选与单选分离)
|
||
matchCount: 0,
|
||
agents: null, // GET /api/agents 结果(404 时为 null)
|
||
metrics: null, // GET /api/metrics 结果(失败时为 null)
|
||
cplxMenuFor: null, // 复杂度下拉打开的任务 id
|
||
syncReqAt: 0, // 本端发起 sync 的时间(避免 WS 重复 toast)
|
||
previewId: null, // 全局预览中的任务 id
|
||
previewReject: false, // 预览层内驳回意见框是否展开
|
||
archive: { page: 1, size: 20 }, // 归档区分页
|
||
confirmCancel: null, // 取消确认中的任务 id
|
||
confirmDelete: null, // 删除确认中的任务 id
|
||
depsEditFor: null, // 正在编辑依赖的任务 id
|
||
depsEditSel: null, // 编辑中已选依赖 id 集合(Set)
|
||
};
|
||
|
||
const $ = (sel) => document.querySelector(sel);
|
||
|
||
function esc(s) {
|
||
return String(s ?? '').replace(/[&<>"']/g, (c) =>
|
||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
||
}
|
||
function fmtTime(iso) {
|
||
try { return new Date(iso).toLocaleTimeString('zh-CN', { hour12: false }); }
|
||
catch { return iso; }
|
||
}
|
||
function fmtRel(iso) {
|
||
if (!iso) return '';
|
||
const ms = Date.now() - new Date(iso).getTime();
|
||
if (!Number.isFinite(ms)) return String(iso);
|
||
const s = Math.floor(ms / 1000);
|
||
const zh = i18nState.lang === 'zh';
|
||
if (s < 60) return zh ? '刚刚' : 'just now';
|
||
const m = Math.floor(s / 60);
|
||
if (m < 60) return zh ? `${m} 分钟前` : `${m}m ago`;
|
||
const h = Math.floor(m / 60);
|
||
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 */
|
||
function shortModel(m) {
|
||
return String(m || '—').replace(/^claude-/, '');
|
||
}
|
||
|
||
/** 毫秒 → 紧凑时长("45s" / "1m 23s" / "2h 5m");无效 → '—' */
|
||
function fmtDur(ms) {
|
||
const n = Number(ms);
|
||
if (!Number.isFinite(n) || n <= 0) return '—';
|
||
const s = Math.round(n / 1000);
|
||
if (s < 60) return `${s}s`;
|
||
const m = Math.floor(s / 60);
|
||
if (m < 60) return s % 60 ? `${m}m ${s % 60}s` : `${m}m`;
|
||
const h = Math.floor(m / 60);
|
||
return m % 60 ? `${h}h ${m % 60}m` : `${h}h`;
|
||
}
|
||
|
||
/** 0-1 比率 → 百分比文本;null/无效 → '—' */
|
||
function fmtRate(rate) {
|
||
return rate == null || !Number.isFinite(Number(rate)) ? '—' : `${Math.round(Number(rate) * 100)}%`;
|
||
}
|
||
|
||
/** fmtRel 的未来版:到 iso 还有多久("3h 33m" / "42m" / "<1m";已过/无效 → '') */
|
||
function fmtUntil(iso) {
|
||
if (!iso) return '';
|
||
const ms = new Date(iso).getTime() - Date.now();
|
||
if (!Number.isFinite(ms) || ms <= 0) return '';
|
||
const m = Math.ceil(ms / 60000);
|
||
if (m < 1) return '<1m';
|
||
if (m < 60) return `${m}m`;
|
||
const h = Math.floor(m / 60);
|
||
if (h < 24) return m % 60 ? `${h}h ${m % 60}m` : `${h}h`;
|
||
const d = Math.floor(h / 24);
|
||
return h % 24 ? `${d}d ${h % 24}h` : `${d}d`;
|
||
}
|
||
|
||
/** 字符进度条:8 格 █/░ */
|
||
function usageBar(pct) {
|
||
const filled = Math.round(Math.min(100, Math.max(0, pct)) / 100 * 8);
|
||
return '█'.repeat(filled) + '░'.repeat(8 - filled);
|
||
}
|
||
|
||
/** 单个额度窗口(5h / 周)的展示段;>80% 琥珀、>95% 红 */
|
||
function usageSeg(label, w) {
|
||
if (!w || !Number.isFinite(Number(w.percent))) {
|
||
return `<span class="usage-seg"><span class="usage-label">${label}</span> <span class="usage-na">—</span></span>`;
|
||
}
|
||
const pct = Number(w.percent);
|
||
const lvl = pct > 95 ? ' crit' : pct > 80 ? ' warn' : '';
|
||
const until = fmtUntil(w.resetsAt);
|
||
return `<span class="usage-seg${lvl}"><span class="usage-label">${label}</span> ` +
|
||
`<span class="usage-bar">${usageBar(pct)}</span> ${pct}%` +
|
||
`${until ? `<span class="usage-reset">${t('quotaReset', { t: until })}</span>` : ''}</span>`;
|
||
}
|
||
|
||
/** Agent 面板顶部的订阅额度行(执行 agent 烧的就是这个池子);usage=null → 灰字降级 */
|
||
function usageLine(usage) {
|
||
if (!usage || (!usage.session && !usage.weekly)) {
|
||
return `<div class="agent-usage unavail">${t('usageUnavail')}</div>`;
|
||
}
|
||
return `<div class="agent-usage">${t('quota')} ${usageSeg('5h', usage.session)} · ${usageSeg(t('quotaWeek'), usage.weekly)}</div>`;
|
||
}
|
||
|
||
// ── 轻量 Markdown 渲染(零依赖;先整体转义再做结构转换,杜绝注入) ──
|
||
function mdToHtml(src) {
|
||
const inline = (s) => s
|
||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||
.replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
||
const lines = esc(src).split('\n');
|
||
const out = [];
|
||
let i = 0;
|
||
let para = [];
|
||
let list = null; // {type:'ul'|'ol', items:[]}
|
||
const flushPara = () => { if (para.length) { out.push(`<p>${para.map(inline).join('<br>')}</p>`); para = []; } };
|
||
const flushList = () => {
|
||
if (list) {
|
||
out.push(`<${list.type}>${list.items.map((x) => `<li>${inline(x)}</li>`).join('')}</${list.type}>`);
|
||
list = null;
|
||
}
|
||
};
|
||
while (i < lines.length) {
|
||
const line = lines[i];
|
||
if (/^```/.test(line)) { // 围栏代码块
|
||
flushPara(); flushList();
|
||
const buf = []; i++;
|
||
while (i < lines.length && !/^```/.test(lines[i])) { buf.push(lines[i]); i++; }
|
||
i++;
|
||
out.push(`<pre class="codeblock">${buf.join('\n')}</pre>`);
|
||
continue;
|
||
}
|
||
if (/^\s*\|.*\|\s*$/.test(line) && i + 1 < lines.length && /^\s*\|[\s\-:|]+\|\s*$/.test(lines[i + 1])) {
|
||
flushPara(); flushList(); // 表格
|
||
const cells = (l) => l.trim().replace(/^\||\|$/g, '').split('|').map((c) => inline(c.trim()));
|
||
const head = cells(line);
|
||
i += 2;
|
||
const rows = [];
|
||
while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) { rows.push(cells(lines[i])); i++; }
|
||
out.push(`<table><thead><tr>${head.map((h) => `<th>${h}</th>`).join('')}</tr></thead><tbody>${
|
||
rows.map((r) => `<tr>${r.map((c) => `<td>${c}</td>`).join('')}</tr>`).join('')}</tbody></table>`);
|
||
continue;
|
||
}
|
||
let m;
|
||
if ((m = line.match(/^(#{1,6})\s+(.*)$/))) { // 标题
|
||
flushPara(); flushList();
|
||
const lv = m[1].length;
|
||
out.push(`<div class="md-h md-h${lv}">${inline(m[2])}</div>`);
|
||
i++; continue;
|
||
}
|
||
if (/^\s*(---+|\*\*\*+)\s*$/.test(line)) { flushPara(); flushList(); out.push('<hr>'); i++; continue; }
|
||
if ((m = line.match(/^\s*>\s?(.*)$/))) { flushPara(); flushList(); out.push(`<blockquote>${inline(m[1])}</blockquote>`); i++; continue; }
|
||
if ((m = line.match(/^\s*[-*]\s+(.*)$/))) {
|
||
flushPara();
|
||
if (!list || list.type !== 'ul') { flushList(); list = { type: 'ul', items: [] }; }
|
||
list.items.push(m[1]); i++; continue;
|
||
}
|
||
if ((m = line.match(/^\s*\d+[.)]\s+(.*)$/))) {
|
||
flushPara();
|
||
if (!list || list.type !== 'ol') { flushList(); list = { type: 'ol', items: [] }; }
|
||
list.items.push(m[1]); i++; continue;
|
||
}
|
||
if (/^\s*$/.test(line)) { flushPara(); flushList(); i++; continue; }
|
||
para.push(line); i++;
|
||
}
|
||
flushPara(); flushList();
|
||
return out.join('\n');
|
||
}
|
||
|
||
// ── API ──
|
||
async function api(path, opts = {}) {
|
||
let res;
|
||
try {
|
||
res = await fetch(path, {
|
||
headers: { 'Content-Type': 'application/json' },
|
||
...opts,
|
||
});
|
||
} catch (e) {
|
||
throw new Error('无法连接 daemon:' + e.message);
|
||
}
|
||
let data = null;
|
||
try { data = await res.json(); } catch { /* 非 JSON 响应 */ }
|
||
if (!res.ok) throw new Error((data && data.error) || `HTTP ${res.status}`);
|
||
return data;
|
||
}
|
||
|
||
// ── Toast ──
|
||
function toast(msg, kind = 'err') {
|
||
const el = document.createElement('div');
|
||
el.className = `toast ${kind}`;
|
||
el.textContent = msg;
|
||
$('#toastRoot').appendChild(el);
|
||
setTimeout(() => { el.classList.add('out'); setTimeout(() => el.remove(), 350); }, 4200);
|
||
}
|
||
|
||
// ── 数据加载 ──
|
||
async function loadProjects() {
|
||
S.projects = await api('/api/projects');
|
||
if (!S.currentProjectId && S.projects.length) S.currentProjectId = S.projects[0].id;
|
||
if (S.currentProjectId && !S.projects.some((p) => p.id === S.currentProjectId)) {
|
||
S.currentProjectId = S.projects.length ? S.projects[0].id : null;
|
||
}
|
||
}
|
||
|
||
async function loadProjectData() {
|
||
const pid = S.currentProjectId;
|
||
if (!pid) { S.tasks = []; S.events = []; S.approvals = []; return; }
|
||
const [tasks, events, approvals] = await Promise.all([
|
||
api(`/api/projects/${pid}/tasks`),
|
||
api(`/api/projects/${pid}/events`),
|
||
api(`/api/approvals?projectId=${encodeURIComponent(pid)}`),
|
||
]);
|
||
S.tasks = tasks;
|
||
S.events = events.slice().reverse(); // 接口升序 → 展示新在前
|
||
S.approvals = approvals;
|
||
}
|
||
|
||
// agents 端点未就绪(404)时静默降级为空态,不打扰用户
|
||
async function loadAgents() {
|
||
try { S.agents = await api('/api/agents'); }
|
||
catch { S.agents = null; }
|
||
}
|
||
|
||
// 指标聚合(当前项目);失败静默降级为 null(面板隐藏)
|
||
async function loadMetrics() {
|
||
const pid = S.currentProjectId;
|
||
if (!pid) { S.metrics = null; return; }
|
||
try { S.metrics = await api(`/api/metrics?project=${encodeURIComponent(pid)}`); }
|
||
catch { S.metrics = null; }
|
||
}
|
||
|
||
async function refresh() {
|
||
try {
|
||
await Promise.all([loadProjectData(), loadMetrics()]);
|
||
renderAll();
|
||
} catch (e) { toast(e.message); }
|
||
}
|
||
|
||
async function fullRefresh() {
|
||
try {
|
||
await loadProjects();
|
||
await Promise.all([loadProjectData(), loadAgents(), loadMetrics()]);
|
||
renderAll();
|
||
} catch (e) { toast(e.message); }
|
||
}
|
||
|
||
// ── 渲染:草稿保护(重绘前保存输入框内容与焦点) ──
|
||
function snapshotDrafts() {
|
||
const map = new Map();
|
||
document.querySelectorAll('textarea[id], input[id][type=text]').forEach((el) => {
|
||
if (el.value) map.set(el.id, el.value);
|
||
});
|
||
const focusId = document.activeElement && document.activeElement.id;
|
||
return { map, focusId };
|
||
}
|
||
function restoreDrafts(snap) {
|
||
snap.map.forEach((v, id) => {
|
||
const el = document.getElementById(id);
|
||
if (el && !el.value) el.value = v;
|
||
});
|
||
if (snap.focusId) {
|
||
const el = document.getElementById(snap.focusId);
|
||
if (el) { el.focus(); try { el.selectionStart = el.value.length; } catch { /* 非文本 */ } }
|
||
}
|
||
}
|
||
|
||
function renderAll() {
|
||
const snap = snapshotDrafts();
|
||
applyStaticI18n();
|
||
renderSidebar();
|
||
renderTopbar();
|
||
renderAgents();
|
||
renderGates();
|
||
renderTree();
|
||
renderFilterBar();
|
||
renderEvents();
|
||
renderParentOptions();
|
||
renderMetrics();
|
||
renderArchive();
|
||
renderPreview();
|
||
restoreDrafts(snap);
|
||
}
|
||
|
||
// ── 渲染:侧栏 ──
|
||
function renderSidebar() {
|
||
const ul = $('#projectList');
|
||
if (!S.projects.length) {
|
||
ul.innerHTML = `<li class="p-empty">${esc(t('sideEmpty'))}</li>`;
|
||
return;
|
||
}
|
||
ul.innerHTML = S.projects.map((p) => {
|
||
const ch = (p.name || '?').trim().charAt(0).toUpperCase();
|
||
const hue = [...(p.name || '')].reduce((a, c) => (a * 31 + c.charCodeAt(0)) % 360, 7);
|
||
return `
|
||
<li class="${p.id === S.currentProjectId ? 'active' : ''}" data-action="select-project" data-id="${esc(p.id)}" draggable="true">
|
||
<span class="p-logo-wrap">
|
||
<span class="p-logo-fb" style="--h:${hue}">${esc(ch)}</span>
|
||
<img class="p-logo" src="/api/projects/${esc(p.id)}/logo?v=${esc(p.logo || '')}" alt="" onload="this.classList.add('ok')" onerror="this.remove()">
|
||
</span>
|
||
<span class="p-meta">
|
||
<span class="p-name">${esc(p.name)}</span>
|
||
<span class="p-path">${esc(p.repoPath)}</span>
|
||
</span>
|
||
</li>`;
|
||
}).join('');
|
||
}
|
||
|
||
function renderTopbar() {
|
||
const p = S.projects.find((x) => x.id === S.currentProjectId);
|
||
$('#projTitle').textContent = p ? p.name : t('noProject');
|
||
$('#projMeta').textContent = p
|
||
? `${p.repoPath} · ${t('branch')} ${p.defaultBranch} · ${p.status === 'active' ? t('projActive') : t('projPaused')} · ${S.tasks.length}`
|
||
: '';
|
||
// 徽章组(全部不含已完成:done / 已拆解容器 / 取消):
|
||
// 待审批 / 待执行(ready 叶子依赖满足 + 排队中) / 执行中 / 被阻塞 / 总量
|
||
$('#badgeGroup').hidden = !p;
|
||
const FINISHED = new Set(['done', 'decomposed', 'cancelled']);
|
||
const alive = S.tasks.filter((x) => !FINISHED.has(x.status));
|
||
const hasKids = new Set(S.tasks.filter((x) => x.parentId).map((x) => x.parentId));
|
||
const byId = new Map(S.tasks.map((x) => [x.id, x]));
|
||
const nReady = alive.filter((x) =>
|
||
(x.status === 'ready' && !hasKids.has(x.id) &&
|
||
(x.deps || []).every((d) => byId.get(d)?.status === 'done')) ||
|
||
x.status === 'queued',
|
||
).length;
|
||
const nRun = alive.filter((x) => x.status === 'executing').length;
|
||
const nBlocked = alive.filter((x) => x.status === 'blocked').length;
|
||
const setBdg = (sel, n) => {
|
||
const el = $(sel);
|
||
el.querySelector('.bdg-n').textContent = n;
|
||
el.classList.toggle('zero', n === 0);
|
||
};
|
||
setBdg('#bdgGate', S.approvals.length);
|
||
setBdg('#bdgReady', nReady);
|
||
setBdg('#bdgRun', nRun);
|
||
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 ? 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;
|
||
}
|
||
}
|
||
|
||
// ── 渲染:Agent 执行面板 ──
|
||
function renderAgents() {
|
||
const body = $('#agentBody');
|
||
const a = S.agents;
|
||
const total = a && Number(a.totalActive) > 0 ? Number(a.totalActive) : 0;
|
||
$('#agentTotal').textContent = total ? `· ${total}` : '';
|
||
const usage = usageLine(a ? a.usage : null);
|
||
if (!total) {
|
||
body.innerHTML = `${usage}<div class="agent-empty">${t('agentEmpty')}</div>`;
|
||
return;
|
||
}
|
||
const groups = (a.agents || []).filter((g) => g.active && g.active.length);
|
||
const sinceLabel = t('since');
|
||
body.innerHTML = `
|
||
${usage}
|
||
<div class="agent-flex">
|
||
<div class="agent-big">${total}<span class="agent-big-sub">ACTIVE</span></div>
|
||
<div class="agent-groups">
|
||
${groups.map((g) => `
|
||
<div class="agent-proj">
|
||
<div class="agent-proj-head"><b>${esc(g.projectName)}</b>
|
||
<span class="agent-proj-meta">${esc(String(g.concurrency ?? '—'))} · ${esc(autonomyShort(g.autonomy) || g.autonomy || '—')}</span>
|
||
<span class="agent-proj-meta" title="${t('scheduleScore')}">${t('scheduleScore')}</span>
|
||
${g.models ? `<span class="agent-proj-meta">${t('models')} E:${esc(shortModel(g.models.easy))} M:${esc(shortModel(g.models.medium))} H:${esc(shortModel(g.models.hard))}</span>` : ''}
|
||
</div>
|
||
${g.active.map((r) => `
|
||
<div class="agent-run">
|
||
<span class="agent-run-dot"></span>
|
||
<span class="agent-run-title">${esc(r.taskTitle)}</span>
|
||
<span class="agent-run-kind">${esc(r.kind || '')}</span>
|
||
<span class="agent-run-time">${fmtRel(r.startedAt)}${sinceLabel}</span>
|
||
</div>`).join('')}
|
||
</div>`).join('')}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// ── 渲染:审批闸 ──
|
||
function gateDocs(task) {
|
||
const gate = GATE_OF[task.status];
|
||
const blocks = [];
|
||
const emptyNote = i18nState.lang === 'zh' ? '(未填写)' : '(empty)';
|
||
const doc = (label, text) => blocks.push(
|
||
`<div class="gate-doc-label">${label}</div>` +
|
||
(text ? `<div class="doc md">${mdToHtml(text)}</div>` : `<pre class="doc empty">${emptyNote}</pre>`));
|
||
if (gate === 'plan') doc(t('planDocLabel'), task.plan);
|
||
if (gate === 'spec') doc(t('specDocLabel'), task.spec);
|
||
if (gate === 'exec') {
|
||
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(`<div class="gate-doc-label">${t('mergeStatus')}</div>
|
||
<div class="doc merge-failed-banner">${t('mergeFailedBanner')}
|
||
${rem ? `「${esc(rem.title)}」(${statusLabel(rem.status)})` : `${esc(r.mergeTaskId)}`}
|
||
${remDone ? '' : `<br>${t('mergeRemNote')}`}</div>`);
|
||
}
|
||
blocks.push(reviewReportBlocks(r)); // CODE REVIEW + 安全审计 两节(各带 verdict 徽章),置于 DIFF 摘要上方
|
||
blocks.push(`<div class="gate-doc-label">${t('resultDetailLabel')}</div>
|
||
<dl class="result-kv">
|
||
${r.branch ? `<dt>${icon('git')} ${t('branch')}</dt><dd>${esc(r.branch)}</dd>` : ''}
|
||
${r.worktree ? `<dt>worktree</dt><dd>${esc(r.worktree)}</dd>` : ''}
|
||
${r.prUrl ? `<dt>PR</dt><dd><a href="${esc(r.prUrl)}" target="_blank">${esc(r.prUrl)}</a></dd>` : ''}
|
||
</dl>`);
|
||
if (r.diffSummary) blocks.push(`<div class="gate-doc-label">${t('diffLabel')}</div><pre class="doc">${esc(r.diffSummary)}</pre>`);
|
||
} else {
|
||
const noResult = i18nState.lang === 'zh' ? '(无执行结果记录)' : '(no result recorded)';
|
||
blocks.push(`<pre class="doc empty">${noResult}</pre>`);
|
||
}
|
||
if (task.operations) doc(t('opsDocLabel'), task.operations);
|
||
else if (task.spec) doc(t('specDocLabel'), task.spec);
|
||
}
|
||
return blocks.join('');
|
||
}
|
||
|
||
function renderGates() {
|
||
const sec = $('#gateSection');
|
||
if (!S.approvals.length) { sec.hidden = true; return; }
|
||
sec.hidden = false;
|
||
const proj = S.projects.find((x) => x.id === S.currentProjectId);
|
||
const defBranch = proj ? proj.defaultBranch : 'main';
|
||
// 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')));
|
||
}
|
||
|
||
const gateApprovals = S.approvals.filter((ap) => ap.status !== 'needs_attention');
|
||
const attnTasks = S.approvals.filter((ap) => ap.status === 'needs_attention');
|
||
|
||
const gateCards = gateApprovals.map((ap) => {
|
||
const gate = GATE_OF[ap.status];
|
||
const rejOpen = S.rejectOpen.has(ap.id);
|
||
const mergeNote = gate === 'exec' ? `<span class="proj-meta">${t('gateMergeNote', { branch: esc(defBranch) })}</span>` : '';
|
||
const onlyPassBtn = gate === 'exec'
|
||
? `<button class="btn btn-ghost btn-xs" data-action="gate-accept" data-merge="false" data-id="${esc(ap.id)}" title="${t('gateOnlyPass')}">${t('gateOnlyPass')}</button>`
|
||
: '';
|
||
return `
|
||
<div class="gate-card">
|
||
<div class="gate-card-head">
|
||
<span class="gate-kind">${gateLabel(gate)}</span>
|
||
<span class="gate-title">${esc(ap.title)}</span>
|
||
${cplxBadge(ap.complexity)}
|
||
${statusChip(ap.status)}
|
||
<button class="btn btn-ghost btn-xs gate-preview-btn" data-action="preview-open" data-id="${esc(ap.id)}" title="${t('fullRead')}">${t('gatePreview')}</button>
|
||
</div>
|
||
<div class="gate-body">${gateDocs(ap)}</div>
|
||
<div class="gate-actions">
|
||
<button class="btn btn-accept" data-action="gate-accept" data-id="${esc(ap.id)}">${gate === 'exec' ? t('gateAcceptMerge') : t('gateAccept')}</button>${onlyPassBtn}
|
||
<button class="btn btn-reject" data-action="gate-reject-toggle" data-id="${esc(ap.id)}">${t('gateReject')}</button>
|
||
${mergeNote}
|
||
</div>
|
||
${rejOpen ? `
|
||
<div class="reject-form">
|
||
<textarea id="rej-${esc(ap.id)}" placeholder="${t('rejectPlaceholder')}"></textarea>
|
||
<button class="btn btn-reject" data-action="gate-reject-confirm" data-id="${esc(ap.id)}">${t('confirmReject')}</button>
|
||
<button class="btn" data-action="gate-reject-toggle" data-id="${esc(ap.id)}">${t('collapse')}</button>
|
||
</div>` : ''}
|
||
</div>`;
|
||
});
|
||
|
||
const attnCards = attnTasks.map((ap) => {
|
||
const reason = ap.lastRunError || t('attnNoReason');
|
||
return `
|
||
<div class="gate-card gate-card-attn">
|
||
<div class="gate-card-head">
|
||
<span class="gate-kind gate-kind-attn">${t('attnLabel')}</span>
|
||
<span class="gate-title">${esc(ap.title)}</span>
|
||
${cplxBadge(ap.complexity)}
|
||
${statusChip(ap.status)}
|
||
</div>
|
||
<div class="gate-body">
|
||
<div class="gate-doc-label">${t('attnReason')}</div>
|
||
<pre class="doc attn-reason">${esc(reason)}</pre>
|
||
</div>
|
||
<div class="gate-actions">
|
||
<button class="btn btn-accept" data-action="task-requeue" data-id="${esc(ap.id)}">${t('attnRequeue')}</button>
|
||
<button class="btn btn-reject" data-action="task-cancel" data-id="${esc(ap.id)}">${t('attnCancel')}</button>
|
||
<span class="proj-meta">id ${esc(ap.id)} · ${fmtTime(ap.updatedAt)}</span>
|
||
</div>
|
||
</div>`;
|
||
});
|
||
|
||
$('#gateList').innerHTML = [...attnCards, ...gateCards].join('');
|
||
}
|
||
|
||
// ── 渲染:指标面板(数字卡 + 按模型额度分布) ──
|
||
function metricCard(label, value, sub, cls) {
|
||
return `<div class="metric-card${cls ? ' ' + cls : ''}">
|
||
<div class="metric-val">${value}</div>
|
||
<div class="metric-label">${esc(label)}</div>
|
||
${sub ? `<div class="metric-sub">${sub}</div>` : ''}
|
||
</div>`;
|
||
}
|
||
|
||
function renderMetrics() {
|
||
const sec = $('#metricsSection');
|
||
const m = S.metrics;
|
||
// 无项目或无任何任务数据 → 隐藏面板(不打扰空项目)
|
||
if (!m || !S.currentProjectId || (m.taskCount === 0 && m.runs.total === 0 && m.duration.count === 0)) {
|
||
sec.hidden = true;
|
||
return;
|
||
}
|
||
sec.hidden = false;
|
||
$('#metricsScope').textContent = t('metricsTasks', { n: m.taskCount });
|
||
|
||
const d = m.duration;
|
||
const r = m.runs;
|
||
const rv = m.review;
|
||
const rt = m.retry;
|
||
|
||
// 成功率配色:≥80% 绿、≥50% 琥珀、其余红
|
||
const rateCls = (rate) => {
|
||
if (rate == null) return '';
|
||
const v = Number(rate);
|
||
return v >= 0.8 ? 'ok' : v >= 0.5 ? 'warn' : 'crit';
|
||
};
|
||
|
||
const cards = [
|
||
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(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(t('metricCost'), `${totalUnits.toFixed(1)}<span class="metric-unit">u</span>`,
|
||
t('metricCostSub', { dur: fmtDur(totalDur), est: `<span class="metric-est">${t('metricEstTag')}</span>` }), 'est'));
|
||
|
||
const dist = (m.byModel || []).length
|
||
? `<div class="metric-models">
|
||
<div class="metric-models-head">${t('metricModelHead')} <span class="metric-est">${t('metricEst')}</span></div>
|
||
${m.byModel.map((x) => {
|
||
const w = totalUnits > 0 ? Math.round(Number(x.estCostUnits) / totalUnits * 100) : 0;
|
||
return `<div class="metric-model-row">
|
||
<span class="metric-model-name">${esc(shortModel(x.model))}</span>
|
||
<span class="metric-model-bar"><span class="metric-model-fill" style="width:${w}%"></span></span>
|
||
<span class="metric-model-num">${Number(x.estCostUnits).toFixed(1)}u · ${x.runs} run · ${fmtDur(x.durationMs)}</span>
|
||
</div>`;
|
||
}).join('')}
|
||
</div>`
|
||
: '';
|
||
|
||
// Update section head
|
||
const metSec = $('#metricsSection');
|
||
const metHead = metSec && metSec.querySelector('.sec-head');
|
||
if (metHead) metHead.innerHTML = `<span class="head-mark cyan">▍</span>${t('metricsSection')}<span id="metricsScope" class="metrics-scope"> ${t('metricsTasks', { n: m.taskCount })}</span>`;
|
||
$('#metricsBody').innerHTML = `<div class="metric-cards">${cards.join('')}</div>${dist}`;
|
||
}
|
||
|
||
// ── 渲染:归档区(done/取消,updatedAt 倒序,分页) ──
|
||
function renderArchive() {
|
||
const sec = $('#archiveSection');
|
||
if (!S.currentProjectId) { sec.hidden = true; return; }
|
||
// 只列深度 1 的归档树根,行内标注子任务数
|
||
const m = childrenMap(S.tasks);
|
||
const arch = archivedIdSet();
|
||
const subCount = (t) => {
|
||
let n = 0;
|
||
const stack = [...(m.get(t.id) || [])];
|
||
while (stack.length) { const x = stack.pop(); n++; for (const c of m.get(x.id) || []) stack.push(c); }
|
||
return n;
|
||
};
|
||
const items = (m.get('__root__') || [])
|
||
.filter((t) => arch.has(t.id))
|
||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||
sec.hidden = items.length === 0;
|
||
if (sec.hidden) return;
|
||
|
||
const a = S.archive;
|
||
const pages = Math.max(1, Math.ceil(items.length / a.size));
|
||
if (a.page > pages) a.page = pages;
|
||
const slice = items.slice((a.page - 1) * a.size, a.page * a.size);
|
||
|
||
// Update archive section head
|
||
const archSec = $('#archiveSection');
|
||
const archHead = archSec && archSec.querySelector('.sec-head');
|
||
if (archHead) archHead.innerHTML = `<span class="head-mark">▣</span>${t('archiveSection')} · <span id="archiveCount">${items.length}</span>`;
|
||
|
||
$('#archiveList').innerHTML = slice.map((task) => {
|
||
const n = subCount(task);
|
||
return `
|
||
<div class="archive-row" data-action="archive-detail" data-id="${esc(task.id)}" title="${t('archiveTip')}">
|
||
<span class="t-title">${esc(task.title)}<span class="t-id">${esc(task.id.slice(-6))}</span></span>
|
||
${n ? `<span class="archive-subs">${t('archiveSubs', { n })}</span>` : ''}
|
||
<span class="spacer"></span>
|
||
${cplxBadge(task.complexity)}
|
||
${statusChip(task.status)}
|
||
<span class="archive-time" title="${esc(task.updatedAt)}">${fmtRel(task.updatedAt)}</span>
|
||
</div>`;
|
||
}).join('');
|
||
// 底部:翻页 + 每页条数 chip 组(替代原生下拉)
|
||
const sizeChips = [10, 20, 50, 100].map((n) =>
|
||
`<button type="button" class="fchip ${a.size === n ? 'on' : ''}" data-action="archive-size" data-size="${n}">${n}</button>`,
|
||
).join('');
|
||
$('#archivePager').innerHTML = `
|
||
${pages > 1 ? `
|
||
<button class="btn btn-xs" data-action="archive-prev" ${a.page <= 1 ? 'disabled' : ''}>${t('pagePrev')}</button>
|
||
<span class="archive-page">${t('pageOf', { a: a.page, b: pages })}</span>
|
||
<button class="btn btn-xs" data-action="archive-next" ${a.page >= pages ? 'disabled' : ''}>${t('pageNext')}</button>` : ''}
|
||
<span class="archive-size-chips"><span class="archive-size-label">${t('perPage')}</span>${sizeChips}</span>`;
|
||
}
|
||
|
||
// ── 归档详情对话框(只读:全属性/执行历史/审批/状态流转时间线) ──
|
||
function fmtFull(iso) {
|
||
try { return new Date(iso).toLocaleString('zh-CN', { hour12: false }); } catch { return iso || '—'; }
|
||
}
|
||
function fmtDuration(startIso, endIso) {
|
||
if (!startIso || !endIso) return '—';
|
||
const ms = new Date(endIso) - new Date(startIso);
|
||
if (!Number.isFinite(ms) || ms < 0) return '—';
|
||
const s = Math.round(ms / 1000);
|
||
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' ? 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(' · ');
|
||
}
|
||
|
||
async function openArchiveDetail(id) {
|
||
try {
|
||
const [task, runs, events] = await Promise.all([
|
||
api(`/api/tasks/${id}`), api(`/api/tasks/${id}/runs`), api(`/api/tasks/${id}/events`),
|
||
]);
|
||
renderArchiveModal(task, runs, events);
|
||
} catch (e) { toast(e.message); }
|
||
}
|
||
|
||
function renderArchiveModal(task, runs, events) {
|
||
const root = $('#archiveModalRoot');
|
||
const byId = new Map(S.tasks.map((x) => [x.id, x]));
|
||
const parent = task.parentId ? byId.get(task.parentId) : null;
|
||
const r = task.result;
|
||
const depSep = i18nState.lang === 'zh' ? '、' : ', ';
|
||
|
||
const kv = (dt, dd) => `<dt>${dt}</dt><dd>${dd}</dd>`;
|
||
const attrs = `<dl class="result-kv">
|
||
${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))}
|
||
</dl>`;
|
||
|
||
const docs = [['plan', t('planDetailLabel')], ['spec', t('specDetailLabel')], ['operations', t('opsDetailLabel')]]
|
||
.filter(([f]) => task[f])
|
||
.map(([f, label]) => `<div class="gate-doc-label">${label}</div><div class="doc md">${mdToHtml(task[f])}</div>`)
|
||
.join('');
|
||
|
||
const runRows = runs.length ? `<div class="gate-doc-label">${t('runsLabel', { n: runs.length })}</div>
|
||
<div class="run-list">${runs.map((x) => `
|
||
<div class="run-item">
|
||
<div class="run-head">
|
||
<span class="run-kind">${runKindLabel(x.kind)}</span>
|
||
<span class="run-st run-st-${esc(x.status)}">${runStLabel(x.status)}</span>
|
||
<span class="run-time">${fmtFull(x.startedAt)} → ${x.endedAt ? fmtFull(x.endedAt) : '—'} · ${fmtDuration(x.startedAt, x.endedAt)}</span>
|
||
<span class="spacer"></span>
|
||
${x.transcriptRef ? `<button class="btn btn-xs btn-ghost" data-action="run-transcript" data-id="${esc(x.id)}">${t('transcriptView')}</button>` : ''}
|
||
</div>
|
||
${x.error ? `<div class="run-err">${i18nState.lang === 'zh' ? '错误:' : 'Error: '}${esc(x.error)}</div>` : ''}
|
||
${x.transcriptRef ? `<div class="transcript" id="transcript-${esc(x.id)}" hidden></div>` : ''}
|
||
</div>`).join('')}</div>` : '';
|
||
|
||
const resultBlock = r ? `${reviewReportBlocks(r)}
|
||
<div class="gate-doc-label">${t('resultLabel')}</div>
|
||
<dl class="result-kv">
|
||
${r.branch ? kv(`${icon('git')} ${t('branch')}`, esc(r.branch)) : ''}
|
||
${r.worktree ? kv('worktree', esc(r.worktree)) : ''}
|
||
${r.prUrl ? kv('PR', esc(r.prUrl)) : ''}
|
||
${r.commits && r.commits.length ? kv('commits', r.commits.map(esc).join('<br>')) : ''}
|
||
</dl>
|
||
${r.diffSummary ? `<pre class="doc">${esc(r.diffSummary)}</pre>` : ''}` : '';
|
||
|
||
const approvalRows = task.approvals && task.approvals.length ? `<div class="gate-doc-label">${t('approvalsLabel')}</div>
|
||
<dl class="result-kv">${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 ? `<br>${icon('reason')} ${esc(a.reason)}` : ''}`,
|
||
)).join('')}</dl>` : '';
|
||
|
||
const timeline = events.length ? `<div class="gate-doc-label">${t('timelineLabel')}</div>
|
||
<div class="tl">${events.map((e) => {
|
||
const p = e.payload || {};
|
||
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)
|
||
? t('tlMergeFailFiles', { files: p.conflictFiles.join(depSep) }) : '';
|
||
const remT = p.remediationTaskId ? (byId.get(p.remediationTaskId)?.title || p.remediationTaskId) : '';
|
||
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 = t('tlMergeOk', { task: remT ? t('tlMergeOkTask', { title: remT }) : '' });
|
||
}
|
||
const who = tlActor(p);
|
||
return `<div class="tl-item">
|
||
<span class="tl-time">${fmtFull(e.at)}</span>
|
||
<span class="tl-text">${esc(text)}</span>
|
||
${who ? `<span class="tl-who">${esc(who)}</span>` : ''}
|
||
</div>`;
|
||
}).join('')}</div>` : '';
|
||
|
||
root.hidden = false;
|
||
root.innerHTML = `
|
||
<div class="preview-mask" data-action="archive-modal-close"></div>
|
||
<div class="preview-panel">
|
||
<div class="preview-head">
|
||
<span class="gate-kind">${t('archiveModalTitle')}</span>
|
||
<span class="preview-title">${esc(task.title)}</span>
|
||
${cplxBadge(task.complexity)}
|
||
${statusChip(task.status)}
|
||
<span class="spacer"></span>
|
||
<button class="btn btn-ghost" data-action="archive-modal-close" title="${t('closeTip')}">${icon('close')}</button>
|
||
</div>
|
||
<div class="preview-body">
|
||
${attrs}${docs}${runRows}${resultBlock}${approvalRows}${timeline}
|
||
</div>
|
||
<div class="preview-foot archive-modal-actions">
|
||
<button class="btn btn-xs btn-reject" data-action="archive-delete" data-id="${esc(task.id)}">${t('archiveDelete')}</button>
|
||
<button class="btn btn-ghost" data-action="archive-modal-close">${t('collapse')}</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// 过长内容折叠为 <details>,短内容直接 <pre>
|
||
function foldable(text, truncated, threshold = 400) {
|
||
const body = esc(text) + (truncated ? esc('\n' + t('transcriptTrunc')) : '');
|
||
if ((text || '').length <= threshold) return `<pre class="t-pre">${body}</pre>`;
|
||
return `<details class="t-fold"><summary>${t('foldExpand', { n: (text || '').length })}</summary><pre class="t-pre">${body}</pre></details>`;
|
||
}
|
||
|
||
// 结构化转录消息流 → HTML(按 type 分类;文本/结论复用 mdToHtml,工具调用/结果折叠)
|
||
function renderTranscript(messages) {
|
||
if (!messages || !messages.length) return `<div class="transcript-empty">${t('transcriptEmpty')}</div>`;
|
||
const trunc = `<div class="t-trunc">${t('transcriptTrunc')}</div>`;
|
||
return `<div class="t-stream">${messages.map((m) => {
|
||
if (m.type === 'text') {
|
||
return `<div class="t-msg t-text"><span class="t-badge t-badge-a">${t('transcriptBadgeA')}</span>
|
||
<div class="doc md">${mdToHtml(m.text || '')}</div>${m.truncated ? trunc : ''}</div>`;
|
||
}
|
||
if (m.type === 'result') {
|
||
return `<div class="t-msg t-result"><span class="t-badge t-badge-r">${t('transcriptBadgeR')}</span>
|
||
<div class="doc md">${mdToHtml(m.text || '')}</div>${m.truncated ? trunc : ''}</div>`;
|
||
}
|
||
if (m.type === 'fallback') {
|
||
return `<div class="t-msg t-fallback"><span class="t-badge t-badge-w">${t('transcriptBadgeW')}</span>
|
||
<span class="t-fallback-txt">${esc(m.from)} → ${esc(m.to)}</span></div>`;
|
||
}
|
||
if (m.type === 'tool_use') {
|
||
return `<div class="t-msg t-tool"><span class="t-badge t-badge-t">${t('transcriptBadgeT')}</span>
|
||
<span class="t-tool-name">${esc(m.name)}</span>${m.input ? foldable(m.input, m.truncated) : ''}</div>`;
|
||
}
|
||
if (m.type === 'tool_result') {
|
||
return `<div class="t-msg t-tool-result${m.isError ? ' is-err' : ''}">
|
||
<span class="t-badge t-badge-tr">${m.isError ? t('transcriptBadgeTRErr') : t('transcriptBadgeTR')}</span>${foldable(m.text || '', m.truncated)}</div>`;
|
||
}
|
||
return '';
|
||
}).join('')}</div>`;
|
||
}
|
||
|
||
// ── 渲染:全局预览(全屏读方案 + 就地裁决) ──
|
||
function renderPreview() {
|
||
const root = $('#previewRoot');
|
||
if (!S.previewId) { root.hidden = true; root.innerHTML = ''; return; }
|
||
const task = S.approvals.find((x) => x.id === S.previewId) || S.tasks.find((x) => x.id === S.previewId);
|
||
// 任务已离开闸(被裁决/状态变化)→ 自动关闭
|
||
if (!task || !GATE_OF[task.status]) { S.previewId = null; S.previewReject = false; root.hidden = true; root.innerHTML = ''; return; }
|
||
const gate = GATE_OF[task.status];
|
||
const pp = S.projects.find((x) => x.id === S.currentProjectId);
|
||
const defBranch = pp ? pp.defaultBranch : 'main';
|
||
const onlyPassBtn = gate === 'exec'
|
||
? `<button class="btn btn-ghost btn-xs" data-action="gate-accept" data-merge="false" data-id="${esc(task.id)}" title="${t('gateOnlyPass')}">${t('gateOnlyPass')}</button>`
|
||
: '';
|
||
root.hidden = false;
|
||
root.innerHTML = `
|
||
<div class="preview-mask" data-action="preview-close"></div>
|
||
<div class="preview-panel">
|
||
<div class="preview-head">
|
||
<span class="gate-kind">${gateLabel(gate)}</span>
|
||
<span class="preview-title">${esc(task.title)}</span>
|
||
${cplxBadge(task.complexity)}
|
||
${statusChip(task.status)}
|
||
<span class="spacer"></span>
|
||
<button class="btn btn-ghost" data-action="preview-close" title="${t('closeTip')}">${icon('close')}</button>
|
||
</div>
|
||
<div class="preview-body">${gateDocs(task)}</div>
|
||
<div class="preview-foot">
|
||
<button class="btn btn-accept" data-action="gate-accept" data-id="${esc(task.id)}">${gate === 'exec' ? t('gateAcceptMerge') : t('gateAccept')}</button>${onlyPassBtn}
|
||
<button class="btn btn-reject" data-action="preview-reject-toggle" data-id="${esc(task.id)}">${t('gateReject')}</button>
|
||
${gate === 'exec' ? `<span class="proj-meta">${t('gateMergeNote', { branch: esc(defBranch) })}</span>` : ''}
|
||
<span class="proj-meta">id ${esc(task.id)} · ${fmtTime(task.updatedAt)}</span>
|
||
${S.previewReject ? `
|
||
<div class="reject-form preview-reject">
|
||
<textarea id="prev-rej-${esc(task.id)}" placeholder="${t('rejectPlaceholder')}"></textarea>
|
||
<button class="btn btn-reject" data-action="gate-reject-confirm" data-id="${esc(task.id)}" data-ta="prev-rej-${esc(task.id)}">${t('confirmReject')}</button>
|
||
<button class="btn" data-action="preview-reject-toggle" data-id="${esc(task.id)}">${t('collapse')}</button>
|
||
</div>` : ''}
|
||
</div>
|
||
</div>`;
|
||
if (S.previewReject) {
|
||
const ta = document.getElementById(`prev-rej-${task.id}`);
|
||
if (ta) ta.focus();
|
||
}
|
||
}
|
||
|
||
// ── 渲染:任务树 ──
|
||
// taskId 传入时徽章可点击 → 弹出复杂度选择(审批闸卡片不传,保持只读)
|
||
function cplxBadge(c, taskId) {
|
||
const text = CPLX_LABEL[c] || esc(c);
|
||
if (!taskId) return `<span class="cplx cplx-${esc(c)}">${text}</span>`;
|
||
const open = S.cplxMenuFor === taskId;
|
||
const pop = open ? `<span class="cplx-pop">${['hard', 'medium', 'easy'].map((x) =>
|
||
`<button type="button" class="cplx cplx-${x} ${x === c ? 'cur' : ''}" data-action="cplx-set" data-id="${esc(taskId)}" data-cplx="${x}">${CPLX_LABEL[x]}</button>`,
|
||
).join('')}</span>` : '';
|
||
return `<span class="cplx-wrap">
|
||
<button type="button" class="cplx cplx-${esc(c)} cplx-btn" data-action="cplx-menu" data-id="${esc(taskId)}" title="点击修改复杂度(状态会按新复杂度重置)">${text}</button>
|
||
${pop}
|
||
</span>`;
|
||
}
|
||
function statusChip(st) {
|
||
return `<span class="chip chip-${STATUS_GROUP[st] || 'idle'}">${statusLabel(st)}</span>`;
|
||
}
|
||
|
||
// 复审结论徽章:approve=建议通过(绿) / reject=建议拒绝(红) / 其他=未复审(灰)
|
||
function verdictBadge(v) {
|
||
if (v === 'approve') return `<span class="verdict verdict-approve">${t('verdictApprove')}</span>`;
|
||
if (v === 'reject') return `<span class="verdict verdict-reject">${t('verdictReject')}</span>`;
|
||
return `<span class="verdict verdict-none">${t('verdictNone')}</span>`;
|
||
}
|
||
|
||
// 复审报告区块:「CODE REVIEW」「安全审计」两节,各自 verdict 徽章(通过绿/拒绝红/未复审灰)
|
||
function reviewReportBlocks(r) {
|
||
if (!r) return '';
|
||
const noReview = i18nState.lang === 'zh' ? '(无复审记录)' : '(no review record)';
|
||
const sec = (label, summary, verdict) => {
|
||
const head = `<div class="gate-doc-label">${label} ${verdictBadge(verdict)}</div>`;
|
||
if (summary) return `${head}<div class="doc md review-summary">${mdToHtml(summary)}</div>`;
|
||
return `${head}<pre class="doc empty">${noReview}</pre>`;
|
||
};
|
||
return sec(t('reviewLabel'), r.summary, r.verdict) + sec(t('securityLabel'), r.securitySummary, r.securityVerdict);
|
||
}
|
||
|
||
function childrenMap(list = S.tasks) {
|
||
const m = new Map();
|
||
for (const t of list) {
|
||
const key = t.parentId || '__root__';
|
||
if (!m.has(key)) m.set(key, []);
|
||
m.get(key).push(t);
|
||
}
|
||
return m;
|
||
}
|
||
|
||
/**
|
||
* 归档单位 = 深度 1 任务整树:自身与全部子孙都 done/取消 才算「完成」。
|
||
* 返回归档任务 id 集合(含整树)。子任务先完成但兄弟未完时整树留在任务树里展示进度。
|
||
*/
|
||
function archivedIdSet() {
|
||
const m = childrenMap(S.tasks);
|
||
const subtreeDone = (t) => ARCHIVED.has(t.status) && (m.get(t.id) || []).every(subtreeDone);
|
||
const ids = new Set();
|
||
for (const r of m.get('__root__') || []) {
|
||
if (!subtreeDone(r)) continue;
|
||
const stack = [r];
|
||
while (stack.length) {
|
||
const x = stack.pop();
|
||
ids.add(x.id);
|
||
for (const c of m.get(x.id) || []) stack.push(c);
|
||
}
|
||
}
|
||
return ids;
|
||
}
|
||
|
||
/** 任务树里显示的任务(已归档整树沉到底部归档区) */
|
||
function aliveTasks() {
|
||
const arch = archivedIdSet();
|
||
return S.tasks.filter((t) => !arch.has(t.id));
|
||
}
|
||
|
||
// ── 筛选 ──
|
||
function filterActive() {
|
||
const f = S.filter;
|
||
return f.cplx.size > 0 || f.status.size > 0 || f.statusGroups.size > 0 || f.kw.trim() !== '';
|
||
}
|
||
function matchTask(t) {
|
||
const f = S.filter;
|
||
if (f.cplx.size && !f.cplx.has(t.complexity)) return false;
|
||
// 状态条件 = 单选状态 ∪ 已选组的全部成员(任一命中即过)
|
||
if (f.status.size || f.statusGroups.size) {
|
||
const inStatus = f.status.has(t.status);
|
||
const inGroup = [...f.statusGroups].some((gi) => FILTER_GROUPS[gi]?.[1].includes(t.status));
|
||
if (!inStatus && !inGroup) return false;
|
||
}
|
||
const kw = f.kw.trim().toLowerCase();
|
||
if (kw && !String(t.title).toLowerCase().includes(kw)) return false;
|
||
return true;
|
||
}
|
||
// 命中节点 + 其全部祖先(保留父链以维持树形)
|
||
function computeVisible() {
|
||
S.matchCount = 0;
|
||
if (!filterActive()) return null;
|
||
const alive = aliveTasks();
|
||
const byId = new Map(alive.map((t) => [t.id, t]));
|
||
const visible = new Set();
|
||
const matched = new Set();
|
||
for (const t of alive) {
|
||
if (!matchTask(t)) continue;
|
||
matched.add(t.id);
|
||
let cur = t;
|
||
while (cur && !visible.has(cur.id)) {
|
||
visible.add(cur.id);
|
||
cur = cur.parentId ? byId.get(cur.parentId) : null;
|
||
}
|
||
}
|
||
S.matchCount = matched.size;
|
||
return { visible, matched };
|
||
}
|
||
|
||
function renderFilterBar() {
|
||
const bar = $('#filterBar');
|
||
bar.hidden = !S.currentProjectId;
|
||
if (bar.hidden) return;
|
||
const f = S.filter;
|
||
$('#filterCplx').innerHTML = ['hard', 'medium', 'easy'].map((c) =>
|
||
`<button type="button" class="fchip fc-${c} ${f.cplx.has(c) ? 'on' : ''}" data-action="filter-cplx" data-cplx="${c}">${CPLX_LABEL[c]}</button>`,
|
||
).join('');
|
||
$('#filterStatus').innerHTML = FILTER_GROUPS.map(([key, sts], gi) => {
|
||
// 组选与单选分离:组 chip 只反映组选择,成员 chip 只反映单选;单状态组只渲染状态 chip 本身
|
||
const members = sts.map((s) =>
|
||
`<button type="button" class="fchip fst ${f.status.has(s) ? 'on' : ''}" data-action="filter-status" data-st="${s}">${statusLabel(s)}</button>`,
|
||
).join('');
|
||
if (sts.length === 1) return `<span class="fgroup">${members}</span>`;
|
||
const gCls = f.statusGroups.has(gi) ? 'on' : '';
|
||
const groupName = fgroupLabel(key);
|
||
const groupTip = i18nState.lang === 'zh' ? '按整组筛选' : 'Filter by group';
|
||
return `<span class="fgroup">
|
||
<button type="button" class="fchip fgroup-chip ${gCls}" data-action="filter-group" data-gi="${gi}" title="${groupTip}">${groupName}</button>
|
||
${members}
|
||
</span>`;
|
||
}).join('');
|
||
const active = filterActive();
|
||
$('#filterClear').hidden = !active;
|
||
$('#filterCount').textContent = active ? t('matchCount', { n: S.matchCount, total: S.tasks.length }) : '';
|
||
}
|
||
|
||
function renderTree() {
|
||
const root = $('#taskTree');
|
||
if (!S.currentProjectId) {
|
||
root.innerHTML = `<div class="tree-empty"><b>NO PROJECT</b>${t('treeNoProject')}</div>`;
|
||
return;
|
||
}
|
||
if (!S.tasks.length) {
|
||
root.innerHTML = `<div class="tree-empty"><b>EMPTY</b>${t('treeEmpty')}</div>`;
|
||
return;
|
||
}
|
||
const alive = aliveTasks();
|
||
if (!alive.length) {
|
||
root.innerHTML = `<div class="tree-empty"><b>ALL DONE</b>${t('treeAllDone')}</div>`;
|
||
return;
|
||
}
|
||
const m = childrenMap(alive);
|
||
const vis = computeVisible();
|
||
if (vis && !vis.visible.size) {
|
||
root.innerHTML = `<div class="tree-empty"><b>NO MATCH</b>${t('treeNoMatch')}</div>`;
|
||
return;
|
||
}
|
||
const byId = new Map(S.tasks.map((x) => [x.id, x]));
|
||
const i18n = t; // t will be shadowed by the task parameter inside renderNode
|
||
const renderNode = (t) => {
|
||
if (vis && !vis.visible.has(t.id)) return '';
|
||
const kids = m.get(t.id) || [];
|
||
const collapsed = !vis && S.collapsed.has(t.id); // 筛选时强制展开,保证命中可见
|
||
const expanded = S.expanded.has(t.id);
|
||
const isGate = !!GATE_OF[t.status];
|
||
const isCtx = vis && !vis.matched.has(t.id); // 仅作为父链保留的节点,弱化显示
|
||
const caret = kids.length
|
||
? `<span class="caret ${collapsed ? '' : 'open'}" data-action="toggle-collapse" data-id="${esc(t.id)}">▶</span>`
|
||
: `<span class="caret leaf">·</span>`;
|
||
return `
|
||
<div class="task-node depth-${t.depth}">
|
||
<div class="task-row ${isGate ? 'is-gate' : ''} ${expanded ? 'expanded' : ''} ${isCtx ? 'filter-ctx' : ''}" data-action="toggle-detail" data-id="${esc(t.id)}">
|
||
${caret}
|
||
<span class="t-title">${esc(t.title)}<span class="t-id">${esc(String(t.id || '').slice(-6))}</span></span>
|
||
<span class="spacer"></span>
|
||
${(() => {
|
||
// blocked(系统按依赖自动落位)→ 显示在等哪几条
|
||
if (t.status !== 'blocked' || !(t.deps || []).length) return '';
|
||
const unmet = t.deps.filter((d) => byId.get(d)?.status !== 'done');
|
||
if (!unmet.length) return '';
|
||
const depSep2 = i18nState.lang === 'zh' ? '、' : ', ';
|
||
return `<span class="t-deps-wait" title="${unmet.map((d) => esc(byId.get(d)?.title || d)).join(depSep2)}">${icon('deps')} ${i18n('depsWait', { n: unmet.length })}</span>`;
|
||
})()}
|
||
<span class="t-prio ${t.priority === 0 ? 'hot' : ''}">P${t.priority}</span>
|
||
${cplxBadge(t.complexity, t.id)}
|
||
${statusChip(t.status)}
|
||
${!ARCHIVED.has(t.status) ? `<button class="btn btn-ghost btn-xs task-row-cancel" data-action="row-cancel" data-id="${esc(t.id)}" title="取消任务(进入归档)">${icon('close')}</button>` : ''}
|
||
<button class="btn btn-ghost btn-xs task-row-delete" data-action="row-delete" data-id="${esc(t.id)}" title="彻底删除任务(不可恢复)">🗑</button>
|
||
</div>
|
||
${expanded ? renderDetail(t) : ''}
|
||
${kids.length && !collapsed ? `<div class="task-children">${kids.map(renderNode).join('')}</div>` : ''}
|
||
</div>`;
|
||
};
|
||
root.innerHTML = (m.get('__root__') || []).map(renderNode).join('');
|
||
}
|
||
|
||
// ── 渲染:任务详情 ──
|
||
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(task) {
|
||
const parts = [];
|
||
const w = writableField(task);
|
||
|
||
// 产出全部只读展示:写产出与提交评审由 Claude Code 经 MCP 完成,看板只留用户动作(审批/配置/同步)
|
||
const docs = [['plan', t('planDetailLabel')], ['spec', t('specDetailLabel')], ['operations', t('opsDetailLabel')]];
|
||
for (const [f, label] of docs) {
|
||
if (!task[f]) continue;
|
||
parts.push(`<div><div class="detail-label">${label}</div><div class="doc md">${mdToHtml(task[f])}</div></div>`);
|
||
}
|
||
|
||
// 当前阶段应产出但还没有内容 → 占位提示
|
||
if (w && !task[w.field]) {
|
||
parts.push(`<div><div class="detail-label">${w.label}</div><div class="doc doc-pending">${t('pendingDoc')}</div></div>`);
|
||
}
|
||
|
||
// result
|
||
if (task.result) {
|
||
const r = task.result;
|
||
parts.push(`
|
||
<div>${reviewReportBlocks(r)}
|
||
<div class="detail-label">${t('resultDetailLabel')}</div>
|
||
<dl class="result-kv">
|
||
<dt>${icon('git')} ${t('branch')}</dt><dd>${esc(r.branch || '—')}</dd>
|
||
<dt>worktree</dt><dd>${esc(r.worktree || '—')}</dd>
|
||
${r.prUrl ? `<dt>PR</dt><dd>${esc(r.prUrl)}</dd>` : ''}
|
||
${r.commits && r.commits.length ? `<dt>commits</dt><dd>${r.commits.map(esc).join('<br>')}</dd>` : ''}
|
||
</dl>
|
||
${r.diffSummary ? `<pre class="doc" style="margin-top:6px">${esc(r.diffSummary)}</pre>` : ''}
|
||
</div>`);
|
||
}
|
||
|
||
// 审批历史
|
||
if (task.approvals && task.approvals.length) {
|
||
parts.push(`
|
||
<div><div class="detail-label">${t('approvalsHistLabel')}</div>
|
||
${task.approvals.map((a) => `
|
||
<div class="approval-item">
|
||
<span class="ap-act-${a.action}">${a.action === 'accept' ? `✓ ${t('accepted')}` : `✗ ${t('rejected')}`}</span>
|
||
<span class="ap-gate">${gateLabel(a.gate)} · ${esc(a.actor)}</span>
|
||
<span class="ap-time">${fmtTime(a.at)}</span>
|
||
${a.reason ? `<span class="ap-reason">${icon('reason')}<span>${esc(a.reason)}</span></span>` : ''}
|
||
</div>`).join('')}
|
||
</div>`);
|
||
}
|
||
|
||
// 依赖列表:标题 + 状态 chip,未完成的高亮(全部 done 本任务才可被领取);可编辑状态支持增删
|
||
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 `<div class="dep-item ${ok ? 'ok' : 'wait'}" data-action="goto-task" data-id="${esc(d)}" title="${depGotoTip}">
|
||
<span class="dep-mark">${ok ? '✓' : icon('deps')}</span>
|
||
${dt ? statusChip(dt.status) : ''}
|
||
<span class="dep-title">${esc(dt ? dt.title : d)}</span>
|
||
<span class="t-id">${esc(d.slice(-6))}</span>
|
||
<span class="dep-go">↧</span>
|
||
</div>`;
|
||
}).join('') || `<div class="dep-empty">${t('depEmpty')}</div>`;
|
||
const depEditTip = i18nState.lang === 'zh' ? '增删本任务的依赖' : 'Edit task dependencies';
|
||
const editBtn = depsEditable
|
||
? `<button class="btn btn-xs deps-edit-btn" data-action="deps-edit" data-id="${esc(task.id)}" title="${depEditTip}">${t('depEdit')}</button>`
|
||
: '';
|
||
parts.push(`<div><div class="detail-label">${t('depsLabel')}${editBtn}</div><div class="dep-list">${items}</div></div>`);
|
||
}
|
||
|
||
if (!parts.length) parts.push(`<div class="proj-meta">${t('noOutput', { st: statusLabel(task.status) })}</div>`);
|
||
|
||
const depLabelMeta = i18nState.lang === 'zh' ? '深度' : 'depth';
|
||
parts.push(`<div class="proj-meta">id ${esc(task.id)} · ${depLabelMeta} ${task.depth} · ${fmtTime(task.createdAt)} · ${fmtTime(task.updatedAt)}</div>`);
|
||
|
||
// ── 取消 / 删除操作区 ──
|
||
const m = childrenMap(S.tasks);
|
||
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 `<div class="detail-action-confirm">
|
||
<span class="detail-confirm-msg">${esc(msg)}</span>
|
||
<button class="btn btn-xs btn-reject" data-action="task-cancel-confirm" data-id="${esc(task.id)}">${t('cancelConfirmBtn')}</button>
|
||
<button class="btn btn-xs" data-action="task-cancel-abort" data-id="${esc(task.id)}">${t('cancelAbortBtn')}</button>
|
||
</div>`;
|
||
}
|
||
return `<button class="btn btn-xs btn-reject" data-action="task-cancel" data-id="${esc(task.id)}" title="${cancelTip}">${icon('close')} ${t('cancelBtn')}</button>`;
|
||
})() : '';
|
||
const deleteSection = (() => {
|
||
if (S.confirmDelete === task.id) {
|
||
const hint = childCount ? t('deleteDetailHint', { n: childCount }) : '';
|
||
const msg = t('deleteDetailMsg', { title: task.title, hint });
|
||
return `<div class="detail-action-confirm detail-action-confirm-del">
|
||
<span class="detail-confirm-msg">${esc(msg)}</span>
|
||
<button class="btn btn-xs btn-reject" data-action="task-delete-confirm" data-id="${esc(task.id)}">${t('deleteConfirmBtn')}</button>
|
||
<button class="btn btn-xs" data-action="task-delete-abort" data-id="${esc(task.id)}">${t('deleteAbortBtn')}</button>
|
||
</div>`;
|
||
}
|
||
return childCount
|
||
? `<button class="btn btn-xs" data-action="task-delete" data-id="${esc(task.id)}" title="${deleteTip}">${t('deleteBtnHint', { n: childCount })}</button>`
|
||
: `<button class="btn btn-xs" data-action="task-delete" data-id="${esc(task.id)}" title="${deleteTip}">${t('deleteBtn')}</button>`;
|
||
})();
|
||
if (cancelSection || deleteSection) {
|
||
parts.push(`<div class="detail-actions">${cancelSection}${deleteSection}</div>`);
|
||
}
|
||
|
||
return `<div class="task-detail"><div class="detail-grid">${parts.join('')}</div></div>`;
|
||
}
|
||
|
||
/** 选某任务为 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);
|
||
if (!node) return false;
|
||
for (const d of (node.deps || [])) {
|
||
if (d === target) return true;
|
||
if (!seen.has(d)) { seen.add(d); if (reaches(d, target, seen)) return true; }
|
||
}
|
||
return false;
|
||
};
|
||
const bad = new Set();
|
||
for (const x of S.tasks) {
|
||
if (x.id === task.id) continue;
|
||
if (reaches(x.id, task.id, new Set())) bad.add(x.id);
|
||
}
|
||
return bad;
|
||
}
|
||
|
||
/** 依赖编辑器:多选当前项目其它任务(排除自己与会成环者),保存走 PATCH deps */
|
||
function depsEditorBlock(task) {
|
||
const sel = S.depsEditSel || new Set();
|
||
const bad = depsWouldCycle(task);
|
||
const candidates = S.tasks
|
||
.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 `<div class="dep-pick ${on ? 'on' : ''}" data-action="deps-edit-toggle" data-id="${esc(task.id)}" data-dep="${esc(x.id)}">
|
||
<span class="dep-check">${on ? '☑' : '☐'}</span>
|
||
${statusChip(x.status)}
|
||
<span class="dep-title">${esc(x.title)}</span>
|
||
<span class="t-id">${esc(x.id.slice(-6))}</span>
|
||
</div>`;
|
||
}).join('') || `<div class="dep-empty">${t('depNoOthers')}</div>`;
|
||
return `<div><div class="detail-label">${t('depsEditLabel')}</div>
|
||
<div class="dep-list dep-pick-list">${rows}</div>
|
||
<div class="deps-edit-foot">
|
||
<button class="btn btn-xs btn-accept" data-action="deps-edit-save" data-id="${esc(task.id)}">${t('saveDeps', { n: sel.size })}</button>
|
||
<button class="btn btn-xs" data-action="deps-edit-cancel" data-id="${esc(task.id)}">${t('cancel')}</button>
|
||
</div></div>`;
|
||
}
|
||
|
||
// ── 渲染:事件流 ──
|
||
function eventDetail(e) {
|
||
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') {
|
||
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} · ${gateLabel(p.gate)}${r}`;
|
||
}
|
||
if (e.type === 'task.created') {
|
||
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)
|
||
? (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 === 'project.synced') {
|
||
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} · ${runKindLabel(p.kind) || p.kind || ''} ${p.status || ''}`;
|
||
}
|
||
return name;
|
||
}
|
||
|
||
function renderEvents() {
|
||
const ul = $('#eventList');
|
||
if (!S.events.length) {
|
||
ul.innerHTML = `<li class="ev-empty">${t('noEvents')}</li>`;
|
||
return;
|
||
}
|
||
ul.innerHTML = S.events.slice(0, 80).map((e) => `
|
||
<li class="${EVENT_CLASS[e.type] || ''}">
|
||
<div class="ev-head">
|
||
<span class="ev-type">${icon('event')}${eventLabel(e.type)}</span>
|
||
<span class="ev-time">${fmtTime(e.at)}</span>
|
||
</div>
|
||
<div class="ev-detail">${eventDetail(e)}</div>
|
||
</li>`).join('');
|
||
}
|
||
|
||
// ── 渲染:父任务下拉 ──
|
||
function renderParentOptions() {
|
||
const sel = document.querySelector('#newTaskPanel select[name=parentId]');
|
||
const cur = sel.value;
|
||
sel.innerHTML = `<option value="">${t('topLevel')}</option>` +
|
||
S.tasks.map((task) => `<option value="${esc(task.id)}">${esc('· '.repeat(Math.max(0, (task.depth || 1) - 1)) + task.title)}</option>`).join('');
|
||
sel.value = cur;
|
||
}
|
||
|
||
// ── 动作 ──
|
||
async function act(fn, okMsg) {
|
||
try {
|
||
await fn();
|
||
if (okMsg) toast(okMsg, 'ok');
|
||
await refresh();
|
||
} catch (e) { toast(e.message); }
|
||
}
|
||
|
||
document.addEventListener('click', (ev) => {
|
||
const el = ev.target.closest('[data-action]');
|
||
|
||
// 复杂度下拉:点击弹层外任意处关闭
|
||
if (S.cplxMenuFor && !ev.target.closest('.cplx-wrap')) {
|
||
S.cplxMenuFor = null;
|
||
renderTree();
|
||
}
|
||
if (!el) return;
|
||
const action = el.dataset.action;
|
||
const id = el.dataset.id;
|
||
|
||
switch (action) {
|
||
case 'select-project':
|
||
if (S.currentProjectId !== id) {
|
||
S.currentProjectId = id;
|
||
S.expanded.clear(); S.collapsed.clear(); S.rejectOpen.clear();
|
||
S.cplxMenuFor = null; S.confirmCancel = null; S.confirmDelete = null;
|
||
$('#configPanel').hidden = true;
|
||
refresh();
|
||
}
|
||
break;
|
||
|
||
case 'open-new-project':
|
||
$('#modalRoot').hidden = false;
|
||
document.querySelector('#newProjectForm input[name=name]').focus();
|
||
break;
|
||
case 'close-modal':
|
||
$('#modalRoot').hidden = true;
|
||
break;
|
||
|
||
case 'open-settings':
|
||
api('/api/settings').then((s) => {
|
||
$('#setGlobalConc').value = s.globalConcurrency ?? 0;
|
||
$('#settingsRoot').hidden = false;
|
||
$('#setGlobalConc').focus();
|
||
}).catch((e) => toast(e.message));
|
||
break;
|
||
case 'close-settings':
|
||
$('#settingsRoot').hidden = true;
|
||
break;
|
||
|
||
case 'toggle-new-task': {
|
||
const p = $('#newTaskPanel');
|
||
p.hidden = !p.hidden;
|
||
if (!p.hidden) p.querySelector('input[name=title]').focus();
|
||
break;
|
||
}
|
||
|
||
case 'toggle-collapse':
|
||
ev.stopPropagation();
|
||
S.collapsed.has(id) ? S.collapsed.delete(id) : S.collapsed.add(id);
|
||
renderTree();
|
||
break;
|
||
|
||
case 'toggle-detail': {
|
||
// 点的是 caret 时由上面分支处理(stopPropagation);输入区内点击不折叠
|
||
if (ev.target.closest('.task-detail')) break;
|
||
if (S.expanded.has(id)) {
|
||
S.expanded.delete(id);
|
||
// 折叠时清除行内确认状态
|
||
if (S.confirmCancel === id) S.confirmCancel = null;
|
||
if (S.confirmDelete === id) S.confirmDelete = null;
|
||
} else {
|
||
S.expanded.add(id);
|
||
}
|
||
renderTree();
|
||
break;
|
||
}
|
||
|
||
case 'gate-accept': {
|
||
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 ? t('toastAcceptedNoMerge') : t('toastAccepted'));
|
||
break;
|
||
}
|
||
|
||
case 'task-requeue':
|
||
act(() => api(`/api/tasks/${id}/requeue`, { method: 'POST' }), t('toastRequeued'));
|
||
break;
|
||
|
||
case 'task-cancel':
|
||
act(() => api(`/api/tasks/${id}/transition`, {
|
||
method: 'POST', body: JSON.stringify({ status: 'cancelled' }),
|
||
}), t('toastCancelled'));
|
||
break;
|
||
|
||
case 'gate-reject-toggle':
|
||
S.rejectOpen.has(id) ? S.rejectOpen.delete(id) : S.rejectOpen.add(id);
|
||
renderGates();
|
||
if (S.rejectOpen.has(id)) {
|
||
const ta = document.getElementById(`rej-${id}`);
|
||
if (ta) ta.focus();
|
||
}
|
||
break;
|
||
|
||
case 'preview-open':
|
||
S.previewId = id;
|
||
S.previewReject = false;
|
||
renderPreview();
|
||
break;
|
||
|
||
case 'preview-close':
|
||
S.previewId = null;
|
||
S.previewReject = false;
|
||
renderPreview();
|
||
break;
|
||
|
||
case 'preview-reject-toggle':
|
||
S.previewReject = !S.previewReject;
|
||
renderPreview();
|
||
break;
|
||
|
||
case 'gate-reject-confirm': {
|
||
const ta = document.getElementById(el.dataset.ta || `rej-${id}`);
|
||
const reason = ta ? ta.value.trim() : '';
|
||
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(t('toastTaskNotFound')); break; }
|
||
// 被筛选隐藏时清除筛选,保证可见
|
||
const vis = computeVisible();
|
||
if (vis && !vis.visible.has(id)) {
|
||
S.filter.cplx.clear(); S.filter.status.clear(); S.filter.statusGroups.clear(); S.filter.kw = '';
|
||
renderFilterBar();
|
||
}
|
||
// 展开全部祖先 + 展开目标详情
|
||
let cur = target;
|
||
while (cur && cur.parentId) {
|
||
S.collapsed.delete(cur.parentId);
|
||
cur = S.tasks.find((x) => x.id === cur.parentId);
|
||
}
|
||
S.expanded.add(id);
|
||
renderTree();
|
||
const row = document.querySelector(`.task-row[data-id="${CSS.escape(id)}"]`);
|
||
if (row) {
|
||
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
row.classList.add('flash');
|
||
setTimeout(() => row.classList.remove('flash'), 1800);
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'archive-detail':
|
||
openArchiveDetail(id);
|
||
break;
|
||
|
||
case 'run-transcript': {
|
||
ev.stopPropagation();
|
||
const box = document.getElementById(`transcript-${id}`);
|
||
if (!box) break;
|
||
if (!box.hidden) { box.hidden = true; el.textContent = t('transcriptView'); break; }
|
||
box.hidden = false;
|
||
el.textContent = t('transcriptHide');
|
||
if (!box.dataset.loaded) {
|
||
box.innerHTML = `<div class="transcript-loading">${t('transcriptLoading')}</div>`;
|
||
api(`/api/runs/${id}/transcript`)
|
||
.then((data) => { box.dataset.loaded = '1'; box.innerHTML = renderTranscript(data.messages); })
|
||
.catch((e) => { box.innerHTML = `<div class="transcript-err">${esc(e.message)}</div>`; });
|
||
}
|
||
break;
|
||
}
|
||
case 'archive-modal-close':
|
||
$('#archiveModalRoot').hidden = true;
|
||
$('#archiveModalRoot').innerHTML = '';
|
||
break;
|
||
|
||
case 'archive-delete': {
|
||
const archTask = S.tasks.find((x) => x.id === id);
|
||
const mDel = childrenMap(S.tasks);
|
||
let descCount = 0;
|
||
const countDesc = (tid) => { for (const c of mDel.get(tid) || []) { descCount++; countDesc(c.id); } };
|
||
countDesc(id);
|
||
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 ? t('toastArchiveDeletedSubs', { n: descCount }) : t('toastArchiveDeleted'));
|
||
break;
|
||
}
|
||
|
||
case 'archive-size':
|
||
S.archive.size = Number(el.dataset.size) || 20;
|
||
S.archive.page = 1;
|
||
renderArchive();
|
||
break;
|
||
|
||
case 'archive-prev':
|
||
S.archive.page = Math.max(1, S.archive.page - 1);
|
||
renderArchive();
|
||
break;
|
||
case 'archive-next':
|
||
S.archive.page += 1;
|
||
renderArchive();
|
||
break;
|
||
|
||
case 'cplx-menu':
|
||
S.cplxMenuFor = S.cplxMenuFor === id ? null : id;
|
||
renderTree();
|
||
break;
|
||
|
||
case 'cplx-set': {
|
||
const c = el.dataset.cplx;
|
||
S.cplxMenuFor = null;
|
||
act(() => api(`/api/tasks/${id}`, {
|
||
method: 'PATCH', body: JSON.stringify({ complexity: c }),
|
||
}), t('toastCplxSet', { c: CPLX_LABEL[c] }));
|
||
break;
|
||
}
|
||
|
||
// ── 依赖编辑 ──
|
||
case 'deps-edit': {
|
||
const t = S.tasks.find((x) => x.id === id);
|
||
S.depsEditFor = id;
|
||
S.depsEditSel = new Set((t && t.deps) || []);
|
||
renderTree();
|
||
break;
|
||
}
|
||
case 'deps-edit-toggle': {
|
||
const dep = el.dataset.dep;
|
||
if (!S.depsEditSel) S.depsEditSel = new Set();
|
||
S.depsEditSel.has(dep) ? S.depsEditSel.delete(dep) : S.depsEditSel.add(dep);
|
||
renderTree();
|
||
break;
|
||
}
|
||
case 'deps-edit-cancel':
|
||
S.depsEditFor = null; S.depsEditSel = null;
|
||
renderTree();
|
||
break;
|
||
case 'deps-edit-save': {
|
||
const deps = [...(S.depsEditSel || new Set())];
|
||
S.depsEditFor = null; S.depsEditSel = null;
|
||
act(() => api(`/api/tasks/${id}`, {
|
||
method: 'PATCH', body: JSON.stringify({ deps }),
|
||
}), t('toastDepsUpdated', { n: deps.length }));
|
||
break;
|
||
}
|
||
|
||
// ── 任务树筛选 ──
|
||
case 'filter-cplx': {
|
||
const c = el.dataset.cplx;
|
||
S.filter.cplx.has(c) ? S.filter.cplx.delete(c) : S.filter.cplx.add(c);
|
||
renderTree(); renderFilterBar();
|
||
break;
|
||
}
|
||
case 'filter-status': {
|
||
const st = el.dataset.st;
|
||
S.filter.status.has(st) ? S.filter.status.delete(st) : S.filter.status.add(st);
|
||
renderTree(); renderFilterBar();
|
||
break;
|
||
}
|
||
case 'filter-group': {
|
||
const gi = Number(el.dataset.gi);
|
||
if (!FILTER_GROUPS[gi]) break;
|
||
S.filter.statusGroups.has(gi) ? S.filter.statusGroups.delete(gi) : S.filter.statusGroups.add(gi);
|
||
renderTree(); renderFilterBar();
|
||
break;
|
||
}
|
||
case 'filter-clear':
|
||
S.filter.cplx.clear();
|
||
S.filter.status.clear();
|
||
S.filter.statusGroups.clear();
|
||
S.filter.kw = '';
|
||
$('#filterKw').value = '';
|
||
renderTree(); renderFilterBar();
|
||
break;
|
||
|
||
// ── 同步 todo ──
|
||
case 'sync-todo': {
|
||
if (!S.currentProjectId) break;
|
||
el.disabled = true;
|
||
S.syncReqAt = Date.now();
|
||
api(`/api/projects/${S.currentProjectId}/sync`, { method: 'POST', body: JSON.stringify({}) })
|
||
.then((r) => {
|
||
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();
|
||
})
|
||
.catch((e) => { toast(e.message); el.disabled = false; });
|
||
break;
|
||
}
|
||
|
||
// ── Agent 配置 ──
|
||
case 'toggle-config': {
|
||
const panel = $('#configPanel');
|
||
panel.hidden = !panel.hidden;
|
||
if (!panel.hidden) {
|
||
const p = S.projects.find((x) => x.id === S.currentProjectId);
|
||
if (p) {
|
||
$('#cfgConcurrency').value = p.concurrency ?? 1;
|
||
$('#cfgAutonomy').value = AUTONOMY_LABEL[p.autonomy] ? p.autonomy : 'manual';
|
||
$('#cfgLogo').value = p.logo ?? '';
|
||
$('#cfgVerifyCmd').value = p.verifyCmd ?? '';
|
||
$('#cfgModel').value = p.model ?? '';
|
||
// 分项检查(checks 是 JSON 文本)
|
||
let checks = {};
|
||
try { checks = p.checks ? JSON.parse(p.checks) : {}; } catch { checks = {}; }
|
||
$('#cfgLint').value = checks.lint ?? '';
|
||
$('#cfgTypecheck').value = checks.typecheck ?? '';
|
||
$('#cfgBuild').value = checks.build ?? '';
|
||
// 自动放行
|
||
$('#cfgAutoPlan').checked = !!p.autoApprovePlan;
|
||
$('#cfgAutoExec').checked = !!p.autoApproveExec;
|
||
// agent 规范 + 预算
|
||
$('#cfgAgentRules').value = p.agentRules ?? '';
|
||
$('#cfgBudget').value = p.budgetUsd == null ? '' : p.budgetUsd;
|
||
$('#cfgBudgetPeriod').value = p.budgetPeriod === 'day' ? 'day' : 'month';
|
||
// 角色模型(字符串原样显示;对象形式显示 JSON)
|
||
const m = p.models || {};
|
||
for (const [role, el] of [['executor', '#cfgMdlExecutor'], ['planner', '#cfgMdlPlanner'], ['reviewer', '#cfgMdlReviewer'], ['conflict', '#cfgMdlConflict']]) {
|
||
const v = m[role];
|
||
$(el).value = v == null ? '' : (typeof v === 'string' ? v : JSON.stringify(v));
|
||
}
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
case 'save-config': {
|
||
if (!S.currentProjectId) break;
|
||
const cc = Number($('#cfgConcurrency').value);
|
||
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;
|
||
const model = $('#cfgModel').value.trim() || null;
|
||
// 分项检查 → checks JSON(全空=null)
|
||
const checksObj = {};
|
||
for (const [k, el] of [['lint', '#cfgLint'], ['typecheck', '#cfgTypecheck'], ['build', '#cfgBuild']]) {
|
||
const v = $(el).value.trim(); if (v) checksObj[k] = v;
|
||
}
|
||
const checks = Object.keys(checksObj).length ? JSON.stringify(checksObj) : null;
|
||
// 角色模型 → models(字符串或 {..} JSON;全空=null)
|
||
const modelsObj = {};
|
||
let modelsBad = null;
|
||
for (const [role, el] of [['executor', '#cfgMdlExecutor'], ['planner', '#cfgMdlPlanner'], ['reviewer', '#cfgMdlReviewer'], ['conflict', '#cfgMdlConflict']]) {
|
||
const v = $(el).value.trim(); if (!v) continue;
|
||
if (v[0] === '{') { try { modelsObj[role] = JSON.parse(v); } catch { modelsBad = role; } }
|
||
else modelsObj[role] = v;
|
||
}
|
||
if (modelsBad) { toast(t('cfgModelsBad', { role: modelsBad })); break; }
|
||
const models = Object.keys(modelsObj).length ? modelsObj : null;
|
||
const agentRules = $('#cfgAgentRules').value.trim() || null;
|
||
const budgetRaw = $('#cfgBudget').value.trim();
|
||
const budgetUsd = budgetRaw === '' ? null : Number(budgetRaw);
|
||
if (budgetUsd !== null && (!Number.isFinite(budgetUsd) || budgetUsd < 0)) { toast(t('cfgModelsBad', { role: 'budget' })); break; }
|
||
const budgetPeriod = $('#cfgBudgetPeriod').value === 'day' ? 'day' : 'month';
|
||
const autoApprovePlan = $('#cfgAutoPlan').checked;
|
||
const autoApproveExec = $('#cfgAutoExec').checked;
|
||
act(async () => {
|
||
await api(`/api/projects/${S.currentProjectId}`, {
|
||
method: 'PATCH', body: JSON.stringify({ concurrency: cc, autonomy, logo: logo || null, verifyCmd, model, checks, models, agentRules, budgetUsd, budgetPeriod, autoApprovePlan, autoApproveExec }),
|
||
});
|
||
await loadProjects(); // 刷新当前值显示 + logo
|
||
renderSidebar();
|
||
$('#configPanel').hidden = true; // 保存成功自动收起
|
||
}, t('toastSaved'));
|
||
break;
|
||
}
|
||
|
||
// ── 任务行:取消(带确认) ──
|
||
case 'row-cancel': {
|
||
ev.stopPropagation();
|
||
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 delTask = S.tasks.find((x) => x.id === id);
|
||
const mRowDel = childrenMap(S.tasks);
|
||
// 递归统计子孙数
|
||
let descCount = 0;
|
||
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 ? t('toastDeletedWithSubs', { n: descCount }) : t('toastDeleted'));
|
||
break;
|
||
}
|
||
|
||
// ── 详情:取消 — 显示行内确认 ──
|
||
case 'task-cancel': {
|
||
ev.stopPropagation();
|
||
S.confirmCancel = id;
|
||
S.confirmDelete = null;
|
||
renderTree();
|
||
break;
|
||
}
|
||
case 'task-cancel-abort': {
|
||
ev.stopPropagation();
|
||
S.confirmCancel = null;
|
||
renderTree();
|
||
break;
|
||
}
|
||
case 'task-cancel-confirm': {
|
||
ev.stopPropagation();
|
||
S.confirmCancel = null;
|
||
act(() => api(`/api/tasks/${id}/cancel`, { method: 'POST', body: '{}' }), t('toastCancelled'));
|
||
break;
|
||
}
|
||
|
||
// ── 详情:删除 — 显示行内确认 ──
|
||
case 'task-delete': {
|
||
ev.stopPropagation();
|
||
S.confirmDelete = id;
|
||
S.confirmCancel = null;
|
||
renderTree();
|
||
break;
|
||
}
|
||
case 'task-delete-abort': {
|
||
ev.stopPropagation();
|
||
S.confirmDelete = null;
|
||
renderTree();
|
||
break;
|
||
}
|
||
case 'task-delete-confirm': {
|
||
ev.stopPropagation();
|
||
const delId = id;
|
||
S.confirmDelete = null;
|
||
act(async () => {
|
||
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;
|
||
}
|
||
|
||
}
|
||
});
|
||
|
||
// 阻止详情区域内点击冒泡触发折叠
|
||
document.addEventListener('click', (ev) => {
|
||
if (ev.target.closest('.task-detail') && !ev.target.closest('[data-action]')) {
|
||
ev.stopPropagation();
|
||
}
|
||
}, true);
|
||
|
||
// ── 项目列表拖动排序 ──
|
||
let dragId = null;
|
||
$('#projectList').addEventListener('dragstart', (ev) => {
|
||
const li = ev.target.closest('li[data-id]');
|
||
if (!li) return;
|
||
dragId = li.dataset.id;
|
||
li.classList.add('dragging');
|
||
ev.dataTransfer.effectAllowed = 'move';
|
||
});
|
||
$('#projectList').addEventListener('dragend', (ev) => {
|
||
const li = ev.target.closest('li');
|
||
if (li) li.classList.remove('dragging');
|
||
$('#projectList').querySelectorAll('.drop-over').forEach((x) => x.classList.remove('drop-over'));
|
||
dragId = null;
|
||
});
|
||
$('#projectList').addEventListener('dragover', (ev) => {
|
||
ev.preventDefault();
|
||
const li = ev.target.closest('li[data-id]');
|
||
$('#projectList').querySelectorAll('.drop-over').forEach((x) => x.classList.remove('drop-over'));
|
||
if (li && li.dataset.id !== dragId) li.classList.add('drop-over');
|
||
});
|
||
$('#projectList').addEventListener('drop', (ev) => {
|
||
ev.preventDefault();
|
||
const li = ev.target.closest('li[data-id]');
|
||
if (!li || !dragId || li.dataset.id === dragId) return;
|
||
const ids = S.projects.map((p) => p.id);
|
||
const from = ids.indexOf(dragId);
|
||
const to = ids.indexOf(li.dataset.id);
|
||
if (from < 0 || to < 0) return;
|
||
ids.splice(to, 0, ids.splice(from, 1)[0]); // 插到目标位置
|
||
S.projects.sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id)); // 乐观重排
|
||
renderSidebar();
|
||
act(async () => {
|
||
await api('/api/projects/reorder', { method: 'POST', body: JSON.stringify({ order: ids }) });
|
||
await loadProjects();
|
||
renderSidebar();
|
||
}, t('reorderSaved'));
|
||
});
|
||
|
||
// ── 表单提交 ──
|
||
$('#newProjectForm').addEventListener('submit', (ev) => {
|
||
ev.preventDefault();
|
||
const f = ev.target;
|
||
const name = f.name.value.trim();
|
||
const repoPath = f.repoPath.value.trim();
|
||
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();
|
||
act(async () => {
|
||
const p = await api('/api/projects', { method: 'POST', body: JSON.stringify(body) });
|
||
S.currentProjectId = p.id;
|
||
f.reset();
|
||
$('#modalRoot').hidden = true;
|
||
await loadProjects();
|
||
}, t('toastProjectCreated', { name }));
|
||
});
|
||
|
||
$('#settingsForm').addEventListener('submit', (ev) => {
|
||
ev.preventDefault();
|
||
const n = Number($('#setGlobalConc').value);
|
||
if (!Number.isInteger(n) || n < 0) { toast(t('globalConcInt')); return; }
|
||
act(async () => {
|
||
await api('/api/settings', { method: 'PUT', body: JSON.stringify({ globalConcurrency: n }) });
|
||
$('#settingsRoot').hidden = true;
|
||
}, t('toastSettingsSaved'));
|
||
});
|
||
|
||
$('#newTaskPanel').addEventListener('submit', (ev) => {
|
||
ev.preventDefault();
|
||
const f = ev.target;
|
||
if (!S.currentProjectId) { toast(t('toastTaskNoProject')); return; }
|
||
const title = f.title.value.trim();
|
||
if (!title) { toast(t('toastTitleRequired')); return; }
|
||
const body = {
|
||
title,
|
||
complexity: f.complexity.value,
|
||
priority: Number(f.priority.value ?? 1),
|
||
};
|
||
if (f.parentId.value) body.parentId = f.parentId.value;
|
||
act(async () => {
|
||
await api(`/api/projects/${S.currentProjectId}/tasks`, {
|
||
method: 'POST', body: JSON.stringify(body),
|
||
});
|
||
f.title.value = '';
|
||
}, t('toastCreated'));
|
||
});
|
||
|
||
// 关键字筛选(轻防抖)
|
||
let kwTimer = null;
|
||
$('#filterKw').addEventListener('input', (ev) => {
|
||
S.filter.kw = ev.target.value;
|
||
clearTimeout(kwTimer);
|
||
kwTimer = setTimeout(() => { renderTree(); renderFilterBar(); }, 150);
|
||
});
|
||
|
||
// Esc 关模态
|
||
document.addEventListener('keydown', (ev) => {
|
||
if (ev.key === 'Escape') {
|
||
const am = $('#archiveModalRoot');
|
||
if (!am.hidden) { am.hidden = true; am.innerHTML = ''; return; }
|
||
if (S.previewId) { S.previewId = null; S.previewReject = false; renderPreview(); return; }
|
||
if (!$('#settingsRoot').hidden) { $('#settingsRoot').hidden = true; return; }
|
||
$('#modalRoot').hidden = true;
|
||
}
|
||
});
|
||
|
||
// ── WebSocket 实时刷新(指数退避重连) ──
|
||
let wsAttempt = 0;
|
||
let refreshTimer = null;
|
||
|
||
function setWsState(on, text) {
|
||
$('#wsDot').className = `ws-dot ${on ? 'on' : 'off'}`;
|
||
$('#wsText').textContent = text;
|
||
}
|
||
|
||
function scheduleRefresh() {
|
||
if (refreshTimer) return;
|
||
refreshTimer = setTimeout(() => { refreshTimer = null; refresh(); }, 200);
|
||
}
|
||
|
||
function connectWs() {
|
||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||
const ws = new WebSocket(`${proto}://${location.host}/ws`);
|
||
|
||
ws.onopen = () => {
|
||
wsAttempt = 0;
|
||
setWsState(true, t('liveConn'));
|
||
fullRefresh(); // 重连后补齐错过的状态
|
||
};
|
||
|
||
ws.onmessage = (msg) => {
|
||
let evt;
|
||
try { evt = JSON.parse(msg.data); } catch { return; }
|
||
if (evt.type === 'project.synced') {
|
||
// 本端刚发起的 sync 由 POST 响应负责 toast,避免重复
|
||
if (Date.now() - S.syncReqAt > 3000) {
|
||
const p = evt.payload || {};
|
||
toast(t('toastWsSynced', { created: p.created ?? 0, done: p.doneAdvanced ?? 0, skipped: p.skipped ?? 0 }), 'ok');
|
||
}
|
||
fullRefresh();
|
||
return;
|
||
}
|
||
if (evt.type === 'run.started' || evt.type === 'run.finished') {
|
||
loadAgents().then(renderAgents).catch(() => {});
|
||
}
|
||
if (evt.projectId === S.currentProjectId) {
|
||
scheduleRefresh();
|
||
} else if (evt.type === 'task.created' && evt.payload && evt.payload.kind === 'project') {
|
||
loadProjects().then(renderSidebar).catch(() => {});
|
||
}
|
||
};
|
||
|
||
const retry = () => {
|
||
wsAttempt += 1;
|
||
const delay = Math.min(30000, 1000 * Math.pow(2, wsAttempt - 1));
|
||
setWsState(false, t('disconnected', { n: Math.round(delay / 1000) }));
|
||
setTimeout(connectWs, delay);
|
||
};
|
||
ws.onclose = retry;
|
||
ws.onerror = () => { try { ws.close(); } catch { /* noop */ } };
|
||
}
|
||
|
||
// 相对时间(agent 开始时间 / 上次同步)定期重绘
|
||
setInterval(() => { renderAgents(); renderTopbar(); }, 30000);
|
||
|
||
// ── 启动 ──
|
||
// 初始化 i18n(从 localStorage 恢复语言,更新静态元素)
|
||
applyStaticI18n();
|
||
// 更新连接状态文字(此时还未连接)
|
||
$('#wsText').textContent = t('connecting');
|
||
fullRefresh().then(connectWs);
|