路由详情加附加规则:每路入站自己的分流表
用内核的 sub-rules 机制:listener 写 rule: 指向顶层 sub-rules 里的表 (listener/inbound/base.go:96,tunnel.go:713-721)。两个硬约束决定了 形态:proxy 优先级更高且完全短路规则匹配(tunnel.go:318-325),有附加 规则时 listener 只能写 rule;子规则表没命中时兜底是 DIRECT 而不是这一 路的出站(tunnel.go match 循环末尾),表底那条 MATCH,专属组 是承重的, 由 Patchbay 自动维护、界面上单独画不参与编辑。不变式:附加规则空 ⇔ proxy 直连专属组,非空 ⇔ rule 形态。 三处既有逻辑教会认 rule 形态:applySetup 不再无条件写 proxy(会把附加 规则静默短路掉);referencesTo 扫 sub-rules 里的规则——表底 MATCH 是 rule 形态下专属组活着的唯一理由,不数它就会被当孤儿清掉;pruneGenerated 清理没有 listener 引用的生成表。panelGroups 和 syncGenerated 的「是否 由 Patchbay 接线」收敛成一个 managedWiring。 listener 的 rule 指向不存在的表时内核静默退回全局规则(getRules 的 else 分支),-t 和重载都不拦,自检里补上这条(vendor 本地改动,记入 ORIGIN.md)。界面:详情列新增附加规则区块,逐条增删改和排序复用 RuleForm,带内网直连预设;生成形状过了 mihomo -t 实测。
This commit is contained in:
@@ -1,10 +1,14 @@
|
|||||||
import { describe, expect, it } from 'bun:test'
|
import { describe, expect, it } from 'bun:test'
|
||||||
import {
|
import {
|
||||||
applySetup,
|
applySetup,
|
||||||
|
applySubRules,
|
||||||
chainGroupName,
|
chainGroupName,
|
||||||
chainProviderName,
|
chainProviderName,
|
||||||
|
managedWiring,
|
||||||
panelGroups,
|
panelGroups,
|
||||||
|
readSubRules,
|
||||||
sharedPeers,
|
sharedPeers,
|
||||||
|
subRuleName,
|
||||||
syncGenerated,
|
syncGenerated,
|
||||||
isGenerated,
|
isGenerated,
|
||||||
linesMatching,
|
linesMatching,
|
||||||
@@ -356,3 +360,70 @@ describe('清理', () => {
|
|||||||
expect((pruneGenerated(orphaned)['proxy-groups'] as ConfigObject[]).length).toBe(3)
|
expect((pruneGenerated(orphaned)['proxy-groups'] as ConfigObject[]).length).toBe(3)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('附加规则', () => {
|
||||||
|
const managed = () => applySetup(base(), 'test1', plain)
|
||||||
|
const listenerOf = (data: ConfigObject) => (data.listeners as ConfigObject[])[0]!
|
||||||
|
|
||||||
|
it('写入后 listener 换成 rule 形态,表底自动补兜底 MATCH', () => {
|
||||||
|
const next = applySubRules(managed(), 'test1', ['DOMAIN-SUFFIX,lan,DIRECT'])
|
||||||
|
|
||||||
|
const listener = listenerOf(next)
|
||||||
|
expect(listener.rule).toBe(subRuleName('test1'))
|
||||||
|
expect(listener.proxy).toBeUndefined()
|
||||||
|
expect((next['sub-rules'] as ConfigObject)[subRuleName('test1')]).toEqual([
|
||||||
|
'DOMAIN-SUFFIX,lan,DIRECT',
|
||||||
|
'MATCH,patchbay:test1',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('读出来不含兜底 MATCH,往返一致', () => {
|
||||||
|
const rules = ['DOMAIN-SUFFIX,lan,DIRECT', 'GEOIP,CN,DIRECT']
|
||||||
|
const next = applySubRules(managed(), 'test1', rules)
|
||||||
|
expect(readSubRules(next, 'test1')).toEqual(rules)
|
||||||
|
expect(readSubRules(managed(), 'test1')).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('清空后恢复 proxy 直连形态,sub-rules 键整个消失', () => {
|
||||||
|
const withRules = applySubRules(managed(), 'test1', ['GEOIP,CN,DIRECT'])
|
||||||
|
const cleared = applySubRules(withRules, 'test1', [])
|
||||||
|
|
||||||
|
const listener = listenerOf(cleared)
|
||||||
|
expect(listener.proxy).toBe(proxyGroupName('test1'))
|
||||||
|
expect(listener.rule).toBeUndefined()
|
||||||
|
expect(cleared['sub-rules']).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rule 形态仍被认成 Patchbay 接线,重建设置不会把 proxy 加回来', () => {
|
||||||
|
// proxy 会短路规则匹配(tunnel.go:318-325),加回来附加规则就静默失效了
|
||||||
|
const withRules = applySubRules(managed(), 'test1', ['GEOIP,CN,DIRECT'])
|
||||||
|
const inbound = collectInbounds(withRules).find((i) => i.name === 'test1')!
|
||||||
|
expect(managedWiring(withRules, inbound)).toBe(true)
|
||||||
|
|
||||||
|
const rebuilt = syncGenerated(withRules, collectInbounds(withRules))
|
||||||
|
const listener = listenerOf(rebuilt)
|
||||||
|
expect(listener.rule).toBe(subRuleName('test1'))
|
||||||
|
expect(listener.proxy).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rule 形态下专属组靠表底 MATCH 活着,不会被当孤儿清掉', () => {
|
||||||
|
const withRules = applySubRules(managed(), 'test1', ['GEOIP,CN,DIRECT'])
|
||||||
|
const pruned = pruneGenerated(withRules)
|
||||||
|
const groupNames = (pruned['proxy-groups'] as ConfigObject[]).map((g) => g.name)
|
||||||
|
expect(groupNames).toContain(proxyGroupName('test1'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('删掉入站后,附加规则表和组连锁消失', () => {
|
||||||
|
const withRules = applySubRules(managed(), 'test1', ['GEOIP,CN,DIRECT'])
|
||||||
|
const removed: ConfigObject = { ...withRules, listeners: [] }
|
||||||
|
|
||||||
|
const cleaned = pruneGenerated(removed)
|
||||||
|
expect(cleaned['sub-rules']).toBeUndefined()
|
||||||
|
expect(cleaned['proxy-groups']).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('用户自己的 sub-rules 表不被清理', () => {
|
||||||
|
const data: ConfigObject = { ...base(), 'sub-rules': { mine: ['MATCH,DIRECT'] } }
|
||||||
|
expect((pruneGenerated(data)['sub-rules'] as ConfigObject).mine).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
+129
-10
@@ -55,6 +55,11 @@ export function chainGroupName(inbound: string): string {
|
|||||||
return `${GENERATED_PREFIX}${inbound} 链式`
|
return `${GENERATED_PREFIX}${inbound} 链式`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 这一路入站的附加规则表在顶层 `sub-rules:` 里的键名。listener 的 `rule:` 指向它。 */
|
||||||
|
export function subRuleName(inbound: string): string {
|
||||||
|
return `${GENERATED_PREFIX}${inbound} 附加`
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 一个出站在界面上叫什么。
|
* 一个出站在界面上叫什么。
|
||||||
*
|
*
|
||||||
@@ -69,6 +74,7 @@ export function displayName(name: string): string {
|
|||||||
[' AUTO', 'AUTO'],
|
[' AUTO', 'AUTO'],
|
||||||
[' FALLBACK', 'FALLBACK'],
|
[' FALLBACK', 'FALLBACK'],
|
||||||
[' 链式', '链式代理'],
|
[' 链式', '链式代理'],
|
||||||
|
[' 附加', '附加规则'],
|
||||||
] as const) {
|
] as const) {
|
||||||
if (rest.endsWith(suffix)) return label
|
if (rest.endsWith(suffix)) return label
|
||||||
}
|
}
|
||||||
@@ -219,6 +225,85 @@ export function readSetup(data: ConfigObject, inbound: string): OutboundSetup |
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 附加规则 ─────────────────────────────────────
|
||||||
|
*
|
||||||
|
* 挂在某一路入站上的分流规则(内核的 sub-rules 机制):listener 写 `rule: <表名>`,
|
||||||
|
* 该入站的流量改查顶层 `sub-rules:` 里同名的表(tunnel.go:713-721)。两个硬约束:
|
||||||
|
* - `proxy:` 的优先级更高且完全短路规则匹配(tunnel.go:318-325),所以有附加规则时
|
||||||
|
* listener 只能写 rule 不写 proxy;
|
||||||
|
* - 子规则表没有命中时兜底是 DIRECT 而不是这一路的出站(tunnel.go match 循环末尾),
|
||||||
|
* 所以表底那条 `MATCH,专属组` 是承重的,Patchbay 始终自动补上,不交给用户维护。
|
||||||
|
* 于是不变式是:附加规则为空 ⇔ listener 用 proxy 直连专属组;非空 ⇔ listener 用 rule,
|
||||||
|
* 表底 MATCH 指回专属组。两种形态对「没被规则分走的流量」效果完全相同。
|
||||||
|
*/
|
||||||
|
|
||||||
|
function subRulesOf(data: ConfigObject): ConfigObject {
|
||||||
|
return isPlainObject(data['sub-rules']) ? data['sub-rules'] : {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 这一路入站有没有 Patchbay 管理的附加规则表。 */
|
||||||
|
export function hasSubRules(data: ConfigObject, inbound: string): boolean {
|
||||||
|
return subRuleName(inbound) in subRulesOf(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 表底的兜底规则。它把没被分走的流量送回这一路自己的出站。 */
|
||||||
|
function terminalRule(inbound: string): string {
|
||||||
|
return `MATCH,${proxyGroupName(inbound)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读出这一路的附加规则,不含表底的兜底 MATCH——那条是生成物,界面上单独画、
|
||||||
|
* 不参与编辑。手改过、兜底不在末尾的表原样全给,编辑保存时会重新规整。
|
||||||
|
*/
|
||||||
|
export function readSubRules(data: ConfigObject, inbound: string): string[] {
|
||||||
|
const entry = subRulesOf(data)[subRuleName(inbound)]
|
||||||
|
const rules = Array.isArray(entry)
|
||||||
|
? entry.filter((rule): rule is string => typeof rule === 'string')
|
||||||
|
: []
|
||||||
|
return rules.at(-1) === terminalRule(inbound) ? rules.slice(0, -1) : rules
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把这一路的附加规则写回配置。
|
||||||
|
*
|
||||||
|
* 空表恢复 proxy 直连形态(删表、删 rule、接回专属组);非空补上兜底 MATCH、
|
||||||
|
* listener 换成 rule。listener 上用户手写的其它字段原样保留。
|
||||||
|
*/
|
||||||
|
export function applySubRules(data: ConfigObject, inbound: string, rules: string[]): ConfigObject {
|
||||||
|
const next = { ...data }
|
||||||
|
const name = subRuleName(inbound)
|
||||||
|
const tables = { ...subRulesOf(next) }
|
||||||
|
|
||||||
|
if (rules.length === 0) delete tables[name]
|
||||||
|
else tables[name] = [...rules, terminalRule(inbound)]
|
||||||
|
|
||||||
|
if (Object.keys(tables).length === 0) delete next['sub-rules']
|
||||||
|
else next['sub-rules'] = tables
|
||||||
|
|
||||||
|
const listeners = (Array.isArray(next.listeners) ? next.listeners : []).map((entry) => {
|
||||||
|
if (!isPlainObject(entry) || entry.name !== inbound) return entry
|
||||||
|
if (rules.length === 0) {
|
||||||
|
const { rule: _rule, ...rest } = entry
|
||||||
|
return { ...rest, proxy: proxyGroupName(inbound) }
|
||||||
|
}
|
||||||
|
const { proxy: _proxy, ...rest } = entry
|
||||||
|
return { ...rest, rule: name }
|
||||||
|
})
|
||||||
|
if (listeners.length > 0) next.listeners = listeners
|
||||||
|
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 这一路入站是否由 Patchbay 接线。两种形态:proxy 直指专属组,
|
||||||
|
* 或经附加规则表兜底到专属组。panelGroups 和 syncGenerated 共用这一个答案。
|
||||||
|
*/
|
||||||
|
export function managedWiring(data: ConfigObject, inbound: Inbound): boolean {
|
||||||
|
const mode = inbound.outbound
|
||||||
|
if (mode.kind === 'named' && mode.name === proxyGroupName(inbound.name)) return true
|
||||||
|
return inbound.ruleSet === subRuleName(inbound.name) && hasSubRules(data, inbound.name)
|
||||||
|
}
|
||||||
|
|
||||||
function makeGroup(
|
function makeGroup(
|
||||||
name: string,
|
name: string,
|
||||||
type: string,
|
type: string,
|
||||||
@@ -332,11 +417,17 @@ export function applySetup(data: ConfigObject, inbound: string, setup: OutboundS
|
|||||||
if (Object.keys(providers).length === 0) delete next['proxy-providers']
|
if (Object.keys(providers).length === 0) delete next['proxy-providers']
|
||||||
else next['proxy-providers'] = providers
|
else next['proxy-providers'] = providers
|
||||||
|
|
||||||
const listeners = (Array.isArray(next.listeners) ? next.listeners : []).map((entry) =>
|
// 有附加规则时 listener 走 rule 形态,proxy 会短路规则匹配、不能写;
|
||||||
isPlainObject(entry) && entry.name === inbound
|
// 表底的兜底 MATCH 已经指向专属组,接线效果一样
|
||||||
? { ...entry, proxy: proxyGroupName(inbound) }
|
const viaSubRules = hasSubRules(next, inbound)
|
||||||
: entry,
|
const listeners = (Array.isArray(next.listeners) ? next.listeners : []).map((entry) => {
|
||||||
)
|
if (!isPlainObject(entry) || entry.name !== inbound) return entry
|
||||||
|
if (viaSubRules) {
|
||||||
|
const { proxy: _proxy, ...rest } = entry
|
||||||
|
return { ...rest, rule: subRuleName(inbound) }
|
||||||
|
}
|
||||||
|
return { ...entry, proxy: proxyGroupName(inbound) }
|
||||||
|
})
|
||||||
if (listeners.length > 0) next.listeners = listeners
|
if (listeners.length > 0) next.listeners = listeners
|
||||||
|
|
||||||
return next
|
return next
|
||||||
@@ -365,6 +456,17 @@ function referencesTo(data: ConfigObject, name: string): number {
|
|||||||
if (typeof rule === 'string' && rule.split(',').some((part) => part.trim() === name)) count += 1
|
if (typeof rule === 'string' && rule.split(',').some((part) => part.trim() === name)) count += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 附加规则表里的规则同样引用出站——表底的 MATCH,专属组 正是 rule 形态下
|
||||||
|
// 专属组活着的唯一理由,不数它专属组会被当孤儿清掉
|
||||||
|
for (const table of Object.values(subRulesOf(data))) {
|
||||||
|
if (!Array.isArray(table)) continue
|
||||||
|
for (const rule of table) {
|
||||||
|
if (typeof rule === 'string' && rule.split(',').some((part) => part.trim() === name)) {
|
||||||
|
count += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,7 +494,21 @@ export function pruneGenerated(data: ConfigObject): ConfigObject {
|
|||||||
)
|
)
|
||||||
const providersChanged = keptProviders.length !== Object.keys(providers).length
|
const providersChanged = keptProviders.length !== Object.keys(providers).length
|
||||||
|
|
||||||
if (keptGroups.length === groups.length && !providersChanged) return data
|
// 生成的附加规则表活着的唯一理由是有 listener 的 rule 指着它。
|
||||||
|
// 入站被删后先清表,表里的 MATCH 引用消失,下一轮才轮到那几个组
|
||||||
|
const listenerRules = new Set(
|
||||||
|
(Array.isArray(data.listeners) ? data.listeners : [])
|
||||||
|
.filter(isPlainObject)
|
||||||
|
.map((entry) => entry.rule)
|
||||||
|
.filter((rule): rule is string => typeof rule === 'string'),
|
||||||
|
)
|
||||||
|
const tables = subRulesOf(data)
|
||||||
|
const keptTables = Object.entries(tables).filter(
|
||||||
|
([name]) => !isGenerated(name) || listenerRules.has(name),
|
||||||
|
)
|
||||||
|
const tablesChanged = keptTables.length !== Object.keys(tables).length
|
||||||
|
|
||||||
|
if (keptGroups.length === groups.length && !providersChanged && !tablesChanged) return data
|
||||||
|
|
||||||
const next = { ...data }
|
const next = { ...data }
|
||||||
if (keptGroups.length === 0) delete next['proxy-groups']
|
if (keptGroups.length === 0) delete next['proxy-groups']
|
||||||
@@ -403,6 +519,11 @@ export function pruneGenerated(data: ConfigObject): ConfigObject {
|
|||||||
else next['proxy-providers'] = Object.fromEntries(keptProviders)
|
else next['proxy-providers'] = Object.fromEntries(keptProviders)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tablesChanged) {
|
||||||
|
if (keptTables.length === 0) delete next['sub-rules']
|
||||||
|
else next['sub-rules'] = Object.fromEntries(keptTables)
|
||||||
|
}
|
||||||
|
|
||||||
return pruneGenerated(next)
|
return pruneGenerated(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,8 +538,7 @@ export function syncGenerated(data: ConfigObject, inbounds: Inbound[]): ConfigOb
|
|||||||
let next = data
|
let next = data
|
||||||
for (const inbound of inbounds) {
|
for (const inbound of inbounds) {
|
||||||
const setup = readSetup(next, inbound.name)
|
const setup = readSetup(next, inbound.name)
|
||||||
const mode = inbound.outbound
|
if (!setup || !managedWiring(next, inbound)) continue
|
||||||
if (!setup || mode.kind !== 'named' || mode.name !== proxyGroupName(inbound.name)) continue
|
|
||||||
next = applySetup(next, inbound.name, setup)
|
next = applySetup(next, inbound.name, setup)
|
||||||
}
|
}
|
||||||
return next
|
return next
|
||||||
@@ -449,8 +569,7 @@ export function panelGroups(
|
|||||||
runtime: Record<string, RuntimeNode>,
|
runtime: Record<string, RuntimeNode>,
|
||||||
inbound: Inbound,
|
inbound: Inbound,
|
||||||
): { groups: PanelGroup[]; shared: boolean } {
|
): { groups: PanelGroup[]; shared: boolean } {
|
||||||
const mode = inbound.outbound
|
const managed = managedWiring(data, inbound)
|
||||||
const managed = mode.kind === 'named' && mode.name === proxyGroupName(inbound.name)
|
|
||||||
|
|
||||||
if (managed) {
|
if (managed) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
Vendored
+1
@@ -12,6 +12,7 @@
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `index.test.ts` | 测试框架的导入从 `vitest` 换成 `bun:test` | 本项目用 Bun 自带的测试运行器,不引入 vitest。两者的 `describe` / `it` / `expect` 接口一致,用例本身一行没动 |
|
| `index.test.ts` | 测试框架的导入从 `vitest` 换成 `bun:test` | 本项目用 Bun 自带的测试运行器,不引入 vitest。两者的 `describe` / `it` / `expect` 接口一致,用例本身一行没动 |
|
||||||
| `index.ts` | `stringifyTouched` 结尾的 `String(doc)` 换成 `doc.toString({ flowCollectionPadding: false, lineWidth: 0 })` | 上游用默认输出参数,会把 `[a, b]` 改写成 `[ a, b ]`,并且在超过 80 列时折行——目标机器的订阅地址就够长。这两样都会让没被改动的段落出现在 diff 里 |
|
| `index.ts` | `stringifyTouched` 结尾的 `String(doc)` 换成 `doc.toString({ flowCollectionPadding: false, lineWidth: 0 })` | 上游用默认输出参数,会把 `[a, b]` 改写成 `[ a, b ]`,并且在超过 80 列时折行——目标机器的订阅地址就够长。这两样都会让没被改动的段落出现在 diff 里 |
|
||||||
|
| `index.ts` | `validateDocument` 末尾新增:listener 的 `rule:` 指向不存在的 sub-rules 表时报 `missing-reference` | 内核对这种引用静默退回全局规则(tunnel.go getRules 的 else 分支),`mihomo -t` 和重载都不会拦,只有这层自检能拦住 |
|
||||||
|
|
||||||
## 配套的本项目代码
|
## 配套的本项目代码
|
||||||
|
|
||||||
|
|||||||
Vendored
+19
@@ -1056,6 +1056,25 @@ export function validateDocument(data: ConfigObject): ConfigDiagnostic[] {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Patchbay local addition (see ORIGIN.md): a listener whose `rule:` names a
|
||||||
|
// missing sub-rules table silently falls back to the global rules (mihomo
|
||||||
|
// tunnel.go getRules), so neither `mihomo -t` nor a reload will ever flag it.
|
||||||
|
if (Array.isArray(data.listeners)) {
|
||||||
|
data.listeners.forEach((entry, index) => {
|
||||||
|
if (!isObject(entry)) return
|
||||||
|
const rule = entry.rule
|
||||||
|
if (typeof rule !== 'string' || !rule) return
|
||||||
|
if (!isObject(subRules) || !(rule in subRules)) {
|
||||||
|
diagnostics.push({
|
||||||
|
path: ['listeners', index, 'rule'],
|
||||||
|
code: 'missing-reference',
|
||||||
|
message: `Unknown sub-rule: ${rule}`,
|
||||||
|
severity: 'error',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
return diagnostics
|
return diagnostics
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+193
-1
@@ -18,7 +18,9 @@ import {
|
|||||||
fallbackGroupName,
|
fallbackGroupName,
|
||||||
linesMatching,
|
linesMatching,
|
||||||
proxyGroupName,
|
proxyGroupName,
|
||||||
|
subRuleName,
|
||||||
} from '@shared/inbound-groups.ts'
|
} from '@shared/inbound-groups.ts'
|
||||||
|
import { missingLanRules } from '@shared/rules.ts'
|
||||||
import type { GroupSpec, OutboundSetup, PanelGroup } from '@shared/inbound-groups.ts'
|
import type { GroupSpec, OutboundSetup, PanelGroup } from '@shared/inbound-groups.ts'
|
||||||
import { resolveChain } from '@shared/outbound-chain.ts'
|
import { resolveChain } from '@shared/outbound-chain.ts'
|
||||||
import { latestDelay } from '../api'
|
import { latestDelay } from '../api'
|
||||||
@@ -32,6 +34,9 @@ import IconEdit from '~icons/ri/edit-line'
|
|||||||
import IconDelete from '~icons/ri/delete-bin-line'
|
import IconDelete from '~icons/ri/delete-bin-line'
|
||||||
import IconRight from '~icons/ri/arrow-right-s-line'
|
import IconRight from '~icons/ri/arrow-right-s-line'
|
||||||
import IconOffline from '~icons/ri/cloud-off-line'
|
import IconOffline from '~icons/ri/cloud-off-line'
|
||||||
|
import IconAdd from '~icons/ri/add-line'
|
||||||
|
import IconUp from '~icons/ri/arrow-up-line'
|
||||||
|
import IconDown from '~icons/ri/arrow-down-line'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
inbound: Inbound
|
inbound: Inbound
|
||||||
@@ -45,6 +50,8 @@ const props = defineProps<{
|
|||||||
subscriptions: string[]
|
subscriptions: string[]
|
||||||
/** 当前的来源和筛选,配置表单的初值 */
|
/** 当前的来源和筛选,配置表单的初值 */
|
||||||
setup: OutboundSetup | null
|
setup: OutboundSetup | null
|
||||||
|
/** 这一路的附加规则(不含表底的兜底 MATCH,那条由 Patchbay 自动维护) */
|
||||||
|
subRules: string[]
|
||||||
/** 正在测延迟的线路 */
|
/** 正在测延迟的线路 */
|
||||||
testing: string[]
|
testing: string[]
|
||||||
/** 配置正在保存 */
|
/** 配置正在保存 */
|
||||||
@@ -61,8 +68,22 @@ const emit = defineEmits<{
|
|||||||
save: [setup: OutboundSetup, picks: { proxy: string | null; chain: string | null }]
|
save: [setup: OutboundSetup, picks: { proxy: string | null; chain: string | null }]
|
||||||
/** 把这一路改成专属代理组模式 */
|
/** 把这一路改成专属代理组模式 */
|
||||||
adopt: []
|
adopt: []
|
||||||
|
/** 附加规则的增删改和排序。规则文本的编辑走父组件的 RuleForm */
|
||||||
|
'sub-add': []
|
||||||
|
'sub-edit': [index: number]
|
||||||
|
'sub-remove': [index: number]
|
||||||
|
'sub-move': [index: number, delta: -1 | 1]
|
||||||
|
'sub-lan': []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
/** 手写的 rule 字段(不是 Patchbay 的附加规则表)才在事实行里原样展示 */
|
||||||
|
const foreignRuleSet = computed(
|
||||||
|
() => props.inbound.ruleSet !== null && props.inbound.ruleSet !== subRuleName(props.inbound.name),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 内网直连还缺几条。全齐了按钮就不出现 */
|
||||||
|
const lanMissing = computed(() => missingLanRules(props.subRules).length)
|
||||||
|
|
||||||
/** 默认入站由配置顶层的端口字段声明,没有 proxy 字段可写,也不能删。 */
|
/** 默认入站由配置顶层的端口字段声明,没有 proxy 字段可写,也不能删。 */
|
||||||
const locked = computed(() => props.inbound.isDefault)
|
const locked = computed(() => props.inbound.isDefault)
|
||||||
|
|
||||||
@@ -434,7 +455,8 @@ const complete = computed(
|
|||||||
<dd class="mono">{{ inbound.type }}</dd>
|
<dd class="mono">{{ inbound.type }}</dd>
|
||||||
<dt>监听</dt>
|
<dt>监听</dt>
|
||||||
<dd class="mono">{{ inbound.listen ?? '0.0.0.0' }}</dd>
|
<dd class="mono">{{ inbound.listen ?? '0.0.0.0' }}</dd>
|
||||||
<template v-if="inbound.ruleSet">
|
<!-- Patchbay 自己的附加规则表有专门的编辑区块,这里只展示手写的 -->
|
||||||
|
<template v-if="foreignRuleSet">
|
||||||
<dt>附加规则</dt>
|
<dt>附加规则</dt>
|
||||||
<dd class="mono">{{ inbound.ruleSet }}</dd>
|
<dd class="mono">{{ inbound.ruleSet }}</dd>
|
||||||
</template>
|
</template>
|
||||||
@@ -671,6 +693,71 @@ const complete = computed(
|
|||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- 附加规则:只对从这一路进来的流量生效的分流。表底的兜底 MATCH 是生成物,
|
||||||
|
把没被分走的流量送回这一路自己的出站,单独画、不参与编辑 -->
|
||||||
|
<section v-if="setup !== null" class="block">
|
||||||
|
<h3>
|
||||||
|
附加规则
|
||||||
|
<span class="h3-acts">
|
||||||
|
<button
|
||||||
|
v-if="lanMissing > 0"
|
||||||
|
type="button"
|
||||||
|
class="mini"
|
||||||
|
:disabled="saving"
|
||||||
|
:title="`加入 ${lanMissing} 条内网直连规则:回环、私网和链路本地地址不进代理`"
|
||||||
|
@click="emit('sub-lan')"
|
||||||
|
>
|
||||||
|
内网直连
|
||||||
|
</button>
|
||||||
|
<button type="button" class="mini" :disabled="saving" @click="emit('sub-add')">
|
||||||
|
<IconAdd /> 添加
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<ul class="srules">
|
||||||
|
<li v-for="(rule, i) in subRules" :key="i" class="srule">
|
||||||
|
<span class="mono s-text" :title="rule">{{ rule }}</span>
|
||||||
|
<span class="s-acts">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="上移"
|
||||||
|
:disabled="saving || i === 0"
|
||||||
|
@click="emit('sub-move', i, -1)"
|
||||||
|
>
|
||||||
|
<IconUp />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="下移"
|
||||||
|
:disabled="saving || i === subRules.length - 1"
|
||||||
|
@click="emit('sub-move', i, 1)"
|
||||||
|
>
|
||||||
|
<IconDown />
|
||||||
|
</button>
|
||||||
|
<button type="button" title="编辑" :disabled="saving" @click="emit('sub-edit', i)">
|
||||||
|
<IconEdit />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="s-danger"
|
||||||
|
title="删除"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="emit('sub-remove', i)"
|
||||||
|
>
|
||||||
|
<IconDelete />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
class="srule is-final"
|
||||||
|
title="兜底:没被上面的规则分走的流量走这一路自己的出站。这一条由 Patchbay 自动维护"
|
||||||
|
>
|
||||||
|
<span class="mono s-text">MATCH,{{ proxyGroupName(inbound.name) }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
@@ -798,6 +885,111 @@ const complete = computed(
|
|||||||
border-top: 1px solid var(--c-border);
|
border-top: 1px solid var(--c-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 附加规则 ────────────────────────────────── */
|
||||||
|
.h3-acts {
|
||||||
|
margin-left: auto;
|
||||||
|
display: inline-flex;
|
||||||
|
gap: var(--s-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s-1);
|
||||||
|
padding: 2px var(--s-2);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: none;
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
font-size: var(--t-xs);
|
||||||
|
font-weight: 400;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.mini:hover:not(:disabled) {
|
||||||
|
background: var(--c-surface-sunken);
|
||||||
|
color: var(--c-text);
|
||||||
|
}
|
||||||
|
.mini:disabled {
|
||||||
|
color: var(--c-text-faint);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.mini svg {
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.srules {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.srule {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s-2);
|
||||||
|
padding: var(--s-1) var(--s-2);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
}
|
||||||
|
.srule:hover {
|
||||||
|
background: var(--c-surface-sunken);
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: var(--t-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 兜底那行是生成物:淡色示意,不给操作 */
|
||||||
|
.is-final .s-text {
|
||||||
|
color: var(--c-text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 操作按钮悬停到行上才现身,规则文本是主角 */
|
||||||
|
.s-acts {
|
||||||
|
flex: none;
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 1px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
.srule:hover .s-acts,
|
||||||
|
.s-acts:focus-within {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-acts button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: none;
|
||||||
|
color: var(--c-text-faint);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.s-acts button:hover:not(:disabled) {
|
||||||
|
background: var(--c-surface);
|
||||||
|
color: var(--c-text);
|
||||||
|
}
|
||||||
|
.s-acts button:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.s-acts .s-danger:hover:not(:disabled) {
|
||||||
|
color: var(--c-danger);
|
||||||
|
}
|
||||||
|
.s-acts svg {
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -11,16 +11,19 @@ import { collectInbounds } from '@shared/inbounds.ts'
|
|||||||
import type { Inbound } from '@shared/inbounds.ts'
|
import type { Inbound } from '@shared/inbounds.ts'
|
||||||
import {
|
import {
|
||||||
applySetup,
|
applySetup,
|
||||||
|
applySubRules,
|
||||||
chainGroupName,
|
chainGroupName,
|
||||||
groupUses,
|
groupUses,
|
||||||
panelGroups,
|
panelGroups,
|
||||||
proxyGroupName,
|
proxyGroupName,
|
||||||
pruneGenerated,
|
pruneGenerated,
|
||||||
|
readSubRules,
|
||||||
sharedPeers,
|
sharedPeers,
|
||||||
syncGenerated,
|
syncGenerated,
|
||||||
readSetup,
|
readSetup,
|
||||||
userProviders,
|
userProviders,
|
||||||
} from '@shared/inbound-groups.ts'
|
} from '@shared/inbound-groups.ts'
|
||||||
|
import { missingLanRules, moveRule, ruleProvidersOf } from '@shared/rules.ts'
|
||||||
import type { OutboundSetup } from '@shared/inbound-groups.ts'
|
import type { OutboundSetup } from '@shared/inbound-groups.ts'
|
||||||
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
|
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
|
||||||
import { ApiError, api } from '../api'
|
import { ApiError, api } from '../api'
|
||||||
@@ -33,6 +36,7 @@ import ConfirmDialog from '../components/ConfirmDialog.vue'
|
|||||||
import InboundList from '../components/InboundList.vue'
|
import InboundList from '../components/InboundList.vue'
|
||||||
import InboundDetail from './InboundDetail.vue'
|
import InboundDetail from './InboundDetail.vue'
|
||||||
import InboundForm from './InboundForm.vue'
|
import InboundForm from './InboundForm.vue'
|
||||||
|
import RuleForm from './RuleForm.vue'
|
||||||
import IconAdd from '~icons/ri/add-line'
|
import IconAdd from '~icons/ri/add-line'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -219,6 +223,71 @@ async function selectMember(group: string, member: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 附加规则 ─────────────────────────────────── */
|
||||||
|
|
||||||
|
/** 选中这一路的附加规则(不含表底的兜底 MATCH)。 */
|
||||||
|
const subRules = computed(() =>
|
||||||
|
selected.value && setup.value !== null ? readSubRules(props.data, selected.value.name) : [],
|
||||||
|
)
|
||||||
|
|
||||||
|
/** RULE-SET 载荷的候选,给规则表单用 */
|
||||||
|
const ruleProviderNames = computed(() => Object.keys(ruleProvidersOf(props.data)))
|
||||||
|
|
||||||
|
const subFormOpen = ref(false)
|
||||||
|
/** 编辑时是下标,新增时是 null */
|
||||||
|
const subEditing = ref<number | null>(null)
|
||||||
|
/** 待确认删除的附加规则下标 */
|
||||||
|
const removingSubRule = ref<number | null>(null)
|
||||||
|
|
||||||
|
function openSubAdd() {
|
||||||
|
subEditing.value = null
|
||||||
|
subFormOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSubEdit(index: number) {
|
||||||
|
subEditing.value = index
|
||||||
|
subFormOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 所有附加规则的改动都收敛到这里:算出新表,整表写回。 */
|
||||||
|
async function commitSubRules(next: string[], success: string): Promise<boolean> {
|
||||||
|
const target = selected.value
|
||||||
|
if (!target || saving.value) return false
|
||||||
|
return commit(applySubRules(clone(), target.name, next), success)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitSubRule(ruleText: string) {
|
||||||
|
const editing = subEditing.value
|
||||||
|
const next =
|
||||||
|
editing === null
|
||||||
|
? [...subRules.value, ruleText]
|
||||||
|
: subRules.value.map((rule, i) => (i === editing ? ruleText : rule))
|
||||||
|
const ok = await commitSubRules(next, editing === null ? '已新增附加规则' : '已保存')
|
||||||
|
// 失败时不能关对话框——关了再打开就是空表单
|
||||||
|
if (ok) subFormOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveSubRule(index: number, delta: -1 | 1) {
|
||||||
|
if (saving.value) return
|
||||||
|
await commitSubRules(moveRule(subRules.value, index, delta), '顺序已保存')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeSubRuleConfirmed() {
|
||||||
|
const index = removingSubRule.value
|
||||||
|
removingSubRule.value = null
|
||||||
|
if (index === null) return
|
||||||
|
await commitSubRules(
|
||||||
|
subRules.value.filter((_, i) => i !== index),
|
||||||
|
'已删除这条附加规则',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addLanSubRules() {
|
||||||
|
const lan = missingLanRules(subRules.value)
|
||||||
|
if (lan.length === 0) return
|
||||||
|
await commitSubRules([...subRules.value, ...lan], `已加入 ${lan.length} 条内网直连规则`)
|
||||||
|
}
|
||||||
|
|
||||||
/** 待确认删除的那一路。非 null 时确认对话框打开着。 */
|
/** 待确认删除的那一路。非 null 时确认对话框打开着。 */
|
||||||
const removing = ref<Inbound | null>(null)
|
const removing = ref<Inbound | null>(null)
|
||||||
|
|
||||||
@@ -280,6 +349,7 @@ async function removeConfirmed() {
|
|||||||
:chain="chainView"
|
:chain="chainView"
|
||||||
:subscriptions="subscriptions"
|
:subscriptions="subscriptions"
|
||||||
:setup="setup"
|
:setup="setup"
|
||||||
|
:sub-rules="subRules"
|
||||||
:testing="testing"
|
:testing="testing"
|
||||||
:saving="saving"
|
:saving="saving"
|
||||||
@edit="openEdit"
|
@edit="openEdit"
|
||||||
@@ -288,6 +358,11 @@ async function removeConfirmed() {
|
|||||||
@test="test"
|
@test="test"
|
||||||
@save="saveSetup"
|
@save="saveSetup"
|
||||||
@adopt="adopt"
|
@adopt="adopt"
|
||||||
|
@sub-add="openSubAdd"
|
||||||
|
@sub-edit="openSubEdit"
|
||||||
|
@sub-remove="removingSubRule = $event"
|
||||||
|
@sub-move="moveSubRule"
|
||||||
|
@sub-lan="addLanSubRules"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -310,6 +385,25 @@ async function removeConfirmed() {
|
|||||||
@close="removing = null"
|
@close="removing = null"
|
||||||
@confirm="removeConfirmed"
|
@confirm="removeConfirmed"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<RuleForm
|
||||||
|
:open="subFormOpen"
|
||||||
|
:editing="subEditing === null ? null : { index: subEditing, raw: subRules[subEditing]! }"
|
||||||
|
:provider-names="ruleProviderNames"
|
||||||
|
:saving="saving"
|
||||||
|
@close="subFormOpen = false"
|
||||||
|
@submit="submitSubRule"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
:open="removingSubRule !== null"
|
||||||
|
:message="
|
||||||
|
removingSubRule !== null ? `删除附加规则「${subRules[removingSubRule] ?? ''}」?` : ''
|
||||||
|
"
|
||||||
|
action="删除"
|
||||||
|
@close="removingSubRule = null"
|
||||||
|
@confirm="removeSubRuleConfirmed"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user