修掉审查查出的五个问题

保存失败会丢掉整张表单:对话框在 await commit 之前就关了,重开是空的。改成只有成功才关。

错误响应原本有两种形状({error} 和 {message,hint}),前端只读 message,于是服务端
写好的中文提示一律退化成「请求失败(HTTP 4xx)」。统一成 { message, hint? }。

路由处理器没有兜底:请求体不是合法 JSON、配置文件运行中被删,都会返回一个没有响应体
的 500。这条链路的立意是出事时把用户救回来,而出事时给的偏偏是最没信息量的一句话。

延迟测试失败被写进 done,而 done 的样式是成功绿。拆出中性的 notice——测不通是关于
线路的结论,既不是操作失败也不是成功。

刷新原本只有一个动词,测一条线路的延迟会导致整份配置被重读、重新解析、跑一遍引用校验,
外加四个往返。拆成 saved 和 runtime-changed 两个事件,运行时动作只重读运行时状态。
This commit is contained in:
im0o
2026-08-10 14:29:46 +08:00
parent 007d137b5d
commit 6b208847a2
4 changed files with 95 additions and 24 deletions
+29 -6
View File
@@ -98,14 +98,37 @@ async function serveStatic(path: string): Promise<Response> {
} }
export function createRouter(env: Env, token: string) { export function createRouter(env: Env, token: string) {
const route = makeRoute(env, token)
/**
* 兜底。没有它的话,请求体不是合法 JSON、或者配置文件在运行中被删,都会抛出一个
* 没有响应体的 500,前端只能显示「请求失败(HTTP 500)」——这条链路的全部立意是
* 出事时把用户救回来,而出事时给出的偏偏是最没信息量的一句话。
*/
return async function handle(req: Request): Promise<Response> { return async function handle(req: Request): Promise<Response> {
try {
return await route(req)
} catch (error) {
return json(
{
message: String(error),
hint: '这是 Patchbay 自身的故障,配置文件没有被改动。',
},
500,
)
}
}
}
function makeRoute(env: Env, token: string) {
return async function route(req: Request): Promise<Response> {
const url = new URL(req.url) const url = new URL(req.url)
const path = url.pathname const path = url.pathname
if (!path.startsWith('/api/')) return serveStatic(path) if (!path.startsWith('/api/')) return serveStatic(path)
if (!tokenMatches(token, tokenFromRequest(req))) { if (!tokenMatches(token, tokenFromRequest(req))) {
return json({ error: '令牌不对。它在配置文件旁边的 .patchbay-token 里。' }, 401) return json({ message: '令牌不正确', hint: '它在服务端配置文件的同级目录 .patchbay-token 里。' }, 401)
} }
// ── 配置 ──────────────────────────────────────────── // ── 配置 ────────────────────────────────────────────
@@ -117,7 +140,7 @@ export function createRouter(env: Env, token: string) {
if (path === '/api/config' && req.method === 'PUT') { if (path === '/api/config' && req.method === 'PUT') {
const body = (await req.json()) as { data?: ConfigObject; baseRevision?: string } const body = (await req.json()) as { data?: ConfigObject; baseRevision?: string }
if (!body.data || typeof body.data !== 'object') { if (!body.data || typeof body.data !== 'object') {
return json({ error: '请求里没有 data 字段' }, 400) return json({ message: '请求缺少 data 字段' }, 400)
} }
const result = await applyConfig(env, body.data, body.baseRevision) const result = await applyConfig(env, body.data, body.baseRevision)
return result.ok ? json(result) : json(result, applyFailureStatus(result.stage)) return result.ok ? json(result) : json(result, applyFailureStatus(result.stage))
@@ -145,11 +168,11 @@ export function createRouter(env: Env, token: string) {
if (selectMatch && req.method === 'PUT') { if (selectMatch && req.method === 'PUT') {
const group = decodeURIComponent(selectMatch[1]!) const group = decodeURIComponent(selectMatch[1]!)
const body = (await req.json()) as { name?: string } const body = (await req.json()) as { name?: string }
if (!body.name) return json({ error: '请求里没有 name 字段' }, 400) if (!body.name) return json({ message: '请求缺少 name 字段' }, 400)
const upstream = await selectProxy(env, group, body.name) const upstream = await selectProxy(env, group, body.name)
return upstream.ok return upstream.ok
? json({ ok: true }) ? json({ ok: true })
: json({ error: upstream.message || `内核拒绝了这次切换` }, 422) : json({ message: upstream.message || '内核拒绝了这次切换' }, 422)
} }
if (path.startsWith('/api/kernel/') && req.method === 'GET') { if (path.startsWith('/api/kernel/') && req.method === 'GET') {
@@ -157,7 +180,7 @@ export function createRouter(env: Env, token: string) {
const allowed = const allowed =
READABLE_KERNEL_PATHS.has(target) || DELAY_PATH.test(target) || HEALTHCHECK_PATH.test(target) READABLE_KERNEL_PATHS.has(target) || DELAY_PATH.test(target) || HEALTHCHECK_PATH.test(target)
if (!allowed) { if (!allowed) {
return json({ error: `不允许透传这个路径:${target}` }, 403) return json({ message: `不允许访问该路径:${target}` }, 403)
} }
const upstream = await proxyGet(env, target + url.search) const upstream = await proxyGet(env, target + url.search)
return new Response(upstream.body, { return new Response(upstream.body, {
@@ -171,6 +194,6 @@ export function createRouter(env: Env, token: string) {
return json(listenerFields) return json(listenerFields)
} }
return json({ error: '没有这个接口' }, 404) return json({ message: '没有这个接口' }, 404)
} }
} }
+14 -1
View File
@@ -102,6 +102,18 @@ function optimisticSelect(group: string, member: string) {
if (info) info.now = member if (info) info.now = member
} }
/**
* 只重读运行时状态,不碰配置文件。
*
* 切换策略组、测一条线路的延迟,改变的只是内核的运行状态。走完整的 load() 会让服务端
* 重读整份配置、重新解析、再跑一遍引用校验,外加两个多余的往返——最便宜的动作触发了
* 最贵的操作。
*/
async function reloadRuntime() {
if (!kernel.value.online) return
runtime.value = await readRuntime()
}
function submitToken(value: string) { function submitToken(value: string) {
setToken(value) setToken(value)
void load() void load()
@@ -184,8 +196,9 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
:provider-lines="providerLines" :provider-lines="providerLines"
:line-provider="lineProvider" :line-provider="lineProvider"
:runtime="runtime" :runtime="runtime"
@changed="load" @saved="load"
@optimistic-select="optimisticSelect" @optimistic-select="optimisticSelect"
@runtime-changed="reloadRuntime"
/> />
<div v-else class="note"> <div v-else class="note">
+16 -2
View File
@@ -23,16 +23,30 @@ export function clearToken(): void {
localStorage.removeItem(TOKEN_KEY) localStorage.removeItem(TOKEN_KEY)
} }
/** 接口报错时抛这个,界面据此决定是提示重填令牌还是显示错误内容。 */ /**
* 接口报错时抛这个。
*
* 服务端的错误响应统一是 { message, hint? }:message 说发生了什么,hint 说下一步该干嘛。
* 界面直接用这两个字段,不需要认得每个端点各自的错误形状。
*/
export class ApiError extends Error { export class ApiError extends Error {
constructor( constructor(
readonly status: number, readonly status: number,
readonly payload: Record<string, unknown>, readonly payload: Record<string, unknown>,
) { ) {
super(typeof payload.message === 'string' ? payload.message : `请求失败(HTTP ${String(status)})`) super(
typeof payload.message === 'string'
? payload.message
: `请求失败(HTTP ${String(status)})`,
)
this.name = 'ApiError' this.name = 'ApiError'
} }
/** 服务端给的下一步建议,没有就是空串。 */
get hint(): string {
return typeof this.payload.hint === 'string' ? this.payload.hint : ''
}
get unauthorized(): boolean { get unauthorized(): boolean {
return this.status === 401 return this.status === 401
} }
+36 -15
View File
@@ -25,7 +25,13 @@ const props = defineProps<{
runtime: Record<string, ProxyInfo> runtime: Record<string, ProxyInfo>
}>() }>()
const emit = defineEmits<{ changed: []; optimisticSelect: [group: string, member: string] }>() const emit = defineEmits<{
/** 配置文件变了,需要重读整份配置 */
saved: []
/** 只有内核的运行时状态变了,不用重读配置 */
runtimeChanged: []
optimisticSelect: [group: string, member: string]
}>()
const inbounds = computed(() => collectInbounds(props.data)) const inbounds = computed(() => collectInbounds(props.data))
const outbounds = computed(() => collectOutboundNames(props.data)) const outbounds = computed(() => collectOutboundNames(props.data))
@@ -33,7 +39,10 @@ const outbounds = computed(() => collectOutboundNames(props.data))
const selectedId = ref<string | null>(null) const selectedId = ref<string | null>(null)
const saving = ref(false) const saving = ref(false)
const failure = ref<{ message: string; hint: string } | null>(null) const failure = ref<{ message: string; hint: string } | null>(null)
/** 成功的结果,绿色 */
const done = ref('') const done = ref('')
/** 中性的告知,比如某条线路测不通——那是关于线路的结论,不是操作失败 */
const notice = ref('')
const fields = ref<ListenerFields | null>(null) const fields = ref<ListenerFields | null>(null)
const formOpen = ref(false) const formOpen = ref(false)
const formEditing = ref<Inbound | null>(null) const formEditing = ref<Inbound | null>(null)
@@ -88,17 +97,16 @@ async function commit(next: ConfigObject, success: string) {
saving.value = true saving.value = true
failure.value = null failure.value = null
done.value = '' done.value = ''
notice.value = ''
try { try {
await api.applyConfig(next, props.revision) await api.applyConfig(next, props.revision)
done.value = success done.value = success
emit('changed') emit('saved')
} catch (error) { } catch (error) {
if (error instanceof ApiError) { failure.value =
const payload = error.payload as { message?: string; hint?: string } error instanceof ApiError
failure.value = { message: payload.message ?? error.message, hint: payload.hint ?? '' } ? { message: error.message, hint: error.hint }
} else { : { message: String(error), hint: '' }
failure.value = { message: String(error), hint: '' }
}
} finally { } finally {
saving.value = false saving.value = false
} }
@@ -144,8 +152,10 @@ async function submitForm(entry: ConfigObject) {
else listeners[editingIndex] = entry else listeners[editingIndex] = entry
next.listeners = listeners next.listeners = listeners
formOpen.value = false
await commit(next, editingIndex === null ? `已新增 ${String(entry.name)}` : '已保存') 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)}` if (editingIndex === null) selectedId.value = `listener:${String(listeners.length - 1)}`
} }
@@ -156,12 +166,15 @@ async function selectMember(group: string, member: string) {
emit('optimisticSelect', group, member) emit('optimisticSelect', group, member)
failure.value = null failure.value = null
done.value = '' done.value = ''
notice.value = ''
try { try {
await api.selectProxy(group, member) await api.selectProxy(group, member)
emit('changed') emit('runtimeChanged')
} catch (error) { } catch (error) {
const message = error instanceof ApiError ? String(error.payload.error ?? error.message) : String(error) failure.value = {
failure.value = { message, hint: '该操作仅修改内核运行状态,配置文件未被修改。' } message: error instanceof ApiError ? error.message : String(error),
hint: '该操作仅修改内核运行状态,配置文件未被修改。',
}
} }
} }
@@ -201,10 +214,11 @@ async function testLine(name: string) {
testing.value = [...testing.value, name] testing.value = [...testing.value, name]
try { try {
await api.testLine(name, props.lineProvider[name], TEST_TIMEOUT_MS) await api.testLine(name, props.lineProvider[name], TEST_TIMEOUT_MS)
emit('changed') emit('runtimeChanged')
} catch (error) { } catch (error) {
// 测不通是关于线路的结论,不是 Patchbay 出了故障,所以不用错误框 // 测不通是关于线路的结论,不是 Patchbay 出了故障。既不该用错误框,
done.value = describeTestFailure(name, error) // 也不该用成功的绿色——它需要第三种中性的样子。
notice.value = describeTestFailure(name, error)
} finally { } finally {
testing.value = testing.value.filter((n) => n !== name) testing.value = testing.value.filter((n) => n !== name)
} }
@@ -255,6 +269,7 @@ async function removeSelected() {
<span v-if="failure.hint">{{ failure.hint }}</span> <span v-if="failure.hint">{{ failure.hint }}</span>
</div> </div>
<p v-else-if="done" class="done">{{ done }}</p> <p v-else-if="done" class="done">{{ done }}</p>
<p v-else-if="notice" class="notice">{{ notice }}</p>
<div class="split"> <div class="split">
<ol class="rows"> <ol class="rows">
@@ -379,6 +394,12 @@ async function removeSelected() {
margin-bottom: var(--s-4); margin-bottom: var(--s-4);
} }
.notice {
color: var(--c-text-soft);
font-size: var(--t-sm);
margin-bottom: var(--s-4);
}
/* 左列表右详情。窄屏时改成上下,详情不再吸顶 */ /* 左列表右详情。窄屏时改成上下,详情不再吸顶 */
.split { .split {
display: grid; display: grid;