chore(v2): 从兴邦预审v2白名单抽取独立可运行基座

按 docs/v2/05-implementation-plan.md §1 候选迁移表与 claude_use/T00 白名单,
从 55e2cdb 抽取 224 个文件(2.48 MB)到独立目录 F:/mini-OpenClaw-v2。

- 排除:凭据(.env/server-deploy.conf/旧DELIVERY.md)、运行数据(xingbang_data 等
  4 个 SQLite)、依赖(venv/node_modules 符号链接)、归档(_backup*/test-evidence/
  ori_file)、Office 临时文件
- 目标目录独立 git 仓库,初始恢复点;Gitea 远程与分支待 OQ-06 Q2 定案
- 未部署、未启动服务、未调用模型

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-29 12:58:26 +08:00
co-authored by Claude Code
commit d56c699ce6
224 changed files with 33392 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
import {NextRequest, NextResponse} from "next/server";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const allowed = new Set(["chat", "sessions", "files", "skills", "tokens", "config", "xingbang"]);
async function proxy(request:NextRequest, {params}:{params:{path:string[]}}) {
if (!allowed.has(params.path[0]) || params.path.some(p=>!p || p==="." || p===".." || /[\\/]/.test(p)))
return NextResponse.json({detail:"接口不存在"},{status:404});
const base = process.env.CORE_BACKEND_URL || process.env.RISK_AGENT_BACKEND_URL || "http://127.0.0.1:8002";
try {
// Production nextUrl can contain the bind address (0.0.0.0), not the client Host.
// Never use Host as authentication: all production core/tool access requires admin.
const authorization = request.headers.get("authorization") || "";
const host = request.headers.get("host") || "";
const requiresAuth = process.env.NODE_ENV === "production" || process.env.XINGBANG_REQUIRE_AUTH === "1" || !!process.env.XINGBANG_AUTH_USERS || !!authorization;
if (requiresAuth) {
if (!authorization.startsWith("Bearer ") || !authorization.slice(7).trim())
return NextResponse.json({detail:"请先在风险研判页面连接管理员访问身份(401)"},{status:401});
const identity = await fetch(`${base}/api/xingbang/risk-agent/access/context`, {
headers:{Authorization:authorization,"X-Xingbang-Client-Host":host},
cache:"no-store",redirect:"error",signal:request.signal,
});
if (!identity.ok) return NextResponse.json({detail:"访问身份校验失败"},{status:identity.status === 401 || identity.status === 403 ? identity.status : 503});
const user = await identity.json();
if (user.role !== "admin" || user.preview || !user.id)
return NextResponse.json({detail:"通用聊天与文件工具仅限已认证管理员"},{status:403});
} else {
let hostname = "";
try { hostname = new URL(`http://${host}`).hostname; } catch { /* reject malformed Host */ }
if (!["localhost","127.0.0.1","::1","[::1]"].includes(hostname))
return NextResponse.json({detail:"未配置身份认证,仅允许本机开发访问"},{status:403});
const origin = request.headers.get("origin");
if ((origin && new URL(origin).host !== host) || request.headers.get("sec-fetch-site") === "cross-site")
return NextResponse.json({detail:"不允许跨站访问开发工具"},{status:403});
}
const upstream = await fetch(`${base}/api/${params.path.map(encodeURIComponent).join("/")}${request.nextUrl.search}`, {
method:request.method,headers:{"Content-Type":request.headers.get("content-type")||"application/json",Authorization:authorization},
body:["GET","HEAD"].includes(request.method)?undefined:await request.arrayBuffer(),
cache:"no-store",redirect:"error",signal:request.signal,
});
const headers = new Headers({"Content-Type":upstream.headers.get("content-type")||"application/json", "Cache-Control":"no-cache, no-transform", "X-Accel-Buffering":"no"});
// Never arrayBuffer()/text() an SSE response: pass through chunks immediately.
return new NextResponse(upstream.body,{status:upstream.status,headers});
} catch {
return NextResponse.json({detail:"当前聊天后端未连接,请检查本地服务"},{status:502});
}
}
export {proxy as GET, proxy as POST, proxy as PUT, proxy as DELETE};
+18
View File
@@ -0,0 +1,18 @@
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("/")}${request.nextUrl.search}`,{
method:request.method,headers:{"Content-Type":request.headers.get("content-type")||"application/json", "Authorization":request.headers.get("authorization")||"", "X-Xingbang-Demo-Role":request.headers.get("x-xingbang-demo-role")||"", "X-Xingbang-Client-Host":request.headers.get("host")||"", "Idempotency-Key":request.headers.get("idempotency-key")||""},
body:["GET","HEAD"].includes(request.method)?undefined:await request.arrayBuffer(),cache:"no-store",
});
const headers=new Headers({"Content-Type":response.headers.get("content-type")||"application/json"});
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});
}
}
export {proxy as GET,proxy as POST,proxy as PUT,proxy as DELETE};
@@ -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}}
+5
View File
@@ -0,0 +1,5 @@
import {redirect} from "next/navigation";
export default function LegacyCollateral({searchParams}:{searchParams:{project?:string}}){
redirect(`/xingbang-workbench${searchParams.project?`?project=${encodeURIComponent(searchParams.project)}&tab=findings`:""}`);
}
+42
View File
@@ -0,0 +1,42 @@
"use client";
import {useState} from "react";
import {Download, ShieldCheck, Plus, Trash2, X} from "lucide-react";
type Fields={item:any;field:(key:string,value:unknown)=>void};
function Field({label,children}:{label:string;children:React.ReactNode}){return <label className="ra-field"><span>{label}</span>{children}</label>}
export function ConnectionFields({item,field}:Fields){return <>
<Field label="连接类型"><select value={item.type} onChange={e=>field("type",e.target.value)}>{["demo","SQLite","PostgreSQL","MySQL"].map(t=><option key={t} value={t}>{t==="demo"?"本地演示数据库":t}</option>)}</select></Field>
{item.type!=="demo"&&<>
{item.type!=="SQLite"&&<><div className="ra-two"><Field label="主机地址"><input value={item.host||""} onChange={e=>field("host",e.target.value)}/></Field><Field label="端口"><input type="number" value={item.port||""} onChange={e=>field("port",e.target.value)}/></Field></div><Field label="只读账号"><input value={item.username||""} onChange={e=>field("username",e.target.value)}/></Field><Field label="服务器密码引用"><input placeholder="XINGBANG_DB_ERP_PASSWORD" value={item.password_env||""} onChange={e=>field("password_env",e.target.value)}/></Field></>}
<Field label={item.type==="SQLite"?"服务器 SQLite 文件完整路径":"数据库名称"}><input value={item.database||""} onChange={e=>field("database",e.target.value)}/></Field>
<Field label="允许访问的表或视图 · 每行一个"><textarea value={(item.allowed_tables||[]).join("\n")} onChange={e=>field("allowed_tables",e.target.value.split("\n").filter(Boolean))}/></Field>
<div className="ra-two"><Field label="超时(秒)"><input type="number" min="1" max="60" value={item.timeout||15} onChange={e=>field("timeout",Number(e.target.value))}/></Field><Field label="最大返回行数"><input type="number" min="1" max="10000" value={item.max_rows||1000} onChange={e=>field("max_rows",Number(e.target.value))}/></Field></div>
<p className="ra-note">密码由服务器密钥或环境配置提供,不写入数据卡。数据库账号必须具有只读权限。SQLite 文件须位于服务器允许的目录内。</p>
</>}
</>}
export function AlignmentFields({item,field}:Fields){const a=item.alignment||{};const set=(key:string,value:string)=>field("alignment",{...a,[key]:value});return <section className="ra-alignment"><h3>多卡数据对齐</h3><Field label="项目标识源字段"><input value={a.project_field??"project_id"} onChange={e=>set("project_field",e.target.value)}/></Field><div className="ra-two"><Field label="期间源字段(可选)"><input placeholder="period" value={a.period_field||""} onChange={e=>set("period_field",e.target.value)}/></Field><Field label="口径源字段(可选)"><input placeholder="scope" value={a.scope_field||""} onChange={e=>set("scope_field",e.target.value)}/></Field></div><Field label="记录选择方式"><select value={a.selection||"single"} onChange={e=>set("selection",e.target.value)}><option value="single">唯一记录</option><option value="latest">最新一期</option><option value="period">指定期间</option><option value="all">保留多期供综合研判</option></select></Field><div className="ra-two"><Field label="目标口径(可选)"><input placeholder="合并" value={a.target_scope||""} onChange={e=>set("target_scope",e.target.value)}/></Field>{a.selection==="period"&&<Field label="目标期间"><input placeholder="2025" value={a.target_period||""} onChange={e=>set("target_period",e.target.value)}/></Field>}</div><p className="ra-note">不同数据卡平级取数。金额换算保留原值证据,期间、口径及重复记录存在冲突时不自动计算。</p></section>}
export function RuleFields({item,field,json,setJson}:{item:any;field:Fields["field"];json:string;setJson:(value:string)=>void}){
let conditions:any[]=[];let invalid=false;try{conditions=JSON.parse(json);if(!Array.isArray(conditions))invalid=true}catch{invalid=true}
const change=(i:number,k:string,v:unknown)=>setJson(JSON.stringify(conditions.map((r,j)=>j===i?{...r,[k]:v}:r),null,2));
return <><Field label="人为自然语言规则"><textarea placeholder="例如:结合利润与经营现金流的变化识别疑点,逐项列明证据不足之处,只给核查建议。" value={item.natural_language||""} onChange={e=>field("natural_language",e.target.value)}/></Field><p className="ra-note">所选规则随平级数据卡送入本地模型;模型不可修改规则或作出审批决定。模型未配置时明确显示该项未完成。</p><h3>确定性检查条件</h3>{!invalid&&conditions.map((c,i)=><div className="ra-rule-row" key={i}><Field label="检查名称"><input value={c.title||""} onChange={e=>change(i,"title",e.target.value)}/></Field><div className="ra-two"><Field label="标准字段"><input value={c.field||""} onChange={e=>change(i,"field",e.target.value)}/></Field><Field label="判断条件"><select value={c.operator||"gt"} onChange={e=>change(i,"operator",e.target.value)}><option value="gt">大于</option><option value="lt">小于</option><option value="eq">等于</option></select></Field></div><Field label="阈值"><input type="number" step="any" value={c.threshold??0} onChange={e=>change(i,"threshold",Number(e.target.value))}/></Field><Field label="核查建议"><input value={c.suggestion||""} onChange={e=>change(i,"suggestion",e.target.value)}/></Field><button className="ra-button" title="删除条件" onClick={()=>setJson(JSON.stringify(conditions.filter((_,j)=>j!==i),null,2))}><Trash2 size={15}/></button></div>)}<button className="ra-button" disabled={invalid} onClick={()=>setJson(JSON.stringify([...conditions,{title:"新检查",field:"metrics.debt_ratio",operator:"gt",threshold:0.7,suggestion:"请核实"}],null,2))}><Plus size={15}/>添加条件</button><details className="ra-note"><summary>高级 JSON 配置</summary><Field label="条件 JSON"><textarea className="ra-code" value={json} onChange={e=>setJson(e.target.value)}/></Field></details></>
}
const demoRoles = [
{value:"manager", label:"客户经理", description:"查看项目并发起研判,不能修改系统配置。"},
{value:"reviewer", label:"发布审核员", description:"审核发布规则与协同成果,不能直接修改配置。"},
{value:"admin", label:"配置管理员", description:"维护演示配置、规则与数据连接。"},
];
export function AccessDialog({close,connected}:{close:()=>void;connected:()=>void}){
const [role,setRole]=useState(()=>{const stored=sessionStorage.getItem("xingbang-demo-role");return demoRoles.some(item=>item.value===stored)?stored!:"manager"});
const selected=demoRoles.find(item=>item.value===role)!;
const finish=(next?:string)=>{sessionStorage.removeItem("xingbang-access-token");if(next)sessionStorage.setItem("xingbang-demo-role",next);else sessionStorage.removeItem("xingbang-demo-role");connected()};
return <div className="ra-overlay"><section className="ra-confirm" role="dialog" aria-modal="true"><button style={{float:"right"}} title="关闭身份窗口" onClick={close}><X size={18}/></button><ShieldCheck size={24}/><h2>选择演示身份</h2><Field label="当前身份"><select aria-label="演示身份" value={role} onChange={e=>setRole(e.target.value)}>{demoRoles.map(item=><option key={item.value} value={item.value}>{item.label}</option>)}</select></Field><p>{selected.description}</p><p className="ra-note">这是临时演示入口,仅用于业务工作台和模拟数据。通用聊天及文件工具仍需管理员令牌,后续可切换为正式令牌或单点登录。</p><div><button className="ra-button" onClick={()=>finish()}>退出演示身份</button><button className="ra-button primary" onClick={()=>finish(role)}>以此身份进入</button></div></section></div>
}
export function Reviews({items,busy,review}:{items:any[];busy:boolean;review:(id:string,approve:boolean,note:string)=>void}){const [notes,setNotes]=useState<Record<string,string>>({});return <section>{items.map(r=><article className="ra-review-row" key={r.id}><h3>{r.snapshot.name} · {r.snapshot.version}</h3><p className="ra-note">提交人 {r.submitted_by} · {new Date(r.time).toLocaleString()} · {r.status}</p><details className="ra-note"><summary>查看本次提交内容</summary><pre className="ra-result-json">{JSON.stringify(r.snapshot,null,2)}</pre></details>{r.status==="pending"?<><Field label="审核意见"><input value={notes[r.id]||""} onChange={e=>setNotes({...notes,[r.id]:e.target.value})}/></Field><button className="ra-button" disabled={busy||!notes[r.id]?.trim()} onClick={()=>review(r.id,false,notes[r.id])}>驳回</button> <button className="ra-button primary" disabled={busy||!notes[r.id]?.trim()} onClick={()=>review(r.id,true,notes[r.id])}>审核并发布</button></>:<p className="ra-note">{r.reviewed_by}:{r.note}</p>}</article>)}{!items.length&&<p className="ra-empty">暂无审核记录</p>}</section>}
export function ResultExtras({result,previous}:{result:any;previous:any}){const exportResult=()=>{const blob=new Blob([JSON.stringify(result,null,2)],{type:"application/json"});const url=URL.createObjectURL(blob);const a=document.createElement("a");a.href=url;a.download=`risk-${result.project_id}-${result.id.slice(0,8)}.json`;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000)};const changes=previous&&previous.project_id===result.project_id?Object.keys({...previous.values,...result.values}).filter(k=>previous.values?.[k]!==result.values?.[k]):[];return <div><button className="ra-button" onClick={exportResult}><Download size={15}/>导出建议与证据</button>{result.summary?.incomplete&&<p className="ra-alert error">本次存在信息缺失或未完成检查,请补充或核查后重新研判。</p>}{previous&&previous.project_id===result.project_id&&<details className="ra-note"><summary>与上次运行比较:{changes.length} 项数据变化</summary>{changes.map(k=><p key={k}>{k}:{String(previous.values?.[k]??"缺失")} → {String(result.values?.[k]??"缺失")}</p>)}</details>}</div>}
+86
View File
@@ -0,0 +1,86 @@
"use client";
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";
type Item = { id: string; name: string; description?: string; status: string; version: string; [key: string]: any };
type Kind = "connections" | "cards" | "skills" | "rules" | "plans";
type Catalog = Record<Kind, Item[]>;
const EMPTY: Catalog = {connections:[],cards:[],skills:[],rules:[],plans:[]};
const META = {connections:{label:"数据连接",icon:Database},cards:{label:"数据卡",icon:Layers3},skills:{label:"Skills 能力库",icon:Sparkles},rules:{label:"规则集",icon:ShieldCheck},plans:{label:"研判方案",icon:Workflow}};
const STATUS: Record<string,string> = {published:"已发布",draft:"草稿",archived:"已归档"};
const API = "/api/risk-agent";
async function api(path: string, method="GET", body?: unknown) {
const token=typeof window!=="undefined"?sessionStorage.getItem("xingbang-access-token"):null, role=typeof window!=="undefined"?sessionStorage.getItem("xingbang-demo-role"):null;
const r=await fetch(API+path,{method,headers:{"Content-Type":"application/json",...(token?{"Authorization":"Bearer "+token}:{}),...(role?{"X-Xingbang-Demo-Role":role}:{})},body:body===undefined?undefined:JSON.stringify(body)});
const data=await r.json();
if(!r.ok) throw Error(typeof data.detail==="string"?data.detail:JSON.stringify(data.detail||data));
return data;
}
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);
const [project,setProject]=useState("XB-2026-001"),[plan,setPlan]=useState("plan-default"),[result,setResult]=useState<any>(null),[history,setHistory]=useState<any[]>([]),[test,setTest]=useState<any>(null);
const [nativeSkills,setNativeSkills]=useState<any[]>([]);
async function reload() { setCatalog(await api("/catalog")); }
const [access,setAccess]=useState<any>({role:"admin",preview:true}),[accessOpen,setAccessOpen]=useState(false),[reviewItems,setReviewItems]=useState<any[]>([]),[previous,setPrevious]=useState<any>(null);
const canManage=access.role!=="manager", canEdit=access.role==="admin";
async function connectIdentity(){try{const user=await api("/access/context");const requested=typeof window!=="undefined"?new URLSearchParams(window.location.search).get("view"):null;const initialView=requested&&["run","history","rules"].includes(requested)?requested:(user.role==="manager"?"run":"cards");setAccess(user);setResult(null);setHistory([]);setEditor(null);setView(initialView as Kind|"run"|"history");await reload();if(user.role!=="manager")setNativeSkills(await api("/native-skills/list"));setAccessOpen(false);setError("")}catch(e:any){setError(e.message);setCatalog(EMPTY);setAccessOpen(true)}finally{setLoading(false)}}
useEffect(()=>{connectIdentity()},[]);
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:Kind,item?:Item) {
const value=item?structuredClone(item):{...defaults(kind),id:"",name:"",description:"",status:"draft",version:"v1"} as Item;
setEditor({kind,item:value,fresh:!item});setAdvanced(false);setTest(null);
setJson(JSON.stringify(kind==="cards"?value.mapping||{}:kind==="rules"?value.conditions||[]:{},null,2));
}
function field(key:string,value:unknown){setEditor(e=>e?{...e,item:{...e.item,[key]:value}}:e)}
function toggle(key:string,id:string){if(editor)field(key,(editor.item[key]||[]).includes(id)?editor.item[key].filter((x:string)=>x!==id):[...(editor.item[key]||[]),id])}
async function save(publish=false){if(!editor)return;await perform(async()=>{
const data={...editor.item};if(editor.kind==="cards")data.mapping=JSON.parse(json);if(editor.kind==="rules")data.conditions=JSON.parse(json);
data.status=publish&&access.preview?"published":"draft";
const saved=await api(`/${editor.kind}${editor.fresh?"":"/"+data.id}`,editor.fresh?"POST":"PUT",{name:data.name,data});
if(editor.fresh&&publish&&access.preview)await api(`/${editor.kind}/${saved.id}`,"PUT",{name:saved.name,data:{...saved,status:"published"}});
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} 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&&<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"?"本地样例可运行":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>
{editor.kind==="connections"&&<ConnectionFields item={editor.item} field={field}/>}
{editor.kind==="cards"&&<><Field label="复用数据连接"><select value={editor.item.connection||""} onChange={e=>field("connection",e.target.value)}><option value="">请选择</option>{active("connections").map(c=><option key={c.id} value={c.id}>{c.name}</option>)}</select></Field><Field label="记录粒度"><input value={editor.item.grain||""} onChange={e=>field("grain",e.target.value)}/></Field><div className="ra-two"><Field label="金额单位"><input value={editor.item.unit||""} onChange={e=>field("unit",e.target.value)}/></Field><Field label="报表期间"><input value={editor.item.period||""} onChange={e=>field("period",e.target.value)}/></Field></div><Field label="财务口径"><input value={editor.item.scope||""} onChange={e=>field("scope",e.target.value)}/></Field><AlignmentFields item={editor.item} field={field}/><CheckList title="关联 Skills" items={active("skills")} checked={editor.item.skills||[]} onToggle={id=>toggle("skills",id)}/><button className="ra-button" onClick={()=>setAdvanced(!advanced)}><FileCode2 size={15}/>{advanced?"收起":"展开"} SQL 与字段映射</button>{advanced&&<><Field label="参数化 SQL · :project_id"><textarea className="ra-code" value={editor.item.sql||""} onChange={e=>field("sql",e.target.value)}/></Field><Field label="字段映射 · 标准字段 → 源字段 (JSON)"><textarea className="ra-code" value={json} onChange={e=>setJson(e.target.value)}/></Field></>}<button disabled={busy||editor.fresh} className="ra-button" onClick={()=>perform(async()=>setTest(await api(`/cards/${editor.item.id}/test`,"POST",{project_id:project})))}><Play size={15}/>测试已保存版本</button>{test&&<pre className="ra-result-json">{JSON.stringify(test,null,2)}</pre>}</>}
{editor.kind==="skills"&&<><Field label="执行器"><select value={editor.item.engine} onChange={e=>field("engine",e.target.value)}><option value="quality">数据质量检查</option><option value="ratio">比率计算(Python)</option><option value="overdue">征信输入检查</option><option value="model">本地模型</option></select></Field><Field label="所需标准字段 · 每行一个"><textarea value={(editor.item.inputs||[]).join("\n")} onChange={e=>field("inputs",e.target.value.split("\n"))}/></Field>{editor.item.engine==="ratio"&&<div className="ra-two"><Field label="分子字段"><input value={editor.item.numerator||""} onChange={e=>field("numerator",e.target.value)}/></Field><Field label="分母字段"><input value={editor.item.denominator||""} onChange={e=>field("denominator",e.target.value)}/></Field></div>}<Field label="输出字段"><input value={editor.item.output||""} onChange={e=>field("output",e.target.value)}/></Field>{editor.item.engine==="model"&&<CheckList title="mini-openclaw 原生 Skills" items={nativeSkills.map(s=>({...s,status:"published",version:"SKILL.md"}))} checked={editor.item.native_skills||[]} onToggle={id=>toggle("native_skills",id)}/>}<Field label="业务说明 / 模型指令"><textarea value={editor.item.instructions||""} onChange={e=>field("instructions",e.target.value)}/></Field><p className="ra-note">确定性执行器依据结构化字段运行;模型指令在配置本地模型端点后执行。当前不支持上传任意 Python 脚本。</p></>}
{editor.kind==="rules"&&<RuleFields item={editor.item} field={field} json={json} setJson={setJson}/>}
{editor.kind==="plans"&&<><CheckList title="选择数据卡" items={active("cards")} checked={editor.item.cards||[]} onToggle={id=>toggle("cards",id)}/><CheckList title="选择 Skills" items={active("skills")} checked={editor.item.skills||[]} onToggle={id=>toggle("skills",id)}/><CheckList title="选择规则集" items={active("rules")} checked={editor.item.rules||[]} onToggle={id=>toggle("rules",id)}/><Dependency plan={editor.item} catalog={catalog}/></>}
</div><footer><button className="ra-button" onClick={()=>setEditor(null)}>取消</button><button disabled={busy||!editor.item.name.trim()} className="ra-button" onClick={()=>save()}>保存草稿</button><button disabled={busy||!editor.item.name.trim()} className="ra-button primary" onClick={()=>save(true)}><Check size={16}/>{access.preview?"保存并发布":"保存并提交审核"}</button></footer></section></div>}
{confirmDelete&&<div className="ra-overlay"><section className="ra-confirm" role="dialog" aria-modal="true"><Trash2 size={24}/><h2>归档「{confirmDelete.item.name}」?</h2><p>配置将停止使用,历史运行快照保留。被其他有效配置引用时需要先解除关联。</p><div><button className="ra-button" onClick={()=>setConfirmDelete(null)}>取消</button><button disabled={busy} className="ra-button primary" onClick={()=>perform(async()=>{await api(`/${confirmDelete.kind}/${confirmDelete.item.id}`,"DELETE");await reload();setConfirmDelete(null);setNotice("配置已归档")})}>确认归档</button></div>{error&&<p role="alert">{error}</p>}</section></div>}
{accessOpen&&<AccessDialog close={()=>setAccessOpen(false)} connected={connectIdentity}/>}
</div></div>;
}
function Field({label,children}:{label:string;children:React.ReactNode}){return <label className="ra-field"><span>{label}</span>{children}</label>}
function CheckList({title,items,checked,onToggle}:{title:string;items:Item[];checked:string[];onToggle:(id:string)=>void}){return <section className="ra-checks"><h3>{title}<span>{checked.length} 已选</span></h3>{items.map(item=><label key={item.id}><input type="checkbox" checked={checked.includes(item.id)} onChange={()=>onToggle(item.id)}/><div><b>{item.name}</b><small>{item.description}</small></div><span className={`ra-status ${item.status}`}>{STATUS[item.status]}</span></label>)}</section>}
function Dependency({plan,catalog}:{plan:Item;catalog:Catalog}){const fields=new Set(catalog.cards.filter(c=>(plan.cards||[]).includes(c.id)).flatMap(c=>Object.keys(c.mapping||{})));const issues=catalog.skills.filter(s=>(plan.skills||[]).includes(s.id)).flatMap(s=>(s.inputs||[]).filter((f:string)=>f!=="*"&&!fields.has(f)).map((f:string)=>s.name+" 缺少 "+f));return <div className={"ra-dependency "+(issues.length?"warning":"")}><h3>输入依赖检查</h3>{issues.length?issues.map((x:string)=><p key={x}>{x}</p>):<p>所选 Skills 的声明字段均有数据卡提供。</p>}</div>}
function Results({result}:{result:any}){return <div className="ra-results"><section className="ra-findings"><div className="ra-section-heading"><h2>研判发现 <span>{result.findings.length}</span></h2><span className="ra-badge">{result.mode}</span></div>{result.findings.map((f:any,i:number)=><article key={i}><div className="ra-finding-title"><span className={"ra-finding-dot "+(f.category==="信息缺失"?"missing":"")}/><h3>{f.title}</h3><span>{f.category}</span></div><p>{f.suggestion}</p>{f.value!==undefined&&<p>计算值:<b>{Number(f.value).toFixed(4)}</b></p>}<details><summary>证据与规则 <ChevronRight size={13}/></summary><pre>{JSON.stringify({来源:f.evidence,规则:f.rule,条件:f.condition},null,2)}</pre></details></article>)}{result.advisories?.map((a:any,i:number)=><article key={`model-${i}`}><h3>{a.skill}</h3><span className="ra-badge">{a.status}</span><p style={{whiteSpace:"pre-wrap"}}>{a.text}</p></article>)}{!result.findings.length&&<p className="ra-note">当前检查未发现异常;不代表项目无风险。</p>}</section><aside className="ra-trace"><h2>执行轨迹</h2>{result.agent_traces?.map((trace:any,i:number)=><details key={`core-${i}`}><summary><Workflow size={17}/><span><b>mini-openclaw Agent 内核</b><small>{trace.skill} · 工具调用轨迹</small></span></summary>{trace.messages.filter((m:any)=>m.type!=="human").map((m:any,j:number)=><div key={j}><b>{m.type} {m.data.name||""}</b><pre>{JSON.stringify({content:m.data.content,tool_calls:m.data.tool_calls},null,2)}</pre></div>)}</details>)}<p className="ra-note">{new Date(result.time).toLocaleString()}</p>{result.steps.map((s:any,i:number)=><details key={i}><summary>{s.status==="完成"?<CheckCircle2 size={17}/>:<CircleDashed size={17}/>}<span><b>{s.name}</b><small>{s.status} · {s.version}</small></span></summary><p>{s.detail}</p>{s.rows&&<pre>{JSON.stringify(s.rows,null,2)}</pre>}</details>)}<p className="ra-note">运行 ID:{result.id}</p></aside></div>}
File diff suppressed because one or more lines are too long
+235
View File
@@ -0,0 +1,235 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--bg-primary: #f5f5f7;
--text-primary: #1d1d1f;
--text-secondary: #86868b;
--accent: #002fa7;
--accent-light: #e8edff;
--accent-orange: #ff6723;
}
html { height: 100%; overflow-y: auto; }
body { min-height: 100%; }
body {
color: var(--text-primary);
font-family: "Microsoft YaHei", "PingFang SC", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 16px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
button,
input,
select,
textarea {
font: inherit;
}
:focus-visible {
outline: 3px solid rgba(23, 99, 76, 0.28);
outline-offset: 3px;
}
/* ── Layered background ─────────────────────────────── */
.app-bg {
background:
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(0, 47, 167, 0.05) 0%, transparent 60%),
radial-gradient(ellipse 50% 40% at 100% 100%, rgba(255, 103, 35, 0.03) 0%, transparent 50%),
linear-gradient(180deg, #f8f8fa 0%, #f0f0f3 100%);
}
/* ── Glass variants ─────────────────────────────────── */
.glass-nav {
background: rgba(255,255,255,0.78);
backdrop-filter: blur(40px) saturate(180%);
-webkit-backdrop-filter: blur(40px) saturate(180%);
border-bottom: 1px solid rgba(0,0,0,0.06);
}
.glass-panel {
background: rgba(255,255,255,0.68);
backdrop-filter: blur(28px) saturate(160%);
-webkit-backdrop-filter: blur(28px) saturate(160%);
}
.glass-input {
background: rgba(255,255,255,0.72);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(0,0,0,0.06);
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
}
/* ── Scrollbar ──────────────────────────────────────── */
::-webkit-scrollbar { width: 14px; height: 14px; }
::-webkit-scrollbar-track { background: #e8ecf2; border-radius: 10px; }
::-webkit-scrollbar-thumb {
background: #8796ad;
border: 3px solid #e8ecf2;
border-radius: 10px;
min-height: 40px;
}
.xingbang-mark {
width: 28px;
height: 28px;
display: grid;
place-items: center;
flex: 0 0 auto;
border-radius: 7px;
background: #17634c;
color: #fff;
font-family: "Microsoft YaHei", sans-serif;
font-size: 15px;
font-weight: 700;
box-shadow: inset 0 -2px 0 rgba(0,0,0,.12);
}
.xingbang-chat-mark {
width: 58px;
height: 58px;
display: grid;
place-items: center;
margin-bottom: 16px;
border-radius: 16px;
background: #17634c;
color: #fff;
font-family: "Microsoft YaHei", sans-serif;
font-size: 28px;
font-weight: 700;
box-shadow: 0 8px 20px rgba(23,99,76,.18), inset 0 -3px 0 rgba(0,0,0,.12);
}
::-webkit-scrollbar-thumb:hover { background: #596f91; }
::-webkit-scrollbar-thumb:active { background: var(--accent); }
@supports not selector(::-webkit-scrollbar) {
* { scrollbar-width: auto; scrollbar-color: #8796ad #e8ecf2; }
}
/* ── Scroll shadow overlay (top/bottom) ─────────────── */
.scroll-shadow {
position: relative;
}
.scroll-shadow::before,
.scroll-shadow::after {
content: '';
position: absolute; left: 0; right: 0; height: 24px;
pointer-events: none; z-index: 2;
}
.scroll-shadow::before {
top: 0;
background: linear-gradient(to bottom, rgba(245,245,247,0.8), transparent);
}
.scroll-shadow::after {
bottom: 0;
background: linear-gradient(to top, rgba(245,245,247,0.8), transparent);
}
/* ── Markdown ───────────────────────────────────────── */
.markdown-content h1 { font-size: 1.35em; font-weight: 700; margin: 0.5em 0 0.3em; }
.markdown-content h2 { font-size: 1.15em; font-weight: 600; margin: 0.4em 0 0.3em; }
.markdown-content h3 { font-size: 1.05em; font-weight: 600; margin: 0.3em 0 0.2em; }
.markdown-content p { margin: 0.35em 0; line-height: 1.65; color: #333; }
.markdown-content ul, .markdown-content ol { padding-left: 1.5em; margin: 0.35em 0; }
.markdown-content li { margin: 0.1em 0; line-height: 1.6; }
.markdown-content code {
background: rgba(0,47,167,0.06); padding: 0.1em 0.35em; border-radius: 4px;
font-size: 0.87em; font-family: "SF Mono","JetBrains Mono","Fira Code",Consolas,monospace;
}
.markdown-content pre {
background: #1a1a2e; color: #d4d4e8; padding: 0.85em 1em; border-radius: 10px;
overflow-x: auto; margin: 0.5em 0; font-size: 0.87em;
box-shadow: 0 2px 8px rgba(0,0,0,0.10);
}
.markdown-content pre code { background: none; padding: 0; color: inherit; font-size: 1em; }
.markdown-content blockquote {
border-left: 3px solid var(--accent); padding-left: 1em; color: var(--text-secondary);
margin: 0.5em 0; font-style: italic;
}
/* ── Animations ─────────────────────────────────────── */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeInScale {
from { opacity: 0; transform: scale(0.97); }
to { opacity: 1; transform: scale(1); }
}
.animate-fade-in { animation: fadeIn 0.2s ease-out; }
.animate-fade-in-scale { animation: fadeInScale 0.2s ease-out; }
@keyframes pulse-dot {
0%, 100% { opacity: 0.2; transform: scale(0.8); }
50% { opacity: 1; transform: scale(1); }
}
.typing-dot { animation: pulse-dot 1.4s ease-in-out infinite; }
.typing-dot:nth-child(2) { animation-delay: 0.2s; }
.typing-dot:nth-child(3) { animation-delay: 0.4s; }
/* ── Resize Handle ──────────────────────────────────── */
.resize-handle {
width: 5px;
cursor: col-resize;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
position: relative;
z-index: 10;
}
.resize-handle:hover .resize-handle-line,
.resize-handle:active .resize-handle-line {
opacity: 1;
}
.resize-handle-line {
width: 2px;
height: 32px;
border-radius: 1px;
background: rgba(0,0,0,0.12);
opacity: 0;
transition: opacity 0.15s ease;
}
/* ── Panel transitions ──────────────────────────────── */
.panel-transition {
transition: width 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
/* ── Raw Message Viewer ─────────────────────────────── */
.raw-message-viewer {
font-family: "SF Mono","JetBrains Mono","Fira Code",Consolas,monospace;
font-size: 11px;
line-height: 1.5;
}
.raw-message-viewer .msg-system {
color: #86868b;
}
.raw-message-viewer .msg-user {
color: #002fa7;
}
.raw-message-viewer .msg-assistant {
color: #16a34a;
}
.raw-message-viewer .msg-role {
font-weight: 600;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 2px;
}
.raw-message-viewer .msg-content {
white-space: pre-wrap;
word-break: break-word;
padding: 4px 8px;
border-radius: 6px;
background: rgba(0,0,0,0.02);
margin-bottom: 8px;
max-height: 200px;
overflow-y: auto;
}
+19
View File
@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "兴邦金租智能评审工作台",
description: "兴邦金租项目评审与四 Agent 协同工作台",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-CN">
<body className="antialiased">{children}</body>
</html>
);
}
File diff suppressed because one or more lines are too long
+47
View File
@@ -0,0 +1,47 @@
"use client";
import {useEffect,useMemo,useState} from "react";
import {Activity,ArrowUpRight,Clock3,DatabaseZap,RefreshCw,TimerReset,UsersRound} from "lucide-react";
import AgentNav from "@/components/xingbang/AgentNav";
import IdentitySelector from "@/components/xingbang/IdentitySelector";
import "./operations.css";
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>;
}
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>}
+5
View File
@@ -0,0 +1,5 @@
import BusinessAssistant from "@/components/xingbang/BusinessAssistant";
export default function Home() {
return <BusinessAssistant/>;
}
+5
View File
@@ -0,0 +1,5 @@
import {redirect} from "next/navigation";
export default function LegacyReport({searchParams}:{searchParams:{project?:string}}){
redirect(`/xingbang-workbench${searchParams.project?`?project=${encodeURIComponent(searchParams.project)}&tab=report`:""}`);
}
+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}}
+10
View File
@@ -0,0 +1,10 @@
import type {Metadata} from "next";
export const metadata:Metadata={
title:"演示数据复位",
robots:{index:false,follow:false,noarchive:true},
};
export default function ResetDataLayout({children}:{children:React.ReactNode}){
return children;
}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import {useState} from "react";
import "./resetdata.css";
const RESET_ENDPOINT="/api/risk-agent/project-assistant/demo/reset";
export default function ResetDataPage(){
const [busy,setBusy]=useState(false);
async function reset(){
if(!window.confirm("确认恢复演示初始数据?DEMO-A、DEMO-B、DEMO-C 当前演示操作将被归档。"))return;
setBusy(true);
try{
const response=await fetch(RESET_ENDPOINT,{
method:"POST",
headers:{"Content-Type":"application/json","X-Xingbang-Demo-Role":"admin"},
body:JSON.stringify({confirmation:"RESET-DEMO-A-B-C"}),
});
const result=await response.json();
if(!response.ok)throw Error(typeof result.detail==="string"?result.detail:"数据恢复失败");
window.alert("演示数据已恢复到初始状态。");
}catch(error){
window.alert(error instanceof Error?error.message:"数据恢复失败,请稍后重试。");
}finally{setBusy(false)}
}
return <main className="reset-page">
<button type="button" onClick={reset} disabled={busy} aria-label="一键恢复演示数据">
{busy?"正在恢复……":"一键恢复演示数据"}
</button>
</main>;
}
+37
View File
@@ -0,0 +1,37 @@
.reset-page{
min-height:100vh;
display:grid;
place-items:center;
background:#f4f7f6;
}
.reset-page button{
min-width:260px;
min-height:64px;
padding:18px 30px;
border:0;
border-radius:14px;
background:#c83b31;
color:#fff;
font-size:20px;
font-weight:800;
cursor:pointer;
box-shadow:0 14px 34px rgba(152,35,28,.24);
transition:background .15s ease,transform .15s ease,box-shadow .15s ease;
}
.reset-page button:hover:not(:disabled){
background:#ad2f27;
transform:translateY(-1px);
box-shadow:0 17px 38px rgba(152,35,28,.3);
}
.reset-page button:focus-visible{
outline:4px solid rgba(200,59,49,.25);
outline-offset:4px;
}
.reset-page button:disabled{
opacity:.65;
cursor:wait;
}
+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>;
}
+5
View File
@@ -0,0 +1,5 @@
import {redirect} from "next/navigation";
export default function LegacyReview({searchParams}:{searchParams:{project?:string}}){
redirect(`/xingbang-workbench${searchParams.project?`?project=${encodeURIComponent(searchParams.project)}`:""}`);
}
File diff suppressed because one or more lines are too long
+5
View File
@@ -0,0 +1,5 @@
import {redirect} from "next/navigation";
export default function LegacyTrajectory(){
redirect("/xingbang-workbench");
}
+151
View File
@@ -0,0 +1,151 @@
"use client";
import {useEffect, useRef, useState} from "react";
import {ArrowRight, CheckCircle2, FileText, RefreshCw, ShieldCheck} from "lucide-react";
import AgentNav from "@/components/xingbang/AgentNav";
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", "材料与问题"], ["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.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} : {})};};
async function request(path: string, body?: any) {
const response = await fetch(`/api/risk-agent/project-assistant${path}`, {method: body === undefined ? "GET" : "POST", headers: {...headers(), ...(/\/(runs|reports)$/.test(path) && body ? {"Idempotency-Key": newRequestId()} : {})}, body: body === undefined ? undefined : JSON.stringify(body)});
const data = await response.json();
if (!response.ok) throw Error(typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail || "操作失败"));
return data;
}
async function open(id: string) {
selected.current = id;
const data = await request(`/projects/${encodeURIComponent(id)}`);
if (selected.current !== id) return;
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 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;}
catch (e: any) {setError(e.message); return null;} finally {setBusy(false);}
}
async function download(path: string, name: string) {
try {const r = await fetch(`/api/risk-agent/project-assistant${path}`, {headers: headers(false)}); if (!r.ok) throw Error((await r.json()).detail || "下载失败"); const url = URL.createObjectURL(await r.blob()); const a = window.document.createElement("a"); a.href = url; a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(url), 10000);} catch (e: any) {setError(e.message);}
}
useEffect(() => {load();}, []);
const running = project?.runs?.some((r: any) => ["queued", "running"].includes(r.status));
useEffect(() => {if (!running || !project?.id) return; const id = project.id; const timer = setInterval(() => {if (selected.current === id) open(id).catch((e: any) => setError(e.message));}, 1500); return () => clearInterval(timer);}, [project?.id, running]);
const 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" 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 => {const returned=!isReviewer&&x.workflow_state==="returned";return <button className={`xb-project-card${returned?" is-returned":""}`} key={x.id} disabled={busy} onClick={() => {setTab("overview");isReviewer?claimAndOpen(x):open(x.id).catch(e => setError(e.message));}}><span>{returned?"评审退回 · 待补材料":isReviewer?(x.reviewer_id?"我已领取":"项目池待领取"):(x.data_domain === "demo" ? "演示项目" : "正式项目")}</span><h2>{x.customer}</h2><p>{x.id} · {x.industry} · {x.stage}</p>{returned&&<div className="xb-returned-summary"><small>{x.returned_at?`${new Date(x.returned_at).toLocaleString()} 退回`:"评审已退回"}</small><strong>{x.return_reason||"请打开项目查看评审退回要求"}</strong></div>}{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>{returned?"请优先处理":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) && (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", "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,opinion?:string){const saved=await reviewDecision(null,decision,undefined,opinion);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" 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 === "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>;
}