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:
YANG JIANKUAN
2026-11-03 21:32:15 +08:00
parent a95e27a290
commit e3f234ca0b
47 changed files with 3125 additions and 164 deletions

View File

@@ -8,6 +8,7 @@ import { useQueryClient } from "@tanstack/vue-query";
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { RouterLink, RouterView, useRoute, useRouter } from "vue-router";
import logoUrl from "@design/logo/svg/logo-vertical-reverse.svg?url";
import Toasts from "./Toasts.vue";
import { api } from "@/lib/api";
import { provideCrumbs } from "@/lib/crumbs";
import { startEventStream } from "@/lib/events";
@@ -103,5 +104,6 @@ async function logout() {
</header>
<RouterView />
</main>
<Toasts />
</div>
</template>

View File

@@ -2,17 +2,21 @@
/**
* 清单项(设计规范 §7.5、§7.6、§7.16):重要性 + 标题 + 标签(来源、分类、包)+ 出处。
* AI 产出的文字带「AI」标签、正文墨色;待确认区的条目另加左侧 2px 蓝铅笔虚线(.ai-block)。
* 当前条(.sel)= 浅底 + 3px 墨色竖条(Q3);操作按钮放在 actions 插槽,只在当前条和待确认条目上显示。
*/
import { computed } from "vue";
import CitationBlock from "./CitationBlock.vue";
import { groupCitations, type CitationGroup } from "@/lib/citations";
import { category, IMPORTANCE_MARK, IMPORTANCE_NAME, pendingReason, specFacts } from "@/lib/labels";
import type { ChecklistItem } from "@/lib/queries";
const props = defineProps<{ item: ChecklistItem; pending?: boolean }>();
const props = defineProps<{ item: ChecklistItem; pending?: boolean; selected?: boolean; activeCitation?: string | null }>();
const emit = defineEmits<{ select: []; openCitation: [g: CitationGroup] }>();
/** 一条结论最多默认展开 2 条出处,其余写「另有 N 处」(§7.3) */
const shown = computed(() => props.item.citations.slice(0, 2));
const more = computed(() => props.item.citations.length - shown.value.length);
/** 一条结论最多默认展开 2 处出处,其余写「另有 N 处」(§7.3);跨页的一句话算一处 */
const groups = computed(() => groupCitations(props.item.citations));
const shown = computed(() => groups.value.slice(0, 2));
const more = computed(() => groups.value.length - shown.value.length);
const mark = computed(() => IMPORTANCE_MARK[props.item.importance] ?? "");
/** AI 摘要和标题不同时才单独显示,避免重复 */
const summary = computed(() => (props.item.summary && props.item.summary !== props.item.title ? props.item.summary : ""));
@@ -21,7 +25,7 @@ const facts = computed(() => specFacts(props.item.spec));
</script>
<template>
<div class="item" :class="{ pq: pending }">
<div class="item" :class="{ pq: pending, sel: selected }" :data-item="item.id" :tabindex="selected ? 0 : -1" @click="emit('select')">
<span class="imp" :title="IMPORTANCE_NAME[item.importance]">
{{ mark }}<span v-if="mark" class="vh">{{ IMPORTANCE_NAME[item.importance] }}</span>
</span>
@@ -34,6 +38,7 @@ const facts = computed(() => specFacts(props.item.spec));
<span class="tag">{{ item.lot_no ? `采购包 ${item.lot_no}` : "通用" }}</span>
<span v-if="pending && item.pending_reason" class="flag">{{ pendingReason(item.pending_reason) }}</span>
<span v-if="item.no_citation_ack" class="flag">人工确认、无出处</span>
<span v-if="item.edited_fields.length" class="flag">已修改</span>
</div>
<p v-if="facts.length" class="facts">
<span v-for="f in facts" :key="f">{{ f }}</span>
@@ -41,11 +46,14 @@ const facts = computed(() => specFacts(props.item.spec));
<p v-if="item.evidence_required" class="evid"><span class="muted">证明材料</span>{{ item.evidence_required }}</p>
<p v-if="summary" class="sum"><span class="tag ai">AI 摘要</span>{{ summary }}</p>
<div v-if="shown.length" class="cites">
<CitationBlock v-for="c in shown" :key="c.id" :citation="c" />
<CitationBlock v-for="g in shown" :key="g.head.id" :citation="g.head" :tail="g.tail" :active="activeCitation === g.head.id" @open="emit('openCitation', g)" />
<p v-if="more > 0" class="cap muted">另有 <span class="fig">{{ more }}</span> 处出处</p>
</div>
<div v-else class="cites">
<div class="cite missing"><p>{{ item.no_citation_ack ? "人工确认、无出处" : "没有出处,确认前不参与检查" }}</p></div>
<div class="cite missing"><p>{{ item.no_citation_ack || item.origin === "human" ? "没有出处" : "没有出处,确认前不参与检查" }}</p></div>
</div>
<div v-if="$slots.actions && (pending || selected)" class="f-actions" @click.stop>
<slot name="actions" />
</div>
</div>
</div>
@@ -57,9 +65,17 @@ const facts = computed(() => specFacts(props.item.spec));
padding: var(--sp-3) var(--card-x) var(--sp-3) var(--sp-3);
border-top: var(--line) solid var(--border-subtle);
}
.item {
cursor: default;
}
.item:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: -2px;
}
.item .t {
font-weight: var(--fw-medium);
}
.cites {
margin-top: var(--sp-2);
}

