Compare commits
64 Commits
ee329c7b9b
...
codex/anal
| Author | SHA1 | Date | |
|---|---|---|---|
| e61e244c39 | |||
| 2c90404637 | |||
| 3508e7de19 | |||
| adc67e428d | |||
| dee947ce3e | |||
| 4d940e2cd4 | |||
| 8371b2a76b | |||
| e1fb43d57b | |||
| 5825783f8b | |||
| e884fc5bc0 | |||
| cf0861f8f6 | |||
| 4885cf6778 | |||
| 654a2a66d9 | |||
| 0118dd2e15 | |||
| 6888454727 | |||
| 91d91ebd08 | |||
| 37c5cfe5a9 | |||
| 2de43d5fbb | |||
| 434dbc15e9 | |||
| d5b2e529ff | |||
| d7b7ebfef9 | |||
| 8f3b2fd6e2 | |||
| 8a537e80e3 | |||
| 216a8e118d | |||
| b45896a9dd | |||
| 4a9961df70 | |||
| 18ac660ab2 | |||
| a6621f975c | |||
| 83e6ed2412 | |||
| c8ddaa4dcc | |||
| 88a80d2268 | |||
| f7ee298060 | |||
| 2638af3f7f | |||
| 2bbb8239a6 | |||
| 0f6d14d791 | |||
| 17f185f366 | |||
| d1bff8bfd9 | |||
| 9f92b57371 | |||
| e759576b58 | |||
| 4703ef3548 | |||
| 50f5fbd122 | |||
| ee44586c6f | |||
| 2cc7fbc5be | |||
| 4e16e933f2 | |||
| 2479653bab | |||
| bf7c7c54cd | |||
| d49c80cc05 | |||
| 8d0164208d | |||
| f753550d44 | |||
| fb0d135a79 | |||
| 103c86e14d | |||
| 20e0cd068f | |||
| f8ff5c3d31 | |||
| 9d76dc508a | |||
| 279e453ad6 | |||
| 02a361488b | |||
| 73f0c6243a | |||
| 8ee2378c78 | |||
| 168a5b5613 | |||
| f1932676be | |||
| 7bd4e379de | |||
| ce53a4f218 | |||
| 70ae7da0d9 | |||
| 9bdeea8a12 |
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -7,5 +7,8 @@
|
||||
"editor.formatOnSave": true,
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode"
|
||||
}
|
||||
}
|
||||
86
README.md
86
README.md
@@ -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>
|
||||
```
|
||||
|
||||
## 📚 技术文档
|
||||
|
||||
@@ -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
32
apps/csms/Dockerfile
Normal 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"]
|
||||
@@ -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"]
|
||||
@@ -1,12 +0,0 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
csms:
|
||||
build: .
|
||||
ports:
|
||||
- "3001:3001"
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
# env_file:
|
||||
# - .env
|
||||
restart: unless-stopped
|
||||
@@ -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);
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
2
apps/csms/drizzle/0002_sweet_the_professor.sql
Normal file
2
apps/csms/drizzle/0002_sweet_the_professor.sql
Normal 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';
|
||||
17
apps/csms/drizzle/0003_milky_supreme_intelligence.sql
Normal file
17
apps/csms/drizzle/0003_milky_supreme_intelligence.sql
Normal 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;
|
||||
1
apps/csms/drizzle/0004_nervous_frog_thor.sql
Normal file
1
apps/csms/drizzle/0004_nervous_frog_thor.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "charge_point" ADD COLUMN "device_name" varchar(100);
|
||||
1
apps/csms/drizzle/0005_peaceful_anthem.sql
Normal file
1
apps/csms/drizzle/0005_peaceful_anthem.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "charge_point" ADD COLUMN "password_hash" varchar(255);
|
||||
6
apps/csms/drizzle/0006_spooky_skin.sql
Normal file
6
apps/csms/drizzle/0006_spooky_skin.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
CREATE TABLE "system_setting" (
|
||||
"key" varchar(64) PRIMARY KEY NOT NULL,
|
||||
"value" jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
9
apps/csms/drizzle/0007_unusual_squadron_supreme.sql
Normal file
9
apps/csms/drizzle/0007_unusual_squadron_supreme.sql
Normal 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;
|
||||
1829
apps/csms/drizzle/meta/0002_snapshot.json
Normal file
1829
apps/csms/drizzle/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1936
apps/csms/drizzle/meta/0003_snapshot.json
Normal file
1936
apps/csms/drizzle/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1942
apps/csms/drizzle/meta/0004_snapshot.json
Normal file
1942
apps/csms/drizzle/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1948
apps/csms/drizzle/meta/0005_snapshot.json
Normal file
1948
apps/csms/drizzle/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1987
apps/csms/drizzle/meta/0006_snapshot.json
Normal file
1987
apps/csms/drizzle/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2042
apps/csms/drizzle/meta/0007_snapshot.json
Normal file
2042
apps/csms/drizzle/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -10,7 +10,9 @@ const allDeps = {
|
||||
...packageJson.dependencies,
|
||||
...packageJson.devDependencies,
|
||||
}
|
||||
const externalModules = Object.keys(allDeps)
|
||||
// 开发模式将依赖标记为 external 以加快构建速度;
|
||||
// 生产模式全部打包进 bundle,runner 阶段无需 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'}"`,
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.reservationId(optional)
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
export * from './auth-schema.ts'
|
||||
export * from './ocpp-schema.ts'
|
||||
export * from './tariff-schema.ts'
|
||||
export * from './settings-schema.ts'
|
||||
|
||||
15
apps/csms/src/db/settings-schema.ts
Normal file
15
apps/csms/src/db/settings-schema.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { jsonb, pgTable, timestamp, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
/**
|
||||
* 系统参数配置(按模块 key 存储)
|
||||
* 例如:key=ocpp16j, value={ heartbeatInterval: 60 }
|
||||
*/
|
||||
export const systemSetting = pgTable("system_setting", {
|
||||
key: varchar("key", { length: 64 }).primaryKey(),
|
||||
value: jsonb("value").notNull().$type<Record<string, unknown>>(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date()),
|
||||
});
|
||||
58
apps/csms/src/db/tariff-schema.ts
Normal file
58
apps/csms/src/db/tariff-schema.ts
Normal 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:起始小时(含,0–23)
|
||||
* end:结束小时(不含,1–24)
|
||||
* 示例:{ start: 8, end: 12, tier: "peak" } 表示 08:00–12: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
|
||||
*
|
||||
* 时段列表存储为 JSONB(TariffSlot[])。
|
||||
* 若无任何 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()),
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
});
|
||||
|
||||
32
apps/csms/src/lib/ocpp-auth.ts
Normal file
32
apps/csms/src/lib/ocpp-auth.ts
Normal 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)
|
||||
}
|
||||
45
apps/csms/src/lib/system-settings.ts
Normal file
45
apps/csms/src/lib/system-settings.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { systemSetting } from "@/db/schema.js";
|
||||
import { useDrizzle } from "@/lib/db.js";
|
||||
|
||||
export const SETTINGS_KEY_OCPP16J = "ocpp16j";
|
||||
const DEFAULT_HEARTBEAT_INTERVAL = 60;
|
||||
const MIN_HEARTBEAT_INTERVAL = 10;
|
||||
const MAX_HEARTBEAT_INTERVAL = 86400;
|
||||
|
||||
export type Ocpp16jSettings = {
|
||||
heartbeatInterval: number;
|
||||
};
|
||||
|
||||
export type SettingsPayload = {
|
||||
ocpp16j: Ocpp16jSettings;
|
||||
};
|
||||
|
||||
export function sanitizeHeartbeatInterval(raw: unknown): number {
|
||||
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
||||
return DEFAULT_HEARTBEAT_INTERVAL;
|
||||
}
|
||||
const n = Math.round(raw);
|
||||
if (n < MIN_HEARTBEAT_INTERVAL) return MIN_HEARTBEAT_INTERVAL;
|
||||
if (n > MAX_HEARTBEAT_INTERVAL) return MAX_HEARTBEAT_INTERVAL;
|
||||
return n;
|
||||
}
|
||||
|
||||
export function getDefaultHeartbeatInterval(): number {
|
||||
return DEFAULT_HEARTBEAT_INTERVAL;
|
||||
}
|
||||
|
||||
export async function getOcpp16jSettings(): Promise<Ocpp16jSettings> {
|
||||
const db = useDrizzle();
|
||||
const [ocpp16jRow] = await db
|
||||
.select()
|
||||
.from(systemSetting)
|
||||
.where(eq(systemSetting.key, SETTINGS_KEY_OCPP16J))
|
||||
.limit(1);
|
||||
|
||||
const ocpp16jRaw = (ocpp16jRow?.value ?? {}) as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
heartbeatInterval: sanitizeHeartbeatInterval(ocpp16jRaw.heartbeatInterval),
|
||||
};
|
||||
}
|
||||
@@ -1,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" };
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
}));
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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})`)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
65
apps/csms/src/routes/settings.ts
Normal file
65
apps/csms/src/routes/settings.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Hono } from "hono";
|
||||
import { useDrizzle } from "@/lib/db.js";
|
||||
import { systemSetting } from "@/db/schema.js";
|
||||
import type { HonoEnv } from "@/types/hono.ts";
|
||||
import {
|
||||
SETTINGS_KEY_OCPP16J,
|
||||
getOcpp16jSettings,
|
||||
sanitizeHeartbeatInterval,
|
||||
type SettingsPayload,
|
||||
} from "@/lib/system-settings.js";
|
||||
|
||||
const app = new Hono<HonoEnv>();
|
||||
|
||||
app.get("/", async (c) => {
|
||||
const payload: SettingsPayload = {
|
||||
ocpp16j: await getOcpp16jSettings(),
|
||||
};
|
||||
|
||||
return c.json(payload);
|
||||
});
|
||||
|
||||
app.put("/", async (c) => {
|
||||
const currentUser = c.get("user");
|
||||
if (currentUser?.role !== "admin") {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
let body: Partial<SettingsPayload>;
|
||||
try {
|
||||
body = await c.req.json<Partial<SettingsPayload>>();
|
||||
} catch {
|
||||
return c.json({ error: "Invalid JSON" }, 400);
|
||||
}
|
||||
|
||||
if (!body.ocpp16j) {
|
||||
return c.json({ error: "Missing ocpp16j settings" }, 400);
|
||||
}
|
||||
|
||||
const heartbeatInterval = sanitizeHeartbeatInterval(body.ocpp16j.heartbeatInterval);
|
||||
|
||||
const db = useDrizzle();
|
||||
await db
|
||||
.insert(systemSetting)
|
||||
.values({
|
||||
key: SETTINGS_KEY_OCPP16J,
|
||||
value: { heartbeatInterval },
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: systemSetting.key,
|
||||
set: {
|
||||
value: { heartbeatInterval },
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const payload: SettingsPayload = {
|
||||
ocpp16j: {
|
||||
heartbeatInterval,
|
||||
},
|
||||
};
|
||||
|
||||
return c.json(payload);
|
||||
});
|
||||
|
||||
export default app;
|
||||
168
apps/csms/src/routes/stats-chart.ts
Normal file
168
apps/csms/src/routes/stats-chart.ts
Normal 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;
|
||||
@@ -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)
|
||||
|
||||
98
apps/csms/src/routes/tariff.ts
Normal file
98
apps/csms/src/routes/tariff.ts
Normal 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;
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
134
apps/web/.dockerignore
Normal 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
62
apps/web/Dockerfile
Normal 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"]
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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 <base64({createdCp?.chargePointIdentifier}
|
||||
:<password>)>
|
||||
</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>
|
||||
|
||||
891
apps/web/app/dashboard/charge/page.tsx
Normal file
891
apps/web/app/dashboard/charge/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
233
apps/web/app/dashboard/pricing/page.tsx
Normal file
233
apps/web/app/dashboard/pricing/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
161
apps/web/app/dashboard/settings/parameters/page.tsx
Normal file
161
apps/web/app/dashboard/settings/parameters/page.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Alert, Button, Input, Label, Spinner, TextField, toast } from "@heroui/react";
|
||||
import { Gear, Lock } from "@gravity-ui/icons";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
import { api, type SystemSettings } from "@/lib/api";
|
||||
|
||||
const MIN_HEARTBEAT = 10;
|
||||
const MAX_HEARTBEAT = 86400;
|
||||
const DEFAULT_HEARTBEAT = 60;
|
||||
|
||||
export default function ParametersSettingsPage() {
|
||||
const { data: session } = useSession();
|
||||
const isAdmin = session?.user?.role === "admin";
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: settings, isLoading } = useQuery({
|
||||
queryKey: ["system-settings"],
|
||||
queryFn: () => api.settings.get(),
|
||||
enabled: isAdmin,
|
||||
});
|
||||
|
||||
const [heartbeatInterval, setHeartbeatInterval] = useState(String(DEFAULT_HEARTBEAT));
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings) return;
|
||||
setHeartbeatInterval(String(settings.ocpp16j.heartbeatInterval));
|
||||
}, [settings]);
|
||||
|
||||
const parsedHeartbeat = useMemo(() => {
|
||||
const n = Number(heartbeatInterval);
|
||||
if (!Number.isFinite(n)) return null;
|
||||
return Math.round(n);
|
||||
}, [heartbeatInterval]);
|
||||
|
||||
const heartbeatError =
|
||||
parsedHeartbeat === null
|
||||
? "请输入有效数字"
|
||||
: parsedHeartbeat < MIN_HEARTBEAT || parsedHeartbeat > MAX_HEARTBEAT
|
||||
? `范围应为 ${MIN_HEARTBEAT} - ${MAX_HEARTBEAT} 秒`
|
||||
: "";
|
||||
|
||||
const isDirty = settings
|
||||
? Number(heartbeatInterval) !== settings.ocpp16j.heartbeatInterval
|
||||
: false;
|
||||
|
||||
const handleSave = async () => {
|
||||
if (parsedHeartbeat === null) {
|
||||
toast.warning("请输入有效的心跳间隔");
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsedHeartbeat < MIN_HEARTBEAT || parsedHeartbeat > MAX_HEARTBEAT) {
|
||||
toast.warning(`心跳间隔范围应为 ${MIN_HEARTBEAT}-${MAX_HEARTBEAT} 秒`);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: SystemSettings = {
|
||||
ocpp16j: { heartbeatInterval: parsedHeartbeat },
|
||||
};
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.settings.put(payload);
|
||||
toast.success("参数配置已保存");
|
||||
queryClient.invalidateQueries({ queryKey: ["system-settings"] });
|
||||
} catch {
|
||||
toast.warning("保存失败,请稍后重试");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<div className="mb-4 flex size-12 items-center justify-center rounded-full bg-warning/10">
|
||||
<Lock className="size-6 text-warning" />
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-foreground">需要管理员权限</p>
|
||||
<p className="mt-1 text-sm text-muted">参数配置仅对管理员开放</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Gear className="size-5 text-foreground" />
|
||||
<h1 className="text-xl font-semibold text-foreground">参数配置</h1>
|
||||
</div>
|
||||
<p className="mt-0.5 text-sm text-muted">
|
||||
按功能模块管理系统参数,变更将影响后续设备交互行为
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border bg-surface-secondary">
|
||||
<div className="flex items-center gap-3 border-b border-border px-5 py-4">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg bg-accent/10">
|
||||
<Gear className="size-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">OCPP 1.6J</p>
|
||||
<p className="text-xs text-muted">协议层行为参数</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 px-5 py-4">
|
||||
<TextField fullWidth>
|
||||
<Label className="text-sm font-medium">心跳间隔</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={String(MIN_HEARTBEAT)}
|
||||
max={String(MAX_HEARTBEAT)}
|
||||
step="1"
|
||||
value={heartbeatInterval}
|
||||
onChange={(e) => setHeartbeatInterval(e.target.value)}
|
||||
placeholder="60"
|
||||
/>
|
||||
</TextField>
|
||||
|
||||
<p className="text-xs text-muted">
|
||||
单位:秒。该值会用于 BootNotification.conf 的 interval
|
||||
字段,并下发给充电桩默认心跳参数。
|
||||
</p>
|
||||
|
||||
{!!heartbeatError && (
|
||||
<Alert status="warning">
|
||||
<Alert.Indicator />
|
||||
<Alert.Content>
|
||||
<Alert.Description>{heartbeatError}</Alert.Description>
|
||||
</Alert.Content>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
isDisabled={saving || !isDirty || !!heartbeatError}
|
||||
onPress={handleSave}
|
||||
>
|
||||
{saving ? <Spinner size="sm" color="current" /> : "保存"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
537
apps/web/app/dashboard/settings/pricing/page.tsx
Normal file
537
apps/web/app/dashboard/settings/pricing/page.tsx
Normal 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 (0–23), `end`: exclusive hour (1–24).
|
||||
* Example: { start: 8, end: 12, tier: "peak" } → 08:00–12: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).
|
||||
* 00–07 谷, 08–11 峰, 12 平, 13–16 峰, 17–21 峰, 22–23 谷
|
||||
*/
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
12
apps/web/app/dashboard/topology/page.tsx
Normal file
12
apps/web/app/dashboard/topology/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
apps/web/app/dashboard/topology/topology-client.tsx
Normal file
18
apps/web/app/dashboard/topology/topology-client.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
438
apps/web/app/dashboard/topology/topology-flow.tsx
Normal file
438
apps/web/app/dashboard/topology/topology-flow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
453
apps/web/app/dashboard/transactions/[id]/page.tsx
Normal file
453
apps/web/app/dashboard/transactions/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
13
apps/web/components/faces/circles.tsx
Normal file
13
apps/web/components/faces/circles.tsx
Normal 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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
48
apps/web/components/faces/glow.tsx
Normal file
48
apps/web/components/faces/glow.tsx
Normal 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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
17
apps/web/components/faces/index.ts
Normal file
17
apps/web/components/faces/index.ts
Normal 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;
|
||||
17
apps/web/components/faces/line.tsx
Normal file
17
apps/web/components/faces/line.tsx
Normal 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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
241
apps/web/components/faces/redeye.tsx
Normal file
241
apps/web/components/faces/redeye.tsx
Normal 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%)",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
10
apps/web/components/faces/types.ts
Normal file
10
apps/web/components/faces/types.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
/**
|
||||
* 卡面(卡底装饰)组件的 props。
|
||||
* 卡面仅负责视觉装饰(颜色纹理、光效、几何图形等),不接收任何业务数据。
|
||||
*/
|
||||
export type CardFaceProps = Record<string, never>;
|
||||
|
||||
/** 卡面组件类型 */
|
||||
export type CardFaceComponent = ComponentType<CardFaceProps>;
|
||||
129
apps/web/components/faces/vip.tsx
Normal file
129
apps/web/components/faces/vip.tsx
Normal 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)",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
144
apps/web/components/id-tag-card.tsx
Normal file
144
apps/web/components/id-tag-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
apps/web/components/info-section.tsx
Normal file
15
apps/web/components/info-section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
apps/web/components/metric-indicator.tsx
Normal file
41
apps/web/components/metric-indicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
8
apps/web/lib/dayjs.ts
Normal 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;
|
||||
@@ -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*"],
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -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
67
apps/web/proxy.ts
Normal 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
24
docker-compose.yml
Normal 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
|
||||
37
hardware/firmware/lib/MicroOcppMongoose/library.json
Normal file
37
hardware/firmware/lib/MicroOcppMongoose/library.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
@@ -1 +1,3 @@
|
||||
#define MG_ARCH MG_ARCH_ESP32
|
||||
// Enable TLS support using mbedTLS (built into ESP32)
|
||||
#define MG_TLS MG_TLS_MBED
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user