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

@@ -224,4 +224,5 @@ samples/
- 2026-11-04:项目基本信息修改和分包选择(FR-M2-03、04,`docs/architecture/project-info-plan.md`,PI1–PI7 全部按建议确认)。新增「概览」标签页(项目要点可改、带出处、可恢复 AI 值;采购包;关键日期)和选包页(手工调整、一键「不分包」);清单默认只列通用 + 所投的包;检查记下当时用的截止时间、限价、所投的包、法律体系,报告按它写,之后改了信息查标页提示重新检查。样本里分包识别对的只有 S10(S06 把分项认成 5 个包,S01、S08 单包写了包号),改进分包抽取记进读标专项。 - 2026-11-04:项目基本信息修改和分包选择(FR-M2-03、04,`docs/architecture/project-info-plan.md`,PI1–PI7 全部按建议确认)。新增「概览」标签页(项目要点可改、带出处、可恢复 AI 值;采购包;关键日期)和选包页(手工调整、一键「不分包」);清单默认只列通用 + 所投的包;检查记下当时用的截止时间、限价、所投的包、法律体系,报告按它写,之后改了信息查标页提示重新检查。样本里分包识别对的只有 S10(S06 把分项认成 5 个包,S01、S08 单包写了包号),改进分包抽取记进读标专项。
- 2026-11-05:补遗追加(FR-M2-10,`docs/architecture/addendum-plan.md`,AD1–AD6 全部按建议确认)。招标文件解析后可以追加补遗 / 澄清(单独解析、扣 1 次),新条目标「补遗 N」进正式清单、清单回到草稿;同一种日期时间不同的旧条目标「已被补遗 N 更新」,项目字段的新值在概览「采用 / 不采用」,采用截止时间时日期条目一起确认,没确认前查标用旧值、检查预览提示。补遗不改项目名称、编号、采购人等(更正公告标题会被抽成项目名称)。真实模型对虚构更正公告抽得对;还没有真实补遗样本。 - 2026-11-05:补遗追加(FR-M2-10,`docs/architecture/addendum-plan.md`,AD1–AD6 全部按建议确认)。招标文件解析后可以追加补遗 / 澄清(单独解析、扣 1 次),新条目标「补遗 N」进正式清单、清单回到草稿;同一种日期时间不同的旧条目标「已被补遗 N 更新」,项目字段的新值在概览「采用 / 不采用」,采用截止时间时日期条目一起确认,没确认前查标用旧值、检查预览提示。补遗不改项目名称、编号、采购人等(更正公告标题会被抽成项目名称)。真实模型对虚构更正公告抽得对;还没有真实补遗样本。
- 2026-11-05:读标、查标全流程走查(浏览器从注册走到导出报告)。修了走查发现的问题:原生下拉换成按规范 §7.21 的 `SelectBox`(Reka UI,和搜索框同高,长选项不再挤压签名);关键日期去重;报告页致命数改读项目的实时值;★ 只是「重要」的文件清单项也挂到 ★ 规则上;检查范围文案过期;报告出处不重复文件名(模板 2026.11-v3)。查标结果页按原型补齐:本次处理的留在原位、J / K / O / 1 / 2 / 3 / X / U 键盘、「提示」批量确认、「判断有误?」反馈、覆盖范围默认收起、导出报告入口;项目头改为「就绪 / 未就绪」,致命数挂在查标标签上。 - 2026-11-05:读标、查标全流程走查(浏览器从注册走到导出报告)。修了走查发现的问题:原生下拉换成按规范 §7.21 的 `SelectBox`(Reka UI,和搜索框同高,长选项不再挤压签名);关键日期去重;报告页致命数改读项目的实时值;★ 只是「重要」的文件清单项也挂到 ★ 规则上;检查范围文案过期;报告出处不重复文件名(模板 2026.11-v3)。查标结果页按原型补齐:本次处理的留在原位、J / K / O / 1 / 2 / 3 / X / U 键盘、「提示」批量确认、「判断有误?」反馈、覆盖范围默认收起、导出报告入口;项目头改为「就绪 / 未就绪」,致命数挂在查标标签上。
- 2026-11-05:查标结果页、概览改成和读标页一样的左右布局(左边内容,右边原文贴住视口),去掉原文侧边抽屉。查标右侧招标文件、投标文件左右切换(`components/DocStage.vue`):当前的占满,另一份露出一窄条(文件名、纸边、带方向箭头的标签,截断处淡入底色),点它或点另一边的出处就滑过去,两边各自记着页和高亮,J / K 时原文跟着翻。概览把关键日期和采购包合成一张卡放到最上面。修了原文查看器直接打开到某页时重复渲染出镜像乱字的问题。内容区四角改为直角(设计规范 §5.3 同步)。
- 下一步:国动真实投标文件(用户晚些提供,到时先读标、请用户看清单,再查标一次;请用户按公司真实证书录资质库;有更正公告一起要);其余可做:第 6 步前端补齐(键盘操作、批量处理、判断有误反馈、日期选择器的日历弹层、概览页的检查记录和文件卡)、M6 剩下的 P0(额度流水、套餐开通、数据删除、运营后台)。 - 下一步:国动真实投标文件(用户晚些提供,到时先读标、请用户看清单,再查标一次;请用户按公司真实证书录资质库;有更正公告一起要);其余可做:第 6 步前端补齐(键盘操作、批量处理、判断有误反馈、日期选择器的日历弹层、概览页的检查记录和文件卡)、M6 剩下的 P0(额度流水、套餐开通、数据删除、运营后台)。

