Files
helios-evcs/apps/web/app/dashboard/charge-points/page.tsx
Timothy Yin 2cb89c74b3 feat(dashboard): add transactions and users management pages with CRUD functionality
feat(auth): implement login page and authentication middleware
feat(sidebar): create sidebar component with user info and navigation links
feat(api): establish API client for interacting with backend services
2026-03-10 15:17:32 +08:00

276 lines
11 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, Modal, Spinner, Table, TextField } from "@heroui/react";
import { Pencil, TrashBin } from "@gravity-ui/icons";
import { api, type ChargePoint } from "@/lib/api";
const statusColorMap: Record<string, "success" | "danger" | "warning"> = {
Available: "success",
Charging: "success",
Occupied: "warning",
Reserved: "warning",
Faulted: "danger",
Unavailable: "danger",
Preparing: "warning",
Finishing: "warning",
SuspendedEV: "warning",
SuspendedEVSE: "warning",
};
const registrationColorMap: Record<string, "success" | "warning" | "danger"> = {
Accepted: "success",
Pending: "warning",
Rejected: "danger",
};
export default function ChargePointsPage() {
const [chargePoints, setChargePoints] = useState<ChargePoint[]>([]);
const [editTarget, setEditTarget] = useState<ChargePoint | null>(null);
const [feeInput, setFeeInput] = useState("0");
const [saving, setSaving] = 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 openEdit = (cp: ChargePoint) => {
setEditTarget(cp);
setFeeInput(String(cp.feePerKwh));
};
const handleSave = async () => {
if (!editTarget) return;
const fee = Math.max(0, Math.round(Number(feeInput) || 0));
setSaving(true);
try {
await api.chargePoints.update(String(editTarget.id), { feePerKwh: fee });
setChargePoints((prev) =>
prev.map((cp) => (cp.id === editTarget.id ? { ...cp, feePerKwh: fee } : cp)),
);
} finally {
setSaving(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);
}
};
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-semibold text-foreground"></h1>
<p className="mt-0.5 text-sm text-muted"> {chargePoints.length} </p>
</div>
<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">
{cp.connectors.length === 0 ? (
<span className="text-muted text-sm"></span>
) : (
cp.connectors.map((conn) => (
<Chip
key={conn.id}
color={statusColorMap[conn.status] ?? "warning"}
size="sm"
variant="soft"
>
#{conn.connectorId} {conn.status}
</Chip>
))
)}
</div>
</Table.Cell>
<Table.Cell>
<div className="flex items-center gap-1">
<Modal>
<Button
isIconOnly
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>
<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>
);
}