走查(注册 → 读标 → 查标 → 导出报告)发现的问题: - 下拉:原生 select 换成 SelectBox(Reka UI,规范 §7.21):右侧 chevron 有边距、 sm 高 32 和搜索框一致、浮层和触发框同宽、选中项加粗带 ✓;响应方式栏的签名不再被挤成竖排 - 读标:「依据 · 第 N 页」改成行内出处链接;证明材料标签不再被挤压 - 概览:关键日期同一种、同一时刻只列一次 - 报告页:「还有 N 项致命」读项目实时值(检查的 counts 是快照) - 读标抽取:★ 只表示「重要」的文件,清单项也挂到 ★ 规则上(「已识别 N 条」) - 检查范围文案和后端覆盖范围一致;报告出处不重复写文件名(模板 2026.11-v3) - 带加载态的按钮图标和文字间距;单选、半选、禁用复选框样式 查标结果页按原型 results.html 补齐: - 本次处理的问题留在原位(署名行可撤销),修了处理后仍显示处理按钮的问题 - 键盘 J / K / O / 1 / 2 / 3 / X / U / Esc;「提示」批量确认无误;「判断有误?」反馈 - 覆盖范围默认收起成一行;顶部加导出报告入口和致命提示;已处理计数 - 检查进度卡和解析进度卡同一种样式 - 项目头「就绪 / 未就绪」,致命数挂在查标标签上 - 上传投标文件页:已有分册时新增表单收起到「添加分册」 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
241 lines
9.1 KiB
Vue
241 lines
9.1 KiB
Vue
<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);
|
||
// 检查的 counts 是检查结束时的快照;处理问题后的待处理数看项目(和项目头的「就绪」一致)
|
||
const openFatal = computed(() => p.value?.readiness.open_fatal_count ?? 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>
|