Unify charge point command channel status

This commit is contained in:
2026-03-18 15:26:56 +08:00
parent 3508e7de19
commit ff5b92986f
14 changed files with 347 additions and 86 deletions

View File

@@ -180,8 +180,16 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
// Online if last heartbeat within 3× interval
const isOnline =
cp?.transportStatus === "online" &&
cp?.lastHeartbeatAt != null &&
dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < (cp.heartbeatInterval ?? 60) * 3;
const commandChannelUnavailable = cp?.transportStatus === "unavailable";
const statusLabel = isOnline ? "在线" : commandChannelUnavailable ? "通道异常" : "离线";
const statusDotClass = isOnline
? "bg-success animate-pulse"
: commandChannelUnavailable
? "bg-warning"
: "bg-muted";
const { data: sessionData } = useSession();
const isAdmin = sessionData?.user?.role === "admin";
@@ -245,9 +253,9 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
</Chip>
<div className="flex items-center gap-1.5">
<span
className={`size-2 rounded-full ${isOnline ? "bg-success animate-pulse" : "bg-muted"}`}
className={`size-2 rounded-full ${statusDotClass}`}
/>
<span className="text-xs text-muted">{isOnline ? "在线" : "离线"}</span>
<span className="text-xs text-muted">{statusLabel}</span>
</div>
</div>
{(cp.chargePointVendor || cp.chargePointModel) && (
@@ -437,9 +445,9 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
<dd>
<div className="flex items-center gap-1.5">
<span
className={`size-2 rounded-full ${isOnline ? "bg-success animate-pulse" : "bg-muted"}`}
className={`size-2 rounded-full ${statusDotClass}`}
/>
<span className="text-sm text-foreground">{isOnline ? "在线" : "离线"}</span>
<span className="text-sm text-foreground">{statusLabel}</span>
</div>
</dd>
</div>

View File

@@ -574,18 +574,22 @@ export default function ChargePointsPage() {
{isAdmin && <Table.Cell>{""}</Table.Cell>}
</Table.Row>
)}
{chargePoints.map((cp) => (
<Table.Row key={cp.id} id={String(cp.id)} className={"group"}>
{chargePoints.map((cp) => {
const online =
cp.transportStatus === "online" &&
!!cp.lastHeartbeatAt &&
dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120;
const commandChannelUnavailable = cp.transportStatus === "unavailable";
return (
<Table.Row key={cp.id} id={String(cp.id)} className={"group"}>
<Table.Cell>
<Tooltip delay={0}>
<Tooltip.Trigger>
<div className="flex items-center gap-2">
<span
className={`size-2 shrink-0 rounded-full ${
cp.lastHeartbeatAt &&
dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120
? "bg-success"
: "bg-gray-300"
online ? "bg-success" : commandChannelUnavailable ? "bg-warning" : "bg-gray-300"
}`}
/>
<div className="flex flex-col">
@@ -604,11 +608,7 @@ export default function ChargePointsPage() {
</div>
</Tooltip.Trigger>
<Tooltip.Content placement="start">
{cp.lastHeartbeatAt
? dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120
? "在线"
: "离线"
: "从未连接"}
{online ? "在线" : commandChannelUnavailable ? "通道异常" : cp.lastHeartbeatAt ? "离线" : "从未连接"}
</Tooltip.Content>
</Tooltip>
</Table.Cell>
@@ -751,8 +751,9 @@ export default function ChargePointsPage() {
</div>
</Table.Cell>
)}
</Table.Row>
))}
</Table.Row>
);
})}
</Table.Body>
</Table.Content>
</Table.ScrollContainer>

View File

@@ -363,8 +363,11 @@ function ChargePageContent() {
const msg = err.message ?? "";
const lowerMsg = msg.toLowerCase();
if (lowerMsg.includes("offline")) setStartError("充电桩当前不在线,请稍后再试");
else if (
if (lowerMsg.includes("command channel is unavailable") || lowerMsg.includes("offline")) {
setStartError("充电桩下行通道不可用,请稍后再试");
} else if (lowerMsg.includes("did not confirm remotestarttransaction in time")) {
setStartError("充电桩未及时确认启动指令,请稍后重试");
} else if (
lowerMsg.includes("chargepoint is not accepted") ||
lowerMsg.includes("not accepted")
) {
@@ -596,7 +599,10 @@ function ChargePageContent() {
.filter((cp) => cp.registrationStatus === "Accepted")
.map((cp) => {
const online =
!!cp.lastHeartbeatAt && dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120;
cp.transportStatus === "online" &&
!!cp.lastHeartbeatAt &&
dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120;
const commandChannelUnavailable = cp.transportStatus === "unavailable";
const availableCount = cp.connectors.filter(
(c) => c.status === "Available",
).length;
@@ -644,13 +650,17 @@ function ChargePageContent() {
"shrink-0 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold",
online
? "bg-success/12 text-success"
: commandChannelUnavailable
? "bg-warning/12 text-warning"
: "bg-surface-tertiary text-muted",
].join(" ")}
>
<span
className={`size-1.5 rounded-full ${online ? "bg-success" : "bg-muted"}`}
className={`size-1.5 rounded-full ${
online ? "bg-success" : commandChannelUnavailable ? "bg-warning" : "bg-muted"
}`}
/>
{online ? "在线" : "离线"}
{online ? "在线" : commandChannelUnavailable ? "通道异常" : "离线"}
</span>
</div>
{/* Bottom row: connectors + fee */}

View File

@@ -35,7 +35,7 @@ function timeAgo(dateStr: string | null | undefined): string {
}
function cpOnline(cp: ChargePoint): boolean {
if (!cp.lastHeartbeatAt) return false;
if (cp.transportStatus !== "online" || !cp.lastHeartbeatAt) return false;
return dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120;
}

View File

@@ -26,7 +26,8 @@ import { Clock, EvCharger, Plug, Zap } from "lucide-react";
type ConnectionStatus = "online" | "stale" | "offline";
function getStatus(cp: ChargePoint, connected: string[]): ConnectionStatus {
if (!connected.includes(cp.chargePointIdentifier)) return "offline";
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";
}
@@ -36,7 +37,7 @@ const STATUS_CONFIG: Record<
{ color: string; edgeColor: string; label: string; animated: boolean }
> = {
online: { color: "#22c55e", edgeColor: "#22c55e", label: "在线", animated: true },
stale: { color: "#f59e0b", edgeColor: "#f59e0b", label: "心跳超时", animated: true },
stale: { color: "#f59e0b", edgeColor: "#f59e0b", label: "通道异常", animated: true },
offline: { color: "#71717a", edgeColor: "#9ca3af", label: "离线", animated: false },
};

View File

@@ -72,6 +72,8 @@ export type ConnectionsStatus = {
connectedIdentifiers: string[];
};
export type ChargePointConnectionStatus = "online" | "unavailable" | "offline";
export type ChargePoint = {
id: string;
chargePointIdentifier: string;
@@ -79,7 +81,12 @@ export type ChargePoint = {
chargePointVendor: string | null;
chargePointModel: string | null;
registrationStatus: string;
transportStatus: ChargePointConnectionStatus;
lastHeartbeatAt: string | null;
lastWsConnectedAt: string | null;
lastWsDisconnectedAt: string | null;
lastCommandStatus: "Accepted" | "Rejected" | "Error" | "Timeout" | null;
lastCommandAt: string | null;
lastBootNotificationAt: string | null;
feePerKwh: number;
pricingMode: "fixed" | "tou";
@@ -102,7 +109,12 @@ export type ChargePointDetail = {
meterType: string | null;
registrationStatus: string;
heartbeatInterval: number | null;
transportStatus: ChargePointConnectionStatus;
lastHeartbeatAt: string | null;
lastWsConnectedAt: string | null;
lastWsDisconnectedAt: string | null;
lastCommandStatus: "Accepted" | "Rejected" | "Error" | "Timeout" | null;
lastCommandAt: string | null;
lastBootNotificationAt: string | null;
feePerKwh: number;
pricingMode: "fixed" | "tou";
@@ -134,6 +146,9 @@ export type Transaction = {
chargeAmount: number | null;
electricityFee: number | null;
serviceFee: number | null;
remoteStopStatus: "Requested" | "Accepted" | "Rejected" | "Error" | "Timeout" | null;
remoteStopRequestedAt: string | null;
remoteStopRequestId: string | null;
};
export type IdTag = {