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>
This commit is contained in:
YANG JIANKUAN
2026-11-03 17:01:08 +08:00
parent 7f9592ad16
commit 819e66519a
24 changed files with 3601 additions and 1102 deletions

View File

@@ -152,4 +152,6 @@ samples/
- 2026-11-03:技术验证 S1–S4 完成(`docs/architecture/tech-validation.md`,代码在 `spikes/`),四项都可行。要点:Word 转 PDF 要修正 WPS 字体字符集并改用行高调整过的字体;格式识别不需要 Docling;表格内切句要改进;DeepSeek 抽取要加第三道依据校验和规则兜底,单项抽取每份约 0.15 元。FR-M2-05 和 FR-M2-06 的目标值按建议确认(TV1)。 - 2026-11-03:技术验证 S1–S4 完成(`docs/architecture/tech-validation.md`,代码在 `spikes/`),四项都可行。要点:Word 转 PDF 要修正 WPS 字体字符集并改用行高调整过的字体;格式识别不需要 Docling;表格内切句要改进;DeepSeek 抽取要加第三道依据校验和规则兜底,单项抽取每份约 0.15 元。FR-M2-05 和 FR-M2-06 的目标值按建议确认(TV1)。
- 2026-11-03:UI 风格定稿(U5)。工作台经过六轮方案(`docs/design/ui-demo/explore/`),定为 D5:白色主调、墨色渐变窄侧栏、透明顶栏、圆角三档、只用功能图标、少写说明。设计规范和 `tokens.css` 升到 v0.2。 - 2026-11-03:UI 风格定稿(U5)。工作台经过六轮方案(`docs/design/ui-demo/explore/`),定为 D5:白色主调、墨色渐变窄侧栏、透明顶栏、圆角三档、只用功能图标、少写说明。设计规范和 `tokens.css` 升到 v0.2。
- 2026-11-03:UI demo 全部按 v0.2 重刷(控件、表单、卡片、列表、工作台、读标)。Q1–Q12 全部按建议确认,写入设计规范;各页的共用问题收回到 `components.css`、`demo.css`、`demo.js`。Q13(流水记 0 点的「免扣」行)、U 键(撤销当前条)按建议确认。 - 2026-11-03:UI demo 全部按 v0.2 重刷(控件、表单、卡片、列表、工作台、读标)。Q1–Q12 全部按建议确认,写入设计规范;各页的共用问题收回到 `components.css`、`demo.css`、`demo.js`。Q13(流水记 0 点的「免扣」行)、U 键(撤销当前条)按建议确认。
- 下一步:按 v0.2 重刷 MVP 原型(`docs/prototype/`),包括侧栏框架和待确认区。 - 2026-11-03:MVP 原型 13 页全部按 v0.2 重刷:框架、项目头和标签页、对照布局、问题处理交互与 UI demo 一致,业务流程和红线内容不变。报告页脚与免责声明改为「本报告为辅助检查结果,仅供投标前自查参考」,规范 §7.17 和 FR-M3-09 同步,消除了「保证」字样。
- 待办:VI 手册页脚仍写「不构成……保证」,需要改 `vi-manual.html` 并重出 PDF。
- 下一步:进入实现阶段前的工程准备(物理数据建模、接口契约、工程骨架,见 `docs/architecture/architecture.md` 第 12 节)。

View File

@@ -181,7 +181,7 @@
PDF 报告内容: PDF 报告内容:
1. 首页:项目信息、投标文件版本、检查时间、规则版本、**检查覆盖范围**(查了什么、没查什么)、免责声明(辅助检查,不保证结果); 1. 首页:项目信息、投标文件版本、检查时间、规则版本、**检查覆盖范围**(查了什么、没查什么)、免责声明(辅助检查结果,仅供投标前自查参考);
2. 汇总:各级别的问题数和处理状态; 2. 汇总:各级别的问题数和处理状态;
3. 问题明细:问题、出处、处理状态、处理人、时间、忽略原因; 3. 问题明细:问题、出处、处理状态、处理人、时间、忽略原因;
4. 附录:需人工核对的条目,以及勾选结果。 4. 附录:需人工核对的条目,以及勾选结果。

View File

@@ -471,9 +471,9 @@
- A4 竖版,页边距 20mm,思源黑体正文 10.5pt / 行距 1.6,标题思源宋体。 - A4 竖版,页边距 20mm,思源黑体正文 10.5pt / 行距 1.6,标题思源宋体。
- 页眉:左侧小号横版 logo(干净版),右侧「投标文件辅助检查报告」。**logo 只出现在页眉**,不放在任何结论、汇总数字或签名旁边(红线:logo 不能当「通过」章)。 - 页眉:左侧小号横版 logo(干净版),右侧「投标文件辅助检查报告」。**logo 只出现在页眉**,不放在任何结论、汇总数字或签名旁边(红线:logo 不能当「通过」章)。
- 首页:项目信息、投标文件版本、检查时间、规则版本、**检查覆盖范围**(查了什么 / 没查什么)、免责声明。 - 首页:项目信息、投标文件版本、检查时间、规则版本、**检查覆盖范围**(查了什么 / 没查什么)、免责声明。
- 问题明细的级别标签和界面一致(实心 / 描边 / 无框),黑白打印也能分辨。 - 免责声明(v0.2):写明报告是辅助检查结果、检查范围以首页「检查覆盖范围」为准、结果可能有遗漏或误判、不代表资格审查 / 符合性审查 / 评标的结论、是否递交和如何修改由投标人自行决定。参考写法见原型 `report.html`。
- 页脚:页码「3 / 12」+「本报告为辅助检查结果,不构成对投标结果的保证」。 - 页脚(v0.2):「本报告为辅助检查结果,仅供投标前自查参考」+ 页码「3 / 12」。
- 全文不出现「通过」「合格」「保证」。 - 全文(含免责声明和页脚)不出现「通过」「合格」「保证」。免责的意思用「仅供……参考」「不代表……结论」「由投标人自行决定」来说,不写「不构成保证」「不保证」。引用招标文件原文时照录原文,不在此列。
### 7.18 选中态(v0.2,Q3,已确认 2026-11-03) ### 7.18 选中态(v0.2,Q3,已确认 2026-11-03)

View File

@@ -236,8 +236,8 @@ table.data tbody tr.sel td:first-child{box-shadow:inset 3px 0 0 var(--c-ink)}
.report dl{display:grid;grid-template-columns:96px 1fr;gap:4px 12px;margin:14px 0} .report dl{display:grid;grid-template-columns:96px 1fr;gap:4px 12px;margin:14px 0}
.report dt{color:var(--text-secondary)} .report dt{color:var(--text-secondary)}
.report dd{margin:0} .report dd{margin:0}
.scope{border:var(--line) solid var(--border-subtle);padding:10px 14px;margin-top:10px} .report-scope{border:var(--line) solid var(--border-subtle);padding:10px 14px;margin-top:10px}
.scope b{display:block;margin-bottom:2px} .report-scope b{display:block;margin-bottom:2px}
.report-foot{margin-top:18px;padding-top:8px;border-top:var(--line) solid var(--border-subtle);display:flex;justify-content:space-between;font-size:11px;color:var(--text-secondary)} .report-foot{margin-top:18px;padding-top:8px;border-top:var(--line) solid var(--border-subtle);display:flex;justify-content:space-between;font-size:11px;color:var(--text-secondary)}

View File

@@ -286,8 +286,8 @@ section{margin-top:var(--sp-12)}
<dt>检查时间</dt><dd>2026-11-02 14:20 · 规则版本 2026.11</dd> <dt>检查时间</dt><dd>2026-11-02 14:20 · 规则版本 2026.11</dd>
<dt>处理情况</dt><dd><span class="sev fatal">致命<span class="fig">2</span></span> <span class="sev major">重要<span class="fig">1</span></span> <span class="sev hint">提示<span class="fig">4</span></span> 其中待处理 1 项</dd> <dt>处理情况</dt><dd><span class="sev fatal">致命<span class="fig">2</span></span> <span class="sev major">重要<span class="fig">1</span></span> <span class="sev hint">提示<span class="fig">4</span></span> 其中待处理 1 项</dd>
</dl> </dl>
<div class="scope"><b>检查覆盖范围</b>查了:清单 86 条的响应完整性、形式要件(格式文件、签章位置有无、落款日期)、资质匹配、报价形式。<br>没查:份数、装订密封、CA 加密(见附录「需人工核对」);签章真伪;报价是否合理。</div> <div class="report-scope"><b>检查覆盖范围</b>查了:清单 86 条的响应完整性、形式要件(格式文件、签章位置有无、落款日期)、资质匹配、报价形式。<br>没查:份数、装订密封、CA 加密(见附录「需人工核对」);签章真伪;报价是否合理。</div>
<div class="report-foot"><span>本报告为辅助检查结果,不构成对投标结果的保证</span><span class="fig">1 / 12</span></div> <div class="report-foot"><span>本报告为辅助检查结果,仅供投标前自查参考</span><span class="fig">1 / 12</span></div>
</div> </div>
<p class="note">logo 只在页眉,不靠近任何结论或汇总数字。</p> <p class="note">logo 只在页眉,不靠近任何结论或汇总数字。</p>
</section> </section>

View File

@@ -152,7 +152,7 @@
--row-h: 64px; /* 列表行最小高度:两行文字(名称 + 辅助灰小字)的项目行(v0.2) */ --row-h: 64px; /* 列表行最小高度:两行文字(名称 + 辅助灰小字)的项目行(v0.2) */
--rail-w: 96px; /* 侧栏(v0.2):单一状态,上图标下文字,不折叠 */ --rail-w: 96px; /* 侧栏(v0.2):单一状态,上图标下文字,不折叠 */
--topbar-h: 56px; /* 顶栏:透明,滚动后半透明白 + 毛玻璃 */ --topbar-h: 56px; /* 顶栏:透明,滚动后半透明白 + 毛玻璃 */
--sidebar-w: 216px; /* v0.1 宽侧栏,仅旧原型使用,重刷后删除 */ --sidebar-w: 216px; /* v0.1 宽侧栏。原型 v0.2 已不用(2026-11-03);只剩 ui-demo/explore/ 的历史方案(a–f、d2–d5)引用,那些归档后可删 */
--margin-note-w: 280px; /* 页边批注栏 */ --margin-note-w: 280px; /* 页边批注栏 */
--content-max: 1200px; --content-max: 1200px;

View File

@@ -118,7 +118,7 @@
else if (e.key === "Escape" && document.activeElement === gq) gq.blur(); 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 }); } }); document.addEventListener("click", (e) => { if (e.target.closest("[data-demo-nav]")) { e.preventDefault(); toast("(演示)这一页没有做", { ok: false }); } });
$("[data-notice]").onclick = () => toast("解析完成:补遗 1 已并入清单(新增 2 条,更新 1 条)。"); $("[data-notice]").onclick = () => toast("解析完成:补遗 1 已并入清单(更新 2 条)。");
} }
/* ---------- 浮动菜单:列表行的「更多」等(role=menu,↑ ↓ 移动,Esc 关闭,焦点回到触发按钮) ---------- /* ---------- 浮动菜单:列表行的「更多」等(role=menu,↑ ↓ 移动,Esc 关闭,焦点回到触发按钮) ----------

View File

@@ -1,8 +1,9 @@
# MVP 可点击原型 v0.1(2026-11-02) # MVP 可点击原型 v0.2(2026-11-03 按设计规范 v0.2 重刷)
按 `docs/analysis/02-user-journeys.md` 的 J1–J5 做的 HTML 原型,覆盖 MVP 全链路:注册 → 新建项目 → 解析 → 选包 → 读标 → 上传投标文件 → 查标 → 导出报告,以及工作台、项目概览、额度、资质库。 按 `docs/analysis/02-user-journeys.md` 的 J1–J5 做的 HTML 原型,覆盖 MVP 全链路:注册 → 新建项目 → 解析 → 选包 → 读标 → 上传投标文件 → 查标 → 导出报告,以及工作台、项目概览、额度、资质库。
- **保真度**:中保真。直接使用设计规范的变量和组件(`docs/design/system/`),重点验证流程、信息结构和产品红线落地;视觉细节留到 UI 设计阶段精修。 - **版本**:v0.1(2026-11-02)验证流程;v0.2(2026-11-03)按设计规范 v0.2(决策 U5、Q1–Q13、U 键)重刷全部 13 页。业务内容、流程和页面跳转不变,只换视觉、框架,精简文案。样式和交互以 `docs/design/ui-demo/`(v0.2 参考实现)为准,但原型不引用 ui-demo 目录,可以独立运行。
- **保真度**:中高保真。颜色、字号、间距、圆角、组件状态都按 v0.2 规范;框架是 96px 墨色渐变侧栏 + 透明顶栏 + 圆角内容区,项目内页面有项目头和「概览 / 读标 / 查标 / 报告」标签页。数据是示意的,只模拟前端交互,不连后端。
- **示意场景**:某市政务服务中心智慧大厅建设项目(政府采购 · 公开招标 · 2 个采购包,投采购包 1)。条款改写自苏采云样本 S02、S10;项目名、金额、页码、投标响应、人名都是示意。 - **示意场景**:某市政务服务中心智慧大厅建设项目(政府采购 · 公开招标 · 2 个采购包,投采购包 1)。条款改写自苏采云样本 S02、S10;项目名、金额、页码、投标响应、人名都是示意。
- **入口**:`index.html`(原型目录,含页面与旅程的对应关系、模拟范围)。 - **入口**:`index.html`(原型目录,含页面与旅程的对应关系、模拟范围)。
@@ -32,9 +33,34 @@ cd docs && python3 -m http.server 8766
| `project.html` | 项目概览:状态流、AI 摘要、关键日期、文件、检查记录、递交 / 归档 / 删除 | | `project.html` | 项目概览:状态流、AI 摘要、关键日期、文件、检查记录、递交 / 归档 / 删除 |
| `quota.html` | 额度与流水 | | `quota.html` | 额度与流水 |
| `qualifications.html` | 资质库 | | `qualifications.html` | 资质库 |
| `assets/data.js` | 示意数据(项目、文件页面、清单、检查结果) | | `assets/data.js` | 示意数据:项目、文件页面(原文)、清单、待确认、检查结果、需人工核对;v0.2 新增工作台的项目列表、`tasks`(进行中的任务)、`recent`(最近处理)、致命问题的 `brief`。演示里的「现在」统一为 11月2日 15:00 |
| `assets/app.js` | 页面框架、出处、原文查看器、对话框、轻提示 | | `assets/app.js` | 共用脚本(`window.App`),见下表 |
| `assets/app.css` | 页面框架与布局(组件样式在 `design/system/components.css`) | | `assets/app.css` | 页面框架与共用组件 v0.2:照搬 `design/ui-demo/demo.css` 的 B 部分,基础组件在 `design/system/components.css`。v0.2 收进来的共用类:项目行 `.prow`、对照布局 `.cmp`、统计 / 额度 / 关键日期、署名 `.sign`、AI 块 `.ai-block`、行内说明 `.u4`、选中态、表单控件、赭色提示小字 `.hint-warn`、`aria-disabled` 按钮(不可用但保留悬停说明)等。页面特有的样式写在各页 `<style>` 里 |
### `App.*` 主要接口
| 接口 | 用途 |
|---|---|
| `shell({ nav, crumbs, tab, search, fit })` | 生成侧栏和顶栏;传 `tab`(overview / checklist / results / report)时加项目头和标签页;`fit` 用于整屏的对照布局 |
| `projHead({ fatal, status })` | 更新项目头里的致命数、「就绪」和状态。页面不要自己改 |
| `viewer(el, opts)` | 原文查看器。v0.2 新增 `onPrev` / `onNext`(翻页按钮交给页面,如读标的「上一条 / 下一条」)、`prevLabel` / `nextLabel` / `prevKey` / `nextKey`(按钮文案和快捷键,默认「上一处 / 下一处」)、`setNav(上, 下)`、`clear(说明)`(没有出处时清空查看器并显示说明) |
| `drawer()` | 从右侧滑出的原文查看器(概览、查标),返回 `{ open, close, isOpen }`,Esc 关闭 |
| `cite` / `citeLink` / `citeLabel` / `quote` / `parseCite` | 出处块、行内出处链接、出处文字、原句、解析出处字符串 |
| `dialog({ title, body, ok, cancel, wide, onOk, onOpen, footLeft })` | 对话框;`footLeft` 放扣点等说明;`onOk` 返回 false 时不关闭 |
| `toast(msg, { ok, undo })` | 轻提示,默认带绿色 ✓;提醒和原型说明传 `{ ok: false }` |
| `floatMenu(btn, items, onPick)` | 行内「更多」菜单 |
| `keysDialog(App.KEYS.reading 或 App.KEYS.check)` | 快捷键说明 |
| `init(root)` / `hydrate(root)` | 初始化下拉、日期输入、对照布局、开关、筛选、标签页;把 `<i data-ic>` 换成图标。下拉选中后在 `.pop` 上派发冒泡的 `change`(`detail` 是选中值),可以在容器上委托 |
| `dateInput` / `fieldError` / `cmp` / `sortable` / `icon` | 可键入的日期输入、字段错误、可拖动的对照布局、可排序表格、图标 |
### 网址参数(预览不同状态)
| 参数 | 页面 | 作用 |
|---|---|---|
| `?quota=warn` / `?quota=out` | 所有带框架的页面(顶栏额度)、`quota.html`、`bid-check.html` | 预览检查额度用到 80%、用完(Q9)。额度页的流水会补上对应记录;查标上传页配合 `?charged=1` 演示额度用完时点「开始检查」 |
| `?plan=free` / `pro` / `ent` | 所有带框架的页面 | 侧栏的套餐标签 |
| `?charged=1` | `bid-check.html` | 超出 24 小时免扣窗口、这次检查要扣 1 次 |
| `?addendum=1` | `project-new.html`、`parsing.html` | 追加补遗、解析补遗 |
## 原型阶段发现的问题(2026-11-02 已按建议确认,见 `docs/decisions.md` P1–P2) ## 原型阶段发现的问题(2026-11-02 已按建议确认,见 `docs/decisions.md` P1–P2)
@@ -46,4 +72,4 @@ cd docs && python3 -m http.server 8766
## 和需求、规范的对应 ## 和需求、规范的对应
- 每个页面对应的 FR 编号见 `index.html` 的「其他页面」表。 - 每个页面对应的 FR 编号见 `index.html` 的「其他页面」表。
- 产品红线自查:没有出处的结论只出现在「待确认」区;全站无「通过 / 合格 / 保证」字样;红色只用于致命问题;致命问题未处理也能导出和标记已递交;扣点前在按钮旁说明;报告 logo 只在页眉。 - 产品红线自查:没有出处的结论只出现在「待确认」区;界面和报告文案无「通过 / 合格 / 保证 / 100%」字样(「保证金」和照录的招标文件原文除外);红色只用于致命问题;致命问题未处理也能导出和标记已递交;扣点前在按钮旁说明;报告 logo 只在页眉。

View File

@@ -1,126 +1,704 @@
/* 原型专用:页面框架、布局和各页面的局部样式。组件样式在 design/system/components.css。 */ /* 标博士原型 · 页面框架与共用组件 v0.2(2026-11-03)
* 依赖 design/system/tokens.css 和 components.css;只用 tokens.css 的变量,不写裸色值(Q6)。
* 照搬 design/ui-demo/demo.css 的 B 部分(v0.2 参考实现,视觉以 ui-demo/explore/workbench-d5.html 为准),原型不引用 ui-demo 目录。
* 分三部分:
* 1. 页面框架:侧栏、顶栏、内容区、页头、项目头 + 标签页(结构由 app.js 的 App.shell() 生成);
* 2. 共用组件:ui-demo/demo.css 的 B1、B3–B20(按钮补充、输入、下拉、日期、复选 / 开关、筛选 / 标签页、分页、轻提示、对话框、
* 提示气泡、空状态、杂项、AI 块 / 署名行、卡片 / 统计 / 额度 / 关键日期、表格补充 / 当前条 / 行内说明、对照布局、项目行);
* 3. 原型特有:抽屉、需人工核对清单、原型说明等。
* v0.1 的 216px 侧栏、校样纸底色、2px 圆角已删除。 */
/* ---------- 页面框架 ---------- */ /* =====================================================================
body.app{display:grid;grid-template-columns:var(--sidebar-w) minmax(0,1fr);min-height:100vh;min-width:1280px} * 1. 页面框架 v0.2(§5.3):96px 墨色渐变侧栏 + 透明顶栏 + 内容区左上角圆角
.sidebar{position:sticky;top:0;height:100vh;display:flex;flex-direction:column;border-right:1px solid var(--border-subtle);background:var(--bg-page);padding:var(--sp-5) var(--sp-3)} * 结构:body.app > aside.sidebar + main.main > header.top + [header.ph 项目头] + 页面内容
.sidebar .brand{display:block;padding:0 var(--sp-2) var(--sp-6)} * ===================================================================== */
.sidebar .brand img{height:30px;display:block} body.app{display:grid;grid-template-columns:var(--rail-w) minmax(0,1fr);min-height:100vh;min-width:1280px;background:linear-gradient(180deg,var(--side-top) 0%,var(--side-bottom) 100%) fixed}
.nav{display:flex;flex-direction:column;gap:2px} .sidebar{position:sticky;top:0;height:100vh;display:flex;flex-direction:column;padding:0 0 var(--sp-4) 10px;color:var(--side-fg);min-width:0}
.nav a{display:flex;align-items:center;gap:var(--sp-3);height:36px;padding:0 var(--sp-3);border-radius:var(--radius-sm);color:var(--text-primary);text-decoration:none} .sidebar::after{content:"";position:absolute;left:0;right:0;bottom:0;height:260px;pointer-events:none;background-image:radial-gradient(var(--side-dots) 1px,transparent 1.2px);background-size:14px 14px;-webkit-mask-image:linear-gradient(to top,black,transparent);mask-image:linear-gradient(to top,black,transparent)}
.nav a:hover{background:var(--bg-hover)} .sidebar > *{position:relative;z-index:1}
.nav a.on{background:var(--bg-surface);box-shadow:inset 2px 0 0 var(--c-ink);font-weight:var(--fw-bold)} .sidebar .brand{display:flex;justify-content:center;padding:var(--sp-5) 10px var(--sp-5) 0;border-radius:var(--radius-ctl)}
.nav svg,.ic{width:18px;height:18px;flex:none;fill:none;stroke:currentColor;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round} .sidebar .brand img{width:60px;height:auto;display:block}
.nav .sub{margin:var(--sp-5) var(--sp-3) var(--sp-2);font-size:var(--fs-caption);color:var(--text-secondary)} .sidebar .nav{display:flex;flex-direction:column;gap:var(--sp-1)}
.sidebar-foot{margin-top:auto;padding:var(--sp-3);border-top:1px solid var(--border-subtle);font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary)} .sidebar .nav a{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:var(--sp-1);height:62px;margin-right:10px;border-radius:var(--radius-card);font-size:var(--fs-caption);line-height:16px;color:var(--side-fg);text-align:center;text-decoration:none;white-space:nowrap;transition:background-color var(--dur-fast) var(--ease),color var(--dur-fast) var(--ease)}
.sidebar-foot b{display:block;color:var(--text-primary);font-size:var(--fs-body)} .sidebar .nav a .ic{width:22px;height:22px}
.sidebar .nav a:hover{background:var(--side-hover);color:var(--side-fg-strong)}
.sidebar .nav a[aria-current]{background:var(--side-active);color:var(--side-fg-strong);font-weight:var(--fw-bold)}
.sidebar .nav .sep{display:block;height:1px;margin:10px 22px 10px 12px;background:var(--side-line)}
.sidebar a:focus-visible{outline:2px solid var(--side-fg-strong);outline-offset:-2px}
.sidebar .me{display:flex;flex-direction:column;align-items:center;margin-top:auto;padding:var(--sp-4) 10px var(--sp-1) 0;color:var(--side-muted);text-align:center}
.sidebar .avatar{display:inline-grid;place-items:center;width:44px;height:44px;margin-bottom:var(--sp-2);border-radius:50%;background:var(--side-avatar-bg);box-shadow:0 0 0 1px var(--side-avatar-ring);color:var(--side-fg-strong);font-size:var(--fs-title);font-weight:var(--fw-bold)}
.sidebar .uname{color:var(--side-fg-strong);font-size:var(--fs-body);line-height:var(--lh-body);font-weight:var(--fw-bold)}
/* 套餐标签(§3.1):只在账户区使用;免费版中性、专业版浅金、企业版金色实底 */
.plan{display:inline-block;margin-top:var(--sp-1);padding:0 var(--sp-2);border-radius:var(--radius-xs);font-size:var(--fs-caption);line-height:20px;font-weight:var(--fw-bold);white-space:nowrap}
.plan[data-plan="free"]{background:var(--plan-free-bg);color:var(--side-fg)}
.plan[data-plan="pro"]{background:var(--plan-pro-bg);color:var(--plan-pro-fg);box-shadow:inset 0 0 0 1px var(--plan-pro-line)}
.plan[data-plan="ent"]{background:var(--plan-ent-bg);color:var(--c-ink)}
.main{min-width:0;display:flex;flex-direction:column} .main{min-width:0;display:flex;flex-direction:column;min-height:100vh;background:var(--bg-page);border-top-left-radius:var(--radius-card)}
.top{position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:var(--sp-4);height:52px;padding:0 var(--sp-8);background:var(--bg-page);border-bottom:1px solid var(--border-subtle)} .top{position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:var(--sp-3);height:var(--topbar-h);padding:0 var(--pad-page);background:transparent;border-bottom:var(--line) solid transparent;border-top-left-radius:var(--radius-card);transition:background-color var(--dur-base) var(--ease),border-color var(--dur-base) var(--ease)}
.crumbs{display:flex;gap:var(--sp-2);color:var(--text-secondary);min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .top.scrolled{background:var(--topbar-glass);-webkit-backdrop-filter:blur(14px) saturate(1.2);backdrop-filter:blur(14px) saturate(1.2);border-bottom-color:var(--border-subtle)}
/* 左侧「公司 / 页面」 */
.crumbs{display:flex;align-items:center;gap:6px;min-width:0;color:var(--text-secondary);white-space:nowrap;overflow:hidden}
.crumbs > *{min-width:0;overflow:hidden;text-overflow:ellipsis}
.crumbs > span{flex:none}
.crumbs .org{flex:none;color:var(--text-primary);font-weight:var(--fw-bold)}
.crumbs a{color:var(--text-secondary);text-decoration:none} .crumbs a{color:var(--text-secondary);text-decoration:none}
.crumbs a:hover{color:var(--text-primary);text-decoration:underline;text-underline-offset:3px} .crumbs a:hover{color:var(--text-primary);text-decoration:underline;text-decoration-color:var(--link-underline);text-underline-offset:3px}
.crumbs b{color:var(--text-primary);font-weight:var(--fw-regular);overflow:hidden;text-overflow:ellipsis} .crumbs b{font-weight:var(--fw-regular)}
.top-right{margin-left:auto;display:flex;align-items:center;gap:var(--sp-4)} /* 右侧:搜索、额度、通知 */
.quota-chip{display:flex;gap:var(--sp-3);align-items:center;height:28px;padding:0 var(--sp-3);border:1px solid var(--border-subtle);border-radius:var(--radius-sm);color:var(--text-secondary);text-decoration:none;font-size:var(--fs-caption);background:var(--bg-surface)} .top-search{position:relative;display:flex;align-items:center;flex:none;width:280px;height:var(--control-h);margin-left:auto;border:var(--line) solid var(--border-control);border-radius:var(--radius-ctl);background:var(--bg-surface);transition:width var(--dur-base) var(--ease),border-color var(--dur-fast) var(--ease)}
.quota-chip .fig{color:var(--text-primary);font-size:14px;margin-left:3px} .top-search:hover{border-color:var(--c-ink)}
.icon-btn{display:inline-grid;place-items:center;width:32px;height:32px;border-radius:var(--radius-sm);color:var(--text-primary);background:none;border:0;cursor:pointer;position:relative} .top-search:focus-within{width:360px;border-color:var(--c-ink);box-shadow:0 0 0 1px var(--c-ink)}
.icon-btn:hover{background:var(--bg-hover)} .top-search > .ic{position:absolute;left:11px;color:var(--text-secondary);pointer-events:none}
.icon-btn .dot{position:absolute;top:6px;right:6px;width:6px;height:6px;border-radius:50%;background:var(--c-ink)} .top-search input{flex:1;min-width:0;height:100%;padding:0 var(--sp-2) 0 34px;border:0;background:transparent;font:inherit;color:var(--text-primary);outline:0}
.top-search input::placeholder{color:var(--text-secondary)}
.content{padding:var(--sp-6) var(--sp-8) var(--sp-16);max-width:calc(var(--content-max) + 64px);width:100%} .top-search input::-webkit-search-cancel-button{display:none}
.content.wide{max-width:none} .top-search .kbd{margin-right:6px}
.page-title{display:flex;align-items:flex-end;gap:var(--sp-4);margin-bottom:var(--sp-5)} .top .vsep{flex:none;width:1px;height:20px;margin:0 var(--sp-1);background:var(--border-subtle)}
.top-right{display:flex;align-items:center;gap:var(--sp-1);flex:none}
/* 顶栏额度(§7.10、Q9):用到 ≥ 80% 时该项数字变赭色加粗(.warn);不用红 */
.quota-chip{display:inline-flex;align-items:center;gap:6px;height:var(--control-h-sm);padding:0 10px;border-radius:var(--radius-ctl);color:var(--text-secondary);text-decoration:none;white-space:nowrap;transition:background-color var(--dur-fast) var(--ease)}
.quota-chip:hover{background:var(--c-n100)}
.quota-chip .q{display:inline-flex;align-items:baseline;gap:6px}
.quota-chip .fig{color:var(--text-primary);font-size:var(--fs-figure-sm)}
.quota-chip .q.warn .fig,.quota-chip.warn .fig{color:var(--warn-text);font-weight:var(--fw-bold)}
.quota-chip .sep{width:1px;height:12px;margin:0 var(--sp-1);background:var(--border-subtle)}
.content{width:100%;padding:20px var(--pad-page) var(--sp-12)}
/* 页头:标题 + 一句话;右侧放页面级主按钮(Q12:页面级一个) */
.page-title{display:flex;align-items:flex-end;justify-content:space-between;gap:var(--sp-6);margin-bottom:20px}
.page-title h1{font:var(--fw-bold) var(--fs-h1)/var(--lh-h1) var(--font-serif);margin:0} .page-title h1{font:var(--fw-bold) var(--fs-h1)/var(--lh-h1) var(--font-serif);margin:0}
.page-title p{margin:var(--sp-1) 0 0;color:var(--text-secondary)} .page-title p{margin:2px 0 0;color:var(--text-secondary)}
.page-title .actions{margin-left:auto;display:flex;gap:var(--sp-2);align-items:center} .page-title .actions{margin-left:auto;display:flex;gap:var(--sp-3);align-items:center}
.section-h{display:flex;align-items:center;gap:var(--sp-3);margin:var(--sp-8) 0 var(--sp-3)}
/* 项目页头 + 页签 */
.proj-head{padding:var(--sp-5) var(--sp-8) 0;border-bottom:1px solid var(--border-subtle);background:var(--bg-page)}
.proj-head .line1{display:flex;align-items:flex-start;gap:var(--sp-4)}
.proj-head h1{font:var(--fw-bold) var(--fs-h1)/var(--lh-h1) var(--font-serif);margin:0}
.proj-head .meta{margin-top:var(--sp-1);color:var(--text-secondary);display:flex;flex-wrap:wrap;gap:var(--sp-2) var(--sp-4)}
.proj-head .facts{margin-left:auto;display:flex;gap:var(--sp-6);text-align:right;white-space:nowrap}
.proj-head .facts small{display:block;font-size:var(--fs-caption);color:var(--text-secondary)}
.proj-head .facts .fig{font-size:16px}
.tabs{display:flex;gap:var(--sp-6);margin-top:var(--sp-3)}
.tabs a{padding:var(--sp-2) 0 10px;color:var(--text-secondary);text-decoration:none;border-bottom:2px solid transparent;display:flex;gap:6px;align-items:center}
.tabs a:hover{color:var(--text-primary)}
.tabs a.on{color:var(--text-primary);font-weight:var(--fw-bold);border-bottom-color:var(--c-ink)}
/* ---------- 原文查看器 ---------- */
.viewer{min-height:0}
.viewer-bar{flex-wrap:wrap}
.viewer-bar .doc-name{display:flex;gap:var(--sp-2);align-items:baseline;min-width:0}
.viewer-bar .doc-file{color:var(--text-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:240px}
.viewer-body{flex:1;overflow:auto;padding:var(--sp-4) 0}
.viewer .sheet{min-height:560px;margin:0 auto;width:calc(100% - 48px);max-width:640px;position:relative}
.sheet .pno{position:absolute;bottom:14px;left:0;right:0;text-align:center;font:12px var(--font-figures);color:#999}
.sheet .h{font-weight:700;margin:4px 0 10px}
.sheet .stub{height:9px;background:#EEE;margin:12px 0;border-radius:1px}
.sheet .stub.s{width:60%}
.viewer-empty{color:var(--text-secondary);font-size:var(--fs-caption);text-align:center;margin-top:var(--sp-4)}
.viewer-note{font-size:var(--fs-caption);color:var(--text-secondary);padding:0 var(--sp-6) var(--sp-3)}
/* 抽屉(查标页从右侧滑出原文) */
.drawer{position:fixed;top:0;right:0;bottom:0;width:min(46vw,720px);z-index:40;background:var(--bg-sunken);box-shadow:var(--shadow-overlay);transform:translateX(100%);transition:transform var(--dur-base) var(--ease);display:flex;flex-direction:column}
.drawer.open{transform:none}
.drawer .viewer{flex:1;border:0;border-radius:0}
.drawer-close{margin-left:var(--sp-2)}
/* ---------- 对话框与轻提示 ---------- */
.scrim{position:fixed;inset:0;background:rgba(29,39,51,.32);z-index:50;display:grid;place-items:center}
.dialog{width:480px;max-width:calc(100vw - 32px);background:var(--bg-surface);border-radius:var(--radius-md);box-shadow:var(--shadow-overlay);padding:var(--sp-6)}
.dialog.wide{width:640px}
.dialog h2{font:var(--fw-bold) var(--fs-title)/var(--lh-title) var(--font-sans);margin:0 0 var(--sp-3)}
.dialog .body > p{margin:0 0 var(--sp-3)}
.dialog .foot{display:flex;justify-content:flex-end;gap:var(--sp-2);margin-top:var(--sp-6)}
.toast{position:fixed;left:50%;bottom:32px;transform:translateX(-50%);z-index:60;background:var(--c-ink);color:#fff;padding:10px 16px;border-radius:var(--radius-sm);box-shadow:var(--shadow-overlay);display:flex;gap:var(--sp-3);align-items:center}
.toast a{color:#fff}
textarea.input{height:auto;min-height:72px;padding:8px 10px;resize:vertical;line-height:var(--lh-body);width:100%}
.field .input{width:100%}
.field{max-width:none}
/* ---------- 通用小件 ---------- */
.muted{color:var(--text-secondary)}
.cap{font-size:var(--fs-caption);line-height:var(--lh-caption)}
.stack > * + *{margin-top:var(--sp-4)}
.section-h{display:flex;align-items:baseline;gap:var(--sp-3);margin:var(--sp-8) 0 var(--sp-3)}
.section-h h2{font:var(--fw-bold) var(--fs-title)/var(--lh-title) var(--font-sans);margin:0} .section-h h2{font:var(--fw-bold) var(--fs-title)/var(--lh-title) var(--font-sans);margin:0}
.section-h .more{margin-left:auto} .section-h .more{margin-left:auto;display:flex;gap:var(--sp-2);align-items:center}
.kbd{display:inline-block;min-width:20px;padding:0 5px;border:1px solid var(--border-control);border-bottom-width:2px;border-radius:3px;font:12px/18px var(--font-figures);text-align:center;background:var(--bg-surface)} .stack > * + *{margin-top:var(--gap-card)}
.seg{display:inline-flex;border:1px solid var(--border-control);border-radius:var(--radius-sm);overflow:hidden;background:var(--bg-surface)}
.seg button{height:28px;padding:0 10px;border:0;background:none;font:var(--fs-body) var(--font-sans);color:var(--text-primary);cursor:pointer;display:flex;gap:6px;align-items:center}
.seg button + button{border-left:1px solid var(--border-subtle)}
.seg button.on{background:var(--c-ink);color:#fff}
.seg button.on .sev.hint{background:#fff}
.chk{display:flex;gap:var(--sp-3);align-items:flex-start;padding:var(--sp-3) 0;border-top:1px solid var(--border-subtle)}
.chk:first-child{border-top:0}
.chk input{width:16px;height:16px;margin:3px 0 0;accent-color:var(--c-ink)}
.free-badge{font-size:var(--fs-caption);border:1px solid var(--border-control);padding:0 6px;border-radius:2px}
.placeholder{border:1px dashed var(--border-control);color:var(--text-secondary);border-radius:var(--radius-sm);padding:var(--sp-4);text-align:center}
/* 状态流 */ /* 整屏布局(App.shell({ fit: true })):读标、查标的对照布局,main 不滚动,里面各栏自己滚动 */
.flowline{display:flex;align-items:center;gap:0;margin:var(--sp-2) 0} .main.fit{height:100vh;overflow:hidden}
.content.wide{max-width:none}
.page-title .cost{white-space:nowrap}
/* 项目头(项目内页面:概览 / 读标 / 查标 / 报告):名称 + 信息一行;右侧截止、状态、就绪;下方标签页 */
.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}
.ph .facts{display:flex;gap:var(--sp-8);margin:0 0 0 auto;flex:none}
.ph .facts > div{display:flex;flex-direction:column;gap:2px}
.ph .facts dt{font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary)}
.ph .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)}
.ph .tabs .sev{margin-left:2px}
.ph + .content{padding-top:var(--sp-5)}
/* =====================================================================
* 2. 共用组件(ui-demo/demo.css B1、B3–B20)
* ===================================================================== */
/* 对话框、卡片里的表格:不再带自己的外框,首尾列贴齐容器内边距;没有表头时第一行不画上边线 */
.dialog table.data{border:0;border-radius:0;background:none}
.dialog table.data :is(th,td):first-child{padding-left:0}
.dialog table.data :is(th,td):last-child{padding-right:0}
.dialog table.data:not(:has(thead)) tbody tr:first-child td{border-top:0}
table.data.keys td:first-child{width:1%;padding-right:var(--sp-6)} /* 快捷键表(App.keysTable) */
/* ---- B1 图标(Lucide 线性,1.5px 描边,16 / 20px,§6;侧栏菜单 22px) ---- */
.ic{width:16px;height:16px;flex:none;fill:none;stroke:currentColor;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round;vertical-align:-3px}
.ic.lg{width:20px;height:20px}
.ic.xl{width:32px;height:32px;stroke-width:1.25}
.vh{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
/* ---- B3 按钮补充状态(§7.1) ---- */
.btn.is-hover{background:var(--c-n50);border-color:var(--c-ink)}
.btn.is-active{background:var(--c-n100);border-color:var(--c-ink)}
.btn.primary.is-hover{background:var(--primary-hover);border-color:var(--primary-hover)}
.btn.primary.is-active{background:var(--primary-active);border-color:var(--primary-active)}
.btn.text.is-hover{background:var(--c-n100);border-color:transparent}
.btn.text:active,.btn.text.is-active{background:var(--c-n100);border-color:transparent}
.btn.is-focus{outline:2px solid var(--focus-ring);outline-offset:2px}
.btn .ic{margin:0 -2px}
/* 不可用但要保留悬停说明(如已递交后只读):用 aria-disabled,样式同 [disabled];点击由页面拦下 */
.btn[aria-disabled="true"],.btn[aria-disabled="true"]:hover,.btn[aria-disabled="true"]:active{color:var(--text-disabled);background:var(--c-n100);border-color:var(--c-n100);cursor:not-allowed;font-weight:var(--fw-regular)}
.btn.text[aria-disabled="true"],.btn.text[aria-disabled="true"]:hover{background:none;border-color:transparent}
.drop[aria-disabled="true"]{pointer-events:none;color:var(--text-disabled);border-color:var(--border-subtle);background:var(--c-n50)}
.drop[aria-disabled="true"] > .ic{color:var(--text-disabled)}
/* 加载中:两个文字叠在同一格,按宽的那个定宽,切换时宽度不变 */
.btn.swap{display:inline-grid;place-items:center}
.btn.swap > span{grid-area:1/1;white-space:nowrap}
.btn.swap > .busy,.btn.swap.is-loading > .idle{visibility:hidden}
.btn.swap.is-loading > .busy{visibility:visible}
.btn.is-loading{cursor:progress}
.btn.primary.is-loading{background:var(--primary-hover);border-color:var(--primary-hover)}
.btn.is-loading:not(.primary){color:var(--text-secondary)}
/* 只有图标的按钮:36 / 32,悬停出 --c-n100;必须有 aria-label 和悬停提示(§6) */
.icon-btn{position:relative;flex:none;display:inline-grid;place-items:center;width:var(--control-h);height:var(--control-h);padding:0;border:var(--line) solid transparent;border-radius:var(--radius-ctl);background:none;color:var(--text-primary);cursor:pointer;transition:background-color var(--dur-fast) var(--ease),color var(--dur-fast) var(--ease)}
.icon-btn:hover{background:var(--c-n100)}
.icon-btn.bordered{border-color:var(--border-control);background:var(--bg-surface)}
.icon-btn.bordered:hover{border-color:var(--c-ink);background:var(--c-n50)}
.icon-btn.sm{width:var(--control-h-sm);height:var(--control-h-sm)}
.icon-btn.muted{color:var(--text-secondary)}
.icon-btn .dot{position:absolute;top:8px;right:8px;width:7px;height:7px;border-radius:50%;background:var(--c-ink);box-shadow:0 0 0 2px var(--c-white)}
.icon-btn[disabled]{color:var(--text-disabled);cursor:not-allowed;background:none}
.btn-group{display:inline-flex}
.btn-group .btn + .btn{margin-left:-1px}
.btn-group .btn:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}
.btn-group .btn:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}
.btn-group .btn:hover{position:relative;z-index:1}
/* ---- B4 输入框、文本域、带单位、搜索(§7.13) ----
* 聚焦:边框变墨 + 1px 墨色内圈(合计 2px,与顶栏搜索一致);错误:赭色边框 + 赭色内圈 */
.input{width:100%;transition:border-color var(--dur-fast) var(--ease)}
.input:hover,.input.is-hover{border-color:var(--c-ink)}
.input:focus,.input.is-focus{outline:0;border-color:var(--c-ink);box-shadow:0 0 0 1px var(--c-ink)}
.field.err .input:focus{border-color:var(--warn);box-shadow:0 0 0 1px var(--warn)}
.input[disabled],.input.is-disabled{background:var(--c-n100);border-color:var(--c-n100);color:var(--text-disabled);cursor:not-allowed}
.input[disabled]::placeholder{color:var(--text-disabled)}
.input[readonly]{background:var(--c-n50);border-color:var(--border-subtle)}
.input[readonly]:focus{box-shadow:none;border-color:var(--border-control)}
textarea.input{height:auto;min-height:80px;padding:var(--sp-2) var(--sp-3);resize:vertical;line-height:var(--lh-body)}
select.input{appearance:none;padding-right:32px;cursor:pointer}
.sel-wrap{position:relative;display:block}
.sel-wrap > .ic{position:absolute;right:10px;top:10px;pointer-events:none;color:var(--text-secondary)}
.field label{display:flex;gap:var(--sp-1);align-items:baseline}
.field label small{margin-left:2px}
/* .field 里的复选 / 单选:不继承 label 的粗体和 flex 写法(components.css 的 .field label) */
.field .choice{display:inline-flex;gap:var(--sp-2);align-items:flex-start;font-weight:var(--fw-regular)}
.field .choice small{margin-left:0}
.field .choice-card{font-weight:var(--fw-regular)}
.field .choice-card small{margin-left:0}
.field > .choice{margin-top:var(--sp-1)}
/* 赭色提示小字(不是错误,不挡保存):如证书早于截止日到期、文件读不了;「!」用 .ico.w,也可以放 Lucide 图标 */
.hint-warn{display:flex;gap:6px;align-items:flex-start;margin:0;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--warn-text)}
.hint-warn .ico{flex:none;width:14px;height:14px;font-size:10px;margin:3px 0 0}
.hint-warn > .ic{flex:none;width:14px;height:14px;margin-top:3px}
.hint-warn[hidden]{display:none}
.field .msg-row{display:flex;gap:var(--sp-3);align-items:baseline}
.field .msg-row .msg{flex:1}
.field .count{font:var(--fw-medium) var(--fs-figure-xs)/var(--lh-caption) var(--font-figures);font-variant-numeric:tabular-nums;color:var(--text-secondary);white-space:nowrap}
.field.err .count{color:var(--warn-text)}
.field.err textarea.input,.field.err .select{border-color:var(--warn);box-shadow:inset 0 0 0 1px var(--warn)}
.field.dis label{color:var(--text-disabled)}
.field.fill{max-width:none}
.field + .field{margin-top:0}
.input-unit{display:flex}
.input-unit .input{border-top-right-radius:0;border-bottom-right-radius:0;font-family:var(--font-figures);font-variant-numeric:tabular-nums;font-size:var(--fs-figure-sm)}
.input-unit .unit{display:flex;align-items:center;padding:0 var(--sp-3);border:var(--line) solid var(--border-control);border-left:0;border-radius:0 var(--radius-ctl) var(--radius-ctl) 0;background:var(--c-n50);color:var(--text-secondary);white-space:nowrap}
.search{position:relative;display:block}
.search > .ic{position:absolute;left:11px;top:10px;color:var(--text-secondary);pointer-events:none}
.search .input{padding-left:34px}
.search .kbd{position:absolute;right:7px;top:7px}
/* 表单布局 */
.form{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--sp-5) var(--sp-6)}
.form .span2{grid-column:1 / -1}
.form .field{max-width:none}
.form-sec{margin:0;padding:0;border:0;min-width:0}
.form-sec + .form-sec{margin-top:var(--sp-8);padding-top:var(--sp-6);border-top:var(--line) solid var(--border-subtle)}
.form-sec > legend{float:left;width:100%;padding:0;margin:0 0 var(--sp-4);font:var(--fw-bold) var(--fs-title)/var(--lh-title) var(--font-sans)}
.form-sec > legend small{font-weight:var(--fw-regular);font-size:var(--fs-caption);color:var(--text-secondary);margin-left:var(--sp-2)}
.form-sec > legend + *{clear:both}
.form-foot{display:flex;gap:var(--sp-3);align-items:center;margin-top:var(--sp-8);padding-top:var(--sp-5);border-top:var(--line) solid var(--border-subtle)}
.form-foot .cost{margin-right:auto}
.err-sum{margin-bottom:var(--sp-5)}
.err-sum ul{margin:var(--sp-1) 0 0;padding-left:1.2em}
.err-sum a{color:var(--warn-text);text-underline-offset:3px}
/* ---- B5 下拉选择 / 下拉菜单(Q4:选中项加粗,右侧墨色 ✓;浮层圆角 12,只有浮层有阴影) ---- */
.pop{position:relative;display:block}
.pop.inline{display:inline-block}
.select{display:flex;align-items:center;gap:var(--sp-2);width:100%;height:var(--control-h);padding:0 10px 0 var(--sp-3);border:var(--line) solid var(--border-control);border-radius:var(--radius-ctl);background:var(--bg-surface);font:var(--fs-body) var(--font-sans);color:var(--text-primary);text-align:left;cursor:pointer;transition:border-color var(--dur-fast) var(--ease)}
.select:hover{border-color:var(--c-ink)}
.select[aria-expanded="true"],.select.is-focus{outline:0;border-color:var(--c-ink);box-shadow:0 0 0 1px var(--c-ink)}
.select .val{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.select .ph{color:var(--text-secondary)}
.select > .ic{color:var(--text-secondary);transition:transform var(--dur-fast) var(--ease)}
.select[aria-expanded="true"] > .ic{transform:rotate(180deg)}
.select[disabled]{background:var(--c-n100);border-color:var(--c-n100);color:var(--text-disabled);cursor:not-allowed}
.select.sm{height:var(--control-h-sm)}
.menu{position:absolute;z-index:45;top:calc(100% + 4px);left:0;min-width:100%;max-height:300px;overflow:auto;margin:0;padding:6px;list-style:none;background:var(--bg-surface);border:var(--line) solid var(--border-subtle);border-radius:var(--radius-card);box-shadow:var(--shadow-overlay)}
.menu.right{left:auto;right:0}
.menu.float{position:fixed;top:auto;left:auto;min-width:248px} /* 由脚本按触发按钮定位,如列表行的「更多」 */
.menu [role=option],.menu [role=menuitem]{display:flex;align-items:center;gap:var(--sp-2);width:100%;min-height:34px;padding:6px 10px;border:0;border-radius:var(--radius-tag);background:none;font:var(--fs-body)/var(--lh-body) var(--font-sans);color:var(--text-primary);text-align:left;cursor:pointer;white-space:nowrap}
.menu [role=option]:hover,.menu [role=menuitem]:hover,.menu .is-active,.menu [role=menuitem]:focus-visible{background:var(--c-n100);outline:0}
.menu [aria-selected="true"]{font-weight:var(--fw-bold)}
.menu [aria-selected="true"]::after{content:"✓";margin-left:auto;padding-left:var(--sp-4);color:var(--c-ink);font-weight:var(--fw-bold)}
.menu [aria-disabled="true"],.menu [aria-disabled="true"]:hover{color:var(--text-disabled);cursor:not-allowed;background:none}
.menu [aria-disabled="true"]:focus-visible{background:var(--c-n50)}
.menu .grp{padding:var(--sp-2) 10px 2px;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary)}
.menu hr{border:0;border-top:var(--line) solid var(--border-subtle);margin:6px 0}
.menu small{color:var(--text-secondary);font-size:var(--fs-caption);margin-left:auto;padding-left:var(--sp-4)}
.menu small .fig{color:var(--text-primary);font-size:var(--fs-figure-xs)}
/* 两行菜单项:动作 + 说明(扣点说明写在这里,§8.3) */
.menu .two{flex-direction:column;align-items:flex-start;gap:0}
.menu .two small{display:block;margin:0;padding:0;line-height:var(--lh-caption)}
.menu .ic{color:var(--text-secondary)}
/* ---- B6 日期输入与日期选择(§7.22、§9.3;Q4:不可选日期只变禁用灰;Q10:输入「2026-11-20 09:30」) ----
* 日期输入 .dt[data-dt]:可直接键入(接受 2026/11/20 9:30 等常见写法,离开时整理成标准格式),右侧按钮打开日期选择器;
* 带时间的字段(data-time)在选择器底部填时间,24 小时制;下方 [data-dt-msg] 显示「11月20日 09:30 · 剩 N 天」(data-left)。
* 交互见 demo.js 的 Demo.dateInput(),页面里不用再写脚本。 */
.dt{position:relative;display:block}
.dt > .input{padding-right:40px;font-family:var(--font-figures);font-variant-numeric:tabular-nums;font-size:var(--fs-figure-sm)}
.dt > .input::placeholder{font-family:var(--font-sans);font-size:var(--fs-body)}
.dt > .icon-btn{position:absolute;top:2px;right:2px;color:var(--text-secondary)}
.dt > .icon-btn:hover,.dt > .icon-btn[aria-expanded="true"]{color:var(--text-primary)}
.dt.up > .cal{top:auto;bottom:calc(100% + 4px)}
.dt.d-open > .cal{position:static;margin-top:var(--sp-1)}
.cal{position:absolute;z-index:45;top:calc(100% + 4px);left:0;width:288px;padding:var(--sp-3);background:var(--bg-surface);border:var(--line) solid var(--border-subtle);border-radius:var(--radius-card);box-shadow:var(--shadow-overlay)}
.cal-h{display:flex;align-items:center;gap:var(--sp-1);margin-bottom:var(--sp-2)}
.cal-h b{flex:1;text-align:center;font-weight:var(--fw-bold)}
.cal-h b .fig{font-size:var(--fs-figure-sm)}
.cal-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:2px;text-align:center}
.cal-grid .wd{font-size:var(--fs-caption);line-height:28px;color:var(--text-secondary)}
.cal-grid button{position:relative;height:34px;border:0;border-radius:var(--radius-ctl);background:none;font:var(--fw-medium) var(--fs-figure-sm)/1 var(--font-figures);font-variant-numeric:tabular-nums;color:var(--text-primary);cursor:pointer;transition:background-color var(--dur-fast) var(--ease)}
.cal-grid button:not([disabled]):hover{background:var(--c-n100)}
/* 非本月的格子留空(不显示日期),和不可选日期(禁用灰、无悬停)不再混淆 */
.cal-grid .pad{height:34px}
.cal-grid button.today{box-shadow:inset 0 0 0 1px var(--border-control)}
.cal-grid button.mark::after{content:"";position:absolute;left:50%;bottom:4px;width:4px;height:4px;margin-left:-2px;border-radius:50%;background:var(--c-ink)}
.cal-grid button[aria-selected="true"]{background:var(--c-ink);color:var(--text-on-dark);box-shadow:none}
.cal-grid button[aria-selected="true"]:hover{background:var(--primary-hover)}
.cal-grid button[aria-selected="true"].mark::after{background:var(--c-white)}
.cal-grid button[disabled]{color:var(--text-disabled);cursor:not-allowed;background:none;font-weight:var(--fw-regular)}
.cal-grid button[disabled].mark::after{background:var(--text-disabled)}
.cal-f{display:flex;align-items:center;gap:var(--sp-2);margin-top:var(--sp-2);padding-top:var(--sp-2);border-top:var(--line) solid var(--border-subtle);font-size:var(--fs-caption);color:var(--text-secondary)}
.cal-f .btn{margin-left:auto}
.cal-f .time{width:72px;height:var(--control-h-sm);font-family:var(--font-figures);font-size:var(--fs-figure-sm);text-align:center}
/* ---- B7 复选框、单选、开关(§8.2 批量勾选;Q4 开关) ---- */
.ck{appearance:none;flex:none;display:inline-grid;place-content:center;width:16px;height:16px;margin:0;border:var(--line) solid var(--border-control);border-radius:var(--radius-xs);background:var(--bg-surface);cursor:pointer;vertical-align:-3px}
.ck:hover{border-color:var(--c-ink)}
.ck::after{content:"";display:none}
.ck:checked,.ck[type=checkbox]:indeterminate{background:var(--c-ink);border-color:var(--c-ink)}
.ck:checked::after{display:block;width:8px;height:4px;border:solid var(--c-white);border-width:0 0 1.75px 1.75px;transform:translateY(-1px) rotate(-45deg)}
.ck[type=checkbox]:indeterminate::after{display:block;width:8px;height:0;border-top:1.75px solid var(--c-white);transform:none}
.ck[type=radio]{border-radius:50%}
.ck[type=radio]:checked{background:var(--bg-surface);border-color:var(--c-ink)}
.ck[type=radio]:checked::after{width:8px;height:8px;border:0;border-radius:50%;background:var(--c-ink);transform:none}
.ck:disabled{background:var(--c-n100);border-color:var(--border-subtle);cursor:not-allowed}
.ck:disabled:checked{background:var(--c-muted);border-color:var(--c-muted)}
.ck[type=radio]:disabled:checked{background:var(--c-n100)}
.ck[type=radio]:disabled:checked::after{background:var(--c-muted)}
.ck.is-focus{outline:2px solid var(--focus-ring);outline-offset:2px}
.choice{display:inline-flex;gap:var(--sp-2);align-items:flex-start;cursor:pointer;line-height:var(--lh-body)}
.choice .ck{margin-top:3px}
.choice small{display:block;color:var(--text-secondary);font-size:var(--fs-caption);line-height:var(--lh-caption)}
.choice.dis{color:var(--text-disabled);cursor:not-allowed}
.choice.dis small{color:var(--text-disabled)}
.choices{display:flex;flex-direction:column;gap:var(--sp-2)}
.choices.h{flex-direction:row;flex-wrap:wrap;gap:var(--sp-2) var(--sp-6)}
.choice-card{display:flex;gap:var(--sp-3);align-items:flex-start;padding:var(--sp-3) var(--sp-4);border:var(--line) solid var(--border-control);border-radius:var(--radius-ctl);background:var(--bg-surface);cursor:pointer;transition:border-color var(--dur-fast) var(--ease),background-color var(--dur-fast) var(--ease)}
.choice-card:hover{background:var(--c-n50)}
.choice-card:has(:checked){border-color:var(--c-ink);box-shadow:inset 0 0 0 1px var(--c-ink)}
.choice-card .ck{margin-top:3px}
.choice-card b{display:block}
.choice-card small{display:block;color:var(--text-secondary);font-size:var(--fs-caption);line-height:var(--lh-caption)}
/* 开关(Q4):圆角 6 的矩形轨道 + 圆角 4 的方块滑钮;开为墨色;旁边写「开 / 关」;不用胶囊形 */
.switch{display:inline-flex;align-items:center;gap:var(--sp-2);padding:0;border:0;background:none;font:var(--fs-body) var(--font-sans);color:var(--text-primary);cursor:pointer}
.switch .track{position:relative;flex:none;width:34px;height:20px;border:var(--line) solid var(--border-control);border-radius:var(--radius-tag);background:var(--bg-surface);transition:background-color var(--dur-fast) var(--ease),border-color var(--dur-fast) var(--ease)}
.switch .track::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:var(--radius-xs);background:var(--c-muted);transition:transform var(--dur-fast) var(--ease),background-color var(--dur-fast) var(--ease)}
.switch:hover .track{border-color:var(--c-ink)}
.switch[aria-checked="true"] .track{background:var(--c-ink);border-color:var(--c-ink)}
.switch[aria-checked="true"] .track::after{transform:translateX(14px);background:var(--c-white)}
.switch .st{min-width:2em;font-size:var(--fs-caption);color:var(--text-secondary)}
.switch[aria-checked="true"] .st{color:var(--text-primary)}
.switch[disabled]{color:var(--text-disabled);cursor:not-allowed}
.switch[disabled] .track{background:var(--c-n100);border-color:var(--border-subtle)}
.switch[disabled] .track::after{background:var(--border-subtle)}
.switch[disabled][aria-checked="true"] .track{background:var(--c-muted);border-color:var(--c-muted)}
.switch[disabled][aria-checked="true"] .track::after{background:var(--c-n100)}
.switch[disabled] .st{color:var(--text-disabled)}
/* ---- B8 筛选(分段)、标签页(§5.3 页签、§7.5 分类) ----
* v0.2 筛选:文字按钮一排,按下 = --c-n100 实底 + 加粗(与工作台项目卡片头一致),不再用墨底反白。 */
.seg{display:inline-flex;align-items:center;gap:2px}
.seg button{display:inline-flex;align-items:center;gap:6px;height:var(--control-h-sm);padding:0 10px;border:0;border-radius:var(--radius-ctl);background:none;font:var(--fs-body) var(--font-sans);color:var(--text-secondary);white-space:nowrap;cursor:pointer;transition:background-color var(--dur-fast) var(--ease),color var(--dur-fast) var(--ease)}
.seg button:hover{background:var(--c-n100);color:var(--text-primary)}
.seg button[aria-pressed="true"]{background:var(--c-n100);color:var(--text-primary);font-weight:var(--fw-bold)}
.seg .fig{font-size:var(--fs-figure-sm);color:inherit}
.seg .sev .fig{font-size:var(--fs-figure-xs)}
.tabs{display:flex;gap:var(--sp-6);border-bottom:var(--line) solid var(--border-subtle)}
.tabs.flush{border-bottom:0}
.tabs [role=tab]{display:flex;gap:6px;align-items:center;margin-bottom:-1px;padding:var(--sp-2) 0 10px;border:0;border-bottom:2px solid transparent;background:none;font:var(--fs-body) var(--font-sans);color:var(--text-secondary);cursor:pointer;text-decoration:none;white-space:nowrap}
.tabs [role=tab]:hover{color:var(--text-primary)}
.tabs [role=tab][aria-selected="true"]{color:var(--text-primary);font-weight:var(--fw-bold);border-bottom-color:var(--c-ink)}
.tabs [role=tab][aria-disabled="true"]{color:var(--text-disabled);cursor:not-allowed}
.tabs [role=tab] > .fig{font-size:var(--fs-figure-sm)}
.tabpanel{padding-top:var(--sp-4)}
/* ---- B9 分页(Q4:当前页 --c-n100 实底 + 加粗,与筛选一致) ---- */
.pagination{display:flex;align-items:center;gap:2px;margin-top:var(--sp-3);font-size:var(--fs-caption);color:var(--text-secondary)}
.pagination .total{margin-right:auto}
.pagination .fig{font-size:var(--fs-figure-xs)}
.pagination button.pg{min-width:var(--control-h-sm);height:var(--control-h-sm);padding:0 var(--sp-2);border:0;border-radius:var(--radius-ctl);background:none;font:var(--fw-medium) var(--fs-figure-sm)/1 var(--font-figures);font-variant-numeric:tabular-nums;color:var(--text-primary);cursor:pointer;transition:background-color var(--dur-fast) var(--ease)}
.pagination button.pg:hover{background:var(--c-n100)}
.pagination button.pg[aria-current="page"]{background:var(--c-n100);font-weight:var(--fw-bold)}
.pagination button.pg[disabled]{color:var(--text-disabled);cursor:not-allowed;background:none}
.pagination .gap{width:20px;text-align:center}
.pagination .jump{display:flex;align-items:center;gap:6px;margin-left:var(--sp-4)}
.pagination .jump .input{width:52px;height:var(--control-h-sm);text-align:center;font-family:var(--font-figures);font-size:var(--fs-figure-sm)}
/* ---- B10 轻提示 Toast(§7.12:只用于操作结果,4 秒消失,悬停暂停) ---- */
.toasts{position:fixed;left:50%;bottom:28px;transform:translateX(-50%);z-index:60;display:flex;flex-direction:column;gap:var(--sp-2);align-items:center;pointer-events:none}
.toast{display:flex;gap:var(--sp-3);align-items:center;min-height:40px;padding:var(--sp-2) var(--sp-4);background:var(--c-ink);color:var(--text-on-dark);border-radius:var(--radius-ctl);box-shadow:var(--shadow-overlay);pointer-events:auto;white-space:nowrap}
.toast .ok{display:inline-grid;place-items:center;width:16px;height:16px;border-radius:50%;background:var(--c-green);color:var(--c-white);font-size:11px;line-height:1}
.toast a,.toast button.lnk{color:var(--text-on-dark);text-decoration:underline;text-underline-offset:3px;background:none;border:0;font:inherit;cursor:pointer;padding:0}
.toast .sep{width:1px;align-self:stretch;margin:2px 0;background:var(--on-dark-line)}
.toast.static{position:static}
/* ---- B11 对话框(§7.14:480 / 640,圆角 12;对话框里可以有自己的主按钮,Q12) ---- */
/* 对话框本身不滚动、不裁剪(overflow 可见),里面的下拉、日期浮层可以伸出对话框;内容比窗口高时由遮罩滚动 */
.scrim{position:fixed;inset:0;z-index:50;display:grid;overflow:auto;padding:var(--sp-8) var(--sp-4);background:var(--scrim)}
.dialog{position:relative;margin:auto;width:480px;max-width:100%;padding:var(--sp-6);background:var(--bg-surface);border-radius:var(--radius-card);box-shadow:var(--shadow-overlay)}
.dialog.wide{width:640px}
.dialog.static{margin:0;max-width:none} /* 陈列用:直接摆在页面里 */
.dialog h2{font:var(--fw-bold) var(--fs-title)/var(--lh-title) var(--font-sans);margin:0 var(--sp-8) var(--sp-3) 0}
.dialog .x{position:absolute;top:var(--sp-4);right:var(--sp-4)}
.dialog .body > p{margin:0 0 var(--sp-3)}
.dialog .body > .field{margin-top:var(--sp-4)}
.dialog .foot{display:flex;justify-content:flex-end;align-items:center;gap:var(--sp-2);margin-top:var(--sp-6)}
.dialog .foot .left{margin-right:auto}
.dialog table.data td{height:auto}
/* ---- B12 提醒补充(§7.12、§7.15):右侧操作、副说明、错误编号 ---- */
.alert:has(> .btn,> .act){align-items:center}
.alert:has(> .btn,> .act) > .ic{margin-top:0}
.alert > div{flex:1;min-width:0}
.alert > p{flex:1}
.alert .act{margin-left:auto;flex:none;align-self:center;display:flex;gap:var(--sp-2);align-items:center}
.alert > .btn{flex:none;align-self:center}
.alert .sub{display:block;margin-top:2px;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary)}
.alert .fig{font-weight:var(--fw-medium)}
.errno{font:var(--fw-medium) var(--fs-figure-xs)/1 var(--font-figures);color:var(--text-secondary);letter-spacing:.02em}
/* ---- B13 提示气泡(悬停 / 聚焦显示,§6 只有图标的按钮要有悬停提示) ---- */
[data-tip]{position:relative}
[data-tip]:hover::after,[data-tip]:focus-visible::after,[data-tip].tip-on::after{content:attr(data-tip);position:absolute;z-index:55;bottom:calc(100% + 8px);left:50%;transform:translateX(-50%);width:max-content;max-width:280px;padding:6px 10px;border-radius:var(--radius-ctl);background:var(--c-ink);color:var(--text-on-dark);font:var(--fw-regular) var(--fs-caption)/var(--lh-caption) var(--font-sans);white-space:normal;text-align:left;pointer-events:none}
[data-tip].tip-below:hover::after,[data-tip].tip-below:focus-visible::after,[data-tip].tip-below.tip-on::after{bottom:auto;top:calc(100% + 8px)}
[data-tip].tip-start:hover::after,[data-tip].tip-start:focus-visible::after,[data-tip].tip-start.tip-on::after{left:0;transform:none}
[data-tip].tip-left:hover::after,[data-tip].tip-left:focus-visible::after,[data-tip].tip-left.tip-on::after{left:auto;right:0;transform:none}
/* ---- B14 空状态、骨架屏、进度(§7.11、§7.15;Q8 骨架屏静止,不做明暗起伏) ---- */
.empty{display:flex;flex-direction:column;align-items:center;text-align:center;padding:var(--sp-12) var(--sp-6)}
.empty > .ic{color:var(--text-secondary);margin-bottom:var(--sp-3)}
.empty b{font-size:var(--fs-title);line-height:var(--lh-title)}
.empty p{margin:var(--sp-1) 0 var(--sp-5);color:var(--text-secondary);max-width:30em}
.empty.sm{padding:var(--sp-8) var(--sp-4)}
.empty.sm b{font-size:var(--fs-body);line-height:var(--lh-body)}
.empty.sm p{margin-bottom:0}
.empty.sm > .btn{margin-top:var(--sp-4)}
.skel{display:block;height:12px;border-radius:var(--radius-xs);background:var(--c-n100)}
.skel.t{height:16px;width:40%}
.skel.w60{width:60%} .skel.w80{width:80%} .skel.w30{width:30%} .skel.w20{width:20%}
.skel + .skel{margin-top:var(--sp-2)}
.skel-row{display:grid;grid-template-columns:24px minmax(0,1fr) 120px;gap:var(--sp-3);padding:var(--sp-3) 0;border-top:var(--line) solid var(--border-subtle)}
.skel-row:first-child{border-top:0}
.progress{display:flex;align-items:center;gap:10px}
.progress .bar{flex:1;margin:0}
.progress .fig{min-width:3em;text-align:right}
.steps .dot{line-height:1;font-family:var(--font-sans)}
.steps .fig{font-weight:var(--fw-medium);color:var(--text-secondary);margin-left:auto}
.steps li > span:nth-child(2){flex:1}
.steps .fail .dot{border-color:var(--warn);color:var(--warn-text);font-weight:var(--fw-bold)}
.steps .fail{color:var(--warn-text)}
/* ---- B15 杂项小件 ---- */
.kbd{display:inline-flex;align-items:center;justify-content:center;min-width:22px;height:22px;padding:0 6px;border-radius:var(--radius-tag);background:var(--c-n100);font:var(--fw-bold) var(--fs-caption)/1 var(--font-figures);color:var(--text-secondary);white-space:nowrap}
.muted{color:var(--text-secondary)}
.nw{white-space:nowrap}
.cap{font-size:var(--fs-caption);line-height:var(--lh-caption)}
.fig.m{font-weight:var(--fw-medium)}
.fig.sm{font-size:var(--fs-figure-sm)}
.fig.xs{font-size:var(--fs-figure-xs)}
/* 数字默认字号见 components.css(.fig = 15,小字里 13);这里补上本层的 12px 小字容器 */
:is(.filters-on,.ai-label,.hint-warn,.sign.sm,.sign-note,.dates .left,.stat p,.quota p,.lb-h) .fig{font-size:var(--fs-figure-xs)}
.kv{display:grid;grid-template-columns:96px minmax(0,1fr);gap:var(--sp-2) var(--sp-4);margin:0}
.kv dt{color:var(--text-secondary)}
.kv dd{margin:0}
/* 可移除的筛选标签:中性实底 */
.chip{display:inline-flex;align-items:center;gap:var(--sp-1);height:var(--tag-h);padding:0 3px 0 var(--sp-2);border-radius:var(--radius-tag);background:var(--c-n100);font-size:var(--fs-caption);white-space:nowrap}
.chip button{display:inline-grid;place-items:center;width:18px;height:18px;padding:0;border:0;border-radius:var(--radius-xs);background:none;color:var(--text-secondary);cursor:pointer}
.chip button:hover{background:var(--border-subtle);color:var(--text-primary)}
.drop{display:flex;flex-direction:column;align-items:center;gap:var(--sp-1);border:1.5px dashed var(--border-control);border-radius:var(--radius-card);background:var(--bg-surface);padding:var(--sp-8) var(--sp-6);text-align:center;cursor:pointer;transition:background-color var(--dur-fast) var(--ease),border-color var(--dur-fast) var(--ease)}
.drop:hover,.drop.is-over{border-color:var(--c-ink);background:var(--c-n50)}
.drop > .ic{color:var(--text-secondary)}
.drop b{font-size:var(--fs-title);line-height:var(--lh-title)}
.drop span{color:var(--text-secondary);font-size:var(--fs-caption);line-height:var(--lh-caption)}
.file-row{display:grid;grid-template-columns:40px minmax(0,1fr);grid-auto-flow:column;grid-auto-columns:auto;gap:var(--sp-3);align-items:center;padding:var(--sp-3) var(--sp-4);background:var(--bg-surface);border:var(--line) solid var(--border-subtle);border-radius:var(--radius-card)}
.file-row + .file-row{margin-top:var(--sp-2)}
.file-row .ext{font:var(--fw-bold) 11px/1 var(--font-figures);background:var(--c-n100);border-radius:var(--radius-xs);padding:5px 0;text-align:center}
.file-row b{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.file-row .acts{display:flex;gap:var(--sp-1);align-items:center}
.flowline{display:flex;align-items:center}
.flowline .st{display:flex;align-items:center;gap:var(--sp-2);color:var(--text-secondary);white-space:nowrap} .flowline .st{display:flex;align-items:center;gap:var(--sp-2);color:var(--text-secondary);white-space:nowrap}
.flowline .st i{width:10px;height:10px;border-radius:50%;border:1.5px solid var(--border-control);flex:none} .flowline .st i{width:10px;height:10px;border-radius:50%;border:1.5px solid var(--border-control);flex:none}
.flowline .st.done{color:var(--text-primary)} .flowline .st.done{color:var(--text-primary)}
.flowline .st.done i{background:var(--c-ink);border-color:var(--c-ink)} .flowline .st.done i{background:var(--c-ink);border-color:var(--c-ink)}
.flowline .st.now{color:var(--text-primary);font-weight:var(--fw-bold)} .flowline .st.now{color:var(--text-primary);font-weight:var(--fw-bold)}
.flowline .st.now i{border:3px solid var(--c-ink)} .flowline .st.now i{border:3px solid var(--c-ink)}
.flowline .ln{flex:1;min-width:24px;height:1px;background:var(--border-control);margin:0 var(--sp-3)} .flowline .ln{flex:1;min-width:16px;height:1px;background:var(--border-control);margin:0 var(--sp-2)}
.flowline.sm{font-size:var(--fs-caption)}
.flowline.sm .st i{width:8px;height:8px}
.flowline.sm .st.now i{border-width:2.5px}
/* 头像(列表、记录里的小头像) */
.av{display:inline-grid;place-items:center;flex:none;width:22px;height:22px;border-radius:50%;background:var(--c-n100);color:var(--text-primary);font-size:var(--fs-caption);font-weight:var(--fw-bold);line-height:1}
/* 键值列表 */ /* ---- B16 AI 产出块(§3.3、§7.16;Q2) ----
.kv{display:grid;grid-template-columns:112px 1fr;gap:var(--sp-2) var(--sp-4);margin:0} * 确认前:「AI」小标签 + 左侧 2px 蓝铅笔虚线;正文始终墨色。
.kv dt{color:var(--text-secondary)} * 确认后(.done):虚线去掉,下方出署名行(.sign:绿 ✓ + 姓名 + 时间)。左内边距保留,正文不跳动。 */
.kv dd{margin:0} .ai-block{border-left:2px dashed var(--ai);padding:var(--sp-1) 0 var(--sp-1) var(--sp-3);color:var(--text-primary)}
.ai-block.done{border-left-color:transparent}
.ai-label{display:flex;gap:var(--sp-2);align-items:center;margin-bottom:var(--sp-2);font-size:var(--fs-caption);color:var(--text-secondary)}
.flag.box{background:var(--c-n100);border-radius:var(--radius-tag);padding:0 6px;line-height:var(--tag-h)}
.cite-src.inline{display:inline;margin-left:6px;white-space:nowrap}
/* 署名行(§7.4、§7.16、Q2):确认后的人工签名,绿 ✓ + 姓名 + 时间 + 动作,可撤销。
* <div class="sign"><span class="ok" aria-hidden="true">✓</span><b>王敏</b><span class="fig">11月3日 14:30</span><span>确认无误</span><button type="button" class="undo">撤销</button></div>
* 下方说明(忽略原因、确认说明)用 <p class="sign-note">;「已忽略」不是确认:.sign.ign 用辅助灰和「–」。 */
.sign .fig{font-weight:var(--fw-medium)}
.sign .undo{margin-left:var(--sp-1);padding:0;border:0;background:none;font:var(--fs-caption)/var(--lh-caption) var(--font-sans);color:var(--text-secondary);text-decoration:underline;text-decoration-color:var(--link-underline);text-underline-offset:3px;cursor:pointer}
.sign .undo:hover{color:var(--text-primary);text-decoration-color:var(--c-ink)}
.sign.ign{color:var(--text-secondary)}
.sign.sm{font-size:var(--fs-caption);line-height:var(--lh-caption);margin-top:var(--sp-2)}
.sign-note{margin:2px 0 0;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary)}
.more-cites{display:inline-block;margin-top:var(--sp-2);font-size:var(--fs-caption);color:var(--ai);background:none;border:0;padding:0;cursor:pointer;font-family:var(--font-sans)}
.more-cites:hover{color:var(--ai-strong);text-decoration:underline;text-underline-offset:3px}
.cite q.clamp{-webkit-line-clamp:3}
.cite .expand{font-size:var(--fs-caption);color:var(--ai);background:none;border:0;padding:0;cursor:pointer;font-family:var(--font-sans)}
/* 上传区 */ /* ---- B17 卡片:卡片头、卡片底、统计、额度、关键日期(§7.9、§7.10) ----
.drop{border:1.5px dashed var(--border-control);border-radius:var(--radius-sm);background:var(--bg-surface);padding:var(--sp-10) var(--sp-6);text-align:center;cursor:pointer} * 整块卡片 .card:头用 .card-h,底用 .card-foot(贴边、带发丝线);
.drop:hover{border-color:var(--c-ink)} * 分区卡片 .card.flush:.card-h / .card-b / .card-f 见 components.css。 */
.drop b{display:block;font-size:var(--fs-title);margin-bottom:var(--sp-1)} .card-h{display:flex;align-items:center;gap:var(--sp-3);margin-bottom:var(--sp-3)}
.file-row{display:grid;grid-template-columns:32px minmax(0,1fr) auto auto;gap:var(--sp-3);align-items:center;padding:var(--sp-3) var(--sp-4);background:var(--bg-surface);border:1px solid var(--border-subtle);border-radius:var(--radius-sm)} .card-h .more{margin-left:auto;display:flex;gap:var(--sp-3);align-items:center}
.file-row + .file-row{margin-top:var(--sp-2)} .card.tight{padding:var(--sp-3) var(--card-x)}
.file-row .ext{font:600 11px/1 var(--font-figures);border:1px solid var(--border-control);border-radius:2px;padding:4px 0;text-align:center} .card-foot{display:flex;gap:var(--sp-4);align-items:center;margin:var(--sp-4) calc(-1 * var(--card-x)) calc(-1 * var(--card-y));padding:var(--sp-3) var(--card-x);border-top:var(--line) solid var(--border-subtle)}
.file-row select{height:28px;border:1px solid var(--border-control);border-radius:var(--radius-sm);background:#fff;font:var(--fs-body) var(--font-sans);padding:0 6px} .card.tight .card-foot{margin-bottom:calc(-1 * var(--sp-3))}
.card-foot .r{margin-left:auto;display:flex;gap:var(--sp-2);align-items:center}
/* 项目卡已删除(Q11:MVP 不做项目卡片视图) */
/* 统计(d5):一张卡片四格,竖向发丝线分隔;格子可以是 button(点击筛选,aria-pressed 时底部 2px 墨色边条) */
.stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));background:var(--bg-surface);border:var(--line) solid var(--border-subtle);border-radius:var(--radius-card);overflow:hidden}
.stats.c3{grid-template-columns:repeat(3,minmax(0,1fr))}
.stat{position:relative;display:flex;flex-direction:column;align-items:flex-start;gap:2px;min-width:0;padding:var(--sp-4) var(--card-x);border:0;border-left:var(--line) solid var(--border-subtle);background:none;font:inherit;color:inherit;text-align:left}
.stat:first-child{border-left:0}
button.stat{cursor:pointer;transition:background-color var(--dur-fast) var(--ease)}
button.stat:hover{background:var(--c-n50)}
button.stat:focus-visible{outline-offset:-2px;border-radius:0}
.stat::after{content:"";position:absolute;left:0;right:0;bottom:0;height:2px;background:var(--c-ink);transform:scaleY(0);transform-origin:bottom;transition:transform var(--dur-fast) var(--ease)}
.stat[aria-pressed="true"]::after{transform:scaleY(1)}
.stat[aria-pressed="true"] small{color:var(--text-primary);font-weight:var(--fw-bold)}
.stat small{display:block;font-size:var(--fs-body);line-height:var(--lh-body);color:var(--text-secondary)}
.stat .big{font:var(--fw-bold) var(--fs-figure)/var(--lh-figure) var(--font-figures);font-variant-numeric:tabular-nums}
.stat .big.fatal{color:var(--sev-fatal)}
.stat .big.due{color:var(--warn-text)}
.stat .unit{color:var(--text-secondary);margin-left:4px;font-family:var(--font-figures)}
.stat > .ic{position:absolute;top:18px;right:var(--card-x);color:var(--text-secondary);opacity:0;transition:opacity var(--dur-fast) var(--ease)}
.stat:hover > .ic,.stat:focus-visible > .ic,.stat[aria-pressed="true"] > .ic{opacity:1}
.stat[aria-pressed="true"] > .ic{color:var(--text-primary)}
.stat p{margin:0;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary)}
.stat p.due-soon,.stat p.due-today{color:var(--warn-text)}
/* 额度卡 */
.quota small{display:block;color:var(--text-secondary);font-size:var(--fs-caption);line-height:var(--lh-caption)}
.quota .q2{display:grid;grid-template-columns:1fr 1fr;gap:var(--sp-5)}
.quota .q2 .bar{margin:6px 0 0}
.quota .big.warn{color:var(--warn-text)}
.quota p{margin:0;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary)}
.quota p.warn{color:var(--warn-text)}
/* 关键日期 */
.dates{list-style:none;margin:0;padding:0}
.dates li{display:grid;grid-template-columns:88px minmax(0,1fr) auto;gap:var(--sp-3);align-items:baseline;padding:var(--sp-2) 0;border-top:var(--line) solid var(--border-subtle)}
.dates li:first-child{border-top:0}
.dates li > .fig{font-weight:var(--fw-medium);font-size:var(--fs-figure-sm)}
.dates li.past{color:var(--text-secondary)}
.dates li.key > span:nth-child(2) > b{font-weight:var(--fw-bold)}
.dates .left{display:block;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary)}
.dates .left.due-soon,.dates .left.due-today{color:var(--warn-text)}
.dates .cite-src{white-space:nowrap}
@media (prefers-reduced-motion:reduce){.drawer{transition:none}} /* ---- B18 表格补充:排序、勾选、选中行、批量栏、空结果(§7.7、§8.2) ---- */
.toolbar{display:flex;align-items:center;gap:var(--sp-3);margin-bottom:var(--sp-3);flex-wrap:wrap}
.toolbar .right{margin-left:auto;display:flex;gap:var(--sp-2);align-items:center}
.toolbar .search{width:260px}
.toolbar .pop .select{width:auto;min-width:132px}
.filters-on{display:flex;gap:var(--sp-2);align-items:center;flex-wrap:wrap;margin:calc(-1 * var(--sp-1)) 0 var(--sp-3);font-size:var(--fs-caption);color:var(--text-secondary)}
table.data th.sortable{padding:0}
.th-sort{display:flex;align-items:center;gap:4px;width:100%;padding:var(--sp-3) var(--sp-3) var(--sp-2);border:0;background:none;font:inherit;color:inherit;cursor:pointer;text-align:left;white-space:nowrap}
table.data th.sortable:first-child .th-sort{padding-left:var(--card-x)}
table.data th.sortable:last-child .th-sort{padding-right:var(--card-x)}
.th-sort:hover{color:var(--text-primary)}
.th-sort .ic{width:14px;height:14px;color:var(--text-disabled)}
th[aria-sort] .th-sort{color:var(--text-primary)}
th[aria-sort] .th-sort .ic{color:var(--text-primary)}
table.data th.r .th-sort{justify-content:flex-end}
table.data .ckc{width:40px;padding-right:0}
table.data td.empty-cell{height:auto;padding:0}
table.data td .cap{color:var(--text-secondary)}
table.data .acts{white-space:nowrap;text-align:right}
table.data td.mid{vertical-align:middle}
.batchbar{position:sticky;top:calc(var(--topbar-h) + var(--sp-2));z-index:10;display:flex;align-items:center;gap:var(--sp-3);height:48px;padding:0 var(--sp-2) 0 var(--card-x);margin-bottom:var(--sp-2);background:var(--bg-surface);border:var(--line) solid var(--c-ink);border-radius:var(--radius-card)}
.batchbar b .fig{font-weight:var(--fw-bold)}
.batchbar .r{margin-left:auto;display:flex;gap:var(--sp-2);align-items:center}
.list-box{background:var(--bg-surface);border:var(--line) solid var(--border-subtle);border-radius:var(--radius-card);overflow:hidden}
.list-box > .lb-h{display:flex;align-items:center;gap:var(--sp-3);min-height:40px;padding:var(--sp-1) var(--card-x);border-bottom:var(--line) solid var(--border-subtle);font-size:var(--fs-caption);color:var(--text-secondary)}
.list-box > .lb-h .r{margin-left:auto;display:flex;gap:var(--sp-2);align-items:center}
.list-box .item{padding:var(--sp-3) var(--card-x);border-top:var(--line) solid var(--border-subtle)}
.list-box .item:first-of-type{border-top:0}
.list-box .item.has-ck{grid-template-columns:20px 24px minmax(0,1fr) auto}
.list-box .item .ck{margin-top:3px}
/* 当前条与勾选条(§7.18,v0.2 收尾):
* 当前条(键盘 J / K、点击定位到的那一条)= --c-n50 底 + 左侧 3px 墨色竖条:.item.sel / .finding.sel / tr.sel;
* 键盘操作时再加 2px 墨色内框:条目自身 :focus-visible、加 .focus,或列表容器 .kb-on / :focus-visible;
* 勾选条(批量处理的复选框)只有复选框是选中状态,不加底色和竖条;
* 致命行不加红竖线,竖条只表示「当前」,和级别无关。 */
.finding.sel{background:var(--c-n50);box-shadow:inset 3px 0 0 var(--c-ink)}
.finding{transition:background-color var(--dur-fast) var(--ease)}
:is(.item,.finding).focus,.item[tabindex]:focus-visible{box-shadow:inset 0 0 0 2px var(--focus-ring)}
:is(.item,.finding).sel.focus,.item.sel[tabindex]:focus-visible,:is(.kb-on,:focus-visible) > :is(.item,.finding).sel{box-shadow:inset 3px 0 0 var(--c-ink),inset 0 0 0 2px var(--focus-ring)}
table.data tbody tr.sel.focus td{box-shadow:inset 0 2px 0 var(--focus-ring),inset 0 -2px 0 var(--focus-ring)}
.item[tabindex]{cursor:pointer;outline:none;transition:background-color var(--dur-fast) var(--ease)}
.item[tabindex]:hover{background:var(--c-n50)}
.item .upd{margin-top:6px;display:flex;gap:var(--sp-2);align-items:center;font-size:var(--fs-caption)}
.item .upd .btn{height:24px;padding:0 var(--sp-2);font-size:var(--fs-caption);border-radius:var(--radius-tag)}
/* 问题行带复选框(列表页批量处理只给「提示」) */
.finding .ckw{position:absolute;left:14px;top:calc(var(--sp-5) + 2px)}
.finding.has-ck{padding-left:calc(var(--card-x) + 24px)}
/* 行内说明(§7.4、U4):忽略原因、致命问题「确认无误」的说明,在问题行内展开,替换处理按钮;无底色,不在卡片里再套面板 */
.u4{margin-top:var(--sp-3)}
.u4 .field{max-width:560px}
.u4 .chips .btn{height:26px}
.u4 .row{margin-top:var(--sp-3);gap:var(--sp-2)}
.group-h{display:flex;align-items:center;gap:var(--sp-2);width:100%;padding:var(--sp-3) var(--card-x);border:0;border-top:var(--line) solid var(--border-subtle);background:none;color:var(--text-secondary);font:var(--fs-body) var(--font-sans);cursor:pointer;text-align:left;transition:background-color var(--dur-fast) var(--ease)}
.group-h:hover{background:var(--c-n50);color:var(--text-primary)}
.verdict.hint{color:var(--sev-hint)}
/* ---- B19 对照布局:分类导航 + 列表 + 原文查看器(§5.3、§7.8;v0.2 收尾,原 .split) ----
* <div class="cmp" data-cmp>
* <section class="card flush cmp-list"><nav class="cats">…</nav><div class="lst">…</div></section>
* <div class="rsz" role="separator" tabindex="0" aria-label="调整原文查看器宽度(← → 键)"></div>
* <section class="viewer">…</section>
* </div>
* 尺寸:分类导航 176(1400 以下 152);列表最小 560;查看器默认 45%,最小 400,拖动或 ← → 调整(Demo.cmp)。
* 1280 宽时:列表 ≥ 560、查看器 ≤ 560,不横向滚动。整屏布局:.cmp 撑满剩余高度,三栏各自滚动。 */
.cmp{--cats-w:176px;--list-min:560px;--viewer-min:400px;flex:1;min-height:0;display:grid;grid-template-columns:minmax(var(--list-min),1fr) var(--gap-card) var(--vw,45%);padding:0 var(--pad-page) var(--sp-4)}
@media (max-width:1399px){.cmp{--cats-w:152px}}
.cmp-list{display:grid;grid-template-columns:var(--cats-w) minmax(0,1fr);min-height:0;overflow:hidden}
.cmp-list > .lst{position:relative;min-height:0;overflow:auto}
.cmp > .viewer{min-width:0}
/* 拖动条:平时不可见,悬停 / 拖动时出一条浅灰细线,键盘聚焦时墨色 */
.rsz{position:relative;cursor:col-resize;outline:0;touch-action:none}
.rsz::after{content:"";position:absolute;top:var(--sp-3);bottom:var(--sp-3);left:50%;width:2px;margin-left:-1px;border-radius:1px;background:transparent;transition:background-color var(--dur-fast) var(--ease)}
.rsz:hover::after,.rsz.on::after{background:var(--c-n300)}
.rsz:focus-visible::after{background:var(--focus-ring)}
body.dragging{cursor:col-resize;user-select:none}
/* 分类导航:在列表卡片左侧;当前分类 = --c-n100 实底 + 加粗(导航的「当前」,不是列表项的「当前条」) */
.cats{display:flex;flex-direction:column;gap:2px;border-right:var(--line) solid var(--border-subtle);padding:var(--sp-3) var(--sp-2);overflow:auto}
.cats button{display:flex;flex:none;width:100%;align-items:center;justify-content:space-between;height:var(--control-h-sm);padding:0 10px;border:0;background:none;border-radius:var(--radius-ctl);font:var(--fs-body) var(--font-sans);color:var(--text-primary);cursor:pointer;text-align:left;transition:background-color var(--dur-fast) var(--ease)}
.cats button:hover{background:var(--c-n100)}
/* 当前分类:和筛选一样用 --c-n100 实底 + 加粗(导航的「当前」,不是列表项的「选中」) */
.cats button[aria-current="true"]{background:var(--c-n100);font-weight:var(--fw-bold)}
.cats button .fig{font-size:var(--fs-figure-sm);font-weight:var(--fw-medium);color:var(--text-secondary)}
.cats button.ai,.cats button.ai .fig{color:var(--ai)}
.cats hr{width:calc(100% - 20px);border:0;border-top:var(--line) solid var(--border-subtle);margin:var(--sp-2) 10px}
.cats .hint{margin:auto 0 0;padding:var(--sp-3) 10px 0;font-size:var(--fs-caption);line-height:26px;color:var(--text-secondary)}
.cats .hint .kbd{margin-right:2px}
/* 列表里的分组标题;.ai 是清单顶部的「待确认」分组(§7.6 v0.2:蓝色标题,不再用虚线框) */
.grp-h{display:flex;align-items:baseline;gap:var(--sp-2);padding:var(--sp-5) var(--card-x) var(--sp-2)}
.grp-h b{font-weight:var(--fw-bold)}
.grp-h .fig{font-weight:var(--fw-medium);color:var(--text-secondary)}
.grp-h.ai b,.grp-h.ai .fig{color:var(--ai)}
.grp-h p{margin:0;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary)}
.viewer{min-height:0}
.viewer-bar{flex-wrap:nowrap;min-height:48px}
.viewer-bar .doc-name{flex:1;display:flex;gap:var(--sp-2);align-items:baseline;min-width:0}
.viewer-bar .pager{gap:var(--sp-1)}
.viewer-bar .pager > .fig{margin-right:var(--sp-2);font-size:var(--fs-figure-xs);color:var(--text-secondary)}
.viewer-bar .doc-file{color:var(--text-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:220px}
.viewer-body{flex:1;overflow:auto;padding:var(--sp-4) 0}
.viewer .sheet{min-height:560px;margin:0 auto;width:calc(100% - 48px);max-width:640px;position:relative;background:var(--bg-surface);color:var(--text-primary)}
.sheet .pno{position:absolute;bottom:14px;left:0;right:0;text-align:center;font:var(--fs-caption) var(--font-figures);color:var(--text-secondary)}
.sheet .h{font-weight:var(--fw-bold);margin:4px 0 10px}
.sheet .faint{color:var(--text-secondary)}
.sheet .stub{height:9px;background:var(--c-n100);margin:12px 0;border-radius:var(--radius-xs)}
.sheet .stub.s{width:60%}
.viewer-note{font-size:var(--fs-caption);color:var(--text-secondary);padding:var(--sp-2) var(--sp-6) 0;margin:0;text-align:center}
/* ---- B20 项目行(§7.9,v0.2 收尾:工作台和列表页共用) ----
* 栅格对齐的列表(不是 table),整行可点:
* <div class="prow-head">项目 · 投标截止 · 状态 · 致命 · 下一步 · (空)</div>
* <ul class="prows"><li class="prow">
* <span class="pt"><a class="pname">…</a><span class="psub">…</span></span>
* <span class="dl"><b>剩 17 天</b><small>11月20日 09:30</small></span>
* <span class="st"><span class="tag">已检查</span>未就绪</span>
* <span class="c">致命标签或 —</span>
* <span><button class="nx"><span>下一步</span>›</button></span>
* <span class="more-cell"><button class="icon-btn sm">⋯</button></span></li></ul>
* 规则:「下一步」表头和内容左对齐,行悬停 / 聚焦时箭头右移 3px;「更多」单独一列常驻,平时辅助灰,行悬停变墨色;「致命」列居中。 */
:root{--prow-cols:minmax(0,1fr) 120px 120px 64px 140px var(--control-h-sm);--prow-gap:var(--sp-4)}
@media (max-width:1399px){:root{--prow-cols:minmax(0,1fr) 108px 108px 56px 132px var(--control-h-sm);--prow-gap:var(--sp-3)}}
.prow-head,.prow{display:grid;grid-template-columns:var(--prow-cols);column-gap:var(--prow-gap);align-items:center;padding:0 var(--card-x)}
.prow-head{padding-bottom:var(--sp-2);font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary)}
:is(.prow-head,.prow) > .c{justify-self:center;text-align:center}
.prows{list-style:none;margin:0;padding:0}
.prows > .empty{border-top:var(--line) solid var(--border-subtle)}
.prow{position:relative;min-height:var(--row-h);padding-top:10px;padding-bottom:10px;border-top:var(--line) solid var(--border-subtle);cursor:pointer;transition:background-color var(--dur-fast) var(--ease)}
.prow:hover,.prow:focus-within{background:var(--bg-hover)}
.prow:last-child{border-radius:0 0 var(--radius-card) var(--radius-card)}
.prow:has(.pname:focus-visible){outline:2px solid var(--focus-ring);outline-offset:-2px}
.prow .pt{min-width:0}
.pname{display:block;font-weight:var(--fw-bold);color:var(--text-primary);text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;outline:0}
.prow .pname::after{content:"";position:absolute;inset:0}
.psub{display:block;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.prow .dl b{display:block;font-weight:var(--fw-regular);white-space:nowrap}
.prow .dl b.due-today{font-weight:var(--fw-bold)}
.prow .dl small{display:block;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary);white-space:nowrap}
.prow .st{display:flex;align-items:center;gap:var(--sp-2);white-space:nowrap}
.prow .st .hint-u{position:relative;z-index:1}
.prow.arch .pname,.prow.arch .dl b{color:var(--text-secondary);font-weight:var(--fw-regular)}
.dash{color:var(--text-secondary)}
.nx{position:relative;z-index:1;display:inline-flex;align-items:center;gap:var(--sp-1);height:var(--control-h-sm);padding:0;border:0;background:none;font:inherit;color:var(--text-primary);white-space:nowrap;cursor:pointer}
.nx span{text-decoration:underline;text-decoration-color:var(--link-underline);text-underline-offset:3px;transition:text-decoration-color var(--dur-fast) var(--ease)}
.nx:hover span{text-decoration-color:var(--c-ink)}
.nx .ic{color:var(--text-secondary);transition:transform var(--dur-fast) var(--ease),color var(--dur-fast) var(--ease)}
.prow:hover .nx .ic,.prow:focus-within .nx .ic{transform:translateX(3px);color:var(--text-primary)}
.prow .more-cell{justify-self:end}
.prow .icon-btn{position:relative;z-index:1;color:var(--text-secondary)}
.prow:hover .icon-btn,.prow:focus-within .icon-btn,.prow .icon-btn[aria-expanded="true"]{color:var(--text-primary)}
/* 可排序的列名(栅格表头版的 .th-sort) */
.prow-head .srt{display:inline-flex;align-items:center;gap:4px;padding:0;border:0;background:none;font:inherit;color:inherit;cursor:pointer;white-space:nowrap}
.prow-head .srt:hover,.prow-head .srt[data-dir]{color:var(--text-primary)}
.prow-head .srt .ic{width:14px;height:14px;color:var(--text-disabled)}
.prow-head .srt[data-dir] .ic{color:var(--text-primary)}
/* =====================================================================
* 3. 原型特有
* ===================================================================== */
/* 原文查看器补充:空状态、关闭按钮前的分隔线 */
.viewer-empty{margin:var(--sp-8) var(--sp-6) 0;color:var(--text-secondary);font-size:var(--fs-caption);line-height:var(--lh-caption);text-align:center}
.viewer-bar .vsep{flex:none;width:1px;height:16px;margin:0 2px;background:var(--border-subtle)}
/* 抽屉(批注布局、概览页):从右侧滑出原文查看器,不跳页面(App.drawer()) */
.drawer{position:fixed;top:var(--sp-2);right:0;bottom:var(--sp-2);width:min(46vw,720px);z-index:40;display:flex;flex-direction:column;background:var(--c-n100);border-radius:var(--radius-card) 0 0 var(--radius-card);box-shadow:var(--shadow-overlay);overflow:hidden;transform:translateX(calc(100% + 16px));visibility:hidden;transition:transform var(--dur-base) var(--ease),visibility 0s linear var(--dur-base)}
.drawer.open{transform:none;visibility:visible;transition:transform var(--dur-base) var(--ease)}
.drawer > .viewer{flex:1;border:0;border-radius:0}
/* 筛选:v0.1 的 .on 写法也认(新代码用 aria-pressed) */
.seg button.on{background:var(--c-n100);color:var(--text-primary);font-weight:var(--fw-bold)}
/* 需人工核对的清单(查标页底部):复选框 + 条目 + 出处;勾选后右侧出署名行 */
.chk{display:flex;gap:var(--sp-3);align-items:flex-start;padding:var(--sp-3) 0;border-top:var(--line) solid var(--border-subtle)}
.chk:first-child{border-top:0}
.chk > .ck{margin-top:3px}
.card.flush .chk{padding-left:var(--card-x);padding-right:var(--card-x)}
/* 免费版角标、待补录占位 */
.free-badge{display:inline-flex;align-items:center;height:var(--tag-h);padding:0 var(--sp-2);border-radius:var(--radius-tag);background:var(--c-n100);font-size:var(--fs-caption);white-space:nowrap}
.placeholder{border:var(--line) dashed var(--border-control);border-radius:var(--radius-ctl);padding:var(--sp-4);color:var(--text-secondary);text-align:center}
/* 页面间距小件 */
.gap-b{margin-bottom:var(--gap-card)}
.mt0{margin-top:0}

View File

@@ -1,74 +1,179 @@
/* 原型公共脚本:页面框架、出处、原文查看器、对话框、轻提示。只模拟交互,不连后端。 */ /* 标博士原型 · 公共脚本 v0.2(2026-11-03)
* 页面框架、图标、出处、原文查看器(并排 / 抽屉)、对话框、轻提示、浮动菜单、快捷键说明、日期输入、对照布局、开关 / 筛选 / 标签页 / 排序。
* 只模拟交互,不连后端。实现照搬 design/ui-demo/demo.js(v0.2 参考实现),但不引用 ui-demo 目录,原型独立运行。
* v0.2:App.shell() 生成 96px 墨色渐变侧栏 + 透明顶栏 + 内容区圆角;传 tab 时另加项目头和「概览 / 读标 / 查标 / 报告」标签页。
* 页面调用方式和 v0.1 相同:App.shell / cite / citeLabel / quote / viewer / dialog / toast 的参数不变。 */
(function () { (function () {
const D = window.PROTO; const D = window.PROTO;
const $ = (s, el = document) => el.querySelector(s); const $ = (s, el = document) => el.querySelector(s);
const $$ = (s, el = document) => [...el.querySelectorAll(s)]; const $$ = (s, el = document) => [...el.querySelectorAll(s)];
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c])); const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
/* Lucide 图标(ISC 许可)的简化路径 */ /* ---------- 图标:Lucide(ISC 许可)路径 ---------- */
const ICONS = { 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"/>', 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"/>', 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 14l4-4"/><path d="M3.3 19a9 9 0 1 1 17.4 0"/>', 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.9 1.9 0 0 0 3.4 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.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3M12 17h.01"/>', 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 18M6 6l12 12"/>', x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
plus: '<path d="M12 5v14M5 12h14"/>', plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
upload: '<path d="M12 3v12M7 8l5-5 5 5"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>', 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"/>',
file: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/>', 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"/>',
keyboard: '<rect x="2" y="5" width="20" height="14" rx="2"/><path d="M6 9h.01M10 9h.01M14 9h.01M18 9h.01M7 15h10"/>' 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"/>',
"arrow-left": '<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>',
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"/>',
lock: '<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
printer: '<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><path d="M6 9V3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v6"/><rect x="6" y="14" width="12" height="8" rx="1"/>',
external: '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>',
"log-in": '<path d="m10 17 5-5-5-5"/><path d="M15 12H3"/><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/>'
}; };
const icon = (n, cls = "ic") => `<svg class="${cls}" viewBox="0 0 24 24" aria-hidden="true">${ICONS[n]}</svg>`; const icon = (n, cls = "") => `<svg class="ic ${cls}" viewBox="0 0 24 24" aria-hidden="true">${ICONS[n] || ""}</svg>`;
/* 静态 HTML 里写 <i data-ic="plus" class="lg"></i>,页面加载后换成 SVG;动态插入的内容可再调 App.hydrate(容器) */
function hydrate(root = document) {
$$("i[data-ic]", root).forEach((el) => { el.outerHTML = icon(el.dataset.ic, el.className); });
}
/* ---------- 页面框架 ---------- */ /* ---------- 演示里的「现在」:2026-11-02 15:00(与示意数据一致) ---------- */
function shell(opt) { const [TY, TM, TD] = (D.today || "2026-11-02").split("-").map(Number);
const TODAY = new Date(TY, TM - 1, TD), NOW = new Date(TY, TM - 1, TD, 15, 0), TODAY_KEY = `${TY}-${TM}-${TD}`;
/* ---------- 页面框架 v0.2 ----------
* App.shell({ nav, crumbs, tab, search, fit })
* nav:当前菜单 workbench / quals / quota;不传时,面包屑以「工作台」开头或有 tab 的页面自动选中「工作台」;传 null 不选中
* crumbs:[[文字, 链接], …],最后一项是当前页;顶栏左侧显示「公司 / 面包屑」。
* 有 tab 时最后一项若是标签页名(概览 / 读标 / 查标 / 报告)会自动去掉,由项目头的标签页表示当前页
* tab:overview / checklist / results / report,项目内页面传;生成项目头(名称、采购人、截止、状态、就绪)+ 标签页
* fatal:项目头里的致命数,默认按 D.check.findings 里未处理的致命问题算
* search:{ placeholder, onInput(q) };不传 onInput 时回车只出原型提示。Ctrl K 聚焦
* fit:整屏布局(读标、查标的对照布局):main 高 100vh 不滚动,里面的栏各自滚动
* URL 带 ?quota=warn / ?quota=out 可预览额度 80% 和用完(Q9);?plan=free / pro / ent 预览套餐标签。 */
const PLAN = { free: "免费版", pro: "专业版", ent: "企业版" };
const PLAN_KEY = { 免费版: "free", 专业版: "pro", 企业版: "ent" };
const TABS = [["project.html", "概览", "overview"], ["checklist.html", "读标", "checklist"], ["results.html", "查标", "results"], ["report.html", "报告", "report"]];
const READY_TIP = "只表示致命问题都已处理,不代表投标文件没有问题。";
let head = null; // 项目头的当前状态
function shell(opt = {}) {
const qs = new URLSearchParams(location.search);
const P = D.project; const P = D.project;
const nav = [ let crumbs = (opt.crumbs || []).slice();
["workbench.html", "grid", "工作台", "workbench"], if (opt.tab && crumbs.length > 1 && TABS.some((t) => t[1] === crumbs[crumbs.length - 1][0])) crumbs.pop();
["qualifications.html", "cert", "资质库", "quals"], const navKey = opt.nav || (opt.nav === null ? "" : (opt.tab || (crumbs[0] && crumbs[0][0] === "工作台")) ? "workbench" : "");
["quota.html", "gauge", "额度", "quota"] const nav = [["workbench.html", "house", "工作台", "workbench"], ["qualifications.html", "award", "资质库", "quals"], ["quota.html", "wallet", "额度", "quota"]]
].map(([h, i, t, k]) => `<a href="${h}" class="${opt.nav === k ? "on" : ""}">${icon(i)}${t}</a>`).join(""); .map(([h, i, t, k]) => `<a href="${h}" ${navKey === k ? 'aria-current="page"' : ""}>${icon(i)}<span>${t}</span></a>`).join("");
const side = `<aside class="sidebar"> const crumbHTML = 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("");
<a class="brand" href="workbench.html"><img src="../design/logo/svg/logo-horizontal.svg" alt="标博士"></a> const plan = PLAN[qs.get("plan")] ? qs.get("plan") : (PLAN_KEY[D.user.plan] || "pro");
<nav class="nav" aria-label="主导航">${nav}<div class="sub">原型</div><a href="index.html">${icon("help")}原型目录</a></nav> const quota = { parse: [D.quota.parse.left, D.quota.parse.total], check: [D.quota.check.left, D.quota.check.total] };
<div class="sidebar-foot"><b>${D.user.name}</b>${D.user.company}<br>${D.user.plan} · 至 2027-10-31</div> if (qs.get("quota") === "warn") quota.check = [3, quota.check[1]];
</aside>`; if (qs.get("quota") === "out") quota.check = [0, quota.check[1]];
const crumbs = (opt.crumbs || []).map((c, i, a) => i < a.length - 1 ? `<a href="${c[1]}">${c[0]}</a><span>/</span>` : `<b>${c[0]}</b>`).join(""); const qItem = (name, [left, total]) => `<span class="q ${(total - left) / total >= 0.8 ? "warn" : ""}">${name}<span class="fig">${left}</span></span>`;
const top = `<header class="top"> const out = [["解析", quota.parse], ["检查", quota.check]].filter(([, [l]]) => l <= 0).map(([n]) => n);
<div class="crumbs">${crumbs}</div> const qTip = `本期剩余额度:解析 ${quota.parse[0]} / ${quota.parse[1]} 次,检查 ${quota.check[0]} / ${quota.check[1]} 次` +
<div class="top-right"> (out.length ? `。本期${out.join("、")}额度已用完,可在额度页提交续费申请,或等下期发放` : "");
<a class="quota-chip" href="quota.html" title="本期剩余额度">解析<span class="fig">${D.quota.parse.left}</span>检查<span class="fig">${D.quota.check.left}</span></a> const ph = (opt.search && opt.search.placeholder) || "搜索项目、采购人";
<button class="icon-btn" aria-label="通知" data-notice>${icon("bell")}<span class="dot"></span></button>
</div> if (!$('link[rel="icon"]')) document.head.insertAdjacentHTML("beforeend", '<link rel="icon" href="../design/logo/favicon.svg">');
</header>`;
let head = "";
if (opt.tab) {
const tabs = [["project.html", "概览", "overview"], ["checklist.html", "读标", "checklist"], ["results.html", "查标", "results"], ["report.html", "报告", "report"]]
.map(([h, t, k]) => `<a href="${h}" class="${opt.tab === k ? "on" : ""}">${t}${k === "results" ? ' <span class="sev fatal" data-fatal-count>致命<span class="fig">3</span></span>' : ""}</a>`).join("");
head = `<div class="proj-head"><div class="line1"><div>
<h1>${P.name}</h1>
<div class="meta"><span>${P.buyer}</span><span>${P.regime} · ${P.method}</span><span>投 采购包 1(共 2 个包)</span><span class="fig" style="font-weight:500">${P.no}</span></div>
</div>
<div class="facts">
<div><small>投标截止</small><span class="fig">${P.deadlineText}</span> · 剩 <span class="fig">${P.daysLeft}</span> 天</div>
<div><small>状态</small><span class="tag status">${P.status}</span></div>
<div><small>就绪</small><span data-ready>未就绪</span></div>
</div></div>
<nav class="tabs" aria-label="项目">${tabs}</nav></div>`;
}
document.head.insertAdjacentHTML("beforeend", '<link rel="icon" href="../design/logo/favicon.svg">');
document.body.classList.add("app"); document.body.classList.add("app");
document.body.insertAdjacentHTML("afterbegin", `<aside class="sidebar">
<a class="brand" href="workbench.html" aria-label="标博士 · 工作台"><img src="../design/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>原型目录</span></a></nav>
<div class="me"><span class="avatar" aria-hidden="true">${esc(D.user.name.slice(0, 1))}</span><b class="uname">${esc(D.user.name)}</b><span class="plan" data-plan="${plan}">${PLAN[plan]}</span></div></aside>`);
const main = $("main"); const main = $("main");
main.classList.add("main"); main.classList.add("main");
document.body.insertAdjacentHTML("afterbegin", side); if (opt.fit) main.classList.add("fit");
main.insertAdjacentHTML("afterbegin", top + head); let phHTML = "";
$("[data-notice]").addEventListener("click", () => toast("解析完成:补遗 1 已并入清单(新增 2 条,更新 1 条)。")); if (opt.tab) {
const lot = P.lots.find((l) => l.bid);
phHTML = `<header class="ph">
<div class="ph-l1">
<div><h1 title="${esc(P.name)}">${esc(P.name)}</h1>
<p class="ph-meta">${esc(P.buyer)} · ${P.regime} · ${P.method}${lot ? ` · 投采购包 ${lot.no}(共 ${P.lots.length} 个包)` : ""} · <span class="fig">${P.no}</span></p></div>
<dl class="facts">
<div><dt>投标截止</dt><dd><span class="fig m">${P.deadlineText}</span><span class="muted">剩 <span class="fig">${P.daysLeft}</span> 天</span></dd></div>
<div><dt>状态</dt><dd><span class="tag" data-ph-status></span></dd></div>
<div data-ph-ready-box><dt>就绪</dt><dd data-ready></dd></div>
</dl>
</div>
<nav class="tabs" aria-label="项目">${TABS.map(([h, t, k]) => `<a role="tab" href="${h}" aria-selected="${opt.tab === k}">${t}${k === "results" ? "<span data-fatal-count></span>" : ""}</a>`).join("")}</nav>
</header>`;
}
main.insertAdjacentHTML("afterbegin", `<header class="top">
<div class="crumbs"><span class="org">${esc(D.user.company)}</span><span aria-hidden="true">/</span>${crumbHTML}</div>
<form class="top-search" role="search" data-gsearch>${icon("search")}<input type="search" placeholder="${esc(ph)}" aria-label="${esc(ph)}" 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="quota.html" data-tip="${qTip}" aria-label="${qTip}">${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>${phHTML}`);
if (opt.tab) {
const fatal = opt.fatal !== undefined ? opt.fatal : D.check.findings.filter((f) => f.sev === "fatal" && !f.status).length;
head = { fatal, status: P.status };
projHead({});
}
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();
});
$("[data-notice]").onclick = () => toast("解析完成:补遗 1 已并入清单(更新 2 条,新增 0 条)。");
}
/* 项目头联动:App.projHead({ fatal, status })。
* fatal:未处理的致命问题数,同时更新「查标」标签上的计数和「就绪」;status:项目状态(跟进中 / 编制中 / 已检查 / 已递交 / 已归档)。
* 「就绪」只在「已检查」时显示:致命为 0 → 就绪(虚线下划线,悬停说明不代表没有问题);否则 未就绪(悬停说明不影响导出)。 */
function projHead(upd = {}) {
if (!head) return;
Object.assign(head, upd);
const { fatal, status } = head;
const st = $("[data-ph-status]"); if (st) st.textContent = status;
$$("[data-fatal-count]").forEach((el) => { el.className = fatal ? "sev fatal" : ""; el.innerHTML = fatal ? `致命<span class="fig">${fatal}</span>` : ""; });
const box = $("[data-ph-ready-box]"); if (box) box.hidden = status !== "已检查";
const r = $("[data-ready]");
if (r) r.innerHTML = fatal
? `<span class="hint-u tip-below tip-left" tabindex="0" data-tip="还有 ${fatal} 个致命问题没处理。不影响导出,决定权在你。">未就绪</span>`
: `<span class="hint-u tip-below tip-left" tabindex="0" data-tip="${READY_TIP}">就绪</span>`;
} }
/* ---------- 出处 ---------- */ /* ---------- 出处 ---------- */
const docLabel = (doc) => D.docs[doc].name;
function citeLabel(c) { function citeLabel(c) {
const d = D.docs[c[0]]; return `${docLabel(c[0])} · 第 <span class="fig">${c[1]}</span> 页`;
return `${d.name} · <span class="fig">第 ${c[1]} 页</span>`;
} }
function quote(c) { function quote(c) {
const p = (D.pages[c[0]] || {})[c[1]] || []; const p = (D.pages[c[0]] || {})[c[1]] || [];
@@ -78,79 +183,465 @@
/* 段落里只标出关键原句;没有关键原句时整段标出 */ /* 段落里只标出关键原句;没有关键原句时整段标出 */
function marked(text, key, tag = "mark") { function marked(text, key, tag = "mark") {
const i = key ? text.indexOf(key) : -1; const i = key ? text.indexOf(key) : -1;
if (i < 0) return `<${tag}>${esc(text)}</${tag.split(" ")[0]}>`; const close = tag.split(" ")[0];
return esc(text.slice(0, i)) + `<${tag}>${esc(key)}</${tag.split(" ")[0]}>` + esc(text.slice(i + key.length)); if (i < 0) return `<${tag}>${esc(text)}</${close}>`;
return esc(text.slice(0, i)) + `<${tag}>${esc(key)}</${close}>` + esc(text.slice(i + key.length));
} }
/* 出处块(页边批注):文件 · 页码 + 原句;opt.noQuote 只要链接;opt.clamp 原句收成 3 行 */
function cite(c, opt = {}) { function cite(c, opt = {}) {
return `<div class="cite"><a class="cite-src" href="#" data-open="${esc(c.join("|"))}">${citeLabel(c)}</a>${opt.noQuote ? "" : `<q>${marked(quote(c), c[3])}</q>`}</div>`; return `<div class="cite"><a class="cite-src" href="#" data-open="${esc(c.join("|"))}">${citeLabel(c)}</a>${opt.noQuote ? "" : `<q class="${opt.clamp ? "clamp" : ""}">${marked(quote(c), c[3])}</q>`}</div>`;
} }
/* 行内出处链接(列表行右侧):蓝铅笔字 + 文件图标 */
function citeLink(c, label) {
return `<a class="cite-link" href="#" data-open="${esc(c.join("|"))}">${icon("file")}${label || citeLabel(c)}</a>`;
}
const parseCite = (s) => { const c = String(s).split("|"); c[1] = +c[1]; return c; };
/* ---------- 原文查看器 ---------- */ /* ---------- 原文查看器 ----------
* App.viewer(el, { closable, onClose, onOpen, empty, onPrev, onNext, prevLabel, nextLabel, prevKey, nextKey })
* → { open(c, all), clear(msg), setNav(canPrev, canNext), el }
* c 为出处 [文件, 页码, 段落 id, 关键原句];all 为同一条的全部出处,默认用「上一处 / 下一处」在 all 里翻。
* 传 onPrev / onNext 时,两个按钮改由页面接管(如读标清单的「上一条 / 下一条」),是否可点由页面调 setNav() 决定;
* prevLabel / nextLabel 改按钮文案(默认「上一处」「下一处」),prevKey / nextKey 写进悬停说明和读屏名(如「K」「J」)。
* clear(msg):没有出处时清空查看器,只显示一句说明(如「这条是人工确认的,没有出处。」)。 */
function viewer(el, opt = {}) { function viewer(el, opt = {}) {
let cur = null, list = []; let cur = null, list = [];
const custom = !!(opt.onPrev || opt.onNext);
const pl = opt.prevLabel || "上一处", nl = opt.nextLabel || "下一处";
const navBtn = (lb, k, attr, ic) => `<button class="icon-btn sm tip-below tip-left" aria-label="${lb}${k ? `(${k})` : custom ? "" : "出处"}" data-tip="${lb}${k ? " " + k : ""}" ${attr} ${custom ? "" : "disabled"}>${icon(ic)}</button>`;
const emptyMsg = opt.empty || "点清单项或出处,在这里看原文和高亮的原句。";
el.classList.add("viewer"); el.classList.add("viewer");
el.innerHTML = `<div class="viewer-bar"> el.innerHTML = `<div class="viewer-bar">
<span class="doc-name"><b data-v-name>原文</b><span class="doc-file" data-v-file></span></span> <span class="doc-name"><b data-v-name>原文</b><span class="doc-file" data-v-file></span></span>
<div class="pager"><button class="btn sm" data-v-prev>上一处</button><span class="fig" data-v-page></span><button class="btn sm" data-v-next>下一处</button>${opt.closable ? `<button class="icon-btn drawer-close" aria-label="关闭原文" data-v-close>${icon("x")}</button>` : ""}</div> <div class="pager"><span class="fig" data-v-page></span>
${navBtn(pl, opt.prevKey, "data-v-prev", "chev-left")}
${navBtn(nl, opt.nextKey, "data-v-next", "chev-right")}
${opt.closable ? `<span class="vsep" aria-hidden="true"></span><button class="icon-btn sm tip-below tip-left" aria-label="关闭原文(Esc)" data-tip="关闭 Esc" data-v-close>${icon("x")}</button>` : ""}</div>
</div> </div>
<div class="viewer-body" data-v-body><p class="viewer-empty">点击清单项或出处,在这里查看原文并高亮原句。</p></div>`; <div class="viewer-body" data-v-body><p class="viewer-empty">${emptyMsg}</p></div>`;
const body = $("[data-v-body]", el); const body = $("[data-v-body]", el), prev = $("[data-v-prev]", el), next = $("[data-v-next]", el);
const idx = () => cur ? list.findIndex((x) => x.join("|") === cur.join("|")) : -1;
function setNav(p, n) { prev.disabled = !p; next.disabled = !n; }
function render() { function render() {
const [doc, page, hl] = cur; const [doc, page, hl] = cur;
const d = D.docs[doc]; const d = D.docs[doc];
$("[data-v-name]", el).textContent = d.name; $("[data-v-name]", el).textContent = d.name;
$("[data-v-file]", el).textContent = d.file; $("[data-v-file]", el).textContent = d.file;
$("[data-v-file]", el).title = d.file;
$("[data-v-page]", el).textContent = `第 ${page} / ${d.pages} 页`; $("[data-v-page]", el).textContent = `第 ${page} / ${d.pages} 页`;
const paras = (D.pages[doc] || {})[page]; const paras = (D.pages[doc] || {})[page];
const inner = paras const inner = paras
? paras.map((p) => p.h ? `<p class="h">${esc(p.t)}</p>` : `<p class="${p.faint ? "faint" : ""}">${p.id === hl ? marked(p.t, cur[3], 'span class="hl on"') : esc(p.t)}</p>`).join("") ? paras.map((p) => p.h ? `<p class="h">${esc(p.t)}</p>` : `<p class="${p.faint ? "faint" : ""}">${p.id === hl ? marked(p.t, cur[3], 'span class="hl on"') : esc(p.t)}</p>`).join("")
: '<div class="stub"></div><div class="stub"></div><div class="stub s"></div>'; : '<div class="stub"></div><div class="stub"></div><div class="stub s"></div>';
body.innerHTML = `<div class="sheet">${inner}<div class="stub"></div><div class="stub s"></div><span class="pno">— ${page} —</span></div> body.innerHTML = `<div class="sheet">${inner}<div class="stub"></div><div class="stub s"></div><span class="pno">— ${page} —</span></div>
<p class="viewer-note">${doc === "B1" && page === 3 ? "扫描页 · 文字由 OCR 识别,请以原图为准。" : "页码以规范化 PDF 为准(原型只做了被引用的页面)。"}</p>`; <p class="viewer-note">${doc === "B1" && +page === 3 ? "扫描页 · 文字由 OCR 识别,请以原图为准" : "页码以规范化 PDF 为准 · 原文只读"}</p>`;
const on = $(".hl.on", body); const on = $(".hl.on", body);
if (on) on.scrollIntoView({ block: "center", behavior: "smooth" }); if (on) on.scrollIntoView({ block: "center" });
const i = list.findIndex((x) => x.join("|") === cur.join("|")); if (!custom) { const i = idx(); setNav(i > 0, i >= 0 && i < list.length - 1); }
$("[data-v-prev]", el).disabled = i <= 0;
$("[data-v-next]", el).disabled = i < 0 || i >= list.length - 1;
} }
function open(c, all) { function open(c, all) {
cur = c; list = all || [c]; cur = c; list = all || [c];
render(); render();
if (opt.onOpen) opt.onOpen(); if (opt.onOpen) opt.onOpen();
} }
$("[data-v-prev]", el).onclick = () => { const i = list.findIndex((x) => x.join("|") === cur.join("|")); if (i > 0) open(list[i - 1], list); }; function clear(msg) {
$("[data-v-next]", el).onclick = () => { const i = list.findIndex((x) => x.join("|") === cur.join("|")); if (i < list.length - 1) open(list[i + 1], list); }; cur = null; list = [];
if (opt.closable) $("[data-v-close]", el).onclick = opt.onClose; $("[data-v-name]", el).textContent = "原文";
return { open }; $("[data-v-file]", el).textContent = ""; $("[data-v-file]", el).removeAttribute("title");
$("[data-v-page]", el).textContent = "";
body.innerHTML = `<p class="viewer-empty">${msg || emptyMsg}</p>`;
if (!custom) setNav(false, false);
}
prev.onclick = () => { if (custom) { if (opt.onPrev) opt.onPrev(); return; } const i = idx(); if (i > 0) open(list[i - 1], list); };
next.onclick = () => { if (custom) { if (opt.onNext) opt.onNext(); return; } const i = idx(); if (i >= 0 && i < list.length - 1) open(list[i + 1], list); };
if (opt.closable) $("[data-v-close]", el).onclick = () => opt.onClose && opt.onClose();
return { open, clear, setNav, el };
}
/* 抽屉:从右侧滑出的原文查看器(批注布局、概览页)。App.drawer() → { open(c, all), close(), isOpen() };Esc 关闭。 */
function drawer() {
const box = document.createElement("aside");
box.className = "drawer"; box.setAttribute("aria-label", "原文");
box.innerHTML = "<div></div>";
document.body.appendChild(box);
const close = () => box.classList.remove("open");
const v = viewer(box.firstChild, { closable: true, onClose: close, onOpen: () => box.classList.add("open") });
document.addEventListener("keydown", (e) => { if (e.key === "Escape" && box.classList.contains("open") && !$(".scrim")) close(); });
return { el: box, open: v.open, close, isOpen: () => box.classList.contains("open") };
} }
/* ---------- 对话框与轻提示 ---------- */ /* ---------- 浮动菜单:列表行的「更多」等(role=menu,↑ ↓ 移动,Esc 关闭,焦点回到触发按钮) ----------
function dialog({ title, body, ok = "确定", cancel = "取消", wide = false, onOk, onOpen }) { * App.floatMenu(btn, items, onPick)
* items:[{ label, sub, act, disabled }] 或 "-"(分隔线);sub 是第二行说明,可以是 HTML(如扣点说明);
* disabled:不可用的原因(如「递交后可用」),该项变禁用灰、右侧写原因,点了不触发;onPick(act, item)。 */
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):按页面传入,只列本页能用的键 ----------
* App.keysDialog(App.KEYS.reading) 读标:J / K、Enter、?
* App.keysDialog(App.KEYS.check) 查标:J / K、O / Enter、1 / 2 / 3、X、U、Esc、?
* 也可以自己拼:App.keysDialog([App.KEY.jk, [App.kb("Esc"), "关闭原文"], App.KEY.help])。App.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"), "撤销当前条的处理"],
esc: [kb("Esc"), "关闭原文查看器"],
help: [kb("?"), "显示本说明"]
};
const KEYS = { reading: [KEY.jk, KEY.enter, KEY.help], check: [KEY.jk, KEY.open, KEY.act, KEY.x, KEY.undo, KEY.esc, 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 秒消失,悬停暂停 ----------
* App.toast(msg, { ok, undo }):默认带绿色 ✓(操作成功);提醒、原型说明、撤销结果传 { ok: false };undo 传函数时右侧出「撤销」。 */
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 关闭、焦点锁定、焦点回到触发处 ----------
* App.dialog({ title, body, ok, cancel, wide, onOk(scrim), onOpen(scrim), footLeft })
* onOk 返回 false 时不关闭(用于校验);cancel: null 只留一个按钮;footLeft 是底栏左侧的说明(如扣点)。 */
function dialog({ title, body, ok = "确定", cancel = "取消", wide = false, onOk, onOpen, footLeft = "" }) {
const back = document.activeElement;
const s = document.createElement("div"); const s = document.createElement("div");
s.className = "scrim"; s.className = "scrim";
s.innerHTML = `<div class="dialog ${wide ? "wide" : ""}" role="dialog" aria-modal="true" aria-labelledby="dlg-t"> s.innerHTML = `<div class="dialog ${wide ? "wide" : ""}" role="dialog" aria-modal="true" aria-labelledby="dlg-t">
<h2 id="dlg-t">${title}</h2><div class="body">${body}</div> <h2 id="dlg-t">${title}</h2><button class="icon-btn sm x" aria-label="关闭" data-x>${icon("x")}</button>
<div class="foot">${cancel ? `<button class="btn" data-cancel>${cancel}</button>` : ""}<button class="btn primary" data-ok>${ok}</button></div></div>`; <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.appendChild(s);
const close = () => { s.remove(); document.removeEventListener("keydown", onKey); }; document.body.style.overflow = "hidden";
const onKey = (e) => { if (e.key === "Escape") close(); }; const close = () => { s.remove(); document.body.style.overflow = ""; document.removeEventListener("keydown", onKey, true); if (back && back.focus) back.focus(); };
document.addEventListener("keydown", onKey); const onKey = (e) => {
s.addEventListener("click", (e) => { if (e.target === s) close(); }); 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; if (cancel) $("[data-cancel]", s).onclick = close;
$("[data-ok]", s).onclick = () => { if (!onOk || onOk(s) !== false) close(); }; $("[data-ok]", s).onclick = () => { if (!onOk || onOk(s) !== false) close(); };
hydrate(s); init(s);
if (onOpen) onOpen(s); if (onOpen) onOpen(s);
const f = $("textarea, input, [data-ok]", s); const f = $("textarea, input:not([type=checkbox]):not([type=radio]), [data-ok]", s);
if (f) f.focus(); if (f) f.focus();
s.close = close;
return s; return s;
} }
let toastTimer;
function toast(msg) { /* ---------- 下拉选择(listbox)与下拉菜单:.pop[data-select] / .pop[data-menu],选中后在 .pop 上派发 change ---------- */
let t = $(".toast"); function closePops(except) {
if (!t) { t = document.createElement("div"); t.className = "toast"; t.setAttribute("role", "status"); document.body.appendChild(t); } $$(".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; } });
t.innerHTML = msg; }
clearTimeout(toastTimer); function initSelect(p) {
toastTimer = setTimeout(() => t.remove(), 4000); 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", { bubbles: true, 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); } });
} }
window.App = { D, $, $$, esc, icon, shell, cite, citeLabel, quote, viewer, dialog, toast }; /* ---------- 日期选择(§7.22):周一开始;非本月格子留空;不可选日期禁用灰;今天描边;关键日期下方小圆点 ---------- */
const WD = ["一", "二", "三", "四", "五", "六", "日"];
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>`;
}
/* ---------- 日期输入(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:带时间;data-left:下方写「· 剩 N 天」;data-req:必填时空着的错误说明;data-marks / data-min / data-note:关键日期圆点 / 最早可选日期 / 底部说明。
* 可直接键入「2026/11/20 9:30」「2026年11月20日」等写法,离开时整理成「2026-11-20 09:30」,下方显示「11月20日 09:30 · 剩 17 天」。
* 每次整理后在 .dt 上派发 dt-change(detail:{ ok, empty, val, dt, days });提交时调 el.dt.commit({ required: true })。 */
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 = withTime ? Math.floor((new Date(y, mo - 1, d, hh, mi) - NOW) / 864e5) : 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"; const a = $(":scope > .choice, :scope > .choices", f); a ? f.insertBefore(m, a) : 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):.cmp[data-cmp],拖动或用 ← → 调整原文查看器宽度 ---------- */
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()); });
}
/* ---------- 开关、筛选、标签页、半选、加载按钮:App.init(容器) 初始化动态插入的内容 ---------- */
function init(root = document) {
$$(".pop[data-select], .pop[data-menu]", root).forEach(initSelect);
$$(".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(); s.dispatchEvent(new CustomEvent("change", { detail: s.getAttribute("aria-checked") === "true" })); });
});
$$(".seg:not([data-ready])", root).forEach((g) => {
g.dataset.ready = 1;
g.addEventListener("click", (e) => { const b = e.target.closest("button"); if (!b || b.disabled) 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; }); tl.dispatchEvent(new CustomEvent("change", { detail: t.dataset.v })); };
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;onSort(列, "ascending" | "descending") */
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.App = {
D, $, $$, esc, icon, hydrate, shell, projHead, READY_TIP,
cite, citeLink, citeLabel, quote, marked, parseCite, viewer, drawer,
dialog, toast, floatMenu, closeFloatMenu, menuHTML, keysDialog, keysTable, KEYS, KEY, kb,
init, sortable, calHTML, parseDT, fieldError, dateInput, cmp, TODAY, NOW
};
document.addEventListener("DOMContentLoaded", () => { hydrate(); init(); });
})(); })();

View File

@@ -220,13 +220,13 @@ window.PROTO = {
files: "商务册 v3、技术册 v3、报价册 v2", files: "商务册 v3、技术册 v3、报价册 v2",
resolved: 2, resolved: 2,
findings: [ findings: [
{ id: "f1", sev: "fatal", item: "c8", title: "报价超过采购包 1 的最高限价", flag: "新增", { id: "f1", sev: "fatal", item: "c8", title: "报价超过采购包 1 的最高限价", flag: "新增", brief: "开标一览表 287.60 万元,限价 286.00 万元",
text: ["开标一览表报价 287.60 万元,采购包 1 最高限价 286.00 万元,超出 1.60 万元。", "这一项会导致投标无效。请核对开标一览表的报价。"], text: ["开标一览表报价 287.60 万元,采购包 1 最高限价 286.00 万元,超出 1.60 万元。", "这一项会导致投标无效。请核对开标一览表的报价。"],
cites: [["T", 2, "t2-cap", "采购包1:286.00万元"], ["B3", 1, "b3-total", "2876000.00 元"]] }, cites: [["T", 2, "t2-cap", "采购包1:286.00万元"], ["B3", 1, "b3-total", "2876000.00 元"]] },
{ id: "f2", sev: "fatal", item: "c19", title: "★ 条款疑似不满足:应用服务器内存", flag: "沿用 · 上次标记为已修改,问题仍在", { id: "f2", sev: "fatal", item: "c19", title: "★ 条款疑似不满足:应用服务器内存", flag: "沿用 · 上次标记为已修改,问题仍在", brief: "响应 256G,要求 ≥ 512G · 上次已修改,问题仍在",
text: ["技术偏离表第 12 行响应「内存 256G DDR4」,要求「≥512G DDR4」。", "这一项会导致投标无效。请在技术偏离表第 12 行和对应产品参数中更正内存配置。"], text: ["技术偏离表第 12 行响应「内存 256G DDR4」,要求「≥512G DDR4」。", "这一项会导致投标无效。请在技术偏离表第 12 行和对应产品参数中更正内存配置。"],
cites: [["T", 41, "t41-server", "内存: ≥512G DDR4"], ["B2", 12, "b2-mem", "内存:256G DDR4"]] }, cites: [["T", 41, "t41-server", "内存: ≥512G DDR4"], ["B2", 12, "b2-mem", "内存:256G DDR4"]] },
{ id: "f3", sev: "fatal", item: "c11", title: "授权委托书的单位签章位置未发现盖章", flag: "新增", { id: "f3", sev: "fatal", item: "c11", title: "授权委托书的单位签章位置未发现盖章", flag: "新增", brief: "只看有无,不鉴定真伪",
text: ["商务册第 5 页《法定代表人授权委托书》的「投标人(电子签章)」位置是空的。本检查只看有无,不鉴定真伪。", "这一项会导致投标无效。请在递交前补盖单位电子签章。"], text: ["商务册第 5 页《法定代表人授权委托书》的「投标人(电子签章)」位置是空的。本检查只看有无,不鉴定真伪。", "这一项会导致投标无效。请在递交前补盖单位电子签章。"],
cites: [["T", 24, "t24-auth", "加盖单位电子签章"], ["B1", 5, "b1-auth-seal", "投标人(电子签章):(签章位置为空)"]] }, cites: [["T", 24, "t24-auth", "加盖单位电子签章"], ["B1", 5, "b1-auth-seal", "投标人(电子签章):(签章位置为空)"]] },
{ id: "f4", sev: "major", item: "c22", title: "缺少 ★ 参数的证明材料:核心交换机硬件 BFD", { id: "f4", sev: "major", item: "c22", title: "缺少 ★ 参数的证明材料:核心交换机硬件 BFD",
@@ -250,17 +250,37 @@ window.PROTO = {
], ],
manual: [ manual: [
{ id: "m1", title: "电子投标文件已使用 CA 数字证书加密", cite: ["T", 21, "t21-copies"] }, { id: "m1", title: "电子投标文件已使用 CA 数字证书加密", cite: ["T", 21, "t21-copies"] },
{ id: "m2", title: "在截止时间前通过电子交易平台上传,并取得回执", cite: ["T", 6, "t6-place"] }, { id: "m2", title: "在截止时间前经电子交易平台上传,并取得回执", cite: ["T", 6, "t6-place"] },
{ id: "m3", title: "加密使用的 CA 证书在有效期内,开标时可正常解密", cite: ["T", 21, "t21-copies"] } { id: "m3", title: "加密使用的 CA 证书在有效期内,开标时可正常解密", cite: ["T", 21, "t21-copies"] }
] ]
}, },
/* 工作台的项目列表。演示里的「现在」是 11月2日 15:00;hoursLeft 是距截止的小时数(已截止为负)。
* due:[主文字, 辅助灰小字];pend:待确认的清单条目数;next:「下一步」及链接;menu:「更多」里的操作 [文字, 说明或 "cost"(扣 1 次检查), 链接] */
projects: [ projects: [
{ id: "p1", hoursLeft: 408, name: "某市政务服务中心智慧大厅建设项目", buyer: "某市政务服务管理办公室", lot: "采购包 1", due: "11月20日 09:30 · 剩 17 天", dueCls: "", status: "已检查", ready: "未就绪", fatal: 3, href: "results.html" }, { id: "p3", hoursLeft: 2, name: "某县数据中心机房改造项目", buyer: "某县大数据管理局", lot: "标段 1", due: ["今天 17:00 截止", "剩 2 小时"], dueCls: "due-today", status: "编制中", ready: "", fatal: 0, pend: 3, href: "checklist.html",
{ id: "p2", hoursLeft: 53, name: "某区智慧社区平台运维服务", buyer: "某区民政局", lot: "全部", due: "剩 2 天 5 小时", dueCls: "due-soon", status: "已检查", ready: "就绪", fatal: 0, href: "project.html" }, next: ["确认清单", "checklist.html"], menu: [["上传投标文件", "", "bid-check.html"], ["发起检查", "cost", "bid-check.html"], ["导出清单", "", ""]] },
{ id: "p3", hoursLeft: 7, name: "某县数据中心机房改造项目", buyer: "某县大数据管理局", lot: "标段 1", due: "今天 17:00 截止", dueCls: "due-today", status: "编制中", ready: "", fatal: 0, href: "checklist.html" }, { id: "p2", hoursLeft: 49, name: "某区智慧社区平台运维服务", buyer: "某区民政局", lot: "全部", due: ["剩 2 天 1 小时", "11月4日 16:00"], dueCls: "due-soon", status: "已检查", ready: "就绪", fatal: 0, pend: 0, href: "project.html",
{ id: "p4", hoursLeft: 600, name: "某市医保信息系统等保测评服务", buyer: "某市医疗保障局", lot: "全部", due: "11月28日 10:00 · 剩 25 天", dueCls: "", status: "跟进中", ready: "", fatal: 0, href: "project-new.html" }, next: ["导出检查报告", "report.html"], menu: [["查看检查结果", "", "results.html"], ["标记为已递交", "", "project.html"]] },
{ id: "p5", hoursLeft: 9999, name: "某区智慧校园一期", buyer: "某区教育局", lot: "全部", due: "已截止", dueCls: "due-past", status: "已递交", ready: "", fatal: 0, href: "project.html" }, { id: "p1", hoursLeft: 426, name: "某市政务服务中心智慧大厅建设项目", buyer: "某市政务服务管理办公室", lot: "采购包 1", due: ["剩 17 天", "11月20日 09:30"], dueCls: "", status: "已检查", ready: "未就绪", fatal: 3, pend: 0, href: "results.html",
{ id: "p6", hoursLeft: 9999, name: "某市不动产登记信息平台升级", buyer: "某市自然资源和规划局", lot: "采购包 2", due: "已截止", dueCls: "due-past", status: "已归档", ready: "", fatal: 0, href: "project.html" } next: ["处理致命问题", "results.html"], menu: [["重新检查", "cost", "bid-check.html"], ["导出检查报告", "致命问题未处理也可以导出", "report.html"], ["标记为已递交", "", "project.html"]] },
{ id: "p4", hoursLeft: 619, name: "某市医保信息系统等保测评服务", buyer: "某市医疗保障局", lot: "全部", due: ["剩 25 天", "11月28日 10:00"], dueCls: "", status: "跟进中", ready: "", fatal: 0, pend: 1, href: "project.html",
next: ["确认清单", "checklist.html"], menu: [["查看解析进度", "补遗 2 解析中 62%", "parsing.html?addendum=1"], ["上传补遗", "", "project-new.html?addendum=1"]] },
{ id: "p5", hoursLeft: -40, name: "某区智慧校园一期", buyer: "某区教育局", lot: "全部", due: ["已截止", "已递交"], dueCls: "due-past", status: "已递交", ready: "", fatal: 0, pend: 0, href: "project.html",
next: ["归档项目", "project.html"], menu: [["导出检查报告", "", "report.html"]] },
{ id: "p6", hoursLeft: -900, name: "某市不动产登记信息平台升级", buyer: "某市自然资源和规划局", lot: "采购包 2", due: ["已截止", "已归档"], dueCls: "due-past", status: "已归档", ready: "", fatal: 0, pend: 0, href: "project.html",
next: ["查看项目", "project.html"], menu: [["恢复为进行中", "", ""]] }
],
/* 工作台右栏:正在进行的任务、最近处理(时间倒序) */
tasks: {
running: { title: "解析补遗 2", stage: "提取清单", project: "某市医保信息系统等保测评服务", pct: 62, href: "parsing.html?addendum=1" },
queue: "检查排队中:智慧社区平台运维,前面 1 个"
},
recent: [
{ who: "系统", text: "并入补遗 1 · 更新 2 条", at: "14:48" },
{ who: "系统", text: "完成第 2 次检查 · 致命 3", at: "14:20" },
{ who: "王敏", ok: "确认无误", text: "「投标函落款日期」", at: "11-01" },
{ who: "王敏", text: "确认了清单 v2 · 智慧大厅", at: "11-01" }
] ]
}; };

View File

@@ -1,82 +1,211 @@
<!doctype html> <!doctype html>
<!--
上传投标文件并检查(v0.2):表单卡片按 design/ui-demo/forms.html 的做法(卡片头 / form-sec / 卡片底放扣点说明和按钮)。
右栏「检查依据」:清单版本、规则版本、所投的包、上次检查、24 小时免扣窗口(决策 P1)。
URL 参数:?charged=1 预览超出 24 小时、要扣 1 次检查的情况;再加 ?quota=out 预览额度用完(按钮仍可点,点了说明并给续费入口,§8.3)。
-->
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>上传投标文件并检查 · 标博士原型</title> <title>上传投标文件并检查 · 标博士原型</title>
<link rel="icon" href="../design/logo/favicon.svg">
<link rel="stylesheet" href="../design/system/tokens.css"> <link rel="stylesheet" href="../design/system/tokens.css">
<link rel="stylesheet" href="../design/system/fonts-local.css"> <link rel="stylesheet" href="../design/system/fonts-local.css">
<link rel="stylesheet" href="../design/system/components.css"> <link rel="stylesheet" href="../design/system/components.css">
<link rel="stylesheet" href="assets/app.css"> <link rel="stylesheet" href="assets/app.css">
<style> <style>
.narrow{max-width:860px} /* 主列 + 右栏(--aside-w),间距 --gap-card */
.vol{display:grid;grid-template-columns:40px minmax(0,1fr) auto auto;gap:var(--sp-3);align-items:center;padding:var(--sp-3) var(--sp-4);background:var(--bg-surface);border:1px solid var(--border-subtle);border-radius:var(--radius-sm)} .content > .alert{margin-bottom:var(--gap-card)}
.vol + .vol{margin-top:var(--sp-2)} .bc{display:grid;grid-template-columns:minmax(0,1fr) var(--aside-w);gap:var(--gap-card);align-items:start}
.vol.new{border-color:var(--c-ink)} .card-b.form-b{padding-top:var(--sp-5);padding-bottom:var(--sp-6)}
.vol.bad{border-color:var(--warn);background:var(--warn-soft)} .card-f .cost{margin-right:auto}
.vol .ext{font:600 11px/1 var(--font-figures);border:1px solid var(--border-control);border-radius:2px;padding:4px 0;text-align:center} .card-f .cost .hint-u{margin-left:var(--sp-1)}
.scope{display:grid;grid-template-columns:1fr 1fr;gap:var(--sp-4)}
.scope ul{margin:var(--sp-1) 0 0;padding-left:1.2em} /* 分册:文件行 + 分册类型 + 移除;读不了的文件写原因(赭色) */
.vols .file-row{grid-template-columns:40px minmax(0,1fr) 140px auto}
.vols .file-row.new{border-color:var(--c-ink)}
.vols .file-row .ext.bad{background:none;box-shadow:inset 0 0 0 1px var(--warn);color:var(--warn-text)}
.vols .name{display:flex;align-items:center;gap:var(--sp-2);min-width:0}
.vols .name b{flex:none}
.vols .pop .select{height:var(--control-h-sm)}
.vols + .drop{margin-top:var(--sp-3);padding:var(--sp-5) var(--sp-6)}
.vols-empty{padding:var(--sp-4);border:var(--line) solid var(--border-subtle);border-radius:var(--radius-card);color:var(--text-secondary);text-align:center}
/* 检查范围:会查 / 不查 两栏;报告首页照此写明 */
.scope{display:grid;grid-template-columns:1fr 1fr;gap:var(--sp-6)}
.scope h3{margin:0 0 var(--sp-1);font:var(--fw-bold) var(--fs-body)/var(--lh-body) var(--font-sans)}
.scope ul{margin:0;padding-left:1.2em}
.scope li{margin:2px 0} .scope li{margin:2px 0}
.foot-bar{display:flex;align-items:center;gap:var(--sp-3);margin-top:var(--sp-6);padding-top:var(--sp-4);border-top:1px solid var(--border-subtle)} .scope li::marker{color:var(--text-secondary)}
.foot-bar .cost{margin-right:auto} .scope .no{color:var(--text-secondary)}
/* 右栏:检查依据 */
.basis .kv{grid-template-columns:72px minmax(0,1fr);padding:0 var(--card-x) var(--sp-4)}
.basis .kv dd .fig{font-size:var(--fs-figure-xs);font-weight:var(--fw-medium)}
.basis .kv .sub{display:block;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary)}
.basis .free{margin:0;padding:var(--sp-3) var(--card-x);border-top:var(--line) solid var(--border-subtle);font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary)}
.basis .free b{color:var(--text-primary)}
.basis .free .fig{font-size:var(--fs-figure-xs)}
/* 检查进度 */
.run{margin-top:var(--gap-card)}
.run .card-b{padding-top:0}
.run .note{margin-top:var(--sp-2)}
.run .alert{margin-top:var(--sp-3)}
.run .alert .btn{flex:none}
</style> </style>
</head> </head>
<body> <body>
<main> <main>
<div class="content narrow"> <div class="content">
<div class="page-title"><div><h1>上传投标文件并检查</h1><p>只处理加密前的 Word / PDF 稿。每次重新上传都是一个新版本,检查结果关联到具体版本。</p></div></div> <div class="page-title">
<div class="alert"><span class="ico i">i</span><p>清单 v3 还是草稿(补遗 1 并入后没有重新确认),检查结果以当前清单为准。<a class="plain" href="checklist.html">去确认清单</a></p></div> <div><h1>上传投标文件并检查</h1><p>只读加密前的 Word / PDF 稿。每次上传都是新版本,检查结果对应到具体版本。</p></div>
<div class="section-h"><h2>投标文件分册</h2><span class="more"><button class="btn sm" data-add>上传新版本</button></span></div>
<div data-vols></div>
<div class="section-h"><h2>这次会检查什么</h2></div>
<div class="card scope">
<div><b>会查</b><ul><li>清单 <span class="fig">26</span> 条的响应完整性(未响应 / 负偏离未说明 / 疑似不满足)</li><li>形式要件:格式文件是否齐全、签章位置有没有盖章、落款日期、其他项目名称</li><li>资质匹配:按投标截止日判断证书是否过期(用你的资质库)</li><li>报价形式:限价三层、开标一览表与分项合计、大小写</li></ul></div>
<div><b>不查</b><ul><li>份数、密封、CA 加密、递交回执(列为「需人工核对」)</li><li>签章真伪</li><li>报价是否合理、报多少</li><li>评分高低</li></ul></div>
</div> </div>
<div class="foot-bar"> <div class="alert" role="note"><i data-ic="info" class="lg"></i><p>清单 v3 还是草稿:补遗 1 并入后还没重新确认。这次检查以当前清单为准。</p><a class="btn sm" href="checklist.html">去确认清单</a></div>
<span class="cost">24 小时内复查不扣额度:从上次扣点的检查(11-01 16:00)算起,至今天 16:00</span>
<a class="btn" href="project.html">取消</a> <div class="bc">
<button class="btn primary" data-start>开始检查</button> <div>
<form class="card flush" novalidate data-form aria-labelledby="h-form">
<div class="card-h"><h2 id="h-form">采购包 1 · 第 3 次检查</h2></div>
<div class="card-b form-b">
<fieldset class="form-sec">
<legend>投标文件分册<small>按分册上传;类型识别错了可以改</small></legend>
<div class="vols" data-vols></div>
<div class="drop" tabindex="0" role="button" aria-describedby="drop-sub" data-drop><i data-ic="upload" class="lg"></i><b>拖入或点击上传新版本</b><span id="drop-sub">Word / PDF,单个文件不超过 200 MB;电子投标客户端加密后的文件读不了</span></div>
</fieldset>
<fieldset class="form-sec">
<legend>检查范围<small>报告首页会照此写明</small></legend>
<div class="scope">
<div><h3>会查</h3><ul>
<li>清单 <span class="fig">26</span> 条的响应:未响应、负偏离未说明、疑似不满足</li>
<li>形式要件:格式文件是否齐全、签章位置有没有盖章、落款日期、其他项目的名称</li>
<li>资质:按投标截止日判断证书是否过期(用你的资质库)</li>
<li>报价形式:三层限价、开标一览表与分项合计、大小写</li></ul></div>
<div class="no"><h3>不查</h3><ul>
<li>签章真伪(只看有无,不鉴定真伪)</li>
<li>份数、密封、CA 加密、递交回执(列入「需人工核对」)</li>
<li>报价是否合理、该报多少</li>
<li>评分高低</li></ul></div>
</div>
</fieldset>
</div>
<div class="card-f">
<span class="cost" data-cost></span>
<a class="btn" href="project.html">取消</a>
<button type="submit" class="btn primary" data-start>开始检查</button>
</div>
</form>
<div data-run></div>
</div>
<aside class="stack">
<section class="card flush basis" aria-labelledby="h-basis">
<div class="card-h"><h2 id="h-basis">检查依据</h2></div>
<dl class="kv">
<dt>清单</dt><dd>v3 · 草稿<span class="sub">补遗 1 并入,更新 2 条、新增 0 条</span></dd>
<dt>所投的包</dt><dd>采购包 1(共 2 个)</dd>
<dt>规则</dt><dd>IT 集成 2026.11<span class="sub">政府采购 · 公开招标</span></dd>
<dt>上次检查</dt><dd><span class="fig">11</span>月<span class="fig">2</span>日 <span class="fig">14:20</span><span class="sub">第 <span class="fig">2</span> 次 · 致命 <span class="fig">3</span> 项未处理;复查时沿用处理状态</span></dd>
</dl>
<p class="free" data-free></p>
</section>
</aside>
</div> </div>
<div data-run></div>
</div> </div>
</main> </main>
<script src="assets/data.js"></script> <script src="assets/data.js"></script>
<script src="assets/app.js"></script> <script src="assets/app.js"></script>
<script> <script>
const { D, $, toast } = App; const { D, $, $$, esc, icon, toast, dialog } = App;
App.shell({ crumbs: [["工作台", "workbench.html"], [D.project.name, "project.html"], ["上传投标文件并检查", ""]] }); App.shell({ crumbs: [["工作台", "workbench.html"], [D.project.name, "project.html"], ["上传投标文件并检查", ""]] });
const qs = new URLSearchParams(location.search);
const charged = qs.get("charged") === "1", out = qs.get("quota") === "out";
const left = out ? 0 : D.quota.check.left;
/* ---------- 扣点说明(§8.3、决策 P1:24 小时从上一次扣点的检查算起,窗口内复查不延长窗口) ---------- */
$("[data-cost]").innerHTML = charged
? `扣 1 次检查 · 剩 <span class="fig">${left}</span> 次<span class="hint-u tip-start" tabindex="0" data-tip="解析失败、超时或系统异常不扣额度,已扣的自动退回">失败不扣</span>`
: `<span class="hint-u tip-start" tabindex="0" data-tip="从上一次扣点的检查(11月1日 16:00)算起,至今天 16:00。窗口内复查不扣额度,也不延长窗口。">24 小时内复查,不扣额度</span>`;
$("[data-free]").innerHTML = charged
? `上一次扣点的检查是 <span class="fig">11</span>月<span class="fig">1</span>日 <span class="fig">09:00</span>,已超过 24 小时。这次检查<b>扣 1 次</b>,之后 24 小时内复查不扣。`
: `上一次扣点的检查是 <span class="fig">11</span>月<span class="fig">1</span>日 <span class="fig">16:00</span>。<b>今天 <span class="fig">16:00</span> 前</b>复查不扣额度;窗口不会因复查延长。`;
/* ---------- 分册 ---------- */
const TYPES = ["商务册", "技术册", "报价册", "资格证明册"];
const vols = [ const vols = [
{ ext: "DOCX", name: "商务册", file: "商务册-v3.docx", ver: "v3", info: "64 页 · 11-02 14:12" }, { ext: "DOCX", type: "商务册", file: "商务册-v3.docx", ver: "v3", info: "64 页 · 11月2日 14:12" },
{ ext: "DOCX", name: "技术册", file: "技术册-v3.docx", ver: "v3", info: "212 页 · 11-02 14:12" }, { ext: "DOCX", type: "技术册", file: "技术册-v3.docx", ver: "v3", info: "212 页 · 11月2日 14:12" },
{ ext: "DOCX", name: "报价册", file: "报价册-v2.docx", ver: "v2", info: "8 页 · 11-01 15:40" } { ext: "DOCX", type: "报价册", file: "报价册-v2.docx", ver: "v2", info: "8 页 · 11月1日 15:40" }
]; ];
const figs = (s) => esc(s).replace(/(\d+(?::\d+)?)/g, '<span class="fig">$1</span>');
function typeSelect(v, i) {
if (v.bad) return `<button type="button" class="select sm" disabled aria-label="分册类型"><span class="val">—</span>${icon("chev-down")}</button>`;
return `<div class="pop" data-select data-vi="${i}"><button type="button" class="select sm" aria-haspopup="listbox" aria-expanded="false" aria-label="分册类型:${v.file}"><span class="val">${v.type}</span>${icon("chev-down")}</button>
<ul class="menu" role="listbox">${TYPES.map((t) => `<li role="option" aria-selected="${t === v.type}">${t}</li>`).join("")}</ul></div>`;
}
function render() { function render() {
$("[data-vols]").innerHTML = vols.map((v) => `<div class="vol ${v.cls || ""}"><span class="ext">${v.ext}</span> $("[data-vols]").innerHTML = vols.length ? vols.map((v, i) => `<div class="file-row ${v.isNew ? "new" : ""}"><span class="ext ${v.bad ? "bad" : ""}">${v.ext}</span>
<div><b>${v.name}</b> <span class="tag">${v.ver}</span>${v.cls === "new" ? ' <span class="flag">新版本</span>' : ""}<div class="cap ${v.cls === "bad" ? "" : "muted"}" ${v.cls === "bad" ? 'style="color:var(--warn-text)"' : ""}>${v.file} · ${v.info}</div></div> <div><span class="name"><b>${v.bad ? "无法读取" : v.type}</b>${v.bad ? "" : `<span class="tag">${v.ver}</span>`}${v.isNew ? '<span class="flag">新版本</span>' : ""}</span>
<select class="input" style="width:120px;height:28px" aria-label="分册类型" ${v.cls === "bad" ? "disabled" : ""}><option>${v.name}</option><option>商务册</option><option>技术册</option><option>报价册</option><option>资格证明册</option></select> ${v.bad ? `<span class="hint-warn"><span class="ico w" aria-hidden="true">!</span><span>${esc(v.file)}:电子投标客户端加密后的文件,标博士读不了。请上传加密前的 Word 或 PDF 稿。</span></span>`
<button class="btn sm text" data-rm="${v.file}">移除</button></div>`).join(""); : `<span class="cap muted">${esc(v.file)} · ${figs(v.info)}</span>`}</div>
${typeSelect(v, i)}
<span class="acts"><button type="button" class="icon-btn sm tip-left" aria-label="移除 ${esc(v.file)}" data-tip="移除" data-rm="${i}">${icon("x")}</button></span></div>`).join("")
: `<div class="vols-empty">还没有分册。上传后才能开始检查。</div>`;
App.init($("[data-vols]"));
} }
render(); render();
$("[data-add]").onclick = () => { /* 分册类型下拉:change 冒泡,在容器上委托 */
vols[2] = { ext: "DOCX", name: "报价册", file: "报价册-v3.docx", ver: "v3", info: "8 页 · 刚刚上传", cls: "new" }; $("[data-vols]").addEventListener("change", (e) => { const p = e.target.closest("[data-vi]"); if (!p) return; vols[+p.dataset.vi].type = e.detail; render(); toast(`已改为「${e.detail}」`); });
vols.push({ ext: "KEDT", name: "资格证明册", file: "资格证明册.kedt", ver: "—", info: "这是电子投标客户端生成的加密文件,标博士读不了。请上传加密前的 Word 或 PDF 稿。", cls: "bad" }); $("[data-vols]").addEventListener("click", (e) => {
render(); toast("报价册已更新为 v3;有 1 个文件无法读取"); const b = e.target.closest("[data-rm]"); if (!b) return;
}; const [v] = vols.splice(+b.dataset.rm, 1); render();
document.addEventListener("click", (e) => { if (e.target.matches("[data-rm]")) { const i = vols.findIndex((v) => v.file === e.target.dataset.rm); vols.splice(i, 1); render(); } }); toast(`已移除 ${esc(v.file)}`, { undo: () => { vols.splice(+b.dataset.rm, 0, v); render(); } });
$("[data-start]").onclick = () => { });
$("[data-start]").disabled = true; $("[data-start]").textContent = "正在检查…"; /* 演示:上传一次 = 报价册更新到 v3 + 一个加密文件读不了 */
function upload() {
if (vols.some((v) => v.isNew)) return toast("(原型)上传只演示一次:报价册 v3 和一个加密文件", { ok: false });
const i = vols.findIndex((v) => v.type === "报价册");
const nv = { ext: "DOCX", type: "报价册", file: "报价册-v3.docx", ver: "v3", info: "8 页 · 刚刚上传", isNew: true };
if (i >= 0) vols[i] = nv; else vols.push(nv);
vols.push({ ext: "KEDT", type: "", file: "资格证明册.kedt", bad: true });
render(); toast("报价册已更新为 v3;有 1 个文件读不了", { ok: false });
}
$("[data-drop]").addEventListener("click", upload);
$("[data-drop]").addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); upload(); } });
/* ---------- 发起检查 ---------- */
$("[data-form]").addEventListener("submit", (e) => {
e.preventDefault();
const ok = vols.filter((v) => !v.bad);
if (!ok.length) { toast("还没有能读的分册,请先上传", { ok: false }); $("[data-drop]").focus(); return; }
if (charged && left <= 0) {
dialog({ title: "本期检查额度已用完", body: "<p>这次检查要扣 1 次检查额度,本期已经用完。可以在额度页提交续费申请,或等下期发放。</p>", ok: "去额度页", cancel: "取消", onOk: () => { location.href = "quota.html"; } });
return;
}
run(ok, vols.filter((v) => v.bad));
});
function run(ok, bad) {
const btn = $("[data-start]");
btn.disabled = true; btn.textContent = "正在检查…";
$$("[data-rm], [data-drop], .vols .select").forEach((x) => { x.disabled = true; x.setAttribute("aria-disabled", "true"); });
const S = ["解析投标文件分册", "逐条找响应", "形式要件与签章位置", "资质匹配", "报价形式", "沿用上次处理状态"]; const S = ["解析投标文件分册", "逐条找响应", "形式要件与签章位置", "资质匹配", "报价形式", "沿用上次处理状态"];
let i = 0; let i = 0;
const draw = () => $("[data-run]").innerHTML = `<div class="card" style="margin-top:var(--sp-4)"><ol class="steps">${S.map((s, k) => `<li class="${k < i ? "done" : k === i ? "now" : "todo"}"><span class="dot">${k < i ? "✓" : ""}</span>${s}</li>`).join("")}</ol> const box = $("[data-run]");
${i >= S.length ? `<div class="alert done" style="margin-top:var(--sp-3)"><span class="ico ok">✓</span><p><b>第 3 次检查完成,未扣额度。</b>致命 3 · 重要 3 · 提示 3;新增 0,沿用 7,本次已解决 2。</p></div><div class="row" style="margin-top:var(--sp-3)"><a class="btn primary" href="results.html">查看检查结果</a></div>` : '<p class="note">可以离开本页,完成后会发站内通知。</p>'}</div>`; const draw = () => {
const tick = () => { draw(); if (i++ < S.length) setTimeout(tick, 700); }; const fin = i >= S.length;
tick(); box.innerHTML = `<section class="card flush run" aria-labelledby="h-run" aria-live="polite">
}; <div class="card-h"><h2 id="h-run">${fin ? "检查完成" : "正在检查"}</h2><span class="cap muted">${ok.map((v) => `${v.type} ${v.ver}`).join("、")}</span></div>
<div class="card-b"><ol class="steps">${S.map((s, k) => `<li class="${k < i ? "done" : k === i ? "now" : "todo"}"><span class="dot">${k < i ? "✓" : ""}</span><span>${s}</span></li>`).join("")}</ol>
${fin ? `<div class="alert done"><span class="ico ok" aria-hidden="true">✓</span><div><p><b>第 <span class="fig">3</span> 次检查完成,${charged ? "扣了 1 次检查" : "未扣额度"}。</b></p><span class="sub">致命 3 · 重要 3 · 提示 3;新增 0,沿用 7,本次已解决 2${bad.length ? `。${bad.map((v) => esc(v.file)).join("、")} 读不了,没有检查` : ""}</span></div><a class="btn primary" href="results.html">查看检查结果</a></div>`
: '<p class="note">可以离开本页,完成后会发站内通知。</p>'}</div></section>`;
};
const step = () => { draw(); if (i++ < S.length) setTimeout(step, 650); else { btn.textContent = "已完成"; } };
step();
box.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
</script> </script>
</body> </body>
</html> </html>

View File

@@ -1,184 +1,297 @@
<!doctype html> <!doctype html>
<!--
读标(v0.2):按 design/ui-demo/page-reading.html 重刷;数据来自 assets/data.js。
框架来自 App.shell({ tab: "checklist", fit: true }):项目头 + 标签页;对照布局(.cmp:分类导航 + 清单 + 可拖动的原文查看器)在 assets/app.css。
本页 <style> 只放读标特有的:清单栏、清单工具条、条目细节、修改记录表。
Q2:AI 产出正文墨色,AI 身份靠「AI」标签;待确认条目另加 2px 蓝铅笔虚线。
Q3:清单当前条 = --c-n50 底 + 3px 墨色竖条;蓝色只留给原文高亮和出处链接。
-->
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>读标 · 标博士原型</title> <title>读标 · 标博士原型</title>
<link rel="icon" href="../design/logo/favicon.svg">
<link rel="stylesheet" href="../design/system/tokens.css"> <link rel="stylesheet" href="../design/system/tokens.css">
<link rel="stylesheet" href="../design/system/fonts-local.css"> <link rel="stylesheet" href="../design/system/fonts-local.css">
<link rel="stylesheet" href="../design/system/components.css"> <link rel="stylesheet" href="../design/system/components.css">
<link rel="stylesheet" href="assets/app.css"> <link rel="stylesheet" href="assets/app.css">
<style> <style>
.cl-bar{display:flex;align-items:center;gap:var(--sp-4);padding:var(--sp-4) var(--sp-8);border-bottom:1px solid var(--border-subtle);background:var(--bg-page)} /* 清单栏:标题 + 清单状态;右侧页面级操作(主按钮一个,Q12) */
.cl-bar h2{font:var(--fw-bold) var(--fs-h2)/var(--lh-h2) var(--font-serif);margin:0} .cl-bar{display:flex;align-items:center;gap:var(--sp-3);padding:var(--sp-4) var(--pad-page) var(--sp-3)}
.cl-bar .actions{margin-left:auto;display:flex;gap:var(--sp-2);align-items:center} .cl-bar h2{margin:0;font:var(--fw-bold) var(--fs-title)/var(--lh-title) var(--font-sans);white-space:nowrap}
main.main{height:100vh;overflow:hidden} .cl-bar .status{display:flex;align-items:center;gap:var(--sp-2);min-width:0;white-space:nowrap;overflow:hidden}
.split{display:grid;grid-template-columns:200px minmax(0,1fr) minmax(420px,45%);flex:1;min-height:0} .cl-bar .status .sign{margin:0}
.cats{border-right:1px solid var(--border-subtle);padding:var(--sp-4) var(--sp-3);overflow:auto} .cl-bar .status .cap{overflow:hidden;text-overflow:ellipsis}
.cats button{display:flex;width:100%;align-items:center;justify-content:space-between;height:34px;padding:0 var(--sp-3);border:0;background:none;border-radius:var(--radius-sm);font:var(--fs-body) var(--font-sans);color:var(--text-primary);cursor:pointer;text-align:left} .cl-bar .actions{margin-left:auto;display:flex;gap:var(--sp-2);align-items:center;flex:none}
.cats button:hover{background:var(--bg-hover)} .cl-bar .actions .lnk{margin-right:var(--sp-2)}
.cats button.on{background:var(--bg-surface);box-shadow:inset 2px 0 0 var(--c-ink);font-weight:var(--fw-bold)}
.cats .fig{font-weight:500;color:var(--text-secondary)} /* 清单 */
.cats hr{border:0;border-top:1px solid var(--border-subtle);margin:var(--sp-3) 0} .lst{scroll-padding-top:72px}
.cats .hint{font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary);padding:0 var(--sp-3)} .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)}
.list{overflow:auto;padding:var(--sp-4) var(--sp-6) var(--sp-12)} .lst-tb .search{flex:1;min-width:0}
.list .alert{margin-bottom:var(--sp-4)} .lst-tb .search .input{height:var(--control-h-sm)}
.group{margin-top:var(--sp-5)} .lst-tb .search > .ic{top:8px}
.group > h3{font:var(--fw-bold) var(--fs-title)/var(--lh-title) var(--font-sans);margin:0 0 var(--sp-2);display:flex;gap:var(--sp-2);align-items:baseline} .lst-tb .choice{flex:none;white-space:nowrap;align-items:center}
.group > h3 .fig{font-weight:500;color:var(--text-secondary);font-size:14px} .lst-tb .choice .ck{margin-top:0}
.items{background:var(--bg-surface);border:1px solid var(--border-subtle);border-radius:var(--radius-sm);padding:0 var(--sp-4)} .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)}
.item{cursor:pointer;margin:0 calc(-1 * var(--sp-4));padding:var(--sp-3) var(--sp-4)} .lot-note .fig{font-size:var(--fs-figure-xs)}
.item:hover{background:var(--bg-hover)} .rule{display:flex;gap:var(--sp-2);align-items:flex-start;padding:var(--sp-2) var(--card-x) 0}
.item.sel{background:var(--ai-soft);box-shadow:inset 3px 0 0 var(--ai)} .rule > .ic{margin-top:3px;flex:none;color:var(--text-secondary)}
.item .upd{margin-top:6px;display:flex;gap:var(--sp-2);align-items:center;font-size:var(--fs-caption)} .rule p{margin:0}
.item .upd .btn{height:24px;font-size:var(--fs-caption)} .rule .cite-link{margin:0 -4px;padding:0 4px}
.right{border-left:1px solid var(--border-subtle);display:flex;min-height:0} .lst .item{padding:var(--sp-3) var(--card-x) var(--sp-3) var(--sp-3);border-top:var(--line) solid var(--border-subtle)}
.right .viewer{flex:1;border:0;border-radius:0} .lst .item .t{font-weight:var(--fw-medium)}
.lot-note{font-size:var(--fs-caption);color:var(--text-secondary)} .lst .item .side{padding-top:1px;white-space:nowrap}
.status-line{display:flex;gap:var(--sp-2);align-items:center} .lst .item .cite-link{margin:0;padding:0 4px;font-size:var(--fs-caption)}
.status-line .sign{margin:0;font-size:var(--fs-caption)} .lst .item .cite-link .fig{font-size:var(--fs-figure-xs)}
.hist{width:100%;border-collapse:collapse;font-size:var(--fs-body)} .lst .item .upd .flag b{color:var(--warn-text);font-weight:var(--fw-bold)}
.hist td,.hist th{text-align:left;padding:6px 8px;border-bottom:1px solid var(--border-subtle);vertical-align:top} .lst .item .meta .lnk{font-size:var(--fs-caption)}
.hist th{font-size:var(--fs-caption);color:var(--text-secondary);font-weight:var(--fw-bold)} .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)}
/* 修改记录(对话框里的表格) */
.hist td:nth-child(-n+3){white-space:nowrap}
.hist .old{white-space:normal}
.dialog .input-unit.pg{width:200px}
</style> </style>
</head> </head>
<body> <body>
<main> <main>
<div class="cl-bar"> <div class="cl-bar">
<h2>项目检查清单</h2> <h2>项目检查清单</h2>
<div class="status-line" data-status></div> <div class="status" data-status></div>
<div class="actions"> <div class="actions">
<a class="plain cap" href="#" data-missing>反馈:有遗漏的要求</a> <a class="lnk cap" href="#" data-missing>反馈遗漏</a>
<button class="btn" data-add>新增条目</button> <button class="btn" data-add><i data-ic="plus"></i>新增条目</button>
<button class="btn primary" data-confirm>确认清单</button> <button class="btn primary" data-confirm>确认清单</button>
</div> </div>
</div> </div>
<div class="split">
<nav class="cats" aria-label="清单分类" data-cats></nav> <div class="cmp" data-cmp>
<div class="list" data-list></div> <section class="card flush cmp-list" aria-label="项目检查清单">
<div class="right"><div data-viewer></div></div> <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"></div>
<section aria-label="原文查看器" data-viewer></section>
</div> </div>
</main> </main>
<script src="assets/data.js"></script> <script src="assets/data.js"></script>
<script src="assets/app.js"></script> <script src="assets/app.js"></script>
<script> <script>
const { D, $, $$, esc, dialog, toast } = App; const { D, $, $$, esc, icon, dialog, toast } = App;
App.shell({ crumbs: [["工作台", "workbench.html"], [D.project.name, "project.html"], ["读标", ""]], tab: "checklist" }); App.shell({ crumbs: [["工作台", "workbench.html"], [D.project.name, "project.html"], ["读标", ""]], tab: "checklist", fit: true });
const V = App.viewer($("[data-viewer]"));
const items = D.checklist.map((c) => ({ ...c })); const items = D.checklist.map((c) => ({ ...c }));
let pending = D.pending.map((q) => ({ ...q })); let pending = D.pending.map((q) => ({ ...q }));
let cat = "all", sel = null, confirmed = false; const dropped = []; // 丢弃的待确认条目,可在「修改记录」里看到
const SRC = { ai: '<span class="src-ai">AI 提取</span>', manual: "人工添加" }; let cat = "all", sel = items[0].id, confirmed = false, ver = 3, lastOk = 2, 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> 页`;
/* ---------- 原文查看器:App.viewer;翻页按钮由清单接管,是上一条 / 下一条(K / J) ---------- */
const V = App.viewer($("[data-viewer]"), { onPrev: () => step(-1), onNext: () => step(1), prevLabel: "上一条", nextLabel: "下一条", prevKey: "K", nextKey: "J" });
function openItem(i) {
/* 人工确认、无出处的条目:查看器不定位到任何原文 */
if (i.noCite) V.clear("这条是人工确认的,没有出处。");
else V.open(i.cite);
const v = visible(), k = v.findIndex((x) => x.id === i.id);
V.setNav(k > 0, k >= 0 && k < v.length - 1);
}
/* ---------- 清单状态、分类、列表 ---------- */
function status() { function status() {
$("[data-status]").innerHTML = confirmed $("[data-status]").innerHTML = confirmed
? `<span class="tag status">已确认 v3</span><span class="sign"><span>✓</span><b>${D.user.name}</b><span class="fig">11月2日 15:12</span>确认</span>` ? `<span class="tag">已确认 v${ver}</span><span class="sign"><span class="ok" aria-hidden="true">✓</span><b>${esc(D.user.name)}</b><span class="fig">11月2日 15:00</span><span>确认</span></span>`
: `<span class="tag">草稿 v3</span><span class="cap muted">补遗 1 并入后待重新确认 · 上一版 v2 已确认</span>`; : `<span class="tag">草稿 v${ver}</span><span class="cap muted">${ver === 3 ? "补遗 1 并入后待重新确认" : "有修改,待重新确认"} · 上一版 v${lastOk} 已确认</span>`;
$("[data-confirm]").hidden = confirmed; $("[data-confirm]").hidden = confirmed;
} }
/* 已确认的清单再修改(新增、采纳):回到草稿,版本号加一,历史保留 */
function edited() { if (confirmed) { confirmed = false; lastOk = ver; ver++; toast(`清单回到草稿 v${ver},v${lastOk} 保留在历史里`, { ok: false }); } }
function cats() { function cats() {
const n = (id) => items.filter((i) => i.cats.includes(id)).length; const n = (id) => items.filter((i) => i.cats.includes(id)).length;
$("[data-cats]").innerHTML = const b = (id, name, num, cls = "") => `<button class="${cls}" data-cat="${id}" aria-current="${cat === id}">${name}<span class="fig">${num}</span></button>`;
`<button data-cat="all" class="${cat === "all" ? "on" : ""}">全部<span class="fig">${items.length}</span></button>` + $("[data-cats]").innerHTML = b("all", "全部", items.length) + D.categories.map((c) => b(c.id, c.name, n(c.id))).join("") +
D.categories.map((c) => `<button data-cat="${c.id}" class="${cat === c.id ? "on" : ""}">${c.name}<span class="fig">${n(c.id)}</span></button>`).join("") + `<hr>${b("pending", "待确认", pending.length, "ai")}
`<hr><button data-cat="pending" class="${cat === "pending" ? "on" : ""}" style="color:var(--ai)">待确认<span class="fig" style="color:var(--ai)">${pending.length}</span></button> <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>`;
<hr><p class="hint">一条要求可以属于多个分类,例如 ★ 参数同时在「技术参数」和「无效条款」里。</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) { function itemRow(i) {
const src = i.src === "addendum" ? i.srcName : SRC[i.src]; const src = i.src === "ai" ? '<span class="tag ai">AI</span>' : `<span class="tag">${i.src === "addendum" ? i.srcName : "人工添加"}</span>`;
const upd = i.updated ? `<div class="upd"><span class="flag">已被${i.srcName}更新 · 原为 <s>${esc(i.old)}</s></span><button class="btn" data-ack="${i.id}">已核对</button></div>` : ""; const meta = src + i.cats.map((c) => `<span class="tag">${catName(c)}</span>`).join("") + `<span class="tag">${i.lot ? "采购包 " + i.lot : "通用"}</span>` +
const meta = i.cats.map((c) => `<span class="tag">${D.categories.find((x) => x.id === c).name}</span>`).join("") + (i.noCite ? '<span class="flag">人工确认、无出处</span>' : "") +
`<span class="tag">${i.lot ? "采购包 " + i.lot : "通用"}</span>` + (i.edited ? `<span class="flag">已修改 · <a class="plain" href="#" data-hist="${i.id}">查看记录</a></span>` : ""); (i.edited ? `<span class="flag">已修改 · <a class="lnk" href="#" data-hist="${i.id}">查看记录</a></span>` : "");
return `<div class="item ${sel === i.id ? "sel" : ""}" data-item="${i.id}" tabindex="0"> 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>` : "";
<span class="imp">${i.imp || ""}</span> 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><div class="t">${esc(i.title)}</div><div class="meta">${meta}</div>${upd}</div>
<div class="side">${src}<br><a class="cite-src" href="#">${App.citeLabel(i.cite).replace(/^.*? · /, "")}</a></div> <div class="side">${i.noCite ? '<span class="muted">无出处</span>' : `<a class="cite-link" href="#" data-cite>${page(i.cite)}</a>`}</div></div>`;
</div>`;
} }
/* 待确认(§7.6):放在清单顶部,不和正式清单混排;条目是未确认的 AI 产出,左侧 2px 蓝铅笔虚线(Q2)。确认前不参与检查 */
function pendingBox() { function pendingBox() {
if (!pending.length) return ""; if (!pending.length) return "";
return `<div class="pending" style="margin-bottom:var(--sp-5)"><h4>待确认(<span class="fig">${pending.length}</span>)</h4> return `<section aria-label="待确认"><div class="grp-h ai"><b>待确认</b><span class="fig">${pending.length}</span><p>没找到可靠出处,或把握不大。确认前不参与检查。</p></div>
<p>这些结果没有找到可靠出处,或系统把握不大。确认之前,不参与检查。</p> ${pending.map((q) => `<div class="item pq" data-q="${q.id}"><span class="imp">${q.imp || ""}</span>
${pending.map((q) => `<div class="item" style="cursor:default" 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><div class="t">${esc(q.title)}</div><div class="meta"><span class="src-ai cap">AI 提取 · ${q.reason}</span>${q.cite ? `<a class="cite-src" href="#" data-qopen="${q.id}" style="display:inline">第 <span class="fig">${q.cite[1]}</span> 页</a>` : ""}</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="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><span></span></div>`).join("")}</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() { function list() {
const head = `<div class="alert"><span class="ico i">i</span><p>实质性条款的标注规则:<b>打「★」的内容为实质性要求</b>(招标文件 <a class="cite-src" href="#" data-rule style="display:inline">第 <span class="fig">24</span> 页</a>)。文件没有写明 ★ 条款的总数,已识别采购包 1 和通用部分的 ★ 条款 <span class="fig">4</span> 条,请对照原文核对有无遗漏。</p></div>`; const L = $("[data-list]");
const lot = `<p class="lot-note" style="margin:0 0 var(--sp-3)">显示:通用 + 采购包 1 · 采购包 2 的 <span class="fig" style="font-weight:500">${D.hiddenLotItems}</span> 条已隐藏 · <a class="plain" href="lots.html">更改所投的包</a></p>`; const focusKw = document.activeElement?.matches?.("[data-kw]");
if (cat === "pending") return $("[data-list]").innerHTML = pendingBox() || '<p class="muted">没有待确认的条目。</p>'; 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>`;
const groups = cat === "all" ? D.categories : D.categories.filter((c) => c.id === cat); if (cat === "pending") {
const seen = new Set(); L.innerHTML = (pendingBox() || '<div class="empty sm"><b>没有待确认的条目</b></div>') + '<div class="end"></div>';
$("[data-list]").innerHTML = lot + (cat === "all" ? head + pendingBox() : "") + groups.map((g) => { } else {
// 「全部」视图下每条只出现一次,放在它的第一个分类里 const v = visible();
const rows = items.filter((i) => i.cats.includes(g.id) && (cat !== "all" || !seen.has(i.id))); const groups = cat === "all" ? D.categories : D.categories.filter((c) => c.id === cat);
rows.forEach((i) => seen.add(i.id)); 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("");
return rows.length ? `<section class="group"><h3>${g.name}<span class="fig">${rows.length}</span></h3><div class="items">${rows.map(itemRow).join("")}</div></section>` : ""; const head = `<p class="lot-note">通用 + 采购包 1 · 已隐藏采购包 2 的 <span class="fig">${D.hiddenLotItems}</span> 条 · <a class="lnk" href="lots.html">更改所投的包</a></p>` +
}).join(""); (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()}` : "");
const none = kw ? `<div class="empty sm"><b>没有找到「${esc(kw)}」</b><p>只搜当前分类的条目标题。</p><button class="btn sm" data-clear>清除搜索</button></div>` : '<div class="empty sm"><b>这个分类下没有 ★ 条款</b></div>';
L.innerHTML = tb + head + (body || none) + '<div class="end"></div>';
}
App.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 }); }
if (i) openItem(i);
}
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(); } function render() { status(); cats(); list(); }
/* ---------- 交互 ---------- */
document.addEventListener("click", (e) => { document.addEventListener("click", (e) => {
const t = e.target; const t = e.target;
if (t.closest("[data-cat]")) { cat = t.closest("[data-cat]").dataset.cat; render(); return; } if (t.closest("[data-cat]")) { cat = t.closest("[data-cat]").dataset.cat; render(); return; }
if (t.matches("[data-ack]")) { const i = items.find((x) => x.id === t.dataset.ack); i.updated = false; i.edited = true; render(); toast("已核对补遗更新,记录已保存"); 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; i.acked = true; list(); toast("已核对补遗更新,记录已保存"); return; }
if (t.matches("[data-hist]")) { e.preventDefault(); history(t.dataset.hist); return; } if (t.closest("[data-hist]")) { e.preventDefault(); history(t.closest("[data-hist]").dataset.hist); return; }
if (t.closest("[data-rule]")) { e.preventDefault(); V.open(["T", 24, "t24-mark"]); return; } if (t.closest("[data-rule]")) { e.preventDefault(); V.open(["T", 24, "t24-mark"]); return; }
if (t.closest("[data-qopen]")) { e.preventDefault(); const q = pending.find((x) => x.id === t.closest("[data-qopen]").dataset.qopen); V.open(q.cite); 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.matches("[data-qa]")) { pendingAct(t.closest("[data-q]").dataset.q, t.dataset.qa); 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]"); const it = t.closest("[data-item]");
if (it) { e.preventDefault(); sel = it.dataset.item; const i = items.find((x) => x.id === sel); $$(".item.sel").forEach((x) => x.classList.remove("sel")); it.classList.add("sel"); V.open(i.cite); } 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, [contenteditable]") || $(".scrim")) return;
if (e.key === "?") { App.keysDialog(App.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.target.matches("[data-item]") || e.target === document.body)) { e.preventDefault(); select(sel, { scroll: true, focus: true }); }
}); });
document.addEventListener("keydown", (e) => { if (e.key === "Enter" && e.target.matches("[data-item]")) e.target.click(); });
/* ---------- 待确认:采纳 / 补上出处后采纳 / 修改后采纳 / 丢弃 ---------- */
function pendingAct(id, act) { function pendingAct(id, act) {
const q = pending.find((x) => x.id === id); const q = pending.find((x) => x.id === id);
if (act === "drop") { pending = pending.filter((x) => x !== q); render(); toast(`已丢弃「${esc(q.title)}」 · 可在修改记录中恢复`); return; } if (act === "drop") {
if (act === "adopt") { adopt(q, q.cite, false); return; } pending = pending.filter((x) => x !== q); dropped.push(q); render();
toast(`已丢弃「${esc(q.title)}」`, { undo: () => { restore(q); toast("已恢复到待确认", { ok: false }); } });
return;
}
if (act === "adopt") { adopt(q, q.cite); return; }
dialog({ dialog({
title: act === "cite" ? "补上出处后采纳" : "修改后采纳", title: act === "cite" ? "补上出处后采纳" : "修改后采纳", wide: true, ok: "采纳",
body: `<div class="field"><label for="qt">要求</label><input class="input" id="qt" value="${esc(q.title)}"></div> body: `<div class="field fill"><label for="qt">要求 <small>(必填)</small></label><input class="input" id="qt" value="${esc(q.title)}"></div>
<div class="field" style="margin-top:var(--sp-4)"><label for="qp">出处页码 <small>(招标文件)</small></label><input class="input" id="qp" placeholder="例如 26" style="width:120px"></div> <div class="field" id="qp-f" style="margin-top:var(--sp-4)"><label for="qp">出处页码 <small>(招标文件)</small></label><div class="input-unit pg"><input class="input" id="qp" placeholder="例如 26" value="${q.cite ? q.cite[1] : ""}" inputmode="numeric"><span class="unit">/ ${D.docs.T.pages} 页</span></div>
<label class="row" style="gap:8px;margin-top:var(--sp-3)"><input type="checkbox" id="qn" style="accent-color:var(--c-ink)"> 找不到出处,按「人工确认、无出处」采纳</label>`, <label class="choice"><input type="checkbox" class="ck" id="qn">找不到出处,按「人工确认、无出处」采纳</label></div>`,
ok: "采纳", onOpen: (s) => { $("#qn", s).addEventListener("change", (e) => { const qp = $("#qp", s); qp.disabled = e.target.checked; if (e.target.checked) { qp.value = ""; App.fieldError($("#qp-f", s), ""); } }); },
onOk: (s) => { const pg = $("#qp", s).value.trim(); q.title = $("#qt", s).value.trim() || q.title; adopt(q, pg ? ["T", +pg, ""] : null, !pg); } onOk: (s) => {
const pg = $("#qp", s).value.trim(), noCite = $("#qn", s).checked;
const f = $("#qp-f", s);
if (!pg && !noCite) return App.fieldError(f, "填写页码,或勾选「人工确认、无出处」") && false;
if (pg && !(+pg >= 1 && +pg <= D.docs.T.pages)) return App.fieldError(f, `页码在 1–${D.docs.T.pages} 之间`) && false;
q.title = $("#qt", s).value.trim() || q.title;
adopt(q, pg ? (q.cite && +pg === q.cite[1] ? q.cite : ["T", +pg, ""]) : null, !pg);
}
}); });
} }
function adopt(q, c, noCite) { function restore(q) {
pending = pending.filter((x) => x !== q); dropped.splice(dropped.indexOf(q), 1); pending.push(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, ""] }); const ord = D.pending.map((x) => x.id); pending.sort((a, b) => ord.indexOf(a.id) - ord.indexOf(b.id));
render(); render();
toast(noCite ? "已采纳,标注为「人工确认、无出处」" : "已采纳,进入正式清单");
} }
function adopt(q, c, noCite) {
pending = pending.filter((x) => x !== q); edited();
items.push({ id: "n" + q.id, cats: ["invalid"], imp: q.imp, title: q.title, lot: 0, src: "manual", edited: true, adoptedFrom: q, noCite: !!noCite, cite: c || ["T", 1, ""] });
render(); toast(noCite ? "已采纳,标注为「人工确认、无出处」" : "已采纳,进入正式清单");
}
/* ---------- 修改记录(全程留痕:谁在何时改了什么) ---------- */
/* 补遗改动的那一段(示意数据里 old 是改前的那一段) */
const UPD_NEW = { c16: "2026年11月20日 09:30", c21: "2 块 ≥ 960G SSD" };
function history(id) { function history(id) {
const i = items.find((x) => x.id === id); const i = items.find((x) => x.id === id);
const u = D.user.name; const u = D.user.name;
const rows = id === "c26" let rows;
? [["11-01 10:42", "系统", "AI 提取", "—", "接受联合体投标"], ["11-01 11:05", u, "修改", "接受联合体投标", "不接受联合体投标"], ["11-01 11:06", u, "备注", "—", "原文写的是「否」,AI 读反了"]] if (id === "c26") rows = [["11月1日 10:42", "系统", "AI 提取", "—", "接受联合体投标"], ["11月1日 11:05", u, "修改", "接受联合体投标", "不接受联合体投标"], ["11月1日 11:06", u, "备注", "—", "原文写的是「否」,AI 读反了"]];
: [["11-01 10:42", "系统", "AI 提取", "—", i.old || i.title], ["11-02 14:31", "系统", "补遗 1 更新", i.old || "—", i.title], ["11-02 15:08", u, "核对补遗更新", "—", "已核对"]]; else if (i.adoptedFrom) {
dialog({ title: "修改记录", cancel: null, ok: "关闭", wide: true, const orig = D.pending.find((x) => x.id === i.adoptedFrom.id).title;
body: `<p class="muted">${esc(i.title)}</p><table class="hist"><tr><th>时间</th><th>操作人</th><th>操作</th><th>改前</th><th>改后</th></tr>${rows.map((r) => `<tr>${r.map((c, k) => `<td${k ? "" : ' class="fig" style="font-weight:500"'}>${esc(c)}</td>`).join("")}</tr>`).join("")}</table>` }); rows = [["11月2日 14:31", "系统", `AI 提取 · ${i.adoptedFrom.reason},进待确认`, "—", orig], ["11月2日 15:00", u, i.noCite ? "采纳 · 人工确认、无出处" : orig !== i.title ? "修改后采纳" : "采纳", orig !== i.title ? orig : "—", i.title]];
}
else if (i.old) rows = [["11月1日 10:42", "系统", "AI 提取", "—", i.title.replace(UPD_NEW[id], i.old)], ["11月2日 14:31", "系统", `${i.srcName} 更新`, i.old, UPD_NEW[id]]].concat(i.acked ? [["11月2日 15:00", u, "核对补遗更新", "—", "已核对"]] : []);
else rows = [i.src === "manual" ? ["11月2日 15:00", u, "人工添加", "—", i.title] : ["11月1日 10:42", "系统", "AI 提取", "—", i.title]];
const drops = dropped.length ? `<p class="cap muted" style="margin-top:var(--sp-4)">本次丢弃的待确认条目:${dropped.map((q) => `「${esc(q.title)}」<a class="lnk" href="#" data-restore="${q.id}">恢复</a>`).join(";")}</p>` : "";
const s = dialog({ title: "修改记录", cancel: null, ok: "关闭", wide: true,
body: `<p class="muted" style="margin-top:0">${esc(i.title)}</p><table class="data hist"><thead><tr><th>时间</th><th>操作人</th><th>操作</th><th>改前</th><th>改后</th></tr></thead><tbody>${rows.map((r) => `<tr><td>${r[0].replace(/\d+(:\d+)?/g, (m) => `<span class="fig">${m}</span>`)}</td><td>${esc(r[1])}</td><td>${esc(r[2])}</td><td>${r[3] === "—" ? '<span class="muted">—</span>' : `<span class="old">${esc(r[3])}</span>`}</td><td>${esc(r[4])}</td></tr>`).join("")}</tbody></table>${drops}` });
s.addEventListener("click", (e) => { const r = e.target.closest("[data-restore]"); if (!r) return; e.preventDefault(); restore(dropped.find((x) => x.id === r.dataset.restore)); s.close(); toast("已恢复到待确认"); });
} }
/* ---------- 页面级操作:确认清单、新增条目、反馈遗漏 ---------- */
$("[data-confirm]").onclick = () => dialog({ $("[data-confirm]").onclick = () => dialog({
title: "确认清单 v3?", title: `确认清单 v${ver}?`, ok: "确认清单",
body: `<p>确认后,查标会以这一版清单为依据。之后再修改清单,会回到草稿并保留历史,已有的检查结果不受影响。</p>${pending.length ? `<div class="alert warn"><span class="ico w">!</span><p><b>还有 ${pending.length} 条待确认。</b>它们不会进入这一版清单,也不参与检查。</p></div>` : ""}`, body: `<p style="margin-top:0">确认后,查标以这一版清单为依据。之后再改清单会回到草稿,历史保留,已有检查结果不受影响。</p>${pending.length ? `<div class="alert warn"><span class="ico w">!</span><div><p><b>还有 ${pending.length} 条待确认。</b>它们不进这一版清单,也不参与检查。</p></div></div>` : ""}${items.some((i) => i.updated) ? `<p class="cap muted">有 ${items.filter((i) => i.updated).length} 条补遗更新还没核对,确认后仍可逐条核对。</p>` : ""}`,
ok: "确认清单", onOk: () => { confirmed = true; status(); toast(`清单 v${ver} 已确认`); }
onOk: () => { confirmed = true; render(); toast("清单 v3 已确认"); }
}); });
$("[data-add]").onclick = () => dialog({ $("[data-add]").onclick = () => {
title: "新增条目", let ncat = "invalid";
body: `<div class="field"><label for="na">要求</label><textarea class="input" id="na" placeholder="写下招标文件里的要求"></textarea></div> dialog({
<div class="field" style="margin-top:var(--sp-4)"><label for="nc">分类</label><select class="input" id="nc">${D.categories.map((c) => `<option value="${c.id}">${c.name}</option>`).join("")}</select></div> title: "新增条目", wide: true, ok: "添加",
<p class="cap muted">人工添加的条目可以没有出处,会标注「人工添加」。</p>`, body: `<div class="field fill" id="na-f"><label for="na">要求 <small>(必填)</small></label><textarea class="input" id="na" placeholder="写下招标文件里的要求"></textarea></div>
ok: "添加", <div class="form" style="margin-top:var(--sp-4)"><div class="field"><label>分类</label><div class="pop" data-select data-ncat><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" data-value="${c.id}" ${c.id === "invalid" ? 'aria-selected="true"' : ""}>${c.name}</li>`).join("")}</ul></div></div>
onOk: (s) => { const v = $("#na", s).value.trim(); if (!v) return false; items.push({ id: "u" + items.length, cats: [$("#nc", s).value], title: v, lot: 0, src: "manual", cite: ["T", 1, ""] }); render(); toast("已添加"); } <div class="field"><label for="np">出处页码 <small>(选填)</small></label><div class="input-unit"><input class="input" id="np" inputmode="numeric"><span class="unit">/ ${D.docs.T.pages} 页</span></div></div></div>
}); <p class="cap muted" style="margin:var(--sp-3) 0 0">人工添加的条目可以没有出处,会标注「人工添加」。</p>`,
$("[data-missing]").onclick = (e) => { e.preventDefault(); dialog({ title: "反馈:有遗漏的要求", body: `<div class="field"><label for="mi">漏了什么?在第几页?</label><textarea class="input" id="mi" placeholder="例如:第 26 页要求提供原厂授权书,清单里没有"></textarea></div><p class="cap muted">反馈用于改进提取规则。如果需要把这条加进清单,请用「新增条目」。</p>`, ok: "提交反馈", onOk: () => toast("已收到反馈,谢谢") }); }; onOpen: (s) => { $("[data-ncat]", s).addEventListener("change", (e) => { ncat = e.detail; }); $("#na", s).addEventListener("input", () => App.fieldError($("#na-f", s), "")); },
onOk: (s) => {
const v = $("#na", s).value.trim();
if (!v) return App.fieldError($("#na-f", s), "请写下这条要求") && false;
const pg = +$("#np", s).value || 0;
edited(); items.push({ id: "u" + items.length, cats: [ncat], title: v, lot: 0, src: "manual", noCite: !pg, cite: ["T", pg || 1, ""] });
render(); toast(`已添加到「${catName(ncat)}」`);
}
});
};
$("[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:var(--sp-3) 0 0">反馈只用于改进提取。要加进清单,请用「新增条目」。</p>`,
onOk: () => toast("已收到反馈,谢谢") });
};
render(); render();
V.open(items[0].cite); sel = items[0].id; list(); select(sel);
</script> </script>
</body> </body>
</html> </html>

View File

@@ -10,73 +10,106 @@
<link rel="stylesheet" href="../design/system/components.css"> <link rel="stylesheet" href="../design/system/components.css">
<link rel="stylesheet" href="assets/app.css"> <link rel="stylesheet" href="assets/app.css">
<style> <style>
.page{max-width:1120px;margin:0 auto;padding:var(--sp-12) var(--sp-6) var(--sp-16)} /* 原型目录:不用侧栏框架;白色主调、白卡片、圆角三档 */
.page > header img{height:36px} body{min-width:1280px}
.page h1{font:600 32px/40px var(--font-serif);margin:var(--sp-6) 0 var(--sp-2)} .dir-top{position:sticky;top:0;z-index:30;display:flex;align-items:center;gap:var(--sp-3);height:var(--topbar-h);padding:0 var(--pad-page);background:var(--topbar-glass);-webkit-backdrop-filter:blur(14px) saturate(1.2);backdrop-filter:blur(14px) saturate(1.2);border-bottom:var(--line) solid var(--border-subtle)}
.lede{color:var(--text-secondary);max-width:52em;margin:0} .dir-top img{height:26px;display:block}
h2{font:600 var(--fs-h2)/var(--lh-h2) var(--font-serif);margin:var(--sp-12) 0 var(--sp-4);padding-bottom:var(--sp-2);border-bottom:2px solid var(--c-ink)} .dir-top .r{margin-left:auto;display:flex;gap:var(--sp-2);align-items:center}
.journey{display:grid;grid-template-columns:repeat(4,1fr);gap:var(--sp-3);counter-reset:s} .page{max-width:calc(var(--content-max) + 2 * var(--pad-page));margin:0 auto;padding:var(--sp-10) var(--pad-page) var(--sp-16)}
.journey a{display:block;background:var(--bg-surface);border:1px solid var(--border-subtle);border-radius:var(--radius-sm);padding:var(--sp-4);color:inherit;text-decoration:none;counter-increment:s;position:relative} .page h1{font:var(--fw-bold) var(--fs-h1)/var(--lh-h1) var(--font-serif);margin:0}
.journey a:hover{border-color:var(--c-ink)} .lede{color:var(--text-secondary);margin:var(--sp-2) 0 0;max-width:56em}
.journey a::before{content:counter(s);font:600 22px/1 var(--font-figures);color:var(--text-secondary);display:block;margin-bottom:var(--sp-2)} .sec{margin-top:var(--sp-10)}
.journey b{display:block;font-size:var(--fs-title)} .sec-h{display:flex;align-items:baseline;gap:var(--sp-3);margin-bottom:var(--sp-4)}
.journey span{font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary);display:block;margin-top:4px} .sec-h h2{font:var(--fw-bold) var(--fs-h2)/var(--lh-h2) var(--font-serif);margin:0}
.journey .file{position:absolute;top:var(--sp-4);right:var(--sp-4);font:12px var(--font-figures);color:var(--ai)} .sec-h .ref{margin-left:auto;font-size:var(--fs-caption);color:var(--text-secondary)}
table.data td{height:auto}
.two{display:grid;grid-template-columns:1fr 1fr;gap:var(--sp-6)} /* J1 旅程:八步,一行四张 */
.two ul{margin:0;padding-left:1.2em} .journey{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--gap-card);list-style:none;margin:0;padding:0;counter-reset:s}
.two li{margin:4px 0} .journey a{display:flex;flex-direction:column;gap:var(--sp-1);height:100%;padding:var(--sp-4) var(--card-x);background:var(--bg-surface);border:var(--line) solid var(--border-subtle);border-radius:var(--radius-card);color:var(--text-primary);text-decoration:none;counter-increment:s;transition:background-color var(--dur-fast) var(--ease),border-color var(--dur-fast) var(--ease)}
.journey a:hover{background:var(--c-n50);border-color:var(--border-control)}
.journey .no{display:flex;align-items:baseline;gap:var(--sp-2);margin-bottom:var(--sp-1)}
.journey .no::before{content:counter(s);font:var(--fw-bold) var(--fs-figure)/1 var(--font-figures);color:var(--text-secondary)}
.journey .no code{margin-left:auto;font:var(--fs-caption)/1 var(--font-figures);color:var(--text-secondary)}
.journey b{font-size:var(--fs-title);line-height:var(--lh-title)}
.journey span.d{font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary)}
.pages td:first-child{white-space:nowrap}
.pages td:nth-child(2){white-space:nowrap;color:var(--text-secondary);font-family:var(--font-figures);font-size:var(--fs-figure-xs)}
table.data.pages td{height:auto}
.two{display:grid;grid-template-columns:1fr 1fr;gap:var(--gap-card)}
.two ul{margin:var(--sp-2) 0 0;padding-left:1.2em}
.two li{margin:var(--sp-1) 0}
pre.run{margin:var(--sp-3) 0 0;padding:var(--sp-3) var(--sp-4);background:var(--c-n100);border-radius:var(--radius-ctl);font:13px/20px ui-monospace,Menlo,Consolas,monospace;color:var(--text-primary);overflow:auto}
code{font:13px/20px ui-monospace,Menlo,Consolas,monospace}
</style> </style>
</head> </head>
<body> <body>
<div class="page"> <header class="dir-top">
<header><img src="../design/logo/svg/logo-horizontal.svg" alt="标博士"></header> <img src="../design/logo/svg/logo-horizontal.svg" alt="标博士">
<h1>MVP 可点击原型 v0.1</h1> <span class="tag">原型</span>
<p class="lede">按 <code>docs/analysis/02-user-journeys.md</code> 的 J1–J5 走一遍 MVP 链路。示意场景:某市政务服务中心智慧大厅建设项目(政府采购 · 公开招标 · 2 个采购包,投采购包 1)。条款改写自苏采云样本 S02、S10,项目名、金额、页码、投标响应和人名都是示意。直接使用设计规范的变量和组件(<code>design/system/</code>),所以是中保真:重点验证流程、信息结构和红线落地,视觉细节留到 UI 设计阶段精修。</p> <div class="r"><a class="btn text sm" href="login.html">登录页</a><a class="btn sm" href="workbench.html">进入工作台<i data-ic="chev-right"></i></a></div>
</header>
<main class="page">
<h1>MVP 可点击原型 v0.2</h1>
<p class="lede">按 J1–J5 走一遍 MVP 链路。示意场景:某市政务服务中心智慧大厅建设项目(政府采购 · 公开招标 · 2 个采购包,投采购包 1)。条款改写自苏采云样本 S02、S10,项目名、金额、页码、投标响应和人名都是示意。视觉按设计规范 v0.2 重刷(2026-11-03)。</p>
<h2>J1 · 首次体验:从上传到导出报告</h2> <section class="sec">
<div class="journey"> <div class="sec-h"><h2>J1 · 从上传到导出报告</h2></div>
<a href="login.html"><span class="file">login</span><b>注册 / 登录</b><span>手机号 + 验证码,填企业名称;协议写明不得用于串通投标</span></a> <ol class="journey">
<a href="project-new.html"><span class="file">project-new</span><b>新建项目,上传招标文件</b><span>按文件头识别真实格式;加密文件直接说明原因、不扣点</span></a> <li><a href="login.html"><span class="no"><code>login</code></span><b>注册 / 登录</b><span class="d">手机号 + 验证码,填企业名称;协议写明不得用于串通投标</span></a></li>
<a href="parsing.html"><span class="file">parsing</span><b>等待解析</b><span>分阶段进度,可以离开;失败自动退回额度</span></a> <li><a href="project-new.html"><span class="no"><code>project-new</code></span><b>新建项目,上传招标文件</b><span class="d">按文件头识别真实格式;加密文件直接说明原因、不扣点</span></a></li>
<a href="lots.html"><span class="file">lots</span><b>选择所投的包</b><span>只保留通用 + 所选包的条目;分包可手工更正</span></a> <li><a href="parsing.html"><span class="no"><code>parsing</code></span><b>等待解析</b><span class="d">分阶段进度,可以离开;失败自动退回额度</span></a></li>
<a href="checklist.html"><span class="file">checklist</span><b>读标:浏览清单,看原文</b><span>左分类、中清单、右原文高亮;待确认区;补遗更新;确认清单</span></a> <li><a href="lots.html"><span class="no"><code>lots</code></span><b>选择所投的包</b><span class="d">只保留通用 + 所选包的条目;分包可手工更正</span></a></li>
<a href="bid-check.html"><span class="file">bid-check</span><b>上传投标文件并检查</b><span>多分册、多版本;写明查什么、不查什么;识别加密格式</span></a> <li><a href="checklist.html"><span class="no"><code>checklist</code></span><b>读标:看清单和原文</b><span class="d">分类、清单、原文高亮并排;待确认;补遗更新;确认清单</span></a></li>
<a href="results.html"><span class="file">results</span><b>查标:逐条处理</b><span>出处批注、三种处理、致命项确认需写说明、键盘逐条处理、批量处理提示级</span></a> <li><a href="bid-check.html"><span class="no"><code>bid-check</code></span><b>上传投标文件并检查</b><span class="d">多分册、多版本;写明查什么、不查什么;识别加密格式</span></a></li>
<a href="report.html"><span class="file">report</span><b>导出报告</b><span>首页写覆盖范围和免责声明;logo 只在页眉</span></a> <li><a href="results.html"><span class="no"><code>results</code></span><b>查标:逐条处理</b><span class="d">出处批注、三种处理、致命项确认写说明、键盘逐条处理、批量处理提示</span></a></li>
</div> <li><a href="report.html"><span class="no"><code>report</code></span><b>导出报告</b><span class="d">首页写检查范围和免责声明;logo 只在页眉</span></a></li>
</ol>
</section>
<h2>其他页面</h2> <section class="sec">
<table class="data"> <div class="sec-h"><h2>其他页面</h2></div>
<thead><tr><th>页面</th><th>对应</th><th>要点</th></tr></thead> <div class="card flush">
<tbody> <table class="data pages">
<tr><td><a class="plain" href="workbench.html">工作台</a></td><td>FR-M6-01</td><td>按截止排序;倒计时临近用赭色;就绪只在「已检查」显示;致命计数</td></tr> <thead><tr><th>页面</th><th>对应</th><th>要点</th></tr></thead>
<tr><td><a class="plain" href="project.html">项目概览</a></td><td>FR-M6-02、M2-09、J2</td><td>状态流;AI 摘要带出处;关键日期(补遗更新);文件与版本;标记已递交(可撤销)、归档、删除(输入项目名确认)</td></tr> <tbody>
<tr><td><a class="plain" href="project-new.html?addendum=1">追加补遗</a> → <a class="plain" href="parsing.html?addendum=1">解析补遗</a></td><td>FR-M2-10、J2-1</td><td>补遗单独扣 1 次;并入清单后回到草稿,由用户核对</td></tr> <tr><td><a class="lnk" href="workbench.html">工作台</a></td><td>FR-M6-01</td><td>按截止排序;截止临近用赭色;统计可点筛选;「就绪」只在「已检查」显示;致命计数;下一步 / 更多</td></tr>
<tr><td><a class="plain" href="quota.html">额度</a></td><td>FR-M6-03、J3</td><td>扣点规则可见;流水对应到任务;预扣 / 退回成对出现;复查免扣也有记录</td></tr> <tr><td><a class="lnk" href="project.html">项目概览</a></td><td>FR-M6-02、M2-09、J2</td><td>状态流;AI 摘要带出处;关键日期(补遗更新);文件与版本;标记已递交(可撤销)、归档、删除(输入项目名确认)</td></tr>
<tr><td><a class="plain" href="qualifications.html">资质库</a></td><td>FR-M6-08、J2-3</td><td>到期按项目截止日判断;缺附件显示「待补录」</td></tr> <tr><td><a class="lnk" href="project-new.html?addendum=1">追加补遗</a> → <a class="lnk" href="parsing.html?addendum=1">解析补遗</a></td><td>FR-M2-10、J2-1</td><td>补遗单独扣 1 次;并入清单后回到草稿,由用户核对</td></tr>
<tr><td><a class="plain" href="../design/system/components.html">组件预览</a></td><td>设计规范</td><td>原型用到的全部组件</td></tr> <tr><td><a class="lnk" href="quota.html">额度</a></td><td>FR-M6-03、J3</td><td>扣点规则可见;流水对应到任务;预扣 / 退回成对出现;24 小时内复查记「免扣」</td></tr>
</tbody> <tr><td><a class="lnk" href="qualifications.html">资质库</a></td><td>FR-M6-08、J2-3</td><td>到期按项目截止日判断;缺附件显示「待补录」</td></tr>
</table> <tr><td><a class="lnk" href="../design/ui-demo/index.html">UI demo</a></td><td>设计规范 v0.2</td><td>控件、表单、卡片、列表的全部状态;原型照搬它的共用实现</td></tr>
</tbody>
</table>
</div>
</section>
<h2>模拟了什么、没模拟什么</h2> <section class="sec">
<div class="two"> <div class="sec-h"><h2>模拟了什么、没模拟什么</h2></div>
<div class="card"><b>可以点、会变化</b><ul> <div class="two">
<li>查标:处理 / 撤销 / 忽略原因校验 / 致命项确认说明 / 批量 / 快捷键 J K O 1 2 3 U ? / 原文抽屉 / 就绪状态与页签计数联动</li> <div class="card"><b>可以点、会变化</b><ul>
<li>读标:分类筛选、点清单项看原文高亮、待确认区采纳与丢弃、补遗更新核对、修改记录、确认清单</li> <li>查标:处理 / 撤销 / 忽略原因 / 致命项确认说明 / 批量 / 快捷键 J K O 1 2 3 X U ? / 原文抽屉 / 就绪状态与标签页计数联动</li>
<li>解析与检查的进度动画、取消、失败演示</li> <li>读标:分类筛选、点清单项看原文高亮、待确认的采纳与丢弃、补遗更新核对、修改记录、确认清单</li>
<li>项目状态:标记已递交、撤销、归档、删除确认</li> <li>解析与检查的进度、取消、失败演示</li>
<li>报告:勾选附录、出处后实时重排</li></ul></div> <li>项目状态:标记已递交、撤销、归档、删除确认</li>
<div class="card"><b>没有做</b><ul> <li>报告:勾选附录、出处后实时重排</li></ul></div>
<li>真实文件上传与解析(点上传区就出示例文件)</li> <div class="card"><b>没有做</b><ul>
<li>数据不保存,刷新后恢复初始状态</li> <li>真实文件上传与解析(点上传区就出示例文件)</li>
<li>原文查看器只做了被引用到的页面</li> <li>数据不保存,刷新后恢复初始状态</li>
<li>免费版的差异(报告角标、额度用完后的升级引导)只在文字里说明</li> <li>原文查看器只做了被引用到的页面</li>
<li>运营后台(FR-M6-09)、通知中心、账号设置</li></ul></div> <li>免费版的差异(报告角标、额度用完后的升级引导)只在文字里说明</li>
</div> <li>运营后台(FR-M6-09)、通知中心、账号设置、全局搜索</li></ul></div>
</div>
</section>
<h2>运行</h2> <section class="sec">
<p>在 <code>docs/</code> 目录运行 <code>python3 -m http.server 8766</code>,打开 <code>http://127.0.0.1:8766/prototype/</code>。宋体和数字字体从 <code>design/logo/.work/fonts</code> 加载,没有该目录时退回系统字体。设计基准 1440 宽,最小 1280。</p> <div class="sec-h"><h2>运行</h2></div>
</div> <p class="mt0">必须从 <code>docs/</code> 起服务(原型引用了 <code>../design/system/</code> 和 <code>../design/logo/</code>)。设计基准 1440 宽,最小 1280。URL 加 <code>?quota=warn</code> / <code>?quota=out</code> 可预览额度 80% 和用完。</p>
<pre class="run">cd docs &amp;&amp; python3 -m http.server 8766
# 打开 http://127.0.0.1:8766/prototype/</pre>
</section>
</main>
<script src="assets/data.js"></script>
<script src="assets/app.js"></script>
</body> </body>
</html> </html>

View File

@@ -10,55 +10,87 @@
<link rel="stylesheet" href="../design/system/components.css"> <link rel="stylesheet" href="../design/system/components.css">
<link rel="stylesheet" href="assets/app.css"> <link rel="stylesheet" href="assets/app.css">
<style> <style>
body{display:grid;grid-template-columns:minmax(0,1fr) 520px;min-height:100vh;min-width:1280px} /* 登录页(v0.2):不用侧栏框架。浅灰页面上一张白卡片放表单;左侧品牌与三句话;按钮、输入框用 40 高(§7.1) */
.brand-side{padding:var(--sp-16) var(--sp-16) var(--sp-10);display:flex;flex-direction:column;border-right:1px solid var(--border-subtle)} body{min-width:1280px;min-height:100vh;display:flex;flex-direction:column}
.brand-side img{width:200px} .wrap{flex:1;display:grid;grid-template-columns:minmax(0,1fr) 440px;gap:var(--sp-16);align-items:center;width:100%;max-width:1120px;margin:0 auto;padding:var(--sp-12) var(--pad-page)}
.brand-side .slogan{font:600 32px/1.4 var(--font-serif);margin:var(--sp-12) 0 var(--sp-6)} .brand img{width:200px;display:block}
.points{list-style:none;margin:0;padding:0;max-width:34em} .brand .slogan{font:var(--fw-bold) 28px/1.4 var(--font-serif);margin:var(--sp-10) 0 var(--sp-8)}
.points li{padding:var(--sp-3) 0 var(--sp-3) var(--sp-4);border-left:2px solid var(--ai);margin-bottom:var(--sp-3)} .points{list-style:none;margin:0;padding:0;max-width:30em;counter-reset:p}
.points b{display:block;font-size:var(--fs-title)} .points li{display:grid;grid-template-columns:32px minmax(0,1fr);padding:var(--sp-4) 0;border-top:var(--line) solid var(--border-subtle);counter-increment:p}
.points li:first-child{border-top:0;padding-top:0}
.points li::before{content:counter(p);font:var(--fw-bold) var(--fs-title)/var(--lh-title) var(--font-figures);color:var(--text-secondary)}
.points b{display:block;font-size:var(--fs-title);line-height:var(--lh-title)}
.points span{color:var(--text-secondary)} .points span{color:var(--text-secondary)}
.brand-side .legal{margin-top:auto;font-size:var(--fs-caption);color:var(--text-secondary)}
.form-side{background:var(--bg-surface);padding:120px var(--sp-12) var(--sp-10);display:flex;flex-direction:column} .login{padding:var(--sp-8)}
.form-side .tabs{margin:0 0 var(--sp-8);border-bottom:1px solid var(--border-subtle)} .login .tabs{margin-bottom:var(--sp-6)}
.login .tabs [role=tab]{font-size:var(--fs-title)}
.login .field{max-width:none}
.login .field + .field{margin-top:var(--sp-5)}
.login .input,.login .btn{height:var(--control-h-lg)}
.code{display:flex;gap:var(--sp-2)} .code{display:flex;gap:var(--sp-2)}
.code .input{flex:1} .code .input{flex:1;min-width:0}
.agree{display:flex;gap:var(--sp-2);align-items:flex-start;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary);margin-top:var(--sp-5)} .code .btn{min-width:128px}
.agree input{margin-top:3px;accent-color:var(--c-ink)} .agree{display:flex;margin-top:var(--sp-5);font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary)}
.free{margin-top:var(--sp-5);padding:var(--sp-3) var(--sp-4);background:var(--bg-sunken);border-radius:var(--radius-sm);font-size:var(--fs-caption);line-height:var(--lh-caption)} .agree .choice{gap:var(--sp-2)}
.agree .choice .ck{margin-top:2px}
.agree-err{display:flex;gap:6px;align-items:center;margin-top:var(--sp-2);font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--warn-text)}
.agree-err .ico{width:14px;height:14px;font-size:10px;margin:0}
.go{width:100%;margin-top:var(--sp-6)}
.free{margin:var(--sp-5) 0 0;padding-top:var(--sp-4);border-top:var(--line) solid var(--border-subtle);font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary)}
.free .fig{color:var(--text-primary)}
.legal{padding:0 var(--pad-page) var(--sp-6);text-align:center;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary)}
</style> </style>
</head> </head>
<body> <body>
<section class="brand-side"> <div class="wrap">
<img src="../design/logo/svg/logo-horizontal-textured.svg" alt="标博士 BIAOBOSHI"> <section class="brand" aria-label="标博士">
<p class="slogan">标博士,你的AI投标专家</p> <img src="../design/logo/svg/logo-horizontal-textured.svg" alt="标博士 BIAOBOSHI">
<ul class="points"> <p class="slogan">标博士,你的AI投标专家</p>
<li><b>读标:每一条要求都有出处</b><span>上传招标文件,逐条提取资格、无效条款、评分、日期和 ★ 参数,点一下就跳到原文那一页。</span></li> <ol class="points">
<li><b>查标:递交前逐条对一遍</b><span>把投标文件和清单逐条核对,找出没响应、疑似不满足、漏盖章的地方,由你逐条确认。</span></li> <li><div><b>读标:每一条要求都有出处</b><span>资格、无效条款、评分、日期和 ★ 参数逐条列出,点一下就到原文那一页。</span></div></li>
<li><b>报告:谁在什么时候确认了什么</b><span>导出带处理记录的检查报告,写明查了什么、没查什么。</span></li> <li><div><b>查标:递交前逐条对一遍</b><span>找出没响应、疑似不满足、漏盖章的地方,由你逐条确认。</span></div></li>
</ul> <li><div><b>报告:谁在什么时候确认了什么</b><span>导出带处理记录的检查报告,写明查了什么、没查什么。</span></div></li>
<p class="legal">辅助检查工具,不替代人工审核,也不对投标结果作任何保证。</p> </ol>
</section> </section>
<section class="form-side">
<nav class="tabs" aria-label="登录方式"><a href="#" class="on" data-t="login">登录</a><a href="#" data-t="reg">注册</a></nav> <section class="card login" aria-label="登录或注册">
<div class="stack"> <div class="tabs" role="tablist" aria-label="登录方式" data-mode>
<div class="field"><label for="ph">手机号</label><input class="input" id="ph" inputmode="numeric" placeholder="11 位手机号" value="13800000000"></div> <button role="tab" aria-selected="true" data-v="login">登录</button>
<div class="field"><label for="cd">验证码</label><div class="code"><input class="input" id="cd" inputmode="numeric" placeholder="6 位数字"><button class="btn" data-send>发送验证码</button></div></div> <button role="tab" aria-selected="false" data-v="reg">注册</button>
<div class="field" data-reg hidden><label for="co">企业名称</label><input class="input" id="co" placeholder="与开票信息一致,例如:南京某信息技术有限公司"><span class="msg">额度和数据归属企业主体,一个手机号只能属于一个企业。</span></div> </div>
</div> <div class="field"><label for="ph">手机号</label><input class="input" id="ph" inputmode="numeric" autocomplete="tel" placeholder="11 位手机号" value="13800000000"></div>
<label class="agree" data-reg hidden><input type="checkbox" id="ag"><span>我已阅读并同意《服务协议》《隐私政策》。服务协议约定:本产品只用于投标人对自己投标文件的投前自查,不得用于串通投标、帮助他人规避雷同等违法用途。</span></label> <div class="field"><label for="cd">验证码</label><div class="code"><input class="input" id="cd" inputmode="numeric" autocomplete="one-time-code" placeholder="6 位数字"><button class="btn" type="button" data-send>发送验证码</button></div></div>
<button class="btn primary lg" style="margin-top:var(--sp-6);width:100%;justify-content:center" data-go>登录</button> <div class="field" data-reg hidden><label for="co">企业名称</label><input class="input" id="co" placeholder="与开票信息一致"><span class="msg">额度和数据归属企业主体,一个手机号只能属于一个企业。</span></div>
<div class="free" data-reg hidden>注册即开通免费版:解读 <b>3</b> 次招标文件,检查 <b>1</b> 个项目(项目内复查不限次数)。不需要上传营业执照。</div> <div data-reg hidden>
</section> <div class="agree"><label class="choice"><input type="checkbox" class="ck" id="ag"><span>我已阅读并同意《服务协议》《隐私政策》。本产品只用于投标人对自己投标文件的投前自查,不得用于串通投标、帮助他人规避雷同等违法用途。</span></label></div>
<p class="agree-err" data-ag-err hidden><span class="ico w" aria-hidden="true">!</span>请先阅读并同意服务协议</p>
</div>
<button class="btn primary lg go" data-go>登录</button>
<p class="free" data-reg hidden>注册即开通免费版:解读 <span class="fig">3</span> 次招标文件,检查 <span class="fig">1</span> 个项目(项目内复查不限次数)。不需要上传营业执照。</p>
</section>
</div>
<p class="legal">辅助检查工具,不替代人工审核;检查结果仅供投标前自查参考,是否递交由投标人自行决定。</p>
<script src="assets/data.js"></script> <script src="assets/data.js"></script>
<script src="assets/app.js"></script> <script src="assets/app.js"></script>
<script> <script>
const { $, $$, toast } = App; const { $, $$, toast } = App;
let mode = "login"; let mode = "login";
$$("[data-t]").forEach((a) => (a.onclick = (e) => { e.preventDefault(); mode = a.dataset.t; $$("[data-t]").forEach((x) => x.classList.toggle("on", x === a)); $$("[data-reg]").forEach((x) => (x.hidden = mode !== "reg")); $("[data-go]").textContent = mode === "reg" ? "注册并开通免费版" : "登录"; })); $("[data-mode]").addEventListener("change", (e) => {
$("[data-send]").onclick = (e) => { let n = 60; const b = e.target; b.disabled = true; const t = setInterval(() => { b.textContent = `${--n} 秒后重发`; if (!n) { clearInterval(t); b.disabled = false; b.textContent = "发送验证码"; } }, 1000); toast("验证码已发送(原型:任意 6 位数字)"); }; mode = e.detail;
$$("[data-reg]").forEach((x) => (x.hidden = mode !== "reg"));
$("[data-go]").textContent = mode === "reg" ? "注册并开通免费版" : "登录";
});
$("[data-send]").onclick = (e) => {
let n = 60; const b = e.currentTarget; b.disabled = true; b.textContent = `${n} 秒后重发`;
const t = setInterval(() => { b.textContent = `${--n} 秒后重发`; if (!n) { clearInterval(t); b.disabled = false; b.textContent = "发送验证码"; } }, 1000);
toast("验证码已发送(原型:任意 6 位数字)");
$("#cd").focus();
};
$("#ag").onchange = () => { if ($("#ag").checked) $("[data-ag-err]").hidden = true; };
$("[data-go]").onclick = () => { $("[data-go]").onclick = () => {
if (mode === "reg" && !$("#ag").checked) { toast("请先阅读并同意服务协议"); return; } if (mode === "reg" && !$("#ag").checked) { $("[data-ag-err]").hidden = false; $("#ag").focus(); return; }
location.href = mode === "reg" ? "project-new.html" : "workbench.html"; location.href = mode === "reg" ? "project-new.html" : "workbench.html";
}; };
</script> </script>

View File

@@ -1,54 +1,131 @@
<!doctype html> <!doctype html>
<!--
选择所投的包(v0.2):解析完成后进入;读标页「更改所投的包」也回到这里。
框架来自 App.shell();选择卡(.choice-card)、出处链接(App.citeLink)、原文抽屉(App.drawer)都是共用组件,本页 <style> 只放布局。
限价是招标文件里的原文数字(带出处),不是系统给出的报价建议(红线 6)。
-->
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>选择所投的包 · 标博士原型</title> <title>选择所投的包 · 标博士原型</title>
<link rel="icon" href="../design/logo/favicon.svg">
<link rel="stylesheet" href="../design/system/tokens.css"> <link rel="stylesheet" href="../design/system/tokens.css">
<link rel="stylesheet" href="../design/system/fonts-local.css"> <link rel="stylesheet" href="../design/system/fonts-local.css">
<link rel="stylesheet" href="../design/system/components.css"> <link rel="stylesheet" href="../design/system/components.css">
<link rel="stylesheet" href="assets/app.css"> <link rel="stylesheet" href="assets/app.css">
<style> <style>
.narrow{max-width:760px} .lots-wrap{max-width:800px}
.lot{display:grid;grid-template-columns:20px minmax(0,1fr) auto;gap:var(--sp-4);align-items:start;padding:var(--sp-4) var(--sp-5);background:var(--bg-surface);border:1px solid var(--border-control);border-radius:var(--radius-sm);cursor:pointer} .lots{display:flex;flex-direction:column;gap:var(--sp-3);padding-top:var(--sp-1)}
.lot + .lot{margin-top:var(--sp-3)} .lot{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:var(--sp-3);align-items:start;padding:var(--sp-4)}
.lot.on{border-color:var(--c-ink);box-shadow:inset 0 0 0 1px var(--c-ink)} .lot b{font-size:var(--fs-title);line-height:var(--lh-title)}
.lot input{width:16px;height:16px;margin:3px 0 0;accent-color:var(--c-ink)} .lot .sub{display:flex;flex-wrap:wrap;gap:var(--sp-1) var(--sp-3);align-items:center;margin-top:2px;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary)}
.lot b{font-size:var(--fs-title)} .lot .sub .cite-link{font-size:var(--fs-caption)}
.lot .money{text-align:right} .lot .sub .cite-link .fig{font-size:var(--fs-figure-xs)}
.lot .money .fig{font-size:20px} .lot .money{text-align:right;white-space:nowrap}
.common{margin-top:var(--sp-4);padding:var(--sp-3) var(--sp-5);border:1px dashed var(--border-control);border-radius:var(--radius-sm);color:var(--text-secondary)} .lot .money small{display:block;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary)}
.lot .money .fig{font-size:var(--fs-figure-sm);font-weight:var(--fw-medium)}
.common{display:flex;gap:var(--sp-2);align-items:flex-start;margin-top:var(--sp-4);color:var(--text-secondary)}
.common > .ic{flex:none;margin-top:3px}
.common p{margin:0}
.common .fig{color:var(--text-primary)}
.card-f .sum{color:var(--text-secondary)}
.card-f .sum .fig{color:var(--text-primary);font-weight:var(--fw-medium)}
.card-f .sum.warn{color:var(--warn-text)}
.card-f .r{margin-left:auto;display:flex;gap:var(--sp-3);align-items:center}
table.data.lot-tbl td{vertical-align:middle}
.lot-tbl .input{height:var(--control-h-sm)}
.lot-tbl .money-in{width:120px;text-align:right}
</style> </style>
</head> </head>
<body> <body>
<main> <main>
<div class="content narrow"> <div class="content">
<div class="page-title"><div><h1>选择要投的包</h1><p>识别到 2 个采购包。清单只保留通用条目和所选包的条目,没选的包折叠隐藏,以后可以改。</p></div></div> <div class="lots-wrap">
<label class="lot on"><input type="checkbox" checked data-lot="1"><div><b>采购包 1 · 智慧大厅软硬件集成</b><div class="muted cap">8 条专属要求,其中 ★ 条款 4 条 · <a class="cite-src" href="#" style="display:inline" data-open>招标文件 第 2 页</a></div></div><div class="money"><span class="cap muted">最高限价</span><br><span class="fig">286.00</span> 万元</div></label> <div class="page-title"><div><h1>选择要投的包</h1><p>识别到 <span class="fig" data-n>2</span> 个采购包。清单只保留通用条目和所选包的条目,以后可以改。</p></div></div>
<label class="lot"><input type="checkbox" data-lot="2"><div><b>采购包 2 · 机房网络设备更新</b><div class="muted cap">9 条专属要求,其中 ★ 条款 2 条 · <a class="cite-src" href="#" style="display:inline" data-open>招标文件 第 2 页</a></div></div><div class="money"><span class="cap muted">最高限价</span><br><span class="fig">132.00</span> 万元</div></label>
<div class="common">通用条目 <span class="fig">18</span> 条(资格条件、无效情形、递交要求等),所有包都适用,始终保留。</div> <section class="card flush" aria-labelledby="h-lots">
<div class="row" style="margin-top:var(--sp-6)"> <div class="card-h"><h2 id="h-lots">采购包</h2><span class="more" style="margin-left:auto"><a class="lnk cap" href="#" data-fix>识别得不对?手工调整</a></span></div>
<a class="plain cap" href="#" data-fix>分包识别得不对?手工调整</a> <div class="card-b">
<span style="margin-left:auto"></span> <div class="lots" role="group" aria-labelledby="h-lots" data-lots></div>
<a class="btn primary" href="checklist.html" data-go>查看清单</a> <div class="common"><i data-ic="info"></i><p>通用条目 <span class="fig">18</span> 条(资格条件、无效情形、递交要求等)所有包都适用,始终保留。</p></div>
</div>
<div class="card-f">
<span class="sum" data-sum></span>
<div class="r"><a class="btn primary" href="checklist.html" data-go>查看清单</a></div>
</div>
</section>
</div> </div>
</div> </div>
</main> </main>
<script src="assets/data.js"></script> <script src="assets/data.js"></script>
<script src="assets/app.js"></script> <script src="assets/app.js"></script>
<script> <script>
const { D, $, $$, dialog, toast } = App; const { D, $, $$, esc, dialog, toast } = App;
App.shell({ crumbs: [["工作台", "workbench.html"], [D.project.name, "project.html"], ["选择所投的包", ""]] }); App.shell({ crumbs: [["工作台", "workbench.html"], [D.project.name, "project.html"], ["选择所投的包", ""]] });
$$("[data-lot]").forEach((c) => (c.onchange = () => { c.closest(".lot").classList.toggle("on", c.checked); $("[data-go]").classList.toggle("disabled", !$$("[data-lot]:checked").length); }));
$$("[data-open]").forEach((a) => (a.onclick = (e) => { e.preventDefault(); e.stopPropagation(); toast("原型:在读标页的原文查看器里打开第 2 页"); })); /* 每个包的专属条目数、★ 条款数(示意);限价是招标文件第 2 页的原文 */
$("[data-go]").onclick = (e) => { if (!$$("[data-lot]:checked").length) { e.preventDefault(); toast("至少选择一个包"); } }; const COMMON = 18;
$("[data-fix]").onclick = (e) => { e.preventDefault(); dialog({ title: "手工调整分包", wide: true, let lots = D.project.lots.map((l) => ({ ...l, src: true, n: l.no === 1 ? 8 : D.hiddenLotItems, star: l.no === 1 ? 4 : 2 }));
body: `<p class="muted">可以增加、删除、重命名采购包,或把清单条目改到另一个包下(在读标页逐条调整)。</p> const drawer = App.drawer();
<table class="data"><thead><tr><th>包号</th><th>名称</th><th class="r">最高限价(万元)</th><th></th></tr></thead><tbody> const capCite = (l) => ["T", 2, "t2-cap", `采购包${l.no}:${l.cap}万元`];
<tr><td class="num">1</td><td><input class="input" value="智慧大厅软硬件集成"></td><td class="r"><input class="input" value="286.00" style="width:100px;text-align:right"></td><td><button class="btn sm text">删除</button></td></tr>
<tr><td class="num">2</td><td><input class="input" value="机房网络设备更新"></td><td class="r"><input class="input" value="132.00" style="width:100px;text-align:right"></td><td><button class="btn sm text">删除</button></td></tr> function render() {
</tbody></table><button class="btn sm" style="margin-top:var(--sp-3)">增加一个包</button>`, $("[data-n]").textContent = lots.length;
ok: "保存", onOk: () => toast("分包已保存,修改已留痕") }); }; $("[data-lots]").innerHTML = lots.map((l) => `<label class="choice-card lot">
<input type="checkbox" class="ck" data-lot="${l.no}" ${l.bid ? "checked" : ""}>
<span><b>采购包 ${l.no} · ${esc(l.name)}</b>
<span class="sub"><span>专属要求 <span class="fig">${l.n}</span> 条 · 其中 ★ 条款 <span class="fig">${l.star}</span> 条</span>${l.src ? App.citeLink(capCite(l), `招标文件 · 第 <span class="fig">2</span> 页`) : "<span>人工添加</span>"}</span></span>
<span class="money"><small>最高限价</small>${l.cap ? `<span class="fig">${l.cap}</span> 万元` : `<span class="muted">${l.src ? "未识别" : "—"}</span>`}</span>
</label>`).join("");
App.hydrate($("[data-lots]"));
sum();
}
function sum() {
const on = lots.filter((l) => l.bid);
const go = $("[data-go]"), s = $("[data-sum]");
const hid = lots.filter((l) => !l.bid).reduce((a, l) => a + l.n, 0);
go.setAttribute("aria-disabled", !on.length);
s.classList.toggle("warn", !on.length);
s.innerHTML = on.length
? `清单 <span class="fig">${COMMON + on.reduce((a, l) => a + l.n, 0)}</span> 条:通用 + ${on.map((l) => "采购包 " + l.no).join("、")}${hid ? ` · 隐藏 <span class="fig">${hid}</span> 条` : ""}`
: '<span class="ico w" aria-hidden="true">!</span> 至少选一个包';
}
document.addEventListener("change", (e) => {
if (!e.target.matches("[data-lot]")) return;
lots.find((l) => l.no === +e.target.dataset.lot).bid = e.target.checked;
sum();
});
document.addEventListener("click", (e) => {
const c = e.target.closest("[data-open]");
if (c) { e.preventDefault(); e.stopPropagation(); drawer.open(App.parseCite(c.dataset.open)); }
});
$("[data-go]").onclick = (e) => { if (!lots.some((l) => l.bid)) e.preventDefault(); };
/* 手工调整分包:增删、改名、改限价;修改留痕 */
$("[data-fix]").onclick = (e) => {
e.preventDefault(); e.stopPropagation();
const row = (l) => `<tr data-row data-orig="${l.orig || ""}"><td class="num"><span class="fig">${l.no}</span></td><td><input class="input" aria-label="包 ${l.no} 名称" value="${esc(l.name)}" data-name></td><td class="r"><input class="input money-in fig" aria-label="包 ${l.no} 最高限价(万元)" value="${l.cap || ""}" inputmode="decimal" data-cap></td><td class="acts"><button type="button" class="btn sm text" data-del>删除</button></td></tr>`;
const s = dialog({
title: "手工调整分包", wide: true, ok: "保存", footLeft: '<span class="cap muted">修改会留痕</span>',
body: `<p class="muted" style="margin-top:0">可以增加、删除、重命名采购包。条目归属哪个包,在读标页逐条改。</p>
<table class="data lot-tbl"><thead><tr><th>包号</th><th>名称</th><th class="r">最高限价(万元)</th><th></th></tr></thead><tbody data-rows>${lots.map((l) => row({ ...l, orig: l.no })).join("")}</tbody></table>
<button type="button" class="btn sm" style="margin-top:var(--sp-3)" data-addrow><i data-ic="plus"></i>增加一个包</button>`,
onOk: (s) => {
const rows = $$("[data-row]", s);
if (!rows.length) { toast("至少保留一个包", { ok: false }); return false; }
lots = rows.map((r, k) => { const old = lots.find((l) => String(l.no) === r.dataset.orig) || {}; return { no: k + 1, name: $("[data-name]", r).value.trim() || `采购包 ${k + 1}`, cap: $("[data-cap]", r).value.trim(), bid: !!old.bid, src: !!old.src, n: old.n || 0, star: old.star || 0 }; });
render(); toast("分包已保存,修改已留痕");
}
});
s.addEventListener("click", (ev) => {
if (ev.target.closest("[data-del]")) { ev.target.closest("[data-row]").remove(); $$("[data-row] .num .fig", s).forEach((f, k) => { f.textContent = k + 1; }); }
if (ev.target.closest("[data-addrow]")) { const n = $$("[data-row]", s).length + 1; $("[data-rows]", s).insertAdjacentHTML("beforeend", row({ no: n, name: "", cap: "" })); $("[data-rows] tr:last-child [data-name]", s).focus(); }
});
};
render();
</script> </script>
</body> </body>
</html> </html>

View File

@@ -1,83 +1,156 @@
<!doctype html> <!doctype html>
<!--
解析进度(v0.2):框架来自 App.shell();任务进度卡按 design/ui-demo/cards.html 的「任务进度卡」(分阶段、进度条、失败说明、扣点)。
?addendum=1:解析补遗。页面底部的原型说明里可以演示解析失败。
本页 <style> 只放本页特有的:窄版宽度、阶段说明、失败说明。
-->
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>解析中 · 标博士原型</title> <title>解析 · 标博士原型</title>
<link rel="icon" href="../design/logo/favicon.svg">
<link rel="stylesheet" href="../design/system/tokens.css"> <link rel="stylesheet" href="../design/system/tokens.css">
<link rel="stylesheet" href="../design/system/fonts-local.css"> <link rel="stylesheet" href="../design/system/fonts-local.css">
<link rel="stylesheet" href="../design/system/components.css"> <link rel="stylesheet" href="../design/system/components.css">
<link rel="stylesheet" href="assets/app.css"> <link rel="stylesheet" href="assets/app.css">
<style> <style>
.narrow{max-width:760px} .narrow{max-width:800px}
.steps li{padding:10px 0} .card-h .aside{margin-left:auto;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary);white-space:nowrap}
.steps .sub{font-weight:var(--fw-regular);color:var(--text-secondary);font-size:var(--fs-caption);margin-left:auto} .card-h .aside .fig{font-size:var(--fs-figure-xs)}
.steps .now .dot{animation:pulse 1.2s ease-in-out infinite} .card-f .r{margin-left:auto;display:flex;gap:var(--sp-3);align-items:center}
@keyframes pulse{50%{opacity:.35}} .card-b{padding-top:var(--sp-2)}
@media (prefers-reduced-motion:reduce){.steps .now .dot{animation:none}}
.prog{height:4px;background:var(--bg-sunken);margin:var(--sp-4) 0 var(--sp-2)} /* 阶段:右侧辅助灰小字写这一步的结果 */
.prog i{display:block;height:100%;background:var(--c-ink);transition:width var(--dur-base)} .steps .sub{margin-left:auto;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary);font-weight:var(--fw-regular)}
.done-box{display:none} .steps .fail .sub{color:var(--warn-text)}
.finished .done-box{display:block} .card-b .progress{margin-top:var(--sp-3)}
.finished .run-box{display:none}
/* 失败说明(§7.11):赭色图标 + 赭色关键字,不用红 */
.fail-why{display:flex;gap:var(--sp-2);margin-top:var(--sp-3);padding-top:var(--sp-3);border-top:var(--line) solid var(--border-subtle)}
.fail-why > .ic{flex:none;margin-top:3px;color:var(--warn)}
.fail-why b{color:var(--warn-text);font-weight:var(--fw-bold)}
.fail-why p{margin:0}
.fail-why .sub{display:block;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary)}
/* 完成:结果摘要 */
.result .kv{grid-template-columns:88px minmax(0,1fr)}
.result .kv dd > .fig{font-weight:var(--fw-medium)}
.proto{margin-top:var(--sp-4)}
</style> </style>
</head> </head>
<body> <body>
<main> <main>
<div class="content narrow"> <div class="content narrow">
<div class="page-title"><div><h1 data-title>正在解析招标文件</h1><p>ZC-2026-0418 采购文件.doc · 186 页</p></div></div> <div class="page-title"><div><h1 data-title>解析招标文件</h1><p data-sub>ZC-2026-0418 采购文件.doc · 186 页</p></div></div>
<div class="card" data-card>
<div class="run-box"> <section class="card flush" aria-labelledby="h-task" data-card>
<ol class="steps" data-steps></ol> <div class="card-h"><h2 id="h-task" data-h>正在解析</h2><span class="aside" data-time></span></div>
<div class="prog"><i data-prog style="width:0"></i></div> <div class="card-b" data-body></div>
<p class="cap muted" style="margin:0">300 页以内一般 10 分钟内完成(原型里加速演示)。<b style="color:var(--text-primary)">可以离开本页</b>,完成或失败都会发站内通知。</p> <div class="card-f" data-foot></div>
<div class="row" style="margin-top:var(--sp-5)"> </section>
<span class="cost">已预扣 1 次解析,成功后结算;失败、超时或取消会自动退回</span>
<span style="margin-left:auto"></span> <p class="note proto">原型说明:进度加速演示,实际 300 页以内一般 10 分钟内完成。<a class="lnk" href="#" data-fail>看解析失败的样子</a></p>
<a class="btn" href="workbench.html">回工作台,完成后通知我</a>
<button class="btn text" data-cancel>取消解析</button>
</div>
</div>
<div class="done-box" data-done></div>
</div>
<p class="note"><a class="plain" href="#" data-fail>演示:解析失败的样子</a></p>
</div> </div>
</main> </main>
<script src="assets/data.js"></script> <script src="assets/data.js"></script>
<script src="assets/app.js"></script> <script src="assets/app.js"></script>
<script> <script>
const { D, $, dialog, toast } = App; const { D, $, icon, dialog, toast } = App;
const addendum = new URLSearchParams(location.search).has("addendum"); const addendum = new URLSearchParams(location.search).has("addendum");
App.shell({ crumbs: [["工作台", "workbench.html"], [addendum ? D.project.name : "新建项目", addendum ? "project.html" : ""], ["解析", ""]] }); App.shell({
if (addendum) $("[data-title]").textContent = "正在解析补遗 1"; nav: "workbench",
crumbs: addendum ? [["工作台", "workbench.html"], [D.project.name, "project.html"], ["解析补遗", ""]] : [["工作台", "workbench.html"], ["新建项目", "project-new.html"], ["解析", ""]]
});
if (addendum) {
document.title = "解析补遗 · 标博士原型";
$("[data-title]").textContent = "解析补遗 1";
$("[data-sub]").textContent = "关于 ZC-2026-0418 的更正公告.pdf · 2 页";
}
/* 阶段:[名称, 完成后的结果] */
const S = addendum const S = addendum
? [["识别文件格式", "PDF · 2 页"], ["提取文字", ""], ["提取变更内容", ""], ["核对出处", ""], ["并入清单", ""]] ? [["识别文件格式", "PDF · 2 页"], ["提取文字", "2 页"], ["提取变更内容", "2 处更正"], ["核对出处", "逐条定位页码和原句"], ["并入清单", "更新 2 条"]]
: [["识别文件格式", "实际为 DOCX,保留斜体、下划线等格式"], ["提取文字", "186 页"], ["识别分包与法律体系", "2 个采购包 · 政府采购"], ["提取清单", "资格、无效条款、评分、日期、技术参数"], ["核对出处", "逐条定位页码和原句"]]; : [["识别文件格式", "实际为 DOCX,保留斜体、下划线等格式"], ["提取文字", "186 页"], ["识别分包与法律体系", "2 个采购包 · 政府采购"], ["提取清单", "资格、无效条款、评分、日期、技术参数"], ["核对出处", "逐条定位页码和原句"]];
let i = 0, timer; const STEP_MS = 1400, SPEED = 30; // 每个阶段 1.4 秒;「已用时」按 30 倍折算,看起来像真实耗时
function render() { let i = 0, t0 = Date.now(), timer, state = "run";
$("[data-steps]").innerHTML = S.map(([t, sub], k) => `<li class="${k < i ? "done" : k === i ? "now" : "todo"}"><span class="dot">${k < i ? "✓" : ""}</span>${t}${k <= i && sub ? `<span class="sub">${sub}</span>` : ""}</li>`).join("");
$("[data-prog]").style.width = (i / S.length) * 100 + "%"; const dur = () => { const s = Math.round((Date.now() - t0) / 1000 * SPEED); return `<span class="fig">${Math.floor(s / 60)}</span> 分 <span class="fig">${String(s % 60).padStart(2, "0")}</span> 秒`; };
function steps(failAt = -1) {
return `<ol class="steps">${S.map(([t, sub], k) => {
const cls = k === failAt ? "fail" : k < i ? "done" : k === i ? "now" : "todo";
const dot = cls === "done" ? "✓" : cls === "fail" ? "!" : "";
return `<li class="${cls}"><span class="dot" aria-hidden="true">${dot}</span><span>${t}${cls === "now" ? '<span class="vh">(进行中)</span>' : ""}</span>${cls === "done" ? `<span class="sub">${sub.replace(/(\d+)/g, '<span class="fig xs">$1</span>')}</span>` : ""}</li>`;
}).join("")}</ol>`;
} }
function tick() { function renderRun() {
i++; render(); const pct = Math.min(99, Math.round(((i + Math.min(1, (Date.now() - stepAt) / STEP_MS)) / S.length) * 100));
if (i < S.length) timer = setTimeout(tick, 1100); $("[data-h]").textContent = "正在解析";
else finish(); $("[data-time]").innerHTML = `已用时 ${dur()}`;
$("[data-body]").innerHTML = `${steps()}
<div class="progress"><div class="bar" role="progressbar" aria-valuenow="${pct}" aria-valuemin="0" aria-valuemax="100" aria-label="解析进度"><i style="width:${pct}%"></i></div><span class="fig">${pct}%</span></div>
<p class="note">可以离开本页,完成或失败都会发站内通知。</p>`;
} }
const footRun = () => `<button class="btn sm" data-cancel>取消解析</button><span class="cost">已预扣 <span class="fig">1</span> 次解析 · 失败、超时、取消都退回</span>
<span class="r"><a class="btn sm" href="${addendum ? "project.html" : "workbench.html"}">${addendum ? "回项目概览" : "回工作台"},完成后通知我</a></span>`;
let stepAt = Date.now(), frame;
function loop() {
if (state !== "run") return;
if (Date.now() - stepAt >= STEP_MS) { i++; stepAt = Date.now(); if (i >= S.length) return finish(); }
renderRun();
frame = setTimeout(loop, 120);
}
function stop(h, aside) { state = "end"; clearTimeout(frame); $("[data-h]").textContent = h; $("[data-time]").innerHTML = aside; }
function finish() { function finish() {
$("[data-card]").classList.add("finished"); stop(addendum ? "补遗 1 解析完成" : "解析完成", `用时 ${dur()}`);
$("[data-done]").innerHTML = addendum $("[data-body]").innerHTML = addendum
? `<div class="alert done"><span class="ico ok">✓</span><p><b>补遗 1 解析完成,已结算 1 次解析。</b>更新了 2 条清单项(投标截止时间、服务器系统盘),新增 0 条。清单已回到草稿,请核对后重新确认。</p></div> ? `<dl class="kv result">
<div class="row" style="margin-top:var(--sp-4)"><a class="btn primary" href="checklist.html">去核对更新</a></div>` <dt>更新</dt><dd><span class="fig">2</span> 条:投标截止时间、应用服务器系统盘</dd>
: `<div class="alert done"><span class="ico ok">✓</span><p><b>解析完成,已结算 1 次解析。</b>提取了 <span class="fig">35</span> 条要求,其中 <span class="fig">2</span> 条没找到可靠出处,放在「待确认」里。</p></div> <dt>新增</dt><dd><span class="fig">0</span> 条</dd>
<dl class="kv" style="margin-top:var(--sp-4)"><dt>项目名称</dt><dd>${D.project.name}</dd><dt>法律体系</dt><dd>政府采购 · 公开招标</dd><dt>采购包</dt><dd>识别到 <span class="fig">2</span> 个采购包,下一步选择要投的包</dd></dl> <dt>清单</dt><dd>回到草稿,核对补遗更新后重新确认</dd></dl>`
<div class="row" style="margin-top:var(--sp-5)"><a class="btn primary" href="lots.html">选择所投的包</a></div>`; : `<dl class="kv result">
<dt>项目名称</dt><dd>${D.project.name}</dd>
<dt>法律体系</dt><dd>政府采购 · 公开招标</dd>
<dt>提取要求</dt><dd><span class="fig">35</span> 条 · 其中 <span class="fig">2</span> 条没找到可靠出处,放在「待确认」里</dd>
<dt>采购包</dt><dd>识别到 <span class="fig">2</span> 个,下一步选择要投的包</dd></dl>`;
$("[data-foot]").innerHTML = addendum
? `<a class="btn primary" href="checklist.html">去核对更新</a><span class="r"><span class="cost">已结算 <span class="fig">1</span> 次解析</span></span>`
: `<a class="btn primary" href="lots.html">选择所投的包</a><span class="r"><span class="cost">已结算 <span class="fig">1</span> 次解析</span></span>`;
$("[data-foot] .btn").focus();
} }
render(); timer = setTimeout(tick, 900); function fail() {
$("[data-cancel]").onclick = () => dialog({ title: "取消解析?", body: "<p>已预扣的 1 次解析会退回。取消后可以重新上传。</p>", ok: "取消解析", cancel: "继续解析", const at = 1; i = at; // 两种失败(OCR 识别不出、文件加密)都停在「提取文字」
onOk: () => { clearTimeout(timer); $("[data-card]").classList.add("finished"); $("[data-done]").innerHTML = `<div class="alert"><span class="ico i">i</span><p>已取消,退回 1 次解析额度。</p></div><div class="row" style="margin-top:var(--sp-4)"><a class="btn primary" href="project-new.html">重新上传</a></div>`; } }); stop(addendum ? "补遗 1 解析失败" : "解析失败", `11月2日 <span class="fig">15:03</span>`);
$("[data-fail]").onclick = (e) => { e.preventDefault(); clearTimeout(timer); $("[data-card]").classList.add("finished"); $("[data-body]").innerHTML = `${steps(at)}
$("[data-done]").innerHTML = `<div class="alert warn"><span class="ico w">!</span><p><b>解析失败:第 112–118 页是图片,OCR 没能识别出文字。</b>已退回 1 次解析额度。可以换一份更清晰的扫描件,或者重试。错误编号 PJ-20261102-0417。</p></div> <div class="fail-why" role="note">${icon("alert-circle")}<p><b>${addendum ? "文件已加密,无法读取文字。" : "第 112–118 页是图片,OCR 没能识别出文字。"}</b>已退回 <span class="fig">1</span> 次解析额度。<span class="sub">${addendum ? "请上传未加密的版本。" : "可以换一份更清晰的扫描件,或者重试。"}</span></p></div>`;
<div class="row" style="margin-top:var(--sp-4)"><button class="btn primary" onclick="location.reload()">重试(重新预扣 1 次)</button><a class="btn" href="project-new.html">换一个文件</a></div>`; }; $("[data-foot]").innerHTML = `<button class="btn primary" data-retry>${icon("refresh")}重试</button><a class="btn" href="project-new.html${addendum ? "?addendum=1" : ""}">${icon("upload")}换一个文件</a>
<span class="cost">重试重新预扣 <span class="fig">1</span> 次 · 剩 <span class="fig">${D.quota.parse.left}</span> 次</span><span class="r"><span class="errno">PJ-20261102-0417</span></span>`;
}
function start() {
i = 0; t0 = stepAt = Date.now(); state = "run";
$("[data-foot]").innerHTML = footRun();
loop();
}
$("[data-foot]").addEventListener("click", (e) => {
if (e.target.closest("[data-cancel]")) dialog({
title: "取消解析?", ok: "取消解析", cancel: "继续解析",
body: "<p>已完成的阶段不会保留。取消后退回 1 次解析额度,可以稍后重新上传。</p>",
onOk: () => {
stop("已取消解析", `11月2日 <span class="fig">15:02</span>`);
$("[data-body]").innerHTML = `<p style="margin:0">已退回 <span class="fig">1</span> 次解析额度。</p>`;
$("[data-foot]").innerHTML = `<a class="btn primary" href="project-new.html${addendum ? "?addendum=1" : ""}">${icon("upload")}重新上传</a>`;
toast("已取消解析 · 已退回 1 次解析额度");
setTimeout(() => $("[data-foot] .btn").focus());
}
});
else if (e.target.closest("[data-retry]")) { start(); toast("已重新开始解析 · 预扣 1 次解析"); }
});
$("[data-fail]").addEventListener("click", (e) => { e.preventDefault(); fail(); $("[data-retry]").focus(); });
start();
</script> </script>
</body> </body>
</html> </html>

View File

@@ -1,76 +1,141 @@
<!doctype html> <!doctype html>
<!--
新建项目 / 追加补遗(v0.2):框架来自 App.shell();表单按 design/ui-demo/forms.html 的「新建项目」(分区卡片、上传区、文件行、扣点说明)。
?addendum=1:追加补遗 / 澄清(不填项目名称,文件类型默认「补遗」)。
本页 <style> 只放本页特有的:窄版宽度、文件行里的类型下拉和提醒行。
-->
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>新建项目 · 标博士原型</title> <title>新建项目 · 标博士原型</title>
<link rel="icon" href="../design/logo/favicon.svg">
<link rel="stylesheet" href="../design/system/tokens.css"> <link rel="stylesheet" href="../design/system/tokens.css">
<link rel="stylesheet" href="../design/system/fonts-local.css"> <link rel="stylesheet" href="../design/system/fonts-local.css">
<link rel="stylesheet" href="../design/system/components.css"> <link rel="stylesheet" href="../design/system/components.css">
<link rel="stylesheet" href="assets/app.css"> <link rel="stylesheet" href="assets/app.css">
<style> <style>
.narrow{max-width:760px} .narrow{max-width:800px}
.file-row.bad{border-color:var(--warn);background:var(--warn-soft)} .card-b.form-b{padding-top:var(--sp-5);padding-bottom:var(--sp-6)}
.file-row .st{font-size:var(--fs-caption);line-height:var(--lh-caption)} .form-b > .field{max-width:none}
.file-row .st.warn{color:var(--warn-text)} .form-b > .field + .field{margin-top:var(--sp-5)}
.foot-bar{display:flex;align-items:center;gap:var(--sp-3);margin-top:var(--sp-6);padding-top:var(--sp-4);border-top:1px solid var(--border-subtle)} .form-b > [hidden] + .field{margin-top:0}
.foot-bar .cost{margin-right:auto} .card-f .cost{margin-right:auto}
.card-f .cost .hint-u{margin-left:var(--sp-1)}
.field.err .drop{border-color:var(--warn)}
/* 文件行:类型下拉 + 移除;提醒行只把图标和文字变赭色(扩展名不符只提醒、不拦截) */
[data-files] .file-row{margin-top:var(--sp-2)}
[data-files] .file-row:first-child{margin-top:var(--sp-3)}
.file-row .meta{display:block;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary)}
.file-row.off b{color:var(--text-secondary);font-weight:var(--fw-regular)}
.file-row .pop .select{width:112px}
</style> </style>
</head> </head>
<body> <body>
<main> <main>
<div class="content narrow"> <div class="content narrow">
<div class="page-title"><div><h1 data-title>新建项目</h1><p data-sub>上传招标文件,标博士会逐条提取要求,每一条都带原文出处。</p></div></div> <div class="page-title"><div><h1 data-title>新建项目</h1><p data-sub>上传招标文件,标博士会逐条提取要求,每一条都带原文出处。</p></div></div>
<div class="card stack">
<div class="field" data-name-field><label for="pn">项目名称 <small>(选填,解析后自动填写,可以修改)</small></label><input class="input" id="pn" placeholder="例如:某市政务服务中心智慧大厅建设项目"></div> <form class="card flush" novalidate data-form aria-labelledby="h-form">
<div> <h2 class="vh" id="h-form" data-form-h>新建项目</h2>
<div class="drop" tabindex="0" role="button" data-drop> <div class="card-b form-b">
<b>把招标文件拖到这里,或点击选择</b> <div class="field" data-name-field><label for="pn">项目名称 <small>(选填)</small></label><input class="input" id="pn" placeholder="例如:某市政务服务中心智慧大厅建设项目" autocomplete="off" aria-describedby="pn-msg"><span class="msg" id="pn-msg">不填时,解析后按招标文件自动填写,可以再改</span></div>
<span class="muted">支持 PDF(含扫描件)、Word(DOC / DOCX)、WPS。正文和附件分开的,可以一起上传。</span><br>
<span class="cap muted">专业版单个文件不超过 <span class="fig" style="font-weight:500">500</span> 页、<span class="fig" style="font-weight:500">100</span> MB(示意,以套餐页为准)</span> <div class="field" id="f-file"><label id="l-file" data-file-label>招标文件</label>
<div class="drop" tabindex="0" role="button" aria-labelledby="l-file" aria-describedby="drop-sub" data-drop>
<i data-ic="upload" class="lg"></i>
<b>拖入或点击上传</b>
<span id="drop-sub" data-drop-sub>PDF(含扫描件)、Word(DOC / DOCX)、WPS;正文和附件分开的,可以一起上传<br>专业版单个文件不超过 <span class="fig xs">500</span> 页、<span class="fig xs">100</span> MB(示意,以套餐页为准)</span>
</div>
<div data-files></div>
</div> </div>
<div data-files style="margin-top:var(--sp-3)"></div>
</div> </div>
</div> <div class="card-f">
<div class="foot-bar"> <span class="cost" data-cost></span>
<span class="cost" data-cost>选好文件后显示扣点</span> <a class="btn" href="workbench.html" data-cancel>取消</a>
<a class="btn" href="workbench.html">取消</a> <button type="submit" class="btn primary swap" data-start><span class="idle">开始解析</span><span class="busy">正在上传…</span></button>
<button class="btn primary" data-start disabled>开始解析</button> </div>
</div> </form>
</div> </div>
</main> </main>
<script src="assets/data.js"></script> <script src="assets/data.js"></script>
<script src="assets/app.js"></script> <script src="assets/app.js"></script>
<script> <script>
const { D, $, toast } = App; const { D, $, $$, esc, icon } = App;
const addendum = new URLSearchParams(location.search).has("addendum"); const addendum = new URLSearchParams(location.search).has("addendum");
App.shell({ nav: addendum ? "" : "workbench", crumbs: addendum ? [["工作台", "workbench.html"], [D.project.name, "project.html"], ["追加补遗", ""]] : [["工作台", "workbench.html"], ["新建项目", ""]] }); App.shell({
nav: "workbench",
crumbs: addendum ? [["工作台", "workbench.html"], [D.project.name, "project.html"], ["追加补遗", ""]] : [["工作台", "workbench.html"], ["新建项目", ""]]
});
if (addendum) { if (addendum) {
document.title = "追加补遗 · 标博士原型";
$("[data-title]").textContent = "追加补遗 / 澄清"; $("[data-title]").textContent = "追加补遗 / 澄清";
$("[data-sub]").textContent = "补遗单独解析,新提取的条目标注来源「补遗 N」并入清单;日期以最新文件为准,由你核对。MVP 不自动判断哪些旧条目被作废。"; $("[data-form-h]").textContent = "追加补遗 / 澄清";
$("[data-sub]").textContent = "补遗单独解析,新条目标注「补遗 N」并入清单。标博士不判断哪些旧条目已作废,并入后由你核对。";
$("[data-name-field]").hidden = true; $("[data-name-field]").hidden = true;
$("[data-file-label]").textContent = "补遗 / 澄清文件";
$("[data-cancel]").href = "project.html";
} }
const files = addendum
? [{ ext: "PDF", name: "关于 ZC-2026-0418 的更正公告.pdf", info: "2 页 · 0.3 MB", type: "补遗", ok: true }] /* 示意文件:点上传区加入。新建项目里一个扩展名不符(只提醒),一个已加密(不创建任务、不扣额度) */
const DEMO = addendum
? [{ ext: "PDF", name: "关于 ZC-2026-0418 的更正公告.pdf", meta: `<span class="fig xs">2</span> 页 · <span class="fig xs">0.3</span> MB`, type: "补遗", ok: true }]
: [ : [
{ ext: "DOCX", name: "ZC-2026-0418 采购文件.doc", info: "186 页 · 4.2 MB", type: "招标文件", ok: true, note: "扩展名是 .doc,实际是 Word 2007 格式(DOCX),已按实际格式处理。" }, { ext: "DOCX", name: "ZC-2026-0418 采购文件.doc", meta: `<span class="fig xs">186</span> 页 · <span class="fig xs">4.2</span> MB`, type: "招标文件", ok: true, warn: "扩展名是 .doc,内容是 Word 2007 格式(DOCX),会按实际格式解析" },
{ ext: "PDF", name: "附件:采购需求-技术参数.pdf", info: "页数无法读取", type: "招标文件", ok: false, note: "文件已加密,无法读取。请上传未加密的文件。这个文件不会创建任务,也不扣额度。" } { ext: "PDF", name: "附件:采购需求-技术参数.pdf", type: "招标文件", ok: false, warn: "文件已加密,无法读取。请换成未加密的文件;这个文件不会解析,也不扣额度" }
]; ];
function render() { let files = [];
$("[data-files]").innerHTML = files.map((f, i) => `<div class="file-row ${f.ok ? "" : "bad"}"> const TYPES = ["招标文件", "补遗", "澄清答复"];
<span class="ext">${f.ext}</span> const fileField = $("#f-file");
<div><b>${f.name}</b><div class="st ${f.ok ? "muted" : "warn"}">${f.ok ? "" : '<span class="ico w" style="width:14px;height:14px;font-size:10px;display:inline-grid;margin:0 4px -2px 0">!</span>'}${f.info}${f.note ? " · " + f.note : ""}</div></div>
<select aria-label="文件类型" ${f.ok ? "" : "disabled"}><option ${f.type === "招标文件" ? "selected" : ""}>招标文件</option><option ${f.type === "补遗" ? "selected" : ""}>补遗</option><option>澄清答复</option></select> function typeSelect(f, i) {
<button class="btn sm text" data-rm="${i}">移除</button></div>`).join(""); return `<div class="pop inline" data-select data-type="${i}"><button type="button" class="select sm" aria-haspopup="listbox" aria-expanded="false" aria-label="${esc(f.name)} 的文件类型" ${f.ok ? "" : "disabled"}><span class="val">${f.type}</span>${icon("chev-down")}</button>
const ok = files.filter((f) => f.ok).length; <ul class="menu right" role="listbox">${TYPES.map((t) => `<li role="option" aria-selected="${t === f.type}">${t}</li>`).join("")}</ul></div>`;
$("[data-cost]").innerHTML = ok ? `扣 <span class="fig">${ok}</span> 次解析 · 剩 <span class="fig">${D.quota.parse.left}</span> 次 · 解析失败、超时、取消都不扣` : "选好文件后显示扣点";
$("[data-start]").disabled = !ok;
} }
let shown = false; function render() {
$("[data-drop]").onclick = () => { if (!shown) { shown = true; render(); } }; $("[data-files]").innerHTML = files.map((f, i) => `<div class="file-row ${f.ok ? "" : "off"}">
$("[data-drop]").onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); $("[data-drop]").click(); } }; <span class="ext">${f.ext}</span>
document.addEventListener("click", (e) => { if (e.target.matches("[data-rm]")) { files.splice(+e.target.dataset.rm, 1); render(); } }); <div><b title="${esc(f.name)}">${esc(f.name)}</b>${f.meta ? `<span class="meta">${f.meta}</span>` : ""}${f.warn ? `<span class="hint-warn"><span class="ico w" aria-hidden="true">!</span><span>${f.warn}</span></span>` : ""}</div>
$("[data-start]").onclick = () => location.href = "parsing.html" + (addendum ? "?addendum=1" : ""); <span class="acts">${typeSelect(f, i)}<button type="button" class="icon-btn sm tip-left" aria-label="移除 ${esc(f.name)}" data-tip="移除" data-rm="${i}">${icon("x")}</button></span></div>`).join("");
App.init($("[data-files]"));
const n = files.filter((f) => f.ok).length;
$("[data-cost]").innerHTML = `${n ? `扣 <span class="fig">${n}</span> 次解析` : "每个文件扣 1 次解析"} · 剩 <span class="fig">${D.quota.parse.left}</span> 次<span class="hint-u tip-start" tabindex="0" data-tip="解析失败、超时、取消或系统异常都不扣额度,已预扣的自动退回">失败不扣</span>`;
}
function add() {
if (!files.length) files = DEMO.map((f) => ({ ...f }));
render();
if (fileField.classList.contains("err")) check();
}
function check() {
const msg = !files.length ? (addendum ? "请先上传补遗或澄清文件" : "请先上传招标文件")
: !files.some((f) => f.ok) ? "没有可以解析的文件:加密的文件请换成未加密的版本" : "";
return App.fieldError(fileField, msg);
}
const drop = $("[data-drop]");
drop.addEventListener("click", add);
drop.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); add(); } });
["dragenter", "dragover"].forEach((t) => drop.addEventListener(t, (e) => { e.preventDefault(); drop.classList.add("is-over"); }));
["dragleave", "drop"].forEach((t) => drop.addEventListener(t, (e) => { e.preventDefault(); drop.classList.remove("is-over"); if (t === "drop") add(); }));
$("[data-files]").addEventListener("click", (e) => {
const b = e.target.closest("[data-rm]"); if (!b) return;
files.splice(+b.dataset.rm, 1); render();
if (fileField.classList.contains("err")) check();
(files.length ? $("[data-rm]") : drop).focus();
});
$("[data-files]").addEventListener("change", (e) => { const p = e.target.closest("[data-type]"); if (p) files[+p.dataset.type].type = e.detail; });
$("[data-form]").addEventListener("submit", (e) => {
e.preventDefault();
if (!check()) { drop.focus(); return; }
const b = $("[data-start]");
b.classList.add("is-loading"); b.setAttribute("aria-busy", "true");
$$("input, button:not([data-start]), .select", e.target).forEach((x) => { x.disabled = true; });
setTimeout(() => { location.href = "parsing.html" + (addendum ? "?addendum=1" : ""); }, 700);
});
render();
</script> </script>
</body> </body>
</html> </html>

View File

@@ -1,138 +1,257 @@
<!doctype html> <!doctype html>
<!--
项目概览(v0.2):项目头 + 标签页来自 App.shell({ tab: "overview" }),状态联动用 App.projHead()。
卡片规格按 design/ui-demo/cards.html:AI 摘要卡、关键日期卡、分区卡片 .card.flush。
本页 <style> 只放概览特有的布局:两栏、文件列表、状态卡。
-->
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>项目概览 · 标博士原型</title> <title>项目概览 · 标博士原型</title>
<link rel="icon" href="../design/logo/favicon.svg">
<link rel="stylesheet" href="../design/system/tokens.css"> <link rel="stylesheet" href="../design/system/tokens.css">
<link rel="stylesheet" href="../design/system/fonts-local.css"> <link rel="stylesheet" href="../design/system/fonts-local.css">
<link rel="stylesheet" href="../design/system/components.css"> <link rel="stylesheet" href="../design/system/components.css">
<link rel="stylesheet" href="assets/app.css"> <link rel="stylesheet" href="assets/app.css">
<style> <style>
.ov{display:grid;grid-template-columns:minmax(0,1.5fr) minmax(0,1fr);gap:var(--sp-6);align-items:start} /* 页首提醒 + 两栏(主列 3 : 右栏 2),全页一个间距 --gap-card */
.card h3{font:var(--fw-bold) var(--fs-title)/var(--lh-title) var(--font-sans);margin:0 0 var(--sp-3);display:flex;gap:var(--sp-2);align-items:center} [data-next] > .alert{margin-bottom:var(--gap-card)}
.card h3 .right{margin-left:auto;font-weight:var(--fw-regular)} .ov{display:grid;grid-template-columns:minmax(0,1fr) 420px;gap:var(--gap-card);align-items:start}
.next{border-left:3px solid var(--c-ink)} @media (max-width:1399px){.ov{grid-template-columns:minmax(0,1fr) 384px}}
.next p{margin:0 0 var(--sp-3)} .card-h .aside{margin-left:auto;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary);white-space:nowrap}
.summary .kv{grid-template-columns:120px 1fr} .card-h .r{margin-left:auto;display:flex;gap:var(--sp-2);align-items:center}
.summary dd .cite-src{display:inline;margin-left:6px} .card-f .r{margin-left:auto;display:flex;gap:var(--sp-2);align-items:center}
.dates{list-style:none;margin:0;padding:0}
.dates li{display:grid;grid-template-columns:110px minmax(0,1fr) auto;gap:var(--sp-3);padding:var(--sp-2) 0;border-top:1px solid var(--border-subtle);align-items:baseline} /* AI 摘要卡 */
.dates li:first-child{border-top:0} .ai-card .kv{grid-template-columns:88px minmax(0,1fr)}
.dates .fig{font-weight:500} .ai-card .kv dd > .fig{font-weight:var(--fw-medium)}
.dates .past{color:var(--text-secondary)}
.docs .file-row{grid-template-columns:32px minmax(0,1fr) auto} /* 检查记录 */
.runs td{height:auto} .runs td{height:auto;padding-top:var(--sp-3);padding-bottom:var(--sp-3)}
.runs .tags{display:flex;gap:var(--sp-1);flex-wrap:nowrap}
.runs .files-cell{color:var(--text-secondary)}
/* 关键日期:日期按「11月20日 09:30」写,列放宽 */
.dates li{grid-template-columns:108px minmax(0,1fr) auto}
/* 状态卡:标题行右侧放项目操作(递交 + 更多),下面是状态流 */
.state .card-b{padding-top:0}
.state .flowline{max-width:640px}
.state .who{margin:var(--sp-2) 0 0;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary)}
.state .who .fig{font-size:var(--fs-figure-xs)}
/* 文件:卡片里不再套带框的文件行,改用发丝线分隔 */
.docs .grp{margin:var(--sp-3) 0 0;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary)}
.docs .grp:first-child{margin-top:0}
.docs ul{list-style:none;margin:0;padding:0}
.docs li{display:grid;grid-template-columns:40px minmax(0,1fr) auto;gap:var(--sp-3);align-items:center;padding:10px 0;border-top:var(--line) solid var(--border-subtle)}
.docs .grp + ul > li:first-child{border-top:0}
.docs .ext{font:var(--fw-bold) 11px/1 var(--font-figures);background:var(--c-n100);border-radius:var(--radius-xs);padding:5px 0;text-align:center}
.docs b{display:block;font-weight:var(--fw-bold);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.docs .meta{display:block;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* 对话框里的提醒:颜色只在图标和关键字上 */
.dlg-warn{display:flex;gap:var(--sp-2);align-items:flex-start;margin:0}
.dlg-warn > .ic{margin-top:3px;color:var(--warn)}
.dlg-warn b{color:var(--warn-text);font-weight:var(--fw-bold)}
</style> </style>
</head> </head>
<body> <body>
<main> <main>
<div class="content"> <div class="content">
<div class="flowline" data-flow></div> <div data-next></div>
<div class="ov" style="margin-top:var(--sp-5)">
<div class="ov">
<div class="stack"> <div class="stack">
<div class="card next"> <section class="card flush state" aria-labelledby="h-state">
<h3>下一步</h3> <div class="card-h"><h3 id="h-state">项目状态</h3><span class="r" data-state-acts></span></div>
<p>第 2 次检查还有 <span class="sev fatal">致命<span class="fig">3</span></span> 项待处理。处理完不是递交的前提,决定权在你;报告会如实列出每一项的处理情况。</p> <div class="card-b" data-state></div>
<div class="row"><a class="btn primary" href="results.html">去处理检查结果</a><a class="btn" href="report.html">导出报告</a></div> </section>
</div> <section class="card flush ai-card" aria-labelledby="h-sum" data-ai-card>
<div class="card summary"> <div class="card-h"><h3 id="h-sum">项目要点</h3><span class="tag ai" data-ai-tag>AI 摘要</span></div>
<h3><span class="tag ai">AI 摘要</span>项目要点<span class="right cap muted">每一条都可以点出处核对</span></h3> <div class="card-b">
<dl class="kv"> <div class="ai-block" data-ai-block>
<dt>法律体系</dt><dd>政府采购 · 公开招标(按「投标无效」口径检查)</dd> <dl class="kv">
<dt>预算 / 限价</dt><dd>采购包 1:<span class="fig">286.00</span> 万元(最高限价同预算)<a class="cite-src" href="#" data-open="T|2|t2-cap|采购包1:286.00万元">第 2 页</a></dd> <dt>法律体系</dt><dd>政府采购 · 公开招标(按「投标无效」口径检查)</dd>
<dt>评标方法</dt><dd>综合评分法:价格 10、技术 40、实施方案 20、售后 10、认证 6、业绩 8 <a class="cite-src" href="#" data-open="T|31|t31-price">第 31 页</a></dd> <dt>预算 / 限价</dt><dd>采购包 1:<span class="fig">286.00</span> 万元(最高限价同预算)<a class="cite-src inline" href="#" data-open="T|2|t2-cap|采购包1:286.00万元">第 <span class="fig">2</span> 页</a></dd>
<dt>联合体</dt><dd>不接受 <span class="flag">已修改</span><a class="cite-src" href="#" data-open="T|2|t2-joint">第 2 页</a></dd> <dt>评标方法</dt><dd>综合评分法:价格 10、技术 40、实施方案 20、售后 10、认证 6、业绩 8<a class="cite-src inline" href="#" data-open="T|31|t31-price">第 <span class="fig">31</span> 页</a></dd>
<dt>中小企业</dt><dd>不专门面向中小企业;小微企业有价格扣除 <a class="cite-src" href="#" data-open="T|2|t2-sme">第 2 页</a></dd> <dt>联合体</dt><dd>不接受 <span class="flag box">已修改</span><a class="cite-src inline" href="#" data-open="T|2|t2-joint">第 <span class="fig">2</span> 页</a></dd>
<dt>保证金</dt><dd>不收取 <a class="cite-src" href="#" data-open="T|21|t21-deposit">第 21 页</a></dd> <dt>中小企业</dt><dd>不专门面向中小企业;小微企业有价格扣除<a class="cite-src inline" href="#" data-open="T|2|t2-sme">第 <span class="fig">2</span> 页</a></dd>
<dt>递交</dt><dd>电子交易平台在线递交,CA 加密,不接受纸质 <a class="cite-src" href="#" data-open="T|6|t6-place">第 6 页</a></dd> <dt>保证金</dt><dd>不收取<a class="cite-src inline" href="#" data-open="T|21|t21-deposit">第 <span class="fig">21</span> 页</a></dd>
<dt>★ 条款</dt><dd>采购包 1 和通用部分共 <span class="fig">4</span> 条 · <a class="plain" href="checklist.html">在清单里查看</a></dd> <dt>递交</dt><dd>电子交易平台在线递交,CA 加密,不接受纸质<a class="cite-src inline" href="#" data-open="T|6|t6-place">第 <span class="fig">6</span> 页</a></dd>
</dl> <dt>★ 条款</dt><dd>采购包 1 和通用部分共 <span class="fig">4</span> 条 · <a class="lnk" href="checklist.html">在清单里查看</a></dd>
</div> </dl>
<div class="card"> <div data-ai-sign></div>
<h3>检查记录<span class="right"><a class="btn sm" href="bid-check.html">上传新版本并检查</a></span></h3> </div>
</div>
<div class="card-f" data-ai-foot></div>
</section>
<section class="card flush" aria-labelledby="h-runs">
<div class="card-h"><h3 id="h-runs">检查记录</h3><span class="r"><a class="btn sm" href="bid-check.html" data-editable><i data-ic="upload"></i>上传新版本并检查</a></span></div>
<table class="data runs"> <table class="data runs">
<thead><tr><th>批次</th><th>时间</th><th>投标文件</th><th>结果</th><th>报告</th></tr></thead> <thead><tr><th>批次</th><th>时间</th><th>投标文件</th><th>结果</th><th class="r">报告</th></tr></thead>
<tbody> <tbody>
<tr><td>第 <span class="fig">2</span> 次</td><td class="num">11-02 14:20</td><td>商务册 v3、技术册 v3、报价册 v2</td><td><span class="sev fatal">致命<span class="fig">3</span></span> <span class="sev major">重要<span class="fig">3</span></span> <span class="sev hint">提示<span class="fig">3</span></span></td><td><a class="plain" href="results.html">查看</a></td></tr> <tr><td class="nw">第 <span class="fig">2</span> 次</td><td class="nw">11月2日 <span class="fig">14:20</span></td><td class="files-cell"><span class="nw">商务册 v3</span>、<span class="nw">技术册 v3</span>、<span class="nw">报价册 v2</span></td>
<tr><td>第 <span class="fig">1</span> 次</td><td class="num">11-01 16:00</td><td>商务册 v2、技术册 v2、报价册 v1</td><td><span class="sev fatal">致命<span class="fig">2</span></span> <span class="sev major">重要<span class="fig">4</span></span> <span class="sev hint">提示<span class="fig">2</span></span></td><td><a class="plain" href="report.html">11-01 报告.pdf</a></td></tr> <td><span class="tags"><span class="sev fatal">致命<span class="fig">3</span></span><span class="sev major">重要<span class="fig">3</span></span><span class="sev hint">提示<span class="fig">3</span></span></span></td>
<td class="r nw"><a class="lnk" href="results.html">查看结果</a></td></tr>
<tr><td class="nw">第 <span class="fig">1</span> 次</td><td class="nw">11月1日 <span class="fig">16:00</span></td><td class="files-cell"><span class="nw">商务册 v2</span>、<span class="nw">技术册 v2</span>、<span class="nw">报价册 v1</span></td>
<td><span class="tags"><span class="sev fatal">致命<span class="fig">2</span></span><span class="sev major">重要<span class="fig">4</span></span><span class="sev hint">提示<span class="fig">2</span></span></span></td>
<td class="r nw"><a class="lnk" href="report.html">11-01 报告.pdf</a></td></tr>
</tbody> </tbody>
</table> </table>
</div> </section>
</div> </div>
<div class="stack"> <div class="stack">
<div class="card">
<h3>关键日期</h3> <section class="card flush" aria-labelledby="h-dates">
<ul class="dates"> <div class="card-h"><h3 id="h-dates">关键日期</h3><span class="aside">已按补遗 1 更新</span></div>
<li class="past"><span class="fig">10-28</span><span>采购文件公告发布</span><span></span></li> <div class="card-b">
<li><span class="fig">11-09</span><span>询问、质疑截止(公告期满后 7 个工作日)</span><a class="cite-src" href="#" data-open="T|6|t6-ask">第 6 页</a></li> <ul class="dates">
<li><span class="fig">11-20 09:30</span><span><b>投标截止 / 开标</b> · 剩 <span class="fig">17</span> 天<br><span class="flag">已被补遗 1 更新,原为 11-18 09:30</span></span><a class="cite-src" href="#" data-open="A1|1|a1-deadline|2026年11月20日09时30分">补遗 1</a></li> <li class="past"><span class="fig">10月28日</span><span>采购文件公告发布</span><span></span></li>
<li><span class="fig">2027-02-18</span><span>投标有效期至(截止日起 90 天)</span><a class="cite-src" href="#" data-open="T|21|t21-valid">第 21 页</a></li> <li><span class="fig">11月9日</span><span>询问、质疑截止<span class="left">剩 <span class="fig">7</span> 天 · <span class="hint-u tip-below tip-left" tabindex="0" data-tip="原文是「公告期限届满之日起七个工作日内」,日期按此推算,请核对">推算</span></span></span><a class="cite-src" href="#" data-open="T|6|t6-ask">第 <span class="fig">6</span> 页</a></li>
</ul> <li class="key"><span class="fig">11月20日 09:30</span><span><b>投标截止 / 开标</b><span class="left">剩 <span class="fig">17</span> 天 · <span class="nw">原为 11月18日 09:30</span></span></span><a class="cite-src" href="#" data-open="A1|1|a1-deadline|2026年11月20日09时30分">补遗 1 · 第 <span class="fig">1</span> 页</a></li>
<p class="note">MVP 只在站内显示倒计时,不发截止提醒。</p> <li><span class="fig">2027年2月18日</span><span>投标有效期至<span class="left">截止日起 <span class="fig">90</span> 天</span></span><a class="cite-src" href="#" data-open="T|21|t21-valid">第 <span class="fig">21</span> 页</a></li>
</div> </ul>
<div class="card docs"> </div>
<h3>文件<span class="right"><button class="btn sm" data-addendum>追加补遗 / 澄清</button></span></h3> </section>
<p class="cap muted" style="margin:0 0 var(--sp-2)">招标方</p>
<div class="file-row"><span class="ext">PDF</span><div><b>招标文件</b><div class="cap muted">ZC-2026-0418 采购文件.pdf · 186 页 · 解析成功</div></div><a class="plain cap" href="checklist.html">读标</a></div> <section class="card flush docs" aria-labelledby="h-docs">
<div class="file-row"><span class="ext">PDF</span><div><b>补遗 1</b><div class="cap muted">更正公告 · 2 页 · 新增 0 条、更新 2 条</div></div><a class="plain cap" href="checklist.html">读标</a></div> <div class="card-h"><h3 id="h-docs">文件</h3><span class="r"><a class="btn sm" href="project-new.html?addendum=1" data-editable><i data-ic="plus"></i>追加补遗 / 澄清</a></span></div>
<p class="cap muted" style="margin:var(--sp-4) 0 var(--sp-2)">投标文件(加密前的 Word / PDF 稿)</p> <div class="card-b">
<div class="file-row"><span class="ext">DOCX</span><div><b>商务册 v3</b><div class="cap muted">64 页 · 11-02 14:12 上传</div></div><span></span></div> <p class="grp">招标方</p>
<div class="file-row"><span class="ext">DOCX</span><div><b>技术册 v3</b><div class="cap muted">212 页 · 11-02 14:12 上传</div></div><span></span></div> <ul>
<div class="file-row"><span class="ext">DOCX</span><div><b>报价册 v2</b><div class="cap muted">8 页 · 11-01 15:40 上传</div></div><span></span></div> <li><span class="ext">PDF</span><span><b>招标文件</b><span class="meta">ZC-2026-0418 采购文件.pdf · <span class="fig xs">186</span> 页 · 解析成功</span></span><a class="lnk cap" href="checklist.html">读标</a></li>
</div> <li><span class="ext">PDF</span><span><b>补遗 1</b><span class="meta">更正公告 · <span class="fig xs">2</span> 页 · 新增 <span class="fig xs">0</span> 条、更新 <span class="fig xs">2</span> 条</span></span><a class="lnk cap" href="checklist.html">读标</a></li>
<div class="card"> </ul>
<h3>项目操作</h3> <p class="grp">投标文件(加密前的 Word / PDF 稿)</p>
<div class="row"><button class="btn" data-submit>标记为已递交</button><button class="btn" data-archive>归档</button><button class="btn text" data-delete>删除项目</button></div> <ul>
<p class="note">标记已递交后项目变为只读,仍可导出报告;可以撤销,撤销会留下记录。</p> <li><span class="ext">DOCX</span><span><b>商务册 v3</b><span class="meta"><span class="fig xs">64</span> 页 · 11月2日 <span class="fig xs">14:12</span> 上传</span></span><span></span></li>
</div> <li><span class="ext">DOCX</span><span><b>技术册 v3</b><span class="meta"><span class="fig xs">212</span> 页 · 11月2日 <span class="fig xs">14:12</span> 上传</span></span><span></span></li>
<li><span class="ext">DOCX</span><span><b>报价册 v2</b><span class="meta"><span class="fig xs">8</span> 页 · 11月1日 <span class="fig xs">15:40</span> 上传</span></span><span></span></li>
</ul>
</div>
</section>
</div> </div>
</div> </div>
</div> </div>
</main> </main>
<aside class="drawer" aria-label="原文" data-drawer><div data-viewer></div></aside>
<script src="assets/data.js"></script> <script src="assets/data.js"></script>
<script src="assets/app.js"></script> <script src="assets/app.js"></script>
<script> <script>
const { D, $, dialog, toast, esc } = App; const { D, $, $$, esc, icon, dialog, toast } = App;
App.shell({ crumbs: [["工作台", "workbench.html"], [D.project.name, ""]], tab: "overview" }); App.shell({ crumbs: [["工作台", "workbench.html"], [D.project.name, ""], ["概览", ""]], tab: "overview" });
const drawer = $("[data-drawer]"); const drawer = App.drawer();
const V = App.viewer($("[data-viewer]"), { closable: true, onClose: () => drawer.classList.remove("open"), onOpen: () => drawer.classList.add("open") }); const FATAL = D.check.findings.filter((f) => f.sev === "fatal" && !f.status).length;
document.addEventListener("click", (e) => { const NOW_TXT = `11月2日 <span class="fig">15:00</span>`;
const a = e.target.closest("[data-open]");
if (a) { e.preventDefault(); V.open(a.dataset.open.split("|").map((v, i) => i === 1 ? +v : v)); }
});
document.addEventListener("keydown", (e) => { if (e.key === "Escape") drawer.classList.remove("open"); });
let status = "已检查"; /* ---------- 出处:从右侧滑出原文,不跳页面 ---------- */
function flow() { document.addEventListener("click", (e) => {
const S = ["跟进中", "编制中", "已检查", "已递交", "已归档"]; const a = e.target.closest("[data-open]"); if (!a) return;
const i = S.indexOf(status); e.preventDefault();
$("[data-flow]").innerHTML = S.map((s, k) => `<span class="st ${k < i ? "done" : k === i ? "now" : ""}"><i></i>${s}</span>`).join('<span class="ln"></span>'); drawer.open(App.parseCite(a.dataset.open));
document.querySelector(".proj-head .tag.status").textContent = status;
}
flow();
$("[data-submit]").onclick = () => dialog({
title: "标记为已递交?",
body: `<p>标记后项目变为只读:不能再改清单、上传文件或处理问题,仍可以查看和导出报告。</p>
<div class="alert warn"><span class="ico w">!</span><p><b>还有 3 项致命问题待处理。</b>标记已递交不受影响,只是提醒你。</p></div>`,
ok: "标记为已递交",
onOk: () => { status = "已递交"; flow(); toast('已标记为已递交 · <a href="#" data-undo-submit>撤销</a>'); }
}); });
document.addEventListener("click", (e) => { if (e.target.matches("[data-undo-submit]")) { e.preventDefault(); status = "已检查"; flow(); toast("已撤销递交标记,记录已保存"); } });
$("[data-archive]").onclick = () => { status = "已归档"; flow(); toast("项目已归档,可以随时撤销归档"); }; /* ---------- 状态流:页首提醒、状态卡、项目头(App.projHead)一起更新 ---------- */
$("[data-addendum]").onclick = () => location.href = "project-new.html?addendum=1"; const FLOW = ["跟进中", "编制中", "已检查", "已递交", "已归档"];
$("[data-delete]").onclick = () => dialog({ let status = D.project.status, before = status;
title: `删除项目「${esc(D.project.name)}」?`, const READONLY = "已递交的项目只读。撤销递交标记后可以继续修改";
body: `<p>项目下的招标文件、投标文件、清单、检查结果和报告都会删除。删除后 30 天内可以在回收站恢复,之后彻底清除。</p>
<p class="muted cap">额度流水和一条不含业务内容的审计记录会保留。</p> function renderNext() {
<div class="field" data-field><label for="dn">输入项目名称确认</label><input class="input" id="dn" placeholder="${esc(D.project.name)}"><span class="msg"></span></div>`, let html = "";
ok: "删除项目", if (status === "已检查" && FATAL) html = `<div class="alert" role="note">${icon("alert-circle", "lg")}
onOk: (s) => { <div><p>第 <span class="fig">2</span> 次检查还有 <span class="sev fatal">致命<span class="fig">${FATAL}</span></span> 待处理。</p><span class="sub">不影响导出和标记递交,决定权在你。</span></div>
if ($("#dn", s).value.trim() !== D.project.name) { $("[data-field]", s).classList.add("err"); $(".msg", s).innerHTML = '<span class="ico w">!</span>项目名称不一致'; return false; } <span class="act"><a class="btn" href="report.html">导出报告</a><a class="btn primary" href="results.html">去处理</a></span></div>`;
location.href = "workbench.html"; else if (status === "已递交") html = `<div class="alert" role="note">${icon("lock", "lg")}
<div><p>项目已标记为已递交,现在只读。</p><span class="sub">仍可以查看和导出报告。</span></div>
<span class="act"><a class="btn primary" href="report.html">导出报告</a></span></div>`;
else if (status === "已归档") html = `<div class="alert" role="note">${icon("archive", "lg")}
<div><p>项目已归档。</p><span class="sub">归档的项目不出现在工作台的「进行中」里,可以随时撤销归档。</span></div></div>`;
$("[data-next]").innerHTML = html;
}
function renderState() {
const i = FLOW.indexOf(status);
const flow = FLOW.map((s, k) => `<span class="st ${k < i ? "done" : k === i ? "now" : ""}"><i></i>${s}</span>`).join('<span class="ln"></span>');
const who = status === "已递交" ? `<p class="who">王敏 · ${NOW_TXT} 标记为已递交</p>`
: status === "已归档" ? `<p class="who">王敏 · ${NOW_TXT} 归档</p>` : "";
$("[data-state]").innerHTML = `<div class="flowline sm" aria-label="项目状态:${status}">${flow}</div>${who}`;
const main = status === "已递交" ? `<button class="btn sm text" data-act="unsubmit">撤销递交标记</button>`
: status === "已归档" ? `<button class="btn sm" data-act="unarchive">撤销归档</button>`
: `<button class="btn sm" data-act="submit">标记为已递交</button>`;
$("[data-state-acts]").innerHTML = `${main}<button class="icon-btn sm tip-below tip-left" aria-label="更多项目操作" data-tip="更多" aria-haspopup="menu" aria-expanded="false" data-more>${icon("more")}</button>`;
// 已递交、已归档后只读:上传、追加补遗变禁用灰,悬停说明原因
$$("[data-editable]").forEach((b) => {
const ro = status === "已递交" || status === "已归档";
if (ro) { b.setAttribute("aria-disabled", "true"); b.dataset.tip = status === "已递交" ? READONLY : "已归档的项目只读。撤销归档后可以继续修改"; b.classList.add("tip-below", "tip-left"); }
else { b.removeAttribute("aria-disabled"); b.removeAttribute("data-tip"); b.classList.remove("tip-below", "tip-left"); }
});
}
function setStatus(s) {
status = s;
App.projHead({ status: s });
renderNext(); renderState();
}
document.addEventListener("click", (e) => { const b = e.target.closest("[data-editable][aria-disabled=true]"); if (b) e.preventDefault(); }, true);
function act(k) {
if (k === "submit") dialog({
title: "标记为已递交?",
body: `<p>标记后项目变为只读:不能再改清单、上传文件或处理问题,仍可以查看和导出报告。可以撤销,撤销会留下记录。</p>
${FATAL ? `<p class="dlg-warn">${icon("alert-circle")}<span><b>还有 ${FATAL} 项致命问题待处理。</b>不影响标记,只是提醒你。</span></p>` : ""}`,
ok: "标记为已递交",
onOk: () => { before = status; setStatus("已递交"); toast("已标记为已递交", { undo: () => { setStatus(before); toast("已撤销递交标记,记录已保存", { ok: false }); } }); }
});
else if (k === "unsubmit") { setStatus("已检查"); toast("已撤销递交标记,记录已保存", { ok: false }); }
else if (k === "archive") { before = status; setStatus("已归档"); toast("项目已归档", { undo: () => { setStatus(before); toast("已撤销归档", { ok: false }); } }); }
else if (k === "unarchive") { setStatus(before === "已归档" ? "已检查" : before); toast("已撤销归档", { ok: false }); }
else if (k === "delete") dialog({
title: `删除项目「${esc(D.project.name)}」?`,
body: `<p>项目下的招标文件、投标文件、清单、检查结果和报告都会删除。删除后 30 天内可以在回收站恢复,之后彻底清除。</p>
<p class="cap muted">额度流水和一条不含业务内容的审计记录会保留。</p>
<div class="field" data-field><label for="dn">输入项目名称确认</label><input class="input" id="dn" placeholder="${esc(D.project.name)}" autocomplete="off"></div>`,
ok: "删除项目",
onOk: (s) => {
const f = $("[data-field]", s);
if ($("#dn", s).value.trim() !== D.project.name) { App.fieldError(f, "项目名称不一致,请照上面的名称输入"); $("#dn", s).focus(); return false; }
location.href = "workbench.html";
},
onOpen: (s) => { $("#dn", s).addEventListener("input", () => { if ($("[data-field]", s).classList.contains("err")) App.fieldError($("[data-field]", s), ""); }); }
});
}
$("[data-state-acts]").addEventListener("click", (e) => {
const b = e.target.closest("button"); if (!b) return;
if (b.dataset.act) return act(b.dataset.act);
if (b.matches("[data-more]")) {
e.stopPropagation();
const items = status === "已归档" ? [{ label: "删除项目", act: "delete" }]
: [{ label: "归档", sub: "不再出现在工作台的「进行中」里,可以撤销", act: "archive" }, "-", { label: "删除项目", sub: "30 天内可以在回收站恢复", act: "delete" }];
App.floatMenu(b, items, (k) => act(k));
} }
}); });
/* ---------- AI 摘要:确认前 ⇄ 确认后(Q2:正文墨色;确认后去掉虚线和标签,出署名行) ---------- */
const card = $("[data-ai-card]");
function setDone(done) {
$("[data-ai-block]", card).classList.toggle("done", done);
$("[data-ai-tag]", card).hidden = done;
$("[data-ai-sign]", card).innerHTML = done ? `<div class="sign sm"><span class="ok" aria-hidden="true">✓</span><b>王敏</b>${NOW_TXT}<span>确认无误</span></div>` : "";
$("[data-ai-foot]", card).innerHTML = done
? `<button class="btn sm text" data-ai-log>${icon("history")}处理记录</button><span class="r"><button class="btn sm text" data-ai-undo>撤销确认</button></span>`
: `<button class="btn sm" data-ai-ok>确认无误</button><a class="btn sm text" href="checklist.html">在清单里修改</a><span class="r cap"><a class="lnk" href="#" data-ai-wrong>判断有误?</a></span>`;
}
card.addEventListener("click", (e) => {
if (e.target.closest("[data-ai-ok]")) { setDone(true); toast("已确认项目要点", { undo: () => { setDone(false); $("[data-ai-ok]", card).focus(); } }); $("[data-ai-undo]", card).focus(); }
else if (e.target.closest("[data-ai-undo]")) { setDone(false); toast("已撤销确认", { ok: false }); $("[data-ai-ok]", card).focus(); }
else if (e.target.closest("[data-ai-log]")) toast("(原型)处理记录:王敏 11月2日 15:00 确认无误", { ok: false });
else if (e.target.closest("[data-ai-wrong]")) { e.preventDefault(); toast("(原型)反馈判断有误:写明哪一条、错在哪里,会附上出处", { ok: false }); }
});
setDone(false);
setStatus(status);
</script> </script>
</body> </body>
</html> </html>

View File

@@ -1,51 +1,250 @@
<!doctype html> <!doctype html>
<!--
资质库(v0.2,FR-M6-08 最小资质库):列表 + 新增 / 编辑对话框 + 删除。按 design/ui-demo/forms.html「资质录入」重刷。
到期按每个进行中项目的投标截止日判断,不按今天;只提醒,不拦截保存。附件可以稍后补录,列表里显示「待补录」,不生成内容(红线 5)。
有效期用可键入的日期输入(.dt[data-dt],Q10),日期选择器里圆点是进行中项目的投标截止日。
-->
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>资质库 · 标博士原型</title> <title>资质库 · 标博士原型</title>
<link rel="icon" href="../design/logo/favicon.svg">
<link rel="stylesheet" href="../design/system/tokens.css"> <link rel="stylesheet" href="../design/system/tokens.css">
<link rel="stylesheet" href="../design/system/fonts-local.css"> <link rel="stylesheet" href="../design/system/fonts-local.css">
<link rel="stylesheet" href="../design/system/components.css"> <link rel="stylesheet" href="../design/system/components.css">
<link rel="stylesheet" href="assets/app.css"> <link rel="stylesheet" href="assets/app.css">
<style>
[data-alert] > .alert{margin-bottom:var(--gap-card)}
/* 卡片头:标题 + 类型筛选 + 搜索 */
.card-h .seg{margin-left:var(--sp-2)}
.card-h .r{margin-left:auto}
.card-h .search{width:240px}
.card-h .search .input{height:var(--control-h-sm)}
.card-h .search > .ic{top:8px}
/* 表格:名称列可换行;整行可点开编辑 */
table.quals td:first-child{white-space:normal;min-width:220px}
table.quals td{vertical-align:middle}
table.quals .nm{display:inline;padding:0;border:0;background:none;font:inherit;font-weight:var(--fw-bold);color:var(--text-primary);text-align:left;cursor:pointer}
table.quals .nm:hover{text-decoration:underline;text-decoration-color:var(--c-ink);text-underline-offset:3px}
table.quals td.more-cell{width:1%;padding-left:0}
table.quals .icon-btn{color:var(--text-secondary)}
table.quals tr:hover .icon-btn,table.quals .icon-btn[aria-expanded="true"]{color:var(--text-primary)}
table.quals .exp .warn{display:block;color:var(--warn-text);font-size:var(--fs-caption);line-height:var(--lh-caption);white-space:nowrap}
table.quals .exp .warn .fig{font-size:var(--fs-figure-xs);color:inherit}
.note.below{margin:var(--sp-3) 0 0}
/* 提醒小字(只提醒,不拦截):赭色小字,边框不变 */
.long-row{display:flex;gap:var(--sp-3);align-items:center}
.field.err .drop{border-color:var(--warn)}
</style>
</head> </head>
<body> <body>
<main> <main>
<div class="content"> <div class="content">
<div class="page-title"><div><h1>资质库</h1><p>查标时用来核对证书有没有过期、等级够不够。数据只来自你自己录入的内容,系统不从外部补数据。</p></div> <div class="page-title">
<div class="actions"><button class="btn primary" data-add>添加资质</button></div></div> <div><h1>资质库</h1><p>查标时按每个项目的投标截止日核对有效期和等级。只用你录入的资质。</p></div>
<div class="alert warn" style="margin-bottom:var(--sp-5)"><span class="ico w">!</span><p><b>1 张证书会在进行中项目的投标截止日前到期。</b>到期判断以每个项目的投标截止日为准,不以今天为准。</p></div> <div class="actions"><button class="btn primary" data-add><i data-ic="plus"></i>添加资质</button></div>
<table class="data"> </div>
<thead><tr><th>名称</th><th>类型</th><th>等级</th><th>证书编号</th><th>发证机构</th><th>有效期至</th><th>附件</th></tr></thead>
<tbody data-rows></tbody> <div data-alert></div>
</table>
<p class="note">MVP 只有最小资质库;业绩、人员、历史标书等完整资产库在 V1 提供。</p> <section class="card flush" aria-labelledby="h-quals">
<div class="card-h">
<h2 id="h-quals">资质</h2>
<div class="seg" role="group" aria-label="按类型筛选" data-tseg></div>
<div class="r"><label class="search"><i data-ic="search"></i><input class="input" type="search" placeholder="名称、证书编号" aria-label="搜索资质" data-q></label></div>
</div>
<table class="data hover quals">
<thead><tr><th>名称</th><th>类型</th><th>等级</th><th>证书编号</th><th>发证机构</th><th>有效期至</th><th>附件</th><th><span class="vh">更多</span></th></tr></thead>
<tbody data-rows></tbody>
</table>
</section>
<p class="note below">业绩、人员、历史标书等资产库在 V1 提供。</p>
</div> </div>
</main> </main>
<script src="assets/data.js"></script> <script src="assets/data.js"></script>
<script src="assets/app.js"></script> <script src="assets/app.js"></script>
<script> <script>
const { $, dialog, toast } = App; const { $, $$, esc, icon, dialog, toast, fieldError, floatMenu, parseDT } = App;
App.shell({ nav: "quals", crumbs: [["资质库", ""]] }); App.shell({ nav: "quals", crumbs: [["资质库", ""]] });
/* 进行中项目的投标截止日(与工作台一致;「现在」是 11月2日 15:00)。到期判断用它,不用今天 */
const DUE = [
["数据中心机房改造", "2026-11-02"], ["智慧社区平台运维", "2026-11-04"],
["智慧大厅建设项目", "2026-11-20"], ["医保等保测评", "2026-11-28"]
].map(([n, d]) => ({ n, d, dt: parseDT(d, false).dt, disp: parseDT(d, false).disp }));
const MARKS = DUE.map((x) => x.d).join(",");
const TYPES = ["资质证书", "体系认证", "软件著作权", "其他"];
let seq = 0;
const Q = [ const Q = [
["ISO/IEC 27001 信息安全管理体系", "体系认证", "—", "01226IS0458", "某认证中心", "2026-11-15", '<span style="color:var(--warn-text)">早于「智慧大厅建设项目」截止日 11-20</span>', "已上传"], { name: "ISO/IEC 27001 信息安全管理体系认证", type: "体系认证", lv: "", no: "01226IS0458", org: "某认证中心", from: "2023-11-16", to: "2026-11-15", file: "ISO27001 证书 2023.pdf", pages: 2 },
["ISO 9001 质量管理体系", "体系认证", "—", "01226Q31877", "某认证中心", "2028-03-09", "", "已上传"], { name: "ISO 9001 质量管理体系认证", type: "体系认证", lv: "", no: "01226Q31877", org: "某认证中心", from: "2025-03-10", to: "2028-03-09", file: "ISO9001 证书 2025.pdf", pages: 1 },
["ISO/IEC 20000-1 信息技术服务管理体系", "体系认证", "—", "01226IT0921", "某认证中心", "2027-06-30", "", "已上传"], { name: "ISO/IEC 20000-1 信息技术服务管理体系认证", type: "体系认证", lv: "", no: "01226IT0921", org: "某认证中心", from: "2024-07-01", to: "2027-06-30", file: "ISO20000 证书 2024.pdf", pages: 2 },
["信息系统建设和服务能力等级证书", "资质证书", "CS3", "CS3-3201-000488", "中国电子信息行业联合会", "2027-12-31", "", "已上传"], { name: "信息系统建设和服务能力等级证书", type: "资质证书", lv: "CS3", no: "CS3-3201-000488", org: "中国电子信息行业联合会", from: "2023-12-31", to: "2027-12-31", file: "CS3 证书.pdf", pages: 1 },
["电子与智能化工程专业承包", "资质证书", "二级", "D232045871", "某省住房和城乡建设厅", "2029-05-20", "", '<span class="muted">待补录</span>'], { name: "电子与智能化工程专业承包", type: "资质证书", lv: "二级", no: "D232045871", org: "某省住房和城乡建设厅", from: "2024-05-21", to: "2029-05-20", file: "" },
["政务大厅排队叫号系统 V3.0", "软件著作权", "—", "2024SR0638812", "国家版权局", "长期", "", "已上传"] { name: "政务大厅排队叫号系统 V3.0", type: "软件著作权", lv: "", no: "2024SR0638812", org: "国家版权局", from: "2024-04-12", to: "", long: true, file: "软著登记证书.pdf", pages: 1 }
]; ].map((q) => ({ ...q, id: "q" + ++seq }));
$("[data-rows]").innerHTML = Q.map((r) => `<tr><td><b>${r[0]}</b></td><td>${r[1]}</td><td>${r[2]}</td><td class="num">${r[3]}</td><td>${r[4]}</td><td><span class="num">${r[5]}</span>${r[6] ? `<div class="cap">${r[6]}</div>` : ""}</td><td>${r[7]}</td></tr>`).join("");
$("[data-add]").onclick = () => dialog({ title: "添加资质", wide: true, /* 有效期早于哪些进行中项目的投标截止日 */
body: `<div class="grid2" style="gap:var(--sp-4)"> const lateFor = (q) => q.long || !q.to ? [] : DUE.filter((x) => parseDT(q.to, false).dt < x.dt);
<div class="field"><label>名称</label><input class="input" placeholder="例如:信息系统建设和服务能力等级证书"></div> const lateText = (late) => `早于「${esc(late[0].n)}」的投标截止 ${late[0].disp}${late.length > 1 ? ` 等 <span class="fig">${late.length}</span> 个项目` : ""}`;
<div class="field"><label>类型</label><select class="input"><option>资质证书</option><option>体系认证</option><option>软件著作权</option><option>其他</option></select></div> /* 列表排序:有效期近的在上,长期有效在最后 */
<div class="field"><label>等级 <small>(选填)</small></label><input class="input"></div> const key = (q) => q.long || !q.to ? "9999" : q.to;
<div class="field"><label>证书编号</label><input class="input"></div>
<div class="field"><label>发证机构</label><input class="input"></div> let tf = "", kw = "";
<div class="field"><label>有效期至</label><input class="input" type="date"></div></div> function render() {
<div class="drop" style="padding:var(--sp-5);margin-top:var(--sp-4)"><b style="font-size:14px">上传证书扫描件</b><span class="cap muted">PDF 或图片;可以稍后补录</span></div>`, $("[data-tseg]").innerHTML = [["", "全部"], ...TYPES.map((t) => [t, t])]
ok: "保存", onOk: () => toast("已保存") }); .map(([v, t]) => { const n = Q.filter((q) => !v || q.type === v).length; return v && !n ? "" : `<button aria-pressed="${tf === v}" data-v="${v}">${t}<span class="fig">${n}</span></button>`; }).join("");
const rows = Q.filter((q) => (!tf || q.type === tf) && (!kw || (q.name + q.no + q.org).toLowerCase().includes(kw.toLowerCase())))
.sort((a, b) => key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0);
$("[data-rows]").innerHTML = rows.length ? rows.map((q) => {
const late = lateFor(q);
const exp = q.long ? "长期有效" : parseDT(q.to, false).disp;
return `<tr data-id="${q.id}">
<td><button type="button" class="nm" data-edit>${esc(q.name)}</button></td>
<td>${q.type}</td><td>${q.lv ? esc(q.lv) : '<span class="dash">—</span>'}</td>
<td class="num">${esc(q.no)}</td><td>${esc(q.org) || '<span class="dash">—</span>'}</td>
<td class="exp"><span>${exp}</span>${late.length ? `<span class="warn">${lateText(late)}</span>` : ""}</td>
<td>${q.file ? `<a class="lnk" href="#" data-file>查看</a>` : '<span class="tag status">待补录</span>'}</td>
<td class="more-cell"><button type="button" class="icon-btn sm" aria-label="更多操作" aria-haspopup="menu" aria-expanded="false" data-more>${icon("more")}</button></td></tr>`;
}).join("") : `<tr><td colspan="8" class="empty-cell"><div class="empty sm"><b>没有找到匹配的资质</b><p>换个关键词,或清除筛选。</p><button class="btn sm" data-clear>清除筛选</button></div></td></tr>`;
/* 页内提醒:证书在进行中项目的投标截止日前到期(明细写在表格的有效期列) */
const bad = Q.filter((q) => lateFor(q).length);
$("[data-alert]").innerHTML = bad.length ? `<div class="alert warn" role="note">${icon("alert-circle", "lg")}<p><b>${bad.length} 张证书会在进行中项目的投标截止前到期。</b>到期按每个项目的投标截止日判断,不按今天。</p>${bad.length === 1 ? `<button class="btn sm" data-fix="${bad[0].id}">更新有效期</button>` : ""}</div>` : "";
}
/* ---------- 新增 / 编辑对话框 ---------- */
const dtHTML = (id, val, label, extra = "", eg = "2026-11-20") => `<div class="dt" data-dt ${extra}><input class="input" id="${id}" value="${val || ""}" placeholder="例如:${eg}" autocomplete="off"><button type="button" class="icon-btn sm" aria-label="打开日期选择器" aria-haspopup="dialog" aria-expanded="false">${icon("calendar")}</button><div class="cal" role="dialog" aria-label="${label}" hidden></div></div>`;
const fileHTML = (q) => q.file
? `<div class="file-row"><span class="ext">PDF</span><div><b>${esc(q.file)}</b><span class="cap muted"><span class="fig">${q.pages || 1}</span> 页</span></div><span class="acts"><button type="button" class="btn sm text" data-fpick>替换</button><button type="button" class="icon-btn sm" aria-label="移除附件" data-tip="移除附件" data-fdel>${icon("x")}</button></span></div>`
: `<div class="drop" tabindex="0" role="button" style="padding:var(--sp-4)" data-fpick>${icon("upload")}<span>拖入或点击上传 PDF / 图片</span></div>`;
function edit(q) {
const isNew = !q;
const d = q ? { ...q } : { type: "资质证书", file: "" };
dialog({
title: isNew ? "添加资质" : "编辑资质", wide: true, ok: "保存资质",
footLeft: '<span class="cap muted">不扣额度</span>',
body: `<div class="form">
<div class="field span2" id="qd-name"><label for="qdn">名称 <small>(必填)</small></label><input class="input" id="qdn" value="${esc(d.name || "")}" placeholder="和证书上的写法一致" autocomplete="off"></div>
<div class="field"><label id="qdt-l">类型 <small>(必填)</small></label><div class="pop" data-select data-type><button type="button" class="select" aria-haspopup="listbox" aria-expanded="false" aria-labelledby="qdt-l"><span class="val">${d.type}</span>${icon("chev-down")}</button><ul class="menu" role="listbox">${TYPES.map((t) => `<li role="option" aria-selected="${t === d.type}">${t}</li>`).join("")}</ul></div></div>
<div class="field"><label for="qdl">等级 <small>(选填)</small></label><input class="input" id="qdl" value="${esc(d.lv || "")}" placeholder="例如:CS3、二级"></div>
<div class="field" id="qd-no"><label for="qdo">证书编号 <small>(必填)</small></label><input class="input" id="qdo" value="${esc(d.no || "")}" autocomplete="off"></div>
<div class="field"><label for="qdg">发证机构</label><input class="input" id="qdg" value="${esc(d.org || "")}"></div>
<div class="field" id="qd-from"><label for="qdf">有效期起</label>${dtHTML("qdf", d.from, "选择有效期起", "", "2023-11-16")}</div>
<div class="field" id="qd-exp"><label for="qde">有效期至 <small>(必填)</small></label>${dtHTML("qde", d.long ? "" : d.to, "选择有效期至", `data-marks="${MARKS}" data-note="圆点是进行中项目的投标截止日" data-req="请填写有效期至;长期有效请打开下方开关"`)}
<span class="hint-warn" data-exp-hint data-off hidden><span class="ico w" aria-hidden="true">!</span><span data-exp-txt></span></span></div>
<div class="field span2"><div class="long-row"><span id="q-long">长期有效</span><button type="button" class="switch" role="switch" aria-checked="${!!d.long}" aria-labelledby="q-long" data-long><span class="track"></span><span class="st"></span></button></div></div>
<div class="field span2"><label>证书扫描件 <small>(可以稍后补录)</small></label><div data-file-box>${fileHTML(d)}</div></div>
</div>`,
onOpen: (s) => {
const expF = $("#qd-exp", s), expDT = $("[data-dt]", expF), hint = $("[data-exp-hint]", expF), fromDT = $("#qd-from [data-dt]", s);
const sw = $("[data-long]", s), expInp = $("#qde", s);
$("[data-type]", s).addEventListener("change", (e) => { d.type = e.detail; });
/* 到期提醒:早于进行中项目的投标截止日时出赭色小字,可以保存 */
const setHint = (dt) => {
const late = dt ? DUE.filter((x) => dt < x.dt) : [];
if (late.length) { delete hint.dataset.off; $("[data-exp-txt]", hint).innerHTML = lateText(late); } else hint.dataset.off = "";
hint.hidden = !late.length || expF.classList.contains("err");
};
/* 有效期至要晚于有效期起 */
const order = () => {
const a = fromDT.dt.parse(), b = expDT.dt.parse();
return a.dt && b.dt && b.dt <= a.dt ? "有效期至要晚于有效期起,请核对" : "";
};
expF.addEventListener("dt-change", (e) => {
const r = e.detail;
if (r.ok && !r.empty) { const m = order(); if (m) { fieldError(expF, m); hint.hidden = true; return; } }
setHint(r.ok && !r.empty ? r.dt : null);
});
fromDT.addEventListener("dt-change", () => { if (expInp.value.trim() && !sw.matches('[aria-checked="true"]')) expDT.dt.commit(); });
const setLong = (long) => {
expInp.disabled = long; $(".icon-btn", expDT).disabled = long;
if (long) { expInp.value = ""; hint.dataset.off = ""; hint.hidden = true; fieldError(expF, ""); }
};
sw.addEventListener("change", (e) => { setLong(e.detail); if (!e.detail) expInp.focus(); });
setLong(!!d.long);
if (!d.long && d.to) { const r = parseDT(d.to, false); setHint(r.dt); }
/* 附件(原型):点击即视为选了一个文件 */
const box = $("[data-file-box]", s);
const bindFile = () => {
const pick = $("[data-fpick]", box), del = $("[data-fdel]", box);
if (pick) { pick.onclick = () => { d.file = (d.name ? d.name.split(" ")[0] : "证书") + " 扫描件.pdf"; d.pages = 1; box.innerHTML = fileHTML(d); bindFile(); toast("已选择文件(原型不读取文件)", { ok: false }); }; pick.onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); pick.click(); } }; }
if (del) del.onclick = () => { d.file = ""; box.innerHTML = fileHTML(d); bindFile(); $("[data-fpick]", box).focus(); };
};
bindFile();
/* 必填项:离开时校验,提交时汇总 */
const R = {
"qd-name": () => $("#qdn", s).value.trim() ? "" : "请填写资质名称,和证书上的写法一致",
"qd-no": () => $("#qdo", s).value.trim() ? "" : "请填写证书编号,查标时用它核对附件"
};
s._check = () => {
const a = Object.entries(R).map(([id, fn]) => fieldError($("#" + id, s), fn()));
const f = fromDT.dt.commit();
const long = sw.getAttribute("aria-checked") === "true";
let e = long ? true : expDT.dt.commit({ required: true });
if (e && !long) { const m = order(); if (m) { fieldError(expF, m); hint.hidden = true; e = false; } }
return [...a, f, e].every(Boolean);
};
Object.entries(R).forEach(([id, fn]) => {
const f = $("#" + id, s), inp = $("input", f); let dirty = false;
inp.addEventListener("input", () => { dirty = true; if (f.classList.contains("err") && !fn()) fieldError(f, ""); });
inp.addEventListener("blur", () => { if (dirty || f.classList.contains("err")) fieldError(f, fn()); });
});
if (!isNew && q.fixFocus) { delete q.fixFocus; setTimeout(() => { expInp.focus(); expInp.select(); }); }
},
onOk: (s) => {
if (!s._check()) { const e = $(".field.err input:not([disabled])", s); if (e) e.focus(); return false; }
const long = $("[data-long]", s).getAttribute("aria-checked") === "true";
Object.assign(d, { name: $("#qdn", s).value.trim(), lv: $("#qdl", s).value.trim(), no: $("#qdo", s).value.trim(), org: $("#qdg", s).value.trim(), from: $("#qdf", s).value.trim(), to: long ? "" : $("#qde", s).value.trim(), long });
if (isNew) { d.id = "q" + ++seq; Q.push(d); } else Object.assign(q, d);
render();
toast(isNew ? `已添加「${esc(d.name)}」` : `已保存「${esc(d.name)}」`);
}
});
}
function remove(q) {
dialog({
title: `删除资质「${esc(q.name)}」?`, ok: "删除",
body: "<p>删除后,查标不再用它核对资质。已导出的检查报告不受影响。</p>",
onOk: () => {
const i = Q.indexOf(q); Q.splice(i, 1); render();
toast(`已删除「${esc(q.name)}」`, { undo: () => { Q.splice(i, 0, q); render(); toast("已恢复", { ok: false }); } });
}
});
}
/* ---------- 事件 ---------- */
const byId = (el) => Q.find((q) => q.id === el.closest("tr").dataset.id);
$("[data-add]").onclick = () => edit(null);
$("[data-tseg]").addEventListener("click", (e) => { const b = e.target.closest("button"); if (!b) return; tf = b.dataset.v; render(); });
$("[data-q]").addEventListener("input", (e) => { kw = e.target.value.trim(); render(); });
$("[data-alert]").addEventListener("click", (e) => { const b = e.target.closest("[data-fix]"); if (!b) return; const q = Q.find((x) => x.id === b.dataset.fix); q.fixFocus = true; edit(q); });
$("[data-rows]").addEventListener("click", (e) => {
if (e.target.closest("[data-clear]")) { tf = ""; kw = ""; $("[data-q]").value = ""; render(); return; }
const tr = e.target.closest("tr[data-id]"); if (!tr) return;
const q = byId(tr);
if (e.target.closest("[data-file]")) { e.preventDefault(); toast(`(原型)打开「${esc(q.file)}」`, { ok: false }); return; }
const more = e.target.closest("[data-more]");
if (more) {
e.stopPropagation();
floatMenu(more, [{ label: "编辑", act: "edit" }, { label: q.file ? "替换附件" : "上传附件", act: "file" }, "-", { label: "删除", act: "del" }], (act) => {
if (act === "edit") edit(q);
else if (act === "del") remove(q);
else { q.file = q.name.split(" ")[0] + " 扫描件.pdf"; q.pages = 1; render(); toast("已上传证书扫描件"); }
});
return;
}
edit(q);
});
render();
</script> </script>
</body> </body>
</html> </html>

View File

@@ -1,64 +1,293 @@
<!doctype html> <!doctype html>
<!--
额度(v0.2):额度卡 + 扣点规则 + 本期流水。按 design/ui-demo/cards.html(额度卡)和 lists.html(额度流水)重刷。
Q1:流水的「+」「−」都是墨色;Q9:用到 80% 起赭色,用完说明续费;Q13:24 小时内复查记一行 0 点的「免扣」。
续费按决策 G8:在线申请 → 对公转账 → 运营确认到账后开通并开发票(不写「联系客服」)。
URL 带 ?quota=warn / ?quota=out 预览检查额度用到 80% / 用完(和顶栏 App.shell 一致)。
-->
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>额度 · 标博士原型</title> <title>额度 · 标博士原型</title>
<link rel="icon" href="../design/logo/favicon.svg">
<link rel="stylesheet" href="../design/system/tokens.css"> <link rel="stylesheet" href="../design/system/tokens.css">
<link rel="stylesheet" href="../design/system/fonts-local.css"> <link rel="stylesheet" href="../design/system/fonts-local.css">
<link rel="stylesheet" href="../design/system/components.css"> <link rel="stylesheet" href="../design/system/components.css">
<link rel="stylesheet" href="assets/app.css"> <link rel="stylesheet" href="assets/app.css">
<style> <style>
.meters{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--sp-4)} [data-alert] > .alert{margin-bottom:var(--gap-card)}
.meter small{display:block;color:var(--text-secondary);font-size:var(--fs-caption)} .content > section + section{margin-top:var(--gap-card)}
.meter .big{font:600 var(--fs-figure)/var(--lh-figure) var(--font-figures);font-variant-numeric:tabular-nums}
.meter .unit{color:var(--text-secondary);margin-left:4px} /* 额度卡:三张等高 */
.meter p{margin:0;font-size:var(--fs-caption);color:var(--text-secondary)} .qgrid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--gap-card)}
.rules td{height:auto} .qgrid > .card{display:flex;flex-direction:column}
td.plus{color:var(--confirmed)} .qgrid > .card > .card-b{flex:1}
.qgrid > .card > .card-f{margin-top:auto}
.card-h .aside{margin-left:auto;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary);white-space:nowrap}
.card-h .aside.warn{color:var(--warn-text);font-weight:var(--fw-bold)}
.quota .big{font:var(--fw-bold) var(--fs-figure)/var(--lh-figure) var(--font-figures);font-variant-numeric:tabular-nums}
.quota .big.warn{font-weight:var(--fw-bold)}
.quota .unit{margin-left:4px;color:var(--text-secondary);font-family:var(--font-figures)}
.quota .bar{margin:var(--sp-2) 0}
.quota p.warn{display:flex;gap:var(--sp-1);align-items:flex-start}
.quota p.warn .ic{flex:none;margin-top:2px;width:14px;height:14px}
/* 按功能分布 */
.dist{list-style:none;margin:0;padding:0}
.dist li{display:flex;align-items:baseline;gap:var(--sp-3);padding:6px 0;border-top:var(--line) solid var(--border-subtle)}
.dist li:first-child{border-top:0;padding-top:0}
.dist li > span:first-child{flex:1;min-width:0}
.dist .fig{font-weight:var(--fw-medium)}
.dist small{display:inline;color:var(--text-secondary);font-size:var(--fs-caption)}
/* 工作台额度卡的「扣点规则」「查看流水」链到 #rules / #flow:留出顶栏高度 */
#rules,#flow{scroll-margin-top:calc(var(--topbar-h) + var(--sp-2))}
/* 扣点规则 */
table.rules td{height:auto}
table.rules td:first-child{white-space:normal;font-weight:var(--fw-bold)}
/* 流水 */
.card-h .seg{margin-left:var(--sp-2)}
.card-h .r{margin-left:auto;display:flex;gap:var(--sp-3);align-items:center}
table.flow td:nth-child(2),table.flow td:nth-child(3){white-space:nowrap}
table.flow .num.r{font-weight:var(--fw-medium)}
.flow-sub td{border-top:0}
.fl-link{display:inline-block;width:14px;color:var(--text-secondary)}
.del{color:var(--text-secondary)}
.card-f .pagination{flex:1;margin:0}
</style> </style>
</head> </head>
<body> <body>
<main> <main>
<div class="content"> <div class="content">
<div class="page-title"><div><h1>额度</h1><p>专业版 · 本期 2026-11-01 至 2027-10-31。额度绑定企业主体,不可转让;期末未用完的额度不结转。</p></div> <div class="page-title">
<div class="actions"><button class="btn" data-contact>联系客服加额度</button></div></div> <div><h1>额度</h1><p data-sub></p></div>
<div class="alert" style="margin-bottom:var(--sp-5)"><span class="ico i">i</span><p>额度用到 80% 和 100% 时会站内提醒(每个周期每个档位只提醒一次),提醒用赭色,不用红色。</p></div> <div class="actions" data-actions></div>
<div class="meters">
<div class="card meter"><small>解析剩余</small><span class="big">37</span><span class="unit">/ 50 次</span><div class="bar"><i style="width:26%"></i></div><p>已用 13 次 · 招标文件和补遗各算 1 次</p></div>
<div class="card meter"><small>检查剩余</small><span class="big">12</span><span class="unit">/ 20 次</span><div class="bar"><i style="width:40%"></i></div><p>已用 8 次 · 同一项目 24 小时内复查不扣</p></div>
<div class="card meter"><small>按功能分布(本期)</small><p style="margin-top:6px;color:var(--text-primary);font-size:14px;line-height:22px">读标 11 次 · 补遗 2 次<br>查标 8 次 · 复查免扣 5 次</p></div>
</div> </div>
<div class="section-h"><h2>扣点规则</h2><span class="cap muted">所有扣点都能在下方流水里对应到具体项目和任务</span></div> <div data-alert></div>
<table class="data rules">
<thead><tr><th>动作</th><th>扣点</th><th>不扣点</th></tr></thead>
<tbody>
<tr><td>解读一份招标文件或补遗</td><td>1 次解析(发起时预扣,成功后结算)</td><td>解析失败、超时、取消:自动退回</td></tr>
<tr><td>对一个项目做一次全量检查</td><td>1 次检查(投标文件解析含在内)</td><td>检查中断、系统异常;同一项目 24 小时内复查</td></tr>
</tbody>
</table>
<div class="section-h"><h2>流水</h2><span class="more"><div class="seg"><button class="on">全部</button><button>解析</button><button>检查</button></div></span></div> <section class="qgrid" aria-label="本期额度" data-cards></section>
<table class="data">
<thead><tr><th>时间</th><th>类型</th><th>额度</th><th class="r">数量</th><th>关联项目 / 任务</th><th>说明</th></tr></thead> <section class="card flush" id="rules" aria-labelledby="h-rules">
<tbody> <div class="card-h"><h2 id="h-rules">扣点规则</h2></div>
<tr><td class="num">11-02 14:31</td><td>退回</td><td>解析</td><td class="num r plus">+1</td><td>智慧大厅建设项目 · 解析任务 PJ-0417</td><td class="muted">解析失败:OCR 未识别出文字</td></tr> <table class="data rules">
<tr><td class="num">11-02 14:28</td><td>预扣</td><td>解析</td><td class="num r">−1</td><td>智慧大厅建设项目 · 解析任务 PJ-0417</td><td></td></tr> <thead><tr><th style="width:26%">动作</th><th style="width:30%">扣点</th><th>不扣点</th></tr></thead>
<tr><td class="num">11-02 14:20</td><td>—</td><td>检查</td><td class="num r">0</td><td>智慧大厅建设项目 · 第 2 次检查</td><td class="muted">24 小时内复查,不扣</td></tr> <tbody>
<tr><td class="num">11-02 10:05</td><td>结算</td><td>解析</td><td class="num r">−1</td><td>智慧大厅建设项目 · 补遗 1</td><td></td></tr> <tr><td>解读一份招标文件、补遗或澄清</td><td>1 次解析:发起时预扣,成功后结算</td><td>解析失败、超时、取消:自动退回</td></tr>
<tr><td class="num">11-01 16:00</td><td>结算</td><td>检查</td><td class="num r">−1</td><td>智慧大厅建设项目 · 第 1 次检查</td><td></td></tr> <tr><td>对一个项目做一次检查</td><td>1 次检查:投标文件的解析含在内</td><td>检查中断、系统异常:自动退回<br>同一项目 24 小时内复查:不扣,流水记一行「免扣」。24 小时从上一次扣额度的检查算起</td></tr>
<tr><td class="num">11-01 10:40</td><td>结算</td><td>解析</td><td class="num r">−1</td><td>智慧大厅建设项目 · 招标文件</td><td></td></tr> </tbody>
<tr><td class="num">11-01 09:00</td><td>发放</td><td>解析 / 检查</td><td class="num r plus">+50 / +20</td><td>专业版开通(对公转账)</td><td class="muted">运营:开通单 KT-1101</td></tr> </table>
</tbody> </section>
</table>
<section class="card flush" id="flow" aria-labelledby="h-flow">
<div class="card-h">
<h2 id="h-flow">本期流水</h2>
<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"><span class="fig">2026-11-01</span> 至 <span class="fig">2027-10-31</span></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>
</div> </div>
</main> </main>
<script src="assets/data.js"></script> <script src="assets/data.js"></script>
<script src="assets/app.js"></script> <script src="assets/app.js"></script>
<script> <script>
const { D, $, $$, esc, icon, dialog, toast, fieldError } = App;
App.shell({ nav: "quota", crumbs: [["额度", ""]] }); App.shell({ nav: "quota", crumbs: [["额度", ""]] });
App.$("[data-contact]").onclick = () => App.dialog({ title: "联系客服加额度", cancel: null, ok: "知道了", body: "<p>MVP 暂不支持线上加量包。请联系客服,确认后由运营在后台调整额度,调整会写进流水并注明原因。</p><dl class='kv'><dt>客服电话</dt><dd>(待定)</dd><dt>官网</dt><dd>www.biaoboshi.cn</dd></dl>" });
/* ---------- 流水:按时间先后记账,余额由流水算出;期末余额和顶栏一致(解析 37、检查 12) ----------
* [时间 "月-日 时:分", 类型, 额度, 数量, 关联, 说明, 用途(t 招标文件 / a 补遗、澄清)];退回紧跟在对应的预扣之后。
* 已删除的项目(决策 G10):业务内容已删除,流水只留任务编号。 */
const A = "智慧大厅建设项目", B = "智慧社区平台运维", C = "数据中心机房改造", Dp = "医保等保测评";
const DEL = '<span class="del">已删除的项目</span>';
const FREE = "免扣(24 小时内复查)";
const WIN = "距上次扣额度的检查不到 24 小时";
const LEDGER = [
["11-01 09:00", "发放", "both", [50, 20], "专业版开通(对公转账)", "开通单 KT-1101"],
["11-01 09:20", "结算", "解析", -1, `${DEL} · 解析 PJ-0401`, "", "t"],
["11-01 09:45", "结算", "解析", -1, `${DEL} · 解析 PJ-0402`, "", "t"],
["11-01 10:05", "结算", "检查", -1, `${DEL} · 检查 CK-0403`, ""],
["11-01 10:20", "预扣", "检查", -1, `${DEL} · 检查 CK-0404`, ""],
["11-01 10:32", "退回", "检查", 1, `${DEL} · 检查 CK-0404`, "检查超时"],
["11-01 10:40", "结算", "检查", -1, `${DEL} · 检查 CK-0405`, "超时后重新发起"],
["11-01 10:50", "结算", "解析", -1, `${A} · 招标文件`, "", "t"],
["11-01 11:30", "结算", "检查", -1, `${B} · 第 1 次检查`, ""],
["11-01 13:20", "结算", "解析", -1, `${C} · 招标文件`, "", "t"],
["11-01 13:35", "结算", "解析", -1, `${C} · 招标文件附件`, "", "t"],
["11-01 14:10", "结算", "解析", -1, `${Dp} · 招标文件`, "", "t"],
["11-01 15:10", FREE, "检查", 0, `${B} · 第 2 次检查`, WIN],
["11-01 16:00", "结算", "检查", -1, `${A} · 第 1 次检查`, ""],
["11-02 08:40", "结算", "解析", -1, `${C} · 补遗 1`, "", "a"],
["11-02 09:05", "结算", "解析", -1, `${B} · 澄清答复 1`, "", "a"],
["11-02 09:30", "结算", "检查", -1, `${DEL} · 检查 CK-0409`, ""],
["11-02 10:05", "结算", "解析", -1, `${A} · 补遗 1`, "", "a"],
["11-02 10:30", "结算", "检查", -1, `${DEL} · 检查 CK-0411`, ""],
["11-02 10:50", "结算", "解析", -1, `${Dp} · 补遗 1`, "", "a"],
["11-02 11:00", "结算", "检查", -1, `${DEL} · 检查 CK-0412`, ""],
["11-02 11:40", "结算", "检查", -1, `${B} · 第 3 次检查`, ""],
["11-02 13:10", "结算", "解析", -1, `${C} · 澄清答复 1`, "", "a"],
["11-02 13:40", "结算", "解析", -1, `${B} · 补遗 1`, "", "a"],
["11-02 14:20", FREE, "检查", 0, `${A} · 第 2 次检查`, WIN],
["11-02 14:28", "预扣", "解析", -1, `${A} · 补遗 1 附件`, ""],
["11-02 14:31", "退回", "解析", 1, `${A} · 补遗 1 附件`, "解析失败:文件已加密"],
["11-02 14:40", "预扣", "解析", -1, `${Dp} · 补遗 2`, "解析中,完成后结算", "a"],
["11-02 14:50", FREE, "检查", 0, `${B} · 第 4 次检查`, "排队中 · 不扣额度"]
];
/* 预览 80% / 用完:和顶栏一样把检查剩余改成 3 / 0,流水里补上相应的检查记录,余额仍然对得上 */
const qs = new URLSearchParams(location.search);
const mode = qs.get("quota") === "out" ? "out" : qs.get("quota") === "warn" ? "warn" : "";
const extra = { warn: 9, out: 12 }[mode] || 0;
for (let i = 0; i < extra; i++) {
const m = 17 * 60 + i * 20; // 11月1日 17:00 起,每 20 分钟一条
LEDGER.push([`11-01 ${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`, "结算", "检查", -1, `${DEL} · 检查 CK-05${String(i + 10)}`, ""]);
}
LEDGER.sort((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0);
const T = (k) => { const [md, hm] = k.split(" "), [mo, d] = md.split("-").map(Number); return `${mo}月${d}日 ${hm}`; };
const bal = { 解析: 0, 检查: 0 };
const ROWS = LEDGER.map(([t, type, q, n, ref, note, kind]) => {
if (q === "both") { bal.解析 += n[0]; bal.检查 += n[1]; return { t: T(t), type, q: "解析 / 检查", qs: ["解析", "检查"], n: `+${n[0]} / +${n[1]}`, bal: `${bal.解析} / ${bal.检查}`, ref, note }; }
bal[q] += n;
return { t: T(t), type, q, qs: [q], n: n > 0 ? `+${n}` : n < 0 ? `−${-n}` : "0", bal: bal[q], ref, note, kind, raw: n };
}).reverse(); // 新的在上
/* ---------- 额度卡与页头 ---------- */
const Q = { 解析: { left: bal.解析, total: 50 }, 检查: { left: bal.检查, total: 20 } };
const cnt = (f) => ROWS.filter(f).length;
const used = (k) => Q[k].total - Q[k].left, pct = (k) => Math.round(used(k) / Q[k].total * 100);
const back = (k) => cnt((r) => r.q === k && r.type === "退回");
const free = cnt((r) => r.type === FREE);
const RENEW_TXT = (k) => `本期${k}额度已用完,可以提交续费申请,或等下期发放。`;
let applied = false;
$("[data-sub]").innerHTML = `${esc(D.user.plan)} · 本期 <span class="fig">2026-11-01</span> 至 <span class="fig">2027-10-31</span>。额度绑定企业主体,不可转让;本期没用完的不结转。`;
function card(k, rule) {
const p = pct(k), warn = p >= 80, out = Q[k].left <= 0;
const aside = out ? "已用完" : `已用 ${p}%`;
const line = out && applied ? `<p>续费申请已提交,到账开通后发放新一期额度。</p>`
: out ? `<p class="warn">${icon("alert-circle")}${RENEW_TXT(k)}</p>`
: warn ? `<p class="warn">${icon("alert-circle")}${rule}</p>`
: `<p>已用 <span class="fig">${used(k)}</span> 次 · 退回 <span class="fig">${back(k)}</span> 次</p>`;
return `<section class="card flush quota" aria-labelledby="q-${k}">
<div class="card-h"><h3 id="q-${k}">${k}额度</h3><span class="aside ${warn ? "warn" : ""}">${aside}</span></div>
<div class="card-b">
<small>剩余</small><span class="big ${warn ? "warn" : ""}">${Q[k].left}</span><span class="unit">/ ${Q[k].total} 次</span>
<div class="bar ${warn ? "warn" : ""}" role="img" aria-label="已用 ${used(k)} / ${Q[k].total} 次"><i style="width:${p}%"></i></div>
${line}
</div>
${out && !applied ? `<div class="card-f cap"><button class="btn sm primary" data-renew>提交续费申请</button></div>` : ""}
</section>`;
}
function cards() {
const parsed = ROWS.filter((r) => r.q === "解析" && r.raw === -1 && !r.ret);
const tParse = parsed.filter((r) => r.kind === "t").length, aParse = parsed.filter((r) => r.kind === "a").length;
$("[data-cards]").innerHTML = card("解析", "招标文件、补遗、澄清各算 1 次;失败自动退回。") + card("检查", "同一项目 24 小时内复查不扣额度。") + `
<section class="card flush quota" aria-labelledby="q-dist">
<div class="card-h"><h3 id="q-dist">按功能分布</h3></div>
<div class="card-b"><ul class="dist">
<li><span>读标 · 招标文件</span><span><span class="fig">${tParse}</span> <small>次解析</small></span></li>
<li><span>读标 · 补遗和澄清</span><span><span class="fig">${aParse}</span> <small>次解析</small></span></li>
<li><span>查标</span><span><span class="fig">${used("检查")}</span> <small>次检查</small></span></li>
<li><span>24 小时内复查</span><span><span class="fig">${free}</span> <small>次 · 免扣</small></span></li>
</ul></div>
</section>`;
$$("[data-renew]").forEach((b) => (b.onclick = renew));
}
/* 预扣后被退回的那一条,不算进「按功能分布」 */
ROWS.forEach((r, i) => { if (r.type === "预扣" && ROWS.slice(0, i).some((x) => x.type === "退回" && x.ref === r.ref)) r.ret = true; });
function head() {
/* 页面级只有一个续费入口:平时在页头(次按钮);用完时在检查额度卡里(主按钮,§7.10);提交后换成状态提醒 */
const out = Q.检查.left <= 0 || Q.解析.left <= 0;
$("[data-actions]").innerHTML = applied || out ? "" : `<button class="btn" data-renew-top>提交续费申请</button>`;
const top = $("[data-renew-top]"); if (top) top.onclick = renew;
$("[data-alert]").innerHTML = applied ? `<div class="alert" role="status">${icon("info", "lg")}<p>续费申请 <b class="fig">XF-1102-01</b> 已提交,等待对公转账。到账后由运营开通并开发票,发放会记进流水。</p><button class="btn sm" data-transfer>转账信息</button></div>` : "";
const t = $("[data-transfer]"); if (t) t.onclick = transfer;
}
/* ---------- 续费申请(决策 G8:在线申请 → 对公转账 → 运营确认到账后开通,并开发票) ---------- */
function renew() {
dialog({
title: "提交续费申请", wide: true, ok: "提交申请",
footLeft: '<span class="cap muted">提交申请不扣额度,也不用先付款</span>',
body: `<p class="muted">专业版按年续费:提交申请 → 对公转账 → 运营确认到账后开通,并开发票。</p>
<div class="form">
<div class="field span2"><label for="rn-co">企业名称</label><input class="input" id="rn-co" value="${esc(D.user.company)}" readonly><span class="msg">额度绑定这个企业主体,不可转让</span></div>
<div class="field"><label id="rn-inv-l">发票类型 <small>(必填)</small></label>
<div class="pop" data-select><button type="button" class="select" aria-haspopup="listbox" aria-expanded="false" aria-labelledby="rn-inv-l"><span class="val">增值税普通发票</span>${icon("chev-down")}</button>
<ul class="menu" role="listbox"><li role="option" aria-selected="true">增值税普通发票</li><li role="option">增值税专用发票</li></ul></div></div>
<div class="field" id="rn-tax"><label for="rn-tax-i">纳税人识别号 <small>(必填)</small></label><input class="input" id="rn-tax-i" placeholder="18 位统一社会信用代码" autocomplete="off" maxlength="20"></div>
<div class="field" id="rn-man"><label for="rn-man-i">联系人 <small>(必填)</small></label><input class="input" id="rn-man-i" value="${esc(D.user.name)}"></div>
<div class="field" id="rn-tel"><label for="rn-tel-i">手机号 <small>(必填)</small></label><input class="input" id="rn-tel-i" value="13900001234" inputmode="numeric" maxlength="11"></div>
</div>`,
onOpen: (s) => {
const R = {
"rn-tax": (v) => /^[0-9A-Z]{18}$/.test(v.toUpperCase()) ? "" : v ? "纳税人识别号是 18 位数字或大写字母,请核对" : "请填写纳税人识别号,开发票要用",
"rn-man": (v) => v ? "" : "请填写联系人",
"rn-tel": (v) => /^1\d{10}$/.test(v) ? "" : "手机号应为 11 位数字"
};
s._check = () => Object.entries(R).map(([id, fn]) => fieldError($("#" + id, s), fn($("#" + id + " input", s).value.trim()))).every(Boolean);
Object.entries(R).forEach(([id, fn]) => {
const f = $("#" + id, s), inp = $("input", f); let dirty = false;
inp.addEventListener("input", () => { dirty = true; if (f.classList.contains("err") && !fn(inp.value.trim())) fieldError(f, ""); });
inp.addEventListener("blur", () => { if (dirty || f.classList.contains("err")) fieldError(f, fn(inp.value.trim())); });
});
$("#rn-tax-i", s).focus();
},
onOk: (s) => {
if (!s._check()) { $(".field.err input", s).focus(); return false; }
applied = true; head(); cards();
toast("已提交续费申请 XF-1102-01");
}
});
}
function transfer() {
dialog({
title: "续费申请 XF-1102-01", cancel: null, ok: "知道了",
body: `<dl class="kv">
<dt>状态</dt><dd><span class="tag status">等待对公转账</span></dd>
<dt>套餐</dt><dd>专业版 · 1 年</dd>
<dt>收款单位</dt><dd class="muted">(待定)</dd>
<dt>开户行</dt><dd class="muted">(待定)</dd>
<dt>账号</dt><dd class="muted">(待定)</dd>
<dt>转账备注</dt><dd>请写申请单号 <b class="fig">XF-1102-01</b></dd></dl>
<p class="muted" style="margin:var(--sp-4) 0 0">运营确认到账后开通,并开发票;开通后新一期额度会记进流水。</p>`
});
}
/* ---------- 流水表:筛选、分页(Q4:只在超过一页时显示) ---------- */
const PER = 10;
let qf = "", page = 1;
function flow() {
const rows = ROWS.filter((r) => !qf || r.qs.includes(qf)), pages = Math.max(1, Math.ceil(rows.length / PER));
page = Math.min(Math.max(page, 1), 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.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) => `<button class="pg" data-pg="${p}" ${p === page ? 'aria-current="page"' : ""} aria-label="第 ${p} 页">${p}</button>`;
$("[data-ffoot]").hidden = pages < 2;
$("[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(); const cur = $(`[data-pg="${page}"][aria-current]`); if (cur) cur.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(原型不生成文件)", { ok: false });
cards(); head(); flow();
</script> </script>
</body> </body>
</html> </html>

View File

@@ -1,102 +1,202 @@
<!doctype html> <!doctype html>
<!--
检查报告预览(v0.2,FR-M3-09,规范 §7.17):只换外围框架(项目头 + 标签页 + 右侧导出栏),A4 报告本体保持 PDF 印刷件的版式。
报告本体:logo 只在页眉;首页写项目信息、检查覆盖范围(查了 / 没查)、免责声明;全文不出现「通过 / 合格 / 保证」。
分页:按 A4 高度自动排版(问题明细一页放不下时续页),页脚「n / N」。打印时只打印 A4 页。
-->
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>检查报告 · 标博士原型</title> <title>检查报告 · 标博士原型</title>
<link rel="icon" href="../design/logo/favicon.svg">
<link rel="stylesheet" href="../design/system/tokens.css"> <link rel="stylesheet" href="../design/system/tokens.css">
<link rel="stylesheet" href="../design/system/fonts-local.css"> <link rel="stylesheet" href="../design/system/fonts-local.css">
<link rel="stylesheet" href="../design/system/components.css"> <link rel="stylesheet" href="../design/system/components.css">
<link rel="stylesheet" href="assets/app.css"> <link rel="stylesheet" href="assets/app.css">
<style> <style>
.rp{display:grid;grid-template-columns:260px minmax(0,1fr);gap:var(--sp-8);align-items:start} /* ---------- 外围框架:A4 预览 + 右侧导出栏(同工作台的主列 + 右栏) ---------- */
.rp-side{position:sticky;top:76px} .rp{display:grid;grid-template-columns:minmax(0,1fr) var(--aside-w);gap:var(--gap-card);align-items:start}
.rp-side .card + .card{margin-top:var(--sp-4)} .rp-side{position:sticky;top:calc(var(--topbar-h) + var(--sp-2))}
.rp-side h3{font:var(--fw-bold) var(--fs-title)/var(--lh-title) var(--font-sans);margin:0 0 var(--sp-3)} .rp-side > .card + .card{margin-top:var(--gap-card)}
.rp-side .chk{padding:var(--sp-2) 0;border:0} .rp-side .choices{gap:var(--sp-3)}
.rp-side .hint-warn{margin-top:var(--sp-4)}
.rp-side .card-f .btn.primary{flex:1}
.exports{list-style:none;margin:0;padding:0}
.exports li{display:flex;gap:var(--sp-3);align-items:center;padding:var(--sp-2) 0;border-top:var(--line) solid var(--border-subtle)}
.exports li:first-child{border-top:0;padding-top:0}
.exports li > div{flex:1;min-width:0}
.exports b{display:block;font-weight:var(--fw-bold)}
.exports small{display:block;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary)}
.exports .new b::after{content:"本次";margin-left:var(--sp-2);padding:0 6px;border-radius:var(--radius-tag);background:var(--c-n100);font-size:var(--fs-caption);font-weight:var(--fw-regular)}
.pages{display:flex;flex-direction:column;align-items:center;gap:var(--sp-6)} .pages{display:flex;flex-direction:column;align-items:center;gap:var(--sp-6)}
.a4{width:794px;min-height:1123px;background:#fff;box-shadow:0 1px 3px rgba(29,39,51,.18);padding:76px 76px 64px;position:relative;font-size:13.5px;line-height:1.6;color:#1D2733}
.a4 .report-head{margin-bottom:0} /* ---------- 报告本体:PDF 版式(A4 210 × 297mm = 794 × 1123px,页边距 20mm),不随界面风格变化 ---------- */
.a4 h2{font:600 26px/1.35 var(--font-serif);margin:40px 0 6px} .a4{--mm:3.78px;--rp-fs:10.5pt;--rp-lh:1.6;--rp-sm:9pt;--rp-xs:8pt;
.a4 h4{font:600 15px/1.4 var(--font-sans);margin:26px 0 10px;padding-bottom:6px;border-bottom:1px solid var(--c-ink)} display:flex;flex-direction:column;flex:none;width:794px;height:1123px;padding:calc(14 * var(--mm)) calc(20 * var(--mm)) calc(10 * var(--mm));
background:var(--c-white);color:var(--c-ink);box-shadow:var(--shadow-sheet);font:var(--rp-fs)/var(--rp-lh) var(--font-sans)}
.a4 .report-head{flex:none}
.a4 .report-head span{font-size:var(--rp-xs)}
.a4-body{flex:1;min-height:0;overflow:hidden;padding-top:calc(4 * var(--mm))}
.a4 .report-foot{flex:none;margin-top:var(--sp-3);font-size:var(--rp-xs)}
.a4 .report-foot .fig{font-size:var(--rp-xs);font-weight:var(--fw-medium)}
.a4 h2{font:var(--fw-bold) 20pt/1.35 var(--font-serif);margin:calc(6 * var(--mm)) 0 var(--sp-1)}
.a4 h4{font:var(--fw-bold) 11pt/1.4 var(--font-sans);margin:calc(5 * var(--mm)) 0 var(--sp-2);padding-bottom:var(--sp-1);border-bottom:var(--line) solid var(--c-ink)}
.a4 h4.first{margin-top:0}
.a4 p{margin:0 0 var(--sp-2)}
.a4 .sub{color:var(--text-secondary);margin:0} .a4 .sub{color:var(--text-secondary);margin:0}
.a4 dl{display:grid;grid-template-columns:110px 1fr;gap:6px 14px;margin:22px 0} .a4 dl{display:grid;grid-template-columns:28mm 1fr;gap:2px var(--sp-4);margin:calc(5 * var(--mm)) 0 0}
.a4 dt{color:var(--text-secondary)} .a4 dt{color:var(--text-secondary)}
.a4 dd{margin:0} .a4 dd{margin:0}
.a4 .disc{margin-top:18px;padding:12px 14px;background:var(--bg-sunken);font-size:12.5px} .a4 .fig{font-size:inherit;font-weight:var(--fw-medium)}
.a4 table{width:100%;border-collapse:collapse;font-size:12.5px} .a4 .disc{margin-top:calc(5 * var(--mm));padding:var(--sp-3) var(--sp-4);border:var(--line) solid var(--c-ink);font-size:var(--rp-sm);line-height:1.6}
.a4 th{text-align:left;font-weight:600;color:var(--text-secondary);border-bottom:1.5px solid var(--c-ink);padding:6px 6px} .a4 .disc b{display:block;margin-bottom:2px}
.a4 td{border-bottom:1px solid var(--border-subtle);padding:7px 6px;vertical-align:top} .a4 table{width:100%;border-collapse:collapse;font-size:var(--rp-sm);line-height:1.55}
.a4 th{text-align:left;font-weight:var(--fw-bold);color:var(--text-secondary);border-bottom:1.5px solid var(--c-ink);padding:var(--sp-1) 6px}
.a4 td{border-bottom:var(--line) solid var(--border-subtle);padding:6px;vertical-align:top}
.a4 .sumtab td,.a4 .sumtab th{text-align:center} .a4 .sumtab td,.a4 .sumtab th{text-align:center}
.a4 .sumtab td:first-child,.a4 .sumtab th:first-child{text-align:left} .a4 .sumtab td:first-child,.a4 .sumtab th:first-child{text-align:left}
.a4 .cites{color:var(--ai);font-size:11.5px} .a4 .sev{font-size:var(--rp-xs)}
.a4 .free{position:absolute;top:30px;right:76px;font-size:11px;border:1px solid var(--border-control);padding:1px 6px;color:var(--text-secondary)} .a4 .flag{margin-left:var(--sp-1);color:var(--text-secondary);font-size:var(--rp-xs);font-weight:var(--fw-regular)}
.a4 .report-foot{position:absolute;left:76px;right:76px;bottom:36px} .a4 .cites{margin-top:2px;color:var(--ai);font-size:var(--rp-xs);line-height:1.5}
.a4 .st-todo{font-weight:600} .a4 .st-todo{font-weight:var(--fw-bold)}
@media (max-width:1400px){.pages{zoom:.86}} .a4 .who{display:block;color:var(--text-secondary);font-size:var(--rp-xs)}
.a4 .cap{color:var(--text-secondary);font-size:var(--rp-sm);margin-top:var(--sp-2)}
/* 打印:只打印 A4 页 */
@page{size:A4;margin:0}
@media print{
.sidebar,.top,.ph,.rp-side,.toasts{display:none !important}
body.app,.main,.content{display:block;padding:0;margin:0;background:none;min-height:0}
.rp{display:block}
.pages{gap:0}
.a4{box-shadow:none;break-after:page}
}
</style> </style>
</head> </head>
<body> <body>
<main> <main>
<div class="content wide"> <div class="content wide">
<div class="rp"> <div class="rp">
<div class="rp-side"> <div class="pages" data-pages aria-label="报告预览"></div>
<div class="card"> <aside class="rp-side">
<h3>导出报告</h3> <section class="card flush" aria-labelledby="h-exp">
<label class="chk"><input type="checkbox" checked disabled><span>问题明细(全部级别)</span></label> <div class="card-h"><h2 id="h-exp">导出 PDF</h2></div>
<label class="chk"><input type="checkbox" checked data-opt="appendix"><span>附录:需人工核对</span></label> <div class="card-b">
<label class="chk"><input type="checkbox" checked data-opt="cites"><span>每条附原文出处</span></label> <div class="choices">
<div class="alert warn" style="margin:var(--sp-3) 0"><span class="ico w">!</span><p class="cap">还有 <b>3 项致命问题</b>待处理,报告会如实列出。</p></div> <label class="choice dis"><input type="checkbox" class="ck" checked disabled><span>问题明细<small>全部级别,必选</small></span></label>
<div class="row"><button class="btn primary" data-dl>下载 PDF</button><button class="btn" onclick="window.print()">打印</button></div> <label class="choice"><input type="checkbox" class="ck" checked data-opt="appendix"><span>附录:需人工核对</span></label>
</div> <label class="choice"><input type="checkbox" class="ck" checked data-opt="cites"><span>每条附原文出处</span></label>
<div class="card"> </div>
<h3>已导出的报告</h3> <p class="hint-warn" data-fatal-hint></p>
<div class="cap"><a class="plain" href="#">11-01 16:12 · 第 1 次检查.pdf</a><br><span class="muted">王敏 导出 · 12 页</span></div> </div>
<p class="note">报告生成后不再改动,随项目归档;重新导出会生成一份新的。</p> <div class="card-f"><button class="btn primary swap" data-dl><span class="idle"><i data-ic="download"></i> 下载 PDF</span><span class="busy">正在生成…</span></button><button class="btn" data-print><i data-ic="printer"></i>打印</button></div>
</div> </section>
</div> <section class="card flush" aria-labelledby="h-done">
<div class="pages" data-pages></div> <div class="card-h"><h2 id="h-done">已导出的报告</h2></div>
<div class="card-b"><ul class="exports" data-exports></ul></div>
<div class="card-f cap muted">导出后不再改动,随项目归档;重新导出会另存一份。</div>
</section>
</aside>
</div> </div>
</div> </div>
</main> </main>
<script src="assets/data.js"></script> <script src="assets/data.js"></script>
<script src="assets/app.js"></script> <script src="assets/app.js"></script>
<script> <script>
const { D, $, $$, esc, toast } = App; const { D, $, $$, esc, icon, toast } = App;
App.shell({ crumbs: [["工作台", "workbench.html"], [D.project.name, "project.html"], ["报告", ""]], tab: "report" }); App.shell({ crumbs: [["工作台", "workbench.html"], [D.project.name, "project.html"], ["报告", ""]], tab: "report" });
const C = D.check, SEV = { fatal: "致命", major: "重要", hint: "提示" };
const ST = (f) => f.status === "ok" ? `已确认无误 · ${f.by} ${f.at}` : '<span class="st-todo">待处理</span>'; const C = D.check, P = D.project, SEV = { fatal: "致命", major: "重要", hint: "提示" };
const head = (n, total) => `<div class="report-head"><img src="../design/logo/svg/logo-horizontal.svg" alt="标博士"><span>投标文件辅助检查报告</span></div>`; const EXPORT_AT = "2026-11-02 15:00"; // 演示里的「现在」
const foot = (n) => `<div class="report-foot"><span>本报告为辅助检查结果,不构成对投标结果的保证</span><span class="fig">${n} / 3</span></div>`; /* 报告和留痕的日期写法:「2026-11-02 14:20」(规范 §9.3) */
const full = (s) => { const m = String(s).match(/(\d+)月(\d+)日\s*(\d{1,2}:\d{2})/); return m ? `2026-${m[1].padStart(2, "0")}-${m[2].padStart(2, "0")} ${m[3]}` : s; };
const fatalLeft = C.findings.filter((f) => f.sev === "fatal" && !f.status).length;
const cnt = (s, st) => C.findings.filter((f) => f.sev === s && (st ? (f.status || "todo") === st : true)).length; const cnt = (s, st) => C.findings.filter((f) => f.sev === s && (st ? (f.status || "todo") === st : true)).length;
const ST = (f) => f.status === "ok" ? `已确认无误<span class="who">${esc(f.by)} · <span class="fig">${full(f.at)}</span></span>` : '<span class="st-todo">待处理</span>';
const citeTxt = (c) => `${esc(D.docs[c[0]].name)} · 第 <span class="fig">${c[1]}</span> 页${c[3] ? `「${esc(c[3])}」` : ""}`;
/* 侧栏:致命问题未处理也能导出,只提示(§8.4) */
$("[data-fatal-hint]").innerHTML = fatalLeft ? `${icon("alert-circle")}<span>还有 <b><span class="fig">${fatalLeft}</span> 项致命问题</b>待处理,报告会如实列出。</span>` : "";
$("[data-fatal-hint]").hidden = !fatalLeft;
/* ---------- 报告页面 ---------- */
const head = `<header class="report-head"><img src="../design/logo/svg/logo-horizontal.svg" alt="标博士"><span>投标文件辅助检查报告</span></header>`;
const FOOT = "本报告为辅助检查结果,仅供投标前自查参考";
const page = (body) => `<section class="a4">${head}<div class="a4-body">${body}</div><footer class="report-foot"><span>${FOOT}</span><span class="fig" data-pno></span></footer></section>`;
function cover() {
return page(`
<h2>${esc(P.name)}</h2><p class="sub">采购包 1 · 智慧大厅软硬件集成</p>
<dl><dt>项目编号</dt><dd class="fig">${P.no}</dd><dt>采购人</dt><dd>${esc(P.buyer)}</dd><dt>法律体系</dt><dd>${P.regime} · ${P.method}</dd>
<dt>投标截止</dt><dd class="fig">${P.deadline}</dd><dt>投标文件</dt><dd>${C.files}</dd><dt>检查依据</dt><dd>招标文件 + 补遗 1;清单 v2(王敏 <span class="fig">2026-11-01 11:20</span> 确认)</dd>
<dt>检查时间</dt><dd><span class="fig">${full(C.time)}</span>(第 ${C.no} 次检查)</dd><dt>规则版本</dt><dd>${C.rules}</dd><dt>导出</dt><dd>王敏 · <span class="fig">${EXPORT_AT}</span></dd></dl>
<h4>检查覆盖范围</h4>
<p><b>查了:</b>清单 26 条的响应完整性;形式要件(格式文件、签章位置有无、落款日期、其他项目名称);资质匹配(以投标截止日判断有效期);报价形式(最高限价、开标一览表与分项价格表合计、大小写)。</p>
<p><b>没查:</b>份数、密封、CA 加密、递交回执(见附录「需人工核对」);签章真伪(只看有无,不鉴定真伪);报价是否合理;评分高低。</p>
<h4>汇总</h4>
<table class="sumtab"><thead><tr><th>级别</th><th>问题数</th><th>待处理</th><th>已确认无误</th><th>已修改</th><th>已忽略</th></tr></thead><tbody>
${["fatal", "major", "hint"].map((s) => `<tr><td><span class="sev ${s}">${SEV[s]}</span></td><td class="fig">${cnt(s)}</td><td class="fig">${cnt(s, "todo")}</td><td class="fig">${cnt(s, "ok")}</td><td class="fig">0</td><td class="fig">0</td></tr>`).join("")}</tbody></table>
<p class="cap">本次已解决 ${C.resolved} 项(上次检查有、这次没有再发现)。</p>
<div class="disc"><b>免责声明</b>本报告由标博士比对招标文件和投标文件生成,经用户逐项处理后导出,供投标前自查使用。检查范围以上方「检查覆盖范围」为准;结果可能有遗漏或误判,不代表资格审查、符合性审查或评标的结论。是否递交、如何修改,由投标人自行决定。</div>`);
}
/* 表格按 A4 高度续页:逐行放入,放不下就另起一页并重复表头 */
function flowTable(title, thead, rows, after = "") {
const box = $("[data-pages]");
let body, tb;
const fresh = (cont) => {
box.insertAdjacentHTML("beforeend", page(`<h4 class="first">${title}${cont ? "(续)" : ""}</h4><table>${thead}<tbody></tbody></table>`));
body = box.lastElementChild.querySelector(".a4-body"); tb = body.querySelector("tbody");
};
fresh(false);
rows.forEach((r) => {
tb.insertAdjacentHTML("beforeend", r);
if (body.scrollHeight > body.clientHeight && tb.rows.length > 1) { tb.lastElementChild.remove(); fresh(true); tb.insertAdjacentHTML("beforeend", r); }
});
if (after) { body.insertAdjacentHTML("beforeend", after); if (body.scrollHeight > body.clientHeight) { body.lastElementChild.remove(); box.insertAdjacentHTML("beforeend", page(after)); } }
}
function render() { function render() {
const cites = $("[data-opt=cites]").checked, appx = $("[data-opt=appendix]").checked; const cites = $("[data-opt=cites]").checked, appx = $("[data-opt=appendix]").checked;
const p1 = `<section class="a4">${head()} const box = $("[data-pages]");
<h2>${D.project.name}</h2><p class="sub">采购包 1 · 智慧大厅软硬件集成</p> box.innerHTML = cover();
<dl><dt>项目编号</dt><dd>${D.project.no}</dd><dt>采购人</dt><dd>${D.project.buyer}</dd><dt>法律体系</dt><dd>政府采购 · 公开招标</dd> flowTable("问题明细", `<thead><tr><th style="width:15mm">级别</th><th>问题</th><th style="width:36mm">处理</th></tr></thead>`,
<dt>投标截止</dt><dd>2026-11-20 09:30</dd><dt>投标文件</dt><dd>${C.files}</dd><dt>检查依据</dt><dd>招标文件 + 补遗 1;清单 v2(王敏 2026-11-01 11:20 确认)</dd> C.findings.map((f) => `<tr><td><span class="sev ${f.sev}">${SEV[f.sev]}</span></td>
<dt>检查时间</dt><dd>2026-11-02 14:20(第 ${C.no} 次检查)</dd><dt>规则版本</dt><dd>${C.rules}</dd><dt>导出</dt><dd>王敏 · 2026-11-02 15:20</dd></dl> <td><b>${esc(f.title)}</b>${f.flag ? `<span class="flag">${esc(f.flag.split(" · ")[0])}</span>` : ""}<br>${esc(f.text[0])}${cites ? `<div class="cites">${f.cites.map(citeTxt).join(";")}</div>` : ""}</td>
<h4>检查覆盖范围</h4> <td>${ST(f)}</td></tr>`));
<p><b>查了:</b>清单 26 条的响应完整性;形式要件(格式文件、签章位置有无、落款日期、其他项目名称);资质匹配(以投标截止日判断有效期);报价形式(限价、开标一览表与分项合计、大小写)。</p> if (appx) flowTable("附录 · 需人工核对", `<thead><tr><th>要求</th><th style="width:36mm">出处</th><th style="width:30mm">核对</th></tr></thead>`,
<p><b>没查:</b>份数、密封、CA 加密、递交回执(见附录「需人工核对」);签章真伪;报价是否合理;评分高低。</p> C.manual.map((m) => `<tr><td>${esc(m.title)}</td><td class="cites">${citeTxt(m.cite)}</td><td>未勾选</td></tr>`),
<h4>汇总</h4> `<p class="cap">「未勾选」表示导出时还没有人在系统里勾选核对。勾选结果按项目保存,写明勾选人和时间。</p>`);
<table class="sumtab"><tr><th>级别</th><th>问题数</th><th>待处理</th><th>已确认无误</th><th>已修改</th><th>已忽略</th></tr> const pages = $$(".a4", box);
${["fatal", "major", "hint"].map((s) => `<tr><td><span class="sev ${s}">${SEV[s]}</span></td><td class="fig">${cnt(s)}</td><td class="fig">${cnt(s, "todo")}</td><td class="fig">${cnt(s, "ok")}</td><td class="fig">0</td><td class="fig">0</td></tr>`).join("")}</table> pages.forEach((p, i) => { $("[data-pno]", p).textContent = `${i + 1} / ${pages.length}`; });
<p class="cap muted" style="margin-top:8px">本次已解决 ${C.resolved} 项(上次检查有、这次没有再发现)。</p> return pages.length;
<div class="disc">本报告由标博士根据招标文件和投标文件自动比对生成,经用户逐项处理后导出,用于投标前自查。检查范围以上方「检查覆盖范围」为准,结论可能存在遗漏或误判,不构成对资格审查、符合性审查或评标结果的任何保证。</div>
${foot(1)}</section>`;
const p2 = `<section class="a4">${head()}<h4 style="margin-top:28px">问题明细</h4>
<table><tr><th style="width:52px">级别</th><th>问题</th><th style="width:150px">处理</th></tr>
${C.findings.map((f) => `<tr><td><span class="sev ${f.sev}">${SEV[f.sev]}</span></td><td><b>${esc(f.title)}</b><br>${esc(f.text[0])}${cites ? `<div class="cites">${f.cites.map((c) => `${D.docs[c[0]].name} 第 ${c[1]} 页${c[3] ? `「${esc(c[3])}」` : ""}`).join(";")}</div>` : ""}</td><td>${ST(f)}</td></tr>`).join("")}</table>${foot(2)}</section>`;
const p3 = appx ? `<section class="a4">${head()}<h4 style="margin-top:28px">附录 · 需人工核对</h4>
<table><tr><th>要求</th><th style="width:130px">出处</th><th style="width:150px">核对</th></tr>
${C.manual.map((m) => `<tr><td>${m.title}</td><td class="cites">${D.docs[m.cite[0]].name} 第 ${m.cite[1]} 页</td><td>未勾选</td></tr>`).join("")}</table>
<p class="cap muted" style="margin-top:10px">「未勾选」表示导出时用户尚未在系统里确认核对。</p>${foot(3)}</section>` : "";
$("[data-pages]").innerHTML = p1 + p2 + p3;
} }
$$("[data-opt]").forEach((c) => (c.onchange = render));
$("[data-dl]").onclick = () => toast("报告已导出(原型不生成文件)"); /* ---------- 已导出的报告 ---------- */
render(); const EXPORTS = [{ no: 1, at: "11月1日 16:12", pages: 3 }];
function exportsList() {
$("[data-exports]").innerHTML = EXPORTS.map((x) => `<li class="${x.isNew ? "new" : ""}"><div><b>第 ${x.no} 次检查</b><small><span class="fig">${x.at}</span> · 王敏 · <span class="fig">${x.pages}</span> 页</small></div>
<button class="icon-btn sm tip-left" aria-label="下载第 ${x.no} 次检查的报告" data-tip="下载" data-get>${icon("download")}</button></li>`).join("");
}
$("[data-exports]").addEventListener("click", (e) => { if (e.target.closest("[data-get]")) toast("(原型)下载已归档的报告", { ok: false }); });
let total = 0;
$$("[data-opt]").forEach((c) => (c.onchange = () => { total = render(); }));
$("[data-print]").onclick = () => window.print();
$("[data-dl]").onclick = (e) => {
const b = e.currentTarget; 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");
EXPORTS.forEach((x) => delete x.isNew);
EXPORTS.unshift({ no: C.no, at: "11月2日 15:00", pages: total, isNew: true });
exportsList();
toast("报告已导出(原型不生成文件)");
}, 900);
};
exportsList();
(document.fonts ? document.fonts.ready : Promise.resolve()).then(() => { total = render(); });
</script> </script>
</body> </body>
</html> </html>

View File

@@ -1,274 +1,340 @@
<!doctype html> <!doctype html>
<!--
查标(v0.2):问题列表按 design/ui-demo/lists.html 的「03 问题列表」重刷;项目头和标签页来自 App.shell({ tab })。
批注布局:问题 + 右侧页边批注(出处);点出处从右侧滑出原文抽屉(App.drawer)。
交互:J / K 移动当前条,O / Enter 打开原文,1 / 2 / 3 处理,X 勾选「提示」,U 撤销当前条,? 快捷键说明。
本次处理过的问题留在原位(署名行),J / K 可以回到它按 U 撤销;页面加载前已处理的折叠在「已处理」里(§8.2)。
忽略、致命问题的「确认无误」在行内展开说明框(U4),不弹对话框。批量只开放给「提示」。
-->
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>查标 · 标博士原型</title> <title>查标 · 标博士原型</title>
<link rel="icon" href="../design/logo/favicon.svg">
<link rel="stylesheet" href="../design/system/tokens.css"> <link rel="stylesheet" href="../design/system/tokens.css">
<link rel="stylesheet" href="../design/system/fonts-local.css"> <link rel="stylesheet" href="../design/system/fonts-local.css">
<link rel="stylesheet" href="../design/system/components.css"> <link rel="stylesheet" href="../design/system/components.css">
<link rel="stylesheet" href="assets/app.css"> <link rel="stylesheet" href="assets/app.css">
<style> <style>
.res-bar{display:flex;align-items:flex-end;gap:var(--sp-4);flex-wrap:wrap} /* 结果栏:标题 + 本次检查的信息;右侧页面级操作(主按钮一个:导出报告) */
.res-bar h2{font:var(--fw-bold) var(--fs-h2)/var(--lh-h2) var(--font-serif);margin:0} .rbar{display:flex;align-items:flex-end;gap:var(--sp-4);margin-bottom:var(--sp-4)}
.res-bar .actions{margin-left:auto;display:flex;align-items:center;gap:var(--sp-3)} .rbar > div:first-child{min-width:0}
.filters{display:flex;align-items:center;gap:var(--sp-3);margin:var(--sp-5) 0 var(--sp-3)} .rbar h2{margin:0;font:var(--fw-bold) var(--fs-title)/var(--lh-title) var(--font-sans)}
.filters .right{margin-left:auto;display:flex;gap:var(--sp-2);align-items:center} .rbar .meta{margin:2px 0 0;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.finding{outline:none} .rbar .meta .fig{font-size:var(--fs-figure-xs);font-weight:var(--fw-medium);color:var(--text-secondary)}
.finding.focus{box-shadow:inset 0 0 0 2px var(--c-ink)} .rbar .actions{margin-left:auto;display:flex;flex-direction:column;align-items:flex-end;gap:6px;flex:none}
.finding.leaving{opacity:.3;transition:opacity var(--dur-base)} .rbar .actions .row{display:flex;gap:var(--sp-2);align-items:center}
.u4{margin-top:var(--sp-3);padding:var(--sp-3) var(--sp-4);background:var(--bg-sunken);border-radius:var(--radius-sm)} .rbar .actions .cost{margin-right:var(--sp-2)}
.u4 label{font-weight:var(--fw-bold);display:block;margin-bottom:6px} .rbar .export-note{font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary);white-space:nowrap}
.u4 .row{margin-top:var(--sp-2)}
.group-h{display:flex;align-items:center;gap:var(--sp-2);width:100%;padding:var(--sp-3) var(--sp-6);border:0;border-top:1px solid var(--border-subtle);background:var(--bg-sunken);color:var(--text-secondary);font:var(--fs-body) var(--font-sans);cursor:pointer;text-align:left} /* 卡片头:筛选 + 右侧全选「提示」、快捷键 */
.group-h:hover{color:var(--text-primary)} .card-h .r{margin-left:auto;display:flex;gap:var(--sp-3);align-items:center}
.group-h .chev{transition:transform var(--dur-fast)} .card-h .choice{align-items:center;white-space:nowrap}
.group-h[aria-expanded="true"] .chev{transform:rotate(90deg)} .card-h .choice .ck{margin-top:0}
.processed .finding{background:#FCFCFB} .card-h .cap{color:var(--text-secondary)}
.resolved-row{display:flex;gap:var(--sp-3);padding:var(--sp-3) var(--sp-6);border-top:1px solid var(--border-subtle);color:var(--text-secondary)}
.resolved-row s{color:var(--text-primary)} /* 问题列表放进卡片:第一条也要和卡片头隔开 */
.empty-todo{padding:var(--sp-10) var(--sp-6);text-align:center;color:var(--text-secondary)} .flist{outline:0}
.sign .note-text{color:var(--text-secondary)} .flist .finding:first-of-type,.flist > .empty{border-top:var(--line) solid var(--border-subtle)}
.finding{cursor:default}
.finding .cite .cite-src{cursor:pointer}
.finding .sign{margin-top:var(--sp-3)}
.results + .card{margin-top:var(--gap-card)}
/* 本次已解决:上次有、这次没再发现,自动关闭(FR-M3-08) */
.group-h .sub{margin-left:var(--sp-1);font-size:var(--fs-caption);color:var(--text-secondary)}
.resolved{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:var(--sp-3);align-items:center;padding:var(--sp-3) var(--card-x);border-top:var(--line) solid var(--border-subtle);color:var(--text-secondary)}
.resolved s{color:var(--text-primary);text-decoration-color:var(--text-secondary)}
.resolved .cap{white-space:nowrap}
/* 需人工核对:勾选后右侧出署名行 */
.manual .card-h > .cap:not(.r){margin-left:var(--sp-1)}
.manual .card-h .r .fig{font-size:var(--fs-figure-xs)}
.mrow{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:var(--sp-4);align-items:start;padding:var(--sp-3) var(--card-x);border-top:var(--line) solid var(--border-subtle)}
.mrow .choice .ck{margin-top:3px}
.mrow .sign.sm{margin:2px 0 0;padding-left:24px}
.mrow .cite-link{font-size:var(--fs-caption);margin:0 -6px}
.mrow .cite-link .fig{font-size:var(--fs-figure-xs)}
/* 反馈对话框 */
.fb .choices{margin-top:var(--sp-3)}
</style> </style>
</head> </head>
<body> <body>
<main> <main>
<div class="content wide"> <div class="content">
<div class="res-bar"> <div class="rbar">
<div> <div>
<h2>采购包 1 的检查结果</h2> <h2>采购包 1 的检查结果</h2>
<p class="muted cap" style="margin:4px 0 0">第 <span class="fig">2</span> 次检查 · 11月2日 14:20 · 商务册 v3、技术册 v3、报价册 v2 · 规则版本 IT 集成 2026.11 · 清单版本 v2(已确认)</p> <p class="meta" data-meta></p>
</div> </div>
<div class="actions"> <div class="actions">
<span class="cost">24 小时内复查不扣额度(至今天 16:00)</span> <div class="row">
<a class="btn" href="bid-check.html">重新检查</a> <span class="cost"><span class="hint-u tip-below tip-left" tabindex="0" data-tip="从上一次扣点的检查(11月1日 16:00)算起,至今天 16:00。窗口内复查不扣额度,也不延长窗口。">24 小时内复查,不扣额度</span></span>
<a class="btn primary" href="report.html" data-export>导出报告</a> <a class="btn" href="bid-check.html">重新检查</a>
</div> <a class="btn primary" href="report.html"><i data-ic="download"></i>导出报告</a>
</div> </div>
<p class="cap" data-export-note style="text-align:right;margin:6px 0 0;color:var(--text-secondary)"></p> <span class="export-note" data-export-note></span>
<div class="filters">
<div class="seg" role="group" aria-label="按级别筛选" data-seg>
<button class="on" data-f="all">全部 <span class="fig" data-c="all"></span></button>
<button data-f="fatal"><span class="sev fatal">致命<span class="fig" data-c="fatal"></span></span></button>
<button data-f="major"><span class="sev major">重要<span class="fig" data-c="major"></span></span></button>
<button data-f="hint"><span class="sev hint">提示<span class="fig" data-c="hint"></span></span></button>
</div>
<span class="cap muted">只显示待处理的问题;已处理的在列表底部。</span>
<div class="right">
<button class="btn sm" data-batch hidden>批量确认无误(<span class="fig" data-batch-n></span>)</button>
<button class="btn sm text" data-keys>快捷键 <span class="kbd">?</span></button>
</div> </div>
</div> </div>
<div class="results" data-list></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>
<div class="section-h"><h2>需人工核对</h2><span class="cap muted">这些要求从文件里看不出来,请递交前自己核对并勾选。勾选结果会写进报告附录。</span></div> <section class="results card flush" aria-label="检查结果">
<div class="card" data-manual></div> <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" data-keys>快捷键 <span class="kbd">?</span></button>
</div>
</div>
<div class="flist" data-flist tabindex="0" aria-label="问题列表:J K 移动,1 2 3 处理,U 撤销当前条"></div>
</section>
<section class="card flush manual" aria-labelledby="h-manual">
<div class="card-h">
<h2 id="h-manual">需人工核对</h2>
<span class="cap">文件里看不出来,递交前请自己核对。勾选按项目保存,写进报告附录。</span>
<span class="r cap">已核对 <span class="fig" data-mn>0</span> / <span class="fig" data-mt></span></span>
</div>
<div data-manual></div>
</section>
</div> </div>
</main> </main>
<aside class="drawer" aria-label="原文" data-drawer><div data-viewer></div></aside>
<script src="assets/data.js"></script> <script src="assets/data.js"></script>
<script src="assets/app.js"></script> <script src="assets/app.js"></script>
<script> <script>
const { D, $, $$, esc, cite, dialog, toast } = App; const { D, $, $$, esc, icon, toast, dialog } = App;
App.shell({ crumbs: [["工作台", "workbench.html"], [D.project.name, "project.html"], ["查标", ""]], tab: "results" }); App.shell({ crumbs: [["工作台", "workbench.html"], [D.project.name, "project.html"], ["查标", ""]], tab: "results" });
const C = D.check;
const SEV = { fatal: "致命", major: "重要", hint: "提示" }; const SEV = { fatal: "致命", major: "重要", hint: "提示" };
const ACT = { ok: "确认无误", fixed: "已修改", ignored: "已忽略" }; const ACT = { ok: "确认无误", fixed: "已修改", ignored: "已忽略" };
const F = D.check.findings.map((f) => ({ status: "todo", ...f })); /* status:undefined 待处理 / ok / fixed / ignored;session:本次处理的(留在原位) */
let filter = "all", focusId = null, lastHandled = null; const F = C.findings.map((f) => ({ ...f, ck: false, open: null, draft: "", err: false, session: false }));
let ff = "", fcur = 0, foldOpen = false, resOpen = false, tick = 1;
const stamp = () => `11月2日 15:${String(tick++).padStart(2, "0")}`; // 演示里的「现在」是 11月2日 15:00
/* 原文抽屉 */ $("[data-meta]").innerHTML = `第 <span class="fig">${C.no}</span> 次检查 · ${C.time.replace(/(\d+)/g, '<span class="fig">$1</span>')} · ${C.files} · 规则 ${C.rules} · 清单 v2(已确认)`;
const drawer = $("[data-drawer]"); $("[data-meta]").title = $("[data-meta]").textContent;
const V = App.viewer($("[data-viewer]"), { closable: true, onClose: () => drawer.classList.remove("open"), onOpen: () => drawer.classList.add("open") });
document.addEventListener("click", (e) => { /* ---------- 原文抽屉:点出处、O / Enter ---------- */
const a = e.target.closest("[data-open]"); const DR = App.drawer();
if (!a) return; function openCite(f, c) {
e.preventDefault(); DR.open(c, f ? f.cites : [c]);
const f = F.find((x) => x.id === a.closest("[data-id]")?.dataset.id); $$(".cite.on").forEach((x) => x.classList.remove("on"));
V.open(a.dataset.open.split("|").map((v, i) => i === 1 ? +v : v), f ? f.cites : null); if (f) { const i = f.cites.findIndex((x) => x.join("|") === c.join("|")); $$(`[data-fid="${f.id}"] aside > .cite`)[i]?.classList.add("on"); }
$$(".cite.on").forEach((x) => x.classList.remove("on")); a.closest(".cite")?.classList.add("on"); }
/* ---------- 问题行 ---------- */
const flist = $("[data-flist]");
const ftodo = () => F.filter((f) => (!f.status || f.session) && (!ff || f.sev === ff));
const fatalLeft = () => F.filter((f) => f.sev === "fatal" && !f.status).length;
/* 出处:招标原文 → 投标原文;OCR 页加小字;「未找到」也是出处,写明找过哪里 */
function citesHTML(f) {
return f.cites.map((c) => {
let h = App.cite(c);
if (c[0] === "B1" && c[1] === 3) h = h.replace("</a>", '<span class="ocr">OCR</span></a>');
return h;
}).join("") + (f.missing ? `<div class="cite missing"><span class="cite-src">投标文件 · 未找到</span><p>${esc(f.missing.replace(/^.*?· /, ""))}</p></div>` : "");
}
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 signHTML(f) {
const ign = f.status === "ignored";
return `<div class="sign ${ign ? "ign" : ""}"><span class="ok" aria-hidden="true">${ign ? "–" : "✓"}</span><b>${esc(f.by)}</b><span class="fig">${f.at}</span><span>${ACT[f.status]}</span><button type="button" class="undo" data-undo>撤销</button></div>`
+ (f.note ? `<p class="sign-note">${ign ? "原因" : "说明"}:${esc(f.note)}${!ign && f.sev === "fatal" ? " · 已作为误报反馈提交" : ""}</p>` : "");
}
function findingHTML(f, isCur) {
const ck = f.status ? "" : f.sev === "hint"
? `<label class="ckw tip-start" data-tip="批量处理"><input type="checkbox" class="ck" data-fck ${f.ck ? "checked" : ""} aria-label="勾选:${esc(f.title)}"></label>`
: `<span class="ckw tip-start" data-tip="只有「提示」可以批量处理"><input type="checkbox" class="ck" disabled aria-label="不可批量"></span>`;
const body = f.status ? signHTML(f) : 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="#" data-feedback>判断有误?</a></div>`;
return `<article class="finding ${f.status ? "" : "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">${esc(f.title)}</span>${f.flag ? `<span class="flag">${esc(f.flag)}</span>` : ""}</div>
${f.text.map((t) => `<p class="f-text">${esc(t)}</p>`).join("")}${body}</div>
<aside>${citesHTML(f)}</aside></article>`;
}
const RESOLVED = [["major", "未找到售后服务方案中「响应时间要求」的响应"], ["hint", "投标文件中项目编号写成「ZC-2026-0481」"]];
function render() {
const todo = ftodo(), done = F.filter((f) => f.status && !f.session);
["", "fatal", "major", "hint"].forEach((k) => { $(`[data-c="${k}"]`).textContent = F.filter((f) => !f.status && (!k || f.sev === k)).length; });
fcur = Math.min(fcur, Math.max(todo.length - 1, 0));
flist.innerHTML = (todo.map((f, i) => findingHTML(f, i === fcur)).join("")
|| `<div class="empty sm"><b>${ff ? "这个级别的问题都已处理" : "全部问题都已处理"}</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("") : "")
+ `<button class="group-h" data-res aria-expanded="${resOpen}">${icon(resOpen ? "chev-down" : "chev-right")}本次已解决(<span class="fig">${C.resolved}</span>)<span class="sub">上次有、这次没再发现,已自动关闭</span></button>`
+ (resOpen ? RESOLVED.map(([s, t]) => `<div class="resolved"><span class="sev ${s}">${SEV[s]}</span><s>${t}</s><span class="cap">上次:已修改</span></div>`).join("") : "");
// 全选「提示」、批量栏
const hints = todo.filter((f) => f.sev === "hint" && !f.status), n = F.filter((f) => f.ck && !f.status).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;
// 项目头的致命数、就绪;导出旁的提示(不阻断,§8.4)
const fatal = fatalLeft();
App.projHead({ fatal });
$("[data-export-note]").textContent = fatal ? `还有 ${fatal} 项致命问题待处理,报告会如实列出` : "";
}
const focusList = () => flist.focus({ preventScroll: true });
const curF = () => ftodo()[fcur];
function openU4(f, kind) {
f.open = kind; fcur = Math.max(0, ftodo().indexOf(f)); render();
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 ? "ok" : "fixed");
}
function submitU4(f) {
const v = f.draft.trim();
if (v.length < 5) { f.err = true; render(); $(`#u4t-${f.id}`).focus(); return; }
const kind = f.open === "ign" ? "ignored" : "ok"; f.open = null; f.draft = ""; f.err = false;
done(f, kind, v);
}
function done(f, status, note = "") {
Object.assign(f, { status, by: D.user.name, at: stamp(), note, session: true, ck: false });
const vis = ftodo(), nx = vis.findIndex((g, i) => i > vis.indexOf(f) && !g.status);
if (nx >= 0) fcur = nx;
render(); focusList();
toast(`已标记「${ACT[status]}」`, { ok: status !== "ignored", undo: () => { undo(f); toast("已撤销", { ok: false }); } });
}
function undo(f) {
Object.assign(f, { status: undefined, by: "", at: "", note: "", session: false });
const i = ftodo().indexOf(f); if (i >= 0) fcur = i;
render();
}
/* 筛选、全选、批量 */
$("[data-fseg]").addEventListener("change", (e) => { ff = e.detail || ""; fcur = 0; render(); });
$("[data-fall]").addEventListener("change", (e) => { ftodo().filter((f) => f.sev === "hint" && !f.status).forEach((f) => { f.ck = e.target.checked; }); render(); });
$("[data-bok]").onclick = () => {
const sel = F.filter((f) => f.ck && !f.status), at = stamp();
sel.forEach((f) => Object.assign(f, { status: "ok", by: D.user.name, at, note: "", session: true, ck: false }));
render();
toast(`已将 ${sel.length} 项「提示」标记为确认无误,逐条记了处理人和时间`, { undo: () => { sel.forEach(undo); toast("已撤销批量处理", { ok: false }); } });
};
$("[data-bclear]").onclick = () => { F.forEach((f) => { f.ck = false; }); render(); };
$("[data-keys]").onclick = () => App.keysDialog(App.KEYS.check);
/* 列表里的点击 */
flist.addEventListener("click", (e) => {
const t = e.target, row = t.closest("[data-fid]"), f = row && F.find((x) => x.id === row.dataset.fid);
if (t.closest("[data-fold]")) { foldOpen = !foldOpen; render(); return; }
if (t.closest("[data-res]")) { resOpen = !resOpen; render(); return; }
if (!f) return;
if (t.matches("[data-fck]")) { f.ck = t.checked; render(); 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; render(); focusList(); return; }
if (t.closest("[data-u4]")) return;
if (t.closest("[data-feedback]")) { e.preventDefault(); feedback(f); return; }
const a = t.closest("[data-open]");
if (a) { e.preventDefault(); openCite(f, App.parseCite(a.dataset.open)); }
// 点哪条,哪条就是当前条
const i = ftodo().indexOf(f);
if (i >= 0 && i !== fcur && !t.closest(".ckw")) {
fcur = i; render(); if (!a) focusList();
if (a) openCite(f, App.parseCite(a.dataset.open)); // 重绘后把高亮的出处补回去
}
});
flist.addEventListener("input", (e) => {
if (!e.target.matches("[data-u4t]")) return;
const box = e.target.closest("[data-u4]"), f = F.find((x) => x.id === 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]; }
}); });
function sign(f) { /* ---------- 键盘逐条处理(§8.2):在页面任何地方都能用;输入框、对话框、菜单里不响应 ---------- */
return `<div class="sign" ${f.status === "ignored" ? 'style="color:var(--text-secondary)"' : ""}><span>${f.status === "ignored" ? "–" : "✓"}</span><b>${f.by}</b><span class="fig">${f.at}</span><span>${f.status === "ok" ? "已确认无误" : f.status === "fixed" ? "已修改" : "已忽略"}</span>${f.note ? `<span class="note-text">· ${esc(f.note)}</span>` : ""}<a class="plain undo" href="#" data-undo>撤销</a></div>`; document.addEventListener("keydown", (e) => {
} const t = e.target;
function row(f) { if (t.matches && t.matches("[data-u4t]")) {
const cites = f.cites.map((c) => cite(c)).join("") + (f.missing ? `<div class="cite missing"><span class="cite-src">投标文件 · 未找到</span><p>${f.missing.replace(/^.*?· /, "")}</p></div>` : ""); const f = F.find((x) => x.id === t.closest("[data-fid]").dataset.fid);
const actions = f.status === "todo" if (e.key === "Escape") { e.preventDefault(); f.open = null; f.err = false; render(); focusList(); }
? `<div class="f-actions"> if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); submitU4(f); }
<button class="btn ${f.sev === "hint" ? "sm" : ""}" data-act="ok" title="我核对过,投标文件这里没问题">确认无误</button> return;
<button class="btn ${f.sev === "hint" ? "sm" : ""}" data-act="fixed" title="我已在投标文件里修改,复查时会再查">已修改</button> }
<button class="btn ${f.sev === "hint" ? "sm" : ""}" data-act="ignored" title="知道这个问题,决定不处理,需要填原因">忽略并注明原因</button> if ($(".scrim") || t.closest?.("input, textarea, select, .menu, .pop") || e.ctrlKey || e.metaKey || e.altKey) return;
<a class="plain feedback" href="#" data-feedback>判断有误?</a></div>` const todo = ftodo(), f = todo[fcur], k = e.key.toLowerCase();
: sign(f); if (e.key === "?") { e.preventDefault(); App.keysDialog(App.KEYS.check); return; }
return `<article class="finding ${f.sev === "fatal" ? "is-fatal" : ""} ${focusId === f.id ? "focus" : ""}" data-id="${f.id}" tabindex="-1"> if (k === "u") {
<div> if (f && f.status) { undo(f); toast(`已撤销「${esc(f.title)}」的处理`, { ok: false }); }
<div class="f-head"><span class="sev ${f.sev}">${SEV[f.sev]}</span><span class="f-title">${esc(f.title)}</span>${f.flag ? `<span class="flag">${f.flag}</span>` : ""}</div> else toast("当前这一条还没有处理", { ok: false });
${f.text.map((t) => `<p class="f-text">${esc(t)}</p>`).join("")} return;
${actions} }
</div> if (!f) return;
<aside>${cites}</aside> if (k === "j" || k === "k") {
</article>`; e.preventDefault();
} fcur = Math.max(0, Math.min(todo.length - 1, fcur + (k === "j" ? 1 : -1)));
let openProcessed = false, openResolved = false; render(); flist.classList.add("kb-on"); focusList();
function render() { $(".finding.sel").scrollIntoView({ block: "nearest" });
const todo = F.filter((f) => f.status === "todo"); if (DR.isOpen()) openCite(todo[fcur], todo[fcur].cites[0]); // 抽屉开着时跟着翻到当前条的出处
const shown = todo.filter((f) => filter === "all" || f.sev === filter); } else if (k === "o" || (e.key === "Enter" && !t.closest("button, a, label"))) {
const done = F.filter((f) => f.status !== "todo"); e.preventDefault(); openCite(f, f.cites[0]);
const n = (s) => todo.filter((f) => f.sev === s).length; } else if (k === "x" && f.sev === "hint" && !f.status) {
$("[data-c=all]").textContent = todo.length; f.ck = !f.ck; render();
["fatal", "major", "hint"].forEach((s) => ($(`[data-c=${s}]`).textContent = n(s))); } else if (["1", "2", "3"].includes(e.key) && !f.status && !f.open) {
$("[data-list]").innerHTML = e.preventDefault(); act(f, +e.key);
(shown.length ? shown.map(row).join("") : `<div class="empty-todo">${filter === "all" ? "全部问题都已处理。报告会列出每条的处理人、时间和原因。" : "这个级别没有待处理的问题。"}</div>`) + }
`<button class="group-h" data-toggle="p" aria-expanded="${openProcessed}"><span class="chev">›</span>已处理(<span class="fig">${done.length}</span>)</button> });
<div class="processed" ${openProcessed ? "" : "hidden"}>${done.map(row).join("")}</div> flist.addEventListener("pointerdown", () => flist.classList.remove("kb-on"));
<button class="group-h" data-toggle="r" aria-expanded="${openResolved}"><span class="chev">›</span>本次已解决(<span class="fig">${D.check.resolved}</span>)· 上次检查有、这次没有再发现的问题,自动关闭</button>
<div ${openResolved ? "" : "hidden"}>
<div class="resolved-row"><span class="sev major">重要</span><s>未找到售后服务方案中「响应时间要求」的响应</s><span class="cap">上次:已修改</span></div>
<div class="resolved-row"><span class="sev hint">提示</span><s>投标文件中项目编号写成「ZC-2026-0481」</s><span class="cap">上次:已修改</span></div>
</div>`;
// 就绪状态、页签计数、导出提示、批量
const fatal = n("fatal");
$$("[data-fatal-count]").forEach((el) => (el.outerHTML = fatal ? `<span class="sev fatal" data-fatal-count>致命<span class="fig">${fatal}</span></span>` : `<span data-fatal-count></span>`));
$("[data-ready]").innerHTML = fatal ? "未就绪" : `<span class="tag status" title="只表示致命问题都已处理,不代表投标文件没有问题">就绪</span>`;
$("[data-export-note]").textContent = fatal ? `还有 ${fatal} 项致命问题待处理,报告会如实列出。` : "";
const hints = todo.filter((f) => f.sev === "hint").length;
$("[data-batch]").hidden = !(filter === "hint" && hints > 1);
$("[data-batch-n]").textContent = hints;
}
function handle(id, act, extra = {}) { /* ---------- 判断有误?(J5):反馈不改变处理状态 ---------- */
const f = F.find((x) => x.id === id); function feedback(f) {
const el = $(`[data-id="${id}"]`);
const apply = () => {
Object.assign(f, { status: act, by: D.user.name, at: "11月2日 15:02", ...extra });
lastHandled = id;
const next = nextTodo(id);
focusId = next;
render();
toast(`「${esc(f.title)}」已标记为${act === "ok" ? "已确认无误" : act === "fixed" ? "已修改" : "已忽略"} · <a href="#" data-toast-undo>撤销</a>`);
};
if (el) { el.classList.add("leaving"); setTimeout(apply, 160); } else apply();
}
function undo(id) {
const f = F.find((x) => x.id === id);
Object.assign(f, { status: "todo", note: "", by: "", at: "" });
focusId = id;
render();
toast(`已撤销:「${esc(f.title)}」回到待处理`);
}
function nextTodo(id) {
const list = F.filter((f) => f.status === "todo" && f.id !== id && (filter === "all" || f.sev === filter));
const all = F.filter((f) => filter === "all" || f.sev === filter);
const i = all.findIndex((f) => f.id === id);
return (list.find((f) => all.indexOf(f) > i) || list[0] || {}).id || null;
}
function confirmOk(id) {
const f = F.find((x) => x.id === id);
if (f.sev !== "fatal") return handle(id, "ok");
// U4:致命问题确认无误也要写说明
const el = $(`[data-id="${id}"] .f-actions`);
if ($(".u4", el.parentNode)) return;
el.insertAdjacentHTML("afterend", `<div class="u4"><label for="u4-${id}">说明为什么没有问题(必填)</label>
<textarea class="input" id="u4-${id}" placeholder="例如:内存配置在技术册第 15 页的配置清单里已写为 512G,偏离表是笔误,已另行更正"></textarea>
<p class="cap muted" style="margin:6px 0 0">致命问题被确认为没问题,等于系统可能判断错了。这条说明会进入处理记录,也会作为误报反馈发给我们。</p>
<div class="row"><button class="btn primary sm" data-u4-ok>确认无误</button><button class="btn sm" data-u4-cancel>取消</button><span class="cap" data-u4-err></span></div></div>`);
const box = $(".u4", el.parentNode);
$("textarea", box).focus();
$("[data-u4-cancel]", box).onclick = () => box.remove();
$("[data-u4-ok]", box).onclick = () => {
const v = $("textarea", box).value.trim();
if (v.length < 5) {
$("[data-u4-err]", box).innerHTML = '<span style="color:var(--warn-text)">! 请至少写 5 个字</span>';
$("textarea", box).style.borderColor = "var(--warn)";
return;
}
handle(id, "ok", { note: v });
};
}
function ignore(id) {
const f = F.find((x) => x.id === id);
dialog({
title: "忽略这个问题?",
body: `<p>「${esc(f.title)}」</p>
<div class="field" data-field><label for="ig">忽略原因 <small>(必填,至少 5 个字)</small></label>
<textarea class="input" id="ig" placeholder="说明为什么不处理这一项"></textarea>
<div class="chips">${["招标文件允许", "已在其他分册响应", "与本项目无关"].map((c) => `<button class="btn" type="button" data-chip>${c}</button>`).join("")}</div>
<span class="msg">原因会写进处理记录和检查报告。</span></div>`,
ok: "忽略并记录原因",
onOpen: (s) => $$("[data-chip]", s).forEach((b) => (b.onclick = () => { $("#ig", s).value = b.textContent + ":"; $("#ig", s).focus(); })),
onOk: (s) => {
const v = $("#ig", s).value.trim();
if (v.replace(/[::]$/, "").length < 5) {
$("[data-field]", s).classList.add("err");
$(".msg", s).innerHTML = '<span class="ico w">!</span>请写清楚原因,至少 5 个字';
return false;
}
handle(id, "ignored", { note: v });
}
});
}
function feedback(id) {
const f = F.find((x) => x.id === id);
dialog({ dialog({
title: "反馈:判断有误", title: "反馈:判断有误",
body: `<p class="muted">「${esc(f.title)}」</p> body: `<div class="fb"><p class="muted" style="margin:0">「${esc(f.title)}」</p>
<div class="stack">${["这不是问题(误报)", "级别不对", "出处定位错了", "其他"].map((t, i) => `<label class="row" style="gap:8px"><input type="radio" name="fb" ${i ? "" : "checked"} style="accent-color:var(--c-ink)"> ${t}</label>`).join("")}</div> <div class="choices" role="radiogroup" aria-label="哪里不对">${["这不是问题(误报)", "级别不对", "出处定位错了", "其他"].map((t, i) => `<label class="choice"><input type="radio" class="ck" name="fb" ${i ? "" : "checked"}>${t}</label>`).join("")}</div>
<div class="field" style="margin-top:var(--sp-4)"><label for="fbt">补充说明 <small>(选填)</small></label><textarea class="input" id="fbt"></textarea></div> <div class="field fill" style="margin-top:var(--sp-4)"><label for="fbt">补充说明 <small>(选填)</small></label><textarea class="input" id="fbt" maxlength="200"></textarea>
<p class="cap muted">反馈只用于改进检查规则,不会改变这条问题的处理状态。</p>`, <span class="msg">反馈只用于改进检查规则,不改变这条问题的处理状态。</span></div></div>`,
ok: "提交反馈", ok: "提交反馈",
onOk: () => toast("已收到反馈,谢谢。我们会逐条复盘。") onOk: () => toast("已收到反馈")
}); });
} }
document.addEventListener("click", (e) => { /* ---------- 需人工核对(FR-M3-04,决策 P2:按项目保存,复查沿用,报告写勾选人和时间) ---------- */
const t = e.target; const M = C.manual.map((m) => ({ ...m, by: "" }));
const id = t.closest("[data-id]")?.dataset.id; function mrender() {
if (t.matches("[data-act]")) { focusId = id; const a = t.dataset.act; a === "ok" ? confirmOk(id) : a === "fixed" ? handle(id, "fixed") : ignore(id); } $("[data-manual]").innerHTML = M.map((m) => `<div class="mrow" data-m="${m.id}">
else if (t.matches("[data-undo]")) { e.preventDefault(); undo(id); } <div><label class="choice"><input type="checkbox" class="ck" data-mck ${m.by ? "checked" : ""}><span>${esc(m.title)}</span></label>
else if (t.matches("[data-toast-undo]")) { e.preventDefault(); $(".toast")?.remove(); undo(lastHandled); } ${m.by ? `<div class="sign sm"><span class="ok" aria-hidden="true">✓</span><b>${esc(m.by)}</b><span class="fig">${m.at}</span><span>已核对</span></div>` : ""}</div>
else if (t.matches("[data-feedback]")) { e.preventDefault(); feedback(id); } ${App.citeLink(m.cite)}</div>`).join("");
else if (t.closest("[data-toggle]")) { const k = t.closest("[data-toggle]").dataset.toggle; k === "p" ? (openProcessed = !openProcessed) : (openResolved = !openResolved); render(); } $("[data-mn]").textContent = M.filter((m) => m.by).length;
else if (t.closest("[data-f]")) { filter = t.closest("[data-f]").dataset.f; $$("[data-f]").forEach((b) => b.classList.toggle("on", b.dataset.f === filter)); focusId = null; render(); } $("[data-mt]").textContent = M.length;
else if (t.closest("[data-batch]")) {
const hs = F.filter((f) => f.status === "todo" && f.sev === "hint");
dialog({ title: `批量确认 ${hs.length} 条「提示」?`, body: "<p>这些问题都会标记为「已确认无误」,处理记录里逐条写上你的名字和时间。批量处理只对「提示」级开放。</p>", ok: "批量确认无误",
onOk: () => { hs.forEach((f) => Object.assign(f, { status: "ok", by: D.user.name, at: "11月2日 15:04" })); render(); toast(`已批量确认 ${hs.length} 条提示`); } });
}
else if (t.closest("[data-keys]")) keysHelp();
});
function keysHelp() {
dialog({ title: "快捷键", cancel: null, ok: "知道了",
body: `<dl class="kv">${[["J / K", "下一条 / 上一条"], ["O 或 Enter", "打开当前问题的原文"], ["1", "确认无误"], ["2", "已修改"], ["3", "忽略并注明原因"], ["U", "撤销刚才的处理"], ["Esc", "关闭原文或对话框"]].map(([k, v]) => `<dt><span class="kbd">${k}</span></dt><dd>${v}</dd>`).join("")}</dl>` });
} }
document.addEventListener("keydown", (e) => { $("[data-manual]").addEventListener("change", (e) => {
if (e.target.matches("input, textarea") || $(".scrim")) return; if (!e.target.matches("[data-mck]")) return;
const list = $$("[data-list] > .finding"); const m = M.find((x) => x.id === e.target.closest("[data-m]").dataset.m);
let i = list.findIndex((el) => el.dataset.id === focusId); Object.assign(m, e.target.checked ? { by: D.user.name, at: stamp() } : { by: "", at: "" });
const k = e.key.toLowerCase(); mrender(); $(`[data-m="${m.id}"] [data-mck]`).focus();
if (k === "j" || k === "k") { });
i = k === "j" ? Math.min(i + 1, list.length - 1) : Math.max(i - 1, 0); $("[data-manual]").addEventListener("click", (e) => {
focusId = list[i]?.dataset.id; list.forEach((el) => el.classList.toggle("focus", el.dataset.id === focusId)); const a = e.target.closest("[data-open]"); if (!a) return;
list[i]?.scrollIntoView({ block: "nearest", behavior: "smooth" }); e.preventDefault(); openCite(null, App.parseCite(a.dataset.open));
} else if ((k === "o" || k === "enter") && focusId) { $(`[data-id="${focusId}"] [data-open]`)?.click(); }
else if (k === "1" && focusId) confirmOk(focusId);
else if (k === "2" && focusId) handle(focusId, "fixed");
else if (k === "3" && focusId) ignore(focusId);
else if (k === "u" && lastHandled) undo(lastHandled);
else if (k === "escape") drawer.classList.remove("open");
else if (e.key === "?") keysHelp();
}); });
/* 需人工核对 */ render(); mrender();
$("[data-manual]").innerHTML = D.check.manual.map((m) => `<label class="chk"><input type="checkbox" data-m="${m.id}"><div style="flex:1"><div>${m.title}</div><div class="cap muted" data-m-sign></div></div><a class="cite-src" href="#" data-open="${m.cite.join("|")}">${App.citeLabel(m.cite)}</a></label>`).join("");
$$("[data-m]").forEach((c) => (c.onchange = () => { $("[data-m-sign]", c.parentNode).innerHTML = c.checked ? `<span style="color:var(--confirmed)">✓ ${D.user.name} 11月2日 15:06 已核对</span>` : ""; }));
render();
</script> </script>
</body> </body>
</html> </html>

View File

@@ -1,74 +1,221 @@
<!doctype html> <!doctype html>
<!--
工作台(v0.2):按 design/ui-demo/page-workbench.html 重刷;数据来自 assets/data.js。
框架来自 App.shell();项目行(.prow)、统计(.stats)、额度卡等共用样式在 assets/app.css,本页 <style> 只放工作台特有的布局。
-->
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>工作台 · 标博士原型</title> <title>工作台 · 标博士原型</title>
<link rel="icon" href="../design/logo/favicon.svg">
<link rel="stylesheet" href="../design/system/tokens.css"> <link rel="stylesheet" href="../design/system/tokens.css">
<link rel="stylesheet" href="../design/system/fonts-local.css"> <link rel="stylesheet" href="../design/system/fonts-local.css">
<link rel="stylesheet" href="../design/system/components.css"> <link rel="stylesheet" href="../design/system/components.css">
<link rel="stylesheet" href="assets/app.css"> <link rel="stylesheet" href="assets/app.css">
<style> <style>
.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:var(--sp-4);margin-bottom:var(--sp-6)} /* 主列 + 右侧信息栏(--aside-w),全页一个间距 --gap-card */
.stat{background:var(--bg-surface);border:1px solid var(--border-subtle);border-radius:var(--radius-sm);padding:var(--sp-4) var(--sp-5)} .content > .alert,.content > .stats{margin-bottom:var(--gap-card)}
.stat small{display:block;color:var(--text-secondary);font-size:var(--fs-caption)} .wb{display:grid;grid-template-columns:minmax(0,1fr) var(--aside-w);gap:var(--gap-card);align-items:start}
.stat .big{font:600 var(--fs-figure)/var(--lh-figure) var(--font-figures);font-variant-numeric:tabular-nums}
.stat .unit{color:var(--text-secondary);margin-left:4px} /* 项目卡片头:筛选 + 已选统计格的清除按钮 */
.stat p{margin:var(--sp-1) 0 0;font-size:var(--fs-caption);color:var(--text-secondary)} .card-h .seg{margin-left:var(--sp-2)}
.toolbar{display:flex;align-items:center;gap:var(--sp-3);margin-bottom:var(--sp-3)} .card-h .clear{margin-left:auto}
.toolbar .right{margin-left:auto;display:flex;gap:var(--sp-2);align-items:center}
table.data tbody tr{cursor:pointer} /* 待处理的致命问题:不加左侧红竖线,靠实心「致命」标签扫读 */
table.data tbody tr:hover td{background:var(--bg-hover)} .todos{list-style:none;margin:0;padding:0}
.todo{display:grid;grid-template-columns:auto minmax(0,1fr) auto auto;column-gap:var(--sp-4);align-items:center;padding:var(--sp-3) var(--card-x);border-top:var(--line) solid var(--border-subtle)}
.todo b{display:block;font-weight:var(--fw-bold)}
.todo .cite-link{margin-right:2px}
/* 右栏 */
.wb aside .card-h{min-height:52px;padding-bottom:var(--sp-1)}
.task-h{display:flex;align-items:baseline;gap:var(--sp-2)}
.task-h .cap{margin-left:auto}
.wb .progress{margin-top:10px}
.queue{display:flex;gap:var(--sp-2);align-items:flex-start;margin-top:14px;padding-top:var(--sp-3);border-top:var(--line) solid var(--border-subtle)}
.queue .ic{margin-top:2px;color:var(--text-secondary)}
.log{list-style:none;margin:0;padding:0}
.log li{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:var(--sp-2);padding:var(--sp-2) 0;border-top:var(--line) solid var(--border-subtle);font-size:var(--fs-caption);line-height:var(--lh-caption)}
.log li:first-child{border-top:0;padding-top:0}
.log li > span:last-child{color:var(--text-secondary);font-family:var(--font-figures);font-variant-numeric:tabular-nums;font-size:var(--fs-figure-xs);white-space:nowrap}
.log .who{font-weight:var(--fw-bold)}
.log .ok{color:var(--confirmed);white-space:nowrap}
</style> </style>
</head> </head>
<body> <body>
<main> <main>
<div class="content"> <div class="content">
<div class="page-title"> <div class="page-title">
<div><h1>工作台</h1><p>按投标截止时间排序。截止临近的项目排在前面。</p></div> <div><h1>工作台</h1><p data-hello></p></div>
<div class="actions"><a class="btn primary" href="project-new.html">新建项目</a></div> <div class="actions">
</div> <span class="cost">扣 1 次解析 · 剩 <span class="fig" data-parse-left></span> 次</span>
<div class="stats"> <a class="btn primary" href="project-new.html"><i data-ic="plus"></i>新建项目</a>
<div class="stat"><small>进行中的项目</small><span class="big">4</span><p>已递交 1 · 已归档 1</p></div>
<div class="stat"><small>7 天内截止</small><span class="big">2</span><p class="due-soon" style="color:var(--warn-text)">最近:今天 17:00</p></div>
<div class="stat"><small>待处理的致命问题</small><span class="big">3</span><p>在 1 个项目里</p></div>
<div class="stat"><small>本期额度剩余</small><span class="big">37</span><span class="unit">次解析</span><p>检查 12 次 · <a class="plain" href="quota.html">查看明细</a></p></div>
</div>
<div class="toolbar">
<div class="seg" role="group" aria-label="按状态筛选" data-seg>
<button class="on" data-s="active">进行中</button><button data-s="all">全部</button><button data-s="已递交">已递交</button><button data-s="已归档">已归档</button>
</div> </div>
<div class="right"><input class="input" placeholder="搜索项目名称、采购人、项目编号" style="width:280px"></div>
</div> </div>
<table class="data">
<thead><tr><th>项目</th><th>所投包</th><th>截止</th><th>状态</th><th>就绪</th><th class="r">致命</th></tr></thead> <div data-alert></div>
<tbody data-rows></tbody>
</table> <section class="stats" aria-label="项目统计,点击可筛选项目列表" data-stats></section>
<p class="note">「就绪」只在「已检查」的项目上显示,只表示致命问题都已处理,不代表投标文件没有问题。截止临近用赭色提示,不用红色。</p>
<div class="wb">
<div class="stack">
<section class="card flush" aria-labelledby="h-proj">
<div class="card-h">
<h2 id="h-proj">项目</h2>
<div class="seg" role="group" aria-label="按状态筛选" data-seg></div>
<span class="clear" data-chip></span>
</div>
<div class="prow-head" aria-hidden="true"><span>项目</span><span>投标截止</span><span>状态</span><span class="c">致命</span><span>下一步</span><span></span></div>
<ul class="prows" data-rows aria-label="项目列表"></ul>
</section>
<section class="card flush" aria-labelledby="h-todo">
<div class="card-h">
<h2 id="h-todo">待处理的致命问题</h2>
<span data-todo-count></span>
<a class="lnk cap" style="margin-left:auto" href="results.html">查看全部</a>
</div>
<ul class="todos" data-todos></ul>
</section>
</div>
<aside class="stack" aria-label="任务、额度与记录">
<section class="card flush" aria-labelledby="h-task">
<div class="card-h"><h3 id="h-task">正在进行的任务</h3></div>
<div class="card-b" data-task></div>
</section>
<section class="card flush quota" aria-labelledby="h-quota">
<div class="card-h"><h3 id="h-quota">本期额度</h3><span class="cap muted" style="margin-left:auto">至 2027-10-31</span></div>
<div class="card-b"><div class="q2" data-quota></div></div>
<div class="card-f cap"><a class="lnk" href="quota.html#rules">扣点规则</a><a class="lnk" href="quota.html#flow">查看流水</a></div>
</section>
<section class="card flush" aria-labelledby="h-log">
<div class="card-h"><h3 id="h-log">最近处理</h3></div>
<div class="card-b"><ul class="log" data-log></ul></div>
</section>
</aside>
</div>
</div> </div>
</main> </main>
<script src="assets/data.js"></script> <script src="assets/data.js"></script>
<script src="assets/app.js"></script> <script src="assets/app.js"></script>
<script> <script>
const { D, $, $$ } = App; const { D, $, $$, esc, icon, toast } = App;
App.shell({ nav: "workbench", crumbs: [["工作台", ""]] }); const P = [...D.projects].sort((a, b) => (a.hoursLeft < 0) - (b.hoursLeft < 0) || (a.hoursLeft < 0 ? b.hoursLeft - a.hoursLeft : a.hoursLeft - b.hoursLeft));
let s = "active"; const CHECK_COST = `扣 1 次检查 · 剩 <span class="fig">${D.quota.check.left}</span> 次`;
function render() { const ACTIVE = (p) => !["已递交", "已归档"].includes(p.status);
const rows = [...D.projects].sort((a, b) => a.hoursLeft - b.hoursLeft).filter((p) => s === "all" || (s === "active" ? !["已递交", "已归档"].includes(p.status) : p.status === s)); const STAT = {
$("[data-rows]").innerHTML = rows.map((p) => `<tr data-href="${p.href}"> active: { label: "进行中", fn: ACTIVE },
<td><div class="proj-name">${p.name}</div><div class="proj-sub">${p.buyer}</div></td> due7: { label: "7 天内截止", fn: (p) => p.hoursLeft >= 0 && p.hoursLeft <= 168 },
<td>${p.lot}</td><td class="${p.dueCls}">${p.due}</td> fatal: { label: "有致命问题", fn: (p) => p.fatal > 0 },
<td><span class="tag status">${p.status}</span></td> pend: { label: "有待确认条目", fn: (p) => p.pend > 0 }
<td>${p.ready === "就绪" ? '<span class="tag status" title="只表示致命问题都已处理,不代表投标文件没有问题">就绪</span>' : p.ready || "—"}</td> };
<td class="r">${p.fatal ? `<span class="sev fatal">致命<span class="fig">${p.fatal}</span></span>` : "—"}</td></tr>`).join("") let seg = "active", stat = "", q = "";
|| `<tr><td colspan="6" class="muted" style="text-align:center;padding:40px">没有符合条件的项目。</td></tr>`;
App.shell({ nav: "workbench", crumbs: [["工作台", ""]], search: { placeholder: "搜索项目、采购人", onInput: (v) => { q = v; render(); } } });
const drawer = App.drawer();
/* ---------- 页头、提醒、统计 ---------- */
const [y, m, d] = D.today.split("-").map(Number);
$("[data-hello]").textContent = `下午好,${D.user.name}。今天是 ${m}月${d}日,周${"日一二三四五六"[new Date(y, m - 1, d).getDay()]}。`;
$("[data-parse-left]").textContent = D.quota.parse.left;
const soon = P.find((p) => p.dueCls === "due-today" && p.pend);
if (soon) $("[data-alert]").outerHTML = `<div class="alert warn" role="note">${icon("clock", "lg")}
<p><strong>${esc(soon.name)}</strong> <span class="when">${soon.due[0]}</span>,清单还没确认。</p>
<a class="btn sm" href="${soon.next[1]}">${icon("list")}${soon.next[0]}</a></div>`;
const n = (f) => P.filter(f).length;
const sum = (k) => P.filter(ACTIVE).reduce((a, p) => a + p[k], 0);
$("[data-stats]").innerHTML = [
["active", "进行中的项目", n(ACTIVE), ""],
["due7", "7 天内截止", n(STAT.due7.fn), "due"],
["fatal", "待处理的致命问题", sum("fatal"), "fatal"],
["pend", "待确认的清单条目", sum("pend"), ""]
].map(([f, t, v, c]) => `<button class="stat" data-f="${f}" aria-pressed="false"><small>${t}</small><span class="big ${v ? c : ""}">${v}</span>${icon("filter")}</button>`).join("");
$("[data-seg]").innerHTML = `<button aria-pressed="true" data-v="active">进行中 <span class="fig">${n(ACTIVE)}</span></button><button aria-pressed="false" data-v="all">全部 <span class="fig">${P.length}</span></button><button aria-pressed="false" data-v="已递交">已递交</button><button aria-pressed="false" data-v="已归档">已归档</button>`;
/* ---------- 项目列表 ---------- */
function status(p) {
let r = "";
if (p.ready === "就绪") r = `<span class="hint-u tip-below" tabindex="0" data-tip="${App.READY_TIP}">就绪</span>`;
else if (p.ready) r = `<span>${p.ready}</span>`;
return `<span class="st"><span class="tag">${p.status}</span>${r}</span>`;
} }
document.addEventListener("click", (e) => { function render() {
const b = e.target.closest("[data-s]"); const rows = P.map((p, i) => [p, i]).filter(([p]) => {
if (b) { s = b.dataset.s; $$("[data-s]").forEach((x) => x.classList.toggle("on", x === b)); render(); } if (stat) { if (!STAT[stat].fn(p)) return false; }
const r = e.target.closest("[data-href]"); else if (seg !== "all" && !(seg === "active" ? ACTIVE(p) : p.status === seg)) return false;
if (r) location.href = r.dataset.href; return !q || (p.name + p.buyer).includes(q);
});
$("[data-rows]").innerHTML = rows.map(([p, i]) => `<li class="prow ${p.status === "已归档" ? "arch" : ""}" data-i="${i}">
<span class="pt"><a class="pname" href="${p.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.dueCls}">${p.due[0]}</b><small>${p.due[1]}</small></span>
${status(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[0]}</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>换个关键词,或切到「全部」看看。</p></li>`;
$$("[data-seg] button").forEach((b) => b.setAttribute("aria-pressed", !stat && b.dataset.v === seg));
$$("[data-stats] .stat").forEach((b) => b.setAttribute("aria-pressed", b.dataset.f === stat || (!stat && b.dataset.f === "active" && seg === "active")));
$("[data-chip]").innerHTML = stat ? `<button class="btn text sm" data-clear>${STAT[stat].label}${icon("x")}<span class="vh">,清除筛选</span></button>` : "";
}
$("[data-rows]").addEventListener("click", (e) => {
const row = e.target.closest(".prow"); if (!row) return;
const p = P[+row.dataset.i];
const mb = e.target.closest("[data-menu]");
if (mb) {
e.preventDefault(); e.stopPropagation();
const items = [{ label: "打开项目", act: p.href }, "-", ...p.menu.map(([t, s, h]) => ({ label: t, sub: s === "cost" ? CHECK_COST : s, act: h || t }))];
App.floatMenu(mb, items, (act, it) => { if (/\.html/.test(act)) location.href = act; else toast(`(原型)${it.label}:${p.name}`, { ok: false }); });
return;
}
if (e.target.closest(".hint-u")) return;
if (e.target.closest("[data-next]")) { location.href = p.next[1]; return; }
if (!e.target.closest("a")) location.href = p.href;
}); });
$("[data-seg]").addEventListener("change", (e) => { seg = e.detail; stat = ""; render(); });
$("[data-stats]").addEventListener("click", (e) => {
const b = e.target.closest(".stat"); if (!b) return;
const f = b.dataset.f;
if (f === "active") { seg = "active"; stat = ""; }
else if (stat === f) { stat = ""; seg = "active"; }
else stat = f;
render();
});
$("[data-chip]").addEventListener("click", (e) => { if (e.target.closest("[data-clear]")) { stat = ""; seg = "active"; render(); $("[data-seg] button[aria-pressed=true]").focus(); } });
/* ---------- 待处理的致命问题(来自第 2 次检查),出处在抽屉里看原文 ---------- */
const todos = D.check.findings.filter((f) => f.sev === "fatal" && !f.status);
const short = D.project.name.replace(/^某市政务服务中心/, "").replace(/项目$/, "");
$("[data-todo-count]").outerHTML = todos.length ? `<span class="sev fatal">致命<span class="fig">${todos.length}</span></span>` : "";
$("[data-todos]").innerHTML = todos.map((f) => {
const c = f.cites.find((x) => D.docs[x[0]].role === "bid") || f.cites[0];
return `<li class="todo"><span class="sev fatal">致命</span><div><b>${esc(f.title)}</b><span class="psub">${short} · ${esc(f.brief || "")}</span></div>${App.citeLink(c)}<a class="btn sm" href="results.html">去处理</a></li>`;
}).join("") || `<li class="empty sm"><b>没有待处理的致命问题</b></li>`;
$("[data-todos]").addEventListener("click", (e) => {
const a = e.target.closest("[data-open]"); if (!a) return;
e.preventDefault();
const f = todos[$$("[data-todos] .todo").indexOf(a.closest(".todo"))];
const c = App.parseCite(a.dataset.open);
drawer.open(c, f.cites.map((x) => x.slice()));
});
/* ---------- 右栏:任务、额度、最近处理 ---------- */
const T = D.tasks.running;
$("[data-task]").innerHTML = `<div class="task-h"><b>${T.title}</b><span class="cap muted">${T.stage}</span></div>
<div class="cap muted">${T.project}</div>
<div class="progress"><div class="bar" role="progressbar" aria-valuenow="${T.pct}" aria-valuemin="0" aria-valuemax="100" aria-label="${T.title}的进度"><i style="width:${T.pct}%"></i></div><span class="fig">${T.pct}%</span></div>
<div class="queue">${icon("clock")}<span class="cap muted">${D.tasks.queue}</span></div>`;
$("[data-quota]").innerHTML = [["解析", D.quota.parse], ["检查", D.quota.check]].map(([t, v]) => {
const warn = (v.total - v.left) / v.total >= 0.8;
return `<div><small>${t}剩余</small><span class="big ${warn ? "warn" : ""}">${v.left}</span><span class="unit">/ ${v.total}</span><div class="bar ${warn ? "warn" : ""}" aria-hidden="true"><i style="width:${Math.round((v.total - v.left) / v.total * 100)}%"></i></div></div>`;
}).join("");
$("[data-log]").innerHTML = D.recent.map((r) => `<li><span><span class="who">${r.who}</span> ${r.ok ? `<span class="ok">✓ ${r.ok}</span>` : ""}${esc(r.text)}</span><span>${r.at}</span></li>`).join("");
render(); render();
</script> </script>
</body> </body>