diff --git a/apps/csms/src/ocpp/actions/boot-notification.ts b/apps/csms/src/ocpp/actions/boot-notification.ts index afcf54a..e4a1e63 100644 --- a/apps/csms/src/ocpp/actions/boot-notification.ts +++ b/apps/csms/src/ocpp/actions/boot-notification.ts @@ -14,7 +14,7 @@ export async function handleBootNotification( ): Promise { const db = useDrizzle() - await db + const [cp] = await db .insert(chargePoint) .values({ id: crypto.randomUUID(), @@ -27,7 +27,8 @@ export async function handleBootNotification( imsi: payload.imsi ?? null, meterType: payload.meterType ?? null, meterSerialNumber: payload.meterSerialNumber ?? null, - registrationStatus: 'Accepted', + // New, unknown devices start as Pending — admin must manually accept them + registrationStatus: 'Pending', heartbeatInterval: DEFAULT_HEARTBEAT_INTERVAL, lastBootNotificationAt: new Date(), }) @@ -42,20 +43,22 @@ export async function handleBootNotification( imsi: payload.imsi ?? null, meterType: payload.meterType ?? null, meterSerialNumber: payload.meterSerialNumber ?? null, - registrationStatus: 'Accepted', + // Do NOT override registrationStatus — preserve whatever the admin set heartbeatInterval: DEFAULT_HEARTBEAT_INTERVAL, lastBootNotificationAt: new Date(), updatedAt: new Date(), }, }) + .returning() - ctx.isRegistered = true + const status = cp.registrationStatus + ctx.isRegistered = status === 'Accepted' - console.log(`[OCPP] BootNotification accepted: ${ctx.chargePointIdentifier}`) + console.log(`[OCPP] BootNotification ${ctx.chargePointIdentifier} status=${status}`) return { currentTime: new Date().toISOString(), interval: DEFAULT_HEARTBEAT_INTERVAL, - status: 'Accepted', + status, } } diff --git a/apps/csms/src/routes/charge-points.ts b/apps/csms/src/routes/charge-points.ts index b1a12f3..4d40a46 100644 --- a/apps/csms/src/routes/charge-points.ts +++ b/apps/csms/src/routes/charge-points.ts @@ -35,6 +35,43 @@ app.get("/", async (c) => { ); }); +/** POST /api/charge-points — manually pre-register a charge point */ +app.post("/", async (c) => { + const db = useDrizzle(); + const body = await c.req.json<{ + chargePointIdentifier: string; + chargePointVendor?: string; + chargePointModel?: string; + registrationStatus?: "Accepted" | "Pending" | "Rejected"; + feePerKwh?: number; + }>(); + + if (!body.chargePointIdentifier?.trim()) { + return c.json({ error: "chargePointIdentifier is required" }, 400); + } + if (body.feePerKwh !== undefined && (!Number.isInteger(body.feePerKwh) || body.feePerKwh < 0)) { + return c.json({ error: "feePerKwh must be a non-negative integer" }, 400); + } + + const [created] = await db + .insert(chargePoint) + .values({ + id: crypto.randomUUID(), + chargePointIdentifier: body.chargePointIdentifier.trim(), + chargePointVendor: body.chargePointVendor?.trim() || "Unknown", + chargePointModel: body.chargePointModel?.trim() || "Unknown", + registrationStatus: body.registrationStatus ?? "Pending", + feePerKwh: body.feePerKwh ?? 0, + }) + .returning() + .catch((err: Error) => { + if (err.message.includes("unique")) throw Object.assign(err, { status: 409 }); + throw err; + }); + + return c.json({ ...created, connectors: [] }, 201); +}); + /** GET /api/charge-points/:id — single charge point */ app.get("/:id", async (c) => { const db = useDrizzle(); @@ -49,29 +86,50 @@ app.get("/:id", async (c) => { return c.json({ ...cp, connectors }); }); -/** PATCH /api/charge-points/:id — update feePerKwh */ +/** PATCH /api/charge-points/:id — update charge point fields */ app.patch("/:id", async (c) => { const db = useDrizzle(); const id = c.req.param("id"); - const body = await c.req.json<{ feePerKwh?: number }>(); + const body = await c.req.json<{ + feePerKwh?: number; + registrationStatus?: string; + chargePointVendor?: string; + chargePointModel?: string; + }>(); - if ( - typeof body.feePerKwh !== "number" || - body.feePerKwh < 0 || - !Number.isInteger(body.feePerKwh) - ) { - return c.json({ error: "feePerKwh must be a non-negative integer (unit: fen/kWh)" }, 400); + const set: { + feePerKwh?: number; + registrationStatus?: "Accepted" | "Pending" | "Rejected"; + chargePointVendor?: string; + chargePointModel?: string; + updatedAt: Date; + } = { updatedAt: new Date() }; + + if (body.feePerKwh !== undefined) { + if (!Number.isInteger(body.feePerKwh) || body.feePerKwh < 0) { + return c.json({ error: "feePerKwh must be a non-negative integer (unit: fen/kWh)" }, 400); + } + set.feePerKwh = body.feePerKwh; } + if (body.registrationStatus !== undefined) { + if (!["Accepted", "Pending", "Rejected"].includes(body.registrationStatus)) { + return c.json({ error: "invalid registrationStatus" }, 400); + } + set.registrationStatus = body.registrationStatus as "Accepted" | "Pending" | "Rejected"; + } + if (body.chargePointVendor !== undefined) set.chargePointVendor = body.chargePointVendor.trim() || "Unknown"; + if (body.chargePointModel !== undefined) set.chargePointModel = body.chargePointModel.trim() || "Unknown"; const [updated] = await db .update(chargePoint) - .set({ feePerKwh: body.feePerKwh, updatedAt: new Date() }) + .set(set) .where(eq(chargePoint.id, id)) .returning(); if (!updated) return c.json({ error: "Not found" }, 404); - return c.json({ feePerKwh: updated.feePerKwh }); + const connectors = await db.select().from(connector).where(eq(connector.chargePointId, id)); + return c.json({ ...updated, connectors }); }); /** DELETE /api/charge-points/:id — delete a charge point (cascades to connectors, transactions, meter values) */ diff --git a/apps/web/app/dashboard/charge-points/page.tsx b/apps/web/app/dashboard/charge-points/page.tsx index 4b1a6ed..6d5fb8c 100644 --- a/apps/web/app/dashboard/charge-points/page.tsx +++ b/apps/web/app/dashboard/charge-points/page.tsx @@ -1,21 +1,35 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; -import { Button, Chip, Input, Label, Modal, Spinner, Table, TextField } from "@heroui/react"; -import { Pencil, TrashBin } from "@gravity-ui/icons"; +import { Button, Chip, Input, Label, ListBox, Modal, Select, Spinner, Table, TextField } from "@heroui/react"; +import { Plus, Pencil, PlugConnection, TrashBin } from "@gravity-ui/icons"; import { api, type ChargePoint } from "@/lib/api"; -const statusColorMap: Record = { - Available: "success", - Charging: "success", - Occupied: "warning", - Reserved: "warning", - Faulted: "danger", - Unavailable: "danger", - Preparing: "warning", - Finishing: "warning", - SuspendedEV: "warning", - SuspendedEVSE: "warning", + +const statusLabelMap: Record = { + Available: "空闲中", + Charging: "充电中", + Preparing: "准备中", + Finishing: "结束中", + SuspendedEV: "EV 暂停", + SuspendedEVSE: "EVSE 暂停", + Reserved: "已预约", + Faulted: "故障", + Unavailable: "不可用", + Occupied: "占用", +}; + +const statusDotClass: Record = { + Available: "bg-success", + Charging: "bg-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 = { @@ -24,11 +38,28 @@ const registrationColorMap: Record = { Rejected: "danger", }; +type FormData = { + chargePointIdentifier: string; + chargePointVendor: string; + chargePointModel: string; + registrationStatus: "Accepted" | "Pending" | "Rejected"; + feePerKwh: string; +}; + +const EMPTY_FORM: FormData = { + chargePointIdentifier: "", + chargePointVendor: "", + chargePointModel: "", + registrationStatus: "Pending", + feePerKwh: "0", +}; + export default function ChargePointsPage() { const [chargePoints, setChargePoints] = useState([]); - const [editTarget, setEditTarget] = useState(null); - const [feeInput, setFeeInput] = useState("0"); - const [saving, setSaving] = useState(false); + const [formOpen, setFormOpen] = useState(false); + const [formTarget, setFormTarget] = useState(null); + const [formData, setFormData] = useState(EMPTY_FORM); + const [formBusy, setFormBusy] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); const [deleting, setDeleting] = useState(false); const hasFetched = useRef(false); @@ -45,22 +76,54 @@ export default function ChargePointsPage() { } }, [load]); - const openEdit = (cp: ChargePoint) => { - setEditTarget(cp); - setFeeInput(String(cp.feePerKwh)); + const openCreate = () => { + setFormTarget(null); + setFormData(EMPTY_FORM); + setFormOpen(true); }; - const handleSave = async () => { - if (!editTarget) return; - const fee = Math.max(0, Math.round(Number(feeInput) || 0)); - setSaving(true); + const openEdit = (cp: ChargePoint) => { + setFormTarget(cp); + setFormData({ + chargePointIdentifier: cp.chargePointIdentifier, + chargePointVendor: cp.chargePointVendor ?? "", + chargePointModel: cp.chargePointModel ?? "", + registrationStatus: cp.registrationStatus as FormData["registrationStatus"], + feePerKwh: String(cp.feePerKwh), + }); + setFormOpen(true); + }; + + const handleSubmit = async () => { + if (!formData.chargePointIdentifier.trim()) return; + setFormBusy(true); try { - await api.chargePoints.update(String(editTarget.id), { feePerKwh: fee }); - setChargePoints((prev) => - prev.map((cp) => (cp.id === editTarget.id ? { ...cp, feePerKwh: fee } : cp)), - ); + const fee = Math.max(0, Math.round(Number(formData.feePerKwh) || 0)); + if (formTarget) { + // Edit + const updated = await api.chargePoints.update(String(formTarget.id), { + chargePointVendor: formData.chargePointVendor, + chargePointModel: formData.chargePointModel, + registrationStatus: formData.registrationStatus, + feePerKwh: fee, + }); + setChargePoints((prev) => + prev.map((cp) => (cp.id === formTarget.id ? { ...updated, connectors: cp.connectors } : cp)), + ); + } else { + // Create + const created = await api.chargePoints.create({ + chargePointIdentifier: formData.chargePointIdentifier.trim(), + chargePointVendor: formData.chargePointVendor.trim() || undefined, + chargePointModel: formData.chargePointModel.trim() || undefined, + registrationStatus: formData.registrationStatus, + feePerKwh: fee, + }); + setChargePoints((prev) => [created, ...prev]); + } + setFormOpen(false); } finally { - setSaving(false); + setFormBusy(false); } }; @@ -76,13 +139,112 @@ export default function ChargePointsPage() { } }; + const isEdit = formTarget !== null; + return (
-
-

充电桩管理

-

共 {chargePoints.length} 台设备

+
+
+

充电桩管理

+

共 {chargePoints.length} 台设备

+
+
+ {/* Create / Edit modal */} + { if (!formBusy) setFormOpen(open); }}> + + + + + + {isEdit ? "编辑充电桩" : "新建充电桩"} + + + + + setFormData((f) => ({ ...f, chargePointIdentifier: e.target.value }))} + /> + +
+ + + setFormData((f) => ({ ...f, chargePointVendor: e.target.value }))} + /> + + + + setFormData((f) => ({ ...f, chargePointModel: e.target.value }))} + /> + +
+
+ + +
+ + + setFormData((f) => ({ ...f, feePerKwh: e.target.value }))} + /> + + {!isEdit && ( +

+ 手动创建的充电桩默认注册状态为 Pending,需手动改为 Accepted 后才可正常充电。 +

+ )} +
+ + + + +
+
+
+
+ @@ -150,76 +312,38 @@ export default function ChargePointsPage() { )} -
+
{cp.connectors.length === 0 ? ( ) : ( - cp.connectors.map((conn) => ( - a.connectorId - b.connectorId).map((conn) => ( +
- #{conn.connectorId} {conn.status} - + + + #{conn.connectorId} + + + + + {statusLabelMap[conn.status] ?? conn.status} + +
)) )}
- - - - - - - - 配置电价 - - -

- 充电桩: - - {cp.chargePointIdentifier} - -

- - - setFeeInput(e.target.value)} - /> - -

