- 字段修改:人工值优先、撤销恢复 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>
370 lines
16 KiB
Vue
370 lines
16 KiB
Vue
<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>
|