补上真正日常要点的那个动作:切换策略组当前选中谁

之前的面板只做了「这个入站指向哪个出站」,那是改配置文件。但 metacubexd 里点一条线路
改的根本不是入站,是策略组的当前选中——调 PUT /proxies/{组},不写文件、立刻生效。
mixed-port 走规则表,末尾 MATCH,PROXY 把流量交给 PROXY 组,所以换那个组的选中就等于
换了这个端口的出口。这是每天都要点的动作,之前整个漏了。

面板顶部现在显示完整链条(PROXY → AUTO → 某条线路),并让人在最后一个策略组上换选中。
只显示第一层等于没回答「这个端口现在走哪条线」。

透传的白名单里为此开了唯一一个写操作,单独一条路由而不是放开整个转发。
This commit is contained in:
im0o
2026-08-10 12:44:31 +08:00
parent f97981b146
commit 0062a261f7
7 changed files with 234 additions and 4 deletions
+90 -1
View File
@@ -13,13 +13,49 @@ const props = defineProps<{
groups: string[]
ownProxies: string[]
providerLines: string[]
/** 流量最终交给哪个出站。走规则的入站要查到规则表末尾的 MATCH */
destination: string | null
/** 运行时状态,键是出站名字。内核没连接时是空的 */
runtime: Record<string, ProxyInfo>
kernelOnline: boolean
saving: boolean
}>()
const emit = defineEmits<{ pick: [mode: OutboundMode]; edit: []; remove: []; convert: [] }>()
const emit = defineEmits<{
pick: [mode: OutboundMode]
edit: []
remove: []
convert: []
select: [group: string, member: string]
}>()
/**
* 从落脚的出站一路跟着「当前选中」往下走,直到走到一条真正的线路。
*
* 策略组可以选中另一个策略组,所以「这个端口现在走哪条线」往往要跳好几层:
* PROXY 选了 AUTO,AUTO 选了某条线路。只显示第一层等于没回答问题。
*/
function resolveChain(start: string | null): string[] {
const chain: string[] = []
let cursor = start
while (cursor && !chain.includes(cursor)) {
chain.push(cursor)
cursor = props.runtime[cursor]?.now ?? null
}
return chain
}
const chain = computed(() => resolveChain(props.destination))
/** 链条上最后一个还是策略组的环节——用户能在这里换选中。 */
const switchable = computed(() => {
for (let i = chain.value.length - 1; i >= 0; i -= 1) {
const name = chain.value[i]!
const info = props.runtime[name]
if (info?.all?.length) return { group: name, members: info.all, now: info.now ?? '' }
}
return null
})
interface Option {
key: string
@@ -130,6 +166,35 @@ function delayClass(ms: number | null): string {
</dl>
</header>
<section v-if="chain.length" class="pick">
<h3>流量去向</h3>
<p class="chain mono">
<template v-for="(hop, i) in chain" :key="hop">
<span v-if="i" class="arrow">→</span><span :class="i === chain.length - 1 && 'tail'">{{ hop }}</span>
</template>
</p>
<template v-if="switchable">
<h4>{{ switchable.group }} 当前选中</h4>
<div class="grid">
<button
v-for="m in switchable.members"
:key="m"
type="button"
:class="['card', m === switchable.now && 'is-on']"
:disabled="saving"
@click="emit('select', switchable.group, m)"
>
<span class="card-name">{{ m }}</span>
<span class="card-sub">{{ runtime[m]?.now ? `${runtime[m]!.type}(${runtime[m]!.now})` : (runtime[m]?.type ?? '—') }}</span>
<span :class="['card-delay', delayClass(latestDelay(runtime[m]))]">
{{ latestDelay(runtime[m]) === null ? '' : `${latestDelay(runtime[m])} ms` }}
</span>
</button>
</div>
</template>
</section>
<section class="pick">
<h3>出站</h3>
@@ -351,6 +416,30 @@ function delayClass(ms: number | null): string {
padding-top: var(--s-4);
}
.pick + .pick {
margin-top: var(--s-5);
border-top: 1px solid var(--c-border);
}
.chain {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--s-2);
font-size: var(--t-sm);
color: var(--c-text-soft);
margin-bottom: var(--s-2);
}
.chain .arrow {
color: var(--c-text-faint);
}
.chain .tail {
color: var(--c-accent-text);
font-weight: 500;
}
h3 {
font-size: var(--t-sm);
font-weight: 600;
+26 -1
View File
@@ -1,6 +1,11 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { collectInbounds, collectOutboundNames, outboundToProxyField } from '@shared/inbounds.ts'
import {
collectInbounds,
collectOutboundNames,
outboundToProxyField,
resolveDestination,
} from '@shared/inbounds.ts'
import type { Inbound, OutboundMode } from '@shared/inbounds.ts'
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
import { ApiError, api } from '../api'
@@ -162,6 +167,24 @@ async function submitForm(entry: ConfigObject) {
if (editingIndex === null) selectedId.value = `listener:${String(listeners.length - 1)}`
}
/** 切换策略组当前选中谁。运行时动作,不写配置文件,所以不走那条落盘链路。 */
async function selectMember(group: string, member: string) {
if (saving.value) return
saving.value = true
failure.value = null
done.value = ''
try {
await api.selectProxy(group, member)
done.value = `${group} 切到了 ${member}`
emit('changed')
} catch (error) {
const message = error instanceof ApiError ? String(error.payload.error ?? error.message) : String(error)
failure.value = { message, hint: '这一步只改内核的运行状态,配置文件没有被动过。' }
} finally {
saving.value = false
}
}
async function removeSelected() {
const target = selected.value
if (!target || target.listenerIndex === null || saving.value) return
@@ -235,6 +258,7 @@ async function removeSelected() {
:groups="outbounds.groups"
:own-proxies="outbounds.ownProxies"
:provider-lines="providerLines"
:destination="resolveDestination(data, selected)"
:runtime="runtime"
:kernel-online="kernelOnline"
:saving="saving"
@@ -242,6 +266,7 @@ async function removeSelected() {
@edit="openEdit"
@remove="removeSelected"
@convert="openConvert"
@select="selectMember"
/>
</div>