Files
mihomo-patchbay/web/views/RouterPage.vue
T
im0o 860da5d28f 订阅线路的类型、延迟和测速都改走 provider 接口
404 不是 bug。内核的 /proxies 只返回全局名字表(内置出站、自有线路、策略组),
订阅线路不在里面——跟「订阅线路不能被入站直接引用」是同一条约束,我没想到它同样
适用于运行时接口。所以 /proxies/{订阅线路}/delay 一律 404,而订阅线路的卡片也一直
没有协议和延迟,因为那些数据同样查不到。

现在运行时状态从两处合成:/proxies 给内置出站和策略组,/providers/proxies 给订阅线路
(它返回的线路本来就带完整的协议和延迟历史)。测速按线路归属选接口,订阅线路走
{订阅}/{线路}/healthcheck。实测该接口对同一条线路返回 200 和 325 毫秒。
2026-08-10 13:41:42 +08:00

477 lines
13 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, 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[]
/** 订阅线路 → 所属订阅 */
lineProvider: Record<string, 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: '该操作仅修改内核运行状态,配置文件未被修改。' }
}
}
/** 延迟测试用的超时,毫秒。也用在失败提示里,让「超时」这个词有具体含义。 */
const TEST_TIMEOUT_MS = 5000
/**
* 把内核的延迟测试结果翻译成一句说明原因的话。
*
* 内核的语义是精确的(hub/route/proxies.go:122-145):
* 504 在给定超时内没有完成
* 503 连接建立失败,或者测得的延迟为 0
* 笼统说一句「测不通」会把这两种情况混在一起,而它们要查的地方不一样。
*/
function describeTestFailure(name: string, error: unknown): string {
if (!(error instanceof ApiError)) return `${name} 延迟测试失败:${String(error)}`
const seconds = String(TEST_TIMEOUT_MS / 1000)
if (error.status === 504) return `${name} 延迟测试超时,${seconds} 秒内没有取到测试地址`
const detail = String(error.payload.message ?? '')
const generic = detail === '' || detail === 'An error occurred in the delay test'
if (error.status === 404) {
return `${name} 不在内核的出站表里,无法测试。订阅线路需要从所属订阅进去测。`
}
if (error.status === 503) {
return generic
? `${name} 无法建立连接,线路可能已失效,或本机到该服务器的网络不通`
: `${name} 无法建立连接:${detail}`
}
return `${name} 延迟测试失败(HTTP ${String(error.status)})${generic ? '' : `:${detail}`}`
}
/** 测一条线路的延迟。纯读操作,不影响任何配置,所以不禁用任何东西。 */
async function testLine(name: string) {
if (testing.value.includes(name)) return
testing.value = [...testing.value, name]
try {
await api.testLine(name, props.lineProvider[name], TEST_TIMEOUT_MS)
emit('changed')
} catch (error) {
// 测不通是关于线路的结论,不是 Patchbay 出了故障,所以不用错误框
done.value = describeTestFailure(name, error)
} 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">没有入站,内核不会监听任何端口。</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>