feat(csms): 添加 OCPP 鉴权

This commit is contained in:
2026-03-16 16:53:33 +08:00
parent 4885cf6778
commit cf0861f8f6
8 changed files with 328 additions and 21 deletions

View File

@@ -15,9 +15,10 @@ import {
Spinner,
Table,
TextField,
Tooltip,
} from "@heroui/react";
import { ArrowLeft, Pencil, ArrowRotateRight } from "@gravity-ui/icons";
import { api } from "@/lib/api";
import { ArrowLeft, Pencil, ArrowRotateRight, Key, Copy, Check } from "@gravity-ui/icons";
import { api, type ChargePointPasswordReset } from "@/lib/api";
import { useSession } from "@/lib/auth-client";
import dayjs from "@/lib/dayjs";
import InfoSection from "@/components/info-section";
@@ -105,6 +106,29 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
feePerKwh: "0",
});
// reset password
const [resetBusy, setResetBusy] = useState(false);
const [resetResult, setResetResult] = useState<ChargePointPasswordReset | null>(null);
const [resetCopied, setResetCopied] = useState(false);
const handleResetPassword = async () => {
if (!cp) return;
setResetBusy(true);
try {
const result = await api.chargePoints.resetPassword(cp.id);
setResetResult(result);
} 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),
@@ -237,10 +261,21 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
<ArrowRotateRight className={`size-4 ${refreshing ? "animate-spin" : ""}`} />
</Button>
{isAdmin && (
<Button size="sm" variant="secondary" onPress={openEdit}>
<Pencil className="size-4" />
</Button>
<>
<Tooltip>
<Tooltip.Content> OCPP </Tooltip.Content>
<Tooltip.Trigger>
<Button size="sm" variant="ghost" isDisabled={resetBusy} onPress={handleResetPassword}>
{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>
@@ -552,6 +587,57 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
)}
</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

View File

@@ -6,6 +6,7 @@ import {
Button,
Chip,
Input,
InputGroup,
Label,
ListBox,
Modal,
@@ -22,11 +23,13 @@ import {
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 } from "@/lib/api";
import { api, type ChargePoint, type ChargePointCreated } from "@/lib/api";
import { useSession } from "@/lib/auth-client";
import dayjs from "@/lib/dayjs";
@@ -123,6 +126,8 @@ export default function ChargePointsPage() {
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,
@@ -168,9 +173,11 @@ export default function ChargePointsPage() {
feePerKwh: formData.pricingMode === "fixed" ? fee : 0,
deviceName: formData.deviceName.trim() || null,
});
await refetchList();
setFormOpen(false);
} else {
// Create
await api.chargePoints.create({
// 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,
@@ -179,9 +186,10 @@ export default function ChargePointsPage() {
feePerKwh: formData.pricingMode === "fixed" ? fee : 0,
deviceName: formData.deviceName.trim() || undefined,
});
await refetchList();
setFormOpen(false);
setCreatedCp(created);
}
await refetchList();
setFormOpen(false);
} finally {
setFormBusy(false);
}
@@ -201,6 +209,13 @@ export default function ChargePointsPage() {
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";
@@ -268,9 +283,7 @@ export default function ChargePointsPage() {
<Input
placeholder="1号楼A区01号桩"
value={formData.deviceName}
onChange={(e) =>
setFormData((f) => ({ ...f, deviceName: e.target.value }))
}
onChange={(e) => setFormData((f) => ({ ...f, deviceName: e.target.value }))}
/>
</TextField>
<div className="grid grid-cols-2 gap-3">
@@ -444,6 +457,95 @@ export default function ChargePointsPage() {
</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 &lt;base64({createdCp?.chargePointIdentifier}
:&lt;password&gt;)&gt;
</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">