View File

@@ -0,0 +1,156 @@
<script setup lang="ts">
/**
* 页面右侧的原文区(左右布局,同读标页):一个或两个原文查看器。
* 两个时(查标:招标文件、投标文件)横向排开,当前的那个占满,另一个只在边上露出一窄条(--peek);
* 点露出的那一条、或点到另一边的出处,就滑过去,原来那个从另一侧露出一窄条。两边各自记着翻到的页和高亮。
* 露出的那一条在截断处淡入页面底色,不留硬边;标签上的箭头指向文件所在的一侧,悬停时文件先往外探出一点。
*/
import { ChevronLeft, ChevronRight } from "@lucide/vue";
import { computed } from "vue";
import PdfViewer, { type ViewerTarget } from "@/components/PdfViewer.vue";
export interface StageSlot {
key: string;
label: string;
doc: { id: string; label: string } | null;
target: ViewerTarget | null;
emptyText?: string;
}
const props = defineProps<{ slots: StageSlot[] }>();
const active = defineModel<string>("active", { required: true });
const index = computed(() => Math.max(0, props.slots.findIndex((s) => s.key === active.value)));
</script>
<template>
<div class="stage" :class="{ multi: slots.length > 1, second: index > 0 }">
<div class="track">
<div v-for="(s, i) in slots" :key="s.key" class="panel" :class="i === index ? 'on' : i < index ? 'at-left' : 'at-right'" :inert="i !== index || undefined">
<PdfViewer :document-id="s.doc?.id" :document-label="s.doc?.label ?? s.label" :target="s.target" :empty-text="s.emptyText" />
</div>
</div>
<!-- 露出的那一条:放在 track 外面,不跟着 inert -->
<button
v-for="(s, i) in slots"
v-show="slots.length > 1 && i !== index"
:key="`peek-${s.key}`"
type="button"
class="peek"
:class="i < index ? 'left' : 'right'"
:aria-label="`看${s.label}`"
@click="active = s.key"
>
<span class="tab">
<component :is="i < index ? ChevronLeft : ChevronRight" class="ic" :stroke-width="1.75" aria-hidden="true" />
<span class="t">{{ s.label }}</span>
</span>
</button>
</div>
</template>
<style scoped>
.stage {
--peek: 80px;
--stage-gap: var(--sp-2);
position: relative;
overflow: hidden;
min-height: 0;
height: 100%;
}
.track {
display: flex;
gap: var(--stage-gap);
height: 100%;
transition: transform calc(var(--dur-base) * 1.5) var(--ease);
}
/* 第二个是当前的:整条左移,第一个只露出右边 --peek 宽 */
.stage.second .track {
transform: translateX(calc(-100% + 2 * var(--peek) + var(--stage-gap)));
}
.panel {
flex: 1;
min-width: 0;
height: 100%;
}
.stage.multi .panel {
flex: 0 0 calc(100% - var(--peek) - var(--stage-gap));
}
.panel:not(.on) {
opacity: 0.8;
transition: opacity var(--dur-base) var(--ease);
}
/* 露出的一窄条:留下文件名(挪到露出的那一侧),藏起翻页按钮,看得出是哪份文件 */
.panel:not(.on) :deep(.pager) {
visibility: hidden;
}
.panel.at-left :deep(.viewer-bar) {
flex-direction: row-reverse;
}
.panel.at-left :deep(.doc-name) {
text-align: right;
}
/* 悬停露出的那一条:文件往外探出一点,提示可以切过去 */
.stage:has(.peek.right:hover) .track {
transform: translateX(-10px);
}
.stage.second:has(.peek.left:hover) .track {
transform: translateX(calc(-100% + 2 * var(--peek) + var(--stage-gap) + 10px));
}
.peek {
position: absolute;
top: 0;
bottom: 0;
z-index: 2;
display: grid;
place-items: center;
width: var(--peek);
padding: 0;
border: 0;
cursor: pointer;
}
/* 截断处淡入页面底色:外侧不透明,往里渐隐,看不出硬切的边 */
.peek.right {
right: 0;
background: linear-gradient(to left, var(--bg-page), transparent 35%);
}
.peek.left {
left: 0;
background: linear-gradient(to right, var(--bg-page), transparent 35%);
}
.tab {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--sp-1);
padding: var(--sp-2) 3px var(--sp-3);
border: var(--line) solid var(--border-subtle);
border-radius: var(--radius-ctl);
background: var(--bg-surface);
color: var(--text-secondary);
transition: color var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease);
}
.tab .t {
writing-mode: vertical-rl;
font-size: var(--fs-caption);
letter-spacing: 2px;
}
.tab .ic {
width: 14px;
height: 14px;
transition: transform var(--dur-fast) var(--ease);
}
.peek:hover .tab,
.peek:focus-visible .tab {
color: var(--text-primary);
border-color: var(--c-ink);
}
.peek.left:hover .ic {
transform: translateX(-2px);
}
.peek.right:hover .ic {
transform: translateX(2px);
}
.peek:focus-visible {
outline: 0;
}
</style>

