feat: 完成项目助手与监控演示并修复聊天及云部署配置
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import {NextRequest, NextResponse} from "next/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
const allowed = new Set(["chat", "sessions", "files", "skills", "tokens", "config", "xingbang"]);
|
||||
|
||||
async function proxy(request:NextRequest, {params}:{params:{path:string[]}}) {
|
||||
// General chat has filesystem/tools, unlike the scoped project APIs: local only.
|
||||
if (!["localhost","127.0.0.1","::1","[::1]"].includes(request.nextUrl.hostname))
|
||||
return NextResponse.json({detail:"原始开发聊天仅允许本机访问"},{status:403});
|
||||
if (!allowed.has(params.path[0]) || params.path.some(p=>!p || p==="." || p===".." || /[\\/]/.test(p)))
|
||||
return NextResponse.json({detail:"接口不存在"},{status:404});
|
||||
const base = process.env.CORE_BACKEND_URL || process.env.RISK_AGENT_BACKEND_URL || "http://127.0.0.1:8002";
|
||||
try {
|
||||
const upstream = await fetch(`${base}/api/${params.path.map(encodeURIComponent).join("/")}${request.nextUrl.search}`, {
|
||||
method:request.method,headers:{"Content-Type":request.headers.get("content-type")||"application/json"},
|
||||
body:["GET","HEAD"].includes(request.method)?undefined:await request.arrayBuffer(),
|
||||
cache:"no-store",redirect:"error",signal:request.signal,
|
||||
});
|
||||
const headers = new Headers({"Content-Type":upstream.headers.get("content-type")||"application/json", "Cache-Control":"no-cache, no-transform", "X-Accel-Buffering":"no"});
|
||||
// Never arrayBuffer()/text() an SSE response: pass through chunks immediately.
|
||||
return new NextResponse(upstream.body,{status:upstream.status,headers});
|
||||
} catch {
|
||||
return NextResponse.json({detail:"当前聊天后端未连接,请检查本地服务"},{status:502});
|
||||
}
|
||||
}
|
||||
export {proxy as GET, proxy as POST, proxy as PUT, proxy as DELETE};
|
||||
@@ -4,7 +4,7 @@ async function proxy(request: NextRequest, {params}: {params:{path:string[]}}) {
|
||||
const base=process.env.RISK_AGENT_BACKEND_URL || "http://127.0.0.1:8002";
|
||||
try {
|
||||
const response=await fetch(`${base}/api/xingbang/risk-agent/${params.path.map(encodeURIComponent).join("/")}${request.nextUrl.search}`,{
|
||||
method:request.method,headers:{"Content-Type":request.headers.get("content-type")||"application/json", "Authorization":request.headers.get("authorization")||"", "X-Xingbang-Client-Host":request.headers.get("host")||""},
|
||||
method:request.method,headers:{"Content-Type":request.headers.get("content-type")||"application/json", "Authorization":request.headers.get("authorization")||"", "X-Xingbang-Client-Host":request.headers.get("host")||"", "Idempotency-Key":request.headers.get("idempotency-key")||""},
|
||||
body:["GET","HEAD"].includes(request.method)?undefined:await request.arrayBuffer(),cache:"no-store",
|
||||
});
|
||||
const headers=new Headers({"Content-Type":response.headers.get("content-type")||"application/json"});
|
||||
|
||||
@@ -2,3 +2,5 @@
|
||||
@media(max-width:760px){.ra-heading{flex-wrap:wrap}.ra-heading>.ra-button{white-space:nowrap;min-width:128px}.ra-heading>div{flex-basis:100%}}
|
||||
.ra-rule-row,.ra-review-row{padding:20px 0;border-bottom:1px solid #dfe5e9;margin-bottom:18px}.ra-review-row h3{font-weight:600;font-size:15px}.ra-rule-row .ra-field{margin-bottom:12px}
|
||||
.ra-alignment{margin:24px 0}.ra-alignment h3{font-size:14px;font-weight:600;margin-bottom:18px}
|
||||
.xb-project-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:16px}.xb-project-card{text-align:left;border:1px solid #dfe7e2;background:#fff;border-radius:10px;padding:22px;cursor:pointer;color:#334b3a}.xb-project-card:hover{border-color:#82ad91;box-shadow:0 7px 24px rgba(32,92,59,.08)}.xb-project-card>span{font-size:10px;color:#34805a;background:#e8f4ec;padding:4px 7px;border-radius:12px}.xb-project-card h2{font-size:17px;margin:18px 0 8px}.xb-project-card p{font-size:11px;color:#829087}.xb-project-card footer{display:flex;align-items:center;gap:10px;border-top:1px solid #edf1ee;margin-top:20px;padding-top:15px}.xb-project-card footer small{margin-left:auto}.xb-back{border:0;background:transparent;color:#47755a;cursor:pointer;margin-bottom:22px}.xb-tabs{display:flex;gap:4px;border-bottom:1px solid #dfe6e1;margin-bottom:22px;overflow:auto}.xb-tabs button{border:0;background:transparent;padding:13px 17px;color:#788a7e;white-space:nowrap;cursor:pointer}.xb-tabs button.active{color:#17633f;border-bottom:2px solid #17633f}.xb-cards{display:grid;grid-template-columns:repeat(3,1fr);gap:15px}.xb-cards article,.xb-panel{background:#fff;border:1px solid #e0e7e2;border-radius:8px;padding:22px}.xb-cards b,.xb-cards small{display:block;color:#829087;font-size:11px}.xb-cards strong{display:block;color:#225f41;font-size:25px;margin:15px 0}.xb-panel{max-width:920px}.xb-panel h2{font-size:18px;margin-bottom:18px}.xb-panel>p{font-size:12px;color:#687b6e;line-height:1.8;margin-bottom:18px}.xb-finding{display:flex;gap:14px;padding:16px 0;border-bottom:1px solid #edf1ee}.xb-finding:last-of-type{margin-bottom:20px}.xb-finding b{font-size:13px}.xb-finding p{font-size:12px;margin:7px 0;color:#627367}.xb-finding small{font-size:10px;color:#94a097}.xb-status{height:23px;white-space:nowrap;font-size:10px;color:#a05e19;background:#fff2df;padding:5px 8px;border-radius:12px}.xb-status.ok{color:#29734c;background:#e6f4eb}.xb-compare{display:flex;align-items:center;gap:20px;margin-top:24px;padding:22px;background:#f3f8f4;border-radius:8px}.xb-compare strong{font-size:27px;color:#276947}.xb-compare small{margin-left:auto;max-width:260px;color:#7b887f}.xb-report{margin-top:20px;border-top:1px solid #e3e9e5;padding-top:20px}.xb-report>small{color:#a36d2d}.xb-report article{margin-top:18px}.xb-report h3{font-size:14px;margin-bottom:8px}.xb-report pre{white-space:pre-wrap;font:12px/1.8 Arial,"Microsoft YaHei",sans-serif;color:#53645a}@media(max-width:760px){.xb-cards{grid-template-columns:1fr}.xb-compare{align-items:flex-start;flex-wrap:wrap}.xb-compare small{margin-left:0;width:100%}}
|
||||
.xb-wide{max-width:none;margin-top:20px}.xb-task{background:#fff;border:1px solid #dfe6e1;border-radius:8px;padding:20px;margin:15px 0 22px}.xb-task .ra-field{margin-bottom:12px}.xb-task small{display:block;color:#66776d;margin-top:12px}.xb-actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:14px 0}.xb-actions input,.xb-actions select,.xb-panel>select,.xb-report>select{background:#fff;border:1px solid #dbe1e7;border-radius:5px;padding:9px 11px;font-size:12px;max-width:100%}.xb-form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px 20px;margin-top:20px}.xb-grow{flex:1;min-width:0}.xb-panel pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:450px;overflow:auto;font:12px/1.7 Consolas,"Microsoft YaHei",monospace;background:#f6f8f7;padding:14px;margin:10px 0}.xb-panel details{font-size:12px;line-height:1.8;margin:12px 0}.xb-panel summary{cursor:pointer;color:#345e49}.xb-panel h3{font-size:15px;font-weight:600;margin:20px 0 12px}.xb-panel p{font-size:12px;line-height:1.8;overflow-wrap:anywhere;margin:8px 0}.xb-panel a{color:#17634c;text-decoration:underline}.xb-step{border-bottom:1px solid #e5ece7;padding:10px 0}.xb-warning{color:#9a5a16}.xb-document{border-top:1px solid #dfe6e1;margin-top:20px;padding-top:12px}.xb-section-text{width:100%;min-height:170px;border:1px solid #dbe1e7;padding:14px;font:13px/1.8 Arial,"Microsoft YaHei",sans-serif;border-radius:5px;resize:vertical}.xb-section-text:disabled{color:#526258;background:#f5f7f5}.xb-scenario{border-top:1px solid #e5ece7;padding:15px 0}.xb-finding{flex-wrap:wrap}.xb-finding small{overflow-wrap:anywhere}.xb-config-list .xb-finding>div{flex:1;min-width:250px}@media(max-width:760px){.xb-form-grid{grid-template-columns:1fr}.xb-task{padding:14px}}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,103 @@
|
||||
"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>;
|
||||
}
|
||||
@@ -1,12 +1,83 @@
|
||||
"use client";
|
||||
import {useEffect,useState} from "react";
|
||||
import {ArrowRight,RefreshCw,ShieldCheck} from "lucide-react";
|
||||
import AgentNav,{projectLink} from "@/components/xingbang/AgentNav";
|
||||
import {useEffect, useRef, useState} from "react";
|
||||
import {ArrowRight, FileText, RefreshCw, ShieldCheck} from "lucide-react";
|
||||
import AgentNav from "@/components/xingbang/AgentNav";
|
||||
import AssistantConfiguration from "@/components/xingbang/AssistantConfiguration";
|
||||
import AssistantHistory from "@/components/xingbang/AssistantHistory";
|
||||
import "../data-access/workspace.css";
|
||||
|
||||
export default function XingbangWorkspace(){
|
||||
const [data,setData]=useState<any[]>([]),[search,setSearch]=useState(""),[error,setError]=useState(""),[loading,setLoading]=useState(true);
|
||||
async function load(){setLoading(true);setError("");try{const token=sessionStorage.getItem("xingbang-access-token");const r=await fetch("/api/risk-agent/workspace/projects",{headers:token?{Authorization:`Bearer ${token}`}:{}});const d=await r.json();if(!r.ok)throw Error(d.detail);setData(d.projects);}catch(e:any){setError(e.message);}finally{setLoading(false);}}
|
||||
useEffect(()=>{load();},[]);
|
||||
return <div className="ra-app"><AgentNav active="overview"/><main className="xb-overview"><div className="xb-overview-heading"><div><h1>兴邦金租 · 项目工作台</h1><p>项目总览</p></div><a className="ra-button primary" href="/data-access?view=run"><ShieldCheck size={16}/>发起风险研判</a></div>{error&&<div role="alert" className="ra-alert error">{error}<a href="/data-access">身份设置</a></div>}<div className="xb-overview-stat"><span><strong>{data.length}</strong>项目</span><span><strong>{data.filter(p=>p.coordination&&p.coordination.stage!=="已完成").length}</strong>评审在办</span><span><strong>{data.filter(p=>p.collateral?.stale).length}</strong>核验待更新</span><span><strong>{data.filter(p=>p.report?.status==="final").length}</strong>报告已定稿</span></div><div className="xb-overview-tools"><input aria-label="搜索项目" placeholder="项目编号 / 客户名称" value={search} onChange={e=>setSearch(e.target.value)}/><button className="ra-button" title="刷新项目" disabled={loading} onClick={load}><RefreshCw size={15}/></button></div><div className="xb-table-wrap"><table className="xb-project-table"><thead><tr><th>项目 / 客户</th><th>01 风险研判</th><th>02 报告与经验</th><th>03 评审协同</th><th>04 租赁物与增信</th></tr></thead><tbody>{data.filter(p=>(p.project_id+" "+p.risk?.customer).toLowerCase().includes(search.toLowerCase())).map(p=><tr key={p.project_id}><td><b>{p.project_id}</b><small>{p.risk?.customer||"客户资料待接入"}</small></td><td><a href={projectLink("/data-access",p.project_id)}>{p.risk?`${p.risk.findings} 项关注`:"开始研判"}<ArrowRight size={12}/></a><small>{p.risk?.time?new Date(p.risk.time).toLocaleString():"暂无快照"}</small></td><td><a href={projectLink("/report-workbench",p.project_id)}>{p.report?(p.report.status==="final"?"已定稿":"待复核草稿"):"建立报告"}<ArrowRight size={12}/></a><small>{p.report?`版本 ${p.report.version}`:"尚未建立"}</small></td><td><a href={projectLink("/review-workbench",p.project_id)}>{p.coordination?.stage||"接入评审"}<ArrowRight size={12}/></a><small>{p.coordination?`${p.coordination.owner} · ${p.coordination.open_tasks} 项补件`:"尚未分配"}</small></td><td><a href={projectLink("/collateral-workbench",p.project_id)}>{!p.collateral?"开始核验":p.collateral.stale?"需重新核验":`${p.collateral.open_checks} 项待落实`}<ArrowRight size={12}/></a><small>{p.collateral?.has_result?"已保存核验快照":"尚无核验结果"}</small></td></tr>)}</tbody></table></div>{!data.length&&<div className="ra-empty">{loading?"正在读取项目…":"暂无项目研判记录"}</div>}</main></div>;
|
||||
const tabs = [["overview", "概览"], ["findings", "材料与问题"], ["scenario", "方案比较"], ["report", "报告底稿"], ["experience", "反馈与经验"]];
|
||||
const statuses: Record<string, string> = {queued: "已排队", running: "检查中", completed: "已完成", partial: "部分完成", failed: "未完成", cancelled: "已取消", skipped: "未执行", draft: "草稿", published: "已发布", rejected: "已拒绝", rolled_back: "已停用", stale: "待更新", finalized: "已定稿"};
|
||||
const fields = [["operating_cashflow", "经营现金流(万元)"], ["due_diligence_liabilities", "尽调负债(万元)"], ["financial_liabilities", "财报负债(万元)"], ["duplicate_asset_count", "重复设备数量"], ["collection_evidence", "回款依据"], ["collection_dependency", "是否依赖特定回款"]];
|
||||
const categories = [["rule_omission", "检查遗漏"], ["false_positive", "误报"], ["judgment", "专业判断"], ["process", "流程建议"], ["expression", "表达修改"], ["factual", "事实纠正"], ["data", "数据纠正"]];
|
||||
|
||||
export default function XingbangWorkspace() {
|
||||
const [items, setItems] = useState<any[]>([]), [project, setProject] = useState<any>(null), [identity, setIdentity] = useState<any>(null);
|
||||
const [tab, setTab] = useState("overview"), [error, setError] = useState(""), [notice, setNotice] = useState(""), [busy, setBusy] = useState(false), [search, setSearch] = useState("");
|
||||
const [requirement, setRequirement] = useState(""), [plan, setPlan] = useState("corporate.default"), [runId, setRunId] = useState(""), [document, setDocument] = useState<any>(null);
|
||||
const [field, setField] = useState(fields[0][0]), [value, setValue] = useState(""), [reason, setReason] = useState(""), [amount, setAmount] = useState("4000");
|
||||
const [template, setTemplate] = useState("template.due-diligence"), [reportId, setReportId] = useState(""), [sectionEdits, setSectionEdits] = useState<Record<string, string>>({});
|
||||
const [category, setCategory] = useState("rule_omission"), [feedbackNote, setFeedbackNote] = useState(""), [feedbackId, setFeedbackId] = useState("");
|
||||
const [experience, setExperience] = useState({title: "", trigger: "", action: "", exceptions: "", scope: "project", team: "", industry: "", required_field: "collection.evidence", exception_field: "collection.evidence", expires_at: "2027-09-11", condition_field: "collection.dependency", condition_value: "true"});
|
||||
const selected = useRef("");
|
||||
const headers = (json = true) => {const token = sessionStorage.getItem("xingbang-access-token"); return {...(json ? {"Content-Type": "application/json"} : {}), ...(token ? {Authorization: `Bearer ${token}`} : {})};};
|
||||
async function request(path: string, body?: any) {
|
||||
const response = await fetch(`/api/risk-agent/project-assistant${path}`, {method: body === undefined ? "GET" : "POST", headers: {...headers(), ...(/\/(runs|reports)$/.test(path) && body ? {"Idempotency-Key": crypto.randomUUID()} : {})}, body: body === undefined ? undefined : JSON.stringify(body)});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw Error(typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail || "操作失败"));
|
||||
return data;
|
||||
}
|
||||
async function open(id: string) {
|
||||
selected.current = id;
|
||||
const data = await request(`/projects/${encodeURIComponent(id)}`);
|
||||
if (selected.current !== id) return;
|
||||
setProject(data); history.replaceState({}, "", `?project=${encodeURIComponent(id)}`);
|
||||
}
|
||||
async function load() {try {const data = await request("/projects"); setItems(data.projects); setIdentity(data.identity); const id = new URLSearchParams(location.search).get("project"); if (id) await open(id);} catch (e: any) {setError(e.message);}}
|
||||
async function act(path: string, body: any, message = "操作已记录") {
|
||||
setBusy(true); setError(""); setNotice("");
|
||||
try {const result = await request(path, body); if (selected.current) await open(selected.current); setNotice(message); return result;}
|
||||
catch (e: any) {setError(e.message); return null;} finally {setBusy(false);}
|
||||
}
|
||||
async function download(path: string, name: string) {
|
||||
try {const r = await fetch(`/api/risk-agent/project-assistant${path}`, {headers: headers(false)}); if (!r.ok) throw Error((await r.json()).detail || "下载失败"); const url = URL.createObjectURL(await r.blob()); const a = window.document.createElement("a"); a.href = url; a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(url), 10000);} catch (e: any) {setError(e.message);}
|
||||
}
|
||||
useEffect(() => {load();}, []);
|
||||
const running = project?.runs?.some((r: any) => ["queued", "running"].includes(r.status));
|
||||
useEffect(() => {if (!running || !project?.id) return; const id = project.id; const timer = setInterval(() => {if (selected.current === id) open(id).catch((e: any) => setError(e.message));}, 1500); return () => clearInterval(timer);}, [project?.id, running]);
|
||||
const canConfigure = identity?.preview || ["admin", "reviewer"].includes(identity?.role);
|
||||
const canReview = identity?.preview || identity?.role === "reviewer";
|
||||
function back() {selected.current = ""; setProject(null); setDocument(null); setRunId(""); setReportId(""); setFeedbackId(""); setSectionEdits({}); history.replaceState({}, "", "/xingbang-workbench"); load();}
|
||||
const alerts = <>{error && <div role="alert" className="ra-alert error">{error}</div>}{notice && <div role="status" className="ra-alert">{notice}</div>}</>;
|
||||
if (!project) return <div className="ra-app"><AgentNav active="overview"/><main className="xb-overview"><div className="xb-overview-heading"><div><h1>兴邦金租 · 对公项目助手</h1><p>项目检查、补件复核、融资比较、底稿与经验复用</p></div><button className="ra-button" onClick={load}><RefreshCw size={15}/>刷新</button></div>{alerts}<div className="xb-actions"><input aria-label="搜索项目" placeholder="项目编号 / 客户名称" value={search} onChange={e => setSearch(e.target.value)}/>{canConfigure && <button className="ra-button" onClick={() => setTab(tab === "configuration" ? "overview" : "configuration")}>组件配置与发布</button>}</div>{tab === "configuration" && canConfigure ? <AssistantConfiguration request={request} canEdit={identity?.preview || identity?.role === "admin"}/> : <div className="xb-project-grid">{items.filter(x => (x.id + x.customer).toLowerCase().includes(search.toLowerCase())).map(x => <button className="xb-project-card" key={x.id} onClick={() => {setTab("overview"); open(x.id).catch(e => setError(e.message));}}><span>{x.data_domain === "demo" ? "演示项目" : "正式项目"}</span><h2>{x.customer}</h2><p>{x.id} · {x.industry} · {x.stage}</p><footer><b>{x.open_findings} 项待处理</b><small>{statuses[x.latest_run?.status] || "尚未检查"}</small><ArrowRight size={16}/></footer></button>)}</div>}</main></div>;
|
||||
const base = `/projects/${encodeURIComponent(project.id)}`;
|
||||
const chosenRun = project.runs.find((r: any) => r.id === runId) || project.runs[0];
|
||||
const chosenReport = project.reports.find((r: any) => r.id === reportId) || project.reports[0];
|
||||
const finished = project.runs.filter((r: any) => ["completed", "partial"].includes(r.status));
|
||||
const currentRun = finished[0];
|
||||
const reportSourceRun = finished.find((r: any) => r.id === runId) || currentRun;
|
||||
const openFindings = project.findings.filter((f: any) => !["已解决", "不适用"].includes(f.status));
|
||||
const evidence = project.runs.flatMap((r: any) => r.evidence || []);
|
||||
async function upload(file: File) {
|
||||
setBusy(true); setError("");
|
||||
try {const form = new FormData(); form.append("file", file); const response = await fetch(`/api/risk-agent/project-assistant${base}/documents`, {method: "POST", headers: headers(false), body: form}); const data = await response.json(); if (!response.ok) throw Error(data.detail || "上传失败"); setDocument(data); await open(project.id); setNotice("材料已解析;请核对候选字段后确认,尚未自动修改项目事实。");} catch (e: any) {setError(e.message);} finally {setBusy(false);}
|
||||
}
|
||||
async function reportAction(action: string, payload: any = {}) {if (["regenerate", "finalize"].includes(action) && Object.keys(sectionEdits).length) {setError("请先保存尚未提交的章节修改"); return;} const saved = await act(`${base}/reports/${chosenReport.id}/actions`, {action, revision: chosenReport.revision, payload}); if (saved && payload.section_id) setSectionEdits(previous => {const next = {...previous}; delete next[payload.section_id]; return next;});}
|
||||
return <div className="ra-app"><AgentNav active="overview" project={project.id}/><main className="xb-overview">
|
||||
<button className="xb-back" onClick={back}>← 返回项目列表</button><div className="xb-overview-heading"><div><h1>{project.customer}</h1><p>{project.id} · {project.industry} · {project.stage} · {project.data_domain === "demo" ? "演示数据" : "正式数据"}</p></div><button className="ra-button" onClick={() => open(project.id)}><RefreshCw size={15}/>刷新</button></div>{alerts}
|
||||
<section className="xb-task"><label className="ra-field"><span>本次检查要求(可选,不改变正式规则)</span><textarea value={requirement} onChange={e => setRequirement(e.target.value)} maxLength={2000} placeholder="例如:重点核对现金流与回款依据,说明哪些材料还需要补充"/></label><div className="xb-actions"><select aria-label="业务检查方案" value={plan} onChange={e => setPlan(e.target.value)}>{project.plans.map((p: any) => <option value={p.id} key={p.id}>{p.name} · {p.version}</option>)}</select><button className="ra-button primary" disabled={busy || running} onClick={() => act(`${base}/runs`, {requirement, plan_id: plan, revision: project.revision}, "检查已进入队列;刷新或离开页面不会丢失任务。 ")}><ShieldCheck size={16}/>{running ? "检查进行中" : "检查项目"}</button></div><small>必查项始终执行;语义核查需要已配置的内网模型,失败会明确标出。</small></section>
|
||||
<nav className="xb-tabs">{tabs.map(t => <button key={t[0]} className={tab === t[0] ? "active" : ""} onClick={() => setTab(t[0])}>{t[1]}</button>)}{canConfigure && <button className={tab === "configuration" ? "active" : ""} onClick={() => setTab("configuration")}>配置治理</button>}</nav>
|
||||
{tab === "overview" && <><section className="xb-cards"><article><b>当前数据版本</b><strong>v{project.data_revision}</strong><small>{project.documents.length} 份材料</small></article><article><b>未处理问题</b><strong>{openFindings.length}</strong><small>人工状态与机器结论分别留痕</small></article><article><b>最近运行</b><strong>{statuses[project.runs[0]?.status] || "未检查"}</strong><small>{project.runs[0]?.created_at ? new Date(project.runs[0].created_at).toLocaleString() : "点击检查项目"}</small></article></section>
|
||||
<section className="xb-panel xb-wide"><h2>检查过程与历史</h2>{project.runs.length ? <><select aria-label="选择检查历史" value={chosenRun?.id || ""} onChange={e => setRunId(e.target.value)}>{project.runs.map((r: any) => <option key={r.id} value={r.id}>{new Date(r.created_at).toLocaleString()} · {statuses[r.status]} · {r.id.slice(-8)}</option>)}</select><p>本次要求:{chosenRun.requirement || "无额外要求"}</p><small>快照:{chosenRun.snapshot_id || "尚未完成取数"}</small>{["queued", "running"].includes(chosenRun.status) && <button className="ra-button" onClick={() => act(`${base}/runs/${chosenRun.id}/cancel`, {}, "已取消;迟到结果不会发布。")}>取消本次运行</button>}{chosenRun.error && <p role="alert">{chosenRun.error}</p>}{chosenRun.steps.map((s: any) => <details className="xb-step" key={s.step_id}><summary><span className="xb-status">{statuses[s.status]}</span> {s.name} · {s.skill.version}</summary><p>{s.errors?.map((e: any) => e.message).join(";")}</p>{s.outputs?.requirement_response && <p>{s.outputs.requirement_response}</p>}<pre>{JSON.stringify({outputs: s.outputs, checks: s.findings, input_hash: s.input_hash, trace: s.trace}, null, 2)}</pre></details>)}{chosenRun.data_errors?.map((e: any, i: number) => <p key={i} className="xb-warning">{e.card_id}:{e.message}</p>)}<details><summary>冻结组件版本与变更摘要</summary><pre>{JSON.stringify({versions: chosenRun.component_versions, changes: chosenRun.change_summary, superseded: chosenRun.superseded}, null, 2)}</pre></details></> : <p>尚无运行记录。</p>}</section>
|
||||
<details className="xb-panel xb-wide"><summary>项目事件与兼容历史</summary><AssistantHistory key={project.id} projectId={project.id} records={project.legacy_records} request={request}/>{project.events.map((e: any) => <p key={e.seq}>{new Date(e.time).toLocaleString()} · {e.action} · {e.actor}</p>)}{project.migration_note && <p>{project.migration_note};已保留 {project.legacy_records.length} 条旧记录。</p>}<p><a href={`/review-workbench?project=${encodeURIComponent(project.id)}`}>原评审工作台</a> · <a href={`/report-workbench?project=${encodeURIComponent(project.id)}`}>原报告工作台</a> · <a href={`/collateral-workbench?project=${encodeURIComponent(project.id)}`}>租赁物核验</a></p></details></>}
|
||||
{tab === "findings" && <><section className="xb-panel xb-wide"><h2>材料档案</h2><p>上传后先查看解析结果,确认候选值才更新项目。旧版本与来源保留;扫描件无法识别时会提示补充。</p><label className="ra-field"><span>上传 TXT / CSV / DOCX / XLSX / PDF / 图片(最多 10 MB)</span><input aria-label="上传项目材料" type="file" accept=".txt,.csv,.docx,.xlsx,.pdf,.png,.jpg,.jpeg" disabled={busy} onChange={e => {if (e.target.files?.[0]) upload(e.target.files[0]); e.target.value = "";}}/></label>{project.documents.map((d: any) => <div className="xb-finding" key={d.id}><div><b>{d.name}</b><p>版本 {d.version} · {d.status} · {d.size} 字节</p></div><button className="ra-button" onClick={async () => {try {setDocument(await request(`${base}/documents/${d.id}`));} catch (e: any) {setError(e.message);}}}>查看解析</button><button className="ra-button" onClick={() => download(`${base}/documents/${d.id}/download`, d.name)}>下载原件</button></div>)}
|
||||
{document && <div className="xb-document"><h3>{document.name}</h3>{document.warnings.map((w: string, i: number) => <p className="xb-warning" key={i}>{w}</p>)}<label className="ra-field"><span>来源确认或补件说明</span><input value={reason} onChange={e => setReason(e.target.value)} placeholder="说明已核对的期间、口径和依据"/></label>{document.candidates.map((c: any) => <div className="xb-finding" key={c.id}><div><b>{c.field} = {String(c.value ?? "缺失")}</b><p>{c.locator} · {c.period || "期间未明"} · {c.unit || "单位未明"} · {c.scope || "口径未明"}</p></div><button className="ra-button" disabled={busy || !reason.trim()} onClick={() => act(`${base}/supplements`, {candidate_ids: [c.id], revision: project.revision, reason}, "候选值已确认;请重新检查项目。")}>确认该来源</button></div>)}<details><summary>解析原文与定位</summary>{document.chunks.map((c: any, i: number) => <p key={i}><b>{c.locator}</b> {c.text}{c.formula && <small> 公式:{c.formula};缓存:{c.cached_value || "无"}</small>}</p>)}</details></div>}</section>
|
||||
<section className="xb-panel xb-wide"><h2>问题与补件</h2>{!project.findings.length && <p>尚无问题记录,请先执行检查。</p>}{project.findings.map((f: any) => <article className="xb-finding" key={f.id}><span className={`xb-status ${f.status === "已解决" ? "ok" : ""}`}>{f.status}</span><div className="xb-grow"><b>{f.title}</b><p>{f.suggested_action}</p><small>机器:{f.result} · {f.stale ? "来源待复核" : "已核对本次快照"} · {f.code}</small><details><summary>查看证据、依据与处理历史</summary>{f.evidence_ids.map((id: string) => {const e = evidence.find((r: any) => r.id === id); return <p key={id}>{e ? `${e.source} · ${e.locator} · ${e.field} = ${String(e.value ?? "缺失")}` : id}</p>;})}<p>依据:{f.basis_ids.join("、")}</p>{f.history?.map((h: any, i: number) => <p key={i}>{h.time} · {h.from} → {h.to} · {h.note || h.result}</p>)}</details><div className="xb-actions">{["待补件", "待复核", "不适用"].map(status => <button key={status} className="ra-button" disabled={busy || !reason.trim()} onClick={() => act(`${base}/findings/${f.id}/actions`, {action: status, revision: f.revision, note: reason})}>{status}</button>)}</div></div></article>)}<div className="xb-form-grid"><label className="ra-field"><span>人工补充字段</span><select value={field} onChange={e => setField(e.target.value)}>{fields.map(f => <option key={f[0]} value={f[0]}>{f[1]}</option>)}</select></label><label className="ra-field"><span>确认值(留空表示资料仍缺失)</span><input value={value} onChange={e => setValue(e.target.value)} placeholder={field === "collection_dependency" ? "true 或 false" : "输入实际核对值"}/></label><label className="ra-field"><span>补件 / 问题处理说明</span><input value={reason} onChange={e => setReason(e.target.value)} placeholder="必须填写确认依据"/></label></div><button className="ra-button primary" disabled={busy || !reason.trim()} onClick={() => act(`${base}/supplements`, {changes: {[field]: value === "" ? null : field === "collection_dependency" ? value === "true" ? true : value === "false" ? false : value : value}, finding_ids: [], revision: project.revision, reason}, "补件已保存,原来源已保留;请重新检查确认问题变化。")}>保存补件</button></section></>}
|
||||
{tab === "scenario" && <section className="xb-panel xb-wide"><h2>融资金额情景比较</h2><p>当前金额 {project.facts.facility_amount} 万元,租赁物价值 {project.facts.lease_asset_value} 万元。比较不会修改基线;现金流等其他问题继续保留。120% 仅是演示阈值。</p><div className="xb-actions"><input aria-label="情景融资金额" value={amount} onChange={e => setAmount(e.target.value)}/><span>万元</span><button className="ra-button primary" disabled={busy} onClick={() => act(`${base}/scenarios`, {facility_amount: amount}, "情景已计算,项目基线未改变。")}>计算比较</button></div>{project.scenarios.map((s: any) => <article key={s.id} className="xb-scenario"><div className="xb-compare"><strong>{(Number(s.baseline.coverage) * 100).toFixed(2)}%</strong><ArrowRight/><strong>{(Number(s.scenario.coverage) * 100).toFixed(2)}%</strong><small>融资额 {s.baseline.facility_amount} → {s.scenario.facility_amount} 万元;另有 {s.unresolved.length} 项问题未消除</small></div><p>{s.assumptions.join(";")}</p><label className="ra-field"><span>采纳理由(可选,采纳将改变项目融资金额)</span><input value={reason} onChange={e => setReason(e.target.value)}/></label><button className="ra-button" disabled={busy || !reason.trim() || s.project_revision !== project.revision} onClick={() => act(`${base}/scenarios/${s.id}/adopt`, {action: "adopt", revision: project.revision, note: reason}, "情景已采纳;原版本保留,请重新检查。")}>{s.project_revision === project.revision ? "确认采纳该情景" : "基线已变化,请重新比较"}</button></article>)}</section>}
|
||||
{tab === "report" && <section className="xb-panel xb-wide"><h2>报告底稿</h2><div className="xb-actions"><select aria-label="报告模板" value={template} onChange={e => setTemplate(e.target.value)}><option value="template.due-diligence">尽调草稿</option><option value="template.review">评审底稿</option><option value="template.committee">上会摘要</option></select><select aria-label="报告来源运行" value={reportSourceRun?.id || ""} onChange={e => setRunId(e.target.value)}>{finished.map((r: any) => <option key={r.id} value={r.id}>{new Date(r.created_at).toLocaleString()} · {statuses[r.status]}</option>)}</select><button className="ra-button primary" disabled={busy || !currentRun} onClick={async () => {const r = await act(`${base}/reports`, {run_id: reportSourceRun.id, template_id: template}); if (r) setReportId(r.id);}}><FileText size={15}/>生成底稿</button></div>{chosenReport && <div className="xb-report"><select aria-label="选择底稿版本" value={chosenReport.id} onChange={e => {setReportId(e.target.value); setSectionEdits({});}}>{project.reports.map((r: any) => <option key={r.id} value={r.id}>{r.template.name} · {statuses[r.status]} · {r.id.slice(-8)}</option>)}</select><p>{chosenReport.disclaimer} · {statuses[chosenReport.status]}</p><small>来源快照:{chosenReport.snapshot_id}</small><div className="xb-actions"><button className="ra-button" onClick={() => download(`${base}/reports/${chosenReport.id}/export`, `${project.id}-${chosenReport.template.name}.docx`)}>导出 Word</button><button className="ra-button" disabled={busy || !currentRun || chosenReport.status === "finalized"} onClick={() => reportAction("regenerate", {run_id: currentRun.id})}>按最新运行更新候选</button>{canReview && <button className="ra-button" disabled={busy || chosenReport.status === "finalized"} onClick={() => reportAction("finalize")}>确认定稿</button>}</div>{chosenReport.sections.map((s: any) => <article key={s.id}><h3>{s.title} {s.stale ? "· 来源已过期" : ""} {s.locked ? "· 已锁定" : ""} {s.confirmed ? "· 已确认" : ""}</h3><textarea aria-label={`编辑${s.title}`} className="xb-section-text" disabled={s.locked || ["overview", "findings"].includes(s.id) || chosenReport.status === "finalized"} value={sectionEdits[s.id] ?? s.content} onChange={e => setSectionEdits({...sectionEdits, [s.id]: e.target.value})}/>{chosenReport.status !== "finalized" && <div className="xb-actions"><button className="ra-button" disabled={busy || s.locked || ["overview", "findings"].includes(s.id)} onClick={() => reportAction("section", {section_id: s.id, content: sectionEdits[s.id] ?? s.content, confirmed: false})}>保存修改</button><button className="ra-button" disabled={busy || s.stale} onClick={() => reportAction("section", {section_id: s.id, content: sectionEdits[s.id] ?? s.content, confirmed: true, locked: true})}>确认并锁定</button>{s.locked && <button className="ra-button" disabled={busy} onClick={() => reportAction("section", {section_id: s.id, locked: false, confirmed: false})}>解除锁定</button>}</div>}{s.candidate && <details open><summary>更新候选(未覆盖当前正文)</summary><pre>{s.candidate.content}</pre><button className="ra-button" disabled={busy} onClick={() => reportAction("section", {section_id: s.id, accept_candidate: true})}>采纳候选内容</button></details>}</article>)}</div>}</section>}
|
||||
{tab === "experience" && <section className="xb-panel xb-wide"><h2>专业反馈与经验复用</h2><p>先记录具体纠正,再形成经验草稿。经验需明确适用和例外条件,评测后审核发布;不会自动变成正式制度。</p><div className="xb-form-grid"><label className="ra-field"><span>反馈类别</span><select value={category} onChange={e => setCategory(e.target.value)}>{categories.map(c => <option key={c[0]} value={c[0]}>{c[1]}</option>)}</select></label><label className="ra-field"><span>反馈内容(关联最近一次已结束运行)</span><textarea value={feedbackNote} onChange={e => setFeedbackNote(e.target.value)}/></label></div><button className="ra-button" disabled={busy || !currentRun || !feedbackNote.trim()} onClick={async () => {const f = await act(`${base}/feedback`, {category, note: feedbackNote, target_kind: "run", target_id: currentRun.id}); if (f) setFeedbackId(f.id);}}>记录反馈</button>
|
||||
<div className="xb-actions"><button className="ra-button" disabled={busy || running || !feedbackId} onClick={() => act(`${base}/runs`, {feedback_id: feedbackId, requirement: "根据选定专业反馈提炼经验草稿,不改变正式制度", plan_id: plan, revision: project.revision}, "经验提炼任务已排队;结果仅为本项目草稿,未配置模型时会明确失败。")}>从选定反馈提炼经验草稿</button></div><h3>创建经验草稿</h3><label className="ra-field"><span>来源反馈</span><select value={feedbackId} onChange={e => setFeedbackId(e.target.value)}><option value="">请选择反馈</option>{project.feedback.map((f: any) => <option key={f.id} value={f.id}>{f.note}</option>)}</select></label><div className="xb-form-grid">{[["title", "经验标题"], ["trigger", "适用条件说明"], ["action", "建议核查动作"], ["exceptions", "不适用 / 例外说明"]].map(([key, label]) => <label className="ra-field" key={key}><span>{label}</span><input value={(experience as any)[key]} onChange={e => setExperience({...experience, [key]: e.target.value})}/></label>)}<label className="ra-field"><span>触发条件</span><select value={experience.condition_field} onChange={e => setExperience({...experience, condition_field: e.target.value})}><option value="collection.dependency">依赖特定回款</option></select><select aria-label="触发条件值" value={experience.condition_value} onChange={e => setExperience({...experience, condition_value: e.target.value})}><option value="true">是</option><option value="false">否</option></select></label><label className="ra-field"><span>执行动作与例外</span><p>检查回款依据是否缺失;已有到账依据时不再触发。</p></label><label className="ra-field"><span>范围</span><select value={experience.scope} onChange={e => setExperience({...experience, scope: e.target.value})}><option value="project">仅本项目</option><option value="team">指定团队</option></select></label>{experience.scope === "team" && <label className="ra-field"><span>团队</span><input value={experience.team} placeholder="本机演示可填 demo" onChange={e => setExperience({...experience, team: e.target.value})}/></label>}<label className="ra-field"><span>有效期</span><input type="date" value={experience.expires_at} onChange={e => setExperience({...experience, expires_at: e.target.value})}/></label></div><button className="ra-button primary" disabled={busy || !feedbackId || !experience.title || !experience.trigger || !experience.action || !experience.exceptions} onClick={() => {const {condition_field, condition_value, ...body} = experience; act(`${base}/experiences`, {...body, feedback_id: feedbackId, conditions: {[condition_field]: condition_value === "true"}, expires_at: experience.expires_at + "T23:59:59+08:00"}, "经验草稿已保存,尚未生效。");}}>保存经验草稿</button>
|
||||
{project.experiences.map((x: any) => <article className="xb-panel" key={x.id}><span className="xb-status">{statuses[x.status]}</span><h3>{x.title}</h3><p>适用:{x.trigger};例外:{x.exceptions}</p><p>{x.action} · {x.scope === "team" ? `团队 ${x.team}` : "仅本项目"} · 有效至 {x.expires_at}</p><div className="xb-actions">{x.status === "draft" && <button className="ra-button" disabled={busy} onClick={() => act(`${base}/experiences/${x.id}/actions`, {action: "evaluate", revision: x.revision, note: "隔离留出集评测"})}>评测适用与反例</button>}{canReview && x.status === "draft" && <button className="ra-button" disabled={busy || !x.evaluation?.passed} onClick={() => act(`${base}/experiences/${x.id}/actions`, {action: "publish", revision: x.revision, note: "确认脱敏条件与检查动作后发布"})}>审核发布</button>}{canReview && x.status === "published" && <button className="ra-button" disabled={busy} onClick={() => act(`${base}/experiences/${x.id}/actions`, {action: "rollback", revision: x.revision, note: "停用经验,后续运行不再应用"})}>停用回滚</button>}</div>{x.evaluation && <details><summary>评测结果:{x.evaluation.passed ? "通过" : "未通过"}</summary><pre>{JSON.stringify(x.evaluation, null, 2)}</pre></details>}</article>)}<h3>本项目实际应用记录</h3>{project.experience_applications.length ? project.experience_applications.map((a: any) => <p key={a.id}>{a.experience_version_id} · {a.status} · {a.check_code} · {a.reason}</p>) : <p>尚未应用已发布经验。</p>}</section>}
|
||||
{tab === "configuration" && canConfigure && <AssistantConfiguration request={request} canEdit={identity?.preview || identity?.role === "admin"}/>}
|
||||
</main></div>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user