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

@@ -2,7 +2,8 @@ import { Hono } from "hono";
import { desc, eq, sql } from "drizzle-orm"; import { desc, eq, sql } from "drizzle-orm";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useDrizzle } from "@/lib/db.js"; import { useDrizzle } from "@/lib/db.js";
import { chargePoint, connector } from "@/db/schema.js"; import { chargePoint, connector, meterValue } from "@/db/schema.js";
import type { SampledValue } from "@/db/schema.js";
import { ocppConnections } from "@/ocpp/handler.js"; import { ocppConnections } from "@/ocpp/handler.js";
import { generateOcppPassword, hashOcppPassword } from "@/lib/ocpp-auth.js"; import { generateOcppPassword, hashOcppPassword } from "@/lib/ocpp-auth.js";
import type { HonoEnv } from "@/types/hono.ts"; import type { HonoEnv } from "@/types/hono.ts";
@@ -122,6 +123,7 @@ app.get("/connections", (c) => {
app.get("/:id", async (c) => { app.get("/:id", async (c) => {
const db = useDrizzle(); const db = useDrizzle();
const id = c.req.param("id"); const id = c.req.param("id");
const isAdmin = c.get("user")?.role === "admin";
const [cp] = await db.select().from(chargePoint).where(eq(chargePoint.id, id)).limit(1); const [cp] = await db.select().from(chargePoint).where(eq(chargePoint.id, id)).limit(1);
@@ -130,12 +132,39 @@ app.get("/:id", async (c) => {
const allConnectors = await db.select().from(connector).where(eq(connector.chargePointId, id)); const allConnectors = await db.select().from(connector).where(eq(connector.chargePointId, id));
const cpStatus = allConnectors.find((conn) => conn.connectorId === 0); const cpStatus = allConnectors.find((conn) => conn.connectorId === 0);
const displayConnectors = allConnectors.filter((conn) => conn.connectorId > 0); const displayConnectors = allConnectors.filter((conn) => conn.connectorId > 0);
const [latestMeter] = await db
.select({
timestamp: meterValue.timestamp,
sampledValues: meterValue.sampledValues,
})
.from(meterValue)
.where(eq(meterValue.chargePointId, id))
.orderBy(desc(meterValue.timestamp), desc(meterValue.receivedAt))
.limit(1);
const meterHistory = isAdmin
? (
await db
.select({
connectorNumber: meterValue.connectorNumber,
timestamp: meterValue.timestamp,
sampledValues: meterValue.sampledValues,
})
.from(meterValue)
.where(eq(meterValue.chargePointId, id))
.orderBy(desc(meterValue.timestamp), desc(meterValue.receivedAt))
.limit(24)
).reverse()
: [];
return c.json({ return c.json({
...cp, ...cp,
connectors: displayConnectors, connectors: displayConnectors,
chargePointStatus: cpStatus?.status ?? null, chargePointStatus: cpStatus?.status ?? null,
chargePointErrorCode: cpStatus?.errorCode ?? null, chargePointErrorCode: cpStatus?.errorCode ?? null,
latestMeterTimestamp: latestMeter?.timestamp ?? null,
latestMeterValues: ((latestMeter?.sampledValues as SampledValue[] | undefined) ?? []),
meterHistory,
}); });
}); });

View File

@@ -18,7 +18,12 @@ import {
Tooltip, Tooltip,
} from "@heroui/react"; } from "@heroui/react";
import { ArrowLeft, Pencil, ArrowRotateRight, Key, Copy, Check } from "@gravity-ui/icons"; 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 { useSession } from "@/lib/auth-client";
import dayjs from "@/lib/dayjs"; import dayjs from "@/lib/dayjs";
import InfoSection from "@/components/info-section"; import InfoSection from "@/components/info-section";
@@ -58,6 +63,8 @@ const registrationColorMap: Record<string, "success" | "warning" | "danger"> = {
Rejected: "danger", Rejected: "danger",
}; };
const RESET_CONFIRM_TEXT = "我将重新配置设备";
const TX_LIMIT = 10; const TX_LIMIT = 10;
// ── Helpers ──────────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────────
@@ -75,6 +82,179 @@ function relativeTime(iso: string): string {
return dayjs(iso).fromNow(); 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 ───────────────────────────────────────────────────────── // ── Edit form type ─────────────────────────────────────────────────────────
type EditForm = { type EditForm = {
@@ -107,16 +287,25 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
}); });
// reset password // reset password
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
const [resetConfirmText, setResetConfirmText] = useState("");
const [resetBusy, setResetBusy] = useState(false); const [resetBusy, setResetBusy] = useState(false);
const [resetResult, setResetResult] = useState<ChargePointPasswordReset | null>(null); const [resetResult, setResetResult] = useState<ChargePointPasswordReset | null>(null);
const [resetCopied, setResetCopied] = useState(false); const [resetCopied, setResetCopied] = useState(false);
const handleResetPassword = async () => { const openResetConfirm = () => {
setResetConfirmText("");
setResetConfirmOpen(true);
};
const handleConfirmResetPassword = async () => {
if (!cp) return; if (!cp) return;
setResetBusy(true); setResetBusy(true);
try { try {
const result = await api.chargePoints.resetPassword(cp.id); const result = await api.chargePoints.resetPassword(cp.id);
setResetResult(result); setResetResult(result);
setResetConfirmOpen(false);
setResetConfirmText("");
} finally { } finally {
setResetBusy(false); 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 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 ─────────────────────────────────────────────────────────────── // ── Render ───────────────────────────────────────────────────────────────
@@ -239,10 +438,10 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold text-foreground"> <h1 className="text-2xl font-semibold text-foreground">
{cp.deviceName ?? <span className="font-mono">{cp.chargePointIdentifier}</span>} {cp.deviceName ?? <span>{cp.chargePointIdentifier}</span>}
</h1> </h1>
{isAdmin && cp.deviceName && ( {isAdmin && cp.deviceName && (
<span className="font-mono text-sm text-muted">{cp.chargePointIdentifier}</span> <span className="text-sm text-muted">{cp.chargePointIdentifier}</span>
)} )}
<Chip <Chip
color={registrationColorMap[cp.registrationStatus] ?? "warning"} color={registrationColorMap[cp.registrationStatus] ?? "warning"}
@@ -252,9 +451,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
{cp.registrationStatus} {cp.registrationStatus}
</Chip> </Chip>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span <span className={`size-2 rounded-full ${transportStatusDotClass}`} />
className={`size-2 rounded-full ${transportStatusDotClass}`}
/>
<span className="text-xs text-muted">{statusLabel}</span> <span className="text-xs text-muted">{statusLabel}</span>
</div> </div>
</div> </div>
@@ -265,7 +462,14 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
)} )}
</div> </div>
<div className="flex items-center gap-2"> <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" : ""}`} /> <ArrowRotateRight className={`size-4 ${refreshing ? "animate-spin" : ""}`} />
</Button> </Button>
{isAdmin && ( {isAdmin && (
@@ -273,7 +477,12 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
<Tooltip> <Tooltip>
<Tooltip.Content> OCPP </Tooltip.Content> <Tooltip.Content> OCPP </Tooltip.Content>
<Tooltip.Trigger> <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" />} {resetBusy ? <Spinner size="sm" /> : <Key className="size-4" />}
</Button> </Button>
@@ -340,6 +549,66 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
</dd> </dd>
</div> </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> </dl>
</InfoSection> </InfoSection>
)} )}
@@ -444,9 +713,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
<dt className="shrink-0 text-sm text-muted"></dt> <dt className="shrink-0 text-sm text-muted"></dt>
<dd> <dd>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span <span className={`size-2 rounded-full ${transportStatusDotClass}`} />
className={`size-2 rounded-full ${transportStatusDotClass}`}
/>
<span className="text-sm text-foreground">{statusLabel}</span> <span className="text-sm text-foreground">{statusLabel}</span>
</div> </div>
</dd> </dd>
@@ -483,10 +750,8 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
{conn.errorCode && conn.errorCode !== "NoError" && ( {conn.errorCode && conn.errorCode !== "NoError" && (
<p className="text-xs text-danger">: {conn.errorCode}</p> <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"> <p className="text-xs text-muted">
{" "} {dayjs(conn.lastStatusAt).format("MM/DD HH:mm")}
{dayjs(conn.lastStatusAt).format("MM/DD HH:mm")}
</p> </p>
</div> </div>
))} ))}
@@ -494,6 +759,19 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
</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 */} {/* Transactions */}
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -600,7 +878,10 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
<Modal <Modal
isOpen={resetResult !== null} isOpen={resetResult !== null}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) { setResetResult(null); setResetCopied(false); } if (!open) {
setResetResult(null);
setResetCopied(false);
}
}} }}
> >
<Modal.Backdrop> <Modal.Backdrop>
@@ -626,9 +907,15 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
isIconOnly isIconOnly
size="sm" size="sm"
variant="ghost" 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> </Button>
</Tooltip.Trigger> </Tooltip.Trigger>
</Tooltip> </Tooltip>
@@ -636,7 +923,12 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
</div> </div>
</Modal.Body> </Modal.Body>
<Modal.Footer className="flex justify-end"> <Modal.Footer className="flex justify-end">
<Button onPress={() => { setResetResult(null); setResetCopied(false); }}> <Button
onPress={() => {
setResetResult(null);
setResetCopied(false);
}}
>
</Button> </Button>
</Modal.Footer> </Modal.Footer>
@@ -667,9 +959,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
<Input <Input
placeholder="1号楼A区01号桩" placeholder="1号楼A区01号桩"
value={editForm.deviceName} value={editForm.deviceName}
onChange={(e) => onChange={(e) => setEditForm((f) => ({ ...f, deviceName: e.target.value }))}
setEditForm((f) => ({ ...f, deviceName: e.target.value }))
}
/> />
</TextField> </TextField>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">

View File

@@ -74,6 +74,22 @@ export type ConnectionsStatus = {
export type ChargePointConnectionStatus = "online" | "unavailable" | "offline"; 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 = { export type ChargePoint = {
id: string; id: string;
chargePointIdentifier: string; chargePointIdentifier: string;
@@ -123,6 +139,9 @@ export type ChargePointDetail = {
connectors: ConnectorDetail[]; connectors: ConnectorDetail[];
chargePointStatus: string | null; chargePointStatus: string | null;
chargePointErrorCode: string | null; chargePointErrorCode: string | null;
latestMeterTimestamp: string | null;
latestMeterValues: MeterSampledValue[];
meterHistory: MeterHistoryPoint[];
}; };
export type Transaction = { export type Transaction = {