From 83e6ed2412decb397eba6d85c5110c014658dd5e Mon Sep 17 00:00:00 2001 From: Timothy Yin Date: Fri, 13 Mar 2026 11:51:06 +0800 Subject: [PATCH] feat(transactions): add transaction detail page with live energy and cost estimation feat(transactions): implement active transaction checks and idTag validation feat(id-tag): enhance idTag card with disabled state for active transactions fix(transactions): improve error handling and user feedback for transaction actions --- apps/csms/src/ocpp/actions/authorize.ts | 16 +- .../csms/src/ocpp/actions/stop-transaction.ts | 4 +- apps/csms/src/routes/transactions.ts | 70 +++- apps/web/app/dashboard/charge/page.tsx | 256 +++++++++--- .../app/dashboard/transactions/[id]/page.tsx | 369 ++++++++++++++++++ apps/web/app/dashboard/transactions/page.tsx | 86 +++- apps/web/components/id-tag-card.tsx | 6 +- 7 files changed, 747 insertions(+), 60 deletions(-) create mode 100644 apps/web/app/dashboard/transactions/[id]/page.tsx diff --git a/apps/csms/src/ocpp/actions/authorize.ts b/apps/csms/src/ocpp/actions/authorize.ts index b92e59f..2bc9ec0 100644 --- a/apps/csms/src/ocpp/actions/authorize.ts +++ b/apps/csms/src/ocpp/actions/authorize.ts @@ -1,7 +1,7 @@ -import { eq } from "drizzle-orm"; +import { and, eq, isNull } from "drizzle-orm"; import dayjs from "dayjs"; import { useDrizzle } from "@/lib/db.js"; -import { idTag } from "@/db/schema.js"; +import { idTag, transaction } from "@/db/schema.js"; import type { AuthorizeRequest, AuthorizeResponse, @@ -19,6 +19,7 @@ import type { export async function resolveIdTagInfo( idTagValue: string, checkBalance = true, + checkConcurrent = true, ): Promise { const db = useDrizzle(); const [tag] = await db.select().from(idTag).where(eq(idTag.idTag, idTagValue)).limit(1); @@ -31,6 +32,17 @@ export async function resolveIdTagInfo( if (tag.status !== "Accepted") { return { status: tag.status as IdTagInfo["status"] }; } + + // Enforce single active transaction per idTag. + if (checkConcurrent) { + const [activeTx] = await db + .select({ id: transaction.id }) + .from(transaction) + .where(and(eq(transaction.idTag, idTagValue), isNull(transaction.stopTimestamp))) + .limit(1); + if (activeTx) return { status: "ConcurrentTx" }; + } + // Reject if balance is zero or negative if (checkBalance && tag.balance <= 0) { return { status: "Blocked" }; diff --git a/apps/csms/src/ocpp/actions/stop-transaction.ts b/apps/csms/src/ocpp/actions/stop-transaction.ts index ef72993..bacb2f6 100644 --- a/apps/csms/src/ocpp/actions/stop-transaction.ts +++ b/apps/csms/src/ocpp/actions/stop-transaction.ts @@ -183,7 +183,9 @@ export async function handleStopTransaction( ); // Resolve idTagInfo for the stop tag if provided (no balance check — charging already occurred) - const idTagInfo = payload.idTag ? await resolveIdTagInfo(payload.idTag, false) : undefined; + const idTagInfo = payload.idTag + ? await resolveIdTagInfo(payload.idTag, false, false) + : undefined; return { idTagInfo }; } diff --git a/apps/csms/src/routes/transactions.ts b/apps/csms/src/routes/transactions.ts index 396d19a..1b1fee1 100644 --- a/apps/csms/src/routes/transactions.ts +++ b/apps/csms/src/routes/transactions.ts @@ -2,11 +2,12 @@ import { Hono } from "hono"; import { and, desc, eq, isNull, isNotNull, sql } from "drizzle-orm"; import dayjs from "dayjs"; import { useDrizzle } from "@/lib/db.js"; -import { transaction, chargePoint, connector, idTag, meterValue } from "@/db/schema.js"; +import { transaction, chargePoint, connector, idTag } from "@/db/schema.js"; import type { SampledValue } from "@/db/schema.js"; import { user } from "@/db/auth-schema.js"; import { ocppConnections } from "@/ocpp/handler.js"; import { OCPP_MESSAGE_TYPE } from "@/ocpp/types.js"; +import { resolveIdTagInfo } from "@/ocpp/actions/authorize.js"; import type { HonoEnv } from "@/types/hono.ts"; const app = new Hono(); @@ -42,15 +43,33 @@ app.post("/remote-start", async (c) => { ); } - // Non-admin: verify idTag belongs to current user and is Accepted + // Non-admin: verify idTag belongs to current user if (currentUser.role !== "admin") { const [tag] = await db - .select({ status: idTag.status }) + .select({ idTag: idTag.idTag }) .from(idTag) .where(and(eq(idTag.idTag, body.idTag.trim()), eq(idTag.userId, currentUser.id))) .limit(1); if (!tag) return c.json({ error: "idTag not found or not authorized" }, 403); - if (tag.status !== "Accepted") return c.json({ error: "idTag is not accepted" }, 400); + } + + // Reuse the same authorization logic as Authorize/StartTransaction. + const tagInfo = await resolveIdTagInfo(body.idTag.trim()); + if (tagInfo.status !== "Accepted") { + if (tagInfo.status === "ConcurrentTx") { + return c.json({ error: "ConcurrentTx: idTag already has an active transaction" }, 409); + } + return c.json({ error: `idTag rejected: ${tagInfo.status}` }, 400); + } + + // One idTag can only have one active transaction at a time. + const [activeTx] = await db + .select({ id: transaction.id }) + .from(transaction) + .where(and(eq(transaction.idTag, body.idTag.trim()), isNull(transaction.stopTimestamp))) + .limit(1); + if (activeTx) { + return c.json({ error: "ConcurrentTx: idTag already has an active transaction" }, 409); } // Verify charge point exists and is Accepted @@ -224,21 +243,64 @@ app.get("/:id", async (c) => { .select({ transaction, chargePointIdentifier: chargePoint.chargePointIdentifier, + connectorNumber: connector.connectorId, + feePerKwh: chargePoint.feePerKwh, + pricingMode: chargePoint.pricingMode, }) .from(transaction) .leftJoin(chargePoint, eq(transaction.chargePointId, chargePoint.id)) + .leftJoin(connector, eq(transaction.connectorId, connector.id)) .where(eq(transaction.id, id)) .limit(1); if (!row) return c.json({ error: "Not found" }, 404); + let liveEnergyWh: number | null = null; + let estimatedCost: number | null = null; + + // For active transactions, return live estimated energy/cost like the list endpoint. + if (!row.transaction.stopTimestamp) { + const latestRows = await db.execute<{ + sampled_values: SampledValue[]; + }>(sql` + SELECT sampled_values + FROM meter_value + WHERE transaction_id = ${id} + ORDER BY timestamp DESC + LIMIT 1 + `); + + const latest = latestRows.rows[0]; + if (latest) { + const svList = latest.sampled_values as SampledValue[]; + const energySv = svList.find( + (sv) => (!sv.measurand || sv.measurand === "Energy.Active.Import.Register") && !sv.phase, + ); + + if (energySv != null) { + const raw = parseFloat(energySv.value); + if (!Number.isNaN(raw) && row.transaction.startMeterValue != null) { + const latestMeterWh = energySv.unit === "kWh" ? raw * 1000 : raw; + liveEnergyWh = latestMeterWh - row.transaction.startMeterValue; + + if (liveEnergyWh > 0 && row.pricingMode === "fixed" && (row.feePerKwh ?? 0) > 0) { + estimatedCost = Math.ceil((liveEnergyWh * (row.feePerKwh ?? 0)) / 1000); + } + } + } + } + } + return c.json({ ...row.transaction, chargePointIdentifier: row.chargePointIdentifier, + connectorNumber: row.connectorNumber, energyWh: row.transaction.stopMeterValue != null ? row.transaction.stopMeterValue - row.transaction.startMeterValue : null, + liveEnergyWh, + estimatedCost, }); }); diff --git a/apps/web/app/dashboard/charge/page.tsx b/apps/web/app/dashboard/charge/page.tsx index f1d6afa..371e824 100644 --- a/apps/web/app/dashboard/charge/page.tsx +++ b/apps/web/app/dashboard/charge/page.tsx @@ -1,17 +1,17 @@ "use client"; -import { useState, useEffect, useRef, Fragment, Suspense } from "react"; +import { useState, useEffect, useRef, Fragment, Suspense, useMemo } from "react"; import { useSearchParams } from "next/navigation"; import { useQuery, useMutation } from "@tanstack/react-query"; -import { Button, Modal, Spinner } from "@heroui/react"; +import { Alert, AlertDialog, Button, Modal, Spinner } from "@heroui/react"; import { ThunderboltFill, CreditCard, Check, QrCode, Xmark } from "@gravity-ui/icons"; import jsQR from "jsqr"; import { api } from "@/lib/api"; +import { useSession } from "@/lib/auth-client"; import dayjs from "@/lib/dayjs"; -import { EvCharger, Plug } from "lucide-react"; +import { BanknoteArrowUp, EvCharger, Plug } from "lucide-react"; import Link from "next/link"; import { IdTagCard } from "@/components/id-tag-card"; -import router from "next/router"; // ── Status maps (same as charge-points page) ──────────────────────────────── @@ -221,6 +221,7 @@ function QrScanner({ onResult, onClose }: ScannerProps) { function ChargePageContent() { const searchParams = useSearchParams(); + const { data: sessionData } = useSession(); const [step, setStep] = useState(1); const [selectedCpId, setSelectedCpId] = useState(null); @@ -230,6 +231,12 @@ function ChargePageContent() { const [scanError, setScanError] = useState(null); const [startResult, setStartResult] = useState<"success" | "error" | null>(null); const [startError, setStartError] = useState(null); + const [startSnapshot, setStartSnapshot] = useState<{ + cpId: string; + chargePointIdentifier: string; + connectorId: number; + idTag: string; + } | null>(null); // Detect mobile const [isMobile, setIsMobile] = useState(false); @@ -263,8 +270,67 @@ function ChargePageContent() { queryFn: () => api.idTags.list().catch(() => []), }); + const { data: activeTransactions = [] } = useQuery({ + queryKey: ["transactions", "active", "idTagLock"], + queryFn: async () => { + const res = await api.transactions.list({ page: 1, limit: 200, status: "active" }); + return res.data; + }, + refetchInterval: 3_000, + }); + const selectedCp = chargePoints.find((cp) => cp.id === selectedCpId) ?? null; const myTags = idTags?.filter((t) => t.status === "Accepted") ?? []; + const activeTransactionsForCurrentUser = useMemo(() => { + const currentUserId = sessionData?.user?.id; + if (!currentUserId) return activeTransactions; + return activeTransactions.filter((tx) => tx.idTagUserId === currentUserId); + }, [activeTransactions, sessionData?.user?.id]); + + const activeCount = activeTransactionsForCurrentUser.length; + const activeDetailHref = + activeCount === 1 + ? `/dashboard/transactions/${activeTransactionsForCurrentUser[0].id}` + : "/dashboard/transactions?status=active"; + const activeIdTagSet = useMemo( + () => new Set(activeTransactionsForCurrentUser.map((tx) => tx.idTag)), + [activeTransactionsForCurrentUser], + ); + + useEffect(() => { + if (startResult !== "success" && selectedIdTag && activeIdTagSet.has(selectedIdTag)) { + setSelectedIdTag(null); + } + }, [selectedIdTag, activeIdTagSet, startResult]); + + const { data: startedTransactionId, isFetching: locatingTx } = useQuery({ + queryKey: [ + "latestStartedTx", + startSnapshot?.cpId, + startSnapshot?.connectorId, + startSnapshot?.idTag, + startResult, + ], + enabled: startResult === "success" && !!startSnapshot?.cpId && !!startSnapshot?.idTag, + queryFn: async () => { + if (!startSnapshot?.cpId || !startSnapshot?.idTag) return null; + const res = await api.transactions.list({ + page: 1, + limit: 30, + status: "active", + chargePointId: startSnapshot.cpId, + }); + + const matched = res.data.find( + (tx) => + tx.idTag === startSnapshot.idTag && tx.connectorNumber === startSnapshot.connectorId, + ); + + return matched?.id ?? null; + }, + refetchInterval: (query) => (query.state.data ? false : 1_500), + retry: false, + }); const startMutation = useMutation({ mutationFn: async () => { @@ -277,28 +343,45 @@ function ChargePageContent() { idTag: selectedIdTag, }); }, + onMutate: () => { + if (!selectedCp || selectedConnectorId === null || !selectedIdTag) return; + setStartSnapshot({ + cpId: selectedCp.id, + chargePointIdentifier: selectedCp.chargePointIdentifier, + connectorId: selectedConnectorId, + idTag: selectedIdTag, + }); + }, onSuccess: () => { setStartResult("success"); }, onError: (err: Error) => { setStartResult("error"); const msg = err.message ?? ""; - if (msg.includes("offline")) setStartError("充电桩当前不在线,请稍后再试"); - else if (msg.includes("not accepted")) setStartError("充电桩未启用,请联系管理员"); - else if (msg.includes("idTag")) setStartError("储值卡无效或无权使用"); - else setStartError("启动失败:" + msg); + const lowerMsg = msg.toLowerCase(); + + if (lowerMsg.includes("offline")) setStartError("充电桩当前不在线,请稍后再试"); + else if ( + lowerMsg.includes("chargepoint is not accepted") || + lowerMsg.includes("not accepted") + ) { + setStartError("充电桩未启用,请联系管理员"); + } else if (msg.includes("ConcurrentTx") || lowerMsg.includes("concurrent")) { + setStartError("该储值卡已有进行中的充电订单,请先结束后再发起"); + } else if (lowerMsg.includes("rejected: blocked")) { + setStartError("储值卡不可用或余额不足,请更换储值卡或先充值"); + } else if (lowerMsg.includes("rejected: expired")) { + setStartError("储值卡已过期,请联系管理员处理"); + } else if (lowerMsg.includes("rejected: invalid")) { + setStartError("储值卡无效,请确认卡号后重试"); + } else if (lowerMsg.includes("not found or not authorized")) { + setStartError("该储值卡无权使用或不存在"); + } else if (msg.includes("idTag") || lowerMsg.includes("idtag")) { + setStartError("储值卡状态不可用,无法启动充电"); + } else setStartError("启动失败:" + msg); }, }); - function resetAll() { - setStep(1); - setSelectedCpId(null); - setSelectedConnectorId(null); - setSelectedIdTag(null); - setStartResult(null); - setStartError(null); - } - function handleScanResult(raw: string) { setShowScanner(false); setScanError(null); @@ -334,29 +417,52 @@ function ChargePageContent() {
-

正在启动中

+

已发起充电

充电桩正在响应,稍候将自动开始

充电桩 - {selectedCp?.chargePointIdentifier} + + {startSnapshot?.chargePointIdentifier ?? selectedCp?.chargePointIdentifier} +
接口 - #{selectedConnectorId} + + #{startSnapshot?.connectorId ?? selectedConnectorId} +
储值卡 - {selectedIdTag} + + {startSnapshot?.idTag ?? selectedIdTag} +
- - - + {startedTransactionId ? ( + + + + ) : ( +
+ + + +

+ 正在生成订单,可先前往列表查看 +

+
+ )} ); } @@ -404,12 +510,67 @@ function ChargePageContent() { + { + if (!open) { + setStartResult(null); + setStartError(null); + setStartSnapshot(null); + } + }} + > + + + + + + + 启动失败 + + +

{startError ?? "启动失败,请稍后重试"}

+
+ + + +
+
+
+
+ {scanError && (
{scanError}
)} + {activeCount > 0 && ( + + + + 当前有 {activeCount} 笔进行中的充电 + + 同一张储值卡无法发起多笔充电订单,若要结束进行中的订单,请{" "} + + + + + + + )} + {/* Step bar */} setStep(t)} /> @@ -645,23 +806,29 @@ function ChargePageContent() { ) : (
- {myTags.map((tag) => ( - setSelectedIdTag(tag.idTag)} - /> - ))} -
- )} - - {startResult === "error" && ( -
- {startError ?? "启动失败,请重试"} + {myTags.map((tag) => { + const locked = activeIdTagSet.has(tag.idTag); + return ( +
+ { + if (!locked) setSelectedIdTag(tag.idTag); + }} + /> + {locked && ( +

+ 该卡有进行中的订单,暂不可再次启动 +

+ )} +
+ ); + })}
)} @@ -673,6 +840,7 @@ function ChargePageContent() { setStep(2); setStartResult(null); setStartError(null); + setStartSnapshot(null); }} > 上一步 diff --git a/apps/web/app/dashboard/transactions/[id]/page.tsx b/apps/web/app/dashboard/transactions/[id]/page.tsx new file mode 100644 index 0000000..4a40a69 --- /dev/null +++ b/apps/web/app/dashboard/transactions/[id]/page.tsx @@ -0,0 +1,369 @@ +"use client"; + +import { use, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; +import { Button, Chip, Modal, Spinner } from "@heroui/react"; +import { ArrowLeft, ArrowRotateRight, TrashBin } from "@gravity-ui/icons"; +import { APIError, api } from "@/lib/api"; +import { useSession } from "@/lib/auth-client"; +import dayjs from "@/lib/dayjs"; + +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 formatDateTime(iso: string | null | undefined): string { + if (!iso) return "—"; + return dayjs(iso).format("YYYY/M/D HH:mm:ss"); +} + +function formatEnergy(wh: number | null | undefined): string { + if (wh == null) return "—"; + return `${(wh / 1000).toFixed(3)} kWh`; +} + +function formatAmount(fen: number | null | undefined): string { + if (fen == null) return "—"; + return `¥${(fen / 100).toFixed(2)}`; +} + +export default function TransactionDetailPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = use(params); + const router = useRouter(); + const txId = Number(id); + const isValidId = Number.isInteger(txId) && txId > 0; + + const [stopping, setStopping] = useState(false); + const [deleting, setDeleting] = useState(false); + + const { data: sessionData } = useSession(); + const isAdmin = sessionData?.user?.role === "admin"; + + const { + data: tx, + isPending, + isFetching, + isError, + error, + refetch, + } = useQuery({ + queryKey: ["transaction", txId], + queryFn: () => api.transactions.get(txId), + enabled: isValidId, + refetchInterval: 3_000, + retry: false, + }); + + const handleStop = async () => { + if (!tx) return; + setStopping(true); + try { + await api.transactions.stop(tx.id); + await refetch(); + } finally { + setStopping(false); + } + }; + + const handleDelete = async () => { + if (!tx) return; + setDeleting(true); + try { + await api.transactions.delete(tx.id); + router.push("/dashboard/transactions"); + } finally { + setDeleting(false); + } + }; + + if (!isValidId) { + return ( +
+ + + 充电记录 + +

无效的交易编号。

+
+ ); + } + + if (isPending) { + return ( +
+ +
+ ); + } + + if (isError || !tx) { + const notFound = error instanceof APIError && error.status === 404; + return ( +
+ + + 充电记录 + +

+ {notFound ? "交易记录不存在。" : "加载交易记录失败。"} +

+
+ ); + } + + const energyWh = tx.energyWh ?? tx.liveEnergyWh; + const amountFen = tx.chargeAmount ?? tx.estimatedCost; + const isEstimatedEnergy = tx.energyWh == null && tx.liveEnergyWh != null; + const isEstimatedAmount = tx.chargeAmount == null && tx.estimatedCost != null; + + return ( +
+ + + 充电记录 + + +
+
+
+

充电订单#{tx.id}

+ {tx.stopTimestamp ? ( + + 已完成 + + ) : ( + + 进行中 + + )} + {isEstimatedAmount && ( + + 费用预估 + + )} +
+

+ 开始于 {formatDateTime(tx.startTimestamp)} · {tx.stopTimestamp ? "时长 " : ""} + {formatDuration(tx.startTimestamp, tx.stopTimestamp)} +

+
+ +
+ + + {!tx.stopTimestamp && ( + + + + + + + + 确认中止充电 + + +

+ 将远程中止充电交易{" "} + #{tx.id}。 +

+
+ + + + +
+
+
+
+ )} + + {isAdmin && ( + + + + + + + + 确认删除记录 + + +

+ 将永久删除交易 #{tx.id}。 +

+
+ + + + +
+
+
+
+ )} +
+
+ +
+
+

充电量

+
+

{formatEnergy(energyWh)}

+ {isEstimatedEnergy && ( + + 预估 + + )} +
+
+
+

总费用

+
+

{formatAmount(amountFen)}

+ {isEstimatedAmount && ( + + 预估 + + )} +
+
+
+

状态

+

+ {tx.stopTimestamp ? "已完成" : "进行中"} +

+
+
+

停止原因

+

{tx.stopReason ?? "—"}

+
+
+ +
+
+
+

交易信息

+
+
+
交易编号
+
#{tx.id}
+ +
储值卡
+
{tx.idTag}
+ +
桩编号
+
{tx.chargePointIdentifier ?? "—"}
+ +
连接器
+
{tx.connectorNumber ?? "—"}
+ +
开始时间
+
{formatDateTime(tx.startTimestamp)}
+ +
结束时间
+
{formatDateTime(tx.stopTimestamp)}
+ +
持续时长
+
+ {formatDuration(tx.startTimestamp, tx.stopTimestamp)} +
+
+
+ +
+
+

计量与费用

+
+
+
起始表计
+
+ {tx.startMeterValue != null ? `${tx.startMeterValue} Wh` : "—"} +
+ +
结束表计
+
+ {tx.stopMeterValue != null ? `${tx.stopMeterValue} Wh` : "—"} +
+ +
消耗电量
+
+ + {formatEnergy(energyWh)} + {isEstimatedEnergy && ( + + 预估 + + )} + +
+ +
电费
+
{formatAmount(tx.electricityFee)}
+ +
服务费
+
{formatAmount(tx.serviceFee)}
+ +
总费用
+
+ + {formatAmount(amountFen)} + {isEstimatedAmount && ( + + 预估 + + )} + +
+
+
+
+
+ ); +} diff --git a/apps/web/app/dashboard/transactions/page.tsx b/apps/web/app/dashboard/transactions/page.tsx index 3d5dff2..7a01d5f 100644 --- a/apps/web/app/dashboard/transactions/page.tsx +++ b/apps/web/app/dashboard/transactions/page.tsx @@ -1,8 +1,10 @@ "use client"; -import { useState } from "react"; +import { 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"; @@ -10,6 +12,39 @@ import dayjs from "@/lib/dayjs"; const LIMIT = 15; +const idTagRejectLabelMap: Record = { + Blocked: "卡片不可用或余额不足", + Expired: "卡片已过期", + Invalid: "卡片无效", + ConcurrentTx: "该卡已有进行中的订单", +}; + +const stopReasonLabelMap: Record = { + EmergencyStop: "紧急停止", + EVDisconnected: "车辆断开", + HardReset: "硬重启", + Local: "本地结束", + Other: "其他原因", + PowerLoss: "断电结束", + Reboot: "重启结束", + Remote: "远程结束", + SoftReset: "软重启", + UnlockCommand: "解锁结束", +}; + +const stopReasonColorMap: Record = { + 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"); @@ -20,13 +55,27 @@ function formatDuration(start: string, stop: string | null): string { } export default function TransactionsPage() { + 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">("all"); + const [status, setStatus] = useState<"all" | "active" | "completed">(initialStatus); const [stoppingId, setStoppingId] = useState(null); const [deletingId, setDeletingId] = useState(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, @@ -131,12 +180,23 @@ export default function TransactionsPage() { > {(data?.data ?? []).map((tx) => ( - {tx.id} + + + {tx.id} + + {tx.chargePointIdentifier ?? "—"} {tx.connectorNumber ?? "—"} {tx.idTag} - {tx.stopTimestamp ? ( + {tx.stopTimestamp && tx.stopReason === "DeAuthorized" ? ( + + 已拒绝 + + ) : tx.stopTimestamp ? ( 已完成 @@ -181,13 +241,23 @@ export default function TransactionsPage() { )} - {tx.stopReason ? ( - - {tx.stopReason} + {tx.stopReason === "DeAuthorized" ? ( +

+ {tx.idTagStatus && tx.idTagStatus !== "Accepted" + ? `${idTagRejectLabelMap[tx.idTagStatus] ?? tx.idTagStatus}` + : "鉴权失败"} +

+ ) : tx.stopReason ? ( + + {stopReasonLabelMap[tx.stopReason] ?? tx.stopReason} ) : tx.stopTimestamp ? ( - Local + 本地结束 ) : ( "—" diff --git a/apps/web/components/id-tag-card.tsx b/apps/web/components/id-tag-card.tsx index f6fc7f7..f204b34 100644 --- a/apps/web/components/id-tag-card.tsx +++ b/apps/web/components/id-tag-card.tsx @@ -34,6 +34,7 @@ type IdTagCardProps = { idTag: string; balance: number; isSelected?: boolean; + isDisabled?: boolean; /** 内容排列方式:余额、logo、卡号等信息元素的布局 */ layout?: CardLayoutName; /** 卡底装饰风格:纹理、光效、几何图形等视觉元素 */ @@ -45,6 +46,7 @@ export function IdTagCard({ idTag, balance, isSelected = false, + isDisabled = false, layout = "around", skin = "circles", onClick, @@ -55,10 +57,12 @@ export function IdTagCard({ return (