feat: 读标可以走完一份文件;审计待确认区,修规则缺陷、提示词出新版本
读标操作(FR-M2-07、08) - 后端:清单项详情(ETag)、修改(If-Match 选填,记 edited_fields 和 AI 原值)、 采纳(补出处或声明「人工确认、无出处」,可带修改)、删除 / 丢弃与恢复、人工新增、 替换出处、人工建出处(选同一页的句子)、修改记录(来自审计)、确认清单(不可变版本, 内容没变复用上一版)、版本列表;正式清单有变化时已确认的清单回到草稿;项目已递交只读 - 重新解析不推翻人工决定:丢弃的不再冒出来,采纳的留在正式清单,人工补过的出处不被覆盖 - 前端:待确认区采纳 / 补上出处后采纳 / 修改后采纳 / 丢弃,正式清单修改、补出处、删除 (可撤销)、修改记录、新增条目、确认清单;pdf.js 原文查看器(点出处跳页、按坐标框高亮、 选句补出处),整屏对照布局,J / K 上下条;跨页的一句话合成一处出处显示 待确认审计(架构 §13 SK11) - 规则只修真正的缺陷:表格框线不算下划线(文字层 v3);句号后的右括号并进同一句 (切句 s3-v3);中文编号小节和冒号编号条目的上级条款,依据沿上级链核对;取值认中文 数字、同一行单元格和评分因素标题;补几种无效写法;比对引文前去掉我们加进输入的标记 - 不放宽:引文最短 8 个字、编号必须对、证据全部通过 - 提示词新版本:substantive v3,qualification / scoring / schedule / tech_param v2; 引文复制整句、并列多项不拆、跨页只引上一行,除实质性条款外输入改成纯文本 - BD_AI_MODELS 按提示词声明的能力选模型;离线评测工具 evals/run_extract.py - 待确认:国动 46 → 4,S02 17 → 13,S06 174 → 57 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,28 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 读标(原型 checklist.html):项目头 + 项目检查清单。
|
||||
* 清单按分类分组,待确认区放在顶部、不和正式清单混排(§7.6);顶部写明标注规则和数量核对提示。
|
||||
* 骨架阶段只读:采纳 / 修改 / 确认清单等写接口还没实现。
|
||||
* TODO(pdf.js):右侧原文查看器(原型的 .cmp 对照布局)。现在每条直接显示「第 N 页」和原句。
|
||||
* 读标(原型 checklist.html):项目头 + 项目检查清单 + 原文查看器(对照布局 §5.3)。
|
||||
* - 清单按分类分组,待确认区放在顶部、不和正式清单混排(§7.6);顶部写明标注规则和数量核对提示;
|
||||
* - 点清单项或出处:右侧翻到原文那一页并高亮原句;J / K 上下条;
|
||||
* - 待确认:采纳 / 补上出处后采纳 / 修改后采纳 / 丢弃;正式清单:修改、补出处、删除(可撤销)、修改记录;
|
||||
* - 补出处:查看器进入选句模式,点选同一页上的句子,建一条人工出处再挂到条目上;
|
||||
* - 确认清单:生成不可变版本,查标以这一版为依据;之后再改会回到草稿,历史保留(FR-M2-08)。
|
||||
*/
|
||||
import { FileText, Info, Search } from "@lucide/vue";
|
||||
import { computed, ref } from "vue";
|
||||
import { unwrap } from "@bid-doctor/api-client";
|
||||
import { FileText, Info, Plus, Search } from "@lucide/vue";
|
||||
import { useQuery } from "@tanstack/vue-query";
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { RouterLink } from "vue-router";
|
||||
import ChecklistItemRow from "@/components/ChecklistItemRow.vue";
|
||||
import CitationBlock from "@/components/CitationBlock.vue";
|
||||
import { errorTitle } from "@/lib/api";
|
||||
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 { 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 { category, CATEGORY_ORDER, deadline, fmtDateTime, legalRegime, MARKER, projectStatus } from "@/lib/labels";
|
||||
import { useChecklist, useChecklistItems, useProject, type ChecklistItem, type MarkingRule } from "@/lib/queries";
|
||||
import { useChecklist, useChecklistItems, 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 itemsQ = useChecklistItems(() => props.projectId);
|
||||
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 meta = computed(() =>
|
||||
@@ -35,10 +52,12 @@ const all = computed(() => (itemsQ.data.value?.items ?? []).filter((i) => !i.del
|
||||
const formal = computed(() => all.value.filter((i) => i.zone === "formal"));
|
||||
const pending = computed(() => all.value.filter((i) => i.zone === "pending"));
|
||||
|
||||
type Cat = "all" | "pending" | ChecklistItem["categories"][number];
|
||||
type Cat = "all" | "pending" | "deleted" | ChecklistItem["categories"][number];
|
||||
const cat = ref<Cat>("all");
|
||||
const kw = ref("");
|
||||
const starOnly = ref(false);
|
||||
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));
|
||||
@@ -57,8 +76,15 @@ const groups = computed(() => {
|
||||
})
|
||||
.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);
|
||||
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)];
|
||||
});
|
||||
|
||||
/* ---------- 标注规则(如「打 ★ 的为实质性要求」)和数量核对 ---------- */
|
||||
function ruleText(r: MarkingRule): string {
|
||||
@@ -71,9 +97,155 @@ function ruleText(r: MarkingRule): string {
|
||||
|
||||
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);
|
||||
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">
|
||||
<header class="ph">
|
||||
<div class="ph-l1">
|
||||
<div>
|
||||
@@ -107,81 +279,173 @@ const loadErr = computed(() => itemsQ.error.value ?? checklist.error.value);
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="tag">草稿</span>
|
||||
<span class="cap muted">{{ cl.version_no ? `上一版 v${cl.version_no} 已确认` : "还没确认过;确认后,查标以这一版清单为依据" }}</span>
|
||||
<span class="cap muted">{{ cl.version_no ? `有修改,待重新确认 · 上一版 v${cl.version_no} 已确认` : "还没确认过;确认后,查标以这一版清单为依据" }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<span v-if="cl" class="counts cap muted">正式清单 <span class="fig">{{ cl.counts.formal }}</span> 条 · 待确认 <span class="fig">{{ cl.counts.pending }}</span> 条</span>
|
||||
<div v-if="cl && !readOnly" class="actions">
|
||||
<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-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>
|
||||
|
||||
<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>
|
||||
</nav>
|
||||
<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" 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="cat !== 'pending'" 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>
|
||||
<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>
|
||||
</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" />
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
</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 />
|
||||
</section>
|
||||
<div v-if="cat === 'pending' && !pending.length" class="empty sm"><b>没有待确认的条目</b></div>
|
||||
|
||||
<template v-if="cat !== 'pending'">
|
||||
<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" />
|
||||
<!-- 待确认(§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="!groups.length" class="empty sm">
|
||||
<b>{{ kw ? `没有找到「${kw}」` : "这个分类下没有符合条件的条目" }}</b>
|
||||
<p v-if="kw">只搜条目标题。</p>
|
||||
</div>
|
||||
<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">
|
||||
<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 class="end"></div>
|
||||
</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>
|
||||
</div>
|
||||
</section>
|
||||
</PdfViewer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ItemDialog v-model:open="dlg.open" :mode="dlg.mode" :item="dlg.item" :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>
|
||||
/* 清单栏:标题 + 清单状态 + 数量 */
|
||||
/* 清单栏:标题 + 清单状态;右侧页面级操作(主按钮一个,Q12) */
|
||||
.cl-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -204,15 +468,61 @@ const loadErr = computed(() => itemsQ.error.value ?? checklist.error.value);
|
||||
.cl-bar .status .sign {
|
||||
margin: 0;
|
||||
}
|
||||
.cl-bar .counts {
|
||||
.cl-bar .actions {
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
flex: none;
|
||||
}
|
||||
.warn-row {
|
||||
margin-bottom: var(--sp-3);
|
||||
}
|
||||
/* 原型里工具条吸顶(整屏布局下列表自己滚动);这里页面整体滚动、卡片 overflow:hidden,先不吸顶,接原文查看器时恢复 */
|
||||
/* 整屏布局(原型 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);
|
||||
@@ -259,4 +569,24 @@ const loadErr = computed(() => itemsQ.error.value ?? checklist.error.value);
|
||||
.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>
|
||||
|
||||
Reference in New Issue
Block a user