Files
bid-doctor/apps/web/src/components/ProjectHead.vue
YANG JIANKUAN e7fb797925
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled
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>
2026-11-04 20:51:43 +08:00

109 lines
5.1 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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