feat: 完成三角色工作台及金租对公Skills全流程演示

This commit is contained in:
NathanAlex7
2026-09-15 10:32:24 +08:00
parent 5f0b1f9409
commit 47ec090345
58 changed files with 2534 additions and 609 deletions
File diff suppressed because one or more lines are too long
+12 -5
View File
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { Activity, ArrowLeft, ArrowRight, Boxes, Check, CheckCircle2, ChevronRight, CircleDashed, Copy, Database, FileCode2, History, Layers3, Loader2, Pencil, Play, Plus, Search, ShieldCheck, Sparkles, Trash2, Workflow, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Activity, ArrowLeft, ArrowRight, Boxes, Check, CheckCircle2, ChevronRight, CircleDashed, Copy, Database, FileCode2, FolderUp, History, Layers3, Loader2, Pencil, Play, Plus, Search, ShieldCheck, Sparkles, Trash2, Workflow, X } from "lucide-react";
import "./workspace.css";
import {ConnectionFields, AlignmentFields, RuleFields, AccessDialog, Reviews, ResultExtras} from "./Enhancements";
import AgentNav from "@/components/xingbang/AgentNav";
@@ -23,6 +23,7 @@ async function api(path: string, method="GET", body?: unknown) {
const defaults = (kind:Kind): Record<string,unknown> => ({connections:{type:"demo",host:"",database:""},cards:{connection:"conn-erp",sql:"SELECT * FROM finance WHERE project_id = :project_id",mapping:{"financial.total_assets":"assets","financial.total_liabilities":"liabilities"},skills:[],grain:"客户 + 期间 + 口径",unit:"万元",period:"2025",scope:"合并"},skills:{engine:"quality",inputs:["*"],output:"quality.findings",instructions:"识别空值与缺失字段"},rules:{conditions:[]},plans:{cards:[],skills:[],rules:[]}}[kind]);
export default function RiskAgentConsole() {
const folderInput=useRef<HTMLInputElement>(null);
const [catalog,setCatalog]=useState<Catalog>(EMPTY),[view,setView]=useState<Kind|"run"|"history"|"reviews">("cards");
const [search,setSearch]=useState(""),[status,setStatus]=useState("active"),[loading,setLoading]=useState(true),[busy,setBusy]=useState(false),[error,setError]=useState(""),[notice,setNotice]=useState("");
const [editor,setEditor]=useState<{kind:Kind;item:Item;fresh:boolean}|null>(null),[advanced,setAdvanced]=useState(false),[json,setJson]=useState(""),[confirmDelete,setConfirmDelete]=useState<{kind:Kind;item:Item}|null>(null);
@@ -49,17 +50,23 @@ export default function RiskAgentConsole() {
if(publish&&!access.preview)await api(`/${editor.kind}/${saved.id}/submit`,"POST");
await reload();setEditor(null);setNotice(publish?(access.preview?"配置已发布,后续运行使用新版本。":"已提交审核,独立审核员批准后生效。"):"草稿已保存。");
})}
async function importSkills(files:FileList|null){
const selected=Array.from(files||[]).filter(file=>file.name==="SKILL.md");
if(!selected.length){setError("所选文件夹中没有 SKILL.md");return;}
await perform(async()=>{const result=await api("/native-skills/import","POST",{skills:await Promise.all(selected.map(async file=>({skill_md:await file.text()})))});await reload();setNativeSkills(await api("/native-skills/list"));setView("skills");setNotice(`已导入 ${result.count} 个 Skill${access.preview?"并发布":"为待审核草稿"}。`)});
if(folderInput.current)folderInput.current.value="";
}
const active=(kind:Kind)=>catalog[kind].filter(x=>x.status!=="archived");
const title=view==="reviews"?"发布审核":view==="run"?"项目提交前自查":view==="history"?"研判运行记录":META[view as Kind].label;
const subtitle=view==="cards"?"把业务数据封装成可复用的数据卡,连接每一次风险研判。":view==="skills"?"组合专业检查、确定性计算与语义分析能力。":view==="plans"?"选择数据、能力与规则,形成客户经理的一键自查方案。":view==="run"?"项目事实、专业检查与制度规则在同一条证据链上汇合。":view==="rules"?"管理检查条件与核查建议,保留每一个制度版本。":view==="connections"?"统一维护连接配置,由数据卡复用。":view==="reviews"?"核对提交版本,由独立审核员发布或驳回。":"回看数据快照、配置版本、执行结果与证据。";
const items=view in META?catalog[view as Kind].filter(x=>(status==="all"||x.status!=="archived")&&(x.name+" "+x.description).toLowerCase().includes(search.toLowerCase())):[];
return <div className="xb-agent-shell"><AgentNav active="risk" project={project}/><div className="ra-app">
return <div className="xb-agent-shell"><AgentNav active="risk" project={project} identity={access}/><div className="ra-app">
<aside className="ra-sidebar"><a href="/" className="ra-brand"><div className="ra-brand-mark"><Activity size={22}/></div><div>兴邦金租<small>RISK INTELLIGENCE</small></div></a><div className="ra-side-label">客户经理</div><button className={view==="run"?"active":""} onClick={()=>setView("run")}><ShieldCheck size={18}/>项目风险自查</button><button className={view==="history"?"active":""} onClick={()=>{setView("history");perform(async()=>setHistory(await api("/runs/history")))}}><History size={18}/>运行记录</button><div className="ra-side-label">配置管理</div>{(canManage?Object.keys(META) as Kind[]:[]).map(k=>{const Icon=META[k].icon;return <button key={k} className={view===k?"active":""} onClick={()=>{setView(k);setSearch("")}}><Icon size={18}/>{META[k].label}<span>{active(k).length}</span></button>})}{canManage&&<button className={view==="reviews"?"active":""} onClick={()=>{setView("reviews");perform(async()=>setReviewItems(await api("/reviews/list")))}}><ShieldCheck size={18}/>发布审核</button>}<div className="ra-sidebar-bottom"><div className="ra-env-dot"/>本地业务验证环境<small>示例数据 · 演示规则</small><a href="/xingbang-workbench"><ArrowLeft size={14}/>返回项目总览</a></div></aside>
<div className="ra-body"><header className="ra-top"><div>风险研判 Agent <ChevronRight size={14}/><b>{title}</b></div><span className="ra-badge">业务验证版</span><button className="ra-button" onClick={()=>setAccessOpen(true)}>{access.preview?"本机预览":({admin:"配置管理员",manager:"客户经理",reviewer:"发布审核员"} as Record<string,string>)[access.role]}</button></header>
<main className="ra-main"><div className="ra-heading"><div><div className="ra-eyebrow">RISK AGENT / {view.toUpperCase()}</div><h1>{title}</h1><p>{subtitle}</p></div>{canEdit&&view in META&&<button className="ra-button primary" onClick={()=>open(view as Kind)}><Plus size={16}/>新建{META[view as Kind].label}</button>}</div>
<main className="ra-main"><div className="ra-heading"><div><div className="ra-eyebrow">RISK AGENT / {view.toUpperCase()}</div><h1>{title}</h1><p>{subtitle}</p></div>{canEdit&&view in META&&<div className="ra-heading-actions">{view==="skills"&&<><input ref={folderInput} className="ra-folder-input" type="file" multiple {...({webkitdirectory:"",directory:""} as any)} onChange={e=>importSkills(e.target.files)}/><button className="ra-button" onClick={()=>folderInput.current?.click()}><FolderUp size={16}/>从本地文件夹导入</button></>}<button className="ra-button primary" onClick={()=>open(view as Kind)}><Plus size={16}/>新建{META[view as Kind].label}</button></div>}</div>
{error&&<div role="alert" className="ra-alert error">{error}<button title="关闭错误" onClick={()=>setError("")}><X size={16}/></button></div>}{notice&&<div role="status" className="ra-alert">{notice}<button title="关闭提示" onClick={()=>setNotice("")}><X size={16}/></button></div>}
<div className="ra-pipeline">{[{label:"业务数据",sub:`${active("cards").length} 张数据卡`,icon:Database},{label:"专业能力",sub:`${active("skills").length} 项 Skills`,icon:Sparkles},{label:"制度规则",sub:`${active("rules").length} 套规则集`,icon:ShieldCheck},{label:"研判方案",sub:`${active("plans").length} 个方案`,icon:Workflow}].map((x,i)=><div key={x.label} className="ra-pipeline-step"><span className={`ra-stage-icon color-${i}`}><x.icon size={19}/></span><div><b>{x.label}</b><small>{x.sub}</small></div>{i<3&&<ArrowRight className="ra-pipeline-arrow" size={18}/>}</div>)}</div>
{loading?<div className="ra-empty"><Loader2 className="ra-spin"/>正在读取配置…</div>:view in META?<><div className="ra-toolbar"><div className="ra-search"><Search size={16}/><input aria-label="搜索配置" placeholder="搜索名称或业务描述…" value={search} onChange={e=>setSearch(e.target.value)}/></div><select aria-label="状态筛选" value={status} onChange={e=>setStatus(e.target.value)}><option value="active">有效配置</option><option value="all">包含归档</option></select><span>{items.length} 个配置</span></div><div className="ra-card-grid">{items.map(item=>{const kind=view as Kind,Icon=META[kind].icon;return <article className="ra-card" key={item.id}><div className="ra-card-top"><div className={`ra-object-icon ${kind}`}><Icon size={23}/></div><span className={`ra-status ${item.status}`}><i/>{STATUS[item.status]}</span></div><h2>{item.name}</h2><p>{item.description||"尚未填写业务描述"}</p>{kind==="cards"?<><div className="ra-chips"><span>{catalog.connections.find(c=>c.id===item.connection)?.name||"未选择连接"}</span><span>{Object.keys(item.mapping||{}).length} 个字段</span></div><div className="ra-card-detail"><span>记录粒度</span><b>{item.grain||"未定义"}</b></div><div className="ra-card-detail"><span>关联 Skills</span><b>{(item.skills||[]).length} 项</b></div></>:kind==="skills"?<><div className="ra-chips"><span>{{quality:"质量检查",ratio:"确定性计算",overdue:"征信检查",model:"本地模型"}[item.engine as string]||item.engine}</span></div><div className="ra-card-detail"><span>输入依赖</span><b>{(item.inputs||[]).length} 个字段</b></div></>:kind==="plans"?<><div className="ra-plan-counts">{["cards","skills","rules"].map((k,i)=><div key={k}><b>{(item[k]||[]).length}</b><small>{["数据卡","Skills","规则集"][i]}</small></div>)}</div></>:kind==="rules"?<div className="ra-card-detail"><span>配置条件</span><b>{(item.conditions||[]).length} 条</b></div>:<><div className="ra-chips"><span>{item.type==="demo"?"演示数据库":item.type}</span></div><div className="ra-card-detail"><span>连接状态</span><b>{item.type==="demo"?"本地样例可运行":"连接器待接入"}</b></div></>}
{loading?<div className="ra-empty"><Loader2 className="ra-spin"/>正在读取配置…</div>:view in META?<><div className="ra-toolbar"><div className="ra-search"><Search size={16}/><input aria-label="搜索配置" placeholder="搜索名称或业务描述…" value={search} onChange={e=>setSearch(e.target.value)}/></div><select aria-label="状态筛选" value={status} onChange={e=>setStatus(e.target.value)}><option value="active">有效配置</option><option value="all">包含归档</option></select><span>{items.length} 个配置</span></div><div className="ra-card-grid">{items.map(item=>{const kind=view as Kind,Icon=META[kind].icon;return <article className="ra-card" key={item.id}><div className="ra-card-top"><div className={`ra-object-icon ${kind}`}><Icon size={23}/></div><span className={`ra-status ${item.status}`}><i/>{STATUS[item.status]}</span></div><h2>{item.name}</h2><p>{item.description||"尚未填写业务描述"}</p>{kind==="cards"?<><div className="ra-chips"><span>{catalog.connections.find(c=>c.id===item.connection)?.name||"未选择连接"}</span><span>{Object.keys(item.mapping||{}).length} 个字段</span></div><div className="ra-card-detail"><span>记录粒度</span><b>{item.grain||"未定义"}</b></div><div className="ra-card-detail"><span>关联 Skills</span><b>{(item.skills||[]).length} 项</b></div></>:kind==="skills"?<><div className="ra-chips"><span>{{quality:"质量检查",ratio:"确定性计算",overdue:"征信检查",model:"本地模型"}[item.engine as string]||item.engine}</span></div><div className="ra-card-detail"><span>输入依赖</span><b>{(item.inputs||[]).length} 个字段</b></div></>:kind==="plans"?<><div className="ra-plan-counts">{["cards","skills","rules"].map((k,i)=><div key={k}><b>{(item[k]||[]).length}</b><small>{["数据卡","Skills","规则集"][i]}</small></div>)}</div></>:kind==="rules"?<div className="ra-card-detail"><span>配置条件</span><b>{(item.conditions||[]).length} 条</b></div>:<><div className="ra-chips"><span>{item.type==="demo"?"演示数据库":item.type}</span></div><div className="ra-card-detail"><span>连接状态</span><b>{item.type==="demo"?"本地样例可运行":item.type==="SQLite"?"只读业务库已就绪":"只读连接已配置"}</b></div></>}
<footer><span>{item.version}</span><div><button disabled={!canEdit} title="复制为草稿" onClick={()=>{open(kind,{...item,id:"",name:item.name+" 副本",status:"draft"});setEditor(e=>e?{...e,fresh:true}:e)}}><Copy size={15}/></button><button disabled={!canEdit} title="归档配置" onClick={()=>setConfirmDelete({kind,item})}><Trash2 size={15}/></button><button disabled={!canEdit} className="ra-edit" onClick={()=>open(kind,item)}><Pencil size={14}/>配置</button></div></footer></article>})}<button disabled={!canEdit} className="ra-add-card" onClick={()=>open(view as Kind)}><Plus size={27}/><b>添加{META[view as Kind].label}</b><span>扩展你的研判能力</span></button></div></>:view==="run"?<><section className="ra-run-form"><label>项目编号<input value={project} onChange={e=>setProject(e.target.value)}/></label><label>研判方案<select value={plan} onChange={e=>setPlan(e.target.value)}>{active("plans").map(p=><option key={p.id} value={p.id}>{p.name} · {STATUS[p.status]}</option>)}</select></label><button disabled={busy} className="ra-button primary" onClick={()=>perform(async()=>{setPrevious(result);setResult(await api(`/plans/${plan}/run`,"POST",{project_id:project}))})}>{busy?<Loader2 className="ra-spin" size={16}/>:<Play size={16}/>}开始风险自查</button></section><p className="ra-note">示例项目 XB-2026-001。数据库查询与指标计算真实执行;尚未接入兴邦 ERP 与正式制度,本地模型需配置内网端点。</p>{result?<><ResultExtras result={result} previous={previous}/><Results result={result}/></>:<div className="ra-empty"><ShieldCheck size={36}/><h2>从项目事实开始研判</h2><p>选择已发布方案,查看检查结果与来源证据。</p></div>}</>:view==="reviews"?<Reviews items={reviewItems} busy={busy} review={(id,approve,note)=>perform(async()=>{await api(`/reviews/${id}/review`,"POST",{approve,note});setReviewItems(await api("/reviews/list"));await reload();setNotice(approve?"已审核发布":"已驳回")})}/>:<><div className="ra-history">{history.map(r=><button key={r.id} onClick={()=>{setResult(r);setView("run")}}><History size={18}/><div><b>{r.project_id} · {r.plan.name}</b><small>{new Date(r.time).toLocaleString()} · {r.mode}</small></div><span>{r.findings.length} 项关注</span><ChevronRight size={16}/></button>)}</div>{!history.length&&<div className="ra-empty">暂无运行记录</div>}</>}
</main></div>
{editor&&<div className="ra-overlay"><section className="ra-drawer" role="dialog" aria-modal="true" aria-label="配置编辑"><header><div><small>{META[editor.kind].label} / {editor.fresh?"新建":"编辑"}</small><h2>{editor.item.name||"新配置"}</h2></div><button title="关闭编辑" onClick={()=>setEditor(null)}><X size={22}/></button></header><div className="ra-drawer-body">{error&&<div className="ra-alert error">{error}</div>}<div className="ra-two"><Field label="名称"><input value={editor.item.name} onChange={e=>field("name",e.target.value)}/></Field><Field label="版本 / 状态"><input readOnly value={`${editor.item.version} · ${STATUS[editor.item.status]}`}/></Field></div><Field label="业务描述"><textarea value={editor.item.description||""} onChange={e=>field("description",e.target.value)}/></Field>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+40 -97
View File
@@ -1,104 +1,47 @@
"use client";
import {useEffect, useRef, useState} from "react";
import {Activity, ArrowRight, CheckCircle2, Database, FlaskConical, Play, RefreshCw, ShieldCheck, SlidersHorizontal, Square, X} from "lucide-react";
import {useEffect,useMemo,useState} from "react";
import {Activity,ArrowUpRight,Clock3,DatabaseZap,RefreshCw,TimerReset,UsersRound} from "lucide-react";
import AgentNav from "@/components/xingbang/AgentNav";
import {newRequestId} from "@/lib/request-id";
import IdentitySelector from "@/components/xingbang/IdentitySelector";
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"), role=sessionStorage.getItem("xingbang-demo-role");
const response = await fetch(ROOT+path, {method:body===undefined?"GET":"POST", cache:"no-store", headers:{"Content-Type":"application/json", ...(token?{Authorization:`Bearer ${token}`} : {}), ...(role?{"X-Xingbang-Demo-Role":role}:{}), ...(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;
const ROOT="/api/risk-agent/project-assistant";
const CATEGORY:Record<string,{label:string,color:string}>={active:{label:"人员处理",color:"#39c98a"},queue:{label:"排队等待",color:"#7d91ef"},external_wait:{label:"外部等待",color:"#f1a95b"},system:{label:"系统执行",color:"#55b9d6"}};
function headers(){const t=sessionStorage.getItem("xingbang-access-token"),r=sessionStorage.getItem("xingbang-demo-role");return {...(t?{Authorization:`Bearer ${t}`}:{}) ,...(r?{"X-Xingbang-Demo-Role":r}:{})};}
function hours(value:number){return value<1?`${Math.max(1,Math.round(value*60))}分钟`:value<24?`${value.toFixed(1)}小时`:`${(value/24).toFixed(1)}天`;}
function stamp(value:string){return new Date(value).toLocaleString("zh-CN",{month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit"});}
export default function TimeDashboard(){
const [data,setData]=useState<any>(null),[error,setError]=useState(""),[busy,setBusy]=useState(false),[scope,setScope]=useState("all");
async function load(){setBusy(true);setError("");try{const response=await fetch(`${ROOT}/time-dashboard`,{headers:headers()});const body=await response.json();if(!response.ok)throw Error(body.detail||"时效数据读取失败");setData(body);}catch(e:any){setError(e.message)}finally{setBusy(false)}}
useEffect(()=>{load()},[]);
const filterRole=data?.identity?.role==="admin"?scope:data?.identity?.role||"all";
const projects=useMemo(()=>!data?[]:data.projects.map((p:any)=>({...p,events:filterRole==="all"?p.events:p.events.filter((e:any)=>e.role===filterRole||e.role==="system")})).filter((p:any)=>p.events.length),[data,filterRole]);
const view=useMemo(()=>{
const categories:Record<string,number>={},phaseMap:Record<string,any>={};
const totals=projects.map((project:any)=>project.events.reduce((sum:number,item:any)=>sum+item.duration_hours,0)).sort((a:number,b:number)=>a-b);
for(const project of projects)for(const item of project.events){categories[item.category]=(categories[item.category]||0)+item.duration_hours;const phase=phaseMap[item.phase_code]||(phaseMap[item.phase_code]={code:item.phase_code,name:item.phase_name,hours:0,count:0});phase.hours+=item.duration_hours;phase.count+=1;}
const phases=Object.values(phaseMap).map((item:any)=>({...item,average_hours:item.hours/item.count}));
return {categories,phases,summary:{project_count:projects.length,median_hours:totals.length?totals[Math.floor(totals.length/2)]:0,active_hours:categories.active||0,wait_hours:(categories.queue||0)+(categories.external_wait||0),system_hours:categories.system||0}};
},[projects]);
const maxProject=Math.max(1,...projects.map((p:any)=>p.events.reduce((n:number,e:any)=>n+e.duration_hours,0)));
return <div className="td-shell"><AgentNav active="time" identity={data?.identity}/><main className="td-main">
<header className="td-hero"><div><p>BUSINESS VELOCITY</p><h1>业务时效看板</h1><span>把对公业务的处理、等待与系统耗时分开,看清真正的提效空间。</span></div><div className="td-hero-actions"><IdentitySelector identity={data?.identity} dark/>{data?.identity?.role==="admin"&&<label className="td-scope"><span>统计范围</span><select aria-label="统计范围" value={scope} onChange={e=>setScope(e.target.value)}><option value="all">全部角色</option><option value="manager">客户经理</option><option value="reviewer">评审员</option></select></label>}<button onClick={load} disabled={busy}><RefreshCw size={16} className={busy?"spin":""}/>{busy?"更新中":"刷新数据"}</button></div></header>
{error&&<div role="alert" className="td-error">{error}</div>}
{!data?<div className="td-loading"><Activity/>正在读取SQLite时间事件…</div>:<>
<section className="td-kpis">
<Metric icon={UsersRound} label="纳入项目" value={`${view.summary.project_count}`} unit="个" note="仅统计当前身份授权范围"/>
<Metric icon={Clock3} label="中位业务周期" value={hours(view.summary.median_hours)} note="减少极端项目对平均值的干扰"/>
<Metric icon={TimerReset} label="人员处理" value={hours(view.summary.active_hours)} note="当前身份实际办理时间" accent/>
<Metric icon={DatabaseZap} label="排队与外部等待" value={hours(view.summary.wait_hours)} note={`系统执行仅 ${hours(view.summary.system_hours)}`}/>
</section>
<section className="td-insight"><div><p>本期关键观察</p><h2>时间主要消耗在等待与人工闭环,而不是AI运行。</h2></div><div className="td-donut" style={{background:`conic-gradient(#e2a354 0 ${Math.round(view.summary.wait_hours/Math.max(1,view.summary.active_hours+view.summary.wait_hours+view.summary.system_hours)*100)}%,#38c487 0 100%)`}}><strong>{Math.round(view.summary.wait_hours/Math.max(1,view.summary.active_hours+view.summary.wait_hours+view.summary.system_hours)*100)}%</strong><span>等待占比</span></div></section>
<section className="td-grid"><article className="td-panel"><header><div><p>STAGE DISTRIBUTION</p><h2>阶段耗时分布</h2></div><small>自动记录 · 无需人工打卡</small></header><div className="td-phases">{[...view.phases].sort((a:any,b:any)=>b.hours-a.hours).map((p:any)=>{const max=Math.max(...view.phases.map((x:any)=>x.hours),1);return <div className="td-phase" key={p.code}><div><b>{p.name}</b><span>{hours(p.hours)} · {p.count}次</span></div><div className="td-track"><i style={{width:`${Math.max(3,p.hours/max*100)}%`}}/></div><strong>{hours(p.average_hours)}</strong></div>})}</div></article>
<article className="td-panel td-legend"><header><div><p>TIME COMPOSITION</p><h2>时间口径</h2></div></header>{Object.entries(CATEGORY).map(([key,item])=><div key={key}><i style={{background:item.color}}/><span>{item.label}</span><strong>{hours(view.categories[key]||0)}</strong></div>)}<p>“业务端准备周期”和“正式评审周期”分开统计,外部补件等待不会计入人员实际处理时间。</p></article></section>
<section className="td-projects"><header><div><p>PROJECT TIMELINE</p><h2>项目时间轴</h2></div><span>点击项目进入完整业务记录</span></header>{projects.map((p:any)=>{const total=p.events.reduce((n:number,e:any)=>n+e.duration_hours,0);return <a className="td-project" href={`/xingbang-workbench?project=${encodeURIComponent(p.project_id)}&tab=time`} key={p.project_id}><div className="td-project-title"><span>{p.project_id}</span><h3>{p.customer}</h3><small>{p.industry} · {p.stage}</small></div><div className="td-bars">{p.events.map((e:any)=><div className="td-segment" key={e.id} style={{width:`${Math.max(4,e.duration_hours/maxProject*100)}%`,background:CATEGORY[e.category]?.color}} title={`${e.phase_name} ${hours(e.duration_hours)}`}><span>{e.duration_hours/maxProject>0.1?e.phase_name:""}</span></div>)}</div><div className="td-project-total"><small>累计周期</small><strong>{hours(total)}</strong><ArrowUpRight size={17}/></div><div className="td-project-events">{p.events.map((e:any)=><span key={e.id}><i style={{background:CATEGORY[e.category]?.color}}/>{e.phase_name}<b>{hours(e.duration_hours)}</b><small>{stamp(e.started_at)}{e.status==="running"?" · 进行中":""}</small></span>)}</div></a>})}</section>
</>}
</main></div>;
}
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:""},newRequestId());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>;
}
function Metric({icon:Icon,label,value,unit,note,accent}:{icon:any,label:string,value:string,unit?:string,note:string,accent?:boolean}){return <article className={accent?"accent":""}><div><span>{label}</span><Icon size={19}/></div><strong>{value}<small>{unit}</small></strong><p>{note}</p></article>}
+2 -87
View File
@@ -1,90 +1,5 @@
"use client";
import { useCallback } from "react";
import { AppProvider, useApp } from "@/lib/store";
import Navbar from "@/components/layout/Navbar";
import Sidebar from "@/components/layout/Sidebar";
import ChatPanel from "@/components/chat/ChatPanel";
import InspectorPanel from "@/components/editor/InspectorPanel";
import ResizeHandle from "@/components/layout/ResizeHandle";
const MIN_SIDEBAR = 200;
const MIN_INSPECTOR = 280;
const MIN_CHAT = 360;
function MainLayout() {
const {
sidebarOpen,
inspectorOpen,
sidebarWidth,
setSidebarWidth,
inspectorWidth,
setInspectorWidth,
} = useApp();
const handleSidebarResize = useCallback(
(delta: number) => {
setSidebarWidth((prev: number) => Math.max(MIN_SIDEBAR, prev + delta));
},
[setSidebarWidth]
);
const handleInspectorResize = useCallback(
(delta: number) => {
setInspectorWidth((prev: number) => Math.max(MIN_INSPECTOR, prev + delta));
},
[setInspectorWidth]
);
return (
<div className="h-screen flex flex-col app-bg">
<Navbar />
{/* Content area — flexbox layout with resizable panels */}
<div className="flex-1 min-h-0 flex overflow-hidden">
{/* Left sidebar */}
<div
className="glass-panel border-r border-black/[0.06] shrink-0 panel-transition overflow-hidden"
style={{ width: sidebarOpen ? sidebarWidth : 0 }}
>
<div style={{ width: sidebarWidth, minWidth: MIN_SIDEBAR }} className="h-full">
<Sidebar />
</div>
</div>
{/* Left resize handle */}
{sidebarOpen && (
<ResizeHandle onResize={handleSidebarResize} direction="left" />
)}
{/* Chat — fills remaining space */}
<div className="flex-1 overflow-hidden" style={{ minWidth: MIN_CHAT }}>
<ChatPanel />
</div>
{/* Right resize handle */}
{inspectorOpen && (
<ResizeHandle onResize={handleInspectorResize} direction="right" />
)}
{/* Right inspector */}
<div
className="glass-panel border-l border-black/[0.06] shrink-0 panel-transition overflow-hidden"
style={{ width: inspectorOpen ? inspectorWidth : 0 }}
>
<div style={{ width: inspectorWidth, minWidth: MIN_INSPECTOR }} className="h-full">
<InspectorPanel />
</div>
</div>
</div>
</div>
);
}
import BusinessAssistant from "@/components/xingbang/BusinessAssistant";
export default function Home() {
return (
<AppProvider>
<MainLayout />
</AppProvider>
);
return <BusinessAssistant/>;
}
+3 -30
View File
@@ -1,32 +1,5 @@
"use client";
import {useEffect,useState} from "react";
import {BookOpen,Check,Download,FileText,Lock,Plus,Save,Sparkles,Unlock} from "lucide-react";
import AgentNav,{useRequestedProject} from "@/components/xingbang/AgentNav";
import "../data-access/workspace.css";
import "./report.css";
import {redirect} from "next/navigation";
const BASE="/api/risk-agent";
function headers(){const token=sessionStorage.getItem("xingbang-access-token"),role=sessionStorage.getItem("xingbang-demo-role");return {"Content-Type":"application/json",...(token?{Authorization:`Bearer ${token}`}:{}),...(role?{"X-Xingbang-Demo-Role":role}:{})}}
async function call(path:string,body?:unknown){const r=await fetch(BASE+path,{method:body?"POST":"GET",headers:headers(),body:body?JSON.stringify(body):undefined});const data=await r.json();if(!r.ok)throw Error(typeof data.detail==="string"?data.detail:JSON.stringify(data.detail));return data}
const states:Record<string,string>={draft:"待复核草稿",final:"已定稿"};
export default function Reports(){
const [docs,setDocs]=useState<any[]>([]),[runs,setRuns]=useState<any[]>([]),[doc,setDoc]=useState<any>(null),[selectedRun,setSelectedRun]=useState(""),[section,setSection]=useState("0"),[text,setText]=useState(""),[note,setNote]=useState(""),[error,setError]=useState(""),[busy,setBusy]=useState(false),[view,setView]=useState("report"),[approval,setApproval]=useState(""),[outcome,setOutcome]=useState("");
const requested=useRequestedProject();
const update=(d:any)=>{setDoc(d);setDocs(xs=>xs.some(x=>x.id===d.id)?xs.map(x=>x.id===d.id?d:x):[d,...xs]);setText(d.sections.find((s:any)=>s.id===section)?.text||"")};
useEffect(()=>{Promise.all([call("/reports/documents"),call("/runs/history")]).then(([d,r])=>{setDocs(d);setRuns(r);setSelectedRun(r[0]?.id||"");if(d[0]){setDoc(d[0]);setText(d[0].sections[0].text)}}).catch(e=>setError(e.message))},[]);
async function perform(task:()=>Promise<void>){setBusy(true);setError("");try{await task()}catch(e:any){setError(e.message)}finally{setBusy(false)}}
async function action(kind:string,extra:any={}){await perform(async()=>update(await call(`/reports/documents/${doc.id}/actions`,{version:doc.version,kind,section_id:section,text,note,approval_result:approval,post_lease_result:outcome,...extra})))}
const current=doc?.sections.find((s:any)=>s.id===section);
const proposal=doc?.proposals?.filter((p:any)=>p.section_id===section).at(-1);
const dirty=current&&text!==current.text;
const selectDoc=(d:any)=>{if(dirty&&!window.confirm("放弃当前未保存的修改?"))return;setDoc(d);setSection("0");setText(d.sections[0].text);setApproval(d.experience?.approval_result||"");setOutcome(d.experience?.post_lease_result||"")};
async function download(){await perform(async()=>{const r=await fetch(BASE+`/reports/documents/${doc.id}/export`,{headers:headers()});if(!r.ok)throw Error("导出失败,请检查访问权限");const url=URL.createObjectURL(await r.blob());const a=document.createElement("a");a.href=url;a.download=`${doc.project_id}-评审底稿.docx`;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000)})}
return <div className="xb-agent-shell"><AgentNav active="report" project={requested||undefined}/><div className="ra-app"><main className="report-main"><div className="ra-heading"><div><div className="ra-eyebrow">02 / REPORT & EXPERIENCE</div><h1>从项目事实,到评审底稿</h1><p>源研判快照 · 章节确认 · 正式定稿 · 经验沉淀</p></div><div className="report-tabs"><button className={"ra-button "+(view==="report"?"primary":"")} onClick={()=>setView("report")}><FileText size={15}/>报告工作台</button><button className={"ra-button "+(view==="experience"?"primary":"")} onClick={()=>setView("experience")}><BookOpen size={15}/>项目经验卡</button></div></div>{error&&<div role="alert" className="ra-alert error">{error}</div>}
<div className="report-layout"><aside className="report-list"><h2>项目报告 <span>{docs.length}</span></h2>{docs.map(d=><button key={d.id} className={doc?.id===d.id?"selected":""} onClick={()=>selectDoc(d)}><b>{d.project_id}</b><small>{states[d.status]} · v{d.version}</small><small>{d.source_time?.slice(0,10)}</small></button>)}<section className="report-create"><h3>从研判记录建立底稿</h3><label className="ra-field"><span>来源研判</span><select value={selectedRun} onChange={e=>setSelectedRun(e.target.value)}><option value="">请选择</option>{runs.map(r=><option key={r.id} value={r.id}>{r.project_id} · {new Date(r.time).toLocaleString()}</option>)}</select></label><label className="ra-field"><span>来源确认意见</span><textarea placeholder="确认数据来源、适用期间及未解决缺口" value={note} onChange={e=>setNote(e.target.value)}/></label><button className="ra-button primary" disabled={busy||!selectedRun||!note.trim()} onClick={()=>perform(async()=>{const d=await call("/reports/documents",{run_id:selectedRun,confirmation:note});setSection("0");update(d);setText(d.sections[0].text);setView("report")})}><Plus size={15}/>建立底稿</button><p className="ra-note">使用示例对公模板。事实章节固定引用源快照,缺失仍作为待核查事项。</p></section></aside>
{!doc?<div className="ra-empty"><FileText size={36}/><h2>选择一份研判记录开始</h2><p>可以先在风险研判助手运行示例项目,再回到这里建立报告。</p></div>:<section className="report-content"><div className="report-document-heading"><div><h2>{doc.title}</h2><p>{states[doc.status]} · v{doc.version} · {doc.template.name}</p></div><button className="ra-button" disabled={busy} onClick={download}><Download size={15}/>导出 Word</button></div>
{view==="report"?<><nav className="report-sections">{doc.sections.map((s:any)=><button key={s.id} className={section===s.id?"selected":""} onClick={()=>{if(dirty&&!window.confirm("放弃未保存的修改?"))return;setSection(s.id);setText(s.text)}}>{s.locked?<Lock size={14}/>:<FileText size={14}/>}<span>{s.title}</span></button>)}</nav><div className="report-editor-heading"><h3>{current?.title}</h3><span>{current?.locked?"已确认锁定":section==="0"||section==="1"?"来源快照,只读":"待补充判断"}</span></div><textarea aria-label="报告章节内容" className="report-editor" value={text} readOnly={doc.status==="final"||current?.locked||section==="0"||section==="1"} onChange={e=>setText(e.target.value)}/><div className="report-actions"><button className="ra-button" disabled={busy||doc.status==="final"||current?.locked||section==="0"||section==="1"} onClick={()=>action("edit")}><Save size={15}/>保存章节</button><button className="ra-button" disabled={busy||dirty||doc.status==="final"} onClick={()=>action("lock",{locked:!current?.locked})}>{current?.locked?<Unlock size={15}/>:<Lock size={15}/>} {current?.locked?"解锁修改":"确认并锁定"}</button><button className="ra-button" disabled={busy||dirty||doc.status==="final"||current?.locked||section==="0"||section==="1"} onClick={()=>perform(async()=>update(await call(`/reports/documents/${doc.id}/generate`,{kind:"generate",version:doc.version,section_id:section})))}><Sparkles size={15}/>{busy?"处理中…":"mini-openclaw 生成候选稿"}</button></div>
{proposal&&<section className="report-proposal"><h3>AI 候选稿 · 未覆盖正文</h3><p>{proposal.text}</p><button className="ra-button" disabled={doc.status==="final"||current?.locked} onClick={()=>setText(proposal.text)}>载入编辑区复核</button><details><summary>查看 Agent 工具轨迹</summary><pre>{JSON.stringify(proposal.trace.messages,null,2)}</pre></details></section>}
<section className="report-finalize"><label className="ra-field"><span>主审员定稿意见</span><textarea value={note} onChange={e=>setNote(e.target.value)} placeholder="确认事实、数字、风险判断与待落实条件"/></label><button className="ra-button primary" disabled={busy||dirty||doc.status==="final"||!note.trim()||!doc.sections.every((s:any)=>s.locked)} onClick={()=>action("finalize")}><Check size={16}/>定稿并提取经验卡</button><p className="ra-note">逐章确认后定稿。经验卡仍为草稿,不自动进入共享经验库。</p></section></>:<><div className="report-experience-status">{["草稿","主审员已确认","部门审核","可检索","已验证","已过期/归档"].map(s=><span key={s} className={doc.experience?.status===s?"selected":""}>{s}</span>)}</div>{doc.experience?<><h3>风险判断与处置经验</h3><p className="report-prose">{doc.experience.judgment}</p><h3>方案与缓释措施</h3><p className="report-prose">{doc.experience.measures}</p><label className="ra-field"><span>审批结果</span><textarea value={approval} onChange={e=>setApproval(e.target.value)}/></label><label className="ra-field"><span>租后观察结果</span><textarea value={outcome} onChange={e=>setOutcome(e.target.value)}/></label><label className="ra-field"><span>确认或审核意见</span><textarea value={note} onChange={e=>setNote(e.target.value)}/></label><button className="ra-button primary" disabled={busy||!note.trim()||doc.experience.status==="已过期/归档"} onClick={()=>action("experience")}>确认并进入下一阶段</button><details className="ra-note"><summary>经验卡来源与历史</summary><pre className="ra-result-json">{JSON.stringify(doc.experience,null,2)}</pre></details></>:<div className="ra-empty">报告定稿后生成经验卡草稿。</div>}</>}
<details className="ra-note"><summary>来源与审计记录</summary><p>源研判 {doc.source_id} · {doc.source_time}</p><p>来源确认:{doc.confirmation}</p><pre className="ra-result-json">{JSON.stringify(doc.events,null,2)}</pre></details></section>}
</div></main></div></div>
export default function LegacyReport({searchParams}:{searchParams:{project?:string}}){
redirect(`/xingbang-workbench${searchParams.project?`?project=${encodeURIComponent(searchParams.project)}&tab=report`:""}`);
}
+27
View File
@@ -0,0 +1,27 @@
"use client";
import {ArrowRight, Clock3, RefreshCw, RotateCcw, Undo2} from "lucide-react";
import {useEffect,useState} from "react";
import AgentNav from "@/components/xingbang/AgentNav";
import "../data-access/workspace.css";
const ROOT="/api/risk-agent/project-assistant";
const STATUS:Record<string,string>={submitted:"待领取",withdrawn:"已撤回",returned:"已退回",approved:"评审通过",superseded:"已被新版本替换"};
function headers(){const token=sessionStorage.getItem("xingbang-access-token"),role=sessionStorage.getItem("xingbang-demo-role");return {"Content-Type":"application/json",...(token?{Authorization:`Bearer ${token}`}:{}) ,...(role?{"X-Xingbang-Demo-Role":role}:{})};}
function duration(value:number){return value<1?`${Math.max(1,Math.round(value*60))}分钟`:value<24?`${value.toFixed(1)}小时`:`${(value/24).toFixed(1)}天`;}
export default function ReviewPool(){
const [data,setData]=useState<any>(null),[search,setSearch]=useState(""),[busy,setBusy]=useState(""),[error,setError]=useState(""),[notice,setNotice]=useState("");
async function request(path:string,body?:any){const response=await fetch(ROOT+path,{method:body===undefined?"GET":"POST",headers:headers(),body:body===undefined?undefined:JSON.stringify(body)});const result=await response.json();if(!response.ok)throw Error(result.detail||"操作失败");return result;}
async function load(){setError("");try{const result=await request("/review-pool");setData(result);const submitted=new URLSearchParams(location.search).get("submitted");if(submitted){const item=result.projects.find((project:any)=>project.project_id===submitted&&project.status==="submitted");if(item)setNotice(`${item.customer}已进入评审池,可在这里查看等待和评审进度。`);else setError("刚提交的项目暂未出现在评审池,请刷新后重试。");}}catch(e:any){setError(e.message);}}
useEffect(()=>{load();},[]);
async function act(projectId:string,action:"claim"|"withdraw"|"resubmit"){
if(action==="withdraw"&&!window.confirm("确认撤回当前送审版本?评审员将停止办理,所有历史记录仍会保留。"))return;
setBusy(projectId);setError("");setNotice("");
try{await request(`/projects/${encodeURIComponent(projectId)}/review-${action}`,{});if(action==="claim"){location.href=`/xingbang-workbench?project=${encodeURIComponent(projectId)}&tab=review`;return;}setNotice(action==="withdraw"?"项目已撤回,历史送审版本和评审留痕均已保留。":"项目已按新送审版本重新进入评审池。");await load();}catch(e:any){setError(e.message);}finally{setBusy("");}
}
const role=data?.identity?.role;
const submitted=typeof window!=="undefined"?new URLSearchParams(location.search).get("submitted"):null;
const projects=(data?.projects||[]).filter((item:any)=>(item.project_id+item.customer).toLowerCase().includes(search.toLowerCase()));
return <div className="ra-app"><AgentNav active="pool" identity={data?.identity}/><main className="xb-overview"><div className="xb-overview-heading"><div><span className={`xb-role-banner role-${role}`}>当前身份 · {role==="reviewer"?"评审员":role==="manager"?"客户经理":role==="admin"?"配置管理员":"加载中"}</span><h1>项目评审池</h1><p>{role==="reviewer"?"按等待时间领取项目,领取后开始记录评审办理时间。":role==="manager"?"查看本人提交的项目;可撤回当前版本,重新推送时历史记录不会丢失。":"请在右上角切换客户经理或评审员身份查看对应项目池。"}</p></div><button className="ra-button" onClick={load}><RefreshCw size={15}/>刷新</button></div>{error&&<div className="ra-alert error" role="alert">{error}</div>}{notice&&<div className="ra-alert" role="status">{notice}</div>}<div className="xb-actions"><input aria-label="搜索评审池项目" placeholder="项目编号 / 客户名称" value={search} onChange={event=>setSearch(event.target.value)}/></div><div className="xb-project-grid">{projects.map((item:any)=><article className={`xb-project-card xb-pool-card${submitted===item.project_id?" just-submitted":""}`} key={item.submission_id}><span>{submitted===item.project_id?"刚刚提交":item.reviewer_id?"评审办理中":STATUS[item.status]||item.status}</span><h2>{item.customer}</h2><p>{item.project_id} · {item.industry} · 送审版本 v{item.submission_version}</p><div className="xb-review-clock"><small>客户经理提交时间</small><b>{new Date(item.submitted_at).toLocaleString()}</b><strong>{item.reviewer_id?"领取前等待 ":"已等待 "}{duration(item.wait_hours)}</strong><span>{item.reviewer_id?"评审员已领取":STATUS[item.status]||item.status}</span></div><div className="xb-pool-actions">{role==="reviewer"?<button className="ra-button primary" disabled={busy===item.project_id} onClick={()=>act(item.project_id,"claim")}>{item.reviewer_id?"继续办理":"领取并开始评审"}<ArrowRight size={15}/></button>:<><a className="ra-button" href={`/xingbang-workbench?project=${encodeURIComponent(item.project_id)}`}>查看项目</a>{item.status==="submitted"?<button className="ra-button" disabled={busy===item.project_id} onClick={()=>act(item.project_id,"withdraw")}><Undo2 size={14}/>撤回</button>:<button className="ra-button primary" disabled={busy===item.project_id} onClick={()=>act(item.project_id,"resubmit")}><RotateCcw size={14}/>重新推送</button>}</>}</div><footer><b><Clock3 size={14}/> {item.stage}</b><small>提交与评审共用同一时间源</small></footer></article>)}</div>{data&&!projects.length&&<div className="ra-empty"><h2>{role==="reviewer"?"暂无可领取项目":"暂无送审项目"}</h2><p>{role==="reviewer"?"客户经理提交后,项目将按等待时间出现在这里。":"请先在项目报告页提交正式评审。"}</p></div>}</main></div>;
}
+3 -139
View File
@@ -1,141 +1,5 @@
"use client";
import {redirect} from "next/navigation";
import {useEffect, useState} from "react";
import {ArrowRight, Check, ClipboardList, Clock, Download, FileCheck2, GitCompareArrows, Plus, RefreshCw, Save, Search, ShieldCheck, Sparkles, Trash2, Users, X} from "lucide-react";
import AgentNav,{useRequestedProject} from "@/components/xingbang/AgentNav";
import "../data-access/workspace.css";
import "./review.css";
const ROOT = "/api/risk-agent/coordination";
const fields: Record<string, string> = {amount:"额度(万元)",term:"期限(月)",rate:"年化报价(%)",deposit:"保证金(%)",guarantee:"担保条件"};
const tabs = [{id:"tasks",name:"风险与补件",icon:ClipboardList},{id:"allocation",name:"主审员安排",icon:Users},{id:"plans",name:"方案与变更",icon:GitCompareArrows},{id:"brief",name:"上会前摘要",icon:FileCheck2}];
const newPerson = {id:"",version:0,name:"",industries:[],capacity:5,external_load:0,available:true,conflicts:[]};
function headers() {const token=sessionStorage.getItem("xingbang-access-token"),role=sessionStorage.getItem("xingbang-demo-role");return {"Content-Type":"application/json",...(token?{Authorization:`Bearer ${token}`}:{}),...(role?{"X-Xingbang-Demo-Role":role}:{})};}
async function api(path:string, body?:unknown, method?:string) {
const r=await fetch(path,{method:method||(body?"POST":"GET"),headers:headers(),body:body?JSON.stringify(body):undefined});
const d=await r.json();if(!r.ok)throw new Error(typeof d.detail==="string"?d.detail:JSON.stringify(d.detail));return d;
}
function stamp(value:string){return value?new Date(value).toLocaleString("zh-CN",{hour12:false}):"待确认";}
function riskDone(p:any,i:number){return p.risk_reviews?.[`${p.source.id}:${i}`]||p.tasks.some((t:any)=>t.source_id===p.source.id&&t.finding_index===i&&t.status==="已解决");}
function counts(p:any){return {risks:p.source.findings.filter((_:any,i:number)=>!riskDone(p,i)).length,tasks:p.tasks.filter((t:any)=>t.status!=="已解决").length,impacts:p.impacts.filter((i:any)=>!i.confirmed).length};}
function age(value:string){return Math.max(0,Math.floor((Date.now()-new Date(value).getTime())/3600000));}
export default function ReviewWorkbench(){
const [workspace,setWorkspace]=useState<any>({projects:[],people:[],stages:[],identity:{}});
const [runs,setRuns]=useState<any[]>([]),[selected,setSelected]=useState(""),[tab,setTab]=useState("tasks"),[search,setSearch]=useState("");
const [busy,setBusy]=useState(false),[loading,setLoading]=useState(true),[error,setError]=useState(""),[notice,setNotice]=useState("");
const [modal,setModal]=useState<any>(null),[form,setForm]=useState<any>({}),[people,setPeople]=useState<any[]>([]);
const requested=useRequestedProject();
const [plan,setPlan]=useState<any>({amount:"",term:"",rate:"",deposit:"",guarantee:"",note:""}),[dirty,setDirty]=useState(false);
const p=workspace.projects.find((x:any)=>x.id===selected);
const identity=workspace.identity,preview=identity.preview;
const canManage=preview||identity.role==="admin",canReview=preview||identity.role==="reviewer",canWork=canManage||canReview;
async function reload(){
const [w,r]=await Promise.all([api(ROOT+"/workspace"),api("/api/risk-agent/runs/history")]);
setWorkspace(w);setRuns(r);setSelected(s=>w.projects.some((x:any)=>x.id===s)?s:w.projects[0]?.id||"");
return w;
}
useEffect(()=>{reload().catch(e=>setError(e.message)).finally(()=>setLoading(false));},[]);
useEffect(()=>{
if(!p)return;
setPlan({...p.plans.at(-1)?.values,amount:p.plans.at(-1)?.values.amount??"",term:p.plans.at(-1)?.values.term??"",rate:p.plans.at(-1)?.values.rate??"",deposit:p.plans.at(-1)?.values.deposit??"",guarantee:p.plans.at(-1)?.values.guarantee??"",note:""});setDirty(false);
},[selected]);
useEffect(()=>{
if(!dirty)return;
const handler=(e:BeforeUnloadEvent)=>{e.preventDefault();e.returnValue="";};window.addEventListener("beforeunload",handler);return()=>window.removeEventListener("beforeunload",handler);
},[dirty]);
useEffect(()=>{
if(!modal)return;
const previous=document.activeElement as HTMLElement|null;
const dialog=document.querySelector<HTMLElement>('[role="dialog"]');
const focusable=()=>Array.from(dialog?.querySelectorAll<HTMLElement>('button:not(:disabled), input, select, textarea, a[href]')||[]);
focusable()[0]?.focus();
const keydown=(e:KeyboardEvent)=>{
if(e.key==="Escape"&&!busy)setModal(null);
if(e.key==="Tab"){
const elements=focusable(),first=elements[0],last=elements.at(-1);
if(e.shiftKey&&document.activeElement===first){e.preventDefault();last?.focus();}
else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first?.focus();}
}
};
document.addEventListener("keydown",keydown);
return()=>{document.removeEventListener("keydown",keydown);previous?.focus();};
},[modal,busy]);
useEffect(()=>{
if(!selected||!canWork)return;
let active=true;api(`${ROOT}/projects/${selected}/candidates`).then(d=>{if(active)setPeople(d);}).catch(e=>{if(active)setError(e.message);});return()=>{active=false;};
},[selected,workspace,canWork]);
function update(obj:any){setWorkspace((w:any)=>({...w,projects:w.projects.some((x:any)=>x.id===obj.id)?w.projects.map((x:any)=>x.id===obj.id?obj:x):[obj,...w.projects]}));setSelected(obj.id);}
async function perform(fn:()=>Promise<void>){setBusy(true);setError("");setNotice("");try{await fn();}catch(e:any){setError(e.message);}finally{setBusy(false);}}
function open(kind:string,extra:any={}){setModal({kind,...extra});setForm({note:"",...extra.values});}
async function saveModal(){await perform(async()=>{
if(modal.kind==="create")update(await api(ROOT+"/projects",{run_id:form.run_id,industry:form.industry,complexity:Number(form.complexity||1)}));
else if(modal.kind==="person"){
await api(ROOT+"/people",{...form,industries:form.industriesText.split(/[,,]/).map((s:string)=>s.trim()).filter(Boolean),conflicts:form.conflictsText.split(/[,,]/).map((s:string)=>s.trim()).filter(Boolean),capacity:Number(form.capacity),external_load:Number(form.external_load)});await reload();
}else if(modal.kind==="delete_person"){await api(ROOT+`/people/${modal.person.id}`,undefined,"DELETE");await reload();}
else update(await api(`${ROOT}/projects/${p.id}/actions`,{version:p.version,kind:modal.kind,...modal.extra,...form}));
setModal(null);setNotice("已保存");
});}
function chooseProject(id:string){if(dirty&&!window.confirm("当前方案尚未保存,确定切换项目?"))return;setSelected(id);setDirty(false);}
async function refresh(){
const w=await reload();const current=w.projects.find((x:any)=>x.id===selected);
if(current)setPlan({amount:"",term:"",rate:"",deposit:"",guarantee:"",...current.plans.at(-1)?.values,note:""});
setDirty(false);
}
function editPerson(person:any=newPerson){open("person",{values:{...person,industriesText:person.industries.join(","),conflictsText:person.conflicts.join(",")}});}
const c=p?counts(p):{risks:0,tasks:0,impacts:0};
const closed=p?.stage==="已完成";
const next=p?workspace.stages[workspace.stages.indexOf(p.stage)+1]:null;
const late=workspace.projects.reduce((n:number,x:any)=>n+x.tasks.filter((t:any)=>t.status!=="已解决"&&t.due<new Date().toLocaleDateString("en-CA")).length,0);
const pending=workspace.projects.reduce((n:number,x:any)=>n+counts(x).impacts,0);
const visible=workspace.projects.filter((x:any)=>`${x.project_id} ${x.owner_name} ${x.industry}`.toLowerCase().includes(search.toLowerCase()));
function exportBrief(){
const text=[`${p.project_id} 上会前摘要(待人工复核)`,`来源:${p.source.id} / ${stamp(p.source.time)}`,`协同版本:${p.version},负责人:${p.owner_name},节点:${p.stage}`,"\n当前方案",...Object.entries(p.plans.at(-1)?.values||{}).map(([k,v])=>`${fields[k]}:${v}`),"\n待确认风险",...p.source.findings.filter((_:any,i:number)=>!riskDone(p,i)).map((f:any)=>`${f.title}:${f.suggestion}`),"\n未关闭补件",...p.tasks.filter((t:any)=>t.status!=="已解决").map((t:any)=>`${t.title} / ${t.assignee} / ${t.due} / ${t.status}`),"\n待复核变更",...p.impacts.filter((i:any)=>!i.confirmed).map((i:any)=>`方案 v${i.plan_version}:${i.title}`),"\nAI 建议",p.summary?`${p.summary.source_version+1===p.version?"当前":"已过期"}:${p.summary.text}`:"未生成"].join("\n");
const url=URL.createObjectURL(new Blob([text],{type:"text/plain;charset=utf-8"}));const a=document.createElement("a");a.href=url;a.download=`${p.project_id}-上会前摘要.txt`;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);
}
return <div className="xb-agent-shell"><AgentNav active="coordination" project={requested||undefined}/><div className="ra-app co-app">
<main className="co-main">
<div className="co-heading"><div><p className="co-eyebrow">REVIEW OPERATIONS</p><h1>评审协同</h1></div><div className="co-actions"><span className="co-mode">{preview?"本机预览":identity.role==="manager"?"客户经理":identity.role==="reviewer"?"审核员":"管理员"}</span><button className="ra-button" title="刷新工作台" aria-label="刷新工作台" disabled={busy} onClick={()=>{if(dirty&&!window.confirm("刷新将丢弃未保存方案,是否继续?"))return;perform(refresh);}}><RefreshCw size={16}/></button>{canManage&&<button className="ra-button" onClick={()=>{setModal({kind:"roster"});}}><Users size={16}/>人员配置</button>}{canWork&&<button className="ra-button primary" disabled={busy} onClick={()=>open("create",{values:{run_id:runs[0]?.id||"",industry:"",complexity:1}})}><Plus size={16}/>接入项目</button>}</div></div>
{error&&<div role="alert" className="ra-alert error">{error}</div>}{notice&&<div role="status" className="co-notice"><Check size={15}/>{notice}</div>}
<section className="co-metrics"><div><span>在办项目</span><strong>{workspace.projects.filter((x:any)=>x.stage!=="已完成").length.toString().padStart(2,"0")}</strong><ClipboardList size={21}/></div><div><span>待分配</span><strong>{workspace.projects.filter((x:any)=>!x.owner_id).length.toString().padStart(2,"0")}</strong><Users size={21}/></div><div className="warning"><span>逾期补件</span><strong>{late.toString().padStart(2,"0")}</strong><Clock size={21}/></div><div><span>变更待复核</span><strong>{pending.toString().padStart(2,"0")}</strong><GitCompareArrows size={21}/></div></section>
<div className="co-layout"><aside className="co-projects"><div className="co-list-title"><h2>项目队列</h2><span>{workspace.projects.length}</span></div><label className="co-search"><Search size={16}/><input aria-label="搜索项目" placeholder="项目、行业、负责人" value={search} onChange={e=>setSearch(e.target.value)}/></label>
{loading?<p className="co-empty">正在读取项目…</p>:visible.length===0?<p className="co-empty">暂无匹配项目</p>:visible.map((x:any)=><button key={x.id} className={selected===x.id?"co-project selected":"co-project"} onClick={()=>chooseProject(x.id)}><div><b>{x.project_id}</b><span className="co-tag">{x.stage}</span></div><p>{x.industry} · {x.owner_name}</p><footer><span><Clock size={12}/>{age(x.stage_since)} 小时</span><span>{counts(x).tasks} 项补件</span></footer></button>)}
</aside><section className="co-detail">{!p?<div className="co-empty large"><ClipboardList size={42}/><h2>暂无协同项目</h2><p>当前研判记录 {runs.length} 份</p>{canWork&&<button className="ra-button primary" onClick={()=>open("create",{values:{run_id:runs[0]?.id||"",industry:"",complexity:1}})}><Plus size={16}/>接入项目</button>}</div>:<>
<div className="co-project-heading"><div><p className="co-eyebrow">PROJECT / {p.industry}</p><h2>{p.project_id}</h2><p>主审员:{p.owner_name} <span>·</span>复杂度 {p.complexity}<span>·</span> 协同 v{p.version}</p></div>{canWork&&!closed&&next&&p.owner_id&&<button className="ra-button" disabled={busy} onClick={()=>open("stage",{extra:{stage:next}})}>推进至{next}<ArrowRight size={15}/></button>}</div>
<ol className="co-stages">{workspace.stages.map((s:string,i:number)=><li key={s} className={s===p.stage?"current":i<workspace.stages.indexOf(p.stage)?"done":""}><span>{i<workspace.stages.indexOf(p.stage)?<Check size={12}/>:i+1}</span>{s}</li>)}</ol>
<div className="co-source"><span>数据截止 {stamp(p.source.time)}</span><span>待确认风险 {c.risks} · 未关闭补件 {c.tasks} · 待复核变更 {c.impacts}</span>{canWork&&!closed&&<button title="更新来源研判" onClick={()=>open("refresh_source",{values:{run_id:""}})}><RefreshCw size={13}/>更新数据</button>}</div>
<nav className="co-tabs">{tabs.map(t=><button key={t.id} className={tab===t.id?"active":""} onClick={()=>setTab(t.id)}><t.icon size={16}/>{t.name}</button>)}</nav>
{tab==="tasks"&&<div className="co-section"><div className="co-section-title"><h3>来源风险与核查事项 <small>{p.source.findings.length}</small></h3></div>
{p.source.findings.length===0&&<p className="co-empty">所选检查未发现异常,不代表项目无风险。</p>}
{p.source.findings.map((f:any,i:number)=><article className="co-risk" key={i}><div className="co-row"><b>{f.title}</b><span className={riskDone(p,i)?"co-tag green":"co-tag amber"}>{riskDone(p,i)?"已人工确认":"待确认"}</span></div><p>{f.suggestion}</p><div className="co-row"><details><summary>来源证据</summary><pre>{JSON.stringify(f.evidence,null,2)}</pre></details><div className="co-actions">{canWork&&!closed&&<button className="ra-button" onClick={()=>open("task",{extra:{finding_index:i},values:{title:f.title,assignee:"",due:""}})}><Plus size={14}/>转补件</button>}{canReview&&!closed&&!riskDone(p,i)&&<button className="ra-button" onClick={()=>open("risk_review",{extra:{finding_index:i}})}><Check size={14}/>确认处置意见</button>}</div></div>{p.risk_reviews?.[`${p.source.id}:${i}`]&&<p className="co-confirmed">{p.risk_reviews[`${p.source.id}:${i}`].note}</p>}</article>)}
<div className="co-section-title"><h3>补件任务 <small>{p.tasks.length}</small></h3>{canWork&&!closed&&<button className="ra-button" onClick={()=>open("task",{values:{title:"",assignee:"",due:""}})}><Plus size={14}/>新建任务</button>}</div>
{p.tasks.length===0&&<p className="co-empty">暂无补件任务</p>}{p.tasks.map((t:any)=><article className="co-task" key={t.id}><div className="co-row"><b>{t.title}</b><span className={t.status==="已解决"?"co-tag green":"co-tag amber"}>{t.status}</span></div><p>{t.assignee} · 截止 {t.due}{t.status!=="已解决"&&t.due<new Date().toLocaleDateString("en-CA")&&<strong className="co-overdue">已逾期</strong>}</p>{t.submissions.map((s:any,i:number)=><div className="co-submission" key={i}><FileCheck2 size={14}/><span>{s.reference}<small>{s.note} · {stamp(s.time)}</small></span></div>)}<div className="co-actions">
{!closed&&["待提交","检查不通过","继续追问"].includes(t.status)&&<button className="ra-button" onClick={()=>open("submit",{extra:{task_id:t.id},values:{reference:""}})}><Plus size={14}/>提交材料记录</button>}
{!closed&&canWork&&t.status==="已提交待检查"&&["检查不通过","待主审员确认"].map(status=><button key={status} className="ra-button" onClick={()=>open("task_status",{extra:{task_id:t.id,status}})}>{status==="检查不通过"?"退回补正":"材料检查通过"}</button>)}
{!closed&&canReview&&["待主审员确认","已解决"].includes(t.status)&&(t.status==="已解决"?["继续追问"]:["继续追问","已解决"]).map(status=><button key={status} className="ra-button" onClick={()=>open("task_status",{extra:{task_id:t.id,status}})}>{status==="已解决"?"确认问题已解决":"继续追问"}</button>)}
</div><details><summary>处理记录</summary>{t.history.map((h:any,i:number)=><p key={i}>{h.status} · {h.actor} · {stamp(h.time)}<br/>{h.note}</p>)}</details></article>)}
</div>}
{tab==="allocation"&&<div className="co-section"><div className="co-section-title"><h3>主审员推荐</h3><span className="co-muted">行业匹配 · 在办复杂度 · 容量 · 回避</span></div>{!canWork?<p className="co-empty">当前负责人:{p.owner_name}</p>:people.length===0?<div className="co-empty"><Users size={30}/><p>尚未配置评审人员</p>{canManage&&<button className="ra-button" onClick={()=>editPerson()}><Plus size={14}/>新增人员</button>}</div>:people.map((u:any)=><article key={u.id} className="co-person"><div className="co-initial">{u.name.slice(0,1)}</div><div className="co-person-main"><div className="co-row"><h4>{u.name}</h4><span className={u.eligible?"co-tag green":"co-tag"}>{u.eligible?`推荐分 ${u.score}`:"暂不可分配"}</span></div><p>{u.industries.join("、")||"未配置行业"} · {u.industry_match?"行业匹配":"行业经验待核实"}</p><div className="co-load"><progress value={Math.min(u.load,u.capacity)} max={u.capacity}/><span>负荷 {u.load} / {u.capacity}</span></div>{u.reasons.length>0&&<p className="co-overdue">{u.reasons.join(";")}</p>}</div>{canManage&&!closed&&<button className="ra-button" disabled={busy||!u.eligible||p.owner_id===u.id} onClick={()=>open("assign",{extra:{owner_id:u.id}})}>{p.owner_id===u.id?"当前主审员":"确认分配"}</button>}</article>)}<p className="co-muted">分配记录保存在本工作台,正式 ERP 分配状态待对接。</p></div>}
{tab==="plans"&&<div className="co-section"><div className="co-section-title"><h3>当前方案 <small>v{p.plans.length}</small></h3><span className="co-muted">人民币 · 万元</span></div>{canWork&&!closed?<form onSubmit={e=>{e.preventDefault();perform(async()=>{const obj=await api(`${ROOT}/projects/${p.id}/plans`,{...plan,term:Number(plan.term),version:p.version});update(obj);setPlan({...obj.plans.at(-1).values,note:""});setDirty(false);setNotice("方案已保存,相关复核事项已生成");});}}><div className="co-form-grid">{Object.entries(fields).map(([key,label])=><label key={key} className={"ra-field "+(key==="guarantee"?"wide":"")}><span>{label}</span>{key==="guarantee"?<textarea required value={plan[key]||""} onChange={e=>{setPlan({...plan,[key]:e.target.value});setDirty(true);}}/>:<input required type="number" min={key==="term"?1:0} max={key==="rate"||key==="deposit"?100:key==="term"?600:1000000000} step={key==="term"?1:"0.0001"} value={plan[key]??""} onChange={e=>{setPlan({...plan,[key]:e.target.value});setDirty(true);}}/>}</label>)}</div><label className="ra-field"><span>方案确认 / 变更原因</span><textarea required value={plan.note} onChange={e=>{setPlan({...plan,note:e.target.value});setDirty(true);}}/></label><button className="ra-button primary" disabled={busy}><Save size={15}/>保存方案版本</button></form>:<dl className="co-facts">{Object.entries(p.plans.at(-1)?.values||{}).map(([k,v])=><div key={k}><dt>{fields[k]}</dt><dd>{String(v)}</dd></div>)}</dl>}
<div className="co-section-title"><h3>变更影响与复核 <small>{c.impacts} 项待处理</small></h3></div>{p.impacts.length===0&&<p className="co-empty">暂无变更复核事项</p>}{p.impacts.map((i:any)=><div className="co-impact" key={i.id}><GitCompareArrows size={16}/><div><b>{i.title}</b><p>方案 v{i.plan_version}{i.source_id?" · 数据更新":""}{i.confirmed?` · ${i.actor} 已确认`:" · 待复核"}</p>{i.note&&<p>{i.note}</p>}</div>{canReview&&!closed&&!i.confirmed&&<button className="ra-button" onClick={()=>open("impact",{extra:{impact_id:i.id}})}><Check size={14}/>确认复核</button>}</div>)}
<div className="co-section-title"><h3>方案版本记录</h3></div>{[...p.plans].reverse().map((v:any)=><details className="co-version" key={v.number}><summary>v{v.number} · {stamp(v.time)} · {v.note}</summary>{Object.entries(v.changes).map(([k,d]:[string,any])=><p key={k}>{fields[k]}:{d.before??"未录入"} → {d.after}</p>)}</details>)}
</div>}
{tab==="brief"&&<div className="co-section"><div className="co-section-title"><h3>上会准备情况</h3><button className="ra-button" onClick={exportBrief}><Download size={15}/>导出摘要</button></div><div className="co-readiness"><ShieldCheck size={26}/><div><b>{c.risks+c.tasks+c.impacts>0?"仍有事项待确认":"当前协同检查项已处理"}</b><p>待确认风险 {c.risks} · 未关闭补件 {c.tasks} · 变更待复核 {c.impacts}</p></div></div><dl className="co-facts"><div><dt>当前节点</dt><dd>{p.stage}</dd></div><div><dt>负责人 / 等待时间</dt><dd>{p.owner_name} / {age(p.stage_since)} 小时</dd></div><div><dt>数据截止</dt><dd>{stamp(p.source.time)}</dd></div><div><dt>当前方案</dt><dd>{p.plans.length?`额度 ${p.plans.at(-1).values.amount} 万元 · ${p.plans.at(-1).values.term} 个月`:"尚未录入"}</dd></div></dl><h3>待讨论事项</h3><ul className="co-discussion">{p.source.findings.filter((_:any,i:number)=>!riskDone(p,i)).map((f:any,i:number)=><li key={i}>{f.title}:{f.suggestion}</li>)}{p.tasks.filter((t:any)=>t.status!=="已解决").map((t:any)=><li key={t.id}>{t.title} · {t.assignee} · {t.status}</li>)}{p.impacts.filter((i:any)=>!i.confirmed).map((i:any)=><li key={i.id}>方案 v{i.plan_version}:{i.title}</li>)}</ul><div className="co-section-title"><h3>AI 上会建议</h3>{canWork&&<button className="ra-button" disabled={busy} onClick={()=>perform(async()=>update(await api(`${ROOT}/projects/${p.id}/summary`,{version:p.version,kind:"summary"})))}><Sparkles size={15}/>{busy?"处理中…":"生成建议"}</button>}</div>{p.summary?<><div className={p.summary.source_version+1===p.version?"co-tag green":"co-tag amber"}>{p.summary.source_version+1===p.version?"当前快照":"项目已变化,需重新生成"} · 来源 v{p.summary.source_version}</div><p className="co-prose">{p.summary.text}</p><details><summary>工具调用记录</summary><pre>{JSON.stringify(p.summary.trace.messages,null,2)}</pre></details></>:<p className="co-empty">尚未生成 AI 建议</p>}</div>}
<details className="co-audit"><summary>操作审计 · {p.events.length} 条</summary>{[...p.events].reverse().map((e:any,i:number)=><p key={i}>{stamp(e.time)} · {e.actor} · {e.kind}<br/>{e.note}</p>)}</details>
</>}</section></div>
</main>
{modal&&<div className="co-overlay" onClick={e=>{if(e.target===e.currentTarget&&!busy)setModal(null);}}><section role="dialog" aria-modal="true" aria-labelledby="co-dialog-title" className="co-dialog"><header><h2 id="co-dialog-title">{({create:"接入协同项目",roster:"人员配置",person:"编辑评审人员",delete_person:"删除人员",assign:"确认主审员安排",stage:"推进评审节点",task:"新建补件任务",submit:"提交材料记录",task_status:"确认任务处理结果",impact:"确认变更复核",risk_review:"确认风险处置意见",refresh_source:"更新研判来源"} as any)[modal.kind]}</h2><button title="关闭" aria-label="关闭弹窗" disabled={busy} onClick={()=>setModal(null)}><X size={20}/></button></header>{error&&<div role="alert" className="ra-alert error">{error}</div>}
{modal.kind==="roster"?<><button className="ra-button primary" onClick={()=>editPerson()}><Plus size={15}/>新增人员</button>{workspace.people.map((u:any)=><div key={u.id} className="co-roster-row"><div><b>{u.name}</b><p>{u.industries.join("、")} · 容量 {u.capacity} · {u.available?"可用":"不可用"}</p></div><button className="ra-button" onClick={()=>editPerson(u)}>编辑</button><button className="ra-button" title={`删除${u.name}`} onClick={()=>setModal({kind:"delete_person",person:u})}><Trash2 size={15}/></button></div>)}</>:<form onSubmit={e=>{e.preventDefault();saveModal();}}>
{modal.kind==="create"&&<><label className="ra-field"><span>来源研判</span><select required value={form.run_id} onChange={e=>setForm({...form,run_id:e.target.value})}><option value="">请选择</option>{runs.map(r=><option key={r.id} value={r.id}>{r.project_id} · {stamp(r.time)}</option>)}</select></label><label className="ra-field"><span>所属行业</span><input required maxLength={100} value={form.industry} onChange={e=>setForm({...form,industry:e.target.value})}/></label><label className="ra-field"><span>项目复杂度</span><select value={form.complexity} onChange={e=>setForm({...form,complexity:e.target.value})}>{[1,2,3,4,5].map(n=><option key={n} value={n}>{n}</option>)}</select></label></>}
{modal.kind==="person"&&<>{[["name","姓名"],["industriesText","擅长行业(逗号分隔)"],["capacity","总容量"],["external_load","其他在办负荷"],["conflictsText","回避项目编号(逗号分隔)"]].map(([k,label])=><label className="ra-field" key={k}><span>{label}</span><input required={k==="name"||k==="capacity"||k==="external_load"} type={k==="capacity"||k==="external_load"?"number":"text"} min={k==="capacity"?1:0} max={100} value={form[k]} onChange={e=>setForm({...form,[k]:e.target.value})}/></label>)}<label className="co-checkbox"><input type="checkbox" checked={form.available} onChange={e=>setForm({...form,available:e.target.checked})}/>当前可安排项目</label></>}
{modal.kind==="delete_person"&&<p>删除人员「{modal.person.name}」?仍有在办项目的人员无法删除。</p>}
{modal.kind==="task"&&<>{[["title","补件 / 核查事项"],["assignee","责任人"],["due","截止日期"]].map(([k,label])=><label className="ra-field" key={k}><span>{label}</span><input required type={k==="due"?"date":"text"} value={form[k]} onChange={e=>setForm({...form,[k]:e.target.value})}/></label>)}</>}
{modal.kind==="submit"&&<label className="ra-field"><span>ERP 材料编号 / 文件位置</span><input required value={form.reference} onChange={e=>setForm({...form,reference:e.target.value})}/></label>}
{modal.kind==="refresh_source"&&<label className="ra-field"><span>新的研判快照</span><select required value={form.run_id} onChange={e=>setForm({...form,run_id:e.target.value})}><option value="">请选择</option>{runs.filter(r=>r.project_id===p.project_id&&r.time>p.source.time).map(r=><option key={r.id} value={r.id}>{stamp(r.time)} · {r.id.slice(0,8)}</option>)}</select></label>}
{modal.kind==="stage"&&<p className="co-modal-context">{p.stage} → {modal.extra.stage}</p>}
{!["create","person","delete_person"].includes(modal.kind)&&<label className="ra-field"><span>{modal.kind==="risk_review"?"处置结论、依据及保留条件":"操作 / 确认意见"}</span><textarea required maxLength={5000} value={form.note||""} onChange={e=>setForm({...form,note:e.target.value})}/></label>}
<footer><button type="button" className="ra-button" disabled={busy} onClick={()=>setModal(null)}>取消</button><button className="ra-button primary" disabled={busy}><Check size={15}/>{busy?"保存中…":"确认保存"}</button></footer></form>}
</section></div>}
</div></div>;
export default function LegacyReview({searchParams}:{searchParams:{project?:string}}){
redirect(`/xingbang-workbench${searchParams.project?`?project=${encodeURIComponent(searchParams.project)}`:""}`);
}
+3 -113
View File
@@ -1,115 +1,5 @@
"use client";
import {redirect} from "next/navigation";
import { Suspense, useEffect, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { getTrajectory } from "@/lib/api";
import { ArrowLeft, MessageSquare, GitBranch } from "lucide-react";
import AgentLoopFlow from "@/components/trajectory/AgentLoopFlow";
interface Message {
role: string;
content: string;
timestamp?: number;
tool_calls?: Array<{ name: string; args: any }>;
name?: string;
tool_call_id?: string;
}
function TrajectoryContent() {
const searchParams = useSearchParams();
const router = useRouter();
const sessionId = searchParams?.get("session") || "default";
const [messages, setMessages] = useState<Message[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function loadTrajectory() {
try {
setLoading(true);
const data = await getTrajectory(sessionId);
// 优先使用新的 messages 格式(包含 tool 消息)
setMessages(data.messages || []);
} catch (err: any) {
setError(err.message || "加载轨迹失败");
} finally {
setLoading(false);
}
}
loadTrajectory();
}, [sessionId]);
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50 flex items-center justify-center">
<div className="text-center">
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-sm text-gray-600">加载 Agent Loop 轨迹...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50 flex items-center justify-center">
<div className="text-center">
<p className="text-red-600 mb-4">❌ {error}</p>
<button
onClick={() => router.push("/")}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"
>
返回聊天
</button>
</div>
</div>
);
}
return (
<div className="h-screen overflow-y-auto bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50">
{/* 顶部导航 */}
<div className="sticky top-0 z-10 bg-white/80 backdrop-blur-sm border-b border-gray-200">
<div className="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => router.push("/")}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
title="返回聊天"
>
<ArrowLeft className="w-5 h-5 text-gray-600" />
</button>
<div>
<h1 className="text-lg font-semibold text-gray-800 flex items-center gap-2">
<GitBranch className="w-5 h-5 text-indigo-600" />
Agent Loop 交互轨迹
</h1>
<p className="text-xs text-gray-500">会话 ID: {sessionId}</p>
</div>
</div>
</div>
</div>
{/* 主体内容 */}
<div className="max-w-5xl mx-auto px-6 py-8">
{messages.length === 0 ? (
<div className="text-center py-16">
<MessageSquare className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<p className="text-gray-500">该会话暂无交互记录</p>
</div>
) : (
<AgentLoopFlow messages={messages} />
)}
</div>
</div>
);
}
export default function TrajectoryPage() {
return (
<Suspense fallback={<div className="min-h-screen flex items-center justify-center text-sm text-gray-600">正在加载交互轨迹...</div>}>
<TrajectoryContent />
</Suspense>
);
export default function LegacyTrajectory(){
redirect("/xingbang-workbench");
}
+91 -24
View File
@@ -1,24 +1,25 @@
"use client";
import {useEffect, useRef, useState} from "react";
import {ArrowRight, FileText, RefreshCw, ShieldCheck} from "lucide-react";
import {ArrowRight, CheckCircle2, 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 ReviewerWorkspace from "@/components/xingbang/ReviewerWorkspace";
import {newRequestId} from "@/lib/request-id";
import "../data-access/workspace.css";
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 tabs = [["overview", "项目概览"], ["findings", "材料与问题"], ["review", "全链路评审"], ["report", "报告"], ["experience", "评审经验"], ["time", "时间轨迹"]];
const statuses: Record<string, string> = {queued: "已排队", running: "检查中", completed: "已完成", partial: "部分完成", failed: "未完成", cancelled: "已取消", skipped: "未执行", draft: "草稿", submitted: "已提交评审", 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 [requirement, setRequirement] = useState(""), [plan, setPlan] = useState("corporate.pre-review"), [runId, setRunId] = useState(""), [document, setDocument] = useState<any>(null);
const [field, setField] = useState(fields[0][0]), [value, setValue] = useState(""), [reason, setReason] = useState("");
const [template, setTemplate] = useState("template.ai-pre-review"), [reportId, setReportId] = useState(""), [sectionEdits, setSectionEdits] = useState<Record<string, string>>({});
const [category, setCategory] = useState("rule_omission"), [feedbackNote, setFeedbackNote] = useState(""), [feedbackId, setFeedbackId] = useState("");
const [reviewOpinion,setReviewOpinion]=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"), role=sessionStorage.getItem("xingbang-demo-role"); return {...(json ? {"Content-Type": "application/json"} : {}), ...(token ? {Authorization: `Bearer ${token}`} : {}), ...(role ? {"X-Xingbang-Demo-Role":role} : {})};};
@@ -32,9 +33,10 @@ export default function XingbangWorkspace() {
selected.current = id;
const data = await request(`/projects/${encodeURIComponent(id)}`);
if (selected.current !== id) return;
setProject(data); history.replaceState({}, "", `?project=${encodeURIComponent(id)}`);
const activeTab=new URLSearchParams(location.search).get("tab");
setProject(data); history.replaceState({}, "", `?project=${encodeURIComponent(id)}${activeTab?`&tab=${encodeURIComponent(activeTab)}`:""}`);
}
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 load() {try {const data = await request("/projects"); setItems(data.projects); setIdentity(data.identity); const query = new URLSearchParams(location.search), id = query.get("project"), requestedTab=query.get("tab"); if(requestedTab) setTab(requestedTab==="findings"&&data.identity.role==="reviewer"?"review":requestedTab); if(data.identity.role === "reviewer") {setPlan("corporate.independent-review"); setTemplate("template.independent-review");} else {setPlan("corporate.pre-review"); setTemplate("template.ai-pre-review");} 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;}
@@ -46,39 +48,104 @@ export default function XingbangWorkspace() {
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";
const isManager = identity?.role === "manager";
const isReviewer = identity?.role === "reviewer";
const roleTitle = isReviewer ? "评审员独立评审" : identity?.role === "admin" ? "配置管理员项目视图" : "客户经理业务办理";
const availableTabs = tabs.filter(([key]) => identity?.role === "admin" ? ["overview", "time"].includes(key) : isManager ? !["experience", "review"].includes(key) : key !== "findings");
function goToFindings(){const target=isReviewer?"review":"findings";setTab(target);history.replaceState({},"",`?project=${encodeURIComponent(project.id)}&tab=${target}`);setTimeout(()=>window.document.getElementById(isReviewer?"full-review":"project-findings")?.scrollIntoView({behavior:"smooth",block:"start"}),80);}
function back() {selected.current = ""; setProject(null); setDocument(null); setRunId(""); setReportId(""); setFeedbackId(""); setSectionEdits({}); history.replaceState({}, "", "/xingbang-workbench"); load();}
async function claimAndOpen(item:any){
setBusy(true);setError("");setNotice("");
try {if(!item.reviewer_id) await request(`/projects/${encodeURIComponent(item.id)}/review-claim`,{});await open(item.id);setNotice(item.reviewer_id?"已打开你领取的评审项目。":"项目已领取,评审等待停止计时。");}
catch(e:any){setError(e.message);await load();}finally{setBusy(false);}
}
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>;
if (!project) return <div className="ra-app"><AgentNav active="overview" identity={identity}/><main className="xb-overview"><div className="xb-overview-heading"><div><span className={`xb-role-banner role-${identity?.role}`}>当前身份 · {roleTitle}</span><h1>{isReviewer ? "待评审项目池" : "兴邦金租 · 对公项目工作台"}</h1><p>{isReviewer ? "客户经理提交后进入项目池;按等待时间排序,领取后进入你的评审办理。" : "选择一个项目,按“材料—预审/评审—报告—提交”主线办理。"}</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)}/>{identity?.role === "admin" && <a className="ra-button" href="/data-access">进入配置治理</a>}</div><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} disabled={busy} onClick={() => {setTab("overview");isReviewer?claimAndOpen(x):open(x.id).catch(e => setError(e.message));}}><span>{isReviewer?(x.reviewer_id?"我已领取":"项目池待领取"):(x.data_domain === "demo" ? "演示项目" : "正式项目")}</span><h2>{x.customer}</h2><p>{x.id} · {x.industry} · {x.stage}</p>{isReviewer&&x.submitted_at&&<div className="xb-review-clock"><small>客户经理提交</small><b>{new Date(x.submitted_at).toLocaleString()}</b><strong>已等待 {durationText(x.review_wait_hours)}</strong><span>{x.review_state}</span></div>}<div className="xb-next"><small>{isReviewer?"办理操作":"建议下一步"}</small><b>{x.next_action}</b></div><footer><b>{x.open_findings} 项待处理</b><small>{statuses[x.latest_run?.status] || "尚未检查"}</small><ArrowRight size={16}/></footer></button>)}</div>{isReviewer&&!items.length&&<div className="ra-empty"><h2>项目池暂无待评审项目</h2><p>客户经理正式提交后,项目会按等待时间自动进入这里。</p></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 finished = project.runs.filter((r: any) => ["completed", "partial"].includes(r.status) && (identity?.role === "admin" || r.run_kind === (isReviewer ? "independent_review" : "pre_review")));
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 || []);
const hasReviewClaim = !isReviewer || project.reviewer_id === identity?.id;
const reviewTopics = project.review_topics || [];
const reviewedTopics = reviewTopics.filter((item:any)=>item.decision).length;
const blockingTopics = reviewTopics.filter((item:any)=>["要求补件","转现场核查","认可AI判断"].includes(item.decision?.decision));
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></>}
async function reportAction(action: string, payload: any = {}) {if (["regenerate", "finalize", "submit"].includes(action) && Object.keys(sectionEdits).length) {setError("请先保存尚未提交的章节修改"); return;} if(action === "submit" && !window.confirm("确认提交正式评审?提交后当前AI预审报告将作为不可覆盖的送审版本保留。")) return; const saved = await act(`${base}/reports/${chosenReport.id}/actions`, {action, revision: chosenReport.revision, payload}, action === "submit" ? "AI预审报告已提交正式评审。" : "报告操作已记录"); if(saved&&action==="submit"){location.href=`/review-pool?submitted=${encodeURIComponent(project.id)}`;return;} if (saved && payload.section_id) setSectionEdits(previous => {const next = {...previous}; delete next[payload.section_id]; return next;});}
async function reviewDecision(findingId:string|null,decision:string,nodeId?:string,opinion?:string){return act(`${base}/review-decisions`,{finding_id:findingId,node_id:nodeId||null,decision,opinion:opinion??reviewOpinion},findingId||nodeId?"本项评审意见已保存。":decision==="批准"?"评审已批准,正在形成评审报告。":"项目已退回客户经理补件。");}
async function completeReview(decision:string){const saved=await reviewDecision(null,decision);if(!saved)return;if(decision==="批准"&&reportSourceRun){const report=await act(`${base}/reports`,{run_id:reportSourceRun.id,template_id:"template.independent-review"},"独立评审报告已生成,请确认后定稿。");if(report)setReportId(report.id);setTab("report");}}
return <div className="ra-app"><AgentNav active="overview" project={project.id} identity={identity}/><main className="xb-overview">
<button className="xb-back" onClick={back}>← 返回项目列表</button><div className="xb-overview-heading"><div><span className={`xb-role-banner role-${identity?.role}`}>当前身份 · {roleTitle}</span><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}
{isReviewer&&!hasReviewClaim&&<section className="xb-task xb-claim-gate"><div><b>该项目仍在待评审项目池</b><small>领取后将停止等待计时,并记录由你开始办理。</small></div><button className="ra-button primary" disabled={busy} onClick={()=>claimAndOpen(project)}>领取并开始评审</button></section>}
{identity?.role !== "admin" && hasReviewClaim && <section className="xb-task"><label className="ra-field"><span>{isReviewer ? "本次独立评审关注点" : "本次AI预审要求"}(可选,不改变正式规则)</span><textarea value={requirement} onChange={e => setRequirement(e.target.value)} maxLength={2000} placeholder={isReviewer ? "例如:独立核对还款来源与租赁物权属,不沿用客户经理结论" : "例如:重点核对现金流与回款依据,说明哪些材料还需要补充"}/></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}, `${isReviewer ? "独立评审" : "AI预审"}已进入队列;离开页面不会丢失任务。`)}><ShieldCheck size={16}/>{running ? "执行中" : isReviewer ? "发起独立评审" : "开始AI预审"}</button></div><small>底层由 mini-openclaw 调用当前方案冻结的 Skills 与规则,结果可逐项追溯。</small></section>}
<nav className="xb-tabs">{availableTabs.map(t => <button key={t[0]} className={tab === t[0] ? "active" : ""} onClick={() => {setTab(t[0]);history.replaceState({},"",`?project=${encodeURIComponent(project.id)}&tab=${encodeURIComponent(t[0])}`);}}>{isReviewer&&t[0]==="findings"?"全链路评审":t[1]}</button>)}{identity?.role === "admin" && <a href="/data-access">配置治理</a>}</nav>
{tab === "review" && isReviewer && <ReviewerWorkspace topics={reviewTopics} reviewed={reviewedTopics} blocking={blockingTopics} busy={busy||!hasReviewClaim} opinion={reviewOpinion} setOpinion={setReviewOpinion} onDecision={(nodeId:string,decision:string,opinion?:string)=>reviewDecision(null,decision,nodeId,opinion)} onComplete={completeReview} hasRun={!!currentRun}/>}{/* Reviewer decisions stay separate from manager issue handling. */}
{tab === "overview" && <><section className="xb-cards"><article><b>当前数据版本</b><strong>v{project.data_revision}</strong><small>{project.documents.length} 份材料</small></article><button className="xb-metric-card" onClick={goToFindings}><b>未处理问题</b><strong>{openFindings.length}</strong><small>{openFindings[0] ? `${openFindings[0].title} · 点击查看怎么解决` : "当前没有待处理问题"}</small><ArrowRight size={17}/></button><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>AI检查结果</h2><p>先看业务结论;数据来源和解决建议可直接展开,技术信息放在最后。</p>{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>{["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((step:any)=><BusinessStep key={step.step_id} step={step} evidence={evidence} statuses={statuses}/>)}{chosenRun.data_errors?.map((e:any,i:number)=><p key={i} className="xb-warning">数据读取未完成:{e.message}</p>)}<details><summary>技术追溯信息</summary><pre>{JSON.stringify({snapshot:chosenRun.snapshot_id,versions:chosenRun.component_versions,changes:chosenRun.change_summary},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} events={project.events} request={request}/>{project.migration_note && <p>{project.migration_note};已保留 {project.legacy_records.length} 条历史记录。</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>}
<section className="xb-panel xb-wide" id="project-findings"><h2>{isReviewer?"全链路评审":"问题与解决"}</h2><p>{isReviewer?"逐项查看数据来源、AI判断和客户经理说明,再选择评审结论。":"每个问题都说明影响和下一步;处理完成后重新AI预审。"}</p>{isReviewer&&<label className="ra-field xb-review-opinion"><span>常用评审意见(选择后应用到下方操作)</span><select value={reviewOpinion} onChange={e=>setReviewOpinion(e.target.value)}><option>请补充最新材料并说明数据口径</option><option>证据充分,同意AI判断</option><option>AI判断不适用于本项目,以人工核实为准</option><option>请补充现场核实记录</option></select></label>}{!project.findings.length&&<p>尚无问题记录,请先执行检查。</p>}{[...project.findings].sort((a:any,b:any)=>Number(["已解决","不适用"].includes(a.status))-Number(["已解决","不适用"].includes(b.status))).map((finding:any)=><FindingCard key={finding.id} finding={finding} evidence={evidence} isReviewer={isReviewer} busy={busy||!hasReviewClaim} reason={reason} onManager={(status:string)=>act(`${base}/findings/${finding.id}/actions`,{action:status,revision:finding.revision,note:reason})} onReviewer={(decision:string)=>reviewDecision(finding.id,decision)} decision={project.review_decisions?.find((item:any)=>item.finding_id===finding.id)}/>)}{!isReviewer&&<><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></>}{isReviewer&&<div className="xb-review-final"><div><CheckCircle2 size={21}/><span><b>完成全链路核查</b><small>AI只提供建议,批准或退回由评审员确认。</small></span></div><button className="ra-button" disabled={busy||!hasReviewClaim} onClick={()=>completeReview("退回")}>退回补充材料</button><button className="ra-button primary" disabled={busy||!hasReviewClaim||!currentRun} onClick={()=>completeReview("批准")}>批准并生成评审报告</button></div>}</section></>}
{tab === "report" && <section className="xb-panel xb-wide"><h2>{isReviewer ? "独立评审报告" : "AI预审报告"}</h2><p>模板、运行快照、Skills 与规则版本随报告冻结;每个历史版本都可以重新打开。</p><div className="xb-actions"><select aria-label="报告模板" value={template} onChange={e => setTemplate(e.target.value)}>{isReviewer ? <><option value="template.independent-review">独立评审报告</option><option value="template.committee-brief">上会摘要</option></> : <option value="template.ai-pre-review">AI预审意见</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 || ["finalized","submitted"].includes(chosenReport.status)} onClick={() => reportAction("regenerate", {run_id: currentRun.id})}>按最新运行更新候选</button>{isManager && <button className="ra-button primary" disabled={busy || chosenReport.status === "submitted"} onClick={() => reportAction("submit")}>提交正式评审</button>}{canReview && <button className="ra-button primary" 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) || ["finalized","submitted"].includes(chosenReport.status)} value={sectionEdits[s.id] ?? s.content} onChange={e => setSectionEdits({...sectionEdits, [s.id]: e.target.value})}/>{!["finalized","submitted"].includes(chosenReport.status) && <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"}/>}
{tab === "time" && <section className="xb-panel xb-wide"><div className="xb-time-heading"><div><h2>本项目时间轨迹</h2><p>自动区分人员处理、排队、外部等待和系统执行,无需额外打卡。</p></div><a className="ra-button primary" href={`/operations-center?project=${encodeURIComponent(project.id)}`}>查看全局时效看板</a></div><div className="xb-timeline">{(project.timeline||[]).map((item:any)=><article key={item.id}><i className={`category-${item.category}`}/><div><b>{item.phase_name}</b><p>{detailText(item.detail)}</p><small>{new Date(item.started_at).toLocaleString()} · {item.status === "running" ? "进行中" : "已结束"}</small></div><strong>{durationText(item.duration_hours)}</strong></article>)}</div></section>}
</main></div>;
}
function durationText(value:any){
const hours=Number(value||0);
if(hours<1)return `${Math.max(1,Math.round(hours*60))}分钟`;
if(hours<24)return `${hours.toFixed(1)}小时`;
return `${(hours/24).toFixed(1)}天`;
}
function detailText(detail:any){
if(typeof detail==="string"&&detail.trim())return detail;
if(detail?.submitted_at)return "评审员领取后自动开始记录办理时间";
if(detail?.plan_id)return "使用已配置的业务检查方案自动执行";
if(detail?.template_id)return "按已配置的报告模板自动编制";
if(detail?.demo)return "演示数据:系统自动记录";
return "系统自动记录";
}
const resultLabels:Record<string,string>={pass:"检查通过",issue:"发现问题",unknown:"材料不足",not_applicable:"不适用",error:"执行失败"};
function BusinessStep({step,evidence,statuses}:any){
const checks=step.findings||[];
const needsAttention=checks.some((item:any)=>["issue","unknown","error"].includes(item.result));
return <details className={`xb-step-business ${needsAttention?"attention":"ok"}`} open={needsAttention}>
<summary><span><b>{step.name}</b><small>{checks.length?`${checks.filter((item:any)=>["issue","unknown","error"].includes(item.result)).length} 项需关注`:"本步骤未形成业务问题"}</small></span><strong>{statuses[step.status]||step.status}</strong></summary>
<div className="xb-step-content">
{step.outputs?.requirement_response&&<div className="xb-ai-answer"><b>对本次要求的回应</b><p>{step.outputs.requirement_response}</p></div>}
{step.errors?.map((item:any,index:number)=><p className="xb-warning" key={index}>该项未完成:{item.message}</p>)}
{checks.length?<div className="xb-business-checks">{checks.map((item:any,index:number)=>{const refs=(item.evidence_ids||[]).map((id:string)=>evidence.find((source:any)=>source.id===id)).filter(Boolean);return <article key={item.code||index}><span className={`xb-result result-${item.result}`}>{resultLabels[item.result]||item.result}</span><div><b>{item.title}</b><p>{item.suggested_action||"无需额外处理"}</p>{refs.length>0&&<small>来源:{refs.map((source:any)=>`${source.source||"项目材料"}(${source.locator||"已定位"})`).join(";")}</small>}</div></article>;})}</div>:<p>本步骤未发现需要业务人员处理的事项。</p>}
<details><summary>技术明细</summary><pre>{JSON.stringify({skill:step.skill,status:step.status,outputs:step.outputs,missing_inputs:step.missing_inputs},null,2)}</pre></details>
</div>
</details>;
}
function FindingCard({finding,evidence,isReviewer,busy,reason,onManager,onReviewer,decision}:any){
const refs=(finding.evidence_ids||[]).map((id:string)=>evidence.find((source:any)=>source.id===id)).filter(Boolean);
const impact=finding.result==="unknown"?"缺少足够材料,当前无法确认":finding.result==="issue"?"可能影响项目判断,需要人工确认":"机器检查已通过,仍可人工复核";
return <article className={`xb-issue-card result-${finding.result}`}>
<header><span className={`xb-result result-${finding.result}`}>{resultLabels[finding.result]||finding.status}</span><div><h3>{finding.title}</h3><small>当前状态:{finding.status}</small></div></header>
<div className="xb-chain">
<section><small>1 · 数据来源</small>{refs.length?refs.map((source:any,index:number)=><p key={source.id||index}><b>{source.source||"项目材料"}</b><span>{source.locator||"已定位"}{source.value===null?" · 未提供":source.value!==undefined?` · ${String(source.value)}`:""}</span></p>):<p className="missing">尚无可验证来源</p>}</section>
<section><small>2 · AI判断</small><p><b>{resultLabels[finding.result]||finding.result}</b><span>{impact}</span></p></section>
<section><small>3 · 客户经理说明</small><p><b>{finding.human_note?"已说明":"尚未说明"}</b><span>{finding.human_note||"评审员可要求补件或进一步核实"}</span></p></section>
</div>
<div className="xb-solution"><small>怎么解决</small><b>{finding.suggested_action||"请核实材料、数据口径及业务事实"}</b></div>
{decision&&<p className="xb-saved-decision">评审结论:<b>{decision.decision}</b> · {decision.opinion||"无补充意见"}</p>}
<div className="xb-actions">{isReviewer?<><button className="ra-button" disabled={busy} onClick={()=>onReviewer("认可AI判断")}>认可AI判断</button><button className="ra-button" disabled={busy} onClick={()=>onReviewer("不采纳AI判断")}>不采纳</button><button className="ra-button primary" disabled={busy} onClick={()=>onReviewer("要求补件")}>要求补件</button></>:<><button className="ra-button" disabled={busy||!reason.trim()} onClick={()=>onManager("待补件")}>标记待补件</button><button className="ra-button" disabled={busy||!reason.trim()} onClick={()=>onManager("待复核")}>提交复核</button><button className="ra-button" disabled={busy||!reason.trim()} onClick={()=>onManager("不适用")}>确认不适用</button></>}</div>
<details><summary>查看规则与技术追溯</summary><p>检查依据:{(finding.basis_ids||[]).map((item:string)=>item.split("@")[0]).join("、")||"未记录"}</p><pre>{JSON.stringify({code:finding.code,basis_ids:finding.basis_ids,evidence_ids:finding.evidence_ids,history:finding.history},null,2)}</pre></details>
</article>;
}