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>
1243 lines
45 KiB
Markdown
1243 lines
45 KiB
Markdown
# 能源管理模块实施计划
|
||
|
||
> **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 派一个干净的 subagent,task 间我做 review,节奏快、上下文不污染
|
||
|
||
**2. Inline Execution** — 当前会话里串起来跑,期间设几个 checkpoint 给你 review
|
||
|
||
**选哪种?**
|