"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 { useSession } from "@/lib/auth-client"; 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 (cp.transportStatus === "unavailable") return "stale"; if (cp.transportStatus !== "online" || !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; isAdmin: boolean }; function ChargePointNode({ data }: NodeProps) { const { cp, status, isAdmin } = data as ChargePointNodeData; const cfg = STATUS_CONFIG[status]; const hbText = cp.lastHeartbeatAt ? dayjs(cp.lastHeartbeatAt).fromNow() : "从未"; return (
{cp.deviceName ?? cp.chargePointIdentifier} {isAdmin && cp.deviceName && ( {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[], isAdmin: boolean, ): { 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, isAdmin }, 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 { data: sessionData } = useSession(); const isAdmin = sessionData?.user?.role === "admin"; const connectedIds = connections?.connectedIdentifiers ?? []; const { nodes, edges } = useMemo( () => buildGraph(chargePoints, connectedIds, isAdmin), [chargePoints, connectedIds, isAdmin], ); 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 />
); }