View File

@@ -1,20 +1,39 @@
<script setup lang="ts">
/**
* 出处(设计规范 §7.3):左侧 2px 蓝铅笔竖线,上面是「文件 · 第 N 页」,下面照录原句。
* TODO(pdf.js):点击出处打开原文查看器(pdfjs-dist 渲染 GET /documents/{id}/file?variant=normalized,
* 按 citation.quads 画原句高亮;citation.normalized_sha256 和文档当前哈希不一致时不画高亮)。
* TODO:原句超过 3 行时截断,提供「展开原文」;OCR 识别出的原句前加「OCR」小字(等接口给出页的 text_source)。
* 点出处在原文查看器里翻到那一页、高亮原句;当前打开的出处竖线加粗(.on)。
* 一句话跨页时(tail 是下一页的接续部分)合成一处显示:「第 47–48 页」,原句接起来。
* TODO:OCR 识别出的原句前加「OCR」小字(等接口给出页的 text_source)。
*/
import { computed } from "vue";
import type { Citation } from "@/lib/queries";
defineProps<{ citation: Citation }>();
const props = defineProps<{ citation: Citation; tail?: Citation[]; active?: boolean }>();
const lastPage = computed(() => props.tail?.at(-1)?.page_no);
const quote = computed(() => [props.citation, ...(props.tail ?? [])].map((c) => c.quote).join(""));
const emit = defineEmits<{ open: [] }>();
</script>
<template>
<div class="cite">
<div class="cite" :class="{ on: active }" role="button" tabindex="0" :aria-pressed="active" @click.stop="emit('open')" @keydown.enter.prevent.stop="emit('open')">
<span class="cite-src">
<template v-if="citation.document_label">{{ citation.document_label }} · </template>第 <span class="fig">{{ citation.page_no }}</span> 页
<template v-if="citation.document_label">{{ citation.document_label }} · </template>第 <span class="fig">{{ citation.page_no }}</span><template v-if="lastPage">–<span class="fig">{{ lastPage }}</span></template> 页
</span>
<q>{{ citation.quote }}</q>
<q>{{ quote }}</q>
</div>
</template>
<style scoped>
.cite {
cursor: pointer;
border-radius: 0 var(--radius-xs) var(--radius-xs) 0;
transition: background-color var(--dur-fast) var(--ease);
}
.cite:hover {
background: var(--ai-soft);
}
.cite:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
</style>

View File

