Compare commits
34 Commits
1c62059424
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f014ba246 | |||
| d0aa53c79d | |||
| ea3ad710ff | |||
| 02371b3f89 | |||
| 42f9788b0a | |||
| 0c1f630dca | |||
| e115e065e6 | |||
| 5443ffa21c | |||
| 382afe3322 | |||
| a59588a0ce | |||
| ee9232e575 | |||
| 8d3192e7cb | |||
| 80f257171d | |||
| 33bef7f5fc | |||
| db4e6e2fb4 | |||
| eac3575851 | |||
| 64b8c912ef | |||
| f1ee537ab7 | |||
| d9d3de26b8 | |||
| f443b16649 | |||
| 00e6bc72e7 | |||
| b51163ceed | |||
| 65cd5add5c | |||
| bf2f7d8155 | |||
| 2500e3e779 | |||
| 3ffdf411fc | |||
| b48d4d8135 | |||
| 5c68bb9324 | |||
| af690701bf | |||
| 5a609a8675 | |||
| 8b494e92aa | |||
| aca8f1b18b | |||
| 0569423b7d | |||
| 7884182d2b |
@@ -53,7 +53,11 @@ Communication between daemon and worker is **file + signal only** (`job.json`, `
|
||||
| `src/api/server.ts` | Fastify REST + WebSocket server |
|
||||
| `src/mcp/` | MCP server tools exposed to Claude Code sessions |
|
||||
| `src/sync/` | One-way sync from legacy `todo.json` files |
|
||||
| `web/` | Dashboard SPA (multi-project, real-time WS, inline approve/reject) |
|
||||
| `app/` | **Active console (the dashboard users actually use)** — React SPA on `:4519` (vite dev; proxies `/api`+`/ws` to `:4517`). Data & wiring live here: `src/api.js`, `src/app.jsx`, `src/adapt.js`. |
|
||||
| `design/ui_kits/console/` | **Active console UI** — React UI components, the single source of truth for the console; `app/` loads them via vite. **Frontend UI changes go here; data/actions go in `app/src/`.** Strings go through `i18n.js` (5 langs). |
|
||||
| `web/` | **⚠️ Legacy / deprecated** — old vanilla-JS dashboard served directly by the daemon. Superseded by the `app/` + `design/ui_kits/console/` React console. **Do NOT add new features, config entries, or UI here** — build them in `app/` + `design/ui_kits/console/`. |
|
||||
|
||||
> **Frontend rule of thumb:** the live console is **`design/ui_kits/console/` (UI) + `app/src/` (data/wiring)** on `:4519`. `web/` is legacy — never target it for new work.
|
||||
|
||||
### Task Lifecycle
|
||||
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>MAESTRO · 任务调度台</title>
|
||||
<!-- Markdown 渲染(方案/复审/任务树 SPEC 用,window 全局,先于 module 脚本加载)-->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@highlightjs/cdn-assets@11.9.0/styles/github-dark.min.css" integrity="sha384-wH75j6z1lH97ZOpMOInqhgKzFkAInZPPSPlZpYKYTOqsaizPvhQZmAtLcPKXpLyH" crossorigin="anonymous" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked@12.0.2/marked.min.js" integrity="sha384-/TQbtLCAerC3jgaim+N78RZSDYV7ryeoBCVqTuzRrFec2akfBkHS7ACQ3PQhvMVi" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.1.6/dist/purify.min.js" integrity="sha384-+VfUPEb0PdtChMwmBcBmykRMDd+v6D/oFmB3rZM/puCMDYcIvF968OimRh4KQY9a" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@highlightjs/cdn-assets@11.9.0/highlight.min.js" integrity="sha384-F/bZzf7p3Joyp5psL90p/p89AZJsndkSoGwRpXcZhleCWhd8SnRuoYo4d0yirjJp" crossorigin="anonymous"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+15
-3
@@ -25,7 +25,10 @@ export function adaptProject(p) {
|
||||
return {
|
||||
id: p.id, name: p.name, path: shortPath(p.repoPath), branch: p.defaultBranch,
|
||||
autonomy: p.autonomy, concurrency: p.concurrency, hue: hueFor(p.name),
|
||||
state, pending: s.attention || 0, agents: s.executing || 0,
|
||||
// agents 徽标 = 实际在途 agent 数(runs.status='started',含 planner/spec/executor 各阶段),
|
||||
// 与「运行中」青点同源(s.running);不可用 s.executing——那只统计任务恰处 executing 状态,
|
||||
// 会漏掉 analyzing(planner)/speccing(spec) 阶段正在跑的 agent,导致徽标缺失。
|
||||
state, pending: s.attention || 0, agents: s.running || 0, blocked: s.blocked || 0,
|
||||
// 透传给 ConfigPanel 用的原始字段
|
||||
model: p.model, verifyCmd: p.verifyCmd, maxRetries: p.maxRetries, repoPath: p.repoPath,
|
||||
logo: p.logo, budgetUsd: p.budgetUsd ?? null, budgetPeriod: p.budgetPeriod ?? 'month',
|
||||
@@ -54,6 +57,13 @@ export function buildTaskTree(flat) {
|
||||
if (pid && byId.has(pid)) byId.get(pid).children.push(node);
|
||||
else roots.push(node);
|
||||
});
|
||||
// 冻结标记:父任务尚未批准拆解(plan_review/analyzing)时,子任务虽落在 speccing/ready/blocked,
|
||||
// 实际被编排器闸冻结、不会领取执行。前端据此改显「待拆解确认」,避免误读成「写方案中/可执行」。
|
||||
byId.forEach((node) => {
|
||||
const pid = node._raw.parentId;
|
||||
const ps = pid && byId.has(pid) ? byId.get(pid)._raw.status : null;
|
||||
node.frozen = ps === 'plan_review' || ps === 'analyzing';
|
||||
});
|
||||
// 子节点按优先级(P0 在前)再按创建时间排序
|
||||
const sortKids = (n) => {
|
||||
n.children.sort((a, b) => a.prio.localeCompare(b.prio) || a._raw.createdAt.localeCompare(b._raw.createdAt));
|
||||
@@ -76,13 +86,15 @@ export function buildTaskTree(flat) {
|
||||
return { active, archived };
|
||||
}
|
||||
|
||||
// ── 审批闸卡片 ─────────────────────────────────────────
|
||||
// ── 「需你处理」卡片:审批闸(plan/spec/exec) + 需人工(attention) ────────────
|
||||
export function adaptApproval(tk, projName) {
|
||||
const gate = tk.status.replace('_review', ''); // plan | spec | exec
|
||||
// needs_attention 不是审批闸,是卡住态——并入同区但操作不同(requeue/接管/取消,非 accept/reject)
|
||||
const gate = tk.status === 'needs_attention' ? 'attention' : tk.status.replace('_review', ''); // plan | spec | exec | attention
|
||||
const r = tk.result || {};
|
||||
let doc;
|
||||
if (gate === 'plan') doc = tk.plan;
|
||||
else if (gate === 'spec') doc = tk.spec;
|
||||
else if (gate === 'attention') doc = tk.lastRunError || tk.spec || tk.plan || '(任务卡住,无错误详情——可能为执行前重评估 / 合并冲突)';
|
||||
else doc = r.summary || r.diffSummary || tk.operations; // exec:优先 code review 报告(做了什么/怎么做/测试/逐条 review)
|
||||
const out = {
|
||||
id: tk.id, gate, taskId: tk.id, title: tk.title,
|
||||
|
||||
@@ -52,6 +52,13 @@ export const api = {
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
// 附件预览/下载 URL:默认 inline(图片可 <img src> 预览),download=true 触发下载。
|
||||
// name = basename(att.path)(磁盘安全名,非展示名)。纯字符串构造,GET 走 <img>/<a> 由浏览器处理。
|
||||
attachmentUrl: (taskId, name, opts = {}) =>
|
||||
`/api/tasks/${taskId}/attachments/${encodeURIComponent(name)}${opts.download ? '?download=1' : ''}`,
|
||||
// 删除单个附件(按磁盘文件名)。返回 { attachments } 最新列表。
|
||||
deleteAttachment: (taskId, name) =>
|
||||
http('DELETE', `/api/tasks/${taskId}/attachments/${encodeURIComponent(name)}`),
|
||||
};
|
||||
|
||||
// WS 单向订阅:连上后服务端推送 events 表记录。断线自动重连(指数退避,封顶 10s)。
|
||||
|
||||
+19
-6
@@ -300,19 +300,22 @@ export function App() {
|
||||
const project = projects.find((p) => p.id === currentId) || projects[0];
|
||||
const projNameOf = (pid) => (projectsRaw.find((p) => p.id === pid) || {}).name;
|
||||
const { active: tasks, archived } = buildTaskTree(tasksRaw);
|
||||
// 「需你处理」= 三个审批闸 + needs_attention(卡住待人工)。后者并入同区但操作不同。
|
||||
const REVIEW = new Set(['plan_review', 'spec_review', 'exec_review']);
|
||||
const gates = approvalsRaw
|
||||
.filter((a) => a.projectId === currentId && REVIEW.has(a.status))
|
||||
.filter((a) => a.projectId === currentId && (REVIEW.has(a.status) || a.status === 'needs_attention'))
|
||||
.map((a) => {
|
||||
const g = adaptApproval(a, projNameOf(a.projectId));
|
||||
// 拆解评审:挂上实际拆出的子任务(标题/复杂度/优先级/依赖/状态),供评审弹层结构化展示
|
||||
if (g.gate === 'plan') {
|
||||
g.subtasks = tasksRaw
|
||||
.filter((tk) => tk.parentId === a.id)
|
||||
.map((tk) => ({ id: tk.id, title: tk.title, complexity: tk.complexity, priority: tk.priority, deps: tk.deps || [], status: tk.status }));
|
||||
.map((tk) => ({ id: tk.id, title: tk.title, complexity: tk.complexity, priority: tk.priority, deps: tk.deps || [], status: tk.status, scopeFiles: tk.scopeFiles || [] }));
|
||||
}
|
||||
return g;
|
||||
});
|
||||
})
|
||||
// 审批闸在前、需人工在后
|
||||
.sort((x, y) => (x.gate === 'attention' ? 1 : 0) - (y.gate === 'attention' ? 1 : 0));
|
||||
const agents = adaptActiveAgents(agentsResp, currentId);
|
||||
const events = eventsRaw.map(adaptEvent);
|
||||
const quota = adaptQuota(usage);
|
||||
@@ -347,8 +350,13 @@ export function App() {
|
||||
try {
|
||||
const task = await api.createTask(currentId, body);
|
||||
if (files && files.length) {
|
||||
try { await api.uploadAttachments(task.id, files); toast('ok', `附件已上传 · ${files.length} 个`); }
|
||||
catch (e) { toast('warn', e.message); }
|
||||
try {
|
||||
// 消费后端去重后的权威列表:新建任务原本无附件,stored 即本批去重后入库条数
|
||||
const { attachments } = await api.uploadAttachments(task.id, files);
|
||||
const stored = attachments.length;
|
||||
const deduped = files.length - stored; // >0 表示有同内容文件被去重
|
||||
toast('ok', t.attUploaded.replace('{n}', stored) + (deduped > 0 ? t.attDeduped.replace('{n}', deduped) : ''));
|
||||
} catch (e) { toast('warn', e.message); }
|
||||
}
|
||||
toast('ok', t.toastCreated);
|
||||
loadProject(currentId); loadGlobal();
|
||||
@@ -384,6 +392,11 @@ export function App() {
|
||||
try { await api.deleteTask(taskId); toast('warn', '已删除'); loadProject(currentId); loadGlobal(); }
|
||||
catch (e) { toast('warn', '删除失败:' + e.message); }
|
||||
},
|
||||
// 删除已建任务的单个附件(name = 磁盘文件名 basename(att.path)),成功后局部刷新
|
||||
delAttachment: async (taskId, name) => {
|
||||
try { await api.deleteAttachment(taskId, name); toast('ok', t.attDeleted); loadProject(currentId); }
|
||||
catch (e) { toast('warn', t.attDeleteFailed + e.message); }
|
||||
},
|
||||
};
|
||||
const [showNewProject, setShowNewProject] = React.useState(false);
|
||||
const createProject = async (body) => {
|
||||
@@ -423,7 +436,7 @@ export function App() {
|
||||
onSync={onSync}
|
||||
onClose={() => setShowConfig(false)} /> : null}
|
||||
<window.MaestroKitAgentSection agents={agents} quota={quota} t={t} onOpenStream={setStreamAgent} />
|
||||
<window.MaestroKitGateSection approvals={gates} onDecide={decide} t={t} />
|
||||
<window.MaestroKitGateSection approvals={gates} onDecide={decide} taskOps={taskOps} onTakeover={takeover} t={t} />
|
||||
{tasks.length ? (
|
||||
<window.MaestroKitTaskSection tasks={tasks} onToast={toast} onCreate={createTask} onTakeover={takeover} taskOps={taskOps} t={t} />
|
||||
) : (
|
||||
|
||||
@@ -7,6 +7,11 @@ import '@ds/_ds_bundle.js';
|
||||
// 文案 i18n(真实数据由 src/api.js + src/adapt.js 提供,不再用 data.js mock)
|
||||
import '@ds/ui_kits/console/i18n.js';
|
||||
|
||||
// 共享 Markdown 渲染组件(window.MaestroRichDoc)——须在引用它的 GateSection/TaskTree 之前
|
||||
import '@ds/ui_kits/console/RichDoc.jsx';
|
||||
// 共享 transcript 回放组件(window.MaestroTranscript)——拆解评审「拆解轨迹」等用
|
||||
import '@ds/ui_kits/console/Transcript.jsx';
|
||||
|
||||
// 六个整屏 surface(保持 design/ 源文件不动,作为单一真相源)
|
||||
import '@ds/ui_kits/console/Sidebar.jsx';
|
||||
import '@ds/ui_kits/console/EventPanel.jsx';
|
||||
|
||||
@@ -11,9 +11,10 @@ function ArchiveRow({ item, t, onOpen }) {
|
||||
borderBottom: '1px dashed var(--line-soft)',
|
||||
background: hover ? 'var(--panel)' : 'transparent',
|
||||
}}>
|
||||
<span style={{ color: 'var(--muted)', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{item.title}<span style={{ color: 'var(--faint)', fontSize: 10.5, marginLeft: 6 }}>{item.id}</span>
|
||||
<span style={{ flex: '0 1 auto', minWidth: 0, color: 'var(--muted)', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{item.title}
|
||||
</span>
|
||||
<span style={{ flex: 'none', color: 'var(--faint)', fontSize: 10.5 }}>{item.id}</span>
|
||||
{item.subs ? <span style={{ flex: 'none', fontSize: 10.5, color: 'var(--faint)' }}>{t.archiveSubs.replace('{n}', item.subs)}</span> : null}
|
||||
<span style={{ flex: 1, minWidth: 8 }}></span>
|
||||
<ComplexityBadge complexity={item.cplx} />
|
||||
@@ -126,7 +127,7 @@ function ArchiveModal({ item, t, onClose }) {
|
||||
const d = item.detail || {};
|
||||
const label = (text) => <div style={{ fontSize: 10.5, color: 'var(--muted)', letterSpacing: '.18em', margin: '14px 0 4px' }}>{text}</div>;
|
||||
const doc = (text) => (
|
||||
<pre style={{ background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderLeft: '2px solid var(--green-dim)', borderRadius: 4, padding: '10px 12px', fontFamily: 'var(--mono)', fontSize: 12.5, lineHeight: 1.7, whiteSpace: 'pre-wrap', wordBreak: 'break-word', margin: 0, color: 'var(--ink)' }}>{text}</pre>
|
||||
<div style={{ background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderLeft: '2px solid var(--green-dim)', borderRadius: 4, padding: '10px 12px' }}><window.MaestroRichDoc md={text} /></div>
|
||||
);
|
||||
const kvGrid = { fontSize: 12, display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '3px 12px', margin: 0 };
|
||||
return (
|
||||
|
||||
@@ -1,32 +1,4 @@
|
||||
// ── 报告渲染:把 markdown(## 章节 + - 列表 + **粗体** + `代码`)渲染成可折叠卡片 ──
|
||||
function mdInline(text) {
|
||||
const nodes = []; const re = /(\*\*[^*]+\*\*|`[^`]+`)/g;
|
||||
let last = 0; let m; let key = 0;
|
||||
while ((m = re.exec(text))) {
|
||||
if (m.index > last) nodes.push(text.slice(last, m.index));
|
||||
const tok = m[0];
|
||||
if (tok.startsWith('**')) nodes.push(<b key={key++} style={{ color: 'var(--ink)' }}>{tok.slice(2, -2)}</b>);
|
||||
else nodes.push(<code key={key++} style={{ fontFamily: 'var(--mono)', fontSize: '.92em', color: 'var(--cyan)', background: 'var(--panel-2)', borderRadius: 3, padding: '1px 5px' }}>{tok.slice(1, -1)}</code>);
|
||||
last = m.index + tok.length;
|
||||
}
|
||||
if (last < text.length) nodes.push(text.slice(last));
|
||||
return nodes;
|
||||
}
|
||||
function mdBody(body) {
|
||||
const out = []; let key = 0;
|
||||
for (const raw of body.split('\n')) {
|
||||
const t = raw.trim();
|
||||
if (!t) { out.push(<div key={key++} style={{ height: 6 }} />); continue; }
|
||||
if (/^[-*]\s/.test(t)) {
|
||||
out.push(<div key={key++} style={{ display: 'flex', gap: 8, padding: '2px 0 2px 2px' }}><span style={{ flex: 'none', color: 'var(--green)' }}>·</span><span>{mdInline(t.replace(/^[-*]\s/, ''))}</span></div>);
|
||||
} else if (/^#{1,4}\s/.test(t)) {
|
||||
out.push(<div key={key++} style={{ fontWeight: 700, color: 'var(--muted)', margin: '6px 0 2px' }}>{mdInline(t.replace(/^#{1,4}\s/, ''))}</div>);
|
||||
} else {
|
||||
out.push(<div key={key++} style={{ padding: '1px 0' }}>{mdInline(t)}</div>);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// ── 报告渲染:把 markdown 按「## 章节」切成可折叠卡片,章节内容交 window.MaestroRichDoc 渲染 ──
|
||||
function mdSections(md) {
|
||||
if (!md) return [];
|
||||
const out = []; const lines = md.split('\n');
|
||||
@@ -48,7 +20,7 @@ function ReportSectionCard({ title, body, t, defaultOpen }) {
|
||||
<span style={{ flex: 'none', color: 'var(--green)', fontSize: 9, transition: 'transform .12s', transform: open ? 'none' : 'rotate(-90deg)' }}>▼</span>
|
||||
<span style={{ flex: 1, fontSize: 12.5, fontWeight: 700, color: 'var(--ink)', letterSpacing: '.02em' }}>{title || '说明'}</span>
|
||||
</div>
|
||||
{open ? <div style={{ padding: '11px 14px', fontSize: 12.5, lineHeight: 1.7, color: 'var(--ink)', wordBreak: 'break-word' }}>{mdBody(body)}</div> : null}
|
||||
{open ? <div style={{ padding: '11px 14px' }}><window.MaestroRichDoc md={body} /></div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -100,6 +72,85 @@ function FileDiffs({ taskId, t }) {
|
||||
);
|
||||
}
|
||||
|
||||
// 单段复审报告卡片:默认折叠,标题显示复审类型 + 裁决徽章,点击展开看完整报告(markdown)。
|
||||
function ReviewReportCard({ label, verdict, report, t, first }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const vb = verdict === 'reject' ? { c: 'var(--red)', bd: 'var(--red-dim)', txt: '✗ ' + t.vReject }
|
||||
: verdict === 'approve' ? { c: 'var(--green)', bd: 'var(--green-dim)', txt: '✓ ' + t.vApprove } : null;
|
||||
return (
|
||||
<div style={{ marginTop: first ? 0 : 8, background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderRadius: 6, overflow: 'hidden' }}>
|
||||
<div onClick={() => setOpen((o) => !o)} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', cursor: 'pointer', userSelect: 'none', background: 'var(--panel-2)' }}>
|
||||
<span style={{ color: 'var(--green)', fontSize: 9, transition: 'transform .12s', transform: open ? 'none' : 'rotate(-90deg)' }}>▼</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, letterSpacing: '.14em', color: 'var(--muted)' }}>{label}</span>
|
||||
{vb ? <span style={{ fontSize: 10, fontWeight: 700, color: vb.c, border: '1px solid ' + vb.bd, borderRadius: 3, padding: '0 6px' }}>{vb.txt}</span> : null}
|
||||
<span style={{ marginLeft: 'auto', fontSize: 10, color: 'var(--faint)' }}>{open ? t.gateCollapse : t.gateExpand}</span>
|
||||
</div>
|
||||
{open ? <div style={{ padding: '11px 13px' }}><window.MaestroRichDoc md={report} /></div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 需人工卡片:拉最近的 reviewer / security run 的「完整复审报告」(脱敏 transcript 末块=最终报告),markdown 渲染——
|
||||
// 替代被截断到 200 字的 lastRunError,把驳回原因罗列清楚。无复审报告(执行崩溃/冲突/重评估等)则回退显示 lastRunError。
|
||||
function ReviewReports({ taskId, fallback, t }) {
|
||||
const [st, setSt] = React.useState({ loading: true, reports: null });
|
||||
React.useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
const runs = await fetch('/api/tasks/' + taskId + '/runs').then((r) => r.ok ? r.json() : []);
|
||||
const list = Array.isArray(runs) ? runs : [];
|
||||
const latest = (kind) => list.filter((r) => r.kind === kind)
|
||||
.slice().sort((a, b) => String(b.startedAt || '').localeCompare(String(a.startedAt || '')))[0];
|
||||
const out = [];
|
||||
for (const [kind, label] of [['reviewer', t.lblCodeReview], ['security', t.lblSecurity]]) {
|
||||
const run = latest(kind);
|
||||
if (!run) continue;
|
||||
const d = await fetch('/api/runs/' + run.id + '/transcript').then((r) => r.ok ? r.json() : null);
|
||||
const texts = ((d && d.messages) || []).filter((m) => m.type === 'text' && m.text && m.text.trim());
|
||||
if (texts.length) {
|
||||
const report = texts[texts.length - 1].text;
|
||||
const vm = /VERDICT:\s*(approve|reject)/i.exec(report);
|
||||
out.push({ label, report, verdict: vm ? vm[1].toLowerCase() : null });
|
||||
}
|
||||
}
|
||||
if (alive) setSt({ loading: false, reports: out });
|
||||
} catch (e) { if (alive) setSt({ loading: false, reports: [] }); }
|
||||
})();
|
||||
return () => { alive = false; };
|
||||
}, [taskId]);
|
||||
|
||||
if (st.loading) return <div style={{ fontSize: 11.5, color: 'var(--faint)', padding: '4px 2px' }}>…</div>;
|
||||
if (st.reports && st.reports.length) {
|
||||
return (
|
||||
<div>
|
||||
{st.reports.map((r, i) => (
|
||||
<ReviewReportCard key={i} label={r.label} verdict={r.verdict} report={r.report} t={t} first={i === 0} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return fallback ? <window.MaestroRichDoc md={fallback} /> : null;
|
||||
}
|
||||
|
||||
// 拆解评审「拆解轨迹」:默认折叠,点击展开后懒加载该任务 planner run 的完整 transcript(表格/分析+工具调用)。
|
||||
function GateTranscript({ taskId, t }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
return (
|
||||
<div style={{ marginTop: 18 }}>
|
||||
<div onClick={() => setOpen((o) => !o)} style={{ display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer', userSelect: 'none', padding: '4px 0' }}>
|
||||
<span style={{ color: 'var(--green)', fontSize: 9, transition: 'transform .12s', transform: open ? 'none' : 'rotate(-90deg)' }}>▼</span>
|
||||
<span style={{ fontSize: 10.5, fontWeight: 700, color: 'var(--muted)', letterSpacing: '.18em' }}>{t.planTranscript}</span>
|
||||
</div>
|
||||
{open ? (
|
||||
<div style={{ marginTop: 8, background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderRadius: 6, padding: '8px 12px' }}>
|
||||
<window.MaestroTranscript taskId={taskId} kind="planner" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 全屏预览:读完整方案 + 就地裁决
|
||||
function GatePreview({ item, t, onDecide, onClose }) {
|
||||
const { ComplexityBadge, StatusChip, Button, Textarea } = window.MaestroDesignSystem_a6a290;
|
||||
@@ -130,7 +181,6 @@ function GatePreview({ item, t, onDecide, onClose }) {
|
||||
);
|
||||
};
|
||||
const secLabel = (txt) => <div style={{ fontSize: 10.5, fontWeight: 700, color: 'var(--muted)', letterSpacing: '.18em', margin: '18px 0 8px' }}>{txt}</div>;
|
||||
const reportBox = (txt) => <div style={{ background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderRadius: 4, padding: '12px 16px', fontSize: 12.5, lineHeight: 1.7, whiteSpace: 'pre-wrap', wordBreak: 'break-word', color: 'var(--ink)' }}>{txt}</div>;
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 1100, display: 'grid', placeItems: 'center' }}>
|
||||
<div onClick={onClose} style={{ position: 'absolute', inset: 0, background: 'rgba(4,6,5,.86)', backdropFilter: 'blur(3px)' }}></div>
|
||||
@@ -150,9 +200,7 @@ function GatePreview({ item, t, onDecide, onClose }) {
|
||||
</div>
|
||||
) : null}
|
||||
{item.docLabel ? <div style={{ fontSize: 10.5, color: 'var(--muted)', letterSpacing: '.18em', marginBottom: 6 }}>{item.docLabel}</div> : null}
|
||||
{/^##\s/m.test(item.doc || '')
|
||||
? <ReportCards md={item.doc} t={t} />
|
||||
: <div style={{ background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderLeft: '2px solid var(--green-dim)', borderRadius: 4, padding: '12px 16px', fontSize: 13.5, lineHeight: 1.7, whiteSpace: 'pre-wrap', wordBreak: 'break-word', color: 'var(--ink)' }}>{item.doc}</div>}
|
||||
{item.doc ? <ReportCards md={item.doc} t={t} /> : null}
|
||||
{item.gate === 'exec' && rv && rv.securitySummary ? (
|
||||
<React.Fragment>{secLabel(t.lblSecurity)}<ReportCards md={rv.securitySummary} t={t} /></React.Fragment>
|
||||
) : null}
|
||||
@@ -179,17 +227,26 @@ function GatePreview({ item, t, onDecide, onClose }) {
|
||||
<ComplexityBadge complexity={s.complexity} />
|
||||
<span style={{ flex: 'none', fontSize: 10, fontWeight: 700, color: 'var(--muted)', border: '1px solid var(--line)', borderRadius: 3, padding: '1px 6px' }}>P{s.priority}</span>
|
||||
<span style={{ flex: 1, fontSize: 13.5, fontWeight: 600, color: 'var(--ink)', minWidth: 120 }}>{s.title}</span>
|
||||
{StatusChip ? <StatusChip status={s.status} label={t.status[s.status]} /> : null}
|
||||
{StatusChip ? <StatusChip status="paused" label={t.frozenPending} /> : null}
|
||||
</div>
|
||||
<div style={{ marginTop: 7, paddingLeft: 31, fontSize: 11.5, color: 'var(--faint)' }}>
|
||||
{t.dependsOn}: {deps.length ? <span style={{ color: 'var(--amber)', fontWeight: 600 }}>{deps.join('、')}</span> : t.noDeps}
|
||||
</div>
|
||||
{s.scopeFiles && s.scopeFiles.length ? (
|
||||
<div style={{ marginTop: 5, paddingLeft: 31, display: 'flex', flexWrap: 'wrap', gap: 5, alignItems: 'center' }}>
|
||||
<span style={{ flex: 'none', fontSize: 11.5, color: 'var(--faint)' }}>{t.scopeFiles}:</span>
|
||||
{s.scopeFiles.map((f) => (
|
||||
<span key={f} style={{ fontSize: 10.5, fontFamily: 'var(--mono)', color: 'var(--cyan)', background: 'var(--panel-2)', border: '1px solid var(--line-soft)', borderRadius: 3, padding: '1px 6px' }}>{f}</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{item.gate === 'plan' ? <GateTranscript taskId={item.taskId} t={t} /> : null}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', padding: '12px 16px', borderTop: '1px solid var(--line)', background: 'var(--panel-2)' }}>
|
||||
{rejecting ? (
|
||||
@@ -212,8 +269,8 @@ function GatePreview({ item, t, onDecide, onClose }) {
|
||||
);
|
||||
}
|
||||
|
||||
// 审批闸区:斜纹条 + GateCard 列表(accept/reject + 驳回必填理由)
|
||||
function GateSection({ approvals, onDecide, t }) {
|
||||
// 「需你处理」区:审批闸(accept/reject + 驳回必填理由)+ 需人工(requeue/接管/取消)
|
||||
function GateSection({ approvals, onDecide, taskOps, onTakeover, t }) {
|
||||
const { GateCard, GateStripe, Button, Textarea } = window.MaestroDesignSystem_a6a290;
|
||||
const [rejecting, setRejecting] = React.useState(null);
|
||||
const [reason, setReason] = React.useState('');
|
||||
@@ -233,32 +290,53 @@ function GateSection({ approvals, onDecide, t }) {
|
||||
const isFolded = folded.has(a.id);
|
||||
return (
|
||||
<div key={a.id} style={{ marginBottom: 12 }}>
|
||||
<GateCard gate={a.gate} kindLabel={t.gates[a.gate]} title={a.title} meta={a.meta} docLabel={a.docLabel} doc={a.doc}
|
||||
<GateCard gate={a.gate} kindLabel={a.gate === 'attention' ? t.gateNeedsHuman : t.gates[a.gate]} title={a.title} meta={a.meta} docLabel={a.docLabel} doc={undefined}
|
||||
collapsed={isFolded} foldable onToggleFold={() => toggleFold(a.id)}
|
||||
foldTitle={isFolded ? t.gateExpand : t.gateCollapse}
|
||||
onHeaderDoubleClick={() => setPreview(a)} headerTitle={t.gateDblTip}
|
||||
headerExtra={<button type="button" title={t.fullRead} onClick={() => setPreview(a)}
|
||||
onHeaderDoubleClick={a.gate === 'attention' ? undefined : () => setPreview(a)} headerTitle={a.gate === 'attention' ? null : t.gateDblTip}
|
||||
headerExtra={a.gate === 'attention' ? null : (<button type="button" title={t.fullRead} onClick={() => setPreview(a)}
|
||||
style={{ fontFamily: 'var(--mono)', fontSize: 12, lineHeight: 1, cursor: 'pointer', background: 'transparent', color: 'var(--muted)', border: '1px solid var(--line)', borderRadius: 'var(--radius-sm,4px)', padding: '3px 7px' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--violet)'; e.currentTarget.style.borderColor = 'var(--violet-dim)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--muted)'; e.currentTarget.style.borderColor = 'var(--line)'; }}>⛶</button>}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--muted)'; e.currentTarget.style.borderColor = 'var(--line)'; }}>⛶</button>)}
|
||||
actions={
|
||||
<React.Fragment>
|
||||
<Button variant="accept" onClick={() => onDecide(a, 'accept')}>{t.accept}</Button>
|
||||
<Button variant="reject" onClick={() => setRejecting(rejecting === a.id ? null : a.id)}>{t.reject}</Button>
|
||||
<span style={{ marginLeft: 'auto' }}>
|
||||
<Button variant="ghost" size="xs" title={t.fullRead} onClick={() => setPreview(a)}>⛶ {t.fullRead}</Button>
|
||||
</span>
|
||||
</React.Fragment>
|
||||
a.gate === 'attention' ? (
|
||||
<React.Fragment>
|
||||
<Button variant="accept" onClick={() => taskOps && taskOps.requeue(a.taskId)}>{t.gRequeue}</Button>
|
||||
{onTakeover ? <Button variant="ghost" onClick={() => onTakeover({ id: a.taskId, title: a.title })}>{t.gTakeover}</Button> : null}
|
||||
<span style={{ marginLeft: 'auto' }}>
|
||||
<Button variant="reject" size="xs" onClick={() => taskOps && taskOps.cancel(a.taskId)}>{t.gCancelTask}</Button>
|
||||
</span>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<React.Fragment>
|
||||
<Button variant="accept" onClick={() => onDecide(a, 'accept')}>{t.accept}</Button>
|
||||
<Button variant="reject" onClick={() => setRejecting(rejecting === a.id ? null : a.id)}>{t.reject}</Button>
|
||||
<span style={{ marginLeft: 'auto' }}>
|
||||
<Button variant="ghost" size="xs" title={t.fullRead} onClick={() => setPreview(a)}>⛶ {t.fullRead}</Button>
|
||||
</span>
|
||||
</React.Fragment>
|
||||
)
|
||||
}>
|
||||
{rejecting === a.id ? (
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-start', marginTop: 10 }}>
|
||||
<Textarea danger placeholder={t.rejectPlaceholder} minHeight={56} value={reason} onChange={setReason} />
|
||||
<Button variant="reject" disabled={!reason.trim()}
|
||||
onClick={() => { onDecide(a, 'reject', reason); setRejecting(null); setReason(''); }}>
|
||||
{t.confirmReject}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{a.gate === 'attention' ? (
|
||||
<ReviewReports taskId={a.taskId} fallback={a.doc} t={t} />
|
||||
) : (
|
||||
<React.Fragment>
|
||||
{a.doc ? (
|
||||
<div style={{ background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderLeft: '2px solid var(--green-dim)', borderRadius: 'var(--radius-sm, 4px)', padding: '10px 12px', maxHeight: 280, overflow: 'auto' }}>
|
||||
<window.MaestroRichDoc md={a.doc} />
|
||||
</div>
|
||||
) : null}
|
||||
{rejecting === a.id ? (
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-start', marginTop: 10 }}>
|
||||
<Textarea danger placeholder={t.rejectPlaceholder} minHeight={56} value={reason} onChange={setReason} />
|
||||
<Button variant="reject" disabled={!reason.trim()}
|
||||
onClick={() => { onDecide(a, 'reject', reason); setRejecting(null); setReason(''); }}>
|
||||
{t.confirmReject}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
)}
|
||||
</GateCard>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// 共享 Markdown 渲染:marked 解析 + DOMPurify 净化 + highlight.js 代码高亮。
|
||||
// 挂 window.MaestroRichDoc,供 GateSection / TaskTree 等在 vite app 与 standalone demo 两环境共用。
|
||||
// 依赖 window.marked / window.DOMPurify(必需,CDN 全局)+ window.hljs(可选,缺则代码块不着色)。
|
||||
function ensureRichDocCss() {
|
||||
if (document.getElementById('maestro-richdoc-css')) return;
|
||||
const s = document.createElement('style');
|
||||
s.id = 'maestro-richdoc-css';
|
||||
s.textContent = `
|
||||
.m-richdoc { font-size: 12.8px; line-height: 1.7; color: var(--ink); word-break: break-word; }
|
||||
.m-richdoc > :first-child { margin-top: 0; }
|
||||
.m-richdoc > :last-child { margin-bottom: 0; }
|
||||
.m-richdoc h1, .m-richdoc h2, .m-richdoc h3, .m-richdoc h4 { font-weight: 700; line-height: 1.3; margin: 16px 0 8px; letter-spacing: .01em; }
|
||||
.m-richdoc h1 { font-size: 1.32em; color: var(--ink); border-bottom: 1px solid var(--line-soft); padding-bottom: 5px; }
|
||||
.m-richdoc h2 { font-size: 1.16em; color: var(--green); }
|
||||
.m-richdoc h3 { font-size: 1.05em; color: var(--cyan); }
|
||||
.m-richdoc h4 { font-size: 1em; color: var(--muted); }
|
||||
.m-richdoc p { margin: 7px 0; }
|
||||
.m-richdoc ul, .m-richdoc ol { margin: 7px 0; padding-left: 22px; }
|
||||
.m-richdoc li { margin: 3px 0; }
|
||||
.m-richdoc li::marker { color: var(--green); }
|
||||
.m-richdoc a { color: var(--cyan); text-decoration: none; border-bottom: 1px solid var(--cyan-dim); }
|
||||
.m-richdoc a:hover { border-bottom-color: var(--cyan); }
|
||||
.m-richdoc strong { color: var(--ink); font-weight: 700; }
|
||||
.m-richdoc em { color: var(--muted); }
|
||||
.m-richdoc code { font-family: var(--mono); font-size: .9em; color: var(--cyan); background: var(--panel-2); border-radius: 3px; padding: 1.5px 5px; }
|
||||
.m-richdoc pre { background: var(--bg-deep); border: 1px solid var(--line-soft); border-radius: 5px; padding: 11px 13px; overflow-x: auto; margin: 9px 0; }
|
||||
.m-richdoc pre code { display: block; font-family: var(--mono); font-size: 11.8px; line-height: 1.55; color: var(--ink); background: none; border: 0; padding: 0; }
|
||||
.m-richdoc blockquote { margin: 9px 0; padding: 2px 13px; border-left: 3px solid var(--line); color: var(--muted); }
|
||||
.m-richdoc table { border-collapse: collapse; margin: 9px 0; font-size: .95em; display: block; overflow-x: auto; }
|
||||
.m-richdoc th, .m-richdoc td { border: 1px solid var(--line); padding: 5px 10px; text-align: left; }
|
||||
.m-richdoc th { background: var(--panel-2); color: var(--ink); font-weight: 700; }
|
||||
.m-richdoc hr { border: 0; border-top: 1px solid var(--line-soft); margin: 14px 0; }
|
||||
.m-richdoc img { max-width: 100%; }
|
||||
`;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
function MaestroRichDoc({ md }) {
|
||||
const ref = React.useRef(null);
|
||||
ensureRichDocCss();
|
||||
const ready = typeof window !== 'undefined' && !!window.marked && !!window.DOMPurify;
|
||||
const html = React.useMemo(() => {
|
||||
if (!ready || !md) return null;
|
||||
try {
|
||||
const raw = window.marked.parse(String(md), { gfm: true, breaks: false });
|
||||
return window.DOMPurify.sanitize(raw);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}, [md, ready]);
|
||||
React.useEffect(() => {
|
||||
if (html && ref.current && window.hljs) {
|
||||
ref.current.querySelectorAll('pre code').forEach((el) => {
|
||||
try { window.hljs.highlightElement(el); } catch (e) { /* noop */ }
|
||||
});
|
||||
}
|
||||
}, [html]);
|
||||
// 兜底:库未就绪或解析失败 → 原文 pre-wrap,永不崩
|
||||
if (html == null) {
|
||||
return <pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontFamily: 'var(--mono)', fontSize: 12.5, lineHeight: 1.6, color: 'var(--ink)' }}>{md || ''}</pre>;
|
||||
}
|
||||
return <div ref={ref} className="m-richdoc" dangerouslySetInnerHTML={{ __html: html }} />;
|
||||
}
|
||||
|
||||
window.MaestroRichDoc = MaestroRichDoc;
|
||||
@@ -48,6 +48,17 @@ function projStateMeta(state, t) {
|
||||
})[state] || { color: 'var(--muted)', pulse: false, label: t.projIdle };
|
||||
}
|
||||
|
||||
// 项目计数徽标:按优先级展示「需人工 / 运行中 / 被阻塞」,仅非零显示
|
||||
function CountBadge({ value, color, title }) {
|
||||
return (
|
||||
<span title={title} style={{
|
||||
fontFamily: 'var(--mono)', fontSize: 9.5, fontWeight: 700, lineHeight: '15px',
|
||||
minWidth: 15, height: 15, textAlign: 'center', padding: '0 4px',
|
||||
color: 'var(--bg-deep)', background: color, borderRadius: 8,
|
||||
}}>{value}</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjStatusDot({ meta, size = 7 }) {
|
||||
return (
|
||||
<span style={{
|
||||
@@ -247,16 +258,18 @@ function AgentCard({ global, summary, t, collapsed, projects }) {
|
||||
<span style={{ color: 'var(--cyan)', display: 'inline-flex' }}><IcoAgents /></span>{t.gAgents.toUpperCase()}
|
||||
<span style={{ marginLeft: 'auto', fontWeight: 400, letterSpacing: '.04em', color: 'var(--faint)' }}>{t.apWeek}</span>
|
||||
</div>
|
||||
{/* 口径说明:本卡仅统计 maestro 任务执行,与账号级额度条不同源 */}
|
||||
<div style={{ fontSize: 9.5, color: 'var(--faint)', letterSpacing: '.04em', marginBottom: 8, marginTop: -4 }}>{t.apScopeMaestro}</div>
|
||||
{/* 概览 2×2 */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1, background: 'var(--line-soft)', border: '1px solid var(--line-soft)', borderRadius: 4, overflow: 'hidden', marginBottom: 10 }}>
|
||||
{[
|
||||
[fmtTokens(summary.tokensWeek), t.apTokens, 'var(--cyan)'],
|
||||
['$' + summary.costWeek.toFixed(1), t.apCost, 'var(--green)'],
|
||||
[summary.runsWeek, t.apRuns, 'var(--ink)'],
|
||||
[summary.activeNow + ' / ' + global.maxAgents, t.apActive, summary.activeNow ? 'var(--cyan)' : 'var(--faint)'],
|
||||
].map(([v, k, c], i) => (
|
||||
[fmtTokens(summary.tokensWeek), t.apTokens, 'var(--cyan)', null],
|
||||
['≈$' + summary.costWeek.toFixed(1), t.apCost, 'var(--green)', t.apCostEstimateHint],
|
||||
[summary.runsWeek, t.apRuns, 'var(--ink)', null],
|
||||
[summary.activeNow + ' / ' + global.maxAgents, t.apActive, summary.activeNow ? 'var(--cyan)' : 'var(--faint)', null],
|
||||
].map(([v, k, c, hint], i) => (
|
||||
<div key={i} style={{ background: 'var(--bg-deep)', padding: '7px 9px' }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: c, letterSpacing: '.02em' }}>{v}</div>
|
||||
<div title={hint || undefined} style={{ fontSize: 15, fontWeight: 700, color: c, letterSpacing: '.02em' }}>{v}</div>
|
||||
<div style={{ fontSize: 9.5, color: 'var(--muted)', letterSpacing: '.06em', marginTop: 1 }}>{k}</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -308,6 +321,7 @@ function AgentDetailModal({ projects, t, onClose }) {
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
|
||||
<span style={{ color: 'var(--cyan)', display: 'inline-flex' }}><IcoAgents /></span>
|
||||
<span style={{ fontSize: 14, fontWeight: 700, letterSpacing: '.04em', color: 'var(--ink)' }}>{t.apDetailTitle}</span>
|
||||
<span style={{ fontSize: 9.5, color: 'var(--faint)', letterSpacing: '.04em' }}>· {t.apScopeMaestro}</span>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: 1, background: 'var(--line-soft)', border: '1px solid var(--line-soft)', borderRadius: 4, overflow: 'hidden' }}>
|
||||
{[['day', t.apByDay], ['week', t.apByWeek], ['month', t.apByMonth]].map(([g, label]) => (
|
||||
<button key={g} type="button" onClick={() => setGran(g)} style={{
|
||||
@@ -323,13 +337,13 @@ function AgentDetailModal({ projects, t, onClose }) {
|
||||
{/* total 概览 */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', gap: 1, background: 'var(--line-soft)', border: '1px solid var(--line-soft)', borderRadius: 4, overflow: 'hidden', marginBottom: 14 }}>
|
||||
{[
|
||||
[fmtUsd(tot.costUsd), t.apCost, 'var(--green)'],
|
||||
[fmtTokens(tot.tokens), t.apTokens, 'var(--cyan)'],
|
||||
[tot.runs, t.apRuns, 'var(--ink)'],
|
||||
[tot.tasks, t.apColTasks, 'var(--ink)'],
|
||||
].map(([v, k, c], i) => (
|
||||
[fmtUsd(tot.costUsd), t.apCost, 'var(--green)', t.apCostEstimateHint],
|
||||
[fmtTokens(tot.tokens), t.apTokens, 'var(--cyan)', null],
|
||||
[tot.runs, t.apRuns, 'var(--ink)', null],
|
||||
[tot.tasks, t.apColTasks, 'var(--ink)', null],
|
||||
].map(([v, k, c, hint], i) => (
|
||||
<div key={i} style={{ background: 'var(--bg-deep)', padding: '8px 10px' }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: c }}>{v}</div>
|
||||
<div title={hint || undefined} style={{ fontSize: 15, fontWeight: 700, color: c }}>{v}</div>
|
||||
<div style={{ fontSize: 9.5, color: 'var(--muted)', letterSpacing: '.06em', marginTop: 1 }}>{k}</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -361,7 +375,7 @@ function AgentDetailModal({ projects, t, onClose }) {
|
||||
<th style={{ padding: '4px 6px', fontWeight: 600, textAlign: 'right' }}>{t.apTokens}</th>
|
||||
<th style={{ padding: '4px 6px', fontWeight: 600, textAlign: 'right' }}>{t.apCost}</th>
|
||||
<th style={{ padding: '4px 6px', fontWeight: 600, textAlign: 'right' }}>{t.apColTasks}</th>
|
||||
<th style={{ padding: '4px 6px', fontWeight: 600, textAlign: 'left' }}>{t.apColModels}</th>
|
||||
<th title={t.apModelApprox} style={{ padding: '4px 6px', fontWeight: 600, textAlign: 'left', cursor: 'help' }}>{t.apColModels} <span style={{ color: 'var(--faint)' }}>≈</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -509,7 +523,10 @@ function Sidebar({ projects, currentId, onSelect, onNewProject, collapsed, onTog
|
||||
const active = p.id === currentId;
|
||||
const meta = projStateMeta(p.state, t);
|
||||
return (
|
||||
<li key={p.id} onClick={() => onSelect(p.id)} title={p.name + ' · ' + meta.label + (p.pending ? ' · ' + t.projPendingTip.replace('{n}', p.pending) : '')}
|
||||
<li key={p.id} onClick={() => onSelect(p.id)} title={p.name + ' · ' + meta.label
|
||||
+ (p.pending ? ' · ' + t.projPendingTip.replace('{n}', p.pending) : '')
|
||||
+ (p.agents ? ' · ' + t.projAgentsTip.replace('{n}', p.agents) : '')
|
||||
+ (p.blocked ? ' · ' + t.projBlockedTip.replace('{n}', p.blocked) : '')}
|
||||
draggable={!collapsed}
|
||||
onDragStart={(e) => { setDragId(p.id); e.dataTransfer.effectAllowed = 'move'; }}
|
||||
onDragOver={(e) => { if (dragId) { e.preventDefault(); if (p.id !== dragId && overId !== p.id) setOverId(p.id); } }}
|
||||
@@ -536,20 +553,16 @@ function Sidebar({ projects, currentId, onSelect, onNewProject, collapsed, onTog
|
||||
<span style={{ fontWeight: 600, fontSize: 13, color: active ? 'var(--green)' : 'var(--ink)' }}>{p.name}</span>
|
||||
<span style={{ fontSize: 10.5, color: 'var(--faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.path}</span>
|
||||
</span>
|
||||
<span style={{ flex: 'none', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{p.pending > 0 ? (
|
||||
<span title={t.projPendingTip.replace('{n}', p.pending)} style={{
|
||||
fontFamily: 'var(--mono)', fontSize: 9.5, fontWeight: 700, lineHeight: '15px',
|
||||
minWidth: 15, height: 15, textAlign: 'center', padding: '0 4px',
|
||||
color: 'var(--bg-deep)', background: 'var(--violet)', borderRadius: 8,
|
||||
}}>{p.pending}</span>
|
||||
) : null}
|
||||
<span style={{ flex: 'none', display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||
{p.pending > 0 ? <CountBadge value={p.pending} color="var(--violet)" title={t.projPendingTip.replace('{n}', p.pending)} /> : null}
|
||||
{p.agents > 0 ? <CountBadge value={p.agents} color="var(--cyan)" title={t.projAgentsTip.replace('{n}', p.agents)} /> : null}
|
||||
{p.blocked > 0 ? <CountBadge value={p.blocked} color="var(--amber)" title={t.projBlockedTip.replace('{n}', p.blocked)} /> : null}
|
||||
<ProjStatusDot meta={meta} />
|
||||
</span>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<span style={{ position: 'absolute', top: 7, right: 9, display: 'flex', alignItems: 'center' }}>
|
||||
<ProjStatusDot meta={meta} size={p.pending > 0 ? 8 : 6} />
|
||||
<ProjStatusDot meta={meta} size={(p.pending || p.agents || p.blocked) > 0 ? 8 : 6} />
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
|
||||
@@ -9,6 +9,10 @@ const KIT_STATUS_ORDER = [
|
||||
];
|
||||
const KIT_CPLX_OPTS = [{ value: 'hard', label: 'HARD' }, { value: 'medium', label: 'MED' }, { value: 'easy', label: 'EASY' }];
|
||||
const KIT_PRIO_OPTS = [{ value: 'P0', label: 'P0' }, { value: 'P1', label: 'P1' }, { value: 'P2', label: 'P2' }];
|
||||
// 与后端 src/api/server.ts 的 SAFE_INLINE 一字不差:只有这些类型后端才 inline 回原图,
|
||||
// 其余(尤其 image/svg+xml、text/html)强制 attachment+octet-stream 下载。前端缩略图必须对齐同一硬白名单,
|
||||
// 否则对 svg 用 <img src=服务端URL> 会拿到 octet-stream 出坏图,且策略漂移时埋同源存储型 XSS 隐患。
|
||||
const KIT_ATT_SAFE_INLINE = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
|
||||
|
||||
// 主显示区各区块标题通用折叠按钮(▾ 展开 / ▸ 折叠);暴露到 window 供 Agent/审批闸/归档复用
|
||||
function KitFoldBtn({ folded, onClick, t }) {
|
||||
@@ -223,9 +227,10 @@ function TaskRow({ task, depth, ctx, expandedId, setExpandedId, openIds, toggleO
|
||||
}}>
|
||||
{kids.length ? '▶' : '·'}
|
||||
</span>
|
||||
<span style={{ fontWeight: 500, color: ctx ? 'var(--muted)' : 'var(--ink)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{task.title}<span style={{ color: 'var(--faint)', fontSize: 10.5, marginLeft: 6 }}>{task.id}</span>
|
||||
<span style={{ flex: '0 1 auto', minWidth: 0, fontWeight: 500, color: ctx ? 'var(--muted)' : 'var(--ink)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{task.title}
|
||||
</span>
|
||||
<span style={{ flex: 'none', color: 'var(--faint)', fontSize: 10.5 }}>{task.id}</span>
|
||||
<span style={{ flex: 1, minWidth: 8 }}></span>
|
||||
{task.deps ? (
|
||||
<span style={{ flex: 'none', fontSize: 10.5, letterSpacing: '.06em', color: 'var(--amber)', border: '1px dashed var(--amber-dim)', borderRadius: 3, padding: '1px 7px', lineHeight: 1.5, whiteSpace: 'nowrap' }}>
|
||||
@@ -234,7 +239,9 @@ function TaskRow({ task, depth, ctx, expandedId, setExpandedId, openIds, toggleO
|
||||
) : null}
|
||||
<span style={{ fontSize: 10.5, color: task.prio === 'P0' ? 'var(--amber)' : 'var(--faint)', flex: 'none' }}>{task.prio}</span>
|
||||
<CplxPicker task={task} cplx={cplxOf(task)} onChange={onChangeCplx} />
|
||||
<StatusChip status={task.status} label={t.status[task.status]} />
|
||||
{task.frozen
|
||||
? <StatusChip status="paused" label={t.frozenPending} />
|
||||
: <StatusChip status={task.status} label={t.status[task.status]} />}
|
||||
</div>
|
||||
{expanded ? <TaskDetail task={task} t={t} byId={byId} onJump={onJump} onTakeover={onTakeover} taskOps={taskOps} /> : null}
|
||||
{kids.length && open ? (
|
||||
@@ -251,6 +258,7 @@ function TaskRow({ task, depth, ctx, expandedId, setExpandedId, openIds, toggleO
|
||||
}
|
||||
|
||||
function TaskDetail({ task, t, byId, onJump, onTakeover, taskOps }) {
|
||||
ensureNewTaskCss();
|
||||
const { Button, Input, Select } = window.MaestroDesignSystem_a6a290;
|
||||
const label = task.doc ? t.specLabel : task.ops ? t.opsLabel : null;
|
||||
const text = task.doc || task.ops;
|
||||
@@ -281,8 +289,37 @@ function TaskDetail({ task, t, byId, onJump, onTakeover, taskOps }) {
|
||||
{ops.requeue && canRequeue ? <button onClick={() => ops.requeue(task.id)} style={btn('green')}>↻ 重投</button> : null}
|
||||
{ops.cancel && canCancel ? <button onClick={() => ops.cancel(task.id)} style={btn('amber')}>⊘ 取消</button> : null}
|
||||
{ops.del ? <button onClick={() => { if (confirm('删除任务「' + task.title + '」?子任务一并删除,不可恢复。')) ops.del(task.id); }} style={btn('red')}>🗑 删除</button> : null}
|
||||
{attachments.length ? <span style={{ marginLeft: 'auto', fontSize: 11, color: 'var(--faint)' }}>📎 {attachments.map((a) => a.name).join(' · ')}</span> : null}
|
||||
</div>
|
||||
{/* 附件画廊:图片缩略图(点击新标签预览)+ 下载 + 删除(确认后调用 taskOps.delAttachment) */}
|
||||
{attachments.length ? (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<div style={{ fontSize: 10.5, color: 'var(--muted)', letterSpacing: '.18em', marginBottom: 6 }}>{t.attSection} · {attachments.length}</div>
|
||||
<div className="m-nt-grid">
|
||||
{attachments.map((a) => {
|
||||
const dn = attDiskName(a);
|
||||
const isImg = KIT_ATT_SAFE_INLINE.has(a.type || '');
|
||||
const url = attUrl(task.id, dn);
|
||||
return (
|
||||
<div key={dn || a.name} className="m-nt-chip">
|
||||
<a className="m-att-thumb-link" href={url} target="_blank" rel="noopener noreferrer" title={a.name}>
|
||||
<span className="m-nt-thumb">
|
||||
{isImg ? <img src={url} alt={a.name} /> : <span className="m-nt-ext">{(a.name.includes('.') ? a.name.split('.').pop() : (a.type || 'file').split('/').pop() || 'file').toUpperCase()}</span>}
|
||||
</span>
|
||||
</a>
|
||||
<span className="m-nt-name" title={a.name}>{a.name}</span>
|
||||
<span className="m-att-act">
|
||||
<a className="m-att-btn" href={attUrl(task.id, dn, true)} download={a.name}>↓ {t.attDownload}</a>
|
||||
{ops.delAttachment ? (
|
||||
<button type="button" className="m-att-btn is-del"
|
||||
onClick={() => { if (confirm(t.attDeleteConfirm.replace('{name}', a.name))) ops.delAttachment(task.id, dn); }}>✕</button>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{/* 编辑表单 */}
|
||||
{editing ? (
|
||||
<div style={{ display: 'grid', gap: 12, padding: 12, border: '1px solid var(--line)', borderRadius: 6, background: 'var(--bg-deep)' }} onClick={(e) => e.stopPropagation()}>
|
||||
@@ -315,7 +352,7 @@ function TaskDetail({ task, t, byId, onJump, onTakeover, taskOps }) {
|
||||
{label ? (
|
||||
<div>
|
||||
<div style={{ fontSize: 10.5, color: 'var(--muted)', letterSpacing: '.18em', marginBottom: 4 }}>{label}</div>
|
||||
<pre style={{ background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderLeft: '2px solid var(--green-dim)', padding: '10px 12px', fontFamily: 'var(--mono)', fontSize: 12.5, whiteSpace: 'pre-wrap', wordBreak: 'break-word', margin: 0, color: 'var(--ink)', borderRadius: 4 }}>{text}</pre>
|
||||
<div style={{ background: 'var(--bg-deep)', border: '1px solid var(--line-soft)', borderLeft: '2px solid var(--green-dim)', padding: '10px 14px', borderRadius: 4 }}><window.MaestroRichDoc md={text} /></div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: '10px 12px', border: '1px dashed var(--line)', borderRadius: 6, color: 'var(--faint)', fontStyle: 'italic', fontSize: 12 }}>
|
||||
@@ -348,17 +385,164 @@ function TaskDetail({ task, t, byId, onJump, onTakeover, taskOps }) {
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{/* 审批记录:读 _raw.approvals,按记录列出闸名·通过/驳回 + 审批人 + 时间;驳回突出 reason。
|
||||
安全:reason/actor/gate 全部走 React 文本节点(默认转义),绝不经 MaestroRichDoc / innerHTML,避免同源存储型 XSS。 */}
|
||||
{(() => {
|
||||
const approvals = (task._raw && task._raw.approvals) || [];
|
||||
if (!approvals.length) return null;
|
||||
return (
|
||||
<div>
|
||||
<div style={{ fontSize: 10.5, color: 'var(--muted)', letterSpacing: '.18em', marginBottom: 6 }}>{t.approvalsLabel}</div>
|
||||
<div style={{ display: 'grid', gap: 6 }}>
|
||||
{approvals.map((a, i) => {
|
||||
const rej = a.action === 'reject';
|
||||
const at = (a.at || '').replace('T', ' ').slice(0, 19);
|
||||
const gate = (t.gates && t.gates[a.gate]) || a.gate;
|
||||
return (
|
||||
<div key={i} style={{
|
||||
fontSize: 12, padding: '6px 10px', borderRadius: 4,
|
||||
background: 'var(--bg-deep)', border: '1px solid var(--line-soft)',
|
||||
borderLeft: '2px solid ' + (rej ? 'var(--red-dim)' : 'var(--green-dim)'),
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span style={{ color: rej ? 'var(--red)' : 'var(--green)', fontWeight: 700, flex: 'none' }}>
|
||||
{gate} · {rej ? t.rejected : t.accepted}
|
||||
</span>
|
||||
<span style={{ color: 'var(--faint)', fontSize: 11 }}>{t.approver} {a.actor} · {at}</span>
|
||||
</div>
|
||||
{rej && a.reason ? (
|
||||
<div style={{ marginTop: 5, padding: '5px 9px', borderRadius: 3,
|
||||
background: 'rgba(240,100,100,.08)', borderLeft: '2px solid var(--red-dim)', color: 'var(--amber)' }}>
|
||||
<span style={{ color: 'var(--red)', fontWeight: 600, marginRight: 6 }}>{t.rejectReason}</span>{a.reason}
|
||||
</div>
|
||||
) : a.reason ? (
|
||||
<div style={{ marginTop: 4, color: 'var(--muted)' }}>↳ {a.reason}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 新建任务面板的附件区样式(dropzone 高亮 / 缩略图 chip / 删除角标),一次性注入避免污染全局
|
||||
function ensureNewTaskCss() {
|
||||
if (document.getElementById('maestro-kit-newtask-css')) return;
|
||||
const s = document.createElement('style');
|
||||
s.id = 'maestro-kit-newtask-css';
|
||||
s.textContent = `
|
||||
.m-nt-drop { display: flex; flex-direction: column; gap: 8px; padding: 10px; border: 1px dashed var(--line); border-radius: var(--radius-sm, 4px); background: var(--bg-deep); transition: border-color .12s, background .12s; }
|
||||
.m-nt-drop.is-over { border-color: var(--green-dim); background: var(--panel-2); }
|
||||
.m-nt-hint { font-size: 10.5px; color: var(--faint); letter-spacing: .04em; }
|
||||
.m-nt-hint.is-over { color: var(--green); }
|
||||
.m-nt-grid { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.m-nt-chip { position: relative; width: 92px; display: flex; flex-direction: column; gap: 3px; }
|
||||
.m-nt-thumb { width: 92px; height: 64px; border: 1px solid var(--line); border-radius: var(--radius-sm, 4px); background: var(--panel-2); overflow: hidden; display: flex; align-items: center; justify-content: center; }
|
||||
.m-nt-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.m-nt-ext { font-family: var(--mono); font-size: 12px; color: var(--cyan); text-transform: uppercase; letter-spacing: .06em; }
|
||||
.m-nt-name { font-size: 10px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.m-nt-size { font-size: 9.5px; color: var(--faint); font-family: var(--mono); }
|
||||
.m-nt-del { position: absolute; top: -6px; right: -6px; width: 18px; height: 18px; padding: 0; line-height: 1; border: 1px solid var(--line); border-radius: 50%; background: var(--panel); color: var(--muted); cursor: pointer; font-size: 12px; display: flex; align-items: center; justify-content: center; transition: color .12s, border-color .12s, background .12s; }
|
||||
.m-nt-del:hover { color: var(--red); border-color: var(--red-dim); background: var(--panel-2); }
|
||||
.m-att-thumb-link { display: block; width: 92px; height: 64px; }
|
||||
.m-att-act { display: flex; gap: 6px; align-items: center; }
|
||||
.m-att-btn { font-family: var(--mono); font-size: 10px; line-height: 1; padding: 3px 6px; border: 1px solid var(--line); border-radius: var(--radius-sm, 4px); background: var(--panel); color: var(--muted); cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; transition: color .12s, border-color .12s, background .12s; }
|
||||
.m-att-btn:hover { color: var(--green); border-color: var(--green-dim); background: var(--panel-2); }
|
||||
.m-att-btn.is-del:hover { color: var(--red); border-color: var(--red-dim); }
|
||||
`;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
// 已建任务附件:磁盘文件名(后端 :name 标识符 = basename(att.path),非展示名 att.name)
|
||||
function attDiskName(a) { return (a.path || '').split('/').pop() || a.name || ''; }
|
||||
// 附件预览/下载 URL(UI kit 内联,零外部依赖;与 app/src/api.js attachmentUrl 同构)
|
||||
function attUrl(taskId, name, download) {
|
||||
return `/api/tasks/${taskId}/attachments/${encodeURIComponent(name)}${download ? '?download=1' : ''}`;
|
||||
}
|
||||
|
||||
// 三来源(选择/粘贴/拖拽)统一去重键:name+size+lastModified
|
||||
function ntFileKey(f) { return (f.name || '') + ' | ||||