左侧栏 ProjectNav:项目完整列表(名 + 短 id + 我的角色, 选中高亮,点击切换)+ 创建项目(名/GitHub 仓库/repo_url) + 选中项目成员(邀请/改角色/新增移除);工作台删除 sec-projects 一节与任务列表里的项目下拉框,侧栏为唯一 项目选择器,自动选第一个项目逻辑不变。 薄模块补齐(均为已核实后端路由):成员移除 DELETE、 自动化详情(GET 单条 + 产出)与编辑 PATCH、分诊条目点进 任务/自动化详情、Workers 搜索与在线筛选、管理区设置配额 POST。主线新功能(Todos、Browser、taskMeta 等)原样保留, 未做任何回退。console 标记单测本地 53 项通过。
179 lines
10 KiB
TypeScript
179 lines
10 KiB
TypeScript
import { useState } from "react";
|
||
import { useConsole } from "../../context";
|
||
import { Bar, DataList } from "../../components/ui";
|
||
import { fmt, shortId } from "../../api/client";
|
||
import type { Automation, AutomationDetail } from "../../api/types";
|
||
|
||
const PROMPT_PREVIEW = 160;
|
||
|
||
function humanInterval(s?: number): string {
|
||
if (!s) return "";
|
||
if (s % 86400 === 0) return "(每天)";
|
||
if (s % 3600 === 0) return "(每小时)";
|
||
if (s % 60 === 0) return "(每 " + s / 60 + " 分钟)";
|
||
return "";
|
||
}
|
||
|
||
// Automation detail (read-only display): backed by GET
|
||
// /projects/{pid}/automations/{aid} — full definition + runs[] +
|
||
// runs_note + heartbeat_binding. Editing is NOT duplicated here:
|
||
// the single edit form is main's inline edit in the list below
|
||
// (updateAutomation with the (a, name, prompt, interval, repo)
|
||
// signature). Detail display + runs + task buttons are our side's
|
||
// addition; cross-view jumps use location.hash because the IDE
|
||
// shell switches views on hashchange (no sec-conv / sec-automations
|
||
// element to scroll to).
|
||
function AutomationDetailBox({ detail }: { detail: AutomationDetail }) {
|
||
const ctx = useConsole();
|
||
const openTask = (id?: string) => {
|
||
if (!id) return;
|
||
ctx.showDetail(id);
|
||
try { window.location.hash = "#view-tasks"; } catch { /* noop */ }
|
||
};
|
||
return (
|
||
<div className="card">
|
||
<h2>自动化详情:{detail.name}</h2>
|
||
<div className="muted">
|
||
状态 {detail.status}{detail.due ? "|到期" : ""} | 每 {detail.interval_seconds} 秒 | 下次 {fmt(detail.next_run_at)}
|
||
| 上次执行 {fmt(detail.last_run_at)} | 创建人 {detail.created_by ? shortId(detail.created_by) : ""} | 创建于 {fmt(detail.created_at)}
|
||
{detail.cancelled_at ? " | 已取消于 " + fmt(detail.cancelled_at) : ""}
|
||
{detail.heartbeat_binding ? " | 心跳绑定:已绑定" : ""}
|
||
</div>
|
||
<div className="muted" style={{ whiteSpace: "pre-wrap", marginTop: 8 }}>{detail.prompt || ""}</div>
|
||
{detail.repo_url ? <div className="muted">仓库 {detail.repo_url}</div> : null}
|
||
<Bar>
|
||
<button onClick={ctx.closeAutomationDetail}>关闭详情</button>
|
||
<span className="muted">编辑请在下面列表里点“编辑”。{ctx.autoDetailMsg}</span>
|
||
</Bar>
|
||
<div className="data-group"><b>最近产出({(detail.runs || []).length})</b>
|
||
<DataList empty="(无)" items={(detail.runs || []).map((r, i) => (
|
||
<span key={i} className="row-main">
|
||
{r.status}{r.created_at ? "|" + fmt(r.created_at) : ""}{r.task_id ? <> | 任务 <button onClick={() => openTask(r.task_id as string)}>{shortId(r.task_id)}</button></> : ""}{r.detail ? "|" + String(r.detail).slice(0, 120) : ""}
|
||
</span>
|
||
))} />
|
||
</div>
|
||
{detail.runs_note ? <div className="muted">{detail.runs_note}</div> : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function AutomationPanel() {
|
||
const ctx = useConsole();
|
||
const [name, setName] = useState("");
|
||
const [prompt, setPrompt] = useState("");
|
||
const [interval, setInterval] = useState("");
|
||
const [repo, setRepo] = useState("");
|
||
const [editingId, setEditingId] = useState("");
|
||
const [editName, setEditName] = useState("");
|
||
const [editPrompt, setEditPrompt] = useState("");
|
||
const [editInterval, setEditInterval] = useState("");
|
||
const [editRepo, setEditRepo] = useState("");
|
||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||
const startEdit = (a: Automation) => {
|
||
setEditingId(a.id);
|
||
setEditName(a.name || "");
|
||
setEditPrompt(a.prompt || "");
|
||
setEditInterval(a.interval_seconds ? String(a.interval_seconds) : "");
|
||
setEditRepo(a.repo_url || "");
|
||
};
|
||
return (
|
||
<>
|
||
<input placeholder="自动化名称(同项目内启用中的名称唯一)" style={{ width: "100%" }} value={name} onChange={(e) => setName(e.target.value)} />
|
||
<textarea placeholder="定时任务内容(创建只登记定义与到期状态;执行由后续执行器经 claim 抢单后建任务,不另起调度器)" value={prompt} onChange={(e) => setPrompt(e.target.value)} />
|
||
<Bar>
|
||
<input placeholder="间隔秒数(默认 86400,最小 60)" style={{ width: "40%" }} value={interval} onChange={(e) => setInterval(e.target.value)} />
|
||
<input placeholder="仓库(可选)" style={{ width: "40%" }} value={repo} onChange={(e) => setRepo(e.target.value)} />
|
||
<button className="primary" onClick={async () => { await ctx.createAutomation(name, prompt, interval, repo); setName(""); setPrompt(""); setInterval(""); setRepo(""); }}>创建自动化</button>
|
||
<button onClick={ctx.refreshAutomations}>刷新自动化</button>
|
||
<span className="muted">{ctx.autoMsg}</span>
|
||
</Bar>
|
||
<DataList empty="(暂无自动化)" items={ctx.automations.map((a) => {
|
||
const full = a.prompt || "";
|
||
const showFull = !!expanded[a.id];
|
||
const tooLong = full.length > PROMPT_PREVIEW;
|
||
const shown = !full ? "(无内容)" : (showFull || !tooLong ? full : full.slice(0, PROMPT_PREVIEW) + "…");
|
||
return (
|
||
<div key={a.id}>
|
||
<div className="row-main">
|
||
[{a.status}{a.due ? "|到期" : ""}{a.run_once ? "|一次性" : ""}] {a.name}|每 {a.interval_seconds} 秒{humanInterval(a.interval_seconds)}|上次 {fmt(a.last_run_at) || "未运行"}|下次 {fmt(a.next_run_at)}
|
||
<button onClick={() => void ctx.loadAutomationDetail(a.id)}>详情</button>
|
||
{a.status === "active" ? <> <button onClick={() => startEdit(a)}>编辑</button> <button onClick={() => ctx.cancelAutomation(a.id)}>取消</button></> : null}
|
||
</div>
|
||
{a.repo_url ? <div className="muted">仓库:{a.repo_url}</div> : null}
|
||
{a.status === "cancelled" && a.cancelled_at ? <div className="muted">已取消于 {fmt(a.cancelled_at)}</div> : null}
|
||
<div className="muted" style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>
|
||
{shown}
|
||
{tooLong ? <button onClick={() => setExpanded((prev) => ({ ...prev, [a.id]: !prev[a.id] }))}>{showFull ? "收起" : "展开"}</button> : null}
|
||
</div>
|
||
{editingId === a.id ? (
|
||
<div>
|
||
<input placeholder="自动化名称" style={{ width: "100%" }} value={editName} onChange={(e) => setEditName(e.target.value)} />
|
||
<textarea placeholder="定时任务内容" value={editPrompt} onChange={(e) => setEditPrompt(e.target.value)} />
|
||
<Bar>
|
||
<input placeholder="间隔秒数(最小 60,留空保持原值)" style={{ width: "40%" }} value={editInterval} onChange={(e) => setEditInterval(e.target.value)} />
|
||
<input placeholder="仓库(可选,清空则清除)" style={{ width: "40%" }} value={editRepo} onChange={(e) => setEditRepo(e.target.value)} />
|
||
<button className="primary" onClick={async () => {
|
||
const cur = ctx.automations.find((x) => x.id === editingId) || a;
|
||
const ok = await ctx.updateAutomation(cur, editName, editPrompt, editInterval, editRepo);
|
||
if (ok) setEditingId("");
|
||
}}>保存</button>
|
||
<button onClick={() => setEditingId("")}>取消</button>
|
||
</Bar>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
})} />
|
||
{ctx.autoDetailMsg && !ctx.autoDetail ? <div className="muted">{ctx.autoDetailMsg}</div> : null}
|
||
{ctx.autoDetail ? <AutomationDetailBox key={ctx.autoDetail.id} detail={ctx.autoDetail} /> : null}
|
||
</>
|
||
);
|
||
}
|
||
|
||
export function TriagePanel() {
|
||
const ctx = useConsole();
|
||
const t = ctx.triage;
|
||
// Clicking an actionable item opens that task's detail (tasks view)
|
||
// or the automation's detail (automation view). Field names are
|
||
// exactly the triage payload's: failed_tasks[].id, pending_followups
|
||
// [].task_id, recent_runs[].task_id (nullable), automations_due[].id.
|
||
// Jumps use location.hash because the IDE shell switches views on
|
||
// hashchange; there is no sec-conv / sec-automations to scroll to.
|
||
const openTask = (id?: string) => {
|
||
if (!id) return;
|
||
ctx.showDetail(id);
|
||
try { window.location.hash = "#view-tasks"; } catch { /* noop */ }
|
||
};
|
||
const openAutomation = (id?: string) => {
|
||
if (!id) return;
|
||
void ctx.loadAutomationDetail(id);
|
||
try { window.location.hash = "#view-automation"; } catch { /* noop */ }
|
||
};
|
||
if (ctx.triageError) return <div className="muted">{ctx.triageError}</div>;
|
||
if (!t) return <div className="muted">(先选项目)</div>;
|
||
return (
|
||
<>
|
||
<div className="data-group"><b>到期自动化({t.counts?.automations_due || 0})</b>
|
||
<DataList empty="(无)" items={(t.automations_due || []).map((a, i) => (
|
||
<span key={i} className="row-main">{a.id ? <button onClick={() => openAutomation(a.id)}>{a.name}|每 {a.interval_seconds} 秒</button> : <>{a.name}|每 {a.interval_seconds} 秒</>}</span>
|
||
))} />
|
||
</div>
|
||
<div className="data-group"><b>失败/中断任务({t.counts?.failed_tasks || 0})</b>
|
||
<DataList empty="(无)" items={(t.failed_tasks || []).map((x, i) => (
|
||
<span key={i} className="row-main"><button onClick={() => openTask(x.id)}>{shortId(x.id)}|{x.status}|{String(x.prompt || "").slice(0, 80)}</button></span>
|
||
))} />
|
||
</div>
|
||
<div className="data-group"><b>待处理后续消息({t.counts?.pending_followups || 0})</b>
|
||
<DataList empty="(无)" items={(t.pending_followups || []).map((f, i) => (
|
||
<span key={i} className="row-main"><button onClick={() => openTask(f.task_id)}>任务 {shortId(f.task_id)}|{String(f.body || "").slice(0, 80)}</button></span>
|
||
))} />
|
||
</div>
|
||
<div className="data-group"><b>最近自动化产出({t.counts?.recent_runs || 0})</b>
|
||
<DataList empty="(无)" items={(t.recent_runs || []).map((r, i) => (
|
||
<span key={i} className="row-main">{shortId(r.automation_id)}|{r.status}{r.task_id ? <>|任务 <button onClick={() => openTask(r.task_id)}>{shortId(r.task_id)}</button></> : ""}{r.task_status ? "|" + r.task_status : ""}</span>
|
||
))} />
|
||
</div>
|
||
</>
|
||
);
|
||
}
|