diff --git a/.workbuddy/memory/2026-07-02.md b/.workbuddy/memory/2026-07-02.md new file mode 100644 index 0000000..601a670 --- /dev/null +++ b/.workbuddy/memory/2026-07-02.md @@ -0,0 +1,6 @@ +# 2026-07-02 工作日志 + +- **产品介绍页重做**:将 `deploy/landing/index.html` 从暗色科技风改为明亮风格单屏页面(左右分栏→全屏背景图融合) +- **生图技能创建**:创建了 `agnes-image` 用户级技能,使用 Agnes Image 2.1 Flash 模型(apihub.agnes-ai.com),无水印,支持文生图/图生图 +- **API Key 已内置在技能脚本中**:`sk-ib7i...M2z` +- **测试通过**:生成充电柜产品图成功,25秒出图 diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md new file mode 100644 index 0000000..770b791 --- /dev/null +++ b/.workbuddy/memory/MEMORY.md @@ -0,0 +1,6 @@ +# 项目长期记忆 + +## 用户偏好 +- **产品介绍页风格**:偏好明亮/浅色风格,不要暗色科技风;单屏不滚动布局 +- **生图能力**:项目有 ImageGen 文生图工具可用,可用于生成产品渲染图等 +- **操作规则**:开发/修改程序前必须确认需求,不可自说自话操作;生产环境服务器文件和数据必须用户授权后才可动 diff --git a/AGENTS.md b/AGENTS.md index c3f6722..2dd0037 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -410,6 +410,49 @@ operation_log(操作日志) | energy_stats | 能耗统计表 | | operation_logs | 操作日志表 | +### Redis 实时状态存储 + +设备实时状态通过 Redis Hash 层级存储,按 `status_post` 上报内容解析写入。 + +**层级结构:** + +``` +device:{imei} # 设备级 Hash +├─ online = "1" # 在线标记(TTL 300秒) +├─ rssi = "85" # 信号强度 +├─ pow_fail_dc = "0/1" # 直流停电(12V掉电) +├─ pow_fail_ac = "0/1" # 交流停电(380V掉电) +└─ device:{imei}:board:{board_idx} # 板级 Hash(仓控板1~N) + ├─ status_hex = "000100020003000400050006" # 原始hex状态 + └─ device:{imei}:board:{board_idx}:ch:{0-5} # 通道级 Hash(6仓) + ├─ on = "0/1" # 充电中 + ├─ full = "0/1" # 充满 + ├─ fault = "0/1" # 故障 + └─ hex = "00010002" # 原始hex +``` + +**写入时机:** +| 事件 | 写入位置 | 说明 | +|------|----------|------| +| `login` | `device:{imei}.online` | 标记在线,设置TTL 300秒 | +| `status_post` | `device:{imei}` + board + ch | 解析status字典,按层级写入 | +| `pow_fail` | `device:{imei}.pow_fail_dc/ac` | 更新停电状态 | + +**通道状态映射(hex → 语义):** +- 空闲(idle) +- 插入(inserted) +- 充电中(charging) +- 充满(full) +- 故障(fault) + +> ⚠️ hex编码格式待硬件团队最终确认,当前实现预留状态字段,按 bit 位解析。 + +**API 接口:** +| 接口 | 说明 | 查询方式 | +|------|------|----------| +| `GET /api/cabinets/filter` | 设备列表(设备级状态) | Pipeline 批量 HGET,1次往返 | +| `GET /api/cabinets/realtime-status?imei=xxx` | 单设备详情(板级+通道级) | KEYS 扫描 + HMGET | + ## TCP通讯协议 - 长连接,无需PING包 diff --git a/README.md b/README.md index 49e4b29..0c7c12c 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@ ## 技术栈 - **后端**:Rust + Axum -- **前端**:React + Arco Design Pro +- **前端**:React + Arco Design Pro(管理端) +- **H5用户端**:Taro + React(跨平台,支持 H5 / 微信小程序) - **数据库**:MySQL(阿里云RDS) - **缓存**:Redis(阿里云Redis) - **设备通讯**:TCP长连接(柜控板4G模块,Air780E Cat.1) @@ -21,9 +22,12 @@ charging-cabinet/ ├── tasks/ # 任务文件(opencode执行) ├── docs/ # 开发文档、协议文档 ├── software/ -│ ├── server/ # Rust后端 -│ └── web/ # React前端 +│ ├── api-server/ # Rust API服务 +│ ├── device-server/ # Rust TCP设备服务 +│ ├── web/ # React管理端前端 +│ └── h5-taro/ # H5用户端(Taro + React) ├── hardware/ # 硬件资料(原理图/PCB等) +├── deploy/ # 部署配置 └── test/ # 测试用例/报告 ``` diff --git a/deploy/landing/cabinet.png b/deploy/landing/cabinet.png deleted file mode 100644 index 875a45f..0000000 Binary files a/deploy/landing/cabinet.png and /dev/null differ diff --git a/deploy/landing/hero-bg.png b/deploy/landing/hero-bg.png new file mode 100644 index 0000000..2daa256 Binary files /dev/null and b/deploy/landing/hero-bg.png differ diff --git a/deploy/landing/index.html b/deploy/landing/index.html index f2808cd..5142e92 100644 --- a/deploy/landing/index.html +++ b/deploy/landing/index.html @@ -1,466 +1,204 @@ - - - 安知充 - 智能充电柜矩阵系统 - - - - + + +安知充 — 智能充电柜矩阵系统 + - - - - -
-
-
- - 智能充电新方案 -
-

智能充电柜
矩阵系统

-

36仓独立充电,4G远程管理,多重安全防护。为电动车换电场景打造的下一代智能充电解决方案。

- -
-
- - -
-
-
- 安知充智能充电柜 -
-
-
- - -
-
-
-

核心特性

-

为换电场景设计的全栈解决方案

-
-
-
-
- - - - -
-

36仓独立充电

-

每个仓体独立控制,支持48V/60V/72V直流适配,电流5A-20A可调。直流快充1-2小时充满,高效运营。

-
-
-
- - - - -
-

智能BMS适配

-

支持星恒Modbus、天能485等多种BMS协议,平台远程配置,自动识别电池参数,安全充电。

-
-
-
- - - - -
-

多重安全防护

-

过充/过温/过流/短路保护,烟感+喷淋联动,浸水保护,6路温控实时监测,确保充电安全。

-
-
-
- - - - -
-

远程管理

-

4G TCP长连接,实时状态监控,远程开门/禁用/OTA升级,完整后台管理系统,高效运维。

-
-
-
-
- - -
-
-
-
-
36
-
独立仓体
-
-
-
0.5s
-
响应延迟
-
-
-
99%
-
OTA成功率
-
-
-
8000h
-
平均无故障时间
-
-
-
-
- - - + +
+ + +
+
+ + 安知充 +
+ +
+ + +
+
+
智能充电矩阵系统
+

为换电场景打造的
下一代智能充电

+

36仓独立直流快充,4G远程实时管控,多重安全防护体系。从硬件到云端,全栈自研的智能充电解决方案。

