可切换的那一层改成最后一个 Selector,不是最后一个有成员的组

原来的规则会落在 AUTO 上,而 AUTO 是 url-test 类型——它按延迟自己挑,手动选它没有
意义,点完会被下一轮延迟测试算回去。内核里只有 Selector 是「由人来选」的
(constant/adapters.go:240-248 列了全部五种),URLTest 挑最快、Fallback 挑第一个可用、
LoadBalance 分摊,Relay 已被移除。

链条上现在每一跳都标出类型(PROXY Selector → AUTO URLTest → 线路 Vless),并明说
自动决定的那几层不接受手动指定——不说清楚的话,用户看到链条里有个组却点不了,
只会以为是坏了。
This commit is contained in:
im0o
2026-08-10 18:38:59 +08:00
parent c5ac59ecf9
commit 5049879a70
3 changed files with 126 additions and 26 deletions
+62 -18
View File
@@ -1,14 +1,17 @@
import { describe, expect, it } from 'bun:test' import { describe, expect, it } from 'bun:test'
import { lastSwitchableGroup, resolveChain } from './outbound-chain.ts' import { acceptsManualChoice, autoDecidedHops, lastSwitchableGroup, resolveChain } from './outbound-chain.ts'
import type { RuntimeNode } from './outbound-chain.ts' import type { RuntimeNode } from './outbound-chain.ts'
/** 仿照真实那台机器:PROXY 手动选,选中了自动测速的 AUTO,AUTO 挑了一条订阅线路。 */ /**
* 仿照真实那台机器:PROXY 手动选,它选中了按延迟自动挑的 AUTO,AUTO 挑了一条订阅线路。
* PROXY 的成员是手写的 AUTO、DIRECT 加上订阅带进来的两条线路。
*/
const runtime: Record<string, RuntimeNode> = { const runtime: Record<string, RuntimeNode> = {
PROXY: { now: 'AUTO', all: ['AUTO', 'DIRECT'] }, PROXY: { type: 'Selector', now: 'AUTO', all: ['AUTO', 'DIRECT', '[vmess]DMIT', '[vless]Gomami'] },
AUTO: { now: '[vless]Gomami', all: ['[vmess]DMIT', '[vless]Gomami'] }, AUTO: { type: 'URLTest', now: '[vless]Gomami', all: ['[vmess]DMIT', '[vless]Gomami'] },
'[vless]Gomami': {}, '[vless]Gomami': { type: 'Vless' },
'[vmess]DMIT': {}, '[vmess]DMIT': { type: 'Vmess' },
DIRECT: {}, DIRECT: { type: 'Direct' },
} }
describe('解析流量最终走到哪条线', () => { describe('解析流量最终走到哪条线', () => {
@@ -31,35 +34,76 @@ describe('解析流量最终走到哪条线', () => {
it('策略组互相引用成环时停下来,不会转死', () => { it('策略组互相引用成环时停下来,不会转死', () => {
const looped: Record<string, RuntimeNode> = { const looped: Record<string, RuntimeNode> = {
A: { now: 'B', all: ['B'] }, A: { type: 'Selector', now: 'B', all: ['B'] },
B: { now: 'A', all: ['A'] }, B: { type: 'Selector', now: 'A', all: ['A'] },
} }
expect(resolveChain(looped, 'A')).toEqual(['A', 'B']) expect(resolveChain(looped, 'A')).toEqual(['A', 'B'])
}) })
})
it('组选中了自己时也停得下来', () => { describe('哪种策略组接受手动选择', () => {
expect(resolveChain({ A: { now: 'A', all: ['A'] } }, 'A')).toEqual(['A']) it('只有 Selector', () => {
expect(acceptsManualChoice('Selector')).toBe(true)
})
it('自动决定的那几种都不接受', () => {
// 内核里 URLTest 按延迟挑、Fallback 挑第一个可用、LoadBalance 分摊,
// 它们的选中结果是算出来的,点了会被算回去
for (const type of ['URLTest', 'Fallback', 'LoadBalance', 'Relay']) {
expect(acceptsManualChoice(type)).toBe(false)
}
expect(acceptsManualChoice(undefined)).toBe(false)
}) })
}) })
describe('找出用户能换选中的那一层', () => { describe('找出用户能换选中的那一层', () => {
it('取链条上最靠近线路的那个策略组', () => { it('跳过自动决定的组,落在最后一个 Selector 上', () => {
// 换 AUTO 的选中才是「换线路」;换 PROXY 换的是「走哪套策略」 // 链条是 PROXY(Selector) → AUTO(URLTest) → 线路。
// 能点的是 PROXY,不是 AUTO——让人去点 URLTest 的成员,点完会被延迟测试算回去。
const chain = resolveChain(runtime, 'PROXY') const chain = resolveChain(runtime, 'PROXY')
expect(lastSwitchableGroup(runtime, chain)).toEqual({ expect(lastSwitchableGroup(runtime, chain)).toEqual({
group: 'AUTO', group: 'PROXY',
members: ['[vmess]DMIT', '[vless]Gomami'], members: ['AUTO', 'DIRECT', '[vmess]DMIT', '[vless]Gomami'],
now: '[vless]Gomami', now: 'AUTO',
}) })
}) })
it('有多个 Selector 时取最靠近线路的那个', () => {
// 越靠近线路越接近「换线路」这个意图;前面的层换的是「走哪套策略」
const nested: Record<string, RuntimeNode> = {
外层: { type: 'Selector', now: '内层', all: ['内层'] },
内层: { type: 'Selector', now: '线路A', all: ['线路A', '线路B'] },
线路A: { type: 'Vmess' },
}
expect(lastSwitchableGroup(nested, resolveChain(nested, '外层'))?.group).toBe('内层')
})
it('整条链都是自动决定时返回空', () => {
const auto: Record<string, RuntimeNode> = {
AUTO: { type: 'URLTest', now: '线路A', all: ['线路A'] },
线路A: { type: 'Vmess' },
}
expect(lastSwitchableGroup(auto, resolveChain(auto, 'AUTO'))).toBeNull()
})
it('链条上一个策略组都没有时返回空', () => { it('链条上一个策略组都没有时返回空', () => {
expect(lastSwitchableGroup(runtime, ['[vless]Gomami'])).toBeNull() expect(lastSwitchableGroup(runtime, ['[vless]Gomami'])).toBeNull()
expect(lastSwitchableGroup(runtime, [])).toBeNull() expect(lastSwitchableGroup(runtime, [])).toBeNull()
}) })
it('组存在但成员是空的,不算可切换', () => { it('Selector 存在但成员是空的,不算可切换', () => {
// 筛选条件没匹配到任何线路时会出现这种组,给出一个点不动的列表只会让人困惑 // 筛选条件没匹配到任何线路时会出现这种组,给出一个点不动的列表只会让人困惑
expect(lastSwitchableGroup({ 空组: { all: [], now: '' } }, ['空组'])).toBeNull() expect(lastSwitchableGroup({ 空组: { type: 'Selector', all: [], now: '' } }, ['空组'])).toBeNull()
})
})
describe('哪几层是自动决定的', () => {
it('列出来,界面用它解释为什么这一层点不了', () => {
const chain = resolveChain(runtime, 'PROXY')
expect(autoDecidedHops(runtime, chain)).toEqual([{ name: 'AUTO', type: 'URLTest' }])
})
it('线路本身不算——它不是组,本来就没得选', () => {
expect(autoDecidedHops(runtime, ['[vless]Gomami'])).toEqual([])
}) })
}) })
+37 -5
View File
@@ -11,12 +11,26 @@
/** 运行时状态里跟解析链路有关的部分。完整定义在 web/api.ts。 */ /** 运行时状态里跟解析链路有关的部分。完整定义在 web/api.ts。 */
export interface RuntimeNode { export interface RuntimeNode {
/** 出站类型。策略组是 Selector / URLTest / Fallback / LoadBalance 之一 */
type?: string
/** 策略组当前选中的成员。不是策略组就没有这个字段 */ /** 策略组当前选中的成员。不是策略组就没有这个字段 */
now?: string now?: string
/** 策略组的成员列表 */ /** 策略组的成员列表 */
all?: string[] all?: string[]
} }
/**
* 这个类型的策略组接不接受手动选择。
*
* 内核里只有 `Selector` 是「由人来选」的(内核源码 constant/adapters.go:240-248 列出
* 全部类型)。`URLTest` 按延迟挑最快的、`Fallback` 挑第一个可用的、`LoadBalance` 在成员
* 之间分摊——它们的选中结果是算出来的,界面上给一个能点的列表只会让人困惑:点完可能
* 立刻被算回去。`Relay` 已经被内核移除。
*/
export function acceptsManualChoice(type: string | undefined): boolean {
return type === 'Selector'
}
/** /**
* 从落脚的出站一路跟着「当前选中」往下走。 * 从落脚的出站一路跟着「当前选中」往下走。
* *
@@ -44,11 +58,13 @@ export interface SwitchableGroup {
} }
/** /**
* 链条上最后一个还是策略组的环节。 * 链条上最后一个能由人来选的策略组。
* *
* 那是用户能真正做出选择的地方:往前的环节只是层层转交,往后已经是具体线路了。 * 取最后一个而不是第一个,是因为越靠近线路的那一层越接近「换线路」这个意图;
* 取最后一个而不是第一个,是因为换最靠近线路的那一层才是「换线路」;换前面的层 * 前面的层换的是「走哪套策略」,那是另一回事。
* 换的是「走哪套策略」,那是另一回事。 *
* 跳过自动决定的组:让人去点一个 URLTest 组的成员,点完会被延迟测试算回去,
* 那不是选择,是错觉。
*/ */
export function lastSwitchableGroup( export function lastSwitchableGroup(
runtime: Record<string, RuntimeNode>, runtime: Record<string, RuntimeNode>,
@@ -57,7 +73,23 @@ export function lastSwitchableGroup(
for (let i = chain.length - 1; i >= 0; i -= 1) { for (let i = chain.length - 1; i >= 0; i -= 1) {
const name = chain[i]! const name = chain[i]!
const node = runtime[name] const node = runtime[name]
if (node?.all?.length) return { group: name, members: node.all, now: node.now ?? '' } if (!node?.all?.length || !acceptsManualChoice(node.type)) continue
return { group: name, members: node.all, now: node.now ?? '' }
} }
return null return null
} }
/**
* 链条上那些自动决定选中结果的组。
*
* 界面用它来解释「为什么这一层点不了」——不说清楚的话,用户看到链条里有个组却没法
* 操作,只会以为是坏了。
*/
export function autoDecidedHops(
runtime: Record<string, RuntimeNode>,
chain: string[],
): Array<{ name: string; type: string }> {
return chain
.map((name) => ({ name, type: runtime[name]?.type ?? '' }))
.filter((hop) => hop.type !== '' && !acceptsManualChoice(hop.type) && runtime[hop.name]?.all)
}
+27 -3
View File
@@ -8,7 +8,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 { autoDecidedHops, lastSwitchableGroup, resolveChain } from '@shared/outbound-chain.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'
@@ -50,6 +50,10 @@ const isTesting = (name: string) => props.testing.includes(name)
const chain = computed(() => resolveChain(runtime.value, props.destination)) const chain = computed(() => resolveChain(runtime.value, props.destination))
const switchable = computed(() => lastSwitchableGroup(runtime.value, chain.value)) const switchable = computed(() => lastSwitchableGroup(runtime.value, chain.value))
/** 链条上那些自己算选中结果的组,用来解释为什么它们点不了。 */
const autoHops = computed(() => autoDecidedHops(runtime.value, chain.value))
/** 每一跳的类型,显示在链条上。线路显示协议,策略组显示它是怎么选的。 */
const hopType = (name: string) => runtime.value[name]?.type ?? ''
function isCurrentOutbound(mode: OutboundMode): boolean { function isCurrentOutbound(mode: OutboundMode): boolean {
const now = props.inbound.outbound const now = props.inbound.outbound
@@ -93,13 +97,21 @@ const NO_PROXY_FIELD = '该入站没有 proxy 字段'
<!-- 运行时:换线路。不写文件,点了立刻变 --> <!-- 运行时:换线路。不写文件,点了立刻变 -->
<section v-if="chain.length" class="block"> <section v-if="chain.length" class="block">
<h3>流量去向</h3> <h3>流量去向</h3>
<p class="chain mono"> <p class="chain">
<template v-for="(hop, i) in chain" :key="hop"> <template v-for="(hop, i) in chain" :key="hop">
<span v-if="i" class="arrow">→</span> <span v-if="i" class="arrow">→</span>
<span :class="i === chain.length - 1 && 'tail'">{{ hop }}</span> <span class="hop">
<span :class="['mono', i === chain.length - 1 && 'tail']">{{ hop }}</span>
<span v-if="hopType(hop)" class="hop-type">{{ hopType(hop) }}</span>
</span>
</template> </template>
</p> </p>
<p v-if="autoHops.length" class="hint">
{{ autoHops.map((h) => `${h.name}(${h.type})`).join('、') }}
自己决定选中哪一条,不接受手动指定
</p>
<template v-if="switchable"> <template v-if="switchable">
<h4>{{ switchable.group }} 的当前选中</h4> <h4>{{ switchable.group }} 的当前选中</h4>
<div class="grid"> <div class="grid">
@@ -117,6 +129,7 @@ const NO_PROXY_FIELD = '该入站没有 proxy 字段'
/> />
</div> </div>
</template> </template>
<p v-else-if="chain.length" class="hint">这条链路上没有可手动选择的策略组</p>
</section> </section>
<!-- 配置:换这路入站指向谁。要写文件、要校验 --> <!-- 配置:换这路入站指向谁。要写文件、要校验 -->
@@ -320,6 +333,17 @@ h4 {
color: var(--c-text-faint); color: var(--c-text-faint);
} }
.hop {
display: inline-flex;
align-items: baseline;
gap: var(--s-1);
}
.hop-type {
font-size: var(--t-xs);
color: var(--c-text-faint);
}
.chain .tail { .chain .tail {
color: var(--c-accent-text); color: var(--c-accent-text);
font-weight: 500; font-weight: 500;