From f803a447b56fc786979440d9c02448899718cb48 Mon Sep 17 00:00:00 2001 From: Timothy Yin Date: Tue, 10 Mar 2026 16:12:38 +0800 Subject: [PATCH] feat(web): charge point details page --- apps/csms/src/routes/transactions.ts | 11 +- .../app/dashboard/charge-points/[id]/page.tsx | 596 ++++++++++++++++++ apps/web/app/dashboard/charge-points/page.tsx | 12 +- apps/web/components/sidebar-footer.tsx | 2 +- apps/web/lib/api.ts | 43 +- 5 files changed, 654 insertions(+), 10 deletions(-) create mode 100644 apps/web/app/dashboard/charge-points/[id]/page.tsx diff --git a/apps/csms/src/routes/transactions.ts b/apps/csms/src/routes/transactions.ts index 5701d3d..e3aaeeb 100644 --- a/apps/csms/src/routes/transactions.ts +++ b/apps/csms/src/routes/transactions.ts @@ -1,5 +1,5 @@ import { Hono } from "hono"; -import { desc, eq, isNull, isNotNull, sql } from "drizzle-orm"; +import { and, desc, eq, isNull, isNotNull, sql } from "drizzle-orm"; import { useDrizzle } from "@/lib/db.js"; import { transaction, chargePoint, connector, idTag } from "@/db/schema.js"; import { ocppConnections } from "@/ocpp/handler.js"; @@ -12,17 +12,24 @@ app.get("/", async (c) => { const page = Math.max(1, Number(c.req.query("page") ?? 1)); const limit = Math.min(100, Math.max(1, Number(c.req.query("limit") ?? 20))); const status = c.req.query("status"); // 'active' | 'completed' + const chargePointId = c.req.query("chargePointId"); const offset = (page - 1) * limit; const db = useDrizzle(); - const whereClause = + const statusCondition = status === "active" ? isNull(transaction.stopTimestamp) : status === "completed" ? isNotNull(transaction.stopTimestamp) : undefined; + const whereClause = chargePointId + ? statusCondition + ? and(statusCondition, eq(transaction.chargePointId, chargePointId)) + : eq(transaction.chargePointId, chargePointId) + : statusCondition; + const [{ total }] = await db .select({ total: sql`count(*)::int` }) .from(transaction) diff --git a/apps/web/app/dashboard/charge-points/[id]/page.tsx b/apps/web/app/dashboard/charge-points/[id]/page.tsx new file mode 100644 index 0000000..a47a72f --- /dev/null +++ b/apps/web/app/dashboard/charge-points/[id]/page.tsx @@ -0,0 +1,596 @@ +"use client"; + +import { use, useCallback, useEffect, useState } from "react"; +import Link from "next/link"; +import { + Button, + Chip, + Input, + Label, + ListBox, + Modal, + Pagination, + Select, + Spinner, + Table, + TextField, +} from "@heroui/react"; +import { ArrowLeft, Pencil, PlugConnection } from "@gravity-ui/icons"; +import { api, type ChargePointDetail, type PaginatedTransactions } from "@/lib/api"; + +// ── Status maps ──────────────────────────────────────────────────────────── + +const statusLabelMap: Record = { + Available: "空闲", + Charging: "充电中", + Preparing: "准备中", + Finishing: "结束中", + SuspendedEV: "EV 暂停", + SuspendedEVSE: "EVSE 暂停", + Reserved: "已预约", + Faulted: "故障", + Unavailable: "不可用", + Occupied: "占用", +}; + +const statusDotClass: Record = { + Available: "bg-success", + Charging: "bg-[var(--accent)] animate-pulse", + Preparing: "bg-warning animate-pulse", + Finishing: "bg-warning", + SuspendedEV: "bg-warning", + SuspendedEVSE: "bg-warning", + Reserved: "bg-warning", + Faulted: "bg-danger", + Unavailable: "bg-danger", + Occupied: "bg-warning", +}; + +const registrationColorMap: Record = { + Accepted: "success", + Pending: "warning", + Rejected: "danger", +}; + +const TX_LIMIT = 10; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function formatDuration(start: string, stop: string | null): string { + if (!stop) return "进行中"; + const ms = new Date(stop).getTime() - new Date(start).getTime(); + const min = Math.floor(ms / 60000); + if (min < 60) return `${min} 分钟`; + const h = Math.floor(min / 60); + const m = min % 60; + return `${h}h ${m}m`; +} + +function relativeTime(iso: string): string { + const diff = Date.now() - new Date(iso).getTime(); + const s = Math.floor(diff / 1000); + if (s < 60) return `${s} 秒前`; + const m = Math.floor(s / 60); + if (m < 60) return `${m} 分钟前`; + const h = Math.floor(m / 60); + if (h < 24) return `${h} 小时前`; + const d = Math.floor(h / 24); + return `${d} 天前`; +} + +// ── Edit form type ───────────────────────────────────────────────────────── + +type EditForm = { + chargePointVendor: string; + chargePointModel: string; + registrationStatus: "Accepted" | "Pending" | "Rejected"; + feePerKwh: string; +}; + +// ── Component ────────────────────────────────────────────────────────────── + +export default function ChargePointDetailPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = use(params); + + const [cp, setCp] = useState(null); + const [notFound, setNotFound] = useState(false); + const [loading, setLoading] = useState(true); + + // transactions + const [txData, setTxData] = useState(null); + const [txPage, setTxPage] = useState(1); + const [txLoading, setTxLoading] = useState(true); + + // edit modal + const [editOpen, setEditOpen] = useState(false); + const [editBusy, setEditBusy] = useState(false); + const [editForm, setEditForm] = useState({ + chargePointVendor: "", + chargePointModel: "", + registrationStatus: "Pending", + feePerKwh: "0", + }); + + const loadCp = useCallback(async () => { + setLoading(true); + try { + const data = await api.chargePoints.get(id); + setCp(data); + } catch { + setNotFound(true); + } finally { + setLoading(false); + } + }, [id]); + + const loadTx = useCallback( + async (p: number) => { + setTxLoading(true); + try { + const data = await api.transactions.list({ + page: p, + limit: TX_LIMIT, + chargePointId: id, + }); + setTxData(data); + } finally { + setTxLoading(false); + } + }, + [id], + ); + + useEffect(() => { + loadCp(); + }, [loadCp]); + + useEffect(() => { + loadTx(txPage); + }, [txPage, loadTx]); + + const openEdit = () => { + if (!cp) return; + setEditForm({ + chargePointVendor: cp.chargePointVendor ?? "", + chargePointModel: cp.chargePointModel ?? "", + registrationStatus: cp.registrationStatus as EditForm["registrationStatus"], + feePerKwh: String(cp.feePerKwh), + }); + setEditOpen(true); + }; + + const handleEditSubmit = async () => { + if (!cp) return; + setEditBusy(true); + try { + const fee = Math.max(0, Math.round(Number(editForm.feePerKwh) || 0)); + const updated = await api.chargePoints.update(cp.id, { + chargePointVendor: editForm.chargePointVendor, + chargePointModel: editForm.chargePointModel, + registrationStatus: editForm.registrationStatus, + feePerKwh: fee, + }); + setCp((prev) => (prev ? { ...prev, ...updated, connectors: prev.connectors } : prev)); + setEditOpen(false); + } finally { + setEditBusy(false); + } + }; + + // Online if last heartbeat within 3× interval + const isOnline = + cp?.lastHeartbeatAt != null && + Date.now() - new Date(cp.lastHeartbeatAt).getTime() < (cp.heartbeatInterval ?? 60) * 3 * 1000; + + // ── Render: loading / not found ────────────────────────────────────────── + + if (loading) { + return ( +
+ +
+ ); + } + + if (notFound || !cp) { + return ( +
+ + + 充电桩管理 + +

充电桩不存在或已被删除。

+
+ ); + } + + const sortedConnectors = [...cp.connectors].sort((a, b) => a.connectorId - b.connectorId); + + // ── Render ─────────────────────────────────────────────────────────────── + + return ( +
+ {/* Breadcrumb back */} + + + 充电桩管理 + + + {/* Header */} +
+
+
+

