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

892 lines
36 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 { useState, useEffect, useRef, Fragment, Suspense, useMemo } from "react";
import { useSearchParams } from "next/navigation";
import { useQuery, useMutation } from "@tanstack/react-query";
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 { BanknoteArrowUp, EvCharger, Plug } from "lucide-react";
import Link from "next/link";
import { IdTagCard } from "@/components/id-tag-card";
// ── Status maps (same as charge-points page) ────────────────────────────────
const statusLabelMap: Record<string, string> = {
Available: "空闲中",
Charging: "充电中",
Preparing: "准备中",
Finishing: "结束中",
SuspendedEV: "EV 暂停",
SuspendedEVSE: "EVSE 暂停",
Reserved: "已预约",
Faulted: "故障",
Unavailable: "不可用",
Occupied: "占用",
};
// ── Step indicator ───────────────────────────────────────────────────────────
function StepBar({ step, onGoBack }: { step: number; onGoBack: (target: number) => void }) {
const labels = ["选择充电桩", "选择充电口", "选择储值卡"];
return (
<div className="flex w-full items-center">
{labels.map((label, i) => {
const idx = i + 1;
const isActive = step === idx;
const isDone = idx < step;
const isLast = i === labels.length - 1;
return (
<Fragment key={i}>
<button
type="button"
onClick={() => isDone && onGoBack(idx)}
disabled={!isDone}
className={[
"flex shrink-0 flex-col items-center gap-1.5 py-1 min-w-16",
isDone ? "cursor-pointer active:opacity-70" : "cursor-default",
].join(" ")}
>
<span
className={[
"flex size-8 items-center justify-center rounded-full text-sm font-bold ring-2 ring-offset-2 ring-offset-background transition-all",
isActive
? "bg-accent text-accent-foreground ring-accent shadow-md shadow-accent/30"
: isDone
? "bg-success text-white ring-success"
: "bg-surface-tertiary text-muted ring-transparent",
].join(" ")}
>
{isDone ? <Check className="size-4" /> : idx}
</span>
<span
className={[
"text-[11px] font-semibold leading-none whitespace-nowrap tracking-tight mt-1",
isActive ? "text-accent" : isDone ? "text-success" : "text-muted",
].join(" ")}
>
{label}
</span>
</button>
{!isLast && (
<div className="flex-1 mb-3.5">
<span
className={[
"block h-0.5 w-full rounded-full transition-colors duration-300",
isDone ? "bg-success" : "bg-border",
].join(" ")}
/>
</div>
)}
</Fragment>
);
})}
</div>
);
}
// ── QR Scanner ───────────────────────────────────────────────────────────────
type ScannerProps = {
onResult: (raw: string) => void;
onClose: () => void;
};
function QrScanner({ onResult, onClose }: ScannerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const scanningRef = useRef(true);
const mountedRef = useRef(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
mountedRef.current = true;
scanningRef.current = true;
let detector: any = null;
async function start() {
let stream: MediaStream;
try {
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "environment" },
});
} catch (err: any) {
if (mountedRef.current) setError("无法访问摄像头:" + (err?.message ?? "未知错误"));
return;
}
if (!mountedRef.current) {
stream.getTracks().forEach((t) => t.stop());
return;
}
streamRef.current = stream;
if (videoRef.current) {
videoRef.current.srcObject = stream;
try {
await videoRef.current.play();
} catch (err: any) {
// AbortError fires when the element is removed mid-play (e.g. Modal animation).
// It is not a real error — just bail out silently.
if (err?.name === "AbortError") return;
if (mountedRef.current) setError("无法播放摄像头画面:" + (err?.message ?? "未知错误"));
return;
}
}
if (!mountedRef.current) return;
const useNative = "BarcodeDetector" in window;
if (useNative) {
detector = new (window as any).BarcodeDetector({ formats: ["qr_code"] });
}
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const scan = async () => {
if (!scanningRef.current || !videoRef.current) return;
try {
if (useNative) {
const codes: Array<{ rawValue: string }> = await detector.detect(videoRef.current);
if (codes.length > 0) {
onResult(codes[0].rawValue);
return;
}
} else if (ctx) {
const video = videoRef.current;
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const code = jsQR(imageData.data, imageData.width, imageData.height);
if (code) {
onResult(code.data);
return;
}
}
} catch {}
requestAnimationFrame(scan);
};
requestAnimationFrame(scan);
}
start();
return () => {
mountedRef.current = false;
scanningRef.current = false;
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
};
}, [onResult]);
return (
<div className="relative h-full w-full overflow-hidden bg-black">
{error ? (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<p className="text-sm text-danger">{error}</p>
</div>
) : (
<>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video ref={videoRef} className="h-full w-full object-cover" playsInline muted />
{/* Aim overlay */}
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="size-56 rounded-2xl border-2 border-white/80 shadow-[0_0_0_9999px_rgba(0,0,0,0.45)]" />
</div>
<p className="absolute bottom-8 left-0 right-0 text-center text-sm text-white/80">
</p>
</>
)}
{/* Close button — overlaid top-right */}
<button
type="button"
onClick={onClose}
className="absolute right-4 top-4 flex size-9 items-center justify-center rounded-full bg-black/50 text-white backdrop-blur-sm hover:bg-black/70"
aria-label="关闭扫描"
>
<Xmark className="size-5" />
</button>
</div>
);
}
// ── Main Page ────────────────────────────────────────────────────────────────
function ChargePageContent() {
const searchParams = useSearchParams();
const { data: sessionData } = useSession();
const isAdmin = sessionData?.user?.role === "admin";
const [step, setStep] = useState(1);
const [selectedCpId, setSelectedCpId] = useState<string | null>(null);
const [selectedConnectorId, setSelectedConnectorId] = useState<number | null>(null);
const [selectedIdTag, setSelectedIdTag] = useState<string | null>(null);
const [showScanner, setShowScanner] = useState(false);
const [scanError, setScanError] = useState<string | null>(null);
const [startResult, setStartResult] = useState<"success" | "error" | null>(null);
const [startError, setStartError] = useState<string | null>(null);
const [startSnapshot, setStartSnapshot] = useState<{
cpId: string;
chargePointIdentifier: string;
deviceName: string | null;
connectorId: number;
idTag: string;
} | null>(null);
// Detect mobile
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
setIsMobile(/Mobi|Android|iPhone|iPad/i.test(navigator.userAgent));
}, []);
// Pre-fill from URL params (QR code redirect)
useEffect(() => {
const cpId = searchParams.get("cpId");
const connector = searchParams.get("connector");
if (cpId) {
setSelectedCpId(cpId);
if (connector && !Number.isNaN(Number(connector))) {
setSelectedConnectorId(Number(connector));
setStep(3);
} else {
setStep(2);
}
}
}, [searchParams]);
const { data: chargePoints = [], isLoading: cpLoading } = useQuery({
queryKey: ["chargePoints"],
queryFn: () => api.chargePoints.list().catch(() => []),
refetchInterval: 5_000,
});
const { data: idTags = [], isLoading: tagsLoading } = useQuery({
queryKey: ["idTags", "list"],
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 } = 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 () => {
if (!selectedCp || selectedConnectorId === null || !selectedIdTag) {
throw new Error("请先完成所有选择");
}
return api.transactions.remoteStart({
chargePointIdentifier: selectedCp.chargePointIdentifier,
connectorId: selectedConnectorId,
idTag: selectedIdTag,
});
},
onMutate: () => {
if (!selectedCp || selectedConnectorId === null || !selectedIdTag) return;
setStartSnapshot({
cpId: selectedCp.id,
chargePointIdentifier: selectedCp.chargePointIdentifier,
deviceName: selectedCp.deviceName,
connectorId: selectedConnectorId,
idTag: selectedIdTag,
});
},
onSuccess: () => {
setStartResult("success");
},
onError: (err: Error) => {
setStartResult("error");
const msg = err.message ?? "";
const lowerMsg = msg.toLowerCase();
if (lowerMsg.includes("command channel is unavailable") || lowerMsg.includes("offline")) {
setStartError("充电桩下行通道不可用,请稍后再试");
} else if (lowerMsg.includes("did not confirm remotestarttransaction in time")) {
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 handleScanResult(raw: string) {
setShowScanner(false);
setScanError(null);
try {
// Support both helios:// scheme and https:// web URLs
const url = new URL(raw);
const cpId = url.searchParams.get("cpId");
const connector = url.searchParams.get("connector");
if (!cpId) {
setScanError("二维码内容无效,缺少充电桩信息");
return;
}
setSelectedCpId(cpId);
if (connector && !Number.isNaN(Number(connector))) {
setSelectedConnectorId(Number(connector));
setStep(3);
} else {
setSelectedConnectorId(null);
setStep(2);
}
} catch {
setScanError("二维码内容格式错误");
}
}
// ── Success screen ─────────────────────────────────────────────────────────
if (startResult === "success") {
return (
<div className="flex flex-col items-center justify-center gap-8 py-16 text-center">
<div className="relative">
<div className="flex size-24 items-center justify-center rounded-full bg-success-soft ring-8 ring-success/10">
<Check className="size-12 text-success" />
</div>
</div>
<div className="space-y-2">
<h2 className="text-2xl font-bold text-foreground"></h2>
<p className="text-sm text-muted leading-relaxed"></p>
</div>
<div className="w-full max-w-xs rounded-2xl border border-border bg-surface p-4 text-left space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted"></span>
<span className="font-medium text-foreground">
{startSnapshot?.deviceName ?? startSnapshot?.chargePointIdentifier ?? selectedCp?.deviceName ?? selectedCp?.chargePointIdentifier}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted"></span>
<span className="font-medium text-foreground">
#{startSnapshot?.connectorId ?? selectedConnectorId}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted"></span>
<span className="font-mono font-medium text-foreground">
{startSnapshot?.idTag ?? selectedIdTag}
</span>
</div>
</div>
{startedTransactionId ? (
<Link
href={`/dashboard/transactions/${startedTransactionId}`}
className="w-full max-w-xs"
>
<Button size="lg" className="w-full">
<BanknoteArrowUp className="size-4" />
</Button>
</Link>
) : (
<div className="w-full max-w-xs space-y-2">
<Link href="/dashboard/transactions?status=active" className="block">
<Button size="lg" variant="secondary" className="w-full">
<BanknoteArrowUp className="size-4" />
</Button>
</Link>
<p className="text-xs text-muted text-center inline-flex w-full items-center justify-center gap-1.5">
</p>
</div>
)}
</div>
);
}
// ── Main UI ────────────────────────────────────────────────────────────────
return (
<div className="space-y-5 pb-4">
{/* Header */}
<div className="flex items-center justify-between gap-3">
<div>
<h1 className="text-xl font-bold text-foreground"></h1>
<p className="mt-0.5 text-sm text-muted"></p>
</div>
{/* QR scan button — mobile only */}
{isMobile && (
<button
type="button"
onClick={() => {
setScanError(null);
setShowScanner(true);
}}
disabled={showScanner}
className="flex shrink-0 flex-col items-center gap-1 rounded-2xl border border-border bg-surface px-3.5 py-2.5 text-foreground shadow-sm active:opacity-70 disabled:opacity-40"
>
<QrCode className="size-5" />
<span className="text-[10px] font-medium leading-none"></span>
</button>
)}
</div>
{/* QR Scanner Modal */}
<Modal
isOpen={showScanner}
onOpenChange={(open) => {
if (!open) setShowScanner(false);
}}
>
<Modal.Backdrop>
<Modal.Container scroll="outside" size="full">
<Modal.Dialog className="h-full overflow-hidden p-0">
<QrScanner onResult={handleScanResult} onClose={() => setShowScanner(false)} />
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
<AlertDialog
isOpen={startResult === "error" && !!startError}
onOpenChange={(open) => {
if (!open) {
setStartResult(null);
setStartError(null);
setStartSnapshot(null);
}
}}
>
<AlertDialog.Backdrop variant="blur">
<AlertDialog.Container size="sm">
<AlertDialog.Dialog>
<AlertDialog.Header>
<AlertDialog.CloseTrigger />
<AlertDialog.Icon status="danger" />
<AlertDialog.Heading></AlertDialog.Heading>
</AlertDialog.Header>
<AlertDialog.Body className="overflow-hidden">
<p>{startError ?? "启动失败,请稍后重试"}</p>
</AlertDialog.Body>
<AlertDialog.Footer>
<Button
slot="close"
variant="secondary"
onPress={() => {
setStartResult(null);
setStartError(null);
}}
>
</Button>
</AlertDialog.Footer>
</AlertDialog.Dialog>
</AlertDialog.Container>
</AlertDialog.Backdrop>
</AlertDialog>
{scanError && (
<div className="rounded-xl border border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">
{scanError}
</div>
)}
{activeCount > 0 && (
<Alert status="accent">
<Alert.Indicator />
<Alert.Content>
<Alert.Title> {activeCount} </Alert.Title>
<Alert.Description>
{" "}
<Link href={activeDetailHref} className="underline">
<Button variant="secondary" size="sm" className="px-1.5 h-6 rounded-xl">
</Button>
</Link>
</Alert.Description>
</Alert.Content>
</Alert>
)}
{/* Step bar */}
<StepBar step={step} onGoBack={(t) => setStep(t)} />
{/* ── Step 1: Select charge point ──────────────────────────────── */}
{step === 1 && (
<div className="space-y-3">
{cpLoading ? (
<div className="flex justify-center py-16">
<Spinner />
</div>
) : chargePoints.filter((cp) => cp.registrationStatus === "Accepted").length === 0 ? (
<div className="rounded-2xl border border-border px-6 py-14 text-center">
<Plug className="mx-auto mb-3 size-10 text-muted" />
<p className="font-medium text-foreground"></p>
<p className="mt-1 text-sm text-muted"></p>
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{chargePoints
.filter((cp) => cp.registrationStatus === "Accepted")
.map((cp) => {
const online =
cp.transportStatus === "online" &&
!!cp.lastHeartbeatAt &&
dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120;
const commandChannelUnavailable = cp.transportStatus === "unavailable";
const availableCount = cp.connectors.filter(
(c) => c.status === "Available",
).length;
const disabled = !online || availableCount === 0;
return (
<button
key={cp.id}
type="button"
disabled={disabled}
onClick={() => {
setSelectedCpId(cp.id);
setSelectedConnectorId(null);
setStep(2);
}}
className={[
"flex flex-col gap-3 rounded-2xl border p-4 text-left transition-all",
disabled
? "cursor-not-allowed opacity-40 border-border bg-surface-secondary"
: selectedCpId === cp.id
? "border-accent bg-accent/8 shadow-sm shadow-accent-soft-hover active:scale-[0.98]"
: "cursor-pointer border-border bg-surface hover:border-accent/60 hover:bg-accent/5 active:scale-[0.98]",
].join(" ")}
>
{/* Top row: name + status */}
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="font-semibold text-foreground truncate leading-tight">
{cp.deviceName ?? cp.chargePointIdentifier}
</span>
{isAdmin && cp.deviceName && (
<span className="font-mono text-xs text-muted truncate">
{cp.chargePointIdentifier}
</span>
)}
{(cp.chargePointVendor || cp.chargePointModel) && (
<span className="text-xs text-muted truncate">
{[cp.chargePointVendor, cp.chargePointModel]
.filter(Boolean)
.join(" · ")}
</span>
)}
</div>
<span
className={[
"shrink-0 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold",
online
? "bg-success/12 text-success"
: commandChannelUnavailable
? "bg-warning/12 text-warning"
: "bg-surface-tertiary text-muted",
].join(" ")}
>
<span
className={`size-1.5 rounded-full ${
online ? "bg-success" : commandChannelUnavailable ? "bg-warning" : "bg-muted"
}`}
/>
{online ? "在线" : commandChannelUnavailable ? "通道异常" : "离线"}
</span>
</div>
{/* Bottom row: connectors + fee */}
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-sm text-muted">
<Plug className="size-3.5 shrink-0" />
<span>
<span
className={availableCount > 0 ? "font-semibold text-foreground" : ""}
>
{availableCount}
</span>
/{cp.connectors.length}
</span>
</span>
{cp.pricingMode === "tou" ? (
<span className="text-sm font-medium text-accent"></span>
) : cp.feePerKwh > 0 ? (
<span className="text-sm font-medium text-foreground">
¥{(cp.feePerKwh / 100).toFixed(2)}
<span className="text-xs text-muted font-normal">/kWh</span>
</span>
) : (
<span className="text-sm font-semibold text-success"></span>
)}
</div>
</button>
);
})}
</div>
)}
</div>
)}
{/* ── Step 2: Select connector ──────────────────────────────────── */}
{step === 2 && (
<div className="space-y-4">
{/* Context pill */}
{selectedCp && (
<div className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface px-3 py-1.5 text-xs">
<EvCharger className="size-3.5 text-muted" />
<span className="text-muted"></span>
<span className="font-semibold text-foreground">
{selectedCp.deviceName ?? selectedCp.chargePointIdentifier}
</span>
</div>
)}
{selectedCp && selectedCp.connectors.filter((c) => c.connectorId > 0).length === 0 ? (
<div className="rounded-2xl border border-border px-6 py-14 text-center">
<Plug className="mx-auto mb-3 size-10 text-muted" />
<p className="font-medium text-foreground"></p>
</div>
) : (
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
{selectedCp?.connectors
.filter((c) => c.connectorId > 0)
.sort((a, b) => a.connectorId - b.connectorId)
.map((conn) => {
const available = conn.status === "Available";
return (
<button
key={conn.id}
type="button"
disabled={!available}
onClick={() => {
if (available) {
setSelectedConnectorId(conn.connectorId);
setStep(3);
}
}}
className={[
"relative flex flex-col items-center gap-3 rounded-2xl border py-5 px-3 text-center transition-all",
!available
? "cursor-not-allowed opacity-40 border-border bg-surface-secondary"
: selectedConnectorId === conn.connectorId
? "border-accent bg-accent/8 shadow-sm shadow-accent-soft-hover active:scale-[0.97]"
: "cursor-pointer border-border bg-surface hover:border-accent/60 hover:bg-accent/5 active:scale-[0.97]",
].join(" ")}
>
<span
className={[
"flex size-12 items-center justify-center rounded-full text-xl font-bold",
available
? "bg-success/12 text-success"
: "bg-surface-tertiary text-muted",
].join(" ")}
>
{conn.connectorId}
</span>
<div className="space-y-0.5">
<p className="text-sm font-semibold text-foreground">
#{conn.connectorId}
</p>
<p
className={[
"text-xs font-medium",
conn.status === "Available" ? "text-success" : "text-muted",
].join(" ")}
>
{statusLabelMap[conn.status] ?? conn.status}
</p>
</div>
{selectedConnectorId === conn.connectorId && (
<span className="absolute right-2 top-2 flex size-5 items-center justify-center rounded-full bg-accent">
<Check className="size-3 text-white" />
</span>
)}
</button>
);
})}
</div>
)}
<Button variant="secondary" size="sm" onPress={() => setStep(1)}>
</Button>
</div>
)}
{/* ── Step 3: Select ID tag + start ────────────────────────────── */}
{step === 3 && (
<div className="space-y-4">
{/* Context pills */}
<div className="flex flex-wrap gap-2">
{selectedCp && (
<div className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface px-3 py-1.5 text-xs">
<EvCharger className="size-3.5 text-muted" />
<span className="text-muted"></span>
<span className="font-semibold text-foreground">
{selectedCp.deviceName ?? selectedCp.chargePointIdentifier}
</span>
</div>
)}
{selectedConnectorId !== null && (
<div className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface px-3 py-1.5 text-xs">
<Plug className="size-3.5 text-muted" />
<span className="text-muted"></span>
<span className="font-semibold text-foreground">#{selectedConnectorId}</span>
</div>
)}
</div>
<p className="text-sm font-semibold text-foreground"></p>
{tagsLoading ? (
<div className="flex justify-center py-16">
<Spinner />
</div>
) : myTags.length === 0 ? (
<div className="rounded-2xl border border-border px-6 py-14 text-center">
<CreditCard className="mx-auto mb-3 size-10 text-muted" />
<p className="font-medium text-foreground"></p>
<p className="mt-1 text-sm text-muted">
<Link href="/dashboard/id-tags" className="text-accent hover:underline">
</Link>
</p>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{myTags.map((tag) => {
const locked = activeIdTagSet.has(tag.idTag);
return (
<div key={tag.idTag} className="space-y-2">
<IdTagCard
idTag={tag.idTag}
balance={tag.balance}
layout={tag.cardLayout ?? undefined}
skin={tag.cardSkin ?? undefined}
isSelected={selectedIdTag === tag.idTag}
isDisabled={locked}
onClick={() => {
if (!locked) setSelectedIdTag(tag.idTag);
}}
/>
{locked && (
<p className="px-1 text-xs font-medium text-warning">
</p>
)}
</div>
);
})}
</div>
)}
{/* Action bar */}
<div className="flex gap-3 pt-1">
<Button
variant="secondary"
onPress={() => {
setStep(2);
setStartResult(null);
setStartError(null);
setStartSnapshot(null);
}}
>
</Button>
<Button
className="flex-1"
isDisabled={!selectedIdTag || startMutation.isPending}
onPress={() => startMutation.mutate()}
>
{startMutation.isPending ? (
<Spinner size="sm" />
) : (
<ThunderboltFill className="size-4" />
)}
{startMutation.isPending ? "发送中…" : "启动充电"}
</Button>
</div>
</div>
)}
</div>
);
}
export default function ChargePage() {
return (
<Suspense>
<ChargePageContent />
</Suspense>
);
}