feat(ui): 报告预览改用 pdf.js、读标页左右布局、「导出」改称「生成」
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled

- 报告页:预览从浏览器自带 PDF 查看器(深色、改不了配色)换成 pdf.js 连续滚动(components/ReportViewer.vue),顶栏带下载;
  「导出 PDF」改为「生成报告 / 重新生成报告」,列表改称「已生成的报告」,最新一份之后又检查或致命数变了时提示重新生成
- 报告 PDF 模板 2026.11-v4:「导出人 / 导出时」改为「生成」;其余页面的「导出的报告」同步改称
- 读标页:改成和查标、概览一样的左右布局,项目头和清单栏滚出屏幕;分类导航、搜索栏和卡片上沿贴在顶栏下方留出间距
- 读标页:响应方式收进清单栏「响应方式」按钮的浮层,招标文件没写明或写法不一致且没人改过时按钮上加提示点;清单状态改为标题下一行
- 概览检查记录:报告列只写份数和「查看报告」,跳到报告标签页并预览那次检查最新的一份
- 招标 / 投标文件切换标签:双箭头放在文字两头,方向改为指向展开的方向
- 项目头:投标截止、状态、就绪三项取值同高对齐

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-05 15:57:00 +08:00
parent dd8c04001d
commit 0b783ababa
17 changed files with 455 additions and 190 deletions

View File

