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>