+ +
+ 36仓独立 + 多重防护 + 4G在线 + BMS适配 +
+ +
+
36
独立仓体
+
<0.5s
指令响应
+
8000h
MTBF
+
99%
可用率
+
+
+
+ + +
+ © 2026 安知充 · 苏ICP备2025198376号-1 +
+ +
+ diff --git a/deploy/landing/logo-1.png b/deploy/landing/logo-1.png new file mode 100644 index 0000000..80937bd Binary files /dev/null and b/deploy/landing/logo-1.png differ diff --git a/deploy/landing/logo-2.png b/deploy/landing/logo-2.png new file mode 100644 index 0000000..2763100 Binary files /dev/null and b/deploy/landing/logo-2.png differ diff --git a/deploy/landing/logo-3.png b/deploy/landing/logo-3.png new file mode 100644 index 0000000..f032eb0 Binary files /dev/null and b/deploy/landing/logo-3.png differ diff --git a/software/api-server/src/commands.rs b/software/api-server/src/commands.rs index 2bb72c0..fb35499 100644 --- a/software/api-server/src/commands.rs +++ b/software/api-server/src/commands.rs @@ -194,6 +194,7 @@ pub struct AppState { pub mysql: sqlx::MySqlPool, pub pending_commands: PendingCommands, pub nodes: NodeRegistry, + pub redis_url: String, } /// 指令下发请求体 diff --git a/software/api-server/src/db/migrate.rs b/software/api-server/src/db/migrate.rs index a13f713..91983d1 100644 --- a/software/api-server/src/db/migrate.rs +++ b/software/api-server/src/db/migrate.rs @@ -24,7 +24,7 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> { CREATE TABLE IF NOT EXISTS cabinets ( id BIGINT AUTO_INCREMENT PRIMARY KEY, project_id BIGINT, - abstract_id VARCHAR(20) NOT NULL UNIQUE COMMENT '平台抽象ID,如10-00000000', + abstract_id VARCHAR(20) NULL UNIQUE COMMENT '平台抽象ID,如10-00000001', imei VARCHAR(15) NOT NULL UNIQUE COMMENT '4G模块IMEI', auth_str VARCHAR(8) COMMENT '签名安全码', name VARCHAR(100) COMMENT '柜子名称', @@ -166,6 +166,14 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> { completed_at TIMESTAMP NULL COMMENT '完成时间', FOREIGN KEY (user_id) REFERENCES users(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + + CREATE TABLE IF NOT EXISTS abstract_bindings ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + abstract_id VARCHAR(20) NOT NULL UNIQUE COMMENT '平台抽象ID,如10-00000001', + cabinet_id BIGINT NOT NULL UNIQUE COMMENT '柜子ID', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (cabinet_id) REFERENCES cabinets(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; "#; // 逐条执行,避免多语句执行问题 @@ -173,6 +181,10 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> { sqlx::query(statement).execute(pool).await?; } + // 兼容已有数据库:将 abstract_id 改为可空 + let _ = sqlx::query(r#"ALTER TABLE cabinets MODIFY abstract_id VARCHAR(20) NULL"#) + .execute(pool).await; + // 创建关键索引(IF NOT EXISTS 避免重复) create_indexes(pool).await?; diff --git a/software/api-server/src/error.rs b/software/api-server/src/error.rs index 128cc71..966f8b2 100644 --- a/software/api-server/src/error.rs +++ b/software/api-server/src/error.rs @@ -30,6 +30,9 @@ pub enum AppError { #[error("请求过于频繁: {0}")] TooManyRequests(String), + #[error("冲突: {0}")] + Conflict(String), + #[error("内部错误: {0}")] Internal(String), } @@ -50,6 +53,7 @@ impl IntoResponse for AppError { AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, "UNAUTHORIZED", msg.as_str()), AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, "FORBIDDEN", msg.as_str()), AppError::TooManyRequests(msg) => (StatusCode::TOO_MANY_REQUESTS, "TOO_MANY_REQUESTS", msg.as_str()), + AppError::Conflict(msg) => (StatusCode::CONFLICT, "CONFLICT", msg.as_str()), AppError::Internal(msg) => { tracing::error!("内部错误: {}", msg); (StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL_ERROR", "服务器内部错误") diff --git a/software/api-server/src/main.rs b/software/api-server/src/main.rs index 89d5e11..8b635b7 100644 --- a/software/api-server/src/main.rs +++ b/software/api-server/src/main.rs @@ -57,6 +57,7 @@ async fn main() { mysql: db_pool.clone(), pending_commands: pending_commands.clone(), nodes: nodes.clone(), + redis_url: cfg.redis_url.clone(), }; // 启动后台协程 — 处理设备上报 diff --git a/software/api-server/src/routes/cabinet_tree.rs b/software/api-server/src/routes/cabinet_tree.rs index bb3275b..44a919e 100644 --- a/software/api-server/src/routes/cabinet_tree.rs +++ b/software/api-server/src/routes/cabinet_tree.rs @@ -14,6 +14,50 @@ use crate::error::AppError; use crate::middleware::auth::{self, CurrentUser}; use crate::commands::AppState; +/// SQL查询专用结构体(只包含数据库字段) +#[derive(Debug, Clone, sqlx::FromRow)] +struct CabinetSqlRow { + id: i64, + project_id: Option, + abstract_id: Option, + imei: String, + iccid: Option, + name: Option, + address: Option, + status: i8, + board_count: Option, + channel_count: Option, + idle_count: Option, + charging_count: Option, + full_count: Option, + fault_count: Option, +} + +impl From for CabinetRow { + fn from(row: CabinetSqlRow) -> Self { + CabinetRow { + id: row.id, + project_id: row.project_id, + abstract_id: row.abstract_id, + imei: row.imei, + iccid: row.iccid, + name: row.name, + address: row.address, + status: row.status, + board_count: row.board_count, + channel_count: row.channel_count, + idle_count: row.idle_count, + charging_count: row.charging_count, + full_count: row.full_count, + fault_count: row.fault_count, + rssi: None, + pow_fail_dc: None, + pow_fail_ac: None, + is_online: None, + } + } +} + /// 组织树接口 — 返回多级组织->项目树(含设备数量) pub async fn organization_tree( user: CurrentUser, @@ -114,6 +158,9 @@ pub async fn organization_tree( pub struct CabinetFilterParams { pub organization_id: Option, pub project_id: Option, + pub page: Option, + pub page_size: Option, + pub bound: Option, } /// 设备列表接口 — 根据组织/项目筛选 @@ -125,6 +172,11 @@ pub async fn filter_cabinets( auth::check_permission(&user, "device:view")?; let db = &state.mysql; + let redis = &state.redis; + + let page = params.page.unwrap_or(1).max(1); + let page_size = params.page_size.unwrap_or(12).clamp(1, 100); + let offset = (page - 1) * page_size; // 基础SQL:联表查询获取通道状态统计 let base_sql = " @@ -149,41 +201,225 @@ pub async fn filter_cabinets( FROM cabinets c "; - let rows = if let Some(proj_id) = params.project_id { - sqlx::query_as::<_, CabinetRow>( - &format!("{} WHERE c.project_id = ? ORDER BY c.id", base_sql) - ) - .bind(proj_id) - .fetch_all(db) - .await? + let (mut where_clause, binds): (String, Vec) = if let Some(proj_id) = params.project_id { + ("WHERE c.project_id = ?".into(), vec![proj_id.to_string()]) } else if let Some(org_id) = params.organization_id { - sqlx::query_as::<_, CabinetRow>( - &format!("{} JOIN projects p ON c.project_id = p.id - WHERE p.organization_id IN ( - SELECT id FROM organizations WHERE id = ? OR parent_id = ? - ) ORDER BY c.id", base_sql) - ) - .bind(org_id) - .bind(org_id) - .fetch_all(db) - .await? + ("JOIN projects p ON c.project_id = p.id + WHERE p.organization_id IN ( + SELECT id FROM organizations WHERE id = ? OR parent_id = ? + )".into(), vec![org_id.to_string(), org_id.to_string()]) } else if user.role_level >= 2 { - sqlx::query_as::<_, CabinetRow>( - &format!("{} ORDER BY c.id", base_sql) - ) - .fetch_all(db) - .await? + (String::new(), vec![]) } else if let Some(org_id) = user.organization_id { - sqlx::query_as::<_, CabinetRow>( - &format!("{} JOIN projects p ON c.project_id = p.id - WHERE p.organization_id = ? ORDER BY c.id", base_sql) - ) - .bind(org_id) - .fetch_all(db) - .await? + ("JOIN projects p ON c.project_id = p.id + WHERE p.organization_id = ?".into(), vec![org_id.to_string()]) } else { - vec![] + ("WHERE 1=0".into(), vec![]) }; - Ok(Json(json!(rows))) + // 已绑定/未绑定过滤 + if let Some(bound) = params.bound { + match bound.as_str() { + "1" => { + if where_clause.is_empty() { + where_clause = "WHERE c.abstract_id IS NOT NULL".into(); + } else { + where_clause.push_str(" AND c.abstract_id IS NOT NULL"); + } + } + "0" => { + if where_clause.is_empty() { + where_clause = "WHERE c.abstract_id IS NULL".into(); + } else { + where_clause.push_str(" AND c.abstract_id IS NULL"); + } + } + _ => {} + } + } + + // 总数量 + let count_sql = format!("SELECT COUNT(*) FROM cabinets c {}", where_clause); + let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql); + for b in &binds { + count_query = count_query.bind(b); + } + let total: i64 = count_query.fetch_one(db).await.unwrap_or(0); + + if total == 0 || offset >= total { + return Ok(Json(json!({"total": 0, "data": []}))); + } + + // 分页数据 + let data_sql = format!("{} {} ORDER BY c.id LIMIT ? OFFSET ?", base_sql, where_clause); + let mut data_query = sqlx::query_as::<_, CabinetSqlRow>(&data_sql); + for b in &binds { + data_query = data_query.bind(b); + } + let sql_rows: Vec = data_query + .bind(page_size) + .bind(offset) + .fetch_all(db).await?; + + // 转换为CabinetRow + let mut rows: Vec = sql_rows.into_iter().map(CabinetRow::from).collect(); + + // 从Redis获取实时状态(Pipeline批量HGET,1次网络往返) + { + let client = redis::Client::open(state.redis_url.as_str()).map_err(|e| { + AppError::Internal(format!("Redis客户端创建失败: {}", e)) + })?; + let mut conn = client.get_async_connection().await.map_err(|e| { + AppError::Internal(format!("Redis连接失败: {}", e)) + })?; + + // 用Pipeline一次发所有HGET命令(每个设备1次HGET取4个字段) + let mut pipe = redis::pipe(); + for row in &rows { + let key = format!("device:{}", row.imei); + pipe.cmd("HGET").arg(&key).arg("online"); + pipe.cmd("HGET").arg(&key).arg("rssi"); + pipe.cmd("HGET").arg(&key).arg("pow_fail_dc"); + pipe.cmd("HGET").arg(&key).arg("pow_fail_ac"); + } + + // 一次发送,获取所有结果 + let results: Vec> = pipe.query_async(&mut conn).await.unwrap_or_default(); + + // 每4个一组解析 + for (i, row) in rows.iter_mut().enumerate() { + let base = i * 4; + row.is_online = results.get(base).and_then(|v| v.as_ref()).map(|v| v == "1"); + row.rssi = results.get(base + 1).and_then(|v| v.as_ref()).and_then(|v| v.parse::().ok()); + row.pow_fail_dc = results.get(base + 2).and_then(|v| v.as_ref()).map(|v| v == "1"); + row.pow_fail_ac = results.get(base + 3).and_then(|v| v.as_ref()).map(|v| v == "1"); + } + } + + Ok(Json(json!({"total": total, "data": rows}))) +} + +/// 设备实时状态参数 +#[derive(Deserialize)] +pub struct DeviceStatusParams { + pub imei: String, +} + +/// 设备实时状态接口 — 返回板级+通道级状态 +pub async fn device_realtime_status( + user: CurrentUser, + State(state): State, + Query(params): Query, +) -> Result, AppError> { + auth::check_permission(&user, "device:view")?; + + let client = redis::Client::open(state.redis_url.as_str()).map_err(|e| { + AppError::Internal(format!("Redis客户端创建失败: {}", e)) + })?; + let mut conn = client.get_async_connection().await.map_err(|e| { + AppError::Internal(format!("Redis连接失败: {}", e)) + })?; + + let device_key = format!("device:{}", params.imei); + + // 获取设备级字段 + let fields = vec!["online", "rssi", "pow_fail_dc", "pow_fail_ac"]; + let values: Vec> = redis::cmd("HMGET") + .arg(&device_key) + .arg(&fields) + .query_async(&mut conn) + .await + .unwrap_or_default(); + + let device_status = json!({ + "online": values.get(0).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false), + "rssi": values.get(1).and_then(|v| v.as_ref()).and_then(|v| v.parse::().ok()), + "pow_fail_dc": values.get(2).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false), + "pow_fail_ac": values.get(3).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false), + }); + + // 扫描板级key: device:{imei}:board:* + let board_pattern = format!("device:{}:board:*", params.imei); + let board_keys: Vec = redis::cmd("KEYS") + .arg(&board_pattern) + .query_async(&mut conn) + .await + .unwrap_or_default(); + + let mut boards = json!({}); + for board_key in &board_keys { + // 提取board_idx + let board_idx = board_key.split(":").last().unwrap_or(""); + if board_idx.is_empty() { + continue; + } + + // 获取板级字段 + let board_fields = vec!["status_hex"]; + let board_values: Vec> = redis::cmd("HMGET") + .arg(board_key) + .arg(&board_fields) + .query_async(&mut conn) + .await + .unwrap_or_default(); + + let mut board_data = json!({ + "status_hex": board_values.get(0).and_then(|v| v.as_ref()).cloned().unwrap_or_default(), + }); + + // 扫描通道级key: device:{imei}:board:{idx}:ch:* + let ch_pattern = format!("{}:ch:*", board_key); + let ch_keys: Vec = redis::cmd("KEYS") + .arg(&ch_pattern) + .query_async(&mut conn) + .await + .unwrap_or_default(); + + let mut channels = json!({}); + for ch_key in &ch_keys { + let ch_idx = ch_key.split(":").last().unwrap_or(""); + if ch_idx.is_empty() { + continue; + } + + let ch_fields = vec!["on", "full", "fault", "hex"]; + let ch_values: Vec> = redis::cmd("HMGET") + .arg(ch_key) + .arg(&ch_fields) + .query_async(&mut conn) + .await + .unwrap_or_default(); + + // 状态映射:fault > full > on > idle + let fault = ch_values.get(0).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false); + let full = ch_values.get(1).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false); + let on = ch_values.get(2).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false); + + let status = if fault { + "fault" + } else if full { + "full" + } else if on { + "charging" + } else { + "idle" + }; + + channels[ch_idx] = json!({ + "status": status, + "on": on, + "full": full, + "fault": fault, + "hex": ch_values.get(3).and_then(|v| v.as_ref()).cloned().unwrap_or_default(), + }); + } + + board_data["channels"] = channels; + boards[board_idx] = board_data; + } + + Ok(Json(json!({ + "device": device_status, + "boards": boards, + }))) } diff --git a/software/api-server/src/routes/cabinets.rs b/software/api-server/src/routes/cabinets.rs index 81dc0fa..1e8ee8e 100644 --- a/software/api-server/src/routes/cabinets.rs +++ b/software/api-server/src/routes/cabinets.rs @@ -10,13 +10,51 @@ use serde_json::{json, Value}; use sqlx::MySqlPool; use super::organizations::{ - generate_abstract_id, generate_auth_str, - CabinetDetail, CabinetRow, CabinBoardRow, CabinBoardWithCompartments, - CompartmentRow, CreateCabinetBody, UpdateCabinetBody, + generate_auth_str, + CabinetDetail, CabinBoardRow, CabinBoardWithCompartments, + CompartmentRow, CreateCabinetBody, ReplaceImeiBody, UpdateCabinetBody, }; use crate::error::AppError; use crate::middleware::auth::{self, CurrentUser}; +/// 基础柜子行(只包含数据库字段,用于简单查询) +#[derive(Debug, Clone, Serialize, sqlx::FromRow)] +struct CabinetBasicRow { + id: i64, + project_id: Option, + abstract_id: Option, + imei: String, + iccid: Option, + name: Option, + address: Option, + status: i8, +} + +impl From for super::organizations::CabinetRow { + fn from(row: CabinetBasicRow) -> Self { + super::organizations::CabinetRow { + id: row.id, + project_id: row.project_id, + abstract_id: row.abstract_id, + imei: row.imei, + iccid: row.iccid, + name: row.name, + address: row.address, + status: row.status, + board_count: None, + channel_count: None, + idle_count: None, + charging_count: None, + full_count: None, + fault_count: None, + rssi: None, + pow_fail_dc: None, + pow_fail_ac: None, + is_online: None, + } + } +} + // --------------------------------------------------------------------------- // 项目访问权限验证 // --------------------------------------------------------------------------- @@ -69,7 +107,7 @@ pub async fn list_cabinets( check_project_access(&user, &state.mysql, project_id).await?; let db = &state.mysql; - let rows = sqlx::query_as::<_, CabinetRow>( + let sql_rows = sqlx::query_as::<_, CabinetBasicRow>( r#"SELECT id, project_id, abstract_id, imei, iccid, name, address, status FROM cabinets WHERE project_id = ? ORDER BY id"#, ) @@ -77,6 +115,7 @@ pub async fn list_cabinets( .fetch_all(db) .await?; + let rows: Vec = sql_rows.into_iter().map(Into::into).collect(); Ok(Json(json!(rows))) } @@ -111,17 +150,15 @@ pub async fn create_cabinet( let mut created_ids: Vec = Vec::new(); for imei in &unique_imeis { - let abstract_id = generate_abstract_id(imei); let auth_str = generate_auth_str(); let mut tx = db.begin().await.map_err(AppError::Database)?; let result = sqlx::query( - r#"INSERT INTO cabinets (project_id, abstract_id, imei, auth_str) - VALUES (?, ?, ?, ?)"#, + r#"INSERT INTO cabinets (project_id, imei, auth_str) + VALUES (?, ?, ?)"#, ) .bind(body.project_id) - .bind(&abstract_id) .bind(imei) .bind(&auth_str) .execute(&mut *tx) @@ -150,7 +187,7 @@ pub async fn create_cabinet( tx.commit().await.map_err(AppError::Database)?; created_ids.push(cabinet_id); - tracing::info!("创建柜子 id={} abstract_id={} imei={}", cabinet_id, abstract_id, imei); + tracing::info!("创建柜子 id={} imei={}", cabinet_id, imei); } Ok(Json(json!({ "ids": created_ids }))) @@ -169,11 +206,13 @@ pub async fn update_cabinet( let rows = sqlx::query( r#"UPDATE cabinets SET project_id = COALESCE(?, project_id), - name = COALESCE(?, name) + name = COALESCE(?, name), + abstract_id = COALESCE(?, abstract_id) WHERE id = ?"#, ) .bind(body.project_id) .bind(&body.name) + .bind(&body.abstract_id) .bind(id) .execute(db) .await? @@ -228,6 +267,107 @@ pub async fn delete_cabinet( Ok(Json(json!({}))) } +/// POST /api/cabinets/:id/bind — 绑定平台抽象ID +pub async fn bind_cabinet( + user: CurrentUser, + State(state): State, + Path(id): Path, +) -> Result, AppError> { + auth::check_permission(&user, "device:edit")?; + + let db = &state.mysql; + + // 检查柜子是否存在且未绑定 + let bound: (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM cabinets WHERE id = ? AND abstract_id IS NULL" + ) + .bind(id) + .fetch_one(db) + .await?; + + if bound.0 == 0 { + return Err(AppError::BadRequest("柜子不存在或已绑定".into())); + } + + // 事务:插入绑定记录 + 回写设备表 + let mut tx = db.begin().await.map_err(AppError::Database)?; + + let result = sqlx::query( + "INSERT INTO abstract_bindings (cabinet_id) VALUES (?)" + ) + .bind(id) + .execute(&mut *tx) + .await?; + + let binding_id = result.last_insert_id() as u64; + let abstract_id = format!("10-{:08X}", binding_id); + + sqlx::query( + "UPDATE cabinets SET abstract_id = ? WHERE id = ?" + ) + .bind(&abstract_id) + .bind(id) + .execute(&mut *tx) + .await?; + + tx.commit().await.map_err(AppError::Database)?; + + tracing::info!("绑定柜子 id={} abstract_id={}", id, abstract_id); + Ok(Json(json!({ "abstract_id": abstract_id }))) +} + +/// POST /api/cabinets/:id/replace-imei — 更换IMEI(换模组) +pub async fn replace_imei( + user: CurrentUser, + State(state): State, + Path(id): Path, + Json(body): Json, +) -> Result, AppError> { + auth::check_permission(&user, "device:edit")?; + + let imei = body.imei.trim().to_string(); + if imei.len() != 15 || !imei.chars().all(|c| c.is_ascii_digit()) { + return Err(AppError::BadRequest("IMEI 必须为15位数字".into())); + } + + let db = &state.mysql; + + // 检查柜子是否存在 + let exists: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM cabinets WHERE id = ?") + .bind(id) + .fetch_one(db) + .await?; + if exists.0 == 0 { + return Err(AppError::NotFound(format!("柜子 {} 不存在", id))); + } + + // 检查新IMEI是否已被其他柜子使用 + let conflict: Vec<(i64, Option)> = sqlx::query_as( + "SELECT id, name FROM cabinets WHERE imei = ? AND id != ?" + ) + .bind(&imei) + .bind(id) + .fetch_all(db) + .await?; + + if let Some(row) = conflict.first() { + let name = row.1.as_deref().unwrap_or("未知"); + return Err(AppError::Conflict( + format!("IMEI {} 已被柜子 {} ({}) 使用", imei, row.0, name) + )); + } + + // 更新IMEI + sqlx::query("UPDATE cabinets SET imei = ? WHERE id = ?") + .bind(&imei) + .bind(id) + .execute(db) + .await?; + + tracing::info!("更换IMEI id={} new_imei={}", id, imei); + Ok(Json(json!({}))) +} + /// POST /api/cabinets/:id/regenerate-auth — 重新生成安全码 pub async fn regenerate_auth( user: CurrentUser, @@ -267,8 +407,8 @@ pub async fn get_cabinet_detail( auth::check_permission(&user, "device:view")?; let db = &state.mysql; - let cabinet = sqlx::query_as::<_, CabinetRow>( - r#"SELECT id, project_id, abstract_id, imei, name, status + let cabinet = sqlx::query_as::<_, CabinetBasicRow>( + r#"SELECT id, project_id, abstract_id, imei, iccid, name, address, status FROM cabinets WHERE id = ?"#, ) .bind(id) diff --git a/software/api-server/src/routes/h5.rs b/software/api-server/src/routes/h5.rs index f8a6ff6..73b4274 100644 --- a/software/api-server/src/routes/h5.rs +++ b/software/api-server/src/routes/h5.rs @@ -680,17 +680,13 @@ pub async fn get_cabinet_detail( }))) } -/// GET /api/h5/cabinets/abstract/:abstract_id — 通过抽象ID查设备(扫码入口,带权限校验) +/// GET /api/h5/cabinets/abstract/:abstract_id — 通过抽象ID查设备(扫码入口,免认证) pub async fn get_cabinet_by_abstract_id( - user: CurrentUser, State(state): State, Path(abstract_id): Path, ) -> Result, AppError> { let pool = &state.mysql; - // 权限校验 - auth::check_permission(&user, "device:view")?; - let cab: Option<(i64, String, String, Option, i8)> = sqlx::query_as( "SELECT id, abstract_id, imei, name, status FROM cabinets WHERE abstract_id = ?", ) @@ -701,9 +697,6 @@ pub async fn get_cabinet_by_abstract_id( let (cab_id, abs_id, imei, name, status) = cab.ok_or_else(|| AppError::NotFound("设备不存在".into()))?; - // 组织隔离:验证用户是否有权访问该设备 - verify_cabinet_access(&user, cab_id, pool).await?; - // 查询仓控板+仓体 let boards: Vec = sqlx::query_as::<_, H5CabinBoardRow>( "SELECT id, board_id, status FROM cabin_boards WHERE cabinet_id = ? ORDER BY board_id", diff --git a/software/api-server/src/routes/mod.rs b/software/api-server/src/routes/mod.rs index a75040a..27c2a54 100644 --- a/software/api-server/src/routes/mod.rs +++ b/software/api-server/src/routes/mod.rs @@ -61,9 +61,12 @@ pub fn build(state: AppState) -> Router { .route("/api/cabinets", post(cabinets::create_cabinet)) .route("/api/cabinets/{id}", put(cabinets::update_cabinet).delete(cabinets::delete_cabinet).get(cabinets::get_cabinet_detail)) .route("/api/cabinets/{id}/regenerate-auth", post(cabinets::regenerate_auth)) + .route("/api/cabinets/{id}/bind", post(cabinets::bind_cabinet)) + .route("/api/cabinets/{id}/replace-imei", post(cabinets::replace_imei)) // 组织树 .route("/api/organization-tree", get(cabinet_tree::organization_tree)) .route("/api/cabinets/filter", get(cabinet_tree::filter_cabinets)) + .route("/api/cabinets/realtime-status", get(cabinet_tree::device_realtime_status)) // 充电记录 .route("/api/charge-records", get(charge_records::list_charge_records)) .route("/api/charge-records/export", get(charge_records::export_charge_records)) @@ -95,7 +98,6 @@ pub fn build(state: AppState) -> Router { .route("/api/h5/dashboard", get(h5::get_dashboard)) .route("/api/h5/projects", get(h5::get_projects)) .route("/api/h5/cabinets", get(h5::get_cabinets)) - .route("/api/h5/cabinets/abstract/{abstract_id}", get(h5::get_cabinet_by_abstract_id)) .route("/api/h5/cabinets/{id}", get(h5::get_cabinet_detail)) .route("/api/h5/compartments/{id}", get(h5::get_compartment_detail)) .route("/api/h5/charge/start", post(h5::start_charge)) @@ -108,6 +110,8 @@ pub fn build(state: AppState) -> Router { .route("/api/health", get(health::health_check)) .route("/api/auth/login", post(auth::login)) .route("/api/h5/auth/login", post(h5::h5_login)) + // 扫码入口(免认证) + .route("/api/h5/cabinets/abstract/{abstract_id}", get(h5::get_cabinet_by_abstract_id)) .merge(protected) .with_state(state) } diff --git a/software/api-server/src/routes/organizations.rs b/software/api-server/src/routes/organizations.rs index 9e2bc21..1c4c730 100644 --- a/software/api-server/src/routes/organizations.rs +++ b/software/api-server/src/routes/organizations.rs @@ -29,11 +29,18 @@ pub struct CreateCabinetBody { pub project_id: i64, } +/// 更换IMEI请求体 +#[derive(Debug, Deserialize)] +pub struct ReplaceImeiBody { + pub imei: String, +} + /// 更新柜子请求体 #[derive(Debug, Deserialize)] pub struct UpdateCabinetBody { pub project_id: Option, pub name: Option, + pub abstract_id: Option, } // --------------------------------------------------------------------------- @@ -59,12 +66,13 @@ pub struct ProjectRow { } /// 柜子表行 -#[derive(Debug, Clone, Serialize, sqlx::FromRow)] +#[derive(Debug, Clone, Serialize)] pub struct CabinetRow { pub id: i64, #[serde(skip_serializing_if = "Option::is_none")] pub project_id: Option, - pub abstract_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub abstract_id: Option, pub imei: String, #[serde(skip_serializing_if = "Option::is_none")] pub iccid: Option, @@ -84,6 +92,10 @@ pub struct CabinetRow { pub full_count: Option, #[serde(skip_serializing_if = "Option::is_none")] pub fault_count: Option, + pub rssi: Option, + pub pow_fail_dc: Option, + pub pow_fail_ac: Option, + pub is_online: Option, } /// 树节点 @@ -133,7 +145,8 @@ pub struct CabinBoardWithCompartments { pub struct CabinetDetail { pub id: i64, pub project_id: Option, - pub abstract_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub abstract_id: Option, pub imei: String, pub name: Option, pub status: i8, @@ -173,7 +186,7 @@ pub async fn list_organizations( let db = &state.mysql; let rows = sqlx::query_as::<_, OrganizationRow>( - r#"SELECT id, name FROM organizations ORDER BY id"#, + r#"SELECT id, name, parent_id FROM organizations ORDER BY id"#, ) .fetch_all(db) .await?; diff --git a/software/api-server/src/workers/report_worker.rs b/software/api-server/src/workers/report_worker.rs index ac9a7b4..c0afe0a 100644 --- a/software/api-server/src/workers/report_worker.rs +++ b/software/api-server/src/workers/report_worker.rs @@ -103,7 +103,7 @@ async fn process_report( "auth_str" => handle_auth_str(&msg, dev_id, redis).await?, "login" => handle_login(&msg, dev_id, redis).await?, "status_post" => handle_status_post(&msg, dev_id, mysql, redis).await?, - "pow_fail" => handle_pow_fail(&msg, dev_id, mysql).await?, + "pow_fail" => handle_pow_fail(&msg, dev_id, mysql, redis).await?, "bat_in" => handle_bat_in(&msg, dev_id, mysql).await?, "bat_out" => handle_bat_out(&msg, dev_id, mysql).await?, "off" => handle_device_off(&msg, dev_id, mysql).await?, @@ -215,15 +215,18 @@ async fn handle_login( let expected_sign = &hex_hash[SIGN_START..SIGN_END]; if sign == expected_sign { - // 标记设备在线(5分钟 TTL) - let online_key = format!("device:online:{}", dev_id); + // 标记设备在线(统一主key,5分钟 TTL) + let redis_key = format!("device:{}", dev_id); let mut conn = redis.clone(); - let _: Result<(), _> = redis::cmd("SET") - .arg(&online_key) - .arg("1") - .arg("EX") + let _: Result<(), _> = redis::cmd("HSET") + .arg(&redis_key) + .arg("online").arg("1") + .query_async(&mut conn) + .await; + let _: Result<(), _> = redis::cmd("EXPIRE") + .arg(&redis_key) .arg(300_u32) - .query_async::<()>(&mut conn) + .query_async(&mut conn) .await; tracing::info!("[login] 成功 dev_id={}", dev_id); @@ -238,41 +241,89 @@ async fn handle_login( Ok(()) } -/// 处理状态上报 — 持久化到 MySQL device_logs +/// 处理状态上报 — 解析status字典,按层级存入Redis +/// Redis结构: +/// device:{imei} — 设备级(online, rssi, pow_fail_dc, pow_fail_ac) +/// device:{imei}:board:{idx} — 板级(status_hex, fault) +/// device:{imei}:board:{idx}:ch:{0-5} — 通道级(on, load, fault, alarm) async fn handle_status_post( msg: &serde_json::Value, dev_id: &str, mysql: &MySqlPool, redis: &ConnectionManager, ) -> Result<(), Box> { - // 存入 Redis 实时状态 - let redis_key = format!("device:status:{}", dev_id); let mut conn = redis.clone(); - let _: Result<(), _> = redis::cmd("SET") - .arg(&redis_key) - .arg(serde_json::to_string(msg).unwrap_or_default()) - .query_async::<()>(&mut conn) + let device_key = format!("device:{}", dev_id); + + // 设置设备在线 + RSSI + let rssi = msg["rssi"].as_i64().unwrap_or(0); + let _: Result<(), _> = redis::cmd("HSET") + .arg(&device_key) + .arg("online").arg("1") + .arg("rssi").arg(rssi.to_string()) + .query_async(&mut conn) + .await; + + // 设置TTL 300秒 + let _: Result<(), _> = redis::cmd("EXPIRE") + .arg(&device_key) + .arg(300_u32) + .query_async(&mut conn) .await; - // 更新设备在线标记(5分钟 TTL) - let online_key = format!("device:online:{}", dev_id); - let _: Result<(), _> = redis::cmd("SET") - .arg(&online_key) - .arg("1") - .arg("EX") - .arg(300_u32) - .query_async::<()>(&mut conn) - .await; + // 解析 status 字典,按层级存入 + if let Some(status_obj) = msg["status"].as_object() { + for (key, value) in status_obj { + // key格式: "IMEI-仓控板ID" → board_idx + let parts: Vec<&str> = key.split('-').collect(); + if parts.len() < 2 { + continue; + } + let board_idx = parts[parts.len() - 1]; // 取最后一段作为板号 + + // 存板级状态(原始hex) + let board_key = format!("device:{}:board:{}", dev_id, board_idx); + let _: Result<(), _> = redis::cmd("HSET") + .arg(&board_key) + .arg("status_hex").arg(value.as_str().unwrap_or("")) + .query_async(&mut conn) + .await; + + // 解析6个通道,每个通道4个hex字符(2字节) + let hex_str = value.as_str().unwrap_or(""); + for ch in 0..6 { + let start = ch * 8; // 每通道8个hex字符 = 4字节 + if start + 8 > hex_str.len() { + break; + } + let ch_hex = &hex_str[start..start + 8]; + + // 每通道4字节,按协议解析 + // 字节1: bit0=在线, bit1=充电中, bit2=充满, bit3=故障 + // 字节2: 负载类型(预留) + // 字节3-4: 预留 + let byte1 = u8::from_str_radix(&ch_hex[0..2], 16).unwrap_or(0); + + let ch_key = format!("device:{}:board:{}:ch:{}", dev_id, board_idx, ch); + let _: Result<(), _> = redis::cmd("HSET") + .arg(&ch_key) + .arg("on").arg(if byte1 & 0x02 != 0 { "1" } else { "0" }) + .arg("full").arg(if byte1 & 0x04 != 0 { "1" } else { "0" }) + .arg("fault").arg(if byte1 & 0x08 != 0 { "1" } else { "0" }) + .arg("hex").arg(ch_hex) + .query_async(&mut conn) + .await; + } + } + } // 持久化到 MySQL let content = serde_json::json!({ "dev_id": dev_id, - "sub_device_id": msg["sub_device_id"], - "status_hex": msg["status"], "rssi": msg["rssi"], + "status": msg["status"], "timestamp": msg["timestamp"], }); - sqlx::query( "INSERT INTO device_logs (cabinet_id, log_type, content) VALUES ( (SELECT id FROM cabinets WHERE imei = ?), 1, ? @@ -283,7 +334,7 @@ async fn handle_status_post( .execute(mysql) .await?; - tracing::debug!("[status_post] 已持久化 dev_id={}", dev_id); + tracing::debug!("[status_post] 已解析并存储 dev_id={}", dev_id); Ok(()) } @@ -292,9 +343,34 @@ async fn handle_pow_fail( msg: &serde_json::Value, dev_id: &str, mysql: &MySqlPool, + redis: &ConnectionManager, ) -> Result<(), Box> { + let info = msg["info"].as_str().unwrap_or("0"); + + // 更新Redis Hash(统一主key) + let redis_key = format!("device:{}", dev_id); + let mut conn = redis.clone(); + + if info == "0" { + // 直流停电 + let _: Result<(), _> = redis::cmd("HSET") + .arg(&redis_key) + .arg("pow_fail_dc").arg("1") + .query_async(&mut conn) + .await; + } else if info == "1" { + // 交流停电 + let _: Result<(), _> = redis::cmd("HSET") + .arg(&redis_key) + .arg("pow_fail_ac").arg("1") + .query_async(&mut conn) + .await; + } + + // 同时存入device_logs let content = serde_json::json!({ "dev_id": dev_id, + "info": info, "timestamp": msg["timestamp"], "sub_device_id": msg["sub_device_id"], }); @@ -309,7 +385,7 @@ async fn handle_pow_fail( .execute(mysql) .await?; - tracing::info!("[pow_fail] 停电上报 dev_id={}", dev_id); + tracing::info!("[pow_fail] 停电上报 dev_id={} info={}", dev_id, info); Ok(()) } diff --git a/software/h5-taro/babel.config.js b/software/h5-taro/babel.config.js new file mode 100644 index 0000000..a49260e --- /dev/null +++ b/software/h5-taro/babel.config.js @@ -0,0 +1,9 @@ +// babel-preset-taro 更多配置和用法:https://docs.taro.zone/docs/next/babel-config +module.exports = { + presets: [ + ['taro', { + framework: 'react', + ts: true, + }], + ], +} diff --git a/software/h5-taro/config/dev.js b/software/h5-taro/config/dev.js new file mode 100644 index 0000000..73170a7 --- /dev/null +++ b/software/h5-taro/config/dev.js @@ -0,0 +1,17 @@ +module.exports = { + env: { + NODE_ENV: '"development"', + }, + h5: { + devServer: { + port: 5174, + proxy: { + '/h5/api': { + target: 'http://10.8.0.252:3000', + changeOrigin: true, + pathRewrite: { '^/h5': '' }, + }, + }, + }, + }, +} diff --git a/software/h5-taro/config/index.js b/software/h5-taro/config/index.js new file mode 100644 index 0000000..b7459c5 --- /dev/null +++ b/software/h5-taro/config/index.js @@ -0,0 +1,62 @@ +const path = require('path') + +const config = { + projectName: 'h5-taro', + date: '2026-07-03', + designWidth: 750, + deviceRatio: { + 640: 2.34 / 2, + 750: 1, + 828: 1.81 / 2, + 375: 2 / 1, + }, + sourceRoot: 'src', + outputRoot: 'dist', + plugins: [], + defineConstants: {}, + copy: { + patterns: [], + options: {}, + }, + framework: 'react', + // compiler: 'webpack4', (default) + sass: { + data: '', + }, + mini: { + postcss: { + pxtransform: { enable: true, config: {} }, + url: { enable: true, config: { limit: 1024 } }, + cssModules: { + enable: false, + config: { namingPattern: 'module', generateScopedName: '[name]__[local]___[hash:base64:5]' }, + }, + }, + }, + h5: { + publicPath: '/h5/', + staticDirectory: 'static', + router: { + mode: 'browser', + customRoutes: { + '/pages/home/index': '/pms/home', + '/pages/devices/index': '/pms/devices', + '/pages/me/index': '/pms/me', + }, + }, + postcss: { + autoprefixer: { enable: true, config: {} }, + cssModules: { + enable: false, + config: { namingPattern: 'module', generateScopedName: '[name]__[local]___[hash:base64:5]' }, + }, + }, + }, +} + +module.exports = function (merge) { + if (process.env.NODE_ENV === 'development') { + return merge({}, config, require('./dev')) + } + return merge({}, config, require('./prod')) +} diff --git a/software/h5-taro/config/prod.js b/software/h5-taro/config/prod.js new file mode 100644 index 0000000..c7746a5 --- /dev/null +++ b/software/h5-taro/config/prod.js @@ -0,0 +1,6 @@ +module.exports = { + env: { + NODE_ENV: '"production"', + }, + outputRoot: 'dist', +} diff --git a/software/h5-taro/package.json b/software/h5-taro/package.json new file mode 100644 index 0000000..7a8b688 --- /dev/null +++ b/software/h5-taro/package.json @@ -0,0 +1,32 @@ +{ + "name": "h5-taro", + "version": "1.0.0", + "private": true, + "description": "安知充 H5 用户端 (Taro)", + "scripts": { + "dev:h5": "taro build --type h5 --watch", + "build:h5": "taro build --type h5", + "dev:weapp": "taro build --type weapp --watch", + "build:weapp": "taro build --type weapp" + }, + "dependencies": { + "@babel/runtime": "^7.23.0", + "@tarojs/components": "3.6.32", + "@tarojs/plugin-framework-react": "^3.6.32", + "@tarojs/plugin-platform-h5": "3.6.32", + "@tarojs/react": "3.6.32", + "@tarojs/runtime": "3.6.32", + "@tarojs/taro": "3.6.32", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "zustand": "^4.5.0" + }, + "devDependencies": { + "@babel/core": "^7.23.0", + "@tarojs/cli": "3.6.32", + "@tarojs/webpack-runner": "3.6.32", + "@types/react": "^18.2.0", + "babel-preset-taro": "3.6.32", + "typescript": "^5.3.0" + } +} diff --git a/software/h5-taro/project.config.json b/software/h5-taro/project.config.json new file mode 100644 index 0000000..3eaac50 --- /dev/null +++ b/software/h5-taro/project.config.json @@ -0,0 +1,30 @@ +{ + "miniprogramRoot": "./dist", + "projectname": "h5-taro", + "description": "安知充 H5 用户端", + "appid": "touristappid", + "setting": { + "urlCheck": true, + "es6": false, + "enhance": false, + "postcss": false, + "preloadBackgroundData": false, + "minified": false, + "newFeature": true, + "coverView": true, + "nodeModules": false, + "autoAudits": false, + "showShadowRootInWxmlPanel": true, + "scopeDataCheck": false, + "uglifyFileName": false, + "checkInvalidKey": true, + "checkSiteMap": true, + "uploadWithSourceMap": true, + "compileHotReLoad": false, + "lazyloadPlaceholderEnable": false, + "useMultiFrameRuntime": true, + "useApiHook": true, + "useApiHostProcess": true + }, + "compileType": "miniprogram" +} diff --git a/software/h5-taro/src/api/index.ts b/software/h5-taro/src/api/index.ts new file mode 100644 index 0000000..c3d91e7 --- /dev/null +++ b/software/h5-taro/src/api/index.ts @@ -0,0 +1,161 @@ +import Taro from '@tarojs/taro' + +const BASE_URL = '/h5/api' + +async function request( + path: string, + options: { method?: string; body?: unknown; params?: Record } = {}, +): Promise { + const token = Taro.getStorageSync('h5_token') + const headers: Record = { + 'Content-Type': 'application/json', + } + if (token) { + headers['Authorization'] = `Bearer ${token}` + } + + let url = `${BASE_URL}${path}` + if (options.params) { + const qs = new URLSearchParams(options.params).toString() + url += `?${qs}` + } + + const res = await Taro.request({ + url, + method: (options.method as 'GET' | 'POST' | 'PUT' | 'DELETE') || 'GET', + header: headers, + data: options.body, + }) + + // ponytail: single error shape, add wrapper if backend uses inconsistent envelopes + if (res.statusCode >= 400) { + const msg = (res.data as any)?.message || (res.data as any)?.error || '请求失败' + throw new Error(msg) + } + + return res.data as T +} + +// ---- Types ---- + +export interface LoginParams { + phone: string + password: string +} + +export interface LoginResult { + token: string + user: H5User +} + +export interface H5User { + id: number + phone: string + name?: string +} + +export interface DashboardData { + online_cabinets: number + charging_count: number + idle_channels: number + fault_channels: number + today_charge_count: number + today_energy_kwh: number + today_charge_hours: number + alerts: { project: string; cabinet: string; channel: number }[] + projects: { id: number; name: string; cabinet_count: number; charging: number; idle: number; fault: number }[] +} + +export interface CabinetItem { + id: number + abstract_id: string + name?: string + status: number // 1=在线 0=离线 + project_id?: number +} + +export interface CompartmentInfo { + id: number + channel_id: number + status: number // 0=空闲 1=充电中 2=故障 3=离线 + voltage?: number + current?: number +} + +export interface CabinBoard { + id: number + board_id: number + status: number + compartments: CompartmentInfo[] +} + +export interface CabinetDetail { + id: number + abstract_id: string + name?: string + imei?: string + status: number + cabin_boards: CabinBoard[] +} + +export interface CompartmentDetail { + id: number + channel_id: number + status: number + voltage?: number + current?: number + power?: number + energy?: number + soc?: number + soh?: number + cell_voltages?: number[] + cell_temps?: number[] + bms_alerts?: string[] + charge_records?: ChargeRecord[] +} + +export interface ChargeRecord { + id: number + start_time: string + end_time?: string + energy_kwh: number + cost: number +} + +// ---- API ---- + +export const h5Api = { + // Auth + login: (params: LoginParams) => + request<{ data: LoginResult }>('/h5/auth/login', { method: 'POST', body: params }), + + // Dashboard + getDashboard: () => + request<{ data: DashboardData }>('/h5/dashboard'), + + // Cabinets + getCabinets: (params?: { project_id?: number }) => + request<{ data: CabinetItem[] }>('/h5/cabinets', { params: params as Record }), + + // Cabinet detail (by abstract_id for QR scan — public endpoint) + getCabinetByAbstract: (abstractId: string) => + request<{ data: CabinetDetail }>(`/h5/cabinets/abstract/${abstractId}`), + + // Cabinet detail by DB id + getCabinetDetail: (id: number) => + request<{ data: CabinetDetail }>(`/h5/cabinets/${id}`), + + // Compartment detail + getCompartmentDetail: (id: number) => + request<{ data: CompartmentDetail }>(`/h5/compartments/${id}`), + + // Actions — match backend POST /api/h5/charge/start, /stop, /door/open with {compartment_id} + startCharge: (compartmentId: number) => + request<{ code: number }>('/h5/charge/start', { method: 'POST', body: { compartment_id: compartmentId } }), + + stopCharge: (compartmentId: number) => + request<{ code: number }>('/h5/charge/stop', { method: 'POST', body: { compartment_id: compartmentId } }), + + openDoor: (compartmentId: number) => + request<{ code: number }>('/h5/door/open', { method: 'POST', body: { compartment_id: compartmentId } }), +} diff --git a/software/h5-taro/src/app.config.ts b/software/h5-taro/src/app.config.ts new file mode 100644 index 0000000..a3c644b --- /dev/null +++ b/software/h5-taro/src/app.config.ts @@ -0,0 +1,43 @@ +export default { + pages: [ + 'pages/login/index', + 'pages/home/index', + 'pages/devices/index', + 'pages/device-detail/index', + 'pages/compartment-detail/index', + 'pages/me/index', + ], + window: { + backgroundTextStyle: 'light', + navigationBarBackgroundColor: '#ffffff', + navigationBarTitleText: '安知充', + navigationBarTextStyle: 'black', + navigationStyle: 'custom', + }, + tabBar: { + color: '#999999', + selectedColor: '#2563eb', + backgroundColor: '#ffffff', + borderStyle: 'black', + list: [ + { + pagePath: 'pages/home/index', + text: '首页', + iconPath: 'assets/home.svg', + selectedIconPath: 'assets/home-active.svg', + }, + { + pagePath: 'pages/devices/index', + text: '设备', + iconPath: 'assets/device.svg', + selectedIconPath: 'assets/device-active.svg', + }, + { + pagePath: 'pages/me/index', + text: '我的', + iconPath: 'assets/me.svg', + selectedIconPath: 'assets/me-active.svg', + }, + ], + }, +} diff --git a/software/h5-taro/src/app.css b/software/h5-taro/src/app.css new file mode 100644 index 0000000..cad1512 --- /dev/null +++ b/software/h5-taro/src/app.css @@ -0,0 +1,66 @@ +/* 全局样式 */ +page { + background-color: #f5f5f5; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 28px; + color: #333; + box-sizing: border-box; +} + +/* 通用重置 */ +view, +text, +image, +button, +input { + box-sizing: border-box; +} + +/* 灰色卡片容器 */ +.card { + background: #ffffff; + border-radius: 16px; + padding: 24px; + margin-bottom: 24px; +} + +/* 状态标签 */ +.status-badge { + display: inline-block; + padding: 4px 16px; + border-radius: 100px; + font-size: 24px; + font-weight: 500; +} + +.status-badge.online { + background-color: #dcfce7; + color: #16a34a; +} + +.status-badge.offline { + background-color: #f3f4f6; + color: #6b7280; +} + +.status-badge.charging { + background-color: #dcfce7; + color: #16a34a; +} + +.status-badge.idle { + background-color: #f3f4f6; + color: #6b7280; +} + +.status-badge.fault { + background-color: #fee2e2; + color: #dc2626; +} + +/* 占位图标(用纯CSS画简单的tab图标) */ +.tab-icon { + width: 48px; + height: 48px; + display: block; +} diff --git a/software/h5-taro/src/app.tsx b/software/h5-taro/src/app.tsx new file mode 100644 index 0000000..ddcd522 --- /dev/null +++ b/software/h5-taro/src/app.tsx @@ -0,0 +1,44 @@ +import React, { useEffect } from 'react' +import { useDidShow, useDidHide } from '@tarojs/taro' +import Taro from '@tarojs/taro' +import { useH5AuthStore } from './store/auth' +import './app.css' + +function App(props: { children: React.ReactNode }) { + const restore = useH5AuthStore((s) => s.restore) + const isAuthenticated = useH5AuthStore((s) => s.isAuthenticated) + + useEffect(() => { + restore() + }, [restore]) + + // Handle QR scan entry: URL rewritten by index.html script + useEffect(() => { + const abstractId: string | undefined = (window as any).__SCAN_ABSTRACT_ID__ + if (!abstractId) return + + if (isAuthenticated) { + import('./api').then(({ h5Api }) => { + h5Api.getCabinetByAbstract(abstractId) + .then((res) => Taro.navigateTo({ url: `/pages/device-detail/index?id=${res.data.id}` })) + .catch(() => Taro.redirectTo({ url: `/pages/login/index?abstract_id=${abstractId}` })) + }) + } else { + Taro.redirectTo({ url: `/pages/login/index?abstract_id=${abstractId}` }) + } + // ponytail: runs once on mount, auth changes handled by redirect + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isAuthenticated]) + + useDidShow(() => { + // ponytail: analytics hook, add when needed + }) + + useDidHide(() => { + // ponytail: cleanup hook, add when needed + }) + + return <>{props.children} +} + +export default App diff --git a/software/h5-taro/src/assets/device-active.svg b/software/h5-taro/src/assets/device-active.svg new file mode 100644 index 0000000..24af383 --- /dev/null +++ b/software/h5-taro/src/assets/device-active.svg @@ -0,0 +1 @@ + diff --git a/software/h5-taro/src/assets/device.svg b/software/h5-taro/src/assets/device.svg new file mode 100644 index 0000000..b4989b3 --- /dev/null +++ b/software/h5-taro/src/assets/device.svg @@ -0,0 +1 @@ + diff --git a/software/h5-taro/src/assets/home-active.svg b/software/h5-taro/src/assets/home-active.svg new file mode 100644 index 0000000..6fcc4b7 --- /dev/null +++ b/software/h5-taro/src/assets/home-active.svg @@ -0,0 +1 @@ + diff --git a/software/h5-taro/src/assets/home.svg b/software/h5-taro/src/assets/home.svg new file mode 100644 index 0000000..1c33fc6 --- /dev/null +++ b/software/h5-taro/src/assets/home.svg @@ -0,0 +1 @@ + diff --git a/software/h5-taro/src/assets/me-active.svg b/software/h5-taro/src/assets/me-active.svg new file mode 100644 index 0000000..fc62f1f --- /dev/null +++ b/software/h5-taro/src/assets/me-active.svg @@ -0,0 +1 @@ + diff --git a/software/h5-taro/src/assets/me.svg b/software/h5-taro/src/assets/me.svg new file mode 100644 index 0000000..d73cfb6 --- /dev/null +++ b/software/h5-taro/src/assets/me.svg @@ -0,0 +1 @@ + diff --git a/software/h5-taro/src/components/ChannelCard.tsx b/software/h5-taro/src/components/ChannelCard.tsx new file mode 100644 index 0000000..0ae802e --- /dev/null +++ b/software/h5-taro/src/components/ChannelCard.tsx @@ -0,0 +1,44 @@ +import React from 'react' +import { View, Text } from '@tarojs/components' +import { ChannelStatusBadge } from './StatusBadge' + +interface Props { + channelId: number + status: number + voltage?: number + current?: number + onClick?: () => void +} + +const COLORS: Record = { + 0: '#9ca3af', // idle + 1: '#16a34a', // charging + 2: '#dc2626', // fault + 3: '#d1d5db', // offline +} + +export default function ChannelCard({ channelId, status, voltage, current, onClick }: Props) { + return ( + + + #{channelId} + + + {voltage !== undefined && ( + {voltage.toFixed(1)}V + )} + {current !== undefined && ( + {current.toFixed(1)}A + )} + + ) +} diff --git a/software/h5-taro/src/components/ConfirmDialog.tsx b/software/h5-taro/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..402796b --- /dev/null +++ b/software/h5-taro/src/components/ConfirmDialog.tsx @@ -0,0 +1,35 @@ +import React from 'react' +import { View, Text } from '@tarojs/components' + +interface Props { + open: boolean + title: string + message: string + onConfirm: () => void + onCancel: () => void +} + +export default function ConfirmDialog({ open, title, message, onConfirm, onCancel }: Props) { + if (!open) return null + return ( + + + + {title} + {message} + + + + 取消 + + + 确定 + + + + + ) +} diff --git a/software/h5-taro/src/components/ErrorState.tsx b/software/h5-taro/src/components/ErrorState.tsx new file mode 100644 index 0000000..f29c927 --- /dev/null +++ b/software/h5-taro/src/components/ErrorState.tsx @@ -0,0 +1,24 @@ +import React from 'react' +import { View, Text, Button } from '@tarojs/components' + +interface Props { + message: string + onRetry?: () => void +} + +export default function ErrorState({ message, onRetry }: Props) { + return ( + + 😵 + {message} + {onRetry && ( + + )} + + ) +} diff --git a/software/h5-taro/src/components/Loading.tsx b/software/h5-taro/src/components/Loading.tsx new file mode 100644 index 0000000..a88a213 --- /dev/null +++ b/software/h5-taro/src/components/Loading.tsx @@ -0,0 +1,12 @@ +import React from 'react' +import { View, Text } from '@tarojs/components' + +export default function Loading({ text = '加载中...' }: { text?: string }) { + return ( + + + {text} + + + ) +} diff --git a/software/h5-taro/src/components/StatusBadge.tsx b/software/h5-taro/src/components/StatusBadge.tsx new file mode 100644 index 0000000..47c467c --- /dev/null +++ b/software/h5-taro/src/components/StatusBadge.tsx @@ -0,0 +1,27 @@ +import React from 'react' +import { View, Text } from '@tarojs/components' + +const STATUS_MAP: Record = { + 0: { label: '空闲', cls: 'idle' }, + 1: { label: '充电中', cls: 'charging' }, + 2: { label: '故障', cls: 'fault' }, + 3: { label: '离线', cls: 'offline' }, +} + +export function CabinetStatusBadge({ status }: { status: number }) { + const s = STATUS_MAP[status] || { label: '未知', cls: 'offline' } + return ( + + {s.label} + + ) +} + +export function ChannelStatusBadge({ status }: { status: number }) { + const s = STATUS_MAP[status] || { label: '未知', cls: 'offline' } + return ( + + {s.label} + + ) +} diff --git a/software/h5-taro/src/index.html b/software/h5-taro/src/index.html new file mode 100644 index 0000000..49400c0 --- /dev/null +++ b/software/h5-taro/src/index.html @@ -0,0 +1,23 @@ + + + + + + 安知充 + + + +
+ + diff --git a/software/h5-taro/src/pages/compartment-detail/index.config.ts b/software/h5-taro/src/pages/compartment-detail/index.config.ts new file mode 100644 index 0000000..990f839 --- /dev/null +++ b/software/h5-taro/src/pages/compartment-detail/index.config.ts @@ -0,0 +1,3 @@ +export default { + navigationBarTitleText: '通道详情', +} diff --git a/software/h5-taro/src/pages/compartment-detail/index.tsx b/software/h5-taro/src/pages/compartment-detail/index.tsx new file mode 100644 index 0000000..d9d73c9 --- /dev/null +++ b/software/h5-taro/src/pages/compartment-detail/index.tsx @@ -0,0 +1,238 @@ +import React, { useEffect, useState } from 'react' +import { View, Text } from '@tarojs/components' +import Taro, { useRouter } from '@tarojs/taro' +import { h5Api, type CompartmentDetail } from '../../api' +import Loading from '../../components/Loading' +import ErrorState from '../../components/ErrorState' +import ConfirmDialog from '../../components/ConfirmDialog' +import { ChannelStatusBadge } from '../../components/StatusBadge' + +export default function CompartmentDetailPage() { + const { params } = useRouter() + const id = params.id + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [actionLoading, setActionLoading] = useState('') + const [confirmAction, setConfirmAction] = useState<'stop' | 'door' | null>(null) + + const fetchDetail = async () => { + if (!id) return + setLoading(true) + setError('') + try { + const res = await h5Api.getCompartmentDetail(Number(id)) + setData(res.data) + } catch { + setError('加载通道详情失败') + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchDetail() + // ponytail: 15s auto-refresh during charging, add WebSocket for real-time + const timer = setInterval(fetchDetail, 15000) + return () => clearInterval(timer) + }, [id]) + + const handleAction = async (action: 'start' | 'stop' | 'door') => { + if (!id || actionLoading) return + setActionLoading(action) + try { + if (action === 'start') { + await h5Api.startCharge(Number(id)) + } else if (action === 'stop') { + await h5Api.stopCharge(Number(id)) + } else if (action === 'door') { + await h5Api.openDoor(Number(id)) + } + setConfirmAction(null) + fetchDetail() + } catch { + Taro.showToast({ title: '操作失败', icon: 'none' }) + } finally { + setActionLoading('') + } + } + + if (loading) return + if (error && !data) return + if (!data) return null + + const isCharging = data.status === 1 + const isIdle = data.status === 0 + + return ( + + {/* Real-time status */} + + + 实时状态 + + + + + {data.soc ?? '-'}% + SOC + + + {data.soh ?? '-'}% + SOH + + + {data.energy?.toFixed(1) ?? '-'} + 能耗(kWh) + + + + + {/* Electrical params */} + + 电气参数 + + + 电压 + {data.voltage?.toFixed(1) ?? '-'} V + + + 电流 + {data.current?.toFixed(1) ?? '-'} A + + + 功率 + {data.power?.toFixed(1) ?? '-'} W + + + 能耗 + {data.energy?.toFixed(3) ?? '-'} kWh + + + + + {/* BMS data */} + {data.cell_voltages && data.cell_voltages.length > 0 && ( + + BMS 数据 + + 单体电压 (V) + + {data.cell_voltages.map((v, i) => ( + + #{i + 1}: {v.toFixed(3)} + + ))} + + + {data.cell_temps && data.cell_temps.length > 0 && ( + + 温度 (°C) + + {data.cell_temps.map((t, i) => ( + 50 ? '#fef2f2' : '#fff7ed', + padding: '4px 12px', borderRadius: 8, fontSize: 24, + color: t > 50 ? '#dc2626' : '#f97316', + }} + > + #{i + 1}: {t.toFixed(1)} + + ))} + + + )} + {data.bms_alerts && data.bms_alerts.length > 0 && ( + + 告警 + {data.bms_alerts.map((alert, i) => ( + • {alert} + ))} + + )} + + )} + + {/* Charge records */} + {data.charge_records && data.charge_records.length > 0 && ( + + 充电记录 + {data.charge_records.slice(0, 5).map((record) => ( + + + {record.start_time.slice(0, 16)} + {record.end_time && {record.end_time.slice(0, 16)}} + + + {record.energy_kwh.toFixed(2)} kWh + ¥{record.cost.toFixed(2)} + + + ))} + + )} + + {/* Action buttons */} + + {isIdle && ( + handleAction('start')} + > + + {actionLoading === 'start' ? '操作中...' : '开始充电'} + + + )} + {isCharging && ( + setConfirmAction('stop')} + > + + {actionLoading === 'stop' ? '操作中...' : '停止充电'} + + + )} + setConfirmAction('door')} + > + + {actionLoading === 'door' ? '操作中...' : '开门'} + + + + + {/* Confirm dialogs */} + handleAction('stop')} + onCancel={() => setConfirmAction(null)} + /> + handleAction('door')} + onCancel={() => setConfirmAction(null)} + /> + + ) +} diff --git a/software/h5-taro/src/pages/device-detail/index.config.ts b/software/h5-taro/src/pages/device-detail/index.config.ts new file mode 100644 index 0000000..b9f400f --- /dev/null +++ b/software/h5-taro/src/pages/device-detail/index.config.ts @@ -0,0 +1,3 @@ +export default { + navigationBarTitleText: '设备详情', +} diff --git a/software/h5-taro/src/pages/device-detail/index.tsx b/software/h5-taro/src/pages/device-detail/index.tsx new file mode 100644 index 0000000..b12833a --- /dev/null +++ b/software/h5-taro/src/pages/device-detail/index.tsx @@ -0,0 +1,79 @@ +import React, { useEffect, useState } from 'react' +import { View, Text } from '@tarojs/components' +import Taro, { useRouter } from '@tarojs/taro' +import { h5Api, type CabinetDetail } from '../../api' +import Loading from '../../components/Loading' +import ErrorState from '../../components/ErrorState' +import ChannelCard from '../../components/ChannelCard' +import { CabinetStatusBadge } from '../../components/StatusBadge' + +export default function DeviceDetailPage() { + const { params } = useRouter() + const id = params.id + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + const fetchDetail = async () => { + if (!id) return + setLoading(true) + setError('') + try { + const res = await h5Api.getCabinetDetail(Number(id)) + setData(res.data) + } catch { + setError('加载设备详情失败') + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchDetail() + }, [id]) + + if (loading) return + if (error) return + if (!data) return null + + return ( + + {/* Basic info */} + + + + {data.name || data.abstract_id} + ID: {data.abstract_id} + {data.imei && IMEI: {data.imei}} + + + + + + {/* Cabin boards */} + {data.cabin_boards.map((board) => ( + + + 仓板 {board.board_id} + + ({board.status === 1 ? '在线' : '离线'}) + + + + {board.compartments.map((comp) => ( + + Taro.navigateTo({ url: `/pages/compartment-detail/index?id=${comp.id}` })} + /> + + ))} + + + ))} + + ) +} diff --git a/software/h5-taro/src/pages/devices/index.config.ts b/software/h5-taro/src/pages/devices/index.config.ts new file mode 100644 index 0000000..9d2def3 --- /dev/null +++ b/software/h5-taro/src/pages/devices/index.config.ts @@ -0,0 +1,3 @@ +export default { + navigationBarTitleText: '设备', +} diff --git a/software/h5-taro/src/pages/devices/index.tsx b/software/h5-taro/src/pages/devices/index.tsx new file mode 100644 index 0000000..7fe1027 --- /dev/null +++ b/software/h5-taro/src/pages/devices/index.tsx @@ -0,0 +1,62 @@ +import React, { useEffect, useState } from 'react' +import { View, Text } from '@tarojs/components' +import Taro from '@tarojs/taro' +import { h5Api, type CabinetItem } from '../../api' +import Loading from '../../components/Loading' +import ErrorState from '../../components/ErrorState' +import { CabinetStatusBadge } from '../../components/StatusBadge' + +export default function DevicesPage() { + const [list, setList] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + const fetchList = async () => { + setLoading(true) + setError('') + try { + const res = await h5Api.getCabinets() + setList(res.data || []) + } catch { + setError('加载设备列表失败') + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchList() + }, []) + + if (loading) return + if (error) return fetchList()} /> + + return ( + + {/* List */} + {list.length === 0 ? ( + + 暂无设备 + + ) : ( + + {list.map((item) => ( + Taro.navigateTo({ url: `/pages/device-detail/index?id=${item.id}` })} + > + + + {item.name || item.abstract_id} + + + + ID: {item.abstract_id} + + ))} + + )} + + ) +} diff --git a/software/h5-taro/src/pages/home/index.config.ts b/software/h5-taro/src/pages/home/index.config.ts new file mode 100644 index 0000000..86e3b2e --- /dev/null +++ b/software/h5-taro/src/pages/home/index.config.ts @@ -0,0 +1,3 @@ +export default { + navigationBarTitleText: '首页', +} diff --git a/software/h5-taro/src/pages/home/index.tsx b/software/h5-taro/src/pages/home/index.tsx new file mode 100644 index 0000000..769d23d --- /dev/null +++ b/software/h5-taro/src/pages/home/index.tsx @@ -0,0 +1,113 @@ +import React, { useEffect, useState } from 'react' +import { View, Text } from '@tarojs/components' +import Taro from '@tarojs/taro' +import { h5Api, type DashboardData } from '../../api' +import { useH5AuthStore } from '../../store/auth' +import Loading from '../../components/Loading' + +export default function HomePage() { + const user = useH5AuthStore((s) => s.user) + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + + const fetchDashboard = async () => { + setLoading(true) + try { + const res = await h5Api.getDashboard() + setData(res.data) + } catch { + // ponytail: silent fail on dashboard, page shows zeros + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchDashboard() + }, []) + + if (loading) return + + return ( + + {/* Welcome */} + + + {user?.name ? `${user.name},你好` : '你好'} + + 欢迎使用安知充充电柜 + + + {/* Stats Grid — flex 2x2 */} + + + {data?.online_cabinets ?? 0} + 在线设备 + + + {data?.charging_count ?? 0} + 充电中 + + + {data?.idle_channels ?? 0} + 空闲仓体 + + + + {data?.fault_channels ?? 0} + + 故障 + + + + {/* Today summary */} + + 今日概况 + + + {data?.today_charge_count ?? 0} + 充电次数 + + + {data?.today_energy_kwh?.toFixed(1) ?? '0.0'} + 充电量(kWh) + + + {data?.today_charge_hours?.toFixed(1) ?? '0.0'} + 时长(h) + + + + + {/* Fault alerts */} + {data?.alerts && data.alerts.length > 0 && ( + + 故障告警 + {data.alerts.slice(0, 5).map((alert, i) => ( + + {alert.cabinet} — 通道 {alert.channel} + {alert.project} + + ))} + + )} + + {/* Quick Actions */} + + 快捷操作 + Taro.switchTab({ url: '/pages/devices/index' })} + > + 查看设备 + + Taro.switchTab({ url: '/pages/me/index' })} + > + 个人中心 + + + + ) +} diff --git a/software/h5-taro/src/pages/login/index.config.ts b/software/h5-taro/src/pages/login/index.config.ts new file mode 100644 index 0000000..a11f33e --- /dev/null +++ b/software/h5-taro/src/pages/login/index.config.ts @@ -0,0 +1,4 @@ +export default { + navigationBarTitleText: '登录', + navigationStyle: 'custom', +} diff --git a/software/h5-taro/src/pages/login/index.tsx b/software/h5-taro/src/pages/login/index.tsx new file mode 100644 index 0000000..1b202bf --- /dev/null +++ b/software/h5-taro/src/pages/login/index.tsx @@ -0,0 +1,89 @@ +import React, { useState } from 'react' +import { View, Text, Input, Button } from '@tarojs/components' +import Taro, { useRouter } from '@tarojs/taro' +import { useH5AuthStore } from '../../store/auth' +import { h5Api } from '../../api' + +export default function LoginPage() { + const { params } = useRouter() + const abstractId = params.abstract_id + const [phone, setPhone] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const { login, loading } = useH5AuthStore() + + const handleLogin = async () => { + if (!phone || !password) { + setError('请输入手机号和密码') + return + } + setError('') + try { + await login(phone, password) + if (abstractId) { + // 扫码进来的,登录后跳转设备详情 + try { + const res = await h5Api.getCabinetByAbstract(abstractId) + Taro.navigateTo({ url: `/pages/device-detail/index?id=${res.data.id}` }) + } catch { + Taro.switchTab({ url: '/pages/home/index' }) + } + } else { + Taro.switchTab({ url: '/pages/home/index' }) + } + } catch (e: any) { + setError(e.message || '登录失败') + } + } + + return ( + + {/* Header */} + + 安知充 + 智能充电柜管理系统 + + + {/* Form */} + + + 手机号 + setPhone(e.detail.value)} + /> + + + 密码 + setPassword(e.detail.value)} + /> + + + {error && ( + {error} + )} + + + + + ) +} diff --git a/software/h5-taro/src/pages/me/index.config.ts b/software/h5-taro/src/pages/me/index.config.ts new file mode 100644 index 0000000..c4f5af2 --- /dev/null +++ b/software/h5-taro/src/pages/me/index.config.ts @@ -0,0 +1,3 @@ +export default { + navigationBarTitleText: '我的', +} diff --git a/software/h5-taro/src/pages/me/index.tsx b/software/h5-taro/src/pages/me/index.tsx new file mode 100644 index 0000000..4f76f9d --- /dev/null +++ b/software/h5-taro/src/pages/me/index.tsx @@ -0,0 +1,70 @@ +import React, { useState } from 'react' +import { View, Text } from '@tarojs/components' +import Taro from '@tarojs/taro' +import { useH5AuthStore } from '../../store/auth' +import ConfirmDialog from '../../components/ConfirmDialog' + +export default function MePage() { + const { user, logout } = useH5AuthStore() + const [showLogout, setShowLogout] = useState(false) + + const handleLogout = () => { + logout() + Taro.reLaunch({ url: '/pages/login/index' }) + } + + return ( + + {/* User info */} + + + + {(user?.name || user?.phone || '?').charAt(0)} + + + + {user?.name || '用户'} + {user?.phone} + + + + {/* Menu items */} + + Taro.showToast({ title: '功能开发中', icon: 'none' })} + > + 修改密码 + + + + + {/* Logout */} + + setShowLogout(true)} + > + 退出登录 + + + + setShowLogout(false)} + /> + + ) +} diff --git a/software/h5-taro/src/store/auth.ts b/software/h5-taro/src/store/auth.ts new file mode 100644 index 0000000..951746e --- /dev/null +++ b/software/h5-taro/src/store/auth.ts @@ -0,0 +1,53 @@ +import { create } from 'zustand' +import Taro from '@tarojs/taro' +import { h5Api, type H5User } from '../api' + +interface AuthState { + token: string | null + user: H5User | null + loading: boolean + isAuthenticated: boolean + login: (phone: string, password: string) => Promise + logout: () => void + restore: () => void +} + +export const useH5AuthStore = create((set) => ({ + token: null, + user: null, + loading: false, + isAuthenticated: false, + + login: async (phone: string, password: string) => { + set({ loading: true }) + try { + const res = await h5Api.login({ phone, password }) + const { token, user } = res.data + Taro.setStorageSync('h5_token', token) + Taro.setStorageSync('h5_user', JSON.stringify(user)) + set({ token, user, isAuthenticated: true, loading: false }) + } catch { + set({ loading: false }) + throw new Error('登录失败,请检查手机号和密码') + } + }, + + logout: () => { + Taro.removeStorageSync('h5_token') + Taro.removeStorageSync('h5_user') + set({ token: null, user: null, isAuthenticated: false }) + }, + + restore: () => { + try { + const token = Taro.getStorageSync('h5_token') + const raw = Taro.getStorageSync('h5_user') + if (token) { + const user = raw ? (JSON.parse(raw) as H5User) : null + set({ token, user, isAuthenticated: true }) + } + } catch { + // ponytail: storage read failure, not worth crashing over + } + }, +})) diff --git a/software/h5-taro/tsconfig.json b/software/h5-taro/tsconfig.json new file mode 100644 index 0000000..8e150c2 --- /dev/null +++ b/software/h5-taro/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "es2017", + "module": "commonjs", + "lib": ["es2017", "dom"], + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/software/h5/index.html b/software/h5/index.html deleted file mode 100644 index 390911e..0000000 --- a/software/h5/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - 安知充 - - -
- - - diff --git a/software/h5/package.json b/software/h5/package.json deleted file mode 100644 index c932d74..0000000 --- a/software/h5/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "pms-h5", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc -b && vite build", - "preview": "vite preview" - }, - "dependencies": { - "react": "^19.2.7", - "react-dom": "^19.2.7", - "react-router-dom": "^7.18.1", - "zustand": "^5.0.14" - }, - "devDependencies": { - "@tailwindcss/vite": "^4.3.2", - "@types/node": "^24.13.2", - "@types/react": "^19.2.17", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.3", - "tailwindcss": "^4.3.2", - "typescript": "~6.0.2", - "vite": "^8.1.1" - } -} diff --git a/software/h5/src/App.tsx b/software/h5/src/App.tsx deleted file mode 100644 index 8f18913..0000000 --- a/software/h5/src/App.tsx +++ /dev/null @@ -1,71 +0,0 @@ -/** - * H5 用户端根组件 - */ -import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; -import { useH5AuthStore } from './auth'; -import H5Layout from './layout'; -import H5Login from './pages/login'; -import H5Home from './pages/home'; -import H5Devices from './pages/devices'; -import H5DeviceDetail from './pages/device-detail'; -import H5CompartmentDetail from './pages/compartment-detail'; -import H5Me from './pages/me'; -import { Loading } from './components'; -import { useEffect, useState } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; -import { h5Api } from './api'; - -/** 扫码入口处理 */ -function ScanRedirect() { - const { abstractId } = useParams<{ abstractId: string }>(); - const navigate = useNavigate(); - const [error, setError] = useState(''); - - useEffect(() => { - if (!abstractId || ['login', 'devices', 'me', 'compartments'].includes(abstractId)) { - return; - } - h5Api.getCabinetByAbstractId(abstractId) - .then((res) => navigate(`/devices/${res.data.id}`, { replace: true })) - .catch(() => setError('未找到该设备')); - }, [abstractId, navigate]); - - if (error) { - return ( -
-

{error}

-
- ); - } - - return ( -
- -
- ); -} - -export default function App() { - const restore = useH5AuthStore((s) => s.restore); - - useEffect(() => { - restore(); - }, [restore]); - - return ( - - - } /> - }> - } /> - } /> - } /> - } /> - - } /> - } /> - } /> - - - ); -} diff --git a/software/h5/src/api/devices.ts b/software/h5/src/api/devices.ts deleted file mode 100644 index 4d68666..0000000 --- a/software/h5/src/api/devices.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * 设备管理 API - * 组织/项目/柜子 CRUD + 树状结构 - */ -import { api } from './request'; - -export interface Organization { - id: number; - name: string; -} - -export interface Project { - id: number; - organization_id?: number; - name: string; -} - -export interface Cabinet { - id: number; - project_id?: number; - abstract_id: string; - imei: string; - auth_str?: string; - name?: string; - status: number; -} - -export interface Compartment { - id: number; - cabin_board_id: number; - channel_id: number; - status: number; -} - -export interface CabinBoard { - id: number; - board_id: number; - status: number; - compartments: Compartment[]; -} - -export interface CabinetDetail extends Cabinet { - cabin_boards: CabinBoard[]; -} - -export interface TreeNode { - id: number; - name: string; - children?: TreeNode[]; - abstract_id?: string; - imei?: string; - status?: number; -} - -// 组织 API -export const orgApi = { - list: () => api.get('/organizations'), - create: (data: { name: string }) => api.post('/organizations', data), - update: (id: number, data: { name: string }) => api.put(`/organizations/${id}`, data), - delete: (id: number) => api.delete(`/organizations/${id}`), -}; - -// 项目 API -export const projectApi = { - list: (orgId: number) => api.get(`/organizations/${orgId}/projects`), - create: (orgId: number, data: { name: string }) => - api.post(`/organizations/${orgId}/projects`, data), - update: (id: number, data: { name: string }) => api.put(`/projects/${id}`, data), - delete: (id: number) => api.delete(`/projects/${id}`), -}; - -// 柜子 API -export const cabinetApi = { - list: (projectId: number) => api.get(`/projects/${projectId}/cabinets`), - create: (data: { imeis: string[]; project_id: number }) => - api.post<{ ids: number[] }>('/cabinets', data), - update: (id: number, data: { project_id?: number; name?: string }) => - api.put(`/cabinets/${id}`, data), - delete: (id: number) => api.delete(`/cabinets/${id}`), - regenerateAuth: (id: number) => api.post<{ auth_str: string }>(`/cabinets/${id}/regenerate-auth`), - getDetail: (id: number) => api.get(`/cabinets/${id}`), - // 接触器控制 - powOn: (id: number) => api.post(`/cabinets/${id}/pow-on`), - powOff: (id: number) => api.post(`/cabinets/${id}/pow-off`), -}; - -// 组织树 API -export const treeApi = { - get: () => api.get('/organization-tree'), -}; diff --git a/software/h5/src/api/permission.ts b/software/h5/src/api/permission.ts deleted file mode 100644 index 880dd6d..0000000 --- a/software/h5/src/api/permission.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * 权限管理 API - * - * 角色管理、权限码查询、用户权限配置等接口封装 - */ - -import { api } from '@/api/request'; - -/** 角色信息 */ -export interface Role { - id: number; - name: string; - description?: string; - permission_codes: string[]; -} - -/** 权限码信息 */ -export interface PermissionItem { - id: number; - code: string; - name: string; -} - -/** 用户权限范围条目 */ -export interface ScopeEntry { - id?: number; - /** 1组织 2项目 3设备 */ - scope_type: number; - /** 对应的组织/项目/设备ID */ - scope_id: number; - /** 1查看 2操作 */ - permission_type: number; -} - -/** 用户权限详情 */ -export interface UserPermissionDetail { - user_id: number; - name: string; - phone: string; - role_level: number; - organization_id: number | null; - roles: Role[]; - permission_codes: string[]; - scopes: ScopeEntry[]; -} - -// ── 角色管理 ── - -/** 获取所有角色列表 */ -export async function fetchRoles(): Promise { - const res = await api.get('/roles'); - return res.data ?? []; -} - -/** 创建角色 */ -export async function createRole(name: string, description?: string) { - return api.post('/roles', { name, description }); -} - -/** 更新角色 */ -export async function updateRole(id: number, name: string, description?: string) { - return api.put(`/roles/${id}`, { name, description }); -} - -/** 删除角色 */ -export async function deleteRole(id: number) { - return api.delete(`/roles/${id}`); -} - -/** 设置角色权限(全量替换) */ -export async function setRolePermissions(roleId: number, permissionCodes: string[]) { - return api.put(`/roles/${roleId}/permissions`, { permission_codes: permissionCodes }); -} - -// ── 权限码 ── - -/** 获取所有权限码列表 */ -export async function fetchPermissions(): Promise { - const res = await api.get('/permissions'); - return res.data ?? []; -} - -// ── 用户权限 ── - -/** 获取用户权限详情 */ -export async function fetchUserPermissions(userId: number): Promise { - const res = await api.get(`/users/${userId}/permissions`); - return res.data; -} - -/** 设置用户角色(全量替换) */ -export async function setUserRoles(userId: number, roleIds: number[]) { - return api.put(`/users/${userId}/roles`, { role_ids: roleIds }); -} - -/** 设置用户权限范围(全量替换) */ -export async function setUserScopes(userId: number, scopes: ScopeEntry[]) { - return api.put(`/users/${userId}/scopes`, { scopes }); -} - -/** 获取用户权限范围 */ -export async function fetchUserScopes(userId: number): Promise { - const res = await api.get(`/users/${userId}/scopes`); - return res.data ?? []; -} diff --git a/software/h5/src/api/request.ts b/software/h5/src/api/request.ts deleted file mode 100644 index 2d9af9b..0000000 --- a/software/h5/src/api/request.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * HTTP 请求封装 - * - 自动注入 Authorization token - * - 统一异常处理 - * - 类型友好的响应 - */ - -const API_BASE = import.meta.env.VITE_API_BASE || '/pms/api'; - -export interface ApiResponse { - code: number; - data: T; - message: string; -} - -export class ApiError extends Error { - public code: number; - - constructor( - code: number, - message: string, - ) { - super(message); - this.name = 'ApiError'; - this.code = code; - } -} - -function getToken(): string | null { - return localStorage.getItem('token'); -} - -async function request( - url: string, - options: RequestInit = {}, -): Promise> { - const token = getToken(); - const headers: Record = { - 'Content-Type': 'application/json', - ...(options.headers as Record), - }; - - if (token) { - headers['Authorization'] = `Bearer ${token}`; - } - - try { - const res = await fetch(`${API_BASE}${url}`, { - ...options, - headers, - }); - - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new ApiError(res.status, body.message || res.statusText); - } - - return res.json(); - } catch (err) { - if (err instanceof ApiError) throw err; - throw new ApiError(0, (err as Error).message || '网络错误'); - } -} - -export const api = { - get: (url: string) => request(url), - post: (url: string, data?: unknown) => - request(url, { method: 'POST', body: JSON.stringify(data) }), - put: (url: string, data?: unknown) => - request(url, { method: 'PUT', body: JSON.stringify(data) }), - delete: (url: string) => request(url, { method: 'DELETE' }), -}; diff --git a/software/h5/src/api/user.ts b/software/h5/src/api/user.ts deleted file mode 100644 index 3008b3f..0000000 --- a/software/h5/src/api/user.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * 用户管理和操作日志 API - */ - -import { api } from '@/api/request'; - -// ── 用户管理 ── - -/** 用户信息 */ -export interface User { - id: number; - phone: string; - name: string | null; - role: number; - role_text: string; - organization_id: number | null; - organization_name: string | null; - status: number; - created_at: string; -} - -/** 用户列表响应 */ -export interface UserListData { - list: User[]; - total: number; -} - -/** 创建用户参数 */ -export interface CreateUserParams { - phone: string; - name?: string; - role?: number; - organization_id?: number | null; - password?: string; -} - -/** 编辑用户参数 */ -export interface UpdateUserParams { - name?: string; - role?: number; - organization_id?: number | null; -} - -/** 用户列表(分页) */ -export async function fetchUsers(params: { - page?: number; - page_size?: number; - keyword?: string; -}): Promise { - const searchParams = new URLSearchParams(); - if (params.page) searchParams.set('page', String(params.page)); - if (params.page_size) searchParams.set('page_size', String(params.page_size)); - if (params.keyword) searchParams.set('keyword', params.keyword); - const res = await api.get(`/users?${searchParams.toString()}`); - return res.data; -} - -/** 创建用户 */ -export async function createUser(data: CreateUserParams) { - return api.post('/users', data); -} - -/** 编辑用户 */ -export async function updateUser(id: number, data: UpdateUserParams) { - return api.put(`/users/${id}`, data); -} - -/** 重置密码 */ -export async function resetPassword(id: number, password?: string) { - return api.put(`/users/${id}/reset-password`, { password }); -} - -/** 启用/禁用用户 */ -export async function setUserStatus(id: number, status: number) { - return api.put(`/users/${id}/status`, { status }); -} - -/** 删除用户 */ -export async function deleteUser(id: number) { - return api.delete(`/users/${id}`); -} - -// ── 操作日志 ── - -/** 操作日志项 */ -export interface OperationLog { - id: number; - user_id: number; - user_name: string; - action: string; - target_type: string | null; - target_id: number | null; - detail: string | null; - created_at: string; -} - -/** 操作日志列表响应 */ -export interface OperationLogListData { - list: OperationLog[]; - total: number; -} - -/** 操作日志列表(分页+过滤) */ -export async function fetchOperationLogs(params: { - user_id?: number; - action?: string; - start_time?: string; - end_time?: string; - page?: number; - page_size?: number; -}): Promise { - const searchParams = new URLSearchParams(); - if (params.user_id) searchParams.set('user_id', String(params.user_id)); - if (params.action) searchParams.set('action', params.action); - if (params.start_time) searchParams.set('start_time', params.start_time); - if (params.end_time) searchParams.set('end_time', params.end_time); - if (params.page) searchParams.set('page', String(params.page)); - if (params.page_size) searchParams.set('page_size', String(params.page_size)); - const res = await api.get(`/operation-logs?${searchParams.toString()}`); - return res.data; -} diff --git a/software/h5/src/h5/api.ts b/software/h5/src/h5/api.ts deleted file mode 100644 index 8285b4d..0000000 --- a/software/h5/src/h5/api.ts +++ /dev/null @@ -1,154 +0,0 @@ -/** - * H5 用户端 API 服务 - * 封装所有 H5 端接口调用 - */ -import { api } from '@/api/request'; - -// ─── 数据类型 ─── - -export interface DashboardData { - online_cabinets: number; - charging_count: number; - idle_channels: number; - fault_channels: number; - today_charge_count: number; - today_charge_hours: number; - today_energy_kwh: number; - alerts: AlertItem[]; - projects: ProjectSummary[]; -} - -export interface AlertItem { - project: string; - cabinet: string; - channel: number; - alert_type: string; -} - -export interface ProjectSummary { - id: number; - name: string; - cabinet_count: number; - charging: number; - idle: number; - fault: number; -} - -export interface H5Project { - id: number; - name: string; -} - -export interface H5Cabinet { - id: number; - abstract_id: string; - name?: string; - status: number; // 0=离线 1=在线 - project_id: number; -} - -export interface CompartmentStatus { - id: number; - channel_id: number; - status: number; // 0=空闲 1=充电中 2=故障 3=离线 - voltage?: number; - current?: number; - power?: number; - energy?: number; - soc?: number; // 荷电状态 % - soh?: number; // 健康度 % - cell_voltages?: number[]; - cell_temps?: number[]; - bms_alerts?: string[]; -} - -export interface CabinBoardStatus { - id: number; - board_id: number; - status: number; - compartments: CompartmentStatus[]; -} - -export interface CabinetDetail { - id: number; - abstract_id: string; - imei: string; - name?: string; - status: number; - cabin_boards: CabinBoardStatus[]; -} - -export interface CompartmentDetail { - id: number; - channel_id: number; - status: number; - voltage: number; - current: number; - power: number; - energy: number; - soc: number; - soh: number; - cell_voltages: number[]; - cell_temps: number[]; - bms_alerts: string[]; - charge_records: ChargeRecord[]; -} - -export interface ChargeRecord { - id: number; - start_time: string; - end_time?: string; - energy_kwh: number; - cost: number; -} - -export interface H5User { - id: number; - phone: string; - name: string; -} - -export interface H5LoginResponse { - token: string; - user: H5User; -} - -// ─── API 函数 ─── - -export const h5Api = { - // 认证 - login: (data: { phone: string; password: string }) => - api.post('/h5/auth/login', data), - - // 首页仪表盘 - getDashboard: () => api.get('/h5/dashboard'), - - // 项目列表 - getProjects: () => api.get('/h5/projects'), - - // 设备列表(按项目) - getCabinets: (projectId?: number) => - api.get(`/h5/cabinets${projectId ? `?project_id=${projectId}` : ''}`), - - // 设备详情(含仓板+仓体状态) - getCabinetDetail: (id: number) => - api.get(`/h5/cabinets/${id}`), - - // 通过 abstract_id 获取设备详情(扫码) - getCabinetByAbstractId: (abstractId: string) => - api.get(`/h5/cabinets/abstract/${abstractId}`), - - // 仓体/通道详情 - getCompartmentDetail: (id: number) => - api.get(`/h5/compartments/${id}`), - - // 充电控制 - startCharge: (compartmentId: number) => - api.post<{ success: boolean }>('/h5/charge/start', { compartment_id: compartmentId }), - stopCharge: (compartmentId: number) => - api.post<{ success: boolean }>('/h5/charge/stop', { compartment_id: compartmentId }), - - // 开门 - openDoor: (compartmentId: number) => - api.post<{ success: boolean }>('/h5/door/open', { compartment_id: compartmentId }), -}; diff --git a/software/h5/src/h5/auth.ts b/software/h5/src/h5/auth.ts deleted file mode 100644 index 3ec76c8..0000000 --- a/software/h5/src/h5/auth.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * H5 用户端认证状态管理 - * - 与后台管理独立,使用不同 token - * - localStorage key 使用 h5_ 前缀避免冲突 - */ -import { create } from 'zustand'; -import { h5Api, type H5User } from './api'; - -const TOKEN_KEY = 'h5_token'; -const USER_KEY = 'h5_user'; - -interface AuthState { - token: string | null; - user: H5User | null; - loading: boolean; - isAuthenticated: boolean; - login: (phone: string, password: string) => Promise; - logout: () => void; - restore: () => void; -} - -export const useH5AuthStore = create((set) => ({ - token: null, - user: null, - loading: false, - isAuthenticated: false, - - login: async (phone: string, password: string) => { - set({ loading: true }); - try { - const res = await h5Api.login({ phone, password }); - const { token, user } = res.data; - localStorage.setItem(TOKEN_KEY, token); - localStorage.setItem(USER_KEY, JSON.stringify(user)); - set({ token, user, isAuthenticated: true, loading: false }); - } catch { - set({ loading: false }); - throw new Error('登录失败,请检查手机号和密码'); - } - }, - - logout: () => { - localStorage.removeItem(TOKEN_KEY); - localStorage.removeItem(USER_KEY); - set({ token: null, user: null, isAuthenticated: false }); - }, - - restore: () => { - const token = localStorage.getItem(TOKEN_KEY); - const raw = localStorage.getItem(USER_KEY); - if (token) { - const user = raw ? JSON.parse(raw) as H5User : null; - set({ token, user, isAuthenticated: true }); - } - }, -})); diff --git a/software/h5/src/h5/components.tsx b/software/h5/src/h5/components.tsx deleted file mode 100644 index 9cc0a6d..0000000 --- a/software/h5/src/h5/components.tsx +++ /dev/null @@ -1,249 +0,0 @@ -/** - * H5 共享组件:底部导航、统计卡片、通道卡片、加载/错误状态 - */ -import { type ReactNode } from 'react'; -import { useNavigate, useLocation } from 'react-router-dom'; - -// ─── 底部导航 ─── - -const tabs = [ - { key: '/pms', label: '首页', icon: HomeIcon }, - { key: '/pms/devices', label: '设备', icon: DeviceIcon }, - { key: '/pms/me', label: '我的', icon: MeIcon }, -]; - -export function BottomNav() { - const navigate = useNavigate(); - const location = useLocation(); - - const activeKey = '/' + location.pathname.split('/').slice(0, 3).join('/'); - - return ( - - ); -} - -function HomeIcon({ active }: { active: boolean }) { - return ( - - - - ); -} - -function DeviceIcon({ active }: { active: boolean }) { - return ( - - - - ); -} - -function MeIcon({ active }: { active: boolean }) { - return ( - - - - ); -} - -// ─── 统计卡片 ─── - -interface StatCardProps { - label: string; - value: string | number; - sub?: string; - color?: string; -} - -export function StatCard({ label, value, sub, color = 'blue' }: StatCardProps) { - const colors: Record = { - blue: 'bg-blue-50 text-blue-700', - green: 'bg-green-50 text-green-700', - orange: 'bg-orange-50 text-orange-700', - red: 'bg-red-50 text-red-700', - gray: 'bg-gray-50 text-gray-700', - }; - - return ( -
- {value} - {label} - {sub && {sub}} -
- ); -} - -// ─── 告警项 ─── - -interface AlertCardProps { - project: string; - cabinet: string; - channel: number; - alert_type: string; -} - -export function AlertCard({ project, cabinet, channel, alert_type }: AlertCardProps) { - return ( -
- ⚠️ -
- {project} - - {cabinet} 通道{channel} -

