diff --git a/shared/outbound-chain.test.ts b/shared/outbound-chain.test.ts index 8913d7c..3bd3bac 100644 --- a/shared/outbound-chain.test.ts +++ b/shared/outbound-chain.test.ts @@ -1,14 +1,17 @@ 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' -/** 仿照真实那台机器:PROXY 手动选,选中了自动测速的 AUTO,AUTO 挑了一条订阅线路。 */ +/** + * 仿照真实那台机器:PROXY 手动选,它选中了按延迟自动挑的 AUTO,AUTO 挑了一条订阅线路。 + * PROXY 的成员是手写的 AUTO、DIRECT 加上订阅带进来的两条线路。 + */ const runtime: Record = { - PROXY: { now: 'AUTO', all: ['AUTO', 'DIRECT'] }, - AUTO: { now: '[vless]Gomami', all: ['[vmess]DMIT', '[vless]Gomami'] }, - '[vless]Gomami': {}, - '[vmess]DMIT': {}, - DIRECT: {}, + PROXY: { type: 'Selector', now: 'AUTO', all: ['AUTO', 'DIRECT', '[vmess]DMIT', '[vless]Gomami'] }, + AUTO: { type: 'URLTest', now: '[vless]Gomami', all: ['[vmess]DMIT', '[vless]Gomami'] }, + '[vless]Gomami': { type: 'Vless' }, + '[vmess]DMIT': { type: 'Vmess' }, + DIRECT: { type: 'Direct' }, } describe('解析流量最终走到哪条线', () => { @@ -31,35 +34,76 @@ describe('解析流量最终走到哪条线', () => { it('策略组互相引用成环时停下来,不会转死', () => { const looped: Record = { - A: { now: 'B', all: ['B'] }, - B: { now: 'A', all: ['A'] }, + A: { type: 'Selector', now: 'B', all: ['B'] }, + B: { type: 'Selector', now: 'A', all: ['A'] }, } expect(resolveChain(looped, 'A')).toEqual(['A', 'B']) }) +}) - it('组选中了自己时也停得下来', () => { - expect(resolveChain({ A: { now: 'A', all: ['A'] } }, 'A')).toEqual(['A']) +describe('哪种策略组接受手动选择', () => { + 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('找出用户能换选中的那一层', () => { - it('取链条上最靠近线路的那个策略组', () => { - // 换 AUTO 的选中才是「换线路」;换 PROXY 换的是「走哪套策略」 + it('跳过自动决定的组,落在最后一个 Selector 上', () => { + // 链条是 PROXY(Selector) → AUTO(URLTest) → 线路。 + // 能点的是 PROXY,不是 AUTO——让人去点 URLTest 的成员,点完会被延迟测试算回去。 const chain = resolveChain(runtime, 'PROXY') expect(lastSwitchableGroup(runtime, chain)).toEqual({ - group: 'AUTO', - members: ['[vmess]DMIT', '[vless]Gomami'], - now: '[vless]Gomami', + group: 'PROXY', + members: ['AUTO', 'DIRECT', '[vmess]DMIT', '[vless]Gomami'], + now: 'AUTO', }) }) + it('有多个 Selector 时取最靠近线路的那个', () => { + // 越靠近线路越接近「换线路」这个意图;前面的层换的是「走哪套策略」 + const nested: Record = { + 外层: { 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 = { + AUTO: { type: 'URLTest', now: '线路A', all: ['线路A'] }, + 线路A: { type: 'Vmess' }, + } + expect(lastSwitchableGroup(auto, resolveChain(auto, 'AUTO'))).toBeNull() + }) + it('链条上一个策略组都没有时返回空', () => { expect(lastSwitchableGroup(runtime, ['[vless]Gomami'])).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([]) }) }) diff --git a/shared/outbound-chain.ts b/shared/outbound-chain.ts index ee05af3..8765cf6 100644 --- a/shared/outbound-chain.ts +++ b/shared/outbound-chain.ts @@ -11,12 +11,26 @@ /** 运行时状态里跟解析链路有关的部分。完整定义在 web/api.ts。 */ export interface RuntimeNode { + /** 出站类型。策略组是 Selector / URLTest / Fallback / LoadBalance 之一 */ + type?: string /** 策略组当前选中的成员。不是策略组就没有这个字段 */ now?: 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( runtime: Record, @@ -57,7 +73,23 @@ export function lastSwitchableGroup( for (let i = chain.length - 1; i >= 0; i -= 1) { const name = chain[i]! 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 } + +/** + * 链条上那些自动决定选中结果的组。 + * + * 界面用它来解释「为什么这一层点不了」——不说清楚的话,用户看到链条里有个组却没法 + * 操作,只会以为是坏了。 + */ +export function autoDecidedHops( + runtime: Record, + 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) +} diff --git a/web/views/InboundDetail.vue b/web/views/InboundDetail.vue index 0b82730..02a214a 100644 --- a/web/views/InboundDetail.vue +++ b/web/views/InboundDetail.vue @@ -8,7 +8,7 @@ */ import { computed } from 'vue' 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 { kernel, providerLines, runtime } from '../runtime' 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 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 { const now = props.inbound.outbound @@ -93,13 +97,21 @@ const NO_PROXY_FIELD = '该入站没有 proxy 字段'

流量去向

-

+

+

+ {{ autoHops.map((h) => `${h.name}(${h.type})`).join('、') }} + 自己决定选中哪一条,不接受手动指定 +

+ +

这条链路上没有可手动选择的策略组

@@ -320,6 +333,17 @@ h4 { 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 { color: var(--c-accent-text); font-weight: 500;