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

737 lines
33 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">
/**
* 读标(原型 checklist.html):项目头 + 项目检查清单 + 原文查看器(对照布局 §5.3)。
* - 清单按分类分组,待确认区放在顶部、不和正式清单混排(§7.6);顶部写明标注规则和数量核对提示;
* - 点清单项或出处:右侧翻到原文那一页并高亮原句;J / K 上下条;
* - 待确认:采纳 / 补上出处后采纳 / 修改后采纳 / 丢弃;正式清单:修改、补出处、删除(可撤销)、修改记录;
* - 补出处:查看器进入选句模式,点选同一页上的句子,建一条人工出处再挂到条目上;
* - 确认清单:生成不可变版本,查标以这一版为依据;之后再改会回到草稿,历史保留(FR-M2-08)。
*/
import { unwrap } from "@bid-doctor/api-client";
import { FileText, Info, Plus, Search } from "@lucide/vue";
import { useQuery, useQueryClient } from "@tanstack/vue-query";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { RouterLink, useRoute } from "vue-router";
import ChecklistItemRow from "@/components/ChecklistItemRow.vue";
import CitationBlock from "@/components/CitationBlock.vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import HistoryDialog from "@/components/HistoryDialog.vue";
import ItemDialog from "@/components/ItemDialog.vue";
import PdfViewer, { type ViewerTarget } from "@/components/PdfViewer.vue";
import ProjectHead from "@/components/ProjectHead.vue";
import SelectBox from "@/components/SelectBox.vue";
import { api, errorTitle } from "@/lib/api";
import { useChecklistOps, useDeletedItems, type ItemFields } from "@/lib/checklistOps";
import { groupCitations, type CitationGroup } from "@/lib/citations";
import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys";
import { category, CATEGORY_ORDER, fmtDateTime, MARKER, projectStatus } from "@/lib/labels";
import { useChecklist, useChecklistItems, useLots, useProject, type ChecklistItem, type Citation, type MarkingRule } from "@/lib/queries";
import { toast } from "@/lib/toast";
const props = defineProps<{ projectId: string }>();
const project = useProject(() => props.projectId);
const checklist = useChecklist(() => props.projectId);
const lotScope = ref<"bidding" | "all">("bidding"); // 默认只列通用 + 所投的包(FR-M2-04)
const itemsQ = useChecklistItems(() => props.projectId, lotScope);
const lotsQ = useLots(() => props.projectId);
const realLots = computed(() => (lotsQ.data.value?.items ?? []).filter((l) => !l.is_default));
const biddingNos = computed(() => realLots.value.filter((l) => l.is_bidding).map((l) => `采购包 ${l.lot_no}`));
const docsQ = useQuery({
queryKey: computed(() => ["project-documents", props.projectId] as const),
queryFn: () => unwrap(api.GET("/projects/{project_id}/documents", { params: { path: { project_id: props.projectId } } })),
});
const ops = useChecklistOps(() => props.projectId);
const p = computed(() => project.data.value);
const cl = computed(() => checklist.data.value);
const readOnly = computed(() => p.value?.status === "submitted" || p.value?.status === "archived");
useCrumbs(() => [{ label: "工作台", to: { name: "workbench" } }, { label: p.value?.name ?? "项目" }, { label: "读标" }]);
/* ---------- 分组与筛选 ---------- */
const all = computed(() => (itemsQ.data.value?.items ?? []).filter((i) => !i.deleted_at));
const formal = computed(() => all.value.filter((i) => i.zone === "formal"));
const pending = computed(() => all.value.filter((i) => i.zone === "pending"));
type Cat = "all" | "pending" | "deleted" | ChecklistItem["categories"][number];
const cat = ref<Cat>("all");
const kw = ref("");
const starOnly = ref(false);
/* 补遗(FR-M2-10):按来源文件筛选(解析页「核对补遗」带 ?source=<文档>),只看待确认的日期更新 */
const route = useRoute();
const source = ref(typeof route.query.source === "string" ? route.query.source : "");
const supOnly = ref(false);
const sources = computed(() => {
const m = new Map<string, { id: string; label: string; n: number }>();
for (const i of formal.value) {
const d = i.origin_document;
if (!d) continue;
const e = m.get(d.id) ?? { id: d.id, label: d.label, n: 0 };
e.n++;
m.set(d.id, e);
}
return [...m.values()].sort((a, b) => (a.label === "招标文件" ? -1 : b.label === "招标文件" ? 1 : a.label.localeCompare(b.label, "zh-CN")));
});
const addenda = computed(() => sources.value.filter((x) => x.label !== "招标文件"));
const supPending = (i: ChecklistItem) => !!i.superseded_by && !i.superseded_by.confirmed;
const supCount = computed(() => formal.value.filter(supPending).length);
const deletedQ = useDeletedItems(() => props.projectId, () => cat.value === "deleted");
const deleted = computed(() => deletedQ.data.value ?? []);
const catCount = (c: string) => formal.value.filter((i) => i.categories.includes(c as ChecklistItem["categories"][number])).length;
const cats = computed(() => CATEGORY_ORDER.filter((c) => catCount(c) > 0));
const match = (i: ChecklistItem) =>
(!starOnly.value || i.importance === "substantive") &&
(!kw.value || i.title.includes(kw.value.trim())) &&
(!source.value || i.origin_document?.id === source.value) &&
(!supOnly.value || supPending(i));
/** 「全部」视图下每条只出现一次,放在它的第一个分类里(一条要求可以属于多个分类) */
const groups = computed(() => {
const order = cat.value === "all" ? cats.value : cats.value.filter((c) => c === cat.value);
const seen = new Set<string>();
return order
.map((c) => {
const rows = formal.value.filter((i) => i.categories.includes(c) && match(i) && (cat.value !== "all" || !seen.has(i.id)));
rows.forEach((i) => seen.add(i.id));
return { id: c, name: category(c), rows };
})
.filter((g) => g.rows.length);
});
const listMode = computed(() => (cat.value === "pending" || cat.value === "deleted" ? cat.value : "formal"));
const showTop = computed(() => cat.value === "all" && !kw.value && !starOnly.value && !source.value && !supOnly.value);
const showPending = computed(() => (cat.value === "all" && showTop.value) || cat.value === "pending");
/** 当前视图里自上而下的条目(J / K 用) */
const visible = computed<ChecklistItem[]>(() => {
if (cat.value === "deleted") return deleted.value;
const top = showPending.value ? pending.value : [];
return cat.value === "pending" ? top : [...top, ...groups.value.flatMap((g) => g.rows)];
});
/** 确认「已被补遗 N 更新」:采用时旧条目删除(可以恢复),保留两条时解除关联(AD3) */
async function resolveSupersession(i: ChecklistItem, accept: boolean) {
try {
await unwrap(api.POST("/checklist-items/{item_id}/supersession", { params: { path: { item_id: i.id } }, body: { accept } }));
void qc.invalidateQueries({ queryKey: qk.checklistItems(props.projectId) });
void qc.invalidateQueries({ queryKey: qk.checklist(props.projectId) });
void qc.invalidateQueries({ queryKey: qk.project(props.projectId) });
toast(accept ? "已按补遗更新,旧条目已删除(可以在「已删除」里恢复)" : "两条都保留,请自己核对");
} catch (e) {
toast(errorTitle(e), { ok: false });
}
}
/* ---------- 标注规则(如「打 ★ 的为实质性要求」)和数量核对 ---------- */
function ruleText(r: MarkingRule): string {
const mk = r.marker === "keyword" ? `「${r.marker_text ?? ""}」字样` : `「${r.marker_text || MARKER[r.marker]}」`;
const head = `标注${mk}的为${r.meaning === "substantive" ? "实质性要求" : "重要要求"}。`;
return r.declared_count == null
? `${head}文件没写这类条款的总数,已识别 ${r.found_count} 条,请对照原文核对有无遗漏。`
: `${head}文件写明共 ${r.declared_count} 条,已识别 ${r.found_count} 条。`;
}
const loading = computed(() => itemsQ.isPending.value || checklist.isPending.value);
const loadErr = computed(() => itemsQ.error.value ?? checklist.error.value);
/* ---------- 当前条与原文查看器 ---------- */
const tender = computed(() => docsQ.data.value?.items.find((d) => d.role === "tender" && d.status === "ready"));
const selId = ref<string | null>(null);
const selected = computed(() => [...all.value, ...deleted.value].find((i) => i.id === selId.value) ?? null);
const viewDoc = ref<{ id: string; label: string } | null>(null);
const target = ref<(ViewerTarget & { id: string }) | null>(null);
function openCitation(c: Citation, tail: Citation[] = []) {
viewDoc.value = { id: c.document_id, label: c.document_label };
const more = tail.map((x) => ({ page: x.page_no, quads: x.quads }));
target.value = { id: c.id, page: c.page_no, quads: c.quads, sha: c.normalized_sha256, more };
}
const openGroup = (g: CitationGroup) => openCitation(g.head, g.tail);
/* ---------- 响应方式(C12):招标文件要求逐条响应,还是只列偏离、没列出的视为接受;查标按它判没找到响应算不算问题 ---------- */
const qc = useQueryClient();
const MODE_CATEGORY: Record<string, string> = { technical: "技术要求", commercial: "商务要求" };
const MODE_OPTIONS = [
{ value: "explicit", label: "逐条响应" },
{ value: "implicit", label: "只列偏离,没列出的视为接受" },
];
const MODE_STATUS: Record<string, string> = { unknown: "招标文件没写明,按逐条响应处理", conflict: "招标文件两处写法不一致,按逐条响应处理" };
const modeBusy = ref("");
async function setMode(category: "technical" | "commercial", mode: "explicit" | "implicit") {
modeBusy.value = category;
try {
await unwrap(
api.PUT("/projects/{project_id}/checklist/response-modes/{category}", {
params: { path: { project_id: props.projectId, category } },
body: { mode },
}),
);
await qc.invalidateQueries({ queryKey: qk.checklist(props.projectId) });
toast(`已改为「${mode === "implicit" ? "只列偏离,没列出的视为接受" : "逐条响应"}」,下次检查按这个判断`);
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
modeBusy.value = "";
}
}
function select(i: ChecklistItem, opt: { scroll?: boolean } = {}) {
if (picking.value && picking.value.item.id !== i.id) cancelPick();
selId.value = i.id;
const g = groupCitations(i.citations)[0];
if (g) openGroup(g);
else target.value = null;
if (opt.scroll) document.querySelector<HTMLElement>(`[data-item="${i.id}"]`)?.scrollIntoView({ block: "nearest" });
}
function step(d: number) {
const v = visible.value;
if (!v.length) return;
const k = v.findIndex((x) => x.id === selId.value);
const n = v[Math.max(0, Math.min(v.length - 1, (k < 0 ? -1 : k) + d))];
if (n) select(n, { scroll: true });
}
watch(tender, (d) => {
if (d && !viewDoc.value) viewDoc.value = { id: d.id, label: "招标文件" };
});
/* ---------- 补出处:查看器进入选句模式 ---------- */
const picking = ref<{ item: ChecklistItem; purpose: "accept" | "add"; changes?: ItemFields } | null>(null);
const picked = ref<string[]>([]);
const pickBusy = ref(false);
function startPick(i: ChecklistItem, purpose: "accept" | "add", changes?: ItemFields) {
selId.value = i.id;
if (!viewDoc.value && tender.value) viewDoc.value = { id: tender.value.id, label: "招标文件" };
if (!viewDoc.value) return toast("还没有可用的招标文件原文", { ok: false });
picking.value = { item: i, purpose, changes };
picked.value = [];
}
function cancelPick() {
picking.value = null;
picked.value = [];
}
async function finishPick() {
const pk = picking.value;
if (!pk || !viewDoc.value || !picked.value.length) return;
pickBusy.value = true;
try {
const c = await ops.cite(viewDoc.value.id, picked.value);
if (pk.purpose === "accept") {
await ops.accept(pk.item.id, { citation_ids: [c.id], changes: pk.changes });
toast("已补上出处并采纳,进入正式清单");
} else {
await ops.setCitations(pk.item.id, [...pk.item.citations.map((x) => x.id), c.id]);
toast("已补上出处");
}
cancelPick();
openCitation(c);
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
pickBusy.value = false;
}
}
/* ---------- 条目操作 ---------- */
async function act<T>(p: Promise<T>, ok: string, undo?: () => void): Promise<T | undefined> {
try {
const out = await p;
toast(ok, { undo });
return out;
} catch (e) {
toast(errorTitle(e), { ok: false });
return undefined;
}
}
const short = (t: string) => (t.length > 16 ? `${t.slice(0, 16)}…` : t);
const accept = (i: ChecklistItem) => act(ops.accept(i.id, {}), "已采纳,进入正式清单");
const remove = (i: ChecklistItem) =>
act(ops.remove(i.id), `已${i.zone === "pending" ? "丢弃" : "删除"}「${short(i.title)}」`, () => void act(ops.restore(i.id), "已恢复"));
const restore = (i: ChecklistItem) => act(ops.restore(i.id), i.zone === "pending" ? "已恢复到待确认" : "已恢复到正式清单");
const dlg = ref<{ open: boolean; mode: "edit" | "accept" | "create"; item: ChecklistItem | null }>({ open: false, mode: "edit", item: null });
const openDialog = (mode: "edit" | "accept" | "create", item: ChecklistItem | null = null) => (dlg.value = { open: true, mode, item });
async function onSubmit(changes: ItemFields, ack: boolean, done: (e?: unknown) => void) {
const { mode, item } = dlg.value;
try {
if (mode === "create") {
const created = await ops.create({ ...changes, categories: changes.categories ?? [], title: changes.title ?? "" });
done();
toast(`已添加到「${category(created.categories[0] ?? "")}」`);
cat.value = "all";
selId.value = created.id;
} else if (mode === "accept" && item) {
await ops.accept(item.id, { changes, no_citation_ack: ack || undefined });
done();
toast(ack ? "已采纳,标注为「人工确认、无出处」" : "已采纳,进入正式清单");
} else if (item) {
if (Object.keys(changes).length) await ops.update(item.id, changes);
done();
if (Object.keys(changes).length) toast("已保存,修改记录已留存");
}
} catch (e) {
done(e);
}
}
function onDialogPick(changes: ItemFields) {
if (dlg.value.item) startPick(dlg.value.item, "accept", changes);
}
const hist = ref<{ open: boolean; item: ChecklistItem | null }>({ open: false, item: null });
/* ---------- 确认清单 ---------- */
const confirmOpen = ref(false);
const confirming = ref(false);
const nextVersion = computed(() => (cl.value?.version_no ?? 0) + 1);
async function doConfirm() {
confirming.value = true;
try {
const r = await ops.confirm();
confirmOpen.value = false;
toast(`清单 v${r.version_no} 已确认`);
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
confirming.value = false;
}
}
/* ---------- 键盘:J / K 上下条,Enter 看原文 ---------- */
function onKey(e: KeyboardEvent) {
if (e.ctrlKey || e.metaKey || e.altKey || document.querySelector(".scrim")) return;
if ((e.target as HTMLElement).closest("input, textarea, select, [contenteditable]")) return;
if (e.key === "j" || e.key === "k") {
e.preventDefault();
step(e.key === "j" ? 1 : -1);
} else if (e.key === "Escape" && picking.value) cancelPick();
}
onMounted(() => window.addEventListener("keydown", onKey));
onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
</script>
<template>
<div class="reading">
<ProjectHead :project="p" tab="checklist" />
<div class="content">
<div class="cl-bar">
<h2>项目检查清单</h2>
<div v-if="cl" class="status">
<template v-if="cl.status === 'confirmed'">
<span class="tag">已确认 v{{ cl.version_no }}</span>
<span v-if="cl.confirmed_by" class="sign">
<span aria-hidden="true">✓</span><b>{{ cl.confirmed_by.name }}</b><span v-if="cl.confirmed_at" class="fig">{{ fmtDateTime(cl.confirmed_at) }}</span><span>确认</span>
</span>
</template>
<template v-else>
<span class="tag">草稿</span>
<span class="cap muted">{{ cl.version_no ? `有修改,待重新确认 · 上一版 v${cl.version_no} 已确认` : "还没确认过;确认后,查标以这一版清单为依据" }}</span>
</template>
</div>
<div v-if="cl && !readOnly" class="actions">
<RouterLink v-if="tender" class="btn" :to="{ name: 'project-new', query: { project: projectId, addendum: '1' } }"><Plus class="ic" :stroke-width="1.5" />追加补遗</RouterLink>
<button type="button" class="btn" :disabled="!all.length" @click="openDialog('create')"><Plus class="ic" :stroke-width="1.5" />新增条目</button>
<button v-if="cl.status !== 'confirmed'" type="button" class="btn primary" :disabled="!formal.length" @click="confirmOpen = true">确认清单</button>
</div>
</div>
<div v-if="addenda.length" class="alert warn warn-row" role="note">
<span class="ico w" aria-hidden="true">!</span>
<p>
{{ addenda.map((x) => `${x.label} 带来 ${x.n} 条`).join(",") }}<template v-if="supCount">,{{ supCount }} 处日期等你确认</template>。标博士不判断哪些旧条目被补遗改了或作废了,请核对:改了的旧条目可以修改或删除。
</p>
<button v-for="x in addenda" :key="x.id" type="button" class="btn sm" @click="(source = x.id), (cat = 'all')">只看{{ x.label }}</button>
<button v-if="supCount" type="button" class="btn sm" @click="(supOnly = true), (cat = 'all')">看日期更新</button>
</div>
<div v-if="realLots.length > 1" class="lot-scope" aria-label="清单范围">
<span class="cap muted">采购包</span>
<template v-if="lotScope === 'bidding'">
<span>通用 + {{ biddingNos.join("、") }}</span>
<span v-if="itemsQ.data.value?.hidden_count" class="cap muted">· 不投的包隐藏 <span class="fig">{{ itemsQ.data.value.hidden_count }}</span> 条</span>
<button v-if="itemsQ.data.value?.hidden_count" type="button" class="lnk cap" @click="lotScope = 'all'">显示全部</button>
</template>
<template v-else>
<span>全部采购包的条目</span>
<button type="button" class="lnk cap" @click="lotScope = 'bidding'">只看所投的包</button>
</template>
<RouterLink v-if="!readOnly" class="lnk cap" :to="{ name: 'lots', params: { projectId } }">更改所投的包</RouterLink>
</div>
<div v-if="cl?.response_modes?.length" class="modes" aria-label="招标文件的响应方式">
<span class="cap muted">响应方式</span>
<div v-for="m in cl.response_modes" :key="m.category" class="mode">
<label :for="`mode-${m.category}`">{{ MODE_CATEGORY[m.category] }}</label>
<SelectBox
:id="`mode-${m.category}`"
size="sm"
:model-value="m.mode"
:options="MODE_OPTIONS"
:disabled="readOnly || modeBusy === m.category"
@update:model-value="(v) => v !== m.mode && setMode(m.category, v as 'explicit' | 'implicit')"
/>
<button v-if="m.citations.length" type="button" class="cite-src inline" @click="openCitation(m.citations[0]!)">依据 · 第 <span class="fig">{{ m.citations[0]!.page_no }}</span> 页</button>
<span v-if="m.origin === 'human'" class="sign cap"><span aria-hidden="true">✓</span><b>{{ m.set_by?.name }}</b><span>改过</span></span>
<span v-else-if="MODE_STATUS[m.status]" class="hint-warn cap">{{ MODE_STATUS[m.status] }}</span>
</div>
</div>
<div v-for="w in cl?.warnings ?? []" :key="w.code" class="alert warn warn-row" role="note">
<span class="ico w" aria-hidden="true">!</span>
<p>{{ w.message }}</p>
</div>
<div v-if="readOnly" class="alert warn warn-row" role="note">
<span class="ico w" aria-hidden="true">!</span>
<p>项目已{{ projectStatus(p!.status) }},清单只读。要修改,先撤销{{ p!.status === "archived" ? "归档" : "递交" }}。</p>
</div>
<div class="cmp">
<section class="card flush cmp-list" aria-label="项目检查清单">
<nav class="cats" aria-label="清单分类">
<button type="button" :aria-current="cat === 'all'" @click="cat = 'all'">全部<span class="fig">{{ formal.length }}</span></button>
<button v-for="c in cats" :key="c" type="button" :aria-current="cat === c" @click="cat = c">{{ category(c) }}<span class="fig">{{ catCount(c) }}</span></button>
<hr />
<button type="button" class="ai" :aria-current="cat === 'pending'" @click="cat = 'pending'">待确认<span class="fig">{{ pending.length }}</span></button>
<button v-if="(cl?.counts.deleted ?? 0) > 0" type="button" :aria-current="cat === 'deleted'" @click="cat = 'deleted'">
已删除<span class="fig">{{ cl?.counts.deleted }}</span>
</button>
<p class="hint cap"><span class="kbd">J</span><span class="kbd">K</span> 上下条</p>
</nav>
<div class="lst">
<div v-if="loading" class="lst-pad"><span class="skel t"></span><span class="skel"></span><span class="skel"></span></div>
<div v-else-if="loadErr" class="lst-pad">
<div class="alert warn" role="alert"><span class="ico w" aria-hidden="true">!</span><p>{{ errorTitle(loadErr) }}</p></div>
</div>
<div v-else-if="!all.length && !(cl?.counts.deleted)" class="empty">
<FileText class="ic xl" :stroke-width="1.25" />
<b>还没有清单</b>
<p>上传招标文件并解析后,这里会逐条列出要求,每一条都带原文出处。</p>
<RouterLink class="btn primary" :to="{ name: 'project-new', query: { project: projectId } }">上传招标文件</RouterLink>
</div>
<template v-else>
<div v-if="listMode === 'formal'" class="lst-tb">
<label class="search">
<Search class="ic" :stroke-width="1.5" />
<input v-model="kw" class="input" placeholder="搜索清单" aria-label="搜索清单" />
</label>
<label class="choice cap"><input v-model="starOnly" type="checkbox" class="ck" />只看 ★</label>
<SelectBox
v-if="sources.length > 1"
:model-value="source"
size="sm"
class="src"
aria-label="来源"
:options="[{ value: '', label: '全部来源' }, ...sources.map((x) => ({ value: x.id, label: `${x.label}(${x.n})` }))]"
@update:model-value="(v) => (source = v ?? '')"
/>
<label v-if="supCount" class="choice cap"><input v-model="supOnly" type="checkbox" class="ck" />只看日期更新({{ supCount }})</label>
</div>
<template v-if="showTop">
<div v-for="r in cl?.marking_rules ?? []" :key="r.id" class="rule">
<Info class="ic" :stroke-width="1.5" />
<div>
<p>{{ ruleText(r) }}</p>
<CitationBlock :citation="r.citation" :active="target?.id === r.citation.id" @open="openCitation(r.citation)" />
</div>
</div>
</template>
<!-- 待确认(§7.6):清单顶部的分组,不和正式清单混排;确认前不参与检查 -->
<section v-if="showPending && pending.length" aria-label="待确认">
<div class="grp-h ai"><b>待确认</b><span class="fig">{{ pending.length }}</span><p>没找到可靠出处,或把握不大。确认前不参与检查。</p></div>
<ChecklistItemRow
v-for="i in pending"
:key="i.id"
:item="i"
pending
:selected="selId === i.id"
:active-citation="target?.id"
@select="select(i)"
@open-citation="(g) => { selId = i.id; openGroup(g); }"
>
<template v-if="!readOnly" #actions>
<button v-if="i.citations.length" type="button" class="btn sm" @click="accept(i)">采纳</button>
<button v-else type="button" class="btn sm" @click="startPick(i, 'accept')">补上出处后采纳</button>
<button type="button" class="btn sm" @click="openDialog('accept', i)">修改后采纳</button>
<button type="button" class="btn sm text" @click="remove(i)">丢弃</button>
</template>
</ChecklistItemRow>
</section>
<div v-if="cat === 'pending' && !pending.length" class="empty sm"><b>没有待确认的条目</b></div>
<template v-if="listMode === 'formal'">
<section v-for="g in groups" :key="g.id" :aria-label="g.name">
<div class="grp-h"><b>{{ g.name }}</b><span class="fig">{{ g.rows.length }}</span></div>
<ChecklistItemRow
v-for="i in g.rows"
:key="i.id"
:item="i"
:selected="selId === i.id"
:active-citation="target?.id"
@select="select(i)"
@open-citation="(g) => { selId = i.id; openGroup(g); }"
>
<template #actions>
<template v-if="!readOnly && supPending(i)">
<button type="button" class="btn sm" @click="resolveSupersession(i, true)">确认更新(删除旧条目)</button>
<button type="button" class="btn sm text" @click="resolveSupersession(i, false)">保留两条</button>
</template>
<template v-if="!readOnly">
<button type="button" class="btn sm" @click="openDialog('edit', i)">修改</button>
<button type="button" class="btn sm" @click="startPick(i, 'add')">补出处</button>
</template>
<button type="button" class="btn sm text" @click="hist = { open: true, item: i }">修改记录</button>
<button v-if="!readOnly" type="button" class="btn sm text" @click="remove(i)">删除</button>
</template>
</ChecklistItemRow>
</section>
<div v-if="!groups.length" class="empty sm">
<b>{{ kw ? `没有找到「${kw}」` : "这个分类下没有符合条件的条目" }}</b>
<p v-if="kw">只搜条目标题。</p>
</div>
</template>
<section v-if="listMode === 'deleted'" aria-label="已删除">
<div class="grp-h"><b>已删除</b><span class="fig">{{ deleted.length }}</span><p>删除和丢弃的条目,可以恢复;重新解析也不会再加回来。</p></div>
<ChecklistItemRow v-for="i in deleted" :key="i.id" :item="i" :selected="selId === i.id" :active-citation="target?.id" @select="select(i)" @open-citation="openGroup">
<template #actions>
<button v-if="!readOnly" type="button" class="btn sm" @click="restore(i)">恢复</button>
<button type="button" class="btn sm text" @click="hist = { open: true, item: i }">修改记录</button>
</template>
</ChecklistItemRow>
</section>
<div class="end"></div>
</template>
</div>
</section>
<PdfViewer
v-model:picked="picked"
class="cmp-viewer"
:document-id="viewDoc?.id"
:document-label="viewDoc?.label"
:target="target"
:selecting="!!picking"
:empty-text="tender ? undefined : '招标文件解析完成后,在这里看原文。'"
>
<template v-if="picking" #banner>
<div class="pick-bar">
<p>
点选句子,作为「{{ short(picking.item.title) }}」的出处<span class="muted">(只能选同一页;翻页会清空已选)</span>
</p>
<div class="acts">
<span class="cap muted">已选 <span class="fig">{{ picked.length }}</span> 句</span>
<button type="button" class="btn sm text" :disabled="pickBusy" @click="cancelPick">取消</button>
<button type="button" class="btn sm primary swap" :class="{ 'is-loading': pickBusy }" :disabled="!picked.length || pickBusy" @click="finishPick">
<span class="idle">{{ picking.purpose === "accept" ? "用作出处并采纳" : "用作出处" }}</span><span class="busy">正在保存…</span>
</button>
</div>
</div>
</template>
</PdfViewer>
</div>
</div>
</div>
<ItemDialog v-model:open="dlg.open" :mode="dlg.mode" :item="dlg.item" :lots="realLots" :default-category="cat !== 'all' && cat !== 'pending' && cat !== 'deleted' ? cat : undefined" @submit="onSubmit" @pick="onDialogPick" />
<HistoryDialog v-model:open="hist.open" :item="hist.item" />
<ConfirmDialog v-model:open="confirmOpen" :title="`确认清单 v${nextVersion}?`" ok-text="确认清单" busy-text="正在确认…" :busy="confirming" @ok="doConfirm">
<p>确认后,查标以这一版清单为依据。之后再改清单会回到草稿,历史版本保留。</p>
<div v-if="pending.length" class="alert warn">
<span class="ico w" aria-hidden="true">!</span>
<div><p><b>还有 {{ pending.length }} 条待确认。</b>它们不进这一版清单,也不参与检查。</p></div>
</div>
</ConfirmDialog>
</template>
<style scoped>
.lst-tb :deep(.src) {
flex: none;
width: auto;
max-width: 200px;
}
.lot-scope {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
align-items: baseline;
margin-bottom: var(--sp-3);
}
/* 响应方式(C12):清单栏下面一行,每类一个选择框 */
.modes {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-2) var(--sp-6);
margin-bottom: var(--sp-3);
}
.modes .mode {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.modes label {
font-size: var(--fs-caption);
color: var(--text-secondary);
white-space: nowrap;
}
.modes :deep(.select) {
width: 236px;
}
.modes .sign {
flex: none;
flex-wrap: nowrap;
margin: 0;
white-space: nowrap;
}
.modes > .cap {
white-space: nowrap;
}
/* 清单栏:标题 + 清单状态;右侧页面级操作(主按钮一个,Q12) */
.cl-bar {
display: flex;
align-items: center;
gap: var(--sp-3);
margin-bottom: var(--sp-3);
}
.cl-bar h2 {
margin: 0;
font: var(--fw-bold) var(--fs-title) / var(--lh-title) var(--font-sans);
white-space: nowrap;
}
.cl-bar .status {
display: flex;
align-items: center;
gap: var(--sp-2);
min-width: 0;
white-space: nowrap;
overflow: hidden;
}
.cl-bar .status .sign {
margin: 0;
}
.cl-bar .actions {
margin-left: auto;
display: flex;
gap: var(--sp-2);
flex: none;
}
.warn-row {
margin-bottom: var(--sp-3);
}
/* 整屏布局(原型 shell fit):页面不滚动,清单和原文查看器各自滚动 */
.reading {
display: flex;
flex-direction: column;
height: calc(100vh - var(--topbar-h));
}
.reading > .content {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding-bottom: var(--sp-4);
}
.cmp {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(560px, 1fr) minmax(400px, 45%);
gap: var(--gap-card);
}
.cmp-list {
min-height: 0;
}
.cmp-list > .lst {
min-height: 0;
overflow: auto;
}
.cmp-viewer {
min-height: 0;
}
.cats {
overflow: auto;
}
.cats .hint {
margin: var(--sp-3) 0 0;
padding: 0 10px;
color: var(--text-secondary);
line-height: 26px;
}
.cats .hint .kbd {
margin-right: 2px;
}
.lst-tb {
position: sticky;
top: 0;
z-index: 2;
display: flex;
align-items: center;
gap: var(--sp-4);
padding: var(--sp-3) var(--card-x);
background: var(--bg-surface);
border-bottom: var(--line) solid var(--border-subtle);
border-top-right-radius: var(--radius-card);
}
.lst-tb .search {
flex: 1;
min-width: 0;
}
.lst-tb .search .input {
height: var(--control-h-sm);
}
.lst-tb .search > .ic {
top: var(--sp-2);
}
.lst-tb .choice {
flex: none;
white-space: nowrap;
align-items: center;
}
.lst-tb .choice .ck {
margin-top: 0;
}
.lst-pad {
padding: var(--card-y) var(--card-x);
}
.rule {
display: flex;
gap: var(--sp-2);
align-items: flex-start;
padding: var(--sp-3) var(--card-x) 0;
}
.rule > .ic {
margin-top: var(--sp-1);
flex: none;
color: var(--text-secondary);
}
.rule p {
margin: 0 0 var(--sp-2);
}
.end {
height: var(--sp-8);
}
/* 选句模式的提示条:在查看器顶栏下面,蓝铅笔浅底 */
.pick-bar {
display: flex;
flex-direction: column;
gap: var(--sp-2);
padding: var(--sp-3) var(--sp-4);
background: var(--ai-soft);
border-bottom: var(--line) solid var(--border-subtle);
}
.pick-bar p {
margin: 0;
}
.pick-bar .acts {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.pick-bar .acts .cap {
margin-right: auto;
}
</style>