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

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

透传的白名单里为此开了唯一一个写操作,单独一条路由而不是放开整个转发。
2026-08-10 12:44:31 +08:00

544 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed } from 'vue'
import type { Inbound, OutboundMode } from '@shared/inbounds.ts'
import { latestDelay } from '../api'
import type { ProxyInfo } from '../api'
import IconOffline from '~icons/ri/cloud-off-line'
import IconEdit from '~icons/ri/edit-line'
import IconDelete from '~icons/ri/delete-bin-line'
import IconConvert from '~icons/ri/swap-line'
const props = defineProps<{
inbound: Inbound
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: []
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
mode: OutboundMode
label: string
/** 卡片第二行。策略组显示「类型(当前选中)」,线路显示协议 */
sub: string
delay: number | null
disabled: boolean
/** 不可选时的原因,鼠标悬停显示 */
reason?: string
}
/** 默认入站没有 proxy 字段可写,整块都不可选。 */
const locked = computed(() => props.inbound.isDefault)
function describe(name: string): { sub: string; delay: number | null } {
const info = props.runtime[name]
if (!info) return { sub: '', delay: null }
const sub = info.now ? `${info.type}(${info.now})` : info.type
return { sub, delay: latestDelay(info) }
}
const builtins = computed<Option[]>(() =>
(
[
{ mode: { kind: 'rules' } as OutboundMode, label: 'RULE', sub: '走分流规则' },
{ mode: { kind: 'direct' } as OutboundMode, label: 'DIRECT', sub: '不经过代理' },
{ mode: { kind: 'global' } as OutboundMode, label: 'GLOBAL', sub: '跟随全局选择' },
] as const
).map((b) => ({
key: b.label,
mode: b.mode,
label: b.label,
sub: b.sub,
delay: null,
disabled: locked.value,
})),
)
function named(names: string[]): Option[] {
return names.map((name) => {
const { sub, delay } = describe(name)
return {
key: name,
mode: { kind: 'named', name } as OutboundMode,
label: name,
sub,
delay,
disabled: locked.value,
}
})
}
const groupOptions = computed(() => named(props.groups))
const ownOptions = computed(() => named(props.ownProxies))
const lineOptions = computed<Option[]>(() =>
props.providerLines.map((name) => {
const { sub, delay } = describe(name)
return {
key: name,
mode: { kind: 'named', name } as OutboundMode,
label: name,
sub,
delay,
disabled: true,
reason: '订阅线路不能被直接引用,选中它需要自动建一个筛选组——这部分还没做',
}
}),
)
function isCurrent(mode: OutboundMode): boolean {
const now = props.inbound.outbound
if (now.kind !== mode.kind) return false
return now.kind !== 'named' || now.name === (mode as { name: string }).name
}
function delayClass(ms: number | null): string {
if (ms === null) return 'd-none'
if (ms < 200) return 'd-ok'
if (ms < 500) return 'd-warn'
return 'd-bad'
}
</script>
<template>
<aside class="detail">
<header class="top">
<div class="title">
<span class="port mono">{{ inbound.port }}</span>
<span class="name">{{ inbound.name }}</span>
</div>
<div v-if="!inbound.isDefault" class="acts">
<button type="button" :disabled="saving" @click="emit('edit')"><IconEdit /> 编辑</button>
<button type="button" class="danger" :disabled="saving" @click="emit('remove')">
<IconDelete /> 删除
</button>
</div>
<dl class="facts">
<dt>类型</dt>
<dd class="mono">{{ inbound.type }}</dd>
<dt>监听</dt>
<dd class="mono">{{ inbound.listen ?? '0.0.0.0' }}</dd>
<dt>附加规则</dt>
<dd class="mono">{{ inbound.ruleSet ?? '—' }}</dd>
</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>
<div v-if="locked" class="convert">
<p>
由 <code>{{ inbound.legacyKey }}</code> 声明,这种写法没有 proxy 字段,出站固定是
<span class="mono">RULE</span>,下面这些都不能选。
</p>
<button type="button" :disabled="saving" @click="emit('convert')">
<IconConvert /> 转成命名入站
</button>
</div>
<div v-if="!locked" class="grid">
<button
v-for="opt in builtins"
:key="opt.key"
type="button"
:class="['card', isCurrent(opt.mode) && 'is-on']"
:disabled="opt.disabled || saving"
@click="emit('pick', opt.mode)"
>
<span class="card-name">{{ opt.label }}</span>
<span class="card-sub">{{ opt.sub }}</span>
</button>
</div>
<template v-if="groupOptions.length">
<h4 :class="locked && 'is-dim'">策略组</h4>
<div class="grid">
<button
v-for="opt in groupOptions"
:key="opt.key"
type="button"
:class="['card', isCurrent(opt.mode) && 'is-on']"
:disabled="opt.disabled || saving"
@click="emit('pick', opt.mode)"
>
<span class="card-name">{{ opt.label }}</span>
<span class="card-sub">{{ opt.sub || '—' }}</span>
<span :class="['card-delay', delayClass(opt.delay)]">
{{ opt.delay === null ? '' : `${opt.delay} ms` }}
</span>
</button>
</div>
</template>
<template v-if="ownOptions.length">
<h4 :class="locked && 'is-dim'">自有线路</h4>
<div class="grid">
<button
v-for="opt in ownOptions"
:key="opt.key"
type="button"
:class="['card', isCurrent(opt.mode) && 'is-on']"
:disabled="opt.disabled || saving"
@click="emit('pick', opt.mode)"
>
<span class="card-name">{{ opt.label }}</span>
<span class="card-sub">{{ opt.sub || '—' }}</span>
<span :class="['card-delay', delayClass(opt.delay)]">
{{ opt.delay === null ? '' : `${opt.delay} ms` }}
</span>
</button>
</div>
</template>
<h4 :class="locked && 'is-dim'">订阅线路</h4>
<p v-if="!kernelOnline" class="aside-note">
<IconOffline />
内核未连接。订阅里有哪些线路只有运行中的内核知道
</p>
<p v-else-if="lineOptions.length === 0" class="aside-note">订阅里没有线路</p>
<div v-else class="grid">
<button
v-for="opt in lineOptions"
:key="opt.key"
type="button"
class="card"
disabled
:title="opt.reason"
>
<span class="card-name">{{ opt.label }}</span>
<span class="card-sub">{{ opt.sub || '—' }}</span>
<span :class="['card-delay', delayClass(opt.delay)]">
{{ opt.delay === null ? '' : `${opt.delay} ms` }}
</span>
</button>
</div>
</section>
</aside>
</template>
<style scoped>
.detail {
border: 1px solid var(--c-border);
border-radius: var(--r-md);
background: var(--c-surface);
padding: var(--s-4) var(--s-5) var(--s-5);
align-self: start;
position: sticky;
top: var(--s-6);
}
.top {
padding-bottom: var(--s-4);
border-bottom: 1px solid var(--c-border);
}
.title {
display: flex;
align-items: baseline;
gap: var(--s-3);
margin-bottom: var(--s-3);
}
.port {
font-size: var(--t-xl);
font-variant-numeric: tabular-nums;
}
.name {
color: var(--c-text-soft);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.facts {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--s-1) var(--s-4);
margin: 0;
font-size: var(--t-sm);
}
.facts dt {
color: var(--c-text-faint);
}
.facts dd {
margin: 0;
}
.acts {
display: flex;
gap: var(--s-2);
margin-bottom: var(--s-3);
}
.acts button {
display: flex;
align-items: center;
gap: var(--s-1);
padding: var(--s-1) var(--s-3);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-surface);
font-size: var(--t-sm);
cursor: pointer;
}
.acts button:hover:not(:disabled) {
border-color: var(--c-accent);
color: var(--c-accent-text);
}
.acts button.danger:hover:not(:disabled) {
border-color: var(--c-danger);
color: var(--c-danger);
}
.acts button:disabled {
color: var(--c-text-faint);
cursor: not-allowed;
}
.convert {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--s-3);
padding: var(--s-3);
border: 1px solid var(--c-border);
border-radius: var(--r-sm);
background: var(--c-surface-sunken);
margin-bottom: var(--s-4);
}
.convert p {
color: var(--c-text-soft);
font-size: var(--t-sm);
line-height: 1.6;
}
.convert button {
display: flex;
align-items: center;
gap: var(--s-2);
padding: var(--s-2) var(--s-3);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-surface);
font-size: var(--t-sm);
cursor: pointer;
}
.convert button:hover:not(:disabled) {
border-color: var(--c-accent);
color: var(--c-accent-text);
}
.is-dim {
opacity: 0.6;
}
.pick {
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;
color: var(--c-text-soft);
margin-bottom: var(--s-3);
}
h4 {
font-size: var(--t-xs);
font-weight: 500;
color: var(--c-text-faint);
margin: var(--s-5) 0 var(--s-2);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
gap: var(--s-2);
}
.card {
display: flex;
flex-direction: column;
gap: var(--s-1);
min-width: 0;
padding: var(--s-3);
border: 1px solid var(--c-border);
border-radius: var(--r-sm);
background: var(--c-surface-sunken);
text-align: left;
cursor: pointer;
}
.card:hover:not(:disabled) {
border-color: var(--c-accent);
}
.card.is-on {
border-color: var(--c-accent);
background: var(--c-accent-soft);
}
.card:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.card-name {
font-size: var(--t-sm);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card.is-on .card-name {
color: var(--c-accent-text);
}
.card-sub {
font-size: var(--t-xs);
color: var(--c-text-faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-delay {
font-family: var(--f-mono);
font-size: var(--t-xs);
font-variant-numeric: tabular-nums;
min-height: 1.2em;
}
.d-ok {
color: var(--c-ok);
}
.d-warn {
color: var(--c-warn);
}
.d-bad {
color: var(--c-danger);
}
.d-none {
color: transparent;
}
.aside-note {
display: flex;
align-items: center;
gap: var(--s-2);
color: var(--c-text-faint);
font-size: var(--t-sm);
margin-bottom: var(--s-3);
line-height: 1.6;
}
.aside-note code {
color: var(--c-text-soft);
}
</style>