+
+
+
+
+
+
关键日期
+
招标文件解析后,这里列出投标截止、开标等日期
+
+ -
+ {{ fmtDateTimeFull(d.at) }}
+ {{ d.label }}
+ 已修改
+ {{ d.due.main }}
+ 已被补遗更新,待确认
+ 已按{{ d.replaces.document_label }} 更新 · 原为 {{ d.replaces.at ? fmtDateTimeFull(d.replaces.at) : "—" }}
+
+
+
+
+
+
采购包
+ {{ realLots.length > 1 ? "更改所投的包" : "调整分包" }}
+
+
不分包:清单里的条目都适用
+
+ -
+ 采购包 {{ l.lot_no }}
+ {{ l.is_bidding ? "投" : "不投" }}
+ {{ l.name }}
+
+ 预算 {{ fmtYuan(l.budget_amount) }}
+ ·
+ 最高限价 {{ fmtYuan(l.ceiling_amount) }}
+ · 已修改
+
+
+
+
限价是招标文件里的原文数字,不是报价建议。
+
+
+
项目要点
@@ -120,70 +170,50 @@ function openCitation(c: Citation) {
-
+
+
+
-
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(() => {
重新检查