feat(ui): 报告预览改用 pdf.js、读标页左右布局、「导出」改称「生成」
- 报告页:预览从浏览器自带 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:
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
220
apps/web/src/components/ReportViewer.vue
Normal file
220
apps/web/src/components/ReportViewer.vue
Normal 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>
|
||||
Reference in New Issue
Block a user