Compare commits

...

39 Commits

Author SHA1 Message Date
e61e244c39 chore: generate migration 2026-03-18 15:44:43 +08:00
2c90404637 Unify charge point command channel status 2026-03-18 15:26:56 +08:00
3508e7de19 docs: update README.md 2026-03-18 13:05:49 +08:00
adc67e428d chore(pcb): initial kicad project 2026-03-18 12:47:56 +08:00
dee947ce3e feat(csms): add system settings management for OCPP 1.6J heartbeat interval 2026-03-17 01:42:29 +08:00
4d940e2cd4 chore(firmware): update text 2026-03-17 01:26:12 +08:00
8371b2a76b feat(firmware): add Mongoose client implementation for OCPP communication 2026-03-17 01:25:41 +08:00
e1fb43d57b refactor(csms): 更新 OCPP 认证相关文本 2026-03-17 00:38:37 +08:00
5825783f8b feat(csms): update schema for ocpp authorization 2026-03-17 00:32:54 +08:00
e884fc5bc0 feat(firmware): 更新 OCPP 配置,添加密码字段以支持基本认证 2026-03-16 17:17:15 +08:00
cf0861f8f6 feat(csms): 添加 OCPP 鉴权 2026-03-16 16:53:39 +08:00
4885cf6778 refactor: 移除重复的 tariff-schema 导出 2026-03-16 15:27:40 +08:00
654a2a66d9 feat(csms): 充电桩添加 deviceName 字段,区别于 identifier 用于区分设备 2026-03-16 13:43:46 +08:00
0118dd2e15 feat(web): 添加拓扑图页面和相关组件
feat(csms): 添加获取当前连接状态的API
feat(csms): 添加获取当前活动OCPP WebSocket连接的接口
deps(web): 添加@xyflow/react依赖
2026-03-16 12:59:05 +08:00
6888454727 fix(web): 修正二维码链接的URL路径 2026-03-16 02:03:57 +08:00
91d91ebd08 feat(main): 自动生成充电点标识符并优化WiFi设置 2026-03-16 01:48:18 +08:00
37c5cfe5a9 feat(main): 添加OCPP配置的持久化支持和LED状态管理功能 2026-03-16 00:52:17 +08:00
2de43d5fbb fix(csms): 添加缺失的chargePointId参数检查 2026-03-15 04:41:00 +08:00
434dbc15e9 fix(config): 修正OCPP后端URL 2026-03-15 04:33:52 +08:00
d5b2e529ff feat(mongoose): 添加对mbedTLS的TLS支持 2026-03-15 04:33:33 +08:00
d7b7ebfef9 Revert "feat(ocpp): 添加对WebSocket子协议的支持和缺失参数检查"
This reverts commit 216a8e118d.
2026-03-15 04:28:32 +08:00
8f3b2fd6e2 Revert "feat(boot-notification): 添加超时处理和日志记录以增强引导通知的持久性"
This reverts commit 8a537e80e3.
2026-03-15 04:28:32 +08:00
8a537e80e3 feat(boot-notification): 添加超时处理和日志记录以增强引导通知的持久性 2026-03-15 04:23:13 +08:00
216a8e118d feat(ocpp): 添加对WebSocket子协议的支持和缺失参数检查 2026-03-15 03:41:38 +08:00
b45896a9dd fix(config): 更新OCPP后端URL和CP标识符配置 2026-03-15 03:12:09 +08:00
4a9961df70 style(transactions): 修改订单标题样式 2026-03-14 23:17:29 +08:00
18ac660ab2 feat(transactions): wrap TransactionsPageContent in Suspense for loading state 2026-03-13 12:18:09 +08:00
a6621f975c feat: 添加信息和指标组件以增强充电订单和计量信息的展示 2026-03-13 12:11:33 +08:00
83e6ed2412 feat(transactions): add transaction detail page with live energy and cost estimation
feat(transactions): implement active transaction checks and idTag validation
feat(id-tag): enhance idTag card with disabled state for active transactions
fix(transactions): improve error handling and user feedback for transaction actions
2026-03-13 11:51:06 +08:00
c8ddaa4dcc feat(sidebar): update icons for navigation and charge items 2026-03-12 17:50:04 +08:00
88a80d2268 feat(transactions): add live energy and estimated cost to transaction data 2026-03-12 17:38:49 +08:00
f7ee298060 feat(charge-points): add pricing mode for charge points with validation
feat(pricing): implement tariff management with peak, valley, and flat pricing
feat(api): add tariff API for fetching and updating pricing configurations
feat(tariff-schema): create database schema for tariff configuration
feat(pricing-page): create UI for displaying and managing pricing tiers
fix(sidebar): update sidebar to include pricing settings link
2026-03-12 17:23:06 +08:00
2638af3f7f feat: 峰谷电价编辑器 2026-03-12 16:06:48 +08:00
2bbb8239a6 fix(web): dockerfile 2026-03-12 13:38:18 +08:00
0f6d14d791 fix: migration 2026-03-12 13:34:52 +08:00
17f185f366 chore(csms): update migration files 2026-03-12 13:30:41 +08:00
d1bff8bfd9 feat: add grid view for IdTagsPage and toggle button 2026-03-12 13:30:02 +08:00
9f92b57371 feat: add card skins support 2026-03-12 13:19:46 +08:00
e759576b58 refactor(proxy): simplify isInitialized function and remove cookie caching 2026-03-12 10:18:06 +08:00
80 changed files with 18667 additions and 621 deletions

View File

