refactor: surface hydrogen source failures
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import {
|
import {
|
||||||
|
ApiRequestError,
|
||||||
_clearApiClientInflightForTests,
|
_clearApiClientInflightForTests,
|
||||||
fetchJson,
|
fetchJson,
|
||||||
setTokenGetter,
|
setTokenGetter,
|
||||||
@@ -59,3 +60,40 @@ test('does not retain a failed GET request', async () => {
|
|||||||
_clearApiClientInflightForTests();
|
_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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
+38
-1
@@ -3,6 +3,27 @@
|
|||||||
let tokenGetter: () => string | null = () => null;
|
let tokenGetter: () => string | null = () => null;
|
||||||
const inflightGetRequests = new Map<string, Promise<unknown>>();
|
const inflightGetRequests = new Map<string, Promise<unknown>>();
|
||||||
|
|
||||||
|
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) {
|
export function setTokenGetter(fn: () => string | null) {
|
||||||
tokenGetter = fn;
|
tokenGetter = fn;
|
||||||
}
|
}
|
||||||
@@ -19,7 +40,23 @@ async function executeJsonRequest<T>(url: string, options: RequestInit | undefin
|
|||||||
window.dispatchEvent(new CustomEvent('auth:unauthorized'));
|
window.dispatchEvent(new CustomEvent('auth:unauthorized'));
|
||||||
throw new Error('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();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, type ComponentType, type ReactNode } from 'react';
|
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 { AnimatePresence, motion } from 'motion/react';
|
||||||
import { cn } from '../../lib/cn';
|
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 (
|
return (
|
||||||
<div className="rounded-2xl border border-rose-100 bg-rose-50 p-4 text-rose-700 shadow-sm">
|
<div className="rounded-2xl border border-rose-100 bg-rose-50 p-4 text-rose-700 shadow-sm">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<AlertCircle size={18} className="mt-0.5 shrink-0" />
|
<AlertCircle size={18} className="mt-0.5 shrink-0" />
|
||||||
<div>
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-sm font-black">加载失败</div>
|
<div className="text-sm font-black">{title}</div>
|
||||||
<div className="mt-1 text-xs font-bold leading-relaxed">{message}</div>
|
<div className="mt-1 text-xs font-bold leading-relaxed">{message}</div>
|
||||||
|
{onRetry ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRetry}
|
||||||
|
disabled={retrying}
|
||||||
|
className="mt-3 inline-flex h-8 items-center gap-1.5 rounded-lg border border-rose-200 bg-white px-3 text-xs font-black text-rose-700 transition-colors hover:bg-rose-100 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<RefreshCw size={13} className={retrying ? 'animate-spin' : ''} />
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export default function HydrogenDaily() {
|
|||||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null);
|
const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [retryKey, setRetryKey] = useState(0);
|
||||||
|
|
||||||
const effectiveRange = useMemo(() => normalizeRange(dateRange.start, dateRange.end), [dateRange.start, dateRange.end]);
|
const effectiveRange = useMemo(() => normalizeRange(dateRange.start, dateRange.end), [dateRange.start, dateRange.end]);
|
||||||
const selectedStationId = drillContext.level === 'station' ? drillContext.stationId : undefined;
|
const selectedStationId = drillContext.level === 'station' ? drillContext.stationId : undefined;
|
||||||
@@ -90,7 +91,7 @@ export default function HydrogenDaily() {
|
|||||||
.then(r => { if (!cancelled) setRows(r); })
|
.then(r => { if (!cancelled) setRows(r); })
|
||||||
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
||||||
return () => { cancelled = true; };
|
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]);
|
const trendData = useMemo(() => (rows ? [...rows].sort((a, b) => a.date.localeCompare(b.date)) : []), [rows]);
|
||||||
@@ -164,6 +165,12 @@ export default function HydrogenDaily() {
|
|||||||
}, 'replace');
|
}, 'replace');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const retryLoad = () => {
|
||||||
|
setRows(null);
|
||||||
|
setError(null);
|
||||||
|
setRetryKey(key => key + 1);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handlePopState = () => {
|
const handlePopState = () => {
|
||||||
const next = parseHydrogenDrillContext(window.location.search);
|
const next = parseHydrogenDrillContext(window.location.search);
|
||||||
@@ -186,6 +193,7 @@ export default function HydrogenDaily() {
|
|||||||
<button
|
<button
|
||||||
key={opt.id}
|
key={opt.id}
|
||||||
onClick={() => applyQuickPick(opt.id)}
|
onClick={() => applyQuickPick(opt.id)}
|
||||||
|
aria-pressed={pick === opt.id}
|
||||||
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
|
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
|
||||||
pick === opt.id
|
pick === opt.id
|
||||||
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
|
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
|
||||||
@@ -197,6 +205,7 @@ export default function HydrogenDaily() {
|
|||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
onClick={() => setPick('custom')}
|
onClick={() => setPick('custom')}
|
||||||
|
aria-pressed={pick === 'custom'}
|
||||||
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
|
className={`min-h-9 shrink-0 rounded-xl border px-3 text-[12px] font-black transition-colors ${
|
||||||
pick === 'custom'
|
pick === 'custom'
|
||||||
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
|
? 'border-blue-200 bg-blue-50 text-blue-600 shadow-sm'
|
||||||
@@ -211,6 +220,8 @@ export default function HydrogenDaily() {
|
|||||||
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
|
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
|
||||||
<span className="block text-[10px] font-black text-slate-400">开始日期</span>
|
<span className="block text-[10px] font-black text-slate-400">开始日期</span>
|
||||||
<input
|
<input
|
||||||
|
id="hydrogen-start-date"
|
||||||
|
name="hydrogenStartDate"
|
||||||
type="date"
|
type="date"
|
||||||
value={dateRange.start}
|
value={dateRange.start}
|
||||||
onChange={e => updateDateRange('start', e.target.value)}
|
onChange={e => updateDateRange('start', e.target.value)}
|
||||||
@@ -221,6 +232,8 @@ export default function HydrogenDaily() {
|
|||||||
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
|
<label className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2">
|
||||||
<span className="block text-[10px] font-black text-slate-400">结束日期</span>
|
<span className="block text-[10px] font-black text-slate-400">结束日期</span>
|
||||||
<input
|
<input
|
||||||
|
id="hydrogen-end-date"
|
||||||
|
name="hydrogenEndDate"
|
||||||
type="date"
|
type="date"
|
||||||
value={dateRange.end}
|
value={dateRange.end}
|
||||||
onChange={e => updateDateRange('end', e.target.value)}
|
onChange={e => updateDateRange('end', e.target.value)}
|
||||||
@@ -235,6 +248,7 @@ export default function HydrogenDaily() {
|
|||||||
<button
|
<button
|
||||||
key={c}
|
key={c}
|
||||||
onClick={() => updateCustomer(c)}
|
onClick={() => updateCustomer(c)}
|
||||||
|
aria-pressed={customer === c}
|
||||||
className={`flex min-h-9 items-center justify-center gap-1.5 rounded-lg text-[12px] font-black transition-all ${
|
className={`flex min-h-9 items-center justify-center gap-1.5 rounded-lg text-[12px] font-black transition-all ${
|
||||||
customer === c ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'
|
customer === c ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-700'
|
||||||
}`}
|
}`}
|
||||||
@@ -284,15 +298,23 @@ export default function HydrogenDaily() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
{error && (
|
||||||
|
<ErrorState
|
||||||
|
title="氢能数据源暂不可用"
|
||||||
|
message={error}
|
||||||
|
onRetry={retryLoad}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && rows !== null && <div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
<MetricTile icon={Fuel} label={`${scopeLabel}加氢量`} value={totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="Kg" helper={rangeText} />
|
<MetricTile icon={Fuel} label={`${scopeLabel}加氢量`} value={totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="Kg" helper={rangeText} />
|
||||||
<MetricTile icon={Truck} label="车辆归属" value={customer === 'external' ? '外部' : '羚牛'} helper="当前筛选口径" tone="emerald" />
|
<MetricTile icon={Truck} label="车辆归属" value={customer === 'external' ? '外部' : '羚牛'} helper="当前筛选口径" tone="emerald" />
|
||||||
<MetricTile icon={TrendingUp} label="有效天数" value={`${activeDays}/${rows?.length ?? 0}`} helper={`日均 ${avgKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} Kg`} tone="amber" />
|
<MetricTile icon={TrendingUp} label="有效天数" value={`${activeDays}/${rows?.length ?? 0}`} helper={`日均 ${avgKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} Kg`} tone="amber" />
|
||||||
<MetricTile icon={Plug} label="涉及加氢站" value={stationCount} unit="站" helper="按明细站点去重" tone="slate" />
|
<MetricTile icon={Plug} label="涉及加氢站" value={stationCount} unit="站" helper="按明细站点去重" tone="slate" />
|
||||||
</div>
|
</div>}
|
||||||
|
|
||||||
{/* 外部车辆:新系统数据还没准备好 */}
|
{/* 外部车辆:新系统数据还没准备好 */}
|
||||||
{customer === 'external' && rows !== null && totalKg === 0 && (
|
{!error && customer === 'external' && rows !== null && totalKg === 0 && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 8 }}
|
initial={{ opacity: 0, y: 8 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
@@ -313,7 +335,7 @@ export default function HydrogenDaily() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 时段加氢量柱图(外部车辆无数据时不渲染) */}
|
{/* 时段加氢量柱图(外部车辆无数据时不渲染) */}
|
||||||
{!(customer === 'external' && totalKg === 0) && trendData.length > 0 && (
|
{!error && !(customer === 'external' && totalKg === 0) && trendData.length > 0 && (
|
||||||
<SurfaceCard>
|
<SurfaceCard>
|
||||||
<div className="flex items-center justify-between px-4 pt-4 mb-2">
|
<div className="flex items-center justify-between px-4 pt-4 mb-2">
|
||||||
<span className="text-sm font-bold text-slate-700">每日加氢量</span>
|
<span className="text-sm font-bold text-slate-700">每日加氢量</span>
|
||||||
@@ -390,7 +412,7 @@ export default function HydrogenDaily() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 表格(外部车辆 + 全 0 时不渲染,由上方友好空状态替代) */}
|
{/* 表格(外部车辆 + 全 0 时不渲染,由上方友好空状态替代) */}
|
||||||
{!(customer === 'external' && rows !== null && totalKg === 0) && (
|
{!error && !(customer === 'external' && rows !== null && totalKg === 0) && (
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
|
||||||
{/* 表头 */}
|
{/* 表头 */}
|
||||||
<div className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500">
|
<div className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500">
|
||||||
@@ -407,9 +429,7 @@ export default function HydrogenDaily() {
|
|||||||
<span />
|
<span />
|
||||||
</div>
|
</div>
|
||||||
{/* 主行 + 子行 */}
|
{/* 主行 + 子行 */}
|
||||||
{error ? (
|
{rows === null ? (
|
||||||
<div className="p-3"><ErrorState message={error} /></div>
|
|
||||||
) : rows === null ? (
|
|
||||||
<div className="p-3"><LoadingState label="正在加载加氢明细" /></div>
|
<div className="p-3"><LoadingState label="正在加载加氢明细" /></div>
|
||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 ? (
|
||||||
<div className="p-3"><EmptyState title="暂无加氢数据" description="请切换时间范围或车辆归属" /></div>
|
<div className="p-3"><EmptyState title="暂无加氢数据" description="请切换时间范围或车辆归属" /></div>
|
||||||
@@ -476,7 +496,7 @@ export default function HydrogenDaily() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<RotatingFooterHint />
|
{!error && <RotatingFooterHint />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { motion, AnimatePresence } from 'motion/react';
|
|||||||
import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
|
import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
|
||||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
import { buildHydrogenDrillUrl } from './hydrogen-drill-context';
|
import { buildHydrogenDrillUrl } from './hydrogen-drill-context';
|
||||||
|
import { ErrorState } from '../../components/ui/surface';
|
||||||
|
|
||||||
const REGION_COLORS = [
|
const REGION_COLORS = [
|
||||||
'#3b82f6', '#22d3ee', '#a855f7', '#f59e0b',
|
'#3b82f6', '#22d3ee', '#a855f7', '#f59e0b',
|
||||||
@@ -120,7 +121,14 @@ export default function HydrogenOverview() {
|
|||||||
}, [year, load]);
|
}, [year, load]);
|
||||||
|
|
||||||
if (error && !data) {
|
if (error && !data) {
|
||||||
return <div className="bg-red-50 text-red-600 rounded-2xl border border-red-100 p-4 text-sm">加载失败:{error}</div>;
|
return (
|
||||||
|
<ErrorState
|
||||||
|
title="氢能数据源暂不可用"
|
||||||
|
message={error}
|
||||||
|
onRetry={() => void load(year, true)}
|
||||||
|
retrying={refreshing}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return <HydrogenOverviewSkeleton />;
|
return <HydrogenOverviewSkeleton />;
|
||||||
|
|||||||
@@ -26,6 +26,26 @@ app.use('*', async (c, next) => {
|
|||||||
return next();
|
return next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.onError((error, c) => {
|
||||||
|
const sourceCode = typeof error === 'object' && error !== null && 'code' in error
|
||||||
|
? String((error as { code?: unknown }).code ?? 'UNKNOWN')
|
||||||
|
: 'UNKNOWN';
|
||||||
|
if (c.req.path.includes('/hydrogen/')) {
|
||||||
|
console.error('[energy/hydrogen] data source unavailable', { code: sourceCode });
|
||||||
|
return c.json({
|
||||||
|
error: '当前无法读取氢能业务数据,请稍后重试',
|
||||||
|
code: 'HYDROGEN_SOURCE_UNAVAILABLE',
|
||||||
|
retryable: true,
|
||||||
|
}, 503);
|
||||||
|
}
|
||||||
|
console.error('[energy] request failed', { path: c.req.path, code: sourceCode });
|
||||||
|
return c.json({
|
||||||
|
error: '能源数据请求失败,请稍后重试',
|
||||||
|
code: 'ENERGY_REQUEST_FAILED',
|
||||||
|
retryable: true,
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
|
||||||
const HYDROGEN_MIN_DATE = '2024-01-01';
|
const HYDROGEN_MIN_DATE = '2024-01-01';
|
||||||
|
|
||||||
// hydrogen_fuel_ledger.refuel_time 已是业务本地时间字面值,直接使用即可(不再 +8 小时)
|
// hydrogen_fuel_ledger.refuel_time 已是业务本地时间字面值,直接使用即可(不再 +8 小时)
|
||||||
|
|||||||
Reference in New Issue
Block a user