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