fix(ui): 读标、查标走查修复;查标结果页按原型补齐
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled

走查(注册 → 读标 → 查标 → 导出报告)发现的问题:
- 下拉:原生 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>
This commit is contained in:
YANG JIANKUAN
2026-11-05 11:38:56 +08:00
parent 6e0d4c1dd6
commit 1966ce9b15
16 changed files with 529 additions and 127 deletions

View File

@@ -106,6 +106,12 @@ const facts = computed(() => specFacts(props.item.spec));
margin: var(--sp-1) 0 0;
font-size: var(--fs-caption);
}
.evid .muted {
flex: none; /* 标签不跟着长文本一起被挤成竖排 */
}
.t {
overflow-wrap: anywhere; /* 型号、参数这类没有空格的长串 */
}
.cites .cap {
margin: var(--sp-1) 0 0;
}

View File

@@ -7,6 +7,7 @@ import { unwrap, type Schemas } from "@bid-doctor/api-client";
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 { api, errorTitle, isApiError } from "@/lib/api";
import { BOOL_FIELDS, FIELD_LABEL, fieldText, LEGAL_REGIME, MONEY_FIELDS, parseDay } from "@/lib/labels";
import type { ProjectField } from "@/lib/queries";
@@ -104,9 +105,12 @@ async function save(restore = false) {
</div>
<div v-else-if="kind === 'regime'" class="field fill">
<label for="fd-regime">法律体系</label>
<select id="fd-regime" v-model="text" class="input">
<option v-for="(label, v) in LEGAL_REGIME" :key="v" :value="v">{{ label }}</option>
</select>
<SelectBox
id="fd-regime"
:model-value="text"
:options="Object.entries(LEGAL_REGIME).map(([value, label]) => ({ value, label }))"
@update:model-value="(v) => (text = v ?? '')"
/>
<span class="msg">决定查标用「投标无效」还是「否决投标」的口径</span>
</div>
<div v-else-if="kind === 'bool'" class="field fill">

View File

@@ -9,6 +9,7 @@
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";
@@ -135,10 +136,11 @@ function submit() {
</div>
<div v-if="(lots?.length ?? 0) > 1" class="field fill">
<label for="it-lot">所属采购包</label>
<select id="it-lot" v-model="lotId" class="input">
<option :value="null">各包通用</option>
<option v-for="l in lots" :key="l.id" :value="l.id">采购包 {{ l.lot_no }}<template v-if="l.name"> · {{ l.name }}</template></option>
</select>
<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>

View File

@@ -89,8 +89,8 @@ const checkTo = computed(() =>
<div v-if="p.readiness.ready !== null && p.readiness.ready !== undefined">
<dt>就绪</dt>
<dd>
<span v-if="p.readiness.ready" class="tip-below" :data-tip="READY_TIP" tabindex="0">致命问题已处理</span>
<span v-else class="sev fatal">致命<span class="fig">{{ p.readiness.open_fatal_count }}</span></span>
<span v-if="p.readiness.ready" class="hint-u tip-below tip-left" :data-tip="READY_TIP" tabindex="0">就绪</span>
<span v-else class="hint-u tip-below tip-left" :data-tip="`还有 ${p.readiness.open_fatal_count} 个致命问题没处理。不影响导出,决定权在你。`" tabindex="0">未就绪</span>
</dd>
</div>
</dl>
@@ -98,7 +98,7 @@ const checkTo = computed(() =>
<nav class="tabs" aria-label="项目">
<RouterLink v-if="p" role="tab" :aria-selected="tab === 'overview'" :to="{ name: 'overview', params: { projectId: p.id } }">概览</RouterLink>
<RouterLink v-if="p" role="tab" :aria-selected="tab === 'checklist'" :to="{ name: 'checklist', params: { projectId: p.id } }">读标</RouterLink>
<RouterLink v-if="p" role="tab" :aria-selected="tab === 'check'" :to="checkTo">查标</RouterLink>
<RouterLink v-if="p" role="tab" :aria-selected="tab === 'check'" :to="checkTo">查标<span v-if="p.readiness.open_fatal_count" class="sev fatal">致命<span class="fig">{{ p.readiness.open_fatal_count }}</span></span></RouterLink>
<RouterLink v-if="p" role="tab" :aria-selected="tab === 'report'" :to="{ name: 'report', params: { projectId: p.id } }">报告</RouterLink>
</nav>
</header>

View File

@@ -9,6 +9,7 @@ import { unwrap, uploadFile } from "@bid-doctor/api-client";
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 { api, apiOptions, errorTitle } from "@/lib/api";
import { extOf, fmtMonthDay, fmtSize, parseDay, QUALIFICATION_KIND, QUALIFICATION_KIND_ORDER } from "@/lib/labels";
import type { Qualification } from "@/lib/queries";
@@ -25,6 +26,7 @@ const open = defineModel<boolean>("open", { required: true });
const emit = defineEmits<{ saved: [q: Qualification, how: "created" | "updated" | "files"] }>();
type Kind = Qualification["kind"];
const KIND_OPTIONS = QUALIFICATION_KIND_ORDER.map((k) => ({ value: k, label: QUALIFICATION_KIND[k] }));
const name = ref("");
const kind = ref<Kind>("certificate");
const level = ref("");
@@ -203,9 +205,12 @@ async function removeFile(fileId: string) {
</div>
<div class="field">
<label for="qd-kind">类型 <small>(必填)</small></label>
<select id="qd-kind" v-model="kind" class="input">
<option v-for="k in QUALIFICATION_KIND_ORDER" :key="k" :value="k">{{ QUALIFICATION_KIND[k] }}</option>
</select>
<SelectBox
id="qd-kind"
:model-value="kind"
:options="KIND_OPTIONS"
@update:model-value="(v) => (kind = v as Kind)"
/>
</div>
<div class="field">
<label for="qd-level">等级 <small>(选填)</small></label>

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
/**
* 下拉选择(规范 §7.21):触发框和输入框同规格,右侧 chevron,展开时旋转;
* 浮层白底、圆角 12,选中项加粗、右侧墨色 ✓。用 Reka UI 的 Select 打底(AR10),不用原生 <select>。
* - size="sm":高 32,用在筛选栏、行内(和旁边的搜索框一样高);
* - 值可以是空串或 null(「全部来源」「各包通用」):Reka 的选项不许空串,这里换成占位值再换回来。
*/
import { ChevronDown } from "@lucide/vue";
import { SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectPortal, SelectRoot, SelectTrigger, SelectViewport } from "reka-ui";
import { computed } from "vue";
export interface SelectOption {
value: string | null;
label: string;
disabled?: boolean;
}
defineOptions({ inheritAttrs: false }); // id、class、aria-label 给触发框(SelectRoot 本身不出元素)
const props = defineProps<{
modelValue: string | null;
options: SelectOption[];
disabled?: boolean;
size?: "sm";
}>();
const emit = defineEmits<{ "update:modelValue": [value: string | null] }>();
const NULL = "\u0000null";
const EMPTY = "\u0000empty";
const enc = (v: string | null) => (v === null ? NULL : v === "" ? EMPTY : v);
const dec = (v: string) => (v === NULL ? null : v === EMPTY ? "" : v);
const current = computed(() => props.options.find((o) => o.value === props.modelValue));
</script>
<template>
<SelectRoot :model-value="enc(modelValue)" :disabled="disabled" @update:model-value="(v) => emit('update:modelValue', dec(String(v)))">
<SelectTrigger v-bind="$attrs" class="select" :class="{ sm: size === 'sm' }">
<span class="val">{{ current?.label ?? "" }}</span>
<ChevronDown class="ic" :stroke-width="1.5" aria-hidden="true" />
</SelectTrigger>
<SelectPortal>
<SelectContent class="menu popper sel-menu" position="popper" align="start" :side-offset="4">
<SelectViewport>
<SelectItem v-for="o in options" :key="enc(o.value)" :value="enc(o.value)" :disabled="o.disabled">
<SelectItemText>{{ o.label }}</SelectItemText>
<SelectItemIndicator class="chk" aria-hidden="true">✓</SelectItemIndicator>
</SelectItem>
</SelectViewport>
</SelectContent>
</SelectPortal>
</SelectRoot>
</template>