feat: H5 用户端 Taro 重写 + 前后端接口对齐修复

- 使用 Taro + React 重写 H5 用户端(替换旧的 React+Vite+H5 实现)
- 6 个页面:登录、首页、设备列表、设备详情、仓体详情、个人中心
- Zustand 状态管理 + API 层封装 11 个接口
- 后端 H5 路由:新增仪表盘/项目/设备/充电控制接口,组织数据隔离
- 扫码入口:/h5/{abstract_id} → 免认证查设备 + 登录后跳转
- 管理端:新增绑定码管理、二维码展示下载、设备绑定/替换 IMEI
- 产品落地页更新(hero-bg + logo 替换)
- 接口对齐修复:修正 action 路径/请求体,DashboardData 和 CabinetItem 字段匹配后端
- 布局兼容性:grid → flex,提升移动浏览器兼容性
- 移除旧 H5 项目 software/h5/
This commit is contained in:
12451 2026-07-03 02:45:00 +08:00
parent 7290da8eae
commit 06765f99db
92 changed files with 3476 additions and 2545 deletions

View File

@ -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秒出图

View File

@ -0,0 +1,6 @@
# 项目长期记忆
## 用户偏好
- **产品介绍页风格**:偏好明亮/浅色风格,不要暗色科技风;单屏不滚动布局
- **生图能力**:项目有 ImageGen 文生图工具可用,可用于生成产品渲染图等
- **操作规则**:开发/修改程序前必须确认需求,不可自说自话操作;生产环境服务器文件和数据必须用户授权后才可动

View File

@ -410,6 +410,49 @@ operation_log(操作日志)
| energy_stats | 能耗统计表 | | energy_stats | 能耗统计表 |
| operation_logs | 操作日志表 | | 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} # 通道级 Hash6仓
├─ 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 批量 HGET1次往返 |
| `GET /api/cabinets/realtime-status?imei=xxx` | 单设备详情(板级+通道级) | KEYS 扫描 + HMGET |
## TCP通讯协议 ## TCP通讯协议
- 长连接无需PING包 - 长连接无需PING包

View File

@ -9,7 +9,8 @@
## 技术栈 ## 技术栈
- **后端**Rust + Axum - **后端**Rust + Axum
- **前端**React + Arco Design Pro - **前端**React + Arco Design Pro管理端
- **H5用户端**Taro + React跨平台支持 H5 / 微信小程序)
- **数据库**MySQL阿里云RDS - **数据库**MySQL阿里云RDS
- **缓存**Redis阿里云Redis - **缓存**Redis阿里云Redis
- **设备通讯**TCP长连接柜控板4G模块Air780E Cat.1 - **设备通讯**TCP长连接柜控板4G模块Air780E Cat.1
@ -21,9 +22,12 @@ charging-cabinet/
├── tasks/ # 任务文件opencode执行 ├── tasks/ # 任务文件opencode执行
├── docs/ # 开发文档、协议文档 ├── docs/ # 开发文档、协议文档
├── software/ ├── software/
│ ├── server/ # Rust后端 │ ├── api-server/ # Rust API服务
│ └── web/ # React前端 │ ├── device-server/ # Rust TCP设备服务
│ ├── web/ # React管理端前端
│ └── h5-taro/ # H5用户端Taro + React
├── hardware/ # 硬件资料(原理图/PCB等 ├── hardware/ # 硬件资料(原理图/PCB等
├── deploy/ # 部署配置
└── test/ # 测试用例/报告 └── test/ # 测试用例/报告
``` ```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

BIN
deploy/landing/hero-bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 906 KiB

View File

