把入站固定到某条订阅线路
订阅线路不在内核的全局名字表里,入站的 proxy 指不到它(决策记录 0005)。做法是自动建 一个只装它的策略组再指过去,用户看到的只是「这个口固定走这条线」,组是实现细节: 不出现在策略组列表里,列表和详情里都显示成线路本身的名字,没人再引用时自动删掉。 筛选条件必须做对两件事,都写了测试:转义正则元字符(线路名形如 [vmess]DMIT.LAX.Pro 里的方括号和点不转义会匹配错),以及前后加锚点(内核是部分匹配,不加锚点时「香港01」 会把「香港011」也捞进来)。线路名含反引号时表达不了——那是内核切分多个筛选条件的 分隔符——这种情况明说做不到而不是生成一个会静默匹配错的组。 实测:生成的配置 mihomo -t 通过;起一个临时内核问 /proxies,这个组解析出来的成员 恰好只有那一条线路,且确实进了名字表所以入站指得到。
This commit is contained in:
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* 验证「把入站固定到某条订阅线路」生成的配置内核真的接受。
|
||||||
|
*
|
||||||
|
* 这个功能会往用户的配置里塞一个自动生成的策略组,光靠单元测试只能证明字符串拼对了,
|
||||||
|
* 证明不了内核认。所以这里走一遍真实的写回路径,再交给 mihomo -t 校验。
|
||||||
|
*
|
||||||
|
* 用法:bun run scripts/verify-pin.ts <源配置> <输出路径> <线路名>
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs'
|
||||||
|
import { openDocument } from '../vendor/config-editor/index.ts'
|
||||||
|
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
||||||
|
import { rewriteYaml } from '../server/config-store.ts'
|
||||||
|
import { generatedGroupName, makeFilterGroup, pruneGeneratedGroups } from '../shared/filter-groups.ts'
|
||||||
|
|
||||||
|
const [src, out, line] = process.argv.slice(2)
|
||||||
|
if (!src || !out || !line) {
|
||||||
|
console.error('用法:bun run scripts/verify-pin.ts <源配置> <输出路径> <线路名>')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = readFileSync(src, 'utf8')
|
||||||
|
const data = JSON.parse(JSON.stringify(openDocument(source).data)) as ConfigObject
|
||||||
|
|
||||||
|
// 模拟界面上的两个动作:新增一路入站,把它固定到某条订阅线路
|
||||||
|
data.listeners = [{ name: '容器A', type: 'mixed', port: 17895 }]
|
||||||
|
const group = makeFilterGroup(line, 'subs')
|
||||||
|
if (!group) {
|
||||||
|
console.error(`${line} 的名字里含反引号,无法生成筛选条件`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
data['proxy-groups'] = [...(data['proxy-groups'] as ConfigObject[]), group]
|
||||||
|
;(data.listeners as ConfigObject[])[0]!.proxy = generatedGroupName(line)
|
||||||
|
|
||||||
|
writeFileSync(out, rewriteYaml(source, pruneGeneratedGroups(data)), 'utf8')
|
||||||
|
console.log('生成的组:')
|
||||||
|
console.log(JSON.stringify(group, null, 2))
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test'
|
||||||
|
import {
|
||||||
|
generatedGroupName,
|
||||||
|
isGeneratedGroup,
|
||||||
|
lineFilter,
|
||||||
|
makeFilterGroup,
|
||||||
|
pinnedLineOf,
|
||||||
|
pruneGeneratedGroups,
|
||||||
|
} from './filter-groups.ts'
|
||||||
|
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
||||||
|
|
||||||
|
const LINE = '[vless]Gomami.SIN Secret'
|
||||||
|
|
||||||
|
describe('筛选条件', () => {
|
||||||
|
it('转义正则元字符', () => {
|
||||||
|
// 线路名里的 [ ] . 不转义会当成正则语法,匹配到别的线路上去
|
||||||
|
expect(lineFilter(LINE)).toBe('^\\[vless\\]Gomami\\.SIN Secret$')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('前后加锚点,避免匹配到名字更长的线路', () => {
|
||||||
|
// 内核的筛选是部分匹配:不加锚点时「香港01」会把「香港011」也捞进来
|
||||||
|
expect(lineFilter('香港01')).toBe('^香港01$')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('线路名含反引号时表达不了,返回空让调用方说明原因', () => {
|
||||||
|
// 反引号在内核里是多个筛选条件的分隔符,塞进一条正则会被切成两半
|
||||||
|
expect(lineFilter('怪`名字')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('转义后的正则确实只匹配它自己', () => {
|
||||||
|
const re = new RegExp(lineFilter(LINE)!)
|
||||||
|
expect(re.test(LINE)).toBe(true)
|
||||||
|
expect(re.test('[vless]GomamiXSIN Secret')).toBe(false)
|
||||||
|
expect(re.test(`${LINE} 备用`)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('生成的组', () => {
|
||||||
|
it('名字带前缀,这是识别它的唯一可靠标记', () => {
|
||||||
|
// 注释靠不住——被碰到的段落重写时会丢,见决策记录 0002
|
||||||
|
const name = generatedGroupName(LINE)
|
||||||
|
expect(isGeneratedGroup(name)).toBe(true)
|
||||||
|
expect(pinnedLineOf(name)).toBe(LINE)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('用户自己建的组不会被认成生成的', () => {
|
||||||
|
expect(isGeneratedGroup('PROXY')).toBe(false)
|
||||||
|
expect(pinnedLineOf('PROXY')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('组里只有那一条线路,类型是手动选', () => {
|
||||||
|
expect(makeFilterGroup(LINE, 'subs')).toEqual({
|
||||||
|
name: `patchbay:${LINE}`,
|
||||||
|
type: 'select',
|
||||||
|
use: ['subs'],
|
||||||
|
filter: '^\\[vless\\]Gomami\\.SIN Secret$',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('表达不了的线路名不生成组', () => {
|
||||||
|
expect(makeFilterGroup('怪`名字', 'subs')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('清理没人用的生成组', () => {
|
||||||
|
const generated = (line: string) => ({ name: generatedGroupName(line), type: 'select' })
|
||||||
|
|
||||||
|
it('入站还指着它就留着', () => {
|
||||||
|
const data: ConfigObject = {
|
||||||
|
listeners: [{ name: 'A', type: 'mixed', port: 1, proxy: generatedGroupName(LINE) }],
|
||||||
|
'proxy-groups': [generated(LINE)],
|
||||||
|
}
|
||||||
|
expect(pruneGeneratedGroups(data)['proxy-groups']).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('没人再指它就删掉', () => {
|
||||||
|
const data: ConfigObject = {
|
||||||
|
listeners: [{ name: 'A', type: 'mixed', port: 1 }],
|
||||||
|
'proxy-groups': [generated(LINE)],
|
||||||
|
}
|
||||||
|
// 不清理的话,改几次出站文件里就攒下一堆用户从没主动建过的组
|
||||||
|
expect(pruneGeneratedGroups(data)['proxy-groups']).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('被别的策略组当成员引用也算在用', () => {
|
||||||
|
const data: ConfigObject = {
|
||||||
|
'proxy-groups': [generated(LINE), { name: 'PROXY', proxies: [generatedGroupName(LINE)] }],
|
||||||
|
}
|
||||||
|
expect(pruneGeneratedGroups(data)['proxy-groups']).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('被规则引用也算在用', () => {
|
||||||
|
const data: ConfigObject = {
|
||||||
|
'proxy-groups': [generated(LINE)],
|
||||||
|
rules: [`MATCH,${generatedGroupName(LINE)}`],
|
||||||
|
}
|
||||||
|
expect(pruneGeneratedGroups(data)['proxy-groups']).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('用户自己建的组一律不碰,哪怕真的没人引用', () => {
|
||||||
|
const data: ConfigObject = { 'proxy-groups': [{ name: '我建的组', type: 'select' }] }
|
||||||
|
expect(pruneGeneratedGroups(data)['proxy-groups']).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('没有可删的时候原样返回,不制造无谓的改动', () => {
|
||||||
|
// 返回同一个对象,落盘链路才看得出「内容没变」而跳过备份和重载
|
||||||
|
const data: ConfigObject = { 'proxy-groups': [{ name: 'PROXY' }] }
|
||||||
|
expect(pruneGeneratedGroups(data)).toBe(data)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* 把一路入站固定到某一条订阅线路。
|
||||||
|
*
|
||||||
|
* 内核不允许直接这么写:入站的 `proxy:` 只认全局名字表里的名字,而订阅线路不在表上
|
||||||
|
* (见决策记录 0005)。绕法是给那条线路单独建一个只装它的策略组,入站指向这个组。
|
||||||
|
*
|
||||||
|
* 这些组是 Patchbay 自己造的,用户不该看到也不该管。名字带前缀是唯一可靠的标记——
|
||||||
|
* 注释靠不住,被碰到的段落重写时会丢。
|
||||||
|
*/
|
||||||
|
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
||||||
|
|
||||||
|
/** 自动生成的筛选组的名字前缀。改它会让已有配置里的组失去标记,别改。 */
|
||||||
|
export const GENERATED_PREFIX = 'patchbay:'
|
||||||
|
|
||||||
|
export function isGeneratedGroup(name: string): boolean {
|
||||||
|
return name.startsWith(GENERATED_PREFIX)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 固定到某条线路时,生成的组叫什么。名字里带上线路名,翻文件时一眼看得懂。 */
|
||||||
|
export function generatedGroupName(line: string): string {
|
||||||
|
return `${GENERATED_PREFIX}${line}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 这个组固定的是哪条线路。不是生成的组返回 null。 */
|
||||||
|
export function pinnedLineOf(groupName: string): string | null {
|
||||||
|
return isGeneratedGroup(groupName) ? groupName.slice(GENERATED_PREFIX.length) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 出站在界面上怎么念。
|
||||||
|
*
|
||||||
|
* 生成的组显示成它固定的那条线路——用户从没建过这个组,让他在界面上读到
|
||||||
|
* `patchbay:[vless]Gomami` 只会莫名其妙。
|
||||||
|
*/
|
||||||
|
export function displayOutboundName(name: string): string {
|
||||||
|
return pinnedLineOf(name) ?? name
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把线路名转成只匹配它自己的筛选条件。
|
||||||
|
*
|
||||||
|
* 两件事必须做对:
|
||||||
|
* 转义正则元字符——线路名形如 `[vmess]DMIT.LAX.Pro.MTYQ`,`[`、`]`、`.` 不转义会匹配错
|
||||||
|
* 前后加锚点——内核的筛选是部分匹配,不加锚点时 `香港01` 会把 `香港011` 也匹配进来
|
||||||
|
*
|
||||||
|
* 反引号在内核里是多个筛选条件的分隔符(adapter/outboundgroup/groupbase.go:72-76),
|
||||||
|
* 线路名里含它就没法用一条正则表达,这种情况返回 null,调用方据此告诉用户做不到。
|
||||||
|
*/
|
||||||
|
export function lineFilter(line: string): string | null {
|
||||||
|
if (line.includes('`')) return null
|
||||||
|
const escaped = line.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
return `^${escaped}$`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 造一个只装指定线路的策略组。
|
||||||
|
*
|
||||||
|
* 用 `select` 而不是 `url-test`:组里只有一条线路,没什么可测的,而 `select` 的语义
|
||||||
|
* 就是「用这一个」。
|
||||||
|
*/
|
||||||
|
export function makeFilterGroup(line: string, provider: string): ConfigObject | null {
|
||||||
|
const filter = lineFilter(line)
|
||||||
|
if (filter === null) return null
|
||||||
|
return {
|
||||||
|
name: generatedGroupName(line),
|
||||||
|
type: 'select',
|
||||||
|
use: [provider],
|
||||||
|
filter,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 配置里所有指向某个名字的位置。用来判断一个生成的组还有没有人在用。 */
|
||||||
|
function referencesTo(data: ConfigObject, name: string): number {
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
for (const entry of Array.isArray(data.listeners) ? data.listeners : []) {
|
||||||
|
if (typeof entry === 'object' && entry !== null) {
|
||||||
|
if ((entry as Record<string, unknown>).proxy === name) count += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const group of Array.isArray(data['proxy-groups']) ? data['proxy-groups'] : []) {
|
||||||
|
if (typeof group !== 'object' || group === null) continue
|
||||||
|
const members = (group as Record<string, unknown>).proxies
|
||||||
|
if (Array.isArray(members) && members.includes(name)) count += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rule of Array.isArray(data.rules) ? data.rules : []) {
|
||||||
|
// 规则的目标是逗号分隔的最后一段之前那个位置,形如 MATCH,目标 或 DOMAIN,x,目标
|
||||||
|
if (typeof rule === 'string' && rule.split(',').some((part) => part.trim() === name)) count += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删掉没人再引用的生成组。
|
||||||
|
*
|
||||||
|
* 每次改完配置都跑一遍。不清理的话,改几次出站文件里就攒下一堆没用的组,而用户
|
||||||
|
* 并不知道它们是干嘛的——他从来没主动建过它们。
|
||||||
|
*
|
||||||
|
* 只动带前缀的组,用户自己建的一律不碰,哪怕它确实没人引用。
|
||||||
|
*/
|
||||||
|
export function pruneGeneratedGroups(data: ConfigObject): ConfigObject {
|
||||||
|
const groups = Array.isArray(data['proxy-groups']) ? data['proxy-groups'] : []
|
||||||
|
const kept = groups.filter((group) => {
|
||||||
|
if (typeof group !== 'object' || group === null) return true
|
||||||
|
const name = (group as Record<string, unknown>).name
|
||||||
|
if (typeof name !== 'string' || !isGeneratedGroup(name)) return true
|
||||||
|
return referencesTo(data, name) > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
if (kept.length === groups.length) return data
|
||||||
|
const next = { ...data }
|
||||||
|
if (kept.length === 0) delete next['proxy-groups']
|
||||||
|
else next['proxy-groups'] = kept
|
||||||
|
return next
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
* 只管显示和选中,不碰任何数据。
|
* 只管显示和选中,不碰任何数据。
|
||||||
*/
|
*/
|
||||||
import { inboundTypeLabel, isUnsetOutbound, outboundLabel } from '@shared/inbounds.ts'
|
import { inboundTypeLabel, isUnsetOutbound, outboundLabel } from '@shared/inbounds.ts'
|
||||||
|
import { displayOutboundName } from '@shared/filter-groups.ts'
|
||||||
import type { Inbound } from '@shared/inbounds.ts'
|
import type { Inbound } from '@shared/inbounds.ts'
|
||||||
|
|
||||||
defineProps<{ inbounds: Inbound[] }>()
|
defineProps<{ inbounds: Inbound[] }>()
|
||||||
@@ -26,7 +27,7 @@ const selected = defineModel<string | null>('selected', { required: true })
|
|||||||
<span v-if="inbound.isDefault" class="tag">默认</span>
|
<span v-if="inbound.isDefault" class="tag">默认</span>
|
||||||
</span>
|
</span>
|
||||||
<span :class="['out mono', isUnsetOutbound(inbound.outbound) && 'is-muted']">
|
<span :class="['out mono', isUnsetOutbound(inbound.outbound) && 'is-muted']">
|
||||||
{{ outboundLabel(inbound.outbound) }}
|
{{ displayOutboundName(outboundLabel(inbound.outbound)) }}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import type { Inbound, OutboundMode } from '@shared/inbounds.ts'
|
import type { Inbound, OutboundMode } from '@shared/inbounds.ts'
|
||||||
import { lastSwitchableGroup, resolveChain } from '@shared/outbound-chain.ts'
|
import { lastSwitchableGroup, resolveChain } from '@shared/outbound-chain.ts'
|
||||||
|
import { generatedGroupName } from '@shared/filter-groups.ts'
|
||||||
import { latestDelay } from '../api'
|
import { latestDelay } from '../api'
|
||||||
import { kernel, providerLines, runtime } from '../runtime'
|
import { kernel, providerLines, runtime } from '../runtime'
|
||||||
import OutboundCard from '../components/OutboundCard.vue'
|
import OutboundCard from '../components/OutboundCard.vue'
|
||||||
@@ -34,6 +35,8 @@ const emit = defineEmits<{
|
|||||||
remove: []
|
remove: []
|
||||||
select: [group: string, member: string]
|
select: [group: string, member: string]
|
||||||
test: [name: string]
|
test: [name: string]
|
||||||
|
/** 把这一路入站固定到某条订阅线路 */
|
||||||
|
pin: [line: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,7 +70,11 @@ const BUILTINS: Array<{ mode: OutboundMode; label: string; sub: string }> = [
|
|||||||
{ mode: { kind: 'global' }, label: 'GLOBAL', sub: '跟随 GLOBAL 组' },
|
{ mode: { kind: 'global' }, label: 'GLOBAL', sub: '跟随 GLOBAL 组' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const LINE_LOCKED = '订阅线路无法被入站直接引用,需要通过筛选组间接引入。该功能尚未实现。'
|
/** 这一路入站是不是已经固定在某条订阅线路上。 */
|
||||||
|
function isPinnedTo(line: string): boolean {
|
||||||
|
const mode = props.inbound.outbound
|
||||||
|
return mode.kind === 'named' && mode.name === generatedGroupName(line)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -192,10 +199,11 @@ const LINE_LOCKED = '订阅线路无法被入站直接引用,需要通过筛
|
|||||||
:name="line"
|
:name="line"
|
||||||
:sub="describe(line)"
|
:sub="describe(line)"
|
||||||
:delay="delayOf(line)"
|
:delay="delayOf(line)"
|
||||||
:selected="false"
|
:selected="isPinnedTo(line)"
|
||||||
:disabled-reason="LINE_LOCKED"
|
:pending="pendingOutbound === generatedGroupName(line)"
|
||||||
:testing="isTesting(line)"
|
:testing="isTesting(line)"
|
||||||
testable
|
testable
|
||||||
|
@pick="emit('pin', line)"
|
||||||
@test="emit('test', line)"
|
@test="emit('test', line)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ import type { Inbound, OutboundMode } from '@shared/inbounds.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'
|
||||||
import type { ListenerFields } from '../api'
|
import type { ListenerFields } from '../api'
|
||||||
|
import {
|
||||||
|
generatedGroupName,
|
||||||
|
isGeneratedGroup,
|
||||||
|
makeFilterGroup,
|
||||||
|
pruneGeneratedGroups,
|
||||||
|
} from '@shared/filter-groups.ts'
|
||||||
import { lineProvider, optimisticSelect, providerLines, reloadRuntime } from '../runtime'
|
import { lineProvider, optimisticSelect, providerLines, reloadRuntime } from '../runtime'
|
||||||
import { useDelayTest } from '../delay-test'
|
import { useDelayTest } from '../delay-test'
|
||||||
import { density } from '../prefs'
|
import { density } from '../prefs'
|
||||||
@@ -32,7 +38,12 @@ const props = defineProps<{ data: ConfigObject; revision: string }>()
|
|||||||
const emit = defineEmits<{ saved: [] }>()
|
const emit = defineEmits<{ saved: [] }>()
|
||||||
|
|
||||||
const inbounds = computed(() => collectInbounds(props.data))
|
const inbounds = computed(() => collectInbounds(props.data))
|
||||||
const outbounds = computed(() => collectOutboundNames(props.data))
|
const outbounds = computed(() => {
|
||||||
|
const names = collectOutboundNames(props.data)
|
||||||
|
// 生成的筛选组不出现在可选列表里:它们是「固定到某条线路」的实现细节,
|
||||||
|
// 用户从没建过,让他在策略组那一栏看到只会莫名其妙。
|
||||||
|
return { ...names, groups: names.groups.filter((g) => !isGeneratedGroup(g)) }
|
||||||
|
})
|
||||||
|
|
||||||
const selectedId = ref<string | null>(null)
|
const selectedId = ref<string | null>(null)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
@@ -77,7 +88,10 @@ const takenNames = computed(() => inbounds.value.map((i) => i.name))
|
|||||||
const clone = () => JSON.parse(JSON.stringify(props.data)) as ConfigObject
|
const clone = () => JSON.parse(JSON.stringify(props.data)) as ConfigObject
|
||||||
|
|
||||||
/** 所有改配置文件的动作都从这里走。 */
|
/** 所有改配置文件的动作都从这里走。 */
|
||||||
async function commit(next: ConfigObject, success: string) {
|
async function commit(raw: ConfigObject, success: string) {
|
||||||
|
// 顺手清掉没人再引用的生成组。不清理的话,改几次出站文件里就攒下一堆
|
||||||
|
// 用户从没主动建过、也不知道是干嘛的组。
|
||||||
|
const next = pruneGeneratedGroups(raw)
|
||||||
saving.value = true
|
saving.value = true
|
||||||
failure.value = null
|
failure.value = null
|
||||||
done.value = ''
|
done.value = ''
|
||||||
@@ -115,6 +129,45 @@ async function pickOutbound(mode: OutboundMode) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把当前这一路入站固定到某条订阅线路。
|
||||||
|
*
|
||||||
|
* 订阅线路不能被入站直接引用,所以要先建一个只装它的策略组再指过去(决策记录 0005)。
|
||||||
|
* 这一步对用户是透明的:他看到的只是「这个口固定走这条线」。
|
||||||
|
*/
|
||||||
|
async function pinLine(line: string) {
|
||||||
|
const target = selected.value
|
||||||
|
if (!target || target.listenerIndex === null || pendingOutbound.value) return
|
||||||
|
|
||||||
|
const provider = lineProvider.value[line]
|
||||||
|
if (!provider) {
|
||||||
|
notice.value = `找不到 ${line} 属于哪个订阅,无法固定`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = makeFilterGroup(line, provider)
|
||||||
|
if (!group) {
|
||||||
|
notice.value = `${line} 的名字里含反引号,内核会把它当成筛选条件的分隔符,无法固定`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupName = generatedGroupName(line)
|
||||||
|
pendingOutbound.value = groupName
|
||||||
|
|
||||||
|
const next = clone()
|
||||||
|
const groups = Array.isArray(next['proxy-groups']) ? (next['proxy-groups'] as ConfigObject[]) : []
|
||||||
|
// 同一条线路被两路入站固定时共用一个组,不重复建
|
||||||
|
if (!groups.some((g) => g.name === groupName)) groups.push(group)
|
||||||
|
next['proxy-groups'] = groups
|
||||||
|
;(next.listeners as ConfigObject[])[target.listenerIndex]!.proxy = groupName
|
||||||
|
|
||||||
|
try {
|
||||||
|
await commit(next, `${target.name} 已固定走 ${line}`)
|
||||||
|
} finally {
|
||||||
|
pendingOutbound.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openAdd() {
|
function openAdd() {
|
||||||
formEditing.value = null
|
formEditing.value = null
|
||||||
formOpen.value = true
|
formOpen.value = true
|
||||||
@@ -223,6 +276,7 @@ async function removeSelected() {
|
|||||||
@remove="removeSelected"
|
@remove="removeSelected"
|
||||||
@select="selectMember"
|
@select="selectMember"
|
||||||
@test="(name: string) => test(name, lineProvider[name])"
|
@test="(name: string) => test(name, lineProvider[name])"
|
||||||
|
@pin="pinLine"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user