Files
bid-doctor/apps/web/src/lib/labels.ts
YANG JIANKUAN ed6a2a907d
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled
feat(project): 项目基本信息修改和分包选择(FR-M2-03、04)
- 字段修改:人工值优先、撤销恢复 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

350 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/** 枚举的中文显示名由前端维护(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: "已忽略",
};
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;
}