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

View File

@@ -23,6 +23,7 @@ import {
import { parseDate } from "@internationalized/date";
import { Pencil, Plus, TrashBin } from "@gravity-ui/icons";
import { api, type IdTag, type UserRow } from "@/lib/api";
import { useSession } from "@/lib/auth-client";
const statusColorMap: Record<string, "success" | "danger" | "warning"> = {
Accepted: "success",
@@ -245,6 +246,8 @@ function TagFormBody({
}
export default function IdTagsPage() {
const { data: sessionData } = useSession();
const isAdmin = sessionData?.user?.role === "admin";
const [tags, setTags] = useState<IdTag[]>([]);
const [users, setUsers] = useState<UserRow[]>([]);
const [loading, setLoading] = useState(true);
@@ -252,6 +255,17 @@ export default function IdTagsPage() {
const [form, setForm] = useState<FormState>(emptyForm);
const [saving, setSaving] = useState(false);
const [deletingTag, setDeletingTag] = useState<string | null>(null);
const [claiming, setClaiming] = useState(false);
const handleClaim = async () => {
setClaiming(true);
try {
await api.idTags.claim();
await load();
} finally {
setClaiming(false);
}
};
const load = async () => {
setLoading(true);
@@ -332,11 +346,12 @@ export default function IdTagsPage() {
<h1 className="text-xl font-semibold text-foreground"></h1>
<p className="mt-0.5 text-sm text-muted"> {tags.length} </p>
</div>
<Modal>
<Button size="sm" variant="secondary" onPress={openCreate}>
<Plus className="size-4" />
</Button>
{isAdmin ? (
<Modal>
<Button size="sm" variant="secondary" onPress={openCreate}>
<Plus className="size-4" />
</Button>
<Modal.Backdrop>
<Modal.Container scroll="outside">
<Modal.Dialog className="sm:max-w-105">
@@ -359,6 +374,12 @@ export default function IdTagsPage() {
</Modal.Container>
</Modal.Backdrop>
</Modal>
) : (
<Button size="sm" variant="secondary" isDisabled={claiming} onPress={handleClaim}>
<Plus className="size-4" />
{claiming ? <Spinner size="sm" /> : "申领储值卡"}
</Button>
)}
</div>
<Table>
@@ -368,11 +389,11 @@ export default function IdTagsPage() {
<Table.Column isRowHeader></Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
{isAdmin && <Table.Column></Table.Column>}
<Table.Column></Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
<Table.Column className="text-end"></Table.Column>
{isAdmin && <Table.Column className="text-end"></Table.Column>}
</Table.Header>
<Table.Body
renderEmptyState={() => (
@@ -396,6 +417,7 @@ export default function IdTagsPage() {
</Chip>
</Table.Cell>
<Table.Cell className="font-mono">¥{fenToYuan(tag.balance)}</Table.Cell>
{isAdmin && (
<Table.Cell className="text-sm">
{owner ? (
<span title={owner.email}>
@@ -405,6 +427,7 @@ export default function IdTagsPage() {
<span className="text-muted"></span>
)}
</Table.Cell>
)}
<Table.Cell>
{tag.expiryDate ? (
new Date(tag.expiryDate).toLocaleDateString("zh-CN")
@@ -418,6 +441,7 @@ export default function IdTagsPage() {
<Table.Cell className="text-sm">
{new Date(tag.createdAt).toLocaleString("zh-CN")}
</Table.Cell>
{isAdmin && (
<Table.Cell>
<div className="flex justify-end gap-1">
{/* Edit button */}
@@ -506,6 +530,7 @@ export default function IdTagsPage() {
</Modal>
</div>
</Table.Cell>
)}
</Table.Row>
);
})}

View File

@@ -1,8 +1,10 @@
import { Card } from "@heroui/react";
import { Thunderbolt, PlugConnection, CreditCard, ChartColumn } from "@gravity-ui/icons";
import { api } from "@/lib/api";
"use client";
export const dynamic = "force-dynamic";
import { useEffect, useState } from "react";
import { Card } from "@heroui/react";
import { Thunderbolt, PlugConnection, CreditCard, ChartColumn, TagDollar } from "@gravity-ui/icons";
import { useSession } from "@/lib/auth-client";
import { api, type Stats, type UserStats } from "@/lib/api";
type CardColor = "accent" | "success" | "warning" | "default";
@@ -55,42 +57,134 @@ function StatCard({
);
}
export default async function DashboardPage() {
const stats = await api.stats.get().catch(() => null);
export default function DashboardPage() {
const { data: sessionData, isPending } = useSession();
const isAdmin = sessionData?.user?.role === "admin";
const todayKwh = stats ? (stats.todayEnergyWh / 1000).toFixed(1) : "—";
const offlineCount = (stats?.totalChargePoints ?? 0) - (stats?.onlineChargePoints ?? 0);
const [adminStats, setAdminStats] = useState<Stats | null>(null);
const [userStats, setUserStats] = useState<UserStats | null>(null);
useEffect(() => {
if (isPending) return;
api.stats
.get()
.then((data) => {
if ("todayEnergyWh" in data) {
setAdminStats(data);
return;
}
setUserStats(data);
})
.catch(() => {});
}, [isPending, isAdmin]);
if (isPending) {
return (
<div className="space-y-6">
<div>
<h1 className="text-xl font-semibold text-foreground"></h1>
<p className="mt-0.5 text-sm text-muted"></p>
</div>
</div>
);
}
if (isAdmin) {
const stats = adminStats;
const todayKwh = stats ? (stats.todayEnergyWh / 1000).toFixed(1) : "—";
const offlineCount = (stats?.totalChargePoints ?? 0) - (stats?.onlineChargePoints ?? 0);
return (
<div className="space-y-6">
<div>
<h1 className="text-xl font-semibold text-foreground"></h1>
<p className="mt-0.5 text-sm text-muted"></p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-5">
<StatCard
title="充电桩总数"
value={stats?.totalChargePoints ?? "—"}
icon={PlugConnection}
color="accent"
footer={
<>
<StatusDot color="success" />
<span className="font-medium text-success">
{stats?.onlineChargePoints ?? 0} 线
</span>
<span className="text-border">·</span>
<span>{offlineCount} 线</span>
</>
}
/>
<StatCard
title="在线充电桩"
value={stats?.onlineChargePoints ?? "—"}
icon={PlugConnection}
color="success"
footer={<span> 2 </span>}
/>
<StatCard
title="进行中充电"
value={stats?.activeTransactions ?? "—"}
icon={Thunderbolt}
color={stats?.activeTransactions ? "warning" : "default"}
footer={
<>
<StatusDot color={stats?.activeTransactions ? "success" : "muted"} />
<span className={stats?.activeTransactions ? "font-medium text-success" : ""}>
{stats?.activeTransactions ? "活跃中" : "当前空闲"}
</span>
</>
}
/>
<StatCard
title="储值卡总数"
value={stats?.totalIdTags ?? "—"}
icon={CreditCard}
color="default"
footer={<span></span>}
/>
<StatCard
title="今日充电量"
value={`${todayKwh} kWh`}
icon={ChartColumn}
color="accent"
footer={<span> 00:00 </span>}
/>
</div>
</div>
);
}
// User view
const stats = userStats;
const totalYuan = stats ? (stats.totalBalance / 100).toFixed(2) : "—";
return (
<div className="space-y-6">
<div>
<h1 className="text-xl font-semibold text-foreground"></h1>
<p className="mt-0.5 text-sm text-muted"></p>
<p className="mt-0.5 text-sm text-muted">
{sessionData?.user?.name ?? sessionData?.user?.email}
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-5">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard
title="充电桩总数"
value={stats?.totalChargePoints ?? "—"}
icon={PlugConnection}
title="我的储值卡"
value={stats?.totalIdTags ?? "—"}
icon={CreditCard}
color="accent"
footer={
<>
<StatusDot color="success" />
<span className="font-medium text-success">
{stats?.onlineChargePoints ?? 0} 线
</span>
<span className="text-border">·</span>
<span>{offlineCount} 线</span>
</>
}
footer={<span></span>}
/>
<StatCard
title="在线充电桩"
value={stats?.onlineChargePoints ?? "—"}
icon={PlugConnection}
title="账户总余额"
value={`¥${totalYuan}`}
icon={TagDollar}
color="success"
footer={<span> 2 </span>}
footer={<span></span>}
/>
<StatCard
title="进行中充电"
@@ -101,24 +195,17 @@ export default async function DashboardPage() {
<>
<StatusDot color={stats?.activeTransactions ? "success" : "muted"} />
<span className={stats?.activeTransactions ? "font-medium text-success" : ""}>
{stats?.activeTransactions ? "活跃中" : "当前空闲"}
{stats?.activeTransactions ? "充电中" : "当前空闲"}
</span>
</>
}
/>
<StatCard
title="储值卡总数"
value={stats?.totalIdTags ?? "—"}
icon={CreditCard}
color="default"
footer={<span></span>}
/>
<StatCard
title="今日充电量"
value={`${todayKwh} kWh`}
title="累计充电次数"
value={stats?.totalTransactions ?? "—"}
icon={ChartColumn}
color="accent"
footer={<span> 00:00 </span>}
color="default"
footer={<span></span>}
/>
</div>
</div>

View File

@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react";
import { Button, Chip, Modal, Pagination, Spinner, Table } from "@heroui/react";
import { TrashBin } from "@gravity-ui/icons";
import { api, type PaginatedTransactions } from "@/lib/api";
import { useSession } from "@/lib/auth-client";
const LIMIT = 15;
@@ -18,6 +19,8 @@ function formatDuration(start: string, stop: string | null): string {
}
export default function TransactionsPage() {
const { data: sessionData } = useSession();
const isAdmin = sessionData?.user?.role === "admin";
const [data, setData] = useState<PaginatedTransactions | null>(null);
const [page, setPage] = useState(1);
const [status, setStatus] = useState<"all" | "active" | "completed">("all");
@@ -201,6 +204,7 @@ export default function TransactionsPage() {
</Modal.Backdrop>
</Modal>
)}
{isAdmin && (
<Modal>
<Button
isIconOnly
@@ -248,6 +252,7 @@ export default function TransactionsPage() {
</Modal.Container>
</Modal.Backdrop>
</Modal>
)}
</div>
</Table.Cell>
</Table.Row>

View File

@@ -5,16 +5,17 @@ import { usePathname } from 'next/navigation'
import { useState } from 'react'
import { CreditCard, ListCheck, Person, PlugConnection, Thunderbolt, Xmark, Bars } from '@gravity-ui/icons'
import SidebarFooter from '@/components/sidebar-footer'
import { useSession } from '@/lib/auth-client'
const navItems = [
{ href: '/dashboard', label: '概览', icon: Thunderbolt, exact: true },
{ href: '/dashboard/charge-points', label: '充电桩', icon: PlugConnection },
{ href: '/dashboard/transactions', label: '充电记录', icon: ListCheck },
{ href: '/dashboard/id-tags', label: '储值卡', icon: CreditCard },
{ href: '/dashboard/users', label: '用户管理', icon: Person },
{ href: '/dashboard', label: '概览', icon: Thunderbolt, exact: true, adminOnly: false },
{ href: '/dashboard/charge-points', label: '充电桩', icon: PlugConnection, adminOnly: false },
{ href: '/dashboard/transactions', label: '充电记录', icon: ListCheck, adminOnly: false },
{ href: '/dashboard/id-tags', label: '储值卡', icon: CreditCard, adminOnly: false },
{ href: '/dashboard/users', label: '用户管理', icon: Person, adminOnly: true },
]
function NavContent({ pathname, onNavigate }: { pathname: string; onNavigate?: () => void }) {
function NavContent({ pathname, isAdmin, onNavigate }: { pathname: string; isAdmin: boolean; onNavigate?: () => void }) {
return (
<>
{/* Logo */}
@@ -32,7 +33,7 @@ function NavContent({ pathname, onNavigate }: { pathname: string; onNavigate?: (
<p className="mb-1 px-2 text-[11px] font-semibold uppercase tracking-widest text-muted">
</p>
{navItems.map((item) => {
{navItems.filter((item) => !item.adminOnly || isAdmin).map((item) => {
const isActive = item.exact
? pathname === item.href
: pathname === item.href || pathname.startsWith(item.href + '/')
@@ -68,6 +69,8 @@ function NavContent({ pathname, onNavigate }: { pathname: string; onNavigate?: (
export default function Sidebar() {
const pathname = usePathname()
const [open, setOpen] = useState(false)
const { data: sessionData } = useSession()
const isAdmin = sessionData?.user?.role === "admin"
return (
<>
@@ -112,12 +115,12 @@ export default function Sidebar() {
>
<Xmark className="size-4" />
</button>
<NavContent pathname={pathname} onNavigate={() => setOpen(false)} />
<NavContent pathname={pathname} isAdmin={isAdmin} onNavigate={() => setOpen(false)} />
</aside>
{/* Desktop sidebar */}
<aside className="hidden w-60 shrink-0 flex-col border-r border-border bg-surface-secondary lg:flex">
<NavContent pathname={pathname} />
<NavContent pathname={pathname} isAdmin={isAdmin} />
</aside>
</>
)

View File

@@ -26,6 +26,13 @@ export type Stats = {
todayEnergyWh: number;
};
export type UserStats = {
totalIdTags: number;
totalBalance: number;
activeTransactions: number;
totalTransactions: number;
};
export type ConnectorSummary = {
id: string;
connectorId: number;
@@ -128,7 +135,7 @@ export type PaginatedTransactions = {
export const api = {
stats: {
get: () => apiFetch<Stats>("/api/stats"),
get: () => apiFetch<Stats | UserStats>("/api/stats"),
},
chargePoints: {
list: () => apiFetch<ChargePoint[]>("/api/charge-points"),
@@ -178,6 +185,7 @@ export const api = {
idTags: {
list: () => apiFetch<IdTag[]>("/api/id-tags"),
get: (idTag: string) => apiFetch<IdTag>(`/api/id-tags/${idTag}`),
claim: () => apiFetch<IdTag>("/api/id-tags/claim", { method: "POST" }),
create: (data: {
idTag: string;
status?: string;