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 });
|
||||
|
||||
Reference in New Issue
Block a user