From 326201ababf214d9afa1733e09485a9b82b23002 Mon Sep 17 00:00:00 2001 From: kkfluous Date: Fri, 7 Aug 2026 15:29:21 +0800 Subject: [PATCH] refactor: surface hydrogen source failures --- src/auth/api-client.test.ts | 38 +++++++++++++++++++++++ src/auth/api-client.ts | 39 +++++++++++++++++++++++- src/components/ui/surface.tsx | 29 +++++++++++++++--- src/modules/energy/HydrogenDaily.tsx | 40 ++++++++++++++++++------- src/modules/energy/HydrogenOverview.tsx | 10 ++++++- src/server/routes/energy/index.ts | 20 +++++++++++++ 6 files changed, 160 insertions(+), 16 deletions(-) diff --git a/src/auth/api-client.test.ts b/src/auth/api-client.test.ts index dfdc571..4aadc81 100644 --- a/src/auth/api-client.test.ts +++ b/src/auth/api-client.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { + ApiRequestError, _clearApiClientInflightForTests, fetchJson, setTokenGetter, @@ -59,3 +60,40 @@ test('does not retain a failed GET request', async () => { _clearApiClientInflightForTests(); } }); + +test('surfaces structured API errors without retaining the failed request', async () => { + const originalFetch = globalThis.fetch; + let calls = 0; + globalThis.fetch = (async () => { + calls += 1; + if (calls > 1) return jsonResponse({ ok: true }); + return new Response(JSON.stringify({ + error: '当前无法读取氢能业务数据,请稍后重试', + code: 'HYDROGEN_SOURCE_UNAVAILABLE', + retryable: true, + }), { + status: 503, + headers: { 'content-type': 'application/json' }, + }); + }) as typeof fetch; + _clearApiClientInflightForTests(); + + try { + await assert.rejects( + fetchJson('/api/energy/hydrogen/overview'), + (error: unknown) => { + assert.ok(error instanceof ApiRequestError); + assert.equal(error.message, '当前无法读取氢能业务数据,请稍后重试'); + assert.equal(error.status, 503); + assert.equal(error.code, 'HYDROGEN_SOURCE_UNAVAILABLE'); + assert.equal(error.retryable, true); + return true; + }, + ); + assert.deepEqual(await fetchJson('/api/energy/hydrogen/overview'), { ok: true }); + assert.equal(calls, 2); + } finally { + globalThis.fetch = originalFetch; + _clearApiClientInflightForTests(); + } +}); diff --git a/src/auth/api-client.ts b/src/auth/api-client.ts index d2df265..884ef60 100644 --- a/src/auth/api-client.ts +++ b/src/auth/api-client.ts @@ -3,6 +3,27 @@ let tokenGetter: () => string | null = () => null; const inflightGetRequests = new Map>(); +export class ApiRequestError extends Error { + readonly status: number; + readonly code: string | null; + readonly retryable: boolean; + + constructor(message: string, status: number, code: string | null, retryable: boolean) { + super(message); + this.name = 'ApiRequestError'; + this.status = status; + this.code = code; + this.retryable = retryable; + } +} + +interface ApiErrorPayload { + error?: unknown; + message?: unknown; + code?: unknown; + retryable?: unknown; +} + export function setTokenGetter(fn: () => string | null) { tokenGetter = fn; } @@ -19,7 +40,23 @@ async function executeJsonRequest(url: string, options: RequestInit | undefin window.dispatchEvent(new CustomEvent('auth:unauthorized')); throw new Error('Unauthorized'); } - if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`); + if (!res.ok) { + let payload: ApiErrorPayload = {}; + try { + const parsed: unknown = await res.json(); + if (typeof parsed === 'object' && parsed !== null) payload = parsed as ApiErrorPayload; + } catch { + payload = {}; + } + const message = typeof payload?.error === 'string' + ? payload.error + : typeof payload?.message === 'string' + ? payload.message + : `请求失败(${res.status})`; + const code = typeof payload?.code === 'string' ? payload.code : null; + const retryable = typeof payload?.retryable === 'boolean' ? payload.retryable : res.status >= 500; + throw new ApiRequestError(message, res.status, code, retryable); + } return res.json(); } diff --git a/src/components/ui/surface.tsx b/src/components/ui/surface.tsx index a3d0ded..01e1b12 100644 --- a/src/components/ui/surface.tsx +++ b/src/components/ui/surface.tsx @@ -1,5 +1,5 @@ import { useState, type ComponentType, type ReactNode } from 'react'; -import { AlertCircle, Info, Loader2, SearchX, X } from 'lucide-react'; +import { AlertCircle, Info, Loader2, RefreshCw, SearchX, X } from 'lucide-react'; import { AnimatePresence, motion } from 'motion/react'; import { cn } from '../../lib/cn'; @@ -258,14 +258,35 @@ export function EmptyState({ ); } -export function ErrorState({ message }: { message: string }) { +export function ErrorState({ + message, + title = '加载失败', + onRetry, + retrying = false, +}: { + message: string; + title?: string; + onRetry?: () => void; + retrying?: boolean; +}) { return (
-
-
加载失败
+
+
{title}
{message}
+ {onRetry ? ( + + ) : null}
diff --git a/src/modules/energy/HydrogenDaily.tsx b/src/modules/energy/HydrogenDaily.tsx index 707cfde..0b08fe8 100644 --- a/src/modules/energy/HydrogenDaily.tsx +++ b/src/modules/energy/HydrogenDaily.tsx @@ -64,6 +64,7 @@ export default function HydrogenDaily() { const [expanded, setExpanded] = useState>(new Set()); const [rows, setRows] = useState(null); const [error, setError] = useState(null); + const [retryKey, setRetryKey] = useState(0); const effectiveRange = useMemo(() => normalizeRange(dateRange.start, dateRange.end), [dateRange.start, dateRange.end]); const selectedStationId = drillContext.level === 'station' ? drillContext.stationId : undefined; @@ -90,7 +91,7 @@ export default function HydrogenDaily() { .then(r => { if (!cancelled) setRows(r); }) .catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); }); return () => { cancelled = true; }; - }, [pick, customer, effectiveRange.start, effectiveRange.end, selectedStationId, selectedCustomerName]); + }, [pick, customer, effectiveRange.start, effectiveRange.end, selectedStationId, selectedCustomerName, retryKey]); // 柱图:按日期升序,用于"从左到右时间流" const trendData = useMemo(() => (rows ? [...rows].sort((a, b) => a.date.localeCompare(b.date)) : []), [rows]); @@ -164,6 +165,12 @@ export default function HydrogenDaily() { }, 'replace'); }; + const retryLoad = () => { + setRows(null); + setError(null); + setRetryKey(key => key + 1); + }; + useEffect(() => { const handlePopState = () => { const next = parseHydrogenDrillContext(window.location.search); @@ -186,6 +193,7 @@ export default function HydrogenDaily() {