新增入站不再预填订阅

来源一个都不选时 AUTO 和 FALLBACK 就是空组,内核会拒绝整份配置
(adapter/outboundgroup/parser.go:117-119),所以 applySetup 这时只建
SELECT、成员只有 DIRECT;用户在配置里选好订阅再保存,那两个组建回来。
链式代理打开时同样不预填入口订阅。

「改成逐条选线路」保持全给上:那一路本来就在把流量送到某个出站,
接管不该顺手把它改成直连。
This commit is contained in:
2026-08-14 14:04:47 +08:00
parent b1b251b897
commit 56546b20c5
4 changed files with 72 additions and 20 deletions
+22
View File
@@ -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)
+11
View File
@@ -390,6 +390,16 @@ export function applySetup(data: ConfigObject, inbound: string, setup: OutboundS
const { filter } = setup.proxy
let groups = groupsOf(next)
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 就是静默直连,对按端口定向出站的场景是泄露。
@@ -403,6 +413,7 @@ export function applySetup(data: ConfigObject, inbound: string, setup: OutboundS
)
groups = upsert(groups, makeGroup(autoGroupName(inbound), 'url-test', use, filter))
groups = upsert(groups, makeGroup(fallbackGroupName(inbound), 'fallback', use, filter))
}
if (chained) {
groups = upsert(
+14 -5
View File
@@ -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(
</h3>
<!-- 草稿态:显示保存后的成员。现有成员点了立刻生效,保存后才加入的点了是预选 -->
<div v-if="chainDraft" class="grid">
<p v-if="chainDraft && form.chain.sources.length === 0" class="hint">还没有选订阅</p>
<div v-else-if="chainDraft" class="grid">
<OutboundCard
v-for="card in chainCards"
:key="card.name"
+12 -2
View File
@@ -54,12 +54,22 @@ const emit = defineEmits<{ saved: []; 'focus-done': [] }>()
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<string | null>(null)
const saving = ref(false)
const fields = ref<ListenerFields | null>(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}` : '已保存')
// 失败时不能关对话框——关了再打开就是空表单,用户填的名称、端口、类型全没了