Files
mihomo-patchbay/web/views/InboundDetail.vue
T
im0o e8b8181ef6 本地开发换成真订阅;闪电挪到卡片上;忙碌状态改成按卡片
占位线路删了。它们指向 127.0.0.1:1080,从来没测通过,而 URLTest 只在你选的那条
「对测试地址是活的」时才认账(urltest.go:106-111)——所以点了立刻被切回去,
一个正常行为看起来像 bug。现在本地内核直接用真订阅,通过隧道拉。

订阅地址带访问令牌,不能进 git:dev/config.yaml 变成模板,dev:reset 从 .env 取值生成
dev/home/config.yaml。顺带确认内核认 base64 分享链接格式(provider.go:384-390 回退到
ConvertsV2Ray),所以 Sub-Store 的输出直接能用。

闪电从组标题挪到每张卡的左下角,也就是延迟显示的位置:测过显示毫秒数,没测过显示闪电,
两种都能点,点了测这一条。忙碌状态从「禁用整个界面」改成只有正在提交的那张卡显示虚线边框。
2026-08-10 13:21:27 +08:00

376 lines
9.9 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 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[]
providerLines: string[]
runtime: Record<string, ProxyInfo>
/** 流量最终交给哪个出站。走规则的入站要查到规则表末尾的 MATCH */
destination: string | null
kernelOnline: boolean
/** 正在提交的那个出站的名字。只有它显示忙碌,其余照常能点 */
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 = props.runtime[name]
if (!info) return ''
return info.now ? `${info.type}(${info.now})` : info.type
}
const delayOf = (name: string) => latestDelay(props.runtime[name])
const isTesting = (name: string) => props.testing.includes(name)
/**
* 从落脚的出站一路跟着「当前选中」往下走,直到走到一条真正的线路。
*
* 策略组可以选中另一个策略组,所以「这个端口现在走哪条线」往往要跳好几层。
* 只显示第一层等于没回答问题。
*/
const chain = computed(() => {
const hops: string[] = []
let cursor = props.destination
while (cursor && !hops.includes(cursor)) {
hops.push(cursor)
cursor = props.runtime[cursor]?.now ?? null
}
return hops
})
/** 链条上最后一个还是策略组的环节——用户能在这里换选中。 */
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
})
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: '跟随全局选择' },
]
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 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">
<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>
</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="!kernelOnline" 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);
}
.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>