Files

1436 lines
88 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>C · 极致简化 · LeAudit</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/remixicon@4.2.0/fonts/remixicon.css" rel="stylesheet">
<script>
tailwind.config = {
theme: { extend: {
colors: { brand: { 50:'#e8f3ef', 100:'#c7e0d5', 200:'#8ec1ab', 500:'#00684a', 600:'#005a3f', 700:'#004a34' } },
fontFamily: {
sans: ['-apple-system','BlinkMacSystemFont','"PingFang SC"','"Microsoft YaHei"','system-ui','sans-serif'],
mono: ['ui-monospace','"SF Mono"','Menlo','monospace'],
},
}},
};
</script>
<style>
html, body { height: 100%; overflow: hidden; }
body { font-feature-settings: 'tnum' 1; }
.fadein { animation: fd .2s ease-out; }
@keyframes fd { from { opacity:0; transform: translateY(-2px); } to { opacity:1; transform:none; } }
.scroll-slim::-webkit-scrollbar { width:6px; height:6px; }
.scroll-slim::-webkit-scrollbar-thumb { background:#cbd5e1; border-radius:3px; }
.scroll-slim::-webkit-scrollbar-thumb:hover { background:#94a3b8; }
/* cat group transitions */
.group-body { overflow: hidden; transition: max-height .18s ease-out; }
/* highlight grounding animation */
@keyframes pulseHl {
0% { box-shadow: 0 0 0 0 rgba(250,204,21,.55); }
70% { box-shadow: 0 0 0 10px rgba(250,204,21,0); }
100% { box-shadow: 0 0 0 0 rgba(250,204,21,0); }
}
.hl-pulse { animation: pulseHl 1.2s ease-out; }
details > summary { list-style: none; cursor: pointer; }
details > summary::-webkit-details-marker { display: none; }
details > summary .chev { transition: transform .15s ease; }
details[open] > summary .chev { transform: rotate(180deg); }
/* ── Left sidebar (白底 · icon + 小字标签) ── */
.nav-item {
width: 52px; height: 50px;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: 3px; padding: 4px 0;
color: #475569; /* slate-600 — 清晰 */
border-radius: 8px;
cursor: pointer;
position: relative;
transition: background .12s, color .12s;
}
.nav-item i { font-size: 18px; line-height: 1; }
.nav-item span { font-size: 10.5px; line-height: 1; font-weight: 500; letter-spacing: 0.02em; }
.nav-item:hover { color: #0f172a; background: #f1f5f9; }
.nav-item.is-active {
color: #00684a;
background: rgba(0,104,74,.10);
font-weight: 600;
}
.nav-item.is-active::before {
content: ''; position: absolute;
left: -6px; top: 10px; bottom: 10px; width: 3px;
background: #00684a; border-radius: 0 2px 2px 0;
}
/* 头像按钮:保留圆形不套内边距 */
.nav-item.nav-avatar { height: auto; padding: 4px; }
.nav-item.nav-avatar span { display: none; }
/* field card right stripe */
.field-btn { display: flex; align-items: stretch; overflow: hidden; }
</style>
</head>
<body class="bg-slate-50 text-slate-800 font-sans antialiased flex h-screen">
<!-- ░ Left Sidebar (白底·高对比度) ░ -->
<aside class="w-[64px] bg-white border-r border-slate-200 flex flex-col shrink-0 relative z-30">
<!-- Logo -->
<div class="h-12 flex items-center justify-center border-b border-slate-100">
<div class="w-8 h-8 rounded-lg bg-brand-500 grid place-items-center text-white shadow-sm cursor-pointer" title="LeAudit">
<i class="ri-scales-3-line text-[16px]"></i>
</div>
</div>
<!-- Nav items (icon + small label) -->
<nav class="flex-1 py-2 flex flex-col gap-1 items-center">
<a class="nav-item"><i class="ri-dashboard-line"></i><span>概览</span></a>
<a class="nav-item is-active"><i class="ri-file-list-3-line"></i><span>文档</span></a>
<a class="nav-item"><i class="ri-checkbox-multiple-line"></i><span>任务</span></a>
<a class="nav-item"><i class="ri-book-open-line"></i><span>规则库</span></a>
<a class="nav-item"><i class="ri-question-answer-line"></i><span>手册</span></a>
</nav>
<!-- Bottom utilities -->
<div class="py-2 flex flex-col gap-1 items-center border-t border-slate-100">
<button class="nav-item"><i class="ri-search-line"></i><span>搜索</span></button>
<button class="nav-item relative">
<i class="ri-notification-3-line"></i><span>通知</span>
<span class="absolute top-1.5 right-2 w-1.5 h-1.5 rounded-full bg-red-500 ring-2 ring-white"></span>
</button>
<button class="nav-item nav-avatar">
<div class="w-8 h-8 rounded-full bg-gradient-to-br from-brand-500 to-brand-700 grid place-items-center text-white text-[11px] font-semibold">ZW</div>
</button>
</div>
</aside>
<!-- ░ Right column ░ -->
<div class="flex-1 flex flex-col min-w-0 min-h-0">
<!-- ░░░ 三栏主体 ░░░ -->
<main class="flex-1 min-h-0 grid grid-cols-[260px,1fr,440px]">
<!-- ═══ 左栏 · 规则目录 ═══ -->
<aside class="border-r border-slate-200 bg-white flex flex-col min-h-0">
<!-- 文件 + 总分(融合) -->
<div class="shrink-0 px-3 py-3 border-b border-slate-100 space-y-2"
title="DOC-20260409-651041 · 62 页 · 13.7 MB · contract.sale v2.1 · 阶段 executed · 耗时 124.8s">
<!-- 文件名(次要,小字) -->
<div class="flex items-center gap-1.5 text-[11px] text-slate-500">
<button class="w-5 h-5 grid place-items-center rounded hover:bg-slate-100 text-slate-400 shrink-0" title="返回"><i class="ri-arrow-left-line"></i></button>
<i class="ri-file-text-line text-slate-400 shrink-0"></i>
<span class="truncate flex-1">2026年XX设备采购合同.pdf</span>
</div>
<!-- 分数 + 进度条 同行 -->
<div class="flex items-center gap-2">
<span class="text-[22px] font-semibold text-slate-900 tabular-nums leading-none">85</span>
<span class="text-[10.5px] text-slate-400">/100</span>
<div class="flex-1 h-1.5 rounded-full overflow-hidden bg-slate-100 ml-1">
<div class="flex h-full">
<div class="bg-emerald-500" style="width:76%"></div>
<div class="bg-amber-400" style="width:14%"></div>
<div class="bg-red-500" style="width:7%"></div>
<div class="bg-slate-300" style="width:3%"></div>
</div>
</div>
</div>
<!-- 合并状态:需关注 + 已通过 -->
<div class="flex items-center justify-between text-[11px]">
<span class="inline-flex items-center gap-1 text-orange-700 bg-orange-50 border border-orange-200 rounded px-1.5 py-0.5 font-medium">
<i class="ri-focus-3-line"></i> <span class="font-mono">7</span> 项需关注
</span>
<span class="text-slate-400"><span class="font-mono text-slate-500">22</span> / 30 已通过</span>
</div>
</div>
<!-- 顶部搜索 -->
<div class="shrink-0 p-2.5 border-b border-slate-100">
<div class="relative">
<i class="ri-search-line absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400 text-[14px]"></i>
<input id="rule-search" placeholder="搜索规则"
class="w-full h-8 pl-8 pr-2 bg-slate-50 border border-slate-200 rounded-md text-[12.5px]
focus:outline-none focus:bg-white focus:border-brand-500 focus:ring-2 focus:ring-brand-500/15">
</div>
</div>
<!-- rules list -->
<div id="rule-list" class="flex-1 min-h-0 overflow-y-auto scroll-slim py-1"></div>
</aside>
<!-- ═══ 中栏 · 原文预览 ═══ -->
<section class="flex flex-col min-h-0 bg-slate-100">
<!-- viewer toolbar -->
<div class="shrink-0 h-11 px-4 flex items-center justify-between bg-white border-b border-slate-200 text-[12.5px] text-slate-600">
<div class="flex items-center gap-2">
<button id="btn-thumbs" class="w-7 h-7 grid place-items-center rounded hover:bg-slate-100 text-brand-600" title="显示/隐藏页面缩略图"><i class="ri-layout-masonry-line"></i></button>
<span class="mx-0.5 text-slate-300">|</span>
<button class="w-7 h-7 grid place-items-center rounded hover:bg-slate-100"><i class="ri-arrow-left-s-line"></i></button>
<span id="page-info" class="font-mono tabular-nums">22 <span class="text-slate-400">/ 62</span></span>
<button class="w-7 h-7 grid place-items-center rounded hover:bg-slate-100"><i class="ri-arrow-right-s-line"></i></button>
<span class="mx-2 text-slate-300">|</span>
<button class="w-7 h-7 grid place-items-center rounded hover:bg-slate-100"><i class="ri-zoom-out-line"></i></button>
<span class="font-mono">100%</span>
<button class="w-7 h-7 grid place-items-center rounded hover:bg-slate-100"><i class="ri-zoom-in-line"></i></button>
</div>
<div class="flex items-center gap-2 text-[11.5px]">
<span class="text-slate-400">当前高亮:</span>
<span id="current-gd" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-amber-50 text-amber-800 border border-amber-200">
<i class="ri-focus-3-line"></i> MM-017 · 验收条款
</span>
<button class="w-7 h-7 grid place-items-center rounded hover:bg-slate-100" title="跳转到高亮"><i class="ri-crosshair-line"></i></button>
<button class="w-7 h-7 grid place-items-center rounded hover:bg-slate-100" title="全屏"><i class="ri-fullscreen-line"></i></button>
</div>
</div>
<!-- Body: thumbnails + viewport -->
<div class="flex-1 min-h-0 flex min-w-0">
<!-- Thumbnails panel (PDF 查看器内置) -->
<div id="thumbs-wrap" class="w-[132px] shrink-0 bg-white border-r border-slate-200 flex flex-col">
<!-- 模式切换条 -->
<div id="thumbs-header" class="shrink-0 px-2 py-2 border-b border-slate-100">
<div class="inline-flex rounded border border-slate-200 bg-slate-50 p-0.5 text-[10.5px] w-full">
<button id="btn-mode-filter" data-mode="filtered" class="mode-btn flex-1 h-5 rounded font-medium bg-white text-brand-700 shadow-sm">只看相关</button>
<button id="btn-mode-all" data-mode="all" class="mode-btn flex-1 h-5 rounded text-slate-500 hover:text-slate-700">全部 62</button>
</div>
</div>
<!-- 缩略图列表 -->
<div id="thumbs-panel" class="flex-1 overflow-y-auto scroll-slim py-2 px-2 space-y-2"></div>
</div>
<!-- Page viewport (宽高自动适配) -->
<div class="flex-1 min-h-0 overflow-hidden flex justify-center items-stretch p-4">
<div id="page-canvas" class="w-full max-w-[820px] bg-white rounded shadow-lg border border-slate-200 px-10 py-7 relative overflow-y-auto scroll-slim">
<!-- 内容由 JS 动态渲染 -->
</div>
</div><!-- /viewport -->
</div><!-- /body -->
</section>
<!-- ═══ 右栏 · 规则详情 ═══ -->
<aside class="border-l border-slate-200 bg-white flex flex-col min-h-0">
<!-- 右栏 Tabs -->
<nav id="detail-tabs" class="shrink-0 h-11 px-3 flex items-stretch gap-4 border-b border-slate-200 text-[12.5px]">
<button data-rtab="result" class="rtab h-full flex items-center text-slate-900 font-medium border-b-2 border-brand-500 -mb-[1px]">评查结果</button>
<button data-rtab="fields" class="rtab h-full flex items-center text-slate-500 hover:text-slate-800">抽取字段 <span class="ml-1 font-mono text-[11px] text-slate-400">35</span></button>
<button data-rtab="info" class="rtab h-full flex items-center text-slate-500 hover:text-slate-800">文件信息</button>
<button data-rtab="log" class="rtab h-full flex items-center text-slate-500 hover:text-slate-800">日志</button>
</nav>
<div id="detail" class="flex-1 min-h-0 flex flex-col"></div>
<!-- 全局操作栏 -->
<div class="shrink-0 border-t border-slate-200 p-2.5 bg-slate-50/60 flex items-center gap-2">
<button class="h-9 w-9 rounded-md text-slate-500 hover:bg-slate-200 grid place-items-center" title="下载文档"><i class="ri-download-line text-[16px]"></i></button>
<button class="h-9 w-9 rounded-md text-slate-500 hover:bg-slate-200 grid place-items-center" title="重新评查"><i class="ri-restart-line text-[16px]"></i></button>
<button class="flex-1 h-9 rounded-md bg-brand-500 text-white hover:bg-brand-600 text-[12.5px] font-medium flex items-center justify-center gap-1.5 shadow-sm">
<i class="ri-checkbox-circle-line"></i> 确认评查结果
</button>
</div>
</aside>
</main>
</div><!-- /Right column -->
<script>
/* ═══════════════════════════════════════════════════════════════════ */
/* Mock rules data */
/* ═══════════════════════════════════════════════════════════════════ */
const RULES = [
// 完整性 11
{ id:"MM-001", name:"合同主体齐全", cat:"完整性", risk:"high", sMax:10, sGet:10, status:"pass", conf:1.00, page:1,
stages:[{n:1,t:"required",pass:true,reason:"甲方 已填"},{n:2,t:"required",pass:true,reason:"乙方 已填"}],
fields:[{k:"甲方",v:"XX信息科技有限公司",conf:1,p:1},{k:"乙方",v:"某设备制造有限公司",conf:1,p:1}],
msg:{pass:"甲乙方信息完整",fail:"缺少甲方或乙方信息"} },
{ id:"MM-002", name:"标的物与金额必填", cat:"完整性", risk:"high", sMax:10, sGet:10, status:"pass", conf:0.96, page:3,
stages:[{n:1,t:"required",pass:true,reason:"标的描述 已填"},{n:2,t:"required",pass:true,reason:"合同金额 已填"}],
fields:[{k:"合同标的描述",v:"ERP 管理系统…",conf:.95,p:3},{k:"合同金额",v:"500000.00",conf:.92,p:3}],
msg:{pass:"标的物与金额信息完整",fail:"缺少标的物描述或合同金额"} },
{ id:"MM-003", name:"合同名称必填", cat:"完整性", risk:"medium", sMax:5, sGet:5, status:"pass", conf:0.99, page:1,
stages:[{n:1,t:"required",pass:true}], fields:[{k:"合同名称",v:"设备采购合同",conf:.99,p:1}],
msg:{pass:"合同名称已填写"} },
{ id:"MM-005", name:"交货期限必填", cat:"完整性", risk:"high", sMax:8, sGet:8, status:"pass", conf:0.95, page:8 },
{ id:"MM-006", name:"验收条款存在", cat:"完整性", risk:"high", sMax:8, sGet:8, status:"pass", conf:1.00, page:22 },
{ id:"MM-007", name:"违约责任条款存在", cat:"完整性", risk:"high", sMax:8, sGet:8, status:"pass", conf:1.00, page:30 },
{ id:"MM-010", name:"签约日期必填", cat:"完整性", risk:"high", sMax:8, sGet:8, status:"pending", conf:0.58, page:58,
stages:[{n:1,t:"required",pass:true,reason:"签约日期 已填 (低置信度)"}],
fields:[{k:"签约日期",v:"2025年1月7日",conf:.58,p:58,fallback:"2025-01-07"}],
msg:{pass:"签约日期已填写"}, rescue:"L1 · 队列 #3" },
{ id:"MM-011", name:"合同编号必填", cat:"完整性", risk:"medium", sMax:3, sGet:3, status:"pass", conf:0.99, page:1 },
// 规范性 2
{ id:"MM-012", name:"甲方信用代码校验", cat:"规范性", risk:"medium", sMax:5, sGet:5, status:"pass", conf:0.99, page:60 },
{ id:"MM-013", name:"乙方信用代码校验", cat:"规范性", risk:"medium", sMax:5, sGet:5, status:"pass", conf:0.99, page:60 },
// 合理性 3
{ id:"MM-014", name:"金额大小写一致", cat:"合理性", risk:"high", sMax:10, sGet:10, status:"pass", conf:0.98, page:20,
stages:[{n:1,t:"amount_match",pass:true,reason:"500000.00 ⇄ 伍拾万元整 一致"}],
fields:[{k:"合同金额",v:"500000.00",conf:.95,p:20},{k:"合同金额大写",v:"伍拾万元整",conf:.93,p:20}],
msg:{pass:"金额大小写一致"} },
{ id:"MM-015", name:"金额为正数", cat:"合理性", risk:"low", sMax:3, sGet:3, status:"pass", conf:1.00, page:3 },
{ id:"MM-016", name:"签约日期不是未来", cat:"合理性", risk:"low", sMax:3, sGet:3, status:"pass", conf:1.00, page:58 },
// 买卖专项 7
{ id:"MM-017", name:"验收条款完整", cat:"买卖专项", risk:"high", sMax:5, sGet:3.75, status:"warn", conf:0.87, page:22,
stages:[
{n:1,t:"required",pass:true,reason:"验收条款 已填"},
{n:2,t:"ai",pass:false,result:"warn",reason:"条款基本合理,但缺少明确的验收组织方和验收标准参照"}
],
fields:[{k:"验收条款",v:"乙方应于交付后 10 日内提交验收报告,甲方收到后 5 个工作日内组织初验…",conf:.90,p:22}],
suggestion:["补充明确验收组织方(谁组织、谁参与)","引用具体验收标准(如国家标准 / 行业标准 / 招标文件要求)","补充验收不合格时的详细处理流程"],
strengths:["已约定验收报告提交时限","有初验 / 终验两阶段区分"],
msg:{pass:"验收条款完整",fail:"验收条款不完整"} },
{ id:"MM-018", name:"风险转移条款明确", cat:"买卖专项", risk:"medium", sMax:2, sGet:2, status:"pass", conf:0.91, page:26 },
{ id:"MM-019", name:"质保期条款完整", cat:"买卖专项", risk:"high", sMax:3, sGet:2.25, status:"warn", conf:0.85, page:28,
stages:[{n:1,t:"required",pass:true},{n:2,t:"ai",pass:false,result:"warn",reason:"质保范围描述较笼统,未列出具体除外情形"}],
fields:[{k:"质保期条款",v:"乙方对设备提供 12 个月质保…",conf:.88,p:28}],
suggestion:["列出质保范围外的情形(人为损坏等)","明确 7×24 / 响应时限"],
msg:{pass:"质保期条款完整",fail:"质保期条款不完整"} },
{ id:"MM-020", name:"履约保证金条款完整", cat:"买卖专项", risk:"medium", sMax:3, sGet:3, status:"pass", conf:0.89, page:34 },
{ id:"MM-022", name:"知识产权条款完整", cat:"买卖专项", risk:"high", sMax:3, sGet:null, status:"skipped", conf:1.00, skipReason:"activate_if 未命中", skipExpr:'涉及知识产权 == "是"', skipActual:"否" },
{ id:"MM-023", name:"标的清单金额校验", cat:"买卖专项", risk:"high", sMax:5, sGet:0, status:"fail", conf:0.92, page:16,
stages:[{n:1,t:"required",pass:true},{n:2,t:"ai",pass:false,result:"fail",reason:"清单合计 498000 与合同总金额 500000 不一致,差额 2000"}],
fields:[{k:"标的清单明细",v:"设备 A×2 + 设备 B×1 + 软件授权 × 10 …",conf:.92,p:16},{k:"合同金额",v:"500000.00",conf:.95,p:3}],
suggestion:["核对清单各项单价 × 数量 = 项总价","核对各项总价之和 = 合同总金额"],
msg:{pass:"标的清单金额校验通过",fail:"标的清单金额不一致"} },
{ id:"MM-024", name:"招投标信息引用完整", cat:"买卖专项", risk:"high", sMax:3, sGet:3, status:"pending", conf:0.72, page:2, rescue:"L2 · 队列 #8" },
// 合规 AI 4
{ id:"MM-025", name:"违约责任条款充分", cat:"合规 AI", risk:"medium", sMax:5, sGet:5, status:"pass", conf:0.91, page:30 },
{ id:"MM-026", name:"争议解决方式明确", cat:"合规 AI", risk:"medium", sMax:5, sGet:5, status:"pass", conf:0.93, page:36 },
{ id:"MM-027", name:"付款条款明确", cat:"合规 AI", risk:"medium", sMax:5, sGet:3.75, status:"warn", conf:0.82, page:15,
stages:[{n:1,t:"required",pass:true},{n:2,t:"ai",pass:false,result:"warn",reason:"付款条件基本清楚,但付款方式未明确说明"}],
fields:[{k:"付款方式",v:"签订合同后支付 30%,验收后支付 60%,质保期满支付 10%",conf:.88,p:15}],
suggestion:["补充付款方式:银行转账 / 电汇等","补充发票/验收报告等付款前置条件"],
msg:{pass:"付款条款明确",fail:"付款条款不够明确"} },
{ id:"MM-028", name:"保密条款完整", cat:"合规 AI", risk:"low", sMax:3, sGet:3, status:"pass", conf:0.94, page:40 },
// 银行信息 1
{ id:"MM-029", name:"收款方银行信息完整", cat:"银行信息", risk:"high", sMax:5, sGet:0, status:"fail", conf:1.00, page:58,
stages:[{n:1,t:"required",pass:false,reason:"收款方开户银行 缺失"}],
fields:[{k:"收款方开户银行",v:null,conf:0,p:null},{k:"收款方银行账号",v:"6225****1234",conf:1,p:58}],
msg:{pass:"收款方银行信息完整",fail:"缺少收款方银行开户行或账号,付款无法执行"} },
// 案卷校核 1
{ id:"ADM-045", name:"行政处罚决定当事人基本情况记载准确性", cat:"案卷校核", risk:"medium", sMax:10, sGet:7.5, status:"warn", conf:0.83, page:5,
stages:[{n:1,t:"ai",pass:false,result:"warn",reason:"基本信息整体准确,仅处罚决定书未载明统一社会信用代码,建议补充以提升法律文书严谨性"}],
comparisons:[
{ label:"当事人身份", status:"pass", note:"个体户对应",
pairs:[{src:"处罚决定书·当事人",val:"江小妹"},{src:"许可证·企业名称",val:"郁南县连滩镇领航烟酒商行"}],
aiNote:"个人当事人 + 所属个体商号,名称对应合理" },
{ label:"字号 / 工商名称", status:"pass", note:"一致",
pairs:[{src:"处罚决定书·字号",val:"郁烟处[2025]第2号"},{src:"营业执照·名称",val:"郁南县连滩镇领航烟酒商行"}] },
{ label:"统一社会信用代码", status:"warn", note:"决定书缺失",
pairs:[{src:"处罚决定书·信用代码",val:null},{src:"营业执照·信用代码",val:"92445322MA56A8BP2A"}] },
{ label:"经营地址", status:"pass", note:"全一致", count:3,
pairs:[{src:"处罚决定书·经营地址",val:"郁南县连滩镇中华路106号一楼"},{src:"许可证·经营场所",val:"郁南县连滩镇中华路106号一楼"},{src:"营业执照·住所",val:"郁南县连滩镇中华路106号一楼"}] },
{ label:"身份证", status:"pass", note:"全一致", fold:true, foldLabel:"姓名·性别·民族·住址·证号",
pairs:[{src:"姓名",val:"江小妹 ⇄ 江小妹"},{src:"性别",val:"女性 ⇄ 女"},{src:"民族",val:"汉族 ⇄ 汉"},{src:"住址",val:"广东省云浮市郁南县宋桂镇宋桂村委尾一村6号"},{src:"身份证号",val:"445322198602014328"}],
foldFooter:"处罚决定书 ⇄ 居民身份证" }
],
suggestion:["决定书\u201c当事人基本情况\u201d段落补充统一社会信用代码"],
strengths:["个人信息(姓名/身份证号)5 项完全匹配","三处经营地址跨文档完全一致"],
msg:{pass:"当事人基本情况记载准确",fail:"当事人基本情况记载存在不一致"} },
];
const STATUS = {
pass: { icon:"ri-checkbox-circle-fill", color:"text-emerald-500", chipCls:"bg-emerald-50 text-emerald-700 border-emerald-200", label:"通过" },
warn: { icon:"ri-lightbulb-flash-fill", color:"text-amber-500", chipCls:"bg-amber-50 text-amber-700 border-amber-200", label:"提示" },
fail: { icon:"ri-close-circle-fill", color:"text-red-500", chipCls:"bg-red-50 text-red-700 border-red-200", label:"失败" },
skipped: { icon:"ri-forbid-2-line", color:"text-slate-400", chipCls:"bg-slate-100 text-slate-600 border-slate-200", label:"跳过" },
pending: { icon:"ri-question-fill", color:"text-orange-500", chipCls:"bg-orange-50 text-orange-700 border-orange-200", label:"待人工" },
};
const RISK = {
high: { dot:"bg-red-500", cls:"bg-red-50 text-red-700 border-red-200", label:"高" },
medium: { dot:"bg-amber-500", cls:"bg-amber-50 text-amber-700 border-amber-200", label:"中" },
low: { dot:"bg-emerald-500", cls:"bg-emerald-50 text-emerald-700 border-emerald-200", label:"低" },
};
const CHECKS = {
required: { icon:"ri-check-double-line", label:"字段必填", tone:"bg-sky-50 text-sky-700 ring-sky-200" },
format: { icon:"ri-regex", label:"格式校验", tone:"bg-violet-50 text-violet-700 ring-violet-200" },
compare: { icon:"ri-equalizer-2-line", label:"数值比较", tone:"bg-amber-50 text-amber-700 ring-amber-200" },
amount_match: { icon:"ri-scales-2-line", label:"金额一致", tone:"bg-emerald-50 text-emerald-700 ring-emerald-200" },
assert: { icon:"ri-function-line", label:"表达式断言", tone:"bg-slate-100 text-slate-700 ring-slate-200" },
ai: { icon:"ri-sparkling-2-line", label:"大模型判断", tone:"bg-fuchsia-50 text-fuchsia-700 ring-fuchsia-200" },
};
/* ═══════════════════════════════════════════════════════════════════ */
/* Filter state */
/* ═══════════════════════════════════════════════════════════════════ */
const fState = { status:"all", risk:new Set(["high","medium","low"]), q:"", sort:"default" };
const STATUS_FILTERS = [
{ key:"all", label:"全部", getCls:(n,active)=>active ? "bg-slate-900 text-white border-slate-900" : "bg-white text-slate-700 border-slate-200 hover:border-slate-400" },
{ key:"pending", label:"待人工", getCls:(n,active)=>active ? "bg-orange-500 text-white border-orange-500" : "bg-white text-orange-700 border-orange-200 hover:bg-orange-50" },
{ key:"warn", label:"提示", getCls:(n,active)=>active ? "bg-amber-500 text-white border-amber-500" : "bg-white text-amber-700 border-amber-200 hover:bg-amber-50" },
{ key:"fail", label:"失败", getCls:(n,active)=>active ? "bg-red-500 text-white border-red-500" : "bg-white text-red-700 border-red-200 hover:bg-red-50" },
{ key:"pass", label:"通过", getCls:(n,active)=>active ? "bg-emerald-500 text-white border-emerald-500":"bg-white text-emerald-700 border-emerald-200 hover:bg-emerald-50" },
{ key:"skipped", label:"跳过", getCls:(n,active)=>active ? "bg-slate-500 text-white border-slate-500" : "bg-white text-slate-600 border-slate-200 hover:bg-slate-100" },
];
function renderFilterChips() {
const host = document.getElementById("filter-chips");
host.innerHTML = STATUS_FILTERS.map(f => {
const n = f.key === "all" ? RULES.length : RULES.filter(r => r.status === f.key).length;
const active = fState.status === f.key;
return `<button data-status="${f.key}" class="inline-flex items-center gap-1 px-2 h-6 rounded border text-[11px] font-medium ${f.getCls(n,active)}">
${f.label}<span class="font-mono text-[10px] opacity-80">${n}</span>
</button>`;
}).join("");
}
/* ═══════════════════════════════════════════════════════════════════ */
/* Rule list render */
/* ═══════════════════════════════════════════════════════════════════ */
let selectedId = "MM-017";
const openCats = new Set([
"完整性","规范性","合理性","买卖专项","合规 AI","银行信息",
"passcat:完整性","passcat:规范性","passcat:合理性","passcat:买卖专项","passcat:合规 AI","passcat:银行信息",
]);
function filteredRules() {
let arr = RULES.filter(r => {
if (fState.status !== "all" && r.status !== fState.status) return false;
if (!fState.risk.has(r.risk)) return false;
if (fState.q) {
const q = fState.q.toLowerCase();
if (!(r.id.toLowerCase().includes(q) || r.name.toLowerCase().includes(q))) return false;
}
return true;
});
// sort
if (fState.sort === "fail") {
const order = { fail:0, warn:1, pending:2, skipped:3, pass:4 };
arr = arr.slice().sort((a,b) => order[a.status]-order[b.status]);
} else if (fState.sort === "risk") {
const order = { high:0, medium:1, low:2 };
arr = arr.slice().sort((a,b) => order[a.risk]-order[b.risk]);
} else if (fState.sort === "conf") {
arr = arr.slice().sort((a,b) => a.conf-b.conf);
}
return arr;
}
function ruleRow(r, showCat = true) {
const s = STATUS[r.status];
const active = r.id === selectedId;
return `
<button data-id="${r.id}" class="rule-item relative w-full py-2 px-3 text-left transition
${active ? 'bg-brand-50' : 'hover:bg-slate-50'}">
${active ? '<span class="absolute left-0 top-2 bottom-2 w-0.5 bg-brand-500 rounded-r"></span>' : ''}
<div class="flex items-center gap-2 min-w-0">
<i class="${s.icon} ${s.color} shrink-0 text-[14px]"></i>
<span class="text-[12.5px] text-slate-800 truncate flex-1 ${active ? 'font-semibold' : ''}">${r.name}</span>
${showCat ? `<span class="shrink-0 text-[10px] text-slate-400">${r.cat}</span>` : ''}
</div>
</button>`;
}
let ruleSearchQuery = "";
function renderRuleList() {
const host = document.getElementById("rule-list");
const q = ruleSearchQuery.toLowerCase();
const match = r => !q || r.id.toLowerCase().includes(q) || r.name.toLowerCase().includes(q) || r.cat.toLowerCase().includes(q);
// 上半:需关注(扁平 · 每条右侧显示维度)
const needHandle = RULES.filter(r => r.status !== "pass" && match(r));
const handleList = needHandle.length
? `<div>${needHandle.map(r => ruleRow(r, true)).join("")}</div>`
: `<div class="text-center py-6 text-[12px] text-slate-400">
<i class="${q ? 'ri-search-eye-line' : 'ri-check-double-line'} text-2xl ${q?'text-slate-300':'text-emerald-400'}"></i>
<div class="mt-1">${q ? '没有匹配' : '已全部处理'}</div>
</div>`;
// 下半:已通过(折叠 · 展开后按维度分组)
const passed = RULES.filter(r => r.status === "pass" && match(r));
const passOpen = openCats.has("__pass__");
let passBlock = "";
if (passed.length) {
let innerGroups = "";
if (passOpen) {
const groups = {};
passed.forEach(r => (groups[r.cat] = groups[r.cat] || []).push(r));
innerGroups = Object.keys(groups).map(cat => {
const subKey = "passcat:" + cat;
const subOpen = openCats.has(subKey);
return `
<div>
<button data-cat="${subKey}" class="cat-hd w-full flex items-center gap-1.5 px-3 py-1.5 bg-slate-50/40 hover:bg-slate-100 text-left">
<i class="ri-arrow-right-s-line text-slate-400 text-[11px] transition-transform ${subOpen?'rotate-90':''}"></i>
<span class="text-[10px] font-medium text-slate-500 uppercase tracking-wider">${cat}</span>
<span class="ml-auto font-mono text-[10px] text-slate-400">${groups[cat].length}</span>
</button>
${subOpen ? groups[cat].map(r => ruleRow(r, false)).join("") : ""}
</div>`;
}).join("");
}
passBlock = `
<div class="border-t border-slate-200 mt-2">
<button data-cat="__pass__" class="cat-hd w-full flex items-center gap-2 px-3 py-2 bg-slate-50/60 hover:bg-slate-100 text-left">
<i class="ri-arrow-right-s-line text-slate-400 text-[12px] transition-transform ${passOpen?'rotate-90':''}"></i>
<i class="ri-checkbox-circle-fill text-emerald-500 text-[14px]"></i>
<span class="text-[12px] text-slate-600">已通过</span>
<span class="ml-auto font-mono text-[11px] text-slate-400">${passed.length}</span>
</button>
${innerGroups}
</div>`;
}
host.innerHTML = handleList + passBlock;
}
/* ═══════════════════════════════════════════════════════════════════ */
/* Card render (卡片式右栏) */
/* ═══════════════════════════════════════════════════════════════════ */
function renderCard(r) {
const s = STATUS[r.status];
const selected = r.id === selectedId;
const st = r.status;
const wrapCls = st === 'skipped'
? 'bg-slate-50 border-slate-200 opacity-80'
: selected ? 'bg-white border-brand-300 ring-1 ring-brand-200' : 'bg-white border-slate-200';
const scoreColor = st === 'pass' ? 'text-emerald-600' : st === 'skipped' ? 'text-slate-400' : st === 'fail' ? 'text-red-600' : 'text-amber-600';
const scoreText = st === 'skipped' ? `—/${r.sMax}` : `${r.sGet}/${r.sMax}`;
const confColor = r.conf < 0.8 ? 'text-orange-600' : 'text-slate-700';
const sLabel = { pass:'通过', warn:'警告', fail:'不通过', pending:'修救中', skipped:'已跳过' };
let statusText = sLabel[st];
if (st === 'pending' && r.rescue) statusText = `修救中 · ${r.rescue}`;
// ── Header ──
const header = `
<header class="px-4 pt-4 pb-3 border-b border-slate-100">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-mono text-[11px] px-1.5 py-0.5 rounded ${st === 'skipped' ? 'bg-slate-200/70 text-slate-500' : 'bg-slate-100 text-slate-600'} shrink-0">${r.id}</span>
<h2 class="text-[14.5px] ${st === 'skipped' ? 'font-medium text-slate-500 line-through decoration-slate-400/60' : 'font-semibold text-slate-900'} break-all leading-snug">${r.name}</h2>
</div>
<div class="mt-2 flex items-center gap-2">
<div class="flex items-center gap-1.5 flex-wrap flex-1 min-w-0">
<span class="inline-flex items-center gap-1 px-1.5 h-5 rounded text-[10.5px] ${s.chipCls}">
${st === 'pending' ? '<i class="ri-loader-4-line animate-spin"></i>' : `<i class="${s.icon}"></i>`}${statusText}
</span>
<span class="inline-flex items-center gap-1 px-1.5 h-5 rounded text-[10.5px] ${RISK[r.risk].cls}">
<span class="w-1.5 h-1.5 rounded-full ${RISK[r.risk].dot}"></span>${RISK[r.risk].label}风险
</span>
${st !== 'pass' && st !== 'skipped' ? '<span class="inline-flex items-center gap-1 px-1.5 h-5 rounded text-[10.5px] bg-slate-50 text-slate-600 border border-slate-200"><i class="ri-user-line"></i>需人工</span>' : ''}
${r.conf < 0.8 && st !== 'skipped' ? '<span class="inline-flex items-center gap-1 px-1.5 h-5 rounded text-[10.5px] bg-orange-50 text-orange-700 border border-orange-200"><i class="ri-focus-3-line"></i>低置信</span>' : ''}
</div>
${st !== 'skipped' ? `<div class="flex items-center gap-3 shrink-0 text-[11px] text-slate-500">
<span>分值 <span class="font-mono ${scoreColor} font-medium">${scoreText}</span></span>
<span>置信度 <span class="font-mono ${confColor} font-medium">${Math.round(r.conf*100)}%</span></span>
</div>` : ''}
</div>
</header>`;
// ── Fields ──
let fieldsHTML = '';
if (r.fields?.length && st !== 'skipped') {
const cards = r.fields.map(f => {
const low = f.conf < 0.8;
const bCls = low ? 'border-orange-200 bg-orange-50/40' : 'border-slate-200 bg-slate-50';
const ico = f.v === null ? '<i class="ri-prohibited-line text-red-500 text-[16px]"></i>'
: low ? '<i class="ri-question-line text-orange-500 text-[16px]"></i>'
: '<i class="ri-check-line text-emerald-500 text-[16px]"></i>';
return `
<button type="button" class="field-btn ${f.p ? 'jump-page' : ''} w-full border ${bCls} rounded-md hover:bg-brand-50 hover:border-brand-200 transition text-left group" ${f.p ? `data-page="${f.p}"` : ''}>
<div class="p-2.5 flex-1 min-w-0">
<div class="flex items-center justify-between gap-2">
<div class="text-[11px] text-slate-500 truncate font-medium">${f.k}</div>
${f.v !== null
? `<span class="text-[10.5px] font-mono ${low ? 'text-orange-600' : 'text-slate-400'} shrink-0">${Math.round(f.conf*100)}%</span>`
: '<span class="text-[10.5px] text-red-600 shrink-0">缺失</span>'}
</div>
${f.v !== null ? `<div class="text-[12px] text-slate-700 mt-1 leading-relaxed line-clamp-2">${f.v}</div>` : ''}
${f.fallback ? `<div class="mt-1.5 flex items-center gap-1 text-[10.5px] text-orange-700 bg-orange-100/60 rounded px-1.5 py-0.5 w-fit"><i class="ri-refresh-line"></i>deep_retry → <code class="font-mono">${f.fallback}</code></div>` : ''}
</div>
<div class="w-8 shrink-0 flex items-center justify-center border-l ${low ? 'border-orange-200' : 'border-slate-200'} group-hover:border-brand-200">${ico}</div>
</button>`;
}).join('');
fieldsHTML = `
<section class="px-4 pt-3">
<div class="text-[11px] font-medium text-slate-400 uppercase tracking-wider mb-2">抽取字段 <span class="font-mono normal-case text-[10.5px]">${r.fields.length}</span></div>
<div class="space-y-2">${cards}</div>
</section>`;
}
// ── Comparisons (跨文档比对) ──
let comparisonsHTML = '';
if (r.comparisons?.length) {
const cNums = '①②③④⑤⑥⑦⑧⑨⑩';
const passC = r.comparisons.filter(c => c.status === 'pass').length;
const warnC = r.comparisons.filter(c => c.status !== 'pass').length;
const badge = [
passC ? `<span class="inline-flex items-center gap-0.5 text-emerald-700"><i class="ri-checkbox-circle-fill"></i>${passC}</span>` : '',
warnC ? `<span class="inline-flex items-center gap-0.5 text-amber-700"><i class="ri-error-warning-fill"></i>${warnC}</span>` : ''
].filter(Boolean).join('');
const groups = r.comparisons.map((c, i) => {
const ok = c.status === 'pass';
const bdr = ok ? 'border-emerald-200' : 'border-amber-300';
const hBg = ok ? 'bg-emerald-50/60' : 'bg-amber-50/70';
const hBdr = ok ? 'border-emerald-200/70' : 'border-amber-200';
const nCls = ok ? 'text-emerald-700' : 'text-amber-800';
const nIco = ok ? 'ri-checkbox-circle-fill' : 'ri-error-warning-fill';
const cntLabel = c.count ? `<span class="text-slate-400 text-[10.5px]">${c.count} 处</span>` : '';
const pRows = c.pairs.map(p => `
<div class="px-2.5 py-1.5 flex items-start justify-between gap-3">
<div class="text-slate-500 shrink-0">${p.src}</div>
${p.val === null
? '<span class="text-red-600 inline-flex items-center gap-0.5 font-medium shrink-0"><i class="ri-prohibited-line"></i>未填写</span>'
: `<div class="text-slate-800 truncate min-w-0 text-right ${/^\d/.test(p.val) ? 'font-mono text-[11px]' : ''}">${p.val}</div>`}
</div>`).join('');
const aiN = c.aiNote ? `
<div class="px-2.5 py-1.5 bg-fuchsia-50/40 border-t border-fuchsia-100 flex gap-1.5 text-[11px] text-slate-600 leading-relaxed">
<i class="ri-sparkling-2-fill text-fuchsia-500 shrink-0 mt-0.5"></i><span>${c.aiNote}</span>
</div>` : '';
const fFoot = c.foldFooter ? `
<div class="px-2.5 py-1 bg-slate-50/60 border-t border-slate-100 text-[10.5px] text-slate-400 text-right">${c.foldFooter}</div>` : '';
if (c.fold) {
return `
<details class="border ${bdr} rounded-md overflow-hidden bg-white">
<summary class="${hBg} px-2.5 py-1.5 flex items-center justify-between gap-2 cursor-pointer hover:bg-emerald-50 list-none">
<div class="flex items-center gap-1.5 text-[12px] min-w-0">
<i class="chev ri-arrow-down-s-line text-slate-400 shrink-0"></i>
<span class="font-mono text-[10.5px] text-slate-500">${cNums[i]}</span>
<span class="font-medium text-slate-800 shrink-0">${c.label}</span>
<span class="text-slate-400 text-[10.5px] truncate">${c.foldLabel || ''}</span>
</div>
<span class="text-[10.5px] ${nCls} inline-flex items-center gap-0.5 shrink-0 font-medium"><i class="${nIco}"></i>${c.note}</span>
</summary>
<div class="divide-y divide-slate-100 text-[11px] border-t ${hBdr}">${pRows}</div>
${fFoot}
</details>`;
}
return `
<div class="border ${bdr} rounded-md overflow-hidden bg-white">
<div class="${hBg} px-2.5 py-1.5 flex items-center justify-between gap-2 border-b ${hBdr}">
<div class="flex items-center gap-1.5 text-[12px] min-w-0">
<span class="font-mono text-[10.5px] text-slate-500">${cNums[i]}</span>
<span class="font-medium text-slate-800 truncate">${c.label}</span>${cntLabel}
</div>
<span class="text-[10.5px] ${nCls} inline-flex items-center gap-0.5 shrink-0 font-medium"><i class="${nIco}"></i>${c.note}</span>
</div>
<div class="divide-y divide-slate-100 text-[11.5px]">${pRows}</div>
${aiN}
</div>`;
}).join('');
comparisonsHTML = `
<section class="px-4 pt-3">
<div class="flex items-center justify-between mb-2">
<div class="text-[11px] font-medium text-slate-400 uppercase tracking-wider">跨文档比对 <span class="font-mono normal-case text-[10.5px]">${r.comparisons.length} 组</span></div>
<div class="flex items-center gap-1.5 text-[11px]">${badge}</div>
</div>
<div class="space-y-2">${groups}</div>
</section>`;
}
// ── Body (type-specific) ──
let bodyHTML = '';
if (st === 'skipped') {
bodyHTML = `
<section class="px-4 py-3">
<div class="text-[11px] font-medium text-slate-400 uppercase tracking-wider mb-2">跳过原因</div>
<div class="p-3 bg-white border border-slate-200 rounded-md text-[12px] text-slate-600 space-y-1">
<div class="flex items-start gap-2">
<i class="ri-git-branch-line text-slate-400 text-[14px] mt-0.5 shrink-0"></i>
<div>
<div class="font-medium text-slate-700">${r.skipReason || 'activate_if 未命中'}</div>
${r.skipExpr ? `<div class="mt-1 flex items-center gap-1"><span class="text-slate-400">条件:</span><code class="font-mono bg-slate-100 px-1 rounded text-slate-700">${r.skipExpr}</code></div>` : ''}
${r.skipActual !== undefined ? `<div class="mt-0.5 flex items-center gap-1"><span class="text-slate-400">实际:</span><code class="font-mono bg-slate-100 px-1 rounded text-slate-700">${r.skipActual}</code></div>` : ''}
</div>
</div>
</div>
</section>`;
} else if (st === 'pending') {
bodyHTML = `
<section class="px-4 pt-3">
<div class="text-[11px] font-medium text-slate-400 uppercase tracking-wider mb-2 flex items-center gap-1">
<i class="ri-loader-4-line text-orange-500 text-[12px] animate-spin"></i> 修救进行中
</div>
<div class="p-3 bg-orange-50/60 border border-orange-200 rounded-md text-[12.5px] text-slate-700 leading-relaxed">
<div class="flex gap-2">
<i class="ri-information-line text-orange-500 text-[15px] mt-0.5 shrink-0"></i>
<div>
已提交 <span class="font-mono bg-white/70 px-1 rounded">${r.rescue || 'LLM 重判'}</span>
完成后将自动更新字段置信度与评查结论。
<div class="mt-1.5 text-slate-500 text-[11.5px]">预计 25 秒 · 无需人工干预</div>
</div>
</div>
</div>
</section>`;
} else if (st === 'pass') {
const last = r.stages?.[r.stages.length - 1];
const txt = r.msg?.pass || last?.reason || '校验通过';
const tp = last?.t || '';
bodyHTML = `
<section class="px-4 pt-3">
<div class="text-[11px] font-medium text-slate-400 uppercase tracking-wider mb-2 flex items-center gap-1">
<i class="ri-shield-check-line text-emerald-500 text-[12px]"></i> 校验结果
</div>
<div class="p-3 bg-emerald-50/60 border border-emerald-200 rounded-md">
<div class="flex gap-2 text-[12.5px] text-slate-700 leading-relaxed">
<i class="ri-checkbox-circle-fill text-emerald-500 text-[15px] mt-0.5 shrink-0"></i>
<div>${txt}${tp ? `<div class="mt-0.5 text-[11px] text-slate-500">校验类型 <code class="font-mono">${tp}</code></div>` : ''}</div>
</div>
</div>
</section>`;
} else {
// fail or warn → AI opinion
const aiStage = [...(r.stages||[])].reverse().find(x => x.t === 'ai');
const bg = st === 'fail' ? 'bg-red-50/60 border-red-200' : 'bg-amber-50/50 border-amber-200';
const aIco = st === 'fail'
? '<i class="ri-close-circle-fill text-red-500 text-[15px] mt-0.5 shrink-0"></i>'
: '<i class="ri-lightbulb-flash-fill text-amber-500 text-[15px] mt-0.5 shrink-0"></i>';
const dv = st === 'fail' ? 'border-red-200/70' : 'border-amber-200/60';
const reason = aiStage?.reason || (st === 'fail' ? r.msg?.fail : '') || '';
let inner = `<div class="flex gap-2 text-[12.5px] text-slate-700 leading-relaxed">${aIco}<div>${reason}</div></div>`;
if (r.strengths?.length) {
inner += `<div class="pt-2 border-t ${dv}">
<div class="flex items-center gap-1 text-[11px] font-medium text-emerald-700 mb-1.5"><i class="ri-medal-line"></i> 亮点 <span class="font-mono text-[10.5px] text-emerald-500">${r.strengths.length}</span></div>
<ul class="space-y-1 text-[12px] text-slate-700">${r.strengths.map(x => `<li class="flex gap-1.5"><i class="ri-check-line text-emerald-500 mt-[1px] shrink-0"></i><span>${x}</span></li>`).join('')}</ul>
</div>`;
}
if (r.suggestion?.length) {
inner += `<div class="pt-2 border-t ${dv}">
<div class="flex items-center gap-1 text-[11px] font-medium text-fuchsia-700 mb-1.5"><i class="ri-edit-2-line"></i> 修改建议 <span class="font-mono text-[10.5px] text-fuchsia-500">${r.suggestion.length}</span></div>
<ul class="space-y-1 text-[12px] text-slate-700">${r.suggestion.map(x => `<li class="flex gap-1.5"><span class="text-fuchsia-400 shrink-0">•</span>${x}</li>`).join('')}</ul>
</div>`;
}
bodyHTML = `
<section class="px-4 pt-3">
<div class="text-[11px] font-medium text-slate-400 uppercase tracking-wider mb-2 flex items-center gap-1">
<i class="ri-sparkling-2-fill text-fuchsia-500 text-[12px]"></i> AI 评查意见
</div>
<div class="p-3 ${bg} border rounded-md space-y-3">${inner}</div>
</section>`;
}
// ── Textarea (non-pass, non-skipped) ──
let textareaHTML = '';
if (st !== 'skipped' && st !== 'pass') {
textareaHTML = `
<section class="px-4 pt-3">
<div class="text-[11px] font-medium text-slate-400 uppercase tracking-wider mb-2">审核意见 <span class="normal-case text-slate-300 font-normal">可选</span></div>
<textarea rows="2" placeholder="在此填写人工复核结论…"
class="w-full p-2 border border-slate-200 rounded-md text-[12.5px] min-h-[56px]
focus:outline-none bg-white focus:border-brand-500 focus:ring-2 focus:ring-brand-500/15 resize-none placeholder:text-slate-400"></textarea>
</section>`;
}
// ── Footer ──
let footerHTML = '';
if (st !== 'skipped') {
let btns = '';
if (st === 'pending') {
btns = '<button disabled class="h-8 px-3 rounded-md text-[12.5px] bg-slate-100 text-slate-400 cursor-not-allowed flex items-center gap-1 font-medium"><i class="ri-loader-4-line animate-spin"></i>等待修救</button>';
} else if (st === 'pass') {
btns = `<div class="flex items-center gap-2">
<span class="text-[11px] text-emerald-700 inline-flex items-center gap-1"><i class="ri-verified-badge-fill"></i>已自动通过</span>
<button class="h-8 px-3 rounded-md text-[12.5px] bg-white border border-slate-200 text-slate-600 hover:bg-slate-100 flex items-center gap-1 font-medium"><i class="ri-edit-line"></i>修改</button>
</div>`;
} else if (st === 'fail') {
btns = `<div class="flex gap-2">
<button class="h-8 px-3 rounded-md text-[12.5px] bg-white border border-slate-200 text-slate-600 hover:bg-slate-100 flex items-center gap-1 font-medium"><i class="ri-check-line"></i>通过</button>
<button class="h-8 px-3 rounded-md text-[12.5px] bg-red-500 text-white hover:bg-red-600 flex items-center gap-1 font-medium shadow-sm"><i class="ri-close-line"></i>不通过</button>
</div>`;
} else {
btns = `<div class="flex gap-2">
<button class="h-8 px-3 rounded-md text-[12.5px] bg-white border border-red-200 text-red-700 hover:bg-red-50 flex items-center gap-1 font-medium"><i class="ri-close-line"></i>不通过</button>
<button class="h-8 px-3 rounded-md text-[12.5px] bg-emerald-500 text-white hover:bg-emerald-600 flex items-center gap-1 font-medium shadow-sm"><i class="ri-check-line"></i>通过</button>
</div>`;
}
footerHTML = `
<footer class="mt-3 px-4 py-3 flex items-center justify-between gap-2 border-t border-slate-100 bg-slate-50/60">
<div class="flex items-center gap-0.5 text-[11.5px] text-slate-500">
<button class="h-7 px-1.5 rounded hover:bg-slate-200/70 inline-flex items-center gap-1"><i class="ri-code-s-slash-line"></i>DSL</button>
</div>
${btns}
</footer>`;
}
return `<article data-card-id="${r.id}" class="${wrapCls} border rounded-lg shadow-sm overflow-hidden transition hover:shadow-md">
${header}${fieldsHTML}${comparisonsHTML}${bodyHTML}${textareaHTML}${footerHTML}
</article>`;
}
/* ═══════════════════════════════════════════════════════════════════ */
/* Detail render (legacy) */
/* ═══════════════════════════════════════════════════════════════════ */
function _detailHTML(r) {
const s = STATUS[r.status];
const stages = (r.stages || []).map(st => {
const c = CHECKS[st.t];
const statusIcon = st.pass ? '<i class="ri-checkbox-circle-fill text-emerald-500"></i>'
: st.result === "warn" ? '<i class="ri-lightbulb-flash-fill text-amber-500"></i>'
: '<i class="ri-close-circle-fill text-red-500"></i>';
return `
<div class="flex gap-2.5">
<div class="shrink-0 flex flex-col items-center">
<div class="w-5 h-5 rounded-full bg-white border-2 border-slate-200 grid place-items-center font-mono text-[10px] text-slate-500">${st.n}</div>
</div>
<div class="flex-1 pb-2">
<div class="flex items-center gap-1.5">
${statusIcon}
<span class="inline-flex items-center gap-1 px-1.5 h-5 rounded text-[10.5px] ring-1 ring-inset ${c.tone}"><i class="${c.icon}"></i> ${st.t}</span>
</div>
${st.reason ? `<div class="text-[12px] text-slate-600 mt-0.5">${st.reason}</div>` : ""}
</div>
</div>`;
}).join("");
const fields = (r.fields || []).map(f => `
<div class="p-2.5 bg-slate-50 border border-slate-100 rounded-md">
<div class="flex items-center gap-1.5">
<span class="text-[12px] font-medium text-slate-800">${f.k}</span>
${f.v === null ? '<span class="text-[11px] text-red-600 ml-auto">缺失</span>'
: `<span class="ml-auto text-[10.5px] font-mono ${f.conf < .8 ? 'text-orange-600' : 'text-slate-400'}">${Math.round(f.conf*100)}%</span>`}
${f.p ? `<button data-page="${f.p}" class="jump-page w-5 h-5 grid place-items-center rounded hover:bg-brand-100 text-brand-600" title="跳转到第 ${f.p} 页"><i class="ri-focus-3-line text-[12px]"></i></button>` : ""}
</div>
${f.v !== null ? `<div class="text-[11.5px] text-slate-600 mt-1 line-clamp-2">${f.v}</div>` : ""}
${f.fallback ? `<div class="mt-1 flex items-center gap-1 text-[10.5px] text-orange-600"><i class="ri-refresh-line"></i> deep_retry → <code class="font-mono bg-white px-1 rounded">${f.fallback}</code></div>` : ""}
</div>`).join("");
const skippedBlock = r.status === "skipped" ? `
<section class="px-5 py-4 border-b border-slate-100">
<h3 class="text-[11px] font-medium text-slate-400 uppercase tracking-wider mb-2">跳过原因</h3>
<div class="p-3 bg-slate-50 border border-slate-200 rounded text-[12.5px]">
<div class="text-slate-700 mb-1">${r.skipReason}</div>
<div class="flex items-center gap-2 text-[11.5px]">
<span class="text-slate-500">条件:</span>
<code class="font-mono bg-white px-1 rounded border border-slate-200 text-slate-700">${r.skipExpr || ""}</code>
</div>
<div class="flex items-center gap-2 text-[11.5px] mt-0.5">
<span class="text-slate-500">当前值:</span>
<code class="font-mono bg-white px-1 rounded border border-slate-200 text-slate-700">${r.skipActual || ""}</code>
</div>
</div>
</section>` : "";
const pendingBlock = r.status === "pending" ? `
<section class="px-5 py-4 border-b border-slate-100">
<h3 class="text-[11px] font-medium text-slate-400 uppercase tracking-wider mb-2">修救状态</h3>
<div class="p-3 bg-orange-50 border border-orange-200 rounded text-[12.5px] text-orange-800 flex items-center gap-2">
<i class="ri-loader-4-line animate-spin"></i>
已提交 ${r.rescue || "修救队列"},完成后将自动更新置信度
</div>
</section>` : "";
const actionBar = r.status === "skipped" ? "" : `
<div class="shrink-0 border-t border-slate-200 p-3 bg-slate-50/60 space-y-2">
<div class="flex gap-2">
<button class="flex-1 h-8 rounded-md text-[12.5px] bg-emerald-500 text-white hover:bg-emerald-600 flex items-center justify-center gap-1"><i class="ri-check-line"></i> 确认通过</button>
<button class="flex-1 h-8 rounded-md text-[12.5px] bg-white border border-red-200 text-red-700 hover:bg-red-50 flex items-center justify-center gap-1"><i class="ri-close-line"></i> 确认不通过</button>
</div>
<div class="flex items-center gap-2">
<button class="flex-1 h-7 rounded text-[11.5px] text-slate-600 hover:bg-slate-200 flex items-center justify-center gap-1"><i class="ri-chat-3-line"></i> 留言</button>
<button class="flex-1 h-7 rounded text-[11.5px] text-slate-600 hover:bg-slate-200 flex items-center justify-center gap-1"><i class="ri-code-s-slash-line"></i> 查看 DSL</button>
</div>
<div class="flex items-center gap-1 justify-center pt-1">
<button id="prev-unhandled" class="h-7 px-2 rounded text-[11px] text-slate-500 hover:bg-slate-200 flex items-center gap-1"><i class="ri-arrow-up-line"></i> 上条未处理</button>
<button id="next-unhandled" class="h-7 px-2 rounded text-[11px] text-slate-500 hover:bg-slate-200 flex items-center gap-1"><i class="ri-arrow-down-line"></i> 下条未处理</button>
</div>
</div>`;
// A 版:保留基本信息 + 步骤展开,次要区折叠
const suggestion = r.suggestion?.length ? `
<details class="border-b border-slate-100">
<summary class="px-5 py-3 flex items-center justify-between hover:bg-slate-50">
<span class="text-[11px] font-medium text-fuchsia-700 uppercase tracking-wider flex items-center gap-1"><i class="ri-sparkling-2-fill"></i> AI 修正建议 <span class="font-mono normal-case text-[10.5px] text-fuchsia-500">${r.suggestion.length}</span></span>
<i class="chev ri-arrow-down-s-line text-slate-400"></i>
</summary>
<ul class="px-5 pb-4 space-y-1.5">
${r.suggestion.map(s => `<li class="flex gap-2 text-[12.5px] text-slate-700"><span class="text-fuchsia-400">•</span><span>${s}</span></li>`).join("")}
</ul>
</details>` : "";
const strengths = r.strengths?.length ? `
<details class="border-b border-slate-100">
<summary class="px-5 py-3 flex items-center justify-between hover:bg-slate-50">
<span class="text-[11px] font-medium text-emerald-700 uppercase tracking-wider flex items-center gap-1"><i class="ri-medal-line"></i> 亮点 <span class="font-mono normal-case text-[10.5px] text-emerald-500">${r.strengths.length}</span></span>
<i class="chev ri-arrow-down-s-line text-slate-400"></i>
</summary>
<ul class="px-5 pb-4 space-y-1.5">
${r.strengths.map(s => `<li class="flex gap-2 text-[12.5px] text-slate-600"><i class="ri-check-line text-emerald-500"></i><span>${s}</span></li>`).join("")}
</ul>
</details>` : "";
return `
<!-- Hero -->
<div class="shrink-0 px-5 py-4 border-b border-slate-200">
<div class="flex items-start justify-between gap-2 mb-2">
<div class="min-w-0">
<div class="flex items-center gap-1.5 mb-1">
<span class="font-mono text-[11px] px-1.5 py-0.5 rounded bg-slate-100 text-slate-600">${r.id}</span>
<span class="inline-flex items-center gap-1 px-1.5 h-5 rounded text-[10.5px] border ${s.chipCls}">
<i class="${s.icon}"></i> ${s.label}
</span>
<span class="inline-flex items-center gap-1 px-1.5 h-5 rounded text-[10.5px] border ${RISK[r.risk].cls}">
<span class="w-1.5 h-1.5 rounded-full ${RISK[r.risk].dot}"></span> ${RISK[r.risk].label}风险
</span>
</div>
<h2 class="text-[15px] font-semibold text-slate-900">${r.name}</h2>
</div>
${r.page ? `<button data-page="${r.page}" class="jump-page shrink-0 h-7 px-2 rounded border border-slate-200 text-[11px] text-slate-600 hover:bg-slate-50 flex items-center gap-1">
<i class="ri-map-pin-line"></i> 第 ${r.page}
</button>` : ''}
</div>
<div class="grid grid-cols-2 gap-2 text-[11.5px]">
<div class="p-2 bg-slate-50 rounded">
<div class="text-slate-400">分值</div>
<div class="font-mono ${r.sGet === r.sMax ? 'text-emerald-600' : r.status==='skipped' ? 'text-slate-400' : 'text-amber-600'}">
${r.status === "skipped" ? `—/${r.sMax}` : `${r.sGet}/${r.sMax}`}
</div>
</div>
<div class="p-2 bg-slate-50 rounded">
<div class="text-slate-400">置信度</div>
<div class="font-mono ${r.conf < 0.8 ? 'text-orange-600' : 'text-slate-700'}">${Math.round(r.conf*100)}%</div>
</div>
</div>
</div>
<!-- scroll body -->
<div class="flex-1 min-h-0 overflow-y-auto scroll-slim">
${skippedBlock}
${pendingBlock}
${r.stages?.length ? `
<section class="px-5 py-4 border-b border-slate-100">
<h3 class="text-[11px] font-medium text-slate-400 uppercase tracking-wider mb-3">评查步骤</h3>
<div class="space-y-0.5">${stages}</div>
</section>` : ""}
${r.fields?.length ? `
<details class="border-b border-slate-100">
<summary class="px-5 py-3 flex items-center justify-between hover:bg-slate-50">
<span class="text-[11px] font-medium text-slate-400 uppercase tracking-wider">涉及字段 <span class="font-mono normal-case text-[10.5px]">${r.fields.length}</span></span>
<i class="chev ri-arrow-down-s-line text-slate-400"></i>
</summary>
<div class="px-5 pb-4 space-y-2">${fields}</div>
</details>` : ""}
${suggestion}
${strengths}
${r.msg ? `
<section class="px-5 py-4 border-b border-slate-100">
<div class="flex items-start gap-2 p-2 bg-emerald-50 border border-emerald-100 rounded text-[11.5px] text-emerald-800 mb-1.5">
<i class="ri-checkbox-circle-fill text-emerald-500 mt-0.5"></i>${r.msg.pass || "—"}
</div>
${r.msg.fail ? `<div class="flex items-start gap-2 p-2 bg-red-50 border border-red-100 rounded text-[11.5px] text-red-800">
<i class="ri-close-circle-fill text-red-500 mt-0.5"></i>${r.msg.fail}
</div>` : ""}
</section>` : ""}
<details>
<summary class="px-5 py-3 flex items-center justify-between hover:bg-slate-50">
<span class="text-[11px] font-medium text-slate-400 uppercase tracking-wider">元信息</span>
<i class="chev ri-arrow-down-s-line text-slate-400"></i>
</summary>
<div class="px-5 pb-4 grid grid-cols-2 gap-y-1 text-[11.5px]">
<div class="text-slate-500">rule_id</div><div class="font-mono text-slate-700">${r.id}</div>
<div class="text-slate-500">分类</div><div class="text-slate-700">${r.cat}</div>
<div class="text-slate-500">阶段</div><div class="text-slate-700">executed</div>
<div class="text-slate-500">stage 数</div><div class="font-mono text-slate-700">${r.stages?.length || 1}</div>
</div>
</details>
</div>
${actionBar}`;
}
let rightTab = "result";
function renderDetail() {
const host = document.getElementById("detail");
if (rightTab === "result") {
const r = RULES.find(x => x.id === selectedId);
host.innerHTML = r
? `<div class="flex-1 min-h-0 overflow-y-auto scroll-slim p-3 bg-slate-50/60">${renderCard(r)}</div>`
: '<div class="p-10 text-center text-slate-400 text-[13px]">未选中规则</div>';
} else if (rightTab === "fields") {
host.innerHTML = renderFieldsPanel();
} else if (rightTab === "info") {
host.innerHTML = renderInfoPanel();
} else if (rightTab === "log") {
host.innerHTML = renderLogPanel();
}
// 更新 tab 激活态
document.querySelectorAll("#detail-tabs .rtab").forEach(b => {
const active = b.dataset.rtab === rightTab;
b.classList.toggle("text-slate-900", active);
b.classList.toggle("font-medium", active);
b.classList.toggle("border-b-2", active);
b.classList.toggle("border-brand-500", active);
b.classList.toggle("-mb-[1px]", active);
b.classList.toggle("text-slate-500", !active);
b.classList.toggle("hover:text-slate-800", !active);
});
}
/* 抽取字段面板 */
function renderFieldsPanel() {
// 汇总所有 fields(去重按字段名)
const all = [];
const seen = new Set();
RULES.forEach(r => (r.fields || []).forEach(f => {
if (!seen.has(f.k)) { seen.add(f.k); all.push(f); }
}));
const rows = all.map(f => {
const confCls = f.conf < 0.8 ? 'text-orange-600' : 'text-slate-500';
return `
<div class="flex items-center gap-2 px-3 py-2 hover:bg-slate-50 border-b border-slate-100 text-[12px]">
<div class="flex-1 min-w-0">
<div class="font-medium text-slate-800 truncate">${f.k}</div>
${f.v === null
? '<div class="text-[11px] text-red-500 mt-0.5">缺失</div>'
: `<div class="text-[11px] text-slate-500 truncate mt-0.5">${f.v}</div>`}
</div>
<div class="shrink-0 text-right">
<div class="font-mono text-[10.5px] ${confCls}">${Math.round(f.conf*100)}%</div>
${f.p ? `<button class="jump-page mt-0.5 text-[10px] text-brand-600 hover:underline" data-page="${f.p}">p.${f.p}</button>` : ''}
</div>
</div>`;
}).join("");
return `
<div class="shrink-0 px-4 py-3 border-b border-slate-100">
<div class="flex items-baseline justify-between">
<h3 class="text-[14px] font-semibold text-slate-900">抽取字段 <span class="font-mono text-[11px] text-slate-400">${all.length}</span></h3>
<div class="text-[11px] text-slate-400">置信度 · 锚定页</div>
</div>
<div class="mt-2 flex gap-1 text-[10.5px]">
<button class="px-1.5 h-5 rounded bg-slate-900 text-white">全部</button>
<button class="px-1.5 h-5 rounded border border-slate-200 text-slate-500">有值</button>
<button class="px-1.5 h-5 rounded border border-red-200 text-red-700">缺失</button>
<button class="px-1.5 h-5 rounded border border-orange-200 text-orange-700">低置信</button>
</div>
</div>
<div class="flex-1 overflow-y-auto scroll-slim">${rows}</div>`;
}
/* 文件信息面板 */
function renderInfoPanel() {
return `
<div class="p-5 overflow-y-auto scroll-slim text-[12.5px] space-y-5">
<section>
<h3 class="text-[11px] font-medium text-slate-400 uppercase tracking-wider mb-2">基本属性</h3>
<div class="grid grid-cols-[90px,1fr] gap-y-1.5">
<div class="text-slate-500">文件名</div><div class="text-slate-800 break-all">2026年XX设备采购合同.pdf</div>
<div class="text-slate-500">文档 ID</div><div class="font-mono text-[11px] text-slate-700">DOC-20260409-651041</div>
<div class="text-slate-500">大小</div><div class="text-slate-800">13.7 MB</div>
<div class="text-slate-500">页数</div><div class="text-slate-800">62</div>
<div class="text-slate-500">上传时间</div><div class="text-slate-800">2026-04-09 18:57</div>
<div class="text-slate-500">上传者</div><div class="text-slate-800">zwy</div>
</div>
</section>
<section>
<h3 class="text-[11px] font-medium text-slate-400 uppercase tracking-wider mb-2">规则集</h3>
<div class="grid grid-cols-[90px,1fr] gap-y-1.5">
<div class="text-slate-500">type_id</div><div class="font-mono text-[11px] text-brand-600">contract.sale</div>
<div class="text-slate-500">版本</div><div class="font-mono text-slate-700">v2.1</div>
<div class="text-slate-500">阶段</div><div class="text-slate-800">executed (已签章)</div>
<div class="text-slate-500">规则数</div><div class="text-slate-800">29</div>
<div class="text-slate-500">字段数</div><div class="text-slate-800">35</div>
</div>
</section>
<section>
<h3 class="text-[11px] font-medium text-slate-400 uppercase tracking-wider mb-2">OCR / 预处理</h3>
<div class="grid grid-cols-[90px,1fr] gap-y-1.5">
<div class="text-slate-500">OCR</div><div class="text-slate-800">Chandra v2.1</div>
<div class="text-slate-500">识别页数</div><div class="text-slate-800">62 / 62</div>
<div class="text-slate-500">识别耗时</div><div class="font-mono text-slate-700">41.3 s</div>
<div class="text-slate-500">分段数</div><div class="text-slate-800">128</div>
</div>
</section>
</div>`;
}
/* 运行日志面板 */
function renderLogPanel() {
const timeline = [
{ t:"00:00.0", ic:"ri-download-cloud-2-line", txt:"加载文档 + OCR 源文本(缓存命中)", dur:"0.1s" },
{ t:"00:00.1", ic:"ri-search-line", txt:"字段抽取 · case-file dispatcher", dur:"" },
{ t:"00:00.1", ic:"ri-arrow-right-s-line", txt:" 单文档抽取 (prompt batch × 6)", dur:"41.2s", sub:true },
{ t:"00:41.3", ic:"ri-arrow-right-s-line", txt:" 派生字段", dur:"0.3s", sub:true },
{ t:"00:41.6", ic:"ri-arrow-right-s-line", txt:" 多实体处理", dur:"2.1s", sub:true },
{ t:"00:43.7", ic:"ri-arrow-right-s-line", txt:" grounding 锚定", dur:"14.5s", sub:true },
{ t:"00:58.2", ic:"ri-flag-line", txt:"phase 判定 → executed", dur:"0.2s" },
{ t:"00:58.4", ic:"ri-scales-2-line", txt:"规则评查 (29 条)", dur:"" },
{ t:"00:58.5", ic:"ri-arrow-right-s-line", txt:" 完整性 11 条 ✓×11", dur:"6.2s", sub:true },
{ t:"01:04.7", ic:"ri-arrow-right-s-line", txt:" 规范性 2 条 ✓×2", dur:"1.1s", sub:true },
{ t:"01:05.8", ic:"ri-arrow-right-s-line", txt:" 合理性 3 条 ✓×3", dur:"2.8s", sub:true },
{ t:"01:08.6", ic:"ri-arrow-right-s-line", txt:" 买卖专项 AI 7 条", dur:"34.5s", sub:true, warn:true },
{ t:"01:43.1", ic:"ri-arrow-right-s-line", txt:" 合规 AI 4 条", dur:"19.8s", sub:true, warn:true },
{ t:"02:02.9", ic:"ri-arrow-right-s-line", txt:" 银行信息 1 条 ✕", dur:"0.4s", sub:true, fail:true },
{ t:"02:03.3", ic:"ri-refresh-line", txt:"触发修救 · 签约日期 (L1 LLM 重判)", dur:"排队 #3" },
{ t:"02:04.8", ic:"ri-check-line", txt:"完成", dur:"总计 124.8s" },
];
const rows = timeline.map(s => `
<div class="flex items-start gap-2 py-1 px-3 hover:bg-slate-50 ${s.sub?'pl-6':''}">
<span class="font-mono text-[10.5px] text-slate-400 shrink-0 w-14">${s.t}</span>
<i class="${s.ic} text-[13px] ${s.fail?'text-red-500':s.warn?'text-amber-500':'text-slate-400'} shrink-0 mt-[1px]"></i>
<span class="flex-1 min-w-0 text-[11.5px] ${s.sub?'text-slate-500':'text-slate-700 font-medium'} truncate">${s.txt}</span>
<span class="font-mono text-[10.5px] text-slate-400 shrink-0">${s.dur}</span>
</div>`).join("");
return `
<div class="shrink-0 px-4 py-3 border-b border-slate-100 flex items-center justify-between">
<h3 class="text-[14px] font-semibold text-slate-900">运行日志</h3>
<span class="font-mono text-[11px] text-slate-400">124.8s 总</span>
</div>
<div class="flex-1 overflow-y-auto scroll-slim py-1">${rows}</div>`;
}
/* ═══════════════════════════════════════════════════════════════════ */
/* Page content (mock 5 代表性合同页) */
/* ═══════════════════════════════════════════════════════════════════ */
const PAGES = {
1: {
title: "XX公司设备采购合同",
sub: "合同编号:DOC-20260409-651041 · 签订地点:杭州",
blocks: [
{ type:"h", text:"一、合同主体" },
{ type:"gd", field:"甲方", text:"甲方(买方):XX信息科技有限公司" },
{ type:"para", text:"法定代表人:张三 联系人:李四 联系电话:0571-8888XXXX" },
{ type:"para", text:"住所:浙江省杭州市西湖区文三路 XXX 号(统一社会信用代码详见第 60 页)" },
{ type:"gd", field:"乙方", text:"乙方(卖方):某设备制造有限公司" },
{ type:"para", text:"法定代表人:王五 联系人:赵六 联系电话:021-6688XXXX" },
{ type:"para", text:"住所:上海市浦东新区张江路 XXX 号(统一社会信用代码详见第 60 页)" },
{ type:"h", text:"二、合同订立依据" },
{ type:"para", text:"根据《中华人民共和国民法典》合同编及相关法律、行政法规的规定,甲乙双方本着平等、自愿、诚实、信用的原则,经友好协商,就甲方向乙方采购设备及相关服务事宜,达成如下协议,共同遵守执行。本合同附件与本合同具有同等法律效力。" },
{ type:"h", text:"三、合同结构说明" },
{ type:"para", text:"本合同由正文、附件一「标的清单明细」、附件二「技术规格书」、附件三「服务与质保约定」、附件四「双方签署页」等共五部分组成,共计 62 页。甲乙双方应按照本合同正文及各附件的约定,全面、及时、准确地履行各自义务。" },
],
},
3: {
title: "第一条 · 合同标的与金额",
blocks: [
{ type:"para", text:"第一款 合同标的。乙方按本合同约定向甲方提供如下设备与服务:" },
{ type:"gd", field:"合同标的描述", text:"供应 ERP 管理系统 1 套(包含软件授权、服务器硬件、部署实施、运维培训)。具体型号、数量、单价及配置详见附件一「标的清单明细」。" },
{ type:"para", text:"第二款 交付方式。乙方应按本合同第三条「交货期限」及第四条「交货地点」的约定,按时向甲方交付上述全部标的,并提供必要的随附技术文档。" },
{ type:"para", text:"第三款 合同金额。本合同所有金额均以人民币结算,税率按 13% 计入总价:" },
{ type:"gd", field:"合同金额", text:"合同总金额(小写):500,000.00 元" },
{ type:"gd", field:"合同金额大写", text:"合同总金额(大写):人民币 伍拾万元整" },
{ type:"para", text:"第四款 金额一致性。附件一「标的清单明细」中各项单价乘以数量得出的合计金额,应当严格等于本条第三款所述合同总金额;如数值存在不一致之处,以本条第三款所示大写金额为准。" },
{ type:"para", text:"第五款 金额变更。本合同生效后,非经甲乙双方书面协商一致,不得擅自变更合同总金额或任何单项金额。任何变更应以书面补充协议的形式予以明确。" },
{ type:"h", text:"第二条 · 税费与发票" },
{ type:"para", text:"乙方应按国家税法规定,在每笔款项收讫后 5 个工作日内向甲方开具增值税专用发票,发票金额与实际收款金额保持一致。除本合同另有约定外,各自承担合同履行过程中所产生的税费。" },
],
},
15: {
title: "第三条 · 付款方式与周期",
blocks: [
{ type:"para", text:"甲乙双方经友好协商,确定本合同项下款项按以下方式和比例分期支付:" },
{ type:"gd", field:"付款方式", text:"第一款 预付款:甲方应在本合同签订后 5 个工作日内,向乙方支付合同总金额的 30%(即人民币 150,000.00 元整)作为预付款。第二款 进度款:验收合格后 10 个工作日内,甲方向乙方支付合同总金额的 60%(即人民币 300,000.00 元整)。第三款 尾款:质保期满且无任何质量问题的情况下,甲方在质保期满后 30 日内向乙方支付剩余 10%(即人民币 50,000.00 元整)。" },
{ type:"para", text:"第四款 付款方式。上述款项均通过银行转账方式支付至乙方指定的银行账户;乙方的收款银行信息详见本合同第十条「签署与银行信息」。" },
{ type:"para", text:"第五款 付款前置条件。每次付款前,乙方应先行向甲方提供合法、足额、真实的增值税专用发票及对应的交付/验收凭证。未开具发票或发票内容与款项不符的,甲方有权暂缓支付,延期期间不构成甲方违约。" },
{ type:"h", text:"第四条 · 迟延付款违约" },
{ type:"para", text:"甲方迟延支付任何一期款项的,每迟延一日,甲方应按迟延支付金额的万分之三向乙方支付违约金;累计迟延超过 30 日的,乙方有权解除本合同,并要求甲方承担因此给乙方造成的一切损失,包括但不限于已支出的成本、可得利益、维权费用等。" },
{ type:"para", text:"迟延付款违约金的支付不免除甲方继续履行付款义务的责任;乙方亦有权要求甲方同时支付违约金与本金。" },
],
},
22: {
title: "第五条 · 验收与质保",
blocks: [
{ type:"para", text:"第一款 乙方应按本合同约定向甲方交付符合合同约定的设备及相关文档资料。交付完成后,甲方应于 10 日内组织验收。" },
{ type:"gd", field:"验收条款", text:"第二款 乙方应于交付后 10 日内提交验收报告,甲方应在收到验收报告后 5 个工作日内组织初验;初验通过后,双方进入终验阶段。验收合格后,乙方应交付相关技术文档及培训材料。" },
{ type:"para", text:"第三款 若验收不合格,乙方应在收到书面通知后 15 日内整改并重新提交验收;整改后仍不合格的,甲方有权解除本合同并要求乙方承担因此造成的损失。" },
{ type:"para", text:"第四款 设备验收合格之日起计算质保期;质保期为自验收合格之日起连续 12 个月。" },
{ type:"h", text:"第六条 · 质保服务" },
{ type:"gd", field:"质保期条款", text:"第一款 质保期内,乙方对因设计、制造、材料等原因引起的故障负责免费维修或更换;因甲方不当使用、自然灾害、战争、不可抗力等事件造成的损坏除外。" },
{ type:"para", text:"第二款 响应时限。乙方应在接到甲方报修后 4 小时内响应,24 小时内到达现场,48 小时内修复故障或提供等效的替代方案。" },
{ type:"para", text:"第三款 备件保障。乙方应保证本合同所涉设备的关键备件供应不少于 5 年。质保期满后,乙方可提供有偿延保服务,具体价格与条件双方届时另行协商确定。" },
{ type:"para", text:"第四款 服务记录。每次维修或服务完成后,乙方应向甲方提供书面服务记录,双方确认签字后存档备查。" },
],
},
58: {
title: "第十条 · 签署与银行信息",
blocks: [
{ type:"h", text:"一、合同签署" },
{ type:"gd", field:"签约日期", text:"本合同一式两份,甲乙双方各执一份,自甲乙双方法定代表人或授权代表签字并加盖公章之日起生效。签约日期:2025 年 1 月 7 日。" },
{ type:"para", text:"甲方(盖章):XX信息科技有限公司      经办人签字:___________" },
{ type:"para", text:"乙方(盖章):某设备制造有限公司      经办人签字:___________" },
{ type:"h", text:"二、乙方收款银行信息" },
{ type:"gd", field:"收款方开户银行", text:"开户行:—(注:本合同中未明示乙方开户行名称,建议补充)" },
{ type:"gd", field:"收款方银行账号", text:"收款账号:6225 **** **** 1234" },
{ type:"para", text:"户名:某设备制造有限公司  行号(联行号):3010XXXXXXX" },
{ type:"para", text:"(以上收款信息如有变更,乙方应提前 10 个工作日以书面形式通知甲方,否则因信息错误导致的付款失败或款项损失由乙方自行承担。)" },
{ type:"h", text:"三、附则" },
{ type:"para", text:"本合同未尽事宜,由双方另行协商签订补充协议;补充协议与本合同具有同等法律效力。本合同履行过程中发生的争议,双方应先行友好协商解决;协商不成的,任何一方均有权向合同签订地人民法院提起诉讼。" },
],
},
};
function renderPage(pageNum) {
pageNum = Number(pageNum) || 1;
const page = PAGES[pageNum];
const curRule = RULES.find(r => r.id === selectedId);
const relevantFields = new Set((curRule?.fields || []).filter(f => f.p === pageNum).map(f => f.k));
const canvas = document.getElementById("page-canvas");
if (!page) {
canvas.innerHTML = `
<div class="text-center text-slate-400 text-[11px] mb-6">— 第 ${pageNum} 页 · 共 62 页 —</div>
<div class="grid place-items-center h-[520px] text-slate-300">
<div class="text-center max-w-xs">
<i class="ri-file-text-line text-6xl"></i>
<div class="mt-4 text-[13px] text-slate-500">本页内容略(示例文档只渲染 5 个代表性页面:1 / 3 / 15 / 22 / 58</div>
<div class="mt-1 text-[11.5px]">正式环境会以 PDF.js 或图片形式渲染真实原文</div>
</div>
</div>
<div class="text-center text-slate-300 text-[11px] mt-10 pt-4 border-t border-slate-100">— 第 ${pageNum} 页 —</div>`;
return;
}
const body = page.blocks.map(b => {
if (b.type === "h") return `<h3 class="text-[15.5px] font-semibold text-slate-800 mt-7 mb-3">${b.text}</h3>`;
if (b.type === "para") return `<p class="text-[13.5px] text-slate-700 leading-[1.95] mb-3 indent-7">${b.text}</p>`;
if (b.type === "gd") {
const active = relevantFields.has(b.field);
const frame = active
? 'hl-pulse border-2 border-amber-400 bg-amber-50/70'
: 'border border-dashed border-slate-300 bg-slate-50/50';
const badge = active
? `<span class="absolute -top-2.5 left-3 px-1.5 py-0.5 bg-amber-400 text-white text-[10px] rounded font-medium flex items-center gap-1 shadow"><i class="ri-price-tag-3-line"></i> ${b.field} · ${curRule.id}</span>`
: `<span class="absolute -top-2 left-3 px-1.5 py-0 bg-slate-200 text-slate-500 text-[10px] rounded font-medium">${b.field}</span>`;
return `<div class="relative ${frame} rounded-md px-3 py-2.5 my-4">
${badge}
<p class="text-[13.5px] text-slate-800 leading-[1.95] indent-7">${b.text}</p>
</div>`;
}
return '';
}).join("");
canvas.innerHTML = `
<div class="text-center text-slate-400 text-[11px] mb-1">— 第 ${pageNum} 页 · 共 62 页 —</div>
<h2 class="text-[20px] font-semibold text-slate-900 text-center mb-1">${page.title}</h2>
${page.sub ? `<div class="text-center text-[11.5px] text-slate-500 mb-6">${page.sub}</div>` : '<div class="mb-6"></div>'}
${body}
<div class="text-center text-slate-300 text-[11px] mt-14 pt-4 border-t border-slate-100">— 第 ${pageNum} 页 / 共 62 页 —</div>`;
}
/* ═══════════════════════════════════════════════════════════════════ */
/* Thumbnail panel */
/* ═══════════════════════════════════════════════════════════════════ */
const STATUS_BADGE = {
fail: { cls:"bg-red-500", ic:"ri-close-circle-fill" },
warn: { cls:"bg-amber-500", ic:"ri-lightbulb-flash-fill" },
pending: { cls:"bg-orange-500", ic:"ri-question-fill" },
pass: { cls:"bg-emerald-500", ic:"ri-checkbox-circle-fill" },
};
const STATUS_ORDER = { fail:0, warn:1, pending:2, pass:3 };
function computePageStatus() {
// 每页 => 最坏状态 + 问题数(非 pass 的规则)+ 每页涉及的字段列表(来自所有规则)
const m = new Map();
RULES.forEach(r => {
if (!r.page || r.status === "skipped") return;
const cur = m.get(r.page) || { worst:"pass", count:0, issues:0 };
if (STATUS_ORDER[r.status] < STATUS_ORDER[cur.worst]) cur.worst = r.status;
cur.count++;
if (r.status !== "pass") cur.issues++;
m.set(r.page, cur);
});
return m;
}
// 取某条规则涉及的页集(主 page + 所有字段的 p),去重排序
function getRulePages(rule) {
const s = new Set();
if (rule?.page) s.add(rule.page);
(rule?.fields || []).forEach(f => f.p && s.add(f.p));
return [...s].sort((a,b) => a-b);
}
// 取某条规则在某页涉及的字段列表
function getFieldsOnPage(rule, page) {
return (rule?.fields || []).filter(f => f.p === page);
}
// 当前缩略图模式:filtered=只看相关 / all=全部
let thumbMode = "filtered";
function renderThumbnails() {
const pageStatus = computePageStatus();
const curRule = RULES.find(r => r.id === selectedId);
const currentPage = curRule?.page || 1;
const rulePages = curRule ? getRulePages(curRule) : [];
const hasPages = rulePages.length > 0;
// 无 pages 时强制 all
const effMode = hasPages ? thumbMode : "all";
// 更新模式切换条的文字 / 激活态
const subj = document.getElementById("thumbs-subject-txt");
if (subj) {
subj.textContent = curRule
? (hasPages ? `${curRule.id} 涉及 ${rulePages.length}` : `${curRule.id} 无关联页`)
: "点击规则定位页面";
}
const btnF = document.getElementById("btn-mode-filter");
const btnA = document.getElementById("btn-mode-all");
if (btnF && btnA) {
const setActive = (btn, active) => {
btn.className = `mode-btn flex-1 h-5 rounded font-medium ${active
? 'bg-white shadow-sm text-brand-700'
: 'text-slate-500 hover:text-slate-700'}`;
};
setActive(btnF, effMode === "filtered");
setActive(btnA, effMode === "all");
// 若无 pages 则 filter 按钮禁用
btnF.disabled = !hasPages;
btnF.classList.toggle("opacity-40", !hasPages);
btnF.classList.toggle("cursor-not-allowed", !hasPages);
}
const host = document.getElementById("thumbs-panel");
const lines = [70, 80, 65, 90, 72, 60, 85, 78, 55, 88, 68, 75];
const pages = effMode === "filtered"
? rulePages
: Array.from({ length: 62 }, (_, i) => i + 1);
host.innerHTML = pages.map(p => {
const info = pageStatus.get(p);
const isCur = p === currentPage;
const isRulePage = rulePages.includes(p);
let badge = "";
if (info) {
const b = STATUS_BADGE[info.worst];
const num = info.issues > 0 ? info.issues : (info.worst === "pass" ? "" : info.count);
badge = `<span class="absolute top-1 right-1 inline-flex items-center justify-center min-w-[14px] h-[14px] px-0.5 rounded-full text-[9px] font-semibold text-white ${b.cls} shadow ring-1 ring-white">
${num || '<i class="'+b.ic+' text-[9px]"></i>'}
</span>`;
}
const seed = p * 7 % 12;
const mockLines = Array.from({length:10}).map((_,k) => {
const w = lines[(seed+k) % lines.length];
return `<div class="h-[2px] bg-slate-200 rounded" style="width:${w}%"></div>`;
}).join("");
// filtered 模式下显示字段标签
let fieldsLabel = "";
if (effMode === "filtered" && curRule) {
const fs = getFieldsOnPage(curRule, p);
const txt = fs.length
? fs.map(f => f.k).join(" · ")
: (curRule.page === p ? "规则锚定页" : "");
if (txt) {
fieldsLabel = `<div class="text-[10px] leading-tight text-slate-500 text-center mt-0.5 line-clamp-2" title="${txt.replace(/"/g,'&quot;')}">${txt}</div>`;
}
}
// all 模式下:当前规则涉及的页面用粗边强调
const frameCls = isCur
? 'ring-2 ring-brand-500 shadow-md'
: (effMode === "all" && isRulePage)
? 'ring-1 ring-brand-300 shadow-sm'
: 'border border-slate-200 group-hover:border-slate-400 shadow-sm';
return `
<button data-page="${p}" class="jump-page thumb-item block w-full group">
<div class="relative rounded overflow-hidden bg-white transition ${frameCls}">
<div class="w-full h-[128px] bg-gradient-to-b from-white to-slate-50 p-1.5 flex flex-col gap-[3px]">
${mockLines}
</div>
${badge}
</div>
<div class="text-center text-[10.5px] mt-1 ${isCur ? 'text-brand-600 font-semibold' : 'text-slate-500 group-hover:text-slate-700'}">${p}</div>
${fieldsLabel}
</button>`;
}).join("") || `
<div class="text-center text-[11px] text-slate-400 py-8">
<i class="ri-forbid-2-line text-2xl text-slate-300"></i>
<div class="mt-1">此规则无关联页面</div>
</div>`;
// 滚动当前页到视口
const el = host.querySelector(`[data-page="${currentPage}"]`);
if (el) el.scrollIntoView({ block:"nearest", behavior:"smooth" });
}
// 模式切换按钮
document.addEventListener("click", e => {
const b = e.target.closest(".mode-btn");
if (!b || b.disabled) return;
thumbMode = b.dataset.mode;
renderThumbnails();
});
// 右栏 Tab 切换
// C 方案无 tab,跳过
// 切换缩略图面板显示/隐藏
document.getElementById("btn-thumbs").addEventListener("click", () => {
const wrap = document.getElementById("thumbs-wrap");
wrap.classList.toggle("hidden");
document.getElementById("btn-thumbs").classList.toggle("text-brand-600");
document.getElementById("btn-thumbs").classList.toggle("text-slate-400");
});
/* ═══════════════════════════════════════════════════════════════════ */
/* Events */
/* ═══════════════════════════════════════════════════════════════════ */
document.getElementById("rule-list").addEventListener("click", e => {
const cat = e.target.closest("button[data-cat]");
if (cat) {
openCats.has(cat.dataset.cat) ? openCats.delete(cat.dataset.cat) : openCats.add(cat.dataset.cat);
renderRuleList();
return;
}
const item = e.target.closest("button[data-id]");
if (item) {
selectedId = item.dataset.id;
thumbMode = "filtered";
rightTab = "result"; // 切规则时回到"评查结果"tab
renderRuleList(); renderDetail(); updateCurrentGrounding(); renderThumbnails();
}
});
// 搜索
document.getElementById("rule-search").addEventListener("input", e => {
ruleSearchQuery = e.target.value.trim();
renderRuleList();
});
// 右栏 Tab 切换
document.getElementById("detail-tabs").addEventListener("click", e => {
const b = e.target.closest(".rtab");
if (!b) return;
rightTab = b.dataset.rtab;
renderDetail();
});
// 跳转页面:渲染对应页内容 + 更新页码显示
document.addEventListener("click", e => {
const b = e.target.closest(".jump-page");
if (!b) return;
const p = Number(b.dataset.page);
document.getElementById("page-info").innerHTML = `${p} <span class="text-slate-400">/ 62</span>`;
renderPage(p);
});
// (card detail — single card, no list interaction needed)
function updateCurrentGrounding() {
const r = RULES.find(x => x.id === selectedId);
const info = document.getElementById("current-gd");
const pageInfo = document.getElementById("page-info");
if (r && r.page) {
info.innerHTML = `<i class="ri-focus-3-line"></i> ${r.id} · ${r.fields?.[0]?.k || r.name}`;
pageInfo.innerHTML = `${r.page} <span class="text-slate-400">/ 62</span>`;
renderPage(r.page);
} else {
info.innerHTML = `<i class="ri-forbid-2-line"></i> 无锚定信息`;
}
}
/* boot */
renderRuleList();
renderDetail();
renderThumbnails();
renderPage(RULES.find(r => r.id === selectedId)?.page || 22);
</script>
</body>
</html>