Files
mihomo-patchbay/web/views/InboundDetail.vue
T
im0o e9e2eb555d 每路入站配一组专属策略组,换线不再改配置文件
原本换一条线要改 config.yaml、跑校验、等内核重载,几秒起步;而换线恰恰是这个工具
最高频的动作,把它放在最重的路径上方向就错了。现在每一路配一个 select 组加 AUTO、
FALLBACK 两个,入站的 proxy 永久指向 select,换线只是调一次内核接口。这样做的前提是
选中不会丢:内核把它写进 cache 文件,重启后由 patchSelectGroup 恢复,而
profile.store-selected 默认为真。

链式代理靠给来源订阅复制一份声明、挂上 override.dialer-proxy 实现,因为这个字段写在
策略组上内核只会打条日志然后忽略。副本是 type: file 读原订阅已经落盘的缓存,而不是
自己再拉一次:自己拉的话订阅地址一不可达副本就是空的,那一路入站会一条线都不剩,
表现是端口还在监听却没有任何出口。

顺带把 shared/outbound-chain.ts 里「只有 Selector 接受手动选择」那条断言删了,它是
错的(内核检查的是 SelectAble 接口,URLTest 和 Fallback 也实现了)。界面仍然只让
Selector 可点,但理由换成了另外两种的手动指定会被它们自己撤销。

完整取舍见工作区决策记录 0007。
2026-08-11 03:10:38 +08:00

