feat(api): add create and update functionality for charge points with registration status

This commit is contained in:
2026-03-10 15:48:03 +08:00
parent 2cb89c74b3
commit 08cd00c802
5 changed files with 359 additions and 123 deletions

View File

@@ -14,7 +14,7 @@ export async function handleBootNotification(
): Promise<BootNotificationResponse> { ): Promise<BootNotificationResponse> {
const db = useDrizzle() const db = useDrizzle()
await db const [cp] = await db
.insert(chargePoint) .insert(chargePoint)
.values({ .values({
id: crypto.randomUUID(), id: crypto.randomUUID(),
@@ -27,7 +27,8 @@ export async function handleBootNotification(
imsi: payload.imsi ?? null, imsi: payload.imsi ?? null,
meterType: payload.meterType ?? null, meterType: payload.meterType ?? null,
meterSerialNumber: payload.meterSerialNumber ?? null, meterSerialNumber: payload.meterSerialNumber ?? null,
registrationStatus: 'Accepted', // New, unknown devices start as Pending — admin must manually accept them
registrationStatus: 'Pending',
heartbeatInterval: DEFAULT_HEARTBEAT_INTERVAL, heartbeatInterval: DEFAULT_HEARTBEAT_INTERVAL,
lastBootNotificationAt: new Date(), lastBootNotificationAt: new Date(),
}) })
@@ -42,20 +43,22 @@ export async function handleBootNotification(
imsi: payload.imsi ?? null, imsi: payload.imsi ?? null,
meterType: payload.meterType ?? null, meterType: payload.meterType ?? null,
meterSerialNumber: payload.meterSerialNumber ?? null, meterSerialNumber: payload.meterSerialNumber ?? null,
registrationStatus: 'Accepted', // Do NOT override registrationStatus — preserve whatever the admin set
heartbeatInterval: DEFAULT_HEARTBEAT_INTERVAL, heartbeatInterval: DEFAULT_HEARTBEAT_INTERVAL,
lastBootNotificationAt: new Date(), lastBootNotificationAt: new Date(),
updatedAt: 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 { return {
currentTime: new Date().toISOString(), currentTime: new Date().toISOString(),
interval: DEFAULT_HEARTBEAT_INTERVAL, interval: DEFAULT_HEARTBEAT_INTERVAL,
status: 'Accepted', status,
} }
} }

View File

@@ -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 */ /** GET /api/charge-points/:id — single charge point */
app.get("/:id", async (c) => { app.get("/:id", async (c) => {
const db = useDrizzle(); const db = useDrizzle();
@@ -49,29 +86,50 @@ app.get("/:id", async (c) => {
return c.json({ ...cp, connectors }); 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) => { app.patch("/:id", async (c) => {
const db = useDrizzle(); const db = useDrizzle();
const id = c.req.param("id"); 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 ( const set: {
typeof body.feePerKwh !== "number" || feePerKwh?: number;
body.feePerKwh < 0 || registrationStatus?: "Accepted" | "Pending" | "Rejected";
!Number.isInteger(body.feePerKwh) chargePointVendor?: string;
) { chargePointModel?: string;
return c.json({ error: "feePerKwh must be a non-negative integer (unit: fen/kWh)" }, 400); 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 const [updated] = await db
.update(chargePoint) .update(chargePoint)
.set({ feePerKwh: body.feePerKwh, updatedAt: new Date() }) .set(set)
.where(eq(chargePoint.id, id)) .where(eq(chargePoint.id, id))
.returning(); .returning();
if (!updated) return c.json({ error: "Not found" }, 404); 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) */ /** DELETE /api/charge-points/:id — delete a charge point (cascades to connectors, transactions, meter values) */

View File

@@ -1,21 +1,35 @@
"use client"; "use client";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Button, Chip, Input, Label, Modal, Spinner, Table, TextField } from "@heroui/react"; import { Button, Chip, Input, Label, ListBox, Modal, Select, Spinner, Table, TextField } from "@heroui/react";
import { Pencil, TrashBin } from "@gravity-ui/icons"; import { Plus, Pencil, PlugConnection, TrashBin } from "@gravity-ui/icons";
import { api, type ChargePoint } from "@/lib/api"; import { api, type ChargePoint } from "@/lib/api";
const statusColorMap: Record<string, "success" | "danger" | "warning"> = {
Available: "success", const statusLabelMap: Record<string, string> = {
Charging: "success", Available: "空闲中",
Occupied: "warning", Charging: "充电中",
Reserved: "warning", Preparing: "准备中",
Faulted: "danger", Finishing: "结束中",
Unavailable: "danger", SuspendedEV: "EV 暂停",
Preparing: "warning", SuspendedEVSE: "EVSE 暂停",
Finishing: "warning", Reserved: "已预约",
SuspendedEV: "warning", Faulted: "故障",
SuspendedEVSE: "warning", Unavailable: "不可用",
Occupied: "占用",
};
const statusDotClass: Record<string, string> = {
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<string, "success" | "warning" | "danger"> = { const registrationColorMap: Record<string, "success" | "warning" | "danger"> = {
@@ -24,11 +38,28 @@ const registrationColorMap: Record<string, "success" | "warning" | "danger"> = {
Rejected: "danger", 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() { export default function ChargePointsPage() {
const [chargePoints, setChargePoints] = useState<ChargePoint[]>([]); const [chargePoints, setChargePoints] = useState<ChargePoint[]>([]);
const [editTarget, setEditTarget] = useState<ChargePoint | null>(null); const [formOpen, setFormOpen] = useState(false);
const [feeInput, setFeeInput] = useState("0"); const [formTarget, setFormTarget] = useState<ChargePoint | null>(null);
const [saving, setSaving] = useState(false); const [formData, setFormData] = useState<FormData>(EMPTY_FORM);
const [formBusy, setFormBusy] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<ChargePoint | null>(null); const [deleteTarget, setDeleteTarget] = useState<ChargePoint | null>(null);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const hasFetched = useRef(false); const hasFetched = useRef(false);
@@ -45,22 +76,54 @@ export default function ChargePointsPage() {
} }
}, [load]); }, [load]);
const openEdit = (cp: ChargePoint) => { const openCreate = () => {
setEditTarget(cp); setFormTarget(null);
setFeeInput(String(cp.feePerKwh)); setFormData(EMPTY_FORM);
setFormOpen(true);
}; };
const handleSave = async () => { const openEdit = (cp: ChargePoint) => {
if (!editTarget) return; setFormTarget(cp);
const fee = Math.max(0, Math.round(Number(feeInput) || 0)); setFormData({
setSaving(true); 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 { try {
await api.chargePoints.update(String(editTarget.id), { feePerKwh: fee }); const fee = Math.max(0, Math.round(Number(formData.feePerKwh) || 0));
setChargePoints((prev) => if (formTarget) {
prev.map((cp) => (cp.id === editTarget.id ? { ...cp, feePerKwh: fee } : cp)), // 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 { } finally {
setSaving(false); setFormBusy(false);
} }
}; };
@@ -76,13 +139,112 @@ export default function ChargePointsPage() {
} }
}; };
const isEdit = formTarget !== null;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div> <div className="flex flex-wrap items-start justify-between gap-3">
<h1 className="text-xl font-semibold text-foreground"></h1> <div>
<p className="mt-0.5 text-sm text-muted"> {chargePoints.length} </p> <h1 className="text-xl font-semibold text-foreground"></h1>
<p className="mt-0.5 text-sm text-muted"> {chargePoints.length} </p>
</div>
<Button size="sm" variant="secondary" onPress={openCreate}>
<Plus className="size-4" />
</Button>
</div> </div>
{/* Create / Edit modal */}
<Modal isOpen={formOpen} onOpenChange={(open) => { if (!formBusy) setFormOpen(open); }}>
<Modal.Backdrop>
<Modal.Container scroll="outside">
<Modal.Dialog className="sm:max-w-md">
<Modal.CloseTrigger />
<Modal.Header>
<Modal.Heading>{isEdit ? "编辑充电桩" : "新建充电桩"}</Modal.Heading>
</Modal.Header>
<Modal.Body className="space-y-3">
<TextField fullWidth isRequired isReadOnly={isEdit}>
<Label className="text-sm font-medium"></Label>
<Input
placeholder="CP001"
value={formData.chargePointIdentifier}
onChange={(e) => setFormData((f) => ({ ...f, chargePointIdentifier: e.target.value }))}
/>
</TextField>
<div className="grid grid-cols-2 gap-3">
<TextField fullWidth>
<Label className="text-sm font-medium"></Label>
<Input
placeholder="ABB"
value={formData.chargePointVendor}
onChange={(e) => setFormData((f) => ({ ...f, chargePointVendor: e.target.value }))}
/>
</TextField>
<TextField fullWidth>
<Label className="text-sm font-medium"></Label>
<Input
placeholder="Terra AC"
value={formData.chargePointModel}
onChange={(e) => setFormData((f) => ({ ...f, chargePointModel: e.target.value }))}
/>
</TextField>
</div>
<div className="space-y-1.5">
<Label className="text-sm font-medium"></Label>
<Select
fullWidth
selectedKey={formData.registrationStatus}
onSelectionChange={(key) =>
setFormData((f) => ({ ...f, registrationStatus: String(key) as FormData["registrationStatus"] }))
}
>
<Select.Trigger>
<Select.Value />
<Select.Indicator />
</Select.Trigger>
<Select.Popover>
<ListBox>
<ListBox.Item id="Accepted">Accepted</ListBox.Item>
<ListBox.Item id="Pending">Pending</ListBox.Item>
<ListBox.Item id="Rejected">Rejected</ListBox.Item>
</ListBox>
</Select.Popover>
</Select>
</div>
<TextField fullWidth>
<Label className="text-sm font-medium">/kWh</Label>
<Input
type="number"
min="0"
step="1"
placeholder="0"
value={formData.feePerKwh}
onChange={(e) => setFormData((f) => ({ ...f, feePerKwh: e.target.value }))}
/>
</TextField>
{!isEdit && (
<p className="text-xs text-muted">
Pending Accepted
</p>
)}
</Modal.Body>
<Modal.Footer className="flex justify-end gap-2">
<Button variant="ghost" onPress={() => setFormOpen(false)}>
</Button>
<Button
isDisabled={formBusy || !formData.chargePointIdentifier.trim()}
onPress={handleSubmit}
>
{formBusy ? <Spinner size="sm" /> : isEdit ? "保存" : "创建"}
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
<Table> <Table>
<Table.ScrollContainer> <Table.ScrollContainer>
<Table.Content aria-label="充电桩列表" className="min-w-200"> <Table.Content aria-label="充电桩列表" className="min-w-200">
@@ -150,76 +312,38 @@ export default function ChargePointsPage() {
)} )}
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1.5">
{cp.connectors.length === 0 ? ( {cp.connectors.length === 0 ? (
<span className="text-muted text-sm"></span> <span className="text-muted text-sm"></span>
) : ( ) : (
cp.connectors.map((conn) => ( [...cp.connectors].sort((a, b) => a.connectorId - b.connectorId).map((conn) => (
<Chip <div
key={conn.id} key={conn.id}
color={statusColorMap[conn.status] ?? "warning"} className="flex items-center gap-1.5 rounded-lg border border-border bg-surface-secondary px-2 py-1"
size="sm"
variant="soft"
> >
#{conn.connectorId} {conn.status} <PlugConnection className="size-3 shrink-0 text-muted" />
</Chip> <span className="text-xs font-medium tabular-nums text-muted">
#{conn.connectorId}
</span>
<span className="h-3 w-px bg-border" />
<span
className={`size-1.5 shrink-0 rounded-full ${
statusDotClass[conn.status] ?? "bg-warning"
}`}
/>
<span className="text-xs text-foreground">
{statusLabelMap[conn.status] ?? conn.status}
</span>
</div>
)) ))
)} )}
</div> </div>
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Modal> <Button isIconOnly size="sm" variant="tertiary" onPress={() => openEdit(cp)}>
<Button <Pencil className="size-4" />
isIconOnly </Button>
size="sm"
variant="tertiary"
onPress={() => openEdit(cp)}
>
<Pencil className="size-4" />
</Button>
<Modal.Backdrop>
<Modal.Container scroll="outside">
<Modal.Dialog className="sm:max-w-96">
<Modal.CloseTrigger />
<Modal.Header>
<Modal.Heading></Modal.Heading>
</Modal.Header>
<Modal.Body className="space-y-3">
<p className="text-sm text-muted">
<span className="font-mono font-medium text-foreground">
{cp.chargePointIdentifier}
</span>
</p>
<TextField fullWidth>
<Label className="text-sm font-medium">/kWh</Label>
<Input
type="number"
min="0"
step="1"
placeholder="0"
value={feeInput}
onChange={(e) => setFeeInput(e.target.value)}
/>
</TextField>
<p className="text-xs text-muted">
0 ¥
{((Number(feeInput) || 0) / 100).toFixed(2)}/kWh
</p>
</Modal.Body>
<Modal.Footer className="flex justify-end gap-2">
<Button slot="close" variant="ghost">
</Button>
<Button isDisabled={saving} slot="close" onPress={handleSave}>
{saving ? <Spinner size="sm" /> : "保存"}
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
<Modal> <Modal>
<Button <Button
isIconOnly isIconOnly
@@ -273,3 +397,4 @@ export default function ChargePointsPage() {
</div> </div>
); );
} }

View File

@@ -21,7 +21,7 @@ import {
useFilter, useFilter,
} from "@heroui/react"; } from "@heroui/react";
import { parseDate } from "@internationalized/date"; import { parseDate } from "@internationalized/date";
import { Pencil, TrashBin } from "@gravity-ui/icons"; import { Pencil, Plus, TrashBin } from "@gravity-ui/icons";
import { api, type IdTag, type UserRow } from "@/lib/api"; import { api, type IdTag, type UserRow } from "@/lib/api";
const statusColorMap: Record<string, "success" | "danger" | "warning"> = { const statusColorMap: Record<string, "success" | "danger" | "warning"> = {
@@ -333,7 +333,8 @@ export default function IdTagsPage() {
<p className="mt-0.5 text-sm text-muted"> {tags.length} </p> <p className="mt-0.5 text-sm text-muted"> {tags.length} </p>
</div> </div>
<Modal> <Modal>
<Button variant="secondary" onPress={openCreate}> <Button size="sm" variant="secondary" onPress={openCreate}>
<Plus className="size-4" />
</Button> </Button>
<Modal.Backdrop> <Modal.Backdrop>
@@ -457,19 +458,52 @@ export default function IdTagsPage() {
</Modal.Backdrop> </Modal.Backdrop>
</Modal> </Modal>
{/* Delete button */} {/* Delete button */}
<Button <Modal>
isDisabled={deletingTag === tag.idTag} <Button
isIconOnly isDisabled={deletingTag === tag.idTag}
size="sm" isIconOnly
variant="danger-soft" size="sm"
onPress={() => handleDelete(tag.idTag)} variant="danger-soft"
> >
{deletingTag === tag.idTag ? ( {deletingTag === tag.idTag ? (
<Spinner size="sm" /> <Spinner size="sm" />
) : ( ) : (
<TrashBin className="size-4" /> <TrashBin className="size-4" />
)} )}
</Button> </Button>
<Modal.Backdrop>
<Modal.Container scroll="outside">
<Modal.Dialog className="sm:max-w-96">
<Modal.CloseTrigger />
<Modal.Header>
<Modal.Heading></Modal.Heading>
</Modal.Header>
<Modal.Body>
<p className="text-sm text-muted">
{" "}
<span className="font-mono font-medium text-foreground">
{tag.idTag}
</span>
</p>
</Modal.Body>
<Modal.Footer className="flex justify-end gap-2">
<Button slot="close" variant="ghost">
</Button>
<Button
slot="close"
variant="danger"
isDisabled={deletingTag === tag.idTag}
onPress={() => handleDelete(tag.idTag)}
>
{deletingTag === tag.idTag ? <Spinner size="sm" /> : "确认删除"}
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
</div> </div>
</Table.Cell> </Table.Cell>
</Table.Row> </Table.Row>

View File

@@ -99,8 +99,24 @@ export const api = {
chargePoints: { chargePoints: {
list: () => apiFetch<ChargePoint[]>("/api/charge-points"), list: () => apiFetch<ChargePoint[]>("/api/charge-points"),
get: (id: number) => apiFetch<ChargePoint>(`/api/charge-points/${id}`), get: (id: number) => apiFetch<ChargePoint>(`/api/charge-points/${id}`),
update: (id: string, data: { feePerKwh: number }) => create: (data: {
apiFetch<{ feePerKwh: number }>(`/api/charge-points/${id}`, { chargePointIdentifier: string;
chargePointVendor?: string;
chargePointModel?: string;
registrationStatus?: "Accepted" | "Pending" | "Rejected";
feePerKwh?: number;
}) =>
apiFetch<ChargePoint>("/api/charge-points", {
method: "POST",
body: JSON.stringify(data),
}),
update: (id: string, data: {
feePerKwh?: number;
registrationStatus?: "Accepted" | "Pending" | "Rejected";
chargePointVendor?: string;
chargePointModel?: string;
}) =>
apiFetch<ChargePoint>(`/api/charge-points/${id}`, {
method: "PATCH", method: "PATCH",
body: JSON.stringify(data), body: JSON.stringify(data),
}), }),