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