@ -1,466 +1,204 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>安知充 - 智能充电柜矩阵系统</title> <title>安知充 — 智能充电柜矩阵系统</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <style>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> * { margin: 0; padding: 0; box-sizing: border-box; }
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-primary: #09090b;
--bg-secondary: #18181b;
--bg-card: #27272a;
--border: #3f3f46;
--text-primary: #fafafa;
--text-secondary: #a1a1aa;
--text-muted: #71717a;
--accent: #22d3ee;
--accent-dim: rgba(34, 211, 238, 0.1);
}
* { margin: 0; padding: 0; box-sizing: border-box; } html, body {
height: 100%;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", sans-serif;
color: #1A1C20;
}
body { a { color: inherit; text-decoration: none; }
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
/* 布局 */ /* ===== 整体布局:上中下三块 ===== */
.container { .layout {
max-width: 1200px; display: flex;
margin: 0 auto; flex-direction: column;
padding: 0 24px; height: 100vh;
} }
/* 导航栏 */ /* ===== TOP: 顶部导航 ===== */
.nav { .top {
position: fixed; flex-shrink: 0;
top: 0;
left: 0;
right: 0;
z-index: 100;
background: rgba(9, 9, 11, 0.8);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border);
}
.nav-inner {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
height: 64px; padding: 10px 48px;
} z-index: 10;
.nav-brand { }
display: flex;
align-items: center;
gap: 12px;
}
.nav-logo {
width: 32px;
height: 32px;
background: var(--accent);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.nav-logo svg { width: 18px; height: 18px; }
.nav-title {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
}
.nav-links {
display: flex;
gap: 32px;
}
.nav-links a {
color: var(--text-secondary);
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: color 0.2s;
}
.nav-links a:hover { color: var(--text-primary); }
/* Hero区域 */ .top-brand { display: flex; align-items: center; gap: 8px; }
.hero { .logo {
padding: 140px 0 80px; height: 48px;
text-align: center; width: auto;
} display: block;
.hero-badge { }
display: inline-flex; .top-name { font-size: 24px; font-weight: 800; color: #1A1C20; letter-spacing: 0.02em; }
.top-links { display: flex; gap: 8px; }
.top-link {
font-size: 13px; font-weight: 500;
color: #4A5060;
padding: 7px 16px;
border-radius: 8px;
transition: all 0.2s;
}
.top-link:hover { background: rgba(37,99,235,0.07); color: #2563EB; }
.top-link--cta { background: #1A1C20; color: #fff; }
.top-link--cta:hover { background: #2D3038; color: #fff; }
/* ===== MID: 中部背景图,填满 ===== */
.mid {
flex: 1;
min-height: 0;
background: url("hero-bg.png") no-repeat right center;
background-size: 100% 100%;
display: flex;
align-items: center; align-items: center;
gap: 8px; padding: 0 80px;
padding: 6px 16px; }
background: var(--accent-dim);
border: 1px solid rgba(34, 211, 238, 0.2); /* ===== COPY: 文字区域 ===== */
.copy {
max-width: 580px;
display: flex;
flex-direction: column;
gap: 22px;
}
.tag {
display: inline-flex; align-items: center; gap: 7px;
padding: 5px 14px;
border: 1px solid rgba(37,99,235,0.25);
border-radius: 100px; border-radius: 100px;
font-size: 13px; font-size: 13px; font-weight: 600;
font-weight: 500; color: #2563EB;
color: var(--accent); width: fit-content;
margin-bottom: 24px; }
} .tag .dot {
.hero-badge-dot { width: 6px; height: 6px;
width: 6px; background: #2563EB;
height: 6px;
background: var(--accent);
border-radius: 50%; border-radius: 50%;
animation: pulse 2s infinite; }
}
@keyframes pulse { h1 {
0%, 100% { opacity: 1; } font-size: clamp(40px, 4.5vw, 60px);
50% { opacity: 0.4; } font-weight: 900;
} line-height: 1.15;
.hero h1 {
font-size: 56px;
font-weight: 800;
line-height: 1.1;
margin-bottom: 20px;
letter-spacing: -0.02em; letter-spacing: -0.02em;
} color: #1A1C20;
.hero h1 span { }
background: linear-gradient(135deg, var(--accent), #06b6d4); h1 .accent { color: #2563EB; }
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.hero p {
font-size: 18px;
color: var(--text-secondary);
max-width: 560px;
margin: 0 auto 40px;
}
.hero-actions {
display: flex;
gap: 12px;
justify-content: center;
}
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
font-size: 14px;
font-weight: 500;
border-radius: 8px;
text-decoration: none;
transition: all 0.2s;
cursor: pointer;
border: none;
}
.btn-primary {
background: var(--accent);
color: var(--bg-primary);
}
.btn-primary:hover {
background: #06b6d4;
transform: translateY(-1px);
}
.btn-secondary {
background: var(--bg-card);
color: var(--text-primary);
border: 1px solid var(--border);
}
.btn-secondary:hover {
background: #3f3f46;
border-color: #52525b;
}
/* 产品展示 */ .subhead {
.showcase { font-size: clamp(15px, 1.3vw, 18px);
padding: 40px 0 80px; font-weight: 300;
} color: #4A5060;
.showcase-frame { line-height: 1.75;
position: relative; max-width: 480px;
background: var(--bg-secondary); }
border: 1px solid var(--border);
border-radius: 16px;
overflow: hidden;
aspect-ratio: 16/9;
display: flex;
align-items: center;
justify-content: center;
}
.showcase-frame::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
opacity: 0.5;
}
.showcase-img {
max-width: 70%;
max-height: 80%;
object-fit: contain;
}
/* 特性网格 */ .pills { display: flex; flex-wrap: wrap; gap: 8px; }
.features { .pill {
padding: 80px 0; padding: 5px 12px;
} border: 1px solid rgba(0,0,0,0.08);
.section-header { border-radius: 100px;
text-align: center; font-size: 13px; font-weight: 500;
margin-bottom: 48px; color: #4A5060;
} }
.section-header h2 {
font-size: 32px; .ctas { display: flex; align-items: center; gap: 12px; margin-top: 4px; }
font-weight: 700; .btn {
margin-bottom: 12px; display: inline-flex; align-items: center; gap: 8px;
} padding: 13px 28px;
.section-header p { font-size: 15px; font-weight: 600;
font-size: 16px;
color: var(--text-secondary);
}
.features-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.feature-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 12px;
padding: 28px;
transition: all 0.2s;
}
.feature-card:hover {
border-color: #52525b;
transform: translateY(-2px);
}
.feature-icon {
width: 40px;
height: 40px;
background: var(--accent-dim);
border-radius: 10px; border-radius: 10px;
display: flex; transition: all 0.2s;
align-items: center; }
justify-content: center; .btn-primary {
margin-bottom: 16px; background: #1A1C20; color: #fff;
} box-shadow: 0 4px 24px rgba(26,28,32,0.10);
.feature-icon svg { }
width: 20px; .btn-primary:hover { background: #2D3038; transform: translateY(-2px); }
height: 20px; .btn-ghost {
color: var(--accent); color: #4A5060;
} border: 1.5px solid rgba(0,0,0,0.12);
.feature-card h3 { }
font-size: 16px; .btn-ghost:hover { border-color: #8B92A5; color: #1A1C20; }
font-weight: 600;
margin-bottom: 8px;
}
.feature-card p {
font-size: 14px;
color: var(--text-secondary);
line-height: 1.6;
}
/* 数据统计 */ .stats {
.stats { display: flex; gap: 32px;
padding: 60px 0; padding-top: 14px;
} border-top: 1px solid rgba(0,0,0,0.08);
.stats-grid { margin-top: 4px;
display: grid; }
grid-template-columns: repeat(4, 1fr); .stat-num { font-size: 28px; font-weight: 700; color: #1A1C20; line-height: 1.2; }
gap: 1px; .stat-label { font-size: 12px; font-weight: 500; color: #8B92A5; margin-top: 2px; }
background: var(--border);
border-radius: 12px; /* ===== BOT: 底部 ICP ===== */
overflow: hidden; .bot {
} flex-shrink: 0;
.stat-item {
background: var(--bg-secondary);
padding: 32px;
text-align: center; text-align: center;
} padding: 14px;
.stat-value {
font-family: 'JetBrains Mono', monospace;
font-size: 36px;
font-weight: 700;
color: var(--accent);
margin-bottom: 4px;
}
.stat-label {
font-size: 13px;
color: var(--text-muted);
}
/* 底部 */
.footer {
border-top: 1px solid var(--border);
padding: 40px 0;
margin-top: 80px;
}
.footer-inner {
display: flex;
align-items: center;
justify-content: space-between;
}
.footer-text {
font-size: 13px;
color: var(--text-muted);
}
.footer-icp {
font-size: 12px; font-size: 12px;
color: var(--text-muted); color: #6B7280;
} z-index: 10;
.footer-icp a { }
color: var(--text-secondary); .bot a { color: #2563EB; }
text-decoration: none; .bot a:hover { text-decoration: underline; }
} </style>
.footer-icp a:hover { color: var(--text-primary); }
/* 响应式 */
@media (max-width: 768px) {
.hero h1 { font-size: 36px; }
.features-grid { grid-template-columns: 1fr; }
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.nav-links { display: none; }
.footer-inner { flex-direction: column; gap: 16px; text-align: center; }
}
</style>
</head> </head>
<body> <body>
<!-- 导航栏 -->
<nav class="nav">
<div class="container nav-inner">
<div class="nav-brand">
<div class="nav-logo">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
</svg>
</div>
<span class="nav-title">安知充</span>
</div>
<div class="nav-links">
<a href="#features">产品特性</a>
<a href="#stats">技术参数</a>
<a href="/pms/">管理后台</a>
<a href="/doc/">开发文档</a>
</div>
</div>
</nav>
<!-- Hero区域 --> <div class="layout">
<section class="hero">
<div class="container">
<div class="hero-badge">
<span class="hero-badge-dot"></span>
智能充电新方案
</div>
<h1>智能充电柜<br><span>矩阵系统</span></h1>
<p>36仓独立充电4G远程管理多重安全防护。为电动车换电场景打造的下一代智能充电解决方案。</p>
<div class="hero-actions">
<a href="/pms/" class="btn btn-primary">
进入管理后台
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</a>
<a href="/doc/" class="btn btn-secondary">开发文档</a>
</div>
</div>
</section>
<!-- 产品展示 --> <!-- 上:顶部导航 -->
<section class="showcase"> <div class="top">
<div class="container"> <div class="top-brand">
<div class="showcase-frame"> <img class="logo" src="logo-1.png" alt="安知充">
<img src="cabinet.png" alt="安知充智能充电柜" class="showcase-img"> <span class="top-name">安知充</span>
</div>
<div class="top-links">
<a href="/doc/" class="top-link">开发文档</a>
<a href="/pms/" class="top-link top-link--cta">管理后台</a>
</div> </div>
</div> </div>
</section>
<!-- 产品特性 --> <!-- 中:背景图填满 -->
<section class="features" id="features"> <div class="mid">
<div class="container"> <div class="copy">
<div class="section-header"> <div class="tag"><span class="dot"></span>智能充电矩阵系统</div>
<h2>核心特性</h2> <h1>为换电场景打造的<br><span class="accent">下一代智能充电</span></h1>
<p>为换电场景设计的全栈解决方案</p> <p class="subhead">36仓独立直流快充4G远程实时管控多重安全防护体系。从硬件到云端全栈自研的智能充电解决方案。</p>
</div>
<div class="features-grid">
<div class="feature-card">
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="6" y="3" width="12" height="18" rx="2"/>
<path d="M12 8v4M10 14h4"/>
</svg>
</div>
<h3>36仓独立充电</h3>
<p>每个仓体独立控制支持48V/60V/72V直流适配电流5A-20A可调。直流快充1-2小时充满高效运营。</p>
</div>
<div class="feature-card">
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2a10 10 0 1 0 10 10H12V2z"/>
<path d="M20.66 7A10 10 0 0 0 14 2v6h6.66z" fill="currentColor" opacity="0.3"/>
</svg>
</div>
<h3>智能BMS适配</h3>
<p>支持星恒Modbus、天能485等多种BMS协议平台远程配置自动识别电池参数安全充电。</p>
</div>
<div class="feature-card">
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
<path d="M9 12l2 2 4-4"/>
</svg>
</div>
<h3>多重安全防护</h3>
<p>过充/过温/过流/短路保护,烟感+喷淋联动浸水保护6路温控实时监测确保充电安全。</p>
</div>
<div class="feature-card">
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="5" y="2" width="14" height="20" rx="2"/>
<path d="M12 18h.01"/>
</svg>
</div>
<h3>远程管理</h3>
<p>4G TCP长连接实时状态监控远程开门/禁用/OTA升级完整后台管理系统高效运维。</p>
</div>
</div>
</div>
</section>
<!-- 数据统计 --> <div class="pills">
<section class="stats" id="stats"> <span class="pill">36仓独立</span>
<div class="container"> <span class="pill">多重防护</span>
<div class="stats-grid"> <span class="pill">4G在线</span>
<div class="stat-item"> <span class="pill">BMS适配</span>
<div class="stat-value">36</div>
<div class="stat-label">独立仓体</div>
</div> </div>
<div class="stat-item">
<div class="stat-value">0.5s</div>
<div class="stat-label">响应延迟</div>
</div>
<div class="stat-item">
<div class="stat-value">99%</div>
<div class="stat-label">OTA成功率</div>
</div>
<div class="stat-item">
<div class="stat-value">8000h</div>
<div class="stat-label">平均无故障时间</div>
</div>
</div>
</div>
</section>
<!-- 底部 --> <div class="stats">
<footer class="footer"> <div><div class="stat-num">36</div><div class="stat-label">独立仓体</div></div>
<div class="container footer-inner"> <div><div class="stat-num">&lt;0.5s</div><div class="stat-label">指令响应</div></div>
<div class="footer-text"> <div><div class="stat-num">8000h</div><div class="stat-label">MTBF</div></div>
© 2026 无锡艾动电子有限公司 安知充 AnZhiZhiChong. All rights reserved. <div><div class="stat-num">99%</div><div class="stat-label">可用率</div></div>
</div>
<div class="footer-icp">
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">苏ICP备2025198376号-1</a>
</div> </div>
</div> </div>
</footer> </div>
<!-- 下:底部 ICP -->
<div class="bot">
© 2026 安知充 · <a href="https://beian.miit.gov.cn/" target="_blank">苏ICP备2025198376号-1</a>
</div>
</div>
</body> </body>
</html> </html>

BIN
deploy/landing/logo-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

BIN
deploy/landing/logo-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

BIN
deploy/landing/logo-3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 KiB

View File

@ -194,6 +194,7 @@ pub struct AppState {
pub mysql: sqlx::MySqlPool, pub mysql: sqlx::MySqlPool,
pub pending_commands: PendingCommands, pub pending_commands: PendingCommands,
pub nodes: NodeRegistry, pub nodes: NodeRegistry,
pub redis_url: String,
} }
/// 指令下发请求体 /// 指令下发请求体

View File

@ -24,7 +24,7 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
CREATE TABLE IF NOT EXISTS cabinets ( CREATE TABLE IF NOT EXISTS cabinets (
id BIGINT AUTO_INCREMENT PRIMARY KEY, id BIGINT AUTO_INCREMENT PRIMARY KEY,
project_id BIGINT, project_id BIGINT,
abstract_id VARCHAR(20) NOT NULL UNIQUE COMMENT 'ID10-00000000', abstract_id VARCHAR(20) NULL UNIQUE COMMENT 'ID10-00000001',
imei VARCHAR(15) NOT NULL UNIQUE COMMENT '4G模块IMEI', imei VARCHAR(15) NOT NULL UNIQUE COMMENT '4G模块IMEI',
auth_str VARCHAR(8) COMMENT '', auth_str VARCHAR(8) COMMENT '',
name VARCHAR(100) COMMENT '', name VARCHAR(100) COMMENT '',
@ -166,6 +166,14 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
completed_at TIMESTAMP NULL COMMENT '', completed_at TIMESTAMP NULL COMMENT '',
FOREIGN KEY (user_id) REFERENCES users(id) FOREIGN KEY (user_id) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) 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 'ID10-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?; 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 避免重复) // 创建关键索引IF NOT EXISTS 避免重复)
create_indexes(pool).await?; create_indexes(pool).await?;

View File

@ -30,6 +30,9 @@ pub enum AppError {
#[error("请求过于频繁: {0}")] #[error("请求过于频繁: {0}")]
TooManyRequests(String), TooManyRequests(String),
#[error("冲突: {0}")]
Conflict(String),
#[error("内部错误: {0}")] #[error("内部错误: {0}")]
Internal(String), Internal(String),
} }
@ -50,6 +53,7 @@ impl IntoResponse for AppError {
AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, "UNAUTHORIZED", msg.as_str()), AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, "UNAUTHORIZED", msg.as_str()),
AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, "FORBIDDEN", 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::TooManyRequests(msg) => (StatusCode::TOO_MANY_REQUESTS, "TOO_MANY_REQUESTS", msg.as_str()),
AppError::Conflict(msg) => (StatusCode::CONFLICT, "CONFLICT", msg.as_str()),
AppError::Internal(msg) => { AppError::Internal(msg) => {
tracing::error!("内部错误: {}", msg); tracing::error!("内部错误: {}", msg);
(StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL_ERROR", "服务器内部错误") (StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL_ERROR", "服务器内部错误")

View File

@ -57,6 +57,7 @@ async fn main() {
mysql: db_pool.clone(), mysql: db_pool.clone(),
pending_commands: pending_commands.clone(), pending_commands: pending_commands.clone(),
nodes: nodes.clone(), nodes: nodes.clone(),
redis_url: cfg.redis_url.clone(),
}; };
// 启动后台协程 — 处理设备上报 // 启动后台协程 — 处理设备上报

View File

@ -14,6 +14,50 @@ use crate::error::AppError;
use crate::middleware::auth::{self, CurrentUser}; use crate::middleware::auth::{self, CurrentUser};
use crate::commands::AppState; use crate::commands::AppState;
/// SQL查询专用结构体只包含数据库字段
#[derive(Debug, Clone, sqlx::FromRow)]
struct CabinetSqlRow {
id: i64,
project_id: Option<i64>,
abstract_id: Option<String>,
imei: String,
iccid: Option<String>,
name: Option<String>,
address: Option<String>,
status: i8,
board_count: Option<i64>,
channel_count: Option<i64>,
idle_count: Option<i64>,
charging_count: Option<i64>,
full_count: Option<i64>,
fault_count: Option<i64>,
}
impl From<CabinetSqlRow> 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( pub async fn organization_tree(
user: CurrentUser, user: CurrentUser,
@ -114,6 +158,9 @@ pub async fn organization_tree(
pub struct CabinetFilterParams { pub struct CabinetFilterParams {
pub organization_id: Option<i64>, pub organization_id: Option<i64>,
pub project_id: Option<i64>, pub project_id: Option<i64>,
pub page: Option<i64>,
pub page_size: Option<i64>,
pub bound: Option<String>,
} }
/// 设备列表接口 — 根据组织/项目筛选 /// 设备列表接口 — 根据组织/项目筛选
@ -125,6 +172,11 @@ pub async fn filter_cabinets(
auth::check_permission(&user, "device:view")?; auth::check_permission(&user, "device:view")?;
let db = &state.mysql; 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联表查询获取通道状态统计 // 基础SQL联表查询获取通道状态统计
let base_sql = " let base_sql = "
@ -149,41 +201,225 @@ pub async fn filter_cabinets(
FROM cabinets c FROM cabinets c
"; ";
let rows = if let Some(proj_id) = params.project_id { let (mut where_clause, binds): (String, Vec<String>) = if let Some(proj_id) = params.project_id {
sqlx::query_as::<_, CabinetRow>( ("WHERE c.project_id = ?".into(), vec![proj_id.to_string()])
&format!("{} WHERE c.project_id = ? ORDER BY c.id", base_sql)
)
.bind(proj_id)
.fetch_all(db)
.await?
} else if let Some(org_id) = params.organization_id { } else if let Some(org_id) = params.organization_id {
sqlx::query_as::<_, CabinetRow>( ("JOIN projects p ON c.project_id = p.id
&format!("{} JOIN projects p ON c.project_id = p.id
WHERE p.organization_id IN ( WHERE p.organization_id IN (
SELECT id FROM organizations WHERE id = ? OR parent_id = ? SELECT id FROM organizations WHERE id = ? OR parent_id = ?
) ORDER BY c.id", base_sql) )".into(), vec![org_id.to_string(), org_id.to_string()])
)
.bind(org_id)
.bind(org_id)
.fetch_all(db)
.await?
} else if user.role_level >= 2 { } else if user.role_level >= 2 {
sqlx::query_as::<_, CabinetRow>( (String::new(), vec![])
&format!("{} ORDER BY c.id", base_sql)
)
.fetch_all(db)
.await?
} else if let Some(org_id) = user.organization_id { } else if let Some(org_id) = user.organization_id {
sqlx::query_as::<_, CabinetRow>( ("JOIN projects p ON c.project_id = p.id
&format!("{} JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?".into(), vec![org_id.to_string()])
WHERE p.organization_id = ? ORDER BY c.id", base_sql)
)
.bind(org_id)
.fetch_all(db)
.await?
} else { } 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<CabinetSqlRow> = data_query
.bind(page_size)
.bind(offset)
.fetch_all(db).await?;
// 转换为CabinetRow
let mut rows: Vec<CabinetRow> = sql_rows.into_iter().map(CabinetRow::from).collect();
// 从Redis获取实时状态Pipeline批量HGET1次网络往返
{
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<Option<String>> = 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::<i32>().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<AppState>,
Query(params): Query<DeviceStatusParams>,
) -> Result<Json<Value>, 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<Option<String>> = 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::<i32>().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<String> = 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<Option<String>> = 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<String> = 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<Option<String>> = 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,
})))
} }

View File

@ -10,13 +10,51 @@ use serde_json::{json, Value};
use sqlx::MySqlPool; use sqlx::MySqlPool;
use super::organizations::{ use super::organizations::{
generate_abstract_id, generate_auth_str, generate_auth_str,
CabinetDetail, CabinetRow, CabinBoardRow, CabinBoardWithCompartments, CabinetDetail, CabinBoardRow, CabinBoardWithCompartments,
CompartmentRow, CreateCabinetBody, UpdateCabinetBody, CompartmentRow, CreateCabinetBody, ReplaceImeiBody, UpdateCabinetBody,
}; };
use crate::error::AppError; use crate::error::AppError;
use crate::middleware::auth::{self, CurrentUser}; use crate::middleware::auth::{self, CurrentUser};
/// 基础柜子行(只包含数据库字段,用于简单查询)
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
struct CabinetBasicRow {
id: i64,
project_id: Option<i64>,
abstract_id: Option<String>,
imei: String,
iccid: Option<String>,
name: Option<String>,
address: Option<String>,
status: i8,
}
impl From<CabinetBasicRow> 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?; check_project_access(&user, &state.mysql, project_id).await?;
let db = &state.mysql; 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 r#"SELECT id, project_id, abstract_id, imei, iccid, name, address, status
FROM cabinets WHERE project_id = ? ORDER BY id"#, FROM cabinets WHERE project_id = ? ORDER BY id"#,
) )
@ -77,6 +115,7 @@ pub async fn list_cabinets(
.fetch_all(db) .fetch_all(db)
.await?; .await?;
let rows: Vec<super::organizations::CabinetRow> = sql_rows.into_iter().map(Into::into).collect();
Ok(Json(json!(rows))) Ok(Json(json!(rows)))
} }
@ -111,17 +150,15 @@ pub async fn create_cabinet(
let mut created_ids: Vec<i64> = Vec::new(); let mut created_ids: Vec<i64> = Vec::new();
for imei in &unique_imeis { for imei in &unique_imeis {
let abstract_id = generate_abstract_id(imei);
let auth_str = generate_auth_str(); let auth_str = generate_auth_str();
let mut tx = db.begin().await.map_err(AppError::Database)?; let mut tx = db.begin().await.map_err(AppError::Database)?;
let result = sqlx::query( let result = sqlx::query(
r#"INSERT INTO cabinets (project_id, abstract_id, imei, auth_str) r#"INSERT INTO cabinets (project_id, imei, auth_str)
VALUES (?, ?, ?, ?)"#, VALUES (?, ?, ?)"#,
) )
.bind(body.project_id) .bind(body.project_id)
.bind(&abstract_id)
.bind(imei) .bind(imei)
.bind(&auth_str) .bind(&auth_str)
.execute(&mut *tx) .execute(&mut *tx)
@ -150,7 +187,7 @@ pub async fn create_cabinet(
tx.commit().await.map_err(AppError::Database)?; tx.commit().await.map_err(AppError::Database)?;
created_ids.push(cabinet_id); 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 }))) Ok(Json(json!({ "ids": created_ids })))
@ -169,11 +206,13 @@ pub async fn update_cabinet(
let rows = sqlx::query( let rows = sqlx::query(
r#"UPDATE cabinets SET r#"UPDATE cabinets SET
project_id = COALESCE(?, project_id), project_id = COALESCE(?, project_id),
name = COALESCE(?, name) name = COALESCE(?, name),
abstract_id = COALESCE(?, abstract_id)
WHERE id = ?"#, WHERE id = ?"#,
) )
.bind(body.project_id) .bind(body.project_id)
.bind(&body.name) .bind(&body.name)
.bind(&body.abstract_id)
.bind(id) .bind(id)
.execute(db) .execute(db)
.await? .await?
@ -228,6 +267,107 @@ pub async fn delete_cabinet(
Ok(Json(json!({}))) Ok(Json(json!({})))
} }
/// POST /api/cabinets/:id/bind — 绑定平台抽象ID
pub async fn bind_cabinet(
user: CurrentUser,
State(state): State<super::AppState>,
Path(id): Path<i64>,
) -> Result<Json<Value>, 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<super::AppState>,
Path(id): Path<i64>,
Json(body): Json<ReplaceImeiBody>,
) -> Result<Json<Value>, 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<String>)> = 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 — 重新生成安全码 /// POST /api/cabinets/:id/regenerate-auth — 重新生成安全码
pub async fn regenerate_auth( pub async fn regenerate_auth(
user: CurrentUser, user: CurrentUser,
@ -267,8 +407,8 @@ pub async fn get_cabinet_detail(
auth::check_permission(&user, "device:view")?; auth::check_permission(&user, "device:view")?;
let db = &state.mysql; let db = &state.mysql;
let cabinet = sqlx::query_as::<_, CabinetRow>( let cabinet = sqlx::query_as::<_, CabinetBasicRow>(
r#"SELECT id, project_id, abstract_id, imei, name, status r#"SELECT id, project_id, abstract_id, imei, iccid, name, address, status
FROM cabinets WHERE id = ?"#, FROM cabinets WHERE id = ?"#,
) )
.bind(id) .bind(id)

View File

@ -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( pub async fn get_cabinet_by_abstract_id(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(abstract_id): Path<String>, Path(abstract_id): Path<String>,
) -> Result<Json<Value>, AppError> { ) -> Result<Json<Value>, AppError> {
let pool = &state.mysql; let pool = &state.mysql;
// 权限校验
auth::check_permission(&user, "device:view")?;
let cab: Option<(i64, String, String, Option<String>, i8)> = sqlx::query_as( let cab: Option<(i64, String, String, Option<String>, i8)> = sqlx::query_as(
"SELECT id, abstract_id, imei, name, status FROM cabinets WHERE abstract_id = ?", "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) = let (cab_id, abs_id, imei, name, status) =
cab.ok_or_else(|| AppError::NotFound("设备不存在".into()))?; cab.ok_or_else(|| AppError::NotFound("设备不存在".into()))?;
// 组织隔离:验证用户是否有权访问该设备
verify_cabinet_access(&user, cab_id, pool).await?;
// 查询仓控板+仓体 // 查询仓控板+仓体
let boards: Vec<H5CabinBoardRow> = sqlx::query_as::<_, H5CabinBoardRow>( let boards: Vec<H5CabinBoardRow> = sqlx::query_as::<_, H5CabinBoardRow>(
"SELECT id, board_id, status FROM cabin_boards WHERE cabinet_id = ? ORDER BY board_id", "SELECT id, board_id, status FROM cabin_boards WHERE cabinet_id = ? ORDER BY board_id",

View File

@ -61,9 +61,12 @@ pub fn build(state: AppState) -> Router {
.route("/api/cabinets", post(cabinets::create_cabinet)) .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}", 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}/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/organization-tree", get(cabinet_tree::organization_tree))
.route("/api/cabinets/filter", get(cabinet_tree::filter_cabinets)) .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", get(charge_records::list_charge_records))
.route("/api/charge-records/export", get(charge_records::export_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/dashboard", get(h5::get_dashboard))
.route("/api/h5/projects", get(h5::get_projects)) .route("/api/h5/projects", get(h5::get_projects))
.route("/api/h5/cabinets", get(h5::get_cabinets)) .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/cabinets/{id}", get(h5::get_cabinet_detail))
.route("/api/h5/compartments/{id}", get(h5::get_compartment_detail)) .route("/api/h5/compartments/{id}", get(h5::get_compartment_detail))
.route("/api/h5/charge/start", post(h5::start_charge)) .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/health", get(health::health_check))
.route("/api/auth/login", post(auth::login)) .route("/api/auth/login", post(auth::login))
.route("/api/h5/auth/login", post(h5::h5_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) .merge(protected)
.with_state(state) .with_state(state)
} }

View File

@ -29,11 +29,18 @@ pub struct CreateCabinetBody {
pub project_id: i64, pub project_id: i64,
} }
/// 更换IMEI请求体
#[derive(Debug, Deserialize)]
pub struct ReplaceImeiBody {
pub imei: String,
}
/// 更新柜子请求体 /// 更新柜子请求体
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct UpdateCabinetBody { pub struct UpdateCabinetBody {
pub project_id: Option<i64>, pub project_id: Option<i64>,
pub name: Option<String>, pub name: Option<String>,
pub abstract_id: Option<String>,
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -59,12 +66,13 @@ pub struct ProjectRow {
} }
/// 柜子表行 /// 柜子表行
#[derive(Debug, Clone, Serialize, sqlx::FromRow)] #[derive(Debug, Clone, Serialize)]
pub struct CabinetRow { pub struct CabinetRow {
pub id: i64, pub id: i64,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub project_id: Option<i64>, pub project_id: Option<i64>,
pub abstract_id: String, #[serde(skip_serializing_if = "Option::is_none")]
pub abstract_id: Option<String>,
pub imei: String, pub imei: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub iccid: Option<String>, pub iccid: Option<String>,
@ -84,6 +92,10 @@ pub struct CabinetRow {
pub full_count: Option<i64>, pub full_count: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub fault_count: Option<i64>, pub fault_count: Option<i64>,
pub rssi: Option<i32>,
pub pow_fail_dc: Option<bool>,
pub pow_fail_ac: Option<bool>,
pub is_online: Option<bool>,
} }
/// 树节点 /// 树节点
@ -133,7 +145,8 @@ pub struct CabinBoardWithCompartments {
pub struct CabinetDetail { pub struct CabinetDetail {
pub id: i64, pub id: i64,
pub project_id: Option<i64>, pub project_id: Option<i64>,
pub abstract_id: String, #[serde(skip_serializing_if = "Option::is_none")]
pub abstract_id: Option<String>,
pub imei: String, pub imei: String,
pub name: Option<String>, pub name: Option<String>,
pub status: i8, pub status: i8,
@ -173,7 +186,7 @@ pub async fn list_organizations(
let db = &state.mysql; let db = &state.mysql;
let rows = sqlx::query_as::<_, OrganizationRow>( 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) .fetch_all(db)
.await?; .await?;

View File

@ -103,7 +103,7 @@ async fn process_report(
"auth_str" => handle_auth_str(&msg, dev_id, redis).await?, "auth_str" => handle_auth_str(&msg, dev_id, redis).await?,
"login" => handle_login(&msg, dev_id, redis).await?, "login" => handle_login(&msg, dev_id, redis).await?,
"status_post" => handle_status_post(&msg, dev_id, mysql, 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_in" => handle_bat_in(&msg, dev_id, mysql).await?,
"bat_out" => handle_bat_out(&msg, dev_id, mysql).await?, "bat_out" => handle_bat_out(&msg, dev_id, mysql).await?,
"off" => handle_device_off(&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]; let expected_sign = &hex_hash[SIGN_START..SIGN_END];
if sign == expected_sign { if sign == expected_sign {
// 标记设备在线(5分钟 TTL // 标记设备在线(统一主key5分钟 TTL
let online_key = format!("device:online:{}", dev_id); let redis_key = format!("device:{}", dev_id);
let mut conn = redis.clone(); let mut conn = redis.clone();
let _: Result<(), _> = redis::cmd("SET") let _: Result<(), _> = redis::cmd("HSET")
.arg(&online_key) .arg(&redis_key)
.arg("1") .arg("online").arg("1")
.arg("EX") .query_async(&mut conn)
.await;
let _: Result<(), _> = redis::cmd("EXPIRE")
.arg(&redis_key)
.arg(300_u32) .arg(300_u32)
.query_async::<()>(&mut conn) .query_async(&mut conn)
.await; .await;
tracing::info!("[login] 成功 dev_id={}", dev_id); tracing::info!("[login] 成功 dev_id={}", dev_id);
@ -238,41 +241,89 @@ async fn handle_login(
Ok(()) 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( async fn handle_status_post(
msg: &serde_json::Value, msg: &serde_json::Value,
dev_id: &str, dev_id: &str,
mysql: &MySqlPool, mysql: &MySqlPool,
redis: &ConnectionManager, redis: &ConnectionManager,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// 存入 Redis 实时状态
let redis_key = format!("device:status:{}", dev_id);
let mut conn = redis.clone(); let mut conn = redis.clone();
let _: Result<(), _> = redis::cmd("SET") let device_key = format!("device:{}", dev_id);
.arg(&redis_key)
.arg(serde_json::to_string(msg).unwrap_or_default()) // 设置设备在线 + RSSI
.query_async::<()>(&mut conn) 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; .await;
// 更新设备在线标记5分钟 TTL // 设置TTL 300秒
let online_key = format!("device:online:{}", dev_id); let _: Result<(), _> = redis::cmd("EXPIRE")
let _: Result<(), _> = redis::cmd("SET") .arg(&device_key)
.arg(&online_key)
.arg("1")
.arg("EX")
.arg(300_u32) .arg(300_u32)
.query_async::<()>(&mut conn) .query_async(&mut conn)
.await; .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 // 持久化到 MySQL
let content = serde_json::json!({ let content = serde_json::json!({
"dev_id": dev_id, "dev_id": dev_id,
"sub_device_id": msg["sub_device_id"],
"status_hex": msg["status"],
"rssi": msg["rssi"], "rssi": msg["rssi"],
"status": msg["status"],
"timestamp": msg["timestamp"], "timestamp": msg["timestamp"],
}); });
sqlx::query( sqlx::query(
"INSERT INTO device_logs (cabinet_id, log_type, content) VALUES ( "INSERT INTO device_logs (cabinet_id, log_type, content) VALUES (
(SELECT id FROM cabinets WHERE imei = ?), 1, ? (SELECT id FROM cabinets WHERE imei = ?), 1, ?
@ -283,7 +334,7 @@ async fn handle_status_post(
.execute(mysql) .execute(mysql)
.await?; .await?;
tracing::debug!("[status_post] 已持久化 dev_id={}", dev_id); tracing::debug!("[status_post] 已解析并存储 dev_id={}", dev_id);
Ok(()) Ok(())
} }
@ -292,9 +343,34 @@ async fn handle_pow_fail(
msg: &serde_json::Value, msg: &serde_json::Value,
dev_id: &str, dev_id: &str,
mysql: &MySqlPool, mysql: &MySqlPool,
redis: &ConnectionManager,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
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!({ let content = serde_json::json!({
"dev_id": dev_id, "dev_id": dev_id,
"info": info,
"timestamp": msg["timestamp"], "timestamp": msg["timestamp"],
"sub_device_id": msg["sub_device_id"], "sub_device_id": msg["sub_device_id"],
}); });
@ -309,7 +385,7 @@ async fn handle_pow_fail(
.execute(mysql) .execute(mysql)
.await?; .await?;
tracing::info!("[pow_fail] 停电上报 dev_id={}", dev_id); tracing::info!("[pow_fail] 停电上报 dev_id={} info={}", dev_id, info);
Ok(()) Ok(())
} }

View File

@ -0,0 +1,9 @@
// babel-preset-taro 更多配置和用法https://docs.taro.zone/docs/next/babel-config
module.exports = {
presets: [
['taro', {
framework: 'react',
ts: true,
}],
],
}

View File

@ -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': '' },
},
},
},
},
}

View File

@ -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'))
}

View File

@ -0,0 +1,6 @@
module.exports = {
env: {
NODE_ENV: '"production"',
},
outputRoot: 'dist',
}

View File

@ -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"
}
}

View File

@ -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"
}

View File

@ -0,0 +1,161 @@
import Taro from '@tarojs/taro'
const BASE_URL = '/h5/api'
async function request<T>(
path: string,
options: { method?: string; body?: unknown; params?: Record<string, string> } = {},
): Promise<T> {
const token = Taro.getStorageSync('h5_token')
const headers: Record<string, string> = {
'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<string, string> }),
// 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 } }),
}

View File

@ -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',
},
],
},
}

View File

@ -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;
}

View File

@ -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

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="#2563eb"><path d="M12 4C10.9 4 10 4.9 10 6v36c0 1.1.9 2 2 2h24c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2H12zm0 6h24v28H12V10z"/></svg>

After

Width:  |  Height:  |  Size: 191 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="#999"><path d="M12 4C10.9 4 10 4.9 10 6v36c0 1.1.9 2 2 2h24c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2H12zm0 6h24v28H12V10z"/></svg>

After

Width:  |  Height:  |  Size: 188 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="#2563eb"><path d="M24 4L4 22h6v18h12V28h4v12h12V22h6L24 4z"/></svg>

After

Width:  |  Height:  |  Size: 134 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="#999"><path d="M24 4L4 22h6v18h12V28h4v12h12V22h6L24 4z"/></svg>

After

Width:  |  Height:  |  Size: 131 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="#2563eb"><path d="M24 24c4.42 0 8-3.58 8-8s-3.58-8-8-8-8 3.58-8 8 3.58 8 8 8zm0 4c-5.33 0-16 2.67-16 8v4h32v-4c0-5.33-10.67-8-16-8z"/></svg>

After

Width:  |  Height:  |  Size: 207 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="#999"><path d="M24 24c4.42 0 8-3.58 8-8s-3.58-8-8-8-8 3.58-8 8 3.58 8 8 8zm0 4c-5.33 0-16 2.67-16 8v4h32v-4c0-5.33-10.67-8-16-8z"/></svg>

After

Width:  |  Height:  |  Size: 204 B

View File

@ -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<number, string> = {
0: '#9ca3af', // idle
1: '#16a34a', // charging
2: '#dc2626', // fault
3: '#d1d5db', // offline
}
export default function ChannelCard({ channelId, status, voltage, current, onClick }: Props) {
return (
<View
style={{
backgroundColor: '#fff',
borderRadius: 12,
padding: 16,
border: `2px solid ${COLORS[status] || '#e5e7eb'}`,
cursor: onClick ? 'pointer' : undefined,
}}
onClick={onClick}
>
<View style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<Text style={{ fontSize: 24, fontWeight: 600, color: '#111' }}>#{channelId}</Text>
<ChannelStatusBadge status={status} />
</View>
{voltage !== undefined && (
<Text style={{ fontSize: 22, color: '#6b7280' }}>{voltage.toFixed(1)}V</Text>
)}
{current !== undefined && (
<Text style={{ fontSize: 22, color: '#6b7280' }}>{current.toFixed(1)}A</Text>
)}
</View>
)
}

View File

@ -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 (
<View style={{ position: 'fixed', inset: 0, zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(0,0,0,0.4)' }}>
<View style={{ width: '70%', backgroundColor: '#fff', borderRadius: 24, overflow: 'hidden' }}>
<View style={{ padding: '32px 24px', textAlign: 'center' }}>
<Text style={{ fontSize: 32, fontWeight: 600, color: '#111', marginBottom: 12, display: 'block' }}>{title}</Text>
<Text style={{ fontSize: 28, color: '#6b7280' }}>{message}</Text>
</View>
<View style={{ display: 'flex', borderTop: '1px solid #e5e7eb' }}>
<View
style={{ flex: 1, padding: '24px 0', textAlign: 'center', borderRight: '1px solid #e5e7eb' }}
onClick={onCancel}
>
<Text style={{ fontSize: 28, color: '#6b7280' }}></Text>
</View>
<View style={{ flex: 1, padding: '24px 0', textAlign: 'center' }} onClick={onConfirm}>
<Text style={{ fontSize: 28, color: '#2563eb', fontWeight: 500 }}></Text>
</View>
</View>
</View>
</View>
)
}

View File

@ -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 (
<View style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '80px 0' }}>
<Text style={{ fontSize: 60, marginBottom: 16 }}>😵</Text>
<Text style={{ fontSize: 28, color: '#6b7280', marginBottom: 24 }}>{message}</Text>
{onRetry && (
<Button
style={{ padding: '16px 48px', backgroundColor: '#2563eb', color: '#fff', borderRadius: 12, fontSize: 28, border: 'none' }}
onClick={onRetry}
>
</Button>
)}
</View>
)
}

View File

@ -0,0 +1,12 @@
import React from 'react'
import { View, Text } from '@tarojs/components'
export default function Loading({ text = '加载中...' }: { text?: string }) {
return (
<View style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '80px 0' }}>
<View style={{ width: 64, height: 64, border: '4px solid #e5e7eb', borderTopColor: '#2563eb', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
<Text style={{ marginTop: 16, fontSize: 28, color: '#9ca3af' }}>{text}</Text>
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</View>
)
}

View File

@ -0,0 +1,27 @@
import React from 'react'
import { View, Text } from '@tarojs/components'
const STATUS_MAP: Record<number, { label: string; cls: string }> = {
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 (
<View className={`status-badge ${s.cls}`}>
<Text>{s.label}</Text>
</View>
)
}
export function ChannelStatusBadge({ status }: { status: number }) {
const s = STATUS_MAP[status] || { label: '未知', cls: 'offline' }
return (
<View className={`status-badge ${s.cls}`}>
<Text>{s.label}</Text>
</View>
)
}

View File

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>安知充</title>
<script>
// 扫码入口: 提取 URL 中的 abstract_id 并重写路由
;(function () {
var match = window.location.pathname.match(/\/h5\/(\d{2}-\d{8})/);
if (match) {
window.__SCAN_ABSTRACT_ID__ = match[1];
window.history.replaceState({}, '', '/h5/');
}
})();
// Taro H5 路由 base
window.__TARO_ROUTER_BASE__ = '/h5/';
</script>
</head>
<body>
<div id="app"></div>
</body>
</html>

View File

@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '通道详情',
}

View File

@ -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<CompartmentDetail | null>(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 <Loading />
if (error && !data) return <ErrorState message={error} onRetry={fetchDetail} />
if (!data) return null
const isCharging = data.status === 1
const isIdle = data.status === 0
return (
<View style={{ padding: 24 }}>
{/* Real-time status */}
<View style={{ backgroundColor: '#fff', borderRadius: 16, padding: 24, marginBottom: 24 }}>
<View style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Text style={{ fontSize: 28, fontWeight: 600, color: '#111' }}></Text>
<ChannelStatusBadge status={data.status} />
</View>
<View style={{ display: 'flex', gap: 16 }}>
<View style={{ flex: 1, textAlign: 'center' }}>
<Text style={{ fontSize: 36, fontWeight: 700, color: '#2563eb' }}>{data.soc ?? '-'}%</Text>
<Text style={{ fontSize: 24, color: '#6b7280' }}>SOC</Text>
</View>
<View style={{ flex: 1, textAlign: 'center' }}>
<Text style={{ fontSize: 36, fontWeight: 700, color: '#2563eb' }}>{data.soh ?? '-'}%</Text>
<Text style={{ fontSize: 24, color: '#6b7280' }}>SOH</Text>
</View>
<View style={{ flex: 1, textAlign: 'center' }}>
<Text style={{ fontSize: 36, fontWeight: 700, color: '#2563eb' }}>{data.energy?.toFixed(1) ?? '-'}</Text>
<Text style={{ fontSize: 24, color: '#6b7280' }}>(kWh)</Text>
</View>
</View>
</View>
{/* Electrical params */}
<View style={{ backgroundColor: '#fff', borderRadius: 16, padding: 24, marginBottom: 24 }}>
<Text style={{ fontSize: 28, fontWeight: 600, color: '#111', marginBottom: 16 }}></Text>
<View style={{ display: 'flex', flexWrap: 'wrap', gap: 16 }}>
<View style={{ flex: '0 0 calc(50% - 8px)', display: 'flex', justifyContent: 'space-between' }}>
<Text style={{ color: '#6b7280', fontSize: 26 }}></Text>
<Text style={{ fontWeight: 500, color: '#111', fontSize: 26 }}>{data.voltage?.toFixed(1) ?? '-'} V</Text>
</View>
<View style={{ flex: '0 0 calc(50% - 8px)', display: 'flex', justifyContent: 'space-between' }}>
<Text style={{ color: '#6b7280', fontSize: 26 }}></Text>
<Text style={{ fontWeight: 500, color: '#111', fontSize: 26 }}>{data.current?.toFixed(1) ?? '-'} A</Text>
</View>
<View style={{ flex: '0 0 calc(50% - 8px)', display: 'flex', justifyContent: 'space-between' }}>
<Text style={{ color: '#6b7280', fontSize: 26 }}></Text>
<Text style={{ fontWeight: 500, color: '#111', fontSize: 26 }}>{data.power?.toFixed(1) ?? '-'} W</Text>
</View>
<View style={{ flex: '0 0 calc(50% - 8px)', display: 'flex', justifyContent: 'space-between' }}>
<Text style={{ color: '#6b7280', fontSize: 26 }}></Text>
<Text style={{ fontWeight: 500, color: '#111', fontSize: 26 }}>{data.energy?.toFixed(3) ?? '-'} kWh</Text>
</View>
</View>
</View>
{/* BMS data */}
{data.cell_voltages && data.cell_voltages.length > 0 && (
<View style={{ backgroundColor: '#fff', borderRadius: 16, padding: 24, marginBottom: 24 }}>
<Text style={{ fontSize: 28, fontWeight: 600, color: '#111', marginBottom: 16 }}>BMS </Text>
<View style={{ marginBottom: 16 }}>
<Text style={{ fontSize: 24, color: '#6b7280', marginBottom: 8 }}> (V)</Text>
<View style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{data.cell_voltages.map((v, i) => (
<Text key={i} style={{ backgroundColor: '#eff6ff', padding: '4px 12px', borderRadius: 8, fontSize: 24, color: '#2563eb' }}>
#{i + 1}: {v.toFixed(3)}
</Text>
))}
</View>
</View>
{data.cell_temps && data.cell_temps.length > 0 && (
<View style={{ marginBottom: 16 }}>
<Text style={{ fontSize: 24, color: '#6b7280', marginBottom: 8 }}> (°C)</Text>
<View style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{data.cell_temps.map((t, i) => (
<Text
key={i}
style={{
backgroundColor: t > 50 ? '#fef2f2' : '#fff7ed',
padding: '4px 12px', borderRadius: 8, fontSize: 24,
color: t > 50 ? '#dc2626' : '#f97316',
}}
>
#{i + 1}: {t.toFixed(1)}
</Text>
))}
</View>
</View>
)}
{data.bms_alerts && data.bms_alerts.length > 0 && (
<View>
<Text style={{ fontSize: 24, color: '#dc2626', marginBottom: 8 }}></Text>
{data.bms_alerts.map((alert, i) => (
<Text key={i} style={{ fontSize: 24, color: '#dc2626' }}> {alert}</Text>
))}
</View>
)}
</View>
)}
{/* Charge records */}
{data.charge_records && data.charge_records.length > 0 && (
<View style={{ backgroundColor: '#fff', borderRadius: 16, padding: 24, marginBottom: 24 }}>
<Text style={{ fontSize: 28, fontWeight: 600, color: '#111', marginBottom: 16 }}></Text>
{data.charge_records.slice(0, 5).map((record) => (
<View key={record.id} style={{ display: 'flex', justifyContent: 'space-between', padding: '12px 0', borderBottom: '1px solid #f3f4f6' }}>
<View>
<Text style={{ fontSize: 26, color: '#111' }}>{record.start_time.slice(0, 16)}</Text>
{record.end_time && <Text style={{ fontSize: 22, color: '#9ca3af' }}>{record.end_time.slice(0, 16)}</Text>}
</View>
<View style={{ textAlign: 'right' }}>
<Text style={{ fontSize: 26, color: '#111' }}>{record.energy_kwh.toFixed(2)} kWh</Text>
<Text style={{ fontSize: 22, color: '#9ca3af' }}>¥{record.cost.toFixed(2)}</Text>
</View>
</View>
))}
</View>
)}
{/* Action buttons */}
<View style={{ display: 'flex', flexDirection: 'column', gap: 16, paddingBottom: 32 }}>
{isIdle && (
<View
style={{
height: 88, backgroundColor: '#16a34a', borderRadius: 12,
display: 'flex', alignItems: 'center', justifyContent: 'center',
opacity: actionLoading ? 0.5 : 1,
}}
onClick={() => handleAction('start')}
>
<Text style={{ color: '#fff', fontSize: 32, fontWeight: 500 }}>
{actionLoading === 'start' ? '操作中...' : '开始充电'}
</Text>
</View>
)}
{isCharging && (
<View
style={{
height: 88, backgroundColor: '#f97316', borderRadius: 12,
display: 'flex', alignItems: 'center', justifyContent: 'center',
opacity: actionLoading ? 0.5 : 1,
}}
onClick={() => setConfirmAction('stop')}
>
<Text style={{ color: '#fff', fontSize: 32, fontWeight: 500 }}>
{actionLoading === 'stop' ? '操作中...' : '停止充电'}
</Text>
</View>
)}
<View
style={{
height: 88, backgroundColor: '#fff', borderRadius: 12,
display: 'flex', alignItems: 'center', justifyContent: 'center',
border: '1px solid #d1d5db', opacity: actionLoading ? 0.5 : 1,
}}
onClick={() => setConfirmAction('door')}
>
<Text style={{ color: '#374151', fontSize: 32, fontWeight: 500 }}>
{actionLoading === 'door' ? '操作中...' : '开门'}
</Text>
</View>
</View>
{/* Confirm dialogs */}
<ConfirmDialog
open={confirmAction === 'stop'}
title='停止充电'
message='确定要停止当前充电?'
onConfirm={() => handleAction('stop')}
onCancel={() => setConfirmAction(null)}
/>
<ConfirmDialog
open={confirmAction === 'door'}
title='开门'
message='确定要打开仓门?'
onConfirm={() => handleAction('door')}
onCancel={() => setConfirmAction(null)}
/>
</View>
)
}

View File

@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '设备详情',
}

View File

@ -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<CabinetDetail | null>(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 <Loading />
if (error) return <ErrorState message={error} onRetry={fetchDetail} />
if (!data) return null
return (
<View style={{ padding: 24 }}>
{/* Basic info */}
<View style={{ backgroundColor: '#fff', borderRadius: 16, padding: 24, marginBottom: 24 }}>
<View style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<View>
<Text style={{ fontSize: 32, fontWeight: 600, color: '#111' }}>{data.name || data.abstract_id}</Text>
<Text style={{ fontSize: 24, color: '#6b7280', marginTop: 4 }}>ID: {data.abstract_id}</Text>
{data.imei && <Text style={{ fontSize: 24, color: '#9ca3af' }}>IMEI: {data.imei}</Text>}
</View>
<CabinetStatusBadge status={data.status} />
</View>
</View>
{/* Cabin boards */}
{data.cabin_boards.map((board) => (
<View key={board.id} style={{ marginBottom: 24 }}>
<View style={{ display: 'flex', alignItems: 'center', marginBottom: 12 }}>
<Text style={{ fontSize: 26, fontWeight: 500, color: '#374151' }}> {board.board_id}</Text>
<Text style={{ fontSize: 24, color: board.status === 1 ? '#16a34a' : '#9ca3af', marginLeft: 12 }}>
({board.status === 1 ? '在线' : '离线'})
</Text>
</View>
<View style={{ display: 'flex', flexWrap: 'wrap', gap: 12 }}>
{board.compartments.map((comp) => (
<View key={comp.id} style={{ flex: '0 0 calc(33.33% - 8px)' }}>
<ChannelCard
channelId={comp.channel_id}
status={comp.status}
voltage={comp.voltage}
current={comp.current}
onClick={() => Taro.navigateTo({ url: `/pages/compartment-detail/index?id=${comp.id}` })}
/>
</View>
))}
</View>
</View>
))}
</View>
)
}

View File

@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '设备',
}

View File

@ -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<CabinetItem[]>([])
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 <Loading />
if (error) return <ErrorState message={error} onRetry={() => fetchList()} />
return (
<View style={{ padding: 24 }}>
{/* List */}
{list.length === 0 ? (
<View style={{ padding: '80px 0', textAlign: 'center' }}>
<Text style={{ fontSize: 28, color: '#9ca3af' }}></Text>
</View>
) : (
<View style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{list.map((item) => (
<View
key={item.id}
style={{ backgroundColor: '#fff', borderRadius: 16, padding: 24, cursor: 'pointer' }}
onClick={() => Taro.navigateTo({ url: `/pages/device-detail/index?id=${item.id}` })}
>
<View style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<Text style={{ fontSize: 30, fontWeight: 600, color: '#111' }}>
{item.name || item.abstract_id}
</Text>
<CabinetStatusBadge status={item.status} />
</View>
<Text style={{ fontSize: 24, color: '#9ca3af' }}>ID: {item.abstract_id}</Text>
</View>
))}
</View>
)}
</View>
)
}

View File

@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '首页',
}

View File

@ -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<DashboardData | null>(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 <Loading />
return (
<View style={{ padding: 24 }}>
{/* Welcome */}
<View style={{ marginBottom: 32 }}>
<Text style={{ fontSize: 36, fontWeight: 600, color: '#111' }}>
{user?.name ? `${user.name},你好` : '你好'}
</Text>
<Text style={{ fontSize: 26, color: '#6b7280', marginTop: 8 }}>使</Text>
</View>
{/* Stats Grid — flex 2x2 */}
<View style={{ display: 'flex', flexWrap: 'wrap', gap: 16, marginBottom: 32 }}>
<View style={{ flex: '0 0 calc(50% - 8px)', backgroundColor: '#fff', borderRadius: 16, padding: 24 }}>
<Text style={{ fontSize: 40, fontWeight: 700, color: '#2563eb' }}>{data?.online_cabinets ?? 0}</Text>
<Text style={{ fontSize: 24, color: '#6b7280', marginTop: 4 }}>线</Text>
</View>
<View style={{ flex: '0 0 calc(50% - 8px)', backgroundColor: '#fff', borderRadius: 16, padding: 24 }}>
<Text style={{ fontSize: 40, fontWeight: 700, color: '#16a34a' }}>{data?.charging_count ?? 0}</Text>
<Text style={{ fontSize: 24, color: '#6b7280', marginTop: 4 }}></Text>
</View>
<View style={{ flex: '0 0 calc(50% - 8px)', backgroundColor: '#fff', borderRadius: 16, padding: 24 }}>
<Text style={{ fontSize: 40, fontWeight: 700, color: '#f59e0b' }}>{data?.idle_channels ?? 0}</Text>
<Text style={{ fontSize: 24, color: '#6b7280', marginTop: 4 }}></Text>
</View>
<View style={{ flex: '0 0 calc(50% - 8px)', backgroundColor: '#fff', borderRadius: 16, padding: 24 }}>
<Text style={{ fontSize: 40, fontWeight: 700, color: data?.fault_channels ? '#dc2626' : '#6b7280' }}>
{data?.fault_channels ?? 0}
</Text>
<Text style={{ fontSize: 24, color: '#6b7280', marginTop: 4 }}></Text>
</View>
</View>
{/* Today summary */}
<View style={{ backgroundColor: '#fff', borderRadius: 16, padding: 24, marginBottom: 24 }}>
<Text style={{ fontSize: 28, fontWeight: 600, color: '#111', marginBottom: 16 }}></Text>
<View style={{ display: 'flex', justifyContent: 'space-between' }}>
<View style={{ textAlign: 'center', flex: 1 }}>
<Text style={{ fontSize: 32, fontWeight: 700, color: '#111' }}>{data?.today_charge_count ?? 0}</Text>
<Text style={{ fontSize: 22, color: '#6b7280' }}></Text>
</View>
<View style={{ textAlign: 'center', flex: 1 }}>
<Text style={{ fontSize: 32, fontWeight: 700, color: '#111' }}>{data?.today_energy_kwh?.toFixed(1) ?? '0.0'}</Text>
<Text style={{ fontSize: 22, color: '#6b7280' }}>(kWh)</Text>
</View>
<View style={{ textAlign: 'center', flex: 1 }}>
<Text style={{ fontSize: 32, fontWeight: 700, color: '#111' }}>{data?.today_charge_hours?.toFixed(1) ?? '0.0'}</Text>
<Text style={{ fontSize: 22, color: '#6b7280' }}>(h)</Text>
</View>
</View>
</View>
{/* Fault alerts */}
{data?.alerts && data.alerts.length > 0 && (
<View style={{ backgroundColor: '#fff', borderRadius: 16, padding: 24, marginBottom: 24 }}>
<Text style={{ fontSize: 28, fontWeight: 600, color: '#dc2626', marginBottom: 16 }}></Text>
{data.alerts.slice(0, 5).map((alert, i) => (
<View key={i} style={{ padding: '12px 0', borderBottom: i < data.alerts.length - 1 ? '1px solid #f3f4f6' : 'none' }}>
<Text style={{ fontSize: 26, color: '#111' }}>{alert.cabinet} {alert.channel}</Text>
<Text style={{ fontSize: 22, color: '#9ca3af' }}>{alert.project}</Text>
</View>
))}
</View>
)}
{/* Quick Actions */}
<View style={{ backgroundColor: '#fff', borderRadius: 16, padding: 24 }}>
<Text style={{ fontSize: 28, fontWeight: 600, color: '#111', marginBottom: 16 }}></Text>
<View
style={{ padding: '20px 0', borderBottom: '1px solid #f3f4f6', cursor: 'pointer' }}
onClick={() => Taro.switchTab({ url: '/pages/devices/index' })}
>
<Text style={{ fontSize: 28, color: '#374151' }}></Text>
</View>
<View
style={{ padding: '20px 0', cursor: 'pointer' }}
onClick={() => Taro.switchTab({ url: '/pages/me/index' })}
>
<Text style={{ fontSize: 28, color: '#374151' }}></Text>
</View>
</View>
</View>
)
}

View File

@ -0,0 +1,4 @@
export default {
navigationBarTitleText: '登录',
navigationStyle: 'custom',
}

View File

@ -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 (
<View style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column', backgroundColor: '#fff' }}>
{/* Header */}
<View style={{ padding: '80px 48px 40px' }}>
<Text style={{ fontSize: 56, fontWeight: 700, color: '#111', display: 'block' }}></Text>
<Text style={{ fontSize: 28, color: '#6b7280', marginTop: 12 }}></Text>
</View>
{/* Form */}
<View style={{ padding: '0 48px' }}>
<View style={{ marginBottom: 32 }}>
<Text style={{ fontSize: 26, color: '#374151', marginBottom: 8, display: 'block' }}></Text>
<Input
style={{ height: 88, border: '1px solid #d1d5db', borderRadius: 12, padding: '0 24px', fontSize: 28 }}
placeholder='请输入手机号'
type='text'
maxlength={11}
value={phone}
onInput={(e) => setPhone(e.detail.value)}
/>
</View>
<View style={{ marginBottom: 32 }}>
<Text style={{ fontSize: 26, color: '#374151', marginBottom: 8, display: 'block' }}></Text>
<Input
style={{ height: 88, border: '1px solid #d1d5db', borderRadius: 12, padding: '0 24px', fontSize: 28 }}
placeholder='请输入密码'
type='password'
value={password}
onInput={(e) => setPassword(e.detail.value)}
/>
</View>
{error && (
<Text style={{ color: '#dc2626', fontSize: 26, marginBottom: 16, display: 'block' }}>{error}</Text>
)}
<Button
style={{
height: 88, backgroundColor: '#2563eb', color: '#fff', borderRadius: 12,
fontSize: 32, fontWeight: 500, display: 'flex', alignItems: 'center', justifyContent: 'center',
border: 'none', opacity: loading ? 0.6 : 1,
}}
disabled={loading}
onClick={handleLogin}
>
{loading ? '登录中...' : '登录'}
</Button>
</View>
</View>
)
}

View File

@ -0,0 +1,3 @@
export default {
navigationBarTitleText: '我的',
}

View File

@ -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 (
<View style={{ padding: 24 }}>
{/* User info */}
<View style={{ backgroundColor: '#fff', borderRadius: 16, padding: 24, marginBottom: 32, display: 'flex', alignItems: 'center', gap: 16 }}>
<View
style={{
width: 56, height: 56, borderRadius: '50%', backgroundColor: '#dbeafe',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<Text style={{ fontSize: 28, fontWeight: 700, color: '#2563eb' }}>
{(user?.name || user?.phone || '?').charAt(0)}
</Text>
</View>
<View>
<Text style={{ fontSize: 28, fontWeight: 600, color: '#111' }}>{user?.name || '用户'}</Text>
<Text style={{ fontSize: 24, color: '#6b7280' }}>{user?.phone}</Text>
</View>
</View>
{/* Menu items */}
<View style={{ backgroundColor: '#fff', borderRadius: 16 }}>
<View
style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '24px 24px' }}
onClick={() => Taro.showToast({ title: '功能开发中', icon: 'none' })}
>
<Text style={{ fontSize: 26, color: '#111' }}></Text>
<Text style={{ fontSize: 24, color: '#9ca3af' }}></Text>
</View>
</View>
{/* Logout */}
<View style={{ marginTop: 32 }}>
<View
style={{
height: 88, backgroundColor: '#fff', borderRadius: 12,
display: 'flex', alignItems: 'center', justifyContent: 'center',
border: '1px solid #fecaca',
}}
onClick={() => setShowLogout(true)}
>
<Text style={{ color: '#dc2626', fontSize: 28, fontWeight: 500 }}>退</Text>
</View>
</View>
<ConfirmDialog
open={showLogout}
title='退出登录'
message='确定要退出当前账号?'
onConfirm={handleLogout}
onCancel={() => setShowLogout(false)}
/>
</View>
)
}

View File

@ -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<void>
logout: () => void
restore: () => void
}
export const useH5AuthStore = create<AuthState>((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
}
},
}))

View File

@ -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"]
}

View File

@ -1,13 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/h5/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>安知充</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -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"
}
}

