Files
mini-openclaw/src/app/operations-center/page.tsx
T

104 lines
22 KiB
TypeScript

"use client";
import {useEffect, useRef, useState} from "react";
import {Activity, ArrowRight, CheckCircle2, Database, FlaskConical, Play, RefreshCw, ShieldCheck, SlidersHorizontal, Square, X} from "lucide-react";
import AgentNav from "@/components/xingbang/AgentNav";
import "./operations.css";
const ROOT = "/api/risk-agent/project-assistant/operations";
const names: Record<string, string> = {queued:"排队中", running:"执行中", completed:"执行完成", partial:"部分完成", failed:"执行失败", cancelled:"已取消", pass:"检查通过", issue:"发现问题", unknown:"依据不足", not_applicable:"不适用", error:"错误", skipped:"未执行", ok:"正常", unverified:"未验证", unavailable:"不可用"};
const stamp = (value?:string) => value ? new Date(value).toLocaleString("zh-CN", {hour12:false}) : "—";
const fmt = (value:any) => value === null || value === undefined ? "—" : String(value);
function Badge({value}:{value:string}) {return <span className={`oc-badge ${value}`}>{names[value] || value}</span>;}
async function api(path:string, body?:unknown, key?:string) {
const token = sessionStorage.getItem("xingbang-access-token");
const response = await fetch(ROOT+path, {method:body===undefined?"GET":"POST", cache:"no-store", headers:{"Content-Type":"application/json", ...(token?{Authorization:`Bearer ${token}`} : {}), ...(key?{"Idempotency-Key":key}:{})}, body:body===undefined?undefined:JSON.stringify(body)});
const data = await response.json();
if (!response.ok) throw Error(typeof data.detail === "string" ? data.detail : "请求失败,请核对输入与权限");
return data;
}
export default function OperationsCenter() {
const [domain,setDomain] = useState("lab"), [view,setView] = useState("monitor"), [ready,setReady] = useState(false);
const [overview,setOverview] = useState<any>(null), [source,setSource] = useState<any>(null), [detail,setDetail] = useState<any>(null);
const [selected,setSelected] = useState(""), [status,setStatus] = useState("all"), [search,setSearch] = useState("");
const [busy,setBusy] = useState(false), [error,setError] = useState(""), [notice,setNotice] = useState(""), [health,setHealth] = useState<any>(null);
const [connectionError,setConnectionError] = useState("");
const [form,setForm] = useState<any>({}), [dirty,setDirty] = useState(false), [model,setModel] = useState(false), [requirement,setRequirement] = useState("");
const [compareId,setCompareId] = useState(""), [comparison,setComparison] = useState<any>(null);
const epoch = useRef(0);
const admin = overview?.identity?.preview || overview?.identity?.role === "admin";
const loadSource = async () => {
const data = await api("/lab/source"); setSource(data);
setForm({revision:data.project.revision, amount:data.project.amount, liabilities:data.finance.liabilities, dd_liabilities:data.finance.dd_liabilities, cashflow:data.finance.cashflow ?? "", duplicate:!!data.assets.find((a:any)=>a.row_id===3)?.active, enabled:!!data.project.enabled});
setDirty(false);
};
useEffect(()=>{const q=new URLSearchParams(location.search); setDomain(q.get("domain")==="current" || (q.has("project") && !q.has("domain")) ? "current":"lab"); setView(["monitor","trace","lab","health"].includes(q.get("view")||"")?q.get("view")!:"monitor"); setReady(true);},[]);
useEffect(()=>{
if(!ready)return;
const version=++epoch.current; let disposed=false, loading=false;
setOverview(null);setSelected("");setDetail(null);setSource(null);setError("");setCompareId("");setComparison(null);
async function load(){if(loading)return;loading=true;try{const data=await api(`/overview?domain=${domain}`);if(!disposed&&epoch.current===version){setOverview(data);setConnectionError("");}}catch(e:any){if(!disposed)setConnectionError(e.message);}finally{loading=false;}}
load(); const timer=setInterval(load,2500);
if(domain==="lab")loadSource().catch(e=>{if(!disposed)setError(e.message);});
return()=>{disposed=true;clearInterval(timer);};
},[domain,ready]);
useEffect(()=>{
if(!selected)return; let disposed=false, loading=false;setDetail(null);setCompareId("");setComparison(null);
async function load(){if(loading)return;loading=true;try{const data=await api(`/runs/${encodeURIComponent(selected)}?domain=${domain}`);if(!disposed)setDetail(data);}catch(e:any){if(!disposed)setError(e.message);}finally{loading=false;}}
load();const timer=setInterval(load,2500);return()=>{disposed=true;clearInterval(timer);};
},[selected,domain]);
useEffect(()=>{if(view==="health"&&admin)api("/health").then(setHealth).catch(e=>setError(e.message));},[view,admin]);
useEffect(()=>{let disposed=false;setComparison(null);if(compareId)api(`/runs/${compareId}?domain=${domain}`).then(data=>{if(!disposed)setComparison(data);}).catch(e=>{if(!disposed)setError(e.message);});return()=>{disposed=true;};},[compareId,domain]);
async function act(fn:()=>Promise<void>){setBusy(true);setError("");setNotice("");try{await fn();setOverview(await api(`/overview?domain=${domain}`));}catch(e:any){setError(e.message);}finally{setBusy(false);}}
function edit(key:string,value:any){setForm((f:any)=>({...f,[key]:value}));setDirty(true);}
function chooseView(next:string){setView(next);history.replaceState({},"",`?domain=${domain}&view=${next}`);}
async function submit(){await act(async()=>{const run=await api("/lab/runs",{source_revision:source.project.revision,model,requirement:model?requirement:""},crypto.randomUUID());setSelected(run.id);setView("monitor");await loadSource();setNotice("真实任务已入队。执行记录会自动刷新;历史快照保持不变。");});}
const runs=(overview?.runs||[]).filter((r:any)=>(status==="all"||r.status===status)&&(!search||`${r.project_id} ${r.id}`.toLowerCase().includes(search.toLowerCase())));
const run=detail?.run;
const oldChecks=new Map<string,any>((comparison?.run?.checks||[]).map((c:any)=>[c.code,c]));
const newChecks=new Map<string,any>((run?.checks||[]).map((c:any)=>[c.code,c]));
const differences=Array.from(new Set([...Array.from(oldChecks.keys()),...Array.from(newChecks.keys())])).map(code=>({code,before:oldChecks.get(code),after:newChecks.get(code)}));
return <div className="oc-shell"><AgentNav active="operations"/>
<main className="oc-main">
<header className="oc-hero"><div><p className="oc-eyebrow">XINGBANG / OPERATIONS</p><h1>项目运行中心</h1><p>看到真实执行,追溯每次变化。</p></div><div className="oc-live"><span/>每 2.5 秒更新 <small>最后成功读取 {overview?stamp(overview.time):"等待连接"}</small></div></header>
<div className="oc-environment"><div role="group" aria-label="数据环境"><button className={domain==="current"?"active":""} onClick={()=>setDomain("current")} disabled={busy}>项目工作区</button><button className={domain==="lab"?"active":""} onClick={()=>setDomain("lab")} disabled={busy}>隔离演示库</button></div><p>{domain==="lab"?"LAB-001 · 独立 SQLite · 所有数值及阈值均为演示数据":"仅展示当前身份获授权的项目;不会自动重跑或修改项目"}</p></div>
<nav className="oc-tabs" aria-label="运行中心功能">{[["monitor","运行与待办",Activity],["trace","检查依据与对比",ShieldCheck],["lab","演示与验收",FlaskConical],["health","系统自检",SlidersHorizontal]].map(([id,label,Icon]:any)=><button key={id} className={view===id?"active":""} onClick={()=>chooseView(id)}><Icon size={16}/>{label}</button>)}</nav>
{error&&<div className="oc-alert error" role="alert">{error}<button aria-label="关闭错误" onClick={()=>setError("")}><X size={15}/></button></div>}
{connectionError&&<div className="oc-alert error" role="alert">监控连接异常:{connectionError}。下方可能是上次读取的数据。</div>}
{notice&&<div className="oc-alert" role="status">{notice}</div>}
{(view==="monitor"||view==="trace")&&<>
<section className="oc-stats" aria-label="运行汇总">{[["running","执行中"],["queued","排队中"],["failed","执行失败"],["partial","部分完成"]].map(([key,label])=><button key={key} onClick={()=>setStatus(status===key?"all":key)} className={status===key?"chosen":""}><span>{label}</span><strong>{overview?.counts[key]??"—"}</strong><small>{key==="partial"?"缺依据或步骤未完整执行":"点击筛选任务"}</small></button>)}<div><span>项目待处理问题</span><strong>{overview?.todo_count??"—"}</strong><small>{overview?.stale_report_count??0} 份底稿待更新</small></div></section>
<div className="oc-workspace"><section className="oc-card"><div className="oc-card-head"><h2>任务记录</h2><small>最近 100 条 / 共 {overview?.total_runs??0} 条</small></div><div className="oc-filters"><input aria-label="搜索项目或任务" placeholder="搜索项目 / 任务 ID" value={search} onChange={e=>setSearch(e.target.value)}/><select aria-label="筛选运行状态" value={status} onChange={e=>setStatus(e.target.value)}><option value="all">全部状态</option>{Object.entries(names).slice(0,6).map(([k,n])=><option key={k} value={k}>{n}</option>)}</select></div>
<div className="oc-table-wrap"><table><thead><tr><th>项目 / 任务</th><th>执行状态</th><th>耗时 / 等待</th><th>业务结果</th></tr></thead><tbody>{runs.map((r:any)=><tr key={r.id} className={selected===r.id?"selected":""}><td><button className="oc-task" onClick={()=>setSelected(r.id)}><b>{r.project_id}</b><small>{stamp(r.created_at)}</small><code>{r.id.slice(0,16)}…</code></button></td><td><Badge value={r.status}/><small>{r.step}</small>{r.lease_expired&&<em>工作租约已过期,等待恢复</em>}{r.heartbeat_at&&<small>心跳 {stamp(r.heartbeat_at)}</small>}</td><td>{fmt(r.elapsed_seconds)} s<small>等待 {fmt(r.queue_seconds)} s</small></td><td>{r.summary?.open??"—"} 项待处理{r.superseded&&<small>数据已变更,此次结果未覆盖项目</small>}</td></tr>)}</tbody></table></div>
{!runs.length&&<div className="oc-empty"><Activity size={26}/><p>{overview?"此筛选下暂无运行":"正在连接后端…"}</p>{domain==="lab"&&<button onClick={()=>setView("lab")}>准备演示数据 <ArrowRight size={14}/></button>}</div>}
</section>
<aside className="oc-card"><div className="oc-card-head"><h2>待处理事项</h2><small>按项目权限过滤</small></div>{!(overview?.todos?.length||overview?.stale_reports?.length)&&<p className="oc-empty">暂无记录不代表全部检查通过,请核对运行范围。</p>}{overview?.todos?.slice(0,10).map((f:any)=><button className="oc-todo" key={f.id} onClick={()=>setSelected(f.last_seen_run_id)}><span>{f.project_id} · {f.status}{f.stale?" · 需重新检查":""}</span><b>{f.title}</b><small>{f.suggested_action}</small></button>)}{overview?.stale_reports?.map((r:any)=><div className="oc-todo" key={r.id}><b>{r.project_id} · 底稿待更新</b>{domain==="current"&&<a href={`/xingbang-workbench?project=${encodeURIComponent(r.project_id)}`}>进入项目处理</a>}</div>)}</aside></div>
{detail&&<section className="oc-card oc-detail"><div className="oc-card-head"><div><h2>执行依据 · {run.project_id}</h2><code>{run.id}</code></div><div className="oc-actions"><Badge value={run.status}/>{domain==="lab"&&admin&&["queued","running"].includes(run.status)&&<button disabled={busy} onClick={()=>act(async()=>{await api(`/lab/runs/${run.id}/cancel`,{});setNotice("已请求取消,已完成记录保留。");})}><Square size={14}/>取消任务</button>}</div></div>
<p className="oc-note">数据版本 {detail.data_revision} · SQL 源版本 {fmt(detail.source_revision)} · 快照 {run.input_snapshot_id}。执行完成 ≠ 无业务问题。</p>
<p className="oc-note">本次自然语言要求:{run.requirement||"无;按已冻结的方案执行"}</p>{run.error&&<p className="oc-alert error">{run.error}</p>}
<div className="oc-steps">{run.steps.map((s:any)=><article key={s.step_id}><div><b>{s.name}</b><Badge value={s.status}/></div><small>{s.skill.version} · {fmt(s.elapsed_seconds)} 秒</small>{s.errors?.map((e:any,i:number)=><p className="oc-error-text" key={i}>{e.message}</p>)}{s.missing_inputs?.length>0&&<p>缺输入:{s.missing_inputs.join("、")}</p>}</article>)}</div>
<h3>实际采用的 Skills 与规则</h3><div className="oc-table-wrap"><table><thead><tr><th>组件</th><th>本次冻结版本</th><th>当前发布版本</th><th>执行内容 / 哈希</th></tr></thead><tbody>{detail.components.map((c:any)=><tr key={c.id}><td>{c.name}<small>{c.id} · {c.engine||c.kind}</small></td><td>{c.version}</td><td>{c.current_version}{c.changed&&<em>版本已变化,历史任务不变</em>}</td><td>{c.conditions?.map((rule:any,i:number)=><p key={i}>{rule.field} {rule.operator} {rule.threshold}(触发问题)</p>)}<code>{c.content_hash?.slice(0,20)}…</code></td></tr>)}</tbody></table></div>
<h3>检查结果与数据来源</h3><div className="oc-checks">{(run.checks||[]).map((c:any,i:number)=><details key={c.code+String(i)}><summary><Badge value={c.result}/>{c.title}</summary><p>{c.suggested_action}</p><p>规则依据:{c.basis_ids?.join("、")}</p>{(run.evidence||[]).filter((e:any)=>c.evidence_ids?.includes(e.id)).map((e:any)=><p key={e.id}>{e.source} · {e.locator} · 值:{fmt(e.value)}<small>来源版本 {fmt(e.source_revision)} · {e.id}</small></p>)}</details>)}</div>
<h3>与同项目历史运行对比</h3><select aria-label="选择对比运行" value={compareId} onChange={e=>setCompareId(e.target.value)}><option value="">选择一个历史版本</option>{overview?.runs.filter((r:any)=>r.project_id===run.project_id&&r.id!==run.id).map((r:any)=><option key={r.id} value={r.id}>{stamp(r.created_at)} · {r.id.slice(-8)}</option>)}</select>
{comparison&&<><p className="oc-note">所选历史 → 当前选中任务。覆盖率:{fmt(comparison.run.summary?.coverage)} → {fmt(run.summary?.coverage)};未执行的检查不会被视作通过。</p><div className="oc-table-wrap"><table><thead><tr><th>检查项</th><th>所选历史</th><th>当前任务</th></tr></thead><tbody>{differences.map(d=><tr key={d.code}><td>{d.after?.title||d.before?.title}</td><td><Badge value={d.before?.result||"skipped"}/></td><td><Badge value={d.after?.result||"skipped"}/></td></tr>)}</tbody></table></div></>}
<details className="oc-events"><summary>执行事件 · {detail.events.length} 条</summary>{detail.events.map((e:any)=><p key={e.seq}>{stamp(e.time)} · {e.action} · {e.detail.name||""}</p>)}</details>
</section>}
{view==="trace"&&!detail&&<p className="oc-note">点击上方任务,查看当次冻结的配置版本、检查结果、SQL 行级来源及历史对比。</p>}
</>}
{view==="lab"&&(domain!=="lab"?<section className="oc-card oc-empty"><Database size={32}/><h2>演示操作只在隔离库执行</h2><p>不会修改项目工作区已有数据。</p><button className="primary" onClick={()=>setDomain("lab")}>切换到隔离演示库</button></section>:<>
<section className="oc-demo-intro"><div><p className="oc-eyebrow">MINIMAL DATA / REAL EXECUTION</p><h2>一个项目,演示完整的数据变化</h2><p>SQLite 原始记录 → 人工确认同步 → 冻结快照 → Skills / 规则 → 问题与差异</p></div><Database size={42}/></section>
<div className="oc-workspace"><section className="oc-card oc-source"><div className="oc-card-head"><h2>01 / 编辑演示源</h2><small>{source?.database} · 版本 {source?.project.revision??"—"}</small></div><p className="oc-note">1 条项目、1 条财务、3 条设备(其中 1 条为重复记录)。万元 / 人民币 / 2025 年 / 合并口径。</p>
{source&&<><div className="oc-form-grid">{[["amount","融资金额(万元)"],["liabilities","财报负债(万元)"],["dd_liabilities","尽调负债(万元)"],["cashflow","经营现金流(空白表示缺失)"]].map(([key,label])=><label key={key}>{label}<input aria-label={label} type="number" step="any" disabled={!admin||busy} value={form[key]??""} onChange={e=>edit(key,e.target.value)}/></label>)}</div><div className="oc-checkboxes"><label><input type="checkbox" disabled={!admin||busy} checked={!!form.duplicate} onChange={e=>edit("duplicate",e.target.checked)}/>设备重复记录参与读取</label><label><input type="checkbox" disabled={!admin||busy} checked={!!form.enabled} onChange={e=>edit("enabled",e.target.checked)}/>数据源启用(取消可演示读取失败)</label></div>
<div className="oc-table-wrap"><table><thead><tr><th>源表行 ID</th><th>设备编号</th><th>设备价值</th><th>参与读取</th></tr></thead><tbody>{source.assets.map((a:any)=><tr key={a.row_id}><td>{a.row_id}</td><td>{a.asset_key}</td><td>{a.value}</td><td>{a.active?"是":"否"}</td></tr>)}</tbody></table></div>
<div className="oc-actions"><button className="primary" disabled={!admin||busy||!dirty} onClick={()=>act(async()=>{await api("/lab/source",form);await loadSource();setNotice("已写入 SQLite 源表,尚未重新检查。点击“同步并检查”才采用这批数据。");})}>保存源数据</button><button disabled={busy} onClick={()=>act(loadSource)}><RefreshCw size={14}/>重新读取源表</button></div><p className="oc-note">源版本 {source.project.revision} / 已同步版本 {fmt(source.synced_revision)} {dirty?"· 有未保存修改":""}</p></>}
</section><aside className="oc-card oc-recipe"><h2>最小演示步骤</h2><ol><li><b>先检查</b><p>5000 万、负债不一致、缺现金流、设备重复:预期 4 项待处理。</p></li><li><b>补正两处</b><p>尽调负债改为 13200,取消设备重复,保存并检查:预期 2 项。</p></li><li><b>补齐并调方案</b><p>现金流填 900,融资金额改 4000:覆盖率 1.08 → 1.35,预期 0 项。</p></li><li><b>切换规则</b><p>阈值从 120% 改为 140%,重新检查:覆盖率问题重新出现。</p></li></ol><p className="oc-note">以上是固定演示条件,不是审批结论。不会一键重置当前数据。</p></aside></div>
<section className="oc-card oc-source"><div className="oc-card-head"><h2>02 / 配置与真实执行</h2><small>固定 4 个确定性 Skills,模型步骤可选</small></div><div className="oc-skill-pills">{["财务一致性","现金流完整性","设备去重","价值覆盖率"].map(s=><span key={s}><CheckCircle2 size={15}/>{s}</span>)}</div><div className="oc-form-grid"><label>演示规则版本<select aria-label="演示规则版本" disabled={!admin||busy||!source||dirty} value={source?.release.version_id||"lab.coverage@1.0.0"} onChange={e=>act(async()=>{await api("/lab/rule",{version:e.target.value.split("@")[1],revision:source.release.revision});await loadSource();setNotice("演示规则已切换,仅影响下一次运行。历史依据不变。");})}><option value="lab.coverage@1.0.0">v1.0.0 · 低于 120% 提示问题</option><option value="lab.coverage@2.0.0">v2.0.0 · 低于 140% 提示问题</option></select></label><label>本次要求(仅真实模型模式)<input aria-label="本次自然语言要求" disabled={!model||busy} placeholder="例如:重点核对现金流,列明未验证事项" value={requirement} onChange={e=>setRequirement(e.target.value)}/></label></div><label className="oc-model"><input type="checkbox" checked={model} disabled={busy} onChange={e=>setModel(e.target.checked)}/>附加原生 risk_evidence Skill 与真实模型核查(需内网模型配置,失败会如实显示)</label><div className="oc-actions"><button className="primary" disabled={busy||!admin||!source||dirty} onClick={submit}><Play size={16}/>{busy?"处理中…":"确认同步并检查"}</button><small>默认不调用模型、不生成模拟思考过程。</small></div></section>
<section className="oc-card oc-source"><div className="oc-card-head"><div><h2>03 / 隔离回归验收</h2><p className="oc-note">临时库中执行四组固定案例,比较真实检查码与覆盖率。不改本页数据。</p></div><button disabled={busy||!admin} onClick={()=>act(async()=>{const result=await api("/lab/evaluate",{});setNotice(result.passed?"四组隔离回归验收通过,结果已留存。":"验收发现差异,请查看结果。");})}><FlaskConical size={16}/>{busy?"处理中…":"运行四组验收"}</button></div>{overview?.evaluations?.map((e:any)=><details className="oc-evaluation" key={e.id} open={e===overview.evaluations[0]}><summary><Badge value={e.passed?"pass":"failed"}/>{stamp(e.created_at)} · {e.duration_ms} ms</summary><p className="oc-note">{e.scope}</p><div className="oc-table-wrap"><table><thead><tr><th>案例</th><th>预期 / 实际问题项</th><th>预期 / 实际覆盖率</th><th>结果</th></tr></thead><tbody>{e.cases.map((c:any)=><tr key={c.name}><td>{c.name}<small>规则 {c.rule_version}</small></td><td>{c.expected_codes.length} / {c.actual_codes.length}<details><summary>检查码对照</summary><p>预期:{c.expected_codes.join("、")||"无"}</p><p>实际:{c.actual_codes.join("、")||"无"}</p></details></td><td>{c.expected_coverage} / {fmt(c.actual_coverage)}</td><td><Badge value={c.passed?"pass":"failed"}/></td></tr>)}</tbody></table></div></details>)}{!overview?.evaluations?.length&&<p className="oc-note">尚无验收记录,不能据此声明通过。</p>}</section>
</>)}
{view==="health"&&<section className="oc-card oc-source"><div className="oc-card-head"><h2>系统自检</h2>{admin&&<button disabled={busy} onClick={()=>act(async()=>setHealth(await api("/health")))}><RefreshCw size={15}/>重新检查</button>}</div>{!admin?<p className="oc-note">仅管理员可查看系统诊断,项目资料权限另行校验。</p>:<><p className="oc-note">本次检测 {stamp(health?.time)};结果是检查时点的状态,不是持续可用性承诺。</p>{health?.checks.map((c:any)=><article className="oc-health" key={c.name}><div><h3>{c.name}</h3><p>{c.detail}</p></div><Badge value={c.status}/></article>)}</>}</section>}
<footer className="oc-footer">兴邦金租项目助手 · 本地演示 / 运行追溯 · 原四 Agent 能力保留在兼容入口,未直接删除</footer>
</main>
</div>;
}