Files
helios-evcs/apps/web/app/dashboard/charge-points/[id]/page.tsx

632 lines
24 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 { use, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import {
Button,
Chip,
Input,
Label,
ListBox,
Modal,
Pagination,
Select,
Spinner,
Table,
TextField,
} from "@heroui/react";
import { ArrowLeft, Pencil, PlugConnection, ArrowRotateRight } from "@gravity-ui/icons";
import { api } from "@/lib/api";
import { useSession } from "@/lib/auth-client";
import dayjs from "@/lib/dayjs";
// ── Status maps ────────────────────────────────────────────────────────────
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-[var(--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",
};
const registrationColorMap: Record<string, "success" | "warning" | "danger"> = {
Accepted: "success",
Pending: "warning",
Rejected: "danger",
};
const TX_LIMIT = 10;
// ── Helpers ────────────────────────────────────────────────────────────────
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 relativeTime(iso: string): string {
return dayjs(iso).fromNow();
}
// ── Edit form type ─────────────────────────────────────────────────────────
type EditForm = {
chargePointVendor: string;
chargePointModel: string;
registrationStatus: "Accepted" | "Pending" | "Rejected";
feePerKwh: string;
};
// ── Component ──────────────────────────────────────────────────────────────
export default function ChargePointDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
// transactions
const [txPage, setTxPage] = useState(1);
// edit modal
const [editOpen, setEditOpen] = useState(false);
const [editBusy, setEditBusy] = useState(false);
const [editForm, setEditForm] = useState<EditForm>({
chargePointVendor: "",
chargePointModel: "",
registrationStatus: "Pending",
feePerKwh: "0",
});
const { isFetching: refreshing, ...cpQuery } = useQuery({
queryKey: ["chargePoint", id],
queryFn: () => api.chargePoints.get(id),
refetchInterval: 3_000,
retry: false,
});
const txQuery = useQuery({
queryKey: ["chargePointTransactions", id, txPage],
queryFn: () => api.transactions.list({ page: txPage, limit: TX_LIMIT, chargePointId: id }),
refetchInterval: 3_000,
});
const cp = cpQuery.data;
const txData = txQuery.data;
const openEdit = () => {
if (!cp) return;
setEditForm({
chargePointVendor: cp.chargePointVendor ?? "",
chargePointModel: cp.chargePointModel ?? "",
registrationStatus: cp.registrationStatus as EditForm["registrationStatus"],
feePerKwh: String(cp.feePerKwh),
});
setEditOpen(true);
};
const handleEditSubmit = async () => {
if (!cp) return;
setEditBusy(true);
try {
const fee = Math.max(0, Math.round(Number(editForm.feePerKwh) || 0));
await api.chargePoints.update(cp.id, {
chargePointVendor: editForm.chargePointVendor,
chargePointModel: editForm.chargePointModel,
registrationStatus: editForm.registrationStatus,
feePerKwh: fee,
});
await cpQuery.refetch();
setEditOpen(false);
} finally {
setEditBusy(false);
}
};
// Online if last heartbeat within 3× interval
const isOnline =
cp?.lastHeartbeatAt != null &&
dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < (cp.heartbeatInterval ?? 60) * 3;
const { data: sessionData } = useSession();
const isAdmin = sessionData?.user?.role === "admin";
// ── Render: loading / not found ──────────────────────────────────────────
if (cpQuery.isPending) {
return (
<div className="flex h-48 items-center justify-center">
<Spinner />
</div>
);
}
if (!cp) {
return (
<div className="space-y-4">
<Link
href="/dashboard/charge-points"
className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-foreground"
>
<ArrowLeft className="size-4" />
</Link>
<p className="text-sm text-danger"></p>
</div>
);
}
const sortedConnectors = [...cp.connectors].sort((a, b) => a.connectorId - b.connectorId);
// ── Render ───────────────────────────────────────────────────────────────
return (
<div className="space-y-6">
{/* Breadcrumb back */}
<Link
href="/dashboard/charge-points"
className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-foreground"
>
<ArrowLeft className="size-4" />
</Link>
{/* Header */}
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-1.5">
<div className="flex flex-wrap items-center gap-2">
<h1 className="font-mono text-2xl font-semibold text-foreground">
{cp.chargePointIdentifier}
</h1>
<Chip
color={registrationColorMap[cp.registrationStatus] ?? "warning"}
size="sm"
variant="soft"
>
{cp.registrationStatus}
</Chip>
<div className="flex items-center gap-1.5">
<span
className={`size-2 rounded-full ${isOnline ? "bg-success animate-pulse" : "bg-muted"}`}
/>
<span className="text-xs text-muted">{isOnline ? "在线" : "离线"}</span>
</div>
</div>
{(cp.chargePointVendor || cp.chargePointModel) && (
<p className="text-sm text-muted">
{[cp.chargePointVendor, cp.chargePointModel].filter(Boolean).join(" · ")}
</p>
)}
</div>
<div className="flex items-center gap-2">
<Button isIconOnly size="sm" variant="ghost" isDisabled={refreshing} onPress={() => cpQuery.refetch()} aria-label="刷新">
<ArrowRotateRight className={`size-4 ${refreshing ? "animate-spin" : ""}`} />
</Button>
{isAdmin && (
<Button size="sm" variant="secondary" onPress={openEdit}>
<Pencil className="size-4" />
</Button>
)}
</div>
</div>
{/* Info grid */}
{cp.chargePointStatus && (
<div
className={`flex items-center gap-3 rounded-xl border px-4 py-3 ${
cp.chargePointStatus === "Available"
? "border-success/30 bg-success/5"
: cp.chargePointStatus === "Faulted" || cp.chargePointStatus === "Unavailable"
? "border-danger/30 bg-danger/5"
: "border-warning/30 bg-warning/5"
}`}
>
<span
className={`size-2.5 shrink-0 rounded-full ${statusDotClass[cp.chargePointStatus] ?? "bg-warning"}`}
/>
<span className="text-sm font-semibold text-foreground">
{cp.chargePointStatus === "Available"
? "正常"
: (statusLabelMap[cp.chargePointStatus] ?? cp.chargePointStatus)}
</span>
{cp.chargePointErrorCode && cp.chargePointErrorCode !== "NoError" && (
<>
<span className="text-muted">·</span>
<span className="text-xs text-danger">{cp.chargePointErrorCode}</span>
</>
)}
<span className="ml-auto text-xs text-muted"></span>
</div>
)}
{/* Info grid */}
<div className="grid gap-4 md:grid-cols-2">
{/* Device info — admin only */}
{isAdmin && (
<div className="rounded-xl border border-border bg-surface p-4">
<h2 className="mb-3 text-sm font-semibold text-foreground"></h2>
<dl className="divide-y divide-border">
{[
{ label: "品牌", value: cp.chargePointVendor },
{ label: "型号", value: cp.chargePointModel },
{ label: "序列号", value: cp.chargePointSerialNumber },
{ label: "固件版本", value: cp.firmwareVersion },
{ label: "电表型号", value: cp.meterType },
{ label: "电表序列号", value: cp.meterSerialNumber },
{ label: "ICCID", value: cp.iccid },
{ label: "IMSI", value: cp.imsi },
].map(({ label, value }) => (
<div key={label} className="flex items-center justify-between gap-4 py-2">
<dt className="shrink-0 text-sm text-muted">{label}</dt>
<dd className="truncate text-sm text-foreground">
{value ?? <span className="text-muted"></span>}
</dd>
</div>
))}
</dl>
</div>
)}
{/* Operation info — admin only */}
{isAdmin && (
<div className="rounded-xl border border-border bg-surface p-4">
<h2 className="mb-3 text-sm font-semibold text-foreground"></h2>
<dl className="divide-y divide-border">
<div className="flex items-center justify-between gap-4 py-2">
<dt className="shrink-0 text-sm text-muted"></dt>
<dd>
<Chip
color={registrationColorMap[cp.registrationStatus] ?? "warning"}
size="sm"
variant="soft"
>
{cp.registrationStatus}
</Chip>
</dd>
</div>
<div className="flex items-center justify-between gap-4 py-2">
<dt className="shrink-0 text-sm text-muted"></dt>
<dd className="text-sm text-foreground">
{cp.feePerKwh > 0 ? (
<span>
{cp.feePerKwh} /kWh
<span className="ml-1 text-xs text-muted">
(¥{(cp.feePerKwh / 100).toFixed(2)}/kWh)
</span>
</span>
) : (
"免费"
)}
</dd>
</div>
<div className="flex items-center justify-between gap-4 py-2">
<dt className="shrink-0 text-sm text-muted"></dt>
<dd className="text-sm text-foreground">
{cp.heartbeatInterval != null ? (
`${cp.heartbeatInterval}`
) : (
<span className="text-muted"></span>
)}
</dd>
</div>
<div className="flex items-center justify-between gap-4 py-2">
<dt className="shrink-0 text-sm text-muted"></dt>
<dd className="text-right text-sm text-foreground">
{cp.lastHeartbeatAt ? (
<span title={dayjs(cp.lastHeartbeatAt).format("YYYY/M/D HH:mm:ss")}>
{relativeTime(cp.lastHeartbeatAt)}
</span>
) : (
<span className="text-muted"></span>
)}
</dd>
</div>
<div className="flex items-center justify-between gap-4 py-2">
<dt className="shrink-0 text-sm text-muted"></dt>
<dd className="text-right text-sm text-foreground">
{cp.lastBootNotificationAt ? (
<span title={dayjs(cp.lastBootNotificationAt).format("YYYY/M/D HH:mm:ss")}>
{relativeTime(cp.lastBootNotificationAt)}
</span>
) : (
<span className="text-muted"></span>
)}
</dd>
</div>
<div className="flex items-center justify-between gap-4 py-2">
<dt className="shrink-0 text-sm text-muted"></dt>
<dd className="text-sm text-foreground">
{dayjs(cp.createdAt).format("YYYY/M/D")}
</dd>
</div>
</dl>
</div>
)}
{/* Fee info — user only */}
{!isAdmin && (
<div className="rounded-xl border border-border bg-surface p-4">
<h2 className="mb-3 text-sm font-semibold text-foreground"></h2>
<dl className="divide-y divide-border">
<div className="flex items-center justify-between gap-4 py-2">
<dt className="shrink-0 text-sm text-muted"></dt>
<dd className="text-sm text-foreground">
{cp.feePerKwh > 0 ? (
<span>
<span className="font-semibold">¥{(cp.feePerKwh / 100).toFixed(2)}</span>
<span className="ml-1 text-xs text-muted">/kWh</span>
</span>
) : (
<span className="text-success font-medium"></span>
)}
</dd>
</div>
<div className="flex items-center justify-between gap-4 py-2">
<dt className="shrink-0 text-sm text-muted"></dt>
<dd>
<div className="flex items-center gap-1.5">
<span
className={`size-2 rounded-full ${isOnline ? "bg-success animate-pulse" : "bg-muted"}`}
/>
<span className="text-sm text-foreground">{isOnline ? "在线" : "离线"}</span>
</div>
</dd>
</div>
</dl>
</div>
)}
</div>
{/* Connectors */}
{sortedConnectors.length > 0 && (
<div className="space-y-3">
<h2 className="text-sm font-semibold text-foreground"></h2>
<div className="flex flex-wrap gap-3">
{sortedConnectors.map((conn) => (
<div
key={conn.id}
className="flex min-w-40 flex-col gap-2 rounded-xl border border-border bg-surface p-3"
>
<div className="flex items-center gap-2">
<PlugConnection className="size-4 shrink-0 text-muted" />
<span className="text-sm font-medium text-foreground">
#{conn.connectorId}
</span>
<span className="ml-auto flex items-center gap-1">
<span
className={`size-2 shrink-0 rounded-full ${statusDotClass[conn.status] ?? "bg-warning"}`}
/>
<span className="text-xs text-muted">
{statusLabelMap[conn.status] ?? conn.status}
</span>
</span>
</div>
{conn.errorCode && conn.errorCode !== "NoError" && (
<p className="text-xs text-danger">: {conn.errorCode}</p>
)}
{/* {conn.info && <p className="text-xs text-muted">{conn.info}</p>} */}
<p className="text-xs text-muted">
{" "}
{dayjs(conn.lastStatusAt).format("MM/DD HH:mm")}
</p>
</div>
))}
</div>
</div>
)}
{/* Transactions */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-foreground"></h2>
{txData && <span className="text-xs text-muted"> {txData.total} </span>}
</div>
<Table>
<Table.ScrollContainer>
<Table.Content aria-label="充电记录">
<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></Table.Column>
</Table.Header>
<Table.Body
renderEmptyState={() => (
<div className="py-8 text-center text-sm text-muted">
{txQuery.isPending ? "加载中…" : "暂无充电记录"}
</div>
)}
>
{(txData?.data ?? []).map((tx) => (
<Table.Row key={tx.id} id={tx.id}>
<Table.Cell className="font-mono text-sm text-muted">#{tx.id}</Table.Cell>
<Table.Cell>
{tx.connectorNumber != null ? (
`#${tx.connectorNumber}`
) : (
<span className="text-muted"></span>
)}
</Table.Cell>
<Table.Cell className="font-mono">{tx.idTag}</Table.Cell>
<Table.Cell className="tabular-nums text-sm">
{dayjs(tx.startTimestamp).format("MM/DD HH:mm")}
</Table.Cell>
<Table.Cell>{formatDuration(tx.startTimestamp, tx.stopTimestamp)}</Table.Cell>
<Table.Cell className="tabular-nums">
{tx.energyWh != null ? (
`${(tx.energyWh / 1000).toFixed(2)} kWh`
) : (
<span className="text-muted"></span>
)}
</Table.Cell>
<Table.Cell className="tabular-nums">
{tx.chargeAmount != null ? (
`¥${(tx.chargeAmount / 100).toFixed(2)}`
) : (
<span className="text-muted"></span>
)}
</Table.Cell>
<Table.Cell>
{tx.stopReason ?? <span className="text-muted"></span>}
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Content>
</Table.ScrollContainer>
</Table>
{txData && txData.totalPages > 1 && (
<div className="flex justify-center">
<Pagination size="sm">
<Pagination.Content>
<Pagination.Item>
<Pagination.Previous
isDisabled={txPage === 1}
onPress={() => setTxPage((p) => Math.max(1, p - 1))}
>
<Pagination.PreviousIcon />
</Pagination.Previous>
</Pagination.Item>
{Array.from({ length: txData.totalPages }, (_, i) => i + 1).map((p) => (
<Pagination.Item key={p}>
<Pagination.Link isActive={p === txPage} onPress={() => setTxPage(p)}>
{p}
</Pagination.Link>
</Pagination.Item>
))}
<Pagination.Item>
<Pagination.Next
isDisabled={txPage === txData.totalPages}
onPress={() => setTxPage((p) => Math.min(txData.totalPages, p + 1))}
>
<Pagination.NextIcon />
</Pagination.Next>
</Pagination.Item>
</Pagination.Content>
</Pagination>
</div>
)}
</div>
{/* Edit modal */}
{isAdmin && (
<Modal
isOpen={editOpen}
onOpenChange={(open) => {
if (!editBusy) setEditOpen(open);
}}
>
<Modal.Backdrop>
<Modal.Container scroll="outside">
<Modal.Dialog className="sm:max-w-md">
<Modal.CloseTrigger />
<Modal.Header>
<Modal.Heading></Modal.Heading>
</Modal.Header>
<Modal.Body className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<TextField fullWidth>
<Label className="text-sm font-medium"></Label>
<Input
placeholder="Unknown"
value={editForm.chargePointVendor}
onChange={(e) =>
setEditForm((f) => ({ ...f, chargePointVendor: e.target.value }))
}
/>
</TextField>
<TextField fullWidth>
<Label className="text-sm font-medium"></Label>
<Input
placeholder="Unknown"
value={editForm.chargePointModel}
onChange={(e) =>
setEditForm((f) => ({ ...f, chargePointModel: e.target.value }))
}
/>
</TextField>
</div>
<div className="space-y-1.5">
<Label className="text-sm font-medium"></Label>
<Select
fullWidth
selectedKey={editForm.registrationStatus}
onSelectionChange={(key) =>
setEditForm((f) => ({
...f,
registrationStatus: String(key) as EditForm["registrationStatus"],
}))
}
>
<Select.Trigger>
<Select.Value />
<Select.Indicator />
</Select.Trigger>
<Select.Popover>
<ListBox>
<ListBox.Item id="Accepted">Accepted</ListBox.Item>
<ListBox.Item id="Pending">Pending</ListBox.Item>
<ListBox.Item id="Rejected">Rejected</ListBox.Item>
</ListBox>
</Select.Popover>
</Select>
</div>
<TextField fullWidth>
<Label className="text-sm font-medium">/kWh</Label>
<Input
type="number"
min="0"
step="1"
placeholder="0"
value={editForm.feePerKwh}
onChange={(e) => setEditForm((f) => ({ ...f, feePerKwh: e.target.value }))}
/>
</TextField>
</Modal.Body>
<Modal.Footer className="flex justify-end gap-2">
<Button variant="ghost" onPress={() => setEditOpen(false)}>
</Button>
<Button isDisabled={editBusy} onPress={handleEditSubmit}>
{editBusy ? <Spinner size="sm" /> : "保存"}
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
)}
</div>
);
}