Files
codex-app-server/cloud/console-react/src/sections/detail/ProjectToolsPanels.tsx
T
host 08484302ac console: 项目导航移到左侧栏,工作台不再承担项目模块
左侧栏 ProjectNav:项目完整列表(名 + 短 id + 我的角色,
选中高亮,点击切换)+ 创建项目(名/GitHub 仓库/repo_url)
+ 选中项目成员(邀请/改角色/新增移除);工作台删除
sec-projects 一节与任务列表里的项目下拉框,侧栏为唯一
项目选择器,自动选第一个项目逻辑不变。
薄模块补齐(均为已核实后端路由):成员移除 DELETE、
自动化详情(GET 单条 + 产出)与编辑 PATCH、分诊条目点进
任务/自动化详情、Workers 搜索与在线筛选、管理区设置配额
POST。主线新功能(Todos、Browser、taskMeta 等)原样保留,
未做任何回退。console 标记单测本地 53 项通过。
2026-10-30 07:43:54 +00:00

179 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
</>
);
}