+ {cp.chargePointIdentifier} +

+ + {cp.registrationStatus} + +
+ + {isOnline ? "在线" : "离线"} +
+
+ {(cp.chargePointVendor || cp.chargePointModel) && ( +

+ {[cp.chargePointVendor, cp.chargePointModel].filter(Boolean).join(" · ")} +

+ )} +
+ +
+ + {/* Info grid */} +
+ {/* Device info */} +
+

设备信息

+
+ {[ + { label: "品牌", value: cp.chargePointVendor }, + { label: "型号", value: cp.chargePointModel }, + { label: "序列号", value: cp.chargePointSerialNumber }, + { label: "固件版本", value: cp.firmwareVersion }, + { label: "电表型号", value: cp.meterType }, + { label: "电表序列号", value: cp.meterSerialNumber }, + { label: "ICCID", value: cp.iccid }, + { label: "IMSI", value: cp.imsi }, + ].map(({ label, value }) => ( +
+
{label}
+
+ {value ?? } +
+
+ ))} +
+
+ + {/* Operation info */} +
+

运行配置

+
+
+
注册状态
+
+ + {cp.registrationStatus} + +
+
+
+
电价
+
+ {cp.feePerKwh > 0 ? ( + + {cp.feePerKwh} 分/kWh + + (¥{(cp.feePerKwh / 100).toFixed(2)}/kWh) + + + ) : ( + "免费" + )} +
+
+
+
心跳间隔
+
+ {cp.heartbeatInterval != null ? ( + `${cp.heartbeatInterval} 秒` + ) : ( + + )} +
+
+
+
最后心跳
+
+ {cp.lastHeartbeatAt ? ( + + {relativeTime(cp.lastHeartbeatAt)} + + ) : ( + + )} +
+
+
+
最后启动通知
+
+ {cp.lastBootNotificationAt ? ( + + {relativeTime(cp.lastBootNotificationAt)} + + ) : ( + + )} +
+
+
+
注册时间
+
+ {new Date(cp.createdAt).toLocaleDateString("zh-CN")} +
+
+
+
+
+ + {/* Connectors */} + {sortedConnectors.length > 0 && ( +
+

接口状态

+
+ {sortedConnectors.map((conn) => ( +
+
+ + + 接口 #{conn.connectorId} + + + + + {statusLabelMap[conn.status] ?? conn.status} + + +
+ {conn.errorCode && conn.errorCode !== "NoError" && ( +

错误: {conn.errorCode}

+ )} + {conn.info &&

{conn.info}

} +

+ 更新于{" "} + {new Date(conn.lastStatusAt).toLocaleString("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + })} +

+
+ ))} +
+
+ )} + + {/* Transactions */} +
+
+

充电记录

+ {txData && 共 {txData.total} 条} +
+ + + + + + ID + 接口 + 充电卡 + 开始时间 + 时长 + 用电量 + 费用 + 停止原因 + + ( +
+ {txLoading ? "加载中…" : "暂无充电记录"} +
+ )} + > + {(txData?.data ?? []).map((tx) => ( + + #{tx.id} + + {tx.connectorNumber != null ? ( + `#${tx.connectorNumber}` + ) : ( + + )} + + {tx.idTag} + + {new Date(tx.startTimestamp).toLocaleString("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + })} + + {formatDuration(tx.startTimestamp, tx.stopTimestamp)} + + {tx.energyWh != null ? ( + `${(tx.energyWh / 1000).toFixed(2)} kWh` + ) : ( + + )} + + + {tx.chargeAmount != null ? ( + `¥${(tx.chargeAmount / 100).toFixed(2)}` + ) : ( + + )} + + + {tx.stopReason ?? } + + + ))} +
+
+
+
+ + {txData && txData.totalPages > 1 && ( +
+ + + + setTxPage((p) => Math.max(1, p - 1))} + > + + 上一页 + + + {Array.from({ length: txData.totalPages }, (_, i) => i + 1).map((p) => ( + + setTxPage(p)}> + {p} + + + ))} + + setTxPage((p) => Math.min(txData.totalPages, p + 1))} + > + 下一页 + + + + + +
+ )} +
+ + {/* Edit modal */} + { + if (!editBusy) setEditOpen(open); + }} + > + + + + + + 编辑充电桩 + + +
+ + + + setEditForm((f) => ({ ...f, chargePointVendor: e.target.value })) + } + /> + + + + + setEditForm((f) => ({ ...f, chargePointModel: e.target.value })) + } + /> + +
+
+ + +
+ + + setEditForm((f) => ({ ...f, feePerKwh: e.target.value }))} + /> + +
+ + + + +
+
+
+
+
+ ); +} diff --git a/apps/web/app/dashboard/charge-points/page.tsx b/apps/web/app/dashboard/charge-points/page.tsx index 6d5fb8c..e27d13a 100644 --- a/apps/web/app/dashboard/charge-points/page.tsx +++ b/apps/web/app/dashboard/charge-points/page.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Button, Chip, Input, Label, ListBox, Modal, Select, Spinner, Table, TextField } from "@heroui/react"; import { Plus, Pencil, PlugConnection, TrashBin } from "@gravity-ui/icons"; +import Link from "next/link"; import { api, type ChargePoint } from "@/lib/api"; @@ -273,8 +274,13 @@ export default function ChargePointsPage() { )} {chargePoints.map((cp) => ( - - {cp.chargePointIdentifier} + + + {cp.chargePointIdentifier} + {cp.chargePointVendor && cp.chargePointModel ? ( @@ -331,7 +337,7 @@ export default function ChargePointsPage() { statusDotClass[conn.status] ?? "bg-warning" }`} /> - + {statusLabelMap[conn.status] ?? conn.status} diff --git a/apps/web/components/sidebar-footer.tsx b/apps/web/components/sidebar-footer.tsx index 26b0f43..0cffc33 100644 --- a/apps/web/components/sidebar-footer.tsx +++ b/apps/web/components/sidebar-footer.tsx @@ -42,7 +42,7 @@ export default function SidebarFooter() { 退出登录 -

OCPP 1.6-J • v0.1.0

+

Helios EVCS

) } diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index ad15ecf..985dd7d 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -27,14 +27,27 @@ export type Stats = { }; export type ConnectorSummary = { - id: number; + id: string; connectorId: number; status: string; lastStatusAt: string | null; }; +export type ConnectorDetail = { + id: string; + connectorId: number; + status: string; + errorCode: string; + info: string | null; + vendorId: string | null; + vendorErrorCode: string | null; + lastStatusAt: string; + createdAt: string; + updatedAt: string; +}; + export type ChargePoint = { - id: number; + id: string; chargePointIdentifier: string; chargePointVendor: string | null; chargePointModel: string | null; @@ -45,6 +58,27 @@ export type ChargePoint = { connectors: ConnectorSummary[]; }; +export type ChargePointDetail = { + id: string; + chargePointIdentifier: string; + chargePointVendor: string | null; + chargePointModel: string | null; + chargePointSerialNumber: string | null; + firmwareVersion: string | null; + iccid: string | null; + imsi: string | null; + meterSerialNumber: string | null; + meterType: string | null; + registrationStatus: string; + heartbeatInterval: number | null; + lastHeartbeatAt: string | null; + lastBootNotificationAt: string | null; + feePerKwh: number; + createdAt: string; + updatedAt: string; + connectors: ConnectorDetail[]; +}; + export type Transaction = { id: number; chargePointIdentifier: string | null; @@ -98,7 +132,7 @@ export const api = { }, chargePoints: { list: () => apiFetch("/api/charge-points"), - get: (id: number) => apiFetch(`/api/charge-points/${id}`), + get: (id: string) => apiFetch(`/api/charge-points/${id}`), create: (data: { chargePointIdentifier: string; chargePointVendor?: string; @@ -124,11 +158,12 @@ export const api = { apiFetch<{ success: true }>(`/api/charge-points/${id}`, { method: "DELETE" }), }, transactions: { - list: (params?: { page?: number; limit?: number; status?: "active" | "completed" }) => { + list: (params?: { page?: number; limit?: number; status?: "active" | "completed"; chargePointId?: string }) => { const q = new URLSearchParams(); if (params?.page) q.set("page", String(params.page)); if (params?.limit) q.set("limit", String(params.limit)); if (params?.status) q.set("status", params.status); + if (params?.chargePointId) q.set("chargePointId", params.chargePointId); const qs = q.toString(); return apiFetch(`/api/transactions${qs ? "?" + qs : ""}`); },