diff --git a/apps/csms/src/routes/stats.ts b/apps/csms/src/routes/stats.ts index 9037906..cf0c929 100644 --- a/apps/csms/src/routes/stats.ts +++ b/apps/csms/src/routes/stats.ts @@ -1,7 +1,7 @@ import { Hono } from "hono"; import { eq, isNull, sql } from "drizzle-orm"; 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"; const app = new Hono(); @@ -12,25 +12,42 @@ app.get("/", async (c) => { const isAdmin = currentUser?.role === "admin"; if (isAdmin) { - const [totalChargePoints, onlineChargePoints, activeTransactions, totalIdTags, todayEnergy] = - await Promise.all([ - db.select({ count: sql`count(*)::int` }).from(chargePoint), - db - .select({ count: sql`count(*)::int` }) - .from(chargePoint) - .where(sql`${chargePoint.lastHeartbeatAt} > now() - interval '120 seconds'`), - db - .select({ count: sql`count(*)::int` }) - .from(transaction) - .where(isNull(transaction.stopTimestamp)), - db.select({ count: sql`count(*)::int` }).from(idTag), - db - .select({ - total: sql`coalesce(sum(${transaction.stopMeterValue} - ${transaction.startMeterValue}), 0)::int`, - }) - .from(transaction) - .where(sql`${transaction.stopTimestamp} >= date_trunc('day', now())`), - ]); + const [ + totalChargePoints, + onlineChargePoints, + activeTransactions, + totalIdTags, + todayEnergy, + todayRevenue, + totalUsers, + todayTransactions, + ] = await Promise.all([ + db.select({ count: sql`count(*)::int` }).from(chargePoint), + db + .select({ count: sql`count(*)::int` }) + .from(chargePoint) + .where(sql`${chargePoint.lastHeartbeatAt} > now() - interval '120 seconds'`), + db + .select({ count: sql`count(*)::int` }) + .from(transaction) + .where(isNull(transaction.stopTimestamp)), + db.select({ count: sql`count(*)::int` }).from(idTag), + db + .select({ + total: sql`coalesce(sum(${transaction.stopMeterValue} - ${transaction.startMeterValue}), 0)::int`, + }) + .from(transaction) + .where(sql`${transaction.stopTimestamp} >= date_trunc('day', now())`), + db + .select({ total: sql`coalesce(sum(${transaction.chargeAmount}), 0)::int` }) + .from(transaction) + .where(sql`${transaction.stopTimestamp} >= date_trunc('day', now())`), + db.select({ count: sql`count(*)::int` }).from(user), + db + .select({ count: sql`count(*)::int` }) + .from(transaction) + .where(sql`${transaction.stopTimestamp} >= date_trunc('day', now())`), + ]); return c.json({ totalChargePoints: totalChargePoints[0].count, @@ -38,6 +55,9 @@ app.get("/", async (c) => { activeTransactions: activeTransactions[0].count, totalIdTags: totalIdTags[0].count, 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 [userIdTags, totalBalance, activeCount, totalTxCount] = await Promise.all([ + const [userIdTags, totalBalance, activeCount, totalTxCount, todayEnergy, todayTxCount] = await Promise.all([ // Cards belonging to this user db .select({ count: sql`count(*)::int` }) @@ -69,6 +89,24 @@ app.get("/", async (c) => { .from(transaction) .innerJoin(idTag, eq(transaction.idTag, idTag.idTag)) .where(eq(idTag.userId, userId)), + // Today's energy for user's cards + db + .select({ + total: sql`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`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({ @@ -76,6 +114,8 @@ app.get("/", async (c) => { totalBalance: totalBalance[0].total, activeTransactions: activeCount[0].count, totalTransactions: totalTxCount[0].count, + todayEnergyWh: todayEnergy[0].total, + todayTransactions: todayTxCount[0].count, }); }); diff --git a/apps/web/app/dashboard/page.tsx b/apps/web/app/dashboard/page.tsx index dc73ffe..13f91b9 100644 --- a/apps/web/app/dashboard/page.tsx +++ b/apps/web/app/dashboard/page.tsx @@ -1,10 +1,35 @@ "use client"; -import { useEffect, useState } from "react"; -import { Card } from "@heroui/react"; -import { Thunderbolt, PlugConnection, CreditCard, ChartColumn, TagDollar } from "@gravity-ui/icons"; +import { useCallback, useEffect, useState } from "react"; +import { Card, Spinner } from "@heroui/react"; +import { + Thunderbolt, + PlugConnection, + CreditCard, + ChartColumn, + TagDollar, + Person, +} from "@gravity-ui/icons"; 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"; @@ -57,42 +82,166 @@ function StatCard({ ); } +// ── Panel wrapper ────────────────────────────────────────────────────────── + +function Panel({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
+

{title}

