feat(api): add create and update functionality for charge points with registration status
This commit is contained in:
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
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);
|
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) */
|
||||||
|
|||||||
@@ -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));
|
||||||
|
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) =>
|
setChargePoints((prev) =>
|
||||||
prev.map((cp) => (cp.id === editTarget.id ? { ...cp, feePerKwh: fee } : cp)),
|
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,12 +139,111 @@ export default function ChargePointsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isEdit = formTarget !== null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-semibold text-foreground">充电桩管理</h1>
|
<h1 className="text-xl font-semibold text-foreground">充电桩管理</h1>
|
||||||
<p className="mt-0.5 text-sm text-muted">共 {chargePoints.length} 台设备</p>
|
<p className="mt-0.5 text-sm text-muted">共 {chargePoints.length} 台设备</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Button size="sm" variant="secondary" onPress={openCreate}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
新建充电桩
|
||||||
|
</Button>
|
||||||
|
</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>
|
||||||
@@ -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
|
|
||||||
isIconOnly
|
|
||||||
size="sm"
|
|
||||||
variant="tertiary"
|
|
||||||
onPress={() => openEdit(cp)}
|
|
||||||
>
|
|
||||||
<Pencil className="size-4" />
|
<Pencil 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 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,12 +458,12 @@ export default function IdTagsPage() {
|
|||||||
</Modal.Backdrop>
|
</Modal.Backdrop>
|
||||||
</Modal>
|
</Modal>
|
||||||
{/* Delete button */}
|
{/* Delete button */}
|
||||||
|
<Modal>
|
||||||
<Button
|
<Button
|
||||||
isDisabled={deletingTag === tag.idTag}
|
isDisabled={deletingTag === tag.idTag}
|
||||||
isIconOnly
|
isIconOnly
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="danger-soft"
|
variant="danger-soft"
|
||||||
onPress={() => handleDelete(tag.idTag)}
|
|
||||||
>
|
>
|
||||||
{deletingTag === tag.idTag ? (
|
{deletingTag === tag.idTag ? (
|
||||||
<Spinner size="sm" />
|
<Spinner size="sm" />
|
||||||
@@ -470,6 +471,39 @@ export default function IdTagsPage() {
|
|||||||
<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>
|
||||||
|
|||||||
@@ -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),
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user