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,18 +1,28 @@
import { useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import { ChevronRight } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import TrendBadge from './TrendBadge';
import { ELECTRIC_MONTHLY } from './mock';
import type { CustomerType } from './types';
import { fetchElectricMonthly } from './api';
import type { CustomerType, ElectricMonthGroup } from './types';
export default function ElectricDaily() {
const [customer, setCustomer] = useState<CustomerType>('external');
const [openMonths, setOpenMonths] = useState<Set<string>>(new Set([ELECTRIC_MONTHLY[0]?.month]));
const [months, setMonths] = useState<ElectricMonthGroup[] | null>(null);
const [openMonths, setOpenMonths] = useState<Set<string>>(new Set());
const [error, setError] = useState<string | null>(null);
const months = useMemo(() => {
// mock 暂不区分客户类型customer 切换不影响数据;保留 UI 切换以与 BI 一致
void customer;
return ELECTRIC_MONTHLY;
useEffect(() => {
let cancelled = false;
setError(null);
fetchElectricMonthly(customer)
.then(m => {
if (cancelled) return;
setMonths(m);
// 默认展开最新一个月
if (m.length > 0) setOpenMonths(prev => prev.size > 0 ? prev : new Set([m[0].month]));
})
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
return () => { cancelled = true; };
}, [customer]);
const toggleMonth = (m: string) => setOpenMonths(prev => {
@@ -47,7 +57,13 @@ export default function ElectricDaily() {
<span className="text-right">()</span>
<span className="text-right"></span>
</div>
{months.map(m => {
{error ? (
<div className="px-3 py-10 text-center text-red-500 text-[12px] font-bold">{error}</div>
) : months === null ? (
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold"></div>
) : months.length === 0 ? (
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold"></div>
) : months.map(m => {
const open = openMonths.has(m.month);
return (
<div key={m.month} className="border-t border-slate-100 first:border-t-0">

View File

@@ -1,8 +1,8 @@
import { useMemo } from 'react';
import { useEffect, useState } from 'react';
import { Wallet, BatteryCharging, CalendarClock } from 'lucide-react';
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip } from 'recharts';
import TrendBadge from './TrendBadge';
import { ELECTRIC_KPI, ELECTRIC_MONTHLY } from './mock';
import { fetchElectricOverview, type ElectricOverviewResponse } from './api';
function fmtYuan(yuan: number) {
return `¥${yuan.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`;
@@ -12,15 +12,32 @@ function fmtKwh(kwh: number) {
}
export default function ElectricOverview() {
const k = ELECTRIC_KPI;
const [data, setData] = useState<ElectricOverviewResponse | null>(null);
const [error, setError] = useState<string | null>(null);
// 本月每日数据(按日期升序,便于柱图按时间从左到右展示)
const trendData = useMemo(() => {
const first = ELECTRIC_MONTHLY[0];
if (!first) return [];
return [...first.rows].sort((a, b) => a.date.localeCompare(b.date));
useEffect(() => {
let cancelled = false;
fetchElectricOverview()
.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 trendData = data.trend;
// 当电能数据滞后(本月无数据走 fallback柱图标题显示实际月份
const trendMonthLabel = trendData[0]?.date.slice(0, 7);
const currentMonth = new Date().toISOString().slice(0, 7);
const chartTitle = trendMonthLabel && trendMonthLabel !== currentMonth
? `${trendMonthLabel} 每日充电`
: '本月每日充电';
return (
<div className="flex flex-col gap-3">
{/* 横向 mini KPI 头 */}
@@ -52,7 +69,7 @@ export default function ElectricOverview() {
{/* 本月每日充电柱图 */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-slate-700"></span>
<span className="text-sm font-bold text-slate-700">{chartTitle}</span>
<span className="text-[11px] text-slate-400 font-bold"> </span>
</div>
<ResponsiveContainer width="100%" height={160}>

View File

@@ -1,13 +1,11 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { ChevronRight } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip } from 'recharts';
import TrendBadge from './TrendBadge';
import { HYDROGEN_DAILY } from './mock';
import { fetchHydrogenDaily } from './api';
import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types';
const TODAY = new Date('2026-04-28');
const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
{ id: 'today', label: '当天' },
{ id: 'thisWeek', label: '本周' },
@@ -17,51 +15,25 @@ const QUICK_PICK_OPTIONS: Array<{ id: DateQuickPick; label: string }> = [
{ id: 'last30', label: '最近30天' },
];
function isInPick(date: string, pick: DateQuickPick): boolean {
const d = new Date(date);
switch (pick) {
case 'today': {
return d.toISOString().slice(0, 10) === TODAY.toISOString().slice(0, 10);
}
case 'thisWeek': {
const day = TODAY.getDay() || 7;
const start = new Date(TODAY); start.setDate(TODAY.getDate() - day + 1);
return d >= start && d <= TODAY;
}
case 'thisMonth':
return d.getFullYear() === TODAY.getFullYear() && d.getMonth() === TODAY.getMonth();
case 'thisQuarter': {
const q = Math.floor(TODAY.getMonth() / 3);
const dq = Math.floor(d.getMonth() / 3);
return d.getFullYear() === TODAY.getFullYear() && dq === q;
}
case 'last7': {
const c = new Date(TODAY); c.setDate(TODAY.getDate() - 6);
return d >= c && d <= TODAY;
}
case 'last30': {
const c = new Date(TODAY); c.setDate(TODAY.getDate() - 29);
return d >= c && d <= TODAY;
}
}
}
export default function HydrogenDaily() {
const [pick, setPick] = useState<DateQuickPick>('last30');
const [customer, setCustomer] = useState<CustomerType>('external');
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null);
const [error, setError] = useState<string | null>(null);
const rows = useMemo<HydrogenDailyRow[]>(() => {
return HYDROGEN_DAILY
.filter(r => r.customerType === customer)
.filter(r => isInPick(r.date, pick))
.sort((a, b) => b.date.localeCompare(a.date));
useEffect(() => {
let cancelled = false;
setError(null);
fetchHydrogenDaily(pick, customer)
.then(r => { if (!cancelled) setRows(r); })
.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
return () => { cancelled = true; };
}, [pick, customer]);
// 柱图:按日期升序,用于"从左到右时间流"
const trendData = useMemo(() => [...rows].sort((a, b) => a.date.localeCompare(b.date)), [rows]);
const totalKg = rows.reduce((a, r) => a + r.totalKg, 0);
const trendData = useMemo(() => (rows ? [...rows].sort((a, b) => a.date.localeCompare(b.date)) : []), [rows]);
const totalKg = (rows ?? []).reduce((a, r) => a + r.totalKg, 0);
const toggle = (date: string) => setExpanded(prev => {
const next = new Set(prev);
@@ -161,7 +133,11 @@ export default function HydrogenDaily() {
<span />
</div>
{/* 主行 + 子行 */}
{rows.length === 0 ? (
{error ? (
<div className="px-3 py-10 text-center text-red-500 text-[12px] font-bold">{error}</div>
) : rows === null ? (
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold"></div>
) : rows.length === 0 ? (
<div className="px-3 py-10 text-center text-slate-400 text-[12px] font-bold"></div>
) : rows.map(r => {
const open = expanded.has(r.date);

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>

37
src/modules/energy/api.ts Normal file
View File

@@ -0,0 +1,37 @@
import { fetchJson } from '../../auth/api-client';
import type {
HydrogenKpi, HydrogenStationTop, HydrogenRegionShare, HydrogenDailyRow,
ElectricKpi, ElectricDailyRow, ElectricMonthGroup,
CustomerType, DateQuickPick,
} from './types';
const BASE = '/api/energy';
export interface HydrogenOverviewResponse {
kpi: HydrogenKpi;
top5: HydrogenStationTop[];
regions: HydrogenRegionShare[];
}
export function fetchHydrogenOverview(): Promise<HydrogenOverviewResponse> {
return fetchJson<HydrogenOverviewResponse>(`${BASE}/hydrogen/overview`);
}
export function fetchHydrogenDaily(range: DateQuickPick, customer: CustomerType): Promise<HydrogenDailyRow[]> {
const q = new URLSearchParams({ range, customer });
return fetchJson<HydrogenDailyRow[]>(`${BASE}/hydrogen/daily?${q.toString()}`);
}
export interface ElectricOverviewResponse {
kpi: ElectricKpi;
trend: ElectricDailyRow[];
}
export function fetchElectricOverview(): Promise<ElectricOverviewResponse> {
return fetchJson<ElectricOverviewResponse>(`${BASE}/electric/overview`);
}
export function fetchElectricMonthly(customer: CustomerType): Promise<ElectricMonthGroup[]> {
const q = new URLSearchParams({ customer });
return fetchJson<ElectricMonthGroup[]>(`${BASE}/electric/monthly?${q.toString()}`);
}

View File

@@ -1,129 +0,0 @@
import type {
HydrogenKpi, HydrogenStationTop, HydrogenRegionShare,
HydrogenDailyRow, ElectricKpi, ElectricMonthGroup,
} from './types';
export const HYDROGEN_KPI: HydrogenKpi = {
yearKg: 362_430,
yearFee: 10_664_600,
ourYearKg: 245_960,
ourYearFee: 6_955_200,
customerYearKg: 116_470,
monthKg: 85_410,
monthFee: 2_612_300,
todayKg: 0,
todayFee: 0,
lingniuBornKg: 302_620,
lingniuBornFee: 100_300,
};
export const HYDROGEN_STATIONS_TOP5: HydrogenStationTop[] = [
{ rank: 1, name: '佛山豪汇石油加氢站', kg: 78_421.30, fee: 2_744_745, share: 0.216 },
{ rank: 2, name: '嘉兴嘉燃经开站', kg: 65_028.80, fee: 2_275_988, share: 0.179 },
{ rank: 3, name: '广州新锋交通联新加氢站', kg: 54_882.50, fee: 2_195_300, share: 0.151 },
{ rank: 4, name: '北京京辉加氢站', kg: 43_127.40, fee: 1_596_714, share: 0.119 },
{ rank: 5, name: '新疆乌鲁木齐加氢站', kg: 38_601.20, fee: 1_351_042, share: 0.106 },
];
export const HYDROGEN_REGION_SHARE: HydrogenRegionShare[] = [
{ region: '广东', kg: 148_400, share: 0.409 },
{ region: '浙江', kg: 72_500, share: 0.200 },
{ region: '北京', kg: 43_500, share: 0.120 },
{ region: '新疆', kg: 39_000, share: 0.108 },
{ region: '上海', kg: 21_800, share: 0.060 },
{ region: '四川', kg: 16_300, share: 0.045 },
{ region: '河北', kg: 10_900, share: 0.030 },
{ region: '山东', kg: 7_300, share: 0.020 },
{ region: '其他', kg: 2_730, share: 0.008 },
];
const HD_STATION_NAMES = [
{ name: '佛山豪汇石油加氢站', pricePerKg: 35 },
{ name: '嘉兴嘉燃经开站', pricePerKg: 35 },
{ name: '广州新锋交通联新加氢站', pricePerKg: 40 },
{ name: '北京京辉加氢站', pricePerKg: 38 },
{ name: '新疆乌鲁木齐加氢站', pricePerKg: 35 },
];
function makeStations(seed: number): HydrogenDailyRow['stations'] {
const count = 2 + (seed % 3);
return HD_STATION_NAMES.slice(0, count).map((s, i) => ({
name: s.name,
pricePerKg: s.pricePerKg,
kg: Math.round(((seed * 13 + i * 17) % 1500 + 80) * 100) / 100,
chainPct: ((seed * 7 + i * 11) % 200 - 100) / 100 / 2,
}));
}
export const HYDROGEN_DAILY: HydrogenDailyRow[] = Array.from({ length: 30 }, (_, i) => {
const day = 28 - i;
const month = day > 0 ? 4 : 3;
const realDay = day > 0 ? day : day + 31;
const date = `2026-${String(month).padStart(2, '0')}-${String(realDay).padStart(2, '0')}`;
const stations = makeStations(i + 1);
const totalKg = stations.reduce((a, b) => a + b.kg, 0);
const chainPct = ((i * 23) % 100 - 50) / 100;
return {
date,
totalKg: Math.round(totalKg * 100) / 100,
chainPct,
customerType: i % 3 === 0 ? 'lingniu' : 'external',
stations,
};
});
const APR_DAYS: Array<[string, number, number]> = [
['2026-04-26', 510.91, 184.82],
['2026-04-25', 2859.61, 314.20],
['2026-04-24', 802.64, 437.83],
['2026-04-23', 2520.22, 495.05],
['2026-04-22', 2234.23, 653.73],
['2026-04-21', 3520.86, 510.06],
['2026-04-20', 527.65, 295.05],
['2026-04-19', 3151.97, 593.55],
['2026-04-18', 1616.38, 183.84],
['2026-04-17', 1069.09, 597.73],
['2026-04-16', 2186.34, 396.63],
['2026-04-15', 2568.16, 572.27],
['2026-04-14', 2315.38, 489.82],
['2026-04-13', 2274.88, 423.15],
['2026-04-12', 2742.85, 248.52],
['2026-04-11', 599.67, 299.13],
['2026-04-10', 2576.59, 806.44],
['2026-04-09', 2627.30, 814.80],
['2026-04-08', 2058.35, 573.11],
['2026-04-07', 2739.61, 261.56],
];
function buildElectricRows(days: Array<[string, number, number]>) {
return days.map(([date, kwh, fee], i) => {
const prev = days[i + 1]?.[1];
const chainPct = prev ? (kwh - prev) / prev : 0;
return { date, kwh, fee, chainPct };
});
}
const APR_KWH_SUM = APR_DAYS.reduce((a, [, k]) => a + k, 0);
const APR_FEE_SUM = APR_DAYS.reduce((a, [, , f]) => a + f, 0);
const [TODAY_DATE, TODAY_KWH, TODAY_FEE] = APR_DAYS[0];
const [, PREV_KWH] = APR_DAYS[1];
void TODAY_DATE;
export const ELECTRIC_KPI: ElectricKpi = {
totalKwh: 817_632.24,
totalFee: 151_542.92,
monthKwh: APR_KWH_SUM,
monthFee: APR_FEE_SUM,
todayKwh: TODAY_KWH,
todayFee: TODAY_FEE,
todayChainPct: (TODAY_KWH - PREV_KWH) / PREV_KWH,
};
export const ELECTRIC_MONTHLY: ElectricMonthGroup[] = [
{
month: '2026-04',
kwh: APR_DAYS.reduce((a, [, k]) => a + k, 0),
fee: APR_DAYS.reduce((a, [, , f]) => a + f, 0),
rows: buildElectricRows(APR_DAYS),
},
];