diff --git a/apps/csms/src/routes/charge-points.ts b/apps/csms/src/routes/charge-points.ts index 7bb4534..bed989a 100644 --- a/apps/csms/src/routes/charge-points.ts +++ b/apps/csms/src/routes/charge-points.ts @@ -3,6 +3,7 @@ import { desc, eq, sql } from "drizzle-orm"; import dayjs from "dayjs"; import { useDrizzle } from "@/lib/db.js"; import { chargePoint, connector } from "@/db/schema.js"; +import { ocppConnections } from "@/ocpp/handler.js"; import type { HonoEnv } from "@/types/hono.ts"; const app = new Hono(); @@ -102,6 +103,13 @@ app.post("/", async (c) => { return c.json({ ...created, connectors: [] }, 201); }); +/** GET /api/charge-points/connections — list currently active OCPP WebSocket connections */ +app.get("/connections", (c) => { + return c.json({ + connectedIdentifiers: Array.from(ocppConnections.keys()), + }); +}); + /** GET /api/charge-points/:id — single charge point */ app.get("/:id", async (c) => { const db = useDrizzle(); diff --git a/apps/web/app/dashboard/topology/page.tsx b/apps/web/app/dashboard/topology/page.tsx new file mode 100644 index 0000000..c038bb3 --- /dev/null +++ b/apps/web/app/dashboard/topology/page.tsx @@ -0,0 +1,12 @@ +import TopologyClient from "./topology-client"; + +export default function TopologyPage() { + return ( + // Break out of the dashboard's max-w-7xl / px padding by using + // a fixed overlay that covers exactly the main content area. + // left-0/lg:left-60 accounts for the sidebar width (w-60). +
+ +
+ ); +} diff --git a/apps/web/app/dashboard/topology/topology-client.tsx b/apps/web/app/dashboard/topology/topology-client.tsx new file mode 100644 index 0000000..1a97dfd --- /dev/null +++ b/apps/web/app/dashboard/topology/topology-client.tsx @@ -0,0 +1,18 @@ +"use client"; + +import dynamic from "next/dynamic"; + +const TopologyFlow = dynamic(() => import("./topology-flow"), { + ssr: false, + loading: () => ( +
加载拓扑图…
+ ), +}); + +export default function TopologyClient() { + return ( +
+ +
+ ); +} diff --git a/apps/web/app/dashboard/topology/topology-flow.tsx b/apps/web/app/dashboard/topology/topology-flow.tsx new file mode 100644 index 0000000..2c4f98c --- /dev/null +++ b/apps/web/app/dashboard/topology/topology-flow.tsx @@ -0,0 +1,427 @@ +"use client"; + +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + ReactFlow, + Background, + Controls, + Handle, + MiniMap, + Panel, + Position, + type Node, + type Edge, + type NodeProps, + BackgroundVariant, +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; +import { api, type ChargePoint, type ConnectorSummary } from "@/lib/api"; +import dayjs from "@/lib/dayjs"; +import { Clock, EvCharger, Plug, Zap } from "lucide-react"; + +// ── Connection status ───────────────────────────────────────────────────── + +type ConnectionStatus = "online" | "stale" | "offline"; + +function getStatus(cp: ChargePoint, connected: string[]): ConnectionStatus { + if (!connected.includes(cp.chargePointIdentifier)) return "offline"; + if (!cp.lastHeartbeatAt) return "stale"; + return dayjs().diff(dayjs(cp.lastHeartbeatAt), "minute") < 5 ? "online" : "stale"; +} + +const STATUS_CONFIG: Record< + ConnectionStatus, + { color: string; edgeColor: string; label: string; animated: boolean } +> = { + online: { color: "#22c55e", edgeColor: "#22c55e", label: "在线", animated: true }, + stale: { color: "#f59e0b", edgeColor: "#f59e0b", label: "心跳超时", animated: true }, + offline: { color: "#71717a", edgeColor: "#9ca3af", label: "离线", animated: false }, +}; + +const CONNECTOR_STATUS_COLOR: Record = { + Available: "#22c55e", + Charging: "#3b82f6", + Preparing: "#f59e0b", + Finishing: "#f59e0b", + SuspendedEV: "#f59e0b", + SuspendedEVSE: "#f59e0b", + Reserved: "#a855f7", + Faulted: "#ef4444", + Unavailable: "#71717a", + Occupied: "#f59e0b", +}; + +const CONNECTOR_STATUS_LABEL: Record = { + Available: "空闲", + Charging: "充电中", + Preparing: "准备中", + Finishing: "结束中", + SuspendedEV: "EV 暂停", + SuspendedEVSE: "EVSE 暂停", + Reserved: "已预约", + Faulted: "故障", + Unavailable: "不可用", + Occupied: "占用", +}; + +// ── CSMS Hub Node ───────────────────────────────────────────────────────── + +type CsmsNodeData = { connectedCount: number; totalCount: number }; + +function CsmsHubNode({ data }: NodeProps) { + const { connectedCount, totalCount } = data as CsmsNodeData; + return ( +
+ +
+
+ +
+
+
CSMS 服务器
+
Helios EVCS
+
+
+
+ 0 ? "#22c55e" : "#71717a", + boxShadow: connectedCount > 0 ? "0 0 6px #22c55e" : "none", + }} + /> + + {connectedCount} / {totalCount} 设备在线 + +
+
+ ); +} + +// ── Charge Point Node ───────────────────────────────────────────────────── + +type ChargePointNodeData = { cp: ChargePoint; status: ConnectionStatus }; + +function ChargePointNode({ data }: NodeProps) { + const { cp, status } = data as ChargePointNodeData; + const cfg = STATUS_CONFIG[status]; + const hbText = cp.lastHeartbeatAt ? dayjs(cp.lastHeartbeatAt).fromNow() : "从未"; + + return ( +
+ + + +
+
+ + + {cp.chargePointIdentifier} + +
+ + + {cfg.label} + +
+ + {(cp.chargePointVendor || cp.chargePointModel) && ( +
+ {[cp.chargePointVendor, cp.chargePointModel].filter(Boolean).join(" · ")} +
+ )} + +
+ + 心跳 {hbText} +
+
+ ); +} + +// ── Connector Node ──────────────────────────────────────────────────────── + +type ConnectorNodeData = { conn: ConnectorSummary; cpStatus: ConnectionStatus }; + +function ConnectorNode({ data }: NodeProps) { + const { conn, cpStatus } = data as ConnectorNodeData; + const color = + cpStatus === "offline" ? "#71717a" : (CONNECTOR_STATUS_COLOR[conn.status] ?? "#f59e0b"); + const label = CONNECTOR_STATUS_LABEL[conn.status] ?? conn.status; + const isActive = conn.status === "Charging"; + + return ( +
+ +
+ + #{conn.connectorId} +
+
+ + {label} +
+
+ ); +} + +// ── Layout constants ────────────────────────────────────────────────────── + +const CP_W = 200; +const CP_H = 70; // matches actual rendered height +const CP_GAP_X = 60; +const CP_GAP_Y = 100; +const CONN_W = 96; +const CONN_H = 62; +const CONN_GAP_X = 12; +const CONN_ROW_GAP = 48; +const COLS = 5; +const CSMS_H = 88; + +/** Horizontal space a charge point needs (driven by its connector spread). */ +function slotWidth(cp: ChargePoint): number { + const n = cp.connectors.length; + if (n === 0) return CP_W; + return Math.max(CP_W, n * CONN_W + (n - 1) * CONN_GAP_X); +} + +function buildGraph( + chargePoints: ChargePoint[], + connectedIdentifiers: string[], +): { nodes: Node[]; edges: Edge[] } { + // Group into rows + const rows: ChargePoint[][] = []; + for (let i = 0; i < chargePoints.length; i += COLS) { + rows.push(chargePoints.slice(i, i + COLS)); + } + + // Width of each row (accounting for variable slot widths) + const rowWidths = rows.map((rowCps) => + rowCps.reduce((sum, cp, ci) => sum + slotWidth(cp) + (ci > 0 ? CP_GAP_X : 0), 0), + ); + const maxRowWidth = Math.max(...rowWidths, CP_W); + const csmsX = maxRowWidth / 2 - CP_W / 2; + + const nodes: Node[] = [ + { + id: "csms", + type: "csmsHub", + position: { x: csmsX, y: 0 }, + data: { connectedCount: connectedIdentifiers.length, totalCount: chargePoints.length }, + draggable: true, + width: CP_W, + height: CSMS_H, + }, + ]; + const edges: Edge[] = []; + + let cpY = CSMS_H + CP_GAP_Y; + + rows.forEach((rowCps, _rowIdx) => { + const rowW = rowWidths[_rowIdx]; + // Center narrower rows under CSMS + let curX = (maxRowWidth - rowW) / 2; + + // tallest connector row determines next cpY offset + const maxConnSpread = Math.max( + ...rowCps.map((cp) => (cp.connectors.length > 0 ? CONN_ROW_GAP + CONN_H : 0)), + ); + + rowCps.forEach((cp) => { + const sw = slotWidth(cp); + const cpX = curX + (sw - CP_W) / 2; // center CP node within its slot + + const status = getStatus(cp, connectedIdentifiers); + const cfg = STATUS_CONFIG[status]; + + nodes.push({ + id: cp.id, + type: "chargePoint", + position: { x: cpX, y: cpY }, + data: { cp, status }, + draggable: true, + width: CP_W, + height: CP_H, + }); + + edges.push({ + id: `csms-${cp.id}`, + source: "csms", + target: cp.id, + animated: cfg.animated, + style: { + stroke: cfg.edgeColor, + strokeWidth: status === "offline" ? 1 : 2, + strokeDasharray: status === "offline" ? "6 4" : undefined, + opacity: status === "offline" ? 0.4 : 0.85, + }, + }); + + // Connector nodes — centered under their CP + const sorted = [...cp.connectors].sort((a, b) => a.connectorId - b.connectorId); + const n = sorted.length; + if (n > 0) { + const totalConnW = n * CONN_W + (n - 1) * CONN_GAP_X; + const connStartX = cpX + CP_W / 2 - totalConnW / 2; + const connY = cpY + CP_H + CONN_ROW_GAP; + + sorted.forEach((conn, ci) => { + const connNodeId = `conn-${conn.id}`; + const connColor = + status === "offline" ? "#71717a" : (CONNECTOR_STATUS_COLOR[conn.status] ?? "#f59e0b"); + + nodes.push({ + id: connNodeId, + type: "connector", + position: { x: connStartX + ci * (CONN_W + CONN_GAP_X), y: connY }, + data: { conn, cpStatus: status }, + draggable: true, + width: CONN_W, + height: CONN_H, + }); + + edges.push({ + id: `${cp.id}-${connNodeId}`, + source: cp.id, + target: connNodeId, + animated: conn.status === "Charging", + style: { + stroke: connColor, + strokeWidth: conn.status === "Charging" ? 2 : 1.5, + opacity: status === "offline" ? 0.35 : 0.7, + strokeDasharray: status === "offline" ? "4 3" : undefined, + }, + }); + }); + } + + curX += sw + CP_GAP_X; + }); + + cpY += CP_H + CP_GAP_Y + maxConnSpread; + }); + + return { nodes, edges }; +} + +// ── Node type registry (stable reference — must be outside component) ───── + +const nodeTypes = { + csmsHub: CsmsHubNode, + chargePoint: ChargePointNode, + connector: ConnectorNode, +}; + +// ── Main component ──────────────────────────────────────────────────────── + +export default function TopologyFlow() { + const { data: chargePoints = [], isLoading } = useQuery({ + queryKey: ["chargePoints"], + queryFn: () => api.chargePoints.list(), + refetchInterval: 10_000, + }); + + const { data: connections } = useQuery({ + queryKey: ["chargePoints", "connections"], + queryFn: () => api.chargePoints.connections(), + refetchInterval: 10_000, + }); + + const connectedIds = connections?.connectedIdentifiers ?? []; + + const { nodes, edges } = useMemo( + () => buildGraph(chargePoints, connectedIds), + [chargePoints, connectedIds], + ); + + if (isLoading) { + return ( +
加载中…
+ ); + } + + if (chargePoints.length === 0) { + return ( +
+ +

暂无充电桩

+

在「充电桩」页面添加设备后将显示在此处

+
+ ); + } + + return ( +
+ + + + { + if (n.type === "csmsHub") return "#6366f1"; + if (n.type === "chargePoint") { + const status = (n.data as ChargePointNodeData).status; + return STATUS_CONFIG[status].color; + } + if (n.type === "connector") { + const { conn, cpStatus } = n.data as ConnectorNodeData; + return cpStatus === "offline" + ? "#71717a" + : (CONNECTOR_STATUS_COLOR[conn.status] ?? "#f59e0b"); + } + return "#888"; + }} + nodeStrokeWidth={0} + style={{ + background: "var(--color-surface-secondary)", + border: "1px solid var(--color-border)", + }} + // zoomable + // pannable + /> + +
+ ); +} diff --git a/apps/web/components/sidebar.tsx b/apps/web/components/sidebar.tsx index 1febe60..d1da288 100644 --- a/apps/web/components/sidebar.tsx +++ b/apps/web/components/sidebar.tsx @@ -17,7 +17,7 @@ import { } from "@gravity-ui/icons"; import SidebarFooter from "@/components/sidebar-footer"; import { useSession } from "@/lib/auth-client"; -import { EvCharger, Gauge, ReceiptText, UserCog, Users } from "lucide-react"; +import { EvCharger, Gauge, Network, ReceiptText, UserCog, Users } from "lucide-react"; const chargeItems = [ { href: "/dashboard/charge", label: "立即充电", icon: Thunderbolt, adminOnly: false }, @@ -27,6 +27,7 @@ const chargeItems = [ const navItems = [ { href: "/dashboard", label: "概览", icon: Gauge, exact: true, adminOnly: false }, { href: "/dashboard/charge-points", label: "充电桩", icon: EvCharger, adminOnly: false }, + { href: "/dashboard/topology", label: "拓扑图", icon: Network, adminOnly: false }, { href: "/dashboard/id-tags", label: "储值卡", icon: CreditCard, adminOnly: false }, { href: "/dashboard/transactions", label: "充电记录", icon: ReceiptText, adminOnly: false }, { href: "/dashboard/settings/pricing", label: "峰谷电价", icon: TagDollar, adminOnly: true }, diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index babdd4d..a273ada 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -68,6 +68,10 @@ export type ConnectorDetail = { updatedAt: string; }; +export type ConnectionsStatus = { + connectedIdentifiers: string[]; +}; + export type ChargePoint = { id: string; chargePointIdentifier: string; @@ -204,6 +208,7 @@ export const api = { chargePoints: { list: () => apiFetch("/api/charge-points"), get: (id: string) => apiFetch(`/api/charge-points/${id}`), + connections: () => apiFetch("/api/charge-points/connections"), create: (data: { chargePointIdentifier: string; chargePointVendor?: string; diff --git a/apps/web/package.json b/apps/web/package.json index c028edf..e74e2b1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,6 +15,7 @@ "@tanstack/react-query": "catalog:", "@tremor/react": "4.0.0-beta-tremor-v4.4", "@types/qrcode": "^1.5.6", + "@xyflow/react": "^12.10.1", "better-auth": "catalog:", "dayjs": "catalog:", "jsqr": "^1.4.0",