Mobile + desktop responsive front-end module with mocked data. Each task = lint pass + chrome-devtools visual verification (no test framework in this project). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
45 KiB
能源管理模块实施计划
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 在底部导航新增「能源管理」入口,含氢能(总览 + 每日明细)与电能(每日明细)两个 Tab,全前端 mock 数据,移动 / 桌面双端响应。
Architecture: React 19 + Tailwind 4 模块,沿用 src/modules/mileage/ 的目录骨架与 sub-nav 风格;图表用 recharts;状态全在组件内 useState;后端 0 改动;UI 双端响应通过 Tailwind md: / lg: 断点完成。
Tech Stack: React 19, TypeScript, Tailwind 4, recharts (已有), lucide-react (已有), motion (已有)。无单元测试框架——本项目历史模块均无测试,验证靠 npm run lint 通过 + chrome-devtools MCP 浏览器视觉确认。
Spec: docs/superpowers/specs/2026-04-28-energy-module-design.md
File Structure
| 文件 | 责任 | 任务 |
|---|---|---|
src/modules/energy/types.ts |
共享类型定义 | T1 |
src/modules/energy/mock.ts |
所有 mock 数据 | T1 |
src/modules/energy/TrendBadge.tsx |
环比 pill 组件,全模块复用 | T2 |
src/modules/energy/EnergyModule.tsx |
顶级容器 + 氢能/电能 切换 | T2 |
src/modules/energy/HydrogenView.tsx |
氢能 Tab 内 总览/每日 切换 | T3 |
src/modules/energy/HydrogenOverview.tsx |
KPI 卡 + Top5 横柱 + 区域占比环 | T3, T4 |
src/modules/energy/HydrogenDaily.tsx |
加氢量明细表 + 站点级下钻 | T5 |
src/modules/energy/ElectricView.tsx |
mini KPI 头 + 月份分组充电表 | T6 |
src/App.tsx (modify) |
在 BASE_MODULES 注册 energy | T2 |
src/components/Shell.tsx (modify) |
PATH_MAP 加 /energy |
T2 |
Task 1: 类型与 mock 数据
Files:
-
Create:
src/modules/energy/types.ts -
Create:
src/modules/energy/mock.ts -
Step 1.1: 创建类型文件
写入 src/modules/energy/types.ts:
export type CustomerType = 'external' | 'lingniu';
export type DateQuickPick = 'today' | 'thisWeek' | 'thisMonth' | 'thisQuarter' | 'last7' | 'last30';
export interface HydrogenKpi {
yearKg: number;
yearFee: number;
ourYearKg: number;
ourYearFee: number;
customerYearKg: number;
monthKg: number;
monthFee: number;
todayKg: number;
todayFee: number;
lingniuBornKg: number;
lingniuBornFee: number;
}
export interface HydrogenStationTop {
rank: number;
name: string;
kg: number;
fee: number;
share: number;
}
export interface HydrogenRegionShare {
region: string;
kg: number;
share: number;
}
export interface HydrogenStationRow {
name: string;
pricePerKg: number;
kg: number;
chainPct: number;
}
export interface HydrogenDailyRow {
date: string;
totalKg: number;
chainPct: number;
customerType: CustomerType;
stations: HydrogenStationRow[];
}
export interface ElectricKpi {
totalKwh: number;
totalFee: number;
monthKwh: number;
monthFee: number;
todayKwh: number;
todayFee: number;
todayChainPct: number;
}
export interface ElectricDailyRow {
date: string;
kwh: number;
fee: number;
chainPct: number;
}
export interface ElectricMonthGroup {
month: string;
kwh: number;
fee: number;
rows: ElectricDailyRow[];
}
- Step 1.2: 创建 mock 数据
写入 src/modules/energy/mock.ts:
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,
};
});
export const ELECTRIC_KPI: ElectricKpi = {
totalKwh: 817_632.24,
totalFee: 151_542.92,
monthKwh: 42_318.56,
monthFee: 8_437.12,
todayKwh: 510.91,
todayFee: 184.82,
todayChainPct: -0.821,
};
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 };
});
}
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),
},
];
- Step 1.3: 类型检查通过
npm run lint
期望:无错误退出。
- Step 1.4: 提交
git add src/modules/energy/types.ts src/modules/energy/mock.ts
git commit -m "feat(energy): add types and mock data for new module
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
Task 2: 模块容器 + 注册到 App
Files:
-
Create:
src/modules/energy/TrendBadge.tsx -
Create:
src/modules/energy/EnergyModule.tsx -
Modify:
src/App.tsx -
Modify:
src/components/Shell.tsx -
Step 2.1: 共用 TrendBadge 组件
写入 src/modules/energy/TrendBadge.tsx:
import { ArrowUp, ArrowDown, Minus } from 'lucide-react';
export interface TrendBadgeProps {
value: number; // -1..+1, 0 表示持平
className?: string;
}
export default function TrendBadge({ value, className = '' }: TrendBadgeProps) {
const isUp = value > 0.0001;
const isDown = value < -0.0001;
const cls = isUp
? 'bg-emerald-50 text-emerald-600'
: isDown
? 'bg-red-50 text-red-600'
: 'bg-slate-100 text-slate-500';
const Icon = isUp ? ArrowUp : isDown ? ArrowDown : Minus;
const sign = isUp ? '+' : '';
return (
<span className={`inline-flex items-center gap-0.5 rounded-full px-2 py-0.5 text-[11px] font-bold ${cls} ${className}`}>
<Icon size={11} />
{sign}{(value * 100).toFixed(2)}%
</span>
);
}
- Step 2.2: EnergyModule 顶层容器
写入 src/modules/energy/EnergyModule.tsx:
import { useState } from 'react';
import { Fuel, BatteryCharging } from 'lucide-react';
import { motion } from 'motion/react';
import HydrogenView from './HydrogenView';
import ElectricView from './ElectricView';
type TopTab = 'hydrogen' | 'electric';
export default function EnergyModule() {
const [activeTab, setActiveTab] = useState<TopTab>('hydrogen');
return (
<div className="min-h-screen bg-[#F8F9FB] text-gray-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
<div className="max-w-6xl mx-auto flex flex-col gap-3 pb-16 landscape:pb-0 landscape:h-full landscape:flex-1 landscape:overflow-hidden">
<div className="bg-white px-4 py-2 rounded-2xl border border-slate-100 shadow-sm flex items-center gap-6 sticky top-0 z-30">
<button
onClick={() => setActiveTab('hydrogen')}
className={`flex items-center gap-2 py-1 transition-all relative ${activeTab === 'hydrogen' ? 'text-blue-600' : 'text-slate-400'}`}
>
<Fuel size={14} />
<span className="text-[11px] font-bold">氢能</span>
{activeTab === 'hydrogen' && (
<motion.div layoutId="activeEnergyTopTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
)}
</button>
<button
onClick={() => setActiveTab('electric')}
className={`flex items-center gap-2 py-1 transition-all relative ${activeTab === 'electric' ? 'text-blue-600' : 'text-slate-400'}`}
>
<BatteryCharging size={14} />
<span className="text-[11px] font-bold">电能</span>
{activeTab === 'electric' && (
<motion.div layoutId="activeEnergyTopTab" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
)}
</button>
</div>
{activeTab === 'hydrogen' ? <HydrogenView /> : <ElectricView />}
</div>
</div>
);
}
- Step 2.3: 占位的 HydrogenView / ElectricView
写入 src/modules/energy/HydrogenView.tsx:
export default function HydrogenView() {
return (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-6 text-center text-slate-400 text-sm">
氢能视图占位 — 将在 Task 3-5 实现
</div>
);
}
写入 src/modules/energy/ElectricView.tsx:
export default function ElectricView() {
return (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-6 text-center text-slate-400 text-sm">
电能视图占位 — 将在 Task 6 实现
</div>
);
}
- Step 2.4: 注册到 App.tsx
修改 src/App.tsx:在 import 区加 Zap、EnergyModule,并把 BASE_MODULES 改为:
import { Truck, Route, Activity, Zap } from 'lucide-react';
import EnergyModule from './modules/energy/EnergyModule';
// ... 其他 import 不变 ...
const BASE_MODULES: ModuleConfig[] = [
{ id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule },
{ id: 'mileage', label: '里程管理', icon: Route, component: MileageModule },
{ id: 'energy', label: '能源管理', icon: Zap, component: EnergyModule },
];
- Step 2.5: 注册 path 到 Shell.tsx
修改 src/components/Shell.tsx 的 PATH_MAP,加一条 '/energy': 'energy':
const PATH_MAP: Record<string, string> = {
'/vehicle': 'assets',
'/assets': 'assets',
'/mileage': 'mileage',
'/scheduling': 'scheduling',
'/energy': 'energy',
};
- Step 2.6: 类型检查 + 视觉验证
npm run lint
期望:无错误。
npm run dev
后台启动后用 chrome-devtools MCP 打开 http://localhost:3000/#energy:
-
移动尺寸(375×812)和桌面尺寸(1440×900)下都应能看到底部 nav(移动)/ 左侧 nav(桌面)多了「能源管理」图标
-
点进去顶部应有「氢能 / 电能」sub-nav,下划线滑块在切换时移动
-
默认显示氢能视图占位文字
-
Step 2.7: 提交
git add src/modules/energy/TrendBadge.tsx src/modules/energy/EnergyModule.tsx \
src/modules/energy/HydrogenView.tsx src/modules/energy/ElectricView.tsx \
src/App.tsx src/components/Shell.tsx
git commit -m "feat(energy): add module shell, register in nav
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
Task 3: 氢能 sub-nav + 总览 KPI 卡
Files:
-
Modify:
src/modules/energy/HydrogenView.tsx -
Create:
src/modules/energy/HydrogenOverview.tsx -
Step 3.1: HydrogenView 加二级 Tab
覆盖 src/modules/energy/HydrogenView.tsx:
import { useState } from 'react';
import { LayoutDashboard, CalendarDays } from 'lucide-react';
import { motion } from 'motion/react';
import HydrogenOverview from './HydrogenOverview';
import HydrogenDaily from './HydrogenDaily';
type SubTab = 'overview' | 'daily';
export default function HydrogenView() {
const [sub, setSub] = useState<SubTab>('overview');
return (
<>
<div className="bg-white px-4 py-2 rounded-2xl border border-slate-100 shadow-sm flex items-center gap-6 sticky top-[58px] z-20">
<button
onClick={() => setSub('overview')}
className={`flex items-center gap-2 py-1 transition-all relative ${sub === 'overview' ? 'text-blue-600' : 'text-slate-400'}`}
>
<LayoutDashboard size={14} />
<span className="text-[11px] font-bold">总览</span>
{sub === 'overview' && (
<motion.div layoutId="activeHydrogenSub" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
)}
</button>
<button
onClick={() => setSub('daily')}
className={`flex items-center gap-2 py-1 transition-all relative ${sub === 'daily' ? 'text-blue-600' : 'text-slate-400'}`}
>
<CalendarDays size={14} />
<span className="text-[11px] font-bold">每日</span>
{sub === 'daily' && (
<motion.div layoutId="activeHydrogenSub" className="absolute -bottom-2 left-0 right-0 h-0.5 bg-blue-600 rounded-full" />
)}
</button>
</div>
{sub === 'overview' ? <HydrogenOverview /> : <HydrogenDaily />}
</>
);
}
注:因为 Task 5 才创建 HydrogenDaily,本步骤需要同时建一个临时占位文件以让 TS 编译通过。
- Step 3.2: HydrogenDaily 临时占位
写入 src/modules/energy/HydrogenDaily.tsx:
export default function HydrogenDaily() {
return (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-6 text-center text-slate-400 text-sm">
每日氢能视图占位 — 将在 Task 5 实现
</div>
);
}
- Step 3.3: HydrogenOverview KPI 4 卡
写入 src/modules/energy/HydrogenOverview.tsx:
import { Fuel, Wallet, Coins, CalendarClock } from 'lucide-react';
import { HYDROGEN_KPI } from './mock';
function fmtKg(kg: number) {
if (kg >= 1000) return `${(kg / 1000).toFixed(2)}T`;
return `${kg.toFixed(2)}Kg`;
}
function fmtYuanWan(yuan: number) {
return `¥${(yuan / 10_000).toFixed(2)}万`;
}
function fmtYuan(yuan: number) {
return `¥${yuan.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`;
}
export default function HydrogenOverview() {
const k = HYDROGEN_KPI;
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">
数据自 2025-01-01 起,每 5 分钟更新
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{/* 卡 1:年加氢量 */}
<div className="bg-gradient-to-br from-cyan-50 to-blue-50 rounded-2xl border border-slate-100 shadow-sm p-4 flex flex-col gap-2">
<div className="flex items-center justify-between text-[11px] text-slate-500">
<span className="flex items-center gap-1 font-bold"><Fuel size={12} className="text-cyan-600" />年加氢量</span>
</div>
<div className="text-2xl md:text-3xl font-bold text-slate-800 leading-tight">{fmtKg(k.yearKg)}</div>
<div className="text-[11px] text-slate-500 font-bold space-y-0.5">
<div>我方 <span className="text-slate-700">{fmtKg(k.ourYearKg)}</span></div>
<div>客户产生 <span className="text-slate-700">{fmtKg(k.customerYearKg)}</span></div>
</div>
</div>
{/* 卡 2:年加氢费 */}
<div className="bg-gradient-to-br from-blue-50 to-violet-50 rounded-2xl border border-slate-100 shadow-sm p-4 flex flex-col gap-2">
<div className="flex items-center justify-between text-[11px] text-slate-500">
<span className="flex items-center gap-1 font-bold"><Wallet size={12} className="text-blue-600" />年加氢费</span>
</div>
<div className="text-2xl md:text-3xl font-bold text-slate-800 leading-tight">{fmtYuanWan(k.yearFee)}</div>
<div className="text-[11px] text-slate-500 font-bold">
<div>我方 <span className="text-slate-700">{fmtYuanWan(k.ourYearFee)}</span></div>
</div>
</div>
{/* 卡 3:累计羚牛承担 */}
<div className="bg-gradient-to-br from-amber-50 to-orange-50 rounded-2xl border border-slate-100 shadow-sm p-4 flex flex-col gap-2">
<div className="flex items-center justify-between text-[11px] text-slate-500">
<span className="flex items-center gap-1 font-bold"><Coins size={12} className="text-amber-600" />累计羚牛承担</span>
</div>
<div className="text-2xl md:text-3xl font-bold text-slate-800 leading-tight">{fmtYuanWan(k.lingniuBornFee)}</div>
<div className="text-[11px] text-slate-500 font-bold space-y-0.5">
<div>量 <span className="text-slate-700">{fmtKg(k.lingniuBornKg)}</span></div>
</div>
</div>
{/* 卡 4:本月 / 今日 */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4 flex flex-col gap-2">
<div className="flex items-center justify-between text-[11px] text-slate-500">
<span className="flex items-center gap-1 font-bold"><CalendarClock size={12} className="text-slate-500" />本月 / 今日</span>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<div className="text-[10px] text-slate-400 font-bold">本月</div>
<div className="text-base md:text-lg font-bold text-slate-800">{fmtKg(k.monthKg)}</div>
<div className="text-[11px] text-slate-500 font-bold">{fmtYuanWan(k.monthFee)}</div>
</div>
<div>
<div className="text-[10px] text-slate-400 font-bold">今日</div>
<div className="text-base md:text-lg font-bold text-slate-800">{fmtKg(k.todayKg)}</div>
<div className="text-[11px] text-slate-500 font-bold">{fmtYuan(k.todayFee)}</div>
</div>
</div>
</div>
</div>
</div>
);
}
- Step 3.4: 类型检查 + 视觉验证
npm run lint
用 chrome-devtools 打开 #energy:
-
顶部「氢能 / 电能」下面应有第二行「总览 / 每日」sub-nav
-
4 张 KPI 卡:移动 2×2,桌面 1×4
-
卡 1:青蓝渐变,主数
362.43T,下面两行分解 -
卡 2:蓝紫渐变,主数
¥1066.46万 -
卡 3:蜜橙渐变,主数
¥10.03万 -
卡 4:白底,左本月右今日两栏
-
切到「每日」显示占位文字
-
Step 3.5: 提交
git add src/modules/energy/HydrogenView.tsx src/modules/energy/HydrogenDaily.tsx \
src/modules/energy/HydrogenOverview.tsx
git commit -m "feat(energy): hydrogen overview KPI cards (4-card grid)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
Task 4: 氢能总览 — Top5 横柱 + 区域占比环
Files:
-
Modify:
src/modules/energy/HydrogenOverview.tsx -
Step 4.1: 增加图表区
在 HydrogenOverview.tsx 顶部追加 import:
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip } from 'recharts';
import { HYDROGEN_KPI, HYDROGEN_STATIONS_TOP5, HYDROGEN_REGION_SHARE } from './mock';
并在 KPI 网格 </div> 之后、组件最外层 </div> 之前,插入:
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{/* Top5 加氢站 */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-bold text-slate-700">加氢站加注量 Top5</span>
<span className="text-[11px] text-slate-400 font-bold">单位 Kg</span>
</div>
<ResponsiveContainer width="100%" height={240}>
<BarChart data={HYDROGEN_STATIONS_TOP5} layout="vertical" margin={{ top: 0, right: 60, bottom: 0, left: 0 }}>
<XAxis type="number" hide />
<YAxis
type="category"
dataKey="name"
width={120}
tick={{ fontSize: 11, fill: '#475569' }}
tickLine={false}
axisLine={false}
/>
<Tooltip
formatter={(v: number) => `${v.toLocaleString('zh-CN')} Kg`}
contentStyle={{ borderRadius: 12, fontSize: 12 }}
/>
<Bar dataKey="kg" radius={[6, 6, 6, 6]}>
{HYDROGEN_STATIONS_TOP5.map((_, i) => (
<Cell key={i} fill={`url(#topBarGrad)`} />
))}
</Bar>
<defs>
<linearGradient id="topBarGrad" x1="0" x2="1" y1="0" y2="0">
<stop offset="0%" stopColor="#3b82f6" />
<stop offset="100%" stopColor="#22d3ee" />
</linearGradient>
</defs>
</BarChart>
</ResponsiveContainer>
</div>
{/* 区域占比环 */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4 flex flex-col gap-2">
<span className="text-sm font-bold text-slate-700">各区域加氢占比</span>
<div className="flex items-center gap-2">
<ResponsiveContainer width="50%" height={200}>
<PieChart>
<Pie
data={HYDROGEN_REGION_SHARE}
dataKey="kg"
nameKey="region"
innerRadius={48}
outerRadius={80}
paddingAngle={1}
>
{HYDROGEN_REGION_SHARE.map((_, i) => (
<Cell key={i} fill={REGION_COLORS[i % REGION_COLORS.length]} />
))}
</Pie>
<Tooltip formatter={(v: number) => `${(v / 1000).toFixed(2)}T`} contentStyle={{ borderRadius: 12, fontSize: 12 }} />
</PieChart>
</ResponsiveContainer>
<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) => (
<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>
<span className="text-slate-400 ml-auto font-bold">{(r.share * 100).toFixed(1)}%</span>
</div>
))}
</div>
</div>
<div className="text-center text-[11px] text-slate-400 font-bold pt-1">年合计 {(HYDROGEN_KPI.yearKg / 1000).toFixed(2)}T</div>
</div>
</div>
并在文件顶部(import 之下、export default 之上)加颜色常量:
const REGION_COLORS = [
'#3b82f6', '#22d3ee', '#a855f7', '#f59e0b',
'#10b981', '#ef4444', '#6366f1', '#14b8a6',
'#94a3b8',
];
- Step 4.2: 类型检查 + 视觉验证
npm run lint
打开 #energy → 氢能 → 总览:
-
KPI 区下方应有两块图:
- 移动尺寸:上下叠(柱图在上、环图在下)
- 桌面尺寸:左右并排
-
Top5 横柱站名完整(不被截断),柱条蓝→青渐变
-
环形图旁有图例,鼠标 hover 柱/扇区时 Tooltip 出现
-
环图下方居中显示「年合计 362.43T」
-
Step 4.3: 提交
git add src/modules/energy/HydrogenOverview.tsx
git commit -m "feat(energy): hydrogen overview Top5 bar + region donut
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
Task 5: 氢能每日明细表 + 站点级下钻
Files:
-
Modify:
src/modules/energy/HydrogenDaily.tsx -
Step 5.1: 完整重写 HydrogenDaily
覆盖 src/modules/energy/HydrogenDaily.tsx:
import { useMemo, useState } from 'react';
import { ChevronRight } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import TrendBadge from './TrendBadge';
import { HYDROGEN_DAILY } from './mock';
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: '本周' },
{ id: 'thisMonth', label: '本月' },
{ id: 'thisQuarter', label: '本季度' },
{ id: 'last7', label: '最近7天' },
{ 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 = useMemo<HydrogenDailyRow[]>(() => {
return HYDROGEN_DAILY
.filter(r => r.customerType === customer)
.filter(r => isInPick(r.date, pick))
.sort((a, b) => b.date.localeCompare(a.date));
}, [pick, customer]);
const totalKg = rows.reduce((a, r) => a + r.totalKg, 0);
const toggle = (date: string) => setExpanded(prev => {
const next = new Set(prev);
next.has(date) ? next.delete(date) : next.add(date);
return next;
});
return (
<div className="flex flex-col gap-3">
{/* 日期速选 */}
<div className="flex items-center gap-2 overflow-x-auto -mx-1 px-1 pb-1 snap-x">
{QUICK_PICK_OPTIONS.map(opt => (
<button
key={opt.id}
onClick={() => setPick(opt.id)}
className={`shrink-0 snap-start rounded-xl px-3 py-1.5 text-[11px] font-bold border transition-colors ${
pick === opt.id
? 'bg-blue-50 text-blue-600 border-blue-200'
: 'bg-white text-slate-500 border-slate-200 hover:bg-slate-50'
}`}
>
{opt.label}
</button>
))}
</div>
{/* 客户类型 segmented */}
<div className="bg-slate-100 rounded-xl p-1 grid grid-cols-2 gap-1">
{(['external', 'lingniu'] as const).map(c => (
<button
key={c}
onClick={() => setCustomer(c)}
className={`rounded-lg py-1.5 text-[12px] font-bold transition-all ${
customer === c ? 'bg-white shadow-sm text-slate-800' : 'text-slate-500'
}`}
>
{c === 'external' ? '外部' : '羚牛'}
</button>
))}
</div>
{/* 表格 */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
{/* 表头 */}
<div className="grid grid-cols-[1fr_auto_auto] md:grid-cols-[1fr_140px_120px_120px] gap-2 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500">
<span>日期 / 加氢站带价格</span>
<span className="hidden md:block text-right">单价</span>
<span className="text-right">加氢量(Kg)</span>
<span className="text-right">环比</span>
</div>
{/* 合计行 */}
<div className="grid grid-cols-[1fr_auto_auto] md:grid-cols-[1fr_140px_120px_120px] gap-2 px-3 py-2 bg-blue-50/50 text-[12px] text-blue-600 font-bold">
<span>合计</span>
<span className="hidden md:block" />
<span className="text-right">{totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}</span>
<span />
</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);
return (
<div key={r.date} className="border-t border-slate-100">
<button
onClick={() => toggle(r.date)}
className="w-full grid grid-cols-[1fr_auto_auto] md:grid-cols-[1fr_140px_120px_120px] gap-2 px-3 py-2.5 text-left hover:bg-slate-50 transition-colors"
>
<span className="flex items-center gap-1 text-[12px] text-slate-700 font-bold">
<ChevronRight size={14} className={`transition-transform ${open ? 'rotate-90' : ''} text-slate-400`} />
{r.date}
</span>
<span className="hidden md:block text-right text-[12px] text-slate-400">—</span>
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
{r.totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
</span>
<span className="text-right"><TrendBadge value={r.chainPct} /></span>
</button>
<AnimatePresence initial={false}>
{open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden bg-slate-50/50"
>
{r.stations.map(s => (
<div
key={s.name}
className="grid grid-cols-[1fr_auto_auto] md:grid-cols-[1fr_140px_120px_120px] gap-2 px-3 py-2 pl-9 md:pl-9 border-t border-slate-100 first:border-t-0"
>
<span className="text-[12px] text-slate-600 truncate">
{s.name}
<span className="md:hidden text-slate-400"> · {s.pricePerKg} 元/Kg</span>
</span>
<span className="hidden md:block text-right text-[12px] text-slate-500 font-bold">{s.pricePerKg} 元/Kg</span>
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
{s.kg.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
</span>
<span className="text-right"><TrendBadge value={s.chainPct} /></span>
</div>
))}
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</div>
</div>
);
}
- Step 5.2: 类型检查 + 视觉验证
npm run lint
用 chrome-devtools 打开 #energy → 氢能 → 每日:
-
顶部:6 个日期速选 pill 横排(窄屏可横滚),默认「最近30天」选中
-
第二行:外部 / 羚牛 双段开关,默认「外部」
-
表头:日期 / (md+ 显示单价) / 加氢量 / 环比
-
合计行:蓝底,显示总量
-
主行:可点击展开 → 子行内缩进、显示站名/单价/量/环比
-
切「羚牛」后行数变化、合计跟随
-
切「当天」后大概率显示「暂无数据」(mock 中 4-28 不一定有 lingniu 数据);切「最近7天」应有数据
-
环比 pill:上绿、下红、持平灰
-
Step 5.3: 提交
git add src/modules/energy/HydrogenDaily.tsx
git commit -m "feat(energy): hydrogen daily table with station drilldown
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
Task 6: 电能视图(mini KPI + 月份分组表)
Files:
-
Modify:
src/modules/energy/ElectricView.tsx -
Step 6.1: 完整重写 ElectricView
覆盖 src/modules/energy/ElectricView.tsx:
import { useMemo, useState } from 'react';
import { ChevronRight, Wallet, BatteryCharging, CalendarClock } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import TrendBadge from './TrendBadge';
import { ELECTRIC_KPI, ELECTRIC_MONTHLY } from './mock';
import type { CustomerType } from './types';
function fmtYuan(yuan: number) {
return `¥${yuan.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`;
}
function fmtKwh(kwh: number) {
return `${kwh.toLocaleString('zh-CN', { maximumFractionDigits: 2 })} 度`;
}
export default function ElectricView() {
const [customer, setCustomer] = useState<CustomerType>('external');
const [openMonths, setOpenMonths] = useState<Set<string>>(new Set([ELECTRIC_MONTHLY[0]?.month]));
const months = useMemo(() => {
// mock 暂不区分客户类型,customer 切换不影响数据;保留 UI 切换以与 BI 一致
void customer;
return ELECTRIC_MONTHLY;
}, [customer]);
const k = ELECTRIC_KPI;
const toggleMonth = (m: string) => setOpenMonths(prev => {
const next = new Set(prev);
next.has(m) ? next.delete(m) : next.add(m);
return next;
});
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">
龙王路停车场充电站,期初 2025-01-01,手工导入每日更新
</div>
{/* 横向 mini KPI 头 */}
<div className="grid grid-cols-3 gap-2 md:gap-3">
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
<div className="flex items-center gap-1 text-[11px] text-slate-500 font-bold mb-1">
<Wallet size={11} className="text-blue-600" />累计
</div>
<div className="text-base md:text-2xl font-bold text-slate-800">{fmtYuan(k.totalFee)}</div>
<div className="text-[11px] text-slate-500 font-bold mt-0.5">{fmtKwh(k.totalKwh)}</div>
</div>
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
<div className="flex items-center gap-1 text-[11px] text-slate-500 font-bold mb-1">
<CalendarClock size={11} className="text-blue-600" />本月
</div>
<div className="text-base md:text-2xl font-bold text-slate-800">{fmtYuan(k.monthFee)}</div>
<div className="text-[11px] text-slate-500 font-bold mt-0.5">{fmtKwh(k.monthKwh)}</div>
</div>
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
<div className="flex items-center gap-1 text-[11px] text-slate-500 font-bold mb-1">
<BatteryCharging size={11} className="text-blue-600" />今日
</div>
<div className="flex items-center gap-1.5">
<div className="text-base md:text-2xl font-bold text-slate-800">{fmtYuan(k.todayFee)}</div>
<TrendBadge value={k.todayChainPct} />
</div>
<div className="text-[11px] text-slate-500 font-bold mt-0.5">{fmtKwh(k.todayKwh)}</div>
</div>
</div>
{/* 客户类型 */}
<div className="bg-slate-100 rounded-xl p-1 grid grid-cols-2 gap-1">
{(['external', 'lingniu'] as const).map(c => (
<button
key={c}
onClick={() => setCustomer(c)}
className={`rounded-lg py-1.5 text-[12px] font-bold transition-all ${
customer === c ? 'bg-white shadow-sm text-slate-800' : 'text-slate-500'
}`}
>
{c === 'external' ? '外部' : '羚牛'}
</button>
))}
</div>
{/* 月份分组表 */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
<div className="grid grid-cols-[1fr_auto_auto] md:grid-cols-[1fr_120px_140px_120px] gap-2 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500">
<span>月份 / 日期</span>
<span className="hidden md:block text-right">充电电量</span>
<span className="text-right md:hidden">度</span>
<span className="text-right">充电费用(元)</span>
<span className="text-right hidden md:block">环比</span>
</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">
<button
onClick={() => toggleMonth(m.month)}
className={`w-full grid grid-cols-[1fr_auto_auto] md:grid-cols-[1fr_120px_140px_120px] gap-2 px-3 py-2.5 text-left transition-colors ${
open ? 'bg-blue-50/30' : 'hover:bg-slate-50'
}`}
>
<span className="flex items-center gap-1 text-[12px] text-slate-700 font-bold">
<ChevronRight size={14} className={`transition-transform ${open ? 'rotate-90' : ''} text-slate-400`} />
{m.month}
</span>
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
{m.kwh.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
</span>
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
{m.fee.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
</span>
<span className="hidden md:block" />
</button>
<AnimatePresence initial={false}>
{open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden bg-slate-50/50"
>
{m.rows.map(d => (
<div
key={d.date}
className="grid grid-cols-[1fr_auto_auto] md:grid-cols-[1fr_120px_140px_120px] gap-2 px-3 py-2 pl-9 border-t border-slate-100"
>
<span className="text-[12px] text-slate-600">{d.date}</span>
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
{d.kwh.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
</span>
<span className="text-right text-[12px] text-slate-700 font-bold tabular-nums">
{d.fee.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}
</span>
<span className="text-right hidden md:block"><TrendBadge value={d.chainPct} /></span>
</div>
))}
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</div>
</div>
);
}
- Step 6.2: 类型检查 + 视觉验证
npm run lint
打开 #energy → 电能:
-
顶部说明条 → 3 张 mini KPI 卡(累计 / 本月 / 今日)
-
今日卡右侧带环比 pill
-
客户类型双段开关
-
表头:月份/日期 + 充电电量 + 充电费用(md+ 还有环比列)
-
默认展开
2026-04,能看到 2026-04-26 等若干日级行 -
点月份行能折叠收起
-
Step 6.3: 提交
git add src/modules/energy/ElectricView.tsx
git commit -m "feat(energy): electric view with mini KPI + month grouping
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
Task 7: 双端响应 / 横屏 / 终验
Files: (不写代码,只校验)
- Step 7.1: 移动尺寸完整走查
启动开发服务器(若未启动):
npm run dev
用 chrome-devtools resize_page width=375 height=812,依次访问并截图保存:
#energy→ 氢能 → 总览:4 KPI 卡 2×2,下方 Top5 在上、环图在下#energy→ 氢能 → 每日:日期 pill 可横滚,行可展开#energy→ 电能:3 mini KPI 横排不堆叠
# 在 chrome-devtools 中
take_screenshot fullPage=true filePath=/tmp/energy-mobile-overview.png
take_screenshot fullPage=true filePath=/tmp/energy-mobile-daily.png
take_screenshot fullPage=true filePath=/tmp/energy-mobile-electric.png
确认底部 nav 在所有页面都不遮挡内容(pb-16 起作用)。
- Step 7.2: 桌面尺寸完整走查
resize_page width=1440 height=900,访问同样三页,确认:
- 总览 KPI 1×4 横排
- Top5 + 环图 左右并排
- 表格出现
单价列 / 环比列
take_screenshot fullPage=true filePath=/tmp/energy-desktop-overview.png
take_screenshot fullPage=true filePath=/tmp/energy-desktop-daily.png
take_screenshot fullPage=true filePath=/tmp/energy-desktop-electric.png
- Step 7.3: 横屏尺寸
resize_page width=812 height=375(landscape 触发)。确认:
-
landscape:overflow-hidden起作用,没有底部 nav 重复占位 -
内容可正常滚动
-
Step 7.4: 完整 lint
npm run lint
期望:无错误。
- Step 7.5: 终结提交(如有调整)
如果 7.1-7.3 中发现需要调整 className,修复后:
git add -p # 选择性 stage
git commit -m "fix(energy): responsive polish from full-screen review
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
如果完全无需调整,跳过此步。
Self-Review (作者已做)
Spec coverage:
| Spec 要点 | 实现位置 |
|---|---|
| BASE_MODULES 注册 + Zap icon | T2 Step 2.4 |
| Shell PATH_MAP 加 /energy | T2 Step 2.5 |
| 顶部「氢能/电能」sub-nav + motion 滑块 | T2 Step 2.2 |
| 氢能内层「总览/每日」sub-tab | T3 Step 3.1 |
| 氢能总览 4 KPI 卡(含渐变色 / icon) | T3 Step 3.3 |
| Top5 横柱(蓝→青渐变) | T4 Step 4.1 |
| 各区域占比环 + 中心年合计 + 双列图例 | T4 Step 4.1 |
| 氢能每日:6 速选 + 客户类型 + 合计行 | T5 Step 5.1 |
| 氢能日期行可下钻到站点级(站名+单价 元/Kg) | T5 Step 5.1 |
| 环比 pill(绿/红/灰,复用) | T2 Step 2.1 + 全模块 import |
| 电能 3 mini KPI 横排 | T6 Step 6.1 |
| 电能月份分组 + 日级展开 | T6 Step 6.1 |
| 移动 2×2 / 桌面 1×4 KPI 网格 | T3 + 验收在 T7 |
| Top5 / 环图 移动叠 / 桌面并排 | T4 + 验收在 T7 |
| 横屏 landscape:overflow-hidden 保留 | T2 容器层 + T7.3 验收 |
| 验收时 npm run lint 通过 | 每个 task |
| 不引入新依赖 | 已确认(recharts/lucide-react/motion 全已存在) |
Placeholder scan: 无 TBD/TODO;每步都有完整代码或具体校验命令。
Type consistency:
HydrogenDailyRow.customerType在 mock 与 HydrogenDaily filter 中均使用external/lingniuTrendBadge接口定义在 T2,全模块复用,无重名HYDROGEN_*mock 常量名(全大写下划线)一致
已添加任务: spec 提到的「icon 选型 Zap / lucide 大小 size={20}」由 T2 Step 2.4 满足(Zap import + 现有 Shell 已 size 20)。
Plan complete and saved to docs/superpowers/plans/2026-04-28-energy-module.md. Two execution options:
1. Subagent-Driven (recommended) — 我每个 task 派一个干净的 subagent,task 间我做 review,节奏快、上下文不污染
2. Inline Execution — 当前会话里串起来跑,期间设几个 checkpoint 给你 review
选哪种?