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>

View File

@@ -6,11 +6,12 @@
* - 扣点说明写在按钮旁边(红线 9),来自 check-preview;发起时带 expected_charge,和服务端算的不一致时不扣点(API4)。
*/
import { ApiError, unwrap, uploadFile, type Schemas } from "@bid-doctor/api-client";
import { Info, Upload } from "@lucide/vue";
import { Info, Plus, Upload } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { computed, ref } from "vue";
import { RouterLink, useRouter } from "vue-router";
import ProjectHead from "@/components/ProjectHead.vue";
import SelectBox from "@/components/SelectBox.vue";
import { api, apiOptions, errorTitle, isApiError } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys";
@@ -33,7 +34,16 @@ useCrumbs(() => [{ label: "工作台", to: { name: "workbench" } }, { label: p.v
const vols = computed<DocumentInfo[]>(() => pv.value?.bid_documents ?? []);
/* ---------- 上传 ---------- */
const KIND_OPTIONS = VOLUME_KINDS.map((k) => ({ value: k, label: VOLUME_KIND[k] }));
const kind = ref<Schemas["VolumeKind"]>("whole");
/** 已经有分册时,新增分册的表单收起来,点「添加分册」再展开;默认选还没用过的类型 */
const adding = ref(false);
const showNew = computed(() => !vols.value.length || adding.value || !!uploading.value);
function startAdd() {
const used = new Set(vols.value.map((v) => v.volume_kind));
kind.value = VOLUME_KINDS.find((k) => k !== "whole" && !used.has(k)) ?? "other";
adding.value = true;
}
const volName = ref("");
const replacing = ref<DocumentInfo | null>(null);
const uploading = ref<{ name: string; ratio: number } | null>(null);
@@ -58,6 +68,7 @@ async function upload(file: File) {
);
volName.value = "";
replacing.value = null;
adding.value = false;
await qc.invalidateQueries({ queryKey: qk.checkPreview(props.projectId) });
void qc.invalidateQueries({ queryKey: qk.projects });
} catch (e) {
@@ -155,12 +166,19 @@ async function start() {
<span class="acts"><button type="button" class="btn sm" :disabled="!!uploading" @click="pick(v)">上传新版本</button></span>
</div>
<button v-if="!showNew" type="button" class="btn sm add-vol" @click="startAdd"><Plus class="ic" :stroke-width="1.5" />添加分册</button>
<template v-if="showNew">
<div class="new-vol">
<div class="field">
<label for="vk">分册类型</label>
<select id="vk" v-model="kind" class="input" :disabled="!!uploading">
<option v-for="k in VOLUME_KINDS" :key="k" :value="k">{{ VOLUME_KIND[k] }}</option>
</select>
<SelectBox
id="vk"
:model-value="kind"
:options="KIND_OPTIONS"
:disabled="!!uploading"
@update:model-value="(v) => (kind = v as Schemas['VolumeKind'])"
/>
</div>
<div class="field">
<label for="vn">分册名称 <small>(选填)</small></label>
@@ -185,6 +203,7 @@ async function start() {
<b>{{ uploading ? `正在上传 ${uploading.name}(${Math.round(uploading.ratio * 100)}%)` : "拖入或点击上传分册" }}</b>
<span id="drop-sub">Word / PDF;电子投标客户端加密后的文件读不了,请上传加密前的稿子</span>
</div>
</template>
<p v-if="upErr" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span><span>{{ upErr }}</span></p>
<fieldset class="form-sec scope">
@@ -194,16 +213,17 @@ async function start() {
<ul>
<li>正式清单每一条在投标文件里有没有响应:未找到响应、负偏离未说明、疑似不满足</li>
<li>每条结论都带投标文件的原文出处;出处核对不上的,不算「已响应」</li>
<li>报价形式:大写和小写金额、分项报价合计、超过最高限价、另外给的报价</li>
<li>报价形式:大写和小写金额、分项报价合计、超过最高限价和分项 / 单价上限、另外给的报价</li>
<li>形式要件:别的项目名称、没填的地方、落款日期、投标有效期、附加条件、响应表里的未响应</li>
<li>资质:证书有效期、等级、有没有覆盖(按资质库和投标文件文字)</li>
</ul>
</div>
<div class="no">
<h3>这一版还不查</h3>
<ul>
<li>分项 / 单价上限、分项和采购需求逐项对照、格式文件固定文字被改、声明函、资质有效期:还没有上线</li>
<li>分项报价表和采购需求清单逐项对照、格式文件固定文字被改、授权代表和签字人一致、声明函填报、签章有无、低价说明、跨页的响应表、证书主体和投标人是否一致:还没有上线</li>
<li>签章真伪(以后也只看有无,不鉴定真伪)</li>
<li>份数、密封、CA 加密、递交方式(文件里看不出来)</li>
<li>份数、密封、CA 加密、递交方式:文件里看不出来,列入「需人工核对」,递交前自己核对</li>
<li>报价是否合理、该报多少,评分高低</li>
</ul>
</div>
@@ -246,9 +266,12 @@ async function start() {
display: flex;
flex-direction: column;
gap: var(--sp-4);
padding-top: var(--sp-5);
padding-top: var(--sp-2);
padding-bottom: var(--sp-6);
}
.add-vol {
align-self: flex-start;
}
.file-row .name {
display: flex;
align-items: center;
@@ -278,6 +301,7 @@ async function start() {
}
.scope legend {
grid-column: 1 / -1;
padding: 0;
font-weight: var(--fw-bold);
margin-bottom: var(--sp-2);
}

View File

@@ -19,6 +19,7 @@ import HistoryDialog from "@/components/HistoryDialog.vue";
import ItemDialog from "@/components/ItemDialog.vue";
import PdfViewer, { type ViewerTarget } from "@/components/PdfViewer.vue";
import ProjectHead from "@/components/ProjectHead.vue";
import SelectBox from "@/components/SelectBox.vue";
import { api, errorTitle } from "@/lib/api";
import { useChecklistOps, useDeletedItems, type ItemFields } from "@/lib/checklistOps";
import { groupCitations, type CitationGroup } from "@/lib/citations";
@@ -152,6 +153,10 @@ const openGroup = (g: CitationGroup) => openCitation(g.head, g.tail);
/* ---------- 响应方式(C12):招标文件要求逐条响应,还是只列偏离、没列出的视为接受;查标按它判没找到响应算不算问题 ---------- */
const qc = useQueryClient();
const MODE_CATEGORY: Record<string, string> = { technical: "技术要求", commercial: "商务要求" };
const MODE_OPTIONS = [
{ value: "explicit", label: "逐条响应" },
{ value: "implicit", label: "只列偏离,没列出的视为接受" },
];
const MODE_STATUS: Record<string, string> = { unknown: "招标文件没写明,按逐条响应处理", conflict: "招标文件两处写法不一致,按逐条响应处理" };
const modeBusy = ref("");
async function setMode(category: "technical" | "commercial", mode: "explicit" | "implicit") {
@@ -357,17 +362,15 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
<span class="cap muted">响应方式</span>
<div v-for="m in cl.response_modes" :key="m.category" class="mode">
<label :for="`mode-${m.category}`">{{ MODE_CATEGORY[m.category] }}</label>
<select
<SelectBox
:id="`mode-${m.category}`"
class="input sm"
:value="m.mode"
size="sm"
:model-value="m.mode"
:options="MODE_OPTIONS"
:disabled="readOnly || modeBusy === m.category"
@change="setMode(m.category, ($event.target as HTMLSelectElement).value as 'explicit' | 'implicit')"
>
<option value="explicit">逐条响应</option>
<option value="implicit">只列偏离,没列出的视为接受</option>
</select>
<button v-if="m.citations.length" type="button" class="plain cap" @click="openCitation(m.citations[0]!)">依据 · 第 {{ m.citations[0]!.page_no }} 页</button>
@update:model-value="(v) => v !== m.mode && setMode(m.category, v as 'explicit' | 'implicit')"
/>
<button v-if="m.citations.length" type="button" class="cite-src inline" @click="openCitation(m.citations[0]!)">依据 · 第 <span class="fig">{{ m.citations[0]!.page_no }}</span> 页</button>
<span v-if="m.origin === 'human'" class="sign cap"><span aria-hidden="true">✓</span><b>{{ m.set_by?.name }}</b><span>改过</span></span>
<span v-else-if="MODE_STATUS[m.status]" class="hint-warn cap">{{ MODE_STATUS[m.status] }}</span>
</div>
@@ -413,10 +416,15 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
<input v-model="kw" class="input" placeholder="搜索清单" aria-label="搜索清单" />
</label>
<label class="choice cap"><input v-model="starOnly" type="checkbox" class="ck" />只看 ★</label>
<select v-if="sources.length > 1" v-model="source" class="input sm src" aria-label="来源">
<option value="">全部来源</option>
<option v-for="x in sources" :key="x.id" :value="x.id">{{ x.label }}({{ x.n }})</option>
</select>
<SelectBox
v-if="sources.length > 1"
:model-value="source"
size="sm"
class="src"
aria-label="来源"
:options="[{ value: '', label: '全部来源' }, ...sources.map((x) => ({ value: x.id, label: `${x.label}(${x.n})` }))]"
@update:model-value="(v) => (source = v ?? '')"
/>
<label v-if="supCount" class="choice cap"><input v-model="supOnly" type="checkbox" class="ck" />只看日期更新({{ supCount }})</label>
</div>
@@ -539,8 +547,10 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
</template>
<style scoped>
.lst-tb .src {
.lst-tb :deep(.src) {
flex: none;
width: auto;
max-width: 200px;
}
.lot-scope {
display: flex;
@@ -567,7 +577,12 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
color: var(--text-secondary);
white-space: nowrap;
}
.modes :deep(.select) {
width: 236px;
}
.modes .sign {
flex: none;
flex-wrap: nowrap;
margin: 0;
white-space: nowrap;
}

View File

@@ -214,20 +214,6 @@ function openCitation(c: Citation) {
.frow:hover .icon-btn {
color: var(--text-primary);
}
.cite-src.inline {
display: inline;
padding: 0;
border: 0;
background: none;
font: inherit;
font-size: var(--fs-caption);
color: var(--ai);
cursor: pointer;
}
.cite-src.inline:hover {
text-decoration: underline;
text-underline-offset: 3px;
}
.warn-text {
color: var(--warn-text);
}

View File

@@ -37,7 +37,8 @@ const citations = ref(true);
const manualChecks = ref(true);
const exporting = ref(false);
const freshId = ref<string | null>(null);
const openFatal = computed(() => run.value?.counts?.open_fatal ?? 0);
// 检查的 counts 是检查结束时的快照;处理问题后的待处理数看项目(和项目头的「就绪」一致)
const openFatal = computed(() => p.value?.readiness.open_fatal_count ?? 0);
async function exportReport() {
if (!runId.value || exporting.value) return;

View File

@@ -11,11 +11,12 @@
* TODO(第 6 步):键盘操作、批量处理「提示」、判断有误反馈、导出报告。
*/
import { unwrap } from "@bid-doctor/api-client";
import { ChevronDown, ChevronRight, X } from "@lucide/vue";
import { ChevronDown, ChevronRight, Download, X } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { computed, ref } from "vue";
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { RouterLink } from "vue-router";
import CitationBlock from "@/components/CitationBlock.vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import PdfViewer, { type ViewerTarget } from "@/components/PdfViewer.vue";
import ProjectHead from "@/components/ProjectHead.vue";
import { api, errorTitle, isApiError } from "@/lib/api";
@@ -68,8 +69,12 @@ const hidden = computed(() => {
});
const group = (f: Finding) => [f, ...all.value.filter((x) => (f.duplicate_ids ?? []).includes(x.id))];
const live = computed(() => all.value.filter((f) => f.presence !== "resolved" && !hidden.value.has(f.id)));
const todo = computed(() => live.value.filter((f) => f.status === "pending" && (!sev.value || f.severity === sev.value)));
const handled = computed(() => live.value.filter((f) => f.status !== "pending" && (!sev.value || f.severity === sev.value)));
/** 这次打开页面后处理的问题留在原位(署名行),J / K 能回到它按 U 撤销;之前处理的折叠在「已处理」(规范 §8.2) */
const session = ref(new Set<string>());
const inSev = (f: Finding) => !sev.value || f.severity === sev.value;
const todo = computed(() => live.value.filter((f) => (f.status === "pending" || session.value.has(f.id)) && inSev(f)));
const handled = computed(() => live.value.filter((f) => f.status !== "pending" && !session.value.has(f.id) && inSev(f)));
const doneCount = computed(() => live.value.filter((f) => f.status !== "pending").length);
const resolved = computed(() => all.value.filter((f) => f.presence === "resolved"));
const count = (s: Sev) => live.value.filter((f) => f.status === "pending" && (!s || f.severity === s)).length;
const showHandled = ref(false);
@@ -105,8 +110,10 @@ async function resolve(f: Finding, status: "confirmed_ok" | "modified" | "ignore
);
}
noteFor.value = null;
remember(group(f));
toast(`已标记为「${FINDING_ACTION[status]}」`, { undo: () => void reopen(f) });
refresh();
next(f);
} catch (e) {
if (isApiError(e, "note_required")) noteErr.value = "请填写说明";
else toast(errorTitle(e), { ok: false });
@@ -115,11 +122,53 @@ async function resolve(f: Finding, status: "confirmed_ok" | "modified" | "ignore
}
}
function remember(fs: Finding[]) {
session.value = new Set([...session.value, ...fs.map((x) => x.id)]);
for (const x of fs) picked.value.delete(x.id);
}
/* ---------- 批量处理:只开放给「提示」(FR-M3-07) ---------- */
const picked = ref(new Set<string>());
const pickable = (f: Finding) => !readOnly.value && f.severity === "hint" && f.status === "pending";
const hints = computed(() => todo.value.filter(pickable));
const pickedList = computed(() => live.value.filter((f) => picked.value.has(f.id) && pickable(f)));
const allPicked = computed(() => hints.value.length > 0 && hints.value.every((f) => picked.value.has(f.id)));
function pick(f: Finding, on: boolean) {
const n = new Set(picked.value);
if (on) n.add(f.id);
else n.delete(f.id);
picked.value = n;
}
function pickAll(on: boolean) {
picked.value = on ? new Set(hints.value.map((f) => f.id)) : new Set();
}
const bulkBusy = ref(false);
async function bulkOk() {
const fs = pickedList.value.flatMap(group).filter((x) => x.status === "pending");
if (!fs.length) return;
bulkBusy.value = true;
try {
await unwrap(
api.POST("/check-runs/{run_id}/findings/bulk-resolve", {
params: { path: { run_id: props.runId } },
body: { finding_ids: fs.map((x) => x.id), status: "confirmed_ok" },
}),
);
remember(fs);
const n = pickedList.value.length || fs.length;
toast(`已将 ${n} 项「提示」标记为确认无误,逐条记了处理人和时间`, { undo: () => void Promise.all(fs.map((x) => reopenOne(x.id))).then(refresh) });
refresh();
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
bulkBusy.value = false;
}
}
const reopenOne = (id: string) => unwrap(api.POST("/findings/{finding_id}/reopen", { params: { path: { finding_id: id } } }));
async function reopen(f: Finding) {
try {
for (const x of group(f)) {
await unwrap(api.POST("/findings/{finding_id}/reopen", { params: { path: { finding_id: x.id } } }));
}
for (const x of group(f)) await reopenOne(x.id);
refresh();
} catch (e) {
toast(errorTitle(e), { ok: false });
@@ -176,6 +225,103 @@ function openCitation(c: Citation) {
target: { id: c.id, page: c.page_no, quads: c.quads, sha: c.normalized_sha256 },
};
}
const firstCite = (f: Finding) => f.tender_citations[0] ?? f.bid_citations[0];
/* ---------- 当前条与键盘(规范 §8.2):J / K 移动,O / Enter 看原文,1 / 2 / 3 处理,X 勾选「提示」,U 撤销,Esc 关原文 ---------- */
const curId = ref<string | null>(null);
const cur = computed(() => todo.value.find((f) => f.id === curId.value) ?? todo.value[0]);
const kbOn = ref(false);
function setCur(f: Finding, scroll = false) {
curId.value = f.id;
if (scroll) requestAnimationFrame(() => document.querySelector(`[data-fid="${f.id}"]`)?.scrollIntoView({ block: "nearest" }));
}
/** 处理完跳到下一条待处理 */
function next(f: Finding) {
const list = todo.value;
const i = list.findIndex((x) => x.id === f.id);
const n = list.find((x, j) => j > i && x.status === "pending" && x.id !== f.id);
if (n) setCur(n, true);
}
function onKey(e: KeyboardEvent) {
if (e.ctrlKey || e.metaKey || e.altKey || document.querySelector(".scrim")) return;
if ((e.target as HTMLElement | null)?.closest("input, textarea, select, [role=listbox], [contenteditable]")) return;
if (e.key === "Escape") {
viewer.value = null;
return;
}
const f = cur.value;
if (!f) return;
const k = e.key.toLowerCase();
if (k === "j" || k === "k") {
e.preventDefault();
const list = todo.value;
const i = Math.max(0, list.indexOf(f));
const n = list[Math.max(0, Math.min(list.length - 1, i + (k === "j" ? 1 : -1)))];
if (!n) return;
kbOn.value = true;
setCur(n, true);
const c = firstCite(n);
if (viewer.value && c) openCitation(c); // 原文开着时跟着翻到当前条的出处
} else if (k === "o" || (e.key === "Enter" && !(e.target as HTMLElement).closest("button, a, label"))) {
const c = firstCite(f);
if (c) {
e.preventDefault();
openCitation(c);
}
} else if (readOnly.value) {
return;
} else if (k === "u") {
if (f.status !== "pending") void reopen(f);
else toast("当前这一条还没有处理", { ok: false });
} else if (k === "x" && pickable(f)) {
pick(f, !picked.value.has(f.id));
} else if (["1", "2", "3"].includes(e.key) && f.status === "pending" && noteFor.value?.id !== f.id) {
e.preventDefault();
act(f, (["confirmed_ok", "modified", "ignored"] as const)[Number(e.key) - 1]!);
}
}
onMounted(() => window.addEventListener("keydown", onKey));
onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
/* ---------- 判断有误?(J5):反馈只用于改进检查规则,不改变处理状态 ---------- */
const FEEDBACK_REASONS = ["这不是问题(误报)", "级别不对", "出处定位错了", "其他"];
const fbFor = ref<Finding | null>(null);
const fbReason = ref(FEEDBACK_REASONS[0]!);
const fbNote = ref("");
const fbBusy = ref(false);
const fbOpen = computed({ get: () => !!fbFor.value, set: (v) => { if (!v) fbFor.value = null; } });
function askFeedback(f: Finding) {
fbFor.value = f;
fbReason.value = FEEDBACK_REASONS[0]!;
fbNote.value = "";
}
async function sendFeedback() {
const f = fbFor.value;
if (!f) return;
fbBusy.value = true;
try {
await unwrap(
api.POST("/feedback", {
body: {
project_id: props.projectId,
object_type: "finding",
object_id: f.id,
kind: "false_positive",
note: [fbReason.value, fbNote.value.trim()].filter(Boolean).join(":").slice(0, 1000),
},
}),
);
fbFor.value = null;
toast("已收到反馈");
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
fbBusy.value = false;
}
}
/* ---------- 检查覆盖范围:默认收起成一行,展开看全部(报告首页照样全文列出) ---------- */
const showCoverage = ref(false);
const metaLine = computed(() => {
const r = run.value;
@@ -196,15 +342,23 @@ const errorText = computed(() => {
<ProjectHead :project="p" tab="check" />
<div class="content">
<!-- 进行中 -->
<section v-if="run && !TERMINAL_JOB.has(run.status)" class="card running" aria-live="polite">
<h2>正在检查<span class="fig">{{ run.progress }}</span>%</h2>
<div class="bar" role="progressbar" :aria-valuenow="run.progress" aria-valuemin="0" aria-valuemax="100" aria-label="检查进度"><i :style="{ width: `${run.progress}%` }"></i></div>
<ol class="steps">
<li v-for="(s, i) in CHECK_STEPS" :key="s.label" :class="{ done: i < step, cur: i === step }">{{ s.label }}</li>
</ol>
<div class="acts">
<span class="cap muted">可以离开这个页面,检查完成后会通知你。</span>
<button type="button" class="btn sm text" :disabled="cancelling || run.cancel_requested" @click="cancel">{{ run.cancel_requested ? "正在取消…" : "取消检查" }}</button>
<section v-if="run && !TERMINAL_JOB.has(run.status)" class="card flush running" aria-labelledby="h-run" aria-live="polite">
<div class="card-h"><h2 id="h-run">正在检查</h2></div>
<div class="card-b">
<ol class="steps">
<li v-for="(s, i) in CHECK_STEPS" :key="s.label" :class="{ done: i < step, now: i === step }">
<span class="dot" aria-hidden="true">{{ i < step ? "✓" : "" }}</span>
<span>{{ s.label }}<span v-if="i === step" class="vh">(进行中)</span></span>
</li>
</ol>
<div class="progress">
<div class="bar" role="progressbar" :aria-valuenow="run.progress" aria-valuemin="0" aria-valuemax="100" aria-label="检查进度"><i :style="{ width: `${run.progress}%` }"></i></div>
<span class="fig">{{ run.progress }}%</span>
</div>
<p class="note">可以离开这个页面,检查完成后会通知你。</p>
</div>
<div class="card-f">
<button type="button" class="btn sm" :disabled="cancelling || run.cancel_requested" @click="cancel">{{ run.cancel_requested ? "正在取消…" : "取消检查" }}</button>
</div>
</section>
@@ -222,7 +376,11 @@ const errorText = computed(() => {
<p class="meta" :title="metaLine">{{ metaLine }}</p>
</div>
<div class="actions">
<RouterLink class="btn" :to="{ name: 'bid-check', params: { projectId } }">重新检查</RouterLink>
<div class="row">
<RouterLink class="btn" :to="{ name: 'bid-check', params: { projectId } }">重新检查</RouterLink>
<RouterLink class="btn primary" :to="{ name: 'report', params: { projectId } }"><Download class="ic" :stroke-width="1.5" />导出报告</RouterLink>
</div>
<span v-if="p?.readiness.open_fatal_count" class="export-note">还有 <span class="fig">{{ p.readiness.open_fatal_count }}</span> 项致命问题待处理,报告会如实列出</span>
</div>
</div>
<div v-if="!run.is_latest" class="alert warn" role="note"><span class="ico w" aria-hidden="true">!</span><p>这是历史检查,只能查看。</p></div>
@@ -233,18 +391,32 @@ const errorText = computed(() => {
<RouterLink class="btn sm" :to="{ name: 'bid-check', params: { projectId } }">重新检查</RouterLink>
</div>
<section v-if="run.coverage" class="card coverage" aria-labelledby="h-cov">
<h2 id="h-cov" class="vh">检查覆盖范围</h2>
<div>
<h3>查了</h3>
<ul><li v-for="t in run.coverage.checked ?? []" :key="t">{{ t }}</li></ul>
</div>
<div class="no">
<h3>没查</h3>
<ul><li v-for="t in run.coverage.not_checked ?? []" :key="t">{{ t }}</li></ul>
<section v-if="run.coverage" class="card flush coverage" aria-labelledby="h-cov">
<button type="button" class="cov-h" :aria-expanded="showCoverage" aria-controls="cov-body" @click="showCoverage = !showCoverage">
<component :is="showCoverage ? ChevronDown : ChevronRight" class="ic" :stroke-width="1.5" />
<h2 id="h-cov">检查覆盖范围</h2>
<span class="cap muted">查了 <span class="fig">{{ run.coverage.checked?.length ?? 0 }}</span> 项 · 没查 <span class="fig">{{ run.coverage.not_checked?.length ?? 0 }}</span> 项,报告首页会全部写明</span>
</button>
<div v-show="showCoverage" id="cov-body" class="cov-body">
<div>
<h3>查了</h3>
<ul><li v-for="t in run.coverage.checked ?? []" :key="t">{{ t }}</li></ul>
</div>
<div class="no">
<h3>没查</h3>
<ul><li v-for="t in run.coverage.not_checked ?? []" :key="t">{{ t }}</li></ul>
</div>
</div>
</section>
<div v-if="pickedList.length" class="batchbar" role="region" aria-label="批量处理">
<b>已选 <span class="fig">{{ pickedList.length }}</span> 项「提示」</b>
<span class="r">
<button type="button" class="btn sm" :disabled="bulkBusy" @click="bulkOk">批量确认无误</button>
<button type="button" class="btn sm text" @click="pickAll(false)">取消选择</button>
</span>
</div>
<section class="card flush results" aria-label="检查结果">
<div class="card-h">
<div class="seg" role="group" aria-label="按级别筛选">
@@ -253,11 +425,31 @@ const errorText = computed(() => {
<span class="sev" :class="SEVERITY_CLASS[s]">{{ SEVERITY[s] }}<span class="fig">{{ count(s) }}</span></span>
</button>
</div>
<div class="r">
<label v-if="!readOnly" class="choice cap" :class="{ dis: !hints.length }">
<input type="checkbox" class="ck" :checked="allPicked" :indeterminate="!allPicked && pickedList.length > 0" :disabled="!hints.length" @change="pickAll(($event.target as HTMLInputElement).checked)" />全选「提示」
</label>
<span class="cap muted">已处理 <span class="fig">{{ doneCount }}</span> / <span class="fig">{{ live.length }}</span></span>
<span class="cap muted keys" title="J / K 上下条 · O 看原文 · 1 确认无误 · 2 已修改 · 3 忽略 · X 勾选「提示」 · U 撤销"><span class="kbd">J</span><span class="kbd">K</span> 上下条 <span class="kbd">1</span><span class="kbd">2</span><span class="kbd">3</span> 处理 <span class="kbd">U</span> 撤销</span>
</div>
</div>
<div v-if="findingsQ.isPending.value" class="lst-pad"><span class="skel t"></span><span class="skel"></span></div>
<template v-else>
<article v-for="f in [...todo, ...(showHandled ? handled : [])]" :key="f.id" class="finding">
<div class="flist" :class="{ 'kb-on': kbOn }" @pointerdown="kbOn = false">
<article
v-for="f in [...todo, ...(showHandled ? handled : [])]"
:key="f.id"
class="finding"
:class="{ sel: cur?.id === f.id, 'has-ck': !readOnly }"
:data-fid="f.id"
:aria-current="cur?.id === f.id ? 'true' : undefined"
@click="todo.includes(f) && setCur(f)"
>
<label v-if="f.status === 'pending' && !readOnly && pickable(f)" class="ckw" data-tip="批量处理" @click.stop>
<input type="checkbox" class="ck" :checked="picked.has(f.id)" :aria-label="`勾选:${f.title}`" @change="pick(f, ($event.target as HTMLInputElement).checked)" />
</label>
<span v-else-if="f.status === 'pending' && !readOnly" class="ckw" data-tip="只有「提示」可以批量处理"><input type="checkbox" class="ck" disabled aria-label="不可批量" /></span>
<div>
<div class="f-head">
<span class="sev" :class="SEVERITY_CLASS[f.severity]">{{ SEVERITY[f.severity] }}</span>
@@ -267,12 +459,14 @@ const errorText = computed(() => {
</div>
<p class="f-text">{{ f.explanation }}</p>
<div v-if="f.status !== 'pending'" class="sign" :class="{ ign: f.status === 'ignored' }">
<span aria-hidden="true">{{ f.status === "ignored" ? "–" : "✓" }}</span><b>{{ f.resolved_by?.name }}</b>
<span v-if="f.resolved_at" class="fig">{{ fmtDateTime(f.resolved_at) }}</span><span>{{ FINDING_ACTION[f.status] }}</span>
<button v-if="!readOnly" type="button" class="undo" @click="reopen(f)">撤销</button>
</div>
<p v-if="f.status !== 'pending' && f.resolution_note" class="sign-note">{{ f.status === "ignored" ? "原因" : "说明" }}:{{ f.resolution_note }}</p>
<template v-if="f.status !== 'pending'">
<div class="sign" :class="{ ign: f.status === 'ignored' }">
<span aria-hidden="true">{{ f.status === "ignored" ? "–" : "✓" }}</span><b>{{ f.resolved_by?.name }}</b>
<span v-if="f.resolved_at" class="fig">{{ fmtDateTime(f.resolved_at) }}</span><span>{{ FINDING_ACTION[f.status] }}</span>
<button v-if="!readOnly" type="button" class="undo" @click="reopen(f)">撤销</button>
</div>
<p v-if="f.resolution_note" class="sign-note">{{ f.status === "ignored" ? "原因" : "说明" }}:{{ f.resolution_note }}</p>
</template>
<div v-else-if="noteFor?.id === f.id" class="u4">
<div class="field" :class="{ err: noteErr }">
@@ -290,6 +484,7 @@ const errorText = computed(() => {
<button type="button" class="btn sm" :disabled="busyId === f.id" @click="act(f, 'confirmed_ok')">确认无误</button>
<button type="button" class="btn sm" :disabled="busyId === f.id" @click="act(f, 'modified')">已修改</button>
<button type="button" class="btn sm" :disabled="busyId === f.id" @click="act(f, 'ignored')">忽略并注明原因</button>
<button type="button" class="lnk cap feedback" @click="askFeedback(f)">判断有误?</button>
</div>
</div>
<aside>
@@ -305,6 +500,7 @@ const errorText = computed(() => {
</div>
</aside>
</article>
</div>
<div v-if="!todo.length" class="empty sm">
<b>{{ live.length ? (sev ? "这个级别的问题都已处理" : "全部问题都已处理") : "没有发现问题" }}</b>
@@ -353,6 +549,18 @@ const errorText = computed(() => {
<div v-else-if="runQ.error.value" class="alert warn" role="alert"><span class="ico w" aria-hidden="true">!</span><p>{{ errorTitle(runQ.error.value) }}</p></div>
</div>
<ConfirmDialog v-model:open="fbOpen" title="反馈:判断有误" ok-text="提交反馈" busy-text="正在提交…" :busy="fbBusy" @ok="sendFeedback">
<p class="muted fb-title">「{{ fbFor?.title }}」</p>
<div class="fb-choices" role="radiogroup" aria-label="哪里不对">
<label v-for="r in FEEDBACK_REASONS" :key="r" class="choice"><input v-model="fbReason" type="radio" class="ck" name="fb" :value="r" />{{ r }}</label>
</div>
<div class="field fill fb-note">
<label for="fb-note">补充说明 <small>(选填)</small></label>
<textarea id="fb-note" v-model="fbNote" class="input" maxlength="200"></textarea>
<span class="msg">反馈只用于改进检查规则,不改变这条问题的处理状态。</span>
</div>
</ConfirmDialog>
<aside v-if="viewer" class="drawer" aria-label="原文">
<button type="button" class="icon-btn sm close" aria-label="关闭原文" @click="viewer = null"><X class="ic" :stroke-width="1.5" /></button>
<PdfViewer :document-id="viewer.doc.id" :document-label="viewer.doc.label" :target="viewer.target" />
@@ -360,45 +568,14 @@ const errorText = computed(() => {
</template>
<style scoped>
.running h2 {
display: flex;
gap: var(--sp-2);
align-items: baseline;
margin: 0 0 var(--sp-3);
font: var(--fw-bold) var(--fs-title) / var(--lh-title) var(--font-sans);
.running {
max-width: 736px;
}
.running .bar {
height: 6px;
border-radius: 3px;
background: var(--c-n100);
overflow: hidden;
.running .card-b {
padding-top: var(--sp-2);
}
.running .bar i {
display: block;
height: 100%;
background: var(--ai);
transition: width var(--dur-base) var(--ease);
}
.steps {
display: flex;
gap: var(--sp-6);
margin: var(--sp-4) 0 0;
padding: 0;
list-style: none;
color: var(--text-secondary);
}
.steps .done {
color: var(--text-primary);
}
.steps .cur {
color: var(--text-primary);
font-weight: var(--fw-bold);
}
.running .acts {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: var(--sp-4);
.running .progress {
margin-top: var(--sp-3);
}
.rbar {
display: flex;
@@ -416,13 +593,50 @@ const errorText = computed(() => {
font-size: var(--fs-caption);
}
.rbar .actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--sp-1);
margin-left: auto;
}
.rbar .actions .row {
display: flex;
gap: var(--sp-2);
}
.export-note {
color: var(--text-secondary);
font-size: var(--fs-caption);
white-space: nowrap;
}
.coverage {
margin-bottom: var(--sp-4);
}
.cov-h {
display: flex;
align-items: center;
gap: var(--sp-2);
width: 100%;
padding: var(--sp-3) var(--card-x);
border: 0;
background: none;
font: inherit;
color: var(--text-primary);
text-align: left;
cursor: pointer;
}
.cov-h h2 {
margin: 0;
font-size: var(--fs-body);
font-weight: var(--fw-bold);
}
.cov-h .ic {
color: var(--text-secondary);
}
.cov-body {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--sp-6);
margin-bottom: var(--sp-4);
padding: 0 var(--card-x) var(--sp-4);
}
.coverage h3 {
margin: 0 0 var(--sp-1);
@@ -438,6 +652,36 @@ const errorText = computed(() => {
.results .card-h {
display: flex;
align-items: center;
gap: var(--sp-3);
}
.results .card-h .r {
display: flex;
align-items: center;
gap: var(--sp-4);
margin-left: auto;
white-space: nowrap;
}
.keys .kbd {
margin-right: 2px;
}
.flist:focus {
outline: 0;
}
.f-actions .feedback {
margin-left: auto;
color: var(--text-secondary);
}
.fb-title {
margin: 0;
}
.fb-choices {
display: flex;
flex-direction: column;
gap: var(--sp-2);
margin-top: var(--sp-3);
}
.fb-note {
margin-top: var(--sp-4);
}
.seg {
display: flex;
@@ -525,7 +769,11 @@ const errorText = computed(() => {
gap: var(--sp-1);
width: 100%;
padding: var(--sp-3) var(--card-x);
border: 0;
border-top: var(--line) solid var(--border-subtle);
background: none;
font: inherit;
cursor: pointer;
color: var(--text-secondary);
text-align: left;
}

View File

@@ -83,7 +83,7 @@
/* B3 按钮补充状态:加载中两个文字叠在同一格,切换时宽度不变 */
.btn .ic{margin:0 -2px}
.btn.swap{display:inline-grid;place-items:center}
.btn.swap > span{grid-area:1/1;white-space:nowrap}
.btn.swap > span{grid-area:1/1;display:inline-flex;align-items:center;gap:6px;white-space:nowrap}
.btn.swap > .busy,.btn.swap.is-loading > .idle{visibility:hidden}
.btn.swap.is-loading > .busy{visibility:visible}
.btn.is-loading{cursor:progress}
@@ -94,6 +94,7 @@
/* B4 输入框 */
.input{width:100%;transition:border-color var(--dur-fast) var(--ease)}
.input.sm{height:var(--control-h-sm)}
.input:hover{border-color:var(--c-ink)}
.input:focus{outline:0;border-color:var(--c-ink);box-shadow:0 0 0 1px var(--c-ink)}
.field.err .input:focus{border-color:var(--warn);box-shadow:0 0 0 1px var(--warn)}
@@ -252,6 +253,9 @@ textarea.input{height:auto;min-height:80px;padding:var(--sp-2) var(--sp-3);resiz
.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)}
/* 行内出处(「第 N 页」):蓝铅笔色的按钮,点开原文 */
.cite-src.inline{display:inline;padding:0;border:0;background:none;font:inherit;font-size:var(--fs-caption);color:var(--ai);white-space:nowrap;cursor:pointer}
.cite-src.inline:hover{text-decoration:underline;text-underline-offset:3px}
/* 分段选择(重要性等单选) */
.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)}
@@ -282,6 +286,45 @@ textarea.input{height:auto;min-height:80px;padding:var(--sp-2) var(--sp-3);resiz
.menu [role=option],.menu [role=menuitem]{display:flex;align-items:center;gap:var(--sp-2);width:100%;min-height:34px;padding:6px 10px;border:0;border-radius:var(--radius-tag);background:none;font:var(--fs-body)/var(--lh-body) var(--font-sans);color:var(--text-primary);text-align:left;cursor:pointer;white-space:nowrap}
.menu [role=option]:hover,.menu [role=menuitem]:hover,.menu .is-active,.menu [role=menuitem]:focus-visible,.menu [data-highlighted]{background:var(--c-n100);outline:0}
.menu hr{border:0;border-top:var(--line) solid var(--border-subtle);margin:6px 0}
/* 列表的当前条与批量勾选(照搬原型 §7.18):当前条 = 浅底 + 左侧 3px 墨色竖条;键盘操作时再加 2px 内框;勾选条只有复选框是选中状态 */
.finding.sel{background:var(--c-n50);box-shadow:inset 3px 0 0 var(--c-ink)}
.finding{transition:background-color var(--dur-fast) var(--ease)}
.kb-on > .finding.sel{box-shadow:inset 3px 0 0 var(--c-ink),inset 0 0 0 2px var(--focus-ring)}
.finding .ckw{position:absolute;left:14px;top:calc(var(--sp-5) + 2px)}
.finding.has-ck{padding-left:calc(var(--card-x) + 24px)}
.batchbar{position:sticky;top:calc(var(--topbar-h) + var(--sp-2));z-index:10;display:flex;align-items:center;gap:var(--sp-3);height:48px;padding:0 var(--sp-2) 0 var(--card-x);margin-bottom:var(--sp-2);background:var(--bg-surface);border:var(--line) solid var(--c-ink);border-radius:var(--radius-card)}
.batchbar b .fig{font-weight:var(--fw-bold)}
.batchbar .r{margin-left:auto;display:flex;gap:var(--sp-2);align-items:center}
.choice.dis{color:var(--text-disabled);cursor:not-allowed}
.ck:disabled{background:var(--c-n100);border-color:var(--border-subtle);cursor:not-allowed}
.ck:indeterminate{background:var(--c-ink);border-color:var(--c-ink)}
.ck:indeterminate::after{display:block;width:8px;height:0;border-top:1.75px solid var(--c-white)}
/* 单选(照搬原型 B7) */
.ck[type=radio]{border-radius:50%}
.ck[type=radio]:checked{background:var(--bg-surface);border-color:var(--c-ink)}
.ck[type=radio]:checked::after{width:8px;height:8px;border:0;border-radius:50%;background:var(--c-ink);transform:none}
.ck[type=radio]:disabled:checked{background:var(--c-n100)}
.ck[type=radio]:disabled:checked::after{background:var(--c-muted)}
.ck:disabled:hover{border-color:var(--border-subtle)}
/* 项目头标签页上的致命数(原型 .ph .tabs .sev) */
.tabs [role=tab] .sev{margin-left:6px}
/* 标题折行时级别标签对齐第一行 */
.f-head{align-items:flex-start}
.f-head .sev{margin-top:2px}
/* 下拉选择(规范 §7.21,components/SelectBox.vue):触发框和输入框同规格;浮层复用 .menu */
.select{display:flex;align-items:center;gap:var(--sp-2);width:100%;min-width:0;height:var(--control-h);padding:0 10px 0 var(--sp-3);border:var(--line) solid var(--border-control);border-radius:var(--radius-ctl);background:var(--bg-surface);font:var(--fs-body) var(--font-sans);color:var(--text-primary);text-align:left;cursor:pointer;transition:border-color var(--dur-fast) var(--ease)}
.select.sm{height:var(--control-h-sm)}
.select:hover{border-color:var(--c-ink)}
.select:focus-visible,.select[data-state=open]{outline:0;border-color:var(--c-ink);box-shadow:0 0 0 1px var(--c-ink)}
.select .val{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.select > .ic{flex:none;color:var(--text-secondary);transition:transform var(--dur-fast) var(--ease)}
.select[data-state=open] > .ic{transform:rotate(180deg)}
.select[data-disabled]{background:var(--c-n100);border-color:var(--c-n100);color:var(--text-disabled);cursor:not-allowed}
.field.err .select{border-color:var(--warn);box-shadow:inset 0 0 0 1px var(--warn)}
.menu.sel-menu{z-index:56;min-width:var(--reka-select-trigger-width);max-height:min(300px,var(--reka-select-content-available-height))}
.sel-menu [role=option][data-state=checked]{font-weight:var(--fw-bold)}
.sel-menu .chk{margin-left:auto;padding-left:var(--sp-4);color:var(--c-ink);font-weight:var(--fw-bold)}
.sel-menu [data-disabled]{color:var(--text-disabled);cursor:not-allowed;background:none}
/* 开关(长期有效等) */
.switch{display:inline-flex;align-items:center;gap:var(--sp-2);padding:0;border:0;background:none;font:var(--fs-body) var(--font-sans);color:var(--text-primary);cursor:pointer}
.switch .track{position:relative;flex:none;width:34px;height:20px;border:var(--line) solid var(--border-control);border-radius:var(--radius-tag);background:var(--bg-surface);transition:background-color var(--dur-fast) var(--ease),border-color var(--dur-fast) var(--ease)}