Files
mihomo-patchbay/web/views/InboundDetail.vue
T
im0o 5cdc183190 去掉转换入口,加测延迟,改掉刷新时丢状态的毛病
最要紧的是第三条:之前每次改完都会先切到「读取中」,页面被拆掉重建,
选中的那一路入站随之丢失,改个设置还得重新找回原来的位置。现在只有首次加载显示占位,
之后都是原地更新。切换策略组还做成了乐观更新——那一步不写配置文件,
没必要让点击等一个往返,真失败了随后的刷新会纠正回来。

测延迟按钮让内核并发测整个策略组。测不通是关于线路的结论而不是 Patchbay 出了问题,
所以全部超时用中性提示,不弹红框。
2026-08-10 12:53:56 +08:00

545 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 IconFlash from '~icons/ri/flashlight-line'
const props = defineProps<{
inbound: Inbound
groups: string[]
ownProxies: string[]
providerLines: string[]
/** 流量最终交给哪个出站。走规则的入站要查到规则表末尾的 MATCH */
destination: string | null
/** 运行时状态,键是出站名字。内核没连接时是空的 */
runtime: Record<string, ProxyInfo>
kernelOnline: boolean
saving: boolean
/** 正在测延迟 */
testing: boolean
}>()
const emit = defineEmits<{
pick: [mode: OutboundMode]
edit: []
remove: []
select: [group: string, member: string]
test: [group: 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">
<div class="sub-head">
<h4>{{ switchable.group }} 当前选中</h4>
<button
type="button"
class="flash"
:disabled="testing"
:title="`测试 ${switchable.group} 里所有线路的延迟`"
@click="emit('test', switchable.group)"
>
<IconFlash />
</button>
</div>
<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>
<p v-if="locked" class="aside-note">
由 <code>{{ inbound.legacyKey }}</code> 声明,这种写法没有 proxy 字段,出站固定是 RULE
</p>
<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;
}
.is-dim {
opacity: 0.6;
}
.sub-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-2);
}
.flash {
display: grid;
place-items: center;
width: 26px;
height: 26px;
border: 1px solid transparent;
border-radius: var(--r-sm);
background: none;
color: var(--c-text-faint);
cursor: pointer;
}
.flash:hover:not(:disabled) {
background: var(--c-accent-soft);
color: var(--c-accent-text);
}
.flash:disabled {
cursor: progress;
opacity: 0.5;
}
.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>