Files
ln-bi/docs/superpowers/plans/2026-04-28-energy-module.md
kkfluous ccd97d3aae docs(energy): add 7-task implementation plan
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>
2026-04-28 11:04:08 +08:00

1243 lines
45 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 能源管理模块实施计划
> **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`:
```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`:
```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: 类型检查通过**
```bash
npm run lint
```
期望:无错误退出。
- [ ] **Step 1.4: 提交**
```bash
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`:
```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`:
```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`:
```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`:
```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` 改为:
```tsx
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'`
```tsx
const PATH_MAP: Record<string, string> = {
'/vehicle': 'assets',
'/assets': 'assets',
'/mileage': 'mileage',
'/scheduling': 'scheduling',
'/energy': 'energy',
};
```
- [ ] **Step 2.6: 类型检查 + 视觉验证**
```bash
npm run lint
```
期望:无错误。
```bash
npm run dev
```
后台启动后用 chrome-devtools MCP 打开 `http://localhost:3000/#energy`
- 移动尺寸375×812和桌面尺寸1440×900下都应能看到底部 nav移动/ 左侧 nav桌面多了「能源管理」图标
- 点进去顶部应有「氢能 / 电能」sub-nav下划线滑块在切换时移动
- 默认显示氢能视图占位文字
- [ ] **Step 2.7: 提交**
```bash
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`:
```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`:
```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`:
```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: 类型检查 + 视觉验证**
```bash
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: 提交**
```bash
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
```tsx
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>` 之前,插入:
```tsx
<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 之上)加颜色常量:
```tsx
const REGION_COLORS = [
'#3b82f6', '#22d3ee', '#a855f7', '#f59e0b',
'#10b981', '#ef4444', '#6366f1', '#14b8a6',
'#94a3b8',
];
```
- [ ] **Step 4.2: 类型检查 + 视觉验证**
```bash
npm run lint
```
打开 `#energy` → 氢能 → 总览:
- KPI 区下方应有两块图:
- 移动尺寸:上下叠(柱图在上、环图在下)
- 桌面尺寸:左右并排
- Top5 横柱站名完整(不被截断),柱条蓝→青渐变
- 环形图旁有图例,鼠标 hover 柱/扇区时 Tooltip 出现
- 环图下方居中显示「年合计 362.43T」
- [ ] **Step 4.3: 提交**
```bash
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`:
```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: 类型检查 + 视觉验证**
```bash
npm run lint
```
用 chrome-devtools 打开 `#energy` → 氢能 → 每日:
- 顶部6 个日期速选 pill 横排窄屏可横滚默认「最近30天」选中
- 第二行:外部 / 羚牛 双段开关,默认「外部」
- 表头:日期 / (md+ 显示单价) / 加氢量 / 环比
- 合计行:蓝底,显示总量
- 主行:可点击展开 → 子行内缩进、显示站名/单价/量/环比
- 切「羚牛」后行数变化、合计跟随
- 切「当天」后大概率显示「暂无数据」mock 中 4-28 不一定有 lingniu 数据切「最近7天」应有数据
- 环比 pill上绿、下红、持平灰
- [ ] **Step 5.3: 提交**
```bash
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`:
```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: 类型检查 + 视觉验证**
```bash
npm run lint
```
打开 `#energy` → 电能:
- 顶部说明条 → 3 张 mini KPI 卡(累计 / 本月 / 今日)
- 今日卡右侧带环比 pill
- 客户类型双段开关
- 表头:月份/日期 + 充电电量 + 充电费用md+ 还有环比列)
- 默认展开 `2026-04`,能看到 2026-04-26 等若干日级行
- 点月份行能折叠收起
- [ ] **Step 6.3: 提交**
```bash
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: 移动尺寸完整走查**
启动开发服务器(若未启动):
```bash
npm run dev
```
用 chrome-devtools `resize_page width=375 height=812`,依次访问并截图保存:
- `#energy` → 氢能 → 总览4 KPI 卡 2×2下方 Top5 在上、环图在下
- `#energy` → 氢能 → 每日:日期 pill 可横滚,行可展开
- `#energy` → 电能3 mini KPI 横排不堆叠
```bash
# 在 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 + 环图 左右并排
- 表格出现 `单价` 列 / 环比列
```bash
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**
```bash
npm run lint
```
期望:无错误。
- [ ] **Step 7.5: 终结提交(如有调整)**
如果 7.1-7.3 中发现需要调整 className修复后
```bash
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`/`lingniu`
- `TrendBadge` 接口定义在 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 派一个干净的 subagenttask 间我做 review节奏快、上下文不污染
**2. Inline Execution** — 当前会话里串起来跑,期间设几个 checkpoint 给你 review
**选哪种?**