"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 }))} />
); }