Compare commits

...

59 Commits

Author SHA1 Message Date
YANG JIANKUAN
0b783ababa feat(ui): 报告预览改用 pdf.js、读标页左右布局、「导出」改称「生成」
Some checks are pending
ci / server (push) Waiting to run
ci / web (push) Waiting to run
ci / contract (push) Waiting to run
- 报告页:预览从浏览器自带 PDF 查看器(深色、改不了配色)换成 pdf.js 连续滚动(components/ReportViewer.vue),顶栏带下载;
  「导出 PDF」改为「生成报告 / 重新生成报告」,列表改称「已生成的报告」,最新一份之后又检查或致命数变了时提示重新生成
- 报告 PDF 模板 2026.11-v4:「导出人 / 导出时」改为「生成」;其余页面的「导出的报告」同步改称
- 读标页:改成和查标、概览一样的左右布局,项目头和清单栏滚出屏幕;分类导航、搜索栏和卡片上沿贴在顶栏下方留出间距
- 读标页:响应方式收进清单栏「响应方式」按钮的浮层,招标文件没写明或写法不一致且没人改过时按钮上加提示点;清单状态改为标题下一行
- 概览检查记录:报告列只写份数和「查看报告」,跳到报告标签页并预览那次检查最新的一份
- 招标 / 投标文件切换标签:双箭头放在文字两头,方向改为指向展开的方向
- 项目头:投标截止、状态、就绪三项取值同高对齐

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:57:00 +08:00
YANG JIANKUAN
dd8c04001d feat(m6): 额度流水、套餐开通、数据删除、运营后台(M6 剩下的 P0)
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled
决策 M6-1–M6-10(docs/architecture/m6-plan.md、docs/decisions.md)。

用户端:
- 额度与套餐页:本期额度、按功能分布、扣点规则、流水(逐条余额、关联项目和任务、导出 CSV)
- 开通 / 续费申请(对公转账,运营确认到账后开通),80% / 100% 提醒
- 专业版到期:整个账号只读、额度清零,到期前 30 / 7 / 0 天提醒(worker 每小时 billing_tick)
- 删除项目(彻底删除、销毁数据密钥),注销账号(企业名称 + 短信验证码)
- 通知铃铛、账号页

运营后台:
- 独立进程 bid-doctor ops(/ops/v1)和 apps/admin;用户名 + 密码 + TOTP + IP 白名单
- 开通申请(到账 / 开通 / 退回 / 发票)、企业查询和调额度、误报 / 漏报、失败任务、审计;不看业务内容
- 左侧菜单(标准 logo、可展开收起的分组、图标、待办数、环境标签)
- 运营人员角色只留结构:ops_admin.role、权限点、每个接口的检查点、菜单按权限过滤

数据库:迁移 0010(排队的订阅、申请单号、通知类型、billing_due)、0011(bd_ops 权限)、0012(运营角色)。
另修:取消解析 / 检查接口报 500;uuid7 进程内单调;data_key 销毁后不再新建;审计 None 存 SQL NULL。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:06:06 +08:00
YANG JIANKUAN
54cbb556d0 feat(ui): 查标第 6 步前端补齐
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled
- 读标页:每条清单项写最近一次检查的结论,点投标侧出处看投标原文;
  「已响应」也能反馈判断有误(漏报);证书要求写资质库里的情况
  (新接口 GET /projects/{id}/qualification-matches,资质库空时不写)
- 概览:检查记录卡(历次检查、各级问题数、报告下载)、文件卡
- 工作台:「致命」列、统计可点筛选、待处理的致命问题卡(?f= 定位到问题)
- 日期输入加日历弹层(DateInput,Reka UI Calendar)
- 查标页:? 键显示快捷键说明;反馈对话框抽成 FeedbackDialog 共用
- 扫描件提示去掉多余空格

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 13:39:19 +08:00
YANG JIANKUAN
808259da87 fix(check): 修国动真实投标文件暴露的 6 处(规则集 r5)
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled
国动真实投标文件查标一次(check-eval §14):44 个问题里有用的 9 个,
致命、重要两级都是误报。按决策 E3 全部修(§15):
- 响应错位:「完全满足,无偏离。」这类复合肯定写法整体算肯定
- 附加条件:「不以……另行收费」是承诺
- 扫描件:文字层 v5 记每页图片占比,带一行说明的扫描页也认;提示写明扫描页范围
- 不需要这家投标人回答的:无特定资格要求、总括性资格要求、中标后才做的事、
  投标文件没声称适用的联合体和价格扣除
- 「方案应明确 A、B、C……」逐项再检索
- 同一批送出的片段都可引用(引文照样核对);出处没过校验的不沿用

国动重新检查 44 → 11(全是提示,有用的 9 个都在);开发集召回 112 → 111
(模型重跑波动),致命级 25/32 不变,规矩的投标文件问题 239 → 213。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 13:16:12 +08:00
YANG JIANKUAN
f5edc585f1 fix(ui): 解析完成时结果没到前不显示「0 条」
事件流先报完成、结果随后由重新拉取带回,中间会闪一下「提取要求 0 条」。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 13:16:12 +08:00
YANG JIANKUAN
5b77012fac feat(ui): 查标、概览改为左右布局;招标文件、投标文件左右切换
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled
- 查标结果页、概览和读标页一样:左边内容随页面滚动,右边原文贴住视口,去掉原文侧边抽屉
- DocStage:查标右侧招标文件、投标文件横向排开,当前的占满,另一份露出 80px
  (文件名移到露出的一侧、纸边、带方向箭头的竖排标签,截断处淡入页面底色,悬停时探出一点);
  点露出的一条或另一边的出处就滑过去,两边各自记着页和高亮
- 查标:进页面翻到第一条问题的出处,J / K 时原文跟着翻,优先留在正在看的一边;
  左边变窄后问题行改单列,出处放在说明下面,快捷键提示收成「快捷键」
- 概览:关键日期和采购包合成一张卡放到最上面,项目要点在下
- 原文查看器:直接打开到某页时会并发两次渲染、叠出镜像乱字,改为后一次等前一次停下

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 12:23:50 +08:00
YANG JIANKUAN
9f1a79ae03 style(ui): 内容区四角改为直角
内容区原来只有左上角 12px 圆角、左下角是直角,看着不统一;去掉 .main 和 .top 的左上角圆角。
设计规范 §5.3、UI demo 和原型的样式同步修改。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 12:23:40 +08:00
YANG JIANKUAN
3a7e3d7f3f fix(extract): ★ 一律按实质性条款处理(口径 Q2 修订)
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled
文件把 ★ 定义成计分项(S02「加★为核心技术参数」)时不再降为「重要」:
删掉 star_scored / STAR_IMPORTANT 及相关分支,不再生成 meaning = important 的标注规则。
S02 的 11 条 ★ 要求离线验证全部为实质性。决策记入 docs/decisions.md。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 11:51:25 +08:00
YANG JIANKUAN
1966ce9b15 fix(ui): 读标、查标走查修复;查标结果页按原型补齐
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled
走查(注册 → 读标 → 查标 → 导出报告)发现的问题:
- 下拉:原生 select 换成 SelectBox(Reka UI,规范 §7.21):右侧 chevron 有边距、
  sm 高 32 和搜索框一致、浮层和触发框同宽、选中项加粗带 ✓;响应方式栏的签名不再被挤成竖排
- 读标:「依据 · 第 N 页」改成行内出处链接;证明材料标签不再被挤压
- 概览:关键日期同一种、同一时刻只列一次
- 报告页:「还有 N 项致命」读项目实时值(检查的 counts 是快照)
- 读标抽取:★ 只表示「重要」的文件,清单项也挂到 ★ 规则上(「已识别 N 条」)
- 检查范围文案和后端覆盖范围一致;报告出处不重复写文件名(模板 2026.11-v3)
- 带加载态的按钮图标和文字间距;单选、半选、禁用复选框样式

查标结果页按原型 results.html 补齐:
- 本次处理的问题留在原位(署名行可撤销),修了处理后仍显示处理按钮的问题
- 键盘 J / K / O / 1 / 2 / 3 / X / U / Esc;「提示」批量确认无误;「判断有误?」反馈
- 覆盖范围默认收起成一行;顶部加导出报告入口和致命提示;已处理计数
- 检查进度卡和解析进度卡同一种样式
- 项目头「就绪 / 未就绪」,致命数挂在查标标签上
- 上传投标文件页:已有分册时新增表单收起到「添加分册」

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 11:38:56 +08:00
YANG JIANKUAN
6e0d4c1dd6 feat(addendum): 补遗追加(FR-M2-10)
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled
- 招标文件解析后可以追加补遗 / 澄清:单独解析、扣 1 次;新条目标「补遗 N」进正式清单,清单回到草稿(AD1)
- 同一种日期、时间不同的旧条目标「已被补遗 N 更新」,逐条确认更新或保留两条(AD2、AD3)
- 项目字段的新值在概览「采用 / 不采用」:采用时以补遗为准、清掉人工值;采用截止时间时日期条目一起确认;
  没确认前查标用旧值,检查预览提示(AD4)
- 补遗不改项目名称、编号、采购人、代理机构、法律体系(更正公告标题会被抽成项目名称);时间比较按北京时间补时区
- 接口:fields/{key}/pending、checklist-items/{id}/supersession;清单项 origin_document、superseded_by;
  关键日期 replaces;解析结果 dates_superseded、fields_pending;检查预览 pending_updates
- 前端:补遗模式的上传页、解析页结果、概览确认、读标页来源筛选和日期确认、检查预览提示
- 决策 AD1–AD6;方案和落地记录 docs/architecture/addendum-plan.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 00:27:21 +08:00
YANG JIANKUAN
ed6a2a907d feat(project): 项目基本信息修改和分包选择(FR-M2-03、04)
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled
- 字段修改:人工值优先、撤销恢复 AI 值、按字段校验(金额可写「286万」,按元存)、
  冗余列同步、审计留痕、只读项目 409;关键日期显示改后的投标截止时间
- 分包:增删改、选包(至少一个)、一键「不分包」;删包时条目归为通用,删到一个不剩回到不分包
- 清单列表 lot_scope 默认只列通用 + 所投的包,响应带 hidden_count
- 检查记下当时用的项目依据(截止时间、限价、所投的包、法律体系):报告按它写,
  之后改了信息检查结果接口给 basis_changed,查标页提示重新检查
- 前端:概览标签页(项目要点、采购包、关键日期)、字段修改对话框、选包页和手工调整;
  解析完识别到多个包时进选包页;读标页清单范围和所属采购包;查标页写明查哪几个包
- 决策 PI1–PI7;方案和落地记录 docs/architecture/project-info-plan.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 23:46:07 +08:00
YANG JIANKUAN
08e3fe7eff feat(qualification): 资质库和资质匹配(查标第 4 步,规则集 2026.11-r4)
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled
- 资质库(FR-M6-08):增删改、证书扫描件(PDF/JPG/PNG,企业级密钥加密、签名下载)、
  late_for 按进行中项目的投标截止日提醒、审计;前端资质库页和对话框,侧栏入口
- 资质匹配(FR-M3-05):证书类别词表(16 类,产品证书、人员证书不进匹配),
  QUAL.EXPIRED / LEVEL_LOW / NOT_COVERED;投标文字层按清单项定级,只有资质库记录的降一级,
  出处显示「依据:资质库记录」(查标页、报告模板 v2);一类证书一个问题;评分项不逐条找响应的也查
- 响应错位规则不再把资质表的证书名称格当成照抄的要求
- 评测:qual_defects.py 派生资质情况表,D12 3/3、D18 2/2、规矩的资质表 0 误报;开发集不退化
- 决策 QL1–QL7;方案和落地记录 docs/architecture/qualification-plan.md,评测 check-eval.md §13

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 23:22:50 +08:00
YANG JIANKUAN
e7fb797925 feat(report): 检查报告 PDF 导出和归档;项目状态流转(递交 / 归档)
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled
- report 模块:导出 → worker 用 Gotenberg 把 HTML 转成 A4 PDF → 按项目数据密钥加密存储 → report
  (不可改、存哈希);report_job 记生成过程(迁移 0009),完成推 report.ready、发通知
- 报告首页:项目信息、投标文件版本、检查依据、规则版本、导出人、检查覆盖范围、汇总、免责声明;
  问题明细带两侧出处和处理人;附录为需人工核对的勾选结果;模板文字不出现「通过 / 合格 / 保证」
- 前端「报告」标签页:导出选项、内嵌预览、历次报告下载;工作台「导出检查报告」直达
- 项目状态流转 POST /projects/{id}/transitions:致命未处理也能递交(确认框说明),
  递交后只读,撤销归档回到归档前的状态,都写审计
- 通知补上检查、报告的标题

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:51:43 +08:00
YANG JIANKUAN
902a130c82 fix(check): 修留出集暴露的 5 处(规则集 2026.11-r3)
- 响应方式:「逐条响应」要说的是要求 / 参数 / 偏离,声明函填写说明不算;「否则视为无效响应」不算默认接受
- 偏离栏按「是不是明确满足」判断:偏离栏、响应情况栏一起看,其他短写法请人核对(最高重要);表体序号认「技-86」等
- 另报价、附加条件按结构判断(另一个不同的总价金额、保留 / 另行 / 调整报价等)
- 空白栏:「精确到年月日」不算没填
- 比对时折叠异体字(戶→户等),原文照存
- 记录决策 E2:先修再跑国动真实投标文件

开发集召回 75%、规矩投标文件问题数不变;留出集参考数字见 check-eval §12(已看过,仅参考)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:34:12 +08:00
YANG JIANKUAN
fee1c0f842 docs(check): 留出集结果(冻结代码跑一次)和要修的 5 处
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:00:48 +08:00
YANG JIANKUAN
b2b8584321 feat(evals): 留出集生成——风格 C、留出集改写导出;评测可模拟人工确认响应方式
- build.py / mutate.py:补风格 C(叙述型),由封存一侧的会话按封存说明实现;开发集风格 A / B 输出逐字节不变
- build.py:复述要求时去行首编号不再吃掉「2路服务器」这类内容数字
- export_rewrite.py:没有风格 A 稿时从未改写的风格 B 稿导出
- run_check.py:--modes 模拟清单页上人工确认的响应方式(C12 的人工确认点)
- check-eval.md:第 3 步冻结点(a60d16e)

查标代码(server/src、prompts)自冻结后未改。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:58:52 +08:00
YANG JIANKUAN
a60d16e169 docs(check): P13 分项逐项对照试读后暂缓,记录原因
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:01:45 +08:00
YANG JIANKUAN
4a5421a412 feat(check): 同一行按单元格;业绩条款不再误跳;重复条目只合并显示(C5 修订)
- 送给模型的「同一行」在有框线的表格里按单元格分行
- 「以合同签订时间为准」不再被当成「签订合同时才交」,要附合同的业绩评分项照常查
- 重复的要求各自判断,问题接口给出 duplicate_ids,查标页同组折叠显示、一起处理
- 决策 C5 修订记入 decisions.md;开发集召回 74%、致命级 25/32

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:00:24 +08:00
YANG JIANKUAN
df3ebbb6a1 feat(check): 偏离栏直读、响应错位判断;复查复用和硬预算(C2)
- deviation.py:模型判已响应的条目,证据所在表格行的偏离格写着「偏离 / 部分满足」等
  就改判负偏离;照抄要求格、响应格写的是别的内容的改判「响应可能对不上」
- reuse.py:按「提示词 + 要求 + 候选句原文」算缓存键,存模型原始结论;复查时内容没变、
  证据找得回的条目直接沿用,不调模型
- 每次检查送给模型的字数有上限,超出的条目列为请人工核对,写进覆盖范围
- 开发集 17 份:召回 61% → 73%,负偏离未说明 64% → 90%,规矩投标文件问题数不变

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 17:47:12 +08:00
YANG JIANKUAN
81c58798d2 feat(check): 需人工核对(P2)和分项 / 单价上限(C7)
- 需人工核对:份数、密封、递交、CA 签章加密、解密、保证金这类文件里看不出来的要求,
  从正式清单按主题合并、带招标原文出处;manual-checks 接口按项目存勾选、写勾选人和时间,
  复查沿用;查标页加「需人工核对」一块,覆盖范围写明列了几条
- 「报价」类清单项:读标从采购需求的清单表规则读出分项 / 单价上限(checklist/price.py);
  查标交给规则 PRICE.OVER_CEILING_ITEM 比对分项报价表,不交给模型;规则集 2026.11-r2
- 版面工具(分页、分行、归栏)挪到 document/pipeline/layout.py,读标和查标共用
- 评测:price_defects.py 从规矩投标文件派生只带报价问题的 4 份(P01、P02、P03)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 17:31:32 +08:00
YANG JIANKUAN
db1b1f6f9f feat(check): 规则检查(报价形式、形式要件、响应表),表格按单元格分行
- check/rules.py(规则集 2026.11-r1,不调模型):大小写金额、分项合计、超最高限价、
  多个报价、别的项目名称、没填的地方、落款日期、投标有效期、附加条件、响应表里的未响应;
  每条挂投标原文,有依据的挂招标侧出处;缺限价、截止时间、有效期要求时写进覆盖范围
- 文字层的句子带上单元格框,表格按单元格上沿分行
- 按 Fable 审读意见收紧致命级:认不准的降为重要或提示
- 评测:score.py 评规则问题;P07 注入记录改为空白处前后原文,剔除注入到人员表的 D10
- 上传页的检查范围文案同步

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:44:22 +08:00
YANG JIANKUAN
47c6a1258e feat(check): 文件要在、扫描件兜底(C10);响应方式存进清单并可改(C12)
- check/presence.py:「提供《授权委托书》」「须提交投标函」这类要求按标题里有没有这个文件名判断,不调模型。
- 扫描件兜底:要附证明材料的要求没找到、投标文件里有扫描件页时,改为提示级「可能在扫描件里,请人工核对」。
- 响应方式解析时识别、带依据句子存进 checklist.response_modes(迁移 0008);清单页显示、可改(写审计,
  重新解析不覆盖人工改过的);查标用存下来的。新接口 PUT /projects/{id}/checklist/response-modes/{category}。
- check_item_result.layer 加 presence(迁移 0007);契约同步默认接受的两种结论。

开发集 17 份:规矩投标文件的致命问题 120 → 51,致命级召回不变(22 / 32)(docs/architecture/check-eval.md §5)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 15:53:45 +08:00
YANG JIANKUAN
cb0c2fcafe feat(check): 响应方式识别(C12)和「这条要不要投标人回答」;评测集纠错
- checklist/response_mode.py:按类别(技术 / 商务)识别招标文件的响应方式(逐条响应 / 默认接受),规则优先;
  两种写法冲突按逐条,认不出按逐条并写进覆盖范围。
- check/applicability.py:无效情形(★ / 斜体下划线标注的条款除外)、日期与递交、没要求附材料的评分标准、
  无需提供证明或由采购人查询的资格,不逐条找响应,写进覆盖范围;要求附证明材料按原文判断,中标后才交的不算。
- 默认接受的要求没找到响应:一般要求记 implicit_accepted、不出问题;实质性要求记 implicit_review、提示级
  人工核对;要附证明材料的不免(迁移 0006)。
- match.py:修同一行单元格又是别的条目候选句时的 KeyError(每条清单项单独记候选句,句子从总表里查)。
- 评测集:注入器同一要求的所有表格行一起改;散文、附件、格式文件里也响应了的要求不挂漏响应类问题;
  错位响应必须是另一个参数;复核判不成立的单元不再挂;regen.py 按固定种子重新生成。

开发集 17 份:规矩投标文件报出的问题 639 → 229,误报 21.3 → 12.4 / 100,召回 68% → 65%,致命级 20 → 21 / 32
(docs/architecture/check-eval.md §4)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 14:25:14 +08:00
YANG JIANKUAN
b7432643dd feat(check): 候选句带同一行的单元格;提示词 check.match v2
检索常命中投标人照抄要求原文的那一格,响应和偏离写在同一行的别的格里,模型看不到。现在候选句在表格里
时,把同一行(同一页、上下范围重叠)的单元格一起交给模型,也可以引用;v2 说明怎么读一行。
开发集 17 份上查标召回 30% → 50%,带标志词的问题 2% → 54%,规矩投标文件上的误报略降,输入 token +44%;
漏响应和响应对象错位变差,下一轮看(docs/architecture/check-eval.md §3)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:58:02 +08:00
YANG JIANKUAN
1d50faccbe fix(evals): 「暂不支持」只套功能类要求;伞状单元和条件性要求不挂问题;低密度投标文件;run_check 加 --v1 对比开关
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:23:16 +08:00
YANG JIANKUAN
112ea5bd41 docs: 用户抽查第二批评测集复核 28 条,全部同意
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:19:53 +08:00
YANG JIANKUAN
912c2b8acf docs: 查标评测开发集第一版(14 份投标文件)结果;汇总脚本 report.py
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:15:57 +08:00
YANG JIANKUAN
85d6ba67b8 feat(evals): 导出风格 B 改写输入;改正规范里 S01、S05 的响应方式记录;空偏离表注明「无偏离」
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:06:46 +08:00
YANG JIANKUAN
2beccd5fc5 feat(evals): 风格 B(两个分册、改写、自己的编号、干扰项 T01);注入的三处待修项
- build.py:风格 B 分商务资格册和技术册,表格行按 rewrite.json 改写(数字对不上的改写丢弃,规矩靠构造
  保证),用自己的编号,每 12 行技术响应挪到商务资格册(T01);干扰项写进 spec.traps。
- mutate.py:支持多个分册;表格的响应方式以档案的 mode 为准(评分要的技术规格表是 presence,不再把单元
  推成逐条响应);「暂不支持」只套在功能类要求上,数值参数写不满足的值;「提供……方案 / 证明 / 报告」
  这类文件要在的要求不注入参数类问题。
- prompts/style-B.md、prompts/profile.md:风格 B 改写说明和招标文件档案说明(开发集用)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 12:51:17 +08:00
YANG JIANKUAN
9d3c6e82c1 docs: 用户抽查评测集复核 17 条,全部同意
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 12:46:58 +08:00
YANG JIANKUAN
d67ea8d017 docs: 查标评测试跑结果(开发集 S02、S04、S10)和发现
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 12:27:10 +08:00
YANG JIANKUAN
54534be0c1 fix(evals): 漏响应在所有表里都删;评分按句子重叠合并重复的标注单元
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 12:26:21 +08:00
YANG JIANKUAN
a07663cbdb feat(evals): 查标评测集生成器、注入、评分和只检索基线
- build.py:从「招标文件档案」和读标清单生成规矩投标文件稿;技术参数按 op / value / unit 算满足的值,
  分项报价按分项上限比例分摊,落款日期取截止日前 3 天,偏离表按格式表头决定列(含设备名称列),
  读标漏抽的技术参数也补上响应;routes 支持「+」组合和 prose 散文响应。
- mutate.py:按词库注入要求项问题(单点 / 连续块,带不带标志词)和规则问题,写响应清单;
  符合性条件响应表的问题按表级记一个。
- score.py:按单元去重、类型族比对、读标漏抽单列、端到端召回、无法对照单列。
- run_check.py:加只检索不调模型的基线;render.py:扫描件按最后一次出现定位。
- vocab-dev.json:开发集注入词库(留出集的另行封存,不进 git)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 12:18:48 +08:00
YANG JIANKUAN
01ddc86281 docs: 确认 C12(响应方式)和 E1(评测集复核方式)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 12:05:17 +08:00
YANG JIANKUAN
4ed945c1de docs(evals): 查标评测集规范 v0.2、留出集划分;渲染和离线查标脚本;新增待定事项 C12
- evals/check/GUIDELINES.md:请 Fable 审查 v0.1 后修改。响应方式按「文件 × 类别」记(逐条 / 默认接受 /
  证明材料(投标时、中标后)/ 文件要在 / 平台内应答),10 份招标文件逐份核对;规矩投标文件靠构造保证
  (表格脚本生成,LLM 只写散文和改写),对全部要求响应,问题只挂人工复核过的标注单元;注入分单点 /
  连续块 / 全局三种粒度、带不带标志词、两档密度;留出集注入词库和 C 风格说明开工时封存;评分补只检索
  基线、按单元去重、端到端召回。
- evals/check/split.json:开发集 S01 S02 S04 S05 S07 S08 S10,留出集 S03 S06 S09 + 真实国动投标文件。
- evals/check/render.py、run_check.py。
- m3-plan.md:新增待定事项 C12(每条要求怎么才算「响应了」)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 12:02:40 +08:00
YANG JIANKUAN
e499de7486 feat: 查标第 1 步——上传投标分册 → 检查 → 逐条处理 → 复查沿用
后端
- check 模块:检查预览、发起、进度、取消、记录;六个阶段(准备投标文件、响应匹配、规则、证据校验、
  状态沿用、汇总);问题列表、处理、撤销、批量处理(只限提示级)。
- 每条清单项的检查结论 check_item_result(C9,迁移 0005):「已响应」也带投标原文出处;
  没有校验通过的证据不能判已响应(C3)。级别从清单项算,不让模型定;说明不写整改建议。
- 响应匹配先只有第三层:内存 BM25 检索候选句 + check.match v1 提示词,检索不到不调模型,
  漏答记 missing_answer,模型临时错误只影响那一批。
- 计费:免费版不记流水;专业版 24 小时内复查免扣(记一行 0);expected_charge 不一致时拒绝不扣点;
  失败、取消退回;投标文件读不出文字按输入问题失败、不扣点。
- 投标分册:类型(volume_kind)和版本;电子投标平台加密格式在上传时拦下。
- 解析流水线前几个阶段拆到 document/pipeline/normalize.py,解析和检查共用。
- ops 模块:反馈接口;致命问题确认无误的说明自动进误报反馈(U4)。
- SK1:Valkey 客户端短超时、SSE 连接池上限。SK2:CI 加 Gotenberg、直接跑 init-roles.sh。

前端
- 上传投标文件并检查页、查标页(进度、覆盖范围、按级别筛选、逐条处理和行内说明、撤销、原文抽屉、
  已处理、本次已解决);项目头抽成公共组件,加「查标」标签页;工作台「下一步」覆盖查标阶段。

契约、文档:openapi.yaml 加 item-results、volume_kind、coverage 字段;data-model、api-design §10、
架构 §8 / §13(SK14)、AGENTS.md 同步。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 11:48:10 +08:00
YANG JIANKUAN
6ab42959df docs: 查标实现计划 v0.2,确认 C1–C11、SK1–SK2;参考项目查标深读
- m3-plan.md:请 Fable 当顾问深读参考项目的查标实现、对照读标教训复评后的计划。
  先建评测集再写规则(金标准是虚构投标文件的响应清单);每条清单项留带证据的结论;
  成本靠按章节打批、硬预算和复查复用控制;认不出、没查的都写进检查覆盖范围。
- decisions.md:C1–C11、SK1–SK2 全部按建议确认;用户的真实国动投标文件作为留出集,第 3 步冻结代码后再用。
- ref-bid-engine-analysis.md:新增 §6 查标深读;§5.4 的检索方案改为内存 BM25。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 11:48:10 +08:00
YANG JIANKUAN
ad34b0cb23 docs: 读标抽取现状、遗留问题和下一步;改正「三项目标全部达标」的说法
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled
- 新增 docs/architecture/extract-status.md:指标两种读法(报告值 / 只按人工标注)、
  已确认的进展、泛化风险(Fable 过拟合评估)、P0–P3 遗留问题、找样本和建留出集的流程
- extract-eval.md、AGENTS.md、架构 SK13、evals/README.md 改正达标说法并指向新文档
- 读标召回率优化暂停,后续专项优化从 extract-status.md 接着做

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 23:37:52 +08:00
YANG JIANKUAN
602703c47c docs(evals): S01–S10 标注集 v1、评测工具和评测报告
- evals/gold:1,887 个标注单元(AI 标注、逐条裁定,未经人工复核),规范见 GUIDELINES.md
- prepare / run_all / score / gold:导出文字层、批量抽取、打分、标注定位
- docs/architecture/extract-eval.md:修复前后的结果(三项目标全部达标)和剩余问题
- decisions.md 记 M1(只用 Flash)和 Q1–Q4;架构 §13 SK13;AGENTS.md 进度

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 23:19:30 +08:00
YANG JIANKUAN
f98fd51128 fix(server): 按 S01–S10 评测修复读标抽取(F1–F8、口径 Q1–Q4)
- 合并时待确认条目不再借正式条目进清单,没通过的类别记在 pending_categories
- 结构识别:章(按章跳过不相干的部分)、表后「上述……否则无效」说明、★ 节标题
- INVALID 补写法;输出用到上限 92% 按截断处理,拆到底时采用已有输出
- 格式范本正文、整表概括句不收;项目信息多看评标方法所在分块
- WPS 旧版 DOC 直接修正字体表字符集;表格按单元格切句(s3-v4)
- 口径:★ 节继承实质性、★ 定义为计分项时降为重要、「必须/应」声明记 keyword 规则、
  政府采购资格条件按 87 号令第六十三条带无效类别
- 提示词 substantive v5,qualification / schedule / tech_param v4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 23:19:30 +08:00
YANG JIANKUAN
f99bb8b358 feat(server): 参考 BidEngine 补五项:原因码、截断重跑、项目信息逐项表态、占位符、提示词规则对应校验
- 待确认记原因码(extract.FAILURES,存在 ai_values.failures),解析结果按原因计数
  (verify_failures);前端待确认条目显示具体原因
- 相邻分块重叠约 800 字;模型输出被截断或不是合法 JSON 时对半重跑(最多 3 层),
  拆到底仍不完整记 output_incomplete。修复:以前截断时整块条目被当成「没有结果」悄悄丢掉
- 项目基本信息提示词 v2:12 个字段逐项表态 found / ambiguous / not_found,跨分块合并为
  found / absent / needs_review(info_fields)
- 「____元」「××」「( )」「待填」等占位符不算取值
- 提示词 substantive v4,qualification / scoring / schedule / tech_param v3:每条禁止事项
  对应一个原因码,对照表在 prompts/README.md
- 顾问(Fable)深读参考项目的结论补进 ref-bid-engine-analysis.md §5;架构 §13 SK12
- 待确认:国动 4 → 2,S02 13 → 3,S06 57 → 55

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 21:59:12 +08:00
YANG JIANKUAN
e3f234ca0b feat: 读标可以走完一份文件;审计待确认区,修规则缺陷、提示词出新版本
读标操作(FR-M2-07、08)
- 后端:清单项详情(ETag)、修改(If-Match 选填,记 edited_fields 和 AI 原值)、
  采纳(补出处或声明「人工确认、无出处」,可带修改)、删除 / 丢弃与恢复、人工新增、
  替换出处、人工建出处(选同一页的句子)、修改记录(来自审计)、确认清单(不可变版本,
  内容没变复用上一版)、版本列表;正式清单有变化时已确认的清单回到草稿;项目已递交只读
- 重新解析不推翻人工决定:丢弃的不再冒出来,采纳的留在正式清单,人工补过的出处不被覆盖
- 前端:待确认区采纳 / 补上出处后采纳 / 修改后采纳 / 丢弃,正式清单修改、补出处、删除
  (可撤销)、修改记录、新增条目、确认清单;pdf.js 原文查看器(点出处跳页、按坐标框高亮、
  选句补出处),整屏对照布局,J / K 上下条;跨页的一句话合成一处出处显示

待确认审计(架构 §13 SK11)
- 规则只修真正的缺陷:表格框线不算下划线(文字层 v3);句号后的右括号并进同一句
  (切句 s3-v3);中文编号小节和冒号编号条目的上级条款,依据沿上级链核对;取值认中文
  数字、同一行单元格和评分因素标题;补几种无效写法;比对引文前去掉我们加进输入的标记
- 不放宽:引文最短 8 个字、编号必须对、证据全部通过
- 提示词新版本:substantive v3,qualification / scoring / schedule / tech_param v2;
  引文复制整句、并列多项不拆、跨页只引上一行,除实质性条款外输入改成纯文本
- BD_AI_MODELS 按提示词声明的能力选模型;离线评测工具 evals/run_extract.py
- 待确认:国动 46 → 4,S02 17 → 13,S06 174 → 57

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 21:32:15 +08:00
YANG JIANKUAN
a95e27a290 feat: 读标补齐五类清单与项目基本信息,修复国动样本暴露的 4 个问题
- 文字层 v2:页眉页脚带单独成句;写到页尾的句子和下一页首句接续(出处两页各一条)
- 结构识别(最小版):十进制编号、冒号引出的列表找上级条款;上级条款写明无效的,条目照样认定
- 抽取:资格、评分、日期与递交、技术参数、项目基本信息各一份提示词;实质性条款出 v2
  (认上级条款、补「做无效响应处理」等写法);各类取值核对;按首句合并;
  拆开被合成一条的列举条款;描述标注方式的句子和表头碎片不当条款;* 号标注
- 项目:解析出的名称、编号、采购人、预算、截止时间、分包写回项目,用户改过的不覆盖
- 前端:清单项显示分值、参数要求、日期、证明材料
- 测试 40 个,新增两页虚构样例

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 19:32:25 +08:00
YANG JIANKUAN
ee4bb23488 feat(server): 分块并发调用模型,配置可从仓库根目录的 .env 读取
- extract:分块并发调用(每个任务最多 4 路),结果按分块顺序合并
- config:在 server/ 下运行时也读取仓库根目录的 .env(和 Compose 的 env_file 一致)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 18:55:59 +08:00
YANG JIANKUAN
be62440531 docs: 工程骨架落地记录(架构 §13)与 AGENTS.md 工程一节
- 架构 §13:SK1–SK8,SK1(事件流用 Valkey stream)、SK2(测试不用 testcontainers)待确认
- api-design §10:接口实现进度
- openapi.yaml:ChecklistItem 的必填字段在同一层声明,生成的类型不再是可选
- AGENTS.md:目录结构、常用命令、编码约定、当前进度

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 18:48:10 +08:00
YANG JIANKUAN
6aaf8e768b feat(web): 用户端骨架——登录、工作台、新建项目、解析进度、读标清单
- packages/api-client:从 openapi.yaml 生成类型;cookie + CSRF / Bearer 两种鉴权、
  分片上传、fetch 读事件流(API2)
- apps/web:Vue 3 + Vite + TanStack Query,样式只来自 tokens.css / components.css(AR10);
  扣点说明放在「开始解析」旁边(红线 9);接口地址、路由方式运行时可配(架构 §5.2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 18:48:10 +08:00
YANG JIANKUAN
ad7fba6021 feat(server): 工程骨架后端——上传 → 解析 → 清单最小链路
- core:配置(prod 拒绝默认密钥 / dev 短信 / 假模型)、租户事务 + 行级安全策略、
  problem+json 错误码、信封加密与签名下载、对象存储、事务内入队、Valkey stream 事件流、权限点
- identity:手机号 HMAC 存储、注册、登录(挤下线需确认,API3)、服务端会话
- billing:额度流水只追加,预扣 / 结算 / 退回,一笔预扣只有一个结局
- document:分片上传、按文件头识别格式、Gotenberg 转换、文字层与稳定句子编号
- checklist:标注规则识别、模型抽取、三道校验、标注类条款规则兜底、重跑对齐
- ai:提示词注册表(extract.substantive v1 来自 S4 v2)、假模型、OpenAI 兼容供应商、ai_call 流水
- 迁移:data-model 基线、套餐、Procrastinate、会话状态函数
- 测试 30 个:租户隔离、额度、失败退回、对象加密、契约一致性
- Compose(含 migrate)、镜像、CI

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 18:48:10 +08:00
YANG JIANKUAN
5747a523bb docs(architecture): 接口契约 v0.1,确认 API1–API5
- openapi.yaml:用户端 MVP 主流程的 OpenAPI 3.1 契约(64 个路径、81 个操作)
- api-design.md:约定、鉴权、页面与接口对应、扣点预览、任务与事件、上传、错误码
- data-model.md:清单项 spec 改为按类别分组

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:44:28 +08:00
YANG JIANKUAN
0991d0237c docs(architecture): 物理数据模型 v0.1,确认 DM1–DM7
- data-model.md:约定、租户隔离落地、按模块的表清单、额度流水语义、
  清单项与检查的建模、删除流程、对象存储布局
- schema-draft.sql:40 张表的建表草案(PostgreSQL 16+)
- schema-draft-test.sql:租户隔离、额度流水、审计、删除等 16 项行为测试
- 回写架构 §3.2/§3.11/§9/§12、05 §3、FR-M2-05、FR-M3-03

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:34:43 +08:00
YANG JIANKUAN
b38fcb856a docs(design): VI 手册报告页脚去掉「保证」字样并重出 PDF
与设计规范 §7.17、FR-M3-09 的免责声明口径保持一致。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:21:28 +08:00
YANG JIANKUAN
47242ba9d7 docs(architecture): v0.3 为多人、多登录方式预留表结构(AR11、AR12)
团队版、企业版、飞书 / 企业微信登录都不在早期版本,但 MVP 的表结构先按
多人、多登录方式建,以后接入不用重构:
- 身份拆成人 / 登录方式 / 成员身份三层,一人一企业在服务层限制
- 项目成员表、统一的权限检查点和可见范围过滤先建好
- 记录「谁」的字段指向人,人不做物理删除;订阅带席位数

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:07:15 +08:00
YANG JIANKUAN
067cad2120 docs(design): UI 风格定稿(U5),设计规范与 tokens 升到 v0.2
- ui-demo/explore/:工作台风格的六轮方案(A–F、D2–D5),D5 为定稿样张
- tokens.css v0.2:墨色派生的中性浅灰作页面底色;圆角三档(6 / 8 / 12);控件高 36;侧栏渐变与套餐标签变量;旧变量保留兼容
- design-system.md v0.2:页面框架(96px 墨色渐变侧栏、透明顶栏、内容区圆角)、只用功能图标、项目行「下一步 / 更多」列、少写说明
- decisions.md 记录 U5;AGENTS.md 与 design/README 同步

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:35:08 +08:00
YANG JIANKUAN
604e889ca4 docs(architecture): 完成技术验证 S1–S4,确定 FR-M2-05/06 目标值
- spikes/:Word 转 PDF(Gotenberg)、格式识别、原句坐标、DeepSeek 抽取四项验证代码与两版提示词;样本派生产物和 .env 不进 git
- tech-validation.md:完整结论。四项都可行:WPS 字体字符集要修正并改用行高调整过的字体;格式识别不需要 Docling;表格内切句待改进;抽取需第三道依据校验与规则兜底
- architecture.md:§3.5 加预处理与字体包,§3.6 证据校验改为三道,§10 回填结论
- 03 功能需求与 decisions.md(TV1):召回 ≥ 99%、采纳准确率 ≥ 95%、页码 100%、高亮正文 ≥ 98% / 表格 ≥ 95%

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 12:46:49 +08:00
YANG JIANKUAN
e1a9017cd1 docs(design): 新增 UI demo 页面,供 UI 精修评审
- docs/design/ui-demo/:控件总览、表单、卡片、列表与表格,以及工作台、读标两个组合页
- 只用设计规范的变量和组件;新增组件样式放在 demo.css,清单见 index
- index 列出 UI 精修待定问题 Q1–Q12
- design/README 与 AGENTS.md 进度同步

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 12:46:27 +08:00
YANG JIANKUAN
94c3bfd688 docs(architecture): 新增技术架构设计 v0.2,记录选型决策 T2–T5、AR1–AR10
- 架构文档:模块化单体 + 按负载拆进程;后端 Python(FastAPI),前端 Vue 3 + Vite 单页应用;PostgreSQL 做权威数据和任务队列(Procrastinate),Valkey 只存可丢数据;S3 / 本地磁盘存储;Compose 与 Helm 共用镜像;桌面端为客户端外壳
- 文档处理组件参考标擎的做法(§3.13):转换服务、Docling、标书组装与两遍导出
- UI 不引入带样式框架,视觉自写,复杂交互用 Reka UI 打底(§4.3)
- decisions.md 记录 T2–T5、AR1–AR10;AGENTS.md 同步技术约束与进度

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 11:56:19 +08:00
YANG JIANKUAN
efbcf6357b docs(architecture): 新增参考项目标擎 BidEngine 分析
从技术架构、提示词工程、大模型路由与业务设计三个方向,整理可参考的做法和需要规避的问题,并给出对标博士技术架构阶段的落地建议。只记录做法层面的结论,不摘录对方代码与提示词。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 11:13:17 +08:00
YANG JIANKUAN
b2c1385ec5 chore: 忽略 opensource-ref 参考项目并说明使用边界
- .gitignore 排除 opensource-ref/(第三方源码,PolyForm Noncommercial 许可)
- AGENTS.md 新增「参考开源项目」一节:不参与编码与逻辑,只在用户要求时阅读

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 11:13:17 +08:00
399 changed files with 97985 additions and 1265 deletions

12
.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
**/.venv
**/node_modules
**/__pycache__
**/.data
.git
docs
samples
spikes
opensource-ref
apps
packages
.env

31
.env.example Normal file
View File

@@ -0,0 +1,31 @@
# 复制为 .env 后按需修改。所有配置都是 BD_ 前缀的环境变量(server/src/bid_doctor/core/config.py)。
# 本地开发用默认值即可,不需要 .env;下面列的是常改的几项。
# BD_ENV=dev # dev / test / prod;prod 下检测到默认密钥、dev 短信、假模型会拒绝启动
# BD_DATABASE_URL=postgresql+psycopg://bd_app:dev-app@127.0.0.1:54329/bid_doctor
# BD_MIGRATE_DATABASE_URL=postgresql+psycopg://bd_owner:dev-owner@127.0.0.1:54329/bid_doctor
# BD_VALKEY_URL=redis://127.0.0.1:63799/0
# BD_GOTENBERG_URL=http://127.0.0.1:3109
# BD_STORAGE_DIR=.data/objects
# 密钥:生产环境必须换成 32 字节以上的随机串(openssl rand -base64 48)
# BD_SECRET_KEY=
# BD_PHONE_PEPPER=
# BD_MASTER_KEY=
# 模型(T1):默认用确定性的假模型;要接国产模型时改成 openai_compat
# BD_AI_PROVIDER=openai_compat
# BD_AI_BASE_URL=https://api.deepseek.com/v1
# BD_AI_API_KEY=
# BD_AI_MODEL=deepseek-chat
# 按能力选模型(选填,JSON):没配的能力用 BD_AI_MODEL
# BD_AI_MODELS={"reasoning": "deepseek-reasoner"}
# 专业版对公转账的收款信息(决策 M6-6):开通申请里给用户看;没配时显示「(待定)」,prod 下必须配
# BD_PAYEE_NAME=
# BD_PAYEE_BANK=
# BD_PAYEE_ACCOUNT=
# 运营后台(决策 M6-2、M6-4):bid-doctor ops 进程用 bd_ops 做跨企业只读;只放行白名单地址(CIDR,JSON 数组)
# BD_OPS_DATABASE_URL=postgresql+psycopg://bd_ops:dev-ops@127.0.0.1:54329/bid_doctor
# BD_OPS_ALLOWED_IPS=["127.0.0.1/32", "10.0.0.0/8"]

83
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,83 @@
# 合并的前提(架构 §8):类型检查、单元测试、租户隔离用例都通过;接口契约校验通过。
name: ci
on:
push:
branches: [main]
pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
server:
runs-on: ubuntu-24.04
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: bd_owner
POSTGRES_PASSWORD: dev-owner
POSTGRES_DB: bid_doctor
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U bd_owner" --health-interval 2s --health-retries 30
valkey:
image: valkey/valkey:8-alpine
ports: ["6379:6379"]
gotenberg: # 投标样例和招标样例都可能是 Word,转换要走 Gotenberg(SK2)
image: gotenberg/gotenberg:8
ports: ["3109:3000"]
env:
BD_TEST_PG: 127.0.0.1:5432
BD_TEST_VALKEY: redis://127.0.0.1:6379/1
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with:
version: "0.12.20"
- run: uv sync --frozen
- name: 数据库角色(直接跑部署用的脚本,两边不会漂移,SK2)
env:
PGHOST: 127.0.0.1
PGPASSWORD: dev-owner
POSTGRES_USER: bd_owner
POSTGRES_DB: bid_doctor
BD_APP_PASSWORD: dev-app
BD_OPS_PASSWORD: dev-ops
run: sh deploy/compose/postgres/init-roles.sh
- working-directory: server
run: |
../.venv/bin/ruff check src tests migrations
../.venv/bin/ruff format --check src tests migrations
../.venv/bin/pyright
../.venv/bin/lint-imports
../.venv/bin/pytest -q
web:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile --ignore-scripts
- name: 接口类型要和 openapi.yaml 一致
run: |
pnpm gen:api
git diff --exit-code packages/api-client/src/schema.d.ts
- run: pnpm typecheck
- run: pnpm build
contract:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: OpenAPI 完整校验(api-design §8)
run: npx --yes @redocly/cli@1.34.0 lint docs/architecture/openapi.yaml --skip-rule operation-4xx-response --skip-rule no-server-example.com

19
.gitignore vendored
View File

@@ -28,3 +28,22 @@ __pycache__/
# VI 手册 iPhone 桌面模拟用的 Apple 素材(版权归 Apple,用 docs/design/vi/mockup/fetch.sh 获取) # VI 手册 iPhone 桌面模拟用的 Apple 素材(版权归 Apple,用 docs/design/vi/mockup/fetch.sh 获取)
docs/design/vi/mockup/apple/ docs/design/vi/mockup/apple/
# 参考开源项目(只供人工参考,不参与本项目,见 AGENTS.md「参考开源项目」)
opensource-ref/
# 技术验证的产物(由样本派生,样本不转载)
spikes/**/out/
spikes/.venv/
spikes/**/.env
# 工程骨架:本地环境与产物
.venv/
**/.data/
dist/
*.tsbuildinfo
.pytest_cache/
.ruff_cache/
# 评测输出(含真实招标文件内容)
evals/out/

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.13

129
AGENTS.md
View File

@@ -7,7 +7,7 @@
- **产品名**:标博士(代码仓库名 `bid-doctor`) - **产品名**:标博士(代码仓库名 `bid-doctor`)
- **一句话定位**:站在**投标方**一侧,贯穿「寻标 → 读标 → 写标 → 查标 → 投标」全生命周期的 AI 辅助工具。产品是「副驾驶」,不是「自动驾驶」。 - **一句话定位**:站在**投标方**一侧,贯穿「寻标 → 读标 → 写标 → 查标 → 投标」全生命周期的 AI 辅助工具。产品是「副驾驶」,不是「自动驾驶」。
- **口号**:标博士,你的AI投标专家(固定写法,见决策 B5)。 - **口号**:标博士,你的AI投标专家(固定写法,见决策 B5)。
- **当前阶段**:需求分析、品牌与 VI 已完成,产品界面设计规范 v0.1、MVP 可点击原型 v0.1 已完成,尚无代码。工作流:需求分析 ✅ → 主题 / 品牌设计 ✅ → 产品界面设计规范 ✅ → 原型设计 ✅ → UI 设计 → 技术架构 → 实现 → 测试。 - **当前阶段**:需求分析、品牌与 VI、产品界面设计规范 v0.2、MVP 原型 v0.2、技术架构 v0.3(含物理数据模型、接口契约)已完成;工程骨架已搭好,进入实现阶段。工作流:需求分析 ✅ → 主题 / 品牌设计 ✅ → 产品界面设计规范 ✅ → 原型设计 ✅ → UI 设计 ✅ → 技术架构 ✅ → 实现(进行中)→ 测试。
- **需求来源**:当前有效版本是 `docs/招投标AI工具PRD-v0.9.1.html`(`docs/招投标AI工具PRD.html` 是 v0.9 原稿,保留作对照),再加上 `docs/decisions.md` 和 `docs/analysis/` 里已确认的 MVP 细化需求。三者冲突时:decisions.md > analysis > PRD。发现矛盾或缺失时,记录问题并向用户确认,不要自行假设。 - **需求来源**:当前有效版本是 `docs/招投标AI工具PRD-v0.9.1.html`(`docs/招投标AI工具PRD.html` 是 v0.9 原稿,保留作对照),再加上 `docs/decisions.md` 和 `docs/analysis/` 里已确认的 MVP 细化需求。三者冲突时:decisions.md > analysis > PRD。发现矛盾或缺失时,记录问题并向用户确认,不要自行假设。
- **工作语言**:文档、界面文案、提交说明默认使用简体中文;代码标识符、文件名使用英文。 - **工作语言**:文档、界面文案、提交说明默认使用简体中文;代码标识符、文件名使用英文。
@@ -67,13 +67,67 @@
## 技术约束(已定) ## 技术约束(已定)
- **大模型**(T1):只用国内云端的国产大模型,数据不出境。按场景路由:多模态(扫描件、签章)/ 快速 / 深度思考。业务代码只声明需要什么能力,不直接写死供应商和模型;每个 AI 任务都要记下模型和提示词版本。 - **大模型**(T1):只用国内云端的国产大模型,数据不出境。按场景路由:多模态(扫描件、签章)/ 快速 / 深度思考。业务代码只声明需要什么能力,不直接写死供应商和模型;每个 AI 任务都要记下模型和提示词版本。
- 其余技术栈尚未选定,由「技术架构设计」阶段决定并回填到本文件。 - **后端语言**(T2):Python 单语言,业务接口和后台任务是同一套代码、两种进程。
- **桌面客户端**(T3):连服务端的客户端外壳,界面和网页版同一套;前端从第一天起守住 `docs/architecture/architecture.md` §5.2 的约束。
- **部署**(T4):同一批镜像,两种编排(Docker Compose / Helm);SaaS 内测期用 Compose,商用后切到托管 K8s。
- **云厂商**(T5):暂不定,只依赖 PostgreSQL、S3 兼容的对象存储、OpenAI 兼容的模型接口。
- **已确认的选型**(AR1–AR3、AR5–AR9):模块化单体;前端用 Vue 3 + TypeScript + Vite 单页应用;数据库 PostgreSQL;文件存储用 S3 协议 + 本地磁盘驱动;单一仓库。详见 `docs/architecture/architecture.md` 第 11 节。
- **Valkey**(AR4):只存丢了也没关系的数据(限速、验证码、推送、缓存);权威数据(含会话、任务队列)都在 PostgreSQL。
- **UI 组件**(AR10):不引入带样式的 UI 框架(如 Element Plus),视觉自己写,样式只来自 `tokens.css`;复杂交互用无样式组件库 Reka UI 打底;不用 Tailwind。
- 构建、运行、测试命令和代码目录见下方「工程」一节。
## 工程
骨架见技术架构 §13。后端 Python 3.12+(uv workspace),前端 Vue 3 + Vite(pnpm workspace)。
```
server/ 后端:api、worker、ops 是同一个包、三种进程(bid-doctor api | worker | ops | migrate | ops-admin)
src/bid_doctor/
core/ 配置、数据库与租户事务、错误码、加密、对象存储、队列、事件流、权限点
identity/ billing/ project/ document/ checklist/ ai/ audit/ notification/ 业务模块(架构 §3.2)
app.py worker.py cli.py
migrations/ Alembic;sql/0001_baseline/ 是 data-model 的建表脚本,按模块拆分
tests/ 集成测试(真实 PostgreSQL + 行级安全策略)和单元测试;fixtures/ 是虚构的小样例
apps/web/ 用户端单页应用
apps/admin/ 运营后台(单独构建、单独部署,私有化不交付;接口见 docs/architecture/ops-api.md)
packages/api-client/ 由 docs/architecture/openapi.yaml 生成的类型 + 客户端(不手写接口类型)
prompts/ 带版本号的提示词(已发布的版本不再修改)
deploy/compose/ Docker Compose;deploy/docker/ 镜像
```
常用命令(都在仓库根目录执行):
| 做什么 | 命令 |
|---|---|
| 装依赖 | `uv sync` 和 `pnpm install --ignore-scripts` |
| 起依赖(PostgreSQL、Valkey、Gotenberg) | `docker compose -f deploy/compose/compose.yaml up -d postgres valkey gotenberg` |
| 建表 / 升级数据库 | `cd server && ../.venv/bin/bid-doctor migrate` |
| 起后端 | `cd server && ../.venv/bin/bid-doctor api`(:8009),另开一个终端 `../.venv/bin/bid-doctor worker` |
| 起前端 | `pnpm dev`(:5173,`/api` 代理到 :8009)。dev 环境短信验证码固定是 `000000` |
| 起运营后台 | `cd server && ../.venv/bin/bid-doctor ops`(:8019,只放行白名单地址),`pnpm dev:admin`(:5174);运营账号 `../.venv/bin/bid-doctor ops-admin create --username <名> --display-name <姓名>`,打印的 otpauth 地址用身份验证器 App 添加 |
| 后端检查 | `cd server && ../.venv/bin/ruff check src tests migrations && ../.venv/bin/ruff format --check src tests migrations && ../.venv/bin/pyright && ../.venv/bin/lint-imports && ../.venv/bin/pytest -q` |
| 前端检查 | `pnpm typecheck && pnpm build` |
| 改了 openapi.yaml 之后 | `pnpm gen:api`,重新生成的 `schema.d.ts` 一起提交(CI 会比对) |
| 全部用容器跑 | `docker compose -f deploy/compose/compose.yaml --profile app up -d --build` |
约定:
- 配置只走 `BD_` 前缀的环境变量(`.env.example`);`BD_ENV=prod` 时检测到默认密钥、dev 短信、假模型会拒绝启动。
- 模型默认用确定性的假模型(`BD_AI_PROVIDER=fake`),测试和 CI 不调用真实模型。
- 数据访问都在 `tenant_tx(ctx)` 里;表结构以迁移为准,ORM 模型不声明外键,写入有先后时要自己 `flush`。
- 测试样例只能用虚构内容:真实招标文件样本不进 git(`samples/tenders/README.md`)。
## 品牌与设计资产(已定) ## 品牌与设计资产(已定)
设计文档索引见 `docs/design/README.md`。做界面、文案、物料时先看这几条: 设计文档索引见 `docs/design/README.md`。做界面、文案、物料时先看这几条:
- **视觉方向**:A「校样」。蓝铅笔 #2F5BD3 = AI 标注 / 出处,墨 #1D2733 = 正文 / 人工确认,校样纸 #F5F6F3 = 底色。 - **视觉方向**:A「校样」。蓝铅笔 #2F5BD3 = AI 标注 / 出处;墨 #1D2733 = 正文 / 人工确认。
- **界面风格**(U5,设计规范 v0.2):
- 定稿样张是 `docs/design/ui-demo/explore/workbench-d5.html`。
- 页面白色主调,浅灰从墨色派生(页面底 #F8F8F9);校样纸 #F5F6F3 只留在品牌色板里,不再作页面底色。
- 墨色渐变窄侧栏(96px,不折叠)。
- 圆角三档:标签 6、控件 8、卡片 12。
- 只用功能图标,少写说明。
- **两种红色不能混用**:印章红 #D9291C **只用于 logo**;致命红 #C4122F **只用于界面里的致命问题**。 - **两种红色不能混用**:印章红 #D9291C **只用于 logo**;致命红 #C4122F **只用于界面里的致命问题**。
- **界面规范**:`docs/design/design-system.md`;写界面代码时颜色、字号、间距只用 `docs/design/system/tokens.css` 里的变量。 - **界面规范**:`docs/design/design-system.md`;写界面代码时颜色、字号、间距只用 `docs/design/system/tokens.css` 里的变量。
- **字体**(均为 OFL,可免费商用):思源宋体(标题、字标)、思源黑体 / 系统黑体(界面正文)、Libre Caslon(英文品牌字)、Barlow Semi Condensed(数字)。 - **字体**(均为 OFL,可免费商用):思源宋体(标题、字标)、思源黑体 / 系统黑体(界面正文)、Libre Caslon(英文品牌字)、Barlow Semi Condensed(数字)。
@@ -102,14 +156,22 @@ samples/
tenders/ # 真实招标文件样本(README 是清单;原始文件不进 git,用 fetch.sh 获取) tenders/ # 真实招标文件样本(README 是清单;原始文件不进 git,用 fetch.sh 获取)
``` ```
以上目录按阶段推进时再创建,不要提前建空目录。代码目录结构在技术架构确定后补充到本文件。 以上目录按阶段推进时再创建,不要提前建空目录。代码目录见「工程」一节。
## 参考开源项目(opensource-ref/)
`opensource-ref/` 放的是别人的开源项目,只供用户本人对照参考,**不属于本项目**,已在 `.gitignore` 中忽略。
- `opensource-ref/bid-engine/`:标擎 BidEngine(github.com/panviviplus/bid-engine),招投标 AI 工作台,Go + Next.js。
- **不参与本项目的编码、文件查找和逻辑**:搜索代码、排查问题、设计架构、写需求时都排除这个目录;不要引用、复制或改写其中的代码、提示词、SQL 和文案,也不要把它的设计当作本项目的既定做法。用户明确要求「看一下参考项目」时才去读,读到的内容只作为讨论素材,结论仍以本项目的 decisions.md / analysis / PRD 为准。
- **许可证**:PolyForm Noncommercial 1.0.0,只允许非商业用途;本产品是商业产品,其代码不能进入本项目。
- 已清理掉其中与本项目无关的工程化文件(嵌套的 .git、.gitignore、.gitkeep、.DS_Store、它自带的 AGENTS.md、ESLint / Prettier 配置、第三方库的 CI 配置);保留了运行它所需的 .env*、.npmrc、Dockerfile、docker-compose 和启动脚本,以及 LICENSE / NOTICE。
## 工作方式 ## 工作方式
- 每个阶段产出落到 `docs/` 对应目录,并在本文件「当前进度」中更新一行。 - 每个阶段产出落到 `docs/` 对应目录,并在本文件「当前进度」中更新一行。
- 用户确认的决策写入 `docs/decisions.md`(日期 + 决策 + 理由),不要只留在对话里。 - 用户确认的决策写入 `docs/decisions.md`(日期 + 决策 + 理由),不要只留在对话里。
- 设计与文案产出提交前,对照上方「产品红线」自查一遍。 - 设计与文案产出提交前,对照上方「产品红线」自查一遍。
- 技术栈确定后,在本文件补充:构建 / 运行 / 测试命令、代码规范、目录结构。
## 当前进度 ## 当前进度
@@ -124,4 +186,59 @@ samples/
- 2026-11-02:产品界面设计规范 v0.1(`docs/design/design-system.md` + `system/tokens.css` + `system/components.html`)。要点:红色只给致命问题(表单错误、额度、截止临近都用赭色);问题级别靠实心 / 描边 / 无框区分,不只靠颜色(色弱模拟验证过);字体全部自托管;出处是固定组件。U1–U4 全部按建议确认(只做桌面 Web、图标用 Lucide、致命问题确认无误也要写说明)。 - 2026-11-02:产品界面设计规范 v0.1(`docs/design/design-system.md` + `system/tokens.css` + `system/components.html`)。要点:红色只给致命问题(表单错误、额度、截止临近都用赭色);问题级别靠实心 / 描边 / 无框区分,不只靠颜色(色弱模拟验证过);字体全部自托管;出处是固定组件。U1–U4 全部按建议确认(只做桌面 Web、图标用 Lucide、致命问题确认无误也要写说明)。
- 2026-11-02:MVP 可点击原型 v0.1(`docs/prototype/`,13 个页面,覆盖 J1–J5)。中保真,直接用设计规范的组件;查标页可逐条处理、键盘操作、看原文。原型阶段的 P1(24 小时免扣从上一次扣点的检查算起)、P2(人工核对勾选按项目保存)已按建议确认。 - 2026-11-02:MVP 可点击原型 v0.1(`docs/prototype/`,13 个页面,覆盖 J1–J5)。中保真,直接用设计规范的组件;查标页可逐条处理、键盘操作、看原文。原型阶段的 P1(24 小时免扣从上一次扣点的检查算起)、P2(人工核对勾选按项目保存)已按建议确认。
- 2026-11-03:应用图标改为印泥铺满、不留白边,圆角按 Apple 连续曲率圆角由系统裁(B6);VI 手册 v1.1 第 22 页改为 iPhone 18 Pro 主屏幕模拟(`docs/design/vi/mockup/`,Apple 素材不进 git,机身需本人同意 Apple Design Resources 许可后放入)。 - 2026-11-03:应用图标改为印泥铺满、不留白边,圆角按 Apple 连续曲率圆角由系统裁(B6);VI 手册 v1.1 第 22 页改为 iPhone 18 Pro 主屏幕模拟(`docs/design/vi/mockup/`,Apple 素材不进 git,机身需本人同意 Apple Design Resources 许可后放入)。
- 下一步:用原型找种子用户走查(顺带问 G15 苏采云客户端的分册对应),再进入 UI 设计精修。 - 2026-11-03:分析参考项目标擎 BidEngine 的架构、提示词和模型路由,整理出可参考的做法和避坑清单(`docs/architecture/ref-bid-engine-analysis.md`),留给技术架构阶段用。
- 2026-11-03:技术架构设计 v0.2(`docs/architecture/architecture.md`)。T2–T5 已确认:Python 单语言、桌面端做客户端外壳、SaaS 先 Compose 后 K8s、云无关。之后确认了 AR1–AR3、AR5–AR9,前端改为 Vue 3;AR4 复议后改为引入 Valkey(已确认);AR10 定为不引入带样式的 UI 框架,用 Reka UI 打底。选型全部确认。编码前先做技术验证 S1–S4。
- 2026-11-03:种子用户走查结束,没有问题。进入 UI 精修前先做 UI demo(`docs/design/ui-demo/`,6 个页面):控件、表单、卡片、列表,以及工作台、读标两个组合页;新增组件清单和待定问题 Q1–Q12 见其 index。
- 2026-11-03:技术验证 S1–S4 完成(`docs/architecture/tech-validation.md`,代码在 `spikes/`),四项都可行。要点:Word 转 PDF 要修正 WPS 字体字符集并改用行高调整过的字体;格式识别不需要 Docling;表格内切句要改进;DeepSeek 抽取要加第三道依据校验和规则兜底,单项抽取每份约 0.15 元。FR-M2-05 和 FR-M2-06 的目标值按建议确认(TV1)。
- 2026-11-03:UI 风格定稿(U5)。工作台经过六轮方案(`docs/design/ui-demo/explore/`),定为 D5:白色主调、墨色渐变窄侧栏、透明顶栏、圆角三档、只用功能图标、少写说明。设计规范和 `tokens.css` 升到 v0.2。
- 2026-11-03:UI demo 全部按 v0.2 重刷(控件、表单、卡片、列表、工作台、读标)。Q1–Q12 全部按建议确认,写入设计规范;各页的共用问题收回到 `components.css`、`demo.css`、`demo.js`。Q13(流水记 0 点的「免扣」行)、U 键(撤销当前条)按建议确认。
- 2026-11-03:MVP 原型 13 页全部按 v0.2 重刷:框架、项目头和标签页、对照布局、问题处理交互与 UI demo 一致,业务流程和红线内容不变。报告页脚与免责声明改为「本报告为辅助检查结果,仅供投标前自查参考」,规范 §7.17 和 FR-M3-09 同步,消除了「保证」字样。
- 2026-11-03:技术架构升到 v0.3,新增 §3.14。团队版、企业版、飞书 / 企业微信登录都是以后的版本,早期不做;MVP 的表结构先按多人、多登录方式建:人 / 登录方式 / 成员身份分三层,项目成员表和统一的权限检查点先建好,以后接入不用重构(AR11、AR12)。
- 2026-11-03:VI 手册第 20 页报告页脚去掉「保证」字样,改为与设计规范 §7.17 一致的「辅助检查结果,仅供投标前自查参考」,PDF 已重出。
- 2026-11-03:物理数据模型 v0.1(`docs/architecture/data-model.md` + `schema-draft.sql`,40 张表,PostgreSQL 16 上 16 项行为测试通过)。DM1–DM7 全部按建议确认:句子放对象存储不进库、外键都带租户、每个项目一把数据密钥、手机号不存明文、规则问题可以只挂规则编号、清单新增「报价」类别。
- 2026-11-03:接口契约 v0.1(`docs/architecture/api-design.md` + `openapi.yaml`,OpenAPI 3.1,64 个路径、81 个操作),覆盖 MVP 原型 13 页的主流程,运营后台另行定义。API1–API5 全部按建议确认:骨架搭好前以 `openapi.yaml` 为准,之后以 FastAPI 生成的快照为准;事件流用 fetch 读;异地登录确认后挤掉旧设备;扣点说明和实际不一致时拒绝、不扣点;报告后台生成。
- 2026-11-03:工程骨架完成(分支 `feat/engineering-skeleton`)。「上传 → 解析 → 显示清单」最小链路在真实样本 S02、S06 和虚构样例上跑通:分片上传、按文件头识别格式、Gotenberg 转 PDF、文字层与稳定句子编号、实质性条款抽取(假模型 + 三道校验 + 标注类规则兜底)、并入清单;额度预扣 / 结算 / 退回、行级安全策略、对象加密都有测试。后端 30 个测试、pyright 严格模式、import 规则全部通过;前端 5 个页面按设计规范 v0.2 实现,类型检查和构建通过。搭建中的调整记在架构 §13(SK1–SK8),SK1、SK2 待确认。
- 2026-11-03:测试环境接上真实模型(DeepSeek,配置在不进 git 的根目录 `.env`)。读标补齐五类清单和项目基本信息的抽取,修了国动样本暴露的 4 个问题(标注规则碎片、页码混入与跨页拆分、无效写法漏认、上级标题下的无效情形),见架构 §13 SK9、SK10。
- 2026-11-03:读标可以走完一份文件:待确认区采纳 / 补上出处后采纳 / 修改后采纳 / 丢弃,正式清单修改、补出处、删除恢复、人工新增、修改记录,确认清单生成不可变版本;右侧 pdf.js 原文查看器点出处跳页、按坐标框高亮原句,选句补出处。实现约定见 `docs/architecture/api-design.md` §10。
- 2026-11-03:审计待确认区:多数是好结果被校验误拦,根源在输入(表格框线被认成下划线)、提示词(要求逐项拆开)和模型没守规则。规则只修真正的缺陷,不迁就模型的错误输出;提示词出新版本(引文复制整句、并列不拆、跨页只引上一行,除实质性条款外输入改成纯文本)。国动的待确认 46 → 4,S02 17 → 13,S06 174 → 57。见架构 §13 SK11;离线评测工具 `evals/run_extract.py`。
- 2026-11-03:请 Fable 做顾问深读参考项目的解析流水线(`docs/architecture/ref-bid-engine-analysis.md` §5),采纳五项:待确认记原因码、分块重叠和截断后对半重跑、项目基本信息逐项表态、占位符不算值、提示词规则对应原因码。发现并修复了模型输出被截断时整块条目悄悄丢失的问题。见架构 §13 SK12。
- 2026-11-03:抽取模型定为只用 DeepSeek Flash,不再对比 Pro 和思考模式,效果靠规则和提示词优化(`docs/decisions.md` M1)。
- 2026-11-03:S01–S10 标注集评测 v1(`docs/architecture/extract-eval.md`,工具和标注集在 `evals/`)。1,829 个标注单元由 AI 标注、逐条裁定,未经人工复核。标注类实质性条款召回 99.7% 达标;写明无效类召回 85.4%、实质性条款准确率 87.8%、资格条件准确率 38.4% 不达标,主因是规则缺陷(合并时待确认条目被并进正式条目、表后「上述……否则无效」说明认不出、模型提前收尾的静默丢失),修复清单 F1–F8 见该文 §4。每份文件实测约 1.4 元。还发现产品转换流程缺 WPS 旧版 DOC 预处理(S07 正文全是乱字)。
- 2026-11-03:按评测修复 F1–F8,口径 Q1–Q4 按建议确认(`docs/decisions.md`);请 Fable 当顾问审读修复,改了它指出的 7 个问题。修掉了几个确定的缺陷(合并时假致命、输出临近上限的静默丢失、WPS 旧版 DOC 乱码、表格串行);报告分数是在同一批样本上调、又用同一批评的,请 Fable 评估后确认有过拟合:只按人工标注算,实质性条款准确率 72%、标注类召回 98.9%,泛化未测。
- 2026-11-03:读标召回率优化暂停。现状、遗留问题(含泛化风险)和下一步整理在 `docs/architecture/extract-status.md`;后续专项优化先建留出集(多省份、招标投标体系、扫描件)并人工标注。
- 2026-11-04:查标(M3)实现计划 v0.2(`docs/architecture/m3-plan.md`)。请 Fable 做顾问深读参考项目的查标实现(`ref-bid-engine-analysis.md` §6),对照读标教训复评;C1–C11、SK1、SK2 全部按建议确认(`docs/decisions.md`)。要点:先建评测集再写规则,金标准是虚构投标文件的响应清单;每条清单项都留带证据的结论(新增 `check_item_result`);成本靠按章节打批、硬预算和复查复用结果控制;认不出、没查的都写进检查覆盖范围。用户的真实国动投标文件留到第 3 步冻结代码后再用。
- 2026-11-04:查标第 1 步(链路打通)完成:上传投标分册(类型、版本、加密格式识别)→ 检查(六个阶段,响应匹配先只有检索 + 模型判断,提示词 `check.match` v1)→ 每条清单项留带证据的结论 → 生成问题 → 逐条处理(致命确认无误要写说明并进误报反馈)→ 复查沿用状态;计费三种方式和失败退回都有测试,后端 80 个测试通过。前端加了上传投标文件并检查页和查标页。SK1、SK2 的补丁已做。真实模型在虚构样例上走通了链路,效果还没评。见 `docs/architecture/m3-plan.md` 进度一节。
- 2026-11-04:查标第 0 步(评测集)。规范 v0.2(`evals/check/GUIDELINES.md`,Fable 审查后修改):响应方式按「文件 × 类别」记,规矩投标文件靠构造保证,问题只挂复核过的要求,留出集词库和风格 C 封存。C12(响应方式)、E1(复核由 Fable 做、用户抽查 20%)已确认。开发集 S02、S04、S10 六份试跑(`docs/architecture/check-eval.md` §1):查标召回 38%,规矩投标文件的误报大头是不需要响应的条目、表格里看不见同一行的响应格、扫描件和格式文件找不到。
- 2026-11-04:用户抽查 Fable 的复核 17 条,全部同意。
- 2026-11-04:查标评测开发集第一版:7 份招标文件、14 份投标文件(风格 A / B 轮换),注入问题 222 个、Fable 复核通过 188 个。查标召回 40%(致命级 49%),带标志词的问题只有 22%;规矩投标文件报出 699 个问题,近一半在不需要响应的要求上(`docs/architecture/check-eval.md` §2)。
- 2026-11-04:用户抽查第二批复核 28 条,全部同意(两批合计 45 条)。
- 2026-11-04:查标第一轮改进:候选句带同一行的单元格、提示词 check.match v2。开发集 17 份上召回 30% → 50%,带标志词的问题 2% → 54%,误报略降,输入 token +44%;漏响应和响应对象错位变差(`docs/architecture/check-eval.md` §3)。评测集修了待修项、加了 3 份低密度投标文件,第三批复核 55 条、用户抽查 11 条全部同意。
- 2026-11-04:查标第二轮:评测集纠错(漏响应、错位的「变差」多是生成器只改了一处);新增响应方式识别(C12,规则优先)和「这条要不要投标人回答」的判断。开发集 17 份上规矩投标文件报出的问题 639 → 229,误报 21.3 → 12.4 / 100,召回 68% → 65%,致命级 20 → 21 / 32;证明材料缺失几乎查不出(扫描件)。第四批复核 45 条,用户抽查 9 条全部同意(四批共 65 条)。见 `docs/architecture/check-eval.md` §4。
- 2026-11-04:查标第三轮:文件要在(按标题找文件名,不调模型)、扫描件兜底(C10,没找到的证明材料改为提示级「可能在扫描件里,请人工核对」)、响应方式存进清单并在清单页显示、可改(C12 的人工确认点,迁移 0007、0008)。开发集上规矩投标文件的致命问题 120 → 51,致命级召回不变(22 / 32);证明材料缺失仍查不出,要靠 OCR / 多模态(S5)。见 `docs/architecture/check-eval.md` §5。
- 2026-11-04:查标第四轮:规则检查(`check/rules.py`,规则集 2026.11-r1,不调模型)——大小写金额、分项合计、超限价、多个报价、别的项目名称、没填的地方、落款日期、投标有效期、附加条件、响应表里的未响应;文字层的句子带上单元格框,表格按单元格分行。请 Fable 从真实投标文件角度审读,按「认不准的不给致命」改了定级和取值范围。开发集 17 份上规则问题 57 / 57 查出、规矩投标文件 0 误报,但规则是看着生成器和开发词库写的,这个分数说明不了泛化,要等留出集和国动真实投标文件。见 `docs/architecture/check-eval.md` §6。
- 2026-11-04:查标第 2 步收尾:需人工核对(份数、密封、递交、CA 签章加密、解密、保证金,从清单按主题合并,查标页勾选、按项目存,P2);「报价」类清单项(C7,读标从清单表规则读出分项 / 单价上限)和规则 `PRICE.OVER_CEILING_ITEM`;评测集派生 4 份报价问题投标文件,21 份上规则问题 62 / 62、误报 0。见 `docs/architecture/check-eval.md` §7。
- 2026-11-04:查标第五轮(不调模型的后处理):偏离栏直读(证据所在表格行的偏离格写着不满足就改判负偏离)、响应错位(照抄要求格 + 响应格写的是别的内容);复查复用(送审内容没变的条目沿用上次的模型结论)和每次检查的硬预算。开发集 17 份召回 61% → 73%,负偏离未说明 64% → 90%,致命级 22 → 24 / 32,规矩投标文件的问题数不变(235)。见 `docs/architecture/check-eval.md` §8。
- 2026-11-04:查标第六轮:送给模型的「同一行」按单元格分行;修了「以合同签订时间为准」被当成中标后才交、业绩评分项被跳过的缺陷;重复条目按用户决定改为各自判断、只合并显示(C5 修订,`docs/decisions.md`);致命项重问按数据(开发集致命级证据校验失败 0 条)暂不做。评测集裁定 1 条。开发集召回 74%、致命级 25 / 32。见 `docs/architecture/check-eval.md` §9。
- 2026-11-04:查标第 3 步冻结(`a60d16e`),跑留出集一次:S03、S06、S09 各一份风格 B 规矩 + 风格 C 马虎(风格 C 由封存一侧的会话实现),96 个注入问题 Fable 复核通过 79,用户抽查 19 条全部同意。留出集召回 54%(开发集 74%)、致命级 56%(开发集 78%)、负偏离未说明 25%(开发集 92%)、规则问题 13 / 20;S03 的规矩投标文件因响应方式识别过度触发报了 1094 个提示,人工确认响应方式后降到 45 个。开发集分数有相当一部分是调出来的;要修的 5 处见 `docs/architecture/check-eval.md` §11。
- 2026-11-04:按用户决定(E2)先修留出集暴露的 5 处(响应方式识别、偏离栏按「是不是明确满足」判断、另报价 / 附加条件按结构判断、空白栏、异体字),规则集 2026.11-r3。开发集不退化(召回 75%、规矩投标文件问题数不变);留出集参考数字召回 54% → 69%、规矩投标文件问题 1134 → 82(已看过,正式成绩仍以冻结代码那次为准)。见 `docs/architecture/check-eval.md` §12。
- 2026-11-04:查标第 5 步检查报告:导出 → 后台用 Gotenberg 生成 A4 PDF(首页覆盖范围和免责声明、问题明细、需人工核对附录,页眉 logo、页脚免责一句和页码)→ 加密归档,可重复下载;前端加「报告」标签页(选项、导出、内嵌预览、历次下载)。补了项目状态流转(递交 / 撤销递交 / 归档 / 撤销归档,致命未处理也能递交,递交后只读,都留痕)。产品可以从上传招标文件一路走到导出报告、标记递交。见 `docs/architecture/m3-plan.md` 进度。
- 2026-11-04:查标第 4 步资质库和资质匹配(`docs/architecture/qualification-plan.md`,QL1–QL7 全部按建议确认)。资质库页可增删改、挂证书扫描件(企业级密钥加密),到期按进行中项目的投标截止日提醒;查标按证书类别词表(16 类,产品证书、人员证书不进匹配)核对有效期、等级、有没有覆盖,投标文字层按清单项定级,只有资质库记录的降一级、出处显示「依据:资质库记录」,规则集 2026.11-r4。开发集不退化;派生的资质情况表上 D12 3 / 3、D18 2 / 2、规矩的资质表 0 误报,但表和词表是对着同一批样本写的,泛化要等国动真实文件(`check-eval.md` §13)。
- 2026-11-04:项目基本信息修改和分包选择(FR-M2-03、04,`docs/architecture/project-info-plan.md`,PI1–PI7 全部按建议确认)。新增「概览」标签页(项目要点可改、带出处、可恢复 AI 值;采购包;关键日期)和选包页(手工调整、一键「不分包」);清单默认只列通用 + 所投的包;检查记下当时用的截止时间、限价、所投的包、法律体系,报告按它写,之后改了信息查标页提示重新检查。样本里分包识别对的只有 S10(S06 把分项认成 5 个包,S01、S08 单包写了包号),改进分包抽取记进读标专项。
- 2026-11-05:补遗追加(FR-M2-10,`docs/architecture/addendum-plan.md`,AD1–AD6 全部按建议确认)。招标文件解析后可以追加补遗 / 澄清(单独解析、扣 1 次),新条目标「补遗 N」进正式清单、清单回到草稿;同一种日期时间不同的旧条目标「已被补遗 N 更新」,项目字段的新值在概览「采用 / 不采用」,采用截止时间时日期条目一起确认,没确认前查标用旧值、检查预览提示。补遗不改项目名称、编号、采购人等(更正公告标题会被抽成项目名称)。真实模型对虚构更正公告抽得对;还没有真实补遗样本。
- 2026-11-05:读标、查标全流程走查(浏览器从注册走到导出报告)。修了走查发现的问题:原生下拉换成按规范 §7.21 的 `SelectBox`(Reka UI,和搜索框同高,长选项不再挤压签名);关键日期去重;报告页致命数改读项目的实时值;★ 只是「重要」的文件清单项也挂到 ★ 规则上;检查范围文案过期;报告出处不重复文件名(模板 2026.11-v3)。查标结果页按原型补齐:本次处理的留在原位、J / K / O / 1 / 2 / 3 / X / U 键盘、「提示」批量确认、「判断有误?」反馈、覆盖范围默认收起、导出报告入口;项目头改为「就绪 / 未就绪」,致命数挂在查标标签上。
- 2026-11-05:查标结果页、概览改成和读标页一样的左右布局(左边内容,右边原文贴住视口),去掉原文侧边抽屉。查标右侧招标文件、投标文件左右切换(`components/DocStage.vue`):当前的占满,另一份露出一窄条(文件名、纸边、带方向箭头的标签,截断处淡入底色),点它或点另一边的出处就滑过去,两边各自记着页和高亮,J / K 时原文跟着翻。概览把关键日期和采购包合成一张卡放到最上面。修了原文查看器直接打开到某页时重复渲染出镜像乱字的问题。内容区四角改为直角(设计规范 §5.3 同步)。
- 2026-11-05:国动真实投标文件跑了一次(`check-eval.md` §14,代码 `5b77012`、规则集 r4、资质库空):重新读标 299 条,查标 26 秒报 44 个问题,逐条对原文后有用 9 个,**致命、重要各 1 个都是误报**(响应格「完全满足,无偏离。」被当成错位;「不……另行收费」被当成附加条件)。另外扫描页只按整页有没有字判断、不需要回答的条目还在出提示。人工看到评分索引表页码和实际页码对不上,产品还不查这一项。结果只记录,6 处问题修不修待用户定。
- 2026-11-05:按用户决定(E3)修了国动暴露的 6 处,规则集 2026.11-r5(`check-eval.md` §15)。国动重新检查 44 → 11 个,全是提示级,有用的 9 个都还在(看过结果才修,只作参考)。开发集不退化:召回 75% → 74%(模型重跑的波动),致命级 25 / 32 不变,规矩的投标文件报出的问题 239 → 213,规则问题 57 / 57。投标分册的文字层升到 v5(每页记图片占比,用来判断扫描件)。
- 2026-11-05:查标第 6 步前端补齐(`m3-plan.md` 进度)。
- 读标页:每条清单项写最近一次检查的结论,点投标侧出处看投标原文;「已响应」也能反馈判断有误。证书要求写资质库里有没有、在截止日是否有效(新接口 `qualification-matches`,资质库空时不写)。
- 概览:加了检查记录卡和文件卡。
- 工作台:加了「致命」列、统计可点筛选、待处理的致命问题卡(「去处理」定位到那条问题)。
- 日期输入加了日历弹层;查标页加了 ? 键。
- 2026-11-05:M6 剩下的 P0(`docs/architecture/m6-plan.md`,M6-1–M6-8 全部按建议确认)。
- 用户端:额度与套餐页(流水逐条可核、按功能分布、扣点规则、导出 CSV)、开通 / 续费申请、80% / 100% 提醒、通知铃铛、账号页和注销、删除项目(彻底删除)。
- 到期:专业版到期后整个账号只读、额度清零,到期前 30 / 7 / 0 天提醒;worker 每小时跑一次定时任务。
- 运营后台:独立进程 `bid-doctor ops` 和 `apps/admin`,用户名 + 密码 + TOTP + IP 白名单,可以处理开通申请、查企业、调额度、看误报 / 漏报、失败任务和审计,不看业务内容(`ops-api.md`)。新增数据库角色 `bd_ops`,迁移 0010、0011。
- 顺手修了取消解析 / 检查时接口报 500 的问题。后端 156 个测试,浏览器从注册走到注销。
- 2026-11-05:运营后台第二版(M6-9、M6-10):左侧菜单(标准 logo、可展开收起的分组、图标、待办数、环境标签),运营人员角色只留结构(`ops_admin.role`、权限点、每个接口的检查点、菜单按权限过滤,现在只有管理员),迁移 0012。
- 2026-11-05:前端走查修改。报告预览改用 pdf.js(浏览器自带的 PDF 查看器是深色的,改不了);读标页改成和查标一样的左右布局(项目头滚出屏幕,清单卡片和原文区贴住顶栏),响应方式收进清单栏的按钮;报告页「导出」统一改称「生成」(生成一份存进项目,下载才是存到电脑),PDF 模板 2026.11-v4;概览检查记录的报告列只写份数和「查看报告」;招标 / 投标文件切换标签改为双箭头、指向展开方向;项目头三项取值对齐。
- 下一步:可做的有:读标抽取专项优化(`extract-status.md`)、更多真实投标文件、M6 的 P1(协作权限、资产库)。

13
apps/admin/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<title>标博士运营后台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

25
apps/admin/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "@bid-doctor/admin",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"typecheck": "vue-tsc --noEmit",
"preview": "vite preview"
},
"dependencies": {
"@fontsource/barlow-semi-condensed": "5.3.0",
"@lucide/vue": "1.50.0",
"vue": "3.5.43",
"vue-router": "4.6.4"
},
"devDependencies": {
"@types/node": "24.19.2",
"@vitejs/plugin-vue": "6.0.9",
"typescript": "5.8.3",
"vite": "7.3.7",
"vue-tsc": "3.3.12"
}
}

53
apps/admin/src/App.vue Normal file
View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
/** 运营后台的框架:左侧菜单(OpsSidebar)+ 内容。登录页不带框架。菜单上的待办数随切换页面刷新,另外每分钟刷新一次。 */
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import { RouterView, useRoute, useRouter } from "vue-router";
import { CHANGED, ops, type Operator, type Summary } from "./api";
import OpsSidebar from "./OpsSidebar.vue";
const route = useRoute();
const router = useRouter();
const me = ref<Operator | null>(null);
const summary = ref<Summary | null>(null);
async function loadMe() {
if (route.meta.bare) return;
me.value = await ops<Operator>("/me").catch(() => null);
}
async function loadSummary() {
if (route.meta.bare || !me.value) return;
summary.value = await ops<Summary>("/summary").catch(() => summary.value);
}
let timer: number | undefined;
onMounted(async () => {
await loadMe();
await loadSummary();
timer = window.setInterval(loadSummary, 60_000);
window.addEventListener(CHANGED, loadSummary);
});
onBeforeUnmount(() => {
window.clearInterval(timer);
window.removeEventListener(CHANGED, loadSummary);
});
watch(
() => route.fullPath,
async (_, old) => {
if (route.meta.bare) return;
if (!me.value || old?.startsWith("/login")) await loadMe();
await loadSummary();
},
);
async function logout() {
await ops("/auth/logout", { method: "POST" }).catch(() => undefined);
me.value = null;
summary.value = null;
await router.replace({ name: "login" });
}
</script>
<template>
<RouterView v-if="route.meta.bare" />
<div v-else class="ops-frame">
<OpsSidebar :me="me" :summary="summary" @logout="logout" />
<main class="ops-main"><div class="content"><RouterView /></div></main>
</div>
</template>

View File

@@ -0,0 +1,289 @@
<script setup lang="ts">
/**
* 运营后台的左侧菜单:标准横版 logo(反白)+「运营后台」+ 环境标签(不是生产环境时醒目提示,免得把测试当生产);
* 分组像文件夹一样可以展开收起(记在本机),当前页所在的分组总是展开;菜单项带图标和待办数;底部是当前运营人员和退出。
* 菜单按权限点过滤(nav.ts)。
*/
import { ChevronDown, LogOut } from "@lucide/vue";
import logoUrl from "@design/logo/svg/logo-horizontal-reverse.svg?url";
import { computed, ref, watch } from "vue";
import { RouterLink, useRoute } from "vue-router";
import type { Operator, Summary } from "./api";
import { visibleNav } from "./nav";
const props = defineProps<{ me: Operator | null; summary: Summary | null }>();
const emit = defineEmits<{ logout: [] }>();
const route = useRoute();
const groups = computed(() => visibleNav(props.me?.permissions ?? []));
const KEY = "ops.nav.collapsed";
function load(): string[] {
try {
return JSON.parse(localStorage.getItem(KEY) ?? "[]") as string[];
} catch {
return [];
}
}
const collapsed = ref(new Set(load()));
function save(s: Set<string>) {
collapsed.value = s;
try {
localStorage.setItem(KEY, JSON.stringify([...s]));
} catch {
/* 隐私模式等存不了:只是这次有效 */
}
}
function toggle(key: string) {
const s = new Set(collapsed.value);
if (s.has(key)) s.delete(key);
else s.add(key);
save(s);
}
const isActive = (name: string, also: string[] = []) => route.name === name || also.includes(String(route.name));
const activeGroup = computed(() => groups.value.find((g) => g.items.some((i) => isActive(i.name, i.also)))?.key);
const isOpen = (key: string) => !collapsed.value.has(key);
// 跳到一个收起的分组里的页面(链接、详情页):把那一组展开
watch(activeGroup, (g) => {
if (g && collapsed.value.has(g)) save(new Set([...collapsed.value].filter((k) => k !== g)));
}, { immediate: true });
const ENV: Record<string, string> = { dev: "开发环境", test: "测试环境" };
</script>
<template>
<aside class="ops-side" aria-label="运营后台菜单">
<RouterLink class="brand" :to="{ name: 'requests' }" aria-label="标博士运营后台">
<img :src="logoUrl" alt="标博士" />
</RouterLink>
<div class="sub">
<span>运营后台</span>
<span v-if="me && me.env !== 'prod'" class="env">{{ ENV[me.env] ?? me.env }}</span>
</div>
<nav class="groups">
<section v-for="g in groups" :key="g.key" class="group">
<button
type="button"
class="gh"
:aria-expanded="isOpen(g.key)"
:aria-controls="`grp-${g.key}`"
@click="toggle(g.key)"
>
<span>{{ g.label }}</span>
<ChevronDown class="chev" :class="{ shut: !isOpen(g.key) }" :stroke-width="1.75" />
</button>
<ul v-show="isOpen(g.key)" :id="`grp-${g.key}`">
<li v-for="i in g.items" :key="i.name">
<RouterLink :to="{ name: i.name }" :aria-current="isActive(i.name, i.also) ? 'page' : undefined">
<component :is="i.icon" class="ic" :stroke-width="1.5" />
<span class="lbl">{{ i.label }}</span>
<span v-if="i.badge && summary && summary[i.badge]" class="badge" :class="{ warn: i.badge === 'failed_jobs_7d' }">
{{ summary[i.badge] }}
</span>
</RouterLink>
</li>
</ul>
</section>
</nav>
<div v-if="me" class="me">
<span class="avatar" aria-hidden="true">{{ me.display_name.slice(0, 1) }}</span>
<span class="who">
<b :title="me.display_name">{{ me.display_name }}</b>
<small>{{ me.role_label }} · {{ me.username }}</small>
</span>
<button type="button" class="out" aria-label="退出登录" title="退出登录" @click="emit('logout')">
<LogOut class="ic" :stroke-width="1.5" />
</button>
</div>
</aside>
</template>
<style scoped>
.ops-side {
position: sticky;
top: 0;
display: flex;
flex-direction: column;
height: 100vh;
padding: var(--sp-5) var(--sp-3) var(--sp-4);
background: linear-gradient(180deg, var(--side-top) 0%, var(--side-bottom) 100%);
color: var(--side-fg);
overflow-y: auto;
}
.brand {
display: block;
padding: 0 var(--sp-2);
border-radius: var(--radius-ctl);
}
.brand img {
display: block;
width: 148px;
height: auto;
}
.brand:focus-visible,
.gh:focus-visible,
.groups a:focus-visible,
.out:focus-visible {
outline: 2px solid var(--side-fg-strong);
outline-offset: -2px;
}
.sub {
display: flex;
gap: var(--sp-2);
align-items: center;
margin: var(--sp-3) var(--sp-2) var(--sp-5);
padding-bottom: var(--sp-4);
border-bottom: var(--line) solid var(--side-line);
font-size: var(--fs-caption);
letter-spacing: 0.12em;
color: var(--side-muted);
}
/* 非生产环境:赭色实底标签(红色只给致命问题,U1) */
.env {
padding: 0 6px;
border-radius: var(--radius-xs);
background: var(--warn);
color: var(--c-ink);
font-weight: var(--fw-bold);
letter-spacing: 0;
line-height: 18px;
}
.groups {
flex: 1;
}
.group + .group {
margin-top: var(--sp-3);
}
.gh {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 28px;
padding: 0 var(--sp-2);
border: 0;
border-radius: var(--radius-ctl);
background: none;
font: inherit;
font-size: var(--fs-caption);
color: var(--side-muted);
cursor: pointer;
}
.gh:hover {
color: var(--side-fg-strong);
}
.chev {
width: 14px;
height: 14px;
transition: transform var(--dur-fast) var(--ease);
}
.chev.shut {
transform: rotate(-90deg);
}
ul {
margin: 2px 0 0;
padding: 0;
list-style: none;
}
.groups a {
display: flex;
gap: var(--sp-3);
align-items: center;
height: 38px;
padding: 0 var(--sp-2) 0 var(--sp-3);
border-radius: var(--radius-ctl);
color: var(--side-fg);
text-decoration: none;
transition: background-color var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);
}
.groups a .ic {
flex: none;
width: 18px;
height: 18px;
}
.groups a .lbl {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.groups a:hover {
background: var(--side-hover);
color: var(--side-fg-strong);
}
.groups a[aria-current="page"] {
background: var(--side-active);
color: var(--side-fg-strong);
font-weight: var(--fw-bold);
}
.badge {
min-width: 20px;
padding: 0 6px;
border-radius: 10px;
background: var(--side-avatar-bg);
color: var(--side-fg-strong);
font: var(--fw-medium) var(--fs-caption) / 20px var(--font-figures);
text-align: center;
}
.badge.warn {
background: var(--warn);
color: var(--c-ink);
}
.me {
display: flex;
gap: var(--sp-2);
align-items: center;
margin-top: var(--sp-4);
padding: var(--sp-3) var(--sp-2) 0;
border-top: var(--line) solid var(--side-line);
}
.avatar {
display: inline-grid;
flex: none;
place-items: center;
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--side-avatar-bg);
box-shadow: 0 0 0 1px var(--side-avatar-ring);
color: var(--side-fg-strong);
font-weight: var(--fw-bold);
}
.who {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.who b {
overflow: hidden;
color: var(--side-fg-strong);
text-overflow: ellipsis;
white-space: nowrap;
}
.who small {
font-size: var(--fs-caption);
color: var(--side-muted);
}
.out {
display: inline-grid;
place-items: center;
width: 32px;
height: 32px;
border: 0;
border-radius: var(--radius-ctl);
background: none;
color: var(--side-fg);
cursor: pointer;
}
.out:hover {
background: var(--side-hover);
color: var(--side-fg-strong);
}
.ic {
width: 18px;
height: 18px;
}
</style>

15
apps/admin/src/admin.css Normal file
View File

@@ -0,0 +1,15 @@
/* 运营后台的框架(决策 M6-3):颜色、字号、间距只用 tokens.css 的变量;按钮、表格、卡片沿用用户端的组件样式 */
body{margin:0;background:var(--bg-page);color:var(--text-primary)}
.ops-frame{display:grid;grid-template-columns:232px minmax(0,1fr);min-height:100vh;min-width:1280px}
.ops-main{min-width:0}
.ops-main .content{max-width:1440px;padding-top:var(--sp-8)}
.toolbar{display:flex;gap:var(--sp-3);align-items:center;margin-bottom:var(--sp-4)}
.toolbar .input{max-width:360px}
.card{margin-bottom:var(--gap-card)}
.card.flush > .card-h{padding:var(--sp-4) var(--card-x) 0}
table.data td{vertical-align:top}
.nowrap{white-space:nowrap}
.r{text-align:right}
.acts{display:flex;flex-wrap:wrap;gap:var(--sp-2)}
.err-text{color:var(--warn-text)}
.inv{margin:0;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary)}

155
apps/admin/src/api.ts Normal file
View File

@@ -0,0 +1,155 @@
/**
* 运营后台接口(/ops/v1,docs/architecture/ops-api.md)。内部工具,类型在这里手写;cookie 会话 + CSRF 头。
*/
export class OpsError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
}
}
/** 页面上做完操作(开通、退回……)发这个事件,菜单上的待办数跟着刷新 */
export const CHANGED = "ops:changed";
export const notifyChanged = () => window.dispatchEvent(new Event(CHANGED));
let onUnauthorized: () => void = () => {};
export function setUnauthorized(fn: () => void) {
onUnauthorized = fn;
}
function csrf(): string {
return document.cookie.split("; ").find((c) => c.startsWith("bd_ops_csrf="))?.split("=")[1] ?? "";
}
export async function ops<T>(path: string, init: { method?: string; body?: unknown; query?: Record<string, string | undefined> } = {}): Promise<T> {
const qs = new URLSearchParams(Object.entries(init.query ?? {}).filter((e): e is [string, string] => !!e[1])).toString();
const res = await fetch(`/ops/v1${path}${qs ? `?${qs}` : ""}`, {
method: init.method ?? "GET",
credentials: "include",
headers: { "Content-Type": "application/json", ...(init.method && init.method !== "GET" ? { "X-CSRF-Token": csrf() } : {}) },
body: init.body === undefined ? undefined : JSON.stringify(init.body),
});
if (res.status === 204) return undefined as T;
const data = await res.json().catch(() => ({}));
if (!res.ok) {
if (res.status === 401 && path !== "/auth/login") onUnauthorized();
throw new OpsError(res.status, data.code ?? "error", data.detail ?? data.title ?? "请求失败");
}
return data as T;
}
/** 时间一律按北京时间显示:「2026-11-05 14:45」 */
const BJ = new Intl.DateTimeFormat("sv-SE", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
export const fmt = (iso: string | null | undefined) => (iso ? BJ.format(new Date(iso)) : "—");
export const day = (iso: string | null | undefined) => (iso ? BJ.format(new Date(iso)).slice(0, 10) : "—");
export type Tenant = {
id: string;
name: string;
status: string;
created_at: string;
plan: { code: string | null; expired: boolean; ends_at: string | null };
balance: number | null;
projects: number;
phone_hint: string | null;
open_requests: number;
};
export type TenantDetail = Tenant & {
members: { display_name: string; role: string; status: string; phone_hint: string | null; last_login_at: string | null }[];
subscriptions: { plan_code: string; status: string; source: string; starts_at: string; ends_at: string | null; request_no: string | null }[];
quota_entries: { kind: string; amount: number; reason: string | null; ref_type: string | null; created_at: string; unit: string; by_ops: boolean }[];
jobs: Record<string, Record<string, number>>;
};
export type ActivationRequest = {
id: string;
request_no: string;
tenant: { id: string; name: string };
plan_code: string;
status: "submitted" | "paid" | "activated" | "rejected" | "cancelled";
invoice_info: Record<string, string>;
ops_note: string | null;
invoice_no: string | null;
applicant_phone_hint: string | null;
created_at: string;
handled_at: string | null;
};
export type Feedback = {
id: string;
kind: string;
source: string;
note: string | null;
object_type: string;
tenant_name: string;
title: string | null;
severity: string | null;
rule_code: string | null;
detector: string | null;
finding_type: string | null;
rule_set_version: string | null;
prompt_versions: Record<string, string> | null;
pages: number[];
created_at: string;
};
export type FailedJob = {
kind: string;
id: string;
tenant: { id: string; name: string };
stage: string | null;
error_class: string | null;
error_code: string | null;
error_detail: string | null;
created_at: string;
duration_s: number | null;
ai_calls: number;
};
export type AuditEvent = {
id: string;
tenant: { id: string; name: string };
actor_kind: string;
actor_name: string | null;
action: string;
object_type: string;
object_id: string | null;
project_id: string | null;
scrubbed: boolean;
created_at: string;
};
export type Operator = {
id: string;
username: string;
display_name: string;
role: string;
role_label: string;
permissions: string[];
env: "dev" | "test" | "prod";
};
export type Summary = { open_requests: number; failed_jobs_7d: number; feedback_7d: number };
export const REQUEST_STATUS: Record<string, string> = {
submitted: "等待转账",
paid: "已到账",
activated: "已开通",
rejected: "已退回",
cancelled: "已撤回",
};
export const QUOTA_KIND: Record<string, string> = {
grant: "发放",
hold: "预扣",
settle: "结算",
release: "退回",
expire: "过期",
adjust: "调整",
waive: "免扣",
};
export const JOB_KIND: Record<string, string> = { parse_job: "解析", check_run: "检查", report_job: "报告" };

11
apps/admin/src/main.ts Normal file
View File

@@ -0,0 +1,11 @@
import { createApp } from "vue";
import "@fontsource/barlow-semi-condensed/500.css";
import "@fontsource/barlow-semi-condensed/600.css";
import "@design/system/tokens.css";
import "@design/system/components.css";
import "@web-styles/app.css";
import "./admin.css";
import App from "./App.vue";
import { router } from "./router";
createApp(App).use(router).mount("#app");

49
apps/admin/src/nav.ts Normal file
View File

@@ -0,0 +1,49 @@
/**
* 运营后台的菜单:分组(像文件夹,可以展开收起)→ 菜单项。每一项写清要哪个权限点(ops/auth.py 的 OpsPermission),
* 没有这个权限的人看不到;一个分组下都看不到时整组不显示。以后加页面、加分组、加二级子菜单都只改这里。
*/
import { Building2, FileClock, MessageSquareWarning, ReceiptText, ScrollText, ServerCrash, type LucideIcon } from "@lucide/vue";
import type { Summary } from "./api";
export type NavItem = {
name: string; // 路由名
label: string;
icon: LucideIcon;
perm: string;
/** 菜单上的待办数(来自 /summary) */
badge?: keyof Summary;
/** 这些路由也算在这一项下面(详情页) */
also?: string[];
};
export type NavGroup = { key: string; label: string; icon: LucideIcon; items: NavItem[] };
export const NAV: NavGroup[] = [
{
key: "customers",
label: "客户",
icon: Building2,
items: [
{ name: "requests", label: "开通申请", icon: ReceiptText, perm: "activation.manage", badge: "open_requests" },
{ name: "tenants", label: "企业", icon: Building2, perm: "tenant.view", also: ["tenant"] },
],
},
{
key: "quality",
label: "质量",
icon: MessageSquareWarning,
items: [
{ name: "feedback", label: "误报 / 漏报", icon: MessageSquareWarning, perm: "feedback.view", badge: "feedback_7d" },
{ name: "failed-jobs", label: "失败任务", icon: ServerCrash, perm: "jobs.view", badge: "failed_jobs_7d" },
],
},
{
key: "security",
label: "安全",
icon: ScrollText,
items: [{ name: "audit", label: "审计", icon: FileClock, perm: "audit.view" }],
},
];
export function visibleNav(perms: string[]): NavGroup[] {
return NAV.map((g) => ({ ...g, items: g.items.filter((i) => perms.includes(i.perm)) })).filter((g) => g.items.length);
}

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
/** 审计(FR-M6-07):谁在什么时候对什么做了什么。修改前后的内容是业务内容,这里不显示;删除项目后只剩动作和时间。 */
import { onMounted, ref } from "vue";
import { RouterLink } from "vue-router";
import { fmt, ops, OpsError, type AuditEvent } from "../api";
const action = ref("");
const items = ref<AuditEvent[]>([]);
const err = ref("");
async function load() {
err.value = "";
try {
items.value = (await ops<{ items: AuditEvent[] }>("/audit-events", { query: { action: action.value.trim() || undefined } })).items;
} catch (e) {
err.value = e instanceof OpsError ? e.message : "读取失败";
}
}
onMounted(load);
const ACTOR: Record<string, string> = { user: "用户", ops: "运营", system: "系统" };
</script>
<template>
<div class="page-title">
<div>
<h1>审计</h1>
<p>最近 200 条。按动作前缀筛选,如 ops.(运营操作)、project.delete、tenant.close。</p>
</div>
</div>
<form class="toolbar" @submit.prevent="load">
<input v-model="action" class="input" placeholder="动作前缀,如 ops." aria-label="动作前缀" />
<button type="submit" class="btn">筛选</button>
</form>
<p v-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
<section class="card flush">
<table class="data">
<thead><tr><th>时间</th><th>企业</th><th>操作人</th><th>动作</th><th>对象</th><th></th></tr></thead>
<tbody>
<tr v-for="e in items" :key="e.id">
<td class="num nowrap">{{ fmt(e.created_at) }}</td>
<td><RouterLink class="lnk" :to="{ name: 'tenant', params: { id: e.tenant.id } }">{{ e.tenant.name }}</RouterLink></td>
<td>{{ ACTOR[e.actor_kind] ?? e.actor_kind }}<template v-if="e.actor_name"> · {{ e.actor_name }}</template></td>
<td class="num">{{ e.action }}</td>
<td class="cap muted">{{ e.object_type }} {{ e.object_id?.slice(0, 8) ?? "" }}</td>
<td class="cap muted">{{ e.scrubbed ? "内容已抹掉" : "" }}</td>
</tr>
<tr v-if="!items.length"><td colspan="6" class="muted">没有记录</td></tr>
</tbody>
</table>
</section>
</template>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
/** 失败任务:解析、检查、报告。错误类别、错误码、阶段、耗时、模型调用次数;不含文档内容。失败的都已自动退回额度(红线 9)。 */
import { onMounted, ref } from "vue";
import { RouterLink } from "vue-router";
import { fmt, JOB_KIND, ops, OpsError, type FailedJob } from "../api";
const items = ref<FailedJob[]>([]);
const err = ref("");
onMounted(async () => {
try {
items.value = (await ops<{ items: FailedJob[] }>("/failed-jobs")).items;
} catch (e) {
err.value = e instanceof OpsError ? e.message : "读取失败";
}
});
const CLASS: Record<string, string> = { input: "文件问题", infra: "系统异常", cancelled: "取消" };
</script>
<template>
<div class="page-title">
<div>
<h1>失败任务</h1>
<p>失败的任务都已自动退回额度。系统异常(infra)要排查,文件问题(input)一般是用户的文件读不了。</p>
</div>
</div>
<p v-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
<section class="card flush">
<table class="data">
<thead><tr><th>时间</th><th>任务</th><th>企业</th><th>阶段</th><th>错误</th><th class="r">耗时</th><th class="r">模型调用</th><th>编号</th></tr></thead>
<tbody>
<tr v-for="j in items" :key="j.id">
<td class="num nowrap">{{ fmt(j.created_at) }}</td>
<td>{{ JOB_KIND[j.kind] ?? j.kind }}</td>
<td><RouterLink class="lnk" :to="{ name: 'tenant', params: { id: j.tenant.id } }">{{ j.tenant.name }}</RouterLink></td>
<td>{{ j.stage ?? "—" }}</td>
<td>
<span :class="{ 'err-text': j.error_class === 'infra' }">{{ CLASS[j.error_class ?? ""] ?? j.error_class ?? "" }}</span> {{ j.error_code ?? "" }}
<p v-if="j.error_detail" class="inv">{{ j.error_detail }}</p>
</td>
<td class="num r">{{ j.duration_s != null ? `${Math.round(j.duration_s)} 秒` : "—" }}</td>
<td class="num r">{{ j.ai_calls }}</td>
<td class="num cap muted">{{ j.id.slice(0, 8) }}</td>
</tr>
<tr v-if="!items.length"><td colspan="8" class="muted">没有失败的任务</td></tr>
</tbody>
</table>
</section>
</template>

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
/**
* 误报 / 漏报反馈(J5):逐条复盘,再迭代规则。不显示招标 / 投标原文(决策 M6-7),只给标题、理由、规则编号、
* 提示词版本、页码;要看原文请用户导出报告发过来。致命问题「确认无误」写的说明也会自动进来(U4)。
*/
import { onMounted, ref, watch } from "vue";
import { fmt, ops, OpsError, type Feedback } from "../api";
const kind = ref<"" | "false_positive" | "false_negative">("");
const items = ref<Feedback[]>([]);
const err = ref("");
async function load() {
err.value = "";
try {
items.value = (await ops<{ items: Feedback[] }>("/feedback", { query: { kind: kind.value || undefined } })).items;
} catch (e) {
err.value = e instanceof OpsError ? e.message : "读取失败";
}
}
onMounted(load);
watch(kind, load);
const SEV: Record<string, string> = { fatal: "致命", important: "重要", hint: "提示" };
</script>
<template>
<div class="page-title">
<div>
<h1>误报 / 漏报</h1>
<p>只看标题、理由和规则,不看原文。这里的数字只给内部复盘用,界面上不对用户展示统计。</p>
</div>
</div>
<div class="toolbar">
<div class="seg" role="group" aria-label="筛选">
<button type="button" :aria-pressed="kind === ''" @click="kind = ''">全部</button>
<button type="button" :aria-pressed="kind === 'false_positive'" @click="kind = 'false_positive'">误报</button>
<button type="button" :aria-pressed="kind === 'false_negative'" @click="kind = 'false_negative'">漏报</button>
</div>
</div>
<p v-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
<section class="card flush">
<table class="data">
<thead><tr><th>时间</th><th>类型</th><th>企业</th><th>条目</th><th>用户写的</th><th>规则 / 版本</th><th>页码</th></tr></thead>
<tbody>
<tr v-for="f in items" :key="f.id">
<td class="num nowrap">{{ fmt(f.created_at) }}</td>
<td>{{ f.kind === "false_positive" ? "误报" : "漏报" }}<p v-if="f.source !== 'user'" class="inv">{{ f.source }}</p></td>
<td>{{ f.tenant_name }}</td>
<td>
<span v-if="f.severity" class="tag">{{ SEV[f.severity] ?? f.severity }}</span> {{ f.title ?? "(条目已删除)" }}
<p class="inv">{{ f.object_type === "finding" ? "查标问题" : "读标清单项" }}<template v-if="f.finding_type"> · {{ f.finding_type }}</template><template v-if="f.detector"> · {{ f.detector === "ai" ? "模型" : "规则" }}</template></p>
</td>
<td>{{ f.note ?? "—" }}</td>
<td>
<p class="inv">{{ f.rule_code ?? "—" }}</p>
<p class="inv">{{ f.rule_set_version ?? "" }}<template v-if="f.prompt_versions"> · {{ Object.entries(f.prompt_versions).map(([k, v]) => `${k} ${v}`).join("、") }}</template></p>
</td>
<td class="num">{{ f.pages.join("、") || "—" }}</td>
</tr>
<tr v-if="!items.length"><td colspan="7" class="muted">还没有反馈</td></tr>
</tbody>
</table>
</section>
</template>

View File

@@ -0,0 +1,82 @@
<script setup lang="ts">
/** 运营登录(决策 M6-4):用户名 + 密码 + 身份验证器 App 上的 6 位动态码。账号用命令行建(bid-doctor ops-admin create)。 */
import logoUrl from "@design/logo/svg/logo-horizontal.svg?url";
import { ref } from "vue";
import { useRouter } from "vue-router";
import { ops, OpsError } from "../api";
const router = useRouter();
const username = ref("");
const password = ref("");
const totp = ref("");
const err = ref("");
const busy = ref(false);
async function submit() {
if (busy.value) return;
busy.value = true;
err.value = "";
try {
await ops("/auth/login", { method: "POST", body: { username: username.value.trim(), password: password.value, totp: totp.value.trim() } });
await router.replace({ name: "requests" });
} catch (e) {
err.value = e instanceof OpsError ? e.message : "登录失败";
} finally {
busy.value = false;
}
}
</script>
<template>
<div class="login">
<form class="card" novalidate @submit.prevent="submit">
<img class="logo" :src="logoUrl" alt="标博士" />
<h1>运营后台</h1>
<p class="cap muted">只在内网和白名单地址可用,所有操作都会留痕。</p>
<div class="field fill">
<label for="u">用户名</label>
<input id="u" v-model="username" class="input" autocomplete="username" />
</div>
<div class="field fill">
<label for="p">密码</label>
<input id="p" v-model="password" class="input" type="password" autocomplete="current-password" />
</div>
<div class="field fill">
<label for="t">动态验证码</label>
<input id="t" v-model="totp" class="input" inputmode="numeric" maxlength="6" autocomplete="one-time-code" placeholder="身份验证器 App 上的 6 位数字" />
</div>
<p v-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
<button type="submit" class="btn primary" :disabled="busy">登录</button>
</form>
</div>
</template>
<style scoped>
.login {
display: grid;
place-items: center;
min-height: 100vh;
}
.login form {
width: 380px;
padding: var(--sp-8);
}
.logo {
display: block;
width: 168px;
margin-bottom: var(--sp-6);
}
h1 {
margin: 0;
font: var(--fw-bold) var(--fs-h1) / var(--lh-h1) var(--font-serif);
}
.field {
margin-top: var(--sp-4);
}
.btn {
width: 100%;
margin-top: var(--sp-6);
}
.hint-warn {
margin: var(--sp-4) 0 0;
}
</style>

View File

@@ -0,0 +1,126 @@
<script setup lang="ts">
/**
* 开通申请(G8):用户在线提交 → 对公转账 → 这里「标记到账」→「开通」(默认专业版 1 年;当前专业版没到期时是续费,新一期排队)
* → 开发票后登记发票号。不通过就「退回」并写原因,用户在额度页看得到。每一步都写审计。
*/
import { computed, onMounted, ref } from "vue";
import { RouterLink } from "vue-router";
import { fmt, notifyChanged, ops, OpsError, REQUEST_STATUS, type ActivationRequest } from "../api";
const filter = ref<"open" | "all">("open");
const items = ref<ActivationRequest[]>([]);
const err = ref("");
async function load() {
err.value = "";
try {
items.value = (await ops<{ items: ActivationRequest[] }>("/activation-requests")).items;
} catch (e) {
err.value = e instanceof OpsError ? e.message : "读取失败";
}
}
onMounted(load);
const rows = computed(() => (filter.value === "open" ? items.value.filter((r) => r.status === "submitted" || r.status === "paid") : items.value));
const acting = ref<{ id: string; kind: "reject" | "invoice" | "activate" } | null>(null);
const input = ref("");
const months = ref(12);
const busy = ref(false);
function start(r: ActivationRequest, kind: "reject" | "invoice" | "activate") {
acting.value = { id: r.id, kind };
input.value = "";
months.value = 12;
}
async function act(r: ActivationRequest, kind: "paid" | "activate" | "reject" | "invoice") {
busy.value = true;
err.value = "";
try {
const body = kind === "reject" ? { note: input.value.trim() } : kind === "invoice" ? { invoice_no: input.value.trim() } : kind === "activate" ? { months: months.value } : undefined;
await ops(`/activation-requests/${r.id}/${kind}`, { method: "POST", body });
acting.value = null;
notifyChanged();
await load();
} catch (e) {
err.value = e instanceof OpsError ? `${r.request_no}:${e.message}` : "操作失败";
} finally {
busy.value = false;
}
}
const INVOICE_TYPE: Record<string, string> = { general: "普票", special: "专票" };
</script>
<template>
<div class="page-title">
<div>
<h1>开通申请</h1>
<p>核对对公转账到账后再开通。转账备注应当是申请单号。</p>
</div>
</div>
<div class="toolbar">
<div class="seg" role="group" aria-label="筛选">
<button type="button" :aria-pressed="filter === 'open'" @click="filter = 'open'">待处理</button>
<button type="button" :aria-pressed="filter === 'all'" @click="filter = 'all'">全部</button>
</div>
</div>
<p v-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
<section class="card flush">
<table class="data">
<thead>
<tr><th>申请单号</th><th>企业</th><th>发票信息</th><th>状态</th><th>提交时间</th><th>操作</th></tr>
</thead>
<tbody>
<tr v-for="r in rows" :key="r.id">
<td class="num">{{ r.request_no }}</td>
<td>
<RouterLink class="lnk" :to="{ name: 'tenant', params: { id: r.tenant.id } }">{{ r.tenant.name }}</RouterLink>
<p class="inv">申请人 {{ r.applicant_phone_hint ?? "—" }}</p>
</td>
<td>
<p class="inv">
{{ r.invoice_info.title }} · {{ INVOICE_TYPE[r.invoice_info.invoice_type ?? "general"] }}<br />
税号 {{ r.invoice_info.tax_no }}<br />
<template v-if="r.invoice_info.address_phone">{{ r.invoice_info.address_phone }}<br /></template>
<template v-if="r.invoice_info.bank_account">{{ r.invoice_info.bank_account }}<br /></template>
联系人 {{ r.invoice_info.contact_name }} {{ r.invoice_info.contact_phone }}
</p>
</td>
<td>
<span class="tag">{{ REQUEST_STATUS[r.status] }}</span>
<p v-if="r.ops_note" class="inv">原因:{{ r.ops_note }}</p>
<p v-if="r.invoice_no" class="inv">发票 {{ r.invoice_no }}</p>
</td>
<td class="num nowrap">{{ fmt(r.created_at) }}</td>
<td>
<div v-if="acting?.id === r.id" class="acts">
<template v-if="acting.kind === 'activate'">
<label class="cap">周期(月)<input v-model.number="months" class="input sm m" type="number" min="1" max="36" /></label>
<button type="button" class="btn sm primary" :disabled="busy" @click="act(r, 'activate')">确认开通</button>
</template>
<template v-else>
<input v-model="input" class="input sm" :placeholder="acting.kind === 'reject' ? '退回原因(用户看得到)' : '发票号'" />
<button type="button" class="btn sm primary" :disabled="busy || input.trim().length < 2" @click="act(r, acting.kind)">确认</button>
</template>
<button type="button" class="btn sm text" @click="acting = null">取消</button>
</div>
<div v-else class="acts">
<button v-if="r.status === 'submitted'" type="button" class="btn sm" :disabled="busy" @click="act(r, 'paid')">标记到账</button>
<button v-if="r.status === 'submitted' || r.status === 'paid'" type="button" class="btn sm primary" @click="start(r, 'activate')">开通</button>
<button v-if="r.status === 'submitted' || r.status === 'paid'" type="button" class="btn sm text" @click="start(r, 'reject')">退回</button>
<button v-if="r.status === 'activated' && !r.invoice_no" type="button" class="btn sm" @click="start(r, 'invoice')">登记发票</button>
</div>
</td>
</tr>
<tr v-if="!rows.length"><td colspan="6" class="muted">{{ filter === "open" ? "没有待处理的申请" : "还没有申请" }}</td></tr>
</tbody>
</table>
</section>
</template>
<style scoped>
.input.m {
width: 64px;
margin-left: var(--sp-1);
}
.hint-warn {
margin: 0 0 var(--sp-4);
}
</style>

View File

@@ -0,0 +1,143 @@
<script setup lang="ts">
/**
* 企业详情:成员(手机号脱敏)、套餐历史、额度流水(不带项目名称)、任务统计;调额度(G6:原因必填,不能扣成负数)。
* 不显示项目名称和任何业务内容(m6-plan §2.2)。
*/
import { onMounted, ref } from "vue";
import { day, fmt, JOB_KIND, ops, OpsError, QUOTA_KIND, type TenantDetail } from "../api";
const props = defineProps<{ id: string }>();
const t = ref<TenantDetail | null>(null);
const err = ref("");
async function load() {
try {
t.value = await ops<TenantDetail>(`/tenants/${props.id}`);
} catch (e) {
err.value = e instanceof OpsError ? e.message : "读取失败";
}
}
onMounted(load);
const amount = ref<number | null>(null);
const reason = ref("");
const msg = ref("");
const busy = ref(false);
async function adjust() {
if (!amount.value || reason.value.trim().length < 2) {
msg.value = "填写数量(可以是负数)和原因";
return;
}
busy.value = true;
msg.value = "";
try {
await ops(`/tenants/${props.id}/quota-adjustments`, { method: "POST", body: { unit: "parse", amount: amount.value, reason: reason.value.trim() } });
amount.value = null;
reason.value = "";
msg.value = "已调整";
await load();
} catch (e) {
msg.value = e instanceof OpsError ? e.message : "调整失败";
} finally {
busy.value = false;
}
}
const PLAN: Record<string, string> = { free: "免费版", pro: "专业版", enterprise: "企业版" };
const SUB: Record<string, string> = { scheduled: "排队中", active: "生效中", expired: "已过期", cancelled: "已结束" };
const JOB_STATUS: Record<string, string> = { queued: "排队", running: "进行中", succeeded: "成功", failed: "失败", cancelled: "取消" };
</script>
<template>
<p v-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
<template v-if="t">
<div class="page-title">
<div>
<h1>{{ t.name }}</h1>
<p>
{{ PLAN[t.plan.code ?? ""] ?? "—" }}<template v-if="t.plan.ends_at"> · 至 {{ day(t.plan.ends_at) }}</template><template v-if="t.plan.expired">(已到期)</template>
· 注册 {{ day(t.created_at) }} · 项目 {{ t.projects }} 个<template v-if="t.status === 'closed'"> · 已注销</template>
</p>
</div>
</div>
<div class="grid">
<section class="card">
<div class="card-h"><h2>成员</h2></div>
<table class="data">
<thead><tr><th>姓名</th><th>手机号</th><th>角色</th><th>最近登录</th></tr></thead>
<tbody>
<tr v-for="(m, i) in t.members" :key="i">
<td>{{ m.display_name }}</td><td class="num">{{ m.phone_hint ?? "—" }}</td><td>{{ m.role === "owner" ? "所有者" : m.role }}</td>
<td class="num">{{ fmt(m.last_login_at) }}</td>
</tr>
</tbody>
</table>
</section>
<section class="card">
<div class="card-h"><h2>调额度</h2><span class="cap muted r">解析余额 {{ t.balance ?? 0 }}</span></div>
<form class="adj" @submit.prevent="adjust">
<input v-model.number="amount" class="input" type="number" placeholder="数量,如 20 或 -5" aria-label="数量" />
<input v-model="reason" class="input" maxlength="200" placeholder="原因(必填,写进流水,用户看得到)" aria-label="原因" />
<button type="submit" class="btn primary" :disabled="busy">调整</button>
</form>
<p v-if="msg" class="cap muted">{{ msg }}</p>
<div class="card-h sub"><h2>任务</h2></div>
<p v-for="(st, kind) in t.jobs" :key="kind" class="cap">
{{ JOB_KIND[kind] ?? kind }}:<template v-for="(n, s, i) in st" :key="s">{{ i ? " · " : "" }}{{ JOB_STATUS[s] ?? s }} {{ n }}</template>
</p>
<p v-if="!Object.keys(t.jobs).length" class="cap muted">还没有任务</p>
</section>
</div>
<section class="card flush">
<div class="card-h"><h2>套餐</h2></div>
<table class="data">
<thead><tr><th>套餐</th><th>状态</th><th>来源</th><th>开始</th><th>结束</th><th>申请单</th></tr></thead>
<tbody>
<tr v-for="(s, i) in t.subscriptions" :key="i">
<td>{{ PLAN[s.plan_code] ?? s.plan_code }}</td><td>{{ SUB[s.status] ?? s.status }}</td>
<td>{{ s.source === "signup" ? "注册" : s.source === "manual" ? "运营开通" : s.source }}</td>
<td class="num">{{ day(s.starts_at) }}</td><td class="num">{{ day(s.ends_at) }}</td><td class="num">{{ s.request_no ?? "—" }}</td>
</tr>
</tbody>
</table>
</section>
<section class="card flush">
<div class="card-h"><h2>最近的额度流水</h2></div>
<table class="data">
<thead><tr><th>时间</th><th>类型</th><th class="r">数量</th><th>任务</th><th>说明</th></tr></thead>
<tbody>
<tr v-for="(e, i) in t.quota_entries" :key="i">
<td class="num nowrap">{{ fmt(e.created_at) }}</td>
<td>{{ QUOTA_KIND[e.kind] ?? e.kind }}<span v-if="e.by_ops" class="cap muted">(运营)</span></td>
<td class="num r">{{ e.amount > 0 ? `+${e.amount}` : e.amount }}</td>
<td>{{ e.ref_type ? JOB_KIND[e.ref_type] : "—" }}</td>
<td class="muted">{{ e.reason === "recheck_24h" ? "24 小时内复查" : e.reason ?? "" }}</td>
</tr>
</tbody>
</table>
</section>
</template>
</template>
<style scoped>
.grid {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
gap: var(--gap-card);
}
.card-h .r {
margin-left: auto;
}
.card-h.sub {
margin-top: var(--sp-6);
}
.adj {
display: grid;
grid-template-columns: 140px minmax(0, 1fr) auto;
gap: var(--sp-2);
}
p.cap {
margin: var(--sp-2) 0 0;
}
</style>

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
/** 企业查询:企业名称(模糊)、手机号(精确,按 HMAC 查,库里没有明文)、申请单号。 */
import { onMounted, ref } from "vue";
import { RouterLink } from "vue-router";
import { day, ops, OpsError, type Tenant } from "../api";
const q = ref("");
const items = ref<Tenant[]>([]);
const err = ref("");
async function search() {
err.value = "";
try {
items.value = (await ops<{ items: Tenant[] }>("/tenants", { query: { q: q.value.trim() || undefined } })).items;
} catch (e) {
err.value = e instanceof OpsError ? e.message : "读取失败";
}
}
onMounted(search);
const PLAN: Record<string, string> = { free: "免费版", pro: "专业版", enterprise: "企业版" };
</script>
<template>
<div class="page-title">
<div>
<h1>企业</h1>
<p>只看账号、套餐和用量,不看项目和文件内容。查看详情会留痕。</p>
</div>
</div>
<form class="toolbar" @submit.prevent="search">
<input v-model="q" class="input" placeholder="企业名称、手机号或申请单号(KT-…)" aria-label="搜索企业" />
<button type="submit" class="btn">搜索</button>
</form>
<p v-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
<section class="card flush">
<table class="data">
<thead>
<tr><th>企业</th><th>手机号</th><th>套餐</th><th class="r">解析余额</th><th class="r">项目</th><th>注册</th><th>状态</th></tr>
</thead>
<tbody>
<tr v-for="t in items" :key="t.id">
<td>
<RouterLink class="lnk" :to="{ name: 'tenant', params: { id: t.id } }">{{ t.name }}</RouterLink>
<span v-if="t.open_requests" class="tag">待处理申请 {{ t.open_requests }}</span>
</td>
<td class="num">{{ t.phone_hint ?? "—" }}</td>
<td>
{{ PLAN[t.plan.code ?? ""] ?? "—" }}
<span v-if="t.plan.expired" class="err-text">已到期</span>
<span v-else-if="t.plan.ends_at" class="cap muted">至 {{ day(t.plan.ends_at) }}</span>
</td>
<td class="num r">{{ t.balance ?? "—" }}</td>
<td class="num r">{{ t.projects }}</td>
<td class="num">{{ day(t.created_at) }}</td>
<td>{{ t.status === "closed" ? "已注销" : t.status === "active" ? "正常" : t.status }}</td>
</tr>
<tr v-if="!items.length"><td colspan="7" class="muted">没有找到</td></tr>
</tbody>
</table>
</section>
</template>

20
apps/admin/src/router.ts Normal file
View File

@@ -0,0 +1,20 @@
import { createRouter, createWebHistory } from "vue-router";
import { setUnauthorized } from "./api";
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: "/login", name: "login", component: () => import("./pages/LoginPage.vue"), meta: { bare: true } },
{ path: "/", redirect: "/requests" },
{ path: "/requests", name: "requests", component: () => import("./pages/RequestsPage.vue") },
{ path: "/tenants", name: "tenants", component: () => import("./pages/TenantsPage.vue") },
{ path: "/tenants/:id", name: "tenant", component: () => import("./pages/TenantPage.vue"), props: true },
{ path: "/feedback", name: "feedback", component: () => import("./pages/FeedbackPage.vue") },
{ path: "/failed-jobs", name: "failed-jobs", component: () => import("./pages/FailedJobsPage.vue") },
{ path: "/audit", name: "audit", component: () => import("./pages/AuditPage.vue") },
],
});
setUnauthorized(() => {
if (router.currentRoute.value.name !== "login") void router.replace({ name: "login" });
});

20
apps/admin/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client", "node"],
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"jsx": "preserve",
"noEmit": true,
"skipLibCheck": true,
"paths": { "@/*": ["./src/*"] }
},
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
}

25
apps/admin/vite.config.ts Normal file
View File

@@ -0,0 +1,25 @@
import { fileURLToPath, URL } from "node:url";
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite";
/**
* 运营后台(决策 M6-3):单独构建、单独部署,私有化不交付。开发时跑在 127.0.0.1:5174,
* 把 /ops 代理到 ops 进程 127.0.0.1:8019(bid-doctor ops)。样式只来自 docs/design/system。
*/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
"@design": fileURLToPath(new URL("../../docs/design", import.meta.url)),
// 和用户端共用组件样式(按钮、输入框、表格、卡片……),不复制(决策 M6-3)
"@web-styles": fileURLToPath(new URL("../web/src/styles", import.meta.url)),
},
},
server: {
host: "127.0.0.1",
port: 5174,
strictPort: true,
proxy: { "/ops": { target: "http://127.0.0.1:8019", changeOrigin: false } },
},
});

14
apps/web/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>标博士</title>
<!-- 运行时配置(接口地址、路由模式):部署时替换 config.js,不用重新构建(架构 §5.2 第 2 条) -->
<script src="%BASE_URL%config.js"></script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

31
apps/web/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "@bid-doctor/web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"typecheck": "vue-tsc --noEmit",
"preview": "vite preview"
},
"dependencies": {
"@bid-doctor/api-client": "workspace:*",
"@fontsource/barlow-semi-condensed": "5.3.0",
"@fontsource/noto-serif-sc": "5.3.0",
"@internationalized/date": "3.12.4",
"@lucide/vue": "1.50.0",
"@tanstack/vue-query": "5.104.1",
"pdfjs-dist": "6.4.299",
"reka-ui": "2.10.5",
"vue": "3.5.43",
"vue-router": "4.6.4"
},
"devDependencies": {
"@types/node": "24.19.2",
"@vitejs/plugin-vue": "6.0.9",
"typescript": "5.8.3",
"vite": "7.3.7",
"vue-tsc": "3.3.12"
}
}

View File

@@ -0,0 +1,9 @@
/* 标博士用户端运行时配置。部署时按环境替换本文件,不用重新构建。
* apiBaseUrl:接口地址。网页版默认同源 "/api/v1";桌面版写用户选择的服务器,如 "https://saas.example.com/api/v1"。
* routerMode:"history"(网页版)或 "hash"(桌面外壳以 file:// 或自定义协议加载时)。
* routerBase:history 模式下的路由基础路径,部署在子路径时改成如 "/app/"。 */
window.__BD_CONFIG__ = {
apiBaseUrl: "/api/v1",
routerMode: "history",
routerBase: "/",
};

7
apps/web/src/App.vue Normal file
View File

@@ -0,0 +1,7 @@
<script setup lang="ts">
import { RouterView } from "vue-router";
</script>
<template>
<RouterView />
</template>

View File

@@ -0,0 +1,192 @@
<script setup lang="ts">
/**
* 专业版开通 / 续费申请(原型 quota.html 的续费对话框,G8,决策 M6-6):
* 提交申请不扣额度、不用先付款;之后对公转账,运营确认到账后开通并开发票。页面不写价格(D2 未定),运营联系确认金额。
* 专票要地址电话和开户行账号。
*/
import { unwrap } from "@bid-doctor/api-client";
import { X } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from "reka-ui";
import { ref, watch } from "vue";
import SelectBox from "@/components/SelectBox.vue";
import { api, errorTitle, isApiError } from "@/lib/api";
import { qk } from "@/lib/keys";
import { useMe, type ActivationRequest } from "@/lib/queries";
import { toast } from "@/lib/toast";
const props = defineProps<{ renew?: boolean }>();
const open = defineModel<boolean>("open", { required: true });
const emit = defineEmits<{ submitted: [r: ActivationRequest] }>();
const qc = useQueryClient();
const me = useMe();
const title = ref("");
const type = ref<"general" | "special">("general");
const taxNo = ref("");
const addressPhone = ref("");
const bankAccount = ref("");
const contact = ref("");
const phone = ref("");
const errs = ref<Record<string, string>>({});
const err = ref("");
const busy = ref(false);
watch(open, (v) => {
if (!v) return;
title.value = me.data.value?.tenant.name ?? "";
contact.value = me.data.value?.user.display_name ?? "";
errs.value = {};
err.value = "";
});
function validate(): boolean {
const e: Record<string, string> = {};
if (title.value.trim().length < 2) e.title = "请填写发票抬头,一般就是企业名称";
const t = taxNo.value.trim().toUpperCase();
if (!t) e.taxNo = "请填写纳税人识别号,开发票要用";
else if (!/^[0-9A-Z]{15,20}$/.test(t)) e.taxNo = "纳税人识别号是 15 到 20 位数字或大写字母,请核对";
if (type.value === "special" && !addressPhone.value.trim()) e.addressPhone = "增值税专用发票要填地址电话";
if (type.value === "special" && !bankAccount.value.trim()) e.bankAccount = "增值税专用发票要填开户行和账号";
if (!contact.value.trim()) e.contact = "请填写联系人";
if (!/^1[3-9]\d{9}$/.test(phone.value.trim())) e.phone = "手机号应为 11 位数字";
errs.value = e;
return !Object.keys(e).length;
}
async function submit() {
if (busy.value || !validate()) return;
busy.value = true;
err.value = "";
try {
const r = await unwrap(
api.POST("/activation-requests", {
body: {
plan_code: "pro",
invoice_info: {
title: title.value.trim(),
tax_no: taxNo.value.trim().toUpperCase(),
invoice_type: type.value,
address_phone: addressPhone.value.trim() || undefined,
bank_account: bankAccount.value.trim() || undefined,
contact_name: contact.value.trim(),
contact_phone: phone.value.trim(),
},
},
}),
);
void qc.invalidateQueries({ queryKey: qk.quota });
open.value = false;
toast(`已提交${props.renew ? "续费" : "开通"}申请 ${r.request_no}`);
emit("submitted", r);
} catch (e) {
err.value = isApiError(e, "activation_pending") ? "已经有一份待处理的申请,等它处理完再提交" : errorTitle(e);
} finally {
busy.value = false;
}
}
</script>
<template>
<DialogRoot v-model:open="open">
<DialogPortal>
<DialogOverlay class="scrim">
<DialogContent class="dialog" :aria-describedby="undefined">
<DialogTitle as="h2">{{ renew ? "续费专业版" : "开通专业版" }}</DialogTitle>
<DialogClose class="icon-btn sm x" aria-label="关闭"><X class="ic" :stroke-width="1.5" /></DialogClose>
<form class="body" novalidate @submit.prevent="submit">
<p class="lead">
专业版按年{{ renew ? "续费" : "开通" }}:提交申请 → 对公转账 → 运营确认到账后开通,并开发票。<b>提交申请不扣额度,也不用先付款</b>;金额由运营联系你确认。
</p>
<div class="field fill" :class="{ err: errs.title }">
<label for="ad-title">发票抬头 <small>(必填)</small></label>
<input id="ad-title" v-model="title" class="input" maxlength="100" autocomplete="off" />
<span v-if="errs.title" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.title }}</span>
<span v-else class="msg">额度绑定注册的企业主体,不可转让</span>
</div>
<div class="grid2">
<div class="field">
<label for="ad-type">发票类型 <small>(必填)</small></label>
<SelectBox
id="ad-type"
:model-value="type"
:options="[
{ value: 'general', label: '增值税普通发票' },
{ value: 'special', label: '增值税专用发票' },
]"
@update:model-value="(v) => (type = v === 'special' ? 'special' : 'general')"
/>
</div>
<div class="field" :class="{ err: errs.taxNo }">
<label for="ad-tax">纳税人识别号 <small>(必填)</small></label>
<input id="ad-tax" v-model="taxNo" class="input num" maxlength="20" placeholder="18 位统一社会信用代码" autocomplete="off" />
<span v-if="errs.taxNo" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.taxNo }}</span>
</div>
</div>
<template v-if="type === 'special'">
<div class="field fill" :class="{ err: errs.addressPhone }">
<label for="ad-addr">地址、电话 <small>(必填)</small></label>
<input id="ad-addr" v-model="addressPhone" class="input" maxlength="200" autocomplete="off" />
<span v-if="errs.addressPhone" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.addressPhone }}</span>
</div>
<div class="field fill" :class="{ err: errs.bankAccount }">
<label for="ad-bank">开户行及账号 <small>(必填)</small></label>
<input id="ad-bank" v-model="bankAccount" class="input" maxlength="200" autocomplete="off" />
<span v-if="errs.bankAccount" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.bankAccount }}</span>
</div>
</template>
<div class="grid2">
<div class="field" :class="{ err: errs.contact }">
<label for="ad-contact">联系人 <small>(必填)</small></label>
<input id="ad-contact" v-model="contact" class="input" maxlength="40" autocomplete="off" />
<span v-if="errs.contact" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.contact }}</span>
</div>
<div class="field" :class="{ err: errs.phone }">
<label for="ad-phone">手机号 <small>(必填)</small></label>
<input id="ad-phone" v-model="phone" class="input num" maxlength="11" inputmode="numeric" autocomplete="off" />
<span v-if="errs.phone" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.phone }}</span>
</div>
</div>
<p v-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
<div class="foot">
<span class="cap muted left">不扣额度</span>
<DialogClose class="btn" type="button" :disabled="busy">取消</DialogClose>
<button type="submit" class="btn primary swap" :class="{ 'is-loading': busy }" :disabled="busy">
<span class="idle">提交申请</span><span class="busy">正在提交…</span>
</button>
</div>
</form>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</DialogRoot>
</template>
<style scoped>
.lead {
margin: 0 0 var(--sp-4);
color: var(--text-secondary);
}
.lead b {
color: var(--text-primary);
font-weight: var(--fw-medium);
}
.grid2 {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: var(--sp-4);
}
.body > .field + .field,
.body > .field + .grid2,
.body > .grid2 + .field,
.body > .grid2 + .grid2 {
margin-top: var(--sp-4);
}
.input.num {
font-family: var(--font-figures);
font-variant-numeric: tabular-nums;
font-size: var(--fs-figure-sm);
}
.hint-warn {
margin: var(--sp-4) 0 0;
}
</style>

View File

@@ -0,0 +1,123 @@
<script setup lang="ts">
/**
* 登录后的页面框架(设计规范 v0.2 §5.3):96px 墨色渐变侧栏 + 透明顶栏(滚动后半透明白)+ 内容区。
* 挂载期间保持一条事件流连接,把服务端的变化推进查询缓存。
*/
import { Award, House, LogOut, Wallet } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { RouterLink, RouterView, useRoute, useRouter } from "vue-router";
import logoUrl from "@design/logo/svg/logo-vertical-reverse.svg?url";
import NotificationBell from "./NotificationBell.vue";
import Toasts from "./Toasts.vue";
import { api } from "@/lib/api";
import { provideCrumbs } from "@/lib/crumbs";
import { startEventStream } from "@/lib/events";
import { PLAN_KEY, quotaUnit } from "@/lib/labels";
import { useMe, useQuota } from "@/lib/queries";
const route = useRoute();
const router = useRouter();
const qc = useQueryClient();
const crumbs = provideCrumbs();
const me = useMe();
const quota = useQuota();
const plan = computed(() => me.data.value?.plan);
const planKey = computed(() => (plan.value ? PLAN_KEY[plan.value.code] : "free"));
const name = computed(() => me.data.value?.user.display_name ?? "");
/** 顶栏额度:只列本期有发放的单位;用到 ≥ 80% 变赭色(Q9) */
const accounts = computed(() => quota.data.value?.accounts.filter((a) => a.period_granted > 0) ?? []);
const quotaTip = computed(
() => "本期剩余额度:" + accounts.value.map((a) => `${quotaUnit(a.unit)} ${a.balance} / ${a.period_granted} 次`).join(","),
);
const onWorkbench = computed(() => route.matched.some((r) => r.name === "workbench") || route.path.startsWith("/projects"));
const scrolled = ref(false);
const onScroll = () => (scrolled.value = window.scrollY > 4);
let stopEvents: (() => void) | null = null;
onMounted(() => {
window.addEventListener("scroll", onScroll, { passive: true });
stopEvents = startEventStream(qc);
});
onBeforeUnmount(() => {
window.removeEventListener("scroll", onScroll);
stopEvents?.();
});
const loggingOut = ref(false);
async function logout() {
loggingOut.value = true;
try {
await api.POST("/auth/logout");
} finally {
stopEvents?.();
stopEvents = null;
qc.clear();
await router.replace({ name: "login" });
}
}
</script>
<template>
<div class="app-frame">
<aside class="sidebar">
<RouterLink class="brand" :to="{ name: 'workbench' }" aria-label="标博士 · 工作台">
<img :src="logoUrl" alt="" />
</RouterLink>
<nav class="nav" aria-label="主导航">
<RouterLink :to="{ name: 'workbench' }" :aria-current="onWorkbench ? 'page' : undefined">
<House class="ic" :stroke-width="1.5" /><span>工作台</span>
</RouterLink>
<RouterLink :to="{ name: 'qualifications' }" :aria-current="route.name === 'qualifications' ? 'page' : undefined">
<Award class="ic" :stroke-width="1.5" /><span>资质库</span>
</RouterLink>
<RouterLink :to="{ name: 'quota' }" :aria-current="route.name === 'quota' ? 'page' : undefined">
<Wallet class="ic" :stroke-width="1.5" /><span>额度</span>
</RouterLink>
</nav>
<RouterLink class="me" :to="{ name: 'account' }" :aria-current="route.name === 'account' ? 'page' : undefined" aria-label="账号">
<span class="avatar" aria-hidden="true">{{ name.slice(0, 1) }}</span>
<b class="uname" :title="name">{{ name }}</b>
<span v-if="plan" class="plan" :data-plan="planKey">{{ plan.name }}</span>
</RouterLink>
</aside>
<main class="main">
<header class="top" :class="{ scrolled }">
<div class="crumbs">
<span class="org">{{ me.data.value?.tenant.name }}</span>
<template v-for="(c, i) in crumbs" :key="i">
<span aria-hidden="true">/</span>
<RouterLink v-if="c.to && i < crumbs.length - 1" :to="c.to" :title="c.label">{{ c.label }}</RouterLink>
<b v-else :title="c.label">{{ c.label }}</b>
</template>
</div>
<div class="top-right">
<RouterLink v-if="accounts.length" class="quota-chip tip-below tip-left" :to="{ name: 'quota' }" :data-tip="quotaTip" :aria-label="quotaTip">
<template v-for="(a, i) in accounts" :key="a.unit">
<span v-if="i > 0" class="sep"></span>
<span class="q" :class="{ warn: (a.used_ratio ?? 0) >= 0.8 }">{{ quotaUnit(a.unit) }}<span class="fig">{{ a.balance }}</span></span>
</template>
</RouterLink>
<NotificationBell />
<span class="vsep" aria-hidden="true"></span>
<button class="icon-btn tip-below tip-left" type="button" aria-label="退出登录" data-tip="退出登录" :disabled="loggingOut" @click="logout">
<LogOut class="ic lg" :stroke-width="1.5" />
</button>
</div>
</header>
<!-- 套餐到期后只读(决策 M6-5):每一页顶上都提醒,额度页里有续费入口 -->
<div v-if="plan?.read_only && route.name !== 'quota'" class="expired-bar" role="note">
<span class="ico w" aria-hidden="true">!</span>
<p>专业版已到期,数据只读:可以查看、下载已生成的报告,不能新建项目、解析和检查。</p>
<RouterLink class="btn sm" :to="{ name: 'quota' }">续费</RouterLink>
</div>
<RouterView />
</main>
<Toasts />
</div>
</template>

View File

@@ -0,0 +1,192 @@
<script setup lang="ts">
/**
* 清单项(设计规范 §7.5、§7.6、§7.16):重要性 + 标题 + 标签(来源、分类、包)+ 出处。
* AI 产出的文字带「AI」标签、正文墨色;待确认区的条目另加左侧 2px 蓝铅笔虚线(.ai-block)。
* 当前条(.sel)= 浅底 + 3px 墨色竖条(Q3);操作按钮放在 actions 插槽,只在当前条和待确认条目上显示。
*/
import { computed } from "vue";
import CitationBlock from "./CitationBlock.vue";
import { groupCitations, type CitationGroup } from "@/lib/citations";
import { RouterLink } from "vue-router";
import { category, CHECK_VERDICT, failureText, fmtDay, IMPORTANCE_MARK, IMPORTANCE_NAME, pendingReason, specFacts } from "@/lib/labels";
import type { CheckItemResult, ChecklistItem, Citation, QualificationMatch } from "@/lib/queries";
const props = defineProps<{
item: ChecklistItem;
pending?: boolean;
selected?: boolean;
activeCitation?: string | null;
/** 最近一次检查对这一条的结论(C9);没参与检查的不传 */
check?: CheckItemResult | null;
/** 资质库里的情况(资格条件、评分项里认出证书类别的条目);资质库是用户自己录的,墨色,不用蓝铅笔色 */
library?: QualificationMatch[] | null;
/** library 按哪天判断有效期:「投标截止日」或「今天」 */
libraryAsOf?: string;
}>();
const emit = defineEmits<{ select: []; openCitation: [g: CitationGroup]; openBid: [c: Citation]; feedback: []; findings: [] }>();
/** 一条结论最多默认展开 2 处出处,其余写「另有 N 处」(§7.3);跨页的一句话算一处 */
const groups = computed(() => groupCitations(props.item.citations));
const shown = computed(() => groups.value.slice(0, 2));
const more = computed(() => groups.value.length - shown.value.length);
const mark = computed(() => IMPORTANCE_MARK[props.item.importance] ?? "");
/** AI 摘要和标题不同时才单独显示,避免重复 */
const summary = computed(() => (props.item.summary && props.item.summary !== props.item.title ? props.item.summary : ""));
/** 结构化取值:分值、参数要求、日期等,都来自原文(取值校验通过的才进正式清单) */
const facts = computed(() => specFacts(props.item.spec));
/** 检查结论:已响应、默认接受是好的;其余在查标页有问题可处理 */
const checkOk = computed(() => props.check?.verdict === "responded" || props.check?.verdict === "implicit_accepted");
const bidCites = computed(() => groupCitations(props.check?.bid_citations ?? []).slice(0, 2));
</script>
<template>
<div class="item" :class="{ pq: pending, sel: selected }" :data-item="item.id" :tabindex="selected ? 0 : -1" @click="emit('select')">
<span class="imp" :title="IMPORTANCE_NAME[item.importance]">
{{ mark }}<span v-if="mark" class="vh">{{ IMPORTANCE_NAME[item.importance] }}</span>
</span>
<div :class="{ 'ai-block': pending }">
<div class="t">{{ item.title }}</div>
<div class="meta">
<span v-if="item.origin === 'ai'" class="tag ai">AI</span>
<span v-else-if="item.origin === 'human' || !item.origin_document || item.origin_document.label === '招标文件'" class="tag">{{ item.origin === "human" ? "人工添加" : "规则" }}</span>
<span v-if="item.origin !== 'human' && item.origin_document && item.origin_document.label !== '招标文件'" class="tag">{{ item.origin_document.label }}</span>
<span v-for="c in item.categories" :key="c" class="tag">{{ category(c) }}</span>
<span class="tag">{{ item.lot_no ? `采购包 ${item.lot_no}` : "通用" }}</span>
<span v-if="pending && item.pending_reason" class="flag">{{ [pendingReason(item.pending_reason), failureText(item.ai_values)].filter(Boolean).join(" · ") }}</span>
<span v-if="item.no_citation_ack" class="flag">人工确认、无出处</span>
<span v-if="item.edited_fields.length" class="flag">已修改</span>
<span v-if="item.superseded_by && !item.superseded_by.confirmed" class="flag">已被{{ item.superseded_by.document_label }} 更新,待确认</span>
</div>
<p v-if="facts.length" class="facts">
<span v-for="f in facts" :key="f">{{ f }}</span>
</p>
<p v-if="item.evidence_required" class="evid"><span class="muted">证明材料</span>{{ item.evidence_required }}</p>
<p v-if="summary" class="sum"><span class="tag ai">AI 摘要</span>{{ summary }}</p>
<div v-if="shown.length" class="cites">
<CitationBlock v-for="g in shown" :key="g.head.id" :citation="g.head" :tail="g.tail" :active="activeCitation === g.head.id" @open="emit('openCitation', g)" />
<p v-if="more > 0" class="cap muted">另有 <span class="fig">{{ more }}</span> 处出处</p>
</div>
<div v-else class="cites">
<div class="cite missing"><p>{{ item.no_citation_ack || item.origin === "human" ? "没有出处" : "没有出处,确认前不参与检查" }}</p></div>
</div>
<p v-for="m in library ?? []" :key="m.family" class="lib cap" @click.stop>
<span class="muted">资质库</span>
<template v-if="m.record">
<span>《{{ m.record.name }}》</span>
<span v-if="m.state === 'expired'" class="warn-text">有效期至 <span class="fig">{{ fmtDay(m.record.valid_to!) }}</span>,早于{{ libraryAsOf }}</span>
<span v-else>{{ m.record.valid_to ? `有效期至 ${fmtDay(m.record.valid_to)}` : "长期有效" }}</span>
<span v-if="m.count > 1" class="muted">(这一类共 <span class="fig">{{ m.count }}</span> 条,这里是有效期最晚的)</span>
</template>
<span v-else class="muted">没有录入「{{ m.family }}」</span>
<RouterLink v-if="m.state !== 'valid'" class="lnk" :to="{ name: 'qualifications' }">去资质库</RouterLink>
</p>
<p v-if="check" class="chk cap" :class="{ bad: !checkOk }" @click.stop>
<span class="muted">查标</span>
<b>{{ CHECK_VERDICT[check.verdict] }}</b>
<button
v-for="g in bidCites"
:key="g.head.id"
type="button"
class="cite-src inline"
:class="{ on: activeCitation === g.head.id }"
@click="emit('select'); emit('openBid', g.head)"
>
{{ g.head.document_label }} 第 <span class="fig">{{ g.head.page_no }}</span> 页
</button>
<span v-if="check.reused" class="muted">沿用上次结论</span>
<button v-if="!checkOk" type="button" class="lnk" @click="emit('findings')">去查标页处理</button>
<button v-else-if="check.verdict === 'responded' && selected" type="button" class="lnk feedback" @click="emit('feedback')">判断有误?</button>
</p>
<div v-if="$slots.actions && (pending || selected)" class="f-actions" @click.stop>
<slot name="actions" />
</div>
</div>
</div>
</template>
<style scoped>
.item {
grid-template-columns: var(--sp-6) minmax(0, 1fr);
padding: var(--sp-3) var(--card-x) var(--sp-3) var(--sp-3);
border-top: var(--line) solid var(--border-subtle);
}
.item {
cursor: default;
}
.item:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: -2px;
}
.item .t {
font-weight: var(--fw-medium);
}
.cites {
margin-top: var(--sp-2);
}
.sum {
display: flex;
gap: var(--sp-2);
align-items: baseline;
margin: var(--sp-2) 0 0;
}
.facts {
display: flex;
flex-wrap: wrap;
gap: var(--sp-1) var(--sp-3);
margin: var(--sp-2) 0 0;
font-size: var(--fs-caption);
color: var(--text-secondary);
}
.facts span + span::before {
content: "·";
margin-right: var(--sp-3);
color: var(--text-disabled);
}
.evid {
display: flex;
gap: var(--sp-2);
margin: var(--sp-1) 0 0;
font-size: var(--fs-caption);
}
.evid .muted {
flex: none; /* 标签不跟着长文本一起被挤成竖排 */
}
.t {
overflow-wrap: anywhere; /* 型号、参数这类没有空格的长串 */
}
.cites .cap {
margin: var(--sp-1) 0 0;
}
/* 检查结论:有问题的加深,不用红(级别和处理都在查标页);投标侧出处同样是蓝铅笔色 */
.chk {
display: flex;
flex-wrap: wrap;
gap: var(--sp-1) var(--sp-2);
align-items: baseline;
margin: var(--sp-2) 0 0;
}
.chk b {
font-weight: var(--fw-medium);
}
.chk:not(.bad) b {
color: var(--text-secondary);
}
.chk .feedback {
margin-left: auto;
}
.lib {
display: flex;
flex-wrap: wrap;
gap: var(--sp-1) var(--sp-2);
align-items: baseline;
margin: var(--sp-2) 0 0;
}
.lib + .lib,
.lib + .chk {
margin-top: var(--sp-1);
}
.warn-text {
color: var(--warn-text);
}
</style>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
/**
* 出处(设计规范 §7.3):左侧 2px 蓝铅笔竖线,上面是「文件 · 第 N 页」,下面照录原句。
* 点出处在原文查看器里翻到那一页、高亮原句;当前打开的出处竖线加粗(.on)。
* 一句话跨页时(tail 是下一页的接续部分)合成一处显示:「第 47–48 页」,原句接起来。
* TODO:OCR 识别出的原句前加「OCR」小字(等接口给出页的 text_source)。
*/
import { computed } from "vue";
import type { Citation } from "@/lib/queries";
const props = defineProps<{ citation: Citation; tail?: Citation[]; active?: boolean }>();
const lastPage = computed(() => props.tail?.at(-1)?.page_no);
const quote = computed(() => [props.citation, ...(props.tail ?? [])].map((c) => c.quote).join(""));
const emit = defineEmits<{ open: [] }>();
</script>
<template>
<div class="cite" :class="{ on: active }" role="button" tabindex="0" :aria-pressed="active" @click.stop="emit('open')" @keydown.enter.prevent.stop="emit('open')">
<span class="cite-src">
<template v-if="citation.document_label">{{ citation.document_label }} · </template>第 <span class="fig">{{ citation.page_no }}</span><template v-if="lastPage">–<span class="fig">{{ lastPage }}</span></template> 页
</span>
<q>{{ quote }}</q>
</div>
</template>
<style scoped>
.cite {
cursor: pointer;
border-radius: 0 var(--radius-xs) var(--radius-xs) 0;
transition: background-color var(--dur-fast) var(--ease);
}
.cite:hover {
background: var(--ai-soft);
}
.cite:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
</style>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
/**
* 确认对话框(设计规范 §7.14):宽 480,标题写要做的事,正文写后果,主操作在右。
* 交互基础用 Reka UI 的 AlertDialog(焦点锁定、Esc 关闭、关闭后焦点回到原处),外观来自 app.css 的 .scrim / .dialog。
*/
import {
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogRoot,
AlertDialogTitle,
} from "reka-ui";
defineProps<{
title: string;
okText: string;
cancelText?: string;
/** 主操作进行中:按钮文字换成 busyText,宽度不变 */
busy?: boolean;
busyText?: string;
}>();
const open = defineModel<boolean>("open", { required: true });
const emit = defineEmits<{ ok: [] }>();
</script>
<template>
<AlertDialogRoot v-model:open="open">
<AlertDialogPortal>
<AlertDialogOverlay class="scrim">
<AlertDialogContent class="dialog">
<AlertDialogTitle as="h2">{{ title }}</AlertDialogTitle>
<AlertDialogDescription as="div" class="body"><slot /></AlertDialogDescription>
<div class="foot">
<AlertDialogCancel class="btn" :disabled="busy">{{ cancelText ?? "取消" }}</AlertDialogCancel>
<button type="button" class="btn primary swap" :class="{ 'is-loading': busy }" :aria-busy="busy" :disabled="busy" @click="emit('ok')">
<span class="idle">{{ okText }}</span><span class="busy">{{ busyText ?? "正在处理…" }}</span>
</button>
</div>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialogPortal>
</AlertDialogRoot>
</template>

View File

@@ -0,0 +1,112 @@
<script setup lang="ts">
/**
* 日期输入(设计规范 §7.22、§9.3):可以直接键入(接受 2026/11/20、2026年11月20日 等,离开时整理成 2026-11-20),
* 右侧按钮打开日期选择器。选择器周一开始;今天 1px 边框,选中墨底白字;非本月的格子留空;不可选的日期只变灰。
* 交互基础用 Reka UI 的 Popover + Calendar(键盘方向键选日期、Esc 关闭、焦点回到按钮),外观来自 app.css 的 .dt / .cal。
*/
import { getLocalTimeZone, parseDate, today, type DateValue } from "@internationalized/date";
import { Calendar, ChevronLeft, ChevronRight } from "@lucide/vue";
import {
CalendarCell,
CalendarCellTrigger,
CalendarGrid,
CalendarGridBody,
CalendarGridHead,
CalendarGridRow,
CalendarHeadCell,
CalendarHeader,
CalendarHeading,
CalendarNext,
CalendarPrev,
CalendarRoot,
PopoverAnchor,
PopoverContent,
PopoverPortal,
PopoverRoot,
PopoverTrigger,
} from "reka-ui";
import { computed, ref } from "vue";
import { parseDay } from "@/lib/labels";
const props = defineProps<{ id?: string; placeholder?: string; disabled?: boolean; label?: string }>();
const text = defineModel<string>({ required: true });
const emit = defineEmits<{ blur: [] }>();
const open = ref(false);
const input = ref<HTMLInputElement | null>(null);
defineExpose({ select: () => input.value?.select(), focus: () => input.value?.focus() });
/** 输入框里能认出的日期,选择器打开时停在这个月 */
const value = computed<DateValue | undefined>(() => {
const d = parseDay(text.value);
return d ? parseDate(d) : undefined;
});
function pick(v: DateValue | undefined) {
if (!v) return;
text.value = v.toString();
open.value = false;
}
function onBlur() {
const d = parseDay(text.value);
if (d) text.value = d;
emit("blur");
}
</script>
<template>
<PopoverRoot v-model:open="open">
<PopoverAnchor as-child>
<div class="dt">
<input
:id="id"
ref="input"
v-model="text"
class="input"
:placeholder="placeholder ?? '例如:2026-11-20'"
:disabled="disabled"
autocomplete="off"
@blur="onBlur"
/>
<PopoverTrigger as-child>
<button type="button" class="icon-btn sm" :aria-label="`打开日期选择器${label ? `:${label}` : ''}`" :disabled="disabled">
<Calendar class="ic" :stroke-width="1.5" />
</button>
</PopoverTrigger>
</div>
</PopoverAnchor>
<PopoverPortal>
<PopoverContent class="cal" align="start" :side-offset="4" :aria-label="label ?? '选择日期'">
<CalendarRoot
v-slot="{ grid, weekDays }"
:model-value="value"
:default-placeholder="value ?? today(getLocalTimeZone())"
locale="zh-CN"
:week-starts-on="1"
weekday-format="narrow"
initial-focus
@update:model-value="pick"
>
<CalendarHeader class="cal-h">
<CalendarPrev class="icon-btn sm" aria-label="上个月"><ChevronLeft class="ic" :stroke-width="1.5" /></CalendarPrev>
<CalendarHeading v-slot="{ headingValue }"><b>{{ headingValue }}</b></CalendarHeading>
<CalendarNext class="icon-btn sm" aria-label="下个月"><ChevronRight class="ic" :stroke-width="1.5" /></CalendarNext>
</CalendarHeader>
<CalendarGrid v-for="month in grid" :key="month.value.toString()" class="cal-grid">
<CalendarGridHead>
<CalendarGridRow>
<CalendarHeadCell v-for="d in weekDays" :key="d" class="wd">{{ d }}</CalendarHeadCell>
</CalendarGridRow>
</CalendarGridHead>
<CalendarGridBody>
<CalendarGridRow v-for="(week, i) in month.rows" :key="i">
<CalendarCell v-for="d in week" :key="d.toString()" :date="d">
<CalendarCellTrigger :day="d" :month="month.value" class="day" />
</CalendarCell>
</CalendarGridRow>
</CalendarGridBody>
</CalendarGrid>
</CalendarRoot>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
</template>

View File

@@ -0,0 +1,158 @@
<script setup lang="ts">
/**
* 页面右侧的原文区(左右布局,同读标页):一个或两个原文查看器。
* 两个时(查标:招标文件、投标文件)横向排开,当前的那个占满,另一个只在边上露出一窄条(--peek);
* 点露出的那一条、或点到另一边的出处,就滑过去,原来那个从另一侧露出一窄条。两边各自记着翻到的页和高亮。
* 露出的那一条在截断处淡入页面底色,不留硬边;标签上的箭头指向文件所在的一侧,悬停时文件先往外探出一点。
*/
import { ChevronsLeft, ChevronsRight } from "@lucide/vue";
import { computed } from "vue";
import PdfViewer, { type ViewerTarget } from "@/components/PdfViewer.vue";
export interface StageSlot {
key: string;
label: string;
doc: { id: string; label: string } | null;
target: ViewerTarget | null;
emptyText?: string;
}
const props = defineProps<{ slots: StageSlot[] }>();
const active = defineModel<string>("active", { required: true });
const index = computed(() => Math.max(0, props.slots.findIndex((s) => s.key === active.value)));
</script>
<template>
<div class="stage" :class="{ multi: slots.length > 1, second: index > 0 }">
<div class="track">
<div v-for="(s, i) in slots" :key="s.key" class="panel" :class="i === index ? 'on' : i < index ? 'at-left' : 'at-right'" :inert="i !== index || undefined">
<PdfViewer :document-id="s.doc?.id" :document-label="s.doc?.label ?? s.label" :target="s.target" :empty-text="s.emptyText" />
</div>
</div>
<!-- 露出的那一条:放在 track 外面,不跟着 inert -->
<button
v-for="(s, i) in slots"
v-show="slots.length > 1 && i !== index"
:key="`peek-${s.key}`"
type="button"
class="peek"
:class="i < index ? 'left' : 'right'"
:aria-label="`看${s.label}`"
@click="active = s.key"
>
<!-- 箭头指向展开的方向:右边露出的往左拉开,左边露出的往右拉开;文字两头各一个 -->
<span class="tab">
<component :is="i < index ? ChevronsRight : ChevronsLeft" class="ic" :stroke-width="1.75" aria-hidden="true" />
<span class="t">{{ s.label }}</span>
<component :is="i < index ? ChevronsRight : ChevronsLeft" class="ic" :stroke-width="1.75" aria-hidden="true" />
</span>
</button>
</div>
</template>
<style scoped>
.stage {
--peek: 80px;
--stage-gap: var(--sp-2);
position: relative;
overflow: hidden;
min-height: 0;
height: 100%;
}
.track {
display: flex;
gap: var(--stage-gap);
height: 100%;
transition: transform calc(var(--dur-base) * 1.5) var(--ease);
}
/* 第二个是当前的:整条左移,第一个只露出右边 --peek 宽 */
.stage.second .track {
transform: translateX(calc(-100% + 2 * var(--peek) + var(--stage-gap)));
}
.panel {
flex: 1;
min-width: 0;
height: 100%;
}
.stage.multi .panel {
flex: 0 0 calc(100% - var(--peek) - var(--stage-gap));
}
.panel:not(.on) {
opacity: 0.8;
transition: opacity var(--dur-base) var(--ease);
}
/* 露出的一窄条:留下文件名(挪到露出的那一侧),藏起翻页按钮,看得出是哪份文件 */
.panel:not(.on) :deep(.pager) {
visibility: hidden;
}
.panel.at-left :deep(.viewer-bar) {
flex-direction: row-reverse;
}
.panel.at-left :deep(.doc-name) {
text-align: right;
}
/* 悬停露出的那一条:文件往外探出一点,提示可以切过去 */
.stage:has(.peek.right:hover) .track {
transform: translateX(-10px);
}
.stage.second:has(.peek.left:hover) .track {
transform: translateX(calc(-100% + 2 * var(--peek) + var(--stage-gap) + 10px));
}
.peek {
position: absolute;
top: 0;
bottom: 0;
z-index: 2;
display: grid;
place-items: center;
width: var(--peek);
padding: 0;
border: 0;
cursor: pointer;
}
/* 截断处淡入页面底色:外侧不透明,往里渐隐,看不出硬切的边 */
.peek.right {
right: 0;
background: linear-gradient(to left, var(--bg-page), transparent 35%);
}
.peek.left {
left: 0;
background: linear-gradient(to right, var(--bg-page), transparent 35%);
}
.tab {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--sp-1);
padding: var(--sp-2) 3px;
border: var(--line) solid var(--border-subtle);
border-radius: var(--radius-ctl);
background: var(--bg-surface);
color: var(--text-secondary);
transition: color var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease);
}
.tab .t {
writing-mode: vertical-rl;
font-size: var(--fs-caption);
letter-spacing: 2px;
}
.tab .ic {
width: 14px;
height: 14px;
transition: transform var(--dur-fast) var(--ease);
}
.peek:hover .tab,
.peek:focus-visible .tab {
color: var(--text-primary);
border-color: var(--c-ink);
}
.peek.left:hover .ic {
transform: translateX(2px);
}
.peek.right:hover .ic {
transform: translateX(-2px);
}
.peek:focus-visible {
outline: 0;
}
</style>

View File

@@ -0,0 +1,98 @@
<script setup lang="ts">
/**
* 反馈:判断有误(J5)。查标页的问题(误报)和读标页的「已响应」结论(漏报)共用。
* 反馈只用于改进检查规则,不改变处理状态;理由和补充说明合成一句存进 note。
*/
import { unwrap } from "@bid-doctor/api-client";
import { ref, watch } from "vue";
import ConfirmDialog from "./ConfirmDialog.vue";
import { api, errorTitle } from "@/lib/api";
import { toast } from "@/lib/toast";
export type FeedbackTarget = {
objectType: "finding" | "checklist_item";
objectId: string;
title: string;
/** false_positive:报了不该报的;false_negative:说已响应,其实没有 */
kind: "false_positive" | "false_negative";
reasons: string[];
/** 对话框底部那句说明:反馈不改变什么 */
note: string;
};
const props = defineProps<{ projectId: string; target: FeedbackTarget | null }>();
const emit = defineEmits<{ close: [] }>();
const reason = ref("");
const text = ref("");
const busy = ref(false);
watch(
() => props.target,
(t) => {
reason.value = t?.reasons[0] ?? "";
text.value = "";
},
);
async function send() {
const t = props.target;
if (!t) return;
busy.value = true;
try {
await unwrap(
api.POST("/feedback", {
body: {
project_id: props.projectId,
object_type: t.objectType,
object_id: t.objectId,
kind: t.kind,
note: [reason.value, text.value.trim()].filter(Boolean).join(":").slice(0, 1000),
},
}),
);
emit("close");
toast("已收到反馈");
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
busy.value = false;
}
}
</script>
<template>
<ConfirmDialog
:open="!!target"
title="反馈:判断有误"
ok-text="提交反馈"
busy-text="正在提交…"
:busy="busy"
@update:open="(v) => { if (!v) emit('close'); }"
@ok="send"
>
<p class="muted fb-title">「{{ target?.title }}」</p>
<div class="fb-choices" role="radiogroup" aria-label="哪里不对">
<label v-for="r in target?.reasons ?? []" :key="r" class="choice"><input v-model="reason" type="radio" class="ck" name="fb" :value="r" />{{ r }}</label>
</div>
<div class="field fill fb-note">
<label for="fb-note">补充说明 <small>(选填)</small></label>
<textarea id="fb-note" v-model="text" class="input" maxlength="200"></textarea>
<span class="msg">{{ target?.note }}</span>
</div>
</ConfirmDialog>
</template>
<style scoped>
.fb-title {
margin: 0;
}
.fb-choices {
display: flex;
flex-direction: column;
gap: var(--sp-2);
margin-top: var(--sp-3);
}
.fb-note {
margin-top: var(--sp-4);
}
</style>

View File

@@ -0,0 +1,171 @@
<script setup lang="ts">
/**
* 修改项目基本信息的一个字段(FR-M2-03,project-info-plan §2)。按字段类型给输入控件;人工值优先,可以恢复 AI 值。
* 金额可写「286万」「2,860,000」,服务端换成元(PI7);投标截止时间按北京时间录入。修改留痕(审计)。
*/
import { unwrap, type Schemas } from "@bid-doctor/api-client";
import { X } from "@lucide/vue";
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from "reka-ui";
import { computed, ref, watch } from "vue";
import DateInput from "@/components/DateInput.vue";
import SelectBox from "@/components/SelectBox.vue";
import { api, errorTitle, isApiError } from "@/lib/api";
import { BOOL_FIELDS, FIELD_LABEL, fieldText, LEGAL_REGIME, MONEY_FIELDS, parseDay } from "@/lib/labels";
import type { ProjectField } from "@/lib/queries";
type Key = Schemas["ProjectFieldKey"];
const props = defineProps<{ projectId: string; fieldKey: Key | null; field: ProjectField | null }>();
const open = defineModel<boolean>("open", { required: true });
const emit = defineEmits<{ saved: [f: ProjectField, restored: boolean] }>();
const text = ref("");
const flag = ref<boolean | null>(null);
const day = ref("");
const time = ref("");
const err = ref("");
const busy = ref(false);
const key = computed(() => props.fieldKey);
const kind = computed(() =>
!key.value ? "text" : MONEY_FIELDS.has(key.value) ? "money" : BOOL_FIELDS.has(key.value) ? "bool" : key.value === "legal_regime" ? "regime" : key.value === "deadline_at" ? "time" : "text",
);
const canRestore = computed(() => props.field?.source === "human" && props.field.ai_value != null);
/** 时间按北京时间拆成日期和时分 */
function splitTime(iso: string): [string, string] {
const fmt = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false });
const parts = Object.fromEntries(fmt.formatToParts(new Date(iso)).map((x) => [x.type, x.value]));
return [`${parts.year}-${parts.month}-${parts.day}`, `${parts.hour === "24" ? "00" : parts.hour}:${parts.minute}`];
}
watch(open, (v) => {
if (!v) return;
const val = props.field?.value;
err.value = "";
text.value = kind.value === "money" && val != null ? String(val) : typeof val === "string" ? val : kind.value === "regime" ? "unknown" : "";
flag.value = typeof val === "boolean" ? val : null;
[day.value, time.value] = kind.value === "time" && typeof val === "string" ? splitTime(val) : ["", "09:30"];
});
function value(): string | boolean | null | undefined {
if (kind.value === "bool") return flag.value;
if (kind.value === "time") {
const d = parseDay(day.value);
const m = /^(\d{1,2})[::](\d{2})$/.exec(time.value.trim());
if (!d || !m || Number(m[1]) > 23 || Number(m[2]) > 59) {
err.value = "请填日期和时间,例如:2026-11-20 和 09:30";
return undefined;
}
return `${d}T${m[1]!.padStart(2, "0")}:${m[2]}:00+08:00`;
}
const t = text.value.trim();
if (key.value === "name" && !t) {
err.value = "项目名称不能为空";
return undefined;
}
return t || null;
}
async function save(restore = false) {
if (!key.value || busy.value) return;
const v = restore ? null : value();
if (v === undefined) return;
busy.value = true;
err.value = "";
try {
const f = await unwrap(
api.PUT("/projects/{project_id}/fields/{field_key}", { params: { path: { project_id: props.projectId, field_key: key.value } }, body: { value: v } }),
);
emit("saved", f, restore);
open.value = false;
} catch (e) {
const detail = isApiError(e, "validation_failed") ? (e.problem as { errors?: { message: string }[] }).errors?.[0]?.message : undefined;
err.value = detail ?? errorTitle(e);
} finally {
busy.value = false;
}
}
</script>
<template>
<DialogRoot v-model:open="open">
<DialogPortal>
<DialogOverlay class="scrim">
<DialogContent class="dialog" :aria-describedby="undefined">
<DialogTitle as="h2">修改{{ fieldKey ? FIELD_LABEL[fieldKey] : "" }}</DialogTitle>
<DialogClose class="icon-btn sm x" aria-label="关闭"><X class="ic" :stroke-width="1.5" /></DialogClose>
<form class="body" novalidate @submit.prevent="save()">
<div v-if="kind === 'text'" class="field fill">
<label for="fd-text">{{ fieldKey ? FIELD_LABEL[fieldKey] : "" }}</label>
<input id="fd-text" v-model="text" class="input" maxlength="500" autocomplete="off" />
</div>
<div v-else-if="kind === 'money'" class="field fill">
<label for="fd-money">{{ fieldKey ? FIELD_LABEL[fieldKey] : "" }}</label>
<input id="fd-money" v-model="text" class="input num" placeholder="例如:2,860,000 或 286万" autocomplete="off" />
<span class="msg">按招标文件原文填写,存成元;留空表示没写明</span>
</div>
<div v-else-if="kind === 'regime'" class="field fill">
<label for="fd-regime">法律体系</label>
<SelectBox
id="fd-regime"
:model-value="text"
:options="Object.entries(LEGAL_REGIME).map(([value, label]) => ({ value, label }))"
@update:model-value="(v) => (text = v ?? '')"
/>
<span class="msg">决定查标用「投标无效」还是「否决投标」的口径</span>
</div>
<div v-else-if="kind === 'bool'" class="field fill">
<label>{{ fieldKey ? FIELD_LABEL[fieldKey] : "" }}</label>
<div class="seg" role="group" :aria-label="fieldKey ? FIELD_LABEL[fieldKey] : ''">
<button type="button" :aria-pressed="flag === true" @click="flag = true">是</button>
<button type="button" :aria-pressed="flag === false" @click="flag = false">否</button>
<button type="button" :aria-pressed="flag === null" @click="flag = null">没写明</button>
</div>
</div>
<div v-else class="field fill">
<label for="fd-day">投标截止时间(北京时间)</label>
<div class="dt-row">
<DateInput id="fd-day" v-model="day" label="投标截止日期" />
<input v-model="time" class="input num" aria-label="时分" placeholder="09:30" autocomplete="off" />
</div>
<span class="msg">查标按它核对证书有效期和落款日期,改了以后重新检查才生效</span>
</div>
<p v-if="field && field.ai_value != null" class="cap muted ai">
AI 提取:{{ fieldText(field.key, field.ai_value) }}<template v-if="field.citation">({{ field.citation.document_label }} 第 <span class="fig">{{ field.citation.page_no }}</span> 页)</template>
</p>
<p v-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
<div class="foot">
<button v-if="canRestore" type="button" class="btn text left" :disabled="busy" @click="save(true)">恢复 AI 值</button>
<span v-else class="cap muted left">修改会留痕</span>
<DialogClose class="btn" type="button" :disabled="busy">取消</DialogClose>
<button type="submit" class="btn primary swap" :class="{ 'is-loading': busy }" :disabled="busy">
<span class="idle">保存</span><span class="busy">正在保存…</span>
</button>
</div>
</form>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</DialogRoot>
</template>
<style scoped>
.dt-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 96px;
gap: var(--sp-2);
}
.input.num {
font-family: var(--font-figures);
font-variant-numeric: tabular-nums;
font-size: var(--fs-figure-sm);
}
.input.num::placeholder {
font-family: var(--font-sans);
font-size: var(--fs-body);
}
.ai,
.hint-warn {
margin: var(--sp-4) 0 0;
}
</style>

View File

@@ -0,0 +1,86 @@
<script setup lang="ts">
/**
* 修改记录(全程留痕,红线 1):谁在何时做了什么、改前、改后。数据来自审计事件,按时间倒序。
*/
import { useQuery } from "@tanstack/vue-query";
import { X } from "@lucide/vue";
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from "reka-ui";
import { computed } from "vue";
import { errorTitle } from "@/lib/api";
import { fetchHistory } from "@/lib/checklistOps";
import { fmtDateTime, HISTORY_FIELD, historyAction, historyValue } from "@/lib/labels";
import type { ChecklistItem } from "@/lib/queries";
const props = defineProps<{ item: ChecklistItem | null }>();
const open = defineModel<boolean>("open", { required: true });
const q = useQuery({
queryKey: computed(() => ["checklist-history", props.item?.id ?? ""] as const),
queryFn: () => fetchHistory(props.item!.id),
enabled: computed(() => open.value && !!props.item),
staleTime: 0,
});
/** 每个改动的字段一行;没有字段改动的操作(删除、恢复)也占一行 */
const rows = computed(() =>
(q.data.value?.items ?? []).flatMap((h, n) => {
const changes = (h.changes ?? []).filter((c) => c.field !== "pending_reason" && (h.action !== "checklist_item.extract" || c.field === "title"));
const what = h.action === "checklist_item.extract" && props.item?.origin === "rule" ? "规则提取" : historyAction(h.action);
const base = { key: `${n}`, at: fmtDateTime(h.at), who: h.actor.name || "—", what };
if (!changes.length) return [{ ...base, field: "", before: "", after: "" }];
return changes.map((c, i) => ({
...base,
key: `${n}-${i}`,
field: HISTORY_FIELD[c.field] ?? c.field,
before: historyValue(c.field, c.before),
after: historyValue(c.field, c.after),
first: i === 0,
}));
}),
);
</script>
<template>
<DialogRoot v-model:open="open">
<DialogPortal>
<DialogOverlay class="scrim">
<DialogContent class="dialog wide" :aria-describedby="undefined">
<DialogTitle as="h2">修改记录</DialogTitle>
<DialogClose class="icon-btn sm x" aria-label="关闭"><X class="ic" :stroke-width="1.5" /></DialogClose>
<p class="muted sub">{{ item?.title }}</p>
<p v-if="q.isPending.value" class="muted">正在加载…</p>
<p v-else-if="q.error.value" class="hint-warn">{{ errorTitle(q.error.value) }}</p>
<table v-else class="data hist">
<thead><tr><th>时间</th><th>操作人</th><th>操作</th><th>改前</th><th>改后</th></tr></thead>
<tbody>
<tr v-for="r in rows" :key="r.key">
<td><span class="fig">{{ r.at }}</span></td>
<td>{{ r.who }}</td>
<td>{{ r.what }}<span v-if="r.field" class="muted"> · {{ r.field }}</span></td>
<td><span v-if="r.before" class="old">{{ r.before }}</span><span v-else class="muted">—</span></td>
<td>{{ r.after || "—" }}</td>
</tr>
</tbody>
</table>
<div class="foot"><DialogClose class="btn">关闭</DialogClose></div>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</DialogRoot>
</template>
<style scoped>
.sub {
margin: 0 0 var(--sp-3);
}
.hist td:nth-child(-n + 3) {
white-space: nowrap;
}
.hist td {
word-break: break-word;
}
.hist td:nth-child(4),
.hist td:nth-child(5) {
min-width: 5em;
}
</style>

View File

@@ -0,0 +1,199 @@
<script setup lang="ts">
/**
* 清单项对话框(宽 640,§7.14):修改、修改后采纳、新增条目共用。
* - 只提交改过的字段(PATCH 语义),服务端据此记 edited_fields 和修改记录;
* - ★ 条款不满足就会被判无效,选 ★ 时自动带上「无效条款」分类(FR-M2-05);
* - 要求原文是招标文件的原话,AI 提取的条目不能改,人工添加的可以写;
* - 采纳没有出处的条目:要么去原文选句子,要么勾选「人工确认、无出处」(红线 2)。
*/
import { X } from "@lucide/vue";
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from "reka-ui";
import { computed, ref, watch } from "vue";
import SelectBox from "@/components/SelectBox.vue";
import { errorTitle, isApiError } from "@/lib/api";
import type { ItemFields } from "@/lib/checklistOps";
import { category, CATEGORY_ORDER, IMPORTANCE_MARK, IMPORTANCE_NAME } from "@/lib/labels";
import type { ChecklistItem, Lot } from "@/lib/queries";
type Mode = "edit" | "accept" | "create";
type Cat = ChecklistItem["categories"][number];
type Imp = ChecklistItem["importance"];
/** lots:项目的分包(不含默认包);有 2 个及以上时可以改条目归属哪个包(FR-M2-04) */
const props = defineProps<{ mode: Mode; item?: ChecklistItem | null; defaultCategory?: Cat; lots?: Lot[] }>();
const open = defineModel<boolean>("open", { required: true });
const emit = defineEmits<{
/** 提交:changes 只含改过的字段;ack = 人工确认、无出处 */
submit: [changes: ItemFields, ack: boolean, done: (err?: unknown) => void];
/** 采纳前先去原文选句子补出处;带上已经改了的字段,选完句子一起采纳 */
pick: [changes: ItemFields];
}>();
const title = ref("");
const cats = ref<Cat[]>([]);
const imp = ref<Imp>("normal");
const evidence = ref("");
const reqText = ref("");
const lotId = ref<string | null>(null);
const ack = ref(false);
const busy = ref(false);
const err = ref("");
const titleErr = ref("");
const catErr = ref("");
watch(open, (v) => {
if (!v) return;
const i = props.item;
title.value = i?.title ?? "";
cats.value = i ? [...i.categories] : props.defaultCategory ? [props.defaultCategory] : [];
imp.value = i?.importance ?? "normal";
evidence.value = i?.evidence_required ?? "";
reqText.value = i?.requirement_text ?? "";
lotId.value = i?.lot_id ?? null;
ack.value = false;
err.value = titleErr.value = catErr.value = "";
});
const heading = computed(() => ({ edit: "修改条目", accept: "修改后采纳", create: "新增条目" })[props.mode]);
const okText = computed(() => ({ edit: "保存", accept: "采纳", create: "添加" })[props.mode]);
const reqEditable = computed(() => props.mode === "create" || props.item?.origin === "human");
const noCite = computed(() => props.mode === "accept" && !props.item?.citations.length);
function toggleCat(c: Cat) {
if (cats.value.includes(c)) {
if (!(c === "invalidity" && imp.value === "substantive")) cats.value = cats.value.filter((x) => x !== c);
} else cats.value = [...cats.value, c];
}
function setImp(v: Imp) {
imp.value = v;
if (v === "substantive" && !cats.value.includes("invalidity")) cats.value = [...cats.value, "invalidity"];
}
function changes(): ItemFields {
const i = props.item;
const out: ItemFields = {};
const t = title.value.trim();
if (!i || t !== i.title) out.title = t;
const order = (xs: Cat[]) => CATEGORY_ORDER.filter((c) => xs.includes(c));
if (!i || order(cats.value).join() !== order(i.categories).join()) out.categories = order(cats.value);
if (!i || imp.value !== i.importance) out.importance = imp.value;
const ev = evidence.value.trim() || null;
if (ev !== (i?.evidence_required ?? null)) out.evidence_required = ev;
if ((props.lots?.length ?? 0) > 1 && lotId.value !== (i?.lot_id ?? null)) out.lot_id = lotId.value;
const rt = reqText.value.trim() || null;
if (reqEditable.value && rt !== (i?.requirement_text ?? null)) out.requirement_text = rt;
return out;
}
function submit() {
err.value = titleErr.value = catErr.value = "";
if (!title.value.trim()) titleErr.value = "请写下这条要求";
if (!cats.value.length) catErr.value = "至少选一个分类";
if (titleErr.value || catErr.value) return;
if (noCite.value && !ack.value) {
err.value = "这一条没有出处:去原文选句子,或者勾选「人工确认、无出处」";
return;
}
busy.value = true;
emit("submit", changes(), noCite.value && ack.value, (e) => {
busy.value = false;
if (!e) open.value = false;
else err.value = isApiError(e) && e.problem.detail ? e.problem.detail : errorTitle(e);
});
}
</script>
<template>
<DialogRoot v-model:open="open">
<DialogPortal>
<DialogOverlay class="scrim">
<DialogContent class="dialog wide" :aria-describedby="undefined">
<DialogTitle as="h2">{{ heading }}</DialogTitle>
<DialogClose class="icon-btn sm x" aria-label="关闭"><X class="ic" :stroke-width="1.5" /></DialogClose>
<form class="body" @submit.prevent="submit">
<div class="field fill" :class="{ err: titleErr }">
<label for="it-title">要求 <small>(必填)</small></label>
<input id="it-title" v-model="title" class="input" maxlength="200" placeholder="一句话写清要求,如「须提供原厂授权书」" />
<span v-if="titleErr" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ titleErr }}</span>
</div>
<div class="field fill" :class="{ err: catErr }">
<label>分类 <small>(可多选)</small></label>
<div class="chips-row">
<label v-for="c in CATEGORY_ORDER" :key="c" class="choice">
<input type="checkbox" class="ck" :checked="cats.includes(c)" @change="toggleCat(c)" />{{ category(c) }}
</label>
</div>
<span v-if="catErr" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ catErr }}</span>
</div>
<div class="field fill">
<label>重要性</label>
<div class="seg" role="group" aria-label="重要性">
<button v-for="v in (['substantive', 'important', 'normal'] as const)" :key="v" type="button" :aria-pressed="imp === v" @click="setImp(v)">
<span v-if="IMPORTANCE_MARK[v]">{{ IMPORTANCE_MARK[v] }}</span>{{ IMPORTANCE_NAME[v] }}
</button>
</div>
<span v-if="imp === 'substantive'" class="msg">★ 条款不满足就会被判无效,已同时归在「无效条款」</span>
</div>
<div v-if="(lots?.length ?? 0) > 1" class="field fill">
<label for="it-lot">所属采购包</label>
<SelectBox
id="it-lot"
v-model="lotId"
:options="[{ value: null, label: '各包通用' }, ...(lots ?? []).map((l) => ({ value: l.id, label: `采购包 ${l.lot_no}${l.name ? ` · ${l.name}` : ''}` }))]"
/>
</div>
<div class="field fill">
<label for="it-ev">证明材料 <small>(选填)</small></label>
<input id="it-ev" v-model="evidence" class="input" placeholder="如「提供营业执照副本复印件并加盖公章」" />
</div>
<div v-if="reqEditable" class="field fill">
<label for="it-req">招标文件原文 <small>(选填)</small></label>
<textarea id="it-req" v-model="reqText" class="input" placeholder="照录招标文件里的原话"></textarea>
</div>
<div v-else-if="item?.requirement_text" class="field fill">
<label>招标文件原文 <small>(只读)</small></label>
<p class="orig">{{ item.requirement_text }}</p>
</div>
<div v-if="noCite" class="field fill">
<label>出处</label>
<p class="cap muted">这一条没有出处。可以 <button type="button" class="lnk" @click="emit('pick', changes()); open = false">去原文选句子</button>,或者:</p>
<label class="choice"><input v-model="ack" type="checkbox" class="ck" />找不到出处,按「人工确认、无出处」采纳</label>
</div>
<p v-if="mode === 'create'" class="cap muted note">人工添加的条目标注「人工添加」,可以没有出处;要补出处,添加后在原文里选句子。</p>
<p v-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
<div class="foot">
<DialogClose class="btn" type="button" :disabled="busy">取消</DialogClose>
<button type="submit" class="btn primary swap" :class="{ 'is-loading': busy }" :disabled="busy">
<span class="idle">{{ okText }}</span><span class="busy">正在保存…</span>
</button>
</div>
</form>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</DialogRoot>
</template>
<style scoped>
.body .field + .field {
margin-top: var(--sp-4);
}
.chips-row {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2) var(--sp-4);
}
.orig {
margin: 0;
padding: var(--sp-2) var(--sp-3);
background: var(--c-n50);
border-radius: var(--radius-ctl);
line-height: var(--lh-read);
max-height: 160px;
overflow: auto;
}
.note,
.hint-warn {
margin: var(--sp-4) 0 0;
}
</style>

View File

@@ -0,0 +1,138 @@
<script setup lang="ts">
/**
* 顶栏的通知(原型 app.js 的铃铛,m6-plan §1.6):有未读时带圆点;点开是最近 30 条,点一条跳到相关页面并标为已读。
* 解析 / 检查 / 报告完成、额度用到 80% / 100%、开通结果、到期提醒都在这里。
*/
import { unwrap } from "@bid-doctor/api-client";
import { Bell } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { PopoverContent, PopoverPortal, PopoverRoot, PopoverTrigger } from "reka-ui";
import { computed, ref } from "vue";
import { useRouter } from "vue-router";
import { api } from "@/lib/api";
import { qk } from "@/lib/keys";
import { fmtDateTime } from "@/lib/labels";
import { useNotifications, type Notification } from "@/lib/queries";
const q = useNotifications();
const qc = useQueryClient();
const router = useRouter();
const open = ref(false);
const items = computed(() => q.data.value?.items ?? []);
const unread = computed(() => q.data.value?.unread_count ?? 0);
async function markRead(ids?: string[]) {
await unwrap(api.POST("/notifications/read", { body: ids ? { ids } : {} }));
void qc.invalidateQueries({ queryKey: qk.notifications });
}
async function go(n: Notification) {
open.value = false;
if (!n.read_at) void markRead([n.id]);
if (n.link) await router.push(n.link);
}
</script>
<template>
<PopoverRoot v-model:open="open">
<PopoverTrigger as-child>
<button
class="icon-btn tip-below tip-left bell"
type="button"
:aria-label="unread ? `通知(${unread} 条未读)` : '通知'"
:data-tip="open ? undefined : '通知'"
>
<Bell class="ic lg" :stroke-width="1.5" /><span v-if="unread" class="dot" aria-hidden="true"></span>
</button>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent class="menu popper notes" align="end" :side-offset="6" aria-label="通知">
<div class="nh">
<b>通知</b>
<button v-if="unread" type="button" class="lnk cap" @click="markRead()">全部标为已读</button>
</div>
<p v-if="!items.length" class="cap muted none">还没有通知</p>
<ul v-else>
<li v-for="n in items" :key="n.id">
<button type="button" class="note" :class="{ unread: !n.read_at }" @click="go(n)">
<span class="t">{{ n.title }}</span>
<span class="cap muted">
<template v-if="n.project_name">{{ n.project_name }} · </template>{{ fmtDateTime(n.created_at) }}
</span>
</button>
</li>
</ul>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
</template>
<style scoped>
.bell {
position: relative;
}
.dot {
position: absolute;
top: 6px;
right: 6px;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--c-ink);
box-shadow: 0 0 0 2px var(--bg-page);
}
/* PopoverContent 的根元素上没有 scoped 属性(在 Reka 的定位外层上),从外层选进去 */
[data-reka-popper-content-wrapper] :deep(.notes) {
width: 360px;
max-height: min(480px, 70vh);
padding: 0;
}
.nh {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-3) var(--sp-4) var(--sp-2);
border-bottom: var(--line) solid var(--border-subtle);
}
.none {
margin: 0;
padding: var(--sp-4);
}
ul {
margin: 0;
padding: var(--sp-1);
list-style: none;
}
.note {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
padding: var(--sp-2) var(--sp-3);
border: 0;
border-radius: var(--radius-ctl);
background: none;
font: inherit;
color: var(--text-secondary);
text-align: left;
cursor: pointer;
}
.note:hover {
background: var(--c-n100);
}
.note.unread {
color: var(--text-primary);
}
.note.unread .t {
font-weight: var(--fw-medium);
}
.note.unread .t::before {
content: "";
display: inline-block;
width: 6px;
height: 6px;
margin-right: var(--sp-2);
border-radius: 50%;
background: var(--c-ink);
vertical-align: 2px;
}
</style>

View File

@@ -0,0 +1,317 @@
<script setup lang="ts">
/**
* 原文查看器(设计规范 §7.8,api-design §4.4):pdf.js 渲染规范化 PDF,一次显示一页,宽度撑满查看器。
* - 定位:传入出处(页码 + 坐标框),翻到那一页,按坐标框画原句高亮并滚到眼前;
* 出处记下的文档哈希和文档当前哈希不一致时不画高亮(data-model §5.1)。
* - 选句(selecting):叠出本页每一句的可点框,点选同一页上的句子,作为人工出处(v-model:picked)。
* 坐标框是 [x0, y0, x1, y1],单位 pt,原点在页面左上角。原文只读。
*/
import { ChevronLeft, ChevronRight } from "@lucide/vue";
import * as pdfjs from "pdfjs-dist";
import workerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
import { computed, markRaw, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from "vue";
import { fetchFileUrl, fetchSentences, useDocumentPages, type Sentence } from "@/lib/checklistOps";
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
export interface ViewerTarget {
page: number;
quads: number[][];
sha?: string;
/** 一句话跨页时,后面几页的接续部分也要高亮 */
more?: { page: number; quads: number[][] }[];
}
const props = defineProps<{
documentId?: string;
documentLabel?: string;
target?: ViewerTarget | null;
selecting?: boolean;
emptyText?: string;
}>();
const picked = defineModel<string[]>("picked", { default: () => [] });
const pagesQ = useDocumentPages(() => props.documentId);
const docSha = computed(() => pagesQ.data.value?.normalized_sha256 ?? "");
const pdf = shallowRef<pdfjs.PDFDocumentProxy | null>(null);
const loadErr = ref("");
const page = ref(1);
const count = computed(() => pdf.value?.numPages ?? 0);
const scale = ref(1);
const size = ref({ w: 0, h: 0 });
const body = ref<HTMLElement>();
const canvas = ref<HTMLCanvasElement>();
let task: pdfjs.RenderTask | null = null;
let loadSeq = 0;
let renderSeq = 0;
async function load(id: string | undefined) {
const seq = ++loadSeq;
await pdf.value?.loadingTask.destroy();
pdf.value = null;
loadErr.value = "";
if (!id) return;
try {
// 签名地址不带鉴权头;取成字节交给 pdf.js(规范化 PDF 一般只有几 MB)
const { url } = await fetchFileUrl(id);
const res = await fetch(url);
if (!res.ok) throw new Error(String(res.status));
const doc = await pdfjs.getDocument({ data: new Uint8Array(await res.arrayBuffer()) }).promise;
if (seq !== loadSeq) return void doc.loadingTask.destroy();
pdf.value = markRaw(doc);
page.value = Math.min(Math.max(1, props.target?.page ?? page.value), doc.numPages);
await render();
} catch {
if (seq === loadSeq) loadErr.value = "原文加载失败,请刷新后重试";
}
}
async function render() {
const doc = pdf.value;
const el = body.value;
if (!doc || !el || !canvas.value) return;
// 同一块画布同时只能有一次渲染:后来的等前一次真正停下,中途又有更新的就让给它(打开时直接翻到出处那页会连发两次)
const seq = ++renderSeq;
const prev = task;
prev?.cancel();
await prev?.promise.catch(() => {});
const p = await doc.getPage(page.value);
if (seq !== renderSeq) return;
const base = p.getViewport({ scale: 1 });
const width = Math.max(320, el.clientWidth - 32);
const vp = p.getViewport({ scale: width / base.width });
const dpr = window.devicePixelRatio || 1;
const c = canvas.value;
c.width = Math.floor(vp.width * dpr);
c.height = Math.floor(vp.height * dpr);
c.style.width = `${vp.width}px`;
c.style.height = `${vp.height}px`;
scale.value = vp.scale;
size.value = { w: vp.width, h: vp.height };
task = p.render({ canvas: c, viewport: vp, transform: dpr === 1 ? undefined : [dpr, 0, 0, dpr, 0, 0] });
try {
await task.promise;
} catch {
return; // 翻页或改尺寸时取消了上一次渲染
}
await nextTick();
centerHighlight();
}
/** 只滚查看器自己,不带动整页(scrollIntoView 会连页面一起滚) */
function centerHighlight() {
const el = body.value;
const hl = el?.querySelector<HTMLElement>(".hl-box");
if (!el || !hl) return;
const top = hl.getBoundingClientRect().top - el.getBoundingClientRect().top + el.scrollTop;
el.scrollTo({ top: Math.max(0, top - el.clientHeight / 2 + hl.offsetHeight / 2) });
}
/* ---------- 高亮 ---------- */
const stale = computed(() => !!props.target?.sha && !!docSha.value && props.target.sha !== docSha.value);
const box = (q: number[]) => ({
left: `${q[0]! * scale.value}px`,
top: `${q[1]! * scale.value}px`,
width: `${(q[2]! - q[0]!) * scale.value}px`,
height: `${(q[3]! - q[1]!) * scale.value}px`,
});
const highlights = computed(() => {
const t = props.target;
if (!t || stale.value) return [];
return [{ page: t.page, quads: t.quads }, ...(t.more ?? [])].filter((x) => x.page === page.value).flatMap((x) => x.quads);
});
/* ---------- 选句 ---------- */
const sentences = ref<Sentence[]>([]);
async function loadSentences() {
sentences.value = [];
if (!props.selecting || !props.documentId) return;
const want = page.value;
const r = await fetchSentences(props.documentId, want);
if (want === page.value) sentences.value = r.items.filter((s) => s.location !== "header_footer");
}
function toggle(key: string) {
picked.value = picked.value.includes(key) ? picked.value.filter((k) => k !== key) : [...picked.value, key];
}
/* ---------- 翻页、跟随出处、尺寸变化 ---------- */
function go(n: number) {
if (!count.value) return;
const next = Math.min(Math.max(1, n), count.value);
if (next !== page.value) page.value = next;
}
watch(page, () => {
picked.value = []; // 出处只能是同一页上的句子
void render();
void loadSentences();
});
watch(
() => props.documentId,
(id) => void load(id),
);
watch(
() => props.target,
(t) => {
if (!t) return;
if (t.page !== page.value) go(t.page);
else void nextTick(centerHighlight);
},
);
watch(
() => props.selecting,
(on) => {
if (!on) picked.value = [];
void loadSentences();
},
);
let ro: ResizeObserver | null = null;
let lastW = 0;
let timer = 0;
onMounted(() => {
void load(props.documentId);
ro = new ResizeObserver(([e]) => {
const w = Math.round(e?.contentRect.width ?? 0);
if (!w || Math.abs(w - lastW) < 8) return;
lastW = w;
clearTimeout(timer);
timer = window.setTimeout(() => void render(), 120);
});
if (body.value) ro.observe(body.value);
});
onBeforeUnmount(() => {
ro?.disconnect();
task?.cancel();
void pdf.value?.loadingTask.destroy();
});
defineExpose({ go });
</script>
<template>
<section class="viewer pdfv" aria-label="原文查看器">
<div class="viewer-bar">
<span class="doc-name"><b>{{ documentLabel || "原文" }}</b></span>
<div class="pager">
<span v-if="count" class="fig">第 {{ page }} / {{ count }} 页</span>
<button type="button" class="icon-btn sm" aria-label="上一页" :disabled="!count || page <= 1" @click="go(page - 1)">
<ChevronLeft class="ic" :stroke-width="1.5" />
</button>
<button type="button" class="icon-btn sm" aria-label="下一页" :disabled="!count || page >= count" @click="go(page + 1)">
<ChevronRight class="ic" :stroke-width="1.5" />
</button>
</div>
</div>
<slot name="banner" />
<div ref="body" class="viewer-body">
<p v-if="!documentId" class="viewer-empty">{{ emptyText || "点清单项或出处,在这里看原文和高亮的原句。" }}</p>
<p v-else-if="loadErr" class="viewer-empty">{{ loadErr }}</p>
<template v-else>
<p v-if="!pdf" class="viewer-empty">正在加载原文…</p>
<div v-show="pdf" class="page" :style="{ width: `${size.w}px`, height: `${size.h}px` }">
<canvas ref="canvas"></canvas>
<span v-for="(q, i) in highlights" :key="`h${i}`" class="hl-box" :style="box(q)"></span>
<template v-if="selecting">
<button
v-for="s in sentences"
:key="s.key"
type="button"
class="pick"
:class="{ on: picked.includes(s.key) }"
:aria-pressed="picked.includes(s.key)"
:aria-label="s.text"
:title="s.text"
@click="toggle(s.key)"
>
<span v-for="(q, i) in s.quads" :key="i" class="pick-q" :style="box(q)"></span>
</button>
</template>
</div>
<p v-if="stale && target?.page === page" class="viewer-note">原文已更新,这条出处的高亮位置可能不准,未画高亮</p>
<p v-else-if="pdf" class="viewer-note">页码以规范化 PDF 为准 · 原文只读</p>
</template>
</div>
</section>
</template>
<style scoped>
.pdfv {
height: 100%;
}
.doc-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.pager .fig {
margin-right: var(--sp-2);
font-size: var(--fs-figure-xs);
color: var(--text-secondary);
}
.viewer-body {
flex: 1;
min-height: 0;
overflow: auto;
padding: var(--sp-4);
}
.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-note {
margin: var(--sp-2) 0 0;
font-size: var(--fs-caption);
color: var(--text-secondary);
text-align: center;
}
.page {
position: relative;
margin: 0 auto;
background: var(--c-white);
box-shadow: var(--shadow-sheet);
}
.page canvas {
display: block;
}
/* 原句高亮(§7.8):蓝铅笔浅底 + 2px 蓝色底边;用正片叠底,不遮住字 */
.hl-box {
position: absolute;
background: var(--ai-soft);
box-shadow: inset 0 -2px 0 var(--ai);
mix-blend-mode: multiply;
pointer-events: none;
}
/* 选句:整句一个按钮,按坐标框叠在字上;悬停浅底,选中加蓝色底边 */
.pick {
position: static;
padding: 0;
border: 0;
background: none;
cursor: pointer;
}
.pick-q {
position: absolute;
border-radius: 2px;
mix-blend-mode: multiply;
}
.pick:hover .pick-q,
.pick:focus-visible .pick-q {
background: var(--c-n100);
}
.pick:focus-visible {
outline: 0;
}
.pick:focus-visible .pick-q {
box-shadow: 0 0 0 2px var(--focus-ring);
}
.pick.on .pick-q {
background: var(--ai-soft);
box-shadow: inset 0 -2px 0 var(--ai);
}
</style>

View File

@@ -0,0 +1,167 @@
<script setup lang="ts">
/**
* 项目头(原型的 projHead):项目名、采购人等、投标截止、状态和就绪;下面是「概览 / 读标 / 查标 / 报告」标签页。
* 就绪只在「已检查」时显示(G7),只表示致命问题都已处理,不代表投标文件没有问题(READY_TIP)。
* 状态操作(FR-M6-02):递交 / 撤销递交 / 归档 / 撤销归档。致命问题没处理完也可以递交(红线 4),确认框里说明;
* 递交后项目只读,可以撤销,都留痕。
* 删除项目(J4,决策 M6-1):放在「更多」里,彻底删除、不能恢复,确认时要输入项目名称;额度流水和不含内容的审计保留。
*/
import { unwrap } from "@bid-doctor/api-client";
import { MoreHorizontal } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuRoot, DropdownMenuTrigger } from "reka-ui";
import { computed, ref } from "vue";
import { RouterLink, useRouter } from "vue-router";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import { api, errorTitle } from "@/lib/api";
import { qk } from "@/lib/keys";
import { deadline, legalRegime, projectStatus, READY_TIP } from "@/lib/labels";
import type { ProjectDetail } from "@/lib/queries";
import { toast } from "@/lib/toast";
const props = defineProps<{ project?: ProjectDetail; tab: "overview" | "checklist" | "check" | "report" }>();
const qc = useQueryClient();
const p = computed(() => props.project);
const meta = computed(() =>
p.value ? [p.value.purchaser, p.value.legal_regime !== "unknown" ? legalRegime(p.value.legal_regime) : "", p.value.procurement_method, p.value.code].filter(Boolean).join(" · ") : "",
);
const due = computed(() => deadline(p.value?.deadline_at));
type Action = "submit" | "unsubmit" | "archive" | "unarchive";
const ACTIONS: Record<string, { action: Action; label: string }[]> = {
tracking: [{ action: "submit", label: "标记为已递交" }],
drafting: [{ action: "submit", label: "标记为已递交" }],
checked: [{ action: "submit", label: "标记为已递交" }],
submitted: [
{ action: "unsubmit", label: "撤销递交" },
{ action: "archive", label: "归档" },
],
archived: [{ action: "unarchive", label: "撤销归档" }],
};
const actions = computed(() => (p.value ? ACTIONS[p.value.status] ?? [] : []));
const confirmSubmit = ref(false);
const busy = ref(false);
async function run(action: Action) {
if (!p.value) return;
if (action === "submit" && !confirmSubmit.value) {
confirmSubmit.value = true;
return;
}
busy.value = true;
try {
await unwrap(api.POST("/projects/{project_id}/transitions", { params: { path: { project_id: p.value.id } }, body: { action } }));
confirmSubmit.value = false;
void qc.invalidateQueries({ queryKey: qk.project(p.value.id) });
void qc.invalidateQueries({ queryKey: qk.projects });
toast(action === "submit" ? "已标记为已递交,项目改为只读" : action === "archive" ? "已归档" : "已撤销");
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
busy.value = false;
}
}
/* ---------- 删除项目 ---------- */
const router = useRouter();
const delOpen = ref(false);
const delName = ref("");
const delErr = ref("");
function askDelete() {
delName.value = "";
delErr.value = "";
delOpen.value = true;
}
async function doDelete() {
if (!p.value) return;
if (delName.value.trim() !== p.value.name) {
delErr.value = "项目名称不一致,请照上面的名称输入";
return;
}
busy.value = true;
try {
await unwrap(api.DELETE("/projects/{project_id}", { params: { path: { project_id: p.value.id } } }));
delOpen.value = false;
qc.removeQueries({ queryKey: qk.project(p.value.id) });
void qc.invalidateQueries({ queryKey: qk.projects });
void qc.invalidateQueries({ queryKey: qk.quota });
toast(`已删除项目「${p.value.name}」`);
await router.replace({ name: "workbench" });
} catch (e) {
delErr.value = errorTitle(e);
} finally {
busy.value = false;
}
}
const checkTo = computed(() =>
p.value?.latest_check_run_id
? { name: "results", params: { projectId: p.value.id, runId: p.value.latest_check_run_id } }
: { name: "bid-check", params: { projectId: p.value?.id ?? "" } },
);
</script>
<template>
<header class="ph">
<div class="ph-l1">
<div>
<h1 :title="p?.name">{{ p?.name ?? " " }}</h1>
<p class="ph-meta">{{ meta || "招标文件信息待解析" }}</p>
</div>
<dl v-if="p" class="facts">
<div>
<dt>投标截止</dt>
<dd>
<template v-if="due"><span class="fig m">{{ due.sub }}</span><span :class="due.cls || 'muted'">{{ due.main }}</span></template>
<span v-else class="muted">—</span>
</dd>
</div>
<div>
<dt>状态</dt>
<dd>
<span class="tag">{{ projectStatus(p.status) }}</span>
<button v-for="a in actions" :key="a.action" type="button" class="btn sm text" :disabled="busy" @click="run(a.action)">{{ a.label }}</button>
<DropdownMenuRoot :modal="false">
<DropdownMenuTrigger class="icon-btn sm" aria-label="更多操作"><MoreHorizontal class="ic" :stroke-width="1.5" /></DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent class="menu popper" align="end" :side-offset="4">
<DropdownMenuItem @select="askDelete">删除项目</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</dd>
</div>
<div v-if="p.readiness.ready !== null && p.readiness.ready !== undefined">
<dt>就绪</dt>
<dd>
<span v-if="p.readiness.ready" class="hint-u tip-below tip-left" :data-tip="READY_TIP" tabindex="0">就绪</span>
<span v-else class="hint-u tip-below tip-left" :data-tip="`还有 ${p.readiness.open_fatal_count} 个致命问题没处理。不影响生成报告和递交,决定权在你。`" tabindex="0">未就绪</span>
</dd>
</div>
</dl>
</div>
<nav class="tabs" aria-label="项目">
<RouterLink v-if="p" role="tab" :aria-selected="tab === 'overview'" :to="{ name: 'overview', params: { projectId: p.id } }">概览</RouterLink>
<RouterLink v-if="p" role="tab" :aria-selected="tab === 'checklist'" :to="{ name: 'checklist', params: { projectId: p.id } }">读标</RouterLink>
<RouterLink v-if="p" role="tab" :aria-selected="tab === 'check'" :to="checkTo">查标<span v-if="p.readiness.open_fatal_count" class="sev fatal">致命<span class="fig">{{ p.readiness.open_fatal_count }}</span></span></RouterLink>
<RouterLink v-if="p" role="tab" :aria-selected="tab === 'report'" :to="{ name: 'report', params: { projectId: p.id } }">报告</RouterLink>
</nav>
</header>
<ConfirmDialog v-model:open="confirmSubmit" title="标记为已递交?" ok-text="标记为已递交" busy-text="正在标记…" :busy="busy" @ok="run('submit')">
<p>标记后项目改为只读,可以随时撤销。</p>
<p v-if="p && p.readiness.open_fatal_count">还有 <b class="fig">{{ p.readiness.open_fatal_count }}</b> 项致命问题没有处理。是否递交由你决定,系统不会阻止。</p>
</ConfirmDialog>
<ConfirmDialog v-model:open="delOpen" :title="`删除项目「${p?.name ?? ''}」?`" ok-text="删除项目" busy-text="正在删除…" :busy="busy" @ok="doDelete">
<p>项目下的招标文件、投标文件、清单、检查结果和报告都会彻底删除,<b>不能恢复</b>。需要留底的报告请先下载。</p>
<p class="cap muted">额度流水和一条不含业务内容的操作记录会保留。</p>
<div class="field fill del-name" :class="{ err: delErr }">
<label for="del-name">输入项目名称确认</label>
<input id="del-name" v-model="delName" class="input" :placeholder="p?.name" autocomplete="off" @input="delErr = ''" />
<span v-if="delErr" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ delErr }}</span>
</div>
</ConfirmDialog>
</template>
<style scoped>
.del-name {
margin-top: var(--sp-3);
}
</style>

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
/**
* 工作台项目行(设计规范 §7.9,原型 app.css B20):整行可点,「下一步」单独可点。
* 列:项目 · 投标截止 · 状态(含就绪)· 致命(待处理的致命问题数)· 下一步。
*/
import { ChevronRight } from "@lucide/vue";
import { computed } from "vue";
import { RouterLink, type RouteLocationRaw } from "vue-router";
import { deadline, nextAction, projectStatus, READY_TIP } from "@/lib/labels";
import type { ProjectListItem } from "@/lib/queries";
const props = defineProps<{ project: ProjectListItem }>();
const p = computed(() => props.project);
const due = computed(() => deadline(p.value.deadline_at));
const sub = computed(() => [p.value.purchaser, p.value.code].filter(Boolean).join(" · "));
const job = computed(() => p.value.active_job ?? null);
/** 项目的默认入口:解析中去进度页;还没上传招标文件去上传;要检查去上传投标文件;检查过去查标;其余去清单 */
const home = computed<RouteLocationRaw>(() => {
const id = p.value.id;
if (job.value?.kind === "parse_job") return { name: "parse", params: { projectId: id, jobId: job.value.id } };
if (p.value.next_action === "upload_tender") return { name: "project-new", query: { project: id } };
if (p.value.next_action === "upload_bid" || p.value.next_action === "run_check") return { name: "bid-check", params: { projectId: id } };
if (p.value.latest_check_run_id && (p.value.next_action === "handle_fatal" || p.value.next_action === "export_report"))
return { name: "results", params: { projectId: id, runId: p.value.latest_check_run_id } };
return { name: "checklist", params: { projectId: id } };
});
/** 「下一步」:生成报告去报告页,其余同默认入口 */
const next = computed<RouteLocationRaw>(() =>
p.value.next_action === "export_report" ? { name: "report", params: { projectId: p.value.id } } : home.value,
);
</script>
<template>
<li class="prow">
<span class="pt">
<RouterLink class="pname" :to="home" :title="p.name">{{ p.name }}</RouterLink>
<span class="psub">
<template v-if="job">{{ job.label || "任务进行中" }} <span class="fig">{{ job.progress ?? 0 }}%</span><template v-if="sub"> · </template></template>{{ sub }}<template v-if="!job && !sub">招标文件信息待解析</template>
</span>
</span>
<span class="dl">
<template v-if="due"><b :class="due.cls">{{ due.main }}</b><small>{{ due.sub }}</small></template>
<span v-else class="dash" aria-label="未填写">—</span>
</span>
<span class="st">
<span class="tag">{{ projectStatus(p.status) }}</span>
<template v-if="p.readiness.ready === true">
<span class="hint-u tip-below" tabindex="0" :data-tip="READY_TIP">就绪</span>
</template>
<template v-else-if="p.readiness.ready === false">
<span class="hint-u tip-below" tabindex="0" :data-tip="`还有 ${p.readiness.open_fatal_count} 个致命问题没处理。不影响生成报告和递交,决定权在你。`">未就绪</span>
</template>
</span>
<span class="c">
<span v-if="p.readiness.open_fatal_count" class="sev fatal">致命<span class="fig">{{ p.readiness.open_fatal_count }}</span></span>
<span v-else class="dash" aria-label="无">—</span>
</span>
<span>
<RouterLink class="nx" :to="next"><span>{{ nextAction(p.next_action) }}</span><ChevronRight class="ic" :stroke-width="1.5" /></RouterLink>
</span>
<span></span>
</li>
</template>

View File

@@ -0,0 +1,317 @@
<script setup lang="ts">
/**
* 资质的添加 / 编辑对话框(宽 640,原型 qualifications.html、ui-demo forms.html「资质录入」)。
* - 名称、证书编号、有效期至必填(长期有效打开开关);证书编号界面必填、接口可空(qualification-plan §2.3);
* - 有效期至早于进行中项目的投标截止日:出赭色提醒小字,只提醒、不拦截保存;
* - 附件可以稍后补录。新增时选的文件在保存资质后再上传挂上。
*/
import { unwrap, uploadFile } from "@bid-doctor/api-client";
import { X } from "@lucide/vue";
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from "reka-ui";
import { computed, ref, watch } from "vue";
import DateInput from "@/components/DateInput.vue";
import SelectBox from "@/components/SelectBox.vue";
import { api, apiOptions, errorTitle } from "@/lib/api";
import { extOf, fmtMonthDay, fmtSize, parseDay, QUALIFICATION_KIND, QUALIFICATION_KIND_ORDER } from "@/lib/labels";
import type { Qualification } from "@/lib/queries";
export interface ActiveDeadline {
name: string;
/** YYYY-MM-DD(北京时间) */
day: string;
}
const props = defineProps<{ item: Qualification | null; deadlines: ActiveDeadline[]; focusExpiry?: boolean }>();
const open = defineModel<boolean>("open", { required: true });
/** saved:created / updated 是点了「保存资质」,files 是在对话框里加删附件 */
const emit = defineEmits<{ saved: [q: Qualification, how: "created" | "updated" | "files"] }>();
type Kind = Qualification["kind"];
const KIND_OPTIONS = QUALIFICATION_KIND_ORDER.map((k) => ({ value: k, label: QUALIFICATION_KIND[k] }));
const name = ref("");
const kind = ref<Kind>("certificate");
const level = ref("");
const certNo = ref("");
const issuer = ref("");
const from = ref("");
const to = ref("");
const longTerm = ref(false);
const files = ref<Qualification["files"]>([]);
const pending = ref<File | null>(null);
const errs = ref<Record<string, string>>({});
const err = ref("");
const busy = ref(false);
const fileBusy = ref(false);
const fileInput = ref<HTMLInputElement | null>(null);
const toInput = ref<InstanceType<typeof DateInput> | null>(null);
watch(open, (v) => {
if (!v) return;
const q = props.item;
name.value = q?.name ?? "";
kind.value = q?.kind ?? "certificate";
level.value = q?.level ?? "";
certNo.value = q?.cert_no ?? "";
issuer.value = q?.issuer ?? "";
from.value = q?.valid_from ?? "";
to.value = q?.valid_to ?? "";
longTerm.value = !!q && !q.valid_to;
files.value = q?.files ?? [];
pending.value = null;
errs.value = {};
err.value = "";
if (props.focusExpiry) setTimeout(() => toInput.value?.select());
});
/** 有效期至早于哪些进行中项目的投标截止日(截止日当天到期仍算有效) */
const late = computed(() => {
const day = longTerm.value ? "" : parseDay(to.value);
return day ? props.deadlines.filter((d) => day < d.day) : [];
});
function validate(): boolean {
const e: Record<string, string> = {};
if (!name.value.trim()) e.name = "请填写资质名称,和证书上的写法一致";
if (!certNo.value.trim()) e.certNo = "请填写证书编号,查标时用它核对";
const f = parseDay(from.value);
if (f === null) e.from = "日期写法不对,例如:2023-11-16";
if (!longTerm.value) {
const t = parseDay(to.value);
if (t === null) e.to = "日期写法不对,例如:2026-11-20";
else if (!t) e.to = "请填写有效期至;长期有效请打开下方开关";
else if (f && t <= f) e.to = "有效期至要晚于有效期起,请核对";
}
errs.value = e;
return !Object.keys(e).length;
}
async function attach(qid: string, file: File): Promise<Qualification> {
const done = await uploadFile(api, apiOptions, file, "qualification_file");
return unwrap(
api.POST("/qualifications/{qualification_id}/files", { params: { path: { qualification_id: qid } }, body: { upload_id: done.id } }),
);
}
async function submit() {
if (!validate() || busy.value) return;
busy.value = true;
err.value = "";
const body = {
name: name.value.trim(),
kind: kind.value,
level: level.value.trim() || null,
cert_no: certNo.value.trim() || null,
issuer: issuer.value.trim() || null,
valid_from: parseDay(from.value) || null,
valid_to: longTerm.value ? null : parseDay(to.value) || null,
};
const how = props.item ? "updated" : "created";
try {
let q = props.item
? await unwrap(api.PUT("/qualifications/{qualification_id}", { params: { path: { qualification_id: props.item.id } }, body }))
: await unwrap(api.POST("/qualifications", { body }));
if (pending.value) {
try {
q = await attach(q.id, pending.value);
} catch (e) {
// 资质已经存好了(再点保存是修改,不会重复添加);附件没挂上时关掉对话框会让人以为都好了,所以留在对话框里
emit("saved", q, how);
err.value = `资质已保存,附件没有上传成功:${errorTitle(e)}。可以稍后在列表里补录。`;
return;
}
}
emit("saved", q, how);
open.value = false;
} catch (e) {
err.value = errorTitle(e);
} finally {
busy.value = false;
}
}
function pick() {
fileInput.value?.click();
}
async function onFile(ev: Event) {
const input = ev.target as HTMLInputElement;
const f = input.files?.[0];
input.value = "";
if (!f) return;
if (!props.item) {
pending.value = f;
return;
}
fileBusy.value = true;
err.value = "";
try {
const q = await attach(props.item.id, f);
files.value = q.files;
emit("saved", q, "files");
} catch (e) {
err.value = errorTitle(e);
} finally {
fileBusy.value = false;
}
}
async function view(fileId: string) {
if (!props.item) return;
try {
const link = await unwrap(
api.GET("/qualifications/{qualification_id}/files/{file_id}", { params: { path: { qualification_id: props.item.id, file_id: fileId } } }),
);
window.open(link.url, "_blank", "noopener");
} catch (e) {
err.value = errorTitle(e);
}
}
async function removeFile(fileId: string) {
if (!props.item) return;
fileBusy.value = true;
try {
await unwrap(
api.DELETE("/qualifications/{qualification_id}/files/{file_id}", { params: { path: { qualification_id: props.item.id, file_id: fileId } } }),
);
files.value = files.value.filter((f) => f.id !== fileId);
emit("saved", { ...props.item, files: files.value }, "files");
} catch (e) {
err.value = errorTitle(e);
} finally {
fileBusy.value = false;
}
}
</script>
<template>
<DialogRoot v-model:open="open">
<DialogPortal>
<DialogOverlay class="scrim">
<DialogContent class="dialog wide" :aria-describedby="undefined">
<DialogTitle as="h2">{{ item ? "编辑资质" : "添加资质" }}</DialogTitle>
<DialogClose class="icon-btn sm x" aria-label="关闭"><X class="ic" :stroke-width="1.5" /></DialogClose>
<form class="body" novalidate @submit.prevent="submit">
<div class="form">
<div class="field span2" :class="{ err: errs.name }">
<label for="qd-name">名称 <small>(必填)</small></label>
<input id="qd-name" v-model="name" class="input" maxlength="200" placeholder="和证书上的写法一致" autocomplete="off" />
<span v-if="errs.name" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.name }}</span>
</div>
<div class="field">
<label for="qd-kind">类型 <small>(必填)</small></label>
<SelectBox
id="qd-kind"
:model-value="kind"
:options="KIND_OPTIONS"
@update:model-value="(v) => (kind = v as Kind)"
/>
</div>
<div class="field">
<label for="qd-level">等级 <small>(选填)</small></label>
<input id="qd-level" v-model="level" class="input" maxlength="40" placeholder="例如:CS3、二级" autocomplete="off" />
</div>
<div class="field" :class="{ err: errs.certNo }">
<label for="qd-no">证书编号 <small>(必填)</small></label>
<input id="qd-no" v-model="certNo" class="input num" maxlength="80" autocomplete="off" />
<span v-if="errs.certNo" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.certNo }}</span>
</div>
<div class="field">
<label for="qd-issuer">发证机构</label>
<input id="qd-issuer" v-model="issuer" class="input" maxlength="120" autocomplete="off" />
</div>
<div class="field" :class="{ err: errs.from }">
<label for="qd-from">有效期起</label>
<DateInput id="qd-from" v-model="from" label="有效期起" placeholder="例如:2023-11-16" />
<span v-if="errs.from" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.from }}</span>
</div>
<div class="field" :class="{ err: errs.to }">
<label for="qd-to">有效期至 <small v-if="!longTerm">(必填)</small></label>
<DateInput id="qd-to" ref="toInput" v-model="to" label="有效期至" :placeholder="longTerm ? '长期有效' : '例如:2026-11-20'" :disabled="longTerm" />
<span v-if="errs.to" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.to }}</span>
<span v-else-if="late.length" class="hint-warn">
<span class="ico w" aria-hidden="true">!</span>
<span>早于「{{ late[0]!.name }}」的投标截止 {{ fmtMonthDay(late[0]!.day) }}<template v-if="late.length > 1"> 等 <span class="fig">{{ late.length }}</span> 个项目</template></span>
</span>
</div>
<div class="field span2">
<button
type="button"
class="switch"
role="switch"
:aria-checked="longTerm"
@click="
longTerm = !longTerm;
if (longTerm) to = '';
"
>
<span class="track" aria-hidden="true"></span>长期有效
</button>
</div>
<div class="field span2">
<label>证书扫描件 <small>(可以稍后补录)</small></label>
<div v-for="f in files" :key="f.id" class="file-row">
<span class="ext">{{ extOf(f.filename) }}</span>
<div><b>{{ f.filename }}</b><span class="cap muted">{{ fmtSize(f.size_bytes) }}</span></div>
<span class="acts">
<button type="button" class="btn sm" @click="view(f.id)">查看</button>
<button type="button" class="btn sm" :disabled="fileBusy" @click="removeFile(f.id)">删除</button>
</span>
</div>
<div v-if="pending" class="file-row">
<span class="ext">{{ extOf(pending.name) }}</span>
<div><b>{{ pending.name }}</b><span class="cap muted">{{ fmtSize(pending.size) }} · 保存资质后上传</span></div>
<span class="acts"><button type="button" class="btn sm" @click="pending = null">移除</button></span>
</div>
<button v-if="!pending" type="button" class="drop" :aria-disabled="fileBusy" @click="pick">
<span>{{ fileBusy ? "正在上传…" : files.length ? "再上传一个附件(PDF / JPG / PNG)" : "点击上传 PDF / JPG / PNG,单个不超过 20MB" }}</span>
</button>
<input ref="fileInput" type="file" accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png" hidden @change="onFile" />
</div>
</div>
<p v-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
<div class="foot">
<span class="cap muted left">不扣额度</span>
<DialogClose class="btn" type="button" :disabled="busy">取消</DialogClose>
<button type="submit" class="btn primary swap" :class="{ 'is-loading': busy }" :disabled="busy">
<span class="idle">保存资质</span><span class="busy">正在保存…</span>
</button>
</div>
</form>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</DialogRoot>
</template>
<style scoped>
/* 编号、日期用数字字体(同原型 .dt > .input) */
.input.num {
font-family: var(--font-figures);
font-variant-numeric: tabular-nums;
font-size: var(--fs-figure-sm);
}
.input.num::placeholder {
font-family: var(--font-sans);
font-size: var(--fs-body);
}
.file-row + .file-row,
.file-row + .drop {
margin-top: var(--sp-2);
}
.drop {
width: 100%;
padding: var(--sp-4);
font: inherit;
cursor: pointer;
}
.file-row .cap {
display: block;
}
.hint-warn {
margin: var(--sp-4) 0 0;
}
.field .hint-warn {
margin: var(--sp-1) 0 0;
}
</style>

View File

@@ -0,0 +1,220 @@
<script setup lang="ts">
/**
* 报告预览:用 pdf.js 把生成的 PDF 逐页画出来,连续滚动,宽度撑满。
* 不用浏览器自带的 PDF 查看器(iframe):Chrome 的是深色工具栏,改不了配色,桌面客户端里也各不一样;
* 这里和原文查看器(PdfViewer)同一套外观。只看不改;下载按钮放在 actions 插槽(顶栏里)。
* 页面多时只画滚到眼前的那几页(IntersectionObserver),改宽度时重画已画过的页。
*/
import { ChevronLeft, ChevronRight } from "@lucide/vue";
import * as pdfjs from "pdfjs-dist";
import workerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
import { markRaw, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from "vue";
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
const props = defineProps<{ url?: string | null; label?: string }>();
const pdf = shallowRef<pdfjs.PDFDocumentProxy | null>(null);
const loadErr = ref("");
/** 每页按查看器宽度缩放后的尺寸;没画之前先用它占位,滚动条长度一开始就对 */
const sizes = ref<{ w: number; h: number }[]>([]);
const page = ref(1);
const body = ref<HTMLElement>();
const canvases: HTMLCanvasElement[] = [];
const drawn = new Map<number, number>(); // 页号 → 画的时候的宽度
const tasks = new Map<number, pdfjs.RenderTask>();
let io: IntersectionObserver | null = null;
let loadSeq = 0;
async function load(url: string | null | undefined) {
const seq = ++loadSeq;
reset();
await pdf.value?.loadingTask.destroy();
pdf.value = null;
loadErr.value = "";
if (!url) return;
try {
const res = await fetch(url);
if (!res.ok) throw new Error(String(res.status));
const doc = await pdfjs.getDocument({ data: new Uint8Array(await res.arrayBuffer()) }).promise;
if (seq !== loadSeq) return void doc.loadingTask.destroy();
pdf.value = markRaw(doc);
page.value = 1;
await layout();
body.value?.scrollTo({ top: 0 });
} catch {
if (seq === loadSeq) loadErr.value = "报告加载失败,请刷新后重试";
}
}
function reset() {
for (const t of tasks.values()) t.cancel();
tasks.clear();
drawn.clear();
sizes.value = [];
}
/** 按当前宽度算每页尺寸,再观察每页、滚到眼前时才画 */
async function layout() {
const doc = pdf.value;
const el = body.value;
if (!doc || !el) return;
const width = Math.max(320, el.clientWidth - 32);
const out: { w: number; h: number }[] = [];
for (let n = 1; n <= doc.numPages; n++) {
const vp = (await doc.getPage(n)).getViewport({ scale: 1 });
out.push({ w: width, h: Math.round((vp.height * width) / vp.width) });
}
if (doc !== pdf.value) return;
sizes.value = out;
await nextTick();
io?.disconnect();
io = new IntersectionObserver(onIntersect, { root: el, rootMargin: "400px 0px" });
el.querySelectorAll(".page").forEach((p) => io!.observe(p));
}
function onIntersect(entries: IntersectionObserverEntry[]) {
for (const e of entries) if (e.isIntersecting) void draw(Number((e.target as HTMLElement).dataset.n));
}
async function draw(n: number) {
const doc = pdf.value;
const c = canvases[n - 1];
const size = sizes.value[n - 1];
if (!doc || !c || !size || drawn.get(n) === size.w) return;
drawn.set(n, size.w);
tasks.get(n)?.cancel();
const p = await doc.getPage(n);
const base = p.getViewport({ scale: 1 });
const vp = p.getViewport({ scale: size.w / base.width });
const dpr = window.devicePixelRatio || 1;
c.width = Math.floor(vp.width * dpr);
c.height = Math.floor(vp.height * dpr);
const task = p.render({ canvas: c, viewport: vp, transform: dpr === 1 ? undefined : [dpr, 0, 0, dpr, 0, 0] });
tasks.set(n, task);
try {
await task.promise;
} catch {
if (drawn.get(n) === size.w) drawn.delete(n); // 被取消的下次再画
} finally {
if (tasks.get(n) === task) tasks.delete(n);
}
}
/* ---------- 当前页(滚动时跟着变)、翻页 ---------- */
function onScroll() {
const el = body.value;
if (!el) return;
const mid = el.getBoundingClientRect().top + el.clientHeight / 3;
const pages = el.querySelectorAll<HTMLElement>(".page");
let cur = 1;
pages.forEach((p, i) => {
if (p.getBoundingClientRect().top <= mid) cur = i + 1;
});
page.value = cur;
}
function go(n: number) {
const el = body.value;
const target = el?.querySelectorAll<HTMLElement>(".page")[n - 1];
if (!el || !target) return;
el.scrollTo({ top: target.offsetTop - 16 });
}
watch(
() => props.url,
(u) => void load(u),
);
let ro: ResizeObserver | null = null;
let lastW = 0;
let timer = 0;
onMounted(() => {
void load(props.url);
ro = new ResizeObserver(([e]) => {
const w = Math.round(e?.contentRect.width ?? 0);
if (!w || Math.abs(w - lastW) < 8) return;
lastW = w;
clearTimeout(timer);
timer = window.setTimeout(() => {
const keep = page.value;
void layout().then(() => go(keep));
}, 120);
});
if (body.value) ro.observe(body.value);
});
onBeforeUnmount(() => {
ro?.disconnect();
io?.disconnect();
reset();
void pdf.value?.loadingTask.destroy();
});
</script>
<template>
<section class="viewer rv" aria-label="报告预览">
<div class="viewer-bar">
<span class="doc-name"><b>{{ label || "检查报告" }}</b></span>
<slot name="actions" />
<div class="pager">
<span v-if="sizes.length" class="fig">第 {{ page }} / {{ sizes.length }} 页</span>
<button type="button" class="icon-btn sm" aria-label="上一页" :disabled="page <= 1" @click="go(page - 1)">
<ChevronLeft class="ic" :stroke-width="1.5" />
</button>
<button type="button" class="icon-btn sm" aria-label="下一页" :disabled="page >= sizes.length" @click="go(page + 1)">
<ChevronRight class="ic" :stroke-width="1.5" />
</button>
</div>
</div>
<div ref="body" class="viewer-body" @scroll.passive="onScroll">
<slot v-if="!url" name="empty" />
<p v-else-if="loadErr" class="viewer-empty">{{ loadErr }}</p>
<p v-else-if="!sizes.length" class="viewer-empty">正在加载报告…</p>
<div v-for="(s, i) in sizes" :key="i" class="page" :data-n="i + 1" :style="{ width: `${s.w}px`, height: `${s.h}px` }">
<canvas :ref="(c) => (canvases[i] = c as HTMLCanvasElement)" :style="{ width: `${s.w}px`, height: `${s.h}px` }"></canvas>
</div>
</div>
</section>
</template>
<style scoped>
.rv {
height: 100%;
}
.doc-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.pager .fig {
margin-right: var(--sp-2);
font-size: var(--fs-figure-xs);
color: var(--text-secondary);
}
.viewer-body {
position: relative;
flex: 1;
min-height: 0;
overflow: auto;
padding: var(--sp-4);
}
.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;
}
.page {
margin: 0 auto;
background: var(--c-white);
box-shadow: var(--shadow-sheet);
}
.page + .page {
margin-top: var(--sp-4);
}
.page canvas {
display: block;
}
</style>

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
/**
* 下拉选择(规范 §7.21):触发框和输入框同规格,右侧 chevron,展开时旋转;
* 浮层白底、圆角 12,选中项加粗、右侧墨色 ✓。用 Reka UI 的 Select 打底(AR10),不用原生 <select>。
* - size="sm":高 32,用在筛选栏、行内(和旁边的搜索框一样高);
* - 值可以是空串或 null(「全部来源」「各包通用」):Reka 的选项不许空串,这里换成占位值再换回来。
*/
import { ChevronDown } from "@lucide/vue";
import { SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectPortal, SelectRoot, SelectTrigger, SelectViewport } from "reka-ui";
import { computed } from "vue";
export interface SelectOption {
value: string | null;
label: string;
disabled?: boolean;
}
defineOptions({ inheritAttrs: false }); // id、class、aria-label 给触发框(SelectRoot 本身不出元素)
const props = defineProps<{
modelValue: string | null;
options: SelectOption[];
disabled?: boolean;
size?: "sm";
}>();
const emit = defineEmits<{ "update:modelValue": [value: string | null] }>();
const NULL = "\u0000null";
const EMPTY = "\u0000empty";
const enc = (v: string | null) => (v === null ? NULL : v === "" ? EMPTY : v);
const dec = (v: string) => (v === NULL ? null : v === EMPTY ? "" : v);
const current = computed(() => props.options.find((o) => o.value === props.modelValue));
</script>
<template>
<SelectRoot :model-value="enc(modelValue)" :disabled="disabled" @update:model-value="(v) => emit('update:modelValue', dec(String(v)))">
<SelectTrigger v-bind="$attrs" class="select" :class="{ sm: size === 'sm' }">
<span class="val">{{ current?.label ?? "" }}</span>
<ChevronDown class="ic" :stroke-width="1.5" aria-hidden="true" />
</SelectTrigger>
<SelectPortal>
<SelectContent class="menu popper sel-menu" position="popper" align="start" :side-offset="4">
<SelectViewport>
<SelectItem v-for="o in options" :key="enc(o.value)" :value="enc(o.value)" :disabled="o.disabled">
<SelectItemText>{{ o.label }}</SelectItemText>
<SelectItemIndicator class="chk" aria-hidden="true">✓</SelectItemIndicator>
</SelectItem>
</SelectViewport>
</SelectContent>
</SelectPortal>
</SelectRoot>
</template>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import { dismiss, toasts } from "@/lib/toast";
</script>
<template>
<div class="toasts" role="status" aria-live="polite">
<div v-for="t in toasts" :key="t.id" class="toast">
<span v-if="t.ok" class="ok" aria-hidden="true">✓</span>
<span>{{ t.text }}</span>
<template v-if="t.undo">
<span class="sep" aria-hidden="true"></span>
<button type="button" class="lnk" @click="t.undo(); dismiss(t.id)">撤销</button>
</template>
</div>
</div>
</template>

7
apps/web/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<object, object, unknown>;
export default component;
}

19
apps/web/src/lib/api.ts Normal file
View File

@@ -0,0 +1,19 @@
/**
* 接口客户端单例。网页版走 cookie(写操作由 api-client 自动带 X-CSRF-Token);
* 桌面版以后在这里传 getToken(从系统钥匙串取 Bearer 令牌),业务代码不用改。
*/
import { ApiError, createApi, type ClientOptions } from "@bid-doctor/api-client";
import { runtimeConfig } from "./config";
export const apiOptions: ClientOptions = { baseUrl: runtimeConfig.apiBaseUrl };
export const api = createApi(apiOptions);
/** 网络中断等非接口错误也包成「请求失败」,界面统一处理。 */
export function errorTitle(e: unknown): string {
if (e instanceof ApiError) return e.problem.title;
return "网络连接失败,请检查网络后重试";
}
export function isApiError(e: unknown, code?: string): e is ApiError {
return e instanceof ApiError && (code === undefined || e.code === code);
}

View File

@@ -0,0 +1,89 @@
/**
* 读标的修改类操作(api-design §4.5):采纳、修改、删除恢复、人工新增、补出处、确认清单、修改记录。
* 每次修改后让清单概况、清单项、项目详情(下一步、待确认数)的缓存失效,由 TanStack Query 重新取。
*/
import { unwrap, type Schemas } from "@bid-doctor/api-client";
import { useQuery, useQueryClient } from "@tanstack/vue-query";
import { computed, toValue, type MaybeRefOrGetter } from "vue";
import { api } from "./api";
import { qk } from "./keys";
import type { ChecklistItem } from "./queries";
export type ItemFields = Schemas["ChecklistItemFields"];
export type HistoryEntry = Schemas["HistoryEntry"];
export type ChecklistVersion = Schemas["ChecklistVersion"];
export type Sentence = Schemas["Sentence"];
export type PageInfo = Schemas["DocumentPage"];
const item = (r: Promise<{ data?: unknown; error?: unknown; response: Response }>) => unwrap(r) as Promise<ChecklistItem>;
const path = (item_id: string) => ({ params: { path: { item_id } } });
export function useChecklistOps(projectId: MaybeRefOrGetter<string>) {
const qc = useQueryClient();
const pid = () => toValue(projectId);
const refresh = async () => {
await Promise.all([
qc.invalidateQueries({ queryKey: qk.checklist(pid()) }),
qc.invalidateQueries({ queryKey: qk.checklistItems(pid()) }),
qc.invalidateQueries({ queryKey: ["checklist-deleted", pid()] }),
qc.invalidateQueries({ queryKey: qk.project(pid()) }),
qc.invalidateQueries({ queryKey: qk.projects }),
]);
};
const run = async <T>(p: Promise<T>): Promise<T> => {
const out = await p;
await refresh();
return out;
};
return {
update: (id: string, changes: ItemFields) => run(item(api.PATCH("/checklist-items/{item_id}", { ...path(id), body: changes }))),
accept: (id: string, body: { citation_ids?: string[]; no_citation_ack?: boolean; changes?: ItemFields }) =>
run(item(api.POST("/checklist-items/{item_id}/accept", { ...path(id), body }))),
remove: (id: string) => run(unwrap(api.DELETE("/checklist-items/{item_id}", path(id)))),
restore: (id: string) => run(item(api.POST("/checklist-items/{item_id}/restore", path(id)))),
setCitations: (id: string, citation_ids: string[]) =>
run(item(api.PUT("/checklist-items/{item_id}/citations", { ...path(id), body: { citation_ids } }))),
create: (body: Schemas["ChecklistItemCreate"]) =>
run(item(api.POST("/projects/{project_id}/checklist/items", { params: { path: { project_id: pid() } }, body }))),
/** 在原文里选中的句子建一条出处(同一页) */
cite: (document_id: string, sentence_keys: string[]) =>
unwrap(api.POST("/projects/{project_id}/citations", { params: { path: { project_id: pid() } }, body: { document_id, sentence_keys } })),
confirm: () => run(unwrap(api.POST("/projects/{project_id}/checklist/confirm", { params: { path: { project_id: pid() } } }))),
};
}
export const fetchHistory = (id: string) => unwrap(api.GET("/checklist-items/{item_id}/history", path(id)));
/** 已删除(含丢弃)的条目:「已删除」分类里看,可以恢复 */
export function useDeletedItems(projectId: MaybeRefOrGetter<string>, enabled: MaybeRefOrGetter<boolean>) {
return useQuery({
queryKey: computed(() => ["checklist-deleted", toValue(projectId)] as const),
queryFn: async () => {
const r = (await unwrap(
api.GET("/projects/{project_id}/checklist/items", {
params: { path: { project_id: toValue(projectId) }, query: { include_deleted: true } },
}),
)) as { items: ChecklistItem[] };
return r.items.filter((i) => i.deleted_at);
},
enabled: computed(() => toValue(enabled)),
});
}
export function useDocumentPages(documentId: MaybeRefOrGetter<string | undefined>) {
return useQuery({
queryKey: computed(() => ["document-pages", toValue(documentId) ?? ""] as const),
queryFn: () => unwrap(api.GET("/documents/{document_id}/pages", { params: { path: { document_id: toValue(documentId)! } } })),
enabled: computed(() => !!toValue(documentId)),
staleTime: Infinity,
});
}
export function fetchSentences(documentId: string, page: number) {
return unwrap(api.GET("/documents/{document_id}/pages/{page_no}/sentences", { params: { path: { document_id: documentId, page_no: page } } }));
}
export function fetchFileUrl(documentId: string) {
return unwrap(api.GET("/documents/{document_id}/file", { params: { path: { document_id: documentId }, query: { variant: "normalized" } } }));
}

View File

@@ -0,0 +1,27 @@
/**
* 出处的显示分组:出处按页定位(每页一条,data-model §5.1),一句话跨页时存成两条。
* 显示时把「上一页那条没写完、紧接着是下一页的接续」的合成一处,页码写「第 47–48 页」,原句接起来。
*/
import type { Citation } from "./queries";
export interface CitationGroup {
head: Citation;
/** 接续到后面几页的部分(一般只有一条) */
tail: Citation[];
}
const ENDED = /[。!?;!?;))」』”]$/;
export function groupCitations(cs: readonly Citation[]): CitationGroup[] {
const out: CitationGroup[] = [];
for (const c of cs) {
const g = out[out.length - 1];
const last = g ? (g.tail[g.tail.length - 1] ?? g.head) : null;
if (g && last && c.document_id === last.document_id && c.page_no === last.page_no + 1 && !ENDED.test(last.quote.trim())) {
g.tail.push(c);
} else {
out.push({ head: c, tail: [] });
}
}
return out;
}

View File

@@ -0,0 +1,24 @@
/**
* 运行时配置(架构 §5.2 第 2、6 条):接口地址和路由基础路径都不写死,
* 由 public/config.js 在页面加载前写入 window.__BD_CONFIG__;桌面外壳可以在加载前注入同名对象。
*/
export interface RuntimeConfig {
apiBaseUrl: string;
routerMode: "history" | "hash";
routerBase: string;
}
declare global {
interface Window {
__BD_CONFIG__?: Partial<RuntimeConfig>;
}
}
const raw = window.__BD_CONFIG__ ?? {};
const viteBase = import.meta.env.BASE_URL.startsWith("/") ? import.meta.env.BASE_URL : "/";
export const runtimeConfig: Readonly<RuntimeConfig> = Object.freeze({
apiBaseUrl: (raw.apiBaseUrl ?? "/api/v1").replace(/\/+$/, ""),
routerMode: raw.routerMode === "hash" ? "hash" : "history",
routerBase: raw.routerBase ?? viteBase,
});

View File

@@ -0,0 +1,21 @@
/** 顶栏面包屑:页面声明自己的位置,AppShell 显示。纯界面状态,用 provide / inject,不进 Pinia。 */
import { inject, provide, ref, watchEffect, type InjectionKey, type Ref } from "vue";
import type { RouteLocationRaw } from "vue-router";
export interface Crumb {
label: string;
to?: RouteLocationRaw;
}
const KEY: InjectionKey<Ref<Crumb[]>> = Symbol("crumbs");
export function provideCrumbs(): Ref<Crumb[]> {
const crumbs = ref<Crumb[]>([]);
provide(KEY, crumbs);
return crumbs;
}
export function useCrumbs(get: () => Crumb[]) {
const crumbs = inject(KEY, null);
if (crumbs) watchEffect(() => (crumbs.value = get()));
}

View File

@@ -0,0 +1,92 @@
/**
* 事件流(SSE,用 fetch 读,API2):事件只是「有变化」的提示,以资源的 GET 结果为准。
* 进度事件直接写进解析任务和项目列表的缓存,让进度条即时动;其余事件让相关查询失效、重新请求。
* 断线由 api-client 自动重连;页面上的 5 秒轮询兜底。
*/
import { subscribeEvents, type BdEvent } from "@bid-doctor/api-client";
import type { QueryClient } from "@tanstack/vue-query";
import { apiOptions } from "./api";
import { qk } from "./keys";
import { TERMINAL_JOB, type CheckRun, type ParseJob, type ProjectListItem } from "./queries";
type ProjectPage = { items: ProjectListItem[]; next_cursor: string | null };
function onEvent(qc: QueryClient, e: BdEvent) {
const job = e.job;
const jobId = job?.id;
if ((e.type === "job.progress" || e.type === "job.finished") && job && jobId && job.kind === "parse_job") {
// 事件可能比 GET 结果旧(重连补发、乱序):已结束的任务不再被进度事件改回「进行中」,进度只增不减
qc.setQueryData<ParseJob>(qk.parseJob(jobId), (old) =>
!old || TERMINAL_JOB.has(old.status) || (job.progress ?? 0) < old.progress
? old
: {
...old,
status: job.status ?? old.status,
stage: (job.stage as ParseJob["stage"]) ?? old.stage,
progress: job.progress ?? old.progress,
},
);
qc.setQueryData<ProjectPage>(qk.projects, (old) =>
old && {
...old,
items: old.items.map((p) =>
p.active_job && p.active_job.id === jobId && (job.progress ?? 0) >= (p.active_job.progress ?? 0) ? { ...p, active_job: { ...p.active_job, progress: job.progress ?? p.active_job.progress } } : p,
),
},
);
if (e.type === "job.finished") {
// 结束后要拿完整结果(error、result),并刷新项目、额度、清单
void qc.invalidateQueries({ queryKey: qk.parseJob(jobId) });
void qc.invalidateQueries({ queryKey: qk.notifications });
void qc.invalidateQueries({ queryKey: qk.projects });
void qc.invalidateQueries({ queryKey: qk.quota });
if (e.project_id) {
void qc.invalidateQueries({ queryKey: qk.project(e.project_id) });
void qc.invalidateQueries({ queryKey: qk.checklist(e.project_id) });
void qc.invalidateQueries({ queryKey: qk.checklistItems(e.project_id) });
}
}
return;
}
if ((e.type === "job.progress" || e.type === "job.finished") && job && jobId && job.kind === "check_run") {
qc.setQueryData<CheckRun>(qk.checkRun(jobId), (old) =>
!old || TERMINAL_JOB.has(old.status) || (job.progress ?? 0) < old.progress
? old
: { ...old, status: job.status ?? old.status, stage: (job.stage as CheckRun["stage"]) ?? old.stage, progress: job.progress ?? old.progress },
);
if (e.type === "job.finished") {
void qc.invalidateQueries({ queryKey: qk.checkRun(jobId) });
void qc.invalidateQueries({ queryKey: qk.notifications });
void qc.invalidateQueries({ queryKey: qk.findings(jobId) });
void qc.invalidateQueries({ queryKey: qk.projects });
void qc.invalidateQueries({ queryKey: qk.quota });
if (e.project_id) {
void qc.invalidateQueries({ queryKey: qk.project(e.project_id) });
void qc.invalidateQueries({ queryKey: qk.checkPreview(e.project_id) });
void qc.invalidateQueries({ queryKey: qk.checkRuns(e.project_id) });
}
}
return;
}
switch (e.type) {
case "quota.changed":
void qc.invalidateQueries({ queryKey: qk.quota });
void qc.invalidateQueries({ queryKey: qk.notifications });
break;
case "project.changed":
void qc.invalidateQueries({ queryKey: qk.projects });
if (e.project_id) void qc.invalidateQueries({ queryKey: qk.project(e.project_id) });
break;
case "resync":
void qc.invalidateQueries();
break;
default:
// notification.created、report.ready:骨架阶段还没有对应界面
break;
}
}
/** 登录后的页面框架挂载时开始订阅,返回取消函数。 */
export function startEventStream(qc: QueryClient): () => void {
return subscribeEvents(apiOptions, (e) => onEvent(qc, e));
}

28
apps/web/src/lib/keys.ts Normal file
View File

@@ -0,0 +1,28 @@
/** TanStack Query 的查询键,集中定义,事件流按这些键让缓存失效。 */
export const qk = {
me: ["me"] as const,
quota: ["quota"] as const,
/** 前缀和 quota 一样:额度一变,流水和申请跟着失效 */
quotaEntries: ["quota", "entries"] as const,
activationRequests: ["quota", "activation-requests"] as const,
notifications: ["notifications"] as const,
plans: ["plans"] as const,
projects: ["projects"] as const,
project: (id: string) => ["project", id] as const,
document: (id: string) => ["document", id] as const,
parseJob: (id: string) => ["parse-job", id] as const,
checklist: (projectId: string) => ["checklist", projectId] as const,
checklistItems: (projectId: string) => ["checklist-items", projectId] as const,
projectDocuments: (projectId: string) => ["project-documents", projectId] as const,
checkPreview: (projectId: string) => ["check-preview", projectId] as const,
checkRuns: (projectId: string) => ["check-runs", projectId] as const,
checkRun: (id: string) => ["check-run", id] as const,
findings: (runId: string) => ["findings", runId] as const,
manualChecks: (projectId: string) => ["manual-checks", projectId] as const,
reports: (projectId: string) => ["reports", projectId] as const,
itemResults: (runId: string) => ["item-results", runId] as const,
qualifications: ["qualifications"] as const,
/** 前缀和 qualifications 一样:资质库一改,读标页的资质库情况跟着失效 */
qualificationMatches: (projectId: string) => ["qualifications", "matches", projectId] as const,
lots: (projectId: string) => ["lots", projectId] as const,
};

362
apps/web/src/lib/labels.ts Normal file
View File

@@ -0,0 +1,362 @@
/** 枚举的中文显示名由前端维护(api-design §1);不认识的枚举值原样显示,不报错。 */
import type { Schemas } from "@bid-doctor/api-client";
type Dict<K extends string> = Readonly<Record<K, string>>;
const pick = <K extends string>(d: Dict<K>, k: string | null | undefined): string => (k ? (d as Record<string, string>)[k] ?? k : "");
export const PROJECT_STATUS: Dict<Schemas["ProjectStatus"]> = {
tracking: "跟进中",
drafting: "编制中",
checked: "已检查",
submitted: "已递交",
archived: "已归档",
};
export const projectStatus = (s: string) => pick(PROJECT_STATUS, s);
export const LEGAL_REGIME: Dict<Schemas["LegalRegime"]> = {
gov_procurement: "政府采购",
tendering: "招标投标",
unknown: "法律体系待识别",
};
export const legalRegime = (s: string) => pick(LEGAL_REGIME, s);
export const CATEGORY_ORDER: readonly Schemas["Category"][] = [
"qualification",
"invalidity",
"scoring",
"schedule",
"tech_param",
"format_doc",
"price",
];
export const CATEGORY: Dict<Schemas["Category"]> = {
qualification: "资格条件",
invalidity: "无效条款",
scoring: "评分办法",
schedule: "日期与递交",
tech_param: "技术参数",
format_doc: "格式文件",
price: "报价",
};
export const category = (s: string) => pick(CATEGORY, s);
/** 界面显示为 ★ / ▲ / 一般(一般不显示符号) */
export const IMPORTANCE_MARK: Dict<Schemas["Importance"]> = { substantive: "★", important: "▲", normal: "" };
export const IMPORTANCE_NAME: Dict<Schemas["Importance"]> = { substantive: "实质性要求", important: "重要", normal: "一般" };
export const PENDING_REASON: Dict<Schemas["PendingReason"]> = {
no_citation: "没找到可靠出处",
low_confidence: "把握不大",
verify_failed: "出处核对没通过",
marker_uncovered: "标注句没被提取覆盖,由规则补出",
absent_in_rerun: "重新解析时没再出现",
};
export const pendingReason = (s: string | null | undefined) => pick(PENDING_REASON, s);
/** 没通过校验的具体原因(服务端 checklist/extract.py 的 FAILURES,记在 ai_values.failures) */
export const FAILURE: Record<string, string> = {
evidence_missing: "没有给出处",
sid_format: "句子编号格式不对",
sid_unknown: "句子编号对不上",
quote_too_short: "引文太短",
quote_not_original: "引文和原文不一致",
basis_unmatched: "原文里找不到实质性条款的依据",
value_unverified: "取值在原文里找不到",
points_unverified: "分值在原文里找不到",
date_unverified: "日期时间在原文里找不到",
amount_unverified: "金额在原文里找不到",
placeholder_value: "原文这里是待填写的空白",
marker_uncovered: "",
};
/** 待确认条目的具体原因,最多列两条;和 pending_reason 重复的不再写 */
export function failureText(aiValues: unknown): string {
const fs = (aiValues as { failures?: unknown } | null)?.failures;
if (!Array.isArray(fs)) return "";
return fs
.map((f) => FAILURE[String(f)] ?? "")
.filter(Boolean)
.slice(0, 2)
.join("、");
}
/* ---------- 修改记录(审计事件) ---------- */
export const HISTORY_ACTION: Record<string, string> = {
"checklist_item.extract": "AI 提取",
"checklist_item.create": "人工添加",
"checklist_item.update": "修改",
"checklist_item.accept": "采纳",
"checklist_item.set_citations": "更换出处",
"checklist_item.discard": "丢弃",
"checklist_item.delete": "删除",
"checklist_item.restore": "恢复",
};
export const historyAction = (s: string) => HISTORY_ACTION[s] ?? s;
export const HISTORY_FIELD: Record<string, string> = {
title: "要求",
categories: "分类",
importance: "重要性",
evidence_required: "证明材料",
requirement_text: "原文",
lot_id: "分包",
spec: "取值",
citation_ids: "出处",
zone: "区域",
no_citation_ack: "人工确认、无出处",
};
/** 修改记录里一个值的显示:分类、重要性、区域翻成中文,出处只写条数 */
export function historyValue(field: string, v: unknown): string {
if (v === null || v === undefined || v === "") return "";
if (field === "categories" && Array.isArray(v)) return v.map((c) => category(String(c))).join("、");
if (field === "importance") return pick(IMPORTANCE_NAME, String(v));
if (field === "zone") return v === "pending" ? "待确认" : "正式清单";
if (field === "citation_ids" && Array.isArray(v)) return v.length ? `${v.length} 处` : "无";
if (field === "no_citation_ack") return v ? "是" : "否";
return typeof v === "string" ? v : JSON.stringify(v);
}
export const MARKER: Dict<Schemas["MarkingRule"]["marker"]> = {
star: "★",
asterisk: "*",
triangle: "▲",
italic_underline: "斜体加下划线",
bold: "加粗",
underline: "下划线",
keyword: "关键词",
};
export const QUOTA_UNIT: Dict<Schemas["QuotaUnit"]> = { parse: "解析", check: "检查", generate: "生成", dedupe: "查重" };
export const quotaUnit = (s: string) => pick(QUOTA_UNIT, s);
export const PLAN_KEY: Dict<Schemas["PlanCode"]> = { free: "free", pro: "pro", enterprise: "ent" };
/** 工作台「下一步」。target 为空表示骨架阶段还没有对应页面,只显示文字。 */
export const NEXT_ACTION: Dict<Schemas["NextAction"]> = {
upload_tender: "上传招标文件",
wait_parse: "查看解析进度",
select_lots: "选择所投的包",
confirm_checklist: "确认清单",
upload_bid: "上传投标文件",
run_check: "发起检查",
handle_fatal: "处理致命问题",
export_report: "生成检查报告",
archive: "归档项目",
none: "查看项目",
};
export const nextAction = (s: string) => pick(NEXT_ACTION, s);
/** 解析阶段(§7.11):服务端的 8 个 stage 合成界面上的 5 步 */
export const PARSE_STEPS: readonly { label: string; stages: readonly string[] }[] = [
{ label: "识别文件格式", stages: ["detect", "convert"] },
{ label: "提取文字", stages: ["text_layer", "ocr"] },
{ label: "识别章节结构", stages: ["structure"] },
{ label: "提取清单", stages: ["extract"] },
{ label: "核对出处", stages: ["verify", "merge"] },
];
export const parseStepIndex = (stage: string | null | undefined) =>
stage ? PARSE_STEPS.findIndex((s) => s.stages.includes(stage)) : -1;
/** 检查阶段:服务端的 6 个 stage 合成界面上的 4 步 */
export const CHECK_STEPS: readonly { label: string; stages: readonly string[] }[] = [
{ label: "读取投标文件", stages: ["prepare_bid"] },
{ label: "逐条找响应", stages: ["match"] },
{ label: "核对出处", stages: ["rules", "verify"] },
{ label: "沿用处理状态、汇总", stages: ["carry_over", "summarize"] },
];
export const checkStepIndex = (stage: string | null | undefined) =>
stage ? CHECK_STEPS.findIndex((s) => s.stages.includes(stage)) : -1;
export const VOLUME_KIND: Dict<Schemas["VolumeKind"]> = {
whole: "整本",
qualification: "资格证明",
commercial: "商务",
technical: "技术",
price: "报价",
other: "其他",
};
export const VOLUME_KINDS = Object.keys(VOLUME_KIND) as Schemas["VolumeKind"][];
/** 级别(§7.5):样式类沿用设计规范的 fatal / major / hint */
export const SEVERITY: Dict<Schemas["Severity"]> = { fatal: "致命", important: "重要", hint: "提示" };
export const SEVERITY_CLASS: Dict<Schemas["Severity"]> = { fatal: "fatal", important: "major", hint: "hint" };
export const FINDING_ACTION: Dict<Schemas["FindingStatus"]> = {
pending: "待处理",
confirmed_ok: "确认无误",
modified: "已修改",
ignored: "已忽略",
};
/** 每条清单项的检查结论(C9),读标页条目下面那一行 */
export const CHECK_VERDICT: Dict<Schemas["CheckItemResult"]["verdict"]> = {
responded: "已响应",
not_found: "未找到响应",
negative_deviation: "负偏离未说明",
suspected_unmet: "疑似不满足",
no_value: "未给出具体值",
undetermined: "未判定",
scanned: "文字部分没找到,请人工核对",
implicit_accepted: "按招标文件默认接受",
implicit_review: "默认接受,请人工核对",
};
export const READY_TIP = "只表示致命问题都已处理,不代表投标文件没有问题。";
export const QUALIFICATION_KIND_ORDER: readonly Schemas["QualificationKind"][] = ["certificate", "system_cert", "software_copyright", "other"];
export const QUALIFICATION_KIND: Dict<Schemas["QualificationKind"]> = {
certificate: "资质证书",
system_cert: "体系认证",
software_copyright: "软件著作权",
other: "其他",
};
export const qualificationKind = (s: string) => pick(QUALIFICATION_KIND, s);
/* ---------- 项目基本信息(FR-M2-03) ---------- */
export const FIELD_LABEL: Dict<Schemas["ProjectFieldKey"]> = {
name: "项目名称",
code: "项目编号",
purchaser: "采购人 / 招标人",
agency: "代理机构",
procurement_method: "采购方式",
legal_regime: "法律体系",
budget_total: "预算金额",
ceiling_total: "最高限价",
eval_method: "评标方法",
accept_consortium: "接受联合体",
accept_imported: "接受进口产品",
sme_only: "专门面向中小企业",
deadline_at: "投标截止时间",
};
export const FIELD_ORDER = Object.keys(FIELD_LABEL) as Schemas["ProjectFieldKey"][];
export const MONEY_FIELDS: ReadonlySet<string> = new Set(["budget_total", "ceiling_total"]);
export const BOOL_FIELDS: ReadonlySet<string> = new Set(["accept_consortium", "accept_imported", "sme_only"]);
/** 金额(元,字符串)显示成「286.00 万元」;不到一万的写元。数字来自招标文件原文,不是报价建议(红线 6) */
export function fmtYuan(v: string | null | undefined): string {
if (v == null || v === "") return "";
const n = Number(v);
if (!Number.isFinite(n)) return v;
return n >= 10000 ? `${(n / 10000).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} 万元` : `${n.toLocaleString("zh-CN")} 元`;
}
/** 字段值的显示文字;没有值时为空串 */
export function fieldText(key: string, v: unknown): string {
if (v == null || v === "") return "";
if (MONEY_FIELDS.has(key)) return fmtYuan(String(v));
if (BOOL_FIELDS.has(key)) return v ? "是" : "否";
if (key === "legal_regime") return legalRegime(String(v));
if (key === "deadline_at") return fmtDateTimeFull(String(v));
return String(v);
}
/* ---------- 时间 ---------- */
const pad = (n: number) => String(n).padStart(2, "0");
export const fmtDate = (d: Date) => `${d.getMonth() + 1}月${d.getDate()}日`;
export const fmtTime = (d: Date) => `${pad(d.getHours())}:${pad(d.getMinutes())}`;
export const fmtDateTime = (iso: string) => {
const d = new Date(iso);
return `${fmtDate(d)} ${fmtTime(d)}`;
};
/** 日期(YYYY-MM-DD,无时区)显示成「2026年11月15日」 */
export const fmtDay = (day: string) => {
const [y, m, d] = day.split("-").map(Number);
return `${y}年${m}月${d}日`;
};
/** 近期的日期(投标截止日)省掉年份,显示成「11月20日」 */
export const fmtMonthDay = (day: string) => {
const [, m, d] = day.split("-").map(Number);
return `${m}月${d}日`;
};
/**
* 键入的日期整理成 YYYY-MM-DD(设计规范 Q10:可直接键入)。接受 2026-11-20、2026/11/20、2026.11.20、20261120、
* 2026年11月20日;空串返回 "",写错或不存在的日期返回 null。
*/
export function parseDay(text: string): string | null {
const t = text.trim();
if (!t) return "";
const m = /^(\d{4})(?:[-/.年]\s*(\d{1,2})[-/.月]\s*(\d{1,2})日?|(\d{2})(\d{2}))$/.exec(t);
if (!m) return null;
const [y, mo, d] = [Number(m[1]), Number(m[2] ?? m[4]), Number(m[3] ?? m[5])];
const dt = new Date(Date.UTC(y, mo - 1, d));
if (dt.getUTCFullYear() !== y || dt.getUTCMonth() !== mo - 1 || dt.getUTCDate() !== d) return null;
return `${y}-${pad(mo)}-${pad(d)}`;
}
/** 完整的日期时间:「2026年11月20日 09:30」(北京时间) */
export function fmtDateTimeFull(iso: string): string {
const d = new Date(iso);
const parts = new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).formatToParts(d);
const get = (t: string) => parts.find((x) => x.type === t)?.value ?? "";
return `${get("year")}年${get("month")}月${get("day")}日 ${get("hour")}:${get("minute")}`;
}
export const fmtDuration = (ms: number) => {
const s = Math.max(0, Math.round(ms / 1000));
return { min: Math.floor(s / 60), sec: pad(s % 60) };
};
/** 投标截止的两行显示和颜色类(components.css 的 .due-*) */
export function deadline(iso: string | null | undefined, now = new Date()): { main: string; sub: string; cls: string } | null {
if (!iso) return null;
const d = new Date(iso);
const ms = d.getTime() - now.getTime();
const when = `${fmtDate(d)} ${fmtTime(d)}`;
if (ms < 0) return { main: "已截止", sub: when, cls: "due-past" };
const hours = Math.floor(ms / 3_600_000);
const days = Math.floor(hours / 24);
if (d.toDateString() === now.toDateString()) return { main: `今天 ${fmtTime(d)} 截止`, sub: `剩 ${hours} 小时`, cls: "due-today" };
if (hours <= 72) return { main: `剩 ${days} 天 ${hours % 24} 小时`, sub: when, cls: "due-soon" };
return { main: `剩 ${days} 天`, sub: when, cls: "" };
}
export function extOf(name: string): string {
const m = /\.([a-z0-9]{1,5})$/i.exec(name);
return m?.[1] ? m[1].toUpperCase() : "文件";
}
export function fmtSize(bytes: number): string {
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
/* ---------- 清单项的结构化取值(ItemSpec,openapi.yaml):只把原文里有的值整理出来显示,不推测 ---------- */
const PART: Record<string, string> = { price: "价格分", technical: "技术分", commercial: "商务分" };
const QUAL_KIND: Record<string, string> = { general: "通用资格", specific: "特定资格", credit: "信用要求" };
const OP: Record<string, string> = { ">=": "≥", "<=": "≤", "=": "=", contains: "包含", supports: "支持" };
const DATE_KIND: Record<string, string> = {
question_deadline: "提疑截止",
bid_deadline: "投标截止",
opening: "开标",
deposit_deadline: "保证金截止",
validity: "有效期",
};
const SUBMISSION: Record<string, string> = { electronic: "电子递交", paper: "纸质递交", ca_encrypted: "CA 加密", mixed: "电子 + 纸质" };
type Spec = Record<string, Record<string, unknown> | undefined>;
const str = (v: unknown): string => (v === null || v === undefined ? "" : String(v));
export function specFacts(spec: unknown): string[] {
const s = (spec ?? {}) as Spec;
const out: string[] = [];
const q = s.qualification;
if (q && QUAL_KIND[str(q.kind)]) out.push(QUAL_KIND[str(q.kind)]!);
const sc = s.scoring;
if (sc) {
if (sc.points) out.push(`分值 ${str(sc.points)}`);
if (PART[str(sc.part)]) out.push(PART[str(sc.part)]!);
}
const t = s.tech_param;
if (t && t.value) out.push([str(t.param), OP[str(t.op)] ?? "", `${str(t.value)}${str(t.unit)}`].filter(Boolean).join(" "));
const d = s.schedule;
if (d) {
if (d.at) out.push(`${DATE_KIND[str(d.date_kind)] ?? "时间"} ${fmtDateTime(str(d.at))}`);
else if (DATE_KIND[str(d.date_kind)]) out.push(DATE_KIND[str(d.date_kind)]!);
if (d.deposit_amount) out.push(`保证金 ${str(d.deposit_amount)}`);
if (SUBMISSION[str(d.submission_mode)]) out.push(SUBMISSION[str(d.submission_mode)]!);
}
return out;
}

221
apps/web/src/lib/queries.ts Normal file
View File

@@ -0,0 +1,221 @@
/** 服务端数据:全部由 TanStack Query 管理,不放进 Pinia(架构 §4.2)。 */
import { unwrap, type Schemas } from "@bid-doctor/api-client";
import { useInfiniteQuery, useQuery } from "@tanstack/vue-query";
import { computed, toValue, type MaybeRefOrGetter } from "vue";
import { api } from "./api";
import { qk } from "./keys";
export type Me = Schemas["Me"];
export type Quota = Schemas["QuotaOverview"];
export type Plan = Schemas["Plan"];
export type ProjectListItem = Schemas["ProjectListItem"];
export type ProjectDetail = Schemas["ProjectDetail"];
export type DocumentInfo = Schemas["Document"];
export type ParseJob = Schemas["ParseJob"];
export type Checklist = Schemas["Checklist"];
/**
* 契约里 ChecklistItem 把 categories、title、importance 列为必填,但它们定义在 allOf 的 ChecklistItemFields 里,
* openapi-typescript 生成的类型仍是可选。这里按契约的 required 收窄,不改字段本身。
*/
export type ChecklistItem = Schemas["ChecklistItem"] & Required<Pick<Schemas["ChecklistItemFields"], "categories" | "title" | "importance">>;
export type Citation = Schemas["Citation"];
export type MarkingRule = Schemas["MarkingRule"];
export type SessionInfo = Schemas["SessionInfo"];
export type CheckPreview = Schemas["CheckPreview"];
export type CheckRun = Schemas["CheckRun"];
export type Finding = Schemas["Finding"];
export type CheckItemResult = Schemas["CheckItemResult"];
export type ManualCheck = Schemas["ManualCheck"];
export type Qualification = Schemas["Qualification"];
export type Lot = Schemas["Lot"];
export type ProjectField = Schemas["ProjectField"];
export const fetchMe = () => unwrap(api.GET("/me"));
export function useMe() {
return useQuery({ queryKey: qk.me, queryFn: fetchMe, staleTime: 60_000 });
}
export function useQuota() {
return useQuery({ queryKey: qk.quota, queryFn: () => unwrap(api.GET("/quota")) });
}
/** 某个额度单位(如 parse)的本期账户;没有这个单位时为 undefined */
export function useQuotaAccount(unit: Schemas["QuotaUnit"]) {
const q = useQuota();
return computed(() => q.data.value?.accounts.find((a) => a.unit === unit));
}
export type QuotaEntry = Schemas["QuotaEntry"];
export type ActivationRequest = Schemas["ActivationRequest"];
export type Notification = Schemas["Notification"];
/** 额度流水:按时间倒序,按游标一页页往后取(「加载更多」) */
export function useQuotaEntries() {
return useInfiniteQuery({
queryKey: qk.quotaEntries,
queryFn: ({ pageParam }) =>
unwrap(api.GET("/quota/entries", { params: { query: { limit: 50, ...(pageParam ? { cursor: pageParam } : {}) } } })),
initialPageParam: "",
getNextPageParam: (last) => last.next_cursor ?? undefined,
});
}
export function useActivationRequests() {
return useQuery({ queryKey: qk.activationRequests, queryFn: () => unwrap(api.GET("/activation-requests")) });
}
/** 站内通知:顶栏铃铛。事件流在任务结束时让它失效;另外每分钟兜底刷新一次(运营开通、到期提醒不走事件流) */
export function useNotifications() {
return useQuery({
queryKey: qk.notifications,
queryFn: () => unwrap(api.GET("/notifications", { params: { query: { limit: 30 } } })),
refetchInterval: 60_000,
});
}
export function usePlans() {
return useQuery({ queryKey: qk.plans, queryFn: () => unwrap(api.GET("/plans")), staleTime: 10 * 60_000 });
}
export function useProjects() {
// 骨架阶段只取第一页(默认按截止时间排序);项目多了再接游标分页
return useQuery({ queryKey: qk.projects, queryFn: () => unwrap(api.GET("/projects", { params: { query: { limit: 100 } } })) });
}
export function useProject(id: MaybeRefOrGetter<string | undefined>) {
return useQuery({
queryKey: computed(() => qk.project(toValue(id) ?? "")),
queryFn: () => unwrap(api.GET("/projects/{project_id}", { params: { path: { project_id: toValue(id)! } } })),
enabled: computed(() => !!toValue(id)),
});
}
export function useDocument(id: MaybeRefOrGetter<string | undefined>) {
return useQuery({
queryKey: computed(() => qk.document(toValue(id) ?? "")),
queryFn: () => unwrap(api.GET("/documents/{document_id}", { params: { path: { document_id: toValue(id)! } } })),
enabled: computed(() => !!toValue(id)),
});
}
export const TERMINAL_JOB: ReadonlySet<Schemas["JobStatus"]> = new Set(["succeeded", "failed", "cancelled"]);
/** 解析进度:事件流推送为主(lib/events.ts 直接改这条缓存),每 5 秒轮询兜底,结束后停止轮询。 */
export function useParseJob(id: MaybeRefOrGetter<string>) {
return useQuery({
queryKey: computed(() => qk.parseJob(toValue(id))),
queryFn: () => unwrap(api.GET("/parse-jobs/{job_id}", { params: { path: { job_id: toValue(id) } } })),
refetchInterval: (query) => (query.state.data && TERMINAL_JOB.has(query.state.data.status) ? false : 5000),
});
}
export function useChecklist(projectId: MaybeRefOrGetter<string>) {
return useQuery({
queryKey: computed(() => qk.checklist(toValue(projectId))),
queryFn: () => unwrap(api.GET("/projects/{project_id}/checklist", { params: { path: { project_id: toValue(projectId) } } })),
});
}
/** 清单项。scope:bidding = 通用 + 所投的包(默认,FR-M2-04),all = 全部;hidden_count 是没列出的条数 */
export function useChecklistItems(projectId: MaybeRefOrGetter<string>, scope: MaybeRefOrGetter<"bidding" | "all"> = "bidding") {
return useQuery({
queryKey: computed(() => [...qk.checklistItems(toValue(projectId)), toValue(scope)] as const),
queryFn: () =>
unwrap(
api.GET("/projects/{project_id}/checklist/items", {
params: { path: { project_id: toValue(projectId) }, query: { lot_scope: toValue(scope) } },
}),
) as Promise<{ items: ChecklistItem[]; hidden_count: number }>,
});
}
export function useProjectDocuments(projectId: MaybeRefOrGetter<string>) {
return useQuery({
queryKey: computed(() => qk.projectDocuments(toValue(projectId))),
queryFn: () => unwrap(api.GET("/projects/{project_id}/documents", { params: { path: { project_id: toValue(projectId) } } })),
});
}
export function useCheckPreview(projectId: MaybeRefOrGetter<string>) {
return useQuery({
queryKey: computed(() => qk.checkPreview(toValue(projectId))),
queryFn: () => unwrap(api.GET("/projects/{project_id}/check-preview", { params: { path: { project_id: toValue(projectId) } } })),
});
}
export function useCheckRuns(projectId: MaybeRefOrGetter<string>) {
return useQuery({
queryKey: computed(() => qk.checkRuns(toValue(projectId))),
queryFn: () => unwrap(api.GET("/projects/{project_id}/check-runs", { params: { path: { project_id: toValue(projectId) } } })),
});
}
/** 检查进度:和解析一样,事件流推送为主、每 5 秒轮询兜底,结束后停止轮询。 */
export function useCheckRun(id: MaybeRefOrGetter<string>) {
return useQuery({
queryKey: computed(() => qk.checkRun(toValue(id))),
queryFn: () => unwrap(api.GET("/check-runs/{run_id}", { params: { path: { run_id: toValue(id) } } })),
enabled: computed(() => !!toValue(id)),
refetchInterval: (query) => (query.state.data && TERMINAL_JOB.has(query.state.data.status) ? false : 5000),
});
}
export function useManualChecks(projectId: MaybeRefOrGetter<string>) {
return useQuery({
queryKey: computed(() => qk.manualChecks(toValue(projectId))),
queryFn: () => unwrap(api.GET("/projects/{project_id}/manual-checks", { params: { path: { project_id: toValue(projectId) } } })),
});
}
export function useFindings(runId: MaybeRefOrGetter<string>, enabled: MaybeRefOrGetter<boolean>) {
return useQuery({
queryKey: computed(() => qk.findings(toValue(runId))),
queryFn: () => unwrap(api.GET("/check-runs/{run_id}/findings", { params: { path: { run_id: toValue(runId) } } })),
enabled: computed(() => toValue(enabled)),
});
}
/** 每条清单项的检查结论(C9):「已响应」也带投标侧出处,读标页显示在条目下面 */
export function useItemResults(runId: MaybeRefOrGetter<string>) {
return useQuery({
queryKey: computed(() => qk.itemResults(toValue(runId))),
queryFn: () => unwrap(api.GET("/check-runs/{run_id}/item-results", { params: { path: { run_id: toValue(runId) } } })),
enabled: computed(() => !!toValue(runId)),
});
}
/** 项目的历次报告(新的在前) */
export function useReports(projectId: MaybeRefOrGetter<string>) {
return useQuery({
queryKey: computed(() => qk.reports(toValue(projectId))),
queryFn: () => unwrap(api.GET("/projects/{project_id}/reports", { params: { path: { project_id: toValue(projectId) } } })),
});
}
/** 报告文件的短时签名地址(预览、下载都现取,不缓存) */
export async function reportFileUrl(id: string): Promise<string> {
return (await unwrap(api.GET("/reports/{report_id}/file", { params: { path: { report_id: id } } }))).url;
}
/** 读标清单上每条资质要求在资质库里的情况(资质库空时没有条目) */
export type QualificationMatch = Schemas["QualificationMatch"];
export function useQualificationMatches(projectId: MaybeRefOrGetter<string>) {
return useQuery({
queryKey: computed(() => qk.qualificationMatches(toValue(projectId))),
queryFn: () => unwrap(api.GET("/projects/{project_id}/qualification-matches", { params: { path: { project_id: toValue(projectId) } } })),
});
}
/** 资质库:一次取全部(最小资质库条目不多),类型筛选和搜索在前端做 */
export function useQualifications() {
return useQuery({ queryKey: qk.qualifications, queryFn: () => unwrap(api.GET("/qualifications")) });
}
/** 分包(含默认包;界面上不显示默认包) */
export function useLots(projectId: MaybeRefOrGetter<string>) {
return useQuery({
queryKey: computed(() => qk.lots(toValue(projectId))),
queryFn: () => unwrap(api.GET("/projects/{project_id}/lots", { params: { path: { project_id: toValue(projectId) } } })),
});
}

View File

@@ -0,0 +1,24 @@
import { ApiError } from "@bid-doctor/api-client";
import { MutationCache, QueryCache, QueryClient } from "@tanstack/vue-query";
let onUnauthorized: (e: ApiError) => void = () => {};
/** 会话失效(401:过期、被其他设备挤下线)时的统一处理,由 main.ts 注入(需要 router)。 */
export function setUnauthorizedHandler(fn: (e: ApiError) => void) {
onUnauthorized = fn;
}
const handle = (e: unknown) => {
if (e instanceof ApiError && e.status === 401) onUnauthorized(e);
};
export const queryClient = new QueryClient({
queryCache: new QueryCache({ onError: handle }),
mutationCache: new MutationCache({ onError: handle }),
defaultOptions: {
queries: {
// 4xx 是确定的结果,不重试;网络和 5xx 重试两次
retry: (count, e) => !(e instanceof ApiError && e.status < 500) && count < 2,
refetchOnWindowFocus: false,
},
},
});

23
apps/web/src/lib/toast.ts Normal file
View File

@@ -0,0 +1,23 @@
/** 操作结果提示(设计规范 §7.15):屏幕底部居中,墨色底;可带「撤销」。几秒后自动消失。 */
import { reactive } from "vue";
export interface Toast {
id: number;
text: string;
ok: boolean;
undo?: () => void;
}
export const toasts = reactive<Toast[]>([]);
let seq = 0;
export function toast(text: string, opt: { ok?: boolean; undo?: () => void; ms?: number } = {}): void {
const t: Toast = { id: ++seq, text, ok: opt.ok ?? true, undo: opt.undo };
toasts.push(t);
setTimeout(() => dismiss(t.id), opt.ms ?? (opt.undo ? 6000 : 3000));
}
export function dismiss(id: number): void {
const i = toasts.findIndex((t) => t.id === id);
if (i >= 0) toasts.splice(i, 1);
}

28
apps/web/src/main.ts Normal file
View File

@@ -0,0 +1,28 @@
import { VueQueryPlugin } from "@tanstack/vue-query";
import { createApp } from "vue";
import faviconUrl from "@design/logo/favicon.svg?url";
// 字体全部自托管(设计规范 §4.1):标题思源宋体只引 600;数字 Barlow Semi Condensed 500 / 600;正文用系统黑体
import "@fontsource/noto-serif-sc/600.css";
import "@fontsource/barlow-semi-condensed/500.css";
import "@fontsource/barlow-semi-condensed/600.css";
// 设计规范是唯一的样式来源(AR10):直接引用 docs/design/system,不复制
import "@design/system/tokens.css";
import "@design/system/components.css";
import "@/styles/app.css";
import App from "./App.vue";
import { queryClient, setUnauthorizedHandler } from "./lib/queryClient";
import { router } from "./router";
const icon = document.createElement("link");
icon.rel = "icon";
icon.href = faviconUrl;
document.head.append(icon);
setUnauthorizedHandler((e) => {
queryClient.clear();
const cur = router.currentRoute.value;
if (cur.name === "login") return;
void router.replace({ name: "login", query: { redirect: cur.fullPath, reason: e.code } });
});
createApp(App).use(router).use(VueQueryPlugin, { queryClient }).mount("#app");

View File

@@ -0,0 +1,154 @@
<script setup lang="ts">
/**
* 账号(m6-plan §1.5):企业名称、手机号(脱敏)、姓名、套餐;最下面注销账号(J4、data-model §6.2,决策 M6-8)。
* 注销:输入企业名称 + 短信验证码(发到自己的手机号)。全部项目、资质库彻底删除,剩余额度作废、不退款;
* 开通申请和发票记录按财务要求保留。注销后手机号可以重新注册。
*/
import { unwrap } from "@bid-doctor/api-client";
import { useQueryClient } from "@tanstack/vue-query";
import { computed, onBeforeUnmount, ref } from "vue";
import { RouterLink, useRouter } from "vue-router";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import { api, errorTitle, isApiError } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs";
import { useMe, useQuota } from "@/lib/queries";
import { toast } from "@/lib/toast";
useCrumbs(() => [{ label: "账号" }]);
const me = useMe();
const quota = useQuota();
const m = computed(() => me.data.value);
const plan = computed(() => quota.data.value?.plan ?? m.value?.plan);
const paidLeft = computed(() => plan.value && plan.value.code !== "free" && !plan.value.read_only);
const open = ref(false);
const name = ref("");
const code = ref("");
const err = ref<{ name?: string; code?: string; other?: string }>({});
const busy = ref(false);
const wait = ref(0);
let timer: number | undefined;
onBeforeUnmount(() => window.clearInterval(timer));
function ask() {
name.value = "";
code.value = "";
err.value = {};
open.value = true;
}
async function sendCode() {
try {
const r = await unwrap(api.POST("/me/sms-codes", { body: { purpose: "close_tenant" } }));
wait.value = r.resend_after_s;
window.clearInterval(timer);
timer = window.setInterval(() => (wait.value = Math.max(0, wait.value - 1)), 1000);
toast(`验证码已发送到 ${m.value?.user.phone_hint ?? "你的手机"}`);
} catch (e) {
err.value = { other: errorTitle(e) };
}
}
const router = useRouter();
const qc = useQueryClient();
async function close() {
const e: typeof err.value = {};
if (name.value.trim() !== m.value?.tenant.name) e.name = "企业名称不一致,请照上面的名称输入";
if (!/^\d{6}$/.test(code.value.trim())) e.code = "请填写 6 位验证码";
err.value = e;
if (Object.keys(e).length) return;
busy.value = true;
try {
await unwrap(api.POST("/tenant/close", { body: { sms_code: code.value.trim(), confirm_tenant_name: name.value.trim() } }));
open.value = false;
qc.clear();
await router.replace({ name: "login" });
toast("账号已注销,业务数据正在后台删除");
} catch (x) {
if (isApiError(x, "sms_code_invalid")) err.value = { code: "验证码不正确或已过期" };
else if (isApiError(x, "tenant_name_mismatch")) err.value = { name: "企业名称不一致,请照上面的名称输入" };
else err.value = { other: errorTitle(x) };
} finally {
busy.value = false;
}
}
</script>
<template>
<div class="content narrow">
<div class="page-title">
<div>
<h1>账号</h1>
<p>一个企业一个账号;额度和数据都归属企业主体。</p>
</div>
</div>
<section class="card" aria-labelledby="h-info">
<div class="card-h"><h2 id="h-info">企业和登录</h2></div>
<dl v-if="m" class="kv">
<dt>企业名称</dt><dd>{{ m.tenant.name }}</dd>
<dt>手机号</dt><dd class="fig">{{ m.user.phone_hint || "—" }}</dd>
<dt>姓名</dt><dd>{{ m.user.display_name }}</dd>
<dt>套餐</dt>
<dd>
{{ plan?.name }}<template v-if="plan?.read_only">(已到期,只读)</template>
<RouterLink class="lnk cap" :to="{ name: 'quota' }">额度与套餐</RouterLink>
</dd>
</dl>
</section>
<section class="card danger" aria-labelledby="h-close">
<div class="card-h"><h2 id="h-close">注销账号</h2></div>
<p>注销后,企业下的全部项目(招标文件、投标文件、清单、检查结果、报告)和资质库都会<b>彻底删除,不能恢复</b>。需要留底的报告请先下载。</p>
<p class="cap muted">开通申请和发票记录按财务要求保留;额度流水和不含业务内容的操作记录保留。注销后这个手机号可以重新注册。</p>
<button type="button" class="btn" @click="ask">注销账号</button>
</section>
</div>
<ConfirmDialog v-model:open="open" title="注销账号?" ok-text="注销账号" busy-text="正在注销…" :busy="busy" @ok="close">
<p>企业「{{ m?.tenant.name }}」下的全部项目和资质库都会彻底删除,<b>不能恢复</b>。</p>
<p v-if="paidLeft" class="warn-text">专业版还没到期(到 <span class="fig">{{ plan?.ends_at?.slice(0, 10) }}</span>),剩余额度作废,不退款。</p>
<div class="field fill" :class="{ err: err.name }">
<label for="cl-name">输入企业名称确认</label>
<input id="cl-name" v-model="name" class="input" :placeholder="m?.tenant.name" autocomplete="off" @input="err.name = undefined" />
<span v-if="err.name" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ err.name }}</span>
</div>
<div class="field fill" :class="{ err: err.code }">
<label for="cl-code">短信验证码</label>
<div class="code-row">
<input id="cl-code" v-model="code" class="input num" maxlength="6" inputmode="numeric" autocomplete="one-time-code" @input="err.code = undefined" />
<button type="button" class="btn" :disabled="wait > 0" @click="sendCode">{{ wait > 0 ? `${wait} 秒后重发` : `发送到 ${m?.user.phone_hint ?? "手机"}` }}</button>
</div>
<span v-if="err.code" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ err.code }}</span>
</div>
<p v-if="err.other" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err.other }}</p>
</ConfirmDialog>
</template>
<style scoped>
.content > .card {
margin-bottom: var(--gap-card);
}
.kv .lnk {
margin-left: var(--sp-3);
}
.danger p {
margin: 0 0 var(--sp-3);
}
.field {
margin-top: var(--sp-3);
}
.code-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--sp-2);
}
.input.num {
font-family: var(--font-figures);
font-variant-numeric: tabular-nums;
font-size: var(--fs-figure-sm);
}
.warn-text {
color: var(--warn-text);
}
.hint-warn {
margin: var(--sp-3) 0 0;
}
</style>

View File

@@ -0,0 +1,331 @@
<script setup lang="ts">
/**
* 上传投标文件并检查(原型 bid-check.html,FR-M3-01、02)。
* - 按分册上传;同一分册重新上传是新版本,检查默认用每个分册的最新版本;
* - 「检查范围」如实写会查什么、不查什么,报告首页照此写明(红线 3);
* - 扣点说明写在按钮旁边(红线 9),来自 check-preview;发起时带 expected_charge,和服务端算的不一致时不扣点(API4)。
*/
import { ApiError, unwrap, uploadFile, type Schemas } from "@bid-doctor/api-client";
import { Info, Plus, Upload } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { computed, ref } from "vue";
import { RouterLink, useRouter } from "vue-router";
import ProjectHead from "@/components/ProjectHead.vue";
import SelectBox from "@/components/SelectBox.vue";
import { api, apiOptions, errorTitle, isApiError } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys";
import { fmtDateTime, fmtSize, VOLUME_KIND, VOLUME_KINDS } from "@/lib/labels";
import { useCheckPreview, useProject, useQuotaAccount, type DocumentInfo } from "@/lib/queries";
import { platform } from "@/platform";
const props = defineProps<{ projectId: string }>();
const ACCEPT = [".pdf", ".docx", ".doc", ".wps"];
const router = useRouter();
const qc = useQueryClient();
const project = useProject(() => props.projectId);
const previewQ = useCheckPreview(() => props.projectId);
const parse = useQuotaAccount("parse");
const p = computed(() => project.data.value);
const pv = computed(() => previewQ.data.value);
useCrumbs(() => [{ label: "工作台", to: { name: "workbench" } }, { label: p.value?.name ?? "项目" }, { label: "上传投标文件并检查" }]);
const vols = computed<DocumentInfo[]>(() => pv.value?.bid_documents ?? []);
/* ---------- 上传 ---------- */
const KIND_OPTIONS = VOLUME_KINDS.map((k) => ({ value: k, label: VOLUME_KIND[k] }));
const kind = ref<Schemas["VolumeKind"]>("whole");
/** 已经有分册时,新增分册的表单收起来,点「添加分册」再展开;默认选还没用过的类型 */
const adding = ref(false);
const showNew = computed(() => !vols.value.length || adding.value || !!uploading.value);
function startAdd() {
const used = new Set(vols.value.map((v) => v.volume_kind));
kind.value = VOLUME_KINDS.find((k) => k !== "whole" && !used.has(k)) ?? "other";
adding.value = true;
}
const volName = ref("");
const replacing = ref<DocumentInfo | null>(null);
const uploading = ref<{ name: string; ratio: number } | null>(null);
const upErr = ref("");
const over = ref(false);
async function upload(file: File) {
upErr.value = "";
uploading.value = { name: file.name, ratio: 0 };
try {
const done = await uploadFile(api, apiOptions, file, "project_document", (ratio) => {
uploading.value = { name: file.name, ratio };
});
const target = replacing.value;
await unwrap(
api.POST("/projects/{project_id}/documents", {
params: { path: { project_id: props.projectId } },
body: target
? { upload_id: done.id, role: "bid_volume", replaces_document_id: target.id }
: { upload_id: done.id, role: "bid_volume", volume_kind: kind.value, ...(volName.value.trim() ? { volume_name: volName.value.trim() } : {}) },
}),
);
volName.value = "";
replacing.value = null;
adding.value = false;
await qc.invalidateQueries({ queryKey: qk.checkPreview(props.projectId) });
void qc.invalidateQueries({ queryKey: qk.projects });
} catch (e) {
// 格式不支持、加密、电子投标平台加密格式:在上传完成时就拦下,不扣点
const detail = e instanceof ApiError && e.problem.detail ? `(${e.problem.detail})` : "";
upErr.value = `${file.name}:${errorTitle(e)}${detail}`;
} finally {
uploading.value = null;
}
}
async function pick(target: DocumentInfo | null = null) {
if (uploading.value) return;
replacing.value = target;
const f = await platform.pickFile({ accept: ACCEPT });
if (f) void upload(f);
else replacing.value = null;
}
function onDrop(e: DragEvent) {
over.value = false;
const f = e.dataTransfer?.files[0];
if (f && !uploading.value) void upload(f);
}
/* ---------- 发起检查 ---------- */
const busy = ref(false);
const startErr = ref("");
const blockerText: Record<string, string> = {
no_parsed_tender: "还没有解析成功的招标文件",
checklist_empty: "正式清单还没有条目",
no_bid_documents: "先上传投标文件",
job_already_running: "这个项目正在检查",
quota_exhausted: "解析额度已用完",
project_read_only: "项目已递交或已归档",
};
const blockers = computed(() => (pv.value?.blockers ?? []).map((b) => blockerText[b] ?? b));
async function start() {
if (!pv.value?.can_start) return;
busy.value = true;
startErr.value = "";
try {
const run = await unwrap(
api.POST("/projects/{project_id}/check-runs", {
params: { path: { project_id: props.projectId } },
body: { document_ids: vols.value.map((d) => d.id), expected_charge: pv.value.charge },
}),
);
void qc.invalidateQueries({ queryKey: qk.quota });
void qc.invalidateQueries({ queryKey: qk.projects });
await router.push({ name: "results", params: { projectId: props.projectId, runId: run.id } });
} catch (e) {
const running = isApiError(e, "job_already_running") ? e.problem.job_id : undefined;
if (typeof running === "string") {
await router.push({ name: "results", params: { projectId: props.projectId, runId: running } });
return;
}
if (isApiError(e, "billing_changed")) {
startErr.value = "扣点情况有变化(例如 24 小时免扣窗口刚好过了),已刷新说明,确认后再点一次。本次没有扣点。";
void qc.invalidateQueries({ queryKey: qk.checkPreview(props.projectId) });
} else {
startErr.value = `${errorTitle(e)}。本次没有扣点。`;
}
} finally {
busy.value = false;
}
}
</script>
<template>
<ProjectHead :project="p" tab="check" />
<div class="content narrow">
<div class="page-title">
<div>
<h1>上传投标文件并检查</h1>
<p>只读加密前的 Word / PDF 稿。每次上传都是新版本,检查结果对应到具体版本。</p>
</div>
</div>
<div v-if="pv && !pv.checklist_confirmed" class="alert" role="note">
<Info class="ic lg" :stroke-width="1.5" />
<p>清单还没确认(或确认后又改过)。这次检查以当前清单为准<template v-if="pv.pending_item_count">;待确认区的 <span class="fig">{{ pv.pending_item_count }}</span> 条不参与检查</template>。</p>
<RouterLink class="btn sm" :to="{ name: 'checklist', params: { projectId } }">去确认清单</RouterLink>
</div>
<form class="card flush" novalidate aria-labelledby="h-form" @submit.prevent="start">
<div class="card-h"><h2 id="h-form">投标文件分册</h2></div>
<div class="card-b form-b">
<div v-for="v in vols" :key="v.id" class="file-row">
<span class="ext">{{ v.detected_format.toUpperCase() }}</span>
<div>
<span class="name"><b>{{ v.label }}</b></span>
<span class="meta">{{ v.original_filename }}<template v-if="v.page_count"> · <span class="fig xs">{{ v.page_count }}</span> 页</template> · {{ fmtSize(v.size_bytes) }} · {{ fmtDateTime(v.created_at) }}</span>
</div>
<span class="acts"><button type="button" class="btn sm" :disabled="!!uploading" @click="pick(v)">上传新版本</button></span>
</div>
<button v-if="!showNew" type="button" class="btn sm add-vol" @click="startAdd"><Plus class="ic" :stroke-width="1.5" />添加分册</button>
<template v-if="showNew">
<div class="new-vol">
<div class="field">
<label for="vk">分册类型</label>
<SelectBox
id="vk"
:model-value="kind"
:options="KIND_OPTIONS"
:disabled="!!uploading"
@update:model-value="(v) => (kind = v as Schemas['VolumeKind'])"
/>
</div>
<div class="field">
<label for="vn">分册名称 <small>(选填)</small></label>
<input id="vn" v-model="volName" class="input" maxlength="40" placeholder="例如:技术册" autocomplete="off" :disabled="!!uploading" />
</div>
</div>
<div
class="drop"
:class="{ 'is-over': over }"
tabindex="0"
role="button"
aria-describedby="drop-sub"
@click="pick()"
@keydown.enter.prevent="pick()"
@keydown.space.prevent="pick()"
@dragenter.prevent="over = true"
@dragover.prevent="over = true"
@dragleave.prevent="over = false"
@drop.prevent="onDrop"
>
<Upload class="ic lg" :stroke-width="1.5" />
<b>{{ uploading ? `正在上传 ${uploading.name}(${Math.round(uploading.ratio * 100)}%)` : "拖入或点击上传分册" }}</b>
<span id="drop-sub">Word / PDF;电子投标客户端加密后的文件读不了,请上传加密前的稿子</span>
</div>
</template>
<p v-if="upErr" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span><span>{{ upErr }}</span></p>
<fieldset class="form-sec scope">
<legend>检查范围<small>报告首页会照此写明</small></legend>
<div>
<h3>会查</h3>
<ul>
<li>正式清单每一条在投标文件里有没有响应:未找到响应、负偏离未说明、疑似不满足</li>
<li>每条结论都带投标文件的原文出处;出处核对不上的,不算「已响应」</li>
<li>报价形式:大写和小写金额、分项报价合计、超过最高限价和分项 / 单价上限、另外给的报价</li>
<li>形式要件:别的项目名称、没填的地方、落款日期、投标有效期、附加条件、响应表里的未响应</li>
<li>资质:证书有效期、等级、有没有覆盖(按资质库和投标文件文字)</li>
</ul>
</div>
<div class="no">
<h3>这一版还不查</h3>
<ul>
<li>分项报价表和采购需求清单逐项对照、格式文件固定文字被改、授权代表和签字人一致、声明函填报、签章有无、低价说明、跨页的响应表、证书主体和投标人是否一致:还没有上线</li>
<li>签章真伪(以后也只看有无,不鉴定真伪)</li>
<li>份数、密封、CA 加密、递交方式:文件里看不出来,列入「需人工核对」,递交前自己核对</li>
<li>报价是否合理、该报多少,评分高低</li>
</ul>
</div>
</fieldset>
<p v-if="project.data.value?.bidding_lots?.length" class="cap muted lots-line">
本次查:通用条目 + {{ project.data.value.bidding_lots.map((n) => `采购包 ${n}`).join("、") }}
<RouterLink class="lnk" :to="{ name: 'lots', params: { projectId } }">更改所投的包</RouterLink>
</p>
<!-- AD4:补遗带来的新值没确认前查标用旧值;只提示,不阻止检查 -->
<div v-if="pv?.pending_updates" class="alert warn" role="note">
<span class="ico w" aria-hidden="true">!</span>
<p>补遗带来的新值还有 <span class="fig">{{ pv.pending_updates }}</span> 处没确认,这次检查按原来的值。可以先在概览和读标页确认。</p>
<RouterLink class="btn sm" :to="{ name: 'overview', params: { projectId } }">去确认</RouterLink>
</div>
<div v-if="startErr" class="alert warn" role="alert"><span class="ico w" aria-hidden="true">!</span><p>{{ startErr }}</p></div>
</div>
<div class="card-f">
<span v-if="pv" class="cost">
<template v-if="pv.billing === 'charged'">扣 <span class="fig">1</span> 次解析额度;检查失败、中断或取消不扣点<template v-if="parse"> · 剩 <span class="fig">{{ parse.balance }}</span> 次</template></template>
<template v-else-if="pv.billing === 'waived_recheck'">24 小时内复查,不扣额度<template v-if="pv.waive_until">(到 {{ fmtDateTime(pv.waive_until) }},复查不延长窗口)</template></template>
<template v-else>免费版:这个项目内检查不限次数</template>
<template v-if="blockers.length"> · <span class="hint-warn inline">{{ blockers.join(";") }}</span></template>
</span>
<RouterLink class="btn" :to="{ name: 'checklist', params: { projectId } }">取消</RouterLink>
<button type="submit" class="btn primary swap" :class="{ 'is-loading': busy }" :aria-busy="busy" :disabled="busy || !pv?.can_start">
<span class="idle">开始检查</span><span class="busy">正在提交…</span>
</button>
</div>
</form>
</div>
</template>
<style scoped>
.alert {
margin-bottom: var(--sp-4);
}
.card-b.form-b {
display: flex;
flex-direction: column;
gap: var(--sp-4);
padding-top: var(--sp-2);
padding-bottom: var(--sp-6);
}
.add-vol {
align-self: flex-start;
}
.file-row .name {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.file-row .meta {
display: block;
font-size: var(--fs-caption);
line-height: var(--lh-caption);
color: var(--text-secondary);
}
.new-vol {
display: grid;
grid-template-columns: 160px minmax(0, 1fr);
gap: var(--sp-4);
}
.new-vol .field {
max-width: none;
}
.scope {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--sp-6);
border: 0;
margin: var(--sp-2) 0 0;
padding: 0;
}
.scope legend {
grid-column: 1 / -1;
padding: 0;
font-weight: var(--fw-bold);
margin-bottom: var(--sp-2);
}
.scope legend small {
margin-left: var(--sp-2);
font-weight: var(--fw-regular);
color: var(--text-secondary);
}
.scope h3 {
margin: 0 0 var(--sp-1);
font-size: var(--fs-body);
}
.scope ul {
margin: 0;
padding-left: 1.2em;
}
.scope .no {
color: var(--text-secondary);
}
.card-f .cost {
margin-right: auto;
white-space: normal;
}
.hint-warn.inline {
display: inline;
}
</style>

View File

@@ -0,0 +1,826 @@
<script setup lang="ts">
/**
* 读标(原型 checklist.html):项目头 + 项目检查清单 + 原文查看器(对照布局 §5.3)。
* - 清单按分类分组,待确认区放在顶部、不和正式清单混排(§7.6);顶部写明标注规则和数量核对提示;
* - 点清单项或出处:右侧翻到原文那一页并高亮原句;J / K 上下条;
* - 待确认:采纳 / 补上出处后采纳 / 修改后采纳 / 丢弃;正式清单:修改、补出处、删除(可撤销)、修改记录;
* - 补出处:查看器进入选句模式,点选同一页上的句子,建一条人工出处再挂到条目上;
* - 确认清单:生成不可变版本,查标以这一版为依据;之后再改会回到草稿,历史保留(FR-M2-08)。
* - 检查过的项目:每条下面写最近一次检查的结论(C9),点投标侧出处在右侧看投标原文;「已响应」也能反馈判断有误。
* - 资格条件、评分项里的证书要求:写资质库里有没有、在投标截止日是否有效(资质库空的不写)。
*/
import { unwrap } from "@bid-doctor/api-client";
import { FileText, Info, Plus, Search, SlidersHorizontal } from "@lucide/vue";
import { useQuery, useQueryClient } from "@tanstack/vue-query";
import { PopoverContent, PopoverPortal, PopoverRoot, PopoverTrigger } from "reka-ui";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { RouterLink, useRoute, useRouter } from "vue-router";
import ChecklistItemRow from "@/components/ChecklistItemRow.vue";
import CitationBlock from "@/components/CitationBlock.vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import FeedbackDialog, { type FeedbackTarget } from "@/components/FeedbackDialog.vue";
import HistoryDialog from "@/components/HistoryDialog.vue";
import ItemDialog from "@/components/ItemDialog.vue";
import PdfViewer, { type ViewerTarget } from "@/components/PdfViewer.vue";
import ProjectHead from "@/components/ProjectHead.vue";
import SelectBox from "@/components/SelectBox.vue";
import { api, errorTitle } from "@/lib/api";
import { useChecklistOps, useDeletedItems, type ItemFields } from "@/lib/checklistOps";
import { groupCitations, type CitationGroup } from "@/lib/citations";
import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys";
import { category, CATEGORY_ORDER, fmtDateTime, MARKER, projectStatus } from "@/lib/labels";
import {
useChecklist,
useChecklistItems,
useItemResults,
useLots,
useProject,
useQualificationMatches,
type ChecklistItem,
type Citation,
type MarkingRule,
} from "@/lib/queries";
import { toast } from "@/lib/toast";
const props = defineProps<{ projectId: string }>();
const project = useProject(() => props.projectId);
const checklist = useChecklist(() => props.projectId);
const lotScope = ref<"bidding" | "all">("bidding"); // 默认只列通用 + 所投的包(FR-M2-04)
const itemsQ = useChecklistItems(() => props.projectId, lotScope);
const lotsQ = useLots(() => props.projectId);
const realLots = computed(() => (lotsQ.data.value?.items ?? []).filter((l) => !l.is_default));
const biddingNos = computed(() => realLots.value.filter((l) => l.is_bidding).map((l) => `采购包 ${l.lot_no}`));
const docsQ = useQuery({
queryKey: computed(() => ["project-documents", props.projectId] as const),
queryFn: () => unwrap(api.GET("/projects/{project_id}/documents", { params: { path: { project_id: props.projectId } } })),
});
const ops = useChecklistOps(() => props.projectId);
const p = computed(() => project.data.value);
const cl = computed(() => checklist.data.value);
const readOnly = computed(() => p.value?.status === "submitted" || p.value?.status === "archived");
useCrumbs(() => [{ label: "工作台", to: { name: "workbench" } }, { label: p.value?.name ?? "项目" }, { label: "读标" }]);
/* ---------- 最近一次检查的结论(C9):只看已完成的那次 ---------- */
const router = useRouter();
const runId = computed(() => p.value?.latest_check_run_id ?? "");
const resultsQ = useItemResults(runId);
const checkOf = computed(() => new Map((resultsQ.data.value?.items ?? []).map((r) => [r.checklist_item_id, r])));
function goFindings() {
if (runId.value) void router.push({ name: "results", params: { projectId: props.projectId, runId: runId.value } });
}
/* 资质库的情况:资质库是空的时没有条目,什么都不显示(QL3) */
const libQ = useQualificationMatches(() => props.projectId);
const libOf = computed(() => new Map((libQ.data.value?.items ?? []).map((x) => [x.checklist_item_id, x.families])));
const libAsOf = computed(() => (libQ.data.value?.by_deadline === false ? "今天" : "投标截止日"));
const fbFor = ref<FeedbackTarget | null>(null);
function askFeedback(i: ChecklistItem) {
fbFor.value = {
objectType: "checklist_item",
objectId: i.id,
title: i.title,
kind: "false_negative",
reasons: ["投标文件里其实没有响应这条", "引的那句不是这条的响应", "响应了,但不满足要求", "其他"],
note: "反馈只用于改进检查规则,不改变这次检查的结论。",
};
}
/* ---------- 分组与筛选 ---------- */
const all = computed(() => (itemsQ.data.value?.items ?? []).filter((i) => !i.deleted_at));
const formal = computed(() => all.value.filter((i) => i.zone === "formal"));
const pending = computed(() => all.value.filter((i) => i.zone === "pending"));
type Cat = "all" | "pending" | "deleted" | ChecklistItem["categories"][number];
const cat = ref<Cat>("all");
const kw = ref("");
const starOnly = ref(false);
/* 补遗(FR-M2-10):按来源文件筛选(解析页「核对补遗」带 ?source=<文档>),只看待确认的日期更新 */
const route = useRoute();
const source = ref(typeof route.query.source === "string" ? route.query.source : "");
const supOnly = ref(false);
const sources = computed(() => {
const m = new Map<string, { id: string; label: string; n: number }>();
for (const i of formal.value) {
const d = i.origin_document;
if (!d) continue;
const e = m.get(d.id) ?? { id: d.id, label: d.label, n: 0 };
e.n++;
m.set(d.id, e);
}
return [...m.values()].sort((a, b) => (a.label === "招标文件" ? -1 : b.label === "招标文件" ? 1 : a.label.localeCompare(b.label, "zh-CN")));
});
const addenda = computed(() => sources.value.filter((x) => x.label !== "招标文件"));
const supPending = (i: ChecklistItem) => !!i.superseded_by && !i.superseded_by.confirmed;
const supCount = computed(() => formal.value.filter(supPending).length);
const deletedQ = useDeletedItems(() => props.projectId, () => cat.value === "deleted");
const deleted = computed(() => deletedQ.data.value ?? []);
const catCount = (c: string) => formal.value.filter((i) => i.categories.includes(c as ChecklistItem["categories"][number])).length;
const cats = computed(() => CATEGORY_ORDER.filter((c) => catCount(c) > 0));
const match = (i: ChecklistItem) =>
(!starOnly.value || i.importance === "substantive") &&
(!kw.value || i.title.includes(kw.value.trim())) &&
(!source.value || i.origin_document?.id === source.value) &&
(!supOnly.value || supPending(i));
/** 「全部」视图下每条只出现一次,放在它的第一个分类里(一条要求可以属于多个分类) */
const groups = computed(() => {
const order = cat.value === "all" ? cats.value : cats.value.filter((c) => c === cat.value);
const seen = new Set<string>();
return order
.map((c) => {
const rows = formal.value.filter((i) => i.categories.includes(c) && match(i) && (cat.value !== "all" || !seen.has(i.id)));
rows.forEach((i) => seen.add(i.id));
return { id: c, name: category(c), rows };
})
.filter((g) => g.rows.length);
});
const listMode = computed(() => (cat.value === "pending" || cat.value === "deleted" ? cat.value : "formal"));
const showTop = computed(() => cat.value === "all" && !kw.value && !starOnly.value && !source.value && !supOnly.value);
const showPending = computed(() => (cat.value === "all" && showTop.value) || cat.value === "pending");
/** 当前视图里自上而下的条目(J / K 用) */
const visible = computed<ChecklistItem[]>(() => {
if (cat.value === "deleted") return deleted.value;
const top = showPending.value ? pending.value : [];
return cat.value === "pending" ? top : [...top, ...groups.value.flatMap((g) => g.rows)];
});
/** 确认「已被补遗 N 更新」:采用时旧条目删除(可以恢复),保留两条时解除关联(AD3) */
async function resolveSupersession(i: ChecklistItem, accept: boolean) {
try {
await unwrap(api.POST("/checklist-items/{item_id}/supersession", { params: { path: { item_id: i.id } }, body: { accept } }));
void qc.invalidateQueries({ queryKey: qk.checklistItems(props.projectId) });
void qc.invalidateQueries({ queryKey: qk.checklist(props.projectId) });
void qc.invalidateQueries({ queryKey: qk.project(props.projectId) });
toast(accept ? "已按补遗更新,旧条目已删除(可以在「已删除」里恢复)" : "两条都保留,请自己核对");
} catch (e) {
toast(errorTitle(e), { ok: false });
}
}
/* ---------- 标注规则(如「打 ★ 的为实质性要求」)和数量核对 ---------- */
function ruleText(r: MarkingRule): string {
const mk = r.marker === "keyword" ? `「${r.marker_text ?? ""}」字样` : `「${r.marker_text || MARKER[r.marker]}」`;
const head = `标注${mk}的为${r.meaning === "substantive" ? "实质性要求" : "重要要求"}。`;
return r.declared_count == null
? `${head}文件没写这类条款的总数,已识别 ${r.found_count} 条,请对照原文核对有无遗漏。`
: `${head}文件写明共 ${r.declared_count} 条,已识别 ${r.found_count} 条。`;
}
const loading = computed(() => itemsQ.isPending.value || checklist.isPending.value);
const loadErr = computed(() => itemsQ.error.value ?? checklist.error.value);
/* ---------- 当前条与原文查看器 ---------- */
const tender = computed(() => docsQ.data.value?.items.find((d) => d.role === "tender" && d.status === "ready"));
const selId = ref<string | null>(null);
const selected = computed(() => [...all.value, ...deleted.value].find((i) => i.id === selId.value) ?? null);
const viewDoc = ref<{ id: string; label: string } | null>(null);
const target = ref<(ViewerTarget & { id: string }) | null>(null);
function openCitation(c: Citation, tail: Citation[] = []) {
viewDoc.value = { id: c.document_id, label: c.document_label };
const more = tail.map((x) => ({ page: x.page_no, quads: x.quads }));
target.value = { id: c.id, page: c.page_no, quads: c.quads, sha: c.normalized_sha256, more };
}
const openGroup = (g: CitationGroup) => openCitation(g.head, g.tail);
/* ---------- 响应方式(C12):招标文件要求逐条响应,还是只列偏离、没列出的视为接受;查标按它判没找到响应算不算问题 ---------- */
const qc = useQueryClient();
const MODE_CATEGORY: Record<string, string> = { technical: "技术要求", commercial: "商务要求" };
const MODE_OPTIONS = [
{ value: "explicit", label: "逐条响应" },
{ value: "implicit", label: "只列偏离,没列出的视为接受" },
];
const MODE_STATUS: Record<string, string> = { unknown: "招标文件没写明,按逐条响应处理", conflict: "招标文件两处写法不一致,按逐条响应处理" };
const modeBusy = ref("");
// 响应方式一个项目设一次,平时收在清单栏的按钮里;招标文件没写明或两处不一致、又没人改过时,按钮上加提示点
const modeAttn = computed(() => (cl.value?.response_modes ?? []).some((m) => m.origin !== "human" && !!MODE_STATUS[m.status]));
async function setMode(category: "technical" | "commercial", mode: "explicit" | "implicit") {
modeBusy.value = category;
try {
await unwrap(
api.PUT("/projects/{project_id}/checklist/response-modes/{category}", {
params: { path: { project_id: props.projectId, category } },
body: { mode },
}),
);
await qc.invalidateQueries({ queryKey: qk.checklist(props.projectId) });
toast(`已改为「${mode === "implicit" ? "只列偏离,没列出的视为接受" : "逐条响应"}」,下次检查按这个判断`);
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
modeBusy.value = "";
}
}
function select(i: ChecklistItem, opt: { scroll?: boolean } = {}) {
if (picking.value && picking.value.item.id !== i.id) cancelPick();
selId.value = i.id;
const g = groupCitations(i.citations)[0];
if (g) openGroup(g);
else target.value = null;
if (opt.scroll) document.querySelector<HTMLElement>(`[data-item="${i.id}"]`)?.scrollIntoView({ block: "nearest" });
}
function step(d: number) {
const v = visible.value;
if (!v.length) return;
const k = v.findIndex((x) => x.id === selId.value);
const n = v[Math.max(0, Math.min(v.length - 1, (k < 0 ? -1 : k) + d))];
if (n) select(n, { scroll: true });
}
watch(tender, (d) => {
if (d && !viewDoc.value) viewDoc.value = { id: d.id, label: "招标文件" };
});
/* ---------- 补出处:查看器进入选句模式 ---------- */
const picking = ref<{ item: ChecklistItem; purpose: "accept" | "add"; changes?: ItemFields } | null>(null);
const picked = ref<string[]>([]);
const pickBusy = ref(false);
function startPick(i: ChecklistItem, purpose: "accept" | "add", changes?: ItemFields) {
selId.value = i.id;
if (!viewDoc.value && tender.value) viewDoc.value = { id: tender.value.id, label: "招标文件" };
if (!viewDoc.value) return toast("还没有可用的招标文件原文", { ok: false });
picking.value = { item: i, purpose, changes };
picked.value = [];
}
function cancelPick() {
picking.value = null;
picked.value = [];
}
async function finishPick() {
const pk = picking.value;
if (!pk || !viewDoc.value || !picked.value.length) return;
pickBusy.value = true;
try {
const c = await ops.cite(viewDoc.value.id, picked.value);
if (pk.purpose === "accept") {
await ops.accept(pk.item.id, { citation_ids: [c.id], changes: pk.changes });
toast("已补上出处并采纳,进入正式清单");
} else {
await ops.setCitations(pk.item.id, [...pk.item.citations.map((x) => x.id), c.id]);
toast("已补上出处");
}
cancelPick();
openCitation(c);
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
pickBusy.value = false;
}
}
/* ---------- 条目操作 ---------- */
async function act<T>(p: Promise<T>, ok: string, undo?: () => void): Promise<T | undefined> {
try {
const out = await p;
toast(ok, { undo });
return out;
} catch (e) {
toast(errorTitle(e), { ok: false });
return undefined;
}
}
const short = (t: string) => (t.length > 16 ? `${t.slice(0, 16)}…` : t);
const accept = (i: ChecklistItem) => act(ops.accept(i.id, {}), "已采纳,进入正式清单");
const remove = (i: ChecklistItem) =>
act(ops.remove(i.id), `已${i.zone === "pending" ? "丢弃" : "删除"}「${short(i.title)}」`, () => void act(ops.restore(i.id), "已恢复"));
const restore = (i: ChecklistItem) => act(ops.restore(i.id), i.zone === "pending" ? "已恢复到待确认" : "已恢复到正式清单");
const dlg = ref<{ open: boolean; mode: "edit" | "accept" | "create"; item: ChecklistItem | null }>({ open: false, mode: "edit", item: null });
const openDialog = (mode: "edit" | "accept" | "create", item: ChecklistItem | null = null) => (dlg.value = { open: true, mode, item });
async function onSubmit(changes: ItemFields, ack: boolean, done: (e?: unknown) => void) {
const { mode, item } = dlg.value;
try {
if (mode === "create") {
const created = await ops.create({ ...changes, categories: changes.categories ?? [], title: changes.title ?? "" });
done();
toast(`已添加到「${category(created.categories[0] ?? "")}」`);
cat.value = "all";
selId.value = created.id;
} else if (mode === "accept" && item) {
await ops.accept(item.id, { changes, no_citation_ack: ack || undefined });
done();
toast(ack ? "已采纳,标注为「人工确认、无出处」" : "已采纳,进入正式清单");
} else if (item) {
if (Object.keys(changes).length) await ops.update(item.id, changes);
done();
if (Object.keys(changes).length) toast("已保存,修改记录已留存");
}
} catch (e) {
done(e);
}
}
function onDialogPick(changes: ItemFields) {
if (dlg.value.item) startPick(dlg.value.item, "accept", changes);
}
const hist = ref<{ open: boolean; item: ChecklistItem | null }>({ open: false, item: null });
/* ---------- 确认清单 ---------- */
const confirmOpen = ref(false);
const confirming = ref(false);
const nextVersion = computed(() => (cl.value?.version_no ?? 0) + 1);
async function doConfirm() {
confirming.value = true;
try {
const r = await ops.confirm();
confirmOpen.value = false;
toast(`清单 v${r.version_no} 已确认`);
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
confirming.value = false;
}
}
/* ---------- 键盘:J / K 上下条,Enter 看原文 ---------- */
function onKey(e: KeyboardEvent) {
if (e.ctrlKey || e.metaKey || e.altKey || document.querySelector(".scrim")) return;
if ((e.target as HTMLElement).closest("input, textarea, select, [contenteditable]")) return;
if (e.key === "j" || e.key === "k") {
e.preventDefault();
step(e.key === "j" ? 1 : -1);
} else if (e.key === "Escape" && picking.value) cancelPick();
}
onMounted(() => window.addEventListener("keydown", onKey));
onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
</script>
<template>
<ProjectHead :project="p" tab="checklist" />
<div class="content split">
<div class="split-main">
<div class="cl-bar">
<div class="cl-title">
<h2>项目检查清单<span v-if="cl" class="tag">{{ cl.status === "confirmed" ? `已确认 v${cl.version_no}` : "草稿" }}</span></h2>
<p v-if="cl" class="status">
<span v-if="cl.status === 'confirmed' && cl.confirmed_by" class="sign cap">
<span aria-hidden="true">✓</span><b>{{ cl.confirmed_by.name }}</b><span v-if="cl.confirmed_at" class="fig">{{ fmtDateTime(cl.confirmed_at) }}</span><span>确认</span>
</span>
<span v-else-if="cl.status !== 'confirmed'" class="cap muted">{{ cl.version_no ? `有修改,待重新确认 · 上一版 v${cl.version_no} 已确认` : "还没确认过;确认后,查标以这一版清单为依据" }}</span>
</p>
</div>
<div v-if="cl" class="actions">
<PopoverRoot v-if="cl.response_modes?.length">
<PopoverTrigger as-child>
<button type="button" class="btn mode-btn" :aria-label="modeAttn ? '响应方式(有待确认的)' : '响应方式'">
<SlidersHorizontal class="ic" :stroke-width="1.5" />响应方式<span v-if="modeAttn" class="dot" aria-hidden="true"></span>
</button>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent class="menu popper modes-pop" align="end" :side-offset="6" aria-label="招标文件的响应方式">
<p class="cap muted">招标文件要求逐条响应,还是只列偏离、没列出的视为接受。查标按它判断没找到响应算不算问题。</p>
<div v-for="m in cl.response_modes" :key="m.category" class="mode">
<label :for="`mode-${m.category}`">{{ MODE_CATEGORY[m.category] }}</label>
<SelectBox
:id="`mode-${m.category}`"
size="sm"
:model-value="m.mode"
:options="MODE_OPTIONS"
:disabled="readOnly || modeBusy === m.category"
@update:model-value="(v) => v !== m.mode && setMode(m.category, v as 'explicit' | 'implicit')"
/>
<div class="mode-meta">
<button v-if="m.citations.length" type="button" class="cite-src inline" @click="openCitation(m.citations[0]!)">依据 · 第 <span class="fig">{{ m.citations[0]!.page_no }}</span> 页</button>
<span v-if="m.origin === 'human'" class="sign cap"><span aria-hidden="true">✓</span><b>{{ m.set_by?.name }}</b><span>改过</span></span>
<span v-else-if="MODE_STATUS[m.status]" class="hint-warn cap">{{ MODE_STATUS[m.status] }}</span>
</div>
</div>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
<template v-if="!readOnly">
<RouterLink v-if="tender" class="btn" :to="{ name: 'project-new', query: { project: projectId, addendum: '1' } }"><Plus class="ic" :stroke-width="1.5" />追加补遗</RouterLink>
<button type="button" class="btn" :disabled="!all.length" @click="openDialog('create')"><Plus class="ic" :stroke-width="1.5" />新增条目</button>
<button v-if="cl.status !== 'confirmed'" type="button" class="btn primary" :disabled="!formal.length" @click="confirmOpen = true">确认清单</button>
</template>
</div>
</div>
<div v-if="addenda.length" class="alert warn warn-row" role="note">
<span class="ico w" aria-hidden="true">!</span>
<p>
{{ addenda.map((x) => `${x.label} 带来 ${x.n} 条`).join(",") }}<template v-if="supCount">,{{ supCount }} 处日期等你确认</template>。标博士不判断哪些旧条目被补遗改了或作废了,请核对:改了的旧条目可以修改或删除。
</p>
<button v-for="x in addenda" :key="x.id" type="button" class="btn sm" @click="(source = x.id), (cat = 'all')">只看{{ x.label }}</button>
<button v-if="supCount" type="button" class="btn sm" @click="(supOnly = true), (cat = 'all')">看日期更新</button>
</div>
<div v-if="realLots.length > 1" class="lot-scope" aria-label="清单范围">
<span class="cap muted">采购包</span>
<template v-if="lotScope === 'bidding'">
<span>通用 + {{ biddingNos.join("、") }}</span>
<span v-if="itemsQ.data.value?.hidden_count" class="cap muted">· 不投的包隐藏 <span class="fig">{{ itemsQ.data.value.hidden_count }}</span> 条</span>
<button v-if="itemsQ.data.value?.hidden_count" type="button" class="lnk cap" @click="lotScope = 'all'">显示全部</button>
</template>
<template v-else>
<span>全部采购包的条目</span>
<button type="button" class="lnk cap" @click="lotScope = 'bidding'">只看所投的包</button>
</template>
<RouterLink v-if="!readOnly" class="lnk cap" :to="{ name: 'lots', params: { projectId } }">更改所投的包</RouterLink>
</div>
<div v-for="w in cl?.warnings ?? []" :key="w.code" class="alert warn warn-row" role="note">
<span class="ico w" aria-hidden="true">!</span>
<p>{{ w.message }}</p>
</div>
<div v-if="readOnly" class="alert warn warn-row" role="note">
<span class="ico w" aria-hidden="true">!</span>
<p>项目已{{ projectStatus(p!.status) }},清单只读。要修改,先撤销{{ p!.status === "archived" ? "归档" : "递交" }}。</p>
</div>
<section class="card flush cmp-list" aria-label="项目检查清单">
<div class="cap-edge" aria-hidden="true"></div>
<nav class="cats" aria-label="清单分类">
<button type="button" :aria-current="cat === 'all'" @click="cat = 'all'">全部<span class="fig">{{ formal.length }}</span></button>
<button v-for="c in cats" :key="c" type="button" :aria-current="cat === c" @click="cat = c">{{ category(c) }}<span class="fig">{{ catCount(c) }}</span></button>
<hr />
<button type="button" class="ai" :aria-current="cat === 'pending'" @click="cat = 'pending'">待确认<span class="fig">{{ pending.length }}</span></button>
<button v-if="(cl?.counts.deleted ?? 0) > 0" type="button" :aria-current="cat === 'deleted'" @click="cat = 'deleted'">
已删除<span class="fig">{{ cl?.counts.deleted }}</span>
</button>
<p class="hint cap"><span class="kbd">J</span><span class="kbd">K</span> 上下条</p>
</nav>
<div class="lst">
<div v-if="loading" class="lst-pad"><span class="skel t"></span><span class="skel"></span><span class="skel"></span></div>
<div v-else-if="loadErr" class="lst-pad">
<div class="alert warn" role="alert"><span class="ico w" aria-hidden="true">!</span><p>{{ errorTitle(loadErr) }}</p></div>
</div>
<div v-else-if="!all.length && !(cl?.counts.deleted)" class="empty">
<FileText class="ic xl" :stroke-width="1.25" />
<b>还没有清单</b>
<p>上传招标文件并解析后,这里会逐条列出要求,每一条都带原文出处。</p>
<RouterLink class="btn primary" :to="{ name: 'project-new', query: { project: projectId } }">上传招标文件</RouterLink>
</div>
<template v-else>
<div v-if="listMode === 'formal'" class="lst-tb">
<label class="search">
<Search class="ic" :stroke-width="1.5" />
<input v-model="kw" class="input" placeholder="搜索清单" aria-label="搜索清单" />
</label>
<label class="choice cap"><input v-model="starOnly" type="checkbox" class="ck" />只看 ★</label>
<SelectBox
v-if="sources.length > 1"
:model-value="source"
size="sm"
class="src"
aria-label="来源"
:options="[{ value: '', label: '全部来源' }, ...sources.map((x) => ({ value: x.id, label: `${x.label}(${x.n})` }))]"
@update:model-value="(v) => (source = v ?? '')"
/>
<label v-if="supCount" class="choice cap"><input v-model="supOnly" type="checkbox" class="ck" />只看日期更新({{ supCount }})</label>
</div>
<template v-if="showTop">
<div v-for="r in cl?.marking_rules ?? []" :key="r.id" class="rule">
<Info class="ic" :stroke-width="1.5" />
<div>
<p>{{ ruleText(r) }}</p>
<CitationBlock :citation="r.citation" :active="target?.id === r.citation.id" @open="openCitation(r.citation)" />
</div>
</div>
</template>
<!-- 待确认(§7.6):清单顶部的分组,不和正式清单混排;确认前不参与检查 -->
<section v-if="showPending && pending.length" aria-label="待确认">
<div class="grp-h ai"><b>待确认</b><span class="fig">{{ pending.length }}</span><p>没找到可靠出处,或把握不大。确认前不参与检查。</p></div>
<ChecklistItemRow
v-for="i in pending"
:key="i.id"
:item="i"
pending
:selected="selId === i.id"
:active-citation="target?.id"
@select="select(i)"
@open-citation="(g) => { selId = i.id; openGroup(g); }"
>
<template v-if="!readOnly" #actions>
<button v-if="i.citations.length" type="button" class="btn sm" @click="accept(i)">采纳</button>
<button v-else type="button" class="btn sm" @click="startPick(i, 'accept')">补上出处后采纳</button>
<button type="button" class="btn sm" @click="openDialog('accept', i)">修改后采纳</button>
<button type="button" class="btn sm text" @click="remove(i)">丢弃</button>
</template>
</ChecklistItemRow>
</section>
<div v-if="cat === 'pending' && !pending.length" class="empty sm"><b>没有待确认的条目</b></div>
<template v-if="listMode === 'formal'">
<section v-for="g in groups" :key="g.id" :aria-label="g.name">
<div class="grp-h"><b>{{ g.name }}</b><span class="fig">{{ g.rows.length }}</span></div>
<ChecklistItemRow
v-for="i in g.rows"
:key="i.id"
:item="i"
:selected="selId === i.id"
:active-citation="target?.id"
:check="checkOf.get(i.id)"
:library="libOf.get(i.id)"
:library-as-of="libAsOf"
@select="select(i)"
@open-citation="(g) => { selId = i.id; openGroup(g); }"
@open-bid="(c) => openCitation(c)"
@findings="goFindings"
@feedback="askFeedback(i)"
>
<template #actions>
<template v-if="!readOnly && supPending(i)">
<button type="button" class="btn sm" @click="resolveSupersession(i, true)">确认更新(删除旧条目)</button>
<button type="button" class="btn sm text" @click="resolveSupersession(i, false)">保留两条</button>
</template>
<template v-if="!readOnly">
<button type="button" class="btn sm" @click="openDialog('edit', i)">修改</button>
<button type="button" class="btn sm" @click="startPick(i, 'add')">补出处</button>
</template>
<button type="button" class="btn sm text" @click="hist = { open: true, item: i }">修改记录</button>
<button v-if="!readOnly" type="button" class="btn sm text" @click="remove(i)">删除</button>
</template>
</ChecklistItemRow>
</section>
<div v-if="!groups.length" class="empty sm">
<b>{{ kw ? `没有找到「${kw}」` : "这个分类下没有符合条件的条目" }}</b>
<p v-if="kw">只搜条目标题。</p>
</div>
</template>
<section v-if="listMode === 'deleted'" aria-label="已删除">
<div class="grp-h"><b>已删除</b><span class="fig">{{ deleted.length }}</span><p>删除和丢弃的条目,可以恢复;重新解析也不会再加回来。</p></div>
<ChecklistItemRow v-for="i in deleted" :key="i.id" :item="i" :selected="selId === i.id" :active-citation="target?.id" @select="select(i)" @open-citation="openGroup">
<template #actions>
<button v-if="!readOnly" type="button" class="btn sm" @click="restore(i)">恢复</button>
<button type="button" class="btn sm text" @click="hist = { open: true, item: i }">修改记录</button>
</template>
</ChecklistItemRow>
</section>
<div class="end"></div>
</template>
</div>
</section>
</div>
<aside class="split-doc" aria-label="原文">
<PdfViewer
v-model:picked="picked"
:document-id="viewDoc?.id"
:document-label="viewDoc?.label"
:target="target"
:selecting="!!picking"
:empty-text="tender ? undefined : '招标文件解析完成后,在这里看原文。'"
>
<template v-if="picking" #banner>
<div class="pick-bar">
<p>
点选句子,作为「{{ short(picking.item.title) }}」的出处<span class="muted">(只能选同一页;翻页会清空已选)</span>
</p>
<div class="acts">
<span class="cap muted">已选 <span class="fig">{{ picked.length }}</span> 句</span>
<button type="button" class="btn sm text" :disabled="pickBusy" @click="cancelPick">取消</button>
<button type="button" class="btn sm primary swap" :class="{ 'is-loading': pickBusy }" :disabled="!picked.length || pickBusy" @click="finishPick">
<span class="idle">{{ picking.purpose === "accept" ? "用作出处并采纳" : "用作出处" }}</span><span class="busy">正在保存…</span>
</button>
</div>
</div>
</template>
</PdfViewer>
</aside>
</div>
<ItemDialog v-model:open="dlg.open" :mode="dlg.mode" :item="dlg.item" :lots="realLots" :default-category="cat !== 'all' && cat !== 'pending' && cat !== 'deleted' ? cat : undefined" @submit="onSubmit" @pick="onDialogPick" />
<HistoryDialog v-model:open="hist.open" :item="hist.item" />
<FeedbackDialog :project-id="projectId" :target="fbFor" @close="fbFor = null" />
<ConfirmDialog v-model:open="confirmOpen" :title="`确认清单 v${nextVersion}?`" ok-text="确认清单" busy-text="正在确认…" :busy="confirming" @ok="doConfirm">
<p>确认后,查标以这一版清单为依据。之后再改清单会回到草稿,历史版本保留。</p>
<div v-if="pending.length" class="alert warn">
<span class="ico w" aria-hidden="true">!</span>
<div><p><b>还有 {{ pending.length }} 条待确认。</b>它们不进这一版清单,也不参与检查。</p></div>
</div>
</ConfirmDialog>
</template>
<style scoped>
.lst-tb :deep(.src) {
flex: none;
width: auto;
max-width: 200px;
}
.lot-scope {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
align-items: baseline;
margin-bottom: var(--sp-3);
}
/* 响应方式(C12):收在清单栏「响应方式」按钮的浮层里 */
.mode-btn {
position: relative;
}
.mode-btn .dot {
position: absolute;
top: 5px;
right: 5px;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--warn);
box-shadow: 0 0 0 2px var(--bg-surface);
}
[data-reka-popper-content-wrapper] :deep(.modes-pop) {
width: 380px;
max-height: none;
padding: var(--sp-4);
}
.modes-pop > p {
margin: 0;
}
.modes-pop .mode {
display: grid;
gap: var(--sp-1);
margin-top: var(--sp-4);
}
.modes-pop label {
font-size: var(--fs-caption);
color: var(--text-secondary);
}
.modes-pop .mode-meta {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--sp-1) var(--sp-3);
}
.modes-pop .mode-meta:empty {
display: none;
}
.modes-pop .sign {
margin: 0;
}
/* 清单栏:标题 + 清单状态;右侧页面级操作(主按钮一个,Q12) */
.cl-bar {
display: flex;
align-items: center;
gap: var(--sp-3);
margin-bottom: var(--sp-3);
}
.cl-title {
min-width: 0;
}
.cl-bar h2 {
display: flex;
align-items: center;
gap: var(--sp-2);
margin: 0;
font: var(--fw-bold) var(--fs-title) / var(--lh-title) var(--font-sans);
white-space: nowrap;
}
.cl-bar h2 .tag {
font-weight: var(--fw-regular);
}
.cl-bar .status {
margin: 2px 0 0;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cl-bar .status .sign {
display: inline-flex;
flex-wrap: nowrap;
margin: 0;
}
.cl-bar .actions {
margin-left: auto;
display: flex;
gap: var(--sp-2);
flex: none;
}
.warn-row {
margin-bottom: var(--sp-3);
}
/* 左右布局(同查标、概览):清单随页面滚动,项目头和清单栏滚出屏幕,只留顶栏;
分类导航、搜索栏贴住(和右边原文区一样离顶栏 --sp-2),右边原文区贴住视口。
卡片不裁切(overflow: visible),里面的 sticky 才跟着页面走 */
.cmp-list {
--stick: calc(var(--topbar-h) + var(--sp-2) + var(--line));
overflow: visible;
}
/* 贴住时的卡片上沿:一条 0 高的 sticky 横条,画出圆角上边框;
外面一圈页面底色,盖住从顶栏和卡片之间滚过去的内容,圆角外也不露出底下的条目 */
.cap-edge {
grid-column: 1 / -1;
position: sticky;
top: var(--stick);
z-index: 3;
height: 0;
}
.cap-edge::before {
content: "";
position: absolute;
top: calc(-1 * var(--line));
left: calc(-1 * var(--line));
right: calc(-1 * var(--line));
height: calc(var(--radius-card) + var(--line));
border: var(--line) solid var(--border-subtle);
border-bottom: 0;
border-radius: var(--radius-card) var(--radius-card) 0 0;
box-shadow: 0 0 0 var(--sp-3) var(--bg-page);
clip-path: inset(calc(-1 * var(--sp-3)) calc(-1 * var(--sp-3)) 0);
pointer-events: none;
}
.cmp-list > .lst {
border-left: var(--line) solid var(--border-subtle);
}
.cats {
position: sticky;
top: var(--stick);
align-self: start;
max-height: calc(100vh - var(--stick));
overflow: auto;
border-right: 0;
}
/* J / K 翻到的条目不钻到顶栏和搜索栏下面 */
.lst :deep([data-item]) {
scroll-margin-top: calc(var(--topbar-h) + 72px);
scroll-margin-bottom: var(--sp-4);
}
.cats .hint {
margin: var(--sp-3) 0 0;
padding: 0 10px;
color: var(--text-secondary);
line-height: 26px;
}
.cats .hint .kbd {
margin-right: 2px;
}
.lst-tb {
position: sticky;
top: var(--stick);
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: var(--sp-2);
}
.lst-tb .choice {
flex: none;
white-space: nowrap;
align-items: center;
}
.lst-tb .choice .ck {
margin-top: 0;
}
.lst-pad {
padding: var(--card-y) var(--card-x);
}
.rule {
display: flex;
gap: var(--sp-2);
align-items: flex-start;
padding: var(--sp-3) var(--card-x) 0;
}
.rule > .ic {
margin-top: var(--sp-1);
flex: none;
color: var(--text-secondary);
}
.rule p {
margin: 0 0 var(--sp-2);
}
.end {
height: var(--sp-8);
}
/* 选句模式的提示条:在查看器顶栏下面,蓝铅笔浅底 */
.pick-bar {
display: flex;
flex-direction: column;
gap: var(--sp-2);
padding: var(--sp-3) var(--sp-4);
background: var(--ai-soft);
border-bottom: var(--line) solid var(--border-subtle);
}
.pick-bar p {
margin: 0;
}
.pick-bar .acts {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.pick-bar .acts .cap {
margin-right: auto;
}
</style>

View File

@@ -0,0 +1,401 @@
<script setup lang="ts">
/**
* 登录 / 注册(原型 login.html):手机号 + 短信验证码。登录和注册共用一个验证码:
* 登录返回 404 phone_not_registered 时切到注册,验证码不用重新获取;
* 已有其他设备在线时返回 409 concurrent_session,用户确认后带 force: true 重发(API3)。
*/
import type { Schemas } from "@bid-doctor/api-client";
import { unwrap } from "@bid-doctor/api-client";
import { useQueryClient } from "@tanstack/vue-query";
import { computed, nextTick, onBeforeUnmount, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import logoUrl from "@design/logo/svg/logo-horizontal-textured.svg?url";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import { api, errorTitle, isApiError } from "@/lib/api";
import { qk } from "@/lib/keys";
import { fmtDateTime } from "@/lib/labels";
import { usePlans, type Me, type SessionInfo } from "@/lib/queries";
/** TODO:服务协议版本应由服务端下发(目前没有接口),暂时写死;协议文本定稿后同步。 */
const TERMS_VERSION = "2026-11-draft";
const PHONE_RE = /^1[3-9][0-9]{9}$/;
const CODE_RE = /^[0-9]{6}$/;
const isDev = import.meta.env.DEV;
const route = useRoute();
const router = useRouter();
const qc = useQueryClient();
const plans = usePlans();
type Mode = "login" | "reg";
const mode = ref<Mode>("login");
const form = reactive({ phone: "", code: "", tenant: "", name: "", agreed: false });
const err = reactive<Partial<Record<"phone" | "code" | "tenant" | "name" | "agree" | "form", string>>>({});
const REASONS: Record<string, string> = {
session_revoked: "你的账号在其他设备登录,请重新登录。",
session_expired: "登录已过期,请重新登录。",
};
const notice = ref(REASONS[String(route.query.reason ?? "")] ?? "");
const free = computed(() => plans.data.value?.items.find((p) => p.code === "free"));
/* ---------- 发送验证码:60 秒内不能重发(以服务端返回为准) ---------- */
const countdown = ref(0);
const sending = ref(false);
let timer: number | undefined;
function startCountdown(s: number) {
countdown.value = s;
window.clearInterval(timer);
timer = window.setInterval(() => {
if (--countdown.value <= 0) window.clearInterval(timer);
}, 1000);
}
onBeforeUnmount(() => window.clearInterval(timer));
function checkPhone(): boolean {
err.phone = PHONE_RE.test(form.phone) ? "" : "手机号应为 11 位数字,以 1 开头";
return !err.phone;
}
async function sendCode() {
if (!checkPhone()) return;
sending.value = true;
err.code = "";
try {
const r = await unwrap(api.POST("/auth/sms-codes", { body: { phone: form.phone, purpose: mode.value === "reg" ? "register" : "login" } }));
startCountdown(r.resend_after_s);
await nextTick();
document.getElementById("cd")?.focus();
} catch (e) {
err.code = errorTitle(e);
const after = isApiError(e, "rate_limited") ? Number(e.problem.retry_after_s) : NaN;
if (Number.isFinite(after) && after > 0) startCountdown(after);
} finally {
sending.value = false;
}
}
/* ---------- 登录 / 注册 ---------- */
const submitting = ref(false);
const concurrent = reactive({ open: false, sessions: [] as SessionInfo[] });
function switchMode(m: Mode) {
mode.value = m;
err.form = err.tenant = err.name = err.agree = "";
}
function validate(): boolean {
checkPhone();
err.code = CODE_RE.test(form.code) ? "" : "验证码是 6 位数字";
if (mode.value === "reg") {
const t = form.tenant.trim();
err.tenant = t.length >= 2 && t.length <= 100 ? "" : "企业名称 2~100 个字";
const n = form.name.trim();
err.name = n.length >= 1 && n.length <= 40 ? "" : "请填写你的姓名,最多 40 个字";
err.agree = form.agreed ? "" : "请先阅读并同意服务协议";
}
return !err.phone && !err.code && !err.tenant && !err.name && !err.agree;
}
/** 接口校验错误按字段放回表单 */
function applyError(e: unknown) {
if (isApiError(e, "sms_code_invalid")) err.code = e.problem.title;
else if (isApiError(e, "validation_failed") && e.problem.errors?.length) {
const map: Record<string, keyof typeof err> = { phone: "phone", sms_code: "code", tenant_name: "tenant", display_name: "name" };
for (const fe of e.problem.errors) err[map[fe.field.replace(/^body\./, "")] ?? "form"] = fe.message;
} else err.form = errorTitle(e);
}
async function finish(me: Me, registered: boolean) {
qc.setQueryData(qk.me, me);
void qc.invalidateQueries({ queryKey: qk.quota });
void qc.invalidateQueries({ queryKey: qk.projects });
const redirect = typeof route.query.redirect === "string" && route.query.redirect.startsWith("/") ? route.query.redirect : "";
await router.replace(redirect || (registered ? { name: "project-new" } : { name: "workbench" }));
}
async function login(force: boolean) {
submitting.value = true;
err.form = "";
try {
const r = await unwrap(api.POST("/auth/login", { body: { phone: form.phone, sms_code: form.code, client_kind: "web", force } }));
concurrent.open = false;
await finish(r.me, false);
} catch (e) {
if (isApiError(e, "phone_not_registered")) {
switchMode("reg");
notice.value = "这个手机号还没有注册。填写企业名称和你的姓名完成注册,验证码不用重新获取。";
await nextTick();
document.getElementById("co")?.focus();
} else if (isApiError(e, "concurrent_session")) {
concurrent.sessions = (e.problem.sessions as Schemas["SessionInfo"][] | undefined) ?? [];
concurrent.open = true;
} else {
concurrent.open = false;
applyError(e);
}
} finally {
submitting.value = false;
}
}
async function register() {
submitting.value = true;
err.form = "";
try {
const r = await unwrap(
api.POST("/auth/register", {
body: {
phone: form.phone,
sms_code: form.code,
tenant_name: form.tenant.trim(),
display_name: form.name.trim(),
terms_version: TERMS_VERSION,
client_kind: "web",
},
}),
);
await finish(r.me, true);
} catch (e) {
if (isApiError(e, "phone_already_registered")) {
switchMode("login");
notice.value = "这个手机号已经注册,直接登录即可。";
} else applyError(e);
} finally {
submitting.value = false;
}
}
function submit() {
if (!validate()) return;
if (mode.value === "reg") void register();
else void login(false);
}
</script>
<template>
<div class="login-page">
<div class="wrap">
<section class="brand" aria-label="标博士">
<img :src="logoUrl" 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>
<form class="card login" novalidate aria-label="登录或注册" @submit.prevent="submit">
<div class="tabs" role="tablist" aria-label="登录方式">
<button type="button" role="tab" :aria-selected="mode === 'login'" @click="switchMode('login')">登录</button>
<button type="button" role="tab" :aria-selected="mode === 'reg'" @click="switchMode('reg')">注册</button>
</div>
<div v-if="notice" class="alert" role="status">
<span class="ico i" aria-hidden="true">i</span>
<p>{{ notice }}</p>
</div>
<div class="field" :class="{ err: err.phone }">
<label for="ph">手机号</label>
<input id="ph" v-model.trim="form.phone" class="input" inputmode="numeric" autocomplete="tel" maxlength="11" placeholder="11 位手机号" @blur="form.phone && checkPhone()" />
<span v-if="err.phone" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ err.phone }}</span>
</div>
<div class="field" :class="{ err: err.code }">
<label for="cd">验证码</label>
<div class="code">
<input id="cd" v-model.trim="form.code" class="input" inputmode="numeric" autocomplete="one-time-code" maxlength="6" placeholder="6 位数字" />
<button class="btn" type="button" :disabled="sending || countdown > 0" @click="sendCode">
{{ countdown > 0 ? `${countdown} 秒后重发` : sending ? "正在发送…" : "发送验证码" }}
</button>
</div>
<span v-if="err.code" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ err.code }}</span>
<span v-else-if="isDev" class="msg">开发环境:验证码固定为 000000</span>
</div>
<template v-if="mode === 'reg'">
<div class="field" :class="{ err: err.tenant }">
<label for="co">企业名称</label>
<input id="co" v-model="form.tenant" class="input" placeholder="与开票信息一致" maxlength="100" />
<span class="msg"><span v-if="err.tenant" class="ico w" aria-hidden="true">!</span>{{ err.tenant || "额度和数据归属企业主体,一个手机号只能属于一个企业。" }}</span>
</div>
<div class="field" :class="{ err: err.name }">
<label for="nm">你的姓名</label>
<input id="nm" v-model="form.name" class="input" placeholder="确认记录里显示这个名字" maxlength="40" autocomplete="name" />
<span v-if="err.name" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ err.name }}</span>
</div>
<div class="agree">
<label class="choice">
<input v-model="form.agreed" type="checkbox" class="ck" @change="form.agreed && (err.agree = '')" />
<span>我已阅读并同意《服务协议》《隐私政策》。本产品只用于投标人对自己投标文件的投前自查,不得用于串通投标、帮助他人规避雷同等违法用途。</span>
</label>
</div>
<p v-if="err.agree" class="hint-warn agree-err"><span class="ico w" aria-hidden="true">!</span>{{ err.agree }}</p>
</template>
<p v-if="err.form" class="hint-warn form-err" role="alert"><span class="ico w" aria-hidden="true">!</span><span>{{ err.form }}</span></p>
<button type="submit" class="btn primary lg go swap" :class="{ 'is-loading': submitting && !concurrent.open }" :aria-busy="submitting" :disabled="submitting">
<span class="idle">{{ mode === "reg" ? "注册并开通免费版" : "登录" }}</span>
<span class="busy">{{ mode === "reg" ? "正在注册…" : "正在登录…" }}</span>
</button>
<p v-if="mode === 'reg' && free" class="free">
注册即开通免费版:解析 <span class="fig">{{ free.quotas.parse ?? 0 }}</span> 次招标文件<template v-if="free.limits.max_projects">,限
<span class="fig">{{ free.limits.max_projects }}</span> 个项目</template>。不需要上传营业执照。
</p>
</form>
</div>
<p class="legal">辅助检查工具,不替代人工审核;检查结果仅供投标前自查参考,是否递交由投标人自行决定。</p>
<ConfirmDialog v-model:open="concurrent.open" title="账号已在其他设备登录" ok-text="继续登录" busy-text="正在登录…" :busy="submitting" @ok="login(true)">
<p>继续登录后,下面的设备会退出登录。</p>
<ul class="sessions">
<li v-for="s in concurrent.sessions" :key="s.id">
<b>{{ s.device || (s.client_kind === "desktop" ? "桌面客户端" : "网页") }}</b>
<span class="cap muted">{{ s.ip_region ? `${s.ip_region} · ` : "" }}最近活跃 <span class="fig">{{ fmtDateTime(s.last_seen_at) }}</span></span>
</li>
</ul>
</ConfirmDialog>
</div>
</template>
<style scoped>
/* 登录页:不用侧栏框架。浅灰页面上一张白卡片放表单;左侧品牌与三句话;按钮、输入框用 40 高(§7.1) */
.login-page {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.wrap {
flex: 1;
display: grid;
/* 表单卡片宽 440(11 × 40),与原型一致 */
grid-template-columns: minmax(0, 1fr) calc(var(--sp-10) * 11);
gap: var(--sp-16);
align-items: center;
width: 100%;
max-width: var(--content-max);
margin: 0 auto;
padding: var(--sp-12) var(--pad-page);
}
.brand img {
width: calc(var(--sp-10) * 5);
display: block;
}
.brand .slogan {
font: var(--fw-bold) var(--fs-figure) / 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: var(--sp-8) 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);
}
.login {
padding: var(--sp-8);
}
.login .tabs {
margin-bottom: var(--sp-6);
}
.login .tabs [role="tab"] {
font-size: var(--fs-title);
}
.login .alert {
margin-bottom: var(--sp-5);
}
.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;
min-width: 0;
}
.code .btn {
min-width: calc(var(--sp-16) * 2);
}
.agree {
display: flex;
margin-top: var(--sp-5);
font-size: var(--fs-caption);
line-height: var(--lh-caption);
color: var(--text-secondary);
}
.agree-err {
margin-top: var(--sp-2);
}
.form-err {
margin-top: var(--sp-4);
}
.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);
}
.sessions {
list-style: none;
margin: 0;
padding: 0;
}
.sessions li {
display: flex;
flex-direction: column;
padding: var(--sp-2) 0;
border-top: var(--line) solid var(--border-subtle);
}
</style>

View File

@@ -0,0 +1,369 @@
<script setup lang="ts">
/**
* 选择要投的包(原型 lots.html,FR-M2-04):解析完成后识别到 2 个及以上的包时进入;概览和读标页「更改所投的包」也回到这里。
* - 清单只保留通用条目和所选包的条目,以后可以改;至少选一个包;
* - 识别得不对时手工调整(增删包、改包号 / 名称 / 预算 / 限价),或者一键「这个项目不分包」(PI2);
* 删包时原属这个包的条目归为通用(PI3);只有 1 个包时按不分包处理(PI4);
* - 限价是招标文件里的原文数字,不是系统给出的报价建议(红线 6)。
*/
import { unwrap } from "@bid-doctor/api-client";
import { Info, Plus, Trash2, X } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from "reka-ui";
import { computed, ref, watch } from "vue";
import { useRouter } from "vue-router";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import ProjectHead from "@/components/ProjectHead.vue";
import { api, errorTitle, isApiError } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys";
import { fmtYuan } from "@/lib/labels";
import { useChecklistItems, useLots, useProject, type Lot } from "@/lib/queries";
import { toast } from "@/lib/toast";
const props = defineProps<{ projectId: string }>();
const router = useRouter();
const qc = useQueryClient();
const project = useProject(() => props.projectId);
const lotsQ = useLots(() => props.projectId);
const itemsQ = useChecklistItems(() => props.projectId, "all");
const p = computed(() => project.data.value);
useCrumbs(() => [{ label: "工作台", to: { name: "workbench" } }, { label: p.value?.name ?? "项目", to: { name: "overview", params: { projectId: props.projectId } } }, { label: "选择所投的包" }]);
const readOnly = computed(() => p.value?.status === "submitted" || p.value?.status === "archived");
const lots = computed(() => (lotsQ.data.value?.items ?? []).filter((l) => !l.is_default));
const multi = computed(() => lots.value.length > 1);
/** 正式清单里各包的条目数;通用 = 没有归属的包 */
const counts = computed(() => {
const out = new Map<string | null, number>();
for (const i of itemsQ.data.value?.items ?? []) {
if (i.zone !== "formal") continue;
const k = i.lot_id ?? null;
out.set(k, (out.get(k) ?? 0) + 1);
}
return out;
});
const common = computed(() => counts.value.get(null) ?? 0);
const chosen = ref<Set<string>>(new Set());
watch(
lots,
(ls) => {
chosen.value = new Set(ls.filter((l) => l.is_bidding).map((l) => l.id));
},
{ immediate: true },
);
function toggle(id: string, on: boolean) {
const s = new Set(chosen.value);
if (on) s.add(id);
else s.delete(id);
chosen.value = s;
}
const shown = computed(() => common.value + lots.value.filter((l) => chosen.value.has(l.id)).reduce((a, l) => a + (counts.value.get(l.id) ?? 0), 0));
const hidden = computed(() => lots.value.filter((l) => !chosen.value.has(l.id)).reduce((a, l) => a + (counts.value.get(l.id) ?? 0), 0));
function refresh() {
void qc.invalidateQueries({ queryKey: qk.lots(props.projectId) });
void qc.invalidateQueries({ queryKey: qk.project(props.projectId) });
void qc.invalidateQueries({ queryKey: qk.projects });
void qc.invalidateQueries({ queryKey: qk.checklistItems(props.projectId) });
}
const saving = ref(false);
async function saveChoice() {
if (!multi.value) {
await router.push({ name: "checklist", params: { projectId: props.projectId } });
return;
}
if (!chosen.value.size || saving.value) return;
saving.value = true;
try {
await unwrap(api.PUT("/projects/{project_id}/bidding-lots", { params: { path: { project_id: props.projectId } }, body: { lot_ids: [...chosen.value] } }));
refresh();
toast(`已选择 ${lots.value.filter((l) => chosen.value.has(l.id)).map((l) => `采购包 ${l.lot_no}`).join("、")}`);
await router.push({ name: "checklist", params: { projectId: props.projectId } });
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
saving.value = false;
}
}
/* ---------- 「这个项目不分包」 ---------- */
const clearOpen = ref(false);
const clearing = ref(false);
async function clearLots() {
clearing.value = true;
try {
await unwrap(api.POST("/projects/{project_id}/lots/clear", { params: { path: { project_id: props.projectId } } }));
clearOpen.value = false;
refresh();
toast("已改为不分包,条目都归为通用");
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
clearing.value = false;
}
}
/* ---------- 手工调整 ---------- */
interface Row {
id: string | null;
lot_no: string;
name: string;
budget: string;
ceiling: string;
removed: boolean;
}
const fixOpen = ref(false);
const fixRows = ref<Row[]>([]);
const fixErr = ref("");
const fixBusy = ref(false);
const yuanText = (v: string | null | undefined) => (v ? String(Number(v)) : "");
function openFix() {
fixRows.value = lots.value.map((l) => ({ id: l.id, lot_no: l.lot_no, name: l.name ?? "", budget: yuanText(l.budget_amount), ceiling: yuanText(l.ceiling_amount), removed: false }));
if (!fixRows.value.length) addRow();
fixErr.value = "";
fixOpen.value = true;
}
function addRow() {
fixRows.value.push({ id: null, lot_no: String(fixRows.value.filter((r) => !r.removed).length + 1), name: "", budget: "", ceiling: "", removed: false });
}
const removedItems = computed(() => fixRows.value.filter((r) => r.removed && r.id).reduce((a, r) => a + (counts.value.get(r.id) ?? 0), 0));
async function saveFix() {
const live = fixRows.value.filter((r) => !r.removed);
const nos = live.map((r) => r.lot_no.trim());
if (nos.some((n) => !n)) return (fixErr.value = "包号不能为空");
if (new Set(nos).size !== nos.length) return (fixErr.value = "包号不能重复");
fixBusy.value = true;
fixErr.value = "";
const before = new Map(lots.value.map((l) => [l.id, l]));
try {
// 先删再改再加:改包号时不会和要删的包撞号
for (const r of fixRows.value.filter((x) => x.removed && x.id)) {
await unwrap(api.DELETE("/lots/{lot_id}", { params: { path: { lot_id: r.id! } } }));
}
for (const r of live.filter((x) => x.id)) {
const old = before.get(r.id!) as Lot;
const body: Record<string, string | null> = {};
if (r.lot_no.trim() !== old.lot_no) body.lot_no = r.lot_no.trim();
if (r.name.trim() !== (old.name ?? "")) body.name = r.name.trim();
if (r.budget.trim() !== yuanText(old.budget_amount)) body.budget_amount = r.budget.trim() || null;
if (r.ceiling.trim() !== yuanText(old.ceiling_amount)) body.ceiling_amount = r.ceiling.trim() || null;
if (Object.keys(body).length) await unwrap(api.PATCH("/lots/{lot_id}", { params: { path: { lot_id: r.id! } }, body }));
}
for (const r of live.filter((x) => !x.id)) {
await unwrap(
api.POST("/projects/{project_id}/lots", {
params: { path: { project_id: props.projectId } },
body: { lot_no: r.lot_no.trim(), name: r.name.trim() || undefined, budget_amount: r.budget.trim() || null, ceiling_amount: r.ceiling.trim() || null },
}),
);
}
fixOpen.value = false;
toast("已保存分包");
} catch (e) {
const detail = isApiError(e, "validation_failed") ? (e.problem as { errors?: { message: string }[] }).errors?.[0]?.message : undefined;
fixErr.value = detail ?? errorTitle(e);
} finally {
fixBusy.value = false;
refresh();
}
}
</script>
<template>
<ProjectHead :project="p" tab="overview" />
<div class="content lots-wrap">
<div class="page-title">
<div>
<h1>选择要投的包</h1>
<p v-if="multi">识别到 <span class="fig">{{ lots.length }}</span> 个采购包。清单只保留通用条目和所选包的条目,以后可以改。</p>
<p v-else-if="lots.length === 1">只识别到 1 个采购包,按不分包处理:清单里的条目都适用,这个包的限价照用。</p>
<p v-else>这个项目不分包,清单里的条目都适用。招标文件分了包而没有识别出来时,可以手工添加。</p>
</div>
</div>
<section class="card flush" aria-labelledby="h-lots">
<div class="card-h">
<h2 id="h-lots">采购包</h2>
<span v-if="!readOnly" class="r acts">
<button type="button" class="lnk cap" @click="openFix">{{ lots.length ? "识别得不对?手工调整" : "手工添加采购包" }}</button>
<button v-if="lots.length" type="button" class="lnk cap" @click="clearOpen = true">这个项目不分包</button>
</span>
</div>
<div class="card-b">
<div v-if="lots.length" class="grid" role="group" aria-labelledby="h-lots">
<label v-for="l in lots" :key="l.id" class="choice-card" :class="{ single: !multi }">
<input v-if="multi" type="checkbox" class="ck" :checked="chosen.has(l.id)" :disabled="readOnly" @change="toggle(l.id, ($event.target as HTMLInputElement).checked)" />
<span class="lot-main">
<b>采购包 {{ l.lot_no }}<template v-if="l.name"> · {{ l.name }}</template></b>
<small>专属条目 <span class="fig">{{ counts.get(l.id) ?? 0 }}</span> 条 · {{ l.origin === "human" ? "人工添加" : l.edited_fields.length ? "AI 识别,已修改" : "AI 识别" }}</small>
</span>
<span class="money">
<small>最高限价</small>
<template v-if="l.ceiling_amount">{{ fmtYuan(l.ceiling_amount) }}</template>
<span v-else class="muted">未识别</span>
</span>
</label>
</div>
<div class="common">
<Info class="ic" :stroke-width="1.5" />
<p>通用条目 <span class="fig">{{ common }}</span> 条(资格条件、无效情形、递交要求等)所有包都适用,始终保留。限价是招标文件里的原文数字,不是报价建议。</p>
</div>
</div>
<div class="card-f">
<span v-if="multi" class="sum" :class="{ warn: !chosen.size }">
<template v-if="chosen.size">清单 <span class="fig">{{ shown }}</span> 条:通用 + {{ lots.filter((l) => chosen.has(l.id)).map((l) => `采购包 ${l.lot_no}`).join("、") }}<template v-if="hidden"> · 隐藏 <span class="fig">{{ hidden }}</span> 条</template></template>
<template v-else><span class="ico w" aria-hidden="true">!</span> 至少选一个包</template>
</span>
<div class="r">
<button type="button" class="btn primary swap" :class="{ 'is-loading': saving }" :disabled="saving || (multi && !chosen.size) || (multi && readOnly)" @click="saveChoice">
<span class="idle">查看清单</span><span class="busy">正在保存…</span>
</button>
</div>
</div>
</section>
</div>
<ConfirmDialog v-model:open="clearOpen" title="改为不分包?" ok-text="改为不分包" busy-text="正在保存…" :busy="clearing" @ok="clearLots">
<p>删掉所有采购包,原属各包的条目都归为通用,检查时全部要查。把分项、标段说明误识别成包时用这个。修改会留痕。</p>
</ConfirmDialog>
<DialogRoot v-model:open="fixOpen">
<DialogPortal>
<DialogOverlay class="scrim">
<DialogContent class="dialog wide" :aria-describedby="undefined">
<DialogTitle as="h2">手工调整分包</DialogTitle>
<DialogClose class="icon-btn sm x" aria-label="关闭"><X class="ic" :stroke-width="1.5" /></DialogClose>
<form class="body" novalidate @submit.prevent="saveFix">
<p class="muted lead">可以增加、删除采购包,改包号、名称、预算和限价。条目归属哪个包,在读标页逐条改。金额可写「286万」或「2,860,000」。</p>
<table class="data">
<thead><tr><th>包号</th><th>名称</th><th>预算(元)</th><th>最高限价(元)</th><th><span class="vh">删除</span></th></tr></thead>
<tbody>
<tr v-for="(r, i) in fixRows" v-show="!r.removed" :key="r.id ?? `new-${i}`">
<td class="no"><input v-model="r.lot_no" class="input" maxlength="40" :aria-label="`第 ${i + 1} 行包号`" /></td>
<td><input v-model="r.name" class="input" maxlength="200" :aria-label="`第 ${i + 1} 行名称`" /></td>
<td><input v-model="r.budget" class="input num" :aria-label="`第 ${i + 1} 行预算`" /></td>
<td><input v-model="r.ceiling" class="input num" :aria-label="`第 ${i + 1} 行最高限价`" /></td>
<td><button type="button" class="icon-btn sm" :aria-label="`删除第 ${i + 1} 行`" @click="r.removed = true"><Trash2 class="ic" :stroke-width="1.5" /></button></td>
</tr>
</tbody>
</table>
<button type="button" class="btn sm add" @click="addRow"><Plus class="ic" :stroke-width="1.5" />增加一个包</button>
<p v-if="removedItems" class="hint-warn"><span class="ico w" aria-hidden="true">!</span>删掉的包里有 <span class="fig">{{ removedItems }}</span> 条清单项,保存后归为通用。</p>
<p v-if="!fixRows.some((r) => !r.removed)" class="cap muted">一个包都不留,项目就改为不分包。</p>
<p v-if="fixErr" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ fixErr }}</p>
<div class="foot">
<span class="cap muted left">修改会留痕</span>
<DialogClose class="btn" type="button" :disabled="fixBusy">取消</DialogClose>
<button type="submit" class="btn primary swap" :class="{ 'is-loading': fixBusy }" :disabled="fixBusy">
<span class="idle">保存</span><span class="busy">正在保存…</span>
</button>
</div>
</form>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</DialogRoot>
</template>
<style scoped>
.lots-wrap {
max-width: 880px;
}
.card-h .r {
margin-left: auto;
}
.acts {
display: flex;
gap: var(--sp-4);
}
.grid {
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
/* 选择卡(原型 app.css 的 .choice-card) */
.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;
}
.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.single {
cursor: default;
}
.choice-card .ck {
margin-top: 3px;
}
.choice-card .lot-main {
flex: 1;
min-width: 0;
}
.choice-card b {
display: block;
}
.choice-card small {
display: block;
color: var(--text-secondary);
font-size: var(--fs-caption);
line-height: var(--lh-caption);
}
.money {
text-align: right;
white-space: nowrap;
}
.common {
display: flex;
gap: var(--sp-2);
align-items: flex-start;
margin-top: var(--sp-4);
color: var(--text-secondary);
}
.common p {
margin: 0;
}
.card-f {
display: flex;
align-items: center;
gap: var(--sp-4);
padding: var(--sp-3) var(--sp-5);
border-top: var(--line) solid var(--border-subtle);
}
.card-f .r {
margin-left: auto;
}
.sum.warn {
color: var(--warn-text);
}
.lead {
margin-top: 0;
}
.dialog td .input {
width: 100%;
}
.dialog td.no {
width: 88px;
}
.input.num {
font-family: var(--font-figures);
font-variant-numeric: tabular-nums;
}
.add,
.hint-warn {
margin-top: var(--sp-3);
}
</style>

View File

@@ -0,0 +1,442 @@
<script setup lang="ts">
/**
* 项目概览(原型 project.html,PI1):关键日期(时间线和倒计时,FR-M2-09)和采购包、项目要点(基本信息字段,
* 带出处,可改、可恢复 AI 值)、检查记录(历次检查的结果和报告)、文件(招标方的文件和投标文件的最新版本)。
* 查标按这里的值核对截止时间、限价、法律体系;改了以后重新检查才生效(PI5)。项目已递交、已归档时只读。
* 补遗(FR-M2-10):招标文件解析后可以追加;补遗带来的新值在这里「采用 / 不采用」(AD3),没确认前查标用旧值(AD4)。
*/
import { unwrap, type Schemas } from "@bid-doctor/api-client";
import { Pencil, Plus } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { computed, ref, watchEffect } from "vue";
import { RouterLink } from "vue-router";
import DocStage, { type StageSlot } from "@/components/DocStage.vue";
import FieldDialog from "@/components/FieldDialog.vue";
import type { ViewerTarget } from "@/components/PdfViewer.vue";
import ProjectHead from "@/components/ProjectHead.vue";
import { api, errorTitle } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys";
import { deadline, FIELD_LABEL, FIELD_ORDER, fieldText, fmtDateTime, fmtDateTimeFull, fmtYuan, SEVERITY, SEVERITY_CLASS } from "@/lib/labels";
import {
useCheckRuns,
useLots,
useProject,
useProjectDocuments,
useReports,
type Citation,
type DocumentInfo,
type ProjectField,
} from "@/lib/queries";
import { toast } from "@/lib/toast";
const props = defineProps<{ projectId: string }>();
const qc = useQueryClient();
const project = useProject(() => props.projectId);
const lotsQ = useLots(() => props.projectId);
const p = computed(() => project.data.value);
useCrumbs(() => [{ label: "工作台", to: { name: "workbench" } }, { label: p.value?.name ?? "项目" }, { label: "概览" }]);
const readOnly = computed(() => p.value?.status === "submitted" || p.value?.status === "archived");
const byKey = computed(() => new Map((p.value?.fields ?? []).map((f) => [f.key, f])));
/** 字段的当前值;没有字段记录的(建项目时填的名称、改过的截止时间等)取项目本身的值 */
const COLUMN: Partial<Record<string, keyof NonNullable<typeof p.value>>> = {
name: "name",
code: "code",
purchaser: "purchaser",
agency: "agency",
procurement_method: "procurement_method",
deadline_at: "deadline_at",
};
const rows = computed(() =>
FIELD_ORDER.map((key) => {
const f = byKey.value.get(key) ?? null;
const col = COLUMN[key];
// 名称:建项目时填的名称优先于 AI 抽到的(解析不覆盖用户填的名称),和项目头一致
const value =
key === "name" && p.value && f?.source !== "human" ? p.value.name : f && f.value != null ? f.value : col && p.value ? (p.value[col] as unknown) : null;
return { key, f, value };
}),
);
const realLots = computed(() => (lotsQ.data.value?.items ?? []).filter((l) => !l.is_default));
const dates = computed(() => (p.value?.key_dates ?? []).map((d) => ({ ...d, due: deadline(d.at) })));
const dlg = ref<{ open: boolean; key: Schemas["ProjectFieldKey"] | null; field: ProjectField | null }>({ open: false, key: null, field: null });
function edit(key: Schemas["ProjectFieldKey"], f: ProjectField | null) {
dlg.value = { open: true, key, field: f };
}
function onSaved(f: ProjectField, restored: boolean) {
void qc.invalidateQueries({ queryKey: qk.project(props.projectId) });
void qc.invalidateQueries({ queryKey: qk.projects });
toast(restored ? `已恢复${FIELD_LABEL[f.key]}的 AI 值` : `已修改${FIELD_LABEL[f.key]}`);
}
/* ---------- 补遗带来的新值:采用 / 不采用(AD3) ---------- */
const resolving = ref<string | null>(null);
async function resolvePending(key: Schemas["ProjectFieldKey"], accept: boolean) {
resolving.value = key;
try {
await unwrap(api.POST("/projects/{project_id}/fields/{field_key}/pending", { params: { path: { project_id: props.projectId, field_key: key } }, body: { accept } }));
void qc.invalidateQueries({ queryKey: qk.project(props.projectId) });
void qc.invalidateQueries({ queryKey: qk.projects });
void qc.invalidateQueries({ queryKey: qk.checklistItems(props.projectId) });
toast(accept ? `已采用补遗的${FIELD_LABEL[key]}` : `保留原来的${FIELD_LABEL[key]}`);
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
resolving.value = null;
}
}
const canAddendum = computed(() => !!p.value && p.value.status !== "tracking" && !readOnly.value);
/* ---------- 检查记录:历次检查和对应的报告 ---------- */
const runsQ = useCheckRuns(() => props.projectId);
const runs = computed(() => runsQ.data.value?.items ?? []);
const reportsQ = useReports(() => props.projectId);
// 一次检查可能生成过很多份报告,这里只写份数、给一个入口,到「报告」标签页看
const reportCount = computed(() => {
const m = new Map<string, number>();
for (const r of reportsQ.data.value?.items ?? []) m.set(r.check_run_id, (m.get(r.check_run_id) ?? 0) + 1);
return m;
});
const RUN_STATE: Record<string, string> = { queued: "排队中", running: "检查中", failed: "检查失败", cancelled: "已取消" };
/* ---------- 文件:招标方的文件全列;投标文件每个分册只列最新版本 ---------- */
const tenderDocs = computed(() => (docsQ.data.value?.items ?? []).filter((d) => d.role !== "bid_volume"));
const bidDocs = computed(() => (docsQ.data.value?.items ?? []).filter((d) => d.role === "bid_volume" && d.is_latest_version !== false));
function parseState(d: DocumentInfo): string {
const s = d.latest_parse_job?.status;
if (s === "succeeded") return "解析成功";
if (s === "queued" || s === "running") return "解析中";
if (s === "failed") return "解析失败";
if (s === "cancelled") return "已取消解析";
return d.status === "failed" ? "读取失败" : "未解析";
}
/* 右侧原文(左右布局,同读标页):默认摆招标文件,点出处翻到那一页并高亮;补遗的出处换成那份补遗 */
const docsQ = useProjectDocuments(() => props.projectId);
const shown = ref<{ doc: { id: string; label: string } | null; target: (ViewerTarget & { id: string }) | null }>({ doc: null, target: null });
watchEffect(() => {
const t = docsQ.data.value?.items.find((d) => d.role === "tender" && d.status === "ready");
if (!shown.value.doc && t) shown.value.doc = { id: t.id, label: "招标文件" };
});
const slots = computed<StageSlot[]>(() => [{ key: "tender", label: "招标文件", ...shown.value, emptyText: "招标文件解析完成后,在这里看原文。" }]);
const side = ref("tender");
function openCitation(c: Citation) {
shown.value = { doc: { id: c.document_id, label: c.document_label }, target: { id: c.id, page: c.page_no, quads: c.quads, sha: c.normalized_sha256 } };
}
</script>
<template>
<ProjectHead :project="p" tab="overview" />
<div class="content">
<div class="split">
<div class="split-main ov">
<!-- 关键日期和采购包:放在最上面,一张卡片左右两栏(右边是原文,左边不够放两列卡片) -->
<section class="card flush glance" aria-label="关键日期和采购包">
<div class="g-col" aria-labelledby="h-dates">
<div class="g-h"><h3 id="h-dates">关键日期</h3></div>
<p v-if="!dates.length" class="cap muted none">招标文件解析后,这里列出投标截止、开标等日期</p>
<ol v-else class="timeline">
<li v-for="(d, i) in dates" :key="i">
<span class="fig when">{{ fmtDateTimeFull(d.at) }}</span>
<b>{{ d.label }}</b>
<span v-if="d.edited" class="flag box">已修改</span>
<span v-if="d.due && d.due.cls !== 'due-past'" class="cap" :class="d.due.cls || 'muted'">{{ d.due.main }}</span>
<span v-if="d.superseded_pending" class="cap warn-text rep">已被补遗更新,待确认</span>
<span v-if="d.replaces" class="cap muted rep">已按{{ d.replaces.document_label }} 更新 · 原为 {{ d.replaces.at ? fmtDateTimeFull(d.replaces.at) : "—" }}</span>
</li>
</ol>
</div>
<div class="g-col" aria-labelledby="h-lots">
<div class="g-h">
<h3 id="h-lots">采购包</h3>
<RouterLink v-if="!readOnly" class="lnk cap r" :to="{ name: 'lots', params: { projectId } }">{{ realLots.length > 1 ? "更改所投的包" : "调整分包" }}</RouterLink>
</div>
<p v-if="!realLots.length" class="cap muted none">不分包:清单里的条目都适用</p>
<ul v-else class="lots">
<li v-for="l in realLots" :key="l.id" :class="{ off: realLots.length > 1 && !l.is_bidding }">
<b>采购包 {{ l.lot_no }}</b>
<span v-if="realLots.length > 1" class="tag">{{ l.is_bidding ? "投" : "不投" }}</span>
<span v-if="l.name" class="name">{{ l.name }}</span>
<span class="cap muted">
<template v-if="l.budget_amount">预算 {{ fmtYuan(l.budget_amount) }}</template>
<template v-if="l.budget_amount && l.ceiling_amount"> · </template>
<template v-if="l.ceiling_amount">最高限价 {{ fmtYuan(l.ceiling_amount) }}</template>
<template v-if="l.edited_fields.length"> · 已修改</template>
</span>
</li>
</ul>
<p v-if="realLots.length" class="cap muted note">限价是招标文件里的原文数字,不是报价建议。</p>
</div>
</section>
<section class="card flush" aria-labelledby="h-facts">
<div class="card-h">
<h2 id="h-facts">项目要点</h2>
<span class="cap muted r">查标按这里的值核对截止时间、限价和法律体系</span>
</div>
<dl class="facts-list">
<div v-for="{ key, f, value } in rows" :key="key" class="frow">
<dt>{{ FIELD_LABEL[key] }}</dt>
<dd>
<span v-if="value != null && value !== ''" :class="{ fig: key === 'deadline_at' }">{{ fieldText(key, value) }}</span>
<span v-else class="dash">没写明</span>
<span v-if="f?.source === 'human'" class="flag box">已修改</span>
<button v-if="f?.citation" type="button" class="cite-src inline" @click="openCitation(f.citation)">
<template v-if="f.citation.document_label && f.citation.document_label !== '招标文件'">{{ f.citation.document_label }} </template>第 <span class="fig">{{ f.citation.page_no }}</span> 页
</button>
<span v-if="f?.pending" class="pend-row">
<span class="cap warn-text">{{ f.pending.document_label }}:{{ fieldText(key, f.pending.value) }}<template v-if="value != null && value !== ''">(原为 {{ fieldText(key, value) }})</template>,没确认前查标用原值</span>
<button v-if="f.pending.citation" type="button" class="cite-src inline" @click="openCitation(f.pending.citation)">{{ f.pending.document_label }} 第 <span class="fig">{{ f.pending.citation.page_no }}</span> 页</button>
<template v-if="!readOnly">
<button type="button" class="btn sm" :disabled="resolving === key" @click="resolvePending(key, true)">采用</button>
<button type="button" class="btn sm text" :disabled="resolving === key" @click="resolvePending(key, false)">不采用</button>
</template>
</span>
</dd>
<button v-if="!readOnly" type="button" class="icon-btn sm" :aria-label="`修改${FIELD_LABEL[key]}`" @click="edit(key, f ?? { key, value: value as never, source: 'none' })">
<Pencil class="ic" :stroke-width="1.5" />
</button>
</div>
</dl>
</section>
<section class="card flush" aria-labelledby="h-runs">
<div class="card-h">
<h2 id="h-runs">检查记录</h2>
<RouterLink v-if="!readOnly && p && p.status !== 'tracking'" class="btn sm r" :to="{ name: 'bid-check', params: { projectId } }">
{{ runs.length ? "上传新版本并检查" : "上传投标文件并检查" }}
</RouterLink>
</div>
<p v-if="!runs.length" class="cap muted none pad">还没有检查过。上传投标文件后,每次检查的结果和生成的报告都列在这里。</p>
<table v-else class="data runs">
<thead>
<tr><th>批次</th><th>时间</th><th>投标文件</th><th>结果</th><th>报告</th></tr>
</thead>
<tbody>
<tr v-for="r in runs" :key="r.id">
<td>第 <span class="fig">{{ r.run_no }}</span> 次</td>
<td class="fig nowrap">{{ fmtDateTime(r.created_at) }}</td>
<td>{{ (r.documents ?? []).map((d) => d.label).join("、") || "—" }}</td>
<td>
<template v-if="r.status === 'succeeded'">
<!-- 只列有问题的级别:「致命 0」不该是红色 -->
<span class="sevs">
<template v-for="s in (['fatal', 'important', 'hint'] as const)" :key="s">
<span v-if="r.counts?.[s]" class="sev" :class="SEVERITY_CLASS[s]">{{ SEVERITY[s] }}<span class="fig">{{ r.counts[s] }}</span></span>
</template>
<span v-if="!r.counts?.fatal && !r.counts?.important && !r.counts?.hint" class="cap muted">没有发现问题</span>
</span>
<RouterLink class="lnk cap" :to="{ name: 'results', params: { projectId, runId: r.id } }">查看结果</RouterLink>
</template>
<RouterLink v-else class="lnk cap" :to="{ name: 'results', params: { projectId, runId: r.id } }">{{ RUN_STATE[r.status] ?? r.status }}</RouterLink>
</td>
<td>
<template v-if="reportCount.get(r.id)">
<span class="cap muted rep-n"><span class="fig">{{ reportCount.get(r.id) }}</span> 份</span>
<RouterLink class="lnk cap" :to="{ name: 'report', params: { projectId }, query: { run: r.id } }">查看报告</RouterLink>
</template>
<span v-else class="dash" aria-label="没有生成报告">—</span>
</td>
</tr>
</tbody>
</table>
</section>
<section class="card flush" aria-labelledby="h-files">
<div class="card-h">
<h2 id="h-files">文件</h2>
<RouterLink v-if="canAddendum" class="btn sm r" :to="{ name: 'project-new', query: { project: projectId, addendum: '1' } }"><Plus class="ic" :stroke-width="1.5" />追加补遗 / 澄清</RouterLink>
</div>
<div class="files">
<h3 class="cap muted">招标方</h3>
<p v-if="!tenderDocs.length" class="cap muted none">还没有上传招标文件</p>
<div v-for="d in tenderDocs" :key="d.id" class="file-row">
<span class="ext">{{ d.detected_format.toUpperCase() }}</span>
<div>
<b>{{ d.label }}</b>
<span class="cap muted">{{ d.original_filename }}<template v-if="d.page_count"> · <span class="fig">{{ d.page_count }}</span> 页</template> · {{ parseState(d) }}</span>
</div>
<span class="acts"><RouterLink class="btn sm text" :to="{ name: 'checklist', params: { projectId } }">读标</RouterLink></span>
</div>
<h3 class="cap muted">投标文件(加密前的 Word / PDF 稿)</h3>
<p v-if="!bidDocs.length" class="cap muted none">还没有上传投标文件</p>
<div v-for="d in bidDocs" :key="d.id" class="file-row">
<span class="ext">{{ d.detected_format.toUpperCase() }}</span>
<div>
<b>{{ d.label }}</b>
<span class="cap muted"><template v-if="d.page_count"><span class="fig">{{ d.page_count }}</span> 页 · </template>{{ fmtDateTime(d.created_at) }} 上传</span>
</div>
</div>
</div>
</section>
</div>
<aside class="split-doc" aria-label="原文">
<DocStage v-model:active="side" :slots="slots" />
</aside>
</div>
</div>
<FieldDialog v-model:open="dlg.open" :project-id="projectId" :field-key="dlg.key" :field="dlg.field" @saved="onSaved" />
</template>
<style scoped>
/* 左边:项目要点,下面采购包和关键日期并排;右边原文 */
.ov {
display: flex;
flex-direction: column;
gap: var(--gap-card);
}
.ov > .card {
margin: 0;
}
.glance {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
}
.g-col {
min-width: 0;
padding: var(--sp-4) var(--card-x);
}
.g-col + .g-col {
border-left: var(--line) solid var(--border-subtle);
}
.g-h {
display: flex;
align-items: baseline;
margin-bottom: var(--sp-2);
}
.g-h h3 {
margin: 0;
font-size: var(--fs-body);
font-weight: var(--fw-bold);
}
.g-h .r {
margin-left: auto;
}
.card-h .r {
margin-left: auto;
}
.pad {
padding: 0 var(--card-x) var(--sp-4);
}
.runs {
margin: 0;
}
.runs td {
vertical-align: baseline;
}
.runs .nowrap {
white-space: nowrap;
}
.rep-n {
margin-right: var(--sp-2);
white-space: nowrap;
}
.sevs {
display: inline-flex;
gap: var(--sp-1);
margin-right: var(--sp-2);
}
.files {
display: flex;
flex-direction: column;
gap: var(--sp-2);
padding: 0 var(--card-x) var(--sp-4);
}
.files h3 {
margin: var(--sp-2) 0 0;
font-weight: var(--fw-regular);
}
.files .file-row b {
font-weight: var(--fw-medium);
}
.files .file-row .cap {
display: block;
}
.facts-list {
margin: 0;
}
.frow {
display: grid;
grid-template-columns: 140px minmax(0, 1fr) auto;
gap: var(--sp-4);
align-items: center;
padding: var(--sp-3) var(--sp-5);
border-top: var(--line) solid var(--border-subtle);
}
.frow dt {
color: var(--text-secondary);
}
.frow dd {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
align-items: baseline;
margin: 0;
}
.frow .icon-btn {
color: var(--text-secondary);
}
.frow:hover .icon-btn {
color: var(--text-primary);
}
.warn-text {
color: var(--warn-text);
}
.pend-row {
display: flex;
flex-basis: 100%;
flex-wrap: wrap;
gap: var(--sp-2);
align-items: baseline;
}
.timeline .rep {
flex-basis: 100%;
}
.none,
.note {
margin: 0;
}
.note {
margin-top: var(--sp-3);
}
.lots,
.timeline {
margin: 0;
padding: 0;
list-style: none;
}
.lots li {
display: flex;
flex-wrap: wrap;
gap: var(--sp-1) var(--sp-2);
align-items: baseline;
padding: var(--sp-2) 0;
}
.lots li + li {
border-top: var(--line) solid var(--border-subtle);
}
.lots li.off {
color: var(--text-secondary);
}
.lots .name {
flex-basis: 100%;
}
.lots .cap {
flex-basis: 100%;
}
.timeline li {
display: flex;
flex-wrap: wrap;
gap: var(--sp-1) var(--sp-3);
align-items: baseline;
padding: var(--sp-1) 0;
}
.timeline .when {
min-width: 11em;
font-size: var(--fs-figure-sm);
}
</style>

View File

@@ -0,0 +1,292 @@
<script setup lang="ts">
/**
* 解析进度(原型 parsing.html,设计规范 §7.11):分阶段列表 + 进度条。
* 进度来自事件流(AppShell 订阅,写进 parse-job 缓存),GET /parse-jobs/{id} 每 5 秒轮询兜底。
* 失败写明原因和额度是否已退回(error.refunded);取消、重试都在本页。
*/
import { unwrap } from "@bid-doctor/api-client";
import { RefreshCw, CircleAlert, Upload } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { RouterLink, useRouter } from "vue-router";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import { api, errorTitle, isApiError } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys";
import { fmtDateTime, fmtDuration, PARSE_STEPS, parseStepIndex } from "@/lib/labels";
import { useDocument, useParseJob, useProject, useQuotaAccount } from "@/lib/queries";
const props = defineProps<{ projectId: string; jobId: string }>();
const router = useRouter();
const qc = useQueryClient();
const job = useParseJob(() => props.jobId);
const j = computed(() => job.data.value);
const doc = useDocument(() => j.value?.document_id);
const project = useProject(() => props.projectId);
const parse = useQuotaAccount("parse");
useCrumbs(() => [
{ label: "工作台", to: { name: "workbench" } },
{ label: project.data.value?.name ?? "项目", to: { name: "checklist", params: { projectId: props.projectId } } },
{ label: "解析" },
]);
const status = computed(() => j.value?.status);
const running = computed(() => status.value === "queued" || status.value === "running");
const heading = computed(
() =>
({ queued: "排队中", running: "正在解析", succeeded: "解析完成", failed: "解析失败", cancelled: "已取消解析" })[status.value ?? "queued"],
);
/** 当前在第几步;成功时全部完成 */
const cur = computed(() => (status.value === "succeeded" ? PARSE_STEPS.length : parseStepIndex(j.value?.stage)));
function stepClass(k: number) {
if (status.value === "failed" && k === Math.max(0, cur.value)) return "fail";
if (k < cur.value) return "done";
if (k === cur.value && running.value) return "now";
return "todo";
}
/* 已用时:从任务创建算起,进行中每秒刷新 */
const now = ref(Date.now());
const tick = window.setInterval(() => (now.value = Date.now()), 1000);
onBeforeUnmount(() => window.clearInterval(tick));
const elapsed = computed(() => {
if (!j.value) return null;
const end = j.value.finished_at ? new Date(j.value.finished_at).getTime() : now.value;
return fmtDuration(end - new Date(j.value.created_at).getTime());
});
/* 结束后刷新额度和项目列表(事件流断线时也能拿到最新结果) */
watch(running, (r, was) => {
if (was && !r) {
void qc.invalidateQueries({ queryKey: qk.quota });
void qc.invalidateQueries({ queryKey: qk.projects });
}
});
/* ---------- 取消 ---------- */
const cancelOpen = ref(false);
const cancelling = ref(false);
const actionErr = ref("");
async function cancel() {
cancelling.value = true;
actionErr.value = "";
try {
const r = await unwrap(api.POST("/parse-jobs/{job_id}/cancel", { params: { path: { job_id: props.jobId } } }));
qc.setQueryData(qk.parseJob(props.jobId), r);
} catch (e) {
if (!isApiError(e, "job_finished")) actionErr.value = errorTitle(e);
} finally {
cancelling.value = false;
cancelOpen.value = false;
void qc.invalidateQueries({ queryKey: qk.parseJob(props.jobId) });
}
}
/* ---------- 重试:新建一条任务并重新预扣 1 次,上一条已经退回(FR-M2-02) ---------- */
const retrying = ref(false);
async function retry() {
if (!j.value) return;
retrying.value = true;
actionErr.value = "";
try {
const r = await unwrap(
api.POST("/documents/{document_id}/parse-jobs", {
params: { path: { document_id: j.value.document_id } },
body: { expected_charge: 1 },
}),
);
void qc.invalidateQueries({ queryKey: qk.quota });
await router.replace({ name: "parse", params: { projectId: props.projectId, jobId: r.id } });
} catch (e) {
const other = isApiError(e, "job_already_running") ? e.problem.job_id : undefined;
if (typeof other === "string") await router.replace({ name: "parse", params: { projectId: props.projectId, jobId: other } });
else actionErr.value = `${errorTitle(e)}。本次没有扣点。`;
} finally {
retrying.value = false;
}
}
const result = computed(() => j.value?.result ?? null);
/** 补遗 / 澄清(FR-M2-10):结果按补遗写,下一步去读标页核对这份补遗的条目 */
const isAddendum = computed(() => !!doc.data.value && doc.data.value.role !== "tender");
</script>
<template>
<div class="content narrow">
<div class="page-title">
<div>
<h1>{{ isAddendum ? `解析${doc.data.value?.label}` : "解析招标文件" }}</h1>
<p v-if="doc.data.value">
{{ doc.data.value.original_filename }}<template v-if="doc.data.value.page_count"> · <span class="fig">{{ doc.data.value.page_count }}</span> 页</template>
</p>
</div>
</div>
<section class="card flush" aria-labelledby="h-task" aria-live="polite">
<div class="card-h">
<h2 id="h-task">{{ j ? heading : "正在读取进度" }}</h2>
<span v-if="elapsed" class="aside">
{{ running ? "已用时" : "用时" }} <span class="fig">{{ elapsed.min }}</span> 分 <span class="fig">{{ elapsed.sec }}</span> 秒
</span>
</div>
<div class="card-b">
<template v-if="job.isError.value && !j">
<div class="alert warn" role="alert"><span class="ico w" aria-hidden="true">!</span><p>{{ errorTitle(job.error.value) }}</p></div>
</template>
<template v-else-if="!j">
<span class="skel t"></span><span class="skel"></span><span class="skel"></span>
</template>
<!-- 事件流先报完成、结果随后由重新拉取带回:没到之前不显示「0 条」 -->
<template v-else-if="status === 'succeeded' && !result">
<span class="skel t"></span><span class="skel"></span>
</template>
<template v-else-if="status === 'succeeded'">
<dl class="kv result">
<dt>提取要求</dt>
<dd>
<span class="fig">{{ result?.items_added ?? 0 }}</span> 条<template v-if="result?.items_pending">
· 其中 <span class="fig">{{ result.items_pending }}</span> 条没找到可靠出处或把握不大,放在「待确认」里</template>
</dd>
<template v-if="result?.items_updated"><dt>更新</dt><dd><span class="fig">{{ result.items_updated }}</span> 条</dd></template>
<template v-if="isAddendum">
<dt>日期</dt>
<dd v-if="result?.dates_superseded"><span class="fig">{{ result.dates_superseded }}</span> 处日期有新值,旧条目标「已被{{ doc.data.value?.label }} 更新」,等你确认</dd>
<dd v-else>没有改动已有的日期</dd>
<template v-if="result?.fields_pending"><dt>项目信息</dt><dd><span class="fig">{{ result.fields_pending }}</span> 个字段有新值,在概览里采用或不采用</dd></template>
<dt>清单</dt><dd>回到草稿。标博士不判断哪些旧条目被补遗改了或作废了,核对后重新确认</dd>
</template>
<template v-else-if="result?.lots_found"><dt>采购包</dt><dd>识别到 <span class="fig">{{ result.lots_found }}</span> 个</dd></template>
</dl>
</template>
<template v-else-if="status === 'cancelled'">
<p class="plain">已取消解析。预扣的 <span class="fig">1</span> 次解析额度已退回。</p>
</template>
<template v-else>
<ol class="steps">
<li v-for="(s, k) in PARSE_STEPS" :key="s.label" :class="stepClass(k)">
<span class="dot" aria-hidden="true">{{ stepClass(k) === "done" ? "✓" : stepClass(k) === "fail" ? "!" : "" }}</span>
<span>{{ s.label }}<span v-if="stepClass(k) === 'now'" class="vh">(进行中)</span></span>
</li>
</ol>
<div v-if="running" class="progress">
<div class="bar" role="progressbar" :aria-valuenow="j.progress" aria-valuemin="0" aria-valuemax="100" aria-label="解析进度"><i :style="{ width: `${j.progress}%` }"></i></div>
<span class="fig">{{ j.progress }}%</span>
</div>
<p v-if="running" class="note">可以离开本页,完成或失败都会发站内通知。</p>
<!-- 失败说明(§7.11):赭色图标 + 赭色关键字,不用红 -->
<div v-if="status === 'failed'" class="fail-why" role="note">
<CircleAlert class="ic" :stroke-width="1.5" />
<p>
<b>{{ j.error?.message ?? "解析没有完成。" }}</b>
<template v-if="j.error?.refunded === true">已退回 <span class="fig">1</span> 次解析额度。</template>
<template v-else-if="j.error?.refunded === false">额度还没退回,请联系客服并提供下方错误编号。</template>
<span class="sub">可以重试,或者换一份文件。</span>
</p>
</div>
</template>
<div v-if="actionErr" class="alert warn act-err" role="alert"><span class="ico w" aria-hidden="true">!</span><p>{{ actionErr }}</p></div>
</div>
<div v-if="j" class="card-f">
<template v-if="running">
<button class="btn sm" type="button" :disabled="j.cancel_requested" @click="cancelOpen = true">{{ j.cancel_requested ? "正在取消…" : "取消解析" }}</button>
<span class="cost">已预扣 <span class="fig">1</span> 次解析 · 失败、超时、取消都退回</span>
<span class="r"><RouterLink class="btn sm" :to="{ name: 'workbench' }">回工作台,完成后通知我</RouterLink></span>
</template>
<template v-else-if="status === 'succeeded'">
<!-- 识别到 2 个及以上的包时先选包(FR-M2-04);只有 1 个包按不分包处理(PI4) -->
<RouterLink v-if="isAddendum" class="btn primary" :to="{ name: 'checklist', params: { projectId }, query: { source: doc.data.value?.id } }">核对补遗</RouterLink>
<RouterLink v-else-if="(result?.lots_found ?? 0) > 1" class="btn primary" :to="{ name: 'lots', params: { projectId } }">选择要投的包</RouterLink>
<RouterLink v-else class="btn primary" :to="{ name: 'checklist', params: { projectId } }">查看检查清单</RouterLink>
<span class="r"><span class="cost">已结算 <span class="fig">1</span> 次解析 · {{ j.finished_at ? fmtDateTime(j.finished_at) : "" }}</span></span>
</template>
<template v-else>
<button class="btn primary swap" type="button" :class="{ 'is-loading': retrying }" :aria-busy="retrying" :disabled="retrying" @click="retry">
<span class="idle"><RefreshCw class="ic" :stroke-width="1.5" /> 重新解析</span><span class="busy">正在提交…</span>
</button>
<RouterLink class="btn" :to="{ name: 'project-new', query: { project: projectId } }"><Upload class="ic" :stroke-width="1.5" />换一个文件</RouterLink>
<span class="cost">重新解析预扣 <span class="fig">1</span> 次<template v-if="parse"> · 剩 <span class="fig">{{ parse.balance }}</span> 次</template></span>
<span v-if="j.error" class="r"><span class="errno">{{ j.error.code }} · {{ j.id.slice(0, 8) }}</span></span>
</template>
</div>
</section>
<ConfirmDialog v-model:open="cancelOpen" title="取消解析?" ok-text="取消解析" cancel-text="继续解析" busy-text="正在取消…" :busy="cancelling" @ok="cancel">
<p>已完成的阶段不会保留。取消后退回 1 次解析额度,可以稍后重新解析。</p>
</ConfirmDialog>
</div>
</template>
<style scoped>
.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-b {
padding-top: var(--sp-2);
}
.card-b .progress {
margin-top: var(--sp-3);
}
.card-f .r {
margin-left: auto;
display: flex;
gap: var(--sp-3);
align-items: center;
}
.btn.swap .idle {
display: inline-flex;
gap: var(--sp-2);
align-items: center;
}
.plain {
margin: 0;
}
.result {
grid-template-columns: calc(var(--sp-8) + var(--sp-12)) minmax(0, 1fr);
}
/* 失败说明(§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: var(--sp-1);
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);
}
.act-err {
margin-top: var(--sp-4);
}
</style>

View File

@@ -0,0 +1,279 @@
<script setup lang="ts">
/**
* 新建项目(原型 project-new.html):项目名称(选填)+ 一份招标文件。
* 选好文件就开始分片上传(格式、加密、页数在上传完成时由服务端拦截,不扣点);
* 「开始解析」:建项目 → 把文件挂到项目上 → 发起解析(预扣 1 次,带 expected_charge,API4)。
* 带 ?project=<id> 时给已有的项目补传招标文件(例如上一次上传失败),不再建新项目。
* 再带 &addendum=1 时是追加补遗 / 澄清(FR-M2-10,原型 project-new.html?addendum=1):不填项目名称,文件类型选补遗或澄清答复,
* 单独解析、扣 1 次;新条目标注「补遗 N」并入清单,标博士不判断哪些旧条目已作废,并入后由用户核对。
*/
import { ApiError, unwrap, uploadFile, type Schemas } from "@bid-doctor/api-client";
import { Upload, X } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { computed, ref } from "vue";
import { RouterLink, useRoute, useRouter } from "vue-router";
import { api, apiOptions, errorTitle, isApiError } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys";
import { extOf, fmtSize } from "@/lib/labels";
import { usePlans, useProject, useQuotaAccount, useMe } from "@/lib/queries";
import { platform } from "@/platform";
const ACCEPT = [".pdf", ".docx", ".doc", ".wps"];
/** 解析招标文件固定扣 1 次;和服务端算的不一致时服务端返回 billing_changed,不扣点 */
const EXPECTED_CHARGE = 1 as const;
const route = useRoute();
const router = useRouter();
const qc = useQueryClient();
const me = useMe();
const plans = usePlans();
const parse = useQuotaAccount("parse");
const projectId = ref(typeof route.query.project === "string" ? route.query.project : "");
const existing = useProject(() => (route.query.project ? projectId.value : undefined));
const addendum = computed(() => !!route.query.project && route.query.addendum === "1");
const role = ref<"addendum" | "clarification">("addendum");
const docWord = computed(() => (addendum.value ? "补遗 / 澄清文件" : "招标文件"));
useCrumbs(() =>
addendum.value
? [{ label: "工作台", to: { name: "workbench" } }, { label: existing.data.value?.name ?? "项目", to: { name: "overview", params: { projectId: projectId.value } } }, { label: "追加补遗" }]
: [{ label: "工作台", to: { name: "workbench" } }, { label: existing.data.value ? `${existing.data.value.name} · 上传招标文件` : "新建项目" }],
);
const limits = computed(() => plans.data.value?.items.find((p) => p.code === me.data.value?.plan.code)?.limits);
const name = ref("");
/* ---------- 上传 ---------- */
type UploadState =
| { kind: "idle" }
| { kind: "uploading"; file: File; ratio: number }
| { kind: "done"; file: File; result: Schemas["UploadCompleted"] }
| { kind: "error"; file: File; message: string };
const up = ref<UploadState>({ kind: "idle" });
let uploadSeq = 0;
const fileErr = ref("");
const over = ref(false);
async function startUpload(file: File) {
const seq = ++uploadSeq;
fileErr.value = "";
startErr.value = "";
documentId.value = "";
up.value = { kind: "uploading", file, ratio: 0 };
try {
const result = await uploadFile(api, apiOptions, file, "project_document", (ratio) => {
if (seq === uploadSeq) up.value = { kind: "uploading", file, ratio };
});
if (seq === uploadSeq) up.value = { kind: "done", file, result };
} catch (e) {
// 格式不支持、已加密、已损坏、超出页数或大小:都在这里拦下,不建任务、不扣点
if (seq === uploadSeq) up.value = { kind: "error", file, message: `${errorTitle(e)}${detailOf(e)}。这个文件不会解析,也不扣额度。` };
}
}
const detailOf = (e: unknown) => (e instanceof ApiError && e.problem.detail ? `(${e.problem.detail})` : "");
async function pick() {
if (busy.value) return;
const f = await platform.pickFile({ accept: ACCEPT });
if (f) void startUpload(f);
}
function onDrop(e: DragEvent) {
over.value = false;
const f = e.dataTransfer?.files[0];
if (f && !busy.value) void startUpload(f);
}
function removeFile() {
uploadSeq++; // 正在上传的分片结果作废;服务端的未完成上传 24 小时后自动过期
up.value = { kind: "idle" };
documentId.value = "";
}
const pct = computed(() => (up.value.kind === "uploading" ? Math.round(up.value.ratio * 100) : 100));
const fileOf = computed(() => (up.value.kind === "idle" ? null : up.value.file));
/* ---------- 开始解析 ---------- */
const documentId = ref("");
const busy = ref(false);
const startErr = ref("");
async function start() {
if (up.value.kind !== "done") {
fileErr.value = up.value.kind === "uploading" ? "文件还在上传,传完再开始解析" : `请先上传${docWord.value}`;
return;
}
const uploadId = up.value.result.id;
busy.value = true;
startErr.value = "";
try {
// 每一步成功后都记下结果,失败重试时不会重复建项目(免费版只能建 1 个)
if (!projectId.value) {
const p = await unwrap(api.POST("/projects", { body: name.value.trim() ? { name: name.value.trim() } : {} }));
projectId.value = p.id;
void router.replace({ query: { ...route.query, project: p.id } });
}
if (!documentId.value) {
const d = await unwrap(
api.POST("/projects/{project_id}/documents", {
params: { path: { project_id: projectId.value } },
body: { upload_id: uploadId, role: addendum.value ? role.value : "tender" },
}),
);
documentId.value = d.id;
}
const job = await unwrap(
api.POST("/documents/{document_id}/parse-jobs", {
params: { path: { document_id: documentId.value } },
body: { expected_charge: EXPECTED_CHARGE },
}),
);
void qc.invalidateQueries({ queryKey: qk.projects });
void qc.invalidateQueries({ queryKey: qk.quota });
await router.push({ name: "parse", params: { projectId: projectId.value, jobId: job.id } });
} catch (e) {
const running = isApiError(e, "job_already_running") ? e.problem.job_id : undefined;
if (typeof running === "string") {
await router.push({ name: "parse", params: { projectId: projectId.value, jobId: running } });
return;
}
startErr.value = `${errorTitle(e)}${detailOf(e)}。本次没有扣点。`;
void qc.invalidateQueries({ queryKey: qk.quota });
} finally {
busy.value = false;
}
}
</script>
<template>
<div class="content narrow">
<div class="page-title">
<div>
<h1>{{ addendum ? "追加补遗 / 澄清" : existing.data.value ? "上传招标文件" : "新建项目" }}</h1>
<p v-if="addendum">补遗单独解析,新条目标注「补遗 N」并入清单,日期和项目信息的新值等你确认。标博士不判断哪些旧条目已作废,并入后由你核对。</p>
<p v-else>上传招标文件,标博士会逐条提取要求,每一条都带原文出处。</p>
</div>
</div>
<form class="card flush" novalidate aria-labelledby="h-form" @submit.prevent="start">
<h2 id="h-form" class="vh">新建项目</h2>
<div class="card-b form-b">
<div v-if="!route.query.project" class="field">
<label for="pn">项目名称 <small>(选填)</small></label>
<input id="pn" v-model="name" class="input" maxlength="200" placeholder="例如:某市政务服务中心智慧大厅建设项目" autocomplete="off" aria-describedby="pn-msg" :disabled="busy" />
<span id="pn-msg" class="msg">不填时显示为「未命名项目」,解析后按招标文件自动填写,可以再改</span>
</div>
<div v-else-if="existing.data.value" class="field">
<label>项目</label>
<span>{{ existing.data.value.name }}</span>
</div>
<div v-if="addendum" class="field">
<label>文件类型</label>
<div class="seg" role="group" aria-label="文件类型">
<button type="button" :aria-pressed="role === 'addendum'" :disabled="busy" @click="role = 'addendum'">补遗 / 更正公告</button>
<button type="button" :aria-pressed="role === 'clarification'" :disabled="busy" @click="role = 'clarification'">澄清答复</button>
</div>
</div>
<div class="field" :class="{ err: fileErr }">
<label id="l-file">{{ docWord }}</label>
<div
v-if="!fileOf"
class="drop"
:class="{ 'is-over': over }"
tabindex="0"
role="button"
aria-labelledby="l-file"
aria-describedby="drop-sub"
@click="pick"
@keydown.enter.prevent="pick"
@keydown.space.prevent="pick"
@dragenter.prevent="over = true"
@dragover.prevent="over = true"
@dragleave.prevent="over = false"
@drop.prevent="onDrop"
>
<Upload class="ic lg" :stroke-width="1.5" />
<b>拖入或点击上传</b>
<span id="drop-sub">
PDF、Word(DOC / DOCX)、WPS,一次一份<template v-if="limits"><br />单个文件不超过 <span class="fig xs">{{ limits.max_pages }}</span> 页、<span class="fig xs">{{ Math.round((limits.max_file_bytes ?? 0) / 1048576) }}</span> MB</template>
</span>
</div>
<div v-else class="file-row" :class="{ off: up.kind === 'error' }">
<span class="ext">{{ up.kind === "done" ? up.result.detected_format.toUpperCase() : extOf(fileOf.name) }}</span>
<div>
<b :title="fileOf.name">{{ fileOf.name }}</b>
<span class="meta">
<template v-if="up.kind === 'done' && up.result.page_count"><span class="fig xs">{{ up.result.page_count }}</span> 页 · </template>{{ fmtSize(fileOf.size) }}
<template v-if="up.kind === 'done'"> · 已上传</template>
</span>
<div v-if="up.kind === 'uploading'" class="progress up">
<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 xs">{{ pct }}%</span>
</div>
<p v-if="up.kind === 'done' && up.result.extension_mismatch" class="hint-warn">
<span class="ico w" aria-hidden="true">!</span>
<span>扩展名和实际格式不一致,会按实际格式({{ up.result.detected_format.toUpperCase() }})解析</span>
</p>
<p v-if="up.kind === 'error'" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span><span>{{ up.message }}</span></p>
</div>
<span class="acts">
<button type="button" class="icon-btn sm tip-left" :aria-label="`移除 ${fileOf.name}`" data-tip="移除" :disabled="busy" @click="removeFile"><X class="ic" :stroke-width="1.5" /></button>
</span>
</div>
<span v-if="fileErr" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ fileErr }}</span>
</div>
<div v-if="startErr" class="alert warn start-err" role="alert">
<span class="ico w" aria-hidden="true">!</span>
<p>{{ startErr }}</p>
</div>
</div>
<div class="card-f">
<!-- 红线 9:扣点规则写在按钮旁边 -->
<span class="cost">将扣 <span class="fig">1</span> 次解析;解析失败、超时或取消不扣点<template v-if="parse"> · 剩 <span class="fig">{{ parse.balance }}</span> 次</template></span>
<RouterLink class="btn" :to="addendum ? { name: 'overview', params: { projectId } } : { name: 'workbench' }">取消</RouterLink>
<button type="submit" class="btn primary swap" :class="{ 'is-loading': busy }" :aria-busy="busy" :disabled="busy || up.kind !== 'done'">
<span class="idle">开始解析</span><span class="busy">正在提交…</span>
</button>
</div>
</form>
</div>
</template>
<style scoped>
.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);
}
.field.err .drop {
border-color: var(--warn);
}
.card-f .cost {
margin-right: auto;
white-space: normal;
}
.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);
}
.progress.up {
margin-top: var(--sp-1);
}
.start-err {
margin-top: var(--sp-5);
}
</style>

View File

@@ -0,0 +1,288 @@
<script setup lang="ts">
/**
* 资质库(FR-M6-08,原型 qualifications.html):列表 + 添加 / 编辑对话框 + 删除。
* - 到期按每个进行中项目的投标截止日判断,不按今天(服务端给 late_for);只提醒,不拦截;
* - 没有附件显示「待补录」,不生成内容(红线 5);
* - 删除只有确认框,没有撤销(QL6)。
*/
import { unwrap } from "@bid-doctor/api-client";
import { MoreHorizontal, Plus, Search } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuRoot, DropdownMenuSeparator, DropdownMenuTrigger } from "reka-ui";
import { computed, ref } from "vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import QualificationDialog, { type ActiveDeadline } from "@/components/QualificationDialog.vue";
import { api, errorTitle } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys";
import { fmtDay, fmtMonthDay, QUALIFICATION_KIND, QUALIFICATION_KIND_ORDER } from "@/lib/labels";
import { useProjects, useQualifications, type Qualification } from "@/lib/queries";
import { toast } from "@/lib/toast";
useCrumbs(() => [{ label: "资质库" }]);
const qc = useQueryClient();
const quals = useQualifications();
const projects = useProjects();
const all = computed(() => quals.data.value?.items ?? []);
const kind = ref<Qualification["kind"] | "">("");
const kw = ref("");
const segs = computed(() => [
{ v: "" as const, label: "全部", n: all.value.length },
...QUALIFICATION_KIND_ORDER.map((k) => ({ v: k, label: QUALIFICATION_KIND[k], n: all.value.filter((q) => q.kind === k).length })).filter((s) => s.n),
]);
const rows = computed(() => {
const k = kw.value.trim().toLowerCase();
return all.value.filter(
(q) => (!kind.value || q.kind === kind.value) && (!k || [q.name, q.cert_no, q.issuer].some((x) => x?.toLowerCase().includes(k))),
);
});
const lateOnes = computed(() => all.value.filter((q) => q.late_for.length));
/** 进行中项目的投标截止日(北京时间的日期),给对话框做到期提醒 */
const deadlines = computed<ActiveDeadline[]>(() => {
const fmt = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai" });
const today = fmt.format(new Date());
return (projects.data.value?.items ?? [])
.filter((p) => ["tracking", "drafting", "checked"].includes(p.status) && p.deadline_at)
.map((p) => ({ name: p.name, day: fmt.format(new Date(p.deadline_at!)) }))
.filter((d) => d.day >= today)
.sort((a, b) => (a.day < b.day ? -1 : 1));
});
const lateText = (q: Qualification) => {
const first = q.late_for[0]!;
return `早于「${first.name}」的投标截止 ${fmtMonthDay(first.deadline)}${q.late_for.length > 1 ? ` 等 ${q.late_for.length} 个项目` : ""}`;
};
const editing = ref<Qualification | null>(null);
const dialogOpen = ref(false);
const focusExpiry = ref(false);
function edit(q: Qualification | null, expiry = false) {
editing.value = q;
focusExpiry.value = expiry;
dialogOpen.value = true;
}
function onSaved(q: Qualification, how: "created" | "updated" | "files") {
void qc.invalidateQueries({ queryKey: qk.qualifications });
editing.value = q; // 新增后对话框还开着(附件没传上)时,再保存就是修改
if (how === "created") toast(`已添加「${q.name}」`);
else if (how === "updated") toast(`已保存「${q.name}」`);
}
/** 打开第一个附件(短时签名地址) */
async function view(q: Qualification) {
try {
const link = await unwrap(
api.GET("/qualifications/{qualification_id}/files/{file_id}", { params: { path: { qualification_id: q.id, file_id: q.files[0]!.id } } }),
);
window.open(link.url, "_blank", "noopener");
} catch (e) {
toast(errorTitle(e), { ok: false });
}
}
const removing = ref<Qualification | null>(null);
const removeOpen = ref(false);
const removeBusy = ref(false);
function askRemove(q: Qualification) {
removing.value = q;
removeOpen.value = true;
}
async function remove() {
const q = removing.value;
if (!q) return;
removeBusy.value = true;
try {
await unwrap(api.DELETE("/qualifications/{qualification_id}", { params: { path: { qualification_id: q.id } } }));
removeOpen.value = false;
await qc.invalidateQueries({ queryKey: qk.qualifications });
toast(`已删除「${q.name}」`);
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
removeBusy.value = false;
}
}
</script>
<template>
<div class="content">
<div class="page-title">
<div>
<h1>资质库</h1>
<p>查标时按每个项目的投标截止日核对有效期和等级。只用你录入的资质。</p>
</div>
<div class="actions">
<button class="btn primary" type="button" @click="edit(null)"><Plus class="ic" :stroke-width="1.5" />添加资质</button>
</div>
</div>
<div v-if="lateOnes.length" class="alert warn" role="note">
<span class="ico w" aria-hidden="true">!</span>
<p><b>{{ lateOnes.length }} 张证书会在进行中项目的投标截止前到期。</b>到期按每个项目的投标截止日判断,不按今天。</p>
<button v-if="lateOnes.length === 1" class="btn sm" type="button" @click="edit(lateOnes[0]!, true)">更新有效期</button>
</div>
<section class="card flush" aria-labelledby="h-quals">
<div class="card-h">
<h2 id="h-quals">资质</h2>
<div v-if="all.length" class="seg" role="group" aria-label="按类型筛选">
<button v-for="s in segs" :key="s.v" type="button" :aria-pressed="kind === s.v" @click="kind = s.v">
{{ s.label }}<span class="fig">{{ s.n }}</span>
</button>
</div>
<div v-if="all.length" class="r">
<label class="search"><Search class="ic" :stroke-width="1.5" /><input v-model="kw" class="input" type="search" placeholder="名称、证书编号" aria-label="搜索资质" /></label>
</div>
</div>
<div v-if="quals.isPending.value" class="card-b"><span class="skel t"></span><span class="skel"></span><span class="skel"></span></div>
<div v-else-if="quals.isError.value" class="card-b">
<div class="alert warn" role="alert">
<span class="ico w" aria-hidden="true">!</span>
<p>{{ errorTitle(quals.error.value) }}</p>
<button class="btn sm" type="button" @click="quals.refetch()">重试</button>
</div>
</div>
<div v-else-if="!all.length" class="empty">
<b>还没有录入资质</b>
<p>录入资质证书、体系认证和软件著作权后,查标会按每个项目的投标截止日核对有效期和等级。</p>
<button class="btn primary" type="button" @click="edit(null)"><Plus class="ic" :stroke-width="1.5" />添加资质</button>
</div>
<table v-else 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>
<tr v-for="q in rows" :key="q.id" @click="edit(q)">
<td><button type="button" class="nm" @click.stop="edit(q)">{{ q.name }}</button></td>
<td>{{ QUALIFICATION_KIND[q.kind] }}</td>
<td><template v-if="q.level">{{ q.level }}</template><span v-else class="dash">—</span></td>
<td class="num">{{ q.cert_no }}</td>
<td><template v-if="q.issuer">{{ q.issuer }}</template><span v-else class="dash">—</span></td>
<td class="exp">
<span>{{ q.valid_to ? fmtDay(q.valid_to) : "长期有效" }}</span>
<span v-if="q.late_for.length" class="warn">{{ lateText(q) }}</span>
</td>
<td>
<template v-if="q.files.length">
<button type="button" class="lnk" @click.stop="view(q)">查看</button>
<span v-if="q.files.length > 1" class="cap muted"> 等 <span class="fig">{{ q.files.length }}</span> 个</span>
</template>
<span v-else class="tag status">待补录</span>
</td>
<td class="more-cell" @click.stop>
<DropdownMenuRoot :modal="false">
<DropdownMenuTrigger class="icon-btn sm" aria-label="更多操作"><MoreHorizontal class="ic" :stroke-width="1.5" /></DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent class="menu popper" align="end" :side-offset="4">
<DropdownMenuItem @select="edit(q)">编辑</DropdownMenuItem>
<DropdownMenuItem @select="edit(q)">{{ q.files.length ? "管理附件" : "上传附件" }}</DropdownMenuItem>
<DropdownMenuSeparator as="hr" />
<DropdownMenuItem @select="askRemove(q)">删除</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</td>
</tr>
<tr v-if="!rows.length">
<td colspan="8" class="empty-cell">
<div class="empty sm">
<b>没有找到匹配的资质</b>
<p>换个关键词,或清除筛选。</p>
<button class="btn sm" type="button" @click="(kind = ''), (kw = '')">清除筛选</button>
</div>
</td>
</tr>
</tbody>
</table>
</section>
<p class="note below">业绩、人员、历史标书等资产库在 V1 提供。</p>
<QualificationDialog v-model:open="dialogOpen" :item="editing" :deadlines="deadlines" :focus-expiry="focusExpiry" @saved="onSaved" />
<ConfirmDialog v-model:open="removeOpen" :title="`删除资质「${removing?.name ?? ''}」?`" ok-text="删除" :busy="removeBusy" busy-text="正在删除…" @ok="remove">
<p>删除后,查标不再用它核对资质,附件一并删除,不能恢复。已生成的检查报告不受影响。</p>
</ConfirmDialog>
</div>
</template>
<style scoped>
.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;
}
.seg .fig {
margin-left: 2px;
}
table.quals {
cursor: default;
}
table.quals tbody tr {
cursor: pointer;
}
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.data td.empty-cell {
height: auto;
padding: 0;
}
.note.below {
margin: var(--sp-3) 0 0;
}
</style>

View File

@@ -0,0 +1,287 @@
<script setup lang="ts">
/**
* 额度与套餐(原型 quota.html,FR-M6-03、05,m6-plan §1.1–1.3):
* - 套餐和本期起止;开通 / 续费申请(对公转账,运营确认到账后开通,G8)和收款信息;到期后只读(M6-5);
* - 本期额度(剩余、已用、退回)、按功能分布、扣点规则(红线 9:扣点规则可见、可查);
* - 本期流水:每一笔都能对到项目和任务,余额逐条可核;项目删了写「已删除的项目」(G10);可以导出 CSV。
* 检查也从解析额度扣(data-model §4.3),所以只有一个「解析」账户。
*/
import { Download, Plus } from "@lucide/vue";
import { computed, ref } from "vue";
import ActivationDialog from "@/components/ActivationDialog.vue";
import { useCrumbs } from "@/lib/crumbs";
import { fmtDateTime, quotaUnit } from "@/lib/labels";
import { useActivationRequests, useQuota, useQuotaEntries, type QuotaEntry } from "@/lib/queries";
import { toast } from "@/lib/toast";
useCrumbs(() => [{ label: "额度与套餐" }]);
const quota = useQuota();
const q = computed(() => quota.data.value);
const plan = computed(() => q.value?.plan);
const acct = computed(() => q.value?.accounts.find((a) => a.unit === "parse"));
const requests = useActivationRequests();
const pending = computed(() => q.value?.pending_request ?? null);
const lastRejected = computed(() => {
const latest = requests.data.value?.items[0];
return latest && latest.status === "rejected" ? latest : null;
});
const day = (iso: string | null | undefined) => (iso ? iso.slice(0, 10) : "");
const expired = computed(() => !!plan.value?.read_only);
const daysLeft = computed(() =>
plan.value?.ends_at ? Math.ceil((new Date(plan.value.ends_at).getTime() - Date.now()) / 86_400_000) : null,
);
/** 开通 / 续费按钮:免费版随时可以申请开通;专业版到期前 30 天起、已到期时可以续费;已续过一期或有待处理的申请时不显示 */
const canApply = computed(() => {
if (!plan.value || pending.value || q.value?.renewal) return false;
if (plan.value.code === "free" || expired.value) return true;
return daysLeft.value !== null && daysLeft.value <= 30;
});
const renewing = computed(() => plan.value?.code !== "free");
const dlg = ref(false);
/* ---------- 流水 ---------- */
const entriesQ = useQuotaEntries();
const rows = computed(() => entriesQ.data.value?.pages.flatMap((p) => p.items) ?? []);
const KIND: Record<string, string> = {
grant: "发放",
hold: "预扣",
settle: "结算",
release: "退回",
expire: "过期",
adjust: "调整",
waive: "免扣(24 小时内复查)",
};
const REASON: Record<string, string> = { recheck_24h: "距上次扣额度的检查不到 24 小时" };
function refText(e: QuotaEntry): string {
const project = e.project ? (e.project.deleted ? "已删除的项目" : e.project.name ?? "") : "";
const task = e.ref?.label ?? (e.ref ? (e.ref.type === "check_run" ? "检查" : "解析") : "");
return [project, task].filter(Boolean).join(" · ");
}
function note(e: QuotaEntry): string {
if (e.reason) return REASON[e.reason] ?? e.reason;
if (e.kind === "release") return "失败、超时或取消,自动退回";
if (e.kind === "settle") return "任务完成";
return "";
}
const signed = (n: number) => (n > 0 ? `+${n}` : String(n));
/** 导出 CSV:把还没加载的页都取完,在浏览器里生成文件 */
const exporting = ref(false);
async function exportCsv() {
exporting.value = true;
try {
while (entriesQ.hasNextPage.value) await entriesQ.fetchNextPage();
const head = ["时间", "类型", "额度", "数量", "余额", "关联项目 · 任务", "说明"];
const lines = rows.value.map((e) =>
[fmtDateTime(e.created_at), KIND[e.kind] ?? e.kind, quotaUnit(e.unit), signed(e.amount), e.balance_after, refText(e), note(e)]
.map((v) => `"${String(v).replaceAll('"', '""')}"`)
.join(","),
);
const blob = new Blob(["" + [head.join(","), ...lines].join("\r\n")], { type: "text/csv;charset=utf-8" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `额度流水-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(a.href);
} catch {
toast("导出失败,请稍后再试", { ok: false });
} finally {
exporting.value = false;
}
}
</script>
<template>
<div class="content">
<div class="page-title">
<div>
<h1>额度与套餐</h1>
<p v-if="plan">
<template v-if="plan.ends_at">{{ plan.name }} · 本期 <span class="fig">{{ day(plan.starts_at) }}</span> 至 <span class="fig">{{ day(plan.ends_at) }}</span>。额度绑定企业主体,不可转让;本期没用完的不结转。</template>
<template v-else>{{ plan.name }} · <span class="fig">{{ day(plan.starts_at) }}</span> 注册起长期有效,额度不再补发。额度绑定企业主体,不可转让。</template>
</p>
</div>
<div class="actions">
<button v-if="canApply" type="button" class="btn" :class="{ primary: expired || plan?.code === 'free' }" @click="dlg = true">
<Plus class="ic" :stroke-width="1.5" />{{ renewing ? "提交续费申请" : "开通专业版" }}
</button>
</div>
</div>
<div v-if="expired" class="alert warn" role="note">
<span class="ico w" aria-hidden="true">!</span>
<p>专业版已于 <span class="fig">{{ day(plan?.ends_at) }}</span> 到期,数据只读:可以查看、下载已生成的报告、删除项目,不能新建项目、解析和检查。续费到账后恢复。</p>
</div>
<div v-else-if="daysLeft !== null && daysLeft <= 30 && !q?.renewal" class="alert warn" role="note">
<span class="ico w" aria-hidden="true">!</span>
<p>专业版还有 <span class="fig">{{ Math.max(daysLeft, 0) }}</span> 天到期,到期后数据只读、额度清零。</p>
</div>
<div v-if="q?.renewal" class="alert" role="note">
<span class="ico i" aria-hidden="true">i</span>
<p>已续费:新一期 <span class="fig">{{ day(q.renewal.starts_at) }}</span> 至 <span class="fig">{{ day(q.renewal.ends_at) }}</span>,到时自动生效并发放额度。</p>
</div>
<section v-if="pending" class="card pend" aria-labelledby="h-pend">
<div class="card-h">
<h2 id="h-pend">{{ renewing ? "续费" : "开通" }}申请 <span class="fig">{{ pending.request_no }}</span></h2>
<span class="tag">{{ pending.status === "paid" ? "已到账,等待开通" : "等待对公转账" }}</span>
</div>
<dl class="kv">
<dt>套餐</dt><dd>专业版 · 1 年</dd>
<dt>收款单位</dt><dd>{{ pending.payment.payee_name }}</dd>
<dt>开户行</dt><dd>{{ pending.payment.payee_bank }}</dd>
<dt>账号</dt><dd class="fig">{{ pending.payment.payee_account }}</dd>
<dt>转账备注</dt><dd>请写申请单号 <b class="fig">{{ pending.request_no }}</b></dd>
</dl>
<p class="cap muted foot-note">金额由运营联系你确认。运营确认到账后开通,并开发票;开通后新一期额度会记进下面的流水。</p>
</section>
<div v-else-if="lastRejected" class="alert warn" role="note">
<span class="ico w" aria-hidden="true">!</span>
<p>申请 <span class="fig">{{ lastRejected.request_no }}</span> 被退回<template v-if="lastRejected.ops_note">:{{ lastRejected.ops_note }}</template>。可以修改后重新提交。</p>
</div>
<div class="cards">
<section class="card" aria-labelledby="h-acct">
<div class="card-h"><h2 id="h-acct">解析额度</h2><span v-if="acct" class="cap muted r">已用 <span class="fig">{{ Math.round((acct.used_ratio ?? 0) * 100) }}</span>%</span></div>
<template v-if="acct">
<small class="muted">剩余</small>
<div><span class="big" :class="{ warn: (acct.used_ratio ?? 0) >= 0.8 }">{{ acct.balance }}</span><span class="unit">/ {{ acct.period_granted }} 次</span></div>
<div class="bar" :class="{ warn: (acct.used_ratio ?? 0) >= 0.8 }" aria-hidden="true">
<i :style="{ width: `${Math.min(100, Math.round((acct.used_ratio ?? 0) * 100))}%` }"></i>
</div>
<p class="cap muted">
已用 <span class="fig">{{ acct.period_used }}</span> 次 · 退回 <span class="fig">{{ acct.period_released ?? 0 }}</span> 次<template v-if="acct.held"> · 正在预扣 <span class="fig">{{ acct.held }}</span> 次</template>
</p>
</template>
<p v-else class="cap muted">读取中…</p>
</section>
<section class="card" aria-labelledby="h-usage">
<div class="card-h"><h2 id="h-usage">按功能分布</h2></div>
<ul v-if="q" class="usage">
<li><span>读标 · 招标文件</span><span><span class="fig">{{ q.usage.tender }}</span> <small>次解析</small></span></li>
<li><span>读标 · 补遗和澄清</span><span><span class="fig">{{ q.usage.addendum }}</span> <small>次解析</small></span></li>
<li><span>查标</span><span><span class="fig">{{ q.usage.check }}</span> <small>次解析</small></span></li>
<li v-if="q.usage.deleted"><span>已删除的项目</span><span><span class="fig">{{ q.usage.deleted }}</span> <small>次解析</small></span></li>
<li><span>24 小时内复查</span><span><span class="fig">{{ q.usage.waived }}</span> <small>次 · 免扣</small></span></li>
</ul>
</section>
</div>
<section class="card flush" aria-labelledby="h-rules">
<div class="card-h pad"><h2 id="h-rules">扣点规则</h2></div>
<table class="data">
<thead><tr><th>动作</th><th>扣点</th><th>不扣点</th></tr></thead>
<tbody>
<tr>
<td><b>解读一份招标文件、补遗或澄清</b></td>
<td>1 次解析:发起时预扣,成功后结算</td>
<td>解析失败、超时、取消:自动退回</td>
</tr>
<tr>
<td><b>对一个项目做一次检查</b></td>
<td>专业版 1 次解析(投标文件的解析含在内);免费版:这个项目内不限次数</td>
<td>检查中断、系统异常:自动退回<br />同一项目 24 小时内复查:不扣,流水记一行「免扣」。24 小时从上一次扣额度的检查算起</td>
</tr>
</tbody>
</table>
</section>
<section class="card flush" aria-labelledby="h-ledger">
<div class="card-h pad">
<h2 id="h-ledger">流水</h2>
<button type="button" class="btn sm r" :disabled="exporting || !rows.length" @click="exportCsv"><Download class="ic" :stroke-width="1.5" />导出 CSV</button>
</div>
<table class="data ledger">
<thead>
<tr><th>时间</th><th>类型</th><th class="r">数量</th><th class="r">余额</th><th>关联项目 · 任务</th><th>说明</th></tr>
</thead>
<tbody>
<tr v-for="e in rows" :key="e.id">
<td class="fig nowrap">{{ fmtDateTime(e.created_at) }}</td>
<td>{{ KIND[e.kind] ?? e.kind }}</td>
<td class="num r">{{ signed(e.amount) }}</td>
<td class="num r">{{ e.balance_after }}</td>
<td :class="{ muted: e.project?.deleted }">{{ refText(e) || "—" }}</td>
<td class="muted">{{ note(e) }}</td>
</tr>
<tr v-if="!rows.length && !entriesQ.isPending.value"><td colspan="6" class="muted">还没有流水</td></tr>
</tbody>
</table>
<div v-if="entriesQ.hasNextPage.value" class="more">
<button type="button" class="btn sm text" :disabled="entriesQ.isFetchingNextPage.value" @click="entriesQ.fetchNextPage()">加载更多</button>
</div>
</section>
</div>
<ActivationDialog v-model:open="dlg" :renew="renewing" />
</template>
<style scoped>
.content > .alert,
.content > .card {
margin-bottom: var(--gap-card);
}
.cards {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: var(--gap-card);
margin-bottom: var(--gap-card);
}
.cards .card {
margin: 0;
}
.card-h .r {
margin-left: auto;
}
.card-h.pad {
padding: var(--sp-4) var(--card-x) 0;
}
.big {
font: var(--fw-bold) var(--fs-figure) / var(--lh-figure) var(--font-figures);
font-variant-numeric: tabular-nums;
}
.big.warn {
color: var(--warn-text);
}
.unit {
margin-left: 4px;
color: var(--text-secondary);
font-family: var(--font-figures);
}
.bar {
margin: var(--sp-3) 0 var(--sp-2);
}
.usage {
margin: 0;
padding: 0;
list-style: none;
}
.usage li {
display: flex;
justify-content: space-between;
padding: var(--sp-2) 0;
border-top: var(--line) solid var(--border-subtle);
}
.usage li:first-child {
border-top: 0;
}
.usage small {
color: var(--text-secondary);
}
.pend .kv {
margin: 0;
}
.foot-note {
margin: var(--sp-3) 0 0;
}
.ledger .r {
text-align: right;
}
.nowrap {
white-space: nowrap;
}
.more {
padding: var(--sp-2) var(--card-x) var(--sp-3);
}
</style>

View File

@@ -0,0 +1,241 @@
<script setup lang="ts">
/**
* 报告(原型 report.html,FR-M3-09):左边预览,右边「生成报告」和「已生成的报告」。
* 用词分开(避免「导出」既像生成又像下载):
* - 生成:按最近一次检查和当前的处理情况,在后台做一份新的 PDF,存进项目;致命问题没处理完也可以生成(红线 4);
* - 每份报告是生成那一刻的快照,之后不再改动;处理了问题要重新生成,旧的保留,随项目归档;
* - 下载:把某一份已生成的 PDF 存到电脑上。预览就是这份 PDF 本身(pdf.js 画出来),和下载的文件一致。
*/
import { unwrap } from "@bid-doctor/api-client";
import { AlertCircle, Download, Eye, RefreshCw } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { computed, ref, watch } from "vue";
import { RouterLink, useRoute } from "vue-router";
import ProjectHead from "@/components/ProjectHead.vue";
import ReportViewer from "@/components/ReportViewer.vue";
import { api, errorTitle } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys";
import { fmtDateTime } from "@/lib/labels";
import { reportFileUrl, useCheckRun, useProject, useReports } from "@/lib/queries";
import { toast } from "@/lib/toast";
const props = defineProps<{ projectId: string }>();
const qc = useQueryClient();
const project = useProject(() => props.projectId);
const p = computed(() => project.data.value);
const runId = computed(() => p.value?.latest_check_run_id ?? "");
const runQ = useCheckRun(runId);
const run = computed(() => (runId.value ? runQ.data.value : undefined));
useCrumbs(() => [{ label: "工作台", to: { name: "workbench" } }, { label: p.value?.name ?? "项目" }, { label: "报告" }]);
const reportsQ = useReports(() => props.projectId);
const reports = computed(() => reportsQ.data.value?.items ?? []);
const citations = ref(true);
const manualChecks = ref(true);
const generating = ref(false);
// 检查的 counts 是检查结束时的快照;处理问题后的待处理数看项目(和项目头的「就绪」一致)
const openFatal = computed(() => p.value?.readiness.open_fatal_count ?? 0);
async function generate() {
if (!runId.value || generating.value) return;
generating.value = true;
try {
let job = await unwrap(
api.POST("/check-runs/{run_id}/reports", {
params: { path: { run_id: runId.value } },
body: { citations: citations.value, manual_checks: manualChecks.value },
}),
);
for (let i = 0; i < 120 && (job.status === "queued" || job.status === "running"); i++) {
await new Promise((r) => setTimeout(r, 1000));
job = await unwrap(api.GET("/report-jobs/{job_id}", { params: { path: { job_id: job.id } } }));
}
if (job.status !== "succeeded" || !job.report_id) throw new Error(job.error?.message ?? "报告生成失败,请稍后重试");
await qc.invalidateQueries({ queryKey: qk.reports(props.projectId) });
await preview(job.report_id);
toast("报告已生成,可以下载");
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
generating.value = false;
}
}
/* ---------- 预览和下载:短时签名地址 ---------- */
const previewId = ref<string | null>(null);
const previewUrl = ref<string | null>(null);
const fileUrl = reportFileUrl;
const previewing = computed(() => reports.value.find((x) => x.id === previewId.value));
/** 最新一份报告之后,检查或致命问题的处理情况变了:提示可以重新生成(只看得出致命数的变化) */
const latest = computed(() => reports.value[0]);
const outdated = computed(() => {
const r = latest.value;
if (!r || !runId.value) return "";
if (r.check_run_id !== runId.value) return "最新一份报告之后又检查过";
if ((r.open_fatal_at_export ?? 0) !== openFatal.value) return "最新一份报告之后处理过致命问题";
return "";
});
async function preview(id: string) {
try {
previewUrl.value = await fileUrl(id);
previewId.value = id;
} catch (e) {
toast(errorTitle(e), { ok: false });
}
}
async function download(id: string) {
try {
window.open(await fileUrl(id), "_blank", "noopener");
} catch (e) {
toast(errorTitle(e), { ok: false });
}
}
// 从概览「查看报告」进来时带着 ?run=:先预览那次检查最新的一份
const route = useRoute();
watch(reports, (list) => {
if (previewId.value || !list[0]) return;
const run = route.query.run;
void preview((list.find((r) => r.check_run_id === run) ?? list[0]).id);
}, { immediate: true });
</script>
<template>
<ProjectHead :project="p" tab="report" />
<div class="content wide">
<div v-if="p && !p.latest_check_run_id" class="empty">
<b>还没有检查结果</b>
<p>报告按最近一次检查生成。先上传投标文件并检查。</p>
<RouterLink class="btn primary" :to="{ name: 'bid-check', params: { projectId } }">去检查</RouterLink>
</div>
<div v-else class="rp">
<div class="split-doc">
<ReportViewer :url="previewUrl" :label="previewing ? `${fmtDateTime(previewing.created_at)} 生成的报告` : '检查报告'">
<template v-if="previewing" #actions>
<button type="button" class="btn sm" @click="download(previewing.id)"><Download class="ic" :stroke-width="1.5" />下载</button>
</template>
<template #empty>
<div class="empty">
<b>还没有生成过报告</b>
<p>在右边选好内容,点「生成报告」,生成的 PDF 会在这里显示,可以下载。</p>
</div>
</template>
</ReportViewer>
</div>
<aside class="rp-side">
<section class="card flush" aria-labelledby="h-gen">
<div class="card-h"><h2 id="h-gen">生成报告</h2></div>
<div class="card-b">
<p v-if="run" class="cap muted">依据第 <span class="fig">{{ run.run_no }}</span> 次检查(<span class="fig">{{ fmtDateTime(run.finished_at ?? run.created_at) }}</span>)和现在的处理情况</p>
<div class="choices">
<label class="choice dis"><input type="checkbox" class="ck" checked disabled /><span>问题明细<small>全部级别,必选</small></span></label>
<label class="choice"><input v-model="manualChecks" type="checkbox" class="ck" /><span>附录:需人工核对</span></label>
<label class="choice"><input v-model="citations" type="checkbox" class="ck" /><span>每条附原文出处</span></label>
</div>
<p v-if="openFatal" class="hint-warn"><AlertCircle class="ic" :stroke-width="1.5" /><span>还有 <b><span class="fig">{{ openFatal }}</span> 项致命问题</b>待处理,报告会如实列出。</span></p>
<p v-if="outdated" class="hint-warn"><AlertCircle class="ic" :stroke-width="1.5" /><span>{{ outdated }},要反映进报告需要重新生成。</span></p>
</div>
<div class="card-f">
<button type="button" class="btn primary swap" :class="{ 'is-loading': generating }" :aria-busy="generating" :disabled="generating || run?.status !== 'succeeded'" @click="generate">
<span class="idle"><RefreshCw v-if="reports.length" class="ic" :stroke-width="1.5" />{{ reports.length ? "重新生成报告" : "生成报告" }}</span><span class="busy">正在生成…</span>
</button>
</div>
</section>
<section class="card flush" aria-labelledby="h-done">
<div class="card-h"><h2 id="h-done">已生成的报告</h2><span v-if="reports.length" class="fig muted">{{ reports.length }}</span></div>
<div class="card-b">
<ul v-if="reports.length" class="exports">
<li v-for="r in reports" :key="r.id" :class="{ cur: r.id === previewId }">
<div>
<b>第 <span class="fig">{{ r.run_no }}</span> 次检查</b>
<small><span class="fig">{{ fmtDateTime(r.created_at) }}</span> · {{ r.generated_by.name }} · <span class="fig">{{ r.page_count }}</span> 页</small>
</div>
<button type="button" class="icon-btn sm" :aria-label="`预览第 ${r.run_no} 次检查的报告`" @click="preview(r.id)"><Eye class="ic" :stroke-width="1.5" /></button>
<button type="button" class="icon-btn sm" :aria-label="`下载第 ${r.run_no} 次检查的报告`" @click="download(r.id)"><Download class="ic" :stroke-width="1.5" /></button>
</li>
</ul>
<p v-else class="cap muted">还没有生成过。</p>
</div>
<div class="card-f cap muted">每份是生成那一刻的结果,之后不再改动,随项目归档。</div>
</section>
</aside>
</div>
</div>
</template>
<style scoped>
.rp {
display: grid;
grid-template-columns: minmax(0, 1fr) var(--aside-w);
gap: var(--gap-card);
align-items: start;
}
.split-doc .empty {
padding: var(--sp-8) var(--card-x);
}
.rp-side {
position: sticky;
top: calc(var(--topbar-h) + var(--sp-2));
}
.rp-side > .card + .card {
margin-top: var(--gap-card);
}
.choices {
display: grid;
gap: var(--sp-3);
margin-top: var(--sp-3);
}
.choice small {
display: block;
color: var(--text-secondary);
font-size: var(--fs-caption);
}
.hint-warn {
margin-top: var(--sp-4);
}
.card-f .btn.primary {
flex: 1;
}
.exports {
list-style: none;
margin: 0;
padding: 0;
}
.exports li {
display: flex;
gap: var(--sp-2);
align-items: center;
padding: var(--sp-3) 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 li.cur b {
text-decoration: underline;
text-underline-offset: 3px;
}
.card-h .fig {
font-weight: var(--fw-medium);
}
</style>

View File

@@ -0,0 +1,799 @@
<script setup lang="ts">
/**
* 查标(原型 results.html,FR-M3-03、07、08)。第 1 步的最小版本:
* - 检查进行中显示进度和取消;失败时写明原因和是否退回额度;
* - 顶部是检查覆盖范围:查了什么、没查什么(m3-plan §2.5,红线 3);
* - 问题按级别筛选,逐条处理:确认无误 / 已修改 / 忽略并注明原因;致命问题确认无误也要写说明(U4),
* 说明框在行内展开;处理后显示署名,可以撤销;
* - 每个问题带招标原文和投标原文两侧出处,「未找到」写明找过哪些分册;点出处在右侧抽屉里看原文;
* - 只有最近一次检查的问题可以处理,历史批次只读。
* - 需人工核对(FR-M3-04、P2):份数、密封、递交、CA 加密等文件里看不出来的要求,按项目勾选,写勾选人和时间;
* - 键盘(规范 §8.2):J / K 上下条、O / Enter 看原文、1 / 2 / 3 处理、X 勾选「提示」、U 撤销、? 快捷键说明;
* 「提示」可以批量确认无误;每个问题可以反馈判断有误;从工作台带 ?f=问题编号 进来时选中那一条。
*/
import { unwrap } from "@bid-doctor/api-client";
import { ChevronDown, ChevronRight, FileText } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { computed, onBeforeUnmount, onMounted, ref, watch, watchEffect } from "vue";
import { RouterLink, useRoute } from "vue-router";
import CitationBlock from "@/components/CitationBlock.vue";
import DocStage, { type StageSlot } from "@/components/DocStage.vue";
import FeedbackDialog, { type FeedbackTarget } from "@/components/FeedbackDialog.vue";
import type { ViewerTarget } from "@/components/PdfViewer.vue";
import ProjectHead from "@/components/ProjectHead.vue";
import { api, errorTitle, isApiError } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys";
import { CHECK_STEPS, checkStepIndex, FINDING_ACTION, fmtDateTime, fmtDay, SEVERITY, SEVERITY_CLASS } from "@/lib/labels";
import { TERMINAL_JOB, useCheckRun, useFindings, useManualChecks, useProject, useProjectDocuments, type Citation, type Finding, type ManualCheck } from "@/lib/queries";
import { toast } from "@/lib/toast";
const props = defineProps<{ projectId: string; runId: string }>();
const qc = useQueryClient();
const project = useProject(() => props.projectId);
const runQ = useCheckRun(() => props.runId);
const run = computed(() => runQ.data.value);
const done = computed(() => run.value?.status === "succeeded");
const findingsQ = useFindings(() => props.runId, done);
const p = computed(() => project.data.value);
useCrumbs(() => [{ label: "工作台", to: { name: "workbench" } }, { label: p.value?.name ?? "项目" }, { label: "查标" }]);
const readOnly = computed(() => !run.value?.is_latest || p.value?.status === "submitted" || p.value?.status === "archived");
const step = computed(() => checkStepIndex(run.value?.stage));
/* ---------- 进行中 ---------- */
const cancelling = ref(false);
async function cancel() {
cancelling.value = true;
try {
await unwrap(api.POST("/check-runs/{run_id}/cancel", { params: { path: { run_id: props.runId } } }));
void qc.invalidateQueries({ queryKey: qk.checkRun(props.runId) });
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
cancelling.value = false;
}
}
/* ---------- 问题 ---------- */
type Sev = "" | Finding["severity"];
const sev = ref<Sev>("");
const all = computed(() => findingsQ.data.value?.items ?? []);
/** 清单里重复出现的要求(决策 C5 修订):各自判断,同组只显示第一条,处理时一起处理 */
const hidden = computed(() => {
const seen = new Set<string>();
const out = new Set<string>();
for (const f of all.value) {
if ((f.duplicate_ids ?? []).some((id) => seen.has(id))) out.add(f.id);
seen.add(f.id);
}
return out;
});
const group = (f: Finding) => [f, ...all.value.filter((x) => (f.duplicate_ids ?? []).includes(x.id))];
const live = computed(() => all.value.filter((f) => f.presence !== "resolved" && !hidden.value.has(f.id)));
/** 这次打开页面后处理的问题留在原位(署名行),J / K 能回到它按 U 撤销;之前处理的折叠在「已处理」(规范 §8.2) */
const session = ref(new Set<string>());
const inSev = (f: Finding) => !sev.value || f.severity === sev.value;
const todo = computed(() => live.value.filter((f) => (f.status === "pending" || session.value.has(f.id)) && inSev(f)));
const handled = computed(() => live.value.filter((f) => f.status !== "pending" && !session.value.has(f.id) && inSev(f)));
const doneCount = computed(() => live.value.filter((f) => f.status !== "pending").length);
const resolved = computed(() => all.value.filter((f) => f.presence === "resolved"));
const count = (s: Sev) => live.value.filter((f) => f.status === "pending" && (!s || f.severity === s)).length;
const showHandled = ref(false);
const showResolved = ref(false);
/** 行内说明框(U4):忽略、致命问题的确认无误必须写说明 */
const noteFor = ref<{ id: string; status: "confirmed_ok" | "ignored" } | null>(null);
const note = ref("");
const noteErr = ref("");
const busyId = ref<string | null>(null);
const needsNote = (f: Finding, status: string) => status === "ignored" || (status === "confirmed_ok" && f.severity === "fatal");
function act(f: Finding, status: "confirmed_ok" | "modified" | "ignored") {
if (needsNote(f, status)) {
noteFor.value = { id: f.id, status: status as "confirmed_ok" | "ignored" };
note.value = "";
noteErr.value = "";
return;
}
void resolve(f, status);
}
async function resolve(f: Finding, status: "confirmed_ok" | "modified" | "ignored", text?: string) {
if (text !== undefined && text.trim().length < 5) {
noteErr.value = status === "ignored" ? "至少写 5 个字,说明为什么不处理" : "请写一句说明,至少 5 个字";
return;
}
busyId.value = f.id;
try {
for (const x of group(f).filter((g) => g.status === "pending")) {
await unwrap(
api.POST("/findings/{finding_id}/resolve", { params: { path: { finding_id: x.id } }, body: { status, ...(text ? { note: text.trim() } : {}) } }),
);
}
noteFor.value = null;
remember(group(f));
toast(`已标记为「${FINDING_ACTION[status]}」`, { undo: () => void reopen(f) });
refresh();
next(f);
} catch (e) {
if (isApiError(e, "note_required")) noteErr.value = "请填写说明";
else toast(errorTitle(e), { ok: false });
} finally {
busyId.value = null;
}
}
function remember(fs: Finding[]) {
session.value = new Set([...session.value, ...fs.map((x) => x.id)]);
for (const x of fs) picked.value.delete(x.id);
}
/* ---------- 批量处理:只开放给「提示」(FR-M3-07) ---------- */
const picked = ref(new Set<string>());
const pickable = (f: Finding) => !readOnly.value && f.severity === "hint" && f.status === "pending";
const hints = computed(() => todo.value.filter(pickable));
const pickedList = computed(() => live.value.filter((f) => picked.value.has(f.id) && pickable(f)));
const allPicked = computed(() => hints.value.length > 0 && hints.value.every((f) => picked.value.has(f.id)));
function pick(f: Finding, on: boolean) {
const n = new Set(picked.value);
if (on) n.add(f.id);
else n.delete(f.id);
picked.value = n;
}
function pickAll(on: boolean) {
picked.value = on ? new Set(hints.value.map((f) => f.id)) : new Set();
}
const bulkBusy = ref(false);
async function bulkOk() {
const fs = pickedList.value.flatMap(group).filter((x) => x.status === "pending");
if (!fs.length) return;
bulkBusy.value = true;
try {
await unwrap(
api.POST("/check-runs/{run_id}/findings/bulk-resolve", {
params: { path: { run_id: props.runId } },
body: { finding_ids: fs.map((x) => x.id), status: "confirmed_ok" },
}),
);
remember(fs);
const n = pickedList.value.length || fs.length;
toast(`已将 ${n} 项「提示」标记为确认无误,逐条记了处理人和时间`, { undo: () => void Promise.all(fs.map((x) => reopenOne(x.id))).then(refresh) });
refresh();
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
bulkBusy.value = false;
}
}
const reopenOne = (id: string) => unwrap(api.POST("/findings/{finding_id}/reopen", { params: { path: { finding_id: id } } }));
async function reopen(f: Finding) {
try {
for (const x of group(f)) await reopenOne(x.id);
refresh();
} catch (e) {
toast(errorTitle(e), { ok: false });
}
}
function refresh() {
void qc.invalidateQueries({ queryKey: qk.findings(props.runId) });
void qc.invalidateQueries({ queryKey: qk.project(props.projectId) });
void qc.invalidateQueries({ queryKey: qk.projects });
}
/* ---------- 资质问题:只按资质库记录判断的,出处是资质库那条记录(QL2,check/qualification.py 存的快照) ---------- */
interface LibrarySnapshot {
name: string;
cert_no: string | null;
level: string | null;
valid_to: string | null;
}
function librarySnapshots(f: Finding): LibrarySnapshot[] {
if (f.type !== "qualification") return [];
const issues = (f.detail as { issues?: { source?: string; library?: LibrarySnapshot }[] }).issues ?? [];
return issues.filter((x) => x.source === "library" && x.library).map((x) => x.library!);
}
const libLine = (r: LibrarySnapshot) =>
[r.cert_no && `编号 ${r.cert_no}`, r.level && `等级 ${r.level}`, r.valid_to ? `有效期至 ${fmtDay(r.valid_to)}` : "长期有效"].filter(Boolean).join(" · ");
/* ---------- 需人工核对 ---------- */
const manualQ = useManualChecks(() => props.projectId);
const manual = computed(() => manualQ.data.value?.items ?? []);
const manualDone = computed(() => manual.value.filter((m) => m.checked).length);
const manualBusy = ref<string | null>(null);
const projectReadOnly = computed(() => p.value?.status === "submitted" || p.value?.status === "archived");
async function toggleManual(m: ManualCheck, checked: boolean) {
manualBusy.value = m.check_key;
try {
await unwrap(
api.PUT("/projects/{project_id}/manual-checks", { params: { path: { project_id: props.projectId } }, body: { check_key: m.check_key, checked } }),
);
void qc.invalidateQueries({ queryKey: qk.manualChecks(props.projectId) });
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
manualBusy.value = null;
}
}
/* ---------- 右侧原文区:招标文件、投标文件左右切换(DocStage),两边各自记着文档和高亮 ---------- */
type Side = "tender" | "bid";
type Shown = { doc: { id: string; label: string } | null; target: (ViewerTarget & { id: string }) | null };
const docsQ = useProjectDocuments(() => props.projectId);
const side = ref<Side>("tender");
const shown = ref<Record<Side, Shown>>({ tender: { doc: null, target: null }, bid: { doc: null, target: null } });
// 还没点过出处时,两边先摆上这次检查用的招标文件和第一个投标分册
watchEffect(() => {
const t = docsQ.data.value?.items.find((d) => d.role === "tender" && d.status === "ready");
if (!shown.value.tender.doc && t) shown.value.tender.doc = { id: t.id, label: "招标文件" };
const b = run.value?.documents?.[0];
if (!shown.value.bid.doc && b) shown.value.bid.doc = { id: b.id, label: b.label };
});
const slots = computed<StageSlot[]>(() => [
{ key: "tender", label: "招标文件", ...shown.value.tender, emptyText: "招标文件解析完成后,在这里看原文。" },
{ key: "bid", label: "投标文件", ...shown.value.bid, emptyText: "点问题右侧的投标文件出处,在这里看原文。" },
]);
const activeCite = computed(() => shown.value[side.value].target?.id);
function openCitation(c: Citation, s: Side) {
shown.value[s] = {
doc: { id: c.document_id, label: c.document_label ?? "" },
target: { id: c.id, page: c.page_no, quads: c.quads, sha: c.normalized_sha256 },
};
side.value = s;
}
/** 当前条的出处:优先留在正在看的那一边 */
function follow(f: Finding) {
const t = f.tender_citations[0];
const b = f.bid_citations[0];
if (b && (side.value === "bid" || !t)) openCitation(b, "bid");
else if (t) openCitation(t, "tender");
}
/* ---------- 当前条与键盘(规范 §8.2):J / K 移动(原文跟着翻),O / Enter 看原文,1 / 2 / 3 处理,X 勾选「提示」,U 撤销 ---------- */
const curId = ref<string | null>(null);
const cur = computed(() => todo.value.find((f) => f.id === curId.value) ?? todo.value[0]);
const kbOn = ref(false);
function setCur(f: Finding, scroll = false) {
const changed = curId.value !== f.id;
curId.value = f.id;
if (changed) follow(f);
if (scroll) requestAnimationFrame(() => document.querySelector(`[data-fid="${f.id}"]`)?.scrollIntoView({ block: "nearest" }));
}
// 从工作台「去处理」进来(?f=问题编号):选中那一条;否则第一次加载完,原文翻到第一条问题的出处
const route = useRoute();
watch(
() => cur.value?.id,
(id, old) => {
if (!id || old || curId.value) return;
const wanted = typeof route.query.f === "string" ? todo.value.find((x) => x.id === route.query.f) : undefined;
if (wanted) setCur(wanted, true);
else if (cur.value) follow(cur.value);
},
{ immediate: true },
);
/** 处理完跳到下一条待处理 */
function next(f: Finding) {
const list = todo.value;
const i = list.findIndex((x) => x.id === f.id);
const n = list.find((x, j) => j > i && x.status === "pending" && x.id !== f.id);
if (n) setCur(n, true);
}
function onKey(e: KeyboardEvent) {
if (e.ctrlKey || e.metaKey || e.altKey || document.querySelector(".scrim")) return;
if ((e.target as HTMLElement | null)?.closest("input, textarea, select, [role=listbox], [contenteditable]")) return;
if (e.key === "?") {
e.preventDefault();
document.querySelector<HTMLElement>(".kb-tip")?.focus(); // 快捷键说明是提示气泡,聚焦就显示
return;
}
const f = cur.value;
if (!f) return;
const k = e.key.toLowerCase();
if (k === "j" || k === "k") {
e.preventDefault();
const list = todo.value;
const i = Math.max(0, list.indexOf(f));
const n = list[Math.max(0, Math.min(list.length - 1, i + (k === "j" ? 1 : -1)))];
if (!n) return;
kbOn.value = true;
setCur(n, true);
} else if (k === "o" || (e.key === "Enter" && !(e.target as HTMLElement).closest("button, a, label"))) {
e.preventDefault();
follow(f);
} else if (readOnly.value) {
return;
} else if (k === "u") {
if (f.status !== "pending") void reopen(f);
else toast("当前这一条还没有处理", { ok: false });
} else if (k === "x" && pickable(f)) {
pick(f, !picked.value.has(f.id));
} else if (["1", "2", "3"].includes(e.key) && f.status === "pending" && noteFor.value?.id !== f.id) {
e.preventDefault();
act(f, (["confirmed_ok", "modified", "ignored"] as const)[Number(e.key) - 1]!);
}
}
onMounted(() => window.addEventListener("keydown", onKey));
onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
/* ---------- 判断有误?(J5):反馈只用于改进检查规则,不改变处理状态 ---------- */
const fbFor = ref<FeedbackTarget | null>(null);
function askFeedback(f: Finding) {
fbFor.value = {
objectType: "finding",
objectId: f.id,
title: f.title,
kind: "false_positive",
reasons: ["这不是问题(误报)", "级别不对", "出处定位错了", "其他"],
note: "反馈只用于改进检查规则,不改变这条问题的处理状态。",
};
}
/* ---------- 检查覆盖范围:默认收起成一行,展开看全部(报告首页照样全文列出) ---------- */
const showCoverage = ref(false);
const metaLine = computed(() => {
const r = run.value;
if (!r) return "";
const files = (r.documents ?? []).map((d) => d.label).join("、");
return [`第 ${r.run_no} 次检查`, fmtDateTime(r.created_at), files, `清单 v${r.checklist_version_no}(${r.checklist_confirmed ? "已确认" : "未确认"})`]
.filter(Boolean)
.join(" · ");
});
const errorText = computed(() => {
const e = run.value?.error;
if (!e) return "";
return `${e.message}。${e.refunded ? "已扣的额度已退回。" : "这次检查没有扣点。"}`;
});
</script>
<template>
<ProjectHead :project="p" tab="check" />
<div class="content">
<!-- 进行中 -->
<section v-if="run && !TERMINAL_JOB.has(run.status)" class="card flush running" aria-labelledby="h-run" aria-live="polite">
<div class="card-h"><h2 id="h-run">正在检查</h2></div>
<div class="card-b">
<ol class="steps">
<li v-for="(s, i) in CHECK_STEPS" :key="s.label" :class="{ done: i < step, now: i === step }">
<span class="dot" aria-hidden="true">{{ i < step ? "✓" : "" }}</span>
<span>{{ s.label }}<span v-if="i === step" class="vh">(进行中)</span></span>
</li>
</ol>
<div class="progress">
<div class="bar" role="progressbar" :aria-valuenow="run.progress" aria-valuemin="0" aria-valuemax="100" aria-label="检查进度"><i :style="{ width: `${run.progress}%` }"></i></div>
<span class="fig">{{ run.progress }}%</span>
</div>
<p class="note">可以离开这个页面,检查完成后会通知你。</p>
</div>
<div class="card-f">
<button type="button" class="btn sm" :disabled="cancelling || run.cancel_requested" @click="cancel">{{ run.cancel_requested ? "正在取消…" : "取消检查" }}</button>
</div>
</section>
<!-- 失败 / 取消 -->
<div v-else-if="run && run.status !== 'succeeded'" class="alert warn" role="alert">
<span class="ico w" aria-hidden="true">!</span>
<p>{{ run.status === "cancelled" ? "检查已取消,额度已退回。" : `检查没有完成:${errorText}` }}</p>
<RouterLink class="btn sm" :to="{ name: 'bid-check', params: { projectId } }">重新检查</RouterLink>
</div>
<div v-else-if="run" class="split">
<div class="split-main">
<div class="rbar">
<div>
<h2>检查结果</h2>
<p class="meta" :title="metaLine">{{ metaLine }}</p>
</div>
<div class="actions">
<div class="row">
<RouterLink class="btn" :to="{ name: 'bid-check', params: { projectId } }">重新检查</RouterLink>
<RouterLink class="btn primary" :to="{ name: 'report', params: { projectId } }"><FileText class="ic" :stroke-width="1.5" />生成报告</RouterLink>
</div>
<span v-if="p?.readiness.open_fatal_count" class="export-note">还有 <span class="fig">{{ p.readiness.open_fatal_count }}</span> 项致命问题待处理,报告会如实列出</span>
</div>
</div>
<div v-if="!run.is_latest" class="alert warn" role="note"><span class="ico w" aria-hidden="true">!</span><p>这是历史检查,只能查看。</p></div>
<!-- PI5:检查之后改了截止时间、限价、所投的包或法律体系;不自动重新检查(可能扣点,由用户决定) -->
<div v-else-if="run.basis_changed" class="alert warn" role="note">
<span class="ico w" aria-hidden="true">!</span>
<p>项目信息(截止时间、限价、所投的包或法律体系)在这次检查之后改过,这次的结果按检查时的值,重新检查后生效。</p>
<RouterLink class="btn sm" :to="{ name: 'bid-check', params: { projectId } }">重新检查</RouterLink>
</div>
<section v-if="run.coverage" class="card flush coverage" aria-labelledby="h-cov">
<button type="button" class="cov-h" :aria-expanded="showCoverage" aria-controls="cov-body" @click="showCoverage = !showCoverage">
<component :is="showCoverage ? ChevronDown : ChevronRight" class="ic" :stroke-width="1.5" />
<h2 id="h-cov">检查覆盖范围</h2>
<span class="cap muted">查了 <span class="fig">{{ run.coverage.checked?.length ?? 0 }}</span> 项 · 没查 <span class="fig">{{ run.coverage.not_checked?.length ?? 0 }}</span> 项,报告首页会全部写明</span>
</button>
<div v-show="showCoverage" id="cov-body" class="cov-body">
<div>
<h3>查了</h3>
<ul><li v-for="t in run.coverage.checked ?? []" :key="t">{{ t }}</li></ul>
</div>
<div class="no">
<h3>没查</h3>
<ul><li v-for="t in run.coverage.not_checked ?? []" :key="t">{{ t }}</li></ul>
</div>
</div>
</section>
<div v-if="pickedList.length" class="batchbar" role="region" aria-label="批量处理">
<b>已选 <span class="fig">{{ pickedList.length }}</span> 项「提示」</b>
<span class="r">
<button type="button" class="btn sm" :disabled="bulkBusy" @click="bulkOk">批量确认无误</button>
<button type="button" class="btn sm text" @click="pickAll(false)">取消选择</button>
</span>
</div>
<section class="card flush results" aria-label="检查结果">
<div class="card-h">
<div class="seg" role="group" aria-label="按级别筛选">
<button type="button" :aria-pressed="sev === ''" @click="sev = ''">全部 <span class="fig">{{ count("") }}</span></button>
<button v-for="s in (['fatal', 'important', 'hint'] as const)" :key="s" type="button" :aria-pressed="sev === s" @click="sev = s">
<span class="sev" :class="SEVERITY_CLASS[s]">{{ SEVERITY[s] }}<span class="fig">{{ count(s) }}</span></span>
</button>
</div>
<div class="r">
<label v-if="!readOnly" class="choice cap" :class="{ dis: !hints.length }">
<input type="checkbox" class="ck" :checked="allPicked" :indeterminate="!allPicked && pickedList.length > 0" :disabled="!hints.length" @change="pickAll(($event.target as HTMLInputElement).checked)" />全选「提示」
</label>
<span class="cap muted">已处理 <span class="fig">{{ doneCount }}</span> / <span class="fig">{{ live.length }}</span></span>
<span class="cap muted hint-u tip-below tip-left kb-tip" tabindex="0" data-tip="J / K 上下条(右边原文跟着翻) · O 看原文 · 1 确认无误 · 2 已修改 · 3 忽略 · X 勾选「提示」 · U 撤销 · ? 显示这条说明">快捷键</span>
</div>
</div>
<div v-if="findingsQ.isPending.value" class="lst-pad"><span class="skel t"></span><span class="skel"></span></div>
<template v-else>
<div class="flist" :class="{ 'kb-on': kbOn }" @pointerdown="kbOn = false">
<article
v-for="f in [...todo, ...(showHandled ? handled : [])]"
:key="f.id"
class="finding"
:class="{ sel: cur?.id === f.id, 'has-ck': !readOnly }"
:data-fid="f.id"
:aria-current="cur?.id === f.id ? 'true' : undefined"
@click="todo.includes(f) && setCur(f)"
>
<label v-if="f.status === 'pending' && !readOnly && pickable(f)" class="ckw" data-tip="批量处理" @click.stop>
<input type="checkbox" class="ck" :checked="picked.has(f.id)" :aria-label="`勾选:${f.title}`" @change="pick(f, ($event.target as HTMLInputElement).checked)" />
</label>
<span v-else-if="f.status === 'pending' && !readOnly" class="ckw" data-tip="只有「提示」可以批量处理"><input type="checkbox" class="ck" disabled aria-label="不可批量" /></span>
<div>
<div class="f-head">
<span class="sev" :class="SEVERITY_CLASS[f.severity]">{{ SEVERITY[f.severity] }}</span>
<span class="f-title">{{ f.title }}</span>
<span v-if="(f.duplicate_ids?.length ?? 0) > 0" class="flag" title="同一要求在清单里出现了几处,各自检查、结果相同,处理时一起处理">清单里出现 <span class="fig">{{ (f.duplicate_ids?.length ?? 0) + 1 }}</span> 处</span>
<span v-if="f.presence === 'carried'" class="flag">沿用<template v-if="f.carried_status === 'modified'">:上次已修改,这次仍查出</template></span>
</div>
<p class="f-text">{{ f.explanation }}</p>
<template v-if="f.status !== 'pending'">
<div class="sign" :class="{ ign: f.status === 'ignored' }">
<span aria-hidden="true">{{ f.status === "ignored" ? "–" : "✓" }}</span><b>{{ f.resolved_by?.name }}</b>
<span v-if="f.resolved_at" class="fig">{{ fmtDateTime(f.resolved_at) }}</span><span>{{ FINDING_ACTION[f.status] }}</span>
<button v-if="!readOnly" type="button" class="undo" @click="reopen(f)">撤销</button>
</div>
<p v-if="f.resolution_note" class="sign-note">{{ f.status === "ignored" ? "原因" : "说明" }}:{{ f.resolution_note }}</p>
</template>
<div v-else-if="noteFor?.id === f.id" class="u4">
<div class="field" :class="{ err: noteErr }">
<label :for="`u4-${f.id}`">{{ noteFor.status === "confirmed_ok" ? "确认无误的说明" : "忽略原因" }} <small>(必填,至少 5 个字)</small></label>
<textarea :id="`u4-${f.id}`" v-model="note" class="input" maxlength="200" :aria-invalid="!!noteErr" @keydown.ctrl.enter="resolve(f, noteFor.status, note)" @keydown.esc="noteFor = null"></textarea>
<span class="msg"><template v-if="noteErr"><span class="ico w" aria-hidden="true">!</span>{{ noteErr }}</template><template v-else>{{ noteFor.status === "confirmed_ok" ? "说明会同时作为误报反馈提交" : "「已忽略」在报告里单独列出" }}</template></span>
</div>
<div class="row">
<button type="button" class="btn sm" :disabled="busyId === f.id" @click="resolve(f, noteFor.status, note)">{{ noteFor.status === "confirmed_ok" ? "确认无误" : "忽略这一项" }}</button>
<button type="button" class="btn sm text" @click="noteFor = null">取消</button>
</div>
</div>
<div v-else-if="!readOnly" class="f-actions">
<button type="button" class="btn sm" :disabled="busyId === f.id" @click="act(f, 'confirmed_ok')">确认无误</button>
<button type="button" class="btn sm" :disabled="busyId === f.id" @click="act(f, 'modified')">已修改</button>
<button type="button" class="btn sm" :disabled="busyId === f.id" @click="act(f, 'ignored')">忽略并注明原因</button>
<button type="button" class="lnk cap feedback" @click="askFeedback(f)">判断有误?</button>
</div>
</div>
<aside>
<CitationBlock v-for="c in f.tender_citations" :key="c.id" :citation="c" :active="activeCite === c.id" @open="openCitation(c, 'tender')" />
<CitationBlock v-for="c in f.bid_citations" :key="c.id" :citation="c" :active="activeCite === c.id" @open="openCitation(c, 'bid')" />
<div v-for="(r, i) in librarySnapshots(f)" :key="`lib-${i}`" class="cite lib">
<RouterLink class="cite-src" :to="{ name: 'qualifications' }">依据:资质库记录</RouterLink>
<p><b>{{ r.name }}</b><br />{{ libLine(r) }}</p>
</div>
<div v-if="f.type === 'unresponded' && f.search_scope" class="cite missing">
<span class="cite-src">投标文件 · 未找到</span>
<p>找过:{{ (f.search_scope.documents ?? []).join("、") || "全部分册" }}</p>
</div>
</aside>
</article>
</div>
<div v-if="!todo.length" class="empty sm">
<b>{{ live.length ? (sev ? "这个级别的问题都已处理" : "全部问题都已处理") : "没有发现问题" }}</b>
<p>{{ live.length ? "已处理的在下方,可以撤销。" : "只表示本次检查的范围内没有发现问题,不代表投标文件没有问题。没查的内容见上方「没查」。" }}</p>
</div>
<button v-if="handled.length" type="button" class="group-h" :aria-expanded="showHandled" @click="showHandled = !showHandled">
<component :is="showHandled ? ChevronDown : ChevronRight" class="ic" :stroke-width="1.5" />已处理(<span class="fig">{{ handled.length }}</span>)
</button>
<button v-if="resolved.length" type="button" class="group-h" :aria-expanded="showResolved" @click="showResolved = !showResolved">
<component :is="showResolved ? ChevronDown : ChevronRight" class="ic" :stroke-width="1.5" />本次已解决(<span class="fig">{{ resolved.length }}</span>)<span class="sub">上次有、这次没再发现,已自动关闭</span>
</button>
<template v-if="showResolved">
<div v-for="f in resolved" :key="f.id" class="resolved">
<span class="sev" :class="SEVERITY_CLASS[f.severity]">{{ SEVERITY[f.severity] }}</span><s>{{ f.title }}</s>
<span v-if="f.carried_status && f.carried_status !== 'pending'" class="cap">上次:{{ FINDING_ACTION[f.carried_status] }}</span>
</div>
</template>
</template>
</section>
<section v-if="manual.length" class="card flush manual" aria-labelledby="h-manual">
<div class="card-h">
<h2 id="h-manual">需人工核对</h2>
<span class="cap">文件里看不出来,递交前请自己核对。勾选按项目保存,复查后还在。</span>
<span class="cap r">已核对 <span class="fig">{{ manualDone }}</span> / <span class="fig">{{ manual.length }}</span></span>
</div>
<article v-for="m in manual" :key="m.check_key" class="finding">
<div>
<label class="choice">
<input type="checkbox" class="ck" :checked="m.checked" :disabled="projectReadOnly || manualBusy === m.check_key" @change="toggleManual(m, ($event.target as HTMLInputElement).checked)" />
<span class="f-title">{{ m.label }}</span>
</label>
<p v-if="m.requirement_text" class="f-text">招标文件:{{ m.requirement_text }}<template v-if="(m.related_items?.length ?? 0) > 1">(同类要求共 <span class="fig">{{ m.related_items?.length }}</span> 条)</template></p>
<div v-if="m.checked" class="sign">
<span aria-hidden="true">✓</span><b>{{ m.checked_by?.name }}</b>
<span v-if="m.checked_at" class="fig">{{ fmtDateTime(m.checked_at) }}</span><span>已核对</span>
</div>
</div>
<aside>
<CitationBlock v-if="m.citation" :citation="m.citation" :active="activeCite === m.citation.id" @open="openCitation(m.citation, 'tender')" />
</aside>
</article>
</section>
</div>
<aside class="split-doc" aria-label="原文">
<DocStage v-model:active="side" :slots="slots" />
</aside>
</div>
<div v-else-if="runQ.error.value" class="alert warn" role="alert"><span class="ico w" aria-hidden="true">!</span><p>{{ errorTitle(runQ.error.value) }}</p></div>
</div>
<FeedbackDialog :project-id="projectId" :target="fbFor" @close="fbFor = null" />
</template>
<style scoped>
.running {
max-width: 736px;
}
.running .card-b {
padding-top: var(--sp-2);
}
.running .progress {
margin-top: var(--sp-3);
}
.rbar {
display: flex;
align-items: flex-end;
gap: var(--sp-4);
margin-bottom: var(--sp-4);
}
.rbar h2 {
margin: 0;
font: var(--fw-bold) var(--fs-title) / var(--lh-title) var(--font-sans);
}
.rbar .meta {
margin: 2px 0 0;
color: var(--text-secondary);
font-size: var(--fs-caption);
}
.rbar .actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--sp-1);
margin-left: auto;
}
.rbar .actions .row {
display: flex;
gap: var(--sp-2);
}
.export-note {
color: var(--text-secondary);
font-size: var(--fs-caption);
white-space: nowrap;
}
.coverage {
margin-bottom: var(--sp-4);
}
.cov-h {
display: flex;
align-items: center;
gap: var(--sp-2);
width: 100%;
padding: var(--sp-3) var(--card-x);
border: 0;
background: none;
font: inherit;
color: var(--text-primary);
text-align: left;
cursor: pointer;
}
.cov-h h2 {
margin: 0;
font-size: var(--fs-body);
font-weight: var(--fw-bold);
}
.cov-h .ic {
color: var(--text-secondary);
}
.cov-body {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--sp-6);
padding: 0 var(--card-x) var(--sp-4);
}
.coverage h3 {
margin: 0 0 var(--sp-1);
font-size: var(--fs-body);
}
.coverage ul {
margin: 0;
padding-left: 1.2em;
}
.coverage .no {
color: var(--text-secondary);
}
.results .card-h {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-2) var(--sp-3);
}
/* 左右布局里问题行改成单列:出处放在说明下面(原文就在右边),文字不被页边批注栏挤窄 */
.finding {
grid-template-columns: minmax(0, 1fr);
gap: var(--sp-3);
}
.finding :deep(.cite:first-child)::before {
display: none;
}
.finding > aside {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2) var(--sp-6);
}
.finding > aside > * {
flex: 1 1 260px;
min-width: 0;
}
.f-head .flag {
white-space: nowrap;
}
.results .card-h .r {
display: flex;
align-items: center;
gap: var(--sp-4);
margin-left: auto;
white-space: nowrap;
}
.flist:focus {
outline: 0;
}
.f-actions .feedback {
margin-left: auto;
color: var(--text-secondary);
}
.seg {
display: flex;
gap: var(--sp-2);
}
.seg button {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
padding: 2px var(--sp-2);
border-radius: var(--radius-ctl);
color: var(--text-secondary);
}
.seg button[aria-pressed="true"] {
background: var(--c-n100);
color: var(--text-primary);
}
.manual {
margin-top: var(--sp-4);
}
.manual .card-h {
display: flex;
align-items: baseline;
gap: var(--sp-2);
}
.manual .card-h h2 {
margin: 0;
font-size: var(--fs-body);
}
.manual .card-h .r {
margin-left: auto;
}
.sign.ign {
color: var(--text-secondary);
}
.sign .undo {
border: 0;
background: none;
padding: 0;
cursor: pointer;
text-decoration: underline;
}
.sign-note {
margin: var(--sp-1) 0 0;
color: var(--text-secondary);
font-size: var(--fs-caption);
}
.u4 {
margin-top: var(--sp-3);
}
.u4 .field {
max-width: none;
}
.u4 textarea {
min-height: 64px;
}
.u4 .row {
display: flex;
gap: var(--sp-2);
margin-top: var(--sp-2);
}
.cite.missing p {
margin: 0;
color: var(--text-secondary);
}
/* 资质库是用户自己录的数据:墨色(人工),不用 AI 出处的蓝铅笔色 */
.cite.lib {
border-left-color: var(--c-ink);
}
.cite.lib .cite-src {
color: var(--text-primary);
}
.cite.lib p {
margin: 2px 0 0;
font-size: var(--fs-caption);
line-height: var(--lh-caption);
color: var(--text-secondary);
}
.cite.lib p b {
color: var(--text-primary);
}
.group-h {
display: flex;
align-items: center;
gap: var(--sp-1);
width: 100%;
padding: var(--sp-3) var(--card-x);
border: 0;
border-top: var(--line) solid var(--border-subtle);
background: none;
font: inherit;
cursor: pointer;
color: var(--text-secondary);
text-align: left;
}
.group-h .sub {
margin-left: var(--sp-2);
font-size: var(--fs-caption);
}
.resolved {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-2) var(--card-x);
color: var(--text-secondary);
}
</style>

View File

@@ -0,0 +1,274 @@
<script setup lang="ts">
/**
* 工作台(原型 workbench.html,ui-demo page-workbench.html):页头 + 统计 + 项目列表 + 待处理的致命问题;
* 右栏是正在进行的任务和本期额度。统计可以点,按它筛选项目列表(再点一下取消)。
* 「最近处理」要等操作记录的接口,还没做。
*/
import { unwrap } from "@bid-doctor/api-client";
import { Filter, Plus, X } from "@lucide/vue";
import { useQueries } from "@tanstack/vue-query";
import { computed, ref } from "vue";
import { RouterLink } from "vue-router";
import ProjectRow from "@/components/ProjectRow.vue";
import { api, errorTitle } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys";
import { quotaUnit } from "@/lib/labels";
import { useMe, useProjects, useQuota, useQuotaAccount, type ProjectListItem } from "@/lib/queries";
useCrumbs(() => [{ label: "工作台" }]);
const me = useMe();
const projects = useProjects();
const quota = useQuota();
const parse = useQuotaAccount("parse");
/** 套餐到期后只读(决策 M6-5):不能新建项目,顶上有续费提醒 */
const readOnly = computed(() => !!me.data.value?.plan.read_only);
const today = new Date();
const hello = computed(() => {
const name = me.data.value?.user.display_name ?? "";
return `你好,${name}。今天是 ${today.getMonth() + 1}月${today.getDate()}日,周${"日一二三四五六"[today.getDay()]}。`;
});
const all = computed(() => projects.data.value?.items ?? []);
const active = computed(() => all.value.filter((p) => p.status !== "submitted" && p.status !== "archived"));
const due7 = (p: ProjectListItem) => {
if (!p.deadline_at) return false;
const ms = new Date(p.deadline_at).getTime() - Date.now();
return ms >= 0 && ms <= 7 * 86_400_000;
};
type StatKey = "active" | "due7" | "fatal" | "pending";
const STAT: Record<StatKey, { label: string; fn: (p: ProjectListItem) => boolean; count: (p: ProjectListItem) => number; cls: string }> = {
active: { label: "进行中的项目", fn: () => true, count: () => 1, cls: "" },
due7: { label: "7 天内截止", fn: due7, count: (p) => (due7(p) ? 1 : 0), cls: "due" },
// 致命红只用于致命问题(§3.2)
fatal: { label: "待处理的致命问题", fn: (p) => p.readiness.open_fatal_count > 0, count: (p) => p.readiness.open_fatal_count, cls: "fatal" },
pending: { label: "待确认的清单条目", fn: (p) => (p.pending_item_count ?? 0) > 0, count: (p) => p.pending_item_count ?? 0, cls: "" },
};
const stats = computed(() =>
(Object.keys(STAT) as StatKey[]).map((key) => ({ key, ...STAT[key], value: active.value.reduce((a, p) => a + STAT[key].count(p), 0) })),
);
/** 点了哪个统计:按它筛选进行中的项目;没点时列全部项目(含已递交、已归档) */
const filter = ref<StatKey | null>(null);
function toggle(k: StatKey) {
filter.value = filter.value === k ? null : k;
}
const list = computed(() => (filter.value ? active.value.filter(STAT[filter.value].fn) : all.value));
/* ---------- 待处理的致命问题:有致命问题的项目,取最近一次检查里还没处理的致命问题 ---------- */
const withFatal = computed(() => active.value.filter((p) => p.readiness.open_fatal_count > 0 && p.latest_check_run_id));
const fatalQs = useQueries({
queries: computed(() =>
withFatal.value.map((p) => ({
queryKey: qk.findings(p.latest_check_run_id!),
queryFn: () => unwrap(api.GET("/check-runs/{run_id}/findings", { params: { path: { run_id: p.latest_check_run_id! } } })),
})),
),
});
const TODO_MAX = 5;
const todos = computed(() =>
withFatal.value.flatMap((p, i) =>
(fatalQs.value[i]?.data?.items ?? [])
.filter((f) => f.severity === "fatal" && f.status === "pending" && f.presence !== "resolved")
.map((f) => ({ p, f, cite: f.bid_citations[0] ?? f.tender_citations[0] ?? null })),
),
);
const fatalTotal = computed(() => withFatal.value.reduce((a, p) => a + p.readiness.open_fatal_count, 0));
const jobs = computed(() => list.value.filter((p) => p.active_job));
const accounts = computed(() => quota.data.value?.accounts.filter((a) => a.period_granted > 0) ?? []);
const planEnds = computed(() => quota.data.value?.plan.ends_at?.slice(0, 10) ?? "");
</script>
<template>
<div class="content">
<div class="page-title">
<div>
<h1>工作台</h1>
<p>{{ hello }}</p>
</div>
<div class="actions">
<span v-if="parse" class="cost">解析 1 份招标文件扣 <span class="fig">1</span> 次 · 剩 <span class="fig">{{ parse.balance }}</span> 次</span>
<RouterLink v-if="!readOnly" class="btn primary" :to="{ name: 'project-new' }"><Plus class="ic" :stroke-width="1.5" />新建项目</RouterLink>
</div>
</div>
<section class="stats" aria-label="项目统计,点击可筛选项目列表">
<button v-for="s in stats" :key="s.key" type="button" class="stat" :aria-pressed="filter === s.key" @click="toggle(s.key)">
<small>{{ s.label }}</small><span class="big" :class="s.value ? s.cls : ''">{{ s.value }}</span>
<Filter class="ic" :stroke-width="1.5" />
</button>
</section>
<div class="wb">
<div class="stack">
<section class="card flush" aria-labelledby="h-proj">
<div class="card-h">
<h2 id="h-proj">项目</h2>
<span v-if="list.length" class="fig muted">{{ list.length }}</span>
<button v-if="filter" type="button" class="btn text sm chip-clear" @click="filter = null">
{{ STAT[filter].label }}<X class="ic" :stroke-width="1.5" /><span class="vh">,清除筛选</span>
</button>
</div>
<div v-if="projects.isPending.value" class="card-b">
<span class="skel t"></span><span class="skel"></span><span class="skel"></span>
</div>
<div v-else-if="projects.isError.value" class="card-b">
<div class="alert warn" role="alert">
<span class="ico w" aria-hidden="true">!</span>
<p>{{ errorTitle(projects.error.value) }}</p>
<button class="btn sm" type="button" @click="projects.refetch()">重试</button>
</div>
</div>
<template v-else-if="list.length">
<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" aria-label="项目列表">
<ProjectRow v-for="p in list" :key="p.id" :project="p" />
</ul>
</template>
<div v-else-if="filter" class="empty sm">
<b>没有符合条件的项目</b>
<p>清除筛选,看全部项目。</p>
</div>
<div v-else class="empty">
<b>还没有项目</b>
<p>上传招标文件,标博士会逐条提取要求,每一条都带原文出处。</p>
<RouterLink v-if="!readOnly" class="btn primary" :to="{ name: 'project-new' }"><Plus class="ic" :stroke-width="1.5" />新建项目</RouterLink>
</div>
</section>
<section v-if="fatalTotal" class="card flush" aria-labelledby="h-todo">
<div class="card-h">
<h2 id="h-todo">待处理的致命问题</h2>
<span class="sev fatal">致命<span class="fig">{{ fatalTotal }}</span></span>
<button v-if="todos.length > TODO_MAX" type="button" class="lnk cap more" @click="filter = 'fatal'">只看有致命问题的项目</button>
</div>
<ul class="todos">
<li v-for="{ p, f, cite } in todos.slice(0, TODO_MAX)" :key="f.id" class="todo">
<span class="sev fatal">致命</span>
<div>
<b>{{ f.title }}</b>
<span class="psub">{{ p.name }}<template v-if="cite"> · {{ cite.document_label }} 第 <span class="fig">{{ cite.page_no }}</span> 页</template></span>
</div>
<RouterLink class="btn sm" :to="{ name: 'results', params: { projectId: p.id, runId: p.latest_check_run_id! }, query: { f: f.id } }">去处理</RouterLink>
</li>
<li v-if="!todos.length" class="todo"><span class="cap muted">正在读取…</span></li>
</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">
<p v-if="!jobs.length" class="cap muted none">没有正在进行的任务</p>
<RouterLink
v-for="p in jobs"
:key="p.id"
class="task"
:to="p.active_job!.kind === 'parse_job' ? { name: 'parse', params: { projectId: p.id, jobId: p.active_job!.id } } : { name: 'checklist', params: { projectId: p.id } }"
>
<b>{{ p.active_job!.label || "任务进行中" }}</b>
<span class="cap muted">{{ p.name }}</span>
<div class="progress">
<div class="bar" role="progressbar" :aria-valuenow="p.active_job!.progress ?? 0" aria-valuemin="0" aria-valuemax="100" :aria-label="`${p.name}的进度`">
<i :style="{ width: `${p.active_job!.progress ?? 0}%` }"></i>
</div>
<span class="fig">{{ p.active_job!.progress ?? 0 }}%</span>
</div>
</RouterLink>
</div>
</section>
<section class="card flush quota" aria-labelledby="h-quota">
<div class="card-h">
<h3 id="h-quota">本期额度</h3>
<span v-if="planEnds" class="cap muted ends">至 {{ planEnds }}</span>
</div>
<div class="card-b">
<div v-for="a in accounts" :key="a.unit" class="acct">
<small class="muted">{{ quotaUnit(a.unit) }}剩余</small>
<div>
<span class="big" :class="{ warn: (a.used_ratio ?? 0) >= 0.8 }">{{ a.balance }}</span><span class="unit">/ {{ a.period_granted }}</span>
</div>
<div class="bar" :class="{ warn: (a.used_ratio ?? 0) >= 0.8 }" aria-hidden="true">
<i :style="{ width: `${Math.round((a.used_ratio ?? 0) * 100)}%` }"></i>
</div>
<p v-if="a.held" class="cap muted">含正在预扣的 <span class="fig">{{ a.held }}</span> 次,解析失败、超时或取消会退回</p>
</div>
</div>
</section>
</aside>
</div>
</div>
</template>
<style scoped>
.stats {
margin-bottom: var(--gap-card);
}
/* 主列 + 右侧信息栏(--aside-w),全页一个间距 --gap-card */
.wb {
display: grid;
grid-template-columns: minmax(0, 1fr) var(--aside-w);
gap: var(--gap-card);
align-items: start;
}
.none {
margin: 0;
}
.chip-clear,
.more {
margin-left: auto;
}
/* 待处理的致命问题:不加左侧红竖线,靠实心「致命」标签扫读(ui-demo d5) */
.todos {
margin: 0;
padding: 0;
list-style: none;
}
.todo {
display: grid;
grid-template-columns: auto minmax(0, 1fr) 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 .psub {
display: block;
}
.task {
display: block;
color: inherit;
text-decoration: none;
}
.task + .task {
margin-top: var(--sp-3);
padding-top: var(--sp-3);
border-top: var(--line) solid var(--border-subtle);
}
.task b,
.task .cap {
display: block;
}
.task .progress {
margin-top: var(--sp-2);
}
.ends {
margin-left: auto;
}
.acct + .acct {
margin-top: var(--sp-4);
}
.quota .big.warn {
color: var(--warn-text);
}
.acct p {
margin: 0;
}
</style>

View File

@@ -0,0 +1,27 @@
/**
* 平台能力抽象(架构 §5.2 第 4 条):文件选择等都从这里走。现在只有网页版实现;
* 以后迁到 packages/platform,桌面外壳另给一份实现,业务代码不用改。
*/
export interface PickFileOptions {
/** 例如 [".pdf", ".docx"];只是选择框的过滤建议,真实格式以服务端按文件头识别为准 */
accept: readonly string[];
}
export interface Platform {
pickFile(opts: PickFileOptions): Promise<File | null>;
}
const web: Platform = {
pickFile({ accept }) {
return new Promise((resolve) => {
const input = document.createElement("input");
input.type = "file";
input.accept = accept.join(",");
input.addEventListener("change", () => resolve(input.files?.[0] ?? null), { once: true });
input.addEventListener("cancel", () => resolve(null), { once: true });
input.click();
});
},
};
export const platform: Platform = web;

88
apps/web/src/router.ts Normal file
View File

@@ -0,0 +1,88 @@
import { ApiError } from "@bid-doctor/api-client";
import { createRouter, createWebHashHistory, createWebHistory } from "vue-router";
import AppShell from "@/components/AppShell.vue";
import { runtimeConfig } from "@/lib/config";
import { qk } from "@/lib/keys";
import { fetchMe } from "@/lib/queries";
import { queryClient } from "@/lib/queryClient";
declare module "vue-router" {
interface RouteMeta {
/** 不需要登录 */
public?: boolean;
}
}
export const router = createRouter({
// 路由基础路径可配置;桌面外壳用 hash 模式(架构 §5.2 第 6 条)
history: runtimeConfig.routerMode === "hash" ? createWebHashHistory() : createWebHistory(runtimeConfig.routerBase),
routes: [
{ path: "/login", name: "login", component: () => import("@/pages/LoginPage.vue"), meta: { public: true } },
{
path: "/",
component: AppShell,
children: [
{ path: "", name: "workbench", component: () => import("@/pages/WorkbenchPage.vue") },
{ path: "projects/new", name: "project-new", component: () => import("@/pages/ProjectNewPage.vue") },
{ path: "qualifications", name: "qualifications", component: () => import("@/pages/QualificationsPage.vue") },
{ path: "quota", name: "quota", component: () => import("@/pages/QuotaPage.vue") },
{ path: "account", name: "account", component: () => import("@/pages/AccountPage.vue") },
{
path: "projects/:projectId/parse/:jobId",
name: "parse",
component: () => import("@/pages/ParsePage.vue"),
props: true,
},
{
path: "projects/:projectId",
name: "overview",
component: () => import("@/pages/OverviewPage.vue"),
props: true,
},
{
path: "projects/:projectId/lots",
name: "lots",
component: () => import("@/pages/LotsPage.vue"),
props: true,
},
{
path: "projects/:projectId/checklist",
name: "checklist",
component: () => import("@/pages/ChecklistPage.vue"),
props: true,
},
{
path: "projects/:projectId/check",
name: "bid-check",
component: () => import("@/pages/BidCheckPage.vue"),
props: true,
},
{
path: "projects/:projectId/report",
name: "report",
component: () => import("@/pages/ReportPage.vue"),
props: true,
},
{
path: "projects/:projectId/results/:runId",
name: "results",
component: () => import("@/pages/ResultsPage.vue"),
props: true,
},
],
},
{ path: "/:pathMatch(.*)*", redirect: "/" },
],
});
router.beforeEach(async (to) => {
if (to.meta.public) return true;
try {
await queryClient.fetchQuery({ queryKey: qk.me, queryFn: fetchMe, staleTime: 60_000 });
return true;
} catch (e) {
// 被其他设备挤下线(session_revoked)、过期(session_expired)时,登录页说明原因
const reason = e instanceof ApiError && e.code !== "unauthenticated" ? { reason: e.code } : {};
return { name: "login", query: { ...(to.fullPath === "/" ? {} : { redirect: to.fullPath }), ...reason } };
}
});

381
apps/web/src/styles/app.css Normal file
View File

@@ -0,0 +1,381 @@
/* 标博士用户端 · 页面框架与共用小件
* 视觉来源:docs/design/system/tokens.css + components.css(main.ts 通过 @design 别名直接引用,不复制)。
* 本文件只放这两份文件里还没有的「页面框架层」:侧栏、顶栏、页头、项目头,以及原型 docs/prototype/assets/app.css
* 第 1、2 部分里本应用用到的那几块(B1 图标、B3 按钮状态、B4 输入、B7 复选、B8 标签页、B11 对话框、B13 提示气泡、
* B14 空状态与进度、B15 杂项、B16 AI 块、B17 统计、B19 分类导航、B20 项目行),选择器和写法照搬原型。
* 只用 tokens.css 的变量,不写裸色值。
* TODO:这一层应提升到 docs/design/system(或 packages/ui)作为唯一来源,届时删掉本文件里的副本。
* 与原型的差别:原型把框架挂在 body.app 上,这里挂在 AppShell 的根元素 .app-frame 上。 */
/* =====================================================================
* 1. 页面框架:96px 墨色渐变侧栏 + 透明顶栏;内容区四角都是直角(侧栏贴边,不做圆角衔接)
* ===================================================================== */
.app-frame{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 a:focus-visible,.sidebar button: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);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* 套餐标签:只在账户区使用;免费版中性、专业版浅金、企业版金色实底 */
.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;min-height:100vh;background:var(--bg-page)}
.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;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-decoration-color:var(--link-underline);text-underline-offset:3px}
.crumbs b{font-weight:var(--fw-regular)}
.top-right{display:flex;align-items:center;gap:var(--sp-1);flex:none;margin-left:auto}
/* 顶栏额度:用到 ≥ 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}
.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{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)}
a.quota-chip:hover{background:var(--c-n100)}
/* 侧栏底部的账号区可以点(进账号页) */
a.me{text-decoration:none;border-radius:var(--radius-ctl);transition:background-color var(--dur-fast) var(--ease)}
a.me:hover{background:var(--side-hover)}a.me[aria-current="page"]{background:var(--side-active)}
/* 套餐到期的提醒条(决策 M6-5):赭色,不用红(U1) */
.expired-bar{display:flex;gap:var(--sp-3);align-items:center;margin:0 var(--pad-page) var(--gap-card);padding:10px var(--card-x);background:var(--bg-surface);border:var(--line) solid var(--warn);border-radius:var(--radius-card)}
.expired-bar p{flex:1;margin:0;color:var(--warn-text)}
.top .vsep{flex:none;width:1px;height:20px;margin:0 var(--sp-1);background:var(--border-subtle)}
.content{width:100%;padding:20px var(--pad-page) var(--sp-12)}
.content.narrow{max-width:800px}
/* 页头:标题 + 一句话;右侧放页面级主按钮(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:2px 0 0;color:var(--text-secondary)}
.page-title .actions{margin-left:auto;display:flex;gap:var(--sp-3);align-items:center}
.stack > * + *{margin-top:var(--gap-card)}
/* 项目头(项目内页面):名称 + 信息一行;右侧截止、状态;下方标签页 */
.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;height:var(--control-h-sm);margin:0;white-space:nowrap} /* 各列取值同高(状态列有按钮),值都在同一条中线上 */
.ph .tabs{margin-top:var(--sp-3)}
.ph + .content{padding-top:var(--sp-5)}
/* 左右布局(查标、概览,同读标页):左边内容随页面滚动,右边原文区贴住视口、自己翻页 */
.split{display:grid;grid-template-columns:minmax(560px,1fr) minmax(400px,45%);gap:var(--gap-card);align-items:start}
.split-main{min-width:0}
.split-doc{position:sticky;top:calc(var(--topbar-h) + var(--sp-2));height:calc(100vh - var(--topbar-h) - var(--sp-2) - var(--sp-12))} /* 减去 .content 的底边距:滚到底时也贴得住,不钻到顶栏下面 */
/* =====================================================================
* 2. 共用小件
* ===================================================================== */
/* B1 图标(Lucide 线性,1.5px 描边,16 / 20px;侧栏菜单 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 按钮补充状态:加载中两个文字叠在同一格,切换时宽度不变 */
.btn .ic{margin:0 -2px}
.btn.swap{display:inline-grid;place-items:center}
.btn.swap > span{grid-area:1/1;display:inline-flex;align-items:center;gap:6px;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)}
.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.sm{width:var(--control-h-sm);height:var(--control-h-sm)}
/* B4 输入框 */
.input{width:100%;transition:border-color var(--dur-fast) var(--ease)}
.input.sm{height:var(--control-h-sm)}
.input:hover{border-color:var(--c-ink)}
.input: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]{background:var(--c-n100);border-color:var(--c-n100);color:var(--text-disabled);cursor:not-allowed}
.field label{display:flex;gap:var(--sp-1);align-items:baseline}
.field label small{margin-left:2px}
.field .choice{display:inline-flex;gap:var(--sp-2);align-items:flex-start;font-weight:var(--fw-regular)}
.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}
.field + .field{margin-top:0}
.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}
/* B7 复选框 */
.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{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)}
.choice{display:inline-flex;gap:var(--sp-2);align-items:flex-start;cursor:pointer;line-height:var(--lh-body)}
.choice .ck{margin-top:3px}
/* B8 标签页 */
.tabs{display:flex;gap:var(--sp-6);border-bottom:var(--line) solid var(--border-subtle)}
.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)}
/* B11 对话框(480 / 640,圆角 12;对话框里可以有自己的主按钮) */
.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 h2{font:var(--fw-bold) var(--fs-title)/var(--lh-title) var(--font-sans);margin:0 var(--sp-8) var(--sp-3) 0}
.dialog .body > p{margin:0 0 var(--sp-3)}
.dialog .foot{display:flex;justify-content:flex-end;align-items:center;gap:var(--sp-2);margin-top:var(--sp-6)}
/* B12 提醒补充 */
.alert > div{flex:1;min-width:0}
.alert > p{flex:1}
.alert .sub{display:block;margin-top:2px;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary)}
.errno{font:var(--fw-medium) var(--fs-figure-xs)/1 var(--font-figures);color:var(--text-secondary);letter-spacing:.02em}
/* B13 提示气泡(悬停 / 聚焦显示) */
[data-tip]{position:relative}
[data-tip]:hover::after,[data-tip]:focus-visible::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{bottom:auto;top:calc(100% + 8px)}
[data-tip].tip-left:hover::after,[data-tip].tip-left:focus-visible::after{left:auto;right:0;transform:none}
/* B14 空状态、骨架屏、进度(骨架屏静止,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 + .skel{margin-top:var(--sp-2)}
.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 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)}
.cap{font-size:var(--fs-caption);line-height:var(--lh-caption)}
.fig.m{font-weight:var(--fw-medium)}
.fig.xs{font-size:var(--fs-figure-xs)}
:is(.hint-warn,.stat p,.quota p) .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}
.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)}
.drop[aria-disabled="true"]{pointer-events:none;color:var(--text-disabled);border-color:var(--border-subtle);background:var(--c-n50)}
.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 .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}
/* B16 AI 产出块:确认前「AI」小标签 + 左侧 2px 蓝铅笔虚线;正文始终墨色(Q2) */
.ai-block{border-left:2px dashed var(--ai);padding:var(--sp-1) 0 var(--sp-1) var(--sp-3);color:var(--text-primary)}
/* B17 卡片头、统计 */
.card-h{display:flex;align-items:center;gap:var(--sp-3);margin-bottom:var(--sp-3)}
.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}
.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}
.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)}
/* 可点的统计(ui-demo 工作台):点一下按它筛选项目列表,再点一下取消 */
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 > .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)}
/* B19 列表卡片:左侧分类导航 + 列表;列表里的分组标题(.ai 是清单顶部的「待确认」分组) */
.cmp-list{display:grid;grid-template-columns:176px minmax(0,1fr);min-height:0;overflow:hidden}
@media (max-width:1399px){.cmp-list{grid-template-columns:152px minmax(0,1fr)}}
.cmp-list > .lst{position:relative;min-width:0}
.cats{display:flex;flex-direction:column;gap:2px;border-right:var(--line) solid var(--border-subtle);padding:var(--sp-3) var(--sp-2)}
.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)}
.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:100%;margin:var(--sp-2) 0;border:0;border-top:var(--line) solid var(--border-subtle)}
.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)}
/* B20 项目行:栅格对齐的列表,整行可点 */
: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}
.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;text-decoration:none}
.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)}
/* =====================================================================
* 3. 读标用到的补充(照搬原型 docs/prototype/assets/app.css)
* ===================================================================== */
/* B4 补充:多行输入、两栏表单、链接 */
textarea.input{height:auto;min-height:80px;padding:var(--sp-2) var(--sp-3);resize:vertical;line-height:var(--lh-body)}
.field.fill{max-width:none}
.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}
.lnk{padding:0;border:0;background:none;font:inherit;color:var(--text-primary);text-decoration:underline;text-decoration-color:var(--link-underline);text-underline-offset:3px;cursor:pointer}
.lnk:hover{text-decoration-color:var(--c-ink)}
/* 行内出处(「第 N 页」):蓝铅笔色的按钮,点开原文 */
.cite-src.inline{display:inline;padding:0;border:0;background:none;font:inherit;font-size:var(--fs-caption);color:var(--ai);white-space:nowrap;cursor:pointer}
.cite-src.inline:hover{text-decoration:underline;text-underline-offset:3px}
/* 分段选择(重要性等单选) */
.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)}
/* B11 补充:宽对话框、关闭按钮、对话框里的表格 */
.dialog.wide{width:640px}
.dialog .x{position:absolute;top:var(--sp-4);right:var(--sp-4)}
.dialog .body > .field{margin-top:var(--sp-4)}
.dialog .foot .left{margin-right:auto}
.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 td{height:auto}
/* 操作结果提示(§7.15):底部居中,墨色底 */
.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 button.lnk{color:var(--text-on-dark);text-decoration-color:currentColor}
.toast .sep{width:1px;align-self:stretch;margin:2px 0;background:var(--on-dark-line)}
/* =====================================================================
* 4. 资质库用到的补充(照搬原型 docs/prototype/assets/app.css 的菜单、开关)
* ===================================================================== */
/* 下拉菜单:列表行的「更多」。定位交给 Reka UI 的 DropdownMenu,这里只管外观 */
.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.popper{position:relative;top:auto;left:auto;min-width:160px}
.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,.menu [data-highlighted]{background:var(--c-n100);outline:0}
.menu hr{border:0;border-top:var(--line) solid var(--border-subtle);margin:6px 0}
/* 列表的当前条与批量勾选(照搬原型 §7.18):当前条 = 浅底 + 左侧 3px 墨色竖条;键盘操作时再加 2px 内框;勾选条只有复选框是选中状态 */
.finding.sel{background:var(--c-n50);box-shadow:inset 3px 0 0 var(--c-ink)}
.finding{transition:background-color var(--dur-fast) var(--ease)}
.kb-on > .finding.sel{box-shadow:inset 3px 0 0 var(--c-ink),inset 0 0 0 2px var(--focus-ring)}
.finding .ckw{position:absolute;left:14px;top:calc(var(--sp-5) + 2px)}
.finding.has-ck{padding-left:calc(var(--card-x) + 24px)}
.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}
.choice.dis{color:var(--text-disabled);cursor:not-allowed}
.ck:disabled{background:var(--c-n100);border-color:var(--border-subtle);cursor:not-allowed}
.ck:indeterminate{background:var(--c-ink);border-color:var(--c-ink)}
.ck:indeterminate::after{display:block;width:8px;height:0;border-top:1.75px solid var(--c-white)}
/* 单选(照搬原型 B7) */
.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[type=radio]:disabled:checked{background:var(--c-n100)}
.ck[type=radio]:disabled:checked::after{background:var(--c-muted)}
.ck:disabled:hover{border-color:var(--border-subtle)}
/* 项目头标签页上的致命数(原型 .ph .tabs .sev) */
.tabs [role=tab] .sev{margin-left:6px}
/* 标题折行时级别标签对齐第一行 */
.f-head{align-items:flex-start}
.f-head .sev{margin-top:2px}
/* 下拉选择(规范 §7.21,components/SelectBox.vue):触发框和输入框同规格;浮层复用 .menu */
.select{display:flex;align-items:center;gap:var(--sp-2);width:100%;min-width:0;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.sm{height:var(--control-h-sm)}
.select:hover{border-color:var(--c-ink)}
.select:focus-visible,.select[data-state=open]{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 > .ic{flex:none;color:var(--text-secondary);transition:transform var(--dur-fast) var(--ease)}
.select[data-state=open] > .ic{transform:rotate(180deg)}
.select[data-disabled]{background:var(--c-n100);border-color:var(--c-n100);color:var(--text-disabled);cursor:not-allowed}
.field.err .select{border-color:var(--warn);box-shadow:inset 0 0 0 1px var(--warn)}
.menu.sel-menu{z-index:56;min-width:var(--reka-select-trigger-width);max-height:min(300px,var(--reka-select-content-available-height))}
.sel-menu [role=option][data-state=checked]{font-weight:var(--fw-bold)}
.sel-menu .chk{margin-left:auto;padding-left:var(--sp-4);color:var(--c-ink);font-weight:var(--fw-bold)}
.sel-menu [data-disabled]{color:var(--text-disabled);cursor:not-allowed;background:none}
/* 开关(长期有效等) */
.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}
/* B6 日期输入与日期选择(§7.22、§9.3,ui-demo demo.css 的 .dt / .cal):DateInput.vue。
* 选择器是 Reka UI 的 Calendar(表格结构),格子状态看 data-today / data-selected / data-disabled / data-outside-view */
.dt{position:relative;display:block}
.dt > .input{width:100%;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)}
.cal{z-index:56;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 > div,.cal-h > h2,.cal-h b{flex:1;text-align:center;font-weight:var(--fw-bold)}
.cal-grid{width:100%;border-collapse:separate;border-spacing:2px;table-layout:fixed}
.cal-grid .wd{font-size:var(--fs-caption);line-height:28px;font-weight:var(--fw-regular);color:var(--text-secondary)}
.cal-grid td{padding:0}
.cal-grid .day{display:grid;place-items:center;height:34px;border-radius:var(--radius-ctl);font:var(--fw-medium) var(--fs-figure-sm)/1 var(--font-figures);font-variant-numeric:tabular-nums;color:var(--text-primary);cursor:pointer;outline:none;transition:background-color var(--dur-fast) var(--ease)}
.cal-grid .day:hover{background:var(--c-n100)}
.cal-grid .day:focus-visible{box-shadow:0 0 0 2px var(--focus-ring)}
.cal-grid .day[data-today]{box-shadow:inset 0 0 0 1px var(--border-control)}
.cal-grid .day[data-selected]{background:var(--c-ink);color:var(--text-on-dark);box-shadow:none}
.cal-grid .day[data-selected]:hover{background:var(--primary-hover)}
.cal-grid .day[data-disabled]{color:var(--text-disabled);cursor:not-allowed;background:none;font-weight:var(--fw-regular)}
/* 非本月的格子留空(不显示日期),和不可选日期不混淆(v0.2) */
.cal-grid .day[data-outside-view]{visibility:hidden}

20
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client", "node"],
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"jsx": "preserve",
"noEmit": true,
"skipLibCheck": true,
"paths": { "@/*": ["./src/*"] }
},
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
}

31
apps/web/vite.config.ts Normal file
View File

@@ -0,0 +1,31 @@
import { fileURLToPath, URL } from "node:url";
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite";
/**
* 开发:Vite 跑在 127.0.0.1:5173,把 /api 代理到后端 127.0.0.1:8009,网页版同源、cookie 直接可用。
* 构建:资源基础路径默认 "/";桌面外壳(file:// 或自定义协议)构建时设 VITE_BASE=./,
* 同时在 public/config.js 里把 routerMode 改成 "hash"(架构 §5.2 第 6 条)。
*/
export default defineConfig({
base: process.env.VITE_BASE ?? "/",
plugins: [vue()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
// 设计规范是唯一的样式来源:tokens.css、components.css、logo 直接从 docs/design 引用,不复制
"@design": fileURLToPath(new URL("../../docs/design", import.meta.url)),
},
},
// pdf.js 被 Vite 预打包后在开发环境里加载文档会一直挂起,直接用它自带的 ES 模块
optimizeDeps: { exclude: ["pdfjs-dist"] },
server: {
host: "127.0.0.1",
port: 5173,
strictPort: true,
proxy: {
"/api": { target: "http://127.0.0.1:8009", changeOrigin: false },
},
},
preview: { host: "127.0.0.1", port: 4173 },
});

100
deploy/compose/compose.yaml Normal file
View File

@@ -0,0 +1,100 @@
# 本地开发与 SaaS 内测期的编排(T4)。
# 只起依赖: docker compose -f deploy/compose/compose.yaml up -d postgres valkey gotenberg
# 全部起来: docker compose -f deploy/compose/compose.yaml --profile app up -d --build
# 端口只绑 127.0.0.1;生产环境的密钥从环境变量注入,不写在这里。
name: bid-doctor
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: bd_owner
POSTGRES_PASSWORD: ${BD_PG_OWNER_PASSWORD:-dev-owner}
POSTGRES_DB: bid_doctor
BD_APP_PASSWORD: ${BD_PG_APP_PASSWORD:-dev-app}
BD_OPS_PASSWORD: ${BD_PG_OPS_PASSWORD:-dev-ops}
ports: ["127.0.0.1:${BD_PG_PORT:-54329}:5432"]
volumes:
- pgdata:/var/lib/postgresql/data
- ./postgres/init-roles.sh:/docker-entrypoint-initdb.d/10-roles.sh:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U bd_owner -d bid_doctor"]
interval: 2s
retries: 30
valkey:
image: valkey/valkey:8-alpine
command: ["valkey-server", "--save", "", "--appendonly", "no"] # 只存丢了也没关系的数据(AR4)
ports: ["127.0.0.1:${BD_VALKEY_PORT:-63799}:6379"]
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 2s
retries: 30
gotenberg:
image: gotenberg/gotenberg:8
command:
- gotenberg
- --api-timeout=240s
- --libreoffice-restart-after=50
# 转换服务不应访问外网(架构 §3.5)。本地开发要从宿主机访问它,所以放在默认网络;
# 生产编排(Helm / 生产 compose)用网络策略禁止它的出口流量。
ports: ["127.0.0.1:${BD_GOTENBERG_PORT:-3109}:3000"]
migrate:
profiles: [app]
image: bid-doctor-server
build: { context: ../.., dockerfile: deploy/docker/server.Dockerfile }
command: ["bid-doctor", "migrate"]
env_file: [{ path: ../../.env, required: false }]
environment:
BD_MIGRATE_DATABASE_URL: postgresql+psycopg://bd_owner:${BD_PG_OWNER_PASSWORD:-dev-owner}@postgres:5432/bid_doctor
depends_on:
postgres: { condition: service_healthy }
api:
profiles: [app]
image: bid-doctor-server
command: ["bid-doctor", "api", "--host", "0.0.0.0", "--port", "8000"]
env_file: [{ path: ../../.env, required: false }]
environment: &app-env
BD_DATABASE_URL: postgresql+psycopg://bd_app:${BD_PG_APP_PASSWORD:-dev-app}@postgres:5432/bid_doctor
BD_MIGRATE_DATABASE_URL: postgresql+psycopg://bd_owner:${BD_PG_OWNER_PASSWORD:-dev-owner}@postgres:5432/bid_doctor
BD_VALKEY_URL: redis://valkey:6379/0
BD_GOTENBERG_URL: http://gotenberg:3000
BD_STORAGE_DIR: /data/objects
ports: ["127.0.0.1:${BD_API_PORT:-8009}:8000"]
volumes: [objects:/data/objects]
depends_on:
migrate: { condition: service_completed_successfully }
valkey: { condition: service_healthy }
# 运营后台接口(决策 M6-2):同一镜像的第三种进程,只绑本机回环;IP 白名单放行本机经 Docker 网桥进来的请求
ops:
profiles: [app]
image: bid-doctor-server
command: ["bid-doctor", "ops", "--host", "0.0.0.0", "--port", "8000"]
env_file: [{ path: ../../.env, required: false }]
environment:
<<: *app-env
BD_OPS_DATABASE_URL: postgresql+psycopg://bd_ops:${BD_PG_OPS_PASSWORD:-dev-ops}@postgres:5432/bid_doctor
BD_OPS_ALLOWED_IPS: '["127.0.0.1/32", "172.16.0.0/12"]'
ports: ["127.0.0.1:${BD_OPS_PORT:-8019}:8000"]
depends_on:
migrate: { condition: service_completed_successfully }
valkey: { condition: service_healthy }
worker:
profiles: [app]
image: bid-doctor-server
command: ["bid-doctor", "worker"]
env_file: [{ path: ../../.env, required: false }]
environment: *app-env
volumes: [objects:/data/objects]
depends_on:
migrate: { condition: service_completed_successfully }
valkey: { condition: service_healthy }
volumes:
pgdata: {}
objects: {}

View File

@@ -0,0 +1,11 @@
#!/bin/sh
# 数据库角色(data-model §2.4):bd_owner 建表和迁移;bd_app 给 api / worker,受行级安全策略约束;
# bd_auth 不登录,只拥有登录前查会话的两个函数;bd_ops 给运营后台的跨企业查询(决策 M6-2)。
# 只在数据卷第一次初始化时执行;已有的数据卷要手工补建 bd_ops(见 docs/architecture/m6-plan.md)。
set -eu
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<SQL
CREATE ROLE bd_app LOGIN PASSWORD '${BD_APP_PASSWORD}';
CREATE ROLE bd_auth NOLOGIN BYPASSRLS;
CREATE ROLE bd_ops LOGIN BYPASSRLS PASSWORD '${BD_OPS_PASSWORD}';
GRANT CONNECT ON DATABASE ${POSTGRES_DB} TO bd_app, bd_ops;
SQL

View File

@@ -0,0 +1,24 @@
# api 和 worker 共用的镜像(T2:同一套代码、两种进程,T4:同一批镜像两种编排)。
# docker build -f deploy/docker/server.Dockerfile -t bid-doctor-server .
# TODO(AR7):私有化交付时核心包用 Nuitka 编译,提示词加密。
FROM python:3.13-slim AS build
COPY --from=ghcr.io/astral-sh/uv:0.12.20 /uv /usr/local/bin/uv
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PROJECT_ENVIRONMENT=/app/.venv
WORKDIR /src
COPY pyproject.toml uv.lock .python-version ./
COPY server/pyproject.toml server/
RUN uv sync --frozen --no-dev --no-install-workspace --package bid-doctor
COPY server server
RUN uv sync --frozen --no-dev --package bid-doctor --no-editable
FROM python:3.13-slim
RUN useradd --system --uid 10001 bd
COPY --from=build /app/.venv /app/.venv
COPY server/alembic.ini /app/server/alembic.ini
COPY server/migrations /app/server/migrations
COPY prompts /app/prompts
ENV PATH=/app/.venv/bin:$PATH BD_PROMPTS_DIR=/app/prompts PYTHONUNBUFFERED=1
WORKDIR /app/server
USER bd
EXPOSE 8000
CMD ["bid-doctor", "api", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -44,7 +44,7 @@
### FR-M2-05 结构化提取 · P0 ### FR-M2-05 结构化提取 · P0
按五类生成清单项:PRD 的四类,加上 IT 集成需要的「技术参数」。字段定义见 `05-it-integration-domain.md` §3。 按六类生成清单项:PRD 的四类,加上 IT 集成需要的「技术参数」,以及承载分项限价的「报价」(DM7)。字段定义见 `05-it-integration-domain.md` §3。
| 类别 | 内容 | | 类别 | 内容 |
|---|---| |---|---|
@@ -53,20 +53,28 @@
| 评分办法 | 评标方法、价格 / 技术 / 商务分值、每个评分项(分值、标准、证明材料)、中小企业价格扣除 | | 评分办法 | 评标方法、价格 / 技术 / 商务分值、每个评分项(分值、标准、证明材料)、中小企业价格扣除 |
| 关键日期与递交 | 截止时间、开标时间、提疑 / 质疑截止、保证金、投标有效期、递交方式、份数、签章、密封 / 加密 | | 关键日期与递交 | 截止时间、开标时间、提疑 / 质疑截止、保证金、投标有效期、递交方式、份数、签章、密封 / 加密 |
| 技术参数 | 参数项、要求值、比较方式(≥ ≤ = 包含 支持)、重要性(★ / ▲ / 一般)、所属包 | | 技术参数 | 参数项、要求值、比较方式(≥ ≤ = 包含 支持)、重要性(★ / ▲ / 一般)、所属包 |
| 报价 | 分项限价、单价上限(总价限价和采购包限价在项目基本信息和分包里提取,FR-M2-03、04) |
**验收** **验收**
- 每条清单项至少有一个出处(文件 + 页码 + 原句);没有出处的进「待确认」区(FR-M2-07)。 - 每条清单项至少有一个出处(文件 + 页码 + 原句);没有出处的进「待确认」区(FR-M2-07)。
- ★ 条款全部进入「无效 / 废标」类,同时保留在原来的类别里(同一条要求可以有多个类别标签)。 - ★ 条款全部进入「无效 / 废标」类,同时保留在原来的类别里(同一条要求可以有多个类别标签)。
- **实质性条款按文件自己的标注规则识别**:先找到「标注规则说明」(★ / * / 斜体加下划线 / 「必须」字样等),再按规则识别;不能写死成 ★(07 F1)。 - **实质性条款按文件自己的标注规则识别**:先找到「标注规则说明」(★ / * / 斜体加下划线 / 「必须」字样等),再按规则识别;不能写死成 ★(07 F1)。
- 文件写明了实质性条款数量时(如「★ 项共 14 条」),和实际抽到的数量对比,不一致就提示用户。 - 文件写明了实质性条款数量时(如「★ 项共 14 条」),和实际抽到的数量对比,不一致就提示用户。
- 用 3 份以上真实 IT 集成招标文件做基准测试:人工标注的 ★ 条款**召回率**是主要指标(漏了就可能废标),具体目标在技术方案阶段定。 - 用 3 份以上真实 IT 集成招标文件做基准测试:人工标注的 ★ 条款**召回率**是主要指标(漏了就可能废标)。
- **目标值**(技术验证 S4 后确定,见 `docs/decisions.md` TV1):
- 标注类实质性条款(★ / 斜体下划线等)召回率 ≥ 99%,靠规则兜底加模型实现;
- 进入正式清单的条目准确率 ≥ 95%。
### FR-M2-06 原文回链 · P0 ### FR-M2-06 原文回链 · P0
- 清单和原文并排显示,点击清单项就跳到对应页并高亮原句;一条可以有多个出处。 - 清单和原文并排显示,点击清单项就跳到对应页并高亮原句;一条可以有多个出处。
- 扫描件按 OCR 文字坐标高亮。 - 扫描件按 OCR 文字坐标高亮。
**验收**:抽查 50 条,出处页码正确率和高亮准确率都要达到目标值(技术方案阶段定);出处无法定位时,宁可进「待确认」,也不能给一个错的页码。 **验收**:抽查 50 条,达到以下目标值(技术验证 S3 后确定,见 `docs/decisions.md` TV1):
- 出处页码正确率 100%;
- 原句高亮准确率:正文 ≥ 98%,表格内 ≥ 95%。
出处无法定位时,宁可进「待确认」,也不能给一个错的页码。
### FR-M2-07 待确认区 · P0 ### FR-M2-07 待确认区 · P0
@@ -131,7 +139,7 @@
**验收** **验收**
- 每个问题都带两边的出处:招标原文,和投标原文(「未找到」时写明找过哪些分册、哪些章节)。 - 每个问题都带两边的出处:招标原文,和投标原文(「未找到」时写明找过哪些分册、哪些章节)。
- 每个问题都对应一条清单项;用户能从问题跳到清单项,也能反过来。 - AI 判断的问题都对应一条清单项;规则检查的问题有对应清单项时也挂上,没有时(如大小写金额不一致)对应规则编号,界面显示规则说明(DM6)。用户能从问题跳到清单项,也能反过来。
### FR-M3-04 形式要件检查 · P0 ### FR-M3-04 形式要件检查 · P0
@@ -174,7 +182,7 @@
PDF 报告内容: PDF 报告内容:
1. 首页:项目信息、投标文件版本、检查时间、规则版本、**检查覆盖范围**(查了什么、没查什么)、免责声明(辅助检查,不保证结果); 1. 首页:项目信息、投标文件版本、检查时间、规则版本、**检查覆盖范围**(查了什么、没查什么)、免责声明(辅助检查结果,仅供投标前自查参考);
2. 汇总:各级别的问题数和处理状态; 2. 汇总:各级别的问题数和处理状态;
3. 问题明细:问题、出处、处理状态、处理人、时间、忽略原因; 3. 问题明细:问题、出处、处理状态、处理人、时间、忽略原因;
4. 附录:需人工核对的条目,以及勾选结果。 4. 附录:需人工核对的条目,以及勾选结果。
@@ -206,7 +214,8 @@ PDF 报告内容:
### FR-M6-03 额度计量 · P0 ### FR-M6-03 额度计量 · P0
- 额度账户按企业主体建;流水类型:发放 / 预扣 / 结算 / 退回 / 过期 / 人工调整(必须填原因)。 - 额度账户按企业主体建;流水类型:发放 / 预扣 / 结算 / 退回 / 过期 / 人工调整(必须填原因)/ 免扣。
- **免扣**(决策 Q13):24 小时内复查不扣点(FR-M3-02、决策 P1)时,也记一行流水,类型「免扣(24 小时内复查)」,数量 0,关联到这次检查,让用户能核对复查确实没扣点。
- 额度页:本期已用、剩余、按功能分布,流水可以查到具体是哪个项目、哪个任务。 - 额度页:本期已用、剩余、按功能分布,流水可以查到具体是哪个项目、哪个任务。
- 用到 80%、100% 时站内提醒,同一阈值每个周期只提醒一次。 - 用到 80%、100% 时站内提醒,同一阈值每个周期只提醒一次。

View File

@@ -1,13 +1,16 @@
# 04 · 领域模型草案 # 04 · 领域模型草案
> 这是需求层面的概念模型,不是数据库设计。字段只列出决定业务规则的那些,物理建模留到技术架构阶段。 > 这是需求层面的概念模型,不是数据库设计。字段只列出决定业务规则的那些;物理模型见 `docs/architecture/data-model.md`。
> 核心是 PRD 第 5 章说的「**项目检查清单**」:M2 产出它,M3 拿它做核对,V1 的 M4 拿它做生成约束,M5 也挂在它上面。 > 核心是 PRD 第 5 章说的「**项目检查清单**」:M2 产出它,M3 拿它做核对,V1 的 M4 拿它做生成约束,M5 也挂在它上面。
## 1. 实体关系 ## 1. 实体关系
```mermaid ```mermaid
erDiagram erDiagram
Tenant ||--o{ User : "成员" Tenant ||--o{ Membership : "成员"
User ||--o{ Membership : "成员身份"
User ||--o{ LoginIdentity : "登录方式"
Project ||--o{ ProjectMember : "项目成员"
Tenant ||--o{ Subscription : "订阅" Tenant ||--o{ Subscription : "订阅"
Tenant ||--|| QuotaAccount : "额度账户" Tenant ||--|| QuotaAccount : "额度账户"
QuotaAccount ||--o{ QuotaEntry : "流水" QuotaAccount ||--o{ QuotaEntry : "流水"
@@ -34,16 +37,19 @@ erDiagram
QuotaEntry }o--o| CheckRun : "关联任务" QuotaEntry }o--o| CheckRun : "关联任务"
``` ```
所有业务实体都带 `tenant_id`,按租户隔离(PRD 7)。 所有业务实体都带 `tenant_id`,按租户隔离(PRD 7)。User 和 LoginIdentity 例外,是全局的(技术架构 §3.14)。
## 2. 实体说明 ## 2. 实体说明
| 实体 | 关键字段 | 说明 | | 实体 | 关键字段 | 说明 |
|---|---|---| |---|---|---|
| **Tenant** 企业主体 | 名称、统一社会信用代码(可空)、当前套餐 | 额度和数据的归属单位(PRD 4.3:额度绑定企业主体) | | **Tenant** 企业主体 | 名称、统一社会信用代码(可空)、当前套餐 | 额度和数据的归属单位(PRD 4.3:额度绑定企业主体) |
| **User** 用户 | 手机号、所属租户、角色 | MVP 一个租户一个成员;角色字段先留着,给 V1 用 | | **User** 用户 | 显示名、状态 | 一个自然人,不属于某个租户;不做物理删除 |
| **Subscription** 订阅 | 套餐、起止日期、来源(注册赠送 / 人工开通 / 线上支付) | | | **LoginIdentity** 登录方式 | 来源(手机号 / 飞书 / 企业微信 / 账号密码 / OIDC / LDAP)、外部组织编号、外部用户编号 | 一个人可以有多种登录方式 |
| **QuotaAccount / QuotaEntry** 额度 | 单位(解析 / 检查 / 生成 / 查重)、类型(发放 / 预扣 / 结算 / 退回 / 过期 / 调整)、数量、关联任务、原因 | 只追加、不修改;余额由流水算出来 | | **Membership** 成员身份 | 用户、租户、企业角色、状态 | MVP 一个人只属于一个企业、一个企业一个成员(所有者);团队版放开成多席位(技术架构 §3.14) |
| **ProjectMember** 项目成员 | 项目、用户、项目角色(负责人 / 编标 / 只读) | MVP 只有建项目的人,自动成为负责人 |
| **Subscription** 订阅 | 套餐、起止日期、来源(注册赠送 / 人工开通 / 线上支付)、席位数 | MVP 席位数是 1 |
| **QuotaAccount / QuotaEntry** 额度 | 单位(解析 / 检查 / 生成 / 查重)、类型(发放 / 预扣 / 结算 / 退回 / 过期 / 调整 / 免扣)、数量、关联任务、原因 | 只追加、不修改;余额由流水算出来 |
| **Qualification** 资质 | 名称、类型、等级、证书号、发证机构、有效期、附件 | MVP 最小资质库 | | **Qualification** 资质 | 名称、类型、等级、证书号、发证机构、有效期、附件 | MVP 最小资质库 |
| **Project** 投标项目 | 名称、编号、采购人、代理机构、采购方式、**法律体系**、截止时间、状态、所投包 | 基本信息来自 FR-M2-03,用户可以改 | | **Project** 投标项目 | 名称、编号、采购人、代理机构、采购方式、**法律体系**、截止时间、状态、所投包 | 基本信息来自 FR-M2-03,用户可以改 |
| **Lot** 包 / 标段 | 包号、名称、预算、最高限价、是否投标 | 没有分包时只有一个默认包 | | **Lot** 包 / 标段 | 包号、名称、预算、最高限价、是否投标 | 没有分包时只有一个默认包 |

View File

@@ -63,7 +63,7 @@ IT 集成项目的采购人大量是政府机关、事业单位,**多数走政
| 字段 | 说明 | | 字段 | 说明 |
|---|---| |---|---|
| 类别标签 | 资格 / 无效条款 / 评分 / 日期与递交 / 技术参数 / 格式文件(可以有多个) | | 类别标签 | 资格 / 无效条款 / 评分 / 日期与递交 / 技术参数 / 格式文件 / 报价(可以有多个;「报价」见 DM7) |
| 要求原文 | 原句,不改写 | | 要求原文 | 原句,不改写 |
| 要求摘要 | AI 生成的简短说明,界面标注是 AI 生成 | | 要求摘要 | AI 生成的简短说明,界面标注是 AI 生成 |
| 重要性 | ★ 实质性 / ▲ 重要 / 一般。常见用 ★ 标实质性条款、用 ▲ 标重要指标,但**样本中 3 / 10 份用「斜体 + 下划线」标实质性条款**(07 F1),所以必须先提取文件里的「标注规则说明」原文,再按规则识别 | | 重要性 | ★ 实质性 / ▲ 重要 / 一般。常见用 ★ 标实质性条款、用 ▲ 标重要指标,但**样本中 3 / 10 份用「斜体 + 下划线」标实质性条款**(07 F1),所以必须先提取文件里的「标注规则说明」原文,再按规则识别 |
@@ -80,6 +80,7 @@ IT 集成项目的采购人大量是政府机关、事业单位,**多数走政
| 评分 | 评分因素、分值、评分标准、所属部分(价格 / 技术 / 商务)、中小企业价格扣除规则 | | 评分 | 评分因素、分值、评分标准、所属部分(价格 / 技术 / 商务)、中小企业价格扣除规则 |
| 日期与递交 | 日期类型(提疑截止 / 投标截止 / 开标 / 保证金截止)、具体时间;保证金金额与形式;投标有效期;递交方式(电子 / 纸质 / CA 加密);正副本份数;签章要求;密封要求 | | 日期与递交 | 日期类型(提疑截止 / 投标截止 / 开标 / 保证金截止)、具体时间;保证金金额与形式;投标有效期;递交方式(电子 / 纸质 / CA 加密);正副本份数;签章要求;密封要求 |
| 技术参数 | 参数名、要求值、单位、比较方式(≥ ≤ = 包含 支持 / 具备)、所属设备或系统 | | 技术参数 | 参数名、要求值、单位、比较方式(≥ ≤ = 包含 支持 / 具备)、所属设备或系统 |
| 报价 | 分项限价、单价上限:限价对象(分项名称)、比较方式、限价金额。总价限价放在项目基本信息里,采购包限价放在分包上(`docs/architecture/data-model.md` §5.3) |
| 格式文件 | 格式名称(投标函、开标一览表、授权委托书、偏离表、中小企业声明函等)、是否允许修改 | | 格式文件 | 格式名称(投标函、开标一览表、授权委托书、偏离表、中小企业声明函等)、是否允许修改 |
## 4. IT 集成投标文件的常见结构(M3 检查对象) ## 4. IT 集成投标文件的常见结构(M3 检查对象)

View File

@@ -0,0 +1,98 @@
# 补遗追加实现方案 v0.1(2026-11-05)
> 范围:FR-M2-10 补遗追加(简化版):追加的补遗 / 澄清文件单独解析、扣 1 次解析,新条目标注来源并入清单;日期类字段以最新文件为准,在旧值上标「已被补遗 N 更新」,由用户确认;不自动判断条目「新增 / 变更 / 作废」(P1)。
> 依据:`03-functional-requirements.md`、`02-user-journeys.md` J2 第 1 步、`01-scope-roles.md`(「追加解析 + 来源标注」,影响分析是 P1)、`data-model.md` §5.2「补遗」、`openapi.yaml`(`/projects/{id}/fields/{key}/pending`、`/checklist-items/{id}/supersession`)、原型 `project-new.html?addendum=1` `parsing.html?addendum=1` `checklist.html` `project.html` 的关键日期卡。
> 状态:AD1–AD6 已全部按建议确认(2026-11-05,`docs/decisions.md`)。
## 0. 结论速览
- **一半已经有了**:后端上传时可以选补遗 / 澄清(编号「补遗 N」「澄清 N」),能单独解析、扣 1 次解析、失败退回;新条目记着来自哪份文件;项目字段的新值和旧值不同时先放进待确认(`pending_value`),不覆盖。
- **缺的**:日期类条目的「已被补遗 N 更新」关联;两个确认接口(字段新值、日期条目);并入后清单回到草稿;前端的追加入口、解析结果、确认界面、来源筛选。
- **按 FR 只做日期类的自动关联**。原型里「★ 参数原为 480G、补遗改成 960G」那种逐条对照属于「变更判断」,是 P1,这次不做(AD2)。
- **补遗的新条目直接进正式清单**,和招标文件的新旧条目都查:宁可多报,不漏报(AD1)。
- **没有补遗样本**:10 份样本里一份都没有。这次用虚构的更正公告测链路;国动项目如果有更正公告,请一起给我,这是第一份真实补遗。
## 1. 现状
| 环节 | 现在 | 要补的 |
|---|---|---|
| 上传、编号 | `role = addendum / clarification`,各自从 1 编号 | 前端入口 |
| 解析、扣点 | 和招标文件同一条流水线,扣 1 次解析,失败、超时、取消退回 | 解析结果页按补遗写(新增几条、哪些日期有新值) |
| 清单项来源 | `origin_document_id` 指向补遗;但接口的 `origin_label` 只有「AI」「人工添加」 | `origin_label` 对补遗来的条目写「补遗 1」;读标页按来源筛选;并入后清单回到草稿 |
| 项目字段 | 新值和旧值不同 → `pending_value`,等确认 | 确认接口和界面;确认后同步冗余列 |
| 日期类条目 | 没有关联(`superseded_by_id` 列已建,没写) | 并入时关联、确认接口、界面 |
| 分包 | 补遗不改已有的分包 | 不变 |
## 2. 并入时做什么(后端)
补遗解析完、并入清单时,在现有的 `checklist.merge` 之后多做三件事:
1. **新条目照常进清单**:和招标文件一样按校验结果进正式清单或待确认区,标「补遗 N」。
2. **关联日期类条目**:补遗里每条有具体时间的「日期与递交」条目(投标截止、开标、提疑截止、保证金截止、投标有效期),在更早的文件(招标文件、编号更小的补遗)里找同一种日期、还在正式清单里的条目,时间不同的把旧条目的 `superseded_by_id` 指向新条目,等用户确认。时间相同的不关联(补遗只是复述)。
3. **清单回到草稿**:已确认的清单改为草稿,历史版本保留,要求重新确认(原型:「补遗 1 并入后待重新确认」)。
解析任务的结果里加:`items_added`(新增几条)、`dates_superseded`(几处日期有新值)、`fields_pending`(几个项目字段有新值),解析页按这三个数写。
## 3. 确认(后端)
按契约实现两个接口:
- **`POST /projects/{id}/fields/{key}/pending`**(`accept: true / false`):
- 采用:补遗的值变成 AI 值(出处换成补遗那一句),**清掉人工值**(以补遗为准;用户之后还可以再改),冗余列同步;
- 不采用:丢掉待确认值,保持原值;
- 采用投标截止时间时,清单里「投标截止」日期条目的更新一起确认(AD3),不用在读标页再点一次;
- 都写审计(`project_field.resolve_pending`)。
- **`POST /checklist-items/{id}/supersession`**(`accept: true / false`,对旧条目调用):
- 采用:旧条目软删除(可以恢复),新条目保留;
- 保留两条:解除关联,两条都在,由用户自己处理;
- 都写审计(`checklist_item.resolve_supersession`)。
接口返回里补上已有字段:清单项的 `superseded_by`(新条目编号、来自哪份补遗)、清单概况的 `superseded_pending`(还有几处没确认)、关键日期的 `superseded_pending`。
**没确认之前查标用旧值**(AD4):项目字段的契约就是「确认后才生效」(`data-model.md` §5.2)。检查预览提示「补遗 1 带来的新值还有 N 处没确认」,不阻止发起检查(红线 4)。确认后截止时间变了,已有的检查结果按 PI5 提示重新检查。
## 4. 前端
- **追加入口**:概览页右上角和读标页清单栏各放一个「追加补遗 / 澄清」,进新建项目页的补遗模式(原型 `project-new.html?addendum=1`)。补遗模式下不填项目名称,文件类型选补遗或澄清答复,说明写「补遗单独解析,新条目标注『补遗 N』并入清单。标博士不判断哪些旧条目已作废,并入后由你核对」,按钮旁写扣 1 次解析。招标文件还没解析完时不显示入口(AD5)。
- **解析页**:标题「解析补遗 1」;完成后写「新增 N 条 · M 处日期有新值 · K 个项目字段有新值 · 清单回到草稿,核对后重新确认」,下一步「核对补遗」进读标页、只看这份补遗的条目。
- **概览页**:
- 项目要点里有待确认值的字段写「补遗 1:新值 → 原值」,带出处,给「采用 / 不采用」;
- 关键日期卡:被更新、还没确认的日期显示「已被补遗 1 更新,待确认」;确认后显示新值并注「已按补遗 1 更新 · 原为 11月18日 09:30」(原型 `project.html`)。
- **读标页**:
- 来源筛选:全部 / 招标文件 / 补遗 1 / ……,每项带条数;
- 被更新的日期条目写「已被补遗 1 更新:新值 ……」,给「确认更新(删除旧条目)」和「保留两条」;
- 并入后顶部提示「补遗 1 新增 N 条。标博士不判断哪些旧条目被它改了或作废了,请核对:改了的旧条目可以修改或删除」,带「只看补遗 1」。
- **查标页**:检查预览里有没确认的新值时出上面那条提示。
## 5. 测试
- 虚构一份更正公告样例(`server/tests/fixtures/addendum-mini.pdf`,内容全虚构):改投标截止时间、开标时间,新增一条 ★ 参数,复述一条没改的日期。
- 集成测试:追加 → 解析 → 扣 1 次 → 新条目标「补遗 1」、清单回到草稿、截止时间进待确认、日期条目关联(复述的不关联);字段采用 / 不采用;采用截止时间时日期条目一起确认;日期条目采用(旧条目软删除、可以恢复)/ 保留两条;检查预览的提示;只读项目 409。
- 假模型能不能从样例里抽出日期要看它的规则;抽不出时测试里用接口建条目。浏览器里走一遍追加到确认的全程。
- 用真实模型对这份虚构样例跑一次解析看看抽取结果,花费几分钱(AD6)。
## 6. 工作量和顺序
1. 后端:并入时关联日期、清单回到草稿、两个确认接口、接口字段补齐、检查预览提示,以及测试样例和测试。约半天。
2. 前端:补遗模式的上传页、解析页的补遗结果、概览页的确认、读标页的来源筛选和日期确认。约半天。
## 7. 待确认事项
| 编号 | 事项 | 建议 |
|---|---|---|
| AD1 | 补遗的新条目进正式清单还是待确认区 | **按招标文件同一套规则进正式清单**(没出处、校验没过的照样进待确认区),标「补遗 N」,读标页给「只看补遗 N」和核对提示,清单回到草稿要求重新确认。新旧条目都在、都查,宁可多报,不漏报;全放待确认区的话,用户不逐条采纳就查不到补遗的新要求 |
| AD2 | 哪些条目自动标「已被补遗更新」 | **只做日期类**(同一种日期、时间不同),按 FR。原型里参数类「原为 480G → 现为 960G」的逐条对照是变更判断,P1;先等真实补遗样本看写法 |
| AD3 | 怎么确认 | **字段在概览页逐个「采用 / 不采用」,日期条目在读标页逐条确认**;采用投标截止时间时清单里的「投标截止」条目一起确认。采用时清掉人工值,以补遗为准 |
| AD4 | 没确认之前查标用哪个值 | **旧值**,确认后生效(数据模型原定);检查预览提示还有几处没确认,不阻止检查 |
| AD5 | 招标文件没解析完能不能追加补遗 | **不能**,界面上招标文件解析成功后才显示入口。没有招标文件时补遗的日期没有可关联的旧值 |
| AD6 | 用真实模型对虚构样例跑一次解析 | **跑一次**(DeepSeek,几分钱),看抽取对补遗写法(「原为……现更正为……」)的表现;结果只记录,这次不按它调提示词 |
## 8. 落地记录(2026-11-05)
- 后端:`checklist/addendum.py`(并入时关联日期条目、确认 / 保留两条、采用截止时间时一起确认「投标截止」条目);`project/edit.resolve_pending`(采用时补遗的值变成 AI 值、出处换成补遗那一句、清掉人工值);清单项接口补 `origin_document`、`superseded_by`,`origin_label` 对补遗来的条目写「补遗 N」;清单概况 `superseded_pending`;关键日期 `replaces`(「已按补遗 1 更新 · 原为……」);解析结果 `dates_superseded`、`fields_pending`;检查预览 `pending_updates`。
- **补遗不改项目是谁**:解析补遗时项目名称、编号、采购人、代理机构、法律体系不进待确认。测试里发现,更正公告的标题会被抽成「项目名称」,和招标文件的不同,每份补遗都会冒出一条没意义的待确认。
- **时间比较按北京时间补时区**:模型给的时间有时带时区、有时不带,补遗复述原时间时不能误判成「有新值」。
- ORM 给 JSONB 列写 `None` 存的是 JSON null,不是 SQL NULL,SQL 里数待确认值时要排除 JSON null。
- 前端:补遗模式的上传页(选补遗 / 澄清答复)、解析页的补遗结果和「核对补遗」、概览页「追加补遗 / 澄清」和字段「采用 / 不采用」、关键日期的待确认和「已按补遗 1 更新」、读标页的来源筛选、核对提示、「只看日期更新」和逐条确认、条目上的「补遗 1」标签、检查预览的提示。
- **AD6 结果**:用 DeepSeek Flash 对虚构的更正公告(推后递交截止时间、新增一条 ★ 参数)跑了一次解析(6 次调用):两处改动都抽对了,★ 参数归为技术参数 + 无效条款、新截止时间 12 月 8 日,出处都是「补遗 1 · 第 1 页」,没有多抽。只是一份很规整的虚构样例,「原为……现更正为……」那种写法这份样例里没有,真实补遗到了再看。
- 浏览器里走过:追加补遗 → 解析结果 → 概览采用新截止时间(项目头、要点、关键日期一起变,旧的「投标截止」条目自动删除)→ 读标页只看补遗 1。

View File

@@ -0,0 +1,205 @@
# 接口契约 v0.1
> 依据:架构 v0.3(§3.4 任务、§3.11 会话、§4.2 前端、§5.2 桌面约束)、`data-model.md`、03 功能需求、MVP 原型 v0.2 的 13 个页面。
> 契约文件:`openapi.yaml`(OpenAPI 3.1,64 个路径、81 个操作、81 个数据结构)。
> 范围:用户端(网页版 / 桌面版)的 MVP 主流程。运营后台是独立应用,接口另行定义。
> API1–API5 已于 2026-11-03 全部按建议确认(`docs/decisions.md`),见 §9。
## 0. 结论速览
- **一份契约,两端同时开发**:前端用 openapi-typescript 从 `openapi.yaml` 生成类型,用 mock 服务器先跑;后端按它写 Pydantic 模型。后端骨架搭好后,改为以 FastAPI 生成的文档为准(API1)。
- **两种鉴权,同一套接口**:网页版用 cookie,并加 CSRF 头;桌面版用 Bearer 令牌(架构 §5.2 第 3 条)。
- **错误统一用 RFC 9457 格式**,带一个稳定的 `code`,前端按 `code` 判断,不解析文案(§5)。
- **会扣点的接口都要带「预期扣点」**:前端把告诉用户的扣点数传上来,服务端算的和它不一致就拒绝,不扣点(API4)。这样用户看到的扣点说明和实际扣点永远一致(红线 9)。
- **进度用事件流推送,轮询兜底**:事件只是「有变化」的提示,以资源的 GET 结果为准。事件流用 fetch 读取,不用浏览器的 EventSource,因为 EventSource 带不了 Bearer 头(API2)。
- **大文件分片上传、断点续传**,和平台无关(架构 §5.2 第 7 条)。格式、加密、页数在上传完成时就拦截,不建任务、不扣点(FR-M2-01)。
## 1. 基本约定
| 事项 | 约定 |
|---|---|
| 地址 | `{服务器}/api/v1`。网页版同源;桌面版用用户选择的服务器地址 |
| 格式 | JSON,字段名用 snake_case(和数据库、Python 一致);生成的 TypeScript 类型也是 snake_case,不做转换 |
| 编号 | UUID 字符串 |
| 时间 | RFC 3339,带时区。显示格式(「11月20日 09:30」)由前端处理(Q10) |
| 金额 | 字符串,如 `"1280000.00"`,单位元,避免浮点误差 |
| 枚举 | 英文小写加下划线,值和数据库的 CHECK 约束一致;中文显示名由前端维护 |
| 列表 | 一个项目内的列表(清单项、问题、文档、分包)一次返回全部,最多几百条;跨项目、会一直增长的列表(项目、额度流水、通知)用游标分页:`?cursor=&limit=`,返回 `next_cursor` |
| 动作 | 不是增删改的操作,用 `POST /资源/{id}/动作`,例如 `/findings/{id}/resolve`、`/parse-jobs/{id}/cancel` |
| 版本 | 路径里的 `v1`。同一版本内只加字段、不删不改;前端忽略不认识的字段和枚举值 |
## 2. 鉴权与会话
| | 网页版 | 桌面版 |
|---|---|---|
| 登录后 | 服务端设置 cookie `bd_session`(HttpOnly、Secure、SameSite=Lax)和 `bd_csrf`(前端可读) | 响应体里返回 `token`,只返回这一次;存进系统钥匙串 |
| 每个请求 | 自动带 cookie;写操作(POST、PUT、PATCH、DELETE)还要带请求头 `X-CSRF-Token`,值等于 `bd_csrf` | `Authorization: Bearer <token>` |
| 退出 | `POST /auth/logout` 吊销会话 | 同左 |
- 登录、注册都用 `client_kind` 声明是哪一端。
- **异地多设备并发登录**(FR-M6-04):同一账号已有其他设备在线时,登录返回 409 `concurrent_session`,附在线设备列表(设备、IP 归属地、最近活跃时间);用户确认后带 `force: true` 重发,其他设备被挤下线(API3)。被挤下线的设备下一个请求收到 401 `session_revoked`,`reason: concurrent_login`,前端提示「你的账号在其他设备登录」。
- **发验证码不泄露注册状态**:号码有没有注册,`POST /auth/sms-codes` 都返回 202。验证码校验通过后,登录接口才会告诉用户「这个号码还没注册」。
- **权限点**:`GET /me` 返回当前用户的权限点列表(AR12),前端只用它决定按钮显示与否,服务端每个接口照样检查。MVP 只有所有者,拥有全部权限点。
## 3. 主流程和接口的对应
| 步骤(原型页面) | 接口 |
|---|---|
| 注册 / 登录(login) | `POST /auth/sms-codes` → `POST /auth/register` 或 `POST /auth/login` → `GET /me` |
| 工作台(workbench) | `GET /projects`,每行带「下一步」`next_action`、就绪状态、正在跑的任务;`GET /quota` 给顶栏额度;「待处理的致命问题」按有致命问题的项目取 `GET /check-runs/{id}/findings`,「去处理」带 `?f=问题编号` 进查标页 |
| 新建项目、上传招标文件(project-new) | `POST /projects` → `POST /uploads` → `PUT /uploads/{id}/parts/{n}` × N → `POST /uploads/{id}/complete` → `POST /projects/{id}/documents` → `POST /documents/{id}/parse-jobs`(预扣 1 次) |
| 解析进度(parsing) | `GET /events` 收 `job.progress`;断线时轮询 `GET /parse-jobs/{id}`;`POST /parse-jobs/{id}/cancel`;失败后重试再调一次 `POST /documents/{id}/parse-jobs` |
| 选包(lots) | `GET /projects/{id}/lots` → `PUT /projects/{id}/bidding-lots`;手工增删改分包 |
| 读标(checklist) | `GET /projects/{id}/checklist`、`GET /projects/{id}/checklist/items`;原文查看器用 `GET /documents/{id}/file?variant=normalized`、`GET /documents/{id}/pages`;选句补出处用 `GET .../pages/{n}/sentences` → `POST /projects/{id}/citations`;修改、采纳、删除、恢复、补遗更新、修改记录、确认清单;每条的检查结论用 `GET /check-runs/{id}/item-results`(「已响应」的反馈判断有误走 `POST /feedback`,`object_type = checklist_item`、`kind = false_negative`);资质库的情况用 `GET /projects/{id}/qualification-matches` |
| 上传投标文件、发起检查(bid-check) | 上传同上,`role = bid_volume`;`GET /projects/{id}/check-preview` 给出扣点说明 → `POST /projects/{id}/check-runs` |
| 查标(results) | `GET /check-runs/{id}`、`GET /check-runs/{id}/findings` → `POST /findings/{id}/resolve`、`/reopen`(U 键)、`POST /check-runs/{id}/findings/bulk-resolve`;需人工核对用 `GET/PUT /projects/{id}/manual-checks`;`POST /feedback` |
| 报告(report) | `POST /check-runs/{id}/reports` → 事件 `report.ready` → `GET /reports/{id}/file` |
| 项目概览(project) | `GET /projects/{id}`、基本信息字段的修改和补遗确认、`GET /projects/{id}/documents`(文件卡)、`GET /projects/{id}/check-runs` 和 `GET /projects/{id}/reports`(检查记录卡)、`POST /projects/{id}/transitions`(递交 / 归档及撤销)、`DELETE /projects/{id}` |
| 额度(quota) | `GET /quota`、`GET /quota/entries`、`GET /plans`、`POST /activation-requests` |
| 资质库(qualifications) | `/qualifications` 的增删改;附件先上传,再 `POST /qualifications/{id}/files` |
## 4. 几个关键设计
### 4.1 扣点:预览 + 预期扣点
- 发起检查前,前端调用 `GET /projects/{id}/check-preview`。服务端按 01 §4 和 P1 算出这次的计费方式(扣点 / 24 小时内免扣 / 免费版)、扣几次、免扣窗口到什么时候,以及能不能开始(没有招标文件、额度用完等)。按钮旁的扣点说明就用这份数据。
- 发起时带 `expected_charge`。如果服务端当下算的和它不一致(比如用户停在页面上时,24 小时窗口刚好过了),就返回 409 `billing_changed`,附最新的预览,**不扣点**。前端刷新说明,让用户再点一次。
- 解析同理:`POST /documents/{id}/parse-jobs` 也接受 `expected_charge`。
- 重复点击不会重复扣点:同一份文档、同一个项目同时只能有一个在跑的任务(数据库部分唯一索引),重复发起返回 409 `job_already_running`,附正在跑的任务编号。
### 4.2 任务与事件
- 解析任务、检查批次、报告生成都是后台任务:发起接口立刻返回任务对象(`status = queued`)。
- `GET /events` 是当前用户的事件流:`job.progress`、`job.finished`、`report.ready`、`notification.created`、`quota.changed`、`project.changed`。
- 每条事件带 `id`。重连时带上 `Last-Event-ID`,服务端尽量补发;补不了(Valkey 里的缓冲已过期)就发 `resync`,前端重新拉取当前页面的数据。
- 事件可能丢、可能重复。前端收到后只做一件事:让 TanStack Query 里对应的查询失效、重新请求。
- 断线兜底:页面上有进行中的任务时,每 5 秒轮询一次任务的 GET。
- 失败原因 `error` 只给用户看的文字和 `refunded`(是否已退回额度),不含文档内容和内部堆栈。
### 4.3 上传
1. `POST /uploads`:传文件名、大小和用途(项目文件或资质附件),可选传 sha256。超出套餐的单文件大小上限,直接返回 409,`detail` 写明上限值。
2. `PUT /uploads/{id}/parts/{n}`:逐片上传,片大小由服务端决定。重复上传同一片会覆盖,所以断点续传时可以先 `GET /uploads/{id}` 看收到了哪些片。
3. `POST /uploads/{id}/complete`:合并分片,按文件头识别真实格式,读取页数。不支持的格式、加密、损坏、电子投标平台的加密格式、页数超限,都在这里返回 422。扩展名和真实格式不一致时只做提示(`extension_mismatch`,07 F2)。
4. 挂到项目上:`POST /projects/{id}/documents`。投标分册重新上传时填 `replaces_document_id`,生成新版本。
上传会话 24 小时后过期,没挂到项目上的文件自动清理。分片经过 api 写入对象存储;本地磁盘和 S3 用同一套接口,私有化环境也一样。
### 4.4 原文和出处
- 原文用 pdf.js 渲染:`GET /documents/{id}/file?variant=normalized` 返回短时有效的下载地址,pdf.js 直接加载,不需要带鉴权头(架构 §5.2 第 5 条)。
- 出处对象自带原句、页码和坐标框,阅读器直接画高亮,不用再取文字层。`normalized_sha256` 和文档的哈希不一致时不画(data-model §5.1)。
- 人工补出处:在阅读器里选中句子(`GET /documents/{id}/pages/{n}/sentences`),再 `POST /projects/{id}/citations` 建一条出处,最后把它挂到清单项上(采纳或替换出处)。
### 4.5 修改清单和处理问题
- **并发修改**:清单项和项目字段的 GET 返回 `ETag`。修改时带上 `If-Match`,版本不一致返回 412。MVP 只有一个人,这个头是选填的;团队版上线后改为必填。
- **改清单项**:`PATCH` 只传要改的字段。服务端把改过的字段记进 `edited_fields`,AI 原值保留在 `ai_values` 里。已确认的清单被修改后回到草稿(FR-M2-08)。
- **处理问题**:
- `resolve` 返回问题本身和项目最新的就绪状态(`open_fatal_count`),项目头直接更新,不用再请求一次;
- 忽略必须写原因,致命问题「确认无误」也必须写说明(U4),否则返回 422 `note_required`;
- 只有最近一次检查的问题可以处理,历史批次返回 409 `run_superseded`;
- 批量处理只限「提示」级,混进了别的级别就整批拒绝,返回不符合的问题编号。
- **已递交、已归档的项目只读**:所有修改接口都返回 409 `project_read_only`。撤销递交或撤销归档之后才能改(FR-M6-02)。
- **套餐到期后整个账号只读**(决策 M6-5):写操作返回 409 `plan_expired`;退出、续费申请、删除项目、注销账号、通知已读、反馈照常放行(在鉴权依赖里统一判断)。
### 4.6 不在接口里出现的东西
对照产品红线:
- 没有任何接口返回或接收「建议报价」。报价相关的字段只有招标文件写明的限价,以及从投标文件里读到的金额(红线 6)。
- 问题、报告的字段里没有「通过 / 合格」这类结论字段;就绪状态只用于提示,不阻止导出和递交(红线 3、4)。
- 没有用户可以填的模型地址或模型选择(T1)。
## 5. 错误
所有错误都用 `application/problem+json`:
```json
{
"type": "about:blank",
"title": "额度不足",
"status": 409,
"code": "quota_exhausted",
"detail": "本期解析额度已用完。专业版续费或加量请联系客服。",
"request_id": "req_01J…"
}
```
- `title` 和 `detail` 是给用户看的中文;前端按 `code` 决定怎么处理,不解析文案。
- 字段校验错误:422 `validation_failed`,`errors` 里逐个字段列出 `field`(如 `spec.tech_param.value`)、`code`、`message`。
- 访问别的企业的资源一律返回 404,不返回 403(架构 §3.3)。
- `request_id` 也放在响应头 `X-Request-ID` 里,用户反馈问题时可以报这个编号。
| HTTP | code | 含义 |
|---|---|---|
| 401 | `unauthenticated` `session_expired` `session_revoked` | 没登录、会话过期、被吊销(含被挤下线) |
| 403 | `csrf_failed` `forbidden` | CSRF 头不对;没有这个权限点 |
| 404 | `not_found` `phone_not_registered` | |
| 409 | `concurrent_session` | 已有其他设备在线(API3) |
| 409 | `quota_exhausted` `billing_changed` `plan_limit_exceeded` `plan_expired` `activation_pending` | 额度、计费、套餐限制和到期 |
| 409 | `job_already_running` `job_finished` | 任务状态冲突 |
| 409 | `project_read_only` `invalid_transition` `run_superseded` `item_not_pending` `document_in_use` 等 | 业务状态不允许这个操作 |
| 412 | `precondition_failed` | If-Match 版本不一致 |
| 422 | `validation_failed` `note_required` `citation_required` `bulk_only_hint` | 输入不合法 |
| 422 | `file_format_unsupported` `file_encrypted` `file_corrupted` `bid_file_platform_encrypted` `upload_checksum_mismatch` | 上传的文件有问题,不扣点 |
| 429 | `rate_limited` | 验证码、登录太频繁;响应头带 Retry-After |
| 500 / 503 | `internal_error` `service_unavailable` | 不扣点;任务类的会自动退回 |
完整的错误码清单在每个接口的响应说明里;写后端时收进 `core/errors.py`,作为唯一的定义来源。
## 6. 契约里没写死、留给实现的
- 分片大小(建议 8MB)、上传会话的过期时间、下载地址的有效期(建议 5 分钟);
- 列表的默认排序以外的排序方式;
- 事件缓冲多长时间(建议 10 分钟);
- `next_action` 的具体推导规则,写在 project 模块里,和原型工作台的「下一步」一致。
## 7. 以后的版本怎么接
| 以后要加的 | 接口上的准备 |
|---|---|
| 团队版:项目成员、角色 | `ProjectDetail.members` 已经有了;以后加 `/projects/{id}/members` 的增删改,`If-Match` 改为必填 |
| 一人多企业、切换企业 | `POST /auth/switch-tenant` 换一个绑定到另一个成员身份的会话(§3.14) |
| 飞书、企业微信、SSO 登录 | `/auth/{provider}/start` 和 `/auth/{provider}/callback`,登录结果的格式不变 |
| 线上支付 | `/orders` 一组新接口,不改额度接口 |
| 标书生成(M4)、查重(M5) | 新的任务类型,复用任务、事件、扣点预览这套模式 |
## 8. 怎么用这份契约
- **前端**:`packages/api-client` 用 openapi-typescript 从 `openapi.yaml` 生成类型,用 openapi-fetch 调用。后端还没好时,用基于契约的 mock 服务器(如 Prism)开发页面。
- **后端**:按契约写 Pydantic 模型和路由。集成测试里比对 FastAPI 生成的文档和契约,有差异就失败。
- **契约检查**:现在只做了结构检查(引用都能解析、operationId 不重复、路径参数都声明了、必填字段都存在)。工程骨架里在 CI 加上 Redocly 或 Spectral 的完整校验。
## 9. 决策(已确认)
| # | 事项 | 结论 |
|---|---|---|
| API1 | 契约以谁为准 | **现在以 `openapi.yaml` 为准**,前后端据此同时开发。后端骨架搭好后,改为以 FastAPI 生成的文档为准:把生成结果存成 `openapi.json` 提交到仓库,CI 检查它是不是最新的;契约有改动时在合并请求里就能看到。之后 `openapi.yaml` 不再维护,和 `schema-draft.sql` 的处理方式一样 |
| API2 | 事件流怎么读 | 用 fetch 读 SSE 流,不用 EventSource。理由:EventSource 带不了 `Authorization` 头,桌面版用不了 |
| API3 | 异地多设备并发登录怎么「提示并限制」 | **新设备登录时提示「账号已在其他设备登录」,用户确认后挤掉旧设备**。另一种做法是旧设备不退出就不让新设备登录,但用户换了电脑、忘了退出时就登不上了 |
| API4 | 扣点说明和实际扣点不一致时 | **拒绝并刷新说明,不扣点**(§4.1)。用户看到「不扣点」,结果却扣了,这种情况必须杜绝 |
| API5 | 报告怎么生成 | **后台生成**,完成后推送事件。用 Chromium 生成 PDF 要几秒到十几秒,不放在请求里同步做(架构:api 只做短事务) |
## 10. 实现进度(工程骨架)
骨架阶段只实现了「上传 → 解析 → 显示清单」这条最小链路要用的接口。`server/tests/test_contract.py` 保证已实现的接口都在契约里;契约里还没实现的,按模块列在这里,实现一个划掉一个。
| 模块 | 已实现 | 还没实现 |
|---|---|---|
| 登录与账号 | 发验证码、注册、登录(含挤下线确认)、退出、`GET /me`、注销账号(`POST /me/sms-codes` + `POST /tenant/close`,m6-plan) | 修改显示名、在线会话列表和下线 |
| 额度与套餐 | `GET /plans`、`GET /quota`(含按功能分布、排队的续费、待处理的申请)、额度流水、开通申请(m6-plan) | — |
| 项目 | 列表(不含筛选和分页)、新建、详情(含解析出的基本信息字段和关键日期)、状态变更、基本信息字段修改、补遗新值的采用 / 不采用、分包的增删改、「不分包」和选包、删除(m6-plan) | — |
| 上传与文档 | 分片上传全套、挂到项目、文档列表和详情、逐页摘要、一页的句子、签名下载、发起解析、解析进度、取消、人工建出处 | 删除文档 |
| 清单 | 清单概况(含确认人、删除数、按类别计数)、清单项列表(按区域、类别、所投的包筛选,可含已删除)、清单项详情(ETag)、修改(If-Match 选填)、采纳、删除恢复、人工新增、替换出处、修改记录、确认清单、版本、「已被补遗 N 更新」的确认 | — |
| 检查 | 检查预览、发起、进度、取消、检查记录、问题列表(含本次已解决)、每条清单项的检查结论(C9,2026-11-04 新增)、处理、撤销、批量处理(只限提示级);投标分册类型和版本、电子投标平台加密格式识别 | 需人工核对(第 2 步) |
| 报告、资质 | 报告:导出、生成进度、历次报告、签名下载(第 5 步);资质库:增删改、按类型 / 关键词筛选、附件(上传后挂上、签名下载、删除)、`late_for` 到期提醒(第 4 步) | — |
| 通知与事件 | 事件流(含检查进度)、通知列表(标题、链接、项目名称、分页)、标为已读、反馈 | — |
读标修改类接口的实现约定(2026-11-03):
- **修改记录来自审计**:每次修改都写一条审计事件,`history` 直接读它们。解析不逐条写审计(一份文件几百条),所以 AI / 规则提取的那一刻由接口在最后补一条 `checklist_item.extract`,操作人是「系统」。
- **AI 原值**:人工第一次改某个字段时,把改前的值记在 `ai_values.original` 里,重新解析也保留,界面可以显示「AI 原为……」。
- **重新解析不推翻人工决定**:已删除(丢弃)的条目对上了就保持删除;采纳过的留在正式清单;人工补过或换过出处的(`edited_fields` 含 `citations`)不被这次解析的出处覆盖。
- **确认清单**:只收正式清单里未删除的条目;内容哈希和上一个确认版本一样时复用上一版,不新增版本号;已确认又重复点确认不写审计。

View File

@@ -0,0 +1,870 @@
# 技术架构设计 v0.3
> 依据:PRD v0.9.1 第 7 章(非功能需求)、`docs/analysis/`(功能需求、领域模型、样本结论)、`docs/decisions.md`(T1 及各阶段决策)、参考项目分析 `ref-bid-engine-analysis.md`。
> 状态:v0.3。v0.2 的选型已全部确认;v0.3 新增 §3.14:MVP 的表结构先按多人、多登录方式建,给以后的团队版、企业版、飞书 / 企业微信登录留结构(AR11、AR12,已确认)。物理数据模型见 `data-model.md`(DM1–DM7,已确认);接口契约见 `api-design.md` + `openapi.yaml`(API1–API5,已确认)。
> - 已确认(2026-11-03):T2–T5;AR1、AR3、AR5–AR9 按建议;AR2 改为 Vue 3 + Vite。
> - 已确认:AR4 修订后的建议(引入 Valkey,只存可以丢的数据,§6.1);AR10 不引入带样式的 UI 框架(§4.3)。至此选型全部确认。
> - 确认后写入 `docs/decisions.md`;构建、运行、测试命令等工程骨架搭好后回填到 AGENTS.md。
> 范围:MVP(M2 + M3 + M6 的 P0 部分),同时给 V1 的标书生成、资产库,P2 的私有化、信创、桌面客户端留出扩展点。
## 0. 结论速览
| 层 | 选型 | 状态 |
|---|---|---|
| 后端语言 | **Python 单语言**:业务接口和后台任务是同一套代码、两种进程 | T2 已确认 |
| 后端框架 | FastAPI + Pydantic v2 + SQLAlchemy 2 + Alembic;包管理用 uv | 建议 |
| 架构形态 | **模块化单体**:按业务模块分包,按负载拆成几个独立部署的进程;不做微服务 | AR1 已确认 |
| 前端 | **Vue 3 + TypeScript + Vite 单页应用**,pdf.js | AR2 已确认 |
| UI 组件 | **不引入带样式的 UI 框架**:视觉自己写(tokens.css + Vue 单文件组件),复杂交互用无样式组件库 Reka UI 打底 | AR10 已确认 |
| 数据库 | **PostgreSQL**(16 以上) | AR3 已确认 |
| 任务队列 | **用 PostgreSQL 做队列**(Procrastinate):任务状态以数据库为准 | AR4 已确认 |
| 缓存、限流、消息推送 | **Valkey**(Redis 的 BSD 许可分支),只存丢了也没关系的数据 | AR4 已确认 |
| 文件存储 | S3 协议的对象存储(SaaS 用云厂商托管服务)+ 本地磁盘驱动(单机私有化);不用 MinIO | AR5 已确认 |
| 文档转换 | Gotenberg(封装了 LibreOffice 和 Chromium):Word 转 PDF、报告 HTML 转 PDF | 建议 |
| OCR | PaddleOCR 独立服务;多模态大模型作补充 | 建议,需验证 |
| 大模型 | 自建模型路由层,按能力声明路由,只接白名单内的国产模型(T1) | 已定方向 |
| 部署 | 同一批镜像,两种编排:Docker Compose 和 Helm(K8s)。**SaaS 内测期用 Compose,商用后切到托管 K8s** | T4 已确认 |
| 云厂商 | **暂不定**:只依赖 PostgreSQL、S3、OpenAI 兼容接口三类标准接口 | T5 已确认 |
| 桌面客户端 | **连服务端的客户端外壳**,界面和网页版同一套;倾向 Electron,到时再定 | T3、AR6 已确认 |
## 1. 设计输入
### 1.1 业务负载特点
这些特点决定了架构不需要很重:
- **请求量低、单个任务耗时长**:B 端投标团队,并发用户少。重活是解析和检查:一份 300 页的文件目标 10 分钟内完成(FR-M2-02),中间要调用几十到上百次大模型。
- **文件大、页数多**:单份文件几十 MB、几百页,前端要能流畅翻页并高亮原文。
- **强审计**:额度流水只追加,人工确认全程留痕,检查批次要能复现当时的判断依据(04 §4)。
- **两种部署形态共用一套代码**:SaaS 多租户;私有化部署一套就是一个租户(04 §4.5),信创环境作为可选项(PRD 7)。
- **数据敏感**:标书里有报价策略、资质、业绩,日志和监控里也不能出现文档内容。
### 1.2 来自产品红线的硬约束
| 红线 | 对架构的要求 | 落在哪一节 |
|---|---|---|
| 可复核(2) | 出处精确到原句和坐标;AI 结论必须通过证据校验,否则进待确认区 | §3.5、§3.6 |
| 全程留痕(1) | 审计事件和业务修改在同一个事务里写入;AI 产出记录模型和提示词版本 | §3.6、§3.10 |
| 额度即货币(9) | 预扣和建任务在同一个事务里;按错误类别决定是否退回;流水只追加 | §3.9 |
| 报价不由系统生成(6) | 报价检查只用确定性规则,不交给模型 | §3.7 |
| 数据敏感(10) | 按租户强制隔离,加密存储,可以彻底删除,日志脱敏 | §3.3、§3.11 |
| T1 国产模型 | 业务代码只声明能力,供应商白名单由运维配置,用户不能填模型地址 | §3.6 |
### 1.3 从参考项目吸取的教训
详见 `ref-bid-engine-analysis.md`,下表是对照结果:
| 标擎的问题 | 本项目的做法 |
|---|---|
| 解析、审核、生成各存一份数据,靠复制快照衔接 | 一份项目检查清单贯穿全流程,要求项编号稳定(§3.8) |
| 只核对证据编号,不核对引文内容;审核证据不校验 | 两道证据校验,适用于所有 AI 结论(§3.6) |
| 用户可以填任意模型地址;API Key 明文存库 | 供应商白名单由运维配置;密钥加密存储(§3.6、§3.11) |
| 自研 Redis 队列,取出任务和标记状态分两步,会丢任务 | 任务状态以数据库为准,入队和建任务在同一个事务里(§3.4) |
| 租户隔离靠每个接口手写条件,传 0 就不过滤 | 数据访问层强制带租户上下文,再加数据库行级策略兜底(§3.3) |
| 登录密钥写死在源码里,开发开关能直接拿到超级管理员身份 | 密钥只从环境注入;调试开关在生产环境下拒绝启动(§3.11) |
| 重跑后人工确认丢失;已处理状态按列表顺序号存 | 人工确认挂在稳定编号上,重跑时按编号对齐(§3.8) |
| 提示词写死在代码里,没有版本号,没有评测集 | 提示词单独存成带版本号的文件,有评测集,CI 跑回归(§3.6) |
| 接口文件 1500 行、组件 3200 行;没有测试 | 分层约定、文件规模上限、测试是合并的前提(§8) |
## 2. 总体架构
### 2.1 为什么不做微服务
用户要求重点考虑微服务。结论是 MVP **不拆微服务,做模块化单体**,理由有三点:
1. **团队小**:微服务带来的服务间调用、分布式事务、链路排查、版本兼容,都是纯成本。
2. **私有化交付**:每多一个服务,客户现场就多一份部署、升级、排障的负担。政企客户的运维往往只会 Docker。
3. **核心流程要求强一致**:「预扣额度 + 建任务 + 入队」、「改清单 + 写审计」都必须在一个数据库事务里完成,拆开后就得上分布式事务。
模块化单体不等于一个进程:代码按业务模块划分边界(§3.2),**按负载特性拆成几个进程独立部署和扩容**(§2.2)。以后真要拆服务时,模块边界就是拆分线。
### 2.2 进程与部署单元
```mermaid
flowchart LR
subgraph client[客户端]
B[浏览器]
D[桌面客户端外壳<br/>以后]
end
subgraph edge[入口]
N[web<br/>nginx:静态页面 + 反向代理]
end
subgraph app[应用(同一个 Python 镜像)]
A[api<br/>FastAPI]
W[worker<br/>解析 / 检查 / 报告]
end
subgraph aux[辅助服务]
G[converter<br/>Gotenberg]
O[ocr<br/>PaddleOCR]
end
subgraph data[数据]
P[(PostgreSQL<br/>业务数据 + 任务队列)]
S[(对象存储 / 本地磁盘)]
V[(Valkey<br/>限流 / 推送 / 缓存)]
end
L[[大模型<br/>国产云端 API / 客户自部署]]
B --> N
D --> N
N --> A
A --> P
A --> S
A --> V
W --> P
W --> S
W --> V
W --> G
W --> O
W --> L
A -. SSE 进度 .-> N
```
| 单元 | 镜像 | 说明 | 扩容方式 |
|---|---|---|---|
| web | `bid-doctor-web` | nginx 托管前端静态文件,并把 `/api` 反向代理到 api | 几乎不需要扩 |
| api | `bid-doctor-app` | 接口服务,只做请求处理和短事务,不跑重活 | 按请求量扩 |
| worker | `bid-doctor-app`(同一个镜像,启动命令不同) | 跑解析、检查、报告等后台任务;可以按队列拆成 `worker-parse` 和 `worker-light` | 按排队长度扩 |
| converter | 官方 Gotenberg 镜像 + 字体层 | Word 转 PDF、HTML 转 PDF;**不开网络出口** | 按转换量扩 |
| ocr | `bid-doctor-ocr` | PaddleOCR 推理服务,有 CPU 版和 GPU 版两种镜像 | 按扫描件量扩 |
| PostgreSQL | 托管服务或官方镜像 | 业务数据、会话、任务队列都在这里,是**唯一的权威数据源** | 纵向扩,加只读副本 |
| Valkey | 托管 Redis 兼容服务或官方镜像 | 限流计数、验证码、进度推送、模型调用并发控制、缓存;**不持久化、不备份**,清空后系统照常运行 | 几乎不需要扩 |
| 对象存储 | 托管 OSS / 本地磁盘 | 原始文件、规范化 PDF、文字层、报告 | — |
单机私有化最少只要 7 个容器:web、api、worker、converter、ocr、postgres、valkey。文件存在本地磁盘上,不需要对象存储,也不需要 K8s。
## 3. 后端
### 3.1 语言与基础库(T2)
- **Python 3.12 以上**,用 uv 管理依赖和锁文件,用 ruff 做代码检查和格式化,用 pyright 的严格模式做类型检查,测试用 pytest。
- **FastAPI**:接口层。用 Pydantic v2 定义请求和响应模型,自动生成 OpenAPI 文档,前端再据此生成类型和客户端(§4.2)。
- **SQLAlchemy 2 + Alembic**:数据库访问层和版本化的迁移脚本;不用自动建表。
- **httpx**:调用大模型和内部服务,统一设置超时和重试策略。
- 选 Python 的代价是私有化交付会暴露源码。缓解办法见 AR7:用 Nuitka 把核心包编译成二进制,再加上授权文件校验。
### 3.2 模块划分
按业务划分模块,每个模块自己管自己的表,模块之间只能通过对方公开的服务接口调用,**不能直接读写别的模块的表**。
| 模块 | 职责 | 对应需求 |
|---|---|---|
| `identity` | 人、登录方式、企业主体(租户)、成员身份、会话、短信验证码、登录风控、权限点(§3.14) | FR-M6-04 |
| `billing` | 套餐、订阅、额度账户、额度流水 | FR-M6-03、05 |
| `project` | 项目、分包、状态流、就绪标记 | FR-M6-01、02 |
| `document` | 文件上传、格式识别、转换、文字层、OCR、出处定位 | FR-M2-01、06 |
| `checklist` | 项目检查清单、清单项、待确认区、版本、补遗合并 | FR-M2-03~10 |
| `check` | 检查批次、问题、人工确认、复查沿用 | FR-M3-01~08 |
| `rules` | 确定性规则:报价、日期、法律体系、形式要件 | FR-M3-04、06 |
| `ai` | 模型路由、提示词注册表、调用流水账、证据校验 | T1 |
| `report` | 检查报告的生成与归档 | FR-M3-09 |
| `qualification` | 最小资质库 | FR-M6-08 |
| `audit` | 审计事件 | FR-M6-07 |
| `ops` | 运营后台接口、误报 / 漏报反馈 | FR-M6-09 |
| `notification` | 站内通知(解析 / 检查完成、额度提醒、开通) | FR-M2-02、FR-M6-03 |
每个模块内部分三层:
- `api`:路由、请求校验、调用服务,一个接口通常不超过 30 行。
- `service`:业务规则和事务边界。
- `repo`:数据访问,**必须带租户上下文**。
模块之间的依赖方向用 import-linter 在 CI 里检查。
### 3.3 租户隔离
用三层防护:
1. **数据访问层强制带租户**:所有仓储方法的第一个参数都是 `TenantContext`,没有「传 0 就查全部」这种写法。跨租户查询只有运营后台能用,而且必须写审计事件。
2. **PostgreSQL 行级安全策略(RLS)兜底**:每个事务开始时设置当前租户,业务表的策略按 `tenant_id` 过滤。即使代码漏了条件,数据库也不会把别的租户的数据返回出去。
3. **测试**:每个接口都跑一条「用 A 租户的令牌访问 B 租户资源」的用例,返回必须是 404。
私有化部署只有一个租户,代码不需要改。如果客户的数据库是信创数据库、不支持行级安全策略,可以关掉第 2 层(AR3)。
### 3.4 异步任务与流水线
**原则:任务状态以 PostgreSQL 为准,队列只负责派发。**
- **建任务和入队在同一个事务里完成**:「预扣额度 → 建解析任务 → 入队」在一个数据库事务内提交。所以不会出现「扣了点但任务没进队列」或者「任务跑了但没扣点」。这是选用 PostgreSQL 做队列的主要理由(AR4)。
- **队列库用 Procrastinate**:基于 PostgreSQL 的成熟开源库,支持锁、重试、定时任务和队列优先级;不自己写队列。它靠 LISTEN/NOTIFY 及时发现新任务,所以 **worker 要直连数据库**,或者经过会话模式的连接池,不能走事务模式的连接池。
- **流水线分阶段、可续跑**:解析任务分为格式识别 → 转换 → 文字层 → OCR 补全 → 结构识别 → 抽取 → 证据校验 → 清单合并。每个阶段完成后把检查点写入数据库,失败后从失败的那个阶段续跑。每个阶段都要**幂等**:开始前先清掉这个阶段的残留产物。
- **过时结果直接丢弃**:任务带运行编号,写结果前比对;发现已经有更新的一次运行,就直接丢弃本次结果。
- **取消**:取消标记写在数据库里(权威),同时通过 Valkey 发一条消息,让 worker 立即知道。worker 在阶段之间、每批模型调用之间检查这个标记,不需要每 300ms 轮询数据库。即使消息丢了,worker 到下一个检查点也会读到数据库里的标记。
- **优雅退出**:worker 收到 SIGTERM 后不再领取新任务,等手头这个阶段跑完再退出;超时没跑完的任务由队列的租约机制自动交还给别的 worker。
- **进度推送**:worker 把进度写进数据库,再写一条事件到 Valkey stream(SK1);api 用 SSE 推给前端,断线重连时按 `Last-Event-ID` 补发。多个 api 实例都能读到。前端断线后用轮询兜底,轮询读的是数据库里的进度。
- **并发**:worker 内部用 asyncio 并发调用大模型,每个供应商、每个租户都有并发上限(§3.6)。
### 3.5 文档处理流水线
这是产品的核心能力,也是技术风险最大的地方,§10 列出了需要先验证的事项。
```
上传 → 格式识别(看文件头,不看扩展名)
→ 预处理(旧版 DOC / WPS 先转 DOCX;修正被错标成 SYMBOL 字符集的中文字体,见技术验证 S1)
→ 转换(DOCX → PDF,用 Gotenberg + LibreOffice + 按宋体行高调整过的开源字体包)
→ 转换质量检查(大段 OpenSymbol 字体、抽出的文字大量重复同一个字 → 判为转换异常,不扣点)
→ 规范化 PDF(只生成一次,存哈希,以后不再重新生成;它是出处页码的唯一依据)
→ 文字层(逐字的坐标、字体、斜体、下划线;按行、句、块切分,给出稳定编号)
→ 逐页检查覆盖:文字太少的页交给 OCR(PaddleOCR),OCR 结果并入文字层
→ 结构识别(章节、表格、分包、「标注规则说明」)
→ 交给 ai 模块抽取 → 三道校验(编号、原文、依据)→ 标注类条款规则兜底 → 并入检查清单
```
关键设计:
- **文字层是一份带编号的数据**:每一句都有编号(文档 + 页 + 序号),并记录它在原文中的字符范围和坐标框。模型只能引用句子编号;高亮时按坐标框画,直接满足「原句高亮」(FR-M2-06)。
- **格式必须保留**:PDF 里的斜体靠字体名或字形倾斜判断,下划线靠文字下方的线条判断,加粗靠字体粗细判断。转成模型的输入时,这些格式变成文本标记(例如 `<u><i>…</i></u>`),对应样本结论 F1。DOCX 原文件里的格式信息可以用来交叉校验。
- **PDF 库选择**:用 pypdfium2(Apache / BSD)和 pdfplumber(MIT)。**不用 PyMuPDF**,它是 AGPL 许可,或者要买商业授权。
- **布局分析**:S2 已验证,**不用 Docling 做主解析器**:它从 DOCX 解析能保留格式但没有坐标,从 PDF 解析有坐标但丢了格式,CPU 上 199 页要 96 秒。文字层自己从规范化 PDF 提取(格式 + 坐标都有)。Docling 的表格结构识别可以作为表格切句的候选。MinerU 历史上用 AGPL 许可,选用前要核实它当前的许可证。
- **DOCX 交叉校验**:读 DOCX 文字时要同时读 `<w:t>` 和 `<w:sym>`(WPS 文档转换后有一部分正文是符号字符),并处理样式继承(S1、S2)。
- **字体与页码**:Word 转 PDF 的分页取决于字体。招标文件常用宋体、仿宋、黑体,这些是商业字体,不能随镜像分发。替代字体会导致分页和用户在 Word 里看到的不同。处理办法:
- 出处页码统一按规范化 PDF 计,界面上说明「页码以系统转换后的 PDF 为准」。平台导出的 DOCX(例如苏采云,由 ONLYOFFICE 生成)本来就没有记录 Word 里的页数,用户看到的分页取决于他们自己的软件,所以这条是必需的。
- S1 结论:分页偏差的主因是行高。用 Noto CJK 克隆出行高 1.0em 的「BD Song / BD Hei」(OFL,改名后仍按 OFL 分发),加 fontconfig 替换规则,有真值的 5 个数据点偏差都在 ±3% 内;默认配置偏差 +17%~+37%。
- 私有化时,客户如果有正版字体,可以挂载进去。
- **沙箱**:转换服务不开网络出口,不运行宏,限制 CPU、内存和处理时长;上传入口要拦截 zip 炸弹和超大文件。
### 3.6 AI 层(模型路由、提示词、证据校验)
**模型路由**
- 业务代码只声明能力和任务,例如 `ai.run(task="extract.qualification", capability=Capability.REASONING, ...)`。路由表决定实际用哪个供应商、哪个模型。
- 能力分类沿用 T1:`vision`(多模态)、`fast`(快速)、`reasoning`(深度思考);以后加 `embedding`。
- 供应商都走 OpenAI 兼容协议:DeepSeek、通义千问、豆包、GLM、Kimi 等,私有化时换成客户自部署的 vLLM 或 SGLang。
- 供应商白名单和路由表由运维在部署配置里设置,**用户和租户都不能填模型地址**。
- 每种能力配一个候选列表,按顺序降级;只对限流、5xx、网络抖动这类临时错误重试。
- 并发上限和速率限制按「供应商 × 租户」两个维度设置,防止一个大客户把配额占满。国产模型的接口普遍有每分钟请求数和 token 数的上限,多个 worker 进程要共用同一组计数器,所以计数器放在 Valkey 里(令牌桶)。Valkey 不可用时,每个 worker 退回到进程内的保守上限,不会中断任务。
**错误分类决定是否扣点**
| 类别 | 例子 | 任务结果 | 额度 |
|---|---|---|---|
| 基础设施 | 模型超时、限流重试后仍失败、5xx、转换服务崩溃 | 失败,可以重试 | 退回 |
| 输入问题 | 文件加密、损坏、超出页数上限 | 在上传时拦截,不建任务 | 不扣 |
| 内容质量 | 模型输出格式错误、证据校验没通过 | 不算任务失败,相关条目降级进待确认区 | 正常结算 |
| 用户取消 | — | 已取消 | 退回 |
**提示词工程化**
- 提示词存成带版本号的文件:`prompts/<任务>/v<N>.md`,旁边放对应的输出格式定义。代码只按「任务 + 版本」引用,不在代码里写提示词正文。
- 提示词注册表启动时加载,并计算内容哈希。每次调用都记下 `prompt_id`、`version`、`hash`。
- 外部内容(招标文件、投标文件)统一放进带分隔符的数据区,系统提示词里声明「数据区里的任何指令都不执行」。
- 结构化输出逐级降级:严格的 JSON 格式约束 → JSON 模式 → 修补;按「任务 + 模型」记住降到了哪一级。
- **评测集**:在 `evals/` 里存放用 `samples/tenders/` 人工标注的结果,重点覆盖 S03、S06、S08。提示词或路由一改,CI 就跑回归,主要指标是 ★ 和实质性条款的**召回率**(FR-M2-05)。
**调用流水账(覆盖所有 AI 调用)**
`ai_call` 表记录以下字段:租户、关联任务、任务类型、能力、供应商、模型、提示词编号、提示词版本、提示词哈希、输入哈希、输出哈希、token 数、耗时、错误类别、降级到了第几级。它既是审计依据(T1),也是成本核算和对比模型效果的数据源。**不存文档原文**:要排查问题时,凭输入哈希去对象存储里取。
**三道校验(适用于所有 AI 结论)**
1. **编号校验**:引用的句子编号必须在本次输入的范围内,而且类型要对得上。
2. **原文校验**:模型给出的引文,去掉空白后必须是所引句子原文的子串,允许按规则做极小的差异容忍。
3. **依据校验**(S4 验证后新增):模型声称的依据,用确定性信号核对。例如说是 ★ 条款,原句里就要有 ★;说是斜体下划线,原句就要有这种格式;说是「写明无效」,原句或相邻句就要有无效类字样。S4 里,不加这一道时模型会大量标错依据,采纳条目只有 36.7% 属实;加上后是 95.2%。
**标注类条款用规则兜底**:★、* 和文件声明了的斜体下划线,都能从规范化 PDF 上确定性识别出来。这类句子必须被某个条目覆盖;模型没覆盖的,系统自动生成一条「待确认」,召回不完全依赖模型。
三道都通过,结论才能进正式清单或成为有效的问题。没通过的,按情况降级为「待确认」或「提示」。投标文件一侧同样如此:**没有证据的结论不能判为「已响应」**。
### 3.7 规则引擎(确定性检查)
能用规则判断的,不交给模型:
- **报价**:超过三层限价、大小写金额是否一致、合计是否一致、是否只有一个报价。金额只从投标文件里读取,用 Decimal 计算,系统不生成任何报价数字。
- **日期**:落款日期是否合理,资质在投标截止日是否已经过期。
- **法律体系**:先识别项目属于政府采购体系还是招标投标体系,再按体系切换术语(「投标无效」或「否决投标」)和无效情形清单(05 §2)。
- **形式**:格式文件是否齐全,固定文字有没有被改动,文件里有没有出现其他项目的名称(这一步可以用模型召回,但判定结论要走证据校验)。
规则写成 Python 代码,每条规则有编号;规则集有版本,检查批次会记下当时用的规则版本(FR-M3-02)。
### 3.8 检查清单与重跑对齐
- **清单项有稳定编号**,出处(Citation)单独建表,清单项和问题都引用它(04 §2)。
- **重新解析时不删清单**:新结果先和原有清单项对齐(按类别、所属包、原文句子编号、文本相似度)。
- 对上的:只更新 AI 给出的字段,人工确认、修改和备注都保留。
- 对不上的旧项:标为「本次解析未出现」,交给用户处理,**不静默删除**。
- 新出现的项:进待确认区或正式清单。
- **人工值和 AI 值分开存**:用户修改不覆盖 AI 原值,留着用来做误报分析。
- **清单有版本**:确认清单时生成一个不可变快照,检查批次引用的就是这个版本(04 §4.2)。
- **复查沿用**:问题按「清单项编号 + 问题类型」匹配(FR-M3-08),不用列表顺序号。
### 3.9 额度
- 额度流水只追加,余额由流水汇总得出;另存一个缓存余额,和流水在同一个事务里更新,并定期对账。
- 每笔流水都带幂等键(任务编号 + 动作),重复执行也不会重复扣点。
- 任务的三种结局(成功结算、失败退回、取消退回)都由任务状态机统一触发,退不退回按 §3.6 的错误分类表决定。
- 24 小时免扣(决策 P1):每个项目记一个「上次扣点检查时间」,是否免扣由规则判断。
### 3.10 审计
- 审计事件和业务修改**在同一个事务里写入**,审计写失败,整个操作就失败;不能像标擎那样忽略写入错误。
- 字段:操作人、租户、动作、对象类型、对象编号、修改前、修改后、时间、请求编号。
- 审计表只追加;可以加哈希链(每条记录带上一条的哈希),让篡改可以被发现,作为可选项。
- 删除项目时,按 G10 只保留不含业务内容的审计记录:「修改前 / 修改后」里的业务字段要能单独抹掉。
### 3.11 安全
- **会话**(AR8):不用长期有效的 JWT,改用服务端会话。
- 令牌是 256 位随机串,只发给客户端一次;数据库里**只存它的 SHA-256 哈希**,数据库泄露也拿不到可用的令牌。
- 会话表 `user_session` 放在 PostgreSQL 里,字段包括:令牌哈希、用户、租户、设备信息、IP、创建时间、最近活跃时间、过期时间、吊销时间。
- 每个请求按令牌哈希做一次主键查询,耗时不到 1 毫秒。「最近活跃时间」最多每 5 分钟更新一次,避免每个请求都写库。
- 放在 PostgreSQL 而不是 Redis,理由见 §6.1:能持久化、能按用户列出所有会话(用来实现「同一账号异地多设备登录时提示并限制」,FR-M6-04),删除租户时也能级联清理。
- 网页端用 HttpOnly + Secure cookie,桌面端用 Bearer 令牌并存进系统钥匙串。
- **验证码和登录限流**:短信验证码、发送频率、登录失败次数都放在 Valkey 里,带过期时间。这些数据丢了,最多是用户重新获取一次验证码。
- **身份来源可插拔**:SaaS 用手机号 + 短信验证码;私有化用账号密码,并预留 OIDC、LDAP 接入(P2 的 SSO);飞书、企业微信登录按同一接口扩展(§3.14)。
- **密钥**:只从环境变量或密钥管理服务注入,代码和镜像里一律不放。启动时如果检测到默认密钥或弱密钥,在生产环境下拒绝启动。
- **调试开关**:「跳过登录」之类的开关只在测试配置里存在;生产环境检测到这类开关会直接退出。
- **加密**:
- 传输:全链路 TLS。
- 存储:云盘和 OSS 开服务端加密。文件另做**信封加密**,每个项目一把数据密钥(资质附件用企业级密钥,DM4),删除时销毁密钥,备份里的数据也就随之不可读,用来落实 G10 的「彻底删除」。
- 模型供应商的 API Key 加密存储。
- **运营后台**:独立应用、独立登录,加 IP 白名单和二次验证,所有操作都写审计。
- **日志脱敏**:日志和监控里只出现编号和哈希,不出现文档内容、引文、金额;有专门的脱敏中间件,加上测试。
### 3.12 报告
- 报告用 HTML 模板渲染,再交给 Gotenberg 里的 Chromium 转成 PDF。字体嵌入思源宋体和思源黑体(OFL)。
- 报告生成后不再修改,存哈希;重新导出就生成一份新报告(04 §2)。
- 模板在 CI 里做文案检查:不出现「通过 / 合格 / 保证」这类字样(FR-M3-09,红线 3)。
### 3.13 文档组件:参考标擎的做法
用户要求在 Office 解析、PDF 解析、标书组装和格式转换这三块参考开源项目。下面是标擎的做法(2026-11-03 阅读源码,路径相对于 `opensource-ref/bid-engine/`)和我们的取舍。只借鉴做法,不复制代码(许可证限制,见 `ref-bid-engine-analysis.md`)。
#### Office / Word 文件
**标擎的做法**
- **转换服务单独一个容器**(`bid-engine-backend/Dockerfile.converter`、`cmd/converter/`),主后端镜像里不装 LibreOffice。
- **转换服务的工程细节**做得扎实:
- 每次转换用独立的 LibreOffice 配置目录(`-env:UserInstallation`),避免并发转换时互相锁住;
- 超时后杀掉**整个进程组**,避免残留 `soffice.bin` 孤儿进程;
- 并发上限默认 2;服务端超时(240s)短于客户端超时(300s),保证错误先由服务端明确返回;
- 上传文件时流式发送,200MB 级的文档也不会整份读进内存。
- **LibreOffice 版本有坑**:Dockerfile 注释写明,LibreOffice 7.3 和 24.2 处理部分含内嵌 OLE 对象的公文类 docx 时会崩溃,换成 26.2 才正常,所以用了 ubuntu:26.04 作基础镜像。
- 字体用 `fonts-noto-cjk`;转换失败时依次回退到 soffice、unoconv(`pkg/repo/pdf/pdf_impl.go:235-300`)。
- DOC 先转成 DOCX,再用 gooxml 按样式识别标题,用来生成标书大纲(`lib/docx/docx.go`)。
- **不保留文字格式**:解析链路里没有处理斜体和下划线。
**我们的取舍**
- **采纳**:转换服务独立部署、不开网络出口、有并发上限、整组清理进程、分层超时、流式上传。这些 Gotenberg 已经内置。
- **要核实**:Gotenberg 镜像里的 LibreOffice 版本。S1 要专门用含 OLE 对象的公文类 docx 做测试;如果版本过旧导致崩溃,就自建转换镜像,沿用上面的工程做法,并锁定 LibreOffice 版本。
- **补上标擎没做的**:DOCX 原文件用 python-docx(MIT)读取文字和格式(斜体、下划线、加粗、样式),和规范化 PDF 的文字层交叉校验(§3.5,对应样本结论 F1)。
- **字体**:Noto CJK 只解决「字能显示」,解决不了「分页一致」:宋体和 Noto 的字宽不同,会导致分页偏移。所以 S1 要比较几种替代字体的分页偏差,标擎在这一点上没有处理。
#### PDF 文件
**标擎的做法**
- **用 Docling 做解析服务**:单独一个容器 `docling-serve` v1.16.1(MIT)。
- 请求参数:输出格式选 json、md、html、text,表格识别选精确模式,OCR 可开关(引擎默认是 easyocr,可选 rapidocr)(`pkg/repo/docling/docling_impl.go:176-231`)。
- 从结果里取文本块的坐标框和表格单元格。
- **按页检查覆盖率**:平均每页文字太少,就强制开 OCR 重新解析一次(`pkg/handler/bidanalysisv3/document.go:391-454`)。
- **PDF 拆页**用 pdfcpu(Apache 2.0)。
- **另有一个不开源的「PDF 服务」**:通过 `pdf_base_url` 加应用编号和密钥调用(`pkg/repo/pdf/pdf.go`),负责解析、生成 HTML 和字符索引。这部分能力离开它就跑不起来,参考价值有限。
- **注意**:compose 里给 docling 镜像写死了 `platform: linux/amd64`。
**我们的取舍**
- **采纳**:
- 解析服务化,主应用不装模型类重型依赖;
- 按页检查覆盖率,不够就用 OCR 补;
- 从解析结果里保留坐标框。
- **调整**:
- 坐标粒度从「块」细化到「句」(§3.5)。
- 文字层自己用 pypdfium2 和 pdfplumber 提取,字体、斜体、下划线信息由我们自己掌握。
- Docling 只作为版面和表格识别的候选,在 S2 里和自研方案对比。
- OCR 用 PaddleOCR:中文效果更好,坐标也更精细。
- **信创风险**:如果用 Docling,要确认它的镜像或依赖有 arm64 版本,标擎写死 amd64 就是一个信号。S2 要顺带验证。
#### 标书的组装、转码和格式转换(V1 的 M4,现在先定方向)
**标擎的做法**
- **编辑与生成**:
- 富文本编辑器用 TipTap 3。
- 前端用 `docx`(npm 包,MIT)把编辑器内容拼成 DOCX,包括封面、目录和样式(`bid-engine-frontend/components/file-gen/docx-export.ts`)。
- 导出 PDF 时,后端再把 DOCX 交给 LibreOffice 转换(`pkg/handler/bidgen/export.go`)。
- **两遍导出回填目录页码**:
- 前端拼 DOCX 时算不出分页,所以第一遍先把 DOCX 转成 PDF,在正文区按章节标题回读每章的起始页码(`pkg/handler/bidgen/page_map.go:30`);
- 第二遍再把真实页码写进目录。
- **模板导入**:上传 DOC 模板时先转成 DOCX,再按样式拆出大纲(`template_outline.go:74`)。
- **审核报告**用 excelize 导出为多个工作表的 Excel。
- **许可证风险**:前端依赖 TipTap Pro 的私有源(`.npmrc`),Pro 扩展是**收费商业组件**。
**我们的方向**
- **组装放在服务端(Python),不放前端**。理由有三:
- 标书要套用招标文件自带的格式文件(投标函、授权书、开标一览表),本质是「按模板填充 + 合并章节 + 附上资质扫描件」,服务端更好控制;
- 网页版和桌面版的输出能保证一致;
- 组装结果还要回过头来过 M3 的检查。
- **组件候选**(都是宽松许可证,V1 立项时再做验证):
- python-docx(MIT):读写 DOCX;
- docxtpl(LGPL-2.1):在 DOCX 里用模板语法填充,许可证要评审;不行就自己基于 python-docx 写填充;
- docxcompose(MIT):合并多个 DOCX,并保留样式和编号;
- pypdf(BSD):合并 PDF,例如在标书末尾附上资质扫描件。
- **目录页码**:标擎的「两遍导出」可以用,但更好的做法是:
- 在 DOCX 里写入**真正的目录域**;
- 转 PDF 时让 LibreOffice 先更新索引再导出,DOCX 和 PDF 的页码由此一致;
- 用户用 Word 打开 DOCX 时也能自己更新目录。
- 这个做法要验证;不行再退回两遍导出。
- **编辑器**:TipTap 核心是 MIT 许可,但不依赖它的 Pro 扩展;V1 立项时再和其他编辑器对比。
- **报价**:模板里的报价位置只放空白填充位,由用户自己填写(红线 6)。
- **OFD 格式**:标擎没有处理国家标准的 OFD 版式文件(GB/T 33190)。部分政府采购平台和电子公文会用到 OFD,需要在种子用户访谈时确认是否需要支持;需要的话再评估开源的 OFD 解析和转换库。
### 3.14 账号、企业与权限:给以后的版本留结构
以后会上团队版和企业版:一个企业多个席位,按角色和项目分权限;也会支持飞书、企业微信登录,以及私有化部署的 SSO。**这些功能早期版本都不做**。这一节只定一件事:MVP 的表结构和代码分层先按多人、多登录方式来建,以后上这些功能时只加功能,不用重构。
#### 原设计的问题
原设计的租户隔离(§3.3)和建在企业上的额度池(§3.9)不用改。会导致以后要重构的有三处:
1. **用户表直接带所属企业**(04 §2)。以后一个人要能有多种登录方式、要能离职、要能加入别的企业,就得拆表、迁移数据。
2. **权限只有租户这一层**。以后加项目级权限,要回头给每个接口补检查,还要改所有列表查询。
3. **记录「谁」的字段没约定指向哪张表**。如果指向会被删除的账号,人离职以后,留痕就断了。
#### MVP 就按这个结构建
| 内容 | MVP 的样子 | 以后接入时 |
|---|---|---|
| `user_account`:一个自然人,全局表,不带 `tenant_id` | 注册时建一条 | 不变 |
| `login_identity`:登录方式,字段有来源、外部组织编号、外部用户编号;唯一键是「来源 + 外部组织 + 外部用户」 | 只有「手机号」一种来源 | 飞书、企业微信、账号密码、OIDC、LDAP 都只是新的来源 |
| `membership`:人在企业里的成员身份,字段有企业角色、状态 | 每个企业一条,角色是「所有者」;「一人只属于一个企业」(FR-M6-04)在服务层检查,不写进表结构 | 加邀请、停用,放开一人多企业 |
| `user_session` 绑定 `membership_id` | 当前租户从成员身份上取 | 切换企业就是换一个会话 |
| `project_member`:项目、用户、项目角色 | 建项目的人自动成为负责人 | 加项目成员管理、可见范围 |
| 订阅带「席位数」 | 写 1 | 按席位计费 |
| 权限点枚举 + service 层统一调用 `authz.require(ctx, 权限点, 对象)` | 所有者拥有全部权限点,检查总是通过 | 加角色、改角色和权限点的对应关系;接口代码不用改 |
| 列表查询走 repo 层统一的可见范围过滤 | 过滤条件只有租户 | 加「只有项目成员可见」的条件 |
| `IdentityProvider` 接口:授权地址 → 回调换取用户信息 → 统一的外部身份 | 只有手机号验证码一个实现 | 每种新登录方式加一个实现 |
约定:
- 业务表里记录「谁」的字段(创建人、确认人、处理人)都指向 `user_account`。人不做物理删除,离职只停用成员身份。
- 额度流水和审计事件都记操作人;审计再存一份操作人姓名快照,人离职后,报告和留痕上仍显示当时确认的人。
- `user_account` 和 `login_identity` 是 §3.3 唯一的例外:全局表,不受行级安全策略约束,只能由 `identity` 模块读写,在 import-linter 规则里写明。
- 行级安全策略只管租户这一层,项目级权限在应用层判断。
这些在 MVP 里多出来的工作只是两三张表和一层检查调用,界面上看不出任何区别。
#### 接入时再定的事
下面这些不影响表结构,到对应版本立项时再设计:
- 团队版、企业版的具体角色和权限点划分(PRD 写的是管理员 / 编标 / 只读);
- 项目可见范围的默认值、分工指派;
- 飞书、企业微信的应用形态,外部组织怎么绑定到企业,新成员是否需要批准,账号怎么合并;
- 桌面端的第三方登录回调方式;
- 个人注销和企业注销的拆分(MVP 只有一个成员,两者是同一个操作)。
## 4. 前端
### 4.1 Vue 3 单页应用(AR2,已确认)
- **为什么是单页应用、不做服务端渲染**:
- 桌面客户端:单页应用打包后就是一堆静态文件,Electron 或 Tauri 直接加载即可;服务端渲染要多带一个 Node 进程。
- 私有化交付:静态文件由 nginx 托管就行,客户现场少一个 Node 服务要运维。
- 不需要 SEO:工作台在登录之后。以后的官网是独立站点,可以用 Astro 生成静态页。
- **为什么选 Vue 3**(用户决定):政企项目的前端人才和现成案例以 Vue 为主,组件库生态成熟,私有化客户的二次开发和运维人员也更熟悉。
### 4.2 技术选型
| 用途 | 选型 | 说明 |
|---|---|---|
| 构建 | Vite + TypeScript(严格模式)+ vue-tsc | |
| 写法 | 单文件组件 + `<script setup lang="ts">` + 组合式 API | 不用选项式 API |
| 路由 | Vue Router 4 | |
| 客户端状态 | Pinia | 只放界面状态(当前选中的清单项、筛选条件等) |
| 服务端数据 | TanStack Query for Vue(`@tanstack/vue-query`) | 缓存、重试、轮询兜底;服务端数据不放进 Pinia |
| 接口类型 | openapi-typescript + openapi-fetch | 由后端的 OpenAPI 文档生成,不手写类型 |
| UI 组件 | 自己写(`packages/ui`),不引入带样式的 UI 框架(AR10,见 §4.3) | 样式只来自 `tokens.css` 和 `components.css` |
| 交互基础组件 | Reka UI(原 Radix Vue,无样式) | 对话框、下拉选择、日期选择、弹出层、轻提示等的键盘操作和无障碍 |
| 表格 | 自己写样式;需要排序、筛选时用 TanStack Table,长列表用 TanStack Virtual | 都是无样式库 |
| 样式 | `docs/design/system/tokens.css` 的变量 + 单文件组件的 scoped CSS | 不引入 Tailwind,避免和组件库、设计规范形成第三套样式体系 |
| 图标 | Lucide(`lucide-vue-next`,U3) | 不用组件库自带的图标集,全站统一 |
| PDF | pdf.js(pdfjs-dist,Apache 2.0)+ 自研高亮层 | 按页虚拟滚动,按坐标框高亮原句 |
| 表单校验 | Zod + 自写的 `useForm` 组合式函数 | 校验时机按设计规范 7.13 |
| 测试 | Vitest + Vue Test Utils(单元测试)+ Playwright(端到端测试) | |
| 代码检查 | ESLint(flat config)+ Prettier | |
### 4.3 要不要引入 UI 框架(AR10)
**结论:不引入 Element Plus 这类带样式的 UI 框架。** 视觉全部由我们自己用 Vue 单文件组件和样式表实现;只在交互复杂、无障碍要求高的少数几类组件上,用**无样式交互组件库 Reka UI** 打底。
**为什么不用带样式的 UI 框架**
带样式的框架适合「直接用它自带外观」的项目,比如内部系统。我们有一套自己的高保真设计规范,用它反而吃亏,有三个原因:
1. **核心组件它根本不提供**:出处批注、问题行、清单项、原文查看器、参数对照表、项目行、额度、任务进度(设计规范 7.2~7.11),都是产品专属组件,不管用不用框架都得自己写。框架能覆盖的只是外围的基础件。
2. **基础件的默认行为和规范冲突**,几乎每一个都要改造:
| 设计规范的要求 | 带样式框架的典型默认行为 |
|---|---|
| 没有红色危险按钮(7.1) | 提供 danger 按钮,删除确认框默认用红色 |
| 加载中按钮把文字换成「正在…」,宽度不变(7.1) | 在按钮里加转圈图标,宽度会变 |
| 必填项在标签后写「(必填)」,不用红色星号(7.13) | 默认显示红色星号 |
| 错误提示:赭色边框 + 下方赭色小字 + 「!」图标(7.13) | 红色边框,错误文字的位置和结构是固定的 |
| 轻提示 4 秒后消失,悬停暂停;页内提醒用左边线样式(7.12) | 自带一套提示样式和图标 |
| 级别靠实心、描边、无框来区分(3.3) | 标签组件只有几种固定的视觉变体 |
改造的办法是覆盖框架的内部样式(例如 `:deep(.el-input__wrapper)`),要和框架的选择器比优先级。**框架一升级、内部结构一变,这些覆盖就可能悄悄失效。**
3. **两套样式体系并存**:框架有自己的变量、尺寸档位和间距,和 `tokens.css` 并存,开发时要不断判断「该用谁的」,很容易偏离设计稿。
**为什么也不完全手写**
纯手写对外观没问题,但少数几类组件的**交互和无障碍**很难写对,这类代码写得不对,就会在键盘操作、屏幕阅读器、浮层定位上不断出边角问题:
| 组件 | 难点 | 用在哪里 |
|---|---|---|
| 对话框 | 焦点锁定、关闭后焦点回到原处、Esc 关闭、背景禁止滚动 | 删除确认、忽略原因(7.14) |
| 下拉选择、可搜索的下拉框 | 键盘上下选择、输入即搜索、浮层定位和翻转 | 分包选择、资质类型 |
| 日期 / 日期时间选择 | 日历键盘导航、中文本地化、日期范围 | 关键日期修改、资质有效期 |
| 弹出层、提示气泡、下拉菜单 | 定位、碰撞检测、悬停延迟、外部点击关闭 | 出处预览、行内更多操作 |
| 轻提示 | 队列、悬停暂停、读屏播报 | 操作结果(7.12) |
| 标签页、复选框、单选、开关 | 方向键切换、ARIA 状态 | 清单分类、批量勾选 |
**中间方案:无样式交互组件库(headless)**
这类库只提供行为、键盘操作和无障碍属性,**不带任何样式**;外观完全由我们自己写,和设计规范没有冲突。
| 候选 | 最新版本 | 最近发版 | npm 周下载 | 许可证 | 判断 |
|---|---|---|---|---|---|
| **Reka UI**(原 Radix Vue) | 2.11.0 | 2026-10-05 | 约 285 万 | MIT | **推荐**:组件最全(对话框、下拉选择、可搜索下拉框、日期选择、弹出层、菜单、轻提示、标签页等),Nuxt UI 和 shadcn-vue 都基于它 |
| Ark UI(Vue 版) | 5.40.0 | 2026-10-29 | 约 4 万 | MIT | 备选:基于状态机,质量好,但 Vue 社区规模小 |
| Headless UI(Vue 版) | 1.7.23 | 2024-09-09 | — | MIT | 不选:两年没有发版,组件也少 |
(2026-11-03 查询 npm。shadcn-vue 也基于 Reka UI,但它依赖 Tailwind,和我们「只用 tokens.css」的约定冲突,所以不用。)
**最终方案**
```
packages/ui/
tokens.css、base.css ← 来自 docs/design/system/,作为唯一的样式来源
基础件(自己写,纯 Vue + 样式表)
Button、Tag、SeverityTag、Card、Alert、Input、Textarea、Field、
Table、Pagination、Empty、Skeleton、Progress、Steps……
交互件(Reka UI 打底,外观自己写)
Dialog、Select、Combobox、DatePicker、Popover、Tooltip、
DropdownMenu、Toast、Tabs、Checkbox、RadioGroup、Switch
业务组件(自己写)
Citation(出处)、FindingRow(问题行)、ChecklistItem、
PdfViewer(pdf.js + 高亮层)、ParamCompareTable、ProjectRow、
QuotaMeter、TaskProgress、AiMark……
```
其余配套库也都选无样式的:
- **表格**:项目列表、清单、问题列表都是自己写的表格样式。需要排序、筛选、列宽调整时,用 TanStack Table(无样式);长列表用 TanStack Virtual 做虚拟滚动。
- **表单**:用 Zod 定义校验规则,配一个自写的 `useForm` 组合式函数,实现设计规范 7.13 的校验时机(离开输入框时校验、提交时汇总)。表单变复杂后再评估 VeeValidate。
- **图标**:Lucide(`lucide-vue-next`,U3)。
- **浮层定位**:Reka UI 内置了 Floating UI,不用另外引入。
**工作量评估**
- 设计规范第 7 节和原型 `docs/prototype/` 已经把样式和结构定好了,`components.css` 可以直接拿来用。所以基础件和交互件主要是「把现有的样式封装成 Vue 组件」,预计 **2~3 人周**,并且可以和页面开发并行。
- 用 Element Plus 起步看似更快,但要改造的组件和要写的覆盖样式加起来,加上后续升级的维护,不会更省;而业务组件那部分工作量,两种方案完全一样。
- 质量保证:每个组件写一个预览页,继承现有的 `components.html`,作为视觉对照和回归检查;交互件补上键盘操作的单元测试。
**运营后台**:不在这一阶段讨论,等做运营后台时再定。它是独立应用(`apps/admin`),用不用带样式的框架都不影响用户端。
### 4.4 应用划分
- `apps/web`:用户端(工作台、读标、查标、额度、资质库)。
- `apps/admin`:运营后台,单独构建、单独部署,私有化时不交付;技术选型等做到时再定。
- `packages/ui`:基础件、交互件(Reka UI 打底)和业务组件,样式只来自设计规范,见 §4.3。
- `packages/api-client`:生成的接口客户端。
- `packages/platform`:平台能力的抽象层,见 §5.2。
### 4.5 代码规模约定
吸取参考项目的教训:单文件组件超过 400 行要拆分,页面组件只负责组合;服务端数据由 TanStack Query 管理,不在页面里堆一堆 `ref` 和定时器。
## 5. 桌面客户端(T3:连服务端的客户端外壳)
### 5.1 形态
桌面端 = 本地外壳 + **随安装包附带的同一套前端静态文件** + 连接 SaaS 或私有化服务器。外壳不内嵌后端、OCR 和模型。
外壳只额外提供几样本地能力:
- 选择文件或文件夹、拖入大文件;
- 系统通知;
- 报告存到本地并打开;
- 登录令牌存进系统钥匙串;
- 自动更新(SaaS)或离线升级包(私有化);
- 首次启动时选择服务器地址(SaaS 或私有化服务器)。
### 5.2 现在就要守住的约束(保证以后打包成本低)
1. **前端不能依赖服务端渲染**,必须是纯静态构建产物(§4.1)。
2. **接口地址可配置**:不能写死「同源 `/api`」,从运行时配置里读取。网页版默认同源,桌面版用用户选择的服务器地址。
3. **鉴权同时支持 cookie 和 Bearer 令牌**(§3.11)。桌面外壳以自定义协议加载页面时,跨域 cookie 不可靠。
4. **所有平台能力都走 `packages/platform` 抽象层**:文件选择、下载保存、通知、打开外部链接、剪贴板。现在只实现网页版,以后加桌面版实现,业务代码不用改。
5. **不依赖浏览器专有能力**:PDF 预览用 pdf.js 自己渲染,不用浏览器内置的 PDF 查看器;字体全部自托管(已经这样做了);不依赖第三方 CDN。
6. **路由在 `file://` 或自定义协议下也能用**:路由基础路径可配置。
7. **大文件上传**:用分片上传、支持断点续传,接口设计成和平台无关的样子。
### 5.3 外壳选型(AR6,到时再定)
| | Electron | Tauri 2 |
|---|---|---|
| 渲染引擎 | 自带 Chromium,各平台显示一致,和网页版测试的 Chrome 一样 | 用系统的 WebView:Windows 上是 WebView2,macOS 上是 WebKit,Linux 上是 WebKitGTK,各平台有差异 |
| 安装包大小 | 约 100MB | 约 10MB |
| 信创 Linux(UOS / 麒麟) | 有 arm64 构建;LoongArch 要靠社区构建,需要实测 | 依赖系统自带的 WebKitGTK 版本,需要实测 |
| 本地能力开发 | Node.js | Rust |
**倾向 Electron**:读标和查标高度依赖 pdf.js 渲染和精确高亮,渲染一致性比安装包大小更重要;团队也不必再学 Rust。如果到时信创环境对体积敏感,或者 WebKitGTK 实测没问题,再考虑 Tauri。因为有 §5.2 的约束,两者切换的成本都不高。
**估计工作量**:外壳 PoC 约 1 周,加上代码签名(Windows EV 证书、Apple 公证)、自动更新和信创打包,正式版约 3~4 周。
## 6. 配套组件
**许可证策略**:MIT、BSD、Apache 2.0、MPL、OFL 可以直接用。AGPL、SSPL、BSL 以及商业许可要先评审,因为私有化交付属于「分发」。CI 里做依赖许可证扫描,并生成 SBOM(软件物料清单)。
| 组件 | 选型 | 许可证 | SaaS | 私有化 | 不选什么、为什么 |
|---|---|---|---|---|---|
| 数据库 | PostgreSQL 16 以上 | PostgreSQL License | 云厂商托管实例 | 官方镜像,或客户已有的实例 | MySQL:行级安全策略、JSONB、LISTEN/NOTIFY 都不如 PostgreSQL;而且主流信创数据库(人大金仓、openGauss / GaussDB、瀚高)多数兼容 PostgreSQL |
| 任务队列 | Procrastinate(基于 PostgreSQL) | MIT | 同左 | 同左 | Celery + Redis:入队和数据库事务无法原子提交;自研队列:标擎的教训 |
| 缓存、限流、推送 | Valkey 8(§6.1) | BSD | 托管的 Redis 兼容服务 | 官方镜像,不持久化 | Redis:7.4 以后改了许可证(RSAL / SSPL,Redis 8 又加了 AGPL),私有化分发有风险;Valkey 是 Linux 基金会维护的分支,协议兼容 |
| 对象存储 | S3 协议;本地磁盘驱动 | — | 云厂商的 OSS / COS / OBS | 单机用本地磁盘;集群用客户已有的 S3,或 SeaweedFS(Apache 2.0) | MinIO 社区版:2025-10 起不再发布镜像和二进制,随后停止维护,许可证还是 AGPL |
| 文档转换 | Gotenberg(LibreOffice + Chromium) | MIT(LibreOffice 是 MPL) | 同左 | 同左 | 自己封装 LibreOffice:并发和稳定性要自己处理。如果 Gotenberg 里的 LibreOffice 版本太旧,就按标擎的做法自建转换镜像(§3.13) |
| Office 读写 | python-docx、docxcompose;V1 再评估 docxtpl | MIT、MIT、LGPL | — | — | 标擎在前端用 `docx` 拼文件:模板填充和网页版、桌面版输出的一致性都不好控制(§3.13) |
| OCR | PaddleOCR | 历史上是 Apache 2.0,以仓库为准 | CPU 版,量大时上 GPU;也可以用多模态大模型 | CPU 或 GPU 版 | 云 OCR 接口:私有化时用不了 |
| PDF 处理 | pypdfium2、pdfplumber | Apache / BSD、MIT | — | — | PyMuPDF:AGPL |
| 反向代理 | nginx | BSD | 同左,前面再放云负载均衡 | 同左 | Caddy 也行,但政企运维更熟悉 nginx |
| 监控 | OpenTelemetry + Prometheus + Grafana + Loki | Apache / AGPL | 自建或用云厂商的监控 | **不随包交付**:只暴露指标接口和日志文件,由客户接入自己的监控 | — |
| 错误追踪 | GlitchTip 或云厂商服务 | MIT | 内部使用 | 不交付 | Sentry 自部署版是 FSL 许可,内部使用没问题,但不分发 |
### 6.1 要不要引入 Redis(AR4 复议)
v0.1 建议「MVP 不引入 Redis」。用户追问了两点:会话令牌存在哪里?不引入 Redis 到底能省多少?重新评估后的结论是:**引入 Valkey,但只让它存丢了也没关系的数据。**
**先把两件事分开。** v0.1 的建议里混了两个决定:
1. **任务状态和队列放在 PostgreSQL**:「预扣额度 + 建任务 + 入队」在一个事务里原子提交。这是额度红线的保障,**保留不变**。
2. **整个系统不要 Redis**:这一条才是复议的对象。
第 1 条的收益和有没有 Redis 无关。把两者分开看,「不要 Redis」本身能省下的东西很少。
**不引入 Redis 能省多少**:
| 方面 | 能省下的 | 实际分量 |
|---|---|---|
| SaaS 成本 | 一个小规格的托管 Redis 实例,每月几百元 | 小 |
| SaaS 运维 | 托管服务基本免运维 | 很小 |
| 私有化 | 少一个容器(内存约 100MB);只要不让它存权威数据,就不用备份,也不用做高可用 | 小 |
| 代码 | 少一个客户端库 | 很小 |
**不引入 Redis 要付出的代价**(MVP 里确实会遇到的需求):
| 需求 | 只用 PostgreSQL 怎么做 | 问题 |
|---|---|---|
| **模型接口限速**:国产模型普遍限制每分钟请求数和 token 数,多个 worker 要共用计数器 | 用表加行锁或咨询锁模拟令牌桶 | 高频写入,锁竞争重,实现别扭 |
| **短信防刷、登录限流** | 计数表按时间窗口 upsert | 攻击流量会直接打到主库上 |
| **SSE 进度推送到多个 api 实例** | LISTEN/NOTIFY | 每个实例要单独占一条长连接;云厂商的托管 PostgreSQL 前面常有连接池代理,事务模式下用不了 LISTEN |
| 验证码、短期缓存 | 带过期时间字段的表,再加定时清理 | 能做,但要自己处理过期 |
**结论**:不引入 Redis 省下的只有「一个轻量容器」;而限速、防刷、多实例推送这三件 MVP 必做的事,用 PostgreSQL 做都很别扭。**值得为它付出一个容器的代价**,前提是守住下面的边界。
**Valkey 的使用边界**:
| 放在 Valkey | 放在 PostgreSQL(权威) |
|---|---|
| 模型接口的限速计数器、并发信号量 | 用户、租户、会话 |
| 短信验证码、发送频率、登录失败计数 | 项目、清单、问题、审计 |
| 进度和取消的推送通知 | 任务状态、进度、取消标记 |
| 短期缓存(例如规则配置) | 额度账户和流水 |
| | 任务队列(Procrastinate) |
判断标准:**Valkey 被清空后,系统照常运行,最多是用户重新获取一次验证码、限速计数从零开始**。所以 Valkey 不开持久化、不做备份;私有化时就是一个普通容器。
**会话为什么放在 PostgreSQL 而不是 Valkey**(回答「令牌存在哪里」):
- **持久**:Valkey 一重启,所有人就都掉线了;放在数据库里没有这个问题。
- **要做关系查询**:「同一账号异地多设备登录时提示并限制」要按用户列出所有会话;运营后台要查登录记录;删除租户时要级联清理。这些都是关系型操作。
- **性能不是问题**:每个请求一次主键查询,不到 1 毫秒;B 端的并发量远远够用。真要优化,再在 Valkey 里加一层短期缓存,并在吊销会话时主动清掉。
具体设计见 §3.11。
### 6.2 不引入的组件
不引入的组件和理由:
- **Elasticsearch**:投标文件里的检索范围只是单个项目的几百页,在内存里用 BM25 加 jieba 分词就够了。
- **独立向量库**:MVP 不需要,V1 的知识库先用 pgvector。
- **Kafka、RabbitMQ**:消息量太小,用不上。
## 7. 部署与运维
### 7.1 一套镜像,三种形态
| 形态 | 编排 | 数据库、存储 | 用在 |
|---|---|---|---|
| SaaS 内测(T4) | Docker Compose,1~2 台云服务器 | 托管 PostgreSQL + 托管对象存储 | 种子用户阶段;顺带打磨私有化的 Compose 交付 |
| SaaS 商用 | 托管 K8s + Helm chart | 托管 PostgreSQL(高可用)+ 托管对象存储 | 公测以后 |
| 私有化 | 小客户用 Compose 单机;有 K8s 的客户用 Helm | 自带 PostgreSQL + 本地磁盘 / 客户的 S3 | P2 企业版 |
- **Compose 和 Helm 用同一批镜像、同一份配置结构**。配置项定义只写一份,用来生成 `.env` 模板和 Helm 的 `values.yaml`。
- **CI 每次都要验证两种编排**:在 CI 里用 Compose 起一整套服务跑冒烟测试;Helm chart 在 kind 集群里做安装测试。
- **镜像从第一天起就同时构建 amd64 和 arm64**:鲲鹏、飞腾这类信创服务器都是 arm64。
### 7.2 配置与发布
- 配置全部来自环境变量,启动时用 Pydantic Settings 校验,缺项或者值不合法就直接退出。
- **数据库迁移**作为一个一次性任务,在新版本上线前执行;采用「先扩后缩」的方式(先加列,再切换代码,最后删旧列),支持滚动发布。
- 健康检查分两种:存活检查(进程活着)和就绪检查(数据库能连上、迁移版本一致)。
### 7.3 私有化交付包
- **离线安装包**:所有镜像(`docker save` 导出)+ Compose 文件或 Helm chart + 安装脚本 + 校验和 + 签名。字体、OCR 模型都打进镜像,安装时不需要联网。
- **授权文件**:用 Ed25519 签名,内容包括客户名称、有效期、可用功能;离线校验,过期后系统改为只读。
- **代码保护**(AR7):私有化镜像里的 Python 核心包用 Nuitka 编译成二进制,提示词文件加密打包。
- **大模型**:路由层改指向客户自部署的服务(vLLM、SGLang、Xinference 上跑的 Qwen、DeepSeek 等),或者客户批准使用的云端接口。
- **运维脚本**:备份(`pg_dump` + 文件目录)、恢复、升级(含迁移)、一键导出诊断包(日志、版本、配置,自动脱敏)。
### 7.4 信创适配(企业版可选,P2)
- **CPU 和操作系统**:鲲鹏、飞腾(arm64)的镜像从第一天就构建。龙芯(LoongArch)要按需评估,难点是 Python 依赖在 loong64 上有没有现成的 wheel 包(龙芯有自己的 PyPI 镜像,需要实测)。操作系统是麒麟或 UOS,容器运行时用 Docker 或 iSulad。
- **数据库**:核心 SQL 只用 PostgreSQL 的常见子集,不依赖小众扩展,确保能迁到人大金仓、openGauss、瀚高。行级安全策略、LISTEN/NOTIFY、Procrastinate 在这些数据库上的兼容性,等第一个信创客户出现时专门验证(AR3)。
- **中间件**:如果客户要求用东方通、宝兰德这类国产 Web 中间件,只把 nginx 那一层换掉。
### 7.5 可观测性
- 用 OpenTelemetry 打通 api → worker → 大模型调用的完整链路。
- **核心指标**:
- 各解析阶段的耗时,P95 对照 10 分钟的目标;
- 队列长度;
- 各模型供应商的错误率、耗时和 token 用量;
- 证据校验的通过率,它下降意味着提示词或模型出了问题;
- 额度对账的差异。
- 日志统一用 JSON 格式,带请求编号、租户编号、任务编号,**不带文档内容**(§3.11)。
### 7.6 备份与容灾
- SaaS:托管 PostgreSQL 开时间点恢复(PITR);对象存储开版本控制。RPO、RTO 的目标在商用前确定。
- 删除项目时同步清理对象存储,并销毁对应的数据密钥(§3.11),保证备份里的数据也无法再读取。
## 8. 代码仓库与工程规范
采用单一仓库(monorepo),前端用 pnpm workspace,后端用 uv workspace:
```
apps/
web/ # 用户端单页应用
admin/ # 运营后台
desktop/ # 桌面外壳(以后再建)
packages/
ui/ # 设计规范组件库
api-client/ # 由 OpenAPI 文档生成的接口客户端
platform/ # 平台能力抽象层(网页版 / 桌面版)
server/
src/bid_doctor/
identity/ billing/ project/ document/ checklist/ check/
rules/ ai/ report/ qualification/ audit/ ops/
core/ # 配置、数据库、租户上下文、错误、日志
migrations/ # Alembic 迁移脚本
tests/
services/
ocr/ # PaddleOCR 服务封装
prompts/ # 带版本号的提示词与输出格式定义
evals/ # 评测集(标注数据 + 评测脚本)
deploy/
compose/ # Docker Compose
helm/ # Helm chart
offline/ # 私有化离线包构建脚本
docs/ # 现有文档
```
- **合并代码的前提**:类型检查通过、单元测试通过、租户隔离用例通过;改了提示词或路由的,还要跑评测回归。
- **测试分层**:
- 规则引擎和证据校验用单元测试覆盖,要求接近 100%;
- 接口用集成测试,跑在真实的 PostgreSQL 上:本地连 Compose 起的数据库,CI 用服务容器,每次测试会话重建测试库(SK2);
- 关键流程用端到端测试:上传 → 解析 → 检查 → 导出。
- **大模型在测试里用录制回放**,CI 不调用真实模型;评测回归单独跑,需要真实模型。
## 9. 关键数据设计要点
物理建模在下一步细化,这里先定原则:
- 主键用 **UUIDv7**:按时间有序、不可猜测;私有化环境和 SaaS 之间迁移数据时也不会冲突。
- 所有业务表都带 `tenant_id`、`created_at`、`updated_at`;时间一律用 `timestamptz`。例外只有 `user_account` 和 `login_identity` 两张全局表(§3.14)。
- 业务表里记录「谁」的字段(创建人、确认人、处理人)都指向 `user_account`,不指向成员身份;人不做物理删除。
- 出处(Citation)存:文档编号、规范化 PDF 的哈希、页码、句子编号、字符范围、坐标框数组(`quads`)。
- AI 值和人工值分开存(§3.8);状态字段用枚举,不用自由文本。
- 文字层(逐字坐标、格式、句子切分)存在对象存储里,按页分片;**句子不进数据库**(DM1),数据库只存页摘要和出处快照(原句、坐标框)。
- 完整的表结构见 `data-model.md` 和 `schema-draft.sql`。
## 10. 编码前要做的技术验证
| # | 验证内容 | 通过标准 | 影响 |
|---|---|---|---|
| S1 | Word 转 PDF 的保真度:用 10 份样本,加上含内嵌 OLE 对象的公文类 docx,比较替代字体下的分页偏差,并核实 Gotenberg 里的 LibreOffice 版本 | 转换不崩溃;分页偏差可接受,或者有可用的字体替换表 | §3.5、§3.13 |
| S2 | 在规范化 PDF 里识别斜体和下划线,并和 DOCX 原文件的格式交叉校验;Docling 和自研方案对比,同时确认 Docling 有没有 arm64 版本 | S06、S08 的实质性条款能全部识别出来 | FR-M2-05、07 F1 |
| S3 | 原句级的坐标切分和高亮 | 抽查 50 条,位置都对 | FR-M2-06 |
| S4 | 国产模型的结构化输出和证据校验:在 S01–S10 上测 ★ 和实质性条款的召回率,同时测成本 | 召回率目标在验证时定;同时给出 D2 额度定价所需的单次成本 | FR-M2-05、D2 |
| S5 | PaddleOCR 处理扫描件:中文准确率、坐标精度、CPU 上的耗时 | 300 页在 10 分钟内 | FR-M2-02 |
| S6 | 用多模态模型检测签章「位置与有无」 | 给出准确率,决定 G12 怎么做 | FR-M3-04 |
| S7 | Procrastinate 的稳定性:worker 被杀掉、并发领取任务、优雅退出 | 不丢任务、不重复执行 | §3.4 |
S1–S4 风险最高,建议先做;S8(桌面外壳 PoC)等桌面版立项时再做。
**S1–S4 已于 2026-11-03 完成**,详见 `tech-validation.md`,代码在 `spikes/`。结论:
| # | 结论 | 关键数字 |
|---|---|---|
| S1 | 可行,但要加两步:修正 WPS 错标的字体字符集,换成按宋体行高调整过的开源字体。Gotenberg 有 arm64 镜像,自带 LibreOffice 26.8.1;10 份文件都没有崩溃 | 默认配置偏差 +17%~+37%,S07 正文空白;调整后有真值的 5 个数据点都在 ±3% 内。本批样本没有含 OLE 嵌入对象的文件,未测 |
| S2 | 可行,在规范化 PDF 上识别格式,不需要 Docling | 斜体且下划线:4 份文件按字符 100% 一致 |
| S3 | 正文可行;表格内和页眉页脚带要改进切句 | 几何回读 300/300;文本保真:正文 97.5%,表格 78% |
| S4 | 可行,加上第三道依据校验和规则兜底 | ★ 召回 93.5%(扣除银标准噪音后约 96.6%),斜体下划线 99.8%;采纳条目 95.2% 属实;单项抽取每份约 0.15 元(DeepSeek Flash,高峰价) |
据此建议:FR-M2-05 标注类条款召回 ≥ 99%(规则兜底 + 模型),采纳条目准确率 ≥ 95%;FR-M2-06 出处页码正确率 100%,原句高亮准确率正文 ≥ 98%、表格 ≥ 95%(`tech-validation.md` §5)。
## 11. 架构决策清单
| # | 事项 | 结论 | 状态 |
|---|---|---|---|
| AR1 | 架构形态 | 模块化单体 + 按负载拆进程,不拆微服务(§2.1) | 已确认 |
| AR2 | 前端框架 | **Vue 3 + TypeScript + Vite 单页应用**,不做服务端渲染(§4.1) | 已确认(用户改为 Vue 3) |
| AR3 | 数据库 | PostgreSQL,并以兼容 PostgreSQL 的信创数据库作为迁移路线;行级安全策略作为第二层隔离,在不支持它的信创数据库上可以关掉 | 已确认 |
| AR4 | 任务队列与 Redis | 任务状态和队列放在 PostgreSQL(Procrastinate);**引入 Valkey,只存丢了也没关系的数据**(限速、验证码、推送、缓存);会话放在 PostgreSQL(§6.1) | 已确认(修订后) |
| AR5 | 对象存储 | S3 协议 + 本地磁盘驱动;不用 MinIO | 已确认 |
| AR6 | 桌面外壳 | 倾向 Electron,桌面版立项时再定;现在守住 §5.2 的 7 条约束 | 已确认 |
| AR7 | 私有化代码保护 | 用 Nuitka 编译核心包 + 签名授权文件 + 提示词加密 | 已确认 |
| AR8 | 会话机制 | 服务端会话(可吊销),令牌只存哈希;网页端用 cookie,桌面端用 Bearer 令牌 | 已确认 |
| AR9 | 仓库组织 | 单一仓库(monorepo) | 已确认 |
| AR10 | UI 组件 | **不引入带样式的 UI 框架**:视觉自己写,复杂交互用 Reka UI(无样式)打底;表格、表单也用无样式方案(§4.3)。运营后台另议 | 已确认 |
| AR11 | 身份模型 | 拆成人(`user_account`)、登录方式(`login_identity`)、成员身份(`membership`)三层,MVP 就按这个结构建;「一人只属于一个企业」在服务层限制(§3.14) | 已确认 |
| AR12 | 权限检查点 | MVP 就建 `project_member` 表,service 层统一调用 `authz.require`,列表查询统一按可见范围过滤;具体角色划分到团队版再定(§3.14) | 已确认 |
## 12. 下一步
1. 做技术验证 S1–S4,结论写进本文件,并据此确定 FR-M2-05、FR-M2-06 的目标值。
2. ~~物理数据建模~~:✅ 已完成,见 `data-model.md`、`schema-draft.sql`(DM1–DM7 已确认)。
3. ~~定接口契约~~:✅ 已完成,见 `api-design.md`、`openapi.yaml`(API1–API5 已确认)。
4. ~~搭工程骨架~~:✅ 已完成,见 §13 和 AGENTS.md「工程」一节。
## 13. 工程骨架的落地记录(2026-11-03)
搭骨架时按代码实际情况做了下面这些调整或补充。SK1、SK2 改了上文的写法,2026-11-04 已确认(decisions.md C1–C11、SK1–SK2);其余是实现细节,记在这里备查。
| # | 事项 | 做法 | 状态 |
|---|---|---|---|
| SK1 | 进度推送(§3.4) | 用 **Valkey stream** 代替发布 / 订阅:一个结构同时解决实时推送和断线补发(按 `Last-Event-ID` 续读),多个 api 实例都能读到。Valkey 丢了也只是没有推送,前端轮询兜底。发布用的客户端设短超时、SSE 连接池设上限(2026-11-04 补) | 已确认 |
| SK2 | 接口集成测试(§8) | 不用 testcontainers,改为连 Compose 起的 PostgreSQL,每次测试会话重建 `bid_doctor_test` 库;CI 里用 GitHub Actions 的服务容器。更快,本地和 CI 走同一套配置。CI 直接跑 `init-roles.sh`、加 Gotenberg(2026-11-04 补) | 已确认 |
| SK3 | 建任务和入队在同一个事务里(§3.4) | Procrastinate 的 `defer` 用的是它自己的连接,进不了我们的事务,所以直接调用它的 SQL 函数 `procrastinate_defer_jobs_v1`(`core/queue.py`);它的表放在 public schema。pyproject 锁定了大版本,升级时要核对 | 已实现 |
| SK4 | 上传会话(§5.2 第 7 条) | 上传会话的元数据放在 Valkey(24 小时过期),分片按企业级数据密钥加密后写进对象存储;挂到项目上时换成项目密钥 | 已实现 |
| SK5 | ORM 模型 | 表结构以迁移为准,ORM 模型只做列映射、不声明外键,所以写入顺序要自己 `flush` 保证(测试里踩到过两次) | 编码约定 |
| SK6 | 文字层 | 有的 PDF(如 Chromium 生成的)把汉字映射成康熙部首码位(「目」成了 U+2F6C),引文校验会对不上;文字层把康熙部首和部首补充区换回常用汉字,其他字符不动 | 已实现 |
| SK7 | 斜体下划线(07 F1) | 只有文件的「标注规则说明」声明过斜体下划线标注,才按它认定实质性条款;没声明的文件里,标题、表头也常是斜体或下划线(S02 实测踩到) | 已实现 |
| SK8 | 登录验证码 | 登录和注册共用一个验证码;会话建好之后才作废,用户确认挤下线(API3)或发现「还没注册」时不用重新获取 | 已实现 |
| SK9 | 读标抽取(2026-11-03 补) | 五类清单(资格、无效 / 实质性、评分、日期与递交、技术参数)加项目基本信息各一个抽取任务、一份提示词(`prompts/extract.*`),分块并发调用;每类都过三道校验,第三道按类别核对:实质性条款核对标注或「无效」字样,其他类别核对日期、分值、参数值、金额在原文里找得到,是 / 否字段核对说法。各任务抽到同一条要求时按首句合并 | 已实现 |
| SK10 | 文字层 v2 与结构识别 | 页眉页脚带(页码、各页重复的行)单独成句,不并进正文;标记写到页尾的句子,和下一页首句接续成一条要求(出处两页各一条);最小结构识别:十进制分级编号和冒号引出的列表找上级条款,上级条款写明无效情形的,下面的条目照样认定。文字层 v1、v2 的句子编号不同,规范化 PDF 不变 | 已实现 |
| SK11 | 待确认审计后的修正(2026-11-03) | 国动样本 73 条待确认里多数是好结果被校验误拦,逐条审计后按「规则只修真正的缺陷、不迁就模型的错误输出;模型输出不合理的改提示词」处理。**文字层 v3**:两端接竖线的横线是表格框线,不算下划线(功能表整格被误认成斜体下划线条款);句号后紧跟的右括号、右引号并进同一句(切句 s3-v3,句子编号和 v2 不同)。**结构识别**:中文编号小节(「三、……否则投标无效」)下的 1. 2.,以冒号结尾的编号条目下的句子;依据核对沿上级链往上找。**校验**:比对引文前去掉我们自己加进输入的格式标记和〔…〕附注;不足 8 个字的短句整句复制时句末标点可省;取值核对认带量词的中文数字、同一行的单元格和评分因素标题;补「投标(响应)无效」「不能通过资格审查」「没有实质性响应」「拒收」等写法。引文最短 8 个字、编号必须对、证据要全部通过,这些不放宽。**提示词**:substantive v3、qualification / scoring / schedule / tech_param v2,统一证据规则(引文复制整句、并列多项不拆、跨页只引上一行、数字写阿拉伯数字),除实质性条款外输入改成纯文本;资格不收附件范本和价格扣除条件,日期不收合同条款,技术参数一句一条。**模型路由**:`BD_AI_MODELS` 按提示词声明的能力选模型,没配的用 `BD_AI_MODEL`。离线评测工具 `evals/run_extract.py` | 已实现 |
| SK12 | 参考 BidEngine 的五项改进(2026-11-03) | 见 `ref-bid-engine-analysis.md` §5。**原因码**:没通过校验的结果记具体原因(`extract.FAILURES`,存在清单项 `ai_values.failures`),解析结果按原因计数(`verify_failures`),提示词规则和原因码的对照表在 `prompts/README.md`。**分块**:相邻分块重叠约 800 字;模型输出被截断(`finish_reason=length`)或不是合法 JSON 时,把分块对半重跑,最多 3 层,拆到底仍不完整的记 `output_incomplete`(以前当成「这一块没有结果」,审计发现 deepseek-flash 有输出正好停在 8192 token、整块条目悄悄丢掉的情况)。**项目基本信息**:提示词 v2,12 个字段每个都要表态 found / ambiguous / not_found,跨分块合并后分 found / absent(文件本身没写)/ needs_review(抽取有问题),记在解析结果 `info_fields`。**占位符**:「____元」「××」「( )」「待填」不算取值。**提示词**:substantive v4,qualification / scoring / schedule / tech_param v3,每条禁止事项对应一个原因码。三份样本的待确认:国动 4 → 2,S02 13 → 3,S06 57 → 55(S06 有 4 次对半重跑,截断的分块都救回来了) | 已实现 |
| SK13 | S01–S10 标注集评测后的修正(2026-11-03) | 见 `extract-eval.md` §7。规则:合并时待确认条目不再「借道」正式条目(`pending_categories`),表后说明、章识别、★ 节标题、输出接近上限按截断处理、格式范本和整表概括句不收;提示词 substantive v5、qualification v4、schedule v4、tech_param v4;WPS 旧版 DOC 直接修正字体表字符集;表格按单元格切句(切句 s3-v4)。口径 Q1–Q4 见 decisions.md。报告分数有过拟合,只按人工标注算未全部达标,泛化未测,见 `extract-status.md` | 已实现 |
| SK14 | 查标第 1 步(2026-11-04) | 见 `m3-plan.md`「第 1 步落地记录」。解析流水线的前几个阶段(格式核对、转换、文字层、扫描件判断)拆到 `document/pipeline/normalize.py`,解析任务和检查批次共用;投标文件每个版本只解析一次,扫描页不报错,交给检查按「请人工核对」处理(C10)。新增 `check`、`ops`(反馈)模块,worker 任务 `check_project`。Valkey 客户端设 2 秒超时、SSE 连接池上限 500(SK1);CI 加 Gotenberg、直接跑 `init-roles.sh`(SK2) | 已实现 |
SK11 修正前后(同一模型 deepseek-chat,即 deepseek-flash 不思考模式)的待确认数:国动 46 → 4,S02 17 → 13,S06 174 → 57;同一句被拆成多条的情况没有了。剩下的待确认多数是表格两栏或竖排文字串行(要按单元格切句才能解决)。实质性条款任务上比较了 flash 不思考、flash 思考(deepseek-reasoner)、deepseek-v4-pro:抽到的条款大体一致、互有多少,后两者慢 15~45 倍、输出 token 多 7~12 倍,没有看出明显更准;选型等标注集评测出结果再定。
用国动便民小程序(71 页)、S02、S06 三份文件实测(真实模型 DeepSeek):单份 33~87 秒,清单 216~1348 条,正式清单里的条目都带页码和原句。发现并已修复:标注规则说明的碎片被当成条款、页码混进正文、跨页拆成两条、「做无效响应处理」等写法漏认、上级标题下列出的无效情形被合成一条或漏掉、「无效质疑」被误当成投标无效、「是否接受联合体」取错证据。
还没做、留了 TODO 的:转换镜像的字体包(S1)、OCR(S5)、分包对应到清单项、模型路由的候选列表、降级和限流、查标全部。S01–S10 标注集评测和修复见 `extract-eval.md`。

View File

@@ -0,0 +1,376 @@
# 查标评测(2026-11-04 起)
> 评测集规范:`evals/check/GUIDELINES.md`;工具:`evals/check/`;数据和输出在 `evals/out/check/`(不进 git)。
> **读数的前提**:金标准由 Fable 复核、用户抽查 20%(决策 E1),未经全部人工复核;本文的数字都是**开发集**上的,只用于找问题、调实现,不能当效果证据。头条效果以留出集(风格 C)和真实国动投标文件为准,第 3 步冻结代码后才跑。
## 1. 试跑(第 0 步,2026-11-04)
**范围**:开发集 S02、S04、S10 各两份投标文件(规矩 + 马虎),都是风格 A(风格 B 的改写还没做)。查标代码是第 1 步的版本:响应匹配只有「检索 + 模型判断」,规则检查还没有。模型 DeepSeek Flash,提示词 `check.match` v1。
**复核**:马虎的投标文件共注入 82 个要求项问题,Fable 复核通过 70 个、不通过 12 个(S02 10 个:默认接受的文件里注入了漏响应、「暂不支持」套在尺寸和费用这类参数上、要求本身是可选项等)。下面只算通过的。用户抽查 17 条(随机 20%,含 Fable 判不通过的 4 条),**全部同意**(2026-11-04)。
### 1.1 结果
| 投标文件 | 清单条数 | 报出问题 | 查标召回 | 致命级召回 | 规矩响应的要求上误报 | 不需要响应的条目上报了问题 |
|---|---|---|---|---|---|---|
| S02 规矩 | 248 | 104 | — | — | 21 / 56 | 39 |
| S02 马虎 | 248 | 117 | 8 / 16(50%) | 2 / 7 | | |
| S04 规矩 | 264 | 90 | — | — | 11 / 111 | 52 |
| S04 马虎 | 264 | 110 | 11 / 28(39%) | 1 / 4 | | |
| S10 规矩 | 305 | 122 | — | — | 6 / 39 | 75 |
| S10 马虎 | 305 | 142 | 5 / 20(25%) | — | | |
- 合计查标召回 24 / 64(38%)。只检索不调模型的基线召回 0:基线把检索到的第一句都当作已响应,说明现在查出的问题来自模型判断,不是检索。
- 按问题类型:漏响应(D01)9 / 14 较好;写明不支持(D05)5 / 20、负偏离未说明(D06)0 / 7、响应对象错位(D09)0 / 4 很差。
- 每次检查调用模型 31~39 次,输入约 9~11.5 万 token、输出约 1.5~1.7 万 token(约一两毛钱)。
### 1.2 问题出在哪里
1. **不需要投标人响应的条目也被报了「未找到响应」**:三份规矩的投标文件报了 316 个问题,166 个落在根本不需要响应的条目上(无效情形的列举、日期、采购人义务、别的采购包),这是误报的大头。→ C12 的响应方式抽取,加上「这条要不要投标人回答」的判断和按所投采购包过滤(第 2、3 步)。
2. **表格里的「暂不支持」「负偏离」看不见**:表格按单元格切句,检索命中的是投标人照抄要求原文的那一格,同一行里写着「暂不支持」「负偏离」的格子不在模型的输入里。→ 第 3 步把整行交给模型(偏离表直取也是这个思路)。
3. **证明材料和格式文件找不到**:规矩响应的要求上的误报,主要是证明材料(扫描件页没有文字层)和格式文件(声明函、承诺书)报了「未找到响应」,以及让投标人「见附件」的要求被报成「未给出具体值」。→ C10 扫描件兜底;文件要在(presence)的要求按文件在不在判断,不逐条找响应。
4. **级别口径**:S02 的 ★ 是「核心技术参数,负偏离扣分」,不是投标无效条款;标注集记成实质性条款,查标按致命报和真实评审不一致。→ 级别要跟着招标文件对 ★ 的定义走(读标口径 Q2 已有,查标沿用)。
### 1.3 评测集自身的问题(已修 / 待修)
| 问题 | 状态 |
|---|---|
| 分项报价平均摊,一项超过单价上限,规矩的投标文件自带致命问题(S10) | 已修:按分项上限比例分摊 |
| 偏离表有「设备名称」列,按固定列写会错位(S04) | 已修:按表头决定列 |
| 写死的落款日期晚于投标截止日(S04) | 已修:取截止日前 3 天 |
| 同一参数列在两张表里,只删一张表的行不算漏响应(S04) | 已修:漏响应在所有表里都删 |
| 符合性条件响应表的一格被算成 11 个问题 | 已修:按表级问题记一个 |
| 标注集同一要求有两个单元(两个类别、或表格一行拆成几条),一个问题算两次 | 已修:评分按句子重叠合并 |
| 「暂不支持」套在尺寸、费用、打印方式这类参数上,不像真实写法 | 待修:按参数类型选注入方式 |
| 默认接受的文件里注入漏响应(S02 另加了一张逐条的技术规格表,脚本推响应方式时被它带偏) | 待修:响应方式以档案的 modes 为准,不从表格推 |
| 「提供某文件」类要求被当成参数注入偏离 | 待修:推响应方式时识别 presence |
| 风格 B 的改写(截断、同义替换、只写值)还没做;所有数字都是风格 A 的 | 待做 |
## 2. 开发集第一版(2026-11-04)
**范围**:开发集 7 份招标文件各两份投标文件,共 14 份:S02、S05、S08、S10 是 A 规矩 + B 马虎,S01、S04、S07 是 B 规矩 + A 马虎(GUIDELINES §4)。生成器修了试跑的待修项(「暂不支持」不再套在数值参数上、表格响应方式以档案为准、「提供……文件」不注入参数类问题),风格 B 由模型改写、数字对不上的改写丢弃。查标代码仍是第 1 步的版本。
**复核**:注入的要求项问题两批共 222 个,Fable 复核通过 188 个。第二批 140 个里不通过 22 个,其中 14 个在 S07:S07 须知要求逐条标明,格式注又写未列入视作接受,且只有 ★ 项写明未响应无效,非 ★ 参数删一行算不算问题拿不准,金标准从严不算(查标侧仍按 C12 取更严的逐条)。用户抽查第一批 17 条全部同意;第二批抽查 28 条(含 Fable 判不通过的 4 条)也**全部同意**。两批合计抽查 45 条,和 Fable 的判断一致。
### 2.1 结果
| 指标 | 结果 |
|---|---|
| 查标召回(复核通过的问题) | 51 / 128(40%);风格 A 25 / 70(36%),风格 B 26 / 58(45%) |
| 端到端召回(含读标漏抽) | 51 / 132(39%) |
| 致命级召回 | 18 / 37(49%) |
| 不带标志词 / 带标志词 | 40 / 81(49%)/ 11 / 51(22%) |
| 连续一段 / 单点 | 7 / 12(58%)/ 44 / 120(37%) |
| 规矩的投标文件报出的问题 | 清单 2049 条,报出 699 个 |
| 其中规矩响应的要求上的误报 | 153 / 620(每 100 条 24.7) |
| 其中不需要响应的要求上报的 | 334 个(占 48%) |
| 干扰项误报 | 默认接受的要求没复述(T06)39,改写后的要求(T02)20,响应写在别的分册(T01)3 |
| 费用 | 14 次检查输入 150 万 token、输出 25 万 token |
按问题类型:资格证明缺失(D11)9 / 14、评分材料缺失(D13)9 / 12、漏响应(D01)15 / 23 较好;负偏离未说明(D06)0 / 20、响应对象错位(D09)1 / 14、证明材料缺失(D02)2 / 9、写明不支持(D05)11 / 31 很差。
### 2.2 读法
- **试跑的四个判断都成立,而且更清楚了**:「带标志词」的问题(写明不支持、负偏离)召回只有 22%,比不带的还低,正是表格里同一行的响应格、偏离格不在模型输入里;风格 B 反而比 A 高,因为 A 照抄要求原文,检索更容易命中照抄的那一格。规矩的投标文件上近一半的问题落在不需要响应的要求上,默认接受的要求被报了 39 次。
- **第 2、3 步按这个顺序改收益最大**:① 整行上下文 / 偏离表直取(直接对应 D05、D06、D09,51 个问题里的 45 个);② 响应方式和「这条要不要投标人回答」(C12,对应 334 + 39 个误报);③ 扫描件兜底和 presence 判断(证明材料、格式文件类误报)。
### 2.3 评测集还要改的
| 问题 | 状态 |
|---|---|
| 「暂不支持」「可选配置」仍会套在证书、供货清单、服务义务、文档交付这类非功能要求上(两批共 12 条被复核拒掉) | 待修:注入前按「功能类要求」过滤 |
| 同一要求被两个标注单元挂了同一个问题(实质性 + 技术参数、资格 + 符合性,第二批约 20 对);「第二十二条总述」这类伞状单元和具体条目不重叠 | 待修:注入前按 anchor 重叠合并标注单元;伞状单元不挂问题 |
| 条件性要求(「如依法免税则须提供」)被当作必须提供的材料挂 D11 | 待修:按投标人情况排除 |
| 低密度(每份 5~8 个问题)的投标文件还没做 | 待做:选 3 份 |
## 3. 第一轮改进:整行上下文(2026-11-04)
**改了什么**:候选句在表格里时,把同一行的单元格一起交给模型,也可以引用(`check/match.py`);提示词 `check.match` v2 说明怎么读一行(要求格 / 响应格 / 偏离格)。对应 §2.2 的第 ① 项。v2 的规则写法避开了开发集注入词库里的说法(重合的只有「负偏离」「完全响应」,是招标文件格式自带的用语)。
**评测集同时修了 §2.3 的前三项**(「暂不支持」只套功能类要求、伞状单元和条件性要求不挂问题),7 份马虎的投标文件用原来的种子重新生成,另加 3 份低密度的,共 17 份。新目标 55 个由 Fable 复核(通过 54 个),用户抽查 11 个全部同意(三批合计抽查 56 个,全部同意)。因为注入的问题换了一批(「负偏离未说明」多了、「暂不支持」少了),下表的「旧做法」要和同一批投标文件比,不能和 §2 的数字直接比。
| 指标(17 份,复核通过的问题) | 旧做法(v1、不带整行) | 新做法(v2 + 整行) |
|---|---|---|
| 查标召回 | 43 / 145(30%) | **73 / 145(50%)** |
| 致命级召回 | 9 / 31(29%) | **15 / 31(48%)** |
| 带标志词 | 1 / 65(2%) | **35 / 65(54%)** |
| 负偏离未说明 D06 / 写明不支持 D05 / 数值不满足 D03 | 0 / 58、1 / 7、3 / 7 | 28 / 58、7 / 7、6 / 7 |
| 漏响应 D01 / 响应对象错位 D09 | 18 / 27、3 / 14 | **14 / 27、0 / 14**(变差) |
| 规矩的投标文件报出的问题 | 699 | 639 |
| 规矩响应的要求上的误报 | 153 / 620(24.7 / 100) | 132 / 620(21.3 / 100) |
| 不需要响应的要求上报的问题 | 334 | 329 |
| 输入 / 输出 token | 189 万 / 32 万 | 273 万 / 28 万 |
**读法**:整行上下文解决了「看不见响应格、偏离格」的问题,带标志词的问题从几乎查不出到过半;误报略降。代价是输入 token 多 44%。**两处变差**:漏响应(D01)和响应对象错位(D09)。推测是同一行或相邻行的内容让模型更容易「找到点什么就判已响应」:漏掉的那一行不在了,检索命中的是相邻要求的行;错位的那一行响应格写的是另一条参数,模型没比对参数名。下一轮在提示词里要求先核对响应格说的是不是同一个参数 / 设备,并单独看 D01、D09 的漏报明细。不需要响应的要求上的误报(329)没有变化,要靠第 ② 项(C12)。
## 4. 第二轮:评测集纠错 + 响应方式和「要不要回答」(2026-11-04)
**评测集纠错**:§3 里漏响应、响应对象错位「变差」,逐条看漏报后发现多数是**生成器的错**,不是查标的错:读标清单有重复条目、S04 同一参数列在两张表里、技术方案散文也复述了要求,注入器只改了其中一处,别处还留着正确的响应,模型找到它是对的。改为同一要求的所有表格行一起改;在散文、附件、格式文件里也响应了的要求不挂漏响应 / 数值不满足 / 错位;错位响应必须是另一个参数的。马虎的投标文件按原种子重新生成,新目标 45 个由 Fable 复核(通过 40 个),用户抽查 9 个全部同意(四批合计抽查 65 个,全部同意)。
**查标改了什么**(第 2 步的 C12 部分):
- `checklist/response_mode.py`:按类别(技术 / 商务)识别招标文件的响应方式,规则优先;冲突按逐条,认不出按逐条并标明。开发集 7 份的识别结果和档案一致。(还没存进清单、没做清单页的显示和修改。)
- `check/applicability.py`:不需要投标人回答的条目不逐条找响应,写进覆盖范围(无效情形 —— 但 ★ / 斜体下划线标注的条款照常找、日期与递交、没要求附材料的评分标准、无需提供证明或由采购人查询的资格)。要求附证明材料按原文判断(读标的 evidence_required 常常是空的),中标后才交的不算。
- 默认接受的要求没找到响应:一般要求记「按招标文件默认接受」、不出问题;实质性要求出提示级「请人工核对」;要附证明材料的不免(迁移 0006 加了两种结论)。
| 指标(17 份,复核通过的问题) | v1 | v2 + 整行 | + 响应方式和「要不要回答」 |
|---|---|---|---|
| 查标召回 | 48 / 150(32%) | 102 / 150(68%) | 97 / 150(65%) |
| 致命级召回 | 9 / 32 | 20 / 32 | **21 / 32** |
| 规矩的投标文件报出的问题 | 699 | 639 | **229** |
| 规矩响应的要求上的误报 | 24.7 / 100 | 21.3 / 100 | **12.4 / 100** |
| 不需要响应的要求上报的问题 | 334 | 329 | **77** |
| 默认接受的要求被报(T06) | 39 | 35 | **1** |
| 输入 token | 189 万 | 273 万 | 224 万 |
按问题类型(最后一列):漏响应 19 / 24、写明不支持 13 / 14、数值不满足 4 / 4、负偏离未说明 39 / 59、评分材料缺失 9 / 14、错位 6 / 13;**证明材料缺失 1 / 17**。
**读法**:规矩的投标文件上的问题少了三分之二,召回只掉了 3 个点(掉的主要是只在默认接受的文件里、被正确判为「默认接受」的条目,以及证明材料)。证明材料缺失几乎查不出:资格证明、检测报告多是扫描件页,没有文字层,规矩的投标文件上同样报「未找到」——这是下一项(C10 扫描件兜底 + 文件要在的判断)。
**一处要说明的**:调响应方式识别时,打印结果里顺带出现了留出集 S03、S06、S09(招标文件本身,不是投标文件)的识别结果,其中 S03、S09 和规范 §2 的记录不一致。没有据此改任何规则,留到留出集正式评测时再看。
## 5. 第三轮:扫描件兜底、文件要在、响应方式存进清单(2026-11-04)
**改了什么**:
- **文件要在**(`check/presence.py`):「提供《授权委托书》」「须提交投标函」这类要求,从原文取出文件名,在投标文件里找写着它的标题;找到就算已响应(出处是那个标题),不调模型。
- **扫描件兜底**(C10):要附证明材料的要求没找到、而投标文件里有扫描件页时,不报「未找到响应」,改成提示级「可能在扫描件里,请人工核对」。没有 OCR 时,扫描件里有没有这份材料分不出来。
- **响应方式存进清单**(迁移 0008):解析时识别、带依据句子存下来;清单页显示,可以改(人工确认点,写审计,重新解析不覆盖人工改过的);查标用存下来的。
| 指标(17 份) | 上一轮(mode) | 本轮(scan) |
|---|---|---|
| 查标召回 | 98 / 150(65%) | 92 / 150(61%) |
| 致命级召回 | 22 / 32 | 22 / 32 |
| 规矩的投标文件报出的问题 | 229(致命 120、重要 58、提示 51) | 235(**致命 51**、重要 14、提示 170) |
| 规矩响应的要求上的误报 | 12.4 / 100 | 13.5 / 100 |
**读法**:问题的个数没变,变的是级别:规矩的投标文件上的致命问题少了一半多(120 → 51),多出来的是「请到扫描件里核对」的提示,致命级召回不变。总召回掉了 4 个点,主要是批次变化带来的波动(文件要在的条目不再进模型,同一批里的组合变了),以及几条漏响应被「标题里出现了文件名」误判为已响应——要盯着。评测集里证明材料全是扫描件页,所以证明材料缺失(D02)照旧查不出、扫描件兜底也分不出缺没缺;真正查出来要靠 OCR / 多模态(S5)。
## 6. 第四轮:规则检查(报价形式、形式要件)(2026-11-04)
**改了什么**(`check/rules.py`,规则集 `2026.11-r1`,不调模型):
- **投标侧结构**:按坐标把句子分成行、按表头归栏,认三种表——开标一览表(「投标总报价」那一栏)、分项报价表(数量 / 单价 / 合价三栏,可以跨页接续)、响应表(有「响应情况 / 偏离情况」一栏);再加落款日期、格式文件的填写位、页眉。
- **规则**:大写和小写金额(P04)、分项算术和合计(P05)、超最高限价(P01 / P02,按所投采购包取限价)、多个报价(P12)、页眉和填写位里的别的项目名称 / 编号 / 采购人(P06)、没填的地方(P07)、落款日期晚于截止或写错(P08)、投标有效期短于要求(P09)、附加条件(P10)、响应表里写「未完全响应 / 负偏离」或空着(D10)。响应匹配已经对同一句报了负偏离的,规则不重复报。
- 每条问题挂投标原文;有依据的挂招标侧出处(限价、截止时间、项目名称来自项目基本信息,有效期要求来自清单项)。缺项目侧的值(限价、截止时间、有效期要求)或认不出开标一览表时不报,写进覆盖范围。
- 说明只写读到的数字和依据,不写「应改为多少」(红线 6)。
**顾问审读后的修改**(请 Fable 从真实政府采购投标文件的角度审读,只给代码不给结论)。它指出的主要风险是真实版式上的致命误报,按「宁可让人核对,不让规矩的投标文件挂致命」改了:
- **表格分行改按单元格**:文字层原来就用 pdfplumber 找出了单元格框,但没存到句子上;现在句子带上单元格框(可选字段,旧的文字层没有就按句子重叠分行),同一行按单元格上沿分。垂直居中的短格、折成几句的长格、纵向合并的格不再串行或错位。
- **开标一览表认得更严**:总价那一栏下面恰好一个金额才算总价,分项表、多年度报价表的金额栏叫「投标报价」也不会被当成「多个总价」;不同位置写的总价不一致改为重要级的「投标总价前后写的不一致」,不再按致命的「两个报价」报。
- **另报价按把握分级**:「备选方案 / 方案二 / 第二套」致命;「优惠价 / 让利后」重要;金额等于投标总价(开标一览表或分项合计)的不报;否定看整个分句。
- **分项表**:「硬件小计」这类分组小计不进明细;同一栏读到两个数的行(无框线表格的合并格)不核对;下一页重复表头接着算同一张表;说明写「相差多少元」。
- **响应表**:一条记录从序号行到下一个序号行;「空着」一律按重要报(可能是版面没认准);认「否」(「是否响应 / 满足」栏)、「不响应(见说明)」这类写法。
- **投标有效期**:只在投标函、开标一览表那一页上的按致命报,别处按提示;复述的招标原文不算;「本报价有效期」(原厂报价单)不算;要求有几条时取最少的天数;认表格写法(「投标有效期」一格、旁边或下面一格写天数)。
- **别的项目名称**:页眉要像项目名称(以「项目 / 工程 / 采购 / 标段」结尾或带编号),章节页眉不算;招标文件里出现过的名称、编号(代理机构编号、简称)不算;「致:贵单位」不算。
- **附加条件**:复述判断先去掉编号、用前后 20 字;「不影响合同价款」这类不算;技术分册里的按提示报。
- **大写金额**:前后两句都找、只认写着「小写 / ¥ / 报价 / 合计」的句子,任何一处相等就不报。
- 落款日期认汉字数字(「二〇二六年十一月十七日」)和「日 期:」;说明里去掉「……的投标无效」这类绝对说法,改为「通常会导致投标无效,请核对」。复查对齐的目标改为原文前缀 + 摘要,前缀相同的两句不会互相吞掉。
- 每类都加了开发集以外的反例单元测试(`test_check_rules.py`)。
**评测集修正**:P07 的注入记录改成空白处前后的原文(原来记段落开头,空白在同段后一句时对不上);S07-A-sloppy 的 D10 注入在人员表里(不是响应表),剔除,生成器改为只往表头有「响应」的表里注入。score.py 加了规则问题的评分:规则编号对上、投标侧出处里有注入的原文才算查出。
| 指标(17 份) | 结果 |
|---|---|
| 规则问题查出 | 57 / 57(P04 8、P05 7、P06 7、P07 10、P08 7、P09 1、P10 7、P12 7、D10 3) |
| 规则问题误报 | 0(规矩的投标文件 7 份上 0) |
| 规则问题的级别 | 致命 8(备选方案类另报价 6、投标函上的有效期 1、符合性响应表 1)、重要 32、提示 17 |
| 响应匹配 | 不变(召回 92 / 150,致命级 22 / 32) |
**读法:这个满分基本说明不了泛化。** 规则是在知道生成器怎么注入、见过开发词库措辞的情况下写的,投标文件版式也都出自同一个生成器。附加条件(P10)和多个报价(P12)的开发词库措辞直接落在规则的写法里,最需要留出集检验。为此做了两件事:规则按招投标文件的通用写法归纳(单元测试和 `bid-mini-forms.pdf` 都用开发词库以外的措辞);评测集里没有的情形(超限价、跨页分项表、万元单位)只有单元测试。真实投标文件的合并单元格、扫描页、各地模板差异,要等国动真实投标文件来看。P01 / P02 / P03 / P11 / P13–P18 评测集里还没有注入。
## 7. 第 2 步收尾:需人工核对、分项上限(2026-11-04)
**需人工核对**(FR-M3-04、决策 P2,`check/manual.py`):份数、密封、递交、CA 签章加密、解密、保证金这些投标文件里看不出来的要求,从正式清单里挑出来(技术参数、评分项、资格条件不算;写明免收保证金的不列;点名要交的文件归「文件要在」),按主题合并成最多 5 条(开发集 7 份招标文件每份 3~4 条,原始条目 12~48 条),带招标原文出处;查标页勾选,按项目存、写勾选人和时间,复查后还在。接口 `GET / PUT /projects/{id}/manual-checks`,`check_key` 用 `rule:MANUAL.<主题>`,清单改了勾选也还在。不生成「问题」,覆盖范围里写明列了几条。
**分项 / 单价上限**(C7,`checklist/price.py` + 规则 `PRICE.OVER_CEILING_ITEM`):读标时从采购需求的清单表(表头有「名称」和「单价上限 / 分项限价」两栏,表前一句写采购包)逐行读出名称、数量、上限,作为「报价」类正式条目并入清单,出处是表格那一行,不调模型;采购包一级的表不算。查标时这类条目不交给模型,规则拿分项报价表里同名分项的单价比,超了报致命,挂招标侧出处和清单项。名称要求很严(完全相同、去掉括注后相同,或重合度 ≥ 0.8),对不上的写进覆盖范围。开发集只有 S10 有分项上限,5 条全部读对;散文写法(「单价不得超过 × 元」)还不认。
**评测集**:从规矩的投标文件派生 4 份只带报价问题的(`evals/check/price_defects.py`:只改一个分项单价,合价、合计、大小写跟着改,算术一致):S01-B-p01、S04-B-p01(超总价限价 1 元 / 1%)、S10-A-p02(超采购包限价 1%)、S10-A-p03(分项单价超上限 1%)。S10 的分项上限加起来就是采购包限价,总价超限必然有分项超上限,生成器把这条一并记进金标准。21 份上规则问题 62 / 62 查出、误报 0;同样只说明规则和生成器对得上。
## 8. 第五轮:偏离栏直读、响应错位、复查复用和预算(2026-11-04)
**改了什么**(`check/deviation.py`,都在模型判断之后、不调模型):
- **偏离栏直读**:模型判「已响应 / 只写满足」的条目,看它引的证据句所在的表格行;同一行有单元格整格写着「偏离 / 负偏离 / 部分满足 / 未完全响应」这类字样,改判负偏离,证据带上那一格。只写「偏离」两字的要是识别出单元格框的格(跨页无框的行抽字时「无偏离」会被拆成「无」「偏离」,规矩的 S07-B 上出过一次)。漏掉的 D06 多数就是这种:响应格照抄要求「满足,……」、偏离格写「偏离」,提示词已经要求读整行,模型还是漏。
- **响应错位**(D09):模型判「已响应」、引的证据全是照抄的要求格,同一行右边的响应格写的是别的内容(「显示器升降行程:150mm」「1位」),且和要求没有一个字对 / 数字字母串重合 → 改判「响应可能对不上」,说明里写出响应格原文。笼统的「支持 / 具备」、「详见……」、只写「满足」、要求太短(汉字少于 8 个)的不判。条件很严,宁可漏。
- **复查复用**(C2,`check/reuse.py`):每条清单项按「提示词版本 + 要求 + 候选句和同一行的单元格原文」算缓存键,和模型那一层的原始结论(后处理之前)一起存进 `check_item_result`;复查时键相同、证据句在本次投标文件里原样找得回来的,直接沿用,不调模型;响应方式、偏离栏直读这些后处理照常重算。覆盖范围写沿用了几条。
- **硬预算**(C2):每次检查送给模型的字数上限 120 万字(开发集平均约 5 倍),按批次顺序分配,超出的批次不调模型,条目记「超出预算」列为请人工核对,覆盖范围另写。
| 指标(17 份) | 第三轮(scan) | + 偏离栏直读 | + 响应错位(本轮) |
|---|---|---|---|
| 查标召回 | 92 / 150(61%) | 107(71%) | **109 / 150(73%)** |
| 致命级召回 | 22 / 32 | 24 / 32 | **24 / 32** |
| D06 负偏离未说明 | 38 / 59(64%) | 53 / 59(90%) | 53 / 59(90%) |
| D09 响应对象错位 | 5 / 13 | 5 / 13 | **7 / 13** |
| 规矩的投标文件报出的问题 | 235 | 236 | **235** |
| 模型调用 | — | 全部命中缓存 | 全部命中缓存 |
**读法**:两项都是确定的规则,没有改提示词、没有多花 token。响应错位报了 3 处(2 个单元),全对,规矩的投标文件上没有改判;第一版报错的 4 处(「13.56MHz」没算数字字母重合、「支持」当成别的内容、要求太短)修掉后才是这个数。偏离栏直读依赖投标文件的偏离栏写法,真实文件里偏离栏常写「无」「—」「见说明」,这些不会改判(不算不满足),只有明确写了不满足的字样才改。
## 9. 第六轮:同一行按单元格、业绩条款不再误跳、重复条目只合并显示(2026-11-04)
**改了什么**:
- **送给模型的「同一行」按单元格分行**(有框线的表格;正文句子照旧按上下重叠):垂直居中的格子、折行的长格不再串到上下行。要重跑模型(17 份约 225 万 token)。
- **「以合同签订时间为准」不再当作「签订合同时才交」**:`applicability` 判断证明材料是不是中标后才交的正则把它也认成了「签订合同时」,业绩评分项(「需提供合同复印件……以合同签订时间为准」)因此被当作「没要求附材料」跳过。评分项的业绩条款几乎都这样写。
- **重复条目**(决策 C5 修订):每条照常单独判断;问题接口给出同一次检查里问题类型相同、要求原文完全相同的其他问题(`duplicate_ids`),查标页同组只显示一条,注明「清单里出现 N 处」,处理和撤销对整组生效。
- **致命项重问(C3)先不做**:开发集 17 份上证据没通过校验的只有 5 条,致命级 0 条,C3 定的「不超过致命问题的 10%」已经满足,开发集上没有可测的对象;等真实投标文件上看到再做。
- **评测集裁定 1 条**(`adjudication/S10-B-sloppy.md`):t20 的漏响应不成立,技术方案正文完整响应了(生成器没认出风格 B 改写过的正文复述);问题总数 150 → 149。另核实了 3 条疑似错误、不改:S10-A-low t16 正文只响应了一半;S04-A s21 / s22 是同一条认证要求在几台设备下各写一遍、投标文件漏了其中一台——清单把几台设备的同一要求合成了一条,按条目查看不出哪一台漏了,这是现在的局限。
| 指标(17 份,按 149 个问题重算) | 第五轮(mismatch) | + 同一行按单元格(cellrow) | + 业绩条款(award,本轮) |
|---|---|---|---|
| 查标召回 | 109(73%) | 108(72%) | **111 / 149(74%)** |
| 致命级召回 | 24 / 32 | 25 / 32 | **25 / 32** |
| D06 / D09 / D13 | 53 / 7 / 9 | 53 / 5 / 9 | 54 / 5 / **10** |
| 规矩响应的要求上的误报 | 84 / 620 | 81 / 620 | 81 / 620 |
| 干扰项误报 | 30 | 27 | **24** |
| 规矩的投标文件报出的问题 | 235 | 236 | 239 |
| 输入 token(17 份) | 221 万 | 225 万 | 225 万 |
**读法**:按单元格分行和上一轮基本持平(召回 -1、致命 +1、误报略少),在一次模型重跑的波动范围内,结构上更对,保留。D09 少的 2 条是响应错位判断依赖模型引用「照抄的要求格」,模型换了引用就不触发——它是补漏,不是主力。业绩条款的修正带来 D13 +1、D01 +1;规矩的投标文件上多了 3 个提示(新查的业绩评分项)。
## 10. 第 3 步冻结(2026-11-04)
查标代码冻结在 `a60d16e`(规则集 `2026.11-r2`、提示词 `check.match` v2)。之后跑留出集(S03、S06、S09,风格 B 规矩 + 风格 C 马虎)和国动真实投标文件各一次,结果只记录,不据此调规则和提示词(GUIDELINES §8);留出集上发现的生成器错误按「响应清单错误」单独记数。
## 11. 留出集结果(冻结代码 a60d16e,跑一次,2026-11-04)
**留出集怎么做的**:S03、S06、S09 各一份风格 B 规矩 + 一份风格 C 马虎,共 6 份。招标档案由三个代理按 `prompts/profile.md` 写(S03、S06 各有 1100~1300 多条正式条目,是开发集最大的 2~3 倍);风格 B 改写由代理逐行手写(第一版用脚本批量生成,质量不够,返工);风格 C 由封存一侧的会话按封存说明在 build.py / mutate.py 里实现(开发集风格 A / B 输出逐字节不变),注入用留出集词库。96 个注入问题由 Fable 复核,通过 79、不通过 17(18%,开发集约 10%~15%:风格 C 生成器的「别处已响应就不注入」判断不够严,漏响应 6、可选加分当成缺证明 5、偏离行错位 3、删错材料 2、错位 1);用户抽查 19 条(含不通过 4 条)全部同意。
| 指标(只算复核通过的问题) | 开发集 17 份(调过) | 留出集 6 份(冻结代码原样) | S03 人工确认响应方式后 |
|---|---|---|---|
| 查标召回 | 111 / 149(74%) | **40 / 74(54%)** | 12 / 21(57%) |
| 致命级召回 | 25 / 32(78%) | **23 / 41(56%)** | 7 / 10 |
| D06 负偏离未说明 | 54 / 59(92%) | **5 / 20(25%)** | — |
| D01 / D03 / D05 / D09 | 74% / 100% / 86% / 38% | 58% / 90% / 100% / 80% | — |
| D02 / D11 证明材料、资格材料缺失 | 6% / 100% | 50% / 33% | — |
| 规矩的投标文件报出的问题 | 239(7 份) | **1134(3 份)**,其中 S03-B 1094 | S03-B 45 |
| 规则问题查出 | 57 / 57 | **13 / 20(65%)**:P10 0 / 3、P12 0 / 3 | — |
| 规则问题误报 | 0 | 3(都是「没填的地方」) | 0 |
**读法:开发集上的分数有相当一部分是调出来的。** 留出集召回比开发集低 20 个点,致命级低 22 个点,这更接近真实水平(留出集也仍是同一个生成器写的虚构文件,真实文件还要看国动)。具体失效在哪里:
1. **响应方式识别过度触发(影响最大)**:`response_mode` 的「逐条响应」写法里有「逐一」,S03 声明函的填写说明「逐一、准确填写产品名称……」被认成要求逐条响应,和「未列入偏离的视为接受」冲突,按 C12 冲突就按逐条处理,1028 条技术参数在规矩的投标文件上全部报了提示级「未找到响应」。产品里响应方式是人工确认点,清单页会显示识别出的方式和依据原文(这条依据一看就不对),人工改成「只列偏离」后 S03-B 降到 45 个提示。**要修:识别「逐条响应」要求限定在偏离表、响应表的说明里,声明函填写说明不算;冲突时在清单页上提醒确认。**
2. **偏离栏直读只认开发集的写法**:留出集偏离栏写「不完全一致 / 与要求有出入 / 接近要求」,不在「偏离 / 负偏离 / 部分满足」这组词里,模型也没读出来。D06 92% → 25%。**要修:偏离栏的取值按「是否完全一致」判断(「无偏离 / 完全响应 / 满足」以外的非空值都请人核对),不靠枚举不满足的说法。**
3. **附加条件、另报价的写法枚举**(事先就判断最需要留出集检验):「以双方另行签订的服务协议约定为准」「保留调整报价的权利」「备选配置总价」「如同意延长交货期,可将总报价调整为……」「愿以 × 元作为最终让利价格」(金额在关键词前面)都没认出。**要修:这两类改成「投标函、报价相关章节里出现第二个总价金额」「报价、付款、交货条款里出现保留 / 另行 / 调整」这种结构化判断,再配措辞;或者交给模型判断一次。**
4. **空白栏规则**:技术要求里的「精确到年月日」被当成没填的日期,误报 3 次。**要修:「年月日」前后是「精确到 / 格式 / 显示」这类说明性字样时不算。**
5. **PDF 字形**:渲染出的文字层把「户」读成「戶」(康熙部首 / 兼容字),回填位置时定位不到;真实文件里招标侧和投标侧字形不一致时检索和引文核对都会受影响。**要修:文字层做兼容字归一(读标的 `_char` 已经处理了一部分部首,要补全)。**
这些修完以后,留出集已经测不出修复效果(看过了),报告里的留出集数字以本节为准。国动真实投标文件仍然用冻结代码跑一次,之后再修。
## 12. 修留出集暴露的 5 处(2026-11-04,用户决定先修再跑真实文件,决策 E2)
规则集 `2026.11-r3`。
1. **响应方式识别**(`checklist/response_mode.py`):「逐条响应」那句话要说的是要求、条款、参数、偏离、采购需求,声明函填写说明里的「逐一、准确填写……」(10 份招标文件里 7 份都有这句套话)不算;「默认接受」的写法里,「视为」后面紧跟「无效 / 不 / 未 / 非」的(「否则视为无效响应」)是不响应的后果,不算。10 份招标文件的识别结果和档案一致(S03 → 默认接受,S09 → 逐条),S05 商务仍是「没写明」(原文确实没写)。
2. **偏离栏按「是不是明确满足」判断**(`check/deviation.py`):按表头认出偏离栏和响应情况栏,两栏合起来判断——任一栏明确写了不满足(短的一段里有「负偏离 / 部分响应 / 不满足」,长的一段开头就是这些)就是负偏离;偏离栏写的不是「无偏离 / 满足 / 符合」的其他短写法(「不完全一致 / 接近要求 / 基本一致」)请人核对,级别最高到重要;「响应情况」栏写的常是响应内容(「支持」「3 年」),只认明确的不满足。窄栏折成两段的「无偏」「离」合起来认。认不出表头时退回原来的整行明确写法。响应表规则用同一套判断,表体序号认「技-86」「C-3」「(一)」这类写法(原来只认纯数字,风格 B / C 的表整张被跳过)。
3. **另报价、附加条件按结构判断**(`check/rules.py`):另报价看「报价字样或备选 / 优惠 / 让利 / 如……调整为,加上一个和投标总价不同、不低于总价三成的金额」(金额在前在后都算),「备选 / 替代 / 方案二 / 第二套」致命,其余重要;业绩合同金额、限价、保证金不算。附加条件补「保留……权利」「另行签订」「以另行 / 实际 / 届时……为准」「调整报价」「价格波动」,商务语境补「交付 / 供货 / 服务期 / 售后 / 协议」,「不以任何理由……」算承诺。
4. **空白栏**:「精确到 / 格式为 / 显示」后面的「年月日」、后面跟「时」的不算没填。
5. **异体字**:比对时把「戶 / 戸 → 户」这类异体码位和 CJK 兼容汉字折叠(`text_layer.norm`、检索分词),原文照存。
每处都有开发集、留出集以外写法的单元测试;中间一版偏离栏判断把「投标响应」栏的响应内容和折行碎片当成了要核对的值,开发集上冒出几十个误报,改成两栏分开判断、按段判断后回落。
| 指标 | 开发集 修前 → 修后 | 留出集 冻结代码 → 修后(**已看过,仅参考**) |
|---|---|---|
| 查标召回 | 111 / 149(74%)→ 112(75%) | 40 / 74(54%)→ 51(69%) |
| 致命级召回 | 25 / 32 → 25 / 32 | 23 / 41 → 26 / 41 |
| D06 负偏离未说明 | 92% → 93% | 25% → 80% |
| 规矩的投标文件报出的问题 | 239 → 239 | 1134 → 82 |
| 规则问题查出 | 57 / 57 → 57 / 57 | 13 / 20 → 19 / 20 |
| 规则问题误报 | 0 → 4(规矩的投标文件上 1,都是「没读到响应情况」的重要级提示) | 3 → 0 |
留出集的正式成绩仍以 §11(冻结代码)为准:修的时候看过留出集的漏报,修后的数字有过拟合成分。
## 13. 资质匹配(查标第 4 步,2026-11-04)
规则集 `2026.11-r4`,方案 `qualification-plan.md`(决策 QL1–QL7)。只改规则,开发集上的模型结论全部走缓存。
**1. 开发集不退化**:17 份投标文件(空资质库)的全部指标和 §12 修后一致:召回 112 / 149(75%)、致命级 25 / 32、规矩的投标文件报出问题 239、规则问题 57 / 57。资质问题 0 个:开发集的证书全在扫描件页上,空资质库什么都不报(QL3)。
**2. 类别识别**(S01–S10 读标结果里资格条件、评分项中带「证书 / 资质 / 认证」的条目):认出类别 14 条,产品证书、人员证书排除 9 条(节能 / 环境标志产品认证、项目经理和技术人员资质),没认出 2 条(S03「认证证书(3 分)」、S05「数据库兼容证书」,都是产品类,正确地不判)。营业执照、法人证书、联合体规则不算资质要求。读标留出集的 S03、S06、S09 里没有可核对有效期和等级的企业资质。
**3. 模拟资质库的噪音**(开发集 17 份,`run_check.py --library`):
| 资质库 | 资质问题 | 说明 |
|---|---|---|
| 空 | 0 | |
| 每类一条、有效、等级最高 | 4 个提示 | 都是 S01 的「CCRC 服务类别」:要求写了「安全集成」,模拟记录的名称里没写 |
| 只有 ISO 9001 | 16 个重要 | S01 两份的资格条件和评分项「资质库里没有这类资质」,每份 8 个(含同一要求抽成两条的重复,一条评分项里两类证书各一个) |
| 每类一条、2020 年过期 | 29 个重要 | S01(每份 8)、S04(每份 3 类 ISO)、S07(每份 ITSS、CS 两类)「按资质库记录已过期」 |
真实用户的资质库介于「全有效」和「缺几类」之间:规矩的投标文件上,资质库录全了几乎不出问题;录了一部分时,没录的那几类在扫描件条目上会出「未覆盖」(重要)。
**4. 派生的资质情况表**(`evals/check/qual_defects.py`):在 S01-B、S04-B、S07-B 的资格证明一章开头插一张文字的「企业资质情况表」(虚构证书,写法故意和词表别名不完全一样),q00 全部有效、q01 注入 D12(有效期早于截止日)和 D18(等级低一级)。
| 指标 | 结果 |
|---|---|
| D12 有效期早于截止日 | 3 / 3(S01 资格条件致命,S04、S07 评分项重要) |
| D18 等级低于要求 | 2 / 2 |
| q00 规矩的资质表上的资质问题 | 0(空资质库、全有效资质库都是 0) |
调试中修的两处:一条评分项写了两类证书时合成一个问题,规则编号只能取一个(S07 的 CS2 被 ITSS 过期吞掉)→ 改为一类证书一个问题;证书编号「CS3-3201-…」排在等级栏前面,整行读成了 CS3 → 先看短的等级格。同一要求在清单里抽成两条时各报一个,评分按「同规则 + 同出处」算一个。
插表以后,响应匹配的候选句变了,13 + 8 + 12 次模型调用没有命中缓存:S01 有 5 条扫描件兜底的「请人工核对」因为文字表里能找到而消失(应当如此);S07 有 3 条条目的结论变了(1 条新的「请人工核对」、1 条从「请人工核对」变成「疑似不满足」、1 条新的「疑似不满足」),是模型看到资质表后的判断,没有逐条复核。
**局限**:资质情况表和词表是同一个人对着同一批样本写的,分数说明不了泛化;真实投标文件的资质表写法、扫描件比例都没测。国动真实投标文件到了以后,请用户按公司的真实证书录资质库再跑一次(`qualification-plan.md` §5)。
## 14. 国动真实投标文件(2026-11-05,跑一次)
用户提供的真实响应文件(南京长卿科技有限公司,183 页 PDF,WPS 导出;第 18–33 页里有 23 页是没有文字的扫描件,第 104–169 页是合同和劳动合同的扫描件,每页带一行文字页眉)。招标文件用当前代码重新读标(DOC 扩展名、实际是 DOCX,71 页,45 秒,299 条、待确认 3 条,响应方式技术 / 商务都认成「逐条响应」)。用户看过清单、没有改动,资质库空,直接查标。代码是 `5b77012`(规则集 `2026.11-r4`、提示词 `check.match` v2)。文件放在 `samples/tenders/GD/`,不进 git。
**结果**:26 秒,模型调用 31 次(输入 15.6 万 token、输出 2.1 万);249 条参与检查,203 条判「已响应」。报出 44 个问题:致命 1、重要 1、提示 42。没有金标准,下表是逐条对照投标文件原文的人工判断(Claude 判,用户未复核):
| 问题 | 个数 | 判断 |
|---|---|---|
| 响应错位(含唯一的致命) | 15 | **全部误报**。响应格写「完全满足,无偏离。」,`POSITIVE` 去掉「完全满足,」后剩下「无偏离。」,不在 `MARKER` 里,被当成「别的内容」。真实投标文件最常见的写法,开发集、留出集没有 |
| 附加条件(重要) | 1 | 误报。原句是「……均包含在合同价中,**不**以正常接口对接和适配工作**另行收费**」,是承诺不是附加条件 |
| 出处没能核对 | 4 | 误报。部署分区(第 72 页)、架构图(第 61–63 页)、人员经验和岗位(第 132–133 页)原文都有 |
| 未找到响应 | 10 | 5 条有用:平台费用三条(商务表里确实没有逐条回应)、没有承诺延长质保(评分项)、没有声明演示系统是自主研发;5 条误报:售后、实施、数据治理三条「方案应明确……」对应的章节都有,响应时限承诺第 100 页写了 15 分钟 / 1 小时,「合同签订后细化进度计划」不是投标时要回答的 |
| 疑似不满足 | 1 | 误报。团队配置第 132–133 页列了 3 名核心人员和分工,模型的说明「只写了满足」不对 |
| 扫描件兜底「请人工核对」 | 12 | 4 条有用:第二十二条的证明材料(确实在扫描页上),以及软件自主研发、团队专业能力、国产化适配三个评分项(投标文件里确实没有材料,人员证书一栏都写「无」,但提示说「可能在扫描件里」,说法不对);8 条是噪音:不接受联合体、无特定资格要求、须具备资格要求、联合体协议、特定资格证明,以及残疾人福利单位、监狱企业、本国产品三种价格扣除,都不需要这家投标人回答 |
合计有用 9 个、误报或噪音 35 个;**致命、重要级 2 个全是误报**。报价规则(大小写、分项合计 43.2 万、低于限价 45 万)、落款日期、有效期都没有报问题,原文核对也确实没有问题。
**产品没查、人工看到的真问题**:第 3 页「评分索引表」写的页码和实际页码对不上(表上写到第 209 页,文件只有 183 页,「履约能力」写的是第 124–194 页,实际是第 102–169 页),总目录也一样。评委按索引翻页会找不到。这属于「目录页码和实际页码一致」,覆盖范围里还没有这一项。
**暴露的问题**(按规矩只记录,修不修由用户定):
1. **响应错位的肯定写法**:「完全满足,无偏离」「满足,无偏离;」这类复合写法要整体当成肯定回答。影响最大,唯一的致命就是它。
2. **附加条件的否定句**:「不……另行收费」「不以……为由另行……」是承诺,不算附加条件。
3. **扫描件判断只看整页有没有字**:第 104–169 页的扫描件因为有一行页眉,不算扫描页,所以提示写「23 页扫描件」,实际有 80 多页。另外,评分项要的材料文字部分没有时,提示一律说「可能在扫描件里」,即使扫描页全是别的材料也这么说。
4. **「要不要投标人回答」漏判**:联合体、特定资格「无」、价格扣除这些条目(投标人没有声称适用的),以及合同签订后才做的事,不该出问题。
5. **评分细则的「方案应明确……」**:投标文件有整章对应时,模型在候选句里没找到。这类条目要整章去对,不是逐句去找。
6. **出处核对**:4 条模型找对了页,引文没能核对上,降成了「未找到」。
**读法**:真实文件上的问题数(44)不多,原因是这份投标文件很规矩:偏离表逐条照抄、全写「完全满足」。但最高两级全是误报,说明规则在没见过的写法上还很脆。召回测不出来,因为这份文件本身没有致命问题,第 1、2 条的误报正好落在最显眼的位置。
## 15. 修国动暴露的 6 处(2026-11-05,决策 E3)
规则集 `2026.11-r5`。每处都配了单元测试,例句不取自国动、开发集或留出集。
1. **响应错位的复合肯定写法**(`check/deviation.py`):响应格开头连着写的肯定回答(「完全满足,无偏离。」「符合,无负偏离;」)一起去掉,剩下有内容的才拿来和要求比;「完全满足,显示器升降行程:150mm」照报。
2. **附加条件的否定句**(`check/rules.py`):「不以……另行收费」「不以……为由另行计取」是承诺;「另行收取」「另行协商」照报。
3. **扫描件判断**(`text_layer` v5,`check/match.py`、`findings.py`):每页记图片占比。字数不到 20,或图片占两成以上且不到 60 字,或占四成半以上且不到 120 字,都算扫描页(带插图的正文页字多,不算)。提示写明扫描件在哪几页,例如「投标文件第 16–23、26–33、104–131 页是扫描件(共 82 页)……材料如果附在这些页上,请人工核对,没有附就是缺了」,不再说「材料可能在扫描件里」。投标分册下次检查时按 v5 重新抽文字层,句子编号不变。
4. **不需要这家投标人回答的条目**(`check/applicability.py`):写明没有的(「无特定资格要求」;有这条时,「特定资格要求的证明文件」也不查)、总括性的「须具备招标文件规定的资格要求」、标题以「合同签订后 / 中标后」开头的,都不找响应。条件性要求(标题里是联合体、残疾人福利性单位、监狱企业、本国产品的)要投标文件里出现了对应的文件才查。「不接受联合体」不算「没有要求」:投标人真是联合体时恰恰要报。这些都写进检查覆盖范围。
5. **提纲式要求**(`check/match.py`):「方案应明确 / 包括 A、B、C……」里列出的每一项(至少 3 项)再各检索 1 句,候选句最多 10 句,模型能看到整章的内容。
6. **同一批片段的出处核对**(`check/match.py`、`reuse.py`、`runner.py`):同一批送给模型的片段,任何一条都可以引用,引文照样要和原文核对;出处没过校验的结论不再沿用到下次检查(以前沿用回来会变成普通的「未找到」)。
**国动(看过结果才修的,只作参考)**:重新检查报出 11 个问题,全是提示级,用时 7 秒(大部分结论沿用上次,只调了 2 次模型)。§14 里判为有用的 9 个都还在,剩 2 个误报:团队配置的「只写了满足」、更优响应时限(这两条的结论是沿用上次的)。原来的 4 条出处核对失败、「方案应明确……」的 4 条都改判已响应,出处页码是对的(第 61–63、79–85、87、94–101、132 页)。
**开发集不退化**(17 份,运行 `r5` 对比 `r4`):
| 指标 | r4 | r5 |
|---|---|---|
| 查标召回 | 112 / 149(75%) | 111 / 149(74%) |
| 致命级召回 | 25 / 32 | 25 / 32 |
| 规矩的投标文件报出的问题 | 239 | 213 |
| 其中落在不需要响应的要求上 | 79 | 53 |
| 规则问题 | 57 / 57,误报 4 | 57 / 57,误报 4 |
召回少的 1 个是模型重跑的波动:跳过的条目变了以后,批次重新组合,大部分批次没命中缓存,模型重新判了一遍(各类此消彼长:D01 +1、D05 +1、D03 −1、D06 −2)。漏掉的 4 个,把第 6 处临时关掉(全部走缓存)结论一样,不是修复造成的。派生的报价、资质评测文件(8 份)的规则和资质问题和上次完全一样。
**留出集(看过,只作参考)**:召回 51 → 49 / 74,致命级 26 → 26 / 41,规矩的投标文件报出的问题 82 → 78,规则问题 19 / 20 不变。少的 2 个(S06-C 的 D09、S09-C 的 D01)关掉第 6 处后结论也一样,同样是重新组批后模型判法变了。
**局限**:这 6 处都是看着一份真实文件改的。第 4 处的「条件性要求」只认标题里的这几种主体;第 3 处的阈值是按国动这一份文件的扫描页定的,别的文件扫描页的版式(整页图片、多张小图)还没见过。
## 16. 下一步
1. 分项报价表和采购需求清单逐项对照(P13)**暂缓**:在开发集 7 份招标文件上试读「名称 + 数量」的采购清单表,S02、S08(服务类)没有这种表;S04 把「是否进口」一栏读成了名称;S10 采购包 2 的表跨页、第二页没有表头,漏了大半;S01 9 行里能和投标文件的分项对上名称的只有 7 行。P13 是致命级,按这个抽取质量在真实文件上会大量误报「缺项」,评测集里也没有注入过,先不上,覆盖范围里照写「还没有上线」。散文写法的单价上限同样暂缓。
2. 第 3 步冻结前:确认各项改动的单元测试都有开发集以外的反例;冻结后跑留出集(S03、S06、S09,风格 B + C)和国动真实投标文件,各一次。
3. 第 3 步:D09(响应对象错位)、D06(负偏离未说明)的提示词 v3,token 预算、复查复用、重复条目分组。
3. 留出集(S03、S06、S09,风格 B + C,封存的词库)和真实国动投标文件,在第 3 步冻结代码后只跑一次。

View File

@@ -0,0 +1,327 @@
# 物理数据模型 v0.1
> 依据:`docs/analysis/04-domain-model.md`(概念模型)、架构 v0.3 §3、§9、§3.14,以及 03 功能需求和 decisions.md 里已确认的规则。
> 建表脚本草案:`schema-draft.sql`(PostgreSQL 16+,40 张表);行为测试:`schema-draft-test.sql`。2026-11-03 在 PostgreSQL 16 上跑通,结果见 §10。
> 这是设计草案。工程骨架搭好后,按模块拆成 Alembic 迁移,这两个 SQL 文件不再维护。
> DM1–DM7 已于 2026-11-03 全部按建议确认(`docs/decisions.md`),见 §9。
## 0. 结论速览
- **核心是清单项**:一条要求的稳定编号贯穿解读、检查和复查。当前值、AI 原值、人工改过哪些字段,三者分开存。检查引用的是清单的不可变快照。
- **出处不可变**:出处存规范化 PDF 的哈希、句子编号、坐标框和原句快照。清单项、问题、项目字段、标注规则都引用同一张出处表。
- **句子不进数据库**(DM1):逐字坐标和句子都放在对象存储的文字层文件里。数据库只存页摘要和出处快照,否则一年会有十亿行级的句子数据。
- **额度流水只追加**:每笔流水的数量就是它对余额的影响,余额等于流水求和。一笔预扣只能有一个结局(结算或退回),这条由唯一索引保证。
- **租户隔离有三道保险**:
- 应用层:每次查询都带租户上下文;
- 数据库:行级安全策略;
- 外键:都带上 `tenant_id`,跨租户引用在数据库层就插不进去(DM3)。
- **删除项目是真删除**:项目下的数据级联删除,对象存储里的文件靠销毁密钥让它不可读。额度流水和不含业务内容的审计记录保留(G10)。
## 1. 约定
| 事项 | 约定 |
|---|---|
| 主键 | UUIDv7,由应用生成。少数从属表用复合主键,比如 `document_page`(文档 + 页码) |
| 租户 | 业务表都带 `tenant_id`。全局表只有 5 张:`user_account`、`login_identity`、`plan`、`ops_admin`、`ops_session` |
| 时间 | 一律用 `timestamptz`;可改的表带 `created_at` 和 `updated_at`,`updated_at` 由触发器维护 |
| 状态和枚举 | 用 `text` 加 `CHECK`,不用 PostgreSQL 的枚举类型(DM2)。Python 侧用 `StrEnum` 定义同一组值,CI 检查两边一致 |
| 金额 | `numeric(18,2)`。投标文件里读到的金额同时保留原文写法;系统不生成任何报价数字(红线 6) |
| 记录「谁」 | 指向 `user_account`,不指向成员身份(架构 §3.14) |
| 模块归属 | 每张表只属于一个模块(§3)。模块之间可以有外键(保证数据完整),但不能直接读写别的模块的表 |
| 项目内的外键 | 一律 `ON DELETE CASCADE` 或 `SET NULL`。只在删除整个项目时真正触发,清单项平时只做软删除 |
| 只追加 | `quota_entry`、`ai_call` 禁止 UPDATE 和 DELETE;`citation`、`report` 禁止 UPDATE;`audit_event` 只允许「抹掉内容」这一种修改(§8)。都由触发器保证 |
| 大字段 | 文档原件、规范化 PDF、文字层、报告、模型输入输出都放对象存储,数据库只存对象键和哈希 |
## 2. 租户隔离的落地
### 2.1 行级安全策略
- api 和 worker 每个事务开始时执行 `SET LOCAL app.tenant_id = '<租户>'`。
- 所有带 `tenant_id` 的表都打开 `FORCE ROW LEVEL SECURITY`,策略是 `tenant_id = current_tenant()`,读和写都生效。`tenant` 表本身按 `id` 隔离。
- 没设置租户时,`current_tenant()` 返回空,什么也查不到。代码漏了租户条件时,结果是查不到,而不是查到全部。
### 2.2 复合外键
每张业务表都有 `UNIQUE (tenant_id, id)`。子表引用父表时用 `(tenant_id, parent_id)`,所以甲租户的文档不可能挂到乙租户的项目上。行级安全策略只管「能看到哪些行」,复合外键管的是「行和行之间的引用不跨租户」,即使是绕过策略的运营后台也写不进去。
代价是每个外键多一列。好处是私有化客户如果关掉了行级安全策略(AR3),这一道保护仍然在。
### 2.3 登录前的查询
请求刚进来时还不知道租户,这时要按令牌哈希找会话,或者按人找他属于哪些企业。这两类查询用两个 `SECURITY DEFINER` 函数来做:
- `auth_resolve_session(token_hash)`:返回会话、租户和成员身份,同时校验会话没吊销、没过期,成员身份和企业都还有效;
- `auth_list_memberships(user_id)`:返回这个人的成员身份列表,供切换企业使用。
两个函数的所有者是 `bd_auth` 角色,它能绕过行级安全策略,但只对 `user_session`、`membership`、`tenant` 三张表有 SELECT 权限。应用角色只有调用这两个函数的权限。这样 `user_session` 和 `membership` 仍然是普通的租户表,§3.14 说的「只有两张全局表例外」不变。
### 2.4 数据库角色
| 角色 | 用途 | 行级安全策略 |
|---|---|---|
| `bd_owner` | 建表和迁移 | 表的所有者,因为打开了 FORCE,同样受约束 |
| `bd_app` | api 和 worker | 受约束 |
| `bd_auth` | 只拥有 §2.3 的两个函数,不用来登录 | 绕过,只限三张表 |
| `bd_ops` | 运营后台 | 绕过;每次跨租户操作都写审计事件 |
## 3. 表清单与模块归属
| 模块 | 表 | 说明 |
|---|---|---|
| identity | `tenant` `user_account` `login_identity` `membership` `user_session` | 人、登录方式、成员身份三层(AR11) |
| billing | `plan` `subscription` `activation_request` `quota_account` `quota_entry` `quota_alert` | 套餐带版本;开通申请注销后保留 |
| project | `project` `project_member` `lot` `project_field` | 项目字段的 AI 值和人工值分开存 |
| document | `source_document` `document_page` `parse_job` `citation` | 出处表归 document 模块,各模块共用 |
| checklist | `checklist` `checklist_item` `checklist_item_citation` `checklist_version` `checklist_version_item` `marking_rule` | |
| check | `check_run` `check_run_document` `check_item_result` `finding` `finding_citation` `manual_check` | `check_item_result` 2026-11-04 新增(C9) |
| report | `report` | |
| qualification | `qualification` `qualification_file` | |
| ai | `ai_call` | |
| audit | `audit_event` | |
| ops | `ops_admin` `ops_session` `feedback` | 反馈由用户写,在运营后台处理 |
| notification | `notification` | 站内通知(G14),v0.1 新增的小模块,已加入架构 §3.2 |
| (基础设施)storage | `data_key` | 信封加密的数据密钥(DM4) |
```mermaid
erDiagram
tenant ||--o{ membership : ""
user_account ||--o{ membership : ""
user_account ||--o{ login_identity : ""
membership ||--o{ user_session : ""
tenant ||--o{ subscription : ""
tenant ||--o{ quota_account : ""
quota_account ||--o{ quota_entry : "只追加"
tenant ||--o{ project : ""
project ||--o{ project_member : ""
project ||--o{ lot : ""
project ||--o{ project_field : ""
project ||--o{ source_document : ""
source_document ||--o{ document_page : ""
source_document ||--o{ parse_job : "每次重试一行"
source_document ||--o{ citation : "不可变"
project ||--|| checklist : ""
checklist ||--o{ checklist_item : "稳定编号"
checklist_item }o--o{ citation : "checklist_item_citation"
checklist ||--o{ checklist_version : "不可变快照"
project ||--o{ check_run : ""
check_run }o--|| checklist_version : ""
check_run }o--o{ source_document : "check_run_document"
check_run ||--o{ finding : ""
finding }o--o| checklist_item : ""
finding }o--o{ citation : "finding_citation(招标侧 / 投标侧)"
check_run ||--o{ report : ""
```
## 4. 身份与额度
### 4.1 身份
- **注册**:建 `user_account`、`login_identity`(来源为手机号)、`tenant`、`membership`(所有者)、免费版 `subscription`,再建额度账户并发放额度,都在同一个事务里完成。
- **手机号**(DM5):`login_identity.external_user_id` 存的是号码的 HMAC 值,用来查找和保证唯一。完整号码加密后存在 `contact_cipher` 里,用来发短信、在运营后台查询。界面显示 `contact_hint`(138****5678)。数据库泄露时拿不到明文号码。
- **一个手机号只属于一个企业**(FR-M6-04):在服务层检查,表结构不做限制(AR11)。
- **异地多设备并发登录**:按 `user_id` 列出未吊销的会话,被挤下线的会话记 `revoke_reason = concurrent_login`。
### 4.2 额度流水
`amount` 是这笔流水对余额的影响,余额等于 `sum(amount)`。`quota_account.balance` 是缓存,在同一个事务里更新,定期和流水对账。
| 类型 | amount | 说明 |
|---|---|---|
| 发放 grant | +N | 挂在订阅上(`subscription_id`),「本期」按订阅周期计算 |
| 预扣 hold | −1 | 发起任务时记,和建任务、入队在同一个事务里(架构 §3.4) |
| 结算 settle | 0 | 任务成功;`hold_entry_id` 指回预扣 |
| 退回 release | +1 | 任务失败、超时、取消;指回预扣 |
| 免扣 waive | 0 | 24 小时内复查(Q13);`reason = recheck_24h`,关联这次检查 |
| 过期 expire | −余量 | 订阅周期结束。如果还有没了结的预扣,等它了结后再记 |
| 调整 adjust | ±N | 运营后台人工调整,原因必填 |
几条典型流水:
- 解析成功:预扣 −1 → 结算 0,净扣 1 次。
- 解析失败后重试成功:预扣 −1 → 退回 +1 → 预扣 −1 → 结算 0,净扣 1 次。重试建一条新的 `parse_job`,所以幂等键不会冲突。
- 24 小时内复查:只记一行免扣 0(Q13),用户能在明细里核对。
数据库层面的保证:
- 幂等键 `(tenant_id, idempotency_key)` 唯一,例如 `parse_job:<id>:hold`;
- 一笔预扣最多只能有一个结局(结算或退回),由部分唯一索引保证;
- 调整必须填原因,结算和免扣的数量必须是 0,由 CHECK 约束保证。
预扣前先对 `quota_account` 加行锁,确认余额 ≥ 1。同一租户的并发预扣就此串行,不会扣成负数。
### 4.3 检查怎么扣
计费口径来自 01 §4,这里只说怎么落到表上:
- 专业版的检查从解析额度扣:预扣记在 `unit = parse` 的账户上,关联 `check_run`。
- 免费版只限 1 个项目,项目内的检查不限次数:`check_run.billing = free_plan`,不记流水。免费版没有检查额度可言,每次都记一行 0 只会让明细变乱。
- 24 小时免扣:`project.last_charged_check_at` 是起点,只在真正扣了额度的检查之后更新(P1)。窗口内的检查 `billing = waived_recheck`,记一行免扣。
- `quota_account.unit` 里预留了 `check`、`generate`、`dedupe`,给以后的套餐用。
## 5. 文档、清单与检查
### 5.1 文档与出处
- **`source_document`** 记录招标文件、补遗、澄清和投标分册:
- 招标侧:`seq_no` 就是「补遗 N」里的 N;
- 投标侧:同一分册的各个版本共用一个 `lineage_id`,`seq_no` 是版本号;
- 规范化 PDF 的键和哈希一旦写入就不能再改,由触发器保证(04 §4.1);
- `pipeline_profile` 记下转换器、字体包、切句算法的版本,复盘时能知道这份 PDF 是怎么来的。
- **文字层**(DM1):逐字坐标、字体、格式和句子切分结果,放在对象存储里(`text_layer_key`),按页分片。
- 数据库只存 `document_page`:页尺寸、文字来源(文字层 / OCR)、字数、句数。用来做逐页覆盖率检查,阅读器也要用页尺寸。
- 句子编号沿用 S3 的格式 `<文档>:p<页>:s<页内序号>`。规范化 PDF 不会重新生成,所以编号稳定。
- **`citation`** 不可变,存这些内容:
- 规范化 PDF 的哈希、页码;
- 同一页上连续的句子编号,需要时再加句内的字符范围;
- 坐标框快照、原句快照;
- 三道校验通过了哪几道(`checks_passed`)。
- 阅读器按坐标框画高亮,不用再读文字层。哈希和文档当前的哈希对不上时拒绝渲染,宁可不显示,也不画错位置。
- **一条要求跨页时**,就建多条出处。
### 5.2 清单项
一条清单项有三组值:
| 字段 | 内容 |
|---|---|
| 当前值(各列) | 界面和检查用的值;人工改过的字段以人工为准 |
| `ai_values` | 最近一次 AI 给出的原值,人工修改不覆盖(架构 §3.8),用于误报分析 |
| `edited_fields` | 人工改过哪些字段。重新解析对齐时,这些字段不动 |
- **类别**:`categories` 可以有多个标签。CHECK 约束保证 ★ 条款一定带「无效 / 废标」标签(FR-M2-05)。
- **类别特有字段**(05 §3.3)放在 `spec` 里,**按类别分组**:一条清单项有几个类别,就可以有几组,每组用对应的 Pydantic 模型校验。例如 ★ 技术参数是 `{"tech_param": {"param": "内存", "op": ">=", "value": "32", "unit": "GB", "device": "服务器"}, "invalidity": {"source": "substantive_marker"}}`。规则引擎要用的字段都放在这里,常用的再提出来建索引。完整定义见 `openapi.yaml` 的 `ItemSpec`。
- **重要性依据**:`importance_basis` 记下凭什么认定为 ★,可以是标注符号、法规列明或原文写明。`marking_rule_id` 指向文件自己的标注规则(07 F1)。
- **区域**:`zone` 分待确认区和正式清单。进待确认区的原因写在 `pending_reason`:没有出处、置信度低、校验没通过、标注句没被覆盖(规则兜底生成的)、重新解析时没再出现。待确认区的条目不参与检查(FR-M2-07)。
- **补遗**:
- 补遗带来的新条目,`origin_document_id` 指向这份补遗;
- 日期类条目被更新时,旧条目的 `superseded_by_id` 指向新条目,等用户确认(FR-M2-10);
- 项目字段(截止时间等)的新值先放在 `project_field.pending_value` 里,用户确认后才生效。
- **软删除**:`deleted_at`,可以恢复(FR-M2-08)。修改历史从审计事件里查。
- **标注规则**:`marking_rule` 存文件里的「标注规则说明」,包括标记、含义和出处。文件写明了数量的(比如「★ 项共 14 条」),存在 `declared_count` 里,用来和实际抽到的数量对比。
### 5.3 报价限价三层
07 F6 说的三层限价,分别落在:
| 层 | 存在哪里 |
|---|---|
| 总价限价 | `project_field`,字段 `ceiling_total` |
| 采购包限价 | `lot.ceiling_amount` |
| 分项限价、单价上限 | 清单项,类别是「报价」(DM7),`spec = {"price": {"kind": "item_ceiling", "target": "<分项名>", "op": "<=", "value": "..."}}` |
报价形式检查读投标文件里的金额,和这三层比对。读到的金额写在问题的 `detail` 里,同时保留原文写法和出处。
### 5.4 清单版本
- 用户确认清单时,生成一份 `checklist_version`(`kind = confirmed`),每条正式条目在 `checklist_version_item` 里存一份当时的值和出处编号。
- 清单没确认也能检查(G7):检查开始前也生成一份快照(`kind = check`),检查批次总是引用某一个快照,所以历史检查都能复现。
- 内容哈希和上一份快照相同时,直接复用上一份,不重复生成。
### 5.5 检查批次与问题
- **`check_run`** 记下:清单快照、本次用的投标文件版本(`check_run_document`)、规则集版本、法律体系、所投的包、计费方式。阶段检查点和取消标记的用法与解析任务一样。
- **`finding.match_key`** 是复查沿用的匹配依据(FR-M3-08),和问题类型一起,在同一批次内唯一:
- AI 判断的问题:`item:<清单项编号>`,必须挂在清单项上;
- 规则问题:有对应清单项时也挂上(比如分项限价);没有时用 `rule:<规则编号>[:<目标>]`,比如大小写金额不一致(DM6)。
- **`presence`**:
- 新增:本次新出现的问题;
- 沿用:上次也有,沿用上次的处理状态,上次是「已修改」的改回「待处理」;
- 本次已解决:上次有、这次没有的问题,在本批次里留一行,状态不再变。这样每个批次都能独立列出完整结果,报告也只需要读一个批次。
- **处理规则的数据库约束**:忽略必须写原因;致命问题选「确认无误」也必须写说明(U4)。这句说明同时写一条反馈(`feedback.source = fatal_confirm`)。
- **投标分册**(C8,2026-11-04):`source_document.volume_kind` 记分册类型(整本 / 资格证明 / 商务 / 技术 / 报价 / 其他),只用来排序检索候选和写「找过哪些分册」。
- **`check_item_result`**(C9,2026-11-04 新增):每次检查对每条参与检查的清单项留一行结论,「已响应」也留。
- 字段:结论(已响应 / 未找到 / 负偏离未说明 / 疑似不满足 / 未给出具体值 / 未判定 / 扫描件待人工核对)、走的哪一层(偏离表 / 规则比较 / 模型)、投标侧出处、校验状态、原因码、所属的重复组、token;
- 问题只为没响应好的条目生成,`finding` 通过清单项和它对应;
- 24 小时内复查时,按「清单项内容哈希 + 投标分册版本哈希 + 提示词版本」复用上一次的结论,不再调用模型(C2);
- 读标清单上的「已响应 · 第 N 页」、对已响应的「判断有误」反馈、报告的覆盖范围,都读这张表。
- **`manual_check`**:需人工核对的勾选按项目存(P2),以 `check_key` 为键,复查时直接读取,报告里写明勾选人和时间。
### 5.6 就绪标记
`project.open_fatal_count` 是最近一次检查里还没处理的致命问题数,`project.latest_check_run_id` 指向最近一次检查。处理问题时,check 模块在同一个事务里更新这两个值。工作台列表直接读,不用每次去数问题表。数值大于 0 就是「未就绪」,但只在「已检查」状态下显示(G7)。
## 6. 删除
### 6.1 删除项目(J4、G10)
一个事务内:
1. 写一条审计事件「删除项目」,不含业务内容;
2. 把这个项目的审计事件的 `before` 和 `after` 抹掉,`content_digest` 保留,哈希链仍然能校验;
3. 删除 `project` 这一行,项目下的文档、出处、清单、检查、问题、报告、反馈都级联删除;
4. 销毁这个项目的数据密钥(`data_key.wrapped_key` 置空)。
事务提交后,再由后台任务删除对象存储里 `t/<租户>/p/<项目>/` 下的文件。删除失败也没关系:密钥已经销毁,这些文件和备份里的副本都已无法解密。
保留下来的数据:
- 额度流水:`project_id` 和任务编号都不加外键,流水仍然在,明细里显示「(已删除的项目)」;
- `ai_call`:不含内容,成本数据保留;
- 审计记录:只剩操作类型、时间、对象编号。
### 6.2 注销账号
MVP 一个企业只有一个成员,所以注销账号就是注销企业:
1. 按 §6.1 删除所有项目,再删资质库和通知;
2. 吊销所有会话,成员身份改为「已离开」;
3. 删除 `login_identity`;如果这个人不再属于任何企业,`user_account` 改成匿名(显示名换成「已注销用户」),这一行本身保留,审计事件里的 `actor_user_id` 仍然有效;审计里的姓名快照一并抹掉;
4. 企业改为「已注销」,这一行保留,开通申请、订阅和发票记录按财务要求保留(J4)。
## 7. 对象存储布局
```
t/<tenant>/p/<project>/doc/<document>/original 原件
t/<tenant>/p/<project>/doc/<document>/normalized.pdf 规范化 PDF(只写一次)
t/<tenant>/p/<project>/doc/<document>/text/p<NNNN>.json 文字层,按页分片
t/<tenant>/p/<project>/doc/<document>/structure.json 结构识别结果(章节、表格、分包)
t/<tenant>/p/<project>/ai/<input_hash> 模型输入(排查问题用)
t/<tenant>/p/<project>/report/<report>.pdf 检查报告
t/<tenant>/q/<qualification>/<file> 资质附件(企业级密钥)
```
- 按项目分前缀,删除项目时可以整个前缀一起删;
- 每个对象都用所属项目(或企业)的数据密钥加密;
- 对象存储不开公开读,下载一律经过 api 签发短时有效的链接。
## 8. 审计与 AI 流水
- **`audit_event`**:
- 和业务修改在同一个事务里写入(架构 §3.10);
- 记下操作人、姓名快照、动作、对象、修改前、修改后、请求编号;
- 触发器只允许一种修改:把 `before` 和 `after` 置空,并写上抹除时间。其他字段都改不了,也不能删除;
- 可选的哈希链(`prev_hash`、`row_hash`)只覆盖内容的哈希(`content_digest`),不覆盖内容本身,所以抹掉内容以后链条仍然能校验。
- **`ai_call`**:字段按架构 §3.6,另加估算成本(百万分之一元),给 D2 定价用。不存原文。数据量大了以后按月分区。
## 9. 决策(已确认)
| # | 事项 | 结论 | 影响 |
|---|---|---|---|
| DM1 | 句子放在哪里 | **放对象存储,不进数据库**。S3 实测每份文件约 2400 句;一个项目算上投标分册的多个版本,有几万句;1000 个企业一年就是十亿行级。用到句子的地方(抽取、校验、对齐、检索)都在 worker 里按文档整份读取,不需要在数据库里查询;阅读器读的是出处快照 | 修改架构 §9「数据库里只存句子和块的索引」 |
| DM2 | 枚举怎么存 | `text` 加 `CHECK`,不用 PostgreSQL 的枚举类型。改枚举值只要改约束,信创数据库的兼容性也更好 | 工程约定 |
| DM3 | 外键带租户 | 所有业务表之间的外键都带上 `tenant_id`(§2.2) | 每个外键多一列;关掉行级安全策略时仍能防止跨租户引用 |
| DM4 | 加密粒度 | **每个项目一把数据密钥**,资质附件用企业级密钥。删除项目时销毁密钥,备份里的数据随之不可读(落实 G10「彻底删除」) | 架构 §3.11 原文写的是「每个租户(或每个项目)」,这里定为项目 |
| DM5 | 手机号怎么存 | **不存明文**:查找用 HMAC 值,发短信用密文,界面显示掩码 | 运营后台按手机号查询时,同样先算 HMAC |
| DM6 | 规则问题要不要挂清单项 | **AI 判断的问题必须挂清单项**;规则问题有对应条目时挂,没有时(大小写金额不一致、出现其他项目名称等)用规则编号,界面显示规则说明 | FR-M3-03「每个问题都对应一条清单项」改为「AI 判断的问题都对应一条清单项;规则检查的问题对应清单项或规则」 |
| DM7 | 分项限价放在哪 | **新增清单类别「报价」**,承载分项限价和单价上限(§5.3)。总价限价和包限价分别放在项目字段和分包上 | 05 §3.2 的类别从 6 个变为 7 个,FR-M2-05 同步修改 |
## 10. 验证与下一步
**已验证**(2026-11-03,PostgreSQL 16,`schema-draft-test.sql`,16 项全部通过):
- 租户隔离:
- 只能看到本租户的数据;没设置租户时什么都查不到;
- 不能往别的租户写数据,也不能删别的租户的项目;
- 复合外键拦住了跨租户引用,超级用户写入也拦得住;
- 登录前的两个函数在没有租户上下文时也能正常查到会话和成员身份。
- 额度:预扣加结算后余额正确;一笔预扣不能有两个结局;流水不能修改;人工调整必须写原因。
- 其他约束:
- 规范化 PDF 写入后不能替换;
- ★ 条款必须带「无效 / 废标」标签;
- 致命问题「确认无误」必须写说明;
- 审计事件除了抹掉内容,其他字段都改不了。
- 删除项目:项目下的数据全部级联删除,额度流水和抹掉内容后的审计记录保留。这一项第一次测试没通过:多条级联路径之间互相阻塞。改为「项目内的外键一律级联」后通过(§1)。
**已回写**:架构 §3.2(加 notification 模块)、§3.11、§9;05 §3.2–3.3;FR-M2-05、FR-M3-03。
**下一步**:架构 §12 的第 3 步,用 OpenAPI 定义核心接口。

View File

@@ -0,0 +1,221 @@
# 读标抽取评测:S01–S10 标注集 v1(2026-11-03)
> **修复后的结果见 §7**:F1–F8 和四个口径问题已经处理。§7 的分数是在同一批 10 份样本上调出来、又用这批样本评的,评分脚本里还有两条「按定义算对」的规则,**不能当作达标的证据**。只按人工标注算的分数、泛化风险和下一步见 `extract-status.md`。§1–§6 是修复前(base)的评测,保留作对照。
> 范围:读标(M2)的五类清单抽取和项目基本信息,在 S01–S10 上的召回率和准确率。
> 模型:DeepSeek Flash 不思考模式(决策 M1,不再对比 Pro 和思考模式);提示词 substantive v4、qualification / scoring / schedule / tech_param v3、project_info v2;代码版本 f99bb8b。
> 工具和复现方法见 `evals/README.md`;标注规范见 `evals/gold/GUIDELINES.md`。
## 1. 结论
| 指标 | 目标(TV1) | 结果 | |
|---|---|---|---|
| 标注类实质性条款召回(★ / * / 斜体下划线) | ≥ 99% | 正式清单 99.4%,含待确认区 **99.7%**(346/347) | ✓ |
| 写明无效类召回 | ≥ 90% | 正式清单 77.0%,含待确认区 **85.4%**(223/261) | ✗ |
| 进入正式清单的实质性条款准确率 | ≥ 95% | **87.8%**(811/924) | ✗ |
- **标注类达标**:规则兜底起了作用。唯一漏掉的一条,是 S07 那句既说明标注方式、又带 * 号的长句。
- **另外两项不达标,原因都在规则,不在模型**:
- 写明无效类:表格项靠表后的说明定性,「拒绝接收」这类写法没认出来。
- 准确率:合并清单项时,没通过校验的条目被并进了正式条目。
- **其他四类**:
| 类别 | 召回 | 准确率 | 主要问题 |
|---|---|---|---|
| 资格条件 | 98.9% | **38.4%** | 模型多抽 |
| 评分项 | 93.8%(含待确认 99.5%) | 100% | |
| 日期与递交 | 100% | 73.7% | |
| 技术参数(抽样页) | 91.1% | 98.6% | 召回差在一种静默丢失:分块输出接近上限时,模型提前收尾 |
- **项目信息**:12 个字段里 8 个全对。`eval_method` 只对 3/10,`sme_only` 只对 1/10,都是规则问题(§3.6)。
- **成本**:10 份文件全部任务,输入 386 万 token,输出 76 万 token,约 13.8 元(高峰价,未计缓存命中),**平均每份约 1.4 元**。tech-validation §4.3 推算的是 0.6~1.0 元,实测偏高,主要多在 S03、S06 这两份长文件的技术参数输出。D2 定额度时按实测值算。
## 2. 方法
- **标注集**:`evals/gold/S01.json`~`S10.json`,共 1,829 个单元(其中 76 个是裁定后补的,见下文)。
- 每份文件由一个子代理按 `GUIDELINES.md` 标注。子代理只看文字层(`sentences.txt`),不看模型输出。
- 定位用「页码 + 原文片段」,切句规则变了也能用。
- 五类清单和项目信息全文标注。技术参数量太大,每份在技术要求章里等距抽 6 页,这 6 页上全标。
- **目前是 AI 标注、AI 复核,没有人工复核**(`reviewed: false`)。
- **口径统一**:标完后发现各份的处理不一致,统一了三条:句子本身带标注的一律必标;章节标题带 ★ 的继承暂记可选;`sme_only` 原文没写明的改为 null(GUIDELINES §4)。
- **打分**(`evals/score.py`):
- 召回:有对应类别的清单项,出处句子和标注的句子有交集,就算召回。正式清单算一遍,加上待确认区再算一遍。
- 准确率:正式清单里这一类的条目,出处和这一类的某条标注有交集,就算准确。
- `optional` 的标注不进召回的分母,抽到了算对。
- **裁定**:正式清单里对不上标注的 543 条,逐条判断是标注漏了还是模型抽错了。
- 实质性条款、资格条件、日期递交由三个子代理裁定,评分和技术参数我自己裁定。
- 标注漏了的有 76 条,补成 `optional` + `added_in_review`:只修正准确率,不抬高召回率。
- 抽错的按原因分类,结果在 §3。
- **S07 的预处理**:S07 是 WPS 旧版 DOC,产品现在的转换流程直接转,正文全是「第第第第」(S1 §1.3 记录过,转换流程还没做这一步)。评测时在 `prepare.py` 里先转 DOCX、修正字符集。**这是产品缺陷,要在转换流程里补上。**
## 3. 问题和原因
按「改规则能解决」和「要改提示词」分开。规则只修真正的缺陷,不迁就模型的错误输出(SK11 的原则)。
### 3.1 合并清单项时,没通过校验的条目被并进正式条目(规则缺陷,影响最大)
- **现象**:`merge_items` 按首句合并各任务的结果,只跳过「同一任务」的待确认条目。别的任务的待确认条目照样把类别、重要性和出处并进正式条目。
- **后果**:实质性条款任务里依据校验没通过的条目,借技术参数、资格条件的正式条目进了正式清单,还被标成实质性。
- 实质性条款多出来的 113 条里,89 条原文根本没写无效后果,大部分是这样来的。
- S07 一份就有 93 条:普通技术参数被标成了「致命」。
- **修复**:合并时,待确认条目不给正式条目加类别、不提高重要性。它单独留在待确认区,或作为待确认的依据附在正式条目上。
### 3.2 写明无效类的漏抽(规则为主)
| 原因 | 条数 | 修复 |
|---|---|---|
| 表格项靠表后的说明定性:「上述第 1~6 项必须全部提供,否则投标无效」写在资格证明表、符合性审查表**下面**。结构识别只往上找上级条款,看不到它 | 33(S03、S05、S07、S08) | 结构识别加一种:以「上述」开头、写明无效后果的说明,覆盖它上方同一张表的各项 |
| 后果写在另一条里:「有下列情形之一的,视为串通投标」下的各项,「串通投标的投标无效」写在别处 | 7 | 「视为串通投标」作为无效的上级标题。依据:87 号令,串通投标的投标无效 |
| 无效写法没认全:「其投标将被拒绝」「拒绝接收」「拒绝接受」「无资格参加」 | 7 | `INVALID` 补这几种写法 |
| 符合性审查表各项只在表头写了「不通过的投标无效」,上级关系没识别出来 | 13 | 和第一行一起做:表格结构识别 |
### 3.3 资格条件多抽(提示词为主)
正式清单里 466 条资格条件,裁定后 287 条不是资格条件:
| 抽错的内容 | 条数 |
|---|---|
| 技术参数,尤其是写着「提供认证证书 / 检测报告」的参数行 | 112 |
| 投标文件的组成、编制、递交、签章、授权书 | 54 |
| 无效情形、串通情形 | 34 |
| 第六章格式范本里的声明函、承诺函正文(提示词已经禁止,模型照样抽) | 27 |
| 价格扣除、联合体组成规则 | 18 |
| 合同履约、服务要求 | 11 |
| 程序说明 | 5 |
| 其他 | 26 |
- **规律**:句子里带「资格」「证明材料」「须提供」就收。
- **修复**:
- **规则**:结构识别补上章节层级(架构 §3.5 的 TODO)。第六章格式范本和第四章技术参数,不交给资格任务。这一项能去掉一半以上的错。
- **提示词**:qualification v4 把「技术参数里的证书要求」「投标文件组成和签章」「无效情形」写进「不要输出」,各给一个例子。
### 3.4 日期与递交多抽(提示词)
88 条对不上标注,裁定后 29 条是标注漏了(同一要求在另一处换了说法),59 条是模型抽错了:
- 质疑投诉的程序和形式 23 条;
- 运维服务时限、驻场人数 11 条,主要在 S09,模型看到时限、人数就当成了日期;
- 开标记录 5 分钟内提疑义 7 条;
- 中标后事项 5 条;
- 其他 13 条。
**修复**:schedule v4 的「不要输出」补这几类。
### 3.5 技术参数漏抽(规则 + 提示词)
- **静默丢失(规则)**:S03 一个分块有 259 句密集参数表。模型输出 97 条、7,757 个 token(上限 8,192)就自己收了尾,JSON 完整,没被判成截断,后面两页多的参数整段丢了(抽样页上 17 条)。
- **修复**:输出 token 超过上限的 90%,且最后一条证据离分块末尾还远,按截断处理、对半重跑。技术参数任务的分块也可以改小。
- **描述性的实施、服务要求被当成背景(提示词)**:S05、S06、S08、S09、S10 共约 30 条,例如「须统一部署于市级政务云」「试运行不少于 1 个月」。
- **修复**:tech_param v4 写明:带「须」「不少于」「不得」的实施、服务要求要抽,项目背景和建设目标才不抽。
### 3.6 项目信息(规则)
- **`eval_method` 7/10 没答出来**:「本项目采用综合评分法」写在第五章评标办法里,项目信息只看前 3 个分块。
- **修复**:项目信息任务再加一个分块,就是评标办法一章的开头。
- **`sme_only` 8/10、`accept_imported` 2/10 核对不通过**:是 / 否字段的否定词表没有「非」「不专门」,「非专门面向中小企业」被判成证据和取值对不上。
- **修复**:否定词表补上。
### 3.7 其他
- **评分项待确认区里 68% 是好结果**(17/25):被拦下的原因是引文不是原文、分值找不到,都出在评分表被切碎的地方。这正好是已经排好的「表格按单元格切句」。
- **重复条目**:
- 同一条标注被两条以上正式条目命中:实质性条款 115、资格条件 41、日期递交 35。
- 主要原因是同一要求在公告、前附表、审查表里各出现一次,各抽一条。
- 这本身不算错,但清单会显得重复。查标阶段要考虑按语义合并。
## 4. 修复清单(按收益排序)
| # | 改什么 | 类型 | 预计影响 |
|---|---|---|---|
| F1 | 合并时待确认条目不给正式条目加类别、提重要性(§3.1) | 规则 | 实质性条款准确率 87.8% → 95% 以上 |
| F2 | 结构识别:「上述……否则无效」往上覆盖表格各项;「视为串通投标」算无效标题;`INVALID` 补写法(§3.2) | 规则 | 写明无效类召回 85% → 95% 左右 |
| F3 | 静默提前收尾按截断处理、对半重跑(§3.5) | 规则 | 技术参数召回,S03 这类长表 |
| F4 | 章节层级:第六章格式、第四章参数不交给资格任务(§3.3) | 规则 | 资格条件准确率 |
| F5 | qualification v4、schedule v4、tech_param v4 的「只找 / 不要输出」(§3.3–3.5) | 提示词 | 资格、日期准确率,技术参数召回 |
| F6 | 项目信息加评标办法分块;是 / 否字段否定词补「非」(§3.6) | 规则 | `eval_method`、`sme_only` |
| F7 | 转换流程补 WPS 旧版 DOC 预处理(§2) | 规则 | S07 这类文件能用 |
| F8 | 表格按单元格切句(已排期) | 规则 | 评分项、技术参数的待确认 |
## 5. 口径问题(已定,见 decisions.md Q1–Q4)
标注时遇到、规范里没定的,影响产品行为,需要拍板:
1. **★ 标在整节标题上**(S01「三、★商务要求」并写明商务条款全部是实质性、负偏离无效;S07「★售后」;S10「★(一)技术要求」「★(二)商务要求」):节里不带 ★ 的各句算不算实质性条款?
- 现在的流水线只认标题这一句,标注集暂把节里的句子记为可选。
2. **★ 没写无效后果**(S02):文件只说「★ 为核心技术参数」,评分表里 ★ 不满足只扣 4 分。
- 现在一律按实质性处理,用户会看到「致命」,实际只是扣分。
3. **「必须 / 应」都是实质性要求**(S07 第 18 页的声明):逐句标出不现实。
- 建议只在文件层面提示「本文件声明含『必须』『应』字样的要求均为实质性要求」,不逐句抽。
4. **资格条件是否一律带「无效」类别**:政府采购里资格审查不合格就是投标无效,87 号令第六十三条有规定。
- 一律带上的话,§3.2 第一行那 33 条漏抽自然解决。
- 但写明无效类的口径会从「文件写明」变成「法规规定」,要和「双层口径」一并考虑。
## 6. 没覆盖的
- 标注集没有人工复核。
- 1,829 个单元是 AI 标的;裁定时发现,各份文件之间对「授权书算不算资格」「质疑截止算不算日期」的处理仍有差异。
- 建议先人工抽查实质性条款(S03、S06 最难),再把 `reviewed` 改成 true。
- 样本缺口没变:扫描件、补遗、招标投标体系、配套投标文件。
- 原句高亮准确率(FR-M2-06)不在本次范围,等表格按单元格切句后再验。
## 7. 修复后(fix3,2026-11-03)
F1–F8 全部实现,四个口径按建议执行(决策 Q1–Q4)。改完先请 Fable 当顾问审读(§7.3),改了它指出的问题,再跑两轮。
> **注意(同日复核)**:下表「修复后」一列,实质性条款准确率里有 320 条是评分脚本按定义算对的(★ 节、法规无效),只按人工标注算是 72.4%;标注类召回按 S02 的原标注算是 98.9%;资格、日期的准确率含 AI 裁定补标。详见 `extract-status.md` §2。
### 7.1 结果
| 指标 | 目标 | 修复前(base) | 修复后(fix3) |
|---|---|---|---|
| 标注类实质性条款召回(含待确认区) | ≥ 99% | 99.7% | **99.4%** ✓ |
| 写明无效类召回(含待确认区) | ≥ 90% | 85.4% | **98.1%** ✓(正式清单 84.7%) |
| 实质性条款准确率(正式清单) | ≥ 95% | 87.8% | **98.4%** ✓ |
| 资格条件 召回 / 准确率 | | 98.9% / 38.4% | 97.7% / **90.0%** |
| 评分项 召回 / 准确率 | | 93.8% / 100% | **97.4%** / 100% |
| 日期与递交 召回 / 准确率 | | 100% / 73.7% | 95.6% / **99.3%** |
| 技术参数(抽样页) 召回 / 准确率 | | 91.1% / 98.6% | **98.4%** / 98.8% |
| 项目信息:评标方法 / 最高限价 / 是否专门面向中小企业 | | 3 / 9 / 1(共 10 份) | **9** / **10** / **7** |
| 每份文件的成本(Flash 高峰价) | | 约 1.4 元 | **约 1.2 元**(按章跳过,输入少了 27%) |
- **口径**:两次的标注集不完全相同。修复后按 Q1、Q2 改了口径(S01 节内 12 条改必标,S02 的 11 条 ★ 改可选),技术参数的 S07、S09 在新分页上重标,两轮裁定共补了 117 个可选单元。召回率的分母基本没变(597 → 610)。
- **裁定**:准确率都是裁定过的,正式清单里对不上标注的条目逐条判断过,方法同 §2。
### 7.2 改了什么
| # | 改动 | 位置 |
|---|---|---|
| F1 | 合并时,待确认条目不再给正式条目加类别、提重要性;没通过的类别记在正式条目的 `ai_values.pending_categories` 里,不丢 | `extract.merge_items`、`add_pending_categories` |
| F2 | 表后往回指的说明(「上述……否则投标无效」)覆盖上方的编号条目和表格行;「视为串通投标」算无效依据;`INVALID` 补「被拒绝」「拒绝接收」「投标文件无效」「不实质性响应」等写法;规则兜底也认表后说明 | `structure.trailing_notes`、`extract.INVALID` |
| F3 | 输出用到上限的 92% 以上按截断处理、对半重跑;拆到底时采用已有输出 | `ai/providers/base.near_limit` |
| F4 | 章识别(公告、须知、合同、采购需求、评审、格式……),资格、日期、参数、评分任务跳过不相干的章;章认不可靠时不跳过 | `structure.chapters`、`extract.SKIP_CHAPTERS` |
| F5 | 提示词 substantive v5、qualification v4、schedule v4、tech_param v4;规则兜底:格式范本正文(「我单位……」)、列表引导句和整表概括句不收 | `prompts/`、`extract.TEMPLATE`、`BLANKET` |
| F6 | 项目信息多看写着「本项目采用综合评分法」的那一块;是 / 否字段否定词补「非」「不专门」 | `extract.EVAL_METHOD`、`NEGATIVE` |
| F7 | WPS 旧版 DOC:直接修正 DOC 字体表里中文字体的字符集,不用先转 DOCX | `convert.fix_wps_doc_charset` |
| F8 | 有框线的表格按单元格切句(切句 s3-v4),竖排窄列、两栏表格不再串行 | `text_layer._fragments` |
| Q1 | ★ 节标题下的条款继承实质性 | `structure.star_sections` |
| Q2 | ★ 被定义成计分项时降为「重要」,条件从严 | `extract.star_scored` |
| Q3 | 「必须 / 应」声明记成 keyword 标注规则 | `extract.marking_notes`、`checklist.service.merge` |
| Q4 | 政府采购项目的资格条件带「无效」类别,依据记 statute | `extract._qualification` |
### 7.3 顾问审读(Fable)
请 Fable 只读审了第一版修复,确定的问题 7 条,都已改正:
1. ★ 定义句的降级会误伤真正的废标 ★:改成要有计分证据,而且全文没有带 ★ 的后果写法;
2. 章识别会被目录、正文里的引用带偏:排除编号条目,同样长取靠后的序列,不可靠时不跳过;
3. F1 第一版把待确认条目直接丢了:改成记在 `pending_categories`;
4. Q2 文件里实质性任务仍标致命:改为按重要性规则定级;
5. 表后说明会把标注说明、否定句当成无效依据:排除这两种写法;
6. 参数行带 ★ 编号被当成节标题:要求 ★ 紧挨编号,标题短、不带标点和数字;
7. 接近上限拆到底时丢弃完整输出:改为采用已有输出。
推测性的几条也采纳了:合同章里的「拒绝接受」不算无效依据,引导句过滤只针对冒号结尾或很短的句子,F7 收紧字体记录长度的检查。
### 7.4 还剩的问题
- **写明无效类正式清单召回只有 84.7%**:大部分在待确认区(规则兜底或依据校验没过),用户能看到,但要多点一下。主要是符合性审查表各项(表头写「任何一项不符合即无效」,在表格上方),以及串通情形。
- **待确认区里只有 62% 是好结果**(127/205):规则兜底和 `pending_categories` 带进来的噪音偏多。查标阶段要看待确认区的处理体验再定要不要收紧。
- **资格条件准确率 90%**:剩下的错误主要是「本项目不接受进口产品 / 分包」被当成资格条件,以及联合体、中小企业政策条款。
- **项目信息**:`accept_imported`、`sme_only` 各 7/10,多数是模型答「有歧义」或没核对通过。
- **★ 节里的商务条款**(S01 付款方式、履约保证金):规则兜底不对继承来的句子逐句生成待确认,模型没抽到就漏了。
- **标注集没有人工复核**,口径同 §6。

View File

@@ -0,0 +1,155 @@
# 读标抽取:现状、遗留问题和下一步(2026-11-03)
> 用途:读标抽取的召回率优化先暂停,后续做专项优化时从这份文件接着做。
> 评测过程和数字见 `extract-eval.md`;本文件的结论以这里为准。§7 里「三项目标全部达标」的说法要按本文 §2 的口径重新理解。
> 依据:S01–S10 标注集评测(修复前 base、修复后 fix3),以及 Fable 顾问的两次审读(修复审读、过拟合评估),关键数字已复核。
## 1. 现状一句话
修掉了几个确定的缺陷,搭起了结构识别的框架。但现在的分数是在同一批 10 份样本上调出来、又用同一批样本评的,**泛化能力还没有被测过**。
## 2. 指标怎么读
| 指标 | 目标 | 报告值(fix3) | 只按人工标注算 | 说明 |
|---|---|---|---|---|
| 标注类实质性条款召回(含待确认区) | ≥ 99% | 99.4% | **98.9%**(356/360) | 看过输出后,S02 的 11 条 ★ 按口径 Q2 改成了可选;按原标注算不达标 |
| 写明无效类召回(含待确认区) | ≥ 90% | 98.1% | 98.1% | 正式清单只有 84.7%。新增召回主要来自两处:江苏范本的「上述……否则无效」句(33 条),以及 Q4 把资格条件定义成无效 |
| 实质性条款准确率(正式清单) | ≥ 95% | 98.4% | **72.4%**(含补标)/ 71.5%(不含) | 1,232 条里明确判错 20 条;另有 320 条靠评分脚本「按定义算对」:★ 节内 234 条(S10 占 225),资格条件法规无效 86 条。这 320 条没有人逐条核对过,真实准确率在 72%~98% 之间 |
| 资格条件准确率 | — | 90.0% | 71.5% | 两轮 AI 裁定补了可选单元;裁定理由里有不少「拿不准」,真值在两数之间 |
| 日期递交准确率 | — | 99.3% | 81.8% | 同上 |
| 评分项准确率 | — | 100% | 95.4% | 同上 |
| 技术参数准确率 | — | 98.8% | 98.3% | 同上 |
「只按人工标注算」指两点:不含两轮裁定补的 117 个可选单元;评分脚本里的两条「按定义算对」规则不生效。按这个口径,可以确认达标的只有**写明无效类召回(含待确认区)**。
## 3. 已经确认的进展(换任何文件都成立)
| 改动 | 修掉的缺陷 |
|---|---|
| 合并时待确认条目不再借正式条目进清单(`pending_categories`) | S07 一份有 93 条普通技术参数被标成「致命」 |
| 输出用到上限的 92% 按截断处理,拆到底时采用已有输出 | 模型临近上限自己收尾,S03 两页参数整段静默丢失 |
| WPS 旧版 DOC 直接修正字体表字符集 | S07 正文全是「第第第第」,产品里根本没法用 |
| 表格按单元格切句(s3-v4) | 多行单元格串行、竖排窄列一字一句,评分表和参数表的证据对不上 |
| 是 / 否字段的否定词补「非」「不专门」 | 「非专门面向中小企业」8/10 份核对不通过 |
| 「视为串通投标」算无效依据(87 号令第三十七条) | 串通情形依据校验不过 |
| 格式范本正文(「我单位……」)不收 | 资格任务把声明函逐句抽成资格条件 |
| 评分、标注工具:按「页码 + 片段」定位,切句变了能自动改写;有留出集就能直接用 | 以前没有可重复的评测 |
## 4. 泛化风险:哪些规则是照着样本调的
Fable 用样本外的写法逐条验证过,标「已验证」的是复现过的。
### 4.1 道理通用、写法和阈值照样本定(换文件可能失效或误判)
| 规则 | 样本外的失效写法 | 失效后果 |
|---|---|---|
| 章识别 `structure.chapters` | 「第1章」「第一部分」、章号和标题拆成两句、采购需求或附件放在最后(已验证:全部返回空) | 静默不跳章:召回不掉,但资格准确率退回、成本 +27%、合同章「拒绝接受」的排除失效 |
| 资格任务跳过采购需求章 `SKIP_CHAPTERS` | 磋商文件把「供应商资格要求」写在采购需求章 | **静默漏抽,没有兜底**。10 份样本里没有一条资格标注落在被跳过的章,这条规则在样本上没付过代价 |
| 表后说明 `trailing_notes` | 只认句首「上述 / 以上」;「注:第1~5项缺任一项投标无效」覆盖 0 行(已验证) | 漏抽 |
| 同上,后果词含「不合格 / 拒绝」、不看章 | 需求章「以上人员须经考核,不合格的拒绝进场」(已验证) | 上方参数行全被当成无效依据,进待确认 |
| `INVALID` 新增「不得参加 / 被拒绝 / 拒绝接受」 | 「评标委员会成员不得参加评审」「不得参加后续培训」「甲方有权拒绝接受不合格货物」(已验证) | 一般要求冒充写明无效;只排除了合同章,而且前提是章识别成功 |
| 概括句过滤 `BLANKET` / leads | 「上述证书原件须在开标时携带,否则投标无效」本身是一条要求(已验证) | 被当成引导句丢掉,兜底也不补 |
| ★ 节 `star_sections` | 「★1 核心交换机」被当成节标题(已验证);后面没有同级编号时,范围最多延伸 800 句 | S10 一个标题覆盖 381 句、产出 225 条致命,没有人逐条核对 |
| ~~★ 降级 `star_scored`(Q2)~~ | 2026-11-05 Q2 修订后 ★ 一律按实质性,降级已删除,这条风险不再存在 | — |
| 评标方法 `EVAL_METHOD` | 招标投标体系的「综合评估法」「经评审的最低投标价法」 | 项目信息答不出 |
| 阈值 | 表后往回 40 句、行长 ≤ 60 字、★ 节范围 800 句、最后一章不超过一半、92% | 都是看样本定的,没有样本外依据 |
### 4.2 照着答案改的
- **提示词例子取自测试文件**:substantive v5、qualification v4、schedule v4、tech_param v4 的新例子,逐字来自这 10 份(如「须统一部署于市级政务云平台」「上述资格证明文件 1~6 项」「提供公安部检测报告」「5 分钟内提出疑义」)。这些条目在 10 份上的得分不能当证据,换文件前要换成虚构或别处的例子。
- **评分脚本镜像了抽取规则**:`score.py` 里「★ 节范围内一律算对」「法规无效且碰到资格标注就算对」两条,等于用抽取规则去证明抽取规则(§2 的 320 条)。
- **看过输出后改了标注集**:S02 的 ★ 改成可选;两轮 AI 裁定补了 117 个可选单元;标注员和裁定员是同一类模型,都没有人工复核。
### 4.3 换文件后的预计(Fable 的推测,未验证)
| 新文件 | 标注类召回 | 写明无效类召回 | 实质性条款准确率 |
|---|---|---|---|
| 同样是江苏政府采购 IT 类 | 大概率 ≥ 98%,到不到 99% 五五开(★ / 斜体下划线的兜底是通用的) | 能守住 90%(范本句一致) | **最先掉**,按人工逐条口径估 85%~90% |
| 外省、招标投标体系 | 基本不受影响 | 掉到 80% 左右(「作废标处理」「否决投标」写法不全,章识别失效) | 掉得更多;资格准确率退到 60%~70% |
| 扫描件 | 可能掉到 80%~90%:斜体下划线和单元格都依赖矢量信息,★ 经 OCR 常变成 * 或 ☆ | 依赖 OCR 质量 | — |
## 5. 遗留问题清单
按优先级排。P0 不做,后面的分数都不可信。
### P0 评估体系
1. **建留出集**:至少 10 份,**改代码之前选好、不看**,组成见 §6.1。代码冻结后只跑一次、出一份报告,不裁定;裁定中「标注漏了」的只记录,计为错误。
2. **人工标注**:实质性条款和资格条件必须人工标(两项目标指标都在这里,两轮 AI 裁定已经证明偏宽)。评分、日期可以 AI 标注,再人工抽查 20%。标注员不能和裁定用同一类模型。
3. **评分脚本拆列**:准确率分两列报:「对上人工标注」和「按定义算对」(★ 节、法规无效)。标注类召回按 S02 两种口径都报。裁定补标的单元单独计数。
4. **10 份现有样本的标注集人工复核**:先复核实质性条款(S03、S06 最难),再把 `reviewed` 改成 true。
### P1 规则改保守(防样本外误判)
1. **★ 节继承来的条目**:先进 `pending_categories`,由人确认,不直接标致命。S10 一份 250 条致命,和口径 Q3「清单失去意义」的理由自相矛盾,Q1 的落地方式要重新定。
2. **表后说明**:只在公告、须知、资格、格式这几章生效;后果词去掉「不合格」「拒绝」;支持「注:第1~5项……」这类不以「上述」开头的写法。
3. **`INVALID`**:
- 「不得参加」要求同一句里带「投标」或「采购活动」;
- 「拒绝接受」只认主语是采购人或代理机构、对象是投标文件的写法;
- 按法律体系补「作废标处理」「予以废标」(招标投标体系里等于否决投标;政府采购里「废标」多指整个采购终止,不能混用)。
4. **概括句过滤**:只在上下确实有编号列表时生效,句子本身写着具体要求的不过滤。
5. ~~**★ 降级**~~:不再需要(2026-11-05 Q2 修订:★ 一律按实质性)。
6. **章识别**:
- 支持「第1章」「第一部分」、章号和标题拆成两句;
- 认不出时计数 `chapters_unreliable`,写进解析结果;
- 资格任务不跳过采购需求章,或者跳过之后对采购需求章补一遍关键词扫描(「资格要求」「供应商须具备」)。
7. **评标方法**:补「综合评估法」「经评审的最低投标价法」等招标投标体系的写法。
8. **提示词例子**:换成虚构的,或取自留出集以外的文件。
9. **回归测试**:把 §4.1 里每条「已验证」的反例写成单元测试。
### P2 覆盖面
1. **招标投标体系**:术语(招标人、否决投标)、无效写法、评标方法都没有样本,规则基本没覆盖。
2. **扫描件**:OCR(技术验证 S5)还没做。斜体下划线、表格单元格这两条规则在扫描件上不成立,要另想办法,例如用版面分析认表格、用多模态模型认标注。
3. **外省范本**:江苏范本的「上述……否则无效」「苏采云」写法不能当通用规则。
4. **补遗、更正公告**:没有样本。
### P3 质量细节(`extract-eval.md` §7.4)
- **写明无效类在正式清单里的召回只有 84.7%**:主要卡在符合性审查表(表头在上方)和串通情形上。
- **待确认区里只有 62% 是好结果**:噪音偏多,要结合查标阶段的处理体验再定。
- **资格误抽**:「本项目不接受进口产品 / 分包」被当成资格条件。
- **项目信息**:`accept_imported`、`sme_only` 各 7/10。
- **重复条目**:同一要求在公告、须知、审查表各抽一条,查标阶段要按语义合并。
## 6. 下一步建议(专项优化时)
### 6.1 找样本
收集时按下面的组成来,**先全部放进留出集,再从中划出一部分作为开发集**,不要反过来:
| 类别 | 份数 | 要点 |
|---|---|---|
| 江苏政府采购 IT 类 | 4 | 含 1 份非苏采云平台、1 份竞争性磋商、1 份竞争性谈判或询价 |
| 外省政府采购 IT 类 | 3~4 | 不同范本的省份(例如浙江「政采云」、广东、北京),优先选章节写法不同的 |
| 招标投标体系 | 2 | 国企或工程类信息化项目(江苏省公共资源交易网「工程建设」栏目等) |
| 扫描件 PDF | 1~2 | 盖章扫描的招标文件,用于 OCR(S5)和标注识别 |
| 补遗 / 更正公告 | 1 | 带原文件和更正内容 |
获取方式和版权处理沿用 `samples/tenders/README.md`:原文件和抽取文本不进 git,只提交清单和 `fetch.sh`。
### 6.2 优化的流程
1. **划分**:样本分开发集和留出集(留出集至少 10 份,§6.1 每类至少 1 份);
2. **标注**:留出集人工标注实质性条款和资格条件;标注规范沿用 `evals/gold/GUIDELINES.md`,先补上招标投标体系和扫描件的口径;
3. **改代码**:只看开发集;每条新规则都配一个样本外的反例测试;提示词例子不取自留出集;
4. **评分**:冻结代码后在留出集上只跑一次,按 §5 P0 第 3 条拆列报告,不裁定;
5. **决定**:留出集分数没达标的,回到开发集找原因,不在留出集上改。
### 6.3 先做哪几件
1. P0:评分脚本拆列、10 份现有样本的实质性条款人工复核(工作量最小、能立刻看清真实水平);
2. P1 第 1、3、6 条:★ 节改待确认、`INVALID` 收紧、章识别补写法(误判代价最高的三条);
3. 收集样本、建留出集(§6.1),再开始下一轮优化。
## 7. 相关文件
| 文件 | 内容 |
|---|---|
| `docs/architecture/extract-eval.md` | 评测方法、修复前后的结果、改动清单(§7) |
| `docs/decisions.md` | M1(只用 Flash)、Q1–Q4(实质性条款的口径) |
| `evals/README.md`、`evals/gold/GUIDELINES.md` | 评测工具用法、标注规范 |
| `server/src/bid_doctor/checklist/extract.py` | 抽取、校验、合并、兜底 |
| `server/src/bid_doctor/document/pipeline/structure.py` | 章、表后说明、★ 节、上级条款 |
| `server/src/bid_doctor/document/pipeline/text_layer.py` | 切句(按单元格) |
| `evals/score.py` | 评分;`ok()` 里两条「按定义算对」的规则见 §4.2 |

View File

@@ -0,0 +1,270 @@
# 查标(M3)实现计划 v0.2(2026-11-04)
> 范围:FR-M3-01~09,加上查标依赖的 FR-M6-08 最小资质库。
> 依据:`docs/analysis/03-functional-requirements.md`、架构 §3.2 / §3.6~§3.9 / §3.12、`data-model.md` §4.3 / §5.5~§5.6、`api-design.md` §3~§4、原型 `bid-check.html` `results.html` `report.html` `qualifications.html`。
> v0.2:请 Fable 做顾问,深读参考项目的查标实现(`ref-bid-engine-analysis.md` §6),对照读标优化的教训(`extract-status.md`),重新评估了 v0.1 的待定事项。关键结论抽查过源码。
## 进度
| 步骤 | 状态 | 说明 |
|---|---|---|
| 第 0 步 评测集 | 试跑完成,继续扩充 | 规范 v0.2、留出集划分已定,留出集词库和风格 C 已封存;生成、注入、渲染、离线查标、评分工具已写;开发集 7 份招标文件、14 份投标文件第一版跑完,结果和发现见 `check-eval.md` §2 |
| 第 1 步 链路打通 | ✅ 2026-11-04 | 见下方「第 1 步落地记录」 |
| 第 2 步 规则 | 基本完成 | 响应方式(C12)、文件要在、扫描件兜底、规则检查 `rules.py`(报价形式、形式要件、响应表)、需人工核对(`manual.py`、`manual-checks` 接口)、「报价」类清单项和分项上限(C7)。还差分项和采购需求逐项对照(P13)、散文写法的单价上限。见 `check-eval.md` §4–§7 |
| 第 3 步 AI 响应匹配 | 进行中 | 整行上下文、提示词 v2、偏离栏直读、响应错位、复查复用、硬预算;送给模型的同一行按单元格、重复条目只合并显示(C5 修订);致命项重问按数据暂不做。见 `check-eval.md` §8–§9 |
| 第 4 步 资质库 | ✅ 2026-11-04 | 方案和落地记录见 `qualification-plan.md`(决策 QL1–QL7)。`qualification` 模块(增删改、附件按企业级密钥加密、`late_for` 到期提醒、审计)和前端资质库页;`check/qualification.py`:证书类别词表(16 类)、规则 `QUAL.EXPIRED / LEVEL_LOW / NOT_COVERED`,投标文字层按清单项定级、只有资质库记录的降一级,规则集 2026.11-r4。评测见 `check-eval.md` §13 |
| 第 5 步 检查报告 | ✅ 2026-11-04 | `report` 模块:导出 → worker 用 Gotenberg(Chromium)把 HTML 转成 A4 PDF → 按项目数据密钥加密存对象存储 → `report`(不可改,存哈希);`report_job` 记生成过程(迁移 0009),完成推 `report.ready`、发通知。首页有项目信息、投标文件版本、检查依据、规则版本、导出人、检查覆盖范围、汇总、免责声明;问题明细带两侧出处和处理人;附录是需人工核对的勾选结果;页眉 logo、页脚「本报告为辅助检查结果,仅供投标前自查参考」+ 页码;测试检查模板文字不出现「通过 / 合格 / 保证」。前端「报告」标签页:选项、导出、内嵌预览、历次报告下载。「证据索引」页没有单独做(出处已在问题明细里逐条列出)。同时补了项目状态流转(递交 / 撤销递交 / 归档 / 撤销归档,FR-M6-02):致命问题未处理也能递交,确认框说明;递交后只读,撤销归档回到归档前的状态,都写审计 |
| 第 6 步 前端补齐 | ✅ 2026-11-05 | 查标页:对照布局、键盘(J / K / O / 1 / 2 / 3 / X / U / ?)、「提示」批量确认、判断有误反馈、从工作台带 `?f=` 定位到问题。读标页:每条写最近一次检查的结论,点投标侧出处看投标原文,「已响应」也能反馈判断有误(漏报);资格、评分里的证书要求写资质库的情况(`GET /projects/{id}/qualification-matches`,资质库空时不写)。概览:检查记录卡(历次检查、各级问题数、报告下载)、文件卡(招标方文件、投标分册最新版本,追加补遗挪到这里)。工作台:「致命」列、统计可点筛选、待处理的致命问题卡。日期输入加日历弹层(`DateInput`,Reka UI Calendar,周一开始、非本月留空)。反馈对话框抽成 `FeedbackDialog` 两页共用 |
**第 1 步落地记录**(和本文设计不一致、或留到后面的,记在这里):
- 后端 `check` 模块六个阶段都已接上,`rules` 阶段还是空的;问题由 `check_item_result` 生成,状态沿用、计费三种方式、健全性检查(投标文件读不出文字按 `input` 失败、不扣点)都有集成测试。
- 响应匹配目前只有第三层:检索用内存 BM25,分词先用汉字 2-gram + 「数字 + 单位」+ 英文型号,**jieba 留到第 3 步按评测决定**;批量先按 8 条清单项一批,**按投标章节打批、token 预算、复查复用、重复条目分组、致命项重问留到第 3 步**;`cache_key`、`group_key` 列已建好、还没写入。
- 提示词 `check.match` v1;假模型按字面重合判断,只用来测链路。
- 投标分册加了 `volume_kind` 列(迁移 0005),同一分册重新上传按 `lineage_id` 递增版本;电子投标平台加密格式先只认 `.kedt`(苏采云)和条目全部加密的压缩包,见过新的再补。
- 前端:上传投标文件并检查页、查标页(进度、覆盖范围、按级别筛选、逐条处理和行内说明、撤销、原文抽屉、已处理和本次已解决),项目头抽成公共组件,加了「查标」标签页;键盘操作、批量处理、判断有误、需人工核对、导出报告留到第 6 步。
- 用真实模型(DeepSeek Flash)在虚构的 tender-mini + bid-mini 上走了一遍:漏响应的 ★ 条款查出为致命「未找到响应」,另两条判为已响应且出处核对通过;补上响应的 v2 复查后该问题「本次已解决」。一次检查一次模型调用(输入 1,552 token、输出 147 token)。这只说明链路通了,**不代表效果**,效果按第 0 步的评测集评。
## 0. 结论速览
- 表结构和接口契约大部分已有:`check_run` `finding` `finding_citation` `manual_check` `report` `qualification` 在基线迁移里,`openapi.yaml` 里查标相关的 20 多个操作都已定义。要做的是 `check`、`rules`、`report`、`qualification` 四个后端模块、投标文件的解析和结构识别,以及 4 个前端页面。
- 和 v0.1 相比,三处改动最大:
1. **先建评测集,再写第一条规则**。金标准是构造出来的:虚构投标文件时同时写一份「响应清单」,记下每条要求在哪里响应、故意埋了什么问题,按清单打分,不再用 AI 裁定。
2. **每条清单项都留一条带证据的结论,「已响应」也不例外**。新增 `check_item_result` 表,问题只为没响应好的条目生成。
3. **成本靠结构控制,不靠一个上限数字**:按投标文件的章节打批,同一段正文只送一次;每次检查有 token 预算,超出预算没查的条目列在报告的「检查覆盖范围」里;24 小时内复查时,没变的条目复用上次结果。
- 原则:**认不出、查不了、没查的,都写进「检查覆盖范围」(`coverage`),不静默跳过。** 读标里章识别静默失效的教训。
- 分 7 步交付,第 0 步(评测集)在第 2 步(规则)之前完成。
- §7 是 11 个待确认事项的最终建议(含 SK1、SK2)。
## 1. 现状盘点
| 项 | 已有 | 要做 |
|---|---|---|
| 表结构 | 基线迁移 `06_check.sql`、`07_…sql`;`project.latest_check_run_id` `open_fatal_count` `last_charged_check_at` | ORM 模型;新增 `check_item_result`(C9);清单快照里的重复组标记(C5) |
| 接口契约 | `check-preview`、`check-runs`、`findings` 的 resolve / reopen / bulk-resolve、`manual-checks`、`reports`、`qualifications`、`feedback` | 实现;补「每条清单项的检查结论」的读取接口。改了契约就 `pnpm gen:api` |
| 文件上传 | 分片上传、按文件头识别格式;`role = bid_volume` 能挂到项目上 | 分册类型和版本;**电子投标平台加密格式的识别还没做**(C8) |
| 文件解析 | 解析流水线和 `ParseJob` 绑在一起(`runner.py`),阶段函数基本是纯函数 | 拆出阶段函数给检查批次复用;新增投标侧结构识别 `bid_structure` |
| 清单 | 五类清单项,技术参数带 `op / value / unit`;确认时生成快照 | 检查前生成 `kind = check` 快照;**「报价」类清单项还没有抽取任务**(C7) |
| 额度 | 预扣 / 结算 / 退回、幂等键、余额行锁 | 检查的三种计费方式和免扣流水 |
| 模型调用 | 分块并发、截断检测(`near_limit`)、对半重跑、原因码 | **并发上限还是每个任务各算各的**(`extract.py:36` 的 TODO)。解析和检查同时跑会把供应商的每分钟请求数打满,查标上线前要做「供应商 × 租户」统一限流 |
| 事件流 | Valkey stream、`Last-Event-ID` 续读、`resync` | 加 `check_run`、`report_job` 的进度;发布用的客户端设短超时(SK1) |
| CI | PostgreSQL、Valkey 服务容器 | 加 Gotenberg(投标样例有 Word);数据库角色改为直接跑 `init-roles.sh`(SK2) |
| OCR | 未做(技术验证 S5) | 扫描件先按 C10 兜底;S5 排在 M3 之后 |
| 样本 | 10 份真实招标文件、S01–S10 标注集 | **没有投标文件**(C1) |
## 2. 检查流水线
`POST /projects/{id}/check-runs` 在同一个事务里:算计费方式 → 预扣(或记免扣)→ 生成清单快照 → 建 `check_run` → 入队。worker 按六个阶段跑,每个阶段写检查点、开始前清掉本阶段残留的产物,失败后从失败的阶段续跑(架构 §3.4)。
| 阶段 | 做什么 | 产出 |
|---|---|---|
| `prepare_bid` | 每个投标分册版本跑解析流水线的前五个阶段(格式核对、转换、文字层、OCR、结构识别),不抽取;同一版本只解析一次。然后做**投标侧结构识别**:偏离表、开标一览表、分项报价表、格式文件(投标函、授权书、声明函)、资格证明附件。表格沿用读标的按单元格切句(F8),偏离表直接从单元格里读。**健全性检查**:文字层为空或字数过少、全是图片页时,按 `input` 失败,不扣点、不生成问题 | 投标侧文字层;结构索引;每页有没有文字层 |
| `match` | 对每条参与检查的清单项找响应,见 §2.1 | `check_item_result` |
| `rules` | 确定性规则,见 §2.4 | 规则问题 |
| `verify` | 投标侧三道校验(编号、原文、依据)。**没有校验通过的证据,不能判为「已响应」** | 校验状态和原因码 |
| `carry_over` | 和上一次检查对齐,算出新增 / 沿用 / 本次已解决(FR-M3-08,对齐方式见 C5) | `presence` |
| `summarize` | 由 `check_item_result` 生成问题;写 `counts`、`coverage`;更新就绪状态和项目状态;汇总本次 `ai_call` 的 token 和费用;结算额度;发通知 | 检查完成 |
结构识别认不出(比如没找到偏离表)时,改用全文检索,并在 `coverage` 里写明「没有识别出偏离表」。
### 2.1 响应匹配
一份清单有 216~1348 条正式条目(参考项目每个项目只判 90 条左右,它的成本做法不能当依据)。分三层,能在前一层解决的就不进下一层:
1. **偏离表直取**。偏离表的行和清单项要先对齐:投标人抄过来的要求常被截断、改写、重新编号,所以用去掉编号后的文本相似度、参数名和顺序做确定性对齐,设置信阈值,对不上的行进第三层。MVP 以偏离表为准,「偏离表写无偏离,正文却相反」不查,写进 `coverage`。
2. **规则比较**。技术参数要求有 `op / value / unit` 的,用 Decimal 和单位换算比较读到的响应值,覆盖区间、枚举、支持 / 不支持、版本号。响应只写「满足」「响应」、没给具体值的,结论是「未给出具体值」(提示级),不是「疑似不满足」。模型只负责找出响应值在哪,满不满足由规则判断;模型给出的值必须出现在它引用的句子里。★ 参数判为「疑似不满足」时,问题里要同时列出两边的值、单位和比较依据,防止因为单位换算判错出致命误报。
3. **检索 + 模型判断**。见 §2.2。批量方式:
- **按投标文件的章节打批**:一段投标正文只送一次,带上候选句落在这段里的所有清单项。参考项目按清单项打批,同一页正文在一批里重复送好几遍,是反例。
- 每条清单项都必须有结论,漏答的记原因码 `missing_answer`,结论记「需人工核对」。
- 模型引用**句子编号**,不写页码和自由引文,这样三道校验能原样复用。
- 沿用读标的输出上限、`near_limit` 截断检测和对半重跑;每批之间检查取消标记;并发走统一限流。
- 部分批次因为模型的临时错误失败时,不拖垮整个批次:失败的条目记「未判定(模型错误)」,写进 `coverage`。
- 致命级条目的证据没通过校验时,允许重问一次:只把模型指的那一页作为候选,提示词不变。重问之后仍不通过,按 C3 处理。
- 提示词 `check.match` v1 的例子一律虚构,不取自评测集里的投标文件。
**token 预算**:每次检查有一个硬预算。超出后剩下的条目按 提示 → 重要 → 致命 的顺序放弃(先放弃提示级),没查的条目在 `coverage` 里逐条列出。预算按实测定(C2),正常情况下不应触发。
### 2.2 检索
用内存里的 BM25,保留架构 §6.2 的做法。`ref-bid-engine-analysis.md` §5.4 写的「用 PostgreSQL 的 pg_trgm / 全文检索」改掉,理由:
- 句子放对象存储、不进数据库(DM1)。用 PostgreSQL 检索就要为投标文字层另建一张句子表;
- 检索只在 worker 里、一次检查之内用。一个项目的投标文件最多几百页,在内存里建索引用不了几秒;
- 中文全文检索要装 zhparser 之类的第三方扩展,托管数据库和私有化部署都麻烦;pg_trgm 没有 IDF;
- 内存索引是纯函数,单元测试和离线评测都能直接用。
实现要点:
- 索引单位是带编号的句子和单元格,出处能回链;
- 分词用 jieba 加字 2-gram / 3-gram,「数字 + 单位」(≥32GB)和英文型号(10GE)保留成整词;
- 查询按类别构造:技术参数用参数名、值和单位,资格条件用材料名;
- 给模型的候选按句子窗口取上下文,不送整页;
- 排序带结构先验:技术参数先找技术偏离表,商务先找商务偏离表,资格先找资格证明分册。分册类型只用来排序,不限制检索范围(C8)。
### 2.3 问题级别
级别从清单项算出来,不让模型定(参考项目让模型定级别,模型可以把高危项降成低危):
| 清单项 | 级别 |
|---|---|
| `importance = substantive`(★ 条款、法规列明、原文写明无效),或类别含资格条件、无效 / 废标 | 致命 |
| ▲ 参数、评分项缺证明材料 | 重要 |
| 一般参数、其他 | 提示 |
问题上带 `importance_basis`,界面把「法规列明」和「文件写明」的无效分开显示(决策 Q4)。`explanation` 只写事实和出处,**不写整改建议**:参考项目让模型给每条结论写建议,用在报价类问题上就会写出报价数字(红线 6)。
### 2.4 规则(`rules` 模块)
每条规则都有编号,规则集有版本,写进 `check_run.rule_set_version`。每条规则都要有单元测试,至少配一个反例(不该报的写法)。
| 规则组 | 内容 |
|---|---|
| `PRICE.*` | 超出限价:总价、采购包、分项三层(分项见 C7);开标一览表和分项表的合计是否一致(苏采云范本写明「以开标一览表为准」,定为「重要」,说明里引用原文);大写和小写金额是否一致;是否只给了一个报价。金额只从投标文件读,`detail` 里存读到的金额、原文写法和出处 |
| `FORM.*` | 格式文件齐不齐;**格式文件里该填的地方没填**(C4);签章位置有没有图像(C4);落款日期是否合理;**出现了别的项目的名称、编号或采购人**:以本项目的名称、编号、采购人为准做比对,重点扫页眉页脚(照搬旧标书最常留在那里);泛泛匹配公司名只作弱证据;格式文件的固定文字有没有被改动:依赖招标侧识别出格式章,识别不出就不查,写进 `coverage` |
| `QUAL.*` | 证书在投标截止日是否已过期;等级能比较时看够不够;资格条件和评分项要求的材料有没有覆盖 |
| `REGIME.*` | 按法律体系切换术语(「投标无效」/「否决投标」)和无效情形清单;体系识别不出时用中性措辞。招标投标体系没有样本,`coverage` 里写「规则未经样本验证」 |
「需人工核对」的条目(份数、装订密封、递交方式、CA 加密)由规则按清单项生成 `manual_review`,勾选结果按项目存在 `manual_check`,复查时沿用(P2)。
### 2.5 检查覆盖范围(`coverage`)
第 1 步就定下字段,报告首页、查标页顶部都从这里读:
- 查了哪些分册和版本;哪些页没有文字层(扫描件);
- 哪些条目没查,以及原因:在待确认区、不在所投的包、超出预算、模型错误、扫描件;
- 结构识别结果:偏离表、开标一览表、格式文件,哪些认出来了;
- 法律体系,以及这套体系的规则是否经过样本验证;
- 清单是否已确认(G7);
- 规则集版本、提示词版本;本次 token 和费用;证据没通过校验的条目数(按原因码计)。
## 3. 计费
按 `data-model.md` §4.3 实现:
| 情况 | `check_run.billing` | 流水 |
|---|---|---|
| 专业版,24 小时窗口外 | `charged` | 在解析额度上预扣 −1;成功结算,失败、取消退回;成功后更新 `project.last_charged_check_at` |
| 专业版,24 小时窗口内 | `waived_recheck` | 免扣一行 0,`reason = recheck_24h`,关联本次检查 |
| 免费版 | `free_plan` | 不记流水 |
`check-preview` 和 `POST check-runs` 共用同一个计费函数;`expected_charge` 对不上时返回 409 `billing_changed`,不扣点。投标文件的解析算在本次检查里,不另外扣点。
**复查复用结果**:第三层(模型判断)的结论按「清单项内容哈希 + 投标分册版本哈希 + 提示词版本」缓存。没变的条目直接复用上次的 `check_item_result`,不再调用模型。否则一次扣点可能被拿来跑三五次全量。用户改了清单项,或者换了分册版本,相关条目就会重新判断。
## 4. 评测集(第 0 步)
没有任何真实投标文件,先自己造。读标的三个坑这样堵:
| 读标的坑 | 查标的做法 |
|---|---|
| AI 标注、AI 裁定,结果偏宽 | 投标文件是按「响应清单」写出来的,问题在构造时就已知,**响应清单就是金标准**,不需要 AI 裁定 |
| 评分脚本照搬抽取规则 | 评分只对照响应清单,不用检索和偏离表对齐的逻辑来判对错。准确率分两列报:对上响应清单的,和其他 |
| 规则照着样本写法调 | 至少两种写法风格,按招标文件整体留出,开工前选定 |
做法:
1. **问题分类表先定**:从 05 §5 的 R01–R20、07 F6 / F7 和 7 种问题类型派生,不少于 15 类,每类至少 3 种写法。例如:缺行、值不满足、单位不同、写在错的分册里、偏离表写「无偏离」正文却否定、页眉留着旧项目名、大小写金额不一致、证书过期、该填的地方没填。
2. **用招标文件自己的格式章写投标文件**:投标函、开标一览表、偏离表、声明函的模板,S01–S10 里都有。每份招标文件写两份投标文件,一份「规矩的」、一份「马虎的」;至少两种写法风格,其中一种由另一套提示词或另一个人来写。
3. **响应清单**:每条清单项记响应在哪(分册、页码 + 片段),以及怎么响应的(偏离表的行、正文句子、附件);没响应的记问题类型。
4. **留出集**:按招标文件整体划分,开工前选定,其中至少包括 S06(有分项限价)或 S10(多包、有单价上限)中的一份。
5. **裁定政策**:开发集上查出了响应清单里没有的问题,由人裁定一次,再改响应清单。投标文件里的问题是客观可查的,这是唯一允许「看过输出再改标注」的情况。留出集只记录,不改。
6. **人工复核**只看一件事:写出来的投标文件里,有没有无意中留下的问题(占位符没填、复制错位)。
7. 指标:致命级问题的召回率(漏报比误报严重);问题准确率;证据没通过校验的问题占致命问题的比例(目标 < 10%,否则会成为查标的「待确认区噪音」);每次检查的 token 和费用。
8. 按真实招标文件写出的投标文件,和招标文件一样不进 git。
## 5. 分步交付
每一步都要通过后端检查和前端检查(AGENTS.md「工程」一节);新接口都要有「A 企业的令牌访问 B 企业的资源,返回 404」的用例。
### 第 0 步:评测集
按 §4。问题分类表、响应清单格式、留出集划分,**在写第一条规则之前定稿**。投标文件可以和第 1 步并行写。
### 第 1 步:链路打通(假模型)
- 投标分册:类型和版本;加密格式识别(C8)。
- 拆 `ParseJob` 的阶段函数,`prepare_bid` 复用;健全性检查。
- `check` 模块:`check-preview`、发起 / 取消 / 查询检查、六个阶段的骨架、计费三个分支、状态沿用、`check_item_result`(C9)、`coverage` 字段(§2.5)。
- 处理问题:resolve(忽略要写原因;致命问题「确认无误」也要写说明,同时写一条 `feedback`)、reopen、只能批量处理「提示」级、只能处理最近一次检查的问题;同一个事务里更新 `open_fatal_count`。沿用「已确认无误」的致命问题时,如果证据所在的分册换了版本,标出「证据所在文件已更新」。
- SK1、SK2 的补丁:Valkey 发布客户端设短超时,SSE 连接池设上限;CI 加 Gotenberg、直接跑 `init-roles.sh`;架构 §8 改掉 testcontainers 的写法。
- 前端:上传投标文件和发起检查页、查标页的最小版本。
验收:在虚构样例上走完「上传投标文件 → 检查 → 处理问题 → 复查后状态沿用」;扣点、免扣、免费版、失败退回、409 `billing_changed`、健全性检查失败不扣点,都有测试。
### 第 2 步:投标侧结构识别和规则
- 读标新增「响应方式」抽取(C12):类别 × 方式 × 出处,规则优先;查标按响应方式决定「未找到响应」报不报、报什么级别。
- `bid_structure`:偏离表、开标一览表、分项报价表、格式文件、资格证明附件;认不出时走兜底,并写进 `coverage`。
- `rules` 模块:§2.4 的四组规则;需人工核对的条目和 `manual-checks` 接口。
- 「报价」类清单项(C7)。
- 只在开发集上调,每条结构规则配一个开发集以外的反例。
### 第 3 步:AI 响应匹配
- §2.1 三层、§2.2 检索、`check.match` 提示词、投标侧三道校验、致命项重问一次。
- 「供应商 × 租户」统一限流(解决 `extract.py:36` 的 TODO)。
- token 预算、复查复用结果、重复条目分组(C5)、扫描件兜底(C10)。
- 在开发集上调完后冻结代码,留出集跑一次,结果写进 `docs/architecture/check-eval.md`,按实测定 C2 的元数。
### 第 4 步:资质库和资质匹配
- `qualification` 模块:资质的增删改、附件(企业级数据密钥,DM4);前端资质库页。
- `QUAL.*`:过期、等级不足、未覆盖。资格证明是扫描件时,按名称回到资质库里匹配(C10)。资质数据只来自用户自己的资质库和投标文件(红线 5)。
### 第 5 步:检查报告
- HTML 模板 → Gotenberg 的 Chromium 转 PDF,嵌入思源宋体、思源黑体;后台生成,完成后发 `report.ready`。
- 内容按 FR-M3-09:首页(项目信息、投标文件版本、检查时间、规则版本、检查覆盖范围、免责声明「辅助检查结果,仅供投标前自查参考」)、汇总、问题明细、需人工核对附录。另加一页「证据索引」,每条证据的文件、页码、引文单独成行,方便逐条翻原文。
- 报告生成后不再改,存哈希;重复导出就生成新报告。CI 检查模板文案,不能出现「通过 / 合格 / 保证」;logo 不当作结论章使用。
### 第 6 步:前端补齐
- 查标页:对照布局(招标原文 / 投标原文)、键盘操作(含 U 键撤销)、批量处理、「反馈:判断有误」、需人工核对、`coverage` 摘要。
- 读标清单上显示每条清单项的检查结论(「已响应 · 第 N 页」),已响应的也能反馈判断有误。
- 报告页、项目概览的检查记录、工作台的就绪状态和致命问题数。致命问题没处理完时显示「未就绪」,但不限制导出和递交(红线 4)。
## 6. 红线对照
| 红线 | 落在哪里 |
|---|---|
| 1 辅助定位 | 每条清单项的检查结论都留痕,包括「已响应」;处理问题记录处理人和时间 |
| 2 可复核 | 招标侧出处必有;投标侧出处要过三道校验,没通过的不判「已响应」;「未找到」的写明找过哪些分册、哪些章节;规则问题没有清单项时挂规则编号 |
| 3 不用绝对化表述 | 只称「辅助检查 / 风险提示」;没查的写进覆盖范围;报告模板做 CI 文案检查 |
| 4 不阻断用户 | 就绪状态只是提示 |
| 5 不编造 | 资质只来自资质库和投标文件;不给整改建议 |
| 6 报价 | 只读金额、不生成金额,问题说明里不写建议 |
| 7 签章 | 只提示位置和有无,写明「不鉴定真伪;电子签章可能在投标平台加盖」 |
| 9 额度 | 失败、取消、健全性检查不过都不扣点;免扣也记一行;复查复用结果 |
## 7. 待确认事项:最终建议(2026-11-04 全部确认,见 decisions.md)
| # | 事项 | 建议 |
|---|---|---|
| C1 | 投标文件样本 | 按 §4 自己写:用招标文件自己的格式章,响应清单就是金标准,按招标文件整体留出,人工只裁定开发集上「响应清单里漏记的问题」。**用户有一份真实的国动小程序投标文件**,作为留出集里唯一的真实样本:第 3 步冻结代码后再要来跑一次,之前不看、不按它调 |
| C2 | 单次检查成本 | 不先定元数。设计时按 token 预算:模型输入总量不超过投标正文字数的 3 倍加上清单文字。另加三条:按投标文件章节打批、每次检查有硬预算且超出部分写进覆盖范围、复查复用结果。第 3 步在 S06 规模的样本上实测,再定元数(一起为 D2 提供依据) |
| C3 | 模型说已响应,但证据没通过校验 | 不判已响应。问题类型记「未找到响应」,级别不降,`detail` 记证据状态和原因码,说明里写「模型认为第 N 页有响应,引文未能核对」,界面可以按原因码筛。致命项允许重问一次(只给模型指的那一页)。评测单列这类问题的比例,目标是占致命问题的 10% 以内 |
| C4 | 签章 | 只提示位置和有无。主要检查是**格式文件里该填的地方有没有填**:投标人名称、法定代表人或授权代表姓名、落款日期(「年 月 日」)还空着。「(盖单位章)」「(签字)」这类括注在盖章后通常仍然留着,括注本身不算问题。图像检测只看签章位置附近有没有图像,结论只到「提示」。Word 稿也转成了规范化 PDF,两种稿走同一套检查 |
| C5 | 重复条目 | 检查时分组:只在同类别、同一个包里,按保守的文本相似度聚组(阈值记成规则,配反例)。问题挂在组里编号最小的清单项上,其他成员的出处作为附加的招标侧出处,`detail.group_members` 记全组;复查对齐时,组内任一成员对上就算同一个问题。分组结果记在检查快照里,界面显示「此要求出现在 3 处」。读标阶段合并以后再做 |
| C6 | 是否全查 | 正式清单全查。没查的写进覆盖范围:待确认区的条数、不在所投包的条目、超出预算的条目 |
| C7 | 分项限价 | 补 `extract.price`,规则优先(「限价 / 最高限价 / 单价不超过」+ 金额 + 所在的表行),模型只做结构化;用户也能在清单里手工新增「报价」类条目。10 份里只有 S06、S10 两份有分项限价或单价上限,不让它卡住总价和采购包两层 |
| C8 | 投标分册类型 | 固定选项:整本(默认)/ 资格证明 / 商务 / 技术 / 报价 / 其他,再加一个自填的分册名称。类型只用来排序检索候选和填写「找过哪些分册」,不限制检索范围。电子投标平台的加密格式按文件头和扩展名维护一张表,422 写明是哪个平台的格式 |
| C9 | **新增:每条清单项的检查结论** | 新增 `check_item_result` 表(检查批次 × 清单项:结论、走的哪一层、投标侧出处、校验状态、原因码、token)。「已响应」也是 AI 结论,按红线 1、2 要能复核、能反馈;复查复用结果、覆盖范围也靠它。问题仍然只为没响应好的条目生成。需要新迁移,并同步 `data-model.md` |
| C10 | **新增:扫描件投标文件** | 真实投标的资格证明、业绩合同多是盖章扫描件,OCR(S5)还没做,不处理的话会出一大片致命级「未找到」。MVP:候选分册里图片页占比超过阈值的条目,不出「未找到响应」,改出「该部分为扫描件,请人工核对」;资格类按名称回到资质库匹配;覆盖范围列出没有文字层的页码。S5 排在 M3 之后 |
| C11 | **新增:检索方案** | 内存 BM25 + jieba(§2.2),改掉 `ref-bid-engine-analysis.md` §5.4 的 pg_trgm 写法 |
| C12 | **每条要求怎么才算「响应了」**(2026-11-04 新增,已按建议确认,decisions.md C12) | 招标文件对响应方式的规定按类别不同(`evals/check/GUIDELINES.md` §2):逐条响应、默认接受(只列偏离)、要证明材料(分投标时和中标后)、文件要在、平台内应答,同一份文件里常有几种,S07 还自相矛盾。建议:① 读标新增「响应方式」抽取,粒度是「类别 × 方式 × 出处」,规则优先、模型兜底,条款原文写了「提供……证明材料」的按条覆盖并记投标时 / 中标后;在清单页显示、可改,作为人工确认点。② 两处写法冲突时按更严的(逐条)处理,问题说明里同时引两处原文。③ 默认接受的要求没找到响应:一般要求记「按招标文件默认接受」、不出问题,结论带出处;实质性 / ★ 要求出「提示」级「请人工核对」,不出致命;正文里找到了不满足的值照常报。④ 证明材料是中标后提供的,投标时没附不报。⑤ 识别不出响应方式时按逐条处理(宁可多报),这一组「未找到响应」标明「招标文件未写明是否要求逐条响应」,写进覆盖范围;项目上给一个「本项目只列偏离」的开关,打开后这一组降为提示 |
| SK1 | 进度推送用 Valkey stream | 确认。补两处:发布用的客户端设短超时(现在 `valkey()` 没设,Valkey 挂掉时会卡住 worker,而不是只记日志);SSE 连接池设上限 |
| SK2 | 集成测试连 Compose 的 PostgreSQL | 确认。补三处:CI 直接跑 `init-roles.sh`,不再单独写一份建角色的 SQL;CI 加 Gotenberg;架构 §8 改掉 testcontainers 的写法 |
## 8. 读标教训在本计划里的对应
| 读标的教训 | 本计划 |
|---|---|
| 评测集建得太晚,在同一批样本上既调又评 | 第 0 步在规则之前;按招标文件整体留出;冻结代码后留出集只跑一次 |
| AI 标注、AI 裁定偏宽 | 响应清单就是金标准 |
| 评分脚本照搬抽取规则 | 只对照响应清单打分,准确率分两列 |
| 提示词例子取自测试文件 | `check.match` 的例子一律虚构 |
| 输出临近上限时条目静默丢失 | 沿用截断检测和对半重跑;每条必答,漏答记原因码 |
| 待确认区噪音 | 证据没通过的问题记原因码、单列指标、可筛选 |
| 章识别静默失效 | 认不出就兜底,并写进覆盖范围 |
| 规则只修真正的缺陷 | 投标侧引文对不上不在附近找句子改挂;致命项重问是重试,不放宽校验 |

View File

@@ -0,0 +1,114 @@
# M6 剩下的 P0:额度流水、套餐开通、数据删除、运营后台(实现计划 v0.1)
依据:FR-M6-03 / 05 / 06 / 07 / 09(`docs/analysis/03-functional-requirements.md`),J4、J5(`02-user-journeys.md`),G6、G8、G10,决策 Q9、Q13、P1、DM4;表结构已在基线迁移里(`data-model.md` §4、§6),用户端接口已在 `openapi.yaml` 里定义;运营后台的接口和选型「做到时再定」(架构 §3.11、§4.3)。
## 0. 现状
| 方面 | 已有 | 还没有 |
|---|---|---|
| 额度 | 注册发放、预扣 / 结算 / 退回 / 免扣、`GET /quota`、顶栏额度(≥ 80% 赭色) | 流水接口和额度页、80% / 100% 提醒、人工调整、过期 |
| 套餐 | 免费版注册即开通、`GET /plans`、专业版套餐(解析 500,D2 待定) | 开通 / 续费申请、运营开通、到期处理 |
| 删除 | 资质删除(QL6) | 删除项目、注销账号 |
| 通知 | 解析、检查、报告完成写通知;事件流 | 前端通知入口、标为已读 |
| 运营后台 | 表:`ops_admin`、`ops_session`;角色 `bd_ops`(文档里有,部署脚本里还没建);反馈写入 | 进程、登录、接口、前端,全部没有 |
另外发现一处冲突:原型 `project.html` 的删除写「30 天内可以在回收站恢复」,而 G10、DM4(已确认)是删除时销毁项目数据密钥、彻底删除。按决策优先,见 M6-1。
## 1. 用户端
### 1.1 额度页(FR-M6-03,原型 `quota.html`)
- 侧栏账户区(头像、套餐标签)点开进「额度与套餐」页;顶栏额度改成链接,也进这个页。
- 页面:本期额度卡(剩余 / 已用 / 退回,期间起止)→ 扣点规则表(原型原样)→ 按功能分布(读标招标文件、补遗和澄清、查标、免扣次数)→ 本期流水。
- 流水:`GET /quota/entries`,按单位筛选、按时间倒序分页;每行写时间、类型(发放 / 预扣 / 结算 / 退回 / 免扣 / 过期 / 调整)、数量(+ / −,墨色,Q1)、余额、关联项目 · 任务(「招标文件」「第 2 次检查」;项目删了写「已删除的项目」和任务编号,G10)、说明(调整原因、失败原因、「距上次扣额度的检查不到 24 小时」)。导出 CSV 在前端生成。
- 提醒:预扣后余额跨过 80%、100%(按本期发放算)时写站内通知,`quota_alert` 保证每期每档只提醒一次。
### 1.2 开通与续费(FR-M6-05,G8)
- 免费版:额度页和顶栏「额度用完」提示里有「开通专业版」;专业版:到期前 30 天起显示「续费」。
- 申请对话框(原型):企业名称(注册时的,只读)、发票类型(普票 / 专票)、纳税人识别号、联系人、手机号;专票再加地址电话、开户行账号。`POST /activation-requests`,不扣额度、不用先付款。
- 提交后页面顶部显示申请单号、状态(等待对公转账 / 已到账 / 已开通 / 已退回)、收款信息(收款单位、开户行、账号,从配置读 `BD_PAYEE_*`)和「转账备注写申请单号」。
- 运营确认到账并开通:建专业版订阅、发放额度(流水「发放」,说明写申请单号),发站内通知;之后填发票号。
- 续费:当前专业版还没到期时,新一期从旧一期结束时开始;旧一期剩下的额度到期清零(「本期没用完的不结转」,原型)。
### 1.3 到期(FR-M6-05)
- worker 每天跑一次定时任务(Procrastinate 周期任务):到期的订阅改「已过期」、记「过期」流水把余额清零;排队的下一期到点发放。
- 提前 30 天、7 天、到期当天各发一次站内通知。
- 过期后:数据保留,只读(M6-5)。
### 1.4 删除项目(J4、G10、data-model §6.1)
- 入口:项目头「更多」菜单(和归档放一起),已递交、已归档的也能删。
- 确认框:写明删掉什么、保留什么(额度流水、不含业务内容的审计),输入项目名称确认(原型)。
- `DELETE /projects/{id}`:同一事务里写审计、抹掉这个项目审计的内容、删项目行(级联)、销毁数据密钥;提交后后台任务删对象存储前缀。正在解析 / 检查的项目先取消任务再删(预扣退回)。
### 1.5 账号页与注销(J4、data-model §6.2)
- 「额度与套餐」页旁边一个「账号」页:企业名称、手机号(脱敏)、显示名、套餐;最下面「注销账号」。
- 注销:确认框写明后果(全部项目、资质库删除;开通申请和发票记录按财务要求保留;剩余额度作废、不退款),输入企业名称 + 短信验证码。`POST /tenant/close`:立即吊销会话、企业标「已注销」,项目按 §1.4 由后台任务逐个删。
### 1.6 通知入口
- 顶栏加铃铛(原型有),有未读时带圆点;点开是浮层列表(最近 30 条:解析 / 检查 / 报告完成、额度提醒、开通结果、到期提醒),点一条跳到相关页面并标为已读,「全部标为已读」。`POST /notifications/read`。
## 2. 运营后台(FR-M6-09)
### 2.1 形态
- **后端**:同一个 Python 包的第三种进程 `bid-doctor ops`(和 api、worker 一样的镜像),单独端口、路径前缀 `/ops/v1`,用 `bd_ops` 数据库角色(绕过行级安全策略);用户端 api 进程里不挂任何运营接口。部署时运营端口只对内网 / 白名单开放。
- **登录**:用户名 + 密码 + TOTP 二次验证;IP 白名单(`BD_OPS_ALLOWED_IPS`,空着时拒绝启动于生产环境);会话 8 小时;账号用命令行建(`bid-doctor ops-admin create`),没有自助注册。
- **前端**:`apps/admin`,Vue 3 + Vite,复用 `tokens.css` 和组件样式,不引入带样式的框架(M6-3);单独构建、单独部署,私有化不交付。
- **审计**:每个运营操作(包括查询企业详情)都写审计事件,操作人记运营账号。
### 2.2 页面和接口(最小版)
| 页面 | 做什么 | 接口(`/ops/v1`) |
|---|---|---|
| 企业 | 按企业名称、手机号(用 HMAC 查)、申请单号搜;详情:成员(手机号脱敏)、套餐和到期日、各单位余额、项目数、最近任务;**不显示项目名称和任何业务内容** | `GET /tenants`、`GET /tenants/{id}` |
| 开通申请 | 待处理在前;标记「已到账」→「开通」(选套餐和周期,默认专业版 1 年),或「退回」写原因;开通后填发票号 | `GET /activation-requests`、`POST .../{id}/activate`、`/reject`、`/invoice` |
| 额度调整 | 在企业详情里:单位、±数量、原因(必填) | `POST /tenants/{id}/quota-adjustments` |
| 反馈 | 误报 / 漏报列表:类型、用户写的理由、问题标题和级别、规则编号、提示词版本、出处页码,**不显示招标 / 投标文件原文**(M6-7) | `GET /feedback` |
| 失败任务 | 解析、检查、报告失败的:错误类别、错误码、阶段、耗时、模型调用次数,不含文档内容 | `GET /failed-jobs` |
| 审计 | 按企业 / 对象 / 时间查审计事件(项目删除后只剩类型和时间) | `GET /audit-events` |
## 3. 步骤
1. 用户端后端:流水、提醒、开通申请、到期任务、删除项目、注销、通知已读,各配集成测试(额度对账:任何失败任务都没有净扣点;删除后对象不可解密)。
2. 用户端前端:额度与套餐页、账号页、通知入口、删除项目。
3. 运营后台后端:`ops` 进程、`bd_ops` 角色(部署脚本)、登录和 TOTP、上表接口、审计。
4. 运营后台前端:`apps/admin` 六个页面。
5. 浏览器走查:注册 → 用完免费额度 → 申请开通 → 运营开通 → 流水核对 → 到期(改时钟)→ 删除项目 → 注销。
## 4. 待确认(建议见每条)
| # | 问题 | 建议 |
|---|---|---|
| M6-1 | 删除项目要不要「30 天回收站」(原型这么写) | **不要,立即彻底删除**:按已确认的 G10、DM4 销毁数据密钥;回收站要软删除,和「彻底删除」冲突(同 QL6)。确认框要求输入项目名称,写明不能恢复。原型文案改掉 |
| M6-2 | 运营接口放在哪 | **单独的第三种进程 `bid-doctor ops`**(同一镜像、单独端口、`bd_ops` 角色);用户端 api 进程里没有运营接口,绕过行级安全策略的代码不会被公网请求触到 |
| M6-3 | 运营后台前端选型 | **Vue 3 + 复用 tokens.css / 组件样式,不引入带样式框架**:页面只有六个、以表格和表单为主;和用户端同一套组件,维护成本低。另一种做法是引入 Element Plus 之类加快开发,但多一套视觉和依赖 |
| M6-4 | 运营登录 | **用户名 + 密码 + TOTP + IP 白名单**,账号命令行创建(架构 §3.11 已写独立登录、白名单、二次验证) |
| M6-5 | 套餐到期后怎么办 | **不自动降为免费版**;项目只读(能看、能下载已导出的报告、能删除;不能新建项目、解析、检查、改清单);额度清零;提前 30 天、7 天、当天提醒。续费到账后恢复 |
| M6-6 | 开通申请页写不写价格、收款信息从哪来 | **页面不写价格**(D2 额度数字和定价都还没定),写「运营会联系你确认金额」;收款单位 / 开户行 / 账号从配置读,没配时显示「(待定)」,生产环境没配拒绝启动 |
| M6-7 | 运营看反馈时能不能看原文 | **不看原文**:只看标题、理由、规则编号、提示词版本、页码(红线 10:数据敏感、按租户隔离)。要复盘原文时请用户导出报告发过来,或以后做「用户授权查看」 |
| M6-8 | 注销账号的确认方式 | **输入企业名称 + 短信验证码**;有没到期的专业版时写明剩余额度作废、不退款 |
## 5. 落地记录(2026-11-05)
M6-1–M6-8 全部按建议确认后实现。和上文不一样、或计划里没写到的:
- **迁移 0010**:订阅加「排队中」(scheduled),续费到账时当前一期还没到期就排队,到点由定时任务生效发放;开通申请加申请单号 `request_no`(KT- 加 6 位);通知加「已到期」「申请被退回」;`billing_due(now)` 是 bd_auth 所有的 SECURITY DEFINER 函数,定时任务(worker 每小时 `billing_tick`)用它跨企业找到期、该生效、该提醒的订阅,只拿编号,处理时回到各企业自己的租户事务。到期时还有没了结的预扣就等下一次再清零,避免退回的 1 次漏在余额里。
- **迁移 0011**:`bd_ops` 的权限(跨企业只读、运营账号和会话表可写、审计可写),`bd_app` 收回运营账号表的权限。角色本身由部署脚本建(`init-roles.sh`,CI 同一个脚本)。
- **只读的落点**:到期后写操作在鉴权依赖里统一拦(`identity/deps.py` 的 `READ_ONLY_ALLOWED`),退出、续费申请、删除项目、注销、通知已读、反馈、管理登录设备照常放行;不用逐个接口去改。
- **额度单位**:只发放现在用得上的「解析」;专业版套餐里的 `generate`(M4 标书生成)等功能上线再发,免得顶栏出现用不了的额度。
- **删除项目**:正在解析、检查、生成报告时返回 409 `job_already_running`,先取消(预扣退回)再删;`data_key` 销毁以后不再建新密钥,还在跑的任务写不回文件。审计的 `before` / `after` 改为 Python 的 None 存成 SQL NULL(以前是 JSON 的 null)。
- **注销账号**:验证码改为登录后 `POST /me/sms-codes` 发到自己的号码(前端不知道完整号码);同步部分完成后手机号就能重新注册,删项目、资质库、抹审计内容、销毁密钥、清对象存储由后台任务 `close_tenant` 做,可以重复执行。
- **uuid7 单调**:同一进程内严格递增(RFC 9562 方法 2),额度流水按编号排序算逐条余额。
- **顺手修的**:取消解析、取消检查的接口在刷新以前转 API 会触发懒加载报错(MissingGreenlet),取消按钮会 500。
- **运营后台**:接口说明见 `ops-api.md`;前端 `apps/admin`(`pnpm dev:admin`,:5174)六个页面:登录、开通申请、企业(含调额度)、误报 / 漏报、失败任务、审计。
测试:后端 156 个(新增 `test_billing_flow.py` 4 个、`test_ops_console.py` 3 个,覆盖流水对账、提醒只发一次、开通 / 续费 / 到期只读、删除后不可解密、注销后可重新注册、运营登录和 IP 白名单、`bd_app` 读不到运营表、`bd_ops` 改不了业务表)。浏览器走查:注册 → 申请开通 → 运营标记到账、开通、登记发票 → 流水核对 → 调额度 → 到期只读 → 删除项目 → 注销账号。
没做的:运营后台没有「撤回申请」「延长订阅」这类操作(需要时用退回 + 重新申请、调额度代替);收款信息和发票是线下流程,系统只登记发票号。
**运营后台第二版(2026-11-05,决策 M6-9、M6-10)**:菜单改到左侧(墨色渐变底,标准横版 logo 反白,非生产环境挂赭色环境标签),分组可以展开收起、每项带图标和待办数(`GET /summary`),底部是当前运营人员(姓名、角色)和退出;登录页加 logo。运营人员的角色只留结构:迁移 0012 加 `ops_admin.role`,权限点和「角色 → 权限点」对应表、每个接口的检查点、按权限点过滤的菜单都已就位,现在只有管理员。见 `ops-api.md`。

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,65 @@
# 运营后台接口(/ops/v1)
运营后台的接口不在用户端契约 `openapi.yaml` 里:它们只挂在 `bid-doctor ops` 进程上(决策 M6-2),前端 `apps/admin` 是唯一的调用方,类型在 `apps/admin/src/api.ts` 里手写。实现见 `server/src/bid_doctor/ops/`(`console_api.py` 路由、`console.py` 查询、`auth.py` 登录)。
## 安全
| 方面 | 做法 |
|---|---|
| 进程 | 和 api、worker 同一镜像的第三种进程,单独端口(开发 8019);部署时只对内网开放 |
| 来源地址 | `BD_OPS_ALLOWED_IPS`(CIDR 列表)以外的请求一律 403 `ops_ip_denied`;生产环境不配或放开全部地址拒绝启动 |
| 登录 | 用户名 + 密码(scrypt)+ TOTP 6 位动态码(身份验证器 App);同一用户名 15 分钟最多试 5 次;账号只能用 `bid-doctor ops-admin create` 建 |
| 会话 | cookie `bd_ops_session`(HttpOnly、SameSite=Strict,8 小时)+ 写操作带 `X-CSRF-Token`(等于 cookie `bd_ops_csrf`) |
| 数据库 | 跨企业查询用 `bd_ops`(绕过行级安全策略,业务表只读,运营账号和会话表可写,审计可写);对某个企业的修改走 `bd_app` + 那个企业的租户事务。`bd_app` 读不到运营账号表 |
| 留痕 | 每个操作都写审计(`actor_kind = ops`,记运营账号),查看企业详情也记一条 `ops.tenant.view` |
| 内容 | 不显示项目名称、招标 / 投标原文、审计的修改前后(决策 M6-7、红线 10) |
## 角色和权限点(只留结构,决策 M6-10)
以后会有管理员、运营、销售、运维等不同权限的运营人员。现在只建结构:
- `ops_admin.role`(迁移 0012,不加 CHECK);
- 权限点 `OpsPermission`(`ops/auth.py`):`tenant.view`、`quota.adjust`、`activation.manage`、`invoice.manage`、`feedback.view`、`jobs.view`、`audit.view`;
- 「角色 → 权限点」对应表 `ROLE_PERMISSIONS`:现在只有 `admin`(全部权限)。表里有、对应表里没有的角色什么权限都没有;
- 每个接口开头都调 `auth.require(op, 权限点)`,没有权限返回 403 `forbidden`;`/me` 返回角色和权限点,前端菜单按权限点过滤(`apps/admin/src/nav.ts`),一组都看不到时整组不显示。
加角色时只改 `ROLE_PERMISSIONS`、`ROLE_LABEL` 和 `ops-admin create --role` 的说明;要不要有「运营账号管理」页面、谁能建账号,到时再定。
## 菜单(决策 M6-9)
左侧菜单由 `apps/admin/src/nav.ts` 一处配置:分组(客户、质量、安全)→ 菜单项(图标、路由、要的权限点、待办数)。分组可以展开收起,记在本机;跳到收起分组里的页面时自动展开。待办数来自 `GET /summary`(待处理的开通申请、近 7 天失败任务、近 7 天反馈),做完操作和每分钟刷新一次。
## 接口
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | `/auth/login` | `{username, password, totp}`;失败 401 `ops_login_failed` |
| POST | `/auth/logout` | |
| GET | `/me` | 当前运营账号:角色、权限点、运行环境(非生产环境时菜单顶部提示) |
| GET | `/summary` | 菜单上的待办数;没有权限的那一项给 0 |
| GET | `/tenants?q=` | 企业名称(模糊)、手机号(精确,按 HMAC 查)、申请单号;不带条件列最近注册的 50 个 |
| GET | `/tenants/{id}` | 成员(手机号脱敏)、套餐历史、最近 30 条额度流水(不带项目名称)、任务按状态计数 |
| POST | `/tenants/{id}/quota-adjustments` | `{unit: parse, amount, reason}`;原因必填,不能扣成负数(G6) |
| GET | `/activation-requests?status=` | 待处理(等待转账、已到账)在前 |
| POST | `/activation-requests/{id}/paid` | 标记到账(只限等待转账) |
| POST | `/activation-requests/{id}/activate` | `{months = 12}`;免费版 / 已过期马上开始新一期,专业版没到期时新一期排队(续费) |
| POST | `/activation-requests/{id}/reject` | `{note}`,用户在额度页看得到原因 |
| POST | `/activation-requests/{id}/invoice` | `{invoice_no}`,开通以后才能登记 |
| GET | `/feedback?kind=` | 误报 / 漏报:标题、级别、理由、规则编号、规则集和提示词版本、页码 |
| GET | `/failed-jobs` | 解析、检查、报告失败的任务:错误类别、错误码、阶段、耗时、模型调用次数 |
| GET | `/audit-events?tenant_id=&action=` | 动作、对象、操作人、时间;`action` 按前缀筛选 |
## 本地跑起来
```
cd server && ../.venv/bin/bid-doctor ops # :8019
../.venv/bin/bid-doctor ops-admin create --username dev --display-name 开发运营 # 打印 otpauth:// 地址,用身份验证器 App 添加
pnpm dev:admin # :5174,/ops 代理到 :8019
```
已有的数据卷是在加 `bd_ops` 之前初始化的,要手工建一次角色(新数据卷由 `deploy/compose/postgres/init-roles.sh` 建):
```
docker exec -e PGPASSWORD=dev-owner bid-doctor-postgres-1 psql -U bd_owner -d bid_doctor \
-c "CREATE ROLE bd_ops LOGIN BYPASSRLS PASSWORD 'dev-ops'" -c "GRANT CONNECT ON DATABASE bid_doctor TO bd_ops"
```

View File

@@ -0,0 +1,128 @@
# 项目基本信息修改和分包选择实现方案 v0.1(2026-11-04)
> 范围:FR-M2-03 项目基本信息「用户可以修改,修改留痕」,FR-M2-04 分包识别与选择(G3)。
> 依据:`03-functional-requirements.md`、`02-user-journeys.md` J1 第 4 步、决策 G3 / DM7、`data-model.md` §5.3、`openapi.yaml`(`/projects/{id}/fields`、`/lots`、`/bidding-lots`)、原型 `project.html` `lots.html`。
> 状态:PI1–PI7 已全部按建议确认(2026-11-04,`docs/decisions.md`)。
## 0. 结论速览
- **为什么现在做**:查标的几条规则直接用项目信息。投标截止日决定证书过期(第 4 步刚做的资质匹配)和落款日期;所投的包决定查哪些清单项、和哪个限价比;法律体系决定用「投标无效」还是「否决投标」。这些都是模型抽的,现在抽错了用户改不了,查标结果就跟着错。国动是真实文件,不能指望抽得全对。
- **表和接口契约都已经有了**:`project_field` 分开存 AI 值和人工值,`lot` 有 `ai_values` 和 `edited_fields`;`openapi.yaml` 定义了字段修改、分包增删改和选包的接口。查标已经按「人工值优先」读字段、按 `is_bidding` 读所投的包。缺的是这些接口的实现和前端页面。
- **分包识别经常不对**(§1),所以「手工调整」和「这个项目不分包」都要一步就能做到,不能藏得太深。
- **检查要记下当时用的项目依据**:现在报告首页读的是导出时的项目信息,检查用的是检查时的值,中间改过就对不上(§5)。
## 1. 现状
**后端**:解析时 `project/extracted.py` 把 13 个字段和分包写进项目。用户改过的字段不覆盖,补遗的新值放进待确认;第一次识别到分包时替换掉建项目时的默认包。查标读字段时人工值优先,读所投的包用 `bidding_lot_ids`(不含默认包;没有分包时为空,表示整个项目)。清单项有 `lot_id`(空 = 各包通用),清单项修改接口已经能改 `lot_id`。
**没做的**:字段修改接口、分包增删改和选包接口、清单列表的 `lot_scope` 筛选;前端没有概览页、没有选包页,字段只在解析页显示了「识别到 N 个采购包」。
**分包识别的实际情况**(S01–S10 读标结果 `evals/out/runs/fix3/*/info.json`,对照 `07-sample-findings.md` 的人工档案):
| 招标文件 | 档案 | 识别结果 | 问题 |
|---|---|---|---|
| S10 | 3 个采购包,各有限价 | 3 个包,名称、限价都对 | — |
| S06 | 不分包(有分项限价) | 5 个「包」 | 把分项当成了包 |
| S01、S08 | 不分包 | 各 1 个包(「01」「1」),带限价 | 单包项目写了包号 |
| 其余 6 份 | 不分包 | 没有包 | — |
结论:只识别到 1 个包时按不分包处理(PI4);S06 这种错误要靠用户一键改成「不分包」,原属这些包的条目归为通用(PI2、PI3)。改进分包抽取属于读标专项优化(`extract-status.md`),这次不做,记进已知问题。
## 2. 基本信息修改(FR-M2-03)
### 2.1 接口
按契约实现 `PUT /projects/{id}/fields/{field_key}`,`value = null` 表示撤销人工值、恢复 AI 值。
- **校验按字段**:
| 字段 | 值 | 校验 |
|---|---|---|
| 名称、编号、采购人、代理机构、采购方式、评标方法 | 文本 | 去首尾空白;名称不能为空,其余空串等于撤销 |
| 法律体系 | `gov_procurement` / `tendering` / `unknown` | 枚举 |
| 预算、总价限价 | 金额字符串(元,两位小数) | 非负;界面可以写「286 万」「2,860,000」,提交前换成元(PI7) |
| 联合体、进口产品、专门面向中小企业 | 布尔 | 也可以撤销成「没写明」 |
| 投标截止时间 | 带时区的时间 | 界面按北京时间录入到分钟 |
- 写 `human_value / human_set_by / human_set_at`;`project` 表上冗余的几列(名称、编号、采购人、代理机构、采购方式、法律体系、截止时间)同步更新,撤销时回到 AI 值。
- 每次修改写审计 `project_field.set`(前后值),字段的修改记录从审计里读,和清单项同一做法。
- 项目已递交或已归档时返回 409 `project_read_only`。`If-Match` 选填,和清单项修改一致(MVP 只有一个人用,先不强制)。
- 响应里的 `updated_by` 填人工修改人。
- **关键日期跟着改**:概览的关键日期来自「日期与递交」类清单项;投标截止时间被人工改过时,时间线上的「投标截止」显示改后的值并标「已修改」,不然两处对不上。
### 2.2 不在这一步做的
补遗带来的待确认值(`POST .../fields/{key}/pending`)属于 FR-M2-10 补遗追加,下一步一起做;这次只在字段上显示「补遗 N 带来了新值」,不能确认。
## 3. 分包(FR-M2-04)
### 3.1 接口
按契约实现分包列表、新增、修改、删除和选包。补充几条规则:
- **默认包**:没分包的项目有一个默认包(`is_default`),界面上不显示,所有条目都归它(`lot_id` 为空)。手工新增第一个包时删掉默认包;删掉最后一个真实的包时自动建回默认包,即回到「不分包」,**不报 `last_lot`**(PI2,改契约)。
- **「这个项目不分包」**:新增 `POST /projects/{id}/lots/clear`,一次删掉所有包、建回默认包,条目都归为通用(PI2)。S06 那样识别错的项目一步改好。
- **删除一个包**:原属这个包的清单项归为各包通用(契约原文,外键 `ON DELETE SET NULL`);确认框写明「有 N 条清单项会归为通用」(PI3)。
- **修改**:包号(项目内不能重复)、名称、预算、限价;AI 识别的包改过的字段记进 `edited_fields`,AI 原值留在 `ai_values`。
- **选包**:`PUT /projects/{id}/bidding-lots`,至少选一个。没选过时所有包都算投(表的默认值),选包页会提示先选。
- 增删改、选包都写审计(`lot.create / update / delete / clear`、`project.bidding_lots`);只读项目返回 409。
### 3.2 清单按包筛选
- 清单列表加 `lot_scope`:`bidding`(默认,通用 + 所投的包)/ `all`。响应里带被隐藏的条数,清单页顶部写「通用 + 采购包 1 · 隐藏 23 条」,可以切到全部。
- 清单项对话框加「所属采购包」下拉(通用 / 各包),改归属走现有的清单项修改接口,留修改记录。
- 换了所投的包,清单的确认状态不变:清单内容没变,变的只是查哪些(PI6)。查标本来就按检查时的所投的包筛选,不在所投包里的条目写进覆盖范围。
## 4. 前端
- **概览标签页**(原型 `project.html`):这一步只做三张卡(PI1):
- **项目要点**:13 个字段,每个带出处(点开看原文);人工改过的标「已修改」,可以恢复 AI 值;点「修改」打开对话框,按字段类型给输入控件。
- **采购包**:包号、名称、预算、限价、是否投;「更改所投的包」去选包页。不分包的项目这张卡写「不分包」。
- **关键日期**:时间线和倒计时(FR-M2-09)。
- 原型里的检查记录、文件卡已在查标第 6 步做(2026-11-05)。AI 摘要还没做:原型「项目要点」里评分构成、保证金、递交方式这类概括,要另加抽取。
- **选包页**(原型 `lots.html`):
- 解析完成后,识别到 2 个及以上的包时,解析页的「下一步」进选包页,否则直接进清单(PI4)。
- 页面内容:每个包一张选择卡(包号、名称、预算、限价带出处、各包的条目数)、通用条目数、「识别得不对?手工调整」、「这个项目不分包」。
- 手工调整对话框:增删包、改包号、名称、预算、限价。
- **读标页**:顶部显示「通用 + 采购包 1」和隐藏条数,带「更改所投的包」链接;清单项对话框加所属包。
- **查标页**:检查预览里写明这次查哪几个包。检查结果页在项目信息于检查之后改过时提示「项目信息在本次检查后改过,重新检查后生效」(PI5)。
- 金额输入和显示:界面写元或万元都行,显示「286.00 万元」;限价旁边注明「招标文件原文」(红线 6:不是报价建议)。
## 5. 检查记下当时的项目依据(PI5)
`check_run` 已经存了 `lot_ids`。再在覆盖范围里存一份 `basis`:投标截止时间、总价限价、所投各包的包号和限价、法律体系,各带来源(AI / 人工)。
- **报告首页**改为读 `basis`。用户在检查后改了截止日再导出报告时,报告写的是检查时用的值,和问题明细一致。
- **查标结果页**比较 `basis` 和项目当前的值,不一样就出上面那条提示。
- 不加迁移,`coverage` 是 jsonb。
## 6. 测试
- 集成测试:字段修改、撤销、校验、只读、审计、冗余列同步;分包增删改、默认包的建回、「不分包」、删包时条目归通用、选包至少一个;清单 `lot_scope`;检查用了改后的截止日和所投的包,`basis` 记下的是检查时的值。
- 前端在浏览器里走一遍:概览改截止日 → 查标页提示重新检查;选包页手工调整、一键不分包、选包后清单隐藏条数。
- 不需要真实模型:分包用接口直接建,字段用测试样例解析出来的。
## 7. 工作量和顺序
1. 后端字段修改、分包接口、`lot_scope`、`basis`,以及测试。约半天。
2. 前端概览页(三张卡和修改对话框)。约半天。
3. 前端选包页、读标页和查标页的联动。约半天。
## 8. 待确认事项
| 编号 | 事项 | 建议 |
|---|---|---|
| PI1 | 字段在哪里改 | **做「概览」标签页**,这一步只做项目要点、采购包、关键日期三张卡;检查记录、文件、AI 摘要卡留到第 6 步。另一种做法是只在项目头放一个「项目信息」对话框,省事,但以后还得做概览页 |
| PI2 | 删掉最后一个包 | **回到「不分包」**(自动建回默认包),不报 `last_lot`;另给「这个项目不分包」一键操作。S06 那种把分项识别成 5 个包的,一步改好 |
| PI3 | 删包时原属这个包的条目 | **归为各包通用**(契约原文),确认框写明条数。被误识别的包里的条目多半本来就是通用的;归通用宁可多查,不会漏查 |
| PI4 | 只识别到 1 个包 | **按不分包处理**:跳过选包页,这个包的限价照用(作为采购包限价)。S01、S08 都是这种情况 |
| PI5 | 检查之后改了项目信息 | **检查记下当时的依据**,报告按检查时的值写;查标页提示「重新检查后生效」。不自动重新检查(重新检查可能扣点,红线 9 要求用户自己决定) |
| PI6 | 换了所投的包,清单还算不算已确认 | **算**:清单内容没变;查标按检查时的所投的包筛选,覆盖范围里写明不在所投包里的条数 |
| PI7 | 金额怎么录 | **按元存**,界面可以写「286 万」「2,860,000」,提交前换成元;显示用万元 |
## 9. 落地记录(2026-11-04)
- 后端:`project/edit.py`(字段修改、分包增删改、「不分包」、选包,都写审计);`project/service.basis` 是检查时的项目依据,查标存进覆盖范围、检查结果接口给 `basis_changed`,报告首页按它写截止时间;清单列表 `lot_scope` 默认只列通用 + 所投的包,响应带 `hidden_count`。
- 名称没有 AI 值时(建项目时手填的),撤销人工值保持现在的名称;界面只在有 AI 值时给「恢复 AI 值」。
- 前端:概览页三张卡(项目要点的值没有字段记录时取项目本身的值,比如建项目时填的名称)、字段修改对话框、选包页和手工调整对话框;解析完识别到 2 个及以上的包时「下一步」进选包页;读标页显示清单范围和隐藏条数,可以「显示全部」;清单项对话框加「所属采购包」;查标页写明本次查哪几个包;检查结果页在 `basis_changed` 时提示重新检查。
- 浏览器里走过:改截止时间(项目头、要点、关键日期一起变)、写「286万」的限价、手工加两个包并写限价、只投包 1、一键改回不分包。

View File

@@ -0,0 +1,189 @@
# 资质库和资质匹配实现方案 v0.1(2026-11-04)
> 范围:查标实现计划(`m3-plan.md`)第 4 步,即 FR-M6-08 最小资质库 + FR-M3-05 资质匹配检查。
> 依据:`03-functional-requirements.md`、`05-it-integration-domain.md` R07 / R08、决策 G4 / C10 / DM4、`data-model.md`(`qualification` `qualification_file`)、`openapi.yaml`(`/qualifications`)、原型 `qualifications.html` 和 `results.html` 的 f6、评测规范 `evals/check/GUIDELINES.md` D11 / D12 / D14 / D15。
> 状态:QL1–QL7 已全部按建议确认(2026-11-04,`docs/decisions.md`)。
## 0. 结论速览
- **表、接口、原型都已经有了**。基线迁移里已建 `qualification` 和 `qualification_file` 两张表;`openapi.yaml` 里已定义 7 个操作;原型资质库页的交互也已定稿。资质库本身是一个普通的增删改模块,工作量主要在前端页面和附件。
- **难点在匹配,不在资质库**。从样本看(§1),企业资质在政府采购项目里多数是**评分项**,不是资格条件;而且大量「证书」要求是**产品证书**(节能认证、CCC、商用密码)或**人员证书**,资质库管不了,必须先排除。
- **匹配不调模型**:按 IT 集成常见证书建一张类别词表,招标侧的要求、投标文字层、资质库三方都映射到类别再比较。认不出类别的,不做判断,写进检查覆盖范围。
- **证据有两个来源,可信度不同**:投标文字层(比如投标文件里的「资质情况表」)上读到的有效期和等级,有原文出处,按清单项定级;只有资质库记录、投标文件那边是扫描件或没写日期时,**降一级**,说明里写明「按资质库记录」。
- **资质库是空的就不做资质库核对**,只在覆盖范围里写一句。不能因为用户没录,就报一片「未覆盖」。
## 1. 样本里的资质要求长什么样
在 S01–S10 读标结果里筛出和证书有关的清单项(`evals/out/runs/fix3`),分成四种:
| 种类 | 例子 | 清单类别 | 资质库能不能管 |
|---|---|---|---|
| 企业资质(资格条件) | 电子与智能化工程专业承包资质、《安全生产许可证》(S01) | 资格条件 + 无效 | **能**,致命 |
| 企业资质(评分项) | ISO 27001 / 9001 / 20000(S09)、CCRC 信息安全服务资质一级、CCIA 一级(S01) | 评分 | **能**,重要 |
| 软件著作权(评分项) | 「虚拟化云平台软件计算机软件著作权登记证书(1 分)」、「软件著作权证书(3 分)」 | 评分 | 部分:软著长期有效、没有等级,只能看有没有 |
| 产品证书 | 节能产品认证、CCC、商用密码产品认证、IPv6 Ready、网络安全专用产品检测证书 | 技术参数 / 无效 | **不能**:证书是厂商的、跟着产品走 |
| 人员证书 | 「虚拟化维护人员具有虚拟化认证证书」 | 技术参数 | **不能**:人员库是 V1 |
结论:
1. 政府采购项目不能把和项目无关的资质设为资格条件,所以企业资质出现在资格条件里的少(10 份里只有 S01),多数在评分项里。这样一来,资质问题大多是「重要」,不是「致命」。
2. 产品证书和人员证书必须先排除,否则资质库会对它们报「未覆盖」,造成大量误报。
3. 真实投标文件里的证书基本都是盖章扫描件。不少 IT 投标文件另附一张文字的「企业资质情况表」,列出证书名称、编号和有效期,这是投标文字层上唯一能读到有效期的地方。
## 2. 资质库(FR-M6-08)
### 2.1 数据
用基线里已有的两张表,**不加列、不加迁移**:
- `qualification`:名称、类型(资质证书 / 体系认证 / 软件著作权 / 其他)、等级、证书编号、发证机构、有效期起止(「有效期至」为空表示长期有效)。
- `qualification_file`:附件。对象键 `t/<tenant>/q/<qualification>/<file>`,用**企业级数据密钥**加密(DM4)。
证书类别(§3.1)在用到时从名称和类型算出来,不存库。词表改了以后,旧记录不用迁移。
### 2.2 接口
按 `openapi.yaml` 现有的 7 个操作实现:列表(可按类型筛)、新增、修改、删除(连同附件)、挂附件(走现有的分片上传,`purpose = qualification_file`)、附件下载地址、删除附件。只加两处:
- `Qualification` 响应加一个字段 `late_for`:进行中的项目里,投标截止日晚于这张证书「有效期至」的项目(项目编号、名称、截止日)。原型里的「N 张证书会在进行中项目的投标截止前到期」提醒就靠这个字段。「进行中」指状态是跟进中、编制中、已检查,且截止日不早于今天。
- 列表接口加关键词参数 `q`,按名称、编号、发证机构搜索。
权限点用已有的 `qualification.edit`。增删改和附件操作都写审计:`qualification.create / update / delete`、`qualification_file.attach / delete`,修改记录前后的值。不扣额度。
### 2.3 校验
- 名称必填;「有效期至」必须晚于「有效期起」。
- 证书编号、「有效期至」:原型要求必填(长期有效的打开开关),**界面按原型必填,接口保持可空**,因为以后从投标文件批量导入时不一定读得全。
- 附件:PDF / JPG / PNG,单个不超过 20 MB,一条资质可以挂多个附件。
### 2.4 删除
删除是硬删:同时删行、删对象、写审计。原型在确认框之外还有「撤销」提示,**这一步不做撤销**(QL6):要撤销就得软删除,而软删除和「彻底删除」(G10)冲突;确认框已经够了。已导出的报告不受影响,因为报告里存的是生成时的快照。
企业注销时按 `data-model.md` §6.1 的顺序删资质库,企业级密钥一并销毁。
### 2.5 前端
照原型 `qualifications.html` 实现:侧栏「资质库」(图标 `award`)、类型分段筛选、关键词搜索、按「有效期至」排序(长期有效的排最后)、到期提醒条、新增 / 编辑对话框(日期选择器上标出进行中项目的截止日)、附件上传和预览、删除确认。页面底部注明「业绩、人员、历史标书等资产库在 V1 提供」。
## 3. 资质匹配(FR-M3-05)
### 3.1 证书类别词表
`check/qualification.py` 里放一张词表,规则集版本跟着升(2026.11-r4)。每个类别包括:别名(含国标等同写法)、等级写法,以及等级怎么比(数字越大越高,还是「一级」最高)。初版覆盖 IT 集成常见的企业资质:
| 类别 | 别名例子 | 等级 |
|---|---|---|
| ISO 9001 质量 | GB/T 19001 | 无 |
| ISO 14001 环境 | GB/T 24001 | 无 |
| ISO 45001 职业健康安全 | OHSAS 18001、GB/T 45001、GB/T 28001 | 无 |
| ISO 27001 信息安全 | GB/T 22080 | 无 |
| ISO 20000 IT 服务 | GB/T 24405 | 无 |
| ISO 22301 业务连续性 | GB/T 30146 | 无 |
| CMMI | 能力成熟度模型集成 | 1–5,大为高 |
| 信息系统建设和服务能力 | CS、CS1–CS5 | 1–5,大为高 |
| ITSS 运维 | 信息技术服务标准、运行维护 | 一级最高 |
| CCRC 信息安全服务资质 | 中国网络安全审查技术与认证中心;子类:安全集成、风险评估、应急处理、软件安全开发等 | 一级最高 |
| CCIA 信息系统业务安全服务资质 | 中国网络安全产业联盟 | 一级最高 |
| 电子与智能化工程专业承包 | 建筑业企业资质 | 一级 > 二级 |
| 建筑智能化系统设计专项 | 工程设计资质 | 甲 > 乙 |
| 涉密信息系统集成 | 涉密资质 | 甲 > 乙 |
| 安全生产许可证 | | 无 |
| 软件著作权 | 计算机软件著作权登记证书、软著 | 无,长期有效 |
**排除词**:要求里出现「产品」「设备」「所投」「厂商」「制造商」「节能」「环境标志」「CCC」「3C」「商用密码产品」「人员」「工程师」「项目经理」这类主体时,判定为产品证书或人员证书,不进资质匹配,仍按现在的流程处理(找响应、扫描件兜底)。
认不出类别、但清单类别是资格条件或评分项、文字里又有「证书 / 资质 / 认证」字样的,计入覆盖范围:「N 条资质要求没认出是哪类证书,有效期和等级没有核对」。
### 3.2 三方怎么对上
对每条进入资质匹配的清单项(类别是资格条件或评分项,且认出了证书类别):
1. **招标侧**:类别、要求的等级(「CS3 级及以上」「一级」)、子类(CCRC 的「安全集成」)。要求原文就是招标侧出处。
2. **投标文字层**:在响应匹配找到的证据句附近,以及投标文件里写着同一类别的表格行中,读证书编号、等级、有效期(「有效期至」「有效期:A 至 B」「截止日期」)。日期解析复用 `rules.py` 里的写法。只取**和这个类别同一行、或同一段落里只有这一张证书**的日期;附近出现两张以上证书、日期归属不清时不读。
3. **资质库**:同一类别的记录。同类有几条(旧证书和换发后的新证书)时取最有利的一条:有效期最晚、等级最高。
有效期比较用项目基本信息里的**投标截止日**(人工改过的为准),不用今天。「有效期至」当天仍算有效,即 `有效期至 < 截止日` 才算过期。项目没有截止日时,不比有效期,写进覆盖范围。
### 3.3 规则和定级
「按清单项定级」:资格条件 → 致命,评分项 → 重要(沿用 `findings.severity`)。
| 规则 | 什么时候报 | 证据来源 | 级别 |
|---|---|---|---|
| `QUAL.EXPIRED` | 证书在投标截止日已过期 | 投标文字层 | 按清单项 |
| | | 只有资质库 | 降一级(致命 → 重要,重要不变) |
| `QUAL.LEVEL_LOW` | 等级能比较,且低于要求 | 投标文字层 | 按清单项 |
| | | 只有资质库 | 降一级 |
| `QUAL.NOT_COVERED` | 投标文字层没找到(结论是「未找到」或「扫描件」),而且资质库**有记录但没有这一类** | 资质库 | 重要(不给致命:资质库可能没录全) |
| (不报问题) | 资质库里有、而且有效、等级够 | | 扫描件兜底那条「请人工核对」保留,说明里补一句:资质库里有《X》(编号…,有效期至…),请核对扫描件里附的是这一张 |
规定:
- **投标文字层和资质库冲突时,以投标文件为准**。投标文件里的日期已过期、资质库里有换发后的新证书时,仍按投标文字层报 `QUAL.EXPIRED`,说明里写「资质库里有有效期至 … 的新证书,投标文件附的可能是旧证书」。评审看的是投标文件。
- **一条清单项只出一个资质问题**。资质问题比扫描件兜底的「请人工核对」更具体,有资质问题时代替那一条;投标文件是文字版且确实没找到(「未找到响应」,致命)时,保留原问题,只在说明里补资质库的情况(「资质库里有这张证书,可能忘了附」)。
- **软件著作权**不查有效期和等级,只在扫描件兜底说明里补「资质库里有 N 件软件著作权」。要求里写了具体软件名的,也不按名称硬配:软件名的写法太散。
- **CCRC 子类**:要求写了子类(安全集成),资质库记录的名称里没有这个子类,按 `QUAL.NOT_COVERED` 报,说明写「资质库里的 CCRC 证书没写服务类别,请核对是不是『安全集成』」,级别是提示。
- **资质库是空的**:所有资质库规则都不跑,覆盖范围写「资质库还没有录入资质,有效期和等级没有按资质库核对」。查标页在覆盖范围旁边放一个去资质库的链接。
- 说明只写事实,不写整改建议。原型 f6 里「请上传换发后的证书」这一句去掉,改成「按资质库记录,有效期至 2026年11月15日,早于投标截止日 11月20日」。
### 3.4 问题的存法和出处
- `finding.type = qualification`,`detector = rule`,`rule_code` 是上面的编号,`checklist_item_id` 填对应清单项,`match_key = rule:QUAL.EXPIRED:<清单项>`,复查沿用按现有机制走。
- `detail` 里存:`kind`(过期 / 等级不足 / 未覆盖)、`source`(`bid_text` / `library`)、截止日、要求的等级,以及用到的资质库记录的**快照**(编号、名称、等级、有效期、记录 id)。资质库记录以后改了、删了,旧检查的问题和报告不变。
- 出处:招标侧照常挂清单项的原文出处;投标文字层来源的挂投标侧句子;资质库来源的**没有投标侧出处**,界面和报告显示「依据:资质库记录」块(名称、编号、有效期,能点开那条记录和附件)。这一点要你确认(QL2)。
- 覆盖范围加一节「资质核对」:资质库有几条、本次核对了几条资质要求、几条按资质库判断、几条没认出类别、产品证书和人员证书各排除了几条。
### 3.5 在流水线里的位置
放在 `runner.py` 的响应匹配和扫描件兜底之后、`rules.run` 之前,读一次本企业的资质库,不调模型、不计费。复查时重新算:资质库可能改过,结果不复用。
## 4. 不在这一步做的
| 不做 | 原因 |
|---|---|
| D14 证书主体和投标人不一致(子公司、曾用名) | 不在 FR-M3-05 的三种预警里;证书主体只在扫描件上;资质库挂在企业主体下,主体本来就一致。等 OCR(S5)后再看 |
| D15 业绩 / 人员证明的时间窗口 | 业绩库、人员库是 V1 |
| 产品证书核对 | 厂商的证书,不是用户的资质(红线 5:不从外部补数据) |
| 从投标文件或附件里自动识别、批量导入资质 | 要 OCR / 多模态;MVP 只手工录入(01-scope-roles:「手工录入 + 有效期」) |
| 读标清单上显示「资质库:ISO 9001 有效期至 …」 | 已在第 6 步做(2026-11-05):`GET /projects/{id}/qualification-matches`,按投标截止日判断,资质库空时不显示 |
| 软著按软件名称配对、按件数核对评分 | 写法太散,认不准的不报 |
## 5. 评测
- **类别识别**:把 S01–S10 读标结果里和证书有关的清单项全部人工标注(企业资质 / 软著 / 产品证书 / 人员证书 / 无关,再加类别和等级),约几十条,作为词表的单元测试。读标留出集的 S03、S06、S09 不在开发里看,词表写完后跑一次,只用来报数字。
- **规则**:每条规则配正例和反例单元测试:截止日当天到期不报;同类有新证书也有旧证书取新的;等级写法认不出时不比较;产品证书不进匹配;资质库空不报。
- **评测集**:生成器现在不生成证书文字。给风格 A / B 的规矩投标文件加一张「企业资质情况表」(虚构的证书、编号、有效期),注入 D12(有效期早于截止日;整张表的证书都过期)和「等级不足」两类问题,按 GUIDELINES 的流程请 Fable 复核、你抽查。资质库那一侧在评测里用虚构的资质库模拟,分「空 / 全 / 缺一类 / 有过期」四档。
- **局限**:评测集里的证书表是看着词表生成的,分数说明不了泛化。国动的真实投标文件到了以后,请你按自己公司的真实证书录资质库,再跑一次,这才是这一步真正的检验。
## 6. 工作量和顺序
1. 后端 `qualification` 模块:增删改、附件、`late_for`、审计,集成测试(含行级安全、企业级密钥)。约半天。
2. 前端资质库页。约半天。
3. 类别词表、标注样本、`check/qualification.py`、接入流水线、覆盖范围、问题渲染和报告。约 1 天。
4. 评测集加证书表和注入,复核。约半天。
## 7. 待确认事项
| 编号 | 事项 | 建议 |
|---|---|---|
| QL1 | 匹配方式 | **规则 + 证书类别词表,不调模型**。认不出的不判断、写进覆盖范围。理由:资质名称是有限集合,规则可解释,不花钱 |
| QL2 | 只有资质库记录、没有投标侧出处时能不能出问题 | **能出,降一级**,出处显示「依据:资质库记录」。资质库是用户自己录的数据,不是 AI 结论,和红线 2「AI 结论要能回溯原文」不冲突;如果不出,扫描件为主的真实投标文件就基本查不出过期 |
| QL3 | 「未覆盖」怎么报 | 资质库**有记录但没有这一类**时报「重要」;资质库是空的不报,只写覆盖范围 |
| QL4 | 投标文件和资质库冲突时 | **以投标文件为准**,说明里提一句资质库里有新证书 |
| QL5 | 免费版能不能用资质库 | **能用**。01-scope-roles 把资质库写在专业版下,但它不花额度、没有成本;免费版只有 1 个项目,用来试用完整的查标链路,缺了资质库体验就不完整 |
| QL6 | 删除要不要撤销 | **不要**,只留确认框;撤销需要软删除,和「彻底删除」冲突 |
| QL7 | D14(主体不一致)、D15(时间窗口) | **不在这一步做**,评测规范里这两类的实现阶段改为「OCR 之后 / V1」 |
## 8. 落地记录(2026-11-04)
和上文设计不一致、或实现时补的,记在这里:
- **一类证书一个问题**(改 §3.3「一条清单项只出一个资质问题」):一条评分项常写几类证书(S07「ITSS 三级得 1 分;CS3 及以上得 2 分」),一张过期、一张等级不够是两件事,合成一条时规则编号只能取一个。改为每条清单项里每类证书出一个问题,标题写证书名(「证书等级低于要求:信息系统建设和服务能力等级」),`match_key = rule:QUAL.*:<清单项>:<证书类别>`。
- **评分项不逐条找响应的也查**:applicability 把没写「提供……证书」的评分项跳过了(「评分标准本身」),而企业资质多数就在评分项里。资质匹配改为看所投包里的全部清单项;这类条目没有响应结论,投标文字层和资质库的过期、等级照常提示,「未覆盖」不报(不知道投标人报不报这一项分)。
- **等级按证书各读各的**:要求里属于这一类证书的那一段(从它的名称到下一类证书的名称)里读等级;投标侧先看短的格子(等级栏),证书编号里常带等级字样(「CS3-3201-…」);大写数字(「贰级」)也认。
- **响应错位规则收窄**(`check/deviation.py`):资质情况表的名称格(「安全生产许可证」,比要求短、以证书名结尾)不算「照抄的要求格」,右边的编号、有效期不算「响应对不上」。端到端测试里发现的。
- **营业执照、法人证书、联合体的资质认定规则**不算「没认出类别的资质要求」,不进覆盖范围的计数。
- **出处**:资质问题挂清单项自己的招标侧出处(`Hit.tender_refs`);问题接口的 `rule` 改为规则问题都带(原来挂了清单项就不带);报告模板 v2 在问题明细里写「依据:资质库记录《…》」。
- **前端**:资质库页和对话框(日期可直接键入,接受 2026/11/20、2026年11月20日等写法;日历弹层第 6 步已加);查标页资质问题显示「依据:资质库记录」块,墨色(用户录入的数据,不用 AI 出处的蓝铅笔色)。
- 评测见 `check-eval.md` §13。

View File

@@ -0,0 +1,301 @@
# 参考项目分析:标擎 BidEngine
> 对象:`opensource-ref/bid-engine/`(github.com/panviviplus/bid-engine),招投标 AI 工作台,约 16 万行。阅读日期:2026-11-03。
> 方法:按三个方向只读走查源码,关键结论对照源码抽查过。只看了代码,没有部署运行;标「推测」的未经运行验证。
> 目的:为「技术架构设计」阶段提供参考和避坑清单。
> **许可证**:PolyForm Noncommercial 1.0.0,只允许非商业使用。本文只记录**做法层面**的结论,不得把它的代码、提示词、SQL、文案搬进本项目(见 AGENTS.md「参考开源项目」)。本文的结论只是讨论素材,正式结论以 decisions.md / analysis / PRD 为准。
下文的代码路径默认相对于 `opensource-ref/bid-engine/bid-engine-backend/pkg/`;`smart-bid.sql` 指 `opensource-ref/bid-engine/docs/sql/smart-bid.sql`。
## 0. 总评
它在「防止 AI 乱引用原文」上做得扎实,值得学:证据编号白名单由服务端核对,审核侧还会把引文和原文逐字比对。但租户隔离、密钥管理、模型路由和数据贯通都比较粗糙,而这几处恰好是我们的产品红线。它没有一份贯穿全流程的「检查清单」,正好反证了我们以项目检查清单为核心的设计方向是对的。
| 方向 | 值得参考 | 需要避坑 |
|---|---|---|
| 技术架构 | 原文块带页码和坐标;按页检查解析是否完整;AI 值和人工值分开存;每次解析是一次独立运行 | 登录密钥写死在源码里;租户隔离靠手写;API Key 明文存库;自研队列会丢任务;接口文件过大,没有测试 |
| 提示词工程 | 证据编号白名单;引文逐字核对;固定字段逐项表态;结构化输出逐级降级;输出被截断后二分重跑 | 只核对编号不核对内容;审核结论的证据不校验;领域知识缺位;写标书时诱导编造;没有版本号和评测集 |
| 模型路由与业务 | 只对临时错误重试;错误分类;按模型的上下文长度分块;调用流水账;记录模型配置快照 | 用户可以随意填模型地址;只有部分环节记调用账;三个模块之间靠复制数据衔接;重跑会丢人工确认;评分预估是机械折算 |
## 1. 技术架构
### 1.1 现状
- 后端是 Go 1.23 + Gin + GORM(用 gorm.io/gen 生成代码),MySQL 8 有 102 张表;任务队列和进度事件用 Redis 7,对象存储用 MinIO,文档解析用 Docling,另有一个 doc→PDF 转换服务。
- 前端是 Next.js 14(App Router)+ Chakra UI,富文本编辑用 TipTap 3。前端通过 `app/api/[...path]` 同源代理到后端。
- 采集服务是 Python 写的(FastAPI + Playwright + trafilatura),单独部署,不连数据库。
- 后端是单体:HTTP 服务和所有后台 Worker(解析、蓝图、生成、审核、情报采集,外加回收卡死任务的扫描器)在同一个进程里启动(`bid-engine-backend/cmd/server/main.go:89-178`)。
- 分层名义上是 handler / repo / service,实际业务逻辑基本写在 handler 里:`pkg/service` 只有 325 行,`handler/bidanalysisv3` 直接操作数据库约 107 处。
### 1.2 值得参考
1. **原文切块、带页码和坐标入库**:表结构见 `smart-bid.sql:205-231`,坐标处理见 `handler/bidanalysisv3/document.go:544-557`。坐标兼容了左下角原点的情况,并统一缩放到 0–1,前端直接叠一层高亮。
→ 它只能高亮到 Docling 切出的整块,我们要求高亮到原句,需要切到行或字符级。
2. **按页检查解析是否完整**(`document.go:391-454`):逐页核对 Docling 解析出的页数;平均每页少于 40 字时,自动加 OCR 重跑。
→ 可以直接用来判断扫描件和「解析失败」,对应红线 9「解析失败不扣点」。
3. **AI 值和人工值分开存**(`smart-bid.sql:340-358`、`handler/bidanalysisv3/api.go:1108-1121`):每个值有两个维度,来源分为 AI / 人工,状态分为生效 / 建议 / 历史。人工改值不覆盖 AI 原值,改完要求补出处。
→ 正好满足红线 1「全程留痕」。
4. **每次解析是一次独立运行**:运行、阶段、子任务、控制信号分四张表存。
- 任务开始时比对运行编号,过时的结果直接丢弃(`handler/bidanalysisv3/pipeline.go:56-73`)。
- 只有整轮成功才切换成当前结果,晚到的结果会被拒绝(`repo/bidanalysisv3/repository.go:372-407`)。
- 续跑前先清掉该阶段的残留数据(`pipeline.go:103`),重跑某一阶段时按运行清理下游数据(`repo/bidanalysisv3/recovery.go:240-260`)。
5. **模型调用流水账**(`smart-bid.sql:396-415`):记录功能、模型、请求和响应的哈希、token 数、耗时、状态,可以作为额度计量的底账。
### 1.3 需要避坑
1. **鉴权**
- 登录令牌的签名密钥写死在源码里(`middleware/jwt.go:13`),知道这个值就能伪造任何用户的令牌。
- cookie 没开 secure,有效期 30 天,没有吊销机制。
- 只要设了 `LOCAL_DEV=true`,就直接以超级管理员身份放行(`middleware/auth.go:21-30`),配错一次就是后门。
- 开放接口只靠一个全局令牌,验证通过后同样拿到超级管理员身份(`auth.go:111-160`)。
2. **租户隔离靠每个接口手写条件**
- 查询项目时用户编号传 0 就不过滤(`repo/bidanalysisv3/repository.go:179-184`),每个接口都要自己记得传。
- 表里有公司字段,但不参与隔离;没有统一的查询过滤,也没有数据库行级策略。
- 管理员权限是在各个接口里自己调用检查函数(`handler/tenderintel/admin_notices.go:85`)。漏写一处就会越权。
→ 我们要在数据访问层统一强制按租户过滤,不依赖每个接口自觉。
3. **大模型 API Key 明文存库**:建表注释里直接写着「API Key(明文)」(`smart-bid.sql:1722`),只在接口返回时打码。
4. **自研 Redis 队列会丢任务**
- 先把任务从队列里取出来,下一步才标记「处理中」(`repo/taskqueue/worker.go:105-131`),进程在这两步之间崩溃,任务就丢了。
- 回收卡死任务的扫描器每 30 秒把所有任务键扫一遍(`worker.go:338`)。
- 服务没有优雅退出,发版时只能等扫描器去回收任务。
5. **暂停和取消信号靠轮询数据库**:每个阶段开一个协程,每 300ms 查一次(`pipeline.go:323`),并发项目一多,数据库压力就上来了。
6. **代码组织**
- 接口层文件过大:`api.go` 1542 行,前端 `panels.tsx` 3201 行。
- 后端有两套响应格式混用。
- 前端状态全靠 useState 加定时轮询;PDF 预览装了两套库。
7. **工程基础薄弱**:表结构只有一份导出的 SQL,没有迁移工具;后端没有一个测试文件;前端只测了纯函数。
## 2. 提示词工程
### 2.1 现状
- 共约 35 条提示词:招标解析 11 条,标书生成约 8 条外加一组共享写作规范,审核和素材 OCR 约 9 条,情报站 3 条。
- 全部写死在 Go 常量或字符串模板里,按业务模块分文件存放。送给模型的内容统一是「大写标签 + JSON」格式。
- `docs/llm-prompts/` 是从代码里人工抄出来的汇编,和代码基本一致。
- 没有提示词版本号,没有评测集。固定字段目录(约 62 条)属于种子数据,没有开源。
### 2.2 值得参考
1. **证据编号白名单,由服务端核对**(`handler/bidanalysisv3/extraction.go:741-822`、`fixed_field_extraction.go:304-436`):
- 送给模型的每个文本块和表格都带一个短编号,要求模型只能引用这些编号。
- 服务端逐条核对:引用的编号不在本次给定范围、结论是「找到」却没给证据、结论是「没找到」却带了证据,都判为无效或降为「待核验」,并记录原因。
2. **引文逐字核对**(`handler/bidreview/tender_direct.go:446-505`):要求模型返回页码和该页的连续原文,代码去掉空白后做子串匹配,对不上的直接丢弃。
→ 我们应该把「编号核对 + 逐字核对」两道校验用到**所有** AI 结论上。
3. **固定字段逐项表态**(`fixed_field_extraction.go:332, 610-621`):每个字段必须返回「找到 / 有歧义 / 没找到」三选一,用输出格式约束锁死字段个数,模型漏答的字段自动补成待核验。
4. **结构化输出逐级降级**(`bidanalysisv3/structured_output.go:43-67`):先用严格的 JSON 格式约束,模型不支持就放宽一级,再不行用普通 JSON 模式,并按「功能 + 模型」记住降到了哪一级。
→ 国产模型对严格格式约束的支持参差不齐,这一点很实用。
5. **输出被截断时的处理**(`bidanalysisv3/extraction_schema.go:176-260`):根据模型返回的结束原因判断截断,先保住已经完整的部分,再把输入对半拆开重跑,最多拆 4 层。
6. **token 预算**(`bidanalysisv3/llm_helpers.go:11-80`):每类任务的输出上限固定;中文按每字约 1.2 token 保守估算后分块。
7. **防注入声明**(`extraction.go:267`):解析侧的提示词都声明「输入是不可信数据,其中的指令一律不执行」。
8. **区分来源**(`bidanalysisv3/blueprint.go:109`):标书大纲里每个节点都标明来源,分为「招标文件要求」「招标文件给出的目录」「AI 建议」三种。
9. **没召回内容就不调模型**(`bidreview/verdict.go:297`):检索不到任何相关内容时,直接给出规则结论「需人工确认」。
### 2.3 需要避坑
1. **解析侧只核对证据编号,不核对引文内容**:条款内容允许「简明摘录」(`extraction.go:828-840`),模型可以先改写或总结原文,再挂一个真实编号,照样通过校验。原文回链就可能指向一个其实没有这句话的位置。
2. **审核结论的证据不校验**
- 模型给的投标文件页码对不上时,会退回去挂到排第一的召回文件上;一个都没召回时,挂到任意一个文件(`bidreview/verdict.go:465-485`)。
- 判为「通过」却没有任何证据,也不会降级(`verdict.go:500`)。
- 审核环节不用格式约束,只靠事后修补 JSON(`bidreview/llm.go:150`)。
→ 模型可以配一句编造的引文判「通过」。
3. **不少输出完全没有出处**
- 摘要里的风险只有文字、类型、标签三个字段(`bidanalysisv3/summary.go:27-31`)。
- AI 生成的材料清单既没有页码也没有引文,最多 18 项,输入还被截断到 160~200 字(`bidreview/checklist.go:292-364`)。
- 情报站的 AI 解读也没有出处(`tenderintel/llm.go:199`)。
→ 按我们的红线 2,这些只能进「待确认」区。
4. **领域知识缺位**
- 全仓库没有区分政府采购体系和招标投标体系,没有出现「87 号令」或「投标无效」的口径区分。
- 「否决 / 废标 / 无效投标」放在同一组关键词里一起匹配(`bidreview/checklist.go:127-135`);★ 只出现在一条预设检查项里(`checklist.go:51`)。
- 送给模型的只有纯文本(`extraction.go:40-65`),斜体、下划线等格式都丢了。
→ 我们的样本分析(`docs/analysis/07-sample-findings.md` F1)已经确认,有的实质性条款只靠斜体加下划线标注,丢了格式就会全部漏掉。
5. **写标书时会诱导编造**
- 写作规范要求「上下文没有数据时,改写为承诺做法与保障机制」,并且「禁止降低我方响应程度」(`bidgen/writing_rules.go:229-251`),模型因此倾向于全部正偏离、过度承诺。
- 设了 300 字的最低字数,不够就自动扩写(`bidgen/topup.go:68`);生成温度 0.7。
- 生成后不检查数字和资质;不标注「初稿」;没有禁止生成报价,也没有报价占位符。
→ 这些都违反我们的红线 1、5、6。
6. **没有版本管理和评测**:调用记录(`extraction.go:690`)只存了模型名和输入的哈希,没有提示词版本,而且系统提示词不参与哈希,改了提示词也看不出来。
7. **防注入不统一**
- 情报站把抓来的公告原文直接拼进提示词,没有任何隔离(`tenderintel/llm.go:122, 199`);审核判定的提示词也没有隔离声明。
- 打标时让模型自己把预算换算成「元」,结果没有核对(`tenderintel/llm.go:137`)。
## 3. 大模型路由与业务设计
### 3.1 路由现状
- **按「用户 × 业务模块」路由**(`repo/llm/user_config.go:43-75, 162-245`)
- 每个功能写死对应到四个业务模块之一(解析、生成、审核、素材),任务类型不参与路由。
- 查找顺序:先找这个模块的配置,再找用户的通用配置,都没有就报「未配置」,不会回落到平台配置。
- base_url、API Key、模型名都由用户自己填。
- **平台配置只给后台任务用**,比如情报站(`repo/sysllm/sysllm.go:157-216`):候选模型按顺序排列,失败了换下一个,列表为空时回落到配置文件里写死的 DeepSeek。
- **多模态没有用视觉模型**:扫描件靠 Docling 的 OCR;签章只是审核清单里的一条文本项,让模型读文字来判断。
- **整个系统没有额度和计费**:只留了一个空接口(`bidgen/generation_worker.go:27`)。
### 3.2 路由:值得参考
1. **只对临时错误重试**(`repo/llm/openai_client.go:50-54, 150-187`):只在限流、服务端 5xx 和网络抖动时重试,最多 3 次,间隔 2 秒、8 秒。
2. **错误分类**
- 错误统一归成 8 类,前端只显示友好文案(`repo/llm/errors.go:47-77`)。
- 解析环节还区分「基础设施故障」和「内容质量问题」,前者直接让这一阶段失败(`bidanalysisv3/llm_helpers.go:93-105`)。
→ 正好可以作为我们判断「是否扣点」的依据。
3. **按模型能力分块**:每类任务的输出上限固定,输入按模型的上下文长度自动切块。换成上下文小的模型只是多调几次,不会报错。
4. **记录模型配置快照**(`bidanalysisv3/api.go:109`):每次解析开始时,把当时用的模型配置存进这轮运行的记录里。
5. **并发控制**
- 解析用进程内信号量,默认 8 路(`bidanalysisv3/service.go:59-75`);审核默认 4 路。
- 生成按用户在 Redis 里占槽位(`bidgen/generation_worker.go:404-411`)。
### 3.3 路由:需要避坑
1. **用户可以随意填模型地址**:招标文件会被发到任意地址,没有地址白名单,还允许关闭证书校验(`openai_client.go:29`)。
→ 和我们「数据不出境」的约束(T1)根本冲突。
2. **配置连通性测试的设计有风险**:如果提交上来的 Key 是打码后的值,会先从库里还原真实 Key,再发往这次新填的 base_url(`handler/llmconfig/test_connection.go:51-76`)。目前只能用到本人自己的 Key,但这种做法本身危险。
3. **调用账不全**:只有解析环节记了完整的调用账,审核只记了模型和耗时,生成不记。全系统没有提示词版本号;供应商一律记成 `"user"`(`repo/llm/llm.go:136`),审计时分不清是谁家的模型。
4. **用户级调用失败后不会换模型**:只有平台后台任务才有候选列表可以降级。
### 3.4 业务:现状
- **解析流水线实际分 6 个阶段**(`repo/bidanalysisv3/repository.go:40-47`):预处理 → 文档解析 → 摘要 → 章节识别 → 事实抽取(含固定字段、动态条款、证据、解读)→ 归并。标书蓝图不在这条流水线里,单独生成。
- **解析 → 生成**:把蓝图转成标书目录,再附一份字段快照(`bidgen/parse_handler.go:162`)。
- **解析 → 审核**:把解析结果冻结成一份 JSON 快照,再从快照重建审核清单(`bidreview/snapshot.go:388-412`)。没有关联解析项目时,审核侧会自己重新抽取一遍招标依据(`bidreview/tender_direct.go:23`)。
- **结论**:三个模块之间靠复制数据衔接,没有一个共享的「要求项」实体,审核结果也连不回生成的章节。
### 3.5 业务:值得参考
1. **审核项两侧都存证据**:每条审核结论同时存招标文件和投标文件两侧的证据(页码、引文、坐标)。
2. **人工确认留痕**:确认时记录确认人和确认时间,另有操作日志(`bidreview/handlers_items.go:95-118`)。
3. **审核清单项有 4 种来源**:通用预设、解析依据、企业规则库、暗标版式检查。
### 3.6 业务:需要避坑
1. **各模块各存一套数据**
- 原文块和证据在解析、审核两个模块里各存一套。
- 复制成审核清单时只保留了页码和引文,丢了坐标(`bidreview/checklist.go:141-149`)。
- 已经不用的旧审核表还留在库里。
2. **重跑会丢人工确认**
- 重建审核清单时会删掉「通用预设」和「解析依据」两类检查项(`checklist.go:75`),上面的人工确认跟着消失。
- 操作日志写失败时错误被直接忽略,也不和状态更新放在同一个事务里;确认时备注不是必填。
3. **「已处理」状态按列表顺序号存**(`bidanalysisv3/api.go:1137-1183`):风险的「已处理」状态按它在列表里的位置记录,摘要一重新生成,状态就对不上了。
4. **高风险条款截断不提示**:识别靠关键词加重要程度,超过 24 条直接截掉,界面不提示(`checklist.go:127-135`)。
5. **报价、签章、密封全靠模型读文本判断**:报价没有确定性的金额比对,签章没有任何视觉层面的检查。
6. **「竞争力预估得分」是机械折算**(`bidreview/excel.go:76`、`bidreview/scoring.go`):由「通过 / 警告」的数量直接折算成分数,很容易被当成确定结论。
→ 和我们红线 5「数据不足宁可不展示」相反。
7. **审核快照可能把旧版解析的条款也读进来(推测)**:读取条款时只按项目过滤,不按本次解析过滤(`bidreview/snapshot.go:231-237`,这一点代码里已核实)。如果重新解析时不清理旧条款,快照就会把多次解析的条款都读进来;这一点没有实际运行验证。
## 4. 对标博士的落地建议
以下建议留给「技术架构设计」阶段讨论,定下来的写入 `docs/decisions.md` 或 ADR。
1. **把「项目检查清单」做成真正的实体**
- 每条要求项有稳定编号;原文块、证据、AI 结论、人工确认都挂在要求项上。
- 解读、写标、查标三个阶段共用这一份,不复制快照。
- 重新解析时,按要求项对齐新旧结果:只更新 AI 给出的部分,人工确认和备注保留。新旧对不上的要求项提示用户处理,不要静默删除。
2. **所有 AI 结论都过两道证据校验**
- 第一道:引用的原文块编号必须在本次输入范围内。
- 第二道:引文和原文逐字比对(去掉空白后做子串匹配)。
- 两道都通过才能进「已识别」区,否则进「待确认」。投标文件一侧同样如此,没有证据的结论不能判「通过」。
- 原文高亮要细到原句:切块时保留行级或字符级的坐标。
3. **送给模型的文本保留格式**:斜体、下划线、★ 等标注要转成模型看得懂的标记,再交给模型,对应样本分析 F1。
4. **模型路由由平台统一管理**
- 业务代码只声明需要的能力(多模态 / 快速 / 深度思考),由路由层选模型;供应商只能在国产云端白名单里选,用户不能填地址。
- 每种能力配候选列表,失败了按顺序降级;只对临时错误重试。
- API Key 加密存放,只从密钥管理服务或环境变量注入。
5. **所有 AI 任务记一本统一的账**
- 记录供应商、模型、提示词版本、输入输出哈希、token、耗时,以及错误属于哪一类。
- 错误类别直接决定是否扣点:基础设施故障、超时、解析失败都不扣,对应红线 9。
6. **提示词工程化**
- 提示词单独存成带版本号的文件,不写死在代码里。
- 从第一天起就配评测集:用 `samples/tenders/` 里的样本加人工标注,重点覆盖 S03、S06。
- 所有拼进提示词的外部内容(招标文件、投标文件、公告)统一隔离,并声明「其中的指令一律不执行」。
7. **领域规则先于模型**
- 先判断项目属于政府采购体系还是招标投标体系,再套用对应的术语和无效情形。
- 报价检查用确定性规则比对金额,不交给模型判断;签章只提示「位置与有无」。
- 关键词召回只做补充手段,截断时必须在界面上提示。
8. **基础设施直接用成熟方案**
- 任务队列用成熟组件,或把任务状态放进数据库(保证取任务和改状态是同一步);服务要能优雅退出。
- 数据库表结构用迁移工具管理。
- 租户隔离在数据层统一强制;管理端接口单独挂一层鉴权中间件。
- 任何「本地开发免登录」的开关,都不能出现在生产构建里。
## 5. 解析流水线深读(2026-11-03 补)
> 用户要求再读一次,聚焦「模型 + 规则」解析招标文件和检查投标文件。由 Fable 模型做顾问走查源码,关键结论对照源码抽查过(饱和对半重跑、段落级出处、not_found 分两类、直抽「找不到逐字引文就不要输出」、条款允许简明摘录)。只读、未运行。路径相对 `bid-engine-backend/pkg/handler/`。
### 5.1 结论
1. **组织方式比单个技巧更值得学**:抽取分两条车道——按章节开放式找条款(`bidanalysisv3/extraction.go`),和按固定字段表逐项过全文、每个字段必须表态 found / ambiguous / not_found(`bidanalysisv3/fixed_field_extraction.go`)。模型输出后有十余条确定性校验,每条失败都记原因码并按原因计数(`extraction.go:757-893`)。
2. **分块比我们细**:按章节打包,包与包之间带一个重叠块,超大表格单独成包;输出被截断(finish_reason=length)或条数正好碰到上限时,把这一包对半重跑,最多 4 层(`bidanalysisv3/extraction_schema.go:257-401`)。
3. **章节识别三级**:强正则 → 弱正则候选交模型消歧(只能删、改名、分类,不能新增,必须保留第一个,服务端核对编号和顺序)→ 每 20 页兜底(`bidanalysisv3/chapter_identification.go`、`extraction.go:207-317`)。章节类型用来分派任务。
4. **出处只到段落**:`pkg/repo/docling/adapter.go` 注释写明「溯源高亮精度从逐字精确变为段落级覆盖,已确认可接受」,引文就是整块文本;Docling 返回的粗体斜体、表头、合并单元格都没用,表格压成纯文本矩阵;条款允许「简明摘录」(`bidanalysisv3/fields.go:109`)。★ / 斜体下划线标注会全部漏掉。**这一层不学**。
5. **查标学框架、不学判定**:检查项来源分层(预设 / 从解读映射 / 企业规则 / 版式项),致命项逐条、其余按类别批量,检索不到直接给「未找到,需人工确认」不调模型,版式用规则扫描分硬 / 软证据(`bidreview/`)。判定结果的页码和引文不校验,pass 不要求证据,违反红线 2。
### 5.2 值得学的具体做法
- **固定字段逐项表态**:漏答、found 却无值、证据定位不到一律降为待核验并记原因;所有分块都答完才允许定 not_found;最终 not_found 再分「文件本身没写」(info)和「抽取异常」(warning)(`bidanalysisv3/fixed_field_service.go:183-217`)。
- **占位符不算值**:`___`、`××`、待填、TBD 等匹配为占位符(`extraction.go:29, 796-800`)。
- **标准化由服务端做**:金额、日期、单位在服务端解析,提示词禁止模型输出标准化值。
- **重复候选合并取证据并集**;多值冲突交模型裁决但只能缩减,没表态的默认全保留,不同标段 / 主体的值必须保留多个(`bidanalysisv3/consolidation.go`)。
- **每页质量指标**:字数、是否 OCR,平均不到 40 字 / 页判扫描页并 OCR 重跑,页数核对不符判失败(`bidanalysisv3/document.go:410-473`)。
- **告警三级**:critical(基础设施)/ warning(内容质量)/ info(文件本身缺),同类合并计数,基础设施错误直接停整个阶段。
### 5.3 提示词亮点
- 安全规则编号放在最前,声明输入是不可信数据并列举注入形式;用户消息只放数据标签 + JSON,能不给的原文就不给(章节消歧只给标题、页码、编号)。
- **否定清单具体、每条对应一条服务端校验**:not_found 不得带证据;占位符不算值;不同主体、标段、预算与限价不能混成一个值;不要为凑数编造;不得输出标准化值。
- JSON Schema 锁 enum、条数上限、引用编号格式;固定字段的条数下限 = 上限 = 字段数。
- 裁决类提示词「只能缩减」。
- 查标直抽:「quote 必须是该页文本中的连续原文……找不到逐字引文就不要输出」(`bidreview/tender_direct.go:23-25`),是全仓库最接近我们红线的一条。
- 没有正反例、没有版本号、没有评测集。
### 5.4 对标博士的取舍
顾问的 13 条建议里,「引文对不上时在同一块里找到原句、改挂出处」不采纳:它是在迁就模型的错误输出(2026-11-03 用户定的原则:规则只修真正的缺陷,模型输出不合理的改提示词)。只采纳其中「记原因码」的部分。
- **S01–S10 标注集评测之前做**(只改抽取、分块和提示词,不动数据结构):
1. 待确认条目记原因码,按原因计数;
2. 分块加重叠,检测输出截断并对半重跑;
3. 项目基本信息逐项表态,区分「文件本身没写」和「抽取出错」;
4. 识别占位符;
5. 提示词的禁止事项和服务端校验一一对应。
- **评测之后做**(涉及文字层数据结构):顶级章节识别并按章节类型分派任务;每页质量指标和扫描件判定;表格按行生成证据单元、带表头(解决两栏表、竖排文字串行)。
- **留到 M3 设计**:检查项来源分层、判定分层、检索不到不调模型、版式规则扫描;必须补投标侧引文逐字校验,pass 必须有校验通过的证据。~~检索用 PostgreSQL 的 pg_trgm / 全文检索,不自建倒排。~~(2026-11-04 改:句子不进库(DM1),中文全文检索要第三方扩展,改用内存 BM25,见 §6.4 和 `m3-plan.md` §2.2。)
## 6. 查标深读(2026-11-04 补)
> 为 M3 设计再读一次,聚焦 `handler/bidreview/`(18 个文件,约 7100 行)。由 Fable 做顾问走查,关键结论对照源码抽查过(预设清单项、按清单项重复送页面文本、投标侧证据不校验)。只读、未运行。路径相对 `bid-engine-backend/pkg/handler/bidreview/`。采纳情况见 `m3-plan.md`。
### 6.1 规模不同,成本做法不能照搬
它每个项目只有 14 条预设项、最多 24 条否决条款、若干资格字段和评分行、最多 18 条材料清单(`checklist.go:42-59, 133-135, 292-300`),大约 90 条。「技术方案对招标需求全覆盖」只算一条预设项(`checklist.go:57`),技术参数不逐条查。我们的正式清单有 216~1348 条。
### 6.2 值得学的做法
| 做法 | 位置 | 我们怎么用 |
|---|---|---|
| 检查项来源分层,每条有稳定的键;重建清单时只删非人工来源的检查项 | `checklist.go:97-124`;`verdict.go:147-150` | 对应 `match_key`。它的评分项键带序号(`checklist.go:214`),重抽后排序一变键就变,我们用清单项编号 |
| 召回为空就不调模型,直接「需人工确认」;批量结果里漏答的条目默认给 warning,不给 pass | `verdict.go:296-306, 375-394, 498-509` | 每条必答,漏答记 `missing_answer` |
| 判定阶段部分失败不拖垮整体,失败的条目可以单独复检 | `verdict.go:237-245`;`handlers_items.go:122-201` | 失败条目记「未判定(模型错误)」,写进覆盖范围 |
| 所有清单项都召回不到候选时,整个阶段失败并提示检查投标文件 | `pipeline.go:387-389` | `prepare_bid` 之后的健全性检查,按 `input` 失败、不扣点 |
| 版式扫描分强 / 弱证据;没有文字层的页面占比过高时,出「需人工核对」并列出页码 | `format_scan.go:100-176, 235-278` | 「别的项目名称」以本项目名称、编号、采购人为准做强证据;没有文字层的页码写进覆盖范围 |
| 页眉页脚单独落块,版式检查专门扫它 | `parse.go:194-211`;`format_scan.go:178-233` | 我们文字层已把页眉页脚单独成句(SK10),查别的项目名称时重点扫 |
| 报告附一张证据索引:每条证据的文件、页码、引文单独成行 | `excel.go:162-179` | 报告加「证据索引」页 |
| 每个功能的输出上限写死在代码里;模型并发用一个信号量,流水线和人工复检共用 | `llm.go:22-29, 68-91` | `check.match` 固定输出上限;解析和检查共用统一限流 |
### 6.3 需要避坑
| 问题 | 位置 | 对照 |
|---|---|---|
| 报价、签章、截止日都是「把要求写成文字,让模型读投标文件判断」:限价没有确定性的金额比对,签章没有任何图像层面的检查 | `checklist.go:46-50, 102-106`;`verdict.go:254` | 红线 6、7 |
| 投标侧证据不校验:模型给的页码和引文原样入库,页码对不上就挂到第一个召回文件;pass 没有证据也照样 pass;批量判定也一样 | `verdict.go:322-327, 397-402, 428-434, 465-486` | 红线 2 |
| 问题级别由模型定,模型可以把高危项降成低危 | `verdict.go:321, 396, 498-522` | 级别从清单项算 |
| 模型给每条结论写「整改建议」 | `verdict.go:72, 96, 113` | 报价类问题会写出数字(红线 6);我们不写建议 |
| 检索:每块只索引前 240 个词,后半块静默丢失;候选页截到 1400 字;一批里同一页正文给每条清单项各送一遍 | `parse.go:436-438`;`retrieval.go:75`;`verdict.go:345-353` | 召回静默损失,成本翻倍 |
| 投标文件的表格不做结构解析,偏离表、报价表被压成纯文本 | `pipeline.go:273`;`parse.go:113-116` | 偏离表直取靠单元格结构 |
| 解析不出页时把全文每 3000 字切成「伪页」;转 PDF 失败就直接解析原文件 | `parse.go:214-231` | 页码失真;我们按 `input` 失败、不扣点 |
| 输出截断不检测:JSON 修补失败就整批丢掉 | `llm.go:151-157` | 沿用 `near_limit` 和对半重跑 |
| 否决条款最多映射 24 条,超出的静默截掉 | `checklist.go:127-135` | 没查的都写进覆盖范围 |
| 报告用「通过 / 高风险 / 竞争力预估得分」 | `excel.go:18-24, 76`;`scoring.go:77-82` | 红线 3、5 |
| 复检后清单项上的人工确认状态不重置,用户确认的是旧结论(推测) | `verdict.go:411-448`;`handlers_items.go:96-101` | 证据所在分册换了版本时提示 |
| 企业规则的适用条件存了但从不求值,每条规则都进清单 | `checklist.go:237-251` | V1 做规则库时条件要真求值 |
### 6.4 检索方案
保留架构 §6.2 的内存 BM25 + jieba,不用 PostgreSQL 检索:句子放对象存储、不进库(DM1);检索只在 worker 里、一次检查内用,几百页建索引用不了几秒;中文全文检索要装 zhparser 之类的第三方扩展,pg_trgm 没有 IDF;内存索引是纯函数,便于测试和离线评测。它把 2-gram / 3-gram 倒排放进 MySQL 的代价(只索引前 240 词、没有 IDF)就是反例。

View File

@@ -0,0 +1,197 @@
-- schema-draft.sql 的行为测试:在空库上先跑 schema-draft.sql,再跑本文件(psql -tAq)。
-- 每条输出要么带 ok,要么实际值和 expect 一致;出现 FAIL 或 ERROR 就是不通过。
\set ON_ERROR_STOP 1
SET search_path = app;
-- 角色:bd_app 受 RLS 约束;bd_auth 只拥有两个登录函数
CREATE ROLE bd_app NOLOGIN;
CREATE ROLE bd_auth NOLOGIN BYPASSRLS;
GRANT USAGE ON SCHEMA app TO bd_app, bd_auth;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA app TO bd_app;
GRANT SELECT ON user_session, membership, tenant TO bd_auth;
ALTER FUNCTION auth_resolve_session(bytea) OWNER TO bd_auth;
ALTER FUNCTION auth_list_memberships(uuid) OWNER TO bd_auth;
GRANT EXECUTE ON FUNCTION auth_resolve_session(bytea), auth_list_memberships(uuid) TO bd_app;
-- 种子数据(以超级用户写入,绕过 RLS)
INSERT INTO plan VALUES ('free', 1, '免费版', '{"parse":3}', '{}', true, now());
INSERT INTO tenant (id, name) VALUES
('00000000-0000-7000-8000-00000000000a', '甲公司'),
('00000000-0000-7000-8000-00000000000b', '乙公司');
INSERT INTO user_account (id, display_name) VALUES
('00000000-0000-7000-8000-0000000000a1', '张工'),
('00000000-0000-7000-8000-0000000000b1', '李工');
INSERT INTO membership (id, tenant_id, user_id) VALUES
('00000000-0000-7000-8000-0000000000a2', '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a1'),
('00000000-0000-7000-8000-0000000000b2', '00000000-0000-7000-8000-00000000000b', '00000000-0000-7000-8000-0000000000b1');
INSERT INTO user_session (id, tenant_id, membership_id, user_id, token_hash, client_kind, expires_at) VALUES
(gen_random_uuid(), '00000000-0000-7000-8000-00000000000b', '00000000-0000-7000-8000-0000000000b2',
'00000000-0000-7000-8000-0000000000b1', '\x01', 'web', now() + interval '1 day');
INSERT INTO project (id, tenant_id, name, created_by) VALUES
('00000000-0000-7000-8000-0000000000a3', '00000000-0000-7000-8000-00000000000a', '甲的项目', '00000000-0000-7000-8000-0000000000a1'),
('00000000-0000-7000-8000-0000000000b3', '00000000-0000-7000-8000-00000000000b', '乙的项目', '00000000-0000-7000-8000-0000000000b1');
-- 1. 租户隔离
BEGIN;
SET LOCAL ROLE bd_app;
SET LOCAL app.tenant_id = '00000000-0000-7000-8000-00000000000a';
SELECT 'T1 visible projects (expect 1): ' || count(*) FROM project;
SELECT 'T1 visible tenants (expect 1): ' || count(*) FROM tenant;
COMMIT;
BEGIN;
SET LOCAL ROLE bd_app;
SELECT 'T2 no tenant set (expect 0): ' || count(*) FROM project;
COMMIT;
-- 2. 不能往别的租户写数据
BEGIN;
SET LOCAL ROLE bd_app;
SET LOCAL app.tenant_id = '00000000-0000-7000-8000-00000000000a';
DO $$ BEGIN
INSERT INTO app.project (id, tenant_id, name, created_by) VALUES
(gen_random_uuid(), '00000000-0000-7000-8000-00000000000b', 'x', '00000000-0000-7000-8000-0000000000a1');
RAISE NOTICE 'T3 FAIL: cross-tenant insert allowed';
EXCEPTION WHEN insufficient_privilege THEN RAISE NOTICE 'T3 ok: cross-tenant insert blocked';
END $$;
COMMIT;
-- 3. 复合外键:甲租户的文档不能挂到乙的项目上(以超级用户写入,绕过 RLS 也拦得住)
DO $$ BEGIN
INSERT INTO app.source_document (id, tenant_id, project_id, role, seq_no, lineage_id, original_filename,
detected_format, size_bytes, original_key, original_sha256, uploaded_by)
VALUES (gen_random_uuid(), '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000b3',
'tender', 0, gen_random_uuid(), 'a.pdf', 'pdf', 1, 'k', '\x00', '00000000-0000-7000-8000-0000000000a1');
RAISE NOTICE 'T4 FAIL: cross-tenant reference allowed';
EXCEPTION WHEN foreign_key_violation THEN RAISE NOTICE 'T4 ok: cross-tenant FK blocked';
END $$;
-- 4. 登录前解析会话:bd_app 未设租户,也能通过函数拿到乙的会话
BEGIN;
SET LOCAL ROLE bd_app;
SELECT 'T5 session resolved (expect 1): ' || count(*) FROM auth_resolve_session('\x01');
SELECT 'T5 memberships of 李工 (expect 1): ' || count(*) FROM auth_list_memberships('00000000-0000-7000-8000-0000000000b1');
COMMIT;
-- 5. 额度:预扣 → 结算;流水不能改
BEGIN;
SET LOCAL ROLE bd_app;
SET LOCAL app.tenant_id = '00000000-0000-7000-8000-00000000000a';
INSERT INTO quota_account (id, tenant_id, unit) VALUES
('00000000-0000-7000-8000-0000000000a4', '00000000-0000-7000-8000-00000000000a', 'parse');
INSERT INTO quota_entry (id, tenant_id, account_id, kind, amount, idempotency_key, reason) VALUES
(gen_random_uuid(), '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a4', 'grant', 3, 'signup', '注册赠送');
INSERT INTO quota_entry (id, tenant_id, account_id, kind, amount, idempotency_key, ref_type, ref_id, project_id) VALUES
('00000000-0000-7000-8000-0000000000a5', '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a4',
'hold', -1, 'parse_job:1:hold', 'parse_job', gen_random_uuid(), '00000000-0000-7000-8000-0000000000a3');
INSERT INTO quota_entry (id, tenant_id, account_id, kind, amount, idempotency_key, hold_entry_id, project_id) VALUES
(gen_random_uuid(), '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a4',
'settle', 0, 'parse_job:1:settle', '00000000-0000-7000-8000-0000000000a5', '00000000-0000-7000-8000-0000000000a3');
SELECT 'T6 balance (expect 2): ' || sum(amount) FROM quota_entry;
COMMIT;
DO $$ BEGIN
INSERT INTO app.quota_entry (id, tenant_id, account_id, kind, amount, idempotency_key, hold_entry_id) VALUES
(gen_random_uuid(), '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a4',
'release', 1, 'parse_job:1:release', '00000000-0000-7000-8000-0000000000a5');
RAISE NOTICE 'T7 FAIL: hold has two outcomes';
EXCEPTION WHEN unique_violation THEN RAISE NOTICE 'T7 ok: second outcome for one hold blocked';
END $$;
DO $$ BEGIN
UPDATE app.quota_entry SET amount = 100 WHERE kind = 'grant';
RAISE NOTICE 'T8 FAIL: ledger updated';
EXCEPTION WHEN raise_exception THEN RAISE NOTICE 'T8 ok: ledger append-only';
END $$;
DO $$ BEGIN
INSERT INTO app.quota_entry (id, tenant_id, account_id, kind, amount, idempotency_key) VALUES
(gen_random_uuid(), '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a4', 'adjust', 5, 'ops:1');
RAISE NOTICE 'T9 FAIL: adjust without reason';
EXCEPTION WHEN check_violation THEN RAISE NOTICE 'T9 ok: adjust requires reason';
END $$;
-- 6. 文档、规范化 PDF 冻结、清单、检查、问题
INSERT INTO source_document (id, tenant_id, project_id, role, seq_no, lineage_id, original_filename, detected_format,
size_bytes, original_key, original_sha256, normalized_key, normalized_sha256, page_count, uploaded_by)
VALUES ('00000000-0000-7000-8000-0000000000a6', '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a3',
'tender', 0, '00000000-0000-7000-8000-0000000000a6', '招标文件.doc', 'docx', 100, 'orig', '\x01', 'norm', '\x02', 120,
'00000000-0000-7000-8000-0000000000a1');
DO $$ BEGIN
UPDATE app.source_document SET normalized_sha256 = '\x03' WHERE id = '00000000-0000-7000-8000-0000000000a6';
RAISE NOTICE 'T10 FAIL: normalized PDF replaced';
EXCEPTION WHEN raise_exception THEN RAISE NOTICE 'T10 ok: normalized PDF frozen';
END $$;
INSERT INTO citation (id, tenant_id, project_id, document_id, normalized_sha256, page_no, sentence_keys, quads, quote, created_by_kind)
VALUES ('00000000-0000-7000-8000-0000000000a7', '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a3',
'00000000-0000-7000-8000-0000000000a6', '\x02', 38, '{a6:p38:s4}', '[[0,0,1,1]]', '★ 投标人须具备……', 'ai');
INSERT INTO checklist (id, tenant_id, project_id) VALUES
('00000000-0000-7000-8000-0000000000a8', '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a3');
DO $$ BEGIN
INSERT INTO app.checklist_item (id, tenant_id, project_id, checklist_id, categories, title, importance, origin, zone)
VALUES (gen_random_uuid(), '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a3',
'00000000-0000-7000-8000-0000000000a8', '{tech_param}', 'x', 'substantive', 'ai', 'formal');
RAISE NOTICE 'T11 FAIL: star item without invalidity tag';
EXCEPTION WHEN check_violation THEN RAISE NOTICE 'T11 ok: star item must carry invalidity tag';
END $$;
INSERT INTO checklist_item (id, tenant_id, project_id, checklist_id, categories, title, importance, origin, zone)
VALUES ('00000000-0000-7000-8000-0000000000a9', '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a3',
'00000000-0000-7000-8000-0000000000a8', '{tech_param,invalidity}', '★ 内存 ≥ 32GB', 'substantive', 'ai', 'formal');
INSERT INTO checklist_item_citation VALUES
('00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a9', '00000000-0000-7000-8000-0000000000a7', 0);
INSERT INTO checklist_version (id, tenant_id, checklist_id, version_no, kind, content_hash, item_count, created_by)
VALUES ('00000000-0000-7000-8000-0000000000aa', '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a8',
1, 'check', '\x00', 1, '00000000-0000-7000-8000-0000000000a1');
INSERT INTO check_run (id, tenant_id, project_id, run_no, checklist_version_id, checklist_confirmed, rule_set_version,
legal_regime, billing, quota_hold_id, created_by)
VALUES ('00000000-0000-7000-8000-0000000000ab', '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a3',
1, '00000000-0000-7000-8000-0000000000aa', false, 'it-v0', 'gov_procurement', 'charged',
'00000000-0000-7000-8000-0000000000a5', '00000000-0000-7000-8000-0000000000a1');
DO $$ BEGIN
INSERT INTO app.finding (id, tenant_id, project_id, check_run_id, checklist_item_id, match_key, type, severity, title,
explanation, detector, presence, status)
VALUES (gen_random_uuid(), '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a3',
'00000000-0000-7000-8000-0000000000ab', '00000000-0000-7000-8000-0000000000a9', 'item:a9', 'suspected_unmet',
'fatal', 't', 'e', 'ai', 'new', 'confirmed_ok');
RAISE NOTICE 'T12 FAIL: fatal confirm without note';
EXCEPTION WHEN check_violation THEN RAISE NOTICE 'T12 ok: fatal confirm requires note (U4)';
END $$;
INSERT INTO finding (id, tenant_id, project_id, check_run_id, checklist_item_id, match_key, type, severity, title,
explanation, detector, presence)
VALUES (gen_random_uuid(), '00000000-0000-7000-8000-00000000000a', '00000000-0000-7000-8000-0000000000a3',
'00000000-0000-7000-8000-0000000000ab', '00000000-0000-7000-8000-0000000000a9', 'item:a9', 'suspected_unmet',
'fatal', '内存不满足', '要求 ≥ 32GB,响应 16GB', 'ai', 'new');
UPDATE project SET latest_check_run_id = '00000000-0000-7000-8000-0000000000ab', status = 'checked', open_fatal_count = 1
WHERE id = '00000000-0000-7000-8000-0000000000a3';
INSERT INTO audit_event (id, tenant_id, actor_kind, actor_user_id, actor_name, action, object_type, object_id, project_id, before, after)
VALUES ('00000000-0000-7000-8000-0000000000ac', '00000000-0000-7000-8000-00000000000a', 'user',
'00000000-0000-7000-8000-0000000000a1', '张工', 'checklist_item.update', 'checklist_item',
'00000000-0000-7000-8000-0000000000a9', '00000000-0000-7000-8000-0000000000a3', '{"title":"旧"}', '{"title":"新"}');
-- 7. 审计只能抹内容,不能改别的
DO $$ BEGIN
UPDATE app.audit_event SET action = 'x' WHERE id = '00000000-0000-7000-8000-0000000000ac';
RAISE NOTICE 'T13 FAIL: audit action changed';
EXCEPTION WHEN raise_exception THEN RAISE NOTICE 'T13 ok: audit fields immutable';
END $$;
-- 8. 删除项目(G10):抹审计内容 → 删项目;额度流水和审计记录保留
BEGIN;
UPDATE audit_event SET before = NULL, after = NULL, content_scrubbed_at = now()
WHERE project_id = '00000000-0000-7000-8000-0000000000a3';
DELETE FROM project WHERE id = '00000000-0000-7000-8000-0000000000a3';
COMMIT;
SELECT 'T14 after delete — docs/items/findings/runs (expect 0/0/0/0): '
|| (SELECT count(*) FROM source_document WHERE tenant_id = '00000000-0000-7000-8000-00000000000a') || '/'
|| (SELECT count(*) FROM checklist_item WHERE tenant_id = '00000000-0000-7000-8000-00000000000a') || '/'
|| (SELECT count(*) FROM finding WHERE tenant_id = '00000000-0000-7000-8000-00000000000a') || '/'
|| (SELECT count(*) FROM check_run WHERE tenant_id = '00000000-0000-7000-8000-00000000000a');
SELECT 'T14 kept — quota entries (expect 3), audit rows scrubbed (expect 1): '
|| (SELECT count(*) FROM quota_entry WHERE tenant_id = '00000000-0000-7000-8000-00000000000a') || ', '
|| (SELECT count(*) FROM audit_event WHERE before IS NULL AND content_scrubbed_at IS NOT NULL);

View File

@@ -0,0 +1,960 @@
-- 标博士 · 物理数据模型草案 v0.1(PostgreSQL 16+)
-- 说明见 docs/architecture/data-model.md。这是设计草案,不是迁移脚本:
-- 工程骨架搭好后按模块拆成 Alembic 迁移,本文件不再维护。
--
-- 约定(data-model.md §1):
-- 主键 UUIDv7,由应用生成;时间一律 timestamptz;状态字段用 text + CHECK。
-- 业务表都带 tenant_id,并用 (tenant_id, id) 复合外键保证不跨租户引用。
-- 项目内部的外键一律 CASCADE / SET NULL:只在删除整个项目时真正触发(清单项只做软删除)。
-- 全局表只有 user_account、login_identity、plan、ops_admin、ops_session。
-- ============================================================
-- 0. 公共
-- ============================================================
CREATE SCHEMA IF NOT EXISTS app;
SET search_path = app;
-- 当前事务的租户;api / worker 在事务开始时 SET LOCAL app.tenant_id
CREATE FUNCTION current_tenant() RETURNS uuid
LANGUAGE sql STABLE AS $$
SELECT nullif(current_setting('app.tenant_id', true), '')::uuid
$$;
CREATE FUNCTION set_updated_at() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END $$;
-- 只追加表:禁止 UPDATE / DELETE
CREATE FUNCTION forbid_change() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION '% is append-only', TG_TABLE_NAME;
END $$;
-- ============================================================
-- 1. identity:人、登录方式、企业主体、成员身份、会话(架构 §3.14)
-- ============================================================
CREATE TABLE tenant (
id uuid PRIMARY KEY,
name text NOT NULL,
uscc text, -- 统一社会信用代码,MVP 可空(G9)
status text NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'suspended', 'closed')),
closed_at timestamptz, -- 注销:业务数据删除,本行保留给订单和发票
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- 全局表:不带 tenant_id,不受 RLS 约束,只有 identity 模块能读写
CREATE TABLE user_account (
id uuid PRIMARY KEY,
display_name text NOT NULL,
status text NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'disabled', 'anonymized')),
anonymized_at timestamptz, -- 注销账号:抹掉个人信息,行保留(data-model §6.2)
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE login_identity (
id uuid PRIMARY KEY,
user_id uuid NOT NULL REFERENCES user_account (id),
provider text NOT NULL
CHECK (provider IN ('phone', 'password', 'feishu', 'wecom', 'oidc', 'ldap')),
external_org_id text NOT NULL DEFAULT '', -- 手机号、账号密码:空串
external_user_id text NOT NULL, -- 手机号:HMAC(pepper, E.164 号码),不存明文(DM5)
contact_cipher bytea, -- 手机号密文,发短信和运营查询用
contact_hint text, -- 138****5678
secret_hash text, -- 只有 password 来源用(argon2id)
verified_at timestamptz,
last_login_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (provider, external_org_id, external_user_id)
);
CREATE INDEX ON login_identity (user_id);
CREATE TABLE membership (
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL REFERENCES tenant (id),
user_id uuid NOT NULL REFERENCES user_account (id),
role text NOT NULL DEFAULT 'owner'
CHECK (role IN ('owner', 'admin', 'member')), -- MVP 只有 owner
status text NOT NULL DEFAULT 'active'
CHECK (status IN ('invited', 'active', 'disabled', 'left')),
joined_at timestamptz NOT NULL DEFAULT now(),
left_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, id),
UNIQUE (tenant_id, user_id)
);
CREATE INDEX ON membership (user_id);
CREATE TABLE user_session (
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL,
membership_id uuid NOT NULL,
user_id uuid NOT NULL REFERENCES user_account (id),
token_hash bytea NOT NULL UNIQUE, -- SHA-256(令牌),令牌本身不落库(AR8)
client_kind text NOT NULL CHECK (client_kind IN ('web', 'desktop')),
user_agent text,
ip inet,
created_at timestamptz NOT NULL DEFAULT now(),
last_seen_at timestamptz NOT NULL DEFAULT now(), -- 最多每 5 分钟写一次
expires_at timestamptz NOT NULL,
revoked_at timestamptz,
revoke_reason text CHECK (revoke_reason IN ('logout', 'concurrent_login', 'admin', 'tenant_closed', 'expired')),
FOREIGN KEY (tenant_id, membership_id) REFERENCES membership (tenant_id, id)
);
CREATE INDEX ON user_session (user_id) WHERE revoked_at IS NULL;
-- ============================================================
-- 2. billing:套餐、订阅、开通申请、额度(架构 §3.9,FR-M6-03、05)
-- ============================================================
-- 全局表:套餐定义带版本,D2 定价之后改数字就是加一个版本
CREATE TABLE plan (
code text NOT NULL CHECK (code IN ('free', 'pro', 'enterprise')),
version int NOT NULL,
name text NOT NULL,
quotas jsonb NOT NULL, -- {"parse": 500, "generate": 300};检查怎么扣见 data-model §4.3
limits jsonb NOT NULL, -- 单文件大小、页数、项目数上限
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (code, version)
);
CREATE TABLE activation_request ( -- 专业版开通申请(G8);注销后按财务要求保留
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL REFERENCES tenant (id),
applicant_user_id uuid NOT NULL REFERENCES user_account (id),
plan_code text NOT NULL,
plan_version int NOT NULL,
invoice_info jsonb NOT NULL, -- 抬头、税号、地址电话、开户行账号
status text NOT NULL DEFAULT 'submitted'
CHECK (status IN ('submitted', 'paid', 'activated', 'rejected', 'cancelled')),
ops_note text,
handled_by_ops uuid,
handled_at timestamptz,
invoice_no text,
invoiced_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, id),
FOREIGN KEY (plan_code, plan_version) REFERENCES plan (code, version)
);
CREATE TABLE subscription (
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL REFERENCES tenant (id),
plan_code text NOT NULL,
plan_version int NOT NULL,
status text NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'expired', 'cancelled')),
source text NOT NULL CHECK (source IN ('signup', 'manual', 'online')),
seats int NOT NULL DEFAULT 1 CHECK (seats >= 1), -- MVP 固定 1(AR11)
starts_at timestamptz NOT NULL,
ends_at timestamptz, -- 免费版为空
activation_request_id uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, id),
FOREIGN KEY (plan_code, plan_version) REFERENCES plan (code, version),
FOREIGN KEY (tenant_id, activation_request_id) REFERENCES activation_request (tenant_id, id)
);
-- 同一时间只有一个生效的订阅
CREATE UNIQUE INDEX ON subscription (tenant_id) WHERE status = 'active';
CREATE TABLE quota_account (
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL REFERENCES tenant (id),
unit text NOT NULL CHECK (unit IN ('parse', 'check', 'generate', 'dedupe')),
balance int NOT NULL DEFAULT 0, -- 缓存,和流水在同一个事务里更新,定期对账
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, id),
UNIQUE (tenant_id, unit)
);
-- 只追加。amount 是对余额的影响:发放 +N、预扣 −1、退回 +1、结算 0、免扣 0、过期 −余量、调整 ±N
CREATE TABLE quota_entry (
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL,
account_id uuid NOT NULL,
subscription_id uuid, -- 发放和过期挂在订阅周期上;「本期已用」按它汇总
kind text NOT NULL
CHECK (kind IN ('grant', 'hold', 'settle', 'release', 'expire', 'adjust', 'waive')),
amount int NOT NULL,
idempotency_key text NOT NULL, -- 如 parse_job:<id>:hold
hold_entry_id uuid, -- 结算、退回指回对应的预扣
ref_type text CHECK (ref_type IN ('parse_job', 'check_run')),
ref_id uuid, -- 不加外键:删除项目后流水保留(G10)
project_id uuid, -- 同上
reason text, -- 调整必填;免扣写规则,如 recheck_24h
actor_user_id uuid REFERENCES user_account (id),
actor_ops_id uuid,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, id),
UNIQUE (tenant_id, idempotency_key),
FOREIGN KEY (tenant_id, account_id) REFERENCES quota_account (tenant_id, id),
FOREIGN KEY (tenant_id, subscription_id) REFERENCES subscription (tenant_id, id),
FOREIGN KEY (tenant_id, hold_entry_id) REFERENCES quota_entry (tenant_id, id),
CHECK (kind <> 'adjust' OR reason IS NOT NULL),
CHECK (kind NOT IN ('settle', 'waive') OR amount = 0),
CHECK (kind <> 'hold' OR amount < 0),
CHECK (kind NOT IN ('settle', 'release') OR hold_entry_id IS NOT NULL)
);
CREATE INDEX ON quota_entry (tenant_id, account_id, created_at DESC);
CREATE INDEX ON quota_entry (tenant_id, ref_type, ref_id);
-- 一笔预扣只能有一个结局
CREATE UNIQUE INDEX ON quota_entry (tenant_id, hold_entry_id) WHERE kind IN ('settle', 'release');
CREATE TRIGGER quota_entry_append_only BEFORE UPDATE OR DELETE ON quota_entry
FOR EACH ROW EXECUTE FUNCTION forbid_change();
CREATE TABLE quota_alert ( -- 80% / 100% 每个周期只提醒一次
tenant_id uuid NOT NULL,
account_id uuid NOT NULL,
subscription_id uuid NOT NULL,
threshold int NOT NULL CHECK (threshold IN (80, 100)),
sent_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, account_id, subscription_id, threshold),
FOREIGN KEY (tenant_id, account_id) REFERENCES quota_account (tenant_id, id),
FOREIGN KEY (tenant_id, subscription_id) REFERENCES subscription (tenant_id, id)
);
-- ============================================================
-- 3. project:项目、基本信息字段、分包、项目成员(FR-M2-03、04,FR-M6-01、02)
-- ============================================================
CREATE TABLE project (
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL REFERENCES tenant (id),
-- 下面几列是 project_field 的当前值,冗余在这里给工作台列表排序和筛选用
name text NOT NULL,
code text,
purchaser text,
agency text,
procurement_method text,
legal_regime text NOT NULL DEFAULT 'unknown'
CHECK (legal_regime IN ('gov_procurement', 'tendering', 'unknown')),
deadline_at timestamptz,
status text NOT NULL DEFAULT 'tracking'
CHECK (status IN ('tracking', 'drafting', 'checked', 'submitted', 'archived')),
status_before_archive text CHECK (status_before_archive IN ('checked', 'submitted')),
-- 就绪标记:只在 checked 状态显示(G7);由 check 模块在处理问题的同一个事务里更新
latest_check_run_id uuid,
open_fatal_count int NOT NULL DEFAULT 0,
last_charged_check_at timestamptz, -- 24 小时免扣的起点(P1)
created_by uuid NOT NULL REFERENCES user_account (id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, id)
);
CREATE INDEX ON project (tenant_id, status, deadline_at);
CREATE TABLE project_member ( -- AR12:MVP 只有建项目的人,角色 lead
tenant_id uuid NOT NULL,
project_id uuid NOT NULL,
user_id uuid NOT NULL REFERENCES user_account (id),
role text NOT NULL CHECK (role IN ('lead', 'editor', 'viewer')),
added_by uuid NOT NULL REFERENCES user_account (id),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, project_id, user_id),
FOREIGN KEY (tenant_id, project_id) REFERENCES project (tenant_id, id) ON DELETE CASCADE
);
CREATE INDEX ON project_member (tenant_id, user_id);
CREATE TABLE lot ( -- 包 / 标段;没分包时只有一个默认包
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL,
project_id uuid NOT NULL,
lot_no text NOT NULL, -- 原文里的写法:「1」「包2」「第三标段」
name text,
budget_amount numeric(18, 2),
ceiling_amount numeric(18, 2), -- 采购包限价;三层限价的另外两层见 data-model §5.3
is_default boolean NOT NULL DEFAULT false,
is_bidding boolean NOT NULL DEFAULT true, -- 用户选择投哪些包
origin text NOT NULL CHECK (origin IN ('ai', 'human')),
ai_values jsonb, -- AI 原值,人工修改不覆盖
edited_fields text[] NOT NULL DEFAULT '{}',
sort_order int NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, id),
UNIQUE (tenant_id, project_id, lot_no),
FOREIGN KEY (tenant_id, project_id) REFERENCES project (tenant_id, id) ON DELETE CASCADE
);
-- ============================================================
-- 4. document:文档、页、出处、解析任务(FR-M2-01、02、06,FR-M3-01)
-- ============================================================
CREATE TABLE source_document (
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL,
project_id uuid NOT NULL,
role text NOT NULL
CHECK (role IN ('tender', 'addendum', 'clarification', 'bid_volume')),
seq_no int NOT NULL, -- 招标侧:补遗 N 的 N;投标侧:分册内的版本号
volume_name text, -- 投标分册:商务 / 技术 / 报价 / 资格证明
lineage_id uuid NOT NULL, -- 同一分册的各个版本共用;招标侧等于 id
original_filename text NOT NULL,
detected_format text NOT NULL
CHECK (detected_format IN ('pdf', 'docx', 'doc', 'wps', 'ofd', 'encrypted_bid', 'unknown')),
size_bytes bigint NOT NULL,
original_key text NOT NULL, -- 对象存储键
original_sha256 bytea NOT NULL,
-- 规范化 PDF:只生成一次,写入后不再改(由服务层和触发器保证)
normalized_key text,
normalized_sha256 bytea,
page_count int,
text_layer_key text, -- 逐字坐标、格式、句子,存对象存储(DM1)
pipeline_profile jsonb, -- 转换器、字体包、切句算法的版本,复盘用
status text NOT NULL DEFAULT 'uploaded'
CHECK (status IN ('uploaded', 'normalizing', 'ready', 'failed')),
uploaded_by uuid NOT NULL REFERENCES user_account (id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, id),
UNIQUE (tenant_id, project_id, role, lineage_id, seq_no),
FOREIGN KEY (tenant_id, project_id) REFERENCES project (tenant_id, id) ON DELETE CASCADE
);
CREATE FUNCTION freeze_normalized_pdf() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF OLD.normalized_sha256 IS NOT NULL AND
(NEW.normalized_sha256 IS DISTINCT FROM OLD.normalized_sha256 OR
NEW.normalized_key IS DISTINCT FROM OLD.normalized_key) THEN
RAISE EXCEPTION 'normalized PDF of document % is immutable', OLD.id;
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER source_document_freeze BEFORE UPDATE ON source_document
FOR EACH ROW EXECUTE FUNCTION freeze_normalized_pdf();
CREATE TABLE document_page ( -- 逐页摘要:覆盖率检查、OCR 决策、阅读器尺寸
tenant_id uuid NOT NULL,
document_id uuid NOT NULL,
page_no int NOT NULL,
width_pt real NOT NULL,
height_pt real NOT NULL,
rotation int NOT NULL DEFAULT 0,
text_source text NOT NULL CHECK (text_source IN ('text', 'ocr', 'mixed', 'empty')),
char_count int NOT NULL,
sentence_count int NOT NULL,
PRIMARY KEY (tenant_id, document_id, page_no),
FOREIGN KEY (tenant_id, document_id) REFERENCES source_document (tenant_id, id) ON DELETE CASCADE
);
CREATE TABLE parse_job ( -- 招标文件 / 补遗 / 澄清的解析;重试是新的一行
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL,
project_id uuid NOT NULL,
document_id uuid NOT NULL,
attempt_no int NOT NULL,
status text NOT NULL DEFAULT 'queued'
CHECK (status IN ('queued', 'running', 'succeeded', 'failed', 'cancelled')),
stage text CHECK (stage IN ('detect', 'convert', 'text_layer', 'ocr', 'structure',
'extract', 'verify', 'merge')),
checkpoints jsonb NOT NULL DEFAULT '{}', -- 每个阶段完成时写入,失败后从这里续跑
progress int NOT NULL DEFAULT 0 CHECK (progress BETWEEN 0 AND 100),
cancel_requested_at timestamptz,
error_class text CHECK (error_class IN ('infra', 'input', 'content', 'cancelled')),
error_code text,
error_detail text, -- 不含文档内容
prompt_versions jsonb, -- {"extract.qualification": "v3", ...};逐次调用见 ai_call
quota_hold_id uuid,
queue_job_id bigint, -- Procrastinate 的任务编号
created_by uuid NOT NULL REFERENCES user_account (id),
created_at timestamptz NOT NULL DEFAULT now(),
started_at timestamptz,
finished_at timestamptz,
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, id),
UNIQUE (tenant_id, document_id, attempt_no),
FOREIGN KEY (tenant_id, project_id) REFERENCES project (tenant_id, id) ON DELETE CASCADE,
FOREIGN KEY (tenant_id, document_id) REFERENCES source_document (tenant_id, id) ON DELETE CASCADE,
FOREIGN KEY (tenant_id, quota_hold_id) REFERENCES quota_entry (tenant_id, id)
);
-- 一份文档同时只能有一个在跑的解析
CREATE UNIQUE INDEX ON parse_job (tenant_id, document_id) WHERE status IN ('queued', 'running');
-- 出处:不可变。清单项、问题、项目字段、标注规则都引用它
CREATE TABLE citation (
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL,
project_id uuid NOT NULL,
document_id uuid NOT NULL,
normalized_sha256 bytea NOT NULL, -- 和文档上的哈希一致才能渲染
page_no int NOT NULL,
sentence_keys text[] NOT NULL, -- 同一页上连续的句子,如 {<doc>:p12:s3, <doc>:p12:s4}
char_start int, -- 只引用句子的一部分时用,相对于首句
char_end int,
quads jsonb NOT NULL, -- 坐标框快照,阅读器直接画
quote text NOT NULL, -- 原句快照
created_by_kind text NOT NULL CHECK (created_by_kind IN ('ai', 'rule', 'human')),
checks_passed text[] NOT NULL DEFAULT '{}', -- {id, quote, basis}:三道校验(架构 §3.6)
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, id),
CHECK (cardinality(sentence_keys) >= 1),
FOREIGN KEY (tenant_id, project_id) REFERENCES project (tenant_id, id) ON DELETE CASCADE,
FOREIGN KEY (tenant_id, document_id) REFERENCES source_document (tenant_id, id) ON DELETE CASCADE
);
CREATE INDEX ON citation (tenant_id, document_id, page_no);
CREATE TRIGGER citation_immutable BEFORE UPDATE ON citation
FOR EACH ROW EXECUTE FUNCTION forbid_change();
-- 项目基本信息字段(FR-M2-03):AI 值和人工值分开,每个字段带出处
CREATE TABLE project_field (
tenant_id uuid NOT NULL,
project_id uuid NOT NULL,
field_key text NOT NULL
CHECK (field_key IN ('name', 'code', 'purchaser', 'agency', 'procurement_method',
'legal_regime', 'budget_total', 'ceiling_total', 'eval_method',
'accept_consortium', 'accept_imported', 'sme_only', 'deadline_at')),
ai_value jsonb,
ai_citation_id uuid,
ai_document_id uuid, -- 最新一次提取来自哪份文件(补遗覆盖时变)
human_value jsonb,
human_set_by uuid REFERENCES user_account (id),
human_set_at timestamptz,
pending_value jsonb, -- 补遗带来的新值,等用户确认(FR-M2-10)
pending_citation_id uuid,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, project_id, field_key),
FOREIGN KEY (tenant_id, project_id) REFERENCES project (tenant_id, id) ON DELETE CASCADE,
FOREIGN KEY (tenant_id, ai_citation_id) REFERENCES citation (tenant_id, id) ON DELETE SET NULL (ai_citation_id),
FOREIGN KEY (tenant_id, pending_citation_id) REFERENCES citation (tenant_id, id) ON DELETE SET NULL (pending_citation_id),
FOREIGN KEY (tenant_id, ai_document_id) REFERENCES source_document (tenant_id, id) ON DELETE SET NULL (ai_document_id)
);
-- ============================================================
-- 5. checklist:清单、清单项、版本快照、标注规则(FR-M2-05~10)
-- ============================================================
CREATE TABLE checklist ( -- 每个项目一份
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL,
project_id uuid NOT NULL,
status text NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'confirmed')),
last_version_no int NOT NULL DEFAULT 0,
confirmed_version_id uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, id),
UNIQUE (tenant_id, project_id),
FOREIGN KEY (tenant_id, project_id) REFERENCES project (tenant_id, id) ON DELETE CASCADE
);
-- 文件自己的「标注规则说明」(07 F1):先认规则,再按规则认实质性条款
CREATE TABLE marking_rule (
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL,
project_id uuid NOT NULL,
document_id uuid NOT NULL,
marker text NOT NULL
CHECK (marker IN ('star', 'asterisk', 'triangle', 'italic_underline', 'bold', 'underline', 'keyword')),
marker_text text, -- keyword 时是关键词,如「必须」
meaning text NOT NULL CHECK (meaning IN ('substantive', 'important')),
declared_count int, -- 「★ 项共 14 条」;和实际抽到的数量对比
citation_id uuid NOT NULL,
origin text NOT NULL CHECK (origin IN ('ai', 'rule', 'human')),
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, id),
FOREIGN KEY (tenant_id, project_id) REFERENCES project (tenant_id, id) ON DELETE CASCADE,
FOREIGN KEY (tenant_id, document_id) REFERENCES source_document (tenant_id, id) ON DELETE CASCADE,
FOREIGN KEY (tenant_id, citation_id) REFERENCES citation (tenant_id, id) ON DELETE CASCADE
);
CREATE TABLE checklist_item (
id uuid PRIMARY KEY, -- 稳定编号:重新解析时对齐,不换号(架构 §3.8)
tenant_id uuid NOT NULL,
project_id uuid NOT NULL,
checklist_id uuid NOT NULL,
-- 当前值(人工改过的字段以人工为准)
categories text[] NOT NULL, -- 可以多个,见下方 CHECK
title text NOT NULL,
requirement_text text, -- 要求原文,不改写;人工添加时可空
summary text, -- AI 摘要,界面标注 AI 生成
importance text NOT NULL DEFAULT 'normal'
CHECK (importance IN ('substantive', 'important', 'normal')), -- ★ / ▲ / 一般
importance_basis text CHECK (importance_basis IN ('marker', 'statute', 'tender_text', 'human')),
marking_rule_id uuid, -- 按哪条标注规则认定为 ★
lot_id uuid, -- 空 = 各包通用
evidence_required text, -- 证明材料要求
spec jsonb NOT NULL DEFAULT '{}', -- 类别特有字段(05 §3.3),按类别用 Pydantic 校验
-- 来源与区域
origin text NOT NULL CHECK (origin IN ('ai', 'rule', 'human')),
origin_document_id uuid, -- 来自哪份文件;补遗 N 由文档的 seq_no 得出
zone text NOT NULL CHECK (zone IN ('pending', 'formal')), -- 待确认区 / 正式清单
pending_reason text CHECK (pending_reason IN ('no_citation', 'low_confidence', 'verify_failed',
'marker_uncovered', 'absent_in_rerun')),
no_citation_ack_by uuid REFERENCES user_account (id), -- 「人工确认、无出处」
accepted_by uuid REFERENCES user_account (id), -- 从待确认区采纳
accepted_at timestamptz,
superseded_by_id uuid, -- 被补遗的新条目更新(日期类,FR-M2-10)
supersede_confirmed_at timestamptz,
-- AI 原值与对齐
ai_values jsonb, -- 最近一次 AI 给出的字段原值,人工修改不覆盖
edited_fields text[] NOT NULL DEFAULT '{}', -- 人工改过的字段:重跑对齐时不动它们
confidence numeric(4, 3),
last_parse_job_id uuid,
anchor_sentence_key text, -- 首个出处的句子编号:排序和对齐用
sort_key text,
-- 软删除(FR-M2-08)
deleted_at timestamptz,
deleted_by uuid REFERENCES user_account (id),
created_by uuid REFERENCES user_account (id), -- 人工添加时
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, id),
CHECK (cardinality(categories) >= 1 AND
categories <@ ARRAY['qualification', 'invalidity', 'scoring', 'schedule',
'tech_param', 'format_doc', 'price']::text[]),
-- ★ 条款必须同时在「无效 / 废标」类(FR-M2-05)
CHECK (importance <> 'substantive' OR 'invalidity' = ANY (categories)),
CHECK (zone <> 'pending' OR pending_reason IS NOT NULL),
FOREIGN KEY (tenant_id, checklist_id) REFERENCES checklist (tenant_id, id) ON DELETE CASCADE,
FOREIGN KEY (tenant_id, project_id) REFERENCES project (tenant_id, id) ON DELETE CASCADE,
FOREIGN KEY (tenant_id, lot_id) REFERENCES lot (tenant_id, id) ON DELETE SET NULL (lot_id),
FOREIGN KEY (tenant_id, origin_document_id) REFERENCES source_document (tenant_id, id) ON DELETE SET NULL (origin_document_id),
FOREIGN KEY (tenant_id, marking_rule_id) REFERENCES marking_rule (tenant_id, id) ON DELETE SET NULL (marking_rule_id),
FOREIGN KEY (tenant_id, superseded_by_id) REFERENCES checklist_item (tenant_id, id) ON DELETE SET NULL (superseded_by_id),
FOREIGN KEY (tenant_id, last_parse_job_id) REFERENCES parse_job (tenant_id, id) ON DELETE SET NULL (last_parse_job_id)
);
CREATE INDEX ON checklist_item (tenant_id, checklist_id, zone) WHERE deleted_at IS NULL;
CREATE INDEX ON checklist_item USING gin (categories);
CREATE TABLE checklist_item_citation (
tenant_id uuid NOT NULL,
item_id uuid NOT NULL,
citation_id uuid NOT NULL,
position int NOT NULL DEFAULT 0,
PRIMARY KEY (tenant_id, item_id, citation_id),
FOREIGN KEY (tenant_id, item_id) REFERENCES checklist_item (tenant_id, id) ON DELETE CASCADE,
FOREIGN KEY (tenant_id, citation_id) REFERENCES citation (tenant_id, id) ON DELETE CASCADE
);
-- 不可变快照:确认清单时生成;清单没确认就检查时,检查前也生成一份(kind = check)
CREATE TABLE checklist_version (
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL,
checklist_id uuid NOT NULL,
version_no int NOT NULL,
kind text NOT NULL CHECK (kind IN ('confirmed', 'check')),
content_hash bytea NOT NULL, -- 内容没变就复用上一份,不重复生成
item_count int NOT NULL,
created_by uuid NOT NULL REFERENCES user_account (id),
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, id),
UNIQUE (tenant_id, checklist_id, version_no),
FOREIGN KEY (tenant_id, checklist_id) REFERENCES checklist (tenant_id, id) ON DELETE CASCADE
);
ALTER TABLE checklist
ADD FOREIGN KEY (tenant_id, confirmed_version_id) REFERENCES checklist_version (tenant_id, id)
ON DELETE SET NULL (confirmed_version_id);
CREATE TABLE checklist_version_item ( -- 只收正式清单里、未删除的条目
tenant_id uuid NOT NULL,
version_id uuid NOT NULL,
item_id uuid NOT NULL, -- 指向稳定编号;条目以后被删也不影响快照
payload jsonb NOT NULL, -- 当时的当前值 + 出处编号列表
PRIMARY KEY (tenant_id, version_id, item_id),
FOREIGN KEY (tenant_id, version_id) REFERENCES checklist_version (tenant_id, id) ON DELETE CASCADE
);
-- ============================================================
-- 6. check:检查批次、问题、人工核对(FR-M3-02~08)
-- ============================================================
CREATE TABLE check_run (
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL,
project_id uuid NOT NULL,
run_no int NOT NULL, -- 项目内第几次检查
checklist_version_id uuid NOT NULL,
checklist_confirmed boolean NOT NULL, -- false 时报告和界面提示「清单未确认」(G7)
rule_set_version text NOT NULL,
legal_regime text NOT NULL CHECK (legal_regime IN ('gov_procurement', 'tendering', 'unknown')),
lot_ids uuid[] NOT NULL DEFAULT '{}', -- 本次检查的包
previous_run_id uuid, -- 状态沿用的来源
status text NOT NULL DEFAULT 'queued'
CHECK (status IN ('queued', 'running', 'succeeded', 'failed', 'cancelled')),
stage text CHECK (stage IN ('prepare_bid', 'match', 'rules', 'verify', 'carry_over', 'summarize')),
checkpoints jsonb NOT NULL DEFAULT '{}',
progress int NOT NULL DEFAULT 0 CHECK (progress BETWEEN 0 AND 100),
cancel_requested_at timestamptz,
error_class text CHECK (error_class IN ('infra', 'input', 'content', 'cancelled')),
error_code text,
error_detail text,
billing text NOT NULL CHECK (billing IN ('charged', 'waived_recheck', 'free_plan')),
quota_hold_id uuid,
prompt_versions jsonb,
coverage jsonb, -- 查了什么、没查什么(报告首页)
counts jsonb, -- 各级别问题数,结束时写入
queue_job_id bigint,
created_by uuid NOT NULL REFERENCES user_account (id),
created_at timestamptz NOT NULL DEFAULT now(),
started_at timestamptz,
finished_at timestamptz,
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, id),
UNIQUE (tenant_id, project_id, run_no),
CHECK (billing <> 'charged' OR quota_hold_id IS NOT NULL),
FOREIGN KEY (tenant_id, project_id) REFERENCES project (tenant_id, id) ON DELETE CASCADE,
FOREIGN KEY (tenant_id, checklist_version_id) REFERENCES checklist_version (tenant_id, id) ON DELETE CASCADE,
FOREIGN KEY (tenant_id, previous_run_id) REFERENCES check_run (tenant_id, id) ON DELETE SET NULL (previous_run_id),
FOREIGN KEY (tenant_id, quota_hold_id) REFERENCES quota_entry (tenant_id, id)
);
CREATE UNIQUE INDEX ON check_run (tenant_id, project_id) WHERE status IN ('queued', 'running');
ALTER TABLE project
ADD FOREIGN KEY (tenant_id, latest_check_run_id) REFERENCES check_run (tenant_id, id)
ON DELETE SET NULL (latest_check_run_id);
CREATE TABLE check_run_document ( -- 本次检查用了哪些投标文件版本
tenant_id uuid NOT NULL,
check_run_id uuid NOT NULL,
document_id uuid NOT NULL,
PRIMARY KEY (tenant_id, check_run_id, document_id),
FOREIGN KEY (tenant_id, check_run_id) REFERENCES check_run (tenant_id, id) ON DELETE CASCADE,
FOREIGN KEY (tenant_id, document_id) REFERENCES source_document (tenant_id, id) ON DELETE CASCADE
);
CREATE TABLE finding (
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL,
project_id uuid NOT NULL,
check_run_id uuid NOT NULL,
checklist_item_id uuid, -- AI 判断的问题必填;规则问题有对应条目时填(DM6)
rule_code text, -- 规则问题的规则编号,如 PRICE.CAPITAL_MISMATCH
match_key text NOT NULL, -- item:<id> 或 rule:<code>[:<目标>];复查沿用按它 + type 匹配
type text NOT NULL
CHECK (type IN ('unresponded', 'negative_deviation', 'suspected_unmet',
'formality', 'qualification', 'price_form', 'manual_review')),
severity text NOT NULL CHECK (severity IN ('fatal', 'important', 'hint')),
title text NOT NULL,
explanation text NOT NULL,
detector text NOT NULL CHECK (detector IN ('ai', 'rule')),
detail jsonb NOT NULL DEFAULT '{}', -- 按类型:参数的要求值 / 响应值、资质预警种类、读到的报价金额
search_scope jsonb, -- 「未找到」时找过哪些分册、哪些章节
checks_passed text[] NOT NULL DEFAULT '{}',
presence text NOT NULL CHECK (presence IN ('new', 'carried', 'resolved')), -- 新增 / 沿用 / 本次已解决
carried_from_id uuid,
status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'confirmed_ok', 'modified', 'ignored')),
resolution_note text, -- 忽略原因;致命问题「确认无误」的说明(U4)
resolved_by uuid REFERENCES user_account (id),
resolved_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, id),
UNIQUE (tenant_id, check_run_id, match_key, type),
CHECK (checklist_item_id IS NOT NULL OR rule_code IS NOT NULL),
CHECK (detector <> 'ai' OR checklist_item_id IS NOT NULL),
CHECK (status <> 'ignored' OR resolution_note IS NOT NULL),
CHECK (NOT (severity = 'fatal' AND status = 'confirmed_ok') OR resolution_note IS NOT NULL),
CHECK (presence <> 'carried' OR carried_from_id IS NOT NULL),
FOREIGN KEY (tenant_id, project_id) REFERENCES project (tenant_id, id) ON DELETE CASCADE,
FOREIGN KEY (tenant_id, check_run_id) REFERENCES check_run (tenant_id, id) ON DELETE CASCADE,
FOREIGN KEY (tenant_id, checklist_item_id) REFERENCES checklist_item (tenant_id, id) ON DELETE CASCADE,
FOREIGN KEY (tenant_id, carried_from_id) REFERENCES finding (tenant_id, id) ON DELETE CASCADE
);
CREATE INDEX ON finding (tenant_id, check_run_id, severity, status);
CREATE TABLE finding_citation (
tenant_id uuid NOT NULL,
finding_id uuid NOT NULL,
citation_id uuid NOT NULL,
side text NOT NULL CHECK (side IN ('tender', 'bid')),
position int NOT NULL DEFAULT 0,
PRIMARY KEY (tenant_id, finding_id, citation_id),
FOREIGN KEY (tenant_id, finding_id) REFERENCES finding (tenant_id, id) ON DELETE CASCADE,
FOREIGN KEY (tenant_id, citation_id) REFERENCES citation (tenant_id, id) ON DELETE CASCADE
);
-- 需人工核对的勾选:按项目存,复查沿用(P2)
CREATE TABLE manual_check (
tenant_id uuid NOT NULL,
project_id uuid NOT NULL,
check_key text NOT NULL, -- item:<清单项编号> 或 rule:<规则编号>
checked boolean NOT NULL,
note text,
checked_by uuid NOT NULL REFERENCES user_account (id),
checked_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, project_id, check_key),
FOREIGN KEY (tenant_id, project_id) REFERENCES project (tenant_id, id) ON DELETE CASCADE
);
-- ============================================================
-- 7. report / qualification / feedback / notification
-- ============================================================
CREATE TABLE report ( -- 生成后不再改;重新导出就是新的一行
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL,
project_id uuid NOT NULL,
check_run_id uuid NOT NULL,
object_key text NOT NULL,
sha256 bytea NOT NULL,
page_count int NOT NULL,
template_version text NOT NULL,
generated_by uuid NOT NULL REFERENCES user_account (id),
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, id),
FOREIGN KEY (tenant_id, project_id) REFERENCES project (tenant_id, id) ON DELETE CASCADE,
FOREIGN KEY (tenant_id, check_run_id) REFERENCES check_run (tenant_id, id) ON DELETE CASCADE
);
CREATE TRIGGER report_immutable BEFORE UPDATE ON report
FOR EACH ROW EXECUTE FUNCTION forbid_change();
CREATE TABLE qualification ( -- 最小资质库(FR-M6-08)
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL REFERENCES tenant (id),
name text NOT NULL,
kind text NOT NULL CHECK (kind IN ('certificate', 'system_cert', 'software_copyright', 'other')),
level text,
cert_no text,
issuer text,
valid_from date,
valid_to date, -- 空 = 长期有效
created_by uuid NOT NULL REFERENCES user_account (id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, id)
);
CREATE TABLE qualification_file (
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL,
qualification_id uuid NOT NULL,
object_key text NOT NULL,
filename text NOT NULL,
sha256 bytea NOT NULL,
size_bytes bigint NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
FOREIGN KEY (tenant_id, qualification_id) REFERENCES qualification (tenant_id, id) ON DELETE CASCADE
);
CREATE TABLE feedback ( -- 误报 / 漏报,进运营后台(J5)
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL,
project_id uuid NOT NULL,
object_type text NOT NULL CHECK (object_type IN ('finding', 'checklist_item', 'checklist')),
object_id uuid,
kind text NOT NULL CHECK (kind IN ('false_positive', 'false_negative')),
source text NOT NULL CHECK (source IN ('user', 'fatal_confirm')), -- 致命问题「确认无误」的说明自动进来(U4)
note text,
status text NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'triaged', 'closed')),
handled_by_ops uuid,
handled_at timestamptz,
created_by uuid NOT NULL REFERENCES user_account (id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
FOREIGN KEY (tenant_id, project_id) REFERENCES project (tenant_id, id) ON DELETE CASCADE
);
CREATE INDEX ON feedback (status, created_at);
CREATE TABLE notification ( -- 站内通知(G14)
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL REFERENCES tenant (id),
user_id uuid NOT NULL REFERENCES user_account (id),
kind text NOT NULL
CHECK (kind IN ('parse_succeeded', 'parse_failed', 'check_succeeded', 'check_failed',
'quota_80', 'quota_100', 'subscription_activated', 'subscription_expiring')),
project_id uuid,
payload jsonb NOT NULL DEFAULT '{}', -- 只放编号和数字,不放文档内容
read_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
FOREIGN KEY (tenant_id, project_id) REFERENCES project (tenant_id, id) ON DELETE CASCADE
);
CREATE INDEX ON notification (tenant_id, user_id, created_at DESC) WHERE read_at IS NULL;
-- ============================================================
-- 8. ai:调用流水账(架构 §3.6)
-- ============================================================
CREATE TABLE ai_call ( -- 不存原文;输入按 input_hash 去对象存储取
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL,
job_type text NOT NULL CHECK (job_type IN ('parse_job', 'check_run')),
job_id uuid NOT NULL, -- 不加外键:删除项目后成本数据保留
task text NOT NULL, -- 如 extract.qualification
capability text NOT NULL CHECK (capability IN ('vision', 'fast', 'reasoning', 'embedding')),
provider text NOT NULL,
model text NOT NULL,
prompt_id text NOT NULL,
prompt_version text NOT NULL,
prompt_hash bytea NOT NULL,
input_hash bytea NOT NULL,
output_hash bytea,
tokens_in int,
tokens_out int,
tokens_cached int,
latency_ms int,
output_mode text CHECK (output_mode IN ('json_schema', 'json_mode', 'repaired')),
error_class text CHECK (error_class IN ('timeout', 'rate_limited', 'server', 'network', 'bad_output')),
cost_micro_cny bigint, -- 估算成本,百万分之一元
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON ai_call (tenant_id, job_id);
CREATE INDEX ON ai_call (created_at);
CREATE TRIGGER ai_call_append_only BEFORE UPDATE OR DELETE ON ai_call
FOR EACH ROW EXECUTE FUNCTION forbid_change();
-- ============================================================
-- 9. audit:审计事件(架构 §3.10,FR-M6-07)
-- ============================================================
CREATE TABLE audit_event (
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL REFERENCES tenant (id),
actor_kind text NOT NULL CHECK (actor_kind IN ('user', 'ops', 'system')),
actor_user_id uuid REFERENCES user_account (id),
actor_ops_id uuid,
actor_name text, -- 姓名快照:人离职后留痕上仍显示当时的人
action text NOT NULL, -- 如 checklist_item.update、finding.resolve
object_type text NOT NULL,
object_id uuid,
project_id uuid, -- 不加外键:删除项目后审计保留
before jsonb,
after jsonb,
content_digest bytea, -- before / after 的哈希;抹掉内容后哈希链仍可校验
content_scrubbed_at timestamptz, -- 删除项目时抹掉业务内容(G10)
request_id text,
ip inet,
prev_hash bytea, -- 可选的哈希链
row_hash bytea,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON audit_event (tenant_id, project_id, created_at);
CREATE INDEX ON audit_event (tenant_id, object_type, object_id);
-- 审计只追加;唯一允许的修改是「抹掉业务内容」
CREATE FUNCTION audit_scrub_only() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
RAISE EXCEPTION 'audit_event is append-only';
END IF;
IF NEW.before IS NOT NULL OR NEW.after IS NOT NULL OR NEW.content_scrubbed_at IS NULL
OR (to_jsonb(NEW) - ARRAY['before', 'after', 'content_scrubbed_at', 'actor_name'])
IS DISTINCT FROM (to_jsonb(OLD) - ARRAY['before', 'after', 'content_scrubbed_at', 'actor_name']) THEN
RAISE EXCEPTION 'audit_event only allows scrubbing content';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER audit_event_guard BEFORE UPDATE OR DELETE ON audit_event
FOR EACH ROW EXECUTE FUNCTION audit_scrub_only();
-- ============================================================
-- 10. 加密密钥:信封加密,删除时销毁(架构 §3.11,DM4)
-- ============================================================
CREATE TABLE data_key (
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL REFERENCES tenant (id),
scope text NOT NULL CHECK (scope IN ('tenant', 'project')),
project_id uuid, -- 不加外键:项目删除后这行留下销毁记录
wrapped_key bytea, -- 被 KMS 主密钥加密的数据密钥;销毁时置空
kek_ref text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
destroyed_at timestamptz,
CHECK ((scope = 'project') = (project_id IS NOT NULL)),
CHECK ((destroyed_at IS NULL) = (wrapped_key IS NOT NULL))
);
CREATE UNIQUE INDEX ON data_key (tenant_id) WHERE scope = 'tenant' AND destroyed_at IS NULL;
CREATE UNIQUE INDEX ON data_key (tenant_id, project_id) WHERE scope = 'project' AND destroyed_at IS NULL;
-- ============================================================
-- 11. ops:运营后台账号(独立登录,架构 §3.11)
-- ============================================================
CREATE TABLE ops_admin (
id uuid PRIMARY KEY,
username text NOT NULL UNIQUE,
display_name text NOT NULL,
secret_hash text NOT NULL,
totp_cipher bytea NOT NULL, -- 二次验证密钥(密文)
status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE ops_session (
id uuid PRIMARY KEY,
admin_id uuid NOT NULL REFERENCES ops_admin (id),
token_hash bytea NOT NULL UNIQUE,
ip inet NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
revoked_at timestamptz
);
-- ============================================================
-- 12. updated_at 触发器
-- ============================================================
DO $$
DECLARE t text;
BEGIN
FOR t IN
SELECT c.table_name FROM information_schema.columns c
WHERE c.table_schema = 'app' AND c.column_name = 'updated_at'
LOOP
EXECUTE format('CREATE TRIGGER %I BEFORE UPDATE ON %I FOR EACH ROW EXECUTE FUNCTION set_updated_at()',
t || '_updated_at', t);
END LOOP;
END $$;
-- ============================================================
-- 13. 行级安全策略(架构 §3.3 第 2 层)
-- ============================================================
-- 角色:bd_owner 建表(迁移用);bd_app 给 api / worker 用,受 RLS 约束;
-- bd_ops 给运营后台用,BYPASSRLS,所有操作写审计。
DO $$
DECLARE t text;
BEGIN
FOR t IN
SELECT c.table_name FROM information_schema.columns c
JOIN information_schema.tables tb USING (table_schema, table_name)
WHERE c.table_schema = 'app' AND c.column_name = 'tenant_id' AND tb.table_type = 'BASE TABLE'
LOOP
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t);
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', t);
EXECUTE format('CREATE POLICY tenant_isolation ON %I
USING (tenant_id = current_tenant())
WITH CHECK (tenant_id = current_tenant())', t);
END LOOP;
END $$;
-- tenant 表按自己的 id 隔离
ALTER TABLE tenant ENABLE ROW LEVEL SECURITY;
ALTER TABLE tenant FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON tenant
USING (id = current_tenant()) WITH CHECK (id = current_tenant());
-- 登录前还不知道租户:只有这两个函数能跨租户查成员身份和会话(data-model §2.3)
CREATE FUNCTION auth_resolve_session(p_token_hash bytea)
RETURNS TABLE (session_id uuid, tenant_id uuid, membership_id uuid, user_id uuid, expires_at timestamptz)
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = app AS $$
SELECT s.id, s.tenant_id, s.membership_id, s.user_id, s.expires_at
FROM user_session s
JOIN membership m ON m.id = s.membership_id AND m.status = 'active'
JOIN tenant t ON t.id = s.tenant_id AND t.status = 'active'
WHERE s.token_hash = p_token_hash AND s.revoked_at IS NULL AND s.expires_at > now()
$$;
CREATE FUNCTION auth_list_memberships(p_user_id uuid)
RETURNS TABLE (membership_id uuid, tenant_id uuid, tenant_name text, role text, status text)
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = app AS $$
SELECT m.id, m.tenant_id, t.name, m.role, m.status
FROM membership m JOIN tenant t ON t.id = m.tenant_id
WHERE m.user_id = p_user_id AND t.status <> 'closed'
$$;

Some files were not shown because too many files have changed in this diff Show More