feat(ui): 查标第 6 步前端补齐
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled

- 读标页:每条清单项写最近一次检查的结论,点投标侧出处看投标原文;
  「已响应」也能反馈判断有误(漏报);证书要求写资质库里的情况
  (新接口 GET /projects/{id}/qualification-matches,资质库空时不写)
- 概览:检查记录卡(历次检查、各级问题数、报告下载)、文件卡
- 工作台:「致命」列、统计可点筛选、待处理的致命问题卡(?f= 定位到问题)
- 日期输入加日历弹层(DateInput,Reka UI Calendar)
- 查标页:? 键显示快捷键说明;反馈对话框抽成 FeedbackDialog 共用
- 扫描件提示去掉多余空格

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-05 13:39:19 +08:00
parent 808259da87
commit 54cbb556d0
30 changed files with 956 additions and 143 deletions

View File

@@ -1,12 +1,12 @@
<script setup lang="ts">
/**
* 项目概览(原型 project.html,PI1):这一步只做三张卡——项目要点(基本信息字段,带出处,可改、可恢复 AI 值)、
* 采购包(所投的包和限价)、关键日期(时间线和倒计时,FR-M2-09)。检查记录、文件、AI 摘要卡留到第 6 步。
* 项目概览(原型 project.html,PI1):关键日期(时间线和倒计时,FR-M2-09)和采购包、项目要点(基本信息字段,
* 带出处,可改、可恢复 AI 值)、检查记录(历次检查的结果和报告)、文件(招标方的文件和投标文件的最新版本)。
* 查标按这里的值核对截止时间、限价、法律体系;改了以后重新检查才生效(PI5)。项目已递交、已归档时只读。
* 补遗(FR-M2-10):招标文件解析后可以追加;补遗带来的新值在这里「采用 / 不采用」(AD3),没确认前查标用旧值(AD4)。
*/
import { unwrap, type Schemas } from "@bid-doctor/api-client";
import { Pencil, Plus } from "@lucide/vue";
import { Download, Pencil, Plus } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { computed, ref, watchEffect } from "vue";
import { RouterLink } from "vue-router";
@@ -17,8 +17,18 @@ import ProjectHead from "@/components/ProjectHead.vue";
import { api, errorTitle } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys";
import { deadline, FIELD_LABEL, FIELD_ORDER, fieldText, fmtDateTimeFull, fmtYuan } from "@/lib/labels";
import { useLots, useProject, useProjectDocuments, type Citation, type ProjectField } from "@/lib/queries";
import { deadline, FIELD_LABEL, FIELD_ORDER, fieldText, fmtDateTime, fmtDateTimeFull, fmtYuan, SEVERITY, SEVERITY_CLASS } from "@/lib/labels";
import {
reportFileUrl,
useCheckRuns,
useLots,
useProject,
useProjectDocuments,
useReports,
type Citation,
type DocumentInfo,
type ProjectField,
} from "@/lib/queries";
import { toast } from "@/lib/toast";
const props = defineProps<{ projectId: string }>();
@@ -80,6 +90,36 @@ async function resolvePending(key: Schemas["ProjectFieldKey"], accept: boolean)
}
const canAddendum = computed(() => !!p.value && p.value.status !== "tracking" && !readOnly.value);
/* ---------- 检查记录:历次检查和对应的报告 ---------- */
const runsQ = useCheckRuns(() => props.projectId);
const runs = computed(() => runsQ.data.value?.items ?? []);
const reportsQ = useReports(() => props.projectId);
const reportsByRun = computed(() => {
const m = new Map<string, NonNullable<typeof reportsQ.data.value>["items"]>();
for (const r of reportsQ.data.value?.items ?? []) m.set(r.check_run_id, [...(m.get(r.check_run_id) ?? []), r]);
return m;
});
const RUN_STATE: Record<string, string> = { queued: "排队中", running: "检查中", failed: "检查失败", cancelled: "已取消" };
async function openReport(id: string) {
try {
window.open(await reportFileUrl(id), "_blank", "noopener");
} catch (e) {
toast(errorTitle(e), { ok: false });
}
}
/* ---------- 文件:招标方的文件全列;投标文件每个分册只列最新版本 ---------- */
const tenderDocs = computed(() => (docsQ.data.value?.items ?? []).filter((d) => d.role !== "bid_volume"));
const bidDocs = computed(() => (docsQ.data.value?.items ?? []).filter((d) => d.role === "bid_volume" && d.is_latest_version !== false));
function parseState(d: DocumentInfo): string {
const s = d.latest_parse_job?.status;
if (s === "succeeded") return "解析成功";
if (s === "queued" || s === "running") return "解析中";
if (s === "failed") return "解析失败";
if (s === "cancelled") return "已取消解析";
return d.status === "failed" ? "读取失败" : "未解析";
}
/* 右侧原文(左右布局,同读标页):默认摆招标文件,点出处翻到那一页并高亮;补遗的出处换成那份补遗 */
const docsQ = useProjectDocuments(() => props.projectId);
const shown = ref<{ doc: { id: string; label: string } | null; target: (ViewerTarget & { id: string }) | null }>({ doc: null, target: null });
@@ -142,7 +182,6 @@ function openCitation(c: Citation) {
<div class="card-h">
<h2 id="h-facts">项目要点</h2>
<span class="cap muted r">查标按这里的值核对截止时间、限价和法律体系</span>
<RouterLink v-if="canAddendum" class="btn sm" :to="{ name: 'project-new', query: { project: projectId, addendum: '1' } }"><Plus class="ic" :stroke-width="1.5" />追加补遗 / 澄清</RouterLink>
</div>
<dl class="facts-list">
<div v-for="{ key, f, value } in rows" :key="key" class="frow">
@@ -170,6 +209,75 @@ function openCitation(c: Citation) {
</dl>
</section>
<section class="card flush" aria-labelledby="h-runs">
<div class="card-h">
<h2 id="h-runs">检查记录</h2>
<RouterLink v-if="!readOnly && p && p.status !== 'tracking'" class="btn sm r" :to="{ name: 'bid-check', params: { projectId } }">
{{ runs.length ? "上传新版本并检查" : "上传投标文件并检查" }}
</RouterLink>
</div>
<p v-if="!runs.length" class="cap muted none pad">还没有检查过。上传投标文件后,每次检查的结果和导出的报告都列在这里。</p>
<table v-else class="data runs">
<thead>
<tr><th>批次</th><th>时间</th><th>投标文件</th><th>结果</th><th>报告</th></tr>
</thead>
<tbody>
<tr v-for="r in runs" :key="r.id">
<td>第 <span class="fig">{{ r.run_no }}</span> 次</td>
<td class="fig nowrap">{{ fmtDateTime(r.created_at) }}</td>
<td>{{ (r.documents ?? []).map((d) => d.label).join("、") || "—" }}</td>
<td>
<template v-if="r.status === 'succeeded'">
<!-- 只列有问题的级别:「致命 0」不该是红色 -->
<span class="sevs">
<template v-for="s in (['fatal', 'important', 'hint'] as const)" :key="s">
<span v-if="r.counts?.[s]" class="sev" :class="SEVERITY_CLASS[s]">{{ SEVERITY[s] }}<span class="fig">{{ r.counts[s] }}</span></span>
</template>
<span v-if="!r.counts?.fatal && !r.counts?.important && !r.counts?.hint" class="cap muted">没有发现问题</span>
</span>
<RouterLink class="lnk cap" :to="{ name: 'results', params: { projectId, runId: r.id } }">查看结果</RouterLink>
</template>
<RouterLink v-else class="lnk cap" :to="{ name: 'results', params: { projectId, runId: r.id } }">{{ RUN_STATE[r.status] ?? r.status }}</RouterLink>
</td>
<td>
<button v-for="rep in reportsByRun.get(r.id) ?? []" :key="rep.id" type="button" class="lnk cap rep" @click="openReport(rep.id)">
<Download class="ic" :stroke-width="1.5" />{{ fmtDateTime(rep.created_at) }}
</button>
<span v-if="!reportsByRun.get(r.id)?.length" class="dash" aria-label="没有导出">—</span>
</td>
</tr>
</tbody>
</table>
</section>
<section class="card flush" aria-labelledby="h-files">
<div class="card-h">
<h2 id="h-files">文件</h2>
<RouterLink v-if="canAddendum" class="btn sm r" :to="{ name: 'project-new', query: { project: projectId, addendum: '1' } }"><Plus class="ic" :stroke-width="1.5" />追加补遗 / 澄清</RouterLink>
</div>
<div class="files">
<h3 class="cap muted">招标方</h3>
<p v-if="!tenderDocs.length" class="cap muted none">还没有上传招标文件</p>
<div v-for="d in tenderDocs" :key="d.id" class="file-row">
<span class="ext">{{ d.detected_format.toUpperCase() }}</span>
<div>
<b>{{ d.label }}</b>
<span class="cap muted">{{ d.original_filename }}<template v-if="d.page_count"> · <span class="fig">{{ d.page_count }}</span> 页</template> · {{ parseState(d) }}</span>
</div>
<span class="acts"><RouterLink class="btn sm text" :to="{ name: 'checklist', params: { projectId } }">读标</RouterLink></span>
</div>
<h3 class="cap muted">投标文件(加密前的 Word / PDF 稿)</h3>
<p v-if="!bidDocs.length" class="cap muted none">还没有上传投标文件</p>
<div v-for="d in bidDocs" :key="d.id" class="file-row">
<span class="ext">{{ d.detected_format.toUpperCase() }}</span>
<div>
<b>{{ d.label }}</b>
<span class="cap muted"><template v-if="d.page_count"><span class="fig">{{ d.page_count }}</span> 页 · </template>{{ fmtDateTime(d.created_at) }} 上传</span>
</div>
</div>
</div>
</section>
</div>
<aside class="split-doc" aria-label="原文">
<DocStage v-model:active="side" :slots="slots" />
@@ -217,6 +325,47 @@ function openCitation(c: Citation) {
.card-h .r {
margin-left: auto;
}
.pad {
padding: 0 var(--card-x) var(--sp-4);
}
.runs {
margin: 0;
}
.runs td {
vertical-align: baseline;
}
.runs .nowrap {
white-space: nowrap;
}
.sevs {
display: inline-flex;
gap: var(--sp-1);
margin-right: var(--sp-2);
}
.rep {
display: inline-flex;
gap: var(--sp-1);
align-items: center;
}
.rep + .rep {
margin-left: var(--sp-2);
}
.files {
display: flex;
flex-direction: column;
gap: var(--sp-2);
padding: 0 var(--card-x) var(--sp-4);
}
.files h3 {
margin: var(--sp-2) 0 0;
font-weight: var(--fw-regular);
}
.files .file-row b {
font-weight: var(--fw-medium);
}
.files .file-row .cap {
display: block;
}
.facts-list {
margin: 0;
}