Files
mihomo-patchbay/web/views/RouterPage.vue
T
im0o 0062a261f7 补上真正日常要点的那个动作:切换策略组当前选中谁
之前的面板只做了「这个入站指向哪个出站」,那是改配置文件。但 metacubexd 里点一条线路
改的根本不是入站,是策略组的当前选中——调 PUT /proxies/{组},不写文件、立刻生效。
mixed-port 走规则表,末尾 MATCH,PROXY 把流量交给 PROXY 组,所以换那个组的选中就等于
换了这个端口的出口。这是每天都要点的动作,之前整个漏了。

面板顶部现在显示完整链条(PROXY → AUTO → 某条线路),并让人在最后一个策略组上换选中。
只显示第一层等于没回答「这个端口现在走哪条线」。

透传的白名单里为此开了唯一一个写操作,单独一条路由而不是放开整个转发。
2026-08-10 12:44:31 +08:00

451 lines
12 KiB
Vue

<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import {
collectInbounds,
collectOutboundNames,
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, ProxyInfo } from '../api'
import { density } from '../prefs'
import InboundDetail from './InboundDetail.vue'
import InboundForm from './InboundForm.vue'
import IconAdd from '~icons/ri/add-line'
const props = defineProps<{
data: ConfigObject
revision: string
kernelOnline: boolean
providerLines: string[]
runtime: Record<string, ProxyInfo>
}>()
const emit = defineEmits<{ changed: [] }>()
const inbounds = computed(() => collectInbounds(props.data))
const outbounds = computed(() => collectOutboundNames(props.data))
const selectedId = ref<string | null>(null)
const saving = ref(false)
const failure = ref<{ message: string; hint: string } | null>(null)
const done = ref('')
const fields = ref<ListenerFields | null>(null)
const formOpen = ref(false)
const formEditing = ref<Inbound | null>(null)
/** 正在把哪个老式端口字段转成命名入站。null 表示不是在做转换 */
const convertingKey = ref<string | null>(null)
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 TYPE_LABEL: Record<string, string> = {
mixed: 'HTTP + SOCKS5',
http: 'HTTP',
socks: 'SOCKS5',
redir: 'REDIR',
tproxy: 'TPROXY',
}
function typeLabel(inbound: Inbound): string {
return TYPE_LABEL[inbound.type] ?? inbound.type.toUpperCase()
}
function outboundLabel(mode: OutboundMode): { text: string; muted: boolean } {
if (mode.kind === 'rules') return { text: 'RULE', muted: true }
if (mode.kind === 'direct') return { text: 'DIRECT', muted: false }
if (mode.kind === 'global') return { text: 'GLOBAL', muted: false }
return { text: mode.name, muted: false }
}
const clone = () => JSON.parse(JSON.stringify(props.data)) as ConfigObject
/** 所有改动都走这一条路:落盘失败时按停在哪一步说不同的话。 */
async function commit(next: ConfigObject, success: string) {
saving.value = true
failure.value = null
done.value = ''
try {
await api.applyConfig(next, props.revision)
done.value = success
emit('changed')
} catch (error) {
if (error instanceof ApiError) {
const payload = error.payload as { message?: string; hint?: string }
failure.value = { message: payload.message ?? error.message, hint: payload.hint ?? '' }
} else {
failure.value = { message: String(error), hint: '' }
}
} finally {
saving.value = false
}
}
async function pickOutbound(mode: OutboundMode) {
const target = selected.value
if (!target || target.listenerIndex === null || saving.value) return
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
await commit(next, `${target.name} 的出站改成了 ${outboundLabel(mode).text}`)
}
function openAdd() {
formEditing.value = null
convertingKey.value = null
formOpen.value = true
}
/**
* 把一个老式顶层端口转成命名入站。
*
* 转完它才有 proxy 字段,才能单独指定出站。表单预填端口、类型和它当前实际绑定的地址
* (老式端口的地址是内核从 allow-lan 和 bind-address 推出来的,转换后要显式写进 listen,
* 否则改 allow-lan 会连带影响这个端口)。名字留空,让用户自己起一个——沿用 mixed-port
* 当名字很难看。
*/
function openConvert() {
const target = selected.value
if (!target?.legacyKey) return
formEditing.value = { ...target, name: '' }
convertingKey.value = target.legacyKey
formOpen.value = true
}
function openEdit() {
if (!selected.value || selected.value.isDefault) return
formEditing.value = selected.value
convertingKey.value = null
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
const converting = convertingKey.value
if (editingIndex === null) listeners.push(entry)
else listeners[editingIndex] = entry
next.listeners = listeners
// 转换时把老式端口字段去掉,否则同一个端口会被声明两次,内核起不来
if (converting) delete next[converting]
formOpen.value = false
convertingKey.value = null
const what = converting
? `${converting} 转成了命名入站 ${String(entry.name)}`
: editingIndex === null
? `新增了 ${String(entry.name)}`
: '改动已保存'
await commit(next, what)
if (editingIndex === null) selectedId.value = `listener:${String(listeners.length - 1)}`
}
/** 切换策略组当前选中谁。运行时动作,不写配置文件,所以不走那条落盘链路。 */
async function selectMember(group: string, member: string) {
if (saving.value) return
saving.value = true
failure.value = null
done.value = ''
try {
await api.selectProxy(group, member)
done.value = `${group} 切到了 ${member}`
emit('changed')
} catch (error) {
const message = error instanceof ApiError ? String(error.payload.error ?? error.message) : String(error)
failure.value = { message, hint: '这一步只改内核的运行状态,配置文件没有被动过。' }
} finally {
saving.value = false
}
}
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>
<div class="split">
<ol class="rows">
<li v-for="inbound in inbounds" :key="inbound.id">
<button
type="button"
:class="['line', selectedId === inbound.id && 'is-on']"
@click="selectedId = inbound.id"
>
<span class="port mono">{{ inbound.port }}</span>
<span class="type">{{ typeLabel(inbound) }}</span>
<span class="name">
{{ inbound.name }}
<span v-if="inbound.isDefault" class="tag">默认</span>
</span>
<span :class="['out', outboundLabel(inbound.outbound).muted && 'is-muted']">
{{ outboundLabel(inbound.outbound).text }}
</span>
</button>
</li>
</ol>
<InboundDetail
v-if="selected"
:inbound="selected"
:groups="outbounds.groups"
:own-proxies="outbounds.ownProxies"
:provider-lines="providerLines"
:destination="resolveDestination(data, selected)"
:runtime="runtime"
:kernel-online="kernelOnline"
:saving="saving"
@pick="pickOutbound"
@edit="openEdit"
@remove="removeSelected"
@convert="openConvert"
@select="selectMember"
/>
</div>
<p v-if="inbounds.length === 0" class="empty">没有入站。mihomo 不会监听任何端口。</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);
}
/* 左列表右详情。窄屏时改成上下,详情不再吸顶 */
.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;
}
}
.rows {
list-style: none;
margin: 0;
padding: 0;
border: 1px solid var(--c-border);
border-radius: var(--r-md);
background: var(--c-surface);
overflow: hidden;
}
.rows li + li {
border-top: 1px solid var(--c-border);
}
.line {
width: 100%;
display: grid;
grid-template-columns: 4.5rem 6.5rem 1fr minmax(5rem, auto);
align-items: center;
gap: var(--s-3);
padding: var(--s-3) var(--s-4);
border: none;
background: none;
cursor: pointer;
text-align: left;
}
.line:hover {
background: var(--c-surface-sunken);
}
.line.is-on {
background: var(--c-accent-soft);
}
.port {
font-size: var(--t-lg);
font-variant-numeric: tabular-nums;
}
.type {
font-size: var(--t-xs);
color: var(--c-text-faint);
}
.name {
display: flex;
align-items: center;
gap: var(--s-2);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tag {
flex: none;
padding: 0 var(--s-2);
border: 1px solid var(--c-border-strong);
border-radius: 999px;
font-size: var(--t-xs);
color: var(--c-text-soft);
line-height: 1.6;
}
.out {
font-family: var(--f-mono);
font-size: var(--t-sm);
text-align: right;
}
.out.is-muted {
color: var(--c-text-faint);
}
.empty {
color: var(--c-text-faint);
font-size: var(--t-sm);
margin-top: var(--s-4);
}
</style>