Files
bid-doctor/apps/web/src/pages/ResultsPage.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

815 lines
35 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">
/**
* 查标(原型 results.html,FR-M3-03、07、08)。第 1 步的最小版本:
* - 检查进行中显示进度和取消;失败时写明原因和是否退回额度;
* - 顶部是检查覆盖范围:查了什么、没查什么(m3-plan §2.5,红线 3);
* - 问题按级别筛选,逐条处理:确认无误 / 已修改 / 忽略并注明原因;致命问题确认无误也要写说明(U4),
* 说明框在行内展开;处理后显示署名,可以撤销(U 键在第 6 步);
* - 每个问题带招标原文和投标原文两侧出处,「未找到」写明找过哪些分册;点出处在右侧抽屉里看原文;
* - 只有最近一次检查的问题可以处理,历史批次只读。
* - 需人工核对(FR-M3-04、P2):份数、密封、递交、CA 加密等文件里看不出来的要求,按项目勾选,写勾选人和时间;
* TODO(第 6 步):键盘操作、批量处理「提示」、判断有误反馈、导出报告。
*/
import { unwrap } from "@bid-doctor/api-client";
import { ChevronDown, ChevronRight, Download, X } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { RouterLink } from "vue-router";
import CitationBlock from "@/components/CitationBlock.vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import PdfViewer, { type ViewerTarget } from "@/components/PdfViewer.vue";
import ProjectHead from "@/components/ProjectHead.vue";
import { api, errorTitle, isApiError } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys";
import { CHECK_STEPS, checkStepIndex, FINDING_ACTION, fmtDateTime, fmtDay, SEVERITY, SEVERITY_CLASS } from "@/lib/labels";
import { TERMINAL_JOB, useCheckRun, useFindings, useManualChecks, useProject, type Citation, type Finding, type ManualCheck } from "@/lib/queries";
import { toast } from "@/lib/toast";
const props = defineProps<{ projectId: string; runId: string }>();
const qc = useQueryClient();
const project = useProject(() => props.projectId);
const runQ = useCheckRun(() => props.runId);
const run = computed(() => runQ.data.value);
const done = computed(() => run.value?.status === "succeeded");
const findingsQ = useFindings(() => props.runId, done);
const p = computed(() => project.data.value);
useCrumbs(() => [{ label: "工作台", to: { name: "workbench" } }, { label: p.value?.name ?? "项目" }, { label: "查标" }]);
const readOnly = computed(() => !run.value?.is_latest || p.value?.status === "submitted" || p.value?.status === "archived");
const step = computed(() => checkStepIndex(run.value?.stage));
/* ---------- 进行中 ---------- */
const cancelling = ref(false);
async function cancel() {
cancelling.value = true;
try {
await unwrap(api.POST("/check-runs/{run_id}/cancel", { params: { path: { run_id: props.runId } } }));
void qc.invalidateQueries({ queryKey: qk.checkRun(props.runId) });
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
cancelling.value = false;
}
}
/* ---------- 问题 ---------- */
type Sev = "" | Finding["severity"];
const sev = ref<Sev>("");
const all = computed(() => findingsQ.data.value?.items ?? []);
/** 清单里重复出现的要求(决策 C5 修订):各自判断,同组只显示第一条,处理时一起处理 */
const hidden = computed(() => {
const seen = new Set<string>();
const out = new Set<string>();
for (const f of all.value) {
if ((f.duplicate_ids ?? []).some((id) => seen.has(id))) out.add(f.id);
seen.add(f.id);
}
return out;
});
const group = (f: Finding) => [f, ...all.value.filter((x) => (f.duplicate_ids ?? []).includes(x.id))];
const live = computed(() => all.value.filter((f) => f.presence !== "resolved" && !hidden.value.has(f.id)));
/** 这次打开页面后处理的问题留在原位(署名行),J / K 能回到它按 U 撤销;之前处理的折叠在「已处理」(规范 §8.2) */
const session = ref(new Set<string>());
const inSev = (f: Finding) => !sev.value || f.severity === sev.value;
const todo = computed(() => live.value.filter((f) => (f.status === "pending" || session.value.has(f.id)) && inSev(f)));
const handled = computed(() => live.value.filter((f) => f.status !== "pending" && !session.value.has(f.id) && inSev(f)));
const doneCount = computed(() => live.value.filter((f) => f.status !== "pending").length);
const resolved = computed(() => all.value.filter((f) => f.presence === "resolved"));
const count = (s: Sev) => live.value.filter((f) => f.status === "pending" && (!s || f.severity === s)).length;
const showHandled = ref(false);
const showResolved = ref(false);
/** 行内说明框(U4):忽略、致命问题的确认无误必须写说明 */
const noteFor = ref<{ id: string; status: "confirmed_ok" | "ignored" } | null>(null);
const note = ref("");
const noteErr = ref("");
const busyId = ref<string | null>(null);
const needsNote = (f: Finding, status: string) => status === "ignored" || (status === "confirmed_ok" && f.severity === "fatal");
function act(f: Finding, status: "confirmed_ok" | "modified" | "ignored") {
if (needsNote(f, status)) {
noteFor.value = { id: f.id, status: status as "confirmed_ok" | "ignored" };
note.value = "";
noteErr.value = "";
return;
}
void resolve(f, status);
}
async function resolve(f: Finding, status: "confirmed_ok" | "modified" | "ignored", text?: string) {
if (text !== undefined && text.trim().length < 5) {
noteErr.value = status === "ignored" ? "至少写 5 个字,说明为什么不处理" : "请写一句说明,至少 5 个字";
return;
}
busyId.value = f.id;
try {
for (const x of group(f).filter((g) => g.status === "pending")) {
await unwrap(
api.POST("/findings/{finding_id}/resolve", { params: { path: { finding_id: x.id } }, body: { status, ...(text ? { note: text.trim() } : {}) } }),
);
}
noteFor.value = null;
remember(group(f));
toast(`已标记为「${FINDING_ACTION[status]}」`, { undo: () => void reopen(f) });
refresh();
next(f);
} catch (e) {
if (isApiError(e, "note_required")) noteErr.value = "请填写说明";
else toast(errorTitle(e), { ok: false });
} finally {
busyId.value = null;
}
}
function remember(fs: Finding[]) {
session.value = new Set([...session.value, ...fs.map((x) => x.id)]);
for (const x of fs) picked.value.delete(x.id);
}
/* ---------- 批量处理:只开放给「提示」(FR-M3-07) ---------- */
const picked = ref(new Set<string>());
const pickable = (f: Finding) => !readOnly.value && f.severity === "hint" && f.status === "pending";
const hints = computed(() => todo.value.filter(pickable));
const pickedList = computed(() => live.value.filter((f) => picked.value.has(f.id) && pickable(f)));
const allPicked = computed(() => hints.value.length > 0 && hints.value.every((f) => picked.value.has(f.id)));
function pick(f: Finding, on: boolean) {
const n = new Set(picked.value);
if (on) n.add(f.id);
else n.delete(f.id);
picked.value = n;
}
function pickAll(on: boolean) {
picked.value = on ? new Set(hints.value.map((f) => f.id)) : new Set();
}
const bulkBusy = ref(false);
async function bulkOk() {
const fs = pickedList.value.flatMap(group).filter((x) => x.status === "pending");
if (!fs.length) return;
bulkBusy.value = true;
try {
await unwrap(
api.POST("/check-runs/{run_id}/findings/bulk-resolve", {
params: { path: { run_id: props.runId } },
body: { finding_ids: fs.map((x) => x.id), status: "confirmed_ok" },
}),
);
remember(fs);
const n = pickedList.value.length || fs.length;
toast(`已将 ${n} 项「提示」标记为确认无误,逐条记了处理人和时间`, { undo: () => void Promise.all(fs.map((x) => reopenOne(x.id))).then(refresh) });
refresh();
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
bulkBusy.value = false;
}
}
const reopenOne = (id: string) => unwrap(api.POST("/findings/{finding_id}/reopen", { params: { path: { finding_id: id } } }));
async function reopen(f: Finding) {
try {
for (const x of group(f)) await reopenOne(x.id);
refresh();
} catch (e) {
toast(errorTitle(e), { ok: false });
}
}
function refresh() {
void qc.invalidateQueries({ queryKey: qk.findings(props.runId) });
void qc.invalidateQueries({ queryKey: qk.project(props.projectId) });
void qc.invalidateQueries({ queryKey: qk.projects });
}
/* ---------- 资质问题:只按资质库记录判断的,出处是资质库那条记录(QL2,check/qualification.py 存的快照) ---------- */
interface LibrarySnapshot {
name: string;
cert_no: string | null;
level: string | null;
valid_to: string | null;
}
function librarySnapshots(f: Finding): LibrarySnapshot[] {
if (f.type !== "qualification") return [];
const issues = (f.detail as { issues?: { source?: string; library?: LibrarySnapshot }[] }).issues ?? [];
return issues.filter((x) => x.source === "library" && x.library).map((x) => x.library!);
}
const libLine = (r: LibrarySnapshot) =>
[r.cert_no && `编号 ${r.cert_no}`, r.level && `等级 ${r.level}`, r.valid_to ? `有效期至 ${fmtDay(r.valid_to)}` : "长期有效"].filter(Boolean).join(" · ");
/* ---------- 需人工核对 ---------- */
const manualQ = useManualChecks(() => props.projectId);
const manual = computed(() => manualQ.data.value?.items ?? []);
const manualDone = computed(() => manual.value.filter((m) => m.checked).length);
const manualBusy = ref<string | null>(null);
const projectReadOnly = computed(() => p.value?.status === "submitted" || p.value?.status === "archived");
async function toggleManual(m: ManualCheck, checked: boolean) {
manualBusy.value = m.check_key;
try {
await unwrap(
api.PUT("/projects/{project_id}/manual-checks", { params: { path: { project_id: props.projectId } }, body: { check_key: m.check_key, checked } }),
);
void qc.invalidateQueries({ queryKey: qk.manualChecks(props.projectId) });
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
manualBusy.value = null;
}
}
/* ---------- 原文抽屉 ---------- */
const viewer = ref<{ doc: { id: string; label: string }; target: ViewerTarget & { id: string } } | null>(null);
function openCitation(c: Citation) {
viewer.value = {
doc: { id: c.document_id, label: c.document_label ?? "" },
target: { id: c.id, page: c.page_no, quads: c.quads, sha: c.normalized_sha256 },
};
}
const firstCite = (f: Finding) => f.tender_citations[0] ?? f.bid_citations[0];
/* ---------- 当前条与键盘(规范 §8.2):J / K 移动,O / Enter 看原文,1 / 2 / 3 处理,X 勾选「提示」,U 撤销,Esc 关原文 ---------- */
const curId = ref<string | null>(null);
const cur = computed(() => todo.value.find((f) => f.id === curId.value) ?? todo.value[0]);
const kbOn = ref(false);
function setCur(f: Finding, scroll = false) {
curId.value = f.id;
if (scroll) requestAnimationFrame(() => document.querySelector(`[data-fid="${f.id}"]`)?.scrollIntoView({ block: "nearest" }));
}
/** 处理完跳到下一条待处理 */
function next(f: Finding) {
const list = todo.value;
const i = list.findIndex((x) => x.id === f.id);
const n = list.find((x, j) => j > i && x.status === "pending" && x.id !== f.id);
if (n) setCur(n, true);
}
function onKey(e: KeyboardEvent) {
if (e.ctrlKey || e.metaKey || e.altKey || document.querySelector(".scrim")) return;
if ((e.target as HTMLElement | null)?.closest("input, textarea, select, [role=listbox], [contenteditable]")) return;
if (e.key === "Escape") {
viewer.value = null;
return;
}
const f = cur.value;
if (!f) return;
const k = e.key.toLowerCase();
if (k === "j" || k === "k") {
e.preventDefault();
const list = todo.value;
const i = Math.max(0, list.indexOf(f));
const n = list[Math.max(0, Math.min(list.length - 1, i + (k === "j" ? 1 : -1)))];
if (!n) return;
kbOn.value = true;
setCur(n, true);
const c = firstCite(n);
if (viewer.value && c) openCitation(c); // 原文开着时跟着翻到当前条的出处
} else if (k === "o" || (e.key === "Enter" && !(e.target as HTMLElement).closest("button, a, label"))) {
const c = firstCite(f);
if (c) {
e.preventDefault();
openCitation(c);
}
} else if (readOnly.value) {
return;
} else if (k === "u") {
if (f.status !== "pending") void reopen(f);
else toast("当前这一条还没有处理", { ok: false });
} else if (k === "x" && pickable(f)) {
pick(f, !picked.value.has(f.id));
} else if (["1", "2", "3"].includes(e.key) && f.status === "pending" && noteFor.value?.id !== f.id) {
e.preventDefault();
act(f, (["confirmed_ok", "modified", "ignored"] as const)[Number(e.key) - 1]!);
}
}
onMounted(() => window.addEventListener("keydown", onKey));
onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
/* ---------- 判断有误?(J5):反馈只用于改进检查规则,不改变处理状态 ---------- */
const FEEDBACK_REASONS = ["这不是问题(误报)", "级别不对", "出处定位错了", "其他"];
const fbFor = ref<Finding | null>(null);
const fbReason = ref(FEEDBACK_REASONS[0]!);
const fbNote = ref("");
const fbBusy = ref(false);
const fbOpen = computed({ get: () => !!fbFor.value, set: (v) => { if (!v) fbFor.value = null; } });
function askFeedback(f: Finding) {
fbFor.value = f;
fbReason.value = FEEDBACK_REASONS[0]!;
fbNote.value = "";
}
async function sendFeedback() {
const f = fbFor.value;
if (!f) return;
fbBusy.value = true;
try {
await unwrap(
api.POST("/feedback", {
body: {
project_id: props.projectId,
object_type: "finding",
object_id: f.id,
kind: "false_positive",
note: [fbReason.value, fbNote.value.trim()].filter(Boolean).join(":").slice(0, 1000),
},
}),
);
fbFor.value = null;
toast("已收到反馈");
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
fbBusy.value = false;
}
}
/* ---------- 检查覆盖范围:默认收起成一行,展开看全部(报告首页照样全文列出) ---------- */
const showCoverage = ref(false);
const metaLine = computed(() => {
const r = run.value;
if (!r) return "";
const files = (r.documents ?? []).map((d) => d.label).join("、");
return [`第 ${r.run_no} 次检查`, fmtDateTime(r.created_at), files, `清单 v${r.checklist_version_no}(${r.checklist_confirmed ? "已确认" : "未确认"})`]
.filter(Boolean)
.join(" · ");
});
const errorText = computed(() => {
const e = run.value?.error;
if (!e) return "";
return `${e.message}。${e.refunded ? "已扣的额度已退回。" : "这次检查没有扣点。"}`;
});
</script>
<template>
<ProjectHead :project="p" tab="check" />
<div class="content">
<!-- 进行中 -->
<section v-if="run && !TERMINAL_JOB.has(run.status)" class="card flush running" aria-labelledby="h-run" aria-live="polite">
<div class="card-h"><h2 id="h-run">正在检查</h2></div>
<div class="card-b">
<ol class="steps">
<li v-for="(s, i) in CHECK_STEPS" :key="s.label" :class="{ done: i < step, now: i === step }">
<span class="dot" aria-hidden="true">{{ i < step ? "✓" : "" }}</span>
<span>{{ s.label }}<span v-if="i === step" class="vh">(进行中)</span></span>
</li>
</ol>
<div class="progress">
<div class="bar" role="progressbar" :aria-valuenow="run.progress" aria-valuemin="0" aria-valuemax="100" aria-label="检查进度"><i :style="{ width: `${run.progress}%` }"></i></div>
<span class="fig">{{ run.progress }}%</span>
</div>
<p class="note">可以离开这个页面,检查完成后会通知你。</p>
</div>
<div class="card-f">
<button type="button" class="btn sm" :disabled="cancelling || run.cancel_requested" @click="cancel">{{ run.cancel_requested ? "正在取消…" : "取消检查" }}</button>
</div>
</section>
<!-- 失败 / 取消 -->
<div v-else-if="run && run.status !== 'succeeded'" class="alert warn" role="alert">
<span class="ico w" aria-hidden="true">!</span>
<p>{{ run.status === "cancelled" ? "检查已取消,额度已退回。" : `检查没有完成:${errorText}` }}</p>
<RouterLink class="btn sm" :to="{ name: 'bid-check', params: { projectId } }">重新检查</RouterLink>
</div>
<template v-else-if="run">
<div class="rbar">
<div>
<h2>检查结果</h2>
<p class="meta" :title="metaLine">{{ metaLine }}</p>
</div>
<div class="actions">
<div class="row">
<RouterLink class="btn" :to="{ name: 'bid-check', params: { projectId } }">重新检查</RouterLink>
<RouterLink class="btn primary" :to="{ name: 'report', params: { projectId } }"><Download class="ic" :stroke-width="1.5" />导出报告</RouterLink>
</div>
<span v-if="p?.readiness.open_fatal_count" class="export-note">还有 <span class="fig">{{ p.readiness.open_fatal_count }}</span> 项致命问题待处理,报告会如实列出</span>
</div>
</div>
<div v-if="!run.is_latest" class="alert warn" role="note"><span class="ico w" aria-hidden="true">!</span><p>这是历史检查,只能查看。</p></div>
<!-- PI5:检查之后改了截止时间、限价、所投的包或法律体系;不自动重新检查(可能扣点,由用户决定) -->
<div v-else-if="run.basis_changed" class="alert warn" role="note">
<span class="ico w" aria-hidden="true">!</span>
<p>项目信息(截止时间、限价、所投的包或法律体系)在这次检查之后改过,这次的结果按检查时的值,重新检查后生效。</p>
<RouterLink class="btn sm" :to="{ name: 'bid-check', params: { projectId } }">重新检查</RouterLink>
</div>
<section v-if="run.coverage" class="card flush coverage" aria-labelledby="h-cov">
<button type="button" class="cov-h" :aria-expanded="showCoverage" aria-controls="cov-body" @click="showCoverage = !showCoverage">
<component :is="showCoverage ? ChevronDown : ChevronRight" class="ic" :stroke-width="1.5" />
<h2 id="h-cov">检查覆盖范围</h2>
<span class="cap muted">查了 <span class="fig">{{ run.coverage.checked?.length ?? 0 }}</span> 项 · 没查 <span class="fig">{{ run.coverage.not_checked?.length ?? 0 }}</span> 项,报告首页会全部写明</span>
</button>
<div v-show="showCoverage" id="cov-body" class="cov-body">
<div>
<h3>查了</h3>
<ul><li v-for="t in run.coverage.checked ?? []" :key="t">{{ t }}</li></ul>
</div>
<div class="no">
<h3>没查</h3>
<ul><li v-for="t in run.coverage.not_checked ?? []" :key="t">{{ t }}</li></ul>
</div>
</div>
</section>
<div v-if="pickedList.length" class="batchbar" role="region" aria-label="批量处理">
<b>已选 <span class="fig">{{ pickedList.length }}</span> 项「提示」</b>
<span class="r">
<button type="button" class="btn sm" :disabled="bulkBusy" @click="bulkOk">批量确认无误</button>
<button type="button" class="btn sm text" @click="pickAll(false)">取消选择</button>
</span>
</div>
<section class="card flush results" aria-label="检查结果">
<div class="card-h">
<div class="seg" role="group" aria-label="按级别筛选">
<button type="button" :aria-pressed="sev === ''" @click="sev = ''">全部 <span class="fig">{{ count("") }}</span></button>
<button v-for="s in (['fatal', 'important', 'hint'] as const)" :key="s" type="button" :aria-pressed="sev === s" @click="sev = s">
<span class="sev" :class="SEVERITY_CLASS[s]">{{ SEVERITY[s] }}<span class="fig">{{ count(s) }}</span></span>
</button>
</div>
<div class="r">
<label v-if="!readOnly" class="choice cap" :class="{ dis: !hints.length }">
<input type="checkbox" class="ck" :checked="allPicked" :indeterminate="!allPicked && pickedList.length > 0" :disabled="!hints.length" @change="pickAll(($event.target as HTMLInputElement).checked)" />全选「提示」
</label>
<span class="cap muted">已处理 <span class="fig">{{ doneCount }}</span> / <span class="fig">{{ live.length }}</span></span>
<span class="cap muted keys" title="J / K 上下条 · O 看原文 · 1 确认无误 · 2 已修改 · 3 忽略 · X 勾选「提示」 · U 撤销"><span class="kbd">J</span><span class="kbd">K</span> 上下条 <span class="kbd">1</span><span class="kbd">2</span><span class="kbd">3</span> 处理 <span class="kbd">U</span> 撤销</span>
</div>
</div>
<div v-if="findingsQ.isPending.value" class="lst-pad"><span class="skel t"></span><span class="skel"></span></div>
<template v-else>
<div class="flist" :class="{ 'kb-on': kbOn }" @pointerdown="kbOn = false">
<article
v-for="f in [...todo, ...(showHandled ? handled : [])]"
:key="f.id"
class="finding"
:class="{ sel: cur?.id === f.id, 'has-ck': !readOnly }"
:data-fid="f.id"
:aria-current="cur?.id === f.id ? 'true' : undefined"
@click="todo.includes(f) && setCur(f)"
>
<label v-if="f.status === 'pending' && !readOnly && pickable(f)" class="ckw" data-tip="批量处理" @click.stop>
<input type="checkbox" class="ck" :checked="picked.has(f.id)" :aria-label="`勾选:${f.title}`" @change="pick(f, ($event.target as HTMLInputElement).checked)" />
</label>
<span v-else-if="f.status === 'pending' && !readOnly" class="ckw" data-tip="只有「提示」可以批量处理"><input type="checkbox" class="ck" disabled aria-label="不可批量" /></span>
<div>
<div class="f-head">
<span class="sev" :class="SEVERITY_CLASS[f.severity]">{{ SEVERITY[f.severity] }}</span>
<span class="f-title">{{ f.title }}</span>
<span v-if="(f.duplicate_ids?.length ?? 0) > 0" class="flag" title="同一要求在清单里出现了几处,各自检查、结果相同,处理时一起处理">清单里出现 <span class="fig">{{ (f.duplicate_ids?.length ?? 0) + 1 }}</span> 处</span>
<span v-if="f.presence === 'carried'" class="flag">沿用<template v-if="f.carried_status === 'modified'">:上次已修改,这次仍查出</template></span>
</div>
<p class="f-text">{{ f.explanation }}</p>
<template v-if="f.status !== 'pending'">
<div class="sign" :class="{ ign: f.status === 'ignored' }">
<span aria-hidden="true">{{ f.status === "ignored" ? "–" : "✓" }}</span><b>{{ f.resolved_by?.name }}</b>
<span v-if="f.resolved_at" class="fig">{{ fmtDateTime(f.resolved_at) }}</span><span>{{ FINDING_ACTION[f.status] }}</span>
<button v-if="!readOnly" type="button" class="undo" @click="reopen(f)">撤销</button>
</div>
<p v-if="f.resolution_note" class="sign-note">{{ f.status === "ignored" ? "原因" : "说明" }}:{{ f.resolution_note }}</p>
</template>
<div v-else-if="noteFor?.id === f.id" class="u4">
<div class="field" :class="{ err: noteErr }">
<label :for="`u4-${f.id}`">{{ noteFor.status === "confirmed_ok" ? "确认无误的说明" : "忽略原因" }} <small>(必填,至少 5 个字)</small></label>
<textarea :id="`u4-${f.id}`" v-model="note" class="input" maxlength="200" :aria-invalid="!!noteErr" @keydown.ctrl.enter="resolve(f, noteFor.status, note)" @keydown.esc="noteFor = null"></textarea>
<span class="msg"><template v-if="noteErr"><span class="ico w" aria-hidden="true">!</span>{{ noteErr }}</template><template v-else>{{ noteFor.status === "confirmed_ok" ? "说明会同时作为误报反馈提交" : "「已忽略」在报告里单独列出" }}</template></span>
</div>
<div class="row">
<button type="button" class="btn sm" :disabled="busyId === f.id" @click="resolve(f, noteFor.status, note)">{{ noteFor.status === "confirmed_ok" ? "确认无误" : "忽略这一项" }}</button>
<button type="button" class="btn sm text" @click="noteFor = null">取消</button>
</div>
</div>
<div v-else-if="!readOnly" class="f-actions">
<button type="button" class="btn sm" :disabled="busyId === f.id" @click="act(f, 'confirmed_ok')">确认无误</button>
<button type="button" class="btn sm" :disabled="busyId === f.id" @click="act(f, 'modified')">已修改</button>
<button type="button" class="btn sm" :disabled="busyId === f.id" @click="act(f, 'ignored')">忽略并注明原因</button>
<button type="button" class="lnk cap feedback" @click="askFeedback(f)">判断有误?</button>
</div>
</div>
<aside>
<CitationBlock v-for="c in f.tender_citations" :key="c.id" :citation="c" :active="viewer?.target.id === c.id" @open="openCitation(c)" />
<CitationBlock v-for="c in f.bid_citations" :key="c.id" :citation="c" :active="viewer?.target.id === c.id" @open="openCitation(c)" />
<div v-for="(r, i) in librarySnapshots(f)" :key="`lib-${i}`" class="cite lib">
<RouterLink class="cite-src" :to="{ name: 'qualifications' }">依据:资质库记录</RouterLink>
<p><b>{{ r.name }}</b><br />{{ libLine(r) }}</p>
</div>
<div v-if="f.type === 'unresponded' && f.search_scope" class="cite missing">
<span class="cite-src">投标文件 · 未找到</span>
<p>找过:{{ (f.search_scope.documents ?? []).join("、") || "全部分册" }}</p>
</div>
</aside>
</article>
</div>
<div v-if="!todo.length" class="empty sm">
<b>{{ live.length ? (sev ? "这个级别的问题都已处理" : "全部问题都已处理") : "没有发现问题" }}</b>
<p>{{ live.length ? "已处理的在下方,可以撤销。" : "只表示本次检查的范围内没有发现问题,不代表投标文件没有问题。没查的内容见上方「没查」。" }}</p>
</div>
<button v-if="handled.length" type="button" class="group-h" :aria-expanded="showHandled" @click="showHandled = !showHandled">
<component :is="showHandled ? ChevronDown : ChevronRight" class="ic" :stroke-width="1.5" />已处理(<span class="fig">{{ handled.length }}</span>)
</button>
<button v-if="resolved.length" type="button" class="group-h" :aria-expanded="showResolved" @click="showResolved = !showResolved">
<component :is="showResolved ? ChevronDown : ChevronRight" class="ic" :stroke-width="1.5" />本次已解决(<span class="fig">{{ resolved.length }}</span>)<span class="sub">上次有、这次没再发现,已自动关闭</span>
</button>
<template v-if="showResolved">
<div v-for="f in resolved" :key="f.id" class="resolved">
<span class="sev" :class="SEVERITY_CLASS[f.severity]">{{ SEVERITY[f.severity] }}</span><s>{{ f.title }}</s>
<span v-if="f.carried_status && f.carried_status !== 'pending'" class="cap">上次:{{ FINDING_ACTION[f.carried_status] }}</span>
</div>
</template>
</template>
</section>
<section v-if="manual.length" class="card flush manual" aria-labelledby="h-manual">
<div class="card-h">
<h2 id="h-manual">需人工核对</h2>
<span class="cap">文件里看不出来,递交前请自己核对。勾选按项目保存,复查后还在。</span>
<span class="cap r">已核对 <span class="fig">{{ manualDone }}</span> / <span class="fig">{{ manual.length }}</span></span>
</div>
<article v-for="m in manual" :key="m.check_key" class="finding">
<div>
<label class="choice">
<input type="checkbox" class="ck" :checked="m.checked" :disabled="projectReadOnly || manualBusy === m.check_key" @change="toggleManual(m, ($event.target as HTMLInputElement).checked)" />
<span class="f-title">{{ m.label }}</span>
</label>
<p v-if="m.requirement_text" class="f-text">招标文件:{{ m.requirement_text }}<template v-if="(m.related_items?.length ?? 0) > 1">(同类要求共 <span class="fig">{{ m.related_items?.length }}</span> 条)</template></p>
<div v-if="m.checked" class="sign">
<span aria-hidden="true">✓</span><b>{{ m.checked_by?.name }}</b>
<span v-if="m.checked_at" class="fig">{{ fmtDateTime(m.checked_at) }}</span><span>已核对</span>
</div>
</div>
<aside>
<CitationBlock v-if="m.citation" :citation="m.citation" :active="viewer?.target.id === m.citation.id" @open="openCitation(m.citation)" />
</aside>
</article>
</section>
</template>
<div v-else-if="runQ.error.value" class="alert warn" role="alert"><span class="ico w" aria-hidden="true">!</span><p>{{ errorTitle(runQ.error.value) }}</p></div>
</div>
<ConfirmDialog v-model:open="fbOpen" title="反馈:判断有误" ok-text="提交反馈" busy-text="正在提交…" :busy="fbBusy" @ok="sendFeedback">
<p class="muted fb-title">「{{ fbFor?.title }}」</p>
<div class="fb-choices" role="radiogroup" aria-label="哪里不对">
<label v-for="r in FEEDBACK_REASONS" :key="r" class="choice"><input v-model="fbReason" type="radio" class="ck" name="fb" :value="r" />{{ r }}</label>
</div>
<div class="field fill fb-note">
<label for="fb-note">补充说明 <small>(选填)</small></label>
<textarea id="fb-note" v-model="fbNote" class="input" maxlength="200"></textarea>
<span class="msg">反馈只用于改进检查规则,不改变这条问题的处理状态。</span>
</div>
</ConfirmDialog>
<aside v-if="viewer" class="drawer" aria-label="原文">
<button type="button" class="icon-btn sm close" aria-label="关闭原文" @click="viewer = null"><X class="ic" :stroke-width="1.5" /></button>
<PdfViewer :document-id="viewer.doc.id" :document-label="viewer.doc.label" :target="viewer.target" />
</aside>
</template>
<style scoped>
.running {
max-width: 736px;
}
.running .card-b {
padding-top: var(--sp-2);
}
.running .progress {
margin-top: var(--sp-3);
}
.rbar {
display: flex;
align-items: flex-end;
gap: var(--sp-4);
margin-bottom: var(--sp-4);
}
.rbar h2 {
margin: 0;
font: var(--fw-bold) var(--fs-title) / var(--lh-title) var(--font-sans);
}
.rbar .meta {
margin: 2px 0 0;
color: var(--text-secondary);
font-size: var(--fs-caption);
}
.rbar .actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--sp-1);
margin-left: auto;
}
.rbar .actions .row {
display: flex;
gap: var(--sp-2);
}
.export-note {
color: var(--text-secondary);
font-size: var(--fs-caption);
white-space: nowrap;
}
.coverage {
margin-bottom: var(--sp-4);
}
.cov-h {
display: flex;
align-items: center;
gap: var(--sp-2);
width: 100%;
padding: var(--sp-3) var(--card-x);
border: 0;
background: none;
font: inherit;
color: var(--text-primary);
text-align: left;
cursor: pointer;
}
.cov-h h2 {
margin: 0;
font-size: var(--fs-body);
font-weight: var(--fw-bold);
}
.cov-h .ic {
color: var(--text-secondary);
}
.cov-body {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--sp-6);
padding: 0 var(--card-x) var(--sp-4);
}
.coverage h3 {
margin: 0 0 var(--sp-1);
font-size: var(--fs-body);
}
.coverage ul {
margin: 0;
padding-left: 1.2em;
}
.coverage .no {
color: var(--text-secondary);
}
.results .card-h {
display: flex;
align-items: center;
gap: var(--sp-3);
}
.results .card-h .r {
display: flex;
align-items: center;
gap: var(--sp-4);
margin-left: auto;
white-space: nowrap;
}
.keys .kbd {
margin-right: 2px;
}
.flist:focus {
outline: 0;
}
.f-actions .feedback {
margin-left: auto;
color: var(--text-secondary);
}
.fb-title {
margin: 0;
}
.fb-choices {
display: flex;
flex-direction: column;
gap: var(--sp-2);
margin-top: var(--sp-3);
}
.fb-note {
margin-top: var(--sp-4);
}
.seg {
display: flex;
gap: var(--sp-2);
}
.seg button {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
padding: 2px var(--sp-2);
border-radius: var(--radius-ctl);
color: var(--text-secondary);
}
.seg button[aria-pressed="true"] {
background: var(--c-n100);
color: var(--text-primary);
}
.manual {
margin-top: var(--sp-4);
}
.manual .card-h {
display: flex;
align-items: baseline;
gap: var(--sp-2);
}
.manual .card-h h2 {
margin: 0;
font-size: var(--fs-body);
}
.manual .card-h .r {
margin-left: auto;
}
.sign.ign {
color: var(--text-secondary);
}
.sign .undo {
border: 0;
background: none;
padding: 0;
cursor: pointer;
text-decoration: underline;
}
.sign-note {
margin: var(--sp-1) 0 0;
color: var(--text-secondary);
font-size: var(--fs-caption);
}
.u4 {
margin-top: var(--sp-3);
}
.u4 .field {
max-width: none;
}
.u4 textarea {
min-height: 64px;
}
.u4 .row {
display: flex;
gap: var(--sp-2);
margin-top: var(--sp-2);
}
.cite.missing p {
margin: 0;
color: var(--text-secondary);
}
/* 资质库是用户自己录的数据:墨色(人工),不用 AI 出处的蓝铅笔色 */
.cite.lib {
border-left-color: var(--c-ink);
}
.cite.lib .cite-src {
color: var(--text-primary);
}
.cite.lib p {
margin: 2px 0 0;
font-size: var(--fs-caption);
line-height: var(--lh-caption);
color: var(--text-secondary);
}
.cite.lib p b {
color: var(--text-primary);
}
.group-h {
display: flex;
align-items: center;
gap: var(--sp-1);
width: 100%;
padding: var(--sp-3) var(--card-x);
border: 0;
border-top: var(--line) solid var(--border-subtle);
background: none;
font: inherit;
cursor: pointer;
color: var(--text-secondary);
text-align: left;
}
.group-h .sub {
margin-left: var(--sp-2);
font-size: var(--fs-caption);
}
.resolved {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-2) var(--card-x);
color: var(--text-secondary);
}
.drawer {
position: fixed;
top: 0;
right: 0;
bottom: 0;
z-index: 40;
display: flex;
width: min(640px, 50vw);
background: var(--bg-surface);
box-shadow: var(--shadow-overlay);
}
.drawer > :deep(*:last-child) {
flex: 1;
min-width: 0;
}
/* 关闭按钮挂在抽屉左边缘外侧,不挡查看器自己的翻页按钮 */
.drawer .close {
position: absolute;
top: var(--sp-3);
left: calc(-1 * var(--sp-8));
background: var(--bg-surface);
box-shadow: var(--shadow-overlay);
}
</style>