From 56546b20c59937f029bd36a5d1396777046d63ec Mon Sep 17 00:00:00 2001 From: Jz0ojiang Date: Fri, 14 Aug 2026 14:04:47 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=85=A5=E7=AB=99=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E9=A2=84=E5=A1=AB=E8=AE=A2=E9=98=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 来源一个都不选时 AUTO 和 FALLBACK 就是空组,内核会拒绝整份配置 (adapter/outboundgroup/parser.go:117-119),所以 applySetup 这时只建 SELECT、成员只有 DIRECT;用户在配置里选好订阅再保存,那两个组建回来。 链式代理打开时同样不预填入口订阅。 「改成逐条选线路」保持全给上:那一路本来就在把流量送到某个出站, 接管不该顺手把它改成直连。 --- shared/inbound-groups.test.ts | 22 +++++++++++++++++++++ shared/inbound-groups.ts | 37 +++++++++++++++++++++++------------ web/views/InboundDetail.vue | 19 +++++++++++++----- web/views/RouterPage.vue | 14 +++++++++++-- 4 files changed, 72 insertions(+), 20 deletions(-) diff --git a/shared/inbound-groups.test.ts b/shared/inbound-groups.test.ts index 740302d..2b9ae57 100644 --- a/shared/inbound-groups.test.ts +++ b/shared/inbound-groups.test.ts @@ -115,6 +115,28 @@ describe('一路入站的三个组', () => { expect((groups[2] as ConfigObject).type).toBe('fallback') }) + it('一个来源都没选时只有 SELECT,成员只有 DIRECT', () => { + // AUTO 和 FALLBACK 只有 use: 没有点名成员,来源为空就是空组,内核会拒绝 + // 整份配置(parser.go:117-119)。新增入站落在这个状态上,等用户自己挑订阅 + const empty: OutboundSetup = { proxy: { sources: [], filter: null }, chain: null } + const groups = applySetup(base(), 'test1', empty)['proxy-groups'] as ConfigObject[] + expect(groups).toEqual([{ name: 'patchbay:test1', type: 'select', proxies: ['DIRECT'] }]) + }) + + it('从空来源选上订阅后,AUTO 和 FALLBACK 建回来', () => { + const empty: OutboundSetup = { proxy: { sources: [], filter: null }, chain: null } + const data = applySetup(applySetup(base(), 'test1', empty), 'test1', plain) + const groups = data['proxy-groups'] as ConfigObject[] + expect(groups.map((g) => g.name)).toEqual([ + 'patchbay:test1', + 'patchbay:test1 AUTO', + 'patchbay:test1 FALLBACK', + ]) + // 反过来把订阅全取消,那两个组要跟着消失,否则留下的是内核不收的空组 + const back = applySetup(data, 'test1', empty)['proxy-groups'] as ConfigObject[] + expect(back.map((g) => g.name)).toEqual(['patchbay:test1']) + }) + it('tab 的顺序是 SELECT、AUTO、FALLBACK,只有 SELECT 能点', () => { // 另外两个自己决定用哪条,手动指定会被它们自己撤销,见 outbound-chain.ts const data = applySetup(base(), 'test1', plain) diff --git a/shared/inbound-groups.ts b/shared/inbound-groups.ts index ef91b77..209ca37 100644 --- a/shared/inbound-groups.ts +++ b/shared/inbound-groups.ts @@ -390,19 +390,30 @@ export function applySetup(data: ConfigObject, inbound: string, setup: OutboundS const { filter } = setup.proxy let groups = groupsOf(next) - // AUTO 排第一位:内核给没有选中记录的组默认用第一个成员,选中的线路被订阅刷新删掉时 - // 也静默回退到第一个成员(内核源码 adapter/outboundgroup/selector.go:103-112)。这两种 - // 情况都该落到自动测速上;排 DIRECT 就是静默直连,对按端口定向出站的场景是泄露。 - groups = upsert( - groups, - makeGroup(proxyGroupName(inbound), 'select', use, filter, [ - autoGroupName(inbound), - fallbackGroupName(inbound), - 'DIRECT', - ]), - ) - groups = upsert(groups, makeGroup(autoGroupName(inbound), 'url-test', use, filter)) - groups = upsert(groups, makeGroup(fallbackGroupName(inbound), 'fallback', use, filter)) + if (use.length === 0) { + // 一个来源都没选。AUTO 和 FALLBACK 只有 `use:` 没有点名成员,来源为空就是空组, + // 内核会拒绝整份配置(内核源码 adapter/outboundgroup/parser.go:117-119),所以这时 + // 只建 SELECT、成员只有 DIRECT。新增入站落在这个状态上,等用户自己挑订阅。 + // filter 一并不写:没有来源可筛。 + groups = upsert(groups, makeGroup(proxyGroupName(inbound), 'select', [], null, ['DIRECT'])) + groups = groups.filter( + (g) => g.name !== autoGroupName(inbound) && g.name !== fallbackGroupName(inbound), + ) + } else { + // AUTO 排第一位:内核给没有选中记录的组默认用第一个成员,选中的线路被订阅刷新删掉时 + // 也静默回退到第一个成员(内核源码 adapter/outboundgroup/selector.go:103-112)。这两种 + // 情况都该落到自动测速上;排 DIRECT 就是静默直连,对按端口定向出站的场景是泄露。 + groups = upsert( + groups, + makeGroup(proxyGroupName(inbound), 'select', use, filter, [ + autoGroupName(inbound), + fallbackGroupName(inbound), + 'DIRECT', + ]), + ) + groups = upsert(groups, makeGroup(autoGroupName(inbound), 'url-test', use, filter)) + groups = upsert(groups, makeGroup(fallbackGroupName(inbound), 'fallback', use, filter)) + } if (chained) { groups = upsert( diff --git a/web/views/InboundDetail.vue b/web/views/InboundDetail.vue index 1ea716a..8825755 100644 --- a/web/views/InboundDetail.vue +++ b/web/views/InboundDetail.vue @@ -285,7 +285,9 @@ watch([() => props.inbound.id, () => JSON.stringify(props.setup)], reset, { imme const chainOn = computed({ get: () => form.value.chain !== null, set: (on: boolean) => { - form.value.chain = on ? { sources: [...props.subscriptions], filter: null } : null + // 入口订阅不预设:入口是「先落到哪一跳」,用户心里有具体的那一条, + // 把订阅全给上只是让他保存前先去取消勾选 + form.value.chain = on ? { sources: [], filter: null } : null }, }) @@ -426,9 +428,15 @@ const flowPreview = computed( pendingEntry.value !== null, ) /** - * 至少要选一个订阅。AUTO 和 FALLBACK 只有 `use:` 没有点名成员,一个订阅都不选就是空组, - * 内核会直接拒绝整份配置(内核源码 adapter/outboundgroup/parser.go:117-119)。 - * 在这里拦住,比让用户按下保存再读一段内核报错好。 + * 两边都至少要选一个订阅。 + * + * 入口组只有 `use:` 没有点名成员,一个订阅都不选就是空组,内核会直接拒绝整份配置 + * (内核源码 adapter/outboundgroup/parser.go:117-119)。在这里拦住,比让用户按下 + * 保存再读一段内核报错好。 + * + * 代理这边空来源是能落盘的(applySetup 只建 SELECT、成员只有 DIRECT,新增入站就停在 + * 这个状态),但那是「还没配」的起点,不该是取消勾选保存出来的结果——一路已经在走代理 + * 的入站不能这么悄悄变成直连。 */ const filled = (spec: { sources: string[] }) => spec.sources.length > 0 const complete = computed( @@ -586,7 +594,8 @@ const complete = computed( -
+

还没有选订阅

+
() const inbounds = computed(() => collectInbounds(props.data)) const subscriptions = computed(() => userProviders(props.data)) -/** 刚接管一路入站时给它什么:订阅全给上,不筛。三个组由 applySetup 一并建出来。 */ +/** + * 接管一路已有入站时给它什么:订阅全给上,不筛。三个组由 applySetup 一并建出来。 + * 来源不留空,是因为这一路本来就在把流量送到某个出站上,接管不该顺手把它改成直连。 + */ const defaultSetup = (): OutboundSetup => ({ proxy: { sources: [...subscriptions.value], filter: null }, chain: null, }) +/** + * 新增入站时给它什么:一个订阅都不预设。全新的端口还没有人在用,替用户挑一批订阅 + * 只会让他保存完再回来取消勾选。来源为空的专属组只有 DIRECT(见 applySetup), + * 用户在详情页的配置里选好订阅再保存。 + */ +const emptySetup = (): OutboundSetup => ({ proxy: { sources: [], filter: null }, chain: null }) + const selectedId = ref(null) const saving = ref(false) const fields = ref(null) @@ -225,7 +235,7 @@ async function submitForm(entry: ConfigObject) { // 新增的入站直接进专属组模式。否则新建完看到的是一块「这一路不由 Patchbay 管理」 // 的提示,还得再点一次才能用。 const name = String(entry.name) - const withGroup = editingIndex === null ? applySetup(next, name, defaultSetup()) : next + const withGroup = editingIndex === null ? applySetup(next, name, emptySetup()) : next const ok = await commit(withGroup, editingIndex === null ? `已新增 ${name}` : '已保存') // 失败时不能关对话框——关了再打开就是空表单,用户填的名称、端口、类型全没了