- 设为 0 则免费充电。当前:¥ - {((Number(feeInput) || 0) / 100).toFixed(2)}/kWh -

-
- - - - -
-
-
-
+
- @@ -457,19 +458,52 @@ export default function IdTagsPage() { {/* Delete button */} - + + + + + + + + 确认删除储值卡 + + +

+ 将永久删除储值卡{" "} + + {tag.idTag} + + ,此操作不可恢复。 +

+
+ + + + +
+
+
+
diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index 52582a5..ad15ecf 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -99,8 +99,24 @@ export const api = { chargePoints: { list: () => apiFetch("/api/charge-points"), get: (id: number) => apiFetch(`/api/charge-points/${id}`), - update: (id: string, data: { feePerKwh: number }) => - apiFetch<{ feePerKwh: number }>(`/api/charge-points/${id}`, { + create: (data: { + chargePointIdentifier: string; + chargePointVendor?: string; + chargePointModel?: string; + registrationStatus?: "Accepted" | "Pending" | "Rejected"; + feePerKwh?: number; + }) => + apiFetch("/api/charge-points", { + method: "POST", + body: JSON.stringify(data), + }), + update: (id: string, data: { + feePerKwh?: number; + registrationStatus?: "Accepted" | "Pending" | "Rejected"; + chargePointVendor?: string; + chargePointModel?: string; + }) => + apiFetch(`/api/charge-points/${id}`, { method: "PATCH", body: JSON.stringify(data), }),