Compare commits
64 Commits
4703ef3548
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7191a54272 | |||
| 74227ac5bd | |||
| 05f803c423 | |||
| 073bae726a | |||
| d688a8497d | |||
| 3bdb3d7351 | |||
| 79a91745c5 | |||
| 1d378c9bb1 | |||
| f89d602c05 | |||
| afb0c88910 | |||
| 81b28b4461 | |||
| 696f2735ff | |||
| 2d48724e37 | |||
| c11c7f1a4a | |||
| c7f9c959e0 | |||
| 9de7a94a13 | |||
| 9966fada8e | |||
| 5206e98118 | |||
| 1f6019c13c | |||
| 52af4d05fd | |||
| 7881ea6d23 | |||
| c74ff25a6f | |||
| 7ff97a8765 | |||
| f3279601fb | |||
| 524de66ad3 | |||
| 63349a17ed | |||
| ff5b92986f | |||
| 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 |
@@ -1 +0,0 @@
|
||||
../../.agents/skills/heroui-react
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -10,5 +10,6 @@
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode"
|
||||
}
|
||||
},
|
||||
"cmake.ignoreCMakeListsMissing": true
|
||||
}
|
||||
101
README.md
101
README.md
@@ -4,7 +4,22 @@ _这是一个毕业设计项目,旨在尝试实现一个完整的电动汽车
|
||||
|
||||
## 📋 项目概述
|
||||
|
||||
Helios EVCS 是一个全栈解决方案,用于管理和监控电动汽车充电基础设施。项目包含后端 CSMS(充电管理系统)和前端 Web 云平台应用。并设计了一个基于 ESP32,演示用的充电桩终端固件和 PCB 设计。
|
||||
Helios EVCS 是一个全栈解决方案,用于管理和监控电动汽车充电基础设施。项目包含 CSMS(充电管理系统)的前端应用和后端服务。并设计了一个基于 ESP32 的,可演示用的充电桩终端固件和 PCB 设计。
|
||||
|
||||
## 已知问题
|
||||
|
||||
在设计实践的过程中,目前已发现硬件设计存在下列问题,未来可能会修复,也可能不会。现在进行了软件修补的方案在列表中有说明。如果修复了硬件则会划掉那一项。
|
||||
|
||||
### 严重
|
||||
|
||||
- [ ] SW3、SW4 两个 CC 模拟开关在 PCB 布局上放反了,目前在引脚定义中对调了两个 IO。
|
||||
- [ ] SW3、SW4 没有绿波电龙,在当前电磁环境下出现了强烈的干扰,目前在固件中加了积分滤波算法来稳定开关状态。
|
||||
- [ ] KEY1、KEY2 连接的 Input Only GPIO,错误地设计为低电平有效,同时硬件上没有上拉电阻,所以两个按钮完全无效。软件无法解决这个问题,因此调整了刷卡充电的逻辑。
|
||||
|
||||
### 优化
|
||||
|
||||
- [ ] IM1281C 的两根负载线如采用单芯铜线,弯折后很难装配,需要灵活控制下长度,或者在 IM1281C 焊接之前先把先穿好。
|
||||
- [ ] 可以设计螺丝孔位来在背部安装亚克力面板,防止误触背面的交流电焊盘。
|
||||
|
||||
## 🏗️ 项目结构
|
||||
|
||||
@@ -15,19 +30,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 +49,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 +67,18 @@ helios-evcs/
|
||||
- 设备驱动实现
|
||||
- 通信协议栈
|
||||
- 实时控制逻辑
|
||||
- **pcb-kicad/** - PCB 电路板设计
|
||||
- **pcb/** - PCB 电路板设计
|
||||
- KiCAD 工程文件
|
||||
- 电路原理图
|
||||
- PCB 布局设计
|
||||
- BOM 物料清单
|
||||
- 制造文件(Gerber)
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 前置要求
|
||||
|
||||
- Node.js >= 18
|
||||
- Node.js >= 20
|
||||
- pnpm >= 10.18.2
|
||||
|
||||
### 安装依赖
|
||||
@@ -78,6 +94,8 @@ pnpm install
|
||||
# 启动所有开发服务器(CSMS + Web)
|
||||
pnpm dev
|
||||
|
||||
# 迁移数据库
|
||||
pnpm --filter csms run db:migrate
|
||||
# 仅启动后端
|
||||
pnpm dev:csms
|
||||
|
||||
@@ -111,21 +129,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 +141,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 +151,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,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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,6 +8,10 @@ import { logger } from 'hono/logger'
|
||||
import { showRoutes } from 'hono/dev'
|
||||
import { auth } from './lib/auth.ts'
|
||||
import { createOcppHandler } from './ocpp/handler.ts'
|
||||
import { verifyOcppPassword } from './lib/ocpp-auth.ts'
|
||||
import { useDrizzle } from './lib/db.ts'
|
||||
import { chargePoint } from './db/schema.ts'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import statsRoutes from './routes/stats.ts'
|
||||
import statsChartRoutes from './routes/stats-chart.ts'
|
||||
import chargePointRoutes from './routes/charge-points.ts'
|
||||
@@ -15,6 +19,8 @@ import transactionRoutes from './routes/transactions.ts'
|
||||
import idTagRoutes from './routes/id-tags.ts'
|
||||
import userRoutes from './routes/users.ts'
|
||||
import setupRoutes from './routes/setup.ts'
|
||||
import tariffRoutes from './routes/tariff.ts'
|
||||
import settingsRoutes from './routes/settings.ts'
|
||||
|
||||
import type { HonoEnv } from './types/hono.ts'
|
||||
|
||||
@@ -41,7 +47,7 @@ app.use(
|
||||
'/api/*',
|
||||
cors({
|
||||
origin: process.env.WEB_ORIGIN ?? '*',
|
||||
allowMethods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowHeaders: ['Content-Type', 'Authorization'],
|
||||
exposeHeaders: ['Content-Length'],
|
||||
credentials: true,
|
||||
@@ -58,6 +64,8 @@ app.route('/api/transactions', transactionRoutes)
|
||||
app.route('/api/id-tags', idTagRoutes)
|
||||
app.route('/api/users', userRoutes)
|
||||
app.route('/api/setup', setupRoutes)
|
||||
app.route('/api/tariff', tariffRoutes)
|
||||
app.route('/api/settings', settingsRoutes)
|
||||
|
||||
app.get('/api', (c) => {
|
||||
const user = c.get('user')
|
||||
@@ -81,6 +89,63 @@ app.get('/api', (c) => {
|
||||
|
||||
app.get(
|
||||
'/ocpp/:chargePointId',
|
||||
async (c, next) => {
|
||||
const chargePointId = c.req.param('chargePointId')
|
||||
const connInfo = getConnInfo(c)
|
||||
const remoteAddr = connInfo.remote.address
|
||||
const authHeader = c.req.header('Authorization')
|
||||
|
||||
console.info(
|
||||
`[OCPP][AUTH][BEGIN] cp=${chargePointId} remote=${remoteAddr} hasAuthHeader=${Boolean(authHeader)}`,
|
||||
)
|
||||
|
||||
if (!authHeader?.startsWith('Basic ')) {
|
||||
console.warn(
|
||||
`[OCPP][AUTH][REJECT] cp=${chargePointId} remote=${remoteAddr} reason=missing_or_invalid_scheme`,
|
||||
)
|
||||
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 {
|
||||
console.warn(
|
||||
`[OCPP][AUTH][REJECT] cp=${chargePointId} remote=${remoteAddr} reason=invalid_authorization_header`,
|
||||
)
|
||||
return c.json({ error: 'Invalid Authorization header' }, 400)
|
||||
}
|
||||
|
||||
if (id !== chargePointId) {
|
||||
console.warn(
|
||||
`[OCPP][AUTH][REJECT] cp=${chargePointId} remote=${remoteAddr} reason=identity_mismatch authId=${id}`,
|
||||
)
|
||||
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))) {
|
||||
console.warn(
|
||||
`[OCPP][AUTH][REJECT] cp=${chargePointId} remote=${remoteAddr} reason=bad_credentials_or_missing_cp`,
|
||||
)
|
||||
return c.json({ error: 'Unauthorized' }, 401)
|
||||
}
|
||||
|
||||
console.info(`[OCPP][AUTH][PASS] cp=${chargePointId} remote=${remoteAddr}`)
|
||||
|
||||
await next()
|
||||
},
|
||||
upgradeWebSocket((c) => {
|
||||
const chargePointId = c.req.param('chargePointId')
|
||||
const connInfo = getConnInfo(c)
|
||||
|
||||
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,7 +1,7 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import dayjs from "dayjs";
|
||||
import { useDrizzle } from "@/lib/db.js";
|
||||
import { idTag } from "@/db/schema.js";
|
||||
import { idTag, transaction } from "@/db/schema.js";
|
||||
import type {
|
||||
AuthorizeRequest,
|
||||
AuthorizeResponse,
|
||||
@@ -9,6 +9,11 @@ import type {
|
||||
OcppConnectionContext,
|
||||
} from "../types.ts";
|
||||
|
||||
function shortIdTag(idTagValue: string): string {
|
||||
if (idTagValue.length <= 8) return idTagValue;
|
||||
return `${idTagValue.slice(0, 4)}...${idTagValue.slice(-4)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared helper — resolves idTagInfo from the database.
|
||||
* Used by Authorize, StartTransaction, and StopTransaction.
|
||||
@@ -19,6 +24,7 @@ import type {
|
||||
export async function resolveIdTagInfo(
|
||||
idTagValue: string,
|
||||
checkBalance = true,
|
||||
checkConcurrent = true,
|
||||
): Promise<IdTagInfo> {
|
||||
const db = useDrizzle();
|
||||
const [tag] = await db.select().from(idTag).where(eq(idTag.idTag, idTagValue)).limit(1);
|
||||
@@ -31,6 +37,17 @@ export async function resolveIdTagInfo(
|
||||
if (tag.status !== "Accepted") {
|
||||
return { status: tag.status as IdTagInfo["status"] };
|
||||
}
|
||||
|
||||
// Enforce single active transaction per idTag.
|
||||
if (checkConcurrent) {
|
||||
const [activeTx] = await db
|
||||
.select({ id: transaction.id })
|
||||
.from(transaction)
|
||||
.where(and(eq(transaction.idTag, idTagValue), isNull(transaction.stopTimestamp)))
|
||||
.limit(1);
|
||||
if (activeTx) return { status: "ConcurrentTx" };
|
||||
}
|
||||
|
||||
// Reject if balance is zero or negative
|
||||
if (checkBalance && tag.balance <= 0) {
|
||||
return { status: "Blocked" };
|
||||
@@ -44,9 +61,14 @@ export async function resolveIdTagInfo(
|
||||
|
||||
export async function handleAuthorize(
|
||||
payload: AuthorizeRequest,
|
||||
_ctx: OcppConnectionContext,
|
||||
ctx: OcppConnectionContext,
|
||||
): Promise<AuthorizeResponse> {
|
||||
console.info(
|
||||
`[OCPP][ACTION][Authorize][BEGIN] cp=${ctx.chargePointIdentifier} idTag=${shortIdTag(payload.idTag)}`,
|
||||
);
|
||||
const idTagInfo = await resolveIdTagInfo(payload.idTag);
|
||||
console.log(`[OCPP] Authorize idTag=${payload.idTag} -> ${idTagInfo.status}`);
|
||||
console.info(
|
||||
`[OCPP][ACTION][Authorize][END] cp=${ctx.chargePointIdentifier} idTag=${shortIdTag(payload.idTag)} status=${idTagInfo.status}`,
|
||||
);
|
||||
return { idTagInfo };
|
||||
}
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
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> {
|
||||
console.info(
|
||||
`[OCPP][ACTION][BootNotification][BEGIN] cp=${ctx.chargePointIdentifier} vendor=${payload.chargePointVendor} model=${payload.chargePointModel} fw=${payload.firmwareVersion ?? 'n/a'}`,
|
||||
)
|
||||
|
||||
const db = useDrizzle()
|
||||
const { heartbeatInterval } = await getOcpp16jSettings()
|
||||
|
||||
const [cp] = await db
|
||||
.insert(chargePoint)
|
||||
@@ -30,8 +34,9 @@ export async function handleBootNotification(
|
||||
meterSerialNumber: payload.meterSerialNumber ?? null,
|
||||
// New, unknown devices start as Pending — admin must manually accept them
|
||||
registrationStatus: 'Pending',
|
||||
heartbeatInterval: DEFAULT_HEARTBEAT_INTERVAL,
|
||||
heartbeatInterval,
|
||||
lastBootNotificationAt: dayjs().toDate(),
|
||||
transportStatus: 'online',
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: chargePoint.chargePointIdentifier,
|
||||
@@ -45,8 +50,9 @@ export async function handleBootNotification(
|
||||
meterType: payload.meterType ?? null,
|
||||
meterSerialNumber: payload.meterSerialNumber ?? null,
|
||||
// Do NOT override registrationStatus — preserve whatever the admin set
|
||||
heartbeatInterval: DEFAULT_HEARTBEAT_INTERVAL,
|
||||
heartbeatInterval,
|
||||
lastBootNotificationAt: dayjs().toDate(),
|
||||
transportStatus: 'online',
|
||||
updatedAt: dayjs().toDate(),
|
||||
},
|
||||
})
|
||||
@@ -55,11 +61,13 @@ export async function handleBootNotification(
|
||||
const status = cp.registrationStatus
|
||||
ctx.isRegistered = status === 'Accepted'
|
||||
|
||||
console.log(`[OCPP] BootNotification ${ctx.chargePointIdentifier} status=${status}`)
|
||||
console.info(
|
||||
`[OCPP][ACTION][BootNotification][END] cp=${ctx.chargePointIdentifier} status=${status} heartbeatInterval=${heartbeatInterval}`,
|
||||
)
|
||||
|
||||
return {
|
||||
currentTime: dayjs().toISOString(),
|
||||
interval: DEFAULT_HEARTBEAT_INTERVAL,
|
||||
interval: heartbeatInterval,
|
||||
status,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,14 +12,25 @@ export async function handleHeartbeat(
|
||||
_payload: HeartbeatRequest,
|
||||
ctx: OcppConnectionContext,
|
||||
): Promise<HeartbeatResponse> {
|
||||
const now = dayjs()
|
||||
console.info(`[OCPP][ACTION][Heartbeat][BEGIN] cp=${ctx.chargePointIdentifier}`)
|
||||
|
||||
const db = useDrizzle()
|
||||
|
||||
await db
|
||||
.update(chargePoint)
|
||||
.set({ lastHeartbeatAt: dayjs().toDate() })
|
||||
.set({
|
||||
lastHeartbeatAt: now.toDate(),
|
||||
transportStatus: 'online',
|
||||
updatedAt: now.toDate(),
|
||||
})
|
||||
.where(eq(chargePoint.chargePointIdentifier, ctx.chargePointIdentifier))
|
||||
|
||||
console.info(
|
||||
`[OCPP][ACTION][Heartbeat][END] cp=${ctx.chargePointIdentifier} currentTime=${now.toISOString()}`,
|
||||
)
|
||||
|
||||
return {
|
||||
currentTime: dayjs().toISOString(),
|
||||
currentTime: now.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@ export async function handleMeterValues(
|
||||
payload: MeterValuesRequest,
|
||||
ctx: OcppConnectionContext,
|
||||
): Promise<MeterValuesResponse> {
|
||||
console.info(
|
||||
`[OCPP][ACTION][MeterValues][BEGIN] cp=${ctx.chargePointIdentifier} connector=${payload.connectorId} txId=${payload.transactionId ?? 'n/a'} meterValueCount=${payload.meterValue.length}`,
|
||||
);
|
||||
|
||||
const db = useDrizzle();
|
||||
|
||||
const [cp] = await db
|
||||
@@ -48,5 +52,9 @@ export async function handleMeterValues(
|
||||
await db.insert(meterValue).values(records);
|
||||
}
|
||||
|
||||
console.info(
|
||||
`[OCPP][ACTION][MeterValues][END] cp=${ctx.chargePointIdentifier} connector=${payload.connectorId} txId=${payload.transactionId ?? 'n/a'} inserted=${records.length}`,
|
||||
);
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -9,10 +9,19 @@ import type {
|
||||
} from "../types.ts";
|
||||
import { resolveIdTagInfo } from "./authorize.ts";
|
||||
|
||||
function shortIdTag(idTagValue: string): string {
|
||||
if (idTagValue.length <= 8) return idTagValue;
|
||||
return `${idTagValue.slice(0, 4)}...${idTagValue.slice(-4)}`;
|
||||
}
|
||||
|
||||
export async function handleStartTransaction(
|
||||
payload: StartTransactionRequest,
|
||||
ctx: OcppConnectionContext,
|
||||
): Promise<StartTransactionResponse> {
|
||||
console.info(
|
||||
`[OCPP][ACTION][StartTransaction][BEGIN] cp=${ctx.chargePointIdentifier} connector=${payload.connectorId} idTag=${shortIdTag(payload.idTag)} meterStart=${payload.meterStart} timestamp=${payload.timestamp}`,
|
||||
);
|
||||
|
||||
const db = useDrizzle();
|
||||
|
||||
// Resolve idTag authorization
|
||||
@@ -73,13 +82,13 @@ 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));
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[OCPP] StartTransaction cp=${ctx.chargePointIdentifier} connector=${payload.connectorId} ` +
|
||||
`idTag=${payload.idTag} status=${idTagInfo.status} txId=${tx.id}`,
|
||||
`[OCPP][ACTION][StartTransaction][END] cp=${ctx.chargePointIdentifier} connector=${payload.connectorId} ` +
|
||||
`idTag=${shortIdTag(payload.idTag)} status=${idTagInfo.status} txId=${tx.id} rejected=${rejected}`,
|
||||
);
|
||||
|
||||
return { transactionId: tx.id, idTagInfo };
|
||||
|
||||
@@ -42,6 +42,10 @@ export async function handleStatusNotification(
|
||||
payload: StatusNotificationRequest,
|
||||
ctx: OcppConnectionContext,
|
||||
): Promise<StatusNotificationResponse> {
|
||||
console.info(
|
||||
`[OCPP][ACTION][StatusNotification][BEGIN] cp=${ctx.chargePointIdentifier} connector=${payload.connectorId} status=${payload.status} errorCode=${payload.errorCode}`,
|
||||
)
|
||||
|
||||
const db = useDrizzle()
|
||||
|
||||
// Retrieve the internal charge point id
|
||||
@@ -52,6 +56,9 @@ export async function handleStatusNotification(
|
||||
.limit(1)
|
||||
|
||||
if (!cp) {
|
||||
console.error(
|
||||
`[OCPP][ACTION][StatusNotification][ERROR] cp=${ctx.chargePointIdentifier} reason=charge_point_not_found`,
|
||||
)
|
||||
throw new Error(`ChargePoint not found: ${ctx.chargePointIdentifier}`)
|
||||
}
|
||||
|
||||
@@ -101,5 +108,9 @@ export async function handleStatusNotification(
|
||||
})
|
||||
}
|
||||
|
||||
console.info(
|
||||
`[OCPP][ACTION][StatusNotification][END] cp=${ctx.chargePointIdentifier} connector=${payload.connectorId} status=${connStatus} errorCode=${connErrorCode} historySaved=${Boolean(upsertedConnector)}`,
|
||||
)
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { asc, eq, sql } from "drizzle-orm";
|
||||
import dayjs from "dayjs";
|
||||
import { useDrizzle } from "@/lib/db.js";
|
||||
import { chargePoint, connector, idTag, meterValue, transaction } from "@/db/schema.js";
|
||||
import { tariff } from "@/db/schema.js";
|
||||
import type { SampledValue } from "@/db/schema.js";
|
||||
import type { TariffSlot, PriceTier } from "@/db/tariff-schema.ts";
|
||||
import type {
|
||||
StopTransactionRequest,
|
||||
StopTransactionResponse,
|
||||
@@ -11,8 +14,12 @@ import { resolveIdTagInfo } from "./authorize.ts";
|
||||
|
||||
export async function handleStopTransaction(
|
||||
payload: StopTransactionRequest,
|
||||
_ctx: OcppConnectionContext,
|
||||
ctx: OcppConnectionContext,
|
||||
): Promise<StopTransactionResponse> {
|
||||
console.info(
|
||||
`[OCPP][ACTION][StopTransaction][BEGIN] cp=${ctx.chargePointIdentifier} txId=${payload.transactionId} meterStop=${payload.meterStop} reason=${payload.reason ?? 'none'} timestamp=${payload.timestamp}`,
|
||||
);
|
||||
|
||||
const db = useDrizzle();
|
||||
|
||||
// Update the transaction record
|
||||
@@ -23,13 +30,20 @@ export async function handleStopTransaction(
|
||||
stopMeterValue: payload.meterStop,
|
||||
stopIdTag: payload.idTag ?? null,
|
||||
stopReason: (payload.reason as (typeof transaction.$inferSelect)["stopReason"]) ?? null,
|
||||
remoteStopStatus: sql`case
|
||||
when ${transaction.remoteStopRequestedAt} is not null and coalesce(${transaction.remoteStopStatus}, 'Requested') in ('Requested', 'Accepted')
|
||||
then 'Accepted'
|
||||
else ${transaction.remoteStopStatus}
|
||||
end`,
|
||||
updatedAt: dayjs().toDate(),
|
||||
})
|
||||
.where(eq(transaction.id, payload.transactionId))
|
||||
.returning();
|
||||
|
||||
if (!tx) {
|
||||
console.warn(`[OCPP] StopTransaction: transaction ${payload.transactionId} not found`);
|
||||
console.warn(
|
||||
`[OCPP][ACTION][StopTransaction][MISS] cp=${ctx.chargePointIdentifier} txId=${payload.transactionId} reason=transaction_not_found`,
|
||||
);
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -64,20 +78,103 @@ export async function handleStopTransaction(
|
||||
|
||||
const energyWh = payload.meterStop - tx.startMeterValue;
|
||||
|
||||
// Deduct balance from the idTag based on actual energy consumed
|
||||
// Load active tariff and charge point fee
|
||||
const [cp] = await db
|
||||
.select({ feePerKwh: chargePoint.feePerKwh })
|
||||
.select({ feePerKwh: chargePoint.feePerKwh, pricingMode: chargePoint.pricingMode })
|
||||
.from(chargePoint)
|
||||
.where(eq(chargePoint.id, tx.chargePointId))
|
||||
.limit(1);
|
||||
|
||||
const feeFen =
|
||||
cp && cp.feePerKwh > 0 && energyWh > 0 ? Math.ceil((energyWh * cp.feePerKwh) / 1000) : 0;
|
||||
const [activeTariff] = await db.select().from(tariff).where(eq(tariff.isActive, true)).limit(1);
|
||||
|
||||
let electricityFen: number | null = null;
|
||||
let serviceFeeFen: number | null = null;
|
||||
let feeFen = 0;
|
||||
|
||||
if (activeTariff && cp?.pricingMode === 'tou' && energyWh > 0 && tx.startTimestamp) {
|
||||
const stopTs = dayjs(payload.timestamp).toDate();
|
||||
const slots = activeTariff.slots as TariffSlot[];
|
||||
|
||||
const priceMap: Record<PriceTier, { electricity: number; service: number }> = {
|
||||
peak: {
|
||||
electricity: activeTariff.peakElectricityPrice,
|
||||
service: activeTariff.peakServiceFee,
|
||||
},
|
||||
valley: {
|
||||
electricity: activeTariff.valleyElectricityPrice,
|
||||
service: activeTariff.valleyServiceFee,
|
||||
},
|
||||
flat: {
|
||||
electricity: activeTariff.flatElectricityPrice,
|
||||
service: activeTariff.flatServiceFee,
|
||||
},
|
||||
};
|
||||
|
||||
// Build checkpoints from intermediate meter value readings.
|
||||
// Each checkpoint is (timestamp, cumulativeWh), sorted ascending.
|
||||
// Using actual intermediate readings lets each interval's known energy delta
|
||||
// be assigned to its true time slot rather than assuming uniform charging rate.
|
||||
const intervalReadings = await db
|
||||
.select({ timestamp: meterValue.timestamp, sampledValues: meterValue.sampledValues })
|
||||
.from(meterValue)
|
||||
.where(eq(meterValue.transactionId, tx.id))
|
||||
.orderBy(asc(meterValue.timestamp));
|
||||
|
||||
const checkpoints: { ts: Date; wh: number }[] = [
|
||||
{ ts: tx.startTimestamp, wh: tx.startMeterValue },
|
||||
];
|
||||
for (const mv of intervalReadings) {
|
||||
const svList = mv.sampledValues as SampledValue[];
|
||||
// Per OCPP 1.6 §7.17: absent measurand defaults to Energy.Active.Import.Register
|
||||
const energySv = svList.find(
|
||||
(sv) => (!sv.measurand || sv.measurand === "Energy.Active.Import.Register") && !sv.phase,
|
||||
);
|
||||
if (!energySv) continue;
|
||||
const rawVal = parseFloat(energySv.value);
|
||||
if (isNaN(rawVal)) continue;
|
||||
const wh = (energySv.unit ?? "Wh") === "kWh" ? rawVal * 1000 : rawVal;
|
||||
checkpoints.push({ ts: mv.timestamp, wh });
|
||||
}
|
||||
checkpoints.push({ ts: stopTs, wh: payload.meterStop });
|
||||
|
||||
// For each interval, compute actual energy delta and distribute across tiers
|
||||
// by walking minute-by-minute only within that interval.
|
||||
const tierEnergyWh: Record<PriceTier, number> = { peak: 0, valley: 0, flat: 0 };
|
||||
for (let i = 0; i + 1 < checkpoints.length; i++) {
|
||||
const { ts: t1, wh: wh1 } = checkpoints[i];
|
||||
const { ts: t2, wh: wh2 } = checkpoints[i + 1];
|
||||
const deltaWh = Math.max(0, wh2 - wh1);
|
||||
if (deltaWh === 0) continue;
|
||||
const fractions = calcTierFractions(t1, t2, slots);
|
||||
for (const tier of ["peak", "valley", "flat"] as PriceTier[]) {
|
||||
tierEnergyWh[tier] += deltaWh * fractions[tier];
|
||||
}
|
||||
}
|
||||
|
||||
let elecFen = 0;
|
||||
let svcFen = 0;
|
||||
for (const tier of ["peak", "valley", "flat"] as PriceTier[]) {
|
||||
const energyKwh = tierEnergyWh[tier] / 1000;
|
||||
elecFen += energyKwh * priceMap[tier].electricity;
|
||||
svcFen += energyKwh * priceMap[tier].service;
|
||||
}
|
||||
electricityFen = Math.ceil(elecFen);
|
||||
serviceFeeFen = Math.ceil(svcFen);
|
||||
feeFen = electricityFen + serviceFeeFen;
|
||||
} else if (cp && cp.feePerKwh > 0 && energyWh > 0) {
|
||||
// Fallback: flat rate per charge point
|
||||
feeFen = Math.ceil((energyWh * cp.feePerKwh) / 1000);
|
||||
}
|
||||
|
||||
// Always record the charge amount (0 if free)
|
||||
await db
|
||||
.update(transaction)
|
||||
.set({ chargeAmount: feeFen, updatedAt: dayjs().toDate() })
|
||||
.set({
|
||||
chargeAmount: feeFen,
|
||||
electricityFee: electricityFen,
|
||||
serviceFee: serviceFeeFen,
|
||||
updatedAt: dayjs().toDate(),
|
||||
})
|
||||
.where(eq(transaction.id, tx.id));
|
||||
|
||||
if (feeFen > 0) {
|
||||
@@ -91,12 +188,60 @@ export async function handleStopTransaction(
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[OCPP] StopTransaction txId=${payload.transactionId} ` +
|
||||
`reason=${payload.reason ?? "none"} energyWh=${energyWh} feeFen=${feeFen}`,
|
||||
`[OCPP][ACTION][StopTransaction][END] cp=${ctx.chargePointIdentifier} txId=${payload.transactionId} ` +
|
||||
`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'
|
||||
@@ -77,6 +101,59 @@ const actionHandlers: ActionHandlerMap = {
|
||||
StopTransaction: handleStopTransaction,
|
||||
}
|
||||
|
||||
function shortId(value: string): string {
|
||||
return value.length <= 12 ? value : `${value.slice(0, 8)}...${value.slice(-4)}`
|
||||
}
|
||||
|
||||
function summarizeCallPayload(action: string, payload: unknown): Record<string, unknown> {
|
||||
const p = payload as Record<string, unknown>
|
||||
switch (action) {
|
||||
case 'BootNotification':
|
||||
return {
|
||||
chargePointVendor: p.chargePointVendor,
|
||||
chargePointModel: p.chargePointModel,
|
||||
firmwareVersion: p.firmwareVersion ?? null,
|
||||
}
|
||||
case 'Authorize':
|
||||
return {
|
||||
idTag: typeof p.idTag === 'string' ? shortId(p.idTag) : undefined,
|
||||
}
|
||||
case 'Heartbeat':
|
||||
return {}
|
||||
case 'StatusNotification':
|
||||
return {
|
||||
connectorId: p.connectorId,
|
||||
status: p.status,
|
||||
errorCode: p.errorCode,
|
||||
timestamp: p.timestamp ?? null,
|
||||
}
|
||||
case 'StartTransaction':
|
||||
return {
|
||||
connectorId: p.connectorId,
|
||||
idTag: typeof p.idTag === 'string' ? shortId(p.idTag) : undefined,
|
||||
meterStart: p.meterStart,
|
||||
timestamp: p.timestamp,
|
||||
}
|
||||
case 'StopTransaction':
|
||||
return {
|
||||
transactionId: p.transactionId,
|
||||
meterStop: p.meterStop,
|
||||
reason: p.reason ?? null,
|
||||
timestamp: p.timestamp,
|
||||
}
|
||||
case 'MeterValues':
|
||||
return {
|
||||
connectorId: p.connectorId,
|
||||
transactionId: p.transactionId ?? null,
|
||||
meterValueCount: Array.isArray(p.meterValue) ? p.meterValue.length : 0,
|
||||
}
|
||||
default:
|
||||
return {
|
||||
keys: p && typeof p === 'object' ? Object.keys(p).slice(0, 20) : [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sendCallResult(ws: WSContext, uniqueId: string, payload: unknown): void {
|
||||
ws.send(JSON.stringify([OCPP_MESSAGE_TYPE.CALLRESULT, uniqueId, payload]))
|
||||
}
|
||||
@@ -92,6 +169,108 @@ 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))
|
||||
}
|
||||
|
||||
async function getRegistrationStatus(chargePointIdentifier: string) {
|
||||
const db = useDrizzle()
|
||||
const [cp] = await db
|
||||
.select({ registrationStatus: chargePoint.registrationStatus })
|
||||
.from(chargePoint)
|
||||
.where(eq(chargePoint.chargePointIdentifier, chargePointIdentifier))
|
||||
.limit(1)
|
||||
|
||||
return cp?.registrationStatus ?? null
|
||||
}
|
||||
|
||||
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())
|
||||
|
||||
console.info(
|
||||
`[OCPP][TX][CALL] cp=${chargePointIdentifier} action=${action} uniqueId=${uniqueId} timeoutMs=${timeoutMs}`,
|
||||
summarizeCallPayload(action, payload),
|
||||
)
|
||||
|
||||
const resultPromise = new Promise<TResult>((resolve, reject) => {
|
||||
const timeout = setTimeout(async () => {
|
||||
pendingCalls.delete(uniqueId)
|
||||
console.warn(
|
||||
`[OCPP][TX][TIMEOUT] cp=${chargePointIdentifier} action=${action} uniqueId=${uniqueId} timeoutMs=${timeoutMs}`,
|
||||
)
|
||||
await updateTransportState(chargePointIdentifier, {
|
||||
transportStatus: getCommandChannelStatus(chargePointIdentifier),
|
||||
lastCommandStatus: 'Timeout',
|
||||
lastCommandAt: dayjs().toDate(),
|
||||
})
|
||||
reject(new Error('CommandTimeout'))
|
||||
}, timeoutMs)
|
||||
|
||||
pendingCalls.set(uniqueId, {
|
||||
chargePointIdentifier,
|
||||
action,
|
||||
resolve: (response) => {
|
||||
console.info(
|
||||
`[OCPP][TX][CALLRESULT] cp=${chargePointIdentifier} action=${action} uniqueId=${uniqueId}`,
|
||||
)
|
||||
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(),
|
||||
})
|
||||
console.error(
|
||||
`[OCPP][TX][SEND_ERROR] cp=${chargePointIdentifier} action=${action} uniqueId=${uniqueId}`,
|
||||
error,
|
||||
)
|
||||
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,24 +283,70 @@ 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)) {
|
||||
console.warn(
|
||||
`[OCPP][WS][OPEN_REJECT] cp=${chargePointIdentifier} session=${sessionId} subProtocol=${subProtocol} reason=unsupported_subprotocol`,
|
||||
)
|
||||
ws.close(1002, 'Unsupported subprotocol')
|
||||
return
|
||||
}
|
||||
ocppConnections.set(chargePointIdentifier, ws)
|
||||
console.log(
|
||||
`[OCPP] ${chargePointIdentifier} connected` +
|
||||
(remoteAddr ? ` from ${remoteAddr}` : ''),
|
||||
|
||||
const registrationStatus = await getRegistrationStatus(chargePointIdentifier)
|
||||
ctx.isRegistered = registrationStatus === 'Accepted'
|
||||
|
||||
const previous = ocppConnections.get(chargePointIdentifier)
|
||||
if (previous && previous.sessionId !== sessionId) {
|
||||
try {
|
||||
previous.ws.close(1012, 'Replaced by newer connection')
|
||||
} catch {
|
||||
// Ignore close race when the old socket is already gone.
|
||||
}
|
||||
}
|
||||
|
||||
ocppConnections.set(chargePointIdentifier, {
|
||||
ws,
|
||||
sessionId,
|
||||
openedAt: new Date(),
|
||||
lastMessageAt: new Date(),
|
||||
})
|
||||
await updateTransportState(chargePointIdentifier, {
|
||||
transportStatus: 'online',
|
||||
connectionSessionId: sessionId,
|
||||
lastWsConnectedAt: dayjs().toDate(),
|
||||
})
|
||||
console.info(
|
||||
`[OCPP][WS][OPEN] cp=${chargePointIdentifier} session=${sessionId} subProtocol=${subProtocol} registered=${ctx.isRegistered}` +
|
||||
(remoteAddr ? ` remote=${remoteAddr}` : ''),
|
||||
)
|
||||
if (previous && previous.sessionId !== sessionId) {
|
||||
console.info(
|
||||
`[OCPP][WS][REPLACE] cp=${chargePointIdentifier} oldSession=${previous.sessionId} newSession=${sessionId}`,
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
async onMessage(evt: MessageEvent, ws: WSContext) {
|
||||
let uniqueId = '(unknown)'
|
||||
try {
|
||||
const current = ocppConnections.get(chargePointIdentifier)
|
||||
if (!current || current.sessionId !== sessionId) {
|
||||
console.warn(
|
||||
`[OCPP][WS][STALE] cp=${chargePointIdentifier} session=${sessionId} activeSession=${current?.sessionId ?? 'none'}`,
|
||||
)
|
||||
try {
|
||||
ws.close(1008, 'Stale connection')
|
||||
} catch {
|
||||
// Ignore close errors on stale sockets.
|
||||
}
|
||||
return
|
||||
}
|
||||
current.lastMessageAt = new Date()
|
||||
|
||||
const raw = evt.data
|
||||
if (typeof raw !== 'string') return
|
||||
|
||||
@@ -141,13 +366,65 @@ 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.CALL) {
|
||||
const [, , action, payload] = message as OcppCall
|
||||
console.info(
|
||||
`[OCPP][RX][CALL] cp=${chargePointIdentifier} session=${sessionId} action=${action} uniqueId=${uniqueId}`,
|
||||
summarizeCallPayload(action, payload),
|
||||
)
|
||||
} else if (messageType === OCPP_MESSAGE_TYPE.CALLRESULT) {
|
||||
console.info(
|
||||
`[OCPP][RX][CALLRESULT] cp=${chargePointIdentifier} session=${sessionId} uniqueId=${uniqueId}`,
|
||||
)
|
||||
} else if (messageType === OCPP_MESSAGE_TYPE.CALLERROR) {
|
||||
const [, , errorCode, errorDescription] = message as OcppCallErrorMessage
|
||||
console.warn(
|
||||
`[OCPP][RX][CALLERROR] cp=${chargePointIdentifier} session=${sessionId} uniqueId=${uniqueId} code=${errorCode} desc=${errorDescription}`,
|
||||
)
|
||||
}
|
||||
|
||||
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(),
|
||||
})
|
||||
console.warn(
|
||||
`[OCPP][TX][REJECTED] cp=${pending.chargePointIdentifier} action=${pending.action} uniqueId=${responseUniqueId} code=${errorCode} desc=${errorDescription}`,
|
||||
)
|
||||
pending.reject(new Error(`${errorCode}:${errorDescription}`))
|
||||
return
|
||||
}
|
||||
|
||||
if (messageType !== OCPP_MESSAGE_TYPE.CALL) return
|
||||
|
||||
const [, , action, payload] = message as OcppCall
|
||||
|
||||
// Enforce BootNotification before any other action
|
||||
if (!ctx.isRegistered && action !== 'BootNotification') {
|
||||
console.warn(
|
||||
`[OCPP][RX][REJECT] cp=${chargePointIdentifier} session=${sessionId} action=${action} uniqueId=${uniqueId} reason=boot_notification_required`,
|
||||
)
|
||||
sendCallError(
|
||||
ws,
|
||||
uniqueId,
|
||||
@@ -159,6 +436,9 @@ export function createOcppHandler(chargePointIdentifier: string, remoteAddr?: st
|
||||
|
||||
const handler = actionHandlers[action as keyof ActionHandlerMap]
|
||||
if (!handler) {
|
||||
console.warn(
|
||||
`[OCPP][RX][NOT_IMPLEMENTED] cp=${chargePointIdentifier} session=${sessionId} action=${action} uniqueId=${uniqueId}`,
|
||||
)
|
||||
sendCallError(ws, uniqueId, 'NotImplemented', `Action '${action}' is not implemented`)
|
||||
return
|
||||
}
|
||||
@@ -167,16 +447,31 @@ export function createOcppHandler(chargePointIdentifier: string, remoteAddr?: st
|
||||
handler as (payload: unknown, ctx: OcppConnectionContext) => Promise<unknown>
|
||||
)(payload, ctx)
|
||||
|
||||
console.info(
|
||||
`[OCPP][TX][CALLRESULT] cp=${chargePointIdentifier} session=${sessionId} action=${action} uniqueId=${uniqueId}`,
|
||||
)
|
||||
sendCallResult(ws, uniqueId, response)
|
||||
} catch (err) {
|
||||
console.error(`[OCPP] Error handling message from ${chargePointIdentifier} (uniqueId=${uniqueId}):`, err)
|
||||
console.error(
|
||||
`[OCPP][RX][ERROR] cp=${chargePointIdentifier} session=${sessionId} uniqueId=${uniqueId}`,
|
||||
err,
|
||||
)
|
||||
sendCallError(ws, uniqueId, 'InternalError', 'Internal server error')
|
||||
}
|
||||
},
|
||||
|
||||
onClose(evt: CloseEvent, _ws: WSContext) {
|
||||
ocppConnections.delete(chargePointIdentifier)
|
||||
console.log(`[OCPP] ${chargePointIdentifier} disconnected (code=${evt.code})`)
|
||||
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.info(
|
||||
`[OCPP][WS][CLOSE] cp=${chargePointIdentifier} session=${sessionId} code=${evt.code} activeSession=${current?.sessionId ?? 'none'}`,
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -2,7 +2,10 @@ import { Hono } from "hono";
|
||||
import { desc, eq, sql } from "drizzle-orm";
|
||||
import dayjs from "dayjs";
|
||||
import { useDrizzle } from "@/lib/db.js";
|
||||
import { chargePoint, connector } from "@/db/schema.js";
|
||||
import { chargePoint, connector, meterValue } from "@/db/schema.js";
|
||||
import type { SampledValue } from "@/db/schema.js";
|
||||
import { ocppConnections } from "@/ocpp/handler.js";
|
||||
import { generateOcppPassword, hashOcppPassword } from "@/lib/ocpp-auth.js";
|
||||
import type { HonoEnv } from "@/types/hono.ts";
|
||||
|
||||
const app = new Hono<HonoEnv>();
|
||||
@@ -69,6 +72,8 @@ app.post("/", async (c) => {
|
||||
chargePointModel?: string;
|
||||
registrationStatus?: "Accepted" | "Pending" | "Rejected";
|
||||
feePerKwh?: number;
|
||||
pricingMode?: "fixed" | "tou";
|
||||
deviceName?: string;
|
||||
}>();
|
||||
|
||||
if (!body.chargePointIdentifier?.trim()) {
|
||||
@@ -77,6 +82,12 @@ app.post("/", async (c) => {
|
||||
if (body.feePerKwh !== undefined && (!Number.isInteger(body.feePerKwh) || body.feePerKwh < 0)) {
|
||||
return c.json({ error: "feePerKwh must be a non-negative integer" }, 400);
|
||||
}
|
||||
if (body.pricingMode !== undefined && !["fixed", "tou"].includes(body.pricingMode)) {
|
||||
return c.json({ error: "pricingMode must be 'fixed' or 'tou'" }, 400);
|
||||
}
|
||||
|
||||
const plainPassword = generateOcppPassword();
|
||||
const passwordHash = await hashOcppPassword(plainPassword);
|
||||
|
||||
const [created] = await db
|
||||
.insert(chargePoint)
|
||||
@@ -87,6 +98,9 @@ app.post("/", async (c) => {
|
||||
chargePointModel: body.chargePointModel?.trim() || "Unknown",
|
||||
registrationStatus: body.registrationStatus ?? "Pending",
|
||||
feePerKwh: body.feePerKwh ?? 0,
|
||||
pricingMode: body.pricingMode ?? "fixed",
|
||||
deviceName: body.deviceName?.trim() || null,
|
||||
passwordHash,
|
||||
})
|
||||
.returning()
|
||||
.catch((err: Error) => {
|
||||
@@ -94,13 +108,22 @@ 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 */
|
||||
app.get("/:id", async (c) => {
|
||||
const db = useDrizzle();
|
||||
const id = c.req.param("id");
|
||||
const isAdmin = c.get("user")?.role === "admin";
|
||||
|
||||
const [cp] = await db.select().from(chargePoint).where(eq(chargePoint.id, id)).limit(1);
|
||||
|
||||
@@ -109,12 +132,39 @@ app.get("/:id", async (c) => {
|
||||
const allConnectors = await db.select().from(connector).where(eq(connector.chargePointId, id));
|
||||
const cpStatus = allConnectors.find((conn) => conn.connectorId === 0);
|
||||
const displayConnectors = allConnectors.filter((conn) => conn.connectorId > 0);
|
||||
const [latestMeter] = await db
|
||||
.select({
|
||||
timestamp: meterValue.timestamp,
|
||||
sampledValues: meterValue.sampledValues,
|
||||
})
|
||||
.from(meterValue)
|
||||
.where(eq(meterValue.chargePointId, id))
|
||||
.orderBy(desc(meterValue.timestamp), desc(meterValue.receivedAt))
|
||||
.limit(1);
|
||||
|
||||
const meterHistory = isAdmin
|
||||
? (
|
||||
await db
|
||||
.select({
|
||||
connectorNumber: meterValue.connectorNumber,
|
||||
timestamp: meterValue.timestamp,
|
||||
sampledValues: meterValue.sampledValues,
|
||||
})
|
||||
.from(meterValue)
|
||||
.where(eq(meterValue.chargePointId, id))
|
||||
.orderBy(desc(meterValue.timestamp), desc(meterValue.receivedAt))
|
||||
.limit(24)
|
||||
).reverse()
|
||||
: [];
|
||||
|
||||
return c.json({
|
||||
...cp,
|
||||
connectors: displayConnectors,
|
||||
chargePointStatus: cpStatus?.status ?? null,
|
||||
chargePointErrorCode: cpStatus?.errorCode ?? null,
|
||||
latestMeterTimestamp: latestMeter?.timestamp ?? null,
|
||||
latestMeterValues: ((latestMeter?.sampledValues as SampledValue[] | undefined) ?? []),
|
||||
meterHistory,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -125,16 +175,20 @@ app.patch("/:id", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const body = await c.req.json<{
|
||||
feePerKwh?: number;
|
||||
pricingMode?: "fixed" | "tou";
|
||||
registrationStatus?: string;
|
||||
chargePointVendor?: string;
|
||||
chargePointModel?: string;
|
||||
deviceName?: string | null;
|
||||
}>();
|
||||
|
||||
const set: {
|
||||
feePerKwh?: number;
|
||||
pricingMode?: "fixed" | "tou";
|
||||
registrationStatus?: "Accepted" | "Pending" | "Rejected";
|
||||
chargePointVendor?: string;
|
||||
chargePointModel?: string;
|
||||
deviceName?: string | null;
|
||||
updatedAt: Date;
|
||||
} = { updatedAt: dayjs().toDate() };
|
||||
|
||||
@@ -150,14 +204,19 @@ app.patch("/:id", async (c) => {
|
||||
}
|
||||
set.registrationStatus = body.registrationStatus as "Accepted" | "Pending" | "Rejected";
|
||||
}
|
||||
if (body.chargePointVendor !== undefined) set.chargePointVendor = body.chargePointVendor.trim() || "Unknown";
|
||||
if (body.chargePointModel !== undefined) set.chargePointModel = body.chargePointModel.trim() || "Unknown";
|
||||
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)
|
||||
.set(set)
|
||||
.where(eq(chargePoint.id, id))
|
||||
.returning();
|
||||
const [updated] = await db.update(chargePoint).set(set).where(eq(chargePoint.id, id)).returning();
|
||||
|
||||
if (!updated) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
@@ -189,4 +248,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;
|
||||
|
||||
@@ -3,7 +3,6 @@ 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";
|
||||
import { z } from "zod";
|
||||
import type { HonoEnv } from "@/types/hono.ts";
|
||||
|
||||
@@ -16,6 +15,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 });
|
||||
|
||||
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;
|
||||
@@ -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;
|
||||
@@ -3,9 +3,10 @@ import { and, desc, eq, isNull, isNotNull, sql } from "drizzle-orm";
|
||||
import dayjs from "dayjs";
|
||||
import { useDrizzle } from "@/lib/db.js";
|
||||
import { transaction, chargePoint, connector, idTag } from "@/db/schema.js";
|
||||
import type { SampledValue } from "@/db/schema.js";
|
||||
import { user } from "@/db/auth-schema.js";
|
||||
import { ocppConnections } from "@/ocpp/handler.js";
|
||||
import { OCPP_MESSAGE_TYPE } from "@/ocpp/types.js";
|
||||
import { sendOcppCall } from "@/ocpp/handler.js";
|
||||
import { resolveIdTagInfo } from "@/ocpp/actions/authorize.js";
|
||||
import type { HonoEnv } from "@/types/hono.ts";
|
||||
|
||||
const app = new Hono<HonoEnv>();
|
||||
@@ -20,11 +21,13 @@ app.post("/remote-start", async (c) => {
|
||||
if (!currentUser) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const db = useDrizzle();
|
||||
const body = await c.req.json<{
|
||||
chargePointIdentifier: string;
|
||||
connectorId: number;
|
||||
idTag: string;
|
||||
}>().catch(() => null);
|
||||
const body = await c.req
|
||||
.json<{
|
||||
chargePointIdentifier: string;
|
||||
connectorId: number;
|
||||
idTag: string;
|
||||
}>()
|
||||
.catch(() => null);
|
||||
|
||||
if (
|
||||
!body ||
|
||||
@@ -39,15 +42,33 @@ app.post("/remote-start", async (c) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Non-admin: verify idTag belongs to current user and is Accepted
|
||||
// Non-admin: verify idTag belongs to current user
|
||||
if (currentUser.role !== "admin") {
|
||||
const [tag] = await db
|
||||
.select({ status: idTag.status })
|
||||
.select({ idTag: idTag.idTag })
|
||||
.from(idTag)
|
||||
.where(and(eq(idTag.idTag, body.idTag.trim()), eq(idTag.userId, currentUser.id)))
|
||||
.limit(1);
|
||||
if (!tag) return c.json({ error: "idTag not found or not authorized" }, 403);
|
||||
if (tag.status !== "Accepted") return c.json({ error: "idTag is not accepted" }, 400);
|
||||
}
|
||||
|
||||
// Reuse the same authorization logic as Authorize/StartTransaction.
|
||||
const tagInfo = await resolveIdTagInfo(body.idTag.trim());
|
||||
if (tagInfo.status !== "Accepted") {
|
||||
if (tagInfo.status === "ConcurrentTx") {
|
||||
return c.json({ error: "ConcurrentTx: idTag already has an active transaction" }, 409);
|
||||
}
|
||||
return c.json({ error: `idTag rejected: ${tagInfo.status}` }, 400);
|
||||
}
|
||||
|
||||
// One idTag can only have one active transaction at a time.
|
||||
const [activeTx] = await db
|
||||
.select({ id: transaction.id })
|
||||
.from(transaction)
|
||||
.where(and(eq(transaction.idTag, body.idTag.trim()), isNull(transaction.stopTimestamp)))
|
||||
.limit(1);
|
||||
if (activeTx) {
|
||||
return c.json({ error: "ConcurrentTx: idTag already has an active transaction" }, 409);
|
||||
}
|
||||
|
||||
// Verify charge point exists and is Accepted
|
||||
@@ -62,19 +83,28 @@ app.post("/remote-start", async (c) => {
|
||||
return c.json({ error: "ChargePoint is not accepted" }, 400);
|
||||
}
|
||||
|
||||
// Require the charge point to be online
|
||||
const ws = ocppConnections.get(body.chargePointIdentifier.trim());
|
||||
if (!ws) return c.json({ error: "ChargePoint is offline" }, 503);
|
||||
try {
|
||||
const response = await sendOcppCall<
|
||||
{ connectorId: number; idTag: string },
|
||||
{ status?: string }
|
||||
>(body.chargePointIdentifier.trim(), "RemoteStartTransaction", {
|
||||
connectorId: body.connectorId,
|
||||
idTag: body.idTag.trim(),
|
||||
})
|
||||
|
||||
const uniqueId = crypto.randomUUID();
|
||||
ws.send(
|
||||
JSON.stringify([
|
||||
OCPP_MESSAGE_TYPE.CALL,
|
||||
uniqueId,
|
||||
"RemoteStartTransaction",
|
||||
{ connectorId: body.connectorId, idTag: body.idTag.trim() },
|
||||
]),
|
||||
);
|
||||
if (response?.status && response.status !== "Accepted") {
|
||||
return c.json({ error: `RemoteStartTransaction ${response.status}` }, 409)
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "CommandSendFailed"
|
||||
if (message === "TransportUnavailable") {
|
||||
return c.json({ error: "ChargePoint command channel is unavailable" }, 503)
|
||||
}
|
||||
if (message === "CommandTimeout") {
|
||||
return c.json({ error: "ChargePoint did not confirm RemoteStartTransaction in time" }, 504)
|
||||
}
|
||||
return c.json({ error: `RemoteStartTransaction failed: ${message}` }, 502)
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[OCPP] RemoteStartTransaction cp=${body.chargePointIdentifier} ` +
|
||||
@@ -122,6 +152,9 @@ app.get("/", async (c) => {
|
||||
.select({
|
||||
transaction,
|
||||
chargePointIdentifier: chargePoint.chargePointIdentifier,
|
||||
chargePointDeviceName: chargePoint.deviceName,
|
||||
feePerKwh: chargePoint.feePerKwh,
|
||||
pricingMode: chargePoint.pricingMode,
|
||||
connectorNumber: connector.connectorId,
|
||||
idTagUserId: idTag.userId,
|
||||
idTagUserName: user.name,
|
||||
@@ -136,18 +169,75 @@ app.get("/", async (c) => {
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
// For active transactions, fetch the latest meter reading to show live energy
|
||||
const activeTxIds = rows.filter((r) => !r.transaction.stopTimestamp).map((r) => r.transaction.id);
|
||||
|
||||
// Map: transactionId -> latest cumulative Wh from meterValue
|
||||
const latestMeterMap = new Map<number, number>();
|
||||
if (activeTxIds.length > 0) {
|
||||
// DISTINCT ON picks the most recent row per transaction_id
|
||||
const latestRows = await db.execute<{
|
||||
transaction_id: number;
|
||||
sampled_values: SampledValue[];
|
||||
}>(sql`
|
||||
SELECT DISTINCT ON (transaction_id) transaction_id, sampled_values
|
||||
FROM meter_value
|
||||
WHERE transaction_id IN (${sql.join(
|
||||
activeTxIds.map((id) => sql`${id}`),
|
||||
sql`, `,
|
||||
)})
|
||||
ORDER BY transaction_id, timestamp DESC
|
||||
`);
|
||||
for (const row of latestRows.rows) {
|
||||
const svList = row.sampled_values as SampledValue[];
|
||||
const energySv = svList.find(
|
||||
(sv) => (!sv.measurand || sv.measurand === "Energy.Active.Import.Register") && !sv.phase,
|
||||
);
|
||||
if (energySv != null) {
|
||||
// Unit defaults to Wh; kWh is also common
|
||||
const raw = parseFloat(energySv.value);
|
||||
if (!Number.isNaN(raw)) {
|
||||
const wh = energySv.unit === "kWh" ? raw * 1000 : raw;
|
||||
latestMeterMap.set(Number(row.transaction_id), wh);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({
|
||||
data: rows.map((r) => ({
|
||||
...r.transaction,
|
||||
chargePointIdentifier: r.chargePointIdentifier,
|
||||
connectorNumber: r.connectorNumber,
|
||||
idTagUserId: r.idTagUserId,
|
||||
idTagUserName: r.idTagUserName,
|
||||
energyWh:
|
||||
r.transaction.stopMeterValue != null
|
||||
? r.transaction.stopMeterValue - r.transaction.startMeterValue
|
||||
: null,
|
||||
})),
|
||||
data: rows.map((r) => {
|
||||
const isActive = !r.transaction.stopTimestamp;
|
||||
const latestMeterWh = isActive ? (latestMeterMap.get(r.transaction.id) ?? null) : null;
|
||||
const liveEnergyWh =
|
||||
latestMeterWh != null ? latestMeterWh - r.transaction.startMeterValue : null;
|
||||
|
||||
// Estimated cost: only for fixed pricing (TOU requires full interval analysis)
|
||||
let estimatedCost: number | null = null;
|
||||
if (
|
||||
isActive &&
|
||||
liveEnergyWh != null &&
|
||||
liveEnergyWh > 0 &&
|
||||
r.pricingMode === "fixed" &&
|
||||
(r.feePerKwh ?? 0) > 0
|
||||
) {
|
||||
estimatedCost = Math.ceil((liveEnergyWh * (r.feePerKwh ?? 0)) / 1000);
|
||||
}
|
||||
|
||||
return {
|
||||
...r.transaction,
|
||||
chargePointIdentifier: r.chargePointIdentifier,
|
||||
chargePointDeviceName: r.chargePointDeviceName,
|
||||
connectorNumber: r.connectorNumber,
|
||||
idTagUserId: r.idTagUserId,
|
||||
idTagUserName: r.idTagUserName,
|
||||
energyWh:
|
||||
r.transaction.stopMeterValue != null
|
||||
? r.transaction.stopMeterValue - r.transaction.startMeterValue
|
||||
: null,
|
||||
liveEnergyWh,
|
||||
estimatedCost,
|
||||
};
|
||||
}),
|
||||
total,
|
||||
page,
|
||||
totalPages: Math.max(1, Math.ceil(total / limit)),
|
||||
@@ -163,30 +253,74 @@ app.get("/:id", async (c) => {
|
||||
.select({
|
||||
transaction,
|
||||
chargePointIdentifier: chargePoint.chargePointIdentifier,
|
||||
chargePointDeviceName: chargePoint.deviceName,
|
||||
connectorNumber: connector.connectorId,
|
||||
feePerKwh: chargePoint.feePerKwh,
|
||||
pricingMode: chargePoint.pricingMode,
|
||||
})
|
||||
.from(transaction)
|
||||
.leftJoin(chargePoint, eq(transaction.chargePointId, chargePoint.id))
|
||||
.leftJoin(connector, eq(transaction.connectorId, connector.id))
|
||||
.where(eq(transaction.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
let liveEnergyWh: number | null = null;
|
||||
let estimatedCost: number | null = null;
|
||||
|
||||
// For active transactions, return live estimated energy/cost like the list endpoint.
|
||||
if (!row.transaction.stopTimestamp) {
|
||||
const latestRows = await db.execute<{
|
||||
sampled_values: SampledValue[];
|
||||
}>(sql`
|
||||
SELECT sampled_values
|
||||
FROM meter_value
|
||||
WHERE transaction_id = ${id}
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
const latest = latestRows.rows[0];
|
||||
if (latest) {
|
||||
const svList = latest.sampled_values as SampledValue[];
|
||||
const energySv = svList.find(
|
||||
(sv) => (!sv.measurand || sv.measurand === "Energy.Active.Import.Register") && !sv.phase,
|
||||
);
|
||||
|
||||
if (energySv != null) {
|
||||
const raw = parseFloat(energySv.value);
|
||||
if (!Number.isNaN(raw) && row.transaction.startMeterValue != null) {
|
||||
const latestMeterWh = energySv.unit === "kWh" ? raw * 1000 : raw;
|
||||
liveEnergyWh = latestMeterWh - row.transaction.startMeterValue;
|
||||
|
||||
if (liveEnergyWh > 0 && row.pricingMode === "fixed" && (row.feePerKwh ?? 0) > 0) {
|
||||
estimatedCost = Math.ceil((liveEnergyWh * (row.feePerKwh ?? 0)) / 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({
|
||||
...row.transaction,
|
||||
chargePointIdentifier: row.chargePointIdentifier,
|
||||
chargePointDeviceName: row.chargePointDeviceName,
|
||||
connectorNumber: row.connectorNumber,
|
||||
energyWh:
|
||||
row.transaction.stopMeterValue != null
|
||||
? row.transaction.stopMeterValue - row.transaction.startMeterValue
|
||||
: null,
|
||||
liveEnergyWh,
|
||||
estimatedCost,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/transactions/:id/stop
|
||||
* Manually stop an active transaction.
|
||||
* 1. If the charge point is connected, send OCPP RemoteStopTransaction.
|
||||
* 2. In either case (online or offline), settle the transaction in the DB immediately
|
||||
* so the record is always finalised from the admin side.
|
||||
* 1. If the charge point is connected, send OCPP RemoteStopTransaction and wait for confirmation.
|
||||
* 2. Record the stop request state in DB; final settlement still happens on StopTransaction.
|
||||
*/
|
||||
app.post("/:id/stop", async (c) => {
|
||||
const db = useDrizzle();
|
||||
@@ -197,7 +331,6 @@ app.post("/:id/stop", async (c) => {
|
||||
.select({
|
||||
transaction,
|
||||
chargePointIdentifier: chargePoint.chargePointIdentifier,
|
||||
feePerKwh: chargePoint.feePerKwh,
|
||||
})
|
||||
.from(transaction)
|
||||
.leftJoin(chargePoint, eq(transaction.chargePointId, chargePoint.id))
|
||||
@@ -209,55 +342,74 @@ app.post("/:id/stop", async (c) => {
|
||||
|
||||
const now = dayjs();
|
||||
|
||||
// Try to send RemoteStopTransaction via OCPP if the charge point is online
|
||||
const ws = row.chargePointIdentifier ? ocppConnections.get(row.chargePointIdentifier) : null;
|
||||
if (ws) {
|
||||
const uniqueId = crypto.randomUUID();
|
||||
ws.send(
|
||||
JSON.stringify([
|
||||
OCPP_MESSAGE_TYPE.CALL,
|
||||
uniqueId,
|
||||
let remoteStopStatus: "Requested" | "Accepted" | "Rejected" | "Error" | "Timeout" = "Requested";
|
||||
let remoteStopRequestId: string | null = null;
|
||||
let online = false;
|
||||
|
||||
if (row.chargePointIdentifier) {
|
||||
remoteStopRequestId = crypto.randomUUID();
|
||||
try {
|
||||
const response = await sendOcppCall<{ transactionId: number }, { status?: string }>(
|
||||
row.chargePointIdentifier,
|
||||
"RemoteStopTransaction",
|
||||
{ transactionId: row.transaction.id },
|
||||
]),
|
||||
);
|
||||
console.log(`[OCPP] Sent RemoteStopTransaction txId=${id} to ${row.chargePointIdentifier}`);
|
||||
{ uniqueId: remoteStopRequestId },
|
||||
)
|
||||
online = true;
|
||||
remoteStopStatus = response?.status === "Accepted" ? "Accepted" : "Rejected";
|
||||
console.log(`[OCPP] RemoteStopTransaction txId=${id} status=${response?.status ?? "unknown"} to ${row.chargePointIdentifier}`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "CommandSendFailed";
|
||||
remoteStopStatus = message === "CommandTimeout" ? "Timeout" : "Error";
|
||||
online = message !== "TransportUnavailable";
|
||||
console.warn(`[OCPP] RemoteStopTransaction txId=${id} failed for ${row.chargePointIdentifier}: ${message}`);
|
||||
if (message === "TransportUnavailable") {
|
||||
return c.json(
|
||||
{
|
||||
error: "ChargePoint command channel is unavailable",
|
||||
online: false,
|
||||
remoteStopStatus,
|
||||
},
|
||||
503,
|
||||
);
|
||||
}
|
||||
if (message === "CommandTimeout") {
|
||||
return c.json(
|
||||
{
|
||||
error: "ChargePoint did not confirm RemoteStopTransaction in time",
|
||||
online: true,
|
||||
remoteStopStatus,
|
||||
},
|
||||
504,
|
||||
);
|
||||
}
|
||||
return c.json(
|
||||
{
|
||||
error: `RemoteStopTransaction failed: ${message}`,
|
||||
online,
|
||||
remoteStopStatus,
|
||||
},
|
||||
502,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Settle in DB regardless (charge point may be offline or slow to respond)
|
||||
// Use startMeterValue as stopMeterValue when the real value is unknown (offline case)
|
||||
const stopMeterValue = row.transaction.startMeterValue;
|
||||
const energyWh = 0; // cannot know actual energy without stop meter value
|
||||
const feePerKwh = row.feePerKwh ?? 0;
|
||||
const feeFen = feePerKwh > 0 && energyWh > 0 ? Math.ceil((energyWh * feePerKwh) / 1000) : 0;
|
||||
|
||||
const [updated] = await db
|
||||
.update(transaction)
|
||||
.set({
|
||||
stopTimestamp: now.toDate(),
|
||||
stopMeterValue,
|
||||
stopReason: "Remote",
|
||||
chargeAmount: feeFen,
|
||||
remoteStopRequestedAt: now.toDate(),
|
||||
remoteStopRequestId,
|
||||
remoteStopStatus,
|
||||
updatedAt: now.toDate(),
|
||||
})
|
||||
.where(eq(transaction.id, id))
|
||||
.returning();
|
||||
|
||||
if (feeFen > 0) {
|
||||
await db
|
||||
.update(idTag)
|
||||
.set({
|
||||
balance: sql`GREATEST(0, ${idTag.balance} - ${feeFen})`,
|
||||
updatedAt: now.toDate(),
|
||||
})
|
||||
.where(eq(idTag.idTag, row.transaction.idTag));
|
||||
}
|
||||
|
||||
return c.json({
|
||||
...updated,
|
||||
chargePointIdentifier: row.chargePointIdentifier,
|
||||
online: !!ws,
|
||||
energyWh,
|
||||
online,
|
||||
remoteStopStatus,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ COPY apps/web/components ./apps/web/components
|
||||
COPY apps/web/lib ./apps/web/lib
|
||||
COPY apps/web/types ./apps/web/types
|
||||
COPY apps/web/public ./apps/web/public
|
||||
COPY apps/web/middleware.ts ./apps/web/middleware.ts
|
||||
COPY apps/web/proxy.ts ./apps/web/proxy.ts
|
||||
|
||||
RUN pnpm install --filter web
|
||||
|
||||
|
||||
@@ -15,11 +15,19 @@ 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,
|
||||
type MeterHistoryPoint,
|
||||
type MeterSampledValue,
|
||||
} 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 ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -55,6 +63,8 @@ const registrationColorMap: Record<string, "success" | "warning" | "danger"> = {
|
||||
Rejected: "danger",
|
||||
};
|
||||
|
||||
const RESET_CONFIRM_TEXT = "我将重新配置设备";
|
||||
|
||||
const TX_LIMIT = 10;
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
@@ -72,12 +82,187 @@ function relativeTime(iso: string): string {
|
||||
return dayjs(iso).fromNow();
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string | null | undefined): string {
|
||||
if (!iso) return "—";
|
||||
return dayjs(iso).format("YYYY/M/D HH:mm:ss");
|
||||
}
|
||||
|
||||
function extractMeterValue(sampledValues: MeterSampledValue[], measurands: string[]) {
|
||||
const parsedValues = sampledValues
|
||||
.map((sv) => {
|
||||
const numericValue = Number(sv.value);
|
||||
if (Number.isNaN(numericValue)) return null;
|
||||
return {
|
||||
value: numericValue,
|
||||
measurand: sv.measurand,
|
||||
phase: sv.phase,
|
||||
unit: sv.unit,
|
||||
};
|
||||
})
|
||||
.filter((sv): sv is NonNullable<typeof sv> => sv !== null);
|
||||
|
||||
const withoutPhase = parsedValues.find(
|
||||
(sv) =>
|
||||
((sv.measurand == null && measurands.includes("Energy.Active.Import.Register")) ||
|
||||
(sv.measurand != null && measurands.includes(sv.measurand))) &&
|
||||
!sv.phase,
|
||||
);
|
||||
if (withoutPhase) return withoutPhase;
|
||||
|
||||
return parsedValues.find(
|
||||
(sv) =>
|
||||
(sv.measurand == null && measurands.includes("Energy.Active.Import.Register")) ||
|
||||
(sv.measurand != null && measurands.includes(sv.measurand)),
|
||||
);
|
||||
}
|
||||
|
||||
function MeterCard({
|
||||
label,
|
||||
value,
|
||||
emphasis = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
emphasis?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`relative overflow-hidden rounded-xl border border-border/70 px-3 py-2.5 ${
|
||||
emphasis ? "bg-surface-secondary" : "bg-surface"
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<dt className="text-xs font-medium tracking-wide text-muted">{label}</dt>
|
||||
<dd className="mt-1 truncate text-base font-semibold tabular-nums text-foreground">
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildMeterSnapshot(history: MeterHistoryPoint[]) {
|
||||
const latestPoint = history[history.length - 1];
|
||||
return {
|
||||
latestTimestamp: latestPoint?.timestamp ?? null,
|
||||
latestValues: latestPoint?.sampledValues ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function MeterChannelSection({
|
||||
connectorNumber,
|
||||
history,
|
||||
}: {
|
||||
connectorNumber: number;
|
||||
history: MeterHistoryPoint[];
|
||||
}) {
|
||||
const snapshot = buildMeterSnapshot(history);
|
||||
const meterVoltage = extractMeterValue(snapshot.latestValues, ["Voltage"]);
|
||||
const meterCurrent = extractMeterValue(snapshot.latestValues, ["Current.Import"]);
|
||||
const meterPower = extractMeterValue(snapshot.latestValues, ["Power.Active.Import"]);
|
||||
const meterPf = extractMeterValue(snapshot.latestValues, ["Power.Factor"]);
|
||||
const meterFrequency = extractMeterValue(snapshot.latestValues, ["Frequency"]);
|
||||
const meterTemperature = extractMeterValue(snapshot.latestValues, ["Temperature"]);
|
||||
const meterEnergyReg = extractMeterValue(snapshot.latestValues, [
|
||||
"Energy.Active.Import.Register",
|
||||
]);
|
||||
|
||||
return (
|
||||
<InfoSection title={`连接器 #${connectorNumber}`}>
|
||||
{history.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-border/70 bg-surface px-4 py-6 text-center">
|
||||
<div className="mx-auto flex size-10 items-center justify-center rounded-full bg-muted/10 text-muted">
|
||||
<Plug className="size-5" />
|
||||
</div>
|
||||
<p className="mt-3 text-sm font-medium text-foreground">
|
||||
暂未收到该连接器的 MeterValue 采样数据
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-5 text-muted">
|
||||
充电桩上报后,这里会自动显示电压、电流、功率等实时计量信息。
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3 rounded-xl border border-border/70 bg-surface-secondary px-3 py-2">
|
||||
<div>
|
||||
<p className="text-xs font-medium tracking-wide text-muted">
|
||||
连接器 #{connectorNumber}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{formatDateTime(snapshot.latestTimestamp)}
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-accent/10 px-2 py-1 text-xs font-medium text-accent">
|
||||
最近 {history.length} 条
|
||||
</span>
|
||||
</div>
|
||||
<dl className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<MeterCard
|
||||
label="电压"
|
||||
value={
|
||||
meterVoltage ? `${meterVoltage.value.toFixed(1)} ${meterVoltage.unit ?? "V"}` : "—"
|
||||
}
|
||||
/>
|
||||
<MeterCard
|
||||
label="电流"
|
||||
value={
|
||||
meterCurrent ? `${meterCurrent.value.toFixed(2)} ${meterCurrent.unit ?? "A"}` : "—"
|
||||
}
|
||||
/>
|
||||
<MeterCard
|
||||
label="有功功率"
|
||||
value={meterPower ? `${meterPower.value.toFixed(0)} ${meterPower.unit ?? "W"}` : "—"}
|
||||
/>
|
||||
<MeterCard label="功率因数" value={meterPf ? meterPf.value.toFixed(3) : "—"} />
|
||||
<MeterCard
|
||||
label="频率"
|
||||
value={
|
||||
meterFrequency
|
||||
? `${meterFrequency.value.toFixed(1)} ${meterFrequency.unit ?? "Hz"}`
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<MeterCard
|
||||
label="温度"
|
||||
value={
|
||||
meterTemperature
|
||||
? `${meterTemperature.value.toFixed(1)} ${meterTemperature.unit ?? "Celsius"}`
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<div className="sm:col-span-2 xl:col-span-1">
|
||||
<MeterCard
|
||||
label="累计电能表读数"
|
||||
value={
|
||||
meterEnergyReg
|
||||
? `${meterEnergyReg.value.toFixed(3)} ${meterEnergyReg.unit ?? "Wh"}`
|
||||
: "—"
|
||||
}
|
||||
emphasis
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2 xl:col-span-2">
|
||||
<MeterCard
|
||||
label="采样时间"
|
||||
value={formatDateTime(snapshot.latestTimestamp)}
|
||||
emphasis
|
||||
/>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
</InfoSection>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Edit form type ─────────────────────────────────────────────────────────
|
||||
|
||||
type EditForm = {
|
||||
deviceName: string;
|
||||
chargePointVendor: string;
|
||||
chargePointModel: string;
|
||||
registrationStatus: "Accepted" | "Pending" | "Rejected";
|
||||
pricingMode: "fixed" | "tou";
|
||||
feePerKwh: string;
|
||||
};
|
||||
|
||||
@@ -93,12 +278,46 @@ 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 [resetConfirmOpen, setResetConfirmOpen] = useState(false);
|
||||
const [resetConfirmText, setResetConfirmText] = useState("");
|
||||
const [resetBusy, setResetBusy] = useState(false);
|
||||
const [resetResult, setResetResult] = useState<ChargePointPasswordReset | null>(null);
|
||||
const [resetCopied, setResetCopied] = useState(false);
|
||||
|
||||
const openResetConfirm = () => {
|
||||
setResetConfirmText("");
|
||||
setResetConfirmOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmResetPassword = async () => {
|
||||
if (!cp) return;
|
||||
setResetBusy(true);
|
||||
try {
|
||||
const result = await api.chargePoints.resetPassword(cp.id);
|
||||
setResetResult(result);
|
||||
setResetConfirmOpen(false);
|
||||
setResetConfirmText("");
|
||||
} finally {
|
||||
setResetBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyResetPassword = (text: string) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setResetCopied(true);
|
||||
setTimeout(() => setResetCopied(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
const { isFetching: refreshing, ...cpQuery } = useQuery({
|
||||
queryKey: ["chargePoint", id],
|
||||
queryFn: () => api.chargePoints.get(id),
|
||||
@@ -118,9 +337,11 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
|
||||
const openEdit = () => {
|
||||
if (!cp) return;
|
||||
setEditForm({
|
||||
deviceName: cp.deviceName ?? "",
|
||||
chargePointVendor: cp.chargePointVendor ?? "",
|
||||
chargePointModel: cp.chargePointModel ?? "",
|
||||
registrationStatus: cp.registrationStatus as EditForm["registrationStatus"],
|
||||
pricingMode: cp.pricingMode,
|
||||
feePerKwh: String(cp.feePerKwh),
|
||||
});
|
||||
setEditOpen(true);
|
||||
@@ -135,7 +356,9 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
|
||||
chargePointVendor: editForm.chargePointVendor,
|
||||
chargePointModel: editForm.chargePointModel,
|
||||
registrationStatus: editForm.registrationStatus,
|
||||
feePerKwh: fee,
|
||||
pricingMode: editForm.pricingMode,
|
||||
feePerKwh: editForm.pricingMode === "fixed" ? fee : 0,
|
||||
deviceName: editForm.deviceName.trim() || null,
|
||||
});
|
||||
await cpQuery.refetch();
|
||||
setEditOpen(false);
|
||||
@@ -146,8 +369,16 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
|
||||
|
||||
// Online if last heartbeat within 3× interval
|
||||
const isOnline =
|
||||
cp?.transportStatus === "online" &&
|
||||
cp?.lastHeartbeatAt != null &&
|
||||
dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < (cp.heartbeatInterval ?? 60) * 3;
|
||||
const commandChannelUnavailable = cp?.transportStatus === "unavailable";
|
||||
const statusLabel = isOnline ? "在线" : commandChannelUnavailable ? "通道异常" : "离线";
|
||||
const transportStatusDotClass = isOnline
|
||||
? "bg-success animate-pulse"
|
||||
: commandChannelUnavailable
|
||||
? "bg-warning"
|
||||
: "bg-muted";
|
||||
|
||||
const { data: sessionData } = useSession();
|
||||
const isAdmin = sessionData?.user?.role === "admin";
|
||||
@@ -178,6 +409,16 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
|
||||
}
|
||||
|
||||
const sortedConnectors = [...cp.connectors].sort((a, b) => a.connectorId - b.connectorId);
|
||||
const meterHistory = cp.meterHistory ?? [];
|
||||
const meterHistoryByConnector = meterHistory.reduce<Record<number, MeterHistoryPoint[]>>(
|
||||
(acc, row) => {
|
||||
if (!acc[row.connectorNumber]) acc[row.connectorNumber] = [];
|
||||
acc[row.connectorNumber].push(row);
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
const displayConnectors = sortedConnectors.filter((connector) => connector.connectorId > 0);
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -196,9 +437,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>{cp.chargePointIdentifier}</span>}
|
||||
</h1>
|
||||
{isAdmin && cp.deviceName && (
|
||||
<span className="text-sm text-muted">{cp.chargePointIdentifier}</span>
|
||||
)}
|
||||
<Chip
|
||||
color={registrationColorMap[cp.registrationStatus] ?? "warning"}
|
||||
size="sm"
|
||||
@@ -207,10 +451,8 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
|
||||
{cp.registrationStatus}
|
||||
</Chip>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={`size-2 rounded-full ${isOnline ? "bg-success animate-pulse" : "bg-muted"}`}
|
||||
/>
|
||||
<span className="text-xs text-muted">{isOnline ? "在线" : "离线"}</span>
|
||||
<span className={`size-2 rounded-full ${transportStatusDotClass}`} />
|
||||
<span className="text-xs text-muted">{statusLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
{(cp.chargePointVendor || cp.chargePointModel) && (
|
||||
@@ -220,14 +462,37 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button isIconOnly size="sm" variant="ghost" isDisabled={refreshing} onPress={() => cpQuery.refetch()} aria-label="刷新">
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
isDisabled={refreshing}
|
||||
onPress={() => cpQuery.refetch()}
|
||||
aria-label="刷新"
|
||||
>
|
||||
<ArrowRotateRight className={`size-4 ${refreshing ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button size="sm" variant="secondary" onPress={openEdit}>
|
||||
<Pencil className="size-4" />
|
||||
编辑
|
||||
</Button>
|
||||
<>
|
||||
<Tooltip>
|
||||
<Tooltip.Content>重置 OCPP 认证密钥</Tooltip.Content>
|
||||
<Tooltip.Trigger>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
isDisabled={resetBusy}
|
||||
onPress={openResetConfirm}
|
||||
>
|
||||
{resetBusy ? <Spinner size="sm" /> : <Key className="size-4" />}
|
||||
重置密钥
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
</Tooltip>
|
||||
<Button size="sm" variant="secondary" onPress={openEdit}>
|
||||
<Pencil className="size-4" />
|
||||
编辑
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -265,8 +530,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 },
|
||||
@@ -285,14 +549,73 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isAdmin && (
|
||||
<Modal
|
||||
isOpen={resetConfirmOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!resetBusy) {
|
||||
setResetConfirmOpen(open);
|
||||
if (!open) setResetConfirmText("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Modal.Backdrop>
|
||||
<Modal.Container scroll="outside">
|
||||
<Modal.Dialog className="sm:max-w-md">
|
||||
<Modal.CloseTrigger />
|
||||
<Modal.Header>
|
||||
<Modal.Heading>重置 OCPP 认证密钥</Modal.Heading>
|
||||
</Modal.Header>
|
||||
<Modal.Body className="space-y-4">
|
||||
<p className="text-sm text-warning font-medium">
|
||||
重置后旧密钥将立即失效,请先确认设备已准备重新配置。
|
||||
</p>
|
||||
<TextField fullWidth>
|
||||
<Label className="text-sm font-normal">
|
||||
请输入 “
|
||||
<span className="font-medium cursor-text">{RESET_CONFIRM_TEXT}</span>”
|
||||
以继续
|
||||
</Label>
|
||||
<Input
|
||||
placeholder={RESET_CONFIRM_TEXT}
|
||||
value={resetConfirmText}
|
||||
onChange={(e) => setResetConfirmText(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</TextField>
|
||||
</Modal.Body>
|
||||
<Modal.Footer className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onPress={() => {
|
||||
setResetConfirmOpen(false);
|
||||
setResetConfirmText("");
|
||||
}}
|
||||
isDisabled={resetBusy}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
isDisabled={resetBusy || resetConfirmText !== RESET_CONFIRM_TEXT}
|
||||
onPress={handleConfirmResetPassword}
|
||||
>
|
||||
{resetBusy ? <Spinner size="sm" /> : "确认重置"}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Dialog>
|
||||
</Modal.Container>
|
||||
</Modal.Backdrop>
|
||||
</Modal>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
</InfoSection>
|
||||
)}
|
||||
|
||||
{/* Operation info — admin only */}
|
||||
{isAdmin && (
|
||||
<div className="rounded-xl border border-border bg-surface p-4">
|
||||
<h2 className="mb-3 text-sm font-semibold text-foreground">运行配置</h2>
|
||||
<InfoSection title="运行配置">
|
||||
<dl className="divide-y divide-border">
|
||||
<div className="flex items-center justify-between gap-4 py-2">
|
||||
<dt className="shrink-0 text-sm text-muted">注册状态</dt>
|
||||
@@ -309,7 +632,9 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
|
||||
<div className="flex items-center justify-between gap-4 py-2">
|
||||
<dt className="shrink-0 text-sm text-muted">电价</dt>
|
||||
<dd className="text-sm text-foreground">
|
||||
{cp.feePerKwh > 0 ? (
|
||||
{cp.pricingMode === "tou" ? (
|
||||
<span className="text-accent font-medium">峰谷电价</span>
|
||||
) : cp.feePerKwh > 0 ? (
|
||||
<span>
|
||||
{cp.feePerKwh} 分/kWh
|
||||
<span className="ml-1 text-xs text-muted">
|
||||
@@ -362,18 +687,19 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</InfoSection>
|
||||
)}
|
||||
|
||||
{/* Fee info — user only */}
|
||||
{!isAdmin && (
|
||||
<div className="rounded-xl border border-border bg-surface p-4">
|
||||
<h2 className="mb-3 text-sm font-semibold text-foreground">电价信息</h2>
|
||||
<InfoSection title="电价信息">
|
||||
<dl className="divide-y divide-border">
|
||||
<div className="flex items-center justify-between gap-4 py-2">
|
||||
<dt className="shrink-0 text-sm text-muted">单位电价</dt>
|
||||
<dd className="text-sm text-foreground">
|
||||
{cp.feePerKwh > 0 ? (
|
||||
{cp.pricingMode === "tou" ? (
|
||||
<span className="text-accent font-medium">峰谷电价</span>
|
||||
) : cp.feePerKwh > 0 ? (
|
||||
<span>
|
||||
<span className="font-semibold">¥{(cp.feePerKwh / 100).toFixed(2)}</span>
|
||||
<span className="ml-1 text-xs text-muted">/kWh</span>
|
||||
@@ -387,15 +713,13 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
|
||||
<dt className="shrink-0 text-sm text-muted">充电桥状态</dt>
|
||||
<dd>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={`size-2 rounded-full ${isOnline ? "bg-success animate-pulse" : "bg-muted"}`}
|
||||
/>
|
||||
<span className="text-sm text-foreground">{isOnline ? "在线" : "离线"}</span>
|
||||
<span className={`size-2 rounded-full ${transportStatusDotClass}`} />
|
||||
<span className="text-sm text-foreground">{statusLabel}</span>
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</InfoSection>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -410,7 +734,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>
|
||||
@@ -426,10 +750,8 @@ 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>} */}
|
||||
<p className="text-xs text-muted">
|
||||
更新于{" "}
|
||||
{dayjs(conn.lastStatusAt).format("MM/DD HH:mm")}
|
||||
更新于 {dayjs(conn.lastStatusAt).format("MM/DD HH:mm")}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
@@ -437,6 +759,19 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MeterValue */}
|
||||
{isAdmin && (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{displayConnectors.map((connector) => (
|
||||
<MeterChannelSection
|
||||
key={connector.id}
|
||||
connectorNumber={connector.connectorId}
|
||||
history={meterHistoryByConnector[connector.connectorId] ?? []}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transactions */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -538,6 +873,71 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reset password result modal */}
|
||||
{isAdmin && (
|
||||
<Modal
|
||||
isOpen={resetResult !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setResetResult(null);
|
||||
setResetCopied(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Modal.Backdrop>
|
||||
<Modal.Container scroll="outside">
|
||||
<Modal.Dialog className="sm:max-w-md">
|
||||
<Modal.Header>
|
||||
<Modal.Heading>密钥已重置</Modal.Heading>
|
||||
</Modal.Header>
|
||||
<Modal.Body className="space-y-4">
|
||||
<p className="text-sm text-warning font-medium">
|
||||
此密钥仅显示一次。旧密钥已立即失效,请更新固件配置。
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs text-muted font-medium">新 OCPP Basic Auth 密钥</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 rounded-md bg-surface-secondary px-3 py-2 text-sm font-mono text-foreground select-all">
|
||||
{resetResult?.plainPassword}
|
||||
</code>
|
||||
<Tooltip>
|
||||
<Tooltip.Content>{resetCopied ? "已复制" : "复制密钥"}</Tooltip.Content>
|
||||
<Tooltip.Trigger>
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onPress={() =>
|
||||
resetResult && handleCopyResetPassword(resetResult.plainPassword)
|
||||
}
|
||||
>
|
||||
{resetCopied ? (
|
||||
<Check className="size-4 text-success" />
|
||||
) : (
|
||||
<Copy className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</Modal.Body>
|
||||
<Modal.Footer className="flex justify-end">
|
||||
<Button
|
||||
onPress={() => {
|
||||
setResetResult(null);
|
||||
setResetCopied(false);
|
||||
}}
|
||||
>
|
||||
我已保存密钥
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Dialog>
|
||||
</Modal.Container>
|
||||
</Modal.Backdrop>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Edit modal */}
|
||||
{isAdmin && (
|
||||
<Modal
|
||||
@@ -554,6 +954,14 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
|
||||
<Modal.Heading>编辑充电桩</Modal.Heading>
|
||||
</Modal.Header>
|
||||
<Modal.Body className="space-y-3">
|
||||
<TextField fullWidth>
|
||||
<Label className="text-sm font-medium">设备名称</Label>
|
||||
<Input
|
||||
placeholder="1号楼A区01号桩"
|
||||
value={editForm.deviceName}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, deviceName: e.target.value }))}
|
||||
/>
|
||||
</TextField>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TextField fullWidth>
|
||||
<Label className="text-sm font-medium">品牌</Label>
|
||||
@@ -601,17 +1009,43 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
|
||||
</Select.Popover>
|
||||
</Select>
|
||||
</div>
|
||||
<TextField fullWidth>
|
||||
<Label className="text-sm font-medium">电价(分/kWh)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder="0"
|
||||
value={editForm.feePerKwh}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, feePerKwh: e.target.value }))}
|
||||
/>
|
||||
</TextField>
|
||||
<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>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder="0"
|
||||
value={editForm.feePerKwh}
|
||||
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)}>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Button,
|
||||
Chip,
|
||||
Input,
|
||||
InputGroup,
|
||||
Label,
|
||||
ListBox,
|
||||
Modal,
|
||||
@@ -22,13 +23,16 @@ import {
|
||||
TrashBin,
|
||||
ArrowRotateRight,
|
||||
QrCode,
|
||||
Copy,
|
||||
Check,
|
||||
} from "@gravity-ui/icons";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import Link from "next/link";
|
||||
import { ScrollFade } from "@/components/scroll-fade";
|
||||
import { api, type ChargePoint } from "@/lib/api";
|
||||
import { api, type ChargePoint, type ChargePointCreated } from "@/lib/api";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
import dayjs from "@/lib/dayjs";
|
||||
import { Download } from "lucide-react";
|
||||
|
||||
const statusLabelMap: Record<string, string> = {
|
||||
Available: "空闲中",
|
||||
@@ -97,17 +101,21 @@ const registrationColorMap: Record<string, "success" | "warning" | "danger"> = {
|
||||
|
||||
type FormData = {
|
||||
chargePointIdentifier: string;
|
||||
deviceName: string;
|
||||
chargePointVendor: string;
|
||||
chargePointModel: string;
|
||||
registrationStatus: "Accepted" | "Pending" | "Rejected";
|
||||
pricingMode: "fixed" | "tou";
|
||||
feePerKwh: string;
|
||||
};
|
||||
|
||||
const EMPTY_FORM: FormData = {
|
||||
chargePointIdentifier: "",
|
||||
deviceName: "",
|
||||
chargePointVendor: "",
|
||||
chargePointModel: "",
|
||||
registrationStatus: "Pending",
|
||||
pricingMode: "fixed",
|
||||
feePerKwh: "0",
|
||||
};
|
||||
|
||||
@@ -119,6 +127,9 @@ export default function ChargePointsPage() {
|
||||
const [deleteTarget, setDeleteTarget] = useState<ChargePoint | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [qrTarget, setQrTarget] = useState<ChargePoint | null>(null);
|
||||
const [createdCp, setCreatedCp] = useState<ChargePointCreated | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [downloadingQrKey, setDownloadingQrKey] = useState<string | null>(null);
|
||||
const {
|
||||
data: chargePoints = [],
|
||||
refetch: refetchList,
|
||||
@@ -139,9 +150,11 @@ export default function ChargePointsPage() {
|
||||
setFormTarget(cp);
|
||||
setFormData({
|
||||
chargePointIdentifier: cp.chargePointIdentifier,
|
||||
deviceName: cp.deviceName ?? "",
|
||||
chargePointVendor: cp.chargePointVendor ?? "",
|
||||
chargePointModel: cp.chargePointModel ?? "",
|
||||
registrationStatus: cp.registrationStatus as FormData["registrationStatus"],
|
||||
pricingMode: cp.pricingMode,
|
||||
feePerKwh: String(cp.feePerKwh),
|
||||
});
|
||||
setFormOpen(true);
|
||||
@@ -158,20 +171,27 @@ export default function ChargePointsPage() {
|
||||
chargePointVendor: formData.chargePointVendor,
|
||||
chargePointModel: formData.chargePointModel,
|
||||
registrationStatus: formData.registrationStatus,
|
||||
feePerKwh: fee,
|
||||
pricingMode: formData.pricingMode,
|
||||
feePerKwh: formData.pricingMode === "fixed" ? fee : 0,
|
||||
deviceName: formData.deviceName.trim() || null,
|
||||
});
|
||||
await refetchList();
|
||||
setFormOpen(false);
|
||||
} else {
|
||||
// Create
|
||||
await api.chargePoints.create({
|
||||
// Create — capture plainPassword for one-time display
|
||||
const created = await api.chargePoints.create({
|
||||
chargePointIdentifier: formData.chargePointIdentifier.trim(),
|
||||
chargePointVendor: formData.chargePointVendor.trim() || undefined,
|
||||
chargePointModel: formData.chargePointModel.trim() || undefined,
|
||||
registrationStatus: formData.registrationStatus,
|
||||
feePerKwh: fee,
|
||||
pricingMode: formData.pricingMode,
|
||||
feePerKwh: formData.pricingMode === "fixed" ? fee : 0,
|
||||
deviceName: formData.deviceName.trim() || undefined,
|
||||
});
|
||||
await refetchList();
|
||||
setFormOpen(false);
|
||||
setCreatedCp(created);
|
||||
}
|
||||
await refetchList();
|
||||
setFormOpen(false);
|
||||
} finally {
|
||||
setFormBusy(false);
|
||||
}
|
||||
@@ -191,6 +211,73 @@ export default function ChargePointsPage() {
|
||||
|
||||
const isEdit = formTarget !== null;
|
||||
|
||||
const handleCopyPassword = (text: string) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
const handleDownloadConnectorQr = async (
|
||||
chargePointId: string | number,
|
||||
connectorId: number,
|
||||
chargePointIdentifier: string,
|
||||
) => {
|
||||
const svgId = `connector-qr-${chargePointId}-${connectorId}`;
|
||||
const key = `${chargePointId}-${connectorId}`;
|
||||
const svg = document.getElementById(svgId) as SVGSVGElement | null;
|
||||
if (!svg) return;
|
||||
|
||||
setDownloadingQrKey(key);
|
||||
try {
|
||||
const serializer = new XMLSerializer();
|
||||
const svgText = serializer.serializeToString(svg);
|
||||
const svgBlob = new Blob([svgText], { type: "image/svg+xml;charset=utf-8" });
|
||||
const svgUrl = URL.createObjectURL(svgBlob);
|
||||
|
||||
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error("二维码导出失败"));
|
||||
img.src = svgUrl;
|
||||
});
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 1024;
|
||||
canvas.height = 1024;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("二维码导出失败");
|
||||
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
const pngBlob = await new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) resolve(blob);
|
||||
else reject(new Error("二维码导出失败"));
|
||||
}, "image/png");
|
||||
});
|
||||
|
||||
const fileUrl = URL.createObjectURL(pngBlob);
|
||||
const link = document.createElement("a");
|
||||
const safeIdentifier = String(chargePointIdentifier).replace(/[^a-zA-Z0-9_-]/g, "");
|
||||
const safeConnectorId = String(connectorId).replace(/[^a-zA-Z0-9_-]/g, "");
|
||||
|
||||
link.href = fileUrl;
|
||||
link.download = `${safeIdentifier}-connector-${safeConnectorId}.png`;
|
||||
link.rel = "noopener";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
URL.revokeObjectURL(svgUrl);
|
||||
URL.revokeObjectURL(fileUrl);
|
||||
} finally {
|
||||
setDownloadingQrKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const { data: sessionData } = useSession();
|
||||
const isAdmin = sessionData?.user?.role === "admin";
|
||||
|
||||
@@ -253,8 +340,16 @@ export default function ChargePointsPage() {
|
||||
}
|
||||
/>
|
||||
</TextField>
|
||||
<TextField fullWidth>
|
||||
<Label className="text-sm font-medium">设备名称</Label>
|
||||
<Input
|
||||
placeholder="1号楼A区01号桩"
|
||||
value={formData.deviceName}
|
||||
onChange={(e) => setFormData((f) => ({ ...f, deviceName: e.target.value }))}
|
||||
/>
|
||||
</TextField>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TextField fullWidth>
|
||||
<TextField fullWidth isReadOnly={isEdit}>
|
||||
<Label className="text-sm font-medium">品牌</Label>
|
||||
<Input
|
||||
placeholder="ABB"
|
||||
@@ -264,7 +359,7 @@ export default function ChargePointsPage() {
|
||||
}
|
||||
/>
|
||||
</TextField>
|
||||
<TextField fullWidth>
|
||||
<TextField fullWidth isReadOnly={isEdit}>
|
||||
<Label className="text-sm font-medium">型号</Label>
|
||||
<Input
|
||||
placeholder="Terra AC"
|
||||
@@ -300,17 +395,45 @@ export default function ChargePointsPage() {
|
||||
</Select.Popover>
|
||||
</Select>
|
||||
</div>
|
||||
<TextField fullWidth>
|
||||
<Label className="text-sm font-medium">电价(分/kWh)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder="0"
|
||||
value={formData.feePerKwh}
|
||||
onChange={(e) => setFormData((f) => ({ ...f, feePerKwh: e.target.value }))}
|
||||
/>
|
||||
</TextField>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm font-medium">计费模式</Label>
|
||||
<Select
|
||||
fullWidth
|
||||
selectedKey={formData.pricingMode}
|
||||
onSelectionChange={(key) =>
|
||||
setFormData((f) => ({
|
||||
...f,
|
||||
pricingMode: String(key) as FormData["pricingMode"],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<Select.Trigger>
|
||||
<Select.Value />
|
||||
<Select.Indicator />
|
||||
</Select.Trigger>
|
||||
<Select.Popover>
|
||||
<ListBox>
|
||||
<ListBox.Item id="fixed">固定电价</ListBox.Item>
|
||||
<ListBox.Item id="tou">峰谷电价</ListBox.Item>
|
||||
</ListBox>
|
||||
</Select.Popover>
|
||||
</Select>
|
||||
</div>
|
||||
{formData.pricingMode === "fixed" && (
|
||||
<TextField fullWidth>
|
||||
<Label className="text-sm font-medium">固定电价(分/kWh)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder="0"
|
||||
value={formData.feePerKwh}
|
||||
onChange={(e) =>
|
||||
setFormData((f) => ({ ...f, feePerKwh: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</TextField>
|
||||
)}
|
||||
{!isEdit && (
|
||||
<p className="text-xs text-muted">
|
||||
自动注册的充电桩默认状态为 Pending,需手动改为 Accepted 后才可正常上线。
|
||||
@@ -348,16 +471,18 @@ export default function ChargePointsPage() {
|
||||
<Modal.Dialog className="sm:max-w-lg">
|
||||
<Modal.CloseTrigger />
|
||||
<Modal.Header>
|
||||
<Modal.Heading>{qrTarget?.chargePointIdentifier} — 充电二维码</Modal.Heading>
|
||||
<Modal.Heading>
|
||||
{qrTarget?.deviceName ?? qrTarget?.chargePointIdentifier} 固定二维码
|
||||
</Modal.Heading>
|
||||
</Modal.Header>
|
||||
<Modal.Body className="space-y-4">
|
||||
<p className="text-sm text-muted">
|
||||
将以下二维码张贴在对应充电口上,用户扫码后可直接选卡启动充电。
|
||||
将二维码张贴在对应充电口上,用户扫码后可直接选卡启动充电。
|
||||
</p>
|
||||
{qrTarget &&
|
||||
qrTarget.connectors.filter((c) => c.connectorId > 0).length === 0 && (
|
||||
<p className="text-sm text-muted">
|
||||
该充电桩暂无接口信息,请等待设备上线后再尝试。
|
||||
该充电桩暂无接口信息,请等待设备首次上线后再尝试。
|
||||
</p>
|
||||
)}
|
||||
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3">
|
||||
@@ -366,15 +491,50 @@ export default function ChargePointsPage() {
|
||||
.sort((a, b) => a.connectorId - b.connectorId)
|
||||
.map((conn) => {
|
||||
const url = `${qrOrigin}/dashboard/charge?cpId=${qrTarget.id}&connector=${conn.connectorId}`;
|
||||
const downloadKey = `${qrTarget.id}-${conn.connectorId}`;
|
||||
return (
|
||||
<div
|
||||
key={conn.id}
|
||||
className="flex flex-col items-center gap-2 rounded-xl border border-border p-3"
|
||||
className="flex flex-col items-center gap-2 rounded-xl border border-border px-2 py-1"
|
||||
>
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
接口 #{conn.connectorId}
|
||||
</p>
|
||||
<QRCodeSVG value={url} size={120} className="rounded" />
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
接口 #{conn.connectorId}
|
||||
</p>
|
||||
<Tooltip delay={0}>
|
||||
<Tooltip.Content>下载二维码</Tooltip.Content>
|
||||
<Tooltip.Trigger>
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
isDisabled={downloadingQrKey === downloadKey}
|
||||
onPress={() =>
|
||||
handleDownloadConnectorQr(
|
||||
qrTarget.id,
|
||||
conn.connectorId,
|
||||
qrTarget.chargePointIdentifier,
|
||||
)
|
||||
}
|
||||
aria-label={`下载接口 #${conn.connectorId} 二维码`}
|
||||
className="h-6 min-h-6 w-6 min-w-6"
|
||||
>
|
||||
{downloadingQrKey === downloadKey ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
<Download className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<QRCodeSVG
|
||||
id={`connector-qr-${qrTarget.id}-${conn.connectorId}`}
|
||||
value={url}
|
||||
size={120}
|
||||
className="rounded"
|
||||
bgColor="#ffffff"
|
||||
/>
|
||||
<p className="break-all text-center font-mono text-[9px] text-muted leading-tight">
|
||||
{url}
|
||||
</p>
|
||||
@@ -394,14 +554,103 @@ export default function ChargePointsPage() {
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* OCPP Password Modal — shown once after creation */}
|
||||
{isAdmin && (
|
||||
<Modal
|
||||
isOpen={createdCp !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setCreatedCp(null);
|
||||
setCopied(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Modal.Backdrop>
|
||||
<Modal.Container scroll="outside">
|
||||
<Modal.Dialog className="sm:max-w-md">
|
||||
<Modal.Header>
|
||||
<Modal.Heading>充电桩已创建</Modal.Heading>
|
||||
</Modal.Header>
|
||||
<Modal.Body className="space-y-4">
|
||||
<p className="text-sm text-warning font-medium">
|
||||
此认证密钥只显示一次,请立即烧录或妥善保存
|
||||
</p>
|
||||
<TextField fullWidth isReadOnly>
|
||||
<Label className="text-sm font-medium">充电桩标识符</Label>
|
||||
<Input value={createdCp?.chargePointIdentifier ?? ""} className="font-mono" />
|
||||
</TextField>
|
||||
<TextField fullWidth isReadOnly>
|
||||
<Label className="text-sm font-medium">OCPP Basic Auth 密钥</Label>
|
||||
<InputGroup>
|
||||
<InputGroup.Input
|
||||
value={createdCp?.plainPassword ?? ""}
|
||||
className="font-mono select-all"
|
||||
/>
|
||||
<InputGroup.Suffix>
|
||||
<Tooltip>
|
||||
<Tooltip.Content>{copied ? "已复制" : "复制密钥"}</Tooltip.Content>
|
||||
<Tooltip.Trigger>
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onPress={() =>
|
||||
createdCp && handleCopyPassword(createdCp.plainPassword)
|
||||
}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-4 text-success" />
|
||||
) : (
|
||||
<Copy className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
</Tooltip>
|
||||
</InputGroup.Suffix>
|
||||
</InputGroup>
|
||||
</TextField>
|
||||
<div className="space-y-1.5">
|
||||
<TextField fullWidth isReadOnly>
|
||||
<Label className="text-sm font-medium">固件 WebSocket 连接地址</Label>
|
||||
<Input
|
||||
value={`wss://<your-server>/ocpp/${createdCp?.chargePointIdentifier ?? ""}`}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</TextField>
|
||||
<p className="text-xs text-muted">
|
||||
设备连接时需设置 HTTP 头:
|
||||
<br />
|
||||
<code className="text-foreground">
|
||||
Authorization: Basic <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>
|
||||
@@ -422,171 +671,196 @@ export default function ChargePointsPage() {
|
||||
{isAdmin && <Table.Cell>{""}</Table.Cell>}
|
||||
</Table.Row>
|
||||
)}
|
||||
{chargePoints.map((cp) => (
|
||||
<Table.Row key={cp.id} id={String(cp.id)} className={"group"}>
|
||||
<Table.Cell>
|
||||
<Tooltip delay={0}>
|
||||
<Tooltip.Trigger>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`size-2 shrink-0 rounded-full ${
|
||||
cp.lastHeartbeatAt &&
|
||||
dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120
|
||||
? "bg-success"
|
||||
: "bg-gray-300"
|
||||
}`}
|
||||
/>
|
||||
<Link
|
||||
href={`/dashboard/charge-points/${cp.id}`}
|
||||
className="font-medium text-accent"
|
||||
>
|
||||
{cp.chargePointIdentifier}
|
||||
</Link>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content placement="start">
|
||||
{cp.lastHeartbeatAt
|
||||
? dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120
|
||||
? "在线"
|
||||
: "离线"
|
||||
: "从未连接"}
|
||||
</Tooltip.Content>
|
||||
</Tooltip>
|
||||
</Table.Cell>
|
||||
{isAdmin && (
|
||||
{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>
|
||||
{cp.chargePointVendor || cp.chargePointModel ? (
|
||||
<div className="flex flex-col">
|
||||
{cp.chargePointVendor && (
|
||||
<span className="text-xs text-muted font-medium">
|
||||
{cp.chargePointVendor}
|
||||
</span>
|
||||
)}
|
||||
{cp.chargePointModel && (
|
||||
<span className="text-sm text-foreground">{cp.chargePointModel}</span>
|
||||
)}
|
||||
<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.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>
|
||||
{cp.chargePointVendor || cp.chargePointModel ? (
|
||||
<div className="flex flex-col">
|
||||
{cp.chargePointVendor && (
|
||||
<span className="text-xs text-muted font-medium">
|
||||
{cp.chargePointVendor}
|
||||
</span>
|
||||
)}
|
||||
{cp.chargePointModel && (
|
||||
<span className="text-sm text-foreground">{cp.chargePointModel}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted">—</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Table.Cell>
|
||||
<Chip
|
||||
color={registrationColorMap[cp.registrationStatus] ?? "warning"}
|
||||
size="sm"
|
||||
variant="soft"
|
||||
>
|
||||
{cp.registrationStatus}
|
||||
</Chip>
|
||||
</Table.Cell>
|
||||
)}
|
||||
<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">
|
||||
(¥{(cp.feePerKwh / 100).toFixed(2)}/kWh)
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted">免费</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{cp.lastHeartbeatAt ? (
|
||||
dayjs(cp.lastHeartbeatAt).format("YYYY/M/D HH:mm:ss")
|
||||
) : (
|
||||
<span className="text-muted">—</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{cp.chargePointStatus ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={`size-1.5 shrink-0 rounded-full ${statusDotClass[cp.chargePointStatus] ?? "bg-warning"}`}
|
||||
/>
|
||||
<span className="text-sm text-foreground text-nowrap">
|
||||
{cp.chargePointStatus === "Available"
|
||||
? "正常"
|
||||
: (statusLabelMap[cp.chargePointStatus] ?? cp.chargePointStatus)}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted">—</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Table.Cell>
|
||||
<Chip
|
||||
color={registrationColorMap[cp.registrationStatus] ?? "warning"}
|
||||
size="sm"
|
||||
variant="soft"
|
||||
>
|
||||
{cp.registrationStatus}
|
||||
</Chip>
|
||||
<ConnectorCell connectors={cp.connectors} />
|
||||
</Table.Cell>
|
||||
)}
|
||||
<Table.Cell className="tabular-nums">
|
||||
{cp.feePerKwh > 0 ? (
|
||||
<span>
|
||||
{cp.feePerKwh} 分
|
||||
<span className="ml-1 text-xs text-muted">
|
||||
(¥{(cp.feePerKwh / 100).toFixed(2)}/kWh)
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted">免费</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{cp.lastHeartbeatAt ? (
|
||||
dayjs(cp.lastHeartbeatAt).format("YYYY/M/D HH:mm:ss")
|
||||
) : (
|
||||
<span className="text-muted">—</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{cp.chargePointStatus ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={`size-1.5 shrink-0 rounded-full ${statusDotClass[cp.chargePointStatus] ?? "bg-warning"}`}
|
||||
/>
|
||||
<span className="text-sm text-foreground text-nowrap">
|
||||
{cp.chargePointStatus === "Available"
|
||||
? "正常"
|
||||
: (statusLabelMap[cp.chargePointStatus] ?? cp.chargePointStatus)}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted">—</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<ConnectorCell connectors={cp.connectors} />
|
||||
</Table.Cell>
|
||||
{isAdmin && (
|
||||
<Table.Cell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
onPress={() => openEdit(cp)}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
onPress={() => setQrTarget(cp)}
|
||||
aria-label="查看二维码"
|
||||
>
|
||||
<QrCode className="size-4" />
|
||||
</Button>
|
||||
<Modal>
|
||||
{isAdmin && (
|
||||
<Table.Cell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="danger-soft"
|
||||
onPress={() => setDeleteTarget(cp)}
|
||||
variant="tertiary"
|
||||
onPress={() => openEdit(cp)}
|
||||
>
|
||||
<TrashBin className="size-4" />
|
||||
<Pencil 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">
|
||||
{cp.chargePointIdentifier}
|
||||
</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>
|
||||
</Table.Cell>
|
||||
)}
|
||||
</Table.Row>
|
||||
))}
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
onPress={() => setQrTarget(cp)}
|
||||
aria-label="查看二维码"
|
||||
>
|
||||
<QrCode className="size-4" />
|
||||
</Button>
|
||||
<Modal>
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="danger-soft"
|
||||
onPress={() => setDeleteTarget(cp)}
|
||||
>
|
||||
<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-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>
|
||||
<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>
|
||||
</Table.Cell>
|
||||
)}
|
||||
</Table.Row>
|
||||
);
|
||||
})}
|
||||
</Table.Body>
|
||||
</Table.Content>
|
||||
</Table.ScrollContainer>
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, Fragment, Suspense } from "react";
|
||||
import { useState, useEffect, useRef, Fragment, Suspense, useMemo } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { Button, Chip, Modal, Spinner } from "@heroui/react";
|
||||
import {
|
||||
ThunderboltFill,
|
||||
PlugConnection,
|
||||
CreditCard,
|
||||
Check,
|
||||
QrCode,
|
||||
Xmark,
|
||||
} from "@gravity-ui/icons";
|
||||
import { Alert, AlertDialog, Button, Modal, Spinner } from "@heroui/react";
|
||||
import { ThunderboltFill, CreditCard, Check, QrCode, Xmark } from "@gravity-ui/icons";
|
||||
import jsQR from "jsqr";
|
||||
import { api } from "@/lib/api";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
import dayjs from "@/lib/dayjs";
|
||||
import { BanknoteArrowUp, EvCharger, Plug } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { IdTagCard } from "@/components/id-tag-card";
|
||||
|
||||
// ── Status maps (same as charge-points page) ────────────────────────────────
|
||||
|
||||
@@ -31,25 +28,12 @@ const statusLabelMap: Record<string, string> = {
|
||||
Occupied: "占用",
|
||||
};
|
||||
|
||||
const statusDotClass: Record<string, string> = {
|
||||
Available: "bg-success",
|
||||
Charging: "bg-accent animate-pulse",
|
||||
Preparing: "bg-warning animate-pulse",
|
||||
Finishing: "bg-warning",
|
||||
SuspendedEV: "bg-warning",
|
||||
SuspendedEVSE: "bg-warning",
|
||||
Reserved: "bg-warning",
|
||||
Faulted: "bg-danger",
|
||||
Unavailable: "bg-danger",
|
||||
Occupied: "bg-warning",
|
||||
};
|
||||
|
||||
// ── Step indicator ───────────────────────────────────────────────────────────
|
||||
|
||||
function StepBar({ step, onGoBack }: { step: number; onGoBack: (target: number) => void }) {
|
||||
const labels = ["选择充电桩", "选择充电口", "选择储值卡"];
|
||||
return (
|
||||
<div className="flex w-full items-start">
|
||||
<div className="flex w-full items-center">
|
||||
{labels.map((label, i) => {
|
||||
const idx = i + 1;
|
||||
const isActive = step === idx;
|
||||
@@ -62,36 +46,36 @@ function StepBar({ step, onGoBack }: { step: number; onGoBack: (target: number)
|
||||
onClick={() => isDone && onGoBack(idx)}
|
||||
disabled={!isDone}
|
||||
className={[
|
||||
"flex shrink-0 flex-col items-center gap-2",
|
||||
isDone ? "cursor-pointer" : "cursor-default",
|
||||
"flex shrink-0 flex-col items-center gap-1.5 py-1 min-w-16",
|
||||
isDone ? "cursor-pointer active:opacity-70" : "cursor-default",
|
||||
].join(" ")}
|
||||
>
|
||||
<span
|
||||
className={[
|
||||
"flex size-7 items-center justify-center rounded-full text-xs font-semibold ring-2 ring-offset-2 ring-offset-background transition-all",
|
||||
"flex size-8 items-center justify-center rounded-full text-sm font-bold ring-2 ring-offset-2 ring-offset-background transition-all",
|
||||
isActive
|
||||
? "bg-accent text-accent-foreground ring-accent"
|
||||
? "bg-accent text-accent-foreground ring-accent shadow-md shadow-accent/30"
|
||||
: isDone
|
||||
? "bg-success text-white ring-success"
|
||||
: "bg-surface-tertiary text-muted ring-transparent",
|
||||
].join(" ")}
|
||||
>
|
||||
{isDone ? <Check className="size-3.5" /> : idx}
|
||||
{isDone ? <Check className="size-4" /> : idx}
|
||||
</span>
|
||||
<span
|
||||
className={[
|
||||
"text-[11px] font-medium leading-none whitespace-nowrap",
|
||||
isActive ? "text-accent" : isDone ? "text-foreground" : "text-muted",
|
||||
"text-[11px] font-semibold leading-none whitespace-nowrap tracking-tight mt-1",
|
||||
isActive ? "text-accent" : isDone ? "text-success" : "text-muted",
|
||||
].join(" ")}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
{!isLast && (
|
||||
<div className="flex-1 pt-3.5">
|
||||
<div className="flex-1 mb-3.5">
|
||||
<span
|
||||
className={[
|
||||
"block h-px w-full transition-colors",
|
||||
"block h-0.5 w-full rounded-full transition-colors duration-300",
|
||||
isDone ? "bg-success" : "bg-border",
|
||||
].join(" ")}
|
||||
/>
|
||||
@@ -237,6 +221,8 @@ function QrScanner({ onResult, onClose }: ScannerProps) {
|
||||
|
||||
function ChargePageContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const { data: sessionData } = useSession();
|
||||
const isAdmin = sessionData?.user?.role === "admin";
|
||||
|
||||
const [step, setStep] = useState(1);
|
||||
const [selectedCpId, setSelectedCpId] = useState<string | null>(null);
|
||||
@@ -246,6 +232,13 @@ function ChargePageContent() {
|
||||
const [scanError, setScanError] = useState<string | null>(null);
|
||||
const [startResult, setStartResult] = useState<"success" | "error" | null>(null);
|
||||
const [startError, setStartError] = useState<string | null>(null);
|
||||
const [startSnapshot, setStartSnapshot] = useState<{
|
||||
cpId: string;
|
||||
chargePointIdentifier: string;
|
||||
deviceName: string | null;
|
||||
connectorId: number;
|
||||
idTag: string;
|
||||
} | null>(null);
|
||||
|
||||
// Detect mobile
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
@@ -275,12 +268,71 @@ function ChargePageContent() {
|
||||
});
|
||||
|
||||
const { data: idTags = [], isLoading: tagsLoading } = useQuery({
|
||||
queryKey: ["idTags"],
|
||||
queryKey: ["idTags", "list"],
|
||||
queryFn: () => api.idTags.list().catch(() => []),
|
||||
});
|
||||
|
||||
const { data: activeTransactions = [] } = useQuery({
|
||||
queryKey: ["transactions", "active", "idTagLock"],
|
||||
queryFn: async () => {
|
||||
const res = await api.transactions.list({ page: 1, limit: 200, status: "active" });
|
||||
return res.data;
|
||||
},
|
||||
refetchInterval: 3_000,
|
||||
});
|
||||
|
||||
const selectedCp = chargePoints.find((cp) => cp.id === selectedCpId) ?? null;
|
||||
const myTags = idTags?.filter((t) => t.status === "Accepted") ?? [];
|
||||
const activeTransactionsForCurrentUser = useMemo(() => {
|
||||
const currentUserId = sessionData?.user?.id;
|
||||
if (!currentUserId) return activeTransactions;
|
||||
return activeTransactions.filter((tx) => tx.idTagUserId === currentUserId);
|
||||
}, [activeTransactions, sessionData?.user?.id]);
|
||||
|
||||
const activeCount = activeTransactionsForCurrentUser.length;
|
||||
const activeDetailHref =
|
||||
activeCount === 1
|
||||
? `/dashboard/transactions/${activeTransactionsForCurrentUser[0].id}`
|
||||
: "/dashboard/transactions?status=active";
|
||||
const activeIdTagSet = useMemo(
|
||||
() => new Set(activeTransactionsForCurrentUser.map((tx) => tx.idTag)),
|
||||
[activeTransactionsForCurrentUser],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (startResult !== "success" && selectedIdTag && activeIdTagSet.has(selectedIdTag)) {
|
||||
setSelectedIdTag(null);
|
||||
}
|
||||
}, [selectedIdTag, activeIdTagSet, startResult]);
|
||||
|
||||
const { data: startedTransactionId } = useQuery({
|
||||
queryKey: [
|
||||
"latestStartedTx",
|
||||
startSnapshot?.cpId,
|
||||
startSnapshot?.connectorId,
|
||||
startSnapshot?.idTag,
|
||||
startResult,
|
||||
],
|
||||
enabled: startResult === "success" && !!startSnapshot?.cpId && !!startSnapshot?.idTag,
|
||||
queryFn: async () => {
|
||||
if (!startSnapshot?.cpId || !startSnapshot?.idTag) return null;
|
||||
const res = await api.transactions.list({
|
||||
page: 1,
|
||||
limit: 30,
|
||||
status: "active",
|
||||
chargePointId: startSnapshot.cpId,
|
||||
});
|
||||
|
||||
const matched = res.data.find(
|
||||
(tx) =>
|
||||
tx.idTag === startSnapshot.idTag && tx.connectorNumber === startSnapshot.connectorId,
|
||||
);
|
||||
|
||||
return matched?.id ?? null;
|
||||
},
|
||||
refetchInterval: (query) => (query.state.data ? false : 1_500),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const startMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
@@ -293,28 +345,49 @@ function ChargePageContent() {
|
||||
idTag: selectedIdTag,
|
||||
});
|
||||
},
|
||||
onMutate: () => {
|
||||
if (!selectedCp || selectedConnectorId === null || !selectedIdTag) return;
|
||||
setStartSnapshot({
|
||||
cpId: selectedCp.id,
|
||||
chargePointIdentifier: selectedCp.chargePointIdentifier,
|
||||
deviceName: selectedCp.deviceName,
|
||||
connectorId: selectedConnectorId,
|
||||
idTag: selectedIdTag,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
setStartResult("success");
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setStartResult("error");
|
||||
const msg = err.message ?? "";
|
||||
if (msg.includes("offline")) setStartError("充电桩当前不在线,请稍后再试");
|
||||
else if (msg.includes("not accepted")) setStartError("充电桩未启用,请联系管理员");
|
||||
else if (msg.includes("idTag")) setStartError("储值卡无效或无权使用");
|
||||
else setStartError("启动失败:" + msg);
|
||||
const lowerMsg = msg.toLowerCase();
|
||||
|
||||
if (lowerMsg.includes("command channel is unavailable") || lowerMsg.includes("offline")) {
|
||||
setStartError("充电桩下行通道不可用,请稍后再试");
|
||||
} else if (lowerMsg.includes("did not confirm remotestarttransaction in time")) {
|
||||
setStartError("充电桩未及时确认启动指令,请稍后重试");
|
||||
} else if (
|
||||
lowerMsg.includes("chargepoint is not accepted") ||
|
||||
lowerMsg.includes("not accepted")
|
||||
) {
|
||||
setStartError("充电桩未启用,请联系管理员");
|
||||
} else if (msg.includes("ConcurrentTx") || lowerMsg.includes("concurrent")) {
|
||||
setStartError("该储值卡已有进行中的充电订单,请先结束后再发起");
|
||||
} else if (lowerMsg.includes("rejected: blocked")) {
|
||||
setStartError("储值卡不可用或余额不足,请更换储值卡或先充值");
|
||||
} else if (lowerMsg.includes("rejected: expired")) {
|
||||
setStartError("储值卡已过期,请联系管理员处理");
|
||||
} else if (lowerMsg.includes("rejected: invalid")) {
|
||||
setStartError("储值卡无效,请确认卡号后重试");
|
||||
} else if (lowerMsg.includes("not found or not authorized")) {
|
||||
setStartError("该储值卡无权使用或不存在");
|
||||
} else if (msg.includes("idTag") || lowerMsg.includes("idtag")) {
|
||||
setStartError("储值卡状态不可用,无法启动充电");
|
||||
} else setStartError("启动失败:" + msg);
|
||||
},
|
||||
});
|
||||
|
||||
function resetAll() {
|
||||
setStep(1);
|
||||
setSelectedCpId(null);
|
||||
setSelectedConnectorId(null);
|
||||
setSelectedIdTag(null);
|
||||
setStartResult(null);
|
||||
setStartError(null);
|
||||
}
|
||||
|
||||
function handleScanResult(raw: string) {
|
||||
setShowScanner(false);
|
||||
setScanError(null);
|
||||
@@ -343,47 +416,87 @@ function ChargePageContent() {
|
||||
// ── Success screen ─────────────────────────────────────────────────────────
|
||||
if (startResult === "success") {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-6 py-20 text-center">
|
||||
<div className="flex size-16 items-center justify-center rounded-full bg-success/10">
|
||||
<Check className="size-8 text-success" />
|
||||
<div className="flex flex-col items-center justify-center gap-8 py-16 text-center">
|
||||
<div className="relative">
|
||||
<div className="flex size-24 items-center justify-center rounded-full bg-success-soft ring-8 ring-success/10">
|
||||
<Check className="size-12 text-success" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-foreground">充电指令已发送</h2>
|
||||
<p className="mt-1.5 text-sm text-muted">
|
||||
充电桩正在响应,充电将自动开始
|
||||
<br />
|
||||
你可以在"充电记录"中查看进度
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-2xl font-bold text-foreground">已发起充电</h2>
|
||||
<p className="text-sm text-muted leading-relaxed">充电桩正在响应,稍候将自动开始</p>
|
||||
</div>
|
||||
<Button onPress={resetAll}>再次充电</Button>
|
||||
<div className="w-full max-w-xs rounded-2xl border border-border bg-surface p-4 text-left space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted">充电桩</span>
|
||||
<span className="font-medium text-foreground">
|
||||
{startSnapshot?.deviceName ?? startSnapshot?.chargePointIdentifier ?? selectedCp?.deviceName ?? selectedCp?.chargePointIdentifier}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted">接口</span>
|
||||
<span className="font-medium text-foreground">
|
||||
#{startSnapshot?.connectorId ?? selectedConnectorId}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted">储值卡</span>
|
||||
<span className="font-mono font-medium text-foreground">
|
||||
{startSnapshot?.idTag ?? selectedIdTag}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{startedTransactionId ? (
|
||||
<Link
|
||||
href={`/dashboard/transactions/${startedTransactionId}`}
|
||||
className="w-full max-w-xs"
|
||||
>
|
||||
<Button size="lg" className="w-full">
|
||||
<BanknoteArrowUp className="size-4" />
|
||||
查看订单
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="w-full max-w-xs space-y-2">
|
||||
<Link href="/dashboard/transactions?status=active" className="block">
|
||||
<Button size="lg" variant="secondary" className="w-full">
|
||||
<BanknoteArrowUp className="size-4" />
|
||||
查看进行中订单
|
||||
</Button>
|
||||
</Link>
|
||||
<p className="text-xs text-muted text-center inline-flex w-full items-center justify-center gap-1.5">
|
||||
正在生成订单,可先前往列表查看
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main UI ────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-5 pb-4">
|
||||
{/* Header */}
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">立即充电</h1>
|
||||
<h1 className="text-xl font-bold text-foreground">立即充电</h1>
|
||||
<p className="mt-0.5 text-sm text-muted">选择充电桩和储值卡,远程启动充电</p>
|
||||
</div>
|
||||
|
||||
{/* QR scan button — mobile only */}
|
||||
{isMobile && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onPress={() => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setScanError(null);
|
||||
setShowScanner(true);
|
||||
}}
|
||||
isDisabled={showScanner}
|
||||
disabled={showScanner}
|
||||
className="flex shrink-0 flex-col items-center gap-1 rounded-2xl border border-border bg-surface px-3.5 py-2.5 text-foreground shadow-sm active:opacity-70 disabled:opacity-40"
|
||||
>
|
||||
<QrCode className="size-4" />
|
||||
扫码充电
|
||||
</Button>
|
||||
<QrCode className="size-5" />
|
||||
<span className="text-[10px] font-medium leading-none">扫码</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -403,7 +516,66 @@ function ChargePageContent() {
|
||||
</Modal.Backdrop>
|
||||
</Modal>
|
||||
|
||||
{scanError && <p className="text-sm text-danger">{scanError}</p>}
|
||||
<AlertDialog
|
||||
isOpen={startResult === "error" && !!startError}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setStartResult(null);
|
||||
setStartError(null);
|
||||
setStartSnapshot(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertDialog.Backdrop variant="blur">
|
||||
<AlertDialog.Container size="sm">
|
||||
<AlertDialog.Dialog>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.CloseTrigger />
|
||||
<AlertDialog.Icon status="danger" />
|
||||
<AlertDialog.Heading>启动失败</AlertDialog.Heading>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Body className="overflow-hidden">
|
||||
<p>{startError ?? "启动失败,请稍后重试"}</p>
|
||||
</AlertDialog.Body>
|
||||
<AlertDialog.Footer>
|
||||
<Button
|
||||
slot="close"
|
||||
variant="secondary"
|
||||
onPress={() => {
|
||||
setStartResult(null);
|
||||
setStartError(null);
|
||||
}}
|
||||
>
|
||||
我知道了
|
||||
</Button>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Dialog>
|
||||
</AlertDialog.Container>
|
||||
</AlertDialog.Backdrop>
|
||||
</AlertDialog>
|
||||
|
||||
{scanError && (
|
||||
<div className="rounded-xl border border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">
|
||||
{scanError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeCount > 0 && (
|
||||
<Alert status="accent">
|
||||
<Alert.Indicator />
|
||||
<Alert.Content>
|
||||
<Alert.Title>当前有 {activeCount} 笔进行中的充电</Alert.Title>
|
||||
<Alert.Description>
|
||||
同一张储值卡无法发起多笔充电订单,若要结束进行中的订单,请{" "}
|
||||
<Link href={activeDetailHref} className="underline">
|
||||
<Button variant="secondary" size="sm" className="px-1.5 h-6 rounded-xl">
|
||||
点击查看
|
||||
</Button>
|
||||
</Link>
|
||||
</Alert.Description>
|
||||
</Alert.Content>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Step bar */}
|
||||
<StepBar step={step} onGoBack={(t) => setStep(t)} />
|
||||
@@ -412,21 +584,25 @@ function ChargePageContent() {
|
||||
{step === 1 && (
|
||||
<div className="space-y-3">
|
||||
{cpLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="flex justify-center py-16">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : chargePoints.filter((cp) => cp.registrationStatus === "Accepted").length === 0 ? (
|
||||
<div className="rounded-xl border border-border px-6 py-12 text-center">
|
||||
<PlugConnection className="mx-auto mb-2 size-8 text-muted" />
|
||||
<p className="text-sm text-muted">暂无可用充电桩</p>
|
||||
<div className="rounded-2xl border border-border px-6 py-14 text-center">
|
||||
<Plug className="mx-auto mb-3 size-10 text-muted" />
|
||||
<p className="font-medium text-foreground">暂无可用充电桩</p>
|
||||
<p className="mt-1 text-sm text-muted">请稍后刷新查看</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{chargePoints
|
||||
.filter((cp) => cp.registrationStatus === "Accepted")
|
||||
.map((cp) => {
|
||||
const online =
|
||||
!!cp.lastHeartbeatAt && dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120;
|
||||
cp.transportStatus === "online" &&
|
||||
!!cp.lastHeartbeatAt &&
|
||||
dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120;
|
||||
const commandChannelUnavailable = cp.transportStatus === "unavailable";
|
||||
const availableCount = cp.connectors.filter(
|
||||
(c) => c.status === "Available",
|
||||
).length;
|
||||
@@ -442,37 +618,74 @@ function ChargePageContent() {
|
||||
setStep(2);
|
||||
}}
|
||||
className={[
|
||||
"flex flex-col gap-2.5 rounded-xl border p-4 text-left transition-all",
|
||||
"flex flex-col gap-3 rounded-2xl border p-4 text-left transition-all",
|
||||
disabled
|
||||
? "cursor-not-allowed opacity-50 border-border"
|
||||
: "cursor-pointer border-border hover:border-accent hover:bg-accent/5",
|
||||
selectedCpId === cp.id ? "border-accent bg-accent/10" : "",
|
||||
? "cursor-not-allowed opacity-40 border-border bg-surface-secondary"
|
||||
: selectedCpId === cp.id
|
||||
? "border-accent bg-accent/8 shadow-sm shadow-accent-soft-hover active:scale-[0.98]"
|
||||
: "cursor-pointer border-border bg-surface hover:border-accent/60 hover:bg-accent/5 active:scale-[0.98]",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium text-foreground truncate">
|
||||
{cp.chargePointIdentifier}
|
||||
</span>
|
||||
<Chip size="sm" color={online ? "success" : "default"} variant="soft">
|
||||
{online ? "在线" : "离线"}
|
||||
</Chip>
|
||||
</div>
|
||||
{(cp.chargePointVendor || cp.chargePointModel) && (
|
||||
<span className="text-xs text-muted">
|
||||
{[cp.chargePointVendor, cp.chargePointModel].filter(Boolean).join(" · ")}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="flex items-center gap-1 text-xs text-muted">
|
||||
<PlugConnection className="size-3" />
|
||||
{availableCount}/{cp.connectors.length} 接口空闲
|
||||
</span>
|
||||
{cp.feePerKwh > 0 && (
|
||||
<span className="text-xs text-muted">
|
||||
· ¥{(cp.feePerKwh / 100).toFixed(2)}/kWh
|
||||
{/* Top row: name + status */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="font-semibold text-foreground truncate leading-tight">
|
||||
{cp.deviceName ?? cp.chargePointIdentifier}
|
||||
</span>
|
||||
{isAdmin && cp.deviceName && (
|
||||
<span className="font-mono text-xs text-muted truncate">
|
||||
{cp.chargePointIdentifier}
|
||||
</span>
|
||||
)}
|
||||
{(cp.chargePointVendor || cp.chargePointModel) && (
|
||||
<span className="text-xs text-muted truncate">
|
||||
{[cp.chargePointVendor, cp.chargePointModel]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={[
|
||||
"shrink-0 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold",
|
||||
online
|
||||
? "bg-success/12 text-success"
|
||||
: commandChannelUnavailable
|
||||
? "bg-warning/12 text-warning"
|
||||
: "bg-surface-tertiary text-muted",
|
||||
].join(" ")}
|
||||
>
|
||||
<span
|
||||
className={`size-1.5 rounded-full ${
|
||||
online ? "bg-success" : commandChannelUnavailable ? "bg-warning" : "bg-muted"
|
||||
}`}
|
||||
/>
|
||||
{online ? "在线" : commandChannelUnavailable ? "通道异常" : "离线"}
|
||||
</span>
|
||||
</div>
|
||||
{/* Bottom row: connectors + fee */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-1.5 text-sm text-muted">
|
||||
<Plug className="size-3.5 shrink-0" />
|
||||
<span>
|
||||
<span
|
||||
className={availableCount > 0 ? "font-semibold text-foreground" : ""}
|
||||
>
|
||||
{availableCount}
|
||||
</span>
|
||||
/{cp.connectors.length} 空闲
|
||||
</span>
|
||||
</span>
|
||||
{cp.pricingMode === "tou" ? (
|
||||
<span className="text-sm font-medium text-accent">峰谷电价</span>
|
||||
) : cp.feePerKwh > 0 ? (
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
¥{(cp.feePerKwh / 100).toFixed(2)}
|
||||
<span className="text-xs text-muted font-normal">/kWh</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm font-semibold text-success">免费</span>
|
||||
)}
|
||||
{cp.feePerKwh === 0 && <span className="text-xs text-success">· 免费</span>}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
@@ -484,23 +697,25 @@ function ChargePageContent() {
|
||||
|
||||
{/* ── Step 2: Select connector ──────────────────────────────────── */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
{/* Context pill */}
|
||||
{selectedCp && (
|
||||
<p className="text-sm text-muted">
|
||||
充电桩:
|
||||
<span className="font-medium text-foreground">
|
||||
{selectedCp.chargePointIdentifier}
|
||||
<div className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface px-3 py-1.5 text-xs">
|
||||
<EvCharger className="size-3.5 text-muted" />
|
||||
<span className="text-muted">充电桩</span>
|
||||
<span className="font-semibold text-foreground">
|
||||
{selectedCp.deviceName ?? selectedCp.chargePointIdentifier}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedCp && selectedCp.connectors.filter((c) => c.connectorId > 0).length === 0 ? (
|
||||
<div className="rounded-xl border border-border px-6 py-12 text-center">
|
||||
<PlugConnection className="mx-auto mb-2 size-8 text-muted" />
|
||||
<p className="text-sm text-muted">该充电桩暂无接口信息</p>
|
||||
<div className="rounded-2xl border border-border px-6 py-14 text-center">
|
||||
<Plug className="mx-auto mb-3 size-10 text-muted" />
|
||||
<p className="font-medium text-foreground">该桩暂无可用接口</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
|
||||
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{selectedCp?.connectors
|
||||
.filter((c) => c.connectorId > 0)
|
||||
.sort((a, b) => a.connectorId - b.connectorId)
|
||||
@@ -518,112 +733,138 @@ function ChargePageContent() {
|
||||
}
|
||||
}}
|
||||
className={[
|
||||
"flex flex-col gap-2 rounded-xl border p-4 text-left transition-all",
|
||||
"relative flex flex-col items-center gap-3 rounded-2xl border py-5 px-3 text-center transition-all",
|
||||
!available
|
||||
? "cursor-not-allowed opacity-50 border-border"
|
||||
: "cursor-pointer border-border hover:border-accent hover:bg-accent/5",
|
||||
selectedConnectorId === conn.connectorId
|
||||
? "border-accent bg-accent/10"
|
||||
: "",
|
||||
? "cursor-not-allowed opacity-40 border-border bg-surface-secondary"
|
||||
: selectedConnectorId === conn.connectorId
|
||||
? "border-accent bg-accent/8 shadow-sm shadow-accent-soft-hover active:scale-[0.97]"
|
||||
: "cursor-pointer border-border bg-surface hover:border-accent/60 hover:bg-accent/5 active:scale-[0.97]",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-foreground">
|
||||
接口 #{conn.connectorId}
|
||||
</span>
|
||||
<span
|
||||
className={`size-2 rounded-full ${statusDotClass[conn.status] ?? "bg-warning"}`}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted">
|
||||
{statusLabelMap[conn.status] ?? conn.status}
|
||||
<span
|
||||
className={[
|
||||
"flex size-12 items-center justify-center rounded-full text-xl font-bold",
|
||||
available
|
||||
? "bg-success/12 text-success"
|
||||
: "bg-surface-tertiary text-muted",
|
||||
].join(" ")}
|
||||
>
|
||||
{conn.connectorId}
|
||||
</span>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
接口 #{conn.connectorId}
|
||||
</p>
|
||||
<p
|
||||
className={[
|
||||
"text-xs font-medium",
|
||||
conn.status === "Available" ? "text-success" : "text-muted",
|
||||
].join(" ")}
|
||||
>
|
||||
{statusLabelMap[conn.status] ?? conn.status}
|
||||
</p>
|
||||
</div>
|
||||
{selectedConnectorId === conn.connectorId && (
|
||||
<span className="absolute right-2 top-2 flex size-5 items-center justify-center rounded-full bg-accent">
|
||||
<Check className="size-3 text-white" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button variant="secondary" size="sm" onPress={() => setStep(1)}>
|
||||
上一步
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step 3: Select ID tag + start ────────────────────────────── */}
|
||||
{step === 3 && (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap gap-3 text-sm text-muted">
|
||||
<div className="space-y-4">
|
||||
{/* Context pills */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedCp && (
|
||||
<span>
|
||||
充电桩:
|
||||
<span className="font-medium text-foreground">
|
||||
{selectedCp.chargePointIdentifier}
|
||||
<div className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface px-3 py-1.5 text-xs">
|
||||
<EvCharger className="size-3.5 text-muted" />
|
||||
<span className="text-muted">充电桩</span>
|
||||
<span className="font-semibold text-foreground">
|
||||
{selectedCp.deviceName ?? selectedCp.chargePointIdentifier}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{selectedConnectorId !== null && (
|
||||
<span>
|
||||
接口:
|
||||
<span className="font-medium text-foreground">#{selectedConnectorId}</span>
|
||||
</span>
|
||||
<div className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface px-3 py-1.5 text-xs">
|
||||
<Plug className="size-3.5 text-muted" />
|
||||
<span className="text-muted">接口</span>
|
||||
<span className="font-semibold text-foreground">#{selectedConnectorId}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm font-semibold text-foreground">选择储值卡充电</p>
|
||||
|
||||
{tagsLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="flex justify-center py-16">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : myTags.length === 0 ? (
|
||||
<div className="rounded-xl border border-border px-6 py-12 text-center">
|
||||
<CreditCard className="mx-auto mb-2 size-8 text-muted" />
|
||||
<p className="text-sm text-muted">你还没有可用的储值卡</p>
|
||||
<p className="mt-1 text-xs text-muted">请前往"储值卡"页面领取或添加</p>
|
||||
<div className="rounded-2xl border border-border px-6 py-14 text-center">
|
||||
<CreditCard className="mx-auto mb-3 size-10 text-muted" />
|
||||
<p className="font-medium text-foreground">你还没有储值卡</p>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
请前往
|
||||
<Link href="/dashboard/id-tags" className="text-accent hover:underline">
|
||||
储值卡
|
||||
</Link>
|
||||
页面申领或前往服务中心办理
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{myTags.map((tag) => (
|
||||
<button
|
||||
key={tag.idTag}
|
||||
type="button"
|
||||
onClick={() => setSelectedIdTag(tag.idTag)}
|
||||
className={[
|
||||
"flex flex-col gap-1.5 rounded-xl border p-4 text-left transition-all cursor-pointer",
|
||||
"border-border hover:border-accent hover:bg-accent/5",
|
||||
selectedIdTag === tag.idTag ? "border-accent bg-accent/10" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-sm font-medium text-foreground">
|
||||
{tag.idTag}
|
||||
</span>
|
||||
{selectedIdTag === tag.idTag && (
|
||||
<Check className="size-4 shrink-0 text-accent" />
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{myTags.map((tag) => {
|
||||
const locked = activeIdTagSet.has(tag.idTag);
|
||||
return (
|
||||
<div key={tag.idTag} className="space-y-2">
|
||||
<IdTagCard
|
||||
idTag={tag.idTag}
|
||||
balance={tag.balance}
|
||||
layout={tag.cardLayout ?? undefined}
|
||||
skin={tag.cardSkin ?? undefined}
|
||||
isSelected={selectedIdTag === tag.idTag}
|
||||
isDisabled={locked}
|
||||
onClick={() => {
|
||||
if (!locked) setSelectedIdTag(tag.idTag);
|
||||
}}
|
||||
/>
|
||||
{locked && (
|
||||
<p className="px-1 text-xs font-medium text-warning">
|
||||
该卡有进行中的订单,暂不可再次启动
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted">余额</span>
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
¥{(tag.balance / 100).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{startResult === "error" && (
|
||||
<p className="text-sm text-danger">{startError ?? "启动失败,请重试"}</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
{/* Action bar */}
|
||||
<div className="flex gap-3 pt-1">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onPress={() => {
|
||||
setStep(2);
|
||||
setStartResult(null);
|
||||
setStartError(null);
|
||||
setStartSnapshot(null);
|
||||
}}
|
||||
>
|
||||
返回
|
||||
上一步
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
isDisabled={!selectedIdTag || startMutation.isPending}
|
||||
onPress={() => startMutation.mutate()}
|
||||
>
|
||||
|
||||
@@ -24,9 +24,11 @@ import {
|
||||
} from "@heroui/react";
|
||||
import { parseDate } from "@internationalized/date";
|
||||
import { ArrowRotateRight, Pencil, Plus, TrashBin } from "@gravity-ui/icons";
|
||||
import { LayoutGrid, List } from "lucide-react";
|
||||
import { api, type IdTag, type UserRow } from "@/lib/api";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
import dayjs from "@/lib/dayjs";
|
||||
import { IdTagCard } from "@/components/id-tag-card";
|
||||
|
||||
const statusColorMap: Record<string, "success" | "danger" | "warning"> = {
|
||||
Accepted: "success",
|
||||
@@ -43,6 +45,8 @@ type FormState = {
|
||||
parentIdTag: string;
|
||||
userId: string;
|
||||
balance: string;
|
||||
cardLayout: string;
|
||||
cardSkin: string;
|
||||
};
|
||||
|
||||
const emptyForm: FormState = {
|
||||
@@ -52,6 +56,8 @@ const emptyForm: FormState = {
|
||||
parentIdTag: "",
|
||||
userId: "",
|
||||
balance: "0",
|
||||
cardLayout: "around",
|
||||
cardSkin: "circles",
|
||||
};
|
||||
|
||||
const STATUS_OPTIONS = ["Accepted", "Blocked", "Expired", "Invalid", "ConcurrentTx"] as const;
|
||||
@@ -325,6 +331,61 @@ function TagFormBody({
|
||||
users={users}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-sm font-medium">卡面布局</Label>
|
||||
<Select
|
||||
fullWidth
|
||||
selectedKey={form.cardLayout}
|
||||
onSelectionChange={(key) => setForm({ ...form, cardLayout: String(key) })}
|
||||
>
|
||||
<Select.Trigger>
|
||||
<Select.Value />
|
||||
<Select.Indicator />
|
||||
</Select.Trigger>
|
||||
<Select.Popover>
|
||||
<ListBox>
|
||||
<ListBox.Item key="around" id="around">
|
||||
四周
|
||||
</ListBox.Item>
|
||||
<ListBox.Item key="center" id="center">
|
||||
卡号居中
|
||||
</ListBox.Item>
|
||||
</ListBox>
|
||||
</Select.Popover>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-sm font-medium">卡面皮肤</Label>
|
||||
<Select
|
||||
fullWidth
|
||||
selectedKey={form.cardSkin}
|
||||
onSelectionChange={(key) => setForm({ ...form, cardSkin: String(key) })}
|
||||
>
|
||||
<Select.Trigger>
|
||||
<Select.Value />
|
||||
<Select.Indicator />
|
||||
</Select.Trigger>
|
||||
<Select.Popover>
|
||||
<ListBox>
|
||||
<ListBox.Item key="circles" id="circles">
|
||||
装饰圆形
|
||||
</ListBox.Item>
|
||||
<ListBox.Item key="line" id="line">
|
||||
斜线纹理
|
||||
</ListBox.Item>
|
||||
<ListBox.Item key="glow" id="glow">
|
||||
渐变光晕
|
||||
</ListBox.Item>
|
||||
<ListBox.Item key="vip" id="vip">
|
||||
黑金 VIP
|
||||
</ListBox.Item>
|
||||
<ListBox.Item key="redeye" id="redeye">
|
||||
赤眼
|
||||
</ListBox.Item>
|
||||
</ListBox>
|
||||
</Select.Popover>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -337,6 +398,7 @@ export default function IdTagsPage() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deletingTag, setDeletingTag] = useState<string | null>(null);
|
||||
const [claiming, setClaiming] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<"table" | "grid">("table");
|
||||
|
||||
const {
|
||||
data: idTagsData,
|
||||
@@ -344,7 +406,7 @@ export default function IdTagsPage() {
|
||||
isFetching: refreshing,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["idTags"],
|
||||
queryKey: ["idTags", "withUsers"],
|
||||
queryFn: async () => {
|
||||
const [tagList, userList] = await Promise.all([
|
||||
api.idTags.list(),
|
||||
@@ -381,6 +443,8 @@ export default function IdTagsPage() {
|
||||
parentIdTag: tag.parentIdTag ?? "",
|
||||
userId: tag.userId ?? "",
|
||||
balance: fenToYuan(tag.balance),
|
||||
cardLayout: tag.cardLayout ?? "around",
|
||||
cardSkin: tag.cardSkin ?? "circles",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -394,6 +458,8 @@ export default function IdTagsPage() {
|
||||
parentIdTag: form.parentIdTag || null,
|
||||
userId: form.userId || null,
|
||||
balance: yuanToFen(form.balance),
|
||||
cardLayout: (form.cardLayout as "center" | "around") || null,
|
||||
cardSkin: (form.cardSkin as "line" | "circles" | "glow" | "vip" | "redeye") || null,
|
||||
});
|
||||
} else {
|
||||
await api.idTags.create({
|
||||
@@ -403,6 +469,8 @@ export default function IdTagsPage() {
|
||||
parentIdTag: form.parentIdTag || undefined,
|
||||
userId: form.userId || undefined,
|
||||
balance: yuanToFen(form.balance),
|
||||
cardLayout: (form.cardLayout as "center" | "around") || undefined,
|
||||
cardSkin: (form.cardSkin as "line" | "circles" | "glow" | "vip" | "redeye") || undefined,
|
||||
});
|
||||
}
|
||||
await refetch();
|
||||
@@ -440,6 +508,19 @@ export default function IdTagsPage() {
|
||||
>
|
||||
<ArrowRotateRight className={`size-4 ${refreshing ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onPress={() => setViewMode(viewMode === "table" ? "grid" : "table")}
|
||||
aria-label={viewMode === "table" ? "切换到卡片视图" : "切换到列表视图"}
|
||||
>
|
||||
{viewMode === "table" ? (
|
||||
<LayoutGrid className="size-4" />
|
||||
) : (
|
||||
<List className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Modal>
|
||||
<Button size="sm" variant="secondary" onPress={openCreate}>
|
||||
<Plus className="size-4" />
|
||||
@@ -482,69 +563,46 @@ export default function IdTagsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<Table.ScrollContainer>
|
||||
<Table.Content aria-label="储值卡列表" className="min-w-200">
|
||||
<Table.Header>
|
||||
<Table.Column isRowHeader>卡号</Table.Column>
|
||||
<Table.Column>状态</Table.Column>
|
||||
<Table.Column>余额</Table.Column>
|
||||
{isAdmin && <Table.Column>关联用户</Table.Column>}
|
||||
<Table.Column>有效期</Table.Column>
|
||||
<Table.Column>父卡号</Table.Column>
|
||||
<Table.Column>创建时间</Table.Column>
|
||||
{isAdmin && <Table.Column className="text-end">操作</Table.Column>}
|
||||
</Table.Header>
|
||||
<Table.Body
|
||||
renderEmptyState={() => (
|
||||
<div className="py-8 text-center text-sm text-muted">
|
||||
{loading ? "加载中…" : "暂无储值卡"}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{/* ── Grid view ─────────────────────────────────────────── */}
|
||||
{(!isAdmin || viewMode === "grid") && (
|
||||
<>
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : tags.length === 0 ? (
|
||||
<div className="rounded-2xl border border-border px-6 py-14 text-center text-sm text-muted">
|
||||
暂无储值卡
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{tags.map((tag) => {
|
||||
const owner = users.find((u) => u.id === tag.userId);
|
||||
return (
|
||||
<Table.Row key={tag.idTag} id={tag.idTag}>
|
||||
<Table.Cell className="font-mono font-medium">{tag.idTag}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Chip
|
||||
color={statusColorMap[tag.status] ?? "warning"}
|
||||
size="sm"
|
||||
variant="soft"
|
||||
>
|
||||
{tag.status}
|
||||
</Chip>
|
||||
</Table.Cell>
|
||||
<Table.Cell className="font-mono">¥{fenToYuan(tag.balance)}</Table.Cell>
|
||||
<div key={tag.idTag} className="space-y-2">
|
||||
<IdTagCard
|
||||
idTag={tag.idTag}
|
||||
balance={tag.balance}
|
||||
layout={tag.cardLayout ?? undefined}
|
||||
skin={tag.cardSkin ?? undefined}
|
||||
/>
|
||||
{isAdmin && (
|
||||
<Table.Cell className="text-sm">
|
||||
{owner ? (
|
||||
<span title={owner.email}>
|
||||
{owner.name ?? owner.username ?? owner.email}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted">—</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
)}
|
||||
<Table.Cell>
|
||||
{tag.expiryDate ? (
|
||||
dayjs(tag.expiryDate).format("YYYY/M/D")
|
||||
) : (
|
||||
<span className="text-muted">—</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell className="font-mono">
|
||||
{tag.parentIdTag ?? <span className="text-muted">—</span>}
|
||||
</Table.Cell>
|
||||
<Table.Cell className="text-sm">
|
||||
{dayjs(tag.createdAt).format("YYYY/M/D HH:mm:ss")}
|
||||
</Table.Cell>
|
||||
{isAdmin && (
|
||||
<Table.Cell>
|
||||
<div className="flex justify-end gap-1">
|
||||
{/* Edit button */}
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<Chip
|
||||
color={statusColorMap[tag.status] ?? "warning"}
|
||||
size="sm"
|
||||
variant="soft"
|
||||
>
|
||||
{tag.status}
|
||||
</Chip>
|
||||
{owner && (
|
||||
<span className="truncate text-xs text-muted">
|
||||
{owner.name ?? owner.username ?? owner.email}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<Modal>
|
||||
<Button
|
||||
isIconOnly
|
||||
@@ -582,7 +640,6 @@ export default function IdTagsPage() {
|
||||
</Modal.Container>
|
||||
</Modal.Backdrop>
|
||||
</Modal>
|
||||
{/* Delete button */}
|
||||
<Modal>
|
||||
<Button
|
||||
isDisabled={deletingTag === tag.idTag}
|
||||
@@ -634,15 +691,180 @@ export default function IdTagsPage() {
|
||||
</Modal.Backdrop>
|
||||
</Modal>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</div>
|
||||
)}
|
||||
</Table.Row>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Table.Body>
|
||||
</Table.Content>
|
||||
</Table.ScrollContainer>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Table view (admin only) ────────────────────────────────── */}
|
||||
{isAdmin && viewMode === "table" && (
|
||||
<Table>
|
||||
<Table.ScrollContainer>
|
||||
<Table.Content aria-label="储值卡列表" className="min-w-200">
|
||||
<Table.Header>
|
||||
<Table.Column isRowHeader>卡号</Table.Column>
|
||||
<Table.Column>状态</Table.Column>
|
||||
<Table.Column>余额</Table.Column>
|
||||
{isAdmin && <Table.Column>关联用户</Table.Column>}
|
||||
<Table.Column>有效期</Table.Column>
|
||||
<Table.Column>父卡号</Table.Column>
|
||||
<Table.Column>创建时间</Table.Column>
|
||||
{isAdmin && <Table.Column className="text-end">操作</Table.Column>}
|
||||
</Table.Header>
|
||||
<Table.Body
|
||||
renderEmptyState={() => (
|
||||
<div className="py-8 text-center text-sm text-muted">
|
||||
{loading ? "加载中…" : "暂无储值卡"}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{tags.map((tag) => {
|
||||
const owner = users.find((u) => u.id === tag.userId);
|
||||
return (
|
||||
<Table.Row key={tag.idTag} id={tag.idTag}>
|
||||
<Table.Cell className="font-mono font-medium">{tag.idTag}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Chip
|
||||
color={statusColorMap[tag.status] ?? "warning"}
|
||||
size="sm"
|
||||
variant="soft"
|
||||
>
|
||||
{tag.status}
|
||||
</Chip>
|
||||
</Table.Cell>
|
||||
<Table.Cell className="font-mono">¥{fenToYuan(tag.balance)}</Table.Cell>
|
||||
{isAdmin && (
|
||||
<Table.Cell className="text-sm">
|
||||
{owner ? (
|
||||
<span title={owner.email}>
|
||||
{owner.name ?? owner.username ?? owner.email}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted">—</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
)}
|
||||
<Table.Cell>
|
||||
{tag.expiryDate ? (
|
||||
dayjs(tag.expiryDate).format("YYYY/M/D")
|
||||
) : (
|
||||
<span className="text-muted">—</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell className="font-mono">
|
||||
{tag.parentIdTag ?? <span className="text-muted">—</span>}
|
||||
</Table.Cell>
|
||||
<Table.Cell className="text-sm">
|
||||
{dayjs(tag.createdAt).format("YYYY/M/D HH:mm:ss")}
|
||||
</Table.Cell>
|
||||
{isAdmin && (
|
||||
<Table.Cell>
|
||||
<div className="flex justify-end gap-1">
|
||||
{/* Edit button */}
|
||||
<Modal>
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
onPress={() => openEdit(tag)}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Modal.Backdrop>
|
||||
<Modal.Container scroll="outside">
|
||||
<Modal.Dialog className="sm:max-w-105">
|
||||
<Modal.CloseTrigger />
|
||||
<Modal.Header>
|
||||
<Modal.Heading>编辑储值卡</Modal.Heading>
|
||||
</Modal.Header>
|
||||
<Modal.Body className="space-y-3">
|
||||
<TagFormBody
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
isEdit={true}
|
||||
users={users}
|
||||
tags={tags}
|
||||
/>
|
||||
</Modal.Body>
|
||||
<Modal.Footer className="flex justify-end gap-2">
|
||||
<Button slot="close" variant="ghost">
|
||||
取消
|
||||
</Button>
|
||||
<Button isDisabled={saving} slot="close" onPress={handleSave}>
|
||||
{saving ? <Spinner size="sm" /> : "保存"}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Dialog>
|
||||
</Modal.Container>
|
||||
</Modal.Backdrop>
|
||||
</Modal>
|
||||
{/* Delete button */}
|
||||
<Modal>
|
||||
<Button
|
||||
isDisabled={deletingTag === tag.idTag}
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="danger-soft"
|
||||
>
|
||||
{deletingTag === tag.idTag ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
<TrashBin className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Modal.Backdrop>
|
||||
<Modal.Container scroll="outside">
|
||||
<Modal.Dialog className="sm:max-w-96">
|
||||
<Modal.CloseTrigger />
|
||||
<Modal.Header>
|
||||
<Modal.Heading>确认删除储值卡</Modal.Heading>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<p className="text-sm text-muted">
|
||||
将永久删除储值卡{" "}
|
||||
<span className="font-mono font-medium text-foreground">
|
||||
{tag.idTag}
|
||||
</span>
|
||||
,此操作不可恢复。
|
||||
</p>
|
||||
</Modal.Body>
|
||||
<Modal.Footer className="flex justify-end gap-2">
|
||||
<Button slot="close" variant="ghost">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
slot="close"
|
||||
variant="danger"
|
||||
isDisabled={deletingTag === tag.idTag}
|
||||
onPress={() => handleDelete(tag.idTag)}
|
||||
>
|
||||
{deletingTag === tag.idTag ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
"确认删除"
|
||||
)}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Dialog>
|
||||
</Modal.Container>
|
||||
</Modal.Backdrop>
|
||||
</Modal>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
)}
|
||||
</Table.Row>
|
||||
);
|
||||
})}
|
||||
</Table.Body>
|
||||
</Table.Content>
|
||||
</Table.ScrollContainer>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,15 +2,13 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button, Card, Spinner } from "@heroui/react";
|
||||
import { Button, Spinner } from "@heroui/react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Thunderbolt,
|
||||
PlugConnection,
|
||||
CreditCard,
|
||||
ChartColumn,
|
||||
TagDollar,
|
||||
Person,
|
||||
ArrowRotateRight,
|
||||
TriangleExclamation,
|
||||
} from "@gravity-ui/icons";
|
||||
@@ -26,6 +24,8 @@ import {
|
||||
type ChartRange,
|
||||
type ChartDataPoint,
|
||||
} from "@/lib/api";
|
||||
import { BanknoteArrowDown, EvCharger, Plug, Users } from "lucide-react";
|
||||
import MetricIndicator from "@/components/metric-indicator";
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -35,7 +35,7 @@ function timeAgo(dateStr: string | null | undefined): string {
|
||||
}
|
||||
|
||||
function cpOnline(cp: ChargePoint): boolean {
|
||||
if (!cp.lastHeartbeatAt) return false;
|
||||
if (cp.transportStatus !== "online" || !cp.lastHeartbeatAt) return false;
|
||||
return dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120;
|
||||
}
|
||||
|
||||
@@ -71,24 +71,20 @@ function StatCard({
|
||||
}) {
|
||||
const s = colorStyles[color];
|
||||
return (
|
||||
<Card className={`border-t-2 ${s.border}`}>
|
||||
<Card.Content className="flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm text-muted">{title}</p>
|
||||
{Icon && (
|
||||
<div className={`flex size-9 shrink-0 items-center justify-center rounded-xl ${s.bg}`}>
|
||||
<Icon className={`size-4.5 ${s.icon}`} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-3xl font-bold tabular-nums leading-none text-foreground">{value}</p>
|
||||
{footer && (
|
||||
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-muted">
|
||||
{footer}
|
||||
<MetricIndicator
|
||||
title={title}
|
||||
value={value}
|
||||
color={s.border}
|
||||
valueClassName="text-3xl font-bold tabular-nums leading-none text-foreground"
|
||||
icon={
|
||||
Icon ? (
|
||||
<div className={`flex size-9 shrink-0 items-center justify-center rounded-xl ${s.bg}`}>
|
||||
<Icon className={`size-4.5 ${s.icon}`} />
|
||||
</div>
|
||||
)}
|
||||
</Card.Content>
|
||||
</Card>
|
||||
) : undefined
|
||||
}
|
||||
footer={footer}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -259,7 +255,7 @@ function TrendChart() {
|
||||
showXAxis={true}
|
||||
curveType="monotone"
|
||||
showAnimation
|
||||
className="h-56"
|
||||
className="h-56 text-sm"
|
||||
/>
|
||||
)}
|
||||
{/* Legend */}
|
||||
@@ -289,7 +285,7 @@ function TrendChart() {
|
||||
|
||||
// ── RecentTransactions ────────────────────────────────────────────────────
|
||||
|
||||
function RecentTransactions({ txns }: { txns: Transaction[] }) {
|
||||
function RecentTransactions({ txns, isAdmin = false }: { txns: Transaction[]; isAdmin?: boolean }) {
|
||||
if (txns.length === 0) {
|
||||
return <div className="py-8 text-center text-sm text-muted">暂无充电记录</div>;
|
||||
}
|
||||
@@ -310,14 +306,21 @@ function RecentTransactions({ txns }: { txns: Transaction[] }) {
|
||||
<span
|
||||
className={`flex size-7 shrink-0 items-center justify-center rounded-full ${active ? "bg-warning-soft" : "bg-success/10"}`}
|
||||
>
|
||||
<Thunderbolt className={`size-3.5 ${active ? "text-warning" : "text-success"}`} />
|
||||
<BanknoteArrowDown
|
||||
className={`size-3.5 ${active ? "text-warning" : "text-success"}`}
|
||||
/>
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{tx.chargePointIdentifier ?? "—"}
|
||||
{tx.chargePointDeviceName ?? tx.chargePointIdentifier ?? "—"}
|
||||
{tx.connectorNumber != null && (
|
||||
<span className="ml-1 text-xs text-muted">#{tx.connectorNumber}</span>
|
||||
)}
|
||||
{isAdmin && tx.chargePointDeviceName && tx.chargePointIdentifier && (
|
||||
<span className="ml-1 font-mono text-xs text-muted">
|
||||
({tx.chargePointIdentifier})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted">
|
||||
{tx.idTag}
|
||||
@@ -349,7 +352,7 @@ function RecentTransactions({ txns }: { txns: Transaction[] }) {
|
||||
|
||||
// ── ChargePointStatus ─────────────────────────────────────────────────────
|
||||
|
||||
function ChargePointStatus({ cps }: { cps: ChargePoint[] }) {
|
||||
function ChargePointStatus({ cps, isAdmin }: { cps: ChargePoint[]; isAdmin: boolean }) {
|
||||
if (cps.length === 0) {
|
||||
return <div className="py-8 text-center text-sm text-muted">暂无充电桩</div>;
|
||||
}
|
||||
@@ -371,11 +374,16 @@ function ChargePointStatus({ cps }: { cps: ChargePoint[] }) {
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{cp.chargePointIdentifier}
|
||||
</p>
|
||||
<p className="text-xs text-muted">
|
||||
{cp.chargePointModel ?? cp.chargePointVendor ?? "未知型号"}
|
||||
{cp.deviceName ?? cp.chargePointIdentifier}
|
||||
</p>
|
||||
{isAdmin && cp.deviceName && (
|
||||
<p className="font-mono text-xs text-muted">{cp.chargePointIdentifier}</p>
|
||||
)}
|
||||
{!(isAdmin && cp.deviceName) && (
|
||||
<p className="text-xs text-muted">
|
||||
{cp.chargePointModel ?? cp.chargePointVendor ?? "未知型号"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
{online ? (
|
||||
@@ -404,7 +412,7 @@ function ChargePointStatus({ cps }: { cps: ChargePoint[] }) {
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<PlugConnection className="size-3 text-accent" />
|
||||
<Plug className="size-3 text-accent" />
|
||||
<span className="text-muted">{availableCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -531,7 +539,7 @@ export default function DashboardPage() {
|
||||
<StatCard
|
||||
title="充电桩总数"
|
||||
value={s?.totalChargePoints ?? "—"}
|
||||
icon={PlugConnection}
|
||||
icon={EvCharger}
|
||||
color="default"
|
||||
footer={
|
||||
<>
|
||||
@@ -552,7 +560,7 @@ export default function DashboardPage() {
|
||||
<StatCard
|
||||
title="注册用户"
|
||||
value={s?.totalUsers ?? "—"}
|
||||
icon={Person}
|
||||
icon={Users}
|
||||
color="default"
|
||||
footer={<span>系统用户总数</span>}
|
||||
/>
|
||||
@@ -565,12 +573,12 @@ export default function DashboardPage() {
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-5">
|
||||
<div className="lg:col-span-2">
|
||||
<Panel title="充电桩状态">
|
||||
<ChargePointStatus cps={data?.cps ?? []} />
|
||||
<ChargePointStatus cps={data?.cps ?? []} isAdmin={isAdmin} />
|
||||
</Panel>
|
||||
</div>
|
||||
<div className="lg:col-span-3">
|
||||
<Panel title="最近充电会话">
|
||||
<RecentTransactions txns={data?.txns ?? []} />
|
||||
<RecentTransactions txns={data?.txns ?? []} isAdmin={isAdmin} />
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
535
apps/web/app/dashboard/settings/pricing/page.tsx
Normal file
535
apps/web/app/dashboard/settings/pricing/page.tsx
Normal file
@@ -0,0 +1,535 @@
|
||||
"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);
|
||||
|
||||
// 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);
|
||||
|
||||
// ── 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>
|
||||
);
|
||||
}
|
||||
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,
|
||||
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,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button, Chip, Modal, Pagination, Spinner, Table } from "@heroui/react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { TrashBin, ArrowRotateRight } from "@gravity-ui/icons";
|
||||
import { api } from "@/lib/api";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
@@ -10,6 +12,39 @@ import dayjs from "@/lib/dayjs";
|
||||
|
||||
const LIMIT = 15;
|
||||
|
||||
const idTagRejectLabelMap: Record<string, string> = {
|
||||
Blocked: "卡片不可用或余额不足",
|
||||
Expired: "卡片已过期",
|
||||
Invalid: "卡片无效",
|
||||
ConcurrentTx: "该卡已有进行中的订单",
|
||||
};
|
||||
|
||||
const stopReasonLabelMap: Record<string, string> = {
|
||||
EmergencyStop: "紧急停止",
|
||||
EVDisconnected: "车辆断开",
|
||||
HardReset: "硬重启",
|
||||
Local: "本地结束",
|
||||
Other: "其他原因",
|
||||
PowerLoss: "断电结束",
|
||||
Reboot: "重启结束",
|
||||
Remote: "远程结束",
|
||||
SoftReset: "软重启",
|
||||
UnlockCommand: "解锁结束",
|
||||
};
|
||||
|
||||
const stopReasonColorMap: Record<string, "success" | "warning" | "danger" | "default"> = {
|
||||
Local: "success",
|
||||
EVDisconnected: "success",
|
||||
Remote: "warning",
|
||||
UnlockCommand: "warning",
|
||||
EmergencyStop: "danger",
|
||||
PowerLoss: "danger",
|
||||
HardReset: "danger",
|
||||
SoftReset: "warning",
|
||||
Reboot: "warning",
|
||||
Other: "default",
|
||||
};
|
||||
|
||||
function formatDuration(start: string, stop: string | null): string {
|
||||
if (!stop) return "进行中";
|
||||
const min = dayjs(stop).diff(dayjs(start), "minute");
|
||||
@@ -19,14 +54,28 @@ function formatDuration(start: string, stop: string | null): string {
|
||||
return `${h}h ${m}m`;
|
||||
}
|
||||
|
||||
export default function TransactionsPage() {
|
||||
function TransactionsPageContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const { data: sessionData } = useSession();
|
||||
const isAdmin = sessionData?.user?.role === "admin";
|
||||
const statusFromQuery = searchParams.get("status");
|
||||
const initialStatus: "all" | "active" | "completed" =
|
||||
statusFromQuery === "active" || statusFromQuery === "completed" ? statusFromQuery : "all";
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [status, setStatus] = useState<"all" | "active" | "completed">("all");
|
||||
const [status, setStatus] = useState<"all" | "active" | "completed">(initialStatus);
|
||||
const [stoppingId, setStoppingId] = useState<number | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== initialStatus) {
|
||||
setStatus(initialStatus);
|
||||
setPage(1);
|
||||
}
|
||||
// We intentionally depend on searchParams-derived value only.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialStatus]);
|
||||
|
||||
const {
|
||||
data,
|
||||
isPending: loading,
|
||||
@@ -131,12 +180,30 @@ export default function TransactionsPage() {
|
||||
>
|
||||
{(data?.data ?? []).map((tx) => (
|
||||
<Table.Row key={tx.id} id={tx.id}>
|
||||
<Table.Cell className="font-mono text-sm">{tx.id}</Table.Cell>
|
||||
<Table.Cell className="font-mono">{tx.chargePointIdentifier ?? "—"}</Table.Cell>
|
||||
<Table.Cell className="font-mono text-sm">
|
||||
<Link
|
||||
href={`/dashboard/transactions/${tx.id}`}
|
||||
className="text-accent hover:text-accent/80"
|
||||
>
|
||||
{tx.id}
|
||||
</Link>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div className="flex flex-col">
|
||||
<span>{tx.chargePointDeviceName ?? tx.chargePointIdentifier ?? "—"}</span>
|
||||
{isAdmin && tx.chargePointDeviceName && tx.chargePointIdentifier && (
|
||||
<span className="font-mono text-xs text-muted">{tx.chargePointIdentifier}</span>
|
||||
)}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{tx.connectorNumber ?? "—"}</Table.Cell>
|
||||
<Table.Cell className="font-mono">{tx.idTag}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{tx.stopTimestamp ? (
|
||||
{tx.stopTimestamp && tx.stopReason === "DeAuthorized" ? (
|
||||
<Chip color="danger" size="sm" variant="soft">
|
||||
已拒绝
|
||||
</Chip>
|
||||
) : tx.stopTimestamp ? (
|
||||
<Chip color="success" size="sm" variant="soft">
|
||||
已完成
|
||||
</Chip>
|
||||
@@ -153,19 +220,51 @@ export default function TransactionsPage() {
|
||||
{formatDuration(tx.startTimestamp, tx.stopTimestamp)}
|
||||
</Table.Cell>
|
||||
<Table.Cell className="tabular-nums">
|
||||
{tx.energyWh != null ? (tx.energyWh / 1000).toFixed(3) : "—"}
|
||||
{tx.energyWh != null ? (
|
||||
(tx.energyWh / 1000).toFixed(3)
|
||||
) : tx.liveEnergyWh != null ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{(tx.liveEnergyWh / 1000).toFixed(3)}
|
||||
<span className="rounded bg-warning-soft px-1 py-0.5 text-[10px] font-semibold text-warning leading-none text-nowrap">
|
||||
预估
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell className="tabular-nums">
|
||||
{tx.chargeAmount != null ? `¥${(tx.chargeAmount / 100).toFixed(2)}` : "—"}
|
||||
{tx.chargeAmount != null ? (
|
||||
`¥${(tx.chargeAmount / 100).toFixed(2)}`
|
||||
) : tx.estimatedCost != null ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
¥{(tx.estimatedCost / 100).toFixed(2)}
|
||||
<span className="rounded bg-warning-soft px-1 py-0.5 text-[10px] font-semibold text-warning leading-none text-nowrap">
|
||||
预估
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{tx.stopReason ? (
|
||||
<Chip color="default" size="sm" variant="soft">
|
||||
{tx.stopReason}
|
||||
{tx.stopReason === "DeAuthorized" ? (
|
||||
<p className="text-xs font-medium text-muted text-nowrap">
|
||||
{tx.idTagStatus && tx.idTagStatus !== "Accepted"
|
||||
? `${idTagRejectLabelMap[tx.idTagStatus] ?? tx.idTagStatus}`
|
||||
: "鉴权失败"}
|
||||
</p>
|
||||
) : tx.stopReason ? (
|
||||
<Chip
|
||||
color={stopReasonColorMap[tx.stopReason] ?? "default"}
|
||||
size="sm"
|
||||
variant="soft"
|
||||
>
|
||||
{stopReasonLabelMap[tx.stopReason] ?? tx.stopReason}
|
||||
</Chip>
|
||||
) : tx.stopTimestamp ? (
|
||||
<Chip color="default" size="sm" variant="soft">
|
||||
Local
|
||||
本地结束
|
||||
</Chip>
|
||||
) : (
|
||||
"—"
|
||||
@@ -311,3 +410,17 @@ export default function TransactionsPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TransactionsPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-48 items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TransactionsPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,3 +92,9 @@
|
||||
--warning: oklch(82.03% 0.1407 76.34);
|
||||
--warning-foreground: oklch(21.03% 0.0059 76.34);
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.chip__label {
|
||||
@apply text-nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -3,33 +3,30 @@
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
CreditCard,
|
||||
Gear,
|
||||
ListCheck,
|
||||
Person,
|
||||
PlugConnection,
|
||||
Thunderbolt,
|
||||
ThunderboltFill,
|
||||
Xmark,
|
||||
Bars,
|
||||
} from "@gravity-ui/icons";
|
||||
import { CreditCard, Gear, TagDollar, Thunderbolt, 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 chargeItems = [
|
||||
{ href: "/dashboard/charge", label: "立即充电", icon: ThunderboltFill, adminOnly: false },
|
||||
{ href: "/dashboard/charge", label: "立即充电", icon: Thunderbolt, adminOnly: false },
|
||||
{ href: "/dashboard/pricing", label: "电价标准", icon: TagDollar, adminOnly: false },
|
||||
];
|
||||
|
||||
const navItems = [
|
||||
{ href: "/dashboard", label: "概览", icon: Thunderbolt, exact: true, adminOnly: false },
|
||||
{ href: "/dashboard/charge-points", label: "充电桩", icon: PlugConnection, adminOnly: false },
|
||||
{ href: "/dashboard/transactions", label: "充电记录", icon: ListCheck, adminOnly: false },
|
||||
{ href: "/dashboard", label: "概览", icon: Gauge, exact: true, adminOnly: false },
|
||||
{ href: "/dashboard/charge-points", label: "充电桩", icon: EvCharger, adminOnly: false },
|
||||
{ href: "/dashboard/topology", label: "拓扑图", icon: Network, adminOnly: false },
|
||||
{ href: "/dashboard/id-tags", label: "储值卡", icon: CreditCard, adminOnly: false },
|
||||
{ href: "/dashboard/users", label: "用户管理", icon: Person, adminOnly: true },
|
||||
{ href: "/dashboard/transactions", label: "充电记录", icon: ReceiptText, adminOnly: false },
|
||||
{ href: "/dashboard/settings/pricing", label: "峰谷电价", icon: TagDollar, adminOnly: true },
|
||||
{ href: "/dashboard/users", label: "用户管理", icon: Users, adminOnly: true },
|
||||
];
|
||||
|
||||
const settingsItems = [{ href: "/dashboard/settings", label: "账号设置", icon: Gear }];
|
||||
const settingsItems = [
|
||||
{ href: "/dashboard/settings/user", label: "账号设置", icon: UserCog, adminOnly: false },
|
||||
{ href: "/dashboard/settings/parameters", label: "参数配置", icon: Gear, adminOnly: true },
|
||||
];
|
||||
|
||||
function NavContent({
|
||||
pathname,
|
||||
@@ -110,27 +107,29 @@ function NavContent({
|
||||
<p className="mb-1 mt-3 px-2 text-[11px] font-semibold uppercase tracking-widest text-muted">
|
||||
设置
|
||||
</p>
|
||||
{settingsItems.map((item) => {
|
||||
const isActive = pathname === item.href || pathname.startsWith(item.href + "/");
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={onNavigate}
|
||||
className={[
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-accent/10 text-accent"
|
||||
: "text-muted hover:bg-surface-tertiary hover:text-foreground",
|
||||
].join(" ")}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
<span>{item.label}</span>
|
||||
{isActive && <span className="ml-auto h-1.5 w-1.5 rounded-full bg-accent" />}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{settingsItems
|
||||
.filter((item) => !item.adminOnly || isAdmin)
|
||||
.map((item) => {
|
||||
const isActive = pathname === item.href || pathname.startsWith(item.href + "/");
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={onNavigate}
|
||||
className={[
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-accent/10 text-accent"
|
||||
: "text-muted hover:bg-surface-tertiary hover:text-foreground",
|
||||
].join(" ")}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
<span>{item.label}</span>
|
||||
{isActive && <span className="ml-auto h-1.5 w-1.5 rounded-full bg-accent" />}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
|
||||
@@ -68,15 +68,44 @@ export type ConnectorDetail = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type ConnectionsStatus = {
|
||||
connectedIdentifiers: string[];
|
||||
};
|
||||
|
||||
export type ChargePointConnectionStatus = "online" | "unavailable" | "offline";
|
||||
|
||||
export type MeterSampledValue = {
|
||||
value: string;
|
||||
context?: string;
|
||||
format?: string;
|
||||
measurand?: string;
|
||||
phase?: string;
|
||||
location?: string;
|
||||
unit?: string;
|
||||
};
|
||||
|
||||
export type MeterHistoryPoint = {
|
||||
connectorNumber: number;
|
||||
timestamp: string;
|
||||
sampledValues: MeterSampledValue[];
|
||||
};
|
||||
|
||||
export type ChargePoint = {
|
||||
id: string;
|
||||
chargePointIdentifier: string;
|
||||
deviceName: string | null;
|
||||
chargePointVendor: string | null;
|
||||
chargePointModel: string | null;
|
||||
registrationStatus: string;
|
||||
transportStatus: ChargePointConnectionStatus;
|
||||
lastHeartbeatAt: string | null;
|
||||
lastWsConnectedAt: string | null;
|
||||
lastWsDisconnectedAt: string | null;
|
||||
lastCommandStatus: "Accepted" | "Rejected" | "Error" | "Timeout" | null;
|
||||
lastCommandAt: string | null;
|
||||
lastBootNotificationAt: string | null;
|
||||
feePerKwh: number;
|
||||
pricingMode: "fixed" | "tou";
|
||||
connectors: ConnectorSummary[];
|
||||
chargePointStatus: string | null;
|
||||
chargePointErrorCode: string | null;
|
||||
@@ -85,6 +114,7 @@ export type ChargePoint = {
|
||||
export type ChargePointDetail = {
|
||||
id: string;
|
||||
chargePointIdentifier: string;
|
||||
deviceName: string | null;
|
||||
chargePointVendor: string | null;
|
||||
chargePointModel: string | null;
|
||||
chargePointSerialNumber: string | null;
|
||||
@@ -95,19 +125,29 @@ 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[];
|
||||
chargePointStatus: string | null;
|
||||
chargePointErrorCode: string | null;
|
||||
latestMeterTimestamp: string | null;
|
||||
latestMeterValues: MeterSampledValue[];
|
||||
meterHistory: MeterHistoryPoint[];
|
||||
};
|
||||
|
||||
export type Transaction = {
|
||||
id: number;
|
||||
chargePointIdentifier: string | null;
|
||||
chargePointDeviceName: string | null;
|
||||
connectorNumber: number | null;
|
||||
idTag: string;
|
||||
idTagStatus: string | null;
|
||||
@@ -118,9 +158,16 @@ export type Transaction = {
|
||||
startMeterValue: number | null;
|
||||
stopMeterValue: number | null;
|
||||
energyWh: number | null;
|
||||
liveEnergyWh: number | null;
|
||||
estimatedCost: number | null;
|
||||
stopIdTag: string | null;
|
||||
stopReason: string | null;
|
||||
chargeAmount: number | null;
|
||||
electricityFee: number | null;
|
||||
serviceFee: number | null;
|
||||
remoteStopStatus: "Requested" | "Accepted" | "Rejected" | "Error" | "Timeout" | null;
|
||||
remoteStopRequestedAt: string | null;
|
||||
remoteStopRequestId: string | null;
|
||||
};
|
||||
|
||||
export type IdTag = {
|
||||
@@ -130,6 +177,8 @@ export type IdTag = {
|
||||
parentIdTag: string | null;
|
||||
userId: string | null;
|
||||
balance: number;
|
||||
cardLayout: "center" | "around" | null;
|
||||
cardSkin: "line" | "circles" | "glow" | "vip" | "redeye" | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
@@ -145,6 +194,18 @@ export type UserRow = {
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type ChargePointCreated = ChargePoint & {
|
||||
/** 仅在创建时返回一次的明文密码,之后不可再查 */
|
||||
plainPassword: string;
|
||||
};
|
||||
|
||||
export type ChargePointPasswordReset = {
|
||||
id: string;
|
||||
chargePointIdentifier: string;
|
||||
/** 仅在重置时返回一次的新明文密码 */
|
||||
plainPassword: string;
|
||||
};
|
||||
|
||||
export type PaginatedTransactions = {
|
||||
data: Transaction[];
|
||||
total: number;
|
||||
@@ -152,6 +213,37 @@ export type PaginatedTransactions = {
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
export type PriceTier = "peak" | "valley" | "flat";
|
||||
|
||||
export type TariffSlot = {
|
||||
start: number;
|
||||
end: number;
|
||||
tier: PriceTier;
|
||||
};
|
||||
|
||||
export type TierPricing = {
|
||||
/** 电价(元/kWh) */
|
||||
electricityPrice: number;
|
||||
/** 服务费(元/kWh) */
|
||||
serviceFee: number;
|
||||
};
|
||||
|
||||
export type TariffConfig = {
|
||||
id?: string;
|
||||
slots: TariffSlot[];
|
||||
prices: Record<PriceTier, TierPricing>;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type Ocpp16jSettings = {
|
||||
heartbeatInterval: number;
|
||||
};
|
||||
|
||||
export type SystemSettings = {
|
||||
ocpp16j: Ocpp16jSettings;
|
||||
};
|
||||
|
||||
// ── API functions ──────────────────────────────────────────────────────────
|
||||
|
||||
export type ChartRange = "30d" | "7d" | "24h";
|
||||
@@ -173,14 +265,17 @@ export const api = {
|
||||
chargePoints: {
|
||||
list: () => apiFetch<ChargePoint[]>("/api/charge-points"),
|
||||
get: (id: string) => apiFetch<ChargePointDetail>(`/api/charge-points/${id}`),
|
||||
connections: () => apiFetch<ConnectionsStatus>("/api/charge-points/connections"),
|
||||
create: (data: {
|
||||
chargePointIdentifier: string;
|
||||
chargePointVendor?: string;
|
||||
chargePointModel?: string;
|
||||
registrationStatus?: "Accepted" | "Pending" | "Rejected";
|
||||
feePerKwh?: number;
|
||||
pricingMode?: "fixed" | "tou";
|
||||
deviceName?: string;
|
||||
}) =>
|
||||
apiFetch<ChargePoint>("/api/charge-points", {
|
||||
apiFetch<ChargePointCreated>("/api/charge-points", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
@@ -188,9 +283,11 @@ export const api = {
|
||||
id: string,
|
||||
data: {
|
||||
feePerKwh?: number;
|
||||
pricingMode?: "fixed" | "tou";
|
||||
registrationStatus?: "Accepted" | "Pending" | "Rejected";
|
||||
chargePointVendor?: string;
|
||||
chargePointModel?: string;
|
||||
deviceName?: string | null;
|
||||
},
|
||||
) =>
|
||||
apiFetch<ChargePoint>(`/api/charge-points/${id}`, {
|
||||
@@ -199,6 +296,10 @@ export const api = {
|
||||
}),
|
||||
delete: (id: string) =>
|
||||
apiFetch<{ success: true }>(`/api/charge-points/${id}`, { method: "DELETE" }),
|
||||
resetPassword: (id: string) =>
|
||||
apiFetch<ChargePointPasswordReset>(`/api/charge-points/${id}/reset-password`, {
|
||||
method: "POST",
|
||||
}),
|
||||
},
|
||||
transactions: {
|
||||
list: (params?: {
|
||||
@@ -239,6 +340,8 @@ export const api = {
|
||||
parentIdTag?: string;
|
||||
userId?: string | null;
|
||||
balance?: number;
|
||||
cardLayout?: "center" | "around";
|
||||
cardSkin?: "line" | "circles" | "glow" | "vip" | "redeye";
|
||||
}) => apiFetch<IdTag>("/api/id-tags", { method: "POST", body: JSON.stringify(data) }),
|
||||
update: (
|
||||
idTag: string,
|
||||
@@ -248,6 +351,8 @@ export const api = {
|
||||
parentIdTag?: string | null;
|
||||
userId?: string | null;
|
||||
balance?: number;
|
||||
cardLayout?: "center" | "around" | null;
|
||||
cardSkin?: "line" | "circles" | "glow" | "vip" | "redeye" | null;
|
||||
},
|
||||
) => apiFetch<IdTag>(`/api/id-tags/${idTag}`, { method: "PATCH", body: JSON.stringify(data) }),
|
||||
delete: (idTag: string) =>
|
||||
@@ -281,4 +386,14 @@ export const api = {
|
||||
create: (data: { name: string; email: string; username: string; password: string }) =>
|
||||
apiFetch<{ success: boolean }>("/api/setup", { method: "POST", body: JSON.stringify(data) }),
|
||||
},
|
||||
tariff: {
|
||||
get: () => apiFetch<TariffConfig | null>("/api/tariff"),
|
||||
put: (data: { slots: TariffSlot[]; prices: Record<PriceTier, TierPricing> }) =>
|
||||
apiFetch<TariffConfig>("/api/tariff", { method: "PUT", body: JSON.stringify(data) }),
|
||||
},
|
||||
settings: {
|
||||
get: () => apiFetch<SystemSettings>("/api/settings"),
|
||||
put: (data: SystemSettings) =>
|
||||
apiFetch<SystemSettings>("/api/settings", { method: "PUT", body: JSON.stringify(data) }),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -15,9 +15,11 @@
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@tremor/react": "4.0.0-beta-tremor-v4.4",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@xyflow/react": "^12.10.1",
|
||||
"better-auth": "catalog:",
|
||||
"dayjs": "catalog:",
|
||||
"jsqr": "^1.4.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"next": "16.1.6",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "19.2.3",
|
||||
|
||||
@@ -3,32 +3,20 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
const CSMS_INTERNAL_URL =
|
||||
process.env.CSMS_INTERNAL_URL ?? process.env.NEXT_PUBLIC_CSMS_URL ?? "http://localhost:3001";
|
||||
|
||||
/** 检查 CSMS 是否已完成初始化(有用户存在)。使用 cookie 缓存结果,避免每次请求都查询。 */
|
||||
async function isInitialized(
|
||||
request: NextRequest,
|
||||
useCache = true,
|
||||
): Promise<{ initialized: boolean; fromCache: boolean }> {
|
||||
// 读缓存 cookie(仅在 useCache=true 时使用,避免 DB 重置后缓存陈旧)
|
||||
if (useCache) {
|
||||
const cached = request.cookies.get("helios_setup_done");
|
||||
if (cached?.value === "1") {
|
||||
return { initialized: true, fromCache: true };
|
||||
}
|
||||
}
|
||||
|
||||
/** 检查 CSMS 是否已完成初始化(有用户存在)。 */
|
||||
async function isInitialized(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${CSMS_INTERNAL_URL}/api/setup`, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
// 短超时,避免阻塞
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
if (!res.ok) return { initialized: true, fromCache: false }; // 出错时放行,不阻止访问
|
||||
if (!res.ok) return true; // 出错时放行,不阻止访问
|
||||
const data = (await res.json()) as { initialized: boolean };
|
||||
return { initialized: data.initialized, fromCache: false };
|
||||
return data.initialized;
|
||||
} catch {
|
||||
// 无法连接 CSMS 时放行,不强制跳转
|
||||
return { initialized: true, fromCache: false };
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,27 +25,16 @@ export async function proxy(request: NextRequest) {
|
||||
|
||||
// /setup 页面:已初始化则跳转登录
|
||||
if (pathname === "/setup") {
|
||||
const { initialized, fromCache } = await isInitialized(request);
|
||||
if (initialized) {
|
||||
if (await isInitialized()) {
|
||||
return NextResponse.redirect(new URL("/login", request.url));
|
||||
}
|
||||
const res = NextResponse.next();
|
||||
if (!fromCache) {
|
||||
// 未初始化,确保缓存 cookie 不存在(若之前意外设置了)
|
||||
res.cookies.delete("helios_setup_done");
|
||||
}
|
||||
return res;
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// /dashboard 路由:检查 session,未登录跳转 /login
|
||||
if (pathname.startsWith("/dashboard")) {
|
||||
const { initialized, fromCache } = await isInitialized(request);
|
||||
|
||||
// 未初始化,先去 setup
|
||||
if (!initialized) {
|
||||
const res = NextResponse.redirect(new URL("/setup", request.url));
|
||||
if (!fromCache) res.cookies.delete("helios_setup_done");
|
||||
return res;
|
||||
if (!(await isInitialized())) {
|
||||
return NextResponse.redirect(new URL("/setup", request.url));
|
||||
}
|
||||
|
||||
const sessionCookie =
|
||||
@@ -68,30 +45,18 @@ export async function proxy(request: NextRequest) {
|
||||
const loginUrl = new URL("/login", request.url);
|
||||
const fromPath = request.nextUrl.search ? pathname + request.nextUrl.search : pathname;
|
||||
loginUrl.searchParams.set("from", fromPath);
|
||||
const res = NextResponse.redirect(loginUrl);
|
||||
if (!fromCache)
|
||||
res.cookies.set("helios_setup_done", "1", { path: "/", httpOnly: true, sameSite: "lax" });
|
||||
return res;
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
const res = NextResponse.next();
|
||||
if (!fromCache)
|
||||
res.cookies.set("helios_setup_done", "1", { path: "/", httpOnly: true, sameSite: "lax" });
|
||||
return res;
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// /login 路由:未初始化则跳转 /setup(不使用缓存,防止 DB 重置后缓存陈旧)
|
||||
// /login 路由:未初始化则跳转 /setup
|
||||
if (pathname === "/login") {
|
||||
const { initialized, fromCache } = await isInitialized(request, false);
|
||||
if (!initialized) {
|
||||
const res = NextResponse.redirect(new URL("/setup", request.url));
|
||||
if (!fromCache) res.cookies.delete("helios_setup_done");
|
||||
return res;
|
||||
if (!(await isInitialized())) {
|
||||
return NextResponse.redirect(new URL("/setup", request.url));
|
||||
}
|
||||
const res = NextResponse.next();
|
||||
if (!fromCache)
|
||||
res.cookies.set("helios_setup_done", "1", { path: "/", httpOnly: true, sameSite: "lax" });
|
||||
return res;
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
|
||||
36
hardware/firmware/include/pins.h
Normal file
36
hardware/firmware/include/pins.h
Normal file
@@ -0,0 +1,36 @@
|
||||
#ifndef HELIOS_PINS_H
|
||||
#define HELIOS_PINS_H
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
// Panel switches and LEDs
|
||||
static const uint8_t PIN_CC1 = 39; // High-active switch
|
||||
static const uint8_t PIN_CC2 = 36; // High-active switch
|
||||
static const uint8_t PIN_LED1 = 32; // Low-active LED
|
||||
static const uint8_t PIN_LED2 = 33; // Low-active LED
|
||||
|
||||
// Key inputs
|
||||
static const uint8_t PIN_KEY1 = 34;
|
||||
static const uint8_t PIN_KEY2 = 35;
|
||||
|
||||
// Relay outputs
|
||||
static const uint8_t PIN_RELAY1 = 27;
|
||||
static const uint8_t PIN_RELAY2 = 14;
|
||||
|
||||
// I2C (OLED)
|
||||
static const uint8_t PIN_OLED_SCL = 22;
|
||||
static const uint8_t PIN_OLED_SDA = 21;
|
||||
|
||||
// SPI (RC522)
|
||||
static const uint8_t PIN_RC_MOSI = 23;
|
||||
static const uint8_t PIN_RC_MISO = 19;
|
||||
static const uint8_t PIN_RC_SCK = 18;
|
||||
static const uint8_t PIN_RC_CS = 5;
|
||||
static const uint8_t PIN_RC_RST = 4;
|
||||
|
||||
// UART2 <-> IM1281C (U2)
|
||||
static const uint8_t PIN_U2TXD = 26;
|
||||
static const uint8_t PIN_U2RXD = 25;
|
||||
static const uint32_t BAUD_IM1281C = 9600;
|
||||
|
||||
#endif // HELIOS_PINS_H
|
||||
125
hardware/firmware/lib/IM1281C/src/IM1281C.cpp
Normal file
125
hardware/firmware/lib/IM1281C/src/IM1281C.cpp
Normal file
@@ -0,0 +1,125 @@
|
||||
#include "IM1281C.h"
|
||||
|
||||
IM1281C::IM1281C()
|
||||
: _serial(nullptr),
|
||||
_slaveAddress(1),
|
||||
_lastAResult(0xFF),
|
||||
_lastBResult(0xFF)
|
||||
{
|
||||
}
|
||||
|
||||
bool IM1281C::begin(HardwareSerial &serial,
|
||||
int8_t rxPin,
|
||||
int8_t txPin,
|
||||
uint8_t slaveAddress,
|
||||
uint32_t baudRate,
|
||||
uint32_t serialConfig)
|
||||
{
|
||||
_serial = &serial;
|
||||
_slaveAddress = slaveAddress;
|
||||
_a = IM1281CAData{};
|
||||
_b = IM1281CBData{};
|
||||
_lastAResult = 0xFF;
|
||||
_lastBResult = 0xFF;
|
||||
|
||||
_serial->begin(baudRate, serialConfig, rxPin, txPin);
|
||||
_node.begin(_slaveAddress, *_serial);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool IM1281C::readA()
|
||||
{
|
||||
return readAll();
|
||||
}
|
||||
|
||||
bool IM1281C::readB()
|
||||
{
|
||||
return readAll();
|
||||
}
|
||||
|
||||
bool IM1281C::readAll()
|
||||
{
|
||||
if (_serial == nullptr)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// IM1281C datasheet addresses are effectively 32-bit data items.
|
||||
// Requesting 0x0010 from 0x0048 yields 64 data bytes (0x40), which covers A/B block.
|
||||
const uint8_t result = _node.readHoldingRegisters(0x0048, 16);
|
||||
_lastAResult = result;
|
||||
_lastBResult = result;
|
||||
|
||||
if (result != _node.ku8MBSuccess)
|
||||
{
|
||||
_a.valid = false;
|
||||
_b.valid = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
const uint32_t aVoltageRaw = combineWords(_node.getResponseBuffer(0), _node.getResponseBuffer(1));
|
||||
const uint32_t aCurrentRaw = combineWords(_node.getResponseBuffer(2), _node.getResponseBuffer(3));
|
||||
const uint32_t aPowerRaw = combineWords(_node.getResponseBuffer(4), _node.getResponseBuffer(5));
|
||||
const uint32_t aEnergyRaw = combineWords(_node.getResponseBuffer(6), _node.getResponseBuffer(7));
|
||||
const uint32_t aPfRaw = combineWords(_node.getResponseBuffer(8), _node.getResponseBuffer(9));
|
||||
const uint32_t aCo2Raw = combineWords(_node.getResponseBuffer(10), _node.getResponseBuffer(11));
|
||||
const uint32_t aTempRaw = combineWords(_node.getResponseBuffer(12), _node.getResponseBuffer(13));
|
||||
const uint32_t aFreqRaw = combineWords(_node.getResponseBuffer(14), _node.getResponseBuffer(15));
|
||||
|
||||
_a.voltage = scaleValue(aVoltageRaw, 0.0001f);
|
||||
_a.current = scaleValue(aCurrentRaw, 0.0001f);
|
||||
_a.power = scaleValue(aPowerRaw, 0.0001f);
|
||||
_a.energy = scaleValue(aEnergyRaw, 0.0001f);
|
||||
_a.powerFactor = scaleValue(aPfRaw, 0.001f);
|
||||
_a.co2 = scaleValue(aCo2Raw, 0.0001f);
|
||||
_a.temperature = scaleValue(aTempRaw, 0.01f);
|
||||
_a.frequency = scaleValue(aFreqRaw, 0.01f);
|
||||
_a.valid = true;
|
||||
|
||||
const uint32_t bVoltageRaw = combineWords(_node.getResponseBuffer(16), _node.getResponseBuffer(17));
|
||||
const uint32_t bCurrentRaw = combineWords(_node.getResponseBuffer(18), _node.getResponseBuffer(19));
|
||||
const uint32_t bPowerRaw = combineWords(_node.getResponseBuffer(20), _node.getResponseBuffer(21));
|
||||
const uint32_t bEnergyRaw = combineWords(_node.getResponseBuffer(22), _node.getResponseBuffer(23));
|
||||
const uint32_t bPfRaw = combineWords(_node.getResponseBuffer(24), _node.getResponseBuffer(25));
|
||||
const uint32_t bCo2Raw = combineWords(_node.getResponseBuffer(26), _node.getResponseBuffer(27));
|
||||
|
||||
_b.voltage = scaleValue(bVoltageRaw, 0.0001f);
|
||||
_b.current = scaleValue(bCurrentRaw, 0.0001f);
|
||||
_b.power = scaleValue(bPowerRaw, 0.0001f);
|
||||
_b.energy = scaleValue(bEnergyRaw, 0.0001f);
|
||||
_b.powerFactor = scaleValue(bPfRaw, 0.001f);
|
||||
_b.co2 = scaleValue(bCo2Raw, 0.0001f);
|
||||
_b.valid = true;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const IM1281CAData &IM1281C::a() const
|
||||
{
|
||||
return _a;
|
||||
}
|
||||
|
||||
const IM1281CBData &IM1281C::b() const
|
||||
{
|
||||
return _b;
|
||||
}
|
||||
|
||||
uint8_t IM1281C::lastAResult() const
|
||||
{
|
||||
return _lastAResult;
|
||||
}
|
||||
|
||||
uint8_t IM1281C::lastBResult() const
|
||||
{
|
||||
return _lastBResult;
|
||||
}
|
||||
|
||||
uint32_t IM1281C::combineWords(uint16_t highWord, uint16_t lowWord)
|
||||
{
|
||||
return (static_cast<uint32_t>(highWord) << 16) | static_cast<uint32_t>(lowWord);
|
||||
}
|
||||
|
||||
float IM1281C::scaleValue(uint32_t raw, float scale)
|
||||
{
|
||||
return static_cast<float>(raw) * scale;
|
||||
}
|
||||
65
hardware/firmware/lib/IM1281C/src/IM1281C.h
Normal file
65
hardware/firmware/lib/IM1281C/src/IM1281C.h
Normal file
@@ -0,0 +1,65 @@
|
||||
#ifndef HELIOS_IM1281C_H
|
||||
#define HELIOS_IM1281C_H
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <ModbusMaster.h>
|
||||
|
||||
struct IM1281CAData
|
||||
{
|
||||
float voltage = 0.0f;
|
||||
float current = 0.0f;
|
||||
float power = 0.0f;
|
||||
float energy = 0.0f;
|
||||
float powerFactor = 0.0f;
|
||||
float co2 = 0.0f;
|
||||
float temperature = 0.0f;
|
||||
float frequency = 0.0f;
|
||||
bool valid = false;
|
||||
};
|
||||
|
||||
struct IM1281CBData
|
||||
{
|
||||
float voltage = 0.0f;
|
||||
float current = 0.0f;
|
||||
float power = 0.0f;
|
||||
float energy = 0.0f;
|
||||
float powerFactor = 0.0f;
|
||||
float co2 = 0.0f;
|
||||
bool valid = false;
|
||||
};
|
||||
|
||||
class IM1281C
|
||||
{
|
||||
public:
|
||||
IM1281C();
|
||||
|
||||
bool begin(HardwareSerial &serial,
|
||||
int8_t rxPin,
|
||||
int8_t txPin,
|
||||
uint8_t slaveAddress = 1,
|
||||
uint32_t baudRate = 4800,
|
||||
uint32_t serialConfig = SERIAL_8N1);
|
||||
|
||||
bool readA();
|
||||
bool readB();
|
||||
bool readAll();
|
||||
|
||||
const IM1281CAData &a() const;
|
||||
const IM1281CBData &b() const;
|
||||
uint8_t lastAResult() const;
|
||||
uint8_t lastBResult() const;
|
||||
|
||||
private:
|
||||
static uint32_t combineWords(uint16_t highWord, uint16_t lowWord);
|
||||
static float scaleValue(uint32_t raw, float scale);
|
||||
|
||||
ModbusMaster _node;
|
||||
HardwareSerial *_serial;
|
||||
uint8_t _slaveAddress;
|
||||
uint8_t _lastAResult;
|
||||
uint8_t _lastBResult;
|
||||
IM1281CAData _a;
|
||||
IM1281CBData _b;
|
||||
};
|
||||
|
||||
#endif // HELIOS_IM1281C_H
|
||||
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,9 +14,10 @@ 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
|
||||
adafruit/Adafruit SSD1306@^2.5.16
|
||||
4-20ma/ModbusMaster@^2.0.1
|
||||
build_flags = -DMO_PLATFORM=MO_PLATFORM_ARDUINO -DMO_MG_USE_VERSION=MO_MG_V715 -DMO_NUMCONNECTORS=3
|
||||
board_build.partitions = partitions.csv
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
2
hardware/pcb/.gitignore
vendored
Normal file
2
hardware/pcb/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*-backups
|
||||
|
||||
1
hardware/pcb/.history
Submodule
1
hardware/pcb/.history
Submodule
Submodule hardware/pcb/.history added at a87aae3571
33967
hardware/pcb/HeliosDAONE.kicad_pcb
Normal file
33967
hardware/pcb/HeliosDAONE.kicad_pcb
Normal file
File diff suppressed because it is too large
Load Diff
136
hardware/pcb/HeliosDAONE.kicad_prl
Normal file
136
hardware/pcb/HeliosDAONE.kicad_prl
Normal file
@@ -0,0 +1,136 @@
|
||||
{
|
||||
"board": {
|
||||
"active_layer": 5,
|
||||
"active_layer_preset": "",
|
||||
"auto_track_width": true,
|
||||
"hidden_netclasses": [],
|
||||
"hidden_nets": [],
|
||||
"high_contrast_mode": 0,
|
||||
"net_color_mode": 1,
|
||||
"opacity": {
|
||||
"images": 0.6,
|
||||
"pads": 1.0,
|
||||
"shapes": 1.0,
|
||||
"tracks": 1.0,
|
||||
"vias": 1.0,
|
||||
"zones": 0.6
|
||||
},
|
||||
"prototype_zone_fills": false,
|
||||
"selection_filter": {
|
||||
"dimensions": true,
|
||||
"footprints": true,
|
||||
"graphics": true,
|
||||
"keepouts": true,
|
||||
"lockedItems": false,
|
||||
"otherItems": true,
|
||||
"pads": true,
|
||||
"text": true,
|
||||
"tracks": true,
|
||||
"vias": true,
|
||||
"zones": true
|
||||
},
|
||||
"visible_items": [
|
||||
"vias",
|
||||
"footprint_text",
|
||||
"footprint_anchors",
|
||||
"ratsnest",
|
||||
"grid",
|
||||
"footprints_front",
|
||||
"footprints_back",
|
||||
"footprint_values",
|
||||
"footprint_references",
|
||||
"tracks",
|
||||
"drc_errors",
|
||||
"drawing_sheet",
|
||||
"bitmaps",
|
||||
"pads",
|
||||
"zones",
|
||||
"drc_warnings",
|
||||
"drc_exclusions",
|
||||
"locked_item_shadows",
|
||||
"conflict_shadows",
|
||||
"shapes"
|
||||
],
|
||||
"visible_layers": "ffffffff_ffffffff_ffffffff_ffffffff",
|
||||
"zone_display_mode": 0
|
||||
},
|
||||
"git": {
|
||||
"integration_disabled": false,
|
||||
"repo_type": "",
|
||||
"repo_username": "",
|
||||
"ssh_key": ""
|
||||
},
|
||||
"meta": {
|
||||
"filename": "HeliosDAONE.kicad_prl",
|
||||
"version": 5
|
||||
},
|
||||
"net_inspector_panel": {
|
||||
"col_hidden": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
],
|
||||
"col_order": [
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9
|
||||
],
|
||||
"col_widths": [
|
||||
174,
|
||||
159,
|
||||
100,
|
||||
63,
|
||||
100,
|
||||
100,
|
||||
100,
|
||||
63,
|
||||
100,
|
||||
100
|
||||
],
|
||||
"custom_group_rules": [],
|
||||
"expanded_rows": [],
|
||||
"filter_by_net_name": true,
|
||||
"filter_by_netclass": true,
|
||||
"filter_text": "",
|
||||
"group_by_constraint": false,
|
||||
"group_by_netclass": false,
|
||||
"show_time_domain_details": false,
|
||||
"show_unconnected_nets": false,
|
||||
"show_zero_pad_nets": false,
|
||||
"sort_ascending": true,
|
||||
"sorting_column": 0
|
||||
},
|
||||
"open_jobsets": [],
|
||||
"project": {
|
||||
"files": []
|
||||
},
|
||||
"schematic": {
|
||||
"hierarchy_collapsed": [],
|
||||
"selection_filter": {
|
||||
"graphics": true,
|
||||
"images": true,
|
||||
"labels": true,
|
||||
"lockedItems": false,
|
||||
"otherItems": true,
|
||||
"pins": true,
|
||||
"ruleAreas": true,
|
||||
"symbols": true,
|
||||
"text": true,
|
||||
"wires": true
|
||||
}
|
||||
}
|
||||
}
|
||||
739
hardware/pcb/HeliosDAONE.kicad_pro
Normal file
739
hardware/pcb/HeliosDAONE.kicad_pro
Normal file
@@ -0,0 +1,739 @@
|
||||
{
|
||||
"board": {
|
||||
"3dviewports": [],
|
||||
"design_settings": {
|
||||
"defaults": {
|
||||
"apply_defaults_to_fp_barcodes": false,
|
||||
"apply_defaults_to_fp_dimensions": false,
|
||||
"apply_defaults_to_fp_fields": false,
|
||||
"apply_defaults_to_fp_shapes": false,
|
||||
"apply_defaults_to_fp_text": false,
|
||||
"board_outline_line_width": 0.05,
|
||||
"copper_line_width": 0.2,
|
||||
"copper_text_italic": false,
|
||||
"copper_text_size_h": 1.5,
|
||||
"copper_text_size_v": 1.5,
|
||||
"copper_text_thickness": 0.3,
|
||||
"copper_text_upright": false,
|
||||
"courtyard_line_width": 0.05,
|
||||
"dimension_precision": 4,
|
||||
"dimension_units": 3,
|
||||
"dimensions": {
|
||||
"arrow_length": 1270000,
|
||||
"extension_offset": 500000,
|
||||
"keep_text_aligned": true,
|
||||
"suppress_zeroes": true,
|
||||
"text_position": 0,
|
||||
"units_format": 0
|
||||
},
|
||||
"fab_line_width": 0.1,
|
||||
"fab_text_italic": false,
|
||||
"fab_text_size_h": 1.0,
|
||||
"fab_text_size_v": 1.0,
|
||||
"fab_text_thickness": 0.15,
|
||||
"fab_text_upright": false,
|
||||
"other_line_width": 0.1,
|
||||
"other_text_italic": false,
|
||||
"other_text_size_h": 1.0,
|
||||
"other_text_size_v": 1.0,
|
||||
"other_text_thickness": 0.15,
|
||||
"other_text_upright": false,
|
||||
"pads": {
|
||||
"drill": 1.0,
|
||||
"height": 1.7,
|
||||
"width": 1.7
|
||||
},
|
||||
"silk_line_width": 0.1,
|
||||
"silk_text_italic": false,
|
||||
"silk_text_size_h": 1.0,
|
||||
"silk_text_size_v": 1.0,
|
||||
"silk_text_thickness": 0.1,
|
||||
"silk_text_upright": false,
|
||||
"zones": {
|
||||
"min_clearance": 0.5
|
||||
}
|
||||
},
|
||||
"diff_pair_dimensions": [
|
||||
{
|
||||
"gap": 0.0,
|
||||
"via_gap": 0.0,
|
||||
"width": 0.0
|
||||
}
|
||||
],
|
||||
"drc_exclusions": [],
|
||||
"meta": {
|
||||
"version": 2
|
||||
},
|
||||
"rule_severities": {
|
||||
"annular_width": "error",
|
||||
"clearance": "error",
|
||||
"connection_width": "warning",
|
||||
"copper_edge_clearance": "error",
|
||||
"copper_sliver": "warning",
|
||||
"courtyards_overlap": "error",
|
||||
"creepage": "error",
|
||||
"diff_pair_gap_out_of_range": "error",
|
||||
"diff_pair_uncoupled_length_too_long": "error",
|
||||
"drill_out_of_range": "error",
|
||||
"duplicate_footprints": "warning",
|
||||
"extra_footprint": "warning",
|
||||
"footprint": "error",
|
||||
"footprint_filters_mismatch": "ignore",
|
||||
"footprint_symbol_field_mismatch": "warning",
|
||||
"footprint_symbol_mismatch": "warning",
|
||||
"footprint_type_mismatch": "ignore",
|
||||
"hole_clearance": "error",
|
||||
"hole_to_hole": "warning",
|
||||
"holes_co_located": "warning",
|
||||
"invalid_outline": "error",
|
||||
"isolated_copper": "warning",
|
||||
"item_on_disabled_layer": "error",
|
||||
"items_not_allowed": "error",
|
||||
"length_out_of_range": "error",
|
||||
"lib_footprint_issues": "warning",
|
||||
"lib_footprint_mismatch": "warning",
|
||||
"malformed_courtyard": "error",
|
||||
"microvia_drill_out_of_range": "error",
|
||||
"mirrored_text_on_front_layer": "warning",
|
||||
"missing_courtyard": "ignore",
|
||||
"missing_footprint": "warning",
|
||||
"missing_tuning_profile": "warning",
|
||||
"net_conflict": "warning",
|
||||
"nonmirrored_text_on_back_layer": "warning",
|
||||
"npth_inside_courtyard": "ignore",
|
||||
"padstack": "warning",
|
||||
"pth_inside_courtyard": "ignore",
|
||||
"shorting_items": "error",
|
||||
"silk_edge_clearance": "warning",
|
||||
"silk_over_copper": "warning",
|
||||
"silk_overlap": "warning",
|
||||
"skew_out_of_range": "error",
|
||||
"solder_mask_bridge": "error",
|
||||
"starved_thermal": "error",
|
||||
"text_height": "warning",
|
||||
"text_on_edge_cuts": "error",
|
||||
"text_thickness": "warning",
|
||||
"through_hole_pad_without_hole": "error",
|
||||
"too_many_vias": "error",
|
||||
"track_angle": "error",
|
||||
"track_dangling": "warning",
|
||||
"track_not_centered_on_via": "ignore",
|
||||
"track_on_post_machined_layer": "error",
|
||||
"track_segment_length": "error",
|
||||
"track_width": "error",
|
||||
"tracks_crossing": "error",
|
||||
"tuning_profile_track_geometries": "ignore",
|
||||
"unconnected_items": "error",
|
||||
"unresolved_variable": "error",
|
||||
"via_dangling": "warning",
|
||||
"zones_intersect": "error"
|
||||
},
|
||||
"rules": {
|
||||
"max_error": 0.005,
|
||||
"min_clearance": 0.0,
|
||||
"min_connection": 0.0,
|
||||
"min_copper_edge_clearance": 0.5,
|
||||
"min_groove_width": 0.0,
|
||||
"min_hole_clearance": 0.25,
|
||||
"min_hole_to_hole": 0.25,
|
||||
"min_microvia_diameter": 0.2,
|
||||
"min_microvia_drill": 0.1,
|
||||
"min_resolved_spokes": 2,
|
||||
"min_silk_clearance": 0.0,
|
||||
"min_text_height": 0.8,
|
||||
"min_text_thickness": 0.08,
|
||||
"min_through_hole_diameter": 0.3,
|
||||
"min_track_width": 0.0,
|
||||
"min_via_annular_width": 0.1,
|
||||
"min_via_diameter": 0.5,
|
||||
"solder_mask_to_copper_clearance": 0.0,
|
||||
"use_height_for_length_calcs": true
|
||||
},
|
||||
"teardrop_options": [
|
||||
{
|
||||
"td_onpthpad": true,
|
||||
"td_onroundshapesonly": false,
|
||||
"td_onsmdpad": true,
|
||||
"td_ontrackend": false,
|
||||
"td_onvia": true
|
||||
}
|
||||
],
|
||||
"teardrop_parameters": [
|
||||
{
|
||||
"td_allow_use_two_tracks": true,
|
||||
"td_curve_segcount": 0,
|
||||
"td_height_ratio": 1.0,
|
||||
"td_length_ratio": 0.5,
|
||||
"td_maxheight": 2.0,
|
||||
"td_maxlen": 1.0,
|
||||
"td_on_pad_in_zone": false,
|
||||
"td_target_name": "td_round_shape",
|
||||
"td_width_to_size_filter_ratio": 0.9
|
||||
},
|
||||
{
|
||||
"td_allow_use_two_tracks": true,
|
||||
"td_curve_segcount": 0,
|
||||
"td_height_ratio": 1.0,
|
||||
"td_length_ratio": 0.5,
|
||||
"td_maxheight": 2.0,
|
||||
"td_maxlen": 1.0,
|
||||
"td_on_pad_in_zone": false,
|
||||
"td_target_name": "td_rect_shape",
|
||||
"td_width_to_size_filter_ratio": 0.9
|
||||
},
|
||||
{
|
||||
"td_allow_use_two_tracks": true,
|
||||
"td_curve_segcount": 0,
|
||||
"td_height_ratio": 1.0,
|
||||
"td_length_ratio": 0.5,
|
||||
"td_maxheight": 2.0,
|
||||
"td_maxlen": 1.0,
|
||||
"td_on_pad_in_zone": false,
|
||||
"td_target_name": "td_track_end",
|
||||
"td_width_to_size_filter_ratio": 0.9
|
||||
}
|
||||
],
|
||||
"track_widths": [
|
||||
0.0,
|
||||
0.2,
|
||||
0.254,
|
||||
0.5,
|
||||
1.0,
|
||||
2.0,
|
||||
2.54,
|
||||
12.0
|
||||
],
|
||||
"tuning_pattern_settings": {
|
||||
"diff_pair_defaults": {
|
||||
"corner_radius_percentage": 80,
|
||||
"corner_style": 1,
|
||||
"max_amplitude": 1.0,
|
||||
"min_amplitude": 0.2,
|
||||
"single_sided": false,
|
||||
"spacing": 1.0
|
||||
},
|
||||
"diff_pair_skew_defaults": {
|
||||
"corner_radius_percentage": 80,
|
||||
"corner_style": 1,
|
||||
"max_amplitude": 1.0,
|
||||
"min_amplitude": 0.2,
|
||||
"single_sided": false,
|
||||
"spacing": 0.6
|
||||
},
|
||||
"single_track_defaults": {
|
||||
"corner_radius_percentage": 80,
|
||||
"corner_style": 1,
|
||||
"max_amplitude": 1.0,
|
||||
"min_amplitude": 0.2,
|
||||
"single_sided": false,
|
||||
"spacing": 0.6
|
||||
}
|
||||
},
|
||||
"via_dimensions": [
|
||||
{
|
||||
"diameter": 0.0,
|
||||
"drill": 0.0
|
||||
}
|
||||
],
|
||||
"zones_allow_external_fillets": false
|
||||
},
|
||||
"ipc2581": {
|
||||
"bom_rev": "",
|
||||
"dist": "",
|
||||
"distpn": "",
|
||||
"internal_id": "",
|
||||
"mfg": "",
|
||||
"mpn": "",
|
||||
"sch_revision": "1.0.0"
|
||||
},
|
||||
"layer_pairs": [],
|
||||
"layer_presets": [],
|
||||
"viewports": []
|
||||
},
|
||||
"boards": [],
|
||||
"component_class_settings": {
|
||||
"assignments": [],
|
||||
"meta": {
|
||||
"version": 0
|
||||
},
|
||||
"sheet_component_classes": {
|
||||
"enabled": false
|
||||
}
|
||||
},
|
||||
"cvpcb": {
|
||||
"equivalence_files": []
|
||||
},
|
||||
"erc": {
|
||||
"erc_exclusions": [],
|
||||
"meta": {
|
||||
"version": 0
|
||||
},
|
||||
"pin_map": [
|
||||
[
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2
|
||||
],
|
||||
[
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2
|
||||
],
|
||||
[
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
2
|
||||
],
|
||||
[
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
2
|
||||
],
|
||||
[
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2
|
||||
],
|
||||
[
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2
|
||||
],
|
||||
[
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
2
|
||||
],
|
||||
[
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2
|
||||
],
|
||||
[
|
||||
0,
|
||||
2,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2
|
||||
],
|
||||
[
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
2
|
||||
],
|
||||
[
|
||||
0,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
2
|
||||
],
|
||||
[
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2
|
||||
]
|
||||
],
|
||||
"rule_severities": {
|
||||
"bus_definition_conflict": "error",
|
||||
"bus_entry_needed": "error",
|
||||
"bus_to_bus_conflict": "error",
|
||||
"bus_to_net_conflict": "error",
|
||||
"different_unit_footprint": "error",
|
||||
"different_unit_net": "error",
|
||||
"duplicate_reference": "error",
|
||||
"duplicate_sheet_names": "error",
|
||||
"endpoint_off_grid": "warning",
|
||||
"extra_units": "error",
|
||||
"field_name_whitespace": "warning",
|
||||
"footprint_filter": "ignore",
|
||||
"footprint_link_issues": "warning",
|
||||
"four_way_junction": "ignore",
|
||||
"global_label_dangling": "warning",
|
||||
"ground_pin_not_ground": "warning",
|
||||
"hier_label_mismatch": "error",
|
||||
"isolated_pin_label": "warning",
|
||||
"label_dangling": "error",
|
||||
"label_multiple_wires": "warning",
|
||||
"lib_symbol_issues": "warning",
|
||||
"lib_symbol_mismatch": "warning",
|
||||
"missing_bidi_pin": "warning",
|
||||
"missing_input_pin": "warning",
|
||||
"missing_power_pin": "error",
|
||||
"missing_unit": "warning",
|
||||
"multiple_net_names": "warning",
|
||||
"net_not_bus_member": "warning",
|
||||
"no_connect_connected": "warning",
|
||||
"no_connect_dangling": "warning",
|
||||
"pin_not_connected": "error",
|
||||
"pin_not_driven": "error",
|
||||
"pin_to_pin": "warning",
|
||||
"power_pin_not_driven": "error",
|
||||
"same_local_global_label": "warning",
|
||||
"similar_label_and_power": "warning",
|
||||
"similar_labels": "warning",
|
||||
"similar_power": "warning",
|
||||
"simulation_model_issue": "ignore",
|
||||
"single_global_label": "ignore",
|
||||
"stacked_pin_name": "warning",
|
||||
"unannotated": "error",
|
||||
"unconnected_wire_endpoint": "warning",
|
||||
"undefined_netclass": "error",
|
||||
"unit_value_mismatch": "error",
|
||||
"unresolved_variable": "error",
|
||||
"wire_dangling": "error"
|
||||
}
|
||||
},
|
||||
"libraries": {
|
||||
"pinned_footprint_libs": [
|
||||
"RayineComponents",
|
||||
"converted"
|
||||
],
|
||||
"pinned_symbol_libs": [
|
||||
"RayineComponents",
|
||||
"converted"
|
||||
]
|
||||
},
|
||||
"meta": {
|
||||
"filename": "HeliosDAONE.kicad_pro",
|
||||
"version": 3
|
||||
},
|
||||
"net_settings": {
|
||||
"classes": [
|
||||
{
|
||||
"bus_width": 12,
|
||||
"clearance": 0.254,
|
||||
"diff_pair_gap": 0.25,
|
||||
"diff_pair_via_gap": 0.25,
|
||||
"diff_pair_width": 0.2,
|
||||
"line_style": 0,
|
||||
"microvia_diameter": 0.3,
|
||||
"microvia_drill": 0.1,
|
||||
"name": "Default",
|
||||
"pcb_color": "rgba(0, 0, 0, 0.000)",
|
||||
"priority": 2147483647,
|
||||
"schematic_color": "rgba(0, 0, 0, 0.000)",
|
||||
"track_width": 0.254,
|
||||
"tuning_profile": "",
|
||||
"via_diameter": 0.6,
|
||||
"via_drill": 0.3,
|
||||
"wire_width": 6
|
||||
},
|
||||
{
|
||||
"name": "Power In",
|
||||
"pcb_color": "rgba(0, 0, 0, 0.000)",
|
||||
"priority": 2,
|
||||
"schematic_color": "rgba(0, 0, 0, 0.000)",
|
||||
"track_width": 8.0,
|
||||
"tuning_profile": ""
|
||||
},
|
||||
{
|
||||
"name": "Power Low",
|
||||
"pcb_color": "rgba(0, 0, 0, 0.000)",
|
||||
"priority": 0,
|
||||
"schematic_color": "rgba(0, 0, 0, 0.000)",
|
||||
"track_width": 2.54,
|
||||
"tuning_profile": ""
|
||||
},
|
||||
{
|
||||
"name": "Power Out",
|
||||
"pcb_color": "rgba(0, 0, 0, 0.000)",
|
||||
"priority": 1,
|
||||
"schematic_color": "rgba(0, 0, 0, 0.000)",
|
||||
"track_width": 6.0,
|
||||
"tuning_profile": ""
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
"version": 5
|
||||
},
|
||||
"net_colors": null,
|
||||
"netclass_assignments": null,
|
||||
"netclass_patterns": [
|
||||
{
|
||||
"netclass": "Power In",
|
||||
"pattern": "L"
|
||||
},
|
||||
{
|
||||
"netclass": "Power In",
|
||||
"pattern": "N"
|
||||
},
|
||||
{
|
||||
"netclass": "Power Out",
|
||||
"pattern": "/L?OUT"
|
||||
},
|
||||
{
|
||||
"netclass": "Power Out",
|
||||
"pattern": "/L?IN"
|
||||
},
|
||||
{
|
||||
"netclass": "Power Low",
|
||||
"pattern": "N_low"
|
||||
},
|
||||
{
|
||||
"netclass": "Power Low",
|
||||
"pattern": "L_low"
|
||||
}
|
||||
]
|
||||
},
|
||||
"pcbnew": {
|
||||
"last_paths": {
|
||||
"gencad": "",
|
||||
"idf": "",
|
||||
"netlist": "",
|
||||
"plot": "out/",
|
||||
"pos_files": "",
|
||||
"specctra_dsn": "",
|
||||
"step": "",
|
||||
"svg": "",
|
||||
"vrml": ""
|
||||
},
|
||||
"page_layout_descr_file": ""
|
||||
},
|
||||
"schematic": {
|
||||
"annotate_start_num": 0,
|
||||
"annotation": {
|
||||
"method": 0,
|
||||
"sort_order": 0
|
||||
},
|
||||
"bom_export_filename": "${PROJECTNAME}.csv",
|
||||
"bom_fmt_presets": [],
|
||||
"bom_fmt_settings": {
|
||||
"field_delimiter": ",",
|
||||
"keep_line_breaks": false,
|
||||
"keep_tabs": false,
|
||||
"name": "CSV",
|
||||
"ref_delimiter": ",",
|
||||
"ref_range_delimiter": "",
|
||||
"string_delimiter": "\""
|
||||
},
|
||||
"bom_presets": [],
|
||||
"bom_settings": {
|
||||
"exclude_dnp": false,
|
||||
"fields_ordered": [
|
||||
{
|
||||
"group_by": false,
|
||||
"label": "Reference",
|
||||
"name": "Reference",
|
||||
"show": true
|
||||
},
|
||||
{
|
||||
"group_by": false,
|
||||
"label": "Qty",
|
||||
"name": "${QUANTITY}",
|
||||
"show": true
|
||||
},
|
||||
{
|
||||
"group_by": true,
|
||||
"label": "Value",
|
||||
"name": "Value",
|
||||
"show": true
|
||||
},
|
||||
{
|
||||
"group_by": true,
|
||||
"label": "DNP",
|
||||
"name": "${DNP}",
|
||||
"show": true
|
||||
},
|
||||
{
|
||||
"group_by": true,
|
||||
"label": "Exclude from BOM",
|
||||
"name": "${EXCLUDE_FROM_BOM}",
|
||||
"show": true
|
||||
},
|
||||
{
|
||||
"group_by": true,
|
||||
"label": "Exclude from Board",
|
||||
"name": "${EXCLUDE_FROM_BOARD}",
|
||||
"show": true
|
||||
},
|
||||
{
|
||||
"group_by": true,
|
||||
"label": "Footprint",
|
||||
"name": "Footprint",
|
||||
"show": true
|
||||
},
|
||||
{
|
||||
"group_by": false,
|
||||
"label": "Datasheet",
|
||||
"name": "Datasheet",
|
||||
"show": true
|
||||
}
|
||||
],
|
||||
"filter_string": "",
|
||||
"group_symbols": true,
|
||||
"include_excluded_from_bom": true,
|
||||
"name": "Default Editing",
|
||||
"sort_asc": true,
|
||||
"sort_field": "位号"
|
||||
},
|
||||
"bus_aliases": {},
|
||||
"connection_grid_size": 50.0,
|
||||
"drawing": {
|
||||
"dashed_lines_dash_length_ratio": 12.0,
|
||||
"dashed_lines_gap_length_ratio": 3.0,
|
||||
"default_line_thickness": 6.0,
|
||||
"default_text_size": 50.0,
|
||||
"field_names": [],
|
||||
"hop_over_size_choice": 0,
|
||||
"intersheets_ref_own_page": false,
|
||||
"intersheets_ref_prefix": "",
|
||||
"intersheets_ref_short": false,
|
||||
"intersheets_ref_show": false,
|
||||
"intersheets_ref_suffix": "",
|
||||
"junction_size_choice": 3,
|
||||
"label_size_ratio": 0.375,
|
||||
"operating_point_overlay_i_precision": 3,
|
||||
"operating_point_overlay_i_range": "~A",
|
||||
"operating_point_overlay_v_precision": 3,
|
||||
"operating_point_overlay_v_range": "~V",
|
||||
"overbar_offset_ratio": 1.23,
|
||||
"pin_symbol_size": 25.0,
|
||||
"text_offset_ratio": 0.15
|
||||
},
|
||||
"legacy_lib_dir": "",
|
||||
"legacy_lib_list": [],
|
||||
"meta": {
|
||||
"version": 1
|
||||
},
|
||||
"net_format_name": "",
|
||||
"page_layout_descr_file": "",
|
||||
"plot_directory": "",
|
||||
"reuse_designators": true,
|
||||
"space_save_all_events": true,
|
||||
"spice_current_sheet_as_root": false,
|
||||
"spice_external_command": "spice \"%I\"",
|
||||
"spice_model_current_sheet_as_root": true,
|
||||
"spice_save_all_currents": false,
|
||||
"spice_save_all_dissipations": false,
|
||||
"spice_save_all_voltages": false,
|
||||
"subpart_first_id": 65,
|
||||
"subpart_id_separator": 0,
|
||||
"top_level_sheets": [
|
||||
{
|
||||
"filename": "HeliosDAONE.kicad_sch",
|
||||
"name": "HeliosDAONE",
|
||||
"uuid": "ef4a6f87-87d3-400e-b11f-0c7519b83474"
|
||||
}
|
||||
],
|
||||
"used_designators": "NT1,J4",
|
||||
"variants": []
|
||||
},
|
||||
"sheets": [
|
||||
[
|
||||
"ef4a6f87-87d3-400e-b11f-0c7519b83474",
|
||||
"HeliosDAONE"
|
||||
]
|
||||
],
|
||||
"text_variables": {},
|
||||
"tuning_profiles": {
|
||||
"meta": {
|
||||
"version": 0
|
||||
},
|
||||
"tuning_profiles_impedance_geometric": []
|
||||
}
|
||||
}
|
||||
22071
hardware/pcb/HeliosDAONE.kicad_sch
Normal file
22071
hardware/pcb/HeliosDAONE.kicad_sch
Normal file
File diff suppressed because it is too large
Load Diff
4727
hardware/pcb/bom/bom.html
Normal file
4727
hardware/pcb/bom/bom.html
Normal file
File diff suppressed because one or more lines are too long
5
hardware/pcb/design-block-lib-table
Normal file
5
hardware/pcb/design-block-lib-table
Normal file
@@ -0,0 +1,5 @@
|
||||
(design_block_lib_table
|
||||
(version 7)
|
||||
(lib (name "converted.3dmodels") (type "KiCad") (uri "${KIPRJMOD}/library/converted.3dmodels") (options "") (descr ""))
|
||||
(lib (name "RayineComponents.3dmodels") (type "KiCad") (uri "${KIPRJMOD}/library/RayineComponents.3dmodels") (options "") (descr ""))
|
||||
)
|
||||
5
hardware/pcb/fp-lib-table
Normal file
5
hardware/pcb/fp-lib-table
Normal file
@@ -0,0 +1,5 @@
|
||||
(fp_lib_table
|
||||
(version 7)
|
||||
(lib (name "converted") (type "KiCad") (uri "${KIPRJMOD}/library/converted.pretty") (options "") (descr ""))
|
||||
(lib (name "RayineComponents") (type "KiCad") (uri "${KIPRJMOD}/library/RayineComponents.pretty") (options "") (descr ""))
|
||||
)
|
||||
22086
hardware/pcb/library/RayineComponents.3dmodels/IM1281B.step
Normal file
22086
hardware/pcb/library/RayineComponents.3dmodels/IM1281B.step
Normal file
File diff suppressed because it is too large
Load Diff
16500
hardware/pcb/library/RayineComponents.3dmodels/JQC-3FF-005-1Z.step
Normal file
16500
hardware/pcb/library/RayineComponents.3dmodels/JQC-3FF-005-1Z.step
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
27873
hardware/pcb/library/RayineComponents.3dmodels/RFID-RC522.STEP
Normal file
27873
hardware/pcb/library/RayineComponents.3dmodels/RFID-RC522.STEP
Normal file
File diff suppressed because it is too large
Load Diff
172931
hardware/pcb/library/RayineComponents.3dmodels/STM32F103C8T6 Blue Pill.step
Normal file
172931
hardware/pcb/library/RayineComponents.3dmodels/STM32F103C8T6 Blue Pill.step
Normal file
File diff suppressed because it is too large
Load Diff
17426
hardware/pcb/library/RayineComponents.3dmodels/TS-1187A-B-A-B.step
Normal file
17426
hardware/pcb/library/RayineComponents.3dmodels/TS-1187A-B-A-B.step
Normal file
File diff suppressed because it is too large
Load Diff
44805
hardware/pcb/library/RayineComponents.bak
Normal file
44805
hardware/pcb/library/RayineComponents.bak
Normal file
File diff suppressed because it is too large
Load Diff
5487
hardware/pcb/library/RayineComponents.kicad_sym
Normal file
5487
hardware/pcb/library/RayineComponents.kicad_sym
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user