refactor(energy): split electric view into 总览/每日 sub-tabs

- Symmetry with hydrogen — both sides now have a 每日/总览 sub-tab pair
- New ElectricOverview (KPI + bar chart) and ElectricDaily (table)
- Sub-tab styling: pill fill (active = blue-50/blue-600) instead of the
  underline-style used by parent — clearer visual hierarchy
- Tab order swapped to 每日 → 总览 with 每日 as default (daily ops focus)
- Today KPI: pill moves to absolute top-right corner so today's kwh
  reading regains full row width (was getting truncated to "510...")

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
kkfluous
2026-04-28 12:39:05 +08:00
parent 42ec6e1c01
commit 7de2d1ecd5
4 changed files with 256 additions and 213 deletions

View File

@@ -0,0 +1,111 @@
import { useMemo, useState } from 'react';
import { ChevronRight } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import TrendBadge from './TrendBadge';
import { ELECTRIC_MONTHLY } from './mock';
import type { CustomerType } from './types';
export default function ElectricDaily() {
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 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-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_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"></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_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 />
</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"
>
{m.rows.map(d => {
const isAbnormal = Math.abs(d.chainPct) >= 0.3;
const abnormalBg = isAbnormal
? d.chainPct > 0 ? 'bg-emerald-50/40' : 'bg-red-50/40'
: 'bg-slate-50/50';
return (
<div
key={d.date}
className={`grid grid-cols-[1fr_auto_auto_auto] md:grid-cols-[1fr_120px_140px_120px] gap-2 px-3 py-2 pl-9 border-t border-slate-100 ${abnormalBg}`}
>
<span className="text-[12px] text-slate-600">{d.date.slice(5)}</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"><TrendBadge value={d.chainPct} /></span>
</div>
);
})}
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,92 @@
import { useMemo } from 'react';
import { Wallet, BatteryCharging, CalendarClock } from 'lucide-react';
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip } from 'recharts';
import TrendBadge from './TrendBadge';
import { ELECTRIC_KPI, ELECTRIC_MONTHLY } from './mock';
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 ElectricOverview() {
const k = ELECTRIC_KPI;
// 本月每日数据(按日期升序,便于柱图按时间从左到右展示)
const trendData = useMemo(() => {
const first = ELECTRIC_MONTHLY[0];
if (!first) return [];
return [...first.rows].sort((a, b) => a.date.localeCompare(b.date));
}, []);
return (
<div className="flex flex-col gap-3">
{/* 横向 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 relative">
<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="text-base md:text-2xl font-bold text-slate-800">{fmtYuan(k.todayFee)}</div>
<div className="text-[11px] text-slate-500 font-bold mt-0.5 whitespace-nowrap">{fmtKwh(k.todayKwh)}</div>
<TrendBadge value={k.todayChainPct} className="absolute top-2 right-2 md:top-3 md:right-3" />
</div>
</div>
{/* 本月每日充电柱图 */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-slate-700"></span>
<span className="text-[11px] text-slate-400 font-bold"> </span>
</div>
<ResponsiveContainer width="100%" height={160}>
<BarChart data={trendData} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
<XAxis
dataKey="date"
tickFormatter={(v: string) => v.slice(5)}
tick={{ fontSize: 10, fill: '#94a3b8' }}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
minTickGap={8}
/>
<YAxis hide />
<Tooltip
formatter={(v) => [`¥${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`, '充电费用']}
labelFormatter={(d) => `日期 ${d}`}
contentStyle={{ borderRadius: 12, fontSize: 12 }}
cursor={{ fill: 'rgba(59, 130, 246, 0.06)' }}
/>
<Bar dataKey="fee" radius={[4, 4, 0, 0]}>
{trendData.map((_, i) => (
<Cell key={i} fill="url(#electricBarGrad)" />
))}
</Bar>
<defs>
<linearGradient id="electricBarGrad" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="#3b82f6" />
<stop offset="100%" stopColor="#22d3ee" />
</linearGradient>
</defs>
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
}

View File

@@ -1,199 +1,40 @@
import { useMemo, useState } from 'react';
import { ChevronRight, Wallet, BatteryCharging, CalendarClock } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip } from 'recharts';
import TrendBadge from './TrendBadge';
import { ELECTRIC_KPI, ELECTRIC_MONTHLY } from './mock';
import type { CustomerType } from './types';
import { useState } from 'react';
import { LayoutDashboard, CalendarDays } from 'lucide-react';
import ElectricOverview from './ElectricOverview';
import ElectricDaily from './ElectricDaily';
function fmtYuan(yuan: number) {
return `¥${yuan.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`;
}
function fmtKwh(kwh: number) {
return `${kwh.toLocaleString('zh-CN', { maximumFractionDigits: 2 })} 度`;
}
type SubTab = 'daily' | 'overview';
const SUB_TABS: Array<{ id: SubTab; label: string; icon: typeof LayoutDashboard }> = [
{ id: 'daily', label: '每日', icon: CalendarDays },
{ id: 'overview', label: '总览', icon: LayoutDashboard },
];
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 trendData = useMemo(() => {
const first = ELECTRIC_MONTHLY[0];
if (!first) return [];
return [...first.rows].sort((a, b) => a.date.localeCompare(b.date));
}, []);
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;
});
const [sub, setSub] = useState<SubTab>('daily');
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="text-base md:text-2xl font-bold text-slate-800">{fmtYuan(k.todayFee)}</div>
<div className="flex items-center justify-between gap-1 mt-0.5">
<span className="text-[11px] text-slate-500 font-bold truncate">{fmtKwh(k.todayKwh)}</span>
<TrendBadge value={k.todayChainPct} />
</div>
</div>
</div>
{/* 本月每日充电柱图 */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-slate-700"></span>
<span className="text-[11px] text-slate-400 font-bold"> </span>
</div>
<ResponsiveContainer width="100%" height={160}>
<BarChart data={trendData} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
<XAxis
dataKey="date"
tickFormatter={(v: string) => v.slice(5)}
tick={{ fontSize: 10, fill: '#94a3b8' }}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
minTickGap={8}
/>
<YAxis hide />
<Tooltip
formatter={(v) => [`¥${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`, '充电费用']}
labelFormatter={(d) => `日期 ${d}`}
contentStyle={{ borderRadius: 12, fontSize: 12 }}
cursor={{ fill: 'rgba(59, 130, 246, 0.06)' }}
/>
<Bar dataKey="fee" radius={[4, 4, 0, 0]}>
{trendData.map((_, i) => (
<Cell key={i} fill="url(#electricBarGrad)" />
))}
</Bar>
<defs>
<linearGradient id="electricBarGrad" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="#3b82f6" />
<stop offset="100%" stopColor="#22d3ee" />
</linearGradient>
</defs>
</BarChart>
</ResponsiveContainer>
</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_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"></span>
</div>
{months.map(m => {
const open = openMonths.has(m.month);
<>
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-1 sticky top-[58px] z-20 flex gap-1">
{SUB_TABS.map(({ id, label, icon: Icon }) => {
const active = sub === id;
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_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 />
</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"
>
{m.rows.map(d => {
const isAbnormal = Math.abs(d.chainPct) >= 0.3;
const abnormalBg = isAbnormal
? d.chainPct > 0 ? 'bg-emerald-50/40' : 'bg-red-50/40'
: 'bg-slate-50/50';
return (
<div
key={d.date}
className={`grid grid-cols-[1fr_auto_auto_auto] md:grid-cols-[1fr_120px_140px_120px] gap-2 px-3 py-2 pl-9 border-t border-slate-100 ${abnormalBg}`}
>
<span className="text-[12px] text-slate-600">{d.date.slice(5)}</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"><TrendBadge value={d.chainPct} /></span>
</div>
);
})}
</motion.div>
)}
</AnimatePresence>
</div>
<button
key={id}
onClick={() => setSub(id)}
className={`flex-1 flex items-center justify-center gap-1.5 rounded-xl py-1.5 text-[12px] font-bold transition-all ${
active ? 'bg-blue-50 text-blue-600' : 'text-slate-400 hover:bg-slate-50'
}`}
>
<Icon size={14} />
<span>{label}</span>
</button>
);
})}
</div>
</div>
<div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400">
2025-01-01
</div>
{sub === 'overview' ? <ElectricOverview /> : <ElectricDaily />}
</>
);
}

View File

@@ -1,36 +1,35 @@
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';
type SubTab = 'daily' | 'overview';
const SUB_TABS: Array<{ id: SubTab; label: string; icon: typeof LayoutDashboard }> = [
{ id: 'daily', label: '每日', icon: CalendarDays },
{ id: 'overview', label: '总览', icon: LayoutDashboard },
];
export default function HydrogenView() {
const [sub, setSub] = useState<SubTab>('overview');
const [sub, setSub] = useState<SubTab>('daily');
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 className="bg-white rounded-2xl border border-slate-100 shadow-sm p-1 sticky top-[58px] z-20 flex gap-1">
{SUB_TABS.map(({ id, label, icon: Icon }) => {
const active = sub === id;
return (
<button
key={id}
onClick={() => setSub(id)}
className={`flex-1 flex items-center justify-center gap-1.5 rounded-xl py-1.5 text-[12px] font-bold transition-all ${
active ? 'bg-blue-50 text-blue-600' : 'text-slate-400 hover:bg-slate-50'
}`}
>
<Icon size={14} />
<span>{label}</span>
</button>
);
})}
</div>
{sub === 'overview' ? <HydrogenOverview /> : <HydrogenDaily />}
</>