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

@@ -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>