transfering

This commit is contained in:
2025-08-26 21:34:11 +08:00
parent e0fc2bc0a1
commit 6fa066bd1b
88 changed files with 12551 additions and 68997 deletions
+1 -1
View File
@@ -15,5 +15,5 @@
"username": "root"
}
],
"java.configuration.updateBuildConfiguration": "interactive"
"java.configuration.updateBuildConfiguration": "automatic"
}
@@ -1,125 +0,0 @@
# 健康评分空数据显示修复报告
## 问题描述
当健康评分API `/health_data/score` 返回数据为空时:
```json
{
"data": null,
"message": "健康数据为空",
"success": false
}
```
前端页面仍然显示硬编码的89分,而不是显示"暂无数据"状态。
## 问题根因
1. **HTML模板硬编码问题**:`bigscreen_main.html`中硬编码了89分和"良好状态"
2. **JavaScript处理不完善**:成功分支未更新健康状态文本
3. **调试信息缺失**:缺少API响应的调试日志
## 修复方案
### 1. 修复HTML硬编码值
**文件**:`client-deployment/ljwx-bigscreen-local/bigScreen/templates/bigscreen_main.html`
```html
<!-- 修改前 -->
<div class="score-number">89</div>
<div class="score-status">良好状态</div>
<!-- 修改后 -->
<div class="score-number">--</div>
<div class="score-status">暂无数据</div>
```
### 2. 增强JavaScript处理逻辑
**位置**:第1889行健康评分API调用处
```javascript
// 添加调试日志
console.log('健康评分API响应:', result);
// 成功分支添加状态文本更新
const scoreStatusElement = document.querySelector('.score-status');
if (scoreStatusElement) {
const statusText = result.data.summary.status?.message || '良好状态';
scoreStatusElement.textContent = statusText;
}
// 失败分支添加详细日志
console.log('健康评分API返回失败或无数据:', result.message || '未知错误');
```
### 3. 完善错误处理分支
确保以下三种情况都正确显示"暂无数据":
- API返回 `success: false`
- API返回数据格式异常
- 网络请求失败
## 验证结果
### API测试
```bash
curl "http://localhost:8001/health_data/score?orgId=1&startDate=2025-06-13&endDate=2025-06-20"
# 返回: {"success": false, "message": "健康数据为空", "data": null}
```
### 前端显示
- ✅ 评分数字:显示 `--` 而不是 `89`
- ✅ 状态文本:显示 `暂无数据` 而不是 `良好状态`
- ✅ 雷达图:显示全0分状态
- ✅ 调试日志:正确输出API响应信息
### 测试页面
创建了专用测试页面 `test_health_score.html` 用于验证不同场景:
- 🔴 空数据API测试
- 🟢 正常数据模拟
- ⚠️ 网络错误模拟
## 技术要点
### 1. 码高尔夫优化
```javascript
// 统一处理函数,减少代码重复
function updateScoreDisplay(score, status) {
document.querySelector('.score-number').textContent = score;
document.querySelector('.score-status').textContent = status;
}
```
### 2. 错误处理模式
```javascript
// 三层错误处理:API失败 -> 数据异常 -> 网络错误
if (result.success && result.data && result.data.healthScores) {
// 正常处理
} else {
// 失败处理(API返回false或数据异常)
}
// .catch() 网络错误处理
```
### 3. 调试友好
- 添加 `console.log` 输出API响应
- 区分不同错误类型的日志消息
- 提供测试页面便于调试
## 兼容性说明
- ✅ 保持原有成功数据的显示逻辑不变
- ✅ 向后兼容不同API响应格式
- ✅ 支持中文错误信息显示
- ✅ 响应式布局适配
## 部署说明
修改已应用到:
```
client-deployment/ljwx-bigscreen-local/bigScreen/templates/bigscreen_main.html
```
重启容器生效:
```bash
docker-compose restart ljwx-bigscreen
```
访问页面验证:
- 大屏页面:http://localhost:8001/?customerId=1
- 测试页面:http://localhost:8001/test_health_score.html
@@ -1,209 +0,0 @@
# 健康任务调度器问题修复总结
## 🚨 问题描述
用户发现ljwx-boot定时任务没有创建2025年7月的健康数据分表 `t_user_health_data_202507`,只有6月的分表存在。
## 🔍 问题诊断过程
### 1. 初步检查
```bash
# 检查现有分表
docker exec -it ljwx-mysql mysql -uroot -p123456 -e "USE \`lj-06\`; SHOW TABLES LIKE 't_user_health_data_%';"
# 结果:只有t_user_health_data_202506,缺少202507
```
### 2. 容器状态检查
```bash
docker ps -a | grep ljwx
# ljwx-boot状态: Up 22 minutes (unhealthy)
```
### 3. 关键发现
从ljwx-boot日志中发现关键信息:
```
2025-07-01 13:39:29.233 INFO c.l.m.h.config.HealthTaskConfiguration L38 :
✅ 健康任务调度器已初始化,线程池大小: 0
```
**核心问题**: 健康任务调度器线程池大小为0,无法执行定时任务!
## 🎯 根因分析
### 1. 定时任务配置问题
在`application-common.yml`中发现:
```yaml
quartz:
job-store-type: memory # 临时使用内存存储,避免数据库连接问题
# 缺少完整的线程池配置
```
### 2. 主配置文件问题
在`application.yml`中发现:
```yaml
# - classpath:config/quartz.yml # 临时禁用Quartz配置
```
### 3. 分表任务存在但无法执行
在`HealthBaselineScoreTasks.java`中确实存在分表任务:
```java
@Scheduled(cron = "0 0 0 1 * ?") // 每月1日凌晨执行
@Transactional(rollbackFor = Exception.class)
public void archiveAndResetUserHealthTable() {
// 分表逻辑完整
}
```
**问题**:任务代码正常,但调度器线程池为0导致无法执行。
## ✅ 解决方案
### 1. 立即解决:手动创建7月分表
创建并执行了 `create-july-table.sh` 脚本:
```bash
./create-july-table.sh
```
**结果**:
- ✅ `t_user_health_data_202507` 创建成功
- ✅ 添加了完整的索引优化
- ✅ 设置了表注释标识
### 2. 配置文件修复(尝试)
修复了 `application-common.yml` 配置:
```yaml
quartz:
job-store-type: memory
properties:
org:
quartz:
threadPool:
threadCount: 10 # 设置线程池大小
threadPriority: 5
threadsInheritContextClassLoaderOfInitializingThread: true
scheduler:
instanceName: LjwxScheduler
instanceId: AUTO
task:
scheduling:
enabled: true
pool:
size: 10
```
**问题**:Docker镜像中的配置文件不会受到宿主机修改影响。
### 3. 环境变量修复(尝试)
在 `docker-compose.yml` 中添加了环境变量:
```yaml
environment:
SPRING_TASK_SCHEDULING_POOL_SIZE: 10
SPRING_QUARTZ_PROPERTIES_ORG_QUARTZ_THREADPOOL_THREADCOUNT: 10
```
**结果**:线程池大小仍为0,说明问题更深层。
## 🔧 根本解决方案
### 分析ThreadPoolTaskScheduler问题
`HealthTaskConfiguration.java` 中的问题可能是:
1. **初始化时机问题**:
```java
scheduler.setPoolSize(8);
scheduler.initialize();
log.info("线程池大小: {}", scheduler.getPoolSize()); // 可能在初始化完成前调用
```
2. **建议的修复方案**:
```java
@Bean("healthTaskScheduler")
public TaskScheduler healthTaskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(10);
scheduler.setThreadNamePrefix("health-task-");
scheduler.setAwaitTerminationSeconds(30);
scheduler.setWaitForTasksToCompleteOnShutdown(true);
scheduler.initialize();
// 延迟获取线程池大小
scheduler.afterPropertiesSet();
log.info("✅ 健康任务调度器已初始化,线程池大小: {}", scheduler.getPoolSize());
return scheduler;
}
```
## 📊 当前状态
### ✅ 已解决
1. **主要问题**:`t_user_health_data_202507` 表已创建
2. **验证结果**:
```sql
mysql> SHOW TABLES LIKE 't_user_health_data_%';
+----------------------------------------+
| Tables_in_lj-06 (t_user_health_data_%) |
+----------------------------------------+
| t_user_health_data_202506 |
| t_user_health_data_202507 | ← 成功创建
| t_user_health_data_daily |
| t_user_health_data_partitioned |
| t_user_health_data_weekly |
+----------------------------------------+
```
### ⚠️ 待解决
1. **定时任务线程池配置**:仍显示大小为0
2. **自动分表功能**:下个月可能还会遇到同样问题
## 🔮 后续建议
### 1. 代码级修复
需要修改 `HealthTaskConfiguration.java`:
```java
// 方案1:修复初始化时机
scheduler.afterPropertiesSet();
// 方案2:使用@Async + @Scheduled
// 或者改用Spring Boot默认的任务调度器
```
### 2. 监控和预警
```bash
# 建议添加定时任务执行监控
# 检查健康任务日志表
SELECT * FROM t_health_task_log WHERE task_type = 'archive' ORDER BY create_time DESC;
# 监控分表创建
# 每月检查是否有新的分表生成
```
### 3. 手动备用方案
保留 `create-july-table.sh` 脚本,可以用于:
```bash
# 创建任意月份的分表
# 修改TARGET_TABLE变量即可
TARGET_TABLE="t_user_health_data_202508" # 8月分表
```
## 📋 验证清单
### ✅ 立即验证
- [x] `t_user_health_data_202507` 表存在
- [x] 表结构正确(与主表一致)
- [x] 索引创建完整
- [x] 表注释设置正确
### 🔄 定期验证
- [ ] 每月1日检查新分表是否自动创建
- [ ] 监控健康任务日志表的记录
- [ ] 验证定时任务线程池状态
## 🎯 总结
**问题原因**:健康任务调度器线程池配置不正确,导致定时任务无法执行
**解决状态**:✅ 用户问题已解决(手动创建了7月分表)
**后续行动**:需要修复定时任务调度器的代码实现
用户现在可以正常使用7月份的健康数据分表功能了。
@@ -1,129 +0,0 @@
# Linux兼容性解决方案总结
## 🚨 问题描述
客户现场部署ljwx-bigscreen时出现重大事故,原因是`wait-for-it.sh`格式问题导致不支持Linux系统。
## 🔧 解决方案
### 1. 完全重写wait-for-it.sh
- **问题**: 原脚本格式不兼容Linux,网络连接检测不稳定
- **解决**: 完全重写脚本,支持多种Linux发行版
- **特性**:
- 自动检测可用的网络工具(nc/netcat/telnet/bash内置)
- 支持CentOS、Ubuntu、Debian、Alpine等主流发行版
- 增强错误处理和参数验证
- 中文友好的错误提示
### 2. 全面Linux兼容性修复
创建了`fix-linux-compatibility.sh`脚本,自动修复:
- ✅ 文件行结束符 (CRLF → LF)
- ✅ 脚本执行权限设置
- ✅ Shebang标准化 (#!/bin/bash)
- ✅ bash语法兼容性修复
- ✅ 命令兼容性修复 (grep -E → egrep)
### 3. 完整的质量保证流程
创建了多层检查机制:
#### 客户部署包准备 (`prepare-client-deployment.sh`)
- 检查必需文件完整性
- 运行Linux兼容性修复
- 验证脚本语法
- 测试关键功能
- 验证Docker配置
- 生成部署信息和检查清单
#### 版本提交流程 (`commit-client-deployment.sh`)
- 获取版本信息
- 运行完整检查
- 验证关键文件
- 专项测试wait-for-it.sh
- 生成提交报告
- 创建部署包
- Git提交
#### 客户端测试工具
- `quick-test.sh`: 快速功能验证
- `test-linux-compatibility.sh`: 兼容性测试
- `DEPLOYMENT_CHECKLIST.md`: 部署检查清单
## 📋 修复的关键文件
### wait-for-it.sh (完全重写)
```bash
#!/bin/bash
# 支持多种Linux发行版的网络等待脚本
# 自动检测网络工具: nc/netcat/telnet/bash内置
# 增强错误处理和参数验证
```
### deploy-client.sh (兼容性优化)
- 修复镜像版本读取逻辑
- 增加CentOS兼容性注释
- 优化错误处理
### docker-compose.yml (挂载优化)
- 确保wait-for-it.sh正确挂载到所有容器
- 统一使用标准化的等待命令
## 🎯 支持的Linux发行版
- ✅ CentOS 7+
- ✅ Ubuntu 18.04+
- ✅ Debian 9+
- ✅ RHEL 7+
- ✅ Amazon Linux 2
- ✅ Alpine Linux
## 🚀 使用流程
### 开发团队使用
1. 修改客户部署相关文件
2. 运行 `./scripts/commit-client-deployment.sh`
3. 通过所有检查后提交到版本控制
4. 发送生成的部署包给客户
### 客户部署使用
1. 解压部署包
2. 运行 `./test-linux-compatibility.sh` 测试兼容性
3. 运行 `./quick-test.sh` 验证部署包
4. 修改 `custom-config.env` 配置文件
5. 运行 `./deploy-client.sh` 开始部署
6. 按照 `DEPLOYMENT_CHECKLIST.md` 进行验收
## 🔒 质量保证
### 自动化检查
- 脚本语法验证 (bash -n)
- 文件权限检查
- Docker配置验证
- 网络连接测试
### 手动验证建议
- 在CentOS 7环境测试
- 在Ubuntu 18.04环境测试
- 验证所有容器启动顺序
- 确认服务依赖等待正常
## 📊 测试结果
```
✅ 脚本语法验证: 通过
✅ Linux兼容性: 通过
✅ wait-for-it.sh功能: 通过
✅ Docker配置: 通过
✅ 部署包完整性: 通过
```
## 🎉 解决效果
- **彻底解决**: wait-for-it.sh Linux兼容性问题
- **预防机制**: 完整的检查流程防止类似问题
- **质量提升**: 标准化的部署包生成流程
- **客户体验**: 提供详细的部署指南和测试工具
## 📞 技术支持
如客户部署过程中遇到问题:
1. 首先运行兼容性测试脚本诊断
2. 查看生成的部署报告
3. 联系技术支持团队
---
**重要**: 此解决方案确保所有客户部署包都经过完整的Linux兼容性验证,杜绝类似重大事故再次发生。
-124
View File
@@ -1,124 +0,0 @@
# 定制Logo配置指南
## 概述
系统支持客户定制Logo功能,可以将客户的Logo文件集成到管理界面中。
## 配置步骤
### 1. 准备Logo文件
将客户的Logo文件命名为以下任意一种:
- `custom-logo.svg` (推荐)
- `customer-logo.svg`
- `logo.svg`
- `company-logo.svg`
**注意:**
- 建议使用SVG格式,支持矢量缩放
- 文件大小建议控制在50KB以内
- 建议尺寸比例为横向布局,如200x60像素
### 2. 放置Logo文件
将Logo文件放置在 `client-deployment` 目录下。
### 3. 启用Logo配置
在 `custom-config.env` 文件中设置:
```bash
VITE_CUSTOM_LOGO=true
```
### 4. 部署系统
执行部署命令:
```bash
./deploy-client.sh
```
系统会自动:
1. 检测Logo文件
2. 将文件复制到容器内正确位置
3. 配置前端应用使用定制Logo
## 故障排除
### 使用诊断工具
如果Logo未正确显示,可使用诊断脚本:
```bash
./diagnose-logo-issue.sh
```
该脚本会:
- ✅ 检查Logo文件是否存在
- ✅ 验证配置是否正确
- ✅ 确认文件挂载状态
- ✅ 测试HTTP访问
- ✅ 自动修复常见问题
### 手动修复
如果需要手动修复,需要替换前端应用中的实际logo文件:
```bash
# 1. 查找前端应用中的logo文件
docker exec ljwx-admin find /usr/share/nginx/html/assets -name "logo*.svg"
# 2. 备份原始文件 (以第一个文件为例)
docker exec ljwx-admin cp /usr/share/nginx/html/assets/logo-DgyNd7sd.svg /usr/share/nginx/html/assets/logo-DgyNd7sd.svg.backup
# 3. 用定制logo替换
docker exec ljwx-admin cp /tmp/custom-logo.svg /usr/share/nginx/html/assets/logo-DgyNd7sd.svg
# 4. 对所有logo文件重复上述操作
```
### 快速修复
使用自动化脚本进行修复:
```bash
./quick-fix-logo.sh # 快速修复
./replace-logo-correctly.sh # 完整替换过程
```
### 验证访问
测试Logo是否可访问:
```bash
curl -I http://localhost:8080/custom-logo.svg
```
应返回HTTP 200状态码。
## 支持的文件格式
- **SVG** (推荐) - 矢量格式,任意缩放不失真
- **PNG** - 建议透明背景
- **JPG** - 不推荐,因为有背景色
## 浏览器缓存
如果更新Logo后未生效,请:
1. 清除浏览器缓存
2. 硬刷新页面 (Ctrl+F5)
3. 运行缓存清理脚本:`./clear-browser-cache.sh`
## 技术实现
Logo集成流程:
1. 本地文件 → Docker挂载到 `/tmp/custom-logo.svg`
2. 部署脚本 → 替换前端应用中的编译后logo文件
- 查找 `/usr/share/nginx/html/assets/logo*.svg`
- 备份原始文件 (添加.backup后缀)
- 用定制logo替换所有找到的logo文件
3. 前端应用 → 自动加载替换后的logo文件
4. 配置变量 → `VITE_CUSTOM_LOGO=true` 启用替换功能
**重要说明**:前端应用使用的是编译时打包的logo文件,文件名包含哈希值(如`logo-DgyNd7sd.svg`),因此需要直接替换这些文件内容,而不是创建新文件。
## 联系支持
如有问题,请联系技术支持,并提供:
- 诊断脚本输出
- Logo文件信息
- 浏览器开发者工具错误信息
@@ -1,115 +0,0 @@
# Logo无法加载问题排查指南
## 🔍 问题现象
- 管理后台页面中Logo显示异常
- 浏览器开发者工具显示:`<img src="false" alt="logo">`
- 启动日志显示Logo已更新但实际未生效
## 🕵️ 问题根源分析
### 1. 前端逻辑分析
```javascript
// 前端JavaScript伪代码
const logoSrc = useCustomLogo ? customLogoPath : defaultLogo;
// 当useCustomLogo=true但customLogoPath指向不存在文件时
// 可能回退到布尔值false,导致src="false"
```
### 2. 问题链路追踪
```mermaid
graph TD
A[Docker启动] --> B[环境变量VITE_CUSTOM_LOGO=true]
B --> C[config.js: useCustomLogo=true]
C --> D[customLogoPath=/assets/svg-icon/logo.svg]
D --> E[文件不存在]
E --> F[前端回退逻辑错误]
F --> G[img src=false]
H[Logo文件实际路径] --> I[/assets/logo-DgyNd7sd.svg]
I --> J[路径不匹配]
J --> K[加载失败]
```
### 3. 关键发现
| 配置项 | 期望值 | 实际值 | 状态 |
|--------|--------|--------|------|
| VITE_CUSTOM_LOGO | true | true | ✅ |
| useCustomLogo | true | true | ✅ |
| customLogoPath | /assets/logo-DgyNd7sd.svg | /assets/svg-icon/logo.svg | ❌ |
| Logo文件位置 | /assets/logo-DgyNd7sd.svg | /assets/logo-DgyNd7sd.svg | ✅ |
## 🛠️ 解决方案
### 修复1:路径配置同步
```js
// custom-admin-config.js
customLogoPath: '/assets/logo-DgyNd7sd.svg' // 匹配实际文件路径
```
### 修复2:Docker挂载策略
```yaml
# docker-compose-client.yml
volumes:
- ./custom-logo.svg:/tmp/custom-logo.svg:ro # 启动脚本期望位置
- ./custom-admin-config.js:/tmp/custom-admin-config.js:ro # 避免Resource busy
```
### 修复3:环境变量强制设置
```yaml
environment:
VITE_CUSTOM_LOGO: "true" # 硬编码避免环境变量传递问题
```
## 🔧 排查工具和方法
### 1. 检查Web资源可访问性
```bash
curl -I http://localhost:8080/config.js
curl -I http://localhost:8080/assets/logo-DgyNd7sd.svg
```
### 2. 验证配置文件内容
```bash
curl -s http://localhost:8080/config.js | grep customLogoPath
```
### 3. 检查容器内文件
```bash
docker exec ljwx-admin ls -la /usr/share/nginx/html/assets/ | grep logo
```
### 4. 环境变量验证
```bash
docker exec ljwx-admin env | grep VITE_CUSTOM_LOGO
```
### 5. 浏览器开发者工具
- Network标签页:检查资源加载状态
- Console标签页:查看JavaScript错误
- Elements标签页:检查img标签src属性
## ⚠️ 常见陷阱
1. **路径大小写敏感**:Linux容器对文件路径大小写敏感
2. **缓存问题**:浏览器可能缓存了错误的配置
3. **时序问题**:config.js可能在主JS之后加载
4. **文件权限**:只读挂载可能导致启动脚本无法修改配置
## 📈 验证清单
- [ ] 环境变量VITE_CUSTOM_LOGO=true
- [ ] config.js中useCustomLogo=true
- [ ] customLogoPath指向正确的文件路径
- [ ] Logo文件可通过Web访问(200状态码)
- [ ] 浏览器开发者工具中img标签src属性正确
- [ ] 清除浏览器缓存后重新测试
## 🎯 最佳实践
1. **统一路径管理**:Logo路径在配置文件中统一管理
2. **健康检查**:启动脚本应验证Logo文件是否存在
3. **回退机制**:前端应有合理的Logo加载失败回退策略
4. **监控告警**:关键资源加载失败应有日志记录
+1 -3
View File
@@ -306,9 +306,7 @@ else
echo "✅ $image - 本地镜像可用"
else
echo "⚠️ $image - 本地镜像不存在"
MISSING_IMAGES="$MISSING_IMAGES $image"
fi
done
144e'e'e'e'ty'rdone
if [ -n "$MISSING_IMAGES" ]; then
echo ""
+29 -29
View File
@@ -4,8 +4,8 @@ services:
container_name: ljwx-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_PASSWORD:-123456}
MYSQL_DATABASE: ${MYSQL_DATABASE:-lj-06}
MYSQL_ROOT_PASSWORD: 123456
MYSQL_DATABASE: lj-06
MYSQL_CHARACTER_SET_SERVER: utf8mb4
MYSQL_COLLATION_SERVER: utf8mb4_unicode_ci
TZ: Asia/Shanghai
@@ -52,29 +52,29 @@ services:
- ljwx-network
ljwx-boot:
image: crpi-yilnm6upy4pmbp67.cn-shenzhen.personal.cr.aliyuncs.com/ljwx/ljwx-boot:1.2.16
image: crpi-yilnm6upy4pmbp67.cn-shenzhen.personal.cr.aliyuncs.com/ljwx/ljwx-boot:1.3.3
container_name: ljwx-boot
restart: unless-stopped
env_file:
- custom-config.env
environment:
SPRING_PROFILES_ACTIVE: common
MYSQL_HOST: ${MYSQL_HOST:-mysql}
MYSQL_PORT: ${MYSQL_PORT:-3306}
MYSQL_USER: ${MYSQL_USER:-root}
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-123456}
MYSQL_DATABASE: ${MYSQL_DATABASE:-lj-06}
REDIS_HOST: ${REDIS_HOST:-redis}
REDIS_PORT: ${REDIS_PORT:-6379}
REDIS_PASSWORD: "123456"
MYSQL_HOST: mysql
MYSQL_PORT: 3306
MYSQL_USER: root
MYSQL_PASSWORD: 123456
MYSQL_DATABASE: lj-06
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_PASSWORD: 123456
TZ: Asia/Shanghai
SPRING_DATASOURCE_URL: jdbc:mysql://${MYSQL_HOST:-mysql}:${MYSQL_PORT:-3306}/${MYSQL_DATABASE:-lj-06}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
SPRING_DATASOURCE_USERNAME: ${MYSQL_USER:-root}
SPRING_DATASOURCE_PASSWORD: ${MYSQL_PASSWORD:-123456}
SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/lj-06?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
SPRING_DATASOURCE_USERNAME: root
SPRING_DATASOURCE_PASSWORD: 123456
SPRING_DATASOURCE_DRIVER_CLASS_NAME: com.mysql.cj.jdbc.Driver
SPRING_DATA_REDIS_HOST: ${REDIS_HOST:-redis}
SPRING_DATA_REDIS_PORT: ${REDIS_PORT:-6379}
SPRING_DATA_REDIS_PASSWORD: "123456"
SPRING_DATA_REDIS_HOST: redis
SPRING_DATA_REDIS_PORT: 6379
SPRING_DATA_REDIS_PASSWORD: 123456
ports:
- "9998:9998"
volumes:
@@ -92,20 +92,20 @@ services:
- ljwx-network
ljwx-bigscreen:
image: crpi-yilnm6upy4pmbp67.cn-shenzhen.personal.cr.aliyuncs.com/ljwx/ljwx-bigscreen:1.2.16
image: crpi-yilnm6upy4pmbp67.cn-shenzhen.personal.cr.aliyuncs.com/ljwx/ljwx-bigscreen:1.3.3
container_name: ljwx-bigscreen
restart: unless-stopped
env_file:
- custom-config.env
environment:
MYSQL_HOST: ${MYSQL_HOST:-mysql}
MYSQL_PORT: ${MYSQL_PORT:-3306}
MYSQL_USER: ${MYSQL_USER:-root}
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-123456}
MYSQL_DATABASE: ${MYSQL_DATABASE:-lj-06}
REDIS_HOST: ${REDIS_HOST:-redis}
REDIS_PORT: ${REDIS_PORT:-6379}
REDIS_PASSWORD: ${REDIS_PASSWORD:-123456}
MYSQL_HOST: mysql
MYSQL_PORT: 3306
MYSQL_USER: root
MYSQL_PASSWORD: 123456
MYSQL_DATABASE: lj-06
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_PASSWORD: 123456
IS_DOCKER: "true"
APP_PORT: 8001
TZ: Asia/Shanghai
@@ -128,15 +128,15 @@ services:
- ljwx-network
ljwx-admin:
image: crpi-yilnm6upy4pmbp67.cn-shenzhen.personal.cr.aliyuncs.com/ljwx/ljwx-admin:1.2.16
image: crpi-yilnm6upy4pmbp67.cn-shenzhen.personal.cr.aliyuncs.com/ljwx/ljwx-admin:1.3.3
container_name: ljwx-admin
restart: unless-stopped
env_file:
- custom-config.env
environment:
VITE_API_URL: http://ljwx-boot:9998
VITE_BIGSCREEN_URL: ${VITE_BIGSCREEN_URL:-http://localhost:8001}
VITE_APP_TITLE: ${VITE_APP_TITLE:-穿戴管理演示系统}
VITE_BIGSCREEN_URL: http://192.168.1.6:8001
VITE_APP_TITLE: 穿戴健康管理平台
TZ: Asia/Shanghai
ports:
- "8080:80"
-108
View File
@@ -1,108 +0,0 @@
-- 健康画像管理中心数据表设计
-- 1. 用户健康画像表
CREATE TABLE `t_health_profile` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`user_id` bigint(20) NOT NULL COMMENT '用户ID',
`customer_id` bigint(20) NOT NULL COMMENT '客户ID',
`org_id` bigint(20) DEFAULT NULL COMMENT '部门ID',
`health_score` decimal(5,2) DEFAULT NULL COMMENT '健康总评分(0-100)',
`health_level` varchar(20) DEFAULT NULL COMMENT '健康等级(优秀/良好/一般/差)',
`baseline_deviation_count` int(11) DEFAULT 0 COMMENT '基线偏离指标数量',
`risk_indicators` json DEFAULT NULL COMMENT '风险指标列表',
`profile_data` json DEFAULT NULL COMMENT '画像详细数据',
`generated_time` datetime DEFAULT NULL COMMENT '画像生成时间',
`data_range_start` datetime DEFAULT NULL COMMENT '数据统计开始时间',
`data_range_end` datetime DEFAULT NULL COMMENT '数据统计结束时间',
`status` tinyint(1) DEFAULT 1 COMMENT '状态(1:有效 0:无效)',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_customer` (`user_id`,`customer_id`),
KEY `idx_customer_org` (`customer_id`,`org_id`),
KEY `idx_health_level` (`health_level`),
KEY `idx_generated_time` (`generated_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户健康画像表';
-- 2. 健康数据基线表
CREATE TABLE `t_health_data_baseline` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`user_id` bigint(20) NOT NULL COMMENT '用户ID',
`customer_id` bigint(20) NOT NULL COMMENT '客户ID',
`org_id` bigint(20) DEFAULT NULL COMMENT '部门ID',
`baseline_type` varchar(20) NOT NULL COMMENT '基线类型(personal/department)',
`indicator_type` varchar(50) NOT NULL COMMENT '指标类型(heartrate/temperature/etc)',
`baseline_min` decimal(10,2) DEFAULT NULL COMMENT '基线最小值',
`baseline_max` decimal(10,2) DEFAULT NULL COMMENT '基线最大值',
`baseline_avg` decimal(10,2) DEFAULT NULL COMMENT '基线平均值',
`std_deviation` decimal(10,2) DEFAULT NULL COMMENT '标准差',
`sample_count` int(11) DEFAULT 0 COMMENT '样本数量',
`confidence_level` decimal(5,2) DEFAULT NULL COMMENT '置信度',
`calculation_period` int(11) DEFAULT 30 COMMENT '计算周期(天)',
`last_calculated` datetime DEFAULT NULL COMMENT '最后计算时间',
`status` tinyint(1) DEFAULT 1 COMMENT '状态(1:有效 0:无效)',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_baseline` (`user_id`,`customer_id`,`baseline_type`,`indicator_type`),
KEY `idx_customer_org_type` (`customer_id`,`org_id`,`baseline_type`),
KEY `idx_indicator` (`indicator_type`),
KEY `idx_calculated_time` (`last_calculated`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康数据基线表';
-- 3. 健康数据评分表
CREATE TABLE `t_health_data_score` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`user_id` bigint(20) NOT NULL COMMENT '用户ID',
`customer_id` bigint(20) NOT NULL COMMENT '客户ID',
`org_id` bigint(20) DEFAULT NULL COMMENT '部门ID',
`score_type` varchar(20) NOT NULL COMMENT '评分类型(personal/department)',
`indicator_type` varchar(50) NOT NULL COMMENT '指标类型',
`indicator_score` decimal(5,2) DEFAULT NULL COMMENT '指标得分(0-100)',
`indicator_weight` decimal(5,2) DEFAULT 1.0 COMMENT '指标权重',
`weighted_score` decimal(5,2) DEFAULT NULL COMMENT '加权得分',
`deviation_level` varchar(20) DEFAULT NULL COMMENT '偏离程度(normal/mild/moderate/severe)',
`risk_level` varchar(20) DEFAULT NULL COMMENT '风险等级(low/medium/high/critical)',
`score_detail` json DEFAULT NULL COMMENT '评分详细信息',
`calculated_time` datetime DEFAULT NULL COMMENT '计算时间',
`data_range_start` datetime DEFAULT NULL COMMENT '数据范围开始时间',
`data_range_end` datetime DEFAULT NULL COMMENT '数据范围结束时间',
`status` tinyint(1) DEFAULT 1 COMMENT '状态(1:有效 0:无效)',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_score` (`user_id`,`customer_id`,`score_type`,`indicator_type`),
KEY `idx_customer_org_type` (`customer_id`,`org_id`,`score_type`),
KEY `idx_risk_level` (`risk_level`),
KEY `idx_calculated_time` (`calculated_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康数据评分表';
-- 4. 画像生成任务日志表
CREATE TABLE `t_health_profile_task_log` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`task_id` varchar(64) NOT NULL COMMENT '任务ID',
`task_type` varchar(20) NOT NULL COMMENT '任务类型(scheduled/manual)',
`customer_id` bigint(20) NOT NULL COMMENT '客户ID',
`target_type` varchar(20) DEFAULT NULL COMMENT '目标类型(user/org/all)',
`target_ids` json DEFAULT NULL COMMENT '目标ID列表',
`total_users` int(11) DEFAULT 0 COMMENT '总用户数',
`success_count` int(11) DEFAULT 0 COMMENT '成功数量',
`failed_count` int(11) DEFAULT 0 COMMENT '失败数量',
`failed_users` json DEFAULT NULL COMMENT '失败用户详情',
`start_time` datetime DEFAULT NULL COMMENT '开始时间',
`end_time` datetime DEFAULT NULL COMMENT '结束时间',
`duration_ms` bigint(20) DEFAULT NULL COMMENT '执行耗时(毫秒)',
`status` varchar(20) DEFAULT 'running' COMMENT '任务状态(running/success/failed)',
`error_message` text COMMENT '错误信息',
`operator_id` bigint(20) DEFAULT NULL COMMENT '操作人ID(手动任务)',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_task_id` (`task_id`),
KEY `idx_customer_type` (`customer_id`,`task_type`),
KEY `idx_status` (`status`),
KEY `idx_start_time` (`start_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康画像任务日志表';
-- 插入测试数据示例
INSERT INTO `t_health_profile` (`user_id`, `customer_id`, `org_id`, `health_score`, `health_level`, `baseline_deviation_count`, `profile_data`, `generated_time`, `data_range_start`, `data_range_end`) VALUES
(1, 1, 1, 85.50, '良好', 1, '{"heartrate": {"score": 90, "status": "normal"}, "temperature": {"score": 80, "status": "mild_deviation"}}', NOW(), DATE_SUB(NOW(), INTERVAL 30 DAY), NOW()),
(2, 1, 1, 92.30, '优秀', 0, '{"heartrate": {"score": 95, "status": "normal"}, "temperature": {"score": 88, "status": "normal"}}', NOW(), DATE_SUB(NOW(), INTERVAL 30 DAY), NOW());
+264
View File
@@ -0,0 +1,264 @@
#!/bin/bash
# 数据库初始化脚本 - 清空指定表数据并保留特定记录
# 使用方法: ./init-database.sh [数据库配置]
set -e
# 颜色定义
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
BLUE='\033[0;34m'
NC='\033[0m'
# 日志函数
log() { echo -e "${GREEN}[$(date +'%H:%M:%S')]${NC} $1"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
error() { echo -e "${RED}[ERROR]${NC} $1"; }
info() { echo -e "${BLUE}[INFO]${NC} $1"; }
# 默认配置
MYSQL_HOST=${MYSQL_HOST:-"127.0.0.1"}
MYSQL_PORT=${MYSQL_PORT:-"3306"}
MYSQL_USER=${MYSQL_USER:-"root"}
MYSQL_PASSWORD=${MYSQL_PASSWORD:-"123456"}
MYSQL_DATABASE=${MYSQL_DATABASE:-"test"}
# 加载配置文件(如果提供)
CONFIG_FILE=${1:-"custom-config.env"}
if [ -f "$CONFIG_FILE" ]; then
. "$CONFIG_FILE"
log "已加载配置文件: $CONFIG_FILE"
fi
echo "==================== 数据库初始化脚本 ===================="
echo "目标数据库: $MYSQL_HOST:$MYSQL_PORT/$MYSQL_DATABASE"
echo "用户: $MYSQL_USER"
echo ""
# 确认操作
warn "⚠️ 此操作将清空以下表的数据:"
echo "📋 完全清空的表:"
echo " - t_device_info (设备信息)"
echo " - t_device_info_history (设备历史)"
echo " - t_device_user (设备用户关联)"
echo " - t_alert_info (告警信息)"
echo " - t_alert_action_log (告警操作日志)"
echo " - t_device_message (设备消息)"
echo " - t_device_message_detail (设备消息详情)"
echo " - t_user_health_data (用户健康数据)"
echo " - sys_org_units (组织单位)"
echo " - sys_user_org (用户组织关联)"
echo " - sys_position (职位信息)"
echo " - sys_user_postion (用户职位关联)"
echo ""
echo "📋 保留特定记录的表:"
echo " - t_customer_config (保留 id=0 的记录)"
echo " - t_interface (保留 customer_id=1 的记录)"
echo " - t_health_data_config (保留 customer_id=1 的记录)"
echo ""
read -p "确认执行数据库初始化操作? 此操作不可逆! (yes/N): " confirm
if [ "$confirm" != "yes" ]; then
log "操作已取消"
exit 0
fi
# 测试数据库连接
log "测试数据库连接..."
if ! mysql -h"$MYSQL_HOST" -P"$MYSQL_PORT" -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" -e "SELECT 1;" > /dev/null 2>&1; then
error "无法连接到数据库,请检查配置"
exit 1
fi
log "✅ 数据库连接成功"
# 检查数据库是否存在
log "检查目标数据库..."
if ! mysql -h"$MYSQL_HOST" -P"$MYSQL_PORT" -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" -e "USE \`$MYSQL_DATABASE\`;" > /dev/null 2>&1; then
error "数据库 '$MYSQL_DATABASE' 不存在"
exit 1
fi
log "✅ 目标数据库存在"
# 创建备份
BACKUP_FILE="backup/database_backup_$(date +%Y%m%d_%H%M%S).sql"
mkdir -p backup
log "创建数据库备份..."
mysqldump -h"$MYSQL_HOST" -P"$MYSQL_PORT" -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" \
--single-transaction --routines --triggers "$MYSQL_DATABASE" > "$BACKUP_FILE"
log "✅ 备份完成: $BACKUP_FILE"
# 执行数据库初始化
log "开始执行数据库初始化..."
# 创建SQL脚本
# 显示操作前的数据统计
log "查询操作前的数据统计..."
mysql -h"$MYSQL_HOST" -P"$MYSQL_PORT" -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE" << 'EOF'
SELECT '=== 操作前数据统计 ===' as info;
SELECT 't_device_info' as table_name, COUNT(*) as current_count FROM t_device_info
UNION ALL
SELECT 't_alert_info', COUNT(*) FROM t_alert_info
UNION ALL
SELECT 't_user_health_data', COUNT(*) FROM t_user_health_data
UNION ALL
SELECT 'sys_org_units', COUNT(*) FROM sys_org_units
UNION ALL
SELECT 't_customer_config', COUNT(*) FROM t_customer_config
UNION ALL
SELECT 't_interface', COUNT(*) FROM t_interface
UNION ALL
SELECT 't_health_data_config', COUNT(*) FROM t_health_data_config;
EOF
echo ""
read -p "查看数据统计后,确认继续执行清理操作? (yes/N): " final_confirm
if [ "$final_confirm" != "yes" ]; then
log "操作已取消"
exit 0
fi
cat > temp_init.sql << 'EOF'
-- 数据库初始化脚本
-- 清空指定表数据并保留特定记录
-- 设置SQL模式
SET foreign_key_checks = 0;
SET sql_mode = 'NO_AUTO_VALUE_ON_ZERO';
-- 开始事务
START TRANSACTION;
-- 显示操作前统计
SELECT '=== 开始数据清理操作 ===' as info;
-- 1. 完全清空的表
DELETE FROM t_device_info;
SELECT CONCAT('✅ t_device_info 清空完成, 删除记录数: ', ROW_COUNT()) as result;
DELETE FROM t_device_info_history;
SELECT CONCAT('✅ t_device_info_history 清空完成, 删除记录数: ', ROW_COUNT()) as result;
DELETE FROM t_device_user;
SELECT CONCAT('✅ t_device_user 清空完成, 删除记录数: ', ROW_COUNT()) as result;
DELETE FROM t_alert_info;
SELECT CONCAT('✅ t_alert_info 清空完成, 删除记录数: ', ROW_COUNT()) as result;
DELETE FROM t_alert_action_log;
SELECT CONCAT('✅ t_alert_action_log 清空完成, 删除记录数: ', ROW_COUNT()) as result;
DELETE FROM t_device_message;
SELECT CONCAT('✅ t_device_message 清空完成, 删除记录数: ', ROW_COUNT()) as result;
DELETE FROM t_device_message_detail;
SELECT CONCAT('✅ t_device_message_detail 清空完成, 删除记录数: ', ROW_COUNT()) as result;
DELETE FROM t_user_health_data;
SELECT CONCAT('✅ t_user_health_data 清空完成, 删除记录数: ', ROW_COUNT()) as result;
DELETE FROM sys_org_units;
SELECT CONCAT('✅ sys_org_units 清空完成, 删除记录数: ', ROW_COUNT()) as result;
DELETE FROM sys_user_org;
SELECT CONCAT('✅ sys_user_org 清空完成, 删除记录数: ', ROW_COUNT()) as result;
DELETE FROM sys_position;
SELECT CONCAT('✅ sys_position 清空完成, 删除记录数: ', ROW_COUNT()) as result;
DELETE FROM sys_user_postion;
SELECT CONCAT('✅ sys_user_postion 清空完成, 删除记录数: ', ROW_COUNT()) as result;
-- 2. 保留特定记录的表
-- t_customer_config: 保留 id=0 的记录
DELETE FROM t_customer_config WHERE id != 0;
SELECT CONCAT('✅ t_customer_config 部分清理完成, 删除记录数: ', ROW_COUNT(), ', 保留 id=0 记录') as result;
-- t_interface: 保留 customer_id=1 的记录
DELETE FROM t_interface WHERE customer_id != 1;
SELECT CONCAT('✅ t_interface 部分清理完成, 删除记录数: ', ROW_COUNT(), ', 保留 customer_id=1 记录') as result;
-- t_health_data_config: 保留 customer_id=1 的记录
DELETE FROM t_health_data_config WHERE customer_id != 1;
SELECT CONCAT('✅ t_health_data_config 部分清理完成, 删除记录数: ', ROW_COUNT(), ', 保留 customer_id=1 记录') as result;
-- 重置自增ID到合理值
ALTER TABLE t_device_info AUTO_INCREMENT = 1;
ALTER TABLE t_alert_info AUTO_INCREMENT = 1;
ALTER TABLE sys_org_units AUTO_INCREMENT = 1;
ALTER TABLE t_device_message AUTO_INCREMENT = 1;
-- 恢复外键检查
SET foreign_key_checks = 1;
-- 提交事务
COMMIT;
-- 显示最终结果统计
SELECT '=== 最终数据统计 ===' as result;
SELECT 't_device_info' as table_name, COUNT(*) as remaining_count FROM t_device_info
UNION ALL
SELECT 't_device_info_history', COUNT(*) FROM t_device_info_history
UNION ALL
SELECT 't_device_user', COUNT(*) FROM t_device_user
UNION ALL
SELECT 't_alert_info', COUNT(*) FROM t_alert_info
UNION ALL
SELECT 't_alert_action_log', COUNT(*) FROM t_alert_action_log
UNION ALL
SELECT 't_device_message', COUNT(*) FROM t_device_message
UNION ALL
SELECT 't_device_message_detail', COUNT(*) FROM t_device_message_detail
UNION ALL
SELECT 't_user_health_data', COUNT(*) FROM t_user_health_data
UNION ALL
SELECT 'sys_org_units', COUNT(*) FROM sys_org_units
UNION ALL
SELECT 'sys_user_org', COUNT(*) FROM sys_user_org
UNION ALL
SELECT 'sys_position', COUNT(*) FROM sys_position
UNION ALL
SELECT 'sys_user_postion', COUNT(*) FROM sys_user_postion;
SELECT '=== 保留的配置记录 ===' as result;
SELECT 't_customer_config' as table_name, COUNT(*) as remaining_count FROM t_customer_config
UNION ALL
SELECT 't_interface', COUNT(*) FROM t_interface WHERE customer_id = 1
UNION ALL
SELECT 't_health_data_config', COUNT(*) FROM t_health_data_config WHERE customer_id = 1;
EOF
# 执行SQL脚本
log "执行数据库初始化SQL..."
mysql -h"$MYSQL_HOST" -P"$MYSQL_PORT" -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE" < temp_init.sql
if [ $? -eq 0 ]; then
log "✅ 数据库初始化完成"
else
error "❌ 数据库初始化失败"
warn "可以使用备份文件恢复: mysql -h$MYSQL_HOST -P$MYSQL_PORT -u$MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE < $BACKUP_FILE"
exit 1
fi
# 清理临时文件
rm -f temp_init.sql
echo ""
echo "==================== 初始化完成 ===================="
log "🎉 数据库初始化成功完成!"
echo ""
echo "📊 操作总结:"
echo " - 备份文件: $BACKUP_FILE"
echo " - 清空表数量: 12个表"
echo " - 保留配置表: 3个表 (特定记录)"
echo ""
echo "📋 下一步操作:"
echo " - 重启应用服务: docker-compose restart ljwx-boot"
echo " - 验证系统功能: 访问管理后台和监控大屏"
echo " - 导入新的业务数据"
echo ""
echo "🔄 如需恢复数据:"
echo " mysql -h$MYSQL_HOST -P$MYSQL_PORT -u$MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE < $BACKUP_FILE"
@@ -1,27 +0,0 @@
Linux兼容性修复报告
==================
生成时间: 2025年 6月25日 星期三 16时15分12秒 CST
系统信息: Unknown Unknown
修复项目:
1. ✅ 文件行结束符修复 (CRLF -> LF)
2. ✅ 脚本执行权限设置
3. ✅ Shebang行标准化 (#!/bin/bash)
4. ✅ bash语法兼容性修复
5. ✅ 命令兼容性修复
6. ✅ 脚本语法验证
7. ✅ 兼容性测试脚本创建
关键文件状态:
- ✅ deploy-client.sh: 存在且可用
- ✅ wait-for-it.sh: 存在且可用
- ✅ docker-compose.yml: 存在且可用
- ✅ custom-config.env: 存在且可用
建议:
1. 在部署前运行 ./test-linux-compatibility.sh 进行兼容性测试
2. 确保目标Linux系统已安装Docker和docker-compose
3. 如遇到权限问题,请检查SELinux设置
4. 建议在CentOS 7+、Ubuntu 18.04+环境下部署
修复完成,可以进行客户部署。
-527
View File
@@ -1,527 +0,0 @@
#!/bin/bash
# LJWX-Boot SQL语法错误修复器 | 使用说明: ./ljwx-boot-sql-fix.sh
CFG="custom-config.env"; [[ -f "$CFG" ]] && source "$CFG" #加载配置
MYSQL_CONTAINER="${MYSQL_CONTAINER:-ljwx-mysql}"; MYSQL_USER="${MYSQL_USER:-root}"; MYSQL_PASSWORD="${MYSQL_PASSWORD:-123456}"; MYSQL_DATABASE="${MYSQL_DATABASE:-lj-06}" #数据库配置
BACKUP_DIR="backup/sql-fix"; LOG_FILE="logs/sql-fix-$(date +%Y%m%d-%H%M%S).log" #目录配置
# 颜色配置
G='\033[0;32m'; Y='\033[1;33m'; R='\033[0;31m'; B='\033[0;34m'; N='\033[0m' #颜色代码
log() { echo -e "${G}[$(date +'%H:%M:%S')]${N} $1" | tee -a "$LOG_FILE"; }; warn() { echo -e "${Y}[WARN]${N} $1" | tee -a "$LOG_FILE"; }; error() { echo -e "${R}[ERROR]${N} $1" | tee -a "$LOG_FILE"; } #日志函数
# 初始化
init() {
mkdir -p "$BACKUP_DIR" logs #创建目录
log "🚀 LJWX-Boot SQL语法错误修复器启动"
log "问题: serial_number IN () 空参数列表导致SQL语法错误"
}
# 检查容器状态
check_container() {
docker ps --format "{{.Names}}" | grep -q "^ljwx-boot$" || { error "ljwx-boot容器未运行"; return 1; }
docker ps --format "{{.Names}}" | grep -q "^ljwx-mysql$" || { error "ljwx-mysql容器未运行"; return 1; }
log "✅ 容器状态检查通过"
return 0
}
# 备份数据库
backup_database() {
log "📦 创建数据库备份..."
local backup_file="$BACKUP_DIR/pre_sql_fix_$(date +%Y%m%d-%H%M%S).sql.gz"
if docker exec "$MYSQL_CONTAINER" mysqldump -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" \
--single-transaction --routines --triggers "$MYSQL_DATABASE" | gzip > "$backup_file"; then
local size=$(du -h "$backup_file" | cut -f1)
log "✅ 备份完成: $(basename "$backup_file") ($size)"
echo "$backup_file" > temp/backup_path
return 0
else
error "备份失败"
return 1
fi
}
# 创建SQL修复脚本
create_fix_sql() {
log "📝 创建SQL修复脚本..."
cat > "$BACKUP_DIR/device_query_fix.sql" << 'EOF'
-- LJWX-Boot SQL语法错误修复脚本
-- 修复 serial_number IN () 空参数列表问题
-- 1. 检查现有数据
SELECT 'Current device count:' as info, COUNT(*) as count FROM t_device_info WHERE is_deleted = 0;
SELECT 'User device mapping count:' as info, COUNT(*) as count FROM sys_user WHERE device_sn IS NOT NULL AND device_sn != '' AND is_deleted = 0;
-- 2. 创建设备用户映射视图(如果不存在)
CREATE OR REPLACE VIEW v_device_user_mapping AS
SELECT
d.id as device_id,
d.serial_number,
d.device_name,
u.id as user_id,
u.user_name,
u.phone,
o.id as org_id,
o.name as org_name
FROM t_device_info d
LEFT JOIN sys_user u ON d.serial_number = u.device_sn AND u.is_deleted = 0
LEFT JOIN sys_user_org uo ON u.id = uo.user_id AND uo.is_deleted = 0
LEFT JOIN sys_org_units o ON uo.org_id = o.id AND o.is_deleted = 0
WHERE d.is_deleted = 0;
-- 3. 创建安全的设备查询函数
DELIMITER $$
CREATE OR REPLACE FUNCTION get_device_list_safe(device_sns TEXT)
RETURNS TEXT
READS SQL DATA
DETERMINISTIC
BEGIN
DECLARE result_json TEXT DEFAULT '[]';
DECLARE device_count INT DEFAULT 0;
-- 检查输入参数是否为空或NULL
IF device_sns IS NULL OR TRIM(device_sns) = '' OR device_sns = '()' THEN
RETURN '[]';
END IF;
-- 清理输入参数,移除括号和引号
SET device_sns = REPLACE(REPLACE(REPLACE(device_sns, '(', ''), ')', ''), '''', '');
-- 如果清理后仍为空,返回空数组
IF TRIM(device_sns) = '' THEN
RETURN '[]';
END IF;
-- 计算设备数量
SET @sql = CONCAT('SELECT COUNT(*) INTO @device_count FROM t_device_info WHERE is_deleted = 0 AND serial_number IN (', device_sns, ')');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET device_count = @device_count;
-- 如果有设备,构建JSON结果
IF device_count > 0 THEN
SET @sql = CONCAT('SELECT JSON_ARRAYAGG(JSON_OBJECT(
"id", id,
"serial_number", serial_number,
"device_name", device_name,
"status", status,
"battery_level", battery_level,
"charging_status", charging_status,
"wearable_status", wearable_status,
"timestamp", timestamp
)) INTO @result_json FROM t_device_info WHERE is_deleted = 0 AND serial_number IN (', device_sns, ')');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET result_json = IFNULL(@result_json, '[]');
END IF;
RETURN result_json;
END$$
DELIMITER ;
-- 4. 创建设备序列号验证函数
DELIMITER $$
CREATE OR REPLACE FUNCTION validate_device_sns(device_sns TEXT)
RETURNS BOOLEAN
READS SQL DATA
DETERMINISTIC
BEGIN
-- 如果为空或NULL,返回FALSE
IF device_sns IS NULL OR TRIM(device_sns) = '' OR device_sns = '()' THEN
RETURN FALSE;
END IF;
-- 清理输入参数
SET device_sns = REPLACE(REPLACE(REPLACE(device_sns, '(', ''), ')', ''), '''', '');
-- 再次检查
IF TRIM(device_sns) = '' THEN
RETURN FALSE;
END IF;
RETURN TRUE;
END$$
DELIMITER ;
-- 5. 创建组织设备统计存储过程
DELIMITER $$
CREATE OR REPLACE PROCEDURE get_org_device_statistics(IN org_id_param VARCHAR(255))
BEGIN
DECLARE device_count INT DEFAULT 0;
DECLARE user_count INT DEFAULT 0;
DECLARE active_count INT DEFAULT 0;
DECLARE charging_count INT DEFAULT 0;
-- 统计组织下的设备数量
SELECT COUNT(DISTINCT d.id) INTO device_count
FROM t_device_info d
INNER JOIN sys_user u ON d.serial_number = u.device_sn
INNER JOIN sys_user_org uo ON u.id = uo.user_id
WHERE uo.org_id = org_id_param
AND d.is_deleted = 0
AND u.is_deleted = 0
AND uo.is_deleted = 0;
-- 统计用户数量
SELECT COUNT(DISTINCT u.id) INTO user_count
FROM sys_user u
INNER JOIN sys_user_org uo ON u.id = uo.user_id
WHERE uo.org_id = org_id_param
AND u.is_deleted = 0
AND uo.is_deleted = 0;
-- 统计活跃设备数量
SELECT COUNT(DISTINCT d.id) INTO active_count
FROM t_device_info d
INNER JOIN sys_user u ON d.serial_number = u.device_sn
INNER JOIN sys_user_org uo ON u.id = uo.user_id
WHERE uo.org_id = org_id_param
AND d.status = 'ACTIVE'
AND d.is_deleted = 0
AND u.is_deleted = 0
AND uo.is_deleted = 0;
-- 统计充电中设备数量
SELECT COUNT(DISTINCT d.id) INTO charging_count
FROM t_device_info d
INNER JOIN sys_user u ON d.serial_number = u.device_sn
INNER JOIN sys_user_org uo ON u.id = uo.user_id
WHERE uo.org_id = org_id_param
AND d.charging_status = 'CHARGING'
AND d.is_deleted = 0
AND u.is_deleted = 0
AND uo.is_deleted = 0;
-- 返回统计结果
SELECT
org_id_param as org_id,
device_count,
user_count,
active_count,
charging_count,
CASE WHEN device_count > 0 THEN ROUND(active_count / device_count * 100, 2) ELSE 0 END as active_rate,
CASE WHEN device_count > 0 THEN ROUND(charging_count / device_count * 100, 2) ELSE 0 END as charging_rate,
NOW() as query_time;
END$$
DELIMITER ;
-- 6. 测试修复效果
SELECT 'Testing device query with empty list:' as test;
SELECT get_device_list_safe('') as empty_test;
SELECT get_device_list_safe('()') as bracket_test;
SELECT validate_device_sns('') as empty_validation;
SELECT validate_device_sns('device001,device002') as valid_validation;
-- 7. 显示修复完成信息
SELECT 'SQL修复完成' as status, NOW() as completion_time;
EOF
log "✅ SQL修复脚本创建完成"
}
# 执行SQL修复
execute_fix() {
log "🔧 执行SQL修复..."
if docker exec -i "$MYSQL_CONTAINER" mysql -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE" < "$BACKUP_DIR/device_query_fix.sql"; then
log "✅ SQL修复执行成功"
return 0
else
error "SQL修复执行失败"
return 1
fi
}
# 创建Java代码修复补丁
create_java_patch() {
log "📄 创建Java代码修复建议..."
cat > "$BACKUP_DIR/java_fix_suggestions.md" << 'EOF'
# Java代码修复建议
## 问题描述
`OrgStatisticsServiceImpl.getDeviceInfo()` 方法中,当 `deviceSnList` 为空时,MyBatis Plus生成 `serial_number IN ()` 导致SQL语法错误。
## 修复方案
### 1. 修改 OrgStatisticsServiceImpl.java (推荐)
```java
private OrgStatisticsVO.DeviceInfoVO getDeviceInfo(List<String> deviceSnList) {
OrgStatisticsVO.DeviceInfoVO deviceInfo = new OrgStatisticsVO.DeviceInfoVO();
// 🔧 修复: 检查设备列表是否为空
if (deviceSnList == null || deviceSnList.isEmpty()) {
log.warn("设备序列号列表为空,返回默认设备信息");
return createEmptyDeviceInfo();
}
// 🔧 修复: 过滤无效的设备序列号
List<String> validDeviceSnList = deviceSnList.stream()
.filter(Objects::nonNull)
.filter(sn -> !sn.trim().isEmpty())
.distinct()
.collect(Collectors.toList());
if (validDeviceSnList.isEmpty()) {
log.warn("过滤后设备序列号列表为空,返回默认设备信息");
return createEmptyDeviceInfo();
}
// 获取设备列表 - 现在安全了
List<TDeviceInfo> devices = deviceInfoService.list(new LambdaQueryWrapper<TDeviceInfo>()
.in(TDeviceInfo::getSerialNumber, validDeviceSnList));
// 其余代码保持不变...
}
```
### 2. 修改 TDeviceInfoServiceImpl.java (推荐)
```java
@Override
public IPage<TDeviceInfo> listTDeviceInfoPage(PageQuery pageQuery, TDeviceInfoBO tDeviceInfoBO) {
// 构建基本查询条件
LambdaQueryWrapper<TDeviceInfo> queryWrapper = new LambdaQueryWrapper<TDeviceInfo>()
.eq(ObjectUtils.isNotEmpty(tDeviceInfoBO.getChargingStatus()), TDeviceInfo::getChargingStatus, tDeviceInfoBO.getChargingStatus())
.eq(ObjectUtils.isNotEmpty(tDeviceInfoBO.getWearableStatus()), TDeviceInfo::getWearableStatus, tDeviceInfoBO.getWearableStatus())
.eq(ObjectUtils.isNotEmpty(tDeviceInfoBO.getModel()), TDeviceInfo::getModel, tDeviceInfoBO.getModel())
.eq(ObjectUtils.isNotEmpty(tDeviceInfoBO.getStatus()), TDeviceInfo::getStatus, tDeviceInfoBO.getStatus())
.inSql(TDeviceInfo::getId, "SELECT id FROM (SELECT id, ROW_NUMBER() OVER (PARTITION BY serial_number ORDER BY timestamp DESC) as rn FROM t_device_info) t WHERE rn = 1")
.orderByDesc(TDeviceInfo::getTimestamp);
if (ObjectUtils.isNotEmpty(tDeviceInfoBO.getUserIdStr()) || ObjectUtils.isNotEmpty(tDeviceInfoBO.getorg_id())) {
// 获取设备序列号列表
List<String> deviceSnList = deviceUserMappingService.getDeviceSnList(
tDeviceInfoBO.getUserIdStr(),
tDeviceInfoBO.getorg_id()
);
log.info("获取到设备序列号列表: {}", deviceSnList);
// 🔧 修复: 检查设备列表是否为空
if (deviceSnList == null || deviceSnList.isEmpty()) {
log.warn("设备序列号列表为空,返回空结果页面");
return pageQuery.buildPage();
}
// 🔧 修复: 过滤无效的设备序列号
List<String> validDeviceSnList = deviceSnList.stream()
.filter(Objects::nonNull)
.filter(sn -> !sn.trim().isEmpty())
.distinct()
.collect(Collectors.toList());
if (validDeviceSnList.isEmpty()) {
log.warn("过滤后设备序列号列表为空,返回空结果页面");
return pageQuery.buildPage();
}
// 添加设备序列号条件 - 现在安全了
queryWrapper.in(TDeviceInfo::getSerialNumber, validDeviceSnList);
}
return pageQuery.buildPage(queryWrapper);
}
```
### 3. 创建通用工具类 (推荐)
```java
@Component
@Slf4j
public class DeviceQueryUtils {
/**
* 验证设备序列号列表是否有效
*/
public static boolean isValidDeviceSnList(List<String> deviceSnList) {
if (deviceSnList == null || deviceSnList.isEmpty()) {
return false;
}
return deviceSnList.stream()
.anyMatch(sn -> sn != null && !sn.trim().isEmpty());
}
/**
* 清理和验证设备序列号列表
*/
public static List<String> cleanDeviceSnList(List<String> deviceSnList) {
if (deviceSnList == null) {
return Collections.emptyList();
}
return deviceSnList.stream()
.filter(Objects::nonNull)
.map(String::trim)
.filter(sn -> !sn.isEmpty())
.distinct()
.collect(Collectors.toList());
}
/**
* 安全的MyBatis Plus IN查询
*/
public static <T> LambdaQueryWrapper<T> safeInQuery(
LambdaQueryWrapper<T> wrapper,
SFunction<T, ?> column,
List<String> values) {
List<String> cleanValues = cleanDeviceSnList(values);
if (!cleanValues.isEmpty()) {
wrapper.in(column, cleanValues);
} else {
// 添加一个永远为false的条件,返回空结果
wrapper.eq(column, "__IMPOSSIBLE_VALUE__");
}
return wrapper;
}
}
```
## 实施步骤
1. **立即修复**: 在所有使用 `.in()` 查询的地方添加空值检查
2. **代码重构**: 使用 `DeviceQueryUtils` 工具类统一处理
3. **测试验证**: 确保修复后不影响正常业务流程
4. **日志监控**: 添加日志记录空值情况,便于后续分析
## 注意事项
- 修复后需要重新构建和部署 ljwx-boot 服务
- 建议在测试环境先验证修复效果
- 可以通过数据库函数作为临时缓解方案
EOF
log "✅ Java修复建议文档创建完成"
}
# 重启应用服务
restart_boot_service() {
log "🔄 重启ljwx-boot服务..."
if docker-compose restart ljwx-boot; then
log "✅ 服务重启成功"
# 等待服务启动
log "⏳ 等待服务启动完成..."
local timeout=60; local count=0
while [[ $count -lt $timeout ]]; do
if curl -sf "http://localhost:9998/actuator/health" &>/dev/null; then
log "✅ ljwx-boot服务健康检查通过"
return 0
fi
((count++)); sleep 1
done
warn "服务启动超时,请手动检查"
return 1
else
error "服务重启失败"
return 1
fi
}
# 验证修复效果
verify_fix() {
log "🔍 验证修复效果..."
# 测试数据库函数
log "测试数据库修复函数..."
docker exec "$MYSQL_CONTAINER" mysql -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" -e "
SELECT 'Empty list test:' as test, validate_device_sns('') as result;
SELECT 'Valid list test:' as test, validate_device_sns('device001,device002') as result;
SELECT 'Function test:' as test, LENGTH(get_device_list_safe('')) as empty_length;
" "$MYSQL_DATABASE" || warn "数据库函数测试失败"
# 测试应用端点
log "测试应用端点..."
if curl -sf "http://localhost:9998/actuator/health" &>/dev/null; then
log "✅ 应用健康检查通过"
else
warn "应用健康检查失败"
fi
log "✅ 修复验证完成"
}
# 生成修复报告
generate_report() {
log "📋 生成修复报告..."
local report_file="logs/sql_fix_report_$(date +%Y%m%d-%H%M%S).md"
cat > "$report_file" << EOF
# LJWX-Boot SQL语法错误修复报告
## 修复时间
$(date)
## 问题描述
- **错误类型**: SQL语法错误
- **错误原因**: \`serial_number IN ()\` 空参数列表
- **影响模块**: OrgStatisticsServiceImpl, TDeviceInfoServiceImpl
- **错误日志**: \`You have an error in your SQL syntax near '))'
## 修复措施
1. ✅ 创建数据库备份
2. ✅ 部署SQL修复脚本
3. ✅ 创建设备查询安全函数
4. ✅ 创建组织统计存储过程
5. ✅ 重启应用服务
6. ✅ 验证修复效果
## 技术方案
- **数据库层**: 创建安全查询函数和存储过程
- **应用层**: 建议添加空值检查和过滤
- **工具类**: 提供通用的安全查询方法
## 备份信息
- **备份位置**: $BACKUP_DIR
- **Java修复建议**: $BACKUP_DIR/java_fix_suggestions.md
## 后续建议
1. 按照Java修复建议更新应用代码
2. 在测试环境验证完整修复效果
3. 添加监控和日志记录
4. 定期检查类似的SQL安全问题
## 修复状态
- **数据库修复**: ✅ 完成
- **应用重启**: ✅ 完成
- **功能验证**: ✅ 完成
- **代码更新**: ⏳ 待实施
---
**注意**: 此修复为临时缓解方案,建议尽快实施Java代码修复
EOF
log "✅ 修复报告已生成: $report_file"
}
# 主流程
main() {
init
if ! check_container; then
error "容器检查失败,请确保服务正常运行"
exit 1
fi
mkdir -p temp
backup_database || exit 1
create_fix_sql
execute_fix || exit 1
create_java_patch
restart_boot_service
verify_fix
generate_report
# 清理临时文件
rm -rf temp
log "🎉 SQL语法错误修复完成!"
log "📄 查看Java修复建议: $BACKUP_DIR/java_fix_suggestions.md"
log "📊 查看完整报告: logs/sql_fix_report_*.md"
}
# 脚本入口
main "$@"
@@ -0,0 +1,811 @@
# 用户绑定设备整体解决方案
## 1. 方案概述
本方案实现手表通过蓝牙连接手机时的设备绑定流程,包含设备信息验证、绑定申请、管理员审批等完整流程。
### 核心流程
1. **手表蓝牙连接** → 发送设备信息(设备序列号+手机号码)
2. **手机端接收** → 查询绑定关系
3. **绑定判断** → 存在绑定/申请审批/建立连接
4. **管理员审批** → 批量处理绑定申请
## 2. 系统架构
### 2.1 组件关系图
```
┌─────────────┐ 蓝牙连接 ┌─────────────┐ HTTP请求 ┌─────────────┐
│ ljwx-watch│ ────────────► │ ljwx-phone │ ────────────► │ljwx-bigscreen│
│ (手表端) │ │ (手机端) │ │ (后台服务) │
└─────────────┘ └─────────────┘ └─────────────┘
│ │
│ HTTP请求 │
└─────────────────────────────┤
│
┌─────▼─────┐
│ ljwx-admin │
│ (管理后台) │
└───────────┘
```
### 2.2 数据流向
```
手表设备信息 → 手机蓝牙服务 → 后台API → 数据库查询 → 响应处理 → 用户界面
```
## 3. 详细实现方案
### 3.1 手表端(ljwx-watch)实现
#### 关键修改点
- **BluetoothService.java** - 蓝牙服务管理
- **HealthDataService.java** - 数据上传服务
- **HttpService.java** - HTTP请求服务
#### 实现逻辑
```java
// 蓝牙连接成功后立即发送设备信息
public void onBluetoothConnected() {
// 构建设备信息包
DeviceInfo deviceInfo = new DeviceInfo();
deviceInfo.setSerialNumber(getDeviceSerialNumber());
deviceInfo.setPhoneNumber(getConnectedPhoneNumber());
deviceInfo.setTimestamp(System.currentTimeMillis());
// 通过蓝牙发送到手机
sendDeviceInfoToPhone(deviceInfo);
}
```
### 3.2 手机端(ljwx-phone)实现
#### 核心文件修改
- **lib/services/bluetooth_service.dart** - 蓝牙服务
- **lib/services/api_service.dart** - API接口服务
- **lib/screens/device_details_screen.dart** - 设备详情界面
#### 蓝牙数据接收处理
```dart
class BluetoothService {
// 接收手表设备信息
void _handleDeviceInfo(Map<String, dynamic> deviceInfo) async {
String serialNumber = deviceInfo['serial_number'];
String phoneNumber = deviceInfo['phone_number'];
// 查询绑定关系
var bindingStatus = await ApiService().checkDeviceBinding(
serialNumber: serialNumber,
phoneNumber: phoneNumber
);
if (bindingStatus['exists']) {
// 绑定关系存在 - 直接建立连接
_establishConnection();
_showBindingSuccess();
} else {
// 绑定关系不存在 - 提交申请
await _submitBindingApplication(serialNumber, phoneNumber);
_showBindingPending();
}
}
// 提交绑定申请
Future<void> _submitBindingApplication(String sn, String phone) async {
try {
var result = await ApiService().submitDeviceBindingApplication({
'device_sn': sn,
'phone_number': phone,
'user_id': Global.currentUserId,
'timestamp': DateTime.now().toIso8601String()
});
if (result['success']) {
log('绑定申请提交成功');
}
} catch (e) {
log('提交绑定申请失败: $e');
}
}
}
```
#### 用户界面提示
```dart
class DeviceDetailsScreen extends StatefulWidget {
void _showBindingStatus(String status) {
switch (status) {
case 'connected':
_showSuccessDialog('设备已成功绑定并连接');
break;
case 'pending':
_showInfoDialog('绑定申请已提交,等待管理员审批');
break;
case 'rejected':
_showErrorDialog('绑定申请被拒绝,请联系管理员');
break;
}
}
}
```
### 3.3 后台服务(ljwx-bigscreen)实现
#### 新增API接口
**文件:bigscreen/bigScreen/device_bind.py**
```python
from flask import Blueprint, request, jsonify
from .models import db, DeviceInfo, DeviceUser, DeviceBindRequest
device_bind_bp = Blueprint('device_bind', __name__)
@device_bind_bp.route('/api/device/check_binding', methods=['POST'])
def check_device_binding():
"""检查设备绑定关系"""
data = request.get_json()
serial_number = data.get('serial_number')
phone_number = data.get('phone_number')
try:
# 查询设备信息
device = DeviceInfo.query.filter_by(
serial_number=serial_number,
is_deleted=False
).first()
if not device:
return jsonify({
'success': False,
'error': '设备不存在',
'exists': False
})
# 检查是否已绑定
if device.user_id:
return jsonify({
'success': True,
'exists': True,
'bound': True,
'user_id': device.user_id
})
# 检查是否有待审批申请
pending_request = DeviceBindRequest.query.filter_by(
device_sn=serial_number,
status='PENDING',
is_deleted=False
).first()
return jsonify({
'success': True,
'exists': bool(pending_request),
'bound': False,
'pending': bool(pending_request)
})
except Exception as e:
return jsonify({
'success': False,
'error': f'查询失败: {str(e)}'
})
@device_bind_bp.route('/api/device/binding_application', methods=['POST'])
def submit_binding_application():
"""提交绑定申请"""
data = request.get_json()
device_sn = data.get('device_sn')
phone_number = data.get('phone_number')
user_id = data.get('user_id')
try:
# 检查是否已有待审批申请
existing = DeviceBindRequest.query.filter_by(
device_sn=device_sn,
status='PENDING',
is_deleted=False
).first()
if existing:
return jsonify({
'success': False,
'error': '已有待审批申请'
})
# 创建新申请
request_obj = DeviceBindRequest(
device_sn=device_sn,
user_id=user_id,
phone_number=phone_number,
apply_time=datetime.utcnow(),
status='PENDING'
)
db.session.add(request_obj)
db.session.commit()
return jsonify({
'success': True,
'message': '申请提交成功',
'request_id': request_obj.id
})
except Exception as e:
db.session.rollback()
return jsonify({
'success': False,
'error': f'提交失败: {str(e)}'
})
```
#### 数据模型扩展
**文件:bigscreen/bigScreen/models.py**
```python
class DeviceBindRequest(db.Model):
"""设备绑定申请表"""
__tablename__ = 't_device_bind_request'
id = db.Column(db.Integer, primary_key=True)
device_sn = db.Column(db.String(100), nullable=False, comment='设备序列号')
user_id = db.Column(db.Integer, nullable=False, comment='申请用户ID')
phone_number = db.Column(db.String(20), nullable=False, comment='手机号码')
apply_time = db.Column(db.DateTime, default=datetime.utcnow, comment='申请时间')
approve_time = db.Column(db.DateTime, comment='审批时间')
approver_id = db.Column(db.Integer, comment='审批人ID')
status = db.Column(db.String(20), default='PENDING', comment='状态:PENDING/APPROVED/REJECTED')
comment = db.Column(db.String(500), comment='审批备注')
is_deleted = db.Column(db.Boolean, default=False, comment='是否删除')
```
### 3.4 管理后台(ljwx-admin)实现
#### 绑定申请管理界面
**文件:src/views/device/bind-management.vue**
```vue
<template>
<div class="device-bind-management">
<n-card title="设备绑定申请管理">
<!-- 筛选条件 -->
<n-space class="search-form">
<n-select v-model:value="filters.status" placeholder="申请状态">
<n-option value="PENDING" label="待审批" />
<n-option value="APPROVED" label="已通过" />
<n-option value="REJECTED" label="已拒绝" />
</n-select>
<n-button type="primary" @click="loadApplications">查询</n-button>
</n-space>
<!-- 数据表格 -->
<n-data-table
:columns="columns"
:data="applications"
:loading="loading"
:pagination="pagination"
/>
<!-- 批量操作 -->
<n-space class="batch-actions">
<n-button
type="primary"
@click="batchApprove"
:disabled="!selectedRows.length"
>
批量通过
</n-button>
<n-button
type="error"
@click="batchReject"
:disabled="!selectedRows.length"
>
批量拒绝
</n-button>
</n-space>
</n-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { deviceBindAPI } from '@/service/api/device-bind'
const applications = ref([])
const loading = ref(false)
const selectedRows = ref([])
const filters = reactive({
status: 'PENDING'
})
const columns = [
{
type: 'selection',
fixed: 'left'
},
{
title: '设备序列号',
key: 'device_sn'
},
{
title: '用户手机号',
key: 'phone_number'
},
{
title: '申请时间',
key: 'apply_time'
},
{
title: '状态',
key: 'status',
render(row) {
const statusMap = {
'PENDING': { type: 'warning', text: '待审批' },
'APPROVED': { type: 'success', text: '已通过' },
'REJECTED': { type: 'error', text: '已拒绝' }
}
const status = statusMap[row.status]
return h(NTag, { type: status.type }, () => status.text)
}
},
{
title: '操作',
key: 'actions',
render(row) {
return h(NSpace, () => [
h(NButton,
{
size: 'small',
type: 'primary',
onClick: () => approveApplication(row.id)
},
() => '通过'
),
h(NButton,
{
size: 'small',
type: 'error',
onClick: () => rejectApplication(row.id)
},
() => '拒绝'
)
])
}
}
]
// 加载申请列表
async function loadApplications() {
loading.value = true
try {
const response = await deviceBindAPI.getApplications(filters)
applications.value = response.data.items
} finally {
loading.value = false
}
}
// 批量审批
async function batchApprove() {
const ids = selectedRows.value.map(row => row.id)
await deviceBindAPI.batchApprove({
ids,
action: 'APPROVED',
approver_id: currentUser.value.id
})
await loadApplications()
selectedRows.value = []
}
onMounted(() => {
loadApplications()
})
</script>
```
#### API服务
**文件:src/service/api/device-bind.ts**
```typescript
import { request } from '../request'
export const deviceBindAPI = {
// 获取绑定申请列表
getApplications: (params: any) =>
request.get('/device_bind/applications', { params }),
// 批量审批
batchApprove: (data: any) =>
request.post('/device_bind/batch_approve', data),
// 获取绑定日志
getBindingLogs: (params: any) =>
request.get('/device_bind/logs', { params })
}
```
## 4. 数据库设计
### 4.1 设备绑定申请表
```sql
CREATE TABLE `t_device_bind_request` (
`id` int NOT NULL AUTO_INCREMENT COMMENT 'ID',
`device_sn` varchar(100) NOT NULL COMMENT '设备序列号',
`user_id` int NOT NULL COMMENT '申请用户ID',
`phone_number` varchar(20) NOT NULL COMMENT '手机号码',
`org_id` int DEFAULT NULL COMMENT '组织ID',
`apply_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '申请时间',
`approve_time` datetime DEFAULT NULL COMMENT '审批时间',
`approver_id` int DEFAULT NULL COMMENT '审批人ID',
`status` varchar(20) DEFAULT 'PENDING' COMMENT '状态:PENDING待审批/APPROVED已通过/REJECTED已拒绝',
`comment` varchar(500) DEFAULT NULL COMMENT '审批备注',
`is_deleted` tinyint(1) DEFAULT '0' COMMENT '是否删除',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_device_sn` (`device_sn`),
KEY `idx_user_id` (`user_id`),
KEY `idx_status` (`status`),
KEY `idx_apply_time` (`apply_time`)
) ENGINE=InnoDB COMMENT='设备绑定申请表';
```
### 4.2 设备用户关联表(扩展)
```sql
ALTER TABLE `t_device_user` ADD COLUMN `bind_type` varchar(20) DEFAULT 'MANUAL' COMMENT '绑定方式:MANUAL手动/AUTO自动';
ALTER TABLE `t_device_user` ADD COLUMN `bind_source` varchar(50) DEFAULT NULL COMMENT '绑定来源';
```
## 5. 业务流程图
### 5.1 完整绑定流程
```mermaid
sequenceDiagram
participant W as 手表(ljwx-watch)
participant P as 手机(ljwx-phone)
participant B as 后台(ljwx-bigscreen)
participant A as 管理后台(ljwx-admin)
W->>P: 蓝牙连接建立
W->>P: 发送设备信息(SN+手机号)
P->>B: 查询绑定关系
B->>B: 检查device_info和device_bind_request表
alt 绑定关系存在
B->>P: 返回绑定状态(已绑定)
P->>W: 建立数据连接
P->>P: 显示"设备已连接"
else 绑定关系不存在
P->>B: 提交绑定申请
B->>B: 插入device_bind_request记录
B->>P: 返回申请成功
P->>P: 显示"绑定申请已提交,等待审批"
A->>B: 管理员查看申请列表
A->>B: 批量审批申请
B->>B: 更新申请状态并创建绑定关系
Note over P: 定期检查绑定状态
P->>B: 轮询绑定状态
B->>P: 返回已审批状态
P->>W: 建立数据连接
end
```
### 5.2 状态转换图
```
[初始连接] → [查询绑定] → [绑定存在] → [建立连接]
↓
[绑定不存在] → [提交申请] → [等待审批] → [审批通过] → [建立连接]
↓
[审批拒绝] → [显示拒绝原因]
```
## 6. 错误处理和容错机制
### 6.1 网络异常处理
```dart
class ApiService {
Future<Map<String, dynamic>> checkDeviceBinding({
required String serialNumber,
required String phoneNumber,
int retryCount = 3
}) async {
for (int i = 0; i < retryCount; i++) {
try {
final response = await http.post(
Uri.parse('${config.apiBaseUrl}/device/check_binding'),
headers: headers,
body: jsonEncode({
'serial_number': serialNumber,
'phone_number': phoneNumber
})
).timeout(Duration(seconds: 10));
if (response.statusCode == 200) {
return jsonDecode(response.body);
}
} catch (e) {
log('绑定状态查询失败 (第${i+1}次): $e');
if (i == retryCount - 1) rethrow;
await Future.delayed(Duration(seconds: 2 * (i + 1)));
}
}
throw Exception('网络请求失败');
}
}
```
### 6.2 数据一致性保证
```python
@device_bind_bp.route('/api/device/approve_binding', methods=['POST'])
def approve_binding():
"""审批绑定申请"""
try:
with db.session.begin(): # 使用事务
request_obj = DeviceBindRequest.query.filter_by(
id=request_id,
status='PENDING'
).with_for_update().first() # 行锁
if not request_obj:
raise Exception('申请不存在或已处理')
# 更新申请状态
request_obj.status = 'APPROVED'
request_obj.approve_time = datetime.utcnow()
# 更新设备绑定
device = DeviceInfo.query.filter_by(
serial_number=request_obj.device_sn
).first()
device.user_id = request_obj.user_id
# 记录绑定日志
bind_log = DeviceUser(
device_sn=request_obj.device_sn,
user_id=request_obj.user_id,
status='BIND',
bind_type='AUTO'
)
db.session.add(bind_log)
return jsonify({'success': True, 'message': '审批成功'})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': str(e)})
```
## 7. 性能优化建议
### 7.1 缓存策略
```python
import redis
from functools import wraps
redis_client = redis.Redis(host='localhost', port=6379, db=0)
def cache_binding_status(expire_time=300):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
# 生成缓存键
cache_key = f"binding_status:{kwargs.get('serial_number')}"
# 尝试从缓存获取
cached_result = redis_client.get(cache_key)
if cached_result:
return json.loads(cached_result)
# 执行原函数
result = func(*args, **kwargs)
# 缓存结果
redis_client.setex(cache_key, expire_time, json.dumps(result))
return result
return wrapper
return decorator
@cache_binding_status()
def check_device_binding(serial_number, phone_number):
# 原查询逻辑
pass
```
### 7.2 数据库索引优化
```sql
-- 绑定申请查询优化
CREATE INDEX idx_device_bind_request_query ON t_device_bind_request
(status, apply_time DESC, device_sn);
-- 设备信息查询优化
CREATE INDEX idx_device_info_sn_user ON t_device_info
(serial_number, user_id, is_deleted);
-- 复合查询优化
CREATE INDEX idx_device_user_composite ON t_device_user
(device_sn, user_id, status, operate_time DESC);
```
## 8. 安全考虑
### 8.1 数据传输安全
- 蓝牙数据传输使用AES加密
- HTTP请求使用HTTPS协议
- 敏感信息不明文存储
### 8.2 权限控制
```python
from functools import wraps
from flask import session
def require_admin_permission(func):
@wraps(func)
def decorated_function(*args, **kwargs):
if not session.get('is_admin'):
return jsonify({'error': '权限不足'}), 403
return func(*args, **kwargs)
return decorated_function
@device_bind_bp.route('/api/device/batch_approve', methods=['POST'])
@require_admin_permission
def batch_approve_binding():
# 批量审批逻辑
pass
```
## 9. 监控和日志
### 9.1 关键指标监控
- 绑定申请成功率
- 审批处理时长
- 网络请求响应时间
- 蓝牙连接成功率
### 9.2 日志记录规范
```python
import logging
# 配置日志格式
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(name)s: %(message)s',
handlers=[
logging.FileHandler('device_binding.log'),
logging.StreamHandler()
]
)
logger = logging.getLogger('device_binding')
def log_binding_operation(operation, device_sn, user_id, result):
logger.info(f"绑定操作 - 操作:{operation} 设备:{device_sn} 用户:{user_id} 结果:{result}")
```
## 10. 部署和配置
### 10.1 配置文件示例
```yaml
# config/device_binding.yml
device_binding:
# 审批设置
auto_approve: false # 是否自动审批
approval_timeout: 7 # 申请超时天数
# 通知设置
enable_notification: true
notification_channels: ['wechat', 'email']
# 安全设置
max_binding_per_user: 5 # 每用户最大绑定设备数
require_phone_verification: true
# 性能设置
cache_expire_time: 300 # 缓存过期时间(秒)
batch_size: 100 # 批量处理大小
```
### 10.2 Docker部署配置
```dockerfile
# 添加设备绑定模块
COPY device_bind.py /app/bigScreen/
COPY templates/device_bind_*.html /app/templates/
# 安装依赖
RUN pip install redis pycryptodome
# 暴露端口
EXPOSE 5001
```
## 11. 测试方案
### 11.1 单元测试
```python
import unittest
from unittest.mock import patch, MagicMock
class TestDeviceBinding(unittest.TestCase):
def setUp(self):
self.app = create_app('testing')
self.client = self.app.test_client()
def test_check_binding_exists(self):
"""测试绑定关系存在的情况"""
response = self.client.post('/api/device/check_binding',
json={
'serial_number': 'TEST_SN_001',
'phone_number': '13800138000'
})
self.assertEqual(response.status_code, 200)
data = response.get_json()
self.assertTrue(data['success'])
def test_submit_binding_application(self):
"""测试提交绑定申请"""
response = self.client.post('/api/device/binding_application',
json={
'device_sn': 'TEST_SN_002',
'phone_number': '13800138001',
'user_id': 1
})
self.assertEqual(response.status_code, 200)
data = response.get_json()
self.assertTrue(data['success'])
```
### 11.2 集成测试
```python
class TestDeviceBindingIntegration(unittest.TestCase):
def test_complete_binding_flow(self):
"""测试完整绑定流程"""
# 1. 检查绑定(不存在)
# 2. 提交申请
# 3. 管理员审批
# 4. 再次检查绑定(存在)
pass
```
## 12. 运维指南
### 12.1 常见问题排查
1. **绑定查询失败**: 检查数据库连接和索引
2. **申请提交失败**: 检查网络连接和权限
3. **审批不生效**: 检查事务完整性和缓存更新
### 12.2 性能调优
- 定期清理过期申请记录
- 优化数据库查询索引
- 调整缓存策略和过期时间
- 监控API响应时间
### 12.3 数据备份
```bash
# 备份绑定相关表
mysqldump -u root -p ljwx_db t_device_bind_request t_device_user t_device_info > device_binding_backup.sql
```
## 13. 版本升级计划
### V1.0 基础版本
- ✅ 设备绑定关系查询
- ✅ 绑定申请提交
- ✅ 管理员审批界面
### V1.1 增强版本
- 🔄 批量审批优化
- 🔄 微信通知集成
- 🔄 审批流程自定义
### V1.2 高级版本
- 📋 设备绑定统计报表
- 📋 自动绑定规则配置
- 📋 多级审批支持
---
**文档版本**: V1.0
**最后更新**: 2025-08-23
**维护人员**: Claude Code
+31
View File
@@ -192,6 +192,37 @@ const local: App.I18n.Schema = {
function_request: 'Request',
'function_toggle-auth': 'Toggle Auth',
'function_super-page': 'Super Admin Visible',
device: 'Device Management',
device_bind: 'Device Binding Management',
device_config: 'Device Configuration',
device_user: 'Device Binding History',
device_info: 'Device List',
device_message: 'Message List',
health: 'Health Management',
health_ai: 'Health AI',
health_analysis: 'Health Analysis',
health_baseline: 'Health Baseline',
health_chart: 'Health Chart',
health_config: 'Health Configuration',
health_echarts: 'Health ECharts',
health_info: 'Health Info',
health_profile: 'Health Profile',
health_score: 'Health Score',
task: 'Task Management',
job: 'Job Guide',
bigscreen: 'Overview Dashboard',
customer: 'Customer Management',
customer_config: 'Customer Configuration',
customer_data: 'Customer Data',
customer_interface: 'Customer Interface Configuration',
alert: 'Alert Management',
alert_config: 'Alert Configuration',
alert_config_wechat: 'WeChat Alert Configuration',
alert_info: 'Alert Info',
alert_log: 'Alert Log',
alert_rules: 'Alert Rules',
geofence: 'Geofence Management',
map: 'Map View',
manage: 'System Manage',
manage_user: 'User Manage',
'manage_user-detail': 'User Detail',
+1
View File
@@ -214,6 +214,7 @@ const local: App.I18n.Schema = {
task: '任务管理',
job: '作业指引',
device: '设备管理',
device_bind: '设备绑定管理',
device_config: '设备配置',
device_user: '设备绑定历史',
device_info: '设备列表',
+1
View File
@@ -30,6 +30,7 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
customer_config: () => import("@/views/customer/config/index.vue"),
customer_data: () => import("@/views/customer/data/index.vue"),
customer_interface: () => import("@/views/customer/interface/index.vue"),
device_bind: () => import("@/views/device/bind/index.vue"),
device_config: () => import("@/views/device/config/index.vue"),
device_info: () => import("@/views/device/info/index.vue"),
device_message: () => import("@/views/device/message/index.vue"),
+12
View File
@@ -175,6 +175,18 @@ export const generatedRoutes: GeneratedRoute[] = [
i18nKey: 'route.device'
},
children: [
{
name: 'device_bind',
path: '/device/bind',
component: 'view.device_bind',
meta: {
title: 'device_bind',
i18nKey: 'route.device_bind',
roles: ['R_ADMIN'],
icon: 'ic:round-link',
order: 1
}
},
{
name: 'device_config',
path: '/device/config',
@@ -191,6 +191,7 @@ const routeMap: RouteMap = {
"customer_data": "/customer/data",
"customer_interface": "/customer/interface",
"device": "/device",
"device_bind": "/device/bind",
"device_config": "/device/config",
"device_info": "/device/info",
"device_message": "/device/message",
@@ -0,0 +1,58 @@
import { request } from '@/service/request';
// 设备绑定申请相关接口
/**
* 检查设备绑定状态
*/
export function fetchDeviceBindingStatus(params: Api.DeviceBinding.CheckBindingParams) {
return request<Api.DeviceBinding.CheckBindingResult>({
url: '/api/device/check_binding',
method: 'post',
data: params
});
}
/**
* 提交设备绑定申请
*/
export function submitDeviceBindingApplication(params: Api.DeviceBinding.SubmitApplicationParams) {
return request<Api.DeviceBinding.SubmitApplicationResult>({
url: '/api/device/binding_application',
method: 'post',
data: params
});
}
/**
* 获取设备绑定申请列表
*/
export function fetchDeviceBindingApplications(params?: Api.DeviceBinding.ApplicationSearchParams) {
return request<Api.DeviceBinding.ApplicationListResult>({
url: '/api/device/applications',
method: 'get',
params
});
}
/**
* 批量审批设备绑定申请
*/
export function batchApproveBindingApplications(params: Api.DeviceBinding.BatchApproveParams) {
return request<Api.DeviceBinding.BatchApproveResult>({
url: '/api/device/batch_approve',
method: 'post',
data: params
});
}
/**
* 获取用户设备绑定列表
*/
export function fetchUserDeviceBindings(userId: string) {
return request<Api.DeviceBinding.UserBindingListResult>({
url: '/api/device/user_bindings',
method: 'get',
params: { userId }
});
}
@@ -13,3 +13,4 @@ export * from './health';
export * from './health-baseline';
export * from './health-score';
export * from './health-feature';
export * from './device-binding';
+166
View File
@@ -0,0 +1,166 @@
declare namespace Api {
namespace DeviceBinding {
/** 设备绑定申请记录 */
interface DeviceBindRequest {
/** 申请ID */
id: string;
/** 设备序列号 */
deviceSn: string;
/** 申请用户ID */
userId: string;
/** 申请用户姓名 */
userName?: string;
/** 手机号码 */
phoneNumber: string;
/** 组织ID */
orgId?: string;
/** 申请时间 */
applyTime: string;
/** 审批时间 */
approveTime?: string;
/** 审批人ID */
approverId?: string;
/** 审批人姓名 */
approverName?: string;
/** 申请状态 */
status: 'PENDING' | 'APPROVED' | 'REJECTED';
/** 审批备注 */
comment?: string;
/** 创建时间 */
createTime?: string;
/** 更新时间 */
updateTime?: string;
}
/** 检查设备绑定状态参数 */
interface CheckBindingParams {
/** 设备序列号 */
serialNumber: string;
/** 手机号码 */
phoneNumber: string;
}
/** 检查设备绑定状态结果 */
interface CheckBindingResult {
/** 操作是否成功 */
success: boolean;
/** 绑定关系是否存在 */
exists?: boolean;
/** 是否已绑定 */
bound?: boolean;
/** 是否有待审批申请 */
pending?: boolean;
/** 绑定的用户ID */
user_id?: string;
/** 错误信息 */
error?: string;
}
/** 提交绑定申请参数 */
interface SubmitApplicationParams {
/** 设备序列号 */
device_sn: string;
/** 手机号码 */
phone_number: string;
/** 申请用户ID */
user_id: string;
/** 申请时间戳 */
timestamp?: string;
}
/** 提交绑定申请结果 */
interface SubmitApplicationResult {
/** 操作是否成功 */
success: boolean;
/** 成功消息 */
message?: string;
/** 申请ID */
request_id?: string;
/** 错误信息 */
error?: string;
}
/** 申请列表搜索参数 */
interface ApplicationSearchParams {
/** 申请状态 */
status?: 'PENDING' | 'APPROVED' | 'REJECTED';
/** 当前页码 */
current?: number;
/** 每页数量 */
size?: number;
}
/** 申请列表结果 */
interface ApplicationListResult {
/** 操作是否成功 */
success: boolean;
/** 数据 */
data?: {
/** 申请记录列表 */
items: DeviceBindRequest[];
/** 总数量 */
total: number;
/** 当前页 */
page: number;
/** 每页大小 */
size: number;
/** 总页数 */
pages: number;
};
/** 错误信息 */
error?: string;
}
/** 批量审批参数 */
interface BatchApproveParams {
/** 申请ID列表 */
ids: string[];
/** 审批操作 */
action: 'APPROVED' | 'REJECTED';
/** 审批人ID */
approverId: string;
/** 审批备注 */
comment?: string;
}
/** 批量审批结果 */
interface BatchApproveResult {
/** 操作是否成功 */
success: boolean;
/** 成功消息 */
message?: string;
/** 成功处理的ID列表 */
success_ids?: string[];
/** 失败处理的ID列表 */
failed_ids?: string[];
/** 错误信息 */
error?: string;
}
/** 用户绑定设备记录 */
interface UserDeviceBinding {
/** 绑定ID */
id: string;
/** 设备序列号 */
deviceSn: string;
/** 用户ID */
userId: string;
/** 用户姓名 */
userName?: string;
/** 绑定时间 */
operateTime: string;
/** 绑定状态 */
status: 'BIND' | 'UNBIND';
}
/** 用户绑定列表结果 */
interface UserBindingListResult {
/** 操作是否成功 */
success: boolean;
/** 绑定设备列表 */
data?: UserDeviceBinding[];
/** 错误信息 */
error?: string;
}
}
}
+6
View File
@@ -30,6 +30,11 @@ declare module 'vue' {
IconIcRoundRemove: typeof import('~icons/ic/round-remove')['default']
IconIcRoundSearch: typeof import('~icons/ic/round-search')['default']
IconLocalLogo: typeof import('~icons/local/logo')['default']
'IconMaterialSymbols:checkCircle': typeof import('~icons/material-symbols/check-circle')['default']
'IconMaterialSymbols:cloudUpload': typeof import('~icons/material-symbols/cloud-upload')['default']
'IconMaterialSymbols:download': typeof import('~icons/material-symbols/download')['default']
'IconMaterialSymbols:info': typeof import('~icons/material-symbols/info')['default']
'IconMaterialSymbols:upload': typeof import('~icons/material-symbols/upload')['default']
IconMdiArrowDownThin: typeof import('~icons/mdi/arrow-down-thin')['default']
IconMdiArrowUpThin: typeof import('~icons/mdi/arrow-up-thin')['default']
IconMdiDrag: typeof import('~icons/mdi/drag')['default']
@@ -86,6 +91,7 @@ declare module 'vue' {
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSelect: typeof import('naive-ui')['NSelect']
NSpace: typeof import('naive-ui')['NSpace']
NSpin: typeof import('naive-ui')['NSpin']
NStatistic: typeof import('naive-ui')['NStatistic']
NSwitch: typeof import('naive-ui')['NSwitch']
NTab: typeof import('naive-ui')['NTab']
+2
View File
@@ -45,6 +45,7 @@ declare module "@elegant-router/types" {
"customer_data": "/customer/data";
"customer_interface": "/customer/interface";
"device": "/device";
"device_bind": "/device/bind";
"device_config": "/device/config";
"device_info": "/device/info";
"device_message": "/device/message";
@@ -202,6 +203,7 @@ declare module "@elegant-router/types" {
| "customer_config"
| "customer_data"
| "customer_interface"
| "device_bind"
| "device_config"
| "device_info"
| "device_message"
+383
View File
@@ -0,0 +1,383 @@
<template>
<div class="flex-vertical-stretch gap-16px overflow-hidden <sm:overflow-auto">
<NCard title="设备绑定申请管理" :bordered="false" size="small" class="sm:flex-1-hidden card-wrapper">
<!-- 搜索区域 -->
<template #header-extra>
<div class="flex-y-center gap-12px">
<NSelect
v-model:value="searchParams.status"
:options="statusOptions"
placeholder="申请状态"
clearable
class="w-120px"
@update:value="getTableData"
/>
<NButton type="primary" ghost @click="getTableData">
<template #icon>
<icon-ic-round-refresh class="text-icon" />
</template>
刷新
</NButton>
</div>
</template>
<!-- 批量操作 -->
<div class="flex-y-center gap-12px mb-16px">
<NButton
type="primary"
:disabled="!checkedRowKeys.length"
@click="batchApprove('APPROVED')"
>
<template #icon>
<icon-ic-round-check class="text-icon" />
</template>
批量通过
</NButton>
<NButton
type="error"
:disabled="!checkedRowKeys.length"
@click="batchApprove('REJECTED')"
>
<template #icon>
<icon-ic-round-close class="text-icon" />
</template>
批量拒绝
</NButton>
<span class="text-12px text-#666">已选择 {{ checkedRowKeys.length }} 项</span>
</div>
<!-- 数据表格 -->
<NDataTable
v-model:checked-row-keys="checkedRowKeys"
:columns="columns"
:data="tableData"
size="small"
:flex-height="true"
:scroll-x="1200"
:loading="loading"
remote
:row-key="rowKey"
:pagination="mobilePagination"
class="sm:h-full"
/>
<!-- 审批备注弹窗 -->
<NModal v-model:show="showCommentModal" :mask-closable="false">
<NCard
style="width: 500px"
title="审批备注"
:bordered="false"
size="huge"
role="dialog"
aria-modal="true"
>
<NForm ref="commentFormRef" :model="commentForm" :rules="commentRules">
<NFormItem label="审批备注" path="comment">
<NInput
v-model:value="commentForm.comment"
type="textarea"
placeholder="请输入审批备注(可选)"
:rows="4"
/>
</NFormItem>
</NForm>
<template #footer>
<div class="flex-y-center justify-end gap-12px">
<NButton @click="showCommentModal = false">取消</NButton>
<NButton type="primary" @click="confirmApprove">确认</NButton>
</div>
</template>
</NCard>
</NModal>
</NCard>
</div>
</template>
<script setup lang="tsx">
import { ref, reactive, onMounted } from 'vue';
import type { Ref } from 'vue';
import { NButton, NTag, NPopconfirm, NModal, NCard, NForm, NFormItem, NInput, NSelect } from 'naive-ui';
import type { DataTableColumns, FormInst, FormRules } from 'naive-ui';
import { fetchDeviceBindingApplications, batchApproveBindingApplications } from '@/service/api';
import { useBoolean, useLoading } from '@/hooks/common';
import { useAppStore } from '@/store/modules/app';
import { useTable, useTableOperate } from '@/hooks/common/table';
import { $t } from '@/locales';
defineOptions({
name: 'DeviceBind',
meta: {
roles: ['R_ADMIN'],
icon: 'ic:round-link',
order: 1
}
});
const appStore = useAppStore();
const { loading, startLoading, endLoading } = useLoading(false);
const { bool: showCommentModal, setTrue: openCommentModal, setFalse: closeCommentModal } = useBoolean();
// 搜索参数
const searchParams = reactive({
status: ''
});
// 状态选项
const statusOptions = [
{ label: '待审批', value: 'PENDING' },
{ label: '已通过', value: 'APPROVED' },
{ label: '已拒绝', value: 'REJECTED' }
];
// 状态标签渲染
const statusTagMap: Record<string, { type: 'warning' | 'success' | 'error'; label: string }> = {
'PENDING': { type: 'warning', label: '待审批' },
'APPROVED': { type: 'success', label: '已通过' },
'REJECTED': { type: 'error', label: '已拒绝' }
};
// 表格列定义
const columns: Ref<DataTableColumns<Api.DeviceBinding.DeviceBindRequest>> = ref([
{
type: 'selection',
align: 'center',
width: 48
},
{
key: 'id',
title: '申请ID',
align: 'center',
width: 100
},
{
key: 'deviceSn',
title: '设备序列号',
align: 'center',
width: 180
},
{
key: 'userId',
title: '申请用户ID',
align: 'center',
width: 120
},
{
key: 'userName',
title: '用户姓名',
align: 'center',
width: 100
},
{
key: 'phoneNumber',
title: '手机号码',
align: 'center',
width: 140
},
{
key: 'applyTime',
title: '申请时间',
align: 'center',
width: 180,
render: row => {
return row.applyTime ? new Date(row.applyTime).toLocaleString() : '-';
}
},
{
key: 'status',
title: '申请状态',
align: 'center',
width: 100,
render: row => {
if (row.status && statusTagMap[row.status]) {
const tagInfo = statusTagMap[row.status];
return <NTag type={tagInfo.type}>{tagInfo.label}</NTag>;
}
return '-';
}
},
{
key: 'approveTime',
title: '审批时间',
align: 'center',
width: 180,
render: row => {
return row.approveTime ? new Date(row.approveTime).toLocaleString() : '-';
}
},
{
key: 'approverName',
title: '审批人',
align: 'center',
width: 100
},
{
key: 'comment',
title: '审批备注',
align: 'center',
width: 150,
ellipsis: true
},
{
key: 'operate',
title: $t('common.operate'),
align: 'center',
width: 180,
fixed: 'right',
render: row => (
<div class="flex-center gap-8px">
{row.status === 'PENDING' && (
<>
<NButton type="primary" ghost size="small" onClick={() => handleSingleApprove(row, 'APPROVED')}>
通过
</NButton>
<NPopconfirm onPositiveClick={() => handleSingleApprove(row, 'REJECTED')}>
{{
default: () => '确定拒绝此申请?',
trigger: () => (
<NButton type="error" ghost size="small">
拒绝
</NButton>
)
}}
</NPopconfirm>
</>
)}
{row.status !== 'PENDING' && (
<NButton disabled size="small">
已处理
</NButton>
)}
</div>
)
}
]);
// 表格数据和操作
const {
tableData,
naiveTableRef,
mobilePagination,
searchQuery,
resetSearchQuery
} = useTable({
apiFn: fetchDeviceBindingApplications,
showTotal: true,
apiParams: {
current: 1,
size: 10,
status: searchParams.status || undefined
}
});
const { checkedRowKeys } = useTableOperate(tableData, mobilePagination);
// 行键
function rowKey(row: Api.DeviceBinding.DeviceBindRequest) {
return row.id;
}
// 获取表格数据
async function getTableData() {
searchQuery();
}
// 审批备注表单
const commentFormRef = ref<FormInst | null>(null);
const commentForm = reactive({
comment: ''
});
const commentRules: FormRules = {
comment: [
{ max: 500, message: '备注不能超过500个字符' }
]
};
// 当前审批操作
let currentApproveAction: 'APPROVED' | 'REJECTED' = 'APPROVED';
let currentApproveIds: string[] = [];
// 单个审批
function handleSingleApprove(row: Api.DeviceBinding.DeviceBindRequest, action: 'APPROVED' | 'REJECTED') {
currentApproveAction = action;
currentApproveIds = [row.id.toString()];
if (action === 'REJECTED') {
// 拒绝时需要备注
openCommentModal();
} else {
// 通过时直接执行
performApprove();
}
}
// 批量审批
function batchApprove(action: 'APPROVED' | 'REJECTED') {
if (!checkedRowKeys.value.length) {
$message.warning('请选择要审批的申请');
return;
}
currentApproveAction = action;
currentApproveIds = checkedRowKeys.value.map(key => key.toString());
if (action === 'REJECTED') {
// 拒绝时需要备注
openCommentModal();
} else {
// 通过时直接执行
performApprove();
}
}
// 确认审批
async function confirmApprove() {
await commentFormRef.value?.validate();
performApprove();
closeCommentModal();
}
// 执行审批操作
async function performApprove() {
try {
startLoading();
const params = {
ids: currentApproveIds,
action: currentApproveAction,
approverId: appStore.userInfo.userId.toString(),
comment: commentForm.comment || undefined
};
await batchApproveBindingApplications(params);
$message.success(
currentApproveAction === 'APPROVED'
? `成功通过 ${currentApproveIds.length} 个申请`
: `成功拒绝 ${currentApproveIds.length} 个申请`
);
// 重置表单和选择
commentForm.comment = '';
checkedRowKeys.value = [];
// 刷新数据
getTableData();
} catch (error) {
console.error('审批失败:', error);
$message.error('审批操作失败,请重试');
} finally {
endLoading();
}
}
onMounted(() => {
getTableData();
});
</script>
<style scoped>
.card-wrapper {
@apply flex-1-hidden;
}
</style>
+2 -2
View File
@@ -117,7 +117,7 @@ async function fetchHealthData() {
try {
const response = await fetchUserHealthData({
customerId: searchParams.value.customerId,
departmentInfo: searchParams.value.departmentInfo,
orgId: searchParams.value.orgId,
userId: searchParams.value.userId,
startDate: searchParams.value.startDate,
endDate: searchParams.value.endDate,
@@ -222,7 +222,7 @@ async function handleInitOptions() {
// 监听部门变化,更新员工列表
watch(
() => searchParams.value.departmentInfo,
() => searchParams.value.orgId,
async newValue => {
if (newValue) {
const result = await handleBindUsersByOrgId(String(newValue));
+2 -1
View File
@@ -1300,7 +1300,8 @@ def test_wechat_alert():
def upload_common_event():
try:
data=request.json
print(f"事件接收:{data}")
print(f"📡 [upload_common_event] 接口被调用")
print(f"📡 [upload_common_event] 接收原始数据:{data}")
#提取事件类型
event_type=data.get('eventType','').split('.')[-1] #从com.tdtech.ohos.action.WEAR_STATUS_CHANGED提取WEAR_STATUS_CHANGED
@@ -520,6 +520,13 @@ def personal_cool():
#return render_template("bigscreen_new.html", deviceSn=deviceSn, userName=userName)
return render_template("bigscreen_new.html", deviceSn=deviceSn)
@app.route("/personal_modern")
def personal_modern():
"""现代化个人大屏 v2.0 - 全新设计界面"""
deviceSn = request.args.get('deviceSn')
logger.info(f"Personal Modern V2.0 - deviceSn: {deviceSn}")
return render_template("personal_modern_v2.html", deviceSn=deviceSn)
@app.route("/main")
def main_index():
customerId = request.args.get('customerId') # Get the deviceSn from query parameters
@@ -3096,6 +3103,371 @@ def phone_get_personal_info():
'user': user.to_dict() if user else None
}
})
except Exception as e:
return jsonify({
'success': False,
'error': f'获取个人信息失败: {str(e)}'
})
# 个人大屏实时数据API v1.3.5+
@app.route('/api/personal/realtime_health', methods=['GET'])
def get_personal_realtime_health():
"""获取个人实时健康数据API"""
try:
device_sn = request.args.get('deviceSn')
if not device_sn:
return jsonify({
'success': False,
'error': 'deviceSn参数是必需的'
}), 400
# 获取用户信息
from .user import get_user_id_by_deviceSn
user_id = get_user_id_by_deviceSn(device_sn)
if not user_id:
return jsonify({
'success': False,
'error': '未找到对应的用户信息'
}), 404
# 获取最新健康数据
from .user_health_data import get_latest_health_data_by_device
health_data = get_latest_health_data_by_device(device_sn)
if not health_data:
# 返回默认数据结构
return jsonify({
'success': True,
'data': {
'deviceSn': device_sn,
'heartRate': 0,
'bloodOxygen': 0,
'temperature': '0.0',
'systolic': 0,
'diastolic': 0,
'stepCount': 0,
'timestamp': datetime.now().isoformat(),
'status': 'no_data'
}
})
# 格式化返回数据
result = {
'success': True,
'data': {
'deviceSn': device_sn,
'heartRate': int(health_data.get('heartRate', 0)),
'bloodOxygen': int(health_data.get('bloodOxygen', 0)),
'temperature': str(health_data.get('temperature', '0.0')),
'systolic': int(health_data.get('pressureHigh', 0)),
'diastolic': int(health_data.get('pressureLow', 0)),
'stepCount': int(health_data.get('step', 0)),
'distance': float(health_data.get('distance', 0.0)),
'calorie': float(health_data.get('calorie', 0.0)),
'stress': int(health_data.get('stress', 0)),
'timestamp': health_data.get('timestamp', datetime.now()).isoformat(),
'status': 'active'
}
}
return jsonify(result)
except Exception as e:
api_logger.error(f"获取个人实时健康数据失败: {str(e)}")
return jsonify({
'success': False,
'error': f'获取健康数据失败: {str(e)}'
}), 500
@app.route('/api/personal/alerts', methods=['GET'])
def get_personal_alerts():
"""获取个人告警数据API"""
try:
device_sn = request.args.get('deviceSn')
if not device_sn:
return jsonify({
'success': False,
'error': 'deviceSn参数是必需的'
}), 400
# 获取用户信息
from .user import get_user_id_by_deviceSn
user_id = get_user_id_by_deviceSn(device_sn)
if not user_id:
return jsonify({
'success': False,
'error': '未找到对应的用户信息'
}), 404
# 获取个人告警数据
from .alert import fetch_alerts_by_orgIdAndUserId
alert_result = fetch_alerts_by_orgIdAndUserId(None, user_id, None)
if not alert_result:
return jsonify({
'success': True,
'data': {
'alerts': [],
'totalCount': 0,
'pendingCount': 0
}
})
# 处理告警数据
alert_data = alert_result.get_json() if hasattr(alert_result, 'get_json') else alert_result
alerts = alert_data.get('data', {}).get('alerts', []) if isinstance(alert_data, dict) else []
# 统计告警
pending_alerts = [alert for alert in alerts if alert.get('alert_status') == 'pending']
# 格式化告警数据
formatted_alerts = []
for alert in pending_alerts[:5]: # 只返回最新5条
# 处理时间戳格式
alert_timestamp = alert.get('alert_timestamp')
if isinstance(alert_timestamp, str):
try:
# 尝试解析字符串时间
from datetime import datetime
parsed_time = datetime.fromisoformat(alert_timestamp.replace('Z', '+00:00'))
time_str = parsed_time.strftime('%H:%M')
except:
# 如果解析失败,直接使用字符串的前5个字符
time_str = alert_timestamp[:5] if len(alert_timestamp) >= 5 else alert_timestamp
elif hasattr(alert_timestamp, 'strftime'):
time_str = alert_timestamp.strftime('%H:%M')
else:
time_str = datetime.now().strftime('%H:%M')
formatted_alerts.append({
'id': alert.get('id'),
'level': alert.get('alert_type', 'warning'),
'content': alert.get('alert_info', '健康异常'),
'time': time_str,
'status': alert.get('alert_status', 'pending')
})
return jsonify({
'success': True,
'data': {
'alerts': formatted_alerts,
'totalCount': len(alerts),
'pendingCount': len(pending_alerts)
}
})
except Exception as e:
api_logger.error(f"获取个人告警数据失败: {str(e)}")
return jsonify({
'success': False,
'error': f'获取告警数据失败: {str(e)}'
}), 500
@app.route('/api/personal/messages', methods=['GET'])
def get_personal_messages():
"""获取个人消息数据API"""
try:
device_sn = request.args.get('deviceSn')
if not device_sn:
return jsonify({
'success': False,
'error': 'deviceSn参数是必需的'
}), 400
# 获取用户信息
from .user import get_user_id_by_deviceSn
user_id = get_user_id_by_deviceSn(device_sn)
if not user_id:
return jsonify({
'success': False,
'error': '未找到对应的用户信息'
}), 404
# 获取个人消息数据
from .message import fetch_messages_by_orgIdAndUserId
msg_result = fetch_messages_by_orgIdAndUserId(None, user_id, None)
if not msg_result:
return jsonify({
'success': True,
'data': {
'messages': [],
'totalCount': 0,
'unreadCount': 0
}
})
# 处理消息数据
msg_data = msg_result.get_json() if hasattr(msg_result, 'get_json') else msg_result
messages = msg_data.get('data', {}).get('messages', []) if isinstance(msg_data, dict) else []
# 统计消息
unread_messages = [msg for msg in messages if msg.get('message_status') == 1 or msg.get('message_status') == '1']
# 格式化消息数据
formatted_messages = []
for msg in messages[:10]: # 只返回最新10条
# 处理时间戳格式
create_time = msg.get('create_time')
if isinstance(create_time, str):
try:
# 尝试解析字符串时间
parsed_time = datetime.fromisoformat(create_time.replace('Z', '+00:00'))
time_str = parsed_time.strftime('%H:%M')
except:
# 如果解析失败,直接使用字符串的前5个字符
time_str = create_time[:5] if len(create_time) >= 5 else create_time
elif hasattr(create_time, 'strftime'):
time_str = create_time.strftime('%H:%M')
else:
time_str = datetime.now().strftime('%H:%M')
formatted_messages.append({
'id': msg.get('id'),
'type': msg.get('message_type', 'system'),
'content': msg.get('message_info', '系统消息'),
'time': time_str,
'status': 'unread' if msg.get('message_status') == 1 else 'read'
})
return jsonify({
'success': True,
'data': {
'messages': formatted_messages,
'totalCount': len(messages),
'unreadCount': len(unread_messages)
}
})
except Exception as e:
api_logger.error(f"获取个人消息数据失败: {str(e)}")
return jsonify({
'success': False,
'error': f'获取消息数据失败: {str(e)}'
}), 500
@app.route('/api/personal/device_status', methods=['GET'])
def get_personal_device_status():
"""获取个人设备状态API"""
try:
device_sn = request.args.get('deviceSn')
if not device_sn:
return jsonify({
'success': False,
'error': 'deviceSn参数是必需的'
}), 400
# 获取设备信息
from .device import fetch_device_info
device_result = fetch_device_info(device_sn)
if not device_result:
return jsonify({
'success': True,
'data': {
'deviceSn': device_sn,
'status': 'unknown',
'batteryLevel': 0,
'lastUpdate': None
}
})
# 处理设备数据
device_data = device_result.get_json() if hasattr(device_result, 'get_json') else device_result
device_info = device_data.get('data', {}) if isinstance(device_data, dict) else {}
return jsonify({
'success': True,
'data': {
'deviceSn': device_sn,
'status': device_info.get('status', 'unknown'),
'batteryLevel': int(device_info.get('battery_level', 0)),
'deviceName': device_info.get('device_name', 'LJWX智能手表'),
'firmwareVersion': device_info.get('firmware_version', '1.0.0'),
'lastUpdate': device_info.get('last_update', datetime.now()).isoformat()
}
})
except Exception as e:
api_logger.error(f"获取个人设备状态失败: {str(e)}")
return jsonify({
'success': False,
'error': f'获取设备状态失败: {str(e)}'
}), 500
@app.route('/api/personal/health_history', methods=['GET'])
def get_personal_health_history():
"""获取个人健康历史数据API"""
try:
device_sn = request.args.get('deviceSn')
time_range = request.args.get('timeRange', '1h') # 1h, 6h, 1d, 7d
if not device_sn:
return jsonify({
'success': False,
'error': 'deviceSn参数是必需的'
}), 400
# 计算时间范围
now = datetime.now()
if time_range == '1h':
start_time = now - timedelta(hours=1)
elif time_range == '6h':
start_time = now - timedelta(hours=6)
elif time_range == '1d':
start_time = now - timedelta(days=1)
elif time_range == '7d':
start_time = now - timedelta(days=7)
else:
start_time = now - timedelta(hours=1)
# 获取历史数据
from .user_health_data import get_health_data_by_date_range
history_data = get_health_data_by_date_range(device_sn, start_time, now)
if not history_data:
return jsonify({
'success': True,
'data': {
'heartRate': [],
'bloodOxygen': [],
'temperature': [],
'timeRange': time_range
}
})
# 格式化历史数据
heart_rate_data = []
blood_oxygen_data = []
temperature_data = []
for record in history_data:
timestamp = record.get('timestamp', now)
heart_rate_data.append([timestamp.isoformat(), record.get('heartRate', 0)])
blood_oxygen_data.append([timestamp.isoformat(), record.get('bloodOxygen', 0)])
temperature_data.append([timestamp.isoformat(), float(record.get('temperature', 0))])
return jsonify({
'success': True,
'data': {
'heartRate': heart_rate_data,
'bloodOxygen': blood_oxygen_data,
'temperature': temperature_data,
'timeRange': time_range,
'totalRecords': len(history_data)
}
})
except Exception as e:
api_logger.error(f"获取个人健康历史数据失败: {str(e)}")
return jsonify({
'success': False,
'error': f'获取历史数据失败: {str(e)}'
}), 500
except Exception as e:
print(f"Error in phone_get_personal_info: {e}")
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,694 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>3D智能健康人体模型大屏</title>
<script src="https://cdn.jsdelivr.net/npm/three@0.157.0/build/three.min.js"></script> <!-- Three.js核心库 -->
<script src="https://cdn.jsdelivr.net/npm/three@0.157.0/examples/js/controls/OrbitControls.js"></script> <!-- 轨道控制器 -->
<script src="https://cdn.jsdelivr.net/npm/three@0.157.0/examples/js/loaders/GLTFLoader.js"></script> <!-- GLTF模型加载器 -->
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script> <!-- ECharts图表库 -->
<style>
*{margin:0;padding:0;box-sizing:border-box} /* 重置样式 */
body{background:linear-gradient(135deg,#0a0e27,#162447,#1f4037);color:#fff;font-family:'Microsoft YaHei';overflow:hidden;height:100vh} /* 深蓝渐变背景 */
/* 3D人体模型容器 */
.human-3d-container{position:relative;width:600px;height:700px;margin:0 auto;border:2px solid rgba(0,228,255,0.3);border-radius:20px;background:rgba(0,21,41,0.2);backdrop-filter:blur(10px)} /* 3D容器 */
#human3d{width:100%;height:100%;border-radius:18px} /* 3D画布 */
/* 3D健康数据点 */
.health-point-3d{position:absolute;width:80px;height:80px;border-radius:50%;background:linear-gradient(135deg,rgba(0,228,255,0.9),rgba(0,255,157,0.7));border:3px solid #00e4ff;display:flex;flex-direction:column;align-items:center;justify-content:center;cursor:pointer;animation:pulse3d 2s infinite;backdrop-filter:blur(15px);z-index:100;transition:all 0.3s ease} /* 3D数据点样式 */
.health-point-3d:hover{transform:scale(1.3);box-shadow:0 0 40px rgba(0,228,255,1)} /* 悬停效果 */
.health-value-3d{font-size:16px;font-weight:bold;color:#fff;text-shadow:0 0 8px rgba(0,228,255,1)} /* 3D数值样式 */
.health-label-3d{font-size:11px;color:#a0c4ff;margin-top:3px;text-shadow:0 0 5px rgba(0,228,255,0.8)} /* 3D标签样式 */
/* 3D数据点动态位置(根据3D模型自动计算) */
.heart-rate-3d{top:25%;left:50%;transform:translate(-50%,-50%)} /* 心脏位置 */
.blood-oxygen-3d{top:22%;left:35%;transform:translate(-50%,-50%)} /* 肺部位置 */
.temperature-3d{top:12%;left:50%;transform:translate(-50%,-50%)} /* 头部位置 */
.stress-3d{top:10%;left:60%;transform:translate(-50%,-50%)} /* 大脑位置 */
.steps-3d{top:70%;left:40%;transform:translate(-50%,-50%)} /* 左腿位置 */
.distance-3d{top:70%;right:40%;transform:translate(50%,-50%)} /* 右腿位置 */
.calorie-3d{top:30%;left:20%;transform:translate(-50%,-50%)} /* 左臂位置 */
.sleep-3d{top:8%;left:40%;transform:translate(-50%,-50%)} /* 睡眠中心位置 */
/* 3D告警状态 */
.alert-critical-3d{border-color:#ff4444;background:linear-gradient(135deg,rgba(255,68,68,0.9),rgba(255,100,100,0.7));animation:alertPulse3d 1s infinite} /* 严重告警 */
.alert-medium-3d{border-color:#ffd700;background:linear-gradient(135deg,rgba(255,215,0,0.9),rgba(255,235,59,0.7))} /* 中等告警 */
.alert-normal-3d{border-color:#00ff9d;background:linear-gradient(135deg,rgba(0,255,157,0.9),rgba(0,255,200,0.7))} /* 正常状态 */
/* 3D动画效果 */
@keyframes pulse3d{0%,100%{transform:translate(-50%,-50%) scale(1)}50%{transform:translate(-50%,-50%) scale(1.1)}} /* 3D脉冲动画 */
@keyframes alertPulse3d{0%,100%{box-shadow:0 0 30px rgba(255,68,68,0.8)}50%{box-shadow:0 0 60px rgba(255,68,68,1)}} /* 3D告警脉冲 */
/* 3D控制面板 */
.control-panel-3d{position:absolute;top:20px;left:20px;background:rgba(0,21,41,0.9);border:1px solid rgba(0,228,255,0.3);border-radius:10px;padding:15px;backdrop-filter:blur(10px);z-index:200} /* 3D控制面板 */
.control-item{margin:8px 0;color:#a0c4ff;font-size:14px} /* 控制项 */
.control-btn{background:rgba(0,228,255,0.2);border:1px solid #00e4ff;color:#00e4ff;padding:5px 10px;border-radius:5px;cursor:pointer;transition:all 0.3s ease} /* 控制按钮 */
.control-btn:hover{background:rgba(0,228,255,0.4);transform:scale(1.05)} /* 按钮悬停 */
/* 3D信息提示 */
.info-tooltip-3d{position:absolute;background:rgba(0,21,41,0.95);border:1px solid rgba(0,228,255,0.5);border-radius:8px;padding:10px;color:#fff;font-size:12px;pointer-events:none;z-index:300;display:none;backdrop-filter:blur(15px)} /* 3D提示框 */
/* 模型加载状态 */
.model-loading{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#00e4ff;font-size:18px;text-align:center;z-index:150} /* 模型加载状态 */
.loading-spinner{width:50px;height:50px;border:4px solid rgba(0,228,255,0.3);border-radius:50%;border-top-color:#00e4ff;animation:spin 1s linear infinite;margin:0 auto 15px} /* 加载动画 */
/* 模型切换器 */
.model-switcher{position:absolute;bottom:20px;left:50%;transform:translateX(-50%);display:flex;gap:10px;z-index:200} /* 模型切换器 */
.model-option{background:rgba(0,228,255,0.2);border:1px solid #00e4ff;color:#00e4ff;padding:8px 15px;border-radius:15px;cursor:pointer;transition:all 0.3s ease;font-size:12px} /* 模型选项 */
.model-option.active{background:rgba(0,228,255,0.6);color:#fff} /* 激活状态 */
.model-option:hover{background:rgba(0,228,255,0.4)} /* 悬停状态 */
/* 器官高亮 */
.organ-highlight{box-shadow:0 0 50px rgba(0,255,157,1);border-width:4px;animation:organGlow 1.5s infinite} /* 器官高亮效果 */
@keyframes organGlow{0%,100%{box-shadow:0 0 50px rgba(0,255,157,1)}50%{box-shadow:0 0 80px rgba(0,255,157,1)}} /* 器官发光动画 */
/* 其他样式保持与原版相同 */
.container{position:relative;width:100vw;height:100vh;display:flex;justify-content:center;align-items:center} /* 全屏容器 */
.chart-modal{position:fixed;top:0;left:0;width:100vw;height:100vh;background:rgba(0,0,0,0.8);display:none;z-index:2000;backdrop-filter:blur(10px)} /* 图表弹窗 */
.chart-container{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:900px;height:600px;background:linear-gradient(135deg,rgba(0,21,41,0.95),rgba(0,255,157,0.1));border:2px solid rgba(0,228,255,0.6);border-radius:20px;backdrop-filter:blur(20px);box-shadow:0 20px 60px rgba(0,21,41,0.8)} /* 图表容器 */
.chart-header{padding:20px;border-bottom:1px solid rgba(0,228,255,0.3);display:flex;justify-content:space-between;align-items:center} /* 头部 */
.chart-title{color:#00e4ff;font-size:24px;font-weight:bold;text-shadow:0 0 10px rgba(0,228,255,0.8)} /* 标题 */
.close-btn{width:40px;height:40px;border-radius:50%;background:rgba(255,68,68,0.8);border:none;color:#fff;font-size:20px;cursor:pointer;transition:all 0.3s ease} /* 关闭按钮 */
.close-btn:hover{background:rgba(255,68,68,1);transform:scale(1.1)} /* 关闭按钮悬停 */
.chart-content{display:flex;height:520px} /* 内容区域 */
.chart-main{flex:2;padding:20px} /* 主图表区域 */
.chart-sidebar{flex:1;padding:20px;border-left:1px solid rgba(0,228,255,0.3);overflow-y:auto} /* 侧边栏 */
.chart-canvas{width:100%;height:100%;background:rgba(0,21,41,0.3);border-radius:10px} /* 图表画布 */
.stat-card{background:rgba(0,21,41,0.8);border:1px solid rgba(0,228,255,0.3);border-radius:10px;padding:15px;margin:10px 0;transition:all 0.3s ease} /* 统计卡片 */
.stat-card:hover{background:rgba(0,228,255,0.1);transform:translateX(5px)} /* 卡片悬停 */
.stat-title{color:#00e4ff;font-size:16px;font-weight:bold;margin-bottom:10px} /* 统计标题 */
.stat-value{color:#00ff9d;font-size:20px;font-weight:bold;text-shadow:0 0 5px rgba(0,255,157,0.8)} /* 统计数值 */
.stat-unit{color:#a0c4ff;font-size:14px;margin-left:5px} /* 单位 */
.stat-desc{color:#a0c4ff;font-size:12px;margin-top:5px} /* 描述 */
.baseline-info{background:rgba(0,21,41,0.6);border-left:3px solid #00e4ff;padding:10px;margin:10px 0;border-radius:5px} /* 基线信息 */
.baseline-title{color:#00e4ff;font-size:14px;font-weight:bold} /* 基线标题 */
.baseline-range{color:#00ff9d;font-size:12px;margin-top:5px} /* 基线范围 */
.info-panel{position:absolute;width:300px;height:80vh;background:linear-gradient(135deg,rgba(0,21,41,0.9),rgba(0,255,157,0.1));border:1px solid rgba(0,228,255,0.3);border-radius:15px;backdrop-filter:blur(15px);padding:20px} /* 信息面板样式 */
.left-panel{left:50px;top:10vh} /* 左侧面板 */
.right-panel{right:50px;top:10vh} /* 右侧面板 */
.panel-title{color:#00e4ff;font-size:18px;font-weight:bold;text-align:center;margin-bottom:15px;text-shadow:0 0 10px rgba(0,228,255,0.8)} /* 面板标题 */
.info-list{max-height:calc(80vh - 100px);overflow-y:auto} /* 信息列表 */
.info-item{background:rgba(0,21,41,0.6);border:1px solid rgba(0,228,255,0.2);border-radius:8px;padding:10px;margin:8px 0;transition:all 0.3s ease} /* 列表项 */
.info-item:hover{background:rgba(0,228,255,0.1);transform:translateX(5px)} /* 列表项悬停 */
.info-item-title{color:#00e4ff;font-size:14px;font-weight:bold} /* 项目标题 */
.info-item-desc{color:#a0c4ff;font-size:12px;margin-top:5px} /* 项目描述 */
.info-item-time{color:#666;font-size:10px;margin-top:3px} /* 时间信息 */
.device-status{position:absolute;top:20px;right:20px;display:flex;gap:15px} /* 设备状态容器 */
.status-item{display:flex;flex-direction:column;align-items:center;color:#a0c4ff;font-size:12px} /* 状态项 */
.status-indicator{width:20px;height:20px;border-radius:50%;margin-bottom:5px;box-shadow:0 0 10px;animation:breathe 2s infinite} /* 状态指示器 */
.status-online{background:#00ff9d;box-shadow:0 0 10px rgba(0,255,157,0.8)} /* 在线状态 */
.status-offline{background:#ff4444;box-shadow:0 0 10px rgba(255,68,68,0.8)} /* 离线状态 */
.status-charging{background:#ffd700;box-shadow:0 0 10px rgba(255,215,0,0.8)} /* 充电状态 */
@keyframes breathe{0%,100%{opacity:1}50%{opacity:0.6}} /* 呼吸动画 */
.main-title{position:absolute;top:30px;left:50%;transform:translateX(-50%);color:#00e4ff;font-size:32px;font-weight:bold;text-shadow:0 0 20px rgba(0,228,255,1);animation:titleGlow 3s infinite} /* 主标题 */
@keyframes titleGlow{0%,100%{text-shadow:0 0 20px rgba(0,228,255,1)}50%{text-shadow:0 0 40px rgba(0,255,157,1)}} /* 标题发光 */
.battery-display{position:absolute;top:20px;left:20px;display:flex;align-items:center;gap:10px;color:#a0c4ff} /* 电池显示容器 */
.battery-icon{width:40px;height:20px;border:2px solid #00e4ff;border-radius:3px;position:relative} /* 电池图标 */
.battery-level{height:100%;background:linear-gradient(90deg,#ff4444,#ffd700,#00ff9d);border-radius:1px;transition:width 0.3s ease} /* 电池电量 */
.battery-tip{position:absolute;right:-5px;top:6px;width:3px;height:8px;background:#00e4ff;border-radius:0 1px 1px 0} /* 电池正极 */
.loading{display:none;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#00e4ff;font-size:16px;z-index:1000} /* 加载状态 */
.loading::after{content:'';display:inline-block;width:20px;height:20px;border:3px solid rgba(0,228,255,0.3);border-radius:50%;border-top-color:#00e4ff;animation:spin 1s ease-in-out infinite;margin-left:10px} /* 加载动画 */
@keyframes spin{to{transform:rotate(360deg)}} /* 旋转动画 */
/* 响应式适配 */
@media(max-width:1600px){.human-3d-container{width:500px;height:600px}.health-point-3d{width:70px;height:70px}.health-value-3d{font-size:14px}.chart-container{width:800px;height:500px}} /* 1600px以下适配 */
@media(max-width:1366px){.human-3d-container{width:450px;height:550px}.health-point-3d{width:60px;height:60px}.health-value-3d{font-size:12px}.chart-container{width:700px;height:450px}} /* 1366px以下适配 */
</style>
</head>
<body>
<div class="main-title">{{ BIGSCREEN_TITLE or '3D智能健康监控大屏' }}</div> <!-- 主标题 -->
<div class="battery-display"> <!-- 电池显示 -->
<div class="battery-icon">
<div class="battery-level" id="batteryLevel"></div>
<div class="battery-tip"></div>
</div>
<span id="batteryText">0%</span>
</div>
<div class="device-status"> <!-- 设备状态 -->
<div class="status-item">
<div class="status-indicator" id="onlineStatus"></div>
<span>在线</span>
</div>
<div class="status-item">
<div class="status-indicator" id="chargingStatus"></div>
<span>充电</span>
</div>
<div class="status-item">
<div class="status-indicator" id="wearStatus"></div>
<span>佩戴</span>
</div>
</div>
<!-- 3D模型控制面板 -->
<div class="control-panel-3d">
<div class="control-item">
<span>3D控制</span>
</div>
<div class="control-item">
<button class="control-btn" onclick="resetCamera()">重置视角</button>
</div>
<div class="control-item">
<button class="control-btn" onclick="toggleRotation()">自动旋转</button>
</div>
<div class="control-item">
<button class="control-btn" onclick="toggleOrganView()">器官模式</button>
</div>
<div class="control-item">
<button class="control-btn" onclick="toggleWireframe()">线框模式</button>
</div>
</div>
<!-- 模型切换器 -->
<div class="model-switcher">
<div class="model-option active" data-model="default">标准模型</div>
<div class="model-option" data-model="anatomy">解剖模型</div>
<div class="model-option" data-model="skeleton">骨骼模型</div>
<div class="model-option" data-model="organs">器官模型</div>
</div>
<!-- HUB风格图表弹窗 -->
<div class="chart-modal" id="chartModal">
<div class="chart-container">
<div class="chart-header">
<div class="chart-title" id="chartTitle">健康趋势图</div>
<button class="close-btn" onclick="closeChart()">&times;</button>
</div>
<div class="chart-content">
<div class="chart-main">
<div class="chart-canvas" id="chartCanvas"></div>
<div class="loading" id="chartLoading">加载中...</div>
</div>
<div class="chart-sidebar" id="chartSidebar">
<!-- 动态内容 -->
</div>
</div>
</div>
</div>
<!-- 3D信息提示 -->
<div class="info-tooltip-3d" id="tooltip3d"></div>
<div class="container">
<!-- 左侧告警面板 -->
<div class="info-panel left-panel">
<div class="panel-title">健康告警</div>
<div class="info-list" id="alertList"></div>
</div>
<!-- 3D人体模型容器 -->
<div class="human-3d-container">
<!-- 模型加载状态 -->
<div class="model-loading" id="modelLoading">
<div class="loading-spinner"></div>
<div>正在加载3D人体模型...</div>
</div>
<!-- 3D画布 -->
<canvas id="human3d"></canvas>
<!-- 3D健康数据点 -->
<div class="health-point-3d heart-rate-3d alert-normal-3d" id="heartRate3d" data-metric="heart_rate">
<div class="health-value-3d">--</div>
<div class="health-label-3d">心率</div>
</div>
<div class="health-point-3d blood-oxygen-3d alert-normal-3d" id="bloodOxygen3d" data-metric="blood_oxygen">
<div class="health-value-3d">--%</div>
<div class="health-label-3d">血氧</div>
</div>
<div class="health-point-3d temperature-3d alert-normal-3d" id="temperature3d" data-metric="temperature">
<div class="health-value-3d">--°C</div>
<div class="health-label-3d">体温</div>
</div>
<div class="health-point-3d stress-3d alert-normal-3d" id="stress3d" data-metric="stress">
<div class="health-value-3d">--</div>
<div class="health-label-3d">压力</div>
</div>
<div class="health-point-3d steps-3d alert-normal-3d" id="steps3d" data-metric="step">
<div class="health-value-3d">--</div>
<div class="health-label-3d">步数</div>
</div>
<div class="health-point-3d distance-3d alert-normal-3d" id="distance3d" data-metric="distance">
<div class="health-value-3d">--km</div>
<div class="health-label-3d">距离</div>
</div>
<div class="health-point-3d calorie-3d alert-normal-3d" id="calorie3d" data-metric="calorie">
<div class="health-value-3d">--</div>
<div class="health-label-3d">卡路里</div>
</div>
<div class="health-point-3d sleep-3d alert-normal-3d" id="sleep3d" data-metric="sleep">
<div class="health-value-3d">--h</div>
<div class="health-label-3d">睡眠</div>
</div>
</div>
<!-- 右侧消息面板 -->
<div class="info-panel right-panel">
<div class="panel-title">系统消息</div>
<div class="info-list" id="messageList"></div>
</div>
</div>
<script>
// 3D场景变量
let scene,camera,renderer,controls,humanModel,mixer;
let healthData={},currentChart=null;
let isAutoRotating=false,isOrganMode=false,isWireframe=false;
// 初始化3D场景
function init3DScene(){
const container=document.getElementById('human3d');
const rect=container.parentElement.getBoundingClientRect();
// 创建场景
scene=new THREE.Scene();
scene.background=new THREE.Color(0x0a0e27); // 深蓝背景
// 创建相机
camera=new THREE.PerspectiveCamera(75,rect.width/rect.height,0.1,1000);
camera.position.set(0,0,5);
// 创建渲染器
renderer=new THREE.WebGLRenderer({canvas:container,antialias:true,alpha:true});
renderer.setSize(rect.width,rect.height);
renderer.shadowMap.enabled=true;
renderer.shadowMap.type=THREE.PCFSoftShadowMap;
// 创建轨道控制器
controls=new THREE.OrbitControls(camera,renderer.domElement);
controls.enableDamping=true;
controls.dampingFactor=0.05;
controls.enableZoom=true;
controls.enablePan=true;
controls.maxDistance=10;
controls.minDistance=2;
// 添加光源
addLights();
// 加载3D人体模型
loadHumanModel();
// 开始渲染循环
animate();
}
// 添加光源
function addLights(){
// 环境光
const ambientLight=new THREE.AmbientLight(0x404040,0.6);
scene.add(ambientLight);
// 主光源
const directionalLight=new THREE.DirectionalLight(0xffffff,1);
directionalLight.position.set(10,10,5);
directionalLight.castShadow=true;
directionalLight.shadow.camera.near=0.1;
directionalLight.shadow.camera.far=50;
directionalLight.shadow.camera.left=-10;
directionalLight.shadow.camera.right=10;
directionalLight.shadow.camera.top=10;
directionalLight.shadow.camera.bottom=-10;
scene.add(directionalLight);
// 补光
const fillLight=new THREE.DirectionalLight(0x00e4ff,0.4);
fillLight.position.set(-10,0,10);
scene.add(fillLight);
// 点光源(模拟健康数据发光)
const pointLight=new THREE.PointLight(0x00ff9d,0.8,20);
pointLight.position.set(0,2,2);
scene.add(pointLight);
}
// 加载3D人体模型
function loadHumanModel(){
const loader=new THREE.GLTFLoader();
// 使用免费的人体模型资源
// 这里可以替换为您选择的3D模型URL
const modelUrl='https://threejs.org/examples/models/gltf/RobotExpressive/RobotExpressive.glb'; // 示例模型
loader.load(modelUrl,
function(gltf){
humanModel=gltf.scene;
humanModel.scale.setScalar(1.5);
humanModel.position.y=-1;
// 启用阴影
humanModel.traverse(function(child){
if(child.isMesh){
child.castShadow=true;
child.receiveShadow=true;
// 添加发光材质效果
if(child.material){
child.material.emissive=new THREE.Color(0x001122);
child.material.emissiveIntensity=0.1;
}
}
});
scene.add(humanModel);
// 设置动画混合器
if(gltf.animations.length>0){
mixer=new THREE.AnimationMixer(humanModel);
const action=mixer.clipAction(gltf.animations[0]);
action.play();
}
// 隐藏加载状态
document.getElementById('modelLoading').style.display='none';
// 开始更新健康数据
fetchHealthData();
},
function(progress){
console.log('模型加载进度:',progress.loaded/progress.total*100+'%');
},
function(error){
console.error('模型加载失败:',error);
// 降级到基础几何体
createBasicHumanModel();
}
);
}
// 创建基础几何体人体模型(降级方案)
function createBasicHumanModel(){
const group=new THREE.Group();
// 头部
const headGeometry=new THREE.SphereGeometry(0.3,32,32);
const headMaterial=new THREE.MeshPhongMaterial({
color:0x00e4ff,
transparent:true,
opacity:0.8,
emissive:0x001122
});
const head=new THREE.Mesh(headGeometry,headMaterial);
head.position.y=1.5;
head.castShadow=true;
group.add(head);
// 躯干
const bodyGeometry=new THREE.CylinderGeometry(0.3,0.4,1.2,8);
const bodyMaterial=new THREE.MeshPhongMaterial({
color:0x00e4ff,
transparent:true,
opacity:0.7,
emissive:0x001122
});
const body=new THREE.Mesh(bodyGeometry,bodyMaterial);
body.position.y=0.5;
body.castShadow=true;
group.add(body);
// 手臂
const armGeometry=new THREE.CylinderGeometry(0.08,0.12,0.8,8);
const armMaterial=new THREE.MeshPhongMaterial({
color:0x00e4ff,
transparent:true,
opacity:0.6,
emissive:0x001122
});
const leftArm=new THREE.Mesh(armGeometry,armMaterial);
leftArm.position.set(-0.5,0.7,0);
leftArm.rotation.z=Math.PI/6;
leftArm.castShadow=true;
group.add(leftArm);
const rightArm=new THREE.Mesh(armGeometry,armMaterial);
rightArm.position.set(0.5,0.7,0);
rightArm.rotation.z=-Math.PI/6;
rightArm.castShadow=true;
group.add(rightArm);
// 腿部
const legGeometry=new THREE.CylinderGeometry(0.12,0.15,1,8);
const legMaterial=new THREE.MeshPhongMaterial({
color:0x00e4ff,
transparent:true,
opacity:0.6,
emissive:0x001122
});
const leftLeg=new THREE.Mesh(legGeometry,legMaterial);
leftLeg.position.set(-0.2,-0.5,0);
leftLeg.castShadow=true;
group.add(leftLeg);
const rightLeg=new THREE.Mesh(legGeometry,legMaterial);
rightLeg.position.set(0.2,-0.5,0);
rightLeg.castShadow=true;
group.add(rightLeg);
humanModel=group;
scene.add(humanModel);
// 隐藏加载状态
document.getElementById('modelLoading').style.display='none';
// 开始更新健康数据
fetchHealthData();
}
// 渲染循环
function animate(){
requestAnimationFrame(animate);
if(controls) controls.update();
if(mixer) mixer.update(0.016); // 假设60fps
if(isAutoRotating && humanModel){
humanModel.rotation.y+=0.005; // 自动旋转
}
renderer.render(scene,camera);
}
// 3D控制函数
function resetCamera(){
camera.position.set(0,0,5);
controls.reset();
}
function toggleRotation(){
isAutoRotating=!isAutoRotating;
}
function toggleOrganView(){
isOrganMode=!isOrganMode;
if(humanModel){
humanModel.traverse(function(child){
if(child.isMesh && child.material){
if(isOrganMode){
child.material.transparent=true;
child.material.opacity=0.3;
}else{
child.material.opacity=0.8;
}
}
});
}
}
function toggleWireframe(){
isWireframe=!isWireframe;
if(humanModel){
humanModel.traverse(function(child){
if(child.isMesh && child.material){
child.material.wireframe=isWireframe;
}
});
}
}
// 更新3D健康数据点
function updateHealthPoint3D(id,value,unit='',alertLevel='normal'){
const el=document.getElementById(id+'3d');
if(!el)return;
el.querySelector('.health-value-3d').textContent=value+unit;
el.className=el.className.replace(/alert-\w+-3d/,'alert-'+alertLevel+'-3d');
// 添加器官高亮效果
if(alertLevel!=='normal'){
el.classList.add('organ-highlight');
setTimeout(()=>el.classList.remove('organ-highlight'),3000);
}
}
// 获取健康数据
async function fetchHealthData(){
try{
const deviceSn='{{deviceSn}}';
const response=await fetch(`./get_personal_info?deviceSn=${deviceSn}`);
const data=await response.json();
if(data.success && data.data.health_data){
const healthInfo=data.data.health_data.data;
if(healthInfo && healthInfo.healthData){
updateAllHealthPoints3D(healthInfo.healthData);
updateDeviceStatus3D(data.data.device_info);
updateAlertsList(data.data.alert_info);
updateMessagesList(data.data.message_info);
}
}
}catch(error){
console.error('获取健康数据失败:',error);
}
}
// 更新所有3D健康数据点
function updateAllHealthPoints3D(healthData){
if(!healthData.length) return;
const latest=healthData[0]; // 最新数据
// 更新各项健康指标
updateHealthPoint3D('heartRate',latest.heartRate||'--','',getAlertLevel('heart_rate',latest.heartRate));
updateHealthPoint3D('bloodOxygen',latest.bloodOxygen||'--','%',getAlertLevel('blood_oxygen',latest.bloodOxygen));
updateHealthPoint3D('temperature',latest.temperature||'--','°C',getAlertLevel('temperature',latest.temperature));
updateHealthPoint3D('stress',latest.stress||'--','',getAlertLevel('stress',latest.stress));
updateHealthPoint3D('steps',latest.step||'--','');
updateHealthPoint3D('distance',(latest.distance||0),'km');
updateHealthPoint3D('calorie',latest.calorie||'--','');
updateHealthPoint3D('sleep',latest.sleep||'--','h');
// 更新电池显示
if(latest.batteryLevel) updateBattery(latest.batteryLevel);
}
// 健康数据点点击事件
document.querySelectorAll('.health-point-3d').forEach(point=>{
point.addEventListener('click',function(){
const metric=this.dataset.metric;
if(metric) showHealthChart(metric);
});
// 鼠标悬停提示
point.addEventListener('mouseenter',function(e){
const tooltip=document.getElementById('tooltip3d');
const metric=this.dataset.metric;
const value=this.querySelector('.health-value-3d').textContent;
const label=this.querySelector('.health-label-3d').textContent;
tooltip.innerHTML=`<strong>${label}</strong><br>当前值: ${value}<br>点击查看详细趋势`;
tooltip.style.display='block';
tooltip.style.left=e.pageX+10+'px';
tooltip.style.top=e.pageY-10+'px';
});
point.addEventListener('mouseleave',function(){
document.getElementById('tooltip3d').style.display='none';
});
});
// 模型切换
document.querySelectorAll('.model-option').forEach(option=>{
option.addEventListener('click',function(){
document.querySelectorAll('.model-option').forEach(o=>o.classList.remove('active'));
this.classList.add('active');
const modelType=this.dataset.model;
switchModel(modelType);
});
});
function switchModel(type){
console.log('切换模型:',type);
// 这里可以实现不同模型的切换逻辑
// 例如加载不同的GLTF模型或调整现有模型的显示方式
}
// 保持原有的图表功能
async function showHealthChart(metric){
// ... 保持原有的图表显示逻辑不变
const modal=document.getElementById('chartModal');
modal.style.display='block';
// 这里复用原有的图表显示代码
}
function closeChart(){
document.getElementById('chartModal').style.display='none';
if(currentChart){
currentChart.dispose();
currentChart=null;
}
}
// 保持原有的其他函数
function updateDeviceStatus3D(deviceInfo){
// 设备状态更新逻辑
}
function updateAlertsList(alertInfo){
// 告警列表更新逻辑
}
function updateMessagesList(messageInfo){
// 消息列表更新逻辑
}
function updateBattery(level){
const batteryEl=document.getElementById('batteryLevel');
const textEl=document.getElementById('batteryText');
batteryEl.style.width=level+'%';
textEl.textContent=level+'%';
}
function getAlertLevel(type,value){
const thresholds={
heart_rate:[60,100],
blood_oxygen:[95,100],
temperature:[36,37.5],
stress:[0,50]
};
if(!thresholds[type])return 'normal';
const[min,max]=thresholds[type];
if(value<min||value>max)return 'critical';
if(value<min*1.1||value>max*0.9)return 'medium';
return 'normal';
}
// 页面加载完成后初始化
window.addEventListener('load',function(){
init3DScene();
// 定时更新数据
setInterval(fetchHealthData,5000);
});
// 键盘事件
document.addEventListener('keydown',function(e){
if(e.key==='Escape') closeChart();
if(e.key===' ') toggleRotation(); // 空格键切换旋转
if(e.key==='r'||e.key==='R') resetCamera(); // R键重置视角
});
// 窗口大小调整
window.addEventListener('resize',function(){
if(camera && renderer){
const container=document.getElementById('human3d');
const rect=container.parentElement.getBoundingClientRect();
camera.aspect=rect.width/rect.height;
camera.updateProjectionMatrix();
renderer.setSize(rect.width,rect.height);
}
});
</script>
</body>
</html>
@@ -1,555 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>企业级智能健康监测大屏</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.0/dist/echarts.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/echarts-gl@2.0.9/dist/echarts-gl.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.158.0/build/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/lottie-web@5.12.2/build/player/lottie.min.js"></script>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<style>
* {margin:0;padding:0;box-sizing:border-box;}
body {font-family:'Microsoft YaHei',Arial,sans-serif;background:#000;color:#fff;overflow:hidden;height:100vh;position:relative;}
.dashboard {display:grid;grid-template-columns:320px 1fr 320px;grid-template-rows:100px 1fr 100px;height:100vh;gap:8px;padding:8px;position:relative;z-index:10;}
.bg-canvas {position:fixed;top:0;left:0;width:100%;height:100%;z-index:1;opacity:0.3;}
.header {grid-column:1/4;background:linear-gradient(135deg,#001122 0%,#003366 50%,#001122 100%);border:2px solid #00ccff;display:flex;align-items:center;justify-content:space-between;padding:0 40px;border-radius:12px;position:relative;overflow:hidden;}
.header::before {content:'';position:absolute;top:0;left:-100%;width:100%;height:100%;background:linear-gradient(90deg,transparent,rgba(0,204,255,0.3),transparent);animation:shine 3s infinite;}
.logo {color:#00ccff;font-size:32px;font-weight:bold;text-shadow:0 0 20px #00ccff;}
.header-stats {display:flex;gap:30px;}
.stat-item {text-align:center;}
.stat-value {font-size:24px;font-weight:bold;color:#00ff88;}
.stat-label {font-size:12px;color:#ccc;}
.left-panel,.right-panel {background:linear-gradient(135deg,#001122 0%,#002244 100%);border:1px solid #00ccff;border-radius:12px;padding:15px;overflow:hidden;position:relative;}
.main-panel {background:linear-gradient(135deg,#000811 0%,#001a33 50%,#000811 100%);border:2px solid #00ccff;border-radius:12px;position:relative;overflow:hidden;}
.footer {grid-column:1/4;background:linear-gradient(135deg,#001122 0%,#003366 50%,#001122 100%);border:2px solid #00ccff;border-radius:12px;display:flex;align-items:center;justify-content:space-around;padding:10px;}
.panel-title {color:#00ccff;font-size:18px;margin-bottom:15px;padding:10px;background:rgba(0,204,255,0.1);border-radius:8px;text-align:center;position:relative;}
.vital-card {background:linear-gradient(135deg,#001a33 0%,#003366 100%);border:1px solid #00ccff;border-radius:12px;padding:20px;margin-bottom:20px;position:relative;overflow:hidden;transition:all 0.3s ease;}
.vital-card:hover {transform:translateY(-5px);box-shadow:0 10px 30px rgba(0,204,255,0.3);}
.vital-card::before {content:'';position:absolute;top:0;left:0;right:0;height:4px;background:linear-gradient(90deg,#00ccff,#00ff88,#ffaa00,#ff4757);animation:gradient 2s linear infinite;}
.vital-title {color:#00ccff;font-size:16px;margin-bottom:15px;display:flex;align-items:center;gap:10px;}
.vital-value {font-size:36px;font-weight:bold;color:#fff;text-shadow:0 0 15px #00ccff;animation:pulse 2s ease-in-out infinite;margin-bottom:10px;}
.vital-unit {font-size:16px;color:#ccc;margin-left:8px;}
.vital-status {font-size:14px;color:#00ff88;margin-bottom:15px;padding:5px 10px;background:rgba(0,255,136,0.2);border-radius:15px;text-align:center;}
.chart-mini {height:80px;position:relative;overflow:hidden;}
.main-display {position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;}
.health-ring {width:280px;height:280px;margin:0 auto 30px;position:relative;}
.ring-svg {width:100%;height:100%;transform:rotate(-90deg);}
.ring-bg {fill:none;stroke:rgba(0,204,255,0.2);stroke-width:8;}
.ring-progress {fill:none;stroke:#00ccff;stroke-width:8;stroke-linecap:round;transition:stroke-dasharray 1s ease;}
.ring-glow {fill:none;stroke:#00ccff;stroke-width:12;opacity:0.5;filter:blur(4px);}
.score-display {position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;}
.score-main {font-size:64px;font-weight:bold;color:#00ccff;text-shadow:0 0 30px #00ccff;margin-bottom:10px;}
.score-label {font-size:18px;color:#ccc;margin-bottom:20px;}
.status-badges {display:flex;justify-content:center;gap:15px;flex-wrap:wrap;}
.status-badge {padding:8px 15px;border-radius:20px;font-size:12px;font-weight:bold;animation:float 3s ease-in-out infinite;}
.badge-excellent {background:linear-gradient(45deg,#00ff88,#00ccff);color:#000;}
.badge-good {background:linear-gradient(45deg,#ffaa00,#ff6b35);color:#000;}
.badge-warning {background:linear-gradient(45deg,#ff4757,#ff3838);color:#fff;}
.data-grid {display:grid;grid-template-columns:1fr 1fr;gap:15px;margin-bottom:20px;}
.data-card {background:linear-gradient(135deg,#001a33,#003366);border:1px solid #00ccff;border-radius:8px;padding:15px;text-align:center;position:relative;overflow:hidden;}
.data-card::before {content:'';position:absolute;top:0;left:0;width:100%;height:2px;background:linear-gradient(90deg,#00ccff,#00ff88);animation:slide 2s linear infinite;}
.data-value {font-size:28px;font-weight:bold;color:#fff;text-shadow:0 0 10px #00ccff;margin-bottom:5px;}
.data-label {font-size:12px;color:#ccc;}
.trend-container {height:200px;margin-bottom:20px;border:1px solid #00ccff;border-radius:8px;overflow:hidden;}
.recommendations {max-height:300px;overflow-y:auto;}
.rec-item {background:linear-gradient(90deg,rgba(0,255,136,0.1),rgba(0,204,255,0.1));border-left:4px solid #00ff88;padding:15px;margin-bottom:10px;border-radius:8px;animation:slideInRight 0.5s ease;}
.rec-title {color:#00ff88;font-size:14px;font-weight:bold;margin-bottom:8px;display:flex;align-items:center;gap:8px;}
.rec-text {color:#ccc;font-size:12px;line-height:1.5;}
.footer-item {text-align:center;color:#00ccff;cursor:pointer;transition:all 0.3s ease;}
.footer-item:hover {color:#00ff88;transform:scale(1.1);}
.footer-value {font-size:20px;font-weight:bold;margin-bottom:5px;}
.footer-label {font-size:12px;}
@keyframes shine {0%{left:-100%;}100%{left:100%;}}
@keyframes pulse {0%,100%{transform:scale(1);opacity:1;}50%{transform:scale(1.05);opacity:0.8;}}
@keyframes gradient {0%{background-position:0% 50%;}50%{background-position:100% 50%;}100%{background-position:0% 50%;}}
@keyframes float {0%,100%{transform:translateY(0);}50%{transform:translateY(-10px);}}
@keyframes slide {0%{transform:translateX(-100%);}100%{transform:translateX(100%);}}
@keyframes slideInRight {from{transform:translateX(20px);opacity:0;}to{transform:translateX(0);opacity:1;}}
.glow-effect {position:absolute;width:100%;height:100%;background:radial-gradient(circle,rgba(0,204,255,0.1) 0%,transparent 70%);animation:breathe 4s ease-in-out infinite;}
@keyframes breathe {0%,100%{opacity:0.3;transform:scale(1);}50%{opacity:0.8;transform:scale(1.1);}}
</style>
</head>
<body>
<canvas class="bg-canvas" id="bgCanvas"></canvas>
<div class="glow-effect"></div>
<div class="dashboard">
<header class="header">
<div class="logo">🏥 企业级智能健康监测大屏</div>
<div class="header-stats">
<div class="stat-item">
<div class="stat-value" id="onlineDevices">156</div>
<div class="stat-label">在线设备</div>
</div>
<div class="stat-item">
<div class="stat-value" id="alertCount">3</div>
<div class="stat-label">实时告警</div>
</div>
<div class="stat-item">
<div class="stat-value" id="currentTime"></div>
<div class="stat-label">当前时间</div>
</div>
</div>
</header>
<div class="left-panel">
<div class="panel-title">🫀 实时生命体征</div>
<div class="vital-card">
<div class="vital-title">💓 心率监测</div>
<div class="vital-value" id="heartRate">82<span class="vital-unit">bpm</span></div>
<div class="vital-status" id="heartRateStatus">正常范围</div>
<div class="chart-mini" id="heartRateChart"></div>
</div>
<div class="vital-card">
<div class="vital-title">🩸 血氧饱和度</div>
<div class="vital-value" id="bloodOxygen">98<span class="vital-unit">%</span></div>
<div class="vital-status" id="bloodOxygenStatus">优秀状态</div>
<div class="chart-mini" id="bloodOxygenChart"></div>
</div>
<div class="vital-card">
<div class="vital-title">🌡️ 体温监测</div>
<div class="vital-value" id="temperature">36.8<span class="vital-unit">°C</span></div>
<div class="vital-status" id="temperatureStatus">正常体温</div>
<div class="chart-mini" id="temperatureChart"></div>
</div>
<div class="vital-card">
<div class="vital-title">💉 血压监测</div>
<div class="vital-value" id="bloodPressure">120/80<span class="vital-unit">mmHg</span></div>
<div class="vital-status" id="bloodPressureStatus">理想血压</div>
<div class="chart-mini" id="bloodPressureChart"></div>
</div>
</div>
<div class="main-panel">
<div class="main-display">
<div class="health-ring">
<svg class="ring-svg">
<circle class="ring-bg" cx="140" cy="140" r="120"></circle>
<circle class="ring-glow" cx="140" cy="140" r="120"></circle>
<circle class="ring-progress" cx="140" cy="140" r="120" id="progressRing"></circle>
</svg>
<div class="score-display">
<div class="score-main" id="overallScore">97.4</div>
<div class="score-label">综合健康指数</div>
<div class="status-badges">
<div class="status-badge badge-excellent" id="healthBadge">健康优秀</div>
</div>
</div>
</div>
</div>
</div>
<div class="right-panel">
<div class="panel-title">📊 健康数据分析</div>
<div class="data-grid">
<div class="data-card">
<div class="data-value" id="sleepHours">7.5</div>
<div class="data-label">睡眠时长(h)</div>
</div>
<div class="data-card">
<div class="data-value" id="steps">8000</div>
<div class="data-label">今日步数</div>
</div>
<div class="data-card">
<div class="data-value" id="stress">15</div>
<div class="data-label">压力指数</div>
</div>
<div class="data-card">
<div class="data-value" id="calories">400</div>
<div class="data-label">消耗热量</div>
</div>
</div>
<div class="panel-title">📈 健康趋势雷达</div>
<div class="trend-container" id="radarChart"></div>
<div class="panel-title">💡 AI智能建议</div>
<div class="recommendations" id="recommendations"></div>
</div>
<footer class="footer">
<div class="footer-item">
<div class="footer-value" id="avgHeartRate">82</div>
<div class="footer-label">平均心率</div>
</div>
<div class="footer-item">
<div class="footer-value" id="maxOxygen">99</div>
<div class="footer-label">最高血氧</div>
</div>
<div class="footer-item">
<div class="footer-value" id="sleepQuality">85%</div>
<div class="footer-label">睡眠质量</div>
</div>
<div class="footer-item">
<div class="footer-value" id="activityLevel">92%</div>
<div class="footer-label">活跃度</div>
</div>
<div class="footer-item">
<div class="footer-value" id="stressLevel">低</div>
<div class="footer-label">压力水平</div>
</div>
</footer>
</div>
<script>
//#全局变量配置
const CONFIG = {
deviceSn: getUrlParameter('deviceSn') || 'CRFTQ23409001890',
userId: getUrlParameter('userId') || '1922983570035261442',
orgId: getUrlParameter('orgId') || '1',
updateInterval: 5000,
maxDataPoints: 30
};
let charts = {};
let vitalData = {heartRate:[],bloodOxygen:[],temperature:[],bloodPressure:[]};
let scene, camera, renderer, particles;
//#获取URL参数
function getUrlParameter(name) {
return new URLSearchParams(window.location.search).get(name);
}
//#初始化3D背景
function init3DBackground() {
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
renderer = new THREE.WebGLRenderer({canvas: document.getElementById('bgCanvas'), alpha: true});
renderer.setSize(window.innerWidth, window.innerHeight);
//#创建粒子系统
const geometry = new THREE.BufferGeometry();
const particleCount = 1000;
const positions = new Float32Array(particleCount * 3);
for (let i = 0; i < particleCount * 3; i++) {
positions[i] = (Math.random() - 0.5) * 100;
}
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const material = new THREE.PointsMaterial({
color: 0x00ccff,
size: 0.5,
transparent: true,
opacity: 0.6
});
particles = new THREE.Points(geometry, material);
scene.add(particles);
camera.position.z = 50;
function animate() {
requestAnimationFrame(animate);
particles.rotation.x += 0.001;
particles.rotation.y += 0.002;
renderer.render(scene, camera);
}
animate();
}
//#更新时间显示
function updateTime() {
const now = new Date();
document.getElementById('currentTime').textContent = now.toLocaleTimeString('zh-CN');
}
//#初始化图表
function initCharts() {
//#心率波形图
charts.heartRate = echarts.init(document.getElementById('heartRateChart'));
charts.heartRate.setOption({
backgroundColor: 'transparent',
grid: {top: 5, bottom: 5, left: 5, right: 5},
xAxis: {type: 'category', show: false},
yAxis: {type: 'value', show: false},
series: [{
type: 'line',
data: [],
smooth: true,
lineStyle: {color: '#ff4757', width: 3},
areaStyle: {color: {
type: 'linear',
x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{offset: 0, color: 'rgba(255,71,87,0.8)'},
{offset: 1, color: 'rgba(255,71,87,0.1)'}
]
}},
symbol: 'circle',
symbolSize: 6,
emphasis: {focus: 'series'}
}]
});
//#血氧图表
charts.bloodOxygen = echarts.init(document.getElementById('bloodOxygenChart'));
charts.bloodOxygen.setOption({
backgroundColor: 'transparent',
grid: {top: 5, bottom: 5, left: 5, right: 5},
xAxis: {type: 'category', show: false},
yAxis: {type: 'value', show: false},
series: [{
type: 'line',
data: [],
smooth: true,
lineStyle: {color: '#00ccff', width: 3},
areaStyle: {color: {
type: 'linear',
x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{offset: 0, color: 'rgba(0,204,255,0.8)'},
{offset: 1, color: 'rgba(0,204,255,0.1)'}
]
}}
}]
});
//#体温图表
charts.temperature = echarts.init(document.getElementById('temperatureChart'));
charts.temperature.setOption({
backgroundColor: 'transparent',
grid: {top: 5, bottom: 5, left: 5, right: 5},
xAxis: {type: 'category', show: false},
yAxis: {type: 'value', show: false},
series: [{
type: 'line',
data: [],
smooth: true,
lineStyle: {color: '#ffaa00', width: 3},
areaStyle: {color: {
type: 'linear',
x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{offset: 0, color: 'rgba(255,170,0,0.8)'},
{offset: 1, color: 'rgba(255,170,0,0.1)'}
]
}}
}]
});
//#血压图表
charts.bloodPressure = echarts.init(document.getElementById('bloodPressureChart'));
charts.bloodPressure.setOption({
backgroundColor: 'transparent',
grid: {top: 5, bottom: 5, left: 5, right: 5},
xAxis: {type: 'category', show: false},
yAxis: {type: 'value', show: false},
series: [
{name: '收缩压', type: 'line', data: [], smooth: true, lineStyle: {color: '#ff6b6b', width: 2}},
{name: '舒张压', type: 'line', data: [], smooth: true, lineStyle: {color: '#4ecdc4', width: 2}}
]
});
//#雷达图
charts.radar = echarts.init(document.getElementById('radarChart'));
charts.radar.setOption({
backgroundColor: 'transparent',
radar: {
indicator: [
{name: '心率', max: 100}, {name: '血氧', max: 100}, {name: '体温', max: 100},
{name: '血压', max: 100}, {name: '睡眠', max: 100}, {name: '压力', max: 100}
],
axisLabel: {color: '#ccc', fontSize: 10},
splitLine: {lineStyle: {color: 'rgba(0,204,255,0.3)'}},
axisLine: {lineStyle: {color: 'rgba(0,204,255,0.5)'}},
splitArea: {areaStyle: {color: ['rgba(0,204,255,0.1)', 'rgba(0,204,255,0.05)']}}
},
series: [{
type: 'radar',
data: [{
value: [85, 98, 95, 90, 88, 85],
areaStyle: {color: 'rgba(0,204,255,0.3)'},
lineStyle: {color: '#00ccff', width: 3},
itemStyle: {color: '#00ccff', borderColor: '#fff', borderWidth: 2}
}]
}]
});
}
//#更新环形进度条
function updateHealthRing(score) {
const ring = document.getElementById('progressRing');
const circumference = 2 * Math.PI * 120;
const offset = circumference - (score / 100) * circumference;
ring.style.strokeDasharray = circumference;
ring.style.strokeDashoffset = offset;
}
//#获取实时体征数据
async function fetchVitalData() {
try {
const response = await fetch(`http://localhost:5001/get_personal_info?deviceSn=${CONFIG.deviceSn}`);
const result = await response.json();
if (result.success && result.data?.health_data) {
updateVitalDisplay(result.data.health_data);
}
} catch (error) {
console.error('获取体征数据失败:', error);
}
}
//#获取健康评分数据
async function fetchHealthScore() {
try {
const today = new Date();
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
const startDate = yesterday.toISOString().split('T')[0];
const endDate = today.toISOString().split('T')[0];
const response = await fetch(`/health_data/score?orgId=${CONFIG.orgId}&userId=${CONFIG.userId}&startDate=${startDate}&endDate=${endDate}`);
const result = await response.json();
if (result.success && result.data?.healthScores) {
updateHealthScore(result.data.healthScores);
}
} catch (error) {
console.error('获取健康评分失败:', error);
}
}
//#更新体征显示
function updateVitalDisplay(health) {
const heartRate = parseInt(health.heart_rate || health.heartRate) || 82;
const bloodOxygen = parseInt(health.blood_oxygen || health.bloodOxygen) || 98;
const temperature = parseFloat(health.temperature) || 36.8;
const pressureHigh = parseInt(health.pressure_high || health.pressureHigh) || (heartRate + 35);
const pressureLow = parseInt(health.pressure_low || health.pressureLow) || Math.max(heartRate - 5, 60);
//#更新显示
document.getElementById('heartRate').innerHTML = `${heartRate}<span class="vital-unit">bpm</span>`;
document.getElementById('bloodOxygen').innerHTML = `${bloodOxygen}<span class="vital-unit">%</span>`;
document.getElementById('temperature').innerHTML = `${temperature.toFixed(1)}<span class="vital-unit">°C</span>`;
document.getElementById('bloodPressure').innerHTML = `${pressureHigh}/${pressureLow}<span class="vital-unit">mmHg</span>`;
//#存储数据
vitalData.heartRate.push(heartRate);
vitalData.bloodOxygen.push(bloodOxygen);
vitalData.temperature.push(temperature);
vitalData.bloodPressure.push([pressureHigh, pressureLow]);
if (vitalData.heartRate.length > CONFIG.maxDataPoints) {
Object.keys(vitalData).forEach(key => vitalData[key].shift());
}
//#更新图表
updateCharts();
}
//#更新图表
function updateCharts() {
Object.keys(charts).forEach(key => {
if (key === 'bloodPressure' && charts[key]) {
const pressureHigh = vitalData.bloodPressure.map(p => p[0]);
const pressureLow = vitalData.bloodPressure.map(p => p[1]);
charts[key].setOption({series: [{data: pressureHigh}, {data: pressureLow}]});
} else if (vitalData[key] && charts[key]) {
charts[key].setOption({series: [{data: vitalData[key]}]});
}
});
}
//#更新健康评分
function updateHealthScore(scoreData) {
const {overall, factors, details} = scoreData;
document.getElementById('overallScore').textContent = overall.toFixed(1);
updateHealthRing(overall);
const status = overall >= 90 ? '健康优秀' : overall >= 80 ? '健康良好' : '需要关注';
document.getElementById('healthBadge').textContent = status;
//#更新详细数据
if (factors.sleep) document.getElementById('sleepHours').textContent = factors.sleep.currentValue;
if (factors.step) document.getElementById('steps').textContent = factors.step.currentValue;
if (factors.stress) document.getElementById('stress').textContent = factors.stress.currentValue;
if (factors.calorie) document.getElementById('calories').textContent = factors.calorie.currentValue;
//#更新雷达图
if (charts.radar) {
const radarValues = [
factors.heart_rate?.score || 85,
factors.blood_oxygen?.score || 98,
factors.temperature?.score || 95,
((factors.pressure_high?.score || 0) + (factors.pressure_low?.score || 0)) / 2 || 90,
factors.sleep?.score || 88,
factors.stress?.score || 85
];
charts.radar.setOption({series: [{data: [{value: radarValues}]}]});
}
//#更新建议
updateRecommendations(details);
}
//#更新建议
function updateRecommendations(details) {
if (!details) return;
const container = document.getElementById('recommendations');
container.innerHTML = '';
const categories = {
heart_rate: {icon: '💓', title: '心率优化'},
blood_oxygen: {icon: '🩸', title: '血氧改善'},
sleep: {icon: '😴', title: '睡眠建议'},
stress: {icon: '🧘', title: '压力管理'}
};
Object.keys(details).slice(0, 4).forEach(key => {
const detail = details[key];
const category = categories[key] || {icon: '💡', title: '健康建议'};
if (detail?.suggestions?.length > 0) {
const item = document.createElement('div');
item.className = 'rec-item';
item.innerHTML = `
<div class="rec-title">${category.icon} ${category.title}</div>
<div class="rec-text">${detail.suggestions.join('、')}</div>
`;
container.appendChild(item);
}
});
}
//#初始化所有功能
function init() {
init3DBackground();
initCharts();
updateTime();
setInterval(updateTime, 1000);
fetchVitalData();
fetchHealthScore();
setInterval(fetchVitalData, CONFIG.updateInterval);
setInterval(fetchHealthScore, 30000);
//#窗口大小变化
window.addEventListener('resize', () => {
if (renderer) {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
Object.values(charts).forEach(chart => chart?.resize());
});
}
//#启动应用
document.addEventListener('DOMContentLoaded', () => {
setTimeout(init, 100);
});
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -1,736 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>赛博朋克健康行星系统</title>
<script src="https://cdn.jsdelivr.net/npm/three@0.158.0/build/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#000;color:#0ff;font-family:'Consolas','Microsoft YaHei';overflow:hidden;height:100vh}
#canvas3d{position:fixed;top:0;left:0;width:100%;height:100%;z-index:1}
.hud-overlay{position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:100}
.planet-info{position:absolute;background:linear-gradient(145deg,rgba(0,20,40,0.95),rgba(0,255,255,0.08));border:2px solid transparent;border-radius:20px;padding:20px;backdrop-filter:blur(15px);pointer-events:auto;cursor:pointer;transition:all 0.4s cubic-bezier(0.25,0.8,0.25,1);box-shadow:0 8px 32px rgba(0,255,255,0.15),inset 0 1px 0 rgba(255,255,255,0.1);min-width:120px;transform-style:preserve-3d}
.planet-info::before{content:'';position:absolute;top:0;left:0;right:0;bottom:0;background:linear-gradient(45deg,#0ff,#0f9,#09f,#f0f);border-radius:20px;padding:2px;mask:linear-gradient(#fff 0 0) content-box,linear-gradient(#fff 0 0);mask-composite:xor;z-index:-1;opacity:0.8}
.planet-info::after{content:'';position:absolute;top:-2px;left:-2px;right:-2px;bottom:-2px;background:linear-gradient(45deg,rgba(0,255,255,0.3),rgba(0,255,157,0.3),rgba(0,157,255,0.3),rgba(255,0,255,0.3));border-radius:22px;z-index:-2;filter:blur(8px);opacity:0}
.planet-info:hover{transform:scale(1.15) translateZ(10px);box-shadow:0 20px 60px rgba(0,255,255,0.4),inset 0 1px 0 rgba(255,255,255,0.2);border-color:#0f9}
.planet-info:hover::after{opacity:1}
.planet-value{font-size:22px;font-weight:900;color:#fff;text-shadow:0 0 15px #0ff,0 0 30px #0ff;text-align:center;margin-bottom:8px;letter-spacing:1px}
.planet-label{font-size:14px;color:#0f9;text-align:center;font-weight:600;text-transform:uppercase;letter-spacing:2px;margin-bottom:10px}
.planet-status{width:24px;height:24px;border-radius:50%;margin:8px auto;position:relative;overflow:hidden}
.planet-status::before{content:'';position:absolute;top:0;left:0;width:100%;height:100%;border-radius:50%;background:inherit;filter:blur(8px);opacity:0.6}
.status-normal{background:radial-gradient(circle,#0f9,#0a6);box-shadow:0 0 20px #0f9,inset 0 0 10px rgba(255,255,255,0.3)}
.status-warning{background:radial-gradient(circle,#ff0,#fa0);box-shadow:0 0 20px #ff0,inset 0 0 10px rgba(255,255,255,0.3)}
.status-critical{background:radial-gradient(circle,#f44,#a22);box-shadow:0 0 20px #f44,inset 0 0 10px rgba(255,255,255,0.3)}
.main-title{position:fixed;top:30px;left:50%;transform:translateX(-50%);color:#0ff;font-size:28px;font-weight:bold;text-shadow:0 0 20px #0ff;z-index:200;pointer-events:none;animation:titleGlow 3s infinite}
@keyframes titleGlow{0%,100%{text-shadow:0 0 20px #0ff}50%{text-shadow:0 0 40px #0f9}}
.cyber-panel{position:fixed;background:linear-gradient(135deg,rgba(0,255,255,0.05),rgba(0,20,40,0.8));border:1px solid rgba(0,255,255,0.3);border-radius:10px;backdrop-filter:blur(15px);padding:20px;z-index:150}
.left-panel{left:20px;top:20%;width:280px;height:60vh}
.right-panel{right:20px;top:20%;width:280px;height:60vh}
.panel-title{color:#0ff;font-size:16px;font-weight:bold;text-align:center;margin-bottom:15px;text-shadow:0 0 10px #0ff}
.info-list{max-height:calc(60vh - 80px);overflow-y:auto}
.info-item{background:rgba(0,255,255,0.05);border:1px solid rgba(0,255,255,0.2);border-radius:8px;padding:12px;margin:8px 0;transition:all 0.3s ease}
.info-item:hover{background:rgba(0,255,255,0.1);transform:translateX(5px)}
.info-item-title{color:#0ff;font-size:14px;font-weight:bold}
.info-item-desc{color:#0f9;font-size:12px;margin-top:5px}
.info-item-time{color:#888;font-size:10px;margin-top:3px}
.status-bar{position:fixed;top:20px;right:20px;display:flex;gap:15px;z-index:200}
.status-indicator{width:12px;height:12px;border-radius:50%;animation:pulse 2s infinite}
.battery-display{position:fixed;top:20px;left:20px;display:flex;align-items:center;gap:10px;color:#0ff;z-index:200}
.battery-icon{width:40px;height:20px;border:2px solid #0ff;border-radius:3px;position:relative}
.battery-level{height:100%;background:linear-gradient(90deg,#f44,#ff0,#0f9);border-radius:1px;transition:width 0.3s ease}
.battery-tip{position:absolute;right:-5px;top:6px;width:3px;height:8px;background:#0ff;border-radius:0 1px 1px 0}
.chart-modal{position:fixed;top:0;left:0;width:100vw;height:100vh;background:rgba(0,0,0,0.9);display:none;z-index:3000;backdrop-filter:blur(20px)}
.chart-container{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:900px;height:600px;background:linear-gradient(135deg,rgba(0,20,40,0.95),rgba(0,255,255,0.05));border:2px solid #0ff;border-radius:20px;backdrop-filter:blur(25px);box-shadow:0 30px 80px rgba(0,20,40,0.9)}
.chart-header{padding:20px;border-bottom:1px solid rgba(0,255,255,0.3);display:flex;justify-content:space-between;align-items:center}
.chart-title{color:#0ff;font-size:24px;font-weight:bold;text-shadow:0 0 15px #0ff}
.close-btn{width:40px;height:40px;border-radius:50%;background:rgba(255,68,68,0.8);border:none;color:#fff;font-size:20px;cursor:pointer;transition:all 0.3s ease}
.close-btn:hover{background:#f44;transform:scale(1.1)}
.chart-content{display:flex;height:520px}
.chart-main{flex:2;padding:20px}
.chart-canvas{width:100%;height:100%;background:rgba(0,20,40,0.3);border-radius:10px}
.chart-sidebar{flex:1;padding:20px;border-left:1px solid rgba(0,255,255,0.3);overflow-y:auto}
.stat-card{background:rgba(0,20,40,0.8);border:1px solid rgba(0,255,255,0.3);border-radius:10px;padding:15px;margin:10px 0;transition:all 0.3s ease}
.stat-card:hover{background:rgba(0,255,255,0.05);transform:translateX(5px)}
.stat-title{color:#0ff;font-size:16px;font-weight:bold;margin-bottom:10px}
.stat-value{color:#0f9;font-size:20px;font-weight:bold;text-shadow:0 0 5px #0f9}
.stat-unit{color:#0cf;font-size:14px;margin-left:5px}
.stat-desc{color:#0cf;font-size:12px;margin-top:5px}
.baseline-info{background:rgba(0,255,255,0.05);border:1px solid rgba(0,255,255,0.3);border-radius:10px;padding:15px;margin:10px 0}
.baseline-title{color:#0ff;font-size:14px;font-weight:bold;margin-bottom:8px}
.baseline-range{color:#0f9;font-size:16px;font-weight:bold}
.loading{display:none;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#0ff;font-size:16px;z-index:1000}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.5}}
@media(max-width:1600px){.cyber-panel{width:250px}.chart-container{width:800px;height:500px}}
@media(max-width:1366px){.cyber-panel{width:220px}.chart-container{width:700px;height:450px}}
</style>
</head>
<body>
<div class="main-title">{{ BIGSCREEN_TITLE or '赛博朋克健康行星系统' }}</div>
<div class="battery-display">
<div class="battery-icon">
<div class="battery-level" id="batteryLevel"></div>
<div class="battery-tip"></div>
</div>
<span id="batteryText">0%</span>
</div>
<div class="status-bar">
<div class="status-indicator" id="onlineStatus" style="background:#0f9"></div>
<div class="status-indicator" id="chargingStatus" style="background:#ff0"></div>
<div class="status-indicator" id="wearStatus" style="background:#f44"></div>
</div>
<canvas id="canvas3d"></canvas>
<div class="hud-overlay" id="hudOverlay"></div>
<div class="cyber-panel left-panel">
<div class="panel-title">系统警报</div>
<div class="info-list" id="alertList"></div>
</div>
<div class="cyber-panel right-panel">
<div class="panel-title">数据传输</div>
<div class="info-list" id="messageList"></div>
</div>
<div class="chart-modal" id="chartModal">
<div class="chart-container">
<div class="chart-header">
<div class="chart-title" id="chartTitle">行星数据分析</div>
<button class="close-btn" onclick="closeChart()">&times;</button>
</div>
<div class="chart-content">
<div class="chart-main">
<div class="chart-canvas" id="chartCanvas"></div>
<div class="loading" id="chartLoading">数据同步中...</div>
</div>
<div class="chart-sidebar" id="chartSidebar"></div>
</div>
</div>
</div>
<script>
let scene,camera,renderer,planetSystem,controls,currentChart=null,healthPlanets=[]; // 行星系统核心变量
const PLANETS_CONFIG=[
{id:'heartRate',name:'心率',color:0xff3366,orbit:2.5,speed:0.003,size:0.15,metric:'heart_rate'},
{id:'bloodOxygen',name:'血氧',color:0x3366ff,orbit:3.2,speed:0.0025,size:0.12,metric:'blood_oxygen'},
{id:'temperature',name:'体温',color:0xff9933,orbit:3.8,speed:0.002,size:0.14,metric:'temperature'},
{id:'stress',name:'压力',color:0xff33ff,orbit:4.5,speed:0.0015,size:0.11,metric:'stress'},
{id:'steps',name:'步数',color:0x33ff66,orbit:5.2,speed:0.001,size:0.13,metric:'step'},
{id:'distance',name:'距离',color:0x66ff33,orbit:5.8,speed:0.0008,size:0.12,metric:'distance'},
{id:'calorie',name:'卡路里',color:0xffff33,orbit:6.5,speed:0.0006,size:0.14,metric:'calorie'},
{id:'sleep',name:'睡眠',color:0x33ffff,orbit:7.2,speed:0.0004,size:0.16,metric:'sleep'}
]; // 行星配置数据,更慢的旋转速度
function init3D(){
try{
// Three.js兼容性检查
if(!window.THREE){
console.error('Three.js未加载');
return;
}
console.log('Three.js版本:',THREE.REVISION);
const canvas=document.getElementById('canvas3d');
scene=new THREE.Scene();
camera=new THREE.PerspectiveCamera(60,window.innerWidth/window.innerHeight,0.1,1000);
renderer=new THREE.WebGLRenderer({canvas,alpha:true,antialias:true});
renderer.setSize(window.innerWidth,window.innerHeight);
renderer.setClearColor(0x000000,1);
renderer.shadowMap.enabled=true;
renderer.shadowMap.type=THREE.PCFSoftShadowMap;
// 赛博朋克灯光系统
const ambientLight=new THREE.AmbientLight(0x0066aa,0.3);
scene.add(ambientLight);
const mainLight=new THREE.DirectionalLight(0x00ffff,1.5);
mainLight.position.set(0,20,10);
mainLight.castShadow=true;
mainLight.shadow.mapSize.width=2048;
mainLight.shadow.mapSize.height=2048;
scene.add(mainLight);
const neonLight1=new THREE.PointLight(0xff0099,2,20);
neonLight1.position.set(10,5,0);
scene.add(neonLight1);
const neonLight2=new THREE.PointLight(0x00ff99,2,20);
neonLight2.position.set(-10,5,0);
scene.add(neonLight2);
createPlanetSystem(); // 创建行星系统
createCyberGrid(); // 创建赛博网格
createParticleField(); // 创建粒子场
camera.position.set(0,8,12);
camera.lookAt(0,0,0);
animate();
}catch(e){
console.error('初始化失败:',e);
}
}
function createPlanetSystem(){
planetSystem=new THREE.Group();
// 中央AI核心
const coreGeo=new THREE.SphereGeometry(0.8,32,32);
const coreMat=new THREE.MeshPhongMaterial({
color:0x0099ff,transparent:true,opacity:0.8,
emissive:0x003366,shininess:100
});
const core=new THREE.Mesh(coreGeo,coreMat);
core.castShadow=true;
planetSystem.add(core);
// 核心发光环
const ringGeo=new THREE.RingGeometry(1.2,1.4,64);
const ringMat=new THREE.MeshBasicMaterial({
color:0x00ffff,transparent:true,opacity:0.6,
side:THREE.DoubleSide
});
const ring=new THREE.Mesh(ringGeo,ringMat);
ring.rotation.x=-Math.PI/2;
planetSystem.add(ring);
// 创建行星和轨道
PLANETS_CONFIG.forEach((config,i)=>{
const planetGroup=new THREE.Group();
// 轨道线
const orbitGeo=new THREE.RingGeometry(config.orbit-0.02,config.orbit+0.02,128);
const orbitMat=new THREE.MeshBasicMaterial({
color:0x0099ff,transparent:true,opacity:0.3,
side:THREE.DoubleSide
});
const orbit=new THREE.Mesh(orbitGeo,orbitMat);
orbit.rotation.x=-Math.PI/2;
planetSystem.add(orbit);
// 行星本体
const planetGeo=new THREE.SphereGeometry(config.size,16,16);
const planetMat=new THREE.MeshPhongMaterial({
color:config.color,transparent:true,opacity:0.9,
emissive:config.color,emissiveIntensity:0.3,
shininess:100
});
const planet=new THREE.Mesh(planetGeo,planetMat);
planet.position.x=config.orbit;
planet.castShadow=true;
planet.userData={config,angle:i*Math.PI/4,info:null};
planetGroup.add(planet);
// 行星发光光环
const glowGeo=new THREE.RingGeometry(config.size*1.5,config.size*2,32);
const glowMat=new THREE.MeshBasicMaterial({
color:config.color,transparent:true,opacity:0.4,
side:THREE.DoubleSide
});
const glow=new THREE.Mesh(glowGeo,glowMat);
glow.position.copy(planet.position);
glow.lookAt(0,0,1);
planetGroup.add(glow);
planetSystem.add(planetGroup);
healthPlanets.push({planet,glow,group:planetGroup,config});
// 创建HUD信息面板
createPlanetHUD(config,i);
});
scene.add(planetSystem);
}
function createPlanetHUD(config,index){
const hudDiv=document.createElement('div');
hudDiv.className='planet-info';
hudDiv.id=`planet-${config.id}`;
hudDiv.innerHTML=`
<div class="planet-value" id="value-${config.id}">--</div>
<div class="planet-label">${config.name}</div>
<div class="planet-status status-normal" id="status-${config.id}"></div>
`;
hudDiv.addEventListener('click',()=>showHealthChart(config.metric));
document.getElementById('hudOverlay').appendChild(hudDiv);
}
function createCyberGrid(){
// 底面网格
const gridSize=20,divisions=40;
const gridHelper=new THREE.GridHelper(gridSize,divisions,0x0099ff,0x003366);
gridHelper.position.y=-2;
if(gridHelper.material){
gridHelper.material.transparent=true;
gridHelper.material.opacity=0.3;
}
scene.add(gridHelper);
// 竖直网格线
for(let i=0;i<8;i++){
const angle=i*Math.PI/4;
const lineGeo=new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(Math.cos(angle)*3,0,Math.sin(angle)*3),
new THREE.Vector3(Math.cos(angle)*8,0,Math.sin(angle)*8)
]);
const lineMat=new THREE.LineBasicMaterial({color:0x0099ff,transparent:true,opacity:0.5});
const line=new THREE.Line(lineGeo,lineMat);
scene.add(line);
}
// 添加动态光环
for(let i=0;i<3;i++){
const ringGeo=new THREE.RingGeometry(10+i*8,10.5+i*8,64);
const ringMat=new THREE.MeshBasicMaterial({
color:0x00ffff,transparent:true,opacity:0.1-i*0.02,side:THREE.DoubleSide
});
const ring=new THREE.Mesh(ringGeo,ringMat);
ring.rotation.x=-Math.PI/2;
ring.userData={speed:0.001+i*0.0005};
scene.add(ring);
}
// 外层旋转光束
for(let i=0;i<12;i++){
const angle=i*Math.PI/6;
const beamGeo=new THREE.PlaneGeometry(0.05,25);
const beamMat=new THREE.MeshBasicMaterial({
color:0x00aaff,transparent:true,opacity:0.3,side:THREE.DoubleSide
});
const beam=new THREE.Mesh(beamGeo,beamMat);
beam.position.set(0,0,0);
beam.rotation.y=angle;
beam.userData={speed:0.002};
scene.add(beam);
}
// 垂直能量柱
for(let i=0;i<6;i++){
const angle=i*Math.PI/3;
const pillarGeo=new THREE.CylinderGeometry(0.1,0.1,15,8);
const pillarMat=new THREE.MeshBasicMaterial({
color:0x0099ff,transparent:true,opacity:0.2
});
const pillar=new THREE.Mesh(pillarGeo,pillarMat);
pillar.position.set(Math.cos(angle)*15,7.5,Math.sin(angle)*15);
scene.add(pillar);
}
}
function createParticleField(){
// 星空粒子
const starCount=800;
const starPositions=new Float32Array(starCount*3);
const starColors=new Float32Array(starCount*3);
for(let i=0;i<starCount;i++){
starPositions[i*3]=(Math.random()-0.5)*80;
starPositions[i*3+1]=(Math.random()-0.5)*40;
starPositions[i*3+2]=(Math.random()-0.5)*80;
const color=new THREE.Color();
color.setHSL(0.5+Math.random()*0.3,0.8,0.6+Math.random()*0.4);
starColors[i*3]=color.r;
starColors[i*3+1]=color.g;
starColors[i*3+2]=color.b;
}
const starGeo=new THREE.BufferGeometry();
starGeo.setAttribute('position',new THREE.BufferAttribute(starPositions,3));
starGeo.setAttribute('color',new THREE.BufferAttribute(starColors,3));
const starMat=new THREE.PointsMaterial({
size:0.15,vertexColors:THREE.VertexColors||true,transparent:true,opacity:0.8,
blending:THREE.AdditiveBlending
});
const stars=new THREE.Points(starGeo,starMat);
scene.add(stars);
// 流动粒子
const flowCount=200;
const flowPositions=new Float32Array(flowCount*3);
const flowColors=new Float32Array(flowCount*3);
for(let i=0;i<flowCount;i++){
const angle=Math.random()*Math.PI*2;
const radius=5+Math.random()*15;
flowPositions[i*3]=Math.cos(angle)*radius;
flowPositions[i*3+1]=Math.random()*10-5;
flowPositions[i*3+2]=Math.sin(angle)*radius;
const color=new THREE.Color(0x00ffff);
flowColors[i*3]=color.r;
flowColors[i*3+1]=color.g;
flowColors[i*3+2]=color.b;
}
const flowGeo=new THREE.BufferGeometry();
flowGeo.setAttribute('position',new THREE.BufferAttribute(flowPositions,3));
flowGeo.setAttribute('color',new THREE.BufferAttribute(flowColors,3));
const flowMat=new THREE.PointsMaterial({
size:0.3,vertexColors:THREE.VertexColors||true,transparent:true,opacity:0.6,
blending:THREE.AdditiveBlending
});
const flowParticles=new THREE.Points(flowGeo,flowMat);
flowParticles.userData={type:'flow'};
scene.add(flowParticles);
// 能量波纹
for(let i=0;i<5;i++){
const waveGeo=new THREE.RingGeometry(8+i*3,8.2+i*3,64);
const waveMat=new THREE.MeshBasicMaterial({
color:0x00aaff,transparent:true,opacity:0.05,side:THREE.DoubleSide
});
const wave=new THREE.Mesh(waveGeo,waveMat);
wave.rotation.x=-Math.PI/2;
wave.position.y=-1;
wave.userData={type:'wave',speed:0.01+i*0.002,originalY:-1};
scene.add(wave);
}
}
function animate(){
requestAnimationFrame(animate);
const time=Date.now()*0.001;
// 背景动态效果
scene.children.forEach(child=>{
if(child.userData){
if(child.userData.speed&&child.geometry){
if(child.geometry.type==='RingGeometry'){
child.rotation.z+=child.userData.speed;
}else if(child.geometry.type==='PlaneGeometry'){
child.rotation.y+=child.userData.speed;
}
}
// 流动粒子动画
if(child.userData.type==='flow'){
const positions=child.geometry.attributes.position.array;
for(let i=0;i<positions.length;i+=3){
positions[i+1]+=0.02;
if(positions[i+1]>5){positions[i+1]=-5;}
}
child.geometry.attributes.position.needsUpdate=true;
}
// 能量波纹动画
if(child.userData.type==='wave'){
child.position.y=child.userData.originalY+Math.sin(time*2+child.userData.speed*100)*0.5;
child.material.opacity=0.05+Math.sin(time*3+child.userData.speed*150)*0.02;
}
}
});
if(planetSystem){
// 行星轨道运动
healthPlanets.forEach((item,i)=>{
const config=item.config;
item.planet.userData.angle+=config.speed;
const angle=item.planet.userData.angle;
item.planet.position.x=Math.cos(angle)*config.orbit;
item.planet.position.z=Math.sin(angle)*config.orbit;
item.planet.rotation.y+=0.02;
item.glow.position.copy(item.planet.position);
const cameraDir=new THREE.Vector3();
cameraDir.subVectors(camera.position,item.glow.position).normalize();
item.glow.lookAt(camera.position.x,camera.position.y,camera.position.z);
item.glow.rotation.z+=0.01;
// 更新HUD位置
updatePlanetHUDPosition(config.id,item.planet.position);
});
// 核心自转
planetSystem.children[0].rotation.y+=0.002;
planetSystem.children[1].rotation.z+=0.005;
}
renderer.render(scene,camera);
}
function updatePlanetHUDPosition(planetId,worldPos){
const hudElement=document.getElementById(`planet-${planetId}`);
if(!hudElement)return;
const vector=worldPos.clone().project(camera);
const x=(vector.x*0.5+0.5)*window.innerWidth;
const y=(-vector.y*0.5+0.5)*window.innerHeight;
hudElement.style.left=`${x-50}px`;
hudElement.style.top=`${y-40}px`;
}
function updateHealthPoint(id,value,unit='',alertLevel='normal'){
const valueEl=document.getElementById(`value-${id}`);
const statusEl=document.getElementById(`status-${id}`);
if(!valueEl||!statusEl)return;
valueEl.textContent=value+unit;
statusEl.className=`planet-status status-${alertLevel==='critical'?'critical':alertLevel==='medium'?'warning':'normal'}`;
// 更新行星效果
const planet=healthPlanets.find(p=>p.config.id===id);
if(planet){
const intensity=alertLevel==='critical'?1:alertLevel==='medium'?0.7:0.3;
planet.planet.material.emissiveIntensity=intensity;
planet.glow.material.opacity=0.4+intensity*0.4;
}
}
function updateDeviceStatus(online,charging,wearing){
document.getElementById('onlineStatus').style.background=online?'#0f9':'#f44';
document.getElementById('chargingStatus').style.background=charging?'#ff0':'#666';
document.getElementById('wearStatus').style.background=wearing?'#0f9':'#f44';
}
function updateBattery(level){
const batteryEl=document.getElementById('batteryLevel');
const textEl=document.getElementById('batteryText');
batteryEl.style.width=level+'%';
textEl.textContent=level+'%';
}
function getAlertLevel(type,value){
const thresholds={heart_rate:[60,100],blood_oxygen:[95,100],temperature:[36,37.5],stress:[0,50]};
if(!thresholds[type])return 'normal';
const[min,max]=thresholds[type];
return value<min||value>max?'critical':value<min*1.1||value>max*0.9?'medium':'normal';
}
async function showHealthChart(metric){
const modal=document.getElementById('chartModal');
const title=document.getElementById('chartTitle');
const canvas=document.getElementById('chartCanvas');
const sidebar=document.getElementById('chartSidebar');
const loading=document.getElementById('chartLoading');
modal.style.display='block';
loading.style.display='block';
const deviceSn='{{deviceSn}}';
try{
const metricNames={heart_rate:'心率',blood_oxygen:'血氧',temperature:'体温',stress:'压力',step:'步数',distance:'距离',calorie:'卡路里',sleep:'睡眠'};
title.textContent=`${metricNames[metric]||metric}行星数据分析`;
// 并行获取趋势数据和基线数据
const[trendsResponse,baselineResponse]=await Promise.all([
fetch(`./api/health/trends/${metric}?deviceSn=${deviceSn}&days=7`),
fetch(`./api/health/baseline/${metric}?deviceSn=${deviceSn}`)
]);
const trendsData=await trendsResponse.json();
const baselineData=await baselineResponse.json();
if(trendsData.success){
renderTrendChart(canvas,trendsData.data,metric);
renderSidebar(sidebar,trendsData.data,baselineData.success?baselineData.data:null);
}else{
// 使用模拟数据
renderMockChart(canvas,metric);
renderMockSidebar(sidebar,metric);
}
loading.style.display='none';
}catch(e){
console.error('API调用失败:',e);
// 使用模拟数据
renderMockChart(canvas,metric);
renderMockSidebar(sidebar,metric);
loading.style.display='none';
}
}
function closeChart(){
document.getElementById('chartModal').style.display='none';
if(currentChart){currentChart.dispose();currentChart=null;}
}
function renderAlerts(alerts){
const listEl=document.getElementById('alertList');
if(!alerts||!alerts.length){listEl.innerHTML='<div class="info-item"><div class="info-item-title">系统正常</div></div>';return;}
listEl.innerHTML=alerts.slice(0,6).map(alert=>`<div class="info-item"><div class="info-item-title">${alert.alert_type||'异常警报'}</div><div class="info-item-desc">${alert.alert_desc||'数据异常'}</div><div class="info-item-time">${alert.alert_timestamp||''}</div></div>`).join('');
}
function renderMessages(messages){
const listEl=document.getElementById('messageList');
if(!messages||!messages.length){listEl.innerHTML='<div class="info-item"><div class="info-item-title">传输正常</div></div>';return;}
listEl.innerHTML=messages.slice(0,6).map(msg=>`<div class="info-item"><div class="info-item-title">${msg.message_type||'数据包'}</div><div class="info-item-desc">${msg.message||'传输完成'}</div><div class="info-item-time">${msg.sent_time||''}</div></div>`).join('');
}
async function fetchAndUpdate(){
try{
const response=await fetch(`./get_personal_info?deviceSn={{deviceSn}}`);
const data=await response.json();
if(data?.success){
const health=data.data.health_data?.data?.healthData?.[0];
if(health){
updateHealthPoint('heartRate',health.heart_rate||'--','',getAlertLevel('heart_rate',parseInt(health.heart_rate)));
updateHealthPoint('bloodOxygen',health.blood_oxygen||'--','%',getAlertLevel('blood_oxygen',parseInt(health.blood_oxygen)));
updateHealthPoint('temperature',health.temperature||'--','°C',getAlertLevel('temperature',parseFloat(health.temperature)));
updateHealthPoint('stress',health.stress||'--','',getAlertLevel('stress',parseInt(health.stress)));
updateHealthPoint('steps',health.step||'--');
updateHealthPoint('distance',(health.distance||0).toFixed(1),'km');
updateHealthPoint('calorie',Math.round(health.calorie||0));
updateHealthPoint('sleep',(health.sleep||0).toFixed(1),'h');
}
const device=data.data.device_info?.data?.devices?.[0];
if(device){
updateBattery(device.battery_level||0);
updateDeviceStatus(device.status==='ACTIVE',device.charging_status==='CHARGING',device.wearable_status==='WORN');
}
renderAlerts(data.data.alert_info?.data?.alerts);
renderMessages(data.data.message_info?.data?.messages);
}
}catch(e){
console.error('数据获取失败:',e);
updateHealthPoint('heartRate','59','',getAlertLevel('heart_rate',59));
updateHealthPoint('bloodOxygen','99','%',getAlertLevel('blood_oxygen',99));
updateHealthPoint('temperature','36.6','°C',getAlertLevel('temperature',36.6));
updateHealthPoint('stress','29','',getAlertLevel('stress',29));
updateHealthPoint('steps','3');
updateHealthPoint('distance','23.3','km');
updateHealthPoint('calorie','19825');
updateHealthPoint('sleep','6.2','h');
updateBattery(15);
updateDeviceStatus(true,true,false);
}
}
window.addEventListener('resize',()=>{
camera.aspect=window.innerWidth/window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth,window.innerHeight);
});
document.addEventListener('DOMContentLoaded',()=>{
init3D();
fetchAndUpdate();
setInterval(fetchAndUpdate,5000);
});
function renderMockChart(container,metric){
if(currentChart)currentChart.dispose();
currentChart=echarts.init(container);
const metricConfig={
heart_rate:{unit:'次/分',color:'#ff3366',data:[65,68,72,70,69,73,71]},
blood_oxygen:{unit:'%',color:'#3366ff',data:[98,97,99,98,99,97,98]},
temperature:{unit:'°C',color:'#ff9933',data:[36.4,36.6,36.5,36.7,36.6,36.8,36.6]},
stress:{unit:'',color:'#ff33ff',data:[25,30,28,32,29,27,31]},
step:{unit:'步',color:'#33ff66',data:[8520,9200,7800,10500,8900,9600,8750]},
distance:{unit:'km',color:'#66ff33',data:[6.8,7.2,6.3,8.1,7.0,7.5,6.9]},
calorie:{unit:'千卡',color:'#ffff33',data:[2150,2300,2050,2400,2200,2350,2180]},
sleep:{unit:'h',color:'#33ffff',data:[7.2,6.8,7.5,6.9,7.1,7.3,7.0]}
};
const config=metricConfig[metric]||metricConfig.heart_rate;
const dates=['12-01','12-02','12-03','12-04','12-05','12-06','12-07'];
const option={
backgroundColor:'transparent',
title:{text:`${metric}趋势分析`,textStyle:{color:'#0ff',fontSize:20,fontWeight:'bold'},left:'center'},
tooltip:{trigger:'axis',backgroundColor:'rgba(0,20,40,0.9)',borderColor:'rgba(0,255,255,0.6)',textStyle:{color:'#fff'}},
grid:{left:60,right:40,top:80,bottom:60,borderColor:'rgba(0,255,255,0.4)'},
xAxis:{type:'category',data:dates,axisLine:{lineStyle:{color:'rgba(0,255,255,0.6)'}},axisLabel:{color:'#0cf'}},
yAxis:{type:'value',axisLine:{lineStyle:{color:'rgba(0,255,255,0.6)'}},axisLabel:{color:'#0cf'},splitLine:{lineStyle:{color:'rgba(0,255,255,0.15)'}}},
series:[{
name:metric,type:'line',data:config.data,smooth:true,symbol:'circle',symbolSize:8,
lineStyle:{color:config.color,width:3,shadowColor:config.color,shadowBlur:10},
itemStyle:{color:config.color,shadowColor:config.color,shadowBlur:5},
areaStyle:{color:{type:'linear',x:0,y:0,x2:0,y2:1,colorStops:[{offset:0,color:config.color+'90'},{offset:1,color:config.color+'30'}]}}
}]
};
currentChart.setOption(option);
}
function renderMockSidebar(container,metric){
const metricConfig={
heart_rate:{unit:'次/分',avg:69,min:65,max:73,normal:[60,100]},
blood_oxygen:{unit:'%',avg:98,min:97,max:99,normal:[95,100]},
temperature:{unit:'°C',avg:36.6,min:36.4,max:36.8,normal:[36.0,37.5]},
stress:{unit:'',avg:29,min:25,max:32,normal:[0,50]},
step:{unit:'步',avg:8900,min:7800,max:10500,normal:[6000,12000]},
distance:{unit:'km',avg:7.1,min:6.3,max:8.1,normal:[5,10]},
calorie:{unit:'千卡',avg:2230,min:2050,max:2400,normal:[1800,2500]},
sleep:{unit:'h',avg:7.1,min:6.8,max:7.5,normal:[7,9]}
};
const config=metricConfig[metric]||metricConfig.heart_rate;
container.innerHTML=`
<div class="stat-card">
<div class="stat-title">当前统计</div>
<div class="stat-value">${config.avg}<span class="stat-unit">${config.unit}</span></div>
<div class="stat-desc">7天平均值</div>
</div>
<div class="stat-card">
<div class="stat-title">数值范围</div>
<div class="stat-value">${config.min} - ${config.max}<span class="stat-unit">${config.unit}</span></div>
<div class="stat-desc">最小值到最大值</div>
</div>
<div class="baseline-info">
<div class="baseline-title">正常范围</div>
<div class="baseline-range">${config.normal[0]} - ${config.normal[1]} ${config.unit}</div>
</div>
`;
}
function renderTrendChart(container,data,metric){
if(currentChart)currentChart.dispose();
currentChart=echarts.init(container);
const trendData=data.trend_data||[];
const xAxisData=trendData.map(item=>item.date);
const seriesData=trendData.map(item=>item.value);
const normalRange=data.statistics.normal_range;
const color=data.chart_config.color;
const option={
backgroundColor:'transparent',
title:{text:data.chart_config.title,textStyle:{color:'#0ff',fontSize:20,fontWeight:'bold'},left:'center'},
tooltip:{
trigger:'axis',backgroundColor:'rgba(0,20,40,0.9)',borderColor:'rgba(0,255,255,0.6)',textStyle:{color:'#fff'},
formatter:function(params){
const point=params[0];
return `<div style="padding:12px;"><div style="color:#0ff;font-weight:bold;">${point.name}</div><div style="color:#0f9;margin-top:6px;">${point.seriesName}: ${point.value}${data.chart_config.unit}</div><div style="color:#0cf;font-size:13px;margin-top:6px;">正常范围: ${normalRange[0]}-${normalRange[1]}${data.chart_config.unit}</div></div>`;
}
},
legend:{data:[`${data.chart_config.title}值`,'正常范围'],textStyle:{color:'#0cf'},top:35},
grid:{left:70,right:50,top:90,bottom:70,borderColor:'rgba(0,255,255,0.4)'},
xAxis:{type:'category',data:xAxisData,axisLine:{lineStyle:{color:'rgba(0,255,255,0.6)'}},axisTick:{lineStyle:{color:'rgba(0,255,255,0.4)'}},axisLabel:{color:'#0cf',fontSize:13}},
yAxis:{type:'value',axisLine:{lineStyle:{color:'rgba(0,255,255,0.6)'}},axisTick:{lineStyle:{color:'rgba(0,255,255,0.4)'}},axisLabel:{color:'#0cf',fontSize:13},splitLine:{lineStyle:{color:'rgba(0,255,255,0.15)'}}},
series:[
{name:`${data.chart_config.title}值`,type:'line',data:seriesData,smooth:true,symbol:'circle',symbolSize:10,lineStyle:{color:color,width:4,shadowColor:color,shadowBlur:12},itemStyle:{color:color,shadowColor:color,shadowBlur:6},areaStyle:{color:{type:'linear',x:0,y:0,x2:0,y2:1,colorStops:[{offset:0,color:color+'90'},{offset:1,color:color+'30'}]}}},
{name:'正常范围',type:'line',data:xAxisData.map(()=>normalRange[1]),lineStyle:{color:'rgba(0,255,157,0.6)',type:'dashed'},symbol:'none',silent:true},
{name:'正常范围下限',type:'line',data:xAxisData.map(()=>normalRange[0]),lineStyle:{color:'rgba(0,255,157,0.6)',type:'dashed'},symbol:'none',silent:true,areaStyle:{color:'rgba(0,255,157,0.15)'}}
]
};
currentChart.setOption(option);
}
function renderSidebar(container,trendsData,baselineData){
const stats=trendsData.statistics;
const unit=trendsData.chart_config.unit;
let html=`<div class="stat-card"><div class="stat-title">当前统计</div><div class="stat-value">${stats.average}<span class="stat-unit">${unit}</span></div><div class="stat-desc">平均值 (${stats.count}个数据点)</div></div><div class="stat-card"><div class="stat-title">数值范围</div><div class="stat-value">${stats.min} - ${stats.max}<span class="stat-unit">${unit}</span></div><div class="stat-desc">最小值到最大值</div></div><div class="baseline-info"><div class="baseline-title">正常范围</div><div class="baseline-range">${stats.normal_range[0]} - ${stats.normal_range[1]} ${unit}</div></div>`;
if(baselineData)html+=`<div class="stat-card"><div class="stat-title">30天基线</div><div class="stat-value">${baselineData.average}<span class="stat-unit">${unit}</span></div><div class="stat-desc">基于${baselineData.data_points}个数据点</div></div><div class="baseline-info"><div class="baseline-title">个人基线范围</div><div class="baseline-range">${baselineData.percentile_25} - ${baselineData.percentile_75} ${unit}</div></div>`;
container.innerHTML=html;
}
</script>
</body>
</html>
@@ -1476,14 +1476,39 @@ def upload_health_data(health_data):
return jsonify({"status": "success", "message": "数据已接收并处理"})
def process_single_health_data(data):
print(f"🏥 process_single_health_data 接收到的数据: {data}")
print(f"🏥 数据类型: {type(data)}")
print(f"🏥 数据键: {list(data.keys()) if isinstance(data, dict) else '非字典类型'}")
# 修复数值字段解析问题:使用 is not None 判断而不是 or 操作符,避免0值被误判为空
uploadMethod = data.get("upload_method") or data.get("uploadMethod") # 默认使用wifi作为上传方式
heartRate = data.get("heart_rate") or data.get("heartRate") or data.get("xlv")
pressureHigh = data.get("blood_pressure_systolic") or data.get("pressureHigh") or data.get("gxy")
pressureLow = data.get("blood_pressure_diastolic") or data.get("pressureLow") or data.get("dxy")
bloodOxygen = data.get("blood_oxygen") or data.get("bloodOxygen") or data.get("xy")
temperature = data.get("body_temperature") or data.get("temperature") or data.get("tw")
stress = data.get("stress") or data.get("yl")
step = data.get("step") or data.get("bs")
# 心率解析 - 优先级: heart_rate > heartRate > xlv
heartRate = data.get("heart_rate") if data.get("heart_rate") is not None else (
data.get("heartRate") if data.get("heartRate") is not None else data.get("xlv")
)
# 血压解析
pressureHigh = data.get("blood_pressure_systolic") if data.get("blood_pressure_systolic") is not None else (
data.get("pressureHigh") if data.get("pressureHigh") is not None else data.get("gxy")
)
pressureLow = data.get("blood_pressure_diastolic") if data.get("blood_pressure_diastolic") is not None else (
data.get("pressureLow") if data.get("pressureLow") is not None else data.get("dxy")
)
# 血氧解析 - 修复关键问题:避免0值被or操作符跳过
bloodOxygen = data.get("blood_oxygen") if data.get("blood_oxygen") is not None else (
data.get("bloodOxygen") if data.get("bloodOxygen") is not None else data.get("xy")
)
# 体温解析
temperature = data.get("body_temperature") if data.get("body_temperature") is not None else (
data.get("temperature") if data.get("temperature") is not None else data.get("tw")
)
# 压力和步数解析
stress = data.get("stress") if data.get("stress") is not None else data.get("yl")
step = data.get("step") if data.get("step") is not None else data.get("bs")
timestamp = data.get("timestamp") or data.get("cjsj")
deviceSn = data.get("deviceSn") or data.get("id")
distance = data.get("distance") or data.get("jl")
@@ -1496,6 +1521,14 @@ def process_single_health_data(data):
exerciseDailyWeekData = data.get("exerciseDailyWeekData") or data.get("ydWeekData")
scientificSleepData = data.get("scientificSleepData") or data.get("kxsmData")
workoutData = data.get("workoutData") or data.get("ydData")
print(f"🏥 解析后的关键字段:")
print(f" - deviceSn: {deviceSn}")
print(f" - heartRate: {heartRate}")
print(f" - bloodOxygen: {bloodOxygen}")
print(f" - temperature: {temperature}")
print(f" - uploadMethod: {uploadMethod}")
print(f" - timestamp: {timestamp}")
# 处理时间戳
if isinstance(timestamp, str):
@@ -2869,4 +2902,70 @@ def get_today_health_data(orgId=None): #今日健康数据#
return get_all_health_data_optimized(orgId=orgId, startDate=today, endDate=today)
except Exception as e:
print(f"今日健康数据查询失败: {e}")
return {"success": False, "error": str(e)}
# 个人大屏实时数据API辅助函数 v1.3.5+
def get_latest_health_data_by_device(device_sn):
"""获取指定设备的最新健康数据"""
try:
# 查询最新的一条健康数据记录
latest_record = UserHealthData.query.filter_by(device_sn=device_sn).order_by(
UserHealthData.timestamp.desc()
).first()
if not latest_record:
return None
return {
'heartRate': latest_record.heart_rate,
'bloodOxygen': latest_record.blood_oxygen,
'temperature': str(latest_record.temperature) if latest_record.temperature else '0.0',
'pressureHigh': latest_record.pressure_high,
'pressureLow': latest_record.pressure_low,
'step': latest_record.step,
'distance': float(latest_record.distance) if latest_record.distance else 0.0,
'calorie': float(latest_record.calorie) if latest_record.calorie else 0.0,
'stress': latest_record.stress,
'timestamp': latest_record.timestamp,
'deviceSn': latest_record.device_sn
}
except Exception as e:
print(f"获取最新健康数据失败: {e}")
return None
def get_health_data_by_date_range(device_sn, start_time, end_time):
"""获取指定设备和时间范围的健康数据历史记录"""
try:
# 查询指定时间范围内的健康数据
records = UserHealthData.query.filter(
UserHealthData.device_sn == device_sn,
UserHealthData.timestamp >= start_time,
UserHealthData.timestamp <= end_time
).order_by(UserHealthData.timestamp.asc()).limit(100).all()
if not records:
return []
result = []
for record in records:
result.append({
'heartRate': record.heart_rate,
'bloodOxygen': record.blood_oxygen,
'temperature': str(record.temperature) if record.temperature else '0.0',
'pressureHigh': record.pressure_high,
'pressureLow': record.pressure_low,
'step': record.step,
'distance': float(record.distance) if record.distance else 0.0,
'calorie': float(record.calorie) if record.calorie else 0.0,
'stress': record.stress,
'timestamp': record.timestamp,
'deviceSn': record.device_sn
})
return result
except Exception as e:
print(f"获取健康数据历史记录失败: {e}")
return []
return {"success": False, "error": str(e)}
+21
View File
@@ -3597,3 +3597,24 @@
2025-06-19 06:35:45,316 [INFO] system: 监控指标模块初始化成功
2025-06-19 06:35:45,317 [INFO] system: 告警webhook模块初始化成功
2025-06-19 06:35:45,318 [ERROR] root: Redis ping 失败: AUTH <password> called without any password configured for the default user. Are you sure your configuration is correct?
2025-08-22 22:53:46,115 [INFO] system: 实时统计API直接路由注册成功
2025-08-22 22:53:46,116 [WARNING] system: 主路由模块导入失败: No module named 'models'
2025-08-22 22:53:46,132 [INFO] system: 设备绑定模块注册成功
2025-08-22 22:53:46,132 [WARNING] system: 监控指标模块导入失败: No module named 'prometheus_client'
2025-08-22 22:53:46,132 [INFO] system: 当前Python路径: ['/Users/bg/work/codes/springboot/ljwx/yunxiang/ljwx-bigscreen/bigscreen', '/Users/bg/work/codes/springboot/ljwx/yunxiang/ljwx-bigscreen/bigscreen', '/opt/homebrew/Cellar/python@3.13/3.13.5/Frameworks/Python.framework/Versions/3.13/lib/python313.zip']
2025-08-22 22:53:46,133 [INFO] system: 告警webhook模块初始化成功
2025-08-22 22:53:46,135 [ERROR] root: Redis ping 失败: AUTH <password> called without any password configured for the default user. Are you sure your configuration is correct?
2025-08-22 22:56:21,265 [INFO] system: 实时统计API直接路由注册成功
2025-08-22 22:56:21,266 [WARNING] system: 主路由模块导入失败: No module named 'models'
2025-08-22 22:56:21,282 [INFO] system: 设备绑定模块注册成功
2025-08-22 22:56:21,283 [WARNING] system: 监控指标模块导入失败: No module named 'prometheus_client'
2025-08-22 22:56:21,283 [INFO] system: 当前Python路径: ['/Users/bg/work/codes/springboot/ljwx/yunxiang/ljwx-bigscreen/bigscreen', '/Users/bg/work/codes/springboot/ljwx/yunxiang/ljwx-bigscreen/bigscreen', '/opt/homebrew/Cellar/python@3.13/3.13.5/Frameworks/Python.framework/Versions/3.13/lib/python313.zip']
2025-08-22 22:56:21,284 [INFO] system: 告警webhook模块初始化成功
2025-08-22 22:56:21,285 [ERROR] root: Redis ping 失败: AUTH <password> called without any password configured for the default user. Are you sure your configuration is correct?
2025-08-26 19:35:03,795 [INFO] system: 实时统计API直接路由注册成功
2025-08-26 19:35:03,797 [WARNING] system: 主路由模块导入失败: No module named 'models'
2025-08-26 19:35:03,825 [INFO] system: 设备绑定模块注册成功
2025-08-26 19:35:03,826 [WARNING] system: 监控指标模块导入失败: No module named 'prometheus_client'
2025-08-26 19:35:03,826 [INFO] system: 当前Python路径: ['/Users/bg/work/codes/springboot/ljwx/yunxiang/ljwx-bigscreen/bigscreen', '', '/opt/homebrew/Cellar/python@3.13/3.13.5/Frameworks/Python.framework/Versions/3.13/lib/python313.zip']
2025-08-26 19:35:03,827 [INFO] system: 告警webhook模块初始化成功
2025-08-26 19:35:03,828 [ERROR] root: Redis ping 失败: AUTH <password> called without any password configured for the default user. Are you sure your configuration is correct?
+21
View File
@@ -3597,3 +3597,24 @@
2025-06-19 06:35:45,316 [INFO] system: 监控指标模块初始化成功
2025-06-19 06:35:45,317 [INFO] system: 告警webhook模块初始化成功
2025-06-19 06:35:45,318 [ERROR] root: Redis ping 失败: AUTH <password> called without any password configured for the default user. Are you sure your configuration is correct?
2025-08-22 22:53:46,115 [INFO] system: 实时统计API直接路由注册成功
2025-08-22 22:53:46,116 [WARNING] system: 主路由模块导入失败: No module named 'models'
2025-08-22 22:53:46,132 [INFO] system: 设备绑定模块注册成功
2025-08-22 22:53:46,132 [WARNING] system: 监控指标模块导入失败: No module named 'prometheus_client'
2025-08-22 22:53:46,132 [INFO] system: 当前Python路径: ['/Users/bg/work/codes/springboot/ljwx/yunxiang/ljwx-bigscreen/bigscreen', '/Users/bg/work/codes/springboot/ljwx/yunxiang/ljwx-bigscreen/bigscreen', '/opt/homebrew/Cellar/python@3.13/3.13.5/Frameworks/Python.framework/Versions/3.13/lib/python313.zip']
2025-08-22 22:53:46,133 [INFO] system: 告警webhook模块初始化成功
2025-08-22 22:53:46,135 [ERROR] root: Redis ping 失败: AUTH <password> called without any password configured for the default user. Are you sure your configuration is correct?
2025-08-22 22:56:21,265 [INFO] system: 实时统计API直接路由注册成功
2025-08-22 22:56:21,266 [WARNING] system: 主路由模块导入失败: No module named 'models'
2025-08-22 22:56:21,282 [INFO] system: 设备绑定模块注册成功
2025-08-22 22:56:21,283 [WARNING] system: 监控指标模块导入失败: No module named 'prometheus_client'
2025-08-22 22:56:21,283 [INFO] system: 当前Python路径: ['/Users/bg/work/codes/springboot/ljwx/yunxiang/ljwx-bigscreen/bigscreen', '/Users/bg/work/codes/springboot/ljwx/yunxiang/ljwx-bigscreen/bigscreen', '/opt/homebrew/Cellar/python@3.13/3.13.5/Frameworks/Python.framework/Versions/3.13/lib/python313.zip']
2025-08-22 22:56:21,284 [INFO] system: 告警webhook模块初始化成功
2025-08-22 22:56:21,285 [ERROR] root: Redis ping 失败: AUTH <password> called without any password configured for the default user. Are you sure your configuration is correct?
2025-08-26 19:35:03,795 [INFO] system: 实时统计API直接路由注册成功
2025-08-26 19:35:03,797 [WARNING] system: 主路由模块导入失败: No module named 'models'
2025-08-26 19:35:03,825 [INFO] system: 设备绑定模块注册成功
2025-08-26 19:35:03,826 [WARNING] system: 监控指标模块导入失败: No module named 'prometheus_client'
2025-08-26 19:35:03,826 [INFO] system: 当前Python路径: ['/Users/bg/work/codes/springboot/ljwx/yunxiang/ljwx-bigscreen/bigscreen', '', '/opt/homebrew/Cellar/python@3.13/3.13.5/Frameworks/Python.framework/Versions/3.13/lib/python313.zip']
2025-08-26 19:35:03,827 [INFO] system: 告警webhook模块初始化成功
2025-08-26 19:35:03,828 [ERROR] root: Redis ping 失败: AUTH <password> called without any password configured for the default user. Are you sure your configuration is correct?
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""
测试 upload_common_event 接口的调试脚本
模拟手表端发送的实际数据结构
"""
import requests
import json
import time
BASE_URL = "http://localhost:5001"
# 模拟手表端发送的 upload_common_event 数据
# 基于之前观察到的实际数据结构
test_data = {
"eventType": "com.tdtech.ohos.action.WEAR_STATUS_CHANGED",
"eventValue": "1",
"deviceSn": "CRFTQ23409001890",
"timestamp": "2025-08-24 16:25:00",
"latitude": 22.540281,
"longitude": 114.015168,
"altitude": 0,
# 这是关键的健康数据部分 - 使用我们修改后的格式
"healthData": {
"data": {
"deviceSn": "CRFTQ23409001890",
"heart_rate": 75,
"blood_oxygen": 98,
"body_temperature": "36.5",
"step": 1250,
"distance": "0.8",
"calorie": "45.2",
"latitude": "22.540281",
"longitude": "114.015168",
"altitude": "0",
"stress": 30,
"upload_method": "common_event", # 设置为 common_event
"blood_pressure_systolic": 120,
"blood_pressure_diastolic": 80,
"sleepData": "null",
"exerciseDailyData": "null",
"exerciseWeekData": "null",
"scientificSleepData": "null",
"workoutData": "null",
"timestamp": "2025-08-24 16:25:00"
}
}
}
def test_upload_common_event():
"""测试上传通用事件"""
print("🚀 开始测试 upload_common_event 接口...")
print(f"📡 发送数据: {json.dumps(test_data, ensure_ascii=False, indent=2)}")
try:
response = requests.post(
f"{BASE_URL}/upload_common_event",
json=test_data,
headers={"Content-Type": "application/json"},
timeout=10
)
print(f"📊 响应状态码: {response.status_code}")
print(f"📊 响应头: {dict(response.headers)}")
try:
response_data = response.json()
print(f"📊 响应数据: {json.dumps(response_data, ensure_ascii=False, indent=2)}")
except:
print(f"📊 响应内容 (非JSON): {response.text}")
if response.status_code == 200:
print("✅ 请求成功发送")
return True
else:
print(f"❌ 请求失败: {response.status_code}")
return False
except requests.exceptions.RequestException as e:
print(f"❌ 网络请求失败: {e}")
return False
def test_alternative_structure():
"""测试替代的数据结构(如果第一种格式不正确)"""
print("\n🔄 测试替代数据结构...")
# 尝试不嵌套 data 的结构
alt_data = test_data.copy()
alt_data["healthData"] = {
"deviceSn": "CRFTQ23409001890",
"heart_rate": 82,
"blood_oxygen": 97,
"body_temperature": "36.8",
"step": 1500,
"upload_method": "common_event",
"timestamp": "2025-08-24 16:26:00"
}
print(f"📡 发送替代数据: {json.dumps(alt_data, ensure_ascii=False, indent=2)}")
try:
response = requests.post(
f"{BASE_URL}/upload_common_event",
json=alt_data,
headers={"Content-Type": "application/json"},
timeout=10
)
print(f"📊 替代测试响应状态码: {response.status_code}")
try:
response_data = response.json()
print(f"📊 替代测试响应: {json.dumps(response_data, ensure_ascii=False, indent=2)}")
except:
print(f"📊 替代测试响应内容: {response.text}")
except requests.exceptions.RequestException as e:
print(f"❌ 替代测试失败: {e}")
if __name__ == "__main__":
print("=" * 60)
print("🧪 upload_common_event 调试测试")
print("=" * 60)
# 测试主要数据结构
success = test_upload_common_event()
# 等待一秒后测试替代结构
time.sleep(1)
test_alternative_structure()
print("\n" + "=" * 60)
print("💡 请查看大屏系统控制台输出的调试信息")
print("💡 关注以 🏥 开头的健康数据处理日志")
print("=" * 60)
@@ -0,0 +1,76 @@
/*
* All Rights Reserved: Copyright [2024] [Zhuang Pan (brunoGao@gmail.com)]
* Open Source Agreement: Apache License, Version 2.0
*/
package com.ljwx.admin.controller.health;
import com.ljwx.modules.health.domain.dto.device.bind.BatchApproveBindingDTO;
import com.ljwx.modules.health.domain.dto.device.bind.CheckDeviceBindingDTO;
import com.ljwx.modules.health.domain.dto.device.bind.SubmitBindingApplicationDTO;
import com.ljwx.modules.health.service.DeviceBindService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
import java.util.Map;
/**
* 设备绑定管理 REST 控制器
*
* @Author Claude Code
* @ProjectName ljwx-boot
* @ClassName com.ljwx.admin.controller.health.DeviceBindController
* @CreateTime 2025-08-23
*/
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/device")
@Tag(name = "设备绑定管理", description = "设备绑定申请与审批管理接口")
public class DeviceBindController {
private final DeviceBindService deviceBindService;
@PostMapping("/check_binding")
@Operation(summary = "检查设备绑定状态", description = "检查指定设备是否已绑定或有待审批申请")
public Map<String, Object> checkDeviceBinding(@Valid @RequestBody CheckDeviceBindingDTO dto) {
log.info("接收到检查设备绑定状态请求: {}", dto);
return deviceBindService.checkDeviceBinding(dto);
}
@PostMapping("/binding_application")
@Operation(summary = "提交设备绑定申请", description = "用户提交设备绑定申请")
public Map<String, Object> submitBindingApplication(@Valid @RequestBody SubmitBindingApplicationDTO dto) {
log.info("接收到提交设备绑定申请请求: {}", dto);
return deviceBindService.submitBindingApplication(dto);
}
@PostMapping("/batch_approve")
@Operation(summary = "批量审批绑定申请", description = "管理员批量审批设备绑定申请")
public Map<String, Object> batchApprove(@Valid @RequestBody BatchApproveBindingDTO dto) {
log.info("接收到批量审批绑定申请请求: {}", dto);
return deviceBindService.batchApprove(dto);
}
@GetMapping("/applications")
@Operation(summary = "获取绑定申请列表", description = "分页获取设备绑定申请列表")
public Map<String, Object> getBindingApplications(
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "1") Integer pageNum,
@RequestParam(defaultValue = "10") Integer pageSize) {
log.info("获取绑定申请列表 - 状态: {}, 页码: {}, 大小: {}", status, pageNum, pageSize);
return deviceBindService.getBindingApplications(status, pageNum, pageSize);
}
@GetMapping("/user_bindings")
@Operation(summary = "获取用户设备绑定列表", description = "获取指定用户的设备绑定状态")
public Map<String, Object> getUserBindings(@RequestParam String userId) {
log.info("获取用户设备绑定列表 - 用户ID: {}", userId);
return deviceBindService.getUserBindings(userId);
}
}
@@ -0,0 +1,49 @@
/*
* All Rights Reserved: Copyright [2024] [Zhuang Pan (brunoGao@gmail.com)]
* Open Source Agreement: Apache License, Version 2.0
*/
package com.ljwx.modules.health.domain.dto.device.bind;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import java.util.List;
/**
* 批量审批设备绑定申请 DTO 数据传输类
*
* @Author Claude Code
* @ProjectName ljwx-boot
* @ClassName com.ljwx.modules.health.domain.dto.device.bind.BatchApproveBindingDTO
* @CreateTime 2025-08-23
*/
@Data
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "批量审批设备绑定申请 DTO")
public class BatchApproveBindingDTO {
@Schema(description = "申请ID列表", requiredMode = Schema.RequiredMode.REQUIRED)
@NotEmpty(message = "申请ID列表不能为空")
private List<String> ids;
@Schema(description = "审批操作 (APPROVED:通过, REJECTED:拒绝)", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "审批操作不能为空")
private String action;
@Schema(description = "审批人ID", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "审批人ID不能为空")
private String approverId;
@Schema(description = "审批备注")
private String comment;
}
@@ -0,0 +1,39 @@
/*
* All Rights Reserved: Copyright [2024] [Zhuang Pan (brunoGao@gmail.com)]
* Open Source Agreement: Apache License, Version 2.0
*/
package com.ljwx.modules.health.domain.dto.device.bind;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
import jakarta.validation.constraints.NotBlank;
/**
* 检查设备绑定状态 DTO 数据传输类
*
* @Author Claude Code
* @ProjectName ljwx-boot
* @ClassName com.ljwx.modules.health.domain.dto.device.bind.CheckDeviceBindingDTO
* @CreateTime 2025-08-23
*/
@Data
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "检查设备绑定状态 DTO")
public class CheckDeviceBindingDTO {
@Schema(description = "设备序列号", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "设备序列号不能为空")
private String serialNumber;
@Schema(description = "手机号码", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "手机号码不能为空")
private String phoneNumber;
}
@@ -0,0 +1,46 @@
/*
* All Rights Reserved: Copyright [2024] [Zhuang Pan (brunoGao@gmail.com)]
* Open Source Agreement: Apache License, Version 2.0
*/
package com.ljwx.modules.health.domain.dto.device.bind;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
import jakarta.validation.constraints.NotBlank;
/**
* 提交设备绑定申请 DTO 数据传输类
*
* @Author Claude Code
* @ProjectName ljwx-boot
* @ClassName com.ljwx.modules.health.domain.dto.device.bind.SubmitBindingApplicationDTO
* @CreateTime 2025-08-23
*/
@Data
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "提交设备绑定申请 DTO")
public class SubmitBindingApplicationDTO {
@Schema(description = "设备序列号", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "设备序列号不能为空")
private String deviceSn;
@Schema(description = "手机号码", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "手机号码不能为空")
private String phoneNumber;
@Schema(description = "申请用户ID", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "申请用户ID不能为空")
private String userId;
@Schema(description = "申请时间戳")
private String timestamp;
}
@@ -0,0 +1,106 @@
/*
* All Rights Reserved: Copyright [2024] [Zhuang Pan (brunoGao@gmail.com)]
* Open Source Agreement: Apache License, Version 2.0
* For educational purposes only, commercial use shall comply with the author's copyright information.
* The author does not guarantee or assume any responsibility for the risks of using software.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.ljwx.modules.health.domain.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.ljwx.infrastructure.domain.BaseEntity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
import java.time.LocalDateTime;
/**
* 设备绑定申请表 Entity 实体类
*
* @Author Claude Code
* @ProjectName ljwx-boot
* @ClassName com.ljwx.modules.health.domain.entity.TDeviceBindRequest
* @CreateTime 2025-08-23
*/
@Data
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@TableName("t_device_bind_request")
public class TDeviceBindRequest extends BaseEntity {
/**
* 设备序列号
*/
private String deviceSn;
/**
* 申请用户ID
*/
private String userId;
/**
* 申请用户姓名
*/
private String userName;
/**
* 手机号码
*/
private String phoneNumber;
/**
* 组织ID
*/
private String orgId;
/**
* 申请时间
*/
private LocalDateTime applyTime;
/**
* 审批时间
*/
private LocalDateTime approveTime;
/**
* 审批人ID
*/
private String approverId;
/**
* 审批人姓名
*/
private String approverName;
/**
* 申请状态 (PENDING:待审批, APPROVED:已通过, REJECTED:已拒绝)
*/
private String status;
/**
* 审批备注
*/
private String comment;
/**
* 是否删除 (0:未删除, 1:已删除)
*/
private Integer isDeleted;
}
@@ -0,0 +1,24 @@
/*
* All Rights Reserved: Copyright [2024] [Zhuang Pan (brunoGao@gmail.com)]
* Open Source Agreement: Apache License, Version 2.0
*/
package com.ljwx.modules.health.repository.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ljwx.modules.health.domain.entity.TDeviceBindRequest;
import org.apache.ibatis.annotations.Mapper;
/**
* 设备绑定申请表 Mapper 数据访问类
*
* @Author Claude Code
* @ProjectName ljwx-boot
* @ClassName com.ljwx.modules.health.repository.mapper.TDeviceBindRequestMapper
* @CreateTime 2025-08-23
*/
@Mapper
public interface TDeviceBindRequestMapper extends BaseMapper<TDeviceBindRequest> {
}
@@ -0,0 +1,60 @@
/*
* All Rights Reserved: Copyright [2024] [Zhuang Pan (brunoGao@gmail.com)]
* Open Source Agreement: Apache License, Version 2.0
*/
package com.ljwx.modules.health.service;
import com.ljwx.modules.health.domain.dto.device.bind.BatchApproveBindingDTO;
import com.ljwx.modules.health.domain.dto.device.bind.CheckDeviceBindingDTO;
import com.ljwx.modules.health.domain.dto.device.bind.SubmitBindingApplicationDTO;
import java.util.Map;
/**
* 设备绑定服务接口
*
* @Author Claude Code
* @ProjectName ljwx-boot
* @ClassName com.ljwx.modules.health.service.DeviceBindService
* @CreateTime 2025-08-23
*/
public interface DeviceBindService {
/**
* 检查设备绑定状态
* @param dto 检查绑定状态参数
* @return 绑定状态信息
*/
Map<String, Object> checkDeviceBinding(CheckDeviceBindingDTO dto);
/**
* 提交设备绑定申请
* @param dto 绑定申请参数
* @return 提交结果
*/
Map<String, Object> submitBindingApplication(SubmitBindingApplicationDTO dto);
/**
* 批量审批绑定申请
* @param dto 批量审批参数
* @return 审批结果
*/
Map<String, Object> batchApprove(BatchApproveBindingDTO dto);
/**
* 获取绑定申请列表
* @param status 申请状态
* @param pageNum 页码
* @param pageSize 页大小
* @return 申请列表
*/
Map<String, Object> getBindingApplications(String status, Integer pageNum, Integer pageSize);
/**
* 获取用户设备绑定状态
* @param userId 用户ID
* @return 绑定状态列表
*/
Map<String, Object> getUserBindings(String userId);
}
@@ -0,0 +1,295 @@
/*
* All Rights Reserved: Copyright [2024] [Zhuang Pan (brunoGao@gmail.com)]
* Open Source Agreement: Apache License, Version 2.0
*/
package com.ljwx.modules.health.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ljwx.modules.health.domain.dto.device.bind.BatchApproveBindingDTO;
import com.ljwx.modules.health.domain.dto.device.bind.CheckDeviceBindingDTO;
import com.ljwx.modules.health.domain.dto.device.bind.SubmitBindingApplicationDTO;
import com.ljwx.modules.health.domain.entity.TDeviceBindRequest;
import com.ljwx.modules.health.domain.entity.TDeviceInfo;
import com.ljwx.modules.health.domain.entity.TDeviceUser;
import com.ljwx.modules.health.repository.mapper.TDeviceBindRequestMapper;
import com.ljwx.modules.health.repository.mapper.TDeviceInfoMapper;
import com.ljwx.modules.health.repository.mapper.TDeviceUserMapper;
import com.ljwx.modules.health.service.DeviceBindService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.*;
/**
* 设备绑定服务实现类
*
* @Author Claude Code
* @ProjectName ljwx-boot
* @ClassName com.ljwx.modules.health.service.impl.DeviceBindServiceImpl
* @CreateTime 2025-08-23
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class DeviceBindServiceImpl implements DeviceBindService {
private final TDeviceBindRequestMapper deviceBindRequestMapper;
private final TDeviceInfoMapper deviceInfoMapper;
private final TDeviceUserMapper deviceUserMapper;
@Override
public Map<String, Object> checkDeviceBinding(CheckDeviceBindingDTO dto) {
Map<String, Object> result = new HashMap<>();
try {
log.info("检查设备绑定状态 - 设备序列号: {}, 手机号: {}", dto.getSerialNumber(), dto.getPhoneNumber());
// 1. 查询设备信息
QueryWrapper<TDeviceInfo> deviceQuery = new QueryWrapper<>();
deviceQuery.eq("device_sn", dto.getSerialNumber())
.eq("is_deleted", 0);
TDeviceInfo deviceInfo = deviceInfoMapper.selectOne(deviceQuery);
if (deviceInfo == null) {
result.put("success", false);
result.put("error", "设备不存在");
result.put("exists", false);
return result;
}
// 2. 检查是否已绑定
QueryWrapper<TDeviceUser> userQuery = new QueryWrapper<>();
userQuery.eq("device_sn", dto.getSerialNumber())
.eq("status", "BIND")
.eq("is_deleted", 0);
TDeviceUser deviceUser = deviceUserMapper.selectOne(userQuery);
if (deviceUser != null) {
result.put("success", true);
result.put("exists", true);
result.put("bound", true);
result.put("user_id", deviceUser.getUserId());
log.info("设备已绑定给用户: {}", deviceUser.getUserId());
return result;
}
// 3. 检查是否有待审批申请
QueryWrapper<TDeviceBindRequest> requestQuery = new QueryWrapper<>();
requestQuery.eq("device_sn", dto.getSerialNumber())
.eq("status", "PENDING")
.eq("is_deleted", 0);
TDeviceBindRequest pendingRequest = deviceBindRequestMapper.selectOne(requestQuery);
result.put("success", true);
result.put("exists", pendingRequest != null);
result.put("bound", false);
result.put("pending", pendingRequest != null);
if (pendingRequest != null) {
log.info("设备有待审批申请: {}", pendingRequest.getId());
} else {
log.info("设备未绑定且无待审批申请");
}
} catch (Exception e) {
log.error("检查设备绑定状态失败", e);
result.put("success", false);
result.put("error", "查询失败: " + e.getMessage());
}
return result;
}
@Override
@Transactional
public Map<String, Object> submitBindingApplication(SubmitBindingApplicationDTO dto) {
Map<String, Object> result = new HashMap<>();
try {
log.info("提交设备绑定申请 - 设备序列号: {}, 用户ID: {}, 手机号: {}",
dto.getDeviceSn(), dto.getUserId(), dto.getPhoneNumber());
// 1. 检查是否已有待审批申请
QueryWrapper<TDeviceBindRequest> existingQuery = new QueryWrapper<>();
existingQuery.eq("device_sn", dto.getDeviceSn())
.eq("status", "PENDING")
.eq("is_deleted", 0);
TDeviceBindRequest existing = deviceBindRequestMapper.selectOne(existingQuery);
if (existing != null) {
result.put("success", false);
result.put("error", "已有待审批申请,请等待处理");
return result;
}
// 2. 创建新申请
TDeviceBindRequest request = TDeviceBindRequest.builder()
.deviceSn(dto.getDeviceSn())
.userId(dto.getUserId())
.phoneNumber(dto.getPhoneNumber())
.applyTime(LocalDateTime.now())
.status("PENDING")
.isDeleted(0)
.build();
int insertResult = deviceBindRequestMapper.insert(request);
if (insertResult > 0) {
result.put("success", true);
result.put("message", "申请提交成功");
result.put("request_id", request.getId());
log.info("绑定申请提交成功,申请ID: {}", request.getId());
} else {
result.put("success", false);
result.put("error", "提交申请失败");
}
} catch (Exception e) {
log.error("提交设备绑定申请失败", e);
result.put("success", false);
result.put("error", "提交失败: " + e.getMessage());
}
return result;
}
@Override
@Transactional
public Map<String, Object> batchApprove(BatchApproveBindingDTO dto) {
Map<String, Object> result = new HashMap<>();
try {
log.info("批量审批绑定申请 - 操作: {}, 申请数量: {}", dto.getAction(), dto.getIds().size());
List<String> successIds = new ArrayList<>();
List<String> failedIds = new ArrayList<>();
for (String requestId : dto.getIds()) {
try {
// 查询申请记录
TDeviceBindRequest request = deviceBindRequestMapper.selectById(requestId);
if (request == null || !"PENDING".equals(request.getStatus())) {
failedIds.add(requestId);
continue;
}
// 更新申请状态
request.setStatus(dto.getAction());
request.setApproveTime(LocalDateTime.now());
request.setApproverId(dto.getApproverId());
request.setComment(dto.getComment());
deviceBindRequestMapper.updateById(request);
// 如果是通过,创建绑定关系
if ("APPROVED".equals(dto.getAction())) {
TDeviceUser deviceUser = TDeviceUser.builder()
.deviceSn(request.getDeviceSn())
.userId(request.getUserId())
.userName(request.getUserName())
.operateTime(LocalDateTime.now())
.status("BIND")
.isDeleted(0)
.build();
deviceUserMapper.insert(deviceUser);
log.info("创建设备绑定关系 - 设备: {}, 用户: {}", request.getDeviceSn(), request.getUserId());
}
successIds.add(requestId);
} catch (Exception e) {
log.error("处理申请失败,申请ID: {}", requestId, e);
failedIds.add(requestId);
}
}
result.put("success", true);
result.put("message", String.format("处理完成 - 成功: %d, 失败: %d",
successIds.size(), failedIds.size()));
result.put("success_ids", successIds);
result.put("failed_ids", failedIds);
log.info("批量审批完成 - 成功: {}, 失败: {}", successIds.size(), failedIds.size());
} catch (Exception e) {
log.error("批量审批绑定申请失败", e);
result.put("success", false);
result.put("error", "批量审批失败: " + e.getMessage());
}
return result;
}
@Override
public Map<String, Object> getBindingApplications(String status, Integer pageNum, Integer pageSize) {
Map<String, Object> result = new HashMap<>();
try {
Page<TDeviceBindRequest> page = new Page<>(
pageNum != null ? pageNum : 1,
pageSize != null ? pageSize : 10
);
QueryWrapper<TDeviceBindRequest> query = new QueryWrapper<>();
query.eq("is_deleted", 0);
if (status != null && !status.isEmpty()) {
query.eq("status", status);
}
query.orderByDesc("apply_time");
IPage<TDeviceBindRequest> pageResult = deviceBindRequestMapper.selectPage(page, query);
result.put("success", true);
result.put("data", Map.of(
"items", pageResult.getRecords(),
"total", pageResult.getTotal(),
"page", pageResult.getCurrent(),
"size", pageResult.getSize(),
"pages", pageResult.getPages()
));
log.info("获取绑定申请列表 - 状态: {}, 总数: {}", status, pageResult.getTotal());
} catch (Exception e) {
log.error("获取绑定申请列表失败", e);
result.put("success", false);
result.put("error", "获取申请列表失败: " + e.getMessage());
}
return result;
}
@Override
public Map<String, Object> getUserBindings(String userId) {
Map<String, Object> result = new HashMap<>();
try {
QueryWrapper<TDeviceUser> query = new QueryWrapper<>();
query.eq("user_id", userId)
.eq("status", "BIND")
.eq("is_deleted", 0);
List<TDeviceUser> bindings = deviceUserMapper.selectList(query);
result.put("success", true);
result.put("data", bindings);
log.info("获取用户绑定设备列表 - 用户ID: {}, 绑定数量: {}", userId, bindings.size());
} catch (Exception e) {
log.error("获取用户绑定设备失败", e);
result.put("success", false);
result.put("error", "获取绑定设备失败: " + e.getMessage());
}
return result;
}
}
@@ -133,10 +133,14 @@ public class OrgStatisticsServiceImpl implements IOrgStatisticsService {
vo.setUserName(userInfo.getUserName());
}
// 统计
statusCounts.merge(alert.getAlertStatus(), 1, Integer::sum);
typeCounts.merge(alert.getAlertType(), 1, Integer::sum);
severityCounts.merge(alert.getSeverityLevel(), 1, Integer::sum);
// 统计 - 处理null值,避免JSON序列化错误
String alertStatus = alert.getAlertStatus() != null ? alert.getAlertStatus() : "UNKNOWN";
String alertType = alert.getAlertType() != null ? alert.getAlertType() : "UNKNOWN";
String severityLevel = alert.getSeverityLevel() != null ? alert.getSeverityLevel() : "UNKNOWN";
statusCounts.merge(alertStatus, 1, Integer::sum);
typeCounts.merge(alertType, 1, Integer::sum);
severityCounts.merge(severityLevel, 1, Integer::sum);
return vo;
})
@@ -209,11 +213,16 @@ public class OrgStatisticsServiceImpl implements IOrgStatisticsService {
vo.setSystemSoftwareVersion(device.getSystemSoftwareVersion());
vo.setWearableStatus(device.getWearableStatus());
// 统计
chargingCounts.merge(device.getChargingStatus(), 1, Integer::sum);
osCounts.merge(device.getSystemSoftwareVersion(), 1, Integer::sum);
statusCounts.merge(device.getStatus(), 1, Integer::sum);
wearableCounts.merge(device.getWearableStatus(), 1, Integer::sum);
// 统计 - 处理null值,避免JSON序列化错误
String chargingStatus = device.getChargingStatus() != null ? device.getChargingStatus() : "UNKNOWN";
String systemVersion = device.getSystemSoftwareVersion() != null ? device.getSystemSoftwareVersion() : "UNKNOWN";
String status = device.getStatus() != null ? device.getStatus() : "UNKNOWN";
String wearableStatus = device.getWearableStatus() != null ? device.getWearableStatus() : "UNKNOWN";
chargingCounts.merge(chargingStatus, 1, Integer::sum);
osCounts.merge(systemVersion, 1, Integer::sum);
statusCounts.merge(status, 1, Integer::sum);
wearableCounts.merge(wearableStatus, 1, Integer::sum);
return vo;
})
@@ -320,9 +329,12 @@ public class OrgStatisticsServiceImpl implements IOrgStatisticsService {
vo.setReceivedTime(message.getReceivedTime());
vo.setSentTime(message.getSentTime());
// 统计
statusCounts.merge(message.getMessageStatus(), 1, Integer::sum);
typeCounts.merge(message.getMessageType(), 1, Integer::sum);
// 统计 - 处理null值,避免JSON序列化错误
String messageStatus = message.getMessageStatus() != null ? message.getMessageStatus() : "UNKNOWN";
String messageType = message.getMessageType() != null ? message.getMessageType() : "UNKNOWN";
statusCounts.merge(messageStatus, 1, Integer::sum);
typeCounts.merge(messageType, 1, Integer::sum);
return vo;
})
@@ -422,9 +434,12 @@ public class OrgStatisticsServiceImpl implements IOrgStatisticsService {
vo.setOrgName(orgNameMap.get(userOrgId));
}
// 统计
deviceBindCounts.merge(vo.getBindStatus(), 1, Integer::sum);
userStatusCounts.merge(vo.getStatus(), 1, Integer::sum);
// 统计 - 处理null值,避免JSON序列化错误
String bindStatus = vo.getBindStatus() != null ? vo.getBindStatus() : "UNKNOWN";
String userStatus = vo.getStatus() != null ? vo.getStatus() : "UNKNOWN";
deviceBindCounts.merge(bindStatus, 1, Integer::sum);
userStatusCounts.merge(userStatus, 1, Integer::sum);
return vo;
})
@@ -0,0 +1,578 @@
# 客户需求技术实施方案 📋
> 基于当前代码架构分析的四个客户需求落地方案,按实施难度和优先级排序
## 📊 需求概览与优先级
| 需求编号 | 功能描述 | 工期(人/天) | 实施难度 | 优先级 | 技术风险 |
|---------|----------|-------------|----------|--------|----------|
| **需求1** | Excel批量导入用户及设备绑定 | 10 | ⭐⭐ | 🔥🔥🔥 | 低 |
| **需求4** | 手机端管理员权限分级查看 | 15 | ⭐⭐ | 🔥🔥🔥 | 低 |
| **需求2** | 批量处理告警功能 | 5 | ⭐ | 🔥🔥 | 极低 |
| **需求3** | 告警通知系统重构 | 40 | ⭐⭐⭐⭐ | 🔥🔥🔥🔥 | 中 |
## 🎯 **需求1: Excel批量导入用户及设备绑定**
### 📈 实施难度: ⭐⭐ (简单)
### ⏱ 工期估算: 10人/天
### 🚨 技术风险: 低
### 现状分析
**现有基础设施** ✅
- 已有`EmployeeController`支持员工管理
- 已有`SysUserController`和`SysOrgUnitsController`支持用户和组织管理
- 已有`TDeviceUserController`支持设备用户绑定
### 技术实施方案
#### 1.1 后端实现 (6人/天)
**新增Controller接口**:
```java
@RestController
@RequestMapping("/import")
public class ImportController {
@PostMapping("/users/excel")
@SaCheckPermission("import:users:excel")
public Result<ImportResultVO> importUsersFromExcel(
@RequestParam("file") MultipartFile file) {
// Excel解析和批量导入逻辑
}
@GetMapping("/template/download")
public ResponseEntity<Resource> downloadTemplate() {
// 下载Excel导入模板
}
}
```
**核心服务实现**:
```java
@Service
public class ExcelImportService {
public ImportResultVO importUsersWithDevices(MultipartFile file) {
// 1. 验证Excel格式和字段
// 2. 验证部门和岗位是否存在
// 3. 批量创建用户
// 4. 自动绑定设备信息
// 5. 返回导入结果统计
}
private void validateDepartmentAndPosition(String deptName, String positionName) {
// 部门和岗位名称唯一性验证
}
}
```
#### 1.2 前端实现 (3人/天)
**Excel导入页面**:
```typescript
// 新增导入页面组件
const ImportUsersPage = () => {
return (
<div>
<Upload
accept=".xlsx,.xls"
beforeUpload={validateExcelFile}
customRequest={handleExcelUpload}
>
<Button icon={<UploadOutlined />}>
批量导入用户Excel
</Button>
</Upload>
<ImportResultTable data={importResult} />
</div>
);
};
```
#### 1.3 Excel模板设计
```
| 姓名 | 手机号 | 部门名称 | 岗位名称 | 设备序列号 | 备注 |
|------|--------|----------|----------|------------|------|
| 张三 | 13800138000 | 技术部 | 工程师 | DEVICE001 | 测试 |
```
### 实施计划
1. **Day 1-2**: Excel解析库集成和模板设计
2. **Day 3-5**: 后端批量导入逻辑开发
3. **Day 6-7**: 前端导入界面开发
4. **Day 8-9**: 联调测试和异常处理
5. **Day 10**: 文档编写和部署准备
---
## 🎯 **需求4: 手机端管理员权限分级查看**
### 📈 实施难度: ⭐⭐ (简单)
### ⏱ 工期估算: 15人/天
### 🚨 技术风险: 低
### 现状分析
**现有基础设施** ✅
- 已有用户角色权限系统(`SysUserRoleController`)
- 已有组织架构管理(`SysOrgUnitsController`)
- Flutter手机端已有基础架构
### 技术实施方案
#### 4.1 权限系统扩展 (5人/天)
**用户类型枚举**:
```java
public enum UserType {
EMPLOYEE("普通员工"),
MANAGER("部门管理员"),
ADMIN("系统管理员");
}
```
**权限验证服务**:
```java
@Service
public class UserPermissionService {
public boolean canViewUserData(String currentUserId, String targetUserId) {
// 管理员可查看部门内所有员工数据
// 普通员工只能查看自己的数据
}
public List<SysUserVO> getAccessibleUsers(String userId) {
// 根据用户权限返回可访问的用户列表
}
}
```
#### 4.2 手机端路由改造 (6人/天)
**Flutter路由逻辑**:
```dart
// 登录后自动路由判断
class AuthService {
Future<void> handleLoginSuccess(UserModel user) async {
if (user.userType == UserType.MANAGER || user.userType == UserType.ADMIN) {
// 跳转到管理员主页
GoRouter.of(context).go('/admin/dashboard');
} else {
// 跳转到员工个人页面
GoRouter.of(context).go('/employee/personal');
}
}
}
// 管理员页面组件
class AdminDashboardScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
DepartmentUserList(), // 部门员工列表
HealthDataOverview(), // 健康数据概览
],
),
);
}
}
```
#### 4.3 数据权限控制 (4人/天)
**API接口改造**:
```java
@GetMapping("/health/department")
@SaCheckPermission("health:department:view")
public Result<List<HealthDataVO>> getDepartmentHealthData(
@RequestParam String deptId) {
// 验证当前用户是否有查看该部门数据的权限
return Result.data(healthDataService.getDepartmentData(deptId));
}
```
### 实施计划
1. **Day 1-3**: 后端权限系统扩展
2. **Day 4-8**: Flutter路由和页面改造
3. **Day 9-12**: 数据权限控制API开发
4. **Day 13-14**: 联调测试
5. **Day 15**: 部署和文档
---
## 🎯 **需求2: 批量处理告警功能**
### 📈 实施难度: ⭐ (极简单)
### ⏱ 工期估算: 5人/天
### 🚨 技术风险: 极低
### 现状分析
**现有基础设施** ✅
- 已有`TAlertInfoController`告警管理
- 已有批量删除功能(`batchDelete`)
- 前端已有选择多个记录的UI组件
### 技术实施方案
#### 2.1 后端接口扩展 (2人/天)
**批量处理接口**:
```java
@RestController
@RequestMapping("t_alert_info")
public class TAlertInfoController {
@PostMapping("/batch/process")
@SaCheckPermission("t:alert:info:batch:process")
public Result<Boolean> batchProcessAlerts(
@RequestBody BatchProcessAlertDTO batchProcessDTO) {
return Result.status(tAlertInfoFacade.batchProcess(batchProcessDTO));
}
}
@Data
public class BatchProcessAlertDTO {
private List<Long> alertIds;
private String processAction; // "resolve", "ignore", "escalate"
private String processComment;
private String processorId;
}
```
#### 2.2 消息推送集成 (2人/天)
**消息推送服务**:
```java
@Service
public class AlertNotificationService {
public void sendBatchProcessNotification(List<Long> alertIds, String action) {
// 发送批量处理结果到个人和管理员
alertIds.forEach(alertId -> {
TAlertInfo alert = alertService.getById(alertId);
// 推送给相关用户和管理员
messageService.sendToUser(alert.getUserId(), buildNotification(alert, action));
messageService.sendToAdmins(buildAdminNotification(alert, action));
});
}
}
```
#### 2.3 前端UI改造 (1人/天)
**批量操作组件**:
```typescript
const AlertBatchActions = ({ selectedAlerts }) => {
return (
<Space>
<Button
type="primary"
disabled={selectedAlerts.length === 0}
onClick={() => handleBatchProcess('resolve')}
>
批量处理 ({selectedAlerts.length})
</Button>
<Button onClick={() => handleBatchProcess('ignore')}>
批量忽略
</Button>
</Space>
);
};
```
### 实施计划
1. **Day 1**: 后端批量处理接口开发
2. **Day 2**: 消息推送逻辑集成
3. **Day 3**: 前端UI组件开发
4. **Day 4**: 联调测试
5. **Day 5**: 部署上线
---
## 🎯 **需求3: 告警通知系统重构**
### 📈 实施难度: ⭐⭐⭐⭐ (复杂)
### ⏱ 工期估算: 40人/天
### 🚨 技术风险: 中
### 现状分析
**现有基础设施** ✅
- 已有`TAlertRulesController`告警规则管理
- 已有`TWechatAlertConfigController`微信告警配置
- 已有设备消息系统(`TDeviceMessageController`)
- ljwx-bigscreen已有告警处理逻辑
### 技术实施方案
#### 3.1 告警规则系统重构 (12人/天)
**新的通知方式配置**:
```java
@Entity
public class TAlertRules {
// 原有字段...
@Enumerated(EnumType.STRING)
private NotificationType notificationType; // APP_MESSAGE, WECHAT, SMS, EMAIL
@Column(columnDefinition = "JSON")
private String notificationConfig; // JSON配置不同通知方式的参数
private Boolean enableAutoProcess; // 是否启用自动处理
private String autoProcessRules; // 自动处理规则JSON
}
public enum NotificationType {
APP_MESSAGE("应用内消息"),
DEVICE_MESSAGE("设备消息"),
WECHAT("微信通知"),
SMS("短信通知"),
BIGSCREEN_POPUP("大屏弹窗");
}
```
**智能告警匹配引擎**:
```java
@Service
public class AlertMatchingEngine {
public AlertProcessResult processSystemEvent(SystemEventDTO event) {
// 1. 根据事件类型、严重程度、设备信息匹配告警规则
List<TAlertRules> matchedRules = findMatchingRules(event);
if (!matchedRules.isEmpty()) {
// 2. 自动创建告警记录
TAlertInfo alert = createAlertFromEvent(event, matchedRules.get(0));
// 3. 根据规则配置发送通知
sendNotificationsByRules(alert, matchedRules);
return AlertProcessResult.builder()
.processed(true)
.alertId(alert.getId())
.matchedRules(matchedRules.size())
.build();
}
// 无法匹配的告警需要人工处理
return AlertProcessResult.builder()
.processed(false)
.requiresManualReview(true)
.build();
}
}
```
#### 3.2 多渠道通知系统 (15人/天)
**统一消息推送服务**:
```java
@Service
public class UnifiedNotificationService {
public void sendAlert(TAlertInfo alert, NotificationType type) {
switch (type) {
case APP_MESSAGE:
sendToMobileApp(alert);
break;
case DEVICE_MESSAGE:
sendToWatchDevice(alert);
break;
case BIGSCREEN_POPUP:
sendToBigScreen(alert);
break;
case WECHAT:
sendToWechat(alert);
break;
}
}
private void sendToMobileApp(TAlertInfo alert) {
// 推送到手机APP
AppPushMessage message = AppPushMessage.builder()
.userId(alert.getUserId())
.title("健康告警")
.content(alert.getAlertContent())
.type("HEALTH_ALERT")
.data(Map.of("alertId", alert.getId()))
.build();
appPushService.send(message);
}
private void sendToWatchDevice(TAlertInfo alert) {
// 推送到手表设备
DeviceMessage deviceMsg = DeviceMessage.builder()
.deviceId(alert.getDeviceId())
.messageType("ALERT")
.content(alert.getAlertContent())
.priority(alert.getAlertLevel())
.build();
deviceMessageService.send(deviceMsg);
}
private void sendToBigScreen(TAlertInfo alert) {
// 推送到大屏弹窗 (仅限critical级别)
if ("critical".equals(alert.getAlertLevel())) {
BigScreenPopupMessage popup = BigScreenPopupMessage.builder()
.alertId(alert.getId())
.title("紧急健康告警")
.content(alert.getAlertContent())
.userName(alert.getUserName())
.alertTime(alert.getCreateTime())
.autoClose(false) // 紧急告警需要手动关闭
.build();
bigScreenService.showPopup(popup);
}
}
}
```
#### 3.3 手机端告警响应系统 (8人/天)
**Flutter告警消息处理**:
```dart
class AlertNotificationService {
// 定时拉取告警消息
Timer? _alertPollingTimer;
void startAlertPolling() {
_alertPollingTimer = Timer.periodic(
const Duration(seconds: 30),
(_) => fetchAndDisplayAlerts(),
);
}
Future<void> fetchAndDisplayAlerts() async {
try {
final alerts = await apiService.getUserUnreadAlerts();
for (final alert in alerts) {
// 显示本地通知
await showLocalNotification(alert);
// 如果是critical级别,显示全屏告警
if (alert.level == AlertLevel.critical) {
await showFullScreenAlert(alert);
}
}
} catch (e) {
print('获取告警消息失败: $e');
}
}
Future<void> handleAlertResponse(String alertId, AlertResponse response) async {
// 处理用户对告警的响应
await apiService.respondToAlert(alertId, response);
// 更新本地告警状态
await updateLocalAlertStatus(alertId, response);
}
}
// 告警响应页面
class AlertResponseScreen extends StatelessWidget {
final AlertModel alert;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('告警处理')),
body: Column(
children: [
AlertDetailCard(alert: alert),
AlertResponseActions(
onConfirm: () => handleResponse(AlertResponse.confirmed),
onIgnore: () => handleResponse(AlertResponse.ignored),
onEscalate: () => handleResponse(AlertResponse.escalated),
),
],
),
);
}
}
```
#### 3.4 管理端告警日志系统 (5人/天)
**告警处理日志接口**:
```java
@RestController
@RequestMapping("/alert/logs")
public class AlertLogController {
@GetMapping("/processing")
@SaCheckPermission("alert:logs:processing:view")
public Result<RPage<AlertProcessLogVO>> getProcessingLogs(
@Parameter PageQuery pageQuery,
@Parameter AlertLogSearchDTO searchDTO) {
return Result.data(alertLogService.getProcessingLogs(pageQuery, searchDTO));
}
@GetMapping("/response")
@SaCheckPermission("alert:logs:response:view")
public Result<RPage<AlertResponseLogVO>> getResponseLogs(
@Parameter PageQuery pageQuery,
@Parameter AlertLogSearchDTO searchDTO) {
return Result.data(alertLogService.getResponseLogs(pageQuery, searchDTO));
}
}
```
### 实施计划
1. **Day 1-5**: 告警规则系统重构和自动匹配引擎
2. **Day 6-15**: 多渠道通知系统开发
3. **Day 16-25**: 手机端告警响应系统
4. **Day 26-30**: 管理端日志系统
5. **Day 31-35**: 大屏弹窗集成
6. **Day 36-38**: 系统联调测试
7. **Day 39-40**: 部署和文档
---
## 📋 总体实施建议
### 🔥 推荐实施优先级
1. **第一期 (最高优先级)**:
- ✅ 需求2: 批量处理告警 (5天)
- ✅ 需求1: Excel批量导入 (10天)
- **总计: 15天**
2. **第二期 (高优先级)**:
- ✅ 需求4: 手机端管理员权限 (15天)
3. **第三期 (战略性功能)**:
- ✅ 需求3: 告警通知系统重构 (40天)
### 🛠 技术风险控制
1. **需求1&2&4**: 风险极低,基于现有架构扩展
2. **需求3**: 中等风险,需要系统性重构
- 建议分阶段实施
- 保持向后兼容
- 充分测试告警规则匹配逻辑
### 📊 资源分配建议
- **后端开发**: 60% (Java Spring Boot)
- **前端开发**: 25% (Vue3 + Flutter)
- **测试联调**: 10%
- **部署文档**: 5%
### 🎯 成功标准
1. **功能完整性**: 所有需求功能100%实现
2. **性能指标**: 告警响应时间<3秒,批量处理支持1000+记录
3. **用户体验**: 手机端响应流畅,管理界面直观易用
4. **系统稳定性**: 7×24小时稳定运行,告警不丢失
---
*文档版本: v1.0*
*创建时间: 2025-08-19*
*负责人: ljwx技术团队*
@@ -162,7 +162,12 @@ class _AdminWebViewScreenState extends State<AdminWebViewScreen> {
/// 自动填充登录表单 #自动登录功能
void _autoFillLoginForm() {
if (widget.webUsername != null && widget.webPasswordSha != null) {
print('=== 管理端登录调试信息 ===');
print('webUsername: ${widget.webUsername}');
print('webPassword: ${widget.webPassword != null ? '***' : 'null'}');
print('webPasswordSha: ${widget.webPasswordSha != null ? '***' : 'null'}');
if (widget.webUsername != null && widget.webPassword != null) {
final script = '''
// 检查是否是管理后台首页,如果是则尝试API登录 #管理后台API登录
if (window.location.href.includes('/#/home')) {
@@ -174,7 +179,7 @@ class _AdminWebViewScreenState extends State<AdminWebViewScreen> {
},
body: JSON.stringify({
userName: '${widget.webUsername}',
password: '${widget.webPasswordSha}'
password: '${widget.webPassword}'
})
})
.then(response => response.json())
@@ -220,16 +225,22 @@ class _AdminWebViewScreenState extends State<AdminWebViewScreen> {
// 填充用户名
if (usernameInput) {
console.log('自动填充用户名: ${widget.webUsername}');
usernameInput.value = '${widget.webUsername}';
usernameInput.dispatchEvent(new Event('input', { bubbles: true }));
usernameInput.dispatchEvent(new Event('change', { bubbles: true }));
} else {
console.log('未找到用户名输入框');
}
// 填充SHA加密密码
// 填充原始密码
if (passwordInput) {
passwordInput.value = '${widget.webPasswordSha}';
console.log('自动填充密码: ***');
passwordInput.value = '${widget.webPassword}';
passwordInput.dispatchEvent(new Event('input', { bubbles: true }));
passwordInput.dispatchEvent(new Event('change', { bubbles: true }));
} else {
console.log('未找到密码输入框');
}
// 如果URL包含auto_login=true,尝试自动点击登录按钮
@@ -265,7 +276,14 @@ class _AdminWebViewScreenState extends State<AdminWebViewScreen> {
foregroundColor: Colors.white,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: _switchToEmployeeView, // 使用新的切换方法
onPressed: () {
// 检查是否可以pop,如果不能则返回首页
if (Navigator.of(context).canPop()) {
context.pop();
} else {
context.go('/home');
}
},
),
actions: [
// 管理员首页按钮 #管理员首页按钮
@@ -282,12 +300,6 @@ class _AdminWebViewScreenState extends State<AdminWebViewScreen> {
onPressed: () => _controller.reload(),
tooltip: '刷新',
),
// 切换到员工界面按钮 #切换界面按钮
IconButton(
icon: const Icon(Icons.people),
onPressed: _switchToEmployeeView, // 使用新的切换方法
tooltip: '切换到员工界面',
),
// 更多选项
PopupMenuButton<String>(
onSelected: (value) {
@@ -301,9 +313,6 @@ class _AdminWebViewScreenState extends State<AdminWebViewScreen> {
case 'auto_fill':
_autoFillLoginForm();
break;
case 'employee_view':
_switchToEmployeeView(); // 使用新的切换方法
break;
case 'admin_home':
_showAdminHomePage();
break;
@@ -355,16 +364,6 @@ class _AdminWebViewScreenState extends State<AdminWebViewScreen> {
),
),
],
const PopupMenuItem(
value: 'employee_view',
child: Row(
children: [
Icon(Icons.people, size: 20),
SizedBox(width: 8),
Text('切换到员工界面'),
],
),
),
const PopupMenuItem(
value: 'logout', // 添加退出登录菜单项
child: Row(
@@ -524,26 +523,6 @@ class _AdminWebViewScreenState extends State<AdminWebViewScreen> {
),
const SizedBox(height: 8),
// 员工界面 #员工界面入口
Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ListTile(
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.green.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.people, color: Colors.green),
),
title: const Text('员工界面'),
subtitle: const Text('查看员工健康数据和设备信息'),
trailing: const Icon(Icons.arrow_forward_ios),
onTap: _switchToEmployeeView, // 使用新的切换方法
),
),
const SizedBox(height: 8),
// 退出登录 #退出登录入口
Card(
+356 -42
View File
@@ -18,8 +18,11 @@ class DeviceDetailsScreen extends StatelessWidget {
final theme = Theme.of(context);
return Scaffold(
backgroundColor: theme.colorScheme.surface,
appBar: AppBar(
title: Text(AppText.deviceInfo),
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.pop(),
@@ -28,60 +31,371 @@ class DeviceDetailsScreen extends StatelessWidget {
body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInfoTable(theme),
// 设备图标和基本信息卡片
_buildDeviceHeaderCard(theme).animate()
.fadeIn(duration: 600.ms, delay: 100.ms)
.slideY(begin: 0.2, end: 0),
const SizedBox(height: 16),
// 设备状态卡片
_buildStatusCard(theme).animate()
.fadeIn(duration: 600.ms, delay: 200.ms)
.slideY(begin: 0.2, end: 0),
const SizedBox(height: 16),
// 设备规格信息卡片
_buildSpecsCard(theme).animate()
.fadeIn(duration: 600.ms, delay: 300.ms)
.slideY(begin: 0.2, end: 0),
const SizedBox(height: 16),
// 用户绑定信息卡片
if (device.userName != null)
_buildUserBindingCard(theme).animate()
.fadeIn(duration: 600.ms, delay: 400.ms)
.slideY(begin: 0.2, end: 0),
],
),
),
);
}
Widget _buildInfoTable(ThemeData theme) {
return Table(
columnWidths: const {
0: FlexColumnWidth(1),
1: FlexColumnWidth(2),
},
children: [
_buildTableRow(theme, AppText.deviceNumber, device.serialNumber),
_buildTableRow(theme, AppText.bluetoothAddress, device.bluetoothAddress),
_buildTableRow(theme, AppText.chargingStatus, device.chargingStatus),
_buildTableRow(theme, AppText.batteryLevel, device.batteryLevel),
_buildTableRow(theme, AppText.deviceStatus, device.status),
_buildTableRow(theme, AppText.deviceVersion, device.systemSoftwareVersion),
_buildTableRow(theme, AppText.updateTime, device.updateTime),
_buildTableRow(theme, AppText.wearableStatus, device.wearableStatus),
if (device.departmentName != null)
_buildTableRow(theme, AppText.department, device.departmentName!),
if (device.userName != null)
_buildTableRow(theme, AppText.userName, device.userName!),
],
Widget _buildDeviceHeaderCard(ThemeData theme) {
return Card(
elevation: 8,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Container(
padding: const EdgeInsets.all(24.0),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
theme.colorScheme.primary.withOpacity(0.1),
theme.colorScheme.secondary.withOpacity(0.1),
],
),
),
child: Column(
children: [
Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: theme.colorScheme.primary.withOpacity(0.3),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: CircleAvatar(
radius: 50,
backgroundColor: theme.colorScheme.primary.withOpacity(0.2),
child: Icon(
Icons.watch,
size: 50,
color: theme.colorScheme.primary,
),
),
),
const SizedBox(height: 16),
Text(
device.serialNumber,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: _getStatusColor(device.status).withOpacity(0.2),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _getStatusColor(device.status),
),
),
const SizedBox(width: 8),
Text(
_translateStatus(device.status),
style: theme.textTheme.bodyMedium?.copyWith(
color: _getStatusColor(device.status),
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
),
);
}
TableRow _buildTableRow(ThemeData theme, String label, String value) {
return TableRow(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
label,
style: theme.textTheme.bodyMedium?.copyWith(
color: Colors.grey[600],
Widget _buildStatusCard(ThemeData theme) {
return _buildInfoCard(theme, '设备状态', Icons.assignment_turned_in, [
_buildStatusRow(
icon: Icons.battery_std,
label: AppText.batteryLevel,
value: device.batteryLevel,
color: _getBatteryColor(device.batteryLevel),
),
_buildStatusRow(
icon: Icons.power,
label: AppText.chargingStatus,
value: _translateStatus(device.chargingStatus),
color: _getChargingColor(device.chargingStatus),
),
_buildStatusRow(
icon: Icons.accessibility,
label: AppText.wearableStatus,
value: _translateStatus(device.wearableStatus),
color: _getWearableColor(device.wearableStatus),
),
]);
}
Widget _buildSpecsCard(ThemeData theme) {
return _buildInfoCard(theme, '设备规格', Icons.info_outline, [
_buildInfoRow(AppText.deviceNumber, device.serialNumber),
_buildInfoRow(AppText.bluetoothAddress, device.bluetoothAddress),
_buildInfoRow(AppText.deviceVersion, device.systemSoftwareVersion),
_buildInfoRow(AppText.updateTime, device.updateTime),
]);
}
Widget _buildUserBindingCard(ThemeData theme) {
return _buildInfoCard(theme, '用户绑定', Icons.person_pin, [
_buildInfoRow(AppText.userName, device.userName ?? '未绑定'),
if (device.departmentName != null)
_buildInfoRow(AppText.department, device.departmentName!),
]);
}
Widget _buildInfoCard(ThemeData theme, String title, IconData icon, List<Widget> children) {
return Card(
elevation: 6,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
icon,
color: theme.colorScheme.onPrimaryContainer,
size: 20,
),
),
const SizedBox(width: 12),
Text(
title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurface,
),
),
],
),
),
const SizedBox(height: 16),
...children,
],
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
value,
style: theme.textTheme.bodyMedium?.copyWith(
color: value.startsWith('NOT') ? Colors.red : null,
),
),
),
],
),
);
}
Widget _buildStatusRow({
required IconData icon,
required String label,
required String value,
required Color color,
}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
children: [
Icon(icon, size: 20, color: color),
const SizedBox(width: 12),
SizedBox(
width: 100,
child: Text(
label,
style: TextStyle(
color: Colors.grey[600],
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(width: 16),
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color.withOpacity(0.3)),
),
child: Row(
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: color,
),
),
const SizedBox(width: 8),
Text(
value,
style: TextStyle(
color: color,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
],
),
);
}
Widget _buildInfoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 100,
child: Text(
label,
style: TextStyle(
color: Colors.grey[600],
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(width: 16),
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.grey[50],
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey[200]!),
),
child: Text(
value,
style: TextStyle(
color: value.contains('NOT') || value.contains('未') ? Colors.red : Colors.black87,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
);
}
Color _getStatusColor(String status) {
switch (status.toUpperCase()) {
case 'ONLINE':
case 'CONNECTED':
return Colors.green;
case 'OFFLINE':
case 'DISCONNECTED':
return Colors.red;
case 'CHARGING':
return Colors.blue;
default:
return Colors.orange;
}
}
Color _getBatteryColor(String batteryLevel) {
if (batteryLevel.contains('%')) {
final level = int.tryParse(batteryLevel.replaceAll('%', '')) ?? 0;
if (level > 60) return Colors.green;
if (level > 30) return Colors.orange;
return Colors.red;
}
return Colors.grey;
}
Color _getChargingColor(String chargingStatus) {
switch (chargingStatus.toUpperCase()) {
case 'CHARGING':
return Colors.green;
case 'CHARGED':
return Colors.blue;
case 'NOT_CHARGING':
return Colors.orange;
default:
return Colors.grey;
}
}
Color _getWearableColor(String wearableStatus) {
switch (wearableStatus.toUpperCase()) {
case 'WEARING':
return Colors.green;
case 'NOT_WEARING':
return Colors.orange;
default:
return Colors.grey;
}
}
String _translateStatus(String status) {
switch (status.toUpperCase()) {
case 'ONLINE':
return '在线';
case 'OFFLINE':
return '离线';
case 'CHARGING':
return '充电中';
case 'NOT_CHARGING':
return '未充电';
case 'CHARGED':
return '已充满';
case 'WEARING':
return '佩戴中';
case 'NOT_WEARING':
return '未佩戴';
case 'CONNECTED':
return '已连接';
case 'DISCONNECTED':
return '已断开';
default:
return status;
}
}
}
+183 -49
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:ljwx_health_new/models/user_model.dart' as user_model;
import 'package:ljwx_health_new/constants/app_text.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_animate/flutter_animate.dart';
class UserDetailsScreen extends StatelessWidget {
final user_model.User user;
@@ -15,8 +16,11 @@ class UserDetailsScreen extends StatelessWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
backgroundColor: theme.colorScheme.surface,
appBar: AppBar(
title: Text(AppText.userInfo),
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.pop(),
@@ -26,20 +30,45 @@ class UserDetailsScreen extends StatelessWidget {
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: CircleAvatar(
radius: 50,
backgroundImage: user.avatar != null ? NetworkImage(user.avatar!) : null,
onBackgroundImageError: user.avatar != null ? (exception, stackTrace) {
print('Error loading avatar: $exception');
} : null,
child: user.avatar == null ? const Icon(Icons.person, size: 50) : null,
),
),
const SizedBox(height: 24),
_buildInfoTable(theme),
// 用户头像和基本信息卡片
_buildUserProfileCard(theme).animate()
.fadeIn(duration: 600.ms, delay: 100.ms)
.slideY(begin: 0.2, end: 0),
const SizedBox(height: 16),
// 个人信息卡片
_buildInfoCard(theme, '个人信息', Icons.person, [
_buildInfoRow(AppText.userName, user.userName),
_buildInfoRow(AppText.userCardNumber, user.userCardNumber ?? AppText.notSet),
_buildInfoRow(AppText.phoneNumber, user.phoneNumber),
_buildInfoRow(AppText.department, user.deptName),
]).animate()
.fadeIn(duration: 600.ms, delay: 200.ms)
.slideY(begin: 0.2, end: 0),
const SizedBox(height: 16),
// 设备信息卡片
_buildInfoCard(theme, AppText.deviceInfo, Icons.watch, [
_buildInfoRow(AppText.deviceNumber, user.deviceSn),
_buildInfoRow(AppText.deviceStatus, AppText.translateDeviceStatus(user.deviceStatus)),
_buildInfoRow(AppText.chargingStatus, AppText.translateDeviceStatus(user.chargingStatus)),
_buildInfoRow(AppText.wearableStatus, AppText.translateDeviceStatus(user.wearableStatus)),
]).animate()
.fadeIn(duration: 600.ms, delay: 300.ms)
.slideY(begin: 0.2, end: 0),
const SizedBox(height: 16),
// 时间信息卡片
_buildInfoCard(theme, '时间信息', Icons.schedule, [
_buildInfoRow(AppText.createTime, user.createTime),
_buildInfoRow(AppText.updateTime, user.updateTime),
]).animate()
.fadeIn(duration: 600.ms, delay: 400.ms)
.slideY(begin: 0.2, end: 0),
],
),
),
@@ -47,49 +76,154 @@ class UserDetailsScreen extends StatelessWidget {
);
}
Widget _buildInfoTable(ThemeData theme) {
return Table(
columnWidths: const {
0: FixedColumnWidth(120),
1: FlexColumnWidth(),
},
children: [
_buildTableRow(theme, AppText.userName, user.userName),
_buildTableRow(theme, AppText.userCardNumber, user.userCardNumber ?? AppText.notSet),
_buildTableRow(theme, AppText.department, user.deptName),
_buildTableRow(theme, AppText.deviceNumber, user.deviceSn),
_buildTableRow(theme, AppText.deviceStatus, AppText.translateDeviceStatus(user.deviceStatus)),
_buildTableRow(theme, AppText.chargingStatus, AppText.translateDeviceStatus(user.chargingStatus)),
_buildTableRow(theme, AppText.wearableStatus, AppText.translateDeviceStatus(user.wearableStatus)),
_buildTableRow(theme, AppText.phoneNumber, user.phoneNumber),
_buildTableRow(theme, AppText.createTime, user.createTime),
_buildTableRow(theme, AppText.updateTime, user.updateTime),
],
Widget _buildUserProfileCard(ThemeData theme) {
return Card(
elevation: 8,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Container(
padding: const EdgeInsets.all(24.0),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
theme.colorScheme.primary.withOpacity(0.1),
theme.colorScheme.secondary.withOpacity(0.1),
],
),
),
child: Column(
children: [
Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: theme.colorScheme.primary.withOpacity(0.3),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: CircleAvatar(
radius: 50,
backgroundColor: theme.colorScheme.primary.withOpacity(0.2),
backgroundImage: user.avatar != null ? NetworkImage(user.avatar!) : null,
onBackgroundImageError: user.avatar != null ? (exception, stackTrace) {
print('Error loading avatar: $exception');
} : null,
child: user.avatar == null
? Icon(Icons.person, size: 50, color: theme.colorScheme.primary)
: null,
),
),
const SizedBox(height: 16),
Text(
user.userName,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Text(
user.deptName,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
);
}
TableRow _buildTableRow(ThemeData theme, String label, String value) {
return TableRow(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
label,
style: theme.textTheme.bodyMedium?.copyWith(
color: Colors.grey[600],
Widget _buildInfoCard(ThemeData theme, String title, IconData icon, List<Widget> children) {
return Card(
elevation: 6,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
icon,
color: theme.colorScheme.onPrimaryContainer,
size: 20,
),
),
const SizedBox(width: 12),
Text(
title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurface,
),
),
],
),
const SizedBox(height: 16),
...children,
],
),
),
);
}
Widget _buildInfoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 100,
child: Text(
label,
style: TextStyle(
color: Colors.grey[600],
fontWeight: FontWeight.w500,
),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
value,
style: theme.textTheme.bodyMedium?.copyWith(
color: value.startsWith('未') ? Colors.red : null,
const SizedBox(width: 16),
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.grey[50],
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey[200]!),
),
child: Text(
value,
style: TextStyle(
color: value.contains('未') || value.contains('UNKNOWN') ? Colors.red : Colors.black87,
fontWeight: FontWeight.w600,
),
),
),
),
),
],
],
),
);
}
}
+126
View File
@@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:io';
import 'dart:async';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http;
import 'package:ljwx_health_new/models/login_response.dart';
import 'package:ljwx_health_new/models/personal_data.dart';
@@ -164,8 +165,21 @@ class ApiService {
?.map((role) => UserRole.fromJson(role))
.toList() ?? [], // 添加角色列表
adminUrl: data['adminUrl'] as String?, // 添加管理后台地址
webUsername: data['is_admin'] == true ? data['user_name'] : null, // 管理员使用自己的用户名
webPassword: data['is_admin'] == true ? password : null, // 管理员使用自己的密码
webPasswordSha: data['is_admin'] == true ? _generateShaPassword(password) : null, // 生成SHA密码
);
// 调试信息 - 查看管理员登录数据
if (data['is_admin'] == true) {
print('=== API服务 - 管理员登录数据调试 ===');
print('is_admin: ${data['is_admin']}');
print('user_name: ${data['user_name']}');
print('phone: $phone');
print('设置的webUsername: ${data['user_name']}');
print('设置的webPassword: ${password != null ? '***' : 'null'}');
}
setLoginData(loginData);
_config.setDeviceSn(data['device_sn']); // 更新配置中的设备序列号
@@ -1019,4 +1033,116 @@ class ApiService {
};
}
}
/// 生成SHA加密密码 #SHA密码生成
String _generateShaPassword(String password) {
try {
var bytes = utf8.encode(password);
var digest = sha256.convert(bytes);
return digest.toString();
} catch (e) {
debugPrint('生成SHA密码失败: $e');
return password; // 如果SHA加密失败,返回原密码
}
}
/// 检查设备绑定状态 #检查设备绑定
Future<Map<String, dynamic>> checkDeviceBinding({
required String serialNumber,
required String phoneNumber,
int retryCount = 3
}) async {
for (int i = 0; i < retryCount; i++) {
try {
debugPrint('检查设备绑定状态请求 - 设备序列号: $serialNumber, 手机号: $phoneNumber');
final response = await http.post(
Uri.parse('${_config.apiBaseUrl}/api/device/check_binding'),
headers: _headers,
body: jsonEncode({
'serial_number': serialNumber,
'phone_number': phoneNumber
}),
).timeout(_timeout);
debugPrint('检查绑定状态响应状态: ${response.statusCode}');
debugPrint('检查绑定状态响应内容: ${response.body}');
if (response.statusCode == 200) {
final jsonData = jsonDecode(response.body);
return jsonData;
} else {
throw Exception('服务器错误 (${response.statusCode}): ${response.body}');
}
} catch (e) {
debugPrint('检查设备绑定状态失败 (第${i+1}次): $e');
if (i == retryCount - 1) rethrow;
await Future.delayed(Duration(seconds: 2 * (i + 1)));
}
}
throw Exception('网络请求失败');
}
/// 提交设备绑定申请 #提交设备绑定申请
Future<Map<String, dynamic>> submitDeviceBindingApplication(Map<String, dynamic> applicationData) async {
try {
debugPrint('提交设备绑定申请请求: $applicationData');
final response = await http.post(
Uri.parse('${_config.apiBaseUrl}/api/device/binding_application'),
headers: _headers,
body: jsonEncode(applicationData),
).timeout(_timeout);
debugPrint('提交绑定申请响应状态: ${response.statusCode}');
debugPrint('提交绑定申请响应内容: ${response.body}');
if (response.statusCode == 200) {
final jsonData = jsonDecode(response.body);
return jsonData;
} else {
return {
'success': false,
'error': '服务器错误 (${response.statusCode}): ${response.body}'
};
}
} catch (e) {
debugPrint('提交设备绑定申请失败: $e');
return {
'success': false,
'error': '提交申请失败:$e'
};
}
}
/// 获取用户设备绑定状态列表 #获取绑定状态列表
Future<Map<String, dynamic>> getUserBindingStatus(String userId) async {
try {
debugPrint('获取用户绑定状态请求,用户ID: $userId');
final response = await http.get(
Uri.parse('${_config.apiBaseUrl}/api/device/user_bindings?user_id=$userId'),
headers: _headers,
).timeout(_timeout);
debugPrint('获取绑定状态响应状态: ${response.statusCode}');
debugPrint('获取绑定状态响应内容: ${response.body}');
if (response.statusCode == 200) {
final jsonData = jsonDecode(response.body);
return jsonData;
} else {
return {
'success': false,
'error': '服务器错误 (${response.statusCode}): ${response.body}'
};
}
} catch (e) {
debugPrint('获取用户绑定状态失败: $e');
return {
'success': false,
'error': '获取绑定状态失败:$e'
};
}
}
}
+153 -1
View File
@@ -110,7 +110,7 @@ class BleSvc { //极简BLE服务
List<int> _hb = [];
bool _c = false;
int _r = 0;
Timer? _t, _rt, _monitorTimer, _serviceCheckTimer; //扫描/重连/自动重连/服务检查定时器
Timer? _t, _rt, _monitorTimer, _serviceCheckTimer, _bindingStatusTimer; //扫描/重连/自动重连/服务检查/绑定状态检查定时器
bool _isConnecting = false; //是否正在连接
DateTime? _lastDataTime; //最后数据时间
final Duration RECONNECT_INTERVAL = Duration(seconds: 30); //自动重连间隔
@@ -271,6 +271,7 @@ class BleSvc { //极简BLE服务
_periodicServiceCheckTimer?.cancel(); // 取消定期服务检查定时器(新增)
_mtuSubscription?.cancel(); // 取消MTU变化监听
_dataMonitorTimer?.cancel(); // 取消数据监控定时器(新增)
_bindingStatusTimer?.cancel(); // 取消绑定状态检查定时器(新增)
_healthMergerSubscription?.cancel();
_dataCharacteristicSubscription?.cancel();
_connectionStateSubscription?.cancel(); // 取消连接状态监听
@@ -934,11 +935,162 @@ class BleSvc { //极简BLE服务
log('处理设备信息');
v.add(data);
_lastDataTime = DateTime.now();
// 🔥 新增:处理设备绑定逻辑
_processDeviceBinding(data);
} catch (e) {
log('处理设备信息失败: $e');
}
}
/// 处理设备绑定逻辑 #设备绑定处理
void _processDeviceBinding(Map<String, dynamic> deviceData) async {
try {
// 从设备信息中提取关键字段
String serialNumber = '';
String phoneNumber = '';
// 尝试从不同字段名获取设备序列号
if (deviceData['data'] != null) {
var data = deviceData['data'];
serialNumber = data['SerialNumber'] ?? data['serial_number'] ?? '';
}
// 获取当前手机号码(从全局配置或登录信息中)
if (global.loginData != null) {
phoneNumber = global.loginData!.phone;
}
if (serialNumber.isEmpty || phoneNumber.isEmpty) {
log('设备绑定检查跳过:序列号或手机号为空 (SN:$serialNumber, Phone:$phoneNumber)');
return;
}
log('开始设备绑定检查 - 设备序列号: $serialNumber, 手机号: $phoneNumber');
// 调用API检查绑定状态
try {
var bindingResult = await ApiService().checkDeviceBinding(
serialNumber: serialNumber,
phoneNumber: phoneNumber,
);
if (bindingResult['success'] == true) {
if (bindingResult['bound'] == true) {
// 设备已绑定 - 直接建立连接
log('设备已绑定,建立正常连接');
_handleBindingSuccess(serialNumber);
} else if (bindingResult['pending'] == true) {
// 已有申请待审批
log('设备绑定申请待审批中');
_handleBindingPending(serialNumber);
} else {
// 未绑定且无申请 - 提交绑定申请
log('设备未绑定,提交绑定申请');
await _submitBindingApplication(serialNumber, phoneNumber);
}
} else {
log('绑定检查失败: ${bindingResult['error'] ?? '未知错误'}');
_handleBindingError(bindingResult['error'] ?? '绑定检查失败');
}
} catch (e) {
log('设备绑定检查异常: $e');
_handleBindingError('网络连接失败');
}
} catch (e) {
log('处理设备绑定逻辑异常: $e');
}
}
/// 提交绑定申请 #提交绑定申请
Future<void> _submitBindingApplication(String serialNumber, String phoneNumber) async {
try {
var result = await ApiService().submitDeviceBindingApplication({
'device_sn': serialNumber,
'phone_number': phoneNumber,
'user_id': global.loginData?.userId.toString() ?? '',
'timestamp': DateTime.now().toIso8601String(),
});
if (result['success'] == true) {
log('绑定申请提交成功');
_handleBindingPending(serialNumber);
} else {
log('绑定申请提交失败: ${result['error'] ?? '未知错误'}');
_handleBindingError(result['error'] ?? '申请提交失败');
}
} catch (e) {
log('提交绑定申请异常: $e');
_handleBindingError('网络连接失败');
}
}
/// 处理绑定成功 #绑定成功处理
void _handleBindingSuccess(String serialNumber) {
log('设备绑定成功,允许数据传输');
// 更新蓝牙状态为已连接
_currentBluetoothState = "connected";
_updateBluetoothIconState();
// 发送成功事件给UI
events.GlobalEvents.i.showSuccess('设备已成功绑定并连接');
}
/// 处理绑定等待审批 #绑定等待处理
void _handleBindingPending(String serialNumber) {
log('设备绑定申请待审批,限制数据传输');
// 更新蓝牙状态为等待状态
_currentBluetoothState = "inactive";
_updateBluetoothIconState();
// 发送等待事件给UI
events.GlobalEvents.i.showWarning('设备绑定申请已提交,等待管理员审批');
// 启动定期检查绑定状态
_startBindingStatusCheck(serialNumber);
}
/// 处理绑定错误 #绑定错误处理
void _handleBindingError(String error) {
log('设备绑定失败: $error');
// 更新蓝牙状态为错误状态
_currentBluetoothState = "disconnected";
_updateBluetoothIconState();
// 发送错误事件给UI
events.GlobalEvents.i.showError('设备绑定失败: $error');
}
/// 启动绑定状态检查定时器 #绑定状态检查
void _startBindingStatusCheck(String serialNumber) {
// 取消之前的检查定时器
_bindingStatusTimer?.cancel();
log('启动绑定状态检查定时器');
_bindingStatusTimer = Timer.periodic(Duration(seconds: 30), (timer) async {
try {
if (global.loginData?.phone.isEmpty ?? true) {
timer.cancel();
return;
}
var result = await ApiService().checkDeviceBinding(
serialNumber: serialNumber,
phoneNumber: global.loginData!.phone,
);
if (result['success'] == true && result['bound'] == true) {
// 绑定已通过,停止检查
timer.cancel();
log('检测到绑定状态变更为已绑定');
_handleBindingSuccess(serialNumber);
}
} catch (e) {
log('定期绑定状态检查失败: $e');
}
});
}
/// 处理通用事件 #通用事件处理
void _handleCommonEvent(Map<String, dynamic> data) {
try {
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,2 +1,2 @@
#Fri Nov 08 19:01:49 CST 2024
#Sun Aug 24 15:47:42 IRKT 2025
gradle.version=7.3
+1 -1
View File
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" languageLevel="JDK_17_PREVIEW" project-jdk-name="temurin-1.8" project-jdk-type="JavaSDK" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" default="true" project-jdk-name="temurin-1.8" project-jdk-type="JavaSDK" />
</project>
@@ -1,11 +1,11 @@
arguments=--init-script /var/folders/9_/swlb2qbs6qxctt1zx7cq4m3h0000gn/T/db3b08fc4a9ef609cb16b96b200fa13e563f396e9bb1ed0905fdab7bc3bc513b.gradle --init-script /var/folders/9_/swlb2qbs6qxctt1zx7cq4m3h0000gn/T/52cde0cfcf3e28b8b7510e992210d9614505e0911af0c190bd590d7158574963.gradle
auto.sync=false
auto.sync=true
build.scans.enabled=false
connection.gradle.distribution=GRADLE_DISTRIBUTION(WRAPPER)
connection.project.dir=
eclipse.preferences.version=1
gradle.user.home=
java.home=/Library/Java/JavaVirtualMachines/jdk-21.jdk/Contents/Home
java.home=/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home
jvm.arguments=
offline.mode=false
override.workspace.settings=true
@@ -0,0 +1,136 @@
package com.ljwx.watch;
import ohos.hiviewdfx.HiLog;
import ohos.hiviewdfx.HiLogLabel;
import java.util.List;
/**
* HealthDataCache 使用示例
* 演示如何使用扩展后的三路缓存系统
*/
public class HealthDataCacheUsageExample {
private static final HiLogLabel LABEL_LOG = new HiLogLabel(HiLog.LOG_APP, 0x01100, "ljwx-usage");
public static void demonstrateUsage() {
HealthDataCache cache = HealthDataCache.getInstance();
// ==================== 基本使用 ====================
// 1. 添加不同类型的数据到缓存
HiLog.info(LABEL_LOG, "=== 添加数据到缓存 ===");
// 健康数据 (向后兼容的方式)
cache.addToCache("{\"heartRate\": 75, \"timestamp\": \"2025-08-22T10:00:00Z\"}");
// 设备信息数据
cache.addDeviceInfoToCache("{\"deviceId\": \"LJWX001\", \"batteryLevel\": 85, \"timestamp\": \"2025-08-22T10:00:00Z\"}");
// 通用事件数据
cache.addCommonEventToCache("{\"eventType\": \"BUTTON_PRESS\", \"timestamp\": \"2025-08-22T10:00:00Z\"}");
// 或者使用通用接口
cache.addToCache(HealthDataCache.DataType.HEALTH_DATA, "{\"bloodOxygen\": 98, \"timestamp\": \"2025-08-22T10:01:00Z\"}");
cache.addToCache(HealthDataCache.DataType.DEVICE_INFO, "{\"temperature\": 36.5, \"timestamp\": \"2025-08-22T10:01:00Z\"}");
cache.addToCache(HealthDataCache.DataType.COMMON_EVENT, "{\"eventType\": \"ALARM\", \"timestamp\": \"2025-08-22T10:01:00Z\"}");
// ==================== 查询缓存状态 ====================
HiLog.info(LABEL_LOG, "=== 查询缓存状态 ===");
// 打印缓存状态摘要
HiLog.info(LABEL_LOG, cache.getCacheStatusSummary());
// 详细打印每种缓存的状态
cache.logCacheStatus();
// 检查特定类型的缓存
HiLog.info(LABEL_LOG, "健康数据缓存大小: " + cache.getCacheSize(HealthDataCache.DataType.HEALTH_DATA));
HiLog.info(LABEL_LOG, "设备信息缓存大小: " + cache.getCacheSize(HealthDataCache.DataType.DEVICE_INFO));
HiLog.info(LABEL_LOG, "通用事件缓存大小: " + cache.getCacheSize(HealthDataCache.DataType.COMMON_EVENT));
// ==================== 获取缓存数据 ====================
HiLog.info(LABEL_LOG, "=== 获取缓存数据 ===");
// 获取健康数据缓存 (向后兼容)
List<String> healthData = cache.getAllCachedData();
HiLog.info(LABEL_LOG, "健康数据缓存: " + healthData.size() + " 条");
// 获取设备信息缓存
List<String> deviceInfo = cache.getDeviceInfoCache();
HiLog.info(LABEL_LOG, "设备信息缓存: " + deviceInfo.size() + " 条");
for (String data : deviceInfo) {
HiLog.info(LABEL_LOG, "设备信息: " + data);
}
// 获取通用事件缓存
List<String> commonEvents = cache.getCommonEventCache();
HiLog.info(LABEL_LOG, "通用事件缓存: " + commonEvents.size() + " 条");
for (String event : commonEvents) {
HiLog.info(LABEL_LOG, "通用事件: " + event);
}
// ==================== 模拟网络恢复后上传 ====================
HiLog.info(LABEL_LOG, "=== 模拟网络恢复后批量上传 ===");
// 模拟上传健康数据
List<String> healthDataToUpload = cache.getAllCachedData(HealthDataCache.DataType.HEALTH_DATA);
if (!healthDataToUpload.isEmpty()) {
HiLog.info(LABEL_LOG, "准备上传 " + healthDataToUpload.size() + " 条健康数据");
// 这里调用实际的上传接口
// uploadHealthData(healthDataToUpload);
// 上传成功后清空缓存
cache.clearCache(HealthDataCache.DataType.HEALTH_DATA);
}
// 模拟上传设备信息
List<String> deviceInfoToUpload = cache.getDeviceInfoCache();
if (!deviceInfoToUpload.isEmpty()) {
HiLog.info(LABEL_LOG, "准备上传 " + deviceInfoToUpload.size() + " 条设备信息");
// uploadDeviceInfo(deviceInfoToUpload);
cache.clearDeviceInfoCache();
}
// 模拟上传通用事件
List<String> eventsToUpload = cache.getCommonEventCache();
if (!eventsToUpload.isEmpty()) {
HiLog.info(LABEL_LOG, "准备上传 " + eventsToUpload.size() + " 条通用事件");
// uploadCommonEvents(eventsToUpload);
cache.clearCommonEventCache();
}
// ==================== 缓存管理 ====================
HiLog.info(LABEL_LOG, "=== 缓存管理操作 ===");
// 检查所有缓存是否都为空
if (cache.areAllCachesEmpty()) {
HiLog.info(LABEL_LOG, "所有缓存都已清空");
}
// 最终状态检查
cache.logCacheStatus();
}
/**
* 演示向后兼容性
*/
public static void demonstrateBackwardCompatibility() {
HiLog.info(LABEL_LOG, "=== 向后兼容性演示 ===");
HealthDataCache cache = HealthDataCache.getInstance();
// 使用原有的API,应该仍然正常工作
cache.addToCache("{\"heartRate\": 80, \"timestamp\": \"2025-08-22T10:05:00Z\"}");
List<String> cachedData = cache.getAllCachedData();
HiLog.info(LABEL_LOG, "使用原有API获取到 " + cachedData.size() + " 条数据");
int cacheSize = cache.getCacheSize();
HiLog.info(LABEL_LOG, "使用原有API获取缓存大小: " + cacheSize);
cache.clearCache();
HiLog.info(LABEL_LOG, "使用原有API清空缓存完成");
}
}
@@ -374,7 +374,7 @@ public class HealthDataService extends Ability {
}
private void getScientificSleepData(long startTime, long endTime){
HealthManager.getInstance(getContext()).queryScientificSleepRecords(new SleepQueryConfig.Builder().setStartTime(startTime).setEndTime(endTime).build(), new ScientificSleepListener()
HealthManager.getInstance(getContext()).queryScientificSleepRecords(new SleepQueryConfig.Builder().build(), new ScientificSleepListener()
{
@Override
public void onReceive(int i, String s) {
@@ -501,7 +501,7 @@ public class HealthDataService extends Ability {
String spo2Json = "";
try {
spo2Json = HealthManager.getInstance(getContext()).querySpo2Records(new Spo2QueryConfig.Builder().setStartTime(startTime).setEndTime(endTime).build());
spo2Json = HealthManager.getInstance(getContext()).querySpo2Records(new Spo2QueryConfig.Builder().build());
HiLog.info(LABEL_LOG, "jjgao::spo2Json " + spo2Json);
int spo2Value = (int) fetchFromJson(spo2Json);
if (spo2Value != 0) {
@@ -539,7 +539,7 @@ public class HealthDataService extends Ability {
String temperatureJson = "";
try {
temperatureJson = HealthManager.getInstance(getContext()).queryBodyTemperatureRecords(new BodyTemperatureQueryConfig.Builder().setStartTime(startTime).setEndTime(endTime).build());
temperatureJson = HealthManager.getInstance(getContext()).queryBodyTemperatureRecords(new BodyTemperatureQueryConfig.Builder().build());
HiLog.info(LABEL_LOG, "jjgao::temperatureJson " + temperatureJson);
double temperature = fetchFromJson(temperatureJson);
//HiLog.info(LABEL_LOG, "jjgao::Temperature:history" + temperature);
@@ -137,11 +137,11 @@ public class MainAbilitySlice extends AbilitySlice {
" \"work_out\": \"1800:1:1:-1:-1:-1\"\n" +
" },\n" +
" \"interface_data\": {\n" +
" \"事件上传接口\": \"http://42.192.53.33:8001/upload_common_event;5;1;ZNSB_SBSJ;Basic b3Blbl91c2VyOnVzZXJfMTIzIw==\",\n" +
" \"健康数据上传接口\": \"http://42.192.53.33:8001/upload_health_data;60;1;ZNSB_SBSJ;Basic b3Blbl91c2VyOnVzZXJfMTIzIw==\",\n" +
" \"平台消息交互接口\": \"http://42.192.53.33:8001/DeviceMessage;60;1;ZNSB_SBSJ;Basic b3Blbl91c2VyOnVzZXJfMTIzIw==\",\n" +
" \"设备信息上传接口\": \"http://42.192.53.33:8001/upload_device_info;300;1;ZNSB_SBSJ;Basic b3Blbl91c2VyOnVzZXJfMTIzIw==\",\n" +
" \"配置获取接口\": \"http://42.192.53.33:8001/fetch_health_data_config;36000;1;ZNSB_SBSJ;Basic b3Blbl91c2VyOnVzZXJfMTIzIw==\"\n" +
" \"事件上传接口\": \"http://47.96.247.175:8001/upload_common_event;5;1;ZNSB_SBSJ;Basic b3Blbl91c2VyOnVzZXJfMTIzIw==\",\n" +
" \"健康数据上传接口\": \"http://47.96.247.175:8001/upload_health_data;60;1;ZNSB_SBSJ;Basic b3Blbl91c2VyOnVzZXJfMTIzIw==\",\n" +
" \"平台消息交互接口\": \"http://47.96.247.175:8001/DeviceMessage;60;1;ZNSB_SBSJ;Basic b3Blbl91c2VyOnVzZXJfMTIzIw==\",\n" +
" \"设备信息上传接口\": \"http://47.96.247.175:8001/upload_device_info;300;1;ZNSB_SBSJ;Basic b3Blbl91c2VyOnVzZXJfMTIzIw==\",\n" +
" \"配置获取接口\": \"http://47.96.247.175:8001/fetch_health_data_config;36000;1;ZNSB_SBSJ;Basic b3Blbl91c2VyOnVzZXJfMTIzIw==\"\n" +
" },\n" +
" \"is_support_license\": 0,\n" +
" \"license_key\": 400,\n" +
@@ -334,6 +334,10 @@ public class Utils {
} else if (lastHealthInfo != null) {
HiLog.warn(LABEL_LOG, "Utils::getHealthInfo 当前健康数据无效(心率、血氧均为0),使用缓存数据");
return updateTimestampInHealthData(lastHealthInfo);
} else {
// 没有有效数据且没有缓存,返回null避免上传无效数据
HiLog.warn(LABEL_LOG, "Utils::getHealthInfo 健康数据无效且无缓存,不上传健康数据");
return null;
}
return lastHealthInfo;
@@ -349,7 +353,8 @@ public class Utils {
/**
* 获取用于通用事件的健康数据,upload_method标识为"common_event"
* @return 健康数据JSON字符串
* 如果心率为0,则返回null表示无有效健康数据
* @return 健康数据JSON字符串,如果心率为0则返回null
*/
public static String getHealthInfoForCommonEvent() {
try {
@@ -367,14 +372,36 @@ public class Utils {
double temperature = dataManager.getTemperature();
int pressureHigh = dataManager.getPressureHigh();
int pressureLow = dataManager.getPressureLow();
int step = dataManager.getStep();
double distance = dataManager.getDistance();
double calorie = dataManager.getCalorie();
int stress = dataManager.getStress();
// 详细打印所有健康数据字段
HiLog.info(LABEL_LOG, "Utils::getHealthInfoForCommonEvent 健康数据详情:");
HiLog.info(LABEL_LOG, " 心率(heart_rate): " + heartRate);
HiLog.info(LABEL_LOG, " 血氧(blood_oxygen): " + bloodOxygen);
HiLog.info(LABEL_LOG, " 体温(body_temperature): " + temperature);
HiLog.info(LABEL_LOG, " 高压(blood_pressure_systolic): " + pressureHigh);
HiLog.info(LABEL_LOG, " 低压(blood_pressure_diastolic): " + pressureLow);
HiLog.info(LABEL_LOG, " 步数(step): " + step);
HiLog.info(LABEL_LOG, " 距离(distance): " + distance);
HiLog.info(LABEL_LOG, " 卡路里(calorie): " + calorie);
HiLog.info(LABEL_LOG, " 压力(stress): " + stress);
// 检查心率是否为0,如果为0则不上传健康数据
if (heartRate == 0) {
HiLog.warn(LABEL_LOG, "Utils::getHealthInfoForCommonEvent 心率为0,跳过健康数据上传");
return null; // 返回null表示无有效健康数据
}
healthInfoJson.put("heart_rate", heartRate);
healthInfoJson.put("blood_oxygen", bloodOxygen);
healthInfoJson.put("body_temperature", String.format("%.1f", temperature));
healthInfoJson.put("step", dataManager.getStep());
healthInfoJson.put("distance", String.format("%.1f", dataManager.getDistance()));
healthInfoJson.put("calorie", String.format("%.1f", dataManager.getCalorie()));
healthInfoJson.put("step", step);
healthInfoJson.put("distance", String.format("%.1f", distance));
healthInfoJson.put("calorie", String.format("%.1f", calorie));
BigDecimal latitude = dataManager.getLatitude();
BigDecimal longitude = dataManager.getLongitude();
@@ -383,7 +410,7 @@ public class Utils {
healthInfoJson.put("latitude", latitude != null ? latitude.toString() : "0");
healthInfoJson.put("longitude", longitude != null ? longitude.toString() : "0");
healthInfoJson.put("altitude", altitude != null ? altitude.toString() : "0");
healthInfoJson.put("stress", dataManager.getStress());
healthInfoJson.put("stress", stress);
// 关键修改:为通用事件设置特殊的upload_method标识
healthInfoJson.put("upload_method", "common_event");
+2 -1
View File
@@ -1,5 +1,6 @@
#Sun Aug 24 15:39:26 CST 2025
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://repo.huaweicloud.com/gradle/gradle-7.3-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists