feat(report): 检查报告 PDF 导出和归档;项目状态流转(递交 / 归档)
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled

- report 模块:导出 → worker 用 Gotenberg 把 HTML 转成 A4 PDF → 按项目数据密钥加密存储 → report
  (不可改、存哈希);report_job 记生成过程(迁移 0009),完成推 report.ready、发通知
- 报告首页:项目信息、投标文件版本、检查依据、规则版本、导出人、检查覆盖范围、汇总、免责声明;
  问题明细带两侧出处和处理人;附录为需人工核对的勾选结果;模板文字不出现「通过 / 合格 / 保证」
- 前端「报告」标签页:导出选项、内嵌预览、历次报告下载;工作台「导出检查报告」直达
- 项目状态流转 POST /projects/{id}/transitions:致命未处理也能递交(确认框说明),
  递交后只读,撤销归档回到归档前的状态,都写审计
- 通知补上检查、报告的标题

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-04 20:51:43 +08:00
parent 902a130c82
commit e7fb797925
26 changed files with 1276 additions and 13 deletions

View File

@@ -0,0 +1,239 @@
<script setup lang="ts">
/**
* 报告(原型 report.html,FR-M3-09):左边预览已生成的 PDF,右边导出选项和已导出的报告。
* - 报告对应最近一次检查;致命问题没处理完也可以导出(红线 4),只提示「报告会如实列出」;
* - 报告在后台生成,这里轮询任务状态;生成后不再改动,随项目归档,重新导出另存一份;
* - 预览就是生成出来的 PDF 本身(浏览器内嵌显示),和下载的文件一致。
*/
import { unwrap } from "@bid-doctor/api-client";
import { AlertCircle, Download, Eye } from "@lucide/vue";
import { useQuery, useQueryClient } from "@tanstack/vue-query";
import { computed, ref, watch } from "vue";
import { RouterLink } from "vue-router";
import ProjectHead from "@/components/ProjectHead.vue";
import { api, errorTitle } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys";
import { fmtDateTime } from "@/lib/labels";
import { useCheckRun, useProject } from "@/lib/queries";
import { toast } from "@/lib/toast";
const props = defineProps<{ projectId: string }>();
const qc = useQueryClient();
const project = useProject(() => props.projectId);
const p = computed(() => project.data.value);
const runId = computed(() => p.value?.latest_check_run_id ?? "");
const runQ = useCheckRun(runId);
const run = computed(() => (runId.value ? runQ.data.value : undefined));
useCrumbs(() => [{ label: "工作台", to: { name: "workbench" } }, { label: p.value?.name ?? "项目" }, { label: "报告" }]);
const reportsQ = useQuery({
queryKey: computed(() => qk.reports(props.projectId)),
queryFn: () => unwrap(api.GET("/projects/{project_id}/reports", { params: { path: { project_id: props.projectId } } })),
});
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 openFatal = computed(() => run.value?.counts?.open_fatal ?? 0);
async function exportReport() {
if (!runId.value || exporting.value) return;
exporting.value = true;
try {
let job = await unwrap(
api.POST("/check-runs/{run_id}/reports", {
params: { path: { run_id: runId.value } },
body: { citations: citations.value, manual_checks: manualChecks.value },
}),
);
for (let i = 0; i < 120 && (job.status === "queued" || job.status === "running"); i++) {
await new Promise((r) => setTimeout(r, 1000));
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("报告已生成");
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
exporting.value = false;
}
}
/* ---------- 预览和下载:短时签名地址 ---------- */
const previewId = ref<string | null>(null);
const previewUrl = ref<string | null>(null);
async function fileUrl(id: string) {
return (await unwrap(api.GET("/reports/{report_id}/file", { params: { path: { report_id: id } } }))).url;
}
async function preview(id: string) {
try {
previewUrl.value = await fileUrl(id);
previewId.value = id;
} catch (e) {
toast(errorTitle(e), { ok: false });
}
}
async function download(id: string) {
try {
window.open(await fileUrl(id), "_blank", "noopener");
} catch (e) {
toast(errorTitle(e), { ok: false });
}
}
watch(reports, (list) => {
if (!previewId.value && list[0]) void preview(list[0].id);
}, { immediate: true });
</script>
<template>
<ProjectHead :project="p" tab="report" />
<div class="content wide">
<div v-if="p && !p.latest_check_run_id" class="empty">
<b>还没有检查结果</b>
<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>
<aside class="rp-side">
<section class="card flush" aria-labelledby="h-exp">
<div class="card-h"><h2 id="h-exp">导出 PDF</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>
<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>
</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>
</div>
</section>
<section class="card flush" aria-labelledby="h-done">
<div class="card-h"><h2 id="h-done">已导出的报告</h2></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>
</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>
</div>
<div class="card-f cap muted">导出后不再改动,随项目归档;重新导出会另存一份。</div>
</section>
</aside>
</div>
</div>
</template>
<style scoped>
.rp {
display: grid;
grid-template-columns: minmax(0, 1fr) var(--aside-w);
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 {
padding: var(--sp-8) var(--card-x);
}
.rp-side {
position: sticky;
top: calc(var(--topbar-h) + var(--sp-2));
}
.rp-side > .card + .card {
margin-top: var(--gap-card);
}
.choices {
display: grid;
gap: var(--sp-3);
margin-top: var(--sp-3);
}
.choice small {
display: block;
color: var(--text-secondary);
font-size: var(--fs-caption);
}
.hint-warn {
margin-top: var(--sp-4);
}
.card-f .btn.primary {
flex: 1;
}
.exports {
list-style: none;
margin: 0;
padding: 0;
}
.exports li {
display: flex;
gap: var(--sp-2);
align-items: center;
padding: var(--sp-3) 0;
border-top: var(--line) solid var(--border-subtle);
}
.exports li:first-child {
border-top: 0;
padding-top: 0;
}
.exports li > div {
flex: 1;
min-width: 0;
}
.exports b {
display: block;
font-weight: var(--fw-bold);
}
.exports small {
display: block;
font-size: var(--fs-caption);
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;
}
</style>