规则页:全局分流规则的编辑与规则组管理

新增 RulesPage.vue 和规则、规则组、ini 导入三个对话框,纯逻辑放在
shared/rules.ts。拆分规则字符串复用 vendor 的 splitRuleFields——引号和
嵌套括号里的逗号不会被当成字段分隔;目标之后的选项段原样拼回,改一条
规则不会丢掉它的 no-resolve 尾巴。规则组的缓存路径显式生成:不写 path
时内核按 url 哈希自己算一个,那个路径这里算不出来。
This commit is contained in:
2026-08-11 18:53:21 +08:00
parent 6c8a1c4e0e
commit 9e87517a4e
7 changed files with 2397 additions and 0 deletions
+285
View File
@@ -0,0 +1,285 @@
import { describe, expect, it } from 'bun:test'
import {
buildRule,
findRuleIssues,
firstMatchIndex,
inferRuleFormat,
insertBeforeMatch,
isStructuredEditable,
missingLanRules,
moveRule,
parseIniRulesets,
parseRule,
planIniImport,
ruleProviderPath,
ruleSetReferences,
setRuleProviders,
setRules,
} from './rules.ts'
import type { ConfigObject } from '../vendor/config-editor/index.ts'
describe('规则字符串的拆分与拼装', () => {
it('常见的三段规则', () => {
expect(parseRule('DOMAIN-SUFFIX,youtube.com,PROXY')).toEqual({
type: 'DOMAIN-SUFFIX',
payload: 'youtube.com',
target: 'PROXY',
extras: [],
})
})
it('MATCH 没有载荷', () => {
expect(parseRule('MATCH,PROXY')).toEqual({
type: 'MATCH',
payload: null,
target: 'PROXY',
extras: [],
})
})
it('选项尾部原样保留', () => {
const parsed = parseRule('IP-CIDR,127.0.0.0/8,DIRECT,no-resolve')
expect(parsed.extras).toEqual(['no-resolve'])
// 改目标不能丢掉选项
expect(buildRule({ ...parsed, target: 'REJECT' })).toBe('IP-CIDR,127.0.0.0/8,REJECT,no-resolve')
})
it('拆开再拼回原样', () => {
for (const rule of [
'DOMAIN-SUFFIX,youtube.com,PROXY',
'MATCH,PROXY',
'IP-CIDR,127.0.0.0/8,DIRECT,no-resolve',
'RULE-SET,ads,REJECT',
'AND,((DOMAIN-SUFFIX,a.com),(DST-PORT,443)),PROXY',
]) {
expect(buildRule(parseRule(rule))).toBe(rule)
}
})
it('逻辑规则的载荷里有逗号也不被拆开', () => {
const parsed = parseRule('AND,((DOMAIN-SUFFIX,a.com),(DST-PORT,443)),PROXY')
expect(parsed.payload).toBe('((DOMAIN-SUFFIX,a.com),(DST-PORT,443))')
expect(parsed.target).toBe('PROXY')
})
it('结构化编辑只给常见形状,逻辑规则和子规则按原文编辑', () => {
expect(isStructuredEditable('DOMAIN-SUFFIX,youtube.com,PROXY')).toBe(true)
expect(isStructuredEditable('MATCH,PROXY')).toBe(true)
expect(isStructuredEditable('AND,((DOMAIN-SUFFIX,a.com),(DST-PORT,443)),PROXY')).toBe(false)
expect(isStructuredEditable('SUB-RULE,(DOMAIN-SUFFIX,a.com),sub1')).toBe(false)
expect(isStructuredEditable('孤零零一段')).toBe(false)
})
})
describe('规则表的顺序', () => {
const rules = ['DOMAIN-SUFFIX,a.com,DIRECT', 'MATCH,PROXY', 'DOMAIN-SUFFIX,b.com,REJECT']
it('第一条 MATCH 之后的规则不可达', () => {
expect(firstMatchIndex(rules)).toBe(1)
expect(firstMatchIndex(['DOMAIN-SUFFIX,a.com,DIRECT'])).toBe(-1)
})
it('新规则插在第一条 MATCH 之前', () => {
expect(insertBeforeMatch(rules, ['GEOIP,CN,DIRECT'])).toEqual([
'DOMAIN-SUFFIX,a.com,DIRECT',
'GEOIP,CN,DIRECT',
'MATCH,PROXY',
'DOMAIN-SUFFIX,b.com,REJECT',
])
})
it('没有 MATCH 时加在末尾', () => {
expect(insertBeforeMatch(['GEOIP,CN,DIRECT'], ['MATCH,PROXY'])).toEqual([
'GEOIP,CN,DIRECT',
'MATCH,PROXY',
])
})
it('上移下移只交换相邻两条,其余不动', () => {
expect(moveRule(rules, 0, 1)).toEqual([
'MATCH,PROXY',
'DOMAIN-SUFFIX,a.com,DIRECT',
'DOMAIN-SUFFIX,b.com,REJECT',
])
expect(moveRule(rules, 0, -1)).toBe(rules)
expect(moveRule(rules, 2, 1)).toBe(rules)
})
})
describe('断引用', () => {
it('目标出站不存在和规则组不存在都标出来', () => {
const rules = [
'DOMAIN-SUFFIX,a.com,NOPE',
'RULE-SET,ads,REJECT',
'RULE-SET,gone,DIRECT',
'SUB-RULE,(DOMAIN-SUFFIX,a.com),sub1',
'MATCH,PROXY',
]
const issues = findRuleIssues(rules, new Set(['PROXY', 'REJECT', 'DIRECT']), new Set(['ads']))
expect(issues).toEqual([
{ index: 0, kind: 'target', name: 'NOPE' },
{ index: 2, kind: 'rule-set', name: 'gone' },
])
})
})
describe('内网直连预设', () => {
it('空表全缺', () => {
expect(missingLanRules([])).toHaveLength(8)
})
it('已管住的网段不重复加,哪怕写法不完全一样', () => {
const existing = ['IP-CIDR,192.168.0.0/16,DIRECT', 'MATCH,PROXY']
const missing = missingLanRules(existing)
expect(missing).toHaveLength(7)
expect(missing.some((r) => r.includes('192.168.0.0/16'))).toBe(false)
})
})
describe('删除规则组前找引用', () => {
it('列出 rules 里引用它的规则下标', () => {
const rules = ['RULE-SET,ads,REJECT', 'DOMAIN-SUFFIX,a.com,DIRECT', 'RULE-SET,ads,PROXY']
expect(ruleSetReferences(rules, 'ads')).toEqual([0, 2])
expect(ruleSetReferences(rules, 'other')).toEqual([])
})
})
describe('配置形状', () => {
it('rules 空了删掉整个键', () => {
const data: ConfigObject = { rules: ['MATCH,PROXY'] }
setRules(data, ['A,B,C'])
expect(data.rules).toEqual(['A,B,C'])
setRules(data, [])
expect('rules' in data).toBe(false)
})
it('rule-providers 空了删掉整个键', () => {
const data: ConfigObject = {}
setRuleProviders(data, { ads: { type: 'http' } })
expect(Object.keys(data['rule-providers'] as ConfigObject)).toEqual(['ads'])
setRuleProviders(data, {})
expect('rule-providers' in data).toBe(false)
})
})
describe('ini 的 ruleset= 行解析', () => {
const ini = `
[custom]
; 注释行不算
;ruleset=🎯 全球直连,[]GEOIP,LAN
ruleset=🎯 全球直连,https://example.com/LocalAreaNetwork.list
ruleset=🛑 全球拦截,https://example.com/BanAD.list?token=1
ruleset=🎯 全球直连,https://example.com/GoogleCN.list
ruleset=🍎 苹果服务,Apple.list
ruleset=🎯 全球直连,[]GEOIP,CN
ruleset=🐟 漏网之鱼,[]FINAL
custom_proxy_group=🚀 节点选择\`select\`[]AUTO
`
it('URL、本地路径、内联 GEOIP 和 FINAL 都认出来', () => {
expect(parseIniRulesets(ini)).toEqual([
{ target: '🎯 全球直连', kind: 'url', source: 'https://example.com/LocalAreaNetwork.list' },
{ target: '🛑 全球拦截', kind: 'url', source: 'https://example.com/BanAD.list?token=1' },
{ target: '🎯 全球直连', kind: 'url', source: 'https://example.com/GoogleCN.list' },
{ target: '🍎 苹果服务', kind: 'file', source: 'Apple.list' },
{ target: '🎯 全球直连', kind: 'geoip', source: 'CN' },
{ target: '🐟 漏网之鱼', kind: 'final', source: '' },
])
})
it('custom_proxy_group 和残缺行不产生条目', () => {
expect(parseIniRulesets('ruleset=\nruleset=只有目标,\ncustom_proxy_group=x`select`y')).toEqual([])
})
})
describe('ini 导入计划', () => {
const entries = parseIniRulesets(`
ruleset=🎯 全球直连,https://example.com/LocalAreaNetwork.list
ruleset=🎯 全球直连,https://example.com/GoogleCN.list
ruleset=🎯 全球直连,[]GEOIP,CN
ruleset=🐟 漏网之鱼,[]FINAL
`)
it('每行一个规则组加一条规则骨架,FINAL 落在表末尾', () => {
const plan = planIniImport(
entries,
{ '🎯 全球直连': 'DIRECT', '🐟 漏网之鱼': 'PROXY' },
new Set(),
)
expect(Object.keys(plan.providers)).toEqual(['LocalAreaNetwork', 'GoogleCN'])
expect(plan.providers.LocalAreaNetwork).toEqual({
type: 'http',
behavior: 'classical',
url: 'https://example.com/LocalAreaNetwork.list',
path: './providers/rules/LocalAreaNetwork.txt',
format: 'text',
interval: 86400,
})
expect(plan.rules).toEqual([
'RULE-SET,LocalAreaNetwork,DIRECT',
'RULE-SET,GoogleCN,DIRECT',
'GEOIP,CN,DIRECT',
])
expect(plan.finals).toEqual(['MATCH,PROXY'])
})
it('同一个来源出现多次只声明一个规则组', () => {
const plan = planIniImport(
[
{ target: '甲', kind: 'url', source: 'https://example.com/a.list' },
{ target: '乙', kind: 'url', source: 'https://example.com/a.list' },
],
{ 甲: 'DIRECT', 乙: 'REJECT' },
new Set(),
)
expect(Object.keys(plan.providers)).toEqual(['a'])
expect(plan.rules).toEqual(['RULE-SET,a,DIRECT', 'RULE-SET,a,REJECT'])
})
it('和已有规则组撞名时加序号', () => {
const plan = planIniImport(
[{ target: '甲', kind: 'url', source: 'https://example.com/ads.list' }],
{ 甲: 'REJECT' },
new Set(['ads']),
)
expect(Object.keys(plan.providers)).toEqual(['ads-2'])
expect(plan.rules).toEqual(['RULE-SET,ads-2,REJECT'])
})
it('本地路径生成 file 类型,path 就是来源本身', () => {
const plan = planIniImport(
[{ target: '甲', kind: 'file', source: 'D:\\lists\\ads.list' }],
{ 甲: 'REJECT' },
new Set(),
)
expect(plan.providers['ads']).toEqual({
type: 'file',
behavior: 'classical',
path: 'D:\\lists\\ads.list',
format: 'text',
})
})
it('没映射目标组的行跳过', () => {
const plan = planIniImport(entries, { '🎯 全球直连': 'DIRECT' }, new Set())
expect(plan.finals).toEqual([])
expect(plan.rules).toHaveLength(3)
})
})
describe('清单格式与缓存路径', () => {
it('格式按扩展名推断,拿不准回 yaml', () => {
expect(inferRuleFormat('https://example.com/a.list?token=1')).toBe('text')
expect(inferRuleFormat('https://example.com/a.txt')).toBe('text')
expect(inferRuleFormat('https://example.com/a.mrs')).toBe('mrs')
expect(inferRuleFormat('https://example.com/a.yaml')).toBe('yaml')
expect(inferRuleFormat('https://example.com/no-extension')).toBe('yaml')
})
it('缓存路径的扩展名跟着格式走', () => {
expect(ruleProviderPath('ads', 'text')).toBe('./providers/rules/ads.txt')
expect(ruleProviderPath('ads', 'mrs')).toBe('./providers/rules/ads.mrs')
expect(ruleProviderPath('ads', 'yaml')).toBe('./providers/rules/ads.yaml')
expect(ruleProviderPath('🛑 广告', 'yaml')).toBe('./providers/rules/广告.yaml')
})
})