feat(csms): add system settings management for OCPP 1.6J heartbeat interval

This commit is contained in:
2026-03-17 01:42:29 +08:00
parent 4d940e2cd4
commit dee947ce3e
12 changed files with 2308 additions and 5 deletions

View File

@@ -0,0 +1,6 @@
CREATE TABLE "system_setting" (
"key" varchar(64) PRIMARY KEY NOT NULL,
"value" jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);

File diff suppressed because it is too large Load Diff

View File

@@ -43,6 +43,13 @@
"when": 1773678571220, "when": 1773678571220,
"tag": "0005_peaceful_anthem", "tag": "0005_peaceful_anthem",
"breakpoints": true "breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1773682931777,
"tag": "0006_spooky_skin",
"breakpoints": true
} }
] ]
} }

View File

@@ -1,3 +1,4 @@
export * from './auth-schema.ts' export * from './auth-schema.ts'
export * from './ocpp-schema.ts' export * from './ocpp-schema.ts'
export * from './tariff-schema.ts' export * from './tariff-schema.ts'
export * from './settings-schema.ts'

View File

@@ -0,0 +1,15 @@
import { jsonb, pgTable, timestamp, varchar } from "drizzle-orm/pg-core";
/**
* 系统参数配置(按模块 key 存储)
* 例如key=ocpp16j, value={ heartbeatInterval: 60 }
*/
export const systemSetting = pgTable("system_setting", {
key: varchar("key", { length: 64 }).primaryKey(),
value: jsonb("value").notNull().$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
});

View File

