From 79a91745c5ea72f11b834c7a31b3a787eb756a21 Mon Sep 17 00:00:00 2001 From: Timothy Yin Date: Mon, 20 Apr 2026 02:56:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20=E6=B7=BB=E5=8A=A0=20MeterValue=20?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E6=94=AF=E6=8C=81=EF=BC=8C=E9=87=8D=E7=BD=AE?= =?UTF-8?q?=20OCPP=20=E8=AE=A4=E8=AF=81=E5=AF=86=E9=92=A5=E4=BA=8C?= =?UTF-8?q?=E6=AC=A1=E7=A1=AE=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/csms/src/routes/charge-points.ts | 31 +- .../app/dashboard/charge-points/[id]/page.tsx | 356 ++++++++++++++++-- apps/web/lib/api.ts | 19 + 3 files changed, 372 insertions(+), 34 deletions(-) diff --git a/apps/csms/src/routes/charge-points.ts b/apps/csms/src/routes/charge-points.ts index de9285a..68d6fd9 100644 --- a/apps/csms/src/routes/charge-points.ts +++ b/apps/csms/src/routes/charge-points.ts @@ -2,7 +2,8 @@ import { Hono } from "hono"; import { desc, eq, sql } from "drizzle-orm"; import dayjs from "dayjs"; 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 { generateOcppPassword, hashOcppPassword } from "@/lib/ocpp-auth.js"; import type { HonoEnv } from "@/types/hono.ts"; @@ -122,6 +123,7 @@ app.get("/connections", (c) => { app.get("/:id", async (c) => { const db = useDrizzle(); 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); @@ -130,12 +132,39 @@ app.get("/:id", async (c) => { const allConnectors = await db.select().from(connector).where(eq(connector.chargePointId, id)); const cpStatus = allConnectors.find((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({ ...cp, connectors: displayConnectors, chargePointStatus: cpStatus?.status ?? null, chargePointErrorCode: cpStatus?.errorCode ?? null, + latestMeterTimestamp: latestMeter?.timestamp ?? null, + latestMeterValues: ((latestMeter?.sampledValues as SampledValue[] | undefined) ?? []), + meterHistory, }); }); diff --git a/apps/web/app/dashboard/charge-points/[id]/page.tsx b/apps/web/app/dashboard/charge-points/[id]/page.tsx index 0f76dc3..1d7ac56 100644 --- a/apps/web/app/dashboard/charge-points/[id]/page.tsx +++ b/apps/web/app/dashboard/charge-points/[id]/page.tsx @@ -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 = { 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 => 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 ( +
+
+
{label}
+
+ {value} +
+
+
+ ); +} + +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 ( + + {history.length === 0 ? ( +
+
+ +
+

+ 暂未收到该连接器的 MeterValue 采样数据 +

+

+ 充电桩上报后,这里会自动显示电压、电流、功率等实时计量信息。 +

+
+ ) : ( +
+
+
+

+ 连接器 #{connectorNumber} +

+

+ {formatDateTime(snapshot.latestTimestamp)} +

+
+ + 最近 {history.length} 条 + +
+
+ + + + + + +
+ +
+
+ +
+
+
+ )} +
+ ); +} + // ── 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(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>( + (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

- {cp.deviceName ?? {cp.chargePointIdentifier}} + {cp.deviceName ?? {cp.chargePointIdentifier}}

{isAdmin && cp.deviceName && ( - {cp.chargePointIdentifier} + {cp.chargePointIdentifier} )}
- + {statusLabel}
@@ -265,7 +462,14 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id )}
- {isAdmin && ( @@ -273,7 +477,12 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id 重置 OCPP 认证密钥 - @@ -340,6 +549,66 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
))} + + {isAdmin && ( + { + if (!resetBusy) { + setResetConfirmOpen(open); + if (!open) setResetConfirmText(""); + } + }} + > + + + + + + 重置 OCPP 认证密钥 + + +

+ 重置后旧密钥将立即失效,请先确认设备已准备重新配置。 +

+ + + setResetConfirmText(e.target.value)} + autoComplete="off" + /> + +
+ + + + +
+
+
+
+ )} )} @@ -444,9 +713,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
充电桥状态
- + {statusLabel}
@@ -483,10 +750,8 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id {conn.errorCode && conn.errorCode !== "NoError" && (

错误: {conn.errorCode}

)} - {/* {conn.info &&

{conn.info}

} */}

- 更新于{" "} - {dayjs(conn.lastStatusAt).format("MM/DD HH:mm")} + 更新于 {dayjs(conn.lastStatusAt).format("MM/DD HH:mm")}

))} @@ -494,6 +759,19 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id )} + {/* MeterValue */} + {isAdmin && ( +
+ {displayConnectors.map((connector) => ( + + ))} +
+ )} + {/* Transactions */}
@@ -600,7 +878,10 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id { - if (!open) { setResetResult(null); setResetCopied(false); } + if (!open) { + setResetResult(null); + setResetCopied(false); + } }} > @@ -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 ? : } + {resetCopied ? ( + + ) : ( + + )} @@ -636,7 +923,12 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
- @@ -667,9 +959,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id - setEditForm((f) => ({ ...f, deviceName: e.target.value })) - } + onChange={(e) => setEditForm((f) => ({ ...f, deviceName: e.target.value }))} />
@@ -744,17 +1034,17 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
{editForm.pricingMode === "fixed" && ( - - - setEditForm((f) => ({ ...f, feePerKwh: e.target.value }))} - /> - + + + setEditForm((f) => ({ ...f, feePerKwh: e.target.value }))} + /> + )} diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index ef72d19..2760b35 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -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 = {