diff --git a/server/routes.ts b/server/routes.ts index fd5ae61..01b769c 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -98,14 +98,37 @@ async function serveStatic(path: string): Promise { } export function createRouter(env: Env, token: string) { + const route = makeRoute(env, token) + + /** + * 兜底。没有它的话,请求体不是合法 JSON、或者配置文件在运行中被删,都会抛出一个 + * 没有响应体的 500,前端只能显示「请求失败(HTTP 500)」——这条链路的全部立意是 + * 出事时把用户救回来,而出事时给出的偏偏是最没信息量的一句话。 + */ return async function handle(req: Request): Promise { + 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 { const url = new URL(req.url) const path = url.pathname if (!path.startsWith('/api/')) return serveStatic(path) 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') { const body = (await req.json()) as { data?: ConfigObject; baseRevision?: string } 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) 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') { const group = decodeURIComponent(selectMatch[1]!) 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) return upstream.ok ? json({ ok: true }) - : json({ error: upstream.message || `内核拒绝了这次切换` }, 422) + : json({ message: upstream.message || '内核拒绝了这次切换' }, 422) } if (path.startsWith('/api/kernel/') && req.method === 'GET') { @@ -157,7 +180,7 @@ export function createRouter(env: Env, token: string) { const allowed = READABLE_KERNEL_PATHS.has(target) || DELAY_PATH.test(target) || HEALTHCHECK_PATH.test(target) if (!allowed) { - return json({ error: `不允许透传这个路径:${target}` }, 403) + return json({ message: `不允许访问该路径:${target}` }, 403) } const upstream = await proxyGet(env, target + url.search) return new Response(upstream.body, { @@ -171,6 +194,6 @@ export function createRouter(env: Env, token: string) { return json(listenerFields) } - return json({ error: '没有这个接口' }, 404) + return json({ message: '没有这个接口' }, 404) } } diff --git a/web/App.vue b/web/App.vue index c3cd394..19addb7 100644 --- a/web/App.vue +++ b/web/App.vue @@ -102,6 +102,18 @@ function optimisticSelect(group: string, member: string) { if (info) info.now = member } +/** + * 只重读运行时状态,不碰配置文件。 + * + * 切换策略组、测一条线路的延迟,改变的只是内核的运行状态。走完整的 load() 会让服务端 + * 重读整份配置、重新解析、再跑一遍引用校验,外加两个多余的往返——最便宜的动作触发了 + * 最贵的操作。 + */ +async function reloadRuntime() { + if (!kernel.value.online) return + runtime.value = await readRuntime() +} + function submitToken(value: string) { setToken(value) void load() @@ -184,8 +196,9 @@ const current = computed(() => NAV.find((n) => n.key === active.value)) :provider-lines="providerLines" :line-provider="lineProvider" :runtime="runtime" - @changed="load" + @saved="load" @optimistic-select="optimisticSelect" + @runtime-changed="reloadRuntime" />
diff --git a/web/api.ts b/web/api.ts index 1d88ff2..2ddf46b 100644 --- a/web/api.ts +++ b/web/api.ts @@ -23,16 +23,30 @@ export function clearToken(): void { localStorage.removeItem(TOKEN_KEY) } -/** 接口报错时抛这个,界面据此决定是提示重填令牌还是显示错误内容。 */ +/** + * 接口报错时抛这个。 + * + * 服务端的错误响应统一是 { message, hint? }:message 说发生了什么,hint 说下一步该干嘛。 + * 界面直接用这两个字段,不需要认得每个端点各自的错误形状。 + */ export class ApiError extends Error { constructor( readonly status: number, readonly payload: Record, ) { - super(typeof payload.message === 'string' ? payload.message : `请求失败(HTTP ${String(status)})`) + super( + typeof payload.message === 'string' + ? payload.message + : `请求失败(HTTP ${String(status)})`, + ) this.name = 'ApiError' } + /** 服务端给的下一步建议,没有就是空串。 */ + get hint(): string { + return typeof this.payload.hint === 'string' ? this.payload.hint : '' + } + get unauthorized(): boolean { return this.status === 401 } diff --git a/web/views/RouterPage.vue b/web/views/RouterPage.vue index 258b6cf..0732a4c 100644 --- a/web/views/RouterPage.vue +++ b/web/views/RouterPage.vue @@ -25,7 +25,13 @@ const props = defineProps<{ runtime: Record }>() -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 outbounds = computed(() => collectOutboundNames(props.data)) @@ -33,7 +39,10 @@ const outbounds = computed(() => collectOutboundNames(props.data)) const selectedId = ref(null) const saving = ref(false) const failure = ref<{ message: string; hint: string } | null>(null) +/** 成功的结果,绿色 */ const done = ref('') +/** 中性的告知,比如某条线路测不通——那是关于线路的结论,不是操作失败 */ +const notice = ref('') const fields = ref(null) const formOpen = ref(false) const formEditing = ref(null) @@ -88,17 +97,16 @@ async function commit(next: ConfigObject, success: string) { saving.value = true failure.value = null done.value = '' + notice.value = '' try { await api.applyConfig(next, props.revision) done.value = success - emit('changed') + emit('saved') } 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: '' } - } + failure.value = + error instanceof ApiError + ? { message: error.message, hint: error.hint } + : { message: String(error), hint: '' } } finally { saving.value = false } @@ -144,8 +152,10 @@ async function submitForm(entry: ConfigObject) { else listeners[editingIndex] = entry next.listeners = listeners - formOpen.value = false 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)}` } @@ -156,12 +166,15 @@ async function selectMember(group: string, member: string) { emit('optimisticSelect', group, member) failure.value = null done.value = '' + notice.value = '' try { await api.selectProxy(group, member) - emit('changed') + emit('runtimeChanged') } catch (error) { - const message = error instanceof ApiError ? String(error.payload.error ?? error.message) : String(error) - failure.value = { message, hint: '该操作仅修改内核运行状态,配置文件未被修改。' } + failure.value = { + message: error instanceof ApiError ? error.message : String(error), + hint: '该操作仅修改内核运行状态,配置文件未被修改。', + } } } @@ -201,10 +214,11 @@ async function testLine(name: string) { testing.value = [...testing.value, name] try { await api.testLine(name, props.lineProvider[name], TEST_TIMEOUT_MS) - emit('changed') + emit('runtimeChanged') } catch (error) { - // 测不通是关于线路的结论,不是 Patchbay 出了故障,所以不用错误框 - done.value = describeTestFailure(name, error) + // 测不通是关于线路的结论,不是 Patchbay 出了故障。既不该用错误框, + // 也不该用成功的绿色——它需要第三种中性的样子。 + notice.value = describeTestFailure(name, error) } finally { testing.value = testing.value.filter((n) => n !== name) } @@ -255,6 +269,7 @@ async function removeSelected() { {{ failure.hint }}

{{ done }}

+

{{ notice }}

    @@ -379,6 +394,12 @@ async function removeSelected() { margin-bottom: var(--s-4); } +.notice { + color: var(--c-text-soft); + font-size: var(--t-sm); + margin-bottom: var(--s-4); +} + /* 左列表右详情。窄屏时改成上下,详情不再吸顶 */ .split { display: grid;