diff --git a/apps/csms/src/routes/transactions.ts b/apps/csms/src/routes/transactions.ts index fde8c46..a07d905 100644 --- a/apps/csms/src/routes/transactions.ts +++ b/apps/csms/src/routes/transactions.ts @@ -9,6 +9,80 @@ import type { HonoEnv } from "@/types/hono.ts"; const app = new Hono(); +/** + * POST /api/transactions/remote-start + * Send RemoteStartTransaction to a charge point. + * Non-admin users can only use their own id-tags. + */ +app.post("/remote-start", async (c) => { + const currentUser = c.get("user"); + if (!currentUser) return c.json({ error: "Unauthorized" }, 401); + + const db = useDrizzle(); + const body = await c.req.json<{ + chargePointIdentifier: string; + connectorId: number; + idTag: string; + }>().catch(() => null); + + if ( + !body || + !body.chargePointIdentifier?.trim() || + !Number.isInteger(body.connectorId) || + body.connectorId < 1 || + !body.idTag?.trim() + ) { + return c.json( + { error: "chargePointIdentifier, connectorId (>=1), and idTag are required" }, + 400, + ); + } + + // Non-admin: verify idTag belongs to current user and is Accepted + if (currentUser.role !== "admin") { + const [tag] = await db + .select({ status: idTag.status }) + .from(idTag) + .where(and(eq(idTag.idTag, body.idTag.trim()), eq(idTag.userId, currentUser.id))) + .limit(1); + if (!tag) return c.json({ error: "idTag not found or not authorized" }, 403); + if (tag.status !== "Accepted") return c.json({ error: "idTag is not accepted" }, 400); + } + + // Verify charge point exists and is Accepted + const [cp] = await db + .select({ id: chargePoint.id, registrationStatus: chargePoint.registrationStatus }) + .from(chargePoint) + .where(eq(chargePoint.chargePointIdentifier, body.chargePointIdentifier.trim())) + .limit(1); + + if (!cp) return c.json({ error: "ChargePoint not found" }, 404); + if (cp.registrationStatus !== "Accepted") { + return c.json({ error: "ChargePoint is not accepted" }, 400); + } + + // Require the charge point to be online + const ws = ocppConnections.get(body.chargePointIdentifier.trim()); + if (!ws) return c.json({ error: "ChargePoint is offline" }, 503); + + const uniqueId = crypto.randomUUID(); + ws.send( + JSON.stringify([ + OCPP_MESSAGE_TYPE.CALL, + uniqueId, + "RemoteStartTransaction", + { connectorId: body.connectorId, idTag: body.idTag.trim() }, + ]), + ); + + console.log( + `[OCPP] RemoteStartTransaction cp=${body.chargePointIdentifier} ` + + `connector=${body.connectorId} idTag=${body.idTag} user=${currentUser.id}`, + ); + + return c.json({ success: true }); +}); + /** GET /api/transactions?page=1&limit=20&status=active|completed&chargePointId=... */ app.get("/", async (c) => { const page = Math.max(1, Number(c.req.query("page") ?? 1)); diff --git a/apps/web/app/dashboard/charge-points/page.tsx b/apps/web/app/dashboard/charge-points/page.tsx index 687e8c8..3408408 100644 --- a/apps/web/app/dashboard/charge-points/page.tsx +++ b/apps/web/app/dashboard/charge-points/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useQuery } from "@tanstack/react-query"; import { Button, @@ -14,7 +14,15 @@ import { Table, TextField, } from "@heroui/react"; -import { Plus, Pencil, PlugConnection, TrashBin, ArrowRotateRight } from "@gravity-ui/icons"; +import { + Plus, + Pencil, + PlugConnection, + TrashBin, + ArrowRotateRight, + QrCode, +} from "@gravity-ui/icons"; +import { QRCodeSVG } from "qrcode.react"; import Link from "next/link"; import { ScrollFade } from "@/components/scroll-fade"; import { api, type ChargePoint } from "@/lib/api"; @@ -108,6 +116,7 @@ export default function ChargePointsPage() { const [formBusy, setFormBusy] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); const [deleting, setDeleting] = useState(false); + const [qrTarget, setQrTarget] = useState(null); const { data: chargePoints = [], refetch: refetchList, @@ -183,6 +192,11 @@ export default function ChargePointsPage() { const { data: sessionData } = useSession(); const isAdmin = sessionData?.user?.role === "admin"; + const [qrOrigin, setQrOrigin] = useState(""); + useEffect(() => { + setQrOrigin(window.location.origin); + }, []); + return (
@@ -319,6 +333,65 @@ export default function ChargePointsPage() { )} + {/* QR Code Modal */} + {isAdmin && ( + { + if (!open) setQrTarget(null); + }} + > + + + + + + {qrTarget?.chargePointIdentifier} — 充电二维码 + + +

+ 将以下二维码张贴在对应充电口上,用户扫码后可直接选卡启动充电。 +

+ {qrTarget && + qrTarget.connectors.filter((c) => c.connectorId > 0).length === 0 && ( +

+ 该充电桩暂无接口信息,请等待设备上线后再尝试。 +

+ )} +
+ {qrTarget?.connectors + .filter((c) => c.connectorId > 0) + .sort((a, b) => a.connectorId - b.connectorId) + .map((conn) => { + const url = `${qrOrigin}/dashboard/charge?cpId=${qrTarget.id}&connector=${conn.connectorId}`; + return ( +
+

+ 接口 #{conn.connectorId} +

+ +

+ {url} +

+
+ ); + })} +
+
+ + + +
+
+
+
+ )} + @@ -435,6 +508,15 @@ export default function ChargePointsPage() { > + + {!isLast && ( +
+ +
+ )} + + ); + })} + + ); +} + +// ── QR Scanner ─────────────────────────────────────────────────────────────── + +type ScannerProps = { + onResult: (raw: string) => void; + onClose: () => void; +}; + +function QrScanner({ onResult, onClose }: ScannerProps) { + const videoRef = useRef(null); + const streamRef = useRef(null); + const scanningRef = useRef(true); + const mountedRef = useRef(true); + const [error, setError] = useState(null); + + useEffect(() => { + mountedRef.current = true; + scanningRef.current = true; + let detector: any = null; + + async function start() { + let stream: MediaStream; + try { + stream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: "environment" }, + }); + } catch (err: any) { + if (mountedRef.current) setError("无法访问摄像头:" + (err?.message ?? "未知错误")); + return; + } + + if (!mountedRef.current) { + stream.getTracks().forEach((t) => t.stop()); + return; + } + + streamRef.current = stream; + + if (videoRef.current) { + videoRef.current.srcObject = stream; + try { + await videoRef.current.play(); + } catch (err: any) { + // AbortError fires when the element is removed mid-play (e.g. Modal animation). + // It is not a real error — just bail out silently. + if (err?.name === "AbortError") return; + if (mountedRef.current) setError("无法播放摄像头画面:" + (err?.message ?? "未知错误")); + return; + } + } + + if (!mountedRef.current) return; + + if (!("BarcodeDetector" in window)) { + if (mountedRef.current) setError("当前浏览器不支持实时扫描,请升级至最新版本"); + return; + } + + detector = new (window as any).BarcodeDetector({ formats: ["qr_code"] }); + + const scan = async () => { + if (!scanningRef.current || !videoRef.current) return; + try { + const codes: Array<{ rawValue: string }> = await detector.detect(videoRef.current); + if (codes.length > 0) { + onResult(codes[0].rawValue); + return; + } + } catch {} + requestAnimationFrame(scan); + }; + requestAnimationFrame(scan); + } + + start(); + + return () => { + mountedRef.current = false; + scanningRef.current = false; + streamRef.current?.getTracks().forEach((t) => t.stop()); + streamRef.current = null; + }; + }, [onResult]); + + return ( +
+ {error ? ( +
+

{error}

+
+ ) : ( + <> + {/* eslint-disable-next-line jsx-a11y/media-has-caption */} +