Files
helios-evcs/apps/web/app/dashboard/page.tsx

660 lines
24 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}