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>
This commit is contained in:
@@ -18,4 +18,5 @@ export const qk = {
|
||||
reports: (projectId: string) => ["reports", projectId] as const,
|
||||
itemResults: (runId: string) => ["item-results", runId] as const,
|
||||
qualifications: ["qualifications"] as const,
|
||||
lots: (projectId: string) => ["lots", projectId] as const,
|
||||
};
|
||||
|
||||
@@ -196,6 +196,44 @@ export const QUALIFICATION_KIND: Dict<Schemas["QualificationKind"]> = {
|
||||
};
|
||||
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()}日`;
|
||||
@@ -228,6 +266,21 @@ export function parseDay(text: string): string | null {
|
||||
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) };
|
||||
|
||||
@@ -27,6 +27,8 @@ 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"));
|
||||
|
||||
@@ -87,13 +89,16 @@ export function useChecklist(projectId: MaybeRefOrGetter<string>) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useChecklistItems(projectId: MaybeRefOrGetter<string>) {
|
||||
/** 清单项。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))),
|
||||
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) } } })) as Promise<{
|
||||
items: ChecklistItem[];
|
||||
}>,
|
||||
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 }>,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -147,3 +152,11 @@ export function useFindings(runId: MaybeRefOrGetter<string>, enabled: MaybeRefOr
|
||||
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) } } })),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user