Files
mihomo-patchbay/web/views/RouterPage.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

447 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: []; optimisticSelect: [group: string, member: string] }>()
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)
/** 正在提交的那个出站的名字。只有它显示忙碌,其余卡片照常能点 */
const pendingOutbound = ref<string | null>(null)
/** 正在测延迟的线路 */
const testing = ref<string[]>([])
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 || pendingOutbound.value) return
pendingOutbound.value = outboundLabel(mode).text
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).text}`)
} 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
formOpen.value = false
await commit(next, editingIndex === null ? `新增了 ${String(entry.name)}` : '改动已保存')
if (editingIndex === null) selectedId.value = `listener:${String(listeners.length - 1)}`
}
/** 切换策略组当前选中谁。运行时动作,不写配置文件,所以不走那条落盘链路。 */
async function selectMember(group: string, member: string) {
if (saving.value) return
// 先本地改掉,别让点击等一个往返。失败了随后的刷新会纠正回来。
emit('optimisticSelect', group, member)
failure.value = null
done.value = ''
try {
await api.selectProxy(group, member)
emit('changed')
} catch (error) {
const message = error instanceof ApiError ? String(error.payload.error ?? error.message) : String(error)
failure.value = { message, hint: '这一步只改内核的运行状态,配置文件没有被动过。' }
}
}
/** 测一条线路的延迟。纯读操作,不影响任何配置,所以谁都不用禁用。 */
async function testLine(name: string) {
if (testing.value.includes(name)) return
testing.value = [...testing.value, name]
try {
await api.testLine(name)
emit('changed')
} catch (error) {
// 测不通是关于线路的结论,不是 Patchbay 出了问题,不弹错误框
const raw =
error instanceof ApiError ? String(error.payload.message ?? error.message) : String(error)
done.value = raw.includes('timeout') ? `${name} 超时` : `${name} 测不通`
} finally {
testing.value = testing.value.filter((n) => n !== name)
}
}
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"
@pick="pickOutbound"
@edit="openEdit"
@remove="removeSelected"
@select="selectMember"
@test="testLine"
:testing="testing"
:pending-outbound="pendingOutbound"
/>
</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>