diff --git a/AGENTS.md b/AGENTS.md index 4b44dcb..a52a34f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -220,4 +220,5 @@ samples/ - 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` 进度。 -- 下一步:国动真实投标文件(用户晚些提供,到时先读标、请用户看清单,再查标一次);其余可做:第 4 步资质库和资质匹配、第 6 步前端补齐(键盘操作、批量处理、判断有误反馈)。 +- 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)。 +- 下一步:国动真实投标文件(用户晚些提供,到时先读标、请用户看清单,再查标一次;请用户按公司真实证书录资质库);其余可做:第 6 步前端补齐(键盘操作、批量处理、判断有误反馈、日期选择器的日历弹层)、M6 剩下的 P0(额度流水、套餐开通、数据删除、运营后台)。 diff --git a/apps/web/src/components/AppShell.vue b/apps/web/src/components/AppShell.vue index 5a22841..a670564 100644 --- a/apps/web/src/components/AppShell.vue +++ b/apps/web/src/components/AppShell.vue @@ -3,7 +3,7 @@ * 登录后的页面框架(设计规范 v0.2 §5.3):96px 墨色渐变侧栏 + 透明顶栏(滚动后半透明白)+ 内容区。 * 挂载期间保持一条事件流连接,把服务端的变化推进查询缓存。 */ -import { House, LogOut } from "@lucide/vue"; +import { Award, House, LogOut } from "@lucide/vue"; import { useQueryClient } from "@tanstack/vue-query"; import { computed, onBeforeUnmount, onMounted, ref } from "vue"; import { RouterLink, RouterView, useRoute, useRouter } from "vue-router"; @@ -71,6 +71,9 @@ async function logout() { 工作台 + + 资质库 +
diff --git a/apps/web/src/components/QualificationDialog.vue b/apps/web/src/components/QualificationDialog.vue new file mode 100644 index 0000000..afe3c93 --- /dev/null +++ b/apps/web/src/components/QualificationDialog.vue @@ -0,0 +1,326 @@ + + + + + diff --git a/apps/web/src/lib/keys.ts b/apps/web/src/lib/keys.ts index d2db4a6..1dc2385 100644 --- a/apps/web/src/lib/keys.ts +++ b/apps/web/src/lib/keys.ts @@ -17,4 +17,5 @@ export const qk = { 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, }; diff --git a/apps/web/src/lib/labels.ts b/apps/web/src/lib/labels.ts index 98e3268..c504f58 100644 --- a/apps/web/src/lib/labels.ts +++ b/apps/web/src/lib/labels.ts @@ -187,6 +187,15 @@ export const FINDING_ACTION: Dict = { export const READY_TIP = "只表示致命问题都已处理,不代表投标文件没有问题。"; +export const QUALIFICATION_KIND_ORDER: readonly Schemas["QualificationKind"][] = ["certificate", "system_cert", "software_copyright", "other"]; +export const QUALIFICATION_KIND: Dict = { + certificate: "资质证书", + system_cert: "体系认证", + software_copyright: "软件著作权", + other: "其他", +}; +export const qualificationKind = (s: string) => pick(QUALIFICATION_KIND, s); + /* ---------- 时间 ---------- */ const pad = (n: number) => String(n).padStart(2, "0"); export const fmtDate = (d: Date) => `${d.getMonth() + 1}月${d.getDate()}日`; @@ -195,6 +204,30 @@ 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)}`; +} export const fmtDuration = (ms: number) => { const s = Math.max(0, Math.round(ms / 1000)); return { min: Math.floor(s / 60), sec: pad(s % 60) }; diff --git a/apps/web/src/lib/queries.ts b/apps/web/src/lib/queries.ts index 5737cdf..b610382 100644 --- a/apps/web/src/lib/queries.ts +++ b/apps/web/src/lib/queries.ts @@ -26,6 +26,7 @@ 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 const fetchMe = () => unwrap(api.GET("/me")); @@ -141,3 +142,8 @@ export function useFindings(runId: MaybeRefOrGetter, enabled: MaybeRefOr enabled: computed(() => toValue(enabled)), }); } + +/** 资质库:一次取全部(最小资质库条目不多),类型筛选和搜索在前端做 */ +export function useQualifications() { + return useQuery({ queryKey: qk.qualifications, queryFn: () => unwrap(api.GET("/qualifications")) }); +} diff --git a/apps/web/src/pages/QualificationsPage.vue b/apps/web/src/pages/QualificationsPage.vue new file mode 100644 index 0000000..8b9618b --- /dev/null +++ b/apps/web/src/pages/QualificationsPage.vue @@ -0,0 +1,288 @@ + + + + + diff --git a/apps/web/src/pages/ResultsPage.vue b/apps/web/src/pages/ResultsPage.vue index b64ddeb..8b7c9f8 100644 --- a/apps/web/src/pages/ResultsPage.vue +++ b/apps/web/src/pages/ResultsPage.vue @@ -21,7 +21,7 @@ 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, SEVERITY, SEVERITY_CLASS } from "@/lib/labels"; +import { CHECK_STEPS, checkStepIndex, FINDING_ACTION, fmtDateTime, fmtDay, SEVERITY, SEVERITY_CLASS } from "@/lib/labels"; import { TERMINAL_JOB, useCheckRun, useFindings, useManualChecks, useProject, type Citation, type Finding, type ManualCheck } from "@/lib/queries"; import { toast } from "@/lib/toast"; @@ -132,6 +132,21 @@ function refresh() { 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 ?? []); @@ -274,6 +289,10 @@ const errorText = computed(() => {