diff --git a/src/modules/energy/HydrogenOverview.tsx b/src/modules/energy/HydrogenOverview.tsx index 8791ead..d2104af 100644 --- a/src/modules/energy/HydrogenOverview.tsx +++ b/src/modules/energy/HydrogenOverview.tsx @@ -1,8 +1,17 @@ import { useEffect, useState } from 'react'; -import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList } from 'recharts'; +import { + BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList, +} from 'recharts'; +import { Fuel, Wallet, CalendarDays, Sparkles } from 'lucide-react'; import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api'; import RotatingFooterHint from '../../components/RotatingFooterHint'; +const REGION_COLORS = [ + '#3b82f6', '#22d3ee', '#a855f7', '#f59e0b', + '#10b981', '#ef4444', '#6366f1', '#14b8a6', + '#94a3b8', +]; + interface YAxisTickProps { x?: number; y?: number; @@ -24,13 +33,55 @@ function RankYAxisTick({ x = 0, y = 0, index = 0, payload }: YAxisTickProps) { ); } -const REGION_COLORS = [ - '#3b82f6', '#22d3ee', '#a855f7', '#f59e0b', - '#10b981', '#ef4444', '#6366f1', '#14b8a6', - '#94a3b8', -]; +// ---------- 数字格式化 ---------- +function fmtKg(kg: number): { value: string; unit: string } { + if (kg >= 1000) return { value: (kg / 1000).toFixed(2), unit: 'T' }; + return { value: kg.toFixed(2), unit: 'Kg' }; +} +function fmtYuan(yuan: number): { value: string; unit: string } { + if (yuan >= 100_000_000) return { value: (yuan / 100_000_000).toFixed(2), unit: '亿元' }; + if (yuan >= 10_000) { + const w = yuan / 10_000; + return { value: w.toLocaleString('zh-CN', { maximumFractionDigits: 2 }), unit: '万元' }; + } + return { value: yuan.toLocaleString('zh-CN', { maximumFractionDigits: 0 }), unit: '元' }; +} +// ---------- KPI 卡 ---------- +interface KpiCardProps { + icon: React.ReactNode; + label: string; + hero: { value: string; unit: string }; + rows: { label: string; value: string }[]; + accentClass: string; + iconBg: string; +} +function KpiCard({ icon, label, hero, rows, accentClass, iconBg }: KpiCardProps) { + return ( +
+
+
+ {icon} +
+ {label} +
+
+ {hero.value} + {hero.unit} +
+
+ {rows.map((r, i) => ( +
+ {r.label} + {r.value} +
+ ))} +
+
+ ); +} +// ============================================================ export default function HydrogenOverview() { const [data, setData] = useState(null); const [error, setError] = useState(null); @@ -52,14 +103,120 @@ export default function HydrogenOverview() { const k = data.kpi; const top5 = data.top5; const regions = data.regions; + const monthly = data.monthly; + + const yearKgFmt = fmtKg(k.yearKg); + const yearFeeFmt = fmtYuan(k.yearFee); + const ourYearKgFmt = fmtKg(k.ourYearKg); + const customerYearKgFmt = fmtKg(k.customerYearKg); + const monthKgFmt = fmtKg(k.monthKg); + const monthFeeFmt = fmtYuan(k.monthFee); + const todayKgFmt = fmtKg(k.todayKg); + const todayFeeFmt = fmtYuan(k.todayFee); + const customerYearFee = Math.max(0, k.yearFee - k.ourYearFee); + const customerYearFeeFmt = fmtYuan(customerYearFee); + const todayYear = new Date().getFullYear(); + return (
-
- 数据自 2025-01-01 起,每 1 分钟更新 + {/* 顶部说明条 */} +
+ 数据自 2025-01-01 起 · 每分钟刷新 + {todayYear} 年累计口径
+ + {/* KPI 4 卡 */} +
+ } + iconBg="bg-cyan-50" + accentClass="text-slate-800" + label="累计加氢量" + hero={yearKgFmt} + rows={[ + { label: '我司', value: `${ourYearKgFmt.value} ${ourYearKgFmt.unit}` }, + { label: '客户', value: `${customerYearKgFmt.value} ${customerYearKgFmt.unit}` }, + ]} + /> + } + iconBg="bg-blue-50" + accentClass="text-slate-800" + label="累计加氢费" + hero={{ value: `¥${yearFeeFmt.value}`, unit: yearFeeFmt.unit }} + rows={[ + { label: '我司承担', value: `¥${fmtYuan(k.ourYearFee).value} ${fmtYuan(k.ourYearFee).unit}` }, + { label: '客户承担', value: `¥${customerYearFeeFmt.value} ${customerYearFeeFmt.unit}` }, + ]} + /> + } + iconBg="bg-amber-50" + accentClass="text-amber-600" + label="本月加氢" + hero={monthKgFmt} + rows={[ + { label: '加氢费', value: `¥${monthFeeFmt.value} ${monthFeeFmt.unit}` }, + { label: '占年比', value: `${k.yearKg > 0 ? (k.monthKg / k.yearKg * 100).toFixed(1) : '0.0'}%` }, + ]} + /> + } + iconBg="bg-violet-50" + accentClass="text-violet-600" + label="本日加氢" + hero={todayKgFmt} + rows={[ + { label: '加氢费', value: `¥${todayFeeFmt.value} ${todayFeeFmt.unit}` }, + { label: '占月比', value: `${k.monthKg > 0 ? (k.todayKg / k.monthKg * 100).toFixed(1) : '0.0'}%` }, + ]} + /> +
+ + {/* 月度趋势:年内每月 */} + {monthly.length > 0 && ( +
+
+ {todayYear} 年月度加氢量 + 单位 Kg +
+ + + v.slice(5).replace(/^0/, '') + '月'} + tick={{ fontSize: 10, fill: '#94a3b8' }} + tickLine={false} + axisLine={false} + interval={0} + /> + + [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })} Kg`, '加氢量']} + labelFormatter={(d) => `${d}`} + contentStyle={{ borderRadius: 12, fontSize: 12 }} + cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }} + /> + + {monthly.map((_, i) => ( + + ))} + + + + + + + + + +
+ )} + + {/* Top5 + 区域占比 */}
{/* Top5 加氢站 */} -
+
加氢站加注量 Top5 单位 Kg @@ -81,7 +238,7 @@ export default function HydrogenOverview() { /> {top5.map((_, i) => ( - + ))}
- {/* 区域占比环 */} -
+ + {/* 区域占比 */} +
各区域加氢占比
@@ -131,15 +289,16 @@ export default function HydrogenOverview() {
{regions.map((r, i) => (
- - {r.region} - {(r.share * 100).toFixed(1)}% + + {r.region} + {(r.share * 100).toFixed(1)}%
))}
+
); @@ -148,13 +307,41 @@ export default function HydrogenOverview() { function HydrogenOverviewSkeleton() { return (
- {/* 顶部说明条 */}
+ {/* 4 卡占位 */} +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ + {/* 月度柱图占位 */} +
+
+
+
+
+
+ {[60, 75, 50, 80, 35, 90, 45].map((h, i) => ( +
+ ))} +
+
+
- {/* Top5 占位 */}
@@ -171,8 +358,6 @@ function HydrogenOverviewSkeleton() { ))}
- - {/* 区域占比环 占位 */}
diff --git a/src/modules/energy/api.ts b/src/modules/energy/api.ts index 2ee72ea..dc14282 100644 --- a/src/modules/energy/api.ts +++ b/src/modules/energy/api.ts @@ -1,6 +1,6 @@ import { fetchJson } from '../../auth/api-client'; import type { - HydrogenKpi, HydrogenStationTop, HydrogenRegionShare, HydrogenDailyRow, + HydrogenKpi, HydrogenStationTop, HydrogenRegionShare, HydrogenMonthlyPoint, HydrogenDailyRow, ElectricKpi, ElectricDailyRow, ElectricMonthGroup, CustomerType, DateQuickPick, } from './types'; @@ -11,6 +11,7 @@ export interface HydrogenOverviewResponse { kpi: HydrogenKpi; top5: HydrogenStationTop[]; regions: HydrogenRegionShare[]; + monthly: HydrogenMonthlyPoint[]; } export function fetchHydrogenOverview(): Promise { diff --git a/src/modules/energy/types.ts b/src/modules/energy/types.ts index 304c57a..7345394 100644 --- a/src/modules/energy/types.ts +++ b/src/modules/energy/types.ts @@ -29,6 +29,12 @@ export interface HydrogenRegionShare { share: number; } +export interface HydrogenMonthlyPoint { + month: string; // YYYY-MM + kg: number; + fee: number; +} + export interface HydrogenStationRow { name: string; pricePerKg: number; diff --git a/src/server/routes/energy/index.ts b/src/server/routes/energy/index.ts index 6066c4a..e678a7c 100644 --- a/src/server/routes/energy/index.ts +++ b/src/server/routes/energy/index.ts @@ -165,7 +165,33 @@ app.get('/hydrogen/overview', async (c) => { ...(restKg > 0 ? [{ region: '其他', kg: restKg, share: restKg / totalKg }] : []), ]; - return { kpi, top5, regions }; + // 月度趋势(本年内 12 个月,缺失月补 0) + const [monthRows] = await pool.query( + `SELECT DATE_FORMAT(hydrogen_time, '%Y-%m') AS m, + ROUND(SUM(hydrogen_quantity), 2) AS kg, + ROUND(SUM(cost_expense), 2) AS fee + FROM tab_energy_hydrogen_bill + WHERE is_deleted = 0 + AND hydrogen_time >= ? + AND YEAR(hydrogen_time) = YEAR(CURDATE()) + GROUP BY m + ORDER BY m`, + [HYDROGEN_MIN_DATE], + ); + const monthMap = new Map(); + for (const r of monthRows) { + monthMap.set(r.m as string, { kg: Number(r.kg) || 0, fee: Number(r.fee) || 0 }); + } + const year = new Date().getFullYear(); + const currentMonth = new Date().getMonth() + 1; + const monthly: { month: string; kg: number; fee: number }[] = []; + for (let mi = 1; mi <= currentMonth; mi++) { + const key = `${year}-${String(mi).padStart(2, '0')}`; + const v = monthMap.get(key) || { kg: 0, fee: 0 }; + monthly.push({ month: key, kg: v.kg, fee: v.fee }); + } + + return { kpi, top5, regions, monthly }; }); return c.json(data); });