"use client"; import { use, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import Link from "next/link"; import { Button, Chip, Input, Label, ListBox, Modal, Pagination, Select, Spinner, Table, TextField, Tooltip, } from "@heroui/react"; import { ArrowLeft, Pencil, ArrowRotateRight, Key, Copy, Check } from "@gravity-ui/icons"; import { api, type ChargePointPasswordReset } from "@/lib/api"; import { useSession } from "@/lib/auth-client"; import dayjs from "@/lib/dayjs"; import InfoSection from "@/components/info-section"; import { Plug } from "lucide-react"; // ── 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 min = dayjs(stop).diff(dayjs(start), "minute"); 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 { return dayjs(iso).fromNow(); } // ── Edit form type ───────────────────────────────────────────────────────── type EditForm = { deviceName: string; chargePointVendor: string; chargePointModel: string; registrationStatus: "Accepted" | "Pending" | "Rejected"; pricingMode: "fixed" | "tou"; feePerKwh: string; }; // ── Component ────────────────────────────────────────────────────────────── export default function ChargePointDetailPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params); // transactions const [txPage, setTxPage] = useState(1); // edit modal const [editOpen, setEditOpen] = useState(false); const [editBusy, setEditBusy] = useState(false); const [editForm, setEditForm] = useState({ deviceName: "", chargePointVendor: "", chargePointModel: "", registrationStatus: "Pending", pricingMode: "fixed", feePerKwh: "0", }); // reset password const [resetBusy, setResetBusy] = useState(false); const [resetResult, setResetResult] = useState(null); const [resetCopied, setResetCopied] = useState(false); const handleResetPassword = async () => { if (!cp) return; setResetBusy(true); try { const result = await api.chargePoints.resetPassword(cp.id); setResetResult(result); } finally { setResetBusy(false); } }; const handleCopyResetPassword = (text: string) => { navigator.clipboard.writeText(text).then(() => { setResetCopied(true); setTimeout(() => setResetCopied(false), 2000); }); }; const { isFetching: refreshing, ...cpQuery } = useQuery({ queryKey: ["chargePoint", id], queryFn: () => api.chargePoints.get(id), refetchInterval: 3_000, retry: false, }); const txQuery = useQuery({ queryKey: ["chargePointTransactions", id, txPage], queryFn: () => api.transactions.list({ page: txPage, limit: TX_LIMIT, chargePointId: id }), refetchInterval: 3_000, }); const cp = cpQuery.data; const txData = txQuery.data; const openEdit = () => { if (!cp) return; setEditForm({ deviceName: cp.deviceName ?? "", chargePointVendor: cp.chargePointVendor ?? "", chargePointModel: cp.chargePointModel ?? "", registrationStatus: cp.registrationStatus as EditForm["registrationStatus"], pricingMode: cp.pricingMode, 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)); await api.chargePoints.update(cp.id, { chargePointVendor: editForm.chargePointVendor, chargePointModel: editForm.chargePointModel, registrationStatus: editForm.registrationStatus, pricingMode: editForm.pricingMode, feePerKwh: editForm.pricingMode === "fixed" ? fee : 0, deviceName: editForm.deviceName.trim() || null, }); await cpQuery.refetch(); setEditOpen(false); } finally { setEditBusy(false); } }; // Online if last heartbeat within 3× interval const isOnline = cp?.lastHeartbeatAt != null && dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < (cp.heartbeatInterval ?? 60) * 3; const { data: sessionData } = useSession(); const isAdmin = sessionData?.user?.role === "admin"; // ── Render: loading / not found ────────────────────────────────────────── if (cpQuery.isPending) { return (
); } if (!cp) { return (
充电桩管理

充电桩不存在或已被删除。

); } const sortedConnectors = [...cp.connectors].sort((a, b) => a.connectorId - b.connectorId); // ── Render ─────────────────────────────────────────────────────────────── return (
{/* Breadcrumb back */} 充电桩管理 {/* Header */}

{cp.deviceName ?? {cp.chargePointIdentifier}}

{isAdmin && cp.deviceName && ( {cp.chargePointIdentifier} )} {cp.registrationStatus}
{isOnline ? "在线" : "离线"}
{(cp.chargePointVendor || cp.chargePointModel) && (

{[cp.chargePointVendor, cp.chargePointModel].filter(Boolean).join(" · ")}

)}
{isAdmin && ( <> 重置 OCPP 连接密码 )}
{/* Info grid */} {cp.chargePointStatus && (
{cp.chargePointStatus === "Available" ? "正常" : (statusLabelMap[cp.chargePointStatus] ?? cp.chargePointStatus)} {cp.chargePointErrorCode && cp.chargePointErrorCode !== "NoError" && ( <> · {cp.chargePointErrorCode} )} 整桩状态
)} {/* Info grid */}
{/* Device info — admin only */} {isAdmin && (
{[ { 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 — admin only */} {isAdmin && (
注册状态
{cp.registrationStatus}
电价
{cp.pricingMode === "tou" ? ( 峰谷电价 ) : 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)} ) : ( )}
注册时间
{dayjs(cp.createdAt).format("YYYY/M/D")}
)} {/* Fee info — user only */} {!isAdmin && (
单位电价
{cp.pricingMode === "tou" ? ( 峰谷电价 ) : cp.feePerKwh > 0 ? ( ¥{(cp.feePerKwh / 100).toFixed(2)} /kWh ) : ( 免费 )}
充电桥状态
{isOnline ? "在线" : "离线"}
)}
{/* 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}

} */}

更新于{" "} {dayjs(conn.lastStatusAt).format("MM/DD HH:mm")}

))}
)} {/* Transactions */}

充电记录

{txData && 共 {txData.total} 条}
ID 接口 充电卡 开始时间 时长 用电量 费用 停止原因 (
{txQuery.isPending ? "加载中…" : "暂无充电记录"}
)} > {(txData?.data ?? []).map((tx) => ( #{tx.id} {tx.connectorNumber != null ? ( `#${tx.connectorNumber}` ) : ( )} {tx.idTag} {dayjs(tx.startTimestamp).format("MM/DD HH:mm")} {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))} > 下一页
)}
{/* Reset password result modal */} {isAdmin && ( { if (!open) { setResetResult(null); setResetCopied(false); } }} > 密码已重置 — 请保存新密码

⚠️ 此密码仅显示一次,关闭后无法再次查看。旧密码已立即失效,请更新固件配置。

新 OCPP Basic Auth 密码

{resetResult?.plainPassword} {resetCopied ? "已复制" : "复制密码"}
)} {/* Edit modal */} {isAdmin && ( { if (!editBusy) setEditOpen(open); }} > 编辑充电桩 setEditForm((f) => ({ ...f, deviceName: e.target.value })) } />
setEditForm((f) => ({ ...f, chargePointVendor: e.target.value })) } /> setEditForm((f) => ({ ...f, chargePointModel: e.target.value })) } />
{editForm.pricingMode === "fixed" && ( setEditForm((f) => ({ ...f, feePerKwh: e.target.value }))} /> )}
)}
); }