Files
YANG JIANKUAN 819e66519a docs(prototype): MVP 原型按设计规范 v0.2 重刷
- 13 页全部重刷:96px 墨色渐变侧栏、透明顶栏、项目头与标签页、对照布局;查标交互与 UI demo 一致(行内说明、U 撤销当前条、批量仅提示级)
- assets/app.js、app.css 重写为 v0.2 框架与共用组件(查看器上一条 / 下一条与 clear、日期输入、浮动菜单、快捷键说明等);data.js 补任务与最近处理数据
- 额度页:Q1 墨色「+」、Q13 免扣行、续费申请按 G8;补遗 1 条数统一为「更新 2 条」
- 报告页脚与免责声明改为「辅助检查结果,仅供投标前自查参考」,design-system §7.17 与 FR-M3-09 同步;components.css 的 .scope 改名 .report-scope
- README 升 v0.2;AGENTS.md 同步进度与 VI 手册待办

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

517 lines
38 KiB
JavaScript
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.

/* 标博士 · UI demo 公共脚本 v0.2:图标、页面框架、轻提示、对话框、下拉、浮动菜单、日期、开关、标签页、排序。
* 只演示交互,不连后端。正式实现里这些交互件由 Reka UI 打底(AR10)。
* v0.2:Demo.shell() 生成定稿样张 explore/workbench-d5.html 的框架(96px 墨色渐变侧栏 + 透明顶栏 + 内容区圆角)。
* 收尾(2026-11-03):日期输入 Demo.dateInput()(.dt[data-dt] 自动初始化)、字段错误 Demo.fieldError()、
* 浮动菜单支持禁用项、快捷键说明按页面传入(Demo.KEYS)、对照布局拖动宽度 Demo.cmp()(.cmp[data-cmp] 自动初始化)。 */
(function () {
const $ = (s, el = document) => el.querySelector(s);
const $$ = (s, el = document) => [...el.querySelectorAll(s)];
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
/* Lucide 图标(ISC 许可)路径 */
const ICONS = {
grid: '<rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/>',
folder: '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
cert: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 9h10M7 13h6"/><circle cx="16" cy="15" r="2"/>',
gauge: '<path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/>',
bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
help: '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>',
x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
upload: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m17 8-5-5-5 5"/><path d="M12 3v12"/>',
download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/>',
file: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8M16 13H8M16 17H8"/>',
search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
"chev-down": '<path d="m6 9 6 6 6-6"/>',
"chev-left": '<path d="m15 18-6-6 6-6"/>',
"chev-right": '<path d="m9 18 6-6-6-6"/>',
sort: '<path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/>',
"arrow-up": '<path d="m5 12 7-7 7 7"/><path d="M12 19V5"/>',
"arrow-down": '<path d="M12 5v14"/><path d="m19 12-7 7-7-7"/>',
calendar: '<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>',
check: '<path d="M20 6 9 17l-5-5"/>',
more: '<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>',
filter: '<path d="M22 3H2l8 9.46V19l4 2v-8.54L22 3z"/>',
inbox: '<path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
clock: '<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>',
trash: '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
pencil: '<path d="M21.17 6.81a1 1 0 0 0-3.98-3.98L3.84 16.17a2 2 0 0 0-.5.83l-1.32 4.35a.5.5 0 0 0 .62.62l4.35-1.32a2 2 0 0 0 .83-.5z"/>',
history: '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
keyboard: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M8 12h.01M12 12h.01M16 12h.01M7 16h10"/>',
refresh: '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>',
send: '<path d="M14.54 21.69a.5.5 0 0 0 .94-.03l6.5-19a.5.5 0 0 0-.64-.64l-19 6.5a.5.5 0 0 0-.03.94l7.93 3.18a2 2 0 0 1 1.11 1.11z"/><path d="m21.85 2.15-10.94 10.94"/>',
list: '<path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/><path d="M13 6h8M13 12h8M13 18h8"/>',
paperclip: '<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
book: '<path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/>',
archive: '<rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/>',
layers: '<path d="m12.83 2.18 8.58 3.91a1 1 0 0 1 0 1.83l-8.58 3.9a2 2 0 0 1-1.66 0L2.6 7.92a1 1 0 0 1 0-1.83l8.58-3.9a2 2 0 0 1 1.66 0Z"/><path d="m22 12-9.17 4.17a2 2 0 0 1-1.66 0L2 12"/><path d="m22 17-9.17 4.17a2 2 0 0 1-1.66 0L2 17"/>',
house: '<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
award: '<path d="m15.477 12.89 1.515 8.526a.5.5 0 0 1-.81.47l-3.58-2.687a1 1 0 0 0-1.197 0l-3.586 2.686a.5.5 0 0 1-.81-.469l1.514-8.526"/><circle cx="12" cy="8" r="6"/>',
wallet: '<path d="M19 7V4a1 1 0 0 0-1-1H5a2 2 0 0 0 0 4h15a1 1 0 0 1 1 1v4h-3a2 2 0 0 0 0 4h3a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1"/><path d="M3 5v14a2 2 0 0 0 2 2h15a1 1 0 0 0 1-1v-4"/>',
"alert-circle": '<circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/>',
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/>',
"check-circle": '<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>',
eye: '<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/>'
};
const icon = (n, cls = "") => `<svg class="ic ${cls}" viewBox="0 0 24 24" aria-hidden="true">${ICONS[n] || ""}</svg>`;
function hydrate(root = document) {
$$("i[data-ic]", root).forEach((el) => { el.outerHTML = icon(el.dataset.ic, el.className); });
}
/* ---------- demo 顶部导航(控件页) ---------- */
const PAGES = [
["index.html", "目录"], ["controls.html", "① 控件"], ["forms.html", "② 表单"], ["cards.html", "③ 卡片"],
["lists.html", "④ 列表与表格"], ["page-workbench.html", "⑤ 工作台"], ["page-reading.html", "⑥ 读标"]
];
function demoTop(cur) {
const h = $("[data-demo-top]");
if (!h) return;
h.className = "d-top";
h.innerHTML = `<a class="brand" href="index.html"><img src="../logo/svg/logo-horizontal.svg" alt="标博士"></a><span class="tag">UI demo</span>
<nav class="d-nav" aria-label="demo 页面">${PAGES.map(([u, t]) => `<a href="${u}" class="${u === cur ? "on" : ""}">${t}</a>`).join("")}</nav>`;
}
/* ---------- 产品页面框架 v0.2(组合页) ----------
* opt.nav:当前菜单 workbench / quals / quota
* opt.crumbs:[[文字, 链接], …],最后一项是当前页;顶栏左侧显示「公司 / 页面」
* opt.search:{ placeholder, onInput(q) };不传 onInput 时回车只出演示提示。Ctrl K 聚焦
* opt.quota:{ parse: [剩余, 总数], check: [剩余, 总数] };URL 带 ?quota=warn / ?quota=out 可预览 80% 和用完(Q9)
* opt.plan:free / pro / ent(也可用 ?plan=) */
const ORG = "南京某信息技术有限公司";
function shell(opt = {}) {
const qs = new URLSearchParams(location.search);
const nav = [["page-workbench.html", "house", "工作台", "workbench"], ["#", "award", "资质库", "quals"], ["#", "wallet", "额度", "quota"]]
.map(([h, i, t, k]) => `<a href="${h}" ${opt.nav === k ? 'aria-current="page"' : ""} ${h === "#" ? "data-demo-nav" : ""}>${icon(i)}<span>${t}</span></a>`).join("");
const crumbs = (opt.crumbs || []).map((c, i, a) => i < a.length - 1 && c[1] ? `<a href="${c[1]}" title="${esc(c[0])}">${esc(c[0])}</a><span aria-hidden="true">/</span>` : `<b title="${esc(c[0])}">${esc(c[0])}</b>${i < a.length - 1 ? '<span aria-hidden="true">/</span>' : ""}`).join("");
const PLAN = { free: "免费版", pro: "专业版", ent: "企业版" };
const plan = PLAN[qs.get("plan")] ? qs.get("plan") : (opt.plan || "pro");
const quota = Object.assign({ parse: [37, 50], check: [12, 20] }, opt.quota || {});
if (qs.get("quota") === "warn") quota.check = [3, 20];
if (qs.get("quota") === "out") quota.check = [0, 20];
const qItem = (name, [left, total]) => `<span class="q ${(total - left) / total >= 0.8 ? "warn" : ""}">${name}<span class="fig">${left}</span></span>`;
const out = [["解析", quota.parse], ["检查", quota.check]].filter(([, [l]]) => l <= 0).map(([n]) => n);
const qTip = `本期剩余额度:解析 ${quota.parse[0]} / ${quota.parse[1]} 次,检查 ${quota.check[0]} / ${quota.check[1]} 次` +
(out.length ? `。本期${out.join("、")}额度已用完,可在额度页提交续费申请,或等下期发放` : "");
document.body.classList.add("app");
document.body.insertAdjacentHTML("afterbegin", `<aside class="sidebar">
<a class="brand" href="page-workbench.html" aria-label="标博士 · 工作台"><img src="../logo/svg/logo-vertical-reverse.svg" alt=""></a>
<nav class="nav" aria-label="主导航">${nav}<span class="sep" role="separator"></span><a href="index.html">${icon("book")}<span>demo 目录</span></a></nav>
<div class="me"><span class="avatar" aria-hidden="true">王</span><b class="uname">王敏</b><span class="plan" data-plan="${plan}">${PLAN[plan]}</span></div></aside>`);
const main = $("main");
main.classList.add("main");
main.insertAdjacentHTML("afterbegin", `<header class="top">
<div class="crumbs"><span class="org">${ORG}</span><span aria-hidden="true">/</span>${crumbs}</div>
<form class="top-search" role="search" data-gsearch>${icon("search")}<input type="search" placeholder="${esc((opt.search && opt.search.placeholder) || "搜索项目、采购人")}" aria-label="${esc((opt.search && opt.search.placeholder) || "搜索项目、采购人")}" aria-keyshortcuts="Control+K" data-gq><span class="kbd" aria-hidden="true">Ctrl K</span></form>
<span class="vsep" aria-hidden="true"></span>
<div class="top-right">
<a class="quota-chip tip-below tip-left" href="#" data-tip="${qTip}" aria-label="${qTip}" data-demo-nav>${qItem("解析", quota.parse)}<span class="sep"></span>${qItem("检查", quota.check)}</a>
<button class="icon-btn tip-below tip-left" aria-label="通知(1 条未读)" data-tip="通知" data-notice>${icon("bell", "lg")}<span class="dot"></span></button>
</div></header>`);
const top = $(".top", main), gq = $("[data-gq]", main);
const onScroll = () => top.classList.toggle("scrolled", (main.scrollTop || window.scrollY) > 4);
addEventListener("scroll", onScroll, { passive: true }); main.addEventListener("scroll", onScroll, { passive: true }); onScroll();
$("[data-gsearch]", main).addEventListener("submit", (e) => { e.preventDefault(); if (!(opt.search && opt.search.onInput)) toast("(演示)全局搜索:这里只做了样式", { ok: false }); });
if (opt.search && opt.search.onInput) gq.addEventListener("input", () => opt.search.onInput(gq.value.trim()));
document.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") { e.preventDefault(); gq.focus(); }
else if (e.key === "Escape" && document.activeElement === gq) gq.blur();
});
document.addEventListener("click", (e) => { if (e.target.closest("[data-demo-nav]")) { e.preventDefault(); toast("(演示)这一页没有做", { ok: false }); } });
$("[data-notice]").onclick = () => toast("解析完成:补遗 1 已并入清单(更新 2 条)。");
}
/* ---------- 浮动菜单:列表行的「更多」等(role=menu,↑ ↓ 移动,Esc 关闭,焦点回到触发按钮) ----------
* items:[{ label, sub, act, disabled }] 或 "-"(分隔线)
* sub:第二行说明,可以是 HTML(如扣点说明);
* disabled:不可用的原因(如「递交后可用」),该项变禁用灰、右侧写原因,点了不触发;
* onPick(act, item)。Demo.menuHTML(items) 生成同样的菜单项,供陈列页静态展示。 */
function menuHTML(items) {
return items.map((it, i) => it === "-" ? "<hr>" : `<button type="button" role="menuitem" class="${it.sub ? "two" : ""} ${it.active ? "is-active" : ""}" data-i="${i}"${it.disabled ? ' aria-disabled="true"' : ""}>${esc(it.label)}${it.sub ? `<small>${it.sub}</small>` : it.disabled ? `<small>${esc(it.disabled)}</small>` : ""}</button>`).join("");
}
let fm = null;
function closeFloatMenu(focusBack) {
if (!fm) return;
fm.el.remove(); fm.btn.setAttribute("aria-expanded", "false");
if (focusBack) fm.btn.focus();
fm = null;
}
function floatMenu(btn, items, onPick) {
if (fm && fm.btn === btn) return closeFloatMenu();
closeFloatMenu();
const el = document.createElement("div");
el.className = "menu float"; el.setAttribute("role", "menu");
el.innerHTML = menuHTML(items);
document.body.appendChild(el);
const r = btn.getBoundingClientRect();
const below = r.bottom + 4 + el.offsetHeight < innerHeight;
el.style.top = `${below ? r.bottom + 4 : r.top - 4 - el.offsetHeight}px`;
el.style.left = `${Math.max(8, r.right - el.offsetWidth)}px`;
btn.setAttribute("aria-expanded", "true");
fm = { el, btn };
el.addEventListener("click", (e) => { const b = e.target.closest("[data-i]"); if (!b) return; e.stopPropagation(); const it = items[+b.dataset.i]; if (it.disabled) return; closeFloatMenu(true); if (onPick) onPick(it.act || it.label, it); });
el.addEventListener("keydown", (e) => {
const its = $$("[role=menuitem]", el), k = its.indexOf(document.activeElement);
if (e.key === "ArrowDown") { e.preventDefault(); its[(k + 1) % its.length].focus(); }
if (e.key === "ArrowUp") { e.preventDefault(); its[(k - 1 + its.length) % its.length].focus(); }
if (e.key === "Escape") { e.stopPropagation(); closeFloatMenu(true); }
if (e.key === "Tab") closeFloatMenu();
});
$("[role=menuitem]", el).focus();
}
document.addEventListener("click", (e) => { if (fm && !fm.el.contains(e.target) && !fm.btn.contains(e.target)) closeFloatMenu(); });
addEventListener("scroll", () => closeFloatMenu(), { passive: true, capture: true });
addEventListener("resize", () => closeFloatMenu());
/* ---------- 快捷键说明(§8.2):按页面传入,只列本页能用的键 ----------
* Demo.keysDialog(Demo.KEYS.reading) 读标:J / K、Enter、?
* Demo.keysDialog(Demo.KEYS.check) 查标(问题列表):J / K、O / Enter、1 / 2 / 3、X、U、?
* 也可以传自己的 [[键的 HTML, 说明], …];Demo.keysTable(list) 生成同样的表格供静态展示。 */
const kb = (k) => `<span class="kbd">${k}</span>`;
const KEY = {
jk: [`${kb("J")} / ${kb("K")}`, "下一条 / 上一条"],
enter: [kb("Enter"), "在原文查看器里打开当前条的出处"],
open: [`${kb("O")} 或 ${kb("Enter")}`, "在原文查看器里打开当前出处"],
act: [`${kb("1")} ${kb("2")} ${kb("3")}`, "确认无误 / 已修改 / 忽略(忽略、致命的确认无误在行内写说明)"],
x: [kb("X"), "勾选当前条(只限「提示」)"],
undo: [kb("U"), "撤销刚才的处理"],
help: [kb("?"), "显示本说明"]
};
const KEYS = { reading: [KEY.jk, KEY.enter, KEY.help], check: [KEY.jk, KEY.open, KEY.act, KEY.x, KEY.undo, KEY.help] };
const keysTable = (list) => `<table class="data keys"><tbody>${list.map(([k, t]) => `<tr><td class="nw">${k}</td><td>${t}</td></tr>`).join("")}</tbody></table>`;
function keysDialog(list = KEYS.check) {
dialog({ title: "快捷键", cancel: null, ok: "知道了", body: keysTable(list) });
}
/* ---------- 轻提示:4 秒消失,悬停暂停 ---------- */
function toast(msg, opt = {}) {
let box = $(".toasts");
if (!box) { box = document.createElement("div"); box.className = "toasts"; box.setAttribute("role", "status"); box.setAttribute("aria-live", "polite"); document.body.appendChild(box); }
const t = document.createElement("div");
t.className = "toast";
t.innerHTML = `${opt.ok === false ? "" : '<span class="ok" aria-hidden="true">✓</span>'}<span>${msg}</span>${opt.undo ? '<span class="sep"></span><button class="lnk" data-undo>撤销</button>' : ""}`;
box.appendChild(t);
let left = 4000, start = Date.now(), timer = setTimeout(() => t.remove(), left);
t.addEventListener("mouseenter", () => { clearTimeout(timer); left -= Date.now() - start; });
t.addEventListener("mouseleave", () => { start = Date.now(); timer = setTimeout(() => t.remove(), Math.max(left, 1200)); });
if (opt.undo) $("[data-undo]", t).onclick = () => { t.remove(); opt.undo(); };
}
/* ---------- 对话框:Esc 关闭、焦点回到触发处 ---------- */
function dialog({ title, body, ok = "确定", cancel = "取消", wide = false, onOk, onOpen, footLeft = "" }) {
const back = document.activeElement;
const s = document.createElement("div");
s.className = "scrim";
s.innerHTML = `<div class="dialog ${wide ? "wide" : ""}" role="dialog" aria-modal="true" aria-labelledby="dlg-t">
<h2 id="dlg-t">${title}</h2><button class="icon-btn sm x" aria-label="关闭" data-x>${icon("x")}</button>
<div class="body">${body}</div>
<div class="foot">${footLeft ? `<span class="left">${footLeft}</span>` : ""}${cancel ? `<button class="btn" data-cancel>${cancel}</button>` : ""}<button class="btn primary" data-ok>${ok}</button></div></div>`;
document.body.appendChild(s);
document.body.style.overflow = "hidden";
const close = () => { s.remove(); document.body.style.overflow = ""; document.removeEventListener("keydown", onKey, true); if (back && back.focus) back.focus(); };
const onKey = (e) => {
if (e.key === "Escape") {
e.stopPropagation();
const op = $(".pop.open", s); // 先收起对话框里展开的下拉 / 日期选择器
if (op) { closePops(); ($("input, .select", op) || op).focus(); return; }
close();
}
if (e.key === "Tab") { // 焦点锁定在对话框内
const f = $$("button:not([disabled]), input:not([disabled]), textarea, select, a[href]", s);
if (!f.length) return;
if (e.shiftKey && document.activeElement === f[0]) { e.preventDefault(); f[f.length - 1].focus(); }
else if (!e.shiftKey && document.activeElement === f[f.length - 1]) { e.preventDefault(); f[0].focus(); }
}
};
document.addEventListener("keydown", onKey, true);
s.addEventListener("mousedown", (e) => { if (e.target === s) close(); });
$("[data-x]", s).onclick = close;
if (cancel) $("[data-cancel]", s).onclick = close;
$("[data-ok]", s).onclick = () => { if (!onOk || onOk(s) !== false) close(); };
hydrate(s); init(s);
if (onOpen) onOpen(s);
const f = $("textarea, input:not([type=checkbox]):not([type=radio]), [data-ok]", s);
if (f) f.focus();
return s;
}
/* ---------- 下拉选择(listbox)与下拉菜单 ---------- */
function closePops(except) {
$$(".pop.open").forEach((p) => { if (p !== except) { p.classList.remove("open"); const t = $("[aria-expanded]", p); if (t) t.setAttribute("aria-expanded", "false"); const m = $(".menu,.cal", p); if (m) m.hidden = true; } });
}
function initSelect(p) {
const trg = $(".select, [data-menu-trigger]", p), menu = $(".menu", p);
if (!trg || !menu || p.dataset.ready) return;
p.dataset.ready = 1;
menu.hidden = true;
const opts = () => $$('[role=option]:not([aria-disabled="true"]), [role=menuitem]:not([aria-disabled="true"])', menu);
let ai = -1;
const mark = (i) => { opts().forEach((o, k) => o.classList.toggle("is-active", k === i)); ai = i; const o = opts()[i]; if (o) o.scrollIntoView({ block: "nearest" }); };
const open = () => { closePops(p); p.classList.add("open"); menu.hidden = false; trg.setAttribute("aria-expanded", "true"); const sel = opts().findIndex((o) => o.getAttribute("aria-selected") === "true"); mark(sel < 0 ? 0 : sel); };
const close = () => { p.classList.remove("open"); menu.hidden = true; trg.setAttribute("aria-expanded", "false"); };
const pick = (o) => {
if (o.getAttribute("role") === "option") {
$$("[role=option]", menu).forEach((x) => x.setAttribute("aria-selected", x === o ? "true" : "false"));
const v = $(".val", trg); if (v) v.innerHTML = o.dataset.label || o.firstChild.textContent;
p.closest(".field")?.classList.remove("err");
p.dispatchEvent(new CustomEvent("change", { detail: o.dataset.value || o.textContent }));
} else if (o.dataset.toast) toast(o.dataset.toast);
close(); trg.focus();
};
trg.addEventListener("click", (e) => { e.stopPropagation(); p.classList.contains("open") ? close() : open(); });
trg.addEventListener("keydown", (e) => {
const n = opts().length;
if (["ArrowDown", "ArrowUp"].includes(e.key)) { e.preventDefault(); if (!p.classList.contains("open")) return open(); mark((ai + (e.key === "ArrowDown" ? 1 : n - 1)) % n); }
else if (e.key === "Enter" || e.key === " ") { if (p.classList.contains("open") && ai >= 0) { e.preventDefault(); pick(opts()[ai]); } }
else if (e.key === "Escape" && p.classList.contains("open")) { e.stopPropagation(); close(); }
});
menu.addEventListener("click", (e) => { const o = e.target.closest('[role=option], [role=menuitem]'); if (o && o.getAttribute("aria-disabled") !== "true") { e.stopPropagation(); pick(o); } });
}
/* ---------- 日期选择(§7.22) ----------
* 周一开始;非本月的格子留空;不可选日期(opt.min 之前)禁用灰、无悬停;今天描边;关键日期(opt.marks)下方小圆点。
* 日期键统一写成不补零的「2026-11-3」。 */
const WD = ["一", "二", "三", "四", "五", "六", "日"];
const TODAY = new Date(2026, 10, 3), TODAY_KEY = "2026-11-3"; // 演示里的「今天」
const pad2 = (n) => String(n).padStart(2, "0");
const fig = (v) => `<span class="fig">${v}</span>`;
const keyOf = (d) => `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
function calHTML(y, m, sel, opt = {}) {
const start = (new Date(y, m, 1).getDay() + 6) % 7, days = new Date(y, m + 1, 0).getDate();
let cells = "";
for (let i = 0, n = Math.ceil((start + days) / 7) * 7; i < n; i++) {
const d = i - start + 1;
if (d < 1 || d > days) { cells += '<span class="pad" aria-hidden="true"></span>'; continue; }
const date = new Date(y, m, d), key = keyOf(date);
const cls = [key === (opt.today || TODAY_KEY) ? "today" : "", (opt.marks || []).includes(key) ? "mark" : ""].join(" ").trim();
cells += `<button type="button" class="${cls}" data-d="${key}" aria-selected="${key === sel}" ${opt.min && date < opt.min ? "disabled" : ""} tabindex="-1">${d}</button>`;
}
const note = opt.note !== undefined ? opt.note : (opt.marks || []).length ? "圆点是本项目的关键日期" : "";
return `<div class="cal-h"><button type="button" class="icon-btn sm" data-cm="-1" aria-label="上个月">${icon("chev-left")}</button><b><span class="fig">${y}</span> 年 <span class="fig">${m + 1}</span> 月</b><button type="button" class="icon-btn sm" data-cm="1" aria-label="下个月">${icon("chev-right")}</button></div>
<div class="cal-grid" role="grid">${WD.map((w) => `<span class="wd">${w}</span>`).join("")}${cells}</div>
<div class="cal-f">${opt.time ? `时间 <input class="input time" value="${opt.time}" aria-label="时间(24 小时制)" inputmode="numeric">` : `<span>${note}</span>`}<button type="button" class="btn sm text" data-today>今天</button></div>`;
}
/* 只有日历、没有输入框的陈列(.pop.d-open[data-date]) */
function initDate(p) {
if (p.dataset.ready) return;
p.dataset.ready = 1;
const cal = $(".cal", p);
let [y, m] = (p.dataset.month || p.dataset.value || "2026-11").split("-").map(Number); m -= 1;
let sel = p.dataset.value ? keyOf(parseDT(p.dataset.value, false).dt) : "";
const opt = { marks: (p.dataset.marks || "").split(",").filter(Boolean).map((k) => keyOf(parseDT(k, false).dt)), time: p.dataset.time, min: p.dataset.min ? parseDT(p.dataset.min, false).dt : null };
const render = () => { cal.innerHTML = calHTML(y, m, sel, opt); };
render();
cal.addEventListener("click", (e) => {
e.stopPropagation();
const b = e.target.closest("button"); if (!b) return;
if (b.dataset.cm) { m += +b.dataset.cm; if (m < 0) { m = 11; y--; } if (m > 11) { m = 0; y++; } render(); return; }
if (b.dataset.today) { y = 2026; m = 10; sel = TODAY_KEY; }
else if (b.dataset.d) sel = b.dataset.d;
else return;
render();
});
}
/* ---------- 日期输入(Q10):.dt[data-dt] ----------
* <div class="field"><label for="x">投标截止 <small>(必填)</small></label>
* <div class="dt" data-dt data-time="09:30" data-left data-req="请填写投标截止时间">
* <input class="input" id="x" placeholder="例如:2026-11-20 09:30" autocomplete="off">
* <button type="button" class="icon-btn sm" aria-label="打开日期选择器" aria-haspopup="dialog" aria-expanded="false"><i data-ic="calendar"></i></button>
* <div class="cal" role="dialog" aria-label="选择日期" hidden></div></div>
* <span class="msg" data-dt-msg>24 小时制</span></div>
* data-time:带时间(24 小时制,选择器底部填时间);data-left:下方写「· 剩 N 天」;data-req:必填,空着时的错误说明;
* data-marks / data-min / data-note:关键日期圆点 / 最早可选日期 / 选择器底部的圆点说明;类名加 d-open:选择器常开(陈列用)。
* 可直接键入,接受「2026/11/20 9:30」「2026.11.20 9:30」「2026年11月20日」等写法,离开时整理成「2026-11-20 09:30」,
* 下方 [data-dt-msg] 显示界面写法「11月20日 09:30 · 剩 17 天」。没碰过的字段离开时不报错;报错之后边输入边复查。
* 每次整理后在 .dt 上派发 dt-change(detail:{ ok, empty, val, dt, days });p.dt.commit({ required }) 供提交时调用。 */
function parseDT(raw, withTime) {
const s = String(raw || "").trim().replace(/[\/.年月]/g, "-").replace(/日/g, "").replace(/:/g, ":").replace(/\s+/g, " ");
const eg = withTime ? "2026-11-20 09:30" : "2026-11-20";
const m = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?: ?(\d{1,2}):(\d{1,2}))?$/);
if (!m) return { msg: `请按「${eg}」的格式填写` };
const [, y, mo, d, hh, mi] = m.map((v) => v === undefined ? v : +v);
const dt = new Date(y, mo - 1, d);
if (dt.getMonth() !== mo - 1 || dt.getDate() !== d) return { msg: `${y} 年 ${mo} 月没有 ${d} 日,请核对日期` };
if (withTime && hh === undefined) return { msg: `请补上时间,例如「${y}-${pad2(mo)}-${pad2(d)} 09:30」` };
if (withTime && hh > 23) return { msg: "时间用 24 小时制,小时在 00–23 之间" };
if (withTime && mi > 59) return { msg: "分钟在 00–59 之间" };
const time = withTime ? `${pad2(hh)}:${pad2(mi)}` : "";
const val = `${y}-${pad2(mo)}-${pad2(d)}` + (withTime ? ` ${time}` : "");
const days = Math.round((dt - TODAY) / 864e5);
const disp = `${y !== TODAY.getFullYear() ? fig(y) + "年" : ""}${fig(mo)}月${fig(d)}日` + (withTime ? ` ${fig(time)}` : "");
return { val, dt, days, disp, key: keyOf(dt), time };
}
/* 字段错误(§7.13):赭色边框 + 下方「!」说明;其他说明和提醒暂时隐藏。msg 为空时清除。返回是否无错 */
let uid = 0;
function fieldError(f, msg) {
if (!f) return !msg;
f.classList.toggle("err", !!msg);
const inp = $("input:not([type=checkbox]):not([type=radio]), textarea", f); if (inp) inp.toggleAttribute("aria-invalid", !!msg);
let m = $(":scope > .msg.e", f);
if (msg) {
if (!m) { m = document.createElement("span"); m.className = "msg e"; m.id = (f.id || "fld" + ++uid) + "-err"; f.appendChild(m); }
m.innerHTML = `<span class="ico w" aria-hidden="true">!</span>${msg}`;
$$(":scope > .msg:not(.e), :scope > .hint-warn", f).forEach((x) => { x.hidden = true; });
if (inp) inp.setAttribute("aria-describedby", m.id);
} else {
if (m) m.remove();
$$(":scope > .msg, :scope > .hint-warn:not([data-off])", f).forEach((x) => { x.hidden = false; });
const d = $(":scope > .msg[id]", f); if (inp) { if (d) inp.setAttribute("aria-describedby", d.id); else inp.removeAttribute("aria-describedby"); }
}
return !msg;
}
function dateInput(p) {
if (p.dataset.ready) return;
p.dataset.ready = 1; p.classList.add("pop");
const inp = $("input", p), btn = $(":scope > .icon-btn", p), cal = $(".cal", p), f = p.closest(".field");
const msg = f && $("[data-dt-msg]", f), def = msg ? msg.innerHTML : "";
if (msg && !msg.id) msg.id = (inp.id || "dt" + ++uid) + "-msg";
if (msg) inp.setAttribute("aria-describedby", msg.id);
const withTime = p.dataset.time !== undefined, still = p.classList.contains("d-open");
const opt = { marks: (p.dataset.marks || "").split(",").filter(Boolean).map((k) => keyOf(parseDT(k, false).dt)), min: p.dataset.min ? parseDT(p.dataset.min, false).dt : null, note: p.dataset.note };
let y = TODAY.getFullYear(), m = TODAY.getMonth(), sel = "", time = p.dataset.time || "09:00", dirty = false;
const left = (r) => p.dataset.left === undefined ? "" : r.days > 0 ? ` · 剩 ${fig(r.days)} 天` : r.days === 0 ? " · 今天截止" : " · 已截止";
const sync = () => { const r = parseDT(inp.value, withTime); if (r.val) { sel = r.key; y = r.dt.getFullYear(); m = r.dt.getMonth(); if (r.time) time = r.time; } };
const render = () => { cal.innerHTML = calHTML(y, m, sel, { ...opt, time: withTime ? time : undefined }); };
function commit(o = {}) {
if (inp.disabled) { fieldError(f, ""); return true; }
const v = inp.value.trim(), r = v ? parseDT(v, withTime) : {};
let err = "";
if (!v) { err = o.required && p.dataset.req ? p.dataset.req : ""; if (msg) msg.innerHTML = def; }
else if (r.msg) err = r.msg;
else { inp.value = r.val; if (msg) msg.innerHTML = r.disp + left(r); }
fieldError(f, err);
p.dispatchEvent(new CustomEvent("dt-change", { bubbles: true, detail: { ok: !err, empty: !v, ...r } }));
return !err;
}
p.dt = { commit, parse: () => parseDT(inp.value, withTime) };
const close = (focus) => { p.classList.remove("open", "up"); cal.hidden = true; btn.setAttribute("aria-expanded", "false"); if (focus) inp.focus(); };
const open = () => {
closePops(p); closeFloatMenu();
sync(); render(); p.classList.add("open"); cal.hidden = false; btn.setAttribute("aria-expanded", "true");
const r = p.getBoundingClientRect();
p.classList.toggle("up", r.bottom + cal.offsetHeight + 8 > innerHeight && r.top > cal.offsetHeight + 8);
($("[aria-selected=true]", cal) || $(".today", cal) || $(".cal-grid button:not([disabled])", cal)).focus();
};
const write = () => { if (!sel) return; const [a, b, c] = sel.split("-"); inp.value = `${a}-${pad2(b)}-${pad2(c)}` + (withTime ? ` ${time}` : ""); dirty = true; commit(); };
if (still) { sync(); render(); } else cal.hidden = true;
if (inp.value.trim()) commit();
btn.addEventListener("click", (e) => { e.stopPropagation(); if (still) return; p.classList.contains("open") ? close(true) : open(); });
cal.addEventListener("click", (e) => {
e.stopPropagation();
const b = e.target.closest("button"); if (!b || b.disabled) return;
if (b.dataset.cm) { m += +b.dataset.cm; if (m < 0) { m = 11; y--; } if (m > 11) { m = 0; y++; } render(); return; }
if (b.dataset.today) { y = TODAY.getFullYear(); m = TODAY.getMonth(); sel = TODAY_KEY; }
else if (b.dataset.d) sel = b.dataset.d; else return;
render(); write();
if (!still && !withTime) close(true);
else ($(`[data-d="${sel}"]`, cal) || b).focus();
});
cal.addEventListener("input", (e) => {
if (!e.target.classList.contains("time")) return;
const t = e.target.value.trim().replace(":", ":").match(/^(\d{1,2}):(\d{2})$/);
if (t && +t[1] < 24 && +t[2] < 60) { time = `${pad2(t[1])}:${t[2]}`; write(); }
});
cal.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !still) { e.stopPropagation(); close(true); return; }
if (e.key === "Enter" && e.target.classList.contains("time")) { e.preventDefault(); if (!still) close(true); return; }
const cells = $$(".cal-grid button:not([disabled])", cal), i = cells.indexOf(document.activeElement);
const d = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 }[e.key];
if (i >= 0 && d) { e.preventDefault(); (cells[i + d] || cells[i]).focus(); }
});
inp.addEventListener("keydown", (e) => { if (e.key === "ArrowDown" && e.altKey) { e.preventDefault(); open(); } });
inp.addEventListener("input", () => {
dirty = true;
if (f && f.classList.contains("err")) { const v = inp.value.trim(); if (v ? !parseDT(v, withTime).msg : !p.dataset.req) fieldError(f, ""); }
});
inp.addEventListener("blur", (e) => {
if (e.relatedTarget && p.contains(e.relatedTarget)) return; // 焦点进了日期选择器
if (dirty || (f && f.classList.contains("err"))) commit({ required: true });
});
}
/* ---------- 对照布局(§5.3):拖动或用 ← → 调整原文查看器宽度;清单不窄于 --list-min,查看器不窄于 --viewer-min ---------- */
function cmp(el) {
if (el.dataset.ready) return;
el.dataset.ready = 1;
const r = $(":scope > .rsz", el); if (!r) return;
const num = (v) => parseFloat(getComputedStyle(el).getPropertyValue(v)) || 0;
const box = () => { const b = el.getBoundingClientRect(), cs = getComputedStyle(el); return { right: b.right - parseFloat(cs.paddingRight), w: b.width - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight), gap: r.offsetWidth }; };
const cur = () => $(":scope > .viewer", el).offsetWidth;
function setW(px) {
const { w, gap } = box(), max = w - gap - num("--list-min");
px = Math.round(Math.max(num("--viewer-min"), Math.min(max, px)));
el.style.setProperty("--vw", px + "px");
r.setAttribute("aria-valuenow", px);
}
r.setAttribute("aria-valuemin", num("--viewer-min"));
r.addEventListener("pointerdown", (e) => {
e.preventDefault(); r.setPointerCapture(e.pointerId); r.classList.add("on"); document.body.classList.add("dragging");
const mv = (ev) => { const { right, gap } = box(); setW(right - ev.clientX - gap / 2); };
const up = () => { r.classList.remove("on"); document.body.classList.remove("dragging"); r.removeEventListener("pointermove", mv); r.removeEventListener("pointerup", up); };
r.addEventListener("pointermove", mv); r.addEventListener("pointerup", up);
});
r.addEventListener("keydown", (e) => { if (e.key === "ArrowLeft" || e.key === "ArrowRight") { e.preventDefault(); setW(cur() + (e.key === "ArrowLeft" ? 32 : -32)); } });
addEventListener("resize", () => { if (el.style.getPropertyValue("--vw")) setW(cur()); });
}
/* ---------- 开关、分段、标签页、半选、排序、加载按钮 ---------- */
function init(root = document) {
$$(".pop[data-select], .pop[data-menu]", root).forEach(initSelect);
$$(".pop[data-date]", root).forEach(initDate);
$$(".dt[data-dt]", root).forEach(dateInput);
$$(".cmp[data-cmp]", root).forEach(cmp);
$$(".switch:not([data-ready])", root).forEach((s) => {
s.dataset.ready = 1;
const upd = () => { const st = $(".st", s); if (st) st.textContent = s.getAttribute("aria-checked") === "true" ? "开" : "关"; };
upd();
s.addEventListener("click", () => { if (s.disabled) return; s.setAttribute("aria-checked", s.getAttribute("aria-checked") === "true" ? "false" : "true"); upd(); });
});
$$(".seg:not([data-ready])", root).forEach((g) => {
g.dataset.ready = 1;
g.addEventListener("click", (e) => { const b = e.target.closest("button"); if (!b) return; $$("button", g).forEach((x) => x.setAttribute("aria-pressed", x === b)); g.dispatchEvent(new CustomEvent("change", { detail: b.dataset.v })); });
});
$$("[role=tablist]:not([data-ready])", root).forEach((tl) => {
tl.dataset.ready = 1;
const tabs = $$("[role=tab]", tl);
const sel = (t) => { tabs.forEach((x) => { const on = x === t; x.setAttribute("aria-selected", on); x.tabIndex = on ? 0 : -1; const pn = x.getAttribute("aria-controls"); if (pn) document.getElementById(pn).hidden = !on; }); };
tabs.forEach((t) => t.addEventListener("click", () => { if (t.getAttribute("aria-disabled") !== "true") sel(t); }));
tl.addEventListener("keydown", (e) => {
const i = tabs.indexOf(document.activeElement); if (i < 0) return;
const d = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0; if (!d) return;
e.preventDefault(); let j = i; do { j = (j + d + tabs.length) % tabs.length; } while (tabs[j].getAttribute("aria-disabled") === "true");
tabs[j].focus(); sel(tabs[j]);
});
});
$$("input[data-indeterminate]", root).forEach((c) => { c.indeterminate = true; });
$$("[data-loading]:not([data-ready])", root).forEach((b) => {
b.dataset.ready = 1;
b.addEventListener("click", () => {
if (b.classList.contains("is-loading")) return;
b.classList.add("is-loading"); b.setAttribute("aria-busy", "true");
setTimeout(() => { b.classList.remove("is-loading"); b.removeAttribute("aria-busy"); if (b.dataset.loading) toast(b.dataset.loading); }, 2200);
});
});
}
document.addEventListener("click", () => closePops());
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closePops(); });
/* 可排序表格:th[data-sort] 里放 button.th-sort;按 data-sort 指定的列取 td 的 data-v 或文字 */
function sortable(table, onSort) {
$$("th[data-sort]", table).forEach((th) => {
const b = $(".th-sort", th);
b.insertAdjacentHTML("beforeend", icon("sort"));
b.addEventListener("click", () => {
const dir = th.getAttribute("aria-sort") === "ascending" ? "descending" : "ascending";
$$("th[aria-sort]", table).forEach((x) => { x.removeAttribute("aria-sort"); $("svg", x).outerHTML = icon("sort"); });
th.setAttribute("aria-sort", dir);
$("svg", th).outerHTML = icon(dir === "ascending" ? "arrow-up" : "arrow-down");
onSort(th.dataset.sort, dir);
});
});
}
window.Demo = { $, $$, esc, icon, hydrate, demoTop, shell, toast, dialog, init, sortable, keysDialog, keysTable, KEYS, calHTML, parseDT, fieldError, dateInput, cmp, floatMenu, closeFloatMenu, menuHTML };
document.addEventListener("DOMContentLoaded", () => { hydrate(); init(); });
})();