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 {
|
import {
|
||||||
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList, Legend,
|
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList, Legend,
|
||||||
} from 'recharts';
|
} 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 { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
|
||||||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||||||
|
|
||||||
@@ -87,16 +88,37 @@ export default function HydrogenOverview() {
|
|||||||
const [data, setData] = useState<HydrogenOverviewResponse | null>(null);
|
const [data, setData] = useState<HydrogenOverviewResponse | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [year, setYear] = useState<number | 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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
const t = setInterval(() => { void load(year, false); }, 60_000);
|
||||||
fetchHydrogenOverview(year ?? undefined)
|
return () => clearInterval(t);
|
||||||
.then(d => { if (!cancelled) setData(d); })
|
}, [year, load]);
|
||||||
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [year]);
|
|
||||||
|
|
||||||
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>;
|
return <div className="bg-red-50 text-red-600 rounded-2xl border border-red-100 p-4 text-sm">加载失败:{error}</div>;
|
||||||
}
|
}
|
||||||
if (!data) {
|
if (!data) {
|
||||||
@@ -133,10 +155,11 @@ export default function HydrogenOverview() {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
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">
|
<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>
|
<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">
|
<div className="flex items-center gap-1 bg-slate-50 rounded-lg p-0.5">
|
||||||
{availableYears.map(y => {
|
{availableYears.map(y => {
|
||||||
const active = y === activeYear;
|
const active = y === activeYear;
|
||||||
@@ -153,6 +176,16 @@ export default function HydrogenOverview() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* KPI 5 卡 */}
|
{/* KPI 5 卡 */}
|
||||||
@@ -489,10 +522,43 @@ export default function HydrogenOverview() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<RotatingFooterHint />
|
<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>
|
</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() {
|
function HydrogenOverviewSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3 animate-pulse">
|
<div className="flex flex-col gap-3 animate-pulse">
|
||||||
|
|||||||
@@ -19,9 +19,12 @@ export interface HydrogenOverviewResponse {
|
|||||||
year: number;
|
year: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchHydrogenOverview(year?: number): Promise<HydrogenOverviewResponse> {
|
export function fetchHydrogenOverview(year?: number, force = false): Promise<HydrogenOverviewResponse> {
|
||||||
const q = year ? `?year=${year}` : '';
|
const params = new URLSearchParams();
|
||||||
return fetchJson<HydrogenOverviewResponse>(`${BASE}/hydrogen/overview${q}`);
|
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[]> {
|
export function fetchHydrogenDaily(range: DateQuickPick, customer: CustomerType): Promise<HydrogenDailyRow[]> {
|
||||||
|
|||||||
@@ -1,44 +1,135 @@
|
|||||||
/**
|
/**
|
||||||
* 简单 TTL 内存缓存。
|
* SWR 缓存:始终返回热数据,后台定时刷新。
|
||||||
* 命中:直接返回缓存值;过期或未命中:运行 loader、存入缓存。
|
*
|
||||||
* 同一 key 并发请求只会触发一次 loader(共享 in-flight Promise)。
|
* 工作机制:
|
||||||
|
* - 首次请求:阻塞等待 loader(cold start,3-4s 不可避免)
|
||||||
|
* - 之后:每个 key 自调度刷新(TTL 到期前 5s),用户永远命中热缓存
|
||||||
|
* - 闲置 IDLE_TIMEOUT_MS 后取消调度(避免浪费 DB 资源)
|
||||||
|
* - 同一 key 并发请求只触发一次 loader
|
||||||
|
* - force=true:手动强制刷新,绕过缓存(但仍参与 inflight 复用)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface Entry<T> {
|
interface Entry<T> {
|
||||||
value: T;
|
value: T;
|
||||||
|
freshAt: number;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
|
loader: () => Promise<T>;
|
||||||
|
lastAccess: number;
|
||||||
|
timer?: NodeJS.Timeout;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TTL_MS = 60 * 1000;
|
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 cache = new Map<string, Entry<unknown>>();
|
||||||
const inflight = new Map<string, Promise<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>) {
|
||||||
const now = Date.now();
|
if (entry.timer) clearTimeout(entry.timer);
|
||||||
const hit = cache.get(key);
|
const delay = Math.max(0, entry.freshAt + TTL_MS - Date.now() - REFRESH_LEAD_MS);
|
||||||
if (hit && hit.expiresAt > now) {
|
entry.timer = setTimeout(() => { void runRefresh(key); }, delay);
|
||||||
return hit.value as T;
|
entry.timer.unref?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 同一 key 并发只跑一次 loader
|
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) as Entry<T> | undefined;
|
||||||
|
if (hit) {
|
||||||
|
hit.lastAccess = now;
|
||||||
|
hit.loader = 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;
|
const ongoing = inflight.get(key) as Promise<T> | undefined;
|
||||||
if (ongoing) return ongoing;
|
if (ongoing) return ongoing;
|
||||||
|
|
||||||
const p = loader()
|
const p = loader()
|
||||||
.then(value => {
|
.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;
|
return value;
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => inflight.delete(key));
|
||||||
inflight.delete(key);
|
|
||||||
});
|
|
||||||
inflight.set(key, p as Promise<unknown>);
|
inflight.set(key, p as Promise<unknown>);
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 仅用于测试或调试:清空所有缓存 */
|
/** 仅用于测试或调试:清空所有缓存与定时器 */
|
||||||
export function _clearEnergyCache() {
|
export function _clearEnergyCache() {
|
||||||
|
for (const e of cache.values()) {
|
||||||
|
if (e.timer) clearTimeout(e.timer);
|
||||||
|
}
|
||||||
cache.clear();
|
cache.clear();
|
||||||
inflight.clear();
|
inflight.clear();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ function enumerateDates(range: Range): string[] {
|
|||||||
// =========================================================
|
// =========================================================
|
||||||
app.get('/hydrogen/overview', async (c) => {
|
app.get('/hydrogen/overview', async (c) => {
|
||||||
const yearParam = c.req.query('year');
|
const yearParam = c.req.query('year');
|
||||||
|
const force = c.req.query('force') === '1';
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const todayYear = today.getFullYear();
|
const todayYear = today.getFullYear();
|
||||||
const requestedYear = yearParam ? Number(yearParam) || todayYear : todayYear;
|
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 };
|
return { kpi, top5, regions, monthly, customers, stations, availableYears, year };
|
||||||
});
|
}, { force });
|
||||||
return c.json(data);
|
return c.json(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -313,6 +314,7 @@ app.get('/hydrogen/overview', async (c) => {
|
|||||||
app.get('/hydrogen/daily', async (c) => {
|
app.get('/hydrogen/daily', async (c) => {
|
||||||
const range = (c.req.query('range') || 'last15') as Range;
|
const range = (c.req.query('range') || 'last15') as Range;
|
||||||
const customer = (c.req.query('customer') || 'external') as CustomerKind;
|
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 () => {
|
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));
|
const result = ascDays.slice().sort((a, b) => b.date.localeCompare(a.date));
|
||||||
return result;
|
return result;
|
||||||
});
|
}, { force });
|
||||||
return c.json(data);
|
return c.json(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -431,6 +433,7 @@ app.get('/hydrogen/daily', async (c) => {
|
|||||||
// 电能 总览:KPI + 本月每日柱图数据 —— 数据源:bi_ele_charge_record
|
// 电能 总览:KPI + 本月每日柱图数据 —— 数据源:bi_ele_charge_record
|
||||||
// =========================================================
|
// =========================================================
|
||||||
app.get('/electric/overview', async (c) => {
|
app.get('/electric/overview', async (c) => {
|
||||||
|
const force = c.req.query('force') === '1';
|
||||||
const data = await cached('electric/overview', async () => {
|
const data = await cached('electric/overview', async () => {
|
||||||
const [kpiRows] = await pool.query<RowDataPacket[]>(
|
const [kpiRows] = await pool.query<RowDataPacket[]>(
|
||||||
`SELECT
|
`SELECT
|
||||||
@@ -504,7 +507,7 @@ app.get('/electric/overview', async (c) => {
|
|||||||
kpi: { totalKwh, totalFee, monthKwh, monthFee, todayKwh, todayFee, todayChainPct },
|
kpi: { totalKwh, totalFee, monthKwh, monthFee, todayKwh, todayFee, todayChainPct },
|
||||||
trend: trendArr,
|
trend: trendArr,
|
||||||
};
|
};
|
||||||
});
|
}, { force });
|
||||||
return c.json(data);
|
return c.json(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -516,6 +519,7 @@ app.get('/electric/overview', async (c) => {
|
|||||||
app.get('/electric/monthly', async (c) => {
|
app.get('/electric/monthly', async (c) => {
|
||||||
const customer = (c.req.query('customer') || 'lingniu') as CustomerKind;
|
const customer = (c.req.query('customer') || 'lingniu') as CustomerKind;
|
||||||
const range = (c.req.query('range') || 'last15') as Range;
|
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 () => {
|
const data = await cached(`electric/monthly?customer=${customer}&range=${range}`, async () => {
|
||||||
|
|
||||||
@@ -590,7 +594,7 @@ app.get('/electric/monthly', async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return months;
|
return months;
|
||||||
});
|
}, { force });
|
||||||
return c.json(data);
|
return c.json(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user