走查(注册 → 读标 → 查标 → 导出报告)发现的问题: - 下拉:原生 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>
815 lines
35 KiB
Vue
815 lines
35 KiB
Vue
<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>
|