Files
mihomo-patchbay/web/views/InboundDetail.vue
T
im0o 69ae918e7c 出站选项改成卡片,带协议和延迟
参照 fl-clash:一张卡三行——名字、协议(策略组显示「类型(当前选中)」)、最近一次延迟。
延迟按 200/500 毫秒分三档着色,没测过就留空而不是显示 0。
这些数据配置文件里都没有,全部来自内核的 /proxies。

另外主区最大宽度从 960 提到 1600,去掉选中行的左侧高亮条。
2026-08-10 12:09:03 +08:00

369 lines
8.7 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 IconLock from '~icons/ri/lock-line'
import IconOffline from '~icons/ri/cloud-off-line'
const props = defineProps<{
inbound: Inbound
groups: string[]
ownProxies: string[]
providerLines: string[]
/** 运行时状态,键是出站名字。内核没连接时是空的 */
runtime: Record<string, ProxyInfo>
kernelOnline: boolean
saving: boolean
}>()
const emit = defineEmits<{ pick: [mode: OutboundMode] }>()
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>
<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 class="pick">
<h3>出站</h3>
<p v-if="locked" class="aside-note">
<IconLock />
由 <code>{{ inbound.legacyKey }}</code> 声明,没有 proxy 字段,只能走 RULE
</p>
<div 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>策略组</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>自有线路</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>订阅线路</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;
}
.pick {
padding-top: var(--s-4);
}
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>