feat(ui): 查标、概览改为左右布局;招标文件、投标文件左右切换
- 查标结果页、概览和读标页一样:左边内容随页面滚动,右边原文贴住视口,去掉原文侧边抽屉 - DocStage:查标右侧招标文件、投标文件横向排开,当前的占满,另一份露出 80px (文件名移到露出的一侧、纸边、带方向箭头的竖排标签,截断处淡入页面底色,悬停时探出一点); 点露出的一条或另一边的出处就滑过去,两边各自记着页和高亮 - 查标:进页面翻到第一条问题的出处,J / K 时原文跟着翻,优先留在正在看的一边; 左边变窄后问题行改单列,出处放在说明下面,快捷键提示收成「快捷键」 - 概览:关键日期和采购包合成一张卡放到最上面,项目要点在下 - 原文查看器:直接打开到某页时会并发两次渲染、叠出镜像乱字,改为后一次等前一次停下 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
156
apps/web/src/components/DocStage.vue
Normal file
156
apps/web/src/components/DocStage.vue
Normal 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>
|
||||
@@ -45,6 +45,7 @@ const body = ref<HTMLElement>();
|
||||
const canvas = ref<HTMLCanvasElement>();
|
||||
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 });
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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<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 ?? "" },
|
||||
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 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(() => {
|
||||
<RouterLink class="btn sm" :to="{ name: 'bid-check', params: { projectId } }">重新检查</RouterLink>
|
||||
</div>
|
||||
|
||||
<template v-else-if="run">
|
||||
<div v-else-if="run" class="split">
|
||||
<div class="split-main">
|
||||
<div class="rbar">
|
||||
<div>
|
||||
<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)" />全选「提示」
|
||||
</label>
|
||||
<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>
|
||||
|
||||
@@ -488,8 +514,8 @@ const errorText = computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
<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.bid_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="activeCite === c.id" @open="openCitation(c, 'bid')" />
|
||||
<div v-for="(r, i) in librarySnapshots(f)" :key="`lib-${i}`" class="cite lib">
|
||||
<RouterLink class="cite-src" :to="{ name: 'qualifications' }">依据:资质库记录</RouterLink>
|
||||
<p><b>{{ r.name }}</b><br />{{ libLine(r) }}</p>
|
||||
@@ -541,11 +567,15 @@ const errorText = computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</article>
|
||||
</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>
|
||||
|
||||
@@ -561,10 +591,6 @@ const errorText = computed(() => {
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<style scoped>
|
||||
@@ -651,9 +677,30 @@ const errorText = computed(() => {
|
||||
}
|
||||
.results .card-h {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--sp-2) var(--sp-3);
|
||||
}
|
||||
/* 左右布局里问题行改成单列:出处放在说明下面(原文就在右边),文字不被页边批注栏挤窄 */
|
||||
.finding {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -661,9 +708,6 @@ const errorText = computed(() => {
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.keys .kbd {
|
||||
margin-right: 2px;
|
||||
}
|
||||
.flist:focus {
|
||||
outline: 0;
|
||||
}
|
||||
@@ -788,27 +832,4 @@ const errorText = computed(() => {
|
||||
padding: var(--sp-2) var(--card-x);
|
||||
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>
|
||||
|
||||
@@ -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. 共用小件
|
||||
|
||||
Reference in New Issue
Block a user