feat: RBAC controlling
This commit is contained in:
@@ -1,11 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button, Chip, Input, Label, ListBox, Modal, Select, Spinner, Table, TextField } from "@heroui/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 Link from "next/link";
|
||||
import { api, type ChargePoint } from "@/lib/api";
|
||||
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
|
||||
const statusLabelMap: Record<string, string> = {
|
||||
Available: "空闲中",
|
||||
@@ -109,7 +120,9 @@ export default function ChargePointsPage() {
|
||||
feePerKwh: fee,
|
||||
});
|
||||
setChargePoints((prev) =>
|
||||
prev.map((cp) => (cp.id === formTarget.id ? { ...updated, connectors: cp.connectors } : cp)),
|
||||
prev.map((cp) =>
|
||||
cp.id === formTarget.id ? { ...updated, connectors: cp.connectors } : cp,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Create
|
||||
@@ -142,6 +155,9 @@ export default function ChargePointsPage() {
|
||||
|
||||
const isEdit = formTarget !== null;
|
||||
|
||||
const { data: sessionData } = useSession();
|
||||
const isAdmin = sessionData?.user?.role === "admin";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
@@ -149,114 +165,134 @@ export default function ChargePointsPage() {
|
||||
<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>
|
||||
{isAdmin && (
|
||||
<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>
|
||||
{/* 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>
|
||||
<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>
|
||||
{isAdmin && <Table.Column>品牌 / 型号</Table.Column>}
|
||||
{isAdmin && <Table.Column>注册状态</Table.Column>}
|
||||
<Table.Column>电价(分/kWh)</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 && (
|
||||
@@ -264,12 +300,12 @@ export default function ChargePointsPage() {
|
||||
<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>
|
||||
<Table.Cell>{""}</Table.Cell>
|
||||
<Table.Cell>{""}</Table.Cell>
|
||||
{isAdmin && <Table.Cell>{""}</Table.Cell>}
|
||||
</Table.Row>
|
||||
)}
|
||||
{chargePoints.map((cp) => (
|
||||
@@ -282,22 +318,26 @@ export default function ChargePointsPage() {
|
||||
{cp.chargePointIdentifier}
|
||||
</Link>
|
||||
</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>
|
||||
{isAdmin && (
|
||||
<Table.Cell>
|
||||
{cp.chargePointVendor && cp.chargePointModel ? (
|
||||
`${cp.chargePointVendor} / ${cp.chargePointModel}`
|
||||
) : (
|
||||
<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 className="tabular-nums">
|
||||
{cp.feePerKwh > 0 ? (
|
||||
<span>
|
||||
@@ -322,78 +362,87 @@ export default function ChargePointsPage() {
|
||||
{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 text-nowrap">
|
||||
{statusLabelMap[conn.status] ?? conn.status}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
[...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 text-nowrap">
|
||||
{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>
|
||||
{isAdmin && (
|
||||
<Table.Cell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="danger-soft"
|
||||
onPress={() => setDeleteTarget(cp)}
|
||||
variant="tertiary"
|
||||
onPress={() => openEdit(cp)}
|
||||
>
|
||||
<TrashBin className="size-4" />
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -403,4 +452,3 @@ export default function ChargePointsPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user