304 lines
12 KiB
TypeScript
304 lines
12 KiB
TypeScript
"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";
|
||
import { useSession } from "@/lib/auth-client";
|
||
|
||
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: 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");
|
||
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>
|
||
)}
|
||
{isAdmin && (
|
||
<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>
|
||
);
|
||
}
|