Compare commits

..

64 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
4703ef3548 refactor(proxy): rename middleware to proxy 2026-03-12 10:04:38 +08:00
50f5fbd122 refactor(middleware): improve isInitialized function and cookie handling 2026-03-12 09:41:57 +08:00
ee44586c6f chore: update dependencies and restructure package.json 2026-03-12 09:27:42 +08:00
2cc7fbc5be style(charge-points): comment out connection info display for clarity 2026-03-12 01:25:16 +08:00
4e16e933f2 feat(login): enhance routing by preserving 'from' path in login redirects 2026-03-12 01:14:16 +08:00
2479653bab feat(qr-scanner): integrate jsQR for QR code scanning fallback 2026-03-12 01:07:55 +08:00
bf7c7c54cd fix(auth): correct session cookie names for authentication 2026-03-12 00:51:20 +08:00
d49c80cc05 style(auth): fix comment and return value in getParentDomain function 2026-03-12 00:48:52 +08:00
8d0164208d feat(auth): update cookie prefix and default cookie attributes for better security 2026-03-12 00:36:00 +08:00
f753550d44 fix(csms): fix comment and return value in getParentDomain function 2026-03-12 00:27:21 +08:00
fb0d135a79 feat(auth): implement cross-subdomain cookie support with dynamic domain resolution 2026-03-12 00:16:27 +08:00
103c86e14d feat(auth): add support for cross-subdomain cookies and improve environment variable handling 2026-03-11 23:39:14 +08:00
20e0cd068f build(docker): add argument for CSMS URL in Dockerfile 2026-03-11 23:17:44 +08:00
f8ff5c3d31 build(docker): add build argument for CSMS URL in web service 2026-03-11 23:00:34 +08:00
9d76dc508a fix(web): wrap ChargePageContent in Suspense for improved loading handling 2026-03-11 22:42:09 +08:00
279e453ad6 feat(web): add tooltip for charge point status with online/offline indication 2026-03-11 22:01:43 +08:00
02a361488b feat(api): add stats chart endpoint for admin access with time series data
feat(dayjs): integrate dayjs for date handling and formatting across the application
refactor(routes): update date handling in id-tags, transactions, users, and dashboard routes to use dayjs
style(globals): improve CSS variable definitions for better readability and consistency
deps: add dayjs as a dependency for date manipulation
2026-03-11 21:34:21 +08:00
73f0c6243a feat(web): add remote start transaction feature and QR code scanning for charging 2026-03-11 18:09:00 +08:00
8ee2378c78 feat(web): integrate toast notifications for profile and passkey actions 2026-03-11 17:24:56 +08:00
168a5b5613 feat(web): integrate session management and improve API error handling 2026-03-11 17:19:14 +08:00
f1932676be feat(web): add SessionWatcher component for session management and handle session expiration 2026-03-11 17:08:52 +08:00
7bd4e379de feat: web docker 2026-03-11 16:59:56 +08:00
ce53a4f218 feat(csms): restructure deployment setup with Docker and improve build process 2026-03-11 15:39:31 +08:00
70ae7da0d9 feat(web): wrap LoginForm in Suspense for improved loading handling 2026-03-11 13:45:42 +08:00
9bdeea8a12 feat(web): add user ID and name to transaction details for better tracking 2026-03-11 12:09:15 +08:00
107 changed files with 20334 additions and 878 deletions

View File

@@ -7,5 +7,8 @@
"editor.formatOnSave": true,
"[typescriptreact]": {
"editor.defaultFormatter": "oxc.oxc-vscode"
},
"[json]": {
"editor.defaultFormatter": "oxc.oxc-vscode"
}
}

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,2 +1,5 @@
BETTER_AUTH_SECRET=
WEB_ORIGIN=http://localhost:3000
DATABASE_CONNECTION_STRING=
# 生产环境跨子域 Cookie例如 .uniiem.com
COOKIE_DOMAIN=

32
apps/csms/Dockerfile Normal file
View File

@@ -0,0 +1,32 @@
FROM node:22-alpine AS base
FROM base AS builder
RUN apk add --no-cache gcompat
RUN corepack enable && corepack prepare pnpm@10.18.2 --activate
WORKDIR /app
# 复制 monorepo 根配置catalog、lockfile 等)
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
# 复制 csms 应用源码
COPY apps/csms/package.json ./apps/csms/package.json
COPY apps/csms/esbuild.config.js ./apps/csms/esbuild.config.js
COPY apps/csms/tsconfig.json ./apps/csms/tsconfig.json
COPY apps/csms/src ./apps/csms/src
RUN pnpm install --filter csms && \
pnpm --filter csms run build:prod
FROM base AS runner
WORKDIR /app
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 hono
COPY --from=builder --chown=hono:nodejs /app/apps/csms/dist /app/dist
COPY --from=builder --chown=hono:nodejs /app/apps/csms/package.json /app/package.json
USER hono
EXPOSE 3001
CMD ["node", "/app/dist/index.js"]

View File

@@ -1,16 +0,0 @@
FROM node:22-alpine
WORKDIR /app
# 复制应用文件
COPY package.json .
COPY index.js .
# 安装依赖
RUN npm ci --omit=dev
# 暴露端口
EXPOSE 3001
# 启动应用
CMD ["node", "index.js"]

View File

@@ -1,12 +0,0 @@
version: '3.8'
services:
csms:
build: .
ports:
- "3001:3001"
environment:
NODE_ENV: production
# env_file:
# - .env
restart: unless-stopped

View File

@@ -1 +0,0 @@
import{serve as c}from"@hono/node-server";import{Hono as a}from"hono";import{createNodeWebSocket as p}from"@hono/node-ws";import{getConnInfo as i}from"@hono/node-server/conninfo";import{cors as m}from"hono/cors";import{logger as l}from"hono/logger";import{showRoutes as g}from"hono/dev";import{readFileSync as d}from"fs";import{dirname as f,join as u}from"path";import{fileURLToPath as h}from"url";var S=f(h(import.meta.url)),s="1.0.0";try{let o=u(S,"../../package.json");s=JSON.parse(d(o,"utf-8")).version||"1.0.0"}catch{}var r=new a,{injectWebSocket:k,upgradeWebSocket:v}=p({app:r});r.use(l());r.use("/ocpp",m({origin:"*",allowMethods:["GET","POST","OPTIONS"],allowHeaders:["Content-Type","Authorization"],exposeHeaders:["Content-Length"],credentials:!0}));r.get("/",o=>o.json({platform:"Helios CSMS",version:s,message:"ok"}));r.get("/ocpp",v(o=>({onOpen(e,t){let n=i(o);console.log(`New connection from ${n.remote.address}:${n.remote.port}`)},onMessage(e,t){console.log(`Received message: ${e.data}`),t.send(`Echo: ${e.data}`)},onClose(e,t){console.log("Connection closed: ",e.code,e.reason)}})));g(r,{verbose:!0});var w=c({fetch:r.fetch,port:3001},o=>{console.log(`Server is running on http://localhost:${o.port}`)});k(w);

View File

@@ -1,13 +0,0 @@
{
"name": "csms",
"type": "module",
"dependencies": {
"@hono/node-server": "^1.19.6",
"@hono/node-ws": "^1.2.0",
"better-auth": "^1.3.34",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.44.7",
"hono": "^4.10.6",
"pg": "^8.16.3"
}
}

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

