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
This commit is contained in:
275
apps/web/app/dashboard/charge-points/page.tsx
Normal file
275
apps/web/app/dashboard/charge-points/page.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user