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:
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>
|
||||
Reference in New Issue
Block a user