feat(csms): add system settings management for OCPP 1.6J heartbeat interval
This commit is contained in:
6
apps/csms/drizzle/0006_spooky_skin.sql
Normal file
6
apps/csms/drizzle/0006_spooky_skin.sql
Normal 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
|
||||||
|
);
|
||||||
1987
apps/csms/drizzle/meta/0006_snapshot.json
Normal file
1987
apps/csms/drizzle/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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'
|
||||||
|
|||||||
15
apps/csms/src/db/settings-schema.ts
Normal file
15
apps/csms/src/db/settings-schema.ts
Normal 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()),
|
||||||
|
});
|
||||||
@@ -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')
|
||||||
|
|||||||
45
apps/csms/src/lib/system-settings.ts
Normal file
45
apps/csms/src/lib/system-settings.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
65
apps/csms/src/routes/settings.ts
Normal file
65
apps/csms/src/routes/settings.ts
Normal 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;
|
||||||
161
apps/web/app/dashboard/settings/parameters/page.tsx
Normal file
161
apps/web/app/dashboard/settings/parameters/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -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) }),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user