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>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
51
apps/web/src/components/SelectBox.vue
Normal file
51
apps/web/src/components/SelectBox.vue
Normal 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>
|
||||
Reference in New Issue
Block a user