feat(v2): 实现SQLite对公租赁经验加工与卡片预审轨迹闭环

This commit is contained in:
2026-10-01 13:38:03 +08:00
parent 9c351b08d3
commit fa4163b2d0
60 changed files with 2111 additions and 59 deletions
+6 -4
View File
@@ -12,7 +12,7 @@ export function useRequestedProject(){
return project;
}
const ROLE_LABEL:Record<string,string>={manager:"客户经理",reviewer:"评审员",admin:"配置管理员"};
const ROLE_LABEL:Record<string,string>={manager:"客户经理",admin:"配置管理员"};
function identityHeaders(){
const token=sessionStorage.getItem("xingbang-access-token"),role=sessionStorage.getItem("xingbang-demo-role");
@@ -28,6 +28,7 @@ function identityHeaders(){
export default function AgentNav({active,project,identity:provided}:{active:string;project?:string;identity?:any}){
const [loaded,setLoaded]=useState<any>(null),[roleHint,setRoleHint]=useState("");
useEffect(()=>{
if(sessionStorage.getItem("xingbang-demo-role")==="reviewer"){sessionStorage.setItem("xingbang-demo-role","manager");location.reload();return;}
setRoleHint(sessionStorage.getItem("xingbang-demo-role")||"");
if(provided)return;
fetch("/api/risk-agent/access/context",{headers:identityHeaders()}).then(async response=>{
@@ -41,8 +42,8 @@ export default function AgentNav({active,project,identity:provided}:{active:stri
},[provided]);
const identity=provided||loaded;
const role=identity?.role||roleHint;
const canConfigure=identity?.preview||role==="admin"||role==="reviewer";
const canUsePool=role==="manager"||role==="reviewer";
const canConfigure=identity?.preview||role==="admin";
const canUsePool=role==="manager";
return <nav className="xb-agent-nav" aria-label="兴邦对公业务导航">
<a href={projectLink("/",project)} className="xb-brand" title="兴邦金租智审工作台">
<span className="xb-brand-mark" aria-hidden="true">兴</span>
@@ -53,10 +54,11 @@ export default function AgentNav({active,project,identity:provided}:{active:stri
<a href={projectLink("/experience-center",project)} className={active==="experience"?"selected":""}><History size={16}/>历史经验加工</a>
<a href={projectLink("/trajectory-center",project)} className={active==="trajectory"?"selected":""}><GitBranch size={16}/>轨迹复盘</a>
<a href={projectLink("/xingbang-workbench",project)} className={active==="overview"?"selected":""}><LayoutDashboard size={16}/>项目工作台</a>
{canUsePool&&<a href={projectLink("/review-pool",project)} 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>}
<a href="/assistant" className={active==="assistant"?"selected":""}><Bot size={16}/>业务助手</a>
<span className="xb-nav-group">miniOpenClaw · Skills / Rules<br/>受控工具 · 证据留痕</span>
<span className="xb-nav-spacer"/>
{project&&<span className="xb-project-context">项目 · {project}</span>}
<IdentitySelector identity={identity||{role:roleHint}} pending={!identity} dark/>
+3 -3
View File
@@ -2,10 +2,10 @@
import {ShieldCheck} from "lucide-react";
export const ROLE_LABEL:Record<string,string>={manager:"客户经理",reviewer:"评审员",admin:"配置管理员"};
export const ROLE_LABEL:Record<string,string>={manager:"客户经理",admin:"配置管理员"};
export default function IdentitySelector({identity,dark=false,pending=false}:{identity:any;dark?:boolean;pending?:boolean}){
const role=identity?.role||(pending?"":"manager");
const role=(identity?.role === "reviewer" ? "manager" : identity?.role)||(pending?"":"manager");
function switchRole(next:string){
sessionStorage.removeItem("xingbang-access-token");
sessionStorage.setItem("xingbang-demo-role",next);
@@ -15,7 +15,7 @@ export default function IdentitySelector({identity,dark=false,pending=false}:{id
<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>
<option value="manager">客户经理</option><option value="admin">配置管理员</option>
</select>
</label>;
}
+4 -3
View File
@@ -34,7 +34,7 @@ export default function ImportBatchPanel({project, request, base, headers, busy,
function pickFiles(selected: FileList | null) {
if (!selected?.length) return;
const chosen = Array.from(selected);
chosen.forEach((file) => {files.current[file.name] = file;});
chosen.forEach((file) => {files.current[file.webkitRelativePath || file.name] = file;});
setPending(chosen);
setError("");
setNotice(`已选择 ${chosen.length} 个文件;登记清单后逐个上传,未登记的文件不会被解析。`);
@@ -44,7 +44,7 @@ export default function ImportBatchPanel({project, request, base, headers, busy,
setBusy(true); setError(""); setNotice("");
try {
const digests: Record<string, string> = {};
for (const file of pending) digests[file.name] = await fileDigest(file);
for (const file of pending) digests[file.webkitRelativePath || file.name] = await fileDigest(file);
const created = await request(`${PRESCREEN}${projectPath}/import-batches`, {manifest: manifestOf(pending, digests)});
setOpenId(created.batch.id);
setPending([]);
@@ -60,7 +60,7 @@ export default function ImportBatchPanel({project, request, base, headers, busy,
setBusy(true); setError(""); setNotice("");
try {
const form = new FormData();
form.append("file", file);
form.append("file", file, file.webkitRelativePath || file.name);
form.append("batch_id", batch.id);
form.append("item_id", itemId);
const response = await fetch(`/api/risk-agent/project-assistant${base}/documents`, {method: "POST", headers: headers(false), body: form});
@@ -103,6 +103,7 @@ export default function ImportBatchPanel({project, request, base, headers, busy,
<p>一次登记一批文件,再逐个上传。登记只生成清单;<b>未上传或未确认的文件不会进入项目事实</b>。多来源数值不一致时会保留冲突并要求人工裁定,不会按上传先后覆盖。</p>
<label className="ra-field"><span>选择文件集合(PDF / XLSX / CSV / TXT / DOCX / 图片;单文件 ≤ 10 MB,单批 ≤ 20 个文件且合计 ≤ 20 MB)</span>
<label className="ra-field"><span>01 · 资料文件夹(表格、PDF 等)</span><input aria-label="选择资料文件夹" type="file" multiple {...{webkitdirectory:"",directory:""}} disabled={busy} onChange={(e) => {pickFiles(e.target.files); e.target.value = "";}}/></label>
<input aria-label="选择材料集合" type="file" multiple disabled={busy} onChange={(e) => {pickFiles(e.target.files); e.target.value = "";}}/>
</label>
{pending.length > 0 && <>
@@ -0,0 +1,36 @@
'use client';
import {useEffect,useRef,useState} from 'react';
import {FolderPlus,FolderOpen,Plus,Sparkles,FileCheck2} from 'lucide-react';
import SkillPackageDialog from './SkillPackageDialog';
import {encodeFiles} from '@/lib/lease-library';
export default function KnowledgePools({project,identity,send,refreshKey=0}:any) {
const [cards,setCards]=useState<any[]>([]),[fields,setFields]=useState<string[]>([]),[error,setError]=useState(''),[notice,setNotice]=useState('');
const [loading,setLoading]=useState(true),[busy,setBusy]=useState(false),[kind,setKind]=useState('skill'),[opened,setOpened]=useState<any>(null),[expanded,setExpanded]=useState('');
const [pending,setPending]=useState<File[]>([]),[requiredField,setRequiredField]=useState(''),[creating,setCreating]=useState(false),[title,setTitle]=useState(''),[field,setField]=useState(''),[threshold,setThreshold]=useState(''),[instruction,setInstruction]=useState('');
const [revision,setRevision]=useState(''),[samples,setSamples]=useState('');
const folder=useRef<HTMLInputElement>(null); const canEdit=identity?.role==='admin';
async function refresh(){try{const data=await send('GET',`/library/cards?project_id=${encodeURIComponent(project.id)}`);setCards(data.items);setFields(data.fields);}catch(e:any){setError(e.message);}finally{setLoading(false);}}
useEffect(()=>{setExpanded('');setPending([]);refresh();},[project.id,refreshKey]);
async function action(work:()=>Promise<any>){setBusy(true);setError('');setNotice('');try{await work();await refresh();}catch(e:any){setError(e.message);}finally{setBusy(false);}}
async function importFolder(){await action(async()=>{const files=await encodeFiles(pending);await send('POST','/library/skills/import',{project_id:project.id,files,required_field:requiredField||null});setPending([]);setNotice('标准 Skills 文件夹已保存,卡片以人工导入草稿进入池中。');});}
async function create(){await action(async()=>{const content=kind==='skill'?{engine:'required',inputs:[field],code:'manual.'+field,title,instructions:instruction,description:instruction}:{effect:'advisory',on_match:'supplement',description:instruction,conditions:[{code:'manual.'+field,field,operator:'lt',threshold,title,suggestion:instruction}]};
await send('POST',`/prescreen/projects/${encodeURIComponent(project.id)}/knowledge-candidates`,{kind,title,origin:'manual',version:'1.0.0',content});setCreating(false);setNotice('人工草稿已进入卡片池,评测与独立审核后才可用于预审。');});}
async function transition(card:any,step:string){await action(async()=>{const result=await send('POST',`/prescreen/knowledge-candidates/${encodeURIComponent(card.id)}/transitions`,{action:step});setNotice(step==='evaluate'?(result.passed?'当前版本隔离评测通过。':'评测未通过:'+result.checks.map((c:any)=>c.detail||c.case).join(';')):'卡片已发布。');});}
async function revise(card:any){await action(async()=>{const changes:any={};if(samples.trim())changes.test_cases=JSON.parse(samples);await send('PATCH',`/prescreen/knowledge-candidates/${encodeURIComponent(card.id)}`,{requirement:revision,changes,note:revision||'补充独立测试样本',expected_revision:card.revision});setNotice('已保存草稿修订,需要重新评测。');});}
const shown=cards.filter(c=>c.kind===kind);
return <section className="v2-pool">
<div className="v2-section-heading"><div><span className="v2-kicker">KNOWLEDGE ASSETS</span><h2>可复用的经验卡片池</h2><p>来源颜色与风险等级分别表达;只有已发布卡片能装配 Agent。</p></div>{canEdit&&<div className="xb-actions"><button className="ra-button" onClick={()=>{setKind('skill');folder.current?.click();}} disabled={busy}><FolderPlus size={16}/>从文件夹添加 Skills</button><button className="ra-button" onClick={()=>setCreating(!creating)} disabled={busy}><Plus size={16}/>人工创建</button></div>}</div>
<input ref={folder} type="file" multiple hidden {...{webkitdirectory:'',directory:''}} aria-label="选择标准 Skills 文件夹" onChange={e=>{setPending(Array.from(e.target.files||[]));e.currentTarget.value='';}}/>
{error&&<p role="alert" className="ra-alert error">{error}</p>}{notice&&<p role="status" className="ra-alert">{notice}</p>}
<div className="v2-tabs" role="tablist" aria-label="知识卡片类型">{[['skill','Skills'],['rule_set','Rules'],['data_card','数据卡片']].map(([key,label])=><button role="tab" aria-selected={kind===key} key={key} onClick={()=>{setKind(key);setCreating(false);}}>{label} <span>{cards.filter(c=>c.kind===key).length}</span></button>)}</div>
{pending.length>0&&<div className="v2-import-preview"><h3>确认导入标准文件夹 · {pending.length} 个文件</h3><p>{pending[0]?.webkitRelativePath.split('/')[0]} · 总计 {(pending.reduce((n,f)=>n+f.size,0)/1024).toFixed(1)} KB</p><ul>{pending.slice(0,8).map(f=><li key={f.webkitRelativePath}>{f.webkitRelativePath}</li>)}</ul><label className="ra-field"><span>执行方式</span><select value={requiredField} onChange={e=>setRequiredField(e.target.value)}><option value="">原生模型方法(需模型语义评测,暂存草稿)</option>{fields.map(f=><option key={f} value={f}>受控缺件核查:{f}</option>)}</select></label><p className="v2-muted">可绑定一个标准字段作为缺件核查入口;文件内容保留,工具白名单由系统限定。</p><div className="xb-actions"><button className="ra-button primary" disabled={busy} onClick={importFolder}>{busy?'正在保存…':'确认导入'}</button><button className="ra-button" disabled={busy} onClick={()=>setPending([])}>取消</button></div></div>}
{creating&&kind!=='data_card'&&<div className="v2-import-preview"><h3>人工创建 {kind==='skill'?'Skill':'Rule'} 草稿</h3><div className="v2-form-grid"><label className="ra-field"><span>卡片名称</span><input value={title} onChange={e=>setTitle(e.target.value)}/></label><label className="ra-field"><span>核查字段</span><select value={field} onChange={e=>setField(e.target.value)}><option value="">选择标准字段</option>{fields.map(f=><option key={f}>{f}</option>)}</select></label>{kind==='rule_set'&&<label className="ra-field"><span>建议阈值(低于此值提示补件)</span><input type="number" value={threshold} onChange={e=>setThreshold(e.target.value)}/></label>}</div><label className="ra-field"><span>用自然语言描述核查方法、适用范围与例外(当前先按说明保存)</span><textarea value={instruction} onChange={e=>setInstruction(e.target.value)}/></label><button className="ra-button primary" disabled={busy||!field||!title||!instruction||(kind==='rule_set'&&!threshold)} onClick={create}>保存人工草稿</button></div>}
{loading&&<p role="status">正在读取 SQLite 卡片版本…</p>}
{!loading&&!shown.length&&<div className="ra-empty"><h3>尚无{kind==='rule_set'?' Rules ':'此类'}卡片</h3><p>可以提炼历史经验或人工创建;空池不会自动补成已发布卡片。</p></div>}
<div className="v2-card-grid">{shown.map(card=>{const history=card.origin==='history_derived';const key=`${card.id}:${card.version}:${card.status}:${card.revision}`;return <article key={key} className={`v2-knowledge-card ${history?'from-ai':'from-manual'}`}><div className="v2-card-top"><span>{history?<Sparkles size={14}/>:<FileCheck2 size={14}/>} {history?'AI 提炼':card.import_method==='folder'?'文件夹导入':card.edited_by?'人工创建':'系统内置'}</span><span>{card.status==='published'?'已发布':'草稿'} · v{card.version}</span></div><h3>{card.name}</h3><p>{card.description||card.instructions||'查看卡片依据与适用条件'}</p><div className="v2-card-meta">{card.case_count?`来源 ${card.case_count} 条案例 · `:''}{card.engine==='model'?'模型方法':card.kind==='rule_set'?'建议规则':'受控核查'}{card.scope==='demo'?' · 模拟域':''}</div><div className="xb-actions">{card.kind==='skill'&&<button className="ra-button" onClick={()=>setOpened(card)}><FolderOpen size={14}/>标准文件夹</button>}<button className="ra-button" onClick={()=>{setExpanded(expanded===key?'':key);setRevision('');setSamples('');}}>查看依据与治理</button></div>
{expanded===key&&<div className="v2-card-details"><p>适用条件 {JSON.stringify(card.conditions||[])};例外 {card.exception_note||'未定义,须人工确认'}</p><p>来源 {(card.source_evidence_ids||[]).join('、')||'人工/内置配置'};版本哈希 {card.content_hash?.slice(0,16)}</p>{card.evaluation&&<p>隔离评测:{card.evaluation.passed?'通过':'未通过'}</p>}{canEdit&&card.status==='draft'&&<><label className="ra-field"><span>自然语言修订说明(需要真实模型)</span><textarea value={revision} onChange={e=>setRevision(e.target.value)}/></label><label className="ra-field"><span>独立测试样本 JSON(执行回归,不代表真实预测准确率)</span><textarea value={samples} placeholder='[{"name":"缺件样本","values":{},"expected_outputs":{},"expected_checks":{"核查编码":"unknown"}}]' onChange={e=>setSamples(e.target.value)}/></label><div className="xb-actions"><button className="ra-button" disabled={busy||(!revision&&!samples)} onClick={()=>revise(card)}>保存修订</button><button className="ra-button" disabled={busy} onClick={()=>transition(card,'evaluate')}>运行隔离评测</button>{card.edited_by!==identity?.id&&<button className="ra-button primary" disabled={busy||!card.evaluation?.passed} onClick={()=>transition(card,'publish')}>审核发布</button>}</div>{card.edited_by===identity?.id&&<p>发布需要独立审核人:另一位配置管理员。</p>}</>}</div>}
</article>;})}</div>
{opened&&<SkillPackageDialog card={opened} send={send} onClose={()=>setOpened(null)}/>}
</section>;
}
@@ -0,0 +1,9 @@
'use client';
import {useEffect,useRef} from 'react';
import {X} from 'lucide-react';
import {LeaseCase} from '@/lib/lease-library';
export default function LeaseCaseDialog({item,onClose}:{item:LeaseCase;onClose:()=>void}) {
const dialog=useRef<HTMLDialogElement>(null);
useEffect(()=>{dialog.current?.showModal();},[]);
return <dialog ref={dialog} className="v2-dialog" onCancel={onClose} aria-labelledby="case-detail-title"><header><div><span className="v2-kicker">CORPORATE FINANCIAL LEASE</span><h2 id="case-detail-title">{item.lessee}</h2><p>{item.id} · 模拟案例 · {item.year}年</p></div><button className="ra-button" aria-label="关闭案例详情" onClick={onClose}><X size={18}/></button></header><div className="v2-form-grid">{[['行业',item.industry],['租赁方式',item.mode],['融资金额',`${item.amount.toLocaleString()} 万元`],['租赁期限',`${item.term} 个月`],['评审会结果',item.outcome],['增信方式',item.guarantee]].map(([key,value])=><div key={key} className="v2-fact"><span>{key}</span><b>{value}</b></div>)}</div><h3>初始材料中的模拟财务数据</h3><div className="v2-form-grid">{[["经营现金流",item.operating_cashflow],["租赁物价值",item.asset_value],["营业收入",item.revenue],["总负债",item.total_liabilities],["总资产",item.total_assets]].map(([label,value])=><div key={label} className="v2-fact"><span>{label}</span><b>{Number(value).toLocaleString()} 万元</b></div>)}</div><details style={{marginTop:16}}><summary>查看初始材料时点快照</summary><pre className="v2-code-input" style={{whiteSpace:"pre-wrap"}}>{JSON.stringify(JSON.parse(item.initial_snapshot||"{}"),null,2)}</pre></details><h3>租赁物</h3><p>{item.asset}</p><h3>评审过程发现的问题</h3><p>{item.issue}</p><h3>补充材料记录</h3><p>{item.supplements}</p><h3>会议判断依据</h3><p>{item.meeting_reason}</p><p className="v2-muted">所有企业、设备交易与会议记录均为虚构。暂缓与未送审没有通过/否决标签,不能用作负样本。</p></dialog>;
}
@@ -0,0 +1,45 @@
'use client';
import {useEffect,useRef,useState} from 'react';
import {Database,Search,SlidersHorizontal,Sparkles,Check,ChevronLeft,ChevronRight} from 'lucide-react';
import {LeaseCase,industries,outcomes} from '@/lib/lease-library';
import KnowledgePools from './KnowledgePools';
import LeaseCaseDialog from './LeaseCaseDialog';
export default function LeaseLibraryWorkbench({project,identity,send}:any) {
const [items,setItems]=useState<LeaseCase[]>([]),[total,setTotal]=useState(0),[source,setSource]=useState(''),[modelAvailable,setModelAvailable]=useState(false);
const [mode,setMode]=useState('filters'),[filters,setFilters]=useState<Record<string,string>>({}),[text,setText]=useState(''),[requirement,setRequirement]=useState('');
const [preview,setPreview]=useState<any>(null),[selection,setSelection]=useState<any>(null),[picked,setPicked]=useState<string[]>([]),[useAll,setUseAll]=useState(true),[page,setPage]=useState(0),[detail,setDetail]=useState<LeaseCase|null>(null);
const [state,setState]=useState<'loading'|'ready'|'empty'|'error'>('loading'),[submitting,setSubmitting]=useState(false),[error,setError]=useState(''),[notice,setNotice]=useState(''),[record,setRecord]=useState<any>(null),[refreshKey,setRefreshKey]=useState(0);
const seq=useRef(0),canEdit=identity?.role==='admin';
useEffect(()=>{let live=true;send('GET','/library/cases').then((data:any)=>{if(!live)return;setItems(data.items);setTotal(data.total);setSource(data.source);setModelAvailable(data.model_available);setState(data.items.length?'ready':'empty');}).catch((e:any)=>{if(live){setState('error');setError(e.message);}});return()=>{live=false;seq.current++;};},[project.id]);
function invalidate(){seq.current++;setPreview(null);setSelection(null);setPicked([]);setRecord(null);setNotice('条件已修改,请重新预览并确认案例。');}
function update(field:string,value:string){setFilters({...filters,[field]:value});invalidate();}
async function query(){const ticket=++seq.current;setSubmitting(true);setError('');setSelection(null);setPicked([]);setRecord(null);try{const data=await send('POST','/library/queries',{mode,text,filters:mode==='filters'?filters:{}});if(ticket!==seq.current)return;setItems(data.items);setPreview(data);setPage(0);setUseAll(true);setState(data.items.length?'ready':'empty');setNotice(`实际匹配 ${data.count} 条。请确认加工范围。`);}catch(e:any){if(ticket===seq.current){setError(e.message);setState('error');}}finally{if(ticket===seq.current)setSubmitting(false);}}
async function confirm(){setSubmitting(true);setError('');try{const data=await send('POST','/library/selections',{preview_id:preview.preview_id,...(!useAll?{selected_ids:picked}:{})});setSelection(data);setNotice(`已冻结 ${data.count} 条案例,可分别提炼 Skills 或 Rules。`);}catch(e:any){setError(e.message);}finally{setSubmitting(false);}}
async function extract(kind:string){setSubmitting(true);setError('');setRecord(null);try{const data=await send('POST','/library/extractions',{project_id:project.id,selection_id:selection.id,kind,requirement});setRecord(data);if(data.status==='completed'){setNotice('提炼完成:AI 草稿已进入下方对应卡片池。');setRefreshKey(n=>n+1);}else setNotice('本次提炼未完成,没有新增卡片。');}catch(e:any){setError(e.message);}finally{setSubmitting(false);}}
function toggle(id:string){setUseAll(false);setSelection(null);setPicked(current=>current.includes(id)?current.filter(k=>k!==id):[...current,id]);}
const visible=items.slice(page*20,page*20+20),pages=Math.max(1,Math.ceil(items.length/20));
return <div className="v2-library">
<div className="v2-metrics"><article><span><Database size={16}/>历史对公租赁案例</span><b>{total}</b><small>SQLite 持久化 · 全部模拟</small></article><article><span>本次筛选匹配</span><b>{items.length}</b><small>{preview?'实际只读查询结果':'全部案例,尚未确认加工'}</small></article><article><span>已确认加工范围</span><b>{selection?.count??'—'}</b><small>{selection?'选择快照已冻结':'先预览,再确认'}</small></article><article><span>经验加工模型</span><b className="v2-metric-text">{modelAvailable?'已配置':'待连接'}</b><small>沿用受信模型配置,无 Jev 依赖</small></article></div>
<div className="v2-library-layout"><section className="v2-surface"><div className="v2-section-heading"><div><span className="v2-kicker">LEASE CASE DATABASE</span><h2>先看案例,再提炼经验</h2></div><span className="v2-tag">对公金融租赁</span></div>
<div className="v2-tabs" role="tablist" aria-label="案例筛选方式">{[['filters','条件筛选'],['natural','自然语言'],['sql','只读 SQL']].map(([key,label])=><button role="tab" aria-selected={mode===key} key={key} disabled={submitting||!canEdit} onClick={()=>{setMode(key);setText(key==='sql'?'SELECT * FROM lease_cases ORDER BY id':'');invalidate();}}>{label}</button>)}</div>
{mode==='filters'&&<div className="v2-filter-grid">{[['industry','行业',industries],['mode','租赁方式',['直接租赁','售后回租']],['outcome','会议结果',outcomes],['year','评审年份',['2023','2024','2025']]].map(([key,label,options])=><label className="ra-field" key={key as string}><span>{label as string}</span><select disabled={submitting||!canEdit} value={filters[key as string]||''} onChange={e=>update(key as string,e.target.value)}><option value="">全部</option>{(options as string[]).map(v=><option key={v}>{v}</option>)}</select></label>)}</div>}
{mode==='filters'&&<div className="v2-quick-filters"><span><SlidersHorizontal size={14}/>快速筛选</span>{[['outcome','通过'],['outcome','否决'],['industry','制造业'],['mode','售后回租']].map(([key,value])=><button key={value} disabled={submitting||!canEdit} className={filters[key]===value?'selected':''} onClick={()=>update(key,filters[key]===value?'':value)}>{value}</button>)}<button disabled={submitting||!canEdit} onClick={()=>{setFilters({});invalidate();}}>全部案例</button></div>}
{mode!=='filters'&&<label className="ra-field"><span>{mode==='sql'?'仅 SELECT * FROM lease_cases;可用 WHERE、ORDER BY、LIMIT':'描述筛选条件;先确认系统解析结果'}</span><textarea className={mode==='sql'?'v2-code-input':''} value={text} disabled={submitting} onChange={e=>{setText(e.target.value);invalidate();}} placeholder={mode==='natural'?'例如:筛选制造业已经通过的案例;金额不少于2000万元':'SELECT * FROM lease_cases WHERE outcome=\'通过\' ORDER BY amount DESC LIMIT 40'}/></label>}
<div className="xb-actions"><button className="ra-button primary" onClick={query} disabled={submitting||!canEdit}><Search size={15}/>{submitting?'处理中…':'预览筛选结果'}</button>{preview&&<span className="v2-muted">解析条件:{mode==='sql'?preview.sql:JSON.stringify(preview.filters)}</span>}{!canEdit&&<span>客户经理可查看历史案例;经验加工由配置管理员操作。</span>}</div>
{error&&<p role="alert" className="ra-alert error">{error}</p>}{notice&&<p role="status" className="v2-notice">{notice}</p>}
{state==='loading'&&<p role="status">正在读取 SQLite 案例库…</p>}{state==='empty'&&<div className="ra-empty">没有符合条件的案例,请修改筛选。</div>}
<div className="v2-table-wrap"><table className="v2-case-table"><thead><tr><th>选中</th><th>承租企业 / 案例</th><th>业务与租赁物</th><th>金额 / 期限</th><th>会议结果</th></tr></thead><tbody>{visible.map(item=><tr key={item.id}><td><input type="checkbox" aria-label={`选择 ${item.id}`} disabled={!preview||submitting||!canEdit} checked={useAll?Boolean(preview):picked.includes(item.id)} onChange={()=>toggle(item.id)}/></td><td><button className="v2-link" onClick={()=>setDetail(item)}>{item.lessee}</button><small>{item.id} · {item.year}年 · 模拟</small></td><td><b>{item.mode} · {item.industry}</b><small>{item.asset}</small></td><td><b>{item.amount.toLocaleString()} 万元</b><small>{item.term} 个月</small></td><td><span className={`v2-outcome outcome-${outcomes.indexOf(item.outcome)}`}>{item.outcome}</span></td></tr>)}</tbody></table></div>
<div className="v2-pagination"><span>实际 {items.length} 条 · 每页20条 · 第 {page+1}/{pages} 页</span><div className="xb-actions"><button className="ra-button" aria-label="上一页" disabled={page===0||submitting} onClick={()=>{setPage(p=>p-1);setPicked([]);setSelection(null);}}><ChevronLeft size={16}/></button><button className="ra-button" aria-label="下一页" disabled={page>=pages-1||submitting} onClick={()=>{setPage(p=>p+1);setPicked([]);setSelection(null);}}><ChevronRight size={16}/></button></div></div><p className="v2-muted">{source}</p>
</section>
<aside className="v2-surface v2-extraction"><span className="v2-kicker">EXPERIENCE → AGENT</span><h2>把经验变成可复用能力</h2><ol className="v2-steps"><li className={preview?'done':''}><span>1</span><div><b>筛选历史案例</b><p>结果直接来自 SQLite</p></div></li><li className={selection?'done':''}><span>2</span><div><b>确认加工范围</b><p>选择快照保留实际案例编号</p></div></li><li><span>3</span><div><b>分别提炼 Skills / Rules</b><p>真实模型生成草稿,随后审核</p></div></li></ol>
<div className="v2-selection-controls"><label><input type="radio" name="selection-mode" checked={useAll} onChange={()=>{setUseAll(true);setSelection(null);}} disabled={submitting||!preview}/>全部匹配 {preview?.count??0} 条</label><label><input type="radio" name="selection-mode" checked={!useAll} onChange={()=>{setUseAll(false);setSelection(null);}} disabled={submitting||!preview}/>本页勾选 {picked.length} 条</label><button className="ra-button" disabled={submitting||!preview||(!useAll&&!picked.length)} onClick={confirm}><Check size={15}/>{selection?`已确认 ${selection.count} 条`:'确认加工范围'}</button></div>
<label className="ra-field"><span>用自然语言指导提炼(可选)</span><textarea rows={4} placeholder="例如:关注补件后转为通过的项目,总结权属核验的方法及不适用情况。" value={requirement} maxLength={2000} disabled={submitting||!canEdit} onChange={e=>setRequirement(e.target.value)}/></label>
<button className="ra-button primary v2-full" disabled={!selection||submitting||!canEdit} onClick={()=>extract('skill')}><Sparkles size={17}/>提炼 Skills · 核查方法</button><button className="ra-button v2-full" disabled={!selection||submitting||!canEdit} onClick={()=>extract('rule_set')}><SlidersHorizontal size={17}/>提炼 Rules · 建议规则</button>
{!modelAvailable&&<p className="v2-muted">尚未连接受信模型。点击提炼会记录未完成;可以先从文件夹导入或人工创建卡片。</p>}
{record&&<div className="v2-extraction-result" role="status"><b>{record.status==='completed'?'草稿已进入卡片池':'本次提炼未完成'}</b><p>{record.case_ids.length} 条来源 · {record.kind==='skill'?'Skills':'Rules'}</p>{record.degradations.map((d:any)=><p key={d.code}>{d.message}({d.code})</p>)}{record.candidate_id&&<small>草稿 {record.candidate_id}</small>}</div>}
</aside></div>
<KnowledgePools project={project} identity={identity} send={send} refreshKey={refreshKey}/>
{detail&&<LeaseCaseDialog item={detail} onClose={()=>setDetail(null)}/>}
</div>;
}
@@ -0,0 +1,22 @@
'use client';
import {useEffect,useRef,useState} from 'react';
import {Database,Blocks,ShieldCheck,Save} from 'lucide-react';
export default function PrescreenAssembly({project,identity,planId,request,onChanged}:any) {
const [frozen,setFrozen]=useState<any>(null),[available,setAvailable]=useState<any[]>([]),[plans,setPlans]=useState<any[]>([]),[selected,setSelected]=useState<Record<string,string[]>>({skill:[],rule_set:[],data_card:[]});
const [editing,setEditing]=useState(false),[busy,setBusy]=useState(false),[error,setError]=useState(''),[notice,setNotice]=useState(''),[name,setName]=useState(''); const sequence=useRef(0);
const canEdit=identity?.role==='admin';
async function load(){const ticket=++sequence.current;try{const [current,all,custom]=await Promise.all([request(`/library/plans/${encodeURIComponent(planId)}?project_id=${encodeURIComponent(project.id)}`),request(`/library/cards?project_id=${encodeURIComponent(project.id)}`),request(`/prescreen/projects/${encodeURIComponent(project.id)}/review-plans`)]);if(ticket!==sequence.current)return;setFrozen(current);setAvailable(all.items.filter((c:any)=>c.status==='published'));setPlans(custom.items||[]);setSelected({skill:current.skills.map((c:any)=>c.id),rule_set:current.rules.map((c:any)=>c.id),data_card:current.cards.filter((c:any)=>c.source_id).map((c:any)=>c.id)});}catch(e:any){if(ticket===sequence.current)setError(e.message);}}
useEffect(()=>{setEditing(false);setFrozen(null);setError('');if(planId)load();return()=>{sequence.current++;};},[project.id,planId]);
function toggle(kind:string,id:string){setSelected(current=>({...current,[kind]:current[kind].includes(id)?current[kind].filter(k=>k!==id):[...current[kind],id]}));}
async function save(){setBusy(true);setError('');try{const result=await request(`/prescreen/projects/${encodeURIComponent(project.id)}/review-plans`,{name:name||'对公租赁预审装配方案',skills:selected.skill,rules:selected.rule_set,cards:selected.data_card,template:'template.ai-pre-review',audience:['manager','admin'],run_kind:'pre_review',version:'1.0.0'});setNotice(`装配草稿已保存 ${result.id},请完成评测与独立审核。`);setEditing(false);await load();}catch(e:any){setError(e.message);}finally{setBusy(false);}}
async function transition(plan:any,action:string){setBusy(true);setError('');try{const result=await request(`/prescreen/review-plans/${encodeURIComponent(plan.id)}/transitions`,{action});setNotice(action==='evaluate'?(result.passed?'方案隔离评测通过。':'方案评测未通过,请查看依赖与规则。'):'方案已发布,可选择后发起预审。');await load();await onChanged();}catch(e:any){setError(e.message);}finally{setBusy(false);}}
const groups=[{kind:'data_card',title:'02 · 数据卡片',icon:Database,items:frozen?.cards||[]},{kind:'skill',title:'03 · Skills 卡片',icon:Blocks,items:frozen?.skills||[]},{kind:'rule_set',title:'04 · Rules 卡片',icon:ShieldCheck,items:frozen?.rules||[]}];
return <section className="v2-surface v2-assembly"><div className="v2-section-heading"><div><span className="v2-kicker">ASSEMBLE THE PRESCREEN AGENT</span><h2>本次预审使用哪些卡片</h2><p>资料文件夹+数据+Skills+Rules+本次要求,共同组成冻结输入。</p></div>{canEdit&&<button className="ra-button" disabled={busy||!frozen} onClick={()=>setEditing(!editing)}>{editing?'返回已发布方案':'重新装配方案'}</button>}</div>
{error&&<p role="alert" className="ra-alert error">{error}</p>}{notice&&<p role="status" className="ra-alert">{notice}</p>}{!frozen&&!error&&<p role="status">正在读取方案真实组件…</p>}
<div className="v2-assembly-grid">{groups.map(group=>{const Icon=group.icon;const items=editing?available.filter(c=>c.kind===group.kind):group.items;return <div key={group.kind} className="v2-assembly-card"><h3><Icon size={16} style={{display:'inline',verticalAlign:'middle'}}/> {group.title}</h3>{!items.length&&<p className="v2-muted">{group.kind==='data_card'?'未额外绑定数据卡,使用当前项目材料与事实快照。':'本方案未选择此类卡片。'}</p>}{items.map((card:any)=><label key={card.id}><input type="checkbox" checked={editing?selected[group.kind].includes(card.id):true} disabled={!editing||busy} onChange={()=>toggle(group.kind,card.id)}/><span>{card.name}<small>{card.version||'已发布配置'} · {card.origin==='history_derived'?'AI提炼':card.import_method==='folder'?'文件夹导入':'内置/人工'}{card.effect==='hard'?' · 硬规则':''}</small></span></label>)}</div>;})}</div>
{editing&&<div style={{marginTop:20}}><label className="ra-field"><span>方案名称</span><input value={name} onChange={e=>setName(e.target.value)}/></label><button className="ra-button primary" disabled={busy||!selected.skill.length} onClick={save}><Save size={15}/>保存装配草稿</button><p className="v2-muted">此操作不改变当前已发布方案。本轮结束编辑后,另一位配置管理员审核发布;客户经理选择已发布方案进行预审。</p></div>}
{frozen&&<p className="v2-muted" style={{marginTop:16}}>冻结方案 {frozen.plan.id} · v{frozen.plan.version} · 摘要 {frozen.content_hash.slice(0,16)}。Tools 由后台批准;这里没有工具卡片配置入口。</p>}
{canEdit&&plans.some((p:any)=>p.status==='draft')&&<details className="v2-advanced"><summary>待评测与发布的装配草稿</summary>{plans.filter((p:any)=>p.status==='draft').map((draft:any)=><div key={draft.id} className="v2-package-meta"><span>{draft.name} · v{draft.version} · {draft.evaluation?.passed?'评测通过':'待评测'}</span><div className="xb-actions"><button className="ra-button" disabled={busy} onClick={()=>transition(draft,'evaluate')}>隔离评测</button>{draft.edited_by!==identity.id&&<button className="ra-button primary" disabled={busy||!draft.evaluation?.passed} onClick={()=>transition(draft,'publish')}>审核发布</button>}{draft.edited_by===identity.id&&<small>发布需要另一位配置管理员</small>}</div></div>)}</details>}
</section>;
}
@@ -1,4 +1,5 @@
"use client";
import ProbabilitySummary from "./ProbabilitySummary";
import {useEffect, useRef, useState} from "react";
import {AlertTriangle} from "lucide-react";
import {PRESCREEN} from "@/lib/prescreen";
@@ -187,6 +188,8 @@ export default function PrescreenPanel({project, request, busy, setBusy, setErro
</div>
</div>
<ProbabilitySummary baseline={baseline} scenario={scenario} status={scenarioState} reason={simulation?.reason}/>
<details className="v2-advanced"><summary>查看概率口径与情景计算依据</summary>
<div className="xb-finding" aria-live="polite">
<div>
<b>{baseline ? `审批通过概率 · 演示值 ${baseline.percent}%` : "审批通过概率:暂不显示"}</b>
@@ -200,6 +203,8 @@ export default function PrescreenPanel({project, request, busy, setBusy, setErro
</div>
</div>
</details>
<h3>核查结果({(run.findings || []).length})</h3>
{!(run.findings || []).length && <div className="ra-empty"><h2>本次没有待处理事项</h2>
<p>没有发现问题不等于已经通过;正式结论仍由业务流程决定。</p></div>}
@@ -0,0 +1,14 @@
'use client';
export default function ProbabilitySummary({baseline,scenario,status,reason}:any) {
const value=scenario?.percent??baseline?.percent,delta=scenario&&baseline?scenario.percent-baseline.percent:null;
const circumference=2*Math.PI*60;
return <section className="v2-probability" aria-label="审批通过概率与口径" aria-live="polite"><svg width="160" height="160" viewBox="0 0 160 160" role="img" aria-label={value===undefined?'审批概率尚未校准':`审批通过概率演示值 ${value}%`}>
<defs><linearGradient id="approval-ring" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stopColor="#17634C"/><stop offset="100%" stopColor="#75BCA0"/></linearGradient></defs>
<circle cx="80" cy="80" r="70" fill="none" stroke="#DCE9E2" strokeWidth="1" strokeDasharray="2 5"/>
<circle cx="80" cy="80" r="60" fill="white" stroke="#E3EBE6" strokeWidth="10"/>
{value!==undefined&&<circle cx="80" cy="80" r="60" fill="none" stroke="url(#approval-ring)" strokeWidth="10" strokeLinecap="round" strokeDasharray={`${circumference*value/100} ${circumference}`} transform="rotate(-90 80 80)"/>}
<text x="80" y="79" textAnchor="middle" fill="#182D26" fontSize="34" fontWeight="650">{value===undefined?'—':value+'%'}</text><text x="80" y="104" textAnchor="middle" fill="#52675C" fontSize="11">{value===undefined?'待历史校准':'模拟演示值'}</text>
</svg><div><span className="v2-kicker">APPROVAL PROBABILITY</span><h3>{scenario?'所选推进情景':'当前预审基线'}</h3>{delta!==null&&<div className="v2-delta">{delta>=0?'+':''}{delta} 个百分点 <small>基线 {baseline.percent}% → 情景 {scenario.percent}%</small></div>}
<p>{value!==undefined?'人工情景表演示,未经真实历史校准。':'尚未完成真实历史数据校准,审批通过概率暂不显示。'}</p><p>{reason||'下方雷达说明已完成哪些核查、缺少哪些证据;面积不换算概率。'}</p>
{baseline&&<p>完整组合查表 · {baseline.tableVersion} · 条目 {scenario?.scenarioKey||baseline.scenarioKey}</p>}{status==='computing'&&<p>正在重算当前勾选情景…</p>}{status==='failed'&&<p>本情景未完成,保留原有基线。</p>}</div></section>;
}
+12
View File
@@ -0,0 +1,12 @@
'use client';
import {useEffect,useState} from 'react';
export default function RunComparison({project,send}:any){
const runs=project.runs||[],[left,setLeft]=useState(runs[1]?.id||''),[right,setRight]=useState(runs[0]?.id||'');
const [result,setResult]=useState<any>(null),[error,setError]=useState(''),[loading,setLoading]=useState(false);
useEffect(()=>{let live=true;if(!left||!right)return;setLoading(true);setError('');send('GET',`/prescreen/projects/${encodeURIComponent(project.id)}/run-comparison?left=${encodeURIComponent(left)}&right=${encodeURIComponent(right)}`).then((r:any)=>{if(live)setResult(r);}).catch((e:any)=>{if(live)setError(e.message);}).finally(()=>{if(live)setLoading(false);});return()=>{live=false;};},[project.id,left,right]);
if(runs.length<2)return <section className="v2-surface"><h2>先完成两次预审,再比较变化</h2><p>在预审页补充材料、应用候选字段并重新核查;这里会对照两次冻结的文件、卡片版本与问题清单。</p><a className="ra-button primary" href={`/prescreen-workbench?project=${encodeURIComponent(project.id)}`}>返回预审补件</a></section>;
return <section className="v2-surface"><span className="v2-kicker">BEFORE / AFTER</span><h2>比较两次预审,找到实际变化</h2><div className="v2-form-grid" style={{marginTop:20}}>{[['之前',left,setLeft],['之后',right,setRight]].map(([label,value,change])=><label key={label as string} className="ra-field"><span>{label as string}</span><select value={value as string} onChange={e=>(change as any)(e.target.value)}>{runs.map((r:any)=><option value={r.id} key={r.id}>{new Date(r.created_at).toLocaleString()} · {r.status}</option>)}</select></label>)}</div>
{loading&&<p role="status">正在比较冻结快照…</p>}{error&&<p role="alert" className="ra-alert error">{error}</p>}
{result&&<><div className="v2-metrics">{[['已解决',result.delta.resolved.length],['新增问题',result.delta.new.length],['仍待处理',result.delta.remaining.length],['本次未执行',result.delta.unexecuted.length]].map(([label,count])=><article key={label}><span>{label}</span><b>{count}</b></article>)}</div><p className="v2-notice">{result.note}</p><div className="v2-comparison-grid">{[result.left,result.right].map((run:any,index)=><div key={index}><h3>{index?'之后':'之前'} · {run.status}</h3><p>快照 {run.snapshot_id}</p><p>方案 {run.plan.id} · {run.plan.version}</p><p>模型 {run.model.name||'未配置'};指令 {run.instruction||'无额外要求'}</p><h3>材料版本({run.documents.length})</h3><ul>{run.documents.map((d:any)=><li key={d.id}>{d.name} · v{d.version} · {d.content_hash?.slice(0,12)}</li>)}</ul><h3>实际核查结果</h3><ul>{run.checks.map((c:any)=><li key={c.code}><b>{c.title||c.code}</b> · {c.result} <small>{c.suggested_action}</small></li>)}</ul><details><summary>冻结 Skills / Rules 版本</summary><ul>{run.components.map((c:any)=><li key={c.id}>{c.id} · v{c.version} · {c.content_hash?.slice(0,12)}</li>)}</ul></details></div>)}</div></>}
</section>;
}
@@ -0,0 +1,23 @@
'use client';
import {useEffect,useRef,useState} from 'react';
import {FolderOpen,FileText,Download,X} from 'lucide-react';
import {downloadSkill} from '@/lib/lease-library';
export default function SkillPackageDialog({card,send,onClose}:any) {
const dialog=useRef<HTMLDialogElement>(null);
const [data,setData]=useState<any>(null),[error,setError]=useState(''),[file,setFile]=useState('SKILL.md'),[busy,setBusy]=useState(false);
useEffect(()=>{dialog.current?.showModal(); let live=true;
send('GET',`/library/skills/${encodeURIComponent(card.id)}/package`).then((result:any)=>{if(live)setData(result);}).catch((e:any)=>{if(live)setError(e.message);});
return()=>{live=false;};
},[card.id]);
const entry=data?.files.find((item:any)=>item.path===file);
async function openFolder(){setBusy(true);setError('');try{await send('POST',`/library/skills/${encodeURIComponent(card.id)}/open`,{});}catch(e:any){setError(e.message);}finally{setBusy(false);}}
return <dialog ref={dialog} className="v2-dialog v2-file-dialog" onCancel={onClose} aria-labelledby="skill-folder-title">
<header><div><span className="v2-kicker">STANDARD SKILL PACKAGE</span><h2 id="skill-folder-title">{card.name}</h2><p>{data?.name || '正在读取标准文件夹…'} · v{card.version}</p></div><button className="ra-button" aria-label="关闭文件夹查看器" onClick={onClose}><X size={18}/></button></header>
{error&&<p role="alert" className="ra-alert error">{error}</p>}
{data&&<><div className="v2-package-meta"><span>{data.manifest.length} 个原始文件 · 哈希 {data.content_hash.slice(0,16)}</span><div className="xb-actions"><button className="ra-button" disabled={busy} onClick={openFolder}><FolderOpen size={15}/>在本机文件夹打开</button><button className="ra-button" onClick={()=>downloadSkill(card.id).catch(e=>setError(e.message))}><Download size={15}/>下载标准包</button></div></div>
<div className="v2-file-layout"><aside aria-label="Skills 文件树"><b><FolderOpen size={15}/>{data.name}/</b>{data.files.map((item:any)=><button key={item.path} className={file===item.path?'active':''} onClick={()=>setFile(item.path)}><FileText size={14}/>{item.path}</button>)}</aside><section><h3>{file}</h3><pre tabIndex={0}>{entry?.text ?? '此文件是二进制资源,可下载标准包查看。'}</pre></section></div>
<p className="v2-muted">卡片对应实际托管文件夹。原始文件与版本哈希保留;scripts 仅保存,不会在导入时运行。</p></>}
{!data&&!error&&<p role="status">正在读取文件清单…</p>}
</dialog>;
}
+17 -4
View File
@@ -36,7 +36,8 @@ const EMPTY_FORM = {
occurred_at: "", supersedes_id: "", conditions: "",
};
export default function TrajectoryPanel({project, identity, send}: any) {
export default function TrajectoryPanel({project, identity, send, view="all"}: any) {
const [openStep, setOpenStep] = useState("");
const [runId, setRunId] = useState("");
const [trajectory, setTrajectory] = useState<any>(null);
const [outcomes, setOutcomes] = useState<any>(null);
@@ -106,6 +107,7 @@ export default function TrajectoryPanel({project, identity, send}: any) {
const fill = (key: string, value: string) => setForm((current: any) => ({...current, [key]: value}));
return <section className="xb-panel xb-wide">
{view !== "meeting" && <>
<h2>运行轨迹回放</h2>
<p>一次运行是一条轨迹,多次工具与模型调用是其中的步骤。这里只记录<b>可观察的动作与版本</b>,不包含模型内部推理;回放是只读的,不会改动这次运行。</p>
@@ -134,14 +136,22 @@ export default function TrajectoryPanel({project, identity, send}: any) {
</div>
</div>
{steps.map((step: any) => <div className="xb-finding" key={step.id}>
<div className="v2-trajectory-steps">{steps.map((step: any) => <div className="xb-finding" key={step.id}>
<div>
<b>#{step.sequence} · {KIND_LABEL[step.kind] || step.kind} · {STATUS_LABEL[step.status] || step.status}</b>
<b>#{step.sequence + 1} · {trajectory.details?.[step.id]?.name || ""} · {KIND_LABEL[step.kind] || step.kind} · {STATUS_LABEL[step.status] || step.status}</b>
<p>{step.component_ref
? <>组件 <code>{step.component_ref.id}@{step.component_ref.version}</code></>
: "(无组件引用)"}
{step.error_code ? ` · 错误码 ${step.error_code}` : ""}
</p>
<button className="ra-button" aria-expanded={openStep === step.id} onClick={()=>setOpenStep(openStep === step.id ? "" : step.id)}>查看这一步做了什么</button>
{openStep === step.id && trajectory.details?.[step.id] && <div className="v2-card-details">
<p><b>核查动作:</b>{trajectory.details[step.id].action}</p>
<p><b>声明输入:</b>{trajectory.details[step.id].declared_inputs.join("、") || "以受控工具读取授权快照"}</p>
<p><b>适用条件与例外:</b>{JSON.stringify(trajectory.details[step.id].conditions)};{JSON.stringify(trajectory.details[step.id].exceptions)}</p>
{trajectory.details[step.id].checks.map((check:any)=><p key={check.code}><b>{check.title}</b> · {check.result};{check.suggested_action};依据 {(check.evidence_ids || []).join("、") || "尚无引用证据"}</p>)}
{trajectory.details[step.id].evidence.map((item:any)=><p key={item.id}>证据 {item.field || item.id} = {JSON.stringify(item.value)};来源 {item.source_id || item.document_id || "已记录证据"} {JSON.stringify(item.locator)}</p>)}
</div>}
{step.reason_summary && <p className="xb-warning"><AlertTriangle size={13}/> {step.reason_summary}</p>}
{(step.evidence_ids || []).length > 0 &&
<p><FileText size={13} aria-hidden="true"/> 引用证据 {step.evidence_ids.length} 条:{(step.evidence_ids || []).slice(0, 4).join("、")}{(step.evidence_ids || []).length > 4 ? "…" : ""}</p>}
@@ -151,7 +161,7 @@ export default function TrajectoryPanel({project, identity, send}: any) {
{step.started_at ? ` · 开始 ${new Date(step.started_at).toLocaleString()}` : ""}
</small></p>
</div>
</div>)}
</div>)}</div>
{trajectory.next_cursor && <div className="xb-actions">
<button className="ra-button" disabled={busy} onClick={() => load(runId, trajectory.next_cursor)}>
@@ -160,6 +170,8 @@ export default function TrajectoryPanel({project, identity, send}: any) {
</div>}
</>}
</>}
{view !== "process" && <>
<h3>会议结果对照</h3>
<p>AI 轨迹与人工会议记录并列查看。匹配只基于可核验的项目、材料版本:找不到材料版本的结果<b>单列</b>,
既不当否决、也不进已观察通过率的分母。更正只能<b>追加</b>并指向旧记录,历史不被覆盖。</p>
@@ -226,6 +238,7 @@ export default function TrajectoryPanel({project, identity, send}: any) {
</div>}
{!canRecord && <p className="ra-note">当前身份为只读查看:录入会议结果需要记录人身份。</p>}
</>}
</section>;
}
+27
View File
@@ -0,0 +1,27 @@
/* Approved Superdesign v3 tokens. Scoped to the three v2 business surfaces. */
.xb-v2{--v2-nav:#102B27;--v2-canvas:#F3F5F4;--v2-surface:#fff;--v2-text:#182D26;--v2-muted:#52675C;--v2-line:#DCE4DF;--v2-primary:#17634C;--v2-ai:#A33C38;--v2-ai-bg:#FBEFEC;--v2-manual:#226A7A;--v2-manual-bg:#EDF5F7;background:var(--v2-canvas);color:var(--v2-text);font-family:"Microsoft YaHei","PingFang SC",sans-serif;font-size:14px;line-height:1.65}
.xb-v2 .xb-agent-nav{position:fixed;inset:0 auto 0 0;width:216px;flex-direction:column;align-items:stretch;padding:28px 16px;gap:10px;border-right:1px solid #244D41;box-shadow:none;overflow-y:auto}
.xb-v2 .xb-agent-nav .xb-brand{border:0;padding:0 12px 24px;margin:0}.xb-v2 .xb-brand b{font-size:18px}.xb-v2 .xb-brand small{font-size:12px}
.xb-v2 .xb-nav-group{margin:16px 0 4px;padding:0 12px}.xb-v2 .xb-agent-nav a{padding:12px;min-height:46px;font-size:14px}.xb-v2 .xb-nav-spacer{flex:1}
.xb-v2 .xb-agent-nav .xb-role-switch{flex-wrap:wrap;padding:12px}.xb-v2 .xb-role-switch>span{width:100%}.xb-v2 .xb-project-context{font-size:12px}
.xb-v2 .ra-main{margin-left:216px;max-width:none;padding:32px 32px 64px}.xb-v2 .ra-heading{margin-bottom:24px}.xb-v2 .ra-heading h1{font-size:28px;font-weight:650;letter-spacing:-.5px}.xb-v2 .ra-heading p{color:var(--v2-muted);font-size:14px;max-width:820px}.xb-v2 .ra-eyebrow{font-size:12px;color:var(--v2-primary);letter-spacing:1.5px}
.xb-v2 .ra-field{margin:0 0 16px}.xb-v2 .ra-field span{font-size:13px;font-weight:550;color:var(--v2-text)}.xb-v2 .ra-field input,.xb-v2 .ra-field select,.xb-v2 .ra-field textarea{font-size:14px;min-height:42px;background:var(--v2-surface);border:1px solid var(--v2-line);border-radius:8px;padding:10px 12px;width:100%;color:var(--v2-text)}.xb-v2 .ra-field textarea{min-height:94px;resize:vertical}
.xb-v2 .ra-button{min-height:42px;border-radius:8px;border:1px solid var(--v2-line);font-size:13px;padding:9px 14px}.xb-v2 .ra-button.primary{border-color:var(--v2-primary);background:var(--v2-primary)}.xb-v2 .ra-button:disabled{opacity:.55}.xb-v2 :focus-visible{outline:2px solid var(--v2-primary);outline-offset:3px}
.xb-v2 h2{font-size:20px;line-height:1.45;font-weight:650}.xb-v2 h3{font-size:16px;font-weight:600}.xb-v2 p{overflow-wrap:anywhere}.v2-muted{font-size:12px;color:var(--v2-muted);line-height:1.7}.v2-kicker{font-size:11px;letter-spacing:1.7px;color:var(--v2-muted);font-weight:600}.v2-tag{background:#EDF5F1;color:var(--v2-primary);border:1px solid var(--v2-line);border-radius:999px;padding:6px 12px;font-size:12px;white-space:nowrap}
.v2-metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;margin-bottom:24px}.v2-metrics article{background:var(--v2-surface);border:1px solid var(--v2-line);border-radius:12px;padding:20px}.v2-metrics article>span{display:flex;align-items:center;gap:8px;color:var(--v2-muted);font-size:13px}.v2-metrics article>b{display:block;font-size:32px;margin:8px 0 3px;line-height:1.2;font-weight:650}.v2-metrics article>b.v2-metric-text{font-size:24px;margin:13px 0 7px}.v2-metrics small{font-size:12px;color:var(--v2-muted)}
.v2-library-layout{display:grid;grid-template-columns:minmax(0,1fr) 300px;gap:20px;align-items:start}.v2-surface,.xb-v2 .xb-panel,.xb-v2 .xb-task{background:var(--v2-surface);border:1px solid var(--v2-line);border-radius:12px;padding:24px;min-width:0;box-shadow:0 4px 24px #182D2604}.v2-section-heading{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:20px}.v2-section-heading h2{margin:5px 0}.v2-section-heading p{font-size:13px;color:var(--v2-muted)}
.v2-tabs{display:flex;gap:4px;border-bottom:1px solid var(--v2-line);margin-bottom:20px;overflow-x:auto}.v2-tabs button{padding:12px 15px;font-size:14px;white-space:nowrap;border-bottom:2px solid transparent;color:var(--v2-muted);background:transparent}.v2-tabs button[aria-selected=true]{border-bottom-color:var(--v2-primary);color:var(--v2-primary);font-weight:650}.v2-tabs span{font-size:11px;border-radius:999px;background:#E9EFEB;padding:2px 7px;margin-left:5px}
.v2-filter-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}.v2-filter-grid .ra-field select{font-size:13px}.v2-quick-filters{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:0 0 20px}.v2-quick-filters>span{display:flex;gap:4px;align-items:center;font-size:12px;color:var(--v2-muted);margin-right:6px}.v2-quick-filters button{padding:5px 12px;border:1px solid var(--v2-line);border-radius:999px;font-size:12px;background:#fff}.v2-quick-filters button.selected{background:#EDF5F1;color:var(--v2-primary);border-color:#8BB9A4}.v2-code-input{font-family:Consolas,monospace}
.v2-notice{color:var(--v2-primary);background:#EDF5F1;border-radius:8px;padding:10px 12px;margin:12px 0;font-size:13px}.v2-table-wrap{overflow-x:auto;margin-top:20px;border:1px solid var(--v2-line);border-radius:9px}.v2-case-table{border-collapse:collapse;width:100%;min-width:660px;text-align:left}.v2-case-table th{font-size:12px;font-weight:600;background:#F8FAF9;color:var(--v2-muted);padding:13px 12px;white-space:nowrap}.v2-case-table td{padding:15px 12px;border-top:1px solid #EAF0EC;vertical-align:middle;font-size:12px}.v2-case-table td b{font-size:12px;font-weight:500}.v2-case-table small{display:block;color:var(--v2-muted);font-size:11px;margin-top:4px}.v2-case-table tr:hover td{background:#FAFCFB}.v2-case-table input{width:16px;height:16px;accent-color:var(--v2-primary)}.v2-link{color:var(--v2-text);font-weight:550;text-align:left;font-size:13px}.v2-link:hover{color:var(--v2-primary);text-decoration:underline}.v2-outcome{border-radius:5px;padding:4px 8px;white-space:nowrap;background:#F1F4F2;color:var(--v2-muted)}.v2-outcome.outcome-0{background:#EDF5F1;color:#17634C}.v2-outcome.outcome-1{background:#FBEFEC;color:#A33C38}.v2-pagination{display:flex;align-items:center;justify-content:space-between;margin:16px 0;gap:12px;font-size:12px;color:var(--v2-muted)}
.v2-extraction{position:sticky;top:24px}.v2-extraction h2{margin:10px 0 20px;font-size:19px}.v2-steps{list-style:none;margin:0 0 24px;padding:0}.v2-steps li{display:flex;gap:12px;margin-bottom:20px}.v2-steps li>span{height:28px;width:28px;display:grid;place-items:center;border-radius:50%;background:#EDF1EE;color:var(--v2-muted);flex-shrink:0;font-size:12px}.v2-steps li.done>span{background:var(--v2-primary);color:white}.v2-steps b{font-size:13px}.v2-steps p{color:var(--v2-muted);font-size:12px;margin-top:3px}.v2-selection-controls{display:grid;gap:12px;padding:16px;background:#F8FAF9;border-radius:8px;margin-bottom:20px;font-size:13px}.v2-selection-controls label{display:flex;gap:8px;align-items:center}.v2-full{width:100%;margin-bottom:12px}.v2-extraction-result{margin-top:18px;padding:14px;border:1px dashed #A33C38;border-radius:8px;background:#FBEFEC;color:#78342F;font-size:12px}.v2-extraction-result small{display:block;overflow-wrap:anywhere}
.v2-pool{margin-top:32px}.v2-card-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}.v2-knowledge-card{border:1px solid var(--v2-line);border-left:4px solid var(--v2-manual);border-radius:10px;background:var(--v2-manual-bg);padding:20px;min-width:0}.v2-knowledge-card.from-ai{background:var(--v2-ai-bg);border-left:4px dashed var(--v2-ai)}.v2-card-top{display:flex;justify-content:space-between;gap:8px;align-items:center;font-size:11px;color:var(--v2-manual)}.from-ai .v2-card-top{color:var(--v2-ai)}.v2-card-top>span:first-child{display:flex;align-items:center;gap:5px}.v2-knowledge-card h3{margin:14px 0 8px}.v2-knowledge-card>p{font-size:13px;color:var(--v2-muted);display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;min-height:60px}.v2-card-meta{color:var(--v2-muted);font-size:11px;margin:12px 0 16px}.v2-knowledge-card .xb-actions{gap:6px;flex-wrap:wrap}.v2-knowledge-card .ra-button{font-size:12px;padding:8px 10px;min-height:36px}.v2-card-details{margin-top:16px;border-top:1px solid var(--v2-line);padding-top:16px;font-size:12px}.v2-import-preview{border:1px solid var(--v2-line);background:#fff;border-radius:12px;padding:22px;margin-bottom:20px}.v2-import-preview h3{margin-bottom:8px}.v2-import-preview ul{padding-left:18px;font-family:Consolas,monospace;font-size:12px;margin:14px 0}.v2-form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.v2-fact{padding:14px;background:#F8FAF9;border-radius:8px}.v2-fact span{display:block;color:var(--v2-muted);font-size:12px}.v2-fact b{display:block;margin-top:5px;font-size:14px}
.v2-dialog{border:1px solid var(--v2-line);border-radius:16px;box-shadow:0 24px 80px #102B2733;background:#fff;color:var(--v2-text);width:min(720px,calc(100vw - 40px));max-height:90vh;overflow:auto;padding:28px;margin:auto;font-family:"Microsoft YaHei",sans-serif}.v2-dialog::backdrop{background:#102B2780;backdrop-filter:blur(3px)}.v2-dialog header{display:flex;justify-content:space-between;gap:20px;align-items:start;margin-bottom:24px}.v2-dialog header h2{font-size:22px;margin:6px 0}.v2-dialog header p{font-size:12px;color:var(--v2-muted)}.v2-dialog>h3{margin-top:22px}.v2-dialog>p{margin:9px 0}.v2-file-dialog{width:min(960px,calc(100vw - 40px))}.v2-package-meta{display:flex;flex-wrap:wrap;justify-content:space-between;gap:12px;align-items:center;font-size:12px;margin-bottom:18px}.v2-file-layout{display:grid;grid-template-columns:230px minmax(0,1fr);border:1px solid var(--v2-line);border-radius:10px;overflow:hidden;min-height:400px}.v2-file-layout aside{background:#F8FAF9;border-right:1px solid var(--v2-line);padding:16px}.v2-file-layout aside b{display:flex;gap:6px;margin-bottom:16px;font-size:12px;overflow-wrap:anywhere}.v2-file-layout aside button{display:flex;align-items:center;gap:7px;width:100%;text-align:left;padding:10px 7px;border-radius:5px;font-size:12px;overflow-wrap:anywhere}.v2-file-layout aside button.active{background:#E4EEE8;color:var(--v2-primary)}.v2-file-layout section{padding:18px;min-width:0}.v2-file-layout h3{font-family:Consolas,monospace;font-size:13px;border-bottom:1px solid var(--v2-line);padding-bottom:12px}.v2-file-layout pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:470px;overflow:auto;font:12px/1.8 Consolas,"Microsoft YaHei",monospace;padding-top:16px}
.v2-advanced{margin-top:24px;border:1px solid var(--v2-line);border-radius:12px;padding:18px;background:white}.v2-advanced>summary{cursor:pointer;font-size:14px;font-weight:600;color:var(--v2-muted)}.v2-advanced>div{padding-top:18px}
.xb-v2 .xb-finding{border:1px solid var(--v2-line);border-radius:9px;background:#FAFCFB;padding:16px;margin:12px 0;font-size:14px}.xb-v2 .xb-finding p{font-size:13px;color:var(--v2-muted)}.xb-v2 .xb-panel>h3{margin-top:24px;font-size:18px}.xb-v2 .xb-panel>p{font-size:14px;color:var(--v2-muted)}
.v2-probability{display:flex;align-items:center;gap:28px;border:1px solid var(--v2-line);border-radius:12px;padding:24px;background:linear-gradient(135deg,#EDF5F1,#FFFFFF);margin:20px 0}.v2-probability svg{flex-shrink:0}.v2-probability h3{font-size:22px;margin-bottom:8px}.v2-probability .v2-delta{color:var(--v2-primary);font-size:18px;font-weight:650}.v2-probability p{font-size:13px;color:var(--v2-muted)}
.v2-assembly{margin:20px 0}.v2-assembly h2{margin-bottom:16px}.v2-assembly-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.v2-assembly-card{border:1px solid var(--v2-line);border-radius:9px;padding:16px;background:#F8FAF9}.v2-assembly-card h3{margin-bottom:12px}.v2-assembly-card label{display:flex;align-items:start;gap:8px;font-size:13px;margin:10px 0}.v2-assembly-card small{display:block;font-size:11px;color:var(--v2-muted)}.v2-trajectory-steps{position:relative;padding-left:32px;border-left:2px solid #DCE9E2;margin-left:12px}.v2-trajectory-steps .xb-finding{position:relative}.v2-trajectory-steps .xb-finding:before{content:"";position:absolute;left:-42px;top:22px;border:4px solid #EDF5F1;background:var(--v2-primary);border-radius:50%;width:18px;height:18px}.v2-comparison-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-top:20px}.v2-comparison-grid>div{border:1px solid var(--v2-line);border-radius:10px;padding:20px;background:#FAFCFB}.v2-comparison-grid li{font-size:13px;padding:6px 0;overflow-wrap:anywhere}
@media(min-width:1700px){.xb-v2 .ra-main{max-width:1600px;margin-right:auto}.v2-library-layout{grid-template-columns:minmax(0,1fr) 320px}}
@media(max-width:1250px){.v2-library-layout{grid-template-columns:minmax(0,1fr)}.v2-extraction{position:static}.v2-steps{display:flex;gap:20px}.v2-steps li{flex:1;margin:0}.v2-card-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.v2-filter-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:900px){.xb-v2 .xb-agent-nav{width:190px;padding:22px 12px}.xb-v2 .ra-main{margin-left:190px;padding:24px 20px}.v2-metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.v2-card-grid,.v2-assembly-grid{grid-template-columns:minmax(0,1fr)}.v2-section-heading{align-items:start;flex-wrap:wrap}}
@media(max-width:760px){.xb-v2 .xb-agent-nav{position:relative;width:100%;flex-direction:row;flex-wrap:wrap;align-items:center;padding:12px;gap:6px}.xb-v2 .xb-agent-nav .xb-brand{width:100%;padding:4px 6px 14px}.xb-v2 .xb-agent-nav a{font-size:12px;min-height:40px;padding:9px}.xb-v2 .xb-nav-group,.xb-v2 .xb-project-context,.xb-v2 .xb-nav-spacer{display:none}.xb-v2 .xb-agent-nav .xb-role-switch{margin:6px 0}.xb-v2 .xb-role-switch>span{width:auto}.xb-v2 .ra-main{margin:0;padding:24px 16px}.v2-metrics{gap:10px}.v2-metrics article{padding:14px}.v2-surface,.xb-v2 .xb-panel,.xb-v2 .xb-task{padding:18px}.v2-steps{display:block}.v2-steps li{margin-bottom:16px}.v2-form-grid,.v2-file-layout,.v2-comparison-grid{grid-template-columns:1fr}.v2-file-layout aside{border-right:0;border-bottom:1px solid var(--v2-line)}.v2-probability{padding:18px;gap:16px;flex-wrap:wrap}}
@media(prefers-reduced-motion:reduce){.xb-v2 *{animation:none!important;transition:none!important;scroll-behavior:auto!important}}