+
- {{ citation.document_label }} · 第 {{ citation.page_no }} 页
+ {{ citation.document_label }} · 第 {{ citation.page_no }}–{{ lastPage }} 页
- {{ citation.quote }}
+ {{ quote }}
+
+
diff --git a/apps/web/src/components/HistoryDialog.vue b/apps/web/src/components/HistoryDialog.vue
new file mode 100644
index 0000000..244ac65
--- /dev/null
+++ b/apps/web/src/components/HistoryDialog.vue
@@ -0,0 +1,86 @@
+
+
+
+
+
+
+
+ 修改记录
+
+ {{ item?.title }}
+ 正在加载…
+ {{ errorTitle(q.error.value) }}
+
+ | 时间 | 操作人 | 操作 | 改前 | 改后 |
+
+
+ | {{ r.at }} |
+ {{ r.who }} |
+ {{ r.what }} · {{ r.field }} |
+ {{ r.before }}— |
+ {{ r.after || "—" }} |
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/src/components/ItemDialog.vue b/apps/web/src/components/ItemDialog.vue
new file mode 100644
index 0000000..226bc48
--- /dev/null
+++ b/apps/web/src/components/ItemDialog.vue
@@ -0,0 +1,186 @@
+
+
+
+
+
+
+
+ {{ heading }}
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/src/components/PdfViewer.vue b/apps/web/src/components/PdfViewer.vue
new file mode 100644
index 0000000..867bf05
--- /dev/null
+++ b/apps/web/src/components/PdfViewer.vue
@@ -0,0 +1,311 @@
+
+
+
+
+
+ {{ documentLabel || "原文" }}
+
+
+
+
+
{{ emptyText || "点清单项或出处,在这里看原文和高亮的原句。" }}
+
{{ loadErr }}
+
+ 正在加载原文…
+
+
+
+
+
+
+
+ 原文已更新,这条出处的高亮位置可能不准,未画高亮
+ 页码以规范化 PDF 为准 · 原文只读
+
+
+
+
+
+
diff --git a/apps/web/src/components/Toasts.vue b/apps/web/src/components/Toasts.vue
new file mode 100644
index 0000000..584c339
--- /dev/null
+++ b/apps/web/src/components/Toasts.vue
@@ -0,0 +1,16 @@
+
+
+
+
+
+ ✓
+ {{ t.text }}
+
+
+
+
+
+
+
diff --git a/apps/web/src/lib/checklistOps.ts b/apps/web/src/lib/checklistOps.ts
new file mode 100644
index 0000000..40ac933
--- /dev/null
+++ b/apps/web/src/lib/checklistOps.ts
@@ -0,0 +1,89 @@
+/**
+ * 读标的修改类操作(api-design §4.5):采纳、修改、删除恢复、人工新增、补出处、确认清单、修改记录。
+ * 每次修改后让清单概况、清单项、项目详情(下一步、待确认数)的缓存失效,由 TanStack Query 重新取。
+ */
+import { unwrap, type Schemas } from "@bid-doctor/api-client";
+import { useQuery, useQueryClient } from "@tanstack/vue-query";
+import { computed, toValue, type MaybeRefOrGetter } from "vue";
+import { api } from "./api";
+import { qk } from "./keys";
+import type { ChecklistItem } from "./queries";
+
+export type ItemFields = Schemas["ChecklistItemFields"];
+export type HistoryEntry = Schemas["HistoryEntry"];
+export type ChecklistVersion = Schemas["ChecklistVersion"];
+export type Sentence = Schemas["Sentence"];
+export type PageInfo = Schemas["DocumentPage"];
+
+const item = (r: Promise<{ data?: unknown; error?: unknown; response: Response }>) => unwrap(r) as Promise
;
+const path = (item_id: string) => ({ params: { path: { item_id } } });
+
+export function useChecklistOps(projectId: MaybeRefOrGetter) {
+ const qc = useQueryClient();
+ const pid = () => toValue(projectId);
+ const refresh = async () => {
+ await Promise.all([
+ qc.invalidateQueries({ queryKey: qk.checklist(pid()) }),
+ qc.invalidateQueries({ queryKey: qk.checklistItems(pid()) }),
+ qc.invalidateQueries({ queryKey: ["checklist-deleted", pid()] }),
+ qc.invalidateQueries({ queryKey: qk.project(pid()) }),
+ qc.invalidateQueries({ queryKey: qk.projects }),
+ ]);
+ };
+ const run = async (p: Promise): Promise => {
+ const out = await p;
+ await refresh();
+ return out;
+ };
+
+ return {
+ update: (id: string, changes: ItemFields) => run(item(api.PATCH("/checklist-items/{item_id}", { ...path(id), body: changes }))),
+ accept: (id: string, body: { citation_ids?: string[]; no_citation_ack?: boolean; changes?: ItemFields }) =>
+ run(item(api.POST("/checklist-items/{item_id}/accept", { ...path(id), body }))),
+ remove: (id: string) => run(unwrap(api.DELETE("/checklist-items/{item_id}", path(id)))),
+ restore: (id: string) => run(item(api.POST("/checklist-items/{item_id}/restore", path(id)))),
+ setCitations: (id: string, citation_ids: string[]) =>
+ run(item(api.PUT("/checklist-items/{item_id}/citations", { ...path(id), body: { citation_ids } }))),
+ create: (body: Schemas["ChecklistItemCreate"]) =>
+ run(item(api.POST("/projects/{project_id}/checklist/items", { params: { path: { project_id: pid() } }, body }))),
+ /** 在原文里选中的句子建一条出处(同一页) */
+ cite: (document_id: string, sentence_keys: string[]) =>
+ unwrap(api.POST("/projects/{project_id}/citations", { params: { path: { project_id: pid() } }, body: { document_id, sentence_keys } })),
+ confirm: () => run(unwrap(api.POST("/projects/{project_id}/checklist/confirm", { params: { path: { project_id: pid() } } }))),
+ };
+}
+
+export const fetchHistory = (id: string) => unwrap(api.GET("/checklist-items/{item_id}/history", path(id)));
+
+/** 已删除(含丢弃)的条目:「已删除」分类里看,可以恢复 */
+export function useDeletedItems(projectId: MaybeRefOrGetter, enabled: MaybeRefOrGetter) {
+ return useQuery({
+ queryKey: computed(() => ["checklist-deleted", toValue(projectId)] as const),
+ queryFn: async () => {
+ const r = (await unwrap(
+ api.GET("/projects/{project_id}/checklist/items", {
+ params: { path: { project_id: toValue(projectId) }, query: { include_deleted: true } },
+ }),
+ )) as { items: ChecklistItem[] };
+ return r.items.filter((i) => i.deleted_at);
+ },
+ enabled: computed(() => toValue(enabled)),
+ });
+}
+
+export function useDocumentPages(documentId: MaybeRefOrGetter) {
+ return useQuery({
+ queryKey: computed(() => ["document-pages", toValue(documentId) ?? ""] as const),
+ queryFn: () => unwrap(api.GET("/documents/{document_id}/pages", { params: { path: { document_id: toValue(documentId)! } } })),
+ enabled: computed(() => !!toValue(documentId)),
+ staleTime: Infinity,
+ });
+}
+
+export function fetchSentences(documentId: string, page: number) {
+ return unwrap(api.GET("/documents/{document_id}/pages/{page_no}/sentences", { params: { path: { document_id: documentId, page_no: page } } }));
+}
+
+export function fetchFileUrl(documentId: string) {
+ return unwrap(api.GET("/documents/{document_id}/file", { params: { path: { document_id: documentId }, query: { variant: "normalized" } } }));
+}
diff --git a/apps/web/src/lib/citations.ts b/apps/web/src/lib/citations.ts
new file mode 100644
index 0000000..d00d2d6
--- /dev/null
+++ b/apps/web/src/lib/citations.ts
@@ -0,0 +1,27 @@
+/**
+ * 出处的显示分组:出处按页定位(每页一条,data-model §5.1),一句话跨页时存成两条。
+ * 显示时把「上一页那条没写完、紧接着是下一页的接续」的合成一处,页码写「第 47–48 页」,原句接起来。
+ */
+import type { Citation } from "./queries";
+
+export interface CitationGroup {
+ head: Citation;
+ /** 接续到后面几页的部分(一般只有一条) */
+ tail: Citation[];
+}
+
+const ENDED = /[。!?;!?;))」』”]$/;
+
+export function groupCitations(cs: readonly Citation[]): CitationGroup[] {
+ const out: CitationGroup[] = [];
+ for (const c of cs) {
+ const g = out[out.length - 1];
+ const last = g ? (g.tail[g.tail.length - 1] ?? g.head) : null;
+ if (g && last && c.document_id === last.document_id && c.page_no === last.page_no + 1 && !ENDED.test(last.quote.trim())) {
+ g.tail.push(c);
+ } else {
+ out.push({ head: c, tail: [] });
+ }
+ }
+ return out;
+}
diff --git a/apps/web/src/lib/labels.ts b/apps/web/src/lib/labels.ts
index 091fd90..4cf204a 100644
--- a/apps/web/src/lib/labels.ts
+++ b/apps/web/src/lib/labels.ts
@@ -53,6 +53,41 @@ export const PENDING_REASON: Dict = {
};
export const pendingReason = (s: string | null | undefined) => pick(PENDING_REASON, s);
+/* ---------- 修改记录(审计事件) ---------- */
+export const HISTORY_ACTION: Record = {
+ "checklist_item.extract": "AI 提取",
+ "checklist_item.create": "人工添加",
+ "checklist_item.update": "修改",
+ "checklist_item.accept": "采纳",
+ "checklist_item.set_citations": "更换出处",
+ "checklist_item.discard": "丢弃",
+ "checklist_item.delete": "删除",
+ "checklist_item.restore": "恢复",
+};
+export const historyAction = (s: string) => HISTORY_ACTION[s] ?? s;
+export const HISTORY_FIELD: Record = {
+ title: "要求",
+ categories: "分类",
+ importance: "重要性",
+ evidence_required: "证明材料",
+ requirement_text: "原文",
+ lot_id: "分包",
+ spec: "取值",
+ citation_ids: "出处",
+ zone: "区域",
+ no_citation_ack: "人工确认、无出处",
+};
+/** 修改记录里一个值的显示:分类、重要性、区域翻成中文,出处只写条数 */
+export function historyValue(field: string, v: unknown): string {
+ if (v === null || v === undefined || v === "") return "";
+ if (field === "categories" && Array.isArray(v)) return v.map((c) => category(String(c))).join("、");
+ if (field === "importance") return pick(IMPORTANCE_NAME, String(v));
+ if (field === "zone") return v === "pending" ? "待确认" : "正式清单";
+ if (field === "citation_ids" && Array.isArray(v)) return v.length ? `${v.length} 处` : "无";
+ if (field === "no_citation_ack") return v ? "是" : "否";
+ return typeof v === "string" ? v : JSON.stringify(v);
+}
+
export const MARKER: Dict = {
star: "★",
asterisk: "*",
diff --git a/apps/web/src/lib/toast.ts b/apps/web/src/lib/toast.ts
new file mode 100644
index 0000000..1c202f4
--- /dev/null
+++ b/apps/web/src/lib/toast.ts
@@ -0,0 +1,23 @@
+/** 操作结果提示(设计规范 §7.15):屏幕底部居中,墨色底;可带「撤销」。几秒后自动消失。 */
+import { reactive } from "vue";
+
+export interface Toast {
+ id: number;
+ text: string;
+ ok: boolean;
+ undo?: () => void;
+}
+
+export const toasts = reactive([]);
+let seq = 0;
+
+export function toast(text: string, opt: { ok?: boolean; undo?: () => void; ms?: number } = {}): void {
+ const t: Toast = { id: ++seq, text, ok: opt.ok ?? true, undo: opt.undo };
+ toasts.push(t);
+ setTimeout(() => dismiss(t.id), opt.ms ?? (opt.undo ? 6000 : 3000));
+}
+
+export function dismiss(id: number): void {
+ const i = toasts.findIndex((t) => t.id === id);
+ if (i >= 0) toasts.splice(i, 1);
+}
diff --git a/apps/web/src/pages/ChecklistPage.vue b/apps/web/src/pages/ChecklistPage.vue
index 0ff85b7..0a741d5 100644
--- a/apps/web/src/pages/ChecklistPage.vue
+++ b/apps/web/src/pages/ChecklistPage.vue
@@ -1,28 +1,45 @@
+