feat(api): 添加 MeterValue 展示支持,重置 OCPP 认证密钥二次确认
This commit is contained in:
@@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -744,17 +1034,17 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
{editForm.pricingMode === "fixed" && (
|
{editForm.pricingMode === "fixed" && (
|
||||||
<TextField fullWidth>
|
<TextField fullWidth>
|
||||||
<Label className="text-sm font-medium">固定电价(分/kWh)</Label>
|
<Label className="text-sm font-medium">固定电价(分/kWh)</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
step="1"
|
step="1"
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
value={editForm.feePerKwh}
|
value={editForm.feePerKwh}
|
||||||
onChange={(e) => setEditForm((f) => ({ ...f, feePerKwh: e.target.value }))}
|
onChange={(e) => setEditForm((f) => ({ ...f, feePerKwh: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
</TextField>
|
</TextField>
|
||||||
)}
|
)}
|
||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
<Modal.Footer className="flex justify-end gap-2">
|
<Modal.Footer className="flex justify-end gap-2">
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user