feat(web): 添加拓扑图页面和相关组件
feat(csms): 添加获取当前连接状态的API feat(csms): 添加获取当前活动OCPP WebSocket连接的接口 deps(web): 添加@xyflow/react依赖
This commit is contained in:
@@ -3,6 +3,7 @@ import { desc, eq, sql } from "drizzle-orm";
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { useDrizzle } from "@/lib/db.js";
|
import { useDrizzle } from "@/lib/db.js";
|
||||||
import { chargePoint, connector } from "@/db/schema.js";
|
import { chargePoint, connector } from "@/db/schema.js";
|
||||||
|
import { ocppConnections } from "@/ocpp/handler.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>();
|
||||||
@@ -102,6 +103,13 @@ app.post("/", async (c) => {
|
|||||||
return c.json({ ...created, connectors: [] }, 201);
|
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 */
|
/** GET /api/charge-points/:id — single charge point */
|
||||||
app.get("/:id", async (c) => {
|
app.get("/:id", async (c) => {
|
||||||
const db = useDrizzle();
|
const db = useDrizzle();
|
||||||
|
|||||||
12
apps/web/app/dashboard/topology/page.tsx
Normal file
12
apps/web/app/dashboard/topology/page.tsx
Normal file
@@ -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).
|
||||||
|
<div className="fixed inset-0 left-0 top-14 lg:left-60 lg:top-0">
|
||||||
|
<TopologyClient />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
apps/web/app/dashboard/topology/topology-client.tsx
Normal file
18
apps/web/app/dashboard/topology/topology-client.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
const TopologyFlow = dynamic(() => import("./topology-flow"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="flex flex-1 items-center justify-center text-muted text-sm">加载拓扑图…</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function TopologyClient() {
|
||||||
|
return (
|
||||||
|
<div style={{ width: "100%", height: "100%" }}>
|
||||||
|
<TopologyFlow />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
427
apps/web/app/dashboard/topology/topology-flow.tsx
Normal file
427
apps/web/app/dashboard/topology/topology-flow.tsx
Normal file
@@ -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<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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 (
|
||||||
|
<div className="min-w-[200px] rounded-xl border border-accent/70 bg-accent px-5 py-4 text-accent-foreground shadow-lg shadow-accent/25">
|
||||||
|
<Handle type="source" position={Position.Bottom} style={{ opacity: 0 }} />
|
||||||
|
<div className="mb-2.5 flex items-center gap-2.5">
|
||||||
|
<div className="flex rounded-lg bg-accent-foreground/15 p-1.5">
|
||||||
|
<Zap size={16} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[13px] font-semibold leading-tight">CSMS 服务器</div>
|
||||||
|
<div className="mt-0.5 text-[11px] opacity-75">Helios EVCS</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 rounded-lg bg-accent-foreground/12 px-2.5 py-1.5">
|
||||||
|
<span
|
||||||
|
className="size-2 shrink-0 rounded-full"
|
||||||
|
style={{
|
||||||
|
background: connectedCount > 0 ? "#22c55e" : "#71717a",
|
||||||
|
boxShadow: connectedCount > 0 ? "0 0 6px #22c55e" : "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-medium">
|
||||||
|
{connectedCount} / {totalCount} 设备在线
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 (
|
||||||
|
<div
|
||||||
|
className="flex min-w-[190px] flex-col rounded-xl bg-surface px-2 py-2"
|
||||||
|
style={{
|
||||||
|
border: `1.5px solid ${status === "offline" ? "var(--color-border)" : cfg.color + "80"}`,
|
||||||
|
boxShadow:
|
||||||
|
status !== "offline" ? `0 2px 12px ${cfg.color}25` : "0 1px 4px rgba(0,0,0,0.08)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Handle type="target" position={Position.Top} style={{ opacity: 0 }} />
|
||||||
|
<Handle type="source" position={Position.Bottom} style={{ opacity: 0 }} />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<EvCharger className="size-4 shrink-0 text-muted" />
|
||||||
|
<span className="text-[13px] font-semibold tracking-tight text-foreground">
|
||||||
|
{cp.chargePointIdentifier}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium"
|
||||||
|
style={{ background: cfg.color + "18", color: cfg.color }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="inline-block size-1.5 shrink-0 rounded-full"
|
||||||
|
style={{
|
||||||
|
background: cfg.color,
|
||||||
|
boxShadow: status !== "offline" ? `0 0 5px ${cfg.color}` : "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{cfg.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(cp.chargePointVendor || cp.chargePointModel) && (
|
||||||
|
<div className="text-[10px] text-muted">
|
||||||
|
{[cp.chargePointVendor, cp.chargePointModel].filter(Boolean).join(" · ")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-1 flex items-center gap-1 text-[9px] text-muted">
|
||||||
|
<Clock size={10} />
|
||||||
|
<span>心跳 {hbText}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 (
|
||||||
|
<div
|
||||||
|
className="flex min-w-[88px] flex-col items-center gap-1.5 rounded-lg bg-surface px-2.5 py-2"
|
||||||
|
style={{
|
||||||
|
border: `1.5px solid ${color}80`,
|
||||||
|
boxShadow: isActive ? `0 0 10px ${color}40` : "0 1px 3px rgba(0,0,0,0.06)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Handle type="target" position={Position.Top} style={{ opacity: 0 }} />
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Plug className="size-3 shrink-0 text-muted" />
|
||||||
|
<span className="text-xs font-semibold text-foreground">#{conn.connectorId}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium"
|
||||||
|
style={{ background: color + "18", color }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="inline-block size-[5px] shrink-0 rounded-full"
|
||||||
|
style={{ background: color, boxShadow: isActive ? `0 0 4px ${color}` : "none" }}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 (
|
||||||
|
<div className="flex size-full items-center justify-center text-sm text-muted">加载中…</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chargePoints.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex size-full flex-col items-center justify-center gap-2 text-muted">
|
||||||
|
<EvCharger className="size-10 opacity-30" />
|
||||||
|
<p className="text-sm">暂无充电桩</p>
|
||||||
|
<p className="text-xs opacity-60">在「充电桩」页面添加设备后将显示在此处</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="size-full">
|
||||||
|
<ReactFlow
|
||||||
|
nodes={nodes}
|
||||||
|
edges={edges}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
fitView
|
||||||
|
fitViewOptions={{ padding: 0.15 }}
|
||||||
|
minZoom={0.15}
|
||||||
|
maxZoom={2}
|
||||||
|
proOptions={{ hideAttribution: true }}
|
||||||
|
>
|
||||||
|
<Background
|
||||||
|
variant={BackgroundVariant.Dots}
|
||||||
|
gap={20}
|
||||||
|
size={1}
|
||||||
|
color="var(--color-border)"
|
||||||
|
/>
|
||||||
|
<Controls showInteractive={false} />
|
||||||
|
<MiniMap
|
||||||
|
nodeColor={(n) => {
|
||||||
|
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
|
||||||
|
/>
|
||||||
|
</ReactFlow>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
} from "@gravity-ui/icons";
|
} from "@gravity-ui/icons";
|
||||||
import SidebarFooter from "@/components/sidebar-footer";
|
import SidebarFooter from "@/components/sidebar-footer";
|
||||||
import { useSession } from "@/lib/auth-client";
|
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 = [
|
const chargeItems = [
|
||||||
{ href: "/dashboard/charge", label: "立即充电", icon: Thunderbolt, adminOnly: false },
|
{ href: "/dashboard/charge", label: "立即充电", icon: Thunderbolt, adminOnly: false },
|
||||||
@@ -27,6 +27,7 @@ const chargeItems = [
|
|||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: "/dashboard", label: "概览", icon: Gauge, exact: true, adminOnly: false },
|
{ href: "/dashboard", label: "概览", icon: Gauge, exact: true, adminOnly: false },
|
||||||
{ href: "/dashboard/charge-points", label: "充电桩", icon: EvCharger, 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/id-tags", label: "储值卡", icon: CreditCard, adminOnly: false },
|
||||||
{ href: "/dashboard/transactions", label: "充电记录", icon: ReceiptText, adminOnly: false },
|
{ href: "/dashboard/transactions", label: "充电记录", icon: ReceiptText, adminOnly: false },
|
||||||
{ href: "/dashboard/settings/pricing", label: "峰谷电价", icon: TagDollar, adminOnly: true },
|
{ href: "/dashboard/settings/pricing", label: "峰谷电价", icon: TagDollar, adminOnly: true },
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ export type ConnectorDetail = {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ConnectionsStatus = {
|
||||||
|
connectedIdentifiers: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export type ChargePoint = {
|
export type ChargePoint = {
|
||||||
id: string;
|
id: string;
|
||||||
chargePointIdentifier: string;
|
chargePointIdentifier: string;
|
||||||
@@ -204,6 +208,7 @@ export const api = {
|
|||||||
chargePoints: {
|
chargePoints: {
|
||||||
list: () => apiFetch<ChargePoint[]>("/api/charge-points"),
|
list: () => apiFetch<ChargePoint[]>("/api/charge-points"),
|
||||||
get: (id: string) => apiFetch<ChargePointDetail>(`/api/charge-points/${id}`),
|
get: (id: string) => apiFetch<ChargePointDetail>(`/api/charge-points/${id}`),
|
||||||
|
connections: () => apiFetch<ConnectionsStatus>("/api/charge-points/connections"),
|
||||||
create: (data: {
|
create: (data: {
|
||||||
chargePointIdentifier: string;
|
chargePointIdentifier: string;
|
||||||
chargePointVendor?: string;
|
chargePointVendor?: string;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"@tanstack/react-query": "catalog:",
|
"@tanstack/react-query": "catalog:",
|
||||||
"@tremor/react": "4.0.0-beta-tremor-v4.4",
|
"@tremor/react": "4.0.0-beta-tremor-v4.4",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"@xyflow/react": "^12.10.1",
|
||||||
"better-auth": "catalog:",
|
"better-auth": "catalog:",
|
||||||
"dayjs": "catalog:",
|
"dayjs": "catalog:",
|
||||||
"jsqr": "^1.4.0",
|
"jsqr": "^1.4.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user