feat(web): enhance charge point status display with fault indication and link to details

This commit is contained in:
2026-03-11 11:22:24 +08:00
parent f74939917b
commit 1619ed22a0

View File

@@ -2,6 +2,7 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Button, Card, Spinner } from "@heroui/react"; import { Button, Card, Spinner } from "@heroui/react";
import Link from "next/link";
import { import {
Thunderbolt, Thunderbolt,
PlugConnection, PlugConnection,
@@ -10,6 +11,7 @@ import {
TagDollar, TagDollar,
Person, Person,
ArrowRotateRight, ArrowRotateRight,
TriangleExclamation,
} from "@gravity-ui/icons"; } from "@gravity-ui/icons";
import { useSession } from "@/lib/auth-client"; import { useSession } from "@/lib/auth-client";
import { api, type Stats, type UserStats, type Transaction, type ChargePoint } from "@/lib/api"; import { api, type Stats, type UserStats, type Transaction, type ChargePoint } from "@/lib/api";
@@ -87,7 +89,7 @@ function StatCard({
function Panel({ title, children }: { title: string; children: React.ReactNode }) { function Panel({ title, children }: { title: string; children: React.ReactNode }) {
return ( return (
<div className="rounded-xl border border-border bg-surface-secondary"> <div className="rounded-xl border border-border bg-surface-secondary overflow-hidden">
<div className="border-b border-border px-5 py-3.5"> <div className="border-b border-border px-5 py-3.5">
<p className="text-sm font-semibold text-foreground">{title}</p> <p className="text-sm font-semibold text-foreground">{title}</p>
</div> </div>
@@ -156,25 +158,61 @@ function ChargePointStatus({ cps }: { cps: ChargePoint[] }) {
const online = cpOnline(cp); const online = cpOnline(cp);
const chargingCount = cp.connectors.filter((c) => c.status === "Charging").length; const chargingCount = cp.connectors.filter((c) => c.status === "Charging").length;
const availableCount = cp.connectors.filter((c) => c.status === "Available").length; const availableCount = cp.connectors.filter((c) => c.status === "Available").length;
const faultedCount = cp.connectors.filter((c) => c.status === "Faulted").length;
return ( return (
<li key={cp.id} className="flex items-center gap-3 px-5 py-3"> <li key={cp.id}>
<span <Link
className={`mt-0.5 h-2 w-2 shrink-0 rounded-full ${online ? "bg-success" : "bg-muted/40"}`} href={`/dashboard/charge-points/${cp.id}`}
/> className="flex items-center gap-3 px-5 py-3 transition-colors hover:bg-surface-hover"
<div className="min-w-0 flex-1"> >
<p className="truncate font-mono text-sm font-medium text-foreground"> <span
{cp.chargePointIdentifier} className={`mt-0.5 h-2 w-2 shrink-0 rounded-full ${online ? "bg-success" : "bg-muted/40"}`}
</p> />
<p className="text-xs text-muted"> <div className="min-w-0 flex-1">
{cp.chargePointModel ?? cp.chargePointVendor ?? "未知型号"} <p className="truncate text-sm font-medium text-foreground">
</p> {cp.chargePointIdentifier}
</div> </p>
<div className="shrink-0 text-right"> <p className="text-xs text-muted">
{chargingCount > 0 && ( {cp.chargePointModel ?? cp.chargePointVendor ?? "未知型号"}
<p className="text-xs font-medium text-warning">{chargingCount} </p> </p>
)} </div>
<p className="text-xs text-muted">{online ? `${availableCount} 可用` : "离线"}</p> <div className="shrink-0 text-right">
</div> {online ? (
<div className="flex items-center gap-2">
{/* Charge point status */}
{cp.chargePointStatus === "Faulted" && (
<div className="flex justify-end">
<div className="flex items-center gap-1 rounded-md bg-danger/10 px-2 py-1">
<TriangleExclamation className="size-3 text-danger" />
<span className="text-xs font-medium text-danger"></span>
</div>
</div>
)}
{/* Status summary */}
<div className="flex justify-end gap-2 text-xs">
{faultedCount > 0 && (
<div className="flex items-center gap-1">
<TriangleExclamation className="size-3 text-danger" />
<span className="font-medium text-danger">{faultedCount}</span>
</div>
)}
{chargingCount > 0 && (
<div className="flex items-center gap-1">
<Thunderbolt className="size-3 text-warning" />
<span className="font-medium text-warning">{chargingCount}</span>
</div>
)}
<div className="flex items-center gap-1">
<PlugConnection className="size-3 text-accent" />
<span className="text-muted">{availableCount}</span>
</div>
</div>
</div>
) : (
<div className="text-xs text-muted">线</div>
)}
</div>
</Link>
</li> </li>
); );
})} })}
@@ -188,7 +226,12 @@ export default function DashboardPage() {
const { data: sessionData, isPending } = useSession(); const { data: sessionData, isPending } = useSession();
const isAdmin = sessionData?.user?.role === "admin"; const isAdmin = sessionData?.user?.role === "admin";
const { data, isPending: queryPending, isFetching: refreshing, refetch } = useQuery({ const {
data,
isPending: queryPending,
isFetching: refreshing,
refetch,
} = useQuery({
queryKey: ["dashboard", isAdmin], queryKey: ["dashboard", isAdmin],
queryFn: async () => { queryFn: async () => {
const [statsRes, txRes, cpsData] = await Promise.all([ const [statsRes, txRes, cpsData] = await Promise.all([
@@ -231,7 +274,14 @@ export default function DashboardPage() {
<h1 className="text-xl font-semibold text-foreground"></h1> <h1 className="text-xl font-semibold text-foreground"></h1>
<p className="mt-0.5 text-sm text-muted"></p> <p className="mt-0.5 text-sm text-muted"></p>
</div> </div>
<Button isIconOnly size="sm" variant="ghost" isDisabled={refreshing} onPress={() => refetch()} aria-label="刷新"> <Button
isIconOnly
size="sm"
variant="ghost"
isDisabled={refreshing}
onPress={() => refetch()}
aria-label="刷新"
>
<ArrowRotateRight className={`size-4 ${refreshing ? "animate-spin" : ""}`} /> <ArrowRotateRight className={`size-4 ${refreshing ? "animate-spin" : ""}`} />
</Button> </Button>
</div> </div>
@@ -339,7 +389,14 @@ export default function DashboardPage() {
{sessionData?.user?.name ?? sessionData?.user?.email} {sessionData?.user?.name ?? sessionData?.user?.email}
</p> </p>
</div> </div>
<Button isIconOnly size="sm" variant="ghost" isDisabled={refreshing} onPress={() => refetch()} aria-label="刷新"> <Button
isIconOnly
size="sm"
variant="ghost"
isDisabled={refreshing}
onPress={() => refetch()}
aria-label="刷新"
>
<ArrowRotateRight className={`size-4 ${refreshing ? "animate-spin" : ""}`} /> <ArrowRotateRight className={`size-4 ${refreshing ? "animate-spin" : ""}`} />
</Button> </Button>
</div> </div>