@@ -20,6 +20,7 @@ import idTagRoutes from './routes/id-tags.ts'
import userRoutes from './routes/users.ts' import userRoutes from './routes/users.ts'
import setupRoutes from './routes/setup.ts' import setupRoutes from './routes/setup.ts'
import tariffRoutes from './routes/tariff.ts' import tariffRoutes from './routes/tariff.ts'
import settingsRoutes from './routes/settings.ts'
import type { HonoEnv } from './types/hono.ts' import type { HonoEnv } from './types/hono.ts'
@@ -64,6 +65,7 @@ app.route('/api/id-tags', idTagRoutes)
app.route('/api/users', userRoutes) app.route('/api/users', userRoutes)
app.route('/api/setup', setupRoutes) app.route('/api/setup', setupRoutes)
app.route('/api/tariff', tariffRoutes) app.route('/api/tariff', tariffRoutes)
app.route('/api/settings', settingsRoutes)
app.get('/api', (c) => { app.get('/api', (c) => {
const user = c.get('user') const user = c.get('user')

View File

@@ -0,0 +1,45 @@
import { eq } from "drizzle-orm";
import { systemSetting } from "@/db/schema.js";
import { useDrizzle } from "@/lib/db.js";
export const SETTINGS_KEY_OCPP16J = "ocpp16j";
const DEFAULT_HEARTBEAT_INTERVAL = 60;
const MIN_HEARTBEAT_INTERVAL = 10;
const MAX_HEARTBEAT_INTERVAL = 86400;
export type Ocpp16jSettings = {
heartbeatInterval: number;
};
export type SettingsPayload = {
ocpp16j: Ocpp16jSettings;
};
export function sanitizeHeartbeatInterval(raw: unknown): number {
if (typeof raw !== "number" || !Number.isFinite(raw)) {
return DEFAULT_HEARTBEAT_INTERVAL;
}
const n = Math.round(raw);
if (n < MIN_HEARTBEAT_INTERVAL) return MIN_HEARTBEAT_INTERVAL;
if (n > MAX_HEARTBEAT_INTERVAL) return MAX_HEARTBEAT_INTERVAL;
return n;
}
export function getDefaultHeartbeatInterval(): number {
return DEFAULT_HEARTBEAT_INTERVAL;
}
export async function getOcpp16jSettings(): Promise<Ocpp16jSettings> {
const db = useDrizzle();
const [ocpp16jRow] = await db
.select()
.from(systemSetting)
.where(eq(systemSetting.key, SETTINGS_KEY_OCPP16J))
.limit(1);
const ocpp16jRaw = (ocpp16jRow?.value ?? {}) as Record<string, unknown>;
return {
heartbeatInterval: sanitizeHeartbeatInterval(ocpp16jRaw.heartbeatInterval),
};
}

View File

@@ -1,19 +1,19 @@
import { useDrizzle } from '@/lib/db.js' import { useDrizzle } from '@/lib/db.js'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { chargePoint } from '@/db/schema.js' import { chargePoint } from '@/db/schema.js'
import { getOcpp16jSettings } from '@/lib/system-settings.js'
import type { import type {
BootNotificationRequest, BootNotificationRequest,
BootNotificationResponse, BootNotificationResponse,
OcppConnectionContext, OcppConnectionContext,
} from '../types.ts' } from '../types.ts'
const DEFAULT_HEARTBEAT_INTERVAL = 60
export async function handleBootNotification( export async function handleBootNotification(
payload: BootNotificationRequest, payload: BootNotificationRequest,
ctx: OcppConnectionContext, ctx: OcppConnectionContext,
): Promise<BootNotificationResponse> { ): Promise<BootNotificationResponse> {
const db = useDrizzle() const db = useDrizzle()
const { heartbeatInterval } = await getOcpp16jSettings()
const [cp] = await db const [cp] = await db
.insert(chargePoint) .insert(chargePoint)
@@ -30,7 +30,7 @@ export async function handleBootNotification(
meterSerialNumber: payload.meterSerialNumber ?? null, meterSerialNumber: payload.meterSerialNumber ?? null,
// New, unknown devices start as Pending — admin must manually accept them // New, unknown devices start as Pending — admin must manually accept them
registrationStatus: 'Pending', registrationStatus: 'Pending',
heartbeatInterval: DEFAULT_HEARTBEAT_INTERVAL, heartbeatInterval,
lastBootNotificationAt: dayjs().toDate(), lastBootNotificationAt: dayjs().toDate(),
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
@@ -45,7 +45,7 @@ export async function handleBootNotification(
meterType: payload.meterType ?? null, meterType: payload.meterType ?? null,
meterSerialNumber: payload.meterSerialNumber ?? null, meterSerialNumber: payload.meterSerialNumber ?? null,
// Do NOT override registrationStatus — preserve whatever the admin set // Do NOT override registrationStatus — preserve whatever the admin set
heartbeatInterval: DEFAULT_HEARTBEAT_INTERVAL, heartbeatInterval,
lastBootNotificationAt: dayjs().toDate(), lastBootNotificationAt: dayjs().toDate(),
updatedAt: dayjs().toDate(), updatedAt: dayjs().toDate(),
}, },
@@ -59,7 +59,7 @@ export async function handleBootNotification(
return { return {
currentTime: dayjs().toISOString(), currentTime: dayjs().toISOString(),
interval: DEFAULT_HEARTBEAT_INTERVAL, interval: heartbeatInterval,
status, status,
} }
} }

View File

@@ -0,0 +1,65 @@
import { Hono } from "hono";
import { useDrizzle } from "@/lib/db.js";
import { systemSetting } from "@/db/schema.js";
import type { HonoEnv } from "@/types/hono.ts";
import {
SETTINGS_KEY_OCPP16J,
getOcpp16jSettings,
sanitizeHeartbeatInterval,
type SettingsPayload,
} from "@/lib/system-settings.js";
const app = new Hono<HonoEnv>();
app.get("/", async (c) => {
const payload: SettingsPayload = {
ocpp16j: await getOcpp16jSettings(),
};
return c.json(payload);
});
app.put("/", async (c) => {
const currentUser = c.get("user");
if (currentUser?.role !== "admin") {
return c.json({ error: "Forbidden" }, 403);
}
let body: Partial<SettingsPayload>;
try {
body = await c.req.json<Partial<SettingsPayload>>();
} catch {
return c.json({ error: "Invalid JSON" }, 400);
}
if (!body.ocpp16j) {
return c.json({ error: "Missing ocpp16j settings" }, 400);
}
const heartbeatInterval = sanitizeHeartbeatInterval(body.ocpp16j.heartbeatInterval);
const db = useDrizzle();
await db
.insert(systemSetting)
.values({
key: SETTINGS_KEY_OCPP16J,
value: { heartbeatInterval },
})
.onConflictDoUpdate({
target: systemSetting.key,
set: {
value: { heartbeatInterval },
updatedAt: new Date(),
},
});
const payload: SettingsPayload = {
ocpp16j: {
heartbeatInterval,
},
};
return c.json(payload);
});
export default app;

View File

@@ -0,0 +1,161 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Alert, Button, Input, Label, Spinner, TextField, toast } from "@heroui/react";
import { Gear, Lock } from "@gravity-ui/icons";
import { useSession } from "@/lib/auth-client";
import { api, type SystemSettings } from "@/lib/api";
const MIN_HEARTBEAT = 10;
const MAX_HEARTBEAT = 86400;
const DEFAULT_HEARTBEAT = 60;
export default function ParametersSettingsPage() {
const { data: session } = useSession();
const isAdmin = session?.user?.role === "admin";
const queryClient = useQueryClient();
const { data: settings, isLoading } = useQuery({
queryKey: ["system-settings"],
queryFn: () => api.settings.get(),
enabled: isAdmin,
});
const [heartbeatInterval, setHeartbeatInterval] = useState(String(DEFAULT_HEARTBEAT));
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!settings) return;
setHeartbeatInterval(String(settings.ocpp16j.heartbeatInterval));
}, [settings]);
const parsedHeartbeat = useMemo(() => {
const n = Number(heartbeatInterval);
if (!Number.isFinite(n)) return null;
return Math.round(n);
}, [heartbeatInterval]);
const heartbeatError =
parsedHeartbeat === null
? "请输入有效数字"
: parsedHeartbeat < MIN_HEARTBEAT || parsedHeartbeat > MAX_HEARTBEAT
? `范围应为 ${MIN_HEARTBEAT} - ${MAX_HEARTBEAT}`
: "";
const isDirty = settings
? Number(heartbeatInterval) !== settings.ocpp16j.heartbeatInterval
: false;
const handleSave = async () => {
if (parsedHeartbeat === null) {
toast.warning("请输入有效的心跳间隔");
return;
}
if (parsedHeartbeat < MIN_HEARTBEAT || parsedHeartbeat > MAX_HEARTBEAT) {
toast.warning(`心跳间隔范围应为 ${MIN_HEARTBEAT}-${MAX_HEARTBEAT}`);
return;
}
const payload: SystemSettings = {
ocpp16j: { heartbeatInterval: parsedHeartbeat },
};
setSaving(true);
try {
await api.settings.put(payload);
toast.success("参数配置已保存");
queryClient.invalidateQueries({ queryKey: ["system-settings"] });
} catch {
toast.warning("保存失败,请稍后重试");
} finally {
setSaving(false);
}
};
if (!isAdmin) {
return (
<div className="flex flex-col items-center justify-center py-24 text-center">
<div className="mb-4 flex size-12 items-center justify-center rounded-full bg-warning/10">
<Lock className="size-6 text-warning" />
</div>
<p className="text-sm font-semibold text-foreground"></p>
<p className="mt-1 text-sm text-muted"></p>
</div>
);
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-24">
<Spinner size="lg" />
</div>
);
}
return (
<div className="mx-auto max-w-3xl space-y-6">
<div>
<div className="flex items-center gap-2">
<Gear className="size-5 text-foreground" />
<h1 className="text-xl font-semibold text-foreground"></h1>
</div>
<p className="mt-0.5 text-sm text-muted">
</p>
</div>
<div className="rounded-xl border border-border bg-surface-secondary">
<div className="flex items-center gap-3 border-b border-border px-5 py-4">
<div className="flex size-9 items-center justify-center rounded-lg bg-accent/10">
<Gear className="size-5 text-accent" />
</div>
<div>
<p className="text-sm font-semibold text-foreground">OCPP 1.6J</p>
<p className="text-xs text-muted"></p>
</div>
</div>
<div className="space-y-4 px-5 py-4">
<TextField fullWidth>
<Label className="text-sm font-medium"></Label>
<Input
type="number"
min={String(MIN_HEARTBEAT)}
max={String(MAX_HEARTBEAT)}
step="1"
value={heartbeatInterval}
onChange={(e) => setHeartbeatInterval(e.target.value)}
placeholder="60"
/>
</TextField>
<p className="text-xs text-muted">
BootNotification.conf interval
</p>
{!!heartbeatError && (
<Alert status="warning">
<Alert.Indicator />
<Alert.Content>
<Alert.Description>{heartbeatError}</Alert.Description>
</Alert.Content>
</Alert>
)}
<div className="flex justify-end">
<Button
size="sm"
isDisabled={saving || !isDirty || !!heartbeatError}
onPress={handleSave}
>
{saving ? <Spinner size="sm" color="current" /> : "保存"}
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -36,6 +36,7 @@ const navItems = [
const settingsItems = [ const settingsItems = [
{ href: "/dashboard/settings/user", label: "账号设置", icon: UserCog, adminOnly: false }, { href: "/dashboard/settings/user", label: "账号设置", icon: UserCog, adminOnly: false },
{ href: "/dashboard/settings/parameters", label: "参数配置", icon: Gear, adminOnly: true },
]; ];
function NavContent({ function NavContent({

View File

@@ -202,6 +202,14 @@ export type TariffConfig = {
updatedAt?: string; updatedAt?: string;
}; };
export type Ocpp16jSettings = {
heartbeatInterval: number;
};
export type SystemSettings = {
ocpp16j: Ocpp16jSettings;
};
// ── API functions ────────────────────────────────────────────────────────── // ── API functions ──────────────────────────────────────────────────────────
export type ChartRange = "30d" | "7d" | "24h"; export type ChartRange = "30d" | "7d" | "24h";
@@ -349,4 +357,9 @@ export const api = {
put: (data: { slots: TariffSlot[]; prices: Record<PriceTier, TierPricing> }) => put: (data: { slots: TariffSlot[]; prices: Record<PriceTier, TierPricing> }) =>
apiFetch<TariffConfig>("/api/tariff", { method: "PUT", body: JSON.stringify(data) }), apiFetch<TariffConfig>("/api/tariff", { method: "PUT", body: JSON.stringify(data) }),
}, },
settings: {
get: () => apiFetch<SystemSettings>("/api/settings"),
put: (data: SystemSettings) =>
apiFetch<SystemSettings>("/api/settings", { method: "PUT", body: JSON.stringify(data) }),
},
}; };