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/
6
.workbuddy/memory/2026-07-02.md
Normal 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秒出图
|
||||||
6
.workbuddy/memory/MEMORY.md
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
# 项目长期记忆
|
||||||
|
|
||||||
|
## 用户偏好
|
||||||
|
- **产品介绍页风格**:偏好明亮/浅色风格,不要暗色科技风;单屏不滚动布局
|
||||||
|
- **生图能力**:项目有 ImageGen 文生图工具可用,可用于生成产品渲染图等
|
||||||
|
- **操作规则**:开发/修改程序前必须确认需求,不可自说自话操作;生产环境服务器文件和数据必须用户授权后才可动
|
||||||
43
AGENTS.md
@ -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} # 通道级 Hash(6仓)
|
||||||
|
├─ on = "0/1" # 充电中
|
||||||
|
├─ full = "0/1" # 充满
|
||||||
|
├─ fault = "0/1" # 故障
|
||||||
|
└─ hex = "00010002" # 原始hex
|
||||||
|
```
|
||||||
|
|
||||||
|
**写入时机:**
|
||||||
|
| 事件 | 写入位置 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| `login` | `device:{imei}.online` | 标记在线,设置TTL 300秒 |
|
||||||
|
| `status_post` | `device:{imei}` + board + ch | 解析status字典,按层级写入 |
|
||||||
|
| `pow_fail` | `device:{imei}.pow_fail_dc/ac` | 更新停电状态 |
|
||||||
|
|
||||||
|
**通道状态映射(hex → 语义):**
|
||||||
|
- 空闲(idle)
|
||||||
|
- 插入(inserted)
|
||||||
|
- 充电中(charging)
|
||||||
|
- 充满(full)
|
||||||
|
- 故障(fault)
|
||||||
|
|
||||||
|
> ⚠️ hex编码格式待硬件团队最终确认,当前实现预留状态字段,按 bit 位解析。
|
||||||
|
|
||||||
|
**API 接口:**
|
||||||
|
| 接口 | 说明 | 查询方式 |
|
||||||
|
|------|------|----------|
|
||||||
|
| `GET /api/cabinets/filter` | 设备列表(设备级状态) | Pipeline 批量 HGET,1次往返 |
|
||||||
|
| `GET /api/cabinets/realtime-status?imei=xxx` | 单设备详情(板级+通道级) | KEYS 扫描 + HMGET |
|
||||||
|
|
||||||
## TCP通讯协议
|
## TCP通讯协议
|
||||||
|
|
||||||
- 长连接,无需PING包
|
- 长连接,无需PING包
|
||||||
|
|||||||
10
README.md
@ -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/ # 测试用例/报告
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.0 MiB |
BIN
deploy/landing/hero-bg.png
Normal file
|
After Width: | Height: | Size: 906 KiB |
@ -3,464 +3,202 @@
|
|||||||
<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">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<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>
|
<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; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
body {
|
html, body {
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
height: 100%;
|
||||||
background: var(--bg-primary);
|
overflow: hidden;
|
||||||
color: var(--text-primary);
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", sans-serif;
|
||||||
line-height: 1.6;
|
color: #1A1C20;
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 布局 */
|
a { color: inherit; text-decoration: none; }
|
||||||
.container {
|
|
||||||
max-width: 1200px;
|
/* ===== 整体布局:上中下三块 ===== */
|
||||||
margin: 0 auto;
|
.layout {
|
||||||
padding: 0 24px;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
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 {
|
.top-name { font-size: 24px; font-weight: 800; color: #1A1C20; letter-spacing: 0.02em; }
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
.top-links { display: flex; gap: 8px; }
|
||||||
gap: 8px;
|
.top-link {
|
||||||
padding: 6px 16px;
|
font-size: 13px; font-weight: 500;
|
||||||
background: var(--accent-dim);
|
color: #4A5060;
|
||||||
border: 1px solid rgba(34, 211, 238, 0.2);
|
padding: 7px 16px;
|
||||||
border-radius: 100px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--accent);
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
.hero-badge-dot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
background: var(--accent);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: pulse 2s infinite;
|
|
||||||
}
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.4; }
|
|
||||||
}
|
|
||||||
.hero h1 {
|
|
||||||
font-size: 56px;
|
|
||||||
font-weight: 800;
|
|
||||||
line-height: 1.1;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
|
||||||
.hero h1 span {
|
|
||||||
background: linear-gradient(135deg, var(--accent), #06b6d4);
|
|
||||||
-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;
|
border-radius: 8px;
|
||||||
text-decoration: none;
|
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
cursor: pointer;
|
}
|
||||||
border: none;
|
.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;
|
||||||
|
padding: 0 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 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;
|
||||||
|
font-size: 13px; font-weight: 600;
|
||||||
|
color: #2563EB;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
.tag .dot {
|
||||||
|
width: 6px; height: 6px;
|
||||||
|
background: #2563EB;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: clamp(40px, 4.5vw, 60px);
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1.15;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: #1A1C20;
|
||||||
|
}
|
||||||
|
h1 .accent { color: #2563EB; }
|
||||||
|
|
||||||
|
.subhead {
|
||||||
|
font-size: clamp(15px, 1.3vw, 18px);
|
||||||
|
font-weight: 300;
|
||||||
|
color: #4A5060;
|
||||||
|
line-height: 1.75;
|
||||||
|
max-width: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pills { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.pill {
|
||||||
|
padding: 5px 12px;
|
||||||
|
border: 1px solid rgba(0,0,0,0.08);
|
||||||
|
border-radius: 100px;
|
||||||
|
font-size: 13px; font-weight: 500;
|
||||||
|
color: #4A5060;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctas { display: flex; align-items: center; gap: 12px; margin-top: 4px; }
|
||||||
|
.btn {
|
||||||
|
display: inline-flex; align-items: center; gap: 8px;
|
||||||
|
padding: 13px 28px;
|
||||||
|
font-size: 15px; font-weight: 600;
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--accent);
|
background: #1A1C20; color: #fff;
|
||||||
color: var(--bg-primary);
|
box-shadow: 0 4px 24px rgba(26,28,32,0.10);
|
||||||
}
|
}
|
||||||
.btn-primary:hover {
|
.btn-primary:hover { background: #2D3038; transform: translateY(-2px); }
|
||||||
background: #06b6d4;
|
.btn-ghost {
|
||||||
transform: translateY(-1px);
|
color: #4A5060;
|
||||||
}
|
border: 1.5px solid rgba(0,0,0,0.12);
|
||||||
.btn-secondary {
|
|
||||||
background: var(--bg-card);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background: #3f3f46;
|
|
||||||
border-color: #52525b;
|
|
||||||
}
|
}
|
||||||
|
.btn-ghost:hover { border-color: #8B92A5; color: #1A1C20; }
|
||||||
|
|
||||||
/* 产品展示 */
|
|
||||||
.showcase {
|
|
||||||
padding: 40px 0 80px;
|
|
||||||
}
|
|
||||||
.showcase-frame {
|
|
||||||
position: relative;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 特性网格 */
|
|
||||||
.features {
|
|
||||||
padding: 80px 0;
|
|
||||||
}
|
|
||||||
.section-header {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 48px;
|
|
||||||
}
|
|
||||||
.section-header h2 {
|
|
||||||
font-size: 32px;
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
.section-header p {
|
|
||||||
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;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
.feature-icon svg {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
.feature-card h3 {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
.feature-card p {
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 数据统计 */
|
|
||||||
.stats {
|
.stats {
|
||||||
padding: 60px 0;
|
display: flex; gap: 32px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid rgba(0,0,0,0.08);
|
||||||
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
.stats-grid {
|
.stat-num { font-size: 28px; font-weight: 700; color: #1A1C20; line-height: 1.2; }
|
||||||
display: grid;
|
.stat-label { font-size: 12px; font-weight: 500; color: #8B92A5; margin-top: 2px; }
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
gap: 1px;
|
/* ===== BOT: 底部 ICP ===== */
|
||||||
background: var(--border);
|
.bot {
|
||||||
border-radius: 12px;
|
flex-shrink: 0;
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.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);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.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; }
|
|
||||||
}
|
}
|
||||||
|
.bot a { color: #2563EB; }
|
||||||
|
.bot a:hover { text-decoration: underline; }
|
||||||
</style>
|
</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"><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
|
After Width: | Height: | Size: 398 KiB |
BIN
deploy/landing/logo-2.png
Normal file
|
After Width: | Height: | Size: 374 KiB |
BIN
deploy/landing/logo-3.png
Normal file
|
After Width: | Height: | Size: 471 KiB |
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 指令下发请求体
|
/// 指令下发请求体
|
||||||
|
|||||||
@ -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 '平台抽象ID,如10-00000000',
|
abstract_id VARCHAR(20) NULL UNIQUE COMMENT '平台抽象ID,如10-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 '平台抽象ID,如10-00000001',
|
||||||
|
cabinet_id BIGINT NOT NULL UNIQUE COMMENT '柜子ID',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (cabinet_id) REFERENCES cabinets(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
// 逐条执行,避免多语句执行问题
|
// 逐条执行,避免多语句执行问题
|
||||||
@ -173,6 +181,10 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
|
|||||||
sqlx::query(statement).execute(pool).await?;
|
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?;
|
||||||
|
|
||||||
|
|||||||
@ -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", "服务器内部错误")
|
||||||
|
|||||||
@ -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(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// 启动后台协程 — 处理设备上报
|
// 启动后台协程 — 处理设备上报
|
||||||
|
|||||||
@ -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批量HGET,1次网络往返)
|
||||||
|
{
|
||||||
|
let client = redis::Client::open(state.redis_url.as_str()).map_err(|e| {
|
||||||
|
AppError::Internal(format!("Redis客户端创建失败: {}", e))
|
||||||
|
})?;
|
||||||
|
let mut conn = client.get_async_connection().await.map_err(|e| {
|
||||||
|
AppError::Internal(format!("Redis连接失败: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// 用Pipeline一次发所有HGET命令(每个设备1次HGET取4个字段)
|
||||||
|
let mut pipe = redis::pipe();
|
||||||
|
for row in &rows {
|
||||||
|
let key = format!("device:{}", row.imei);
|
||||||
|
pipe.cmd("HGET").arg(&key).arg("online");
|
||||||
|
pipe.cmd("HGET").arg(&key).arg("rssi");
|
||||||
|
pipe.cmd("HGET").arg(&key).arg("pow_fail_dc");
|
||||||
|
pipe.cmd("HGET").arg(&key).arg("pow_fail_ac");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 一次发送,获取所有结果
|
||||||
|
let results: Vec<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,
|
||||||
|
})))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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?;
|
||||||
|
|||||||
@ -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)
|
// 标记设备在线(统一主key,5分钟 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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
9
software/h5-taro/babel.config.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
// babel-preset-taro 更多配置和用法:https://docs.taro.zone/docs/next/babel-config
|
||||||
|
module.exports = {
|
||||||
|
presets: [
|
||||||
|
['taro', {
|
||||||
|
framework: 'react',
|
||||||
|
ts: true,
|
||||||
|
}],
|
||||||
|
],
|
||||||
|
}
|
||||||
17
software/h5-taro/config/dev.js
Normal 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': '' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
62
software/h5-taro/config/index.js
Normal 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'))
|
||||||
|
}
|
||||||
6
software/h5-taro/config/prod.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
env: {
|
||||||
|
NODE_ENV: '"production"',
|
||||||
|
},
|
||||||
|
outputRoot: 'dist',
|
||||||
|
}
|
||||||
32
software/h5-taro/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
30
software/h5-taro/project.config.json
Normal 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"
|
||||||
|
}
|
||||||
161
software/h5-taro/src/api/index.ts
Normal 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 } }),
|
||||||
|
}
|
||||||
43
software/h5-taro/src/app.config.ts
Normal 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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
66
software/h5-taro/src/app.css
Normal 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;
|
||||||
|
}
|
||||||
44
software/h5-taro/src/app.tsx
Normal 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
|
||||||
1
software/h5-taro/src/assets/device-active.svg
Normal 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 |
1
software/h5-taro/src/assets/device.svg
Normal 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 |
1
software/h5-taro/src/assets/home-active.svg
Normal 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 |
1
software/h5-taro/src/assets/home.svg
Normal 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 |
1
software/h5-taro/src/assets/me-active.svg
Normal 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 |
1
software/h5-taro/src/assets/me.svg
Normal 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 |
44
software/h5-taro/src/components/ChannelCard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
35
software/h5-taro/src/components/ConfirmDialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
24
software/h5-taro/src/components/ErrorState.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
12
software/h5-taro/src/components/Loading.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
27
software/h5-taro/src/components/StatusBadge.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
23
software/h5-taro/src/index.html
Normal 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>
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
export default {
|
||||||
|
navigationBarTitleText: '通道详情',
|
||||||
|
}
|
||||||
238
software/h5-taro/src/pages/compartment-detail/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
3
software/h5-taro/src/pages/device-detail/index.config.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export default {
|
||||||
|
navigationBarTitleText: '设备详情',
|
||||||
|
}
|
||||||
79
software/h5-taro/src/pages/device-detail/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
3
software/h5-taro/src/pages/devices/index.config.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export default {
|
||||||
|
navigationBarTitleText: '设备',
|
||||||
|
}
|
||||||
62
software/h5-taro/src/pages/devices/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
3
software/h5-taro/src/pages/home/index.config.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export default {
|
||||||
|
navigationBarTitleText: '首页',
|
||||||
|
}
|
||||||
113
software/h5-taro/src/pages/home/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
4
software/h5-taro/src/pages/login/index.config.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export default {
|
||||||
|
navigationBarTitleText: '登录',
|
||||||
|
navigationStyle: 'custom',
|
||||||
|
}
|
||||||
89
software/h5-taro/src/pages/login/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
3
software/h5-taro/src/pages/me/index.config.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export default {
|
||||||
|
navigationBarTitleText: '我的',
|
||||||
|
}
|
||||||
70
software/h5-taro/src/pages/me/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
53
software/h5-taro/src/store/auth.ts
Normal 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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}))
|
||||||
21
software/h5-taro/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
@ -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>
|
|
||||||
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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'),
|
|
||||||
};
|
|
||||||
@ -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 ?? [];
|
|
||||||
}
|
|
||||||
@ -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' }),
|
|
||||||
};
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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 }),
|
|
||||||
};
|
|
||||||
@ -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 });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
@ -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>,
|
|
||||||
)
|
|
||||||
@ -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"]
|
|
||||||
}
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"files": [],
|
|
||||||
"references": [
|
|
||||||
{ "path": "./tsconfig.app.json" },
|
|
||||||
{ "path": "./tsconfig.node.json" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@ -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"]
|
|
||||||
}
|
|
||||||
@ -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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
327
software/web/package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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()}`);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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 }}>
|
||||||
|
输入新的15位IMEI,原绑定码不变。
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
placeholder="请输入15位新IMEI"
|
||||||
|
value={newImei}
|
||||||
|
onChange={setNewImei}
|
||||||
|
maxLength={15}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
@ -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
@ -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
@ -0,0 +1,159 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Redis 模拟数据填充工具
|
||||||
|
- 从数据库读取前10个设备
|
||||||
|
- 生成随机状态写入Redis(设备级+板级+通道级)
|
||||||
|
- 300秒后自动过期(与设备上报TTL一致)
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python tools/mock_redis.py # 填充模拟数据
|
||||||
|
python tools/mock_redis.py --clear # 清除所有模拟数据
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import redis
|
||||||
|
import pymysql
|
||||||
|
|
||||||
|
# Redis 配置
|
||||||
|
REDIS_HOST = "10.8.0.252"
|
||||||
|
REDIS_PORT = 6379
|
||||||
|
REDIS_PASSWORD = "Hbhyg731024@"
|
||||||
|
REDIS_DB = 5
|
||||||
|
|
||||||
|
# MySQL 配置
|
||||||
|
MYSQL_HOST = "10.8.0.252"
|
||||||
|
MYSQL_PORT = 3306
|
||||||
|
MYSQL_USER = "root"
|
||||||
|
MYSQL_PASSWORD = "Hbhyg731024@"
|
||||||
|
MYSQL_DB = "pms"
|
||||||
|
|
||||||
|
# 设备数量
|
||||||
|
DEVICE_COUNT = 10
|
||||||
|
|
||||||
|
# 通道状态: 0=空闲, 1=插入, 2=充电中, 3=充满, 4=故障
|
||||||
|
STATUS_IDLE = 0
|
||||||
|
STATUS_INSERTED = 1
|
||||||
|
STATUS_CHARGING = 2
|
||||||
|
STATUS_FULL = 3
|
||||||
|
STATUS_FAULT = 4
|
||||||
|
|
||||||
|
|
||||||
|
def get_devices():
|
||||||
|
"""从数据库获取前N个设备"""
|
||||||
|
conn = pymysql.connect(
|
||||||
|
host=MYSQL_HOST, port=MYSQL_PORT, user=MYSQL_USER,
|
||||||
|
password=MYSQL_PASSWORD, database=MYSQL_DB, charset="utf8mb4"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with conn.cursor(pymysql.cursors.DictCursor) as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT c.imei, c.abstract_id, c.name,
|
||||||
|
(SELECT COUNT(*) FROM cabin_boards cb WHERE cb.cabinet_id = c.id) as board_count
|
||||||
|
FROM cabinets c ORDER BY c.id LIMIT %s
|
||||||
|
""", (DEVICE_COUNT,))
|
||||||
|
return cur.fetchall()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def gen_channel_hex(status: int) -> str:
|
||||||
|
"""生成单通道hex(4字节=8个hex字符)"""
|
||||||
|
# 字节1: bit0=在线 bit1=充电中 bit2=充满 bit3=故障
|
||||||
|
byte1 = 0x01 # 在线
|
||||||
|
if status == STATUS_CHARGING:
|
||||||
|
byte1 |= 0x02
|
||||||
|
elif status == STATUS_FULL:
|
||||||
|
byte1 |= 0x04
|
||||||
|
elif status == STATUS_FAULT:
|
||||||
|
byte1 |= 0x08
|
||||||
|
# 字节2-4: 随机(模拟电压电流等)
|
||||||
|
byte2 = random.randint(0x10, 0x60)
|
||||||
|
byte3 = random.randint(0x00, 0xFF)
|
||||||
|
byte4 = random.randint(0x00, 0xFF)
|
||||||
|
return f"{byte1:02x}{byte2:02x}{byte3:02x}{byte4:02x}"
|
||||||
|
|
||||||
|
|
||||||
|
def gen_board_status_hex(board_idx: int) -> str:
|
||||||
|
"""生成一块板6个通道的status_hex"""
|
||||||
|
parts = []
|
||||||
|
for ch in range(6):
|
||||||
|
# 随机分配状态,充电和故障少一些
|
||||||
|
r = random.random()
|
||||||
|
if r < 0.02:
|
||||||
|
status = STATUS_FAULT
|
||||||
|
elif r < 0.25:
|
||||||
|
status = STATUS_CHARGING
|
||||||
|
elif r < 0.32:
|
||||||
|
status = STATUS_FULL
|
||||||
|
elif r < 0.42:
|
||||||
|
status = STATUS_INSERTED
|
||||||
|
else:
|
||||||
|
status = STATUS_IDLE
|
||||||
|
parts.append(gen_channel_hex(status))
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def fill_mock_data():
|
||||||
|
"""填充模拟数据"""
|
||||||
|
r = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, password=REDIS_PASSWORD, db=REDIS_DB, decode_responses=True)
|
||||||
|
devices = get_devices()
|
||||||
|
print(f"获取到 {len(devices)} 个设备")
|
||||||
|
|
||||||
|
for dev in devices:
|
||||||
|
imei = dev["imei"]
|
||||||
|
board_count = dev.get("board_count") or 6
|
||||||
|
device_key = f"device:{imei}"
|
||||||
|
|
||||||
|
# 设备级
|
||||||
|
r.hset(device_key, mapping={
|
||||||
|
"online": "1",
|
||||||
|
"rssi": str(random.randint(30, 95)),
|
||||||
|
"pow_fail_dc": random.choice(["0", "0", "0", "1"]), # 25%概率停电
|
||||||
|
"pow_fail_ac": "0",
|
||||||
|
})
|
||||||
|
r.expire(device_key, 300)
|
||||||
|
|
||||||
|
# 板级 + 通道级
|
||||||
|
for board_idx in range(1, board_count + 1):
|
||||||
|
board_key = f"{device_key}:board:{board_idx}"
|
||||||
|
status_hex = gen_board_status_hex(board_idx)
|
||||||
|
r.hset(board_key, mapping={"status_hex": status_hex})
|
||||||
|
|
||||||
|
# 解析6个通道
|
||||||
|
for ch in range(6):
|
||||||
|
ch_hex = status_hex[ch * 8 : (ch + 1) * 8]
|
||||||
|
byte1 = int(ch_hex[0:2], 16)
|
||||||
|
ch_key = f"{board_key}:ch:{ch}"
|
||||||
|
r.hset(ch_key, mapping={
|
||||||
|
"on": "1" if byte1 & 0x02 else "0",
|
||||||
|
"full": "1" if byte1 & 0x04 else "0",
|
||||||
|
"fault": "1" if byte1 & 0x08 else "0",
|
||||||
|
"hex": ch_hex,
|
||||||
|
})
|
||||||
|
|
||||||
|
name = dev.get("name") or imei
|
||||||
|
print(f" ✓ {name} ({imei}) — {board_count}板")
|
||||||
|
|
||||||
|
print(f"\n完成!已填充 {len(devices)} 个设备,300秒后自动过期")
|
||||||
|
|
||||||
|
|
||||||
|
def clear_mock_data():
|
||||||
|
"""清除所有模拟数据"""
|
||||||
|
r = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, password=REDIS_PASSWORD, db=REDIS_DB, decode_responses=True)
|
||||||
|
devices = get_devices()
|
||||||
|
count = 0
|
||||||
|
for dev in devices:
|
||||||
|
imei = dev["imei"]
|
||||||
|
keys = r.keys(f"device:{imei}*")
|
||||||
|
if keys:
|
||||||
|
r.delete(*keys)
|
||||||
|
count += len(keys)
|
||||||
|
print(f"已清除 {count} 个 Redis key")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if "--clear" in sys.argv:
|
||||||
|
clear_mock_data()
|
||||||
|
else:
|
||||||
|
fill_mock_data()
|
||||||
79
tools/update_cabinets.py
Normal 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
@ -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()
|
||||||
33
tools/update_imei_iccid.py
Normal 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()
|
||||||
7
生产环境.txt
@ -2,6 +2,13 @@
|
|||||||
账号:13951516272
|
账号:13951516272
|
||||||
密码:wxad139
|
密码:wxad139
|
||||||
|
|
||||||
|
无锡艾动电子有限公司
|
||||||
|
|
||||||
|
腾讯地图
|
||||||
|
key:HWVBZ-CMACV-JEBP5-5RDRJ-OIHLK-ZNFE2
|
||||||
|
Secret key( SK ):LYVx9XenBOKGnbAKkmzJxOYFhASp9FQD
|
||||||
|
签名验证
|
||||||
|
|
||||||
https://signin.aliyun.com/login.htm?username=skyp76%401086952030628727.onaliyun.com&defaultShowQrCode=false
|
https://signin.aliyun.com/login.htm?username=skyp76%401086952030628727.onaliyun.com&defaultShowQrCode=false
|
||||||
|
|
||||||
mysql
|
mysql
|
||||||
|
|||||||