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

- 报告页:预览从浏览器自带 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

@@ -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(协作权限、资产库)。

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>

View File

@@ -138,7 +138,7 @@ export const NEXT_ACTION: Dict<Schemas["NextAction"]> = {
upload_bid: "上传投标文件",
run_check: "发起检查",
handle_fatal: "处理致命问题",
export_report: "导出检查报告",
export_report: "生成检查报告",
archive: "归档项目",
none: "查看项目",
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 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>
</section>
<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>

View File

@@ -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>

View File

@@ -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)}
/* 左右布局(查标、概览,同读标页):左边内容随页面滚动,右边原文区贴住视口、自己翻页 */

View File

@@ -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' }

View File

@@ -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"];

View File

@@ -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>
"""