feat(dashboard): add transactions and users management pages with CRUD functionality

feat(auth): implement login page and authentication middleware
feat(sidebar): create sidebar component with user info and navigation links
feat(api): establish API client for interacting with backend services
This commit is contained in:
2026-03-10 15:17:32 +08:00
parent 9a2668fae5
commit 2cb89c74b3
32 changed files with 4648 additions and 83 deletions

View File

@@ -0,0 +1,275 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button, Chip, Input, Label, Modal, Spinner, Table, TextField } from "@heroui/react";
import { Pencil, TrashBin } from "@gravity-ui/icons";
import { api, type ChargePoint } from "@/lib/api";
const statusColorMap: Record<string, "success" | "danger" | "warning"> = {
Available: "success",
Charging: "success",
Occupied: "warning",
Reserved: "warning",
Faulted: "danger",
Unavailable: "danger",
Preparing: "warning",
Finishing: "warning",
SuspendedEV: "warning",
SuspendedEVSE: "warning",
};
const registrationColorMap: Record<string, "success" | "warning" | "danger"> = {
Accepted: "success",
Pending: "warning",
Rejected: "danger",
};
export default function ChargePointsPage() {
const [chargePoints, setChargePoints] = useState<ChargePoint[]>([]);
const [editTarget, setEditTarget] = useState<ChargePoint | null>(null);
const [feeInput, setFeeInput] = useState("0");
const [saving, setSaving] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<ChargePoint | null>(null);
const [deleting, setDeleting] = useState(false);
const hasFetched = useRef(false);
const load = useCallback(async () => {
const data = await api.chargePoints.list().catch(() => []);
setChargePoints(data);
}, []);
useEffect(() => {
if (!hasFetched.current) {
hasFetched.current = true;
load();
}
}, [load]);
const openEdit = (cp: ChargePoint) => {
setEditTarget(cp);
setFeeInput(String(cp.feePerKwh));
};
const handleSave = async () => {
if (!editTarget) return;
const fee = Math.max(0, Math.round(Number(feeInput) || 0));
setSaving(true);
try {
await api.chargePoints.update(String(editTarget.id), { feePerKwh: fee });
setChargePoints((prev) =>
prev.map((cp) => (cp.id === editTarget.id ? { ...cp, feePerKwh: fee } : cp)),
);
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
setDeleting(true);
try {
await api.chargePoints.delete(String(deleteTarget.id));
setChargePoints((prev) => prev.filter((cp) => cp.id !== deleteTarget.id));
setDeleteTarget(null);
} finally {
setDeleting(false);
}
};
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-semibold text-foreground"></h1>
<p className="mt-0.5 text-sm text-muted"> {chargePoints.length} </p>
</div>
<Table>
<Table.ScrollContainer>
<Table.Content aria-label="充电桩列表" className="min-w-200">
<Table.Header>
<Table.Column isRowHeader></Table.Column>
<Table.Column> / </Table.Column>
<Table.Column></Table.Column>
<Table.Column>/kWh</Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
<Table.Column>{""}</Table.Column>
</Table.Header>
<Table.Body>
{chargePoints.length === 0 && (
<Table.Row id="empty">
<Table.Cell>
<span className="text-muted text-sm"></span>
</Table.Cell>
<Table.Cell>{""}</Table.Cell>
<Table.Cell>{""}</Table.Cell>
<Table.Cell>{""}</Table.Cell>
<Table.Cell>{""}</Table.Cell>
<Table.Cell>{""}</Table.Cell>
<Table.Cell>{""}</Table.Cell>
</Table.Row>
)}
{chargePoints.map((cp) => (
<Table.Row key={cp.id} id={String(cp.id)}>
<Table.Cell className="font-mono font-medium">
{cp.chargePointIdentifier}
</Table.Cell>
<Table.Cell>
{cp.chargePointVendor && cp.chargePointModel ? (
`${cp.chargePointVendor} / ${cp.chargePointModel}`
) : (
<span className="text-muted"></span>
)}
</Table.Cell>
<Table.Cell>
<Chip
color={registrationColorMap[cp.registrationStatus] ?? "warning"}
size="sm"
variant="soft"
>
{cp.registrationStatus}
</Chip>
</Table.Cell>
<Table.Cell className="tabular-nums">
{cp.feePerKwh > 0 ? (
<span>
{cp.feePerKwh}
<span className="ml-1 text-xs text-muted">
(¥{(cp.feePerKwh / 100).toFixed(2)}/kWh)
</span>
</span>
) : (
<span className="text-muted"></span>
)}
</Table.Cell>
<Table.Cell>
{cp.lastHeartbeatAt ? (
new Date(cp.lastHeartbeatAt).toLocaleString("zh-CN")
) : (
<span className="text-muted"></span>
)}
</Table.Cell>
<Table.Cell>
<div className="flex flex-wrap gap-1">
{cp.connectors.length === 0 ? (
<span className="text-muted text-sm"></span>
) : (
cp.connectors.map((conn) => (
<Chip
key={conn.id}
color={statusColorMap[conn.status] ?? "warning"}
size="sm"
variant="soft"
>
#{conn.connectorId} {conn.status}
</Chip>
))
)}
</div>
</Table.Cell>
<Table.Cell>
<div className="flex items-center gap-1">
<Modal>
<Button
isIconOnly
size="sm"
variant="tertiary"
onPress={() => openEdit(cp)}
>
<Pencil className="size-4" />
</Button>
<Modal.Backdrop>
<Modal.Container scroll="outside">
<Modal.Dialog className="sm:max-w-96">
<Modal.CloseTrigger />
<Modal.Header>
<Modal.Heading></Modal.Heading>
</Modal.Header>
<Modal.Body className="space-y-3">
<p className="text-sm text-muted">
<span className="font-mono font-medium text-foreground">
{cp.chargePointIdentifier}
</span>
</p>
<TextField fullWidth>
<Label className="text-sm font-medium">/kWh</Label>
<Input
type="number"
min="0"
step="1"
placeholder="0"
value={feeInput}
onChange={(e) => setFeeInput(e.target.value)}
/>
</TextField>
<p className="text-xs text-muted">
0 ¥
{((Number(feeInput) || 0) / 100).toFixed(2)}/kWh
</p>
</Modal.Body>
<Modal.Footer className="flex justify-end gap-2">
<Button slot="close" variant="ghost">
</Button>
<Button isDisabled={saving} slot="close" onPress={handleSave}>
{saving ? <Spinner size="sm" /> : "保存"}
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
<Modal>
<Button
isIconOnly
size="sm"
variant="danger-soft"
onPress={() => setDeleteTarget(cp)}
>
<TrashBin className="size-4" />
</Button>
<Modal.Backdrop>
<Modal.Container scroll="outside">
<Modal.Dialog className="sm:max-w-96">
<Modal.CloseTrigger />
<Modal.Header>
<Modal.Heading></Modal.Heading>
</Modal.Header>
<Modal.Body>
<p className="text-sm text-muted">
{" "}
<span className="font-mono font-medium text-foreground">
{cp.chargePointIdentifier}
</span>
</p>
</Modal.Body>
<Modal.Footer className="flex justify-end gap-2">
<Button slot="close" variant="ghost">
</Button>
<Button
slot="close"
variant="danger"
isDisabled={deleting}
onPress={handleDelete}
>
{deleting ? <Spinner size="sm" /> : "确认删除"}
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
</div>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Content>
</Table.ScrollContainer>
</Table>
</div>
);
}

View File

@@ -0,0 +1,484 @@
"use client";
import { useEffect, useState } from "react";
import {
Autocomplete,
Button,
Calendar,
Chip,
DateField,
DatePicker,
EmptyState,
Input,
Label,
ListBox,
Modal,
SearchField,
Select,
Spinner,
Table,
TextField,
useFilter,
} from "@heroui/react";
import { parseDate } from "@internationalized/date";
import { Pencil, TrashBin } from "@gravity-ui/icons";
import { api, type IdTag, type UserRow } from "@/lib/api";
const statusColorMap: Record<string, "success" | "danger" | "warning"> = {
Accepted: "success",
Blocked: "danger",
Expired: "warning",
Invalid: "danger",
ConcurrentTx: "warning",
};
type FormState = {
idTag: string;
status: string;
expiryDate: string;
parentIdTag: string;
userId: string;
balance: string;
};
const emptyForm: FormState = {
idTag: "",
status: "Accepted",
expiryDate: "",
parentIdTag: "",
userId: "",
balance: "0",
};
const STATUS_OPTIONS = ["Accepted", "Blocked", "Expired", "Invalid", "ConcurrentTx"] as const;
/** 将「元」字符串转为分(整数),无效时返回 0 */
function yuanToFen(yuan: string): number {
const n = parseFloat(yuan);
return isNaN(n) ? 0 : Math.round(n * 100);
}
/** 将分(整数)格式化为「元」字符串 */
function fenToYuan(fen: number): string {
return (fen / 100).toFixed(2);
}
function UserAutocomplete({
userId,
onChange,
users,
}: {
userId: string;
onChange: (id: string) => void;
users: UserRow[];
}) {
const { contains } = useFilter({ sensitivity: "base" });
return (
<Autocomplete
fullWidth
placeholder="搜索用户…"
selectionMode="single"
value={userId || null}
onChange={(key) => onChange(key ? String(key) : "")}
>
<Autocomplete.Trigger>
<Autocomplete.Value>
{({ isPlaceholder, state }: any) => {
if (isPlaceholder || !state.selectedItems?.length)
return <span className="text-muted"></span>;
const u = users.find((u) => u.id === state.selectedItems[0]?.key);
return u ? <span>{u.name ?? u.username ?? u.email}</span> : null;
}}
</Autocomplete.Value>
<Autocomplete.ClearButton />
<Autocomplete.Indicator />
</Autocomplete.Trigger>
<Autocomplete.Popover>
<Autocomplete.Filter filter={contains}>
<SearchField autoFocus name="userSearch" variant="secondary">
<SearchField.Group>
<SearchField.SearchIcon />
<SearchField.Input placeholder="搜索姓名或邮箱…" />
<SearchField.ClearButton />
</SearchField.Group>
</SearchField>
<ListBox renderEmptyState={() => <EmptyState></EmptyState>}>
{users.map((u) => (
<ListBox.Item
key={u.id}
id={u.id}
textValue={`${u.name ?? u.username ?? ""} ${u.email}`}
>
<span className="font-medium">{u.name ?? u.username ?? u.email}</span>
<span className="ml-1.5 text-xs text-muted">{u.email}</span>
<ListBox.ItemIndicator />
</ListBox.Item>
))}
</ListBox>
</Autocomplete.Filter>
</Autocomplete.Popover>
</Autocomplete>
);
}
function TagFormBody({
form,
setForm,
isEdit,
users,
}: {
form: FormState;
setForm: (f: FormState) => void;
isEdit: boolean;
users: UserRow[];
}) {
return (
<>
<TextField fullWidth>
<Label className="text-sm font-medium">{isEdit ? "卡号" : "卡号 (idTag)"}</Label>
<Input
disabled={isEdit}
className="font-mono"
placeholder="e.g. RFID001"
value={form.idTag}
onChange={(e) => setForm({ ...form, idTag: e.target.value })}
/>
</TextField>
<div className="flex flex-col gap-1">
<Label className="text-sm font-medium"></Label>
<Select
fullWidth
selectedKey={form.status}
onSelectionChange={(key) => setForm({ ...form, status: String(key) })}
>
<Select.Trigger>
<Select.Value />
<Select.Indicator />
</Select.Trigger>
<Select.Popover>
<ListBox>
{STATUS_OPTIONS.map((s) => (
<ListBox.Item key={s} id={s}>
{s}
</ListBox.Item>
))}
</ListBox>
</Select.Popover>
</Select>
</div>
<div className="flex flex-col gap-1">
<Label className="text-sm font-medium">{isEdit ? "有效期" : "有效期 (可选)"}</Label>
<DatePicker
value={form.expiryDate ? parseDate(form.expiryDate) : null}
onChange={(date) => setForm({ ...form, expiryDate: date ? date.toString() : "" })}
>
<DateField.Group fullWidth>
<DateField.InputContainer>
<DateField.Input>
{(segment) => <DateField.Segment segment={segment} />}
</DateField.Input>
</DateField.InputContainer>
<DateField.Suffix>
<DatePicker.Trigger>
<DatePicker.TriggerIndicator />
</DatePicker.Trigger>
</DateField.Suffix>
</DateField.Group>
<DatePicker.Popover>
<Calendar>
<Calendar.Header>
<Calendar.NavButton slot="previous" />
<Calendar.Heading />
<Calendar.NavButton slot="next" />
</Calendar.Header>
<Calendar.Grid>
<Calendar.GridHeader>
{(day) => <Calendar.HeaderCell>{day}</Calendar.HeaderCell>}
</Calendar.GridHeader>
<Calendar.GridBody>
{(date) => (
<Calendar.Cell date={date}>
{({ formattedDate }) => (
<>
{formattedDate}
<Calendar.CellIndicator />
</>
)}
</Calendar.Cell>
)}
</Calendar.GridBody>
</Calendar.Grid>
</Calendar>
</DatePicker.Popover>
</DatePicker>
</div>
<TextField fullWidth>
<Label className="text-sm font-medium">{isEdit ? "父卡号" : "父卡号 (可选)"}</Label>
<Input
className="font-mono"
placeholder="parentIdTag"
value={form.parentIdTag}
onChange={(e) => setForm({ ...form, parentIdTag: e.target.value })}
/>
</TextField>
<TextField fullWidth>
<Label className="text-sm font-medium"></Label>
<Input
type="number"
min="0"
step="0.01"
placeholder="0.00"
value={form.balance}
onChange={(e) => setForm({ ...form, balance: e.target.value })}
/>
</TextField>
<div className="flex flex-col gap-1">
<Label className="text-sm font-medium"></Label>
<UserAutocomplete
userId={form.userId}
onChange={(id) => setForm({ ...form, userId: id })}
users={users}
/>
</div>
</>
);
}
export default function IdTagsPage() {
const [tags, setTags] = useState<IdTag[]>([]);
const [users, setUsers] = useState<UserRow[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState<IdTag | null>(null);
const [form, setForm] = useState<FormState>(emptyForm);
const [saving, setSaving] = useState(false);
const [deletingTag, setDeletingTag] = useState<string | null>(null);
const load = async () => {
setLoading(true);
try {
const [tagList, userList] = await Promise.all([
api.idTags.list(),
api.users.list().catch(() => [] as UserRow[]),
]);
setTags(tagList);
setUsers(userList);
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
const openCreate = () => {
setEditing(null);
setForm(emptyForm);
};
const openEdit = (tag: IdTag) => {
setEditing(tag);
setForm({
idTag: tag.idTag,
status: tag.status,
expiryDate: tag.expiryDate ? tag.expiryDate.slice(0, 10) : "",
parentIdTag: tag.parentIdTag ?? "",
userId: tag.userId ?? "",
balance: fenToYuan(tag.balance),
});
};
const handleSave = async () => {
setSaving(true);
try {
if (editing) {
await api.idTags.update(editing.idTag, {
status: form.status,
expiryDate: form.expiryDate || null,
parentIdTag: form.parentIdTag || null,
userId: form.userId || null,
balance: yuanToFen(form.balance),
});
} else {
await api.idTags.create({
idTag: form.idTag,
status: form.status,
expiryDate: form.expiryDate || undefined,
parentIdTag: form.parentIdTag || undefined,
userId: form.userId || undefined,
balance: yuanToFen(form.balance),
});
}
await load();
} finally {
setSaving(false);
}
};
const handleDelete = async (idTag: string) => {
setDeletingTag(idTag);
try {
await api.idTags.delete(idTag);
await load();
} finally {
setDeletingTag(null);
}
};
return (
<div className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-xl font-semibold text-foreground"></h1>
<p className="mt-0.5 text-sm text-muted"> {tags.length} </p>
</div>
<Modal>
<Button variant="secondary" onPress={openCreate}>
</Button>
<Modal.Backdrop>
<Modal.Container scroll="outside">
<Modal.Dialog className="sm:max-w-105">
<Modal.CloseTrigger />
<Modal.Header>
<Modal.Heading></Modal.Heading>
</Modal.Header>
<Modal.Body className="space-y-3">
<TagFormBody form={form} setForm={setForm} isEdit={false} users={users} />
</Modal.Body>
<Modal.Footer className="flex justify-end gap-2">
<Button slot="close" variant="ghost">
</Button>
<Button isDisabled={saving} slot="close" onPress={handleSave}>
{saving ? <Spinner size="sm" /> : "创建"}
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
</div>
<Table>
<Table.ScrollContainer>
<Table.Content aria-label="储值卡列表" className="min-w-200">
<Table.Header>
<Table.Column isRowHeader></Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
<Table.Column className="text-end"></Table.Column>
</Table.Header>
<Table.Body
renderEmptyState={() => (
<div className="py-8 text-center text-sm text-muted">
{loading ? "加载中…" : "暂无储值卡"}
</div>
)}
>
{tags.map((tag) => {
const owner = users.find((u) => u.id === tag.userId);
return (
<Table.Row key={tag.idTag} id={tag.idTag}>
<Table.Cell className="font-mono font-medium">{tag.idTag}</Table.Cell>
<Table.Cell>
<Chip
color={statusColorMap[tag.status] ?? "warning"}
size="sm"
variant="soft"
>
{tag.status}
</Chip>
</Table.Cell>
<Table.Cell className="font-mono">¥{fenToYuan(tag.balance)}</Table.Cell>
<Table.Cell className="text-sm">
{owner ? (
<span title={owner.email}>
{owner.name ?? owner.username ?? owner.email}
</span>
) : (
<span className="text-muted"></span>
)}
</Table.Cell>
<Table.Cell>
{tag.expiryDate ? (
new Date(tag.expiryDate).toLocaleDateString("zh-CN")
) : (
<span className="text-muted"></span>
)}
</Table.Cell>
<Table.Cell className="font-mono">
{tag.parentIdTag ?? <span className="text-muted"></span>}
</Table.Cell>
<Table.Cell className="text-sm">
{new Date(tag.createdAt).toLocaleString("zh-CN")}
</Table.Cell>
<Table.Cell>
<div className="flex justify-end gap-1">
{/* Edit button */}
<Modal>
<Button
isIconOnly
size="sm"
variant="tertiary"
onPress={() => openEdit(tag)}
>
<Pencil className="size-4" />
</Button>
<Modal.Backdrop>
<Modal.Container scroll="outside">
<Modal.Dialog className="sm:max-w-105">
<Modal.CloseTrigger />
<Modal.Header>
<Modal.Heading></Modal.Heading>
</Modal.Header>
<Modal.Body className="space-y-3">
<TagFormBody
form={form}
setForm={setForm}
isEdit={true}
users={users}
/>
</Modal.Body>
<Modal.Footer className="flex justify-end gap-2">
<Button slot="close" variant="ghost">
</Button>
<Button isDisabled={saving} slot="close" onPress={handleSave}>
{saving ? <Spinner size="sm" /> : "保存"}
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
{/* Delete button */}
<Button
isDisabled={deletingTag === tag.idTag}
isIconOnly
size="sm"
variant="danger-soft"
onPress={() => handleDelete(tag.idTag)}
>
{deletingTag === tag.idTag ? (
<Spinner size="sm" />
) : (
<TrashBin className="size-4" />
)}
</Button>
</div>
</Table.Cell>
</Table.Row>
);
})}
</Table.Body>
</Table.Content>
</Table.ScrollContainer>
</Table>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import { ReactNode } from 'react'
import Sidebar from '@/components/sidebar'
export default function DashboardLayout({ children }: { children: ReactNode }) {
return (
<div className="flex h-dvh bg-background">
<Sidebar />
{/* Main content */}
<div className="flex min-w-0 flex-1 flex-col">
<main className="flex-1 overflow-y-auto pt-14 lg:pt-0">
<div className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
{children}
</div>
</main>
</div>
</div>
)
}

View File

@@ -0,0 +1,126 @@
import { Card } from "@heroui/react";
import { Thunderbolt, PlugConnection, CreditCard, ChartColumn } from "@gravity-ui/icons";
import { api } from "@/lib/api";
export const dynamic = "force-dynamic";
type CardColor = "accent" | "success" | "warning" | "default";
const colorStyles: Record<CardColor, { border: string; bg: string; icon: string }> = {
accent: { border: "border-accent", bg: "bg-accent/10", icon: "text-accent" },
success: { border: "border-success", bg: "bg-success/10", icon: "text-success" },
warning: { border: "border-warning", bg: "bg-warning/10", icon: "text-warning" },
default: { border: "border-border", bg: "bg-default", icon: "text-muted" },
};
function StatusDot({ color }: { color: "success" | "warning" | "muted" }) {
const cls =
color === "success" ? "bg-success" : color === "warning" ? "bg-warning" : "bg-muted/40";
return <span className={`inline-block h-1.5 w-1.5 shrink-0 rounded-full ${cls}`} />;
}
function StatCard({
title,
value,
footer,
icon: Icon,
color = "default",
}: {
title: string;
value: string | number;
footer?: React.ReactNode;
icon?: React.ComponentType<{ className?: string }>;
color?: CardColor;
}) {
const s = colorStyles[color];
return (
<Card className={`border-t-2 ${s.border}`}>
<Card.Content className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<p className="text-sm text-muted">{title}</p>
{Icon && (
<div className={`flex size-9 shrink-0 items-center justify-center rounded-xl ${s.bg}`}>
<Icon className={`size-4.5 ${s.icon}`} />
</div>
)}
</div>
<p className="text-3xl font-bold tabular-nums leading-none text-foreground">{value}</p>
{footer && (
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-muted">
{footer}
</div>
)}
</Card.Content>
</Card>
);
}
export default async function DashboardPage() {
const stats = await api.stats.get().catch(() => null);
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>
);
}

View File

@@ -0,0 +1,298 @@
"use client";
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";
const LIMIT = 15;
function formatDuration(start: string, stop: string | null): string {
if (!stop) return "进行中";
const ms = new Date(stop).getTime() - new Date(start).getTime();
const min = Math.floor(ms / 60000);
if (min < 60) return `${min} 分钟`;
const h = Math.floor(min / 60);
const m = min % 60;
return `${h}h ${m}m`;
}
export default function TransactionsPage() {
const [data, setData] = useState<PaginatedTransactions | null>(null);
const [page, setPage] = useState(1);
const [status, setStatus] = useState<"all" | "active" | "completed">("all");
const [loading, setLoading] = useState(true);
const [stoppingId, setStoppingId] = useState<number | null>(null);
const [deletingId, setDeletingId] = useState<number | null>(null);
const load = useCallback(async (p: number, s: typeof status) => {
setLoading(true);
try {
const res = await api.transactions.list({
page: p,
limit: LIMIT,
status: s === "all" ? undefined : s,
});
setData(res);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load(page, status);
}, [page, status, load]);
const handleStatusChange = (s: typeof status) => {
setStatus(s);
setPage(1);
};
const handleStop = async (id: number) => {
setStoppingId(id);
try {
await api.transactions.stop(id);
await load(page, status);
} finally {
setStoppingId(null);
}
};
const handleDelete = async (id: number) => {
setDeletingId(id);
try {
await api.transactions.delete(id);
await load(page, status);
} finally {
setDeletingId(null);
}
};
const pages = data ? Array.from({ length: data.totalPages }, (_, i) => i + 1) : [];
return (
<div className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-xl font-semibold text-foreground"></h1>
<p className="mt-0.5 text-sm text-muted"> {data?.total ?? "—"} </p>
</div>
<div className="flex gap-1.5 rounded-xl bg-surface-secondary p-1">
{(["all", "active", "completed"] as const).map((s) => (
<button
key={s}
onClick={() => handleStatusChange(s)}
className={`rounded-lg px-3 py-1 text-sm font-medium transition-colors ${
status === s
? "bg-surface text-foreground shadow-sm"
: "text-muted hover:text-foreground"
}`}
>
{s === "all" ? "全部" : s === "active" ? "进行中" : "已完成"}
</button>
))}
</div>
</div>
<Table>
<Table.ScrollContainer>
<Table.Content aria-label="充电记录" className="min-w-200">
<Table.Header>
<Table.Column isRowHeader>ID</Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
<Table.Column> (kWh)</Table.Column>
<Table.Column> ()</Table.Column>
<Table.Column></Table.Column>
<Table.Column>{""}</Table.Column>
</Table.Header>
<Table.Body
renderEmptyState={() => (
<div className="py-8 text-center text-sm text-muted">
{loading ? "加载中…" : "暂无记录"}
</div>
)}
>
{(data?.data ?? []).map((tx) => (
<Table.Row key={tx.id} id={tx.id}>
<Table.Cell className="font-mono text-sm">{tx.id}</Table.Cell>
<Table.Cell className="font-mono">{tx.chargePointIdentifier ?? "—"}</Table.Cell>
<Table.Cell>{tx.connectorNumber ?? "—"}</Table.Cell>
<Table.Cell className="font-mono">{tx.idTag}</Table.Cell>
<Table.Cell>
{tx.stopTimestamp ? (
<Chip color="success" size="sm" variant="soft">
</Chip>
) : (
<Chip color="warning" size="sm" variant="soft">
</Chip>
)}
</Table.Cell>
<Table.Cell className="whitespace-nowrap text-sm">
{new Date(tx.startTimestamp).toLocaleString("zh-CN")}
</Table.Cell>
<Table.Cell className="text-sm">
{formatDuration(tx.startTimestamp, tx.stopTimestamp)}
</Table.Cell>
<Table.Cell className="tabular-nums">
{tx.energyWh != null ? (tx.energyWh / 1000).toFixed(3) : "—"}
</Table.Cell>
<Table.Cell className="tabular-nums">
{tx.chargeAmount != null ? `¥${(tx.chargeAmount / 100).toFixed(2)}` : "—"}
</Table.Cell>
<Table.Cell>
{tx.stopReason ? (
<Chip color="default" size="sm" variant="soft">
{tx.stopReason}
</Chip>
) : tx.stopTimestamp ? (
<Chip color="default" size="sm" variant="soft">
Local
</Chip>
) : (
"—"
)}
</Table.Cell>
<Table.Cell>
<div className="flex items-center gap-1">
{!tx.stopTimestamp && (
<Modal>
<Button size="sm" variant="danger-soft" isDisabled={stoppingId === tx.id}>
{stoppingId === tx.id ? <Spinner size="sm" /> : "中止"}
</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">
#{tx.id}
</span>
<span className="font-mono">{tx.idTag}</span>
线
</p>
</Modal.Body>
<Modal.Footer className="flex justify-end gap-2">
<Button slot="close" variant="ghost">
</Button>
<Button
slot="close"
variant="danger"
isDisabled={stoppingId === tx.id}
onPress={() => handleStop(tx.id)}
>
{stoppingId === tx.id ? <Spinner size="sm" /> : "确认中止"}
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
)}
<Modal>
<Button
isIconOnly
size="sm"
variant="tertiary"
isDisabled={deletingId === tx.id}
>
{deletingId === tx.id ? (
<Spinner size="sm" />
) : (
<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">
#{tx.id}
</span>
<span className="font-mono">{tx.idTag}</span>
{!tx.stopTimestamp && "该记录仍进行中,删除同时将重置接口状态。"}
</p>
</Modal.Body>
<Modal.Footer className="flex justify-end gap-2">
<Button slot="close" variant="ghost">
</Button>
<Button
slot="close"
variant="danger"
isDisabled={deletingId === tx.id}
onPress={() => handleDelete(tx.id)}
>
{deletingId === tx.id ? <Spinner size="sm" /> : "确认删除"}
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
</div>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Content>
</Table.ScrollContainer>
{data && data.totalPages > 1 && (
<Table.Footer>
<Pagination size="sm">
<Pagination.Summary>
{(page - 1) * LIMIT + 1}{Math.min(page * LIMIT, data.total)} {data.total}{" "}
</Pagination.Summary>
<Pagination.Content>
<Pagination.Item>
<Pagination.Previous
isDisabled={page === 1}
onPress={() => setPage((p) => Math.max(1, p - 1))}
>
<Pagination.PreviousIcon />
</Pagination.Previous>
</Pagination.Item>
{pages.map((p) => (
<Pagination.Item key={p}>
<Pagination.Link isActive={p === page} onPress={() => setPage(p)}>
{p}
</Pagination.Link>
</Pagination.Item>
))}
<Pagination.Item>
<Pagination.Next
isDisabled={page === data.totalPages}
onPress={() => setPage((p) => Math.min(data.totalPages, p + 1))}
>
<Pagination.NextIcon />
</Pagination.Next>
</Pagination.Item>
</Pagination.Content>
</Pagination>
</Table.Footer>
)}
</Table>
</div>
);
}

View File

@@ -0,0 +1,363 @@
"use client";
import { useEffect, useState } from "react";
import {
Button,
Chip,
Input,
Label,
ListBox,
Modal,
Select,
Spinner,
Table,
TextField,
} from "@heroui/react";
import { Pencil } from "@gravity-ui/icons";
import { api, type UserRow } from "@/lib/api";
import { useSession } from "@/lib/auth-client";
type CreateForm = {
name: string;
email: string;
username: string;
password: string;
role: string;
};
type EditForm = {
name: string;
username: string;
role: string;
};
const emptyCreate: CreateForm = { name: "", email: "", username: "", password: "", role: "user" };
const ROLE_OPTIONS = [
{ key: "user", label: "用户" },
{ key: "admin", label: "管理员" },
];
export default function UsersPage() {
const { data: session } = useSession();
const currentUserId = session?.user?.id;
const [users, setUsers] = useState<UserRow[]>([]);
const [loading, setLoading] = useState(true);
const [updating, setUpdating] = useState<string | null>(null);
const [createForm, setCreateForm] = useState<CreateForm>(emptyCreate);
const [editTarget, setEditTarget] = useState<UserRow | null>(null);
const [editForm, setEditForm] = useState<EditForm>({ name: "", username: "", role: "user" });
const [saving, setSaving] = useState(false);
const load = async () => {
setLoading(true);
try {
setUsers(await api.users.list());
} catch {
// possibly not admin — show empty
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
const openEdit = (u: UserRow) => {
setEditTarget(u);
setEditForm({ name: u.name ?? "", username: u.username ?? "", role: u.role ?? "user" });
};
const handleCreate = async () => {
setSaving(true);
try {
await api.users.create({
name: createForm.name,
email: createForm.email,
password: createForm.password,
username: createForm.username || undefined,
role: createForm.role,
});
setCreateForm(emptyCreate);
await load();
} finally {
setSaving(false);
}
};
const handleEdit = async () => {
if (!editTarget) return;
setSaving(true);
try {
await api.users.update(editTarget.id, {
name: editForm.name || undefined,
username: editForm.username || null,
role: editForm.role,
});
await load();
} finally {
setSaving(false);
}
};
const toggleBan = async (u: UserRow) => {
setUpdating(u.id);
try {
await api.users.update(u.id, {
banned: !u.banned,
banReason: u.banned ? null : "管理员封禁",
});
await load();
} finally {
setUpdating(null);
}
};
const isEditingSelf = editTarget?.id === currentUserId;
return (
<div className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-xl font-semibold text-foreground"></h1>
<p className="mt-0.5 text-sm text-muted"> {users.length} </p>
</div>
<Modal>
<Button variant="secondary" onPress={() => setCreateForm(emptyCreate)}>
</Button>
<Modal.Backdrop>
<Modal.Container scroll="outside">
<Modal.Dialog className="sm:max-w-105">
<Modal.CloseTrigger />
<Modal.Header>
<Modal.Heading></Modal.Heading>
</Modal.Header>
<Modal.Body className="space-y-3">
<TextField fullWidth>
<Label className="text-sm font-medium"></Label>
<Input
placeholder="用户姓名"
value={createForm.name}
onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })}
/>
</TextField>
<TextField fullWidth>
<Label className="text-sm font-medium"></Label>
<Input
type="email"
placeholder="user@example.com"
value={createForm.email}
onChange={(e) => setCreateForm({ ...createForm, email: e.target.value })}
/>
</TextField>
<TextField fullWidth>
<Label className="text-sm font-medium"></Label>
<Input
className="font-mono"
placeholder="username"
value={createForm.username}
onChange={(e) => setCreateForm({ ...createForm, username: e.target.value })}
/>
</TextField>
<TextField fullWidth>
<Label className="text-sm font-medium"></Label>
<Input
type="password"
placeholder="••••••••"
value={createForm.password}
onChange={(e) => setCreateForm({ ...createForm, password: e.target.value })}
/>
</TextField>
<div className="flex flex-col gap-1">
<Label className="text-sm font-medium"></Label>
<Select
fullWidth
selectedKey={createForm.role}
onSelectionChange={(key) =>
setCreateForm({ ...createForm, role: String(key) })
}
>
<Select.Trigger>
<Select.Value />
<Select.Indicator />
</Select.Trigger>
<Select.Popover>
<ListBox>
{ROLE_OPTIONS.map((r) => (
<ListBox.Item key={r.key} id={r.key}>
{r.label}
</ListBox.Item>
))}
</ListBox>
</Select.Popover>
</Select>
</div>
</Modal.Body>
<Modal.Footer className="flex justify-end gap-2">
<Button slot="close" variant="ghost">
</Button>
<Button
isDisabled={
saving || !createForm.name || !createForm.email || !createForm.password
}
slot="close"
onPress={handleCreate}
>
{saving ? <Spinner size="sm" /> : "创建"}
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
</div>
<Table>
<Table.ScrollContainer>
<Table.Content aria-label="用户列表" className="min-w-187.5">
<Table.Header>
<Table.Column isRowHeader></Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
<Table.Column className="text-end"></Table.Column>
</Table.Header>
<Table.Body
renderEmptyState={() => (
<div className="py-8 text-center text-sm text-muted">
{loading ? "加载中…" : "暂无用户或无权限"}
</div>
)}
>
{users.map((u) => (
<Table.Row key={u.id} id={u.id}>
<Table.Cell>
<div className="font-medium">{u.name ?? "—"}</div>
</Table.Cell>
<Table.Cell className="text-sm">{u.email}</Table.Cell>
<Table.Cell className="font-mono text-sm">{u.username ?? "—"}</Table.Cell>
<Table.Cell>
<Chip
color={u.role === "admin" ? "success" : "warning"}
size="sm"
variant="soft"
>
{u.role === "admin" ? "管理员" : "用户"}
</Chip>
</Table.Cell>
<Table.Cell>
{u.banned ? (
<Chip color="danger" size="sm" variant="soft">
</Chip>
) : (
<Chip color="success" size="sm" variant="soft">
</Chip>
)}
</Table.Cell>
<Table.Cell className="text-sm">
{new Date(u.createdAt).toLocaleString("zh-CN")}
</Table.Cell>
<Table.Cell>
<div className="flex justify-end gap-1">
{/* Edit button */}
<Modal>
<Button isIconOnly size="sm" variant="tertiary" onPress={() => openEdit(u)}>
<Pencil className="size-4" />
</Button>
<Modal.Backdrop>
<Modal.Container scroll="outside">
<Modal.Dialog className="sm:max-w-105">
<Modal.CloseTrigger />
<Modal.Header>
<Modal.Heading></Modal.Heading>
</Modal.Header>
<Modal.Body className="space-y-3">
<TextField fullWidth>
<Label className="text-sm font-medium"></Label>
<Input
placeholder="用户姓名"
value={editForm.name}
onChange={(e) =>
setEditForm({ ...editForm, name: e.target.value })
}
/>
</TextField>
<TextField fullWidth>
<Label className="text-sm font-medium"></Label>
<Input
className="font-mono"
placeholder="username"
value={editForm.username}
onChange={(e) =>
setEditForm({ ...editForm, username: e.target.value })
}
/>
</TextField>
{!isEditingSelf && (
<div className="flex flex-col gap-1">
<Label className="text-sm font-medium"></Label>
<Select
fullWidth
selectedKey={editForm.role}
onSelectionChange={(key) =>
setEditForm({ ...editForm, role: String(key) })
}
>
<Select.Trigger>
<Select.Value />
<Select.Indicator />
</Select.Trigger>
<Select.Popover>
<ListBox>
{ROLE_OPTIONS.map((r) => (
<ListBox.Item key={r.key} id={r.key}>
{r.label}
</ListBox.Item>
))}
</ListBox>
</Select.Popover>
</Select>
</div>
)}
</Modal.Body>
<Modal.Footer className="flex justify-end gap-2">
<Button slot="close" variant="ghost">
</Button>
<Button isDisabled={saving} slot="close" onPress={handleEdit}>
{saving ? <Spinner size="sm" /> : "保存"}
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
{/* Ban / Unban button — disabled for current account */}
<Button
isDisabled={u.id === currentUserId || updating === u.id}
size="sm"
variant={u.banned ? "tertiary" : "danger-soft"}
onPress={() => toggleBan(u)}
>
{updating === u.id ? <Spinner size="sm" /> : u.banned ? "解封" : "封禁"}
</Button>
</div>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Content>
</Table.ScrollContainer>
</Table>
</div>
);
}