View File

@ -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 (
<div className="flex min-h-screen flex-col items-center justify-center gap-3 bg-white">
<p className="text-sm text-gray-500">{error}</p>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center bg-white">
<Loading />
</div>
);
}
export default function App() {
const restore = useH5AuthStore((s) => s.restore);
useEffect(() => {
restore();
}, [restore]);
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<H5Login />} />
<Route element={<H5Layout />}>
<Route index element={<H5Home />} />
<Route path="devices" element={<H5Devices />} />
<Route path="devices/:id" element={<H5DeviceDetail />} />
<Route path="me" element={<H5Me />} />
</Route>
<Route path="compartments/:id" element={<H5CompartmentDetail />} />
<Route path=":abstractId" element={<ScanRedirect />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
);
}

View File

@ -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<Organization[]>('/organizations'),
create: (data: { name: string }) => api.post<Organization>('/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<Project[]>(`/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<Cabinet[]>(`/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<CabinetDetail>(`/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<TreeNode[]>('/organization-tree'),
};

View File

@ -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<Role[]> {
const res = await api.get<Role[]>('/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<PermissionItem[]> {
const res = await api.get<PermissionItem[]>('/permissions');
return res.data ?? [];
}
// ── 用户权限 ──
/** 获取用户权限详情 */
export async function fetchUserPermissions(userId: number): Promise<UserPermissionDetail> {
const res = await api.get<UserPermissionDetail>(`/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<ScopeEntry[]> {
const res = await api.get<ScopeEntry[]>(`/users/${userId}/scopes`);
return res.data ?? [];
}

View File

@ -1,72 +0,0 @@
/**
* HTTP
* - Authorization token
* -
* -
*/
const API_BASE = import.meta.env.VITE_API_BASE || '/pms/api';
export interface ApiResponse<T = unknown> {
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<T>(
url: string,
options: RequestInit = {},
): Promise<ApiResponse<T>> {
const token = getToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
};
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: <T>(url: string) => request<T>(url),
post: <T>(url: string, data?: unknown) =>
request<T>(url, { method: 'POST', body: JSON.stringify(data) }),
put: <T>(url: string, data?: unknown) =>
request<T>(url, { method: 'PUT', body: JSON.stringify(data) }),
delete: <T>(url: string) => request<T>(url, { method: 'DELETE' }),
};

View File

@ -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<UserListData> {
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<UserListData>(`/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<OperationLogListData> {
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<OperationLogListData>(`/operation-logs?${searchParams.toString()}`);
return res.data;
}

View File

@ -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<H5LoginResponse>('/h5/auth/login', data),
// 首页仪表盘
getDashboard: () => api.get<DashboardData>('/h5/dashboard'),
// 项目列表
getProjects: () => api.get<H5Project[]>('/h5/projects'),
// 设备列表(按项目)
getCabinets: (projectId?: number) =>
api.get<H5Cabinet[]>(`/h5/cabinets${projectId ? `?project_id=${projectId}` : ''}`),
// 设备详情(含仓板+仓体状态)
getCabinetDetail: (id: number) =>
api.get<CabinetDetail>(`/h5/cabinets/${id}`),
// 通过 abstract_id 获取设备详情(扫码)
getCabinetByAbstractId: (abstractId: string) =>
api.get<CabinetDetail>(`/h5/cabinets/abstract/${abstractId}`),
// 仓体/通道详情
getCompartmentDetail: (id: number) =>
api.get<CompartmentDetail>(`/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 }),
};

View File

@ -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<void>;
logout: () => void;
restore: () => void;
}
export const useH5AuthStore = create<AuthState>((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 });
}
},
}));

View File

@ -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 (
<nav className="fixed bottom-0 left-0 right-0 z-50 flex h-14 items-center justify-around border-t border-gray-200 bg-white pb-safe">
{tabs.map((tab) => {
const isActive = activeKey === tab.key ||
(tab.key === '/pms' && location.pathname === '/pms');
return (
<button
key={tab.key}
className="flex flex-1 flex-col items-center justify-center gap-0.5 py-1"
onClick={() => navigate(tab.key)}
>
<tab.icon active={isActive} />
<span className={`text-xs ${isActive ? 'font-semibold text-blue-600' : 'text-gray-500'}`}>
{tab.label}
</span>
</button>
);
})}
</nav>
);
}
function HomeIcon({ active }: { active: boolean }) {
return (
<svg className="h-5 w-5" viewBox="0 0 24 24" fill={active ? '#2563eb' : '#9ca3af'}>
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" />
</svg>
);
}
function DeviceIcon({ active }: { active: boolean }) {
return (
<svg className="h-5 w-5" viewBox="0 0 24 24" fill={active ? '#2563eb' : '#9ca3af'}>
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" />
</svg>
);
}
function MeIcon({ active }: { active: boolean }) {
return (
<svg className="h-5 w-5" viewBox="0 0 24 24" fill={active ? '#2563eb' : '#9ca3af'}>
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
</svg>
);
}
// ─── 统计卡片 ───
interface StatCardProps {
label: string;
value: string | number;
sub?: string;
color?: string;
}
export function StatCard({ label, value, sub, color = 'blue' }: StatCardProps) {
const colors: Record<string, string> = {
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 (
<div className={`flex flex-col items-center justify-center rounded-xl p-3 ${colors[color] || colors.blue}`}>
<span className="text-2xl font-bold">{value}</span>
<span className="mt-0.5 text-xs">{label}</span>
{sub && <span className="mt-0.5 text-[10px] opacity-70">{sub}</span>}
</div>
);
}
// ─── 告警项 ───
interface AlertCardProps {
project: string;
cabinet: string;
channel: number;
alert_type: string;
}
export function AlertCard({ project, cabinet, channel, alert_type }: AlertCardProps) {
return (
<div className="flex items-start gap-2 rounded-lg bg-red-50 p-3 text-sm">
<span className="mt-0.5 text-base"></span>
<div>
<span className="font-medium text-red-700">{project}</span>
<span className="text-red-600"> - {cabinet} {channel}</span>
<p className="mt-0.5 text-red-500">{alert_type}</p>
</div>
</div>
);
}
// ─── 项目卡片 ───
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 (
<button className="w-full rounded-xl border border-gray-200 bg-white p-4 text-left active:bg-gray-50" onClick={onClick}>
<div className="flex items-center justify-between">
<span className="text-base font-semibold text-gray-900">{name}</span>
<span className="text-xs text-gray-500">{cabinet_count}</span>
</div>
<div className="mt-2 flex gap-3 text-xs">
<span className="text-green-600">{charging}</span>
<span className="text-gray-500">{idle}</span>
{fault > 0 && <span className="text-red-600">{fault}</span>}
</div>
</button>
);
}
// ─── 通道卡片 ───
interface ChannelCardProps {
channelId: number;
status: number; // 0=空闲 1=充电中 2=故障 3=离线
voltage?: number;
current?: number;
onClick: () => void;
}
const statusConfig: Record<number, { label: string; bg: string; dot: string }> = {
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 (
<button className={`flex flex-col items-center rounded-lg p-2.5 ${cfg.bg} active:opacity-70`} onClick={onClick}>
<span className="text-xs font-medium text-gray-700">{channelId}</span>
<span className={`mt-1 inline-block h-2 w-2 rounded-full ${cfg.dot}`} />
<span className="mt-0.5 text-[10px] text-gray-500">{cfg.label}</span>
{voltage !== undefined && (
<span className="mt-0.5 text-[10px] text-gray-400">{voltage.toFixed(1)}V</span>
)}
{current !== undefined && (
<span className="text-[10px] text-gray-400">{current.toFixed(1)}A</span>
)}
</button>
);
}
// ─── 加载状态 ───
export function Loading() {
return (
<div className="flex h-40 items-center justify-center">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
<span className="ml-2 text-sm text-gray-500">...</span>
</div>
);
}
// ─── 错误状态 ───
export function ErrorState({ message, onRetry }: { message: string; onRetry?: () => void }) {
return (
<div className="flex flex-col items-center justify-center gap-3 py-16">
<span className="text-4xl">😵</span>
<p className="text-sm text-gray-500">{message}</p>
{onRetry && (
<button className="rounded-lg bg-blue-600 px-4 py-2 text-sm text-white" onClick={onRetry}>
</button>
)}
</div>
);
}
// ─── 页面容器 ───
export function PageContainer({ children, title, onBack }: { children: ReactNode; title?: string; onBack?: () => void }) {
return (
<div className="flex min-h-screen flex-col bg-gray-50 pb-16">
{title && (
<header className="flex items-center gap-2 border-b border-gray-200 bg-white px-4 py-3">
{onBack && (
<button className="text-lg text-gray-600" onClick={onBack}>
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
</svg>
</button>
)}
<h1 className="text-base font-semibold text-gray-900">{title}</h1>
</header>
)}
<div className="flex-1 px-4 py-4">{children}</div>
</div>
);
}
// ─── 确认弹窗 ───
export function ConfirmDialog({ open, title, message, onConfirm, onCancel }: {
open: boolean;
title: string;
message: string;
onConfirm: () => void;
onCancel: () => void;
}) {
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onCancel}>
<div className="mx-4 w-full max-w-xs rounded-xl bg-white p-5" onClick={(e) => e.stopPropagation()}>
<h3 className="text-center text-base font-semibold text-gray-900">{title}</h3>
<p className="mt-2 text-center text-sm text-gray-500">{message}</p>
<div className="mt-5 flex gap-3">
<button className="flex-1 rounded-lg border border-gray-300 py-2 text-sm text-gray-700" onClick={onCancel}></button>
<button className="flex-1 rounded-lg bg-blue-600 py-2 text-sm text-white" onClick={onConfirm}></button>
</div>
</div>
</div>
);
}

View File

@ -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 <Navigate to={`/pms/login?redirect=${encodeURIComponent(location.pathname + location.search)}`} replace />;
}
return (
<div className="h5-layout mx-auto max-w-md">
<div className="min-h-screen pb-14">
<Outlet />
</div>
<BottomNav />
</div>
);
}
/** 不带底部导航的 H5 布局(登录页、通道详情等全屏页面) */
export function H5PlainLayout({ children }: { children: React.ReactNode }) {
return (
<div className="h5-layout mx-auto max-w-md">
<div className="min-h-screen">{children}</div>
</div>
);
}

View File

@ -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<CompartmentDetail | null>(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 <PageContainer title="通道详情"><Loading /></PageContainer>;
if (error && !data) return <PageContainer title="通道详情"><ErrorState message={error} onRetry={fetchDetail} /></PageContainer>;
if (!data) return null;
const statusLabel: Record<number, string> = { 0: '空闲', 1: '充电中', 2: '故障', 3: '离线' };
const isCharging = data.status === 1;
const isIdle = data.status === 0;
return (
<PageContainer title={`通道${data.channel_id}`} onBack={() => navigate(-1)}>
{/* 实时状态 */}
<section className="mb-4 rounded-xl bg-white p-4">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-gray-900"></h3>
<span className={`rounded-full px-3 py-1 text-xs font-medium ${
data.status === 1 ? 'bg-green-100 text-green-700' :
data.status === 0 ? 'bg-gray-100 text-gray-500' :
'bg-red-100 text-red-700'
}`}>
{statusLabel[data.status] || '未知'}
</span>
</div>
<div className="mt-4 grid grid-cols-3 gap-4 text-center">
<div>
<p className="text-lg font-bold text-blue-600">{data.soc ?? '-'}%</p>
<p className="text-xs text-gray-500">SOC</p>
</div>
<div>
<p className="text-lg font-bold text-blue-600">{data.soh ?? '-'}%</p>
<p className="text-xs text-gray-500">SOH</p>
</div>
<div>
<p className="text-lg font-bold text-blue-600">{data.energy?.toFixed(1) ?? '-'}</p>
<p className="text-xs text-gray-500">(kWh)</p>
</div>
</div>
</section>
{/* 电气参数 */}
<section className="mb-4 rounded-xl bg-white p-4">
<h3 className="mb-3 text-base font-semibold text-gray-900"></h3>
<div className="grid grid-cols-2 gap-y-3 text-sm">
<div className="flex justify-between pr-4">
<span className="text-gray-500"></span>
<span className="font-medium text-gray-900">{data.voltage?.toFixed(1) ?? '-'} V</span>
</div>
<div className="flex justify-between pl-4">
<span className="text-gray-500"></span>
<span className="font-medium text-gray-900">{data.current?.toFixed(1) ?? '-'} A</span>
</div>
<div className="flex justify-between pr-4">
<span className="text-gray-500"></span>
<span className="font-medium text-gray-900">{data.power?.toFixed(1) ?? '-'} W</span>
</div>
<div className="flex justify-between pl-4">
<span className="text-gray-500"></span>
<span className="font-medium text-gray-900">{data.energy?.toFixed(3) ?? '-'} kWh</span>
</div>
</div>
</section>
{/* BMS 数据 */}
{data.cell_voltages && data.cell_voltages.length > 0 && (
<section className="mb-4 rounded-xl bg-white p-4">
<h3 className="mb-3 text-base font-semibold text-gray-900">BMS </h3>
{/* 单体电压 */}
<div className="mb-3">
<p className="mb-1 text-xs text-gray-500"> (V)</p>
<div className="flex flex-wrap gap-1">
{data.cell_voltages.map((v, i) => (
<span key={i} className="rounded bg-blue-50 px-2 py-0.5 text-xs text-blue-700">
#{i + 1}: {v.toFixed(3)}
</span>
))}
</div>
</div>
{/* 单体温度 */}
{data.cell_temps && data.cell_temps.length > 0 && (
<div className="mb-3">
<p className="mb-1 text-xs text-gray-500"> (°C)</p>
<div className="flex flex-wrap gap-1">
{data.cell_temps.map((t, i) => (
<span key={i} className={`rounded px-2 py-0.5 text-xs ${t > 50 ? 'bg-red-50 text-red-700' : 'bg-orange-50 text-orange-700'}`}>
#{i + 1}: {t.toFixed(1)}
</span>
))}
</div>
</div>
)}
{/* BMS 告警 */}
{data.bms_alerts && data.bms_alerts.length > 0 && (
<div>
<p className="mb-1 text-xs text-red-500"></p>
<ul className="list-inside list-disc text-xs text-red-600">
{data.bms_alerts.map((alert, i) => (
<li key={i}>{alert}</li>
))}
</ul>
</div>
)}
</section>
)}
{/* 充电曲线示意 */}
{isCharging && (
<section className="mb-4 rounded-xl bg-white p-4">
<h3 className="mb-3 text-base font-semibold text-gray-900">线</h3>
{/* TODO: 待后端提供充电曲线API后替换为真实数据 */}
<div className="flex h-24 items-end gap-1">
{[30, 45, 55, 50, 65, 70, 75, 72, 80, 85, 82, 88].map((h, i) => (
<div key={i} className="flex-1 rounded-t bg-blue-500" style={{ height: `${h}%` }} title={`${h}%`} />
))}
</div>
<div className="mt-1 flex justify-between text-[10px] text-gray-400">
<span>12:00</span>
<span></span>
</div>
{/* ponytail: static mock chart, replace with real chart lib when backend provides data */}
</section>
)}
{/* 充电记录 */}
{data.charge_records && data.charge_records.length > 0 && (
<section className="mb-4 rounded-xl bg-white p-4">
<h3 className="mb-3 text-base font-semibold text-gray-900"></h3>
<div className="flex flex-col gap-2">
{data.charge_records.slice(0, 5).map((record) => (
<div key={record.id} className="flex items-center justify-between text-sm">
<div>
<p className="text-gray-900">{record.start_time.slice(0, 16)}</p>
{record.end_time && <p className="text-xs text-gray-400">{record.end_time.slice(0, 16)}</p>}
</div>
<div className="text-right">
<p className="text-gray-900">{record.energy_kwh.toFixed(2)} kWh</p>
<p className="text-xs text-gray-400">¥{record.cost.toFixed(2)}</p>
</div>
</div>
))}
</div>
</section>
)}
{/* 操作按钮 */}
<div className="flex flex-col gap-3 pb-4">
{isIdle && (
<button
className="w-full rounded-lg bg-green-600 py-3 text-base font-medium text-white disabled:opacity-50"
disabled={!!actionLoading}
onClick={() => handleAction('start')}
>
{actionLoading === 'start' ? '操作中...' : '开始充电'}
</button>
)}
{isCharging && (
<button
className="w-full rounded-lg bg-orange-500 py-3 text-base font-medium text-white disabled:opacity-50"
disabled={!!actionLoading}
onClick={() => setConfirmAction('stop')}
>
{actionLoading === 'stop' ? '操作中...' : '停止充电'}
</button>
)}
<button
className="w-full rounded-lg border border-gray-300 bg-white py-3 text-base font-medium text-gray-700 disabled:opacity-50"
disabled={!!actionLoading}
onClick={() => setConfirmAction('door')}
>
{actionLoading === 'door' ? '操作中...' : '开门'}
</button>
</div>
{/* 确认弹窗 */}
<ConfirmDialog
open={confirmAction === 'stop'}
title="停止充电"
message="确定要停止当前充电?"
onConfirm={() => handleAction('stop')}
onCancel={() => setConfirmAction(null)}
/>
<ConfirmDialog
open={confirmAction === 'door'}
title="开门"
message="确定要打开仓门?"
onConfirm={() => handleAction('door')}
onCancel={() => setConfirmAction(null)}
/>
</PageContainer>
);
}

View File

@ -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<CabinetDetail | null>(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 <PageContainer title="设备详情"><Loading /></PageContainer>;
if (error) return <PageContainer title="设备详情"><ErrorState message={error} onRetry={fetchDetail} /></PageContainer>;
if (!data) return null;
return (
<PageContainer title={data.name || data.abstract_id} onBack={() => navigate('/pms/devices')}>
{/* 设备基本信息 */}
<div className="mb-4 rounded-xl bg-white p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-base font-semibold text-gray-900">{data.name || data.abstract_id}</p>
<p className="mt-0.5 text-xs text-gray-500">ID: {data.abstract_id}</p>
{data.imei && <p className="text-xs text-gray-400">IMEI: {data.imei}</p>}
</div>
<span className={`rounded-full px-3 py-1 text-xs font-medium ${data.status === 1 ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
{data.status === 1 ? '在线' : '离线'}
</span>
</div>
</div>
{/* 仓板列表 */}
{data.cabin_boards.map((board) => (
<section key={board.id} className="mb-4">
<h3 className="mb-2 text-sm font-medium text-gray-700">
{board.board_id}
<span className={`ml-2 text-xs ${board.status === 1 ? 'text-green-600' : 'text-gray-400'}`}>
({board.status === 1 ? '在线' : '离线'})
</span>
</h3>
<div className="grid grid-cols-3 gap-2">
{board.compartments.map((comp) => (
<ChannelCard
key={comp.id}
channelId={comp.channel_id}
status={comp.status}
voltage={comp.voltage}
current={comp.current}
onClick={() => navigate(`/pms/compartments/${comp.id}`)}
/>
))}
</div>
</section>
))}
</PageContainer>
);
}

View File

@ -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<ViewMode>(projectId ? 'cabinets' : 'projects');
const [projects, setProjects] = useState<H5Project[]>([]);
const [cabinets, setCabinets] = useState<H5Cabinet[]>([]);
const [selectedProject, setSelectedProject] = useState<H5Project | null>(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 <PageContainer title={mode === 'projects' ? '设备' : selectedProject?.name || '设备'}><Loading /></PageContainer>;
if (error) return <PageContainer title="设备"><ErrorState message={error} onRetry={mode === 'projects' ? fetchProjects : () => selectedProject && fetchCabinets(selectedProject.id)} /></PageContainer>;
return (
<PageContainer
title={mode === 'projects' ? '设备' : selectedProject?.name || '设备'}
onBack={mode === 'cabinets' ? handleBack : undefined}
>
{mode === 'projects' ? (
<div className="flex flex-col gap-2">
{projects.length === 0 && <p className="text-center text-sm text-gray-400"></p>}
{projects.map((project) => (
<button
key={project.id}
className="w-full rounded-xl border border-gray-200 bg-white p-4 text-left active:bg-gray-50"
onClick={() => handleProjectClick(project)}
>
<span className="text-base font-medium text-gray-900">{project.name}</span>
<svg className="float-right mt-1 h-4 w-4 text-gray-400" viewBox="0 0 24 24" fill="currentColor">
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" />
</svg>
</button>
))}
</div>
) : (
<div className="flex flex-col gap-2">
{cabinets.length === 0 && <p className="text-center text-sm text-gray-400"></p>}
{cabinets.map((cabinet) => (
<button
key={cabinet.id}
className="flex items-center justify-between rounded-xl border border-gray-200 bg-white p-4 active:bg-gray-50"
onClick={() => navigate(`/pms/devices/${cabinet.id}`)}
>
<div>
<p className="text-base font-medium text-gray-900">{cabinet.name || cabinet.abstract_id}</p>
<p className="mt-0.5 text-xs text-gray-500">ID: {cabinet.abstract_id}</p>
</div>
<div className="flex items-center gap-2">
<span className={`inline-block h-2 w-2 rounded-full ${cabinet.status === 1 ? 'bg-green-500' : 'bg-gray-400'}`} />
<span className="text-xs text-gray-500">{cabinet.status === 1 ? '在线' : '离线'}</span>
<svg className="h-4 w-4 text-gray-400" viewBox="0 0 24 24" fill="currentColor">
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" />
</svg>
</div>
</button>
))}
</div>
)}
</PageContainer>
);
}

View File

@ -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<DashboardData | null>(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 <Loading />;
if (error) return <ErrorState message={error} onRetry={fetchData} />;
if (!data) return null;
return (
<div className="flex flex-col gap-4">
{/* 统计卡片 */}
<section>
<h2 className="mb-3 text-base font-semibold text-gray-900"></h2>
<div className="grid grid-cols-4 gap-2">
<StatCard label="在线" value={data.online_cabinets} sub="柜子" color="blue" />
<StatCard label="充电中" value={data.charging_count} sub="柜子" color="green" />
<StatCard label="空闲" value={data.idle_channels} sub="通道" color="gray" />
<StatCard label="故障" value={data.fault_channels} sub="通道" color="red" />
</div>
</section>
{/* 今日统计 */}
<section className="rounded-xl bg-white p-4">
<h2 className="mb-3 text-base font-semibold text-gray-900"></h2>
<div className="flex justify-around text-center">
<div>
<p className="text-xl font-bold text-blue-600">{data.today_charge_count}</p>
<p className="text-xs text-gray-500"></p>
</div>
<div className="w-px bg-gray-200" />
<div>
<p className="text-xl font-bold text-blue-600">{data.today_charge_hours}</p>
<p className="text-xs text-gray-500">(h)</p>
</div>
<div className="w-px bg-gray-200" />
<div>
<p className="text-xl font-bold text-blue-600">{data.today_energy_kwh.toLocaleString()}</p>
<p className="text-xs text-gray-500">(kWh)</p>
</div>
</div>
</section>
{/* 告警通知 */}
{data.alerts.length > 0 && (
<section>
<h2 className="mb-3 text-base font-semibold text-gray-900"></h2>
<div className="flex flex-col gap-2">
{data.alerts.map((alert, i) => (
<AlertCard key={i} {...alert} />
))}
</div>
</section>
)}
{/* 项目列表 */}
<section>
<h2 className="mb-3 text-base font-semibold text-gray-900"></h2>
<div className="flex flex-col gap-2">
{data.projects.map((project) => (
<ProjectCard
key={project.id}
{...project}
onClick={() => navigate(`/pms/devices?project_id=${project.id}`)}
/>
))}
</div>
</section>
</div>
);
}

View File

@ -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 (
<div className="flex min-h-screen flex-col bg-[#09090b] px-6">
{/* 背景网格 */}
<div
className="fixed inset-0 opacity-[0.03]"
style={{
backgroundImage: 'linear-gradient(rgba(34,211,238,0.5) 1px, transparent 1px), linear-gradient(90deg, rgba(34,211,238,0.5) 1px, transparent 1px)',
backgroundSize: '60px 60px'
}}
/>
{/* 内容 */}
<div className="relative z-10 flex flex-1 flex-col items-center justify-center">
{/* Logo */}
<div className="mb-8 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-[#22d3ee]">
<svg className="h-8 w-8 text-[#09090b]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
</svg>
</div>
<h1 className="text-2xl font-bold text-[#fafafa]"></h1>
<p className="mt-2 text-sm text-[#71717a]"></p>
</div>
{/* 登录表单 */}
<form onSubmit={handleSubmit} className="w-full max-w-sm space-y-4">
<div>
<input
className="w-full rounded-lg border border-[#3f3f46] bg-[#18181b] px-4 py-3 text-base text-[#fafafa] outline-none transition-colors focus:border-[#22d3ee]"
type="tel"
placeholder="手机号"
maxLength={11}
value={phone}
onChange={(e) => setPhone(e.target.value)}
autoComplete="tel"
/>
</div>
<div>
<input
className="w-full rounded-lg border border-[#3f3f46] bg-[#18181b] px-4 py-3 text-base text-[#fafafa] outline-none transition-colors focus:border-[#22d3ee]"
type="password"
placeholder="密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
</div>
{error && (
<p className="text-center text-sm text-red-400">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="mt-4 w-full rounded-lg bg-[#22d3ee] py-3 text-base font-medium text-[#09090b] transition-all hover:bg-[#06b6d4] active:translate-y-[1px] disabled:opacity-50"
>
{loading ? '登录中...' : '登录'}
</button>
</form>
{/* 底部信息 */}
<div className="mt-8 text-center text-xs text-[#52525b]">
<p></p>
</div>
</div>
</div>
);
}

View File

@ -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 (
<PageContainer title="我的">
{/* 用户信息 */}
<div className="mb-6 flex items-center gap-4 rounded-xl bg-white p-4">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-blue-100 text-xl font-bold text-blue-600">
{(user?.name || user?.phone || '?').charAt(0)}
</div>
<div>
<p className="text-base font-semibold text-gray-900">{user?.name || '用户'}</p>
<p className="text-sm text-gray-500">{user?.phone}</p>
</div>
</div>
{/* 菜单项 */}
<div className="rounded-xl bg-white">
<button className="flex w-full items-center justify-between px-4 py-4 active:bg-gray-50" onClick={() => navigate('/pms/me')}>
<span className="text-sm text-gray-900"></span>
<svg className="h-4 w-4 text-gray-400" viewBox="0 0 24 24" fill="currentColor">
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" />
</svg>
</button>
{/* ponytail: password change page placeholder, add when backend endpoint ready */}
</div>
<div className="mt-6">
<button
className="w-full rounded-lg border border-red-200 bg-white py-3 text-base font-medium text-red-500"
onClick={() => setShowLogout(true)}
>
退
</button>
</div>
<ConfirmDialog
open={showLogout}
title="退出登录"
message="确定要退出当前账号?"
onConfirm={handleLogout}
onCancel={() => setShowLogout(false)}
/>
</PageContainer>
);
}

View File

@ -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 = (
<>
{/* 登录页 - 无底部导航 */}
<Route path="/login" element={<H5Login />} />
{/* H5 主布局(含底部导航 + 认证守卫) */}
<Route path="/" element={<H5Layout />}>
<Route index element={<H5Home />} />
<Route path="devices" element={<H5Devices />} />
<Route path="devices/:id" element={<H5DeviceDetail />} />
<Route path="me" element={<H5Me />} />
</Route>
{/* 通道详情 - 全屏页面,直接挂载 */}
<Route path="/compartments/:id" element={<H5CompartmentDetail />} />
{/* 扫码入口: /{abstract_id} → 设备详情 */}
<Route path="/:abstractId" element={<ScanRedirect />} />
</>
);
/** 扫码入口处理:通过 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 (
<div className="flex min-h-screen flex-col items-center justify-center gap-3 bg-white">
<p className="text-sm text-gray-500">{error}</p>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center bg-white">
<Loading />
</div>
);
}

View File

@ -1 +0,0 @@
@import "tailwindcss";

View File

@ -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(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@ -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"]
}

View File

@ -1,7 +0,0 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -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"]
}

View File

@ -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,
},
},
},
})

View File

@ -9,6 +9,8 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@arco-design/web-react": "^2.66.15", "@arco-design/web-react": "^2.66.15",
"qrcode": "^1.5.4",
"qrcode.react": "^4.2.0",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-router-dom": "^7.18.1", "react-router-dom": "^7.18.1",
@ -18,6 +20,7 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.3.2", "@tailwindcss/vite": "^4.3.2",
"@types/node": "^24.13.2", "@types/node": "^24.13.2",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3", "@vitejs/plugin-react": "^6.0.3",
@ -1219,6 +1222,16 @@
"undici-types": "~7.18.0" "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": { "node_modules/@types/react": {
"version": "19.2.17", "version": "19.2.17",
"resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.17.tgz", "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": { "node_modules/b-tween": {
"version": "0.3.3", "version": "0.3.3",
"resolved": "https://registry.npmmirror.com/b-tween/-/b-tween-0.3.3.tgz", "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==", "integrity": "sha512-iCvCkGFskbaYtfQ0a3GmcQCHl/Sv1GufXFGuUQ+FE+WJa7A/espLOuFIn09B944V8/ImPj71T4+rTASxO2PAuA==",
"license": "MIT" "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": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz",
@ -1479,6 +1554,15 @@
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
"license": "MIT" "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": { "node_modules/decimal.js-light": {
"version": "2.5.1", "version": "2.5.1",
"resolved": "https://registry.npmmirror.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz", "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==", "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT" "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": { "node_modules/dom-helpers": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmmirror.com/dom-helpers/-/dom-helpers-5.2.1.tgz", "resolved": "https://registry.npmmirror.com/dom-helpers/-/dom-helpers-5.2.1.tgz",
@ -1511,6 +1601,12 @@
"csstype": "^3.0.2" "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": { "node_modules/enhanced-resolve": {
"version": "5.21.6", "version": "5.21.6",
"resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", "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": { "node_modules/focus-lock": {
"version": "1.3.6", "version": "1.3.6",
"resolved": "https://registry.npmmirror.com/focus-lock/-/focus-lock-1.3.6.tgz", "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": "^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": { "node_modules/graceful-fs": {
"version": "4.2.11", "version": "4.2.11",
"resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz",
@ -1618,6 +1736,15 @@
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
"license": "MIT" "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": { "node_modules/jiti": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.7.0.tgz", "resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.7.0.tgz",
@ -1907,6 +2034,18 @@
"url": "https://opencollective.com/parcel" "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": { "node_modules/lodash": {
"version": "4.18.1", "version": "4.18.1",
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
@ -2038,6 +2222,15 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/postcss": {
"version": "8.5.16", "version": "8.5.16",
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.16.tgz", "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.16.tgz",
@ -2084,6 +2277,32 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT" "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": { "node_modules/react": {
"version": "19.2.7", "version": "19.2.7",
"resolved": "https://registry.npmmirror.com/react/-/react-19.2.7.tgz", "resolved": "https://registry.npmmirror.com/react/-/react-19.2.7.tgz",
@ -2268,6 +2487,21 @@
"redux": "^5.0.0" "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": { "node_modules/reselect": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmmirror.com/reselect/-/reselect-5.2.0.tgz", "resolved": "https://registry.npmmirror.com/reselect/-/reselect-5.2.0.tgz",
@ -2329,6 +2563,12 @@
"compute-scroll-into-view": "^1.0.20" "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": { "node_modules/set-cookie-parser": {
"version": "2.7.2", "version": "2.7.2",
"resolved": "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", "resolved": "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
@ -2360,6 +2600,32 @@
"node": ">=0.10.0" "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": { "node_modules/tailwindcss": {
"version": "4.3.2", "version": "4.3.2",
"resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.3.2.tgz", "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": { "node_modules/zustand": {
"version": "5.0.14", "version": "5.0.14",
"resolved": "https://registry.npmmirror.com/zustand/-/zustand-5.0.14.tgz", "resolved": "https://registry.npmmirror.com/zustand/-/zustand-5.0.14.tgz",

View File

@ -11,6 +11,8 @@
}, },
"dependencies": { "dependencies": {
"@arco-design/web-react": "^2.66.15", "@arco-design/web-react": "^2.66.15",
"qrcode": "^1.5.4",
"qrcode.react": "^4.2.0",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-router-dom": "^7.18.1", "react-router-dom": "^7.18.1",
@ -20,6 +22,7 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.3.2", "@tailwindcss/vite": "^4.3.2",
"@types/node": "^24.13.2", "@types/node": "^24.13.2",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3", "@vitejs/plugin-react": "^6.0.3",

View File

@ -18,7 +18,7 @@ export interface Project {
export interface Cabinet { export interface Cabinet {
id: number; id: number;
project_id?: number; project_id?: number;
abstract_id: string; abstract_id?: string;
imei: string; imei: string;
iccid?: string; iccid?: string;
auth_str?: string; auth_str?: string;
@ -31,6 +31,10 @@ export interface Cabinet {
charging_count?: number; charging_count?: number;
full_count?: number; full_count?: number;
fault_count?: number; fault_count?: number;
rssi?: number;
pow_fail_dc?: boolean;
pow_fail_ac?: boolean;
is_online?: boolean;
} }
export interface Compartment { export interface Compartment {
@ -82,9 +86,12 @@ export const cabinetApi = {
list: (projectId: number) => api.get<Cabinet[]>(`/projects/${projectId}/cabinets`), list: (projectId: number) => api.get<Cabinet[]>(`/projects/${projectId}/cabinets`),
create: (data: { imeis: string[]; project_id: number }) => create: (data: { imeis: string[]; project_id: number }) =>
api.post<{ ids: number[] }>('/cabinets', data), 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), api.put(`/cabinets/${id}`, data),
delete: (id: number) => api.delete(`/cabinets/${id}`), 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`), regenerateAuth: (id: number) => api.post<{ auth_str: string }>(`/cabinets/${id}/regenerate-auth`),
getDetail: (id: number) => api.get<CabinetDetail>(`/cabinets/${id}`), getDetail: (id: number) => api.get<CabinetDetail>(`/cabinets/${id}`),
// 接触器控制 // 接触器控制
@ -92,13 +99,21 @@ export const cabinetApi = {
powOff: (id: number) => api.post(`/cabinets/${id}/pow-off`), powOff: (id: number) => api.post(`/cabinets/${id}/pow-off`),
}; };
/** 分页响应 */
export interface PaginatedResponse<T> {
total: number;
data: T[];
}
// 组织树 API // 组织树 API
export const treeApi = { export const treeApi = {
get: () => api.get<TreeNode[]>('/organization-tree'), get: () => api.get<TreeNode[]>('/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(); const query = new URLSearchParams();
if (params.organization_id) query.set('organization_id', String(params.organization_id)); if (params.organization_id) query.set('organization_id', String(params.organization_id));
if (params.project_id) query.set('project_id', String(params.project_id)); if (params.project_id) query.set('project_id', String(params.project_id));
return api.get<Cabinet[]>(`/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<PaginatedResponse<Cabinet>>(`/cabinets/filter?${query.toString()}`);
}, },
}; };

View File

@ -2,32 +2,212 @@
* *
* - * -
* - * -
* - //
*/ */
import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
Card, Card,
Tag,
Spin, Spin,
Empty, Empty,
Tooltip,
Message,
Input,
Button,
Modal,
} from '@arco-design/web-react'; } 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'; import type { Cabinet } from '@/api/devices';
/** 柜子状态映射 */
const STATUS_MAP: Record<number, { label: string; color: string; icon: React.ReactNode }> = {
0: { label: '离线', color: 'gray', icon: <IconInfoCircle /> },
1: { label: '在线', color: 'green', icon: <IconCheckCircle /> },
2: { label: '充电中', color: 'blue', icon: <IconPoweroff /> },
3: { label: '故障', color: 'red', icon: <IconInfoCircle /> },
};
interface CabinetGridProps { interface CabinetGridProps {
cabinets: Cabinet[]; cabinets: Cabinet[];
loading: boolean; loading: boolean;
onRefresh: () => void;
} }
export default function CabinetGrid({ cabinets, loading }: CabinetGridProps) { export default function CabinetGrid({ cabinets, loading, onRefresh }: CabinetGridProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const [editingId, setEditingId] = useState<number | null>(null);
const [editValue, setEditValue] = useState('');
// 二维码弹窗
const [qrCabinet, setQrCabinet] = useState<{ id: number; abstractId: string } | null>(null);
// 换绑弹窗
const [replaceCabinetId, setReplaceCabinetId] = useState<number | null>(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: <IconWifi />, 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: <IconWifi />, color, text: `${rssi} (${label})` };
};
/** 直流图标 */
const DcIcon = () => (
<svg viewBox="0 0 14 14" width={11} height={11} fill="currentColor">
<text x="0" y="11" fontSize={10} fontWeight={700} fontFamily="Arial,sans-serif">DC</text>
</svg>
);
/** 交流图标 */
const AcIcon = () => (
<svg viewBox="0 0 14 14" width={11} height={11} fill="currentColor">
<text x="0" y="11" fontSize={10} fontWeight={700} fontFamily="Arial,sans-serif">AC</text>
</svg>
);
/** 小标签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 (
<Tooltip content={fail === true ? `${label}供电失败` : fail === false ? `${label}正常` : `${label}无数据`}>
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 16, height: 16, borderRadius: 3,
color: '#fff', background: bg,
}}>
<Icon />
</span>
</Tooltip>
);
};
/** 下载二维码图片 */
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 ( return (
<> <>
@ -36,33 +216,124 @@ export default function CabinetGrid({ cabinets, loading }: CabinetGridProps) {
) : cabinets.length === 0 ? ( ) : cabinets.length === 0 ? (
<Empty description="暂无设备" /> <Empty description="暂无设备" />
) : ( ) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 16 }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 16 }}>
{cabinets.map((cab) => { {cabinets.map((cab) => {
const st = STATUS_MAP[cab.status] || { label: '未知', color: 'gray', icon: <IconInfoCircle /> }; const signal = getSignalIcon(cab.rssi);
return ( return (
<Card <Card
key={cab.id} key={cab.id}
hoverable hoverable
size="small"
onClick={() => navigate(`/devices/cabinet/${cab.id}`)} onClick={() => navigate(`/devices/cabinet/${cab.id}`)}
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
> >
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{/* 标题行:名称 + 状态图标 */} {/* 第一行:绑定码 + 操作按钮(左)+ 状态(右) */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', minHeight: 22 }}>
<span style={{ fontWeight: 600, fontSize: 15, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>
{cab.name || cab.abstract_id}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ color: st.color }}>{st.icon}</span> {cab.abstract_id ? (
<Tag color={st.color} style={{ fontSize: 12, margin: 0 }}>{st.label}</Tag> <>
<span style={{ color: 'var(--color-text-1)', fontWeight: 600, fontSize: 15 }}>{cab.abstract_id}</span>
<IconCopy
style={{ cursor: 'pointer', fontSize: 14, flexShrink: 0, color: 'var(--color-text-3)' }}
onClick={(e) => { e.stopPropagation(); copyText(cab.abstract_id!, '绑定码'); }}
/>
<Button
size="mini"
type="text"
icon={<IconSwap />}
style={{ fontSize: 16, color: 'var(--color-text-3)' }}
onClick={(e) => { e.stopPropagation(); setReplaceCabinetId(cab.id); setNewImei(''); }}
/>
<Button
size="mini"
type="text"
icon={<IconCodeSquare />}
style={{ fontSize: 16, color: 'var(--color-text-3)' }}
onClick={(e) => { e.stopPropagation(); setQrCabinet({ id: cab.id, abstractId: cab.abstract_id! }); }}
/>
</>
) : (
<Button
size="mini"
type="primary"
icon={<IconLink />}
onClick={(e) => handleBind(cab.id, e)}
>
</Button>
)}
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
<PowTag fail={cab.pow_fail_dc} label="DC" />
<PowTag fail={cab.pow_fail_ac} label="AC" />
<Tooltip content={signal.text}>
<span style={{ display: 'flex', alignItems: 'center', gap: 1, color: signal.color, cursor: 'default' }}>
{signal.icon}
<span style={{ fontSize: 11, fontWeight: 500 }}>{cab.rssi || '-'}</span>
</span>
</Tooltip>
</div>
</div>
{/* 第二行:名字 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 4, overflow: 'hidden' }}>
{editingId === cab.id ? (
<>
<Input
value={editValue}
onChange={setEditValue}
size="mini"
style={{ width: 140, height: 24 }}
maxLength={100}
onPressEnter={() => saveName(cab.id)}
autoFocus
/>
<IconCheck
style={{ color: '#00b42a', cursor: 'pointer', flexShrink: 0, fontSize: 14 }}
onClick={(e) => { e.stopPropagation(); saveName(cab.id); }}
/>
<IconClose
style={{ color: '#f53f3f', cursor: 'pointer', flexShrink: 0, fontSize: 14 }}
onClick={(e) => { e.stopPropagation(); setEditingId(null); }}
/>
</>
) : (
<>
<span style={{ fontWeight: 600, fontSize: 15, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{cab.name || `未命名`}
</span>
<IconEdit
style={{ color: 'var(--color-text-3)', cursor: 'pointer', flexShrink: 0, fontSize: 14 }}
onClick={(e) => {
e.stopPropagation();
setEditingId(cab.id);
setEditValue(cab.name || '');
}}
/>
</>
)}
</div> </div>
{/* IMEI 和 ICCID */} {/* IMEI 和 ICCID */}
<div style={{ fontSize: 12, color: 'var(--color-text-3)' }}> <div style={{ fontSize: 12, color: 'var(--color-text-3)' }}>
<div>IMEI: {cab.imei || '-'}</div> <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<div>ICCID: {cab.iccid || '-'}</div> <span>IMEI: {cab.imei || '-'}</span>
{cab.imei && (
<IconCopy
style={{ cursor: 'pointer', fontSize: 12, flexShrink: 0 }}
onClick={(e) => { e.stopPropagation(); copyText(cab.imei!, 'IMEI'); }}
/>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span>ICCID: {cab.iccid || '-'}</span>
{cab.iccid && (
<IconCopy
style={{ cursor: 'pointer', fontSize: 12, flexShrink: 0 }}
onClick={(e) => { e.stopPropagation(); copyText(cab.iccid!, 'ICCID'); }}
/>
)}
</div>
</div> </div>
{/* 通道状态 */} {/* 通道状态 */}
@ -76,9 +347,9 @@ export default function CabinetGrid({ cabinets, loading }: CabinetGridProps) {
{/* 地址 */} {/* 地址 */}
{cab.address && ( {cab.address && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--color-text-3)' }}> <div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--color-text-3)' }}>
<IconLocation style={{ color: '#f53f3f', flexShrink: 0 }} /> <IconLocation style={{ color: '#f53f3f', flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{cab.address}</span> <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 200 }}>{cab.address}</span>
</div> </div>
)} )}
</div> </div>
@ -87,6 +358,56 @@ export default function CabinetGrid({ cabinets, loading }: CabinetGridProps) {
})} })}
</div> </div>
)} )}
{/* 二维码弹窗 */}
<Modal
title="设备二维码"
visible={!!qrCabinet}
onCancel={() => setQrCabinet(null)}
footer={null}
style={{ width: 320 }}
>
{qrCabinet && (
<div style={{ padding: '24px 0', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<QRCodeSVG value={`https://app.anzhizhichong.com/h5/${qrCabinet.abstractId}`} size={220} />
<div style={{ marginTop: 12, fontSize: 18, fontWeight: 700, color: 'var(--color-text-1)' }}>
{qrCabinet.abstractId}
</div>
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--color-text-4)' }}>
</div>
<div style={{ marginTop: 16, display: 'flex', gap: 12 }}>
<Button type="primary" onClick={() => handleDownloadQR(qrCabinet.abstractId)}>
</Button>
<Button disabled></Button>
</div>
</div>
)}
</Modal>
{/* 换绑弹窗 */}
<Modal
title="更换IMEI"
visible={!!replaceCabinetId}
onCancel={() => { setReplaceCabinetId(null); setNewImei(''); }}
onOk={handleReplaceImei}
confirmLoading={replaceLoading}
okText="确认换绑"
cancelText="取消"
style={{ width: 400 }}
>
<div style={{ fontSize: 13, color: 'var(--color-text-2)', marginBottom: 12 }}>
15IMEI
</div>
<Input
placeholder="请输入15位新IMEI"
value={newImei}
onChange={setNewImei}
maxLength={15}
style={{ width: '100%' }}
/>
</Modal>
</> </>
); );
} }

