读标操作(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>
312 lines
9.9 KiB
Vue
312 lines
9.9 KiB
Vue
<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>
|