/** 枚举的中文显示名由前端维护(api-design §1);不认识的枚举值原样显示,不报错。 */ import type { Schemas } from "@bid-doctor/api-client"; type Dict = Readonly>; const pick = (d: Dict, k: string | null | undefined): string => (k ? (d as Record)[k] ?? k : ""); export const PROJECT_STATUS: Dict = { tracking: "跟进中", drafting: "编制中", checked: "已检查", submitted: "已递交", archived: "已归档", }; export const projectStatus = (s: string) => pick(PROJECT_STATUS, s); export const LEGAL_REGIME: Dict = { 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 = { qualification: "资格条件", invalidity: "无效条款", scoring: "评分办法", schedule: "日期与递交", tech_param: "技术参数", format_doc: "格式文件", price: "报价", }; export const category = (s: string) => pick(CATEGORY, s); /** 界面显示为 ★ / ▲ / 一般(一般不显示符号) */ export const IMPORTANCE_MARK: Dict = { substantive: "★", important: "▲", normal: "" }; export const IMPORTANCE_NAME: Dict = { substantive: "实质性要求", important: "重要", normal: "一般" }; export const PENDING_REASON: Dict = { 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 = { 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 = { "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 = { 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 = { star: "★", asterisk: "*", triangle: "▲", italic_underline: "斜体加下划线", bold: "加粗", underline: "下划线", keyword: "关键词", }; export const QUOTA_UNIT: Dict = { parse: "解析", check: "检查", generate: "生成", dedupe: "查重" }; export const quotaUnit = (s: string) => pick(QUOTA_UNIT, s); export const PLAN_KEY: Dict = { free: "free", pro: "pro", enterprise: "ent" }; /** 工作台「下一步」。target 为空表示骨架阶段还没有对应页面,只显示文字。 */ export const NEXT_ACTION: Dict = { 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 = { 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 = { fatal: "致命", important: "重要", hint: "提示" }; export const SEVERITY_CLASS: Dict = { fatal: "fatal", important: "major", hint: "hint" }; export const FINDING_ACTION: Dict = { pending: "待处理", confirmed_ok: "确认无误", modified: "已修改", ignored: "已忽略", }; 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); /* ---------- 项目基本信息(FR-M2-03) ---------- */ export const FIELD_LABEL: Dict = { 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 = new Set(["budget_total", "ceiling_total"]); export const BOOL_FIELDS: ReadonlySet = 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 = { price: "价格分", technical: "技术分", commercial: "商务分" }; const QUAL_KIND: Record = { general: "通用资格", specific: "特定资格", credit: "信用要求" }; const OP: Record = { ">=": "≥", "<=": "≤", "=": "=", contains: "包含", supports: "支持" }; const DATE_KIND: Record = { question_deadline: "提疑截止", bid_deadline: "投标截止", opening: "开标", deposit_deadline: "保证金截止", validity: "有效期", }; const SUBMISSION: Record = { electronic: "电子递交", paper: "纸质递交", ca_encrypted: "CA 加密", mixed: "电子 + 纸质" }; type Spec = Record | 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; }