feat: RBAC controlling

This commit is contained in:
2026-03-10 17:59:44 +08:00
parent f803a447b5
commit b9c0f3025c
11 changed files with 716 additions and 380 deletions

View File

@@ -17,6 +17,7 @@ import {
} from "@heroui/react";
import { ArrowLeft, Pencil, PlugConnection } from "@gravity-ui/icons";
import { api, type ChargePointDetail, type PaginatedTransactions } from "@/lib/api";
import { useSession } from "@/lib/auth-client";
// ── Status maps ────────────────────────────────────────────────────────────
@@ -182,6 +183,9 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
cp?.lastHeartbeatAt != null &&
Date.now() - new Date(cp.lastHeartbeatAt).getTime() < (cp.heartbeatInterval ?? 60) * 3 * 1000;
const { data: sessionData } = useSession();
const isAdmin = sessionData?.user?.role === "admin";
// ── Render: loading / not found ──────────────────────────────────────────
if (loading) {
@@ -249,15 +253,18 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
</p>
)}
</div>
<Button size="sm" variant="secondary" onPress={openEdit}>
<Pencil className="size-4" />
</Button>
{isAdmin && (
<Button size="sm" variant="secondary" onPress={openEdit}>
<Pencil className="size-4" />
</Button>
)}
</div>
{/* Info grid */}
<div className="grid gap-4 md:grid-cols-2">
{/* Device info */}
{/* Device info — admin only */}
{isAdmin && (
<div className="rounded-xl border border-border bg-surface p-4">
<h2 className="mb-3 text-sm font-semibold text-foreground"></h2>
<dl className="divide-y divide-border">
@@ -280,8 +287,10 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
))}
</dl>
</div>
)}
{/* Operation info */}
{/* Operation info — admin only */}
{isAdmin && (
<div className="rounded-xl border border-border bg-surface p-4">
<h2 className="mb-3 text-sm font-semibold text-foreground"></h2>
<dl className="divide-y divide-border">
@@ -354,6 +363,38 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
</div>
</dl>
</div>
)}
{/* Fee info — user only */}
{!isAdmin && (
<div className="rounded-xl border border-border bg-surface p-4">
<h2 className="mb-3 text-sm font-semibold text-foreground"></h2>
<dl className="divide-y divide-border">
<div className="flex items-center justify-between gap-4 py-2">
<dt className="shrink-0 text-sm text-muted"></dt>
<dd className="text-sm text-foreground">
{cp.feePerKwh > 0 ? (
<span>
<span className="font-semibold">¥{(cp.feePerKwh / 100).toFixed(2)}</span>
<span className="ml-1 text-xs text-muted">/kWh</span>
</span>
) : (
<span className="text-success font-medium"></span>
)}
</dd>
</div>
<div className="flex items-center justify-between gap-4 py-2">
<dt className="shrink-0 text-sm text-muted"></dt>
<dd>
<div className="flex items-center gap-1.5">
<span className={`size-2 rounded-full ${isOnline ? "bg-success animate-pulse" : "bg-muted"}`} />
<span className="text-sm text-foreground">{isOnline ? "在线" : "离线"}</span>
</div>
</dd>
</div>
</dl>
</div>
)}
</div>
{/* Connectors */}
@@ -506,91 +547,93 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
</div>
{/* Edit modal */}
<Modal
isOpen={editOpen}
onOpenChange={(open) => {
if (!editBusy) setEditOpen(open);
}}
>
<Modal.Backdrop>
<Modal.Container scroll="outside">
<Modal.Dialog className="sm:max-w-md">
<Modal.CloseTrigger />
<Modal.Header>
<Modal.Heading></Modal.Heading>
</Modal.Header>
<Modal.Body className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<TextField fullWidth>
<Label className="text-sm font-medium"></Label>
<Input
placeholder="Unknown"
value={editForm.chargePointVendor}
onChange={(e) =>
setEditForm((f) => ({ ...f, chargePointVendor: e.target.value }))
{isAdmin && (
<Modal
isOpen={editOpen}
onOpenChange={(open) => {
if (!editBusy) setEditOpen(open);
}}
>
<Modal.Backdrop>
<Modal.Container scroll="outside">
<Modal.Dialog className="sm:max-w-md">
<Modal.CloseTrigger />
<Modal.Header>
<Modal.Heading></Modal.Heading>
</Modal.Header>
<Modal.Body className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<TextField fullWidth>
<Label className="text-sm font-medium"></Label>
<Input
placeholder="Unknown"
value={editForm.chargePointVendor}
onChange={(e) =>
setEditForm((f) => ({ ...f, chargePointVendor: e.target.value }))
}
/>
</TextField>
<TextField fullWidth>
<Label className="text-sm font-medium"></Label>
<Input
placeholder="Unknown"
value={editForm.chargePointModel}
onChange={(e) =>
setEditForm((f) => ({ ...f, chargePointModel: e.target.value }))
}
/>
</TextField>
</div>
<div className="space-y-1.5">
<Label className="text-sm font-medium"></Label>
<Select
fullWidth
selectedKey={editForm.registrationStatus}
onSelectionChange={(key) =>
setEditForm((f) => ({
...f,
registrationStatus: String(key) as EditForm["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={editForm.feePerKwh}
onChange={(e) => setEditForm((f) => ({ ...f, feePerKwh: e.target.value }))}
/>
</TextField>
<TextField fullWidth>
<Label className="text-sm font-medium"></Label>
<Input
placeholder="Unknown"
value={editForm.chargePointModel}
onChange={(e) =>
setEditForm((f) => ({ ...f, chargePointModel: e.target.value }))
}
/>
</TextField>
</div>
<div className="space-y-1.5">
<Label className="text-sm font-medium"></Label>
<Select
fullWidth
selectedKey={editForm.registrationStatus}
onSelectionChange={(key) =>
setEditForm((f) => ({
...f,
registrationStatus: String(key) as EditForm["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={editForm.feePerKwh}
onChange={(e) => setEditForm((f) => ({ ...f, feePerKwh: e.target.value }))}
/>
</TextField>
</Modal.Body>
<Modal.Footer className="flex justify-end gap-2">
<Button variant="ghost" onPress={() => setEditOpen(false)}>
</Button>
<Button isDisabled={editBusy} onPress={handleEditSubmit}>
{editBusy ? <Spinner size="sm" /> : "保存"}
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
</Modal.Body>
<Modal.Footer className="flex justify-end gap-2">
<Button variant="ghost" onPress={() => setEditOpen(false)}>
</Button>
<Button isDisabled={editBusy} onPress={handleEditSubmit}>
{editBusy ? <Spinner size="sm" /> : "保存"}
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
)}
</div>
);
}

View File

@@ -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>
);
}