界面文案改成中性措辞;延迟测试报出真实原因

原来那句「X 测不通」把两种要查不同地方的情况混在了一起。内核的语义是精确的
(hub/route/proxies.go:122-145):504 表示在给定超时内没有完成,503 表示连接建立失败
或测得延迟为 0。现在分开报,超时会写明是几秒,503 且内核没给具体错误时说明可能是
线路失效或本机到该服务器网络不通。

其余文案统一过一遍:「本机之外也能连」「这个名字已经被占用了」「还没做」这类口语改成
「允许来自其它主机的连接」「名称已存在」「尚未实现」。
This commit is contained in:
im0o
2026-08-10 13:38:02 +08:00
parent e8b8181ef6
commit 71adcd60ef
7 changed files with 59 additions and 34 deletions
+4 -4
View File
@@ -139,16 +139,16 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
</nav> </nav>
<main class="main"> <main class="main">
<p v-if="!booted && refreshing" class="note">读取中</p> <p v-if="!booted && refreshing" class="note">载入中</p>
<div v-else-if="loadError" class="alert"> <div v-else-if="loadError" class="alert">
<h2>读不到配置</h2> <h2>配置读取失败</h2>
<p>{{ loadError }}</p> <p>{{ loadError }}</p>
</div> </div>
<template v-else-if="snapshot"> <template v-else-if="snapshot">
<div v-if="problems.length" class="alert"> <div v-if="problems.length" class="alert">
<h2>{{ problems.length }} 处问题</h2> <h2>配置存在 {{ problems.length }} 处错误</h2>
<ul> <ul>
<li v-for="(p, i) in problems" :key="i"> <li v-for="(p, i) in problems" :key="i">
<code>{{ p.path.join('.') }}</code> {{ p.message }} <code>{{ p.path.join('.') }}</code> {{ p.message }}
@@ -169,7 +169,7 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
<div v-else class="note"> <div v-else class="note">
<component :is="current?.icon" class="note-icon" /> <component :is="current?.icon" class="note-icon" />
<p>{{ current?.label }}还没做</p> <p>{{ current?.label }} 尚未实现</p>
</div> </div>
</template> </template>
</main> </main>
+1 -1
View File
@@ -29,7 +29,7 @@ export class ApiError extends Error {
readonly status: number, readonly status: number,
readonly payload: Record<string, unknown>, readonly payload: Record<string, unknown>,
) { ) {
super(typeof payload.message === 'string' ? payload.message : `请求失败(${String(status)})`) super(typeof payload.message === 'string' ? payload.message : `请求失败(HTTP ${String(status)})`)
this.name = 'ApiError' this.name = 'ApiError'
} }
+1 -1
View File
@@ -54,7 +54,7 @@ function delayClass(ms: number | null): string {
type="button" type="button"
:class="['delay', delayClass(delay)]" :class="['delay', delayClass(delay)]"
:disabled="testing" :disabled="testing"
:title="delay === null ? '测这条线路的延迟' : '重新测'" :title="delay === null ? '测试延迟' : '重新测试'"
@click="emit('test')" @click="emit('test')"
> >
<IconLoad v-if="testing" class="spin" /> <IconLoad v-if="testing" class="spin" />
+9 -9
View File
@@ -83,13 +83,13 @@ function isCurrentOutbound(mode: OutboundMode): boolean {
} }
const BUILTINS: Array<{ mode: OutboundMode; label: string; sub: string }> = [ const BUILTINS: Array<{ mode: OutboundMode; label: string; sub: string }> = [
{ mode: { kind: 'rules' }, label: 'RULE', sub: '走分流规则' }, { mode: { kind: 'rules' }, label: 'RULE', sub: '由规则决定' },
{ mode: { kind: 'direct' }, label: 'DIRECT', sub: '不经过代理' }, { mode: { kind: 'direct' }, label: 'DIRECT', sub: '直接连接' },
{ mode: { kind: 'global' }, label: 'GLOBAL', sub: '跟随全局选择' }, { mode: { kind: 'global' }, label: 'GLOBAL', sub: '跟随 GLOBAL 组' },
] ]
const LINE_LOCKED = '订阅线路不能被入站直接引用,选中它需要自动建一个筛选组——这部分还没做' const LINE_LOCKED = '订阅线路无法被入站直接引用,需要通过筛选组间接引入。该功能尚未实现。'
const NO_PROXY_FIELD = '这路入站没有 proxy 字段' const NO_PROXY_FIELD = '该入站没有 proxy 字段'
</script> </script>
<template> <template>
@@ -126,7 +126,7 @@ const NO_PROXY_FIELD = '这路入站没有 proxy 字段'
</p> </p>
<template v-if="switchable"> <template v-if="switchable">
<h4>{{ switchable.group }} 当前选中</h4> <h4>{{ switchable.group }} 的当前选中</h4>
<div class="grid"> <div class="grid">
<OutboundCard <OutboundCard
v-for="m in switchable.members" v-for="m in switchable.members"
@@ -148,7 +148,7 @@ const NO_PROXY_FIELD = '这路入站没有 proxy 字段'
<section class="block"> <section class="block">
<h3>出站</h3> <h3>出站</h3>
<p v-if="locked" class="hint"> <p v-if="locked" class="hint">
由 <code>{{ inbound.legacyKey }}</code> 声明,这种写法没有 proxy 字段,固定走 RULE 由 <code>{{ inbound.legacyKey }}</code> 声明。该写法没有 proxy 字段,出站固定为 RULE。
</p> </p>
<div v-if="!locked" class="grid"> <div v-if="!locked" class="grid">
@@ -207,9 +207,9 @@ const NO_PROXY_FIELD = '这路入站没有 proxy 字段'
<h4 :class="locked && 'is-dim'">订阅线路</h4> <h4 :class="locked && 'is-dim'">订阅线路</h4>
<p v-if="!kernelOnline" class="hint"> <p v-if="!kernelOnline" class="hint">
<IconOffline /> <IconOffline />
内核未连接,看不到订阅里有哪些线路 内核未连接,无法获取订阅线路
</p> </p>
<p v-else-if="providerLines.length === 0" class="hint">订阅里没有线路</p> <p v-else-if="providerLines.length === 0" class="hint">订阅中没有线路</p>
<div v-else class="grid"> <div v-else class="grid">
<OutboundCard <OutboundCard
v-for="line in providerLines" v-for="line in providerLines"
+5 -5
View File
@@ -129,10 +129,10 @@ function submit() {
<div class="body"> <div class="body">
<label class="row"> <label class="row">
<span class="k">名字</span> <span class="k">名称</span>
<span class="v"> <span class="v">
<input v-model="name" type="text" placeholder="容器A" autocomplete="off" /> <input v-model="name" type="text" placeholder="容器A" autocomplete="off" />
<span v-if="nameTaken" class="bad">这个名字已经被占用了</span> <span v-if="nameTaken" class="bad">名称已存在</span>
</span> </span>
</label> </label>
@@ -155,7 +155,7 @@ function submit() {
<span class="k">端口</span> <span class="k">端口</span>
<span class="v"> <span class="v">
<input v-model="port" class="mono" type="text" placeholder="7778" autocomplete="off" /> <input v-model="port" class="mono" type="text" placeholder="7778" autocomplete="off" />
<span v-if="portInvalid" class="bad">支持 7778、200,302、401-429 这几种写法</span> <span v-if="portInvalid" class="bad">支持单个端口、逗号分隔的多个端口、连字符表示的区间</span>
</span> </span>
</label> </label>
@@ -163,7 +163,7 @@ function submit() {
<span class="k">监听</span> <span class="k">监听</span>
<span class="v"> <span class="v">
<input v-model="listen" class="mono" type="text" placeholder="0.0.0.0" autocomplete="off" /> <input v-model="listen" class="mono" type="text" placeholder="0.0.0.0" autocomplete="off" />
<span class="note">留空等于 0.0.0.0,本机之外也能连</span> <span class="note">留空为 0.0.0.0,允许来自其它主机的连接</span>
</span> </span>
</label> </label>
@@ -171,7 +171,7 @@ function submit() {
<span class="k">{{ f.key }}</span> <span class="k">{{ f.key }}</span>
<span class="v"> <span class="v">
<select v-if="f.enum" v-model="extras[f.key]"> <select v-if="f.enum" v-model="extras[f.key]">
<option value="" disabled>选一个</option> <option value="" disabled>请选择</option>
<option v-for="opt in f.enum" :key="String(opt)" :value="String(opt)">{{ opt }}</option> <option v-for="opt in f.enum" :key="String(opt)" :value="String(opt)">{{ opt }}</option>
</select> </select>
<input v-else v-model="extras[f.key]" class="mono" type="text" autocomplete="off" /> <input v-else v-model="extras[f.key]" class="mono" type="text" autocomplete="off" />
+37 -12
View File
@@ -115,7 +115,7 @@ async function pickOutbound(mode: OutboundMode) {
else entry.proxy = proxy else entry.proxy = proxy
try { try {
await commit(next, `${target.name} 的出站改成了 ${outboundLabel(mode).text}`) await commit(next, `${target.name} 的出站已设为 ${outboundLabel(mode).text}`)
} finally { } finally {
pendingOutbound.value = null pendingOutbound.value = null
} }
@@ -143,7 +143,7 @@ async function submitForm(entry: ConfigObject) {
next.listeners = listeners next.listeners = listeners
formOpen.value = false formOpen.value = false
await commit(next, editingIndex === null ? `新增了 ${String(entry.name)}` : '改动已保存') await commit(next, editingIndex === null ? `已新增 ${String(entry.name)}` : '已保存')
if (editingIndex === null) selectedId.value = `listener:${String(listeners.length - 1)}` if (editingIndex === null) selectedId.value = `listener:${String(listeners.length - 1)}`
} }
@@ -159,22 +159,47 @@ async function selectMember(group: string, member: string) {
emit('changed') emit('changed')
} catch (error) { } catch (error) {
const message = error instanceof ApiError ? String(error.payload.error ?? error.message) : String(error) const message = error instanceof ApiError ? String(error.payload.error ?? error.message) : String(error)
failure.value = { message, hint: '这一步只改内核的运行状态,配置文件没有被动过。' } 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 === 503) {
return generic
? `${name} 无法建立连接,线路可能已失效,或本机到该服务器的网络不通`
: `${name} 无法建立连接:${detail}`
}
return `${name} 延迟测试失败(HTTP ${String(error.status)})${generic ? '' : `:${detail}`}`
}
/** 测一条线路的延迟。纯读操作,不影响任何配置,所以不禁用任何东西。 */
async function testLine(name: string) { async function testLine(name: string) {
if (testing.value.includes(name)) return if (testing.value.includes(name)) return
testing.value = [...testing.value, name] testing.value = [...testing.value, name]
try { try {
await api.testLine(name) await api.testLine(name, undefined, TEST_TIMEOUT_MS)
emit('changed') emit('changed')
} catch (error) { } catch (error) {
// 测不通是关于线路的结论,不是 Patchbay 出了问题,不弹错误框 // 测不通是关于线路的结论,不是 Patchbay 出了故障,所以不用错误框
const raw = done.value = describeTestFailure(name, error)
error instanceof ApiError ? String(error.payload.message ?? error.message) : String(error)
done.value = raw.includes('timeout') ? `${name} 超时` : `${name} 测不通`
} finally { } finally {
testing.value = testing.value.filter((n) => n !== name) testing.value = testing.value.filter((n) => n !== name)
} }
@@ -183,7 +208,7 @@ async function testLine(name: string) {
async function removeSelected() { async function removeSelected() {
const target = selected.value const target = selected.value
if (!target || target.listenerIndex === null || saving.value) return if (!target || target.listenerIndex === null || saving.value) return
if (!confirm(`删除入站「${target.name}」?端口 ${target.port} 会立刻停止监听。`)) return if (!confirm(`删除入站「${target.name}」?端口 ${target.port} 将停止监听。`)) return
const next = clone() const next = clone()
const listeners = (next.listeners as ConfigObject[]).filter((_, i) => i !== target.listenerIndex) const listeners = (next.listeners as ConfigObject[]).filter((_, i) => i !== target.listenerIndex)
@@ -192,7 +217,7 @@ async function removeSelected() {
else next.listeners = listeners else next.listeners = listeners
selectedId.value = null selectedId.value = null
await commit(next, `删除了 ${target.name}`) await commit(next, `已删除 ${target.name}`)
} }
</script> </script>
@@ -266,7 +291,7 @@ async function removeSelected() {
/> />
</div> </div>
<p v-if="inbounds.length === 0" class="empty">没有入站。mihomo 不会监听任何端口。</p> <p v-if="inbounds.length === 0" class="empty">没有入站,内核不会监听任何端口。</p>
<InboundForm <InboundForm
:open="formOpen" :open="formOpen"
+2 -2
View File
@@ -9,7 +9,7 @@ const value = ref('')
<div class="gate"> <div class="gate">
<form class="box" @submit.prevent="emit('submit', value)"> <form class="box" @submit.prevent="emit('submit', value)">
<h1>Patchbay</h1> <h1>Patchbay</h1>
<p class="lead">输入访问令牌。</p> <p class="lead">需要访问令牌。</p>
<input <input
v-model="value" v-model="value"
class="field mono" class="field mono"
@@ -21,7 +21,7 @@ const value = ref('')
/> />
<button class="go" type="submit" :disabled="!value.trim()">进入</button> <button class="go" type="submit" :disabled="!value.trim()">进入</button>
<p class="where"> <p class="where">
令牌在服务端配置文件旁边的 <code>.patchbay-token</code> 里,启动时也会打印在控制台上。 令牌位于服务端配置文件的同级目录 <code>.patchbay-token</code>,启动时也会输出到控制台。
</p> </p>
</form> </form>
</div> </div>