feat(energy): connect to real DB (lingniu_prod)

Replace front-end mock data with live API backed by:
- tab_energy_hydrogen_bill (66.5K rows) joined with
  tab_hydrogen_site (internal stations) and tab_outside_hydrogen_site
  (external stations, joined via inner_site_id)
- tab_energy_electricity_bill (4.4K rows, all 龙王路充电站)

New server routes (src/server/routes/energy/):
- GET /api/energy/hydrogen/overview  → KPI + Top5 站点 + 区域占比
- GET /api/energy/hydrogen/daily?range=&customer=  → 日级 + 站点级下钻
- GET /api/energy/electric/overview  → KPI + 本月柱图 (fallback to last
  available month if current month has no data)
- GET /api/energy/electric/monthly?customer=  → 6 个月分组日级表

Business rules encoded server-side:
- 客户类型: customer_id IS NULL = 羚牛承担, NOT NULL = 外部
- 时区: DATETIME 列字面值是 UTC,分组前 +8h 转成 CST
- 数据清理: hydrogen_time >= 2024-01-01 (排除 1900 年脏数据)
- 站点名 fallback: short_name → name → fixed_station_name → station_name → '未知站点'
- 区域归一化: SUBSTRING_INDEX(city, '-', -1) 取最后一段,去掉 '省'/'市'
  让 '四川省-成都市' 和 '成都市' 合并为 '成都'

Component changes:
- All 4 components (HydrogenOverview, HydrogenDaily, ElectricOverview,
  ElectricDaily) now use useEffect + fetch with loading/error states
- HydrogenDaily filtering moved to server (range + customer params)
  → drops client-side TODAY constant + isInPick switch
- ElectricOverview chart title is dynamic: shows 'YYYY-MM 每日充电'
  when fallback kicks in (current month has no data)
- mock.ts deleted

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
kkfluous
2026-04-28 16:42:37 +08:00
parent 7de2d1ecd5
commit 9a4f1945d9
8 changed files with 526 additions and 197 deletions

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { Fuel, Wallet, Coins, CalendarClock } from 'lucide-react';
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList } from 'recharts';
import { HYDROGEN_KPI, HYDROGEN_STATIONS_TOP5, HYDROGEN_REGION_SHARE } from './mock';
import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
interface YAxisTickProps {
x?: number;
@@ -41,7 +42,26 @@ function fmtYuan(yuan: number) {
}
export default function HydrogenOverview() {
const k = HYDROGEN_KPI;
const [data, setData] = useState<HydrogenOverviewResponse | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
fetchHydrogenOverview()
.then(d => { if (!cancelled) setData(d); })
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
return () => { cancelled = true; };
}, []);
if (error) {
return <div className="bg-red-50 text-red-600 rounded-2xl border border-red-100 p-4 text-sm">{error}</div>;
}
if (!data) {
return <div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-6 text-center text-slate-400 text-sm"></div>;
}
const k = data.kpi;
const top5 = data.top5;
const regions = data.regions;
return (
<div className="flex flex-col gap-3">
<div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400">
@@ -106,7 +126,7 @@ export default function HydrogenOverview() {
<span className="text-[11px] text-slate-400 font-bold"> Kg</span>
</div>
<ResponsiveContainer width="100%" height={260}>
<BarChart data={HYDROGEN_STATIONS_TOP5} layout="vertical" margin={{ top: 4, right: 80, bottom: 4, left: 0 }}>
<BarChart data={top5} layout="vertical" margin={{ top: 4, right: 80, bottom: 4, left: 0 }}>
<XAxis type="number" hide />
<YAxis
type="category"
@@ -121,7 +141,7 @@ export default function HydrogenOverview() {
contentStyle={{ borderRadius: 12, fontSize: 12 }}
/>
<Bar dataKey="kg" radius={[6, 6, 6, 6]}>
{HYDROGEN_STATIONS_TOP5.map((_, i) => (
{top5.map((_, i) => (
<Cell key={i} fill={`url(#topBarGrad)`} />
))}
<LabelList
@@ -150,14 +170,14 @@ export default function HydrogenOverview() {
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={HYDROGEN_REGION_SHARE}
data={regions}
dataKey="kg"
nameKey="region"
innerRadius={48}
outerRadius={80}
paddingAngle={1}
>
{HYDROGEN_REGION_SHARE.map((_, i) => (
{regions.map((_, i) => (
<Cell key={i} fill={REGION_COLORS[i % REGION_COLORS.length]} />
))}
</Pie>
@@ -166,11 +186,11 @@ export default function HydrogenOverview() {
</ResponsiveContainer>
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
<div className="text-[10px] text-slate-400 font-bold"></div>
<div className="text-base font-bold text-slate-700 leading-tight">{(HYDROGEN_KPI.yearKg / 1000).toFixed(2)}T</div>
<div className="text-base font-bold text-slate-700 leading-tight">{(k.yearKg / 1000).toFixed(2)}T</div>
</div>
</div>
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-1 text-[11px]">
{HYDROGEN_REGION_SHARE.map((r, i) => (
{regions.map((r, i) => (
<div key={r.region} className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full" style={{ background: REGION_COLORS[i % REGION_COLORS.length] }} />
<span className="text-slate-600">{r.region}</span>