feat(stats): enhance admin and user statistics with additional metrics for revenue and user count
This commit is contained in:
@@ -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,8 +12,16 @@ 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,
|
||||||
|
onlineChargePoints,
|
||||||
|
activeTransactions,
|
||||||
|
totalIdTags,
|
||||||
|
todayEnergy,
|
||||||
|
todayRevenue,
|
||||||
|
totalUsers,
|
||||||
|
todayTransactions,
|
||||||
|
] = await Promise.all([
|
||||||
db.select({ count: sql<number>`count(*)::int` }).from(chargePoint),
|
db.select({ count: sql<number>`count(*)::int` }).from(chargePoint),
|
||||||
db
|
db
|
||||||
.select({ count: sql<number>`count(*)::int` })
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
@@ -30,6 +38,15 @@ app.get("/", async (c) => {
|
|||||||
})
|
})
|
||||||
.from(transaction)
|
.from(transaction)
|
||||||
.where(sql`${transaction.stopTimestamp} >= date_trunc('day', now())`),
|
.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({
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
]);
|
||||||
|
if ("totalChargePoints" in statsData) {
|
||||||
|
setAdminStats(statsData as Stats);
|
||||||
|
} else {
|
||||||
|
setUserStats(statsData as UserStats);
|
||||||
|
}
|
||||||
|
setRecentTxns(txnsData.data);
|
||||||
|
if (cpsData) setChargePoints(cpsData);
|
||||||
|
} catch {}
|
||||||
|
finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
setUserStats(data);
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}, [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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user