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:
@@ -15,6 +15,7 @@
|
||||
"@fontsource/noto-serif-sc": "5.3.0",
|
||||
"@lucide/vue": "1.50.0",
|
||||
"@tanstack/vue-query": "5.104.1",
|
||||
"pdfjs-dist": "6.4.299",
|
||||
"reka-ui": "2.10.5",
|
||||
"vue": "3.5.43",
|
||||
"vue-router": "4.6.4"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
86
apps/web/src/components/HistoryDialog.vue
Normal file
86
apps/web/src/components/HistoryDialog.vue
Normal 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>
|
||||
186
apps/web/src/components/ItemDialog.vue
Normal file
186
apps/web/src/components/ItemDialog.vue
Normal 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>
|
||||
311
apps/web/src/components/PdfViewer.vue
Normal file
311
apps/web/src/components/PdfViewer.vue
Normal 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>
|
||||
16
apps/web/src/components/Toasts.vue
Normal file
16
apps/web/src/components/Toasts.vue
Normal 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>
|
||||
89
apps/web/src/lib/checklistOps.ts
Normal file
89
apps/web/src/lib/checklistOps.ts
Normal file
@@ -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<ChecklistItem>;
|
||||
const path = (item_id: string) => ({ params: { path: { item_id } } });
|
||||
|
||||
export function useChecklistOps(projectId: MaybeRefOrGetter<string>) {
|
||||
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 <T>(p: Promise<T>): Promise<T> => {
|
||||
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<string>, enabled: MaybeRefOrGetter<boolean>) {
|
||||
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<string | undefined>) {
|
||||
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" } } }));
|
||||
}
|
||||
27
apps/web/src/lib/citations.ts
Normal file
27
apps/web/src/lib/citations.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -53,6 +53,41 @@ export const PENDING_REASON: Dict<Schemas["PendingReason"]> = {
|
||||
};
|
||||
export const pendingReason = (s: string | null | undefined) => pick(PENDING_REASON, s);
|
||||
|
||||
/* ---------- 修改记录(审计事件) ---------- */
|
||||
export const HISTORY_ACTION: Record<string, string> = {
|
||||
"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<string, string> = {
|
||||
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<Schemas["MarkingRule"]["marker"]> = {
|
||||
star: "★",
|
||||
asterisk: "*",
|
||||
|
||||
23
apps/web/src/lib/toast.ts
Normal file
23
apps/web/src/lib/toast.ts
Normal file
@@ -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<Toast[]>([]);
|
||||
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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -240,3 +240,35 @@
|
||||
.nx:hover span{text-decoration-color:var(--c-ink)}
|
||||
.nx .ic{color:var(--text-secondary);transition:transform var(--dur-fast) var(--ease),color var(--dur-fast) var(--ease)}
|
||||
.prow:hover .nx .ic,.prow:focus-within .nx .ic{transform:translateX(3px);color:var(--text-primary)}
|
||||
|
||||
/* =====================================================================
|
||||
* 3. 读标用到的补充(照搬原型 docs/prototype/assets/app.css)
|
||||
* ===================================================================== */
|
||||
/* B4 补充:多行输入、两栏表单、链接 */
|
||||
textarea.input{height:auto;min-height:80px;padding:var(--sp-2) var(--sp-3);resize:vertical;line-height:var(--lh-body)}
|
||||
.field.fill{max-width:none}
|
||||
.form{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--sp-5) var(--sp-6)}
|
||||
.form .span2{grid-column:1 / -1}
|
||||
.form .field{max-width:none}
|
||||
.lnk{padding:0;border:0;background:none;font:inherit;color:var(--text-primary);text-decoration:underline;text-decoration-color:var(--link-underline);text-underline-offset:3px;cursor:pointer}
|
||||
.lnk:hover{text-decoration-color:var(--c-ink)}
|
||||
/* 分段选择(重要性等单选) */
|
||||
.seg{display:inline-flex;align-items:center;gap:2px}
|
||||
.seg button{display:inline-flex;align-items:center;gap:6px;height:var(--control-h-sm);padding:0 10px;border:0;border-radius:var(--radius-ctl);background:none;font:var(--fs-body) var(--font-sans);color:var(--text-secondary);white-space:nowrap;cursor:pointer;transition:background-color var(--dur-fast) var(--ease),color var(--dur-fast) var(--ease)}
|
||||
.seg button:hover{background:var(--c-n100);color:var(--text-primary)}
|
||||
.seg button[aria-pressed="true"]{background:var(--c-n100);color:var(--text-primary);font-weight:var(--fw-bold)}
|
||||
/* B11 补充:宽对话框、关闭按钮、对话框里的表格 */
|
||||
.dialog.wide{width:640px}
|
||||
.dialog .x{position:absolute;top:var(--sp-4);right:var(--sp-4)}
|
||||
.dialog .body > .field{margin-top:var(--sp-4)}
|
||||
.dialog .foot .left{margin-right:auto}
|
||||
.dialog table.data{border:0;border-radius:0;background:none}
|
||||
.dialog table.data :is(th,td):first-child{padding-left:0}
|
||||
.dialog table.data :is(th,td):last-child{padding-right:0}
|
||||
.dialog table.data td{height:auto}
|
||||
/* 操作结果提示(§7.15):底部居中,墨色底 */
|
||||
.toasts{position:fixed;left:50%;bottom:28px;transform:translateX(-50%);z-index:60;display:flex;flex-direction:column;gap:var(--sp-2);align-items:center;pointer-events:none}
|
||||
.toast{display:flex;gap:var(--sp-3);align-items:center;min-height:40px;padding:var(--sp-2) var(--sp-4);background:var(--c-ink);color:var(--text-on-dark);border-radius:var(--radius-ctl);box-shadow:var(--shadow-overlay);pointer-events:auto;white-space:nowrap}
|
||||
.toast .ok{display:inline-grid;place-items:center;width:16px;height:16px;border-radius:50%;background:var(--c-green);color:var(--c-white);font-size:11px;line-height:1}
|
||||
.toast button.lnk{color:var(--text-on-dark);text-decoration-color:currentColor}
|
||||
.toast .sep{width:1px;align-self:stretch;margin:2px 0;background:var(--on-dark-line)}
|
||||
|
||||
@@ -17,6 +17,8 @@ export default defineConfig({
|
||||
"@design": fileURLToPath(new URL("../../docs/design", import.meta.url)),
|
||||
},
|
||||
},
|
||||
// pdf.js 被 Vite 预打包后在开发环境里加载文档会一直挂起,直接用它自带的 ES 模块
|
||||
optimizeDeps: { exclude: ["pdfjs-dist"] },
|
||||
server: {
|
||||
host: "127.0.0.1",
|
||||
port: 5173,
|
||||
|
||||
Reference in New Issue
Block a user