出站选项改成卡片,带协议和延迟

参照 fl-clash:一张卡三行——名字、协议(策略组显示「类型(当前选中)」)、最近一次延迟。
延迟按 200/500 毫秒分三档着色,没测过就留空而不是显示 0。
这些数据配置文件里都没有,全部来自内核的 /proxies。

另外主区最大宽度从 960 提到 1600,去掉选中行的左侧高亮条。
This commit is contained in:
im0o
2026-08-10 12:09:03 +08:00
parent 206e14c7e7
commit 69ae918e7c
4 changed files with 225 additions and 80 deletions
+185 -76
View File
@@ -1,39 +1,107 @@
<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[]
/** 顶层 proxies 里手写的线路 */
ownProxies: string[]
/** 订阅带来的线路。只有内核在线时才拿得到 */
providerLines: string[]
/** 运行时状态,键是出站名字。内核没连接时是空的 */
runtime: Record<string, ProxyInfo>
kernelOnline: boolean
saving: boolean
}>()
const emit = defineEmits<{ pick: [mode: OutboundMode] }>()
const BUILTINS: Array<{ mode: OutboundMode; label: string; hint: string }> = [
{ mode: { kind: 'rules' }, label: 'RULE', hint: '不指定出站,流量走分流规则' },
{ mode: { kind: 'direct' }, label: 'DIRECT', hint: '不经过任何代理' },
{ mode: { kind: 'global' }, label: 'GLOBAL', hint: '跟随 GLOBAL 组当前选中的出站' },
]
interface Option {
key: string
mode: OutboundMode
label: string
/** 卡片第二行。策略组显示「类型(当前选中)」,线路显示协议 */
sub: string
delay: number | null
disabled: boolean
/** 不可选时的原因,鼠标悬停显示 */
reason?: string
}
const current = computed(() => props.inbound.outbound)
/** 默认入站没有 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 = current.value
const now = props.inbound.outbound
if (now.kind !== mode.kind) return false
return now.kind !== 'named' || now.name === (mode as { name: string }).name
}
/** 默认入站没有 proxy 字段可写,出站只能是 RULE。 */
const locked = computed(() => props.inbound.isDefault)
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>
@@ -56,73 +124,85 @@ const locked = computed(() => props.inbound.isDefault)
<section class="pick">
<h3>出站</h3>
<p v-if="locked" class="locked">
<p v-if="locked" class="aside-note">
<IconLock />
由 <code>{{ inbound.legacyKey }}</code> 声明,这种写法没有 proxy 字段,只能走 RULE
由 <code>{{ inbound.legacyKey }}</code> 声明,没有 proxy 字段,只能走 RULE
</p>
<div class="chips">
<div class="grid">
<button
v-for="b in BUILTINS"
:key="b.label"
v-for="opt in builtins"
:key="opt.key"
type="button"
:class="['chip', isCurrent(b.mode) && 'is-on']"
:disabled="locked || saving"
:title="b.hint"
@click="emit('pick', b.mode)"
:class="['card', isCurrent(opt.mode) && 'is-on']"
:disabled="opt.disabled || saving"
@click="emit('pick', opt.mode)"
>
{{ b.label }}
<span class="card-name">{{ opt.label }}</span>
<span class="card-sub">{{ opt.sub }}</span>
</button>
</div>
<template v-if="groups.length">
<template v-if="groupOptions.length">
<h4>策略组</h4>
<div class="chips">
<div class="grid">
<button
v-for="g in groups"
:key="g"
v-for="opt in groupOptions"
:key="opt.key"
type="button"
:class="['chip', isCurrent({ kind: 'named', name: g }) && 'is-on']"
:disabled="locked || saving"
@click="emit('pick', { kind: 'named', name: g })"
:class="['card', isCurrent(opt.mode) && 'is-on']"
:disabled="opt.disabled || saving"
@click="emit('pick', opt.mode)"
>
{{ g }}
<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="ownProxies.length">
<template v-if="ownOptions.length">
<h4>自有线路</h4>
<div class="chips">
<div class="grid">
<button
v-for="p in ownProxies"
:key="p"
v-for="opt in ownOptions"
:key="opt.key"
type="button"
:class="['chip', isCurrent({ kind: 'named', name: p }) && 'is-on']"
:disabled="locked || saving"
@click="emit('pick', { kind: 'named', name: p })"
:class="['card', isCurrent(opt.mode) && 'is-on']"
:disabled="opt.disabled || saving"
@click="emit('pick', opt.mode)"
>
{{ p }}
<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="unavailable">
<p v-if="!kernelOnline" class="aside-note">
<IconOffline />
内核未连接。订阅里有哪些线路只有运行中的内核知道,配置文件里只有订阅地址
内核未连接。订阅里有哪些线路只有运行中的内核知道
</p>
<p v-else-if="providerLines.length === 0" class="unavailable">订阅里没有线路</p>
<div v-else class="chips">
<p v-else-if="lineOptions.length === 0" class="aside-note">订阅里没有线路</p>
<div v-else class="grid">
<button
v-for="line in providerLines"
:key="line"
v-for="opt in lineOptions"
:key="opt.key"
type="button"
class="chip is-todo"
class="card"
disabled
title="选中会自动建一个只装这条线路的筛选组——这部分还没做"
:title="opt.reason"
>
{{ line }}
<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>
@@ -196,54 +276,83 @@ h4 {
font-size: var(--t-xs);
font-weight: 500;
color: var(--c-text-faint);
margin: var(--s-4) 0 var(--s-2);
margin: var(--s-5) 0 var(--s-2);
}
.chips {
display: flex;
flex-wrap: wrap;
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
gap: var(--s-2);
}
.chip {
padding: var(--s-1) var(--s-3);
border: 1px solid var(--c-border-strong);
.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);
font-family: var(--f-mono);
font-size: var(--t-sm);
background: var(--c-surface-sunken);
text-align: left;
cursor: pointer;
max-width: 100%;
}
.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;
}
.chip:hover:not(:disabled) {
border-color: var(--c-accent);
.card.is-on .card-name {
color: var(--c-accent-text);
}
.chip.is-on {
background: var(--c-accent-soft);
border-color: var(--c-accent);
color: var(--c-accent-text);
font-weight: 500;
}
.chip:disabled {
cursor: not-allowed;
.card-sub {
font-size: var(--t-xs);
color: var(--c-text-faint);
border-color: var(--c-border);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chip.is-on:disabled {
color: var(--c-accent-text);
border-color: color-mix(in srgb, var(--c-accent) 45%, transparent);
.card-delay {
font-family: var(--f-mono);
font-size: var(--t-xs);
font-variant-numeric: tabular-nums;
min-height: 1.2em;
}
.locked,
.unavailable {
.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);
@@ -253,7 +362,7 @@ h4 {
line-height: 1.6;
}
.locked code {
.aside-note code {
color: var(--c-text-soft);
}
</style>