feat(project): 项目基本信息修改和分包选择(FR-M2-03、04)
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled

- 字段修改:人工值优先、撤销恢复 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:
YANG JIANKUAN
2026-11-04 23:46:07 +08:00
parent 08e3fe7eff
commit ed6a2a907d
29 changed files with 1834 additions and 43 deletions

View 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>

View File

@@ -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="如「提供营业执照副本复印件并加盖公章」" />

View File

@@ -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>

View File

@@ -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,
};

View File

@@ -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) };

View File

@@ -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) } } })),
});
}

View File

@@ -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>

View File

@@ -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;

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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",