diff --git a/apps/web/app/dashboard/charge-points/page.tsx b/apps/web/app/dashboard/charge-points/page.tsx index 61b7e72..4ab34f6 100644 --- a/apps/web/app/dashboard/charge-points/page.tsx +++ b/apps/web/app/dashboard/charge-points/page.tsx @@ -32,6 +32,7 @@ import { ScrollFade } from "@/components/scroll-fade"; import { api, type ChargePoint, type ChargePointCreated } from "@/lib/api"; import { useSession } from "@/lib/auth-client"; import dayjs from "@/lib/dayjs"; +import { Download } from "lucide-react"; const statusLabelMap: Record = { Available: "空闲中", @@ -128,6 +129,7 @@ export default function ChargePointsPage() { const [qrTarget, setQrTarget] = useState(null); const [createdCp, setCreatedCp] = useState(null); const [copied, setCopied] = useState(false); + const [downloadingQrKey, setDownloadingQrKey] = useState(null); const { data: chargePoints = [], refetch: refetchList, @@ -216,6 +218,66 @@ export default function ChargePointsPage() { }); }; + const handleDownloadConnectorQr = async ( + chargePointId: string | number, + connectorId: number, + chargePointIdentifier: string, + ) => { + const svgId = `connector-qr-${chargePointId}-${connectorId}`; + const key = `${chargePointId}-${connectorId}`; + const svg = document.getElementById(svgId) as SVGSVGElement | null; + if (!svg) return; + + setDownloadingQrKey(key); + try { + const serializer = new XMLSerializer(); + const svgText = serializer.serializeToString(svg); + const svgBlob = new Blob([svgText], { type: "image/svg+xml;charset=utf-8" }); + const svgUrl = URL.createObjectURL(svgBlob); + + const image = await new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error("二维码导出失败")); + img.src = svgUrl; + }); + + const canvas = document.createElement("canvas"); + canvas.width = 1024; + canvas.height = 1024; + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("二维码导出失败"); + + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(image, 0, 0, canvas.width, canvas.height); + + const pngBlob = await new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob) resolve(blob); + else reject(new Error("二维码导出失败")); + }, "image/png"); + }); + + const fileUrl = URL.createObjectURL(pngBlob); + const link = document.createElement("a"); + const safeIdentifier = String(chargePointIdentifier).replace(/[^a-zA-Z0-9_-]/g, ""); + const safeConnectorId = String(connectorId).replace(/[^a-zA-Z0-9_-]/g, ""); + + link.href = fileUrl; + link.download = `${safeIdentifier}-connector-${safeConnectorId}.png`; + link.rel = "noopener"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + URL.revokeObjectURL(svgUrl); + URL.revokeObjectURL(fileUrl); + } finally { + setDownloadingQrKey(null); + } + }; + const { data: sessionData } = useSession(); const isAdmin = sessionData?.user?.role === "admin"; @@ -429,15 +491,50 @@ export default function ChargePointsPage() { .sort((a, b) => a.connectorId - b.connectorId) .map((conn) => { const url = `${qrOrigin}/dashboard/charge?cpId=${qrTarget.id}&connector=${conn.connectorId}`; + const downloadKey = `${qrTarget.id}-${conn.connectorId}`; return (
-

- 接口 #{conn.connectorId} -

- +
+

+ 接口 #{conn.connectorId} +

+ + 下载二维码 + + + + +
+

{url}

@@ -583,174 +680,184 @@ export default function ChargePointsPage() { return ( - - - -
- + + + +
+ +
+ + {cp.deviceName ?? cp.chargePointIdentifier} + + {isAdmin && cp.deviceName && ( + + {cp.chargePointIdentifier} + + )} +
+
+
+ + {online + ? "在线" + : commandChannelUnavailable + ? "通道异常" + : cp.lastHeartbeatAt + ? "离线" + : "从未连接"} + +
+
+ {isAdmin && ( + + {cp.chargePointVendor || cp.chargePointModel ? (
- - {cp.deviceName ?? cp.chargePointIdentifier} - - {isAdmin && cp.deviceName && ( - - {cp.chargePointIdentifier} + {cp.chargePointVendor && ( + + {cp.chargePointVendor} )} + {cp.chargePointModel && ( + {cp.chargePointModel} + )}
-
-
- - {online ? "在线" : commandChannelUnavailable ? "通道异常" : cp.lastHeartbeatAt ? "离线" : "从未连接"} - -
-
- {isAdmin && ( + ) : ( + + )} + + )} + {isAdmin && ( + + + {cp.registrationStatus} + + + )} - {cp.chargePointVendor || cp.chargePointModel ? ( -
- {cp.chargePointVendor && ( - - {cp.chargePointVendor} - - )} - {cp.chargePointModel && ( - {cp.chargePointModel} - )} + {cp.pricingMode === "tou" ? ( + 峰谷电价 + ) : cp.feePerKwh > 0 ? ( + + {cp.feePerKwh} 分 + + (¥{(cp.feePerKwh / 100).toFixed(2)}/kWh) + + + ) : ( + 免费 + )} + + + {cp.lastHeartbeatAt ? ( + dayjs(cp.lastHeartbeatAt).format("YYYY/M/D HH:mm:ss") + ) : ( + + )} + + + {cp.chargePointStatus ? ( +
+ + + {cp.chargePointStatus === "Available" + ? "正常" + : (statusLabelMap[cp.chargePointStatus] ?? cp.chargePointStatus)} +
) : ( )}
- )} - {isAdmin && ( - - {cp.registrationStatus} - + - )} - - {cp.pricingMode === "tou" ? ( - 峰谷电价 - ) : cp.feePerKwh > 0 ? ( - - {cp.feePerKwh} 分 - - (¥{(cp.feePerKwh / 100).toFixed(2)}/kWh) - - - ) : ( - 免费 - )} - - - {cp.lastHeartbeatAt ? ( - dayjs(cp.lastHeartbeatAt).format("YYYY/M/D HH:mm:ss") - ) : ( - - )} - - - {cp.chargePointStatus ? ( -
- - - {cp.chargePointStatus === "Available" - ? "正常" - : (statusLabelMap[cp.chargePointStatus] ?? cp.chargePointStatus)} - -
- ) : ( - - )} -
- - - - {isAdmin && ( - -
- - - + {isAdmin && ( + +
- - - - - - 确认删除充电桩 - - -

- 将删除充电桩{" "} - - {cp.deviceName ?? cp.chargePointIdentifier} - - {cp.deviceName && ( - - ({cp.chargePointIdentifier}) + + + + + + + + + 确认删除充电桩 + + +

+ 将删除充电桩{" "} + + {cp.deviceName ?? cp.chargePointIdentifier} - )} - 及其所有连接器和充电记录,此操作不可恢复。 -

-
- - - - -
-
-
- -
-
- )} + {cp.deviceName && ( + + ({cp.chargePointIdentifier}) + + )} + 及其所有连接器和充电记录,此操作不可恢复。 +

+ + + + + + + + +
+
+
+ )} ); })} diff --git a/hardware/pcb/.history b/hardware/pcb/.history index 468ec3b..a87aae3 160000 --- a/hardware/pcb/.history +++ b/hardware/pcb/.history @@ -1 +1 @@ -Subproject commit 468ec3b8092f7aced715b5ab17dde0fa10735f9d +Subproject commit a87aae35711f68fd18d36c5511606d5fed3008e1