Files
bid-doctor/docs/design/ui-demo/lists.html
YANG JIANKUAN 7f9592ad16 docs(design): 按 v0.2 重刷全部 UI demo,确认 Q1–Q13 与 U 键规则
- ui-demo:控件、表单、卡片、列表、工作台、读标六页按 v0.2 重刷;侧栏与顶栏框架、项目行、署名行、可键入日期输入、对照布局等收回 demo.css / demo.js 共用
- system:tokens 补悬停 / 按下 / 遮罩色、数字字阶与原文排版变量;components.css 按 v0.2 改写并清除裸色值
- design-system.md:写入 Q1–Q13 答案,新增开关、分页、下拉、日期选择等组件规则,类别标签、待确认区、选中态、对照布局按 v0.2 修订
- Q13:额度流水记录 0 点的「免扣」行,FR-M6-03 与领域模型同步;U 键改为撤销当前条
- decisions.md 记录 Q1–Q12、Q13 与 U 键;AGENTS.md 同步进度

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:07:15 +08:00

511 lines
46 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>
<!--
陈列页 ④ 列表与表格(v0.2)
项目列表的行和 page-workbench.html 共用 demo.css 的 B20;当前条 / 勾选条、行内说明(U4)、署名行都用共用类。
本页 <style> 只放:卡片头里的筛选控件尺寸、清单和问题列表与卡片头的间隔、流水的退回对应行。
-->
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>列表与表格 · 标博士 UI demo</title>
<link rel="icon" href="../logo/favicon.svg">
<link rel="stylesheet" href="../system/tokens.css">
<link rel="stylesheet" href="../system/fonts-local.css">
<link rel="stylesheet" href="../system/components.css">
<link rel="stylesheet" href="demo.css">
<style>
/* 卡片头里的筛选控件:统一 32 高(与筛选按钮一致) */
.card-h .seg + .pop,.card-h .seg + .choice{margin-left:var(--sp-2)}
.card-h .pop .select{width:auto;min-width:120px;height:var(--control-h-sm)}
.card-h .r{margin-left:auto;display:flex;gap:var(--sp-3);align-items:center}
.card-h .search{width:240px}
.card-h .search .input{height:var(--control-h-sm)}
.card-h .search > .ic{top:8px}
.card-h .choice{align-items:center;white-space:nowrap}
.card-h .choice .ck{margin-top:0}
.card > .filters-on{margin:0;padding:0 var(--card-x) var(--sp-3)}
/* 02 清单、03 问题:列表放进卡片,第一条也要和卡片头隔开 */
.clist,.flist{outline:0}
.clist .lb-h{display:flex;justify-content:space-between;padding:0 var(--card-x) var(--sp-2);font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary)}
.clist .item{padding:var(--sp-3) var(--card-x)}
.clist .item:first-of-type,.flist .finding:first-of-type{border-top:var(--line) solid var(--border-subtle)}
.clist .item:last-child{border-radius:0 0 var(--radius-card) var(--radius-card)}
.clist .item .side{display:flex;flex-direction:column;align-items:flex-end;gap:var(--sp-1);white-space:nowrap}
.clist .empty{border-top:var(--line) solid var(--border-subtle)}
.flist > .empty{border-top:var(--line) solid var(--border-subtle)}
/* 04 额度流水 */
.flow-sub td{border-top:0}
.fl-link{display:inline-block;width:14px;color:var(--text-secondary)}
.card-f .pagination{flex:1;margin:0}
/* 05 参数对照表:点一行 = 当前条(§7.18),在原文查看器里两边对照 */
table.param td:nth-child(4),table.param td:nth-child(5){white-space:nowrap}
</style>
</head>
<body class="demo">
<header data-demo-top></header>
<main class="d-page">
<div class="d-head">
<h1>④ 列表与表格</h1>
<p>列表和表格都放在白卡片里;筛选在卡片头,表头只有 12px 辅助灰列名,行间发丝线,悬停浅灰。演示筛选、排序、键盘逐条处理、批量勾选、分页和空结果。</p>
<nav class="d-toc" aria-label="本页目录"><a href="#s-proj">01 项目列表</a><a href="#s-list">02 清单列表</a><a href="#s-find">03 问题列表</a><a href="#s-flow">04 额度流水</a><a href="#s-param">05 参数对照表</a><a href="#s-empty">06 空结果</a></nav>
</div>
<!-- 01 项目列表 -->
<section class="d-sec" id="s-proj">
<div class="d-sec-h"><span class="no">01</span><h2>项目列表</h2><span class="ref">§7.9 · 行与工作台一致</span></div>
<section class="card flush" aria-labelledby="h-proj">
<div class="card-h">
<h3 id="h-proj">项目</h3>
<div class="seg" role="group" aria-label="按状态筛选" data-pseg><button aria-pressed="true" data-v="active">进行中 <span class="fig">4</span></button><button aria-pressed="false" data-v="all">全部 <span class="fig">6</span></button><button aria-pressed="false" data-v="已递交">已递交</button><button aria-pressed="false" data-v="已归档">已归档</button></div>
<div class="pop inline" data-select data-plot><button type="button" class="select" aria-haspopup="listbox" aria-expanded="false" aria-label="法律体系"><span class="val">所有体系</span><i data-ic="chev-down"></i></button>
<ul class="menu" role="listbox"><li role="option" aria-selected="true" data-value="">所有体系</li><li role="option" aria-selected="false" data-value="政府采购">政府采购</li><li role="option" aria-selected="false" data-value="招标投标">招标投标</li></ul></div>
<div class="r"><label class="search"><i data-ic="search"></i><input class="input" placeholder="搜索项目、采购人" aria-label="搜索项目" data-psearch></label></div>
</div>
<div class="prow-head" data-pcols>
<span><button class="srt" data-sort="name">项目</button></span>
<span><button class="srt" data-sort="h" data-dir="asc">投标截止</button></span>
<span>状态</span>
<span class="c"><button class="srt" data-sort="fatal">致命</button></span>
<span>下一步</span><span></span>
</div>
<ul class="prows" data-prows aria-label="项目列表"></ul>
</section>
</section>
<!-- 02 清单列表 -->
<section class="d-sec" id="s-list">
<div class="d-sec-h"><span class="no">02</span><h2>清单列表</h2><span class="ref">§7.5 · §7.18 · 点列表后按 J / K 移动</span></div>
<section class="card flush" aria-label="项目检查清单">
<div class="card-h">
<div class="seg" role="group" aria-label="按分类筛选" data-cseg><button aria-pressed="true" data-v="">全部 <span class="fig">12</span></button><button aria-pressed="false" data-v="qual">资格条件</button><button aria-pressed="false" data-v="invalid">无效条款</button><button aria-pressed="false" data-v="param">技术参数</button><button aria-pressed="false" data-v="date">日期与递交</button></div>
<label class="choice"><input type="checkbox" class="ck" data-star>只看 ★</label>
<div class="r cap muted"><span><span class="kbd">J</span> <span class="kbd">K</span> 移动</span><span><span class="kbd">Enter</span> 打开出处</span></div>
</div>
<div class="filters-on" data-chips></div>
<div class="clist" data-clist tabindex="0" aria-label="清单,按 J K 移动,Enter 打开出处"></div>
</section>
</section>
<!-- 03 问题列表 -->
<section class="d-sec" id="s-find">
<div class="d-sec-h"><span class="no">03</span><h2>问题列表</h2><span class="ref">§7.4 · §7.18 · §8.2 · U4 · 只有「提示」可以批量处理</span></div>
<div class="batchbar" data-batch hidden><b>已选 <span class="fig" data-bn>0</span> 项「提示」</b><span class="r"><button class="btn sm" data-bok>批量确认无误</button><button class="btn sm text" data-bclear>取消选择</button></span></div>
<section class="results card flush" aria-label="检查结果">
<div class="card-h">
<div class="seg" role="group" aria-label="按级别筛选" data-fseg>
<button aria-pressed="true" data-v="">全部 <span class="fig" data-c=""></span></button>
<button aria-pressed="false" data-v="fatal"><span class="sev fatal">致命<span class="fig" data-c="fatal"></span></span></button>
<button aria-pressed="false" data-v="major"><span class="sev major">重要<span class="fig" data-c="major"></span></span></button>
<button aria-pressed="false" data-v="hint"><span class="sev hint">提示<span class="fig" data-c="hint"></span></span></button>
</div>
<div class="r"><label class="choice cap" data-allw><input type="checkbox" class="ck" data-fall>全选「提示」</label><button class="btn sm text" onclick="Demo.keysDialog(Demo.KEYS.check)">快捷键 <span class="kbd">?</span></button></div>
</div>
<div class="flist" data-flist tabindex="0" aria-label="问题列表,按 J K 移动,1 2 3 处理"></div>
</section>
<ul class="d-rules"><li>当前条:浅灰底 + 左侧墨色竖条,键盘操作时再加墨色内框;勾选只看复选框,不加底色。致命行不加红竖线,级别只看「致命」标签。</li><li>「忽略」和致命问题的「确认无误」在行内展开说明框,不弹对话框(U4)。</li></ul>
</section>
<!-- 04 额度流水 -->
<section class="d-sec" id="s-flow">
<div class="d-sec-h"><span class="no">04</span><h2>额度流水</h2><span class="ref">§7.10 · §7.20 · 红线 9 额度即货币</span></div>
<section class="card flush" aria-labelledby="h-flow">
<div class="card-h">
<h3 id="h-flow">本期流水</h3>
<div class="seg" role="group" aria-label="按额度筛选" data-qseg><button aria-pressed="true" data-v="">全部</button><button aria-pressed="false" data-v="解析">解析</button><button aria-pressed="false" data-v="检查">检查</button></div>
<div class="r"><span class="cap muted">2026-11-01 至 2027-10-31</span><button class="btn sm" data-csv><i data-ic="download"></i>导出 CSV</button></div>
</div>
<table class="data flow">
<thead><tr><th>时间</th><th>类型</th><th>额度</th><th class="r">数量</th><th class="r">余额</th><th>关联项目 · 任务</th><th>说明</th></tr></thead>
<tbody data-flow></tbody>
</table>
<div class="card-f" data-ffoot><div class="pagination" data-fpager></div></div>
</section>
<ul class="d-rules"><li>「+」和「−」都用墨色,只靠符号区分,不用绿色(Q1)。退回和对应的预扣相邻显示。期末余额与顶栏一致:解析 37、检查 12。</li><li>24 小时内复查记一行 0 点的「免扣」,这一行是否保留待确认(<a class="lnk" href="index.html#s-q">Q13</a>)。</li><li>分页(Q4):当前页浅灰实底加粗,和筛选一致;只在超过一页时显示。</li></ul>
</section>
<!-- 05 参数对照表 -->
<section class="d-sec" id="s-param">
<div class="d-sec-h"><span class="no">05</span><h2>参数对照表</h2><span class="ref">§7.7 · 点一行在原文查看器里对照</span></div>
<table class="data hover param" data-param>
<thead><tr><th>参数项</th><th>要求</th><th>投标响应</th><th>判断</th><th>级别</th><th>出处</th></tr></thead>
<tbody>
<tr><td>★ 应用服务器 · 内存</td><td class="num">≥ 512G DDR4</td><td class="num">256G DDR4</td><td><span class="verdict fatal">疑似不满足</span></td><td><span class="sev fatal">致命</span></td><td><a href="#">第 <span class="fig">41</span> 页</a> ↔ <a href="#">技术册 第 <span class="fig">12</span> 页</a></td></tr>
<tr class="sel"><td>★ 应用服务器 · 系统盘</td><td class="num">2 块 ≥ 960G SSD<div class="cap">补遗 1 更新,原为 <s>≥ 480G</s></div></td><td class="num">2 块 960G SSD</td><td>满足</td><td><span class="dash">—</span></td><td><a href="#">补遗 1 第 <span class="fig">1</span> 页</a> ↔ <a href="#">技术册 第 <span class="fig">12</span> 页</a></td></tr>
<tr><td>★ 应用服务器 · CPU</td><td class="num">≥ 2 颗 32C 2.7GHz</td><td class="num">2 颗 7380 32C 2.7GHz</td><td>满足</td><td><span class="dash">—</span></td><td><a href="#">第 <span class="fig">41</span> 页</a> ↔ <a href="#">技术册 第 <span class="fig">12</span> 页</a></td></tr>
<tr><td>★ 核心交换机 · BFD 间隔</td><td class="num">≤ 50ms</td><td class="num">3.3ms</td><td>满足</td><td><span class="dash">—</span></td><td><a href="#">第 <span class="fig">44</span> 页</a> ↔ <a href="#">技术册 第 <span class="fig">58</span> 页</a></td></tr>
<tr><td>★ 核心交换机 · 证明材料</td><td>官网参数截图等</td><td class="muted">—</td><td><span class="verdict major">未找到响应</span></td><td><span class="sev major">重要</span></td><td><a href="#">第 <span class="fig">44</span> 页</a> ↔ <span class="cap">已查找:技术册、商务册附件</span></td></tr>
<tr><td>▲ 核心交换机 · 交换容量</td><td class="num">≥ 19.2Tbps</td><td class="num">19.2Tbps / 48Tbps</td><td><span class="verdict manual">需人工判断</span></td><td><span class="dash">—</span></td><td><a href="#">第 <span class="fig">44</span> 页</a> ↔ <a href="#">技术册 第 <span class="fig">58</span> 页</a></td></tr>
<tr><td>排队叫号一体机 · 显示屏</td><td class="num">≥ 21.5 英寸</td><td class="num">21 英寸</td><td><span class="verdict hint">疑似不满足</span></td><td><span class="sev hint">提示</span></td><td><a href="#">第 <span class="fig">47</span> 页</a> ↔ <a href="#">技术册 第 <span class="fig">77</span> 页</a></td></tr>
</tbody>
</table>
<ul class="d-rules"><li>判断只用四个词:满足 / 疑似不满足 / 未找到响应 / 需人工判断。「满足」用墨色普通字,不用绿色。</li><li>保留「级别」列(Q5):标签的实心 / 描边 / 无框给判断词的颜色做形状兜底;「满足」「需人工判断」写「—」。</li></ul>
</section>
<!-- 06 空结果 -->
<section class="d-sec" id="s-empty">
<div class="d-sec-h"><span class="no">06</span><h2>空结果</h2><span class="ref">§7.15</span></div>
<div class="d-grid c2">
<div>
<span class="d-label">筛选后没有结果:写清条件,给「清除筛选」</span>
<section class="card flush" aria-label="空结果示例:项目">
<div class="card-h"><h3>项目</h3><div class="seg" role="group" aria-label="按状态筛选(示例)"><button aria-pressed="false">进行中 <span class="fig">4</span></button><button aria-pressed="false">全部 <span class="fig">6</span></button><button aria-pressed="false">已递交</button><button aria-pressed="true">已归档</button></div></div>
<ul class="prows"><li class="empty sm"><b>没有符合条件的项目</b><p>「已归档」里没有名称或采购人含「医保」的项目。</p><button class="btn sm">清除筛选</button></li></ul>
</section>
</div>
<div>
<span class="d-label">问题都处理完:说清楚意味着什么,不庆祝</span>
<section class="results card flush" aria-label="空结果示例:问题">
<div class="empty sm"><b>待处理的问题都已处理</b><p>项目为「就绪」。这只表示每一项都有人处理过,不代表投标文件没有问题。</p><button class="btn sm">导出检查报告</button></div>
<button class="group-h"><i data-ic="chev-right"></i>已处理(<span class="fig">9</span>)</button>
</section>
</div>
</div>
</section>
</main>
<script src="demo.js"></script>
<script>
const { $, $$, esc, icon, toast, dialog } = Demo;
Demo.demoTop("lists.html");
const kbdMode = (el) => { el.addEventListener("keydown", () => el.classList.add("kb-on")); el.addEventListener("pointerdown", () => el.classList.remove("kb-on")); };
/* ---------- 01 项目列表(数据和行结构同 page-workbench.html) ---------- */
const CHECK_COST = `扣 1 次检查 · 剩 <span class="fig">12</span> 次`;
const P = [
{ name: "某县数据中心机房改造项目", buyer: "某县大数据管理局", regime: "招标投标", lot: "标段 1", h: 6, due: ["今天 17:00 截止", "剩 6 小时"], cls: "due-today", st: "编制中", ready: "", fatal: 0,
next: "确认清单", menu: [["上传投标文件", ""], ["发起检查", "cost"], ["导出清单", ""]] },
{ name: "某区智慧社区平台运维服务", buyer: "某区民政局", regime: "政府采购", lot: "全部", h: 53, due: ["剩 2 天 5 小时", "11月5日 16:00"], cls: "due-soon", st: "已检查", ready: "就绪", fatal: 0,
next: "导出检查报告", menu: [["查看检查结果", ""], ["标记为已递交", ""]] },
{ name: "某市政务服务中心智慧大厅建设项目", buyer: "某市政务服务管理办公室", regime: "政府采购", lot: "采购包 1", h: 408, due: ["剩 17 天", "11月20日 09:30"], cls: "", st: "已检查", ready: "未就绪", fatal: 3,
next: "处理致命问题", menu: [["重新检查", "cost"], ["导出检查报告", "致命问题未处理也可以导出"], ["标记为已递交", ""]] },
{ name: "某市医保信息系统等保测评服务", buyer: "某市医疗保障局", regime: "政府采购", lot: "全部", h: 600, due: ["剩 25 天", "11月28日 10:00"], cls: "", st: "跟进中", ready: "", fatal: 0,
next: "确认清单", menu: [["查看解析进度", "补遗 2 解析中 62%"], ["上传补遗", ""]] },
{ name: "某区智慧校园一期", buyer: "某区教育局", regime: "政府采购", lot: "全部", h: -1, due: ["已截止", "已递交"], cls: "due-past", st: "已递交", ready: "", fatal: 0,
next: "归档项目", menu: [["导出检查报告", ""]] },
{ name: "某市不动产登记信息平台升级", buyer: "某市自然资源和规划局", regime: "政府采购", lot: "采购包 2", h: -2, due: ["已截止", "已归档"], cls: "due-past", st: "已归档", ready: "", fatal: 0,
next: "查看项目", menu: [["恢复为进行中", ""]] }
];
const SEGN = { active: "进行中", all: "全部", "已递交": "已递交", "已归档": "已归档" };
let pf = { seg: "active", q: "", regime: "", sort: ["h", "asc"] };
function pstatus(p) {
const r = p.ready === "就绪" ? '<span class="hint-u tip-below" tabindex="0" data-tip="只表示致命问题都已处理,不代表投标文件没有问题。">就绪</span>' : p.ready ? `<span>${p.ready}</span>` : "";
return `<span class="st"><span class="tag">${p.st}</span>${r}</span>`;
}
function prows() {
const rows = P.map((p, i) => [p, i]).filter(([p]) => (pf.seg === "all" || (pf.seg === "active" ? !["已递交", "已归档"].includes(p.st) : p.st === pf.seg))
&& (!pf.regime || p.regime === pf.regime) && (!pf.q || (p.name + p.buyer).includes(pf.q)));
const [k, d] = pf.sort;
rows.sort(([a], [b]) => (k === "name" ? a.name.localeCompare(b.name, "zh") : a[k] - b[k]) * (d === "asc" ? 1 : -1));
$("[data-prows]").innerHTML = rows.map(([p, i]) => `<li class="prow ${p.st === "已归档" ? "arch" : ""}" data-i="${i}">
<span class="pt"><a class="pname" href="#" title="${esc(p.name)}">${esc(p.name)}</a><span class="psub">${esc(p.buyer)} · ${esc(p.lot)}</span></span>
<span class="dl"><b class="${p.cls}">${p.due[0]}</b><small>${p.due[1]}</small></span>
${pstatus(p)}
<span class="c">${p.fatal ? `<span class="sev fatal">致命<span class="fig">${p.fatal}</span></span>` : '<span class="dash" aria-label="无">—</span>'}</span>
<span><button class="nx" data-next><span>${p.next}</span>${icon("chev-right")}</button></span>
<span class="more-cell"><button class="icon-btn sm" aria-label="更多操作:${esc(p.name)}" aria-haspopup="menu" aria-expanded="false" data-menu>${icon("more")}</button></span></li>`).join("")
|| `<li class="empty sm"><b>没有符合条件的项目</b><p>「${SEGN[pf.seg]}」里没有${pf.regime ? `「${pf.regime}」体系${pf.q ? "、" : ""}` : ""}${pf.q ? `名称或采购人含「${esc(pf.q)}」` : ""}的项目。</p><button class="btn sm" data-pclear>清除筛选</button></li>`;
}
function psortUI() {
$$("[data-pcols] .srt").forEach((b) => {
const on = b.dataset.sort === pf.sort[0];
if (on) b.dataset.dir = pf.sort[1]; else delete b.dataset.dir;
b.querySelector("svg")?.remove();
b.insertAdjacentHTML("beforeend", icon(on ? (pf.sort[1] === "asc" ? "arrow-up" : "arrow-down") : "sort"));
b.setAttribute("aria-label", `按${b.textContent}排序${on ? (pf.sort[1] === "asc" ? ",当前升序" : ",当前降序") : ""}`);
});
}
$("[data-pcols]").addEventListener("click", (e) => {
const b = e.target.closest(".srt"); if (!b) return;
pf.sort = [b.dataset.sort, pf.sort[0] === b.dataset.sort && pf.sort[1] === "asc" ? "desc" : "asc"];
psortUI(); prows();
});
$("[data-pseg]").addEventListener("change", (e) => { pf.seg = e.detail; prows(); });
$("[data-plot]").addEventListener("change", (e) => { pf.regime = e.detail; prows(); });
$("[data-psearch]").addEventListener("input", (e) => { pf.q = e.target.value.trim(); prows(); });
$("[data-prows]").addEventListener("click", (e) => {
if (e.target.closest("[data-pclear]")) {
pf = { ...pf, seg: "all", q: "", regime: "" }; $("[data-psearch]").value = "";
$$("[data-pseg] button").forEach((b) => b.setAttribute("aria-pressed", b.dataset.v === "all"));
$("[data-plot] .val").textContent = "所有体系"; $$("[data-plot] [role=option]").forEach((o) => o.setAttribute("aria-selected", o.dataset.value === ""));
prows(); return;
}
const row = e.target.closest(".prow"); if (!row) return;
e.preventDefault();
const p = P[+row.dataset.i], m = e.target.closest("[data-menu]");
if (m) {
e.stopPropagation();
Demo.floatMenu(m, [{ label: "打开项目" }, "-", ...p.menu.map(([t, s]) => ({ label: t, sub: s === "cost" ? CHECK_COST : s }))], (act) => toast(`(演示)${act}:${p.name}`, { ok: false }));
return;
}
if (e.target.closest(".hint-u")) return;
if (e.target.closest("[data-next]")) return toast(`(演示)${p.next}:${p.name}`, { ok: false });
toast(`(演示)打开项目:${p.name}`, { ok: false });
});
psortUI(); prows();
/* ---------- 02 清单列表(当前条 = 选中,Q3) ---------- */
const CL = [
{ imp: "★", t: "应用服务器:内存 ≥ 512G DDR4", cats: ["param", "invalid"], lot: "采购包 1", src: "ai", p: 41 },
{ imp: "★", t: "应用服务器:国产自主可控 CPU,提供国产化适配承诺函", cats: ["param", "invalid"], lot: "采购包 1", src: "ai", p: 41 },
{ imp: "★", t: "应用服务器:系统盘 2 块 ≥ 960G SSD", old: "2 块 ≥ 480G SSD", cats: ["param"], lot: "采购包 1", src: "add", p: 1, doc: "补遗 1" },
{ imp: "★", t: "核心交换机:支持硬件 BFD,最小发送间隔 ≤ 50ms,提供证明材料", cats: ["param", "invalid"], lot: "采购包 1", src: "ai", p: 44 },
{ imp: "▲", t: "核心交换机:交换容量 ≥ 19.2Tbps,包转发率 ≥ 2880Mpps", cats: ["param"], lot: "采购包 1", src: "ai", p: 44 },
{ imp: "", t: "营业执照等主体资格证明文件(扫描件)", cats: ["qual", "invalid"], lot: "通用", src: "ai", p: 23 },
{ imp: "", t: "财务状况报告:资产负债表 + 利润表月报表(开标前 6 个月内)", cats: ["qual", "invalid"], lot: "通用", src: "ai", p: 23 },
{ imp: "", t: "不接受联合体投标", cats: ["qual"], lot: "通用", src: "manual", p: 2, edited: true },
{ imp: "", t: "报价不超过采购包 1 最高限价 286.00 万元", cats: ["invalid"], lot: "采购包 1", src: "ai", p: 2 },
{ imp: "", t: "投标有效期不少于 90 天", cats: ["invalid", "date"], lot: "通用", src: "ai", p: 21 },
{ imp: "", t: "提交投标文件截止时间:2026年11月20日 09:30", old: "2026年11月18日 09:30", cats: ["date"], lot: "通用", src: "add", p: 1, doc: "补遗 1", confirmed: true },
{ imp: "", t: "递交方式:电子交易平台在线递交,CA 加密", cats: ["date"], lot: "通用", src: "ai", p: 6 }
];
const CAT = { qual: "资格条件", invalid: "无效条款", param: "技术参数", date: "日期与递交" };
let cf = { cat: "", star: false }, cur = 0;
const cvis = () => CL.filter((c) => (!cf.cat || c.cats.includes(cf.cat)) && (!cf.star || c.imp === "★"));
function crows() {
const rows = cvis();
const chips = [cf.cat && `<span class="chip">分类:${CAT[cf.cat]}<button aria-label="移除筛选:${CAT[cf.cat]}" data-xc="cat">${icon("x")}</button></span>`, cf.star && `<span class="chip">只看 ★<button aria-label="移除筛选:只看 ★" data-xc="star">${icon("x")}</button></span>`].filter(Boolean);
$("[data-chips]").innerHTML = chips.length ? `<span>筛选</span>${chips.join("")}<span><span class="fig m">${rows.length}</span> 条</span><button class="btn sm text" data-xc="all">清除全部</button>` : "";
$("[data-chips]").hidden = !chips.length;
cur = Math.min(cur, Math.max(rows.length - 1, 0));
$("[data-clist]").innerHTML = `<div class="lb-h"><span>要求</span><span>来源 · 出处</span></div>` + (rows.map((c, i) => {
const src = c.src === "ai" ? '<span class="tag ai">AI</span>' : `<span class="tag">${c.src === "add" ? c.doc : "人工添加"}</span>`;
const upd = c.old && !c.confirmed ? `<div class="upd"><span class="flag">已被${c.doc} 更新 · 原为 <s>${c.old}</s></span><button class="btn" data-ack="${i}">已核对</button></div>` : "";
return `<div class="item ${i === cur ? "sel" : ""}" data-ci="${i}" aria-current="${i === cur}"><span class="imp">${c.imp}</span>
<div><div class="t">${c.t}</div><div class="meta">${c.cats.map((k) => `<span class="tag">${CAT[k]}</span>`).join("")}<span class="tag">${c.lot}</span>${c.edited ? '<span class="flag">已修改 · <a class="plain" href="#">查看记录</a></span>' : ""}</div>${upd}</div>
<div class="side">${src}<a class="cite-src" href="#" data-open>第 <span class="fig">${c.p}</span> 页</a></div></div>`;
}).join("") || `<div class="empty sm"><b>这个分类下没有 ★ 条款</b><p>★ 条款在「技术参数」和「无效条款」里。</p><button class="btn sm" data-xc="all">清除筛选</button></div>`);
}
$("[data-cseg]").addEventListener("change", (e) => { cf.cat = e.detail; cur = 0; crows(); });
$("[data-star]").addEventListener("change", (e) => { cf.star = e.target.checked; cur = 0; crows(); });
$("[data-clist]").addEventListener("click", (e) => {
const a = e.target.closest("[data-ack]"); if (a) { cvis()[+a.dataset.ack].confirmed = true; crows(); toast("已核对补遗更新,记录已保存"); return; }
const it = e.target.closest("[data-ci]"); if (!it) return;
cur = +it.dataset.ci; crows(); $("[data-clist]").focus({ preventScroll: true });
if (e.target.closest("[data-open], .plain")) { e.preventDefault(); toast("(演示)在原文查看器里打开这一页", { ok: false }); }
});
document.addEventListener("click", (e) => {
const x = e.target.closest("[data-xc]"); if (!x) return;
const k = x.dataset.xc;
if (k !== "star") { cf.cat = ""; $$("[data-cseg] button").forEach((b) => b.setAttribute("aria-pressed", b.dataset.v === "")); }
if (k !== "cat") { cf.star = false; $("[data-star]").checked = false; }
crows();
});
$("[data-clist]").addEventListener("keydown", (e) => {
const n = $$("[data-ci]").length; if (!n) return;
if (e.key === "j" || e.key === "k") { e.preventDefault(); cur = Math.max(0, Math.min(n - 1, cur + (e.key === "j" ? 1 : -1))); crows(); $(`[data-ci="${cur}"]`).scrollIntoView({ block: "nearest" }); }
if (e.key === "Enter" || e.key === "o") toast(`(演示)打开出处:${$(`[data-ci="${cur}"] .cite-src`).textContent}`, { ok: false });
});
kbdMode($("[data-clist]"));
crows();
/* ---------- 03 问题列表:当前条 = .finding.sel(浅灰底 + 墨色竖条);勾选只看复选框;批量只给「提示」;忽略和致命的确认无误在行内展开说明(U4) ---------- */
const FD = [
{ sev: "fatal", t: "报价超过采购包 1 的最高限价", flag: "新增", text: ["开标一览表报价 287.60 万元,采购包 1 最高限价 286.00 万元,超出 1.60 万元。", "这一项会导致投标无效。请核对开标一览表的报价。"],
cites: [["招标文件", 2, "最高限价:418.00万元(", "采购包1:286.00万元", ",采购包2:132.00万元)。"], ["报价册 · 开标一览表", 1, "投标总价(小写):", "2876000.00 元", ""]] },
{ sev: "fatal", t: "★ 条款疑似不满足:应用服务器内存", flag: "沿用 · 上次已修改,问题仍在", text: ["技术偏离表第 12 行响应「内存 256G DDR4」,要求「≥512G DDR4」。", "这一项会导致投标无效。请在技术偏离表第 12 行和对应产品参数中更正内存配置。"],
cites: [["招标文件", 41, "★ 2U机架式服务器,CPU:配置≥2颗海光架构32C 2.7GHz,", "内存: ≥512G DDR4", "……"], ["技术册", 12, "第 12 行 ", "内存:256G DDR4", " 无偏离"]] },
{ sev: "major", t: "缺少 ★ 参数的证明材料:核心交换机硬件 BFD", text: ["已响应「支持硬件 BFD」,但没有找到官网参数截图等证明材料。请在技术册补充证明材料。"],
cites: [["招标文件", 44, "★ 支持硬件BFD,最小报文发送间隔≤50ms,", "提供相关证明材料(包含但不限于产品官网公开参数截图证明等)", "。"]], missing: "已查找:技术册(全部章节)、商务册(附件)" },
{ sev: "major", t: "ISO27001 证书在投标截止日前到期", flag: "新增", text: ["证书有效期至 2026年11月15日,早于投标截止日 11月20日。按评分办法,这一项可能不得分。", "请上传换发后的证书,或在资质库更新有效期。"],
cites: [["招标文件", 31, "体系认证(6分):……", "以投标截止日在有效期内为准", ",提供证书扫描件)。"], ["商务册", 38, "ISO/IEC 27001 信息安全管理体系认证证书 ", "有效期至:2026年11月15日", ""]] },
{ sev: "hint", t: "投标文件里出现了其他项目的名称", flag: "新增", text: ["商务册第 27 页出现「某区智慧校园一期项目」及其项目编号。如果是业绩介绍可以忽略;如果是照搬旧标书留下的,请修改。"],
cites: [["商务册", 27, "我公司承担的", "某区智慧校园一期项目(项目编号 QJ-2025-0112)", "……"]] },
{ sev: "hint", t: "一般参数疑似不满足:叫号一体机显示屏尺寸", text: ["技术册第 77 页响应「21英寸」,要求「≥21.5英寸」。一般参数负偏离每项扣 0.5 分。"],
cites: [["招标文件", 47, "", "显示屏尺寸≥21.5英寸", ",分辨率≥1920×1080"], ["技术册", 77, "显示屏:", "21英寸", " LED 背光液晶屏"]] },
{ sev: "hint", t: "投标函落款日期晚于投标截止日", flag: "沿用", text: ["投标函落款「2026年11月25日」,投标截止时间是 2026年11月20日 09:30。"],
cites: [["商务册", 3, "投标人(电子签章):南京某信息技术有限公司  ", "日期:2026年11月25日", ""]], ocr: true },
{ sev: "hint", t: "目录页码与正文不一致", text: ["技术册目录写「售后服务方案 第 96 页」,正文实际在第 98 页。"],
cites: [["技术册", 2, "七、售后服务方案 ………… ", "96", ""]] }
].map((f, i) => ({ ...f, id: i, st: null, ck: false, open: null, draft: "", err: false }));
FD[6].st = { act: "确认无误", at: "11月1日 16:05" };
let ff = "", fcur = 0, foldOpen = false;
const hist = []; // 处理顺序
/* 本次处理过的问题留在原位(f.session),显示署名行;J / K 可以回到它,U 撤销当前条(规范 §8.2)。
页面加载前已处理的折叠在「已处理」里。 */
const SEV = { fatal: "致命", major: "重要", hint: "提示" };
const ftodo = () => FD.filter((f) => (!f.st || f.session) && (!ff || f.sev === ff));
function citeHTML(c, ocr) { return `<div class="cite"><a class="cite-src" href="#">${c[0]} · 第 <span class="fig">${c[1]}</span> 页${ocr ? '<span class="ocr">OCR</span>' : ""}</a><q>${esc(c[2])}<mark>${esc(c[3])}</mark>${esc(c[4])}</q></div>`; }
/* 行内说明:忽略原因(所有级别)/ 致命问题「确认无误」的说明(U4);替换处理按钮,不弹对话框 */
const U4MSG = { ok: ["说明会同时作为误报反馈提交", "请写一句说明,至少 5 个字"], ign: ["「已忽略」在报告里单独列出", "至少写 5 个字,说明为什么不处理"] };
function u4HTML(f) {
const ok = f.open === "ok", [tip, err] = U4MSG[f.open];
return `<div class="u4" data-u4>
<div class="field ${f.err ? "err" : ""}">
<label for="u4t-${f.id}">${ok ? "确认无误的说明" : "忽略原因"} <small>(必填,至少 5 个字)</small></label>
${ok ? "" : '<div class="chips" role="group" aria-label="常用原因"><button type="button" class="btn" data-chip>招标文件允许</button><button type="button" class="btn" data-chip>已在其他分册响应</button><button type="button" class="btn" data-chip>与本项目无关</button></div>'}
<textarea class="input" id="u4t-${f.id}" maxlength="200" data-u4t ${f.err ? 'aria-invalid="true"' : ""} aria-describedby="u4m-${f.id}">${esc(f.draft)}</textarea>
<div class="msg-row"><span class="msg" id="u4m-${f.id}" data-u4msg>${f.err ? `<span class="ico w" aria-hidden="true">!</span>${err}` : tip}</span><span class="count"><span data-n>${f.draft.length}</span> / 200</span></div>
</div>
<div class="row"><button type="button" class="btn sm" data-u4ok>${ok ? "确认无误" : "忽略这一项"}</button><button type="button" class="btn sm text" data-u4x>取消</button><span class="cap muted">Ctrl Enter 提交 · Esc 取消</span></div></div>`;
}
function findingHTML(f, isCur) {
const ck = f.st ? "" : f.sev === "hint" ? `<label class="ckw tip-start" data-tip="批量处理"><input type="checkbox" class="ck" data-fck="${f.id}" ${f.ck ? "checked" : ""} aria-label="勾选:${esc(f.t)}"></label>`
: `<span class="ckw tip-start" data-tip="只有「提示」可以批量处理"><input type="checkbox" class="ck" disabled aria-label="不可批量"></span>`;
const body = f.st
? `<div class="sign ${f.st.ign ? "ign" : ""}"><span class="ok" aria-hidden="true">${f.st.ign ? "–" : "✓"}</span><b>王敏</b><span class="fig">${f.st.at}</span><span>${f.st.act}</span><button type="button" class="undo" data-undo="${f.id}">撤销</button></div>${f.st.note ? `<p class="sign-note">${f.st.ign ? "原因" : "说明"}:${esc(f.st.note)}${f.st.ign ? "" : " · 已作为误报反馈提交"}</p>` : ""}`
: f.open ? u4HTML(f)
: `<div class="f-actions"><button class="btn sm" data-act="1" data-tip="我核对过,投标文件这里没问题">确认无误</button><button class="btn sm" data-act="2" data-tip="我已经在投标文件里改了,复查时会再查">已修改</button><button class="btn sm" data-act="3" data-tip="决定不处理,必须填原因">忽略并注明原因</button><a class="plain feedback" href="#">判断有误?</a></div>`;
return `<article class="finding has-ck ${isCur ? "sel" : ""}" data-fid="${f.id}" ${isCur ? 'aria-current="true"' : ""}>${ck}
<div><div class="f-head"><span class="sev ${f.sev}">${SEV[f.sev]}</span><span class="f-title">${f.t}</span>${f.flag ? `<span class="flag">${f.flag}</span>` : ""}</div>
${f.text.map((t) => `<p class="f-text">${t}</p>`).join("")}${body}</div>
<aside>${f.cites.map((c) => citeHTML(c, f.ocr)).join("")}${f.missing ? `<div class="cite missing"><span class="cite-src">投标文件 · 未找到</span><p>${f.missing}</p></div>` : ""}</aside></article>`;
}
function frows() {
const todo = ftodo(), done = FD.filter((f) => f.st && !f.session);
["", "fatal", "major", "hint"].forEach((k) => { $(`[data-c="${k}"]`).textContent = FD.filter((f) => !f.st && (!k || f.sev === k)).length; });
fcur = Math.min(fcur, Math.max(todo.length - 1, 0));
$("[data-flist]").innerHTML = (todo.map((f, i) => findingHTML(f, i === fcur)).join("") || `<div class="empty sm"><b>这个级别的问题都已处理</b><p>已处理的在下方「已处理」里,可以撤销。</p></div>`)
+ `<button class="group-h" data-fold aria-expanded="${foldOpen}">${icon(foldOpen ? "chev-down" : "chev-right")}已处理(<span class="fig">${done.length}</span>)</button>`
+ (foldOpen ? done.map((f) => findingHTML(f, false)).join("") : "");
const hints = todo.filter((f) => f.sev === "hint" && !f.st), n = FD.filter((f) => f.ck && !f.st).length, all = $("[data-fall]");
all.checked = hints.length > 0 && hints.every((f) => f.ck); all.indeterminate = !all.checked && hints.some((f) => f.ck); all.disabled = !hints.length;
$("[data-allw]").classList.toggle("dis", !hints.length);
$("[data-batch]").hidden = !n; $("[data-bn]").textContent = n;
}
const now = () => "11月3日 " + new Date().toTimeString().slice(0, 5);
const focusList = () => $("[data-flist]").focus({ preventScroll: true });
function openU4(f, kind) {
f.open = kind; fcur = Math.max(0, ftodo().indexOf(f)); frows();
const ta = $(`#u4t-${f.id}`); ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length);
}
function act(f, a) {
if (a === 3) return openU4(f, "ign");
if (a === 1 && f.sev === "fatal") return openU4(f, "ok");
done(f, a === 1 ? "确认无误" : "已修改");
}
function submitU4(f) {
const v = f.draft.trim();
if (v.length < 5) { f.err = true; frows(); $(`#u4t-${f.id}`).focus(); return; }
const ign = f.open === "ign"; f.open = null; f.draft = ""; f.err = false;
done(f, ign ? "已忽略" : "确认无误", v, ign);
}
function done(f, actName, note, ign) {
f.st = { act: actName, at: now(), note, ign }; f.session = true; f.ck = false; hist.push(f);
const vis = ftodo(), nx = vis.findIndex((g, i) => i > vis.indexOf(f) && !g.st); if (nx >= 0) fcur = nx;
frows(); focusList();
toast(`已标记「${actName}」`, { undo: () => undo(f) });
}
function undo(f) { f.st = null; f.session = false; const i = hist.lastIndexOf(f); if (i >= 0) hist.splice(i, 1); frows(); }
$("[data-fseg]").addEventListener("change", (e) => { ff = e.detail; fcur = 0; frows(); });
$("[data-fall]").addEventListener("change", (e) => { ftodo().filter((f) => f.sev === "hint" && !f.st).forEach((f) => { f.ck = e.target.checked; }); frows(); });
$("[data-flist]").addEventListener("click", (e) => {
const t = e.target, f = FD[t.closest("[data-fid]")?.dataset.fid];
if (t.matches("[data-fck]")) { f.ck = t.checked; frows(); return; }
if (t.closest("[data-fold]")) { foldOpen = !foldOpen; frows(); return; }
if (t.closest("[data-undo]")) { undo(f); toast("已撤销", { ok: false }); return; }
if (t.closest("[data-act]")) { act(f, +t.closest("[data-act]").dataset.act); return; }
if (t.closest("[data-chip]")) { const ta = $("[data-u4t]", t.closest("[data-u4]")); ta.value = t.textContent; ta.dispatchEvent(new Event("input", { bubbles: true })); ta.focus(); return; }
if (t.closest("[data-u4ok]")) { submitU4(f); return; }
if (t.closest("[data-u4x]")) { f.open = null; f.err = false; frows(); focusList(); return; }
if (t.closest("[data-u4]")) return;
if (t.closest("a")) { e.preventDefault(); toast(t.closest(".cite-src") ? "(演示)在原文查看器里打开这一处" : "(演示)打开误报反馈", { ok: false }); }
if (f && ftodo().includes(f) && !t.closest(".ckw") && ftodo().indexOf(f) !== fcur) { fcur = ftodo().indexOf(f); frows(); focusList(); }
});
$("[data-flist]").addEventListener("input", (e) => {
if (!e.target.matches("[data-u4t]")) return;
const box = e.target.closest("[data-u4]"), f = FD[e.target.closest("[data-fid]").dataset.fid];
f.draft = e.target.value; $("[data-n]", box).textContent = f.draft.length;
if (f.err && f.draft.trim().length >= 5) { f.err = false; $(".field", box).classList.remove("err"); e.target.removeAttribute("aria-invalid"); $("[data-u4msg]", box).textContent = U4MSG[f.open][0]; }
});
$("[data-flist]").addEventListener("keydown", (e) => {
if (e.target.matches("[data-u4t]")) {
const f = FD[e.target.closest("[data-fid]").dataset.fid];
if (e.key === "Escape") { e.preventDefault(); f.open = null; f.err = false; frows(); focusList(); }
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); submitU4(f); }
return;
}
if (e.target.matches("input, textarea") || e.ctrlKey || e.metaKey || e.altKey) return;
const todo = ftodo();
if (e.key === "?") return Demo.keysDialog(Demo.KEYS.check);
if (e.key === "u") { const f = ftodo()[fcur]; if (f && f.st) { undo(f); toast(`已撤销「${f.t}」的处理`, { ok: false }); } else toast("当前这一条还没有处理", { ok: false }); return; }
if (!todo.length) return;
if (e.key === "j" || e.key === "k") { e.preventDefault(); fcur = Math.max(0, Math.min(todo.length - 1, fcur + (e.key === "j" ? 1 : -1))); frows(); $(".finding.sel").scrollIntoView({ block: "nearest" }); }
if (e.key === "x" && todo[fcur].sev === "hint" && !todo[fcur].st) { todo[fcur].ck = !todo[fcur].ck; frows(); }
if (["1", "2", "3"].includes(e.key)) { e.preventDefault(); if (!todo[fcur].st) act(todo[fcur], +e.key); }
if (e.key === "o" || e.key === "Enter") toast("(演示)在原文查看器里打开当前出处", { ok: false });
});
kbdMode($("[data-flist]"));
$("[data-bok]").onclick = () => { const sel = FD.filter((f) => f.ck && !f.st); sel.forEach((f) => { f.st = { act: "确认无误", at: now() }; f.session = true; f.ck = false; hist.push(f); }); frows(); toast(`已将 ${sel.length} 项「提示」标记为确认无误`, { undo: () => sel.forEach(undo) }); };
$("[data-bclear]").onclick = () => { FD.forEach((f) => f.ck = false); frows(); };
frows();
/* ---------- 04 额度流水:按时间先后记账,余额由流水算出(Q1:+ / − 都是墨色) ----------
* [时间, 类型, 额度, 数量, 关联, 说明];退回紧跟在对应的预扣之后。 */
const A = "智慧大厅", B = "智慧社区运维", C = "数据中心机房", D = "医保等保", E = "智慧校园一期", F = "不动产登记平台";
const FREE = "免扣(24 小时内复查)"; // 类型待确认(Q13):FR-M6-03 的类型里没有「免扣」
const LEDGER = [
["11月1日 09:00", "发放", "both", [50, 20], "专业版开通(对公转账)", "开通单 KT-1101"],
["11月1日 09:10", "结算", "检查", -1, `${F} · 第 3 次检查`, ""],
["11月1日 09:20", "结算", "检查", -1, `${E} · 第 1 次检查`, ""],
["11月1日 10:12", "结算", "解析", -1, `${A} · 招标文件`, ""],
["11月1日 10:40", "结算", "解析", -1, `${A} · 招标文件附件`, ""],
["11月1日 11:05", "结算", "解析", -1, `${B} · 招标文件`, ""],
["11月1日 14:02", "结算", "解析", -1, `${C} · 招标文件`, ""],
["11月1日 14:30", "结算", "解析", -1, `${C} · 招标文件附件`, ""],
["11月1日 15:30", "结算", "检查", -1, `${B} · 第 1 次检查`, ""],
["11月1日 16:00", "结算", "检查", -1, `${A} · 第 1 次检查`, ""],
["11月1日 17:20", "结算", "解析", -1, `${D} · 招标文件`, ""],
["11月2日 09:05", "结算", "解析", -1, `${B} · 澄清答复 1`, ""],
["11月2日 09:50", "预扣", "检查", -1, `${E} · 第 2 次检查`, ""],
["11月2日 10:02", "退回", "检查", 1, `${E} · 第 2 次检查`, "检查超时"],
["11月2日 10:05", "结算", "解析", -1, `${A} · 补遗 1`, ""],
["11月2日 10:20", "结算", "检查", -1, `${E} · 第 2 次检查(重试)`, ""],
["11月2日 14:28", "预扣", "解析", -1, `${A} · 补遗 1 附件`, ""],
["11月2日 14:31", "退回", "解析", 1, `${A} · 补遗 1 附件`, "解析失败:文件已加密"],
["11月2日 15:10", "结算", "解析", -1, `${C} · 补遗 1`, ""],
["11月2日 16:20", "结算", "解析", -1, `${D} · 补遗 1`, ""],
["11月2日 16:30", "结算", "检查", -1, `${A} · 第 2 次检查`, "距上次扣点的检查已超过 24 小时"],
["11月2日 16:45", "结算", "检查", -1, `${B} · 第 2 次检查`, ""],
["11月3日 09:15", "结算", "解析", -1, `${B} · 补遗 1`, ""],
["11月3日 10:30", "结算", "解析", -1, `${C} · 澄清答复 1`, ""],
["11月3日 11:00", "结算", "检查", -1, `${E} · 第 3 次检查`, ""],
["11月3日 14:10", "预扣", "解析", -1, `${D} · 补遗 2`, "解析中,完成后结算"],
["11月3日 14:35", FREE, "检查", 0, `${B} · 第 3 次检查`, "排队中 · 不扣额度"]
];
const bal = { 解析: 0, 检查: 0 };
const ROWS = LEDGER.map(([t, type, q, n, ref, note]) => {
if (q === "both") { bal.解析 += n[0]; bal.检查 += n[1]; return { t, type, q: "解析 / 检查", qs: ["解析", "检查"], n: `+${n[0]} / +${n[1]}`, plus: true, bal: `${bal.解析} / ${bal.检查}`, ref, note }; }
bal[q] += n;
return { t, type, q, qs: [q], n: n > 0 ? `+${n}` : n < 0 ? `−${-n}` : "0", plus: n > 0, minus: n < 0, bal: bal[q], ref, note };
}).reverse(); // 新的在上
const PER = 10;
let qf = "", page = 1;
function flow() {
const rows = ROWS.filter((r) => !qf || r.qs.includes(qf)), pages = Math.ceil(rows.length / PER);
page = Math.min(page, pages);
const vis = rows.slice((page - 1) * PER, page * PER);
$("[data-flow]").innerHTML = vis.map((r, i) => {
const sub = r.type === "预扣" && vis[i - 1] && vis[i - 1].type === "退回" && vis[i - 1].ref === r.ref; // 和上一行的退回对应
return `<tr class="${sub ? "flow-sub" : ""}"><td class="num">${sub ? '<span class="fl-link" aria-hidden="true">└</span>' : ""}${r.t}</td><td>${r.type}</td><td>${r.q}</td>
<td class="num r ${r.plus ? "plus" : r.minus ? "minus" : ""}">${r.n}</td><td class="num r">${r.bal}</td><td>${r.ref}</td><td class="muted">${sub ? "对应上一行的退回" : r.note}</td></tr>`;
}).join("");
const pg = (p, label) => `<button class="pg" data-pg="${p}" ${p === page ? 'aria-current="page"' : ""} ${label ? `aria-label="${label}"` : `aria-label="第 ${p} 页"`}>${p}</button>`;
$("[data-ffoot]").hidden = pages < 2; // Q4:只在超过一页时显示
$("[data-fpager]").innerHTML = `<span class="total">共 <span class="fig">${rows.length}</span> 条</span>
<button class="pg" data-pg="${page - 1}" ${page === 1 ? "disabled" : ""} aria-label="上一页">${icon("chev-left")}</button>
${Array.from({ length: pages }, (_, i) => pg(i + 1)).join("")}
<button class="pg" data-pg="${page + 1}" ${page === pages ? "disabled" : ""} aria-label="下一页">${icon("chev-right")}</button>
<label class="jump">跳至 <input class="input" inputmode="numeric" aria-label="跳到第几页" data-jump> 页</label>`;
}
$("[data-qseg]").addEventListener("change", (e) => { qf = e.detail; page = 1; flow(); });
$("[data-fpager]").addEventListener("click", (e) => { const b = e.target.closest("[data-pg]"); if (b && !b.disabled) { page = +b.dataset.pg; flow(); $(`[data-pg="${page}"][aria-current]`)?.focus(); } });
$("[data-fpager]").addEventListener("keydown", (e) => { if (e.key === "Enter" && e.target.matches("[data-jump]")) { const v = parseInt(e.target.value, 10); if (v >= 1) { page = v; flow(); } } });
$("[data-csv]").onclick = () => toast("(演示)已导出本期流水 CSV");
flow();
/* ---------- 05 参数对照表:点一行 = 选中(Q3) ---------- */
$("[data-param]").addEventListener("click", (e) => {
const tr = e.target.closest("tbody tr"); if (!tr) return;
e.preventDefault();
$$("[data-param] tbody tr").forEach((x) => x.classList.toggle("sel", x === tr));
toast(`(演示)原文查看器两边对照:${tr.cells[0].textContent}`, { ok: false });
});
$$("#s-empty button").forEach((b) => b.addEventListener("click", () => toast("(演示)空结果示例", { ok: false })));
</script>
</body>
</html>