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

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

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

392 lines
12 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">
/**
* 路由页:一张「端口 → 出站」的表,加上选中那一路的详情。
*
* 这里只做编排——把配置数据、运行时状态和三个子组件接起来,并守住一条:
* 所有改配置文件的动作都从 commit() 走,失败时按停在哪一步说不同的话。
* 运行时动作(切换选中、测延迟)不经过它,因为它们不写文件也不需要校验。
*/
import { computed, onMounted, ref, watch } from 'vue'
import {
collectInbounds,
collectOutboundNames,
outboundLabel,
outboundToProxyField,
resolveDestination,
} from '@shared/inbounds.ts'
import type { Inbound, OutboundMode } from '@shared/inbounds.ts'
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
import { ApiError, api } from '../api'
import type { ListenerFields } from '../api'
import {
generatedGroupName,
isGeneratedGroup,
makeFilterGroup,
pruneGeneratedGroups,
} from '@shared/filter-groups.ts'
import { lineProvider, optimisticSelect, providerLines, reloadRuntime } from '../runtime'
import { useDelayTest } from '../delay-test'
import { density } from '../prefs'
import InboundList from '../components/InboundList.vue'
import InboundDetail from './InboundDetail.vue'
import InboundForm from './InboundForm.vue'
import IconAdd from '~icons/ri/add-line'
const props = defineProps<{ data: ConfigObject; revision: string }>()
/** 配置文件变了,需要重读整份配置。运行时的变化不走这里。 */
const emit = defineEmits<{ saved: [] }>()
const inbounds = computed(() => collectInbounds(props.data))
const outbounds = computed(() => {
const names = collectOutboundNames(props.data)
// 生成的筛选组不出现在可选列表里:它们是「固定到某条线路」的实现细节,
// 用户从没建过,让他在策略组那一栏看到只会莫名其妙。
return { ...names, groups: names.groups.filter((g) => !isGeneratedGroup(g)) }
})
const selectedId = ref<string | null>(null)
const saving = ref(false)
const failure = ref<{ message: string; hint: string } | null>(null)
/** 成功的结果,绿色 */
const done = ref('')
/** 中性的告知,比如某条线路测不通——那是关于线路的结论,不是操作失败 */
const notice = ref('')
const fields = ref<ListenerFields | null>(null)
const formOpen = ref(false)
const formEditing = ref<Inbound | null>(null)
/** 正在提交的那个出站的名字。只有它显示忙碌,其余卡片照常能点 */
const pendingOutbound = ref<string | null>(null)
const { testing, test } = useDelayTest(
() => void reloadRuntime(),
(message) => {
notice.value = message
},
)
onMounted(async () => {
try {
fields.value = await api.listenerFields()
} catch {
fields.value = null
}
})
// 默认选中第一行。空面板没有信息量,不如直接展示一路入站。
watch(
inbounds,
(list) => {
if (!list.some((i) => i.id === selectedId.value)) selectedId.value = list[0]?.id ?? null
},
{ immediate: true },
)
const selected = computed(() => inbounds.value.find((i) => i.id === selectedId.value) ?? null)
const takenNames = computed(() => inbounds.value.map((i) => i.name))
const clone = () => JSON.parse(JSON.stringify(props.data)) as ConfigObject
/** 所有改配置文件的动作都从这里走。 */
async function commit(raw: ConfigObject, success: string) {
// 顺手清掉没人再引用的生成组。不清理的话,改几次出站文件里就攒下一堆
// 用户从没主动建过、也不知道是干嘛的组。
const next = pruneGeneratedGroups(raw)
saving.value = true
failure.value = null
done.value = ''
notice.value = ''
try {
await api.applyConfig(next, props.revision)
done.value = success
emit('saved')
} catch (error) {
failure.value =
error instanceof ApiError
? { message: error.message, hint: error.hint }
: { message: String(error), hint: '' }
} finally {
saving.value = false
}
}
async function pickOutbound(mode: OutboundMode) {
const target = selected.value
if (!target || target.listenerIndex === null || pendingOutbound.value) return
pendingOutbound.value = outboundLabel(mode)
const next = clone()
const entry = (next.listeners as ConfigObject[])[target.listenerIndex]!
const proxy = outboundToProxyField(mode)
// RULE 是「没有这个字段」而不是「字段为空」,所以是删掉而不是置空。
if (proxy === undefined) delete entry.proxy
else entry.proxy = proxy
try {
await commit(next, `${target.name} 的出站已设为 ${outboundLabel(mode)}`)
} finally {
pendingOutbound.value = null
}
}
/**
* 把当前这一路入站固定到某条订阅线路。
*
* 订阅线路不能被入站直接引用,所以要先建一个只装它的策略组再指过去(决策记录 0005)。
* 这一步对用户是透明的:他看到的只是「这个口固定走这条线」。
*/
async function pinLine(line: string) {
const target = selected.value
if (!target || target.listenerIndex === null || pendingOutbound.value) return
const provider = lineProvider.value[line]
if (!provider) {
notice.value = `找不到 ${line} 属于哪个订阅,无法固定`
return
}
const group = makeFilterGroup(line, provider)
if (!group) {
notice.value = `${line} 的名字里含反引号,内核会把它当成筛选条件的分隔符,无法固定`
return
}
const groupName = generatedGroupName(line)
pendingOutbound.value = groupName
const next = clone()
const groups = Array.isArray(next['proxy-groups']) ? (next['proxy-groups'] as ConfigObject[]) : []
// 同一条线路被两路入站固定时共用一个组,不重复建
if (!groups.some((g) => g.name === groupName)) groups.push(group)
next['proxy-groups'] = groups
;(next.listeners as ConfigObject[])[target.listenerIndex]!.proxy = groupName
try {
await commit(next, `${target.name} 已固定走 ${line}`)
} finally {
pendingOutbound.value = null
}
}
function openAdd() {
formEditing.value = null
formOpen.value = true
}
function openEdit() {
if (!selected.value || selected.value.isDefault) return
formEditing.value = selected.value
formOpen.value = true
}
async function submitForm(entry: ConfigObject) {
const next = clone()
const listeners = Array.isArray(next.listeners) ? (next.listeners as ConfigObject[]) : []
const editingIndex = formEditing.value?.listenerIndex ?? null
if (editingIndex === null) listeners.push(entry)
else listeners[editingIndex] = entry
next.listeners = listeners
await commit(next, editingIndex === null ? `已新增 ${String(entry.name)}` : '已保存')
// 失败时不能关对话框——关了再打开就是空表单,用户填的名称、端口、类型全没了
if (failure.value) return
formOpen.value = false
if (editingIndex === null) selectedId.value = `listener:${String(listeners.length - 1)}`
}
/** 切换策略组当前选中谁。运行时动作,不写配置文件,所以不走 commit()。 */
async function selectMember(group: string, member: string) {
if (saving.value) return
optimisticSelect(group, member)
failure.value = null
done.value = ''
notice.value = ''
try {
await api.selectProxy(group, member)
await reloadRuntime()
} catch (error) {
failure.value = {
message: error instanceof ApiError ? error.message : String(error),
hint: '该操作仅修改内核运行状态,配置文件未被修改。',
}
}
}
async function removeSelected() {
const target = selected.value
if (!target || target.listenerIndex === null || saving.value) return
if (!confirm(`删除入站「${target.name}」?端口 ${target.port} 将停止监听。`)) return
const next = clone()
const listeners = (next.listeners as ConfigObject[]).filter((_, i) => i !== target.listenerIndex)
// 删到一个不剩时把整个键去掉,别在文件里留一个空数组
if (listeners.length === 0) delete next.listeners
else next.listeners = listeners
selectedId.value = null
await commit(next, `已删除 ${target.name}`)
}
</script>
<template>
<div class="page">
<header class="head">
<div class="sums">
<div class="sum">
<span class="sum-n">{{ inbounds.length }}</span>
<span class="sum-k">入站</span>
</div>
<div class="sum">
<span class="sum-n">{{ outbounds.groups.length }}</span>
<span class="sum-k">策略组</span>
</div>
<div class="sum">
<span class="sum-n">{{ providerLines.length }}</span>
<span class="sum-k">订阅线路</span>
</div>
</div>
<button class="add" type="button" :disabled="saving" @click="openAdd">
<IconAdd />
<span v-if="density === 'full'">新增</span>
</button>
</header>
<div v-if="failure" class="failure">
<strong>{{ failure.message }}</strong>
<span v-if="failure.hint">{{ failure.hint }}</span>
</div>
<p v-else-if="done" class="done">{{ done }}</p>
<p v-else-if="notice" class="notice">{{ notice }}</p>
<div class="split">
<InboundList v-model:selected="selectedId" :inbounds="inbounds" />
<InboundDetail
v-if="selected"
:inbound="selected"
:groups="outbounds.groups"
:own-proxies="outbounds.ownProxies"
:destination="resolveDestination(data, selected)"
:pending-outbound="pendingOutbound"
:testing="testing"
@pick="pickOutbound"
@edit="openEdit"
@remove="removeSelected"
@select="selectMember"
@test="(name: string) => test(name, lineProvider[name])"
@pin="pinLine"
/>
</div>
<p v-if="inbounds.length === 0" class="empty">没有入站,内核不会监听任何端口。</p>
<InboundForm
:open="formOpen"
:editing="formEditing"
:fields="fields"
:taken-names="takenNames"
:saving="saving"
@close="formOpen = false"
@submit="submitForm"
/>
</div>
</template>
<style scoped>
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-4);
margin-bottom: var(--s-5);
}
.sums {
display: flex;
gap: var(--s-6);
}
.sum {
display: flex;
align-items: baseline;
gap: var(--s-2);
}
.sum-n {
font-size: var(--t-2xl);
font-weight: 600;
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
}
.sum-k {
color: var(--c-text-soft);
font-size: var(--t-sm);
}
.add {
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);
cursor: pointer;
}
.add:hover:not(:disabled) {
border-color: var(--c-accent);
color: var(--c-accent-text);
}
.add:disabled {
color: var(--c-text-faint);
cursor: not-allowed;
}
.failure {
display: flex;
flex-direction: column;
gap: var(--s-1);
border: 1px solid color-mix(in srgb, var(--c-danger) 35%, var(--c-border));
border-radius: var(--r-md);
background: var(--c-danger-soft);
padding: var(--s-3) var(--s-4);
margin-bottom: var(--s-4);
font-size: var(--t-sm);
}
.done {
color: var(--c-ok);
font-size: var(--t-sm);
margin-bottom: var(--s-4);
}
.notice {
color: var(--c-text-soft);
font-size: var(--t-sm);
margin-bottom: var(--s-4);
}
/* 左列表右详情。窄屏时改成上下,详情不再吸顶 */
.split {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(300px, 1fr);
gap: var(--s-5);
align-items: start;
}
@media (max-width: 900px) {
.split {
grid-template-columns: 1fr;
}
}
.empty {
color: var(--c-text-faint);
font-size: var(--t-sm);
margin-top: var(--s-4);
}
</style>