Files
im0o 48e97bd91f 规则编辑:建议列表全量展开,目标补齐候选,删除免确认
组合框展开先给全量、本次展开输入过才过滤——按当前值过滤的话,预填的
类型会把列表滤到只剩它自己,MATCH 这类排在后面的选项等于不存在。类型
因此可以默认留空,placeholder 当示例。

目标建议由页面传入:全局规则给内置两个加用户自建的组和自有线路;附加
规则多一项本路专属组——「这个载荷走本路代理」写的就是它,放在拦截规则
前面还能做豁免。生成的专属组不列,每路三个会把列表灌成一面墙。

原文/字段切换只拦真正整行编辑的类型(isRawOnlyRule):之前用
isStructuredEditable 拦,新增时的空原文永远过不了,卡死在原文模式。

删除规则和附加规则不再二次确认——删错了有撤回,确认框只是多一步;
toast 带上被删规则的原文。
2026-08-12 13:16:58 +08:00

291 lines
7.3 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">
/**
* 可自由填写的组合框,替代原生 input + datalist。
*
* 和 SelectBox 的分工:SelectBox 的值只能从选项里挑;这里选项只是建议,
* 手填任何值都合法(规则的目标可以是候选之外的出站名)。换掉 datalist
* 的理由和 SelectBox 一样:原生建议列表的样式、动画、开合状态都管不到。
*
* 过滤只在本次展开后输入过才生效:带着已有值展开时按值过滤的话,
* 列表只剩当前值自己,其它选项等于不存在。
*
* Enter 的归属:弹层开着且有高亮项时选中它、不提交表单;弹层收起时不拦,
* 交给对话框的表单提交——填完直接回车保存是原生 input 的习惯,不能破坏。
*/
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import IconChevron from '~icons/ri/arrow-down-s-line'
const props = defineProps<{
/** 建议列表。过滤是 includes,不区分大小写 */
options: string[]
placeholder?: string
/** 等宽字体,规则原文这类内容用 */
mono?: boolean
ariaLabel?: string
}>()
const text = defineModel<string>({ default: '' })
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 active = ref(0)
const menuStyle = ref<Record<string, string>>({})
/** 本次展开后是否输入过。没输入过就给全量列表 */
const typed = ref(false)
const filtered = computed(() => {
const q = text.value.trim().toLowerCase()
if (!typed.value || !q) return props.options
return props.options.filter((name) => name.toLowerCase().includes(q))
})
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) {
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 show() {
typed.value = false
active.value = 0
place()
open.value = true
}
function onInput() {
typed.value = true
active.value = 0
place()
open.value = true
}
function toggleFromChevron() {
if (open.value) open.value = false
else show()
input.value?.focus()
}
function pick(name: string) {
text.value = name
open.value = false
input.value?.focus()
}
function onKeydown(event: KeyboardEvent) {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
if (!open.value) {
show()
return
}
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') {
if (!open.value) return // 交给表单提交
const name = filtered.value[active.value]
event.preventDefault()
if (name) pick(name)
else open.value = false
} else if (event.key === 'Escape' && open.value) {
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
}
</script>
<template>
<div ref="root" class="combo" @focusout="onFocusOut">
<input
ref="input"
v-model="text"
:class="['field', mono && 'mono']"
type="text"
role="combobox"
:aria-expanded="open"
:aria-label="ariaLabel"
:placeholder="placeholder"
autocomplete="off"
spellcheck="false"
@focus="show"
@input="onInput"
@keydown="onKeydown"
/>
<button
type="button"
class="toggle"
:aria-label="open ? '收起建议' : '展开建议'"
tabindex="-1"
@click="toggleFromChevron"
>
<IconChevron :class="['chev', open && 'is-open']" aria-hidden="true" />
</button>
<Transition name="menu">
<ul
v-if="open && filtered.length > 0"
ref="menuEl"
:class="['menu', openUp && 'is-up']"
:style="menuStyle"
role="listbox"
>
<li v-for="(name, i) in filtered" :key="name">
<button
type="button"
role="option"
:aria-selected="name === text"
:class="['opt', i === active && 'is-active']"
@mousemove="active = i"
@click="pick(name)"
>
{{ name }}
</button>
</li>
</ul>
</Transition>
</div>
</template>
<style scoped>
.combo {
position: relative;
}
.field {
width: 100%;
padding: var(--s-2) var(--s-3);
/* 给右侧箭头留位置 */
padding-right: var(--s-6);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--select-bg, var(--c-bg));
}
.toggle {
position: absolute;
top: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
padding: 0 var(--s-2);
border: none;
background: none;
color: var(--c-text-faint);
cursor: pointer;
}
.chev {
width: 16px;
height: 16px;
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;
}
.opt {
display: block;
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);
}
.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>