feat(ui): 查标、概览改为左右布局;招标文件、投标文件左右切换
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled

- 查标结果页、概览和读标页一样:左边内容随页面滚动,右边原文贴住视口,去掉原文侧边抽屉
- DocStage:查标右侧招标文件、投标文件横向排开,当前的占满,另一份露出 80px
  (文件名移到露出的一侧、纸边、带方向箭头的竖排标签,截断处淡入页面底色,悬停时探出一点);
  点露出的一条或另一边的出处就滑过去,两边各自记着页和高亮
- 查标:进页面翻到第一条问题的出处,J / K 时原文跟着翻,优先留在正在看的一边;
  左边变窄后问题行改单列,出处放在说明下面,快捷键提示收成「快捷键」
- 概览:关键日期和采购包合成一张卡放到最上面,项目要点在下
- 原文查看器:直接打开到某页时会并发两次渲染、叠出镜像乱字,改为后一次等前一次停下

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-05 12:23:50 +08:00
parent 9f1a79ae03
commit 5b77012fac
6 changed files with 339 additions and 143 deletions

View File

@@ -6,18 +6,19 @@
* 补遗(FR-M2-10):招标文件解析后可以追加;补遗带来的新值在这里「采用 / 不采用」(AD3),没确认前查标用旧值(AD4)。
*/
import { unwrap, type Schemas } from "@bid-doctor/api-client";
import { Pencil, Plus, X } from "@lucide/vue";
import { Pencil, Plus } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { computed, ref } from "vue";
import { computed, ref, watchEffect } from "vue";
import { RouterLink } from "vue-router";
import DocStage, { type StageSlot } from "@/components/DocStage.vue";
import FieldDialog from "@/components/FieldDialog.vue";
import PdfViewer, { type ViewerTarget } from "@/components/PdfViewer.vue";
import type { ViewerTarget } from "@/components/PdfViewer.vue";
import ProjectHead from "@/components/ProjectHead.vue";
import { api, errorTitle } from "@/lib/api";
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 { useLots, useProject, useProjectDocuments, type Citation, type ProjectField } from "@/lib/queries";
import { toast } from "@/lib/toast";
const props = defineProps<{ projectId: string }>();
@@ -79,15 +80,64 @@ async function resolvePending(key: Schemas["ProjectFieldKey"], accept: boolean)
}
const canAddendum = computed(() => !!p.value && p.value.status !== "tracking" && !readOnly.value);
const viewer = ref<{ doc: { id: string; label: string }; target: ViewerTarget & { id: string } } | null>(null);
/* 右侧原文(左右布局,同读标页):默认摆招标文件,点出处翻到那一页并高亮;补遗的出处换成那份补遗 */
const docsQ = useProjectDocuments(() => props.projectId);
const shown = ref<{ doc: { id: string; label: string } | null; target: (ViewerTarget & { id: string }) | null }>({ doc: null, target: null });
watchEffect(() => {
const t = docsQ.data.value?.items.find((d) => d.role === "tender" && d.status === "ready");
if (!shown.value.doc && t) shown.value.doc = { id: t.id, label: "招标文件" };
});
const slots = computed<StageSlot[]>(() => [{ key: "tender", label: "招标文件", ...shown.value, emptyText: "招标文件解析完成后,在这里看原文。" }]);
const side = ref("tender");
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 } };
shown.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">
<div class="content">
<div class="split">
<div class="split-main ov">
<!-- 关键日期和采购包:放在最上面,一张卡片左右两栏(右边是原文,左边不够放两列卡片) -->
<section class="card flush glance" aria-label="关键日期和采购包">
<div class="g-col" aria-labelledby="h-dates">
<div class="g-h"><h3 id="h-dates">关键日期</h3></div>
<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>
<span v-if="d.superseded_pending" class="cap warn-text rep">已被补遗更新,待确认</span>
<span v-if="d.replaces" class="cap muted rep">已按{{ d.replaces.document_label }} 更新 · 原为 {{ d.replaces.at ? fmtDateTimeFull(d.replaces.at) : "—" }}</span>
</li>
</ol>
</div>
<div class="g-col" aria-labelledby="h-lots">
<div class="g-h">
<h3 id="h-lots">采购包</h3>
<RouterLink v-if="!readOnly" class="lnk cap r" :to="{ name: 'lots', params: { projectId } }">{{ realLots.length > 1 ? "更改所投的包" : "调整分包" }}</RouterLink>
</div>
<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-facts">
<div class="card-h">
<h2 id="h-facts">项目要点</h2>
@@ -120,70 +170,50 @@ function openCitation(c: Citation) {
</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.superseded_pending" class="cap warn-text">已被补遗更新,待确认</span>
<span v-if="d.replaces" class="cap muted rep">已按{{ d.replaces.document_label }} 更新 · 原为 {{ d.replaces.at ? fmtDateTimeFull(d.replaces.at) : "—" }}</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>
<aside class="split-doc" aria-label="原文">
<DocStage v-model:active="side" :slots="slots" />
</aside>
</div>
</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);
}
.ov > .card {
margin: 0;
}
.glance {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
}
.g-col {
min-width: 0;
padding: var(--sp-4) var(--card-x);
}
.g-col + .g-col {
border-left: var(--line) solid var(--border-subtle);
}
.g-h {
display: flex;
align-items: baseline;
margin-bottom: var(--sp-2);
}
.g-h h3 {
margin: 0;
font-size: var(--fs-body);
font-weight: var(--fw-bold);
}
.g-h .r {
margin-left: auto;
}
.card-h .r {
margin-left: auto;
}
@@ -262,34 +292,12 @@ function openCitation(c: Citation) {
.timeline li {
display: flex;
flex-wrap: wrap;
gap: var(--sp-1) var(--sp-2);
gap: var(--sp-1) var(--sp-3);
align-items: baseline;
padding: var(--sp-2) 0;
padding: var(--sp-1) 0;
}
.timeline .when {
flex-basis: 100%;
min-width: 11em;
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>