走查(注册 → 读标 → 查标 → 导出报告)发现的问题: - 下拉:原生 select 换成 SelectBox(Reka UI,规范 §7.21):右侧 chevron 有边距、 sm 高 32 和搜索框一致、浮层和触发框同宽、选中项加粗带 ✓;响应方式栏的签名不再被挤成竖排 - 读标:「依据 · 第 N 页」改成行内出处链接;证明材料标签不再被挤压 - 概览:关键日期同一种、同一时刻只列一次 - 报告页:「还有 N 项致命」读项目实时值(检查的 counts 是快照) - 读标抽取:★ 只表示「重要」的文件,清单项也挂到 ★ 规则上(「已识别 N 条」) - 检查范围文案和后端覆盖范围一致;报告出处不重复写文件名(模板 2026.11-v3) - 带加载态的按钮图标和文字间距;单选、半选、禁用复选框样式 查标结果页按原型 results.html 补齐: - 本次处理的问题留在原位(署名行可撤销),修了处理后仍显示处理按钮的问题 - 键盘 J / K / O / 1 / 2 / 3 / X / U / Esc;「提示」批量确认无误;「判断有误?」反馈 - 覆盖范围默认收起成一行;顶部加导出报告入口和致命提示;已处理计数 - 检查进度卡和解析进度卡同一种样式 - 项目头「就绪 / 未就绪」,致命数挂在查标标签上 - 上传投标文件页:已有分册时新增表单收起到「添加分册」 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
200 lines
9.4 KiB
Vue
200 lines
9.4 KiB
Vue
<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 SelectBox from "@/components/SelectBox.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, Lot } from "@/lib/queries";
|
||
|
||
type Mode = "edit" | "accept" | "create";
|
||
type Cat = ChecklistItem["categories"][number];
|
||
type Imp = ChecklistItem["importance"];
|
||
|
||
/** lots:项目的分包(不含默认包);有 2 个及以上时可以改条目归属哪个包(FR-M2-04) */
|
||
const props = defineProps<{ mode: Mode; item?: ChecklistItem | null; defaultCategory?: Cat; lots?: Lot[] }>();
|
||
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 lotId = ref<string | null>(null);
|
||
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 ?? "";
|
||
lotId.value = i?.lot_id ?? null;
|
||
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;
|
||
if ((props.lots?.length ?? 0) > 1 && lotId.value !== (i?.lot_id ?? null)) out.lot_id = lotId.value;
|
||
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 v-if="(lots?.length ?? 0) > 1" class="field fill">
|
||
<label for="it-lot">所属采购包</label>
|
||
<SelectBox
|
||
id="it-lot"
|
||
v-model="lotId"
|
||
:options="[{ value: null, label: '各包通用' }, ...(lots ?? []).map((l) => ({ value: l.id, label: `采购包 ${l.lot_no}${l.name ? ` · ${l.name}` : ''}` }))]"
|
||
/>
|
||
</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>
|