@@ -0,0 +1,86 @@
<script setup lang="ts">
/**
* 修改记录(全程留痕,红线 1):谁在何时做了什么、改前、改后。数据来自审计事件,按时间倒序。
*/
import { useQuery } from "@tanstack/vue-query";
import { X } from "@lucide/vue";
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from "reka-ui";
import { computed } from "vue";
import { errorTitle } from "@/lib/api";
import { fetchHistory } from "@/lib/checklistOps";
import { fmtDateTime, HISTORY_FIELD, historyAction, historyValue } from "@/lib/labels";
import type { ChecklistItem } from "@/lib/queries";
const props = defineProps<{ item: ChecklistItem | null }>();
const open = defineModel<boolean>("open", { required: true });
const q = useQuery({
queryKey: computed(() => ["checklist-history", props.item?.id ?? ""] as const),
queryFn: () => fetchHistory(props.item!.id),
enabled: computed(() => open.value && !!props.item),
staleTime: 0,
});
/** 每个改动的字段一行;没有字段改动的操作(删除、恢复)也占一行 */
const rows = computed(() =>
(q.data.value?.items ?? []).flatMap((h, n) => {
const changes = (h.changes ?? []).filter((c) => c.field !== "pending_reason" && (h.action !== "checklist_item.extract" || c.field === "title"));
const what = h.action === "checklist_item.extract" && props.item?.origin === "rule" ? "规则提取" : historyAction(h.action);
const base = { key: `${n}`, at: fmtDateTime(h.at), who: h.actor.name || "—", what };
if (!changes.length) return [{ ...base, field: "", before: "", after: "" }];
return changes.map((c, i) => ({
...base,
key: `${n}-${i}`,
field: HISTORY_FIELD[c.field] ?? c.field,
before: historyValue(c.field, c.before),
after: historyValue(c.field, c.after),
first: i === 0,
}));
}),
);
</script>
<template>
<DialogRoot v-model:open="open">
<DialogPortal>
<DialogOverlay class="scrim">
<DialogContent class="dialog wide" :aria-describedby="undefined">
<DialogTitle as="h2">修改记录</DialogTitle>
<DialogClose class="icon-btn sm x" aria-label="关闭"><X class="ic" :stroke-width="1.5" /></DialogClose>
<p class="muted sub">{{ item?.title }}</p>
<p v-if="q.isPending.value" class="muted">正在加载…</p>
<p v-else-if="q.error.value" class="hint-warn">{{ errorTitle(q.error.value) }}</p>
<table v-else class="data hist">
<thead><tr><th>时间</th><th>操作人</th><th>操作</th><th>改前</th><th>改后</th></tr></thead>
<tbody>
<tr v-for="r in rows" :key="r.key">
<td><span class="fig">{{ r.at }}</span></td>
<td>{{ r.who }}</td>
<td>{{ r.what }}<span v-if="r.field" class="muted"> · {{ r.field }}</span></td>
<td><span v-if="r.before" class="old">{{ r.before }}</span><span v-else class="muted">—</span></td>
<td>{{ r.after || "—" }}</td>
</tr>
</tbody>
</table>
<div class="foot"><DialogClose class="btn">关闭</DialogClose></div>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</DialogRoot>
</template>
<style scoped>
.sub {
margin: 0 0 var(--sp-3);
}
.hist td:nth-child(-n + 3) {
white-space: nowrap;
}
.hist td {
word-break: break-word;
}
.hist td:nth-child(4),
.hist td:nth-child(5) {
min-width: 5em;
}
</style>

View File

