Files
mini-openclaw/src/app/xingbang-workbench/page.tsx
T

152 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>;
}