diff --git a/src/modules/energy/HydrogenOverview.tsx b/src/modules/energy/HydrogenOverview.tsx index 0fb6d77..14ad74b 100644 --- a/src/modules/energy/HydrogenOverview.tsx +++ b/src/modules/energy/HydrogenOverview.tsx @@ -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(null); const [error, setError] = useState(null); const [year, setYear] = useState(null); + const [refreshing, setRefreshing] = useState(false); + const [lastRefreshAt, setLastRefreshAt] = useState(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
加载失败:{error}
; } if (!data) { @@ -133,25 +155,36 @@ export default function HydrogenOverview() { })); return ( -
- {/* 顶部说明条 + 年份切换 */} +
+ {/* 顶部说明条 + 年份切换 + 刷新按钮 */}
- 数据自 2025-01-01 起 · 每分钟刷新 -
- {availableYears.map(y => { - const active = y === activeYear; - return ( - - ); - })} + {lastRefreshAt ? `更新于 ${formatRelative(lastRefreshAt)}` : '数据自 2025-01-01 起'} +
+
+ {availableYears.map(y => { + const active = y === activeYear; + return ( + + ); + })} +
+
@@ -489,10 +522,43 @@ export default function HydrogenOverview() { )} + + {/* 刷新中:透明遮罩 + 顶部进度条(不替换内容,避免闪烁) */} + + {refreshing && data && ( + + + + )} +
); } +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 (
diff --git a/src/modules/energy/api.ts b/src/modules/energy/api.ts index d548702..2558882 100644 --- a/src/modules/energy/api.ts +++ b/src/modules/energy/api.ts @@ -19,9 +19,12 @@ export interface HydrogenOverviewResponse { year: number; } -export function fetchHydrogenOverview(year?: number): Promise { - const q = year ? `?year=${year}` : ''; - return fetchJson(`${BASE}/hydrogen/overview${q}`); +export function fetchHydrogenOverview(year?: number, force = false): Promise { + const params = new URLSearchParams(); + if (year) params.set('year', String(year)); + if (force) params.set('force', '1'); + const q = params.toString(); + return fetchJson(`${BASE}/hydrogen/overview${q ? `?${q}` : ''}`); } export function fetchHydrogenDaily(range: DateQuickPick, customer: CustomerType): Promise { diff --git a/src/server/routes/energy/cache.ts b/src/server/routes/energy/cache.ts index 8ac56ee..5c0fc95 100644 --- a/src/server/routes/energy/cache.ts +++ b/src/server/routes/energy/cache.ts @@ -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 { value: T; + freshAt: number; expiresAt: number; + loader: () => Promise; + 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>(); const inflight = new Map>(); -export async function cached(key: string, loader: () => Promise): Promise { +function scheduleRefresh(key: string, entry: Entry) { + 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 | 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 = { + 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 = { ...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(key: string, loader: () => Promise, opts: CachedOpts = {}): Promise { 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 | undefined; + if (hit) { + hit.lastAccess = now; + hit.loader = loader; } - // 同一 key 并发只跑一次 loader + // 强制刷新:等待 loader 完成 + if (opts.force) { + const ongoing = inflight.get(key) as Promise | undefined; + if (ongoing) return ongoing; + const p = loader() + .then(value => { + const t = Date.now(); + const next: Entry = { 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); + 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 | 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 = { 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); return p; } -/** 仅用于测试或调试:清空所有缓存 */ +/** 仅用于测试或调试:清空所有缓存与定时器 */ export function _clearEnergyCache() { + for (const e of cache.values()) { + if (e.timer) clearTimeout(e.timer); + } cache.clear(); inflight.clear(); } diff --git a/src/server/routes/energy/index.ts b/src/server/routes/energy/index.ts index c410804..b3d6478 100644 --- a/src/server/routes/energy/index.ts +++ b/src/server/routes/energy/index.ts @@ -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( `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); });