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

1066 lines
40 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,
Tooltip,
} from "@heroui/react";
import { ArrowLeft, Pencil, ArrowRotateRight, Key, Copy, Check } from "@gravity-ui/icons";
import {
api,
type ChargePointPasswordReset,
type MeterHistoryPoint,
type MeterSampledValue,
} from "@/lib/api";
import { useSession } from "@/lib/auth-client";
import dayjs from "@/lib/dayjs";
import InfoSection from "@/components/info-section";
import { Plug } from "lucide-react";
// ── 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 RESET_CONFIRM_TEXT = "我将重新配置设备";
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();
}
function formatDateTime(iso: string | null | undefined): string {
if (!iso) return "—";
return dayjs(iso).format("YYYY/M/D HH:mm:ss");
}
function extractMeterValue(sampledValues: MeterSampledValue[], measurands: string[]) {
const parsedValues = sampledValues
.map((sv) => {
const numericValue = Number(sv.value);
if (Number.isNaN(numericValue)) return null;
return {
value: numericValue,
measurand: sv.measurand,
phase: sv.phase,
unit: sv.unit,
};
})
.filter((sv): sv is NonNullable<typeof sv> => sv !== null);
const withoutPhase = parsedValues.find(
(sv) =>
((sv.measurand == null && measurands.includes("Energy.Active.Import.Register")) ||
(sv.measurand != null && measurands.includes(sv.measurand))) &&
!sv.phase,
);
if (withoutPhase) return withoutPhase;
return parsedValues.find(
(sv) =>
(sv.measurand == null && measurands.includes("Energy.Active.Import.Register")) ||
(sv.measurand != null && measurands.includes(sv.measurand)),
);
}
function MeterCard({
label,
value,
emphasis = false,
}: {
label: string;
value: string;
emphasis?: boolean;
}) {
return (
<div
className={`relative overflow-hidden rounded-xl border border-border/70 px-3 py-2.5 ${
emphasis ? "bg-surface-secondary" : "bg-surface"
}`}
>
<div className="min-w-0">
<dt className="text-xs font-medium tracking-wide text-muted">{label}</dt>
<dd className="mt-1 truncate text-base font-semibold tabular-nums text-foreground">
{value}
</dd>
</div>
</div>
);
}
function buildMeterSnapshot(history: MeterHistoryPoint[]) {
const latestPoint = history[history.length - 1];
return {
latestTimestamp: latestPoint?.timestamp ?? null,
latestValues: latestPoint?.sampledValues ?? [],
};
}
function MeterChannelSection({
connectorNumber,
history,
}: {
connectorNumber: number;
history: MeterHistoryPoint[];
}) {
const snapshot = buildMeterSnapshot(history);
const meterVoltage = extractMeterValue(snapshot.latestValues, ["Voltage"]);
const meterCurrent = extractMeterValue(snapshot.latestValues, ["Current.Import"]);
const meterPower = extractMeterValue(snapshot.latestValues, ["Power.Active.Import"]);
const meterPf = extractMeterValue(snapshot.latestValues, ["Power.Factor"]);
const meterFrequency = extractMeterValue(snapshot.latestValues, ["Frequency"]);
const meterTemperature = extractMeterValue(snapshot.latestValues, ["Temperature"]);
const meterEnergyReg = extractMeterValue(snapshot.latestValues, [
"Energy.Active.Import.Register",
]);
return (
<InfoSection title={`连接器 #${connectorNumber}`}>
{history.length === 0 ? (
<div className="rounded-xl border border-dashed border-border/70 bg-surface px-4 py-6 text-center">
<div className="mx-auto flex size-10 items-center justify-center rounded-full bg-muted/10 text-muted">
<Plug className="size-5" />
</div>
<p className="mt-3 text-sm font-medium text-foreground">
MeterValue
</p>
<p className="mt-1 text-xs leading-5 text-muted">
</p>
</div>
) : (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3 rounded-xl border border-border/70 bg-surface-secondary px-3 py-2">
<div>
<p className="text-xs font-medium tracking-wide text-muted">
#{connectorNumber}
</p>
<p className="text-sm font-medium text-foreground">
{formatDateTime(snapshot.latestTimestamp)}
</p>
</div>
<span className="rounded-full bg-accent/10 px-2 py-1 text-xs font-medium text-accent">
{history.length}
</span>
</div>
<dl className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<MeterCard
label="电压"
value={
meterVoltage ? `${meterVoltage.value.toFixed(1)} ${meterVoltage.unit ?? "V"}` : "—"
}
/>
<MeterCard
label="电流"
value={
meterCurrent ? `${meterCurrent.value.toFixed(2)} ${meterCurrent.unit ?? "A"}` : "—"
}
/>
<MeterCard
label="有功功率"
value={meterPower ? `${meterPower.value.toFixed(0)} ${meterPower.unit ?? "W"}` : "—"}
/>
<MeterCard label="功率因数" value={meterPf ? meterPf.value.toFixed(3) : "—"} />
<MeterCard
label="频率"
value={
meterFrequency
? `${meterFrequency.value.toFixed(1)} ${meterFrequency.unit ?? "Hz"}`
: "—"
}
/>
<MeterCard
label="温度"
value={
meterTemperature
? `${meterTemperature.value.toFixed(1)} ${meterTemperature.unit ?? "Celsius"}`
: "—"
}
/>
<div className="sm:col-span-2 xl:col-span-1">
<MeterCard
label="累计电能表读数"
value={
meterEnergyReg
? `${meterEnergyReg.value.toFixed(3)} ${meterEnergyReg.unit ?? "Wh"}`
: "—"
}
emphasis
/>
</div>
<div className="sm:col-span-2 xl:col-span-2">
<MeterCard
label="采样时间"
value={formatDateTime(snapshot.latestTimestamp)}
emphasis
/>
</div>
</dl>
</div>
)}
</InfoSection>
);
}
// ── Edit form type ─────────────────────────────────────────────────────────
type EditForm = {
deviceName: string;
chargePointVendor: string;
chargePointModel: string;
registrationStatus: "Accepted" | "Pending" | "Rejected";
pricingMode: "fixed" | "tou";
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>({
deviceName: "",
chargePointVendor: "",
chargePointModel: "",
registrationStatus: "Pending",
pricingMode: "fixed",
feePerKwh: "0",
});
// reset password
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
const [resetConfirmText, setResetConfirmText] = useState("");
const [resetBusy, setResetBusy] = useState(false);
const [resetResult, setResetResult] = useState<ChargePointPasswordReset | null>(null);
const [resetCopied, setResetCopied] = useState(false);
const openResetConfirm = () => {
setResetConfirmText("");
setResetConfirmOpen(true);
};
const handleConfirmResetPassword = async () => {
if (!cp) return;
setResetBusy(true);
try {
const result = await api.chargePoints.resetPassword(cp.id);
setResetResult(result);
setResetConfirmOpen(false);
setResetConfirmText("");
} finally {
setResetBusy(false);
}
};
const handleCopyResetPassword = (text: string) => {
navigator.clipboard.writeText(text).then(() => {
setResetCopied(true);
setTimeout(() => setResetCopied(false), 2000);
});
};
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({
deviceName: cp.deviceName ?? "",
chargePointVendor: cp.chargePointVendor ?? "",
chargePointModel: cp.chargePointModel ?? "",
registrationStatus: cp.registrationStatus as EditForm["registrationStatus"],
pricingMode: cp.pricingMode,
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,
pricingMode: editForm.pricingMode,
feePerKwh: editForm.pricingMode === "fixed" ? fee : 0,
deviceName: editForm.deviceName.trim() || null,
});
await cpQuery.refetch();
setEditOpen(false);
} finally {
setEditBusy(false);
}
};
// Online if last heartbeat within 3× interval
const isOnline =
cp?.transportStatus === "online" &&
cp?.lastHeartbeatAt != null &&
dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < (cp.heartbeatInterval ?? 60) * 3;
const commandChannelUnavailable = cp?.transportStatus === "unavailable";
const statusLabel = isOnline ? "在线" : commandChannelUnavailable ? "通道异常" : "离线";
const transportStatusDotClass = isOnline
? "bg-success animate-pulse"
: commandChannelUnavailable
? "bg-warning"
: "bg-muted";
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);
const meterHistory = cp.meterHistory ?? [];
const meterHistoryByConnector = meterHistory.reduce<Record<number, MeterHistoryPoint[]>>(
(acc, row) => {
if (!acc[row.connectorNumber]) acc[row.connectorNumber] = [];
acc[row.connectorNumber].push(row);
return acc;
},
{},
);
const displayConnectors = sortedConnectors.filter((connector) => connector.connectorId > 0);
// ── 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="text-2xl font-semibold text-foreground">
{cp.deviceName ?? <span>{cp.chargePointIdentifier}</span>}
</h1>
{isAdmin && cp.deviceName && (
<span className="text-sm text-muted">{cp.chargePointIdentifier}</span>
)}
<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 ${transportStatusDotClass}`} />
<span className="text-xs text-muted">{statusLabel}</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 && (
<>
<Tooltip>
<Tooltip.Content> OCPP </Tooltip.Content>
<Tooltip.Trigger>
<Button
size="sm"
variant="ghost"
isDisabled={resetBusy}
onPress={openResetConfirm}
>
{resetBusy ? <Spinner size="sm" /> : <Key className="size-4" />}
</Button>
</Tooltip.Trigger>
</Tooltip>
<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 && (
<InfoSection title="设备信息">
<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>
))}
{isAdmin && (
<Modal
isOpen={resetConfirmOpen}
onOpenChange={(open) => {
if (!resetBusy) {
setResetConfirmOpen(open);
if (!open) setResetConfirmText("");
}
}}
>
<Modal.Backdrop>
<Modal.Container scroll="outside">
<Modal.Dialog className="sm:max-w-md">
<Modal.CloseTrigger />
<Modal.Header>
<Modal.Heading> OCPP </Modal.Heading>
</Modal.Header>
<Modal.Body className="space-y-4">
<p className="text-sm text-warning font-medium">
</p>
<TextField fullWidth>
<Label className="text-sm font-normal">
<span className="font-medium cursor-text">{RESET_CONFIRM_TEXT}</span>
</Label>
<Input
placeholder={RESET_CONFIRM_TEXT}
value={resetConfirmText}
onChange={(e) => setResetConfirmText(e.target.value)}
autoComplete="off"
/>
</TextField>
</Modal.Body>
<Modal.Footer className="flex justify-end gap-2">
<Button
variant="ghost"
onPress={() => {
setResetConfirmOpen(false);
setResetConfirmText("");
}}
isDisabled={resetBusy}
>
</Button>
<Button
variant="danger"
isDisabled={resetBusy || resetConfirmText !== RESET_CONFIRM_TEXT}
onPress={handleConfirmResetPassword}
>
{resetBusy ? <Spinner size="sm" /> : "确认重置"}
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
)}
</dl>
</InfoSection>
)}
{/* Operation info — admin only */}
{isAdmin && (
<InfoSection title="运行配置">
<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.pricingMode === "tou" ? (
<span className="text-accent font-medium"></span>
) : 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>
</InfoSection>
)}
{/* Fee info — user only */}
{!isAdmin && (
<InfoSection title="电价信息">
<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.pricingMode === "tou" ? (
<span className="text-accent font-medium"></span>
) : 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 ${transportStatusDotClass}`} />
<span className="text-sm text-foreground">{statusLabel}</span>
</div>
</dd>
</div>
</dl>
</InfoSection>
)}
</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">
<Plug 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>
)}
<p className="text-xs text-muted">
{dayjs(conn.lastStatusAt).format("MM/DD HH:mm")}
</p>
</div>
))}
</div>
</div>
)}
{/* MeterValue */}
{isAdmin && (
<div className="grid gap-4 lg:grid-cols-2">
{displayConnectors.map((connector) => (
<MeterChannelSection
key={connector.id}
connectorNumber={connector.connectorId}
history={meterHistoryByConnector[connector.connectorId] ?? []}
/>
))}
</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>
{/* Reset password result modal */}
{isAdmin && (
<Modal
isOpen={resetResult !== null}
onOpenChange={(open) => {
if (!open) {
setResetResult(null);
setResetCopied(false);
}
}}
>
<Modal.Backdrop>
<Modal.Container scroll="outside">
<Modal.Dialog className="sm:max-w-md">
<Modal.Header>
<Modal.Heading></Modal.Heading>
</Modal.Header>
<Modal.Body className="space-y-4">
<p className="text-sm text-warning font-medium">
</p>
<div className="space-y-1.5">
<p className="text-xs text-muted font-medium"> OCPP Basic Auth </p>
<div className="flex items-center gap-2">
<code className="flex-1 rounded-md bg-surface-secondary px-3 py-2 text-sm font-mono text-foreground select-all">
{resetResult?.plainPassword}
</code>
<Tooltip>
<Tooltip.Content>{resetCopied ? "已复制" : "复制密钥"}</Tooltip.Content>
<Tooltip.Trigger>
<Button
isIconOnly
size="sm"
variant="ghost"
onPress={() =>
resetResult && handleCopyResetPassword(resetResult.plainPassword)
}
>
{resetCopied ? (
<Check className="size-4 text-success" />
) : (
<Copy className="size-4" />
)}
</Button>
</Tooltip.Trigger>
</Tooltip>
</div>
</div>
</Modal.Body>
<Modal.Footer className="flex justify-end">
<Button
onPress={() => {
setResetResult(null);
setResetCopied(false);
}}
>
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
)}
{/* 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">
<TextField fullWidth>
<Label className="text-sm font-medium"></Label>
<Input
placeholder="1号楼A区01号桩"
value={editForm.deviceName}
onChange={(e) => setEditForm((f) => ({ ...f, deviceName: e.target.value }))}
/>
</TextField>
<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>
<div className="space-y-1.5">
<Label className="text-sm font-medium"></Label>
<Select
fullWidth
selectedKey={editForm.pricingMode}
onSelectionChange={(key) =>
setEditForm((f) => ({
...f,
pricingMode: String(key) as EditForm["pricingMode"],
}))
}
>
<Select.Trigger>
<Select.Value />
<Select.Indicator />
</Select.Trigger>
<Select.Popover>
<ListBox>
<ListBox.Item id="fixed"></ListBox.Item>
<ListBox.Item id="tou"></ListBox.Item>
</ListBox>
</Select.Popover>
</Select>
</div>
{editForm.pricingMode === "fixed" && (
<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>
);
}