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