Files
im0o 7d08f37248 规则表单:类型候选补全为内核全集,目标候选精简
类型照抄内核解析器的 switch(rules/parser.go:17-98),37 种全给——
候选列表是过滤式的,全集不构成负担,缺项才是。目标反着来:只建议
DIRECT 和 REJECT,策略组名手填——全列出来的话,每路入站自动生成的
三个专属组会把列表灌成一面墙。

按原文编辑的输入框给一个完整的逻辑规则做占位例子(原文模式存在的
理由就是这类规则)。弹层滚动条改细杆透明轨道。
2026-08-12 00:13:02 +08:00

318 lines
8.7 KiB
Vue

<script setup lang="ts">
/**
* 单选下拉,替代原生 <select>。
*
* 换成自绘只为一件事:原生控件没有「展开中」这个可读状态,右侧箭头做不了
* 开合动画,弹层样式也管不到。交互仿 SourcePicker(同一族控件):点开列出
* 全部选项、勾标当前选中;上下键移动、Enter 选中、Esc 只收弹层不关对话框。
*
* 背景色用 --select-bg 变量留了口子:对话框里的控件要比对话框底色沉一档
* (--c-bg),页面上的控件要比页面底色浮一档(--c-surface),一个值满足不了。
*/
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import IconCheck from '~icons/ri/check-line'
import IconChevron from '~icons/ri/arrow-down-s-line'
export interface SelectItem {
value: string
/** 界面上显示的字,缺省用 value 原文 */
label?: string
/** 分组标题。相邻同组的选项归在一个标题下 */
group?: string
}
const props = defineProps<{
options: SelectItem[]
/** 当前值不在选项里(含空串)时显示的占位文案 */
placeholder?: string
ariaLabel?: string
}>()
const chosen = defineModel<string>({ default: '' })
const root = ref<HTMLElement | null>(null)
const menuEl = ref<HTMLElement | null>(null)
const open = ref(false)
/** 往上弹。下方视口不够放弹层、上方够时翻转,不把弹层怼出屏幕 */
const openUp = ref(false)
const active = ref(0)
const menuStyle = ref<Record<string, string>>({})
const current = computed(() => props.options.find((o) => o.value === chosen.value) ?? null)
const MENU_ROOM = 220
/**
* 弹层是 position: fixed,按视口摆放。absolute 的弹层会计入祖先滚动容器的
* 可滚动区域——在对话框里打开就把对话框撑出滚动条。fixed 逃出所有 overflow
* 裁剪,又因为还在 dialog 的 DOM 子树里,照样画在 top layer 之上。
* 代价是位置不跟随滚动,所以下面在滚动和缩放时直接收起。
*/
function place() {
const rect = root.value?.getBoundingClientRect()
if (!rect) return
const up = window.innerHeight - rect.bottom < MENU_ROOM && rect.top > MENU_ROOM
openUp.value = up
menuStyle.value = {
left: `${String(rect.left)}px`,
minWidth: `${String(rect.width)}px`,
...(up
? { bottom: `${String(window.innerHeight - rect.top + 4)}px` }
: { top: `${String(rect.bottom + 4)}px` }),
}
}
function toggleOpen() {
if (open.value) {
open.value = false
return
}
active.value = Math.max(
0,
props.options.findIndex((o) => o.value === chosen.value),
)
place()
open.value = true
}
/** 页面或对话框一滚动,锚点就不准了,直接收起。弹层内部的滚动不算 */
function closeOnMove(event?: Event) {
if (event?.target instanceof Node && menuEl.value?.contains(event.target)) return
open.value = false
}
watch(open, (isOpen) => {
if (isOpen) {
window.addEventListener('scroll', closeOnMove, true)
window.addEventListener('resize', closeOnMove)
} else {
window.removeEventListener('scroll', closeOnMove, true)
window.removeEventListener('resize', closeOnMove)
}
})
onBeforeUnmount(() => {
window.removeEventListener('scroll', closeOnMove, true)
window.removeEventListener('resize', closeOnMove)
})
function pick(value: string) {
chosen.value = value
open.value = false
}
function onKeydown(event: KeyboardEvent) {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
if (!open.value) {
toggleOpen()
return
}
const count = props.options.length
if (count === 0) return
const delta = event.key === 'ArrowDown' ? 1 : -1
active.value = (active.value + delta + count) % count
} else if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
if (!open.value) {
toggleOpen()
return
}
const item = props.options[active.value]
if (item) pick(item.value)
} else if (event.key === 'Escape' && open.value) {
// 对话框里按 Esc 只收弹层,不能顺手把整个对话框关了
event.stopPropagation()
event.preventDefault()
open.value = false
}
}
/** 焦点离开整块时收起。新焦点还在里面就不收,否则点选项会先把自己关掉。 */
function onFocusOut(event: FocusEvent) {
const next = event.relatedTarget
if (next instanceof Node && root.value?.contains(next)) return
open.value = false
}
/** 这个下标前面要不要画组标题:是组里第一项才画。 */
function groupAt(index: number): string | null {
const group = props.options[index]?.group
if (!group) return null
return props.options[index - 1]?.group === group ? null : group
}
</script>
<template>
<div ref="root" class="select" @focusout="onFocusOut">
<button
type="button"
class="face"
role="combobox"
:aria-expanded="open"
:aria-label="ariaLabel"
@click="toggleOpen"
@keydown="onKeydown"
>
<span :class="['face-text', !current && 'is-empty']">
{{ current ? (current.label ?? current.value) : (placeholder ?? '请选择') }}
</span>
<IconChevron :class="['chev', open && 'is-open']" aria-hidden="true" />
</button>
<Transition name="menu">
<ul v-if="open" ref="menuEl" :class="['menu', openUp && 'is-up']" :style="menuStyle" role="listbox">
<li v-if="options.length === 0" class="none">没有可选项</li>
<template v-for="(item, i) in options" :key="item.value">
<li v-if="groupAt(i)" class="group" role="presentation">{{ groupAt(i) }}</li>
<li>
<button
type="button"
role="option"
:aria-selected="item.value === chosen"
:class="['opt', item.value === chosen && 'is-on', i === active && 'is-active']"
@mousemove="active = i"
@click="pick(item.value)"
>
<span class="mark"><IconCheck v-if="item.value === chosen" /></span>
{{ item.label ?? item.value }}
</button>
</li>
</template>
</ul>
</Transition>
</div>
</template>
<style scoped>
/* 宽度不在这里定:块级元素在表单列里自然撑满,行内布局由调用方给定宽度。
写死 width: 100% 会和调用方的宽度规则打架,两条同权重规则谁赢看加载顺序 */
.select {
position: relative;
}
.face {
display: flex;
align-items: center;
gap: var(--s-2);
width: 100%;
padding: var(--s-2) var(--s-3);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--select-bg, var(--c-bg));
text-align: left;
cursor: pointer;
user-select: none;
}
/* 和文本输入一致:焦点态是描边变色,不叠外圈 */
.face:focus-visible {
outline: none;
border-color: var(--c-accent);
}
.face-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.face-text.is-empty {
color: var(--c-text-faint);
}
.chev {
flex: none;
width: 16px;
height: 16px;
color: var(--c-text-faint);
transition: transform 0.2s ease;
}
.chev.is-open {
transform: rotate(180deg);
}
/* 位置(left/top/bottom/minWidth)由 place() 以内联样式给出 */
.menu {
position: fixed;
z-index: 1;
/* 弹层可以比控件宽(按最长选项撑开),但不能宽出屏幕 */
max-width: min(360px, calc(100vw - 2 * var(--s-4)));
max-height: 200px;
overflow-y: auto;
/* 细杆加透明轨道,别让弹层里出现一根系统灰滚动条 */
scrollbar-width: thin;
scrollbar-color: var(--c-border-strong) transparent;
list-style: none;
margin: 0;
padding: var(--s-1);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-md);
background: var(--c-surface);
user-select: none;
}
.group {
padding: var(--s-2) var(--s-3) var(--s-1);
color: var(--c-text-faint);
font-size: var(--t-xs);
}
.opt {
display: flex;
align-items: center;
gap: var(--s-2);
width: 100%;
padding: var(--s-2) var(--s-3);
border: none;
border-radius: var(--r-sm);
background: none;
text-align: left;
font-size: var(--t-sm);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
.opt.is-active {
background: var(--c-surface-sunken);
}
.opt.is-on {
color: var(--c-accent-text);
}
.mark {
display: flex;
flex: none;
width: 1em;
}
.none {
padding: var(--s-2) var(--s-3);
color: var(--c-text-faint);
font-size: var(--t-sm);
}
.menu-enter-active,
.menu-leave-active {
transition:
opacity 0.12s ease,
transform 0.12s ease;
}
.menu-enter-from,
.menu-leave-to {
opacity: 0;
transform: translateY(-4px);
}
.menu.is-up.menu-enter-from,
.menu.is-up.menu-leave-to {
transform: translateY(4px);
}
</style>