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

45 KiB
Raw Permalink Blame History

能源管理模块实施计划

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 区加 ZapEnergyModule,并把 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.tsxPATH_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=375landscape 触发)。确认:

  • 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/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

选哪种?