View File

@ -18,12 +18,16 @@ import {
Message, Message,
Spin, Spin,
Tree, Tree,
Tooltip,
Pagination,
} from '@arco-design/web-react'; } from '@arco-design/web-react';
import { import {
IconPlus, IconPlus,
IconDelete, IconDelete,
IconEdit, IconEdit,
IconRefresh, IconRefresh,
IconExpand,
IconShrink,
} from '@arco-design/web-react/icon'; } from '@arco-design/web-react/icon';
import { import {
treeApi, treeApi,
@ -48,8 +52,13 @@ export default function Devices() {
const [cabinets, setCabinets] = useState<Cabinet[]>([]); const [cabinets, setCabinets] = useState<Cabinet[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selectedKeys, setSelectedKeys] = useState<string[]>([]); const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
const [expandedKeys, setExpandedKeys] = useState<string[]>([]);
const [selectedType, setSelectedType] = useState<NodeType>('all'); const [selectedType, setSelectedType] = useState<NodeType>('all');
const [selectedId, setSelectedId] = useState(0); const [selectedId, setSelectedId] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(16);
const [total, setTotal] = useState(0);
const [filterTab, setFilterTab] = useState<string>('all');
// 弹窗状态 // 弹窗状态
const [orgModalVisible, setOrgModalVisible] = useState(false); 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') { if (type === 'cabinet') {
navigate(`/devices/cabinet/${id}`); navigate(`/devices/cabinet/${id}`);
return; return;
@ -89,33 +98,41 @@ export default function Devices() {
setLoading(true); setLoading(true);
try { try {
let res; let res;
if (type === 'all') { const pg: Record<string, unknown> = { page: p, page_size: pageSize };
res = await treeApi.filterCabinets({}); if (tab && tab !== 'all') {
} else if (type === 'organization') { pg.bound = tab === 'bound' ? '1' : '0';
res = await treeApi.filterCabinets({ organization_id: id });
} else if (type === 'project') {
res = await treeApi.filterCabinets({ project_id: id });
} }
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 { } catch {
Message.error('加载柜子列表失败'); Message.error('加载柜子列表失败');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [navigate]); }, [navigate, pageSize]);
useEffect(() => { useEffect(() => {
loadTree().then(() => setLoading(false)); loadTree();
}, [loadTree]); }, [loadTree]);
useEffect(() => { useEffect(() => {
if (selectedType) { if (selectedType) {
loadCabinets(selectedType, selectedId); loadCabinets(selectedType, selectedId, page, filterTab);
} }
}, [selectedType, selectedId, loadCabinets]); }, [selectedType, selectedId, page, filterTab, loadCabinets]);
const handleTreeSelect = useCallback((keys: string[]) => { const handleTreeSelect = useCallback((keys: string[]) => {
const key = keys[0]; const key = keys[0];
setPage(1);
setFilterTab('all');
if (!key || key === 'all') { if (!key || key === 'all') {
setSelectedKeys(keys); setSelectedKeys(keys);
setSelectedType('all'); 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( const renderNodeTitle = useCallback(
(nodeType: NodeType, nodeId: number, title: string) => ( (nodeType: NodeType, nodeId: number, title: string) => (
<span <span
@ -323,30 +352,52 @@ export default function Devices() {
[renderNodeTitle], [renderNodeTitle],
); );
const renderedTreeData = [
{
key: 'all',
title: renderNodeTitle('all', 0, '全部'),
children: injectTitleRender(treeData) as TreeInternalNode[],
},
];
return ( return (
<div style={{ display: 'flex', gap: 16, height: '100%', width: '100%', overflow: 'hidden' }}> <div style={{ display: 'flex', gap: 16, height: '100%', width: '100%', overflow: 'hidden' }}>
{/* 左列:组织架构树 */} {/* 左列:组织架构树 */}
<div style={{ width: 300, flexShrink: 0, background: 'var(--color-bg-2)', borderRadius: 8, padding: 16, overflow: 'auto' }}> <div style={{ width: 240, flexShrink: 0, background: 'var(--color-bg-2)', borderRadius: 8, padding: 12, overflow: 'auto' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<Typography.Text style={{ fontWeight: 600, fontSize: 14 }}></Typography.Text> <Typography.Text style={{ fontWeight: 600, fontSize: 14 }}></Typography.Text>
<Button size="small" type="text" icon={<IconRefresh />} onClick={loadTree} /> <div style={{ display: 'flex', gap: 2 }}>
<Tooltip content={expandedKeys.length > 0 ? '折叠全部' : '展开全部'}>
<Button
size="mini"
type="text"
icon={expandedKeys.length > 0 ? <IconShrink /> : <IconExpand />}
onClick={() => setExpandedKeys(expandedKeys.length > 0 ? [] : getAllKeys(treeData))}
/>
</Tooltip>
<Tooltip content="刷新">
<Button size="mini" type="text" icon={<IconRefresh />} onClick={loadTree} />
</Tooltip>
</div>
</div> </div>
<Spin loading={loading && treeData.length === 0} dot> <Spin loading={loading && treeData.length === 0} dot>
{/* 全部选项 */}
<div
style={{
padding: '6px 8px',
marginBottom: 4,
borderRadius: 4,
cursor: 'pointer',
background: selectedKeys.includes('all') ? 'var(--color-fill-2)' : 'transparent',
fontSize: 13,
fontWeight: selectedKeys.includes('all') ? 600 : 400,
}}
onClick={() => handleTreeSelect(['all'])}
>
</div>
{treeData.length > 0 ? ( {treeData.length > 0 ? (
<Tree <Tree
treeData={renderedTreeData as never} treeData={injectTitleRender(treeData) as never}
selectedKeys={selectedKeys} selectedKeys={selectedKeys.filter(k => k !== 'all')}
expandedKeys={expandedKeys}
onExpand={(keys) => setExpandedKeys(keys as string[])}
onSelect={handleTreeSelect as never} onSelect={handleTreeSelect as never}
autoExpandParent autoExpandParent
defaultExpandedKeys={['all']} defaultExpandedKeys={treeData.map(n => n.key)}
style={{ fontSize: 13 }} style={{ fontSize: 13 }}
/> />
) : ( ) : (
@ -357,30 +408,70 @@ export default function Devices() {
{/* 右列:柜子列表 */} {/* 右列:柜子列表 */}
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}> <div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16, flexShrink: 0 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0, marginBottom: 12 }}>
<Typography.Title heading={5} style={{ margin: 0 }}> <Typography.Title heading={5} style={{ margin: 0 }}>
({cabinets.length}) ({cabinets.length})
</Typography.Title> </Typography.Title>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Button.Group>
{[
{ key: 'all', label: '全部' },
{ key: 'bound', label: '已绑定' },
{ key: 'unbound', label: '未绑定' },
].map((t) => (
<Button
key={t.key}
type={filterTab === t.key ? 'primary' : 'default'}
size="small"
onClick={() => { setFilterTab(t.key); setPage(1); }}
>
{t.label}
</Button>
))}
</Button.Group>
<Button type="primary" icon={<IconPlus />} onClick={openCabinetModal}> <Button type="primary" icon={<IconPlus />} onClick={openCabinetModal}>
</Button> </Button>
</div> </div>
</div>
<div style={{ flex: 1, overflow: 'auto', padding: '0 8px' }}> <div style={{ flex: 1, overflow: 'auto', padding: '0 8px' }}>
<CabinetGrid cabinets={cabinets} loading={loading} /> <CabinetGrid cabinets={cabinets} loading={loading} onRefresh={loadTree} />
{total > pageSize && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '16px 0' }}>
<Pagination
total={total}
current={page}
pageSize={pageSize}
onChange={(p) => setPage(p)}
size="small"
hideOnSinglePage
/>
</div>
)}
</div> </div>
</div> </div>
{/* 右键菜单 */} {/* 右键菜单 */}
{contextVisible && (
<div
style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, zIndex: 999 }}
onClick={() => setContextVisible(false)}
onContextMenu={(e) => { e.preventDefault(); setContextVisible(false); }}
>
<div
style={{ position: 'fixed', left: contextPos.x, top: contextPos.y, zIndex: 1000 }}
onClick={(e) => e.stopPropagation()}
>
<Dropdown <Dropdown
popupVisible={contextVisible} popupVisible={true}
droplist={contextMenu} droplist={contextMenu}
position="bl" position="bl"
> >
<div <div />
style={{ position: 'fixed', left: contextPos.x, top: contextPos.y, width: 0, height: 0 }}
onClick={() => setContextVisible(false)}
/>
</Dropdown> </Dropdown>
</div>
</div>
)}
{/* 添加组织弹窗 */} {/* 添加组织弹窗 */}
<Modal title="添加组织" visible={orgModalVisible} onCancel={() => setOrgModalVisible(false)} onOk={handleCreateOrg} okText="确定" cancelText="取消"> <Modal title="添加组织" visible={orgModalVisible} onCancel={() => setOrgModalVisible(false)} onOk={handleCreateOrg} okText="确定" cancelText="取消">

29
tools/add_cabinets.py Normal file
View File

@ -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()

44
tools/add_sub_orgs.py Normal file
View File

@ -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()

159
tools/mock_redis.py Normal file
View File

@ -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:
"""生成单通道hex4字节=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()

79
tools/update_cabinets.py Normal file
View File

@ -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()

64
tools/update_iccid.py Normal file
View File

@ -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()

View File

@ -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()

View File

@ -2,6 +2,13 @@
账号13951516272 账号13951516272
密码wxad139 密码wxad139
无锡艾动电子有限公司
腾讯地图
keyHWVBZ-CMACV-JEBP5-5RDRJ-OIHLK-ZNFE2
Secret key SK LYVx9XenBOKGnbAKkmzJxOYFhASp9FQD
签名验证
https://signin.aliyun.com/login.htm?username=skyp76%401086952030628727.onaliyun.com&defaultShowQrCode=false https://signin.aliyun.com/login.htm?username=skyp76%401086952030628727.onaliyun.com&defaultShowQrCode=false
mysql mysql