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

763 lines
30 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 { useState, useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import {
Button,
Chip,
Input,
InputGroup,
Label,
ListBox,
Modal,
Select,
Spinner,
Table,
TextField,
Tooltip,
} from "@heroui/react";
import {
Plus,
Pencil,
PlugConnection,
TrashBin,
ArrowRotateRight,
QrCode,
Copy,
Check,
} from "@gravity-ui/icons";
import { QRCodeSVG } from "qrcode.react";
import Link from "next/link";
import { ScrollFade } from "@/components/scroll-fade";
import { api, type ChargePoint, type ChargePointCreated } from "@/lib/api";
import { useSession } from "@/lib/auth-client";
import dayjs from "@/lib/dayjs";
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",
};
function ConnectorCell({ connectors }: { connectors: ChargePoint["connectors"] }) {
return (
<ScrollFade maxWidth="max-w-2xs">
{connectors.length === 0 ? (
<span className="text-muted text-sm"></span>
) : (
[...connectors]
.sort((a, b) => a.connectorId - b.connectorId)
.map((conn) => (
<div
key={conn.id}
className="flex shrink-0 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-nowrap text-foreground">
{statusLabelMap[conn.status] ?? conn.status}
</span>
</div>
))
)}
</ScrollFade>
);
}
const registrationColorMap: Record<string, "success" | "warning" | "danger"> = {
Accepted: "success",
Pending: "warning",
Rejected: "danger",
};
type FormData = {
chargePointIdentifier: string;
deviceName: string;
chargePointVendor: string;
chargePointModel: string;
registrationStatus: "Accepted" | "Pending" | "Rejected";
pricingMode: "fixed" | "tou";
feePerKwh: string;
};
const EMPTY_FORM: FormData = {
chargePointIdentifier: "",
deviceName: "",
chargePointVendor: "",
chargePointModel: "",
registrationStatus: "Pending",
pricingMode: "fixed",
feePerKwh: "0",
};
export default function ChargePointsPage() {
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 [qrTarget, setQrTarget] = useState<ChargePoint | null>(null);
const [createdCp, setCreatedCp] = useState<ChargePointCreated | null>(null);
const [copied, setCopied] = useState(false);
const {
data: chargePoints = [],
refetch: refetchList,
isFetching: refreshing,
} = useQuery({
queryKey: ["chargePoints"],
queryFn: () => api.chargePoints.list().catch(() => []),
refetchInterval: 3_000,
});
const openCreate = () => {
setFormTarget(null);
setFormData(EMPTY_FORM);
setFormOpen(true);
};
const openEdit = (cp: ChargePoint) => {
setFormTarget(cp);
setFormData({
chargePointIdentifier: cp.chargePointIdentifier,
deviceName: cp.deviceName ?? "",
chargePointVendor: cp.chargePointVendor ?? "",
chargePointModel: cp.chargePointModel ?? "",
registrationStatus: cp.registrationStatus as FormData["registrationStatus"],
pricingMode: cp.pricingMode,
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
await api.chargePoints.update(String(formTarget.id), {
chargePointVendor: formData.chargePointVendor,
chargePointModel: formData.chargePointModel,
registrationStatus: formData.registrationStatus,
pricingMode: formData.pricingMode,
feePerKwh: formData.pricingMode === "fixed" ? fee : 0,
deviceName: formData.deviceName.trim() || null,
});
await refetchList();
setFormOpen(false);
} else {
// Create — capture plainPassword for one-time display
const created = await api.chargePoints.create({
chargePointIdentifier: formData.chargePointIdentifier.trim(),
chargePointVendor: formData.chargePointVendor.trim() || undefined,
chargePointModel: formData.chargePointModel.trim() || undefined,
registrationStatus: formData.registrationStatus,
pricingMode: formData.pricingMode,
feePerKwh: formData.pricingMode === "fixed" ? fee : 0,
deviceName: formData.deviceName.trim() || undefined,
});
await refetchList();
setFormOpen(false);
setCreatedCp(created);
}
} finally {
setFormBusy(false);
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
setDeleting(true);
try {
await api.chargePoints.delete(String(deleteTarget.id));
await refetchList();
setDeleteTarget(null);
} finally {
setDeleting(false);
}
};
const isEdit = formTarget !== null;
const handleCopyPassword = (text: string) => {
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
const { data: sessionData } = useSession();
const isAdmin = sessionData?.user?.role === "admin";
const [qrOrigin, setQrOrigin] = useState("");
useEffect(() => {
setQrOrigin(window.location.origin);
}, []);
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>
<div className="flex items-center gap-2">
<Button
isIconOnly
size="sm"
variant="ghost"
isDisabled={refreshing}
onPress={() => refetchList()}
aria-label="刷新"
>
<ArrowRotateRight className={`size-4 ${refreshing ? "animate-spin" : ""}`} />
</Button>
{isAdmin && (
<Button size="sm" variant="secondary" onPress={openCreate}>
<Plus className="size-4" />
</Button>
)}
</div>
</div>
{/* Create / Edit modal — admin only */}
{isAdmin && (
<>
<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>
<TextField fullWidth>
<Label className="text-sm font-medium"></Label>
<Input
placeholder="1号楼A区01号桩"
value={formData.deviceName}
onChange={(e) => setFormData((f) => ({ ...f, deviceName: e.target.value }))}
/>
</TextField>
<div className="grid grid-cols-2 gap-3">
<TextField fullWidth isReadOnly={isEdit}>
<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 isReadOnly={isEdit}>
<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>
<div className="space-y-1.5">
<Label className="text-sm font-medium"></Label>
<Select
fullWidth
selectedKey={formData.pricingMode}
onSelectionChange={(key) =>
setFormData((f) => ({
...f,
pricingMode: String(key) as FormData["pricingMode"],
}))
}
>
<Select.Trigger>
<Select.Value />
<Select.Indicator />
</Select.Trigger>
<Select.Popover>
<ListBox>
<ListBox.Item id="fixed"></ListBox.Item>
<ListBox.Item id="tou"></ListBox.Item>
</ListBox>
</Select.Popover>
</Select>
</div>
{formData.pricingMode === "fixed" && (
<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>
</>
)}
{/* QR Code Modal */}
{isAdmin && (
<Modal
isOpen={qrTarget !== null}
onOpenChange={(open) => {
if (!open) setQrTarget(null);
}}
>
<Modal.Backdrop>
<Modal.Container scroll="outside">
<Modal.Dialog className="sm:max-w-lg">
<Modal.CloseTrigger />
<Modal.Header>
<Modal.Heading>
{qrTarget?.deviceName ?? qrTarget?.chargePointIdentifier}
</Modal.Heading>
</Modal.Header>
<Modal.Body className="space-y-4">
<p className="text-sm text-muted">
</p>
{qrTarget &&
qrTarget.connectors.filter((c) => c.connectorId > 0).length === 0 && (
<p className="text-sm text-muted">
线
</p>
)}
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3">
{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 (
<div
key={conn.id}
className="flex flex-col items-center gap-2 rounded-xl border border-border p-3"
>
<p className="text-xs font-medium text-foreground">
#{conn.connectorId}
</p>
<QRCodeSVG value={url} size={120} className="rounded" />
<p className="break-all text-center font-mono text-[9px] text-muted leading-tight">
{url}
</p>
</div>
);
})}
</div>
</Modal.Body>
<Modal.Footer className="flex justify-end">
<Button variant="ghost" onPress={() => setQrTarget(null)}>
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
)}
{/* OCPP Password Modal — shown once after creation */}
{isAdmin && (
<Modal
isOpen={createdCp !== null}
onOpenChange={(open) => {
if (!open) {
setCreatedCp(null);
setCopied(false);
}
}}
>
<Modal.Backdrop>
<Modal.Container scroll="outside">
<Modal.Dialog className="sm:max-w-md">
<Modal.Header>
<Modal.Heading></Modal.Heading>
</Modal.Header>
<Modal.Body className="space-y-4">
<p className="text-sm text-warning font-medium">
</p>
<TextField fullWidth isReadOnly>
<Label className="text-sm font-medium"></Label>
<Input value={createdCp?.chargePointIdentifier ?? ""} className="font-mono" />
</TextField>
<TextField fullWidth isReadOnly>
<Label className="text-sm font-medium">OCPP Basic Auth </Label>
<InputGroup>
<InputGroup.Input
value={createdCp?.plainPassword ?? ""}
className="font-mono select-all"
/>
<InputGroup.Suffix>
<Tooltip>
<Tooltip.Content>{copied ? "已复制" : "复制密码"}</Tooltip.Content>
<Tooltip.Trigger>
<Button
isIconOnly
size="sm"
variant="ghost"
onPress={() =>
createdCp && handleCopyPassword(createdCp.plainPassword)
}
>
{copied ? (
<Check className="size-4 text-success" />
) : (
<Copy className="size-4" />
)}
</Button>
</Tooltip.Trigger>
</Tooltip>
</InputGroup.Suffix>
</InputGroup>
</TextField>
<div className="space-y-1.5">
<TextField fullWidth isReadOnly>
<Label className="text-sm font-medium"> WebSocket </Label>
<Input
value={`wss://<your-server>/ocpp/${createdCp?.chargePointIdentifier ?? ""}`}
className="font-mono text-xs"
/>
</TextField>
<p className="text-xs text-muted">
HTTP
<br />
<code className="text-foreground">
Authorization: Basic &lt;base64({createdCp?.chargePointIdentifier}
:&lt;password&gt;)&gt;
</code>
</p>
</div>
</Modal.Body>
<Modal.Footer className="flex justify-end">
<Button
onPress={() => {
setCreatedCp(null);
setCopied(false);
}}
>
</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>
{isAdmin && <Table.Column> / </Table.Column>}
{isAdmin && <Table.Column></Table.Column>}
<Table.Column></Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
{isAdmin && <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>
{isAdmin && <Table.Cell>{""}</Table.Cell>}
{isAdmin && <Table.Cell>{""}</Table.Cell>}
<Table.Cell>{""}</Table.Cell>
<Table.Cell>{""}</Table.Cell>
<Table.Cell>{""}</Table.Cell>
<Table.Cell>{""}</Table.Cell>
{isAdmin && <Table.Cell>{""}</Table.Cell>}
</Table.Row>
)}
{chargePoints.map((cp) => (
<Table.Row key={cp.id} id={String(cp.id)} className={"group"}>
<Table.Cell>
<Tooltip delay={0}>
<Tooltip.Trigger>
<div className="flex items-center gap-2">
<span
className={`size-2 shrink-0 rounded-full ${
cp.lastHeartbeatAt &&
dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120
? "bg-success"
: "bg-gray-300"
}`}
/>
<div className="flex flex-col">
<Link
href={`/dashboard/charge-points/${cp.id}`}
className="font-medium text-accent"
>
{cp.deviceName ?? cp.chargePointIdentifier}
</Link>
{isAdmin && cp.deviceName && (
<span className="font-mono text-xs text-muted">
{cp.chargePointIdentifier}
</span>
)}
</div>
</div>
</Tooltip.Trigger>
<Tooltip.Content placement="start">
{cp.lastHeartbeatAt
? dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120
? "在线"
: "离线"
: "从未连接"}
</Tooltip.Content>
</Tooltip>
</Table.Cell>
{isAdmin && (
<Table.Cell>
{cp.chargePointVendor || cp.chargePointModel ? (
<div className="flex flex-col">
{cp.chargePointVendor && (
<span className="text-xs text-muted font-medium">
{cp.chargePointVendor}
</span>
)}
{cp.chargePointModel && (
<span className="text-sm text-foreground">{cp.chargePointModel}</span>
)}
</div>
) : (
<span className="text-muted"></span>
)}
</Table.Cell>
)}
{isAdmin && (
<Table.Cell>
<Chip
color={registrationColorMap[cp.registrationStatus] ?? "warning"}
size="sm"
variant="soft"
>
{cp.registrationStatus}
</Chip>
</Table.Cell>
)}
<Table.Cell>
{cp.pricingMode === "tou" ? (
<span className="text-accent font-medium"></span>
) : 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 ? (
dayjs(cp.lastHeartbeatAt).format("YYYY/M/D HH:mm:ss")
) : (
<span className="text-muted"></span>
)}
</Table.Cell>
<Table.Cell>
{cp.chargePointStatus ? (
<div className="flex items-center gap-1.5">
<span
className={`size-1.5 shrink-0 rounded-full ${statusDotClass[cp.chargePointStatus] ?? "bg-warning"}`}
/>
<span className="text-sm text-foreground text-nowrap">
{cp.chargePointStatus === "Available"
? "正常"
: (statusLabelMap[cp.chargePointStatus] ?? cp.chargePointStatus)}
</span>
</div>
) : (
<span className="text-muted"></span>
)}
</Table.Cell>
<Table.Cell>
<ConnectorCell connectors={cp.connectors} />
</Table.Cell>
{isAdmin && (
<Table.Cell>
<div className="flex items-center gap-1">
<Button
isIconOnly
size="sm"
variant="tertiary"
onPress={() => openEdit(cp)}
>
<Pencil className="size-4" />
</Button>
<Button
isIconOnly
size="sm"
variant="tertiary"
onPress={() => setQrTarget(cp)}
aria-label="查看二维码"
>
<QrCode 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-medium text-foreground">
{cp.deviceName ?? cp.chargePointIdentifier}
</span>
{cp.deviceName && (
<span className="font-mono ml-1 text-xs text-muted">
({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>
);
}