feat(report): 检查报告 PDF 导出和归档;项目状态流转(递交 / 归档)
- 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:
@@ -2,19 +2,61 @@
|
||||
/**
|
||||
* 项目头(原型的 projHead):项目名、采购人等、投标截止、状态和就绪;下面是「读标 / 查标」标签页。
|
||||
* 就绪只在「已检查」时显示(G7),只表示致命问题都已处理,不代表投标文件没有问题(READY_TIP)。
|
||||
* 概览、报告页做到时在这里加标签页。
|
||||
* 状态操作(FR-M6-02):递交 / 撤销递交 / 归档 / 撤销归档。致命问题没处理完也可以递交(红线 4),确认框里说明;
|
||||
* 递交后项目只读,可以撤销,都留痕。
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import { unwrap } from "@bid-doctor/api-client";
|
||||
import { useQueryClient } from "@tanstack/vue-query";
|
||||
import { computed, ref } from "vue";
|
||||
import { RouterLink } from "vue-router";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
import { api, errorTitle } from "@/lib/api";
|
||||
import { qk } from "@/lib/keys";
|
||||
import { deadline, legalRegime, projectStatus, READY_TIP } from "@/lib/labels";
|
||||
import type { ProjectDetail } from "@/lib/queries";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
const props = defineProps<{ project?: ProjectDetail; tab: "checklist" | "check" }>();
|
||||
const props = defineProps<{ project?: ProjectDetail; tab: "checklist" | "check" | "report" }>();
|
||||
const qc = useQueryClient();
|
||||
const p = computed(() => props.project);
|
||||
const meta = computed(() =>
|
||||
p.value ? [p.value.purchaser, p.value.legal_regime !== "unknown" ? legalRegime(p.value.legal_regime) : "", p.value.procurement_method, p.value.code].filter(Boolean).join(" · ") : "",
|
||||
);
|
||||
const due = computed(() => deadline(p.value?.deadline_at));
|
||||
type Action = "submit" | "unsubmit" | "archive" | "unarchive";
|
||||
const ACTIONS: Record<string, { action: Action; label: string }[]> = {
|
||||
tracking: [{ action: "submit", label: "标记为已递交" }],
|
||||
drafting: [{ action: "submit", label: "标记为已递交" }],
|
||||
checked: [{ action: "submit", label: "标记为已递交" }],
|
||||
submitted: [
|
||||
{ action: "unsubmit", label: "撤销递交" },
|
||||
{ action: "archive", label: "归档" },
|
||||
],
|
||||
archived: [{ action: "unarchive", label: "撤销归档" }],
|
||||
};
|
||||
const actions = computed(() => (p.value ? ACTIONS[p.value.status] ?? [] : []));
|
||||
const confirmSubmit = ref(false);
|
||||
const busy = ref(false);
|
||||
async function run(action: Action) {
|
||||
if (!p.value) return;
|
||||
if (action === "submit" && !confirmSubmit.value) {
|
||||
confirmSubmit.value = true;
|
||||
return;
|
||||
}
|
||||
busy.value = true;
|
||||
try {
|
||||
await unwrap(api.POST("/projects/{project_id}/transitions", { params: { path: { project_id: p.value.id } }, body: { action } }));
|
||||
confirmSubmit.value = false;
|
||||
void qc.invalidateQueries({ queryKey: qk.project(p.value.id) });
|
||||
void qc.invalidateQueries({ queryKey: qk.projects });
|
||||
toast(action === "submit" ? "已标记为已递交,项目改为只读" : action === "archive" ? "已归档" : "已撤销");
|
||||
} catch (e) {
|
||||
toast(errorTitle(e), { ok: false });
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const checkTo = computed(() =>
|
||||
p.value?.latest_check_run_id
|
||||
? { name: "results", params: { projectId: p.value.id, runId: p.value.latest_check_run_id } }
|
||||
@@ -37,7 +79,13 @@ const checkTo = computed(() =>
|
||||
<span v-else class="muted">—</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div><dt>状态</dt><dd><span class="tag">{{ projectStatus(p.status) }}</span></dd></div>
|
||||
<div>
|
||||
<dt>状态</dt>
|
||||
<dd>
|
||||
<span class="tag">{{ projectStatus(p.status) }}</span>
|
||||
<button v-for="a in actions" :key="a.action" type="button" class="btn sm text" :disabled="busy" @click="run(a.action)">{{ a.label }}</button>
|
||||
</dd>
|
||||
</div>
|
||||
<div v-if="p.readiness.ready !== null && p.readiness.ready !== undefined">
|
||||
<dt>就绪</dt>
|
||||
<dd>
|
||||
@@ -50,6 +98,11 @@ const checkTo = computed(() =>
|
||||
<nav class="tabs" aria-label="项目">
|
||||
<RouterLink v-if="p" role="tab" :aria-selected="tab === 'checklist'" :to="{ name: 'checklist', params: { projectId: p.id } }">读标</RouterLink>
|
||||
<RouterLink v-if="p" role="tab" :aria-selected="tab === 'check'" :to="checkTo">查标</RouterLink>
|
||||
<RouterLink v-if="p" role="tab" :aria-selected="tab === 'report'" :to="{ name: 'report', params: { projectId: p.id } }">报告</RouterLink>
|
||||
</nav>
|
||||
</header>
|
||||
<ConfirmDialog v-model:open="confirmSubmit" title="标记为已递交?" ok-text="标记为已递交" busy-text="正在标记…" :busy="busy" @ok="run('submit')">
|
||||
<p>标记后项目改为只读,可以随时撤销。</p>
|
||||
<p v-if="p && p.readiness.open_fatal_count">还有 <b class="fig">{{ p.readiness.open_fatal_count }}</b> 项致命问题没有处理。是否递交由你决定,系统不会阻止。</p>
|
||||
</ConfirmDialog>
|
||||
</template>
|
||||
|
||||
@@ -26,8 +26,10 @@ 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>(() => home.value);
|
||||
/** 「下一步」:导出报告去报告页,其余同默认入口 */
|
||||
const next = computed<RouteLocationRaw>(() =>
|
||||
p.value.next_action === "export_report" ? { name: "report", params: { projectId: p.value.id } } : home.value,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -15,5 +15,6 @@ export const qk = {
|
||||
checkRun: (id: string) => ["check-run", id] as const,
|
||||
findings: (runId: string) => ["findings", runId] as const,
|
||||
manualChecks: (projectId: string) => ["manual-checks", projectId] as const,
|
||||
reports: (projectId: string) => ["reports", projectId] as const,
|
||||
itemResults: (runId: string) => ["item-results", runId] as const,
|
||||
};
|
||||
|
||||
@@ -122,6 +122,7 @@ export function useCheckRun(id: MaybeRefOrGetter<string>) {
|
||||
return useQuery({
|
||||
queryKey: computed(() => qk.checkRun(toValue(id))),
|
||||
queryFn: () => unwrap(api.GET("/check-runs/{run_id}", { params: { path: { run_id: toValue(id) } } })),
|
||||
enabled: computed(() => !!toValue(id)),
|
||||
refetchInterval: (query) => (query.state.data && TERMINAL_JOB.has(query.state.data.status) ? false : 5000),
|
||||
});
|
||||
}
|
||||
|
||||
239
apps/web/src/pages/ReportPage.vue
Normal file
239
apps/web/src/pages/ReportPage.vue
Normal 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>
|
||||
@@ -42,6 +42,12 @@ export const router = createRouter({
|
||||
component: () => import("@/pages/BidCheckPage.vue"),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: "projects/:projectId/report",
|
||||
name: "report",
|
||||
component: () => import("@/pages/ReportPage.vue"),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: "projects/:projectId/results/:runId",
|
||||
name: "results",
|
||||
|
||||
Reference in New Issue
Block a user