Files
mihomo-patchbay/web/views/InboundDetail.vue
T
im0o cb8effe05b 把入站固定到某条订阅线路
订阅线路不在内核的全局名字表里,入站的 proxy 指不到它(决策记录 0005)。做法是自动建
一个只装它的策略组再指过去,用户看到的只是「这个口固定走这条线」,组是实现细节:
不出现在策略组列表里,列表和详情里都显示成线路本身的名字,没人再引用时自动删掉。

筛选条件必须做对两件事,都写了测试:转义正则元字符(线路名形如 [vmess]DMIT.LAX.Pro
里的方括号和点不转义会匹配错),以及前后加锚点(内核是部分匹配,不加锚点时「香港01」
会把「香港011」也捞进来)。线路名含反引号时表达不了——那是内核切分多个筛选条件的
分隔符——这种情况明说做不到而不是生成一个会静默匹配错的组。

实测:生成的配置 mihomo -t 通过;起一个临时内核问 /proxies,这个组解析出来的成员
恰好只有那一条线路,且确实进了名字表所以入站指得到。
2026-08-10 19:33:44 +08:00

357 lines
9.3 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 { lastSwitchableGroup, resolveChain } from '@shared/outbound-chain.ts'
import { generatedGroupName } from '@shared/filter-groups.ts'
import { latestDelay } from '../api'
import { kernel, providerLines, runtime } from '../runtime'
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[]
/** 流量最终交给哪个出站。走规则的入站要查到规则表末尾的 MATCH */
destination: string | null
/** 正在提交的那个出站的名字。只有它显示忙碌,其余照常能点 */
pendingOutbound: string | null
/** 正在测延迟的线路 */
testing: string[]
}>()
const emit = defineEmits<{
pick: [mode: OutboundMode]
edit: []
remove: []
select: [group: string, member: string]
test: [name: string]
/** 把这一路入站固定到某条订阅线路 */
pin: [line: string]
}>()
/**
* 默认入站由配置顶层的端口字段声明,没有 proxy 字段可写,也不能删。
* 它的「出站」和编辑删除按钮都不显示。
*/
const locked = computed(() => props.inbound.isDefault)
function describe(name: string): string {
const info = runtime.value[name]
if (!info) return ''
return info.now ? `${info.type}(${info.now})` : info.type
}
const delayOf = (name: string) => latestDelay(runtime.value[name])
const isTesting = (name: string) => props.testing.includes(name)
const chain = computed(() => resolveChain(runtime.value, props.destination))
const switchable = computed(() => lastSwitchableGroup(runtime.value, chain.value))
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: '跟随 GLOBAL 组' },
]
/** 这一路入站是不是已经固定在某条订阅线路上。 */
function isPinnedTo(line: string): boolean {
const mode = props.inbound.outbound
return mode.kind === 'named' && mode.name === generatedGroupName(line)
}
</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>
<template v-if="inbound.ruleSet">
<dt>附加规则</dt>
<dd class="mono">{{ inbound.ruleSet }}</dd>
</template>
</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>
<!--
配置:换这路入站指向谁。要写文件、要校验。
默认入站没有 proxy 字段可写,整块不显示——画一排点不了的卡片只会让人以为是坏了。
-->
<section v-if="!locked" class="block">
<h3>出站</h3>
<div 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>策略组</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 })"
: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>自有线路</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 })"
:pending="pendingOutbound === p"
:testing="isTesting(p)"
testable
@pick="emit('pick', { kind: 'named', name: p })"
@test="emit('test', p)"
/>
</div>
</template>
<h4>订阅线路</h4>
<p v-if="!kernel.online" 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="isPinnedTo(line)"
:pending="pendingOutbound === generatedGroupName(line)"
:testing="isTesting(line)"
testable
@pick="emit('pin', line)"
@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);
}
</style>