@@ -0,0 +1,186 @@
<script setup lang="ts">
/**
* 清单项对话框(宽 640,§7.14):修改、修改后采纳、新增条目共用。
* - 只提交改过的字段(PATCH 语义),服务端据此记 edited_fields 和修改记录;
* - ★ 条款不满足就会被判无效,选 ★ 时自动带上「无效条款」分类(FR-M2-05);
* - 要求原文是招标文件的原话,AI 提取的条目不能改,人工添加的可以写;
* - 采纳没有出处的条目:要么去原文选句子,要么勾选「人工确认、无出处」(红线 2)。
*/
import { X } from "@lucide/vue";
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from "reka-ui";
import { computed, ref, watch } from "vue";
import { errorTitle, isApiError } from "@/lib/api";
import type { ItemFields } from "@/lib/checklistOps";
import { category, CATEGORY_ORDER, IMPORTANCE_MARK, IMPORTANCE_NAME } from "@/lib/labels";
import type { ChecklistItem } from "@/lib/queries";
type Mode = "edit" | "accept" | "create";
type Cat = ChecklistItem["categories"][number];
type Imp = ChecklistItem["importance"];
const props = defineProps<{ mode: Mode; item?: ChecklistItem | null; defaultCategory?: Cat }>();
const open = defineModel<boolean>("open", { required: true });
const emit = defineEmits<{
/** 提交:changes 只含改过的字段;ack = 人工确认、无出处 */
submit: [changes: ItemFields, ack: boolean, done: (err?: unknown) => void];
/** 采纳前先去原文选句子补出处;带上已经改了的字段,选完句子一起采纳 */
pick: [changes: ItemFields];
}>();
const title = ref("");
const cats = ref<Cat[]>([]);
const imp = ref<Imp>("normal");
const evidence = ref("");
const reqText = ref("");
const ack = ref(false);
const busy = ref(false);
const err = ref("");
const titleErr = ref("");
const catErr = ref("");
watch(open, (v) => {
if (!v) return;
const i = props.item;
title.value = i?.title ?? "";
cats.value = i ? [...i.categories] : props.defaultCategory ? [props.defaultCategory] : [];
imp.value = i?.importance ?? "normal";
evidence.value = i?.evidence_required ?? "";
reqText.value = i?.requirement_text ?? "";
ack.value = false;
err.value = titleErr.value = catErr.value = "";
});
const heading = computed(() => ({ edit: "修改条目", accept: "修改后采纳", create: "新增条目" })[props.mode]);
const okText = computed(() => ({ edit: "保存", accept: "采纳", create: "添加" })[props.mode]);
const reqEditable = computed(() => props.mode === "create" || props.item?.origin === "human");
const noCite = computed(() => props.mode === "accept" && !props.item?.citations.length);
function toggleCat(c: Cat) {
if (cats.value.includes(c)) {
if (!(c === "invalidity" && imp.value === "substantive")) cats.value = cats.value.filter((x) => x !== c);
} else cats.value = [...cats.value, c];
}
function setImp(v: Imp) {
imp.value = v;
if (v === "substantive" && !cats.value.includes("invalidity")) cats.value = [...cats.value, "invalidity"];
}
function changes(): ItemFields {
const i = props.item;
const out: ItemFields = {};
const t = title.value.trim();
if (!i || t !== i.title) out.title = t;
const order = (xs: Cat[]) => CATEGORY_ORDER.filter((c) => xs.includes(c));
if (!i || order(cats.value).join() !== order(i.categories).join()) out.categories = order(cats.value);
if (!i || imp.value !== i.importance) out.importance = imp.value;
const ev = evidence.value.trim() || null;
if (ev !== (i?.evidence_required ?? null)) out.evidence_required = ev;
const rt = reqText.value.trim() || null;
if (reqEditable.value && rt !== (i?.requirement_text ?? null)) out.requirement_text = rt;
return out;
}
function submit() {
err.value = titleErr.value = catErr.value = "";
if (!title.value.trim()) titleErr.value = "请写下这条要求";
if (!cats.value.length) catErr.value = "至少选一个分类";
if (titleErr.value || catErr.value) return;
if (noCite.value && !ack.value) {
err.value = "这一条没有出处:去原文选句子,或者勾选「人工确认、无出处」";
return;
}
busy.value = true;
emit("submit", changes(), noCite.value && ack.value, (e) => {
busy.value = false;
if (!e) open.value = false;
else err.value = isApiError(e) && e.problem.detail ? e.problem.detail : errorTitle(e);
});
}
</script>
<template>
<DialogRoot v-model:open="open">
<DialogPortal>
<DialogOverlay class="scrim">
<DialogContent class="dialog wide" :aria-describedby="undefined">
<DialogTitle as="h2">{{ heading }}</DialogTitle>
<DialogClose class="icon-btn sm x" aria-label="关闭"><X class="ic" :stroke-width="1.5" /></DialogClose>
<form class="body" @submit.prevent="submit">
<div class="field fill" :class="{ err: titleErr }">
<label for="it-title">要求 <small>(必填)</small></label>
<input id="it-title" v-model="title" class="input" maxlength="200" placeholder="一句话写清要求,如「须提供原厂授权书」" />
<span v-if="titleErr" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ titleErr }}</span>
</div>
<div class="field fill" :class="{ err: catErr }">
<label>分类 <small>(可多选)</small></label>
<div class="chips-row">
<label v-for="c in CATEGORY_ORDER" :key="c" class="choice">
<input type="checkbox" class="ck" :checked="cats.includes(c)" @change="toggleCat(c)" />{{ category(c) }}
</label>
</div>
<span v-if="catErr" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ catErr }}</span>
</div>
<div class="field fill">
<label>重要性</label>
<div class="seg" role="group" aria-label="重要性">
<button v-for="v in (['substantive', 'important', 'normal'] as const)" :key="v" type="button" :aria-pressed="imp === v" @click="setImp(v)">
<span v-if="IMPORTANCE_MARK[v]">{{ IMPORTANCE_MARK[v] }}</span>{{ IMPORTANCE_NAME[v] }}
</button>
</div>
<span v-if="imp === 'substantive'" class="msg">★ 条款不满足就会被判无效,已同时归在「无效条款」</span>
</div>
<div class="field fill">
<label for="it-ev">证明材料 <small>(选填)</small></label>
<input id="it-ev" v-model="evidence" class="input" placeholder="如「提供营业执照副本复印件并加盖公章」" />
</div>
<div v-if="reqEditable" class="field fill">
<label for="it-req">招标文件原文 <small>(选填)</small></label>
<textarea id="it-req" v-model="reqText" class="input" placeholder="照录招标文件里的原话"></textarea>
</div>
<div v-else-if="item?.requirement_text" class="field fill">
<label>招标文件原文 <small>(只读)</small></label>
<p class="orig">{{ item.requirement_text }}</p>
</div>
<div v-if="noCite" class="field fill">
<label>出处</label>
<p class="cap muted">这一条没有出处。可以 <button type="button" class="lnk" @click="emit('pick', changes()); open = false">去原文选句子</button>,或者:</p>
<label class="choice"><input v-model="ack" type="checkbox" class="ck" />找不到出处,按「人工确认、无出处」采纳</label>
</div>
<p v-if="mode === 'create'" class="cap muted note">人工添加的条目标注「人工添加」,可以没有出处;要补出处,添加后在原文里选句子。</p>
<p v-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
<div class="foot">
<DialogClose class="btn" type="button" :disabled="busy">取消</DialogClose>
<button type="submit" class="btn primary swap" :class="{ 'is-loading': busy }" :disabled="busy">
<span class="idle">{{ okText }}</span><span class="busy">正在保存…</span>
</button>
</div>
</form>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</DialogRoot>
</template>
<style scoped>
.body .field + .field {
margin-top: var(--sp-4);
}
.chips-row {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2) var(--sp-4);
}
.orig {
margin: 0;
padding: var(--sp-2) var(--sp-3);
background: var(--c-n50);
border-radius: var(--radius-ctl);
line-height: var(--lh-read);
max-height: 160px;
overflow: auto;
}
.note,
.hint-warn {
margin: var(--sp-4) 0 0;
}
</style>

