763 lines
30 KiB
TypeScript
763 lines
30 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect } from "react";
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import {
|
||
Button,
|
||
Chip,
|
||
Input,
|
||
InputGroup,
|
||
Label,
|
||
ListBox,
|
||
Modal,
|
||
Select,
|
||
Spinner,
|
||
Table,
|
||
TextField,
|
||
Tooltip,
|
||
} from "@heroui/react";
|
||
import {
|
||
Plus,
|
||
Pencil,
|
||
PlugConnection,
|
||
TrashBin,
|
||
ArrowRotateRight,
|
||
QrCode,
|
||
Copy,
|
||
Check,
|
||
} from "@gravity-ui/icons";
|
||
import { QRCodeSVG } from "qrcode.react";
|
||
import Link from "next/link";
|
||
import { ScrollFade } from "@/components/scroll-fade";
|
||
import { api, type ChargePoint, type ChargePointCreated } from "@/lib/api";
|
||
import { useSession } from "@/lib/auth-client";
|
||
import dayjs from "@/lib/dayjs";
|
||
|
||
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",
|
||
};
|
||
|
||
function ConnectorCell({ connectors }: { connectors: ChargePoint["connectors"] }) {
|
||
return (
|
||
<ScrollFade maxWidth="max-w-2xs">
|
||
{connectors.length === 0 ? (
|
||
<span className="text-muted text-sm">—</span>
|
||
) : (
|
||
[...connectors]
|
||
.sort((a, b) => a.connectorId - b.connectorId)
|
||
.map((conn) => (
|
||
<div
|
||
key={conn.id}
|
||
className="flex shrink-0 items-center gap-1.5 rounded-lg border border-border bg-surface-secondary px-2 py-1"
|
||
>
|
||
<PlugConnection className="size-3 shrink-0 text-muted" />
|
||
<span className="text-xs font-medium tabular-nums text-muted">
|
||
#{conn.connectorId}
|
||
</span>
|
||
<span className="h-3 w-px bg-border" />
|
||
<span
|
||
className={`size-1.5 shrink-0 rounded-full ${
|
||
statusDotClass[conn.status] ?? "bg-warning"
|
||
}`}
|
||
/>
|
||
<span className="text-xs text-nowrap text-foreground">
|
||
{statusLabelMap[conn.status] ?? conn.status}
|
||
</span>
|
||
</div>
|
||
))
|
||
)}
|
||
</ScrollFade>
|
||
);
|
||
}
|
||
|
||
const registrationColorMap: Record<string, "success" | "warning" | "danger"> = {
|
||
Accepted: "success",
|
||
Pending: "warning",
|
||
Rejected: "danger",
|
||
};
|
||
|
||
type FormData = {
|
||
chargePointIdentifier: string;
|
||
deviceName: string;
|
||
chargePointVendor: string;
|
||
chargePointModel: string;
|
||
registrationStatus: "Accepted" | "Pending" | "Rejected";
|
||
pricingMode: "fixed" | "tou";
|
||
feePerKwh: string;
|
||
};
|
||
|
||
const EMPTY_FORM: FormData = {
|
||
chargePointIdentifier: "",
|
||
deviceName: "",
|
||
chargePointVendor: "",
|
||
chargePointModel: "",
|
||
registrationStatus: "Pending",
|
||
pricingMode: "fixed",
|
||
feePerKwh: "0",
|
||
};
|
||
|
||
export default function ChargePointsPage() {
|
||
const [formOpen, setFormOpen] = useState(false);
|
||
const [formTarget, setFormTarget] = useState<ChargePoint | null>(null);
|
||
const [formData, setFormData] = useState<FormData>(EMPTY_FORM);
|
||
const [formBusy, setFormBusy] = useState(false);
|
||
const [deleteTarget, setDeleteTarget] = useState<ChargePoint | null>(null);
|
||
const [deleting, setDeleting] = useState(false);
|
||
const [qrTarget, setQrTarget] = useState<ChargePoint | null>(null);
|
||
const [createdCp, setCreatedCp] = useState<ChargePointCreated | null>(null);
|
||
const [copied, setCopied] = useState(false);
|
||
const {
|
||
data: chargePoints = [],
|
||
refetch: refetchList,
|
||
isFetching: refreshing,
|
||
} = useQuery({
|
||
queryKey: ["chargePoints"],
|
||
queryFn: () => api.chargePoints.list().catch(() => []),
|
||
refetchInterval: 3_000,
|
||
});
|
||
|
||
const openCreate = () => {
|
||
setFormTarget(null);
|
||
setFormData(EMPTY_FORM);
|
||
setFormOpen(true);
|
||
};
|
||
|
||
const openEdit = (cp: ChargePoint) => {
|
||
setFormTarget(cp);
|
||
setFormData({
|
||
chargePointIdentifier: cp.chargePointIdentifier,
|
||
deviceName: cp.deviceName ?? "",
|
||
chargePointVendor: cp.chargePointVendor ?? "",
|
||
chargePointModel: cp.chargePointModel ?? "",
|
||
registrationStatus: cp.registrationStatus as FormData["registrationStatus"],
|
||
pricingMode: cp.pricingMode,
|
||
feePerKwh: String(cp.feePerKwh),
|
||
});
|
||
setFormOpen(true);
|
||
};
|
||
|
||
const handleSubmit = async () => {
|
||
if (!formData.chargePointIdentifier.trim()) return;
|
||
setFormBusy(true);
|
||
try {
|
||
const fee = Math.max(0, Math.round(Number(formData.feePerKwh) || 0));
|
||
if (formTarget) {
|
||
// Edit
|
||
await api.chargePoints.update(String(formTarget.id), {
|
||
chargePointVendor: formData.chargePointVendor,
|
||
chargePointModel: formData.chargePointModel,
|
||
registrationStatus: formData.registrationStatus,
|
||
pricingMode: formData.pricingMode,
|
||
feePerKwh: formData.pricingMode === "fixed" ? fee : 0,
|
||
deviceName: formData.deviceName.trim() || null,
|
||
});
|
||
await refetchList();
|
||
setFormOpen(false);
|
||
} else {
|
||
// Create — capture plainPassword for one-time display
|
||
const created = await api.chargePoints.create({
|
||
chargePointIdentifier: formData.chargePointIdentifier.trim(),
|
||
chargePointVendor: formData.chargePointVendor.trim() || undefined,
|
||
chargePointModel: formData.chargePointModel.trim() || undefined,
|
||
registrationStatus: formData.registrationStatus,
|
||
pricingMode: formData.pricingMode,
|
||
feePerKwh: formData.pricingMode === "fixed" ? fee : 0,
|
||
deviceName: formData.deviceName.trim() || undefined,
|
||
});
|
||
await refetchList();
|
||
setFormOpen(false);
|
||
setCreatedCp(created);
|
||
}
|
||
} finally {
|
||
setFormBusy(false);
|
||
}
|
||
};
|
||
|
||
const handleDelete = async () => {
|
||
if (!deleteTarget) return;
|
||
setDeleting(true);
|
||
try {
|
||
await api.chargePoints.delete(String(deleteTarget.id));
|
||
await refetchList();
|
||
setDeleteTarget(null);
|
||
} finally {
|
||
setDeleting(false);
|
||
}
|
||
};
|
||
|
||
const isEdit = formTarget !== null;
|
||
|
||
const handleCopyPassword = (text: string) => {
|
||
navigator.clipboard.writeText(text).then(() => {
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 2000);
|
||
});
|
||
};
|
||
|
||
const { data: sessionData } = useSession();
|
||
const isAdmin = sessionData?.user?.role === "admin";
|
||
|
||
const [qrOrigin, setQrOrigin] = useState("");
|
||
useEffect(() => {
|
||
setQrOrigin(window.location.origin);
|
||
}, []);
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<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">共 {chargePoints.length} 台设备</p>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
isIconOnly
|
||
size="sm"
|
||
variant="ghost"
|
||
isDisabled={refreshing}
|
||
onPress={() => refetchList()}
|
||
aria-label="刷新"
|
||
>
|
||
<ArrowRotateRight className={`size-4 ${refreshing ? "animate-spin" : ""}`} />
|
||
</Button>
|
||
{isAdmin && (
|
||
<Button size="sm" variant="secondary" onPress={openCreate}>
|
||
<Plus className="size-4" />
|
||
新建充电桩
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Create / Edit modal — admin only */}
|
||
{isAdmin && (
|
||
<>
|
||
<Modal
|
||
isOpen={formOpen}
|
||
onOpenChange={(open) => {
|
||
if (!formBusy) setFormOpen(open);
|
||
}}
|
||
>
|
||
<Modal.Backdrop>
|
||
<Modal.Container scroll="outside">
|
||
<Modal.Dialog className="sm:max-w-md">
|
||
<Modal.CloseTrigger />
|
||
<Modal.Header>
|
||
<Modal.Heading>{isEdit ? "编辑充电桩" : "新建充电桩"}</Modal.Heading>
|
||
</Modal.Header>
|
||
<Modal.Body className="space-y-3">
|
||
<TextField fullWidth isRequired isReadOnly={isEdit}>
|
||
<Label className="text-sm font-medium">标识符</Label>
|
||
<Input
|
||
placeholder="CP001"
|
||
value={formData.chargePointIdentifier}
|
||
onChange={(e) =>
|
||
setFormData((f) => ({ ...f, chargePointIdentifier: e.target.value }))
|
||
}
|
||
/>
|
||
</TextField>
|
||
<TextField fullWidth>
|
||
<Label className="text-sm font-medium">设备名称</Label>
|
||
<Input
|
||
placeholder="1号楼A区01号桩"
|
||
value={formData.deviceName}
|
||
onChange={(e) => setFormData((f) => ({ ...f, deviceName: e.target.value }))}
|
||
/>
|
||
</TextField>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<TextField fullWidth isReadOnly={isEdit}>
|
||
<Label className="text-sm font-medium">品牌</Label>
|
||
<Input
|
||
placeholder="ABB"
|
||
value={formData.chargePointVendor}
|
||
onChange={(e) =>
|
||
setFormData((f) => ({ ...f, chargePointVendor: e.target.value }))
|
||
}
|
||
/>
|
||
</TextField>
|
||
<TextField fullWidth isReadOnly={isEdit}>
|
||
<Label className="text-sm font-medium">型号</Label>
|
||
<Input
|
||
placeholder="Terra AC"
|
||
value={formData.chargePointModel}
|
||
onChange={(e) =>
|
||
setFormData((f) => ({ ...f, chargePointModel: e.target.value }))
|
||
}
|
||
/>
|
||
</TextField>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<Label className="text-sm font-medium">注册状态</Label>
|
||
<Select
|
||
fullWidth
|
||
selectedKey={formData.registrationStatus}
|
||
onSelectionChange={(key) =>
|
||
setFormData((f) => ({
|
||
...f,
|
||
registrationStatus: String(key) as FormData["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={formData.pricingMode}
|
||
onSelectionChange={(key) =>
|
||
setFormData((f) => ({
|
||
...f,
|
||
pricingMode: String(key) as FormData["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>
|
||
{formData.pricingMode === "fixed" && (
|
||
<TextField fullWidth>
|
||
<Label className="text-sm font-medium">固定电价(分/kWh)</Label>
|
||
<Input
|
||
type="number"
|
||
min="0"
|
||
step="1"
|
||
placeholder="0"
|
||
value={formData.feePerKwh}
|
||
onChange={(e) =>
|
||
setFormData((f) => ({ ...f, feePerKwh: e.target.value }))
|
||
}
|
||
/>
|
||
</TextField>
|
||
)}
|
||
{!isEdit && (
|
||
<p className="text-xs text-muted">
|
||
自动注册的充电桩默认状态为 Pending,需手动改为 Accepted 后才可正常上线。
|
||
</p>
|
||
)}
|
||
</Modal.Body>
|
||
<Modal.Footer className="flex justify-end gap-2">
|
||
<Button variant="ghost" onPress={() => setFormOpen(false)}>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
isDisabled={formBusy || !formData.chargePointIdentifier.trim()}
|
||
onPress={handleSubmit}
|
||
>
|
||
{formBusy ? <Spinner size="sm" /> : isEdit ? "保存" : "创建"}
|
||
</Button>
|
||
</Modal.Footer>
|
||
</Modal.Dialog>
|
||
</Modal.Container>
|
||
</Modal.Backdrop>
|
||
</Modal>
|
||
</>
|
||
)}
|
||
|
||
{/* QR Code Modal */}
|
||
{isAdmin && (
|
||
<Modal
|
||
isOpen={qrTarget !== null}
|
||
onOpenChange={(open) => {
|
||
if (!open) setQrTarget(null);
|
||
}}
|
||
>
|
||
<Modal.Backdrop>
|
||
<Modal.Container scroll="outside">
|
||
<Modal.Dialog className="sm:max-w-lg">
|
||
<Modal.CloseTrigger />
|
||
<Modal.Header>
|
||
<Modal.Heading>
|
||
{qrTarget?.deviceName ?? qrTarget?.chargePointIdentifier} — 充电二维码
|
||
</Modal.Heading>
|
||
</Modal.Header>
|
||
<Modal.Body className="space-y-4">
|
||
<p className="text-sm text-muted">
|
||
将以下二维码张贴在对应充电口上,用户扫码后可直接选卡启动充电。
|
||
</p>
|
||
{qrTarget &&
|
||
qrTarget.connectors.filter((c) => c.connectorId > 0).length === 0 && (
|
||
<p className="text-sm text-muted">
|
||
该充电桩暂无接口信息,请等待设备上线后再尝试。
|
||
</p>
|
||
)}
|
||
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3">
|
||
{qrTarget?.connectors
|
||
.filter((c) => c.connectorId > 0)
|
||
.sort((a, b) => a.connectorId - b.connectorId)
|
||
.map((conn) => {
|
||
const url = `${qrOrigin}/dashboard/charge?cpId=${qrTarget.id}&connector=${conn.connectorId}`;
|
||
return (
|
||
<div
|
||
key={conn.id}
|
||
className="flex flex-col items-center gap-2 rounded-xl border border-border p-3"
|
||
>
|
||
<p className="text-xs font-medium text-foreground">
|
||
接口 #{conn.connectorId}
|
||
</p>
|
||
<QRCodeSVG value={url} size={120} className="rounded" />
|
||
<p className="break-all text-center font-mono text-[9px] text-muted leading-tight">
|
||
{url}
|
||
</p>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</Modal.Body>
|
||
<Modal.Footer className="flex justify-end">
|
||
<Button variant="ghost" onPress={() => setQrTarget(null)}>
|
||
关闭
|
||
</Button>
|
||
</Modal.Footer>
|
||
</Modal.Dialog>
|
||
</Modal.Container>
|
||
</Modal.Backdrop>
|
||
</Modal>
|
||
)}
|
||
|
||
{/* OCPP Password Modal — shown once after creation */}
|
||
{isAdmin && (
|
||
<Modal
|
||
isOpen={createdCp !== null}
|
||
onOpenChange={(open) => {
|
||
if (!open) {
|
||
setCreatedCp(null);
|
||
setCopied(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>
|
||
<TextField fullWidth isReadOnly>
|
||
<Label className="text-sm font-medium">充电桩标识符</Label>
|
||
<Input value={createdCp?.chargePointIdentifier ?? ""} className="font-mono" />
|
||
</TextField>
|
||
<TextField fullWidth isReadOnly>
|
||
<Label className="text-sm font-medium">OCPP Basic Auth 密码</Label>
|
||
<InputGroup>
|
||
<InputGroup.Input
|
||
value={createdCp?.plainPassword ?? ""}
|
||
className="font-mono select-all"
|
||
/>
|
||
<InputGroup.Suffix>
|
||
<Tooltip>
|
||
<Tooltip.Content>{copied ? "已复制" : "复制密码"}</Tooltip.Content>
|
||
<Tooltip.Trigger>
|
||
<Button
|
||
isIconOnly
|
||
size="sm"
|
||
variant="ghost"
|
||
onPress={() =>
|
||
createdCp && handleCopyPassword(createdCp.plainPassword)
|
||
}
|
||
>
|
||
{copied ? (
|
||
<Check className="size-4 text-success" />
|
||
) : (
|
||
<Copy className="size-4" />
|
||
)}
|
||
</Button>
|
||
</Tooltip.Trigger>
|
||
</Tooltip>
|
||
</InputGroup.Suffix>
|
||
</InputGroup>
|
||
</TextField>
|
||
<div className="space-y-1.5">
|
||
<TextField fullWidth isReadOnly>
|
||
<Label className="text-sm font-medium">固件 WebSocket 连接地址</Label>
|
||
<Input
|
||
value={`wss://<your-server>/ocpp/${createdCp?.chargePointIdentifier ?? ""}`}
|
||
className="font-mono text-xs"
|
||
/>
|
||
</TextField>
|
||
<p className="text-xs text-muted">
|
||
固件连接时需设置 HTTP 头:
|
||
<br />
|
||
<code className="text-foreground">
|
||
Authorization: Basic <base64({createdCp?.chargePointIdentifier}
|
||
:<password>)>
|
||
</code>
|
||
</p>
|
||
</div>
|
||
</Modal.Body>
|
||
<Modal.Footer className="flex justify-end">
|
||
<Button
|
||
onPress={() => {
|
||
setCreatedCp(null);
|
||
setCopied(false);
|
||
}}
|
||
>
|
||
我已保存密码
|
||
</Button>
|
||
</Modal.Footer>
|
||
</Modal.Dialog>
|
||
</Modal.Container>
|
||
</Modal.Backdrop>
|
||
</Modal>
|
||
)}
|
||
|
||
<Table>
|
||
<Table.ScrollContainer>
|
||
<Table.Content aria-label="充电桩列表" className="min-w-200">
|
||
<Table.Header>
|
||
<Table.Column isRowHeader>设备名称</Table.Column>
|
||
{isAdmin && <Table.Column>品牌 / 型号</Table.Column>}
|
||
{isAdmin && <Table.Column>注册状态</Table.Column>}
|
||
<Table.Column>计费模式</Table.Column>
|
||
<Table.Column>最后发现</Table.Column>
|
||
<Table.Column>状态</Table.Column>
|
||
<Table.Column>接口</Table.Column>
|
||
{isAdmin && <Table.Column>{""}</Table.Column>}
|
||
</Table.Header>
|
||
<Table.Body>
|
||
{chargePoints.length === 0 && (
|
||
<Table.Row id="empty">
|
||
<Table.Cell>
|
||
<span className="text-muted text-sm">暂无设备</span>
|
||
</Table.Cell>
|
||
{isAdmin && <Table.Cell>{""}</Table.Cell>}
|
||
{isAdmin && <Table.Cell>{""}</Table.Cell>}
|
||
<Table.Cell>{""}</Table.Cell>
|
||
<Table.Cell>{""}</Table.Cell>
|
||
<Table.Cell>{""}</Table.Cell>
|
||
<Table.Cell>{""}</Table.Cell>
|
||
{isAdmin && <Table.Cell>{""}</Table.Cell>}
|
||
</Table.Row>
|
||
)}
|
||
{chargePoints.map((cp) => (
|
||
<Table.Row key={cp.id} id={String(cp.id)} className={"group"}>
|
||
<Table.Cell>
|
||
<Tooltip delay={0}>
|
||
<Tooltip.Trigger>
|
||
<div className="flex items-center gap-2">
|
||
<span
|
||
className={`size-2 shrink-0 rounded-full ${
|
||
cp.lastHeartbeatAt &&
|
||
dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120
|
||
? "bg-success"
|
||
: "bg-gray-300"
|
||
}`}
|
||
/>
|
||
<div className="flex flex-col">
|
||
<Link
|
||
href={`/dashboard/charge-points/${cp.id}`}
|
||
className="font-medium text-accent"
|
||
>
|
||
{cp.deviceName ?? cp.chargePointIdentifier}
|
||
</Link>
|
||
{isAdmin && cp.deviceName && (
|
||
<span className="font-mono text-xs text-muted">
|
||
{cp.chargePointIdentifier}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</Tooltip.Trigger>
|
||
<Tooltip.Content placement="start">
|
||
{cp.lastHeartbeatAt
|
||
? dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120
|
||
? "在线"
|
||
: "离线"
|
||
: "从未连接"}
|
||
</Tooltip.Content>
|
||
</Tooltip>
|
||
</Table.Cell>
|
||
{isAdmin && (
|
||
<Table.Cell>
|
||
{cp.chargePointVendor || cp.chargePointModel ? (
|
||
<div className="flex flex-col">
|
||
{cp.chargePointVendor && (
|
||
<span className="text-xs text-muted font-medium">
|
||
{cp.chargePointVendor}
|
||
</span>
|
||
)}
|
||
{cp.chargePointModel && (
|
||
<span className="text-sm text-foreground">{cp.chargePointModel}</span>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<span className="text-muted">—</span>
|
||
)}
|
||
</Table.Cell>
|
||
)}
|
||
{isAdmin && (
|
||
<Table.Cell>
|
||
<Chip
|
||
color={registrationColorMap[cp.registrationStatus] ?? "warning"}
|
||
size="sm"
|
||
variant="soft"
|
||
>
|
||
{cp.registrationStatus}
|
||
</Chip>
|
||
</Table.Cell>
|
||
)}
|
||
<Table.Cell>
|
||
{cp.pricingMode === "tou" ? (
|
||
<span className="text-accent font-medium">峰谷电价</span>
|
||
) : cp.feePerKwh > 0 ? (
|
||
<span>
|
||
{cp.feePerKwh} 分
|
||
<span className="ml-1 text-xs text-muted">
|
||
(¥{(cp.feePerKwh / 100).toFixed(2)}/kWh)
|
||
</span>
|
||
</span>
|
||
) : (
|
||
<span className="text-muted">免费</span>
|
||
)}
|
||
</Table.Cell>
|
||
<Table.Cell>
|
||
{cp.lastHeartbeatAt ? (
|
||
dayjs(cp.lastHeartbeatAt).format("YYYY/M/D HH:mm:ss")
|
||
) : (
|
||
<span className="text-muted">—</span>
|
||
)}
|
||
</Table.Cell>
|
||
<Table.Cell>
|
||
{cp.chargePointStatus ? (
|
||
<div className="flex items-center gap-1.5">
|
||
<span
|
||
className={`size-1.5 shrink-0 rounded-full ${statusDotClass[cp.chargePointStatus] ?? "bg-warning"}`}
|
||
/>
|
||
<span className="text-sm text-foreground text-nowrap">
|
||
{cp.chargePointStatus === "Available"
|
||
? "正常"
|
||
: (statusLabelMap[cp.chargePointStatus] ?? cp.chargePointStatus)}
|
||
</span>
|
||
</div>
|
||
) : (
|
||
<span className="text-muted">—</span>
|
||
)}
|
||
</Table.Cell>
|
||
<Table.Cell>
|
||
<ConnectorCell connectors={cp.connectors} />
|
||
</Table.Cell>
|
||
{isAdmin && (
|
||
<Table.Cell>
|
||
<div className="flex items-center gap-1">
|
||
<Button
|
||
isIconOnly
|
||
size="sm"
|
||
variant="tertiary"
|
||
onPress={() => openEdit(cp)}
|
||
>
|
||
<Pencil className="size-4" />
|
||
</Button>
|
||
<Button
|
||
isIconOnly
|
||
size="sm"
|
||
variant="tertiary"
|
||
onPress={() => setQrTarget(cp)}
|
||
aria-label="查看二维码"
|
||
>
|
||
<QrCode className="size-4" />
|
||
</Button>
|
||
<Modal>
|
||
<Button
|
||
isIconOnly
|
||
size="sm"
|
||
variant="danger-soft"
|
||
onPress={() => setDeleteTarget(cp)}
|
||
>
|
||
<TrashBin className="size-4" />
|
||
</Button>
|
||
<Modal.Backdrop>
|
||
<Modal.Container scroll="outside">
|
||
<Modal.Dialog className="sm:max-w-96">
|
||
<Modal.CloseTrigger />
|
||
<Modal.Header>
|
||
<Modal.Heading>确认删除充电桩</Modal.Heading>
|
||
</Modal.Header>
|
||
<Modal.Body>
|
||
<p className="text-sm text-muted">
|
||
将删除充电桩{" "}
|
||
<span className="font-medium text-foreground">
|
||
{cp.deviceName ?? cp.chargePointIdentifier}
|
||
</span>
|
||
{cp.deviceName && (
|
||
<span className="font-mono ml-1 text-xs text-muted">
|
||
({cp.chargePointIdentifier})
|
||
</span>
|
||
)}
|
||
及其所有连接器和充电记录,此操作不可恢复。
|
||
</p>
|
||
</Modal.Body>
|
||
<Modal.Footer className="flex justify-end gap-2">
|
||
<Button slot="close" variant="ghost">
|
||
取消
|
||
</Button>
|
||
<Button
|
||
slot="close"
|
||
variant="danger"
|
||
isDisabled={deleting}
|
||
onPress={handleDelete}
|
||
>
|
||
{deleting ? <Spinner size="sm" /> : "确认删除"}
|
||
</Button>
|
||
</Modal.Footer>
|
||
</Modal.Dialog>
|
||
</Modal.Container>
|
||
</Modal.Backdrop>
|
||
</Modal>
|
||
</div>
|
||
</Table.Cell>
|
||
)}
|
||
</Table.Row>
|
||
))}
|
||
</Table.Body>
|
||
</Table.Content>
|
||
</Table.ScrollContainer>
|
||
</Table>
|
||
</div>
|
||
);
|
||
}
|