Files
helios-evcs/apps/web/app/dashboard/charge-points/page.tsx

401 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
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 statusLabelMap: Record<string, string> = {
Available: "空闲中",
Charging: "充电中",
Preparing: "准备中",
Finishing: "结束中",
SuspendedEV: "EV 暂停",
SuspendedEVSE: "EVSE 暂停",
Reserved: "已预约",
Faulted: "故障",
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"> = {
Accepted: "success",
Pending: "warning",
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<ChargePoint[]>([]);
const [formOpen, setFormOpen] = useState(false);
const [formTarget, setFormTarget] = useState<ChargePoint | null>(null);
const [formData, setFormData] = useState<FormData>(EMPTY_FORM);
const [formBusy, setFormBusy] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<ChargePoint | null>(null);
const [deleting, setDeleting] = useState(false);
const hasFetched = useRef(false);
const load = useCallback(async () => {
const data = await api.chargePoints.list().catch(() => []);
setChargePoints(data);
}, []);
useEffect(() => {
if (!hasFetched.current) {
hasFetched.current = true;
load();
}
}, [load]);
const openCreate = () => {
setFormTarget(null);
setFormData(EMPTY_FORM);
setFormOpen(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 {
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 {
setFormBusy(false);
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
setDeleting(true);
try {
await api.chargePoints.delete(String(deleteTarget.id));
setChargePoints((prev) => prev.filter((cp) => cp.id !== deleteTarget.id));
setDeleteTarget(null);
} finally {
setDeleting(false);
}
};
const isEdit = formTarget !== null;
return (
<div className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<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>
{/* 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.ScrollContainer>
<Table.Content aria-label="充电桩列表" className="min-w-200">
<Table.Header>
<Table.Column isRowHeader></Table.Column>
<Table.Column> / </Table.Column>
<Table.Column></Table.Column>
<Table.Column>/kWh</Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
<Table.Column>{""}</Table.Column>
</Table.Header>
<Table.Body>
{chargePoints.length === 0 && (
<Table.Row id="empty">
<Table.Cell>
<span className="text-muted text-sm"></span>
</Table.Cell>
<Table.Cell>{""}</Table.Cell>
<Table.Cell>{""}</Table.Cell>
<Table.Cell>{""}</Table.Cell>
<Table.Cell>{""}</Table.Cell>
<Table.Cell>{""}</Table.Cell>
<Table.Cell>{""}</Table.Cell>
</Table.Row>
)}
{chargePoints.map((cp) => (
<Table.Row key={cp.id} id={String(cp.id)}>
<Table.Cell className="font-mono font-medium">
{cp.chargePointIdentifier}
</Table.Cell>
<Table.Cell>
{cp.chargePointVendor && cp.chargePointModel ? (
`${cp.chargePointVendor} / ${cp.chargePointModel}`
) : (
<span className="text-muted"></span>
)}
</Table.Cell>
<Table.Cell>
<Chip
color={registrationColorMap[cp.registrationStatus] ?? "warning"}
size="sm"
variant="soft"
>
{cp.registrationStatus}
</Chip>
</Table.Cell>
<Table.Cell className="tabular-nums">
{cp.feePerKwh > 0 ? (
<span>
{cp.feePerKwh}
<span className="ml-1 text-xs text-muted">
(¥{(cp.feePerKwh / 100).toFixed(2)}/kWh)
</span>
</span>
) : (
<span className="text-muted"></span>
)}
</Table.Cell>
<Table.Cell>
{cp.lastHeartbeatAt ? (
new Date(cp.lastHeartbeatAt).toLocaleString("zh-CN")
) : (
<span className="text-muted"></span>
)}
</Table.Cell>
<Table.Cell>
<div className="flex flex-wrap gap-1.5">
{cp.connectors.length === 0 ? (
<span className="text-muted text-sm"></span>
) : (
[...cp.connectors].sort((a, b) => a.connectorId - b.connectorId).map((conn) => (
<div
key={conn.id}
className="flex items-center gap-1.5 rounded-lg border border-border bg-surface-secondary px-2 py-1"
>
<PlugConnection className="size-3 shrink-0 text-muted" />
<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>
</Table.Cell>
<Table.Cell>
<div className="flex items-center gap-1">
<Button isIconOnly size="sm" variant="tertiary" onPress={() => openEdit(cp)}>
<Pencil className="size-4" />
</Button>
<Modal>
<Button
isIconOnly
size="sm"
variant="danger-soft"
onPress={() => setDeleteTarget(cp)}
>
<TrashBin 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>
<p className="text-sm text-muted">
{" "}
<span className="font-mono font-medium text-foreground">
{cp.chargePointIdentifier}
</span>
</p>
</Modal.Body>
<Modal.Footer className="flex justify-end gap-2">
<Button slot="close" variant="ghost">
</Button>
<Button
slot="close"
variant="danger"
isDisabled={deleting}
onPress={handleDelete}
>
{deleting ? <Spinner size="sm" /> : "确认删除"}
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
</div>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Content>
</Table.ScrollContainer>
</Table>
</div>
);
}