Files
mihomo-patchbay/web/views/InboundDetail.vue
T
im0o f97981b146 默认入站:显示可选出站,并给一条转成命名入站的路
上一版把默认入站的整个出站块都藏了,结果策略组也看不见。改成只藏 RULE/DIRECT/GLOBAL
那三张卡(它固定是 RULE),策略组和线路照常列出来当上下文,只是不可点。

顺带修一个显示错误:老式顶层端口没有 listen 字段,实际地址是内核从 allow-lan 和
bind-address 推出来的(listener/listener.go:709-718)——allow-lan 关着时绑的是
127.0.0.1,而界面一律显示 0.0.0.0,会让人以为局域网连得上。

转换时会把老式端口字段删掉并把推导出的地址显式写进 listen:不删会导致同一个端口被声明
两次内核起不来,不写 listen 则以后改 allow-lan 会连带影响这个端口。
2026-08-10 12:37:33 +08:00

455 lines
11 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[]
/** 运行时状态,键是出站名字。内核没连接时是空的 */
runtime: Record<string, ProxyInfo>
kernelOnline: boolean
saving: boolean
}>()
const emit = defineEmits<{ pick: [mode: OutboundMode]; edit: []; remove: []; convert: [] }>()
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 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);
}
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>