View File

@@ -45,6 +45,7 @@ const body = ref<HTMLElement>();
const canvas = ref<HTMLCanvasElement>(); const canvas = ref<HTMLCanvasElement>();
let task: pdfjs.RenderTask | null = null; let task: pdfjs.RenderTask | null = null;
let loadSeq = 0; let loadSeq = 0;
let renderSeq = 0;
async function load(id: string | undefined) { async function load(id: string | undefined) {
const seq = ++loadSeq; const seq = ++loadSeq;
@@ -71,8 +72,13 @@ async function render() {
const doc = pdf.value; const doc = pdf.value;
const el = body.value; const el = body.value;
if (!doc || !el || !canvas.value) return; if (!doc || !el || !canvas.value) return;
task?.cancel(); // 同一块画布同时只能有一次渲染:后来的等前一次真正停下,中途又有更新的就让给它(打开时直接翻到出处那页会连发两次)
const seq = ++renderSeq;
const prev = task;
prev?.cancel();
await prev?.promise.catch(() => {});
const p = await doc.getPage(page.value); const p = await doc.getPage(page.value);
if (seq !== renderSeq) return;
const base = p.getViewport({ scale: 1 }); const base = p.getViewport({ scale: 1 });
const width = Math.max(320, el.clientWidth - 32); const width = Math.max(320, el.clientWidth - 32);
const vp = p.getViewport({ scale: width / base.width }); const vp = p.getViewport({ scale: width / base.width });

View File

@@ -6,18 +6,19 @@
* 补遗(FR-M2-10):招标文件解析后可以追加;补遗带来的新值在这里「采用 / 不采用」(AD3),没确认前查标用旧值(AD4)。 * 补遗(FR-M2-10):招标文件解析后可以追加;补遗带来的新值在这里「采用 / 不采用」(AD3),没确认前查标用旧值(AD4)。
*/ */
import { unwrap, type Schemas } from "@bid-doctor/api-client"; 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 { useQueryClient } from "@tanstack/vue-query";
import { computed, ref } from "vue"; import { computed, ref, watchEffect } from "vue";
import { RouterLink } from "vue-router"; import { RouterLink } from "vue-router";
import DocStage, { type StageSlot } from "@/components/DocStage.vue";
import FieldDialog from "@/components/FieldDialog.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 ProjectHead from "@/components/ProjectHead.vue";
import { api, errorTitle } from "@/lib/api"; import { api, errorTitle } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs"; import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys"; import { qk } from "@/lib/keys";
import { deadline, FIELD_LABEL, FIELD_ORDER, fieldText, fmtDateTimeFull, fmtYuan } from "@/lib/labels"; 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"; import { toast } from "@/lib/toast";
const props = defineProps<{ projectId: string }>(); 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 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) { 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> </script>
<template> <template>
<ProjectHead :project="p" tab="overview" /> <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"> <section class="card flush" aria-labelledby="h-facts">
<div class="card-h"> <div class="card-h">
<h2 id="h-facts">项目要点</h2> <h2 id="h-facts">项目要点</h2>
@@ -120,70 +170,50 @@ function openCitation(c: Citation) {
</dl> </dl>
</section> </section>
<aside class="stack"> </div>
<section class="card flush" aria-labelledby="h-lots"> <aside class="split-doc" aria-label="原文">
<div class="card-h"> <DocStage v-model:active="side" :slots="slots" />
<h3 id="h-lots">采购包</h3> </aside>
<RouterLink v-if="!readOnly" class="lnk cap r" :to="{ name: 'lots', params: { projectId } }">{{ realLots.length > 1 ? "更改所投的包" : "调整分包" }}</RouterLink> </div>
</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> </div>
<FieldDialog v-model:open="dlg.open" :project-id="projectId" :field-key="dlg.key" :field="dlg.field" @saved="onSaved" /> <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> </template>
<style scoped> <style scoped>
/* 主列 + 右侧信息栏(--aside-w),同工作台 */ /* 左边:项目要点,下面采购包和关键日期并排;右边原文 */
.ov { .ov {
display: grid;
grid-template-columns: minmax(0, 1fr) var(--aside-w);
gap: var(--gap-card);
align-items: start;
}
.stack {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--gap-card); 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 { .card-h .r {
margin-left: auto; margin-left: auto;
} }
@@ -262,34 +292,12 @@ function openCitation(c: Citation) {
.timeline li { .timeline li {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: var(--sp-1) var(--sp-2); gap: var(--sp-1) var(--sp-3);
align-items: baseline; align-items: baseline;
padding: var(--sp-2) 0; padding: var(--sp-1) 0;
} }
.timeline .when { .timeline .when {
flex-basis: 100%; min-width: 11em;
font-size: var(--fs-figure-sm); 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> </style>

View File

@@ -11,19 +11,20 @@
* TODO(第 6 步):键盘操作、批量处理「提示」、判断有误反馈、导出报告。 * TODO(第 6 步):键盘操作、批量处理「提示」、判断有误反馈、导出报告。
*/ */
import { unwrap } from "@bid-doctor/api-client"; import { unwrap } from "@bid-doctor/api-client";
import { ChevronDown, ChevronRight, Download, X } from "@lucide/vue"; import { ChevronDown, ChevronRight, Download } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query"; import { useQueryClient } from "@tanstack/vue-query";
import { computed, onBeforeUnmount, onMounted, ref } from "vue"; import { computed, onBeforeUnmount, onMounted, ref, watch, watchEffect } from "vue";
import { RouterLink } from "vue-router"; import { RouterLink } from "vue-router";
import CitationBlock from "@/components/CitationBlock.vue"; import CitationBlock from "@/components/CitationBlock.vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue"; import ConfirmDialog from "@/components/ConfirmDialog.vue";
import PdfViewer, { type ViewerTarget } from "@/components/PdfViewer.vue"; import DocStage, { type StageSlot } from "@/components/DocStage.vue";
import type { ViewerTarget } from "@/components/PdfViewer.vue";
import ProjectHead from "@/components/ProjectHead.vue"; import ProjectHead from "@/components/ProjectHead.vue";
import { api, errorTitle, isApiError } from "@/lib/api"; import { api, errorTitle, isApiError } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs"; import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys"; import { qk } from "@/lib/keys";
import { CHECK_STEPS, checkStepIndex, FINDING_ACTION, fmtDateTime, fmtDay, SEVERITY, SEVERITY_CLASS } from "@/lib/labels"; import { CHECK_STEPS, checkStepIndex, FINDING_ACTION, fmtDateTime, fmtDay, SEVERITY, SEVERITY_CLASS } from "@/lib/labels";
import { TERMINAL_JOB, useCheckRun, useFindings, useManualChecks, useProject, type Citation, type Finding, type ManualCheck } from "@/lib/queries"; import { TERMINAL_JOB, useCheckRun, useFindings, useManualChecks, useProject, useProjectDocuments, type Citation, type Finding, type ManualCheck } from "@/lib/queries";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
const props = defineProps<{ projectId: string; runId: string }>(); const props = defineProps<{ projectId: string; runId: string }>();
@@ -217,24 +218,57 @@ async function toggleManual(m: ManualCheck, checked: boolean) {
} }
} }
/* ---------- 原文抽屉 ---------- */ /* ---------- 右侧原文区:招标文件、投标文件左右切换(DocStage),两边各自记着文档和高亮 ---------- */
const viewer = ref<{ doc: { id: string; label: string }; target: ViewerTarget & { id: string } } | null>(null); type Side = "tender" | "bid";
function openCitation(c: Citation) { type Shown = { doc: { id: string; label: string } | null; target: (ViewerTarget & { id: string }) | null };
viewer.value = { const docsQ = useProjectDocuments(() => props.projectId);
const side = ref<Side>("tender");
const shown = ref<Record<Side, Shown>>({ tender: { doc: null, target: null }, bid: { doc: null, target: null } });
// 还没点过出处时,两边先摆上这次检查用的招标文件和第一个投标分册
watchEffect(() => {
const t = docsQ.data.value?.items.find((d) => d.role === "tender" && d.status === "ready");
if (!shown.value.tender.doc && t) shown.value.tender.doc = { id: t.id, label: "招标文件" };
const b = run.value?.documents?.[0];
if (!shown.value.bid.doc && b) shown.value.bid.doc = { id: b.id, label: b.label };
});
const slots = computed<StageSlot[]>(() => [
{ key: "tender", label: "招标文件", ...shown.value.tender, emptyText: "招标文件解析完成后,在这里看原文。" },
{ key: "bid", label: "投标文件", ...shown.value.bid, emptyText: "点问题右侧的投标文件出处,在这里看原文。" },
]);
const activeCite = computed(() => shown.value[side.value].target?.id);
function openCitation(c: Citation, s: Side) {
shown.value[s] = {
doc: { id: c.document_id, label: c.document_label ?? "" }, doc: { id: c.document_id, label: c.document_label ?? "" },
target: { id: c.id, page: c.page_no, quads: c.quads, sha: c.normalized_sha256 }, target: { id: c.id, page: c.page_no, quads: c.quads, sha: c.normalized_sha256 },
}; };
side.value = s;
}
/** 当前条的出处:优先留在正在看的那一边 */
function follow(f: Finding) {
const t = f.tender_citations[0];
const b = f.bid_citations[0];
if (b && (side.value === "bid" || !t)) openCitation(b, "bid");
else if (t) openCitation(t, "tender");
} }
const firstCite = (f: Finding) => f.tender_citations[0] ?? f.bid_citations[0];
/* ---------- 当前条与键盘(规范 §8.2):J / K 移动,O / Enter 看原文,1 / 2 / 3 处理,X 勾选「提示」,U 撤销,Esc 关原文 ---------- */ /* ---------- 当前条与键盘(规范 §8.2):J / K 移动(原文跟着翻),O / Enter 看原文,1 / 2 / 3 处理,X 勾选「提示」,U 撤销 ---------- */
const curId = ref<string | null>(null); const curId = ref<string | null>(null);
const cur = computed(() => todo.value.find((f) => f.id === curId.value) ?? todo.value[0]); const cur = computed(() => todo.value.find((f) => f.id === curId.value) ?? todo.value[0]);
const kbOn = ref(false); const kbOn = ref(false);
function setCur(f: Finding, scroll = false) { function setCur(f: Finding, scroll = false) {
const changed = curId.value !== f.id;
curId.value = f.id; curId.value = f.id;
if (changed) follow(f);
if (scroll) requestAnimationFrame(() => document.querySelector(`[data-fid="${f.id}"]`)?.scrollIntoView({ block: "nearest" })); if (scroll) requestAnimationFrame(() => document.querySelector(`[data-fid="${f.id}"]`)?.scrollIntoView({ block: "nearest" }));
} }
// 第一次加载完:原文翻到第一条问题的出处
watch(
() => cur.value?.id,
(id, old) => {
if (id && !old && cur.value && !curId.value) follow(cur.value);
},
{ immediate: true },
);
/** 处理完跳到下一条待处理 */ /** 处理完跳到下一条待处理 */
function next(f: Finding) { function next(f: Finding) {
const list = todo.value; const list = todo.value;
@@ -245,10 +279,6 @@ function next(f: Finding) {
function onKey(e: KeyboardEvent) { function onKey(e: KeyboardEvent) {
if (e.ctrlKey || e.metaKey || e.altKey || document.querySelector(".scrim")) return; if (e.ctrlKey || e.metaKey || e.altKey || document.querySelector(".scrim")) return;
if ((e.target as HTMLElement | null)?.closest("input, textarea, select, [role=listbox], [contenteditable]")) return; if ((e.target as HTMLElement | null)?.closest("input, textarea, select, [role=listbox], [contenteditable]")) return;
if (e.key === "Escape") {
viewer.value = null;
return;
}
const f = cur.value; const f = cur.value;
if (!f) return; if (!f) return;
const k = e.key.toLowerCase(); const k = e.key.toLowerCase();
@@ -260,14 +290,9 @@ function onKey(e: KeyboardEvent) {
if (!n) return; if (!n) return;
kbOn.value = true; kbOn.value = true;
setCur(n, true); setCur(n, true);
const c = firstCite(n);
if (viewer.value && c) openCitation(c); // 原文开着时跟着翻到当前条的出处
} else if (k === "o" || (e.key === "Enter" && !(e.target as HTMLElement).closest("button, a, label"))) { } else if (k === "o" || (e.key === "Enter" && !(e.target as HTMLElement).closest("button, a, label"))) {
const c = firstCite(f); e.preventDefault();
if (c) { follow(f);
e.preventDefault();
openCitation(c);
}
} else if (readOnly.value) { } else if (readOnly.value) {
return; return;
} else if (k === "u") { } else if (k === "u") {
@@ -369,7 +394,8 @@ const errorText = computed(() => {
<RouterLink class="btn sm" :to="{ name: 'bid-check', params: { projectId } }">重新检查</RouterLink> <RouterLink class="btn sm" :to="{ name: 'bid-check', params: { projectId } }">重新检查</RouterLink>
</div> </div>
<template v-else-if="run"> <div v-else-if="run" class="split">
<div class="split-main">
<div class="rbar"> <div class="rbar">
<div> <div>
<h2>检查结果</h2> <h2>检查结果</h2>
@@ -430,7 +456,7 @@ const errorText = computed(() => {
<input type="checkbox" class="ck" :checked="allPicked" :indeterminate="!allPicked && pickedList.length > 0" :disabled="!hints.length" @change="pickAll(($event.target as HTMLInputElement).checked)" />全选「提示」 <input type="checkbox" class="ck" :checked="allPicked" :indeterminate="!allPicked && pickedList.length > 0" :disabled="!hints.length" @change="pickAll(($event.target as HTMLInputElement).checked)" />全选「提示」
</label> </label>
<span class="cap muted">已处理 <span class="fig">{{ doneCount }}</span> / <span class="fig">{{ live.length }}</span></span> <span class="cap muted">已处理 <span class="fig">{{ doneCount }}</span> / <span class="fig">{{ live.length }}</span></span>
<span class="cap muted keys" title="J / K 上下条 · O 看原文 · 1 确认无误 · 2 已修改 · 3 忽略 · X 勾选「提示」 · U 撤销"><span class="kbd">J</span><span class="kbd">K</span> 上下条 <span class="kbd">1</span><span class="kbd">2</span><span class="kbd">3</span> 处理 <span class="kbd">U</span> 撤销</span> <span class="cap muted hint-u tip-below tip-left" tabindex="0" data-tip="J / K 上下条(右边原文跟着翻) · O 看原文 · 1 确认无误 · 2 已修改 · 3 忽略 · X 勾选「提示」 · U 撤销">快捷键</span>
</div> </div>
</div> </div>
@@ -488,8 +514,8 @@ const errorText = computed(() => {
</div> </div>
</div> </div>
<aside> <aside>
<CitationBlock v-for="c in f.tender_citations" :key="c.id" :citation="c" :active="viewer?.target.id === c.id" @open="openCitation(c)" /> <CitationBlock v-for="c in f.tender_citations" :key="c.id" :citation="c" :active="activeCite === c.id" @open="openCitation(c, 'tender')" />
<CitationBlock v-for="c in f.bid_citations" :key="c.id" :citation="c" :active="viewer?.target.id === c.id" @open="openCitation(c)" /> <CitationBlock v-for="c in f.bid_citations" :key="c.id" :citation="c" :active="activeCite === c.id" @open="openCitation(c, 'bid')" />
<div v-for="(r, i) in librarySnapshots(f)" :key="`lib-${i}`" class="cite lib"> <div v-for="(r, i) in librarySnapshots(f)" :key="`lib-${i}`" class="cite lib">
<RouterLink class="cite-src" :to="{ name: 'qualifications' }">依据:资质库记录</RouterLink> <RouterLink class="cite-src" :to="{ name: 'qualifications' }">依据:资质库记录</RouterLink>
<p><b>{{ r.name }}</b><br />{{ libLine(r) }}</p> <p><b>{{ r.name }}</b><br />{{ libLine(r) }}</p>
@@ -541,11 +567,15 @@ const errorText = computed(() => {
</div> </div>
</div> </div>
<aside> <aside>
<CitationBlock v-if="m.citation" :citation="m.citation" :active="viewer?.target.id === m.citation.id" @open="openCitation(m.citation)" /> <CitationBlock v-if="m.citation" :citation="m.citation" :active="activeCite === m.citation.id" @open="openCitation(m.citation, 'tender')" />
</aside> </aside>
</article> </article>
</section> </section>
</template> </div>
<aside class="split-doc" aria-label="原文">
<DocStage v-model:active="side" :slots="slots" />
</aside>
</div>
<div v-else-if="runQ.error.value" class="alert warn" role="alert"><span class="ico w" aria-hidden="true">!</span><p>{{ errorTitle(runQ.error.value) }}</p></div> <div v-else-if="runQ.error.value" class="alert warn" role="alert"><span class="ico w" aria-hidden="true">!</span><p>{{ errorTitle(runQ.error.value) }}</p></div>
</div> </div>
@@ -561,10 +591,6 @@ const errorText = computed(() => {
</div> </div>
</ConfirmDialog> </ConfirmDialog>
<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> </template>
<style scoped> <style scoped>
@@ -651,9 +677,30 @@ const errorText = computed(() => {
} }
.results .card-h { .results .card-h {
display: flex; display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
gap: var(--sp-2) var(--sp-3);
}
/* 左右布局里问题行改成单列:出处放在说明下面(原文就在右边),文字不被页边批注栏挤窄 */
.finding {
grid-template-columns: minmax(0, 1fr);
gap: var(--sp-3); gap: var(--sp-3);
} }
.finding :deep(.cite:first-child)::before {
display: none;
}
.finding > aside {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2) var(--sp-6);
}
.finding > aside > * {
flex: 1 1 260px;
min-width: 0;
}
.f-head .flag {
white-space: nowrap;
}
.results .card-h .r { .results .card-h .r {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -661,9 +708,6 @@ const errorText = computed(() => {
margin-left: auto; margin-left: auto;
white-space: nowrap; white-space: nowrap;
} }
.keys .kbd {
margin-right: 2px;
}
.flist:focus { .flist:focus {
outline: 0; outline: 0;
} }
@@ -788,27 +832,4 @@ const errorText = computed(() => {
padding: var(--sp-2) var(--card-x); padding: var(--sp-2) var(--card-x);
color: var(--text-secondary); color: var(--text-secondary);
} }
.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> </style>

View File

@@ -70,6 +70,10 @@
.ph .facts dd{display:flex;align-items:center;gap:6px;min-height:var(--lh-body);margin:0;white-space:nowrap} .ph .facts dd{display:flex;align-items:center;gap:6px;min-height:var(--lh-body);margin:0;white-space:nowrap}
.ph .tabs{margin-top:var(--sp-3)} .ph .tabs{margin-top:var(--sp-3)}
.ph + .content{padding-top:var(--sp-5)} .ph + .content{padding-top:var(--sp-5)}
/* 左右布局(查标、概览,同读标页):左边内容随页面滚动,右边原文区贴住视口、自己翻页 */
.split{display:grid;grid-template-columns:minmax(560px,1fr) minmax(400px,45%);gap:var(--gap-card);align-items:start}
.split-main{min-width:0}
.split-doc{position:sticky;top:calc(var(--topbar-h) + var(--sp-2));height:calc(100vh - var(--topbar-h) - var(--sp-2) - var(--sp-12))} /* 减去 .content 的底边距:滚到底时也贴得住,不钻到顶栏下面 */
/* ===================================================================== /* =====================================================================
* 2. 共用小件 * 2. 共用小件