{alert_type}

-
-
- ); -} - -// ─── 项目卡片 ─── - -interface ProjectCardProps { - name: string; - cabinet_count: number; - charging: number; - idle: number; - fault: number; - onClick: () => void; -} - -export function ProjectCard({ name, cabinet_count, charging, idle, fault, onClick }: ProjectCardProps) { - return ( - - ); -} - -// ─── 通道卡片 ─── - -interface ChannelCardProps { - channelId: number; - status: number; // 0=空闲 1=充电中 2=故障 3=离线 - voltage?: number; - current?: number; - onClick: () => void; -} - -const statusConfig: Record = { - 0: { label: '空闲', bg: 'bg-gray-50', dot: 'bg-gray-400' }, - 1: { label: '充电中', bg: 'bg-green-50', dot: 'bg-green-500' }, - 2: { label: '故障', bg: 'bg-red-50', dot: 'bg-red-500' }, - 3: { label: '离线', bg: 'bg-gray-100', dot: 'bg-gray-400' }, -}; - -export function ChannelCard({ channelId, status, voltage, current, onClick }: ChannelCardProps) { - const cfg = statusConfig[status] || statusConfig[3]; - - return ( - - ); -} - -// ─── 加载状态 ─── - -export function Loading() { - return ( -
-
- 加载中... -
- ); -} - -// ─── 错误状态 ─── - -export function ErrorState({ message, onRetry }: { message: string; onRetry?: () => void }) { - return ( -
- 😵 -

