Files
bid-doctor/apps/web/src/pages/LotsPage.vue
YANG JIANKUAN ed6a2a907d
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled
feat(project): 项目基本信息修改和分包选择(FR-M2-03、04)
- 字段修改:人工值优先、撤销恢复 AI 值、按字段校验(金额可写「286万」,按元存)、
  冗余列同步、审计留痕、只读项目 409;关键日期显示改后的投标截止时间
- 分包:增删改、选包(至少一个)、一键「不分包」;删包时条目归为通用,删到一个不剩回到不分包
- 清单列表 lot_scope 默认只列通用 + 所投的包,响应带 hidden_count
- 检查记下当时用的项目依据(截止时间、限价、所投的包、法律体系):报告按它写,
  之后改了信息检查结果接口给 basis_changed,查标页提示重新检查
- 前端:概览标签页(项目要点、采购包、关键日期)、字段修改对话框、选包页和手工调整;
  解析完识别到多个包时进选包页;读标页清单范围和所属采购包;查标页写明查哪几个包
- 决策 PI1–PI7;方案和落地记录 docs/architecture/project-info-plan.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 23:46:07 +08:00

370 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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

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