@@ -113,7 +113,7 @@ async function logout() {
<!-- 套餐到期后只读(决策 M6-5):每一页顶上都提醒,额度页里有续费入口 -->
<div v-if="plan?.read_only && route.name !== 'quota'" class="expired-bar" role="note">
<span class="ico w" aria-hidden="true">!</span>
<p>专业版已到期,数据只读:可以查看、下载已导出的报告,不能新建项目、解析和检查。</p>
<p>专业版已到期,数据只读:可以查看、下载已生成的报告,不能新建项目、解析和检查。</p>
<RouterLink class="btn sm" :to="{ name: 'quota' }">续费</RouterLink>
</div>
<RouterView />

View File

@@ -5,7 +5,7 @@
* 点露出的那一条、或点到另一边的出处,就滑过去,原来那个从另一侧露出一窄条。两边各自记着翻到的页和高亮。
* 露出的那一条在截断处淡入页面底色,不留硬边;标签上的箭头指向文件所在的一侧,悬停时文件先往外探出一点。
*/
import { ChevronLeft, ChevronRight } from "@lucide/vue";
import { ChevronsLeft, ChevronsRight } from "@lucide/vue";
import { computed } from "vue";
import PdfViewer, { type ViewerTarget } from "@/components/PdfViewer.vue";
@@ -40,9 +40,11 @@ const index = computed(() => Math.max(0, props.slots.findIndex((s) => s.key ===
: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" />
<component :is="i < index ? ChevronsRight : ChevronsLeft" class="ic" :stroke-width="1.75" aria-hidden="true" />
<span class="t">{{ s.label }}</span>
<component :is="i < index ? ChevronsRight : ChevronsLeft" class="ic" :stroke-width="1.75" aria-hidden="true" />
</span>
</button>
</div>
@@ -122,7 +124,7 @@ const index = computed(() => Math.max(0, props.slots.findIndex((s) => s.key ===
flex-direction: column;
align-items: center;
gap: var(--sp-1);
padding: var(--sp-2) 3px var(--sp-3);
padding: var(--sp-2) 3px;
border: var(--line) solid var(--border-subtle);
border-radius: var(--radius-ctl);
background: var(--bg-surface);
@@ -145,10 +147,10 @@ const index = computed(() => Math.max(0, props.slots.findIndex((s) => s.key ===
border-color: var(--c-ink);
}
.peek.left:hover .ic {
transform: translateX(-2px);
transform: translateX(2px);
}
.peek.right:hover .ic {
transform: translateX(2px);
transform: translateX(-2px);
}
.peek:focus-visible {
outline: 0;

View File

@@ -133,7 +133,7 @@ const checkTo = computed(() =>
<dt>就绪</dt>
<dd>
<span v-if="p.readiness.ready" class="hint-u tip-below tip-left" :data-tip="READY_TIP" tabindex="0">就绪</span>
<span v-else class="hint-u tip-below tip-left" :data-tip="`还有 ${p.readiness.open_fatal_count} 个致命问题没处理。不影响导出,决定权在你。`" tabindex="0">未就绪</span>
<span v-else class="hint-u tip-below tip-left" :data-tip="`还有 ${p.readiness.open_fatal_count} 个致命问题没处理。不影响生成报告和递交,决定权在你。`" tabindex="0">未就绪</span>
</dd>
</div>
</dl>

View File

@@ -26,7 +26,7 @@ const home = computed<RouteLocationRaw>(() => {
return { name: "results", params: { projectId: id, runId: p.value.latest_check_run_id } };
return { name: "checklist", params: { projectId: id } };
});
/** 「下一步」:导出报告去报告页,其余同默认入口 */
/** 「下一步」:生成报告去报告页,其余同默认入口 */
const next = computed<RouteLocationRaw>(() =>
p.value.next_action === "export_report" ? { name: "report", params: { projectId: p.value.id } } : home.value,
);
@@ -50,7 +50,7 @@ const next = computed<RouteLocationRaw>(() =>
<span class="hint-u tip-below" tabindex="0" :data-tip="READY_TIP">就绪</span>
</template>
<template v-else-if="p.readiness.ready === false">
<span class="hint-u tip-below" tabindex="0" :data-tip="`还有 ${p.readiness.open_fatal_count} 个致命问题没处理。不影响导出,决定权在你。`">未就绪</span>
<span class="hint-u tip-below" tabindex="0" :data-tip="`还有 ${p.readiness.open_fatal_count} 个致命问题没处理。不影响生成报告和递交,决定权在你。`">未就绪</span>
</template>
</span>
<span class="c">

View File

@@ -0,0 +1,220 @@
<script setup lang="ts">
/**
* 报告预览:用 pdf.js 把生成的 PDF 逐页画出来,连续滚动,宽度撑满。
* 不用浏览器自带的 PDF 查看器(iframe):Chrome 的是深色工具栏,改不了配色,桌面客户端里也各不一样;
* 这里和原文查看器(PdfViewer)同一套外观。只看不改;下载按钮放在 actions 插槽(顶栏里)。
* 页面多时只画滚到眼前的那几页(IntersectionObserver),改宽度时重画已画过的页。
*/
import { ChevronLeft, ChevronRight } from "@lucide/vue";
import * as pdfjs from "pdfjs-dist";
import workerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
import { markRaw, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from "vue";
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
const props = defineProps<{ url?: string | null; label?: string }>();
const pdf = shallowRef<pdfjs.PDFDocumentProxy | null>(null);
const loadErr = ref("");
/** 每页按查看器宽度缩放后的尺寸;没画之前先用它占位,滚动条长度一开始就对 */
const sizes = ref<{ w: number; h: number }[]>([]);
const page = ref(1);
const body = ref<HTMLElement>();
const canvases: HTMLCanvasElement[] = [];
const drawn = new Map<number, number>(); // 页号 → 画的时候的宽度
const tasks = new Map<number, pdfjs.RenderTask>();
let io: IntersectionObserver | null = null;
let loadSeq = 0;
async function load(url: string | null | undefined) {
const seq = ++loadSeq;
reset();
await pdf.value?.loadingTask.destroy();
pdf.value = null;
loadErr.value = "";
if (!url) return;
try {
const res = await fetch(url);
if (!res.ok) throw new Error(String(res.status));
const doc = await pdfjs.getDocument({ data: new Uint8Array(await res.arrayBuffer()) }).promise;
if (seq !== loadSeq) return void doc.loadingTask.destroy();
pdf.value = markRaw(doc);
page.value = 1;
await layout();
body.value?.scrollTo({ top: 0 });
} catch {
if (seq === loadSeq) loadErr.value = "报告加载失败,请刷新后重试";
}
}
function reset() {
for (const t of tasks.values()) t.cancel();
tasks.clear();
drawn.clear();
sizes.value = [];
}
/** 按当前宽度算每页尺寸,再观察每页、滚到眼前时才画 */
async function layout() {
const doc = pdf.value;
const el = body.value;
if (!doc || !el) return;
const width = Math.max(320, el.clientWidth - 32);
const out: { w: number; h: number }[] = [];
for (let n = 1; n <= doc.numPages; n++) {
const vp = (await doc.getPage(n)).getViewport({ scale: 1 });
out.push({ w: width, h: Math.round((vp.height * width) / vp.width) });
}
if (doc !== pdf.value) return;
sizes.value = out;
await nextTick();
io?.disconnect();
io = new IntersectionObserver(onIntersect, { root: el, rootMargin: "400px 0px" });
el.querySelectorAll(".page").forEach((p) => io!.observe(p));
}
function onIntersect(entries: IntersectionObserverEntry[]) {
for (const e of entries) if (e.isIntersecting) void draw(Number((e.target as HTMLElement).dataset.n));
}
async function draw(n: number) {
const doc = pdf.value;
const c = canvases[n - 1];
const size = sizes.value[n - 1];
if (!doc || !c || !size || drawn.get(n) === size.w) return;
drawn.set(n, size.w);
tasks.get(n)?.cancel();
const p = await doc.getPage(n);
const base = p.getViewport({ scale: 1 });
const vp = p.getViewport({ scale: size.w / base.width });
const dpr = window.devicePixelRatio || 1;
c.width = Math.floor(vp.width * dpr);
c.height = Math.floor(vp.height * dpr);
const task = p.render({ canvas: c, viewport: vp, transform: dpr === 1 ? undefined : [dpr, 0, 0, dpr, 0, 0] });
tasks.set(n, task);
try {
await task.promise;
} catch {
if (drawn.get(n) === size.w) drawn.delete(n); // 被取消的下次再画
} finally {
if (tasks.get(n) === task) tasks.delete(n);
}
}
/* ---------- 当前页(滚动时跟着变)、翻页 ---------- */
function onScroll() {
const el = body.value;
if (!el) return;
const mid = el.getBoundingClientRect().top + el.clientHeight / 3;
const pages = el.querySelectorAll<HTMLElement>(".page");
let cur = 1;
pages.forEach((p, i) => {
if (p.getBoundingClientRect().top <= mid) cur = i + 1;
});
page.value = cur;
}
function go(n: number) {
const el = body.value;
const target = el?.querySelectorAll<HTMLElement>(".page")[n - 1];
if (!el || !target) return;
el.scrollTo({ top: target.offsetTop - 16 });
}
watch(
() => props.url,
(u) => void load(u),
);
let ro: ResizeObserver | null = null;
let lastW = 0;
let timer = 0;
onMounted(() => {
void load(props.url);
ro = new ResizeObserver(([e]) => {
const w = Math.round(e?.contentRect.width ?? 0);
if (!w || Math.abs(w - lastW) < 8) return;
lastW = w;
clearTimeout(timer);
timer = window.setTimeout(() => {
const keep = page.value;
void layout().then(() => go(keep));
}, 120);
});
if (body.value) ro.observe(body.value);
});
onBeforeUnmount(() => {
ro?.disconnect();
io?.disconnect();
reset();
void pdf.value?.loadingTask.destroy();
});
</script>
<template>
<section class="viewer rv" aria-label="报告预览">
<div class="viewer-bar">
<span class="doc-name"><b>{{ label || "检查报告" }}</b></span>
<slot name="actions" />
<div class="pager">
<span v-if="sizes.length" class="fig">第 {{ page }} / {{ sizes.length }} 页</span>
<button type="button" class="icon-btn sm" aria-label="上一页" :disabled="page <= 1" @click="go(page - 1)">
<ChevronLeft class="ic" :stroke-width="1.5" />
</button>
<button type="button" class="icon-btn sm" aria-label="下一页" :disabled="page >= sizes.length" @click="go(page + 1)">
<ChevronRight class="ic" :stroke-width="1.5" />
</button>
</div>
</div>
<div ref="body" class="viewer-body" @scroll.passive="onScroll">
<slot v-if="!url" name="empty" />
<p v-else-if="loadErr" class="viewer-empty">{{ loadErr }}</p>
<p v-else-if="!sizes.length" class="viewer-empty">正在加载报告…</p>
<div v-for="(s, i) in sizes" :key="i" class="page" :data-n="i + 1" :style="{ width: `${s.w}px`, height: `${s.h}px` }">
<canvas :ref="(c) => (canvases[i] = c as HTMLCanvasElement)" :style="{ width: `${s.w}px`, height: `${s.h}px` }"></canvas>
</div>
</div>
</section>
</template>
<style scoped>
.rv {
height: 100%;
}
.doc-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.pager .fig {
margin-right: var(--sp-2);
font-size: var(--fs-figure-xs);
color: var(--text-secondary);
}
.viewer-body {
position: relative;
flex: 1;
min-height: 0;
overflow: auto;
padding: var(--sp-4);
}
.viewer-empty {
margin: var(--sp-8) var(--sp-6) 0;
color: var(--text-secondary);
font-size: var(--fs-caption);
line-height: var(--lh-caption);
text-align: center;
}
.page {
margin: 0 auto;
background: var(--c-white);
box-shadow: var(--shadow-sheet);
}
.page + .page {
margin-top: var(--sp-4);
}
.page canvas {
display: block;
}
</style>