@@ -10,7 +10,9 @@ const allDeps = {
...packageJson.dependencies,
...packageJson.devDependencies,
}
const externalModules = Object.keys(allDeps)
// 开发模式将依赖标记为 external 以加快构建速度;
// 生产模式全部打包进 bundlerunner 阶段无需 node_modules。
const externalModules = isProduction ? [] : Object.keys(allDeps)
const config = {
entryPoints: ['src/index.ts'],
@@ -22,6 +24,12 @@ const config = {
external: externalModules,
sourcemap: !isProduction,
minify: isProduction,
// CJS 包(如 dotenv在 ESM bundle 中需要 require 支持
banner: isProduction
? {
js: `import{createRequire}from'module';const require=createRequire(import.meta.url);`,
}
: {},
define: {
'process.env.NODE_ENV': `"${process.env.NODE_ENV || 'development'}"`,
},

View File

@@ -6,8 +6,6 @@
"build": "node esbuild.config.js",
"build:prod": "NODE_ENV=production node esbuild.config.js",
"start": "node dist/index.js",
"deploy": "node scripts/deploy.js",
"deploy:docker": "node scripts/deploy.js --docker",
"db:gen:auth": "npx @better-auth/cli generate --output src/db/auth-schema.ts",
"db:gen": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
@@ -19,6 +17,7 @@
"@hono/node-ws": "^1.2.0",
"@hono/zod-validator": "^0.7.6",
"better-auth": "catalog:",
"dayjs": "catalog:",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.44.7",
"hono": "^4.10.6",

View File

@@ -1,153 +0,0 @@
#!/usr/bin/env node
/**
* 部署助手脚本
*
* 使用:
* node scripts/deploy.js # 标准部署(生产构建 + 依赖)
* node scripts/deploy.js --docker # Docker 镜像
* node scripts/deploy.js --help # 显示帮助
*/
import fs from 'fs'
import path from 'path'
import { execSync } from 'child_process'
const args = process.argv.slice(2)
const help = args.includes('--help') || args.includes('-h')
const isDocker = args.includes('--docker')
const outDir = args.includes('--out') ? args[args.indexOf('--out') + 1] : 'dist'
if (help) {
console.log(`
Usage: node scripts/deploy.js [options]
Options:
--docker 生成 Dockerfile
--out PATH 输出目录(默认: dist
--help 显示此帮助信息
Examples:
node scripts/deploy.js 生产构建
node scripts/deploy.js --out build 输出到 build 目录
node scripts/deploy.js --docker 生成 Docker 配置
`)
process.exit(0)
}
console.log('🚀 开始部署流程...\n')
try {
// 1. 生产构建
console.log('📦 构建应用...')
execSync('npm run build:prod', { stdio: 'inherit', cwd: process.cwd() })
// 2. 创建部署目录
console.log('\n📁 准备部署目录...')
const deployDir = path.join(process.cwd(), 'deploy')
if (fs.existsSync(deployDir)) {
fs.rmSync(deployDir, { recursive: true })
}
fs.mkdirSync(deployDir, { recursive: true })
// 3. 复制必要文件
console.log('📋 复制文件...')
// 复制构建输出
fs.copyFileSync(
path.join(process.cwd(), 'dist/index.js'),
path.join(deployDir, 'index.js'),
)
fs.copyFileSync(
path.join(process.cwd(), 'dist/package.json'),
path.join(deployDir, 'package.json'),
)
// 复制 package.json用于 npm install
const srcPkg = JSON.parse(fs.readFileSync('package.json', 'utf-8'))
const deployPkg = {
name: srcPkg.name,
version: srcPkg.version,
type: 'module',
dependencies: srcPkg.dependencies,
}
fs.writeFileSync(
path.join(deployDir, 'package.json'),
JSON.stringify(deployPkg, null, 2),
)
// 复制 .env 模板
if (fs.existsSync('.env.example')) {
fs.copyFileSync('.env.example', path.join(deployDir, '.env.example'))
}
console.log(`✅ 部署文件已生成到 ${path.relative(process.cwd(), deployDir)}/`)
// 4. 可选:生成 Docker 配置
if (isDocker) {
console.log('\n🐳 生成 Docker 配置...')
const dockerfile = `FROM node:22-alpine
WORKDIR /app
# 复制应用文件
COPY package.json .
COPY index.js .
# 安装依赖
RUN npm ci --omit=dev
# 暴露端口
EXPOSE 3001
# 启动应用
CMD ["node", "index.js"]
`
const dockerCompose = `version: '3.8'
services:
csms:
build: .
ports:
- "3001:3001"
environment:
NODE_ENV: production
# env_file:
# - .env
restart: unless-stopped
`
fs.writeFileSync(path.join(deployDir, 'Dockerfile'), dockerfile)
fs.writeFileSync(path.join(deployDir, 'docker-compose.yml'), dockerCompose)
console.log('✅ Docker 文件已生成')
}
// 5. 显示部署信息
console.log('\n📊 部署信息:')
console.log(` 名称: ${deployPkg.name}`)
console.log(` 版本: ${deployPkg.version}`)
console.log(` 主文件: index.js`)
console.log(` 依赖数: ${Object.keys(deployPkg.dependencies).length}`)
const indexSize = fs.statSync(path.join(deployDir, 'index.js')).size
console.log(` 代码大小: ${(indexSize / 1024).toFixed(1)}KB`)
console.log('\n✨ 部署准备完成!\n')
console.log('下一步:')
console.log(` 1. cd ${path.relative(process.cwd(), deployDir)}`)
console.log(` 2. npm install --omit=dev`)
console.log(` 3. node index.js`)
if (isDocker) {
console.log('\n或使用 Docker:')
console.log(` 1. cd ${path.relative(process.cwd(), deployDir)}`)
console.log(` 2. docker compose up`)
}
} catch (error) {
console.error('\n❌ 部署失败:', error.message)
process.exit(1)
}

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,12 +8,19 @@ 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'
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'
@@ -40,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,
@@ -51,11 +58,14 @@ app.on(['POST', 'GET'], '/api/auth/*', (c) => auth.handler(c.req.raw))
// REST API routes
app.route('/api/stats', statsRoutes)
app.route('/api/stats/chart', statsChartRoutes)
app.route('/api/charge-points', chargePointRoutes)
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')
@@ -79,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

@@ -5,6 +5,19 @@ import * as schema from "@/db/schema.ts";
import { admin, bearer, username } from "better-auth/plugins";
import { passkey } from "@better-auth/passkey";
const webOrigin = process.env.WEB_ORIGIN ?? "http://localhost:3000";
const rpID = new URL(webOrigin).hostname;
// 从 WEB_ORIGIN 的主机名推导父域(如 csms.uniiem.com → uniiem.com
// 用于跨子域共享 session cookie本地开发时返回 undefined 不启用。
function getParentDomain(hostname: string): string | undefined {
if (hostname === "localhost" || /^\d/.test(hostname)) return undefined;
const parts = hostname.split(".");
return parts.length >= 3 ? parts.slice(1).join(".") : undefined;
}
const cookieDomain = process.env.COOKIE_DOMAIN ?? getParentDomain(rpID);
export const auth = betterAuth({
database: drizzleAdapter(useDrizzle(), {
provider: "pg",
@@ -12,7 +25,7 @@ export const auth = betterAuth({
...schema,
},
}),
trustedOrigins: [process.env.WEB_ORIGIN ?? "http://localhost:3000"],
trustedOrigins: [webOrigin],
appName: "Helios EVCS",
user: {
additionalFields: {},
@@ -20,8 +33,26 @@ export const auth = betterAuth({
emailAndPassword: {
enabled: true,
},
plugins: [admin(), username(), bearer(), passkey()],
plugins: [
admin(),
username(),
bearer(),
passkey({
rpID,
rpName: "Helios EVCS",
origin: webOrigin,
}),
],
advanced: {
cookiePrefix: "helios_auth",
cookiePrefix: "helios",
defaultCookieAttributes: {
httpOnly: true,
secure: true,
sameSite: "none",
domain: cookieDomain,
},
crossSubdomainCookies: cookieDomain
? { enabled: true, domain: cookieDomain }
: { enabled: false },
},
});

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,6 +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,
@@ -18,18 +19,30 @@ 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);
if (!tag) return { status: "Invalid" };
if (tag.status === "Blocked") return { status: "Blocked" };
if (tag.expiryDate && tag.expiryDate < new Date()) {
if (tag.expiryDate && dayjs(tag.expiryDate).isBefore(dayjs())) {
return { status: "Expired", expiryDate: tag.expiryDate.toISOString() };
}
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,18 +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)
@@ -29,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,
lastBootNotificationAt: new Date(),
heartbeatInterval,
lastBootNotificationAt: dayjs().toDate(),
transportStatus: 'online',
})
.onConflictDoUpdate({
target: chargePoint.chargePointIdentifier,
@@ -44,9 +46,10 @@ 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,
lastBootNotificationAt: new Date(),
updatedAt: new Date(),
heartbeatInterval,
lastBootNotificationAt: dayjs().toDate(),
transportStatus: 'online',
updatedAt: dayjs().toDate(),
},
})
.returning()
@@ -57,8 +60,8 @@ export async function handleBootNotification(
console.log(`[OCPP] BootNotification ${ctx.chargePointIdentifier} status=${status}`)
return {
currentTime: new Date().toISOString(),
interval: DEFAULT_HEARTBEAT_INTERVAL,
currentTime: dayjs().toISOString(),
interval: heartbeatInterval,
status,
}
}

View File

@@ -1,4 +1,5 @@
import { eq } from 'drizzle-orm'
import dayjs from 'dayjs'
import { useDrizzle } from '@/lib/db.js'
import { chargePoint } from '@/db/schema.js'
import type {
@@ -15,10 +16,14 @@ export async function handleHeartbeat(
await db
.update(chargePoint)
.set({ lastHeartbeatAt: new Date() })
.set({
lastHeartbeatAt: dayjs().toDate(),
transportStatus: 'online',
updatedAt: dayjs().toDate(),
})
.where(eq(chargePoint.chargePointIdentifier, ctx.chargePointIdentifier))
return {
currentTime: new Date().toISOString(),
currentTime: dayjs().toISOString(),
}
}

View File

@@ -1,4 +1,5 @@
import { and, eq } from "drizzle-orm";
import dayjs from "dayjs";
import { useDrizzle } from "@/lib/db.js";
import { chargePoint, connector, meterValue } from "@/db/schema.js";
import type { MeterValuesRequest, MeterValuesResponse, OcppConnectionContext } from "../types.ts";
@@ -38,7 +39,7 @@ export async function handleMeterValues(
connectorId: conn.id,
chargePointId: cp.id,
connectorNumber: payload.connectorId,
timestamp: new Date(mv.timestamp),
timestamp: dayjs(mv.timestamp).toDate(),
sampledValues:
mv.sampledValue as unknown as (typeof meterValue.$inferInsert)["sampledValues"],
}));

View File

@@ -1,4 +1,5 @@
import { eq } from "drizzle-orm";
import dayjs from "dayjs";
import { useDrizzle } from "@/lib/db.js";
import { chargePoint, connector, transaction } from "@/db/schema.js";
import type {
@@ -35,16 +36,16 @@ export async function handleStartTransaction(
connectorId: payload.connectorId,
status: "Charging",
errorCode: "NoError",
lastStatusAt: new Date(),
lastStatusAt: dayjs().toDate(),
})
.onConflictDoUpdate({
target: [connector.chargePointId, connector.connectorId],
set: { status: "Charging", updatedAt: new Date() },
set: { status: "Charging", updatedAt: dayjs().toDate() },
})
.returning({ id: connector.id });
const rejected = idTagInfo.status !== "Accepted";
const now = new Date();
const now = dayjs();
// Insert transaction record regardless of auth status (OCPP spec requirement)
const [tx] = await db
@@ -55,12 +56,12 @@ export async function handleStartTransaction(
connectorNumber: payload.connectorId,
idTag: payload.idTag,
idTagStatus: idTagInfo.status,
startTimestamp: new Date(payload.timestamp),
startTimestamp: dayjs(payload.timestamp).toDate(),
startMeterValue: payload.meterStart,
reservationId: payload.reservationId ?? null,
// If rejected, immediately close the transaction so it doesn't appear as in-progress
...(rejected && {
stopTimestamp: now,
stopTimestamp: now.toDate(),
stopMeterValue: payload.meterStart,
chargeAmount: 0,
stopReason: "DeAuthorized",
@@ -72,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,4 +1,5 @@
import { eq } from 'drizzle-orm'
import dayjs from 'dayjs'
import { useDrizzle } from '@/lib/db.js'
import { chargePoint, connector, connectorStatusHistory } from '@/db/schema.js'
import type {
@@ -54,7 +55,7 @@ export async function handleStatusNotification(
throw new Error(`ChargePoint not found: ${ctx.chargePointIdentifier}`)
}
const statusTimestamp = payload.timestamp ? new Date(payload.timestamp) : new Date()
const statusTimestamp = payload.timestamp ? dayjs(payload.timestamp).toDate() : dayjs().toDate()
const connStatus = payload.status as ConnectorStatus
const connErrorCode = payload.errorCode as ConnectorErrorCode
@@ -81,7 +82,7 @@ export async function handleStatusNotification(
vendorId: payload.vendorId ?? null,
vendorErrorCode: payload.vendorErrorCode ?? null,
lastStatusAt: statusTimestamp,
updatedAt: new Date(),
updatedAt: dayjs().toDate(),
},
})
.returning({ id: connector.id })

View File

@@ -1,6 +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,
@@ -18,11 +22,16 @@ export async function handleStopTransaction(
const [tx] = await db
.update(transaction)
.set({
stopTimestamp: new Date(payload.timestamp),
stopTimestamp: dayjs(payload.timestamp).toDate(),
stopMeterValue: payload.meterStop,
stopIdTag: payload.idTag ?? null,
stopReason: (payload.reason as (typeof transaction.$inferSelect)["stopReason"]) ?? null,
updatedAt: new Date(),
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))
.returning();
@@ -35,7 +44,7 @@ export async function handleStopTransaction(
// Set connector back to Available
await db
.update(connector)
.set({ status: "Available", updatedAt: new Date() })
.set({ status: "Available", updatedAt: dayjs().toDate() })
.where(eq(connector.id, tx.connectorId));
// Store embedded meter values (transactionData)
@@ -49,7 +58,7 @@ export async function handleStopTransaction(
connectorId: tx.connectorId,
chargePointId: tx.chargePointId,
connectorNumber: tx.connectorNumber,
timestamp: new Date(mv.timestamp),
timestamp: dayjs(mv.timestamp).toDate(),
sampledValues:
mv.sampledValue as unknown as (typeof meterValue.$inferInsert)["sampledValues"],
},
@@ -63,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: new Date() })
.set({
chargeAmount: feeFen,
electricityFee: electricityFen,
serviceFee: serviceFeeFen,
updatedAt: dayjs().toDate(),
})
.where(eq(transaction.id, tx.id));
if (feeFen > 0) {
@@ -84,18 +176,66 @@ export async function handleStopTransaction(
.update(idTag)
.set({
balance: sql`${idTag.balance} - ${feeFen}`,
updatedAt: new Date(),
updatedAt: dayjs().toDate(),
})
.where(eq(idTag.idTag, tx.idTag));
}
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) {
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,7 +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>();
@@ -68,6 +71,8 @@ app.post("/", async (c) => {
chargePointModel?: string;
registrationStatus?: "Accepted" | "Pending" | "Rejected";
feePerKwh?: number;
pricingMode?: "fixed" | "tou";
deviceName?: string;
}>();
if (!body.chargePointIdentifier?.trim()) {
@@ -76,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)
@@ -86,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) => {
@@ -93,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 */
@@ -124,18 +146,22 @@ 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: new Date() };
} = { updatedAt: dayjs().toDate() };
if (body.feePerKwh !== undefined) {
if (!Number.isInteger(body.feePerKwh) || body.feePerKwh < 0) {
@@ -151,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)
@@ -188,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

@@ -1,5 +1,6 @@
import { Hono } from "hono";
import { desc, eq } from "drizzle-orm";
import dayjs from "dayjs";
import { useDrizzle } from "@/lib/db.js";
import { idTag } from "@/db/schema.js";
import { zValidator } from "@hono/zod-validator";
@@ -15,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 });
@@ -69,7 +72,7 @@ app.post("/", async (c) => {
.insert(idTag)
.values({
...parsed.data,
expiryDate: parsed.data.expiryDate ? new Date(parsed.data.expiryDate) : null,
expiryDate: parsed.data.expiryDate ? dayjs(parsed.data.expiryDate).toDate() : null,
})
.returning();
return c.json(created, 201);
@@ -120,8 +123,8 @@ app.patch("/:id", async (c) => {
.update(idTag)
.set({
...parsed.data,
expiryDate: parsed.data.expiryDate ? new Date(parsed.data.expiryDate) : undefined,
updatedAt: new Date(),
expiryDate: parsed.data.expiryDate ? dayjs(parsed.data.expiryDate).toDate() : undefined,
updatedAt: dayjs().toDate(),
})
.where(eq(idTag.idTag, tagId))
.returning();

View File

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

View File

@@ -0,0 +1,168 @@
import { Hono } from "hono";
import { sql } from "drizzle-orm";
import { useDrizzle } from "@/lib/db.js";
import { transaction, connector } from "@/db/schema.js";
import type { HonoEnv } from "@/types/hono.ts";
const app = new Hono<HonoEnv>();
/**
* GET /api/stats/chart?range=30d|7d|24h
* 返回时序图表数据按日30d/7d或小时24h分组
* 仅管理员可访问
*/
app.get("/", async (c) => {
const currentUser = c.get("user");
if (!currentUser || currentUser.role !== "admin") {
return c.json({ error: "Forbidden" }, 403);
}
const range = c.req.query("range") ?? "7d";
const db = useDrizzle();
// 真实插口数量connectorId >= 1至少为 1 防止除零
const [{ cnt }] = await db
.select({ cnt: sql<number>`greatest(count(*)::int, 1)` })
.from(connector)
.where(sql`${connector.connectorId} >= 1`);
if (range === "24h") {
// 按小时分组,最近 24 小时
const rows = await db.execute(sql`
SELECT
to_char(
date_trunc('hour', generate_series),
'YYYY-MM-DD"T"HH24:MI:SS"Z"'
) AS bucket,
coalesce(
(
SELECT coalesce(sum(${transaction.stopMeterValue} - ${transaction.startMeterValue}), 0)::float / 1000
FROM ${transaction}
WHERE date_trunc('hour', ${transaction.stopTimestamp} AT TIME ZONE 'UTC') = date_trunc('hour', generate_series)
AND ${transaction.stopTimestamp} IS NOT NULL
), 0
) AS energy_kwh,
coalesce(
(
SELECT coalesce(sum(${transaction.chargeAmount}), 0)::float / 100
FROM ${transaction}
WHERE date_trunc('hour', ${transaction.stopTimestamp} AT TIME ZONE 'UTC') = date_trunc('hour', generate_series)
AND ${transaction.stopTimestamp} IS NOT NULL
), 0
) AS revenue,
coalesce(
(
SELECT count(*)::int
FROM ${transaction}
WHERE date_trunc('hour', ${transaction.stopTimestamp} AT TIME ZONE 'UTC') = date_trunc('hour', generate_series)
AND ${transaction.stopTimestamp} IS NOT NULL
), 0
) AS tx_count,
round(coalesce(
(
SELECT extract(epoch from sum(
least(coalesce(t.stop_timestamp, now()), date_trunc('hour', generate_series) + interval '1 hour')
- greatest(t.start_timestamp, date_trunc('hour', generate_series))
)) / (${sql.raw(String(cnt))} * 3600.0) * 100
FROM "transaction" t
WHERE t.start_timestamp < date_trunc('hour', generate_series) + interval '1 hour'
AND (t.stop_timestamp IS NULL OR t.stop_timestamp > date_trunc('hour', generate_series))
), 0
)::numeric, 1) AS utilization_pct
FROM generate_series(
date_trunc('hour', now() AT TIME ZONE 'UTC') - interval '23 hours',
date_trunc('hour', now() AT TIME ZONE 'UTC'),
interval '1 hour'
) AS generate_series
ORDER BY bucket ASC
`);
type Row24h = {
bucket: string;
energy_kwh: number;
revenue: number;
tx_count: number;
utilization_pct: number;
};
return c.json(
(rows.rows as Row24h[]).map((r) => ({
bucket: r.bucket,
energyKwh: Number(r.energy_kwh),
revenue: Number(r.revenue),
transactions: Number(r.tx_count),
utilizationPct: Number(r.utilization_pct),
})),
);
}
// 按天分组7d 或 30d
const days = range === "30d" ? 29 : 6;
const rows = await db.execute(sql`
SELECT
to_char(
date_trunc('day', generate_series),
'YYYY-MM-DD"T"HH24:MI:SS"Z"'
) AS bucket,
coalesce(
(
SELECT coalesce(sum(${transaction.stopMeterValue} - ${transaction.startMeterValue}), 0)::float / 1000
FROM ${transaction}
WHERE date_trunc('day', ${transaction.stopTimestamp} AT TIME ZONE 'UTC') = date_trunc('day', generate_series)
AND ${transaction.stopTimestamp} IS NOT NULL
), 0
) AS energy_kwh,
coalesce(
(
SELECT coalesce(sum(${transaction.chargeAmount}), 0)::float / 100
FROM ${transaction}
WHERE date_trunc('day', ${transaction.stopTimestamp} AT TIME ZONE 'UTC') = date_trunc('day', generate_series)
AND ${transaction.stopTimestamp} IS NOT NULL
), 0
) AS revenue,
coalesce(
(
SELECT count(*)::int
FROM ${transaction}
WHERE date_trunc('day', ${transaction.stopTimestamp} AT TIME ZONE 'UTC') = date_trunc('day', generate_series)
AND ${transaction.stopTimestamp} IS NOT NULL
), 0
) AS tx_count,
round(coalesce(
(
SELECT extract(epoch from sum(
least(coalesce(t.stop_timestamp, now()), date_trunc('day', generate_series) + interval '1 day')
- greatest(t.start_timestamp, date_trunc('day', generate_series))
)) / (${sql.raw(String(cnt))} * 86400.0) * 100
FROM "transaction" t
WHERE t.start_timestamp < date_trunc('day', generate_series) + interval '1 day'
AND (t.stop_timestamp IS NULL OR t.stop_timestamp > date_trunc('day', generate_series))
), 0
)::numeric, 1) AS utilization_pct
FROM generate_series(
date_trunc('day', now() AT TIME ZONE 'UTC') - interval '${sql.raw(String(days))} days',
date_trunc('day', now() AT TIME ZONE 'UTC'),
interval '1 day'
) AS generate_series
ORDER BY bucket ASC
`);
type RowDay = {
bucket: string;
energy_kwh: number;
revenue: number;
tx_count: number;
utilization_pct: number;
};
return c.json(
(rows.rows as RowDay[]).map((r) => ({
bucket: r.bucket,
energyKwh: Number(r.energy_kwh),
revenue: Number(r.revenue),
transactions: Number(r.tx_count),
utilizationPct: Number(r.utilization_pct),
})),
);
});
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

@@ -1,13 +1,119 @@
import { Hono } from "hono";
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 { ocppConnections } from "@/ocpp/handler.js";
import { OCPP_MESSAGE_TYPE } from "@/ocpp/types.js";
import type { SampledValue } from "@/db/schema.js";
import { user } from "@/db/auth-schema.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>();
/**
* POST /api/transactions/remote-start
* Send RemoteStartTransaction to a charge point.
* Non-admin users can only use their own id-tags.
*/
app.post("/remote-start", async (c) => {
const currentUser = c.get("user");
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);
if (
!body ||
!body.chargePointIdentifier?.trim() ||
!Number.isInteger(body.connectorId) ||
body.connectorId < 1 ||
!body.idTag?.trim()
) {
return c.json(
{ error: "chargePointIdentifier, connectorId (>=1), and idTag are required" },
400,
);
}
// Non-admin: verify idTag belongs to current user
if (currentUser.role !== "admin") {
const [tag] = await db
.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);
}
// 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
const [cp] = await db
.select({ id: chargePoint.id, registrationStatus: chargePoint.registrationStatus })
.from(chargePoint)
.where(eq(chargePoint.chargePointIdentifier, body.chargePointIdentifier.trim()))
.limit(1);
if (!cp) return c.json({ error: "ChargePoint not found" }, 404);
if (cp.registrationStatus !== "Accepted") {
return c.json({ error: "ChargePoint is not accepted" }, 400);
}
try {
const response = await sendOcppCall<
{ connectorId: number; idTag: string },
{ status?: string }
>(body.chargePointIdentifier.trim(), "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} ` +
`connector=${body.connectorId} idTag=${body.idTag} user=${currentUser.id}`,
);
return c.json({ success: true });
});
/** GET /api/transactions?page=1&limit=20&status=active|completed&chargePointId=... */
app.get("/", async (c) => {
const page = Math.max(1, Number(c.req.query("page") ?? 1));
@@ -46,26 +152,92 @@ 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,
})
.from(transaction)
.leftJoin(chargePoint, eq(transaction.chargePointId, chargePoint.id))
.leftJoin(connector, eq(transaction.connectorId, connector.id))
.leftJoin(idTag, eq(transaction.idTag, idTag.idTag))
.leftJoin(user, eq(idTag.userId, user.id))
.where(whereClause)
.orderBy(desc(transaction.startTimestamp))
.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) => ({
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)),
@@ -81,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();
@@ -115,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))
@@ -125,57 +340,76 @@ app.post("/:id/stop", async (c) => {
if (!row) return c.json({ error: "Not found" }, 404);
if (row.transaction.stopTimestamp) return c.json({ error: "Transaction already stopped" }, 409);
const now = new Date();
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 },
]),
{ 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,
);
console.log(`[OCPP] Sent RemoteStopTransaction txId=${id} to ${row.chargePointIdentifier}`);
}
// 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;
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,
);
}
}
const [updated] = await db
.update(transaction)
.set({
stopTimestamp: now,
stopMeterValue,
stopReason: "Remote",
chargeAmount: feeFen,
updatedAt: now,
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,
})
.where(eq(idTag.idTag, row.transaction.idTag));
}
return c.json({
...updated,
chargePointIdentifier: row.chargePointIdentifier,
online: !!ws,
energyWh,
online,
remoteStopStatus,
});
});
@@ -197,7 +431,7 @@ app.delete("/:id", async (c) => {
if (!row.transaction.stopTimestamp) {
await db
.update(connector)
.set({ status: "Available", updatedAt: new Date() })
.set({ status: "Available", updatedAt: dayjs().toDate() })
.where(eq(connector.id, row.transaction.connectorId));
}

View File

@@ -1,5 +1,6 @@
import { Hono } from "hono";
import { desc, eq } from "drizzle-orm";
import dayjs from "dayjs";
import { useDrizzle } from "@/lib/db.js";
import { user } from "@/db/schema.js";
import { zValidator } from "@hono/zod-validator";
@@ -57,7 +58,7 @@ app.patch("/:id", zValidator("json", userUpdateSchema), async (c) => {
.update(user)
.set({
...body,
updatedAt: new Date(),
updatedAt: dayjs().toDate(),
})
.where(eq(user.id, userId))
.returning({

134
apps/web/.dockerignore Normal file
View File

@@ -0,0 +1,134 @@
############################################################
# Production-ready .dockerignore for a Next.js (Vercel-style) app
# Keeps Docker builds fast, lean, and free of development files.
############################################################
# Dependencies (installed inside Docker, never copied)
node_modules/
.pnpm-store/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Next.js build outputs (always generated during `next build`)
.next/
out/
dist/
build/
.vercel/
# Tests and testing output (not needed in production images)
coverage/
.nyc_output/
__tests__/
__mocks__/
jest/
cypress/
cypress/screenshots/
cypress/videos/
playwright-report/
test-results/
.vitest/
vitest.config.*
jest.config.*
cypress.config.*
playwright.config.*
*.test.*
*.spec.*
# Local development and editor files
.git/
.gitignore
.gitattributes
.vscode/
.idea/
*.swp
*.swo
*~
*.log
# Environment variables (only commit template files)
.env
.env*.local
.env.development
.env.test
.env.production.local
# Docker configuration files (not needed inside build context)
Dockerfile*
.dockerignore
compose.yaml
compose.yml
docker-compose*.yaml
docker-compose*.yml
# Documentation
*.md
docs/
# CI/CD configuration files
.github/
.gitlab-ci.yml
.travis.yml
.circleci/
Jenkinsfile
# Cache directories and temporary data
.cache/
.parcel-cache/
.eslintcache
.stylelintcache
.swc/
.turbo/
.tmp/
.temp/
# TypeScript build metadata
*.tsbuildinfo
# Sensitive or unnecessary configuration files
*.pem
.editorconfig
.prettierrc*
prettier.config.*
.eslintrc*
eslint.config.*
.stylelintrc*
stylelint.config.*
.babelrc*
*.iml
*.ipr
*.iws
# OS-specific junk
.DS_Store
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
Desktop.ini
# AI/ML tool metadata and configs
.cursor/
.cursorrules
.copilot/
.copilotignore
.github/copilot/
.gemini/
.anthropic/
.kiro
.claude
AGENTS.md
.agents/
# AI-generated temp files
*.aider*
*.copilot*
*.chatgpt*
*.claude*
*.gemini*
*.openai*
*.anthropic*

62
apps/web/Dockerfile Normal file
View File

@@ -0,0 +1,62 @@
FROM node:22-alpine AS base
# ============================================
# Stage 1: Install dependencies
# ============================================
FROM base AS builder
RUN apk add --no-cache gcompat
RUN corepack enable && corepack prepare pnpm@10.18.2 --activate
WORKDIR /app
# 复制 monorepo 根配置catalog、lockfile 等)
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
# 复制 web 应用源码
COPY apps/web/package.json ./apps/web/package.json
COPY apps/web/next.config.ts ./apps/web/next.config.ts
COPY apps/web/tsconfig.json ./apps/web/tsconfig.json
COPY apps/web/postcss.config.mjs ./apps/web/postcss.config.mjs
COPY apps/web/app ./apps/web/app
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/proxy.ts ./apps/web/proxy.ts
RUN pnpm install --filter web
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ARG NEXT_PUBLIC_CSMS_URL
ENV NEXT_PUBLIC_CSMS_URL=${NEXT_PUBLIC_CSMS_URL}
RUN pnpm --filter web run build
# ============================================
# Stage 2: Run Next.js application
# ============================================
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./public
RUN mkdir .next && chown nextjs:nodejs .next
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "apps/web/server.js"]

View File

@@ -15,10 +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 ────────────────────────────────────────────────────────────
@@ -60,8 +64,7 @@ const TX_LIMIT = 10;
function formatDuration(start: string, stop: string | null): string {
if (!stop) return "进行中";
const ms = new Date(stop).getTime() - new Date(start).getTime();
const min = Math.floor(ms / 60000);
const min = dayjs(stop).diff(dayjs(start), "minute");
if (min < 60) return `${min} 分钟`;
const h = Math.floor(min / 60);
const m = min % 60;
@@ -69,23 +72,17 @@ function formatDuration(start: string, stop: string | null): string {
}
function relativeTime(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const s = Math.floor(diff / 1000);
if (s < 60) return `${s} 秒前`;
const m = Math.floor(s / 60);
if (m < 60) return `${m} 分钟前`;
const h = Math.floor(m / 60);
if (h < 24) return `${h} 小时前`;
const d = Math.floor(h / 24);
return `${d} 天前`;
return dayjs(iso).fromNow();
}
// ── Edit form type ─────────────────────────────────────────────────────────
type EditForm = {
deviceName: string;
chargePointVendor: string;
chargePointModel: string;
registrationStatus: "Accepted" | "Pending" | "Rejected";
pricingMode: "fixed" | "tou";
feePerKwh: string;
};
@@ -101,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),
@@ -126,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);
@@ -143,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);
@@ -154,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 &&
Date.now() - new Date(cp.lastHeartbeatAt).getTime() < (cp.heartbeatInterval ?? 60) * 3 * 1000;
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";
@@ -204,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"
@@ -216,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) && (
@@ -232,10 +269,21 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
<ArrowRotateRight className={`size-4 ${refreshing ? "animate-spin" : ""}`} />
</Button>
{isAdmin && (
<>
<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>
@@ -273,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 },
@@ -294,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>
@@ -317,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">
@@ -343,7 +391,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
<dt className="shrink-0 text-sm text-muted"></dt>
<dd className="text-right text-sm text-foreground">
{cp.lastHeartbeatAt ? (
<span title={new Date(cp.lastHeartbeatAt).toLocaleString("zh-CN")}>
<span title={dayjs(cp.lastHeartbeatAt).format("YYYY/M/D HH:mm:ss")}>
{relativeTime(cp.lastHeartbeatAt)}
</span>
) : (
@@ -355,7 +403,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
<dt className="shrink-0 text-sm text-muted"></dt>
<dd className="text-right text-sm text-foreground">
{cp.lastBootNotificationAt ? (
<span title={new Date(cp.lastBootNotificationAt).toLocaleString("zh-CN")}>
<span title={dayjs(cp.lastBootNotificationAt).format("YYYY/M/D HH:mm:ss")}>
{relativeTime(cp.lastBootNotificationAt)}
</span>
) : (
@@ -366,22 +414,23 @@ 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">
{new Date(cp.createdAt).toLocaleDateString("zh-CN")}
{dayjs(cp.createdAt).format("YYYY/M/D")}
</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>
@@ -396,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>
@@ -418,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>
@@ -434,15 +483,10 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
{conn.errorCode && conn.errorCode !== "NoError" && (
<p className="text-xs text-danger">: {conn.errorCode}</p>
)}
{conn.info && <p className="text-xs text-muted">{conn.info}</p>}
{/* {conn.info && <p className="text-xs text-muted">{conn.info}</p>} */}
<p className="text-xs text-muted">
{" "}
{new Date(conn.lastStatusAt).toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}
{dayjs(conn.lastStatusAt).format("MM/DD HH:mm")}
</p>
</div>
))}
@@ -489,12 +533,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
</Table.Cell>
<Table.Cell className="font-mono">{tx.idTag}</Table.Cell>
<Table.Cell className="tabular-nums text-sm">
{new Date(tx.startTimestamp).toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}
{dayjs(tx.startTimestamp).format("MM/DD HH:mm")}
</Table.Cell>
<Table.Cell>{formatDuration(tx.startTimestamp, tx.stopTimestamp)}</Table.Cell>
<Table.Cell className="tabular-nums">
@@ -556,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
@@ -572,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>
@@ -619,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"
@@ -630,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

@@ -1,11 +1,12 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import {
Button,
Chip,
Input,
InputGroup,
Label,
ListBox,
Modal,
@@ -13,12 +14,24 @@ import {
Spinner,
Table,
TextField,
Tooltip,
} from "@heroui/react";
import { Plus, Pencil, PlugConnection, TrashBin, ArrowRotateRight } from "@gravity-ui/icons";
import {
Plus,
Pencil,
PlugConnection,
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";
const statusLabelMap: Record<string, string> = {
Available: "空闲中",
@@ -87,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",
};
@@ -108,6 +125,9 @@ export default function ChargePointsPage() {
const [formBusy, setFormBusy] = useState(false);
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,
@@ -128,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);
@@ -147,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);
}
} finally {
setFormBusy(false);
}
@@ -180,9 +209,21 @@ 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";
const [qrOrigin, setQrOrigin] = useState("");
useEffect(() => {
setQrOrigin(window.location.origin);
}, []);
return (
<div className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
@@ -237,8 +278,16 @@ export default function ChargePointsPage() {
}
/>
</TextField>
<div className="grid grid-cols-2 gap-3">
<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 isReadOnly={isEdit}>
<Label className="text-sm font-medium"></Label>
<Input
placeholder="ABB"
@@ -248,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"
@@ -284,17 +333,45 @@ export default function ChargePointsPage() {
</Select.Popover>
</Select>
</div>
<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>
<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 }))}
onChange={(e) =>
setFormData((f) => ({ ...f, feePerKwh: e.target.value }))
}
/>
</TextField>
)}
{!isEdit && (
<p className="text-xs text-muted">
Pending Accepted 线
@@ -319,14 +396,164 @@ export default function ChargePointsPage() {
</>
)}
{/* QR Code Modal */}
{isAdmin && (
<Modal
isOpen={qrTarget !== null}
onOpenChange={(open) => {
if (!open) setQrTarget(null);
}}
>
<Modal.Backdrop>
<Modal.Container scroll="outside">
<Modal.Dialog className="sm:max-w-lg">
<Modal.CloseTrigger />
<Modal.Header>
<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">
{qrTarget?.connectors
.filter((c) => c.connectorId > 0)
.sort((a, b) => a.connectorId - b.connectorId)
.map((conn) => {
const url = `${qrOrigin}/dashboard/charge?cpId=${qrTarget.id}&connector=${conn.connectorId}`;
return (
<div
key={conn.id}
className="flex flex-col items-center gap-2 rounded-xl border border-border p-3"
>
<p className="text-xs font-medium text-foreground">
#{conn.connectorId}
</p>
<QRCodeSVG value={url} size={120} className="rounded" />
<p className="break-all text-center font-mono text-[9px] text-muted leading-tight">
{url}
</p>
</div>
);
})}
</div>
</Modal.Body>
<Modal.Footer className="flex justify-end">
<Button variant="ghost" onPress={() => setQrTarget(null)}>
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</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>
@@ -347,15 +574,43 @@ export default function ChargePointsPage() {
{isAdmin && <Table.Cell>{""}</Table.Cell>}
</Table.Row>
)}
{chargePoints.map((cp) => (
{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 ${
online ? "bg-success" : commandChannelUnavailable ? "bg-warning" : "bg-gray-300"
}`}
/>
<div className="flex flex-col">
<Link
href={`/dashboard/charge-points/${cp.id}`}
className="font-medium text-accent"
>
{cp.chargePointIdentifier}
{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">
{online ? "在线" : commandChannelUnavailable ? "通道异常" : cp.lastHeartbeatAt ? "离线" : "从未连接"}
</Tooltip.Content>
</Tooltip>
</Table.Cell>
{isAdmin && (
<Table.Cell>
@@ -386,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">
@@ -400,7 +657,7 @@ export default function ChargePointsPage() {
</Table.Cell>
<Table.Cell>
{cp.lastHeartbeatAt ? (
new Date(cp.lastHeartbeatAt).toLocaleString("zh-CN")
dayjs(cp.lastHeartbeatAt).format("YYYY/M/D HH:mm:ss")
) : (
<span className="text-muted"></span>
)}
@@ -435,6 +692,15 @@ export default function ChargePointsPage() {
>
<Pencil className="size-4" />
</Button>
<Button
isIconOnly
size="sm"
variant="tertiary"
onPress={() => setQrTarget(cp)}
aria-label="查看二维码"
>
<QrCode className="size-4" />
</Button>
<Modal>
<Button
isIconOnly
@@ -454,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>
@@ -481,7 +752,8 @@ export default function ChargePointsPage() {
</Table.Cell>
)}
</Table.Row>
))}
);
})}
</Table.Body>
</Table.Content>
</Table.ScrollContainer>

View File

@@ -0,0 +1,891 @@
"use client";
import { useState, useEffect, useRef, Fragment, Suspense, useMemo } from "react";
import { useSearchParams } from "next/navigation";
import { useQuery, useMutation } from "@tanstack/react-query";
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) ────────────────────────────────
const statusLabelMap: Record<string, string> = {
Available: "空闲中",
Charging: "充电中",
Preparing: "准备中",
Finishing: "结束中",
SuspendedEV: "EV 暂停",
SuspendedEVSE: "EVSE 暂停",
Reserved: "已预约",
Faulted: "故障",
Unavailable: "不可用",
Occupied: "占用",
};
// ── Step indicator ───────────────────────────────────────────────────────────
function StepBar({ step, onGoBack }: { step: number; onGoBack: (target: number) => void }) {
const labels = ["选择充电桩", "选择充电口", "选择储值卡"];
return (
<div className="flex w-full items-center">
{labels.map((label, i) => {
const idx = i + 1;
const isActive = step === idx;
const isDone = idx < step;
const isLast = i === labels.length - 1;
return (
<Fragment key={i}>
<button
type="button"
onClick={() => isDone && onGoBack(idx)}
disabled={!isDone}
className={[
"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-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 shadow-md shadow-accent/30"
: isDone
? "bg-success text-white ring-success"
: "bg-surface-tertiary text-muted ring-transparent",
].join(" ")}
>
{isDone ? <Check className="size-4" /> : idx}
</span>
<span
className={[
"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 mb-3.5">
<span
className={[
"block h-0.5 w-full rounded-full transition-colors duration-300",
isDone ? "bg-success" : "bg-border",
].join(" ")}
/>
</div>
)}
</Fragment>
);
})}
</div>
);
}
// ── QR Scanner ───────────────────────────────────────────────────────────────
type ScannerProps = {
onResult: (raw: string) => void;
onClose: () => void;
};
function QrScanner({ onResult, onClose }: ScannerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const scanningRef = useRef(true);
const mountedRef = useRef(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
mountedRef.current = true;
scanningRef.current = true;
let detector: any = null;
async function start() {
let stream: MediaStream;
try {
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "environment" },
});
} catch (err: any) {
if (mountedRef.current) setError("无法访问摄像头:" + (err?.message ?? "未知错误"));
return;
}
if (!mountedRef.current) {
stream.getTracks().forEach((t) => t.stop());
return;
}
streamRef.current = stream;
if (videoRef.current) {
videoRef.current.srcObject = stream;
try {
await videoRef.current.play();
} catch (err: any) {
// AbortError fires when the element is removed mid-play (e.g. Modal animation).
// It is not a real error — just bail out silently.
if (err?.name === "AbortError") return;
if (mountedRef.current) setError("无法播放摄像头画面:" + (err?.message ?? "未知错误"));
return;
}
}
if (!mountedRef.current) return;
const useNative = "BarcodeDetector" in window;
if (useNative) {
detector = new (window as any).BarcodeDetector({ formats: ["qr_code"] });
}
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const scan = async () => {
if (!scanningRef.current || !videoRef.current) return;
try {
if (useNative) {
const codes: Array<{ rawValue: string }> = await detector.detect(videoRef.current);
if (codes.length > 0) {
onResult(codes[0].rawValue);
return;
}
} else if (ctx) {
const video = videoRef.current;
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const code = jsQR(imageData.data, imageData.width, imageData.height);
if (code) {
onResult(code.data);
return;
}
}
} catch {}
requestAnimationFrame(scan);
};
requestAnimationFrame(scan);
}
start();
return () => {
mountedRef.current = false;
scanningRef.current = false;
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
};
}, [onResult]);
return (
<div className="relative h-full w-full overflow-hidden bg-black">
{error ? (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<p className="text-sm text-danger">{error}</p>
</div>
) : (
<>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video ref={videoRef} className="h-full w-full object-cover" playsInline muted />
{/* Aim overlay */}
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="size-56 rounded-2xl border-2 border-white/80 shadow-[0_0_0_9999px_rgba(0,0,0,0.45)]" />
</div>
<p className="absolute bottom-8 left-0 right-0 text-center text-sm text-white/80">
</p>
</>
)}
{/* Close button — overlaid top-right */}
<button
type="button"
onClick={onClose}
className="absolute right-4 top-4 flex size-9 items-center justify-center rounded-full bg-black/50 text-white backdrop-blur-sm hover:bg-black/70"
aria-label="关闭扫描"
>
<Xmark className="size-5" />
</button>
</div>
);
}
// ── Main Page ────────────────────────────────────────────────────────────────
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);
const [selectedConnectorId, setSelectedConnectorId] = useState<number | null>(null);
const [selectedIdTag, setSelectedIdTag] = useState<string | null>(null);
const [showScanner, setShowScanner] = useState(false);
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);
useEffect(() => {
setIsMobile(/Mobi|Android|iPhone|iPad/i.test(navigator.userAgent));
}, []);
// Pre-fill from URL params (QR code redirect)
useEffect(() => {
const cpId = searchParams.get("cpId");
const connector = searchParams.get("connector");
if (cpId) {
setSelectedCpId(cpId);
if (connector && !Number.isNaN(Number(connector))) {
setSelectedConnectorId(Number(connector));
setStep(3);
} else {
setStep(2);
}
}
}, [searchParams]);
const { data: chargePoints = [], isLoading: cpLoading } = useQuery({
queryKey: ["chargePoints"],
queryFn: () => api.chargePoints.list().catch(() => []),
refetchInterval: 5_000,
});
const { data: idTags = [], isLoading: tagsLoading } = useQuery({
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 () => {
if (!selectedCp || selectedConnectorId === null || !selectedIdTag) {
throw new Error("请先完成所有选择");
}
return api.transactions.remoteStart({
chargePointIdentifier: selectedCp.chargePointIdentifier,
connectorId: selectedConnectorId,
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 ?? "";
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 handleScanResult(raw: string) {
setShowScanner(false);
setScanError(null);
try {
// Support both helios:// scheme and https:// web URLs
const url = new URL(raw);
const cpId = url.searchParams.get("cpId");
const connector = url.searchParams.get("connector");
if (!cpId) {
setScanError("二维码内容无效,缺少充电桩信息");
return;
}
setSelectedCpId(cpId);
if (connector && !Number.isNaN(Number(connector))) {
setSelectedConnectorId(Number(connector));
setStep(3);
} else {
setSelectedConnectorId(null);
setStep(2);
}
} catch {
setScanError("二维码内容格式错误");
}
}
// ── Success screen ─────────────────────────────────────────────────────────
if (startResult === "success") {
return (
<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 className="space-y-2">
<h2 className="text-2xl font-bold text-foreground"></h2>
<p className="text-sm text-muted leading-relaxed"></p>
</div>
<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-5 pb-4">
{/* Header */}
<div className="flex items-center justify-between gap-3">
<div>
<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
type="button"
onClick={() => {
setScanError(null);
setShowScanner(true);
}}
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-5" />
<span className="text-[10px] font-medium leading-none"></span>
</button>
)}
</div>
{/* QR Scanner Modal */}
<Modal
isOpen={showScanner}
onOpenChange={(open) => {
if (!open) setShowScanner(false);
}}
>
<Modal.Backdrop>
<Modal.Container scroll="outside" size="full">
<Modal.Dialog className="h-full overflow-hidden p-0">
<QrScanner onResult={handleScanResult} onClose={() => setShowScanner(false)} />
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
<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)} />
{/* ── Step 1: Select charge point ──────────────────────────────── */}
{step === 1 && (
<div className="space-y-3">
{cpLoading ? (
<div className="flex justify-center py-16">
<Spinner />
</div>
) : chargePoints.filter((cp) => cp.registrationStatus === "Accepted").length === 0 ? (
<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-3 sm:grid-cols-2 lg:grid-cols-3">
{chargePoints
.filter((cp) => cp.registrationStatus === "Accepted")
.map((cp) => {
const online =
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;
const disabled = !online || availableCount === 0;
return (
<button
key={cp.id}
type="button"
disabled={disabled}
onClick={() => {
setSelectedCpId(cp.id);
setSelectedConnectorId(null);
setStep(2);
}}
className={[
"flex flex-col gap-3 rounded-2xl border p-4 text-left transition-all",
disabled
? "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(" ")}
>
{/* 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>
)}
</div>
</button>
);
})}
</div>
)}
</div>
)}
{/* ── Step 2: Select connector ──────────────────────────────────── */}
{step === 2 && (
<div className="space-y-4">
{/* Context pill */}
{selectedCp && (
<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>
</div>
)}
{selectedCp && selectedCp.connectors.filter((c) => c.connectorId > 0).length === 0 ? (
<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-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)
.map((conn) => {
const available = conn.status === "Available";
return (
<button
key={conn.id}
type="button"
disabled={!available}
onClick={() => {
if (available) {
setSelectedConnectorId(conn.connectorId);
setStep(3);
}
}}
className={[
"relative flex flex-col items-center gap-3 rounded-2xl border py-5 px-3 text-center transition-all",
!available
? "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(" ")}
>
<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-4">
{/* Context pills */}
<div className="flex flex-wrap gap-2">
{selectedCp && (
<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>
</div>
)}
{selectedConnectorId !== null && (
<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-16">
<Spinner />
</div>
) : myTags.length === 0 ? (
<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-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>
)}
{/* 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()}
>
{startMutation.isPending ? (
<Spinner size="sm" />
) : (
<ThunderboltFill className="size-4" />
)}
{startMutation.isPending ? "发送中…" : "启动充电"}
</Button>
</div>
</div>
)}
</div>
);
}
export default function ChargePage() {
return (
<Suspense>
<ChargePageContent />
</Suspense>
);
}

View File

@@ -24,8 +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",
@@ -42,6 +45,8 @@ type FormState = {
parentIdTag: string;
userId: string;
balance: string;
cardLayout: string;
cardSkin: string;
};
const emptyForm: FormState = {
@@ -51,6 +56,8 @@ const emptyForm: FormState = {
parentIdTag: "",
userId: "",
balance: "0",
cardLayout: "around",
cardSkin: "circles",
};
const STATUS_OPTIONS = ["Accepted", "Blocked", "Expired", "Invalid", "ConcurrentTx"] as const;
@@ -324,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>
</>
);
}
@@ -336,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,
@@ -343,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(),
@@ -380,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",
});
};
@@ -393,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({
@@ -402,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();
@@ -439,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" />
@@ -481,6 +563,146 @@ export default function IdTagsPage() {
)}
</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 (
<div key={tag.idTag} className="space-y-2">
<IdTagCard
idTag={tag.idTag}
balance={tag.balance}
layout={tag.cardLayout ?? undefined}
skin={tag.cardSkin ?? undefined}
/>
{isAdmin && (
<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
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>
<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>
</div>
)}
</div>
);
})}
</div>
)}
</>
)}
{/* ── Table view (admin only) ────────────────────────────────── */}
{isAdmin && viewMode === "table" && (
<Table>
<Table.ScrollContainer>
<Table.Content aria-label="储值卡列表" className="min-w-200">
@@ -529,7 +751,7 @@ export default function IdTagsPage() {
)}
<Table.Cell>
{tag.expiryDate ? (
new Date(tag.expiryDate).toLocaleDateString("zh-CN")
dayjs(tag.expiryDate).format("YYYY/M/D")
) : (
<span className="text-muted"></span>
)}
@@ -538,7 +760,7 @@ export default function IdTagsPage() {
{tag.parentIdTag ?? <span className="text-muted"></span>}
</Table.Cell>
<Table.Cell className="text-sm">
{new Date(tag.createdAt).toLocaleString("zh-CN")}
{dayjs(tag.createdAt).format("YYYY/M/D HH:mm:ss")}
</Table.Cell>
{isAdmin && (
<Table.Cell>
@@ -642,6 +864,7 @@ export default function IdTagsPage() {
</Table.Content>
</Table.ScrollContainer>
</Table>
)}
</div>
);
}

View File

@@ -1,35 +1,42 @@
"use client";
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";
import { AreaChart } from "@tremor/react";
import dayjs from "@/lib/dayjs";
import { useSession } from "@/lib/auth-client";
import { api, type Stats, type UserStats, type Transaction, type ChargePoint } from "@/lib/api";
import {
api,
type Stats,
type UserStats,
type Transaction,
type ChargePoint,
type ChartRange,
type ChartDataPoint,
} from "@/lib/api";
import { BanknoteArrowDown, EvCharger, Plug, Users } from "lucide-react";
import MetricIndicator from "@/components/metric-indicator";
// ── Helpers ────────────────────────────────────────────────────────────────
function timeAgo(dateStr: string | null | undefined): string {
if (!dateStr) return "—";
const ms = Date.now() - new Date(dateStr).getTime();
if (ms < 60_000) return "刚刚";
if (ms < 3_600_000) return `${Math.floor(ms / 60_000)} 分钟前`;
if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)} 小时前`;
return new Date(dateStr).toLocaleDateString("zh-CN", { month: "short", day: "numeric" });
return dayjs(dateStr).fromNow();
}
function cpOnline(cp: ChargePoint): boolean {
if (!cp.lastHeartbeatAt) return false;
return Date.now() - new Date(cp.lastHeartbeatAt).getTime() < 120_000;
if (cp.transportStatus !== "online" || !cp.lastHeartbeatAt) return false;
return dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120;
}
// ── StatCard ───────────────────────────────────────────────────────────────
@@ -64,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 && (
<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>
)}
</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}
</div>
)}
</Card.Content>
</Card>
) : undefined
}
footer={footer}
/>
);
}
@@ -98,9 +101,191 @@ function Panel({ title, children }: { title: string; children: React.ReactNode }
);
}
// ── TrendChart ─────────────────────────────────────────────────────────────
function formatBucket(bucket: string, range: ChartRange): string {
const d = dayjs(bucket);
return range === "24h" ? d.format("HH:mm") : d.format("MM/DD");
}
const RANGES: { label: string; value: ChartRange }[] = [
{ label: "24 小时", value: "24h" },
{ label: "7 天", value: "7d" },
{ label: "30 天", value: "30d" },
];
type ChartView = "economy" | "activity";
const SERIES_CONFIG = {
revenue: {
color: "sky" as const,
label: "营收",
unit: "¥",
fmt: (v: number) => (v === 0 ? "¥0" : `¥${v.toFixed(v < 10 ? 2 : 1)}`),
},
energyKwh: {
color: "emerald" as const,
label: "充电量",
unit: "kWh",
fmt: (v: number) => (v === 0 ? "0 kWh" : `${v.toFixed(v < 10 ? 2 : 1)} kWh`),
},
transactions: {
color: "violet" as const,
label: "充电次数",
unit: "次",
fmt: (v: number) => `${v}`,
},
utilizationPct: {
color: "amber" as const,
label: "利用率",
unit: "%",
fmt: (v: number) => `${v.toFixed(1)}%`,
},
} as const;
const VIEW_SERIES: Record<ChartView, (keyof typeof SERIES_CONFIG)[]> = {
economy: ["revenue", "energyKwh"],
activity: ["transactions", "utilizationPct"],
};
function TrendChart() {
const [range, setRange] = useState<ChartRange>("7d");
const [view, setView] = useState<ChartView>("economy");
const [hidden, setHidden] = useState<Set<string>>(new Set());
const toggle = (key: string) =>
setHidden((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
const switchView = (v: ChartView) => {
setView(v);
setHidden(new Set());
};
const allSeriesForView = VIEW_SERIES[view];
const visibleSeries = allSeriesForView.filter((k) => !hidden.has(k));
const visibleColors = visibleSeries.map((k) => SERIES_CONFIG[k].color);
const { data, isPending } = useQuery<ChartDataPoint[]>({
queryKey: ["stats-chart", range],
queryFn: () => api.stats.chart(range),
staleTime: 60_000,
});
return (
<div className="rounded-xl border border-border bg-surface-secondary overflow-hidden">
{/* Header */}
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-5 py-3.5">
{/* View toggle */}
<div className="flex gap-1">
{(["economy", "activity"] as ChartView[]).map((v) => (
<button
key={v}
onClick={() => switchView(v)}
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
view === v
? "bg-accent-soft text-accent"
: "text-muted hover:bg-default hover:text-foreground"
}`}
>
{v === "economy" ? "营收 & 电量" : "次数 & 利用率"}
</button>
))}
</div>
{/* Range toggle */}
<div className="flex gap-1">
{RANGES.map((r) => (
<button
key={r.value}
onClick={() => setRange(r.value)}
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
range === r.value
? "bg-accent text-white"
: "text-muted hover:bg-default hover:text-foreground"
}`}
>
{r.label}
</button>
))}
</div>
</div>
{/* Chart */}
<div className="px-4 py-4">
{isPending ? (
<div className="flex h-56 items-center justify-center">
<Spinner />
</div>
) : (
<AreaChart
data={(data ?? []).map((p) => ({ ...p, label: formatBucket(p.bucket, range) }))}
index="label"
categories={visibleSeries}
colors={visibleColors}
valueFormatter={(v) => `${v}`}
customTooltip={(props) => {
if (!props.active || !props.payload?.length) return null;
return (
<div className="rounded-lg border border-border bg-overlay px-3 py-2 shadow-md text-xs">
<p className="mb-1.5 font-semibold text-foreground">{props.label}</p>
{props.payload.map((entry) => {
const key = entry.dataKey as string;
const cfg = SERIES_CONFIG[key as keyof typeof SERIES_CONFIG];
if (!cfg) return null;
return (
<div key={key} className="flex items-center gap-2">
<span className={`inline-block h-2 w-2 rounded-full bg-${cfg.color}-500`} />
<span className="text-muted">{cfg.label}</span>
<span className="ml-auto font-medium text-foreground">
{cfg.fmt(entry.value as number)}
</span>
</div>
);
})}
</div>
);
}}
showLegend={false}
showGridLines={true}
showYAxis={true}
showXAxis={true}
curveType="monotone"
showAnimation
className="h-56 text-sm"
/>
)}
{/* Legend */}
<div className="mt-2 flex items-center gap-2 px-1">
{allSeriesForView.map((key) => {
const cfg = SERIES_CONFIG[key];
return (
<button
key={key}
onClick={() => toggle(key)}
className={`flex items-center gap-1.5 rounded px-1.5 py-0.5 text-xs transition-opacity select-none hover:opacity-100 ${
hidden.has(key) ? "opacity-35" : "opacity-100"
}`}
>
<span className={`inline-block h-2 w-2 rounded-full bg-${cfg.color}-500`} />
<span className="text-muted">
{cfg.label}{cfg.unit}
</span>
</button>
);
})}
</div>
</div>
</div>
);
}
// ── 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>;
}
@@ -121,19 +306,38 @@ 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}
{tx.idTagUserId && (
<>
<span className="mx-1">·</span>
{tx.idTagUserName ?? tx.idTagUserId}
<span className="ml-1 opacity-50">({tx.idTagUserId.slice(0, 8)})</span>
</>
)}
</p>
<p className="text-xs text-muted">{tx.idTag}</p>
</div>
<div className="shrink-0 text-right">
<p className="text-sm font-medium tabular-nums text-foreground">{kwh}</p>
<p className="text-sm font-medium tabular-nums text-foreground">
{kwh}
<span className="text-xs"> kWh</span>
</p>
<p className="text-xs text-muted">{amount}</p>
</div>
<div className="w-16 shrink-0 text-right">
@@ -148,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>;
}
@@ -170,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}
{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 ? (
@@ -203,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>
@@ -330,7 +539,7 @@ export default function DashboardPage() {
<StatCard
title="充电桩总数"
value={s?.totalChargePoints ?? "—"}
icon={PlugConnection}
icon={EvCharger}
color="default"
footer={
<>
@@ -351,22 +560,25 @@ export default function DashboardPage() {
<StatCard
title="注册用户"
value={s?.totalUsers ?? "—"}
icon={Person}
icon={Users}
color="default"
footer={<span></span>}
/>
</div>
{/* Trend chart */}
{isAdmin && <TrendChart />}
{/* Detail panels */}
<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

@@ -1,9 +1,10 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Alert, Button, CloseButton, Input, Label, Spinner, TextField } from "@heroui/react";
import { Alert, Button, CloseButton, Input, Label, Spinner, TextField, toast } from "@heroui/react";
import { Fingerprint, Lock, Pencil, Person, TrashBin, Xmark, Check } from "@gravity-ui/icons";
import { authClient, useSession } from "@/lib/auth-client";
import dayjs from "@/lib/dayjs";
type Passkey = {
id: string;
@@ -18,8 +19,6 @@ export default function SettingsPage() {
// ── Profile ──────────────────────────────────────────────────────────────
const [profileName, setProfileName] = useState("");
const [savingProfile, setSavingProfile] = useState(false);
const [profileError, setProfileError] = useState("");
const [profileSuccess, setProfileSuccess] = useState("");
// sync name from session once loaded
useEffect(() => {
@@ -27,19 +26,17 @@ export default function SettingsPage() {
}, [session?.user.name]);
const handleSaveProfile = async () => {
setProfileError("");
setProfileSuccess("");
setSavingProfile(true);
try {
const res = await authClient.updateUser({ name: profileName.trim() });
if (res?.error) {
setProfileError(res.error.message ?? "保存失败");
toast.danger(res.error.message ?? "保存失败");
} else {
setProfileSuccess("显示名称已更新");
toast.success("显示名称已更新");
await refetchSession();
}
} catch {
setProfileError("保存失败,请重试");
toast.danger("保存失败,请重试");
} finally {
setSavingProfile(false);
}
@@ -95,8 +92,6 @@ export default function SettingsPage() {
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
const [renameSaving, setRenameSaving] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const renameInputRef = useRef<HTMLInputElement>(null);
const loadPasskeys = useCallback(async () => {
@@ -105,7 +100,7 @@ export default function SettingsPage() {
const res = await authClient.passkey.listUserPasskeys();
setPasskeys((res.data as Passkey[] | null) ?? []);
} catch {
setError("获取 Passkey 列表失败");
toast.danger("获取 Passkey 列表失败");
} finally {
setLoading(false);
}
@@ -117,48 +112,42 @@ export default function SettingsPage() {
const handleStartAdd = () => {
setAddingName("");
setError("");
setSuccess("");
};
const handleCancelAdd = () => setAddingName(null);
const handleRegister = async () => {
setError("");
setSuccess("");
setRegistering(true);
try {
const res = await authClient.passkey.addPasskey({
name: addingName?.trim() || undefined,
});
if (res?.error) {
setError(res.error.message ?? "注册 Passkey 失败");
toast.danger(res.error.message ?? "注册 Passkey 失败");
} else {
setSuccess("Passkey 注册成功");
toast.success("Passkey 注册成功");
setAddingName(null);
await loadPasskeys();
}
} catch {
setError("注册 Passkey 失败,请重试");
toast.danger("注册 Passkey 失败,请重试");
} finally {
setRegistering(false);
}
};
const handleDelete = async (id: string) => {
setError("");
setSuccess("");
setDeletingId(id);
try {
const res = await authClient.passkey.deletePasskey({ id });
if (res?.error) {
setError(res.error.message ?? "删除 Passkey 失败");
toast.danger(res.error.message ?? "删除 Passkey 失败");
} else {
setPasskeys((prev) => prev.filter((p) => p.id !== id));
setSuccess("Passkey 已删除");
toast.success("Passkey 已删除");
}
} catch {
setError("删除 Passkey 失败,请重试");
toast.danger("删除 Passkey 失败,请重试");
} finally {
setDeletingId(null);
}
@@ -176,7 +165,6 @@ export default function SettingsPage() {
};
const handleRename = async (id: string) => {
setError("");
setRenameSaving(true);
try {
const res = await (authClient.passkey as any).updatePasskey({
@@ -184,7 +172,7 @@ export default function SettingsPage() {
name: renameValue.trim() || undefined,
});
if (res?.error) {
setError(res.error.message ?? "重命名失败");
toast.danger(res.error.message ?? "重命名失败");
} else {
setPasskeys((prev) =>
prev.map((p) => (p.id === id ? { ...p, name: renameValue.trim() || null } : p)),
@@ -192,7 +180,7 @@ export default function SettingsPage() {
setRenamingId(null);
}
} catch {
setError("重命名失败,请重试");
toast.danger("重命名失败,请重试");
} finally {
setRenameSaving(false);
}
@@ -228,24 +216,6 @@ export default function SettingsPage() {
}}
/>
</TextField>
{profileError && (
<Alert status="danger">
<Alert.Indicator />
<Alert.Content>
<Alert.Description>{profileError}</Alert.Description>
</Alert.Content>
<CloseButton onPress={() => setProfileError("")} />
</Alert>
)}
{profileSuccess && (
<Alert status="success">
<Alert.Indicator />
<Alert.Content>
<Alert.Description>{profileSuccess}</Alert.Description>
</Alert.Content>
<CloseButton onPress={() => setProfileSuccess("")} />
</Alert>
)}
<div className="flex justify-end">
<Button
size="sm"
@@ -379,29 +349,6 @@ export default function SettingsPage() {
</div>
)}
{error && (
<div className="border-b border-border px-5 py-3">
<Alert status="danger">
<Alert.Indicator />
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
<CloseButton onPress={() => setError("")} />
</Alert>
</div>
)}
{success && (
<div className="border-b border-border px-5 py-3">
<Alert status="success">
<Alert.Indicator />
<Alert.Content>
<Alert.Description>{success}</Alert.Description>
</Alert.Content>
<CloseButton onPress={() => setSuccess("")} />
</Alert>
</div>
)}
{loading ? (
<div className="flex justify-center py-8">
<Spinner />
@@ -465,11 +412,7 @@ export default function SettingsPage() {
{/* Date row: always visible */}
<p className="text-xs text-muted">
{" "}
{new Date(pk.createdAt).toLocaleString("zh-CN", {
year: "numeric",
month: "short",
day: "numeric",
})}
{dayjs(pk.createdAt).format("YYYY年M月D日")}
</p>
</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,32 +1,81 @@
"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";
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 ms = new Date(stop).getTime() - new Date(start).getTime();
const min = Math.floor(ms / 60000);
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`;
}
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,
@@ -78,7 +127,14 @@ export default function TransactionsPage() {
<p className="mt-0.5 text-sm text-muted"> {data?.total ?? "—"} </p>
</div>
<div className="flex items-center gap-2">
<Button isIconOnly size="sm" variant="ghost" isDisabled={refreshing} onPress={() => refetch()} aria-label="刷新">
<Button
isIconOnly
size="sm"
variant="ghost"
isDisabled={refreshing}
onPress={() => refetch()}
aria-label="刷新"
>
<ArrowRotateRight className={`size-4 ${refreshing ? "animate-spin" : ""}`} />
</Button>
<div className="flex gap-1.5 rounded-xl bg-surface-secondary p-1">
@@ -94,7 +150,9 @@ export default function TransactionsPage() {
>
{s === "all" ? "全部" : s === "active" ? "进行中" : "已完成"}
</button>
))} </div> </div>
))}{" "}
</div>{" "}
</div>
</div>
<Table>
@@ -122,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>
@@ -138,25 +214,57 @@ export default function TransactionsPage() {
)}
</Table.Cell>
<Table.Cell className="whitespace-nowrap text-sm">
{new Date(tx.startTimestamp).toLocaleString("zh-CN")}
{dayjs(tx.startTimestamp).format("YYYY/M/D HH:mm:ss")}
</Table.Cell>
<Table.Cell className="text-sm">
{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>
) : (
"—"
@@ -302,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

@@ -20,6 +20,7 @@ import {
import { CreditCard, Pencil, ArrowRotateRight } from "@gravity-ui/icons";
import { api, type IdTag, type UserRow } from "@/lib/api";
import { useSession } from "@/lib/auth-client";
import dayjs from "@/lib/dayjs";
type CreateForm = {
name: string;
@@ -452,7 +453,7 @@ export default function UsersPage() {
)}
</Table.Cell>
<Table.Cell className="text-sm">
{new Date(u.createdAt).toLocaleString("zh-CN")}
{dayjs(u.createdAt).format("YYYY/M/D HH:mm:ss")}
</Table.Cell>
<Table.Cell>
<div className="flex justify-end gap-1">

View File

@@ -1,5 +1,8 @@
@import "tailwindcss";
@import "@heroui/styles";
@source inline("{bg,text,border,ring,stroke,fill}-{slate,gray,zinc,neutral,stone,red,orange,amber,yellow,lime,green,emerald,teal,cyan,sky,blue,indigo,violet,purple,fuchsia,pink,rose}-{50,100,200,300,400,500,600,700,800,900,950}");
@source inline("hover:{bg,text,border}-{slate,gray,zinc,neutral,stone,red,orange,amber,yellow,lime,green,emerald,teal,cyan,sky,blue,indigo,violet,purple,fuchsia,pink,rose}-{50,100,200,300,400,500,600,700,800,900,950}");
@source inline("data-[selected]:{bg,text,border}-{slate,gray,zinc,neutral,stone,red,orange,amber,yellow,lime,green,emerald,teal,cyan,sky,blue,indigo,violet,purple,fuchsia,pink,rose}-{50,100,200,300,400,500,600,700,800,900,950}");
/*
* HeroUI Theme Customization
@@ -17,26 +20,26 @@
--accent: oklch(62.04% 0.1951 253.83);
--accent-foreground: oklch(99.11% 0 0);
--background: oklch(97.02% 0.0069 253.83);
--border: oklch(90.00% 0.0069 253.83);
--danger: oklch(65.32% 0.2360 25.74);
--border: oklch(90% 0.0069 253.83);
--danger: oklch(65.32% 0.236 25.74);
--danger-foreground: oklch(99.11% 0 0);
--default: oklch(94.00% 0.0069 253.83);
--default: oklch(94% 0.0069 253.83);
--default-foreground: oklch(21.03% 0.0059 253.83);
--field-background: oklch(100.00% 0.0034 253.83);
--field-background: oklch(100% 0.0034 253.83);
--field-foreground: oklch(21.03% 0.0069 253.83);
--field-placeholder: oklch(55.17% 0.0138 253.83);
--focus: oklch(62.04% 0.1951 253.83);
--foreground: oklch(21.03% 0.0069 253.83);
--muted: oklch(55.17% 0.0138 253.83);
--overlay: oklch(100.00% 0.0021 253.83);
--overlay: oklch(100% 0.0021 253.83);
--overlay-foreground: oklch(21.03% 0.0069 253.83);
--scrollbar: oklch(87.10% 0.0069 253.83);
--segment: oklch(100.00% 0.0069 253.83);
--scrollbar: oklch(87.1% 0.0069 253.83);
--segment: oklch(100% 0.0069 253.83);
--segment-foreground: oklch(21.03% 0.0069 253.83);
--separator: oklch(92.00% 0.0069 253.83);
--separator: oklch(92% 0.0069 253.83);
--success: oklch(73.29% 0.1962 150.81);
--success-foreground: oklch(21.03% 0.0059 150.81);
--surface: oklch(100.00% 0.0034 253.83);
--surface: oklch(100% 0.0034 253.83);
--surface-foreground: oklch(21.03% 0.0069 253.83);
--surface-secondary: oklch(95.24% 0.0055 253.83);
--surface-secondary-foreground: oklch(21.03% 0.0069 253.83);
@@ -60,32 +63,38 @@
/* Theme Colors (Dark Mode) */
--accent: oklch(62.04% 0.1951 253.83);
--accent-foreground: oklch(99.11% 0 0);
--background: oklch(12.00% 0.0069 253.83);
--border: oklch(28.00% 0.0069 253.83);
--danger: oklch(59.40% 0.1994 24.63);
--background: oklch(12% 0.0069 253.83);
--border: oklch(28% 0.0069 253.83);
--danger: oklch(59.4% 0.1994 24.63);
--danger-foreground: oklch(99.11% 0 0);
--default: oklch(27.40% 0.0069 253.83);
--default: oklch(27.4% 0.0069 253.83);
--default-foreground: oklch(99.11% 0 0);
--field-background: oklch(21.03% 0.0138 253.83);
--field-foreground: oklch(99.11% 0.0069 253.83);
--field-placeholder: oklch(70.50% 0.0138 253.83);
--field-placeholder: oklch(70.5% 0.0138 253.83);
--focus: oklch(62.04% 0.1951 253.83);
--foreground: oklch(99.11% 0.0069 253.83);
--muted: oklch(70.50% 0.0138 253.83);
--muted: oklch(70.5% 0.0138 253.83);
--overlay: oklch(21.03% 0.0138 253.83);
--overlay-foreground: oklch(99.11% 0.0069 253.83);
--scrollbar: oklch(70.50% 0.0069 253.83);
--scrollbar: oklch(70.5% 0.0069 253.83);
--segment: oklch(39.64% 0.0069 253.83);
--segment-foreground: oklch(99.11% 0.0069 253.83);
--separator: oklch(25.00% 0.0069 253.83);
--separator: oklch(25% 0.0069 253.83);
--success: oklch(73.29% 0.1962 150.81);
--success-foreground: oklch(21.03% 0.0059 150.81);
--surface: oklch(21.03% 0.0138 253.83);
--surface-foreground: oklch(99.11% 0.0069 253.83);
--surface-secondary: oklch(25.70% 0.0103 253.83);
--surface-secondary: oklch(25.7% 0.0103 253.83);
--surface-secondary-foreground: oklch(99.11% 0.0069 253.83);
--surface-tertiary: oklch(27.21% 0.0103 253.83);
--surface-tertiary-foreground: oklch(99.11% 0.0069 253.83);
--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

@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { Geist_Mono, Noto_Sans, Saira } from "next/font/google";
import { Toast } from "@heroui/react";
import "./globals.css";
const fontSaira = Saira({
@@ -33,6 +34,7 @@ export default function RootLayout({
<body
className={`${fontSaira.variable} ${fontNotoSans.variable} ${fontMono.variable} antialiased`}
>
<Toast.Provider />
{children}
</body>
</html>

View File

@@ -1,15 +1,16 @@
"use client";
import { useState } from "react";
import { Suspense, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Alert, Button, Card, CloseButton, Input, Label, TextField } from "@heroui/react";
import { Fingerprint, Thunderbolt } from "@gravity-ui/icons";
import { authClient } from "@/lib/auth-client";
export default function LoginPage() {
function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const justSetup = searchParams.get("setup") === "1";
const fromPath = searchParams.get("from");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
@@ -29,7 +30,7 @@ export default function LoginPage() {
if (res.error) {
setError(res.error.message ?? "登录失败,请检查用户名和密码");
} else {
router.push("/dashboard");
router.push(fromPath ?? "/dashboard");
router.refresh();
}
} catch {
@@ -47,7 +48,7 @@ export default function LoginPage() {
if (res?.error) {
setError(res.error.message ?? "Passkey 登录失败");
} else {
router.push("/dashboard");
router.push(fromPath ?? "/dashboard");
router.refresh();
}
} catch {
@@ -145,3 +146,11 @@ export default function LoginPage() {
</div>
);
}
export default function LoginPage() {
return (
<Suspense>
<LoginForm />
</Suspense>
);
}

View File

@@ -4,8 +4,7 @@ import { useState } from "react";
import { useRouter } from "next/navigation";
import { Alert, Button, Card, CloseButton, Input, Label, TextField } from "@heroui/react";
import { Thunderbolt } from "@gravity-ui/icons";
const CSMS_URL = process.env.NEXT_PUBLIC_CSMS_URL ?? "http://localhost:3001";
import { api } from "@/lib/api";
export default function SetupPage() {
const router = useRouter();
@@ -38,28 +37,15 @@ export default function SetupPage() {
setLoading(true);
try {
const res = await fetch(`${CSMS_URL}/api/setup`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
await api.setup.create({
name: form.name,
email: form.email,
username: form.username,
password: form.password,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError((data as { error?: string }).error ?? "初始化失败,请重试");
return;
}
// 初始化成功,跳转登录页
router.push("/login?setup=1");
} catch {
setError("网络错误,请稍后重试");
} catch (err) {
setError(err instanceof Error ? err.message : "初始化失败,请重试");
} finally {
setLoading(false);
}
@@ -74,7 +60,7 @@ export default function SetupPage() {
</div>
<div className="text-center">
<h1 className="text-2xl font-bold tracking-tight text-foreground">Helios EVCS</h1>
<p className="mt-1 text-sm text-muted"> · </p>
<p className="mt-1 text-sm text-muted"> · </p>
</div>
</div>

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

@@ -1,12 +1,67 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import { MutationCache, QueryCache, QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "@heroui/react";
import { signOut, useSession } from "@/lib/auth-client";
import { APIError } from "@/lib/api";
/** 监听 better-auth 会话变为 null服务端过期/异地登出等场景)*/
function SessionMonitor({ onExpired }: { onExpired: () => void }) {
const { data: session, isPending } = useSession();
const wasLoggedIn = useRef(false);
useEffect(() => {
if (isPending) return;
if (session) {
wasLoggedIn.current = true;
} else if (wasLoggedIn.current) {
wasLoggedIn.current = false;
onExpired();
}
}, [session, isPending, onExpired]);
return null;
}
export function ReactQueryProvider({ children }: { children: React.ReactNode }) {
const router = useRouter();
const isHandling = useRef(false);
const handleExpired = useCallback(async () => {
if (isHandling.current) return;
isHandling.current = true;
try {
await signOut({ fetchOptions: { credentials: "include" } });
} catch {
// ignore sign-out errors
}
toast.warning("登录已过期,请重新登录");
router.push("/login");
}, [router]);
// Stable ref so the QueryClient (created once) always calls the latest handler
const handleExpiredRef = useRef(handleExpired);
handleExpiredRef.current = handleExpired;
const [queryClient] = useState(
() =>
new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
if (error instanceof APIError && error.status === 401) {
handleExpiredRef.current();
}
},
}),
mutationCache: new MutationCache({
onError: (error) => {
if (error instanceof APIError && error.status === 401) {
handleExpiredRef.current();
}
},
}),
defaultOptions: {
queries: {
staleTime: 10_000,
@@ -14,5 +69,11 @@ export function ReactQueryProvider({ children }: { children: React.ReactNode })
},
}),
);
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
return (
<QueryClientProvider client={queryClient}>
<SessionMonitor onExpired={handleExpired} />
{children}
</QueryClientProvider>
);
}

View File

@@ -9,22 +9,35 @@ import {
ListCheck,
Person,
PlugConnection,
TagDollar,
Thunderbolt,
ThunderboltFill,
Xmark,
Bars,
} 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 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/id-tags", label: "储值卡", icon: CreditCard, adminOnly: false },
{ href: "/dashboard/users", label: "用户管理", icon: Person, adminOnly: true },
const chargeItems = [
{ href: "/dashboard/charge", label: "立即充电", icon: Thunderbolt, adminOnly: false },
{ href: "/dashboard/pricing", label: "电价标准", icon: TagDollar, adminOnly: false },
];
const settingsItems = [{ href: "/dashboard/settings", label: "账号设置", icon: Gear }];
const navItems = [
{ 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/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/user", label: "账号设置", icon: UserCog, adminOnly: false },
{ href: "/dashboard/settings/parameters", label: "参数配置", icon: Gear, adminOnly: true },
];
function NavContent({
pathname,
@@ -49,7 +62,32 @@ function NavContent({
{/* Navigation */}
<nav className="flex flex-1 flex-col gap-0.5 overflow-y-auto p-3">
{/* Primary: Charge */}
<p className="mb-1 px-2 text-[11px] font-semibold uppercase tracking-widest text-muted">
</p>
{chargeItems.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>
);
})}
<p className="mb-1 mt-3 px-2 text-[11px] font-semibold uppercase tracking-widest text-muted">
</p>
{navItems
@@ -80,7 +118,9 @@ function NavContent({
<p className="mb-1 mt-3 px-2 text-[11px] font-semibold uppercase tracking-widest text-muted">
</p>
{settingsItems.map((item) => {
{settingsItems
.filter((item) => !item.adminOnly || isAdmin)
.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(item.href + "/");
const Icon = item.icon;
return (

View File

@@ -1,3 +1,13 @@
export class APIError extends Error {
constructor(
public readonly status: number,
message: string,
) {
super(message);
this.name = "APIError";
}
}
const CSMS_URL = process.env.NEXT_PUBLIC_CSMS_URL ?? "http://localhost:3001";
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
@@ -11,7 +21,7 @@ async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
});
if (!res.ok) {
const text = await res.text().catch(() => res.statusText);
throw new Error(`API ${path} failed (${res.status}): ${text}`);
throw new APIError(res.status, `API ${path} failed (${res.status}): ${text}`);
}
return res.json() as Promise<T>;
}
@@ -58,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;
@@ -75,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;
@@ -85,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[];
@@ -98,17 +128,27 @@ export type ChargePointDetail = {
export type Transaction = {
id: number;
chargePointIdentifier: string | null;
chargePointDeviceName: string | null;
connectorNumber: number | null;
idTag: string;
idTagStatus: string | null;
idTagUserId: string | null;
idTagUserName: string | null;
startTimestamp: string;
stopTimestamp: string | null;
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 = {
@@ -118,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;
};
@@ -133,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;
@@ -140,23 +194,69 @@ 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";
export type ChartDataPoint = {
bucket: string;
energyKwh: number;
revenue: number;
transactions: number;
utilizationPct: number;
};
export const api = {
stats: {
get: () => apiFetch<Stats | UserStats>("/api/stats"),
chart: (range: ChartRange) =>
apiFetch<ChartDataPoint[]>(`/api/stats/chart?range=${range}`),
},
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),
}),
@@ -164,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}`, {
@@ -175,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?: {
@@ -196,6 +302,11 @@ export const api = {
apiFetch<Transaction & { online: boolean }>(`/api/transactions/${id}/stop`, {
method: "POST",
}),
remoteStart: (data: { chargePointIdentifier: string; connectorId: number; idTag: string }) =>
apiFetch<{ success: true }>("/api/transactions/remote-start", {
method: "POST",
body: JSON.stringify(data),
}),
delete: (id: number) =>
apiFetch<{ success: true }>(`/api/transactions/${id}`, { method: "DELETE" }),
},
@@ -210,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,
@@ -219,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) =>
@@ -248,4 +363,18 @@ export const api = {
},
) => apiFetch<UserRow>(`/api/users/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
},
setup: {
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) }),
},
};

8
apps/web/lib/dayjs.ts Normal file
View File

@@ -0,0 +1,8 @@
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import "dayjs/locale/zh-cn";
dayjs.extend(relativeTime);
dayjs.locale("zh-cn");
export default dayjs;

View File

@@ -1,95 +0,0 @@
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): Promise<{ initialized: boolean; fromCache: boolean }> {
// 读缓存 cookie
const cached = request.cookies.get("helios_setup_done");
if (cached?.value === "1") {
return { initialized: true, fromCache: true };
}
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 }; // 出错时放行,不阻止访问
const data = (await res.json()) as { initialized: boolean };
return { initialized: data.initialized, fromCache: false };
} catch {
// 无法连接 CSMS 时放行,不强制跳转
return { initialized: true, fromCache: false };
}
}
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// /setup 页面:已初始化则跳转登录
if (pathname === "/setup") {
const { initialized, fromCache } = await isInitialized(request);
if (initialized) {
return NextResponse.redirect(new URL("/login", request.url));
}
const res = NextResponse.next();
if (!fromCache) {
// 未初始化,确保缓存 cookie 不存在(若之前意外设置了)
res.cookies.delete("helios_setup_done");
}
return res;
}
// /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;
}
const sessionCookie =
request.cookies.get("helios_auth.session_token") ??
request.cookies.get("__Secure-helios_auth.session_token");
if (!sessionCookie) {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("from", pathname);
const res = NextResponse.redirect(loginUrl);
if (!fromCache) res.cookies.set("helios_setup_done", "1", { path: "/", httpOnly: true, sameSite: "lax" });
return res;
}
const res = NextResponse.next();
if (!fromCache) res.cookies.set("helios_setup_done", "1", { path: "/", httpOnly: true, sameSite: "lax" });
return res;
}
// /login 路由:未初始化则跳转 /setup
if (pathname === "/login") {
const { initialized, fromCache } = await isInitialized(request);
if (!initialized) {
const res = NextResponse.redirect(new URL("/setup", request.url));
if (!fromCache) res.cookies.delete("helios_setup_done");
return res;
}
const res = NextResponse.next();
if (!fromCache) res.cookies.set("helios_setup_done", "1", { path: "/", httpOnly: true, sameSite: "lax" });
return res;
}
return NextResponse.next();
}
export const config = {
matcher: ["/setup", "/login", "/dashboard/:path*"],
};

View File

@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
output: "standalone",
};
export default nextConfig;

View File

@@ -13,8 +13,15 @@
"@heroui/styles": "3.0.0-beta.8",
"@internationalized/date": "^3.12.0",
"@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",
"react-dom": "19.2.3"
},

67
apps/web/proxy.ts Normal file
View File

@@ -0,0 +1,67 @@
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 是否已完成初始化(有用户存在)。 */
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 true; // 出错时放行,不阻止访问
const data = (await res.json()) as { initialized: boolean };
return data.initialized;
} catch {
// 无法连接 CSMS 时放行,不强制跳转
return true;
}
}
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
// /setup 页面:已初始化则跳转登录
if (pathname === "/setup") {
if (await isInitialized(request)) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
// /dashboard 路由:检查 session未登录跳转 /login
if (pathname.startsWith("/dashboard")) {
if (!(await isInitialized(request))) {
return NextResponse.redirect(new URL("/setup", request.url));
}
const sessionCookie =
request.cookies.get("helios.session_token") ??
request.cookies.get("__Secure-helios.session_token");
if (!sessionCookie) {
const loginUrl = new URL("/login", request.url);
const fromPath = request.nextUrl.search ? pathname + request.nextUrl.search : pathname;
loginUrl.searchParams.set("from", fromPath);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
// /login 路由:未初始化则跳转 /setup
if (pathname === "/login") {
if (!(await isInitialized(request))) {
return NextResponse.redirect(new URL("/setup", request.url));
}
return NextResponse.next();
}
return NextResponse.next();
}
export const config = {
matcher: ["/setup", "/login", "/dashboard/:path*"],
};

24
docker-compose.yml Normal file
View File

@@ -0,0 +1,24 @@
name: helios-evcs
services:
csms:
container_name: helios-csms
build:
context: .
dockerfile: apps/csms/Dockerfile
ports:
- "3001:3001"
env_file:
- apps/csms/.env
web:
container_name: helios-web
build:
context: .
dockerfile: apps/web/Dockerfile
args:
NEXT_PUBLIC_CSMS_URL: ${NEXT_PUBLIC_CSMS_URL}
ports:
- "3000:3000"
env_file:
- apps/web/.env

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,6 +421,75 @@ void loop()
mg_mgr_poll(&mgr, 10);
mocpp_loop();
// 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 (!boot_was_pressed)
{
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 (boot_was_pressed)
{
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())
{
@@ -292,6 +505,7 @@ void loop()
s_led_state = LED_WIFI_CONNECTED;
}
}
}
updateLED();

Some files were not shown because too many files have changed in this diff Show More