规则编辑:建议列表全量展开,目标补齐候选,删除免确认

组合框展开先给全量、本次展开输入过才过滤——按当前值过滤的话,预填的
类型会把列表滤到只剩它自己,MATCH 这类排在后面的选项等于不存在。类型
因此可以默认留空,placeholder 当示例。

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

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

删除规则和附加规则不再二次确认——删错了有撤回,确认框只是多一步;
toast 带上被删规则的原文。
This commit is contained in:
2026-08-12 13:16:58 +08:00
parent 955fa340af
commit 48e97bd91f
6 changed files with 80 additions and 55 deletions
+8
View File
@@ -5,6 +5,7 @@ import {
firstMatchIndex,
inferRuleFormat,
insertBeforeMatch,
isRawOnlyRule,
isStructuredEditable,
missingLanRules,
moveRule,
@@ -82,6 +83,13 @@ describe('规则字符串的拆分与拼装', () => {
expect(isStructuredEditable('DOMAIN-REGEX,^ads?,b$,REJECT')).toBe(false)
expect(isStructuredEditable('孤零零一段')).toBe(false)
})
it('只认整行编辑的类型;空行和没写完的行不算,表单要能接回去', () => {
expect(isRawOnlyRule('AND,((DOMAIN-SUFFIX,a.com),(DST-PORT,443)),PROXY')).toBe(true)
expect(isRawOnlyRule('DOMAIN-REGEX,^ads?,b$,REJECT')).toBe(true)
expect(isRawOnlyRule('')).toBe(false)
expect(isRawOnlyRule('DOMAIN,youtube.com')).toBe(false)
})
})
describe('规则表的顺序', () => {
+7 -3
View File
@@ -58,10 +58,14 @@ const RAW_ONLY_TYPES = new Set([
'PROCESS-PATH-REGEX',
])
/** 这条规则是不是只能按原文整行编辑的类型(逻辑规则、载荷可含逗号的正则类)。 */
export function isRawOnlyRule(rule: string): boolean {
return RAW_ONLY_TYPES.has(parseRule(rule).type.toUpperCase())
}
export function isStructuredEditable(rule: string): boolean {
const parsed = parseRule(rule)
if (RAW_ONLY_TYPES.has(parsed.type.toUpperCase())) return false
return parsed.target !== null
if (isRawOnlyRule(rule)) return false
return parseRule(rule).target !== null
}
/** 第一条 MATCH 的下标。它之后的规则永远到不了。没有 MATCH 返回 -1。 */
+11 -2
View File
@@ -6,6 +6,9 @@
* 手填任何值都合法(规则的目标可以是候选之外的出站名)。换掉 datalist
* 的理由和 SelectBox 一样:原生建议列表的样式、动画、开合状态都管不到。
*
* 过滤只在本次展开后输入过才生效:带着已有值展开时按值过滤的话,
* 列表只剩当前值自己,其它选项等于不存在。
*
* Enter 的归属:弹层开着且有高亮项时选中它、不提交表单;弹层收起时不拦,
* 交给对话框的表单提交——填完直接回车保存是原生 input 的习惯,不能破坏。
*/
@@ -30,10 +33,12 @@ 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 (!q) return props.options
if (!typed.value || !q) return props.options
return props.options.filter((name) => name.toLowerCase().includes(q))
})
@@ -80,13 +85,17 @@ onBeforeUnmount(() => {
})
function show() {
typed.value = false
active.value = 0
place()
open.value = true
}
function onInput() {
show()
typed.value = true
active.value = 0
place()
open.value = true
}
function toggleFromChevron() {
+24 -18
View File
@@ -7,13 +7,14 @@
* 运行时动作(切换选中、测延迟)不经过它,因为它们不写文件也不需要校验。
*/
import { computed, onMounted, ref, watch } from 'vue'
import { collectInbounds } from '@shared/inbounds.ts'
import { collectInbounds, collectOutboundNames } from '@shared/inbounds.ts'
import type { Inbound } from '@shared/inbounds.ts'
import {
applySetup,
applySubRules,
chainGroupName,
groupUses,
isGenerated,
panelGroups,
proxyGroupName,
pruneGenerated,
@@ -233,11 +234,24 @@ const subRules = computed(() =>
/** RULE-SET 载荷的候选,给规则表单用 */
const ruleProviderNames = computed(() => Object.keys(ruleProvidersOf(props.data)))
/**
* 附加规则目标的建议:日常用的两个内置出站、这一路自己的专属组(「这个载荷走
* 本路代理」写的就是它),再加用户自己建的策略组和自有线路。别的入站的专属组不列。
*/
const subTargetOptions = computed(() => {
const outs = collectOutboundNames(props.data)
return [
'DIRECT',
'REJECT',
...(selected.value ? [proxyGroupName(selected.value.name)] : []),
...outs.groups.filter((name) => !isGenerated(name)),
...outs.ownProxies,
]
})
const subFormOpen = ref(false)
/** 编辑时是下标,新增时是 null */
const subEditing = ref<number | null>(null)
/** 待确认删除的附加规则下标 */
const removingSubRule = ref<number | null>(null)
function openSubAdd() {
subEditing.value = null
@@ -272,13 +286,13 @@ async function moveSubRule(index: number, delta: -1 | 1) {
await commitSubRules(moveRule(subRules.value, index, delta), '顺序已保存')
}
async function removeSubRuleConfirmed() {
const index = removingSubRule.value
removingSubRule.value = null
if (index === null) return
// 删除不设二次确认:删错了有撤回,规则原文放进提示里让人看清删掉的是哪条
async function removeSubRule(index: number) {
const rule = subRules.value[index]
if (rule === undefined) return
await commitSubRules(
subRules.value.filter((_, i) => i !== index),
'已删除这条附加规则',
`已删除附加规则 [ ${rule} ]`,
)
}
@@ -360,7 +374,7 @@ async function removeConfirmed() {
@adopt="adopt"
@sub-add="openSubAdd"
@sub-edit="openSubEdit"
@sub-remove="removingSubRule = $event"
@sub-remove="removeSubRule"
@sub-move="moveSubRule"
@sub-lan="addLanSubRules"
/>
@@ -390,20 +404,12 @@ async function removeConfirmed() {
:open="subFormOpen"
:editing="subEditing === null ? null : { index: subEditing, raw: subRules[subEditing]! }"
:provider-names="ruleProviderNames"
:target-options="subTargetOptions"
:saving="saving"
@close="subFormOpen = false"
@submit="submitSubRule"
/>
<ConfirmDialog
:open="removingSubRule !== null"
:message="
removingSubRule !== null ? `删除附加规则「${subRules[removingSubRule] ?? ''}」?` : ''
"
action="删除"
@close="removingSubRule = null"
@confirm="removeSubRuleConfirmed"
/>
</div>
</template>
+11 -12
View File
@@ -8,7 +8,7 @@
* 列出来的多得多。
*/
import { computed, ref, watch } from 'vue'
import { buildRule, isStructuredEditable, parseRule } from '@shared/rules.ts'
import { buildRule, isRawOnlyRule, isStructuredEditable, parseRule } from '@shared/rules.ts'
import ComboBox from '../components/ComboBox.vue'
import IconClose from '~icons/ri/close-line'
@@ -18,6 +18,8 @@ const props = defineProps<{
editing: { index: number; raw: string } | null
/** RULE-SET 载荷的候选:已声明的规则组 */
providerNames: string[]
/** 目标的建议。只是建议,手填任何出站名都合法 */
targetOptions: string[]
saving: boolean
}>()
@@ -67,16 +69,10 @@ const RULE_TYPES = [
'MATCH',
]
/**
* 目标的建议只给内置出站里日常用的两个。策略组名允许手填——
* 全部列出来的话,每路入站自动生成的三个专属组会把列表灌成一面墙。
*/
const TARGET_SUGGESTIONS = ['DIRECT', 'REJECT']
const dialog = ref<HTMLDialogElement | null>(null)
const rawMode = ref(false)
const raw = ref('')
const type = ref('DOMAIN-SUFFIX')
const type = ref('')
const payload = ref('')
const target = ref('')
const extras = ref('')
@@ -115,7 +111,8 @@ watch(
} else {
rawMode.value = false
const parsed = parseRule(source)
type.value = parsed.type || 'DOMAIN-SUFFIX'
// 新增时留空:预填的类型会把下拉的建议过滤到只剩它自己
type.value = parsed.type
payload.value = parsed.payload ?? ''
target.value = parsed.target ?? ''
extras.value = parsed.extras.join(',')
@@ -131,7 +128,9 @@ watch(
function toggleMode() {
if (rawMode.value) {
const line = raw.value.trim()
if (!isStructuredEditable(line)) return
// 只有逻辑规则这类整行编辑的类型留在原文模式;空行、没写完的行都带回表单
// 接着填——按 isStructuredEditable 拦的话,新增时的空原文会让人永远切不回去
if (isRawOnlyRule(line)) return
const parsed = parseRule(line)
type.value = parsed.type
payload.value = parsed.payload ?? ''
@@ -189,7 +188,7 @@ function onBackdrop(event: MouseEvent) {
<label class="row">
<span class="k">类型</span>
<span class="v">
<ComboBox v-model="type" mono :options="RULE_TYPES" />
<ComboBox v-model="type" mono :options="RULE_TYPES" placeholder="DOMAIN-SUFFIX" />
</span>
</label>
@@ -218,7 +217,7 @@ function onBackdrop(event: MouseEvent) {
<label class="row">
<span class="k">目标</span>
<span class="v">
<ComboBox v-model="target" mono :options="TARGET_SUGGESTIONS" placeholder="DIRECT" />
<ComboBox v-model="target" mono :options="targetOptions" placeholder="DIRECT" />
</span>
</label>
+19 -20
View File
@@ -10,6 +10,7 @@
*/
import { computed, onMounted, ref, watch } from 'vue'
import { collectOutboundNames } from '@shared/inbounds.ts'
import { isGenerated } from '@shared/inbound-groups.ts'
import {
findRuleIssues,
firstMatchIndex,
@@ -76,6 +77,17 @@ const outboundNames = computed(() => [
...outbounds.value.ownProxies,
])
/**
* 目标的建议:日常用的两个内置出站、用户自己建的策略组和自有线路。
* 生成的专属组不列——每路入站三个,会把列表灌成一面墙,真要指定就手填。
*/
const targetOptions = computed(() => [
'DIRECT',
'REJECT',
...outbounds.value.groups.filter((name) => !isGenerated(name)),
...outbounds.value.ownProxies,
])
/** 第一条 MATCH 的下标。它之后的行标成不可达。 */
const matchAt = computed(() => firstMatchIndex(rules.value))
@@ -161,17 +173,11 @@ async function move(index: number, delta: -1 | 1) {
await commit(withRules(moveRule(rules.value, index, delta)), '顺序已保存')
}
/** 待确认删除的规则下标。非 null 时确认对话框打开着。 */
const removingRule = ref<number | null>(null)
async function removeRuleConfirmed() {
const index = removingRule.value
removingRule.value = null
if (index === null || saving.value) return
await commit(
withRules(rules.value.filter((_, i) => i !== index)),
'已删除这条规则',
)
// 删除不设二次确认:删错了有撤回,规则原文放进提示里让人看清删掉的是哪条
async function removeRule(index: number) {
const rule = rules.value[index]
if (rule === undefined || saving.value) return
await commit(withRules(rules.value.filter((_, i) => i !== index)), `已删除规则 [ ${rule} ]`)
}
async function applyLanPreset() {
@@ -429,7 +435,7 @@ async function updateNow(name: string) {
class="icon-btn danger"
title="删除"
:disabled="saving"
@click="removingRule = row.index"
@click="removeRule(row.index)"
>
<IconDelete />
</button>
@@ -493,6 +499,7 @@ async function updateNow(name: string) {
:open="ruleFormOpen"
:editing="ruleEditing === null ? null : { index: ruleEditing, raw: rules[ruleEditing]! }"
:provider-names="providerNames"
:target-options="targetOptions"
:saving="saving"
@close="ruleFormOpen = false"
@submit="submitRule"
@@ -515,14 +522,6 @@ async function updateNow(name: string) {
@submit="submitImport"
/>
<ConfirmDialog
:open="removingRule !== null"
:message="removingRule === null ? '' : `删除这条规则?${rules[removingRule]}`"
action="删除"
@close="removingRule = null"
@confirm="removeRuleConfirmed"
/>
<ConfirmDialog
:open="removingProvider !== null"
:message="removingProvider === null ? '' : `删除规则组「${removingProvider}」?`"