448 lines
17 KiB
TypeScript
448 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { Button, Card, Spinner } from "@heroui/react";
|
|
import Link from "next/link";
|
|
import {
|
|
Thunderbolt,
|
|
PlugConnection,
|
|
CreditCard,
|
|
ChartColumn,
|
|
TagDollar,
|
|
Person,
|
|
ArrowRotateRight,
|
|
TriangleExclamation,
|
|
} from "@gravity-ui/icons";
|
|
import { useSession } from "@/lib/auth-client";
|
|
import { api, type Stats, type UserStats, type Transaction, type ChargePoint } from "@/lib/api";
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
function timeAgo(dateStr: string | null | undefined): string {
|
|
if (!dateStr) return "—";
|
|
const ms = Date.now() - new Date(dateStr).getTime();
|
|
if (ms < 60_000) return "刚刚";
|
|
if (ms < 3_600_000) return `${Math.floor(ms / 60_000)} 分钟前`;
|
|
if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)} 小时前`;
|
|
return new Date(dateStr).toLocaleDateString("zh-CN", { month: "short", day: "numeric" });
|
|
}
|
|
|
|
function cpOnline(cp: ChargePoint): boolean {
|
|
if (!cp.lastHeartbeatAt) return false;
|
|
return Date.now() - new Date(cp.lastHeartbeatAt).getTime() < 120_000;
|
|
}
|
|
|
|
// ── 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 (
|
|
<Card className={`border-t-2 ${s.border}`}>
|
|
<Card.Content className="flex flex-col gap-3">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<p className="text-sm text-muted">{title}</p>
|
|
{Icon && (
|
|
<div className={`flex size-9 shrink-0 items-center justify-center rounded-xl ${s.bg}`}>
|
|
<Icon className={`size-4.5 ${s.icon}`} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
<p className="text-3xl font-bold tabular-nums leading-none text-foreground">{value}</p>
|
|
{footer && (
|
|
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-muted">
|
|
{footer}
|
|
</div>
|
|
)}
|
|
</Card.Content>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// ── 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>
|
|
);
|
|
}
|
|
|
|
// ── RecentTransactions ────────────────────────────────────────────────────
|
|
|
|
function RecentTransactions({ txns }: { txns: Transaction[] }) {
|
|
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"}`}
|
|
>
|
|
<Thunderbolt 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.chargePointIdentifier ?? "—"}
|
|
{tx.connectorNumber != null && (
|
|
<span className="ml-1 text-xs text-muted">#{tx.connectorNumber}</span>
|
|
)}
|
|
</p>
|
|
<p className="text-xs text-muted">{tx.idTag}</p>
|
|
</div>
|
|
<div className="shrink-0 text-right">
|
|
<p className="text-sm font-medium tabular-nums text-foreground">{kwh}</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 }: { cps: ChargePoint[] }) {
|
|
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.chargePointIdentifier}
|
|
</p>
|
|
<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">
|
|
<PlugConnection 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={PlugConnection}
|
|
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={Person}
|
|
color="default"
|
|
footer={<span>系统用户总数</span>}
|
|
/>
|
|
</div>
|
|
|
|
{/* 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 ?? []} />
|
|
</Panel>
|
|
</div>
|
|
<div className="lg:col-span-3">
|
|
<Panel title="最近充电会话">
|
|
<RecentTransactions txns={data?.txns ?? []} />
|
|
</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>
|
|
);
|
|
}
|