701 lines
19 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">
/**
* 选中那一路入站的详情。
*
* 上下两半,界限就是高级配置上面那条分割线:
* 上半 点卡片立刻生效。换的是策略组当前选中谁,不写配置文件(决策记录 0007)
* 下半 高级配置。改的是组从哪些订阅取成员,写配置文件,要校验和重载
*
* 两半的反馈方式必须不一样。它们长得一样的时候,用户分不清哪个动作要等、哪个不用。
*/
import { computed, ref, watch } from 'vue'
import type { Inbound } from '@shared/inbounds.ts'
import { displayName, linesMatching } from '@shared/inbound-groups.ts'
import type { OutboundSetup, PanelGroup } from '@shared/inbound-groups.ts'
import { resolveChain } from '@shared/outbound-chain.ts'
import { latestDelay } from '../api'
import type { GroupView } from '../runtime'
import { isChainedLine, kernel, linesFrom, membersOf, runtime } from '../runtime'
import OutboundCard from '../components/OutboundCard.vue'
import SourcePicker from '../components/SourcePicker.vue'
import IconEdit from '~icons/ri/edit-line'
import IconDelete from '~icons/ri/delete-bin-line'
import IconDown from '~icons/ri/arrow-down-s-line'
import IconUp from '~icons/ri/arrow-up-s-line'
import IconOffline from '~icons/ri/cloud-off-line'
const props = defineProps<{
inbound: Inbound
/** 能操作哪些策略组、各自能不能点。groups 为空表示这一路没有能点的组 */
panel: { groups: PanelGroup[]; shared: boolean }
/** 链式代理的入口组。null 表示没开 */
chain: GroupView | null
/** 配置里定义过的订阅,高级配置里从这里挑来源 */
subscriptions: string[]
/** 当前的来源和筛选,高级配置的表单初值 */
setup: OutboundSetup | null
/** 正在测延迟的线路 */
testing: string[]
/** 高级配置正在保存 */
saving: boolean
}>()
const emit = defineEmits<{
edit: []
remove: []
/** 切换某个策略组当前选中谁 */
select: [group: string, member: string]
test: [name: string, provider: string]
/** 保存高级配置 */
save: [setup: OutboundSetup]
/** 把这一路改成专属代理组模式 */
adopt: []
}>()
/** 默认入站由配置顶层的端口字段声明,没有 proxy 字段可写,也不能删。 */
const locked = computed(() => props.inbound.isDefault)
const activeTab = ref('')
watch(
() => props.panel.groups,
(list) => {
if (!list.some((t) => t.name === activeTab.value)) activeTab.value = list[0]?.name ?? ''
},
{ immediate: true },
)
const activeGroup = computed(
() => props.panel.groups.find((g) => g.name === activeTab.value) ?? null,
)
/**
* 表单里开了链式代理但还没保存。
*
* 这时内核里还没有入口组,卡片点不了,但候选线路是知道的,先按只读画出来。开关拨完
* 一片空白、要按了保存才看得见东西,会让人以为开关没生效。
*/
const chainPreview = computed(() =>
props.chain === null && form.value.chain ? linesFrom(form.value.chain.sources) : [],
)
const proxyLines = computed(() => (activeGroup.value ? membersOf(activeGroup.value) : []))
const chainLines = computed(() => (props.chain ? membersOf(props.chain) : []))
const nowOf = (group: { name: string } | null) =>
group ? (runtime.value[group.name]?.now ?? '') : ''
const isTesting = (name: string) => props.testing.includes(name)
/**
* 流量最终走到哪。
*
* 从代理组当前选中的那个开始一路跟着「当前选中」查下去。选中的是一条线路就到此为止,
* 选中的是 `AUTO` 这种自动测速组就还要再查一层,否则只显示一个组名等于没回答。
*/
const exitChain = computed(() =>
resolveChain(runtime.value, nowOf(props.panel.groups[0] ?? null) || null),
)
/**
* 这一路当前这条出口是不是真的经过入口。
*
* 开了链式代理不等于每条出口都走中转:`DIRECT` 是点名加进代理组的成员,不属于任何订阅,
* 身上没有 `dialer-proxy`,选中它就是直连。这时画一跳入口是在骗人。
*/
const throughChain = computed(() => {
const exit = exitChain.value.at(-1)
return exit !== undefined && props.chain !== null && isChainedLine(exit)
})
// ── 高级配置 ────────────────────────────────────────────────
const open = ref(false)
const form = ref<OutboundSetup>({ proxy: { sources: [], filter: null }, chain: null })
function reset() {
form.value = props.setup
? (JSON.parse(JSON.stringify(props.setup)) as OutboundSetup)
: { proxy: { sources: [...props.subscriptions], filter: null }, chain: null }
}
// 切到别的入站时表单要跟着换,否则会把上一路的设置保存到这一路上
watch(() => [props.inbound.id, props.setup], reset, { immediate: true, deep: true })
const chainOn = computed({
get: () => form.value.chain !== null,
set: (on: boolean) => {
form.value.chain = on ? { sources: [...props.subscriptions], filter: null } : null
},
})
/** 当前筛选条件会留下几条线路。写错了 kept 是 null,界面据此提示而不是显示错的数字。 */
function preview(spec: { sources: string[]; filter: string | null }) {
const names = linesFrom(spec.sources)
return { total: names.length, kept: linesMatching(spec.filter, names)?.length ?? null }
}
const dirty = computed(() => JSON.stringify(form.value) !== JSON.stringify(props.setup))
/**
* 至少要选一个订阅。AUTO 和 FALLBACK 只有 `use:` 没有点名成员,一个订阅都不选就是空组,
* 内核会直接拒绝整份配置(内核源码 adapter/outboundgroup/parser.go:117-119)。
* 在这里拦住,比让用户按下保存再读一段内核报错好。
*/
const filled = (spec: { sources: string[] }) => spec.sources.length > 0
const complete = computed(
() => filled(form.value.proxy) && (form.value.chain === null || filled(form.value.chain)),
)
</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 v-if="!locked" class="acts">
<button type="button" @click="emit('edit')"><IconEdit /> 编辑</button>
<button type="button" class="danger" @click="emit('remove')">
<IconDelete /> 删除
</button>
</div>
</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>
<template v-if="exitChain.length">
<dt>流量去向</dt>
<dd class="flow mono">
<template v-if="throughChain">
<span class="hop is-chain">{{ displayName(nowOf(chain)) || '未选择' }}</span>
<span class="arrow">→</span>
</template>
<template v-for="(hop, i) in exitChain" :key="hop">
<span v-if="i" class="arrow">→</span>
<span :class="['hop', i === exitChain.length - 1 && 'is-proxy']">{{
displayName(hop)
}}</span>
</template>
</dd>
</template>
</dl>
</header>
<!-- 链条上一个能点的组都没有:只能说明现状 -->
<section v-if="panel.groups.length === 0" class="block">
<p class="hint">没有可切换的策略组。</p>
<button v-if="!locked" type="button" class="adopt" :disabled="saving" @click="emit('adopt')">
改成逐条选线路
</button>
</section>
<template v-else>
<section class="block">
<h3>
代理
<span
v-if="panel.shared"
class="tag"
:title="`换的是 ${panel.groups[0]?.name},所有走分流规则的入站共用它`"
>共用</span
>
</h3>
<div v-if="panel.groups.length > 1" class="tabs">
<button
v-for="t in panel.groups"
:key="t.name"
type="button"
:class="['tab', activeTab === t.name && 'is-on']"
@click="activeTab = t.name"
>
{{ t.label }}
</button>
</div>
<p v-if="!kernel.online" class="hint"><IconOffline /> 内核未连接</p>
<p v-else-if="proxyLines.length === 0" class="hint">这个策略组现在没有成员</p>
<div v-else class="grid">
<OutboundCard
v-for="line in proxyLines"
:key="line.name"
:name="displayName(line.name)"
:sub="line.info?.type ?? ''"
:delay="latestDelay(line.info)"
:selected="line.name === nowOf(activeGroup)"
:disabled-reason="activeGroup?.pickable ? undefined : '这个组自己决定用哪条'"
:testing="isTesting(line.name)"
testable
tone="proxy"
@pick="emit('select', activeTab, line.name)"
@test="emit('test', line.name, line.provider)"
/>
</div>
</section>
<section v-if="chain || chainPreview.length" class="block">
<h3>
链式代理
<span v-if="!chain" class="tag">未保存</span>
</h3>
<div v-if="!chain" class="grid">
<OutboundCard
v-for="name in chainPreview"
:key="name"
:name="name"
sub=""
:delay="null"
:selected="false"
disabled-reason="保存之后才能选"
tone="chain"
/>
</div>
<div v-else class="grid">
<OutboundCard
v-for="line in chainLines"
:key="line.name"
:name="line.name"
:sub="line.info?.type ?? ''"
:delay="latestDelay(line.info)"
:selected="line.name === nowOf(chain)"
:testing="isTesting(line.name)"
testable
tone="chain"
@pick="emit('select', chain.name, line.name)"
@test="emit('test', line.name, line.provider)"
/>
</div>
</section>
<!-- 共用的组只能换选中,改不了它的成员来源:那是别人的组 -->
<section v-if="panel.shared && !locked" class="block">
<button type="button" class="adopt" :disabled="saving" @click="emit('adopt')">
改成逐条选线路
</button>
</section>
<section v-if="!panel.shared" class="block advanced">
<button type="button" class="toggle" @click="open = !open">
<span>高级配置</span>
<component :is="open ? IconUp : IconDown" />
</button>
<div v-if="open" class="form">
<h4>代理</h4>
<div class="row">
<span class="label">订阅</span>
<SourcePicker v-model="form.proxy.sources" :options="subscriptions" />
</div>
<div class="row">
<span class="label">筛选</span>
<div class="field">
<input v-model="form.proxy.filter" placeholder="正则,留空表示全部保留" />
<p class="count">
<template v-if="preview(form.proxy).kept === null">筛选条件写法有误</template>
<template v-else>
{{ preview(form.proxy).total }} 条线路里匹配到 {{ preview(form.proxy).kept }} 条
</template>
</p>
</div>
</div>
<div class="row">
<span class="label">链式代理</span>
<div class="field">
<button
type="button"
role="switch"
:aria-checked="chainOn"
:class="['switch', chainOn && 'is-on']"
@click="chainOn = !chainOn"
>
<span class="knob"></span>
</button>
</div>
</div>
<template v-if="form.chain">
<h4>链式代理</h4>
<div class="row">
<span class="label">订阅</span>
<SourcePicker v-model="form.chain!.sources" :options="subscriptions" />
</div>
<div class="row">
<span class="label">筛选</span>
<div class="field">
<input v-model="form.chain.filter" placeholder="正则,建议筛到作为中转的那一条" />
<p class="count">
<template v-if="preview(form.chain).kept === null">筛选条件写法有误</template>
<template v-else>
{{ preview(form.chain).total }} 条线路里匹配到
{{ preview(form.chain).kept }} 条
</template>
</p>
</div>
</div>
</template>
<div class="form-acts">
<button type="button" :disabled="!dirty || saving" @click="reset">取消</button>
<button
type="button"
class="primary"
:disabled="!dirty || !complete || saving"
@click="emit('save', form)"
>
{{ saving ? '保存中' : '保存' }}
</button>
</div>
</div>
</section>
</template>
</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-left: auto;
}
.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;
min-width: 0;
}
.flow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--s-2);
}
.arrow {
color: var(--c-text-faint);
}
.hop {
color: var(--c-text-soft);
}
/* 两跳用两种颜色,和下面卡片的选中态对得上 */
.hop.is-proxy {
color: var(--c-accent-text);
font-weight: 500;
}
.hop.is-chain {
color: var(--c-chain-text);
font-weight: 500;
}
.block {
padding-top: var(--s-4);
}
.block + .block {
margin-top: var(--s-5);
border-top: 1px solid var(--c-border);
}
h3 {
display: flex;
align-items: center;
gap: var(--s-2);
font-size: var(--t-sm);
font-weight: 600;
color: var(--c-text);
letter-spacing: 0.02em;
margin-bottom: var(--s-4);
}
.tag {
padding: 0 var(--s-2);
border: 1px solid var(--c-border-strong);
border-radius: 999px;
font-size: var(--t-xs);
font-weight: 400;
color: var(--c-text-faint);
cursor: help;
}
.tabs {
display: flex;
gap: var(--s-4);
margin-bottom: var(--s-4);
}
/* 选中态是一条离文字有距离的下划线,不画边框 */
.tab {
padding: 0 0 var(--s-2);
border: none;
border-bottom: 2px solid transparent;
background: none;
color: var(--c-text-faint);
font-size: var(--t-sm);
cursor: pointer;
}
.tab:hover {
color: var(--c-text-soft);
}
.tab.is-on {
border-bottom-color: var(--c-accent);
color: var(--c-text);
}
.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;
}
.adopt {
padding: var(--s-1) var(--s-4);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-surface);
font-size: var(--t-sm);
cursor: pointer;
}
.adopt:hover:not(:disabled) {
border-color: var(--c-accent);
color: var(--c-accent-text);
}
.adopt:disabled {
color: var(--c-text-faint);
cursor: not-allowed;
}
/* ── 高级配置 ───────────────────────────────── */
.toggle {
display: flex;
align-items: center;
gap: var(--s-2);
padding: 0;
border: none;
background: none;
color: var(--c-text-soft);
font-size: var(--t-sm);
font-weight: 600;
cursor: pointer;
}
.toggle:hover {
color: var(--c-text);
}
/* 表单不套盒子,靠小标题和一条分割线分段(设计基调第 2 条:不要卡片套卡片) */
.form {
display: flex;
flex-direction: column;
gap: var(--s-3);
margin-top: var(--s-4);
}
.form h4 {
font-size: var(--t-xs);
font-weight: 600;
letter-spacing: 0.04em;
color: var(--c-text-faint);
}
.form h4:not(:first-child) {
margin-top: var(--s-4);
}
.row {
display: grid;
grid-template-columns: 4.5em minmax(0, 1fr);
align-items: start;
gap: var(--s-3);
}
/* 标签和第一行控件对齐。控件本身高 34px,所以标签也按 34px 居中 */
.label {
display: flex;
align-items: center;
min-height: 34px;
color: var(--c-text-faint);
font-size: var(--t-sm);
}
.field {
display: flex;
min-height: 34px;
flex-direction: column;
justify-content: center;
}
select,
input {
height: 34px;
padding: 0 var(--s-3);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-surface);
font-size: var(--t-base);
}
input {
width: 100%;
font-family: var(--f-mono);
}
.count {
margin-top: var(--s-1);
color: var(--c-text-faint);
font-size: var(--t-xs);
}
.switch {
flex: none;
align-self: flex-start;
width: 38px;
height: 22px;
padding: 2px;
border: 1px solid var(--c-border-strong);
border-radius: 999px;
background: var(--c-surface-sunken);
cursor: pointer;
}
.switch .knob {
display: block;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--c-text-faint);
transition:
transform 0.15s ease,
background 0.15s ease;
}
.switch.is-on {
border-color: var(--c-chain);
background: var(--c-chain-soft);
}
.switch.is-on .knob {
background: var(--c-chain);
transform: translateX(16px);
}
.form-acts {
display: flex;
justify-content: flex-end;
gap: var(--s-2);
}
.form-acts button {
padding: var(--s-1) var(--s-4);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-surface);
font-size: var(--t-sm);
cursor: pointer;
}
.form-acts button:disabled {
color: var(--c-text-faint);
cursor: not-allowed;
}
.form-acts .primary:not(:disabled) {
border-color: var(--c-accent);
background: var(--c-accent-soft);
color: var(--c-accent-text);
}
</style>