diff --git a/shared/rules.test.ts b/shared/rules.test.ts index 4a97264..2723ae8 100644 --- a/shared/rules.test.ts +++ b/shared/rules.test.ts @@ -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('规则表的顺序', () => { diff --git a/shared/rules.ts b/shared/rules.ts index e005932..59148fd 100644 --- a/shared/rules.ts +++ b/shared/rules.ts @@ -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。 */ diff --git a/web/components/ComboBox.vue b/web/components/ComboBox.vue index 89e8100..36ef295 100644 --- a/web/components/ComboBox.vue +++ b/web/components/ComboBox.vue @@ -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>({}) +/** 本次展开后是否输入过。没输入过就给全量列表 */ +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() { diff --git a/web/views/RouterPage.vue b/web/views/RouterPage.vue index 0a54809..7205093 100644 --- a/web/views/RouterPage.vue +++ b/web/views/RouterPage.vue @@ -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(null) -/** 待确认删除的附加规则下标 */ -const removingSubRule = ref(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" /> - diff --git a/web/views/RuleForm.vue b/web/views/RuleForm.vue index 39b278b..9b68652 100644 --- a/web/views/RuleForm.vue +++ b/web/views/RuleForm.vue @@ -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(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) { @@ -218,7 +217,7 @@ function onBackdrop(event: MouseEvent) { diff --git a/web/views/RulesPage.vue b/web/views/RulesPage.vue index 97beecb..e780710 100644 --- a/web/views/RulesPage.vue +++ b/web/views/RulesPage.vue @@ -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(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)" > @@ -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" /> - -