+
+ {children} +
+ ); +} + +// ── RecentTransactions ──────────────────────────────────────────────────── + +function RecentTransactions({ txns }: { txns: Transaction[] }) { + if (txns.length === 0) { + return
暂无充电记录
; + } + return ( +
    + {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 ( +
  • + + + +
    +

    + {tx.chargePointIdentifier ?? "—"} + {tx.connectorNumber != null && ( + #{tx.connectorNumber} + )} +

    +

    {tx.idTag}

    +
    +
    +

    {kwh}

    +

    {amount}

    +
    +
    +

    {timeAgo(tx.startTimestamp)}

    +
    +
  • + ); + })} +
+ ); +} + +// ── ChargePointStatus ───────────────────────────────────────────────────── + +function ChargePointStatus({ cps }: { cps: ChargePoint[] }) { + if (cps.length === 0) { + return
暂无充电桩
; + } + return ( +
    + {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 ( +
  • + +
    +

    + {cp.chargePointIdentifier} +

    +

    + {cp.chargePointModel ?? cp.chargePointVendor ?? "未知型号"} +

    +
    +
    + {chargingCount > 0 && ( +

    {chargingCount} 充电中

    + )} +

    + {online ? `${availableCount} 可用` : "离线"} +

    +
    +
  • + ); + })} +
+ ); +} + +// ── Page ─────────────────────────────────────────────────────────────────── + export default function DashboardPage() { const { data: sessionData, isPending } = useSession(); const isAdmin = sessionData?.user?.role === "admin"; const [adminStats, setAdminStats] = useState(null); const [userStats, setUserStats] = useState(null); + const [recentTxns, setRecentTxns] = useState([]); + const [chargePoints, setChargePoints] = useState([]); + const [loading, setLoading] = useState(false); - useEffect(() => { + const load = useCallback(async () => { if (isPending) return; - api.stats - .get() - .then((data) => { - if ("todayEnergyWh" in data) { - setAdminStats(data); - return; - } - setUserStats(data); - }) - .catch(() => {}); + setLoading(true); + try { + const [statsData, txnsData, cpsData] = await Promise.all([ + api.stats.get(), + api.transactions.list({ limit: 6 }), + 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); + } }, [isPending, isAdmin]); - if (isPending) { + useEffect(() => { void load(); }, [load]); + + if (isPending || loading) { return (

概览

加载中…

+
+ +
); } + // ── Admin view ──────────────────────────────────────────────────────────── + if (isAdmin) { - const stats = adminStats; - const todayKwh = stats ? (stats.todayEnergyWh / 1000).toFixed(1) : "—"; - const offlineCount = (stats?.totalChargePoints ?? 0) - (stats?.onlineChargePoints ?? 0); + const s = adminStats; + 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 (
@@ -101,66 +250,101 @@ export default function DashboardPage() {

实时运营状态

-
+ {/* Today's live metrics */} +
+ 当日 00:00 起累计} + /> + 当日累计输出电能} + /> + 已完成订单金额合计} + /> + + + + {s?.activeTransactions ? "充电进行中" : "当前空闲"} + + + } + /> +
+ + {/* Infrastructure metrics */} +
- {stats?.onlineChargePoints ?? 0} 在线 + {s?.onlineChargePoints ?? 0} 在线 · {offlineCount} 离线 } /> - 最近 2 分钟有心跳} - /> - - - - {stats?.activeTransactions ? "活跃中" : "当前空闲"} - - - } - /> 已注册卡片总量} /> 当日 00:00 起累计} + title="注册用户" + value={s?.totalUsers ?? "—"} + icon={Person} + color="default" + footer={系统用户总数} />
+ + {/* Detail panels */} +
+
+ + + +
+
+ + + +
+
); } - // User view - const stats = userStats; - const totalYuan = stats ? (stats.totalBalance / 100).toFixed(2) : "—"; + // ── User view ───────────────────────────────────────────────────────────── + + const s = userStats; + const totalYuan = s ? (s.totalBalance / 100).toFixed(2) : "—"; + const todayKwh = s ? (s.todayEnergyWh / 1000).toFixed(2) : "—"; return (
@@ -171,10 +355,10 @@ export default function DashboardPage() {

-
+
已绑定的储值卡数量} @@ -187,27 +371,32 @@ export default function DashboardPage() { footer={所有储值卡余额合计} /> - - - {stats?.activeTransactions ? "充电中" : "当前空闲"} + + + {s?.activeTransactions ? "充电中" : "当前空闲"} } /> 历史总交易笔数} + footer={今日 {s?.todayTransactions ?? 0} 次} />
+ + + +
); } + diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index 9830f26..bff39ba 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -24,6 +24,9 @@ export type Stats = { activeTransactions: number; totalIdTags: number; todayEnergyWh: number; + todayRevenue: number; + totalUsers: number; + todayTransactions: number; }; export type UserStats = { @@ -31,6 +34,8 @@ export type UserStats = { totalBalance: number; activeTransactions: number; totalTransactions: number; + todayEnergyWh: number; + todayTransactions: number; }; export type ConnectorSummary = {