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
+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>;
}