56 lines
8.2 KiB
TypeScript
56 lines
8.2 KiB
TypeScript
"use client";
|
||
|
||
import {useEffect, useState} from "react";
|
||
import {AlertTriangle, CheckCircle2, CircleDashed, Database, MapPin, ShieldAlert} from "lucide-react";
|
||
|
||
const AI_STATUS:Record<string,{label:string;note:string}>={
|
||
pass:{label:"未发现异常",note:"AI检查正常,仍需评审员按本章节确认"},
|
||
issue:{label:"发现异常",note:"请结合原始数据作出独立判断"},
|
||
missing:{label:"资料不足",note:"缺少数据或口径,不能视为检查通过"},
|
||
not_run:{label:"尚未检查",note:"请先发起独立评审"},
|
||
};
|
||
|
||
const CHECK_LABEL:Record<string,string>={pass:"正常",issue:"异常",unknown:"资料不足",error:"执行失败",not_applicable:"不适用"};
|
||
|
||
function valueText(value:any){
|
||
if(value===null||value===undefined||value==="")return "未提供";
|
||
if(typeof value==="object")return Object.entries(value).map(([key,item])=>`${key}:${String(item)}`).join(";");
|
||
return String(value);
|
||
}
|
||
|
||
export default function ReviewerWorkspace({topics,reviewed,blocking,busy,opinion,setOpinion,onDecision,onComplete,hasRun}:any){
|
||
const total=topics.length;
|
||
const [returnReason,setReturnReason]=useState("");
|
||
const issues=topics.filter((item:any)=>item.ai_status==="issue").length;
|
||
const incomplete=topics.filter((item:any)=>["missing","not_run"].includes(item.ai_status)).length;
|
||
const pending=topics.filter((item:any)=>!item.decision).map((item:any)=>item.title);
|
||
const canApprove=hasRun&&total>0&&reviewed===total&&!blocking.length;
|
||
return <section className="xb-panel xb-wide xb-full-review" id="full-review">
|
||
<header className="xb-review-heading"><div><span>评审员独立判断</span><h2>全链路评审</h2><p>每个结论自动绑定本次数据快照、材料定位和检查规则;AI只提示,最终结论由评审员选择。</p></div><div className="xb-review-progress"><strong>{reviewed}/{total}</strong><small>必审领域已确认</small></div></header>
|
||
<div className="xb-review-kpis"><article><b>{total}</b><span>必审领域</span></article><article className="danger"><b>{issues}</b><span>AI发现异常</span></article><article className="warning"><b>{incomplete}</b><span>资料不足/未检查</span></article><article><b>{blocking.length}</b><span>尚未闭环</span></article></div>
|
||
<label className="ra-field xb-review-opinion"><span>常用意见模板(选择后填入尚未确认的章节)</span><select value={opinion} onChange={(event)=>setOpinion(event.target.value)}><option value="">请选择,或直接在章节内填写</option><option>请补充最新材料并说明数据口径</option><option>风险已有缓释措施,整体可控</option><option>AI判断不适用于本项目,以人工核实为准</option><option>请补充现场核实记录</option><option>该事项不适用于本项目</option></select></label>
|
||
<div className="xb-review-lanes" aria-hidden="true"><span><Database size={15}/>数据与来源</span><span><ShieldAlert size={15}/>AI辅助判断</span><span><CheckCircle2 size={15}/>评审员结论</span></div>
|
||
<div className="xb-topic-list">{topics.map((topic:any)=><ReviewTopic key={topic.id} topic={topic} busy={busy} opinion={opinion} onDecision={onDecision}/>)}</div>
|
||
{!hasRun&&<div className="xb-review-hint"><CircleDashed size={18}/><span>请先在页面上方发起一次独立评审,系统才会冻结本次数据与规则版本。</span></div>}
|
||
<div className="xb-review-final"><div><CheckCircle2 size={21}/><span><b>完成全链路核查</b><small>{pending.length?`待确认:${pending.join("、")}`:blocking.length?`尚未闭环:${blocking.map((item:any)=>item.title).join("、")}`:"所有必审领域均已形成可追溯结论"}</small></span></div><label className="xb-return-reason"><span>退回原因(退回时必填)</span><textarea aria-label="退回补充材料原因" value={returnReason} maxLength={2000} onChange={(event)=>setReturnReason(event.target.value)} placeholder="例如:请补充最新银行流水并说明回款口径"/></label><button className="ra-button" title={!hasRun?"请先完成独立评审":!returnReason.trim()?"请先填写退回原因":"退回客户经理补充材料"} disabled={busy||!hasRun||!returnReason.trim()} onClick={()=>onComplete("退回",returnReason.trim())}>退回补充材料</button><button className="ra-button primary" disabled={busy||!canApprove} onClick={()=>onComplete("批准")}>批准并生成评审报告</button></div>
|
||
</section>;
|
||
}
|
||
|
||
function ReviewTopic({topic,busy,opinion,onDecision}:any){
|
||
const status=AI_STATUS[topic.ai_status]||AI_STATUS.not_run;
|
||
const needsAttention=topic.ai_status!=="pass";
|
||
const decision=topic.decision;
|
||
const [draft,setDraft]=useState(decision?.opinion||"");
|
||
useEffect(()=>{setDraft(decision?.opinion||"");},[decision?.id,decision?.opinion]);
|
||
useEffect(()=>{if(!decision&&opinion)setDraft(opinion);},[opinion,decision]);
|
||
const requiredOpinion=!draft.trim();
|
||
return <details className={`xb-review-topic status-${topic.ai_status}`} open={needsAttention||!decision}>
|
||
<summary><span className="xb-topic-state">{topic.ai_status==="pass"?<CheckCircle2/>:<AlertTriangle/>}</span><span><b>{topic.title}</b><small>{status.label} · {topic.sources.length} 条数据来源 · {topic.checks.length} 项AI检查</small></span><strong>{decision?`已确认:${decision.decision}`:"待评审员确认"}</strong></summary>
|
||
<div className="xb-topic-lanes">
|
||
<section><h3>数据与来源</h3>{topic.sources.length?topic.sources.map((source:any)=><details className="xb-source" key={source.id}><summary><span><b>{source.source||"项目数据"}</b><small><MapPin size={12}/>{source.locator||"已定位"}</small></span><em>查看来源</em></summary><p><b>数据项</b>{source.field||"未标记"}</p><p><b>原始值</b>{valueText(source.value)}</p><p><b>证据编号</b>{source.id}</p><p><b>冻结快照</b>{topic.snapshot_id||"尚未形成"}</p></details>):<div className="xb-empty-source"><AlertTriangle size={17}/>没有可验证的数据来源</div>}</section>
|
||
<section><h3>AI辅助判断</h3><div className={`xb-ai-state status-${topic.ai_status}`}><b>{status.label}</b><p>{status.note}</p></div>{topic.checks.map((check:any,index:number)=><article className="xb-ai-check" key={check.code||index}><span>{CHECK_LABEL[check.result]||check.result}</span><div><b>{check.title}</b><p>{check.suggested_action||"无需额外处理"}</p><small>依据:{(check.basis_ids||[]).join("、")||"人工核查项"}</small></div></article>)}</section>
|
||
<section><h3>评审员结论</h3>{decision&&<div className="xb-current-decision"><b>{decision.decision}</b><p>{decision.opinion||"已复核本章节数据和AI结果"}</p><small>{new Date(decision.updated_at).toLocaleString()} · 已绑定 {decision.evidence_ids?.length||0} 条证据</small></div>}<label className="xb-decision-editor"><span>{decision?"修改本章节意见":"填写本章节意见"}</span><textarea aria-label={`${topic.title}评审意见`} value={draft} maxLength={2000} onChange={(event)=>setDraft(event.target.value)} placeholder={topic.ai_status==="pass"?"可填写补充判断;无补充可直接确认":"请说明判断依据、补件要求或处理意见"}/><small>{draft.length}/2000</small></label><div className="xb-decision-actions">{topic.ai_status==="pass"?<button className="ra-button primary" disabled={busy} onClick={()=>onDecision(topic.id,"确认无异常",draft.trim()||"已复核本章节数据来源和AI检查结果")}>{decision?"保存本章节":"确认本章节"}</button>:<><button className="ra-button" disabled={busy||requiredOpinion} onClick={()=>onDecision(topic.id,"确认风险可控",draft)}>风险可控</button><button className="ra-button" disabled={busy||requiredOpinion} onClick={()=>onDecision(topic.id,"要求补件",draft)}>要求补件</button><button className="ra-button" disabled={busy||requiredOpinion} onClick={()=>onDecision(topic.id,"转现场核查",draft)}>现场核查</button><button className="ra-button" disabled={busy||requiredOpinion} onClick={()=>onDecision(topic.id,"不采纳AI判断",draft)}>不采纳AI</button><button className="ra-button" disabled={busy||requiredOpinion} onClick={()=>onDecision(topic.id,"不适用",draft)}>不适用</button></>}</div><small className="xb-trace-note">保存后自动记录:评审员、时间、数据快照、证据编号、Skill/规则版本。</small></section>
|
||
</div>
|
||
</details>;
|
||
}
|