diff --git a/AGENTS.md b/AGENTS.md index db1fbc6..01f2cb8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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-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:查标结果页、概览改成和读标页一样的左右布局(左边内容,右边原文贴住视口),去掉原文侧边抽屉。查标右侧招标文件、投标文件左右切换(`components/DocStage.vue`):当前的占满,另一份露出一窄条(文件名、纸边、带方向箭头的标签,截断处淡入底色),点它或点另一边的出处就滑过去,两边各自记着页和高亮,J / K 时原文跟着翻。概览把关键日期和采购包合成一张卡放到最上面。修了原文查看器直接打开到某页时重复渲染出镜像乱字的问题。内容区四角改为直角(设计规范 §5.3 同步)。 - 下一步:国动真实投标文件(用户晚些提供,到时先读标、请用户看清单,再查标一次;请用户按公司真实证书录资质库;有更正公告一起要);其余可做:第 6 步前端补齐(键盘操作、批量处理、判断有误反馈、日期选择器的日历弹层、概览页的检查记录和文件卡)、M6 剩下的 P0(额度流水、套餐开通、数据删除、运营后台)。 diff --git a/apps/web/src/components/DocStage.vue b/apps/web/src/components/DocStage.vue new file mode 100644 index 0000000..b7ad3f9 --- /dev/null +++ b/apps/web/src/components/DocStage.vue @@ -0,0 +1,156 @@ + + + + + diff --git a/apps/web/src/components/PdfViewer.vue b/apps/web/src/components/PdfViewer.vue index 867bf05..05ad044 100644 --- a/apps/web/src/components/PdfViewer.vue +++ b/apps/web/src/components/PdfViewer.vue @@ -45,6 +45,7 @@ const body = ref(); const canvas = ref(); let task: pdfjs.RenderTask | null = null; let loadSeq = 0; +let renderSeq = 0; async function load(id: string | undefined) { const seq = ++loadSeq; @@ -71,8 +72,13 @@ async function render() { const doc = pdf.value; const el = body.value; 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); + if (seq !== renderSeq) return; const base = p.getViewport({ scale: 1 }); const width = Math.max(320, el.clientWidth - 32); const vp = p.getViewport({ scale: width / base.width }); diff --git a/apps/web/src/pages/OverviewPage.vue b/apps/web/src/pages/OverviewPage.vue index 04794c0..753a7e5 100644 --- a/apps/web/src/pages/OverviewPage.vue +++ b/apps/web/src/pages/OverviewPage.vue @@ -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(() => [{ 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 } }; } diff --git a/apps/web/src/pages/ResultsPage.vue b/apps/web/src/pages/ResultsPage.vue index 3276257..195e5ca 100644 --- a/apps/web/src/pages/ResultsPage.vue +++ b/apps/web/src/pages/ResultsPage.vue @@ -11,19 +11,20 @@ * TODO(第 6 步):键盘操作、批量处理「提示」、判断有误反馈、导出报告。 */ 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 { computed, onBeforeUnmount, onMounted, ref } from "vue"; +import { computed, onBeforeUnmount, onMounted, ref, watch, watchEffect } from "vue"; import { RouterLink } from "vue-router"; import CitationBlock from "@/components/CitationBlock.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 { api, errorTitle, isApiError } from "@/lib/api"; import { useCrumbs } from "@/lib/crumbs"; import { qk } from "@/lib/keys"; 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"; const props = defineProps<{ projectId: string; runId: string }>(); @@ -217,24 +218,57 @@ async function toggleManual(m: ManualCheck, checked: boolean) { } } -/* ---------- 原文抽屉 ---------- */ -const viewer = ref<{ doc: { id: string; label: string }; target: ViewerTarget & { id: string } } | null>(null); -function openCitation(c: Citation) { - viewer.value = { +/* ---------- 右侧原文区:招标文件、投标文件左右切换(DocStage),两边各自记着文档和高亮 ---------- */ +type Side = "tender" | "bid"; +type Shown = { doc: { id: string; label: string } | null; target: (ViewerTarget & { id: string }) | null }; +const docsQ = useProjectDocuments(() => props.projectId); +const side = ref("tender"); +const shown = ref>({ 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(() => [ + { 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 ?? "" }, 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(null); const cur = computed(() => todo.value.find((f) => f.id === curId.value) ?? todo.value[0]); const kbOn = ref(false); function setCur(f: Finding, scroll = false) { + const changed = curId.value !== f.id; curId.value = f.id; + if (changed) follow(f); 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) { const list = todo.value; @@ -245,10 +279,6 @@ function next(f: Finding) { function onKey(e: KeyboardEvent) { 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.key === "Escape") { - viewer.value = null; - return; - } const f = cur.value; if (!f) return; const k = e.key.toLowerCase(); @@ -260,14 +290,9 @@ function onKey(e: KeyboardEvent) { if (!n) return; kbOn.value = 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"))) { - const c = firstCite(f); - if (c) { - e.preventDefault(); - openCitation(c); - } + e.preventDefault(); + follow(f); } else if (readOnly.value) { return; } else if (k === "u") { @@ -369,7 +394,8 @@ const errorText = computed(() => { 重新检查 - + + + @@ -561,10 +591,6 @@ const errorText = computed(() => { - diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css index 08fe334..f55940c 100644 --- a/apps/web/src/styles/app.css +++ b/apps/web/src/styles/app.css @@ -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 .tabs{margin-top:var(--sp-3)} .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. 共用小件