feat(api): 添加 MeterValue 展示支持,重置 OCPP 认证密钥二次确认

This commit is contained in:
2026-04-20 02:56:21 +08:00
parent 1d378c9bb1
commit 79a91745c5
3 changed files with 372 additions and 34 deletions

View File

@@ -18,7 +18,12 @@ import {
Tooltip,
} from "@heroui/react";
import { ArrowLeft, Pencil, ArrowRotateRight, Key, Copy, Check } from "@gravity-ui/icons";
import { api, type ChargePointPasswordReset } from "@/lib/api";
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";
@@ -58,6 +63,8 @@ const registrationColorMap: Record<string, "success" | "warning" | "danger"> = {
Rejected: "danger",
};
const RESET_CONFIRM_TEXT = "我将重新配置设备";
const TX_LIMIT = 10;
// ── Helpers ────────────────────────────────────────────────────────────────
@@ -75,6 +82,179 @@ 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 = {
@@ -107,16 +287,25 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
});
// 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 handleResetPassword = async () => {
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);
}
@@ -220,6 +409,16 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
}
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 ───────────────────────────────────────────────────────────────
@@ -239,10 +438,10 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
<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 className="font-mono">{cp.chargePointIdentifier}</span>}
{cp.deviceName ?? <span>{cp.chargePointIdentifier}</span>}
</h1>
{isAdmin && cp.deviceName && (
<span className="font-mono text-sm text-muted">{cp.chargePointIdentifier}</span>
<span className="text-sm text-muted">{cp.chargePointIdentifier}</span>
)}
<Chip
color={registrationColorMap[cp.registrationStatus] ?? "warning"}
@@ -252,9 +451,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
{cp.registrationStatus}
</Chip>
<div className="flex items-center gap-1.5">
<span
className={`size-2 rounded-full ${transportStatusDotClass}`}
/>
<span className={`size-2 rounded-full ${transportStatusDotClass}`} />
<span className="text-xs text-muted">{statusLabel}</span>
</div>
</div>
@@ -265,7 +462,14 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
)}
</div>
<div className="flex items-center gap-2">
<Button isIconOnly size="sm" variant="ghost" isDisabled={refreshing} onPress={() => cpQuery.refetch()} aria-label="刷新">
<Button
isIconOnly
size="sm"
variant="ghost"
isDisabled={refreshing}
onPress={() => cpQuery.refetch()}
aria-label="刷新"
>
<ArrowRotateRight className={`size-4 ${refreshing ? "animate-spin" : ""}`} />
</Button>
{isAdmin && (
@@ -273,7 +477,12 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
<Tooltip>
<Tooltip.Content> OCPP </Tooltip.Content>
<Tooltip.Trigger>
<Button size="sm" variant="ghost" isDisabled={resetBusy} onPress={handleResetPassword}>
<Button
size="sm"
variant="ghost"
isDisabled={resetBusy}
onPress={openResetConfirm}
>
{resetBusy ? <Spinner size="sm" /> : <Key className="size-4" />}
</Button>
@@ -340,6 +549,66 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
</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>
)}
@@ -444,9 +713,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
<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={`size-2 rounded-full ${transportStatusDotClass}`} />
<span className="text-sm text-foreground">{statusLabel}</span>
</div>
</dd>
@@ -483,10 +750,8 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
{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")}
{dayjs(conn.lastStatusAt).format("MM/DD HH:mm")}
</p>
</div>
))}
@@ -494,6 +759,19 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
</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">
@@ -600,7 +878,10 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
<Modal
isOpen={resetResult !== null}
onOpenChange={(open) => {
if (!open) { setResetResult(null); setResetCopied(false); }
if (!open) {
setResetResult(null);
setResetCopied(false);
}
}}
>
<Modal.Backdrop>
@@ -626,9 +907,15 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
isIconOnly
size="sm"
variant="ghost"
onPress={() => resetResult && handleCopyResetPassword(resetResult.plainPassword)}
onPress={() =>
resetResult && handleCopyResetPassword(resetResult.plainPassword)
}
>
{resetCopied ? <Check className="size-4 text-success" /> : <Copy className="size-4" />}
{resetCopied ? (
<Check className="size-4 text-success" />
) : (
<Copy className="size-4" />
)}
</Button>
</Tooltip.Trigger>
</Tooltip>
@@ -636,7 +923,12 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
</div>
</Modal.Body>
<Modal.Footer className="flex justify-end">
<Button onPress={() => { setResetResult(null); setResetCopied(false); }}>
<Button
onPress={() => {
setResetResult(null);
setResetCopied(false);
}}
>
</Button>
</Modal.Footer>
@@ -667,9 +959,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
<Input
placeholder="1号楼A区01号桩"
value={editForm.deviceName}
onChange={(e) =>
setEditForm((f) => ({ ...f, deviceName: e.target.value }))
}
onChange={(e) => setEditForm((f) => ({ ...f, deviceName: e.target.value }))}
/>
</TextField>
<div className="grid grid-cols-2 gap-3">
@@ -744,17 +1034,17 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
</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>
<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">

View File

@@ -74,6 +74,22 @@ export type ConnectionsStatus = {
export type ChargePointConnectionStatus = "online" | "unavailable" | "offline";
export type MeterSampledValue = {
value: string;
context?: string;
format?: string;
measurand?: string;
phase?: string;
location?: string;
unit?: string;
};
export type MeterHistoryPoint = {
connectorNumber: number;
timestamp: string;
sampledValues: MeterSampledValue[];
};
export type ChargePoint = {
id: string;
chargePointIdentifier: string;
@@ -123,6 +139,9 @@ export type ChargePointDetail = {
connectors: ConnectorDetail[];
chargePointStatus: string | null;
chargePointErrorCode: string | null;
latestMeterTimestamp: string | null;
latestMeterValues: MeterSampledValue[];
meterHistory: MeterHistoryPoint[];
};
export type Transaction = {