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:
@@ -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/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;项目名、金额、页码、投标响应、人名都是示意。
|
||||
- **入口**:`index.html`(原型目录,含页面与旅程的对应关系、模拟范围)。
|
||||
|
||||
@@ -32,9 +33,34 @@ cd docs && python3 -m http.server 8766
|
||||
| `project.html` | 项目概览:状态流、AI 摘要、关键日期、文件、检查记录、递交 / 归档 / 删除 |
|
||||
| `quota.html` | 额度与流水 |
|
||||
| `qualifications.html` | 资质库 |
|
||||
| `assets/data.js` | 示意数据(项目、文件页面、清单、检查结果) |
|
||||
| `assets/app.js` | 页面框架、出处、原文查看器、对话框、轻提示 |
|
||||
| `assets/app.css` | 页面框架与布局(组件样式在 `design/system/components.css`) |
|
||||
| `assets/data.js` | 示意数据:项目、文件页面(原文)、清单、待确认、检查结果、需人工核对;v0.2 新增工作台的项目列表、`tasks`(进行中的任务)、`recent`(最近处理)、致命问题的 `brief`。演示里的「现在」统一为 11月2日 15:00 |
|
||||
| `assets/app.js` | 共用脚本(`window.App`),见下表 |
|
||||
| `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)
|
||||
|
||||
@@ -46,4 +72,4 @@ cd docs && python3 -m http.server 8766
|
||||
## 和需求、规范的对应
|
||||
|
||||
- 每个页面对应的 FR 编号见 `index.html` 的「其他页面」表。
|
||||
- 产品红线自查:没有出处的结论只出现在「待确认」区;全站无「通过 / 合格 / 保证」字样;红色只用于致命问题;致命问题未处理也能导出和标记已递交;扣点前在按钮旁说明;报告 logo 只在页眉。
|
||||
- 产品红线自查:没有出处的结论只出现在「待确认」区;界面和报告文案无「通过 / 合格 / 保证 / 100%」字样(「保证金」和照录的招标文件原文除外);红色只用于致命问题;致命问题未处理也能导出和标记已递交;扣点前在按钮旁说明;报告 logo 只在页眉。
|
||||
|
||||
@@ -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}
|
||||
.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)}
|
||||
.sidebar .brand{display:block;padding:0 var(--sp-2) var(--sp-6)}
|
||||
.sidebar .brand img{height:30px;display:block}
|
||||
.nav{display:flex;flex-direction:column;gap:2px}
|
||||
.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}
|
||||
.nav a:hover{background:var(--bg-hover)}
|
||||
.nav a.on{background:var(--bg-surface);box-shadow:inset 2px 0 0 var(--c-ink);font-weight:var(--fw-bold)}
|
||||
.nav svg,.ic{width:18px;height:18px;flex:none;fill:none;stroke:currentColor;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}
|
||||
.nav .sub{margin:var(--sp-5) var(--sp-3) var(--sp-2);font-size:var(--fs-caption);color:var(--text-secondary)}
|
||||
.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-foot b{display:block;color:var(--text-primary);font-size:var(--fs-body)}
|
||||
/* =====================================================================
|
||||
* 1. 页面框架 v0.2(§5.3):96px 墨色渐变侧栏 + 透明顶栏 + 内容区左上角圆角
|
||||
* 结构:body.app > aside.sidebar + main.main > header.top + [header.ph 项目头] + 页面内容
|
||||
* ===================================================================== */
|
||||
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}
|
||||
.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}
|
||||
.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)}
|
||||
.sidebar > *{position:relative;z-index:1}
|
||||
.sidebar .brand{display:flex;justify-content:center;padding:var(--sp-5) 10px var(--sp-5) 0;border-radius:var(--radius-ctl)}
|
||||
.sidebar .brand img{width:60px;height:auto;display:block}
|
||||
.sidebar .nav{display:flex;flex-direction:column;gap:var(--sp-1)}
|
||||
.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 .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}
|
||||
.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)}
|
||||
.crumbs{display:flex;gap:var(--sp-2);color:var(--text-secondary);min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.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-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)}
|
||||
.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:hover{color:var(--text-primary);text-decoration:underline;text-underline-offset:3px}
|
||||
.crumbs b{color:var(--text-primary);font-weight:var(--fw-regular);overflow:hidden;text-overflow:ellipsis}
|
||||
.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)}
|
||||
.quota-chip .fig{color:var(--text-primary);font-size:14px;margin-left:3px}
|
||||
.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}
|
||||
.icon-btn:hover{background:var(--bg-hover)}
|
||||
.icon-btn .dot{position:absolute;top:6px;right:6px;width:6px;height:6px;border-radius:50%;background:var(--c-ink)}
|
||||
|
||||
.content{padding:var(--sp-6) var(--sp-8) var(--sp-16);max-width:calc(var(--content-max) + 64px);width:100%}
|
||||
.content.wide{max-width:none}
|
||||
.page-title{display:flex;align-items:flex-end;gap:var(--sp-4);margin-bottom:var(--sp-5)}
|
||||
.crumbs a:hover{color:var(--text-primary);text-decoration:underline;text-decoration-color:var(--link-underline);text-underline-offset:3px}
|
||||
.crumbs b{font-weight:var(--fw-regular)}
|
||||
/* 右侧:搜索、额度、通知 */
|
||||
.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)}
|
||||
.top-search:hover{border-color:var(--c-ink)}
|
||||
.top-search:focus-within{width:360px;border-color:var(--c-ink);box-shadow:0 0 0 1px var(--c-ink)}
|
||||
.top-search > .ic{position:absolute;left:11px;color:var(--text-secondary);pointer-events:none}
|
||||
.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)}
|
||||
.top-search input::-webkit-search-cancel-button{display:none}
|
||||
.top-search .kbd{margin-right:6px}
|
||||
.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 p{margin:var(--sp-1) 0 0;color:var(--text-secondary)}
|
||||
.page-title .actions{margin-left:auto;display:flex;gap:var(--sp-2);align-items:center}
|
||||
|
||||
/* 项目页头 + 页签 */
|
||||
.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)}
|
||||
.page-title p{margin:2px 0 0;color:var(--text-secondary)}
|
||||
.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)}
|
||||
.section-h h2{font:var(--fw-bold) var(--fs-title)/var(--lh-title) var(--font-sans);margin:0}
|
||||
.section-h .more{margin-left:auto}
|
||||
.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)}
|
||||
.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}
|
||||
.section-h .more{margin-left:auto;display:flex;gap:var(--sp-2);align-items:center}
|
||||
.stack > * + *{margin-top:var(--gap-card)}
|
||||
|
||||
/* 状态流 */
|
||||
.flowline{display:flex;align-items:center;gap:0;margin:var(--sp-2) 0}
|
||||
/* 整屏布局(App.shell({ fit: true })):读标、查标的对照布局,main 不滚动,里面各栏自己滚动 */
|
||||
.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 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 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 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}
|
||||
|
||||
/* 键值列表 */
|
||||
.kv{display:grid;grid-template-columns:112px 1fr;gap:var(--sp-2) var(--sp-4);margin:0}
|
||||
.kv dt{color:var(--text-secondary)}
|
||||
.kv dd{margin:0}
|
||||
/* ---- B16 AI 产出块(§3.3、§7.16;Q2) ----
|
||||
* 确认前:「AI」小标签 + 左侧 2px 蓝铅笔虚线;正文始终墨色。
|
||||
* 确认后(.done):虚线去掉,下方出署名行(.sign:绿 ✓ + 姓名 + 时间)。左内边距保留,正文不跳动。 */
|
||||
.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)}
|
||||
|
||||
/* 上传区 */
|
||||
.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}
|
||||
.drop:hover{border-color:var(--c-ink)}
|
||||
.drop b{display:block;font-size:var(--fs-title);margin-bottom:var(--sp-1)}
|
||||
.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)}
|
||||
.file-row + .file-row{margin-top:var(--sp-2)}
|
||||
.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}
|
||||
.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}
|
||||
/* ---- B17 卡片:卡片头、卡片底、统计、额度、关键日期(§7.9、§7.10) ----
|
||||
* 整块卡片 .card:头用 .card-h,底用 .card-foot(贴边、带发丝线);
|
||||
* 分区卡片 .card.flush:.card-h / .card-b / .card-f 见 components.css。 */
|
||||
.card-h{display:flex;align-items:center;gap:var(--sp-3);margin-bottom:var(--sp-3)}
|
||||
.card-h .more{margin-left:auto;display:flex;gap:var(--sp-3);align-items:center}
|
||||
.card.tight{padding:var(--sp-3) var(--card-x)}
|
||||
.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)}
|
||||
.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}
|
||||
|
||||
661
docs/prototype/assets/app.js
vendored
661
docs/prototype/assets/app.js
vendored
@@ -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 () {
|
||||
const D = window.PROTO;
|
||||
const $ = (s, el = document) => el.querySelector(s);
|
||||
const $$ = (s, el = document) => [...el.querySelectorAll(s)];
|
||||
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
||||
|
||||
/* Lucide 图标(ISC 许可)的简化路径 */
|
||||
/* ---------- 图标:Lucide(ISC 许可)路径 ---------- */
|
||||
const ICONS = {
|
||||
grid: '<rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/>',
|
||||
folder: '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
|
||||
cert: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 9h10M7 13h6"/><circle cx="16" cy="15" r="2"/>',
|
||||
gauge: '<path d="M12 14l4-4"/><path d="M3.3 19a9 9 0 1 1 17.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.9 1.9 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"/>',
|
||||
x: '<path d="M18 6 6 18M6 6l12 12"/>',
|
||||
plus: '<path d="M12 5v14M5 12h14"/>',
|
||||
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"/>',
|
||||
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"/>',
|
||||
keyboard: '<rect x="2" y="5" width="20" height="14" rx="2"/><path d="M6 9h.01M10 9h.01M14 9h.01M18 9h.01M7 15h10"/>'
|
||||
gauge: '<path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/>',
|
||||
bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
|
||||
help: '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>',
|
||||
x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
||||
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
|
||||
upload: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m17 8-5-5-5 5"/><path d="M12 3v12"/>',
|
||||
download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/>',
|
||||
file: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8M16 13H8M16 17H8"/>',
|
||||
search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
|
||||
"chev-down": '<path d="m6 9 6 6 6-6"/>',
|
||||
"chev-left": '<path d="m15 18-6-6 6-6"/>',
|
||||
"chev-right": '<path d="m9 18 6-6-6-6"/>',
|
||||
sort: '<path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/>',
|
||||
"arrow-up": '<path d="m5 12 7-7 7 7"/><path d="M12 19V5"/>',
|
||||
"arrow-down": '<path d="M12 5v14"/><path d="m19 12-7 7-7-7"/>',
|
||||
"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); });
|
||||
}
|
||||
|
||||
/* ---------- 页面框架 ---------- */
|
||||
function shell(opt) {
|
||||
/* ---------- 演示里的「现在」:2026-11-02 15:00(与示意数据一致) ---------- */
|
||||
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 nav = [
|
||||
["workbench.html", "grid", "工作台", "workbench"],
|
||||
["qualifications.html", "cert", "资质库", "quals"],
|
||||
["quota.html", "gauge", "额度", "quota"]
|
||||
].map(([h, i, t, k]) => `<a href="${h}" class="${opt.nav === k ? "on" : ""}">${icon(i)}${t}</a>`).join("");
|
||||
const side = `<aside class="sidebar">
|
||||
<a class="brand" href="workbench.html"><img src="../design/logo/svg/logo-horizontal.svg" alt="标博士"></a>
|
||||
<nav class="nav" aria-label="主导航">${nav}<div class="sub">原型</div><a href="index.html">${icon("help")}原型目录</a></nav>
|
||||
<div class="sidebar-foot"><b>${D.user.name}</b>${D.user.company}<br>${D.user.plan} · 至 2027-10-31</div>
|
||||
</aside>`;
|
||||
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 top = `<header class="top">
|
||||
<div class="crumbs">${crumbs}</div>
|
||||
<div class="top-right">
|
||||
<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>
|
||||
<button class="icon-btn" aria-label="通知" data-notice>${icon("bell")}<span class="dot"></span></button>
|
||||
</div>
|
||||
</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">');
|
||||
let crumbs = (opt.crumbs || []).slice();
|
||||
if (opt.tab && crumbs.length > 1 && TABS.some((t) => t[1] === crumbs[crumbs.length - 1][0])) crumbs.pop();
|
||||
const navKey = opt.nav || (opt.nav === null ? "" : (opt.tab || (crumbs[0] && crumbs[0][0] === "工作台")) ? "workbench" : "");
|
||||
const nav = [["workbench.html", "house", "工作台", "workbench"], ["qualifications.html", "award", "资质库", "quals"], ["quota.html", "wallet", "额度", "quota"]]
|
||||
.map(([h, i, t, k]) => `<a href="${h}" ${navKey === k ? 'aria-current="page"' : ""}>${icon(i)}<span>${t}</span></a>`).join("");
|
||||
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("");
|
||||
const plan = PLAN[qs.get("plan")] ? qs.get("plan") : (PLAN_KEY[D.user.plan] || "pro");
|
||||
const quota = { parse: [D.quota.parse.left, D.quota.parse.total], check: [D.quota.check.left, D.quota.check.total] };
|
||||
if (qs.get("quota") === "warn") quota.check = [3, quota.check[1]];
|
||||
if (qs.get("quota") === "out") quota.check = [0, quota.check[1]];
|
||||
const qItem = (name, [left, total]) => `<span class="q ${(total - left) / total >= 0.8 ? "warn" : ""}">${name}<span class="fig">${left}</span></span>`;
|
||||
const out = [["解析", quota.parse], ["检查", quota.check]].filter(([, [l]]) => l <= 0).map(([n]) => n);
|
||||
const qTip = `本期剩余额度:解析 ${quota.parse[0]} / ${quota.parse[1]} 次,检查 ${quota.check[0]} / ${quota.check[1]} 次` +
|
||||
(out.length ? `。本期${out.join("、")}额度已用完,可在额度页提交续费申请,或等下期发放` : "");
|
||||
const ph = (opt.search && opt.search.placeholder) || "搜索项目、采购人";
|
||||
|
||||
if (!$('link[rel="icon"]')) document.head.insertAdjacentHTML("beforeend", '<link rel="icon" href="../design/logo/favicon.svg">');
|
||||
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");
|
||||
main.classList.add("main");
|
||||
document.body.insertAdjacentHTML("afterbegin", side);
|
||||
main.insertAdjacentHTML("afterbegin", top + head);
|
||||
$("[data-notice]").addEventListener("click", () => toast("解析完成:补遗 1 已并入清单(新增 2 条,更新 1 条)。"));
|
||||
if (opt.fit) main.classList.add("fit");
|
||||
let phHTML = "";
|
||||
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) {
|
||||
const d = D.docs[c[0]];
|
||||
return `${d.name} · <span class="fig">第 ${c[1]} 页</span>`;
|
||||
return `${docLabel(c[0])} · 第 <span class="fig">${c[1]}</span> 页`;
|
||||
}
|
||||
function quote(c) {
|
||||
const p = (D.pages[c[0]] || {})[c[1]] || [];
|
||||
@@ -78,79 +183,465 @@
|
||||
/* 段落里只标出关键原句;没有关键原句时整段标出 */
|
||||
function marked(text, key, tag = "mark") {
|
||||
const i = key ? text.indexOf(key) : -1;
|
||||
if (i < 0) return `<${tag}>${esc(text)}</${tag.split(" ")[0]}>`;
|
||||
return esc(text.slice(0, i)) + `<${tag}>${esc(key)}</${tag.split(" ")[0]}>` + esc(text.slice(i + key.length));
|
||||
const close = tag.split(" ")[0];
|
||||
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 = {}) {
|
||||
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 = {}) {
|
||||
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.innerHTML = `<div class="viewer-bar">
|
||||
<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 class="viewer-body" data-v-body><p class="viewer-empty">点击清单项或出处,在这里查看原文并高亮原句。</p></div>`;
|
||||
const body = $("[data-v-body]", el);
|
||||
<div class="viewer-body" data-v-body><p class="viewer-empty">${emptyMsg}</p></div>`;
|
||||
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() {
|
||||
const [doc, page, hl] = cur;
|
||||
const d = D.docs[doc];
|
||||
$("[data-v-name]", el).textContent = d.name;
|
||||
$("[data-v-file]", el).textContent = d.file;
|
||||
$("[data-v-file]", el).title = d.file;
|
||||
$("[data-v-page]", el).textContent = `第 ${page} / ${d.pages} 页`;
|
||||
const paras = (D.pages[doc] || {})[page];
|
||||
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("")
|
||||
: '<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>
|
||||
<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);
|
||||
if (on) on.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
const i = list.findIndex((x) => x.join("|") === cur.join("|"));
|
||||
$("[data-v-prev]", el).disabled = i <= 0;
|
||||
$("[data-v-next]", el).disabled = i < 0 || i >= list.length - 1;
|
||||
if (on) on.scrollIntoView({ block: "center" });
|
||||
if (!custom) { const i = idx(); setNav(i > 0, i >= 0 && i < list.length - 1); }
|
||||
}
|
||||
function open(c, all) {
|
||||
cur = c; list = all || [c];
|
||||
render();
|
||||
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); };
|
||||
$("[data-v-next]", el).onclick = () => { const i = list.findIndex((x) => x.join("|") === cur.join("|")); if (i < list.length - 1) open(list[i + 1], list); };
|
||||
if (opt.closable) $("[data-v-close]", el).onclick = opt.onClose;
|
||||
return { open };
|
||||
function clear(msg) {
|
||||
cur = null; list = [];
|
||||
$("[data-v-name]", el).textContent = "原文";
|
||||
$("[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") };
|
||||
}
|
||||
|
||||
/* ---------- 对话框与轻提示 ---------- */
|
||||
function dialog({ title, body, ok = "确定", cancel = "取消", wide = false, onOk, onOpen }) {
|
||||
/* ---------- 浮动菜单:列表行的「更多」等(role=menu,↑ ↓ 移动,Esc 关闭,焦点回到触发按钮) ----------
|
||||
* 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");
|
||||
s.className = "scrim";
|
||||
s.innerHTML = `<div class="dialog ${wide ? "wide" : ""}" role="dialog" aria-modal="true" aria-labelledby="dlg-t">
|
||||
<h2 id="dlg-t">${title}</h2><div class="body">${body}</div>
|
||||
<div class="foot">${cancel ? `<button class="btn" data-cancel>${cancel}</button>` : ""}<button class="btn primary" data-ok>${ok}</button></div></div>`;
|
||||
<h2 id="dlg-t">${title}</h2><button class="icon-btn sm x" aria-label="关闭" data-x>${icon("x")}</button>
|
||||
<div class="body">${body}</div>
|
||||
<div class="foot">${footLeft ? `<span class="left">${footLeft}</span>` : ""}${cancel ? `<button class="btn" data-cancel>${cancel}</button>` : ""}<button class="btn primary" data-ok>${ok}</button></div></div>`;
|
||||
document.body.appendChild(s);
|
||||
const close = () => { s.remove(); document.removeEventListener("keydown", onKey); };
|
||||
const onKey = (e) => { if (e.key === "Escape") close(); };
|
||||
document.addEventListener("keydown", onKey);
|
||||
s.addEventListener("click", (e) => { if (e.target === s) close(); });
|
||||
document.body.style.overflow = "hidden";
|
||||
const close = () => { s.remove(); document.body.style.overflow = ""; document.removeEventListener("keydown", onKey, true); if (back && back.focus) back.focus(); };
|
||||
const onKey = (e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
const op = $(".pop.open", s); // 先收起对话框里展开的下拉 / 日期选择器
|
||||
if (op) { closePops(); ($("input, .select", op) || op).focus(); return; }
|
||||
close();
|
||||
}
|
||||
if (e.key === "Tab") {
|
||||
const f = $$("button:not([disabled]), input:not([disabled]), textarea, select, a[href]", s);
|
||||
if (!f.length) return;
|
||||
if (e.shiftKey && document.activeElement === f[0]) { e.preventDefault(); f[f.length - 1].focus(); }
|
||||
else if (!e.shiftKey && document.activeElement === f[f.length - 1]) { e.preventDefault(); f[0].focus(); }
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKey, true);
|
||||
s.addEventListener("mousedown", (e) => { if (e.target === s) close(); });
|
||||
$("[data-x]", s).onclick = close;
|
||||
if (cancel) $("[data-cancel]", s).onclick = close;
|
||||
$("[data-ok]", s).onclick = () => { if (!onOk || onOk(s) !== false) close(); };
|
||||
hydrate(s); init(s);
|
||||
if (onOpen) onOpen(s);
|
||||
const f = $("textarea, input, [data-ok]", s);
|
||||
const f = $("textarea, input:not([type=checkbox]):not([type=radio]), [data-ok]", s);
|
||||
if (f) f.focus();
|
||||
s.close = close;
|
||||
return s;
|
||||
}
|
||||
let toastTimer;
|
||||
function toast(msg) {
|
||||
let t = $(".toast");
|
||||
if (!t) { t = document.createElement("div"); t.className = "toast"; t.setAttribute("role", "status"); document.body.appendChild(t); }
|
||||
t.innerHTML = msg;
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => t.remove(), 4000);
|
||||
|
||||
/* ---------- 下拉选择(listbox)与下拉菜单:.pop[data-select] / .pop[data-menu],选中后在 .pop 上派发 change ---------- */
|
||||
function closePops(except) {
|
||||
$$(".pop.open").forEach((p) => { if (p !== except) { p.classList.remove("open"); const t = $("[aria-expanded]", p); if (t) t.setAttribute("aria-expanded", "false"); const m = $(".menu,.cal", p); if (m) m.hidden = true; } });
|
||||
}
|
||||
function initSelect(p) {
|
||||
const trg = $(".select, [data-menu-trigger]", p), menu = $(".menu", p);
|
||||
if (!trg || !menu || p.dataset.ready) return;
|
||||
p.dataset.ready = 1;
|
||||
menu.hidden = true;
|
||||
const opts = () => $$('[role=option]:not([aria-disabled="true"]), [role=menuitem]:not([aria-disabled="true"])', menu);
|
||||
let ai = -1;
|
||||
const mark = (i) => { opts().forEach((o, k) => o.classList.toggle("is-active", k === i)); ai = i; const o = opts()[i]; if (o) o.scrollIntoView({ block: "nearest" }); };
|
||||
const open = () => { closePops(p); p.classList.add("open"); menu.hidden = false; trg.setAttribute("aria-expanded", "true"); const sel = opts().findIndex((o) => o.getAttribute("aria-selected") === "true"); mark(sel < 0 ? 0 : sel); };
|
||||
const close = () => { p.classList.remove("open"); menu.hidden = true; trg.setAttribute("aria-expanded", "false"); };
|
||||
const pick = (o) => {
|
||||
if (o.getAttribute("role") === "option") {
|
||||
$$("[role=option]", menu).forEach((x) => x.setAttribute("aria-selected", x === o ? "true" : "false"));
|
||||
const v = $(".val", trg); if (v) v.innerHTML = o.dataset.label || o.firstChild.textContent;
|
||||
p.closest(".field")?.classList.remove("err");
|
||||
p.dispatchEvent(new CustomEvent("change", { 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(); });
|
||||
})();
|
||||
|
||||
40
docs/prototype/assets/data.js
vendored
40
docs/prototype/assets/data.js
vendored
@@ -220,13 +220,13 @@ window.PROTO = {
|
||||
files: "商务册 v3、技术册 v3、报价册 v2",
|
||||
resolved: 2,
|
||||
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 万元。", "这一项会导致投标无效。请核对开标一览表的报价。"],
|
||||
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 行和对应产品参数中更正内存配置。"],
|
||||
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 页《法定代表人授权委托书》的「投标人(电子签章)」位置是空的。本检查只看有无,不鉴定真伪。", "这一项会导致投标无效。请在递交前补盖单位电子签章。"],
|
||||
cites: [["T", 24, "t24-auth", "加盖单位电子签章"], ["B1", 5, "b1-auth-seal", "投标人(电子签章):(签章位置为空)"]] },
|
||||
{ id: "f4", sev: "major", item: "c22", title: "缺少 ★ 参数的证明材料:核心交换机硬件 BFD",
|
||||
@@ -250,17 +250,37 @@ window.PROTO = {
|
||||
],
|
||||
manual: [
|
||||
{ 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"] }
|
||||
]
|
||||
},
|
||||
|
||||
/* 工作台的项目列表。演示里的「现在」是 11月2日 15:00;hoursLeft 是距截止的小时数(已截止为负)。
|
||||
* due:[主文字, 辅助灰小字];pend:待确认的清单条目数;next:「下一步」及链接;menu:「更多」里的操作 [文字, 说明或 "cost"(扣 1 次检查), 链接] */
|
||||
projects: [
|
||||
{ id: "p1", hoursLeft: 408, name: "某市政务服务中心智慧大厅建设项目", buyer: "某市政务服务管理办公室", lot: "采购包 1", due: "11月20日 09:30 · 剩 17 天", dueCls: "", status: "已检查", ready: "未就绪", fatal: 3, href: "results.html" },
|
||||
{ id: "p2", hoursLeft: 53, name: "某区智慧社区平台运维服务", buyer: "某区民政局", lot: "全部", due: "剩 2 天 5 小时", dueCls: "due-soon", status: "已检查", ready: "就绪", fatal: 0, href: "project.html" },
|
||||
{ id: "p3", hoursLeft: 7, name: "某县数据中心机房改造项目", buyer: "某县大数据管理局", lot: "标段 1", due: "今天 17:00 截止", dueCls: "due-today", status: "编制中", ready: "", fatal: 0, href: "checklist.html" },
|
||||
{ id: "p4", hoursLeft: 600, name: "某市医保信息系统等保测评服务", buyer: "某市医疗保障局", lot: "全部", due: "11月28日 10:00 · 剩 25 天", dueCls: "", status: "跟进中", ready: "", fatal: 0, href: "project-new.html" },
|
||||
{ id: "p5", hoursLeft: 9999, name: "某区智慧校园一期", buyer: "某区教育局", lot: "全部", due: "已截止", dueCls: "due-past", status: "已递交", ready: "", fatal: 0, href: "project.html" },
|
||||
{ id: "p6", hoursLeft: 9999, name: "某市不动产登记信息平台升级", buyer: "某市自然资源和规划局", lot: "采购包 2", due: "已截止", dueCls: "due-past", status: "已归档", ready: "", fatal: 0, href: "project.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",
|
||||
next: ["确认清单", "checklist.html"], menu: [["上传投标文件", "", "bid-check.html"], ["发起检查", "cost", "bid-check.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",
|
||||
next: ["导出检查报告", "report.html"], menu: [["查看检查结果", "", "results.html"], ["标记为已递交", "", "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",
|
||||
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" }
|
||||
]
|
||||
};
|
||||
|
||||
@@ -1,82 +1,211 @@
|
||||
<!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">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>上传投标文件并检查 · 标博士原型</title>
|
||||
<link rel="icon" href="../design/logo/favicon.svg">
|
||||
<link rel="stylesheet" href="../design/system/tokens.css">
|
||||
<link rel="stylesheet" href="../design/system/fonts-local.css">
|
||||
<link rel="stylesheet" href="../design/system/components.css">
|
||||
<link rel="stylesheet" href="assets/app.css">
|
||||
<style>
|
||||
.narrow{max-width:860px}
|
||||
.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)}
|
||||
.vol + .vol{margin-top:var(--sp-2)}
|
||||
.vol.new{border-color:var(--c-ink)}
|
||||
.vol.bad{border-color:var(--warn);background:var(--warn-soft)}
|
||||
.vol .ext{font:600 11px/1 var(--font-figures);border:1px solid var(--border-control);border-radius:2px;padding:4px 0;text-align:center}
|
||||
.scope{display:grid;grid-template-columns:1fr 1fr;gap:var(--sp-4)}
|
||||
.scope ul{margin:var(--sp-1) 0 0;padding-left:1.2em}
|
||||
/* 主列 + 右栏(--aside-w),间距 --gap-card */
|
||||
.content > .alert{margin-bottom:var(--gap-card)}
|
||||
.bc{display:grid;grid-template-columns:minmax(0,1fr) var(--aside-w);gap:var(--gap-card);align-items:start}
|
||||
.card-b.form-b{padding-top:var(--sp-5);padding-bottom:var(--sp-6)}
|
||||
.card-f .cost{margin-right:auto}
|
||||
.card-f .cost .hint-u{margin-left:var(--sp-1)}
|
||||
|
||||
/* 分册:文件行 + 分册类型 + 移除;读不了的文件写原因(赭色) */
|
||||
.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}
|
||||
.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)}
|
||||
.foot-bar .cost{margin-right:auto}
|
||||
.scope li::marker{color:var(--text-secondary)}
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="content narrow">
|
||||
<div class="page-title"><div><h1>上传投标文件并检查</h1><p>只处理加密前的 Word / PDF 稿。每次重新上传都是一个新版本,检查结果关联到具体版本。</p></div></div>
|
||||
<div class="alert"><span class="ico i">i</span><p>清单 v3 还是草稿(补遗 1 并入后没有重新确认),检查结果以当前清单为准。<a class="plain" href="checklist.html">去确认清单</a></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 class="content">
|
||||
<div class="page-title">
|
||||
<div><h1>上传投标文件并检查</h1><p>只读加密前的 Word / PDF 稿。每次上传都是新版本,检查结果对应到具体版本。</p></div>
|
||||
</div>
|
||||
|
||||
<div class="foot-bar">
|
||||
<span class="cost">24 小时内复查不扣额度:从上次扣点的检查(11-01 16:00)算起,至今天 16:00</span>
|
||||
<a class="btn" href="project.html">取消</a>
|
||||
<button class="btn primary" data-start>开始检查</button>
|
||||
<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>
|
||||
|
||||
<div class="bc">
|
||||
<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 data-run></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="assets/data.js"></script>
|
||||
<script src="assets/app.js"></script>
|
||||
<script>
|
||||
const { D, $, toast } = App;
|
||||
const { D, $, $$, esc, icon, toast, dialog } = App;
|
||||
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 = [
|
||||
{ ext: "DOCX", name: "商务册", file: "商务册-v3.docx", ver: "v3", info: "64 页 · 11-02 14:12" },
|
||||
{ ext: "DOCX", name: "技术册", file: "技术册-v3.docx", ver: "v3", info: "212 页 · 11-02 14:12" },
|
||||
{ ext: "DOCX", name: "报价册", file: "报价册-v2.docx", ver: "v2", info: "8 页 · 11-01 15:40" }
|
||||
{ ext: "DOCX", type: "商务册", file: "商务册-v3.docx", ver: "v3", info: "64 页 · 11月2日 14:12" },
|
||||
{ ext: "DOCX", type: "技术册", file: "技术册-v3.docx", ver: "v3", info: "212 页 · 11月2日 14:12" },
|
||||
{ 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() {
|
||||
$("[data-vols]").innerHTML = vols.map((v) => `<div class="vol ${v.cls || ""}"><span class="ext">${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>
|
||||
<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>
|
||||
<button class="btn sm text" data-rm="${v.file}">移除</button></div>`).join("");
|
||||
$("[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><span class="name"><b>${v.bad ? "无法读取" : v.type}</b>${v.bad ? "" : `<span class="tag">${v.ver}</span>`}${v.isNew ? '<span class="flag">新版本</span>' : ""}</span>
|
||||
${v.bad ? `<span class="hint-warn"><span class="ico w" aria-hidden="true">!</span><span>${esc(v.file)}:电子投标客户端加密后的文件,标博士读不了。请上传加密前的 Word 或 PDF 稿。</span></span>`
|
||||
: `<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();
|
||||
$("[data-add]").onclick = () => {
|
||||
vols[2] = { ext: "DOCX", name: "报价册", file: "报价册-v3.docx", ver: "v3", info: "8 页 · 刚刚上传", cls: "new" };
|
||||
vols.push({ ext: "KEDT", name: "资格证明册", file: "资格证明册.kedt", ver: "—", info: "这是电子投标客户端生成的加密文件,标博士读不了。请上传加密前的 Word 或 PDF 稿。", cls: "bad" });
|
||||
render(); toast("报价册已更新为 v3;有 1 个文件无法读取");
|
||||
};
|
||||
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(); } });
|
||||
$("[data-start]").onclick = () => {
|
||||
$("[data-start]").disabled = true; $("[data-start]").textContent = "正在检查…";
|
||||
/* 分册类型下拉:change 冒泡,在容器上委托 */
|
||||
$("[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}」`); });
|
||||
$("[data-vols]").addEventListener("click", (e) => {
|
||||
const b = e.target.closest("[data-rm]"); if (!b) return;
|
||||
const [v] = vols.splice(+b.dataset.rm, 1); render();
|
||||
toast(`已移除 ${esc(v.file)}`, { undo: () => { vols.splice(+b.dataset.rm, 0, v); render(); } });
|
||||
});
|
||||
/* 演示:上传一次 = 报价册更新到 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 = ["解析投标文件分册", "逐条找响应", "形式要件与签章位置", "资质匹配", "报价形式", "沿用上次处理状态"];
|
||||
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>
|
||||
${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 tick = () => { draw(); if (i++ < S.length) setTimeout(tick, 700); };
|
||||
tick();
|
||||
};
|
||||
const box = $("[data-run]");
|
||||
const draw = () => {
|
||||
const fin = i >= S.length;
|
||||
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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,184 +1,297 @@
|
||||
<!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">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>读标 · 标博士原型</title>
|
||||
<link rel="icon" href="../design/logo/favicon.svg">
|
||||
<link rel="stylesheet" href="../design/system/tokens.css">
|
||||
<link rel="stylesheet" href="../design/system/fonts-local.css">
|
||||
<link rel="stylesheet" href="../design/system/components.css">
|
||||
<link rel="stylesheet" href="assets/app.css">
|
||||
<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)}
|
||||
.cl-bar h2{font:var(--fw-bold) var(--fs-h2)/var(--lh-h2) var(--font-serif);margin:0}
|
||||
.cl-bar .actions{margin-left:auto;display:flex;gap:var(--sp-2);align-items:center}
|
||||
main.main{height:100vh;overflow:hidden}
|
||||
.split{display:grid;grid-template-columns:200px minmax(0,1fr) minmax(420px,45%);flex:1;min-height:0}
|
||||
.cats{border-right:1px solid var(--border-subtle);padding:var(--sp-4) var(--sp-3);overflow:auto}
|
||||
.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}
|
||||
.cats button:hover{background:var(--bg-hover)}
|
||||
.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}
|
||||
.cats .hint{font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary);padding:0 var(--sp-3)}
|
||||
.list{overflow:auto;padding:var(--sp-4) var(--sp-6) var(--sp-12)}
|
||||
.list .alert{margin-bottom:var(--sp-4)}
|
||||
.group{margin-top:var(--sp-5)}
|
||||
.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}
|
||||
.group > h3 .fig{font-weight:500;color:var(--text-secondary);font-size:14px}
|
||||
.items{background:var(--bg-surface);border:1px solid var(--border-subtle);border-radius:var(--radius-sm);padding:0 var(--sp-4)}
|
||||
.item{cursor:pointer;margin:0 calc(-1 * var(--sp-4));padding:var(--sp-3) var(--sp-4)}
|
||||
.item:hover{background:var(--bg-hover)}
|
||||
.item.sel{background:var(--ai-soft);box-shadow:inset 3px 0 0 var(--ai)}
|
||||
.item .upd{margin-top:6px;display:flex;gap:var(--sp-2);align-items:center;font-size:var(--fs-caption)}
|
||||
.item .upd .btn{height:24px;font-size:var(--fs-caption)}
|
||||
.right{border-left:1px solid var(--border-subtle);display:flex;min-height:0}
|
||||
.right .viewer{flex:1;border:0;border-radius:0}
|
||||
.lot-note{font-size:var(--fs-caption);color:var(--text-secondary)}
|
||||
.status-line{display:flex;gap:var(--sp-2);align-items:center}
|
||||
.status-line .sign{margin:0;font-size:var(--fs-caption)}
|
||||
.hist{width:100%;border-collapse:collapse;font-size:var(--fs-body)}
|
||||
.hist td,.hist th{text-align:left;padding:6px 8px;border-bottom:1px solid var(--border-subtle);vertical-align:top}
|
||||
.hist th{font-size:var(--fs-caption);color:var(--text-secondary);font-weight:var(--fw-bold)}
|
||||
/* 清单栏:标题 + 清单状态;右侧页面级操作(主按钮一个,Q12) */
|
||||
.cl-bar{display:flex;align-items:center;gap:var(--sp-3);padding:var(--sp-4) var(--pad-page) var(--sp-3)}
|
||||
.cl-bar h2{margin:0;font:var(--fw-bold) var(--fs-title)/var(--lh-title) var(--font-sans);white-space:nowrap}
|
||||
.cl-bar .status{display:flex;align-items:center;gap:var(--sp-2);min-width:0;white-space:nowrap;overflow:hidden}
|
||||
.cl-bar .status .sign{margin:0}
|
||||
.cl-bar .status .cap{overflow:hidden;text-overflow:ellipsis}
|
||||
.cl-bar .actions{margin-left:auto;display:flex;gap:var(--sp-2);align-items:center;flex:none}
|
||||
.cl-bar .actions .lnk{margin-right:var(--sp-2)}
|
||||
|
||||
/* 清单 */
|
||||
.lst{scroll-padding-top:72px}
|
||||
.lst-tb{position:sticky;top:0;z-index:2;display:flex;align-items:center;gap:var(--sp-4);padding:var(--sp-3) var(--card-x);background:var(--bg-surface);border-bottom:var(--line) solid var(--border-subtle)}
|
||||
.lst-tb .search{flex:1;min-width:0}
|
||||
.lst-tb .search .input{height:var(--control-h-sm)}
|
||||
.lst-tb .search > .ic{top:8px}
|
||||
.lst-tb .choice{flex:none;white-space:nowrap;align-items:center}
|
||||
.lst-tb .choice .ck{margin-top:0}
|
||||
.lot-note{margin:0;padding:var(--sp-3) var(--card-x) 0;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary)}
|
||||
.lot-note .fig{font-size:var(--fs-figure-xs)}
|
||||
.rule{display:flex;gap:var(--sp-2);align-items:flex-start;padding:var(--sp-2) var(--card-x) 0}
|
||||
.rule > .ic{margin-top:3px;flex:none;color:var(--text-secondary)}
|
||||
.rule p{margin:0}
|
||||
.rule .cite-link{margin:0 -4px;padding:0 4px}
|
||||
.lst .item{padding:var(--sp-3) var(--card-x) var(--sp-3) var(--sp-3);border-top:var(--line) solid var(--border-subtle)}
|
||||
.lst .item .t{font-weight:var(--fw-medium)}
|
||||
.lst .item .side{padding-top:1px;white-space:nowrap}
|
||||
.lst .item .cite-link{margin:0;padding:0 4px;font-size:var(--fs-caption)}
|
||||
.lst .item .cite-link .fig{font-size:var(--fs-figure-xs)}
|
||||
.lst .item .upd .flag b{color:var(--warn-text);font-weight:var(--fw-bold)}
|
||||
.lst .item .meta .lnk{font-size:var(--fs-caption)}
|
||||
.lst .pq{cursor:default}
|
||||
.lst .pq:hover{background:none}
|
||||
.lst .pq .ai-block{padding-top:0;padding-bottom:0}
|
||||
.lst .pq .f-actions{margin-top:var(--sp-2)}
|
||||
.lst .end{height:var(--sp-8)}
|
||||
|
||||
/* 修改记录(对话框里的表格) */
|
||||
.hist td:nth-child(-n+3){white-space:nowrap}
|
||||
.hist .old{white-space:normal}
|
||||
.dialog .input-unit.pg{width:200px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="cl-bar">
|
||||
<h2>项目检查清单</h2>
|
||||
<div class="status-line" data-status></div>
|
||||
<div class="status" data-status></div>
|
||||
<div class="actions">
|
||||
<a class="plain cap" href="#" data-missing>反馈:有遗漏的要求</a>
|
||||
<button class="btn" data-add>新增条目</button>
|
||||
<a class="lnk cap" href="#" data-missing>反馈遗漏</a>
|
||||
<button class="btn" data-add><i data-ic="plus"></i>新增条目</button>
|
||||
<button class="btn primary" data-confirm>确认清单</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="split">
|
||||
<nav class="cats" aria-label="清单分类" data-cats></nav>
|
||||
<div class="list" data-list></div>
|
||||
<div class="right"><div data-viewer></div></div>
|
||||
|
||||
<div class="cmp" data-cmp>
|
||||
<section class="card flush cmp-list" aria-label="项目检查清单">
|
||||
<nav class="cats" aria-label="清单分类" data-cats></nav>
|
||||
<div class="lst" data-list></div>
|
||||
</section>
|
||||
<div class="rsz" role="separator" aria-orientation="vertical" aria-label="调整原文查看器宽度(← → 键)" tabindex="0"></div>
|
||||
<section aria-label="原文查看器" data-viewer></section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="assets/data.js"></script>
|
||||
<script src="assets/app.js"></script>
|
||||
<script>
|
||||
const { D, $, $$, esc, dialog, toast } = App;
|
||||
App.shell({ crumbs: [["工作台", "workbench.html"], [D.project.name, "project.html"], ["读标", ""]], tab: "checklist" });
|
||||
const { D, $, $$, esc, icon, dialog, toast } = App;
|
||||
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 }));
|
||||
let pending = D.pending.map((q) => ({ ...q }));
|
||||
let cat = "all", sel = null, confirmed = false;
|
||||
const SRC = { ai: '<span class="src-ai">AI 提取</span>', manual: "人工添加" };
|
||||
const dropped = []; // 丢弃的待确认条目,可在「修改记录」里看到
|
||||
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() {
|
||||
$("[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">草稿 v3</span><span class="cap muted">补遗 1 并入后待重新确认 · 上一版 v2 已确认</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">草稿 v${ver}</span><span class="cap muted">${ver === 3 ? "补遗 1 并入后待重新确认" : "有修改,待重新确认"} · 上一版 v${lastOk} 已确认</span>`;
|
||||
$("[data-confirm]").hidden = confirmed;
|
||||
}
|
||||
/* 已确认的清单再修改(新增、采纳):回到草稿,版本号加一,历史保留 */
|
||||
function edited() { if (confirmed) { confirmed = false; lastOk = ver; ver++; toast(`清单回到草稿 v${ver},v${lastOk} 保留在历史里`, { ok: false }); } }
|
||||
function cats() {
|
||||
const n = (id) => items.filter((i) => i.cats.includes(id)).length;
|
||||
$("[data-cats]").innerHTML =
|
||||
`<button data-cat="all" class="${cat === "all" ? "on" : ""}">全部<span class="fig">${items.length}</span></button>` +
|
||||
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><button data-cat="pending" class="${cat === "pending" ? "on" : ""}" style="color:var(--ai)">待确认<span class="fig" style="color:var(--ai)">${pending.length}</span></button>
|
||||
<hr><p class="hint">一条要求可以属于多个分类,例如 ★ 参数同时在「技术参数」和「无效条款」里。</p>`;
|
||||
const b = (id, name, num, cls = "") => `<button class="${cls}" data-cat="${id}" aria-current="${cat === id}">${name}<span class="fig">${num}</span></button>`;
|
||||
$("[data-cats]").innerHTML = b("all", "全部", items.length) + D.categories.map((c) => b(c.id, c.name, n(c.id))).join("") +
|
||||
`<hr>${b("pending", "待确认", pending.length, "ai")}
|
||||
<p class="hint cap"><span class="kbd">J</span><span class="kbd">K</span> 上下条<br><span class="kbd">Enter</span> 看原文<br><span class="kbd">?</span> 快捷键</p>`;
|
||||
}
|
||||
/* 「全部」视图下每条只出现一次,放在它的第一个分类里;一条要求可以属于多个分类 */
|
||||
const visible = () => {
|
||||
const seen = new Set(), out = [];
|
||||
const groups = cat === "all" ? D.categories : D.categories.filter((c) => c.id === cat);
|
||||
groups.forEach((g) => items.filter((i) => i.cats.includes(g.id) && (cat !== "all" || !seen.has(i.id)) && (!star || i.imp === "★") && (!kw || i.title.includes(kw))).forEach((i) => { seen.add(i.id); out.push({ ...i, g: g.id }); }));
|
||||
return out;
|
||||
};
|
||||
function itemRow(i) {
|
||||
const src = i.src === "addendum" ? i.srcName : SRC[i.src];
|
||||
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 = i.cats.map((c) => `<span class="tag">${D.categories.find((x) => x.id === c).name}</span>`).join("") +
|
||||
`<span class="tag">${i.lot ? "采购包 " + i.lot : "通用"}</span>` + (i.edited ? `<span class="flag">已修改 · <a class="plain" href="#" data-hist="${i.id}">查看记录</a></span>` : "");
|
||||
return `<div class="item ${sel === i.id ? "sel" : ""}" data-item="${i.id}" tabindex="0">
|
||||
<span class="imp">${i.imp || ""}</span>
|
||||
const src = i.src === "ai" ? '<span class="tag ai">AI</span>' : `<span class="tag">${i.src === "addendum" ? i.srcName : "人工添加"}</span>`;
|
||||
const meta = src + i.cats.map((c) => `<span class="tag">${catName(c)}</span>`).join("") + `<span class="tag">${i.lot ? "采购包 " + i.lot : "通用"}</span>` +
|
||||
(i.noCite ? '<span class="flag">人工确认、无出处</span>' : "") +
|
||||
(i.edited ? `<span class="flag">已修改 · <a class="lnk" href="#" data-hist="${i.id}">查看记录</a></span>` : "");
|
||||
const upd = i.updated ? `<div class="upd"><span class="flag"><b>已被${i.srcName} 更新</b> · 原为 <span class="old">${esc(i.old)}</span></span><button class="btn" data-ack="${i.id}">已核对</button></div>` : "";
|
||||
return `<div class="item ${sel === i.id ? "sel" : ""}" data-item="${i.id}" tabindex="${sel === i.id ? 0 : -1}"><span class="imp">${i.imp || ""}</span>
|
||||
<div><div class="t">${esc(i.title)}</div><div class="meta">${meta}</div>${upd}</div>
|
||||
<div class="side">${src}<br><a class="cite-src" href="#">${App.citeLabel(i.cite).replace(/^.*? · /, "")}</a></div>
|
||||
</div>`;
|
||||
<div class="side">${i.noCite ? '<span class="muted">无出处</span>' : `<a class="cite-link" href="#" data-cite>${page(i.cite)}</a>`}</div></div>`;
|
||||
}
|
||||
/* 待确认(§7.6):放在清单顶部,不和正式清单混排;条目是未确认的 AI 产出,左侧 2px 蓝铅笔虚线(Q2)。确认前不参与检查 */
|
||||
function pendingBox() {
|
||||
if (!pending.length) return "";
|
||||
return `<div class="pending" style="margin-bottom:var(--sp-5)"><h4>待确认(<span class="fig">${pending.length}</span>)</h4>
|
||||
<p>这些结果没有找到可靠出处,或系统把握不大。确认之前,不参与检查。</p>
|
||||
${pending.map((q) => `<div class="item" style="cursor:default" data-q="${q.id}"><span class="imp">${q.imp || ""}</span>
|
||||
<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><span></span></div>`).join("")}</div>`;
|
||||
return `<section aria-label="待确认"><div class="grp-h ai"><b>待确认</b><span class="fig">${pending.length}</span><p>没找到可靠出处,或把握不大。确认前不参与检查。</p></div>
|
||||
${pending.map((q) => `<div class="item pq" data-q="${q.id}"><span class="imp">${q.imp || ""}</span>
|
||||
<div class="ai-block"><div class="t">${esc(q.title)}</div><div class="meta"><span class="tag ai">AI</span><span class="flag">${q.reason}</span></div>
|
||||
<div class="f-actions">${q.cite ? '<button class="btn sm" data-qa="adopt">采纳</button>' : '<button class="btn sm" data-qa="cite">补上出处后采纳</button>'}<button class="btn sm" data-qa="edit">修改后采纳</button><button class="btn sm text" data-qa="drop">丢弃</button></div></div>
|
||||
<div class="side">${q.cite ? `<a class="cite-link" href="#" data-qopen>${page(q.cite)}</a>` : '<span class="muted">无出处</span>'}</div></div>`).join("")}</section>`;
|
||||
}
|
||||
function list() {
|
||||
const 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 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>`;
|
||||
if (cat === "pending") return $("[data-list]").innerHTML = pendingBox() || '<p class="muted">没有待确认的条目。</p>';
|
||||
const groups = cat === "all" ? D.categories : D.categories.filter((c) => c.id === cat);
|
||||
const seen = new Set();
|
||||
$("[data-list]").innerHTML = lot + (cat === "all" ? head + pendingBox() : "") + groups.map((g) => {
|
||||
// 「全部」视图下每条只出现一次,放在它的第一个分类里
|
||||
const rows = items.filter((i) => i.cats.includes(g.id) && (cat !== "all" || !seen.has(i.id)));
|
||||
rows.forEach((i) => seen.add(i.id));
|
||||
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>` : "";
|
||||
}).join("");
|
||||
const L = $("[data-list]");
|
||||
const focusKw = document.activeElement?.matches?.("[data-kw]");
|
||||
const tb = `<div class="lst-tb"><label class="search"><i data-ic="search"></i><input class="input" placeholder="搜索清单" aria-label="搜索清单" data-kw value="${esc(kw)}"></label><label class="choice cap"><input type="checkbox" class="ck" data-star ${star ? "checked" : ""}>只看 ★</label></div>`;
|
||||
if (cat === "pending") {
|
||||
L.innerHTML = (pendingBox() || '<div class="empty sm"><b>没有待确认的条目</b></div>') + '<div class="end"></div>';
|
||||
} else {
|
||||
const v = visible();
|
||||
const groups = cat === "all" ? D.categories : D.categories.filter((c) => c.id === cat);
|
||||
const body = groups.map((g) => { const rows = v.filter((i) => i.g === g.id); return rows.length ? `<section><div class="grp-h"><b>${g.name}</b><span class="fig">${rows.length}</span></div>${rows.map(itemRow).join("")}</section>` : ""; }).join("");
|
||||
const head = `<p class="lot-note">通用 + 采购包 1 · 已隐藏采购包 2 的 <span class="fig">${D.hiddenLotItems}</span> 条 · <a class="lnk" href="lots.html">更改所投的包</a></p>` +
|
||||
(cat === "all" && !kw && !star ? `<div class="rule"><i data-ic="info"></i><p>招标文件 <a class="cite-link" href="#" data-rule>第 <span class="fig">24</span> 页</a>:打「★」的为实质性要求。文件没写 ★ 条款总数,已识别 <span class="fig m">4</span> 条,请对照原文核对有无遗漏。</p></div>${pendingBox()}` : "");
|
||||
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(); }
|
||||
|
||||
/* ---------- 交互 ---------- */
|
||||
document.addEventListener("click", (e) => {
|
||||
const t = e.target;
|
||||
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.matches("[data-hist]")) { e.preventDefault(); history(t.dataset.hist); 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.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-qopen]")) { e.preventDefault(); const q = pending.find((x) => x.id === t.closest("[data-qopen]").dataset.qopen); V.open(q.cite); return; }
|
||||
if (t.matches("[data-qa]")) { pendingAct(t.closest("[data-q]").dataset.q, t.dataset.qa); return; }
|
||||
if (t.closest("[data-qopen]")) { e.preventDefault(); V.open(pending.find((x) => x.id === t.closest("[data-q]").dataset.q).cite); return; }
|
||||
if (t.closest("[data-qa]")) { pendingAct(t.closest("[data-q]").dataset.q, t.closest("[data-qa]").dataset.qa); return; }
|
||||
if (t.closest("[data-clear]")) { kw = ""; list(); return; }
|
||||
const it = t.closest("[data-item]");
|
||||
if (it) { 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) {
|
||||
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 === "adopt") { adopt(q, q.cite, false); return; }
|
||||
if (act === "drop") {
|
||||
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({
|
||||
title: act === "cite" ? "补上出处后采纳" : "修改后采纳",
|
||||
body: `<div class="field"><label for="qt">要求</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>
|
||||
<label class="row" style="gap:8px;margin-top:var(--sp-3)"><input type="checkbox" id="qn" style="accent-color:var(--c-ink)"> 找不到出处,按「人工确认、无出处」采纳</label>`,
|
||||
ok: "采纳",
|
||||
onOk: (s) => { const pg = $("#qp", s).value.trim(); q.title = $("#qt", s).value.trim() || q.title; adopt(q, pg ? ["T", +pg, ""] : null, !pg); }
|
||||
title: act === "cite" ? "补上出处后采纳" : "修改后采纳", wide: true, ok: "采纳",
|
||||
body: `<div class="field fill"><label for="qt">要求 <small>(必填)</small></label><input class="input" id="qt" value="${esc(q.title)}"></div>
|
||||
<div class="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="choice"><input type="checkbox" class="ck" id="qn">找不到出处,按「人工确认、无出处」采纳</label></div>`,
|
||||
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(), 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) {
|
||||
pending = pending.filter((x) => x !== q);
|
||||
items.push({ id: "n" + q.id, cats: ["invalid"], imp: q.imp, title: q.title, lot: 0, src: "manual", edited: true, cite: c || ["T", 1, ""] });
|
||||
function restore(q) {
|
||||
dropped.splice(dropped.indexOf(q), 1); pending.push(q);
|
||||
const ord = D.pending.map((x) => x.id); pending.sort((a, b) => ord.indexOf(a.id) - ord.indexOf(b.id));
|
||||
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) {
|
||||
const i = items.find((x) => x.id === id);
|
||||
const u = D.user.name;
|
||||
const rows = id === "c26"
|
||||
? [["11-01 10:42", "系统", "AI 提取", "—", "接受联合体投标"], ["11-01 11:05", u, "修改", "接受联合体投标", "不接受联合体投标"], ["11-01 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, "核对补遗更新", "—", "已核对"]];
|
||||
dialog({ title: "修改记录", cancel: null, ok: "关闭", wide: true,
|
||||
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>` });
|
||||
let rows;
|
||||
if (id === "c26") rows = [["11月1日 10:42", "系统", "AI 提取", "—", "接受联合体投标"], ["11月1日 11:05", u, "修改", "接受联合体投标", "不接受联合体投标"], ["11月1日 11:06", u, "备注", "—", "原文写的是「否」,AI 读反了"]];
|
||||
else if (i.adoptedFrom) {
|
||||
const orig = D.pending.find((x) => x.id === i.adoptedFrom.id).title;
|
||||
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({
|
||||
title: "确认清单 v3?",
|
||||
body: `<p>确认后,查标会以这一版清单为依据。之后再修改清单,会回到草稿并保留历史,已有的检查结果不受影响。</p>${pending.length ? `<div class="alert warn"><span class="ico w">!</span><p><b>还有 ${pending.length} 条待确认。</b>它们不会进入这一版清单,也不参与检查。</p></div>` : ""}`,
|
||||
ok: "确认清单",
|
||||
onOk: () => { confirmed = true; render(); toast("清单 v3 已确认"); }
|
||||
title: `确认清单 v${ver}?`, ok: "确认清单",
|
||||
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>` : ""}`,
|
||||
onOk: () => { confirmed = true; status(); toast(`清单 v${ver} 已确认`); }
|
||||
});
|
||||
$("[data-add]").onclick = () => dialog({
|
||||
title: "新增条目",
|
||||
body: `<div class="field"><label for="na">要求</label><textarea class="input" id="na" placeholder="写下招标文件里的要求"></textarea></div>
|
||||
<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>
|
||||
<p class="cap muted">人工添加的条目可以没有出处,会标注「人工添加」。</p>`,
|
||||
ok: "添加",
|
||||
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("已添加"); }
|
||||
});
|
||||
$("[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("已收到反馈,谢谢") }); };
|
||||
$("[data-add]").onclick = () => {
|
||||
let ncat = "invalid";
|
||||
dialog({
|
||||
title: "新增条目", wide: true, ok: "添加",
|
||||
body: `<div class="field fill" id="na-f"><label for="na">要求 <small>(必填)</small></label><textarea class="input" id="na" placeholder="写下招标文件里的要求"></textarea></div>
|
||||
<div class="form" style="margin-top:var(--sp-4)"><div class="field"><label>分类</label><div class="pop" data-select 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>
|
||||
<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>`,
|
||||
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();
|
||||
V.open(items[0].cite); sel = items[0].id; list();
|
||||
select(sel);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -10,73 +10,106 @@
|
||||
<link rel="stylesheet" href="../design/system/components.css">
|
||||
<link rel="stylesheet" href="assets/app.css">
|
||||
<style>
|
||||
.page{max-width:1120px;margin:0 auto;padding:var(--sp-12) var(--sp-6) var(--sp-16)}
|
||||
.page > header img{height:36px}
|
||||
.page h1{font:600 32px/40px var(--font-serif);margin:var(--sp-6) 0 var(--sp-2)}
|
||||
.lede{color:var(--text-secondary);max-width:52em;margin:0}
|
||||
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)}
|
||||
.journey{display:grid;grid-template-columns:repeat(4,1fr);gap:var(--sp-3);counter-reset:s}
|
||||
.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}
|
||||
.journey a:hover{border-color:var(--c-ink)}
|
||||
.journey a::before{content:counter(s);font:600 22px/1 var(--font-figures);color:var(--text-secondary);display:block;margin-bottom:var(--sp-2)}
|
||||
.journey b{display:block;font-size:var(--fs-title)}
|
||||
.journey span{font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary);display:block;margin-top:4px}
|
||||
.journey .file{position:absolute;top:var(--sp-4);right:var(--sp-4);font:12px var(--font-figures);color:var(--ai)}
|
||||
table.data td{height:auto}
|
||||
.two{display:grid;grid-template-columns:1fr 1fr;gap:var(--sp-6)}
|
||||
.two ul{margin:0;padding-left:1.2em}
|
||||
.two li{margin:4px 0}
|
||||
/* 原型目录:不用侧栏框架;白色主调、白卡片、圆角三档 */
|
||||
body{min-width:1280px}
|
||||
.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)}
|
||||
.dir-top img{height:26px;display:block}
|
||||
.dir-top .r{margin-left:auto;display:flex;gap:var(--sp-2);align-items:center}
|
||||
.page{max-width:calc(var(--content-max) + 2 * var(--pad-page));margin:0 auto;padding:var(--sp-10) var(--pad-page) var(--sp-16)}
|
||||
.page h1{font:var(--fw-bold) var(--fs-h1)/var(--lh-h1) var(--font-serif);margin:0}
|
||||
.lede{color:var(--text-secondary);margin:var(--sp-2) 0 0;max-width:56em}
|
||||
.sec{margin-top:var(--sp-10)}
|
||||
.sec-h{display:flex;align-items:baseline;gap:var(--sp-3);margin-bottom:var(--sp-4)}
|
||||
.sec-h h2{font:var(--fw-bold) var(--fs-h2)/var(--lh-h2) var(--font-serif);margin:0}
|
||||
.sec-h .ref{margin-left:auto;font-size:var(--fs-caption);color:var(--text-secondary)}
|
||||
|
||||
/* J1 旅程:八步,一行四张 */
|
||||
.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}
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<header><img src="../design/logo/svg/logo-horizontal.svg" alt="标博士"></header>
|
||||
<h1>MVP 可点击原型 v0.1</h1>
|
||||
<p class="lede">按 <code>docs/analysis/02-user-journeys.md</code> 的 J1–J5 走一遍 MVP 链路。示意场景:某市政务服务中心智慧大厅建设项目(政府采购 · 公开招标 · 2 个采购包,投采购包 1)。条款改写自苏采云样本 S02、S10,项目名、金额、页码、投标响应和人名都是示意。直接使用设计规范的变量和组件(<code>design/system/</code>),所以是中保真:重点验证流程、信息结构和红线落地,视觉细节留到 UI 设计阶段精修。</p>
|
||||
<header class="dir-top">
|
||||
<img src="../design/logo/svg/logo-horizontal.svg" alt="标博士">
|
||||
<span class="tag">原型</span>
|
||||
<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>
|
||||
<div class="journey">
|
||||
<a href="login.html"><span class="file">login</span><b>注册 / 登录</b><span>手机号 + 验证码,填企业名称;协议写明不得用于串通投标</span></a>
|
||||
<a href="project-new.html"><span class="file">project-new</span><b>新建项目,上传招标文件</b><span>按文件头识别真实格式;加密文件直接说明原因、不扣点</span></a>
|
||||
<a href="parsing.html"><span class="file">parsing</span><b>等待解析</b><span>分阶段进度,可以离开;失败自动退回额度</span></a>
|
||||
<a href="lots.html"><span class="file">lots</span><b>选择所投的包</b><span>只保留通用 + 所选包的条目;分包可手工更正</span></a>
|
||||
<a href="checklist.html"><span class="file">checklist</span><b>读标:浏览清单,看原文</b><span>左分类、中清单、右原文高亮;待确认区;补遗更新;确认清单</span></a>
|
||||
<a href="bid-check.html"><span class="file">bid-check</span><b>上传投标文件并检查</b><span>多分册、多版本;写明查什么、不查什么;识别加密格式</span></a>
|
||||
<a href="results.html"><span class="file">results</span><b>查标:逐条处理</b><span>出处批注、三种处理、致命项确认需写说明、键盘逐条处理、批量处理提示级</span></a>
|
||||
<a href="report.html"><span class="file">report</span><b>导出报告</b><span>首页写覆盖范围和免责声明;logo 只在页眉</span></a>
|
||||
</div>
|
||||
<section class="sec">
|
||||
<div class="sec-h"><h2>J1 · 从上传到导出报告</h2></div>
|
||||
<ol class="journey">
|
||||
<li><a href="login.html"><span class="no"><code>login</code></span><b>注册 / 登录</b><span class="d">手机号 + 验证码,填企业名称;协议写明不得用于串通投标</span></a></li>
|
||||
<li><a href="project-new.html"><span class="no"><code>project-new</code></span><b>新建项目,上传招标文件</b><span class="d">按文件头识别真实格式;加密文件直接说明原因、不扣点</span></a></li>
|
||||
<li><a href="parsing.html"><span class="no"><code>parsing</code></span><b>等待解析</b><span class="d">分阶段进度,可以离开;失败自动退回额度</span></a></li>
|
||||
<li><a href="lots.html"><span class="no"><code>lots</code></span><b>选择所投的包</b><span class="d">只保留通用 + 所选包的条目;分包可手工更正</span></a></li>
|
||||
<li><a href="checklist.html"><span class="no"><code>checklist</code></span><b>读标:看清单和原文</b><span class="d">分类、清单、原文高亮并排;待确认;补遗更新;确认清单</span></a></li>
|
||||
<li><a href="bid-check.html"><span class="no"><code>bid-check</code></span><b>上传投标文件并检查</b><span class="d">多分册、多版本;写明查什么、不查什么;识别加密格式</span></a></li>
|
||||
<li><a href="results.html"><span class="no"><code>results</code></span><b>查标:逐条处理</b><span class="d">出处批注、三种处理、致命项确认写说明、键盘逐条处理、批量处理提示</span></a></li>
|
||||
<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>
|
||||
<table class="data">
|
||||
<thead><tr><th>页面</th><th>对应</th><th>要点</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><a class="plain" href="workbench.html">工作台</a></td><td>FR-M6-01</td><td>按截止排序;倒计时临近用赭色;就绪只在「已检查」显示;致命计数</td></tr>
|
||||
<tr><td><a class="plain" href="project.html">项目概览</a></td><td>FR-M6-02、M2-09、J2</td><td>状态流;AI 摘要带出处;关键日期(补遗更新);文件与版本;标记已递交(可撤销)、归档、删除(输入项目名确认)</td></tr>
|
||||
<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="plain" href="quota.html">额度</a></td><td>FR-M6-03、J3</td><td>扣点规则可见;流水对应到任务;预扣 / 退回成对出现;复查免扣也有记录</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="plain" href="../design/system/components.html">组件预览</a></td><td>设计规范</td><td>原型用到的全部组件</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<section class="sec">
|
||||
<div class="sec-h"><h2>其他页面</h2></div>
|
||||
<div class="card flush">
|
||||
<table class="data pages">
|
||||
<thead><tr><th>页面</th><th>对应</th><th>要点</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><a class="lnk" href="workbench.html">工作台</a></td><td>FR-M6-01</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="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="lnk" href="quota.html">额度</a></td><td>FR-M6-03、J3</td><td>扣点规则可见;流水对应到任务;预扣 / 退回成对出现;24 小时内复查记「免扣」</td></tr>
|
||||
<tr><td><a class="lnk" href="qualifications.html">资质库</a></td><td>FR-M6-08、J2-3</td><td>到期按项目截止日判断;缺附件显示「待补录」</td></tr>
|
||||
<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>
|
||||
<div class="two">
|
||||
<div class="card"><b>可以点、会变化</b><ul>
|
||||
<li>查标:处理 / 撤销 / 忽略原因校验 / 致命项确认说明 / 批量 / 快捷键 J K O 1 2 3 U ? / 原文抽屉 / 就绪状态与页签计数联动</li>
|
||||
<li>读标:分类筛选、点清单项看原文高亮、待确认区采纳与丢弃、补遗更新核对、修改记录、确认清单</li>
|
||||
<li>解析与检查的进度动画、取消、失败演示</li>
|
||||
<li>项目状态:标记已递交、撤销、归档、删除确认</li>
|
||||
<li>报告:勾选附录、出处后实时重排</li></ul></div>
|
||||
<div class="card"><b>没有做</b><ul>
|
||||
<li>真实文件上传与解析(点上传区就出示例文件)</li>
|
||||
<li>数据不保存,刷新后恢复初始状态</li>
|
||||
<li>原文查看器只做了被引用到的页面</li>
|
||||
<li>免费版的差异(报告角标、额度用完后的升级引导)只在文字里说明</li>
|
||||
<li>运营后台(FR-M6-09)、通知中心、账号设置</li></ul></div>
|
||||
</div>
|
||||
<section class="sec">
|
||||
<div class="sec-h"><h2>模拟了什么、没模拟什么</h2></div>
|
||||
<div class="two">
|
||||
<div class="card"><b>可以点、会变化</b><ul>
|
||||
<li>查标:处理 / 撤销 / 忽略原因 / 致命项确认说明 / 批量 / 快捷键 J K O 1 2 3 X U ? / 原文抽屉 / 就绪状态与标签页计数联动</li>
|
||||
<li>读标:分类筛选、点清单项看原文高亮、待确认的采纳与丢弃、补遗更新核对、修改记录、确认清单</li>
|
||||
<li>解析与检查的进度、取消、失败演示</li>
|
||||
<li>项目状态:标记已递交、撤销、归档、删除确认</li>
|
||||
<li>报告:勾选附录、出处后实时重排</li></ul></div>
|
||||
<div class="card"><b>没有做</b><ul>
|
||||
<li>真实文件上传与解析(点上传区就出示例文件)</li>
|
||||
<li>数据不保存,刷新后恢复初始状态</li>
|
||||
<li>原文查看器只做了被引用到的页面</li>
|
||||
<li>免费版的差异(报告角标、额度用完后的升级引导)只在文字里说明</li>
|
||||
<li>运营后台(FR-M6-09)、通知中心、账号设置、全局搜索</li></ul></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<h2>运行</h2>
|
||||
<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>
|
||||
<section class="sec">
|
||||
<div class="sec-h"><h2>运行</h2></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 && 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>
|
||||
</html>
|
||||
|
||||
@@ -10,55 +10,87 @@
|
||||
<link rel="stylesheet" href="../design/system/components.css">
|
||||
<link rel="stylesheet" href="assets/app.css">
|
||||
<style>
|
||||
body{display:grid;grid-template-columns:minmax(0,1fr) 520px;min-height:100vh;min-width:1280px}
|
||||
.brand-side{padding:var(--sp-16) var(--sp-16) var(--sp-10);display:flex;flex-direction:column;border-right:1px solid var(--border-subtle)}
|
||||
.brand-side img{width:200px}
|
||||
.brand-side .slogan{font:600 32px/1.4 var(--font-serif);margin:var(--sp-12) 0 var(--sp-6)}
|
||||
.points{list-style:none;margin:0;padding:0;max-width:34em}
|
||||
.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 b{display:block;font-size:var(--fs-title)}
|
||||
/* 登录页(v0.2):不用侧栏框架。浅灰页面上一张白卡片放表单;左侧品牌与三句话;按钮、输入框用 40 高(§7.1) */
|
||||
body{min-width:1280px;min-height:100vh;display:flex;flex-direction:column}
|
||||
.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 img{width:200px;display:block}
|
||||
.brand .slogan{font:var(--fw-bold) 28px/1.4 var(--font-serif);margin:var(--sp-10) 0 var(--sp-8)}
|
||||
.points{list-style:none;margin:0;padding:0;max-width:30em;counter-reset:p}
|
||||
.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)}
|
||||
.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}
|
||||
.form-side .tabs{margin:0 0 var(--sp-8);border-bottom:1px solid var(--border-subtle)}
|
||||
|
||||
.login{padding:var(--sp-8)}
|
||||
.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 .input{flex:1}
|
||||
.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)}
|
||||
.agree input{margin-top:3px;accent-color:var(--c-ink)}
|
||||
.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)}
|
||||
.code .input{flex:1;min-width:0}
|
||||
.code .btn{min-width:128px}
|
||||
.agree{display:flex;margin-top:var(--sp-5);font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary)}
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
<section class="brand-side">
|
||||
<img src="../design/logo/svg/logo-horizontal-textured.svg" alt="标博士 BIAOBOSHI">
|
||||
<p class="slogan">标博士,你的AI投标专家</p>
|
||||
<ul class="points">
|
||||
<li><b>读标:每一条要求都有出处</b><span>上传招标文件,逐条提取资格、无效条款、评分、日期和 ★ 参数,点一下就跳到原文那一页。</span></li>
|
||||
<li><b>查标:递交前逐条对一遍</b><span>把投标文件和清单逐条核对,找出没响应、疑似不满足、漏盖章的地方,由你逐条确认。</span></li>
|
||||
<li><b>报告:谁在什么时候确认了什么</b><span>导出带处理记录的检查报告,写明查了什么、没查什么。</span></li>
|
||||
</ul>
|
||||
<p class="legal">辅助检查工具,不替代人工审核,也不对投标结果作任何保证。</p>
|
||||
</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>
|
||||
<div class="stack">
|
||||
<div class="field"><label for="ph">手机号</label><input class="input" id="ph" inputmode="numeric" placeholder="11 位手机号" value="13800000000"></div>
|
||||
<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>
|
||||
<div class="field" data-reg hidden><label for="co">企业名称</label><input class="input" id="co" placeholder="与开票信息一致,例如:南京某信息技术有限公司"><span class="msg">额度和数据归属企业主体,一个手机号只能属于一个企业。</span></div>
|
||||
</div>
|
||||
<label class="agree" data-reg hidden><input type="checkbox" id="ag"><span>我已阅读并同意《服务协议》《隐私政策》。服务协议约定:本产品只用于投标人对自己投标文件的投前自查,不得用于串通投标、帮助他人规避雷同等违法用途。</span></label>
|
||||
<button class="btn primary lg" style="margin-top:var(--sp-6);width:100%;justify-content:center" data-go>登录</button>
|
||||
<div class="free" data-reg hidden>注册即开通免费版:解读 <b>3</b> 次招标文件,检查 <b>1</b> 个项目(项目内复查不限次数)。不需要上传营业执照。</div>
|
||||
</section>
|
||||
<div class="wrap">
|
||||
<section class="brand" aria-label="标博士">
|
||||
<img src="../design/logo/svg/logo-horizontal-textured.svg" alt="标博士 BIAOBOSHI">
|
||||
<p class="slogan">标博士,你的AI投标专家</p>
|
||||
<ol class="points">
|
||||
<li><div><b>读标:每一条要求都有出处</b><span>资格、无效条款、评分、日期和 ★ 参数逐条列出,点一下就到原文那一页。</span></div></li>
|
||||
<li><div><b>查标:递交前逐条对一遍</b><span>找出没响应、疑似不满足、漏盖章的地方,由你逐条确认。</span></div></li>
|
||||
<li><div><b>报告:谁在什么时候确认了什么</b><span>导出带处理记录的检查报告,写明查了什么、没查什么。</span></div></li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section class="card login" aria-label="登录或注册">
|
||||
<div class="tabs" role="tablist" aria-label="登录方式" data-mode>
|
||||
<button role="tab" aria-selected="true" data-v="login">登录</button>
|
||||
<button role="tab" aria-selected="false" data-v="reg">注册</button>
|
||||
</div>
|
||||
<div class="field"><label for="ph">手机号</label><input class="input" id="ph" inputmode="numeric" autocomplete="tel" placeholder="11 位手机号" value="13800000000"></div>
|
||||
<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>
|
||||
<div class="field" data-reg hidden><label for="co">企业名称</label><input class="input" id="co" placeholder="与开票信息一致"><span class="msg">额度和数据归属企业主体,一个手机号只能属于一个企业。</span></div>
|
||||
<div data-reg hidden>
|
||||
<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/app.js"></script>
|
||||
<script>
|
||||
const { $, $$, toast } = App;
|
||||
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-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 位数字)"); };
|
||||
$("[data-mode]").addEventListener("change", (e) => {
|
||||
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 = () => {
|
||||
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";
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -1,54 +1,131 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
选择所投的包(v0.2):解析完成后进入;读标页「更改所投的包」也回到这里。
|
||||
框架来自 App.shell();选择卡(.choice-card)、出处链接(App.citeLink)、原文抽屉(App.drawer)都是共用组件,本页 <style> 只放布局。
|
||||
限价是招标文件里的原文数字(带出处),不是系统给出的报价建议(红线 6)。
|
||||
-->
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>选择所投的包 · 标博士原型</title>
|
||||
<link rel="icon" href="../design/logo/favicon.svg">
|
||||
<link rel="stylesheet" href="../design/system/tokens.css">
|
||||
<link rel="stylesheet" href="../design/system/fonts-local.css">
|
||||
<link rel="stylesheet" href="../design/system/components.css">
|
||||
<link rel="stylesheet" href="assets/app.css">
|
||||
<style>
|
||||
.narrow{max-width:760px}
|
||||
.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}
|
||||
.lot + .lot{margin-top:var(--sp-3)}
|
||||
.lot.on{border-color:var(--c-ink);box-shadow:inset 0 0 0 1px var(--c-ink)}
|
||||
.lot input{width:16px;height:16px;margin:3px 0 0;accent-color:var(--c-ink)}
|
||||
.lot b{font-size:var(--fs-title)}
|
||||
.lot .money{text-align:right}
|
||||
.lot .money .fig{font-size:20px}
|
||||
.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)}
|
||||
.lots-wrap{max-width:800px}
|
||||
.lots{display:flex;flex-direction:column;gap:var(--sp-3);padding-top:var(--sp-1)}
|
||||
.lot{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:var(--sp-3);align-items:start;padding:var(--sp-4)}
|
||||
.lot b{font-size:var(--fs-title);line-height:var(--lh-title)}
|
||||
.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 .sub .cite-link{font-size:var(--fs-caption)}
|
||||
.lot .sub .cite-link .fig{font-size:var(--fs-figure-xs)}
|
||||
.lot .money{text-align:right;white-space:nowrap}
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="content narrow">
|
||||
<div class="page-title"><div><h1>选择要投的包</h1><p>识别到 2 个采购包。清单只保留通用条目和所选包的条目,没选的包折叠隐藏,以后可以改。</p></div></div>
|
||||
<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>
|
||||
<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>
|
||||
<div class="row" style="margin-top:var(--sp-6)">
|
||||
<a class="plain cap" href="#" data-fix>分包识别得不对?手工调整</a>
|
||||
<span style="margin-left:auto"></span>
|
||||
<a class="btn primary" href="checklist.html" data-go>查看清单</a>
|
||||
<div class="content">
|
||||
<div class="lots-wrap">
|
||||
<div class="page-title"><div><h1>选择要投的包</h1><p>识别到 <span class="fig" data-n>2</span> 个采购包。清单只保留通用条目和所选包的条目,以后可以改。</p></div></div>
|
||||
|
||||
<section class="card flush" aria-labelledby="h-lots">
|
||||
<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>
|
||||
<div class="card-b">
|
||||
<div class="lots" role="group" aria-labelledby="h-lots" data-lots></div>
|
||||
<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>
|
||||
</main>
|
||||
<script src="assets/data.js"></script>
|
||||
<script src="assets/app.js"></script>
|
||||
<script>
|
||||
const { D, $, $$, dialog, toast } = App;
|
||||
const { D, $, $$, esc, dialog, toast } = App;
|
||||
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 页"); }));
|
||||
$("[data-go]").onclick = (e) => { if (!$$("[data-lot]:checked").length) { e.preventDefault(); toast("至少选择一个包"); } };
|
||||
$("[data-fix]").onclick = (e) => { e.preventDefault(); dialog({ title: "手工调整分包", wide: true,
|
||||
body: `<p class="muted">可以增加、删除、重命名采购包,或把清单条目改到另一个包下(在读标页逐条调整)。</p>
|
||||
<table class="data"><thead><tr><th>包号</th><th>名称</th><th class="r">最高限价(万元)</th><th></th></tr></thead><tbody>
|
||||
<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>
|
||||
</tbody></table><button class="btn sm" style="margin-top:var(--sp-3)">增加一个包</button>`,
|
||||
ok: "保存", onOk: () => toast("分包已保存,修改已留痕") }); };
|
||||
|
||||
/* 每个包的专属条目数、★ 条款数(示意);限价是招标文件第 2 页的原文 */
|
||||
const COMMON = 18;
|
||||
let lots = D.project.lots.map((l) => ({ ...l, src: true, n: l.no === 1 ? 8 : D.hiddenLotItems, star: l.no === 1 ? 4 : 2 }));
|
||||
const drawer = App.drawer();
|
||||
const capCite = (l) => ["T", 2, "t2-cap", `采购包${l.no}:${l.cap}万元`];
|
||||
|
||||
function render() {
|
||||
$("[data-n]").textContent = lots.length;
|
||||
$("[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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,83 +1,156 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
解析进度(v0.2):框架来自 App.shell();任务进度卡按 design/ui-demo/cards.html 的「任务进度卡」(分阶段、进度条、失败说明、扣点)。
|
||||
?addendum=1:解析补遗。页面底部的原型说明里可以演示解析失败。
|
||||
本页 <style> 只放本页特有的:窄版宽度、阶段说明、失败说明。
|
||||
-->
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<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/fonts-local.css">
|
||||
<link rel="stylesheet" href="../design/system/components.css">
|
||||
<link rel="stylesheet" href="assets/app.css">
|
||||
<style>
|
||||
.narrow{max-width:760px}
|
||||
.steps li{padding:10px 0}
|
||||
.steps .sub{font-weight:var(--fw-regular);color:var(--text-secondary);font-size:var(--fs-caption);margin-left:auto}
|
||||
.steps .now .dot{animation:pulse 1.2s ease-in-out infinite}
|
||||
@keyframes pulse{50%{opacity:.35}}
|
||||
@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)}
|
||||
.done-box{display:none}
|
||||
.finished .done-box{display:block}
|
||||
.finished .run-box{display:none}
|
||||
.narrow{max-width:800px}
|
||||
.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 .fig{font-size:var(--fs-figure-xs)}
|
||||
.card-f .r{margin-left:auto;display:flex;gap:var(--sp-3);align-items:center}
|
||||
.card-b{padding-top:var(--sp-2)}
|
||||
|
||||
/* 阶段:右侧辅助灰小字写这一步的结果 */
|
||||
.steps .sub{margin-left:auto;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary);font-weight:var(--fw-regular)}
|
||||
.steps .fail .sub{color:var(--warn-text)}
|
||||
.card-b .progress{margin-top:var(--sp-3)}
|
||||
|
||||
/* 失败说明(§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>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="content narrow">
|
||||
<div class="page-title"><div><h1 data-title>正在解析招标文件</h1><p>ZC-2026-0418 采购文件.doc · 186 页</p></div></div>
|
||||
<div class="card" data-card>
|
||||
<div class="run-box">
|
||||
<ol class="steps" data-steps></ol>
|
||||
<div class="prog"><i data-prog style="width:0"></i></div>
|
||||
<p class="cap muted" style="margin:0">300 页以内一般 10 分钟内完成(原型里加速演示)。<b style="color:var(--text-primary)">可以离开本页</b>,完成或失败都会发站内通知。</p>
|
||||
<div class="row" style="margin-top:var(--sp-5)">
|
||||
<span class="cost">已预扣 1 次解析,成功后结算;失败、超时或取消会自动退回</span>
|
||||
<span style="margin-left:auto"></span>
|
||||
<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 class="page-title"><div><h1 data-title>解析招标文件</h1><p data-sub>ZC-2026-0418 采购文件.doc · 186 页</p></div></div>
|
||||
|
||||
<section class="card flush" aria-labelledby="h-task" data-card>
|
||||
<div class="card-h"><h2 id="h-task" data-h>正在解析</h2><span class="aside" data-time></span></div>
|
||||
<div class="card-b" data-body></div>
|
||||
<div class="card-f" data-foot></div>
|
||||
</section>
|
||||
|
||||
<p class="note proto">原型说明:进度加速演示,实际 300 页以内一般 10 分钟内完成。<a class="lnk" href="#" data-fail>看解析失败的样子</a></p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="assets/data.js"></script>
|
||||
<script src="assets/app.js"></script>
|
||||
<script>
|
||||
const { D, $, dialog, toast } = App;
|
||||
const { D, $, icon, dialog, toast } = App;
|
||||
const addendum = new URLSearchParams(location.search).has("addendum");
|
||||
App.shell({ crumbs: [["工作台", "workbench.html"], [addendum ? D.project.name : "新建项目", addendum ? "project.html" : ""], ["解析", ""]] });
|
||||
if (addendum) $("[data-title]").textContent = "正在解析补遗 1";
|
||||
App.shell({
|
||||
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
|
||||
? [["识别文件格式", "PDF · 2 页"], ["提取文字", ""], ["提取变更内容", ""], ["核对出处", ""], ["并入清单", ""]]
|
||||
? [["识别文件格式", "PDF · 2 页"], ["提取文字", "2 页"], ["提取变更内容", "2 处更正"], ["核对出处", "逐条定位页码和原句"], ["并入清单", "更新 2 条"]]
|
||||
: [["识别文件格式", "实际为 DOCX,保留斜体、下划线等格式"], ["提取文字", "186 页"], ["识别分包与法律体系", "2 个采购包 · 政府采购"], ["提取清单", "资格、无效条款、评分、日期、技术参数"], ["核对出处", "逐条定位页码和原句"]];
|
||||
let i = 0, timer;
|
||||
function render() {
|
||||
$("[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 STEP_MS = 1400, SPEED = 30; // 每个阶段 1.4 秒;「已用时」按 30 倍折算,看起来像真实耗时
|
||||
let i = 0, t0 = Date.now(), timer, state = "run";
|
||||
|
||||
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() {
|
||||
i++; render();
|
||||
if (i < S.length) timer = setTimeout(tick, 1100);
|
||||
else finish();
|
||||
function renderRun() {
|
||||
const pct = Math.min(99, Math.round(((i + Math.min(1, (Date.now() - stepAt) / STEP_MS)) / S.length) * 100));
|
||||
$("[data-h]").textContent = "正在解析";
|
||||
$("[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() {
|
||||
$("[data-card]").classList.add("finished");
|
||||
$("[data-done]").innerHTML = addendum
|
||||
? `<div class="alert done"><span class="ico ok">✓</span><p><b>补遗 1 解析完成,已结算 1 次解析。</b>更新了 2 条清单项(投标截止时间、服务器系统盘),新增 0 条。清单已回到草稿,请核对后重新确认。</p></div>
|
||||
<div class="row" style="margin-top:var(--sp-4)"><a class="btn primary" href="checklist.html">去核对更新</a></div>`
|
||||
: `<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>
|
||||
<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>
|
||||
<div class="row" style="margin-top:var(--sp-5)"><a class="btn primary" href="lots.html">选择所投的包</a></div>`;
|
||||
stop(addendum ? "补遗 1 解析完成" : "解析完成", `用时 ${dur()}`);
|
||||
$("[data-body]").innerHTML = addendum
|
||||
? `<dl class="kv result">
|
||||
<dt>更新</dt><dd><span class="fig">2</span> 条:投标截止时间、应用服务器系统盘</dd>
|
||||
<dt>新增</dt><dd><span class="fig">0</span> 条</dd>
|
||||
<dt>清单</dt><dd>回到草稿,核对补遗更新后重新确认</dd></dl>`
|
||||
: `<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);
|
||||
$("[data-cancel]").onclick = () => dialog({ title: "取消解析?", body: "<p>已预扣的 1 次解析会退回。取消后可以重新上传。</p>", ok: "取消解析", cancel: "继续解析",
|
||||
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>`; } });
|
||||
$("[data-fail]").onclick = (e) => { e.preventDefault(); clearTimeout(timer); $("[data-card]").classList.add("finished");
|
||||
$("[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="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>`; };
|
||||
function fail() {
|
||||
const at = 1; i = at; // 两种失败(OCR 识别不出、文件加密)都停在「提取文字」
|
||||
stop(addendum ? "补遗 1 解析失败" : "解析失败", `11月2日 <span class="fig">15:03</span>`);
|
||||
$("[data-body]").innerHTML = `${steps(at)}
|
||||
<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>`;
|
||||
$("[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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,76 +1,141 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
新建项目 / 追加补遗(v0.2):框架来自 App.shell();表单按 design/ui-demo/forms.html 的「新建项目」(分区卡片、上传区、文件行、扣点说明)。
|
||||
?addendum=1:追加补遗 / 澄清(不填项目名称,文件类型默认「补遗」)。
|
||||
本页 <style> 只放本页特有的:窄版宽度、文件行里的类型下拉和提醒行。
|
||||
-->
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>新建项目 · 标博士原型</title>
|
||||
<link rel="icon" href="../design/logo/favicon.svg">
|
||||
<link rel="stylesheet" href="../design/system/tokens.css">
|
||||
<link rel="stylesheet" href="../design/system/fonts-local.css">
|
||||
<link rel="stylesheet" href="../design/system/components.css">
|
||||
<link rel="stylesheet" href="assets/app.css">
|
||||
<style>
|
||||
.narrow{max-width:760px}
|
||||
.file-row.bad{border-color:var(--warn);background:var(--warn-soft)}
|
||||
.file-row .st{font-size:var(--fs-caption);line-height:var(--lh-caption)}
|
||||
.file-row .st.warn{color:var(--warn-text)}
|
||||
.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)}
|
||||
.foot-bar .cost{margin-right:auto}
|
||||
.narrow{max-width:800px}
|
||||
.card-b.form-b{padding-top:var(--sp-5);padding-bottom:var(--sp-6)}
|
||||
.form-b > .field{max-width:none}
|
||||
.form-b > .field + .field{margin-top:var(--sp-5)}
|
||||
.form-b > [hidden] + .field{margin-top:0}
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="content narrow">
|
||||
<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>
|
||||
<div>
|
||||
<div class="drop" tabindex="0" role="button" data-drop>
|
||||
<b>把招标文件拖到这里,或点击选择</b>
|
||||
<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>
|
||||
|
||||
<form class="card flush" novalidate data-form aria-labelledby="h-form">
|
||||
<h2 class="vh" id="h-form" data-form-h>新建项目</h2>
|
||||
<div class="card-b form-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>
|
||||
|
||||
<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 data-files style="margin-top:var(--sp-3)"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="foot-bar">
|
||||
<span class="cost" data-cost>选好文件后显示扣点</span>
|
||||
<a class="btn" href="workbench.html">取消</a>
|
||||
<button class="btn primary" data-start disabled>开始解析</button>
|
||||
</div>
|
||||
<div class="card-f">
|
||||
<span class="cost" data-cost></span>
|
||||
<a class="btn" href="workbench.html" data-cancel>取消</a>
|
||||
<button type="submit" class="btn primary swap" data-start><span class="idle">开始解析</span><span class="busy">正在上传…</span></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="assets/data.js"></script>
|
||||
<script src="assets/app.js"></script>
|
||||
<script>
|
||||
const { D, $, toast } = App;
|
||||
const { D, $, $$, esc, icon } = App;
|
||||
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) {
|
||||
document.title = "追加补遗 · 标博士原型";
|
||||
$("[data-title]").textContent = "追加补遗 / 澄清";
|
||||
$("[data-sub]").textContent = "补遗单独解析,新提取的条目标注来源「补遗 N」并入清单;日期以最新文件为准,由你核对。MVP 不自动判断哪些旧条目被作废。";
|
||||
$("[data-form-h]").textContent = "追加补遗 / 澄清";
|
||||
$("[data-sub]").textContent = "补遗单独解析,新条目标注「补遗 N」并入清单。标博士不判断哪些旧条目已作废,并入后由你核对。";
|
||||
$("[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: "PDF", name: "附件:采购需求-技术参数.pdf", info: "页数无法读取", type: "招标文件", ok: false, note: "文件已加密,无法读取。请上传未加密的文件。这个文件不会创建任务,也不扣额度。" }
|
||||
{ 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", type: "招标文件", ok: false, warn: "文件已加密,无法读取。请换成未加密的文件;这个文件不会解析,也不扣额度" }
|
||||
];
|
||||
function render() {
|
||||
$("[data-files]").innerHTML = files.map((f, i) => `<div class="file-row ${f.ok ? "" : "bad"}">
|
||||
<span class="ext">${f.ext}</span>
|
||||
<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>
|
||||
<button class="btn sm text" data-rm="${i}">移除</button></div>`).join("");
|
||||
const ok = files.filter((f) => f.ok).length;
|
||||
$("[data-cost]").innerHTML = ok ? `扣 <span class="fig">${ok}</span> 次解析 · 剩 <span class="fig">${D.quota.parse.left}</span> 次 · 解析失败、超时、取消都不扣` : "选好文件后显示扣点";
|
||||
$("[data-start]").disabled = !ok;
|
||||
let files = [];
|
||||
const TYPES = ["招标文件", "补遗", "澄清答复"];
|
||||
const fileField = $("#f-file");
|
||||
|
||||
function typeSelect(f, i) {
|
||||
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>
|
||||
<ul class="menu right" role="listbox">${TYPES.map((t) => `<li role="option" aria-selected="${t === f.type}">${t}</li>`).join("")}</ul></div>`;
|
||||
}
|
||||
let shown = false;
|
||||
$("[data-drop]").onclick = () => { if (!shown) { shown = true; render(); } };
|
||||
$("[data-drop]").onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); $("[data-drop]").click(); } };
|
||||
document.addEventListener("click", (e) => { if (e.target.matches("[data-rm]")) { files.splice(+e.target.dataset.rm, 1); render(); } });
|
||||
$("[data-start]").onclick = () => location.href = "parsing.html" + (addendum ? "?addendum=1" : "");
|
||||
function render() {
|
||||
$("[data-files]").innerHTML = files.map((f, i) => `<div class="file-row ${f.ok ? "" : "off"}">
|
||||
<span class="ext">${f.ext}</span>
|
||||
<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>
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,138 +1,257 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
项目概览(v0.2):项目头 + 标签页来自 App.shell({ tab: "overview" }),状态联动用 App.projHead()。
|
||||
卡片规格按 design/ui-demo/cards.html:AI 摘要卡、关键日期卡、分区卡片 .card.flush。
|
||||
本页 <style> 只放概览特有的布局:两栏、文件列表、状态卡。
|
||||
-->
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>项目概览 · 标博士原型</title>
|
||||
<link rel="icon" href="../design/logo/favicon.svg">
|
||||
<link rel="stylesheet" href="../design/system/tokens.css">
|
||||
<link rel="stylesheet" href="../design/system/fonts-local.css">
|
||||
<link rel="stylesheet" href="../design/system/components.css">
|
||||
<link rel="stylesheet" href="assets/app.css">
|
||||
<style>
|
||||
.ov{display:grid;grid-template-columns:minmax(0,1.5fr) minmax(0,1fr);gap:var(--sp-6);align-items:start}
|
||||
.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}
|
||||
.card h3 .right{margin-left:auto;font-weight:var(--fw-regular)}
|
||||
.next{border-left:3px solid var(--c-ink)}
|
||||
.next p{margin:0 0 var(--sp-3)}
|
||||
.summary .kv{grid-template-columns:120px 1fr}
|
||||
.summary dd .cite-src{display:inline;margin-left:6px}
|
||||
.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}
|
||||
.dates li:first-child{border-top:0}
|
||||
.dates .fig{font-weight:500}
|
||||
.dates .past{color:var(--text-secondary)}
|
||||
.docs .file-row{grid-template-columns:32px minmax(0,1fr) auto}
|
||||
.runs td{height:auto}
|
||||
/* 页首提醒 + 两栏(主列 3 : 右栏 2),全页一个间距 --gap-card */
|
||||
[data-next] > .alert{margin-bottom:var(--gap-card)}
|
||||
.ov{display:grid;grid-template-columns:minmax(0,1fr) 420px;gap:var(--gap-card);align-items:start}
|
||||
@media (max-width:1399px){.ov{grid-template-columns:minmax(0,1fr) 384px}}
|
||||
.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 .r{margin-left:auto;display:flex;gap:var(--sp-2);align-items:center}
|
||||
.card-f .r{margin-left:auto;display:flex;gap:var(--sp-2);align-items:center}
|
||||
|
||||
/* AI 摘要卡 */
|
||||
.ai-card .kv{grid-template-columns:88px minmax(0,1fr)}
|
||||
.ai-card .kv dd > .fig{font-weight:var(--fw-medium)}
|
||||
|
||||
/* 检查记录 */
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="content">
|
||||
<div class="flowline" data-flow></div>
|
||||
<div class="ov" style="margin-top:var(--sp-5)">
|
||||
<div data-next></div>
|
||||
|
||||
<div class="ov">
|
||||
<div class="stack">
|
||||
<div class="card next">
|
||||
<h3>下一步</h3>
|
||||
<p>第 2 次检查还有 <span class="sev fatal">致命<span class="fig">3</span></span> 项待处理。处理完不是递交的前提,决定权在你;报告会如实列出每一项的处理情况。</p>
|
||||
<div class="row"><a class="btn primary" href="results.html">去处理检查结果</a><a class="btn" href="report.html">导出报告</a></div>
|
||||
</div>
|
||||
<div class="card summary">
|
||||
<h3><span class="tag ai">AI 摘要</span>项目要点<span class="right cap muted">每一条都可以点出处核对</span></h3>
|
||||
<dl class="kv">
|
||||
<dt>法律体系</dt><dd>政府采购 · 公开招标(按「投标无效」口径检查)</dd>
|
||||
<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>综合评分法:价格 10、技术 40、实施方案 20、售后 10、认证 6、业绩 8 <a class="cite-src" href="#" data-open="T|31|t31-price">第 31 页</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>不专门面向中小企业;小微企业有价格扣除 <a class="cite-src" href="#" data-open="T|2|t2-sme">第 2 页</a></dd>
|
||||
<dt>保证金</dt><dd>不收取 <a class="cite-src" href="#" data-open="T|21|t21-deposit">第 21 页</a></dd>
|
||||
<dt>递交</dt><dd>电子交易平台在线递交,CA 加密,不接受纸质 <a class="cite-src" href="#" data-open="T|6|t6-place">第 6 页</a></dd>
|
||||
<dt>★ 条款</dt><dd>采购包 1 和通用部分共 <span class="fig">4</span> 条 · <a class="plain" href="checklist.html">在清单里查看</a></dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>检查记录<span class="right"><a class="btn sm" href="bid-check.html">上传新版本并检查</a></span></h3>
|
||||
<section class="card flush state" aria-labelledby="h-state">
|
||||
<div class="card-h"><h3 id="h-state">项目状态</h3><span class="r" data-state-acts></span></div>
|
||||
<div class="card-b" data-state></div>
|
||||
</section>
|
||||
<section class="card flush ai-card" aria-labelledby="h-sum" data-ai-card>
|
||||
<div class="card-h"><h3 id="h-sum">项目要点</h3><span class="tag ai" data-ai-tag>AI 摘要</span></div>
|
||||
<div class="card-b">
|
||||
<div class="ai-block" data-ai-block>
|
||||
<dl class="kv">
|
||||
<dt>法律体系</dt><dd>政府采购 · 公开招标(按「投标无效」口径检查)</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>综合评分法:价格 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>不接受 <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 inline" href="#" data-open="T|2|t2-sme">第 <span class="fig">2</span> 页</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>电子交易平台在线递交,CA 加密,不接受纸质<a class="cite-src inline" href="#" data-open="T|6|t6-place">第 <span class="fig">6</span> 页</a></dd>
|
||||
<dt>★ 条款</dt><dd>采购包 1 和通用部分共 <span class="fig">4</span> 条 · <a class="lnk" href="checklist.html">在清单里查看</a></dd>
|
||||
</dl>
|
||||
<div data-ai-sign></div>
|
||||
</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">
|
||||
<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>
|
||||
<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>第 <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>
|
||||
<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>
|
||||
<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>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="stack">
|
||||
<div class="card">
|
||||
<h3>关键日期</h3>
|
||||
<ul class="dates">
|
||||
<li class="past"><span class="fig">10-28</span><span>采购文件公告发布</span><span></span></li>
|
||||
<li><span class="fig">11-09</span><span>询问、质疑截止(公告期满后 7 个工作日)</span><a class="cite-src" href="#" data-open="T|6|t6-ask">第 6 页</a></li>
|
||||
<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><span class="fig">2027-02-18</span><span>投标有效期至(截止日起 90 天)</span><a class="cite-src" href="#" data-open="T|21|t21-valid">第 21 页</a></li>
|
||||
</ul>
|
||||
<p class="note">MVP 只在站内显示倒计时,不发截止提醒。</p>
|
||||
</div>
|
||||
<div class="card docs">
|
||||
<h3>文件<span class="right"><button class="btn sm" data-addendum>追加补遗 / 澄清</button></span></h3>
|
||||
<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>
|
||||
<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>
|
||||
<p class="cap muted" style="margin:var(--sp-4) 0 var(--sp-2)">投标文件(加密前的 Word / PDF 稿)</p>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>项目操作</h3>
|
||||
<div class="row"><button class="btn" data-submit>标记为已递交</button><button class="btn" data-archive>归档</button><button class="btn text" data-delete>删除项目</button></div>
|
||||
<p class="note">标记已递交后项目变为只读,仍可导出报告;可以撤销,撤销会留下记录。</p>
|
||||
</div>
|
||||
|
||||
<section class="card flush" aria-labelledby="h-dates">
|
||||
<div class="card-h"><h3 id="h-dates">关键日期</h3><span class="aside">已按补遗 1 更新</span></div>
|
||||
<div class="card-b">
|
||||
<ul class="dates">
|
||||
<li class="past"><span class="fig">10月28日</span><span>采购文件公告发布</span><span></span></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>
|
||||
<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>
|
||||
<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>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card flush docs" aria-labelledby="h-docs">
|
||||
<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>
|
||||
<div class="card-b">
|
||||
<p class="grp">招标方</p>
|
||||
<ul>
|
||||
<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>
|
||||
<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>
|
||||
</ul>
|
||||
<p class="grp">投标文件(加密前的 Word / PDF 稿)</p>
|
||||
<ul>
|
||||
<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>
|
||||
<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>
|
||||
</main>
|
||||
<aside class="drawer" aria-label="原文" data-drawer><div data-viewer></div></aside>
|
||||
|
||||
<script src="assets/data.js"></script>
|
||||
<script src="assets/app.js"></script>
|
||||
<script>
|
||||
const { D, $, dialog, toast, esc } = App;
|
||||
App.shell({ crumbs: [["工作台", "workbench.html"], [D.project.name, ""]], tab: "overview" });
|
||||
const drawer = $("[data-drawer]");
|
||||
const V = App.viewer($("[data-viewer]"), { closable: true, onClose: () => drawer.classList.remove("open"), onOpen: () => drawer.classList.add("open") });
|
||||
document.addEventListener("click", (e) => {
|
||||
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"); });
|
||||
const { D, $, $$, esc, icon, dialog, toast } = App;
|
||||
App.shell({ crumbs: [["工作台", "workbench.html"], [D.project.name, ""], ["概览", ""]], tab: "overview" });
|
||||
const drawer = App.drawer();
|
||||
const FATAL = D.check.findings.filter((f) => f.sev === "fatal" && !f.status).length;
|
||||
const NOW_TXT = `11月2日 <span class="fig">15:00</span>`;
|
||||
|
||||
let status = "已检查";
|
||||
function flow() {
|
||||
const S = ["跟进中", "编制中", "已检查", "已递交", "已归档"];
|
||||
const i = S.indexOf(status);
|
||||
$("[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>');
|
||||
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) => {
|
||||
const a = e.target.closest("[data-open]"); if (!a) return;
|
||||
e.preventDefault();
|
||||
drawer.open(App.parseCite(a.dataset.open));
|
||||
});
|
||||
document.addEventListener("click", (e) => { if (e.target.matches("[data-undo-submit]")) { e.preventDefault(); status = "已检查"; flow(); toast("已撤销递交标记,记录已保存"); } });
|
||||
$("[data-archive]").onclick = () => { status = "已归档"; flow(); toast("项目已归档,可以随时撤销归档"); };
|
||||
$("[data-addendum]").onclick = () => location.href = "project-new.html?addendum=1";
|
||||
$("[data-delete]").onclick = () => dialog({
|
||||
title: `删除项目「${esc(D.project.name)}」?`,
|
||||
body: `<p>项目下的招标文件、投标文件、清单、检查结果和报告都会删除。删除后 30 天内可以在回收站恢复,之后彻底清除。</p>
|
||||
<p class="muted cap">额度流水和一条不含业务内容的审计记录会保留。</p>
|
||||
<div class="field" data-field><label for="dn">输入项目名称确认</label><input class="input" id="dn" placeholder="${esc(D.project.name)}"><span class="msg"></span></div>`,
|
||||
ok: "删除项目",
|
||||
onOk: (s) => {
|
||||
if ($("#dn", s).value.trim() !== D.project.name) { $("[data-field]", s).classList.add("err"); $(".msg", s).innerHTML = '<span class="ico w">!</span>项目名称不一致'; return false; }
|
||||
location.href = "workbench.html";
|
||||
|
||||
/* ---------- 状态流:页首提醒、状态卡、项目头(App.projHead)一起更新 ---------- */
|
||||
const FLOW = ["跟进中", "编制中", "已检查", "已递交", "已归档"];
|
||||
let status = D.project.status, before = status;
|
||||
const READONLY = "已递交的项目只读。撤销递交标记后可以继续修改";
|
||||
|
||||
function renderNext() {
|
||||
let html = "";
|
||||
if (status === "已检查" && FATAL) html = `<div class="alert" role="note">${icon("alert-circle", "lg")}
|
||||
<div><p>第 <span class="fig">2</span> 次检查还有 <span class="sev fatal">致命<span class="fig">${FATAL}</span></span> 待处理。</p><span class="sub">不影响导出和标记递交,决定权在你。</span></div>
|
||||
<span class="act"><a class="btn" href="report.html">导出报告</a><a class="btn primary" href="results.html">去处理</a></span></div>`;
|
||||
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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,51 +1,250 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
资质库(v0.2,FR-M6-08 最小资质库):列表 + 新增 / 编辑对话框 + 删除。按 design/ui-demo/forms.html「资质录入」重刷。
|
||||
到期按每个进行中项目的投标截止日判断,不按今天;只提醒,不拦截保存。附件可以稍后补录,列表里显示「待补录」,不生成内容(红线 5)。
|
||||
有效期用可键入的日期输入(.dt[data-dt],Q10),日期选择器里圆点是进行中项目的投标截止日。
|
||||
-->
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>资质库 · 标博士原型</title>
|
||||
<link rel="icon" href="../design/logo/favicon.svg">
|
||||
<link rel="stylesheet" href="../design/system/tokens.css">
|
||||
<link rel="stylesheet" href="../design/system/fonts-local.css">
|
||||
<link rel="stylesheet" href="../design/system/components.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>
|
||||
<body>
|
||||
<main>
|
||||
<div class="content">
|
||||
<div class="page-title"><div><h1>资质库</h1><p>查标时用来核对证书有没有过期、等级够不够。数据只来自你自己录入的内容,系统不从外部补数据。</p></div>
|
||||
<div class="actions"><button class="btn primary" data-add>添加资质</button></div></div>
|
||||
<div class="alert warn" style="margin-bottom:var(--sp-5)"><span class="ico w">!</span><p><b>1 张证书会在进行中项目的投标截止日前到期。</b>到期判断以每个项目的投标截止日为准,不以今天为准。</p></div>
|
||||
<table class="data">
|
||||
<thead><tr><th>名称</th><th>类型</th><th>等级</th><th>证书编号</th><th>发证机构</th><th>有效期至</th><th>附件</th></tr></thead>
|
||||
<tbody data-rows></tbody>
|
||||
</table>
|
||||
<p class="note">MVP 只有最小资质库;业绩、人员、历史标书等完整资产库在 V1 提供。</p>
|
||||
<div class="page-title">
|
||||
<div><h1>资质库</h1><p>查标时按每个项目的投标截止日核对有效期和等级。只用你录入的资质。</p></div>
|
||||
<div class="actions"><button class="btn primary" data-add><i data-ic="plus"></i>添加资质</button></div>
|
||||
</div>
|
||||
|
||||
<div data-alert></div>
|
||||
|
||||
<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>
|
||||
</main>
|
||||
<script src="assets/data.js"></script>
|
||||
<script src="assets/app.js"></script>
|
||||
<script>
|
||||
const { $, dialog, toast } = App;
|
||||
const { $, $$, esc, icon, dialog, toast, fieldError, floatMenu, parseDT } = App;
|
||||
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 = [
|
||||
["ISO/IEC 27001 信息安全管理体系", "体系认证", "—", "01226IS0458", "某认证中心", "2026-11-15", '<span style="color:var(--warn-text)">早于「智慧大厅建设项目」截止日 11-20</span>', "已上传"],
|
||||
["ISO 9001 质量管理体系", "体系认证", "—", "01226Q31877", "某认证中心", "2028-03-09", "", "已上传"],
|
||||
["ISO/IEC 20000-1 信息技术服务管理体系", "体系认证", "—", "01226IT0921", "某认证中心", "2027-06-30", "", "已上传"],
|
||||
["信息系统建设和服务能力等级证书", "资质证书", "CS3", "CS3-3201-000488", "中国电子信息行业联合会", "2027-12-31", "", "已上传"],
|
||||
["电子与智能化工程专业承包", "资质证书", "二级", "D232045871", "某省住房和城乡建设厅", "2029-05-20", "", '<span class="muted">待补录</span>'],
|
||||
["政务大厅排队叫号系统 V3.0", "软件著作权", "—", "2024SR0638812", "国家版权局", "长期", "", "已上传"]
|
||||
];
|
||||
$("[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)">
|
||||
<div class="field"><label>名称</label><input class="input" placeholder="例如:信息系统建设和服务能力等级证书"></div>
|
||||
<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>
|
||||
<div class="field"><label>证书编号</label><input class="input"></div>
|
||||
<div class="field"><label>发证机构</label><input class="input"></div>
|
||||
<div class="field"><label>有效期至</label><input class="input" type="date"></div></div>
|
||||
<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>`,
|
||||
ok: "保存", onOk: () => toast("已保存") });
|
||||
{ name: "ISO/IEC 27001 信息安全管理体系认证", type: "体系认证", lv: "", no: "01226IS0458", org: "某认证中心", from: "2023-11-16", to: "2026-11-15", file: "ISO27001 证书 2023.pdf", pages: 2 },
|
||||
{ name: "ISO 9001 质量管理体系认证", type: "体系认证", lv: "", no: "01226Q31877", org: "某认证中心", from: "2025-03-10", to: "2028-03-09", file: "ISO9001 证书 2025.pdf", pages: 1 },
|
||||
{ name: "ISO/IEC 20000-1 信息技术服务管理体系认证", type: "体系认证", lv: "", no: "01226IT0921", org: "某认证中心", from: "2024-07-01", to: "2027-06-30", file: "ISO20000 证书 2024.pdf", pages: 2 },
|
||||
{ name: "信息系统建设和服务能力等级证书", type: "资质证书", lv: "CS3", no: "CS3-3201-000488", org: "中国电子信息行业联合会", from: "2023-12-31", to: "2027-12-31", file: "CS3 证书.pdf", pages: 1 },
|
||||
{ name: "电子与智能化工程专业承包", type: "资质证书", lv: "二级", no: "D232045871", org: "某省住房和城乡建设厅", from: "2024-05-21", to: "2029-05-20", file: "" },
|
||||
{ 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 }));
|
||||
|
||||
/* 有效期早于哪些进行中项目的投标截止日 */
|
||||
const lateFor = (q) => q.long || !q.to ? [] : DUE.filter((x) => parseDT(q.to, false).dt < x.dt);
|
||||
const lateText = (late) => `早于「${esc(late[0].n)}」的投标截止 ${late[0].disp}${late.length > 1 ? ` 等 <span class="fig">${late.length}</span> 个项目` : ""}`;
|
||||
/* 列表排序:有效期近的在上,长期有效在最后 */
|
||||
const key = (q) => q.long || !q.to ? "9999" : q.to;
|
||||
|
||||
let tf = "", kw = "";
|
||||
function render() {
|
||||
$("[data-tseg]").innerHTML = [["", "全部"], ...TYPES.map((t) => [t, t])]
|
||||
.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,64 +1,293 @@
|
||||
<!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">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>额度 · 标博士原型</title>
|
||||
<link rel="icon" href="../design/logo/favicon.svg">
|
||||
<link rel="stylesheet" href="../design/system/tokens.css">
|
||||
<link rel="stylesheet" href="../design/system/fonts-local.css">
|
||||
<link rel="stylesheet" href="../design/system/components.css">
|
||||
<link rel="stylesheet" href="assets/app.css">
|
||||
<style>
|
||||
.meters{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--sp-4)}
|
||||
.meter small{display:block;color:var(--text-secondary);font-size:var(--fs-caption)}
|
||||
.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)}
|
||||
.rules td{height:auto}
|
||||
td.plus{color:var(--confirmed)}
|
||||
[data-alert] > .alert{margin-bottom:var(--gap-card)}
|
||||
.content > section + section{margin-top:var(--gap-card)}
|
||||
|
||||
/* 额度卡:三张等高 */
|
||||
.qgrid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--gap-card)}
|
||||
.qgrid > .card{display:flex;flex-direction:column}
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="content">
|
||||
<div class="page-title"><div><h1>额度</h1><p>专业版 · 本期 2026-11-01 至 2027-10-31。额度绑定企业主体,不可转让;期末未用完的额度不结转。</p></div>
|
||||
<div class="actions"><button class="btn" data-contact>联系客服加额度</button></div></div>
|
||||
<div class="alert" style="margin-bottom:var(--sp-5)"><span class="ico i">i</span><p>额度用到 80% 和 100% 时会站内提醒(每个周期每个档位只提醒一次),提醒用赭色,不用红色。</p></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 class="page-title">
|
||||
<div><h1>额度</h1><p data-sub></p></div>
|
||||
<div class="actions" data-actions></div>
|
||||
</div>
|
||||
|
||||
<div class="section-h"><h2>扣点规则</h2><span class="cap muted">所有扣点都能在下方流水里对应到具体项目和任务</span></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 data-alert></div>
|
||||
|
||||
<div class="section-h"><h2>流水</h2><span class="more"><div class="seg"><button class="on">全部</button><button>解析</button><button>检查</button></div></span></div>
|
||||
<table class="data">
|
||||
<thead><tr><th>时间</th><th>类型</th><th>额度</th><th class="r">数量</th><th>关联项目 / 任务</th><th>说明</th></tr></thead>
|
||||
<tbody>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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 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 class="num">11-01 10:40</td><td>结算</td><td>解析</td><td class="num r">−1</td><td>智慧大厅建设项目 · 招标文件</td><td></td></tr>
|
||||
<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>
|
||||
</tbody>
|
||||
</table>
|
||||
<section class="qgrid" aria-label="本期额度" data-cards></section>
|
||||
|
||||
<section class="card flush" id="rules" aria-labelledby="h-rules">
|
||||
<div class="card-h"><h2 id="h-rules">扣点规则</h2></div>
|
||||
<table class="data rules">
|
||||
<thead><tr><th style="width:26%">动作</th><th style="width:30%">扣点</th><th>不扣点</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>解读一份招标文件、补遗或澄清</td><td>1 次解析:发起时预扣,成功后结算</td><td>解析失败、超时、取消:自动退回</td></tr>
|
||||
<tr><td>对一个项目做一次检查</td><td>1 次检查:投标文件的解析含在内</td><td>检查中断、系统异常:自动退回<br>同一项目 24 小时内复查:不扣,流水记一行「免扣」。24 小时从上一次扣额度的检查算起</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
</main>
|
||||
<script src="assets/data.js"></script>
|
||||
<script src="assets/app.js"></script>
|
||||
<script>
|
||||
const { D, $, $$, esc, icon, dialog, toast, fieldError } = App;
|
||||
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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,102 +1,202 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
检查报告预览(v0.2,FR-M3-09,规范 §7.17):只换外围框架(项目头 + 标签页 + 右侧导出栏),A4 报告本体保持 PDF 印刷件的版式。
|
||||
报告本体:logo 只在页眉;首页写项目信息、检查覆盖范围(查了 / 没查)、免责声明;全文不出现「通过 / 合格 / 保证」。
|
||||
分页:按 A4 高度自动排版(问题明细一页放不下时续页),页脚「n / N」。打印时只打印 A4 页。
|
||||
-->
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>检查报告 · 标博士原型</title>
|
||||
<link rel="icon" href="../design/logo/favicon.svg">
|
||||
<link rel="stylesheet" href="../design/system/tokens.css">
|
||||
<link rel="stylesheet" href="../design/system/fonts-local.css">
|
||||
<link rel="stylesheet" href="../design/system/components.css">
|
||||
<link rel="stylesheet" href="assets/app.css">
|
||||
<style>
|
||||
.rp{display:grid;grid-template-columns:260px minmax(0,1fr);gap:var(--sp-8);align-items:start}
|
||||
.rp-side{position:sticky;top:76px}
|
||||
.rp-side .card + .card{margin-top:var(--sp-4)}
|
||||
.rp-side h3{font:var(--fw-bold) var(--fs-title)/var(--lh-title) var(--font-sans);margin:0 0 var(--sp-3)}
|
||||
.rp-side .chk{padding:var(--sp-2) 0;border:0}
|
||||
/* ---------- 外围框架:A4 预览 + 右侧导出栏(同工作台的主列 + 右栏) ---------- */
|
||||
.rp{display:grid;grid-template-columns:minmax(0,1fr) var(--aside-w);gap:var(--gap-card);align-items:start}
|
||||
.rp-side{position:sticky;top:calc(var(--topbar-h) + var(--sp-2))}
|
||||
.rp-side > .card + .card{margin-top:var(--gap-card)}
|
||||
.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)}
|
||||
.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}
|
||||
.a4 h2{font:600 26px/1.35 var(--font-serif);margin:40px 0 6px}
|
||||
.a4 h4{font:600 15px/1.4 var(--font-sans);margin:26px 0 10px;padding-bottom:6px;border-bottom:1px solid var(--c-ink)}
|
||||
|
||||
/* ---------- 报告本体:PDF 版式(A4 210 × 297mm = 794 × 1123px,页边距 20mm),不随界面风格变化 ---------- */
|
||||
.a4{--mm:3.78px;--rp-fs:10.5pt;--rp-lh:1.6;--rp-sm:9pt;--rp-xs:8pt;
|
||||
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 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 dd{margin:0}
|
||||
.a4 .disc{margin-top:18px;padding:12px 14px;background:var(--bg-sunken);font-size:12.5px}
|
||||
.a4 table{width:100%;border-collapse:collapse;font-size:12.5px}
|
||||
.a4 th{text-align:left;font-weight:600;color:var(--text-secondary);border-bottom:1.5px solid var(--c-ink);padding:6px 6px}
|
||||
.a4 td{border-bottom:1px solid var(--border-subtle);padding:7px 6px;vertical-align:top}
|
||||
.a4 .fig{font-size:inherit;font-weight:var(--fw-medium)}
|
||||
.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 .disc b{display:block;margin-bottom:2px}
|
||||
.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:first-child,.a4 .sumtab th:first-child{text-align:left}
|
||||
.a4 .cites{color:var(--ai);font-size:11.5px}
|
||||
.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 .report-foot{position:absolute;left:76px;right:76px;bottom:36px}
|
||||
.a4 .st-todo{font-weight:600}
|
||||
@media (max-width:1400px){.pages{zoom:.86}}
|
||||
.a4 .sev{font-size:var(--rp-xs)}
|
||||
.a4 .flag{margin-left:var(--sp-1);color:var(--text-secondary);font-size:var(--rp-xs);font-weight:var(--fw-regular)}
|
||||
.a4 .cites{margin-top:2px;color:var(--ai);font-size:var(--rp-xs);line-height:1.5}
|
||||
.a4 .st-todo{font-weight:var(--fw-bold)}
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="content wide">
|
||||
<div class="rp">
|
||||
<div class="rp-side">
|
||||
<div class="card">
|
||||
<h3>导出报告</h3>
|
||||
<label class="chk"><input type="checkbox" checked disabled><span>问题明细(全部级别)</span></label>
|
||||
<label class="chk"><input type="checkbox" checked data-opt="appendix"><span>附录:需人工核对</span></label>
|
||||
<label class="chk"><input type="checkbox" checked data-opt="cites"><span>每条附原文出处</span></label>
|
||||
<div class="alert warn" style="margin:var(--sp-3) 0"><span class="ico w">!</span><p class="cap">还有 <b>3 项致命问题</b>待处理,报告会如实列出。</p></div>
|
||||
<div class="row"><button class="btn primary" data-dl>下载 PDF</button><button class="btn" onclick="window.print()">打印</button></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>已导出的报告</h3>
|
||||
<div class="cap"><a class="plain" href="#">11-01 16:12 · 第 1 次检查.pdf</a><br><span class="muted">王敏 导出 · 12 页</span></div>
|
||||
<p class="note">报告生成后不再改动,随项目归档;重新导出会生成一份新的。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pages" data-pages></div>
|
||||
<div class="pages" data-pages aria-label="报告预览"></div>
|
||||
<aside class="rp-side">
|
||||
<section class="card flush" aria-labelledby="h-exp">
|
||||
<div class="card-h"><h2 id="h-exp">导出 PDF</h2></div>
|
||||
<div class="card-b">
|
||||
<div class="choices">
|
||||
<label class="choice dis"><input type="checkbox" class="ck" checked disabled><span>问题明细<small>全部级别,必选</small></span></label>
|
||||
<label class="choice"><input type="checkbox" class="ck" checked data-opt="appendix"><span>附录:需人工核对</span></label>
|
||||
<label class="choice"><input type="checkbox" class="ck" checked data-opt="cites"><span>每条附原文出处</span></label>
|
||||
</div>
|
||||
<p class="hint-warn" data-fatal-hint></p>
|
||||
</div>
|
||||
<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>
|
||||
</section>
|
||||
<section class="card flush" aria-labelledby="h-done">
|
||||
<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>
|
||||
</main>
|
||||
<script src="assets/data.js"></script>
|
||||
<script src="assets/app.js"></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" });
|
||||
const C = D.check, SEV = { fatal: "致命", major: "重要", hint: "提示" };
|
||||
const ST = (f) => f.status === "ok" ? `已确认无误 · ${f.by} ${f.at}` : '<span class="st-todo">待处理</span>';
|
||||
const head = (n, total) => `<div class="report-head"><img src="../design/logo/svg/logo-horizontal.svg" alt="标博士"><span>投标文件辅助检查报告</span></div>`;
|
||||
const foot = (n) => `<div class="report-foot"><span>本报告为辅助检查结果,不构成对投标结果的保证</span><span class="fig">${n} / 3</span></div>`;
|
||||
|
||||
const C = D.check, P = D.project, SEV = { fatal: "致命", major: "重要", hint: "提示" };
|
||||
const EXPORT_AT = "2026-11-02 15:00"; // 演示里的「现在」
|
||||
/* 报告和留痕的日期写法:「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 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() {
|
||||
const cites = $("[data-opt=cites]").checked, appx = $("[data-opt=appendix]").checked;
|
||||
const p1 = `<section class="a4">${head()}
|
||||
<h2>${D.project.name}</h2><p class="sub">采购包 1 · 智慧大厅软硬件集成</p>
|
||||
<dl><dt>项目编号</dt><dd>${D.project.no}</dd><dt>采购人</dt><dd>${D.project.buyer}</dd><dt>法律体系</dt><dd>政府采购 · 公开招标</dd>
|
||||
<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>
|
||||
<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>
|
||||
<h4>检查覆盖范围</h4>
|
||||
<p><b>查了:</b>清单 26 条的响应完整性;形式要件(格式文件、签章位置有无、落款日期、其他项目名称);资质匹配(以投标截止日判断有效期);报价形式(限价、开标一览表与分项合计、大小写)。</p>
|
||||
<p><b>没查:</b>份数、密封、CA 加密、递交回执(见附录「需人工核对」);签章真伪;报价是否合理;评分高低。</p>
|
||||
<h4>汇总</h4>
|
||||
<table class="sumtab"><tr><th>级别</th><th>问题数</th><th>待处理</th><th>已确认无误</th><th>已修改</th><th>已忽略</th></tr>
|
||||
${["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>
|
||||
<p class="cap muted" style="margin-top:8px">本次已解决 ${C.resolved} 项(上次检查有、这次没有再发现)。</p>
|
||||
<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;
|
||||
const box = $("[data-pages]");
|
||||
box.innerHTML = cover();
|
||||
flowTable("问题明细", `<thead><tr><th style="width:15mm">级别</th><th>问题</th><th style="width:36mm">处理</th></tr></thead>`,
|
||||
C.findings.map((f) => `<tr><td><span class="sev ${f.sev}">${SEV[f.sev]}</span></td>
|
||||
<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>
|
||||
<td>${ST(f)}</td></tr>`));
|
||||
if (appx) flowTable("附录 · 需人工核对", `<thead><tr><th>要求</th><th style="width:36mm">出处</th><th style="width:30mm">核对</th></tr></thead>`,
|
||||
C.manual.map((m) => `<tr><td>${esc(m.title)}</td><td class="cites">${citeTxt(m.cite)}</td><td>未勾选</td></tr>`),
|
||||
`<p class="cap">「未勾选」表示导出时还没有人在系统里勾选核对。勾选结果按项目保存,写明勾选人和时间。</p>`);
|
||||
const pages = $$(".a4", box);
|
||||
pages.forEach((p, i) => { $("[data-pno]", p).textContent = `${i + 1} / ${pages.length}`; });
|
||||
return pages.length;
|
||||
}
|
||||
$$("[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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,274 +1,340 @@
|
||||
<!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">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>查标 · 标博士原型</title>
|
||||
<link rel="icon" href="../design/logo/favicon.svg">
|
||||
<link rel="stylesheet" href="../design/system/tokens.css">
|
||||
<link rel="stylesheet" href="../design/system/fonts-local.css">
|
||||
<link rel="stylesheet" href="../design/system/components.css">
|
||||
<link rel="stylesheet" href="assets/app.css">
|
||||
<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}
|
||||
.res-bar .actions{margin-left:auto;display:flex;align-items:center;gap:var(--sp-3)}
|
||||
.filters{display:flex;align-items:center;gap:var(--sp-3);margin:var(--sp-5) 0 var(--sp-3)}
|
||||
.filters .right{margin-left:auto;display:flex;gap:var(--sp-2);align-items:center}
|
||||
.finding{outline:none}
|
||||
.finding.focus{box-shadow:inset 0 0 0 2px var(--c-ink)}
|
||||
.finding.leaving{opacity:.3;transition:opacity var(--dur-base)}
|
||||
.u4{margin-top:var(--sp-3);padding:var(--sp-3) var(--sp-4);background:var(--bg-sunken);border-radius:var(--radius-sm)}
|
||||
.u4 label{font-weight:var(--fw-bold);display:block;margin-bottom:6px}
|
||||
.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)}
|
||||
.group-h .chev{transition:transform var(--dur-fast)}
|
||||
.group-h[aria-expanded="true"] .chev{transform:rotate(90deg)}
|
||||
.processed .finding{background:#FCFCFB}
|
||||
.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)}
|
||||
.sign .note-text{color:var(--text-secondary)}
|
||||
/* 结果栏:标题 + 本次检查的信息;右侧页面级操作(主按钮一个:导出报告) */
|
||||
.rbar{display:flex;align-items:flex-end;gap:var(--sp-4);margin-bottom:var(--sp-4)}
|
||||
.rbar > div:first-child{min-width:0}
|
||||
.rbar h2{margin:0;font:var(--fw-bold) var(--fs-title)/var(--lh-title) var(--font-sans)}
|
||||
.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}
|
||||
.rbar .meta .fig{font-size:var(--fs-figure-xs);font-weight:var(--fw-medium);color:var(--text-secondary)}
|
||||
.rbar .actions{margin-left:auto;display:flex;flex-direction:column;align-items:flex-end;gap:6px;flex:none}
|
||||
.rbar .actions .row{display:flex;gap:var(--sp-2);align-items:center}
|
||||
.rbar .actions .cost{margin-right:var(--sp-2)}
|
||||
.rbar .export-note{font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary);white-space:nowrap}
|
||||
|
||||
/* 卡片头:筛选 + 右侧全选「提示」、快捷键 */
|
||||
.card-h .r{margin-left:auto;display:flex;gap:var(--sp-3);align-items:center}
|
||||
.card-h .choice{align-items:center;white-space:nowrap}
|
||||
.card-h .choice .ck{margin-top:0}
|
||||
.card-h .cap{color:var(--text-secondary)}
|
||||
|
||||
/* 问题列表放进卡片:第一条也要和卡片头隔开 */
|
||||
.flist{outline:0}
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="content wide">
|
||||
<div class="res-bar">
|
||||
<div class="content">
|
||||
<div class="rbar">
|
||||
<div>
|
||||
<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 class="actions">
|
||||
<span class="cost">24 小时内复查不扣额度(至今天 16:00)</span>
|
||||
<a class="btn" href="bid-check.html">重新检查</a>
|
||||
<a class="btn primary" href="report.html" data-export>导出报告</a>
|
||||
</div>
|
||||
</div>
|
||||
<p class="cap" data-export-note style="text-align:right;margin:6px 0 0;color:var(--text-secondary)"></p>
|
||||
|
||||
<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 class="row">
|
||||
<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" href="bid-check.html">重新检查</a>
|
||||
<a class="btn primary" href="report.html"><i data-ic="download"></i>导出报告</a>
|
||||
</div>
|
||||
<span class="export-note" data-export-note></span>
|
||||
</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>
|
||||
<div class="card" data-manual></div>
|
||||
<section class="results card flush" aria-label="检查结果">
|
||||
<div class="card-h">
|
||||
<div class="seg" role="group" aria-label="按级别筛选" data-fseg>
|
||||
<button aria-pressed="true" data-v="">全部 <span class="fig" data-c=""></span></button>
|
||||
<button aria-pressed="false" data-v="fatal"><span class="sev fatal">致命<span class="fig" data-c="fatal"></span></span></button>
|
||||
<button aria-pressed="false" data-v="major"><span class="sev major">重要<span class="fig" data-c="major"></span></span></button>
|
||||
<button aria-pressed="false" data-v="hint"><span class="sev hint">提示<span class="fig" data-c="hint"></span></span></button>
|
||||
</div>
|
||||
<div class="r">
|
||||
<label class="choice cap" data-allw><input type="checkbox" class="ck" data-fall>全选「提示」</label>
|
||||
<button class="btn sm text" 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>
|
||||
</main>
|
||||
<aside class="drawer" aria-label="原文" data-drawer><div data-viewer></div></aside>
|
||||
|
||||
<script src="assets/data.js"></script>
|
||||
<script src="assets/app.js"></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" });
|
||||
|
||||
const C = D.check;
|
||||
const SEV = { fatal: "致命", major: "重要", hint: "提示" };
|
||||
const ACT = { ok: "确认无误", fixed: "已修改", ignored: "已忽略" };
|
||||
const F = D.check.findings.map((f) => ({ status: "todo", ...f }));
|
||||
let filter = "all", focusId = null, lastHandled = null;
|
||||
/* status:undefined 待处理 / ok / fixed / ignored;session:本次处理的(留在原位) */
|
||||
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
|
||||
|
||||
/* 原文抽屉 */
|
||||
const drawer = $("[data-drawer]");
|
||||
const V = App.viewer($("[data-viewer]"), { closable: true, onClose: () => drawer.classList.remove("open"), onOpen: () => drawer.classList.add("open") });
|
||||
document.addEventListener("click", (e) => {
|
||||
const a = e.target.closest("[data-open]");
|
||||
if (!a) return;
|
||||
e.preventDefault();
|
||||
const f = F.find((x) => x.id === a.closest("[data-id]")?.dataset.id);
|
||||
V.open(a.dataset.open.split("|").map((v, i) => i === 1 ? +v : v), f ? f.cites : null);
|
||||
$$(".cite.on").forEach((x) => x.classList.remove("on")); a.closest(".cite")?.classList.add("on");
|
||||
$("[data-meta]").innerHTML = `第 <span class="fig">${C.no}</span> 次检查 · ${C.time.replace(/(\d+)/g, '<span class="fig">$1</span>')} · ${C.files} · 规则 ${C.rules} · 清单 v2(已确认)`;
|
||||
$("[data-meta]").title = $("[data-meta]").textContent;
|
||||
|
||||
/* ---------- 原文抽屉:点出处、O / Enter ---------- */
|
||||
const DR = App.drawer();
|
||||
function openCite(f, c) {
|
||||
DR.open(c, f ? f.cites : [c]);
|
||||
$$(".cite.on").forEach((x) => x.classList.remove("on"));
|
||||
if (f) { const i = f.cites.findIndex((x) => x.join("|") === c.join("|")); $$(`[data-fid="${f.id}"] aside > .cite`)[i]?.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) {
|
||||
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>`;
|
||||
}
|
||||
function row(f) {
|
||||
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 actions = f.status === "todo"
|
||||
? `<div class="f-actions">
|
||||
<button class="btn ${f.sev === "hint" ? "sm" : ""}" data-act="ok" title="我核对过,投标文件这里没问题">确认无误</button>
|
||||
<button class="btn ${f.sev === "hint" ? "sm" : ""}" data-act="fixed" title="我已在投标文件里修改,复查时会再查">已修改</button>
|
||||
<button class="btn ${f.sev === "hint" ? "sm" : ""}" data-act="ignored" title="知道这个问题,决定不处理,需要填原因">忽略并注明原因</button>
|
||||
<a class="plain feedback" href="#" data-feedback>判断有误?</a></div>`
|
||||
: sign(f);
|
||||
return `<article class="finding ${f.sev === "fatal" ? "is-fatal" : ""} ${focusId === f.id ? "focus" : ""}" data-id="${f.id}" tabindex="-1">
|
||||
<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">${f.flag}</span>` : ""}</div>
|
||||
${f.text.map((t) => `<p class="f-text">${esc(t)}</p>`).join("")}
|
||||
${actions}
|
||||
</div>
|
||||
<aside>${cites}</aside>
|
||||
</article>`;
|
||||
}
|
||||
let openProcessed = false, openResolved = false;
|
||||
function render() {
|
||||
const todo = F.filter((f) => f.status === "todo");
|
||||
const shown = todo.filter((f) => filter === "all" || f.sev === filter);
|
||||
const done = F.filter((f) => f.status !== "todo");
|
||||
const n = (s) => todo.filter((f) => f.sev === s).length;
|
||||
$("[data-c=all]").textContent = todo.length;
|
||||
["fatal", "major", "hint"].forEach((s) => ($(`[data-c=${s}]`).textContent = n(s)));
|
||||
$("[data-list]").innerHTML =
|
||||
(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>
|
||||
<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;
|
||||
}
|
||||
/* ---------- 键盘逐条处理(§8.2):在页面任何地方都能用;输入框、对话框、菜单里不响应 ---------- */
|
||||
document.addEventListener("keydown", (e) => {
|
||||
const t = e.target;
|
||||
if (t.matches && t.matches("[data-u4t]")) {
|
||||
const f = F.find((x) => x.id === t.closest("[data-fid]").dataset.fid);
|
||||
if (e.key === "Escape") { e.preventDefault(); f.open = null; f.err = false; render(); focusList(); }
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); submitU4(f); }
|
||||
return;
|
||||
}
|
||||
if ($(".scrim") || t.closest?.("input, textarea, select, .menu, .pop") || e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
const todo = ftodo(), f = todo[fcur], k = e.key.toLowerCase();
|
||||
if (e.key === "?") { e.preventDefault(); App.keysDialog(App.KEYS.check); return; }
|
||||
if (k === "u") {
|
||||
if (f && f.status) { undo(f); toast(`已撤销「${esc(f.title)}」的处理`, { ok: false }); }
|
||||
else toast("当前这一条还没有处理", { ok: false });
|
||||
return;
|
||||
}
|
||||
if (!f) return;
|
||||
if (k === "j" || k === "k") {
|
||||
e.preventDefault();
|
||||
fcur = Math.max(0, Math.min(todo.length - 1, fcur + (k === "j" ? 1 : -1)));
|
||||
render(); flist.classList.add("kb-on"); focusList();
|
||||
$(".finding.sel").scrollIntoView({ block: "nearest" });
|
||||
if (DR.isOpen()) openCite(todo[fcur], todo[fcur].cites[0]); // 抽屉开着时跟着翻到当前条的出处
|
||||
} else if (k === "o" || (e.key === "Enter" && !t.closest("button, a, label"))) {
|
||||
e.preventDefault(); openCite(f, f.cites[0]);
|
||||
} else if (k === "x" && f.sev === "hint" && !f.status) {
|
||||
f.ck = !f.ck; render();
|
||||
} else if (["1", "2", "3"].includes(e.key) && !f.status && !f.open) {
|
||||
e.preventDefault(); act(f, +e.key);
|
||||
}
|
||||
});
|
||||
flist.addEventListener("pointerdown", () => flist.classList.remove("kb-on"));
|
||||
|
||||
function handle(id, act, extra = {}) {
|
||||
const f = F.find((x) => x.id === id);
|
||||
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);
|
||||
/* ---------- 判断有误?(J5):反馈不改变处理状态 ---------- */
|
||||
function feedback(f) {
|
||||
dialog({
|
||||
title: "反馈:判断有误",
|
||||
body: `<p class="muted">「${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="field" style="margin-top:var(--sp-4)"><label for="fbt">补充说明 <small>(选填)</small></label><textarea class="input" id="fbt"></textarea></div>
|
||||
<p class="cap muted">反馈只用于改进检查规则,不会改变这条问题的处理状态。</p>`,
|
||||
body: `<div class="fb"><p class="muted" style="margin:0">「${esc(f.title)}」</p>
|
||||
<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 fill" style="margin-top:var(--sp-4)"><label for="fbt">补充说明 <small>(选填)</small></label><textarea class="input" id="fbt" maxlength="200"></textarea>
|
||||
<span class="msg">反馈只用于改进检查规则,不改变这条问题的处理状态。</span></div></div>`,
|
||||
ok: "提交反馈",
|
||||
onOk: () => toast("已收到反馈,谢谢。我们会逐条复盘。")
|
||||
onOk: () => toast("已收到反馈")
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
const t = e.target;
|
||||
const id = t.closest("[data-id]")?.dataset.id;
|
||||
if (t.matches("[data-act]")) { focusId = id; const a = t.dataset.act; a === "ok" ? confirmOk(id) : a === "fixed" ? handle(id, "fixed") : ignore(id); }
|
||||
else if (t.matches("[data-undo]")) { e.preventDefault(); undo(id); }
|
||||
else if (t.matches("[data-toast-undo]")) { e.preventDefault(); $(".toast")?.remove(); undo(lastHandled); }
|
||||
else if (t.matches("[data-feedback]")) { e.preventDefault(); feedback(id); }
|
||||
else if (t.closest("[data-toggle]")) { const k = t.closest("[data-toggle]").dataset.toggle; k === "p" ? (openProcessed = !openProcessed) : (openResolved = !openResolved); render(); }
|
||||
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(); }
|
||||
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>` });
|
||||
/* ---------- 需人工核对(FR-M3-04,决策 P2:按项目保存,复查沿用,报告写勾选人和时间) ---------- */
|
||||
const M = C.manual.map((m) => ({ ...m, by: "" }));
|
||||
function mrender() {
|
||||
$("[data-manual]").innerHTML = M.map((m) => `<div class="mrow" data-m="${m.id}">
|
||||
<div><label class="choice"><input type="checkbox" class="ck" data-mck ${m.by ? "checked" : ""}><span>${esc(m.title)}</span></label>
|
||||
${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>
|
||||
${App.citeLink(m.cite)}</div>`).join("");
|
||||
$("[data-mn]").textContent = M.filter((m) => m.by).length;
|
||||
$("[data-mt]").textContent = M.length;
|
||||
}
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.target.matches("input, textarea") || $(".scrim")) return;
|
||||
const list = $$("[data-list] > .finding");
|
||||
let i = list.findIndex((el) => el.dataset.id === focusId);
|
||||
const k = e.key.toLowerCase();
|
||||
if (k === "j" || k === "k") {
|
||||
i = k === "j" ? Math.min(i + 1, list.length - 1) : Math.max(i - 1, 0);
|
||||
focusId = list[i]?.dataset.id; list.forEach((el) => el.classList.toggle("focus", el.dataset.id === focusId));
|
||||
list[i]?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
} 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();
|
||||
$("[data-manual]").addEventListener("change", (e) => {
|
||||
if (!e.target.matches("[data-mck]")) return;
|
||||
const m = M.find((x) => x.id === e.target.closest("[data-m]").dataset.m);
|
||||
Object.assign(m, e.target.checked ? { by: D.user.name, at: stamp() } : { by: "", at: "" });
|
||||
mrender(); $(`[data-m="${m.id}"] [data-mck]`).focus();
|
||||
});
|
||||
$("[data-manual]").addEventListener("click", (e) => {
|
||||
const a = e.target.closest("[data-open]"); if (!a) return;
|
||||
e.preventDefault(); openCite(null, App.parseCite(a.dataset.open));
|
||||
});
|
||||
|
||||
/* 需人工核对 */
|
||||
$("[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();
|
||||
render(); mrender();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,74 +1,221 @@
|
||||
<!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">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>工作台 · 标博士原型</title>
|
||||
<link rel="icon" href="../design/logo/favicon.svg">
|
||||
<link rel="stylesheet" href="../design/system/tokens.css">
|
||||
<link rel="stylesheet" href="../design/system/fonts-local.css">
|
||||
<link rel="stylesheet" href="../design/system/components.css">
|
||||
<link rel="stylesheet" href="assets/app.css">
|
||||
<style>
|
||||
.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:var(--sp-4);margin-bottom:var(--sp-6)}
|
||||
.stat{background:var(--bg-surface);border:1px solid var(--border-subtle);border-radius:var(--radius-sm);padding:var(--sp-4) var(--sp-5)}
|
||||
.stat small{display:block;color:var(--text-secondary);font-size:var(--fs-caption)}
|
||||
.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)}
|
||||
.toolbar{display:flex;align-items:center;gap:var(--sp-3);margin-bottom:var(--sp-3)}
|
||||
.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)}
|
||||
/* 主列 + 右侧信息栏(--aside-w),全页一个间距 --gap-card */
|
||||
.content > .alert,.content > .stats{margin-bottom:var(--gap-card)}
|
||||
.wb{display:grid;grid-template-columns:minmax(0,1fr) var(--aside-w);gap:var(--gap-card);align-items:start}
|
||||
|
||||
/* 项目卡片头:筛选 + 已选统计格的清除按钮 */
|
||||
.card-h .seg{margin-left:var(--sp-2)}
|
||||
.card-h .clear{margin-left:auto}
|
||||
|
||||
/* 待处理的致命问题:不加左侧红竖线,靠实心「致命」标签扫读 */
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="content">
|
||||
<div class="page-title">
|
||||
<div><h1>工作台</h1><p>按投标截止时间排序。截止临近的项目排在前面。</p></div>
|
||||
<div class="actions"><a class="btn primary" href="project-new.html">新建项目</a></div>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<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><h1>工作台</h1><p data-hello></p></div>
|
||||
<div class="actions">
|
||||
<span class="cost">扣 1 次解析 · 剩 <span class="fig" data-parse-left></span> 次</span>
|
||||
<a class="btn primary" href="project-new.html"><i data-ic="plus"></i>新建项目</a>
|
||||
</div>
|
||||
<div class="right"><input class="input" placeholder="搜索项目名称、采购人、项目编号" style="width:280px"></div>
|
||||
</div>
|
||||
<table class="data">
|
||||
<thead><tr><th>项目</th><th>所投包</th><th>截止</th><th>状态</th><th>就绪</th><th class="r">致命</th></tr></thead>
|
||||
<tbody data-rows></tbody>
|
||||
</table>
|
||||
<p class="note">「就绪」只在「已检查」的项目上显示,只表示致命问题都已处理,不代表投标文件没有问题。截止临近用赭色提示,不用红色。</p>
|
||||
|
||||
<div data-alert></div>
|
||||
|
||||
<section class="stats" aria-label="项目统计,点击可筛选项目列表" data-stats></section>
|
||||
|
||||
<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>
|
||||
</main>
|
||||
|
||||
<script src="assets/data.js"></script>
|
||||
<script src="assets/app.js"></script>
|
||||
<script>
|
||||
const { D, $, $$ } = App;
|
||||
App.shell({ nav: "workbench", crumbs: [["工作台", ""]] });
|
||||
let s = "active";
|
||||
function render() {
|
||||
const rows = [...D.projects].sort((a, b) => a.hoursLeft - b.hoursLeft).filter((p) => s === "all" || (s === "active" ? !["已递交", "已归档"].includes(p.status) : p.status === s));
|
||||
$("[data-rows]").innerHTML = rows.map((p) => `<tr data-href="${p.href}">
|
||||
<td><div class="proj-name">${p.name}</div><div class="proj-sub">${p.buyer}</div></td>
|
||||
<td>${p.lot}</td><td class="${p.dueCls}">${p.due}</td>
|
||||
<td><span class="tag status">${p.status}</span></td>
|
||||
<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("")
|
||||
|| `<tr><td colspan="6" class="muted" style="text-align:center;padding:40px">没有符合条件的项目。</td></tr>`;
|
||||
const { D, $, $$, esc, icon, toast } = App;
|
||||
const P = [...D.projects].sort((a, b) => (a.hoursLeft < 0) - (b.hoursLeft < 0) || (a.hoursLeft < 0 ? b.hoursLeft - a.hoursLeft : a.hoursLeft - b.hoursLeft));
|
||||
const CHECK_COST = `扣 1 次检查 · 剩 <span class="fig">${D.quota.check.left}</span> 次`;
|
||||
const ACTIVE = (p) => !["已递交", "已归档"].includes(p.status);
|
||||
const STAT = {
|
||||
active: { label: "进行中", fn: ACTIVE },
|
||||
due7: { label: "7 天内截止", fn: (p) => p.hoursLeft >= 0 && p.hoursLeft <= 168 },
|
||||
fatal: { label: "有致命问题", fn: (p) => p.fatal > 0 },
|
||||
pend: { label: "有待确认条目", fn: (p) => p.pend > 0 }
|
||||
};
|
||||
let seg = "active", stat = "", q = "";
|
||||
|
||||
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) => {
|
||||
const b = e.target.closest("[data-s]");
|
||||
if (b) { s = b.dataset.s; $$("[data-s]").forEach((x) => x.classList.toggle("on", x === b)); render(); }
|
||||
const r = e.target.closest("[data-href]");
|
||||
if (r) location.href = r.dataset.href;
|
||||
function render() {
|
||||
const rows = P.map((p, i) => [p, i]).filter(([p]) => {
|
||||
if (stat) { if (!STAT[stat].fn(p)) return false; }
|
||||
else if (seg !== "all" && !(seg === "active" ? ACTIVE(p) : p.status === seg)) return false;
|
||||
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();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user