- 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>
109 lines
5.1 KiB
Vue
109 lines
5.1 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* 项目头(原型的 projHead):项目名、采购人等、投标截止、状态和就绪;下面是「读标 / 查标」标签页。
|
||
* 就绪只在「已检查」时显示(G7),只表示致命问题都已处理,不代表投标文件没有问题(READY_TIP)。
|
||
* 状态操作(FR-M6-02):递交 / 撤销递交 / 归档 / 撤销归档。致命问题没处理完也可以递交(红线 4),确认框里说明;
|
||
* 递交后项目只读,可以撤销,都留痕。
|
||
*/
|
||
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" | "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 } }
|
||
: { name: "bid-check", params: { projectId: p.value?.id ?? "" } },
|
||
);
|
||
</script>
|
||
|
||
<template>
|
||
<header class="ph">
|
||
<div class="ph-l1">
|
||
<div>
|
||
<h1 :title="p?.name">{{ p?.name ?? " " }}</h1>
|
||
<p class="ph-meta">{{ meta || "招标文件信息待解析" }}</p>
|
||
</div>
|
||
<dl v-if="p" class="facts">
|
||
<div>
|
||
<dt>投标截止</dt>
|
||
<dd>
|
||
<template v-if="due"><span class="fig m">{{ due.sub }}</span><span :class="due.cls || 'muted'">{{ due.main }}</span></template>
|
||
<span v-else class="muted">—</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>
|
||
<span v-if="p.readiness.ready" class="tip-below" :data-tip="READY_TIP" tabindex="0">致命问题已处理</span>
|
||
<span v-else class="sev fatal">致命<span class="fig">{{ p.readiness.open_fatal_count }}</span></span>
|
||
</dd>
|
||
</div>
|
||
</dl>
|
||
</div>
|
||
<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>
|