@@ -4,7 +4,7 @@ _这是一个毕业设计项目旨在尝试实现一个完整的电动汽车
## 📋 项目概述
Helios EVCS 是一个全栈解决方案,用于管理和监控电动汽车充电基础设施。项目包含后端 CSMS充电管理系统前端 Web 云平台应用。并设计了一个基于 ESP32演示用的充电桩终端固件和 PCB 设计。
Helios EVCS 是一个全栈解决方案,用于管理和监控电动汽车充电基础设施。项目包含 CSMS充电管理系统前端应用和后端服务。并设计了一个基于 ESP32 的,可演示用的充电桩终端固件和 PCB 设计。
## 🏗️ 项目结构
@@ -15,19 +15,18 @@ helios-evcs/
│ └── web/ # 前端云平台应用Next.js 15 + React 19[submodule]
├── hardware/ # 硬件和固件工程
│ ├── firmware/ # 充电桩固件
│ └── pcb-kicad/ # PCB 设计文件KiCAD
│ └── pcb/ # PCB 设计文件KiCAD
├── package.json
├── pnpm-workspace.yaml
── pnpm-lock.yaml
└── .gitmodules
── pnpm-lock.yaml
```
## 📦 工作区包
### `apps/csms` - 充电管理系统后端
### `apps/csms` - CSMS 后端
- **技术栈**Node.js + Hono + TypeScript
- **端口**3001(默认)
- **端口**3001
- **职责**
- OCPP 协议实现
- 充电设备管理
@@ -35,16 +34,17 @@ helios-evcs/
- 实时数据处理
- RESTful API 接口
### `apps/web` - 前端管理界面(子模块)
### `apps/web` - CSMS 前端
- **技术栈**Next.js 15 + React 19 + Tailwind CSS
- **端口**3000(默认)
- **源仓库**https://github.com/HoshinoSuzumi/helios
- **技术栈**Next.js 16 + React 19 + Tailwind CSS
- **端口**3000
- **职责**
- 充电管理界面
- 实时监控面板
- 数据可视化
- 充电管理
- 概览监控面板
- 储值卡管理
- 用户管理
- 充电会话历史
- 远程启动/停止充电
### `hardware/` - 硬件和固件工程
@@ -52,17 +52,18 @@ helios-evcs/
- 设备驱动实现
- 通信协议栈
- 实时控制逻辑
- **pcb-kicad/** - PCB 电路板设计
- **pcb/** - PCB 电路板设计
- KiCAD 工程文件
- 电路原理图
- PCB 布局设计
- BOM 物料清单
- 制造文件Gerber
## 🚀 快速开始
### 前置要求
- Node.js >= 18
- Node.js >= 20
- pnpm >= 10.18.2
### 安装依赖
@@ -78,6 +79,8 @@ pnpm install
# 启动所有开发服务器CSMS + Web
pnpm dev
# 迁移数据库
pnpm --filter csms run db:migrate
# 仅启动后端
pnpm dev:csms
@@ -111,21 +114,11 @@ pnpm start:csms
# 启动前端
pnpm start:web
# 生产环境使用 docker compose
docker compose up -d --build
```
## 📝 Scripts 说明
| 命令 | 描述 |
| ----------------- | ---------------------- |
| `pnpm dev` | 启动所有服务的开发模式 |
| `pnpm dev:csms` | 启动后端开发服务器 |
| `pnpm dev:web` | 启动前端开发服务器 |
| `pnpm build` | 构建所有包 |
| `pnpm build:csms` | 构建后端 |
| `pnpm build:web` | 构建前端 |
| `pnpm start:csms` | 生产环境启动后端 |
| `pnpm start:web` | 生产环境启动前端 |
## 🔧 工作区管理
### 使用 pnpm filter 运行特定包命令
@@ -133,7 +126,7 @@ pnpm start:web
```bash
# 在指定包中运行命令
pnpm --filter csms <command>
pnpm --filter helios-web <command>
pnpm --filter web <command>
# 示例:在 csms 中运行测试
pnpm --filter csms run test
@@ -143,42 +136,11 @@ pnpm --filter csms run test
```bash
# 在根安装(所有包共用)
pnpm add <package>
pnpm add <package> -w
# 在特定包中安装
pnpm --filter csms add <package>
pnpm --filter helios-web add <package>
```
## 📁 Git 子模块管理
`apps/web` 作为 Git 子模块管理:
```bash
# 初始化并更新子模块
git submodule update --init --recursive
# 更新子模块到最新版本
git submodule update --remote
# 克隆包含子模块的仓库
git clone --recurse-submodules <repo-url>
```
在子模块内修改后的工作流:
```bash
# 在 apps/web 目录内提交更改
cd apps/web
git add .
git commit -m "feat: xxx"
git push
# 返回主仓库,更新子模块引用
cd ..
git add apps/web
git commit -m "chore: update helios submodule"
git push
pnpm --filter web add <package>
```
## 📚 技术文档

View File

@@ -1,4 +1,4 @@
CREATE TABLE "passkey" (
CREATE TABLE IF NOT EXISTS "passkey" (
"id" text PRIMARY KEY NOT NULL,
"name" text,
"public_key" text NOT NULL,

View File

@@ -0,0 +1,2 @@
ALTER TABLE "id_tag" ADD COLUMN "card_layout" varchar DEFAULT 'around';--> statement-breakpoint
ALTER TABLE "id_tag" ADD COLUMN "card_skin" varchar DEFAULT 'circles';

View File

@@ -0,0 +1,17 @@
CREATE TABLE "tariff" (
"id" varchar PRIMARY KEY NOT NULL,
"slots" jsonb NOT NULL,
"peak_electricity_price" integer DEFAULT 0 NOT NULL,
"peak_service_fee" integer DEFAULT 0 NOT NULL,
"valley_electricity_price" integer DEFAULT 0 NOT NULL,
"valley_service_fee" integer DEFAULT 0 NOT NULL,
"flat_electricity_price" integer DEFAULT 0 NOT NULL,
"flat_service_fee" integer DEFAULT 0 NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "charge_point" ADD COLUMN "pricing_mode" varchar DEFAULT 'fixed' NOT NULL;--> statement-breakpoint
ALTER TABLE "transaction" ADD COLUMN "electricity_fee" integer;--> statement-breakpoint
ALTER TABLE "transaction" ADD COLUMN "service_fee" integer;

View File

@@ -0,0 +1 @@
ALTER TABLE "charge_point" ADD COLUMN "device_name" varchar(100);

View File

@@ -0,0 +1 @@
ALTER TABLE "charge_point" ADD COLUMN "password_hash" varchar(255);

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
);

View File

@@ -0,0 +1,9 @@
ALTER TABLE "charge_point" ADD COLUMN "last_ws_connected_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "charge_point" ADD COLUMN "last_ws_disconnected_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "charge_point" ADD COLUMN "connection_session_id" varchar(64);--> statement-breakpoint
ALTER TABLE "charge_point" ADD COLUMN "transport_status" varchar DEFAULT 'offline' NOT NULL;--> statement-breakpoint
ALTER TABLE "charge_point" ADD COLUMN "last_command_status" varchar;--> statement-breakpoint
ALTER TABLE "charge_point" ADD COLUMN "last_command_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "transaction" ADD COLUMN "remote_stop_requested_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "transaction" ADD COLUMN "remote_stop_request_id" varchar(64);--> statement-breakpoint
ALTER TABLE "transaction" ADD COLUMN "remote_stop_status" varchar;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,48 @@
"when": 1773159362406,
"tag": "0001_superb_the_twelve",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1773293422460,
"tag": "0002_sweet_the_professor",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1773307380017,
"tag": "0003_milky_supreme_intelligence",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1773639782622,
"tag": "0004_nervous_frog_thor",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1773678571220,
"tag": "0005_peaceful_anthem",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1773682931777,
"tag": "0006_spooky_skin",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1773819865056,
"tag": "0007_unusual_squadron_supreme",
"breakpoints": true
}
]
}

View File

@@ -62,6 +62,29 @@ export const chargePoint = pgTable('charge_point', {
heartbeatInterval: integer('heartbeat_interval').default(60),
/** 最后一次收到 Heartbeat.req 的时间UTC */
lastHeartbeatAt: timestamp('last_heartbeat_at', { withTimezone: true }),
/** 最近一次 WebSocket 连接建立时间UTC */
lastWsConnectedAt: timestamp('last_ws_connected_at', { withTimezone: true }),
/** 最近一次 WebSocket 连接关闭时间UTC */
lastWsDisconnectedAt: timestamp('last_ws_disconnected_at', { withTimezone: true }),
/** 最近一次活跃 WebSocket 会话 ID用于区分重连代次 */
connectionSessionId: varchar('connection_session_id', { length: 64 }),
/**
* OCPP 传输通道状态:
* online = 当前实例持有活动 WebSocket
* unavailable = 最近有心跳,但当前无可用下行通道
* offline = 无活动通道
*/
transportStatus: varchar('transport_status', {
enum: ['online', 'unavailable', 'offline'],
})
.notNull()
.default('offline'),
/** 最近一次下行命令确认结果 */
lastCommandStatus: varchar('last_command_status', {
enum: ['Accepted', 'Rejected', 'Error', 'Timeout'],
}),
/** 最近一次下行命令确认时间UTC */
lastCommandAt: timestamp('last_command_at', { withTimezone: true }),
/** 最后一次收到 BootNotification.req 的时间UTC */
lastBootNotificationAt: timestamp('last_boot_notification_at', {
withTimezone: true,
@@ -69,9 +92,28 @@ export const chargePoint = pgTable('charge_point', {
/**
* 电价(单位:分/kWh即 0.01 CNY/kWh
* 交易结束时按实际用电量从储值卡扣费fee = ceil(energyWh * feePerKwh / 1000)
* 默认为 0即不计费
* 默认为 0即不计费。仅在 pricingMode = 'fixed' 时生效。
*/
/**
* 设备名称(系统内部维护,不会被设备上报信息覆盖)
* 供运营人员标记,例如"1号楼A区01号桩"
*/
deviceName: varchar('device_name', { length: 100 }),
feePerKwh: integer('fee_per_kwh').notNull().default(0),
/**
* 计费模式
* fixed = 使用本桩固定电价 feePerKwh
* tou = 使用系统峰谷电价配置
*/
pricingMode: varchar('pricing_mode', { enum: ['fixed', 'tou'] })
.notNull()
.default('fixed'),
/**
* OCPP Security Profile 1/2: HTTP Basic Auth 密码哈希scrypt
* 格式:<salt>:<hash>,均为 hex 编码
* 首次创建充电桩时自动生成,明文密码仅在创建/重置时返回一次
*/
passwordHash: varchar('password_hash', { length: 255 }),
createdAt: timestamp('created_at', { withTimezone: true })
.notNull()
.defaultNow(),
@@ -281,8 +323,15 @@ export const idTag = pgTable('id_tag', {
* 储值卡余额(单位:分)
* 以整数存储1 分 = 0.01 CNY前端显示时除以 100
*/
balance: integer('balance').notNull().default(0),
createdAt: timestamp('created_at', { withTimezone: true })
balance: integer('balance').notNull().default(0), /**
* 卡面内容排列方式
*/
cardLayout: varchar('card_layout', { enum: ['center', 'around'] }).default('around'),
/**
* 卡底装饰风格
* 对应 faces/ 目录中已注册的卡面组件
*/
cardSkin: varchar('card_skin', { enum: ['line', 'circles', 'glow', 'vip', 'redeye'] }).default('circles'), createdAt: timestamp('created_at', { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true })
@@ -372,12 +421,31 @@ export const transaction = pgTable(
'DeAuthorized',
],
}),
/** 管理端发起远程停止的请求时间 */
remoteStopRequestedAt: timestamp('remote_stop_requested_at', {
withTimezone: true,
}),
/** 管理端发起远程停止的 OCPP uniqueId */
remoteStopRequestId: varchar('remote_stop_request_id', { length: 64 }),
/** 最近一次远程停止请求的结果 */
remoteStopStatus: varchar('remote_stop_status', {
enum: ['Requested', 'Accepted', 'Rejected', 'Error', 'Timeout'],
}),
/**
* 本次充电扣费金额(单位:分)
* 由 StopTransaction 处理时根据实际用电量和充电桩电价计算写入
* null 表示未计费(如免费充电桩或交易异常终止)
*/
chargeAmount: integer('charge_amount'),
/**
* 电费部分(单位:分)= 各时段(电价 × 用电量)之和
* 注chargeAmount = electricityFee + serviceFee
*/
electricityFee: integer('electricity_fee'),
/**
* 服务费部分(单位:分)= 各时段(服务费 × 用电量)之和
*/
serviceFee: integer('service_fee'),
/**
* 关联的预约 ID若本次充电由预约触发
* StartTransaction.req.reservationIdoptional

View File

@@ -1,2 +1,4 @@
export * from './auth-schema.ts'
export * from './ocpp-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

@@ -0,0 +1,58 @@
import { pgTable, timestamp, varchar, integer, jsonb, boolean } from "drizzle-orm/pg-core";
// ---------------------------------------------------------------------------
// 峰谷电价配置 / Time-of-Use Tariff
// ---------------------------------------------------------------------------
/**
* 时段-电价类型映射
* "peak" 峰时 | "valley" 谷时 | "flat" 平时
*/
export type PriceTier = "peak" | "valley" | "flat";
/**
* 电价时段(紧凑格式)
* start起始小时023
* end结束小时不含124
* 示例:{ start: 8, end: 12, tier: "peak" } 表示 08:0012:00 为峰时
*/
export type TariffSlot = {
start: number;
end: number;
tier: PriceTier;
};
/**
* 峰谷电价配置表(单例,通过 isActive 标记当前生效的版本)
*
* 价格以整数"分"存储1 分 = 0.01 CNY精度 0.01 元/kWh
* fen = Math.round(CNY * 100)
* CNY = fen / 100
*
* 时段列表存储为 JSONBTariffSlot[])。
* 若无任何 isActive=true 的记录,计费逻辑将回退到 chargePoint.feePerKwh 平均电价。
*/
export const tariff = pgTable("tariff", {
id: varchar("id").primaryKey(),
/** 24 小时时段列表紧凑格式TariffSlot[] */
slots: jsonb("slots").notNull().$type<TariffSlot[]>(),
/** 峰时电价(分/kWh */
peakElectricityPrice: integer("peak_electricity_price").notNull().default(0),
/** 峰时服务费(分/kWh */
peakServiceFee: integer("peak_service_fee").notNull().default(0),
/** 谷时电价(分/kWh */
valleyElectricityPrice: integer("valley_electricity_price").notNull().default(0),
/** 谷时服务费(分/kWh */
valleyServiceFee: integer("valley_service_fee").notNull().default(0),
/** 平时电价(分/kWh */
flatElectricityPrice: integer("flat_electricity_price").notNull().default(0),
/** 平时服务费(分/kWh */
flatServiceFee: integer("flat_service_fee").notNull().default(0),
/** 是否为当前生效配置(同时只允许一条 isActive=true */
isActive: boolean("is_active").notNull().default(true),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
});

View File

@@ -8,6 +8,10 @@ import { logger } from 'hono/logger'
import { showRoutes } from 'hono/dev'
import { auth } from './lib/auth.ts'
import { createOcppHandler } from './ocpp/handler.ts'
import { verifyOcppPassword } from './lib/ocpp-auth.ts'
import { useDrizzle } from './lib/db.ts'
import { chargePoint } from './db/schema.ts'
import { eq } from 'drizzle-orm'
import statsRoutes from './routes/stats.ts'
import statsChartRoutes from './routes/stats-chart.ts'
import chargePointRoutes from './routes/charge-points.ts'
@@ -15,6 +19,8 @@ import transactionRoutes from './routes/transactions.ts'
import idTagRoutes from './routes/id-tags.ts'
import userRoutes from './routes/users.ts'
import setupRoutes from './routes/setup.ts'
import tariffRoutes from './routes/tariff.ts'
import settingsRoutes from './routes/settings.ts'
import type { HonoEnv } from './types/hono.ts'
@@ -41,7 +47,7 @@ app.use(
'/api/*',
cors({
origin: process.env.WEB_ORIGIN ?? '*',
allowMethods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
exposeHeaders: ['Content-Length'],
credentials: true,
@@ -58,6 +64,8 @@ app.route('/api/transactions', transactionRoutes)
app.route('/api/id-tags', idTagRoutes)
app.route('/api/users', userRoutes)
app.route('/api/setup', setupRoutes)
app.route('/api/tariff', tariffRoutes)
app.route('/api/settings', settingsRoutes)
app.get('/api', (c) => {
const user = c.get('user')
@@ -81,6 +89,43 @@ app.get('/api', (c) => {
app.get(
'/ocpp/:chargePointId',
async (c, next) => {
const chargePointId = c.req.param('chargePointId')
const authHeader = c.req.header('Authorization')
if (!authHeader?.startsWith('Basic ')) {
c.header('WWW-Authenticate', 'Basic realm="OCPP"')
return c.json({ error: 'Unauthorized' }, 401)
}
let id: string, password: string
try {
const decoded = atob(authHeader.slice(6))
const colonIdx = decoded.indexOf(':')
if (colonIdx === -1) throw new Error('Invalid format')
id = decoded.slice(0, colonIdx)
password = decoded.slice(colonIdx + 1)
} catch {
return c.json({ error: 'Invalid Authorization header' }, 400)
}
if (id !== chargePointId) {
return c.json({ error: 'Unauthorized' }, 401)
}
const db = useDrizzle()
const [cp] = await db
.select({ passwordHash: chargePoint.passwordHash })
.from(chargePoint)
.where(eq(chargePoint.chargePointIdentifier, chargePointId))
.limit(1)
if (!cp?.passwordHash || !(await verifyOcppPassword(password, cp.passwordHash))) {
return c.json({ error: 'Unauthorized' }, 401)
}
await next()
},
upgradeWebSocket((c) => {
const chargePointId = c.req.param('chargePointId')
const connInfo = getConnInfo(c)

View File

@@ -0,0 +1,32 @@
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
import { promisify } from 'node:util'
const scryptAsync = promisify(scrypt)
const SALT_LEN = 16
const KEY_LEN = 64
/** 生成随机明文密码24 位 hex 字符串) */
export function generateOcppPassword(): string {
return randomBytes(12).toString('hex')
}
/** 将明文密码哈希为存储格式 `<salt_hex>:<hash_hex>` */
export async function hashOcppPassword(password: string): Promise<string> {
const salt = randomBytes(SALT_LEN)
const hash = (await scryptAsync(password, salt, KEY_LEN)) as Buffer
return `${salt.toString('hex')}:${hash.toString('hex')}`
}
/** 验证明文密码是否与存储的哈希匹配 */
export async function verifyOcppPassword(
password: string,
stored: string,
): Promise<boolean> {
const [saltHex, hashHex] = stored.split(':')
if (!saltHex || !hashHex) return false
const salt = Buffer.from(saltHex, 'hex')
const expectedHash = Buffer.from(hashHex, 'hex')
const actualHash = (await scryptAsync(password, salt, KEY_LEN)) as Buffer
return timingSafeEqual(expectedHash, actualHash)
}

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,7 +1,7 @@
import { eq } from "drizzle-orm";
import { and, eq, isNull } from "drizzle-orm";
import dayjs from "dayjs";
import { useDrizzle } from "@/lib/db.js";
import { idTag } from "@/db/schema.js";
import { idTag, transaction } from "@/db/schema.js";
import type {
AuthorizeRequest,
AuthorizeResponse,
@@ -19,6 +19,7 @@ import type {
export async function resolveIdTagInfo(
idTagValue: string,
checkBalance = true,
checkConcurrent = true,
): Promise<IdTagInfo> {
const db = useDrizzle();
const [tag] = await db.select().from(idTag).where(eq(idTag.idTag, idTagValue)).limit(1);
@@ -31,6 +32,17 @@ export async function resolveIdTagInfo(
if (tag.status !== "Accepted") {
return { status: tag.status as IdTagInfo["status"] };
}
// Enforce single active transaction per idTag.
if (checkConcurrent) {
const [activeTx] = await db
.select({ id: transaction.id })
.from(transaction)
.where(and(eq(transaction.idTag, idTagValue), isNull(transaction.stopTimestamp)))
.limit(1);
if (activeTx) return { status: "ConcurrentTx" };
}
// Reject if balance is zero or negative
if (checkBalance && tag.balance <= 0) {
return { status: "Blocked" };

View File

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

View File

@@ -16,7 +16,11 @@ export async function handleHeartbeat(
await db
.update(chargePoint)
.set({ lastHeartbeatAt: dayjs().toDate() })
.set({
lastHeartbeatAt: dayjs().toDate(),
transportStatus: 'online',
updatedAt: dayjs().toDate(),
})
.where(eq(chargePoint.chargePointIdentifier, ctx.chargePointIdentifier))
return {

View File

@@ -73,7 +73,7 @@ export async function handleStartTransaction(
if (rejected) {
await db
.update(connector)
.set({ status: "Available", updatedAt: now })
.set({ status: "Available", updatedAt: now.toDate() })
.where(eq(connector.id, conn.id));
}

View File

@@ -1,7 +1,10 @@
import { eq, sql } from "drizzle-orm";
import { asc, eq, sql } from "drizzle-orm";
import dayjs from "dayjs";
import { useDrizzle } from "@/lib/db.js";
import { chargePoint, connector, idTag, meterValue, transaction } from "@/db/schema.js";
import { tariff } from "@/db/schema.js";
import type { SampledValue } from "@/db/schema.js";
import type { TariffSlot, PriceTier } from "@/db/tariff-schema.ts";
import type {
StopTransactionRequest,
StopTransactionResponse,
@@ -23,6 +26,11 @@ export async function handleStopTransaction(
stopMeterValue: payload.meterStop,
stopIdTag: payload.idTag ?? null,
stopReason: (payload.reason as (typeof transaction.$inferSelect)["stopReason"]) ?? null,
remoteStopStatus: sql`case
when ${transaction.remoteStopRequestedAt} is not null and coalesce(${transaction.remoteStopStatus}, 'Requested') in ('Requested', 'Accepted')
then 'Accepted'
else ${transaction.remoteStopStatus}
end`,
updatedAt: dayjs().toDate(),
})
.where(eq(transaction.id, payload.transactionId))
@@ -64,20 +72,103 @@ export async function handleStopTransaction(
const energyWh = payload.meterStop - tx.startMeterValue;
// Deduct balance from the idTag based on actual energy consumed
// Load active tariff and charge point fee
const [cp] = await db
.select({ feePerKwh: chargePoint.feePerKwh })
.select({ feePerKwh: chargePoint.feePerKwh, pricingMode: chargePoint.pricingMode })
.from(chargePoint)
.where(eq(chargePoint.id, tx.chargePointId))
.limit(1);
const feeFen =
cp && cp.feePerKwh > 0 && energyWh > 0 ? Math.ceil((energyWh * cp.feePerKwh) / 1000) : 0;
const [activeTariff] = await db.select().from(tariff).where(eq(tariff.isActive, true)).limit(1);
let electricityFen: number | null = null;
let serviceFeeFen: number | null = null;
let feeFen = 0;
if (activeTariff && cp?.pricingMode === 'tou' && energyWh > 0 && tx.startTimestamp) {
const stopTs = dayjs(payload.timestamp).toDate();
const slots = activeTariff.slots as TariffSlot[];
const priceMap: Record<PriceTier, { electricity: number; service: number }> = {
peak: {
electricity: activeTariff.peakElectricityPrice,
service: activeTariff.peakServiceFee,
},
valley: {
electricity: activeTariff.valleyElectricityPrice,
service: activeTariff.valleyServiceFee,
},
flat: {
electricity: activeTariff.flatElectricityPrice,
service: activeTariff.flatServiceFee,
},
};
// Build checkpoints from intermediate meter value readings.
// Each checkpoint is (timestamp, cumulativeWh), sorted ascending.
// Using actual intermediate readings lets each interval's known energy delta
// be assigned to its true time slot rather than assuming uniform charging rate.
const intervalReadings = await db
.select({ timestamp: meterValue.timestamp, sampledValues: meterValue.sampledValues })
.from(meterValue)
.where(eq(meterValue.transactionId, tx.id))
.orderBy(asc(meterValue.timestamp));
const checkpoints: { ts: Date; wh: number }[] = [
{ ts: tx.startTimestamp, wh: tx.startMeterValue },
];
for (const mv of intervalReadings) {
const svList = mv.sampledValues as SampledValue[];
// Per OCPP 1.6 §7.17: absent measurand defaults to Energy.Active.Import.Register
const energySv = svList.find(
(sv) => (!sv.measurand || sv.measurand === "Energy.Active.Import.Register") && !sv.phase,
);
if (!energySv) continue;
const rawVal = parseFloat(energySv.value);
if (isNaN(rawVal)) continue;
const wh = (energySv.unit ?? "Wh") === "kWh" ? rawVal * 1000 : rawVal;
checkpoints.push({ ts: mv.timestamp, wh });
}
checkpoints.push({ ts: stopTs, wh: payload.meterStop });
// For each interval, compute actual energy delta and distribute across tiers
// by walking minute-by-minute only within that interval.
const tierEnergyWh: Record<PriceTier, number> = { peak: 0, valley: 0, flat: 0 };
for (let i = 0; i + 1 < checkpoints.length; i++) {
const { ts: t1, wh: wh1 } = checkpoints[i];
const { ts: t2, wh: wh2 } = checkpoints[i + 1];
const deltaWh = Math.max(0, wh2 - wh1);
if (deltaWh === 0) continue;
const fractions = calcTierFractions(t1, t2, slots);
for (const tier of ["peak", "valley", "flat"] as PriceTier[]) {
tierEnergyWh[tier] += deltaWh * fractions[tier];
}
}
let elecFen = 0;
let svcFen = 0;
for (const tier of ["peak", "valley", "flat"] as PriceTier[]) {
const energyKwh = tierEnergyWh[tier] / 1000;
elecFen += energyKwh * priceMap[tier].electricity;
svcFen += energyKwh * priceMap[tier].service;
}
electricityFen = Math.ceil(elecFen);
serviceFeeFen = Math.ceil(svcFen);
feeFen = electricityFen + serviceFeeFen;
} else if (cp && cp.feePerKwh > 0 && energyWh > 0) {
// Fallback: flat rate per charge point
feeFen = Math.ceil((energyWh * cp.feePerKwh) / 1000);
}
// Always record the charge amount (0 if free)
await db
.update(transaction)
.set({ chargeAmount: feeFen, updatedAt: dayjs().toDate() })
.set({
chargeAmount: feeFen,
electricityFee: electricityFen,
serviceFee: serviceFeeFen,
updatedAt: dayjs().toDate(),
})
.where(eq(transaction.id, tx.id));
if (feeFen > 0) {
@@ -92,11 +183,59 @@ export async function handleStopTransaction(
console.log(
`[OCPP] StopTransaction txId=${payload.transactionId} ` +
`reason=${payload.reason ?? "none"} energyWh=${energyWh} feeFen=${feeFen}`,
`reason=${payload.reason ?? "none"} energyWh=${energyWh} ` +
`feeFen=${feeFen} (elec=${electricityFen ?? "flat"} svc=${serviceFeeFen ?? "-"})`,
);
// Resolve idTagInfo for the stop tag if provided (no balance check — charging already occurred)
const idTagInfo = payload.idTag ? await resolveIdTagInfo(payload.idTag, false) : undefined;
const idTagInfo = payload.idTag
? await resolveIdTagInfo(payload.idTag, false, false)
: undefined;
return { idTagInfo };
}
// ── helpers ────────────────────────────────────────────────────────────────
/**
* Given a charging session's start/stop timestamps and the active tariff slots,
* returns the fraction of total session duration spent in each price tier.
* Fractions sum to 1.0.
*
* Strategy: walk minute-by-minute over the session and bucket each minute into
* its tier. Using minutes keeps the implementation simple and introduces at most
* 1 minute of rounding error (negligible for billing amounts in fen).
*/
function calcTierFractions(
start: Date,
stop: Date,
slots: TariffSlot[],
): Record<PriceTier, number> {
const totals: Record<PriceTier, number> = { peak: 0, valley: 0, flat: 0 };
// Build a 24-element hour → tier lookup
const hourTier: PriceTier[] = [];
for (let i = 0; i < 24; i++) hourTier.push("flat");
for (const slot of slots) {
for (let h = slot.start; h < slot.end; h++) {
hourTier[h] = slot.tier;
}
}
const startMs = start.getTime();
const stopMs = stop.getTime();
const totalMinutes = Math.max(1, Math.round((stopMs - startMs) / 60_000));
for (let m = 0; m < totalMinutes; m++) {
const t = new Date(startMs + m * 60_000);
// 北京时间 = UTC + 8h峰谷时段以北京时间为准
const hourBeijing = Math.floor((t.getUTCHours() * 60 + t.getUTCMinutes() + 480) / 60) % 24;
totals[hourTier[hourBeijing]]++;
}
return {
peak: totals.peak / totalMinutes,
valley: totals.valley / totalMinutes,
flat: totals.flat / totalMinutes,
};
}

View File

@@ -1,11 +1,18 @@
import type { WSContext } from 'hono/ws'
import dayjs from 'dayjs'
import { eq } from 'drizzle-orm'
import { isSupportedOCPP } from '@/constants.js'
import { useDrizzle } from '@/lib/db.js'
import { chargePoint } from '@/db/schema.js'
import {
OCPP_MESSAGE_TYPE,
type OcppCall,
type OcppCallErrorMessage,
type OcppCallResultMessage,
type OcppErrorCode,
type OcppMessage,
type OcppConnectionContext,
type CommandChannelStatus,
type AuthorizeRequest,
type AuthorizeResponse,
type BootNotificationRequest,
@@ -24,9 +31,26 @@ import {
/**
* Global registry of active OCPP WebSocket connections.
* Key = chargePointIdentifier, Value = WSContext
* Key = chargePointIdentifier, Value = connection entry
*/
export const ocppConnections = new Map<string, WSContext>()
export type OcppConnectionEntry = {
ws: WSContext
sessionId: string
openedAt: Date
lastMessageAt: Date
}
type PendingCall =
| {
chargePointIdentifier: string
action: string
resolve: (payload: unknown) => void
reject: (error: Error) => void
timeout: ReturnType<typeof setTimeout>
}
export const ocppConnections = new Map<string, OcppConnectionEntry>()
const pendingCalls = new Map<string, PendingCall>()
import { handleAuthorize } from './actions/authorize.ts'
import { handleBootNotification } from './actions/boot-notification.ts'
import { handleHeartbeat } from './actions/heartbeat.ts'
@@ -92,6 +116,80 @@ function sendCallError(
)
}
async function updateTransportState(
chargePointIdentifier: string,
values: Partial<typeof chargePoint.$inferInsert>,
): Promise<void> {
const db = useDrizzle()
await db
.update(chargePoint)
.set({
...values,
updatedAt: dayjs().toDate(),
})
.where(eq(chargePoint.chargePointIdentifier, chargePointIdentifier))
}
function getCommandChannelStatus(chargePointIdentifier: string): CommandChannelStatus {
return ocppConnections.has(chargePointIdentifier) ? 'online' : 'unavailable'
}
export async function sendOcppCall<TPayload extends Record<string, unknown>, TResult = unknown>(
chargePointIdentifier: string,
action: string,
payload: TPayload,
timeoutOrOptions: number | { timeoutMs?: number; uniqueId?: string } = 15000,
): Promise<TResult> {
const entry = ocppConnections.get(chargePointIdentifier)
if (!entry) {
await updateTransportState(chargePointIdentifier, { transportStatus: 'unavailable' })
throw new Error('TransportUnavailable')
}
const timeoutMs =
typeof timeoutOrOptions === 'number' ? timeoutOrOptions : (timeoutOrOptions.timeoutMs ?? 15000)
const uniqueId =
typeof timeoutOrOptions === 'number' ? crypto.randomUUID() : (timeoutOrOptions.uniqueId ?? crypto.randomUUID())
const resultPromise = new Promise<TResult>((resolve, reject) => {
const timeout = setTimeout(async () => {
pendingCalls.delete(uniqueId)
await updateTransportState(chargePointIdentifier, {
transportStatus: getCommandChannelStatus(chargePointIdentifier),
lastCommandStatus: 'Timeout',
lastCommandAt: dayjs().toDate(),
})
reject(new Error('CommandTimeout'))
}, timeoutMs)
pendingCalls.set(uniqueId, {
chargePointIdentifier,
action,
resolve: (response) => resolve(response as TResult),
reject,
timeout,
})
})
try {
entry.ws.send(JSON.stringify([OCPP_MESSAGE_TYPE.CALL, uniqueId, action, payload]))
} catch (error) {
const pending = pendingCalls.get(uniqueId)
if (pending) {
clearTimeout(pending.timeout)
pendingCalls.delete(uniqueId)
}
await updateTransportState(chargePointIdentifier, {
transportStatus: 'unavailable',
lastCommandStatus: 'Error',
lastCommandAt: dayjs().toDate(),
})
throw error instanceof Error ? error : new Error('CommandSendFailed')
}
return resultPromise
}
/**
* Factory that produces a hono-ws event handler object for a single
* OCPP WebSocket connection.
@@ -104,15 +202,26 @@ export function createOcppHandler(chargePointIdentifier: string, remoteAddr?: st
chargePointIdentifier,
isRegistered: false,
}
const sessionId = crypto.randomUUID()
return {
onOpen(_evt: Event, ws: WSContext) {
async onOpen(_evt: Event, ws: WSContext) {
const subProtocol = ws.protocol ?? 'unknown'
if (!isSupportedOCPP(subProtocol)) {
ws.close(1002, 'Unsupported subprotocol')
return
}
ocppConnections.set(chargePointIdentifier, ws)
ocppConnections.set(chargePointIdentifier, {
ws,
sessionId,
openedAt: new Date(),
lastMessageAt: new Date(),
})
await updateTransportState(chargePointIdentifier, {
transportStatus: 'online',
connectionSessionId: sessionId,
lastWsConnectedAt: dayjs().toDate(),
})
console.log(
`[OCPP] ${chargePointIdentifier} connected` +
(remoteAddr ? ` from ${remoteAddr}` : ''),
@@ -122,6 +231,11 @@ export function createOcppHandler(chargePointIdentifier: string, remoteAddr?: st
async onMessage(evt: MessageEvent, ws: WSContext) {
let uniqueId = '(unknown)'
try {
const current = ocppConnections.get(chargePointIdentifier)
if (current) {
current.lastMessageAt = new Date()
}
const raw = evt.data
if (typeof raw !== 'string') return
@@ -141,7 +255,36 @@ export function createOcppHandler(chargePointIdentifier: string, remoteAddr?: st
const [messageType, msgUniqueId] = message
uniqueId = String(msgUniqueId)
// CSMS only handles CALL messages from the charge point
if (messageType === OCPP_MESSAGE_TYPE.CALLRESULT) {
const [, responseUniqueId, payload] = message as OcppCallResultMessage
const pending = pendingCalls.get(responseUniqueId)
if (!pending) return
clearTimeout(pending.timeout)
pendingCalls.delete(responseUniqueId)
await updateTransportState(pending.chargePointIdentifier, {
transportStatus: getCommandChannelStatus(pending.chargePointIdentifier),
lastCommandStatus: 'Accepted',
lastCommandAt: dayjs().toDate(),
})
pending.resolve(payload)
return
}
if (messageType === OCPP_MESSAGE_TYPE.CALLERROR) {
const [, responseUniqueId, errorCode, errorDescription] = message as OcppCallErrorMessage
const pending = pendingCalls.get(responseUniqueId)
if (!pending) return
clearTimeout(pending.timeout)
pendingCalls.delete(responseUniqueId)
await updateTransportState(pending.chargePointIdentifier, {
transportStatus: getCommandChannelStatus(pending.chargePointIdentifier),
lastCommandStatus: errorCode === 'InternalError' ? 'Error' : 'Rejected',
lastCommandAt: dayjs().toDate(),
})
pending.reject(new Error(`${errorCode}:${errorDescription}`))
return
}
if (messageType !== OCPP_MESSAGE_TYPE.CALL) return
const [, , action, payload] = message as OcppCall
@@ -174,8 +317,15 @@ export function createOcppHandler(chargePointIdentifier: string, remoteAddr?: st
}
},
onClose(evt: CloseEvent, _ws: WSContext) {
ocppConnections.delete(chargePointIdentifier)
async onClose(evt: CloseEvent, _ws: WSContext) {
const current = ocppConnections.get(chargePointIdentifier)
if (current?.sessionId === sessionId) {
ocppConnections.delete(chargePointIdentifier)
await updateTransportState(chargePointIdentifier, {
transportStatus: 'offline',
lastWsDisconnectedAt: dayjs().toDate(),
})
}
console.log(`[OCPP] ${chargePointIdentifier} disconnected (code=${evt.code})`)
},
}

View File

@@ -34,6 +34,11 @@ export type OcppConnectionContext = {
isRegistered: boolean
}
export type OcppCallResultMessage = [3, string, Record<string, unknown>]
export type OcppCallErrorMessage = [4, string, OcppErrorCode, string, Record<string, unknown>]
export type CommandChannelStatus = 'online' | 'unavailable' | 'offline'
// ---------------------------------------------------------------------------
// Action payload types (OCPP 1.6-J Section 4.x)
// ---------------------------------------------------------------------------

View File

@@ -1,8 +1,10 @@
import { Hono } from "hono";
import { desc, eq, sql } from "drizzle-orm";
import { desc, eq, sql, inArray } from "drizzle-orm";
import dayjs from "dayjs";
import { useDrizzle } from "@/lib/db.js";
import { chargePoint, connector } 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";
const app = new Hono<HonoEnv>();
@@ -69,6 +71,8 @@ app.post("/", async (c) => {
chargePointModel?: string;
registrationStatus?: "Accepted" | "Pending" | "Rejected";
feePerKwh?: number;
pricingMode?: "fixed" | "tou";
deviceName?: string;
}>();
if (!body.chargePointIdentifier?.trim()) {
@@ -77,6 +81,12 @@ app.post("/", async (c) => {
if (body.feePerKwh !== undefined && (!Number.isInteger(body.feePerKwh) || body.feePerKwh < 0)) {
return c.json({ error: "feePerKwh must be a non-negative integer" }, 400);
}
if (body.pricingMode !== undefined && !['fixed', 'tou'].includes(body.pricingMode)) {
return c.json({ error: "pricingMode must be 'fixed' or 'tou'" }, 400);
}
const plainPassword = generateOcppPassword()
const passwordHash = await hashOcppPassword(plainPassword)
const [created] = await db
.insert(chargePoint)
@@ -87,6 +97,9 @@ app.post("/", async (c) => {
chargePointModel: body.chargePointModel?.trim() || "Unknown",
registrationStatus: body.registrationStatus ?? "Pending",
feePerKwh: body.feePerKwh ?? 0,
pricingMode: body.pricingMode ?? "fixed",
deviceName: body.deviceName?.trim() || null,
passwordHash,
})
.returning()
.catch((err: Error) => {
@@ -94,7 +107,15 @@ app.post("/", async (c) => {
throw err;
});
return c.json({ ...created, connectors: [] }, 201);
// 明文密码仅在创建时返回一次,之后不可再查
return c.json({ ...created, passwordHash: undefined, plainPassword, connectors: [] }, 201);
});
/** GET /api/charge-points/connections — list currently active OCPP WebSocket connections */
app.get("/connections", (c) => {
return c.json({
connectedIdentifiers: Array.from(ocppConnections.keys()),
});
});
/** GET /api/charge-points/:id — single charge point */
@@ -125,16 +146,20 @@ app.patch("/:id", async (c) => {
const id = c.req.param("id");
const body = await c.req.json<{
feePerKwh?: number;
pricingMode?: "fixed" | "tou";
registrationStatus?: string;
chargePointVendor?: string;
chargePointModel?: string;
deviceName?: string | null;
}>();
const set: {
feePerKwh?: number;
pricingMode?: "fixed" | "tou";
registrationStatus?: "Accepted" | "Pending" | "Rejected";
chargePointVendor?: string;
chargePointModel?: string;
deviceName?: string | null;
updatedAt: Date;
} = { updatedAt: dayjs().toDate() };
@@ -152,6 +177,13 @@ app.patch("/:id", async (c) => {
}
if (body.chargePointVendor !== undefined) set.chargePointVendor = body.chargePointVendor.trim() || "Unknown";
if (body.chargePointModel !== undefined) set.chargePointModel = body.chargePointModel.trim() || "Unknown";
if ("deviceName" in body) set.deviceName = body.deviceName?.trim() || null;
if (body.pricingMode !== undefined) {
if (!['fixed', 'tou'].includes(body.pricingMode)) {
return c.json({ error: "pricingMode must be 'fixed' or 'tou'" }, 400);
}
set.pricingMode = body.pricingMode;
}
const [updated] = await db
.update(chargePoint)
@@ -189,4 +221,25 @@ app.delete("/:id", async (c) => {
return c.json({ success: true });
});
/** POST /api/charge-points/:id/reset-password — regenerate OCPP Basic Auth password */
app.post("/:id/reset-password", async (c) => {
if (c.get("user")?.role !== "admin") return c.json({ error: "Forbidden" }, 403);
const db = useDrizzle();
const id = c.req.param("id");
const plainPassword = generateOcppPassword();
const passwordHash = await hashOcppPassword(plainPassword);
const [updated] = await db
.update(chargePoint)
.set({ passwordHash })
.where(eq(chargePoint.id, id))
.returning({ id: chargePoint.id, chargePointIdentifier: chargePoint.chargePointIdentifier });
if (!updated) return c.json({ error: "Not found" }, 404);
// 明文密码仅返回一次
return c.json({ ...updated, plainPassword });
});
export default app;

View File

@@ -16,6 +16,8 @@ const idTagSchema = z.object({
expiryDate: z.string().date().optional().nullable(),
userId: z.string().optional().nullable(),
balance: z.number().int().min(0).default(0),
cardLayout: z.enum(["center", "around"]).optional(),
cardSkin: z.enum(["line", "circles", "glow", "vip", "redeye"]).optional(),
});
const idTagUpdateSchema = idTagSchema.partial().omit({ idTag: true });

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

@@ -26,7 +26,9 @@ app.get("/", async (c) => {
db
.select({ count: sql<number>`count(*)::int` })
.from(chargePoint)
.where(sql`${chargePoint.lastHeartbeatAt} > now() - interval '120 seconds'`),
.where(
sql`${chargePoint.transportStatus} = 'online' and ${chargePoint.lastHeartbeatAt} > now() - interval '120 seconds'`,
),
db
.select({ count: sql<number>`count(*)::int` })
.from(transaction)

View File

@@ -0,0 +1,98 @@
import { Hono } from "hono";
import { eq } from "drizzle-orm";
import { useDrizzle } from "@/lib/db.js";
import { tariff } from "@/db/schema.js";
import type { HonoEnv } from "@/types/hono.ts";
import type { PriceTier, TariffSlot } from "@/db/tariff-schema.ts";
const app = new Hono<HonoEnv>();
/** GET /api/tariff — 返回当前生效的电价配置(任何已登录用户) */
app.get("/", async (c) => {
const db = useDrizzle();
const [row] = await db.select().from(tariff).where(eq(tariff.isActive, true)).limit(1);
if (!row) return c.json(null);
return c.json(rowToPayload(row));
});
/** PUT /api/tariff — 更新/初始化电价配置(仅管理员) */
app.put("/", async (c) => {
const currentUser = c.get("user");
if (currentUser?.role !== "admin") {
return c.json({ error: "Forbidden" }, 403);
}
type TierPricing = { electricityPrice: number; serviceFee: number };
type TariffBody = {
slots: TariffSlot[];
prices: Record<PriceTier, TierPricing>;
};
let body: TariffBody;
try {
body = await c.req.json<TariffBody>();
} catch {
return c.json({ error: "Invalid JSON" }, 400);
}
const { slots, prices } = body;
if (!Array.isArray(slots) || !prices) {
return c.json({ error: "Missing slots or prices" }, 400);
}
const db = useDrizzle();
// 停用旧配置
await db.update(tariff).set({ isActive: false });
const [row] = await db
.insert(tariff)
.values({
id: crypto.randomUUID(),
slots,
peakElectricityPrice: fenFromCny(prices.peak?.electricityPrice),
peakServiceFee: fenFromCny(prices.peak?.serviceFee),
valleyElectricityPrice: fenFromCny(prices.valley?.electricityPrice),
valleyServiceFee: fenFromCny(prices.valley?.serviceFee),
flatElectricityPrice: fenFromCny(prices.flat?.electricityPrice),
flatServiceFee: fenFromCny(prices.flat?.serviceFee),
isActive: true,
})
.returning();
return c.json(rowToPayload(row));
});
// ── helpers ────────────────────────────────────────────────────────────────
function fenFromCny(cny: number | undefined): number {
if (typeof cny !== "number" || isNaN(cny)) return 0;
return Math.round(cny * 100);
}
function rowToPayload(row: typeof tariff.$inferSelect) {
return {
id: row.id,
slots: row.slots as TariffSlot[],
prices: {
peak: {
electricityPrice: row.peakElectricityPrice / 100,
serviceFee: row.peakServiceFee / 100,
},
valley: {
electricityPrice: row.valleyElectricityPrice / 100,
serviceFee: row.valleyServiceFee / 100,
},
flat: {
electricityPrice: row.flatElectricityPrice / 100,
serviceFee: row.flatServiceFee / 100,
},
},
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
export default app;

View File

@@ -3,9 +3,10 @@ import { and, desc, eq, isNull, isNotNull, sql } from "drizzle-orm";
import dayjs from "dayjs";
import { useDrizzle } from "@/lib/db.js";
import { transaction, chargePoint, connector, idTag } from "@/db/schema.js";
import type { SampledValue } from "@/db/schema.js";
import { user } from "@/db/auth-schema.js";
import { ocppConnections } from "@/ocpp/handler.js";
import { OCPP_MESSAGE_TYPE } from "@/ocpp/types.js";
import { sendOcppCall } from "@/ocpp/handler.js";
import { resolveIdTagInfo } from "@/ocpp/actions/authorize.js";
import type { HonoEnv } from "@/types/hono.ts";
const app = new Hono<HonoEnv>();
@@ -20,11 +21,13 @@ app.post("/remote-start", async (c) => {
if (!currentUser) return c.json({ error: "Unauthorized" }, 401);
const db = useDrizzle();
const body = await c.req.json<{
chargePointIdentifier: string;
connectorId: number;
idTag: string;
}>().catch(() => null);
const body = await c.req
.json<{
chargePointIdentifier: string;
connectorId: number;
idTag: string;
}>()
.catch(() => null);
if (
!body ||
@@ -39,15 +42,33 @@ app.post("/remote-start", async (c) => {
);
}
// Non-admin: verify idTag belongs to current user and is Accepted
// Non-admin: verify idTag belongs to current user
if (currentUser.role !== "admin") {
const [tag] = await db
.select({ status: idTag.status })
.select({ idTag: idTag.idTag })
.from(idTag)
.where(and(eq(idTag.idTag, body.idTag.trim()), eq(idTag.userId, currentUser.id)))
.limit(1);
if (!tag) return c.json({ error: "idTag not found or not authorized" }, 403);
if (tag.status !== "Accepted") return c.json({ error: "idTag is not accepted" }, 400);
}
// Reuse the same authorization logic as Authorize/StartTransaction.
const tagInfo = await resolveIdTagInfo(body.idTag.trim());
if (tagInfo.status !== "Accepted") {
if (tagInfo.status === "ConcurrentTx") {
return c.json({ error: "ConcurrentTx: idTag already has an active transaction" }, 409);
}
return c.json({ error: `idTag rejected: ${tagInfo.status}` }, 400);
}
// One idTag can only have one active transaction at a time.
const [activeTx] = await db
.select({ id: transaction.id })
.from(transaction)
.where(and(eq(transaction.idTag, body.idTag.trim()), isNull(transaction.stopTimestamp)))
.limit(1);
if (activeTx) {
return c.json({ error: "ConcurrentTx: idTag already has an active transaction" }, 409);
}
// Verify charge point exists and is Accepted
@@ -62,19 +83,28 @@ app.post("/remote-start", async (c) => {
return c.json({ error: "ChargePoint is not accepted" }, 400);
}
// Require the charge point to be online
const ws = ocppConnections.get(body.chargePointIdentifier.trim());
if (!ws) return c.json({ error: "ChargePoint is offline" }, 503);
try {
const response = await sendOcppCall<
{ connectorId: number; idTag: string },
{ status?: string }
>(body.chargePointIdentifier.trim(), "RemoteStartTransaction", {
connectorId: body.connectorId,
idTag: body.idTag.trim(),
})
const uniqueId = crypto.randomUUID();
ws.send(
JSON.stringify([
OCPP_MESSAGE_TYPE.CALL,
uniqueId,
"RemoteStartTransaction",
{ connectorId: body.connectorId, idTag: body.idTag.trim() },
]),
);
if (response?.status && response.status !== "Accepted") {
return c.json({ error: `RemoteStartTransaction ${response.status}` }, 409)
}
} catch (error) {
const message = error instanceof Error ? error.message : "CommandSendFailed"
if (message === "TransportUnavailable") {
return c.json({ error: "ChargePoint command channel is unavailable" }, 503)
}
if (message === "CommandTimeout") {
return c.json({ error: "ChargePoint did not confirm RemoteStartTransaction in time" }, 504)
}
return c.json({ error: `RemoteStartTransaction failed: ${message}` }, 502)
}
console.log(
`[OCPP] RemoteStartTransaction cp=${body.chargePointIdentifier} ` +
@@ -122,6 +152,9 @@ app.get("/", async (c) => {
.select({
transaction,
chargePointIdentifier: chargePoint.chargePointIdentifier,
chargePointDeviceName: chargePoint.deviceName,
feePerKwh: chargePoint.feePerKwh,
pricingMode: chargePoint.pricingMode,
connectorNumber: connector.connectorId,
idTagUserId: idTag.userId,
idTagUserName: user.name,
@@ -136,18 +169,75 @@ app.get("/", async (c) => {
.limit(limit)
.offset(offset);
// For active transactions, fetch the latest meter reading to show live energy
const activeTxIds = rows.filter((r) => !r.transaction.stopTimestamp).map((r) => r.transaction.id);
// Map: transactionId -> latest cumulative Wh from meterValue
const latestMeterMap = new Map<number, number>();
if (activeTxIds.length > 0) {
// DISTINCT ON picks the most recent row per transaction_id
const latestRows = await db.execute<{
transaction_id: number;
sampled_values: SampledValue[];
}>(sql`
SELECT DISTINCT ON (transaction_id) transaction_id, sampled_values
FROM meter_value
WHERE transaction_id IN (${sql.join(
activeTxIds.map((id) => sql`${id}`),
sql`, `,
)})
ORDER BY transaction_id, timestamp DESC
`);
for (const row of latestRows.rows) {
const svList = row.sampled_values as SampledValue[];
const energySv = svList.find(
(sv) => (!sv.measurand || sv.measurand === "Energy.Active.Import.Register") && !sv.phase,
);
if (energySv != null) {
// Unit defaults to Wh; kWh is also common
const raw = parseFloat(energySv.value);
if (!Number.isNaN(raw)) {
const wh = energySv.unit === "kWh" ? raw * 1000 : raw;
latestMeterMap.set(Number(row.transaction_id), wh);
}
}
}
}
return c.json({
data: rows.map((r) => ({
...r.transaction,
chargePointIdentifier: r.chargePointIdentifier,
connectorNumber: r.connectorNumber,
idTagUserId: r.idTagUserId,
idTagUserName: r.idTagUserName,
energyWh:
r.transaction.stopMeterValue != null
? r.transaction.stopMeterValue - r.transaction.startMeterValue
: null,
})),
data: rows.map((r) => {
const isActive = !r.transaction.stopTimestamp;
const latestMeterWh = isActive ? (latestMeterMap.get(r.transaction.id) ?? null) : null;
const liveEnergyWh =
latestMeterWh != null ? latestMeterWh - r.transaction.startMeterValue : null;
// Estimated cost: only for fixed pricing (TOU requires full interval analysis)
let estimatedCost: number | null = null;
if (
isActive &&
liveEnergyWh != null &&
liveEnergyWh > 0 &&
r.pricingMode === "fixed" &&
(r.feePerKwh ?? 0) > 0
) {
estimatedCost = Math.ceil((liveEnergyWh * (r.feePerKwh ?? 0)) / 1000);
}
return {
...r.transaction,
chargePointIdentifier: r.chargePointIdentifier,
chargePointDeviceName: r.chargePointDeviceName,
connectorNumber: r.connectorNumber,
idTagUserId: r.idTagUserId,
idTagUserName: r.idTagUserName,
energyWh:
r.transaction.stopMeterValue != null
? r.transaction.stopMeterValue - r.transaction.startMeterValue
: null,
liveEnergyWh,
estimatedCost,
};
}),
total,
page,
totalPages: Math.max(1, Math.ceil(total / limit)),
@@ -163,30 +253,74 @@ app.get("/:id", async (c) => {
.select({
transaction,
chargePointIdentifier: chargePoint.chargePointIdentifier,
chargePointDeviceName: chargePoint.deviceName,
connectorNumber: connector.connectorId,
feePerKwh: chargePoint.feePerKwh,
pricingMode: chargePoint.pricingMode,
})
.from(transaction)
.leftJoin(chargePoint, eq(transaction.chargePointId, chargePoint.id))
.leftJoin(connector, eq(transaction.connectorId, connector.id))
.where(eq(transaction.id, id))
.limit(1);
if (!row) return c.json({ error: "Not found" }, 404);
let liveEnergyWh: number | null = null;
let estimatedCost: number | null = null;
// For active transactions, return live estimated energy/cost like the list endpoint.
if (!row.transaction.stopTimestamp) {
const latestRows = await db.execute<{
sampled_values: SampledValue[];
}>(sql`
SELECT sampled_values
FROM meter_value
WHERE transaction_id = ${id}
ORDER BY timestamp DESC
LIMIT 1
`);
const latest = latestRows.rows[0];
if (latest) {
const svList = latest.sampled_values as SampledValue[];
const energySv = svList.find(
(sv) => (!sv.measurand || sv.measurand === "Energy.Active.Import.Register") && !sv.phase,
);
if (energySv != null) {
const raw = parseFloat(energySv.value);
if (!Number.isNaN(raw) && row.transaction.startMeterValue != null) {
const latestMeterWh = energySv.unit === "kWh" ? raw * 1000 : raw;
liveEnergyWh = latestMeterWh - row.transaction.startMeterValue;
if (liveEnergyWh > 0 && row.pricingMode === "fixed" && (row.feePerKwh ?? 0) > 0) {
estimatedCost = Math.ceil((liveEnergyWh * (row.feePerKwh ?? 0)) / 1000);
}
}
}
}
}
return c.json({
...row.transaction,
chargePointIdentifier: row.chargePointIdentifier,
chargePointDeviceName: row.chargePointDeviceName,
connectorNumber: row.connectorNumber,
energyWh:
row.transaction.stopMeterValue != null
? row.transaction.stopMeterValue - row.transaction.startMeterValue
: null,
liveEnergyWh,
estimatedCost,
});
});
/**
* POST /api/transactions/:id/stop
* Manually stop an active transaction.
* 1. If the charge point is connected, send OCPP RemoteStopTransaction.
* 2. In either case (online or offline), settle the transaction in the DB immediately
* so the record is always finalised from the admin side.
* 1. If the charge point is connected, send OCPP RemoteStopTransaction and wait for confirmation.
* 2. Record the stop request state in DB; final settlement still happens on StopTransaction.
*/
app.post("/:id/stop", async (c) => {
const db = useDrizzle();
@@ -197,7 +331,6 @@ app.post("/:id/stop", async (c) => {
.select({
transaction,
chargePointIdentifier: chargePoint.chargePointIdentifier,
feePerKwh: chargePoint.feePerKwh,
})
.from(transaction)
.leftJoin(chargePoint, eq(transaction.chargePointId, chargePoint.id))
@@ -209,55 +342,74 @@ app.post("/:id/stop", async (c) => {
const now = dayjs();
// Try to send RemoteStopTransaction via OCPP if the charge point is online
const ws = row.chargePointIdentifier ? ocppConnections.get(row.chargePointIdentifier) : null;
if (ws) {
const uniqueId = crypto.randomUUID();
ws.send(
JSON.stringify([
OCPP_MESSAGE_TYPE.CALL,
uniqueId,
let remoteStopStatus: "Requested" | "Accepted" | "Rejected" | "Error" | "Timeout" = "Requested";
let remoteStopRequestId: string | null = null;
let online = false;
if (row.chargePointIdentifier) {
remoteStopRequestId = crypto.randomUUID();
try {
const response = await sendOcppCall<{ transactionId: number }, { status?: string }>(
row.chargePointIdentifier,
"RemoteStopTransaction",
{ transactionId: row.transaction.id },
]),
);
console.log(`[OCPP] Sent RemoteStopTransaction txId=${id} to ${row.chargePointIdentifier}`);
{ uniqueId: remoteStopRequestId },
)
online = true;
remoteStopStatus = response?.status === "Accepted" ? "Accepted" : "Rejected";
console.log(`[OCPP] RemoteStopTransaction txId=${id} status=${response?.status ?? "unknown"} to ${row.chargePointIdentifier}`);
} catch (error) {
const message = error instanceof Error ? error.message : "CommandSendFailed";
remoteStopStatus = message === "CommandTimeout" ? "Timeout" : "Error";
online = message !== "TransportUnavailable";
console.warn(`[OCPP] RemoteStopTransaction txId=${id} failed for ${row.chargePointIdentifier}: ${message}`);
if (message === "TransportUnavailable") {
return c.json(
{
error: "ChargePoint command channel is unavailable",
online: false,
remoteStopStatus,
},
503,
);
}
if (message === "CommandTimeout") {
return c.json(
{
error: "ChargePoint did not confirm RemoteStopTransaction in time",
online: true,
remoteStopStatus,
},
504,
);
}
return c.json(
{
error: `RemoteStopTransaction failed: ${message}`,
online,
remoteStopStatus,
},
502,
);
}
}
// Settle in DB regardless (charge point may be offline or slow to respond)
// Use startMeterValue as stopMeterValue when the real value is unknown (offline case)
const stopMeterValue = row.transaction.startMeterValue;
const energyWh = 0; // cannot know actual energy without stop meter value
const feePerKwh = row.feePerKwh ?? 0;
const feeFen = feePerKwh > 0 && energyWh > 0 ? Math.ceil((energyWh * feePerKwh) / 1000) : 0;
const [updated] = await db
.update(transaction)
.set({
stopTimestamp: now.toDate(),
stopMeterValue,
stopReason: "Remote",
chargeAmount: feeFen,
remoteStopRequestedAt: now.toDate(),
remoteStopRequestId,
remoteStopStatus,
updatedAt: now.toDate(),
})
.where(eq(transaction.id, id))
.returning();
if (feeFen > 0) {
await db
.update(idTag)
.set({
balance: sql`GREATEST(0, ${idTag.balance} - ${feeFen})`,
updatedAt: now.toDate(),
})
.where(eq(idTag.idTag, row.transaction.idTag));
}
return c.json({
...updated,
chargePointIdentifier: row.chargePointIdentifier,
online: !!ws,
energyWh,
online,
remoteStopStatus,
});
});

View File

@@ -22,7 +22,7 @@ COPY apps/web/components ./apps/web/components
COPY apps/web/lib ./apps/web/lib
COPY apps/web/types ./apps/web/types
COPY apps/web/public ./apps/web/public
COPY apps/web/middleware.ts ./apps/web/middleware.ts
COPY apps/web/proxy.ts ./apps/web/proxy.ts
RUN pnpm install --filter web

View File

@@ -15,11 +15,14 @@ import {
Spinner,
Table,
TextField,
Tooltip,
} from "@heroui/react";
import { ArrowLeft, Pencil, PlugConnection, ArrowRotateRight } from "@gravity-ui/icons";
import { api } from "@/lib/api";
import { ArrowLeft, Pencil, ArrowRotateRight, Key, Copy, Check } from "@gravity-ui/icons";
import { api, type ChargePointPasswordReset } from "@/lib/api";
import { useSession } from "@/lib/auth-client";
import dayjs from "@/lib/dayjs";
import InfoSection from "@/components/info-section";
import { Plug } from "lucide-react";
// ── Status maps ────────────────────────────────────────────────────────────
@@ -75,9 +78,11 @@ function relativeTime(iso: string): string {
// ── Edit form type ─────────────────────────────────────────────────────────
type EditForm = {
deviceName: string;
chargePointVendor: string;
chargePointModel: string;
registrationStatus: "Accepted" | "Pending" | "Rejected";
pricingMode: "fixed" | "tou";
feePerKwh: string;
};
@@ -93,12 +98,37 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
const [editOpen, setEditOpen] = useState(false);
const [editBusy, setEditBusy] = useState(false);
const [editForm, setEditForm] = useState<EditForm>({
deviceName: "",
chargePointVendor: "",
chargePointModel: "",
registrationStatus: "Pending",
pricingMode: "fixed",
feePerKwh: "0",
});
// reset password
const [resetBusy, setResetBusy] = useState(false);
const [resetResult, setResetResult] = useState<ChargePointPasswordReset | null>(null);
const [resetCopied, setResetCopied] = useState(false);
const handleResetPassword = async () => {
if (!cp) return;
setResetBusy(true);
try {
const result = await api.chargePoints.resetPassword(cp.id);
setResetResult(result);
} finally {
setResetBusy(false);
}
};
const handleCopyResetPassword = (text: string) => {
navigator.clipboard.writeText(text).then(() => {
setResetCopied(true);
setTimeout(() => setResetCopied(false), 2000);
});
};
const { isFetching: refreshing, ...cpQuery } = useQuery({
queryKey: ["chargePoint", id],
queryFn: () => api.chargePoints.get(id),
@@ -118,9 +148,11 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
const openEdit = () => {
if (!cp) return;
setEditForm({
deviceName: cp.deviceName ?? "",
chargePointVendor: cp.chargePointVendor ?? "",
chargePointModel: cp.chargePointModel ?? "",
registrationStatus: cp.registrationStatus as EditForm["registrationStatus"],
pricingMode: cp.pricingMode,
feePerKwh: String(cp.feePerKwh),
});
setEditOpen(true);
@@ -135,7 +167,9 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
chargePointVendor: editForm.chargePointVendor,
chargePointModel: editForm.chargePointModel,
registrationStatus: editForm.registrationStatus,
feePerKwh: fee,
pricingMode: editForm.pricingMode,
feePerKwh: editForm.pricingMode === "fixed" ? fee : 0,
deviceName: editForm.deviceName.trim() || null,
});
await cpQuery.refetch();
setEditOpen(false);
@@ -146,8 +180,16 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
// Online if last heartbeat within 3× interval
const isOnline =
cp?.transportStatus === "online" &&
cp?.lastHeartbeatAt != null &&
dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < (cp.heartbeatInterval ?? 60) * 3;
const commandChannelUnavailable = cp?.transportStatus === "unavailable";
const statusLabel = isOnline ? "在线" : commandChannelUnavailable ? "通道异常" : "离线";
const statusDotClass = isOnline
? "bg-success animate-pulse"
: commandChannelUnavailable
? "bg-warning"
: "bg-muted";
const { data: sessionData } = useSession();
const isAdmin = sessionData?.user?.role === "admin";
@@ -196,9 +238,12 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-1.5">
<div className="flex flex-wrap items-center gap-2">
<h1 className="font-mono text-2xl font-semibold text-foreground">
{cp.chargePointIdentifier}
<h1 className="text-2xl font-semibold text-foreground">
{cp.deviceName ?? <span className="font-mono">{cp.chargePointIdentifier}</span>}
</h1>
{isAdmin && cp.deviceName && (
<span className="font-mono text-sm text-muted">{cp.chargePointIdentifier}</span>
)}
<Chip
color={registrationColorMap[cp.registrationStatus] ?? "warning"}
size="sm"
@@ -208,9 +253,9 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
</Chip>
<div className="flex items-center gap-1.5">
<span
className={`size-2 rounded-full ${isOnline ? "bg-success animate-pulse" : "bg-muted"}`}
className={`size-2 rounded-full ${statusDotClass}`}
/>
<span className="text-xs text-muted">{isOnline ? "在线" : "离线"}</span>
<span className="text-xs text-muted">{statusLabel}</span>
</div>
</div>
{(cp.chargePointVendor || cp.chargePointModel) && (
@@ -224,10 +269,21 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
<ArrowRotateRight className={`size-4 ${refreshing ? "animate-spin" : ""}`} />
</Button>
{isAdmin && (
<Button size="sm" variant="secondary" onPress={openEdit}>
<Pencil className="size-4" />
</Button>
<>
<Tooltip>
<Tooltip.Content> OCPP </Tooltip.Content>
<Tooltip.Trigger>
<Button size="sm" variant="ghost" isDisabled={resetBusy} onPress={handleResetPassword}>
{resetBusy ? <Spinner size="sm" /> : <Key className="size-4" />}
</Button>
</Tooltip.Trigger>
</Tooltip>
<Button size="sm" variant="secondary" onPress={openEdit}>
<Pencil className="size-4" />
</Button>
</>
)}
</div>
</div>
@@ -265,8 +321,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
<div className="grid gap-4 md:grid-cols-2">
{/* Device info — admin only */}
{isAdmin && (
<div className="rounded-xl border border-border bg-surface p-4">
<h2 className="mb-3 text-sm font-semibold text-foreground"></h2>
<InfoSection title="设备信息">
<dl className="divide-y divide-border">
{[
{ label: "品牌", value: cp.chargePointVendor },
@@ -286,13 +341,12 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
</div>
))}
</dl>
</div>
</InfoSection>
)}
{/* Operation info — admin only */}
{isAdmin && (
<div className="rounded-xl border border-border bg-surface p-4">
<h2 className="mb-3 text-sm font-semibold text-foreground"></h2>
<InfoSection title="运行配置">
<dl className="divide-y divide-border">
<div className="flex items-center justify-between gap-4 py-2">
<dt className="shrink-0 text-sm text-muted"></dt>
@@ -309,7 +363,9 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
<div className="flex items-center justify-between gap-4 py-2">
<dt className="shrink-0 text-sm text-muted"></dt>
<dd className="text-sm text-foreground">
{cp.feePerKwh > 0 ? (
{cp.pricingMode === "tou" ? (
<span className="text-accent font-medium"></span>
) : cp.feePerKwh > 0 ? (
<span>
{cp.feePerKwh} /kWh
<span className="ml-1 text-xs text-muted">
@@ -362,18 +418,19 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
</dd>
</div>
</dl>
</div>
</InfoSection>
)}
{/* Fee info — user only */}
{!isAdmin && (
<div className="rounded-xl border border-border bg-surface p-4">
<h2 className="mb-3 text-sm font-semibold text-foreground"></h2>
<InfoSection title="电价信息">
<dl className="divide-y divide-border">
<div className="flex items-center justify-between gap-4 py-2">
<dt className="shrink-0 text-sm text-muted"></dt>
<dd className="text-sm text-foreground">
{cp.feePerKwh > 0 ? (
{cp.pricingMode === "tou" ? (
<span className="text-accent font-medium"></span>
) : cp.feePerKwh > 0 ? (
<span>
<span className="font-semibold">¥{(cp.feePerKwh / 100).toFixed(2)}</span>
<span className="ml-1 text-xs text-muted">/kWh</span>
@@ -388,14 +445,14 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
<dd>
<div className="flex items-center gap-1.5">
<span
className={`size-2 rounded-full ${isOnline ? "bg-success animate-pulse" : "bg-muted"}`}
className={`size-2 rounded-full ${statusDotClass}`}
/>
<span className="text-sm text-foreground">{isOnline ? "在线" : "离线"}</span>
<span className="text-sm text-foreground">{statusLabel}</span>
</div>
</dd>
</div>
</dl>
</div>
</InfoSection>
)}
</div>
@@ -410,7 +467,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
className="flex min-w-40 flex-col gap-2 rounded-xl border border-border bg-surface p-3"
>
<div className="flex items-center gap-2">
<PlugConnection className="size-4 shrink-0 text-muted" />
<Plug className="size-4 shrink-0 text-muted" />
<span className="text-sm font-medium text-foreground">
#{conn.connectorId}
</span>
@@ -538,6 +595,57 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
)}
</div>
{/* Reset password result modal */}
{isAdmin && (
<Modal
isOpen={resetResult !== null}
onOpenChange={(open) => {
if (!open) { setResetResult(null); setResetCopied(false); }
}}
>
<Modal.Backdrop>
<Modal.Container scroll="outside">
<Modal.Dialog className="sm:max-w-md">
<Modal.Header>
<Modal.Heading></Modal.Heading>
</Modal.Header>
<Modal.Body className="space-y-4">
<p className="text-sm text-warning font-medium">
</p>
<div className="space-y-1.5">
<p className="text-xs text-muted font-medium"> OCPP Basic Auth </p>
<div className="flex items-center gap-2">
<code className="flex-1 rounded-md bg-surface-secondary px-3 py-2 text-sm font-mono text-foreground select-all">
{resetResult?.plainPassword}
</code>
<Tooltip>
<Tooltip.Content>{resetCopied ? "已复制" : "复制密钥"}</Tooltip.Content>
<Tooltip.Trigger>
<Button
isIconOnly
size="sm"
variant="ghost"
onPress={() => resetResult && handleCopyResetPassword(resetResult.plainPassword)}
>
{resetCopied ? <Check className="size-4 text-success" /> : <Copy className="size-4" />}
</Button>
</Tooltip.Trigger>
</Tooltip>
</div>
</div>
</Modal.Body>
<Modal.Footer className="flex justify-end">
<Button onPress={() => { setResetResult(null); setResetCopied(false); }}>
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
)}
{/* Edit modal */}
{isAdmin && (
<Modal
@@ -554,6 +662,16 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
<Modal.Heading></Modal.Heading>
</Modal.Header>
<Modal.Body className="space-y-3">
<TextField fullWidth>
<Label className="text-sm font-medium"></Label>
<Input
placeholder="1号楼A区01号桩"
value={editForm.deviceName}
onChange={(e) =>
setEditForm((f) => ({ ...f, deviceName: e.target.value }))
}
/>
</TextField>
<div className="grid grid-cols-2 gap-3">
<TextField fullWidth>
<Label className="text-sm font-medium"></Label>
@@ -601,8 +719,33 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
</Select.Popover>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm font-medium"></Label>
<Select
fullWidth
selectedKey={editForm.pricingMode}
onSelectionChange={(key) =>
setEditForm((f) => ({
...f,
pricingMode: String(key) as EditForm["pricingMode"],
}))
}
>
<Select.Trigger>
<Select.Value />
<Select.Indicator />
</Select.Trigger>
<Select.Popover>
<ListBox>
<ListBox.Item id="fixed"></ListBox.Item>
<ListBox.Item id="tou"></ListBox.Item>
</ListBox>
</Select.Popover>
</Select>
</div>
{editForm.pricingMode === "fixed" && (
<TextField fullWidth>
<Label className="text-sm font-medium">/kWh</Label>
<Label className="text-sm font-medium">/kWh</Label>
<Input
type="number"
min="0"
@@ -612,6 +755,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
onChange={(e) => setEditForm((f) => ({ ...f, feePerKwh: e.target.value }))}
/>
</TextField>
)}
</Modal.Body>
<Modal.Footer className="flex justify-end gap-2">
<Button variant="ghost" onPress={() => setEditOpen(false)}>

View File

@@ -6,6 +6,7 @@ import {
Button,
Chip,
Input,
InputGroup,
Label,
ListBox,
Modal,
@@ -22,11 +23,13 @@ import {
TrashBin,
ArrowRotateRight,
QrCode,
Copy,
Check,
} from "@gravity-ui/icons";
import { QRCodeSVG } from "qrcode.react";
import Link from "next/link";
import { ScrollFade } from "@/components/scroll-fade";
import { api, type ChargePoint } from "@/lib/api";
import { api, type ChargePoint, type ChargePointCreated } from "@/lib/api";
import { useSession } from "@/lib/auth-client";
import dayjs from "@/lib/dayjs";
@@ -97,17 +100,21 @@ const registrationColorMap: Record<string, "success" | "warning" | "danger"> = {
type FormData = {
chargePointIdentifier: string;
deviceName: string;
chargePointVendor: string;
chargePointModel: string;
registrationStatus: "Accepted" | "Pending" | "Rejected";
pricingMode: "fixed" | "tou";
feePerKwh: string;
};
const EMPTY_FORM: FormData = {
chargePointIdentifier: "",
deviceName: "",
chargePointVendor: "",
chargePointModel: "",
registrationStatus: "Pending",
pricingMode: "fixed",
feePerKwh: "0",
};
@@ -119,6 +126,8 @@ export default function ChargePointsPage() {
const [deleteTarget, setDeleteTarget] = useState<ChargePoint | null>(null);
const [deleting, setDeleting] = useState(false);
const [qrTarget, setQrTarget] = useState<ChargePoint | null>(null);
const [createdCp, setCreatedCp] = useState<ChargePointCreated | null>(null);
const [copied, setCopied] = useState(false);
const {
data: chargePoints = [],
refetch: refetchList,
@@ -139,9 +148,11 @@ export default function ChargePointsPage() {
setFormTarget(cp);
setFormData({
chargePointIdentifier: cp.chargePointIdentifier,
deviceName: cp.deviceName ?? "",
chargePointVendor: cp.chargePointVendor ?? "",
chargePointModel: cp.chargePointModel ?? "",
registrationStatus: cp.registrationStatus as FormData["registrationStatus"],
pricingMode: cp.pricingMode,
feePerKwh: String(cp.feePerKwh),
});
setFormOpen(true);
@@ -158,20 +169,27 @@ export default function ChargePointsPage() {
chargePointVendor: formData.chargePointVendor,
chargePointModel: formData.chargePointModel,
registrationStatus: formData.registrationStatus,
feePerKwh: fee,
pricingMode: formData.pricingMode,
feePerKwh: formData.pricingMode === "fixed" ? fee : 0,
deviceName: formData.deviceName.trim() || null,
});
await refetchList();
setFormOpen(false);
} else {
// Create
await api.chargePoints.create({
// Create — capture plainPassword for one-time display
const created = await api.chargePoints.create({
chargePointIdentifier: formData.chargePointIdentifier.trim(),
chargePointVendor: formData.chargePointVendor.trim() || undefined,
chargePointModel: formData.chargePointModel.trim() || undefined,
registrationStatus: formData.registrationStatus,
feePerKwh: fee,
pricingMode: formData.pricingMode,
feePerKwh: formData.pricingMode === "fixed" ? fee : 0,
deviceName: formData.deviceName.trim() || undefined,
});
await refetchList();
setFormOpen(false);
setCreatedCp(created);
}
await refetchList();
setFormOpen(false);
} finally {
setFormBusy(false);
}
@@ -191,6 +209,13 @@ export default function ChargePointsPage() {
const isEdit = formTarget !== null;
const handleCopyPassword = (text: string) => {
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
const { data: sessionData } = useSession();
const isAdmin = sessionData?.user?.role === "admin";
@@ -253,8 +278,16 @@ export default function ChargePointsPage() {
}
/>
</TextField>
<TextField fullWidth>
<Label className="text-sm font-medium"></Label>
<Input
placeholder="1号楼A区01号桩"
value={formData.deviceName}
onChange={(e) => setFormData((f) => ({ ...f, deviceName: e.target.value }))}
/>
</TextField>
<div className="grid grid-cols-2 gap-3">
<TextField fullWidth>
<TextField fullWidth isReadOnly={isEdit}>
<Label className="text-sm font-medium"></Label>
<Input
placeholder="ABB"
@@ -264,7 +297,7 @@ export default function ChargePointsPage() {
}
/>
</TextField>
<TextField fullWidth>
<TextField fullWidth isReadOnly={isEdit}>
<Label className="text-sm font-medium"></Label>
<Input
placeholder="Terra AC"
@@ -300,17 +333,45 @@ export default function ChargePointsPage() {
</Select.Popover>
</Select>
</div>
<TextField fullWidth>
<Label className="text-sm font-medium">/kWh</Label>
<Input
type="number"
min="0"
step="1"
placeholder="0"
value={formData.feePerKwh}
onChange={(e) => setFormData((f) => ({ ...f, feePerKwh: e.target.value }))}
/>
</TextField>
<div className="space-y-1.5">
<Label className="text-sm font-medium"></Label>
<Select
fullWidth
selectedKey={formData.pricingMode}
onSelectionChange={(key) =>
setFormData((f) => ({
...f,
pricingMode: String(key) as FormData["pricingMode"],
}))
}
>
<Select.Trigger>
<Select.Value />
<Select.Indicator />
</Select.Trigger>
<Select.Popover>
<ListBox>
<ListBox.Item id="fixed"></ListBox.Item>
<ListBox.Item id="tou"></ListBox.Item>
</ListBox>
</Select.Popover>
</Select>
</div>
{formData.pricingMode === "fixed" && (
<TextField fullWidth>
<Label className="text-sm font-medium">/kWh</Label>
<Input
type="number"
min="0"
step="1"
placeholder="0"
value={formData.feePerKwh}
onChange={(e) =>
setFormData((f) => ({ ...f, feePerKwh: e.target.value }))
}
/>
</TextField>
)}
{!isEdit && (
<p className="text-xs text-muted">
Pending Accepted 线
@@ -348,16 +409,18 @@ export default function ChargePointsPage() {
<Modal.Dialog className="sm:max-w-lg">
<Modal.CloseTrigger />
<Modal.Header>
<Modal.Heading>{qrTarget?.chargePointIdentifier} </Modal.Heading>
<Modal.Heading>
{qrTarget?.deviceName ?? qrTarget?.chargePointIdentifier}
</Modal.Heading>
</Modal.Header>
<Modal.Body className="space-y-4">
<p className="text-sm text-muted">
</p>
{qrTarget &&
qrTarget.connectors.filter((c) => c.connectorId > 0).length === 0 && (
<p className="text-sm text-muted">
线
线
</p>
)}
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3">
@@ -394,14 +457,103 @@ export default function ChargePointsPage() {
</Modal>
)}
{/* OCPP Password Modal — shown once after creation */}
{isAdmin && (
<Modal
isOpen={createdCp !== null}
onOpenChange={(open) => {
if (!open) {
setCreatedCp(null);
setCopied(false);
}
}}
>
<Modal.Backdrop>
<Modal.Container scroll="outside">
<Modal.Dialog className="sm:max-w-md">
<Modal.Header>
<Modal.Heading></Modal.Heading>
</Modal.Header>
<Modal.Body className="space-y-4">
<p className="text-sm text-warning font-medium">
</p>
<TextField fullWidth isReadOnly>
<Label className="text-sm font-medium"></Label>
<Input value={createdCp?.chargePointIdentifier ?? ""} className="font-mono" />
</TextField>
<TextField fullWidth isReadOnly>
<Label className="text-sm font-medium">OCPP Basic Auth </Label>
<InputGroup>
<InputGroup.Input
value={createdCp?.plainPassword ?? ""}
className="font-mono select-all"
/>
<InputGroup.Suffix>
<Tooltip>
<Tooltip.Content>{copied ? "已复制" : "复制密钥"}</Tooltip.Content>
<Tooltip.Trigger>
<Button
isIconOnly
size="sm"
variant="ghost"
onPress={() =>
createdCp && handleCopyPassword(createdCp.plainPassword)
}
>
{copied ? (
<Check className="size-4 text-success" />
) : (
<Copy className="size-4" />
)}
</Button>
</Tooltip.Trigger>
</Tooltip>
</InputGroup.Suffix>
</InputGroup>
</TextField>
<div className="space-y-1.5">
<TextField fullWidth isReadOnly>
<Label className="text-sm font-medium"> WebSocket </Label>
<Input
value={`wss://<your-server>/ocpp/${createdCp?.chargePointIdentifier ?? ""}`}
className="font-mono text-xs"
/>
</TextField>
<p className="text-xs text-muted">
HTTP
<br />
<code className="text-foreground">
Authorization: Basic &lt;base64({createdCp?.chargePointIdentifier}
:&lt;password&gt;)&gt;
</code>
</p>
</div>
</Modal.Body>
<Modal.Footer className="flex justify-end">
<Button
onPress={() => {
setCreatedCp(null);
setCopied(false);
}}
>
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
)}
<Table>
<Table.ScrollContainer>
<Table.Content aria-label="充电桩列表" className="min-w-200">
<Table.Header>
<Table.Column isRowHeader></Table.Column>
<Table.Column isRowHeader></Table.Column>
{isAdmin && <Table.Column> / </Table.Column>}
{isAdmin && <Table.Column></Table.Column>}
<Table.Column>/kWh</Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
@@ -422,34 +574,41 @@ export default function ChargePointsPage() {
{isAdmin && <Table.Cell>{""}</Table.Cell>}
</Table.Row>
)}
{chargePoints.map((cp) => (
<Table.Row key={cp.id} id={String(cp.id)} className={"group"}>
{chargePoints.map((cp) => {
const online =
cp.transportStatus === "online" &&
!!cp.lastHeartbeatAt &&
dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120;
const commandChannelUnavailable = cp.transportStatus === "unavailable";
return (
<Table.Row key={cp.id} id={String(cp.id)} className={"group"}>
<Table.Cell>
<Tooltip delay={0}>
<Tooltip.Trigger>
<div className="flex items-center gap-2">
<span
className={`size-2 shrink-0 rounded-full ${
cp.lastHeartbeatAt &&
dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120
? "bg-success"
: "bg-gray-300"
online ? "bg-success" : commandChannelUnavailable ? "bg-warning" : "bg-gray-300"
}`}
/>
<Link
href={`/dashboard/charge-points/${cp.id}`}
className="font-medium text-accent"
>
{cp.chargePointIdentifier}
</Link>
<div className="flex flex-col">
<Link
href={`/dashboard/charge-points/${cp.id}`}
className="font-medium text-accent"
>
{cp.deviceName ?? cp.chargePointIdentifier}
</Link>
{isAdmin && cp.deviceName && (
<span className="font-mono text-xs text-muted">
{cp.chargePointIdentifier}
</span>
)}
</div>
</div>
</Tooltip.Trigger>
<Tooltip.Content placement="start">
{cp.lastHeartbeatAt
? dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120
? "在线"
: "离线"
: "从未连接"}
{online ? "在线" : commandChannelUnavailable ? "通道异常" : cp.lastHeartbeatAt ? "离线" : "从未连接"}
</Tooltip.Content>
</Tooltip>
</Table.Cell>
@@ -482,8 +641,10 @@ export default function ChargePointsPage() {
</Chip>
</Table.Cell>
)}
<Table.Cell className="tabular-nums">
{cp.feePerKwh > 0 ? (
<Table.Cell>
{cp.pricingMode === "tou" ? (
<span className="text-accent font-medium"></span>
) : cp.feePerKwh > 0 ? (
<span>
{cp.feePerKwh}
<span className="ml-1 text-xs text-muted">
@@ -559,9 +720,14 @@ export default function ChargePointsPage() {
<Modal.Body>
<p className="text-sm text-muted">
{" "}
<span className="font-mono font-medium text-foreground">
{cp.chargePointIdentifier}
<span className="font-medium text-foreground">
{cp.deviceName ?? cp.chargePointIdentifier}
</span>
{cp.deviceName && (
<span className="font-mono ml-1 text-xs text-muted">
({cp.chargePointIdentifier})
</span>
)}
</p>
</Modal.Body>
@@ -585,8 +751,9 @@ export default function ChargePointsPage() {
</div>
</Table.Cell>
)}
</Table.Row>
))}
</Table.Row>
);
})}
</Table.Body>
</Table.Content>
</Table.ScrollContainer>

View File

@@ -1,20 +1,17 @@
"use client";
import { useState, useEffect, useRef, Fragment, Suspense } from "react";
import { useState, useEffect, useRef, Fragment, Suspense, useMemo } from "react";
import { useSearchParams } from "next/navigation";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Button, Chip, Modal, Spinner } from "@heroui/react";
import {
ThunderboltFill,
PlugConnection,
CreditCard,
Check,
QrCode,
Xmark,
} from "@gravity-ui/icons";
import { Alert, AlertDialog, Button, Modal, Spinner } from "@heroui/react";
import { ThunderboltFill, CreditCard, Check, QrCode, Xmark } from "@gravity-ui/icons";
import jsQR from "jsqr";
import { api } from "@/lib/api";
import { useSession } from "@/lib/auth-client";
import dayjs from "@/lib/dayjs";
import { BanknoteArrowUp, EvCharger, Plug } from "lucide-react";
import Link from "next/link";
import { IdTagCard } from "@/components/id-tag-card";
// ── Status maps (same as charge-points page) ────────────────────────────────
@@ -31,25 +28,12 @@ const statusLabelMap: Record<string, string> = {
Occupied: "占用",
};
const statusDotClass: Record<string, string> = {
Available: "bg-success",
Charging: "bg-accent animate-pulse",
Preparing: "bg-warning animate-pulse",
Finishing: "bg-warning",
SuspendedEV: "bg-warning",
SuspendedEVSE: "bg-warning",
Reserved: "bg-warning",
Faulted: "bg-danger",
Unavailable: "bg-danger",
Occupied: "bg-warning",
};
// ── Step indicator ───────────────────────────────────────────────────────────
function StepBar({ step, onGoBack }: { step: number; onGoBack: (target: number) => void }) {
const labels = ["选择充电桩", "选择充电口", "选择储值卡"];
return (
<div className="flex w-full items-start">
<div className="flex w-full items-center">
{labels.map((label, i) => {
const idx = i + 1;
const isActive = step === idx;
@@ -62,36 +46,36 @@ function StepBar({ step, onGoBack }: { step: number; onGoBack: (target: number)
onClick={() => isDone && onGoBack(idx)}
disabled={!isDone}
className={[
"flex shrink-0 flex-col items-center gap-2",
isDone ? "cursor-pointer" : "cursor-default",
"flex shrink-0 flex-col items-center gap-1.5 py-1 min-w-16",
isDone ? "cursor-pointer active:opacity-70" : "cursor-default",
].join(" ")}
>
<span
className={[
"flex size-7 items-center justify-center rounded-full text-xs font-semibold ring-2 ring-offset-2 ring-offset-background transition-all",
"flex size-8 items-center justify-center rounded-full text-sm font-bold ring-2 ring-offset-2 ring-offset-background transition-all",
isActive
? "bg-accent text-accent-foreground ring-accent"
? "bg-accent text-accent-foreground ring-accent shadow-md shadow-accent/30"
: isDone
? "bg-success text-white ring-success"
: "bg-surface-tertiary text-muted ring-transparent",
].join(" ")}
>
{isDone ? <Check className="size-3.5" /> : idx}
{isDone ? <Check className="size-4" /> : idx}
</span>
<span
className={[
"text-[11px] font-medium leading-none whitespace-nowrap",
isActive ? "text-accent" : isDone ? "text-foreground" : "text-muted",
"text-[11px] font-semibold leading-none whitespace-nowrap tracking-tight mt-1",
isActive ? "text-accent" : isDone ? "text-success" : "text-muted",
].join(" ")}
>
{label}
</span>
</button>
{!isLast && (
<div className="flex-1 pt-3.5">
<div className="flex-1 mb-3.5">
<span
className={[
"block h-px w-full transition-colors",
"block h-0.5 w-full rounded-full transition-colors duration-300",
isDone ? "bg-success" : "bg-border",
].join(" ")}
/>
@@ -237,6 +221,8 @@ function QrScanner({ onResult, onClose }: ScannerProps) {
function ChargePageContent() {
const searchParams = useSearchParams();
const { data: sessionData } = useSession();
const isAdmin = sessionData?.user?.role === "admin";
const [step, setStep] = useState(1);
const [selectedCpId, setSelectedCpId] = useState<string | null>(null);
@@ -246,6 +232,13 @@ function ChargePageContent() {
const [scanError, setScanError] = useState<string | null>(null);
const [startResult, setStartResult] = useState<"success" | "error" | null>(null);
const [startError, setStartError] = useState<string | null>(null);
const [startSnapshot, setStartSnapshot] = useState<{
cpId: string;
chargePointIdentifier: string;
deviceName: string | null;
connectorId: number;
idTag: string;
} | null>(null);
// Detect mobile
const [isMobile, setIsMobile] = useState(false);
@@ -275,12 +268,71 @@ function ChargePageContent() {
});
const { data: idTags = [], isLoading: tagsLoading } = useQuery({
queryKey: ["idTags"],
queryKey: ["idTags", "list"],
queryFn: () => api.idTags.list().catch(() => []),
});
const { data: activeTransactions = [] } = useQuery({
queryKey: ["transactions", "active", "idTagLock"],
queryFn: async () => {
const res = await api.transactions.list({ page: 1, limit: 200, status: "active" });
return res.data;
},
refetchInterval: 3_000,
});
const selectedCp = chargePoints.find((cp) => cp.id === selectedCpId) ?? null;
const myTags = idTags?.filter((t) => t.status === "Accepted") ?? [];
const activeTransactionsForCurrentUser = useMemo(() => {
const currentUserId = sessionData?.user?.id;
if (!currentUserId) return activeTransactions;
return activeTransactions.filter((tx) => tx.idTagUserId === currentUserId);
}, [activeTransactions, sessionData?.user?.id]);
const activeCount = activeTransactionsForCurrentUser.length;
const activeDetailHref =
activeCount === 1
? `/dashboard/transactions/${activeTransactionsForCurrentUser[0].id}`
: "/dashboard/transactions?status=active";
const activeIdTagSet = useMemo(
() => new Set(activeTransactionsForCurrentUser.map((tx) => tx.idTag)),
[activeTransactionsForCurrentUser],
);
useEffect(() => {
if (startResult !== "success" && selectedIdTag && activeIdTagSet.has(selectedIdTag)) {
setSelectedIdTag(null);
}
}, [selectedIdTag, activeIdTagSet, startResult]);
const { data: startedTransactionId } = useQuery({
queryKey: [
"latestStartedTx",
startSnapshot?.cpId,
startSnapshot?.connectorId,
startSnapshot?.idTag,
startResult,
],
enabled: startResult === "success" && !!startSnapshot?.cpId && !!startSnapshot?.idTag,
queryFn: async () => {
if (!startSnapshot?.cpId || !startSnapshot?.idTag) return null;
const res = await api.transactions.list({
page: 1,
limit: 30,
status: "active",
chargePointId: startSnapshot.cpId,
});
const matched = res.data.find(
(tx) =>
tx.idTag === startSnapshot.idTag && tx.connectorNumber === startSnapshot.connectorId,
);
return matched?.id ?? null;
},
refetchInterval: (query) => (query.state.data ? false : 1_500),
retry: false,
});
const startMutation = useMutation({
mutationFn: async () => {
@@ -293,28 +345,49 @@ function ChargePageContent() {
idTag: selectedIdTag,
});
},
onMutate: () => {
if (!selectedCp || selectedConnectorId === null || !selectedIdTag) return;
setStartSnapshot({
cpId: selectedCp.id,
chargePointIdentifier: selectedCp.chargePointIdentifier,
deviceName: selectedCp.deviceName,
connectorId: selectedConnectorId,
idTag: selectedIdTag,
});
},
onSuccess: () => {
setStartResult("success");
},
onError: (err: Error) => {
setStartResult("error");
const msg = err.message ?? "";
if (msg.includes("offline")) setStartError("充电桩当前不在线,请稍后再试");
else if (msg.includes("not accepted")) setStartError("充电桩未启用,请联系管理员");
else if (msg.includes("idTag")) setStartError("储值卡无效或无权使用");
else setStartError("启动失败:" + msg);
const lowerMsg = msg.toLowerCase();
if (lowerMsg.includes("command channel is unavailable") || lowerMsg.includes("offline")) {
setStartError("充电桩下行通道不可用,请稍后再试");
} else if (lowerMsg.includes("did not confirm remotestarttransaction in time")) {
setStartError("充电桩未及时确认启动指令,请稍后重试");
} else if (
lowerMsg.includes("chargepoint is not accepted") ||
lowerMsg.includes("not accepted")
) {
setStartError("充电桩未启用,请联系管理员");
} else if (msg.includes("ConcurrentTx") || lowerMsg.includes("concurrent")) {
setStartError("该储值卡已有进行中的充电订单,请先结束后再发起");
} else if (lowerMsg.includes("rejected: blocked")) {
setStartError("储值卡不可用或余额不足,请更换储值卡或先充值");
} else if (lowerMsg.includes("rejected: expired")) {
setStartError("储值卡已过期,请联系管理员处理");
} else if (lowerMsg.includes("rejected: invalid")) {
setStartError("储值卡无效,请确认卡号后重试");
} else if (lowerMsg.includes("not found or not authorized")) {
setStartError("该储值卡无权使用或不存在");
} else if (msg.includes("idTag") || lowerMsg.includes("idtag")) {
setStartError("储值卡状态不可用,无法启动充电");
} else setStartError("启动失败:" + msg);
},
});
function resetAll() {
setStep(1);
setSelectedCpId(null);
setSelectedConnectorId(null);
setSelectedIdTag(null);
setStartResult(null);
setStartError(null);
}
function handleScanResult(raw: string) {
setShowScanner(false);
setScanError(null);
@@ -343,47 +416,87 @@ function ChargePageContent() {
// ── Success screen ─────────────────────────────────────────────────────────
if (startResult === "success") {
return (
<div className="flex flex-col items-center justify-center gap-6 py-20 text-center">
<div className="flex size-16 items-center justify-center rounded-full bg-success/10">
<Check className="size-8 text-success" />
<div className="flex flex-col items-center justify-center gap-8 py-16 text-center">
<div className="relative">
<div className="flex size-24 items-center justify-center rounded-full bg-success-soft ring-8 ring-success/10">
<Check className="size-12 text-success" />
</div>
</div>
<div>
<h2 className="text-xl font-semibold text-foreground"></h2>
<p className="mt-1.5 text-sm text-muted">
<br />
"充电记录"
</p>
<div className="space-y-2">
<h2 className="text-2xl font-bold text-foreground"></h2>
<p className="text-sm text-muted leading-relaxed"></p>
</div>
<Button onPress={resetAll}></Button>
<div className="w-full max-w-xs rounded-2xl border border-border bg-surface p-4 text-left space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted"></span>
<span className="font-medium text-foreground">
{startSnapshot?.deviceName ?? startSnapshot?.chargePointIdentifier ?? selectedCp?.deviceName ?? selectedCp?.chargePointIdentifier}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted"></span>
<span className="font-medium text-foreground">
#{startSnapshot?.connectorId ?? selectedConnectorId}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted"></span>
<span className="font-mono font-medium text-foreground">
{startSnapshot?.idTag ?? selectedIdTag}
</span>
</div>
</div>
{startedTransactionId ? (
<Link
href={`/dashboard/transactions/${startedTransactionId}`}
className="w-full max-w-xs"
>
<Button size="lg" className="w-full">
<BanknoteArrowUp className="size-4" />
</Button>
</Link>
) : (
<div className="w-full max-w-xs space-y-2">
<Link href="/dashboard/transactions?status=active" className="block">
<Button size="lg" variant="secondary" className="w-full">
<BanknoteArrowUp className="size-4" />
</Button>
</Link>
<p className="text-xs text-muted text-center inline-flex w-full items-center justify-center gap-1.5">
</p>
</div>
)}
</div>
);
}
// ── Main UI ────────────────────────────────────────────────────────────────
return (
<div className="space-y-6">
<div className="space-y-5 pb-4">
{/* Header */}
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-center justify-between gap-3">
<div>
<h1 className="text-xl font-semibold text-foreground"></h1>
<h1 className="text-xl font-bold text-foreground"></h1>
<p className="mt-0.5 text-sm text-muted"></p>
</div>
{/* QR scan button — mobile only */}
{isMobile && (
<Button
size="sm"
variant="secondary"
onPress={() => {
<button
type="button"
onClick={() => {
setScanError(null);
setShowScanner(true);
}}
isDisabled={showScanner}
disabled={showScanner}
className="flex shrink-0 flex-col items-center gap-1 rounded-2xl border border-border bg-surface px-3.5 py-2.5 text-foreground shadow-sm active:opacity-70 disabled:opacity-40"
>
<QrCode className="size-4" />
</Button>
<QrCode className="size-5" />
<span className="text-[10px] font-medium leading-none"></span>
</button>
)}
</div>
@@ -403,7 +516,66 @@ function ChargePageContent() {
</Modal.Backdrop>
</Modal>
{scanError && <p className="text-sm text-danger">{scanError}</p>}
<AlertDialog
isOpen={startResult === "error" && !!startError}
onOpenChange={(open) => {
if (!open) {
setStartResult(null);
setStartError(null);
setStartSnapshot(null);
}
}}
>
<AlertDialog.Backdrop variant="blur">
<AlertDialog.Container size="sm">
<AlertDialog.Dialog>
<AlertDialog.Header>
<AlertDialog.CloseTrigger />
<AlertDialog.Icon status="danger" />
<AlertDialog.Heading></AlertDialog.Heading>
</AlertDialog.Header>
<AlertDialog.Body className="overflow-hidden">
<p>{startError ?? "启动失败,请稍后重试"}</p>
</AlertDialog.Body>
<AlertDialog.Footer>
<Button
slot="close"
variant="secondary"
onPress={() => {
setStartResult(null);
setStartError(null);
}}
>
</Button>
</AlertDialog.Footer>
</AlertDialog.Dialog>
</AlertDialog.Container>
</AlertDialog.Backdrop>
</AlertDialog>
{scanError && (
<div className="rounded-xl border border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">
{scanError}
</div>
)}
{activeCount > 0 && (
<Alert status="accent">
<Alert.Indicator />
<Alert.Content>
<Alert.Title> {activeCount} </Alert.Title>
<Alert.Description>
{" "}
<Link href={activeDetailHref} className="underline">
<Button variant="secondary" size="sm" className="px-1.5 h-6 rounded-xl">
</Button>
</Link>
</Alert.Description>
</Alert.Content>
</Alert>
)}
{/* Step bar */}
<StepBar step={step} onGoBack={(t) => setStep(t)} />
@@ -412,21 +584,25 @@ function ChargePageContent() {
{step === 1 && (
<div className="space-y-3">
{cpLoading ? (
<div className="flex justify-center py-12">
<div className="flex justify-center py-16">
<Spinner />
</div>
) : chargePoints.filter((cp) => cp.registrationStatus === "Accepted").length === 0 ? (
<div className="rounded-xl border border-border px-6 py-12 text-center">
<PlugConnection className="mx-auto mb-2 size-8 text-muted" />
<p className="text-sm text-muted"></p>
<div className="rounded-2xl border border-border px-6 py-14 text-center">
<Plug className="mx-auto mb-3 size-10 text-muted" />
<p className="font-medium text-foreground"></p>
<p className="mt-1 text-sm text-muted"></p>
</div>
) : (
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{chargePoints
.filter((cp) => cp.registrationStatus === "Accepted")
.map((cp) => {
const online =
!!cp.lastHeartbeatAt && dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120;
cp.transportStatus === "online" &&
!!cp.lastHeartbeatAt &&
dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120;
const commandChannelUnavailable = cp.transportStatus === "unavailable";
const availableCount = cp.connectors.filter(
(c) => c.status === "Available",
).length;
@@ -442,37 +618,74 @@ function ChargePageContent() {
setStep(2);
}}
className={[
"flex flex-col gap-2.5 rounded-xl border p-4 text-left transition-all",
"flex flex-col gap-3 rounded-2xl border p-4 text-left transition-all",
disabled
? "cursor-not-allowed opacity-50 border-border"
: "cursor-pointer border-border hover:border-accent hover:bg-accent/5",
selectedCpId === cp.id ? "border-accent bg-accent/10" : "",
? "cursor-not-allowed opacity-40 border-border bg-surface-secondary"
: selectedCpId === cp.id
? "border-accent bg-accent/8 shadow-sm shadow-accent-soft-hover active:scale-[0.98]"
: "cursor-pointer border-border bg-surface hover:border-accent/60 hover:bg-accent/5 active:scale-[0.98]",
].join(" ")}
>
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-foreground truncate">
{cp.chargePointIdentifier}
</span>
<Chip size="sm" color={online ? "success" : "default"} variant="soft">
{online ? "在线" : "离线"}
</Chip>
</div>
{(cp.chargePointVendor || cp.chargePointModel) && (
<span className="text-xs text-muted">
{[cp.chargePointVendor, cp.chargePointModel].filter(Boolean).join(" · ")}
</span>
)}
<div className="flex flex-wrap items-center gap-2">
<span className="flex items-center gap-1 text-xs text-muted">
<PlugConnection className="size-3" />
{availableCount}/{cp.connectors.length}
</span>
{cp.feePerKwh > 0 && (
<span className="text-xs text-muted">
· ¥{(cp.feePerKwh / 100).toFixed(2)}/kWh
{/* Top row: name + status */}
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="font-semibold text-foreground truncate leading-tight">
{cp.deviceName ?? cp.chargePointIdentifier}
</span>
{isAdmin && cp.deviceName && (
<span className="font-mono text-xs text-muted truncate">
{cp.chargePointIdentifier}
</span>
)}
{(cp.chargePointVendor || cp.chargePointModel) && (
<span className="text-xs text-muted truncate">
{[cp.chargePointVendor, cp.chargePointModel]
.filter(Boolean)
.join(" · ")}
</span>
)}
</div>
<span
className={[
"shrink-0 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold",
online
? "bg-success/12 text-success"
: commandChannelUnavailable
? "bg-warning/12 text-warning"
: "bg-surface-tertiary text-muted",
].join(" ")}
>
<span
className={`size-1.5 rounded-full ${
online ? "bg-success" : commandChannelUnavailable ? "bg-warning" : "bg-muted"
}`}
/>
{online ? "在线" : commandChannelUnavailable ? "通道异常" : "离线"}
</span>
</div>
{/* Bottom row: connectors + fee */}
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-sm text-muted">
<Plug className="size-3.5 shrink-0" />
<span>
<span
className={availableCount > 0 ? "font-semibold text-foreground" : ""}
>
{availableCount}
</span>
/{cp.connectors.length}
</span>
</span>
{cp.pricingMode === "tou" ? (
<span className="text-sm font-medium text-accent"></span>
) : cp.feePerKwh > 0 ? (
<span className="text-sm font-medium text-foreground">
¥{(cp.feePerKwh / 100).toFixed(2)}
<span className="text-xs text-muted font-normal">/kWh</span>
</span>
) : (
<span className="text-sm font-semibold text-success"></span>
)}
{cp.feePerKwh === 0 && <span className="text-xs text-success">· </span>}
</div>
</button>
);
@@ -484,23 +697,25 @@ function ChargePageContent() {
{/* ── Step 2: Select connector ──────────────────────────────────── */}
{step === 2 && (
<div className="space-y-3">
<div className="space-y-4">
{/* Context pill */}
{selectedCp && (
<p className="text-sm text-muted">
<span className="font-medium text-foreground">
{selectedCp.chargePointIdentifier}
<div className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface px-3 py-1.5 text-xs">
<EvCharger className="size-3.5 text-muted" />
<span className="text-muted"></span>
<span className="font-semibold text-foreground">
{selectedCp.deviceName ?? selectedCp.chargePointIdentifier}
</span>
</p>
</div>
)}
{selectedCp && selectedCp.connectors.filter((c) => c.connectorId > 0).length === 0 ? (
<div className="rounded-xl border border-border px-6 py-12 text-center">
<PlugConnection className="mx-auto mb-2 size-8 text-muted" />
<p className="text-sm text-muted"></p>
<div className="rounded-2xl border border-border px-6 py-14 text-center">
<Plug className="mx-auto mb-3 size-10 text-muted" />
<p className="font-medium text-foreground"></p>
</div>
) : (
<div className="grid gap-2 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
{selectedCp?.connectors
.filter((c) => c.connectorId > 0)
.sort((a, b) => a.connectorId - b.connectorId)
@@ -518,112 +733,138 @@ function ChargePageContent() {
}
}}
className={[
"flex flex-col gap-2 rounded-xl border p-4 text-left transition-all",
"relative flex flex-col items-center gap-3 rounded-2xl border py-5 px-3 text-center transition-all",
!available
? "cursor-not-allowed opacity-50 border-border"
: "cursor-pointer border-border hover:border-accent hover:bg-accent/5",
selectedConnectorId === conn.connectorId
? "border-accent bg-accent/10"
: "",
? "cursor-not-allowed opacity-40 border-border bg-surface-secondary"
: selectedConnectorId === conn.connectorId
? "border-accent bg-accent/8 shadow-sm shadow-accent-soft-hover active:scale-[0.97]"
: "cursor-pointer border-border bg-surface hover:border-accent/60 hover:bg-accent/5 active:scale-[0.97]",
].join(" ")}
>
<div className="flex items-center justify-between">
<span className="font-medium text-foreground">
#{conn.connectorId}
</span>
<span
className={`size-2 rounded-full ${statusDotClass[conn.status] ?? "bg-warning"}`}
/>
</div>
<span className="text-xs text-muted">
{statusLabelMap[conn.status] ?? conn.status}
<span
className={[
"flex size-12 items-center justify-center rounded-full text-xl font-bold",
available
? "bg-success/12 text-success"
: "bg-surface-tertiary text-muted",
].join(" ")}
>
{conn.connectorId}
</span>
<div className="space-y-0.5">
<p className="text-sm font-semibold text-foreground">
#{conn.connectorId}
</p>
<p
className={[
"text-xs font-medium",
conn.status === "Available" ? "text-success" : "text-muted",
].join(" ")}
>
{statusLabelMap[conn.status] ?? conn.status}
</p>
</div>
{selectedConnectorId === conn.connectorId && (
<span className="absolute right-2 top-2 flex size-5 items-center justify-center rounded-full bg-accent">
<Check className="size-3 text-white" />
</span>
)}
</button>
);
})}
</div>
)}
<Button variant="secondary" size="sm" onPress={() => setStep(1)}>
</Button>
</div>
)}
{/* ── Step 3: Select ID tag + start ────────────────────────────── */}
{step === 3 && (
<div className="space-y-5">
<div className="flex flex-wrap gap-3 text-sm text-muted">
<div className="space-y-4">
{/* Context pills */}
<div className="flex flex-wrap gap-2">
{selectedCp && (
<span>
<span className="font-medium text-foreground">
{selectedCp.chargePointIdentifier}
<div className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface px-3 py-1.5 text-xs">
<EvCharger className="size-3.5 text-muted" />
<span className="text-muted"></span>
<span className="font-semibold text-foreground">
{selectedCp.deviceName ?? selectedCp.chargePointIdentifier}
</span>
</span>
</div>
)}
{selectedConnectorId !== null && (
<span>
<span className="font-medium text-foreground">#{selectedConnectorId}</span>
</span>
<div className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface px-3 py-1.5 text-xs">
<Plug className="size-3.5 text-muted" />
<span className="text-muted"></span>
<span className="font-semibold text-foreground">#{selectedConnectorId}</span>
</div>
)}
</div>
<p className="text-sm font-semibold text-foreground"></p>
{tagsLoading ? (
<div className="flex justify-center py-12">
<div className="flex justify-center py-16">
<Spinner />
</div>
) : myTags.length === 0 ? (
<div className="rounded-xl border border-border px-6 py-12 text-center">
<CreditCard className="mx-auto mb-2 size-8 text-muted" />
<p className="text-sm text-muted"></p>
<p className="mt-1 text-xs text-muted">"储值卡"</p>
<div className="rounded-2xl border border-border px-6 py-14 text-center">
<CreditCard className="mx-auto mb-3 size-10 text-muted" />
<p className="font-medium text-foreground"></p>
<p className="mt-1 text-sm text-muted">
<Link href="/dashboard/id-tags" className="text-accent hover:underline">
</Link>
</p>
</div>
) : (
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{myTags.map((tag) => (
<button
key={tag.idTag}
type="button"
onClick={() => setSelectedIdTag(tag.idTag)}
className={[
"flex flex-col gap-1.5 rounded-xl border p-4 text-left transition-all cursor-pointer",
"border-border hover:border-accent hover:bg-accent/5",
selectedIdTag === tag.idTag ? "border-accent bg-accent/10" : "",
].join(" ")}
>
<div className="flex items-center justify-between">
<span className="font-mono text-sm font-medium text-foreground">
{tag.idTag}
</span>
{selectedIdTag === tag.idTag && (
<Check className="size-4 shrink-0 text-accent" />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{myTags.map((tag) => {
const locked = activeIdTagSet.has(tag.idTag);
return (
<div key={tag.idTag} className="space-y-2">
<IdTagCard
idTag={tag.idTag}
balance={tag.balance}
layout={tag.cardLayout ?? undefined}
skin={tag.cardSkin ?? undefined}
isSelected={selectedIdTag === tag.idTag}
isDisabled={locked}
onClick={() => {
if (!locked) setSelectedIdTag(tag.idTag);
}}
/>
{locked && (
<p className="px-1 text-xs font-medium text-warning">
</p>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted"></span>
<span className="text-xs font-medium text-foreground">
¥{(tag.balance / 100).toFixed(2)}
</span>
</div>
</button>
))}
);
})}
</div>
)}
{startResult === "error" && (
<p className="text-sm text-danger">{startError ?? "启动失败,请重试"}</p>
)}
<div className="flex gap-3">
{/* Action bar */}
<div className="flex gap-3 pt-1">
<Button
variant="secondary"
onPress={() => {
setStep(2);
setStartResult(null);
setStartError(null);
setStartSnapshot(null);
}}
>
</Button>
<Button
className="flex-1"
isDisabled={!selectedIdTag || startMutation.isPending}
onPress={() => startMutation.mutate()}
>

View File

@@ -24,9 +24,11 @@ import {
} from "@heroui/react";
import { parseDate } from "@internationalized/date";
import { ArrowRotateRight, Pencil, Plus, TrashBin } from "@gravity-ui/icons";
import { LayoutGrid, List } from "lucide-react";
import { api, type IdTag, type UserRow } from "@/lib/api";
import { useSession } from "@/lib/auth-client";
import dayjs from "@/lib/dayjs";
import { IdTagCard } from "@/components/id-tag-card";
const statusColorMap: Record<string, "success" | "danger" | "warning"> = {
Accepted: "success",
@@ -43,6 +45,8 @@ type FormState = {
parentIdTag: string;
userId: string;
balance: string;
cardLayout: string;
cardSkin: string;
};
const emptyForm: FormState = {
@@ -52,6 +56,8 @@ const emptyForm: FormState = {
parentIdTag: "",
userId: "",
balance: "0",
cardLayout: "around",
cardSkin: "circles",
};
const STATUS_OPTIONS = ["Accepted", "Blocked", "Expired", "Invalid", "ConcurrentTx"] as const;
@@ -325,6 +331,61 @@ function TagFormBody({
users={users}
/>
</div>
<div className="flex flex-col gap-1">
<Label className="text-sm font-medium"></Label>
<Select
fullWidth
selectedKey={form.cardLayout}
onSelectionChange={(key) => setForm({ ...form, cardLayout: String(key) })}
>
<Select.Trigger>
<Select.Value />
<Select.Indicator />
</Select.Trigger>
<Select.Popover>
<ListBox>
<ListBox.Item key="around" id="around">
</ListBox.Item>
<ListBox.Item key="center" id="center">
</ListBox.Item>
</ListBox>
</Select.Popover>
</Select>
</div>
<div className="flex flex-col gap-1">
<Label className="text-sm font-medium"></Label>
<Select
fullWidth
selectedKey={form.cardSkin}
onSelectionChange={(key) => setForm({ ...form, cardSkin: String(key) })}
>
<Select.Trigger>
<Select.Value />
<Select.Indicator />
</Select.Trigger>
<Select.Popover>
<ListBox>
<ListBox.Item key="circles" id="circles">
</ListBox.Item>
<ListBox.Item key="line" id="line">
线
</ListBox.Item>
<ListBox.Item key="glow" id="glow">
</ListBox.Item>
<ListBox.Item key="vip" id="vip">
VIP
</ListBox.Item>
<ListBox.Item key="redeye" id="redeye">
</ListBox.Item>
</ListBox>
</Select.Popover>
</Select>
</div>
</>
);
}
@@ -337,6 +398,7 @@ export default function IdTagsPage() {
const [saving, setSaving] = useState(false);
const [deletingTag, setDeletingTag] = useState<string | null>(null);
const [claiming, setClaiming] = useState(false);
const [viewMode, setViewMode] = useState<"table" | "grid">("table");
const {
data: idTagsData,
@@ -344,7 +406,7 @@ export default function IdTagsPage() {
isFetching: refreshing,
refetch,
} = useQuery({
queryKey: ["idTags"],
queryKey: ["idTags", "withUsers"],
queryFn: async () => {
const [tagList, userList] = await Promise.all([
api.idTags.list(),
@@ -381,6 +443,8 @@ export default function IdTagsPage() {
parentIdTag: tag.parentIdTag ?? "",
userId: tag.userId ?? "",
balance: fenToYuan(tag.balance),
cardLayout: tag.cardLayout ?? "around",
cardSkin: tag.cardSkin ?? "circles",
});
};
@@ -394,6 +458,8 @@ export default function IdTagsPage() {
parentIdTag: form.parentIdTag || null,
userId: form.userId || null,
balance: yuanToFen(form.balance),
cardLayout: (form.cardLayout as "center" | "around") || null,
cardSkin: (form.cardSkin as "line" | "circles" | "glow" | "vip" | "redeye") || null,
});
} else {
await api.idTags.create({
@@ -403,6 +469,8 @@ export default function IdTagsPage() {
parentIdTag: form.parentIdTag || undefined,
userId: form.userId || undefined,
balance: yuanToFen(form.balance),
cardLayout: (form.cardLayout as "center" | "around") || undefined,
cardSkin: (form.cardSkin as "line" | "circles" | "glow" | "vip" | "redeye") || undefined,
});
}
await refetch();
@@ -440,6 +508,19 @@ export default function IdTagsPage() {
>
<ArrowRotateRight className={`size-4 ${refreshing ? "animate-spin" : ""}`} />
</Button>
<Button
isIconOnly
size="sm"
variant="ghost"
onPress={() => setViewMode(viewMode === "table" ? "grid" : "table")}
aria-label={viewMode === "table" ? "切换到卡片视图" : "切换到列表视图"}
>
{viewMode === "table" ? (
<LayoutGrid className="size-4" />
) : (
<List className="size-4" />
)}
</Button>
<Modal>
<Button size="sm" variant="secondary" onPress={openCreate}>
<Plus className="size-4" />
@@ -482,69 +563,46 @@ export default function IdTagsPage() {
)}
</div>
<Table>
<Table.ScrollContainer>
<Table.Content aria-label="储值卡列表" className="min-w-200">
<Table.Header>
<Table.Column isRowHeader></Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
{isAdmin && <Table.Column></Table.Column>}
<Table.Column></Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
{isAdmin && <Table.Column className="text-end"></Table.Column>}
</Table.Header>
<Table.Body
renderEmptyState={() => (
<div className="py-8 text-center text-sm text-muted">
{loading ? "加载中…" : "暂无储值卡"}
</div>
)}
>
{/* ── Grid view ─────────────────────────────────────────── */}
{(!isAdmin || viewMode === "grid") && (
<>
{loading ? (
<div className="flex justify-center py-16">
<Spinner />
</div>
) : tags.length === 0 ? (
<div className="rounded-2xl border border-border px-6 py-14 text-center text-sm text-muted">
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{tags.map((tag) => {
const owner = users.find((u) => u.id === tag.userId);
return (
<Table.Row key={tag.idTag} id={tag.idTag}>
<Table.Cell className="font-mono font-medium">{tag.idTag}</Table.Cell>
<Table.Cell>
<Chip
color={statusColorMap[tag.status] ?? "warning"}
size="sm"
variant="soft"
>
{tag.status}
</Chip>
</Table.Cell>
<Table.Cell className="font-mono">¥{fenToYuan(tag.balance)}</Table.Cell>
<div key={tag.idTag} className="space-y-2">
<IdTagCard
idTag={tag.idTag}
balance={tag.balance}
layout={tag.cardLayout ?? undefined}
skin={tag.cardSkin ?? undefined}
/>
{isAdmin && (
<Table.Cell className="text-sm">
{owner ? (
<span title={owner.email}>
{owner.name ?? owner.username ?? owner.email}
</span>
) : (
<span className="text-muted"></span>
)}
</Table.Cell>
)}
<Table.Cell>
{tag.expiryDate ? (
dayjs(tag.expiryDate).format("YYYY/M/D")
) : (
<span className="text-muted"></span>
)}
</Table.Cell>
<Table.Cell className="font-mono">
{tag.parentIdTag ?? <span className="text-muted"></span>}
</Table.Cell>
<Table.Cell className="text-sm">
{dayjs(tag.createdAt).format("YYYY/M/D HH:mm:ss")}
</Table.Cell>
{isAdmin && (
<Table.Cell>
<div className="flex justify-end gap-1">
{/* Edit button */}
<div className="flex items-center justify-between px-1">
<div className="flex min-w-0 items-center gap-1.5">
<Chip
color={statusColorMap[tag.status] ?? "warning"}
size="sm"
variant="soft"
>
{tag.status}
</Chip>
{owner && (
<span className="truncate text-xs text-muted">
{owner.name ?? owner.username ?? owner.email}
</span>
)}
</div>
<div className="flex shrink-0 gap-1">
<Modal>
<Button
isIconOnly
@@ -582,7 +640,6 @@ export default function IdTagsPage() {
</Modal.Container>
</Modal.Backdrop>
</Modal>
{/* Delete button */}
<Modal>
<Button
isDisabled={deletingTag === tag.idTag}
@@ -634,15 +691,180 @@ export default function IdTagsPage() {
</Modal.Backdrop>
</Modal>
</div>
</Table.Cell>
</div>
)}
</Table.Row>
</div>
);
})}
</Table.Body>
</Table.Content>
</Table.ScrollContainer>
</Table>
</div>
)}
</>
)}
{/* ── Table view (admin only) ────────────────────────────────── */}
{isAdmin && viewMode === "table" && (
<Table>
<Table.ScrollContainer>
<Table.Content aria-label="储值卡列表" className="min-w-200">
<Table.Header>
<Table.Column isRowHeader></Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
{isAdmin && <Table.Column></Table.Column>}
<Table.Column></Table.Column>
<Table.Column></Table.Column>
<Table.Column></Table.Column>
{isAdmin && <Table.Column className="text-end"></Table.Column>}
</Table.Header>
<Table.Body
renderEmptyState={() => (
<div className="py-8 text-center text-sm text-muted">
{loading ? "加载中…" : "暂无储值卡"}
</div>
)}
>
{tags.map((tag) => {
const owner = users.find((u) => u.id === tag.userId);
return (
<Table.Row key={tag.idTag} id={tag.idTag}>
<Table.Cell className="font-mono font-medium">{tag.idTag}</Table.Cell>
<Table.Cell>
<Chip
color={statusColorMap[tag.status] ?? "warning"}
size="sm"
variant="soft"
>
{tag.status}
</Chip>
</Table.Cell>
<Table.Cell className="font-mono">¥{fenToYuan(tag.balance)}</Table.Cell>
{isAdmin && (
<Table.Cell className="text-sm">
{owner ? (
<span title={owner.email}>
{owner.name ?? owner.username ?? owner.email}
</span>
) : (
<span className="text-muted"></span>
)}
</Table.Cell>
)}
<Table.Cell>
{tag.expiryDate ? (
dayjs(tag.expiryDate).format("YYYY/M/D")
) : (
<span className="text-muted"></span>
)}
</Table.Cell>
<Table.Cell className="font-mono">
{tag.parentIdTag ?? <span className="text-muted"></span>}
</Table.Cell>
<Table.Cell className="text-sm">
{dayjs(tag.createdAt).format("YYYY/M/D HH:mm:ss")}
</Table.Cell>
{isAdmin && (
<Table.Cell>
<div className="flex justify-end gap-1">
{/* Edit button */}
<Modal>
<Button
isIconOnly
size="sm"
variant="tertiary"
onPress={() => openEdit(tag)}
>
<Pencil className="size-4" />
</Button>
<Modal.Backdrop>
<Modal.Container scroll="outside">
<Modal.Dialog className="sm:max-w-105">
<Modal.CloseTrigger />
<Modal.Header>
<Modal.Heading></Modal.Heading>
</Modal.Header>
<Modal.Body className="space-y-3">
<TagFormBody
form={form}
setForm={setForm}
isEdit={true}
users={users}
tags={tags}
/>
</Modal.Body>
<Modal.Footer className="flex justify-end gap-2">
<Button slot="close" variant="ghost">
</Button>
<Button isDisabled={saving} slot="close" onPress={handleSave}>
{saving ? <Spinner size="sm" /> : "保存"}
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
{/* Delete button */}
<Modal>
<Button
isDisabled={deletingTag === tag.idTag}
isIconOnly
size="sm"
variant="danger-soft"
>
{deletingTag === tag.idTag ? (
<Spinner size="sm" />
) : (
<TrashBin className="size-4" />
)}
</Button>
<Modal.Backdrop>
<Modal.Container scroll="outside">
<Modal.Dialog className="sm:max-w-96">
<Modal.CloseTrigger />
<Modal.Header>
<Modal.Heading></Modal.Heading>
</Modal.Header>
<Modal.Body>
<p className="text-sm text-muted">
{" "}
<span className="font-mono font-medium text-foreground">
{tag.idTag}
</span>
</p>
</Modal.Body>
<Modal.Footer className="flex justify-end gap-2">
<Button slot="close" variant="ghost">
</Button>
<Button
slot="close"
variant="danger"
isDisabled={deletingTag === tag.idTag}
onPress={() => handleDelete(tag.idTag)}
>
{deletingTag === tag.idTag ? (
<Spinner size="sm" />
) : (
"确认删除"
)}
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
</div>
</Table.Cell>
)}
</Table.Row>
);
})}
</Table.Body>
</Table.Content>
</Table.ScrollContainer>
</Table>
)}
</div>
);
}

View File

@@ -2,15 +2,13 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Button, Card, Spinner } from "@heroui/react";
import { Button, Spinner } from "@heroui/react";
import Link from "next/link";
import {
Thunderbolt,
PlugConnection,
CreditCard,
ChartColumn,
TagDollar,
Person,
ArrowRotateRight,
TriangleExclamation,
} from "@gravity-ui/icons";
@@ -26,6 +24,8 @@ import {
type ChartRange,
type ChartDataPoint,
} from "@/lib/api";
import { BanknoteArrowDown, EvCharger, Plug, Users } from "lucide-react";
import MetricIndicator from "@/components/metric-indicator";
// ── Helpers ────────────────────────────────────────────────────────────────
@@ -35,7 +35,7 @@ function timeAgo(dateStr: string | null | undefined): string {
}
function cpOnline(cp: ChargePoint): boolean {
if (!cp.lastHeartbeatAt) return false;
if (cp.transportStatus !== "online" || !cp.lastHeartbeatAt) return false;
return dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120;
}
@@ -71,24 +71,20 @@ function StatCard({
}) {
const s = colorStyles[color];
return (
<Card className={`border-t-2 ${s.border}`}>
<Card.Content className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<p className="text-sm text-muted">{title}</p>
{Icon && (
<div className={`flex size-9 shrink-0 items-center justify-center rounded-xl ${s.bg}`}>
<Icon className={`size-4.5 ${s.icon}`} />
</div>
)}
</div>
<p className="text-3xl font-bold tabular-nums leading-none text-foreground">{value}</p>
{footer && (
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-muted">
{footer}
<MetricIndicator
title={title}
value={value}
color={s.border}
valueClassName="text-3xl font-bold tabular-nums leading-none text-foreground"
icon={
Icon ? (
<div className={`flex size-9 shrink-0 items-center justify-center rounded-xl ${s.bg}`}>
<Icon className={`size-4.5 ${s.icon}`} />
</div>
)}
</Card.Content>
</Card>
) : undefined
}
footer={footer}
/>
);
}
@@ -259,7 +255,7 @@ function TrendChart() {
showXAxis={true}
curveType="monotone"
showAnimation
className="h-56"
className="h-56 text-sm"
/>
)}
{/* Legend */}
@@ -289,7 +285,7 @@ function TrendChart() {
// ── RecentTransactions ────────────────────────────────────────────────────
function RecentTransactions({ txns }: { txns: Transaction[] }) {
function RecentTransactions({ txns, isAdmin = false }: { txns: Transaction[]; isAdmin?: boolean }) {
if (txns.length === 0) {
return <div className="py-8 text-center text-sm text-muted"></div>;
}
@@ -310,14 +306,21 @@ function RecentTransactions({ txns }: { txns: Transaction[] }) {
<span
className={`flex size-7 shrink-0 items-center justify-center rounded-full ${active ? "bg-warning-soft" : "bg-success/10"}`}
>
<Thunderbolt className={`size-3.5 ${active ? "text-warning" : "text-success"}`} />
<BanknoteArrowDown
className={`size-3.5 ${active ? "text-warning" : "text-success"}`}
/>
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{tx.chargePointIdentifier ?? "—"}
{tx.chargePointDeviceName ?? tx.chargePointIdentifier ?? "—"}
{tx.connectorNumber != null && (
<span className="ml-1 text-xs text-muted">#{tx.connectorNumber}</span>
)}
{isAdmin && tx.chargePointDeviceName && tx.chargePointIdentifier && (
<span className="ml-1 font-mono text-xs text-muted">
({tx.chargePointIdentifier})
</span>
)}
</p>
<p className="text-xs text-muted">
{tx.idTag}
@@ -349,7 +352,7 @@ function RecentTransactions({ txns }: { txns: Transaction[] }) {
// ── ChargePointStatus ─────────────────────────────────────────────────────
function ChargePointStatus({ cps }: { cps: ChargePoint[] }) {
function ChargePointStatus({ cps, isAdmin }: { cps: ChargePoint[]; isAdmin: boolean }) {
if (cps.length === 0) {
return <div className="py-8 text-center text-sm text-muted"></div>;
}
@@ -371,11 +374,16 @@ function ChargePointStatus({ cps }: { cps: ChargePoint[] }) {
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{cp.chargePointIdentifier}
</p>
<p className="text-xs text-muted">
{cp.chargePointModel ?? cp.chargePointVendor ?? "未知型号"}
{cp.deviceName ?? cp.chargePointIdentifier}
</p>
{isAdmin && cp.deviceName && (
<p className="font-mono text-xs text-muted">{cp.chargePointIdentifier}</p>
)}
{!(isAdmin && cp.deviceName) && (
<p className="text-xs text-muted">
{cp.chargePointModel ?? cp.chargePointVendor ?? "未知型号"}
</p>
)}
</div>
<div className="shrink-0 text-right">
{online ? (
@@ -404,7 +412,7 @@ function ChargePointStatus({ cps }: { cps: ChargePoint[] }) {
</div>
)}
<div className="flex items-center gap-1">
<PlugConnection className="size-3 text-accent" />
<Plug className="size-3 text-accent" />
<span className="text-muted">{availableCount}</span>
</div>
</div>
@@ -531,7 +539,7 @@ export default function DashboardPage() {
<StatCard
title="充电桩总数"
value={s?.totalChargePoints ?? "—"}
icon={PlugConnection}
icon={EvCharger}
color="default"
footer={
<>
@@ -552,7 +560,7 @@ export default function DashboardPage() {
<StatCard
title="注册用户"
value={s?.totalUsers ?? "—"}
icon={Person}
icon={Users}
color="default"
footer={<span></span>}
/>
@@ -565,12 +573,12 @@ export default function DashboardPage() {
<div className="grid grid-cols-1 gap-4 lg:grid-cols-5">
<div className="lg:col-span-2">
<Panel title="充电桩状态">
<ChargePointStatus cps={data?.cps ?? []} />
<ChargePointStatus cps={data?.cps ?? []} isAdmin={isAdmin} />
</Panel>
</div>
<div className="lg:col-span-3">
<Panel title="最近充电会话">
<RecentTransactions txns={data?.txns ?? []} />
<RecentTransactions txns={data?.txns ?? []} isAdmin={isAdmin} />
</Panel>
</div>
</div>

View File

@@ -0,0 +1,233 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Spinner } from "@heroui/react";
import { TagDollar, ChartLine } from "@gravity-ui/icons";
import { api, type PriceTier, type TariffConfig, type TariffSlot } from "@/lib/api";
// ── Tier meta (matches admin pricing editor colors) ───────────────────────────
const TIER_META: Record<
PriceTier,
{ label: string; sublabel: string; cellBg: string; text: string; border: string; dot: string }
> = {
peak: {
label: "峰时",
sublabel: "高峰时段",
cellBg: "bg-orange-500/70",
text: "text-orange-500",
border: "border-orange-500/40",
dot: "bg-orange-500",
},
flat: {
label: "平时",
sublabel: "肩峰时段",
cellBg: "bg-neutral-400/45",
text: "text-neutral-400",
border: "border-neutral-400/40",
dot: "bg-neutral-400",
},
valley: {
label: "谷时",
sublabel: "低谷时段",
cellBg: "bg-blue-500/65",
text: "text-blue-500",
border: "border-blue-500/40",
dot: "bg-blue-500",
},
};
// ── Slot cards ────────────────────────────────────────────────────────────────
function SlotCards({ tariff }: { tariff: TariffConfig }) {
const sorted = [...tariff.slots].sort((a, b) => a.start - b.start);
return (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{sorted.map((slot: TariffSlot, i) => {
const meta = TIER_META[slot.tier];
const p = tariff.prices[slot.tier];
const total = p.electricityPrice + p.serviceFee;
return (
<div key={i} className={`rounded-xl border bg-surface p-4 space-y-3 ${meta.border}`}>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={`size-2.5 rounded-sm ${meta.cellBg}`} />
<span className={`text-sm font-semibold ${meta.text}`}>{meta.label}</span>
<span className="text-xs text-muted">{meta.sublabel}</span>
</div>
<span className="tabular-nums text-xs font-medium text-muted">
{String(slot.start).padStart(2, "0")}:00 {String(slot.end).padStart(2, "0")}:00
</span>
</div>
{/* Prices */}
<div className="divide-y divide-border rounded-lg border border-border overflow-hidden text-sm">
<div className="flex divide-x divide-border">
<div className="flex-1 flex items-center justify-between bg-surface-secondary px-3 py-2">
<span className="text-muted"></span>
<span className="tabular-nums font-medium text-foreground">
¥{p.electricityPrice.toFixed(4)}
<span className="text-xs text-muted font-normal">/kWh</span>
</span>
</div>
<div className="flex-1 flex items-center justify-between bg-surface-secondary px-3 py-2">
<span className="text-muted"></span>
<span className="tabular-nums font-medium text-foreground">
¥{p.serviceFee.toFixed(4)}
<span className="text-xs text-muted font-normal">/kWh</span>
</span>
</div>
</div>
<div className="flex items-center justify-between bg-surface px-3 py-2">
<span className="font-semibold text-foreground"></span>
<span className={`tabular-nums font-bold ${meta.text}`}>
¥{total.toFixed(4)}
<span className="text-xs font-normal">/kWh</span>
</span>
</div>
</div>
</div>
);
})}
</div>
);
}
// ── Page ─────────────────────────────────────────────────────────────────────
export default function PricingPage() {
const {
data: tariff,
isLoading,
isError,
} = useQuery({
queryKey: ["tariff"],
queryFn: () => api.tariff.get(),
});
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-xl bg-accent/10">
<TagDollar className="size-5 text-accent" />
</div>
<div>
<h1 className="text-xl font-bold text-foreground"></h1>
<p className="text-sm text-muted"></p>
</div>
</div>
{isLoading && (
<div className="flex justify-center py-20">
<Spinner />
</div>
)}
{isError && (
<div className="rounded-xl border border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">
</div>
)}
{!isLoading && !isError && !tariff && (
<div className="rounded-2xl border border-border bg-surface px-6 py-12 text-center">
<TagDollar className="mx-auto mb-3 size-10 text-muted" />
<p className="font-medium text-foreground"></p>
<p className="mt-1 text-sm text-muted">
使
</p>
</div>
)}
{tariff && (
<>
{/* Timeline + legend */}
<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">
<ChartLine className="size-5 text-accent" />
</div>
<div>
<p className="text-sm font-semibold text-foreground">24 </p>
<p className="text-xs text-muted"></p>
</div>
</div>
<div className="space-y-3 px-5 py-5">
{/* Tick labels */}
<div className="relative h-4">
{[0, 6, 12, 18, 24].map((h) => (
<span
key={h}
className="absolute text-[10px] tabular-nums text-muted"
style={{
left: `${(h / 24) * 100}%`,
transform:
h === 24 ? "translateX(-100%)" : h > 0 ? "translateX(-50%)" : undefined,
}}
>
{String(h).padStart(2, "0")}:00
</span>
))}
</div>
{/* Colored bar */}
<div className="flex h-12 overflow-hidden rounded-lg">
{(() => {
const hourTier: PriceTier[] = Array(24).fill("flat" as PriceTier);
for (const slot of tariff.slots) {
for (let h = slot.start; h < slot.end; h++) hourTier[h] = slot.tier;
}
return hourTier.map((tier, h) => (
<div
key={h}
className={`flex-1 ${TIER_META[tier].cellBg} transition-opacity hover:opacity-100 opacity-90 flex items-center justify-center`}
title={`${String(h).padStart(2, "0")}:00 — ${TIER_META[tier].label}`}
>
<span className="hidden text-[10px] font-semibold text-white drop-shadow lg:block">
{String(h).padStart(2, "0")}
</span>
</div>
));
})()}
</div>
{/* Legend */}
<div className="flex flex-wrap items-center gap-3 gap-y-1 pt-0.5">
{(["peak", "flat", "valley"] as PriceTier[]).map((tier) => {
const meta = TIER_META[tier];
const hours = tariff.slots
.filter((s) => s.tier === tier)
.reduce((acc, s) => acc + (s.end - s.start), 0);
const total =
tariff.prices[tier].electricityPrice + tariff.prices[tier].serviceFee;
return (
<div key={tier} className="flex items-center gap-1.5">
<span className={`size-3 rounded-sm ${meta.cellBg}`} />
<span className="text-xs text-muted">
<span className={`font-semibold ${meta.text}`}>{meta.label}</span>
<span className="ml-1 text-muted/70">
{hours}h · ¥{total.toFixed(4)}/kWh
</span>
</span>
</div>
);
})}
</div>
</div>
</div>
{/* Slot cards */}
<div className="space-y-3">
<h2 className="text-sm font-semibold text-foreground"></h2>
<SlotCards tariff={tariff} />
<p className="text-xs text-muted">
= +
</p>
</div>
</>
)}
</div>
);
}

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

@@ -0,0 +1,537 @@
"use client";
import { useState, useCallback, useEffect, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Button, Input, Label, Spinner, TextField, toast } from "@heroui/react";
import { TagDollar, Lock, ArrowRotateRight, ChartLine } from "@gravity-ui/icons";
// TagDollar is used in the page header badge
import { useSession } from "@/lib/auth-client";
import { api } from "@/lib/api";
// ─────────────────────────────────────────────────────────────────────────────
// Types (designed for future backend API integration)
// Backend API: PUT /api/tariff
// ─────────────────────────────────────────────────────────────────────────────
export type PriceTier = "peak" | "valley" | "flat";
/**
* Compact time slot for the backend API.
* `start`: inclusive hour (023), `end`: exclusive hour (124).
* Example: { start: 8, end: 12, tier: "peak" } → 08:0012:00 峰时
*/
export type TimeSlot = {
start: number;
end: number;
tier: PriceTier;
};
export type TierPricing = {
/** Grid electricity tariff in CNY/kWh */
electricityPrice: number;
/** Charging service fee in CNY/kWh */
serviceFee: number;
};
/**
* Full tariff configuration payload.
* Intended for: PUT /api/tariff
*/
export type TariffConfig = {
/** Compact slot representation — what gets stored in the DB */
slots: TimeSlot[];
/** Electricity price + service fee breakdown per tier, in CNY/kWh */
prices: Record<PriceTier, TierPricing>;
};
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
/** Compress a 24-element hourly schedule into minimal time slots */
function scheduleToSlots(schedule: PriceTier[]): TimeSlot[] {
const slots: TimeSlot[] = [];
let i = 0;
while (i < 24) {
const tier = schedule[i];
let j = i + 1;
while (j < 24 && schedule[j] === tier) j++;
slots.push({ start: i, end: j, tier });
i = j;
}
return slots;
}
// ─────────────────────────────────────────────────────────────────────────────
// Constants
// ─────────────────────────────────────────────────────────────────────────────
const TIERS: PriceTier[] = ["peak", "valley", "flat"];
const TIER_META: Record<
PriceTier,
{
label: string;
sublabel: string;
cellBg: string;
activeBorder: string;
activeBg: string;
activeText: string;
dotClass: string;
}
> = {
peak: {
label: "峰时",
sublabel: "高峰时段",
cellBg: "bg-orange-500/70",
activeBorder: "border-orange-500/60",
activeBg: "bg-orange-500/10",
activeText: "text-orange-500",
dotClass: "bg-orange-500",
},
valley: {
label: "谷时",
sublabel: "低谷时段",
cellBg: "bg-blue-500/65",
activeBorder: "border-blue-500/60",
activeBg: "bg-blue-500/10",
activeText: "text-blue-500",
dotClass: "bg-blue-500",
},
flat: {
label: "平时",
sublabel: "肩峰时段",
cellBg: "bg-neutral-400/45",
activeBorder: "border-neutral-400/60",
activeBg: "bg-neutral-400/10",
activeText: "text-neutral-400",
dotClass: "bg-neutral-400",
},
};
/**
* A typical residential TOU schedule for the Shanghai area (illustrative).
* 0007 谷, 0811 峰, 12 平, 1316 峰, 1721 峰, 2223 谷
*/
const DEFAULT_SCHEDULE: PriceTier[] = [
// 0-5
"valley",
"valley",
"valley",
"valley",
"valley",
"valley",
// 6-11
"flat",
"flat",
"peak",
"peak",
"peak",
"flat",
// 12-17
"peak",
"peak",
"flat",
"flat",
"flat",
"peak",
// 18-23
"peak",
"peak",
"flat",
"flat",
"valley",
"valley",
];
const DEFAULT_PRICES: Record<PriceTier, TierPricing> = {
peak: { electricityPrice: 1.0, serviceFee: 0.2 },
valley: { electricityPrice: 0.3, serviceFee: 0.1 },
flat: { electricityPrice: 0.65, serviceFee: 0.15 },
};
// ─────────────────────────────────────────────────────────────────────────────
// Component
// ─────────────────────────────────────────────────────────────────────────────
export default function PricingPage() {
const { data: session } = useSession();
const isAdmin = session?.user?.role === "admin";
const queryClient = useQueryClient();
// Load current tariff from API
const { data: remoteTariff, isLoading: loadingTariff } = useQuery({
queryKey: ["tariff"],
queryFn: () => api.tariff.get(),
enabled: isAdmin,
});
const [schedule, setSchedule] = useState<PriceTier[]>([...DEFAULT_SCHEDULE]);
// ── Price inputs: keep as strings to avoid controlled input jitter ──────
type TierPricingStrings = { electricityPrice: string; serviceFee: string };
const toStrings = (p: TierPricing): TierPricingStrings => ({
electricityPrice: String(p.electricityPrice),
serviceFee: String(p.serviceFee),
});
const [priceStrings, setPriceStrings] = useState<Record<PriceTier, TierPricingStrings>>({
peak: toStrings(DEFAULT_PRICES.peak),
valley: toStrings(DEFAULT_PRICES.valley),
flat: toStrings(DEFAULT_PRICES.flat),
});
const [prices, setPrices] = useState<Record<PriceTier, TierPricing>>({ ...DEFAULT_PRICES });
const [activeTier, setActiveTier] = useState<PriceTier>("peak");
const [isDirty, setIsDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [showPayload, setShowPayload] = useState(false);
// Populate state once remote tariff loads
useEffect(() => {
if (!remoteTariff) return;
// Reconstruct 24-element schedule from slots
const s: PriceTier[] = [];
for (let i = 0; i < 24; i++) s.push("flat");
for (const slot of remoteTariff.slots) {
for (let h = slot.start; h < slot.end; h++) s[h] = slot.tier as PriceTier;
}
setSchedule(s);
const p = remoteTariff.prices as Record<PriceTier, TierPricing>;
setPrices(p);
setPriceStrings({
peak: toStrings(p.peak),
valley: toStrings(p.valley),
flat: toStrings(p.flat),
});
setIsDirty(false);
}, [remoteTariff]);
// ── Drag state via ref (avoids stale closures in global handler) ─────────
const dragRef = useRef({
active: false,
startHour: 0,
endHour: 0,
tier: "peak" as PriceTier,
});
const [dragHighlight, setDragHighlight] = useState<[number, number] | null>(null);
const commitDrag = useCallback(() => {
if (!dragRef.current.active) return;
const { startHour, endHour, tier } = dragRef.current;
const lo = Math.min(startHour, endHour);
const hi = Math.max(startHour, endHour);
dragRef.current.active = false;
setDragHighlight(null);
setSchedule((prev) => {
const next = [...prev];
for (let i = lo; i <= hi; i++) next[i] = tier;
return next;
});
setIsDirty(true);
}, []); // reads from ref, no stale closure
useEffect(() => {
window.addEventListener("mouseup", commitDrag);
return () => window.removeEventListener("mouseup", commitDrag);
}, [commitDrag]);
const handleCellMouseDown = (hour: number, e: React.MouseEvent) => {
e.preventDefault();
dragRef.current = { active: true, startHour: hour, endHour: hour, tier: activeTier };
setDragHighlight([hour, hour]);
};
const handleCellMouseEnter = (hour: number) => {
if (!dragRef.current.active) return;
dragRef.current.endHour = hour;
setDragHighlight([dragRef.current.startHour, hour]);
};
// ── Price input handlers ─────────────────────────────────────────────────
const handlePriceChange = (tier: PriceTier, field: keyof TierPricing, value: string) => {
setPriceStrings((prev) => ({ ...prev, [tier]: { ...prev[tier], [field]: value } }));
const num = parseFloat(value);
if (!isNaN(num) && num >= 0) {
setPrices((prev) => ({ ...prev, [tier]: { ...prev[tier], [field]: num } }));
setIsDirty(true);
}
};
// ── Reset ────────────────────────────────────────────────────────────────
const handleReset = () => {
setSchedule([...DEFAULT_SCHEDULE]);
setPrices({ ...DEFAULT_PRICES });
setPriceStrings({
peak: toStrings(DEFAULT_PRICES.peak),
valley: toStrings(DEFAULT_PRICES.valley),
flat: toStrings(DEFAULT_PRICES.flat),
});
setIsDirty(false);
};
// ── Save ──────────────────────────────────────────────────────────────────────
const handleSave = async () => {
setSaving(true);
try {
await api.tariff.put({ slots, prices });
setIsDirty(false);
queryClient.invalidateQueries({ queryKey: ["tariff"] });
toast.success("电价配置已保存");
} catch {
toast.warning("保存失败,请稍候重试");
} finally {
setSaving(false);
}
};
// ── Derived values ───────────────────────────────────────────────────────
const slots = scheduleToSlots(schedule);
const apiPayload: TariffConfig = { slots, prices };
// ── Admin gate ───────────────────────────────────────────────────────────
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 (loadingTariff) {
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">
{/* ── Page header ───────────────────────────────────────────────────── */}
<div className="flex items-start gap-2">
<div className="flex-1">
<div className="flex items-center gap-2">
<TagDollar 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>
{/* ── Timeline editor ───────────────────────────────────────────────── */}
<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">
<ChartLine className="size-5 text-accent" />
</div>
<div>
<p className="text-sm font-semibold text-foreground"></p>
<p className="text-xs text-muted"></p>
</div>
</div>
<div className="space-y-4 px-5 py-5">
{/* Tier palette */}
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-muted"></span>
{TIERS.map((tier) => {
const meta = TIER_META[tier];
const isActive = activeTier === tier;
return (
<button
key={tier}
type="button"
onClick={() => setActiveTier(tier)}
className={[
"flex items-center gap-1.5 rounded-lg ring-2 px-3 py-1.5 text-sm font-medium transition-all select-none",
isActive
? `${meta.activeBorder} ${meta.activeBg} ${meta.activeText}`
: "ring-transparent text-muted hover:bg-surface-tertiary hover:text-foreground",
].join(" ")}
>
<span className={`size-2.5 shrink-0 rounded-sm ${meta.cellBg}`} />
{meta.label}
</button>
);
})}
</div>
{/* 24-hour grid */}
<div className="select-none">
{/* Hour tick labels at 0, 6, 12, 18, 24 */}
<div className="relative mb-1 h-4">
{[0, 6, 12, 18, 24].map((h) => (
<span
key={h}
className="absolute text-[10px] tabular-nums text-muted"
style={{
left: `${(h / 24) * 100}%`,
transform:
h === 24 ? "translateX(-100%)" : h > 0 ? "translateX(-50%)" : undefined,
}}
>
{String(h).padStart(2, "0")}:00
</span>
))}
</div>
{/* Hour cells */}
<div className="flex h-12 overflow-hidden rounded-lg">
{schedule.map((tier, hour) => {
const inDrag = dragHighlight
? hour >= Math.min(dragHighlight[0], dragHighlight[1]) &&
hour <= Math.max(dragHighlight[0], dragHighlight[1])
: false;
const displayTier = inDrag ? activeTier : tier;
const meta = TIER_META[displayTier];
return (
<div
key={hour}
className={[
"group relative flex h-full flex-1 cursor-crosshair flex-col items-center justify-center transition-colors",
meta.cellBg,
].join(" ")}
onMouseDown={(e) => handleCellMouseDown(hour, e)}
onMouseEnter={() => handleCellMouseEnter(hour)}
>
<span className="hidden text-[10px] font-semibold text-white drop-shadow lg:block">
{String(hour).padStart(2, "0")}
</span>
</div>
);
})}
</div>
{/* Legend */}
<div className="mt-2.5 flex flex-wrap items-center gap-2 gap-y-1">
{TIERS.map((tier) => {
const meta = TIER_META[tier];
const hours = schedule.filter((t) => t === tier).length;
return (
<div key={tier} className="flex items-center gap-1">
<span className={`size-3 rounded-sm ${meta.cellBg}`} />
<span className="text-xs text-muted">
{meta.label}
<span className="ml-1 text-muted/60">
({meta.sublabel} · {hours}h)
</span>
</span>
</div>
);
})}
</div>
</div>
</div>
</div>
{/* ── Price configuration ───────────────────────────────────────────── */}
<div className="rounded-xl border border-border bg-surface-secondary">
<div className="border-b border-border px-5 py-4">
<p className="text-sm font-semibold text-foreground"></p>
<p className="text-xs text-muted"> / kWh</p>
</div>
<div className="grid grid-cols-3 divide-x divide-border">
{TIERS.map((tier) => {
const meta = TIER_META[tier];
const hours = schedule.filter((t) => t === tier).length;
const pct = Math.round((hours / 24) * 100);
const effective = prices[tier].electricityPrice + prices[tier].serviceFee;
return (
<div key={tier} className="space-y-3 p-5">
<div className="flex items-center gap-2">
<span className={`size-2.5 shrink-0 rounded-full ${meta.dotClass}`} />
<span className={`text-sm font-semibold ${meta.activeText}`}>{meta.label}</span>
<span className="ml-auto text-xs tabular-nums text-muted">
{hours}h · {pct}%
</span>
</div>
<TextField fullWidth>
<Label className="text-xs text-muted">¥ / kWh</Label>
<Input
type="number"
min="0"
step="0.01"
placeholder="0.00"
value={priceStrings[tier].electricityPrice}
onChange={(e) => handlePriceChange(tier, "electricityPrice", e.target.value)}
/>
</TextField>
<TextField fullWidth>
<Label className="text-xs text-muted">¥ / kWh</Label>
<Input
type="number"
min="0"
step="0.01"
placeholder="0.00"
value={priceStrings[tier].serviceFee}
onChange={(e) => handlePriceChange(tier, "serviceFee", e.target.value)}
/>
</TextField>
<div className="rounded-lg bg-surface-tertiary px-3 py-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted"></span>
<span className={`text-sm font-semibold tabular-nums ${meta.activeText}`}>
¥{effective.toFixed(2)} /kWh
</span>
</div>
<p className="mt-0.5 text-xs text-muted">
1 kW · {hours}h ¥{(effective * hours).toFixed(2)} /
</p>
</div>
</div>
);
})}
</div>
</div>
{/* ── Time slots summary ────────────────────────────────────────────── */}
<div className="rounded-xl border border-border bg-surface-secondary">
<div className="border-b border-border px-5 py-4">
<p className="text-sm font-semibold text-foreground"></p>
<p className="text-xs text-muted"> {slots.length} </p>
</div>
<ul className="divide-y divide-border">
{slots.map((slot, i) => {
const meta = TIER_META[slot.tier];
return (
<li key={i} className="flex items-center gap-3 px-5 py-3">
<span className={`size-2.5 shrink-0 rounded-sm ${meta.cellBg}`} />
<span className={`w-9 text-sm font-semibold ${meta.activeText}`}>{meta.label}</span>
<span className="text-sm tabular-nums text-foreground">
{String(slot.start).padStart(2, "0")}:00
<span className="mx-1 text-muted"></span>
{String(slot.end).padStart(2, "0")}:00
</span>
<span className="text-xs text-muted">{slot.end - slot.start}h</span>
<span className="ml-auto text-sm tabular-nums text-foreground">
¥{(prices[slot.tier].electricityPrice + prices[slot.tier].serviceFee).toFixed(2)}
<span className="text-xs text-muted"> /kWh</span>
</span>
</li>
);
})}
</ul>
</div>
{/* ── Actions ───────────────────────────────────────────────────────── */}
<div className="flex items-center justify-between pb-2">
<Button variant="danger-soft" size="sm" onPress={handleReset} isDisabled={saving}>
<ArrowRotateRight className="size-4" />
</Button>
<div className="flex items-center gap-3">
{isDirty && <span className="text-xs text-warning"></span>}
<Button size="sm" onPress={handleSave} isDisabled={saving || !isDirty}>
{saving ? <Spinner size="sm" color="current" /> : "保存配置"}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,12 @@
import TopologyClient from "./topology-client";
export default function TopologyPage() {
return (
// Break out of the dashboard's max-w-7xl / px padding by using
// a fixed overlay that covers exactly the main content area.
// left-0/lg:left-60 accounts for the sidebar width (w-60).
<div className="fixed inset-0 left-0 top-14 lg:left-60 lg:top-0">
<TopologyClient />
</div>
);
}

View File

@@ -0,0 +1,18 @@
"use client";
import dynamic from "next/dynamic";
const TopologyFlow = dynamic(() => import("./topology-flow"), {
ssr: false,
loading: () => (
<div className="flex flex-1 items-center justify-center text-muted text-sm"></div>
),
});
export default function TopologyClient() {
return (
<div style={{ width: "100%", height: "100%" }}>
<TopologyFlow />
</div>
);
}

View File

@@ -0,0 +1,438 @@
"use client";
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import {
ReactFlow,
Background,
Controls,
Handle,
MiniMap,
Panel,
Position,
type Node,
type Edge,
type NodeProps,
BackgroundVariant,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { api, type ChargePoint, type ConnectorSummary } from "@/lib/api";
import { useSession } from "@/lib/auth-client";
import dayjs from "@/lib/dayjs";
import { Clock, EvCharger, Plug, Zap } from "lucide-react";
// ── Connection status ─────────────────────────────────────────────────────
type ConnectionStatus = "online" | "stale" | "offline";
function getStatus(cp: ChargePoint, connected: string[]): ConnectionStatus {
if (cp.transportStatus === "unavailable") return "stale";
if (cp.transportStatus !== "online" || !connected.includes(cp.chargePointIdentifier)) return "offline";
if (!cp.lastHeartbeatAt) return "stale";
return dayjs().diff(dayjs(cp.lastHeartbeatAt), "minute") < 5 ? "online" : "stale";
}
const STATUS_CONFIG: Record<
ConnectionStatus,
{ color: string; edgeColor: string; label: string; animated: boolean }
> = {
online: { color: "#22c55e", edgeColor: "#22c55e", label: "在线", animated: true },
stale: { color: "#f59e0b", edgeColor: "#f59e0b", label: "通道异常", animated: true },
offline: { color: "#71717a", edgeColor: "#9ca3af", label: "离线", animated: false },
};
const CONNECTOR_STATUS_COLOR: Record<string, string> = {
Available: "#22c55e",
Charging: "#3b82f6",
Preparing: "#f59e0b",
Finishing: "#f59e0b",
SuspendedEV: "#f59e0b",
SuspendedEVSE: "#f59e0b",
Reserved: "#a855f7",
Faulted: "#ef4444",
Unavailable: "#71717a",
Occupied: "#f59e0b",
};
const CONNECTOR_STATUS_LABEL: Record<string, string> = {
Available: "空闲",
Charging: "充电中",
Preparing: "准备中",
Finishing: "结束中",
SuspendedEV: "EV 暂停",
SuspendedEVSE: "EVSE 暂停",
Reserved: "已预约",
Faulted: "故障",
Unavailable: "不可用",
Occupied: "占用",
};
// ── CSMS Hub Node ─────────────────────────────────────────────────────────
type CsmsNodeData = { connectedCount: number; totalCount: number };
function CsmsHubNode({ data }: NodeProps) {
const { connectedCount, totalCount } = data as CsmsNodeData;
return (
<div className="min-w-[200px] rounded-xl border border-accent/70 bg-accent px-5 py-4 text-accent-foreground shadow-lg shadow-accent/25">
<Handle type="source" position={Position.Bottom} style={{ opacity: 0 }} />
<div className="mb-2.5 flex items-center gap-2.5">
<div className="flex rounded-lg bg-accent-foreground/15 p-1.5">
<Zap size={16} />
</div>
<div>
<div className="text-[13px] font-semibold leading-tight">CSMS </div>
<div className="mt-0.5 text-[11px] opacity-75">Helios EVCS</div>
</div>
</div>
<div className="flex items-center gap-1.5 rounded-lg bg-accent-foreground/12 px-2.5 py-1.5">
<span
className="size-2 shrink-0 rounded-full"
style={{
background: connectedCount > 0 ? "#22c55e" : "#71717a",
boxShadow: connectedCount > 0 ? "0 0 6px #22c55e" : "none",
}}
/>
<span className="text-xs font-medium">
{connectedCount} / {totalCount} 线
</span>
</div>
</div>
);
}
// ── Charge Point Node ─────────────────────────────────────────────────────
type ChargePointNodeData = { cp: ChargePoint; status: ConnectionStatus; isAdmin: boolean };
function ChargePointNode({ data }: NodeProps) {
const { cp, status, isAdmin } = data as ChargePointNodeData;
const cfg = STATUS_CONFIG[status];
const hbText = cp.lastHeartbeatAt ? dayjs(cp.lastHeartbeatAt).fromNow() : "从未";
return (
<div
className="flex min-w-[190px] flex-col rounded-xl bg-surface px-2 py-2"
style={{
border: `1.5px solid ${status === "offline" ? "var(--color-border)" : cfg.color + "80"}`,
boxShadow:
status !== "offline" ? `0 2px 12px ${cfg.color}25` : "0 1px 4px rgba(0,0,0,0.08)",
}}
>
<Handle type="target" position={Position.Top} style={{ opacity: 0 }} />
<Handle type="source" position={Position.Bottom} style={{ opacity: 0 }} />
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
<EvCharger className="size-4 shrink-0 text-muted" />
<div className="flex flex-col">
<span className="text-[13px] font-semibold tracking-tight text-foreground">
{cp.deviceName ?? cp.chargePointIdentifier}
</span>
{isAdmin && cp.deviceName && (
<span className="font-mono text-[10px] text-muted">{cp.chargePointIdentifier}</span>
)}
</div>
</div>
<span
className="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium"
style={{ background: cfg.color + "18", color: cfg.color }}
>
<span
className="inline-block size-1.5 shrink-0 rounded-full"
style={{
background: cfg.color,
boxShadow: status !== "offline" ? `0 0 5px ${cfg.color}` : "none",
}}
/>
{cfg.label}
</span>
</div>
{(cp.chargePointVendor || cp.chargePointModel) && (
<div className="text-[10px] text-muted">
{[cp.chargePointVendor, cp.chargePointModel].filter(Boolean).join(" · ")}
</div>
)}
<div className="mt-1 flex items-center gap-1 text-[9px] text-muted">
<Clock size={10} />
<span> {hbText}</span>
</div>
</div>
);
}
// ── Connector Node ────────────────────────────────────────────────────────
type ConnectorNodeData = { conn: ConnectorSummary; cpStatus: ConnectionStatus };
function ConnectorNode({ data }: NodeProps) {
const { conn, cpStatus } = data as ConnectorNodeData;
const color =
cpStatus === "offline" ? "#71717a" : (CONNECTOR_STATUS_COLOR[conn.status] ?? "#f59e0b");
const label = CONNECTOR_STATUS_LABEL[conn.status] ?? conn.status;
const isActive = conn.status === "Charging";
return (
<div
className="flex min-w-[88px] flex-col items-center gap-1.5 rounded-lg bg-surface px-2.5 py-2"
style={{
border: `1.5px solid ${color}80`,
boxShadow: isActive ? `0 0 10px ${color}40` : "0 1px 3px rgba(0,0,0,0.06)",
}}
>
<Handle type="target" position={Position.Top} style={{ opacity: 0 }} />
<div className="flex items-center gap-1">
<Plug className="size-3 shrink-0 text-muted" />
<span className="text-xs font-semibold text-foreground">#{conn.connectorId}</span>
</div>
<div
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium"
style={{ background: color + "18", color }}
>
<span
className="inline-block size-[5px] shrink-0 rounded-full"
style={{ background: color, boxShadow: isActive ? `0 0 4px ${color}` : "none" }}
/>
{label}
</div>
</div>
);
}
// ── Layout constants ──────────────────────────────────────────────────────
const CP_W = 200;
const CP_H = 70; // matches actual rendered height
const CP_GAP_X = 60;
const CP_GAP_Y = 100;
const CONN_W = 96;
const CONN_H = 62;
const CONN_GAP_X = 12;
const CONN_ROW_GAP = 48;
const COLS = 5;
const CSMS_H = 88;
/** Horizontal space a charge point needs (driven by its connector spread). */
function slotWidth(cp: ChargePoint): number {
const n = cp.connectors.length;
if (n === 0) return CP_W;
return Math.max(CP_W, n * CONN_W + (n - 1) * CONN_GAP_X);
}
function buildGraph(
chargePoints: ChargePoint[],
connectedIdentifiers: string[],
isAdmin: boolean,
): { nodes: Node[]; edges: Edge[] } {
// Group into rows
const rows: ChargePoint[][] = [];
for (let i = 0; i < chargePoints.length; i += COLS) {
rows.push(chargePoints.slice(i, i + COLS));
}
// Width of each row (accounting for variable slot widths)
const rowWidths = rows.map((rowCps) =>
rowCps.reduce((sum, cp, ci) => sum + slotWidth(cp) + (ci > 0 ? CP_GAP_X : 0), 0),
);
const maxRowWidth = Math.max(...rowWidths, CP_W);
const csmsX = maxRowWidth / 2 - CP_W / 2;
const nodes: Node[] = [
{
id: "csms",
type: "csmsHub",
position: { x: csmsX, y: 0 },
data: { connectedCount: connectedIdentifiers.length, totalCount: chargePoints.length },
draggable: true,
width: CP_W,
height: CSMS_H,
},
];
const edges: Edge[] = [];
let cpY = CSMS_H + CP_GAP_Y;
rows.forEach((rowCps, _rowIdx) => {
const rowW = rowWidths[_rowIdx];
// Center narrower rows under CSMS
let curX = (maxRowWidth - rowW) / 2;
// tallest connector row determines next cpY offset
const maxConnSpread = Math.max(
...rowCps.map((cp) => (cp.connectors.length > 0 ? CONN_ROW_GAP + CONN_H : 0)),
);
rowCps.forEach((cp) => {
const sw = slotWidth(cp);
const cpX = curX + (sw - CP_W) / 2; // center CP node within its slot
const status = getStatus(cp, connectedIdentifiers);
const cfg = STATUS_CONFIG[status];
nodes.push({
id: cp.id,
type: "chargePoint",
position: { x: cpX, y: cpY },
data: { cp, status, isAdmin },
draggable: true,
width: CP_W,
height: CP_H,
});
edges.push({
id: `csms-${cp.id}`,
source: "csms",
target: cp.id,
animated: cfg.animated,
style: {
stroke: cfg.edgeColor,
strokeWidth: status === "offline" ? 1 : 2,
strokeDasharray: status === "offline" ? "6 4" : undefined,
opacity: status === "offline" ? 0.4 : 0.85,
},
});
// Connector nodes — centered under their CP
const sorted = [...cp.connectors].sort((a, b) => a.connectorId - b.connectorId);
const n = sorted.length;
if (n > 0) {
const totalConnW = n * CONN_W + (n - 1) * CONN_GAP_X;
const connStartX = cpX + CP_W / 2 - totalConnW / 2;
const connY = cpY + CP_H + CONN_ROW_GAP;
sorted.forEach((conn, ci) => {
const connNodeId = `conn-${conn.id}`;
const connColor =
status === "offline" ? "#71717a" : (CONNECTOR_STATUS_COLOR[conn.status] ?? "#f59e0b");
nodes.push({
id: connNodeId,
type: "connector",
position: { x: connStartX + ci * (CONN_W + CONN_GAP_X), y: connY },
data: { conn, cpStatus: status },
draggable: true,
width: CONN_W,
height: CONN_H,
});
edges.push({
id: `${cp.id}-${connNodeId}`,
source: cp.id,
target: connNodeId,
animated: conn.status === "Charging",
style: {
stroke: connColor,
strokeWidth: conn.status === "Charging" ? 2 : 1.5,
opacity: status === "offline" ? 0.35 : 0.7,
strokeDasharray: status === "offline" ? "4 3" : undefined,
},
});
});
}
curX += sw + CP_GAP_X;
});
cpY += CP_H + CP_GAP_Y + maxConnSpread;
});
return { nodes, edges };
}
// ── Node type registry (stable reference — must be outside component) ─────
const nodeTypes = {
csmsHub: CsmsHubNode,
chargePoint: ChargePointNode,
connector: ConnectorNode,
};
// ── Main component ────────────────────────────────────────────────────────
export default function TopologyFlow() {
const { data: chargePoints = [], isLoading } = useQuery({
queryKey: ["chargePoints"],
queryFn: () => api.chargePoints.list(),
refetchInterval: 10_000,
});
const { data: connections } = useQuery({
queryKey: ["chargePoints", "connections"],
queryFn: () => api.chargePoints.connections(),
refetchInterval: 10_000,
});
const { data: sessionData } = useSession();
const isAdmin = sessionData?.user?.role === "admin";
const connectedIds = connections?.connectedIdentifiers ?? [];
const { nodes, edges } = useMemo(
() => buildGraph(chargePoints, connectedIds, isAdmin),
[chargePoints, connectedIds, isAdmin],
);
if (isLoading) {
return (
<div className="flex size-full items-center justify-center text-sm text-muted"></div>
);
}
if (chargePoints.length === 0) {
return (
<div className="flex size-full flex-col items-center justify-center gap-2 text-muted">
<EvCharger className="size-10 opacity-30" />
<p className="text-sm"></p>
<p className="text-xs opacity-60"></p>
</div>
);
}
return (
<div className="size-full">
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ padding: 0.15 }}
minZoom={0.15}
maxZoom={2}
proOptions={{ hideAttribution: true }}
>
<Background
variant={BackgroundVariant.Dots}
gap={20}
size={1}
color="var(--color-border)"
/>
<Controls showInteractive={false} />
<MiniMap
nodeColor={(n) => {
if (n.type === "csmsHub") return "#6366f1";
if (n.type === "chargePoint") {
const status = (n.data as ChargePointNodeData).status;
return STATUS_CONFIG[status].color;
}
if (n.type === "connector") {
const { conn, cpStatus } = n.data as ConnectorNodeData;
return cpStatus === "offline"
? "#71717a"
: (CONNECTOR_STATUS_COLOR[conn.status] ?? "#f59e0b");
}
return "#888";
}}
nodeStrokeWidth={0}
style={{
background: "var(--color-surface-secondary)",
border: "1px solid var(--color-border)",
}}
// zoomable
// pannable
/>
</ReactFlow>
</div>
);
}

View File

@@ -0,0 +1,453 @@
"use client";
import { use, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { Alert, Button, Chip, Modal, Spinner } from "@heroui/react";
import { ArrowLeft, ArrowRotateRight, TrashBin } from "@gravity-ui/icons";
import { APIError, api } from "@/lib/api";
import { useSession } from "@/lib/auth-client";
import dayjs from "@/lib/dayjs";
import InfoSection from "@/components/info-section";
import MetricIndicator from "@/components/metric-indicator";
import { BanknoteArrowUp, Clock, EvCharger } from "lucide-react";
const stopReasonLabelMap: Record<string, string> = {
EmergencyStop: "紧急停止",
EVDisconnected: "车辆断开",
HardReset: "硬重启",
Local: "本地结束",
Other: "其他原因",
PowerLoss: "断电结束",
Reboot: "重启结束",
Remote: "远程结束",
SoftReset: "软重启",
UnlockCommand: "解锁结束",
DeAuthorized: "鉴权拒绝",
};
const idTagRejectLabelMap: Record<string, string> = {
Blocked: "卡片不可用或余额不足",
Expired: "卡片已过期",
Invalid: "卡片无效",
ConcurrentTx: "该卡已有进行中的订单",
};
function formatDuration(start: string, stop: string | null): string {
if (!stop) return "进行中";
const min = dayjs(stop).diff(dayjs(start), "minute");
if (min < 60) return `${min} 分钟`;
const h = Math.floor(min / 60);
const m = min % 60;
return `${h}h ${m}m`;
}
function formatDateTime(iso: string | null | undefined): string {
if (!iso) return "—";
return dayjs(iso).format("YYYY/M/D HH:mm:ss");
}
function formatEnergy(wh: number | null | undefined): string {
if (wh == null) return "—";
return `${(wh / 1000).toFixed(3)} kWh`;
}
function formatAmount(fen: number | null | undefined): string {
if (fen == null) return "—";
return `¥${(fen / 100).toFixed(2)}`;
}
export default function TransactionDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const router = useRouter();
const txId = Number(id);
const isValidId = Number.isInteger(txId) && txId > 0;
const [stopping, setStopping] = useState(false);
const [deleting, setDeleting] = useState(false);
const { data: sessionData } = useSession();
const isAdmin = sessionData?.user?.role === "admin";
const {
data: tx,
isPending,
isFetching,
isError,
error,
refetch,
} = useQuery({
queryKey: ["transaction", txId],
queryFn: () => api.transactions.get(txId),
enabled: isValidId,
refetchInterval: 3_000,
retry: false,
});
const handleStop = async () => {
if (!tx) return;
setStopping(true);
try {
await api.transactions.stop(tx.id);
await refetch();
} finally {
setStopping(false);
}
};
const handleDelete = async () => {
if (!tx) return;
setDeleting(true);
try {
await api.transactions.delete(tx.id);
router.push("/dashboard/transactions");
} finally {
setDeleting(false);
}
};
if (!isValidId) {
return (
<div className="space-y-4">
<Link
href="/dashboard/transactions"
className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-foreground"
>
<ArrowLeft className="size-4" />
</Link>
<p className="text-sm text-danger"></p>
</div>
);
}
if (isPending) {
return (
<div className="flex h-48 items-center justify-center">
<Spinner />
</div>
);
}
if (isError || !tx) {
const notFound = error instanceof APIError && error.status === 404;
return (
<div className="space-y-4">
<Link
href="/dashboard/transactions"
className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-foreground"
>
<ArrowLeft className="size-4" />
</Link>
<p className="text-sm text-danger">
{notFound ? "交易记录不存在。" : "加载交易记录失败。"}
</p>
</div>
);
}
const energyWh = tx.energyWh ?? tx.liveEnergyWh;
const amountFen = tx.chargeAmount ?? tx.estimatedCost;
const isEstimatedEnergy = tx.energyWh == null && tx.liveEnergyWh != null;
const isEstimatedAmount = tx.chargeAmount == null && tx.estimatedCost != null;
const isRejected = tx.stopReason === "DeAuthorized";
const stopReasonLabel = tx.stopReason
? (stopReasonLabelMap[tx.stopReason] ?? tx.stopReason)
: "—";
const rejectReason =
tx.idTagStatus && tx.idTagStatus !== "Accepted"
? (idTagRejectLabelMap[tx.idTagStatus] ?? tx.idTagStatus)
: "鉴权失败";
return (
<div className="space-y-6">
<Link
href="/dashboard/transactions"
className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-foreground"
>
<ArrowLeft className="size-4" />
</Link>
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-1.5">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold text-foreground"> #{tx.id}</h1>
{isRejected ? (
<Chip color="danger" size="sm" variant="soft">
</Chip>
) : tx.stopTimestamp ? (
<Chip color="success" size="sm" variant="soft">
</Chip>
) : (
<Chip color="warning" size="sm" variant="soft">
</Chip>
)}
{isEstimatedAmount && (
<Chip color="warning" size="sm" variant="soft">
</Chip>
)}
{tx.stopReason && !isRejected && (
<Chip color="default" size="sm" variant="soft">
{stopReasonLabel}
</Chip>
)}
</div>
<p className="text-sm text-muted">
{formatDateTime(tx.startTimestamp)} · {tx.stopTimestamp ? "时长 " : ""}
{formatDuration(tx.startTimestamp, tx.stopTimestamp)}
</p>
</div>
<div className="flex items-center gap-2">
<Button
isIconOnly
size="sm"
variant="ghost"
isDisabled={isFetching}
onPress={() => refetch()}
aria-label="刷新"
>
<ArrowRotateRight className={`size-4 ${isFetching ? "animate-spin" : ""}`} />
</Button>
{!tx.stopTimestamp && (
<Modal>
<Button size="sm" variant="danger-soft" isDisabled={stopping}>
{stopping ? <Spinner size="sm" /> : "中止充电"}
</Button>
<Modal.Backdrop>
<Modal.Container scroll="outside">
<Modal.Dialog className="sm:max-w-96">
<Modal.CloseTrigger />
<Modal.Header>
<Modal.Heading></Modal.Heading>
</Modal.Header>
<Modal.Body>
<p className="text-sm text-muted">
{" "}
<span className="font-mono text-foreground">#{tx.id}</span>
</p>
</Modal.Body>
<Modal.Footer className="flex justify-end gap-2">
<Button slot="close" variant="ghost">
</Button>
<Button
slot="close"
variant="danger"
isDisabled={stopping}
onPress={handleStop}
>
{stopping ? <Spinner size="sm" /> : "确认中止"}
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
)}
{isAdmin && (
<Modal>
<Button isIconOnly size="sm" variant="tertiary" isDisabled={deleting}>
{deleting ? <Spinner size="sm" /> : <TrashBin className="size-4" />}
</Button>
<Modal.Backdrop>
<Modal.Container scroll="outside">
<Modal.Dialog className="sm:max-w-96">
<Modal.CloseTrigger />
<Modal.Header>
<Modal.Heading></Modal.Heading>
</Modal.Header>
<Modal.Body>
<p className="text-sm text-muted">
<span className="font-mono text-foreground">#{tx.id}</span>
</p>
</Modal.Body>
<Modal.Footer className="flex justify-end gap-2">
<Button slot="close" variant="ghost">
</Button>
<Button
slot="close"
variant="danger"
isDisabled={deleting}
onPress={handleDelete}
>
{deleting ? <Spinner size="sm" /> : "确认删除"}
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
)}
</div>
</div>
<Alert status={isRejected ? "danger" : tx.stopTimestamp ? "success" : "accent"}>
<Alert.Indicator />
<Alert.Content>
<Alert.Title>
{isRejected ? "交易已被系统拒绝" : tx.stopTimestamp ? "本次交易已结束" : "充电进行中"}
</Alert.Title>
<Alert.Description>
{isRejected
? rejectReason
: tx.stopTimestamp
? `结束原因:${stopReasonLabel}`
: "充电进行中,实际充电量和费用以结束时系统计算为准。"}
</Alert.Description>
</Alert.Content>
</Alert>
<div className="grid gap-4 md:grid-cols-4">
<MetricIndicator
title="充电量"
color="border-success"
icon={<EvCharger className="size-5 text-success" />}
value={
<span className="inline-flex items-center gap-1.5">
{formatEnergy(energyWh)}
{isEstimatedEnergy && (
<span className="rounded bg-warning-soft px-1 py-0.5 text-[10px] font-semibold text-warning leading-none text-nowrap">
</span>
)}
</span>
}
/>
<MetricIndicator
title="总费用"
color="border-accent"
icon={<BanknoteArrowUp className="size-5 text-accent" />}
value={
<span className="inline-flex items-center gap-1.5">
{formatAmount(amountFen)}
{isEstimatedAmount && (
<span className="rounded bg-warning-soft px-1 py-0.5 text-[10px] font-semibold text-warning leading-none text-nowrap">
</span>
)}
</span>
}
/>
<MetricIndicator
title="订单状态"
icon={<Clock className="size-5 text-foreground" />}
color={
isRejected ? "border-danger" : tx.stopTimestamp ? "border-success" : "border-warning"
}
value={isRejected ? "已拒绝" : tx.stopTimestamp ? "已完成" : "进行中"}
/>
<MetricIndicator title="停止原因" value={isRejected ? rejectReason : stopReasonLabel} />
</div>
<div className="grid gap-4 lg:grid-cols-2">
<InfoSection title="交易信息">
<dl className="divide-y divide-border">
<div className="flex items-center justify-between gap-4 py-2">
<dt className="shrink-0 text-sm text-muted"></dt>
<dd className="font-mono text-sm text-foreground">#{tx.id}</dd>
</div>
<div className="flex items-center justify-between gap-4 py-2">
<dt className="shrink-0 text-sm text-muted"></dt>
<dd className="font-mono text-sm text-foreground">{tx.idTag}</dd>
</div>
<div className="flex items-center justify-between gap-4 py-2">
<dt className="shrink-0 text-sm text-muted"></dt>
<dd className="text-right text-sm text-foreground">
<div className="flex flex-col items-end">
<span>{tx.chargePointDeviceName ?? tx.chargePointIdentifier ?? "—"}</span>
{isAdmin && tx.chargePointDeviceName && tx.chargePointIdentifier && (
<span className="font-mono text-xs text-muted">{tx.chargePointIdentifier}</span>
)}
</div>
</dd>
</div>
<div className="flex items-center justify-between gap-4 py-2">
<dt className="shrink-0 text-sm text-muted"></dt>
<dd className="text-sm text-foreground">{tx.connectorNumber ?? "—"}</dd>
</div>
<div className="flex items-center justify-between gap-4 py-2">
<dt className="shrink-0 text-sm text-muted"></dt>
<dd className="text-right text-sm text-foreground">
{formatDateTime(tx.startTimestamp)}
</dd>
</div>
<div className="flex items-center justify-between gap-4 py-2">
<dt className="shrink-0 text-sm text-muted"></dt>
<dd className="text-right text-sm text-foreground">
{formatDateTime(tx.stopTimestamp)}
</dd>
</div>
<div className="flex items-center justify-between gap-4 py-2">
<dt className="shrink-0 text-sm text-muted"></dt>
<dd className="text-sm text-foreground">
{formatDuration(tx.startTimestamp, tx.stopTimestamp)}
</dd>
</div>
</dl>
</InfoSection>
<InfoSection title="计量与费用">
<dl className="divide-y divide-border">
<div className="flex items-center justify-between gap-4 py-2">
<dt className="shrink-0 text-sm text-muted"></dt>
<dd className="text-sm text-foreground">
{tx.startMeterValue != null ? `${tx.startMeterValue} Wh` : "—"}
</dd>
</div>
<div className="flex items-center justify-between gap-4 py-2">
<dt className="shrink-0 text-sm text-muted"></dt>
<dd className="text-sm text-foreground">
{tx.stopMeterValue != null ? `${tx.stopMeterValue} Wh` : "—"}
</dd>
</div>
<div className="flex items-center justify-between gap-4 py-2">
<dt className="shrink-0 text-sm text-muted"></dt>
<dd className="text-sm text-foreground">
<span className="inline-flex items-center gap-1">
{formatEnergy(energyWh)}
{isEstimatedEnergy && (
<span className="rounded bg-warning-soft px-1 py-0.5 text-[10px] font-semibold text-warning leading-none text-nowrap">
</span>
)}
</span>
</dd>
</div>
<div className="flex items-center justify-between gap-4 py-2">
<dt className="shrink-0 text-sm text-muted"></dt>
<dd className="text-sm text-foreground">{formatAmount(tx.electricityFee)}</dd>
</div>
<div className="flex items-center justify-between gap-4 py-2">
<dt className="shrink-0 text-sm text-muted"></dt>
<dd className="text-sm text-foreground">{formatAmount(tx.serviceFee)}</dd>
</div>
<div className="flex items-center justify-between gap-4 py-2">
<dt className="shrink-0 text-sm text-muted"></dt>
<dd className="text-sm text-foreground">
<span className="inline-flex items-center gap-1">
{formatAmount(amountFen)}
{isEstimatedAmount && (
<span className="rounded bg-warning-soft px-1 py-0.5 text-[10px] font-semibold text-warning leading-none text-nowrap">
</span>
)}
</span>
</dd>
</div>
</dl>
</InfoSection>
</div>
</div>
);
}

View File

@@ -1,8 +1,10 @@
"use client";
import { useState } from "react";
import { Suspense, useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Button, Chip, Modal, Pagination, Spinner, Table } from "@heroui/react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { TrashBin, ArrowRotateRight } from "@gravity-ui/icons";
import { api } from "@/lib/api";
import { useSession } from "@/lib/auth-client";
@@ -10,6 +12,39 @@ import dayjs from "@/lib/dayjs";
const LIMIT = 15;
const idTagRejectLabelMap: Record<string, string> = {
Blocked: "卡片不可用或余额不足",
Expired: "卡片已过期",
Invalid: "卡片无效",
ConcurrentTx: "该卡已有进行中的订单",
};
const stopReasonLabelMap: Record<string, string> = {
EmergencyStop: "紧急停止",
EVDisconnected: "车辆断开",
HardReset: "硬重启",
Local: "本地结束",
Other: "其他原因",
PowerLoss: "断电结束",
Reboot: "重启结束",
Remote: "远程结束",
SoftReset: "软重启",
UnlockCommand: "解锁结束",
};
const stopReasonColorMap: Record<string, "success" | "warning" | "danger" | "default"> = {
Local: "success",
EVDisconnected: "success",
Remote: "warning",
UnlockCommand: "warning",
EmergencyStop: "danger",
PowerLoss: "danger",
HardReset: "danger",
SoftReset: "warning",
Reboot: "warning",
Other: "default",
};
function formatDuration(start: string, stop: string | null): string {
if (!stop) return "进行中";
const min = dayjs(stop).diff(dayjs(start), "minute");
@@ -19,14 +54,28 @@ function formatDuration(start: string, stop: string | null): string {
return `${h}h ${m}m`;
}
export default function TransactionsPage() {
function TransactionsPageContent() {
const searchParams = useSearchParams();
const { data: sessionData } = useSession();
const isAdmin = sessionData?.user?.role === "admin";
const statusFromQuery = searchParams.get("status");
const initialStatus: "all" | "active" | "completed" =
statusFromQuery === "active" || statusFromQuery === "completed" ? statusFromQuery : "all";
const [page, setPage] = useState(1);
const [status, setStatus] = useState<"all" | "active" | "completed">("all");
const [status, setStatus] = useState<"all" | "active" | "completed">(initialStatus);
const [stoppingId, setStoppingId] = useState<number | null>(null);
const [deletingId, setDeletingId] = useState<number | null>(null);
useEffect(() => {
if (status !== initialStatus) {
setStatus(initialStatus);
setPage(1);
}
// We intentionally depend on searchParams-derived value only.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialStatus]);
const {
data,
isPending: loading,
@@ -131,12 +180,30 @@ export default function TransactionsPage() {
>
{(data?.data ?? []).map((tx) => (
<Table.Row key={tx.id} id={tx.id}>
<Table.Cell className="font-mono text-sm">{tx.id}</Table.Cell>
<Table.Cell className="font-mono">{tx.chargePointIdentifier ?? "—"}</Table.Cell>
<Table.Cell className="font-mono text-sm">
<Link
href={`/dashboard/transactions/${tx.id}`}
className="text-accent hover:text-accent/80"
>
{tx.id}
</Link>
</Table.Cell>
<Table.Cell>
<div className="flex flex-col">
<span>{tx.chargePointDeviceName ?? tx.chargePointIdentifier ?? "—"}</span>
{isAdmin && tx.chargePointDeviceName && tx.chargePointIdentifier && (
<span className="font-mono text-xs text-muted">{tx.chargePointIdentifier}</span>
)}
</div>
</Table.Cell>
<Table.Cell>{tx.connectorNumber ?? "—"}</Table.Cell>
<Table.Cell className="font-mono">{tx.idTag}</Table.Cell>
<Table.Cell>
{tx.stopTimestamp ? (
{tx.stopTimestamp && tx.stopReason === "DeAuthorized" ? (
<Chip color="danger" size="sm" variant="soft">
</Chip>
) : tx.stopTimestamp ? (
<Chip color="success" size="sm" variant="soft">
</Chip>
@@ -153,19 +220,51 @@ export default function TransactionsPage() {
{formatDuration(tx.startTimestamp, tx.stopTimestamp)}
</Table.Cell>
<Table.Cell className="tabular-nums">
{tx.energyWh != null ? (tx.energyWh / 1000).toFixed(3) : "—"}
{tx.energyWh != null ? (
(tx.energyWh / 1000).toFixed(3)
) : tx.liveEnergyWh != null ? (
<span className="inline-flex items-center gap-1">
{(tx.liveEnergyWh / 1000).toFixed(3)}
<span className="rounded bg-warning-soft px-1 py-0.5 text-[10px] font-semibold text-warning leading-none text-nowrap">
</span>
</span>
) : (
"—"
)}
</Table.Cell>
<Table.Cell className="tabular-nums">
{tx.chargeAmount != null ? `¥${(tx.chargeAmount / 100).toFixed(2)}` : "—"}
{tx.chargeAmount != null ? (
`¥${(tx.chargeAmount / 100).toFixed(2)}`
) : tx.estimatedCost != null ? (
<span className="inline-flex items-center gap-1">
¥{(tx.estimatedCost / 100).toFixed(2)}
<span className="rounded bg-warning-soft px-1 py-0.5 text-[10px] font-semibold text-warning leading-none text-nowrap">
</span>
</span>
) : (
"—"
)}
</Table.Cell>
<Table.Cell>
{tx.stopReason ? (
<Chip color="default" size="sm" variant="soft">
{tx.stopReason}
{tx.stopReason === "DeAuthorized" ? (
<p className="text-xs font-medium text-muted text-nowrap">
{tx.idTagStatus && tx.idTagStatus !== "Accepted"
? `${idTagRejectLabelMap[tx.idTagStatus] ?? tx.idTagStatus}`
: "鉴权失败"}
</p>
) : tx.stopReason ? (
<Chip
color={stopReasonColorMap[tx.stopReason] ?? "default"}
size="sm"
variant="soft"
>
{stopReasonLabelMap[tx.stopReason] ?? tx.stopReason}
</Chip>
) : tx.stopTimestamp ? (
<Chip color="default" size="sm" variant="soft">
Local
</Chip>
) : (
"—"
@@ -311,3 +410,17 @@ export default function TransactionsPage() {
</div>
);
}
export default function TransactionsPage() {
return (
<Suspense
fallback={
<div className="flex h-48 items-center justify-center">
<Spinner />
</div>
}
>
<TransactionsPageContent />
</Suspense>
);
}

View File

@@ -92,3 +92,9 @@
--warning: oklch(82.03% 0.1407 76.34);
--warning-foreground: oklch(21.03% 0.0059 76.34);
}
@layer components {
.chip__label {
@apply text-nowrap;
}
}

View File

@@ -0,0 +1,13 @@
import type { CardFaceProps } from "./types";
/** 卡面:光泽 + 装饰圆形 */
export function CirclesFace(_: CardFaceProps) {
return (
<>
<div className="absolute inset-0 bg-linear-to-tr from-white/20 via-transparent to-transparent" />
<div className="absolute -right-8 -top-8 size-44 rounded-full bg-white/10" />
<div className="absolute right-4 -bottom-12 size-36 rounded-full bg-white/[0.07]" />
<div className="absolute -left-6 -bottom-4 size-24 rounded-full bg-black/10" />
</>
);
}

View File

@@ -0,0 +1,48 @@
import type { CardFaceProps } from "./types";
/** 卡面:渐变光晕 + 噪点纹理 */
export function GlowFace(_: CardFaceProps) {
return (
<>
{/* 噪点纹理 SVG filter */}
<svg className="absolute size-0">
<filter id="card-noise">
<feTurbulence
type="fractalNoise"
baseFrequency="0.65"
numOctaves="3"
stitchTiles="stitch"
/>
<feColorMatrix type="saturate" values="0" />
<feBlend in="SourceGraphic" mode="overlay" result="blend" />
<feComposite in="blend" in2="SourceGraphic" operator="in" />
</filter>
</svg>
{/* 噪点层 */}
<div
className="absolute inset-0 opacity-[0.18] mix-blend-overlay"
style={{ filter: "url(#card-noise)" }}
/>
{/* 左下光晕 */}
<div
className="absolute -bottom-8 -left-8 size-48 rounded-full opacity-60 blur-3xl"
style={{ background: "radial-gradient(circle, white 0%, transparent 70%)" }}
/>
{/* 右上光晕 */}
<div
className="absolute -right-6 -top-6 size-40 rounded-full opacity-40 blur-2xl"
style={{ background: "radial-gradient(circle, white 0%, transparent 70%)" }}
/>
{/* 中心微光 */}
<div
className="absolute inset-x-0 top-1/2 mx-auto size-32 -translate-y-1/2 rounded-full opacity-20 blur-3xl"
style={{ background: "radial-gradient(circle, white 0%, transparent 70%)" }}
/>
{/* 顶部高光条 */}
<div className="absolute inset-x-0 top-0 h-px bg-linear-to-r from-transparent via-white/50 to-transparent" />
</>
);
}

View File

@@ -0,0 +1,17 @@
import { LineFace } from "./line";
import { CirclesFace } from "./circles";
import { GlowFace } from "./glow";
import { VipFace } from "./vip";
import { RedeyeFace } from "./redeye";
export type { CardFaceProps, CardFaceComponent } from "./types";
import type { CardFaceComponent } from "./types";
export const CARD_FACE_REGISTRY = {
line: LineFace,
circles: CirclesFace,
glow: GlowFace,
vip: VipFace,
redeye: RedeyeFace,
} satisfies Record<string, CardFaceComponent>;
export type CardFaceName = keyof typeof CARD_FACE_REGISTRY;

View File

@@ -0,0 +1,17 @@
import type { CardFaceProps } from "./types";
/** 卡面:斜线纹理 + 右上角光晕 */
export function LineFace(_: CardFaceProps) {
return (
<>
<div
className="absolute inset-0 opacity-[0.07]"
style={{
backgroundImage:
"repeating-linear-gradient(135deg, #fff 0px, #fff 1px, transparent 1px, transparent 12px)",
}}
/>
<div className="absolute -right-10 -top-10 size-48 rounded-full bg-white/15 blur-2xl" />
</>
);
}

View File

@@ -0,0 +1,241 @@
import type { CardFaceProps } from "./types";
/** 卡面:深黑底色 + 深红光晕与锐利几何线条 */
export function RedeyeFace(_: CardFaceProps) {
return (
<>
{/* 深黑底色 */}
<div
className="absolute inset-0"
style={{
background: "linear-gradient(145deg, #0a0505 0%, #120808 45%, #0d0404 100%)",
}}
/>
{/* 红色主光晕:左上 */}
<div
className="absolute -left-8 -top-8 size-56 rounded-full opacity-25 blur-3xl"
style={{
background: "radial-gradient(circle, #cc1020 0%, #7a0810 50%, transparent 75%)",
}}
/>
{/* 红色副光晕:右下 */}
<div
className="absolute -bottom-10 -right-6 size-44 rounded-full opacity-15 blur-3xl"
style={{
background: "radial-gradient(circle, #e01828 0%, transparent 70%)",
}}
/>
{/* 顶部红色高光边 */}
<div
className="absolute inset-x-0 top-0 h-[1.5px]"
style={{
background:
"linear-gradient(to right, transparent 5%, #cc1020 25%, #ff2233 50%, #cc1020 75%, transparent 95%)",
}}
/>
{/* 左侧竖向红线 */}
<div
className="absolute bottom-0 left-5 top-0 w-px opacity-40"
style={{
background:
"linear-gradient(to bottom, transparent, #cc1020 20%, #ff2233 50%, #cc1020 80%, transparent)",
}}
/>
{/* 右侧双竖线 */}
<div
className="absolute bottom-3 right-4 top-3 w-px opacity-30"
style={{
background:
"linear-gradient(to bottom, transparent, #993010 30%, #cc2010 60%, transparent)",
}}
/>
<div
className="absolute bottom-3 right-[18px] top-3 w-px opacity-15"
style={{
background:
"linear-gradient(to bottom, transparent, #993010 30%, #cc2010 60%, transparent)",
}}
/>
{/* 红色横向扫描线组 */}
<div
className="absolute inset-x-0 opacity-[0.06]"
style={{
top: "30%",
height: "1px",
background:
"linear-gradient(to right, transparent 0%, #ff2233 40%, #ff5566 60%, transparent 100%)",
}}
/>
<div
className="absolute inset-x-0 opacity-[0.04]"
style={{
top: "32%",
height: "1px",
background: "linear-gradient(to right, transparent 10%, #ff2233 50%, transparent 90%)",
}}
/>
{/* 电路板风格折角线:左上 */}
<svg
className="absolute left-6 top-3 opacity-20"
width="40"
height="28"
viewBox="0 0 40 28"
fill="none"
>
<polyline points="0,28 0,8 12,0 40,0" stroke="#ff2233" strokeWidth="1" fill="none" />
<circle cx="12" cy="0" r="1.5" fill="#ff2233" />
</svg>
{/* 电路板风格折角线:右下 */}
<svg
className="absolute bottom-3 right-6 opacity-20"
width="40"
height="28"
viewBox="0 0 40 28"
fill="none"
>
<polyline points="40,0 40,20 28,28 0,28" stroke="#cc1020" strokeWidth="1" fill="none" />
<circle cx="28" cy="28" r="1.5" fill="#cc1020" />
</svg>
{/* 风格菱形 */}
<svg
className="absolute opacity-[0.12]"
style={{ right: "28px", top: "50%", transform: "translateY(-50%)" }}
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
>
<polygon points="16,1 31,16 16,31 1,16" stroke="#ff2233" strokeWidth="1.5" fill="none" />
<polygon points="16,7 25,16 16,25 7,16" stroke="#ff2233" strokeWidth="1" fill="none" />
<circle cx="16" cy="16" r="2" fill="#ff2233" />
</svg>
{/* 徽标 — 八角框 + 内部对角交叉 + 中心菱形 */}
<svg
className="absolute opacity-60"
style={{
left: "50%",
top: "50%",
transform: "translate(-50%, -50%)",
filter: "drop-shadow(0 0 6px #cc102088)",
}}
width="56"
height="56"
viewBox="0 0 56 56"
fill="none"
>
{/* 外八角轮廓 */}
<polygon
points="16,2 40,2 54,16 54,40 40,54 16,54 2,40 2,16"
stroke="#cc1020"
strokeWidth="1.5"
fill="#0d0404"
fillOpacity="0.85"
/>
{/* 内八角 */}
<polygon
points="20,8 36,8 48,20 48,36 36,48 20,48 8,36 8,20"
stroke="#cc1020"
strokeWidth="0.75"
strokeOpacity="0.5"
fill="none"
/>
{/* 对角线:左上 → 右下 */}
<line
x1="16"
y1="16"
x2="40"
y2="40"
stroke="#cc1020"
strokeWidth="1"
strokeOpacity="0.7"
/>
{/* 对角线:右上 → 左下 */}
<line
x1="40"
y1="16"
x2="16"
y2="40"
stroke="#cc1020"
strokeWidth="1"
strokeOpacity="0.7"
/>
{/* 横向中轴 */}
<line
x1="6"
y1="28"
x2="50"
y2="28"
stroke="#cc1020"
strokeWidth="0.75"
strokeOpacity="0.4"
/>
{/* 纵向中轴 */}
<line
x1="28"
y1="6"
x2="28"
y2="50"
stroke="#cc1020"
strokeWidth="0.75"
strokeOpacity="0.4"
/>
{/* 中心菱形 */}
<polygon
points="28,18 38,28 28,38 18,28"
stroke="#ff2233"
strokeWidth="1.25"
fill="#1a0505"
fillOpacity="0.9"
/>
{/* 中心圆点 */}
<circle cx="28" cy="28" r="3" fill="#ff2233" />
<circle cx="28" cy="28" r="1.5" fill="#ff6677" />
{/* 四个顶点刻度点 */}
<circle cx="28" cy="6" r="1.25" fill="#cc1020" fillOpacity="0.8" />
<circle cx="50" cy="28" r="1.25" fill="#cc1020" fillOpacity="0.8" />
<circle cx="28" cy="50" r="1.25" fill="#cc1020" fillOpacity="0.8" />
<circle cx="6" cy="28" r="1.25" fill="#cc1020" fillOpacity="0.8" />
</svg>
{/* 噪点 SVG filter */}
<svg className="absolute size-0">
<filter id="arasaka-noise">
<feTurbulence
type="fractalNoise"
baseFrequency="0.8"
numOctaves="4"
stitchTiles="stitch"
/>
<feColorMatrix type="saturate" values="0" />
<feBlend in="SourceGraphic" mode="overlay" result="blend" />
<feComposite in="blend" in2="SourceGraphic" operator="in" />
</filter>
</svg>
{/* 噪点层 */}
<div
className="absolute inset-0 opacity-[0.1] mix-blend-overlay"
style={{ filter: "url(#arasaka-noise)" }}
/>
{/* 底部红色压边 */}
<div
className="absolute inset-x-0 bottom-0 h-px opacity-30"
style={{
background:
"linear-gradient(to right, transparent 10%, #7a0810 40%, #cc1020 60%, transparent 90%)",
}}
/>
</>
);
}

View File

@@ -0,0 +1,10 @@
import type { ComponentType } from "react";
/**
* 卡面(卡底装饰)组件的 props。
* 卡面仅负责视觉装饰(颜色纹理、光效、几何图形等),不接收任何业务数据。
*/
export type CardFaceProps = Record<string, never>;
/** 卡面组件类型 */
export type CardFaceComponent = ComponentType<CardFaceProps>;

View File

@@ -0,0 +1,129 @@
import type { CardFaceProps } from "./types";
/** 卡面:黑金 VIP — 深黑底色 + 金色光晕与描边,体现尊贵身份 */
export function VipFace(_: CardFaceProps) {
return (
<>
{/* 完全覆盖底部 palette建立黑金底色 */}
<div
className="absolute inset-0"
style={{
background: "linear-gradient(135deg, #0f0f0f 0%, #1a1610 40%, #0d0d0d 100%)",
}}
/>
{/* 金色主光晕:右上角 */}
<div
className="absolute -right-10 -top-10 size-52 rounded-full opacity-30 blur-3xl"
style={{
background: "radial-gradient(circle, #d4a843 0%, #9a6f1a 50%, transparent 75%)",
}}
/>
{/* 金色副光晕:左下角 */}
<div
className="absolute -bottom-12 -left-6 size-44 rounded-full opacity-20 blur-3xl"
style={{
background: "radial-gradient(circle, #c49b2e 0%, transparent 70%)",
}}
/>
{/* 中部横向光带 */}
<div
className="absolute inset-x-0 top-1/2 h-px -translate-y-1/2 opacity-20"
style={{
background:
"linear-gradient(to right, transparent 0%, #d4a843 30%, #f0d060 50%, #d4a843 70%, transparent 100%)",
}}
/>
{/* 顶部金色高光边 */}
<div
className="absolute inset-x-0 top-0 h-px"
style={{
background:
"linear-gradient(to right, transparent 5%, #c8992a 30%, #f5d060 50%, #c8992a 70%, transparent 95%)",
}}
/>
{/* 底部深金色压边 */}
<div
className="absolute inset-x-0 bottom-0 h-px opacity-50"
style={{
background:
"linear-gradient(to right, transparent 10%, #9a6f1a 40%, #b8881f 60%, transparent 90%)",
}}
/>
{/* 右侧竖向装饰线 */}
<div
className="absolute bottom-4 right-5 top-4 w-px opacity-15"
style={{
background:
"linear-gradient(to bottom, transparent, #d4a843 30%, #f0d060 60%, transparent)",
}}
/>
{/* 细噪点 SVG filter */}
<svg className="absolute size-0">
<filter id="vip-noise">
<feTurbulence
type="fractalNoise"
baseFrequency="0.75"
numOctaves="4"
stitchTiles="stitch"
/>
<feColorMatrix type="saturate" values="0" />
<feBlend in="SourceGraphic" mode="overlay" result="blend" />
<feComposite in="blend" in2="SourceGraphic" operator="in" />
</filter>
</svg>
{/* 噪点层:增加高端质感 */}
<div
className="absolute inset-0 opacity-[0.12] mix-blend-overlay"
style={{ filter: "url(#vip-noise)" }}
/>
{/* 整体金色微光叠层 */}
<div
className="absolute inset-0 opacity-[0.04]"
style={{
background: "linear-gradient(120deg, transparent 20%, #d4a843 50%, transparent 80%)",
}}
/>
{/* 菱形网格纹 */}
<div
className="absolute inset-0 opacity-[0.06]"
style={{
backgroundImage:
"repeating-linear-gradient(45deg, #d4a843 0px, #d4a843 1px, transparent 1px, transparent 14px), repeating-linear-gradient(-45deg, #d4a843 0px, #d4a843 1px, transparent 1px, transparent 14px)",
}}
/>
{/* VIP 大水印文字 */}
<div
className="pointer-events-none absolute -bottom-4 -left-2 select-none font-black leading-none tracking-widest opacity-[0.07]"
style={{
fontSize: "84px",
color: "#d4a843",
fontFamily: "serif",
letterSpacing: "0.15em",
}}
>
VIP
</div>
{/* 斜向扫光带 */}
<div
className="absolute -inset-y-4 w-12 -rotate-12 opacity-[0.08] blur-sm"
style={{
left: "38%",
background:
"linear-gradient(to bottom, transparent, #f5d060 30%, #fff8dc 50%, #f5d060 70%, transparent)",
}}
/>
</>
);
}

View File

@@ -0,0 +1,144 @@
import { ThunderboltFill } from "@gravity-ui/icons";
import { Nfc } from "lucide-react";
import { CARD_FACE_REGISTRY, type CardFaceName } from "@/components/faces";
// ---------------------------------------------------------------------------
// Palette
// ---------------------------------------------------------------------------
const CARD_PALETTES = [
"bg-linear-to-br from-blue-600 to-indigo-700",
"bg-linear-to-br from-violet-600 to-purple-700",
"bg-linear-to-br from-emerald-600 to-teal-700",
"bg-linear-to-br from-rose-500 to-pink-700",
"bg-linear-to-br from-amber-500 to-orange-600",
"bg-linear-to-br from-slate-600 to-zinc-700",
];
function paletteForId(idTag: string): string {
const hash = idTag.split("").reduce((acc, c) => acc + c.charCodeAt(0), 0);
return CARD_PALETTES[hash % CARD_PALETTES.length];
}
// ---------------------------------------------------------------------------
// Layout — 内容元素余额、logo、卡号的排列方式
// ---------------------------------------------------------------------------
export type CardLayoutName = "center" | "around";
// ---------------------------------------------------------------------------
// IdTagCard
// ---------------------------------------------------------------------------
type IdTagCardProps = {
idTag: string;
balance: number;
isSelected?: boolean;
isDisabled?: boolean;
/** 内容排列方式余额、logo、卡号等信息元素的布局 */
layout?: CardLayoutName;
/** 卡底装饰风格:纹理、光效、几何图形等视觉元素 */
skin?: CardFaceName;
onClick?: () => void;
};
export function IdTagCard({
idTag,
balance,
isSelected = false,
isDisabled = false,
layout = "around",
skin = "circles",
onClick,
}: IdTagCardProps) {
const palette = paletteForId(idTag);
const Skin = CARD_FACE_REGISTRY[skin];
return (
<button
type="button"
disabled={isDisabled}
onClick={onClick}
className={[
"relative w-full overflow-hidden rounded-2xl select-none",
"aspect-[1.586/1] transition-all duration-200 active:scale-[0.97]",
isDisabled ? "cursor-not-allowed opacity-45 grayscale-[0.25]" : "cursor-pointer",
isSelected
? "ring-3 ring-offset-2 ring-offset-background ring-accent shadow-2xl shadow-accent/25"
: "ring-1 ring-black/8 hover:ring-accent/40 shadow-md hover:shadow-xl",
].join(" ")}
>
{/* 渐变底色 */}
<div className={`absolute inset-0 ${palette}`} />
{/* 卡底装饰 */}
<Skin />
{/* 内容布局 */}
{layout === "center" ? (
<div className="relative flex h-full flex-col justify-between px-5 py-4">
<div className="flex items-center justify-between">
<span className="text-[9px] font-bold uppercase tracking-[0.2em] text-white/50">
</span>
<div className="flex items-center gap-1">
<ThunderboltFill className="size-3.5 text-white/60" />
<span className="text-[9px] font-bold uppercase tracking-[0.15em] text-white/60">
Helios
</span>
</div>
</div>
<div className="mt-2">
<p className="text-[24px] font-medium leading-none tracking-[0.22em] text-white drop-shadow-md">
{idTag.replace(/(.{4})/, "$1 ")}
</p>
</div>
<div className="flex items-end justify-between">
<Nfc className="size-6 rotate-180 text-white/30 stroke-[1.5]" />
<div className="text-right">
<p className="text-[8px] font-semibold uppercase tracking-[0.15em] text-white/40">
</p>
<p className="text-lg font-bold leading-tight text-white drop-shadow">
¥{(balance / 100).toFixed(2)}
</p>
</div>
</div>
</div>
) : (
<div className="relative flex h-full flex-col justify-between px-5 py-4">
<div className="flex items-start justify-between">
<Nfc className="size-7 rotate-180 text-white/35 stroke-[1.5]" />
<div className="flex items-center gap-1">
<ThunderboltFill className="size-3.5 text-white/60" />
<span className="text-[9px] font-bold uppercase tracking-[0.15em] text-white/60">
Helios
</span>
</div>
</div>
<div className="flex items-end justify-between">
<div className="flex flex-col items-start gap-0.5">
<p className="text-[10px] font-semibold uppercase tracking-[0.15em] text-white/40">
</p>
<p className="text-[22px] font-bold text-white drop-shadow">
¥{(balance / 100).toFixed(2)}
</p>
</div>
<div className="flex flex-col items-end gap-0.5">
<span className="text-[10px] font-semibold uppercase tracking-[0.12em] text-white/35">
</span>
<p className="text-lg font-medium tracking-widest text-white/90 drop-shadow">
{idTag.replace(/(.{4})/, "$1 ")}
</p>
</div>
</div>
</div>
)}
</button>
);
}

View File

@@ -0,0 +1,15 @@
import type { ReactNode } from "react";
type InfoSectionProps = {
title: string;
children: ReactNode;
};
export default function InfoSection({ title, children }: InfoSectionProps) {
return (
<div className="rounded-xl border border-border bg-surface p-4">
<h2 className="mb-3 text-sm font-semibold text-foreground">{title}</h2>
{children}
</div>
);
}

View File

@@ -0,0 +1,41 @@
import type { ReactNode } from "react";
import { Card } from "@heroui/react";
type MetricIndicatorProps = {
title: string;
value: ReactNode;
hint?: ReactNode;
footer?: ReactNode;
icon?: ReactNode;
color?: string;
valueClassName?: string;
};
export default function MetricIndicator({
title,
value,
hint,
footer,
icon,
color = "border-border",
valueClassName = "text-xl font-semibold text-foreground tabular-nums",
}: MetricIndicatorProps) {
return (
<Card className={`border-t-2 ${color}`}>
<Card.Content className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<p className="text-xs text-muted">{title}</p>
{icon}
</div>
<p className={valueClassName}>{value}</p>
{footer ? (
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-muted">
{footer}
</div>
) : (
hint && <div className="text-xs text-muted">{hint}</div>
)}
</Card.Content>
</Card>
);
}

View File

@@ -9,6 +9,7 @@ import {
ListCheck,
Person,
PlugConnection,
TagDollar,
Thunderbolt,
ThunderboltFill,
Xmark,
@@ -16,20 +17,27 @@ import {
} from "@gravity-ui/icons";
import SidebarFooter from "@/components/sidebar-footer";
import { useSession } from "@/lib/auth-client";
import { EvCharger, Gauge, Network, ReceiptText, UserCog, Users } from "lucide-react";
const chargeItems = [
{ href: "/dashboard/charge", label: "立即充电", icon: ThunderboltFill, adminOnly: false },
{ href: "/dashboard/charge", label: "立即充电", icon: Thunderbolt, adminOnly: false },
{ href: "/dashboard/pricing", label: "电价标准", icon: TagDollar, adminOnly: false },
];
const navItems = [
{ href: "/dashboard", label: "概览", icon: Thunderbolt, exact: true, adminOnly: false },
{ href: "/dashboard/charge-points", label: "充电桩", icon: PlugConnection, adminOnly: false },
{ href: "/dashboard/transactions", label: "充电记录", icon: ListCheck, adminOnly: false },
{ href: "/dashboard", label: "概览", icon: Gauge, exact: true, adminOnly: false },
{ href: "/dashboard/charge-points", label: "充电桩", icon: EvCharger, adminOnly: false },
{ href: "/dashboard/topology", label: "拓扑图", icon: Network, adminOnly: false },
{ href: "/dashboard/id-tags", label: "储值卡", icon: CreditCard, adminOnly: false },
{ href: "/dashboard/users", label: "用户管理", icon: Person, adminOnly: true },
{ href: "/dashboard/transactions", label: "充电记录", icon: ReceiptText, adminOnly: false },
{ href: "/dashboard/settings/pricing", label: "峰谷电价", icon: TagDollar, adminOnly: true },
{ href: "/dashboard/users", label: "用户管理", icon: Users, adminOnly: true },
];
const settingsItems = [{ href: "/dashboard/settings", label: "账号设置", icon: Gear }];
const settingsItems = [
{ href: "/dashboard/settings/user", label: "账号设置", icon: UserCog, adminOnly: false },
{ href: "/dashboard/settings/parameters", label: "参数配置", icon: Gear, adminOnly: true },
];
function NavContent({
pathname,
@@ -110,27 +118,29 @@ function NavContent({
<p className="mb-1 mt-3 px-2 text-[11px] font-semibold uppercase tracking-widest text-muted">
</p>
{settingsItems.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(item.href + "/");
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
onClick={onNavigate}
className={[
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-accent/10 text-accent"
: "text-muted hover:bg-surface-tertiary hover:text-foreground",
].join(" ")}
>
<Icon className="size-4 shrink-0" />
<span>{item.label}</span>
{isActive && <span className="ml-auto h-1.5 w-1.5 rounded-full bg-accent" />}
</Link>
);
})}
{settingsItems
.filter((item) => !item.adminOnly || isAdmin)
.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(item.href + "/");
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
onClick={onNavigate}
className={[
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-accent/10 text-accent"
: "text-muted hover:bg-surface-tertiary hover:text-foreground",
].join(" ")}
>
<Icon className="size-4 shrink-0" />
<span>{item.label}</span>
{isActive && <span className="ml-auto h-1.5 w-1.5 rounded-full bg-accent" />}
</Link>
);
})}
</nav>
{/* Footer */}

View File

@@ -68,15 +68,28 @@ export type ConnectorDetail = {
updatedAt: string;
};
export type ConnectionsStatus = {
connectedIdentifiers: string[];
};
export type ChargePointConnectionStatus = "online" | "unavailable" | "offline";
export type ChargePoint = {
id: string;
chargePointIdentifier: string;
deviceName: string | null;
chargePointVendor: string | null;
chargePointModel: string | null;
registrationStatus: string;
transportStatus: ChargePointConnectionStatus;
lastHeartbeatAt: string | null;
lastWsConnectedAt: string | null;
lastWsDisconnectedAt: string | null;
lastCommandStatus: "Accepted" | "Rejected" | "Error" | "Timeout" | null;
lastCommandAt: string | null;
lastBootNotificationAt: string | null;
feePerKwh: number;
pricingMode: "fixed" | "tou";
connectors: ConnectorSummary[];
chargePointStatus: string | null;
chargePointErrorCode: string | null;
@@ -85,6 +98,7 @@ export type ChargePoint = {
export type ChargePointDetail = {
id: string;
chargePointIdentifier: string;
deviceName: string | null;
chargePointVendor: string | null;
chargePointModel: string | null;
chargePointSerialNumber: string | null;
@@ -95,9 +109,15 @@ export type ChargePointDetail = {
meterType: string | null;
registrationStatus: string;
heartbeatInterval: number | null;
transportStatus: ChargePointConnectionStatus;
lastHeartbeatAt: string | null;
lastWsConnectedAt: string | null;
lastWsDisconnectedAt: string | null;
lastCommandStatus: "Accepted" | "Rejected" | "Error" | "Timeout" | null;
lastCommandAt: string | null;
lastBootNotificationAt: string | null;
feePerKwh: number;
pricingMode: "fixed" | "tou";
createdAt: string;
updatedAt: string;
connectors: ConnectorDetail[];
@@ -108,6 +128,7 @@ export type ChargePointDetail = {
export type Transaction = {
id: number;
chargePointIdentifier: string | null;
chargePointDeviceName: string | null;
connectorNumber: number | null;
idTag: string;
idTagStatus: string | null;
@@ -118,9 +139,16 @@ export type Transaction = {
startMeterValue: number | null;
stopMeterValue: number | null;
energyWh: number | null;
liveEnergyWh: number | null;
estimatedCost: number | null;
stopIdTag: string | null;
stopReason: string | null;
chargeAmount: number | null;
electricityFee: number | null;
serviceFee: number | null;
remoteStopStatus: "Requested" | "Accepted" | "Rejected" | "Error" | "Timeout" | null;
remoteStopRequestedAt: string | null;
remoteStopRequestId: string | null;
};
export type IdTag = {
@@ -130,6 +158,8 @@ export type IdTag = {
parentIdTag: string | null;
userId: string | null;
balance: number;
cardLayout: "center" | "around" | null;
cardSkin: "line" | "circles" | "glow" | "vip" | "redeye" | null;
createdAt: string;
};
@@ -145,6 +175,18 @@ export type UserRow = {
createdAt: string;
};
export type ChargePointCreated = ChargePoint & {
/** 仅在创建时返回一次的明文密码,之后不可再查 */
plainPassword: string;
};
export type ChargePointPasswordReset = {
id: string;
chargePointIdentifier: string;
/** 仅在重置时返回一次的新明文密码 */
plainPassword: string;
};
export type PaginatedTransactions = {
data: Transaction[];
total: number;
@@ -152,6 +194,37 @@ export type PaginatedTransactions = {
totalPages: number;
};
export type PriceTier = "peak" | "valley" | "flat";
export type TariffSlot = {
start: number;
end: number;
tier: PriceTier;
};
export type TierPricing = {
/** 电价(元/kWh */
electricityPrice: number;
/** 服务费(元/kWh */
serviceFee: number;
};
export type TariffConfig = {
id?: string;
slots: TariffSlot[];
prices: Record<PriceTier, TierPricing>;
createdAt?: string;
updatedAt?: string;
};
export type Ocpp16jSettings = {
heartbeatInterval: number;
};
export type SystemSettings = {
ocpp16j: Ocpp16jSettings;
};
// ── API functions ──────────────────────────────────────────────────────────
export type ChartRange = "30d" | "7d" | "24h";
@@ -173,14 +246,17 @@ export const api = {
chargePoints: {
list: () => apiFetch<ChargePoint[]>("/api/charge-points"),
get: (id: string) => apiFetch<ChargePointDetail>(`/api/charge-points/${id}`),
connections: () => apiFetch<ConnectionsStatus>("/api/charge-points/connections"),
create: (data: {
chargePointIdentifier: string;
chargePointVendor?: string;
chargePointModel?: string;
registrationStatus?: "Accepted" | "Pending" | "Rejected";
feePerKwh?: number;
pricingMode?: "fixed" | "tou";
deviceName?: string;
}) =>
apiFetch<ChargePoint>("/api/charge-points", {
apiFetch<ChargePointCreated>("/api/charge-points", {
method: "POST",
body: JSON.stringify(data),
}),
@@ -188,9 +264,11 @@ export const api = {
id: string,
data: {
feePerKwh?: number;
pricingMode?: "fixed" | "tou";
registrationStatus?: "Accepted" | "Pending" | "Rejected";
chargePointVendor?: string;
chargePointModel?: string;
deviceName?: string | null;
},
) =>
apiFetch<ChargePoint>(`/api/charge-points/${id}`, {
@@ -199,6 +277,10 @@ export const api = {
}),
delete: (id: string) =>
apiFetch<{ success: true }>(`/api/charge-points/${id}`, { method: "DELETE" }),
resetPassword: (id: string) =>
apiFetch<ChargePointPasswordReset>(`/api/charge-points/${id}/reset-password`, {
method: "POST",
}),
},
transactions: {
list: (params?: {
@@ -239,6 +321,8 @@ export const api = {
parentIdTag?: string;
userId?: string | null;
balance?: number;
cardLayout?: "center" | "around";
cardSkin?: "line" | "circles" | "glow" | "vip" | "redeye";
}) => apiFetch<IdTag>("/api/id-tags", { method: "POST", body: JSON.stringify(data) }),
update: (
idTag: string,
@@ -248,6 +332,8 @@ export const api = {
parentIdTag?: string | null;
userId?: string | null;
balance?: number;
cardLayout?: "center" | "around" | null;
cardSkin?: "line" | "circles" | "glow" | "vip" | "redeye" | null;
},
) => apiFetch<IdTag>(`/api/id-tags/${idTag}`, { method: "PATCH", body: JSON.stringify(data) }),
delete: (idTag: string) =>
@@ -281,4 +367,14 @@ export const api = {
create: (data: { name: string; email: string; username: string; password: string }) =>
apiFetch<{ success: boolean }>("/api/setup", { method: "POST", body: JSON.stringify(data) }),
},
tariff: {
get: () => apiFetch<TariffConfig | null>("/api/tariff"),
put: (data: { slots: TariffSlot[]; prices: Record<PriceTier, TierPricing> }) =>
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) }),
},
};

View File

@@ -15,9 +15,11 @@
"@tanstack/react-query": "catalog:",
"@tremor/react": "4.0.0-beta-tremor-v4.4",
"@types/qrcode": "^1.5.6",
"@xyflow/react": "^12.10.1",
"better-auth": "catalog:",
"dayjs": "catalog:",
"jsqr": "^1.4.0",
"lucide-react": "^0.577.0",
"next": "16.1.6",
"qrcode.react": "^4.2.0",
"react": "19.2.3",

View File

@@ -3,32 +3,20 @@ import { NextRequest, NextResponse } from "next/server";
const CSMS_INTERNAL_URL =
process.env.CSMS_INTERNAL_URL ?? process.env.NEXT_PUBLIC_CSMS_URL ?? "http://localhost:3001";
/** 检查 CSMS 是否已完成初始化(有用户存在)。使用 cookie 缓存结果,避免每次请求都查询。 */
async function isInitialized(
request: NextRequest,
useCache = true,
): Promise<{ initialized: boolean; fromCache: boolean }> {
// 读缓存 cookie仅在 useCache=true 时使用,避免 DB 重置后缓存陈旧)
if (useCache) {
const cached = request.cookies.get("helios_setup_done");
if (cached?.value === "1") {
return { initialized: true, fromCache: true };
}
}
/** 检查 CSMS 是否已完成初始化(有用户存在)。 */
async function isInitialized(request: NextRequest): Promise<boolean> {
try {
const res = await fetch(`${CSMS_INTERNAL_URL}/api/setup`, {
method: "GET",
headers: { "Content-Type": "application/json" },
// 短超时,避免阻塞
signal: AbortSignal.timeout(3000),
});
if (!res.ok) return { initialized: true, fromCache: false }; // 出错时放行,不阻止访问
if (!res.ok) return true; // 出错时放行,不阻止访问
const data = (await res.json()) as { initialized: boolean };
return { initialized: data.initialized, fromCache: false };
return data.initialized;
} catch {
// 无法连接 CSMS 时放行,不强制跳转
return { initialized: true, fromCache: false };
return true;
}
}
@@ -37,27 +25,16 @@ export async function proxy(request: NextRequest) {
// /setup 页面:已初始化则跳转登录
if (pathname === "/setup") {
const { initialized, fromCache } = await isInitialized(request);
if (initialized) {
if (await isInitialized(request)) {
return NextResponse.redirect(new URL("/login", request.url));
}
const res = NextResponse.next();
if (!fromCache) {
// 未初始化,确保缓存 cookie 不存在(若之前意外设置了)
res.cookies.delete("helios_setup_done");
}
return res;
return NextResponse.next();
}
// /dashboard 路由:检查 session未登录跳转 /login
if (pathname.startsWith("/dashboard")) {
const { initialized, fromCache } = await isInitialized(request);
// 未初始化,先去 setup
if (!initialized) {
const res = NextResponse.redirect(new URL("/setup", request.url));
if (!fromCache) res.cookies.delete("helios_setup_done");
return res;
if (!(await isInitialized(request))) {
return NextResponse.redirect(new URL("/setup", request.url));
}
const sessionCookie =
@@ -68,30 +45,18 @@ export async function proxy(request: NextRequest) {
const loginUrl = new URL("/login", request.url);
const fromPath = request.nextUrl.search ? pathname + request.nextUrl.search : pathname;
loginUrl.searchParams.set("from", fromPath);
const res = NextResponse.redirect(loginUrl);
if (!fromCache)
res.cookies.set("helios_setup_done", "1", { path: "/", httpOnly: true, sameSite: "lax" });
return res;
return NextResponse.redirect(loginUrl);
}
const res = NextResponse.next();
if (!fromCache)
res.cookies.set("helios_setup_done", "1", { path: "/", httpOnly: true, sameSite: "lax" });
return res;
return NextResponse.next();
}
// /login 路由:未初始化则跳转 /setup(不使用缓存,防止 DB 重置后缓存陈旧)
// /login 路由:未初始化则跳转 /setup
if (pathname === "/login") {
const { initialized, fromCache } = await isInitialized(request, false);
if (!initialized) {
const res = NextResponse.redirect(new URL("/setup", request.url));
if (!fromCache) res.cookies.delete("helios_setup_done");
return res;
if (!(await isInitialized(request))) {
return NextResponse.redirect(new URL("/setup", request.url));
}
const res = NextResponse.next();
if (!fromCache)
res.cookies.set("helios_setup_done", "1", { path: "/", httpOnly: true, sameSite: "lax" });
return res;
return NextResponse.next();
}
return NextResponse.next();

View File

@@ -0,0 +1,37 @@
{
"name": "MicroOcppMongoose",
"version": "1.2.0",
"description": "Mongoose Adapter for the MicroOCPP Client",
"keywords": "OCPP, 1.6, OCPP 1.6, OCPP 2.0.1, Smart Energy, Smart Charging, client, ESP8266, ESP32, Arduino, EVSE, Charge Point, Mongoose",
"repository":
{
"type": "git",
"url": "https://github.com/matth-x/MicroOcppMongoose/"
},
"authors":
[
{
"name": "Matthias Akstaller",
"url": "https://www.micro-ocpp.com",
"maintainer": true
}
],
"license": "GPL-3.0",
"homepage": "https://www.micro-ocpp.com",
"frameworks": "arduino,espidf",
"platforms": "espressif8266, espressif32",
"export": {
"include":
[
"src/MicroOcppMongooseClient_c.cpp",
"src/MicroOcppMongooseClient_c.h",
"src/MicroOcppMongooseClient.cpp",
"src/MicroOcppMongooseClient.h",
"CHANGELOG.md",
"CMakeLists.txt",
"library.json",
"LICENSE",
"README.md"
]
}
}

View File

@@ -0,0 +1,754 @@
// matth-x/MicroOcppMongoose
// Copyright Matthias Akstaller 2019 - 2024
// GPL-3.0 License (see LICENSE)
#include "MicroOcppMongooseClient.h"
#include <MicroOcpp/Core/Configuration.h>
#include <MicroOcpp/Debug.h>
#if MO_ENABLE_V201
#include <MicroOcpp/Model/Variables/VariableContainer.h>
#endif
#define DEBUG_MSG_INTERVAL 5000UL
#define WS_UNRESPONSIVE_THRESHOLD_MS 15000UL
#define MO_MG_V614 614
#define MO_MG_V708 708
#define MO_MG_V713 713
#define MO_MG_V714 714
#define MO_MG_V715 715
#ifndef MO_MG_USE_VERSION
#if defined(MO_MG_VERSION_614)
#define MO_MG_USE_VERSION MO_MG_V614
#else
#define MO_MG_USE_VERSION MO_MG_V708
#endif
#endif
#if MO_MG_USE_VERSION == MO_MG_V614
#define MO_MG_F_IS_MOcppMongooseClient MG_F_USER_2
#endif
namespace MicroOcpp {
bool validateAuthorizationKeyHex(const char *auth_key_hex);
}
using namespace MicroOcpp;
#if MO_MG_USE_VERSION <= MO_MG_V708
void ws_cb(struct mg_connection *c, int ev, void *ev_data, void *fn_data);
#else
void ws_cb(struct mg_connection *c, int ev, void *ev_data);
#endif
MOcppMongooseClient::MOcppMongooseClient(struct mg_mgr *mgr,
const char *backend_url_factory,
const char *charge_box_id_factory,
unsigned char *auth_key_factory, size_t auth_key_factory_len,
const char *ca_certificate,
std::shared_ptr<FilesystemAdapter> filesystem,
ProtocolVersion protocolVersion) : mgr(mgr), protocolVersion(protocolVersion) {
bool readonly;
if (filesystem) {
configuration_init(filesystem);
//all credentials are persistent over reboots
readonly = false;
} else {
//make the credentials non-persistent
MO_DBG_WARN("Credentials non-persistent. Use MicroOcpp::makeDefaultFilesystemAdapter(...) for persistency");
readonly = true;
}
if (auth_key_factory_len > MO_AUTHKEY_LEN_MAX) {
MO_DBG_WARN("auth_key_factory too long - will be cropped");
auth_key_factory_len = MO_AUTHKEY_LEN_MAX;
}
#if MO_ENABLE_V201
if (protocolVersion.major == 2) {
websocketSettings = std::unique_ptr<VariableContainerOwning>(new VariableContainerOwning());
if (filesystem) {
websocketSettings->enablePersistency(filesystem, MO_WSCONN_FN_V201);
}
auto csmsUrl = makeVariable(Variable::InternalDataType::String, Variable::AttributeType::Actual);
csmsUrl->setComponentId("SecurityCtrlr");
csmsUrl->setName("CsmsUrl");
csmsUrl->setString(backend_url_factory ? backend_url_factory : "");
csmsUrl->setPersistent();
v201csmsUrlString = csmsUrl.get();
websocketSettings->add(std::move(csmsUrl));
auto identity = makeVariable(Variable::InternalDataType::String, Variable::AttributeType::Actual);
identity->setComponentId("SecurityCtrlr");
identity->setName("Identity");
identity->setString(charge_box_id_factory ? charge_box_id_factory : "");
identity->setPersistent();
v201identityString = identity.get();
websocketSettings->add(std::move(identity));
auto basicAuthPassword = makeVariable(Variable::InternalDataType::String, Variable::AttributeType::Actual);
basicAuthPassword->setComponentId("SecurityCtrlr");
basicAuthPassword->setName("BasicAuthPassword");
char basicAuthPasswordVal [MO_AUTHKEY_LEN_MAX + 1];
snprintf(basicAuthPasswordVal, sizeof(basicAuthPasswordVal), "%.*s", (int)auth_key_factory_len, auth_key_factory ? (const char*)auth_key_factory : "");
basicAuthPassword->setString(basicAuthPasswordVal);
basicAuthPassword->setPersistent();
v201basicAuthPasswordString = basicAuthPassword.get();
websocketSettings->add(std::move(basicAuthPassword));
websocketSettings->load(); //if settings on flash already exist, this overwrites factory defaults
} else
#endif
{
setting_backend_url_str = declareConfiguration<const char*>(
MO_CONFIG_EXT_PREFIX "BackendUrl", backend_url_factory, MO_WSCONN_FN, readonly, true);
setting_cb_id_str = declareConfiguration<const char*>(
MO_CONFIG_EXT_PREFIX "ChargeBoxId", charge_box_id_factory, MO_WSCONN_FN, readonly, true);
char auth_key_hex [2 * MO_AUTHKEY_LEN_MAX + 1];
auth_key_hex[0] = '\0';
if (auth_key_factory) {
for (size_t i = 0; i < auth_key_factory_len; i++) {
snprintf(auth_key_hex + 2 * i, 3, "%02X", auth_key_factory[i]);
}
}
setting_auth_key_hex_str = declareConfiguration<const char*>(
"AuthorizationKey", auth_key_hex, MO_WSCONN_FN, readonly, true);
registerConfigurationValidator("AuthorizationKey", validateAuthorizationKeyHex);
}
ws_ping_interval_int = declareConfiguration<int>(
"WebSocketPingInterval", 5, MO_WSCONN_FN);
reconnect_interval_int = declareConfiguration<int>(
MO_CONFIG_EXT_PREFIX "ReconnectInterval", 10, MO_WSCONN_FN);
stale_timeout_int = declareConfiguration<int>(
MO_CONFIG_EXT_PREFIX "StaleTimeout", 300, MO_WSCONN_FN);
configuration_load(MO_WSCONN_FN); //load configs with values stored on flash
ca_cert = ca_certificate;
reloadConfigs(); //load WS creds with configs values
#if MO_MG_USE_VERSION == MO_MG_V614
MO_DBG_DEBUG("use MG version %s (tested with 6.14)", MG_VERSION);
#elif MO_MG_USE_VERSION == MO_MG_V708
MO_DBG_DEBUG("use MG version %s (tested with 7.8)", MG_VERSION);
#elif MO_MG_USE_VERSION == MO_MG_V713
MO_DBG_DEBUG("use MG version %s (tested with 7.13)", MG_VERSION);
#endif
maintainWsConn();
}
MOcppMongooseClient::MOcppMongooseClient(struct mg_mgr *mgr,
const char *backend_url_factory,
const char *charge_box_id_factory,
const char *auth_key_factory,
const char *ca_certificate,
std::shared_ptr<FilesystemAdapter> filesystem,
ProtocolVersion protocolVersion) :
MOcppMongooseClient(mgr,
backend_url_factory,
charge_box_id_factory,
(unsigned char *)auth_key_factory, auth_key_factory ? strlen(auth_key_factory) : 0,
ca_certificate,
filesystem,
protocolVersion) {
}
MOcppMongooseClient::~MOcppMongooseClient() {
MO_DBG_DEBUG("destruct MOcppMongooseClient");
if (websocket) {
reconnect(); //close WS connection, won't be reopened
#if MO_MG_USE_VERSION == MO_MG_V614
websocket->flags &= ~MO_MG_F_IS_MOcppMongooseClient;
websocket->user_data = nullptr;
#else
websocket->fn_data = nullptr;
#endif
}
}
void MOcppMongooseClient::loop() {
maintainWsConn();
}
bool MOcppMongooseClient::sendTXT(const char *msg, size_t length) {
if (!websocket || !isConnectionOpen()) {
return false;
}
size_t sent;
#if MO_MG_USE_VERSION == MO_MG_V614
if (websocket->send_mbuf.len > 0) {
sent = 0;
return false;
} else {
mg_send_websocket_frame(websocket, WEBSOCKET_OP_TEXT, msg, length);
sent = length;
}
#else
sent = mg_ws_send(websocket, msg, length, WEBSOCKET_OP_TEXT);
#endif
if (sent < length) {
MO_DBG_WARN("mg_ws_send did only accept %zu out of %zu bytes", sent, length);
//flush broken package and wait for next retry
(void)0;
}
return true;
}
void MOcppMongooseClient::maintainWsConn() {
if (mocpp_tick_ms() - last_status_dbg_msg >= DEBUG_MSG_INTERVAL) {
last_status_dbg_msg = mocpp_tick_ms();
//WS successfully connected?
if (!isConnectionOpen()) {
MO_DBG_DEBUG("WS unconnected");
} else if (mocpp_tick_ms() - last_recv >= (ws_ping_interval_int && ws_ping_interval_int->getInt() > 0 ? (ws_ping_interval_int->getInt() * 1000UL) : 0UL) + WS_UNRESPONSIVE_THRESHOLD_MS) {
//WS connected but unresponsive
MO_DBG_DEBUG("WS unresponsive");
}
}
if (websocket && isConnectionOpen() &&
stale_timeout_int && stale_timeout_int->getInt() > 0 && mocpp_tick_ms() - last_recv >= (stale_timeout_int->getInt() * 1000UL)) {
MO_DBG_INFO("connection %s -- stale, reconnect", url.c_str());
reconnect();
return;
}
if (websocket && isConnectionOpen() &&
ws_ping_interval_int && ws_ping_interval_int->getInt() > 0 && mocpp_tick_ms() - last_hb >= (ws_ping_interval_int->getInt() * 1000UL)) {
last_hb = mocpp_tick_ms();
#if MO_MG_USE_VERSION == MO_MG_V614
mg_send_websocket_frame(websocket, WEBSOCKET_OP_PING, "", 0);
#else
mg_ws_send(websocket, "", 0, WEBSOCKET_OP_PING);
#endif
}
if (websocket != nullptr) { //connection pointer != nullptr means that the socket is still open
return;
}
if (url.empty()) {
//cannot open OCPP connection: credentials missing
return;
}
if (reconnect_interval_int && reconnect_interval_int->getInt() > 0 && mocpp_tick_ms() - last_reconnection_attempt < (reconnect_interval_int->getInt() * 1000UL)) {
return;
}
MO_DBG_DEBUG("(re-)connect to %s", url.c_str());
last_reconnection_attempt = mocpp_tick_ms();
/*
* determine auth token
*/
std::string basic_auth64;
if (auth_key_len > 0) {
#if MO_DBG_LEVEL >= MO_DL_DEBUG
{
char auth_key_hex [2 * MO_AUTHKEY_LEN_MAX + 1];
auth_key_hex[0] = '\0';
for (size_t i = 0; i < auth_key_len; i++) {
snprintf(auth_key_hex + 2 * i, 3, "%02X", auth_key[i]);
}
MO_DBG_DEBUG("auth Token=%s:%s (key will be converted to non-hex)", cb_id.c_str(), auth_key_hex);
}
#endif //MO_DBG_LEVEL >= MO_DL_DEBUG
unsigned char *token = new unsigned char[cb_id.length() + 1 + auth_key_len]; //cb_id:auth_key
if (!token) {
//OOM
return;
}
size_t len = 0;
memcpy(token, cb_id.c_str(), cb_id.length());
len += cb_id.length();
token[len++] = (unsigned char) ':';
memcpy(token + len, auth_key, auth_key_len);
len += auth_key_len;
int base64_length = ((len + 2) / 3) * 4; //3 bytes base256 get encoded into 4 bytes base64. --> base64_len = ceil(len/3) * 4
char *base64 = new char[base64_length + 1];
if (!base64) {
//OOM
delete[] token;
return;
}
// mg_base64_encode() places a null terminator automatically, because the output is a c-string
#if MO_MG_USE_VERSION <= MO_MG_V708
mg_base64_encode(token, len, base64);
#else
mg_base64_encode(token, len, base64, base64_length + 1);
#endif
delete[] token;
MO_DBG_DEBUG("auth64 len=%u, auth64 Token=%s", base64_length, base64);
basic_auth64 = &base64[0];
delete[] base64;
} else {
MO_DBG_DEBUG("no authentication");
(void) 0;
}
#if MO_MG_USE_VERSION == MO_MG_V614
struct mg_connect_opts opts;
memset(&opts, 0, sizeof(opts));
const char *ca_string = ca_cert ? ca_cert : "*"; //"*" enables TLS but disables CA verification
//Check if SSL is disabled, i.e. if URL starts with "ws:"
if (url.length() >= strlen("ws:") &&
tolower(url.c_str()[0]) == 'w' &&
tolower(url.c_str()[1]) == 's' &&
url.c_str()[2] == ':') {
//yes, disable SSL
ca_string = nullptr;
MO_DBG_WARN("Insecure connection (WS)");
}
opts.ssl_ca_cert = ca_string;
char extra_headers [128] = {'\0'};
if (!basic_auth64.empty()) {
auto ret = snprintf(extra_headers, 128, "Authorization: Basic %s\r\n", basic_auth64.c_str());
if (ret < 0 || ret >= 128) {
MO_DBG_ERR("Basic Authentication failed: %d", ret);
(void)0;
}
}
websocket = mg_connect_ws_opt(
mgr,
ws_cb,
this,
opts,
url.c_str(),
protocolVersion.major == 2 ? "ocpp2.0.1" : "ocpp1.6",
*extra_headers ? extra_headers : nullptr);
if (websocket) {
websocket->flags |= MO_MG_F_IS_MOcppMongooseClient;
}
#else
websocket = mg_ws_connect(
mgr,
url.c_str(),
ws_cb,
this,
"Sec-WebSocket-Protocol: %s%s%s\r\n",
protocolVersion.major == 2 ? "ocpp2.0.1" : "ocpp1.6",
basic_auth64.empty() ? "" : "\r\nAuthorization: Basic ",
basic_auth64.empty() ? "" : basic_auth64.c_str()); // Create client
#endif
}
void MOcppMongooseClient::reconnect() {
if (!websocket) {
return;
}
#if MO_MG_USE_VERSION == MO_MG_V614
if (!connection_closing) {
const char *msg = "socket closed by client";
mg_send_websocket_frame(websocket, WEBSOCKET_OP_CLOSE, msg, strlen(msg));
}
#else
websocket->is_closing = 1; //Mongoose will close the socket and the following maintainWsConn() call will open it again
#endif
setConnectionOpen(false);
}
void MOcppMongooseClient::setBackendUrl(const char *backend_url_cstr) {
if (!backend_url_cstr) {
MO_DBG_ERR("invalid argument");
return;
}
#if MO_ENABLE_V201
if (protocolVersion.major == 2) {
if (v201csmsUrlString) {
v201csmsUrlString->setString(backend_url_cstr);
websocketSettings->commit();
}
} else
#endif
{
if (setting_backend_url_str) {
setting_backend_url_str->setString(backend_url_cstr);
configuration_save();
}
}
}
void MOcppMongooseClient::setChargeBoxId(const char *cb_id_cstr) {
if (!cb_id_cstr) {
MO_DBG_ERR("invalid argument");
return;
}
#if MO_ENABLE_V201
if (protocolVersion.major == 2) {
if (v201identityString) {
v201identityString->setString(cb_id_cstr);
websocketSettings->commit();
}
} else
#endif
{
if (setting_cb_id_str) {
setting_cb_id_str->setString(cb_id_cstr);
configuration_save();
}
}
}
void MOcppMongooseClient::setAuthKey(const char *auth_key_cstr) {
if (!auth_key_cstr) {
MO_DBG_ERR("invalid argument");
return;
}
return setAuthKey((const unsigned char*)auth_key_cstr, strlen(auth_key_cstr));
}
void MOcppMongooseClient::setAuthKey(const unsigned char *auth_key, size_t len) {
if (!auth_key || len > MO_AUTHKEY_LEN_MAX) {
MO_DBG_ERR("invalid argument");
return;
}
#if MO_ENABLE_V201
if (protocolVersion.major == 2) {
char basicAuthPassword [MO_AUTHKEY_LEN_MAX + 1];
snprintf(basicAuthPassword, sizeof(basicAuthPassword), "%.*s", (int)len, auth_key ? (const char*)auth_key : "");
if (v201basicAuthPasswordString) {
v201basicAuthPasswordString->setString(basicAuthPassword);
}
} else
#endif
{
char auth_key_hex [2 * MO_AUTHKEY_LEN_MAX + 1];
auth_key_hex[0] = '\0';
for (size_t i = 0; i < len; i++) {
snprintf(auth_key_hex + 2 * i, 3, "%02X", auth_key[i]);
}
if (setting_auth_key_hex_str) {
setting_auth_key_hex_str->setString(auth_key_hex);
configuration_save();
}
}
}
void MOcppMongooseClient::setCaCert(const char *ca_cert_cstr) {
ca_cert = ca_cert_cstr; //updated ca_cert takes immediate effect
}
void MOcppMongooseClient::reloadConfigs() {
reconnect(); //closes WS connection; will be reopened in next maintainWsConn execution
/*
* reload WS credentials from configs
*/
#if MO_ENABLE_V201
if (protocolVersion.major == 2) {
if (v201csmsUrlString) {
backend_url = v201csmsUrlString->getString();
}
if (v201identityString) {
cb_id = v201identityString->getString();
}
if (v201basicAuthPasswordString) {
snprintf((char*)auth_key, sizeof(auth_key), "%s", v201basicAuthPasswordString->getString());
auth_key_len = strlen((char*)auth_key);
}
} else
#endif
{
if (setting_backend_url_str) {
backend_url = setting_backend_url_str->getString();
}
if (setting_cb_id_str) {
cb_id = setting_cb_id_str->getString();
}
if (setting_auth_key_hex_str) {
auto auth_key_hex = setting_auth_key_hex_str->getString();
auto auth_key_hex_len = strlen(setting_auth_key_hex_str->getString());
if (!validateAuthorizationKeyHex(auth_key_hex)) {
MO_DBG_ERR("AuthorizationKey stored with format error. Disable Basic Auth");
auth_key_hex_len = 0;
}
auth_key_len = auth_key_hex_len / 2;
#if MO_MG_VERSION_614
cs_from_hex((char*)auth_key, auth_key_hex, auth_key_hex_len);
#elif MO_MG_USE_VERSION <= MO_MG_V713
mg_unhex(auth_key_hex, auth_key_hex_len, auth_key);
#else
for (size_t i = 0; i < auth_key_len; i++) {
mg_str_to_num(mg_str_n(auth_key_hex + 2*i, 2), 16, auth_key + i, sizeof(uint8_t));
}
#endif
auth_key[auth_key_len] = '\0'; //need null-termination as long as deprecated `const char *getAuthKey()` exists
}
}
/*
* determine new URL with updated WS credentials
*/
url.clear();
if (backend_url.empty()) {
MO_DBG_DEBUG("empty URL closes connection");
return;
}
url = backend_url;
if (url.back() != '/' && !cb_id.empty()) {
url.append("/");
}
url.append(cb_id);
}
int MOcppMongooseClient::printAuthKey(unsigned char *buf, size_t size) {
if (!buf || size < auth_key_len) {
MO_DBG_ERR("invalid argument");
return -1;
}
memcpy(buf, auth_key, auth_key_len);
return (int)auth_key_len;
}
void MOcppMongooseClient::setConnectionOpen(bool open) {
if (open) {
connection_established = true;
last_connection_established = mocpp_tick_ms();
} else {
connection_closing = true;
}
}
void MOcppMongooseClient::cleanConnection() {
connection_established = false;
connection_closing = false;
websocket = nullptr;
}
void MOcppMongooseClient::updateRcvTimer() {
last_recv = mocpp_tick_ms();
}
unsigned long MOcppMongooseClient::getLastRecv() {
return last_recv;
}
unsigned long MOcppMongooseClient::getLastConnected() {
return last_connection_established;
}
#if MO_ENABLE_V201
VariableContainer *MOcppMongooseClient::getVariableContainer() {
return websocketSettings.get();
}
#endif
#if MO_MG_USE_VERSION == MO_MG_V614
void ws_cb(struct mg_connection *nc, int ev, void *ev_data, void *user_data) {
MOcppMongooseClient *osock = nullptr;
if (user_data && nc->flags & MG_F_IS_WEBSOCKET && nc->flags & MO_MG_F_IS_MOcppMongooseClient) {
osock = reinterpret_cast<MOcppMongooseClient*>(user_data);
} else {
return;
}
switch (ev) {
case MG_EV_CONNECT: {
int status = *((int *) ev_data);
if (status != 0) {
MO_DBG_WARN("connection %s -- error %d", osock->getUrl(), status);
(void)0;
}
break;
}
case MG_EV_WEBSOCKET_HANDSHAKE_DONE: {
struct http_message *hm = (struct http_message *) ev_data;
if (hm->resp_code == 101) {
MO_DBG_INFO("connection %s -- connected!", osock->getUrl());
osock->setConnectionOpen(true);
} else {
MO_DBG_WARN("connection %s -- HTTP error %d", osock->getUrl(), hm->resp_code);
(void)0;
/* Connection will be closed after this. */
}
osock->updateRcvTimer();
break;
}
case MG_EV_POLL: {
/* Nothing to do here. OCPP engine has own loop-function */
break;
}
case MG_EV_WEBSOCKET_FRAME: {
struct websocket_message *wm = (struct websocket_message *) ev_data;
if (!osock->getReceiveTXTcallback()((const char *) wm->data, wm->size)) { //forward message to Context
MO_DBG_ERR("processing WS input failed");
(void)0;
}
osock->updateRcvTimer();
break;
}
case MG_EV_WEBSOCKET_CONTROL_FRAME: {
osock->updateRcvTimer();
break;
}
case MG_EV_CLOSE: {
MO_DBG_INFO("connection %s -- closed", osock->getUrl());
osock->cleanConnection();
break;
}
}
}
#else
#if MO_MG_USE_VERSION <= MO_MG_V708
void ws_cb(struct mg_connection *c, int ev, void *ev_data, void *fn_data) {
#else
void ws_cb(struct mg_connection *c, int ev, void *ev_data) {
void *fn_data = c->fn_data;
#endif
if (ev != 2) {
MO_DBG_VERBOSE("Cb fn with event: %d\n", ev);
(void)0;
}
MOcppMongooseClient *osock = reinterpret_cast<MOcppMongooseClient*>(fn_data);
if (!osock) {
if (ev == MG_EV_ERROR || ev == MG_EV_CLOSE) {
MO_DBG_INFO("connection %s", ev == MG_EV_CLOSE ? "closed" : "error");
(void)0;
} else {
MO_DBG_ERR("invalid state %d", ev);
(void)0;
}
return;
}
if (ev == MG_EV_ERROR) {
// On error, log error message
MG_ERROR(("%p %s", c->fd, (char *) ev_data));
} else if (ev == MG_EV_CONNECT) {
// If target URL is SSL/TLS, command client connection to use TLS
if (mg_url_is_ssl(osock->getUrl())) {
const char *ca_string = osock->getCaCert();
if (ca_string && *ca_string == '\0') { //check if certificate verification is disabled (cert string is empty)
//yes, disabled
ca_string = nullptr;
}
struct mg_tls_opts opts;
memset(&opts, 0, sizeof(struct mg_tls_opts));
#if MO_MG_USE_VERSION <= MO_MG_V708
opts.ca = ca_string;
opts.srvname = mg_url_host(osock->getUrl());
#else
opts.ca = mg_str(ca_string);
opts.name = mg_url_host(osock->getUrl());
#endif
mg_tls_init(c, &opts);
} else {
MO_DBG_WARN("Insecure connection (WS)");
}
} else if (ev == MG_EV_WS_OPEN) {
// WS connection established. Perform MQTT login
MO_DBG_INFO("connection %s -- connected!", osock->getUrl());
osock->setConnectionOpen(true);
osock->updateRcvTimer();
} else if (ev == MG_EV_WS_MSG) {
struct mg_ws_message *wm = (struct mg_ws_message *) ev_data;
#if MO_MG_USE_VERSION <= MO_MG_V713
if (!osock->getReceiveTXTcallback()((const char*) wm->data.ptr, wm->data.len)) {
#else
if (!osock->getReceiveTXTcallback()((const char*) wm->data.buf, wm->data.len)) {
#endif
MO_DBG_WARN("processing input message failed");
}
osock->updateRcvTimer();
} else if (ev == MG_EV_WS_CTL) {
osock->updateRcvTimer();
}
if (ev == MG_EV_ERROR || ev == MG_EV_CLOSE) {
MO_DBG_INFO("connection %s -- %s", osock->getUrl(), ev == MG_EV_CLOSE ? "closed" : "error");
osock->cleanConnection();
}
}
#endif
bool MicroOcpp::validateAuthorizationKeyHex(const char *auth_key_hex) {
if (!auth_key_hex) {
return true; //nullptr (or "") means disable Auth
}
bool valid = true;
size_t i = 0;
while (i <= 2 * MO_AUTHKEY_LEN_MAX && auth_key_hex[i] != '\0') {
//check if character is in 0-9, a-f, or A-F
if ( (auth_key_hex[i] >= '0' && auth_key_hex[i] <= '9') ||
(auth_key_hex[i] >= 'a' && auth_key_hex[i] <= 'f') ||
(auth_key_hex[i] >= 'A' && auth_key_hex[i] <= 'F')) {
//yes, it is
i++;
} else {
//no, it isn't
valid = false;
break;
}
}
valid &= auth_key_hex[i] == '\0';
valid &= (i % 2) == 0;
if (!valid) {
MO_DBG_ERR("AuthorizationKey must be hex with at most 20 octets");
(void)0;
}
return valid;
}

View File

@@ -0,0 +1,141 @@
// matth-x/MicroOcppMongoose
// Copyright Matthias Akstaller 2019 - 2024
// GPL-3.0 License (see LICENSE)
#ifndef MO_MONGOOSECLIENT_H
#define MO_MONGOOSECLIENT_H
#if defined(ARDUINO) //fix for conflicting definitions of IPAddress on Arduino
#include <Arduino.h>
#include <IPAddress.h>
#endif
#include "mongoose.h"
#include <MicroOcpp/Core/Connection.h>
#include <MicroOcpp/Version.h>
#include <string>
#include <memory>
#ifndef MO_WSCONN_FN
#define MO_WSCONN_FN (MO_FILENAME_PREFIX "ws-conn.jsn")
#define MO_WSCONN_FN_V201 (MO_FILENAME_PREFIX "ws-conn-v201.jsn")
#endif
#define MO_AUTHKEY_LEN_MAX 63 // Basic Auth password length for both OCPP 1.6 and 2.0.1
namespace MicroOcpp {
class FilesystemAdapter;
class Configuration;
#if MO_ENABLE_V201
class Variable;
class VariableContainer;
class VariableContainerOwning;
#endif
class MOcppMongooseClient : public MicroOcpp::Connection {
private:
struct mg_mgr *mgr {nullptr};
struct mg_connection *websocket {nullptr};
std::string backend_url;
std::string cb_id;
std::string url; //url = backend_url + '/' + cb_id
unsigned char auth_key [MO_AUTHKEY_LEN_MAX + 1]; // Stores the raw Basic Auth password bytes. Appends a terminating '\0' for legacy accessors.
size_t auth_key_len;
const char *ca_cert; //zero-copy. The host system must ensure that this pointer remains valid during the lifetime of this class
std::shared_ptr<Configuration> setting_backend_url_str;
std::shared_ptr<Configuration> setting_cb_id_str;
std::shared_ptr<Configuration> setting_auth_key_hex_str;
unsigned long last_status_dbg_msg {0}, last_recv {0};
std::shared_ptr<Configuration> reconnect_interval_int; //minimum time between two connect trials in s
unsigned long last_reconnection_attempt {-1UL / 2UL};
std::shared_ptr<Configuration> stale_timeout_int; //inactivity period after which the connection will be closed
std::shared_ptr<Configuration> ws_ping_interval_int; //heartbeat intervall in s. 0 sets hb off
unsigned long last_hb {0};
#if MO_ENABLE_V201
std::unique_ptr<VariableContainerOwning> websocketSettings;
Variable *v201csmsUrlString = nullptr;
Variable *v201identityString = nullptr;
Variable *v201basicAuthPasswordString = nullptr;
#endif
bool connection_established {false};
unsigned long last_connection_established {-1UL / 2UL};
bool connection_closing {false};
ReceiveTXTcallback receiveTXTcallback = [] (const char *, size_t) {return false;};
ProtocolVersion protocolVersion;
void reconnect();
void maintainWsConn();
public:
MOcppMongooseClient(struct mg_mgr *mgr,
const char *backend_url_factory,
const char *charge_box_id_factory,
unsigned char *auth_key_factory, size_t auth_key_factory_len,
const char *ca_cert = nullptr, //zero-copy, the string must outlive this class and mg_mgr. Forwards this string to Mongoose as ssl_ca_cert (see https://github.com/cesanta/mongoose/blob/ab650ec5c99ceb52bb9dc59e8e8ec92a2724932b/mongoose.h#L4192)
std::shared_ptr<MicroOcpp::FilesystemAdapter> filesystem = nullptr,
ProtocolVersion protocolVersion = ProtocolVersion(1,6));
//DEPRECATED: will be removed in a future release
MOcppMongooseClient(struct mg_mgr *mgr,
const char *backend_url_factory = nullptr,
const char *charge_box_id_factory = nullptr,
const char *auth_key_factory = nullptr,
const char *ca_cert = nullptr, //zero-copy, the string must outlive this class and mg_mgr. Forwards this string to Mongoose as ssl_ca_cert (see https://github.com/cesanta/mongoose/blob/ab650ec5c99ceb52bb9dc59e8e8ec92a2724932b/mongoose.h#L4192)
std::shared_ptr<MicroOcpp::FilesystemAdapter> filesystem = nullptr,
ProtocolVersion protocolVersion = ProtocolVersion(1,6));
~MOcppMongooseClient();
void loop() override;
bool sendTXT(const char *msg, size_t length) override;
void setReceiveTXTcallback(MicroOcpp::ReceiveTXTcallback &receiveTXT) override {
this->receiveTXTcallback = receiveTXT;
}
MicroOcpp::ReceiveTXTcallback &getReceiveTXTcallback() {
return receiveTXTcallback;
}
//update WS configs. To apply the updates, call `reloadConfigs()` afterwards
void setBackendUrl(const char *backend_url);
void setChargeBoxId(const char *cb_id);
void setAuthKey(const char *auth_key); //DEPRECATED: will be removed in a future release
void setAuthKey(const unsigned char *auth_key, size_t len); //set the auth key in bytes-encoded format
void setCaCert(const char *ca_cert); //forwards this string to Mongoose as ssl_ca_cert (see https://github.com/cesanta/mongoose/blob/ab650ec5c99ceb52bb9dc59e8e8ec92a2724932b/mongoose.h#L4192)
void reloadConfigs();
const char *getBackendUrl() {return backend_url.c_str();}
const char *getChargeBoxId() {return cb_id.c_str();}
const char *getAuthKey() {return (const char*)auth_key;} //DEPRECATED: will be removed in a future release
int printAuthKey(unsigned char *buf, size_t size);
const char *getCaCert() {return ca_cert ? ca_cert : "";}
const char *getUrl() {return url.c_str();}
void setConnectionOpen(bool open);
bool isConnectionOpen() {return connection_established && !connection_closing;}
bool isConnected() {return isConnectionOpen();}
void cleanConnection();
void updateRcvTimer();
unsigned long getLastRecv(); //get time of last successful receive in millis
unsigned long getLastConnected(); //get time of last connection establish
#if MO_ENABLE_V201
//WS client creates and manages its own Variables. This getter function is a temporary solution, in future
//the WS client will be initialized with a Context reference for registering the Variables directly
VariableContainer *getVariableContainer();
#endif
};
}
#endif

View File

@@ -0,0 +1,129 @@
// matth-x/MicroOcppMongoose
// Copyright Matthias Akstaller 2019 - 2024
// GPL-3.0 License (see LICENSE)
#include "MicroOcppMongooseClient_c.h"
#include "MicroOcppMongooseClient.h"
#include <MicroOcpp/Core/FilesystemAdapter.h>
#include <MicroOcpp/Debug.h>
using namespace MicroOcpp;
OCPP_Connection *ocpp_makeConnection(struct mg_mgr *mgr,
const char *backend_url_default,
const char *charge_box_id_default,
const char *auth_key_default,
const char *CA_cert_default,
OCPP_FilesystemOpt fsopt) {
std::shared_ptr<MicroOcpp::FilesystemAdapter> filesystem;
#ifndef MO_DEACTIVATE_FLASH
filesystem = makeDefaultFilesystemAdapter(fsopt);
#endif
auto sock = new MOcppMongooseClient(mgr,
backend_url_default,
charge_box_id_default,
auth_key_default,
CA_cert_default,
filesystem);
return reinterpret_cast<OCPP_Connection*>(sock);;
}
void ocpp_deinitConnection(OCPP_Connection *sock) {
auto mgsock = reinterpret_cast<MOcppMongooseClient*>(sock);
delete mgsock;
}
void ocpp_setBackendUrl(OCPP_Connection *sock, const char *backend_url) {
if (!sock) {
MO_DBG_ERR("invalid argument");
return;
}
auto mgsock = reinterpret_cast<MOcppMongooseClient*>(sock);
mgsock->setBackendUrl(backend_url);
}
void ocpp_setChargeBoxId(OCPP_Connection *sock, const char *cb_id) {
if (!sock) {
MO_DBG_ERR("invalid argument");
return;
}
auto mgsock = reinterpret_cast<MOcppMongooseClient*>(sock);
mgsock->setChargeBoxId(cb_id);
}
void ocpp_setAuthKey(OCPP_Connection *sock, const char *auth_key) {
if (!sock) {
MO_DBG_ERR("invalid argument");
return;
}
auto mgsock = reinterpret_cast<MOcppMongooseClient*>(sock);
mgsock->setAuthKey(auth_key);
}
void ocpp_setCaCert(OCPP_Connection *sock, const char *ca_cert) {
if (!sock) {
MO_DBG_ERR("invalid argument");
return;
}
auto mgsock = reinterpret_cast<MOcppMongooseClient*>(sock);
mgsock->setCaCert(ca_cert);
}
void ocpp_reloadConfigs(OCPP_Connection *sock) {
if (!sock) {
MO_DBG_ERR("invalid argument");
return;
}
auto mgsock = reinterpret_cast<MOcppMongooseClient*>(sock);
mgsock->reloadConfigs();
}
const char *ocpp_getBackendUrl(OCPP_Connection *sock) {
if (!sock) {
MO_DBG_ERR("invalid argument");
return nullptr;
}
auto mgsock = reinterpret_cast<MOcppMongooseClient*>(sock);
return mgsock->getBackendUrl();
}
const char *ocpp_getChargeBoxId(OCPP_Connection *sock) {
if (!sock) {
MO_DBG_ERR("invalid argument");
return nullptr;
}
auto mgsock = reinterpret_cast<MOcppMongooseClient*>(sock);
return mgsock->getChargeBoxId();
}
const char *ocpp_getAuthKey(OCPP_Connection *sock) {
if (!sock) {
MO_DBG_ERR("invalid argument");
return nullptr;
}
auto mgsock = reinterpret_cast<MOcppMongooseClient*>(sock);
return mgsock->getAuthKey();
}
const char *ocpp_getCaCert(OCPP_Connection *sock) {
if (!sock) {
MO_DBG_ERR("invalid argument");
return nullptr;
}
auto mgsock = reinterpret_cast<MOcppMongooseClient*>(sock);
return mgsock->getCaCert();
}
bool ocpp_isConnectionOpen(OCPP_Connection *sock) {
if (!sock) {
MO_DBG_ERR("invalid argument");
return false;
}
auto mgsock = reinterpret_cast<MOcppMongooseClient*>(sock);
return mgsock->isConnectionOpen();
}

View File

@@ -0,0 +1,51 @@
// matth-x/MicroOcppMongoose
// Copyright Matthias Akstaller 2019 - 2024
// GPL-3.0 License (see LICENSE)
#ifndef MO_MONGOOSECLIENT_C_H
#define MO_MONGOOSECLIENT_C_H
#if defined(__cplusplus) && defined(ARDUINO) //fix for conflicting defitions of IPAddress on Arduino
#include <Arduino.h>
#include <IPAddress.h>
#endif
#include "mongoose.h"
#include <MicroOcpp/Core/ConfigurationOptions.h>
#ifdef __cplusplus
extern "C" {
#endif
struct OCPP_Connection;
typedef struct OCPP_Connection OCPP_Connection;
OCPP_Connection *ocpp_makeConnection(struct mg_mgr *mgr,
const char *backend_url_default, //all cstrings can be NULL
const char *charge_box_id_default,
const char *auth_key_default,
const char *CA_cert_default,
struct OCPP_FilesystemOpt fsopt);
void ocpp_deinitConnection(OCPP_Connection *sock);
//update WS configs. To apply the updates, call `ocpp_reloadConfigs()` afterwards
void ocpp_setBackendUrl(OCPP_Connection *sock, const char *backend_url);
void ocpp_setChargeBoxId(OCPP_Connection *sock, const char *cb_id);
void ocpp_setAuthKey(OCPP_Connection *sock, const char *auth_key);
void ocpp_setCaCert(OCPP_Connection *sock, const char *ca_cert);
void ocpp_reloadConfigs(OCPP_Connection *sock);
const char *ocpp_getBackendUrl(OCPP_Connection *sock);
const char *ocpp_getChargeBoxId(OCPP_Connection *sock);
const char *ocpp_getAuthKey(OCPP_Connection *sock);
const char *ocpp_getCaCert(OCPP_Connection *sock);
bool ocpp_isConnectionOpen(OCPP_Connection *sock);
#ifdef __cplusplus
}
#endif
#endif

View File

@@ -1 +1,3 @@
#define MG_ARCH MG_ARCH_ESP32
// Enable TLS support using mbedTLS (built into ESP32)
#define MG_TLS MG_TLS_MBED

View File

@@ -14,7 +14,6 @@ board = rymcu-esp32-devkitc
framework = arduino
lib_deps =
matth-x/MicroOcpp@^1.2.0
matth-x/MicroOcppMongoose@^1.2.0
roboticsbrno/SmartLeds@^3.1.5
miguelbalboa/MFRC522@^1.4.12
tzapu/WiFiManager@^2.0.17

View File

@@ -2,10 +2,14 @@
// OCPP 1.6-J: MOcppMongooseClient will append "/<CFG_CP_IDENTIFIER>" to this URL.
// For local dev: ws://<host>:3001/ocpp
// For production: ws://csms.helios.bh8.ga:8180/steve/websocket/CentralSystemService
#define CFG_OCPP_BACKEND "ws://csms.helios.bh8.ga:8180/steve/websocket/CentralSystemService"
#define CFG_CP_IDENTIFIER "CQWU_HHB_0001"
#define CFG_OCPP_BACKEND "wss://csms-server.uniiem.com/ocpp"
// #define CFG_CP_IDENTIFIER "CQWU_HHB_0001"
#define CFG_CP_IDENTIFIER ""
#define CFG_CB_SERIAL "REDAone_prototype00"
#define CFG_CP_FW_VERSION "1.0.0"
#define CFG_CP_MODAL "Helios DA One"
#define CFG_CP_VENDOR "RayineElec"
#define CFG_AUTHORIZATIONKEY "my_secret_key"
// OCPP Security Profile 1: sent as Authorization: Basic base64(<chargePointIdentifier>:<password>)
// Set to nullptr to disable authentication
// #define CFG_OCPP_PASSWORD "my_password"
#define CFG_OCPP_PASSWORD nullptr

View File

@@ -1,5 +1,6 @@
#include <Arduino.h>
#include <WiFiManager.h>
#include <Preferences.h>
#include <string.h>
#include <MicroOcpp.h>
@@ -18,7 +19,9 @@ enum LEDState
LED_INITIALIZING, // Blue blinking - Initialization and WiFi connecting
LED_WIFI_CONNECTED, // Blue solid - WiFi connected, connecting to OCPP server
LED_OCPP_CONNECTED, // Green solid - Successfully connected to OCPP server
LED_ERROR // Red - Error state
LED_ERROR, // Red solid - Error state
LED_RESET_TX, // Yellow solid - 3s BOOT button hold (Ready to clear transaction)
LED_FACTORY_RESET // Magenta fast blink - 7s BOOT button hold (Ready to factory reset)
};
static int s_retry_num = 0;
@@ -31,6 +34,19 @@ static const unsigned long BLINK_INTERVAL = 200; // 200ms blink interval
uint8_t mac[6];
char cpSerial[13];
// OCPP Configuration Variables
char ocpp_backend[128];
char cp_identifier[64];
char ocpp_password[64];
bool shouldSaveConfig = false;
// callback notifying us of the need to save config
void saveConfigCallback()
{
Serial.println("Should save config");
shouldSaveConfig = true;
}
struct mg_mgr mgr;
/**
@@ -86,6 +102,38 @@ void updateLED()
leds[0] = Rgb{255, 0, 0}; // Red solid
leds.show();
break;
case LED_RESET_TX:
// Yellow fast blink - Ready to clear transaction
if (current_time - s_blink_last_time >= 100)
{
s_blink_last_time = current_time;
s_blink_on = !s_blink_on;
if (s_blink_on)
leds[0] = Rgb{150, 150, 0}; // Yellow
else
leds[0] = Rgb{0, 0, 0};
leds.show();
}
break;
case LED_FACTORY_RESET:
// Magenta fast blink - Ready to factory reset
if (current_time - s_blink_last_time >= 100)
{
s_blink_last_time = current_time;
s_blink_on = !s_blink_on;
if (s_blink_on)
leds[0] = Rgb{255, 0, 255}; // Magenta
else
leds[0] = Rgb{0, 0, 0};
leds.show();
}
break;
}
}
@@ -96,6 +144,18 @@ void setup()
snprintf(cpSerial, sizeof(cpSerial),
"%02X%02X%02X%02X%02X%02X",
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
if (strlen(CFG_CP_IDENTIFIER) > 0)
{
strncpy(cp_identifier, CFG_CP_IDENTIFIER, sizeof(cp_identifier) - 1);
cp_identifier[sizeof(cp_identifier) - 1] = '\0';
}
else
{
// Auto-generate Charge Point Identifier based on MAC (e.g. HLCP_A1B2C3)
snprintf(cp_identifier, sizeof(cp_identifier), "HLCP_%s", cpSerial + 6);
}
// reset LED
leds[0] = Rgb{0, 0, 0};
leds.show();
@@ -103,6 +163,7 @@ void setup()
Serial.begin(115200);
delay(1000);
Serial.printf("\n\n%s(%s) made by %s\n", CFG_CP_MODAL, cpSerial, CFG_CP_VENDOR);
Serial.printf("Charge Point Identifier: %s\n", cp_identifier);
Serial.println("Initializing firmware...\n");
// Initialize LED
@@ -113,7 +174,32 @@ void setup()
leds[0] = Rgb{255, 255, 0};
leds.show();
// Load configuration from Preferences
Preferences preferences;
preferences.begin("ocpp-config", false);
String b = preferences.getString("backend", CFG_OCPP_BACKEND);
String p = preferences.getString("ocpp_password", CFG_OCPP_PASSWORD ? CFG_OCPP_PASSWORD : "");
Serial.printf("\n[OCPP] Loaded Backend URL: %s\n", b.c_str());
Serial.printf("[OCPP] Loaded Password length: %d\n", p.length());
strncpy(ocpp_backend, b.c_str(), sizeof(ocpp_backend) - 1);
ocpp_backend[sizeof(ocpp_backend) - 1] = '\0';
strncpy(ocpp_password, p.c_str(), sizeof(ocpp_password) - 1);
ocpp_password[sizeof(ocpp_password) - 1] = '\0';
WiFiManager wm;
wm.setSaveConfigCallback(saveConfigCallback);
wm.setSaveParamsCallback(saveConfigCallback);
wm.setParamsPage(true);
// Use autocomplete=off to prevent browsers from autofilling old URLs after a reset
WiFiManagerParameter custom_ocpp_backend("backend", "OCPP Backend URL", ocpp_backend, 128, "autocomplete=\"off\"");
WiFiManagerParameter custom_ocpp_password("ocpp_password", "OCPP Basic AuthKey", ocpp_password, 64, "autocomplete=\"off\" type=\"password\"");
wm.addParameter(&custom_ocpp_backend);
wm.addParameter(&custom_ocpp_password);
const char *customHeadElement = R"rawliteral(
<style>
:root {
@@ -255,7 +341,21 @@ void setup()
</style>
)rawliteral";
wm.setCustomHeadElement(customHeadElement);
bool autoConnectRet = wm.autoConnect((String("HLCP_") + String(cpSerial).substring(String(cpSerial).length() - 6)).c_str(), cpSerial);
bool autoConnectRet = wm.autoConnect(cp_identifier, cpSerial);
if (shouldSaveConfig)
{
strncpy(ocpp_backend, custom_ocpp_backend.getValue(), sizeof(ocpp_backend) - 1);
ocpp_backend[sizeof(ocpp_backend) - 1] = '\0';
strncpy(ocpp_password, custom_ocpp_password.getValue(), sizeof(ocpp_password) - 1);
ocpp_password[sizeof(ocpp_password) - 1] = '\0';
preferences.putString("backend", ocpp_backend);
preferences.putString("ocpp_password", ocpp_password);
Serial.println("Saved new OCPP config to Preferences");
}
preferences.end();
if (!autoConnectRet)
{
Serial.println("Failed to connect and hit timeout");
@@ -267,8 +367,52 @@ void setup()
s_led_state = LED_WIFI_CONNECTED;
mg_mgr_init(&mgr);
MicroOcpp::MOcppMongooseClient *client = new MicroOcpp::MOcppMongooseClient(&mgr, CFG_OCPP_BACKEND, CFG_CP_IDENTIFIER, CFG_AUTHORIZATIONKEY, "", MicroOcpp::makeDefaultFilesystemAdapter(MicroOcpp::FilesystemOpt::Use_Mount_FormatOnFail), MicroOcpp::ProtocolVersion(1, 6));
const char *basic_auth_password = (strlen(ocpp_password) > 0) ? ocpp_password : nullptr;
unsigned char *basic_auth_password_bytes = nullptr;
size_t basic_auth_password_len = 0;
if (basic_auth_password)
{
basic_auth_password_bytes = reinterpret_cast<unsigned char *>(const_cast<char *>(basic_auth_password));
basic_auth_password_len = strlen(basic_auth_password);
}
MicroOcpp::MOcppMongooseClient *client = new MicroOcpp::MOcppMongooseClient(
&mgr,
ocpp_backend,
cp_identifier,
nullptr,
0,
"",
MicroOcpp::makeDefaultFilesystemAdapter(MicroOcpp::FilesystemOpt::Use_Mount_FormatOnFail),
MicroOcpp::ProtocolVersion(1, 6));
// Preferences and firmware config are the source of truth. Override any stale
// values cached in MicroOcpp's ws-conn storage before the first reconnect cycle.
client->setBackendUrl(ocpp_backend);
client->setChargeBoxId(cp_identifier);
if (basic_auth_password_bytes)
{
client->setAuthKey(basic_auth_password_bytes, basic_auth_password_len);
}
else
{
client->setAuthKey(reinterpret_cast<const unsigned char *>(""), 0);
}
client->reloadConfigs();
mocpp_initialize(*client, ChargerCredentials(CFG_CP_MODAL, CFG_CP_VENDOR, CFG_CP_FW_VERSION, cpSerial, nullptr, nullptr, CFG_CB_SERIAL, nullptr, nullptr), MicroOcpp::makeDefaultFilesystemAdapter(MicroOcpp::FilesystemOpt::Use_Mount_FormatOnFail));
// For development/recovery: Set up BOOT button (GPIO 0)
pinMode(0, INPUT_PULLUP);
// Forcefully accept rejected RemoteStopTransaction (if hardware goes out of sync with CSMS)
setOnSendConf("RemoteStopTransaction", [](JsonObject payload)
{
if (!strcmp(payload["status"], "Rejected")) {
Serial.println("[main] MicroOcpp rejected RemoteStopTransaction! Force overriding and stopping charging...");
endTransaction(nullptr, "Remote", 1);
} });
}
}
@@ -277,19 +421,89 @@ void loop()
mg_mgr_poll(&mgr, 10);
mocpp_loop();
auto ctx = getOcppContext();
if (ctx && ctx->getConnection().isConnected())
// Handle BOOT button (GPIO 0) interactions for recovery
bool is_btn_pressed = (digitalRead(0) == LOW);
static unsigned long boot_press_time = 0;
static bool boot_was_pressed = false;
if (is_btn_pressed)
{
if (s_led_state != LED_OCPP_CONNECTED)
if (!boot_was_pressed)
{
s_led_state = LED_OCPP_CONNECTED;
boot_was_pressed = true;
boot_press_time = millis();
}
unsigned long held_time = millis() - boot_press_time;
if (held_time >= 7000)
{
s_led_state = LED_FACTORY_RESET;
}
else if (held_time >= 3000)
{
s_led_state = LED_RESET_TX;
}
}
else
{
if (s_led_state != LED_WIFI_CONNECTED)
if (boot_was_pressed)
{
s_led_state = LED_WIFI_CONNECTED;
unsigned long held_time = millis() - boot_press_time;
if (held_time >= 7000)
{
Serial.println("BOOT button held for > 7s! Clearing WiFi and OCPP settings, then restarting...");
// Clear WiFi completely
WiFi.disconnect(true, true);
WiFiManager wm;
wm.resetSettings();
// Clear Preferences explicitely
Preferences preferences;
preferences.begin("ocpp-config", false);
preferences.remove("backend");
preferences.remove("ocpp_password");
preferences.clear();
preferences.end();
Serial.println("NVS ocpp-config cleared.");
// Clear MicroOcpp FS configs (this removes MO's cached URL)
auto fs = MicroOcpp::makeDefaultFilesystemAdapter(MicroOcpp::FilesystemOpt::Use_Mount_FormatOnFail);
fs->remove(MO_WSCONN_FN);
Serial.println("MicroOcpp config cache cleared.");
// Give time for serial to print and NVS to sync
delay(1000);
ESP.restart();
}
else if (held_time >= 3000)
{
Serial.println("BOOT button held for > 3s! Forcefully ending dangling transaction...");
endTransaction(nullptr, "PowerLoss", 1);
}
boot_was_pressed = false;
// Temporarily set to init so the logic below restores the actual network state accurately
s_led_state = LED_INITIALIZING;
}
}
// Only update default LED states if button is not overriding them
if (!is_btn_pressed)
{
auto ctx = getOcppContext();
if (ctx && ctx->getConnection().isConnected())
{
if (s_led_state != LED_OCPP_CONNECTED)
{
s_led_state = LED_OCPP_CONNECTED;
}
}
else
{
if (s_led_state != LED_WIFI_CONNECTED)
{
s_led_state = LED_WIFI_CONNECTED;
}
}
}

2
hardware/pcb/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*-backups

View File

@@ -0,0 +1,2 @@
(kicad_pcb (version 20241229) (generator "pcbnew") (generator_version "9.0")
)

View File

@@ -0,0 +1,99 @@
{
"board": {
"active_layer": 0,
"active_layer_preset": "",
"auto_track_width": true,
"hidden_netclasses": [],
"hidden_nets": [],
"high_contrast_mode": 0,
"net_color_mode": 1,
"opacity": {
"images": 0.6,
"pads": 1.0,
"shapes": 1.0,
"tracks": 1.0,
"vias": 1.0,
"zones": 0.6
},
"selection_filter": {
"dimensions": true,
"footprints": true,
"graphics": true,
"keepouts": true,
"lockedItems": false,
"otherItems": true,
"pads": true,
"text": true,
"tracks": true,
"vias": true,
"zones": true
},
"visible_items": [
"vias",
"footprint_text",
"footprint_anchors",
"ratsnest",
"grid",
"footprints_front",
"footprints_back",
"footprint_values",
"footprint_references",
"tracks",
"drc_errors",
"drawing_sheet",
"bitmaps",
"pads",
"zones",
"drc_warnings",
"drc_exclusions",
"locked_item_shadows",
"conflict_shadows",
"shapes"
],
"visible_layers": "ffffffff_ffffffff_ffffffff_ffffffff",
"zone_display_mode": 0
},
"git": {
"integration_disabled": false,
"repo_type": "",
"repo_username": "",
"ssh_key": ""
},
"meta": {
"filename": "HeliosDAONE.kicad_prl",
"version": 5
},
"net_inspector_panel": {
"col_hidden": [],
"col_order": [],
"col_widths": [],
"custom_group_rules": [],
"expanded_rows": [],
"filter_by_net_name": true,
"filter_by_netclass": true,
"filter_text": "",
"group_by_constraint": false,
"group_by_netclass": false,
"show_unconnected_nets": false,
"show_zero_pad_nets": false,
"sort_ascending": true,
"sorting_column": -1
},
"open_jobsets": [],
"project": {
"files": []
},
"schematic": {
"selection_filter": {
"graphics": true,
"images": true,
"labels": true,
"lockedItems": false,
"otherItems": true,
"pins": true,
"symbols": true,
"text": true,
"wires": true
}
}
}

View File

@@ -0,0 +1,418 @@
{
"board": {
"3dviewports": [],
"design_settings": {
"defaults": {},
"diff_pair_dimensions": [],
"drc_exclusions": [],
"rules": {},
"track_widths": [],
"via_dimensions": []
},
"ipc2581": {
"dist": "",
"distpn": "",
"internal_id": "",
"mfg": "",
"mpn": ""
},
"layer_pairs": [],
"layer_presets": [],
"viewports": []
},
"boards": [],
"cvpcb": {
"equivalence_files": []
},
"erc": {
"erc_exclusions": [],
"meta": {
"version": 0
},
"pin_map": [
[
0,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
2
],
[
0,
2,
0,
1,
0,
0,
1,
0,
2,
2,
2,
2
],
[
0,
0,
0,
0,
0,
0,
1,
0,
1,
0,
1,
2
],
[
0,
1,
0,
0,
0,
0,
1,
1,
2,
1,
1,
2
],
[
0,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
2
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
2
],
[
1,
1,
1,
1,
1,
0,
1,
1,
1,
1,
1,
2
],
[
0,
0,
0,
1,
0,
0,
1,
0,
0,
0,
0,
2
],
[
0,
2,
1,
2,
0,
0,
1,
0,
2,
2,
2,
2
],
[
0,
2,
0,
1,
0,
0,
1,
0,
2,
0,
0,
2
],
[
0,
2,
1,
1,
0,
0,
1,
0,
2,
0,
0,
2
],
[
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2
]
],
"rule_severities": {
"bus_definition_conflict": "error",
"bus_entry_needed": "error",
"bus_to_bus_conflict": "error",
"bus_to_net_conflict": "error",
"different_unit_footprint": "error",
"different_unit_net": "error",
"duplicate_reference": "error",
"duplicate_sheet_names": "error",
"endpoint_off_grid": "warning",
"extra_units": "error",
"footprint_filter": "ignore",
"footprint_link_issues": "warning",
"four_way_junction": "ignore",
"global_label_dangling": "warning",
"hier_label_mismatch": "error",
"label_dangling": "error",
"label_multiple_wires": "warning",
"lib_symbol_issues": "warning",
"lib_symbol_mismatch": "warning",
"missing_bidi_pin": "warning",
"missing_input_pin": "warning",
"missing_power_pin": "error",
"missing_unit": "warning",
"multiple_net_names": "warning",
"net_not_bus_member": "warning",
"no_connect_connected": "warning",
"no_connect_dangling": "warning",
"pin_not_connected": "error",
"pin_not_driven": "error",
"pin_to_pin": "warning",
"power_pin_not_driven": "error",
"same_local_global_label": "warning",
"similar_label_and_power": "warning",
"similar_labels": "warning",
"similar_power": "warning",
"simulation_model_issue": "ignore",
"single_global_label": "ignore",
"unannotated": "error",
"unconnected_wire_endpoint": "warning",
"undefined_netclass": "error",
"unit_value_mismatch": "error",
"unresolved_variable": "error",
"wire_dangling": "error"
}
},
"libraries": {
"pinned_footprint_libs": [],
"pinned_symbol_libs": []
},
"meta": {
"filename": "HeliosDAONE.kicad_pro",
"version": 3
},
"net_settings": {
"classes": [
{
"bus_width": 12,
"clearance": 0.2,
"diff_pair_gap": 0.25,
"diff_pair_via_gap": 0.25,
"diff_pair_width": 0.2,
"line_style": 0,
"microvia_diameter": 0.3,
"microvia_drill": 0.1,
"name": "Default",
"pcb_color": "rgba(0, 0, 0, 0.000)",
"priority": 2147483647,
"schematic_color": "rgba(0, 0, 0, 0.000)",
"track_width": 0.2,
"via_diameter": 0.6,
"via_drill": 0.3,
"wire_width": 6
}
],
"meta": {
"version": 4
},
"net_colors": null,
"netclass_assignments": null,
"netclass_patterns": []
},
"pcbnew": {
"last_paths": {
"gencad": "",
"idf": "",
"netlist": "",
"plot": "",
"pos_files": "",
"specctra_dsn": "",
"step": "",
"svg": "",
"vrml": ""
},
"page_layout_descr_file": ""
},
"schematic": {
"annotate_start_num": 0,
"bom_export_filename": "${PROJECTNAME}.csv",
"bom_fmt_presets": [],
"bom_fmt_settings": {
"field_delimiter": ",",
"keep_line_breaks": false,
"keep_tabs": false,
"name": "CSV",
"ref_delimiter": ",",
"ref_range_delimiter": "",
"string_delimiter": "\""
},
"bom_presets": [],
"bom_settings": {
"exclude_dnp": false,
"fields_ordered": [
{
"group_by": false,
"label": "Reference",
"name": "Reference",
"show": true
},
{
"group_by": false,
"label": "Qty",
"name": "${QUANTITY}",
"show": true
},
{
"group_by": true,
"label": "Value",
"name": "Value",
"show": true
},
{
"group_by": true,
"label": "DNP",
"name": "${DNP}",
"show": true
},
{
"group_by": true,
"label": "Exclude from BOM",
"name": "${EXCLUDE_FROM_BOM}",
"show": true
},
{
"group_by": true,
"label": "Exclude from Board",
"name": "${EXCLUDE_FROM_BOARD}",
"show": true
},
{
"group_by": true,
"label": "Footprint",
"name": "Footprint",
"show": true
},
{
"group_by": false,
"label": "Datasheet",
"name": "Datasheet",
"show": true
}
],
"filter_string": "",
"group_symbols": true,
"include_excluded_from_bom": true,
"name": "Default Editing",
"sort_asc": true,
"sort_field": "位号"
},
"connection_grid_size": 50.0,
"drawing": {
"dashed_lines_dash_length_ratio": 12.0,
"dashed_lines_gap_length_ratio": 3.0,
"default_line_thickness": 6.0,
"default_text_size": 50.0,
"field_names": [],
"intersheets_ref_own_page": false,
"intersheets_ref_prefix": "",
"intersheets_ref_short": false,
"intersheets_ref_show": false,
"intersheets_ref_suffix": "",
"junction_size_choice": 3,
"label_size_ratio": 0.375,
"operating_point_overlay_i_precision": 3,
"operating_point_overlay_i_range": "~A",
"operating_point_overlay_v_precision": 3,
"operating_point_overlay_v_range": "~V",
"overbar_offset_ratio": 1.23,
"pin_symbol_size": 25.0,
"text_offset_ratio": 0.15
},
"legacy_lib_dir": "",
"legacy_lib_list": [],
"meta": {
"version": 1
},
"net_format_name": "",
"page_layout_descr_file": "",
"plot_directory": "",
"space_save_all_events": true,
"spice_current_sheet_as_root": false,
"spice_external_command": "spice \"%I\"",
"spice_model_current_sheet_as_root": true,
"spice_save_all_currents": false,
"spice_save_all_dissipations": false,
"spice_save_all_voltages": false,
"subpart_first_id": 65,
"subpart_id_separator": 0
},
"sheets": [
[
"ef4a6f87-87d3-400e-b11f-0c7519b83474",
"Root"
]
],
"text_variables": {}
}

View File

@@ -0,0 +1,20 @@
(kicad_sch
(version 20250114)
(generator "eeschema")
(generator_version "9.0")
(uuid "ef4a6f87-87d3-400e-b11f-0c7519b83474")
(paper "A4")
(title_block
(title "Helios DA ONE")
(date "2026-02-18")
(rev "1.0.0")
(company "RayineElec")
)
(lib_symbols)
(sheet_instances
(path "/"
(page "1")
)
)
(embedded_fonts no)
)