View File

@@ -0,0 +1,311 @@
<script setup lang="ts">
/**
* 原文查看器(设计规范 §7.8,api-design §4.4):pdf.js 渲染规范化 PDF,一次显示一页,宽度撑满查看器。
* - 定位:传入出处(页码 + 坐标框),翻到那一页,按坐标框画原句高亮并滚到眼前;
* 出处记下的文档哈希和文档当前哈希不一致时不画高亮(data-model §5.1)。
* - 选句(selecting):叠出本页每一句的可点框,点选同一页上的句子,作为人工出处(v-model:picked)。
* 坐标框是 [x0, y0, x1, y1],单位 pt,原点在页面左上角。原文只读。
*/
import { ChevronLeft, ChevronRight } from "@lucide/vue";
import * as pdfjs from "pdfjs-dist";
import workerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
import { computed, markRaw, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from "vue";
import { fetchFileUrl, fetchSentences, useDocumentPages, type Sentence } from "@/lib/checklistOps";
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
export interface ViewerTarget {
page: number;
quads: number[][];
sha?: string;
/** 一句话跨页时,后面几页的接续部分也要高亮 */
more?: { page: number; quads: number[][] }[];
}
const props = defineProps<{
documentId?: string;
documentLabel?: string;
target?: ViewerTarget | null;
selecting?: boolean;
emptyText?: string;
}>();
const picked = defineModel<string[]>("picked", { default: () => [] });
const pagesQ = useDocumentPages(() => props.documentId);
const docSha = computed(() => pagesQ.data.value?.normalized_sha256 ?? "");
const pdf = shallowRef<pdfjs.PDFDocumentProxy | null>(null);
const loadErr = ref("");
const page = ref(1);
const count = computed(() => pdf.value?.numPages ?? 0);
const scale = ref(1);
const size = ref({ w: 0, h: 0 });
const body = ref<HTMLElement>();
const canvas = ref<HTMLCanvasElement>();
let task: pdfjs.RenderTask | null = null;
let loadSeq = 0;
async function load(id: string | undefined) {
const seq = ++loadSeq;
await pdf.value?.loadingTask.destroy();
pdf.value = null;
loadErr.value = "";
if (!id) return;
try {
// 签名地址不带鉴权头;取成字节交给 pdf.js(规范化 PDF 一般只有几 MB)
const { url } = await fetchFileUrl(id);
const res = await fetch(url);
if (!res.ok) throw new Error(String(res.status));
const doc = await pdfjs.getDocument({ data: new Uint8Array(await res.arrayBuffer()) }).promise;
if (seq !== loadSeq) return void doc.loadingTask.destroy();
pdf.value = markRaw(doc);
page.value = Math.min(Math.max(1, props.target?.page ?? page.value), doc.numPages);
await render();
} catch {
if (seq === loadSeq) loadErr.value = "原文加载失败,请刷新后重试";
}
}
async function render() {
const doc = pdf.value;
const el = body.value;
if (!doc || !el || !canvas.value) return;
task?.cancel();
const p = await doc.getPage(page.value);
const base = p.getViewport({ scale: 1 });
const width = Math.max(320, el.clientWidth - 32);
const vp = p.getViewport({ scale: width / base.width });
const dpr = window.devicePixelRatio || 1;
const c = canvas.value;
c.width = Math.floor(vp.width * dpr);
c.height = Math.floor(vp.height * dpr);
c.style.width = `${vp.width}px`;
c.style.height = `${vp.height}px`;
scale.value = vp.scale;
size.value = { w: vp.width, h: vp.height };
task = p.render({ canvas: c, viewport: vp, transform: dpr === 1 ? undefined : [dpr, 0, 0, dpr, 0, 0] });
try {
await task.promise;
} catch {
return; // 翻页或改尺寸时取消了上一次渲染
}
await nextTick();
centerHighlight();
}
/** 只滚查看器自己,不带动整页(scrollIntoView 会连页面一起滚) */
function centerHighlight() {
const el = body.value;
const hl = el?.querySelector<HTMLElement>(".hl-box");
if (!el || !hl) return;
const top = hl.getBoundingClientRect().top - el.getBoundingClientRect().top + el.scrollTop;
el.scrollTo({ top: Math.max(0, top - el.clientHeight / 2 + hl.offsetHeight / 2) });
}
/* ---------- 高亮 ---------- */
const stale = computed(() => !!props.target?.sha && !!docSha.value && props.target.sha !== docSha.value);
const box = (q: number[]) => ({
left: `${q[0]! * scale.value}px`,
top: `${q[1]! * scale.value}px`,
width: `${(q[2]! - q[0]!) * scale.value}px`,
height: `${(q[3]! - q[1]!) * scale.value}px`,
});
const highlights = computed(() => {
const t = props.target;
if (!t || stale.value) return [];
return [{ page: t.page, quads: t.quads }, ...(t.more ?? [])].filter((x) => x.page === page.value).flatMap((x) => x.quads);
});
/* ---------- 选句 ---------- */
const sentences = ref<Sentence[]>([]);
async function loadSentences() {
sentences.value = [];
if (!props.selecting || !props.documentId) return;
const want = page.value;
const r = await fetchSentences(props.documentId, want);
if (want === page.value) sentences.value = r.items.filter((s) => s.location !== "header_footer");
}
function toggle(key: string) {
picked.value = picked.value.includes(key) ? picked.value.filter((k) => k !== key) : [...picked.value, key];
}
/* ---------- 翻页、跟随出处、尺寸变化 ---------- */
function go(n: number) {
if (!count.value) return;
const next = Math.min(Math.max(1, n), count.value);
if (next !== page.value) page.value = next;
}
watch(page, () => {
picked.value = []; // 出处只能是同一页上的句子
void render();
void loadSentences();
});
watch(
() => props.documentId,
(id) => void load(id),
);
watch(
() => props.target,
(t) => {
if (!t) return;
if (t.page !== page.value) go(t.page);
else void nextTick(centerHighlight);
},
);
watch(
() => props.selecting,
(on) => {
if (!on) picked.value = [];
void loadSentences();
},
);
let ro: ResizeObserver | null = null;
let lastW = 0;
let timer = 0;
onMounted(() => {
void load(props.documentId);
ro = new ResizeObserver(([e]) => {
const w = Math.round(e?.contentRect.width ?? 0);
if (!w || Math.abs(w - lastW) < 8) return;
lastW = w;
clearTimeout(timer);
timer = window.setTimeout(() => void render(), 120);
});
if (body.value) ro.observe(body.value);
});
onBeforeUnmount(() => {
ro?.disconnect();
task?.cancel();
void pdf.value?.loadingTask.destroy();
});
defineExpose({ go });
</script>
<template>
<section class="viewer pdfv" aria-label="原文查看器">
<div class="viewer-bar">
<span class="doc-name"><b>{{ documentLabel || "原文" }}</b></span>
<div class="pager">
<span v-if="count" class="fig">第 {{ page }} / {{ count }} 页</span>
<button type="button" class="icon-btn sm" aria-label="上一页" :disabled="!count || page <= 1" @click="go(page - 1)">
<ChevronLeft class="ic" :stroke-width="1.5" />
</button>
<button type="button" class="icon-btn sm" aria-label="下一页" :disabled="!count || page >= count" @click="go(page + 1)">
<ChevronRight class="ic" :stroke-width="1.5" />
</button>
</div>
</div>
<slot name="banner" />
<div ref="body" class="viewer-body">
<p v-if="!documentId" class="viewer-empty">{{ emptyText || "点清单项或出处,在这里看原文和高亮的原句。" }}</p>
<p v-else-if="loadErr" class="viewer-empty">{{ loadErr }}</p>
<template v-else>
<p v-if="!pdf" class="viewer-empty">正在加载原文…</p>
<div v-show="pdf" class="page" :style="{ width: `${size.w}px`, height: `${size.h}px` }">
<canvas ref="canvas"></canvas>
<span v-for="(q, i) in highlights" :key="`h${i}`" class="hl-box" :style="box(q)"></span>
<template v-if="selecting">
<button
v-for="s in sentences"
:key="s.key"
type="button"
class="pick"
:class="{ on: picked.includes(s.key) }"
:aria-pressed="picked.includes(s.key)"
:aria-label="s.text"
:title="s.text"
@click="toggle(s.key)"
>
<span v-for="(q, i) in s.quads" :key="i" class="pick-q" :style="box(q)"></span>
</button>
</template>
</div>
<p v-if="stale && target?.page === page" class="viewer-note">原文已更新,这条出处的高亮位置可能不准,未画高亮</p>
<p v-else-if="pdf" class="viewer-note">页码以规范化 PDF 为准 · 原文只读</p>
</template>
</div>
</section>
</template>
<style scoped>
.pdfv {
height: 100%;
}
.doc-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.pager .fig {
margin-right: var(--sp-2);
font-size: var(--fs-figure-xs);
color: var(--text-secondary);
}
.viewer-body {
flex: 1;
min-height: 0;
overflow: auto;
padding: var(--sp-4);
}
.viewer-empty {
margin: var(--sp-8) var(--sp-6) 0;
color: var(--text-secondary);
font-size: var(--fs-caption);
line-height: var(--lh-caption);
text-align: center;
}
.viewer-note {
margin: var(--sp-2) 0 0;
font-size: var(--fs-caption);
color: var(--text-secondary);
text-align: center;
}
.page {
position: relative;
margin: 0 auto;
background: var(--c-white);
box-shadow: var(--shadow-sheet);
}
.page canvas {
display: block;
}
/* 原句高亮(§7.8):蓝铅笔浅底 + 2px 蓝色底边;用正片叠底,不遮住字 */
.hl-box {
position: absolute;
background: var(--ai-soft);
box-shadow: inset 0 -2px 0 var(--ai);
mix-blend-mode: multiply;
pointer-events: none;
}
/* 选句:整句一个按钮,按坐标框叠在字上;悬停浅底,选中加蓝色底边 */
.pick {
position: static;
padding: 0;
border: 0;
background: none;
cursor: pointer;
}
.pick-q {
position: absolute;
border-radius: 2px;
mix-blend-mode: multiply;
}
.pick:hover .pick-q,
.pick:focus-visible .pick-q {
background: var(--c-n100);
}
.pick:focus-visible {
outline: 0;
}
.pick:focus-visible .pick-q {
box-shadow: 0 0 0 2px var(--focus-ring);
}
.pick.on .pick-q {
background: var(--ai-soft);
box-shadow: inset 0 -2px 0 var(--ai);
}
</style>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import { dismiss, toasts } from "@/lib/toast";
</script>
<template>
<div class="toasts" role="status" aria-live="polite">
<div v-for="t in toasts" :key="t.id" class="toast">
<span v-if="t.ok" class="ok" aria-hidden="true">✓</span>
<span>{{ t.text }}</span>
<template v-if="t.undo">
<span class="sep" aria-hidden="true"></span>
<button type="button" class="lnk" @click="t.undo(); dismiss(t.id)">撤销</button>
</template>
</div>
</div>
</template>