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>
|
||||
|
||||
Reference in New Issue
Block a user