Files
helios-evcs/apps/web/app/dashboard/charge/page.tsx
Timothy Yin 02a361488b feat(api): add stats chart endpoint for admin access with time series data
feat(dayjs): integrate dayjs for date handling and formatting across the application
refactor(routes): update date handling in id-tags, transactions, users, and dashboard routes to use dayjs
style(globals): improve CSS variable definitions for better readability and consistency
deps: add dayjs as a dependency for date manipulation
2026-03-11 21:34:21 +08:00

629 lines
23 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 } from "react";
import { useSearchParams } from "next/navigation";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Button, Chip, Modal, Spinner } from "@heroui/react";
import {
ThunderboltFill,
PlugConnection,
CreditCard,
Check,
QrCode,
Xmark,
} from "@gravity-ui/icons";
import { api } from "@/lib/api";
import dayjs from "@/lib/dayjs";
// ── Status maps (same as charge-points page) ────────────────────────────────
const statusLabelMap: Record<string, string> = {
Available: "空闲中",
Charging: "充电中",
Preparing: "准备中",
Finishing: "结束中",
SuspendedEV: "EV 暂停",
SuspendedEVSE: "EVSE 暂停",
Reserved: "已预约",
Faulted: "故障",
Unavailable: "不可用",
Occupied: "占用",
};
const statusDotClass: Record<string, string> = {
Available: "bg-success",
Charging: "bg-accent animate-pulse",
Preparing: "bg-warning animate-pulse",
Finishing: "bg-warning",
SuspendedEV: "bg-warning",
SuspendedEVSE: "bg-warning",
Reserved: "bg-warning",
Faulted: "bg-danger",
Unavailable: "bg-danger",
Occupied: "bg-warning",
};
// ── Step indicator ───────────────────────────────────────────────────────────
function StepBar({ step, onGoBack }: { step: number; onGoBack: (target: number) => void }) {
const labels = ["选择充电桩", "选择充电口", "选择储值卡"];
return (
<div className="flex w-full items-start">
{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-2",
isDone ? "cursor-pointer" : "cursor-default",
].join(" ")}
>
<span
className={[
"flex size-7 items-center justify-center rounded-full text-xs font-semibold ring-2 ring-offset-2 ring-offset-background transition-all",
isActive
? "bg-accent text-accent-foreground ring-accent"
: isDone
? "bg-success text-white ring-success"
: "bg-surface-tertiary text-muted ring-transparent",
].join(" ")}
>
{isDone ? <Check className="size-3.5" /> : idx}
</span>
<span
className={[
"text-[11px] font-medium leading-none whitespace-nowrap",
isActive ? "text-accent" : isDone ? "text-foreground" : "text-muted",
].join(" ")}
>
{label}
</span>
</button>
{!isLast && (
<div className="flex-1 pt-3.5">
<span
className={[
"block h-px w-full transition-colors",
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;
if (!("BarcodeDetector" in window)) {
if (mountedRef.current) setError("当前浏览器不支持实时扫描,请升级至最新版本");
return;
}
detector = new (window as any).BarcodeDetector({ formats: ["qr_code"] });
const scan = async () => {
if (!scanningRef.current || !videoRef.current) return;
try {
const codes: Array<{ rawValue: string }> = await detector.detect(videoRef.current);
if (codes.length > 0) {
onResult(codes[0].rawValue);
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 ────────────────────────────────────────────────────────────────
export default function ChargePage() {
const searchParams = useSearchParams();
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);
// 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"],
queryFn: () => api.idTags.list().catch(() => []),
});
const selectedCp = chargePoints.find((cp) => cp.id === selectedCpId) ?? null;
const myTags = idTags.filter((t) => t.status === "Accepted");
const startMutation = useMutation({
mutationFn: async () => {
if (!selectedCp || selectedConnectorId === null || !selectedIdTag) {
throw new Error("请先完成所有选择");
}
return api.transactions.remoteStart({
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);
},
});
function resetAll() {
setStep(1);
setSelectedCpId(null);
setSelectedConnectorId(null);
setSelectedIdTag(null);
setStartResult(null);
setStartError(null);
}
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-6 py-20 text-center">
<div className="flex size-16 items-center justify-center rounded-full bg-success/10">
<Check className="size-8 text-success" />
</div>
<div>
<h2 className="text-xl font-semibold text-foreground"></h2>
<p className="mt-1.5 text-sm text-muted">
<br />
"充电记录"
</p>
</div>
<Button onPress={resetAll}></Button>
</div>
);
}
// ── Main UI ────────────────────────────────────────────────────────────────
return (
<div className="space-y-6">
{/* Header */}
<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"></p>
</div>
{/* QR scan button — mobile only */}
{isMobile && (
<Button
size="sm"
variant="secondary"
onPress={() => {
setScanError(null);
setShowScanner(true);
}}
isDisabled={showScanner}
>
<QrCode className="size-4" />
</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>
{scanError && <p className="text-sm text-danger">{scanError}</p>}
{/* 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-12">
<Spinner />
</div>
) : chargePoints.filter((cp) => cp.registrationStatus === "Accepted").length === 0 ? (
<div className="rounded-xl border border-border px-6 py-12 text-center">
<PlugConnection className="mx-auto mb-2 size-8 text-muted" />
<p className="text-sm text-muted"></p>
</div>
) : (
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{chargePoints
.filter((cp) => cp.registrationStatus === "Accepted")
.map((cp) => {
const online =
!!cp.lastHeartbeatAt &&
dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120;
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-2.5 rounded-xl border p-4 text-left transition-all",
disabled
? "cursor-not-allowed opacity-50 border-border"
: "cursor-pointer border-border hover:border-accent hover:bg-accent/5",
selectedCpId === cp.id ? "border-accent bg-accent/10" : "",
].join(" ")}
>
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-foreground truncate">
{cp.chargePointIdentifier}
</span>
<Chip size="sm" color={online ? "success" : "default"} variant="soft">
{online ? "在线" : "离线"}
</Chip>
</div>
{(cp.chargePointVendor || cp.chargePointModel) && (
<span className="text-xs text-muted">
{[cp.chargePointVendor, cp.chargePointModel].filter(Boolean).join(" · ")}
</span>
)}
<div className="flex flex-wrap items-center gap-2">
<span className="flex items-center gap-1 text-xs text-muted">
<PlugConnection className="size-3" />
{availableCount}/{cp.connectors.length}
</span>
{cp.feePerKwh > 0 && (
<span className="text-xs text-muted">
· ¥{(cp.feePerKwh / 100).toFixed(2)}/kWh
</span>
)}
{cp.feePerKwh === 0 && <span className="text-xs text-success">· </span>}
</div>
</button>
);
})}
</div>
)}
</div>
)}
{/* ── Step 2: Select connector ──────────────────────────────────── */}
{step === 2 && (
<div className="space-y-3">
{selectedCp && (
<p className="text-sm text-muted">
<span className="font-medium text-foreground">
{selectedCp.chargePointIdentifier}
</span>
</p>
)}
{selectedCp && selectedCp.connectors.filter((c) => c.connectorId > 0).length === 0 ? (
<div className="rounded-xl border border-border px-6 py-12 text-center">
<PlugConnection className="mx-auto mb-2 size-8 text-muted" />
<p className="text-sm text-muted"></p>
</div>
) : (
<div className="grid gap-2 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={[
"flex flex-col gap-2 rounded-xl border p-4 text-left transition-all",
!available
? "cursor-not-allowed opacity-50 border-border"
: "cursor-pointer border-border hover:border-accent hover:bg-accent/5",
selectedConnectorId === conn.connectorId
? "border-accent bg-accent/10"
: "",
].join(" ")}
>
<div className="flex items-center justify-between">
<span className="font-medium text-foreground">
#{conn.connectorId}
</span>
<span
className={`size-2 rounded-full ${statusDotClass[conn.status] ?? "bg-warning"}`}
/>
</div>
<span className="text-xs text-muted">
{statusLabelMap[conn.status] ?? conn.status}
</span>
</button>
);
})}
</div>
)}
</div>
)}
{/* ── Step 3: Select ID tag + start ────────────────────────────── */}
{step === 3 && (
<div className="space-y-5">
<div className="flex flex-wrap gap-3 text-sm text-muted">
{selectedCp && (
<span>
<span className="font-medium text-foreground">
{selectedCp.chargePointIdentifier}
</span>
</span>
)}
{selectedConnectorId !== null && (
<span>
<span className="font-medium text-foreground">#{selectedConnectorId}</span>
</span>
)}
</div>
{tagsLoading ? (
<div className="flex justify-center py-12">
<Spinner />
</div>
) : myTags.length === 0 ? (
<div className="rounded-xl border border-border px-6 py-12 text-center">
<CreditCard className="mx-auto mb-2 size-8 text-muted" />
<p className="text-sm text-muted"></p>
<p className="mt-1 text-xs text-muted">"储值卡"</p>
</div>
) : (
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{myTags.map((tag) => (
<button
key={tag.idTag}
type="button"
onClick={() => setSelectedIdTag(tag.idTag)}
className={[
"flex flex-col gap-1.5 rounded-xl border p-4 text-left transition-all cursor-pointer",
"border-border hover:border-accent hover:bg-accent/5",
selectedIdTag === tag.idTag ? "border-accent bg-accent/10" : "",
].join(" ")}
>
<div className="flex items-center justify-between">
<span className="font-mono text-sm font-medium text-foreground">
{tag.idTag}
</span>
{selectedIdTag === tag.idTag && (
<Check className="size-4 shrink-0 text-accent" />
)}
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted"></span>
<span className="text-xs font-medium text-foreground">
¥{(tag.balance / 100).toFixed(2)}
</span>
</div>
</button>
))}
</div>
)}
{startResult === "error" && (
<p className="text-sm text-danger">{startError ?? "启动失败,请重试"}</p>
)}
<div className="flex gap-3">
<Button
variant="secondary"
onPress={() => {
setStep(2);
setStartResult(null);
setStartError(null);
}}
>
</Button>
<Button
isDisabled={!selectedIdTag || startMutation.isPending}
onPress={() => startMutation.mutate()}
>
{startMutation.isPending ? (
<Spinner size="sm" />
) : (
<ThunderboltFill className="size-4" />
)}
{startMutation.isPending ? "发送中…" : "启动充电"}
</Button>
</div>
</div>
)}
</div>
);
}