每路入站配一组专属策略组,换线不再改配置文件

原本换一条线要改 config.yaml、跑校验、等内核重载,几秒起步;而换线恰恰是这个工具
最高频的动作,把它放在最重的路径上方向就错了。现在每一路配一个 select 组加 AUTO、
FALLBACK 两个,入站的 proxy 永久指向 select,换线只是调一次内核接口。这样做的前提是
选中不会丢:内核把它写进 cache 文件,重启后由 patchSelectGroup 恢复,而
profile.store-selected 默认为真。

链式代理靠给来源订阅复制一份声明、挂上 override.dialer-proxy 实现,因为这个字段写在
策略组上内核只会打条日志然后忽略。副本是 type: file 读原订阅已经落盘的缓存,而不是
自己再拉一次:自己拉的话订阅地址一不可达副本就是空的,那一路入站会一条线都不剩,
表现是端口还在监听却没有任何出口。

顺带把 shared/outbound-chain.ts 里「只有 Selector 接受手动选择」那条断言删了,它是
错的(内核检查的是 SelectAble 接口,URLTest 和 Fallback 也实现了)。界面仍然只让
Selector 可点,但理由换成了另外两种的手动指定会被它们自己撤销。

完整取舍见工作区决策记录 0007。
This commit is contained in:
2026-08-11 03:10:38 +08:00
parent afe53b9790
commit e9e2eb555d
19 changed files with 1807 additions and 567 deletions
+66 -86
View File
@@ -7,24 +7,24 @@
* 运行时动作(切换选中、测延迟)不经过它,因为它们不写文件也不需要校验。
*/
import { computed, onMounted, ref, watch } from 'vue'
import { collectInbounds } from '@shared/inbounds.ts'
import type { Inbound } from '@shared/inbounds.ts'
import {
collectInbounds,
collectOutboundNames,
outboundLabel,
outboundToProxyField,
resolveDestination,
} from '@shared/inbounds.ts'
import type { Inbound, OutboundMode } from '@shared/inbounds.ts'
applySetup,
chainGroupName,
groupUses,
panelGroups,
pruneGenerated,
syncGenerated,
readSetup,
userProviders,
} from '@shared/inbound-groups.ts'
import type { OutboundSetup } from '@shared/inbound-groups.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 type { GroupView } from '../runtime'
import { optimisticSelect, providerLines, reloadRuntime, runtime } from '../runtime'
import { useDelayTest } from '../delay-test'
import { density } from '../prefs'
import InboundList from '../components/InboundList.vue'
@@ -38,11 +38,12 @@ const props = defineProps<{ data: ConfigObject; revision: string }>()
const emit = defineEmits<{ saved: [] }>()
const inbounds = computed(() => collectInbounds(props.data))
const outbounds = computed(() => {
const names = collectOutboundNames(props.data)
// 生成的筛选组不出现在可选列表里:它们是「固定到某条线路」的实现细节,
// 用户从没建过,让他在策略组那一栏看到只会莫名其妙。
return { ...names, groups: names.groups.filter((g) => !isGeneratedGroup(g)) }
const subscriptions = computed(() => userProviders(props.data))
/** 刚接管一路入站时给它什么:订阅全给上,不筛。三个组由 applySetup 一并建出来。 */
const defaultSetup = (): OutboundSetup => ({
proxy: { sources: [...subscriptions.value], filter: null },
chain: null,
})
const selectedId = ref<string | null>(null)
@@ -55,8 +56,6 @@ const notice = ref('')
const fields = ref<ListenerFields | null>(null)
const formOpen = ref(false)
const formEditing = ref<Inbound | null>(null)
/** 正在提交的那个出站的名字。只有它显示忙碌,其余卡片照常能点 */
const pendingOutbound = ref<string | null>(null)
const { testing, test } = useDelayTest(
() => void reloadRuntime(),
@@ -85,13 +84,32 @@ watch(
const selected = computed(() => inbounds.value.find((i) => i.id === selectedId.value) ?? null)
const takenNames = computed(() => inbounds.value.map((i) => i.name))
/** 面板上能操作哪些策略组、各自能不能点。这个问题只在 panelGroups 里回答一次。 */
const panel = computed(() =>
selected.value
? panelGroups(props.data, runtime.value, selected.value)
: { groups: [], shared: false },
)
/** 共用的组是别人的,改不了它的来源,所以高级配置也不给。 */
const setup = computed<OutboundSetup | null>(() =>
selected.value && !panel.value.shared ? readSetup(props.data, selected.value.name) : null,
)
const chainView = computed<GroupView | null>(() => {
const target = selected.value
if (!target || !setup.value?.chain) return null
const name = chainGroupName(target.name)
return { name, uses: groupUses(props.data, name) ?? [] }
})
const clone = () => JSON.parse(JSON.stringify(props.data)) as ConfigObject
/** 所有改配置文件的动作都从这里走。 */
async function commit(raw: ConfigObject, success: string) {
// 顺手清掉没人再引用的生成组。不清理的话,改几次出站文件里就攒下一堆
// 用户从没主动建过、也不知道是干嘛的组。
const next = pruneGeneratedGroups(raw)
// 先把生成的东西按当前规则重建一遍,再清掉没人再引用的。不清理的话,改几次文件里就
// 攒下一堆用户从没主动建过的东西,而没人用的订阅副本还会按 interval 继续联网。
const next = pruneGenerated(syncGenerated(raw, collectInbounds(raw)))
saving.value = true
failure.value = null
done.value = ''
@@ -110,62 +128,18 @@ async function commit(raw: ConfigObject, success: string) {
}
}
async function pickOutbound(mode: OutboundMode) {
/** 保存高级配置:改的是这一路的组从哪些订阅取成员、筛掉哪些。 */
async function saveSetup(next: OutboundSetup) {
const target = selected.value
if (!target || target.listenerIndex === null || pendingOutbound.value) return
pendingOutbound.value = outboundLabel(mode)
const next = clone()
const entry = (next.listeners as ConfigObject[])[target.listenerIndex]!
const proxy = outboundToProxyField(mode)
// RULE 是「没有这个字段」而不是「字段为空」,所以是删掉而不是置空。
if (proxy === undefined) delete entry.proxy
else entry.proxy = proxy
try {
await commit(next, `${target.name} 的出站已设为 ${outboundLabel(mode)}`)
} finally {
pendingOutbound.value = null
}
if (!target || saving.value) return
await commit(applySetup(clone(), target.name, next), `${target.name} 的代理配置已保存`)
}
/**
* 把当前这一路入站固定到某条订阅线路。
*
* 订阅线路不能被入站直接引用,所以要先建一个只装它的策略组再指过去(决策记录 0005)。
* 这一步对用户是透明的:他看到的只是「这个口固定走这条线」。
*/
async function pinLine(line: string) {
/** 把一路还没接管的入站改成专属代理组模式。 */
async function adopt() {
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
}
if (!target || saving.value) return
await commit(applySetup(clone(), target.name, defaultSetup()), `${target.name} 已改为逐条选线路`)
}
function openAdd() {
@@ -188,7 +162,12 @@ async function submitForm(entry: ConfigObject) {
else listeners[editingIndex] = entry
next.listeners = listeners
await commit(next, editingIndex === null ? `已新增 ${String(entry.name)}` : '已保存')
// 新增的入站直接进专属组模式。否则新建完看到的是一块「这一路不由 Patchbay 管理」
// 的提示,还得再点一次才能用。
const name = String(entry.name)
const withGroup = editingIndex === null ? applySetup(next, name, defaultSetup()) : next
await commit(withGroup, editingIndex === null ? `已新增 ${name}` : '已保存')
// 失败时不能关对话框——关了再打开就是空表单,用户填的名称、端口、类型全没了
if (failure.value) return
formOpen.value = false
@@ -238,8 +217,8 @@ async function removeSelected() {
<span class="sum-k">入站</span>
</div>
<div class="sum">
<span class="sum-n">{{ outbounds.groups.length }}</span>
<span class="sum-k">策略组</span>
<span class="sum-n">{{ subscriptions.length }}</span>
<span class="sum-k">订阅</span>
</div>
<div class="sum">
<span class="sum-n">{{ providerLines.length }}</span>
@@ -266,17 +245,18 @@ async function removeSelected() {
<InboundDetail
v-if="selected"
:inbound="selected"
:groups="outbounds.groups"
:own-proxies="outbounds.ownProxies"
:destination="resolveDestination(data, selected)"
:pending-outbound="pendingOutbound"
:panel="panel"
:chain="chainView"
:subscriptions="subscriptions"
:setup="setup"
:testing="testing"
@pick="pickOutbound"
:saving="saving"
@edit="openEdit"
@remove="removeSelected"
@select="selectMember"
@test="(name: string) => test(name, lineProvider[name])"
@pin="pinLine"
@test="test"
@save="saveSetup"
@adopt="adopt"
/>
</div>