660 lines
24 KiB
TypeScript
660 lines
24 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import { Button, Spinner } from "@heroui/react";
|
||
import Link from "next/link";
|
||
import {
|
||
Thunderbolt,
|
||
CreditCard,
|
||
ChartColumn,
|
||
TagDollar,
|
||
ArrowRotateRight,
|
||
TriangleExclamation,
|
||
} from "@gravity-ui/icons";
|
||
import { AreaChart } from "@tremor/react";
|
||
import dayjs from "@/lib/dayjs";
|
||
import { useSession } from "@/lib/auth-client";
|
||
import {
|
||
api,
|
||
type Stats,
|
||
type UserStats,
|
||
type Transaction,
|
||
type ChargePoint,
|
||
type ChartRange,
|
||
type ChartDataPoint,
|
||
} from "@/lib/api";
|
||
import { BanknoteArrowDown, EvCharger, Plug, Users } from "lucide-react";
|
||
import MetricIndicator from "@/components/metric-indicator";
|
||
|
||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||
|
||
function timeAgo(dateStr: string | null | undefined): string {
|
||
if (!dateStr) return "—";
|
||
return dayjs(dateStr).fromNow();
|
||
}
|
||
|
||
function cpOnline(cp: ChargePoint): boolean {
|
||
if (cp.transportStatus !== "online" || !cp.lastHeartbeatAt) return false;
|
||
return dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120;
|
||
}
|
||
|
||
// ── StatCard ───────────────────────────────────────────────────────────────
|
||
|
||
type CardColor = "accent" | "success" | "warning" | "default";
|
||
|
||
const colorStyles: Record<CardColor, { border: string; bg: string; icon: string }> = {
|
||
accent: { border: "border-accent", bg: "bg-accent/10", icon: "text-accent" },
|
||
success: { border: "border-success", bg: "bg-success/10", icon: "text-success" },
|
||
warning: { border: "border-warning", bg: "bg-warning/10", icon: "text-warning" },
|
||
default: { border: "border-border", bg: "bg-default", icon: "text-muted" },
|
||
};
|
||
|
||
function StatusDot({ color }: { color: "success" | "warning" | "muted" }) {
|
||
const cls =
|
||
color === "success" ? "bg-success" : color === "warning" ? "bg-warning" : "bg-muted/40";
|
||
return <span className={`inline-block h-1.5 w-1.5 shrink-0 rounded-full ${cls}`} />;
|
||
}
|
||
|
||
function StatCard({
|
||
title,
|
||
value,
|
||
footer,
|
||
icon: Icon,
|
||
color = "default",
|
||
}: {
|
||
title: string;
|
||
value: string | number;
|
||
footer?: React.ReactNode;
|
||
icon?: React.ComponentType<{ className?: string }>;
|
||
color?: CardColor;
|
||
}) {
|
||
const s = colorStyles[color];
|
||
return (
|
||
<MetricIndicator
|
||
title={title}
|
||
value={value}
|
||
color={s.border}
|
||
valueClassName="text-3xl font-bold tabular-nums leading-none text-foreground"
|
||
icon={
|
||
Icon ? (
|
||
<div className={`flex size-9 shrink-0 items-center justify-center rounded-xl ${s.bg}`}>
|
||
<Icon className={`size-4.5 ${s.icon}`} />
|
||
</div>
|
||
) : undefined
|
||
}
|
||
footer={footer}
|
||
/>
|
||
);
|
||
}
|
||
|
||
// ── Panel wrapper ──────────────────────────────────────────────────────────
|
||
|
||
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
|
||
return (
|
||
<div className="rounded-xl border border-border bg-surface-secondary overflow-hidden">
|
||
<div className="border-b border-border px-5 py-3.5">
|
||
<p className="text-sm font-semibold text-foreground">{title}</p>
|
||
</div>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── TrendChart ─────────────────────────────────────────────────────────────
|
||
|
||
function formatBucket(bucket: string, range: ChartRange): string {
|
||
const d = dayjs(bucket);
|
||
return range === "24h" ? d.format("HH:mm") : d.format("MM/DD");
|
||
}
|
||
|
||
const RANGES: { label: string; value: ChartRange }[] = [
|
||
{ label: "24 小时", value: "24h" },
|
||
{ label: "7 天", value: "7d" },
|
||
{ label: "30 天", value: "30d" },
|
||
];
|
||
|
||
type ChartView = "economy" | "activity";
|
||
|
||
const SERIES_CONFIG = {
|
||
revenue: {
|
||
color: "sky" as const,
|
||
label: "营收",
|
||
unit: "¥",
|
||
fmt: (v: number) => (v === 0 ? "¥0" : `¥${v.toFixed(v < 10 ? 2 : 1)}`),
|
||
},
|
||
energyKwh: {
|
||
color: "emerald" as const,
|
||
label: "充电量",
|
||
unit: "kWh",
|
||
fmt: (v: number) => (v === 0 ? "0 kWh" : `${v.toFixed(v < 10 ? 2 : 1)} kWh`),
|
||
},
|
||
transactions: {
|
||
color: "violet" as const,
|
||
label: "充电次数",
|
||
unit: "次",
|
||
fmt: (v: number) => `${v} 次`,
|
||
},
|
||
utilizationPct: {
|
||
color: "amber" as const,
|
||
label: "利用率",
|
||
unit: "%",
|
||
fmt: (v: number) => `${v.toFixed(1)}%`,
|
||
},
|
||
} as const;
|
||
|
||
const VIEW_SERIES: Record<ChartView, (keyof typeof SERIES_CONFIG)[]> = {
|
||
economy: ["revenue", "energyKwh"],
|
||
activity: ["transactions", "utilizationPct"],
|
||
};
|
||
|
||
function TrendChart() {
|
||
const [range, setRange] = useState<ChartRange>("7d");
|
||
const [view, setView] = useState<ChartView>("economy");
|
||
const [hidden, setHidden] = useState<Set<string>>(new Set());
|
||
|
||
const toggle = (key: string) =>
|
||
setHidden((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(key)) next.delete(key);
|
||
else next.add(key);
|
||
return next;
|
||
});
|
||
|
||
const switchView = (v: ChartView) => {
|
||
setView(v);
|
||
setHidden(new Set());
|
||
};
|
||
|
||
const allSeriesForView = VIEW_SERIES[view];
|
||
const visibleSeries = allSeriesForView.filter((k) => !hidden.has(k));
|
||
const visibleColors = visibleSeries.map((k) => SERIES_CONFIG[k].color);
|
||
|
||
const { data, isPending } = useQuery<ChartDataPoint[]>({
|
||
queryKey: ["stats-chart", range],
|
||
queryFn: () => api.stats.chart(range),
|
||
staleTime: 60_000,
|
||
});
|
||
|
||
return (
|
||
<div className="rounded-xl border border-border bg-surface-secondary overflow-hidden">
|
||
{/* Header */}
|
||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-5 py-3.5">
|
||
{/* View toggle */}
|
||
<div className="flex gap-1">
|
||
{(["economy", "activity"] as ChartView[]).map((v) => (
|
||
<button
|
||
key={v}
|
||
onClick={() => switchView(v)}
|
||
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
|
||
view === v
|
||
? "bg-accent-soft text-accent"
|
||
: "text-muted hover:bg-default hover:text-foreground"
|
||
}`}
|
||
>
|
||
{v === "economy" ? "营收 & 电量" : "次数 & 利用率"}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{/* Range toggle */}
|
||
<div className="flex gap-1">
|
||
{RANGES.map((r) => (
|
||
<button
|
||
key={r.value}
|
||
onClick={() => setRange(r.value)}
|
||
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
|
||
range === r.value
|
||
? "bg-accent text-white"
|
||
: "text-muted hover:bg-default hover:text-foreground"
|
||
}`}
|
||
>
|
||
{r.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Chart */}
|
||
<div className="px-4 py-4">
|
||
{isPending ? (
|
||
<div className="flex h-56 items-center justify-center">
|
||
<Spinner />
|
||
</div>
|
||
) : (
|
||
<AreaChart
|
||
data={(data ?? []).map((p) => ({ ...p, label: formatBucket(p.bucket, range) }))}
|
||
index="label"
|
||
categories={visibleSeries}
|
||
colors={visibleColors}
|
||
valueFormatter={(v) => `${v}`}
|
||
customTooltip={(props) => {
|
||
if (!props.active || !props.payload?.length) return null;
|
||
return (
|
||
<div className="rounded-lg border border-border bg-overlay px-3 py-2 shadow-md text-xs">
|
||
<p className="mb-1.5 font-semibold text-foreground">{props.label}</p>
|
||
{props.payload.map((entry) => {
|
||
const key = entry.dataKey as string;
|
||
const cfg = SERIES_CONFIG[key as keyof typeof SERIES_CONFIG];
|
||
if (!cfg) return null;
|
||
return (
|
||
<div key={key} className="flex items-center gap-2">
|
||
<span className={`inline-block h-2 w-2 rounded-full bg-${cfg.color}-500`} />
|
||
<span className="text-muted">{cfg.label}</span>
|
||
<span className="ml-auto font-medium text-foreground">
|
||
{cfg.fmt(entry.value as number)}
|
||
</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}}
|
||
showLegend={false}
|
||
showGridLines={true}
|
||
showYAxis={true}
|
||
showXAxis={true}
|
||
curveType="monotone"
|
||
showAnimation
|
||
className="h-56 text-sm"
|
||
/>
|
||
)}
|
||
{/* Legend */}
|
||
<div className="mt-2 flex items-center gap-2 px-1">
|
||
{allSeriesForView.map((key) => {
|
||
const cfg = SERIES_CONFIG[key];
|
||
return (
|
||
<button
|
||
key={key}
|
||
onClick={() => toggle(key)}
|
||
className={`flex items-center gap-1.5 rounded px-1.5 py-0.5 text-xs transition-opacity select-none hover:opacity-100 ${
|
||
hidden.has(key) ? "opacity-35" : "opacity-100"
|
||
}`}
|
||
>
|
||
<span className={`inline-block h-2 w-2 rounded-full bg-${cfg.color}-500`} />
|
||
<span className="text-muted">
|
||
{cfg.label}({cfg.unit})
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── RecentTransactions ────────────────────────────────────────────────────
|
||
|
||
function RecentTransactions({ txns, isAdmin = false }: { txns: Transaction[]; isAdmin?: boolean }) {
|
||
if (txns.length === 0) {
|
||
return <div className="py-8 text-center text-sm text-muted">暂无充电记录</div>;
|
||
}
|
||
return (
|
||
<ul className="divide-y divide-border">
|
||
{txns.map((tx) => {
|
||
const active = !tx.stopTimestamp;
|
||
const kwh =
|
||
tx.energyWh != null ? (tx.energyWh / 1000).toFixed(2) : active ? "充电中…" : "—";
|
||
const amount =
|
||
tx.chargeAmount != null && tx.chargeAmount > 0
|
||
? `¥${(tx.chargeAmount / 100).toFixed(2)}`
|
||
: active
|
||
? "—"
|
||
: "免费";
|
||
return (
|
||
<li key={tx.id} className="flex items-center gap-3 px-5 py-3">
|
||
<span
|
||
className={`flex size-7 shrink-0 items-center justify-center rounded-full ${active ? "bg-warning-soft" : "bg-success/10"}`}
|
||
>
|
||
<BanknoteArrowDown
|
||
className={`size-3.5 ${active ? "text-warning" : "text-success"}`}
|
||
/>
|
||
</span>
|
||
<div className="min-w-0 flex-1">
|
||
<p className="truncate text-sm font-medium text-foreground">
|
||
{tx.chargePointDeviceName ?? tx.chargePointIdentifier ?? "—"}
|
||
{tx.connectorNumber != null && (
|
||
<span className="ml-1 text-xs text-muted">#{tx.connectorNumber}</span>
|
||
)}
|
||
{isAdmin && tx.chargePointDeviceName && tx.chargePointIdentifier && (
|
||
<span className="ml-1 font-mono text-xs text-muted">
|
||
({tx.chargePointIdentifier})
|
||
</span>
|
||
)}
|
||
</p>
|
||
<p className="text-xs text-muted">
|
||
{tx.idTag}
|
||
{tx.idTagUserId && (
|
||
<>
|
||
<span className="mx-1">·</span>
|
||
{tx.idTagUserName ?? tx.idTagUserId}
|
||
<span className="ml-1 opacity-50">({tx.idTagUserId.slice(0, 8)})</span>
|
||
</>
|
||
)}
|
||
</p>
|
||
</div>
|
||
<div className="shrink-0 text-right">
|
||
<p className="text-sm font-medium tabular-nums text-foreground">
|
||
{kwh}
|
||
<span className="text-xs"> kWh</span>
|
||
</p>
|
||
<p className="text-xs text-muted">{amount}</p>
|
||
</div>
|
||
<div className="w-16 shrink-0 text-right">
|
||
<p className="text-xs text-muted">{timeAgo(tx.startTimestamp)}</p>
|
||
</div>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
);
|
||
}
|
||
|
||
// ── ChargePointStatus ─────────────────────────────────────────────────────
|
||
|
||
function ChargePointStatus({ cps, isAdmin }: { cps: ChargePoint[]; isAdmin: boolean }) {
|
||
if (cps.length === 0) {
|
||
return <div className="py-8 text-center text-sm text-muted">暂无充电桩</div>;
|
||
}
|
||
return (
|
||
<ul className="divide-y divide-border">
|
||
{cps.map((cp) => {
|
||
const online = cpOnline(cp);
|
||
const chargingCount = cp.connectors.filter((c) => c.status === "Charging").length;
|
||
const availableCount = cp.connectors.filter((c) => c.status === "Available").length;
|
||
const faultedCount = cp.connectors.filter((c) => c.status === "Faulted").length;
|
||
return (
|
||
<li key={cp.id}>
|
||
<Link
|
||
href={`/dashboard/charge-points/${cp.id}`}
|
||
className="flex items-center gap-3 px-5 py-3 transition-colors hover:bg-surface-hover"
|
||
>
|
||
<span
|
||
className={`mt-0.5 h-2 w-2 shrink-0 rounded-full ${online ? "bg-success" : "bg-muted/40"}`}
|
||
/>
|
||
<div className="min-w-0 flex-1">
|
||
<p className="truncate text-sm font-medium text-foreground">
|
||
{cp.deviceName ?? cp.chargePointIdentifier}
|
||
</p>
|
||
{isAdmin && cp.deviceName && (
|
||
<p className="font-mono text-xs text-muted">{cp.chargePointIdentifier}</p>
|
||
)}
|
||
{!(isAdmin && cp.deviceName) && (
|
||
<p className="text-xs text-muted">
|
||
{cp.chargePointModel ?? cp.chargePointVendor ?? "未知型号"}
|
||
</p>
|
||
)}
|
||
</div>
|
||
<div className="shrink-0 text-right">
|
||
{online ? (
|
||
<div className="flex items-center gap-2">
|
||
{/* Charge point status */}
|
||
{cp.chargePointStatus === "Faulted" && (
|
||
<div className="flex justify-end">
|
||
<div className="flex items-center gap-1 rounded-md bg-danger/10 px-2 py-1">
|
||
<TriangleExclamation className="size-3 text-danger" />
|
||
<span className="text-xs font-medium text-danger">整桩故障</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{/* Status summary */}
|
||
<div className="flex justify-end gap-2 text-xs">
|
||
{faultedCount > 0 && (
|
||
<div className="flex items-center gap-1">
|
||
<TriangleExclamation className="size-3 text-danger" />
|
||
<span className="font-medium text-danger">{faultedCount}</span>
|
||
</div>
|
||
)}
|
||
{chargingCount > 0 && (
|
||
<div className="flex items-center gap-1">
|
||
<Thunderbolt className="size-3 text-warning" />
|
||
<span className="font-medium text-warning">{chargingCount}</span>
|
||
</div>
|
||
)}
|
||
<div className="flex items-center gap-1">
|
||
<Plug className="size-3 text-accent" />
|
||
<span className="text-muted">{availableCount}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="text-xs text-muted">离线</div>
|
||
)}
|
||
</div>
|
||
</Link>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
);
|
||
}
|
||
|
||
// ── Page ───────────────────────────────────────────────────────────────────
|
||
|
||
export default function DashboardPage() {
|
||
const { data: sessionData, isPending } = useSession();
|
||
const isAdmin = sessionData?.user?.role === "admin";
|
||
|
||
const {
|
||
data,
|
||
isPending: queryPending,
|
||
isFetching: refreshing,
|
||
refetch,
|
||
} = useQuery({
|
||
queryKey: ["dashboard", isAdmin],
|
||
queryFn: async () => {
|
||
const [statsRes, txRes, cpsData] = await Promise.all([
|
||
api.stats.get(),
|
||
api.transactions.list({ limit: 6 }),
|
||
isAdmin ? api.chargePoints.list() : Promise.resolve([] as ChargePoint[]),
|
||
]);
|
||
return { stats: statsRes, txns: txRes.data, cps: cpsData };
|
||
},
|
||
refetchInterval: 3_000,
|
||
enabled: !isPending,
|
||
});
|
||
|
||
if (isPending || queryPending) {
|
||
return (
|
||
<div className="space-y-6">
|
||
<div>
|
||
<h1 className="text-xl font-semibold text-foreground">概览</h1>
|
||
<p className="mt-0.5 text-sm text-muted">加载中…</p>
|
||
</div>
|
||
<div className="flex justify-center py-16">
|
||
<Spinner />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Admin view ────────────────────────────────────────────────────────────
|
||
|
||
if (isAdmin) {
|
||
const s = data?.stats as Stats | undefined;
|
||
const todayKwh = s ? (s.todayEnergyWh / 1000).toFixed(1) : "—";
|
||
const todayRevenue = s ? `¥${(s.todayRevenue / 100).toFixed(2)}` : "—";
|
||
const offlineCount = (s?.totalChargePoints ?? 0) - (s?.onlineChargePoints ?? 0);
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div>
|
||
<h1 className="text-xl font-semibold text-foreground">概览</h1>
|
||
<p className="mt-0.5 text-sm text-muted">实时运营状态</p>
|
||
</div>
|
||
<Button
|
||
isIconOnly
|
||
size="sm"
|
||
variant="ghost"
|
||
isDisabled={refreshing}
|
||
onPress={() => refetch()}
|
||
aria-label="刷新"
|
||
>
|
||
<ArrowRotateRight className={`size-4 ${refreshing ? "animate-spin" : ""}`} />
|
||
</Button>
|
||
</div>
|
||
|
||
{/* Today's live metrics */}
|
||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||
<StatCard
|
||
title="今日充电次数"
|
||
value={s?.todayTransactions ?? "—"}
|
||
icon={ChartColumn}
|
||
color="accent"
|
||
footer={<span>当日 00:00 起累计</span>}
|
||
/>
|
||
<StatCard
|
||
title="今日充电量"
|
||
value={`${todayKwh} kWh`}
|
||
icon={Thunderbolt}
|
||
color="accent"
|
||
footer={<span>当日累计输出电能</span>}
|
||
/>
|
||
<StatCard
|
||
title="今日营收"
|
||
value={todayRevenue}
|
||
icon={TagDollar}
|
||
color="success"
|
||
footer={<span>已完成订单金额合计</span>}
|
||
/>
|
||
<StatCard
|
||
title="活跃充电"
|
||
value={s?.activeTransactions ?? "—"}
|
||
icon={Thunderbolt}
|
||
color={s?.activeTransactions ? "warning" : "default"}
|
||
footer={
|
||
<>
|
||
<StatusDot color={s?.activeTransactions ? "success" : "muted"} />
|
||
<span className={s?.activeTransactions ? "font-medium text-success" : ""}>
|
||
{s?.activeTransactions ? "充电进行中" : "当前空闲"}
|
||
</span>
|
||
</>
|
||
}
|
||
/>
|
||
</div>
|
||
|
||
{/* Infrastructure metrics */}
|
||
<div className="grid grid-cols-3 gap-4">
|
||
<StatCard
|
||
title="充电桩总数"
|
||
value={s?.totalChargePoints ?? "—"}
|
||
icon={EvCharger}
|
||
color="default"
|
||
footer={
|
||
<>
|
||
<StatusDot color="success" />
|
||
<span className="font-medium text-success">{s?.onlineChargePoints ?? 0} 在线</span>
|
||
<span className="text-border">·</span>
|
||
<span>{offlineCount} 离线</span>
|
||
</>
|
||
}
|
||
/>
|
||
<StatCard
|
||
title="储值卡总数"
|
||
value={s?.totalIdTags ?? "—"}
|
||
icon={CreditCard}
|
||
color="default"
|
||
footer={<span>已注册卡片总量</span>}
|
||
/>
|
||
<StatCard
|
||
title="注册用户"
|
||
value={s?.totalUsers ?? "—"}
|
||
icon={Users}
|
||
color="default"
|
||
footer={<span>系统用户总数</span>}
|
||
/>
|
||
</div>
|
||
|
||
{/* Trend chart */}
|
||
{isAdmin && <TrendChart />}
|
||
|
||
{/* Detail panels */}
|
||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-5">
|
||
<div className="lg:col-span-2">
|
||
<Panel title="充电桩状态">
|
||
<ChargePointStatus cps={data?.cps ?? []} isAdmin={isAdmin} />
|
||
</Panel>
|
||
</div>
|
||
<div className="lg:col-span-3">
|
||
<Panel title="最近充电会话">
|
||
<RecentTransactions txns={data?.txns ?? []} isAdmin={isAdmin} />
|
||
</Panel>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── User view ─────────────────────────────────────────────────────────────
|
||
|
||
const s = data?.stats as UserStats | undefined;
|
||
const totalYuan = s ? (s.totalBalance / 100).toFixed(2) : "—";
|
||
const todayKwh = s ? (s.todayEnergyWh / 1000).toFixed(2) : "—";
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div>
|
||
<h1 className="text-xl font-semibold text-foreground">概览</h1>
|
||
<p className="mt-0.5 text-sm text-muted">
|
||
{sessionData?.user?.name ?? sessionData?.user?.email} 的账户概览
|
||
</p>
|
||
</div>
|
||
<Button
|
||
isIconOnly
|
||
size="sm"
|
||
variant="ghost"
|
||
isDisabled={refreshing}
|
||
onPress={() => refetch()}
|
||
aria-label="刷新"
|
||
>
|
||
<ArrowRotateRight className={`size-4 ${refreshing ? "animate-spin" : ""}`} />
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||
<StatCard
|
||
title="我的储值卡"
|
||
value={s?.totalIdTags ?? "—"}
|
||
icon={CreditCard}
|
||
color="accent"
|
||
footer={<span>已绑定的储值卡数量</span>}
|
||
/>
|
||
<StatCard
|
||
title="账户总余额"
|
||
value={`¥${totalYuan}`}
|
||
icon={TagDollar}
|
||
color="success"
|
||
footer={<span>所有储值卡余额合计</span>}
|
||
/>
|
||
<StatCard
|
||
title="今日充电量"
|
||
value={`${todayKwh} kWh`}
|
||
icon={Thunderbolt}
|
||
color={s?.todayEnergyWh ? "warning" : "default"}
|
||
footer={
|
||
<>
|
||
<StatusDot color={s?.activeTransactions ? "success" : "muted"} />
|
||
<span className={s?.activeTransactions ? "font-medium text-success" : ""}>
|
||
{s?.activeTransactions ? "充电中" : "当前空闲"}
|
||
</span>
|
||
</>
|
||
}
|
||
/>
|
||
<StatCard
|
||
title="累计充电次数"
|
||
value={s?.totalTransactions ?? "—"}
|
||
icon={ChartColumn}
|
||
color="default"
|
||
footer={<span>今日 {s?.todayTransactions ?? 0} 次</span>}
|
||
/>
|
||
</div>
|
||
|
||
<Panel title="最近充电记录">
|
||
<RecentTransactions txns={data?.txns ?? []} />
|
||
</Panel>
|
||
</div>
|
||
);
|
||
}
|