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

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

307 lines
8.0 KiB
Vue
Raw Permalink 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">
/**
* 订阅多选。已选的是输入框里的一排标签,输入文字过滤下拉列表,点选项在
* 选与不选之间切换,列表保持打开,方便一口气挑好几个。
*
* 交互是多选组合框的通行形态(Ant Design、Element Plus 的多选 Select 都是这一套):
* 标签收在框内、下拉列出全部选项并给已选的打勾、点击不收起。订阅只有一两个时
* 平铺标签更直接,但数量一多平铺就是一面墙,这个形态两头都撑得住。
*/
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import IconCheck from '~icons/ri/check-line'
import IconRemove from '~icons/ri/close-line'
const props = defineProps<{ options: string[] }>()
const chosen = defineModel<string[]>({ required: true })
const root = ref<HTMLElement | null>(null)
const input = ref<HTMLInputElement | null>(null)
const menuEl = ref<HTMLElement | null>(null)
const open = ref(false)
const openUp = ref(false)
const query = ref('')
/** 键盘上下键在过滤结果里走到第几项 */
const active = ref(0)
const menuStyle = ref<Record<string, string>>({})
const filtered = computed(() =>
props.options.filter((name) => name.toLowerCase().includes(query.value.trim().toLowerCase())),
)
watch(query, () => {
active.value = 0
open.value = true
})
const MENU_ROOM = 220
/**
* 弹层是 position: fixed,按视口摆放,理由同 SelectBox:absolute 会计入
* 祖先滚动容器的可滚动区域,把页面或对话框撑出滚动条。
* 位置不跟随滚动,所以滚动和缩放时直接收起。
*/
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 closeOnMove(event?: Event) {
if (event?.target instanceof Node && menuEl.value?.contains(event.target)) return
open.value = false
}
watch(open, (isOpen) => {
if (isOpen) {
place()
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 toggle(name: string) {
chosen.value = chosen.value.includes(name)
? chosen.value.filter((item) => item !== name)
: [...chosen.value, name]
// 点完一项接着挑下一项,不用再点回输入框
input.value?.focus()
// 标签增减会改变输入框高度,锚点变了要重新摆
void nextTick(place)
}
function remove(name: string) {
chosen.value = chosen.value.filter((item) => item !== name)
if (open.value) void nextTick(place)
}
function onKeydown(event: KeyboardEvent) {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
open.value = true
const count = filtered.value.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.preventDefault()
const name = filtered.value[active.value]
if (open.value && name) toggle(name)
} else if (event.key === 'Escape') {
open.value = false
} else if (event.key === 'Backspace' && query.value === '') {
const last = chosen.value.at(-1)
if (last) remove(last)
}
}
/** 焦点离开整块时收起。判断新焦点还在不在里面,否则点列表里的选项会先把自己关掉。 */
function onFocusOut(event: FocusEvent) {
const next = event.relatedTarget
if (next instanceof Node && root.value?.contains(next)) return
open.value = false
query.value = ''
}
</script>
<template>
<div ref="root" class="picker" @focusout="onFocusOut">
<div class="box" @click="input?.focus()">
<span v-for="name in chosen" :key="name" class="tag">
{{ name }}
<button type="button" :title="`移除 ${name}`" @click.stop="remove(name)">
<IconRemove />
</button>
</span>
<input
ref="input"
v-model="query"
:placeholder="chosen.length === 0 ? '选择订阅' : ''"
:aria-expanded="open"
role="combobox"
autocomplete="off"
@focus="open = true"
@keydown="onKeydown"
/>
</div>
<Transition name="menu">
<ul
v-if="open"
ref="menuEl"
:class="['menu', openUp && 'is-up']"
:style="menuStyle"
role="listbox"
aria-multiselectable="true"
>
<li v-if="options.length === 0" class="none">没有可选的订阅</li>
<li v-else-if="filtered.length === 0" class="none">没有匹配的订阅</li>
<li v-for="(name, i) in filtered" :key="name">
<button
type="button"
role="option"
:aria-selected="chosen.includes(name)"
:class="['opt', chosen.includes(name) && 'is-on', i === active && 'is-active']"
@mousemove="active = i"
@click="toggle(name)"
>
<span class="mark"><IconCheck v-if="chosen.includes(name)" /></span>
{{ name }}
</button>
</li>
</ul>
</Transition>
</div>
</template>
<style scoped>
.picker {
position: relative;
}
.box {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--s-1);
min-height: 34px;
padding: 3px var(--s-2);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-surface);
cursor: text;
transition: border-color 0.15s ease;
}
.box:focus-within {
border-color: var(--c-accent);
}
.tag {
display: inline-flex;
align-items: center;
gap: var(--s-1);
height: 26px;
padding: 0 var(--s-1) 0 var(--s-2);
border-radius: var(--r-sm);
background: var(--c-surface-sunken);
font-size: var(--t-sm);
}
.tag button {
display: grid;
place-items: center;
width: 20px;
height: 20px;
border: none;
border-radius: var(--r-sm);
background: none;
color: var(--c-text-faint);
cursor: pointer;
}
.tag button:hover {
background: var(--c-surface);
color: var(--c-danger);
}
.box input {
flex: 1;
min-width: 6em;
height: 26px;
padding: 0 var(--s-1);
border: none;
background: none;
font-size: var(--t-sm);
}
/* 浮在上面,不能把下面几行顶下去。位置由 place() 以内联样式给出 */
.menu {
position: fixed;
z-index: 1;
max-width: min(360px, calc(100vw - 2 * var(--s-4)));
max-height: 240px;
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;
}
.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;
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>