Files
helios-evcs/apps/web/app/dashboard/transactions/page.tsx

420 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { Suspense, useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Button, Chip, Modal, Pagination, Spinner, Table } from "@heroui/react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { TrashBin, ArrowRotateRight } from "@gravity-ui/icons";
import { api } from "@/lib/api";
import { useSession } from "@/lib/auth-client";
import dayjs from "@/lib/dayjs";
const LIMIT = 15;
const idTagRejectLabelMap: Record<string, string> = {
Blocked: "卡片不可用或余额不足",
Expired: "卡片已过期",
Invalid: "卡片无效",
ConcurrentTx: "该卡已有进行中的订单",
};
const stopReasonLabelMap: Record<string, string> = {
EmergencyStop: "紧急停止",
EVDisconnected: "车辆断开",
HardReset: "硬重启",
Local: "本地结束",
Other: "其他原因",
PowerLoss: "断电结束",
Reboot: "重启结束",
Remote: "远程结束",
SoftReset: "软重启",
UnlockCommand: "解锁结束",
};
const stopReasonColorMap: Record<string, "success" | "warning" | "danger" | "default"> = {
Local: "success",
EVDisconnected: "success",
Remote: "warning",
UnlockCommand: "warning",
EmergencyStop: "danger",
PowerLoss: "danger",
HardReset: "danger",
SoftReset: "warning",
Reboot: "warning",
Other: "default",
};
function formatDuration(start: string, stop: string | null): string {
if (!stop) return "进行中";
const min = dayjs(stop).diff(dayjs(start), "minute");
if (min < 60) return `${min} 分钟`;
const h = Math.floor(min / 60);
const m = min % 60;
return `${h}h ${m}m`;
}
function TransactionsPageContent() {
const searchParams = useSearchParams();
const { data: sessionData } = useSession();
const isAdmin = sessionData?.user?.role === "admin";
const statusFromQuery = searchParams.get("status");
const initialStatus: "all" | "active" | "completed" =
statusFromQuery === "active" || statusFromQuery === "completed" ? statusFromQuery : "all";
const [page, setPage] = useState(1);
const [status, setStatus] = useState<"all" | "active" | "completed">(initialStatus);
const [stoppingId, setStoppingId] = useState<number | null>(null);
const [deletingId, setDeletingId] = useState<number | null>(null);
useEffect(() => {
if (status !== initialStatus) {
setStatus(initialStatus);
setPage(1);
}
// We intentionally depend on searchParams-derived value only.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialStatus]);
const {
data,
isPending: loading,
isFetching: refreshing,
refetch,
} = useQuery({
queryKey: ["transactions", page, status],
queryFn: () =>
api.transactions.list({
page,
limit: LIMIT,
status: status === "all" ? undefined : status,
}),
refetchInterval: 3_000,
});
const handleStatusChange = (s: typeof status) => {
setStatus(s);
setPage(1);
};
const handleStop = async (id: number) => {
setStoppingId(id);
try {
await api.transactions.stop(id);
await refetch();
} finally {
setStoppingId(null);
}
};
const handleDelete = async (id: number) => {
setDeletingId(id);
try {
await api.transactions.delete(id);
await refetch();
} 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 items-center gap-2">
<Button
isIconOnly
size="sm"
variant="ghost"
isDisabled={refreshing}
onPress={() => refetch()}
aria-label="刷新"
>
<ArrowRotateRight className={`size-4 ${refreshing ? "animate-spin" : ""}`} />
</Button>
<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>
</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">
<Link
href={`/dashboard/transactions/${tx.id}`}
className="text-accent hover:text-accent/80"
>
{tx.id}
</Link>
</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 && tx.stopReason === "DeAuthorized" ? (
<Chip color="danger" size="sm" variant="soft">
</Chip>
) : 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">
{dayjs(tx.startTimestamp).format("YYYY/M/D HH:mm:ss")}
</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)
) : tx.liveEnergyWh != null ? (
<span className="inline-flex items-center gap-1">
{(tx.liveEnergyWh / 1000).toFixed(3)}
<span className="rounded bg-warning-soft px-1 py-0.5 text-[10px] font-semibold text-warning leading-none text-nowrap">
</span>
</span>
) : (
"—"
)}
</Table.Cell>
<Table.Cell className="tabular-nums">
{tx.chargeAmount != null ? (
`¥${(tx.chargeAmount / 100).toFixed(2)}`
) : tx.estimatedCost != null ? (
<span className="inline-flex items-center gap-1">
¥{(tx.estimatedCost / 100).toFixed(2)}
<span className="rounded bg-warning-soft px-1 py-0.5 text-[10px] font-semibold text-warning leading-none text-nowrap">
</span>
</span>
) : (
"—"
)}
</Table.Cell>
<Table.Cell>
{tx.stopReason === "DeAuthorized" ? (
<p className="text-xs font-medium text-muted text-nowrap">
{tx.idTagStatus && tx.idTagStatus !== "Accepted"
? `${idTagRejectLabelMap[tx.idTagStatus] ?? tx.idTagStatus}`
: "鉴权失败"}
</p>
) : tx.stopReason ? (
<Chip
color={stopReasonColorMap[tx.stopReason] ?? "default"}
size="sm"
variant="soft"
>
{stopReasonLabelMap[tx.stopReason] ?? tx.stopReason}
</Chip>
) : tx.stopTimestamp ? (
<Chip color="default" size="sm" variant="soft">
</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>
);
}
export default function TransactionsPage() {
return (
<Suspense
fallback={
<div className="flex h-48 items-center justify-center">
<Spinner />
</div>
}
>
<TransactionsPageContent />
</Suspense>
);
}