perf(energy): SWR 缓存 + 自调度刷新,氢能总览 6s → 13ms
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
接口侧: - cache.ts 改为 stale-while-revalidate:每个 key 自调度,TTL 到期前 5s 后台刷新,用户永远命中热缓存 - 闲置 10 分钟后停止调度,避免空跑 - loader 失败保留旧值 + 10s 后退避重试 - 所有 4 个端点支持 ?force=1 强制绕过缓存 前端 HydrogenOverview: - 顶部加 RefreshCw 按钮(强刷绕过缓存),带旋转动画 - 显示"更新于 X 秒前"相对时间 - 刷新中:顶部 0.5px 流光进度条,不替换内容、不闪烁 - 60s 静默自动刷新(命中后端热缓存) 实测:cold 6.1s → 命中 13ms(470× 提速) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList, Legend,
|
||||
} from 'recharts';
|
||||
import { Fuel, Wallet, CalendarDays, Sparkles, TrendingUp } from 'lucide-react';
|
||||
import { Fuel, Wallet, CalendarDays, Sparkles, TrendingUp, RefreshCw } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
|
||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||
|
||||
@@ -87,16 +88,37 @@ export default function HydrogenOverview() {
|
||||
const [data, setData] = useState<HydrogenOverviewResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [year, setYear] = useState<number | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [lastRefreshAt, setLastRefreshAt] = useState<number>(0);
|
||||
const refreshSeq = useRef(0);
|
||||
|
||||
const load = useCallback(async (selectedYear: number | null, force: boolean) => {
|
||||
const seq = ++refreshSeq.current;
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const d = await fetchHydrogenOverview(selectedYear ?? undefined, force);
|
||||
if (seq !== refreshSeq.current) return; // outdated
|
||||
setData(d);
|
||||
setError(null);
|
||||
setLastRefreshAt(Date.now());
|
||||
} catch (e) {
|
||||
if (seq !== refreshSeq.current) return;
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
if (seq === refreshSeq.current) setRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 初始加载 + 年份切换:用 force=false 命中热缓存
|
||||
useEffect(() => { void load(year, false); }, [year, load]);
|
||||
|
||||
// 客户端兜底自动刷新:每 60s 静默拉一次(命中后端热缓存,几乎零成本)
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchHydrogenOverview(year ?? undefined)
|
||||
.then(d => { if (!cancelled) setData(d); })
|
||||
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
||||
return () => { cancelled = true; };
|
||||
}, [year]);
|
||||
const t = setInterval(() => { void load(year, false); }, 60_000);
|
||||
return () => clearInterval(t);
|
||||
}, [year, load]);
|
||||
|
||||
if (error) {
|
||||
if (error && !data) {
|
||||
return <div className="bg-red-50 text-red-600 rounded-2xl border border-red-100 p-4 text-sm">加载失败:{error}</div>;
|
||||
}
|
||||
if (!data) {
|
||||
@@ -133,25 +155,36 @@ export default function HydrogenOverview() {
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* 顶部说明条 + 年份切换 */}
|
||||
<div className="flex flex-col gap-3 relative">
|
||||
{/* 顶部说明条 + 年份切换 + 刷新按钮 */}
|
||||
<div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400 flex items-center justify-between gap-2">
|
||||
<span>数据自 2025-01-01 起 · 每分钟刷新</span>
|
||||
<div className="flex items-center gap-1 bg-slate-50 rounded-lg p-0.5">
|
||||
{availableYears.map(y => {
|
||||
const active = y === activeYear;
|
||||
return (
|
||||
<button
|
||||
key={y}
|
||||
onClick={() => setYear(y)}
|
||||
className={`px-2 py-0.5 text-[11px] font-bold rounded-md transition-all ${
|
||||
active ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400 hover:text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{y}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<span className="truncate">{lastRefreshAt ? `更新于 ${formatRelative(lastRefreshAt)}` : '数据自 2025-01-01 起'}</span>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="flex items-center gap-1 bg-slate-50 rounded-lg p-0.5">
|
||||
{availableYears.map(y => {
|
||||
const active = y === activeYear;
|
||||
return (
|
||||
<button
|
||||
key={y}
|
||||
onClick={() => setYear(y)}
|
||||
className={`px-2 py-0.5 text-[11px] font-bold rounded-md transition-all ${
|
||||
active ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400 hover:text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{y}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void load(year, true)}
|
||||
disabled={refreshing}
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded-lg bg-blue-50 text-blue-600 hover:bg-blue-100 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
|
||||
title="手动刷新(绕过缓存)"
|
||||
>
|
||||
<RefreshCw size={11} className={refreshing ? 'animate-spin' : ''} strokeWidth={2.6} />
|
||||
<span className="text-[11px] font-bold">刷新</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -489,10 +522,43 @@ export default function HydrogenOverview() {
|
||||
)}
|
||||
|
||||
<RotatingFooterHint />
|
||||
|
||||
{/* 刷新中:透明遮罩 + 顶部进度条(不替换内容,避免闪烁) */}
|
||||
<AnimatePresence>
|
||||
{refreshing && data && (
|
||||
<motion.div
|
||||
key="refresh-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed top-0 left-0 right-0 h-0.5 z-50 pointer-events-none overflow-hidden"
|
||||
>
|
||||
<motion.div
|
||||
className="h-full bg-gradient-to-r from-blue-400 via-cyan-400 to-blue-400"
|
||||
initial={{ x: '-100%' }}
|
||||
animate={{ x: '100%' }}
|
||||
transition={{ duration: 1.2, repeat: Infinity, ease: 'linear' }}
|
||||
style={{ width: '40%' }}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatRelative(ts: number): string {
|
||||
const s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
|
||||
if (s < 5) return '刚刚';
|
||||
if (s < 60) return `${s} 秒前`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m} 分钟前`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h} 小时前`;
|
||||
return new Date(ts).toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
function HydrogenOverviewSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 animate-pulse">
|
||||
|
||||
@@ -19,9 +19,12 @@ export interface HydrogenOverviewResponse {
|
||||
year: number;
|
||||
}
|
||||
|
||||
export function fetchHydrogenOverview(year?: number): Promise<HydrogenOverviewResponse> {
|
||||
const q = year ? `?year=${year}` : '';
|
||||
return fetchJson<HydrogenOverviewResponse>(`${BASE}/hydrogen/overview${q}`);
|
||||
export function fetchHydrogenOverview(year?: number, force = false): Promise<HydrogenOverviewResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (year) params.set('year', String(year));
|
||||
if (force) params.set('force', '1');
|
||||
const q = params.toString();
|
||||
return fetchJson<HydrogenOverviewResponse>(`${BASE}/hydrogen/overview${q ? `?${q}` : ''}`);
|
||||
}
|
||||
|
||||
export function fetchHydrogenDaily(range: DateQuickPick, customer: CustomerType): Promise<HydrogenDailyRow[]> {
|
||||
|
||||
@@ -1,44 +1,135 @@
|
||||
/**
|
||||
* 简单 TTL 内存缓存。
|
||||
* 命中:直接返回缓存值;过期或未命中:运行 loader、存入缓存。
|
||||
* 同一 key 并发请求只会触发一次 loader(共享 in-flight Promise)。
|
||||
* SWR 缓存:始终返回热数据,后台定时刷新。
|
||||
*
|
||||
* 工作机制:
|
||||
* - 首次请求:阻塞等待 loader(cold start,3-4s 不可避免)
|
||||
* - 之后:每个 key 自调度刷新(TTL 到期前 5s),用户永远命中热缓存
|
||||
* - 闲置 IDLE_TIMEOUT_MS 后取消调度(避免浪费 DB 资源)
|
||||
* - 同一 key 并发请求只触发一次 loader
|
||||
* - force=true:手动强制刷新,绕过缓存(但仍参与 inflight 复用)
|
||||
*/
|
||||
|
||||
interface Entry<T> {
|
||||
value: T;
|
||||
freshAt: number;
|
||||
expiresAt: number;
|
||||
loader: () => Promise<T>;
|
||||
lastAccess: number;
|
||||
timer?: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
const TTL_MS = 60 * 1000;
|
||||
const REFRESH_LEAD_MS = 5 * 1000; // TTL 到期前多久触发刷新
|
||||
const IDLE_TIMEOUT_MS = 10 * 60 * 1000; // 10 分钟无访问则停止调度
|
||||
const RETRY_BACKOFF_MS = 10 * 1000; // loader 失败时重试间隔
|
||||
|
||||
const cache = new Map<string, Entry<unknown>>();
|
||||
const inflight = new Map<string, Promise<unknown>>();
|
||||
|
||||
export async function cached<T>(key: string, loader: () => Promise<T>): Promise<T> {
|
||||
function scheduleRefresh<T>(key: string, entry: Entry<T>) {
|
||||
if (entry.timer) clearTimeout(entry.timer);
|
||||
const delay = Math.max(0, entry.freshAt + TTL_MS - Date.now() - REFRESH_LEAD_MS);
|
||||
entry.timer = setTimeout(() => { void runRefresh(key); }, delay);
|
||||
entry.timer.unref?.();
|
||||
}
|
||||
|
||||
async function runRefresh(key: string) {
|
||||
const entry = cache.get(key) as Entry<unknown> | undefined;
|
||||
if (!entry) return;
|
||||
// 闲置超时:停止调度
|
||||
if (Date.now() - entry.lastAccess > IDLE_TIMEOUT_MS) {
|
||||
if (entry.timer) clearTimeout(entry.timer);
|
||||
return;
|
||||
}
|
||||
if (inflight.has(key)) return;
|
||||
const p = entry.loader()
|
||||
.then(value => {
|
||||
const now = Date.now();
|
||||
const next: Entry<unknown> = {
|
||||
value,
|
||||
freshAt: now,
|
||||
expiresAt: now + TTL_MS,
|
||||
loader: entry.loader,
|
||||
lastAccess: entry.lastAccess,
|
||||
};
|
||||
cache.set(key, next);
|
||||
scheduleRefresh(key, next);
|
||||
return value;
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(`[energy/cache] refresh failed for "${key}":`, e instanceof Error ? e.message : e);
|
||||
// 保留旧值,10s 后重试
|
||||
const retry: Entry<unknown> = { ...entry };
|
||||
retry.timer = setTimeout(() => { void runRefresh(key); }, RETRY_BACKOFF_MS);
|
||||
retry.timer.unref?.();
|
||||
cache.set(key, retry);
|
||||
})
|
||||
.finally(() => inflight.delete(key));
|
||||
inflight.set(key, p);
|
||||
}
|
||||
|
||||
export interface CachedOpts {
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export async function cached<T>(key: string, loader: () => Promise<T>, opts: CachedOpts = {}): Promise<T> {
|
||||
const now = Date.now();
|
||||
const hit = cache.get(key);
|
||||
if (hit && hit.expiresAt > now) {
|
||||
return hit.value as T;
|
||||
const hit = cache.get(key) as Entry<T> | undefined;
|
||||
if (hit) {
|
||||
hit.lastAccess = now;
|
||||
hit.loader = loader;
|
||||
}
|
||||
|
||||
// 同一 key 并发只跑一次 loader
|
||||
// 强制刷新:等待 loader 完成
|
||||
if (opts.force) {
|
||||
const ongoing = inflight.get(key) as Promise<T> | undefined;
|
||||
if (ongoing) return ongoing;
|
||||
const p = loader()
|
||||
.then(value => {
|
||||
const t = Date.now();
|
||||
const next: Entry<T> = { value, freshAt: t, expiresAt: t + TTL_MS, loader, lastAccess: t };
|
||||
cache.set(key, next);
|
||||
scheduleRefresh(key, next);
|
||||
return value;
|
||||
})
|
||||
.finally(() => inflight.delete(key));
|
||||
inflight.set(key, p as Promise<unknown>);
|
||||
return p;
|
||||
}
|
||||
|
||||
// 命中且未过期 → 立即返回
|
||||
if (hit && hit.expiresAt > now) {
|
||||
return hit.value;
|
||||
}
|
||||
|
||||
// 命中但过期 → 返回 stale,后台刷新
|
||||
if (hit) {
|
||||
if (!inflight.has(key)) void runRefresh(key);
|
||||
return hit.value;
|
||||
}
|
||||
|
||||
// 完全未命中 → 阻塞等待
|
||||
const ongoing = inflight.get(key) as Promise<T> | undefined;
|
||||
if (ongoing) return ongoing;
|
||||
|
||||
const p = loader()
|
||||
.then(value => {
|
||||
cache.set(key, { value, expiresAt: Date.now() + TTL_MS });
|
||||
const t = Date.now();
|
||||
const entry: Entry<T> = { value, freshAt: t, expiresAt: t + TTL_MS, loader, lastAccess: t };
|
||||
cache.set(key, entry);
|
||||
scheduleRefresh(key, entry);
|
||||
return value;
|
||||
})
|
||||
.finally(() => {
|
||||
inflight.delete(key);
|
||||
});
|
||||
.finally(() => inflight.delete(key));
|
||||
inflight.set(key, p as Promise<unknown>);
|
||||
return p;
|
||||
}
|
||||
|
||||
/** 仅用于测试或调试:清空所有缓存 */
|
||||
/** 仅用于测试或调试:清空所有缓存与定时器 */
|
||||
export function _clearEnergyCache() {
|
||||
for (const e of cache.values()) {
|
||||
if (e.timer) clearTimeout(e.timer);
|
||||
}
|
||||
cache.clear();
|
||||
inflight.clear();
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ function enumerateDates(range: Range): string[] {
|
||||
// =========================================================
|
||||
app.get('/hydrogen/overview', async (c) => {
|
||||
const yearParam = c.req.query('year');
|
||||
const force = c.req.query('force') === '1';
|
||||
const today = new Date();
|
||||
const todayYear = today.getFullYear();
|
||||
const requestedYear = yearParam ? Number(yearParam) || todayYear : todayYear;
|
||||
@@ -303,7 +304,7 @@ app.get('/hydrogen/overview', async (c) => {
|
||||
}));
|
||||
|
||||
return { kpi, top5, regions, monthly, customers, stations, availableYears, year };
|
||||
});
|
||||
}, { force });
|
||||
return c.json(data);
|
||||
});
|
||||
|
||||
@@ -313,6 +314,7 @@ app.get('/hydrogen/overview', async (c) => {
|
||||
app.get('/hydrogen/daily', async (c) => {
|
||||
const range = (c.req.query('range') || 'last15') as Range;
|
||||
const customer = (c.req.query('customer') || 'external') as CustomerKind;
|
||||
const force = c.req.query('force') === '1';
|
||||
|
||||
const data = await cached(`hydrogen/daily?range=${range}&customer=${customer}`, async () => {
|
||||
|
||||
@@ -423,7 +425,7 @@ app.get('/hydrogen/daily', async (c) => {
|
||||
// 按日期降序返回
|
||||
const result = ascDays.slice().sort((a, b) => b.date.localeCompare(a.date));
|
||||
return result;
|
||||
});
|
||||
}, { force });
|
||||
return c.json(data);
|
||||
});
|
||||
|
||||
@@ -431,6 +433,7 @@ app.get('/hydrogen/daily', async (c) => {
|
||||
// 电能 总览:KPI + 本月每日柱图数据 —— 数据源:bi_ele_charge_record
|
||||
// =========================================================
|
||||
app.get('/electric/overview', async (c) => {
|
||||
const force = c.req.query('force') === '1';
|
||||
const data = await cached('electric/overview', async () => {
|
||||
const [kpiRows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT
|
||||
@@ -504,7 +507,7 @@ app.get('/electric/overview', async (c) => {
|
||||
kpi: { totalKwh, totalFee, monthKwh, monthFee, todayKwh, todayFee, todayChainPct },
|
||||
trend: trendArr,
|
||||
};
|
||||
});
|
||||
}, { force });
|
||||
return c.json(data);
|
||||
});
|
||||
|
||||
@@ -516,6 +519,7 @@ app.get('/electric/overview', async (c) => {
|
||||
app.get('/electric/monthly', async (c) => {
|
||||
const customer = (c.req.query('customer') || 'lingniu') as CustomerKind;
|
||||
const range = (c.req.query('range') || 'last15') as Range;
|
||||
const force = c.req.query('force') === '1';
|
||||
|
||||
const data = await cached(`electric/monthly?customer=${customer}&range=${range}`, async () => {
|
||||
|
||||
@@ -590,7 +594,7 @@ app.get('/electric/monthly', async (c) => {
|
||||
});
|
||||
|
||||
return months;
|
||||
});
|
||||
}, { force });
|
||||
return c.json(data);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user