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:
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import {useEffect, useState} from "react";
|
||||
import {BarChart3, Bot, Inbox, LayoutDashboard, Settings2} from "lucide-react";
|
||||
import IdentitySelector from "./IdentitySelector";
|
||||
import "./navigation.css";
|
||||
|
||||
export function projectLink(href:string, project?:string){return href+(project?`${href.includes("?")?"&":"?"}project=${encodeURIComponent(project)}`:"");}
|
||||
export function useRequestedProject(){
|
||||
const [project,setProject]=useState("");
|
||||
useEffect(()=>setProject(new URLSearchParams(window.location.search).get("project")||""),[]);
|
||||
return project;
|
||||
}
|
||||
|
||||
const ROLE_LABEL:Record<string,string>={manager:"客户经理",reviewer:"评审员",admin:"配置管理员"};
|
||||
|
||||
function identityHeaders(){
|
||||
const token=sessionStorage.getItem("xingbang-access-token"),role=sessionStorage.getItem("xingbang-demo-role");
|
||||
return {...(token?{Authorization:`Bearer ${token}`}:{}) ,...(role?{"X-Xingbang-Demo-Role":role}:{})};
|
||||
}
|
||||
|
||||
export default function AgentNav({active,project,identity:provided}:{active:string;project?:string;identity?:any}){
|
||||
const [loaded,setLoaded]=useState<any>(null),[roleHint,setRoleHint]=useState("");
|
||||
useEffect(()=>{
|
||||
setRoleHint(sessionStorage.getItem("xingbang-demo-role")||"");
|
||||
if(provided)return;
|
||||
fetch("/api/risk-agent/access/context",{headers:identityHeaders()}).then(async response=>{
|
||||
if(response.ok){setLoaded(await response.json());return;}
|
||||
if(response.status===401&&sessionStorage.getItem("xingbang-access-token")&&!sessionStorage.getItem("xingbang-demo-role")){
|
||||
sessionStorage.removeItem("xingbang-access-token");
|
||||
sessionStorage.setItem("xingbang-demo-role","manager");
|
||||
location.reload();
|
||||
}
|
||||
}).catch(()=>null);
|
||||
},[provided]);
|
||||
const identity=provided||loaded;
|
||||
const role=identity?.role||roleHint;
|
||||
const canConfigure=identity?.preview||role==="admin"||role==="reviewer";
|
||||
const canUsePool=role==="manager"||role==="reviewer";
|
||||
return <nav className="xb-agent-nav" aria-label="兴邦对公业务导航">
|
||||
<a href={projectLink("/xingbang-workbench",project)} className="xb-brand" title="兴邦金租项目工作台">
|
||||
<span className="xb-brand-mark" aria-hidden="true">兴</span>
|
||||
<span><b>兴邦金租</b><small>对公智能评审</small></span>
|
||||
</a>
|
||||
<a href="/" className={active==="assistant"?"selected":""}><Bot size={16}/>业务助手</a>
|
||||
<a href={projectLink("/xingbang-workbench",project)} className={active==="overview"?"selected":""}><LayoutDashboard size={16}/>项目工作台</a>
|
||||
{canUsePool&&<a href="/review-pool" className={active==="pool"?"selected":""}><Inbox size={16}/>项目评审池</a>}
|
||||
<a href={projectLink("/operations-center",project)} className={active==="time"?"selected":""}><BarChart3 size={16}/>业务时效</a>
|
||||
{canConfigure&&<a href="/data-access" className={active==="risk"?"selected":""}><Settings2 size={16}/>配置治理</a>}
|
||||
<span className="xb-nav-spacer"/>
|
||||
{project&&<span className="xb-project-context">项目 · {project}</span>}
|
||||
<IdentitySelector identity={identity||{role:roleHint}} pending={!identity}/>
|
||||
</nav>;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
import {useEffect, useState} from "react";
|
||||
|
||||
export default function AssistantConfiguration({request, canEdit}: {request: (path: string, body?: any) => Promise<any>; canEdit: boolean}) {
|
||||
const [catalog, setCatalog] = useState<any>(null), [editor, setEditor] = useState(""), [message, setMessage] = useState(""), [busy, setBusy] = useState(false);
|
||||
async function load() {try {setCatalog(await request("/components"));} catch (e: any) {setMessage(e.message);}}
|
||||
useEffect(() => {load();}, []);
|
||||
async function action(path: string, body: any) {
|
||||
setBusy(true); setMessage("");
|
||||
try {const result = await request(path, body); setMessage(result.passed === false ? "评测未通过,禁止发布。请查看评测明细。" : "操作已记录"); await load();}
|
||||
catch (e: any) {setMessage(e.message);} finally {setBusy(false);}
|
||||
}
|
||||
return <section className="xb-panel xb-wide">
|
||||
<h2>组件版本与发布</h2><p>此区域供管理员和审核人使用。已发布版本不可覆盖;修改时填写新版本号,先评测再由独立审核人发布。本机预览允许单人演练。</p>
|
||||
<p><a href="/data-access">打开数据源、数据卡、Skills 与业务方案管理 →</a></p>
|
||||
{message && <div role="status" className="ra-alert">{message}</div>}
|
||||
<div className="xb-config-list">{catalog?.published.map((c: any) => <article className="xb-finding" key={c.id}><div><b>{c.name}</b><p>{c.kind} · {c.id} · {c.version} · {c.scope}</p><small>{c.content_hash}</small></div>{canEdit && <button className="ra-button" onClick={() => {const {revision, component_id, ...body} = c; setEditor(JSON.stringify({...body, version: c.version + ".next"}, null, 2));}}>基于此版本创建草稿</button>}</article>)}</div>
|
||||
{canEdit && <><label className="ra-field"><span>组件草稿 JSON(业务用户无需填写)</span><textarea className="ra-code" value={editor} onChange={e => setEditor(e.target.value)} placeholder="从已发布组件创建草稿;修改版本、规则、模板或技能依赖"/></label><button className="ra-button" disabled={!editor || busy} onClick={() => {try {action("/components", JSON.parse(editor));} catch {setMessage("JSON 格式不正确");}}}>保存草稿</button></>}
|
||||
<h3>待评测与审核</h3>{catalog?.drafts.map((d: any) => <article className="xb-panel" key={d.id}><b>{d.name} · {d.version}</b><p>{d.status} · {d.edited_by}</p><div className="xb-actions"><button className="ra-button" disabled={busy} onClick={() => action(`/components/${encodeURIComponent(d.id)}/actions`, {action: "evaluate", revision: d.revision, note: "执行隔离回归评测"})}>运行评测</button><button className="ra-button" disabled={busy || !d.evaluation?.passed} onClick={() => action(`/components/${encodeURIComponent(d.id)}/actions`, {action: "publish", revision: d.revision, note: "审核发布"})}>审核发布</button></div>{d.evaluation && <pre>{JSON.stringify(d.evaluation, null, 2)}</pre>}</article>)}
|
||||
<details><summary>历史版本与回退</summary>{catalog?.versions.map((v: any) => <div className="xb-finding" key={v.id}><span>{v.name} · {v.version}</span><button className="ra-button" disabled={busy} onClick={() => action(`/components/${encodeURIComponent(v.id)}/actions`, {action: "rollback", revision: v.revision, note: "人工选择已发布历史版本"})}>切换到该版本</button></div>)}</details>
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
import {useMemo,useState} from "react";
|
||||
import {ArrowUpRight,Archive,Clock3} from "lucide-react";
|
||||
|
||||
const TYPES:Record<string,string>={runs:"历史检查",snapshots:"历史快照",reports:"历史报告",findings:"历史问题",experiences:"历史经验",scenarios:"历史方案"};
|
||||
const ACTIONS:Record<string,string>={"report.created":"生成报告草稿","report.submit":"提交正式评审","report.finalize":"确认评审定稿","report.section":"更新报告章节","scenario.compared":"完成融资方案比较","run.completed":"AI检查完成","run.partial":"AI检查部分完成","run.failed":"AI检查未完成","run.queued":"发起AI检查","document.uploaded":"补充项目材料","supplement.accepted":"确认补件数据","finding.action":"处理项目问题","review.claimed":"评审员领取项目","review.decision":"提交评审结论"};
|
||||
function actor(value:string){if(value?.startsWith("worker-"))return "mini-openclaw 系统";if(value==="demo-manager")return "客户经理";if(value==="demo-reviewer")return "评审员";if(value==="local-preview")return "配置管理员";return value||"系统";}
|
||||
|
||||
export default function AssistantHistory({projectId,records,events=[],request}:{projectId:string;records:any[];events?:any[];request:(path:string)=>Promise<any>}){
|
||||
const [archive,setArchive]=useState<any>(null),[error,setError]=useState("");
|
||||
const counts=useMemo(()=>records.reduce((all:Record<string,number>,item:any)=>({...all,[item.kind]:(all[item.kind]||0)+1}),{}),[records]);
|
||||
const important=events.filter((item:any)=>ACTIONS[item.action]).slice(0,12);
|
||||
async function open(record:any){setError("");try{setArchive(await request(`/projects/${encodeURIComponent(projectId)}/legacy/${record.kind}/${encodeURIComponent(record.id.slice(record.id.indexOf(":")+1))}`));}catch(e:any){setError(e.message)}}
|
||||
return <div className="xb-audit">{records.length>0&&<><div className="xb-history-counts">{Object.entries(counts).map(([kind,count])=><button key={kind} onClick={()=>open(records.find(item=>item.kind===kind))}><Archive size={15}/><span>{TYPES[kind]||kind}</span><strong>{Number(count)}</strong><small>点击查看最近一条</small></button>)}</div><p className="xb-history-note">迁移数据仅供历史对照,不作为本次检查的确认依据。</p></>}{error&&<p role="alert">{error}</p>}
|
||||
<h3>最近关键操作</h3><div className="xb-event-list">{important.map((item:any)=><a key={item.seq} href={`/xingbang-workbench?project=${encodeURIComponent(projectId)}&tab=${item.action.startsWith("report.")?"report":item.action.startsWith("finding.")||item.action.startsWith("supplement.")||item.action.startsWith("document.")?"findings":"overview"}`}><Clock3 size={14}/><span><b>{ACTIONS[item.action]}</b><small>{new Date(item.time).toLocaleString()} · {actor(item.actor)}</small></span><ArrowUpRight size={14}/></a>)}{!important.length&&<p>暂无关键操作。</p>}</div>
|
||||
{archive&&<article className="xb-document"><h3>{TYPES[archive.kind]||"历史档案"}</h3>{archive.body.title&&<b>{archive.body.title}</b>}{archive.body.status&&<p>状态:{archive.body.status}</p>}{archive.body.sections?.map((section:any)=><section key={section.id}><h4>{section.title}</h4><p>{section.content}</p></section>)}<details><summary>技术明细</summary><pre>{JSON.stringify(archive.body,null,2)}</pre></details></article>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import {ChangeEvent,FormEvent,useEffect,useMemo,useState} from "react";
|
||||
import {ArrowUpRight,Bot,FilePlus2,Paperclip,Send,Sparkles,UserRound} from "lucide-react";
|
||||
import AgentNav from "./AgentNav";
|
||||
import IdentitySelector,{ROLE_LABEL} from "./IdentitySelector";
|
||||
import {newRequestId} from "@/lib/request-id";
|
||||
import "./business-assistant.css";
|
||||
|
||||
const ROOT="/api/risk-agent/project-assistant";
|
||||
type Message={id:string;side:"assistant"|"user";text:string;url?:string;objectId?:string};
|
||||
|
||||
function 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}:{})};
|
||||
}
|
||||
|
||||
export default function BusinessAssistant(){
|
||||
const [projects,setProjects]=useState<any[]>([]),[identity,setIdentity]=useState<any>(null),[projectId,setProjectId]=useState("");
|
||||
const [input,setInput]=useState(""),[busy,setBusy]=useState(false),[error,setError]=useState("");
|
||||
const [messages,setMessages]=useState<Message[]>([{id:"hello",side:"assistant",text:"你好,我是兴邦对公业务助手。选择项目后,你可以直接告诉我查状态、发起AI预审、生成报告或补充材料。"}]);
|
||||
const role=identity?.role||"manager",project=projects.find(p=>p.id===projectId);
|
||||
const quick=useMemo(()=>role==="reviewer"?["查看项目状态","发起独立评审","生成评审报告"]:role==="admin"?["查看项目状态"]:["查看项目状态","发起AI预审","生成预审报告","提交正式评审"],[role]);
|
||||
|
||||
async function load(){
|
||||
setError("");
|
||||
try{const response=await fetch(`${ROOT}/projects`,{headers:headers()});const body=await response.json();if(!response.ok)throw Error(body.detail||"身份或项目读取失败");setProjects(body.projects);setIdentity(body.identity);const requested=new URLSearchParams(location.search).get("project");setProjectId(current=>current||(requested&&body.projects.some((p:any)=>p.id===requested)?requested:body.projects[0]?.id||""));}
|
||||
catch(e:any){setError(e.message)}
|
||||
}
|
||||
useEffect(()=>{load()},[]);
|
||||
async function send(text:string){
|
||||
const message=text.trim();if(!message||!projectId||busy)return;
|
||||
if(message.includes("提交")&&message.includes("评审")&&!window.confirm(`确认将 ${projectId} 的AI预审报告提交正式评审?提交后保留当前版本。`))return;
|
||||
setMessages(old=>[...old,{id:newRequestId(),side:"user",text:message}]);setInput("");setBusy(true);setError("");
|
||||
try{const response=await fetch(`${ROOT}/commands`,{method:"POST",headers:{...headers(),"Idempotency-Key":newRequestId()},body:JSON.stringify({project_id:projectId,message})});const body=await response.json();if(!response.ok)throw Error(body.detail||"业务指令执行失败");setMessages(old=>[...old,{id:newRequestId(),side:"assistant",text:body.message,url:body.url,objectId:body.object_id}]);await load();}
|
||||
catch(e:any){setError(e.message);setMessages(old=>[...old,{id:newRequestId(),side:"assistant",text:`未执行:${e.message}`}]);}
|
||||
finally{setBusy(false)}
|
||||
}
|
||||
async function attach(event:ChangeEvent<HTMLInputElement>){
|
||||
const file=event.target.files?.[0];event.target.value="";if(!file||!projectId||busy)return;
|
||||
setMessages(old=>[...old,{id:newRequestId(),side:"user",text:`把材料“${file.name}”补充到 ${projectId}`}]);setBusy(true);setError("");
|
||||
try{const form=new FormData();form.append("file",file);const response=await fetch(`${ROOT}/projects/${encodeURIComponent(projectId)}/documents`,{method:"POST",headers:headers(false),body:form});const body=await response.json();if(!response.ok)throw Error(body.detail||"材料上传失败");setMessages(old=>[...old,{id:newRequestId(),side:"assistant",text:`材料已归档并解析出 ${body.candidates?.length||0} 个候选字段。为避免误改事实,请到项目页确认来源。`,url:`/xingbang-workbench?project=${encodeURIComponent(projectId)}&tab=findings`,objectId:body.id}]);await load();}
|
||||
catch(e:any){setError(e.message);setMessages(old=>[...old,{id:newRequestId(),side:"assistant",text:`材料未补充:${e.message}`}]);}
|
||||
finally{setBusy(false)}
|
||||
}
|
||||
function submit(event:FormEvent){event.preventDefault();send(input)}
|
||||
const conversation=<div className="ba-chat"><div className="ba-messages">{messages.map(message=><article key={message.id} className={message.side}><div>{message.side==="assistant"&&<Bot size={16}/>}<p>{message.text}</p>{message.side==="user"&&<span className="ba-user-icon" aria-label="本人"><UserRound size={17}/></span>}</div>{message.objectId&&<small>操作凭证 · {message.objectId}</small>}{message.url&&<a href={message.url}>查看项目中的结果<ArrowUpRight size={14}/></a>}</article>)}{busy&&<article className="assistant pending"><div><Bot size={16}/><p>正在执行并生成可追溯记录…</p></div></article>}</div>
|
||||
{error&&<div role="alert" className="ba-error">{error}</div>}
|
||||
<form onSubmit={submit}><label className="ba-attach" title="补充项目材料"><Paperclip size={18}/><input aria-label="补充项目材料" type="file" accept=".txt,.csv,.docx,.xlsx,.pdf,.png,.jpg,.jpeg" onChange={attach}/></label><input aria-label="业务指令" value={input} onChange={e=>setInput(e.target.value)} placeholder={`例如:帮我做一次 ${projectId||"当前项目"} 风险研判`}/><button aria-label="发送业务指令" disabled={busy||!input.trim()||!projectId}><Send size={18}/></button></form><footer><FilePlus2 size={13}/>当前身份只会调用本项目授权的 Skills、规则和业务工具,并保留操作凭证。</footer>
|
||||
</div>;
|
||||
|
||||
return <div className="ba-shell"><AgentNav active="assistant" project={projectId} identity={identity}/><main className="ba-main">
|
||||
<header className="ba-heading"><div><p>XINGBANG BUSINESS ASSISTANT</p><h1>一句话,推进对公业务</h1><span>对话和页面按钮共用同一套 Skills、规则、权限与留痕。</span></div><IdentitySelector identity={identity}/></header>
|
||||
<section className="ba-context"><div><span>正在处理</span><select aria-label="选择业务项目" value={projectId} onChange={e=>setProjectId(e.target.value)}>{projects.map(p=><option key={p.id} value={p.id}>{p.id} · {p.customer}</option>)}</select></div>{project&&<><div><span>当前阶段</span><b>{project.stage}</b></div><div><span>下一步</span><b>{project.next_action}</b></div><a href={`/xingbang-workbench?project=${encodeURIComponent(projectId)}`}>进入项目<ArrowUpRight size={15}/></a></>}</section>
|
||||
<section className="ba-workspace"><aside><Sparkles size={18}/><h2>{ROLE_LABEL[role]}快捷操作</h2><p>{role==="reviewer"?"独立查看材料和历史,再形成评审意见。":role==="admin"?"配置能力与数据边界,不替业务人员作判断。":"补证、预审、成稿、提交,一条主线完成。"}</p>{quick.map(item=><button key={item} disabled={busy||!projectId} onClick={()=>send(item)}>{item}<ArrowUpRight size={14}/></button>)}<small><Bot size={13}/>每次执行都会生成对象编号,可回到项目中追溯。</small></aside>
|
||||
{conversation}
|
||||
</section>
|
||||
</main></div>;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import {ShieldCheck} from "lucide-react";
|
||||
|
||||
export const ROLE_LABEL:Record<string,string>={manager:"客户经理",reviewer:"评审员",admin:"配置管理员"};
|
||||
|
||||
export default function IdentitySelector({identity,dark=false,pending=false}:{identity:any;dark?:boolean;pending?:boolean}){
|
||||
const role=identity?.role||(pending?"":"manager");
|
||||
function switchRole(next:string){
|
||||
sessionStorage.removeItem("xingbang-access-token");
|
||||
sessionStorage.setItem("xingbang-demo-role",next);
|
||||
location.reload();
|
||||
}
|
||||
return <label className={`xb-role-switch role-${role}${dark?" dark":""}`}>
|
||||
<span>当前身份</span><ShieldCheck size={16}/>
|
||||
<select aria-label="切换演示身份" value={role} onChange={event=>switchRole(event.target.value)}>
|
||||
{pending&&<option value="" disabled>选择身份</option>}
|
||||
<option value="manager">客户经理</option><option value="reviewer">评审员</option><option value="admin">配置管理员</option>
|
||||
</select>
|
||||
</label>;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"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>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
.ba-shell{min-height:100vh;background:#f1f5f2;color:#173127;font-family:Arial,"Microsoft YaHei",sans-serif}.ba-shell *{box-sizing:border-box}.ba-main{max-width:1420px;margin:auto;padding:44px 38px 70px}.ba-heading{display:flex;justify-content:space-between;align-items:flex-end;gap:24px}.ba-heading>div>p{font-size:10px;letter-spacing:.16em;color:#378265;margin-bottom:10px}.ba-heading h1{font:500 36px/1.2 Georgia,"Microsoft YaHei",serif}.ba-heading>div>span{display:block;color:#74857d;font-size:12px;margin-top:12px}.ba-context{display:grid;grid-template-columns:minmax(280px,1.4fr) .7fr 1fr auto;align-items:center;gap:18px;background:#0e2a1e;color:white;margin:28px 0 16px;padding:18px 22px;border-radius:15px;box-shadow:0 15px 42px #0d2d201c}.ba-context>div{min-width:0;border-right:1px solid #ffffff17}.ba-context span{display:block;font-size:9px;color:#7ba18e;margin-bottom:7px}.ba-context select{width:90%;border:0;background:transparent;color:#eaf7f0;font-size:13px;font-weight:700}.ba-context select option{color:#1a2a22}.ba-context b{display:block;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ba-context>a{display:flex;align-items:center;gap:6px;color:#73dfa9;font-size:11px}.ba-workspace{display:grid;grid-template-columns:290px minmax(0,1fr);min-height:610px;border:1px solid #dfe8e2;border-radius:18px;overflow:hidden;background:#fff;box-shadow:0 30px 80px #274d3a0e}.ba-workspace>aside{padding:30px 24px;background:linear-gradient(160deg,#f7fbf8,#edf5f0);border-right:1px solid #e1eae4}.ba-workspace>aside>svg{color:#2c9c6a}.ba-workspace aside h2{font-size:17px;margin:18px 0 10px}.ba-workspace aside p{color:#718179;font-size:11px;line-height:1.8;margin-bottom:24px}.ba-workspace aside button{width:100%;display:flex;justify-content:space-between;align-items:center;border:1px solid #dbe7df;background:#fff;color:#345a48;padding:12px 13px;border-radius:9px;margin-bottom:9px;font-size:11px;cursor:pointer}.ba-workspace aside button:hover{border-color:#62ad89;color:#17633f}.ba-workspace aside small{display:flex;align-items:flex-start;gap:7px;color:#8a9991;font-size:9px;line-height:1.7;margin-top:28px}.ba-chat{display:flex;flex-direction:column;min-width:0}.ba-messages{flex:1;padding:28px 34px;overflow:auto;max-height:560px}.ba-messages article{max-width:75%;margin-bottom:18px}.ba-messages article>div{display:flex;align-items:flex-start;gap:9px}.ba-messages article p{padding:13px 15px;border-radius:12px;background:#edf4f0;color:#385347;font-size:12px;line-height:1.8}.ba-messages article.user{margin-left:auto}.ba-messages article.user p{background:#17633f;color:#fff}.ba-messages article svg{color:#369367;margin-top:13px;flex-shrink:0}.ba-messages article small{display:block;color:#9aa6a0;font-size:9px;margin:7px 0 0 25px}.ba-messages article a{display:inline-flex;align-items:center;gap:4px;color:#24724f;font-size:10px;margin:6px 0 0 25px}.ba-messages .pending{opacity:.65}.ba-error{margin:0 28px 10px;color:#9c463b;background:#fff0ed;padding:10px 13px;border-radius:8px;font-size:11px}.ba-chat form{display:flex;align-items:center;gap:9px;margin:0 28px;padding:8px 9px;border:1px solid #d8e3dc;border-radius:13px;background:#fff;box-shadow:0 8px 25px #244a370d}.ba-chat form>input{flex:1;border:0;outline:0;padding:10px;font-size:12px}.ba-chat form>button,.ba-attach{width:38px;height:38px;display:grid;place-items:center;border:0;border-radius:9px;cursor:pointer}.ba-chat form>button{background:#17633f;color:#fff}.ba-chat form>button:disabled{opacity:.4}.ba-attach{color:#668078;background:#eff4f1}.ba-attach input{display:none}.ba-chat footer{display:flex;align-items:center;gap:6px;color:#93a199;font-size:9px;padding:10px 38px 20px}@media(max-width:820px){.ba-main{padding:26px 14px 50px}.ba-heading{align-items:flex-start;flex-direction:column}.ba-heading h1{font-size:29px}.ba-context{grid-template-columns:1fr 1fr}.ba-workspace{grid-template-columns:1fr}.ba-workspace>aside{border-right:0;border-bottom:1px solid #e1eae4}.ba-messages{padding:22px 18px}.ba-messages article{max-width:90%}.ba-chat form{margin:0 14px}.ba-chat footer{padding-left:22px}}
|
||||
|
||||
/* Larger business copy and clearer assistant actions. */
|
||||
.ba-shell{font-family:"Microsoft YaHei","PingFang SC",sans-serif;font-size:15px}
|
||||
.ba-heading>div>p{font-size:12px}.ba-heading>div>span{font-size:14px;line-height:1.7}
|
||||
.ba-mode-tabs button{min-height:44px;padding:11px 16px;font-size:14px;border:1px solid transparent;cursor:pointer}
|
||||
.ba-mode-tabs button:hover{background:#e8f1ec;color:#17633f}.ba-mode-tabs button.active{border-color:#bdd9c8;box-shadow:0 3px 12px #1c5a3b12}
|
||||
.ba-context span{font-size:12px}.ba-context select{font-size:15px}.ba-context b{font-size:14px}.ba-context>a{font-size:13px;font-weight:700}
|
||||
.ba-workspace aside h2{font-size:20px}.ba-workspace aside p{font-size:14px;color:#5d7167}.ba-workspace aside button{min-height:46px;padding:12px 15px;border-color:#c9d9d0;font-size:14px;font-weight:600;box-shadow:0 2px 8px #244a3708}
|
||||
.ba-workspace aside button:hover{background:#edf7f1;border-color:#3e8f68;box-shadow:0 4px 14px #244a3712}
|
||||
.ba-workspace aside small{font-size:12px}.ba-messages article p{font-size:15px}.ba-messages article small{font-size:12px}.ba-messages article a{font-size:13px;font-weight:700}
|
||||
.ba-error{font-size:14px}.ba-chat form{border-color:#bdd2c6;box-shadow:0 8px 28px #244a3715}.ba-chat form>input{font-size:15px}.ba-chat form>button,.ba-attach{width:44px;height:44px}.ba-chat footer{font-size:12px}
|
||||
|
||||
/* Give operator messages a full row so the bubble cannot drift into the middle. */
|
||||
.ba-messages article.user{width:100%;max-width:100%;margin-left:0}
|
||||
.ba-messages article.user>div{width:100%;justify-content:flex-end;align-items:flex-start}
|
||||
.ba-messages article.user p{max-width:75%}
|
||||
.ba-user-icon{width:32px;height:32px;margin-top:7px;display:grid;place-items:center;flex:0 0 32px;border:1px solid #c9ded2;border-radius:50%;background:#edf6f1;color:#17633f}
|
||||
.ba-messages article .ba-user-icon svg{margin:0;color:inherit}
|
||||
@media(max-width:820px){.ba-messages article.user p{max-width:88%}}
|
||||
@@ -0,0 +1 @@
|
||||
.xb-legacy{position:relative;font-size:11px;color:#839789;cursor:pointer}.xb-legacy>div{position:absolute;right:0;top:28px;width:220px;padding:12px;background:white;border:1px solid #dfe8e2;box-shadow:0 8px 25px #183e2520;border-radius:8px}.xb-legacy small{display:block;padding:8px;line-height:1.6}
|
||||
@@ -0,0 +1,13 @@
|
||||
.xb-agent-nav{position:sticky;top:0;z-index:100;display:flex;align-items:center;gap:8px;padding:10px 22px;background:#fffffff5;backdrop-filter:blur(18px);border-bottom:1px solid #dfe7e3;color:#667b6d;box-shadow:0 2px 14px #1e3d2e0f;font-family:Arial,"Microsoft YaHei",sans-serif}.xb-agent-nav a{display:flex;align-items:center;gap:7px;padding:8px 10px;min-height:36px;border-radius:7px;font-size:12px;font-weight:500}.xb-agent-nav a:hover{background:#f2f6f3;color:#216b48}.xb-agent-nav a.selected{background:#e4f1e9;color:#17633f;box-shadow:inset 0 0 0 1px #cce2d4}.xb-agent-nav .xb-brand{gap:8px;padding:2px 14px 2px 0;margin-right:2px;border-right:1px solid #e4eae5;border-radius:0}.xb-brand-mark{width:30px;height:30px;display:grid;place-items:center;border-radius:7px;background:#17634c;color:#fff;font-size:15px;font-weight:700;box-shadow:inset 0 -2px 0 #0002}.xb-brand b{display:block;color:#17634c;font-size:13px;line-height:1.1}.xb-brand small{display:block;margin-top:4px;color:#91a197;font-size:9px;line-height:1.1}.xb-nav-spacer{flex:1}.xb-project-context{font-size:10px;color:#58705f;background:#f2f7f3;border:1px solid #deebe1;border-radius:999px;padding:7px 11px;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.xb-identity{display:flex;align-items:center;gap:7px;padding:7px 11px;border-radius:999px;background:#f2f6f3;border:1px solid #dce8e0;font-size:10px;color:#7d8c82;white-space:nowrap}.xb-identity i{width:7px;height:7px;border-radius:50%;background:#55a778;box-shadow:0 0 0 4px #55a77818}.xb-identity b{font-size:11px;color:#244a36}.xb-identity.role-reviewer{background:#eef2fb;border-color:#dce3f3}.xb-identity.role-reviewer i{background:#607bb8}.xb-identity.role-reviewer b{color:#344c82}.xb-identity.role-admin{background:#f8f2e8;border-color:#eadfc9}.xb-identity.role-admin i{background:#bc8541}.xb-identity.role-admin b{color:#76511f}.xb-overview{max-width:1480px;margin:auto;padding:32px}.xb-overview-heading{display:flex;justify-content:space-between;gap:16px;align-items:center;margin-bottom:28px}.xb-overview-heading h1{font-size:26px;font-weight:600}.xb-overview-heading p{font-size:12px;color:#84948b;margin-top:9px}.xb-role-switch{display:flex;align-items:center;gap:8px;border:1px solid #cee0d5;background:#fff;padding:9px 12px;border-radius:12px;box-shadow:0 7px 24px #183b2b0b}.xb-role-switch>span{font-size:10px;color:#87978f}.xb-role-switch svg{color:#27805a}.xb-role-switch select{font-weight:700;color:#245c43;border:0;background:transparent;outline:none}.xb-role-switch.dark{border-color:#ffffff1f;background:#ffffff0d;box-shadow:none}.xb-role-switch.dark>span{color:#8daf9e}.xb-role-switch.dark svg{color:#55d49b}.xb-role-switch.dark select{color:#e1f5eb}.xb-role-switch.dark select option{color:#17251e}@media(max-width:760px){.xb-agent-nav{padding:8px 10px;gap:3px;flex-wrap:wrap}.xb-agent-nav a{font-size:10px;padding:6px}.xb-agent-nav .xb-brand{border:0;padding-right:5px}.xb-brand small,.xb-nav-spacer,.xb-identity>span{display:none}.xb-identity{margin-left:auto}.xb-project-context{order:10;width:100%;max-width:none}.xb-overview{padding:22px 15px}.xb-overview-heading{align-items:flex-start;flex-wrap:wrap}}
|
||||
|
||||
/* Business UI readability: keep navigation and identity obvious at a glance. */
|
||||
.xb-agent-nav{min-height:64px;padding:11px 24px;gap:10px;font-family:"Microsoft YaHei","PingFang SC",sans-serif}
|
||||
.xb-agent-nav a{min-height:42px;padding:10px 13px;border-radius:9px;font-size:14px;font-weight:600;transition:background-color .16s,color .16s,box-shadow .16s}
|
||||
.xb-agent-nav a.selected{box-shadow:inset 0 0 0 1px #bdd8c8,0 3px 10px #1d563a12}
|
||||
.xb-brand-mark{width:36px;height:36px;font-size:17px}
|
||||
.xb-brand b{font-size:15px}.xb-brand small{font-size:11px}
|
||||
.xb-project-context{font-size:12px;padding:8px 13px}
|
||||
.xb-role-switch{min-height:42px;padding:8px 13px;border-color:#bcd5c6;box-shadow:0 4px 16px #183b2b12}
|
||||
.xb-role-switch>span{font-size:12px}.xb-role-switch select{font-size:14px;cursor:pointer}
|
||||
.xb-overview-heading h1{font-size:30px;line-height:1.35}.xb-overview-heading p{font-size:14px;line-height:1.75;color:#667970}
|
||||
@media(max-width:760px){.xb-agent-nav a{font-size:12px;min-height:38px;padding:8px}.xb-brand b{font-size:14px}.xb-overview-heading h1{font-size:26px}}
|
||||
Reference in New Issue
Block a user