All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
接口侧: - cache.ts 改为 stale-while-revalidate:每个 key 自调度,TTL 到期前 5s 后台刷新,用户永远命中热缓存 - 闲置 10 分钟后停止调度,避免空跑 - loader 失败保留旧值 + 10s 后退避重试 - 所有 4 个端点支持 ?force=1 强制绕过缓存 前端 HydrogenOverview: - 顶部加 RefreshCw 按钮(强刷绕过缓存),带旋转动画 - 显示"更新于 X 秒前"相对时间 - 刷新中:顶部 0.5px 流光进度条,不替换内容、不闪烁 - 60s 静默自动刷新(命中后端热缓存) 实测:cold 6.1s → 命中 13ms(470× 提速) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
642 lines
29 KiB
TypeScript
642 lines
29 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
||
import {
|
||
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, PieChart, Pie, Tooltip, LabelList, Legend,
|
||
} from 'recharts';
|
||
import { Fuel, Wallet, CalendarDays, Sparkles, TrendingUp, RefreshCw } from 'lucide-react';
|
||
import { motion, AnimatePresence } from 'motion/react';
|
||
import { fetchHydrogenOverview, type HydrogenOverviewResponse } from './api';
|
||
import RotatingFooterHint from '../../components/RotatingFooterHint';
|
||
|
||
const REGION_COLORS = [
|
||
'#3b82f6', '#22d3ee', '#a855f7', '#f59e0b',
|
||
'#10b981', '#ef4444', '#6366f1', '#14b8a6',
|
||
'#94a3b8',
|
||
];
|
||
|
||
interface YAxisTickProps {
|
||
x?: number;
|
||
y?: number;
|
||
index?: number;
|
||
payload?: { value: string };
|
||
}
|
||
|
||
function RankYAxisTick({ x = 0, y = 0, index = 0, payload }: YAxisTickProps) {
|
||
return (
|
||
<g transform={`translate(${x},${y})`}>
|
||
<circle cx={-172} cy={0} r={9} fill="#3b82f6" />
|
||
<text x={-172} y={3} textAnchor="middle" fontSize={10} fontWeight={700} fill="#fff">
|
||
{index + 1}
|
||
</text>
|
||
<text x={-154} y={4} textAnchor="start" fontSize={11} fill="#475569">
|
||
{payload?.value}
|
||
</text>
|
||
</g>
|
||
);
|
||
}
|
||
|
||
// ---------- 数字格式化 ----------
|
||
function fmtKg(kg: number): { value: string; unit: string } {
|
||
if (kg >= 1000) return { value: (kg / 1000).toFixed(2), unit: 'T' };
|
||
return { value: kg.toFixed(2), unit: 'Kg' };
|
||
}
|
||
function fmtYuan(yuan: number): { value: string; unit: string } {
|
||
const abs = Math.abs(yuan);
|
||
if (abs >= 100_000_000) return { value: (yuan / 100_000_000).toFixed(2), unit: '亿元' };
|
||
if (abs >= 10_000) {
|
||
const w = yuan / 10_000;
|
||
return { value: w.toLocaleString('zh-CN', { maximumFractionDigits: 2 }), unit: '万元' };
|
||
}
|
||
return { value: yuan.toLocaleString('zh-CN', { maximumFractionDigits: 0 }), unit: '元' };
|
||
}
|
||
|
||
// ---------- KPI 卡 ----------
|
||
interface KpiCardProps {
|
||
icon: React.ReactNode;
|
||
label: string;
|
||
hero: { value: string; unit: string };
|
||
rows: { label: string; value: string; valueClass?: string }[];
|
||
accentClass: string;
|
||
iconBg: string;
|
||
}
|
||
function KpiCard({ icon, label, hero, rows, accentClass, iconBg }: KpiCardProps) {
|
||
return (
|
||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 flex flex-col gap-2">
|
||
<div className="flex items-center gap-2">
|
||
<div className={`w-7 h-7 rounded-xl flex items-center justify-center ${iconBg}`}>
|
||
{icon}
|
||
</div>
|
||
<span className="text-[11px] font-bold text-slate-500">{label}</span>
|
||
</div>
|
||
<div className="flex items-baseline gap-1">
|
||
<span className={`text-xl md:text-2xl font-black tabular-nums leading-none ${accentClass}`}>{hero.value}</span>
|
||
<span className="text-[11px] text-slate-400 font-bold">{hero.unit}</span>
|
||
</div>
|
||
<div className="space-y-0.5 pt-1 border-t border-slate-50">
|
||
{rows.map((r, i) => (
|
||
<div key={i} className="flex items-center justify-between text-[11px] font-bold">
|
||
<span className="text-slate-400">{r.label}</span>
|
||
<span className={`tabular-nums ${r.valueClass ?? 'text-slate-700'}`}>{r.value}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
export default function HydrogenOverview() {
|
||
const [data, setData] = useState<HydrogenOverviewResponse | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [year, setYear] = useState<number | null>(null);
|
||
const [refreshing, setRefreshing] = useState(false);
|
||
const [lastRefreshAt, setLastRefreshAt] = useState<number>(0);
|
||
const refreshSeq = useRef(0);
|
||
|
||
const load = useCallback(async (selectedYear: number | null, force: boolean) => {
|
||
const seq = ++refreshSeq.current;
|
||
setRefreshing(true);
|
||
try {
|
||
const d = await fetchHydrogenOverview(selectedYear ?? undefined, force);
|
||
if (seq !== refreshSeq.current) return; // outdated
|
||
setData(d);
|
||
setError(null);
|
||
setLastRefreshAt(Date.now());
|
||
} catch (e) {
|
||
if (seq !== refreshSeq.current) return;
|
||
setError(e instanceof Error ? e.message : String(e));
|
||
} finally {
|
||
if (seq === refreshSeq.current) setRefreshing(false);
|
||
}
|
||
}, []);
|
||
|
||
// 初始加载 + 年份切换:用 force=false 命中热缓存
|
||
useEffect(() => { void load(year, false); }, [year, load]);
|
||
|
||
// 客户端兜底自动刷新:每 60s 静默拉一次(命中后端热缓存,几乎零成本)
|
||
useEffect(() => {
|
||
const t = setInterval(() => { void load(year, false); }, 60_000);
|
||
return () => clearInterval(t);
|
||
}, [year, load]);
|
||
|
||
if (error && !data) {
|
||
return <div className="bg-red-50 text-red-600 rounded-2xl border border-red-100 p-4 text-sm">加载失败:{error}</div>;
|
||
}
|
||
if (!data) {
|
||
return <HydrogenOverviewSkeleton />;
|
||
}
|
||
const k = data.kpi;
|
||
const top5 = data.top5;
|
||
const regions = data.regions;
|
||
const monthly = data.monthly;
|
||
const customers = data.customers;
|
||
const stations = data.stations;
|
||
const availableYears = data.availableYears;
|
||
const activeYear = data.year;
|
||
|
||
const yearKgFmt = fmtKg(k.yearKg);
|
||
const yearFeeFmt = fmtYuan(k.yearFee);
|
||
const yearProfitFmt = fmtYuan(k.yearProfit);
|
||
const ourYearKgFmt = fmtKg(k.ourYearKg);
|
||
const customerYearKgFmt = fmtKg(k.customerYearKg);
|
||
const monthKgFmt = fmtKg(k.monthKg);
|
||
const monthFeeFmt = fmtYuan(k.monthFee);
|
||
const todayKgFmt = fmtKg(k.todayKg);
|
||
const todayFeeFmt = fmtYuan(k.todayFee);
|
||
const customerYearFee = Math.max(0, k.yearFee - k.ourYearFee);
|
||
const customerYearFeeFmt = fmtYuan(customerYearFee);
|
||
const yearRevenueFmt = fmtYuan(k.yearRevenue);
|
||
|
||
const profitColor = k.yearProfit >= 0 ? 'text-emerald-600' : 'text-red-600';
|
||
|
||
// 月度收支组合数据(推算"年内每月"图)
|
||
const monthlyDual = monthly.map(m => ({
|
||
...m,
|
||
monthLabel: m.month.slice(5).replace(/^0/, '') + '月',
|
||
}));
|
||
|
||
return (
|
||
<div className="flex flex-col gap-3 relative">
|
||
{/* 顶部说明条 + 年份切换 + 刷新按钮 */}
|
||
<div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400 flex items-center justify-between gap-2">
|
||
<span className="truncate">{lastRefreshAt ? `更新于 ${formatRelative(lastRefreshAt)}` : '数据自 2025-01-01 起'}</span>
|
||
<div className="flex items-center gap-2 flex-shrink-0">
|
||
<div className="flex items-center gap-1 bg-slate-50 rounded-lg p-0.5">
|
||
{availableYears.map(y => {
|
||
const active = y === activeYear;
|
||
return (
|
||
<button
|
||
key={y}
|
||
onClick={() => setYear(y)}
|
||
className={`px-2 py-0.5 text-[11px] font-bold rounded-md transition-all ${
|
||
active ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400 hover:text-slate-600'
|
||
}`}
|
||
>
|
||
{y}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
<button
|
||
onClick={() => void load(year, true)}
|
||
disabled={refreshing}
|
||
className="flex items-center gap-1 px-2 py-0.5 rounded-lg bg-blue-50 text-blue-600 hover:bg-blue-100 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
|
||
title="手动刷新(绕过缓存)"
|
||
>
|
||
<RefreshCw size={11} className={refreshing ? 'animate-spin' : ''} strokeWidth={2.6} />
|
||
<span className="text-[11px] font-bold">刷新</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* KPI 5 卡 */}
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2 md:gap-3">
|
||
<KpiCard
|
||
icon={<Fuel size={14} className="text-cyan-600" strokeWidth={2.4} />}
|
||
iconBg="bg-cyan-50"
|
||
accentClass="text-slate-800"
|
||
label="累计加氢量"
|
||
hero={yearKgFmt}
|
||
rows={[
|
||
{ label: '我司', value: `${ourYearKgFmt.value} ${ourYearKgFmt.unit}` },
|
||
{ label: '客户', value: `${customerYearKgFmt.value} ${customerYearKgFmt.unit}` },
|
||
]}
|
||
/>
|
||
<KpiCard
|
||
icon={<Wallet size={14} className="text-blue-600" strokeWidth={2.4} />}
|
||
iconBg="bg-blue-50"
|
||
accentClass="text-slate-800"
|
||
label="累计加氢费"
|
||
hero={{ value: `¥${yearFeeFmt.value}`, unit: yearFeeFmt.unit }}
|
||
rows={[
|
||
{ label: '我司承担', value: `¥${fmtYuan(k.ourYearFee).value} ${fmtYuan(k.ourYearFee).unit}` },
|
||
{ label: '客户承担', value: `¥${customerYearFeeFmt.value} ${customerYearFeeFmt.unit}` },
|
||
]}
|
||
/>
|
||
<KpiCard
|
||
icon={<TrendingUp size={14} className="text-emerald-600" strokeWidth={2.4} />}
|
||
iconBg="bg-emerald-50"
|
||
accentClass={profitColor}
|
||
label="时享加氢获利"
|
||
hero={{ value: `¥${yearProfitFmt.value}`, unit: yearProfitFmt.unit }}
|
||
rows={[
|
||
{ label: '收入', value: `¥${yearRevenueFmt.value} ${yearRevenueFmt.unit}` },
|
||
{ label: '成本', value: `¥${yearFeeFmt.value} ${yearFeeFmt.unit}` },
|
||
]}
|
||
/>
|
||
<KpiCard
|
||
icon={<CalendarDays size={14} className="text-amber-600" strokeWidth={2.4} />}
|
||
iconBg="bg-amber-50"
|
||
accentClass="text-amber-600"
|
||
label="本月加氢"
|
||
hero={monthKgFmt}
|
||
rows={[
|
||
{ label: '加氢费', value: `¥${monthFeeFmt.value} ${monthFeeFmt.unit}` },
|
||
{ label: '占年比', value: `${k.yearKg > 0 ? (k.monthKg / k.yearKg * 100).toFixed(1) : '0.0'}%` },
|
||
]}
|
||
/>
|
||
<KpiCard
|
||
icon={<Sparkles size={14} className="text-violet-600" strokeWidth={2.4} />}
|
||
iconBg="bg-violet-50"
|
||
accentClass="text-violet-600"
|
||
label="本日加氢"
|
||
hero={todayKgFmt}
|
||
rows={[
|
||
{ label: '加氢费', value: `¥${todayFeeFmt.value} ${todayFeeFmt.unit}` },
|
||
{ label: '占月比', value: `${k.monthKg > 0 ? (k.todayKg / k.monthKg * 100).toFixed(1) : '0.0'}%` },
|
||
]}
|
||
/>
|
||
</div>
|
||
|
||
{/* 月度趋势:年内每月加氢量 */}
|
||
{monthly.length > 0 && (
|
||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<span className="text-sm font-bold text-slate-700">{activeYear} 年月度加氢量</span>
|
||
<span className="text-[11px] text-slate-400 font-bold">单位 Kg</span>
|
||
</div>
|
||
<ResponsiveContainer width="100%" height={140}>
|
||
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
|
||
<XAxis
|
||
dataKey="monthLabel"
|
||
tick={{ fontSize: 10, fill: '#94a3b8' }}
|
||
tickLine={false}
|
||
axisLine={false}
|
||
interval={0}
|
||
/>
|
||
<YAxis hide />
|
||
<Tooltip
|
||
formatter={(v) => [`${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })} Kg`, '加氢量']}
|
||
labelFormatter={(d) => `${d}`}
|
||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||
cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }}
|
||
/>
|
||
<Bar dataKey="kg" radius={[4, 4, 0, 0]}>
|
||
{monthlyDual.map((_, i) => (
|
||
<Cell key={i} fill="url(#monthlyBarGrad)" />
|
||
))}
|
||
</Bar>
|
||
<defs>
|
||
<linearGradient id="monthlyBarGrad" x1="0" x2="0" y1="0" y2="1">
|
||
<stop offset="0%" stopColor="#22d3ee" />
|
||
<stop offset="100%" stopColor="#3b82f6" />
|
||
</linearGradient>
|
||
</defs>
|
||
</BarChart>
|
||
</ResponsiveContainer>
|
||
</div>
|
||
)}
|
||
|
||
{/* 月度收支对比 */}
|
||
{monthly.length > 0 && (
|
||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<span className="text-sm font-bold text-slate-700">{activeYear} 年月度收支对比</span>
|
||
<span className="text-[11px] text-slate-400 font-bold">单位 元</span>
|
||
</div>
|
||
<ResponsiveContainer width="100%" height={180}>
|
||
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
|
||
<XAxis
|
||
dataKey="monthLabel"
|
||
tick={{ fontSize: 10, fill: '#94a3b8' }}
|
||
tickLine={false}
|
||
axisLine={false}
|
||
interval={0}
|
||
/>
|
||
<YAxis hide />
|
||
<Legend
|
||
verticalAlign="top"
|
||
height={20}
|
||
iconSize={8}
|
||
wrapperStyle={{ fontSize: 11, paddingBottom: 4 }}
|
||
/>
|
||
<Tooltip
|
||
formatter={(v, name) => {
|
||
const f = fmtYuan(Number(v ?? 0));
|
||
return [`¥${f.value} ${f.unit}`, name];
|
||
}}
|
||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||
cursor={{ fill: 'rgba(148, 163, 184, 0.06)' }}
|
||
/>
|
||
<Bar dataKey="fee" name="成本支出" fill="#f59e0b" radius={[3, 3, 0, 0]} />
|
||
<Bar dataKey="revenue" name="客户收入" fill="#10b981" radius={[3, 3, 0, 0]} />
|
||
</BarChart>
|
||
</ResponsiveContainer>
|
||
</div>
|
||
)}
|
||
|
||
{/* Top5 + 区域占比 */}
|
||
<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-3 md: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={260}>
|
||
<BarChart data={top5} layout="vertical" margin={{ top: 4, right: 80, bottom: 4, left: 12 }}>
|
||
<XAxis type="number" hide />
|
||
<YAxis
|
||
type="category"
|
||
dataKey="name"
|
||
width={188}
|
||
tick={<RankYAxisTick />}
|
||
tickLine={false}
|
||
axisLine={false}
|
||
/>
|
||
<Tooltip
|
||
formatter={(v) => `${Number(v ?? 0).toLocaleString('zh-CN')} Kg`}
|
||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||
/>
|
||
<Bar dataKey="kg" radius={[6, 6, 6, 6]}>
|
||
{top5.map((_, i) => (
|
||
<Cell key={i} fill="url(#topBarGrad)" />
|
||
))}
|
||
<LabelList
|
||
dataKey="kg"
|
||
position="right"
|
||
formatter={(v) => `${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`}
|
||
fill="#475569"
|
||
fontSize={11}
|
||
fontWeight={700}
|
||
/>
|
||
</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-3 md:p-4 flex flex-col gap-2">
|
||
<span className="text-sm font-bold text-slate-700">各区域加氢占比</span>
|
||
<div className="flex items-center gap-2">
|
||
<div className="relative w-1/2 h-[200px]">
|
||
<ResponsiveContainer width="100%" height="100%">
|
||
<PieChart>
|
||
<Pie
|
||
data={regions}
|
||
dataKey="kg"
|
||
nameKey="region"
|
||
innerRadius={48}
|
||
outerRadius={80}
|
||
paddingAngle={1}
|
||
>
|
||
{regions.map((_, i) => (
|
||
<Cell key={i} fill={REGION_COLORS[i % REGION_COLORS.length]} />
|
||
))}
|
||
</Pie>
|
||
<Tooltip formatter={(v) => `${(Number(v ?? 0) / 1000).toFixed(2)}T`} contentStyle={{ borderRadius: 12, fontSize: 12 }} />
|
||
</PieChart>
|
||
</ResponsiveContainer>
|
||
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||
<div className="text-[10px] text-slate-400 font-bold">年合计</div>
|
||
<div className="text-base font-bold text-slate-700 leading-tight">{(k.yearKg / 1000).toFixed(2)}T</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-1 text-[11px]">
|
||
{regions.map((r, i) => (
|
||
<div key={r.region} className="flex items-center gap-1.5">
|
||
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: REGION_COLORS[i % REGION_COLORS.length] }} />
|
||
<span className="text-slate-600 truncate">{r.region}</span>
|
||
<span className="text-slate-400 ml-auto font-bold flex-shrink-0">{(r.share * 100).toFixed(1)}%</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 加氢站加氢汇总(全量) */}
|
||
{stations.length > 0 && (
|
||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md: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">共 {stations.length} 站</span>
|
||
</div>
|
||
<div className="overflow-x-auto -mx-1 px-1">
|
||
<table className="w-full text-[11px]">
|
||
<thead>
|
||
<tr className="text-slate-400 font-bold border-b border-slate-100">
|
||
<th className="text-left py-1.5 pl-1 w-8">#</th>
|
||
<th className="text-left py-1.5">加氢站</th>
|
||
<th className="text-right py-1.5 w-20">加氢量</th>
|
||
<th className="text-right py-1.5 pl-2 hidden sm:table-cell">占比</th>
|
||
<th className="text-right py-1.5 pl-2 w-24">氢费收入</th>
|
||
<th className="text-right py-1.5 pr-1 hidden md:table-cell">收入占比</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{stations.map((s, i) => {
|
||
const kgFmt = fmtKg(s.kg);
|
||
const revFmt = fmtYuan(s.revenue);
|
||
return (
|
||
<tr key={s.name + i} className="border-b border-slate-50 hover:bg-slate-50/60">
|
||
<td className="py-1.5 pl-1 text-slate-400 tabular-nums">{i + 1}</td>
|
||
<td className="py-1.5 text-slate-700 truncate max-w-[180px]">{s.name}</td>
|
||
<td className="py-1.5 text-right tabular-nums font-bold text-slate-700">
|
||
{kgFmt.value}<span className="text-slate-400 font-normal ml-0.5">{kgFmt.unit}</span>
|
||
</td>
|
||
<td className="py-1.5 pl-2 text-right hidden sm:table-cell">
|
||
<div className="inline-flex items-center gap-1.5">
|
||
<div className="w-12 h-1 bg-slate-100 rounded-full overflow-hidden">
|
||
<div className="h-full bg-gradient-to-r from-cyan-400 to-blue-500" style={{ width: `${Math.min(100, s.share * 100)}%` }} />
|
||
</div>
|
||
<span className="text-slate-500 tabular-nums">{(s.share * 100).toFixed(1)}%</span>
|
||
</div>
|
||
</td>
|
||
<td className="py-1.5 pl-2 text-right tabular-nums font-bold text-emerald-600">
|
||
¥{revFmt.value}<span className="text-slate-400 font-normal ml-0.5">{revFmt.unit}</span>
|
||
</td>
|
||
<td className="py-1.5 pr-1 text-right hidden md:table-cell">
|
||
<div className="inline-flex items-center gap-1.5">
|
||
<div className="w-12 h-1 bg-slate-100 rounded-full overflow-hidden">
|
||
<div className="h-full bg-gradient-to-r from-emerald-400 to-emerald-600" style={{ width: `${Math.min(100, s.revenueShare * 100)}%` }} />
|
||
</div>
|
||
<span className="text-slate-500 tabular-nums">{(s.revenueShare * 100).toFixed(1)}%</span>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 客户账单汇总 Top */}
|
||
{customers.length > 0 && (
|
||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md: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">Top {customers.length}</span>
|
||
</div>
|
||
<div className="overflow-x-auto -mx-1 px-1">
|
||
<table className="w-full text-[11px]">
|
||
<thead>
|
||
<tr className="text-slate-400 font-bold border-b border-slate-100">
|
||
<th className="text-left py-1.5 pl-1 w-8">#</th>
|
||
<th className="text-left py-1.5">客户</th>
|
||
<th className="text-center py-1.5 w-14 hidden sm:table-cell">承担方</th>
|
||
<th className="text-right py-1.5 w-20">加氢量</th>
|
||
<th className="text-right py-1.5 pl-2 w-24">成本支出</th>
|
||
<th className="text-right py-1.5 pr-1 w-24 hidden md:table-cell">应收</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{customers.map((c2, i) => {
|
||
const kgFmt = fmtKg(c2.kg);
|
||
const costFmt = fmtYuan(c2.cost);
|
||
const revFmt = fmtYuan(c2.revenue);
|
||
return (
|
||
<tr key={c2.name + i} className="border-b border-slate-50 hover:bg-slate-50/60">
|
||
<td className="py-1.5 pl-1 text-slate-400 tabular-nums">{i + 1}</td>
|
||
<td className="py-1.5 text-slate-700 truncate max-w-[200px]">{c2.name}</td>
|
||
<td className="py-1.5 text-center hidden sm:table-cell">
|
||
{c2.payer === 'lingniu' ? (
|
||
<span className="px-1.5 py-0.5 rounded bg-blue-50 text-blue-600 text-[10px] font-bold">羚牛</span>
|
||
) : (
|
||
<span className="px-1.5 py-0.5 rounded bg-amber-50 text-amber-600 text-[10px] font-bold">客户</span>
|
||
)}
|
||
</td>
|
||
<td className="py-1.5 text-right tabular-nums font-bold text-slate-700">
|
||
{kgFmt.value}<span className="text-slate-400 font-normal ml-0.5">{kgFmt.unit}</span>
|
||
</td>
|
||
<td className="py-1.5 pl-2 text-right tabular-nums text-amber-600 font-bold">
|
||
¥{costFmt.value}<span className="text-slate-400 font-normal ml-0.5">{costFmt.unit}</span>
|
||
</td>
|
||
<td className="py-1.5 pr-1 text-right tabular-nums text-emerald-600 font-bold hidden md:table-cell">
|
||
¥{revFmt.value}<span className="text-slate-400 font-normal ml-0.5">{revFmt.unit}</span>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<RotatingFooterHint />
|
||
|
||
{/* 刷新中:透明遮罩 + 顶部进度条(不替换内容,避免闪烁) */}
|
||
<AnimatePresence>
|
||
{refreshing && data && (
|
||
<motion.div
|
||
key="refresh-overlay"
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
transition={{ duration: 0.2 }}
|
||
className="fixed top-0 left-0 right-0 h-0.5 z-50 pointer-events-none overflow-hidden"
|
||
>
|
||
<motion.div
|
||
className="h-full bg-gradient-to-r from-blue-400 via-cyan-400 to-blue-400"
|
||
initial={{ x: '-100%' }}
|
||
animate={{ x: '100%' }}
|
||
transition={{ duration: 1.2, repeat: Infinity, ease: 'linear' }}
|
||
style={{ width: '40%' }}
|
||
/>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function formatRelative(ts: number): string {
|
||
const s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
|
||
if (s < 5) return '刚刚';
|
||
if (s < 60) return `${s} 秒前`;
|
||
const m = Math.floor(s / 60);
|
||
if (m < 60) return `${m} 分钟前`;
|
||
const h = Math.floor(m / 60);
|
||
if (h < 24) return `${h} 小时前`;
|
||
return new Date(ts).toLocaleString('zh-CN', { hour12: false });
|
||
}
|
||
|
||
function HydrogenOverviewSkeleton() {
|
||
return (
|
||
<div className="flex flex-col gap-3 animate-pulse">
|
||
<div className="bg-white rounded-xl border border-slate-100 px-3 py-2">
|
||
<div className="h-3 w-44 bg-slate-100 rounded" />
|
||
</div>
|
||
|
||
{/* 5 卡占位 */}
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2 md:gap-3">
|
||
{Array.from({ length: 5 }).map((_, i) => (
|
||
<div key={i} className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 space-y-2">
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-7 h-7 rounded-xl bg-slate-100" />
|
||
<div className="h-3 w-16 bg-slate-100 rounded" />
|
||
</div>
|
||
<div className="h-7 w-24 bg-slate-200 rounded" />
|
||
<div className="space-y-1.5 pt-1 border-t border-slate-50">
|
||
<div className="flex justify-between"><div className="h-2.5 w-10 bg-slate-100 rounded" /><div className="h-2.5 w-16 bg-slate-100 rounded" /></div>
|
||
<div className="flex justify-between"><div className="h-2.5 w-10 bg-slate-100 rounded" /><div className="h-2.5 w-16 bg-slate-100 rounded" /></div>
|
||
</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-3">
|
||
<div className="h-4 w-32 bg-slate-100 rounded" />
|
||
<div className="h-3 w-12 bg-slate-100 rounded" />
|
||
</div>
|
||
<div className="flex items-end gap-2 h-[120px]">
|
||
{[60, 75, 50, 80, 35, 90, 45].map((h, i) => (
|
||
<div key={i} className="flex-1 bg-slate-100 rounded-t" style={{ height: `${h}%` }} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div className="h-4 w-32 bg-slate-100 rounded" />
|
||
<div className="h-3 w-12 bg-slate-100 rounded" />
|
||
</div>
|
||
<div className="space-y-3">
|
||
{[100, 78, 56, 40, 28].map((w, i) => (
|
||
<div key={i} className="flex items-center gap-3">
|
||
<div className="w-5 h-5 rounded-full bg-slate-200" />
|
||
<div className="h-3 w-32 bg-slate-100 rounded" />
|
||
<div className="flex-1 h-4 rounded-md bg-gradient-to-r from-slate-200 to-slate-100" style={{ maxWidth: `${w}%` }} />
|
||
<div className="h-3 w-12 bg-slate-100 rounded" />
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4 flex flex-col gap-3">
|
||
<div className="h-4 w-28 bg-slate-100 rounded" />
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-1/2 h-[200px] flex items-center justify-center">
|
||
<div className="w-32 h-32 rounded-full border-[18px] border-slate-100" />
|
||
</div>
|
||
<div className="flex-1 space-y-2">
|
||
{Array.from({ length: 5 }).map((_, i) => (
|
||
<div key={i} className="flex items-center gap-2">
|
||
<div className="w-2 h-2 rounded-full bg-slate-200" />
|
||
<div className="h-3 w-16 bg-slate-100 rounded" />
|
||
<div className="h-3 w-10 bg-slate-100 rounded ml-auto" />
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="text-center text-[11px] text-slate-400 font-bold flex items-center justify-center gap-1.5">
|
||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
|
||
正在加载氢能总览…
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|