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:
166
apps/web/src/components/FieldDialog.vue
Normal file
166
apps/web/src/components/FieldDialog.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<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 { 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>
|
||||
<select id="fd-regime" v-model="text" class="input">
|
||||
<option v-for="(label, v) in LEGAL_REGIME" :key="v" :value="v">{{ label }}</option>
|
||||
</select>
|
||||
<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">
|
||||
<input id="fd-day" v-model="day" class="input num" placeholder="例如:2026-11-20" autocomplete="off" @blur="day = parseDay(day) || day" />
|
||||
<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>
|
||||
@@ -12,13 +12,14 @@ import { computed, ref, watch } from "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 } from "@/lib/queries";
|
||||
import type { ChecklistItem, Lot } from "@/lib/queries";
|
||||
|
||||
type Mode = "edit" | "accept" | "create";
|
||||
type Cat = ChecklistItem["categories"][number];
|
||||
type Imp = ChecklistItem["importance"];
|
||||
|
||||
const props = defineProps<{ mode: Mode; item?: ChecklistItem | null; defaultCategory?: Cat }>();
|
||||
/** 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 = 人工确认、无出处 */
|
||||
@@ -32,6 +33,7 @@ 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("");
|
||||
@@ -46,6 +48,7 @@ watch(open, (v) => {
|
||||
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 = "";
|
||||
});
|
||||
@@ -75,6 +78,7 @@ function changes(): ItemFields {
|
||||
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;
|
||||
@@ -129,6 +133,13 @@ function submit() {
|
||||
</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>
|
||||
<select id="it-lot" v-model="lotId" class="input">
|
||||
<option :value="null">各包通用</option>
|
||||
<option v-for="l in lots" :key="l.id" :value="l.id">采购包 {{ l.lot_no }}<template v-if="l.name"> · {{ l.name }}</template></option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field fill">
|
||||
<label for="it-ev">证明材料 <small>(选填)</small></label>
|
||||
<input id="it-ev" v-model="evidence" class="input" placeholder="如「提供营业执照副本复印件并加盖公章」" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 项目头(原型的 projHead):项目名、采购人等、投标截止、状态和就绪;下面是「读标 / 查标」标签页。
|
||||
* 项目头(原型的 projHead):项目名、采购人等、投标截止、状态和就绪;下面是「概览 / 读标 / 查标 / 报告」标签页。
|
||||
* 就绪只在「已检查」时显示(G7),只表示致命问题都已处理,不代表投标文件没有问题(READY_TIP)。
|
||||
* 状态操作(FR-M6-02):递交 / 撤销递交 / 归档 / 撤销归档。致命问题没处理完也可以递交(红线 4),确认框里说明;
|
||||
* 递交后项目只读,可以撤销,都留痕。
|
||||
@@ -16,7 +16,7 @@ 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: "checklist" | "check" | "report" }>();
|
||||
const props = defineProps<{ project?: ProjectDetail; tab: "overview" | "checklist" | "check" | "report" }>();
|
||||
const qc = useQueryClient();
|
||||
const p = computed(() => props.project);
|
||||
const meta = computed(() =>
|
||||
@@ -96,6 +96,7 @@ const checkTo = computed(() =>
|
||||
</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">查标</RouterLink>
|
||||
<RouterLink v-if="p" role="tab" :aria-selected="tab === 'report'" :to="{ name: 'report', params: { projectId: p.id } }">报告</RouterLink>
|
||||
|
||||
@@ -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) } } })),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -209,6 +209,10 @@ async function start() {
|
||||
</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>
|
||||
<div v-if="startErr" class="alert warn" role="alert"><span class="ico w" aria-hidden="true">!</span><p>{{ startErr }}</p></div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -25,14 +25,18 @@ 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, useProject, type ChecklistItem, type Citation, type MarkingRule } from "@/lib/queries";
|
||||
import { useChecklist, useChecklistItems, useLots, useProject, 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 itemsQ = useChecklistItems(() => 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 } } })),
|
||||
@@ -290,6 +294,20 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
|
||||
</div>
|
||||
</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-if="cl?.response_modes?.length" class="modes" aria-label="招标文件的响应方式">
|
||||
<span class="cap muted">响应方式</span>
|
||||
<div v-for="m in cl.response_modes" :key="m.category" class="mode">
|
||||
@@ -455,7 +473,7 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ItemDialog v-model:open="dlg.open" :mode="dlg.mode" :item="dlg.item" :default-category="cat !== 'all' && cat !== 'pending' && cat !== 'deleted' ? cat : undefined" @submit="onSubmit" @pick="onDialogPick" />
|
||||
<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" />
|
||||
<ConfirmDialog v-model:open="confirmOpen" :title="`确认清单 v${nextVersion}?`" ok-text="确认清单" busy-text="正在确认…" :busy="confirming" @ok="doConfirm">
|
||||
<p>确认后,查标以这一版清单为依据。之后再改清单会回到草稿,历史版本保留。</p>
|
||||
@@ -467,6 +485,13 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lot-scope {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
align-items: baseline;
|
||||
margin-bottom: var(--sp-3);
|
||||
}
|
||||
/* 响应方式(C12):清单栏下面一行,每类一个选择框 */
|
||||
.modes {
|
||||
display: flex;
|
||||
|
||||
369
apps/web/src/pages/LotsPage.vue
Normal file
369
apps/web/src/pages/LotsPage.vue
Normal 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>
|
||||
267
apps/web/src/pages/OverviewPage.vue
Normal file
267
apps/web/src/pages/OverviewPage.vue
Normal file
@@ -0,0 +1,267 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 项目概览(原型 project.html,PI1):这一步只做三张卡——项目要点(基本信息字段,带出处,可改、可恢复 AI 值)、
|
||||
* 采购包(所投的包和限价)、关键日期(时间线和倒计时,FR-M2-09)。检查记录、文件、AI 摘要卡留到第 6 步。
|
||||
* 查标按这里的值核对截止时间、限价、法律体系;改了以后重新检查才生效(PI5)。项目已递交、已归档时只读。
|
||||
*/
|
||||
import type { Schemas } from "@bid-doctor/api-client";
|
||||
import { Pencil, X } from "@lucide/vue";
|
||||
import { useQueryClient } from "@tanstack/vue-query";
|
||||
import { computed, ref } from "vue";
|
||||
import { RouterLink } from "vue-router";
|
||||
import FieldDialog from "@/components/FieldDialog.vue";
|
||||
import PdfViewer, { type ViewerTarget } from "@/components/PdfViewer.vue";
|
||||
import ProjectHead from "@/components/ProjectHead.vue";
|
||||
import { useCrumbs } from "@/lib/crumbs";
|
||||
import { qk } from "@/lib/keys";
|
||||
import { deadline, FIELD_LABEL, FIELD_ORDER, fieldText, fmtDateTimeFull, fmtYuan } from "@/lib/labels";
|
||||
import { useLots, useProject, type Citation, 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];
|
||||
const value = 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]}`);
|
||||
}
|
||||
|
||||
const viewer = ref<{ doc: { id: string; label: string }; target: ViewerTarget & { id: string } } | null>(null);
|
||||
function openCitation(c: Citation) {
|
||||
viewer.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 ov">
|
||||
<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)">
|
||||
第 <span class="fig">{{ f.citation.page_no }}</span> 页
|
||||
</button>
|
||||
<span v-if="f?.pending" class="cap warn-text">补遗带来了新值:{{ fieldText(key, f.pending.value) }}(下一步补遗确认)</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>
|
||||
|
||||
<aside class="stack">
|
||||
<section class="card flush" aria-labelledby="h-lots">
|
||||
<div class="card-h">
|
||||
<h3 id="h-lots">采购包</h3>
|
||||
<RouterLink v-if="!readOnly" class="lnk cap r" :to="{ name: 'lots', params: { projectId } }">{{ realLots.length > 1 ? "更改所投的包" : "调整分包" }}</RouterLink>
|
||||
</div>
|
||||
<div class="card-b">
|
||||
<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-dates">
|
||||
<div class="card-h"><h3 id="h-dates">关键日期</h3></div>
|
||||
<div class="card-b">
|
||||
<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>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<FieldDialog v-model:open="dlg.open" :project-id="projectId" :field-key="dlg.key" :field="dlg.field" @saved="onSaved" />
|
||||
<aside v-if="viewer" class="drawer" aria-label="原文">
|
||||
<button type="button" class="icon-btn sm close" aria-label="关闭原文" @click="viewer = null"><X class="ic" :stroke-width="1.5" /></button>
|
||||
<PdfViewer :document-id="viewer.doc.id" :document-label="viewer.doc.label" :target="viewer.target" />
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 主列 + 右侧信息栏(--aside-w),同工作台 */
|
||||
.ov {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) var(--aside-w);
|
||||
gap: var(--gap-card);
|
||||
align-items: start;
|
||||
}
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-card);
|
||||
}
|
||||
.card-h .r {
|
||||
margin-left: auto;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.cite-src.inline {
|
||||
display: inline;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
font-size: var(--fs-caption);
|
||||
color: var(--ai);
|
||||
cursor: pointer;
|
||||
}
|
||||
.cite-src.inline:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.warn-text {
|
||||
color: var(--warn-text);
|
||||
}
|
||||
.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-2);
|
||||
align-items: baseline;
|
||||
padding: var(--sp-2) 0;
|
||||
}
|
||||
.timeline .when {
|
||||
flex-basis: 100%;
|
||||
font-size: var(--fs-figure-sm);
|
||||
}
|
||||
.drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
width: min(640px, 50vw);
|
||||
background: var(--bg-surface);
|
||||
box-shadow: var(--shadow-overlay);
|
||||
}
|
||||
.drawer > :deep(*:last-child) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.drawer .close {
|
||||
position: absolute;
|
||||
top: var(--sp-3);
|
||||
left: calc(-1 * var(--sp-8));
|
||||
background: var(--bg-surface);
|
||||
box-shadow: var(--shadow-overlay);
|
||||
}
|
||||
</style>
|
||||
@@ -190,7 +190,9 @@ const result = computed(() => j.value?.result ?? null);
|
||||
<span class="r"><RouterLink class="btn sm" :to="{ name: 'workbench' }">回工作台,完成后通知我</RouterLink></span>
|
||||
</template>
|
||||
<template v-else-if="status === 'succeeded'">
|
||||
<RouterLink class="btn primary" :to="{ name: 'checklist', params: { projectId } }">查看检查清单</RouterLink>
|
||||
<!-- 识别到 2 个及以上的包时先选包(FR-M2-04);只有 1 个包按不分包处理(PI4) -->
|
||||
<RouterLink v-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>
|
||||
|
||||
@@ -226,6 +226,12 @@ const errorText = computed(() => {
|
||||
</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 coverage" aria-labelledby="h-cov">
|
||||
<h2 id="h-cov" class="vh">检查覆盖范围</h2>
|
||||
|
||||
@@ -31,6 +31,18 @@ export const router = createRouter({
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user