Files
bid-doctor/apps/web/src/pages/ReportPage.vue
YANG JIANKUAN 1966ce9b15
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled
fix(ui): 读标、查标走查修复;查标结果页按原型补齐
走查(注册 → 读标 → 查标 → 导出报告)发现的问题:
- 下拉:原生 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>
2026-11-05 11:38:56 +08:00

241 lines
9.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">
/**
* 报告(原型 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>