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

链条上现在每一跳都标出类型(PROXY Selector → AUTO URLTest → 线路 Vless),并明说
自动决定的那几层不接受手动指定——不说清楚的话,用户看到链条里有个组却点不了,
只会以为是坏了。
2026-08-10 18:38:59 +08:00

375 lines
10 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 { autoDecidedHops, lastSwitchableGroup, resolveChain } from '@shared/outbound-chain.ts'
import { latestDelay } from '../api'
import { kernel, providerLines, runtime } from '../runtime'
import OutboundCard from '../components/OutboundCard.vue'
import IconOffline from '~icons/ri/cloud-off-line'
import IconEdit from '~icons/ri/edit-line'
import IconDelete from '~icons/ri/delete-bin-line'
const props = defineProps<{
inbound: Inbound
groups: string[]
ownProxies: string[]
/** 流量最终交给哪个出站。走规则的入站要查到规则表末尾的 MATCH */
destination: string | null
/** 正在提交的那个出站的名字。只有它显示忙碌,其余照常能点 */
pendingOutbound: string | null
/** 正在测延迟的线路 */
testing: string[]
}>()
const emit = defineEmits<{
pick: [mode: OutboundMode]
edit: []
remove: []
select: [group: string, member: string]
test: [name: string]
}>()
/** 默认入站没有 proxy 字段可写,「出站」那一块对它是只读的。 */
const locked = computed(() => props.inbound.isDefault)
function describe(name: string): string {
const info = runtime.value[name]
if (!info) return ''
return info.now ? `${info.type}(${info.now})` : info.type
}
const delayOf = (name: string) => latestDelay(runtime.value[name])
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
if (now.kind !== mode.kind) return false
return now.kind !== 'named' || now.name === (mode as { name: string }).name
}
const BUILTINS: Array<{ mode: OutboundMode; label: string; sub: string }> = [
{ mode: { kind: 'rules' }, label: 'RULE', sub: '由规则决定' },
{ mode: { kind: 'direct' }, label: 'DIRECT', sub: '直接连接' },
{ mode: { kind: 'global' }, label: 'GLOBAL', sub: '跟随 GLOBAL 组' },
]
const LINE_LOCKED = '订阅线路无法被入站直接引用,需要通过筛选组间接引入。该功能尚未实现。'
const NO_PROXY_FIELD = '该入站没有 proxy 字段'
</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="!locked" class="acts">
<button type="button" @click="emit('edit')"><IconEdit /> 编辑</button>
<button type="button" class="danger" @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="block">
<h3>流量去向</h3>
<p class="chain">
<template v-for="(hop, i) in chain" :key="hop">
<span v-if="i" class="arrow">→</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>
</p>
<p v-if="autoHops.length" class="hint">
{{ autoHops.map((h) => `${h.name}(${h.type})`).join('、') }}
自己决定选中哪一条,不接受手动指定
</p>
<template v-if="switchable">
<h4>{{ switchable.group }} 的当前选中</h4>
<div class="grid">
<OutboundCard
v-for="m in switchable.members"
:key="m"
:name="m"
:sub="describe(m)"
:delay="delayOf(m)"
:selected="m === switchable.now"
:testing="isTesting(m)"
testable
@pick="emit('select', switchable.group, m)"
@test="emit('test', m)"
/>
</div>
</template>
<p v-else-if="chain.length" class="hint">这条链路上没有可手动选择的策略组</p>
</section>
<!-- 配置:换这路入站指向谁。要写文件、要校验 -->
<section class="block">
<h3>出站</h3>
<p v-if="locked" class="hint">
由 <code>{{ inbound.legacyKey }}</code> 声明。该写法没有 proxy 字段,出站固定为 RULE。
</p>
<div v-if="!locked" class="grid">
<OutboundCard
v-for="b in BUILTINS"
:key="b.label"
:name="b.label"
:sub="b.sub"
:delay="null"
:selected="isCurrentOutbound(b.mode)"
:pending="pendingOutbound === b.label"
@pick="emit('pick', b.mode)"
/>
</div>
<template v-if="groups.length">
<h4 :class="locked && 'is-dim'">策略组</h4>
<div class="grid">
<OutboundCard
v-for="g in groups"
:key="g"
:name="g"
:sub="describe(g)"
:delay="delayOf(g)"
:selected="isCurrentOutbound({ kind: 'named', name: g })"
:disabled-reason="locked ? NO_PROXY_FIELD : undefined"
:pending="pendingOutbound === g"
:testing="isTesting(g)"
testable
@pick="emit('pick', { kind: 'named', name: g })"
@test="emit('test', g)"
/>
</div>
</template>
<template v-if="ownProxies.length">
<h4 :class="locked && 'is-dim'">自有线路</h4>
<div class="grid">
<OutboundCard
v-for="p in ownProxies"
:key="p"
:name="p"
:sub="describe(p)"
:delay="delayOf(p)"
:selected="isCurrentOutbound({ kind: 'named', name: p })"
:disabled-reason="locked ? NO_PROXY_FIELD : undefined"
:pending="pendingOutbound === p"
:testing="isTesting(p)"
testable
@pick="emit('pick', { kind: 'named', name: p })"
@test="emit('test', p)"
/>
</div>
</template>
<h4 :class="locked && 'is-dim'">订阅线路</h4>
<p v-if="!kernel.online" class="hint">
<IconOffline />
内核未连接,无法获取订阅线路
</p>
<p v-else-if="providerLines.length === 0" class="hint">订阅中没有线路</p>
<div v-else class="grid">
<OutboundCard
v-for="line in providerLines"
:key="line"
:name="line"
:sub="describe(line)"
:delay="delayOf(line)"
:selected="false"
:disabled-reason="LINE_LOCKED"
:testing="isTesting(line)"
testable
@test="emit('test', line)"
/>
</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;
}
.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 {
border-color: var(--c-accent);
color: var(--c-accent-text);
}
.acts button.danger:hover {
border-color: var(--c-danger);
color: var(--c-danger);
}
.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;
}
.block {
padding-top: var(--s-4);
}
.block + .block {
margin-top: var(--s-5);
border-top: 1px solid var(--c-border);
}
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);
}
.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);
}
.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;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
gap: var(--s-2);
}
.hint {
display: flex;
align-items: center;
gap: var(--s-2);
color: var(--c-text-faint);
font-size: var(--t-sm);
line-height: 1.6;
}
.hint code {
color: var(--c-text-soft);
}
.is-dim {
opacity: 0.6;
}
</style>