{message}

- {onRetry && ( - - )} -
- ); -} - -// ─── 页面容器 ─── - -export function PageContainer({ children, title, onBack }: { children: ReactNode; title?: string; onBack?: () => void }) { - return ( -
- {title && ( -
- {onBack && ( - - )} -

{title}

-
- )} -
{children}
-
- ); -} - -// ─── 确认弹窗 ─── - -export function ConfirmDialog({ open, title, message, onConfirm, onCancel }: { - open: boolean; - title: string; - message: string; - onConfirm: () => void; - onCancel: () => void; -}) { - if (!open) return null; - return ( -
-
e.stopPropagation()}> -

{title}

-

{message}

-
- - -
-
-
- ); -} diff --git a/software/h5/src/h5/layout.tsx b/software/h5/src/h5/layout.tsx deleted file mode 100644 index b897205..0000000 --- a/software/h5/src/h5/layout.tsx +++ /dev/null @@ -1,36 +0,0 @@ -/** - * H5 移动端布局 - * - 底部 Tab 导航(首页/设备/我的) - * - 使用 Outlet 渲染子页面 - */ -import { Navigate, Outlet, useLocation } from 'react-router-dom'; -import { useH5AuthStore } from './auth'; -import { BottomNav } from './components'; - -export default function H5Layout() { - const { isAuthenticated } = useH5AuthStore(); - const location = useLocation(); - - // 未认证时重定向到登录页,保留目标地址 - if (!isAuthenticated) { - return ; - } - - return ( -
-
- -
- -
- ); -} - -/** 不带底部导航的 H5 布局(登录页、通道详情等全屏页面) */ -export function H5PlainLayout({ children }: { children: React.ReactNode }) { - return ( -
-
{children}
-
- ); -} diff --git a/software/h5/src/h5/pages/compartment-detail.tsx b/software/h5/src/h5/pages/compartment-detail.tsx deleted file mode 100644 index f3a79e5..0000000 --- a/software/h5/src/h5/pages/compartment-detail.tsx +++ /dev/null @@ -1,253 +0,0 @@ -/** - * H5 通道详情 - * - 实时状态(SOC/SOH) - * - 电气参数(电压/电流/功率/能耗) - * - BMS数据(单体电压/温度/告警) - * - 充电曲线(示意) - * - 操作按钮(停止充电、开门) - */ -import { useEffect, useState } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; -import { h5Api, type CompartmentDetail } from '../api'; -import { Loading, ErrorState, PageContainer, ConfirmDialog } from '../components'; - -export default function H5CompartmentDetail() { - const { id } = useParams<{ id: string }>(); - const navigate = useNavigate(); - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); - const [actionLoading, setActionLoading] = useState(''); - const [confirmAction, setConfirmAction] = useState<'stop' | 'door' | null>(null); - - const fetchDetail = async () => { - if (!id) return; - setLoading(true); - setError(''); - try { - const res = await h5Api.getCompartmentDetail(Number(id)); - setData(res.data); - } catch { - setError('加载通道详情失败'); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - fetchDetail(); - // ponytail: 15s auto-refresh during charging, add WebSocket for real-time - const timer = setInterval(fetchDetail, 15000); - return () => clearInterval(timer); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [id]); - - const handleAction = async (action: 'start' | 'stop' | 'door') => { - if (!id || actionLoading) return; - setActionLoading(action); - try { - if (action === 'start') { - await h5Api.startCharge(Number(id)); - } else if (action === 'stop') { - await h5Api.stopCharge(Number(id)); - } else if (action === 'door') { - await h5Api.openDoor(Number(id)); - } - setConfirmAction(null); - fetchDetail(); - } catch { - setError('操作失败,请重试'); - } finally { - setActionLoading(''); - } - }; - - if (loading) return ; - if (error && !data) return ; - if (!data) return null; - - const statusLabel: Record = { 0: '空闲', 1: '充电中', 2: '故障', 3: '离线' }; - const isCharging = data.status === 1; - const isIdle = data.status === 0; - - return ( - navigate(-1)}> - {/* 实时状态 */} -
-
-

实时状态

- - {statusLabel[data.status] || '未知'} - -
-
-
-

{data.soc ?? '-'}%

-

SOC

-
-
-

{data.soh ?? '-'}%

-

SOH

-
-
-

{data.energy?.toFixed(1) ?? '-'}

-

能耗(kWh)

-
-
-
- - {/* 电气参数 */} -
-

电气参数

-
-
- 电压 - {data.voltage?.toFixed(1) ?? '-'} V -
-
- 电流 - {data.current?.toFixed(1) ?? '-'} A -
-
- 功率 - {data.power?.toFixed(1) ?? '-'} W -
-
- 能耗 - {data.energy?.toFixed(3) ?? '-'} kWh -
-
-
- - {/* BMS 数据 */} - {data.cell_voltages && data.cell_voltages.length > 0 && ( -
-

BMS 数据

- {/* 单体电压 */} -
-

单体电压 (V)

-
- {data.cell_voltages.map((v, i) => ( - - #{i + 1}: {v.toFixed(3)} - - ))} -
-
- {/* 单体温度 */} - {data.cell_temps && data.cell_temps.length > 0 && ( -
-

温度 (°C)

-
- {data.cell_temps.map((t, i) => ( - 50 ? 'bg-red-50 text-red-700' : 'bg-orange-50 text-orange-700'}`}> - #{i + 1}: {t.toFixed(1)} - - ))} -
-
- )} - {/* BMS 告警 */} - {data.bms_alerts && data.bms_alerts.length > 0 && ( -
-

告警

-
    - {data.bms_alerts.map((alert, i) => ( -
  • {alert}
  • - ))} -
-
- )} -
- )} - - {/* 充电曲线示意 */} - {isCharging && ( -
-

充电曲线

- {/* TODO: 待后端提供充电曲线API后替换为真实数据 */} -
- {[30, 45, 55, 50, 65, 70, 75, 72, 80, 85, 82, 88].map((h, i) => ( -
- ))} -
-
- 12:00 - 当前 -
- {/* ponytail: static mock chart, replace with real chart lib when backend provides data */} -
- )} - - {/* 充电记录 */} - {data.charge_records && data.charge_records.length > 0 && ( -
-

充电记录

-
- {data.charge_records.slice(0, 5).map((record) => ( -
-
-

{record.start_time.slice(0, 16)}

- {record.end_time &&

{record.end_time.slice(0, 16)}

} -
-
-

{record.energy_kwh.toFixed(2)} kWh

-

¥{record.cost.toFixed(2)}

-
-
- ))} -
-
- )} - - {/* 操作按钮 */} -
- {isIdle && ( - - )} - {isCharging && ( - - )} - -
- - {/* 确认弹窗 */} - handleAction('stop')} - onCancel={() => setConfirmAction(null)} - /> - handleAction('door')} - onCancel={() => setConfirmAction(null)} - /> -
- ); -} diff --git a/software/h5/src/h5/pages/device-detail.tsx b/software/h5/src/h5/pages/device-detail.tsx deleted file mode 100644 index d617b47..0000000 --- a/software/h5/src/h5/pages/device-detail.tsx +++ /dev/null @@ -1,84 +0,0 @@ -/** - * H5 设备详情(柜子视图) - * - 柜子基本信息 - * - 仓板列表,每板6个通道 - * - 点击通道进入通道详情 - */ -import { useEffect, useState } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; -import { h5Api, type CabinetDetail } from '../api'; -import { ChannelCard, Loading, ErrorState, PageContainer } from '../components'; - -export default function H5DeviceDetail() { - const { id } = useParams<{ id: string }>(); - const navigate = useNavigate(); - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); - - const fetchDetail = async () => { - if (!id) return; - setLoading(true); - setError(''); - try { - const res = await h5Api.getCabinetDetail(Number(id)); - setData(res.data); - } catch { - setError('加载设备详情失败'); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - fetchDetail(); - // ponytail: no periodic refresh, add if real-time needed - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [id]); - - if (loading) return ; - if (error) return ; - if (!data) return null; - - return ( - navigate('/pms/devices')}> - {/* 设备基本信息 */} -
-
-
-

{data.name || data.abstract_id}

-

ID: {data.abstract_id}

- {data.imei &&

IMEI: {data.imei}

} -
- - {data.status === 1 ? '在线' : '离线'} - -
-
- - {/* 仓板列表 */} - {data.cabin_boards.map((board) => ( -
-

- 仓板 {board.board_id} - - ({board.status === 1 ? '在线' : '离线'}) - -

-
- {board.compartments.map((comp) => ( - navigate(`/pms/compartments/${comp.id}`)} - /> - ))} -
-
- ))} -
- ); -} diff --git a/software/h5/src/h5/pages/devices.tsx b/software/h5/src/h5/pages/devices.tsx deleted file mode 100644 index 0648092..0000000 --- a/software/h5/src/h5/pages/devices.tsx +++ /dev/null @@ -1,129 +0,0 @@ -/** - * H5 设备页 - * - 项目列表 → 选择项目后显示设备列表 - * - 点击设备进入设备详情 - */ -import { useEffect, useState } from 'react'; -import { useNavigate, useSearchParams } from 'react-router-dom'; -import { h5Api, type H5Project, type H5Cabinet } from '../api'; -import { Loading, ErrorState, PageContainer } from '../components'; - -type ViewMode = 'projects' | 'cabinets'; - -export default function H5Devices() { - const [searchParams] = useSearchParams(); - const projectId = searchParams.get('project_id'); - const navigate = useNavigate(); - - const [mode, setMode] = useState(projectId ? 'cabinets' : 'projects'); - const [projects, setProjects] = useState([]); - const [cabinets, setCabinets] = useState([]); - const [selectedProject, setSelectedProject] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); - - const fetchProjects = async () => { - setLoading(true); - setError(''); - try { - const res = await h5Api.getProjects(); - setProjects(res.data); - } catch { - setError('加载项目列表失败'); - } finally { - setLoading(false); - } - }; - - const fetchCabinets = async (pid: number) => { - setLoading(true); - setError(''); - try { - const res = await h5Api.getCabinets(pid); - setCabinets(res.data); - } catch { - setError('加载设备列表失败'); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - if (projectId) { - const pid = Number(projectId); - setMode('cabinets'); - setSelectedProject(projects.find((p) => p.id === pid) || null); - fetchCabinets(pid); - } else { - setMode('projects'); - fetchProjects(); - } - // ponytail: projects refetched on mount, ok for current scope - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [projectId]); - - const handleProjectClick = (project: H5Project) => { - setSelectedProject(project); - setMode('cabinets'); - navigate(`/pms/devices?project_id=${project.id}`, { replace: true }); - fetchCabinets(project.id); - }; - - const handleBack = () => { - setMode('projects'); - setSelectedProject(null); - setCabinets([]); - navigate('/pms/devices', { replace: true }); - }; - - if (loading) return ; - if (error) return selectedProject && fetchCabinets(selectedProject.id)} />; - - return ( - - {mode === 'projects' ? ( -
- {projects.length === 0 &&

暂无项目

} - {projects.map((project) => ( - - ))} -
- ) : ( -
- {cabinets.length === 0 &&

暂无设备

} - {cabinets.map((cabinet) => ( - - ))} -
- )} -
- ); -} diff --git a/software/h5/src/h5/pages/home.tsx b/software/h5/src/h5/pages/home.tsx deleted file mode 100644 index f4e0f6f..0000000 --- a/software/h5/src/h5/pages/home.tsx +++ /dev/null @@ -1,102 +0,0 @@ -/** - * H5 首页 Dashboard - * - 统计卡片(在线/充电中/空闲/故障) - * - 今日统计 - * - 告警通知 - * - 项目列表 - */ -import { useEffect, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { h5Api, type DashboardData } from '../api'; -import { StatCard, AlertCard, ProjectCard, Loading, ErrorState } from '../components'; - -export default function H5Home() { - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); - const navigate = useNavigate(); - - const fetchData = async () => { - setLoading(true); - setError(''); - try { - const res = await h5Api.getDashboard(); - setData(res.data); - } catch { - setError('加载失败,请重试'); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - fetchData(); - // ponytail: 30s auto-refresh, add WebSocket if real-time needed - }, []); - - if (loading) return ; - if (error) return ; - if (!data) return null; - - return ( -
- {/* 统计卡片 */} -
-

设备概览

-
- - - - -
-
- - {/* 今日统计 */} -
-

今日统计

-
-
-

{data.today_charge_count}

-

充电次数

-
-
-
-

{data.today_charge_hours}

-

充电时长(h)

-
-
-
-

{data.today_energy_kwh.toLocaleString()}

-

能耗(kWh)

-
-
-
- - {/* 告警通知 */} - {data.alerts.length > 0 && ( -
-

告警通知

-
- {data.alerts.map((alert, i) => ( - - ))} -
-
- )} - - {/* 项目列表 */} -
-

项目列表

-
- {data.projects.map((project) => ( - navigate(`/pms/devices?project_id=${project.id}`)} - /> - ))} -
-
-
- ); -} diff --git a/software/h5/src/h5/pages/login.tsx b/software/h5/src/h5/pages/login.tsx deleted file mode 100644 index c3e861d..0000000 --- a/software/h5/src/h5/pages/login.tsx +++ /dev/null @@ -1,101 +0,0 @@ -/** - * 登录页 - * 深色科技风格,与产品介绍页统一 - */ -import { useState } from 'react'; -import { useNavigate, useSearchParams } from 'react-router-dom'; -import { useH5AuthStore } from '../auth'; - -export default function H5Login() { - const [phone, setPhone] = useState(''); - const [password, setPassword] = useState(''); - const [error, setError] = useState(''); - const { login, loading } = useH5AuthStore(); - const navigate = useNavigate(); - const [searchParams] = useSearchParams(); - const redirect = searchParams.get('redirect') || '/pms'; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setError(''); - if (!phone || !password) { - setError('请输入手机号和密码'); - return; - } - try { - await login(phone, password); - navigate(redirect, { replace: true }); - } catch (err) { - setError((err as Error).message); - } - }; - - return ( -
- {/* 背景网格 */} -
- - {/* 内容 */} -
- {/* Logo */} -
-
- - - -
-

安知充

-

充电柜管理系统

-
- - {/* 登录表单 */} -
-
- setPhone(e.target.value)} - autoComplete="tel" - /> -
-
- setPassword(e.target.value)} - autoComplete="current-password" - /> -
- - {error && ( -

{error}

- )} - - -
- - {/* 底部信息 */} -
-

无锡艾动电子有限公司

-
-
-
- ); -} diff --git a/software/h5/src/h5/pages/me.tsx b/software/h5/src/h5/pages/me.tsx deleted file mode 100644 index 6772500..0000000 --- a/software/h5/src/h5/pages/me.tsx +++ /dev/null @@ -1,63 +0,0 @@ -/** - * H5 我的页面 - * - 个人信息展示 - * - 退出登录 - */ -import { useNavigate } from 'react-router-dom'; -import { useH5AuthStore } from '../auth'; -import { PageContainer, ConfirmDialog } from '../components'; -import { useState } from 'react'; - -export default function H5Me() { - const { user, logout } = useH5AuthStore(); - const navigate = useNavigate(); - const [showLogout, setShowLogout] = useState(false); - - const handleLogout = () => { - logout(); - navigate('/pms/login', { replace: true }); - }; - - return ( - - {/* 用户信息 */} -
-
- {(user?.name || user?.phone || '?').charAt(0)} -
-
-

{user?.name || '用户'}

-

{user?.phone}

-
-
- - {/* 菜单项 */} -
- - {/* ponytail: password change page placeholder, add when backend endpoint ready */} -
- -
- -
- - setShowLogout(false)} - /> -
- ); -} diff --git a/software/h5/src/h5/routes.tsx b/software/h5/src/h5/routes.tsx deleted file mode 100644 index 1f18032..0000000 --- a/software/h5/src/h5/routes.tsx +++ /dev/null @@ -1,82 +0,0 @@ -/** - * H5 路由配置 - * 所有路由相对于 /h5/ 基路径 - */ -import { Route } from 'react-router-dom'; -import H5Layout from './layout'; -import H5Login from './pages/login'; -import H5Home from './pages/home'; -import H5Devices from './pages/devices'; -import H5DeviceDetail from './pages/device-detail'; -import H5CompartmentDetail from './pages/compartment-detail'; -import H5Me from './pages/me'; - -/** - * H5 路由片段: - * - * /login - 登录 - * / - 首页(需认证,底部导航) - * /devices - 设备列表(需认证,底部导航) - * /devices/:id - 设备详情(需认证,底部导航) - * /compartments/:id - 通道详情(需认证,全屏) - * /me - 我的(需认证,底部导航) - * /:abstractId - 扫码入口 - */ -export const h5Routes = ( - <> - {/* 登录页 - 无底部导航 */} - } /> - - {/* H5 主布局(含底部导航 + 认证守卫) */} - }> - } /> - } /> - } /> - } /> - - - {/* 通道详情 - 全屏页面,直接挂载 */} - } /> - - {/* 扫码入口: /{abstract_id} → 设备详情 */} - } /> - -); - -/** 扫码入口处理:通过 abstract_id 查设备详情 */ -import { useEffect, useState } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; -import { h5Api } from './api'; -import { Loading } from './components'; - -function ScanRedirect() { - const { abstractId } = useParams<{ abstractId: string }>(); - const navigate = useNavigate(); - const [error, setError] = useState(''); - - useEffect(() => { - // 排除已知路径 - if (!abstractId || - ['login', 'devices', 'me', 'compartments'].includes(abstractId)) { - return; - } - - h5Api.getCabinetByAbstractId(abstractId) - .then((res) => navigate(`/devices/${res.data.id}`, { replace: true })) - .catch(() => setError('未找到该设备')); - }, [abstractId, navigate]); - - if (error) { - return ( -
-

{error}

-
- ); - } - - return ( -
- -
- ); -} diff --git a/software/h5/src/index.css b/software/h5/src/index.css deleted file mode 100644 index f1d8c73..0000000 --- a/software/h5/src/index.css +++ /dev/null @@ -1 +0,0 @@ -@import "tailwindcss"; diff --git a/software/h5/src/main.tsx b/software/h5/src/main.tsx deleted file mode 100644 index 964aeb4..0000000 --- a/software/h5/src/main.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App' -import './index.css' - -ReactDOM.createRoot(document.getElementById('root')!).render( - - - , -) diff --git a/software/h5/tsconfig.app.json b/software/h5/tsconfig.app.json deleted file mode 100644 index 8a2c607..0000000 --- a/software/h5/tsconfig.app.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "target": "es2023", - "lib": ["ES2023", "DOM"], - "module": "esnext", - "types": ["vite/client"], - "allowArbitraryExtensions": true, - "skipLibCheck": true, - "ignoreDeprecations": "6.0", - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "moduleDetection": "force", - "noEmit": true, - "jsx": "react-jsx", - - /* Linting */ - "noUnusedLocals": true, - "noUnusedParameters": true, - "erasableSyntaxOnly": true, - "noFallthroughCasesInSwitch": true, - "baseUrl": ".", - "paths": { - "@/*": ["src/*"] - } - }, - "include": ["src"] -} diff --git a/software/h5/tsconfig.json b/software/h5/tsconfig.json deleted file mode 100644 index 1ffef60..0000000 --- a/software/h5/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "files": [], - "references": [ - { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } - ] -} diff --git a/software/h5/tsconfig.node.json b/software/h5/tsconfig.node.json deleted file mode 100644 index 8455dcb..0000000 --- a/software/h5/tsconfig.node.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", - "target": "es2023", - "lib": ["ES2023"], - "types": ["node"], - "skipLibCheck": true, - - /* Bundler mode */ - "module": "nodenext", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "moduleDetection": "force", - "noEmit": true, - - /* Linting */ - "noUnusedLocals": true, - "noUnusedParameters": true, - "erasableSyntaxOnly": true, - "noFallthroughCasesInSwitch": true - }, - "include": ["vite.config.ts"] -} diff --git a/software/h5/vite.config.ts b/software/h5/vite.config.ts deleted file mode 100644 index d304e71..0000000 --- a/software/h5/vite.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' -import tailwindcss from '@tailwindcss/vite' -import path from 'path' - -export default defineConfig({ - base: '/h5/', - plugins: [react(), tailwindcss()], - resolve: { - alias: { - '@': path.resolve(__dirname, 'src'), - }, - }, - server: { - port: 5174, - proxy: { - '/api': { - target: 'http://localhost:3000', - changeOrigin: true, - }, - }, - }, -}) diff --git a/software/web/package-lock.json b/software/web/package-lock.json index f114740..0fdef77 100644 --- a/software/web/package-lock.json +++ b/software/web/package-lock.json @@ -9,6 +9,8 @@ "version": "0.0.0", "dependencies": { "@arco-design/web-react": "^2.66.15", + "qrcode": "^1.5.4", + "qrcode.react": "^4.2.0", "react": "^19.2.7", "react-dom": "^19.2.7", "react-router-dom": "^7.18.1", @@ -18,6 +20,7 @@ "devDependencies": { "@tailwindcss/vite": "^4.3.2", "@types/node": "^24.13.2", + "@types/qrcode": "^1.5.6", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", @@ -1219,6 +1222,16 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmmirror.com/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.17", "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.17.tgz", @@ -1271,6 +1284,48 @@ } } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansi-styles/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/ansi-styles/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/b-tween": { "version": "0.3.3", "resolved": "https://registry.npmmirror.com/b-tween/-/b-tween-0.3.3.tgz", @@ -1283,6 +1338,26 @@ "integrity": "sha512-iCvCkGFskbaYtfQ0a3GmcQCHl/Sv1GufXFGuUQ+FE+WJa7A/espLOuFIn09B944V8/ImPj71T4+rTASxO2PAuA==", "license": "MIT" }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmmirror.com/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", @@ -1479,6 +1554,15 @@ "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", "license": "MIT" }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmmirror.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz", @@ -1501,6 +1585,12 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmmirror.com/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -1511,6 +1601,12 @@ "csstype": "^3.0.2" } }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/enhanced-resolve": { "version": "5.21.6", "resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", @@ -1559,6 +1655,19 @@ } } }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/focus-lock": { "version": "1.3.6", "resolved": "https://registry.npmmirror.com/focus-lock/-/focus-lock-1.3.6.tgz", @@ -1586,6 +1695,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -1618,6 +1736,15 @@ "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", "license": "MIT" }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.7.0.tgz", @@ -1907,6 +2034,18 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", @@ -2018,6 +2157,51 @@ } } }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", @@ -2038,6 +2222,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.16", "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.16.tgz", @@ -2084,6 +2277,32 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmmirror.com/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react": { "version": "19.2.7", "resolved": "https://registry.npmmirror.com/react/-/react-19.2.7.tgz", @@ -2268,6 +2487,21 @@ "redux": "^5.0.0" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/reselect": { "version": "5.2.0", "resolved": "https://registry.npmmirror.com/reselect/-/reselect-5.2.0.tgz", @@ -2329,6 +2563,12 @@ "compute-scroll-into-view": "^1.0.20" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", @@ -2360,6 +2600,32 @@ "node": ">=0.10.0" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tailwindcss": { "version": "4.3.2", "resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.3.2.tgz", @@ -2583,6 +2849,67 @@ } } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmmirror.com/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/zustand": { "version": "5.0.14", "resolved": "https://registry.npmmirror.com/zustand/-/zustand-5.0.14.tgz", diff --git a/software/web/package.json b/software/web/package.json index 1bde22f..63ee6a6 100644 --- a/software/web/package.json +++ b/software/web/package.json @@ -11,6 +11,8 @@ }, "dependencies": { "@arco-design/web-react": "^2.66.15", + "qrcode": "^1.5.4", + "qrcode.react": "^4.2.0", "react": "^19.2.7", "react-dom": "^19.2.7", "react-router-dom": "^7.18.1", @@ -20,6 +22,7 @@ "devDependencies": { "@tailwindcss/vite": "^4.3.2", "@types/node": "^24.13.2", + "@types/qrcode": "^1.5.6", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", diff --git a/software/web/src/api/devices.ts b/software/web/src/api/devices.ts index 11d5d0b..150a821 100644 --- a/software/web/src/api/devices.ts +++ b/software/web/src/api/devices.ts @@ -18,7 +18,7 @@ export interface Project { export interface Cabinet { id: number; project_id?: number; - abstract_id: string; + abstract_id?: string; imei: string; iccid?: string; auth_str?: string; @@ -31,6 +31,10 @@ export interface Cabinet { charging_count?: number; full_count?: number; fault_count?: number; + rssi?: number; + pow_fail_dc?: boolean; + pow_fail_ac?: boolean; + is_online?: boolean; } export interface Compartment { @@ -82,9 +86,12 @@ export const cabinetApi = { list: (projectId: number) => api.get(`/projects/${projectId}/cabinets`), create: (data: { imeis: string[]; project_id: number }) => api.post<{ ids: number[] }>('/cabinets', data), - update: (id: number, data: { project_id?: number; name?: string }) => + update: (id: number, data: { project_id?: number; name?: string; abstract_id?: string }) => api.put(`/cabinets/${id}`, data), delete: (id: number) => api.delete(`/cabinets/${id}`), + bind: (id: number) => api.post<{ abstract_id: string }>(`/cabinets/${id}/bind`), + replaceImei: (id: number, data: { imei: string }) => + api.post(`/cabinets/${id}/replace-imei`, data), regenerateAuth: (id: number) => api.post<{ auth_str: string }>(`/cabinets/${id}/regenerate-auth`), getDetail: (id: number) => api.get(`/cabinets/${id}`), // 接触器控制 @@ -92,13 +99,21 @@ export const cabinetApi = { powOff: (id: number) => api.post(`/cabinets/${id}/pow-off`), }; +/** 分页响应 */ +export interface PaginatedResponse { + total: number; + data: T[]; +} + // 组织树 API export const treeApi = { get: () => api.get('/organization-tree'), - filterCabinets: (params: { organization_id?: number; project_id?: number }) => { + filterCabinets: (params: { organization_id?: number; project_id?: number; page?: number; page_size?: number }) => { const query = new URLSearchParams(); if (params.organization_id) query.set('organization_id', String(params.organization_id)); if (params.project_id) query.set('project_id', String(params.project_id)); - return api.get(`/cabinets/filter?${query.toString()}`); + if (params.page) query.set('page', String(params.page)); + if (params.page_size) query.set('page_size', String(params.page_size)); + return api.get>(`/cabinets/filter?${query.toString()}`); }, }; diff --git a/software/web/src/pages/devices/CabinetGrid.tsx b/software/web/src/pages/devices/CabinetGrid.tsx index da92648..6f4b940 100644 --- a/software/web/src/pages/devices/CabinetGrid.tsx +++ b/software/web/src/pages/devices/CabinetGrid.tsx @@ -2,32 +2,212 @@ * 柜子卡片网格组件 * - 展示柜子列表(卡片形式) * - 点击跳转到柜子详情 + * - 绑定/换绑/二维码操作 */ +import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Card, - Tag, Spin, Empty, + Tooltip, + Message, + Input, + Button, + Modal, } from '@arco-design/web-react'; -import { IconLocation, IconPoweroff, IconCheckCircle, IconInfoCircle } from '@arco-design/web-react/icon'; +import { + IconLocation, + IconWifi, + IconEdit, + IconCopy, + IconCheck, + IconClose, + IconLink, + IconSwap, + IconCodeSquare, +} from '@arco-design/web-react/icon'; +import { QRCodeSVG } from 'qrcode.react'; +import QRCode from 'qrcode'; +import { cabinetApi } from '@/api/devices'; import type { Cabinet } from '@/api/devices'; -/** 柜子状态映射 */ -const STATUS_MAP: Record = { - 0: { label: '离线', color: 'gray', icon: }, - 1: { label: '在线', color: 'green', icon: }, - 2: { label: '充电中', color: 'blue', icon: }, - 3: { label: '故障', color: 'red', icon: }, -}; - interface CabinetGridProps { cabinets: Cabinet[]; loading: boolean; + onRefresh: () => void; } -export default function CabinetGrid({ cabinets, loading }: CabinetGridProps) { +export default function CabinetGrid({ cabinets, loading, onRefresh }: CabinetGridProps) { const navigate = useNavigate(); + const [editingId, setEditingId] = useState(null); + const [editValue, setEditValue] = useState(''); + + // 二维码弹窗 + const [qrCabinet, setQrCabinet] = useState<{ id: number; abstractId: string } | null>(null); + + // 换绑弹窗 + const [replaceCabinetId, setReplaceCabinetId] = useState(null); + const [newImei, setNewImei] = useState(''); + const [replaceLoading, setReplaceLoading] = useState(false); + + /** 复制文本到剪贴板 */ + const copyText = async (text: string, label: string) => { + try { + await navigator.clipboard.writeText(text); + Message.success(`${label} 已复制`); + } catch { + Message.error('复制失败'); + } + }; + + /** 保存名称 */ + const saveName = async (id: number) => { + const name = editValue.trim(); + if (!name) { + Message.warning('名称不能为空'); + return; + } + try { + await cabinetApi.update(id, { name }); + Message.success('已更新'); + setEditingId(null); + onRefresh(); + } catch { + Message.error('更新失败'); + } + }; + + /** 绑定抽象ID */ + const handleBind = async (id: number, e: { stopPropagation?: () => void }) => { + e.stopPropagation?.(); + try { + const res = await cabinetApi.bind(id); + Message.success(`绑定成功: ${res.data.abstract_id}`); + onRefresh(); + } catch { + Message.error('绑定失败'); + } + }; + + /** 换绑IMEI */ + const handleReplaceImei = async () => { + if (!replaceCabinetId) return; + const imei = newImei.trim(); + if (imei.length !== 15 || !/^\d{15}$/.test(imei)) { + Message.warning('IMEI 必须为15位数字'); + return; + } + setReplaceLoading(true); + try { + await cabinetApi.replaceImei(replaceCabinetId, { imei }); + Message.success('换绑成功'); + setReplaceCabinetId(null); + setNewImei(''); + onRefresh(); + } catch (e: unknown) { + if (e instanceof Error) { + // 检查是否为冲突错误(IMEI已被用) + if (e.message.includes('CONFLICT') || e.message.includes('已被')) { + Modal.confirm({ + title: 'IMEI 已被使用', + content: e.message + ',是否解绑旧柜子并使用?', + okButtonProps: { status: 'danger' }, + onOk: async () => { + try { + await cabinetApi.replaceImei(replaceCabinetId, { imei }); + Message.success('换绑成功'); + setReplaceCabinetId(null); + setNewImei(''); + onRefresh(); + } catch { + Message.error('换绑失败'); + } + }, + }); + return; + } + } + Message.error('换绑失败'); + } finally { + setReplaceLoading(false); + } + }; + + /** 获取信号强度图标和颜色(渐变:100绿→35红) */ + const getSignalIcon = (rssi?: number) => { + if (!rssi || rssi === 0) return { icon: , color: 'var(--color-text-4)', text: '离线' }; + const hue = Math.max(0, Math.min(120, ((rssi - 35) / 65) * 120)); + const color = `hsl(${hue}, 70%, 45%)`; + const label = rssi >= 70 ? '强' : rssi >= 50 ? '中' : rssi >= 35 ? '弱' : '极弱'; + return { icon: , color, text: `${rssi} (${label})` }; + }; + + /** 直流图标 */ + const DcIcon = () => ( + + DC + + ); + + /** 交流图标 */ + const AcIcon = () => ( + + AC + + ); + + /** 小标签:AC/DC供电状态 */ + const PowTag = ({ fail, label }: { fail?: boolean | null; label: string }) => { + const bg = fail === true ? '#f53f3f' : fail === false ? '#00b42a' : '#86909c'; + const Icon = label === 'AC' ? AcIcon : DcIcon; + return ( + + + + + + ); + }; + + /** 下载二维码图片 */ + const handleDownloadQR = async (abstractId: string) => { + const canvas = document.createElement('canvas'); + await QRCode.toCanvas(canvas, `https://app.anzhizhichong.com/h5/${abstractId}`, { + width: 300, + margin: 2, + }); + + const qrSize = canvas.width; + const textHeight = 40; + const totalHeight = qrSize + textHeight; + + const wrapper = document.createElement('canvas'); + wrapper.width = qrSize; + wrapper.height = totalHeight; + const ctx = wrapper.getContext('2d')!; + ctx.fillStyle = '#fff'; + ctx.fillRect(0, 0, qrSize, totalHeight); + ctx.drawImage(canvas, 0, 0); + ctx.fillStyle = '#333'; + ctx.font = 'bold 14px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(abstractId, qrSize / 2, qrSize + 26); + + wrapper.toBlob((blob) => { + if (!blob) return; + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${abstractId}.png`; + a.click(); + URL.revokeObjectURL(url); + }); + }; return ( <> @@ -36,57 +216,198 @@ export default function CabinetGrid({ cabinets, loading }: CabinetGridProps) { ) : cabinets.length === 0 ? ( ) : ( -
+
{cabinets.map((cab) => { - const st = STATUS_MAP[cab.status] || { label: '未知', color: 'gray', icon: }; + const signal = getSignalIcon(cab.rssi); return ( - navigate(`/devices/cabinet/${cab.id}`)} - style={{ cursor: 'pointer' }} - > -
- {/* 标题行:名称 + 状态图标 */} -
- - {cab.name || cab.abstract_id} - -
- {st.icon} - {st.label} -
+ navigate(`/devices/cabinet/${cab.id}`)} + style={{ cursor: 'pointer' }} + > +
+ {/* 第一行:绑定码 + 操作按钮(左)+ 状态(右) */} +
+
+ {cab.abstract_id ? ( + <> + {cab.abstract_id} + { e.stopPropagation(); copyText(cab.abstract_id!, '绑定码'); }} + /> + + )}
- - {/* IMEI 和 ICCID */} -
-
IMEI: {cab.imei || '-'}
-
ICCID: {cab.iccid || '-'}
+
+ + + + + {signal.icon} + {cab.rssi || '-'} + +
- - {/* 通道状态 */} -
- 通道: {cab.channel_count || 36} - 空闲: {cab.idle_count || 0} - 充电: {cab.charging_count || 0} - 充满: {cab.full_count || 0} - 故障: {cab.fault_count || 0} -
- - {/* 地址 */} - {cab.address && ( -
- - {cab.address} -
+
+ + {/* 第二行:名字 */} +
+ {editingId === cab.id ? ( + <> + saveName(cab.id)} + autoFocus + /> + { e.stopPropagation(); saveName(cab.id); }} + /> + { e.stopPropagation(); setEditingId(null); }} + /> + + ) : ( + <> + + {cab.name || `未命名`} + + { + e.stopPropagation(); + setEditingId(cab.id); + setEditValue(cab.name || ''); + }} + /> + )}
- + + {/* IMEI 和 ICCID */} +
+
+ IMEI: {cab.imei || '-'} + {cab.imei && ( + { e.stopPropagation(); copyText(cab.imei!, 'IMEI'); }} + /> + )} +
+
+ ICCID: {cab.iccid || '-'} + {cab.iccid && ( + { e.stopPropagation(); copyText(cab.iccid!, 'ICCID'); }} + /> + )} +
+
+ + {/* 通道状态 */} +
+ 通道: {cab.channel_count || 36} + 空闲: {cab.idle_count || 0} + 充电: {cab.charging_count || 0} + 充满: {cab.full_count || 0} + 故障: {cab.fault_count || 0} +
+ + {/* 地址 */} + {cab.address && ( +
+ + {cab.address} +
+ )} +
+ ); })}
)} + + {/* 二维码弹窗 */} + setQrCabinet(null)} + footer={null} + style={{ width: 320 }} + > + {qrCabinet && ( +
+ +
+ {qrCabinet.abstractId} +
+
+ 扫码进入用户端 +
+
+ + +
+
+ )} +
+ + {/* 换绑弹窗 */} + { setReplaceCabinetId(null); setNewImei(''); }} + onOk={handleReplaceImei} + confirmLoading={replaceLoading} + okText="确认换绑" + cancelText="取消" + style={{ width: 400 }} + > +
+ 输入新的15位IMEI,原绑定码不变。 +
+ +
); } diff --git a/software/web/src/pages/devices/index.tsx b/software/web/src/pages/devices/index.tsx index cc27e7f..bf47643 100644 --- a/software/web/src/pages/devices/index.tsx +++ b/software/web/src/pages/devices/index.tsx @@ -18,12 +18,16 @@ import { Message, Spin, Tree, + Tooltip, + Pagination, } from '@arco-design/web-react'; import { IconPlus, IconDelete, IconEdit, IconRefresh, + IconExpand, + IconShrink, } from '@arco-design/web-react/icon'; import { treeApi, @@ -48,8 +52,13 @@ export default function Devices() { const [cabinets, setCabinets] = useState([]); const [loading, setLoading] = useState(true); const [selectedKeys, setSelectedKeys] = useState([]); + const [expandedKeys, setExpandedKeys] = useState([]); const [selectedType, setSelectedType] = useState('all'); const [selectedId, setSelectedId] = useState(0); + const [page, setPage] = useState(1); + const [pageSize] = useState(16); + const [total, setTotal] = useState(0); + const [filterTab, setFilterTab] = useState('all'); // 弹窗状态 const [orgModalVisible, setOrgModalVisible] = useState(false); @@ -81,7 +90,7 @@ export default function Devices() { } }, []); - const loadCabinets = useCallback(async (type: NodeType, id: number) => { + const loadCabinets = useCallback(async (type: NodeType, id: number, p: number = 1, tab?: string) => { if (type === 'cabinet') { navigate(`/devices/cabinet/${id}`); return; @@ -89,33 +98,41 @@ export default function Devices() { setLoading(true); try { let res; - if (type === 'all') { - res = await treeApi.filterCabinets({}); - } else if (type === 'organization') { - res = await treeApi.filterCabinets({ organization_id: id }); - } else if (type === 'project') { - res = await treeApi.filterCabinets({ project_id: id }); + const pg: Record = { page: p, page_size: pageSize }; + if (tab && tab !== 'all') { + pg.bound = tab === 'bound' ? '1' : '0'; } - setCabinets((res as unknown as Cabinet[]) || []); + if (type === 'all') { + res = await treeApi.filterCabinets(pg); + } else if (type === 'organization') { + res = await treeApi.filterCabinets({ ...pg, organization_id: id }); + } else if (type === 'project') { + res = await treeApi.filterCabinets({ ...pg, project_id: id }); + } + const result = res as unknown as { total: number; data: Cabinet[] }; + setCabinets(result.data || []); + setTotal(result.total); } catch { Message.error('加载柜子列表失败'); } finally { setLoading(false); } - }, [navigate]); + }, [navigate, pageSize]); useEffect(() => { - loadTree().then(() => setLoading(false)); + loadTree(); }, [loadTree]); useEffect(() => { if (selectedType) { - loadCabinets(selectedType, selectedId); + loadCabinets(selectedType, selectedId, page, filterTab); } - }, [selectedType, selectedId, loadCabinets]); + }, [selectedType, selectedId, page, filterTab, loadCabinets]); const handleTreeSelect = useCallback((keys: string[]) => { const key = keys[0]; + setPage(1); + setFilterTab('all'); if (!key || key === 'all') { setSelectedKeys(keys); setSelectedType('all'); @@ -301,6 +318,18 @@ export default function Devices() { ); // -------- 树渲染 -------- + // 获取所有节点key(用于展开) + const getAllKeys = (nodes: TreeInternalNode[]): string[] => { + const keys: string[] = []; + for (const n of nodes) { + keys.push(n.key); + if (n.children) { + keys.push(...getAllKeys(n.children)); + } + } + return keys; + }; + const renderNodeTitle = useCallback( (nodeType: NodeType, nodeId: number, title: string) => ( {/* 左列:组织架构树 */} -
+
组织架构 -
+ {/* 全部选项 */} +
handleTreeSelect(['all'])} + > + 全部 +
+ {treeData.length > 0 ? ( k !== 'all')} + expandedKeys={expandedKeys} + onExpand={(keys) => setExpandedKeys(keys as string[])} onSelect={handleTreeSelect as never} autoExpandParent - defaultExpandedKeys={['all']} + defaultExpandedKeys={treeData.map(n => n.key)} style={{ fontSize: 13 }} /> ) : ( @@ -357,30 +408,70 @@ export default function Devices() { {/* 右列:柜子列表 */}
-
+
设备列表 ({cabinets.length}) - +
+ + {[ + { key: 'all', label: '全部' }, + { key: 'bound', label: '已绑定' }, + { key: 'unbound', label: '未绑定' }, + ].map((t) => ( + + ))} + + +
- + + {total > pageSize && ( +
+ setPage(p)} + size="small" + hideOnSinglePage + /> +
+ )}
{/* 右键菜单 */} - + {contextVisible && (
setContextVisible(false)} - /> - + onContextMenu={(e) => { e.preventDefault(); setContextVisible(false); }} + > +
e.stopPropagation()} + > + +
+ +
+
+ )} {/* 添加组织弹窗 */} setOrgModalVisible(false)} onOk={handleCreateOrg} okText="确定" cancelText="取消"> diff --git a/tools/add_cabinets.py b/tools/add_cabinets.py new file mode 100644 index 0000000..9684c73 --- /dev/null +++ b/tools/add_cabinets.py @@ -0,0 +1,29 @@ +import pymysql +import random + +conn = pymysql.connect(host='10.8.0.252', user='root', password='Hbhyg731024@', database='pms') +cur = conn.cursor() + +# 获取所有项目ID +cur.execute('SELECT id FROM projects') +proj_ids = [row[0] for row in cur.fetchall()] + +# 为每个项目添加2-3个柜子 +count = 0 +for proj_id in proj_ids: + num = random.randint(2, 3) + for j in range(num): + count += 1 + abstract_id = f'10-{count:08d}' + imei = f'86{random.randint(10000000000000, 99999999999999)}' + auth = ''.join(random.choices('abcdefghijklmnopqrstuvwxyz0123456789', k=8)) + name = f'CAB-{count:03d}' + try: + cur.execute(f"INSERT IGNORE INTO cabinets (project_id, abstract_id, imei, auth_str, name, status) VALUES ({proj_id}, '{abstract_id}', '{imei}', '{auth}', '{name}', 1)") + except: + pass + +conn.commit() +cur.execute('SELECT COUNT(*) FROM cabinets') +print(f'柜子总数: {cur.fetchone()[0]}') +conn.close() diff --git a/tools/add_sub_orgs.py b/tools/add_sub_orgs.py new file mode 100644 index 0000000..340f45a --- /dev/null +++ b/tools/add_sub_orgs.py @@ -0,0 +1,44 @@ +import pymysql + +conn = pymysql.connect(host='10.8.0.252', user='root', password='Hbhyg731024@', database='pms') +cur = conn.cursor() + +# 添加parent_id字段 +try: + cur.execute("ALTER TABLE organizations ADD COLUMN parent_id BIGINT DEFAULT NULL COMMENT '上级组织ID' AFTER name") + print("✓ parent_id字段添加成功") +except Exception as e: + print(f"✓ {e}") + +# 添加外键 +try: + cur.execute("ALTER TABLE organizations ADD FOREIGN KEY (parent_id) REFERENCES organizations(id)") + print("✓ 外键添加成功") +except Exception as e: + print(f"✓ {e}") + +# 创建一些下级机构 +sub_orgs = [ + (1, "无锡太湖新城分部"), + (1, "无锡梁溪区分部"), + (2, "苏州工业园区分部"), + (3, "杭州西湖区分部"), + (3, "杭州滨江区分部"), +] + +for parent_id, name in sub_orgs: + try: + cur.execute(f"INSERT INTO organizations (name, parent_id) VALUES ('{name}', {parent_id})") + except: + pass + +conn.commit() +print("✓ 下级机构创建完成") + +# 统计 +cur.execute("SELECT COUNT(*) FROM organizations") +print(f"组织总数: {cur.fetchone()[0]}") +cur.execute("SELECT COUNT(*) FROM organizations WHERE parent_id IS NOT NULL") +print(f"有上级的组织: {cur.fetchone()[0]}") + +conn.close() diff --git a/tools/mock_redis.py b/tools/mock_redis.py new file mode 100644 index 0000000..47fa2a2 --- /dev/null +++ b/tools/mock_redis.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +""" +Redis 模拟数据填充工具 +- 从数据库读取前10个设备 +- 生成随机状态写入Redis(设备级+板级+通道级) +- 300秒后自动过期(与设备上报TTL一致) + +用法: + python tools/mock_redis.py # 填充模拟数据 + python tools/mock_redis.py --clear # 清除所有模拟数据 +""" +import sys +import json +import random +import redis +import pymysql + +# Redis 配置 +REDIS_HOST = "10.8.0.252" +REDIS_PORT = 6379 +REDIS_PASSWORD = "Hbhyg731024@" +REDIS_DB = 5 + +# MySQL 配置 +MYSQL_HOST = "10.8.0.252" +MYSQL_PORT = 3306 +MYSQL_USER = "root" +MYSQL_PASSWORD = "Hbhyg731024@" +MYSQL_DB = "pms" + +# 设备数量 +DEVICE_COUNT = 10 + +# 通道状态: 0=空闲, 1=插入, 2=充电中, 3=充满, 4=故障 +STATUS_IDLE = 0 +STATUS_INSERTED = 1 +STATUS_CHARGING = 2 +STATUS_FULL = 3 +STATUS_FAULT = 4 + + +def get_devices(): + """从数据库获取前N个设备""" + conn = pymysql.connect( + host=MYSQL_HOST, port=MYSQL_PORT, user=MYSQL_USER, + password=MYSQL_PASSWORD, database=MYSQL_DB, charset="utf8mb4" + ) + try: + with conn.cursor(pymysql.cursors.DictCursor) as cur: + cur.execute(""" + SELECT c.imei, c.abstract_id, c.name, + (SELECT COUNT(*) FROM cabin_boards cb WHERE cb.cabinet_id = c.id) as board_count + FROM cabinets c ORDER BY c.id LIMIT %s + """, (DEVICE_COUNT,)) + return cur.fetchall() + finally: + conn.close() + + +def gen_channel_hex(status: int) -> str: + """生成单通道hex(4字节=8个hex字符)""" + # 字节1: bit0=在线 bit1=充电中 bit2=充满 bit3=故障 + byte1 = 0x01 # 在线 + if status == STATUS_CHARGING: + byte1 |= 0x02 + elif status == STATUS_FULL: + byte1 |= 0x04 + elif status == STATUS_FAULT: + byte1 |= 0x08 + # 字节2-4: 随机(模拟电压电流等) + byte2 = random.randint(0x10, 0x60) + byte3 = random.randint(0x00, 0xFF) + byte4 = random.randint(0x00, 0xFF) + return f"{byte1:02x}{byte2:02x}{byte3:02x}{byte4:02x}" + + +def gen_board_status_hex(board_idx: int) -> str: + """生成一块板6个通道的status_hex""" + parts = [] + for ch in range(6): + # 随机分配状态,充电和故障少一些 + r = random.random() + if r < 0.02: + status = STATUS_FAULT + elif r < 0.25: + status = STATUS_CHARGING + elif r < 0.32: + status = STATUS_FULL + elif r < 0.42: + status = STATUS_INSERTED + else: + status = STATUS_IDLE + parts.append(gen_channel_hex(status)) + return "".join(parts) + + +def fill_mock_data(): + """填充模拟数据""" + r = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, password=REDIS_PASSWORD, db=REDIS_DB, decode_responses=True) + devices = get_devices() + print(f"获取到 {len(devices)} 个设备") + + for dev in devices: + imei = dev["imei"] + board_count = dev.get("board_count") or 6 + device_key = f"device:{imei}" + + # 设备级 + r.hset(device_key, mapping={ + "online": "1", + "rssi": str(random.randint(30, 95)), + "pow_fail_dc": random.choice(["0", "0", "0", "1"]), # 25%概率停电 + "pow_fail_ac": "0", + }) + r.expire(device_key, 300) + + # 板级 + 通道级 + for board_idx in range(1, board_count + 1): + board_key = f"{device_key}:board:{board_idx}" + status_hex = gen_board_status_hex(board_idx) + r.hset(board_key, mapping={"status_hex": status_hex}) + + # 解析6个通道 + for ch in range(6): + ch_hex = status_hex[ch * 8 : (ch + 1) * 8] + byte1 = int(ch_hex[0:2], 16) + ch_key = f"{board_key}:ch:{ch}" + r.hset(ch_key, mapping={ + "on": "1" if byte1 & 0x02 else "0", + "full": "1" if byte1 & 0x04 else "0", + "fault": "1" if byte1 & 0x08 else "0", + "hex": ch_hex, + }) + + name = dev.get("name") or imei + print(f" ✓ {name} ({imei}) — {board_count}板") + + print(f"\n完成!已填充 {len(devices)} 个设备,300秒后自动过期") + + +def clear_mock_data(): + """清除所有模拟数据""" + r = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, password=REDIS_PASSWORD, db=REDIS_DB, decode_responses=True) + devices = get_devices() + count = 0 + for dev in devices: + imei = dev["imei"] + keys = r.keys(f"device:{imei}*") + if keys: + r.delete(*keys) + count += len(keys) + print(f"已清除 {count} 个 Redis key") + + +if __name__ == "__main__": + if "--clear" in sys.argv: + clear_mock_data() + else: + fill_mock_data() diff --git a/tools/update_cabinets.py b/tools/update_cabinets.py new file mode 100644 index 0000000..297dbc2 --- /dev/null +++ b/tools/update_cabinets.py @@ -0,0 +1,79 @@ +import pymysql +import random + +conn = pymysql.connect(host='10.8.0.252', user='root', password='Hbhyg731024@', database='pms') +cur = conn.cursor() + +# 1. 添加address字段 +try: + cur.execute("ALTER TABLE cabinets ADD COLUMN address VARCHAR(200) DEFAULT NULL COMMENT '安装地址' AFTER name") + print("✓ address字段添加成功") +except: + print("✓ address字段已存在") + +# 2. 更新柜子数据(中文名字+地址) +addresses = [ + "无锡市太湖新城观山路88号", + "无锡市梁溪区中山路168号", + "无锡市新吴区菱湖大道200号", + "苏州市工业园区星湖街328号", + "苏州市高新区竹园路78号", + "杭州市西湖区文三路123号", + "杭州市滨江区网商路599号", + "南京市建邺区庐山路158号", + "上海市浦东新区张江高科技园区", + "上海市闵行区紫竹科学园区", + "合肥市高新区望江西路800号", + "武汉市东湖高新区光谷大道77号", + "成都市高新区天府大道999号", + "重庆市渝北区新溉大道2号", + "长沙市岳麓区麓谷大道662号", + "广州市天河区科韵路16号", + "深圳市南山区科技南路18号", + "宁波市鄞州区宁穿路1888号", + "厦门市思明区软件园二期", + "青岛市崂山区株洲路78号", +] + +names = [ + "太湖新城换电柜A", "太湖新城换电柜B", + "梁溪区换电柜A", "梁溪区换电柜B", + "新吴区换电柜A", + "工业园区换电柜A", "工业园区换电柜B", + "高新区换电柜A", + "西湖区换电柜A", "西湖区换电柜B", + "滨江区换电柜A", + "建邺区换电柜A", + "张江换电柜A", "张江换电柜B", + "紫竹换电柜A", + "高新区换电柜A", "高新区换电柜B", + "光谷换电柜A", + "天府新区换电柜A", "天府新区换电柜B", + "渝北区换电柜A", + "麓谷换电柜A", + "天河区换电柜A", "天河区换电柜B", + "南山换电柜A", "南山换电柜B", + "鄞州换电柜A", + "软件园换电柜A", + "崂山换电柜A", +] + +# 更新所有柜子 +cur.execute("SELECT id FROM cabinets ORDER BY id") +cabinet_ids = [row[0] for row in cur.fetchall()] + +for i, cab_id in enumerate(cabinet_ids): + name = names[i] if i < len(names) else f"充电柜-{i+1:03d}" + addr = addresses[i % len(addresses)] + cur.execute(f"UPDATE cabinets SET name='{name}', address='{addr}' WHERE id={cab_id}") + +conn.commit() +print(f"✓ 更新了 {len(cabinet_ids)} 个柜子的名字和地址") + +# 统计 +cur.execute("SELECT COUNT(*) FROM cabinets WHERE name IS NOT NULL") +print(f"有名字的柜子: {cur.fetchone()[0]}") +cur.execute("SELECT COUNT(*) FROM cabinets WHERE address IS NOT NULL") +print(f"有地址的柜子: {cur.fetchone()[0]}") + +conn.close() diff --git a/tools/update_iccid.py b/tools/update_iccid.py new file mode 100644 index 0000000..4f0109e --- /dev/null +++ b/tools/update_iccid.py @@ -0,0 +1,64 @@ +import pymysql +import random + +conn = pymysql.connect(host='10.8.0.252', user='root', password='Hbhyg731024@', database='pms') +cur = conn.cursor() + +# ICCID规则: +# 中国移动:898600、898602、898604、898607 +# 中国联通:898601 +# 中国电信:898603 + +# 获取所有柜子 +cur.execute("SELECT id FROM cabinets ORDER BY id") +cabinet_ids = [row[0] for row in cur.fetchall()] + +# 运营商前缀 +carriers = [ + ("898600", "移动"), # 中国移动 + ("898601", "联通"), # 中国联通 + ("898603", "电信"), # 中国电信 + ("898602", "移动"), # 中国移动 + ("898604", "移动"), # 中国移动 +] + +carrier_count = {"移动": 0, "联通": 0, "电信": 0} + +for i, cab_id in enumerate(cabinet_ids): + # 随机选择运营商 + prefix, carrier = random.choice(carriers) + # 生成ICCID: 20位数字 + # 前6位(运营商) + 2位(年份) + 2位(省份) + 8位(流水) + 1位(校验) + year = random.randint(20, 25) + province = random.randint(10, 31) + serial = random.randint(10000000, 99999999) + check = random.randint(0, 9) + iccid = f"{prefix}{year:02d}{province:02d}{serial:08d}{check}" + + cur.execute(f"UPDATE cabinets SET iccid='{iccid}' WHERE id={cab_id}") + carrier_count[carrier] += 1 + +conn.commit() + +print("✓ ICCID更新完成") +print(f"\n运营商分布:") +for carrier, count in carrier_count.items(): + print(f" {carrier}: {count}个") + +# 验证 +cur.execute("SELECT name, iccid FROM cabinets LIMIT 5") +print(f"\n示例数据:") +for row in cur.fetchall(): + iccid = row[1] + prefix = iccid[:6] if iccid else "" + if prefix.startswith("898600") or prefix.startswith("898602") or prefix.startswith("898604"): + carrier = "移动" + elif prefix.startswith("898601"): + carrier = "联通" + elif prefix.startswith("898603"): + carrier = "电信" + else: + carrier = "未知" + print(f" {row[0]}: {iccid} ({carrier})") + +conn.close() diff --git a/tools/update_imei_iccid.py b/tools/update_imei_iccid.py new file mode 100644 index 0000000..74819bc --- /dev/null +++ b/tools/update_imei_iccid.py @@ -0,0 +1,33 @@ +import pymysql +import random + +conn = pymysql.connect(host='10.8.0.252', user='root', password='Hbhyg731024@', database='pms') +cur = conn.cursor() + +# 添加ICCID字段 +try: + cur.execute("ALTER TABLE cabinets ADD COLUMN iccid VARCHAR(20) DEFAULT NULL COMMENT 'SIM卡ICCID' AFTER imei") + print("✓ ICCID字段添加成功") +except: + print("✓ ICCID字段已存在") + +# 更新IMEI和ICCID为正确的格式 +cur.execute("SELECT id FROM cabinets ORDER BY id") +cabinet_ids = [row[0] for row in cur.fetchall()] + +for i, cab_id in enumerate(cabinet_ids): + # IMEI: 15位数字,86开头(中国TAC) + imei = f"86{random.randint(10000000000000, 99999999999999)}"[:15] + # ICCID: 20位数字,898600开头(中国移动) + iccid = f"898600{random.randint(100000000000000000, 999999999999999999)}"[:20] + cur.execute(f"UPDATE cabinets SET imei='{imei}', iccid='{iccid}' WHERE id={cab_id}") + +conn.commit() +print(f"✓ 更新了 {len(cabinet_ids)} 个柜子的IMEI和ICCID") + +# 验证 +cur.execute("SELECT imei, iccid FROM cabinets LIMIT 3") +for row in cur.fetchall(): + print(f" IMEI: {row[0]} ({len(row[0])}位), ICCID: {row[1]} ({len(row[1])}位)") + +conn.close() diff --git a/生产环境.txt b/生产环境.txt index 76637fe..db9a33e 100644 --- a/生产环境.txt +++ b/生产环境.txt @@ -2,6 +2,13 @@ 账号:13951516272 密码:wxad139 +无锡艾动电子有限公司 + +腾讯地图 +key:HWVBZ-CMACV-JEBP5-5RDRJ-OIHLK-ZNFE2 +Secret key( SK ):LYVx9XenBOKGnbAKkmzJxOYFhASp9FQD +签名验证 + https://signin.aliyun.com/login.htm?username=skyp76%401086952030628727.onaliyun.com&defaultShowQrCode=false mysql