feat: deliver Xingbang four-agent v2 workspace

This commit is contained in:
NathanAlex7
2026-09-08 02:49:06 +08:00
parent 58fb2854dc
commit b5073c3841
32 changed files with 2061 additions and 12 deletions
+7 -4
View File
@@ -3,11 +3,14 @@ import { NextRequest, NextResponse } from "next/server";
async function proxy(request: NextRequest, {params}: {params:{path:string[]}}) {
const base=process.env.RISK_AGENT_BACKEND_URL || "http://127.0.0.1:8002";
try {
const response=await fetch(`${base}/api/xingbang/risk-agent/${params.path.map(encodeURIComponent).join("/")}`,{
method:request.method,headers:{"Content-Type":"application/json", "Authorization":request.headers.get("authorization")||"", "X-Xingbang-Client-Host":request.headers.get("host")||""},
body:["GET","HEAD"].includes(request.method)?undefined:await request.text(),cache:"no-store",
const response=await fetch(`${base}/api/xingbang/risk-agent/${params.path.map(encodeURIComponent).join("/")}${request.nextUrl.search}`,{
method:request.method,headers:{"Content-Type":request.headers.get("content-type")||"application/json", "Authorization":request.headers.get("authorization")||"", "X-Xingbang-Client-Host":request.headers.get("host")||""},
body:["GET","HEAD"].includes(request.method)?undefined:await request.arrayBuffer(),cache:"no-store",
});
return new NextResponse(await response.text(),{status:response.status,headers:{"Content-Type":"application/json"}});
const headers=new Headers({"Content-Type":response.headers.get("content-type")||"application/json"});
const disposition=response.headers.get("content-disposition");
if(disposition)headers.set("Content-Disposition",disposition);
return new NextResponse(await response.arrayBuffer(),{status:response.status,headers});
} catch {
return NextResponse.json({detail:"风险 Agent 服务未连接,请检查后端运行状态"},{status:502});
}
@@ -0,0 +1 @@
.cb-main{max-width:1500px;margin:auto;padding:30px 34px 60px;background:#f5f7f6;min-height:calc(100vh - 60px);font-family:Arial,"Microsoft YaHei",sans-serif;letter-spacing:0;color:#263a31}.cb-header{display:flex;justify-content:space-between;gap:20px;align-items:start;margin-bottom:25px}.cb-eyebrow{font-size:10px;color:#648272;margin:0 0 7px}.cb-header h1{font-size:27px;font-weight:650}.cb-header p:last-child{font-size:12px;color:#829189;margin-top:8px}.cb-actions{display:flex;gap:8px;flex-wrap:wrap}.cb-layout{display:grid;grid-template-columns:280px minmax(0,1fr);gap:25px}.cb-list h2{font-size:15px;margin-bottom:16px}.cb-list h2 small{color:#82938a;font-size:11px;margin-left:7px}.cb-list>button{display:block;width:100%;text-align:left;background:#fff;border:1px solid #e0e7e2;border-radius:6px;padding:17px 15px;margin-bottom:9px}.cb-list>button.selected{border-color:#328066;box-shadow:inset 3px 0 #328066;background:#f4faf6}.cb-list>button b{display:block;font-size:13px}.cb-list>button span{float:right;color:#b17032;background:#fbf0df;font-size:10px;padding:3px 6px}.cb-list>button small{display:block;font-size:10px;color:#85958b;margin-top:12px}.cb-content{background:#fff;border-top:2px solid #337c62;min-width:0}.cb-title{display:flex;justify-content:space-between;gap:15px;padding:25px 27px 20px}.cb-title p{font-size:11px;color:#728579}.cb-title h2{font-size:22px;font-weight:600;margin:9px 0}.cb-title small{font-size:11px;color:#83938a}.cb-metrics{display:grid;grid-template-columns:repeat(4,1fr);border-top:1px solid #ebefec;border-bottom:1px solid #ebefec;padding:17px 0}.cb-metrics>div{padding:0 22px;border-right:1px solid #e7ece8}.cb-metrics>div:last-child{border:0}.cb-metrics span{display:block;font-size:10px;color:#87968e}.cb-metrics b{display:block;font-size:22px;font-weight:500;margin-top:7px;color:#317256}.cb-tabs{display:flex;gap:22px;padding:0 25px;border-bottom:1px solid #e5ebe6}.cb-tabs button{padding:16px 0 13px;font-size:12px;color:#829087;border-bottom:2px solid transparent}.cb-tabs button.active{color:#1e694b;border-color:#1e694b;font-weight:600}.cb-section{padding:24px 27px}.cb-section-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:17px}.cb-section h3{font-size:14px}.cb-measure{padding:17px 0;border-bottom:1px solid #e8ede9}.cb-row{display:flex;justify-content:space-between;gap:10px}.cb-measure b{font-size:13px}.cb-measure p{font-size:11px;color:#77897d;margin:8px 0}.cb-measure small{font-size:10px;color:#8c9b92;overflow-wrap:anywhere}.cb-status{font-size:10px;padding:4px 7px;border-radius:4px;white-space:nowrap}.cb-status.ok{color:#296e4e;background:#e9f5ed}.cb-status.warning{color:#a36c2f;background:#fbf1df}.cb-result{display:flex;gap:10px;margin-top:22px;padding:15px;background:#f3f8f4;color:#397052}.cb-result p{font-size:10px;margin-top:7px;color:#71877a;line-height:1.6}.cb-doc{display:flex;gap:13px;padding:16px 0;border-bottom:1px solid #e8ede9;color:#337a5a}.cb-doc>div{min-width:0}.cb-doc b{display:block;font-size:13px;color:#3d5849}.cb-doc small{display:block;font-size:10px;color:#85968b;margin:6px 0}.cb-doc p{font-size:11px;line-height:1.7;color:#788a7f;overflow-wrap:anywhere}.cb-scenario{display:grid;grid-template-columns:repeat(4,1fr);background:#f4f8f5;padding:18px 0}.cb-scenario>div{padding:0 18px;border-right:1px solid #dfe9e1}.cb-scenario>div:last-child{border:0}.cb-scenario span{display:block;font-size:10px;color:#819189}.cb-scenario b{display:block;margin-top:8px;font-size:18px;font-weight:500;color:#357457}.cb-section pre{white-space:pre-wrap;background:#f5f7f6;padding:14px;font-size:10px;margin-top:18px;line-height:1.7}.cb-prose{white-space:pre-wrap;line-height:1.9;color:#526c5b;font-size:13px}.cb-muted{font-size:11px;color:#85968c}.cb-empty{padding:28px 10px;color:#8b9a91;font-size:12px;text-align:center}.cb-empty.large{display:flex;align-items:center;justify-content:center;flex-direction:column;gap:15px;min-height:400px}.cb-empty h2{font-size:18px;color:#607d6b}@media(max-width:800px){.cb-main{padding:22px 16px}.cb-header{flex-wrap:wrap}.cb-header h1{font-size:23px}.cb-layout{grid-template-columns:1fr;gap:17px}.cb-list{max-height:260px;overflow:auto}.cb-title,.cb-section{padding:20px 16px}.cb-metrics{grid-template-columns:repeat(2,1fr);gap:17px 0}.cb-metrics>div{padding:0 16px}.cb-metrics>div:nth-child(2){border:0}.cb-tabs{padding:0 12px;gap:0;justify-content:space-between;flex-wrap:wrap}.cb-tabs button{width:50%;font-size:11px;padding:13px 4px}.cb-scenario{grid-template-columns:1fr 1fr;gap:15px 0}.cb-scenario>div{border:0}.cb-section-head{align-items:start;flex-wrap:wrap}}
File diff suppressed because one or more lines are too long
+30
View File
@@ -0,0 +1,30 @@
"use client";
import {useEffect,useState} from "react";
import {ArrowLeft,BookOpen,Check,Download,FileText,Lock,Plus,Save,Sparkles,Unlock} from "lucide-react";
import "../data-access/workspace.css";
import "./report.css";
const BASE="/api/risk-agent";
function headers(){const token=sessionStorage.getItem("xingbang-access-token");return {"Content-Type":"application/json",...(token?{Authorization:`Bearer ${token}`}:{})}}
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 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="ra-app"><header className="ra-top"><a href="/data-access" className="ra-button"><ArrowLeft size={15}/>风险研判</a><div><FileText size={20}/><b>兴邦金租 · 报告与经验助手</b></div><span className="ra-badge">Agent 02 / 业务验证版</span></header><main className="report-main"><div className="ra-heading"><div><div className="ra-eyebrow">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>
}
+1
View File
@@ -0,0 +1 @@
.report-main{max-width:1500px;margin:auto;padding:35px}.report-layout{display:grid;grid-template-columns:270px minmax(0,1fr);gap:28px}.report-list{border-right:1px solid #dfe5e9;padding-right:22px}.report-list h2{font-size:14px;font-weight:600;margin-bottom:15px}.report-list h2 span{float:right;color:#99a5ac}.report-list>button{width:100%;text-align:left;padding:15px 12px;border-bottom:1px solid #e0e5e9;font-size:13px}.report-list>button.selected{background:#e7f1eb;color:#17634c;border-radius:5px}.report-list small{display:block;color:#94a1a9;font-size:10px;margin-top:7px}.report-create{padding-top:30px}.report-create h3{font-size:13px;margin-bottom:20px;font-weight:600}.report-content{min-width:0}.report-document-heading{display:flex;justify-content:space-between;gap:15px;align-items:center;margin-bottom:23px}.report-document-heading h2{font-size:21px;font-weight:600}.report-document-heading p{color:#8c9ba5;font-size:11px;margin-top:10px}.report-tabs{display:flex;gap:10px}.report-sections{display:flex;gap:5px;border-bottom:1px solid #dfe5e9;margin-bottom:25px;flex-wrap:wrap}.report-sections button{display:flex;gap:7px;align-items:center;font-size:12px;color:#8a99a1;padding:13px 12px;border-bottom:2px solid transparent}.report-sections button.selected{border-color:#17634c;color:#17634c}.report-editor-heading{display:flex;justify-content:space-between;font-size:13px;margin-bottom:15px}.report-editor-heading h3{font-weight:600}.report-editor-heading span{font-size:11px;color:#8c9e96}.report-editor{width:100%;min-height:390px;padding:25px;border:1px solid #dde4e8;background:white;border-radius:5px;font-size:13px;line-height:2;resize:vertical;color:#40514b}.report-editor:read-only{background:#fafcfb}.report-actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:15px}.report-finalize{border-top:1px solid #dfe5e9;margin-top:28px;padding-top:25px}.report-proposal{margin:25px 0;padding:20px;border-left:3px solid #5c9180;background:#eaf2ee;font-size:12px}.report-proposal p{white-space:pre-wrap;line-height:1.9;margin:15px 0}.report-proposal pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:300px;overflow:auto}.report-proposal details{margin-top:15px}.report-experience-status{display:flex;gap:8px;flex-wrap:wrap;margin:24px 0}.report-experience-status span{padding:7px 10px;font-size:11px;color:#8e9fa5;border-bottom:2px solid #dfe7e4}.report-experience-status span.selected{color:#17634c;border-color:#17634c}.report-prose{white-space:pre-wrap;font-size:13px;color:#67796f;line-height:1.9;margin:15px 0 28px}@media(max-width:760px){.report-main{padding:22px 15px}.report-layout{grid-template-columns:1fr}.report-list{border-right:0;padding:0}.report-document-heading{flex-wrap:wrap}.report-editor{padding:15px}.report-tabs{flex-wrap:wrap}.report-sections button{padding:10px 5px}.report-create{border-bottom:1px solid #dfe5e9;margin-bottom:10px}.report-list>button{display:inline-block;width:50%;vertical-align:top}}
+140
View File
@@ -0,0 +1,140 @@
"use client";
import {useEffect, useState} from "react";
import {ArrowLeft, ArrowRight, Check, ClipboardList, Clock, Download, FileCheck2, GitCompareArrows, Plus, RefreshCw, Save, Search, ShieldCheck, Sparkles, Trash2, Users, X} from "lucide-react";
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");return {"Content-Type":"application/json",...(token?{Authorization:`Bearer ${token}`}:{})};}
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 [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="ra-app co-app">
<header className="co-top"><a href="/data-access" title="返回风险研判"><ArrowLeft size={19}/></a><div className="co-brand"><ShieldCheck size={25}/><b>兴邦金租<span>评审协同工作台</span></b></div><nav><a href="/data-access">风险研判</a><a href="/report-workbench">报告与经验</a><span>Agent 03</span></nav></header>
<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>;
}
File diff suppressed because one or more lines are too long
+12
View File
@@ -0,0 +1,12 @@
"use client";
import {useEffect,useState} from "react";
import {ArrowRight,RefreshCw,ShieldCheck} from "lucide-react";
import AgentNav,{projectLink} from "@/components/xingbang/AgentNav";
import "../data-access/workspace.css";
export default function XingbangWorkspace(){
const [data,setData]=useState<any[]>([]),[search,setSearch]=useState(""),[error,setError]=useState(""),[loading,setLoading]=useState(true);
async function load(){setLoading(true);setError("");try{const token=sessionStorage.getItem("xingbang-access-token");const r=await fetch("/api/risk-agent/workspace/projects",{headers:token?{Authorization:`Bearer ${token}`}:{}});const d=await r.json();if(!r.ok)throw Error(d.detail);setData(d.projects);}catch(e:any){setError(e.message);}finally{setLoading(false);}}
useEffect(()=>{load();},[]);
return <div className="ra-app"><AgentNav active="overview"/><main className="xb-overview"><div className="xb-overview-heading"><div><h1>兴邦金租 · 项目工作台</h1><p>项目总览</p></div><a className="ra-button primary" href="/data-access?view=run"><ShieldCheck size={16}/>发起风险研判</a></div>{error&&<div role="alert" className="ra-alert error">{error}<a href="/data-access">身份设置</a></div>}<div className="xb-overview-stat"><span><strong>{data.length}</strong>项目</span><span><strong>{data.filter(p=>p.coordination&&p.coordination.stage!=="已完成").length}</strong>评审在办</span><span><strong>{data.filter(p=>p.collateral?.stale).length}</strong>核验待更新</span><span><strong>{data.filter(p=>p.report?.status==="final").length}</strong>报告已定稿</span></div><div className="xb-overview-tools"><input aria-label="搜索项目" placeholder="项目编号 / 客户名称" value={search} onChange={e=>setSearch(e.target.value)}/><button className="ra-button" title="刷新项目" disabled={loading} onClick={load}><RefreshCw size={15}/></button></div><div className="xb-table-wrap"><table className="xb-project-table"><thead><tr><th>项目 / 客户</th><th>01 风险研判</th><th>02 报告与经验</th><th>03 评审协同</th><th>04 租赁物与增信</th></tr></thead><tbody>{data.filter(p=>(p.project_id+" "+p.risk?.customer).toLowerCase().includes(search.toLowerCase())).map(p=><tr key={p.project_id}><td><b>{p.project_id}</b><small>{p.risk?.customer||"客户资料待接入"}</small></td><td><a href={projectLink("/data-access",p.project_id)}>{p.risk?`${p.risk.findings} 项关注`:"开始研判"}<ArrowRight size={12}/></a><small>{p.risk?.time?new Date(p.risk.time).toLocaleString():"暂无快照"}</small></td><td><a href={projectLink("/report-workbench",p.project_id)}>{p.report?(p.report.status==="final"?"已定稿":"待复核草稿"):"建立报告"}<ArrowRight size={12}/></a><small>{p.report?`版本 ${p.report.version}`:"尚未建立"}</small></td><td><a href={projectLink("/review-workbench",p.project_id)}>{p.coordination?.stage||"接入评审"}<ArrowRight size={12}/></a><small>{p.coordination?`${p.coordination.owner} · ${p.coordination.open_tasks} 项补件`:"尚未分配"}</small></td><td><a href={projectLink("/collateral-workbench",p.project_id)}>{!p.collateral?"开始核验":p.collateral.stale?"需重新核验":`${p.collateral.open_checks} 项待落实`}<ArrowRight size={12}/></a><small>{p.collateral?.has_result?"已保存核验快照":"尚无核验结果"}</small></td></tr>)}</tbody></table></div>{!data.length&&<div className="ra-empty">{loading?"正在读取项目…":"暂无项目研判记录"}</div>}</main></div>;
}