Files
bid-doctor/docs/design/ui-demo/page-reading.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

286 lines
21 KiB
HTML
Raw 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)
框架来自 Demo.shell(),共用组件来自 components.css 和 demo.css;对照布局(分类导航 + 清单 + 可拖动的原文查看器)是 demo.css 的 B19。
本页 <style> 只放读标特有的:项目头、清单栏、清单工具条与条目细节。
Q2:AI 产出正文墨色,AI 身份靠「AI」标签;待确认条目另加 2px 蓝铅笔虚线。
Q3:清单选中 = --c-n50 底 + 3px 墨色竖条;蓝色只留给原文高亮和出处链接。
-->
<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>
/* 整屏高度:顶栏 + 项目头 + 清单栏固定,下面的对照区(.cmp)三栏各自滚动 */
main.main{height:100vh;overflow:hidden}
/* 项目头:名称 + 信息一行;右侧截止、状态、就绪;下方标签页 */
.ph{padding:var(--sp-1) var(--pad-page) 0}
.ph-l1{display:flex;align-items:flex-end;gap:var(--sp-8)}
.ph-l1 > div{min-width:0}
.ph h1{margin:0;font:var(--fw-bold) var(--fs-h1)/var(--lh-h1) var(--font-serif);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ph-meta{margin:2px 0 0;color:var(--text-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.facts{display:flex;gap:var(--sp-8);margin:0 0 0 auto;flex:none}
.facts > div{display:flex;flex-direction:column;gap:2px}
.facts dt{font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary)}
.facts dd{display:flex;align-items:center;gap:6px;min-height:var(--lh-body);margin:0;white-space:nowrap}
.ph .tabs{margin-top:var(--sp-3)}
.tabs .sev{margin-left:2px}
/* 清单栏:标题 + 清单状态;右侧页面级操作(主按钮一个,Q12) */
.cl-bar{display:flex;align-items:center;gap:var(--sp-3);padding:var(--sp-4) var(--pad-page) var(--sp-3)}
.cl-bar h2{margin:0;font:var(--fw-bold) var(--fs-title)/var(--lh-title) var(--font-sans);white-space:nowrap}
.cl-bar .status{display:flex;align-items:center;gap:var(--sp-2);min-width:0;white-space:nowrap}
.cl-bar .status .sign{margin:0}
.cl-bar .actions{margin-left:auto;display:flex;gap:var(--sp-2);align-items:center;flex:none}
.cl-bar .actions .lnk{margin-right:var(--sp-2)}
/* 清单 */
.lst{scroll-padding-top:72px}
.lst-tb{position:sticky;top:0;z-index:2;display:flex;align-items:center;gap:var(--sp-4);padding:var(--sp-3) var(--card-x);background:var(--bg-surface);border-bottom:var(--line) solid var(--border-subtle)}
.lst-tb .search{flex:1;min-width:0}
.lst-tb .search .input{height:var(--control-h-sm)}
.lst-tb .search > .ic{top:8px}
.lst-tb .choice{flex:none;white-space:nowrap;align-items:center}
.lst-tb .choice .ck{margin-top:0}
.lot-note{margin:0;padding:var(--sp-3) var(--card-x) 0;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary)}
.lot-note .fig{font-size:var(--fs-figure-xs)}
.rule{display:flex;gap:var(--sp-2);align-items:flex-start;padding:var(--sp-2) var(--card-x) 0}
.rule > .ic{margin-top:3px;color:var(--text-secondary)}
.rule p{margin:0}
.rule .cite-link{margin:0 -4px;padding:0 4px}
.lst .item{padding:var(--sp-3) var(--card-x) var(--sp-3) var(--sp-3);border-top:var(--line) solid var(--border-subtle)}
.lst .item .t{font-weight:var(--fw-medium)}
.lst .item .side{padding-top:1px}
.lst .item .cite-link{margin:0;padding:0 4px;font-size:var(--fs-caption)}
.lst .item .cite-link .fig{font-size:var(--fs-figure-xs)}
.lst .item .upd .flag b{color:var(--warn-text);font-weight:var(--fw-bold)}
.lst .item .meta .lnk{font-size:var(--fs-caption)}
.lst .pq{cursor:default}
.lst .pq:hover{background:none}
.lst .pq .ai-block{padding-top:0;padding-bottom:0}
.lst .pq .f-actions{margin-top:var(--sp-2)}
.lst .end{height:var(--sp-8)}
</style>
</head>
<body>
<main>
<header class="ph">
<div class="ph-l1">
<div>
<h1 data-pname></h1>
<p class="ph-meta">某市政务服务管理办公室 · 政府采购 · 公开招标 · 投采购包 1(共 2 个包)· <span class="fig">ZC-2026-0418</span></p>
</div>
<dl class="facts">
<div><dt>投标截止</dt><dd><span class="fig m">11月20日 09:30</span><span class="muted">剩 <span class="fig">17</span> 天</span></dd></div>
<div><dt>状态</dt><dd><span class="tag">已检查</span></dd></div>
<div><dt>就绪</dt><dd><span class="hint-u tip-below tip-left" tabindex="0" data-tip="还有 3 个致命问题没处理。不影响导出,决定权在你。">未就绪</span></dd></div>
</dl>
</div>
<nav class="tabs" aria-label="项目"><a role="tab" href="#" data-demo>概览</a><a role="tab" href="#" aria-selected="true">读标</a><a role="tab" href="#" data-demo>查标<span class="sev fatal">致命<span class="fig">3</span></span></a><a role="tab" href="#" data-demo>报告</a></nav>
</header>
<div class="cl-bar">
<h2>项目检查清单</h2>
<div class="status" data-status></div>
<div class="actions">
<a class="lnk cap" href="#" data-missing>反馈遗漏</a>
<button class="btn" data-add><i data-ic="plus"></i>新增条目</button>
<button class="btn primary" data-confirm>确认清单</button>
</div>
</div>
<div class="cmp" data-cmp>
<section class="card flush cmp-list" aria-label="项目检查清单">
<nav class="cats" aria-label="清单分类" data-cats></nav>
<div class="lst" data-list></div>
</section>
<div class="rsz" role="separator" aria-orientation="vertical" aria-label="调整原文查看器宽度(← → 键)" tabindex="0" data-rsz></div>
<section class="viewer" aria-label="原文查看器" data-viewer></section>
</div>
</main>
<script src="../../prototype/assets/data.js"></script>
<script src="demo.js"></script>
<script>
const { $, $$, esc, icon, toast, dialog } = Demo;
const D = window.PROTO;
const DEMO = "(演示)这里只做了读标页";
/* 面包屑只留「工作台 / 项目名」;当前标签页由项目头的「读标」表示 */
Demo.shell({ nav: "workbench", crumbs: [["工作台", "page-workbench.html"], [D.project.name, ""]], search: { placeholder: "搜索项目、采购人" } });
$("[data-pname]").textContent = D.project.name;
$("[data-pname]").title = D.project.name;
const items = D.checklist.map((c) => ({ ...c }));
let pending = D.pending.map((q) => ({ ...q }));
let cat = "all", sel = "c1", confirmed = false, star = false, kw = "";
const catName = (id) => D.categories.find((x) => x.id === id).name;
const page = (c) => `${D.docs[c[0]].role === "addendum" ? D.docs[c[0]].name + " · " : ""}第 <span class="fig">${c[1]}</span> 页`;
/* ---------- 原文查看器 ---------- */
const V = (() => {
const el = $("[data-viewer]");
el.innerHTML = `<div class="viewer-bar">
<span class="doc-name"><b data-vn>原文</b><span class="doc-file" data-vf></span></span>
<div class="pager"><span class="fig" data-vpg></span>
<button class="icon-btn sm tip-below tip-left" aria-label="上一条(K)" data-tip="上一条 K" data-vp>${icon("chev-left")}</button>
<button class="icon-btn sm tip-below tip-left" aria-label="下一条(J)" data-tip="下一条 J" data-vx>${icon("chev-right")}</button></div></div>
<div class="viewer-body" data-vb></div>`;
function mark(t, key) { const i = key ? t.indexOf(key) : -1; return i < 0 ? `<span class="hl on">${esc(t)}</span>` : esc(t.slice(0, i)) + `<span class="hl on">${esc(key)}</span>` + esc(t.slice(i + key.length)); }
function open(c) {
const [doc, pg, id, key] = c, d = D.docs[doc];
$("[data-vn]", el).textContent = d.name;
$("[data-vf]", el).textContent = d.file;
$("[data-vf]", el).title = d.file;
$("[data-vpg]", el).textContent = `第 ${pg} / ${d.pages} 页`;
const paras = (D.pages[doc] || {})[pg] || [];
$("[data-vb]", el).innerHTML = `<div class="sheet">${paras.map((p) => p.h ? `<p class="h">${esc(p.t)}</p>` : `<p class="${p.faint ? "faint" : ""}">${p.id === id ? mark(p.t, key) : esc(p.t)}</p>`).join("")}<div class="stub"></div><div class="stub"></div><div class="stub s"></div><span class="pno">— ${pg} —</span></div>
<p class="viewer-note">页码以规范化 PDF 为准 · 原文只读</p>`;
const on = $(".hl.on", el); if (on) on.scrollIntoView({ block: "center" });
}
$("[data-vp]", el).onclick = () => step(-1);
$("[data-vx]", el).onclick = () => step(1);
return { open };
})();
/* ---------- 清单状态、分类、列表 ---------- */
function status() {
$("[data-status]").innerHTML = confirmed
? `<span class="tag">已确认 v3</span><span class="sign"><span class="ok" aria-hidden="true">✓</span><b>王敏</b><span class="fig">11月3日 ${new Date().toTimeString().slice(0, 5)}</span><span>确认</span></span>`
: `<span class="tag">草稿 v3</span><span class="cap muted">补遗 1 并入后待重新确认</span>`;
$("[data-confirm]").hidden = confirmed;
}
function cats() {
const n = (id) => items.filter((i) => i.cats.includes(id)).length;
const b = (id, name, num, cls = "") => `<button class="${cls}" data-cat="${id}" aria-current="${cat === id}">${name}<span class="fig">${num}</span></button>`;
$("[data-cats]").innerHTML = b("all", "全部", items.length) + D.categories.map((c) => b(c.id, c.name, n(c.id))).join("") +
`<hr>${b("pending", "待确认", pending.length, "ai")}
<p class="hint cap"><span class="kbd">J</span><span class="kbd">K</span> 上下条<br><span class="kbd">Enter</span> 看原文<br><span class="kbd">?</span> 快捷键</p>`;
}
const visible = () => {
const seen = new Set(), out = [];
const groups = cat === "all" ? D.categories : D.categories.filter((c) => c.id === cat);
groups.forEach((g) => items.filter((i) => i.cats.includes(g.id) && (cat !== "all" || !seen.has(i.id)) && (!star || i.imp === "★") && (!kw || i.title.includes(kw))).forEach((i) => { seen.add(i.id); out.push({ ...i, g: g.id }); }));
return out;
};
function itemRow(i) {
const src = i.src === "ai" ? '<span class="tag ai">AI</span>' : `<span class="tag">${i.src === "addendum" ? i.srcName : "人工添加"}</span>`;
const meta = src + i.cats.map((c) => `<span class="tag">${catName(c)}</span>`).join("") + `<span class="tag">${i.lot ? "采购包 " + i.lot : "通用"}</span>` +
(i.edited ? '<span class="flag">已修改 · <a class="lnk" href="#" data-demo>查看记录</a></span>' : "");
const upd = i.updated ? `<div class="upd"><span class="flag"><b>已被${i.srcName} 更新</b> · 原为 <span class="old">${esc(i.old)}</span></span><button class="btn" data-ack="${i.id}">已核对</button></div>` : "";
return `<div class="item ${sel === i.id ? "sel" : ""}" data-item="${i.id}" tabindex="${sel === i.id ? 0 : -1}"><span class="imp">${i.imp || ""}</span>
<div><div class="t">${esc(i.title)}</div><div class="meta">${meta}</div>${upd}</div>
<div class="side"><a class="cite-link" href="#" data-cite>${page(i.cite)}</a></div></div>`;
}
/* 待确认(§7.6):放在清单顶部,不和正式清单混排;条目是未确认的 AI 产出,左侧 2px 蓝铅笔虚线(Q2) */
function pendingBox() {
if (!pending.length) return "";
return `<section aria-label="待确认"><div class="grp-h ai"><b>待确认</b><span class="fig">${pending.length}</span><p>没找到可靠出处,或把握不大。确认前不参与检查。</p></div>
${pending.map((q) => `<div class="item pq" data-q="${q.id}"><span class="imp">${q.imp || ""}</span>
<div class="ai-block"><div class="t">${esc(q.title)}</div><div class="meta"><span class="tag ai">AI</span><span class="flag">${q.reason}</span></div>
<div class="f-actions">${q.cite ? '<button class="btn sm" data-qa="adopt">采纳</button>' : '<button class="btn sm" data-qa="cite">补上出处后采纳</button>'}<button class="btn sm" data-qa="edit">修改后采纳</button><button class="btn sm text" data-qa="drop">丢弃</button></div></div>
<div class="side">${q.cite ? `<a class="cite-link" href="#" data-qopen>${page(q.cite)}</a>` : '<span class="muted">无出处</span>'}</div></div>`).join("")}</section>`;
}
function list() {
const L = $("[data-list]");
const focusKw = document.activeElement?.matches?.("[data-kw]");
const tb = `<div class="lst-tb"><label class="search"><i data-ic="search"></i><input class="input" placeholder="搜索清单" aria-label="搜索清单" data-kw value="${esc(kw)}"></label><label class="choice cap"><input type="checkbox" class="ck" data-star ${star ? "checked" : ""}>只看 ★</label></div>`;
if (cat === "pending") {
L.innerHTML = (pendingBox() || '<div class="empty sm"><b>没有待确认的条目</b></div>') + '<div class="end"></div>';
} else {
const v = visible();
const groups = cat === "all" ? D.categories : D.categories.filter((c) => c.id === cat);
const body = groups.map((g) => { const rows = v.filter((i) => i.g === g.id); return rows.length ? `<section><div class="grp-h"><b>${g.name}</b><span class="fig">${rows.length}</span></div>${rows.map(itemRow).join("")}</section>` : ""; }).join("");
const head = `<p class="lot-note">通用 + 采购包 1 · 已隐藏采购包 2 的 <span class="fig">${D.hiddenLotItems}</span> 条 · <a class="lnk" href="#" data-demo>更改所投的包</a></p>` +
(cat === "all" && !kw && !star ? `<div class="rule"><i data-ic="info"></i><p>招标文件 <a class="cite-link" href="#" data-rule>第 <span class="fig">24</span> 页</a>:打「★」的为实质性要求。已识别 <span class="fig m">4</span> 条,请对照原文核对有无遗漏。</p></div>${pendingBox()}` : "");
L.innerHTML = tb + head + (body || `<div class="empty sm"><b>没有找到「${esc(kw)}」</b><p>只搜当前分类的条目标题。</p><button class="btn sm" data-clear>清除搜索</button></div>`) + '<div class="end"></div>';
}
Demo.hydrate(L);
if (focusKw) { const i = $("[data-kw]", L); i.focus(); i.setSelectionRange(i.value.length, i.value.length); }
}
function select(id, opt = {}) {
sel = id;
const i = items.find((x) => x.id === id);
$$(".lst .item[data-item]").forEach((x) => { const on = x.dataset.item === id; x.classList.toggle("sel", on); x.tabIndex = on ? 0 : -1; });
const el = $(`[data-item="${id}"]`);
if (el) { if (opt.scroll) el.scrollIntoView({ block: "nearest" }); if (opt.focus) el.focus({ preventScroll: true }); }
V.open(i.cite);
}
function step(d) {
const v = visible(); if (!v.length) return;
const i = v.findIndex((x) => x.id === sel);
const n = v[Math.max(0, Math.min(v.length - 1, (i < 0 ? -1 : i) + d))];
if (n) select(n.id, { scroll: true, focus: true });
}
function render() { status(); cats(); list(); }
/* ---------- 交互 ---------- */
document.addEventListener("click", (e) => {
const t = e.target;
if (t.closest("[data-demo]")) { e.preventDefault(); toast(DEMO, { ok: false }); return; }
if (t.closest("[data-cat]")) { cat = t.closest("[data-cat]").dataset.cat; render(); return; }
if (t.closest("[data-ack]")) { const i = items.find((x) => x.id === t.closest("[data-ack]").dataset.ack); i.updated = false; i.edited = true; list(); toast("已核对补遗更新,记录已保存"); return; }
if (t.closest("[data-rule]")) { e.preventDefault(); V.open(["T", 24, "t24-mark"]); return; }
if (t.closest("[data-qopen]")) { e.preventDefault(); V.open(pending.find((x) => x.id === t.closest("[data-q]").dataset.q).cite); return; }
if (t.closest("[data-qa]")) { pendingAct(t.closest("[data-q]").dataset.q, t.closest("[data-qa]").dataset.qa); return; }
if (t.closest("[data-clear]")) { kw = ""; list(); return; }
const it = t.closest("[data-item]");
if (it && !t.closest("button")) { e.preventDefault(); select(it.dataset.item, { focus: true }); }
});
document.addEventListener("input", (e) => { if (e.target.matches("[data-kw]")) { kw = e.target.value.trim(); list(); } });
document.addEventListener("change", (e) => { if (e.target.matches("[data-star]")) { star = e.target.checked; list(); } });
document.addEventListener("keydown", (e) => {
if (e.ctrlKey || e.metaKey || e.altKey || e.target.matches("input, textarea") || $(".scrim")) return;
if (e.key === "?") { Demo.keysDialog(Demo.KEYS.reading); return; }
if (cat === "pending") return;
if (e.key === "j" || e.key === "k") { e.preventDefault(); step(e.key === "j" ? 1 : -1); }
if ((e.key === "Enter" || e.key === "o") && (e.target.matches("[data-item]") || e.target === document.body)) { e.preventDefault(); select(sel, { scroll: true, focus: true }); }
});
/* 原文查看器宽度:.cmp[data-cmp] 由 demo.js 的 Demo.cmp() 处理(拖动或 ← →;清单不窄于 560) */
function pendingAct(id, act) {
const q = pending.find((x) => x.id === id);
if (act === "drop") { pending = pending.filter((x) => x !== q); render(); toast(`已丢弃「${esc(q.title)}」`, { undo: () => { pending.push(q); render(); } }); return; }
if (act === "adopt") { adopt(q, q.cite); return; }
dialog({
title: act === "cite" ? "补上出处后采纳" : "修改后采纳", wide: true, ok: "采纳",
body: `<div class="field fill"><label for="qt">要求 <small>(必填)</small></label><input class="input" id="qt" value="${esc(q.title)}"></div>
<div class="form" style="margin-top:var(--sp-4)"><div class="field"><label for="qp">出处页码 <small>(招标文件)</small></label><div class="input-unit"><input class="input" id="qp" placeholder="例如 26"><span class="unit">/ 186 页</span></div></div>
<div class="field"><label>&nbsp;</label><label class="choice"><input type="checkbox" class="ck" id="qn">找不到出处,按「人工确认、无出处」采纳</label></div></div>`,
onOk: (s) => { const pg = $("#qp", s).value.trim(); if (!pg && !$("#qn", s).checked) { const f = $("#qp", s).closest(".field"); f.classList.add("err"); $(".msg", f)?.remove(); f.insertAdjacentHTML("beforeend", '<span class="msg"><span class="ico w">!</span>填写页码,或勾选「人工确认、无出处」</span>'); return false; } q.title = $("#qt", s).value.trim() || q.title; adopt(q, pg ? ["T", +pg, ""] : null, !pg); }
});
}
function adopt(q, c, noCite) {
pending = pending.filter((x) => x !== q);
items.push({ id: "n" + q.id, cats: ["invalid"], imp: q.imp, title: q.title, lot: 0, src: "manual", edited: true, cite: c || ["T", 1, ""] });
render(); toast(noCite ? "已采纳,标注为「人工确认、无出处」" : "已采纳,进入正式清单");
}
$("[data-confirm]").onclick = () => dialog({
title: "确认清单 v3?", ok: "确认清单",
body: `<p>确认后,查标以这一版清单为依据。之后再改清单会回到草稿,历史保留,已有检查结果不受影响。</p>${pending.length ? `<div class="alert warn"><span class="ico w">!</span><div><p><b>还有 ${pending.length} 条待确认。</b>它们不进这一版清单,也不参与检查。</p></div></div>` : ""}`,
onOk: () => { confirmed = true; status(); toast("清单 v3 已确认"); }
});
$("[data-add]").onclick = () => dialog({
title: "新增条目", wide: true, ok: "添加",
body: `<div class="field fill" id="na-f"><label for="na">要求 <small>(必填)</small></label><textarea class="input" id="na" placeholder="写下招标文件里的要求"></textarea></div>
<div class="form" style="margin-top:var(--sp-4)"><div class="field"><label>分类</label><div class="pop" data-select><button type="button" class="select" aria-haspopup="listbox" aria-expanded="false"><span class="val">无效条款</span>${icon("chev-down")}</button><ul class="menu" role="listbox">${D.categories.map((c) => `<li role="option" ${c.id === "invalid" ? 'aria-selected="true"' : ""}>${c.name}</li>`).join("")}</ul></div></div>
<div class="field"><label for="np">出处页码 <small>(选填)</small></label><div class="input-unit"><input class="input" id="np"><span class="unit">/ 186 页</span></div></div></div>
<p class="cap muted">没有出处的条目标注「人工添加」。</p>`,
onOk: (s) => { const v = $("#na", s).value.trim(); const f = $("#na-f", s); if (!v) { f.classList.add("err"); $(".msg", f)?.remove(); f.insertAdjacentHTML("beforeend", '<span class="msg"><span class="ico w">!</span>请写下这条要求</span>'); return false; } items.push({ id: "u" + items.length, cats: ["invalid"], title: v, lot: 0, src: "manual", cite: ["T", +$("#np", s).value || 1, ""] }); render(); toast("已添加到「无效条款」"); }
});
$("[data-missing]").onclick = (e) => { e.preventDefault(); e.stopPropagation(); dialog({ title: "反馈遗漏", ok: "提交反馈", body: `<div class="field fill"><label for="mi">漏了什么?在第几页?</label><textarea class="input" id="mi" placeholder="例如:第 26 页要求提供原厂授权书,清单里没有"></textarea></div><p class="cap muted" style="margin-top:var(--sp-3)">反馈只用于改进提取。要加进清单,请用「新增条目」。</p>`, onOk: () => toast("已收到反馈") }); };
render();
select(sel);
</script>
</body>
</html>