feat(energy): electric view with mini KPI + month grouping
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,146 @@
|
|||||||
|
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() {
|
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 (
|
return (
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-6 text-center text-slate-400 text-sm">
|
<div className="flex flex-col gap-3">
|
||||||
电能视图占位 — 将在 Task 6 实现
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user