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:
@@ -240,4 +240,5 @@ samples/
|
||||
- 运营后台:独立进程 `bid-doctor ops` 和 `apps/admin`,用户名 + 密码 + TOTP + IP 白名单,可以处理开通申请、查企业、调额度、看误报 / 漏报、失败任务和审计,不看业务内容(`ops-api.md`)。新增数据库角色 `bd_ops`,迁移 0010、0011。
|
||||
- 顺手修了取消解析 / 检查时接口报 500 的问题。后端 156 个测试,浏览器从注册走到注销。
|
||||
- 2026-11-05:运营后台第二版(M6-9、M6-10):左侧菜单(标准 logo、可展开收起的分组、图标、待办数、环境标签),运营人员角色只留结构(`ops_admin.role`、权限点、每个接口的检查点、菜单按权限过滤,现在只有管理员),迁移 0012。
|
||||
- 2026-11-05:前端走查修改。报告预览改用 pdf.js(浏览器自带的 PDF 查看器是深色的,改不了);读标页改成和查标一样的左右布局(项目头滚出屏幕,清单卡片和原文区贴住顶栏),响应方式收进清单栏的按钮;报告页「导出」统一改称「生成」(生成一份存进项目,下载才是存到电脑),PDF 模板 2026.11-v4;概览检查记录的报告列只写份数和「查看报告」;招标 / 投标文件切换标签改为双箭头、指向展开方向;项目头三项取值对齐。
|
||||
- 下一步:可做的有:读标抽取专项优化(`extract-status.md`)、更多真实投标文件、M6 的 P1(协作权限、资产库)。
|
||||
|
||||
@@ -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>
|
||||
@@ -138,7 +138,7 @@ export const NEXT_ACTION: Dict<Schemas["NextAction"]> = {
|
||||
upload_bid: "上传投标文件",
|
||||
run_check: "发起检查",
|
||||
handle_fatal: "处理致命问题",
|
||||
export_report: "导出检查报告",
|
||||
export_report: "生成检查报告",
|
||||
archive: "归档项目",
|
||||
none: "查看项目",
|
||||
};
|
||||
|
||||
@@ -10,8 +10,9 @@
|
||||
* - 资格条件、评分项里的证书要求:写资质库里有没有、在投标截止日是否有效(资质库空的不写)。
|
||||
*/
|
||||
import { unwrap } from "@bid-doctor/api-client";
|
||||
import { FileText, Info, Plus, Search } from "@lucide/vue";
|
||||
import { FileText, Info, Plus, Search, SlidersHorizontal } from "@lucide/vue";
|
||||
import { useQuery, useQueryClient } from "@tanstack/vue-query";
|
||||
import { PopoverContent, PopoverPortal, PopoverRoot, PopoverTrigger } from "reka-ui";
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { RouterLink, useRoute, useRouter } from "vue-router";
|
||||
import ChecklistItemRow from "@/components/ChecklistItemRow.vue";
|
||||
@@ -196,6 +197,8 @@ const MODE_OPTIONS = [
|
||||
];
|
||||
const MODE_STATUS: Record<string, string> = { unknown: "招标文件没写明,按逐条响应处理", conflict: "招标文件两处写法不一致,按逐条响应处理" };
|
||||
const modeBusy = ref("");
|
||||
// 响应方式一个项目设一次,平时收在清单栏的按钮里;招标文件没写明或两处不一致、又没人改过时,按钮上加提示点
|
||||
const modeAttn = computed(() => (cl.value?.response_modes ?? []).some((m) => m.origin !== "human" && !!MODE_STATUS[m.status]));
|
||||
async function setMode(category: "technical" | "commercial", mode: "explicit" | "implicit") {
|
||||
modeBusy.value = category;
|
||||
try {
|
||||
@@ -347,28 +350,54 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="reading">
|
||||
<ProjectHead :project="p" tab="checklist" />
|
||||
|
||||
<div class="content">
|
||||
<div class="content split">
|
||||
<div class="split-main">
|
||||
<div class="cl-bar">
|
||||
<h2>项目检查清单</h2>
|
||||
<div v-if="cl" class="status">
|
||||
<template v-if="cl.status === 'confirmed'">
|
||||
<span class="tag">已确认 v{{ cl.version_no }}</span>
|
||||
<span v-if="cl.confirmed_by" class="sign">
|
||||
<div class="cl-title">
|
||||
<h2>项目检查清单<span v-if="cl" class="tag">{{ cl.status === "confirmed" ? `已确认 v${cl.version_no}` : "草稿" }}</span></h2>
|
||||
<p v-if="cl" class="status">
|
||||
<span v-if="cl.status === 'confirmed' && cl.confirmed_by" class="sign cap">
|
||||
<span aria-hidden="true">✓</span><b>{{ cl.confirmed_by.name }}</b><span v-if="cl.confirmed_at" class="fig">{{ fmtDateTime(cl.confirmed_at) }}</span><span>确认</span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="tag">草稿</span>
|
||||
<span class="cap muted">{{ cl.version_no ? `有修改,待重新确认 · 上一版 v${cl.version_no} 已确认` : "还没确认过;确认后,查标以这一版清单为依据" }}</span>
|
||||
</template>
|
||||
<span v-else-if="cl.status !== 'confirmed'" class="cap muted">{{ cl.version_no ? `有修改,待重新确认 · 上一版 v${cl.version_no} 已确认` : "还没确认过;确认后,查标以这一版清单为依据" }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="cl && !readOnly" class="actions">
|
||||
<div v-if="cl" class="actions">
|
||||
<PopoverRoot v-if="cl.response_modes?.length">
|
||||
<PopoverTrigger as-child>
|
||||
<button type="button" class="btn mode-btn" :aria-label="modeAttn ? '响应方式(有待确认的)' : '响应方式'">
|
||||
<SlidersHorizontal class="ic" :stroke-width="1.5" />响应方式<span v-if="modeAttn" class="dot" aria-hidden="true"></span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverPortal>
|
||||
<PopoverContent class="menu popper modes-pop" align="end" :side-offset="6" aria-label="招标文件的响应方式">
|
||||
<p class="cap muted">招标文件要求逐条响应,还是只列偏离、没列出的视为接受。查标按它判断没找到响应算不算问题。</p>
|
||||
<div v-for="m in cl.response_modes" :key="m.category" class="mode">
|
||||
<label :for="`mode-${m.category}`">{{ MODE_CATEGORY[m.category] }}</label>
|
||||
<SelectBox
|
||||
:id="`mode-${m.category}`"
|
||||
size="sm"
|
||||
:model-value="m.mode"
|
||||
:options="MODE_OPTIONS"
|
||||
:disabled="readOnly || modeBusy === m.category"
|
||||
@update:model-value="(v) => v !== m.mode && setMode(m.category, v as 'explicit' | 'implicit')"
|
||||
/>
|
||||
<div class="mode-meta">
|
||||
<button v-if="m.citations.length" type="button" class="cite-src inline" @click="openCitation(m.citations[0]!)">依据 · 第 <span class="fig">{{ m.citations[0]!.page_no }}</span> 页</button>
|
||||
<span v-if="m.origin === 'human'" class="sign cap"><span aria-hidden="true">✓</span><b>{{ m.set_by?.name }}</b><span>改过</span></span>
|
||||
<span v-else-if="MODE_STATUS[m.status]" class="hint-warn cap">{{ MODE_STATUS[m.status] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</PopoverRoot>
|
||||
<template v-if="!readOnly">
|
||||
<RouterLink v-if="tender" class="btn" :to="{ name: 'project-new', query: { project: projectId, addendum: '1' } }"><Plus class="ic" :stroke-width="1.5" />追加补遗</RouterLink>
|
||||
<button type="button" class="btn" :disabled="!all.length" @click="openDialog('create')"><Plus class="ic" :stroke-width="1.5" />新增条目</button>
|
||||
<button v-if="cl.status !== 'confirmed'" type="button" class="btn primary" :disabled="!formal.length" @click="confirmOpen = true">确认清单</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -395,24 +424,6 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
|
||||
<RouterLink v-if="!readOnly" class="lnk cap" :to="{ name: 'lots', params: { projectId } }">更改所投的包</RouterLink>
|
||||
</div>
|
||||
|
||||
<div v-if="cl?.response_modes?.length" class="modes" aria-label="招标文件的响应方式">
|
||||
<span class="cap muted">响应方式</span>
|
||||
<div v-for="m in cl.response_modes" :key="m.category" class="mode">
|
||||
<label :for="`mode-${m.category}`">{{ MODE_CATEGORY[m.category] }}</label>
|
||||
<SelectBox
|
||||
:id="`mode-${m.category}`"
|
||||
size="sm"
|
||||
:model-value="m.mode"
|
||||
:options="MODE_OPTIONS"
|
||||
:disabled="readOnly || modeBusy === m.category"
|
||||
@update:model-value="(v) => v !== m.mode && setMode(m.category, v as 'explicit' | 'implicit')"
|
||||
/>
|
||||
<button v-if="m.citations.length" type="button" class="cite-src inline" @click="openCitation(m.citations[0]!)">依据 · 第 <span class="fig">{{ m.citations[0]!.page_no }}</span> 页</button>
|
||||
<span v-if="m.origin === 'human'" class="sign cap"><span aria-hidden="true">✓</span><b>{{ m.set_by?.name }}</b><span>改过</span></span>
|
||||
<span v-else-if="MODE_STATUS[m.status]" class="hint-warn cap">{{ MODE_STATUS[m.status] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="w in cl?.warnings ?? []" :key="w.code" class="alert warn warn-row" role="note">
|
||||
<span class="ico w" aria-hidden="true">!</span>
|
||||
<p>{{ w.message }}</p>
|
||||
@@ -422,8 +433,8 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
|
||||
<p>项目已{{ projectStatus(p!.status) }},清单只读。要修改,先撤销{{ p!.status === "archived" ? "归档" : "递交" }}。</p>
|
||||
</div>
|
||||
|
||||
<div class="cmp">
|
||||
<section class="card flush cmp-list" aria-label="项目检查清单">
|
||||
<div class="cap-edge" aria-hidden="true"></div>
|
||||
<nav class="cats" aria-label="清单分类">
|
||||
<button type="button" :aria-current="cat === 'all'" @click="cat = 'all'">全部<span class="fig">{{ formal.length }}</span></button>
|
||||
<button v-for="c in cats" :key="c" type="button" :aria-current="cat === c" @click="cat = c">{{ category(c) }}<span class="fig">{{ catCount(c) }}</span></button>
|
||||
@@ -549,10 +560,11 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside class="split-doc" aria-label="原文">
|
||||
<PdfViewer
|
||||
v-model:picked="picked"
|
||||
class="cmp-viewer"
|
||||
:document-id="viewDoc?.id"
|
||||
:document-label="viewDoc?.label"
|
||||
:target="target"
|
||||
@@ -574,8 +586,7 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
|
||||
</div>
|
||||
</template>
|
||||
</PdfViewer>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<ItemDialog v-model:open="dlg.open" :mode="dlg.mode" :item="dlg.item" :lots="realLots" :default-category="cat !== 'all' && cat !== 'pending' && cat !== 'deleted' ? cat : undefined" @submit="onSubmit" @pick="onDialogPick" />
|
||||
@@ -603,35 +614,48 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
|
||||
align-items: baseline;
|
||||
margin-bottom: var(--sp-3);
|
||||
}
|
||||
/* 响应方式(C12):清单栏下面一行,每类一个选择框 */
|
||||
.modes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--sp-2) var(--sp-6);
|
||||
margin-bottom: var(--sp-3);
|
||||
/* 响应方式(C12):收在清单栏「响应方式」按钮的浮层里 */
|
||||
.mode-btn {
|
||||
position: relative;
|
||||
}
|
||||
.modes .mode {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
.mode-btn .dot {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--warn);
|
||||
box-shadow: 0 0 0 2px var(--bg-surface);
|
||||
}
|
||||
.modes label {
|
||||
[data-reka-popper-content-wrapper] :deep(.modes-pop) {
|
||||
width: 380px;
|
||||
max-height: none;
|
||||
padding: var(--sp-4);
|
||||
}
|
||||
.modes-pop > p {
|
||||
margin: 0;
|
||||
}
|
||||
.modes-pop .mode {
|
||||
display: grid;
|
||||
gap: var(--sp-1);
|
||||
margin-top: var(--sp-4);
|
||||
}
|
||||
.modes-pop label {
|
||||
font-size: var(--fs-caption);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.modes :deep(.select) {
|
||||
width: 236px;
|
||||
.modes-pop .mode-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--sp-1) var(--sp-3);
|
||||
}
|
||||
.modes .sign {
|
||||
flex: none;
|
||||
flex-wrap: nowrap;
|
||||
.modes-pop .mode-meta:empty {
|
||||
display: none;
|
||||
}
|
||||
.modes-pop .sign {
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.modes > .cap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* 清单栏:标题 + 清单状态;右侧页面级操作(主按钮一个,Q12) */
|
||||
.cl-bar {
|
||||
@@ -640,20 +664,30 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
|
||||
gap: var(--sp-3);
|
||||
margin-bottom: var(--sp-3);
|
||||
}
|
||||
.cl-title {
|
||||
min-width: 0;
|
||||
}
|
||||
.cl-bar h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
margin: 0;
|
||||
font: var(--fw-bold) var(--fs-title) / var(--lh-title) var(--font-sans);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cl-bar h2 .tag {
|
||||
font-weight: var(--fw-regular);
|
||||
}
|
||||
.cl-bar .status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
margin: 2px 0 0;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.cl-bar .status .sign {
|
||||
display: inline-flex;
|
||||
flex-wrap: nowrap;
|
||||
margin: 0;
|
||||
}
|
||||
.cl-bar .actions {
|
||||
@@ -665,38 +699,51 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
|
||||
.warn-row {
|
||||
margin-bottom: var(--sp-3);
|
||||
}
|
||||
/* 整屏布局(原型 shell fit):页面不滚动,清单和原文查看器各自滚动 */
|
||||
.reading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - var(--topbar-h));
|
||||
}
|
||||
.reading > .content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: var(--sp-4);
|
||||
}
|
||||
.cmp {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(560px, 1fr) minmax(400px, 45%);
|
||||
gap: var(--gap-card);
|
||||
}
|
||||
/* 左右布局(同查标、概览):清单随页面滚动,项目头和清单栏滚出屏幕,只留顶栏;
|
||||
分类导航、搜索栏贴住(和右边原文区一样离顶栏 --sp-2),右边原文区贴住视口。
|
||||
卡片不裁切(overflow: visible),里面的 sticky 才跟着页面走 */
|
||||
.cmp-list {
|
||||
min-height: 0;
|
||||
--stick: calc(var(--topbar-h) + var(--sp-2) + var(--line));
|
||||
overflow: visible;
|
||||
}
|
||||
/* 贴住时的卡片上沿:一条 0 高的 sticky 横条,画出圆角上边框;
|
||||
外面一圈页面底色,盖住从顶栏和卡片之间滚过去的内容,圆角外也不露出底下的条目 */
|
||||
.cap-edge {
|
||||
grid-column: 1 / -1;
|
||||
position: sticky;
|
||||
top: var(--stick);
|
||||
z-index: 3;
|
||||
height: 0;
|
||||
}
|
||||
.cap-edge::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: calc(-1 * var(--line));
|
||||
left: calc(-1 * var(--line));
|
||||
right: calc(-1 * var(--line));
|
||||
height: calc(var(--radius-card) + var(--line));
|
||||
border: var(--line) solid var(--border-subtle);
|
||||
border-bottom: 0;
|
||||
border-radius: var(--radius-card) var(--radius-card) 0 0;
|
||||
box-shadow: 0 0 0 var(--sp-3) var(--bg-page);
|
||||
clip-path: inset(calc(-1 * var(--sp-3)) calc(-1 * var(--sp-3)) 0);
|
||||
pointer-events: none;
|
||||
}
|
||||
.cmp-list > .lst {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
.cmp-viewer {
|
||||
min-height: 0;
|
||||
border-left: var(--line) solid var(--border-subtle);
|
||||
}
|
||||
.cats {
|
||||
position: sticky;
|
||||
top: var(--stick);
|
||||
align-self: start;
|
||||
max-height: calc(100vh - var(--stick));
|
||||
overflow: auto;
|
||||
border-right: 0;
|
||||
}
|
||||
/* J / K 翻到的条目不钻到顶栏和搜索栏下面 */
|
||||
.lst :deep([data-item]) {
|
||||
scroll-margin-top: calc(var(--topbar-h) + 72px);
|
||||
scroll-margin-bottom: var(--sp-4);
|
||||
}
|
||||
.cats .hint {
|
||||
margin: var(--sp-3) 0 0;
|
||||
@@ -709,7 +756,7 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
|
||||
}
|
||||
.lst-tb {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
top: var(--stick);
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -717,7 +764,6 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
|
||||
padding: var(--sp-3) var(--card-x);
|
||||
background: var(--bg-surface);
|
||||
border-bottom: var(--line) solid var(--border-subtle);
|
||||
border-top-right-radius: var(--radius-card);
|
||||
}
|
||||
.lst-tb .search {
|
||||
flex: 1;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* 补遗(FR-M2-10):招标文件解析后可以追加;补遗带来的新值在这里「采用 / 不采用」(AD3),没确认前查标用旧值(AD4)。
|
||||
*/
|
||||
import { unwrap, type Schemas } from "@bid-doctor/api-client";
|
||||
import { Download, Pencil, Plus } from "@lucide/vue";
|
||||
import { Pencil, Plus } from "@lucide/vue";
|
||||
import { useQueryClient } from "@tanstack/vue-query";
|
||||
import { computed, ref, watchEffect } from "vue";
|
||||
import { RouterLink } from "vue-router";
|
||||
@@ -19,7 +19,6 @@ import { useCrumbs } from "@/lib/crumbs";
|
||||
import { qk } from "@/lib/keys";
|
||||
import { deadline, FIELD_LABEL, FIELD_ORDER, fieldText, fmtDateTime, fmtDateTimeFull, fmtYuan, SEVERITY, SEVERITY_CLASS } from "@/lib/labels";
|
||||
import {
|
||||
reportFileUrl,
|
||||
useCheckRuns,
|
||||
useLots,
|
||||
useProject,
|
||||
@@ -94,19 +93,13 @@ const canAddendum = computed(() => !!p.value && p.value.status !== "tracking" &&
|
||||
const runsQ = useCheckRuns(() => props.projectId);
|
||||
const runs = computed(() => runsQ.data.value?.items ?? []);
|
||||
const reportsQ = useReports(() => props.projectId);
|
||||
const reportsByRun = computed(() => {
|
||||
const m = new Map<string, NonNullable<typeof reportsQ.data.value>["items"]>();
|
||||
for (const r of reportsQ.data.value?.items ?? []) m.set(r.check_run_id, [...(m.get(r.check_run_id) ?? []), r]);
|
||||
// 一次检查可能生成过很多份报告,这里只写份数、给一个入口,到「报告」标签页看
|
||||
const reportCount = computed(() => {
|
||||
const m = new Map<string, number>();
|
||||
for (const r of reportsQ.data.value?.items ?? []) m.set(r.check_run_id, (m.get(r.check_run_id) ?? 0) + 1);
|
||||
return m;
|
||||
});
|
||||
const RUN_STATE: Record<string, string> = { queued: "排队中", running: "检查中", failed: "检查失败", cancelled: "已取消" };
|
||||
async function openReport(id: string) {
|
||||
try {
|
||||
window.open(await reportFileUrl(id), "_blank", "noopener");
|
||||
} catch (e) {
|
||||
toast(errorTitle(e), { ok: false });
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 文件:招标方的文件全列;投标文件每个分册只列最新版本 ---------- */
|
||||
const tenderDocs = computed(() => (docsQ.data.value?.items ?? []).filter((d) => d.role !== "bid_volume"));
|
||||
@@ -216,7 +209,7 @@ function openCitation(c: Citation) {
|
||||
{{ runs.length ? "上传新版本并检查" : "上传投标文件并检查" }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
<p v-if="!runs.length" class="cap muted none pad">还没有检查过。上传投标文件后,每次检查的结果和导出的报告都列在这里。</p>
|
||||
<p v-if="!runs.length" class="cap muted none pad">还没有检查过。上传投标文件后,每次检查的结果和生成的报告都列在这里。</p>
|
||||
<table v-else class="data runs">
|
||||
<thead>
|
||||
<tr><th>批次</th><th>时间</th><th>投标文件</th><th>结果</th><th>报告</th></tr>
|
||||
@@ -240,10 +233,11 @@ function openCitation(c: Citation) {
|
||||
<RouterLink v-else class="lnk cap" :to="{ name: 'results', params: { projectId, runId: r.id } }">{{ RUN_STATE[r.status] ?? r.status }}</RouterLink>
|
||||
</td>
|
||||
<td>
|
||||
<button v-for="rep in reportsByRun.get(r.id) ?? []" :key="rep.id" type="button" class="lnk cap rep" @click="openReport(rep.id)">
|
||||
<Download class="ic" :stroke-width="1.5" />{{ fmtDateTime(rep.created_at) }}
|
||||
</button>
|
||||
<span v-if="!reportsByRun.get(r.id)?.length" class="dash" aria-label="没有导出">—</span>
|
||||
<template v-if="reportCount.get(r.id)">
|
||||
<span class="cap muted rep-n"><span class="fig">{{ reportCount.get(r.id) }}</span> 份</span>
|
||||
<RouterLink class="lnk cap" :to="{ name: 'report', params: { projectId }, query: { run: r.id } }">查看报告</RouterLink>
|
||||
</template>
|
||||
<span v-else class="dash" aria-label="没有生成报告">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -337,19 +331,15 @@ function openCitation(c: Citation) {
|
||||
.runs .nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rep-n {
|
||||
margin-right: var(--sp-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sevs {
|
||||
display: inline-flex;
|
||||
gap: var(--sp-1);
|
||||
margin-right: var(--sp-2);
|
||||
}
|
||||
.rep {
|
||||
display: inline-flex;
|
||||
gap: var(--sp-1);
|
||||
align-items: center;
|
||||
}
|
||||
.rep + .rep {
|
||||
margin-left: var(--sp-2);
|
||||
}
|
||||
.files {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -204,7 +204,7 @@ async function remove() {
|
||||
|
||||
<QualificationDialog v-model:open="dialogOpen" :item="editing" :deadlines="deadlines" :focus-expiry="focusExpiry" @saved="onSaved" />
|
||||
<ConfirmDialog v-model:open="removeOpen" :title="`删除资质「${removing?.name ?? ''}」?`" ok-text="删除" :busy="removeBusy" busy-text="正在删除…" @ok="remove">
|
||||
<p>删除后,查标不再用它核对资质,附件一并删除,不能恢复。已导出的检查报告不受影响。</p>
|
||||
<p>删除后,查标不再用它核对资质,附件一并删除,不能恢复。已生成的检查报告不受影响。</p>
|
||||
</ConfirmDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -111,7 +111,7 @@ async function exportCsv() {
|
||||
|
||||
<div v-if="expired" class="alert warn" role="note">
|
||||
<span class="ico w" aria-hidden="true">!</span>
|
||||
<p>专业版已于 <span class="fig">{{ day(plan?.ends_at) }}</span> 到期,数据只读:可以查看、下载已导出的报告、删除项目,不能新建项目、解析和检查。续费到账后恢复。</p>
|
||||
<p>专业版已于 <span class="fig">{{ day(plan?.ends_at) }}</span> 到期,数据只读:可以查看、下载已生成的报告、删除项目,不能新建项目、解析和检查。续费到账后恢复。</p>
|
||||
</div>
|
||||
<div v-else-if="daysLeft !== null && daysLeft <= 30 && !q?.renewal" class="alert warn" role="note">
|
||||
<span class="ico w" aria-hidden="true">!</span>
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 报告(原型 report.html,FR-M3-09):左边预览已生成的 PDF,右边导出选项和已导出的报告。
|
||||
* - 报告对应最近一次检查;致命问题没处理完也可以导出(红线 4),只提示「报告会如实列出」;
|
||||
* - 报告在后台生成,这里轮询任务状态;生成后不再改动,随项目归档,重新导出另存一份;
|
||||
* - 预览就是生成出来的 PDF 本身(浏览器内嵌显示),和下载的文件一致。
|
||||
* 报告(原型 report.html,FR-M3-09):左边预览,右边「生成报告」和「已生成的报告」。
|
||||
* 用词分开(避免「导出」既像生成又像下载):
|
||||
* - 生成:按最近一次检查和当前的处理情况,在后台做一份新的 PDF,存进项目;致命问题没处理完也可以生成(红线 4);
|
||||
* - 每份报告是生成那一刻的快照,之后不再改动;处理了问题要重新生成,旧的保留,随项目归档;
|
||||
* - 下载:把某一份已生成的 PDF 存到电脑上。预览就是这份 PDF 本身(pdf.js 画出来),和下载的文件一致。
|
||||
*/
|
||||
import { unwrap } from "@bid-doctor/api-client";
|
||||
import { AlertCircle, Download, Eye } from "@lucide/vue";
|
||||
import { AlertCircle, Download, Eye, RefreshCw } from "@lucide/vue";
|
||||
import { useQueryClient } from "@tanstack/vue-query";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { RouterLink } from "vue-router";
|
||||
import { RouterLink, useRoute } from "vue-router";
|
||||
import ProjectHead from "@/components/ProjectHead.vue";
|
||||
import ReportViewer from "@/components/ReportViewer.vue";
|
||||
import { api, errorTitle } from "@/lib/api";
|
||||
import { useCrumbs } from "@/lib/crumbs";
|
||||
import { qk } from "@/lib/keys";
|
||||
@@ -32,14 +34,13 @@ const reports = computed(() => reportsQ.data.value?.items ?? []);
|
||||
|
||||
const citations = ref(true);
|
||||
const manualChecks = ref(true);
|
||||
const exporting = ref(false);
|
||||
const freshId = ref<string | null>(null);
|
||||
const generating = ref(false);
|
||||
// 检查的 counts 是检查结束时的快照;处理问题后的待处理数看项目(和项目头的「就绪」一致)
|
||||
const openFatal = computed(() => p.value?.readiness.open_fatal_count ?? 0);
|
||||
|
||||
async function exportReport() {
|
||||
if (!runId.value || exporting.value) return;
|
||||
exporting.value = true;
|
||||
async function generate() {
|
||||
if (!runId.value || generating.value) return;
|
||||
generating.value = true;
|
||||
try {
|
||||
let job = await unwrap(
|
||||
api.POST("/check-runs/{run_id}/reports", {
|
||||
@@ -52,14 +53,13 @@ async function exportReport() {
|
||||
job = await unwrap(api.GET("/report-jobs/{job_id}", { params: { path: { job_id: job.id } } }));
|
||||
}
|
||||
if (job.status !== "succeeded" || !job.report_id) throw new Error(job.error?.message ?? "报告生成失败,请稍后重试");
|
||||
freshId.value = job.report_id;
|
||||
await qc.invalidateQueries({ queryKey: qk.reports(props.projectId) });
|
||||
await preview(job.report_id);
|
||||
toast("报告已生成");
|
||||
toast("报告已生成,可以下载");
|
||||
} catch (e) {
|
||||
toast(errorTitle(e), { ok: false });
|
||||
} finally {
|
||||
exporting.value = false;
|
||||
generating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,17 @@ async function exportReport() {
|
||||
const previewId = ref<string | null>(null);
|
||||
const previewUrl = ref<string | null>(null);
|
||||
const fileUrl = reportFileUrl;
|
||||
const previewing = computed(() => reports.value.find((x) => x.id === previewId.value));
|
||||
|
||||
/** 最新一份报告之后,检查或致命问题的处理情况变了:提示可以重新生成(只看得出致命数的变化) */
|
||||
const latest = computed(() => reports.value[0]);
|
||||
const outdated = computed(() => {
|
||||
const r = latest.value;
|
||||
if (!r || !runId.value) return "";
|
||||
if (r.check_run_id !== runId.value) return "最新一份报告之后又检查过";
|
||||
if ((r.open_fatal_at_export ?? 0) !== openFatal.value) return "最新一份报告之后处理过致命问题";
|
||||
return "";
|
||||
});
|
||||
async function preview(id: string) {
|
||||
try {
|
||||
previewUrl.value = await fileUrl(id);
|
||||
@@ -82,8 +93,12 @@ async function download(id: string) {
|
||||
toast(errorTitle(e), { ok: false });
|
||||
}
|
||||
}
|
||||
// 从概览「查看报告」进来时带着 ?run=:先预览那次检查最新的一份
|
||||
const route = useRoute();
|
||||
watch(reports, (list) => {
|
||||
if (!previewId.value && list[0]) void preview(list[0].id);
|
||||
if (previewId.value || !list[0]) return;
|
||||
const run = route.query.run;
|
||||
void preview((list.find((r) => r.check_run_id === run) ?? list[0]).id);
|
||||
}, { immediate: true });
|
||||
</script>
|
||||
|
||||
@@ -92,54 +107,61 @@ watch(reports, (list) => {
|
||||
<div class="content wide">
|
||||
<div v-if="p && !p.latest_check_run_id" class="empty">
|
||||
<b>还没有检查结果</b>
|
||||
<p>报告对应最近一次检查。先上传投标文件并检查。</p>
|
||||
<p>报告按最近一次检查生成。先上传投标文件并检查。</p>
|
||||
<RouterLink class="btn primary" :to="{ name: 'bid-check', params: { projectId } }">去检查</RouterLink>
|
||||
</div>
|
||||
|
||||
<div v-else class="rp">
|
||||
<section class="preview card flush" aria-label="报告预览">
|
||||
<iframe v-if="previewUrl" :key="previewUrl" :src="previewUrl" title="检查报告预览"></iframe>
|
||||
<div v-else class="empty">
|
||||
<b>还没有导出过报告</b>
|
||||
<p>在右边选好内容后导出,生成的 PDF 会在这里显示。</p>
|
||||
</div>
|
||||
</section>
|
||||
<div class="split-doc">
|
||||
<ReportViewer :url="previewUrl" :label="previewing ? `${fmtDateTime(previewing.created_at)} 生成的报告` : '检查报告'">
|
||||
<template v-if="previewing" #actions>
|
||||
<button type="button" class="btn sm" @click="download(previewing.id)"><Download class="ic" :stroke-width="1.5" />下载</button>
|
||||
</template>
|
||||
<template #empty>
|
||||
<div class="empty">
|
||||
<b>还没有生成过报告</b>
|
||||
<p>在右边选好内容,点「生成报告」,生成的 PDF 会在这里显示,可以下载。</p>
|
||||
</div>
|
||||
</template>
|
||||
</ReportViewer>
|
||||
</div>
|
||||
|
||||
<aside class="rp-side">
|
||||
<section class="card flush" aria-labelledby="h-exp">
|
||||
<div class="card-h"><h2 id="h-exp">导出 PDF</h2></div>
|
||||
<section class="card flush" aria-labelledby="h-gen">
|
||||
<div class="card-h"><h2 id="h-gen">生成报告</h2></div>
|
||||
<div class="card-b">
|
||||
<p v-if="run" class="cap muted">第 <span class="fig">{{ run.run_no }}</span> 次检查 · <span class="fig">{{ fmtDateTime(run.finished_at ?? run.created_at) }}</span></p>
|
||||
<p v-if="run" class="cap muted">依据第 <span class="fig">{{ run.run_no }}</span> 次检查(<span class="fig">{{ fmtDateTime(run.finished_at ?? run.created_at) }}</span>)和现在的处理情况</p>
|
||||
<div class="choices">
|
||||
<label class="choice dis"><input type="checkbox" class="ck" checked disabled /><span>问题明细<small>全部级别,必选</small></span></label>
|
||||
<label class="choice"><input v-model="manualChecks" type="checkbox" class="ck" /><span>附录:需人工核对</span></label>
|
||||
<label class="choice"><input v-model="citations" type="checkbox" class="ck" /><span>每条附原文出处</span></label>
|
||||
</div>
|
||||
<p v-if="openFatal" class="hint-warn"><AlertCircle class="ic" :stroke-width="1.5" /><span>还有 <b><span class="fig">{{ openFatal }}</span> 项致命问题</b>待处理,报告会如实列出。</span></p>
|
||||
<p v-if="outdated" class="hint-warn"><AlertCircle class="ic" :stroke-width="1.5" /><span>{{ outdated }},要反映进报告需要重新生成。</span></p>
|
||||
</div>
|
||||
<div class="card-f">
|
||||
<button type="button" class="btn primary swap" :class="{ 'is-loading': exporting }" :aria-busy="exporting" :disabled="exporting || run?.status !== 'succeeded'" @click="exportReport">
|
||||
<span class="idle"><Download class="ic" :stroke-width="1.5" />导出 PDF</span><span class="busy">正在生成…</span>
|
||||
<button type="button" class="btn primary swap" :class="{ 'is-loading': generating }" :aria-busy="generating" :disabled="generating || run?.status !== 'succeeded'" @click="generate">
|
||||
<span class="idle"><RefreshCw v-if="reports.length" class="ic" :stroke-width="1.5" />{{ reports.length ? "重新生成报告" : "生成报告" }}</span><span class="busy">正在生成…</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card flush" aria-labelledby="h-done">
|
||||
<div class="card-h"><h2 id="h-done">已导出的报告</h2></div>
|
||||
<div class="card-h"><h2 id="h-done">已生成的报告</h2><span v-if="reports.length" class="fig muted">{{ reports.length }}</span></div>
|
||||
<div class="card-b">
|
||||
<ul v-if="reports.length" class="exports">
|
||||
<li v-for="r in reports" :key="r.id" :class="{ cur: r.id === previewId }">
|
||||
<div>
|
||||
<b>第 <span class="fig">{{ r.run_no }}</span> 次检查<span v-if="r.id === freshId" class="new">本次</span></b>
|
||||
<small><span class="fig">{{ fmtDateTime(r.created_at) }}</span> · {{ r.generated_by.name }} · <span class="fig">{{ r.page_count }}</span> 页<template v-if="r.open_fatal_at_export"> · 导出时致命 <span class="fig">{{ r.open_fatal_at_export }}</span> 项待处理</template></small>
|
||||
<b>第 <span class="fig">{{ r.run_no }}</span> 次检查</b>
|
||||
<small><span class="fig">{{ fmtDateTime(r.created_at) }}</span> · {{ r.generated_by.name }} · <span class="fig">{{ r.page_count }}</span> 页</small>
|
||||
</div>
|
||||
<button type="button" class="icon-btn sm" :aria-label="`预览第 ${r.run_no} 次检查的报告`" @click="preview(r.id)"><Eye class="ic" :stroke-width="1.5" /></button>
|
||||
<button type="button" class="icon-btn sm" :aria-label="`下载第 ${r.run_no} 次检查的报告`" @click="download(r.id)"><Download class="ic" :stroke-width="1.5" /></button>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="cap muted">还没有导出过。</p>
|
||||
<p v-else class="cap muted">还没有生成过。</p>
|
||||
</div>
|
||||
<div class="card-f cap muted">导出后不再改动,随项目归档;重新导出会另存一份。</div>
|
||||
<div class="card-f cap muted">每份是生成那一刻的结果,之后不再改动,随项目归档。</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
@@ -153,18 +175,7 @@ watch(reports, (list) => {
|
||||
gap: var(--gap-card);
|
||||
align-items: start;
|
||||
}
|
||||
.preview {
|
||||
min-height: 70vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
.preview iframe {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: calc(100vh - 220px);
|
||||
min-height: 70vh;
|
||||
border: 0;
|
||||
}
|
||||
.preview .empty {
|
||||
.split-doc .empty {
|
||||
padding: var(--sp-8) var(--card-x);
|
||||
}
|
||||
.rp-side {
|
||||
@@ -220,16 +231,11 @@ watch(reports, (list) => {
|
||||
line-height: var(--lh-caption);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.exports .new {
|
||||
margin-left: var(--sp-2);
|
||||
padding: 0 6px;
|
||||
border-radius: var(--radius-tag);
|
||||
background: var(--c-n100);
|
||||
font-size: var(--fs-caption);
|
||||
font-weight: var(--fw-regular);
|
||||
}
|
||||
.exports li.cur b {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.card-h .fig {
|
||||
font-weight: var(--fw-medium);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* 「提示」可以批量确认无误;每个问题可以反馈判断有误;从工作台带 ?f=问题编号 进来时选中那一条。
|
||||
*/
|
||||
import { unwrap } from "@bid-doctor/api-client";
|
||||
import { ChevronDown, ChevronRight, Download } from "@lucide/vue";
|
||||
import { ChevronDown, ChevronRight, FileText } from "@lucide/vue";
|
||||
import { useQueryClient } from "@tanstack/vue-query";
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch, watchEffect } from "vue";
|
||||
import { RouterLink, useRoute } from "vue-router";
|
||||
@@ -390,7 +390,7 @@ const errorText = computed(() => {
|
||||
<div class="actions">
|
||||
<div class="row">
|
||||
<RouterLink class="btn" :to="{ name: 'bid-check', params: { projectId } }">重新检查</RouterLink>
|
||||
<RouterLink class="btn primary" :to="{ name: 'report', params: { projectId } }"><Download class="ic" :stroke-width="1.5" />导出报告</RouterLink>
|
||||
<RouterLink class="btn primary" :to="{ name: 'report', params: { projectId } }"><FileText class="ic" :stroke-width="1.5" />生成报告</RouterLink>
|
||||
</div>
|
||||
<span v-if="p?.readiness.open_fatal_count" class="export-note">还有 <span class="fig">{{ p.readiness.open_fatal_count }}</span> 项致命问题待处理,报告会如实列出</span>
|
||||
</div>
|
||||
|
||||
@@ -74,7 +74,7 @@ a.me:hover{background:var(--side-hover)}a.me[aria-current="page"]{background:var
|
||||
.ph .facts{display:flex;gap:var(--sp-8);margin:0 0 0 auto;flex:none}
|
||||
.ph .facts > div{display:flex;flex-direction:column;gap:2px}
|
||||
.ph .facts dt{font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary)}
|
||||
.ph .facts dd{display:flex;align-items:center;gap:6px;min-height:var(--lh-body);margin:0;white-space:nowrap}
|
||||
.ph .facts dd{display:flex;align-items:center;gap:6px;height:var(--control-h-sm);margin:0;white-space:nowrap} /* 各列取值同高(状态列有按钮),值都在同一条中线上 */
|
||||
.ph .tabs{margin-top:var(--sp-3)}
|
||||
.ph + .content{padding-top:var(--sp-5)}
|
||||
/* 左右布局(查标、概览,同读标页):左边内容随页面滚动,右边原文区贴住视口、自己翻页 */
|
||||
|
||||
@@ -3186,7 +3186,7 @@ components:
|
||||
page_count: { type: integer }
|
||||
sha256: { type: string }
|
||||
template_version: { type: string }
|
||||
open_fatal_at_export: { type: integer, description: 导出时还没处理的致命问题数 }
|
||||
open_fatal_at_export: { type: integer, description: 生成时还没处理的致命问题数 }
|
||||
generated_by: { $ref: '#/components/schemas/UserRef' }
|
||||
created_at: { $ref: '#/components/schemas/Timestamp' }
|
||||
|
||||
|
||||
2
packages/api-client/src/schema.d.ts
vendored
2
packages/api-client/src/schema.d.ts
vendored
@@ -2319,7 +2319,7 @@ export interface components {
|
||||
page_count: number;
|
||||
sha256?: string;
|
||||
template_version: string;
|
||||
/** @description 导出时还没处理的致命问题数 */
|
||||
/** @description 生成时还没处理的致命问题数 */
|
||||
open_fatal_at_export?: number;
|
||||
generated_by: components["schemas"]["UserRef"];
|
||||
created_at: components["schemas"]["Timestamp"];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""检查报告的 HTML(设计规范 §7.17,原型 report.html):纯函数,输入整理好的数据,输出正文、页眉、页脚三段 HTML,
|
||||
由 Gotenberg 的 Chromium 转成 PDF(A4 竖版,页边距 20mm,页眉页脚每页重复、页码自动生成)。
|
||||
|
||||
- 首页:项目信息、投标文件版本、检查依据、检查时间、规则版本、导出人和时间、**检查覆盖范围**(查了 / 没查)、
|
||||
- 首页:项目信息、投标文件版本、检查依据、检查时间、规则版本、生成人和时间、**检查覆盖范围**(查了 / 没查)、
|
||||
汇总、免责声明;
|
||||
- 问题明细:级别、问题、说明、出处(可选)、处理状态和处理人;
|
||||
- 附录:需人工核对的条目和勾选结果(可选);
|
||||
@@ -19,11 +19,11 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
TEMPLATE_VERSION = "2026.11-v3" # v2:资质问题的「依据:资质库记录」;v3:出处不重复写文件名
|
||||
TEMPLATE_VERSION = "2026.11-v4" # v2:资质问题的「依据:资质库记录」;v3:出处不重复写文件名;v4:「导出」改称「生成」
|
||||
TITLE = "投标文件辅助检查报告"
|
||||
FOOTER = "本报告为辅助检查结果,仅供投标前自查参考"
|
||||
DISCLAIMER = (
|
||||
"本报告由标博士比对招标文件和投标文件生成,经用户逐项处理后导出,供投标前自查使用。检查范围以首页「检查覆盖范围」为准;"
|
||||
"本报告由标博士比对招标文件和投标文件生成,经用户逐项处理后生成,供投标前自查使用。检查范围以首页「检查覆盖范围」为准;"
|
||||
"结果可能有遗漏或误判,不代表资格审查、符合性审查或评标的结论。是否递交、如何修改,由投标人自行决定。"
|
||||
)
|
||||
SEVERITY = {"fatal": "致命", "important": "重要", "hint": "提示"}
|
||||
@@ -152,7 +152,7 @@ def cover(d: dict[str, Any]) -> str:
|
||||
checked_at = f'<span class="fig">{when(r.get("finished_at") or r.get("created_at"))}</span>'
|
||||
run_no = f'第 <span class="fig">{r["run_no"]}</span> 次检查'
|
||||
alert = (
|
||||
f'<div class="alert">导出时还有 <b class="fig">{open_fatal}</b> 项致命问题待处理,报告如实列出。</div>'
|
||||
f'<div class="alert">生成时还有 <b class="fig">{open_fatal}</b> 项致命问题待处理,报告如实列出。</div>'
|
||||
if open_fatal
|
||||
else ""
|
||||
)
|
||||
@@ -169,7 +169,7 @@ def cover(d: dict[str, Any]) -> str:
|
||||
<dt>检查依据</dt><dd>{basis}</dd>
|
||||
<dt>检查时间</dt><dd>{checked_at}({run_no})</dd>
|
||||
<dt>规则版本</dt><dd class="fig">{_e(r.get("rule_set_version"))}</dd>
|
||||
<dt>导出</dt><dd>{_e(d["exported_by"])} · <span class="fig">{when(d["exported_at"])}</span></dd>
|
||||
<dt>生成</dt><dd>{_e(d["exported_by"])} · <span class="fig">{when(d["exported_at"])}</span></dd>
|
||||
</dl>
|
||||
{alert}
|
||||
<h2>检查覆盖范围</h2>
|
||||
@@ -230,7 +230,7 @@ def appendix(d: dict[str, Any]) -> str:
|
||||
return f"""
|
||||
<h2>附录 · 需人工核对</h2>
|
||||
<p class="cap">这些要求在投标文件里看不出来,需要投标人递交前自己核对。
|
||||
「未勾选」表示导出时还没有人在系统里勾选核对。</p>
|
||||
「未勾选」表示生成时还没有人在系统里勾选核对。</p>
|
||||
<table><thead><tr><th>要求</th><th style="width:48mm">出处</th><th style="width:30mm">核对</th></tr></thead>
|
||||
<tbody>{body}</tbody></table>
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user