feat(stats): enhance admin and user statistics with additional metrics for revenue and user count

This commit is contained in:
2026-03-10 23:37:49 +08:00
parent 56bfdb4614
commit 984274bfb7
3 changed files with 318 additions and 84 deletions

View File

@@ -1,7 +1,7 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { eq, isNull, sql } from "drizzle-orm"; import { eq, isNull, sql } from "drizzle-orm";
import { useDrizzle } from "@/lib/db.js"; import { useDrizzle } from "@/lib/db.js";
import { chargePoint, transaction, idTag } from "@/db/schema.js"; import { chargePoint, transaction, idTag, user } from "@/db/schema.js";
import type { HonoEnv } from "@/types/hono.ts"; import type { HonoEnv } from "@/types/hono.ts";
const app = new Hono<HonoEnv>(); const app = new Hono<HonoEnv>();
@@ -12,25 +12,42 @@ app.get("/", async (c) => {
const isAdmin = currentUser?.role === "admin"; const isAdmin = currentUser?.role === "admin";
if (isAdmin) { if (isAdmin) {
const [totalChargePoints, onlineChargePoints, activeTransactions, totalIdTags, todayEnergy] = const [
await Promise.all([ totalChargePoints,
db.select({ count: sql<number>`count(*)::int` }).from(chargePoint), onlineChargePoints,
db activeTransactions,
.select({ count: sql<number>`count(*)::int` }) totalIdTags,
.from(chargePoint) todayEnergy,
.where(sql`${chargePoint.lastHeartbeatAt} > now() - interval '120 seconds'`), todayRevenue,
db totalUsers,
.select({ count: sql<number>`count(*)::int` }) todayTransactions,
.from(transaction) ] = await Promise.all([
.where(isNull(transaction.stopTimestamp)), db.select({ count: sql<number>`count(*)::int` }).from(chargePoint),
db.select({ count: sql<number>`count(*)::int` }).from(idTag), db
db .select({ count: sql<number>`count(*)::int` })
.select({ .from(chargePoint)
total: sql<number>`coalesce(sum(${transaction.stopMeterValue} - ${transaction.startMeterValue}), 0)::int`, .where(sql`${chargePoint.lastHeartbeatAt} > now() - interval '120 seconds'`),
}) db
.from(transaction) .select({ count: sql<number>`count(*)::int` })
.where(sql`${transaction.stopTimestamp} >= date_trunc('day', now())`), .from(transaction)
]); .where(isNull(transaction.stopTimestamp)),
db.select({ count: sql<number>`count(*)::int` }).from(idTag),
db
.select({
total: sql<number>`coalesce(sum(${transaction.stopMeterValue} - ${transaction.startMeterValue}), 0)::int`,
})
.from(transaction)
.where(sql`${transaction.stopTimestamp} >= date_trunc('day', now())`),
db
.select({ total: sql<number>`coalesce(sum(${transaction.chargeAmount}), 0)::int` })
.from(transaction)
.where(sql`${transaction.stopTimestamp} >= date_trunc('day', now())`),
db.select({ count: sql<number>`count(*)::int` }).from(user),
db
.select({ count: sql<number>`count(*)::int` })
.from(transaction)
.where(sql`${transaction.stopTimestamp} >= date_trunc('day', now())`),
]);
return c.json({ return c.json({
totalChargePoints: totalChargePoints[0].count, totalChargePoints: totalChargePoints[0].count,
@@ -38,6 +55,9 @@ app.get("/", async (c) => {
activeTransactions: activeTransactions[0].count, activeTransactions: activeTransactions[0].count,
totalIdTags: totalIdTags[0].count, totalIdTags: totalIdTags[0].count,
todayEnergyWh: todayEnergy[0].total, todayEnergyWh: todayEnergy[0].total,
todayRevenue: todayRevenue[0].total,
totalUsers: totalUsers[0].count,
todayTransactions: todayTransactions[0].count,
}); });
} }
@@ -46,7 +66,7 @@ app.get("/", async (c) => {
const userId = currentUser.id; const userId = currentUser.id;
const [userIdTags, totalBalance, activeCount, totalTxCount] = await Promise.all([ const [userIdTags, totalBalance, activeCount, totalTxCount, todayEnergy, todayTxCount] = await Promise.all([
// Cards belonging to this user // Cards belonging to this user
db db
.select({ count: sql<number>`count(*)::int` }) .select({ count: sql<number>`count(*)::int` })
@@ -69,6 +89,24 @@ app.get("/", async (c) => {
.from(transaction) .from(transaction)
.innerJoin(idTag, eq(transaction.idTag, idTag.idTag)) .innerJoin(idTag, eq(transaction.idTag, idTag.idTag))
.where(eq(idTag.userId, userId)), .where(eq(idTag.userId, userId)),
// Today's energy for user's cards
db
.select({
total: sql<number>`coalesce(sum(${transaction.stopMeterValue} - ${transaction.startMeterValue}), 0)::int`,
})
.from(transaction)
.innerJoin(idTag, eq(transaction.idTag, idTag.idTag))
.where(
sql`${transaction.stopTimestamp} >= date_trunc('day', now()) and ${idTag.userId} = ${userId}`,
),
// Today's completed transactions for user's cards
db
.select({ count: sql<number>`count(*)::int` })
.from(transaction)
.innerJoin(idTag, eq(transaction.idTag, idTag.idTag))
.where(
sql`${transaction.stopTimestamp} >= date_trunc('day', now()) and ${idTag.userId} = ${userId}`,
),
]); ]);
return c.json({ return c.json({
@@ -76,6 +114,8 @@ app.get("/", async (c) => {
totalBalance: totalBalance[0].total, totalBalance: totalBalance[0].total,
activeTransactions: activeCount[0].count, activeTransactions: activeCount[0].count,
totalTransactions: totalTxCount[0].count, totalTransactions: totalTxCount[0].count,
todayEnergyWh: todayEnergy[0].total,
todayTransactions: todayTxCount[0].count,
}); });
}); });

View File

@@ -1,10 +1,35 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Card } from "@heroui/react"; import { Card, Spinner } from "@heroui/react";
import { Thunderbolt, PlugConnection, CreditCard, ChartColumn, TagDollar } from "@gravity-ui/icons"; import {
Thunderbolt,
PlugConnection,
CreditCard,
ChartColumn,
TagDollar,
Person,
} from "@gravity-ui/icons";
import { useSession } from "@/lib/auth-client"; import { useSession } from "@/lib/auth-client";
import { api, type Stats, type UserStats } from "@/lib/api"; 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"; type CardColor = "accent" | "success" | "warning" | "default";
@@ -57,42 +82,166 @@ function StatCard({
); );
} }
// ── Panel wrapper ──────────────────────────────────────────────────────────
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="rounded-xl border border-border bg-surface-secondary">
<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;
return (
<li key={cp.id} className="flex items-center gap-3 px-5 py-3">
<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 font-mono 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">
{chargingCount > 0 && (
<p className="text-xs font-medium text-warning">{chargingCount} </p>
)}
<p className="text-xs text-muted">
{online ? `${availableCount} 可用` : "离线"}
</p>
</div>
</li>
);
})}
</ul>
);
}
// ── Page ───────────────────────────────────────────────────────────────────
export default function DashboardPage() { export default function DashboardPage() {
const { data: sessionData, isPending } = useSession(); const { data: sessionData, isPending } = useSession();
const isAdmin = sessionData?.user?.role === "admin"; const isAdmin = sessionData?.user?.role === "admin";
const [adminStats, setAdminStats] = useState<Stats | null>(null); const [adminStats, setAdminStats] = useState<Stats | null>(null);
const [userStats, setUserStats] = useState<UserStats | null>(null); const [userStats, setUserStats] = useState<UserStats | null>(null);
const [recentTxns, setRecentTxns] = useState<Transaction[]>([]);
const [chargePoints, setChargePoints] = useState<ChargePoint[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => { const load = useCallback(async () => {
if (isPending) return; if (isPending) return;
api.stats setLoading(true);
.get() try {
.then((data) => { const [statsData, txnsData, cpsData] = await Promise.all([
if ("todayEnergyWh" in data) { api.stats.get(),
setAdminStats(data); api.transactions.list({ limit: 6 }),
return; isAdmin ? api.chargePoints.list() : Promise.resolve(null),
} ]);
setUserStats(data); if ("totalChargePoints" in statsData) {
}) setAdminStats(statsData as Stats);
.catch(() => {}); } else {
setUserStats(statsData as UserStats);
}
setRecentTxns(txnsData.data);
if (cpsData) setChargePoints(cpsData);
} catch {}
finally {
setLoading(false);
}
}, [isPending, isAdmin]); }, [isPending, isAdmin]);
if (isPending) { useEffect(() => { void load(); }, [load]);
if (isPending || loading) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-xl font-semibold text-foreground"></h1> <h1 className="text-xl font-semibold text-foreground"></h1>
<p className="mt-0.5 text-sm text-muted"></p> <p className="mt-0.5 text-sm text-muted"></p>
</div> </div>
<div className="flex justify-center py-16">
<Spinner />
</div>
</div> </div>
); );
} }
// ── Admin view ────────────────────────────────────────────────────────────
if (isAdmin) { if (isAdmin) {
const stats = adminStats; const s = adminStats;
const todayKwh = stats ? (stats.todayEnergyWh / 1000).toFixed(1) : "—"; const todayKwh = s ? (s.todayEnergyWh / 1000).toFixed(1) : "—";
const offlineCount = (stats?.totalChargePoints ?? 0) - (stats?.onlineChargePoints ?? 0); const todayRevenue = s ? `¥${(s.todayRevenue / 100).toFixed(2)}` : "—";
const offlineCount = (s?.totalChargePoints ?? 0) - (s?.onlineChargePoints ?? 0);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -101,66 +250,101 @@ export default function DashboardPage() {
<p className="mt-0.5 text-sm text-muted"></p> <p className="mt-0.5 text-sm text-muted"></p>
</div> </div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-5"> {/* 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 <StatCard
title="充电桩总数" title="充电桩总数"
value={stats?.totalChargePoints ?? "—"} value={s?.totalChargePoints ?? "—"}
icon={PlugConnection} icon={PlugConnection}
color="accent" color="default"
footer={ footer={
<> <>
<StatusDot color="success" /> <StatusDot color="success" />
<span className="font-medium text-success"> <span className="font-medium text-success">
{stats?.onlineChargePoints ?? 0} 线 {s?.onlineChargePoints ?? 0} 线
</span> </span>
<span className="text-border">·</span> <span className="text-border">·</span>
<span>{offlineCount} 线</span> <span>{offlineCount} 线</span>
</> </>
} }
/> />
<StatCard
title="在线充电桩"
value={stats?.onlineChargePoints ?? "—"}
icon={PlugConnection}
color="success"
footer={<span> 2 </span>}
/>
<StatCard
title="进行中充电"
value={stats?.activeTransactions ?? "—"}
icon={Thunderbolt}
color={stats?.activeTransactions ? "warning" : "default"}
footer={
<>
<StatusDot color={stats?.activeTransactions ? "success" : "muted"} />
<span className={stats?.activeTransactions ? "font-medium text-success" : ""}>
{stats?.activeTransactions ? "活跃中" : "当前空闲"}
</span>
</>
}
/>
<StatCard <StatCard
title="储值卡总数" title="储值卡总数"
value={stats?.totalIdTags ?? "—"} value={s?.totalIdTags ?? "—"}
icon={CreditCard} icon={CreditCard}
color="default" color="default"
footer={<span></span>} footer={<span></span>}
/> />
<StatCard <StatCard
title="今日充电量" title="注册用户"
value={`${todayKwh} kWh`} value={s?.totalUsers ?? "—"}
icon={ChartColumn} icon={Person}
color="accent" color="default"
footer={<span> 00:00 </span>} footer={<span></span>}
/> />
</div> </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={chargePoints} />
</Panel>
</div>
<div className="lg:col-span-3">
<Panel title="最近充电会话">
<RecentTransactions txns={recentTxns} />
</Panel>
</div>
</div>
</div> </div>
); );
} }
// User view // ── User view ─────────────────────────────────────────────────────────────
const stats = userStats;
const totalYuan = stats ? (stats.totalBalance / 100).toFixed(2) : "—"; const s = userStats;
const totalYuan = s ? (s.totalBalance / 100).toFixed(2) : "—";
const todayKwh = s ? (s.todayEnergyWh / 1000).toFixed(2) : "—";
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -171,10 +355,10 @@ export default function DashboardPage() {
</p> </p>
</div> </div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4"> <div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<StatCard <StatCard
title="我的储值卡" title="我的储值卡"
value={stats?.totalIdTags ?? "—"} value={s?.totalIdTags ?? "—"}
icon={CreditCard} icon={CreditCard}
color="accent" color="accent"
footer={<span></span>} footer={<span></span>}
@@ -187,27 +371,32 @@ export default function DashboardPage() {
footer={<span></span>} footer={<span></span>}
/> />
<StatCard <StatCard
title="进行中充电" title="今日充电"
value={stats?.activeTransactions ?? "—"} value={`${todayKwh} kWh`}
icon={Thunderbolt} icon={Thunderbolt}
color={stats?.activeTransactions ? "warning" : "default"} color={s?.todayEnergyWh ? "warning" : "default"}
footer={ footer={
<> <>
<StatusDot color={stats?.activeTransactions ? "success" : "muted"} /> <StatusDot color={s?.activeTransactions ? "success" : "muted"} />
<span className={stats?.activeTransactions ? "font-medium text-success" : ""}> <span className={s?.activeTransactions ? "font-medium text-success" : ""}>
{stats?.activeTransactions ? "充电中" : "当前空闲"} {s?.activeTransactions ? "充电中" : "当前空闲"}
</span> </span>
</> </>
} }
/> />
<StatCard <StatCard
title="累计充电次数" title="累计充电次数"
value={stats?.totalTransactions ?? "—"} value={s?.totalTransactions ?? "—"}
icon={ChartColumn} icon={ChartColumn}
color="default" color="default"
footer={<span></span>} footer={<span> {s?.todayTransactions ?? 0} </span>}
/> />
</div> </div>
<Panel title="最近充电记录">
<RecentTransactions txns={recentTxns} />
</Panel>
</div> </div>
); );
} }

View File

@@ -24,6 +24,9 @@ export type Stats = {
activeTransactions: number; activeTransactions: number;
totalIdTags: number; totalIdTags: number;
todayEnergyWh: number; todayEnergyWh: number;
todayRevenue: number;
totalUsers: number;
todayTransactions: number;
}; };
export type UserStats = { export type UserStats = {
@@ -31,6 +34,8 @@ export type UserStats = {
totalBalance: number; totalBalance: number;
activeTransactions: number; activeTransactions: number;
totalTransactions: number; totalTransactions: number;
todayEnergyWh: number;
todayTransactions: number;
}; };
export type ConnectorSummary = { export type ConnectorSummary = {