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

@@ -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>();
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 });