把入站固定到某条订阅线路

订阅线路不在内核的全局名字表里,入站的 proxy 指不到它(决策记录 0005)。做法是自动建
一个只装它的策略组再指过去,用户看到的只是「这个口固定走这条线」,组是实现细节:
不出现在策略组列表里,列表和详情里都显示成线路本身的名字,没人再引用时自动删掉。

筛选条件必须做对两件事,都写了测试:转义正则元字符(线路名形如 [vmess]DMIT.LAX.Pro
里的方括号和点不转义会匹配错),以及前后加锚点(内核是部分匹配,不加锚点时「香港01」
会把「香港011」也捞进来)。线路名含反引号时表达不了——那是内核切分多个筛选条件的
分隔符——这种情况明说做不到而不是生成一个会静默匹配错的组。

实测:生成的配置 mihomo -t 通过;起一个临时内核问 /proxies,这个组解析出来的成员
恰好只有那一条线路,且确实进了名字表所以入站指得到。
This commit is contained in:
im0o
2026-08-10 19:33:44 +08:00
parent 08ab73a4f1
commit cb8effe05b
6 changed files with 333 additions and 6 deletions
+11 -3
View File
@@ -9,6 +9,7 @@
import { computed } from 'vue'
import type { Inbound, OutboundMode } from '@shared/inbounds.ts'
import { lastSwitchableGroup, resolveChain } from '@shared/outbound-chain.ts'
import { generatedGroupName } from '@shared/filter-groups.ts'
import { latestDelay } from '../api'
import { kernel, providerLines, runtime } from '../runtime'
import OutboundCard from '../components/OutboundCard.vue'
@@ -34,6 +35,8 @@ const emit = defineEmits<{
remove: []
select: [group: string, member: 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 组' },
]
const LINE_LOCKED = '订阅线路无法被入站直接引用,需要通过筛选组间接引入。该功能尚未实现。'
/** 这一路入站是不是已经固定在某条订阅线路上。 */
function isPinnedTo(line: string): boolean {
const mode = props.inbound.outbound
return mode.kind === 'named' && mode.name === generatedGroupName(line)
}
</script>
<template>
@@ -192,10 +199,11 @@ const LINE_LOCKED = '订阅线路无法被入站直接引用,需要通过筛
:name="line"
:sub="describe(line)"
:delay="delayOf(line)"
:selected="false"
:disabled-reason="LINE_LOCKED"
:selected="isPinnedTo(line)"
:pending="pendingOutbound === generatedGroupName(line)"
:testing="isTesting(line)"
testable
@pick="emit('pin', line)"
@test="emit('test', line)"
/>
</div>
+56 -2
View File
@@ -18,6 +18,12 @@ import type { Inbound, OutboundMode } from '@shared/inbounds.ts'
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
import { ApiError, api } 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 { useDelayTest } from '../delay-test'
import { density } from '../prefs'
@@ -32,7 +38,12 @@ const props = defineProps<{ data: ConfigObject; revision: string }>()
const emit = defineEmits<{ saved: [] }>()
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 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
/** 所有改配置文件的动作都从这里走。 */
async function commit(next: ConfigObject, success: string) {
async function commit(raw: ConfigObject, success: string) {
// 顺手清掉没人再引用的生成组。不清理的话,改几次出站文件里就攒下一堆
// 用户从没主动建过、也不知道是干嘛的组。
const next = pruneGeneratedGroups(raw)
saving.value = true
failure.value = null
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() {
formEditing.value = null
formOpen.value = true
@@ -223,6 +276,7 @@ async function removeSelected() {
@remove="removeSelected"
@select="selectMember"
@test="(name: string) => test(name, lineProvider[name])"
@pin="pinLine"
/>
</div>