Files
bid-doctor/apps/web/src/components/ItemDialog.vue
YANG JIANKUAN 1966ce9b15
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled
fix(ui): 读标、查标走查修复;查标结果页按原型补齐
走查(注册 → 读标 → 查标 → 导出报告)发现的问题:
- 下拉:原生 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>
2026-11-05 11:38:56 +08:00

200 lines
9.4 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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