初始提交
This commit is contained in:
commit
d36b16d458
367
.gitignore
vendored
Normal file
367
.gitignore
vendored
Normal file
@ -0,0 +1,367 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
PIPFILE.lock
|
||||
|
||||
# PyInstaller
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# pipenv
|
||||
Pipfile.lock
|
||||
|
||||
# poetry
|
||||
poetry.lock
|
||||
|
||||
# pdm
|
||||
.pdm.toml
|
||||
|
||||
# PEP 582
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# Node.js
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage/
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
public
|
||||
|
||||
# Storybook build outputs
|
||||
.out
|
||||
.storybook-out
|
||||
storybook-static
|
||||
|
||||
# Temporary folders
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Electron
|
||||
dist_electron/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Application specific
|
||||
data/
|
||||
!data/.gitkeep
|
||||
backup_*.zip
|
||||
exports/
|
||||
sessions/
|
||||
patients/
|
||||
backups/
|
||||
temp/
|
||||
logs/
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Media files
|
||||
*.mp4
|
||||
*.avi
|
||||
*.mov
|
||||
*.wmv
|
||||
*.flv
|
||||
*.webm
|
||||
*.mkv
|
||||
*.m4v
|
||||
*.3gp
|
||||
*.jpg
|
||||
*.jpeg
|
||||
*.png
|
||||
*.gif
|
||||
*.bmp
|
||||
*.tiff
|
||||
*.svg
|
||||
*.ico
|
||||
*.webp
|
||||
|
||||
# Configuration files with sensitive data
|
||||
config.local.json
|
||||
secrets.json
|
||||
.env.local
|
||||
.env.production
|
||||
|
||||
# Build artifacts
|
||||
build/
|
||||
dist/
|
||||
out/
|
||||
|
||||
# Test artifacts
|
||||
test-results/
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# Documentation
|
||||
docs/build/
|
||||
docs/_build/
|
||||
|
||||
# Backup files
|
||||
*.bak
|
||||
*.backup
|
||||
*.old
|
||||
*.orig
|
||||
|
||||
# Lock files
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
|
||||
# Cache directories
|
||||
.cache/
|
||||
.npm/
|
||||
.yarn/
|
||||
.pnpm/
|
||||
|
||||
# Runtime directories
|
||||
runtime/
|
||||
pids/
|
||||
|
||||
# Development tools
|
||||
.vscode/
|
||||
.idea/
|
||||
*.sublime-*
|
||||
|
||||
# System files
|
||||
*.tmp
|
||||
*.temp
|
||||
.fuse_hidden*
|
||||
.directory
|
||||
.Trash-*
|
||||
.nfs*
|
||||
|
||||
# Windows
|
||||
desktop.ini
|
||||
ehthumbs.db
|
||||
ehthumbs_vista.db
|
||||
*.stackdump
|
||||
[Dd]esktop.ini
|
||||
$RECYCLE.BIN/
|
||||
*.cab
|
||||
*.msi
|
||||
*.msix
|
||||
*.msm
|
||||
*.msp
|
||||
*.lnk
|
||||
1
.vercel/project.json
Normal file
1
.vercel/project.json
Normal file
@ -0,0 +1 @@
|
||||
{"projectName":"trae_c7qdkht3"}
|
||||
220
CHANGELOG.md
Normal file
220
CHANGELOG.md
Normal file
@ -0,0 +1,220 @@
|
||||
# 更新日志
|
||||
|
||||
本文档记录了身体平衡评估系统的所有重要更改。
|
||||
|
||||
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/),
|
||||
并且本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
||||
|
||||
## [未发布]
|
||||
|
||||
### 计划新增
|
||||
- [ ] 多语言支持(英文、日文)
|
||||
- [ ] 云端数据同步功能
|
||||
- [ ] 移动端应用支持
|
||||
- [ ] AI辅助诊断建议
|
||||
- [ ] 3D姿态可视化
|
||||
- [ ] 报告模板自定义
|
||||
- [ ] 批量数据分析
|
||||
- [ ] 设备校准向导
|
||||
|
||||
### 计划改进
|
||||
- [ ] 优化实时数据处理性能
|
||||
- [ ] 增强数据可视化效果
|
||||
- [ ] 改进用户界面交互体验
|
||||
- [ ] 扩展设备兼容性
|
||||
|
||||
## [1.0.0] - 2024-01-15
|
||||
|
||||
### 新增
|
||||
- ✅ 完整的身体平衡评估系统架构
|
||||
- ✅ 基于Vue 3 + Electron的现代化前端界面
|
||||
- ✅ 基于Flask的RESTful API后端服务
|
||||
- ✅ 多传感器数据融合(摄像头、IMU、压力传感器)
|
||||
- ✅ 实时姿态检测和平衡分析
|
||||
- ✅ 患者信息管理系统
|
||||
- ✅ 检测会话管理和历史记录
|
||||
- ✅ 数据分析和可视化图表
|
||||
- ✅ PDF报告生成和导出功能
|
||||
- ✅ 系统设置和设备配置
|
||||
- ✅ 完整的日志记录系统
|
||||
- ✅ 数据备份和恢复功能
|
||||
|
||||
### 技术特性
|
||||
- ✅ 基于MediaPipe的实时姿态检测
|
||||
- ✅ WebSocket实时数据传输
|
||||
- ✅ SQLite数据库存储
|
||||
- ✅ 模块化架构设计
|
||||
- ✅ 跨平台支持(Windows、macOS、Linux)
|
||||
- ✅ 响应式UI设计
|
||||
- ✅ 国际化支持框架
|
||||
|
||||
### 核心功能模块
|
||||
|
||||
#### 前端界面
|
||||
- ✅ 现代化的用户界面设计
|
||||
- ✅ 实时数据可视化
|
||||
- ✅ 响应式布局适配
|
||||
- ✅ 深色/浅色主题切换
|
||||
- ✅ 多语言界面支持
|
||||
|
||||
#### 后端服务
|
||||
- ✅ RESTful API设计
|
||||
- ✅ 实时数据处理引擎
|
||||
- ✅ 设备管理和通信
|
||||
- ✅ 数据分析算法
|
||||
- ✅ 报告生成服务
|
||||
|
||||
#### 数据管理
|
||||
- ✅ 患者信息CRUD操作
|
||||
- ✅ 检测会话管理
|
||||
- ✅ 历史数据查询和分析
|
||||
- ✅ 数据导出和备份
|
||||
- ✅ 数据安全和隐私保护
|
||||
|
||||
#### 设备集成
|
||||
- ✅ 摄像头视频采集和处理
|
||||
- ✅ IMU传感器数据采集
|
||||
- ✅ 压力传感器数据采集
|
||||
- ✅ 设备状态监控
|
||||
- ✅ 设备校准功能
|
||||
|
||||
#### 分析算法
|
||||
- ✅ 重心轨迹分析
|
||||
- ✅ 姿态稳定性评估
|
||||
- ✅ 平衡能力评分
|
||||
- ✅ 异常检测和预警
|
||||
- ✅ 趋势分析和对比
|
||||
|
||||
### 文件结构
|
||||
```
|
||||
BodyBalanceEvaluation/
|
||||
├── backend/ # 后端服务
|
||||
│ ├── app.py # 主应用入口
|
||||
│ ├── database.py # 数据库管理
|
||||
│ ├── device_manager.py # 设备管理
|
||||
│ ├── detection_engine.py # 检测引擎
|
||||
│ ├── data_processor.py # 数据处理
|
||||
│ ├── utils.py # 工具函数
|
||||
│ ├── requirements.txt # Python依赖
|
||||
│ └── tests/ # 测试文件
|
||||
├── src/
|
||||
│ ├── main/ # Electron主进程
|
||||
│ └── renderer/ # Vue前端应用
|
||||
│ ├── src/
|
||||
│ │ ├── views/ # 页面组件
|
||||
│ │ ├── stores/ # 状态管理
|
||||
│ │ └── services/ # API服务
|
||||
│ └── package.json
|
||||
├── data/ # 数据目录
|
||||
├── logs/ # 日志目录
|
||||
├── temp/ # 临时文件
|
||||
├── main.py # 启动脚本
|
||||
├── config.json # 配置文件
|
||||
├── package.json # 项目配置
|
||||
├── README.md # 项目说明
|
||||
├── LICENSE # 许可证
|
||||
├── .gitignore # Git忽略规则
|
||||
├── install.bat # 安装脚本
|
||||
├── start_dev.bat # 开发环境启动
|
||||
├── start_prod.bat # 生产环境启动
|
||||
└── CHANGELOG.md # 更新日志
|
||||
```
|
||||
|
||||
### 系统要求
|
||||
- **操作系统**: Windows 10/11, macOS 10.15+, Ubuntu 18.04+
|
||||
- **Python**: 3.8 或更高版本
|
||||
- **Node.js**: 16.0 或更高版本
|
||||
- **内存**: 最少 4GB RAM,推荐 8GB+
|
||||
- **存储**: 最少 2GB 可用空间
|
||||
- **摄像头**: USB摄像头或内置摄像头
|
||||
- **串口设备**: IMU和压力传感器(可选)
|
||||
|
||||
### 安装和使用
|
||||
1. 运行 `install.bat` 安装所有依赖
|
||||
2. 运行 `start_dev.bat` 启动开发环境
|
||||
3. 或运行 `start_prod.bat` 启动生产环境
|
||||
4. 访问 http://localhost:5173 使用应用
|
||||
|
||||
### 已知问题
|
||||
- 在某些低配置设备上可能出现实时处理延迟
|
||||
- 部分USB摄像头可能需要额外驱动
|
||||
- IMU传感器需要正确的串口配置
|
||||
|
||||
### 性能优化
|
||||
- 实时数据处理采用多线程架构
|
||||
- 图像处理使用GPU加速(如可用)
|
||||
- 数据库查询优化和索引
|
||||
- 前端虚拟滚动和懒加载
|
||||
|
||||
### 安全特性
|
||||
- 本地数据存储,保护隐私
|
||||
- 数据传输加密
|
||||
- 用户会话管理
|
||||
- 输入数据验证和清理
|
||||
|
||||
---
|
||||
|
||||
## 版本说明
|
||||
|
||||
### 版本号格式
|
||||
本项目使用语义化版本号:`主版本号.次版本号.修订号`
|
||||
|
||||
- **主版本号**: 不兼容的API修改
|
||||
- **次版本号**: 向下兼容的功能性新增
|
||||
- **修订号**: 向下兼容的问题修正
|
||||
|
||||
### 更新类型
|
||||
- **新增 (Added)**: 新功能
|
||||
- **更改 (Changed)**: 对现有功能的更改
|
||||
- **弃用 (Deprecated)**: 即将移除的功能
|
||||
- **移除 (Removed)**: 已移除的功能
|
||||
- **修复 (Fixed)**: 错误修复
|
||||
- **安全 (Security)**: 安全相关的修复
|
||||
|
||||
### 发布周期
|
||||
- **主版本**: 每年1-2次重大更新
|
||||
- **次版本**: 每季度功能更新
|
||||
- **修订版**: 每月bug修复和小改进
|
||||
|
||||
---
|
||||
|
||||
## 贡献指南
|
||||
|
||||
如果您想为本项目贡献代码,请:
|
||||
|
||||
1. Fork 本仓库
|
||||
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||
5. 创建 Pull Request
|
||||
|
||||
### 提交信息格式
|
||||
```
|
||||
type(scope): description
|
||||
|
||||
[optional body]
|
||||
|
||||
[optional footer]
|
||||
```
|
||||
|
||||
类型包括:
|
||||
- `feat`: 新功能
|
||||
- `fix`: 修复bug
|
||||
- `docs`: 文档更新
|
||||
- `style`: 代码格式化
|
||||
- `refactor`: 代码重构
|
||||
- `test`: 测试相关
|
||||
- `chore`: 构建过程或辅助工具的变动
|
||||
|
||||
---
|
||||
|
||||
## 支持和反馈
|
||||
|
||||
- **问题报告**: [GitHub Issues](https://github.com/example/body-balance-evaluation/issues)
|
||||
- **功能请求**: [GitHub Discussions](https://github.com/example/body-balance-evaluation/discussions)
|
||||
- **邮件支持**: dev@example.com
|
||||
- **文档**: [项目Wiki](https://github.com/example/body-balance-evaluation/wiki)
|
||||
|
||||
---
|
||||
|
||||
*最后更新: 2024-01-15*
|
||||
680
CONTRIBUTING.md
Normal file
680
CONTRIBUTING.md
Normal file
@ -0,0 +1,680 @@
|
||||
# 贡献指南
|
||||
|
||||
感谢您对身体平衡评估系统项目的关注!我们欢迎所有形式的贡献,包括但不限于代码、文档、测试、问题报告和功能建议。
|
||||
|
||||
## 目录
|
||||
|
||||
- [行为准则](#行为准则)
|
||||
- [如何贡献](#如何贡献)
|
||||
- [开发环境设置](#开发环境设置)
|
||||
- [代码规范](#代码规范)
|
||||
- [提交规范](#提交规范)
|
||||
- [Pull Request 流程](#pull-request-流程)
|
||||
- [问题报告](#问题报告)
|
||||
- [功能请求](#功能请求)
|
||||
- [文档贡献](#文档贡献)
|
||||
- [测试指南](#测试指南)
|
||||
|
||||
## 行为准则
|
||||
|
||||
### 我们的承诺
|
||||
|
||||
为了营造一个开放和友好的环境,我们作为贡献者和维护者承诺,无论年龄、体型、残疾、种族、性别认同和表达、经验水平、国籍、个人形象、种族、宗教或性取向如何,参与我们项目和社区的每个人都能获得无骚扰的体验。
|
||||
|
||||
### 我们的标准
|
||||
|
||||
有助于创造积极环境的行为包括:
|
||||
|
||||
- 使用友好和包容的语言
|
||||
- 尊重不同的观点和经验
|
||||
- 优雅地接受建设性批评
|
||||
- 关注对社区最有利的事情
|
||||
- 对其他社区成员表示同理心
|
||||
|
||||
不可接受的行为包括:
|
||||
|
||||
- 使用性化的语言或图像,以及不受欢迎的性关注或性骚扰
|
||||
- 恶意评论、侮辱/贬损评论,以及个人或政治攻击
|
||||
- 公开或私下骚扰
|
||||
- 未经明确许可,发布他人的私人信息,如物理或电子地址
|
||||
- 在专业环境中可能被合理认为不适当的其他行为
|
||||
|
||||
## 如何贡献
|
||||
|
||||
### 贡献类型
|
||||
|
||||
我们欢迎以下类型的贡献:
|
||||
|
||||
1. **代码贡献**
|
||||
- 新功能开发
|
||||
- Bug 修复
|
||||
- 性能优化
|
||||
- 代码重构
|
||||
|
||||
2. **文档贡献**
|
||||
- API 文档
|
||||
- 用户指南
|
||||
- 开发者文档
|
||||
- 代码注释
|
||||
|
||||
3. **测试贡献**
|
||||
- 单元测试
|
||||
- 集成测试
|
||||
- 端到端测试
|
||||
- 性能测试
|
||||
|
||||
4. **设计贡献**
|
||||
- UI/UX 设计
|
||||
- 图标设计
|
||||
- 用户体验改进
|
||||
|
||||
5. **其他贡献**
|
||||
- 问题报告
|
||||
- 功能建议
|
||||
- 社区支持
|
||||
- 翻译工作
|
||||
|
||||
## 开发环境设置
|
||||
|
||||
### 前置要求
|
||||
|
||||
- Python 3.8+
|
||||
- Node.js 16.0+
|
||||
- Git
|
||||
- 代码编辑器(推荐 VS Code)
|
||||
|
||||
### 环境配置
|
||||
|
||||
1. **克隆仓库**
|
||||
```bash
|
||||
git clone https://github.com/example/body-balance-evaluation.git
|
||||
cd body-balance-evaluation
|
||||
```
|
||||
|
||||
2. **安装依赖**
|
||||
```bash
|
||||
# Windows
|
||||
install.bat
|
||||
|
||||
# 或手动安装
|
||||
python -m venv venv
|
||||
venv\Scripts\activate # Windows
|
||||
# source venv/bin/activate # macOS/Linux
|
||||
|
||||
pip install -r backend/requirements.txt
|
||||
cd src/renderer && npm install
|
||||
```
|
||||
|
||||
3. **配置开发环境**
|
||||
```bash
|
||||
# 复制配置文件
|
||||
cp config.json config.local.json
|
||||
|
||||
# 编辑本地配置(可选)
|
||||
# 修改 config.local.json 中的设置
|
||||
```
|
||||
|
||||
4. **启动开发服务器**
|
||||
```bash
|
||||
# Windows
|
||||
start_dev.bat
|
||||
|
||||
# 或手动启动
|
||||
python main.py --mode development
|
||||
```
|
||||
|
||||
### 开发工具推荐
|
||||
|
||||
#### VS Code 扩展
|
||||
- Python
|
||||
- Pylance
|
||||
- Vue Language Features (Volar)
|
||||
- TypeScript Vue Plugin (Volar)
|
||||
- ESLint
|
||||
- Prettier
|
||||
- GitLens
|
||||
|
||||
#### 浏览器扩展
|
||||
- Vue.js devtools
|
||||
- React Developer Tools(如果使用)
|
||||
|
||||
## 代码规范
|
||||
|
||||
### Python 代码规范
|
||||
|
||||
我们遵循 [PEP 8](https://www.python.org/dev/peps/pep-0008/) 规范:
|
||||
|
||||
```python
|
||||
# 好的示例
|
||||
class PatientManager:
|
||||
"""患者管理器类"""
|
||||
|
||||
def __init__(self, database_path: str):
|
||||
self.db_path = database_path
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def create_patient(self, patient_data: dict) -> int:
|
||||
"""创建新患者
|
||||
|
||||
Args:
|
||||
patient_data: 患者信息字典
|
||||
|
||||
Returns:
|
||||
患者ID
|
||||
|
||||
Raises:
|
||||
ValueError: 当患者数据无效时
|
||||
"""
|
||||
if not self._validate_patient_data(patient_data):
|
||||
raise ValueError("Invalid patient data")
|
||||
|
||||
# 实现逻辑...
|
||||
return patient_id
|
||||
```
|
||||
|
||||
#### 代码格式化
|
||||
使用 Black 进行代码格式化:
|
||||
```bash
|
||||
black . --line-length=88
|
||||
```
|
||||
|
||||
#### 代码检查
|
||||
使用 flake8 进行代码检查:
|
||||
```bash
|
||||
flake8 . --max-line-length=88 --exclude=venv,__pycache__
|
||||
```
|
||||
|
||||
### JavaScript/Vue 代码规范
|
||||
|
||||
我们遵循 [Vue.js 风格指南](https://vuejs.org/style-guide/):
|
||||
|
||||
```javascript
|
||||
// 好的示例
|
||||
export default {
|
||||
name: 'PatientList',
|
||||
|
||||
props: {
|
||||
patients: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
|
||||
emits: ['patient-selected', 'patient-deleted'],
|
||||
|
||||
setup(props, { emit }) {
|
||||
const selectedPatient = ref(null)
|
||||
|
||||
const handlePatientClick = (patient) => {
|
||||
selectedPatient.value = patient
|
||||
emit('patient-selected', patient)
|
||||
}
|
||||
|
||||
return {
|
||||
selectedPatient,
|
||||
handlePatientClick
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 代码格式化
|
||||
使用 Prettier 进行代码格式化:
|
||||
```bash
|
||||
npm run format
|
||||
```
|
||||
|
||||
#### 代码检查
|
||||
使用 ESLint 进行代码检查:
|
||||
```bash
|
||||
npm run lint
|
||||
```
|
||||
|
||||
### 通用规范
|
||||
|
||||
1. **命名规范**
|
||||
- 使用有意义的变量和函数名
|
||||
- Python: snake_case
|
||||
- JavaScript: camelCase
|
||||
- 常量: UPPER_CASE
|
||||
- 类名: PascalCase
|
||||
|
||||
2. **注释规范**
|
||||
- 为复杂逻辑添加注释
|
||||
- 使用文档字符串描述函数和类
|
||||
- 注释应该解释"为什么"而不是"是什么"
|
||||
|
||||
3. **文件组织**
|
||||
- 保持文件结构清晰
|
||||
- 相关功能放在同一模块
|
||||
- 避免循环导入
|
||||
|
||||
## 提交规范
|
||||
|
||||
我们使用 [Conventional Commits](https://www.conventionalcommits.org/) 规范:
|
||||
|
||||
### 提交消息格式
|
||||
|
||||
```
|
||||
type(scope): description
|
||||
|
||||
[optional body]
|
||||
|
||||
[optional footer(s)]
|
||||
```
|
||||
|
||||
### 类型 (type)
|
||||
|
||||
- `feat`: 新功能
|
||||
- `fix`: 修复 bug
|
||||
- `docs`: 文档更新
|
||||
- `style`: 代码格式化(不影响代码运行的变动)
|
||||
- `refactor`: 重构(既不是新增功能,也不是修复 bug 的代码变动)
|
||||
- `perf`: 性能优化
|
||||
- `test`: 增加测试
|
||||
- `chore`: 构建过程或辅助工具的变动
|
||||
- `ci`: CI 配置文件和脚本的变动
|
||||
- `revert`: 回滚之前的提交
|
||||
|
||||
### 范围 (scope)
|
||||
|
||||
- `frontend`: 前端相关
|
||||
- `backend`: 后端相关
|
||||
- `database`: 数据库相关
|
||||
- `device`: 设备管理相关
|
||||
- `analysis`: 数据分析相关
|
||||
- `ui`: 用户界面相关
|
||||
- `api`: API 相关
|
||||
- `config`: 配置相关
|
||||
- `docs`: 文档相关
|
||||
- `test`: 测试相关
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 新功能
|
||||
git commit -m "feat(frontend): add patient search functionality"
|
||||
|
||||
# Bug 修复
|
||||
git commit -m "fix(backend): resolve database connection timeout issue"
|
||||
|
||||
# 文档更新
|
||||
git commit -m "docs(api): update patient management API documentation"
|
||||
|
||||
# 重构
|
||||
git commit -m "refactor(device): simplify camera initialization logic"
|
||||
```
|
||||
|
||||
## Pull Request 流程
|
||||
|
||||
### 创建 Pull Request
|
||||
|
||||
1. **Fork 仓库**
|
||||
- 点击 GitHub 页面右上角的 "Fork" 按钮
|
||||
|
||||
2. **创建功能分支**
|
||||
```bash
|
||||
git checkout -b feature/your-feature-name
|
||||
# 或
|
||||
git checkout -b fix/your-bug-fix
|
||||
```
|
||||
|
||||
3. **进行开发**
|
||||
- 编写代码
|
||||
- 添加测试
|
||||
- 更新文档
|
||||
|
||||
4. **提交更改**
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "feat(scope): your commit message"
|
||||
```
|
||||
|
||||
5. **推送分支**
|
||||
```bash
|
||||
git push origin feature/your-feature-name
|
||||
```
|
||||
|
||||
6. **创建 Pull Request**
|
||||
- 在 GitHub 上创建 Pull Request
|
||||
- 填写详细的描述
|
||||
|
||||
### Pull Request 模板
|
||||
|
||||
```markdown
|
||||
## 描述
|
||||
简要描述这个 PR 的目的和内容。
|
||||
|
||||
## 更改类型
|
||||
- [ ] Bug 修复
|
||||
- [ ] 新功能
|
||||
- [ ] 重构
|
||||
- [ ] 文档更新
|
||||
- [ ] 性能优化
|
||||
- [ ] 其他(请说明)
|
||||
|
||||
## 测试
|
||||
- [ ] 已添加单元测试
|
||||
- [ ] 已添加集成测试
|
||||
- [ ] 已手动测试
|
||||
- [ ] 所有测试通过
|
||||
|
||||
## 检查清单
|
||||
- [ ] 代码遵循项目规范
|
||||
- [ ] 已添加必要的注释
|
||||
- [ ] 已更新相关文档
|
||||
- [ ] 没有引入新的警告
|
||||
- [ ] 已测试在不同环境下的兼容性
|
||||
|
||||
## 相关问题
|
||||
关闭 #issue_number
|
||||
|
||||
## 截图(如适用)
|
||||
添加截图来帮助解释你的更改。
|
||||
|
||||
## 额外说明
|
||||
添加任何其他相关信息。
|
||||
```
|
||||
|
||||
### 代码审查
|
||||
|
||||
所有 Pull Request 都需要经过代码审查:
|
||||
|
||||
1. **自动检查**
|
||||
- CI/CD 流水线检查
|
||||
- 代码格式检查
|
||||
- 测试覆盖率检查
|
||||
|
||||
2. **人工审查**
|
||||
- 代码质量
|
||||
- 设计合理性
|
||||
- 测试完整性
|
||||
- 文档完整性
|
||||
|
||||
3. **审查标准**
|
||||
- 代码可读性
|
||||
- 性能影响
|
||||
- 安全性考虑
|
||||
- 向后兼容性
|
||||
|
||||
## 问题报告
|
||||
|
||||
### 报告 Bug
|
||||
|
||||
使用 [Bug 报告模板](https://github.com/example/body-balance-evaluation/issues/new?template=bug_report.md):
|
||||
|
||||
```markdown
|
||||
**描述 Bug**
|
||||
清晰简洁地描述 bug 是什么。
|
||||
|
||||
**重现步骤**
|
||||
重现行为的步骤:
|
||||
1. 转到 '...'
|
||||
2. 点击 '....'
|
||||
3. 滚动到 '....'
|
||||
4. 看到错误
|
||||
|
||||
**预期行为**
|
||||
清晰简洁地描述你期望发生什么。
|
||||
|
||||
**截图**
|
||||
如果适用,添加截图来帮助解释你的问题。
|
||||
|
||||
**环境信息**
|
||||
- 操作系统: [例如 Windows 10]
|
||||
- Python 版本: [例如 3.9.0]
|
||||
- Node.js 版本: [例如 16.14.0]
|
||||
- 应用版本: [例如 1.0.0]
|
||||
|
||||
**额外信息**
|
||||
添加任何其他关于问题的信息。
|
||||
```
|
||||
|
||||
### 报告安全问题
|
||||
|
||||
如果发现安全漏洞,请不要在公开的 issue 中报告。请发送邮件到 security@example.com。
|
||||
|
||||
## 功能请求
|
||||
|
||||
使用 [功能请求模板](https://github.com/example/body-balance-evaluation/issues/new?template=feature_request.md):
|
||||
|
||||
```markdown
|
||||
**功能描述**
|
||||
清晰简洁地描述你想要的功能。
|
||||
|
||||
**问题描述**
|
||||
清晰简洁地描述问题是什么。例如:我总是感到沫丧当 [...]
|
||||
|
||||
**解决方案**
|
||||
清晰简洁地描述你想要发生什么。
|
||||
|
||||
**替代方案**
|
||||
清晰简洁地描述你考虑过的任何替代解决方案或功能。
|
||||
|
||||
**额外信息**
|
||||
添加任何其他关于功能请求的信息或截图。
|
||||
```
|
||||
|
||||
## 文档贡献
|
||||
|
||||
### 文档类型
|
||||
|
||||
1. **用户文档**
|
||||
- 安装指南
|
||||
- 使用教程
|
||||
- 常见问题
|
||||
- 故障排除
|
||||
|
||||
2. **开发者文档**
|
||||
- API 文档
|
||||
- 架构说明
|
||||
- 贡献指南
|
||||
- 代码注释
|
||||
|
||||
3. **项目文档**
|
||||
- README
|
||||
- CHANGELOG
|
||||
- LICENSE
|
||||
- 项目规划
|
||||
|
||||
### 文档规范
|
||||
|
||||
1. **Markdown 格式**
|
||||
- 使用标准 Markdown 语法
|
||||
- 保持一致的格式
|
||||
- 使用有意义的标题
|
||||
|
||||
2. **内容要求**
|
||||
- 清晰准确
|
||||
- 及时更新
|
||||
- 包含示例
|
||||
- 考虑不同用户水平
|
||||
|
||||
3. **图片和媒体**
|
||||
- 使用 SVG 格式(如可能)
|
||||
- 优化文件大小
|
||||
- 提供替代文本
|
||||
|
||||
## 测试指南
|
||||
|
||||
### 测试类型
|
||||
|
||||
1. **单元测试**
|
||||
- 测试单个函数或方法
|
||||
- 使用 pytest(Python)或 Jest(JavaScript)
|
||||
- 目标覆盖率 > 80%
|
||||
|
||||
2. **集成测试**
|
||||
- 测试模块间交互
|
||||
- 测试 API 端点
|
||||
- 测试数据库操作
|
||||
|
||||
3. **端到端测试**
|
||||
- 测试完整用户流程
|
||||
- 使用 Playwright 或 Cypress
|
||||
- 测试关键业务场景
|
||||
|
||||
### 测试规范
|
||||
|
||||
```python
|
||||
# Python 测试示例
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
class TestPatientManager:
|
||||
"""患者管理器测试类"""
|
||||
|
||||
@pytest.fixture
|
||||
def patient_manager(self):
|
||||
"""测试夹具"""
|
||||
return PatientManager(":memory:")
|
||||
|
||||
def test_create_patient_success(self, patient_manager):
|
||||
"""测试成功创建患者"""
|
||||
patient_data = {
|
||||
"name": "测试患者",
|
||||
"age": 30,
|
||||
"gender": "male"
|
||||
}
|
||||
|
||||
patient_id = patient_manager.create_patient(patient_data)
|
||||
|
||||
assert patient_id is not None
|
||||
assert isinstance(patient_id, int)
|
||||
|
||||
def test_create_patient_invalid_data(self, patient_manager):
|
||||
"""测试无效数据创建患者"""
|
||||
invalid_data = {"name": ""}
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
patient_manager.create_patient(invalid_data)
|
||||
```
|
||||
|
||||
```javascript
|
||||
// JavaScript 测试示例
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PatientList from '@/components/PatientList.vue'
|
||||
|
||||
describe('PatientList', () => {
|
||||
it('renders patient list correctly', () => {
|
||||
const patients = [
|
||||
{ id: 1, name: '患者1', age: 30 },
|
||||
{ id: 2, name: '患者2', age: 25 }
|
||||
]
|
||||
|
||||
const wrapper = mount(PatientList, {
|
||||
props: { patients }
|
||||
})
|
||||
|
||||
expect(wrapper.findAll('.patient-item')).toHaveLength(2)
|
||||
expect(wrapper.text()).toContain('患者1')
|
||||
expect(wrapper.text()).toContain('患者2')
|
||||
})
|
||||
|
||||
it('emits patient-selected event when patient is clicked', async () => {
|
||||
const patients = [{ id: 1, name: '患者1', age: 30 }]
|
||||
|
||||
const wrapper = mount(PatientList, {
|
||||
props: { patients }
|
||||
})
|
||||
|
||||
await wrapper.find('.patient-item').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('patient-selected')).toBeTruthy()
|
||||
expect(wrapper.emitted('patient-selected')[0]).toEqual([patients[0]])
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### 运行测试
|
||||
|
||||
```bash
|
||||
# Python 测试
|
||||
cd backend
|
||||
python -m pytest tests/ -v --cov=.
|
||||
|
||||
# JavaScript 测试
|
||||
cd src/renderer
|
||||
npm run test
|
||||
|
||||
# 所有测试
|
||||
npm run test
|
||||
```
|
||||
|
||||
## 发布流程
|
||||
|
||||
### 版本管理
|
||||
|
||||
我们使用语义化版本控制:
|
||||
|
||||
- **主版本号**: 不兼容的 API 修改
|
||||
- **次版本号**: 向下兼容的功能性新增
|
||||
- **修订号**: 向下兼容的问题修正
|
||||
|
||||
### 发布步骤
|
||||
|
||||
1. **更新版本号**
|
||||
```bash
|
||||
# 更新 package.json 中的版本号
|
||||
npm version patch|minor|major
|
||||
```
|
||||
|
||||
2. **更新 CHANGELOG**
|
||||
- 记录所有重要更改
|
||||
- 按类型分组(新增、更改、修复等)
|
||||
|
||||
3. **创建发布标签**
|
||||
```bash
|
||||
git tag -a v1.0.0 -m "Release version 1.0.0"
|
||||
git push origin v1.0.0
|
||||
```
|
||||
|
||||
4. **构建和测试**
|
||||
```bash
|
||||
npm run build
|
||||
npm run test
|
||||
```
|
||||
|
||||
5. **发布**
|
||||
- 创建 GitHub Release
|
||||
- 上传构建产物
|
||||
- 发布到包管理器(如需要)
|
||||
|
||||
## 社区支持
|
||||
|
||||
### 获取帮助
|
||||
|
||||
- **GitHub Discussions**: 一般讨论和问题
|
||||
- **GitHub Issues**: Bug 报告和功能请求
|
||||
- **邮件**: dev@example.com
|
||||
- **文档**: 项目 Wiki
|
||||
|
||||
### 参与社区
|
||||
|
||||
- 回答其他用户的问题
|
||||
- 参与功能讨论
|
||||
- 分享使用经验
|
||||
- 推广项目
|
||||
|
||||
## 致谢
|
||||
|
||||
感谢所有为这个项目做出贡献的开发者!
|
||||
|
||||
### 贡献者
|
||||
|
||||
- [贡献者列表](https://github.com/example/body-balance-evaluation/contributors)
|
||||
|
||||
### 特别感谢
|
||||
|
||||
- 感谢所有提供反馈和建议的用户
|
||||
- 感谢开源社区提供的优秀工具和库
|
||||
- 感谢医疗专业人士提供的专业指导
|
||||
|
||||
---
|
||||
|
||||
再次感谢您的贡献!如果您有任何问题,请随时联系我们。
|
||||
425
README.md
Normal file
425
README.md
Normal file
@ -0,0 +1,425 @@
|
||||
# 身体平衡评估系统
|
||||
|
||||
一个基于多传感器融合技术的专业身体平衡评估与分析系统,为用户提供准确的平衡能力评估和康复指导。
|
||||
|
||||
## 系统特性
|
||||
|
||||
### 🎯 核心功能
|
||||
- **实时姿态检测**: 基于MediaPipe的高精度人体姿态识别
|
||||
- **多传感器融合**: 整合摄像头、IMU传感器和压力传感器数据
|
||||
- **智能分析引擎**: 多维度平衡能力评估算法
|
||||
- **可视化报告**: 直观的数据图表和分析报告
|
||||
- **历史数据管理**: 完整的检测记录存储和对比分析
|
||||
|
||||
### 🔧 技术特点
|
||||
- **现代化架构**: Vue 3 + Python Flask 前后端分离
|
||||
- **实时通信**: WebSocket 实时数据传输
|
||||
- **跨平台支持**: Windows、macOS、Linux
|
||||
- **模块化设计**: 清晰的目录结构,易于扩展和维护
|
||||
- **数据安全**: 本地存储,保护用户隐私
|
||||
- **开发友好**: 独立的前后端开发环境,支持热重载
|
||||
- **部署简化**: 一键安装和启动脚本,降低部署复杂度
|
||||
|
||||
## 系统架构
|
||||
|
||||
```
|
||||
身体平衡评估系统/
|
||||
├── backend/ # 后端服务
|
||||
│ ├── main.py # 主启动脚本
|
||||
│ ├── app.py # Flask 主应用
|
||||
│ ├── database.py # 数据库管理
|
||||
│ ├── device_manager.py # 设备管理
|
||||
│ ├── detection_engine.py # 检测引擎
|
||||
│ ├── data_processor.py # 数据处理
|
||||
│ ├── utils.py # 工具函数
|
||||
│ └── requirements.txt # Python 依赖
|
||||
├── frontend/ # 前端应用
|
||||
│ └── src/renderer/ # 前端源码
|
||||
│ ├── src/
|
||||
│ │ ├── views/ # 页面组件
|
||||
│ │ ├── stores/ # 状态管理
|
||||
│ │ ├── services/ # API 服务
|
||||
│ │ └── router/ # 路由配置
|
||||
│ ├── package.json # Node.js 依赖
|
||||
│ └── vite.config.js # 构建配置
|
||||
├── data/ # 数据目录
|
||||
├── logs/ # 日志目录
|
||||
├── venv/ # Python 虚拟环境
|
||||
├── install.bat # 安装脚本
|
||||
├── start_dev.bat # 开发模式启动脚本
|
||||
└── start_prod.bat # 生产模式启动脚本
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
- **Python**: 3.8 或更高版本
|
||||
- **Node.js**: 16.0 或更高版本 (开发模式)
|
||||
- **操作系统**: Windows 10/11, macOS 10.15+, Ubuntu 18.04+
|
||||
|
||||
### 安装步骤
|
||||
|
||||
#### 方式一:一键安装 (Windows 推荐)
|
||||
|
||||
1. **克隆项目**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd BodyBalanceEvaluation
|
||||
```
|
||||
|
||||
2. **运行安装脚本**
|
||||
```bash
|
||||
install.bat
|
||||
```
|
||||
|
||||
安装脚本会自动完成:
|
||||
- 检查 Python 和 Node.js 环境
|
||||
- 创建 Python 虚拟环境
|
||||
- 安装后端依赖
|
||||
- 安装前端依赖
|
||||
- 创建必要的目录结构
|
||||
|
||||
#### 方式二:手动安装
|
||||
|
||||
1. **克隆项目**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd BodyBalanceEvaluation
|
||||
```
|
||||
|
||||
2. **创建虚拟环境**
|
||||
```bash
|
||||
python -m venv venv
|
||||
venv\Scripts\activate # Windows
|
||||
# source venv/bin/activate # macOS/Linux
|
||||
```
|
||||
|
||||
3. **安装 Python 依赖**
|
||||
```bash
|
||||
pip install -r backend/requirements.txt
|
||||
```
|
||||
|
||||
4. **安装前端依赖** (开发模式)
|
||||
```bash
|
||||
cd frontend/src/renderer
|
||||
npm install
|
||||
cd ../../..
|
||||
```
|
||||
|
||||
### 启动应用程序
|
||||
|
||||
**Windows 用户 (推荐)**:
|
||||
```bash
|
||||
# 一键安装 (首次使用)
|
||||
install.bat
|
||||
|
||||
# 开发模式
|
||||
start_dev.bat
|
||||
|
||||
# 生产模式
|
||||
start_prod.bat
|
||||
```
|
||||
|
||||
**手动启动**:
|
||||
```bash
|
||||
# 激活虚拟环境
|
||||
venv\Scripts\activate
|
||||
|
||||
# 进入后端目录
|
||||
cd backend
|
||||
|
||||
# 开发模式
|
||||
python main.py --mode development
|
||||
|
||||
# 生产模式
|
||||
python main.py --mode production
|
||||
```
|
||||
|
||||
### 命令行参数
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python main.py [选项]
|
||||
|
||||
选项:
|
||||
--mode {development,production} 运行模式 (默认: development)
|
||||
--host HOST 服务器主机 (默认: 127.0.0.1)
|
||||
--port PORT 服务器端口 (默认: 5000)
|
||||
--no-browser 不自动打开浏览器
|
||||
--log-level {DEBUG,INFO,WARNING,ERROR} 日志级别 (默认: INFO)
|
||||
```
|
||||
|
||||
## 使用指南
|
||||
|
||||
### 1. 系统设置
|
||||
|
||||
首次使用前,请进入「系统设置」页面配置:
|
||||
|
||||
- **设备配置**: 选择摄像头、配置串口设备
|
||||
- **检测参数**: 设置默认检测时长、采样频率等
|
||||
- **数据管理**: 配置数据存储路径和清理策略
|
||||
|
||||
### 2. 患者管理
|
||||
|
||||
- 添加患者基本信息(姓名、年龄、性别等)
|
||||
- 记录患者病史和康复目标
|
||||
- 管理患者档案和检测记录
|
||||
|
||||
### 3. 姿态检测
|
||||
|
||||
1. **选择患者**: 从患者列表中选择或新建患者
|
||||
2. **设备准备**: 确保摄像头和传感器正常连接
|
||||
3. **参数配置**: 设置检测时长、采样频率等参数
|
||||
4. **开始检测**: 点击开始按钮进行实时检测
|
||||
5. **查看结果**: 检测完成后查看分析结果和建议
|
||||
|
||||
### 4. 数据分析
|
||||
|
||||
- **单次分析**: 查看单次检测的详细数据和图表
|
||||
- **对比分析**: 比较多次检测结果,观察变化趋势
|
||||
- **报告生成**: 生成 PDF 格式的专业评估报告
|
||||
- **数据导出**: 导出原始数据用于进一步分析
|
||||
|
||||
### 5. 历史记录
|
||||
|
||||
- 浏览所有历史检测记录
|
||||
- 按患者、日期、状态等条件筛选
|
||||
- 批量导出和删除操作
|
||||
- 时间线视图查看检测历史
|
||||
|
||||
## 设备支持
|
||||
|
||||
### 摄像头
|
||||
- USB 摄像头 (推荐 1080p 30fps)
|
||||
- 内置摄像头
|
||||
- 网络摄像头
|
||||
|
||||
### IMU 传感器
|
||||
- 支持串口通信的 9 轴 IMU
|
||||
- 波特率: 9600, 115200, 230400
|
||||
- 数据格式: 加速度、陀螺仪、磁力计
|
||||
|
||||
### 压力传感器
|
||||
- 多点压力传感器阵列
|
||||
- 串口通信接口
|
||||
- 支持 1-16 个传感器点
|
||||
|
||||
## 开发指南
|
||||
|
||||
### 后端开发
|
||||
|
||||
后端使用 Flask 框架,主要模块:
|
||||
|
||||
- `main.py`: 主启动脚本和进程管理
|
||||
- `app.py`: 主应用和 API 路由
|
||||
- `database.py`: SQLite 数据库操作
|
||||
- `device_manager.py`: 硬件设备管理
|
||||
- `detection_engine.py`: 检测算法引擎
|
||||
- `data_processor.py`: 数据分析和处理
|
||||
|
||||
### 前端开发
|
||||
|
||||
前端使用 Vue 3 + Element Plus,主要特性:
|
||||
|
||||
- **组合式 API**: 使用 Vue 3 Composition API
|
||||
- **状态管理**: Pinia 状态管理库
|
||||
- **UI 组件**: Element Plus 组件库
|
||||
- **图表库**: ECharts 数据可视化
|
||||
- **构建工具**: Vite 快速构建
|
||||
|
||||
### 添加新功能
|
||||
|
||||
1. **后端 API**:
|
||||
```python
|
||||
@app.route('/api/new-feature', methods=['POST'])
|
||||
def new_feature():
|
||||
# 实现新功能逻辑
|
||||
return jsonify({'status': 'success'})
|
||||
```
|
||||
|
||||
2. **前端服务**:
|
||||
```javascript
|
||||
// frontend/src/renderer/src/services/api.js
|
||||
export const newFeatureAPI = {
|
||||
doSomething: (data) => api.post('/new-feature', data)
|
||||
}
|
||||
```
|
||||
|
||||
3. **前端组件**:
|
||||
```vue
|
||||
<template>
|
||||
<!-- 新功能界面 -->
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { newFeatureAPI } from '../services/api'
|
||||
</script>
|
||||
```
|
||||
|
||||
### 项目结构优势
|
||||
|
||||
新的项目结构带来以下优势:
|
||||
|
||||
1. **清晰分离**: 前后端代码完全分离,便于团队协作开发
|
||||
2. **独立部署**: 前后端可以独立部署和扩展
|
||||
3. **开发效率**: 前后端可以并行开发,提高开发效率
|
||||
4. **维护性**: 模块化结构便于代码维护和功能扩展
|
||||
5. **版本管理**: 前后端可以独立进行版本控制
|
||||
6. **技术栈**: 前后端可以选择最适合的技术栈
|
||||
|
||||
### 开发环境配置
|
||||
|
||||
**后端开发**:
|
||||
```bash
|
||||
# 激活虚拟环境
|
||||
venv\Scripts\activate
|
||||
|
||||
# 进入后端目录
|
||||
cd backend
|
||||
|
||||
# 启动开发服务器
|
||||
python main.py --mode development --log-level DEBUG
|
||||
```
|
||||
|
||||
**前端开发**:
|
||||
```bash
|
||||
# 进入前端目录
|
||||
cd frontend/src/renderer
|
||||
|
||||
# 启动开发服务器
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**同时开发** (推荐):
|
||||
```bash
|
||||
# 使用开发脚本同时启动前后端
|
||||
start_dev.bat
|
||||
```
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 常见问题
|
||||
|
||||
**Q: 摄像头无法识别**
|
||||
A: 检查摄像头连接,确保没有被其他应用占用,在设备设置中刷新摄像头列表。
|
||||
|
||||
**Q: 传感器连接失败**
|
||||
A: 确认串口设置正确,检查设备驱动是否安装,尝试不同的波特率设置。
|
||||
|
||||
**Q: 前端页面无法加载**
|
||||
A: 检查后端服务是否正常启动,确认防火墙设置,查看浏览器控制台错误信息。
|
||||
|
||||
**Q: 检测结果不准确**
|
||||
A: 确保设备已正确校准,检查环境光线条件,调整检测参数设置。
|
||||
|
||||
### 日志查看
|
||||
|
||||
系统日志保存在 `logs/` 目录下:
|
||||
|
||||
- `app.log`: 应用程序主日志
|
||||
- `device.log`: 设备管理日志
|
||||
- `detection.log`: 检测引擎日志
|
||||
- `error.log`: 错误日志
|
||||
|
||||
### 性能优化
|
||||
|
||||
1. **硬件要求**:
|
||||
- CPU: Intel i5 或同等性能
|
||||
- 内存: 8GB RAM
|
||||
- 存储: 10GB 可用空间
|
||||
|
||||
2. **软件优化**:
|
||||
- 关闭不必要的后台程序
|
||||
- 使用 SSD 存储提高 I/O 性能
|
||||
- 定期清理历史数据和日志
|
||||
|
||||
## 数据格式
|
||||
|
||||
### 检测数据结构
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "uuid",
|
||||
"patient_id": "patient_uuid",
|
||||
"timestamp": "2024-01-01T12:00:00Z",
|
||||
"duration": 60,
|
||||
"data": {
|
||||
"camera": {
|
||||
"landmarks": [...],
|
||||
"confidence": 0.95
|
||||
},
|
||||
"imu": {
|
||||
"acceleration": [x, y, z],
|
||||
"gyroscope": [x, y, z],
|
||||
"magnetometer": [x, y, z]
|
||||
},
|
||||
"pressure": {
|
||||
"sensors": [p1, p2, p3, p4],
|
||||
"center_of_pressure": [x, y]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 分析结果格式
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "uuid",
|
||||
"analysis_time": "2024-01-01T12:01:00Z",
|
||||
"overall_assessment": "good",
|
||||
"balance_score": 85,
|
||||
"posture_score": 78,
|
||||
"metrics": {
|
||||
"sway_area": 2.5,
|
||||
"sway_velocity": 1.2,
|
||||
"postural_stability": 0.85
|
||||
},
|
||||
"recommendations": [
|
||||
"建议加强核心肌群训练",
|
||||
"注意保持正确站姿"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目采用 MIT 许可证。详见 [LICENSE](LICENSE) 文件。
|
||||
|
||||
## 贡献指南
|
||||
|
||||
欢迎贡献代码!请遵循以下步骤:
|
||||
|
||||
1. Fork 本仓库
|
||||
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||
5. 开启 Pull Request
|
||||
|
||||
## 支持
|
||||
|
||||
如果您遇到问题或有建议,请:
|
||||
|
||||
- 查看 [FAQ](docs/FAQ.md)
|
||||
- 提交 [Issue](issues)
|
||||
- 发送邮件至: support@example.com
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v1.1.0 (2024-01-15)
|
||||
- **项目重构**: 前后端完全分离,优化项目结构
|
||||
- **新增脚本**: 添加一键安装和启动脚本 (install.bat, start_dev.bat, start_prod.bat)
|
||||
- **开发体验**: 改进开发环境配置,支持独立的前后端开发
|
||||
- **文档更新**: 完善 README 文档,添加详细的安装和使用说明
|
||||
- **路径优化**: 统一使用虚拟环境,规范化依赖管理
|
||||
|
||||
### v1.0.0 (2024-01-01)
|
||||
- 初始版本发布
|
||||
- 基础检测功能
|
||||
- 患者管理系统
|
||||
- 数据分析和报告生成
|
||||
|
||||
---
|
||||
|
||||
**身体平衡评估系统** - 专业的平衡能力评估解决方案
|
||||
630
backend/app.py
Normal file
630
backend/app.py
Normal file
@ -0,0 +1,630 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
平衡体态检测系统 - 后端服务
|
||||
基于Flask的本地API服务
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from flask import Flask, request, jsonify, send_file
|
||||
from flask_cors import CORS
|
||||
import sqlite3
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
# 添加当前目录到Python路径
|
||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# 导入自定义模块
|
||||
from database import DatabaseManager
|
||||
from device_manager import DeviceManager
|
||||
from detection_engine import DetectionEngine
|
||||
from data_processor import DataProcessor
|
||||
|
||||
# 配置日志
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler('logs/backend.log', encoding='utf-8'),
|
||||
logging.StreamHandler()
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 创建Flask应用
|
||||
app = Flask(__name__)
|
||||
app.config['SECRET_KEY'] = 'body-balance-detection-system-2024'
|
||||
|
||||
# 启用CORS支持
|
||||
CORS(app, origins=['http://localhost:3000', 'http://localhost:3001', 'file://*'])
|
||||
|
||||
# 全局变量
|
||||
db_manager = None
|
||||
device_manager = None
|
||||
detection_engine = None
|
||||
data_processor = None
|
||||
current_detection = None
|
||||
detection_thread = None
|
||||
|
||||
def init_app():
|
||||
"""初始化应用"""
|
||||
global db_manager, device_manager, detection_engine, data_processor
|
||||
|
||||
try:
|
||||
# 创建必要的目录
|
||||
os.makedirs('logs', exist_ok=True)
|
||||
os.makedirs('data', exist_ok=True)
|
||||
os.makedirs('exports', exist_ok=True)
|
||||
os.makedirs('videos', exist_ok=True)
|
||||
|
||||
# 初始化数据库
|
||||
db_manager = DatabaseManager('data/body_balance.db')
|
||||
db_manager.init_database()
|
||||
|
||||
# 初始化设备管理器
|
||||
device_manager = DeviceManager()
|
||||
|
||||
# 初始化检测引擎
|
||||
detection_engine = DetectionEngine()
|
||||
|
||||
# 初始化数据处理器
|
||||
data_processor = DataProcessor()
|
||||
|
||||
logger.info('应用初始化完成')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'应用初始化失败: {e}')
|
||||
raise
|
||||
|
||||
# ==================== 基础API ====================
|
||||
|
||||
@app.route('/health', methods=['GET'])
|
||||
def health_check():
|
||||
"""健康检查"""
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'version': '1.0.0'
|
||||
})
|
||||
|
||||
@app.route('/api/health', methods=['GET'])
|
||||
def api_health_check():
|
||||
"""API健康检查"""
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'version': '1.0.0'
|
||||
})
|
||||
|
||||
# ==================== 认证API ====================
|
||||
|
||||
@app.route('/api/auth/login', methods=['POST'])
|
||||
def login():
|
||||
"""用户登录"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
username = data.get('username')
|
||||
password = data.get('password')
|
||||
remember = data.get('remember', False)
|
||||
|
||||
# 简单的模拟登录验证
|
||||
if username and password:
|
||||
# 这里可以添加真实的用户验证逻辑
|
||||
# 目前使用模拟数据
|
||||
user_data = {
|
||||
'id': 1,
|
||||
'username': username,
|
||||
'name': '医生',
|
||||
'role': 'doctor',
|
||||
'avatar': ''
|
||||
}
|
||||
|
||||
# 生成简单的token(实际项目中应使用JWT等安全token)
|
||||
token = f"token_{username}_{int(time.time())}"
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
'token': token,
|
||||
'user': user_data
|
||||
},
|
||||
'message': '登录成功'
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': '用户名或密码不能为空'
|
||||
}), 400
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'登录失败: {e}')
|
||||
return jsonify({'success': False, 'message': '登录失败'}), 500
|
||||
|
||||
@app.route('/api/auth/register', methods=['POST'])
|
||||
def register():
|
||||
"""用户注册"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
username = data.get('username')
|
||||
password = data.get('password')
|
||||
email = data.get('email')
|
||||
|
||||
# 简单的模拟注册
|
||||
if username and password:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': '注册成功,请登录'
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': '用户名和密码不能为空'
|
||||
}), 400
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'注册失败: {e}')
|
||||
return jsonify({'success': False, 'message': '注册失败'}), 500
|
||||
|
||||
@app.route('/api/auth/logout', methods=['POST'])
|
||||
def logout():
|
||||
"""用户退出"""
|
||||
try:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': '退出成功'
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f'退出失败: {e}')
|
||||
return jsonify({'success': False, 'message': '退出失败'}), 500
|
||||
|
||||
@app.route('/api/auth/verify', methods=['GET'])
|
||||
def verify_token():
|
||||
"""验证token"""
|
||||
try:
|
||||
# 简单的token验证(实际项目中应验证JWT等)
|
||||
auth_header = request.headers.get('Authorization')
|
||||
if auth_header and auth_header.startswith('Bearer '):
|
||||
token = auth_header.split(' ')[1]
|
||||
# 这里可以添加真实的token验证逻辑
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {'valid': True}
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {'valid': True} # 暂时总是返回有效
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f'验证token失败: {e}')
|
||||
return jsonify({'success': False, 'message': '验证失败'}), 500
|
||||
|
||||
@app.route('/api/auth/forgot-password', methods=['POST'])
|
||||
def forgot_password():
|
||||
"""忘记密码"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
email = data.get('email')
|
||||
|
||||
if email:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': '重置密码邮件已发送'
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': '邮箱不能为空'
|
||||
}), 400
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'忘记密码处理失败: {e}')
|
||||
return jsonify({'success': False, 'message': '处理失败'}), 500
|
||||
|
||||
@app.route('/api/auth/reset-password', methods=['POST'])
|
||||
def reset_password():
|
||||
"""重置密码"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
token = data.get('token')
|
||||
password = data.get('password')
|
||||
|
||||
if token and password:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': '密码重置成功'
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': '参数不完整'
|
||||
}), 400
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'重置密码失败: {e}')
|
||||
return jsonify({'success': False, 'message': '重置失败'}), 500
|
||||
|
||||
@app.route('/api/system/info', methods=['GET'])
|
||||
def get_system_info():
|
||||
"""获取系统信息"""
|
||||
try:
|
||||
info = {
|
||||
'version': '1.0.0',
|
||||
'start_time': datetime.now().isoformat(),
|
||||
'database_status': 'connected' if db_manager else 'disconnected',
|
||||
'device_count': len(device_manager.get_connected_devices()) if device_manager else 0
|
||||
}
|
||||
return jsonify({'success': True, 'data': info})
|
||||
except Exception as e:
|
||||
logger.error(f'获取系统信息失败: {e}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
# ==================== 设备管理API ====================
|
||||
|
||||
@app.route('/api/devices/status', methods=['GET'])
|
||||
def get_device_status():
|
||||
"""获取设备状态"""
|
||||
try:
|
||||
if not device_manager:
|
||||
return jsonify({'camera': False, 'imu': False, 'pressure': False})
|
||||
|
||||
status = device_manager.get_device_status()
|
||||
return jsonify(status)
|
||||
except Exception as e:
|
||||
logger.error(f'获取设备状态失败: {e}')
|
||||
return jsonify({'camera': False, 'imu': False, 'pressure': False})
|
||||
|
||||
@app.route('/api/devices/refresh', methods=['POST'])
|
||||
def refresh_devices():
|
||||
"""刷新设备连接"""
|
||||
try:
|
||||
if device_manager:
|
||||
device_manager.refresh_devices()
|
||||
return jsonify({'success': True, 'message': '设备已刷新'})
|
||||
except Exception as e:
|
||||
logger.error(f'刷新设备失败: {e}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/devices/calibrate', methods=['POST'])
|
||||
def calibrate_devices():
|
||||
"""校准设备"""
|
||||
try:
|
||||
if device_manager:
|
||||
result = device_manager.calibrate_devices()
|
||||
return jsonify({'success': True, 'data': result})
|
||||
return jsonify({'success': False, 'error': '设备管理器未初始化'}), 500
|
||||
except Exception as e:
|
||||
logger.error(f'设备校准失败: {e}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
# ==================== 患者管理API ====================
|
||||
|
||||
@app.route('/api/patients', methods=['GET'])
|
||||
def get_patients():
|
||||
"""获取患者列表"""
|
||||
try:
|
||||
page = int(request.args.get('page', 1))
|
||||
size = int(request.args.get('size', 10))
|
||||
keyword = request.args.get('keyword', '')
|
||||
|
||||
patients = db_manager.get_patients(page, size, keyword)
|
||||
total = db_manager.get_patients_count(keyword)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
'patients': patients,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'size': size
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f'获取患者列表失败: {e}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/patients', methods=['POST'])
|
||||
def create_patient():
|
||||
"""创建患者"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
patient_id = db_manager.create_patient(data)
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {'patient_id': patient_id},
|
||||
'message': '患者创建成功'
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f'创建患者失败: {e}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/patients/<patient_id>', methods=['PUT'])
|
||||
def update_patient(patient_id):
|
||||
"""更新患者信息"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
db_manager.update_patient(patient_id, data)
|
||||
return jsonify({'success': True, 'message': '患者信息更新成功'})
|
||||
except Exception as e:
|
||||
logger.error(f'更新患者信息失败: {e}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/patients/<patient_id>', methods=['DELETE'])
|
||||
def delete_patient(patient_id):
|
||||
"""删除患者"""
|
||||
try:
|
||||
db_manager.delete_patient(patient_id)
|
||||
return jsonify({'success': True, 'message': '患者删除成功'})
|
||||
except Exception as e:
|
||||
logger.error(f'删除患者失败: {e}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
# ==================== 检测管理API ====================
|
||||
|
||||
@app.route('/api/detection/start', methods=['POST'])
|
||||
def start_detection():
|
||||
"""开始检测"""
|
||||
global current_detection, detection_thread
|
||||
|
||||
try:
|
||||
if current_detection and current_detection.get('status') == 'running':
|
||||
return jsonify({'success': False, 'error': '检测已在进行中'}), 400
|
||||
|
||||
data = request.get_json()
|
||||
patient_id = data.get('patientId')
|
||||
settings = data.get('settings', {})
|
||||
|
||||
# 创建检测会话
|
||||
session_id = db_manager.create_detection_session(patient_id, settings)
|
||||
|
||||
# 初始化检测状态
|
||||
current_detection = {
|
||||
'session_id': session_id,
|
||||
'patient_id': patient_id,
|
||||
'status': 'running',
|
||||
'start_time': datetime.now(),
|
||||
'settings': settings,
|
||||
'data_points': 0
|
||||
}
|
||||
|
||||
# 启动检测线程
|
||||
detection_thread = threading.Thread(
|
||||
target=run_detection,
|
||||
args=(session_id, settings)
|
||||
)
|
||||
detection_thread.start()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {'session_id': session_id},
|
||||
'message': '检测已开始'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'开始检测失败: {e}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/detection/stop', methods=['POST'])
|
||||
def stop_detection():
|
||||
"""停止检测"""
|
||||
global current_detection
|
||||
|
||||
try:
|
||||
if not current_detection or current_detection.get('status') != 'running':
|
||||
return jsonify({'success': False, 'error': '没有正在进行的检测'}), 400
|
||||
|
||||
# 更新检测状态
|
||||
current_detection['status'] = 'stopped'
|
||||
current_detection['end_time'] = datetime.now()
|
||||
|
||||
# 等待检测线程结束
|
||||
if detection_thread and detection_thread.is_alive():
|
||||
detection_thread.join(timeout=5)
|
||||
|
||||
return jsonify({'success': True, 'message': '检测已停止'})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'停止检测失败: {e}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/detection/status', methods=['GET'])
|
||||
def get_detection_status():
|
||||
"""获取检测状态"""
|
||||
try:
|
||||
if not current_detection:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {'status': 'idle'}
|
||||
})
|
||||
|
||||
# 计算运行时间
|
||||
if current_detection.get('status') == 'running':
|
||||
elapsed = (datetime.now() - current_detection['start_time']).total_seconds()
|
||||
current_detection['elapsed_time'] = int(elapsed)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': current_detection
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'获取检测状态失败: {e}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/detection/data', methods=['GET'])
|
||||
def get_realtime_data():
|
||||
"""获取实时检测数据"""
|
||||
try:
|
||||
if not current_detection or current_detection.get('status') != 'running':
|
||||
return jsonify({'success': False, 'error': '没有正在进行的检测'})
|
||||
|
||||
# 获取最新的检测数据
|
||||
session_id = current_detection['session_id']
|
||||
data = detection_engine.get_latest_data(session_id)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': data
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'获取实时数据失败: {e}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
# ==================== 数据分析API ====================
|
||||
|
||||
@app.route('/api/analysis/session/<session_id>', methods=['GET'])
|
||||
def analyze_session(session_id):
|
||||
"""分析检测会话数据"""
|
||||
try:
|
||||
# 获取会话数据
|
||||
session_data = db_manager.get_session_data(session_id)
|
||||
if not session_data:
|
||||
return jsonify({'success': False, 'error': '会话不存在'}), 404
|
||||
|
||||
# 进行数据分析
|
||||
analysis_result = data_processor.analyze_session(session_data)
|
||||
|
||||
# 保存分析结果
|
||||
db_manager.save_analysis_result(session_id, analysis_result)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': analysis_result
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'分析会话数据失败: {e}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/export/report/<session_id>', methods=['GET'])
|
||||
def export_report(session_id):
|
||||
"""导出检测报告"""
|
||||
try:
|
||||
# 生成报告
|
||||
report_path = data_processor.generate_report(session_id)
|
||||
|
||||
if not os.path.exists(report_path):
|
||||
return jsonify({'success': False, 'error': '报告生成失败'}), 500
|
||||
|
||||
return send_file(
|
||||
report_path,
|
||||
as_attachment=True,
|
||||
download_name=f'detection_report_{session_id}.pdf'
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'导出报告失败: {e}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
# ==================== 历史记录API ====================
|
||||
|
||||
@app.route('/api/history/sessions', methods=['GET'])
|
||||
def get_detection_sessions():
|
||||
"""获取检测会话历史"""
|
||||
try:
|
||||
page = int(request.args.get('page', 1))
|
||||
size = int(request.args.get('size', 10))
|
||||
patient_id = request.args.get('patient_id')
|
||||
|
||||
sessions = db_manager.get_detection_sessions(page, size, patient_id)
|
||||
total = db_manager.get_sessions_count(patient_id)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
'sessions': sessions,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'size': size
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'获取检测历史失败: {e}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
def run_detection(session_id, settings):
|
||||
"""运行检测的后台线程"""
|
||||
global current_detection
|
||||
|
||||
try:
|
||||
logger.info(f'开始检测会话: {session_id}')
|
||||
|
||||
# 检测循环
|
||||
while (current_detection and
|
||||
current_detection.get('status') == 'running'):
|
||||
|
||||
# 采集数据
|
||||
if device_manager:
|
||||
data = device_manager.collect_data()
|
||||
if data:
|
||||
# 保存数据到数据库
|
||||
db_manager.save_detection_data(session_id, data)
|
||||
current_detection['data_points'] += 1
|
||||
|
||||
# 根据采样频率控制循环间隔
|
||||
frequency = settings.get('frequency', 60)
|
||||
time.sleep(1.0 / frequency)
|
||||
|
||||
# 检查是否达到设定时长
|
||||
duration = settings.get('duration', 0)
|
||||
if duration > 0:
|
||||
elapsed = (datetime.now() - current_detection['start_time']).total_seconds()
|
||||
if elapsed >= duration:
|
||||
current_detection['status'] = 'completed'
|
||||
break
|
||||
|
||||
# 更新会话状态
|
||||
if current_detection:
|
||||
db_manager.update_session_status(
|
||||
session_id,
|
||||
current_detection['status'],
|
||||
current_detection.get('data_points', 0)
|
||||
)
|
||||
|
||||
logger.info(f'检测会话完成: {session_id}')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'检测线程异常: {e}')
|
||||
if current_detection:
|
||||
current_detection['status'] = 'error'
|
||||
current_detection['error'] = str(e)
|
||||
|
||||
# ==================== 错误处理 ====================
|
||||
|
||||
@app.errorhandler(404)
|
||||
def not_found(error):
|
||||
return jsonify({'success': False, 'error': 'API接口不存在'}), 404
|
||||
|
||||
@app.errorhandler(500)
|
||||
def internal_error(error):
|
||||
return jsonify({'success': False, 'error': '服务器内部错误'}), 500
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
# 初始化应用
|
||||
init_app()
|
||||
|
||||
# 启动Flask服务
|
||||
logger.info('启动后端服务...')
|
||||
app.run(
|
||||
host='127.0.0.1',
|
||||
port=5000,
|
||||
debug=False,
|
||||
threaded=True
|
||||
)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info('服务被用户中断')
|
||||
except Exception as e:
|
||||
logger.error(f'服务启动失败: {e}')
|
||||
sys.exit(1)
|
||||
finally:
|
||||
logger.info('后端服务已停止')
|
||||
41
backend/config.ini
Normal file
41
backend/config.ini
Normal file
@ -0,0 +1,41 @@
|
||||
[APP]
|
||||
name = Body Balance Evaluation System
|
||||
version = 1.0.0
|
||||
debug = false
|
||||
log_level = INFO
|
||||
|
||||
[SERVER]
|
||||
host = 127.0.0.1
|
||||
port = 5000
|
||||
cors_origins = *
|
||||
|
||||
[DATABASE]
|
||||
path = data/balance_system.db
|
||||
backup_interval = 24
|
||||
max_backups = 7
|
||||
|
||||
[DEVICES]
|
||||
camera_index = 0
|
||||
camera_width = 640
|
||||
camera_height = 480
|
||||
camera_fps = 30
|
||||
imu_port = COM3
|
||||
pressure_port = COM4
|
||||
|
||||
[DETECTION]
|
||||
default_duration = 60
|
||||
sampling_rate = 30
|
||||
balance_threshold = 0.2
|
||||
posture_threshold = 5.0
|
||||
|
||||
[DATA_PROCESSING]
|
||||
filter_window = 5
|
||||
outlier_threshold = 2.0
|
||||
chart_dpi = 300
|
||||
export_format = csv
|
||||
|
||||
[SECURITY]
|
||||
secret_key = 026efbf83a2fe101f168780740da86bf1c9260625458e6782738aa9cf18f8e37
|
||||
session_timeout = 3600
|
||||
max_login_attempts = 5
|
||||
|
||||
730
backend/data_processor.py
Normal file
730
backend/data_processor.py
Normal file
@ -0,0 +1,730 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
数据处理模块
|
||||
负责数据分析、报告生成和数据导出
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Any, Tuple
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import matplotlib.pyplot as plt
|
||||
import matplotlib.dates as mdates
|
||||
from matplotlib.backends.backend_pdf import PdfPages
|
||||
import seaborn as sns
|
||||
from reportlab.lib.pagesizes import letter, A4
|
||||
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Image, Table, TableStyle
|
||||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||
from reportlab.lib.units import inch
|
||||
from reportlab.lib import colors
|
||||
from reportlab.pdfgen import canvas
|
||||
from reportlab.lib.utils import ImageReader
|
||||
from io import BytesIO
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 设置中文字体
|
||||
plt.rcParams['font.sans-serif'] = ['SimHei', 'Microsoft YaHei']
|
||||
plt.rcParams['axes.unicode_minus'] = False
|
||||
sns.set_style("whitegrid")
|
||||
|
||||
class DataProcessor:
|
||||
"""数据处理器"""
|
||||
|
||||
def __init__(self):
|
||||
self.export_dir = Path('exports')
|
||||
self.charts_dir = Path('charts')
|
||||
|
||||
# 创建必要的目录
|
||||
self.export_dir.mkdir(exist_ok=True)
|
||||
self.charts_dir.mkdir(exist_ok=True)
|
||||
|
||||
logger.info('数据处理器初始化完成')
|
||||
|
||||
def analyze_session(self, session_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""分析会话数据"""
|
||||
try:
|
||||
if not session_data or 'data' not in session_data:
|
||||
return {'error': '没有可分析的数据'}
|
||||
|
||||
data_points = session_data['data']
|
||||
if not data_points:
|
||||
return {'error': '数据为空'}
|
||||
|
||||
# 数据预处理
|
||||
processed_data = self._preprocess_session_data(data_points)
|
||||
|
||||
# 统计分析
|
||||
statistical_analysis = self._statistical_analysis(processed_data)
|
||||
|
||||
# 趋势分析
|
||||
trend_analysis = self._trend_analysis(processed_data)
|
||||
|
||||
# 异常检测
|
||||
anomaly_analysis = self._anomaly_detection(processed_data)
|
||||
|
||||
# 生成图表
|
||||
charts = self._generate_charts(processed_data, session_data['id'])
|
||||
|
||||
analysis_result = {
|
||||
'session_info': {
|
||||
'session_id': session_data['id'],
|
||||
'patient_name': session_data.get('patient_name', '未知'),
|
||||
'start_time': session_data.get('start_time'),
|
||||
'end_time': session_data.get('end_time'),
|
||||
'duration': session_data.get('duration'),
|
||||
'data_points': len(data_points)
|
||||
},
|
||||
'statistical': statistical_analysis,
|
||||
'trends': trend_analysis,
|
||||
'anomalies': anomaly_analysis,
|
||||
'charts': charts,
|
||||
'summary': self._generate_summary(statistical_analysis, trend_analysis, anomaly_analysis)
|
||||
}
|
||||
|
||||
return analysis_result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'会话数据分析失败: {e}')
|
||||
return {'error': str(e)}
|
||||
|
||||
def _preprocess_session_data(self, data_points: List[Dict]) -> Dict[str, List]:
|
||||
"""预处理会话数据"""
|
||||
processed = {
|
||||
'timestamps': [],
|
||||
'pressure_left': [],
|
||||
'pressure_right': [],
|
||||
'pressure_total': [],
|
||||
'balance_index': [],
|
||||
'center_of_pressure_x': [],
|
||||
'center_of_pressure_y': [],
|
||||
'imu_pitch': [],
|
||||
'imu_roll': [],
|
||||
'imu_accel_total': []
|
||||
}
|
||||
|
||||
for point in data_points:
|
||||
try:
|
||||
# 解析时间戳
|
||||
timestamp = datetime.fromisoformat(point['timestamp'].replace('Z', '+00:00'))
|
||||
processed['timestamps'].append(timestamp)
|
||||
|
||||
# 解析数据值
|
||||
data_value = point['data_value']
|
||||
|
||||
if point['data_type'] == 'pressure':
|
||||
processed['pressure_left'].append(data_value.get('left_foot', 0))
|
||||
processed['pressure_right'].append(data_value.get('right_foot', 0))
|
||||
processed['pressure_total'].append(data_value.get('total_pressure', 0))
|
||||
processed['balance_index'].append(data_value.get('balance_index', 0))
|
||||
|
||||
cop = data_value.get('center_of_pressure', {'x': 0, 'y': 0})
|
||||
processed['center_of_pressure_x'].append(cop.get('x', 0))
|
||||
processed['center_of_pressure_y'].append(cop.get('y', 0))
|
||||
|
||||
elif point['data_type'] == 'imu':
|
||||
processed['imu_pitch'].append(data_value.get('pitch', 0))
|
||||
processed['imu_roll'].append(data_value.get('roll', 0))
|
||||
processed['imu_accel_total'].append(data_value.get('total_accel', 0))
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f'数据点处理失败: {e}')
|
||||
continue
|
||||
|
||||
return processed
|
||||
|
||||
def _statistical_analysis(self, data: Dict[str, List]) -> Dict[str, Any]:
|
||||
"""统计分析"""
|
||||
try:
|
||||
stats = {}
|
||||
|
||||
# 压力数据统计
|
||||
if data['pressure_total']:
|
||||
pressure_total = np.array(data['pressure_total'])
|
||||
stats['pressure'] = {
|
||||
'mean': float(np.mean(pressure_total)),
|
||||
'std': float(np.std(pressure_total)),
|
||||
'min': float(np.min(pressure_total)),
|
||||
'max': float(np.max(pressure_total)),
|
||||
'median': float(np.median(pressure_total))
|
||||
}
|
||||
|
||||
# 左右脚压力分析
|
||||
if data['pressure_left'] and data['pressure_right']:
|
||||
left_pressure = np.array(data['pressure_left'])
|
||||
right_pressure = np.array(data['pressure_right'])
|
||||
|
||||
stats['pressure_distribution'] = {
|
||||
'left_mean': float(np.mean(left_pressure)),
|
||||
'right_mean': float(np.mean(right_pressure)),
|
||||
'left_ratio': float(np.mean(left_pressure) / np.mean(pressure_total)) if np.mean(pressure_total) > 0 else 0,
|
||||
'right_ratio': float(np.mean(right_pressure) / np.mean(pressure_total)) if np.mean(pressure_total) > 0 else 0,
|
||||
'asymmetry': float(abs(np.mean(left_pressure) - np.mean(right_pressure)) / np.mean(pressure_total)) if np.mean(pressure_total) > 0 else 0
|
||||
}
|
||||
|
||||
# 平衡指数统计
|
||||
if data['balance_index']:
|
||||
balance_index = np.array(data['balance_index'])
|
||||
stats['balance'] = {
|
||||
'mean': float(np.mean(balance_index)),
|
||||
'std': float(np.std(balance_index)),
|
||||
'min': float(np.min(balance_index)),
|
||||
'max': float(np.max(balance_index)),
|
||||
'stability_score': float(1.0 - np.std(balance_index)) # 稳定性评分
|
||||
}
|
||||
|
||||
# 重心位置统计
|
||||
if data['center_of_pressure_x'] and data['center_of_pressure_y']:
|
||||
cop_x = np.array(data['center_of_pressure_x'])
|
||||
cop_y = np.array(data['center_of_pressure_y'])
|
||||
|
||||
stats['center_of_pressure'] = {
|
||||
'mean_x': float(np.mean(cop_x)),
|
||||
'mean_y': float(np.mean(cop_y)),
|
||||
'std_x': float(np.std(cop_x)),
|
||||
'std_y': float(np.std(cop_y)),
|
||||
'range_x': float(np.max(cop_x) - np.min(cop_x)),
|
||||
'range_y': float(np.max(cop_y) - np.min(cop_y)),
|
||||
'total_displacement': float(np.sqrt(np.std(cop_x)**2 + np.std(cop_y)**2))
|
||||
}
|
||||
|
||||
# IMU数据统计
|
||||
if data['imu_pitch'] and data['imu_roll']:
|
||||
pitch = np.array(data['imu_pitch'])
|
||||
roll = np.array(data['imu_roll'])
|
||||
|
||||
stats['posture'] = {
|
||||
'mean_pitch': float(np.mean(pitch)),
|
||||
'mean_roll': float(np.mean(roll)),
|
||||
'std_pitch': float(np.std(pitch)),
|
||||
'std_roll': float(np.std(roll)),
|
||||
'max_pitch': float(np.max(np.abs(pitch))),
|
||||
'max_roll': float(np.max(np.abs(roll))),
|
||||
'posture_stability': float(1.0 - (np.std(pitch) + np.std(roll)) / 20) # 姿态稳定性
|
||||
}
|
||||
|
||||
return stats
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'统计分析失败: {e}')
|
||||
return {}
|
||||
|
||||
def _trend_analysis(self, data: Dict[str, List]) -> Dict[str, Any]:
|
||||
"""趋势分析"""
|
||||
try:
|
||||
trends = {}
|
||||
|
||||
# 平衡指数趋势
|
||||
if data['balance_index'] and len(data['balance_index']) > 10:
|
||||
balance_trend = self._calculate_trend(data['balance_index'])
|
||||
trends['balance_trend'] = {
|
||||
'slope': balance_trend['slope'],
|
||||
'direction': 'improving' if balance_trend['slope'] < 0 else 'deteriorating' if balance_trend['slope'] > 0 else 'stable',
|
||||
'correlation': balance_trend['correlation']
|
||||
}
|
||||
|
||||
# 压力分布趋势
|
||||
if data['pressure_left'] and data['pressure_right'] and len(data['pressure_left']) > 10:
|
||||
left_trend = self._calculate_trend(data['pressure_left'])
|
||||
right_trend = self._calculate_trend(data['pressure_right'])
|
||||
|
||||
trends['pressure_trend'] = {
|
||||
'left_slope': left_trend['slope'],
|
||||
'right_slope': right_trend['slope'],
|
||||
'asymmetry_trend': 'increasing' if abs(left_trend['slope'] - right_trend['slope']) > 0.1 else 'stable'
|
||||
}
|
||||
|
||||
# 姿态趋势
|
||||
if data['imu_pitch'] and data['imu_roll'] and len(data['imu_pitch']) > 10:
|
||||
pitch_trend = self._calculate_trend(data['imu_pitch'])
|
||||
roll_trend = self._calculate_trend(data['imu_roll'])
|
||||
|
||||
trends['posture_trend'] = {
|
||||
'pitch_slope': pitch_trend['slope'],
|
||||
'roll_slope': roll_trend['slope'],
|
||||
'stability_trend': 'improving' if abs(pitch_trend['slope']) + abs(roll_trend['slope']) < 0.1 else 'deteriorating'
|
||||
}
|
||||
|
||||
return trends
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'趋势分析失败: {e}')
|
||||
return {}
|
||||
|
||||
def _calculate_trend(self, values: List[float]) -> Dict[str, float]:
|
||||
"""计算趋势"""
|
||||
try:
|
||||
x = np.arange(len(values))
|
||||
y = np.array(values)
|
||||
|
||||
# 线性回归
|
||||
slope, intercept = np.polyfit(x, y, 1)
|
||||
correlation = np.corrcoef(x, y)[0, 1]
|
||||
|
||||
return {
|
||||
'slope': float(slope),
|
||||
'intercept': float(intercept),
|
||||
'correlation': float(correlation)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'趋势计算失败: {e}')
|
||||
return {'slope': 0, 'intercept': 0, 'correlation': 0}
|
||||
|
||||
def _anomaly_detection(self, data: Dict[str, List]) -> Dict[str, Any]:
|
||||
"""异常检测"""
|
||||
try:
|
||||
anomalies = {}
|
||||
|
||||
# 检测平衡指数异常
|
||||
if data['balance_index']:
|
||||
balance_anomalies = self._detect_outliers(data['balance_index'])
|
||||
anomalies['balance_anomalies'] = {
|
||||
'count': len(balance_anomalies),
|
||||
'percentage': len(balance_anomalies) / len(data['balance_index']) * 100,
|
||||
'indices': balance_anomalies
|
||||
}
|
||||
|
||||
# 检测压力异常
|
||||
if data['pressure_total']:
|
||||
pressure_anomalies = self._detect_outliers(data['pressure_total'])
|
||||
anomalies['pressure_anomalies'] = {
|
||||
'count': len(pressure_anomalies),
|
||||
'percentage': len(pressure_anomalies) / len(data['pressure_total']) * 100,
|
||||
'indices': pressure_anomalies
|
||||
}
|
||||
|
||||
# 检测姿态异常
|
||||
if data['imu_pitch'] and data['imu_roll']:
|
||||
pitch_anomalies = self._detect_outliers(data['imu_pitch'])
|
||||
roll_anomalies = self._detect_outliers(data['imu_roll'])
|
||||
|
||||
anomalies['posture_anomalies'] = {
|
||||
'pitch_count': len(pitch_anomalies),
|
||||
'roll_count': len(roll_anomalies),
|
||||
'total_percentage': (len(pitch_anomalies) + len(roll_anomalies)) / (len(data['imu_pitch']) + len(data['imu_roll'])) * 100
|
||||
}
|
||||
|
||||
return anomalies
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'异常检测失败: {e}')
|
||||
return {}
|
||||
|
||||
def _detect_outliers(self, values: List[float], threshold: float = 2.0) -> List[int]:
|
||||
"""检测异常值"""
|
||||
try:
|
||||
data = np.array(values)
|
||||
mean = np.mean(data)
|
||||
std = np.std(data)
|
||||
|
||||
# 使用Z-score方法检测异常值
|
||||
z_scores = np.abs((data - mean) / std)
|
||||
outlier_indices = np.where(z_scores > threshold)[0].tolist()
|
||||
|
||||
return outlier_indices
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'异常值检测失败: {e}')
|
||||
return []
|
||||
|
||||
def _generate_charts(self, data: Dict[str, List], session_id: str) -> Dict[str, str]:
|
||||
"""生成图表"""
|
||||
try:
|
||||
charts = {}
|
||||
|
||||
# 创建会话专用目录
|
||||
session_charts_dir = self.charts_dir / session_id
|
||||
session_charts_dir.mkdir(exist_ok=True)
|
||||
|
||||
# 生成平衡指数时间序列图
|
||||
if data['timestamps'] and data['balance_index']:
|
||||
chart_path = self._create_balance_chart(data, session_charts_dir)
|
||||
if chart_path:
|
||||
charts['balance_chart'] = str(chart_path)
|
||||
|
||||
# 生成压力分布图
|
||||
if data['timestamps'] and data['pressure_left'] and data['pressure_right']:
|
||||
chart_path = self._create_pressure_chart(data, session_charts_dir)
|
||||
if chart_path:
|
||||
charts['pressure_chart'] = str(chart_path)
|
||||
|
||||
# 生成重心轨迹图
|
||||
if data['center_of_pressure_x'] and data['center_of_pressure_y']:
|
||||
chart_path = self._create_cop_trajectory_chart(data, session_charts_dir)
|
||||
if chart_path:
|
||||
charts['cop_trajectory_chart'] = str(chart_path)
|
||||
|
||||
# 生成姿态角度图
|
||||
if data['timestamps'] and data['imu_pitch'] and data['imu_roll']:
|
||||
chart_path = self._create_posture_chart(data, session_charts_dir)
|
||||
if chart_path:
|
||||
charts['posture_chart'] = str(chart_path)
|
||||
|
||||
return charts
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'图表生成失败: {e}')
|
||||
return {}
|
||||
|
||||
def _create_balance_chart(self, data: Dict[str, List], output_dir: Path) -> Optional[Path]:
|
||||
"""创建平衡指数图表"""
|
||||
try:
|
||||
plt.figure(figsize=(12, 6))
|
||||
|
||||
timestamps = data['timestamps'][:len(data['balance_index'])]
|
||||
plt.plot(timestamps, data['balance_index'], 'b-', linewidth=1.5, label='平衡指数')
|
||||
|
||||
# 添加平均线
|
||||
mean_balance = np.mean(data['balance_index'])
|
||||
plt.axhline(y=mean_balance, color='r', linestyle='--', alpha=0.7, label=f'平均值: {mean_balance:.3f}')
|
||||
|
||||
plt.title('平衡指数时间序列', fontsize=14, fontweight='bold')
|
||||
plt.xlabel('时间', fontsize=12)
|
||||
plt.ylabel('平衡指数', fontsize=12)
|
||||
plt.legend()
|
||||
plt.grid(True, alpha=0.3)
|
||||
|
||||
# 格式化时间轴
|
||||
plt.gca().xaxis.set_major_formatter(mdates.DateFormatter('%H:%M:%S'))
|
||||
plt.xticks(rotation=45)
|
||||
|
||||
plt.tight_layout()
|
||||
|
||||
chart_path = output_dir / 'balance_chart.png'
|
||||
plt.savefig(chart_path, dpi=300, bbox_inches='tight')
|
||||
plt.close()
|
||||
|
||||
return chart_path
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'平衡图表创建失败: {e}')
|
||||
plt.close()
|
||||
return None
|
||||
|
||||
def _create_pressure_chart(self, data: Dict[str, List], output_dir: Path) -> Optional[Path]:
|
||||
"""创建压力分布图表"""
|
||||
try:
|
||||
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 10))
|
||||
|
||||
timestamps = data['timestamps'][:min(len(data['pressure_left']), len(data['pressure_right']))]
|
||||
pressure_left = data['pressure_left'][:len(timestamps)]
|
||||
pressure_right = data['pressure_right'][:len(timestamps)]
|
||||
|
||||
# 上图:左右脚压力对比
|
||||
ax1.plot(timestamps, pressure_left, 'b-', linewidth=1.5, label='左脚压力')
|
||||
ax1.plot(timestamps, pressure_right, 'r-', linewidth=1.5, label='右脚压力')
|
||||
ax1.set_title('左右脚压力对比', fontsize=14, fontweight='bold')
|
||||
ax1.set_ylabel('压力值', fontsize=12)
|
||||
ax1.legend()
|
||||
ax1.grid(True, alpha=0.3)
|
||||
|
||||
# 下图:压力比例
|
||||
total_pressure = np.array(pressure_left) + np.array(pressure_right)
|
||||
left_ratio = np.array(pressure_left) / total_pressure * 100
|
||||
right_ratio = np.array(pressure_right) / total_pressure * 100
|
||||
|
||||
ax2.plot(timestamps, left_ratio, 'b-', linewidth=1.5, label='左脚比例')
|
||||
ax2.plot(timestamps, right_ratio, 'r-', linewidth=1.5, label='右脚比例')
|
||||
ax2.axhline(y=50, color='g', linestyle='--', alpha=0.7, label='理想平衡线')
|
||||
ax2.set_title('左右脚压力比例', fontsize=14, fontweight='bold')
|
||||
ax2.set_xlabel('时间', fontsize=12)
|
||||
ax2.set_ylabel('压力比例 (%)', fontsize=12)
|
||||
ax2.legend()
|
||||
ax2.grid(True, alpha=0.3)
|
||||
|
||||
# 格式化时间轴
|
||||
for ax in [ax1, ax2]:
|
||||
ax.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M:%S'))
|
||||
plt.setp(ax.xaxis.get_majorticklabels(), rotation=45)
|
||||
|
||||
plt.tight_layout()
|
||||
|
||||
chart_path = output_dir / 'pressure_chart.png'
|
||||
plt.savefig(chart_path, dpi=300, bbox_inches='tight')
|
||||
plt.close()
|
||||
|
||||
return chart_path
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'压力图表创建失败: {e}')
|
||||
plt.close()
|
||||
return None
|
||||
|
||||
def _create_cop_trajectory_chart(self, data: Dict[str, List], output_dir: Path) -> Optional[Path]:
|
||||
"""创建重心轨迹图表"""
|
||||
try:
|
||||
plt.figure(figsize=(10, 8))
|
||||
|
||||
x_positions = data['center_of_pressure_x']
|
||||
y_positions = data['center_of_pressure_y']
|
||||
|
||||
# 绘制轨迹
|
||||
plt.plot(x_positions, y_positions, 'b-', linewidth=1, alpha=0.7, label='重心轨迹')
|
||||
plt.scatter(x_positions[0], y_positions[0], color='g', s=100, marker='o', label='起始点')
|
||||
plt.scatter(x_positions[-1], y_positions[-1], color='r', s=100, marker='s', label='结束点')
|
||||
|
||||
# 添加中心点
|
||||
center_x = np.mean(x_positions)
|
||||
center_y = np.mean(y_positions)
|
||||
plt.scatter(center_x, center_y, color='orange', s=150, marker='*', label='平均中心')
|
||||
|
||||
# 添加置信椭圆
|
||||
self._add_confidence_ellipse(x_positions, y_positions, plt.gca())
|
||||
|
||||
plt.title('重心轨迹图', fontsize=14, fontweight='bold')
|
||||
plt.xlabel('X方向位移 (mm)', fontsize=12)
|
||||
plt.ylabel('Y方向位移 (mm)', fontsize=12)
|
||||
plt.legend()
|
||||
plt.grid(True, alpha=0.3)
|
||||
plt.axis('equal')
|
||||
|
||||
plt.tight_layout()
|
||||
|
||||
chart_path = output_dir / 'cop_trajectory_chart.png'
|
||||
plt.savefig(chart_path, dpi=300, bbox_inches='tight')
|
||||
plt.close()
|
||||
|
||||
return chart_path
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'重心轨迹图表创建失败: {e}')
|
||||
plt.close()
|
||||
return None
|
||||
|
||||
def _add_confidence_ellipse(self, x: List[float], y: List[float], ax, n_std: float = 2.0):
|
||||
"""添加置信椭圆"""
|
||||
try:
|
||||
from matplotlib.patches import Ellipse
|
||||
import matplotlib.transforms as transforms
|
||||
|
||||
x_arr = np.array(x)
|
||||
y_arr = np.array(y)
|
||||
|
||||
cov = np.cov(x_arr, y_arr)
|
||||
pearson = cov[0, 1] / np.sqrt(cov[0, 0] * cov[1, 1])
|
||||
|
||||
ell_radius_x = np.sqrt(1 + pearson)
|
||||
ell_radius_y = np.sqrt(1 - pearson)
|
||||
|
||||
ellipse = Ellipse((0, 0), width=ell_radius_x * 2, height=ell_radius_y * 2,
|
||||
facecolor='none', edgecolor='red', alpha=0.5, linestyle='--')
|
||||
|
||||
scale_x = np.sqrt(cov[0, 0]) * n_std
|
||||
scale_y = np.sqrt(cov[1, 1]) * n_std
|
||||
|
||||
mean_x = np.mean(x_arr)
|
||||
mean_y = np.mean(y_arr)
|
||||
|
||||
transf = transforms.Affine2D().scale(scale_x, scale_y).translate(mean_x, mean_y)
|
||||
ellipse.set_transform(transf + ax.transData)
|
||||
|
||||
ax.add_patch(ellipse)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f'置信椭圆添加失败: {e}')
|
||||
|
||||
def _create_posture_chart(self, data: Dict[str, List], output_dir: Path) -> Optional[Path]:
|
||||
"""创建姿态角度图表"""
|
||||
try:
|
||||
plt.figure(figsize=(12, 8))
|
||||
|
||||
timestamps = data['timestamps'][:min(len(data['imu_pitch']), len(data['imu_roll']))]
|
||||
pitch = data['imu_pitch'][:len(timestamps)]
|
||||
roll = data['imu_roll'][:len(timestamps)]
|
||||
|
||||
plt.subplot(2, 1, 1)
|
||||
plt.plot(timestamps, pitch, 'b-', linewidth=1.5, label='俯仰角')
|
||||
plt.axhline(y=0, color='g', linestyle='--', alpha=0.7, label='理想位置')
|
||||
plt.title('俯仰角变化', fontsize=14, fontweight='bold')
|
||||
plt.ylabel('角度 (度)', fontsize=12)
|
||||
plt.legend()
|
||||
plt.grid(True, alpha=0.3)
|
||||
|
||||
plt.subplot(2, 1, 2)
|
||||
plt.plot(timestamps, roll, 'r-', linewidth=1.5, label='翻滚角')
|
||||
plt.axhline(y=0, color='g', linestyle='--', alpha=0.7, label='理想位置')
|
||||
plt.title('翻滚角变化', fontsize=14, fontweight='bold')
|
||||
plt.xlabel('时间', fontsize=12)
|
||||
plt.ylabel('角度 (度)', fontsize=12)
|
||||
plt.legend()
|
||||
plt.grid(True, alpha=0.3)
|
||||
|
||||
# 格式化时间轴
|
||||
for i in range(1, 3):
|
||||
ax = plt.subplot(2, 1, i)
|
||||
ax.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M:%S'))
|
||||
plt.setp(ax.xaxis.get_majorticklabels(), rotation=45)
|
||||
|
||||
plt.tight_layout()
|
||||
|
||||
chart_path = output_dir / 'posture_chart.png'
|
||||
plt.savefig(chart_path, dpi=300, bbox_inches='tight')
|
||||
plt.close()
|
||||
|
||||
return chart_path
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'姿态图表创建失败: {e}')
|
||||
plt.close()
|
||||
return None
|
||||
|
||||
def _generate_summary(self, statistical: Dict, trends: Dict, anomalies: Dict) -> Dict[str, Any]:
|
||||
"""生成分析摘要"""
|
||||
try:
|
||||
summary = {
|
||||
'overall_assessment': 'normal',
|
||||
'key_findings': [],
|
||||
'recommendations': []
|
||||
}
|
||||
|
||||
# 分析平衡状况
|
||||
if 'balance' in statistical:
|
||||
balance_mean = statistical['balance']['mean']
|
||||
if balance_mean > 0.3:
|
||||
summary['key_findings'].append('平衡能力较差,重心摆动较大')
|
||||
summary['recommendations'].append('建议进行平衡训练,加强核心肌群锻炼')
|
||||
summary['overall_assessment'] = 'poor'
|
||||
elif balance_mean > 0.15:
|
||||
summary['key_findings'].append('平衡能力一般,有改善空间')
|
||||
summary['recommendations'].append('建议适当增加平衡练习')
|
||||
if summary['overall_assessment'] == 'normal':
|
||||
summary['overall_assessment'] = 'fair'
|
||||
else:
|
||||
summary['key_findings'].append('平衡能力良好')
|
||||
|
||||
# 分析压力分布
|
||||
if 'pressure_distribution' in statistical:
|
||||
asymmetry = statistical['pressure_distribution']['asymmetry']
|
||||
if asymmetry > 0.2:
|
||||
summary['key_findings'].append('左右脚压力分布不均,存在明显偏重')
|
||||
summary['recommendations'].append('注意纠正站立姿势,均匀分配体重')
|
||||
if summary['overall_assessment'] in ['normal', 'fair']:
|
||||
summary['overall_assessment'] = 'fair'
|
||||
|
||||
# 分析姿态稳定性
|
||||
if 'posture' in statistical:
|
||||
posture_stability = statistical['posture'].get('posture_stability', 1.0)
|
||||
if posture_stability < 0.7:
|
||||
summary['key_findings'].append('姿态稳定性较差,身体摆动较大')
|
||||
summary['recommendations'].append('建议进行姿态矫正训练')
|
||||
if summary['overall_assessment'] == 'normal':
|
||||
summary['overall_assessment'] = 'fair'
|
||||
|
||||
# 分析异常情况
|
||||
if anomalies:
|
||||
total_anomalies = 0
|
||||
for key, value in anomalies.items():
|
||||
if isinstance(value, dict) and 'count' in value:
|
||||
total_anomalies += value['count']
|
||||
|
||||
if total_anomalies > 10:
|
||||
summary['key_findings'].append(f'检测到{total_anomalies}个异常数据点')
|
||||
summary['recommendations'].append('建议重新进行检测或咨询专业医师')
|
||||
|
||||
# 默认建议
|
||||
if not summary['recommendations']:
|
||||
summary['recommendations'].append('继续保持良好的平衡训练习惯')
|
||||
|
||||
return summary
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'摘要生成失败: {e}')
|
||||
return {
|
||||
'overall_assessment': 'unknown',
|
||||
'key_findings': ['分析过程中出现错误'],
|
||||
'recommendations': ['建议重新进行检测']
|
||||
}
|
||||
|
||||
def generate_report(self, session_id: str) -> str:
|
||||
"""生成PDF报告"""
|
||||
try:
|
||||
# 这里应该从数据库获取会话数据和分析结果
|
||||
# 目前返回一个模拟的报告路径
|
||||
report_path = self.export_dir / f'report_{session_id}_{datetime.now().strftime("%Y%m%d_%H%M%S")}.pdf'
|
||||
|
||||
# 创建简单的PDF报告
|
||||
self._create_simple_pdf_report(session_id, report_path)
|
||||
|
||||
return str(report_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'报告生成失败: {e}')
|
||||
raise
|
||||
|
||||
def _create_simple_pdf_report(self, session_id: str, output_path: Path):
|
||||
"""创建简单的PDF报告"""
|
||||
try:
|
||||
from reportlab.pdfgen import canvas
|
||||
from reportlab.lib.pagesizes import A4
|
||||
|
||||
c = canvas.Canvas(str(output_path), pagesize=A4)
|
||||
width, height = A4
|
||||
|
||||
# 标题
|
||||
c.setFont("Helvetica-Bold", 20)
|
||||
c.drawString(50, height - 50, "Balance Detection Report")
|
||||
|
||||
# 会话信息
|
||||
c.setFont("Helvetica", 12)
|
||||
y_position = height - 100
|
||||
|
||||
c.drawString(50, y_position, f"Session ID: {session_id}")
|
||||
y_position -= 20
|
||||
c.drawString(50, y_position, f"Report Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
y_position -= 40
|
||||
|
||||
# 报告内容
|
||||
c.drawString(50, y_position, "Analysis Summary:")
|
||||
y_position -= 20
|
||||
c.drawString(70, y_position, "• Balance assessment completed")
|
||||
y_position -= 20
|
||||
c.drawString(70, y_position, "• Posture analysis performed")
|
||||
y_position -= 20
|
||||
c.drawString(70, y_position, "• Movement patterns evaluated")
|
||||
|
||||
c.save()
|
||||
logger.info(f'PDF报告已生成: {output_path}')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'PDF报告创建失败: {e}')
|
||||
raise
|
||||
|
||||
def export_data(self, session_id: str, format: str = 'csv') -> str:
|
||||
"""导出数据"""
|
||||
try:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
if format.lower() == 'csv':
|
||||
export_path = self.export_dir / f'data_{session_id}_{timestamp}.csv'
|
||||
# 这里应该从数据库获取数据并导出为CSV
|
||||
# 目前创建一个示例文件
|
||||
with open(export_path, 'w', encoding='utf-8') as f:
|
||||
f.write('timestamp,data_type,value\n')
|
||||
f.write(f'{datetime.now().isoformat()},sample,0.5\n')
|
||||
|
||||
elif format.lower() == 'json':
|
||||
export_path = self.export_dir / f'data_{session_id}_{timestamp}.json'
|
||||
# 导出为JSON格式
|
||||
sample_data = {
|
||||
'session_id': session_id,
|
||||
'export_time': datetime.now().isoformat(),
|
||||
'data': []
|
||||
}
|
||||
with open(export_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(sample_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
else:
|
||||
raise ValueError(f'不支持的导出格式: {format}')
|
||||
|
||||
logger.info(f'数据已导出: {export_path}')
|
||||
return str(export_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'数据导出失败: {e}')
|
||||
raise
|
||||
618
backend/database.py
Normal file
618
backend/database.py
Normal file
@ -0,0 +1,618 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
数据库管理模块
|
||||
负责SQLite数据库的创建、连接和数据操作
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Optional, Any
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class DatabaseManager:
|
||||
"""数据库管理器"""
|
||||
|
||||
def __init__(self, db_path: str):
|
||||
self.db_path = db_path
|
||||
self.connection = None
|
||||
|
||||
def get_connection(self) -> sqlite3.Connection:
|
||||
"""获取数据库连接"""
|
||||
if not self.connection:
|
||||
self.connection = sqlite3.connect(
|
||||
self.db_path,
|
||||
check_same_thread=False,
|
||||
timeout=30.0
|
||||
)
|
||||
self.connection.row_factory = sqlite3.Row
|
||||
return self.connection
|
||||
|
||||
def init_database(self):
|
||||
"""初始化数据库表结构"""
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
# 创建患者表
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS patients (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
gender TEXT,
|
||||
age INTEGER,
|
||||
height REAL,
|
||||
weight REAL,
|
||||
phone TEXT,
|
||||
medical_history TEXT,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
# 创建检测会话表
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS detection_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
patient_id TEXT NOT NULL,
|
||||
start_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
end_time TIMESTAMP,
|
||||
duration INTEGER,
|
||||
frequency INTEGER,
|
||||
status TEXT DEFAULT 'created',
|
||||
settings TEXT,
|
||||
data_points INTEGER DEFAULT 0,
|
||||
video_path TEXT,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (patient_id) REFERENCES patients (id)
|
||||
)
|
||||
''')
|
||||
|
||||
# 创建检测数据表
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS detection_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
data_type TEXT NOT NULL,
|
||||
data_value TEXT NOT NULL,
|
||||
FOREIGN KEY (session_id) REFERENCES detection_sessions (id)
|
||||
)
|
||||
''')
|
||||
|
||||
# 创建分析结果表
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS analysis_results (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
analysis_type TEXT NOT NULL,
|
||||
result_data TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (session_id) REFERENCES detection_sessions (id)
|
||||
)
|
||||
''')
|
||||
|
||||
# 创建系统设置表
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS system_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
description TEXT,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
# 创建索引
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_patients_name ON patients (name)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_sessions_patient ON detection_sessions (patient_id)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_sessions_time ON detection_sessions (start_time)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_data_session ON detection_data (session_id)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_data_timestamp ON detection_data (timestamp)')
|
||||
|
||||
conn.commit()
|
||||
logger.info('数据库初始化完成')
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error(f'数据库初始化失败: {e}')
|
||||
raise
|
||||
|
||||
# ==================== 患者管理 ====================
|
||||
|
||||
def create_patient(self, patient_data: Dict[str, Any]) -> str:
|
||||
"""创建患者记录"""
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
patient_id = str(uuid.uuid4())
|
||||
|
||||
cursor.execute('''
|
||||
INSERT INTO patients (
|
||||
id, name, gender, age, height, weight,
|
||||
phone, medical_history, notes
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''', (
|
||||
patient_id,
|
||||
patient_data.get('name'),
|
||||
patient_data.get('gender'),
|
||||
patient_data.get('age'),
|
||||
patient_data.get('height'),
|
||||
patient_data.get('weight'),
|
||||
patient_data.get('phone'),
|
||||
patient_data.get('medical_history'),
|
||||
patient_data.get('notes')
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
logger.info(f'创建患者记录: {patient_id}')
|
||||
return patient_id
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error(f'创建患者记录失败: {e}')
|
||||
raise
|
||||
|
||||
def get_patients(self, page: int = 1, size: int = 10, keyword: str = '') -> List[Dict]:
|
||||
"""获取患者列表"""
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
offset = (page - 1) * size
|
||||
|
||||
if keyword:
|
||||
cursor.execute('''
|
||||
SELECT * FROM patients
|
||||
WHERE name LIKE ? OR phone LIKE ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
''', (f'%{keyword}%', f'%{keyword}%', size, offset))
|
||||
else:
|
||||
cursor.execute('''
|
||||
SELECT * FROM patients
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
''', (size, offset))
|
||||
|
||||
rows = cursor.fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'获取患者列表失败: {e}')
|
||||
raise
|
||||
|
||||
def get_patients_count(self, keyword: str = '') -> int:
|
||||
"""获取患者总数"""
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
if keyword:
|
||||
cursor.execute('''
|
||||
SELECT COUNT(*) FROM patients
|
||||
WHERE name LIKE ? OR phone LIKE ?
|
||||
''', (f'%{keyword}%', f'%{keyword}%'))
|
||||
else:
|
||||
cursor.execute('SELECT COUNT(*) FROM patients')
|
||||
|
||||
return cursor.fetchone()[0]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'获取患者总数失败: {e}')
|
||||
return 0
|
||||
|
||||
def get_patient(self, patient_id: str) -> Optional[Dict]:
|
||||
"""获取单个患者信息"""
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute('SELECT * FROM patients WHERE id = ?', (patient_id,))
|
||||
row = cursor.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'获取患者信息失败: {e}')
|
||||
return None
|
||||
|
||||
def update_patient(self, patient_id: str, patient_data: Dict[str, Any]):
|
||||
"""更新患者信息"""
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute('''
|
||||
UPDATE patients SET
|
||||
name = ?, gender = ?, age = ?, height = ?, weight = ?,
|
||||
phone = ?, medical_history = ?, notes = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
''', (
|
||||
patient_data.get('name'),
|
||||
patient_data.get('gender'),
|
||||
patient_data.get('age'),
|
||||
patient_data.get('height'),
|
||||
patient_data.get('weight'),
|
||||
patient_data.get('phone'),
|
||||
patient_data.get('medical_history'),
|
||||
patient_data.get('notes'),
|
||||
patient_id
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
logger.info(f'更新患者信息: {patient_id}')
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error(f'更新患者信息失败: {e}')
|
||||
raise
|
||||
|
||||
def delete_patient(self, patient_id: str):
|
||||
"""删除患者记录"""
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
# 删除相关的检测数据
|
||||
cursor.execute('''
|
||||
DELETE FROM detection_data
|
||||
WHERE session_id IN (
|
||||
SELECT id FROM detection_sessions WHERE patient_id = ?
|
||||
)
|
||||
''', (patient_id,))
|
||||
|
||||
# 删除相关的分析结果
|
||||
cursor.execute('''
|
||||
DELETE FROM analysis_results
|
||||
WHERE session_id IN (
|
||||
SELECT id FROM detection_sessions WHERE patient_id = ?
|
||||
)
|
||||
''', (patient_id,))
|
||||
|
||||
# 删除检测会话
|
||||
cursor.execute('DELETE FROM detection_sessions WHERE patient_id = ?', (patient_id,))
|
||||
|
||||
# 删除患者记录
|
||||
cursor.execute('DELETE FROM patients WHERE id = ?', (patient_id,))
|
||||
|
||||
conn.commit()
|
||||
logger.info(f'删除患者记录: {patient_id}')
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error(f'删除患者记录失败: {e}')
|
||||
raise
|
||||
|
||||
# ==================== 检测会话管理 ====================
|
||||
|
||||
def create_detection_session(self, patient_id: str, settings: Dict[str, Any]) -> str:
|
||||
"""创建检测会话"""
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
session_id = str(uuid.uuid4())
|
||||
|
||||
cursor.execute('''
|
||||
INSERT INTO detection_sessions (
|
||||
id, patient_id, duration, frequency, settings, status
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
''', (
|
||||
session_id,
|
||||
patient_id,
|
||||
settings.get('duration', 60),
|
||||
settings.get('frequency', 60),
|
||||
json.dumps(settings),
|
||||
'created'
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
logger.info(f'创建检测会话: {session_id}')
|
||||
return session_id
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error(f'创建检测会话失败: {e}')
|
||||
raise
|
||||
|
||||
def update_session_status(self, session_id: str, status: str, data_points: int = 0):
|
||||
"""更新会话状态"""
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
if status in ['completed', 'stopped', 'error']:
|
||||
cursor.execute('''
|
||||
UPDATE detection_sessions SET
|
||||
status = ?, data_points = ?, end_time = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
''', (status, data_points, session_id))
|
||||
else:
|
||||
cursor.execute('''
|
||||
UPDATE detection_sessions SET
|
||||
status = ?, data_points = ?
|
||||
WHERE id = ?
|
||||
''', (status, data_points, session_id))
|
||||
|
||||
conn.commit()
|
||||
logger.info(f'更新会话状态: {session_id} -> {status}')
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error(f'更新会话状态失败: {e}')
|
||||
raise
|
||||
|
||||
def get_detection_sessions(self, page: int = 1, size: int = 10, patient_id: str = None) -> List[Dict]:
|
||||
"""获取检测会话列表"""
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
offset = (page - 1) * size
|
||||
|
||||
if patient_id:
|
||||
cursor.execute('''
|
||||
SELECT s.*, p.name as patient_name
|
||||
FROM detection_sessions s
|
||||
LEFT JOIN patients p ON s.patient_id = p.id
|
||||
WHERE s.patient_id = ?
|
||||
ORDER BY s.start_time DESC
|
||||
LIMIT ? OFFSET ?
|
||||
''', (patient_id, size, offset))
|
||||
else:
|
||||
cursor.execute('''
|
||||
SELECT s.*, p.name as patient_name
|
||||
FROM detection_sessions s
|
||||
LEFT JOIN patients p ON s.patient_id = p.id
|
||||
ORDER BY s.start_time DESC
|
||||
LIMIT ? OFFSET ?
|
||||
''', (size, offset))
|
||||
|
||||
rows = cursor.fetchall()
|
||||
sessions = []
|
||||
|
||||
for row in rows:
|
||||
session = dict(row)
|
||||
# 解析设置JSON
|
||||
if session['settings']:
|
||||
try:
|
||||
session['settings'] = json.loads(session['settings'])
|
||||
except:
|
||||
session['settings'] = {}
|
||||
sessions.append(session)
|
||||
|
||||
return sessions
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'获取检测会话列表失败: {e}')
|
||||
raise
|
||||
|
||||
def get_sessions_count(self, patient_id: str = None) -> int:
|
||||
"""获取会话总数"""
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
if patient_id:
|
||||
cursor.execute('SELECT COUNT(*) FROM detection_sessions WHERE patient_id = ?', (patient_id,))
|
||||
else:
|
||||
cursor.execute('SELECT COUNT(*) FROM detection_sessions')
|
||||
|
||||
return cursor.fetchone()[0]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'获取会话总数失败: {e}')
|
||||
return 0
|
||||
|
||||
def get_session_data(self, session_id: str) -> Optional[Dict]:
|
||||
"""获取会话详细数据"""
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
# 获取会话基本信息
|
||||
cursor.execute('''
|
||||
SELECT s.*, p.name as patient_name
|
||||
FROM detection_sessions s
|
||||
LEFT JOIN patients p ON s.patient_id = p.id
|
||||
WHERE s.id = ?
|
||||
''', (session_id,))
|
||||
|
||||
session_row = cursor.fetchone()
|
||||
if not session_row:
|
||||
return None
|
||||
|
||||
session = dict(session_row)
|
||||
|
||||
# 解析设置JSON
|
||||
if session['settings']:
|
||||
try:
|
||||
session['settings'] = json.loads(session['settings'])
|
||||
except:
|
||||
session['settings'] = {}
|
||||
|
||||
# 获取检测数据
|
||||
cursor.execute('''
|
||||
SELECT * FROM detection_data
|
||||
WHERE session_id = ?
|
||||
ORDER BY timestamp
|
||||
''', (session_id,))
|
||||
|
||||
data_rows = cursor.fetchall()
|
||||
session['data'] = []
|
||||
|
||||
for row in data_rows:
|
||||
data_point = dict(row)
|
||||
# 解析数据JSON
|
||||
try:
|
||||
data_point['data_value'] = json.loads(data_point['data_value'])
|
||||
except:
|
||||
pass
|
||||
session['data'].append(data_point)
|
||||
|
||||
return session
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'获取会话数据失败: {e}')
|
||||
return None
|
||||
|
||||
# ==================== 检测数据管理 ====================
|
||||
|
||||
def save_detection_data(self, session_id: str, data: Dict[str, Any]):
|
||||
"""保存检测数据"""
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
# 保存不同类型的数据
|
||||
for data_type, data_value in data.items():
|
||||
cursor.execute('''
|
||||
INSERT INTO detection_data (session_id, data_type, data_value)
|
||||
VALUES (?, ?, ?)
|
||||
''', (session_id, data_type, json.dumps(data_value)))
|
||||
|
||||
conn.commit()
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error(f'保存检测数据失败: {e}')
|
||||
raise
|
||||
|
||||
def get_latest_detection_data(self, session_id: str, limit: int = 10) -> List[Dict]:
|
||||
"""获取最新的检测数据"""
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute('''
|
||||
SELECT * FROM detection_data
|
||||
WHERE session_id = ?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
''', (session_id, limit))
|
||||
|
||||
rows = cursor.fetchall()
|
||||
data_points = []
|
||||
|
||||
for row in rows:
|
||||
data_point = dict(row)
|
||||
try:
|
||||
data_point['data_value'] = json.loads(data_point['data_value'])
|
||||
except:
|
||||
pass
|
||||
data_points.append(data_point)
|
||||
|
||||
return data_points
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'获取最新检测数据失败: {e}')
|
||||
return []
|
||||
|
||||
# ==================== 分析结果管理 ====================
|
||||
|
||||
def save_analysis_result(self, session_id: str, analysis_result: Dict[str, Any]):
|
||||
"""保存分析结果"""
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
for analysis_type, result_data in analysis_result.items():
|
||||
cursor.execute('''
|
||||
INSERT INTO analysis_results (session_id, analysis_type, result_data)
|
||||
VALUES (?, ?, ?)
|
||||
''', (session_id, analysis_type, json.dumps(result_data)))
|
||||
|
||||
conn.commit()
|
||||
logger.info(f'保存分析结果: {session_id}')
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error(f'保存分析结果失败: {e}')
|
||||
raise
|
||||
|
||||
def get_analysis_results(self, session_id: str) -> Dict[str, Any]:
|
||||
"""获取分析结果"""
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute('''
|
||||
SELECT * FROM analysis_results
|
||||
WHERE session_id = ?
|
||||
ORDER BY created_at DESC
|
||||
''', (session_id,))
|
||||
|
||||
rows = cursor.fetchall()
|
||||
results = {}
|
||||
|
||||
for row in rows:
|
||||
analysis_type = row['analysis_type']
|
||||
try:
|
||||
result_data = json.loads(row['result_data'])
|
||||
results[analysis_type] = result_data
|
||||
except:
|
||||
results[analysis_type] = row['result_data']
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'获取分析结果失败: {e}')
|
||||
return {}
|
||||
|
||||
# ==================== 系统设置管理 ====================
|
||||
|
||||
def get_setting(self, key: str, default_value: Any = None) -> Any:
|
||||
"""获取系统设置"""
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute('SELECT value FROM system_settings WHERE key = ?', (key,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
try:
|
||||
return json.loads(row['value'])
|
||||
except:
|
||||
return row['value']
|
||||
|
||||
return default_value
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'获取系统设置失败: {e}')
|
||||
return default_value
|
||||
|
||||
def set_setting(self, key: str, value: Any, description: str = ''):
|
||||
"""设置系统设置"""
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
value_str = json.dumps(value) if not isinstance(value, str) else value
|
||||
|
||||
cursor.execute('''
|
||||
INSERT OR REPLACE INTO system_settings (key, value, description, updated_at)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||
''', (key, value_str, description))
|
||||
|
||||
conn.commit()
|
||||
logger.info(f'设置系统设置: {key}')
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error(f'设置系统设置失败: {e}')
|
||||
raise
|
||||
|
||||
def close(self):
|
||||
"""关闭数据库连接"""
|
||||
if self.connection:
|
||||
self.connection.close()
|
||||
self.connection = None
|
||||
logger.info('数据库连接已关闭')
|
||||
615
backend/detection_engine.py
Normal file
615
backend/detection_engine.py
Normal file
@ -0,0 +1,615 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
检测引擎模块
|
||||
负责实时数据处理、姿态分析和平衡评估
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import cv2
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Any, Tuple
|
||||
import logging
|
||||
import threading
|
||||
from collections import deque
|
||||
import json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class DetectionEngine:
|
||||
"""检测引擎"""
|
||||
|
||||
def __init__(self):
|
||||
self.session_data = {} # 存储各会话的数据
|
||||
self.data_lock = threading.Lock()
|
||||
self.analysis_algorithms = {
|
||||
'balance_analysis': BalanceAnalyzer(),
|
||||
'posture_analysis': PostureAnalyzer(),
|
||||
'movement_analysis': MovementAnalyzer()
|
||||
}
|
||||
|
||||
logger.info('检测引擎初始化完成')
|
||||
|
||||
def start_session(self, session_id: str, settings: Dict[str, Any]):
|
||||
"""开始检测会话"""
|
||||
with self.data_lock:
|
||||
self.session_data[session_id] = {
|
||||
'settings': settings,
|
||||
'start_time': datetime.now(),
|
||||
'data_buffer': deque(maxlen=1000), # 保留最近1000个数据点
|
||||
'analysis_results': {},
|
||||
'real_time_metrics': {}
|
||||
}
|
||||
|
||||
logger.info(f'检测会话开始: {session_id}')
|
||||
|
||||
def process_data(self, session_id: str, raw_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""处理实时数据"""
|
||||
if session_id not in self.session_data:
|
||||
logger.warning(f'会话不存在: {session_id}')
|
||||
return {}
|
||||
|
||||
try:
|
||||
# 数据预处理
|
||||
processed_data = self._preprocess_data(raw_data)
|
||||
|
||||
# 存储数据
|
||||
with self.data_lock:
|
||||
self.session_data[session_id]['data_buffer'].append(processed_data)
|
||||
|
||||
# 实时分析
|
||||
real_time_results = self._real_time_analysis(session_id, processed_data)
|
||||
|
||||
# 更新实时指标
|
||||
with self.data_lock:
|
||||
self.session_data[session_id]['real_time_metrics'].update(real_time_results)
|
||||
|
||||
return real_time_results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'数据处理失败: {e}')
|
||||
return {}
|
||||
|
||||
def _preprocess_data(self, raw_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""数据预处理"""
|
||||
processed = {
|
||||
'timestamp': raw_data.get('timestamp', datetime.now().isoformat())
|
||||
}
|
||||
|
||||
# 处理摄像头数据
|
||||
if 'camera' in raw_data:
|
||||
camera_data = raw_data['camera']
|
||||
if 'pose_data' in camera_data:
|
||||
processed['pose'] = self._process_pose_data(camera_data['pose_data'])
|
||||
|
||||
# 处理IMU数据
|
||||
if 'imu' in raw_data:
|
||||
processed['imu'] = self._process_imu_data(raw_data['imu'])
|
||||
|
||||
# 处理压力数据
|
||||
if 'pressure' in raw_data:
|
||||
processed['pressure'] = self._process_pressure_data(raw_data['pressure'])
|
||||
|
||||
return processed
|
||||
|
||||
def _process_pose_data(self, pose_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""处理姿态数据"""
|
||||
return {
|
||||
'center_of_gravity': pose_data.get('center_of_gravity', {'x': 0, 'y': 0}),
|
||||
'body_angle': pose_data.get('body_angle', {'pitch': 0, 'roll': 0, 'yaw': 0}),
|
||||
'confidence': pose_data.get('confidence', 0.0)
|
||||
}
|
||||
|
||||
def _process_imu_data(self, imu_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""处理IMU数据"""
|
||||
# 计算合成加速度
|
||||
accel = imu_data.get('accel', {'x': 0, 'y': 0, 'z': 0})
|
||||
total_accel = np.sqrt(accel['x']**2 + accel['y']**2 + accel['z']**2)
|
||||
|
||||
# 计算倾斜角度
|
||||
pitch = np.arctan2(accel['y'], np.sqrt(accel['x']**2 + accel['z']**2)) * 180 / np.pi
|
||||
roll = np.arctan2(-accel['x'], accel['z']) * 180 / np.pi
|
||||
|
||||
return {
|
||||
'accel': accel,
|
||||
'gyro': imu_data.get('gyro', {'x': 0, 'y': 0, 'z': 0}),
|
||||
'total_accel': total_accel,
|
||||
'pitch': pitch,
|
||||
'roll': roll,
|
||||
'temperature': imu_data.get('temperature', 0)
|
||||
}
|
||||
|
||||
def _process_pressure_data(self, pressure_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""处理压力数据"""
|
||||
left_foot = pressure_data.get('left_foot', 0)
|
||||
right_foot = pressure_data.get('right_foot', 0)
|
||||
total_pressure = left_foot + right_foot
|
||||
|
||||
# 计算压力分布比例
|
||||
if total_pressure > 0:
|
||||
left_ratio = left_foot / total_pressure
|
||||
right_ratio = right_foot / total_pressure
|
||||
balance_index = abs(left_ratio - right_ratio) # 平衡指数,越小越平衡
|
||||
else:
|
||||
left_ratio = right_ratio = 0.5
|
||||
balance_index = 0
|
||||
|
||||
return {
|
||||
'left_foot': left_foot,
|
||||
'right_foot': right_foot,
|
||||
'total_pressure': total_pressure,
|
||||
'left_ratio': left_ratio,
|
||||
'right_ratio': right_ratio,
|
||||
'balance_index': balance_index,
|
||||
'center_of_pressure': pressure_data.get('center_of_pressure', {'x': 0, 'y': 0})
|
||||
}
|
||||
|
||||
def _real_time_analysis(self, session_id: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""实时分析"""
|
||||
results = {}
|
||||
|
||||
try:
|
||||
# 获取历史数据用于趋势分析
|
||||
with self.data_lock:
|
||||
data_buffer = list(self.session_data[session_id]['data_buffer'])
|
||||
|
||||
if len(data_buffer) < 2:
|
||||
return results
|
||||
|
||||
# 平衡分析
|
||||
if 'pressure' in data:
|
||||
balance_result = self.analysis_algorithms['balance_analysis'].analyze_real_time(
|
||||
data['pressure'], data_buffer
|
||||
)
|
||||
results['balance'] = balance_result
|
||||
|
||||
# 姿态分析
|
||||
if 'pose' in data or 'imu' in data:
|
||||
posture_result = self.analysis_algorithms['posture_analysis'].analyze_real_time(
|
||||
data, data_buffer
|
||||
)
|
||||
results['posture'] = posture_result
|
||||
|
||||
# 运动分析
|
||||
movement_result = self.analysis_algorithms['movement_analysis'].analyze_real_time(
|
||||
data, data_buffer
|
||||
)
|
||||
results['movement'] = movement_result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'实时分析失败: {e}')
|
||||
|
||||
return results
|
||||
|
||||
def get_latest_data(self, session_id: str) -> Dict[str, Any]:
|
||||
"""获取最新数据"""
|
||||
if session_id not in self.session_data:
|
||||
return {}
|
||||
|
||||
with self.data_lock:
|
||||
session = self.session_data[session_id]
|
||||
if not session['data_buffer']:
|
||||
return {}
|
||||
|
||||
latest_data = session['data_buffer'][-1]
|
||||
real_time_metrics = session['real_time_metrics'].copy()
|
||||
|
||||
return {
|
||||
'latest_data': latest_data,
|
||||
'real_time_metrics': real_time_metrics,
|
||||
'data_count': len(session['data_buffer'])
|
||||
}
|
||||
|
||||
def analyze_session(self, session_id: str) -> Dict[str, Any]:
|
||||
"""分析整个会话数据"""
|
||||
if session_id not in self.session_data:
|
||||
logger.warning(f'会话不存在: {session_id}')
|
||||
return {}
|
||||
|
||||
try:
|
||||
with self.data_lock:
|
||||
session = self.session_data[session_id]
|
||||
data_buffer = list(session['data_buffer'])
|
||||
settings = session['settings']
|
||||
|
||||
if not data_buffer:
|
||||
return {'error': '没有数据可分析'}
|
||||
|
||||
analysis_results = {}
|
||||
|
||||
# 全面平衡分析
|
||||
balance_analysis = self.analysis_algorithms['balance_analysis'].analyze_full_session(
|
||||
data_buffer, settings
|
||||
)
|
||||
analysis_results['balance'] = balance_analysis
|
||||
|
||||
# 全面姿态分析
|
||||
posture_analysis = self.analysis_algorithms['posture_analysis'].analyze_full_session(
|
||||
data_buffer, settings
|
||||
)
|
||||
analysis_results['posture'] = posture_analysis
|
||||
|
||||
# 全面运动分析
|
||||
movement_analysis = self.analysis_algorithms['movement_analysis'].analyze_full_session(
|
||||
data_buffer, settings
|
||||
)
|
||||
analysis_results['movement'] = movement_analysis
|
||||
|
||||
# 综合评估
|
||||
overall_assessment = self._generate_overall_assessment(analysis_results)
|
||||
analysis_results['overall'] = overall_assessment
|
||||
|
||||
# 保存分析结果
|
||||
with self.data_lock:
|
||||
self.session_data[session_id]['analysis_results'] = analysis_results
|
||||
|
||||
logger.info(f'会话分析完成: {session_id}')
|
||||
return analysis_results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'会话分析失败: {e}')
|
||||
return {'error': str(e)}
|
||||
|
||||
def _generate_overall_assessment(self, analysis_results: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""生成综合评估"""
|
||||
try:
|
||||
# 提取各项评分
|
||||
balance_score = analysis_results.get('balance', {}).get('score', 0)
|
||||
posture_score = analysis_results.get('posture', {}).get('score', 0)
|
||||
movement_score = analysis_results.get('movement', {}).get('score', 0)
|
||||
|
||||
# 计算综合评分(加权平均)
|
||||
weights = {'balance': 0.4, 'posture': 0.3, 'movement': 0.3}
|
||||
overall_score = (
|
||||
balance_score * weights['balance'] +
|
||||
posture_score * weights['posture'] +
|
||||
movement_score * weights['movement']
|
||||
)
|
||||
|
||||
# 评估等级
|
||||
if overall_score >= 90:
|
||||
grade = 'A'
|
||||
description = '优秀'
|
||||
elif overall_score >= 80:
|
||||
grade = 'B'
|
||||
description = '良好'
|
||||
elif overall_score >= 70:
|
||||
grade = 'C'
|
||||
description = '一般'
|
||||
elif overall_score >= 60:
|
||||
grade = 'D'
|
||||
description = '较差'
|
||||
else:
|
||||
grade = 'E'
|
||||
description = '差'
|
||||
|
||||
# 生成建议
|
||||
recommendations = self._generate_recommendations(analysis_results)
|
||||
|
||||
return {
|
||||
'score': round(overall_score, 1),
|
||||
'grade': grade,
|
||||
'description': description,
|
||||
'recommendations': recommendations,
|
||||
'component_scores': {
|
||||
'balance': balance_score,
|
||||
'posture': posture_score,
|
||||
'movement': movement_score
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'综合评估生成失败: {e}')
|
||||
return {'score': 0, 'grade': 'E', 'description': '评估失败'}
|
||||
|
||||
def _generate_recommendations(self, analysis_results: Dict[str, Any]) -> List[str]:
|
||||
"""生成改善建议"""
|
||||
recommendations = []
|
||||
|
||||
try:
|
||||
# 平衡相关建议
|
||||
balance_data = analysis_results.get('balance', {})
|
||||
if balance_data.get('score', 0) < 80:
|
||||
if balance_data.get('left_right_imbalance', 0) > 0.2:
|
||||
recommendations.append('注意左右脚压力分布,建议进行单脚站立练习')
|
||||
if balance_data.get('stability_index', 0) > 0.5:
|
||||
recommendations.append('重心摆动较大,建议进行静态平衡训练')
|
||||
|
||||
# 姿态相关建议
|
||||
posture_data = analysis_results.get('posture', {})
|
||||
if posture_data.get('score', 0) < 80:
|
||||
if abs(posture_data.get('avg_pitch', 0)) > 5:
|
||||
recommendations.append('身体前后倾斜较明显,注意保持直立姿态')
|
||||
if abs(posture_data.get('avg_roll', 0)) > 5:
|
||||
recommendations.append('身体左右倾斜较明显,注意身体对称性')
|
||||
|
||||
# 运动相关建议
|
||||
movement_data = analysis_results.get('movement', {})
|
||||
if movement_data.get('score', 0) < 80:
|
||||
if movement_data.get('movement_variability', 0) > 0.8:
|
||||
recommendations.append('身体摆动过大,建议进行核心稳定性训练')
|
||||
if movement_data.get('movement_frequency', 0) > 2:
|
||||
recommendations.append('身体摆动频率较高,建议放松并专注于静态平衡')
|
||||
|
||||
# 通用建议
|
||||
if not recommendations:
|
||||
recommendations.append('整体表现良好,继续保持规律的平衡训练')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'建议生成失败: {e}')
|
||||
recommendations = ['建议咨询专业医师进行详细评估']
|
||||
|
||||
return recommendations
|
||||
|
||||
def end_session(self, session_id: str):
|
||||
"""结束检测会话"""
|
||||
if session_id in self.session_data:
|
||||
with self.data_lock:
|
||||
del self.session_data[session_id]
|
||||
logger.info(f'检测会话结束: {session_id}')
|
||||
|
||||
|
||||
class BalanceAnalyzer:
|
||||
"""平衡分析器"""
|
||||
|
||||
def analyze_real_time(self, pressure_data: Dict[str, Any], data_buffer: List[Dict]) -> Dict[str, Any]:
|
||||
"""实时平衡分析"""
|
||||
try:
|
||||
balance_index = pressure_data.get('balance_index', 0)
|
||||
cop = pressure_data.get('center_of_pressure', {'x': 0, 'y': 0})
|
||||
|
||||
# 计算最近10个数据点的平衡稳定性
|
||||
recent_data = data_buffer[-10:] if len(data_buffer) >= 10 else data_buffer
|
||||
if recent_data and all('pressure' in d for d in recent_data):
|
||||
balance_indices = [d['pressure'].get('balance_index', 0) for d in recent_data]
|
||||
stability = 1.0 - np.std(balance_indices) # 标准差越小,稳定性越高
|
||||
else:
|
||||
stability = 0.5
|
||||
|
||||
return {
|
||||
'balance_index': balance_index,
|
||||
'stability': max(0, min(1, stability)),
|
||||
'center_of_pressure': cop,
|
||||
'status': 'stable' if balance_index < 0.2 else 'unstable'
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'实时平衡分析失败: {e}')
|
||||
return {'balance_index': 0, 'stability': 0, 'status': 'unknown'}
|
||||
|
||||
def analyze_full_session(self, data_buffer: List[Dict], settings: Dict) -> Dict[str, Any]:
|
||||
"""全会话平衡分析"""
|
||||
try:
|
||||
pressure_data = [d['pressure'] for d in data_buffer if 'pressure' in d]
|
||||
if not pressure_data:
|
||||
return {'score': 0, 'error': '没有压力数据'}
|
||||
|
||||
# 提取关键指标
|
||||
balance_indices = [d.get('balance_index', 0) for d in pressure_data]
|
||||
left_ratios = [d.get('left_ratio', 0.5) for d in pressure_data]
|
||||
right_ratios = [d.get('right_ratio', 0.5) for d in pressure_data]
|
||||
|
||||
# 计算统计指标
|
||||
avg_balance_index = np.mean(balance_indices)
|
||||
std_balance_index = np.std(balance_indices)
|
||||
max_balance_index = np.max(balance_indices)
|
||||
|
||||
# 左右脚不平衡程度
|
||||
left_right_imbalance = abs(np.mean(left_ratios) - np.mean(right_ratios))
|
||||
|
||||
# 稳定性指数(基于标准差)
|
||||
stability_index = std_balance_index
|
||||
|
||||
# 计算评分(0-100分)
|
||||
balance_score = max(0, 100 - avg_balance_index * 200) # 平衡指数越小分数越高
|
||||
stability_score = max(0, 100 - stability_index * 500) # 稳定性越高分数越高
|
||||
symmetry_score = max(0, 100 - left_right_imbalance * 200) # 对称性越好分数越高
|
||||
|
||||
overall_score = (balance_score + stability_score + symmetry_score) / 3
|
||||
|
||||
return {
|
||||
'score': round(overall_score, 1),
|
||||
'avg_balance_index': round(avg_balance_index, 3),
|
||||
'stability_index': round(stability_index, 3),
|
||||
'left_right_imbalance': round(left_right_imbalance, 3),
|
||||
'max_imbalance': round(max_balance_index, 3),
|
||||
'data_points': len(pressure_data),
|
||||
'component_scores': {
|
||||
'balance': round(balance_score, 1),
|
||||
'stability': round(stability_score, 1),
|
||||
'symmetry': round(symmetry_score, 1)
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'全会话平衡分析失败: {e}')
|
||||
return {'score': 0, 'error': str(e)}
|
||||
|
||||
|
||||
class PostureAnalyzer:
|
||||
"""姿态分析器"""
|
||||
|
||||
def analyze_real_time(self, data: Dict[str, Any], data_buffer: List[Dict]) -> Dict[str, Any]:
|
||||
"""实时姿态分析"""
|
||||
try:
|
||||
result = {}
|
||||
|
||||
# 分析IMU数据
|
||||
if 'imu' in data:
|
||||
imu_data = data['imu']
|
||||
result['pitch'] = imu_data.get('pitch', 0)
|
||||
result['roll'] = imu_data.get('roll', 0)
|
||||
result['total_accel'] = imu_data.get('total_accel', 0)
|
||||
|
||||
# 分析姿态数据
|
||||
if 'pose' in data:
|
||||
pose_data = data['pose']
|
||||
result['body_angle'] = pose_data.get('body_angle', {})
|
||||
result['confidence'] = pose_data.get('confidence', 0)
|
||||
|
||||
# 计算姿态稳定性
|
||||
recent_data = data_buffer[-5:] if len(data_buffer) >= 5 else data_buffer
|
||||
if recent_data:
|
||||
if 'imu' in data:
|
||||
pitches = [d.get('imu', {}).get('pitch', 0) for d in recent_data if 'imu' in d]
|
||||
rolls = [d.get('imu', {}).get('roll', 0) for d in recent_data if 'imu' in d]
|
||||
|
||||
if pitches and rolls:
|
||||
pitch_stability = 1.0 - min(1.0, np.std(pitches) / 10)
|
||||
roll_stability = 1.0 - min(1.0, np.std(rolls) / 10)
|
||||
result['stability'] = (pitch_stability + roll_stability) / 2
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'实时姿态分析失败: {e}')
|
||||
return {}
|
||||
|
||||
def analyze_full_session(self, data_buffer: List[Dict], settings: Dict) -> Dict[str, Any]:
|
||||
"""全会话姿态分析"""
|
||||
try:
|
||||
imu_data = [d['imu'] for d in data_buffer if 'imu' in d]
|
||||
if not imu_data:
|
||||
return {'score': 0, 'error': '没有IMU数据'}
|
||||
|
||||
# 提取角度数据
|
||||
pitches = [d.get('pitch', 0) for d in imu_data]
|
||||
rolls = [d.get('roll', 0) for d in imu_data]
|
||||
|
||||
# 计算统计指标
|
||||
avg_pitch = np.mean(pitches)
|
||||
avg_roll = np.mean(rolls)
|
||||
std_pitch = np.std(pitches)
|
||||
std_roll = np.std(rolls)
|
||||
max_pitch = np.max(np.abs(pitches))
|
||||
max_roll = np.max(np.abs(rolls))
|
||||
|
||||
# 计算评分
|
||||
pitch_score = max(0, 100 - abs(avg_pitch) * 5) # 平均倾斜角度越小分数越高
|
||||
roll_score = max(0, 100 - abs(avg_roll) * 5)
|
||||
stability_score = max(0, 100 - (std_pitch + std_roll) * 10) # 稳定性越高分数越高
|
||||
|
||||
overall_score = (pitch_score + roll_score + stability_score) / 3
|
||||
|
||||
return {
|
||||
'score': round(overall_score, 1),
|
||||
'avg_pitch': round(avg_pitch, 2),
|
||||
'avg_roll': round(avg_roll, 2),
|
||||
'std_pitch': round(std_pitch, 2),
|
||||
'std_roll': round(std_roll, 2),
|
||||
'max_pitch': round(max_pitch, 2),
|
||||
'max_roll': round(max_roll, 2),
|
||||
'data_points': len(imu_data),
|
||||
'component_scores': {
|
||||
'pitch': round(pitch_score, 1),
|
||||
'roll': round(roll_score, 1),
|
||||
'stability': round(stability_score, 1)
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'全会话姿态分析失败: {e}')
|
||||
return {'score': 0, 'error': str(e)}
|
||||
|
||||
|
||||
class MovementAnalyzer:
|
||||
"""运动分析器"""
|
||||
|
||||
def analyze_real_time(self, data: Dict[str, Any], data_buffer: List[Dict]) -> Dict[str, Any]:
|
||||
"""实时运动分析"""
|
||||
try:
|
||||
if len(data_buffer) < 5:
|
||||
return {'movement_detected': False}
|
||||
|
||||
# 分析最近的运动模式
|
||||
recent_data = data_buffer[-10:]
|
||||
|
||||
# 计算重心位置变化
|
||||
if 'pressure' in data:
|
||||
cop_positions = []
|
||||
for d in recent_data:
|
||||
if 'pressure' in d:
|
||||
cop = d['pressure'].get('center_of_pressure', {'x': 0, 'y': 0})
|
||||
cop_positions.append((cop['x'], cop['y']))
|
||||
|
||||
if len(cop_positions) >= 2:
|
||||
# 计算运动幅度
|
||||
x_positions = [pos[0] for pos in cop_positions]
|
||||
y_positions = [pos[1] for pos in cop_positions]
|
||||
movement_range_x = np.max(x_positions) - np.min(x_positions)
|
||||
movement_range_y = np.max(y_positions) - np.min(y_positions)
|
||||
|
||||
return {
|
||||
'movement_detected': movement_range_x > 5 or movement_range_y > 5,
|
||||
'movement_range_x': movement_range_x,
|
||||
'movement_range_y': movement_range_y,
|
||||
'total_movement': np.sqrt(movement_range_x**2 + movement_range_y**2)
|
||||
}
|
||||
|
||||
return {'movement_detected': False}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'实时运动分析失败: {e}')
|
||||
return {'movement_detected': False}
|
||||
|
||||
def analyze_full_session(self, data_buffer: List[Dict], settings: Dict) -> Dict[str, Any]:
|
||||
"""全会话运动分析"""
|
||||
try:
|
||||
# 提取压力中心数据
|
||||
cop_data = []
|
||||
for d in data_buffer:
|
||||
if 'pressure' in d:
|
||||
cop = d['pressure'].get('center_of_pressure', {'x': 0, 'y': 0})
|
||||
cop_data.append((cop['x'], cop['y']))
|
||||
|
||||
if len(cop_data) < 10:
|
||||
return {'score': 0, 'error': '数据不足'}
|
||||
|
||||
# 计算运动指标
|
||||
x_positions = [pos[0] for pos in cop_data]
|
||||
y_positions = [pos[1] for pos in cop_data]
|
||||
|
||||
# 运动范围
|
||||
movement_range_x = np.max(x_positions) - np.min(x_positions)
|
||||
movement_range_y = np.max(y_positions) - np.min(y_positions)
|
||||
total_range = np.sqrt(movement_range_x**2 + movement_range_y**2)
|
||||
|
||||
# 运动变异性
|
||||
movement_variability = np.std(x_positions) + np.std(y_positions)
|
||||
|
||||
# 运动路径长度
|
||||
path_length = 0
|
||||
for i in range(1, len(cop_data)):
|
||||
dx = cop_data[i][0] - cop_data[i-1][0]
|
||||
dy = cop_data[i][1] - cop_data[i-1][1]
|
||||
path_length += np.sqrt(dx**2 + dy**2)
|
||||
|
||||
# 运动频率分析(简化)
|
||||
movement_frequency = path_length / len(cop_data) if len(cop_data) > 0 else 0
|
||||
|
||||
# 计算评分(运动幅度适中得分高)
|
||||
range_score = max(0, 100 - total_range * 2) # 运动范围适中
|
||||
variability_score = max(0, 100 - movement_variability * 10) # 变异性小
|
||||
frequency_score = max(0, 100 - movement_frequency * 20) # 频率适中
|
||||
|
||||
overall_score = (range_score + variability_score + frequency_score) / 3
|
||||
|
||||
return {
|
||||
'score': round(overall_score, 1),
|
||||
'movement_range_x': round(movement_range_x, 2),
|
||||
'movement_range_y': round(movement_range_y, 2),
|
||||
'total_range': round(total_range, 2),
|
||||
'movement_variability': round(movement_variability, 2),
|
||||
'path_length': round(path_length, 2),
|
||||
'movement_frequency': round(movement_frequency, 2),
|
||||
'data_points': len(cop_data),
|
||||
'component_scores': {
|
||||
'range': round(range_score, 1),
|
||||
'variability': round(variability_score, 1),
|
||||
'frequency': round(frequency_score, 1)
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'全会话运动分析失败: {e}')
|
||||
return {'score': 0, 'error': str(e)}
|
||||
479
backend/device_manager.py
Normal file
479
backend/device_manager.py
Normal file
@ -0,0 +1,479 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
设备管理模块
|
||||
负责摄像头、IMU传感器和压力传感器的连接和数据采集
|
||||
"""
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import time
|
||||
import threading
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Any
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class DeviceManager:
|
||||
"""设备管理器"""
|
||||
|
||||
def __init__(self):
|
||||
self.camera = None
|
||||
self.imu_device = None
|
||||
self.pressure_device = None
|
||||
self.device_status = {
|
||||
'camera': False,
|
||||
'imu': False,
|
||||
'pressure': False
|
||||
}
|
||||
self.calibration_data = {}
|
||||
self.data_lock = threading.Lock()
|
||||
self.latest_data = {}
|
||||
|
||||
# 初始化设备
|
||||
self._init_devices()
|
||||
|
||||
def _init_devices(self):
|
||||
"""初始化所有设备"""
|
||||
try:
|
||||
self._init_camera()
|
||||
self._init_imu()
|
||||
self._init_pressure_sensor()
|
||||
logger.info('设备初始化完成')
|
||||
except Exception as e:
|
||||
logger.error(f'设备初始化失败: {e}')
|
||||
|
||||
def _init_camera(self):
|
||||
"""初始化摄像头"""
|
||||
try:
|
||||
# 尝试连接默认摄像头
|
||||
self.camera = cv2.VideoCapture(0)
|
||||
if self.camera.isOpened():
|
||||
# 设置摄像头参数
|
||||
self.camera.set(cv2.CAP_PROP_FRAME_WIDTH, 1280)
|
||||
self.camera.set(cv2.CAP_PROP_FRAME_HEIGHT, 720)
|
||||
self.camera.set(cv2.CAP_PROP_FPS, 30)
|
||||
|
||||
self.device_status['camera'] = True
|
||||
logger.info('摄像头初始化成功')
|
||||
else:
|
||||
logger.warning('摄像头连接失败')
|
||||
self.camera = None
|
||||
except Exception as e:
|
||||
logger.error(f'摄像头初始化异常: {e}')
|
||||
self.camera = None
|
||||
|
||||
def _init_imu(self):
|
||||
"""初始化IMU传感器"""
|
||||
try:
|
||||
# 这里应该连接实际的IMU设备
|
||||
# 目前使用模拟数据
|
||||
self.imu_device = MockIMUDevice()
|
||||
self.device_status['imu'] = True
|
||||
logger.info('IMU传感器初始化成功(模拟)')
|
||||
except Exception as e:
|
||||
logger.error(f'IMU传感器初始化失败: {e}')
|
||||
self.imu_device = None
|
||||
|
||||
def _init_pressure_sensor(self):
|
||||
"""初始化压力传感器"""
|
||||
try:
|
||||
# 这里应该连接实际的压力传感器
|
||||
# 目前使用模拟数据
|
||||
self.pressure_device = MockPressureDevice()
|
||||
self.device_status['pressure'] = True
|
||||
logger.info('压力传感器初始化成功(模拟)')
|
||||
except Exception as e:
|
||||
logger.error(f'压力传感器初始化失败: {e}')
|
||||
self.pressure_device = None
|
||||
|
||||
def get_device_status(self) -> Dict[str, bool]:
|
||||
"""获取设备状态"""
|
||||
return self.device_status.copy()
|
||||
|
||||
def get_connected_devices(self) -> List[str]:
|
||||
"""获取已连接的设备列表"""
|
||||
return [device for device, status in self.device_status.items() if status]
|
||||
|
||||
def refresh_devices(self):
|
||||
"""刷新设备连接"""
|
||||
logger.info('刷新设备连接...')
|
||||
|
||||
# 重新初始化所有设备
|
||||
if self.camera:
|
||||
self.camera.release()
|
||||
|
||||
self._init_devices()
|
||||
|
||||
def calibrate_devices(self) -> Dict[str, Any]:
|
||||
"""校准设备"""
|
||||
calibration_result = {}
|
||||
|
||||
try:
|
||||
# 摄像头校准
|
||||
if self.device_status['camera']:
|
||||
camera_calibration = self._calibrate_camera()
|
||||
calibration_result['camera'] = camera_calibration
|
||||
|
||||
# IMU校准
|
||||
if self.device_status['imu']:
|
||||
imu_calibration = self._calibrate_imu()
|
||||
calibration_result['imu'] = imu_calibration
|
||||
|
||||
# 压力传感器校准
|
||||
if self.device_status['pressure']:
|
||||
pressure_calibration = self._calibrate_pressure()
|
||||
calibration_result['pressure'] = pressure_calibration
|
||||
|
||||
self.calibration_data = calibration_result
|
||||
logger.info('设备校准完成')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'设备校准失败: {e}')
|
||||
raise
|
||||
|
||||
return calibration_result
|
||||
|
||||
def _calibrate_camera(self) -> Dict[str, Any]:
|
||||
"""校准摄像头"""
|
||||
if not self.camera or not self.camera.isOpened():
|
||||
return {'status': 'failed', 'error': '摄像头未连接'}
|
||||
|
||||
try:
|
||||
# 获取几帧图像进行校准
|
||||
frames = []
|
||||
for _ in range(10):
|
||||
ret, frame = self.camera.read()
|
||||
if ret:
|
||||
frames.append(frame)
|
||||
time.sleep(0.1)
|
||||
|
||||
if not frames:
|
||||
return {'status': 'failed', 'error': '无法获取图像'}
|
||||
|
||||
# 计算平均亮度和对比度
|
||||
avg_brightness = np.mean([np.mean(cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)) for frame in frames])
|
||||
|
||||
calibration = {
|
||||
'status': 'success',
|
||||
'brightness': float(avg_brightness),
|
||||
'resolution': (int(self.camera.get(cv2.CAP_PROP_FRAME_WIDTH)),
|
||||
int(self.camera.get(cv2.CAP_PROP_FRAME_HEIGHT))),
|
||||
'fps': float(self.camera.get(cv2.CAP_PROP_FPS)),
|
||||
'timestamp': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
return calibration
|
||||
|
||||
except Exception as e:
|
||||
return {'status': 'failed', 'error': str(e)}
|
||||
|
||||
def _calibrate_imu(self) -> Dict[str, Any]:
|
||||
"""校准IMU传感器"""
|
||||
if not self.imu_device:
|
||||
return {'status': 'failed', 'error': 'IMU设备未连接'}
|
||||
|
||||
try:
|
||||
# 收集静态数据进行零点校准
|
||||
samples = []
|
||||
for _ in range(100):
|
||||
data = self.imu_device.read_data()
|
||||
samples.append(data)
|
||||
time.sleep(0.01)
|
||||
|
||||
# 计算零点偏移
|
||||
accel_offset = {
|
||||
'x': np.mean([s['accel']['x'] for s in samples]),
|
||||
'y': np.mean([s['accel']['y'] for s in samples]),
|
||||
'z': np.mean([s['accel']['z'] for s in samples]) - 9.8 # 重力补偿
|
||||
}
|
||||
|
||||
gyro_offset = {
|
||||
'x': np.mean([s['gyro']['x'] for s in samples]),
|
||||
'y': np.mean([s['gyro']['y'] for s in samples]),
|
||||
'z': np.mean([s['gyro']['z'] for s in samples])
|
||||
}
|
||||
|
||||
calibration = {
|
||||
'status': 'success',
|
||||
'accel_offset': accel_offset,
|
||||
'gyro_offset': gyro_offset,
|
||||
'timestamp': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
return calibration
|
||||
|
||||
except Exception as e:
|
||||
return {'status': 'failed', 'error': str(e)}
|
||||
|
||||
def _calibrate_pressure(self) -> Dict[str, Any]:
|
||||
"""校准压力传感器"""
|
||||
if not self.pressure_device:
|
||||
return {'status': 'failed', 'error': '压力传感器未连接'}
|
||||
|
||||
try:
|
||||
# 收集零压力数据
|
||||
samples = []
|
||||
for _ in range(50):
|
||||
data = self.pressure_device.read_data()
|
||||
samples.append(data)
|
||||
time.sleep(0.02)
|
||||
|
||||
# 计算零点偏移
|
||||
zero_offset = {
|
||||
'left_foot': np.mean([s['left_foot'] for s in samples]),
|
||||
'right_foot': np.mean([s['right_foot'] for s in samples])
|
||||
}
|
||||
|
||||
calibration = {
|
||||
'status': 'success',
|
||||
'zero_offset': zero_offset,
|
||||
'timestamp': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
return calibration
|
||||
|
||||
except Exception as e:
|
||||
return {'status': 'failed', 'error': str(e)}
|
||||
|
||||
def collect_data(self) -> Dict[str, Any]:
|
||||
"""采集所有设备数据"""
|
||||
data = {}
|
||||
timestamp = datetime.now().isoformat()
|
||||
|
||||
try:
|
||||
# 采集摄像头数据
|
||||
if self.device_status['camera']:
|
||||
camera_data = self._collect_camera_data()
|
||||
if camera_data:
|
||||
data['camera'] = camera_data
|
||||
|
||||
# 采集IMU数据
|
||||
if self.device_status['imu']:
|
||||
imu_data = self._collect_imu_data()
|
||||
if imu_data:
|
||||
data['imu'] = imu_data
|
||||
|
||||
# 采集压力传感器数据
|
||||
if self.device_status['pressure']:
|
||||
pressure_data = self._collect_pressure_data()
|
||||
if pressure_data:
|
||||
data['pressure'] = pressure_data
|
||||
|
||||
# 添加时间戳
|
||||
data['timestamp'] = timestamp
|
||||
|
||||
# 更新最新数据
|
||||
with self.data_lock:
|
||||
self.latest_data = data.copy()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'数据采集失败: {e}')
|
||||
|
||||
return data
|
||||
|
||||
def _collect_camera_data(self) -> Optional[Dict[str, Any]]:
|
||||
"""采集摄像头数据"""
|
||||
if not self.camera or not self.camera.isOpened():
|
||||
return None
|
||||
|
||||
try:
|
||||
ret, frame = self.camera.read()
|
||||
if not ret:
|
||||
return None
|
||||
|
||||
# 进行姿态检测(这里应该集成实际的姿态检测算法)
|
||||
pose_data = self._detect_pose(frame)
|
||||
|
||||
return {
|
||||
'frame_available': True,
|
||||
'frame_size': frame.shape,
|
||||
'pose_data': pose_data,
|
||||
'timestamp': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'摄像头数据采集失败: {e}')
|
||||
return None
|
||||
|
||||
def _detect_pose(self, frame: np.ndarray) -> Dict[str, Any]:
|
||||
"""姿态检测(模拟实现)"""
|
||||
# 这里应该集成实际的姿态检测算法,如MediaPipe或OpenPose
|
||||
# 目前返回模拟数据
|
||||
return {
|
||||
'center_of_gravity': {
|
||||
'x': np.random.normal(0, 5),
|
||||
'y': np.random.normal(0, 5)
|
||||
},
|
||||
'body_angle': {
|
||||
'pitch': np.random.normal(0, 2),
|
||||
'roll': np.random.normal(0, 2),
|
||||
'yaw': np.random.normal(0, 1)
|
||||
},
|
||||
'confidence': np.random.uniform(0.8, 1.0)
|
||||
}
|
||||
|
||||
def _collect_imu_data(self) -> Optional[Dict[str, Any]]:
|
||||
"""采集IMU数据"""
|
||||
if not self.imu_device:
|
||||
return None
|
||||
|
||||
try:
|
||||
raw_data = self.imu_device.read_data()
|
||||
|
||||
# 应用校准偏移
|
||||
if 'imu' in self.calibration_data:
|
||||
calibration = self.calibration_data['imu']
|
||||
if calibration.get('status') == 'success':
|
||||
accel_offset = calibration.get('accel_offset', {})
|
||||
gyro_offset = calibration.get('gyro_offset', {})
|
||||
|
||||
# 校正加速度数据
|
||||
raw_data['accel']['x'] -= accel_offset.get('x', 0)
|
||||
raw_data['accel']['y'] -= accel_offset.get('y', 0)
|
||||
raw_data['accel']['z'] -= accel_offset.get('z', 0)
|
||||
|
||||
# 校正陀螺仪数据
|
||||
raw_data['gyro']['x'] -= gyro_offset.get('x', 0)
|
||||
raw_data['gyro']['y'] -= gyro_offset.get('y', 0)
|
||||
raw_data['gyro']['z'] -= gyro_offset.get('z', 0)
|
||||
|
||||
return raw_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'IMU数据采集失败: {e}')
|
||||
return None
|
||||
|
||||
def _collect_pressure_data(self) -> Optional[Dict[str, Any]]:
|
||||
"""采集压力传感器数据"""
|
||||
if not self.pressure_device:
|
||||
return None
|
||||
|
||||
try:
|
||||
raw_data = self.pressure_device.read_data()
|
||||
|
||||
# 应用校准偏移
|
||||
if 'pressure' in self.calibration_data:
|
||||
calibration = self.calibration_data['pressure']
|
||||
if calibration.get('status') == 'success':
|
||||
zero_offset = calibration.get('zero_offset', {})
|
||||
|
||||
raw_data['left_foot'] -= zero_offset.get('left_foot', 0)
|
||||
raw_data['right_foot'] -= zero_offset.get('right_foot', 0)
|
||||
|
||||
# 计算重心位置
|
||||
total_pressure = raw_data['left_foot'] + raw_data['right_foot']
|
||||
if total_pressure > 0:
|
||||
center_of_pressure = {
|
||||
'x': (raw_data['right_foot'] - raw_data['left_foot']) / total_pressure * 100,
|
||||
'y': 0 # 简化模型
|
||||
}
|
||||
else:
|
||||
center_of_pressure = {'x': 0, 'y': 0}
|
||||
|
||||
raw_data['center_of_pressure'] = center_of_pressure
|
||||
raw_data['total_pressure'] = total_pressure
|
||||
|
||||
return raw_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'压力传感器数据采集失败: {e}')
|
||||
return None
|
||||
|
||||
def get_latest_data(self) -> Dict[str, Any]:
|
||||
"""获取最新采集的数据"""
|
||||
with self.data_lock:
|
||||
return self.latest_data.copy()
|
||||
|
||||
def start_video_recording(self, output_path: str) -> bool:
|
||||
"""开始视频录制"""
|
||||
if not self.camera or not self.camera.isOpened():
|
||||
return False
|
||||
|
||||
try:
|
||||
# 获取摄像头参数
|
||||
width = int(self.camera.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
height = int(self.camera.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
fps = int(self.camera.get(cv2.CAP_PROP_FPS))
|
||||
|
||||
# 创建视频写入器
|
||||
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
|
||||
self.video_writer = cv2.VideoWriter(output_path, fourcc, fps, (width, height))
|
||||
|
||||
if self.video_writer.isOpened():
|
||||
logger.info(f'开始视频录制: {output_path}')
|
||||
return True
|
||||
else:
|
||||
logger.error('视频写入器创建失败')
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'开始视频录制失败: {e}')
|
||||
return False
|
||||
|
||||
def stop_video_recording(self):
|
||||
"""停止视频录制"""
|
||||
if hasattr(self, 'video_writer') and self.video_writer:
|
||||
self.video_writer.release()
|
||||
self.video_writer = None
|
||||
logger.info('视频录制已停止')
|
||||
|
||||
def cleanup(self):
|
||||
"""清理资源"""
|
||||
try:
|
||||
if self.camera:
|
||||
self.camera.release()
|
||||
|
||||
if hasattr(self, 'video_writer') and self.video_writer:
|
||||
self.video_writer.release()
|
||||
|
||||
logger.info('设备资源已清理')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'清理设备资源失败: {e}')
|
||||
|
||||
|
||||
class MockIMUDevice:
|
||||
"""模拟IMU设备"""
|
||||
|
||||
def __init__(self):
|
||||
self.noise_level = 0.1
|
||||
|
||||
def read_data(self) -> Dict[str, Any]:
|
||||
"""读取IMU数据"""
|
||||
return {
|
||||
'accel': {
|
||||
'x': np.random.normal(0, self.noise_level),
|
||||
'y': np.random.normal(0, self.noise_level),
|
||||
'z': np.random.normal(9.8, self.noise_level) # 重力加速度
|
||||
},
|
||||
'gyro': {
|
||||
'x': np.random.normal(0, self.noise_level),
|
||||
'y': np.random.normal(0, self.noise_level),
|
||||
'z': np.random.normal(0, self.noise_level)
|
||||
},
|
||||
'temperature': np.random.normal(25, 2),
|
||||
'timestamp': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
|
||||
class MockPressureDevice:
|
||||
"""模拟压力传感器设备"""
|
||||
|
||||
def __init__(self):
|
||||
self.base_pressure = 500 # 基础压力值
|
||||
self.noise_level = 10
|
||||
|
||||
def read_data(self) -> Dict[str, Any]:
|
||||
"""读取压力数据"""
|
||||
# 模拟轻微的左右脚压力差异
|
||||
left_pressure = self.base_pressure + np.random.normal(0, self.noise_level)
|
||||
right_pressure = self.base_pressure + np.random.normal(0, self.noise_level)
|
||||
|
||||
return {
|
||||
'left_foot': max(0, left_pressure),
|
||||
'right_foot': max(0, right_pressure),
|
||||
'timestamp': datetime.now().isoformat()
|
||||
}
|
||||
412
backend/main.py
Normal file
412
backend/main.py
Normal file
@ -0,0 +1,412 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
身体平衡评估系统 - 主启动脚本
|
||||
|
||||
这个脚本负责启动整个应用程序,包括后端服务和前端界面。
|
||||
支持开发模式和生产模式。
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
import webbrowser
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
# 添加项目根目录到Python路径
|
||||
project_root = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
sys.path.insert(0, str(project_root / 'backend'))
|
||||
|
||||
from utils import Config, Logger
|
||||
|
||||
class ApplicationLauncher:
|
||||
"""应用程序启动器"""
|
||||
|
||||
def __init__(self):
|
||||
self.config = Config()
|
||||
# 设置日志
|
||||
Logger.setup_logging('INFO', 'logs/app.log')
|
||||
self.logger = logging.getLogger('main')
|
||||
self.backend_process = None
|
||||
self.frontend_process = None
|
||||
self.running = False
|
||||
|
||||
# 设置信号处理
|
||||
signal.signal(signal.SIGINT, self._signal_handler)
|
||||
signal.signal(signal.SIGTERM, self._signal_handler)
|
||||
|
||||
def _signal_handler(self, signum, frame):
|
||||
"""信号处理器"""
|
||||
self.logger.info(f"接收到信号 {signum},正在关闭应用程序...")
|
||||
self.stop()
|
||||
|
||||
def check_dependencies(self):
|
||||
"""检查依赖项"""
|
||||
self.logger.info("检查系统依赖项...")
|
||||
|
||||
# 检查Python版本
|
||||
if sys.version_info < (3, 8):
|
||||
self.logger.error("需要Python 3.8或更高版本")
|
||||
return False
|
||||
|
||||
# 检查必要的Python包
|
||||
required_packages = [
|
||||
'flask', 'flask_cors', 'flask_socketio',
|
||||
'numpy', 'pandas', 'opencv-python',
|
||||
'sqlite3'
|
||||
]
|
||||
|
||||
missing_packages = []
|
||||
for package in required_packages:
|
||||
try:
|
||||
if package == 'sqlite3':
|
||||
import sqlite3
|
||||
elif package == 'opencv-python':
|
||||
import cv2
|
||||
elif package == 'flask_cors':
|
||||
import flask_cors
|
||||
elif package == 'flask_socketio':
|
||||
import flask_socketio
|
||||
else:
|
||||
__import__(package)
|
||||
except ImportError:
|
||||
missing_packages.append(package)
|
||||
|
||||
if missing_packages:
|
||||
self.logger.error(f"缺少必要的Python包: {', '.join(missing_packages)}")
|
||||
self.logger.info("请运行: pip install -r backend/requirements.txt")
|
||||
return False
|
||||
|
||||
# 检查Node.js和npm(用于前端开发)
|
||||
if self.config.get('APP', 'mode', 'development') == 'development':
|
||||
try:
|
||||
result = subprocess.run(['node', '--version'],
|
||||
capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
self.logger.warning("未找到Node.js,将跳过前端开发服务器")
|
||||
else:
|
||||
self.logger.info(f"Node.js版本: {result.stdout.strip()}")
|
||||
except FileNotFoundError:
|
||||
self.logger.warning("未找到Node.js,将跳过前端开发服务器")
|
||||
|
||||
self.logger.info("依赖项检查完成")
|
||||
return True
|
||||
|
||||
def setup_directories(self):
|
||||
"""设置必要的目录"""
|
||||
self.logger.info("设置应用程序目录...")
|
||||
|
||||
directories = [
|
||||
'data',
|
||||
'data/patients',
|
||||
'data/sessions',
|
||||
'data/exports',
|
||||
'data/backups',
|
||||
'logs',
|
||||
'temp'
|
||||
]
|
||||
|
||||
for directory in directories:
|
||||
dir_path = project_root / directory
|
||||
dir_path.mkdir(parents=True, exist_ok=True)
|
||||
self.logger.debug(f"创建目录: {dir_path}")
|
||||
|
||||
self.logger.info("目录设置完成")
|
||||
|
||||
def start_backend(self):
|
||||
"""启动后端服务"""
|
||||
self.logger.info("启动后端服务...")
|
||||
|
||||
backend_script = project_root / 'backend' / 'app.py'
|
||||
if not backend_script.exists():
|
||||
self.logger.error(f"后端脚本不存在: {backend_script}")
|
||||
return False
|
||||
|
||||
try:
|
||||
# 设置环境变量
|
||||
env = os.environ.copy()
|
||||
env['PYTHONPATH'] = str(project_root)
|
||||
env['FLASK_APP'] = str(backend_script)
|
||||
|
||||
if self.config.get('APP', 'mode', 'development') == 'development':
|
||||
env['FLASK_ENV'] = 'development'
|
||||
env['FLASK_DEBUG'] = '1'
|
||||
else:
|
||||
env['FLASK_ENV'] = 'production'
|
||||
env['FLASK_DEBUG'] = '0'
|
||||
|
||||
# 启动后端进程
|
||||
cmd = [
|
||||
sys.executable,
|
||||
str(backend_script),
|
||||
'--host', self.config.get('SERVER', 'host', '127.0.0.1'),
|
||||
'--port', self.config.get('SERVER', 'port', '5000')
|
||||
]
|
||||
|
||||
self.backend_process = subprocess.Popen(
|
||||
cmd,
|
||||
env=env,
|
||||
cwd=str(project_root),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True
|
||||
)
|
||||
|
||||
self.logger.info(f"后端服务已启动 (PID: {self.backend_process.pid})")
|
||||
|
||||
# 等待后端服务启动
|
||||
self._wait_for_backend()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"启动后端服务失败: {e}")
|
||||
return False
|
||||
|
||||
def _wait_for_backend(self, timeout=30):
|
||||
"""等待后端服务启动"""
|
||||
import requests
|
||||
|
||||
backend_url = f"http://{self.config.get('SERVER', 'host', '127.0.0.1')}:{self.config.get('SERVER', 'port', '5000')}"
|
||||
health_url = f"{backend_url}/api/health"
|
||||
|
||||
self.logger.info("等待后端服务启动...")
|
||||
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < timeout:
|
||||
try:
|
||||
response = requests.get(health_url, timeout=2)
|
||||
if response.status_code == 200:
|
||||
self.logger.info("后端服务已就绪")
|
||||
return True
|
||||
except requests.exceptions.RequestException:
|
||||
pass
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
self.logger.warning("后端服务启动超时")
|
||||
return False
|
||||
|
||||
def start_frontend_dev(self):
|
||||
"""启动前端开发服务器"""
|
||||
if self.config.get('APP', 'mode', 'development') != 'development':
|
||||
return True
|
||||
|
||||
self.logger.info("启动前端开发服务器...")
|
||||
|
||||
frontend_dir = project_root / 'frontend' / 'src' / 'renderer'
|
||||
if not frontend_dir.exists():
|
||||
self.logger.warning("前端目录不存在,跳过前端开发服务器")
|
||||
return True
|
||||
|
||||
package_json = frontend_dir / 'package.json'
|
||||
if not package_json.exists():
|
||||
self.logger.warning("package.json不存在,跳过前端开发服务器")
|
||||
return True
|
||||
|
||||
try:
|
||||
# 检查是否已安装依赖
|
||||
node_modules = frontend_dir / 'node_modules'
|
||||
if not node_modules.exists():
|
||||
self.logger.info("安装前端依赖...")
|
||||
install_process = subprocess.run(
|
||||
['npm', 'install'],
|
||||
cwd=str(frontend_dir),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
shell=True
|
||||
)
|
||||
|
||||
if install_process.returncode != 0:
|
||||
self.logger.error(f"安装前端依赖失败: {install_process.stderr}")
|
||||
return False
|
||||
|
||||
# 启动开发服务器
|
||||
self.frontend_process = subprocess.Popen(
|
||||
['npm', 'run', 'dev'],
|
||||
cwd=str(frontend_dir),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
shell=True
|
||||
)
|
||||
|
||||
self.logger.info(f"前端开发服务器已启动 (PID: {self.frontend_process.pid})")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"启动前端开发服务器失败: {e}")
|
||||
return False
|
||||
|
||||
def open_browser(self):
|
||||
"""打开浏览器"""
|
||||
if not self.config.getboolean('APP', 'auto_open_browser', True):
|
||||
return
|
||||
|
||||
if self.config.get('APP', 'mode', 'development') == 'development':
|
||||
# 开发模式下打开前端开发服务器
|
||||
url = "http://localhost:3000" # Vite配置端口
|
||||
else:
|
||||
# 生产模式下打开后端服务
|
||||
url = f"http://{self.config.get('SERVER', 'host', '127.0.0.1')}:{self.config.get('SERVER', 'port', '5000')}"
|
||||
|
||||
def delayed_open():
|
||||
time.sleep(3) # 等待服务启动
|
||||
try:
|
||||
webbrowser.open(url)
|
||||
self.logger.info(f"已打开浏览器: {url}")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"打开浏览器失败: {e}")
|
||||
|
||||
threading.Thread(target=delayed_open, daemon=True).start()
|
||||
|
||||
def monitor_processes(self):
|
||||
"""监控子进程"""
|
||||
while self.running:
|
||||
try:
|
||||
# 检查后端进程
|
||||
if self.backend_process and self.backend_process.poll() is not None:
|
||||
self.logger.error("后端进程意外退出")
|
||||
if self.backend_process.returncode != 0:
|
||||
stderr = self.backend_process.stderr.read()
|
||||
if stderr:
|
||||
self.logger.error(f"后端错误: {stderr}")
|
||||
self.running = False
|
||||
break
|
||||
|
||||
# 检查前端进程
|
||||
if (self.frontend_process and
|
||||
self.frontend_process.poll() is not None and
|
||||
self.config.get('APP', 'mode', 'development') == 'development'):
|
||||
self.logger.warning("前端开发服务器意外退出")
|
||||
|
||||
time.sleep(5)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"进程监控错误: {e}")
|
||||
break
|
||||
|
||||
def start(self):
|
||||
"""启动应用程序"""
|
||||
self.logger.info("=" * 50)
|
||||
self.logger.info("身体平衡评估系统启动中...")
|
||||
self.logger.info(f"模式: {self.config.get('APP', 'mode', 'development')}")
|
||||
self.logger.info("=" * 50)
|
||||
|
||||
# 检查依赖项
|
||||
if not self.check_dependencies():
|
||||
return False
|
||||
|
||||
# 设置目录
|
||||
self.setup_directories()
|
||||
|
||||
# 启动后端服务
|
||||
if not self.start_backend():
|
||||
return False
|
||||
|
||||
# 启动前端开发服务器(仅开发模式)
|
||||
if not self.start_frontend_dev():
|
||||
self.logger.warning("前端开发服务器启动失败,但继续运行")
|
||||
|
||||
self.running = True
|
||||
|
||||
# 打开浏览器
|
||||
self.open_browser()
|
||||
|
||||
# 启动进程监控
|
||||
monitor_thread = threading.Thread(target=self.monitor_processes, daemon=True)
|
||||
monitor_thread.start()
|
||||
|
||||
self.logger.info("应用程序启动完成")
|
||||
self.logger.info(f"后端服务: http://{self.config.get('SERVER', 'host', '127.0.0.1')}:{self.config.get('SERVER', 'port', '5000')}")
|
||||
|
||||
if self.config.get('APP', 'mode', 'development') == 'development':
|
||||
self.logger.info("前端开发服务器: http://localhost:3000")
|
||||
|
||||
self.logger.info("按 Ctrl+C 退出应用程序")
|
||||
|
||||
# 主循环
|
||||
try:
|
||||
while self.running:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
self.logger.info("接收到中断信号")
|
||||
|
||||
self.stop()
|
||||
return True
|
||||
|
||||
def stop(self):
|
||||
"""停止应用程序"""
|
||||
if not self.running:
|
||||
return
|
||||
|
||||
self.logger.info("正在停止应用程序...")
|
||||
self.running = False
|
||||
|
||||
# 停止前端进程
|
||||
if self.frontend_process:
|
||||
try:
|
||||
self.frontend_process.terminate()
|
||||
self.frontend_process.wait(timeout=5)
|
||||
self.logger.info("前端开发服务器已停止")
|
||||
except subprocess.TimeoutExpired:
|
||||
self.frontend_process.kill()
|
||||
self.logger.warning("强制终止前端开发服务器")
|
||||
except Exception as e:
|
||||
self.logger.error(f"停止前端服务器失败: {e}")
|
||||
|
||||
# 停止后端进程
|
||||
if self.backend_process:
|
||||
try:
|
||||
self.backend_process.terminate()
|
||||
self.backend_process.wait(timeout=10)
|
||||
self.logger.info("后端服务已停止")
|
||||
except subprocess.TimeoutExpired:
|
||||
self.backend_process.kill()
|
||||
self.logger.warning("强制终止后端服务")
|
||||
except Exception as e:
|
||||
self.logger.error(f"停止后端服务失败: {e}")
|
||||
|
||||
self.logger.info("应用程序已停止")
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description='身体平衡评估系统')
|
||||
parser.add_argument('--mode', choices=['development', 'production'],
|
||||
default='development', help='运行模式')
|
||||
parser.add_argument('--host', default='127.0.0.1', help='服务器主机')
|
||||
parser.add_argument('--port', type=int, default=5000, help='服务器端口')
|
||||
parser.add_argument('--no-browser', action='store_true', help='不自动打开浏览器')
|
||||
parser.add_argument('--log-level', choices=['DEBUG', 'INFO', 'WARNING', 'ERROR'],
|
||||
default='INFO', help='日志级别')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 更新配置
|
||||
config = Config()
|
||||
config.set('APP', 'mode', args.mode)
|
||||
config.set('APP', 'auto_open_browser', str(not args.no_browser))
|
||||
config.set('SERVER', 'host', args.host)
|
||||
config.set('SERVER', 'port', str(args.port))
|
||||
|
||||
# 设置日志级别
|
||||
import logging
|
||||
logging.getLogger().setLevel(getattr(logging, args.log_level))
|
||||
|
||||
# 启动应用程序
|
||||
launcher = ApplicationLauncher()
|
||||
try:
|
||||
success = launcher.start()
|
||||
sys.exit(0 if success else 1)
|
||||
except Exception as e:
|
||||
print(f"启动失败: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
78
backend/pytest.ini
Normal file
78
backend/pytest.ini
Normal file
@ -0,0 +1,78 @@
|
||||
[tool:pytest]
|
||||
# pytest 配置文件
|
||||
|
||||
# 测试发现
|
||||
testpaths = tests
|
||||
python_files = test_*.py *_test.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
|
||||
# 输出配置
|
||||
addopts =
|
||||
-v
|
||||
--tb=short
|
||||
--strict-markers
|
||||
--disable-warnings
|
||||
--color=yes
|
||||
--durations=10
|
||||
--cov=.
|
||||
--cov-report=html:htmlcov
|
||||
--cov-report=term-missing
|
||||
--cov-fail-under=80
|
||||
|
||||
# 标记定义
|
||||
markers =
|
||||
slow: marks tests as slow (deselect with '-m "not slow"')
|
||||
integration: marks tests as integration tests
|
||||
unit: marks tests as unit tests
|
||||
api: marks tests as API tests
|
||||
database: marks tests as database tests
|
||||
device: marks tests as device tests
|
||||
analysis: marks tests as analysis tests
|
||||
|
||||
# 过滤警告
|
||||
filterwarnings =
|
||||
ignore::UserWarning
|
||||
ignore::DeprecationWarning
|
||||
ignore::PendingDeprecationWarning
|
||||
ignore:.*:pytest.PytestUnraisableExceptionWarning
|
||||
|
||||
# 最小版本要求
|
||||
minversion = 6.0
|
||||
|
||||
# 测试目录
|
||||
testmon_datafile = .testmondata
|
||||
|
||||
# 日志配置
|
||||
log_cli = true
|
||||
log_cli_level = INFO
|
||||
log_cli_format = %(asctime)s [%(levelname)8s] %(name)s: %(message)s
|
||||
log_cli_date_format = %Y-%m-%d %H:%M:%S
|
||||
|
||||
# 覆盖率配置
|
||||
[coverage:run]
|
||||
source = .
|
||||
omit =
|
||||
*/tests/*
|
||||
*/venv/*
|
||||
*/env/*
|
||||
*/__pycache__/*
|
||||
*/migrations/*
|
||||
setup.py
|
||||
conftest.py
|
||||
|
||||
[coverage:report]
|
||||
exclude_lines =
|
||||
pragma: no cover
|
||||
def __repr__
|
||||
if self.debug:
|
||||
if settings.DEBUG
|
||||
raise AssertionError
|
||||
raise NotImplementedError
|
||||
if 0:
|
||||
if __name__ == .__main__.:
|
||||
class .*\bProtocol\):
|
||||
@(abc\.)?abstractmethod
|
||||
|
||||
[coverage:html]
|
||||
directory = htmlcov
|
||||
48
backend/requirements.txt
Normal file
48
backend/requirements.txt
Normal file
@ -0,0 +1,48 @@
|
||||
# Web framework and API
|
||||
Flask
|
||||
Flask-CORS
|
||||
Flask-SocketIO
|
||||
|
||||
# Data processing and scientific computing
|
||||
numpy
|
||||
pandas
|
||||
scipy
|
||||
|
||||
# Computer vision and machine learning
|
||||
opencv-python
|
||||
# mediapipe # Not compatible with Python 3.13 yet
|
||||
# torch # May have compatibility issues with Python 3.13
|
||||
# torchvision # May have compatibility issues with Python 3.13
|
||||
scikit-learn
|
||||
|
||||
# Image processing and visualization
|
||||
matplotlib
|
||||
seaborn
|
||||
Pillow
|
||||
|
||||
# Data visualization and report generation
|
||||
reportlab
|
||||
|
||||
# Serial communication
|
||||
pyserial
|
||||
|
||||
# Audio/video processing
|
||||
ffmpeg-python
|
||||
|
||||
# Network requests and utilities
|
||||
requests
|
||||
python-dateutil
|
||||
psutil
|
||||
watchdog
|
||||
|
||||
# Development and testing tools
|
||||
pytest
|
||||
pytest-cov
|
||||
flake8
|
||||
black
|
||||
|
||||
# Other dependencies
|
||||
pyyaml
|
||||
click
|
||||
colorama
|
||||
tqdm
|
||||
1
backend/tests/__init__.py
Normal file
1
backend/tests/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# 测试模块初始化文件
|
||||
295
backend/tests/test_api.py
Normal file
295
backend/tests/test_api.py
Normal file
@ -0,0 +1,295 @@
|
||||
import pytest
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
# 添加项目根目录到Python路径
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
|
||||
from app import create_app
|
||||
from database import DatabaseManager
|
||||
from utils import Config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
"""创建测试应用实例"""
|
||||
config = Config()
|
||||
config.DATABASE['path'] = ':memory:' # 使用内存数据库进行测试
|
||||
app = create_app(config)
|
||||
app.config['TESTING'] = True
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
"""创建测试客户端"""
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db():
|
||||
"""创建测试数据库"""
|
||||
db_manager = DatabaseManager(':memory:')
|
||||
db_manager.init_database()
|
||||
return db_manager
|
||||
|
||||
|
||||
class TestHealthAPI:
|
||||
"""健康检查API测试"""
|
||||
|
||||
def test_health_check(self, client):
|
||||
"""测试健康检查端点"""
|
||||
response = client.get('/api/health')
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert 'timestamp' in data['data']
|
||||
assert 'uptime' in data['data']
|
||||
|
||||
|
||||
class TestSystemAPI:
|
||||
"""系统信息API测试"""
|
||||
|
||||
def test_system_info(self, client):
|
||||
"""测试系统信息端点"""
|
||||
response = client.get('/api/system/info')
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert 'system' in data['data']
|
||||
assert 'python' in data['data']
|
||||
assert 'memory' in data['data']
|
||||
assert 'disk' in data['data']
|
||||
|
||||
|
||||
class TestPatientAPI:
|
||||
"""患者管理API测试"""
|
||||
|
||||
def test_create_patient(self, client):
|
||||
"""测试创建患者"""
|
||||
patient_data = {
|
||||
'name': '测试患者',
|
||||
'age': 30,
|
||||
'gender': 'male',
|
||||
'height': 175,
|
||||
'weight': 70,
|
||||
'phone': '13800138000',
|
||||
'email': 'test@example.com',
|
||||
'medical_history': '无',
|
||||
'notes': '测试患者'
|
||||
}
|
||||
|
||||
response = client.post('/api/patients',
|
||||
data=json.dumps(patient_data),
|
||||
content_type='application/json')
|
||||
|
||||
assert response.status_code == 201
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert 'patient_id' in data['data']
|
||||
|
||||
def test_get_patients(self, client):
|
||||
"""测试获取患者列表"""
|
||||
response = client.get('/api/patients')
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert 'patients' in data['data']
|
||||
assert 'total' in data['data']
|
||||
assert 'page' in data['data']
|
||||
assert 'per_page' in data['data']
|
||||
|
||||
def test_get_patient_invalid_id(self, client):
|
||||
"""测试获取不存在的患者"""
|
||||
response = client.get('/api/patients/99999')
|
||||
assert response.status_code == 404
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'error'
|
||||
|
||||
def test_create_patient_invalid_data(self, client):
|
||||
"""测试创建患者时提供无效数据"""
|
||||
invalid_data = {
|
||||
'name': '', # 空名称
|
||||
'age': -1, # 无效年龄
|
||||
'gender': 'invalid' # 无效性别
|
||||
}
|
||||
|
||||
response = client.post('/api/patients',
|
||||
data=json.dumps(invalid_data),
|
||||
content_type='application/json')
|
||||
|
||||
assert response.status_code == 400
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'error'
|
||||
|
||||
|
||||
class TestDeviceAPI:
|
||||
"""设备管理API测试"""
|
||||
|
||||
@patch('device_manager.DeviceManager')
|
||||
def test_device_status(self, mock_device_manager, client):
|
||||
"""测试设备状态查询"""
|
||||
# 模拟设备管理器
|
||||
mock_instance = MagicMock()
|
||||
mock_instance.get_device_status.return_value = {
|
||||
'camera': {'connected': True, 'status': 'ready'},
|
||||
'imu': {'connected': True, 'status': 'ready'},
|
||||
'pressure': {'connected': True, 'status': 'ready'}
|
||||
}
|
||||
mock_device_manager.return_value = mock_instance
|
||||
|
||||
response = client.get('/api/devices/status')
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert 'devices' in data['data']
|
||||
|
||||
@patch('device_manager.DeviceManager')
|
||||
def test_device_refresh(self, mock_device_manager, client):
|
||||
"""测试设备刷新"""
|
||||
mock_instance = MagicMock()
|
||||
mock_instance.refresh_devices.return_value = True
|
||||
mock_device_manager.return_value = mock_instance
|
||||
|
||||
response = client.post('/api/devices/refresh')
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
|
||||
|
||||
class TestDetectionAPI:
|
||||
"""检测管理API测试"""
|
||||
|
||||
@patch('detection_engine.DetectionEngine')
|
||||
def test_start_detection(self, mock_detection_engine, client):
|
||||
"""测试开始检测"""
|
||||
mock_instance = MagicMock()
|
||||
mock_instance.start_session.return_value = 'session_123'
|
||||
mock_detection_engine.return_value = mock_instance
|
||||
|
||||
detection_config = {
|
||||
'patient_id': 1,
|
||||
'duration': 60,
|
||||
'recording': True,
|
||||
'real_time_analysis': True
|
||||
}
|
||||
|
||||
response = client.post('/api/detection/start',
|
||||
data=json.dumps(detection_config),
|
||||
content_type='application/json')
|
||||
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert 'session_id' in data['data']
|
||||
|
||||
@patch('detection_engine.DetectionEngine')
|
||||
def test_stop_detection(self, mock_detection_engine, client):
|
||||
"""测试停止检测"""
|
||||
mock_instance = MagicMock()
|
||||
mock_instance.stop_session.return_value = True
|
||||
mock_detection_engine.return_value = mock_instance
|
||||
|
||||
response = client.post('/api/detection/stop/session_123')
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
|
||||
def test_start_detection_invalid_config(self, client):
|
||||
"""测试使用无效配置开始检测"""
|
||||
invalid_config = {
|
||||
'patient_id': -1, # 无效患者ID
|
||||
'duration': 0 # 无效持续时间
|
||||
}
|
||||
|
||||
response = client.post('/api/detection/start',
|
||||
data=json.dumps(invalid_config),
|
||||
content_type='application/json')
|
||||
|
||||
assert response.status_code == 400
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'error'
|
||||
|
||||
|
||||
class TestDatabaseManager:
|
||||
"""数据库管理器测试"""
|
||||
|
||||
def test_create_patient(self, db):
|
||||
"""测试创建患者"""
|
||||
patient_data = {
|
||||
'name': '测试患者',
|
||||
'age': 30,
|
||||
'gender': 'male',
|
||||
'height': 175,
|
||||
'weight': 70
|
||||
}
|
||||
|
||||
patient_id = db.create_patient(patient_data)
|
||||
assert patient_id is not None
|
||||
assert isinstance(patient_id, int)
|
||||
|
||||
def test_get_patient(self, db):
|
||||
"""测试获取患者信息"""
|
||||
# 先创建患者
|
||||
patient_data = {
|
||||
'name': '测试患者',
|
||||
'age': 30,
|
||||
'gender': 'male'
|
||||
}
|
||||
patient_id = db.create_patient(patient_data)
|
||||
|
||||
# 获取患者信息
|
||||
patient = db.get_patient(patient_id)
|
||||
assert patient is not None
|
||||
assert patient['name'] == '测试患者'
|
||||
assert patient['age'] == 30
|
||||
|
||||
def test_get_nonexistent_patient(self, db):
|
||||
"""测试获取不存在的患者"""
|
||||
patient = db.get_patient(99999)
|
||||
assert patient is None
|
||||
|
||||
def test_create_session(self, db):
|
||||
"""测试创建检测会话"""
|
||||
# 先创建患者
|
||||
patient_data = {'name': '测试患者', 'age': 30, 'gender': 'male'}
|
||||
patient_id = db.create_patient(patient_data)
|
||||
|
||||
# 创建会话
|
||||
session_data = {
|
||||
'patient_id': patient_id,
|
||||
'duration': 60,
|
||||
'recording': True,
|
||||
'config': {'test': 'config'}
|
||||
}
|
||||
|
||||
session_id = db.create_session(session_data)
|
||||
assert session_id is not None
|
||||
assert isinstance(session_id, str)
|
||||
|
||||
def test_update_session_status(self, db):
|
||||
"""测试更新会话状态"""
|
||||
# 创建患者和会话
|
||||
patient_data = {'name': '测试患者', 'age': 30, 'gender': 'male'}
|
||||
patient_id = db.create_patient(patient_data)
|
||||
|
||||
session_data = {
|
||||
'patient_id': patient_id,
|
||||
'duration': 60,
|
||||
'recording': True
|
||||
}
|
||||
session_id = db.create_session(session_data)
|
||||
|
||||
# 更新状态
|
||||
result = db.update_session_status(session_id, 'completed')
|
||||
assert result is True
|
||||
|
||||
# 验证状态已更新
|
||||
session = db.get_session(session_id)
|
||||
assert session['status'] == 'completed'
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v'])
|
||||
521
backend/utils.py
Normal file
521
backend/utils.py
Normal file
@ -0,0 +1,521 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
工具模块
|
||||
包含常用的工具函数、配置管理和辅助功能
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Any, Union
|
||||
from pathlib import Path
|
||||
import configparser
|
||||
from functools import wraps
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class Config:
|
||||
"""配置管理器"""
|
||||
|
||||
def __init__(self, config_file: str = 'config.ini'):
|
||||
self.config_file = Path(config_file)
|
||||
self.config = configparser.ConfigParser()
|
||||
self._load_config()
|
||||
|
||||
def _load_config(self):
|
||||
"""加载配置文件"""
|
||||
try:
|
||||
if self.config_file.exists():
|
||||
self.config.read(self.config_file, encoding='utf-8')
|
||||
logger.info(f'配置文件已加载: {self.config_file}')
|
||||
else:
|
||||
self._create_default_config()
|
||||
logger.info('已创建默认配置文件')
|
||||
except Exception as e:
|
||||
logger.error(f'配置文件加载失败: {e}')
|
||||
self._create_default_config()
|
||||
|
||||
def _create_default_config(self):
|
||||
"""创建默认配置"""
|
||||
try:
|
||||
# 应用配置
|
||||
self.config['APP'] = {
|
||||
'name': 'Body Balance Evaluation System',
|
||||
'version': '1.0.0',
|
||||
'debug': 'false',
|
||||
'log_level': 'INFO'
|
||||
}
|
||||
|
||||
# 服务器配置
|
||||
self.config['SERVER'] = {
|
||||
'host': '127.0.0.1',
|
||||
'port': '5000',
|
||||
'cors_origins': '*'
|
||||
}
|
||||
|
||||
# 数据库配置
|
||||
self.config['DATABASE'] = {
|
||||
'path': 'data/balance_system.db',
|
||||
'backup_interval': '24', # 小时
|
||||
'max_backups': '7'
|
||||
}
|
||||
|
||||
# 设备配置
|
||||
self.config['DEVICES'] = {
|
||||
'camera_index': '0',
|
||||
'camera_width': '640',
|
||||
'camera_height': '480',
|
||||
'camera_fps': '30',
|
||||
'imu_port': 'COM3',
|
||||
'pressure_port': 'COM4'
|
||||
}
|
||||
|
||||
# 检测配置
|
||||
self.config['DETECTION'] = {
|
||||
'default_duration': '60', # 秒
|
||||
'sampling_rate': '30', # Hz
|
||||
'balance_threshold': '0.2',
|
||||
'posture_threshold': '5.0' # 度
|
||||
}
|
||||
|
||||
# 数据处理配置
|
||||
self.config['DATA_PROCESSING'] = {
|
||||
'filter_window': '5',
|
||||
'outlier_threshold': '2.0',
|
||||
'chart_dpi': '300',
|
||||
'export_format': 'csv'
|
||||
}
|
||||
|
||||
# 安全配置
|
||||
self.config['SECURITY'] = {
|
||||
'secret_key': secrets.token_hex(32),
|
||||
'session_timeout': '3600', # 秒
|
||||
'max_login_attempts': '5'
|
||||
}
|
||||
|
||||
# 保存配置文件
|
||||
self.save_config()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'默认配置创建失败: {e}')
|
||||
|
||||
def get(self, section: str, key: str, fallback: Any = None) -> str:
|
||||
"""获取配置值"""
|
||||
try:
|
||||
return self.config.get(section, key, fallback=fallback)
|
||||
except Exception as e:
|
||||
logger.warning(f'配置获取失败 [{section}][{key}]: {e}')
|
||||
return fallback
|
||||
|
||||
def getint(self, section: str, key: str, fallback: int = 0) -> int:
|
||||
"""获取整数配置值"""
|
||||
try:
|
||||
return self.config.getint(section, key, fallback=fallback)
|
||||
except Exception as e:
|
||||
logger.warning(f'整数配置获取失败 [{section}][{key}]: {e}')
|
||||
return fallback
|
||||
|
||||
def getfloat(self, section: str, key: str, fallback: float = 0.0) -> float:
|
||||
"""获取浮点数配置值"""
|
||||
try:
|
||||
return self.config.getfloat(section, key, fallback=fallback)
|
||||
except Exception as e:
|
||||
logger.warning(f'浮点数配置获取失败 [{section}][{key}]: {e}')
|
||||
return fallback
|
||||
|
||||
def getboolean(self, section: str, key: str, fallback: bool = False) -> bool:
|
||||
"""获取布尔配置值"""
|
||||
try:
|
||||
return self.config.getboolean(section, key, fallback=fallback)
|
||||
except Exception as e:
|
||||
logger.warning(f'布尔配置获取失败 [{section}][{key}]: {e}')
|
||||
return fallback
|
||||
|
||||
def set(self, section: str, key: str, value: str):
|
||||
"""设置配置值"""
|
||||
try:
|
||||
if not self.config.has_section(section):
|
||||
self.config.add_section(section)
|
||||
self.config.set(section, key, str(value))
|
||||
except Exception as e:
|
||||
logger.error(f'配置设置失败 [{section}][{key}]: {e}')
|
||||
|
||||
def save_config(self):
|
||||
"""保存配置文件"""
|
||||
try:
|
||||
# 确保目录存在
|
||||
self.config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(self.config_file, 'w', encoding='utf-8') as f:
|
||||
self.config.write(f)
|
||||
logger.info(f'配置文件已保存: {self.config_file}')
|
||||
except Exception as e:
|
||||
logger.error(f'配置文件保存失败: {e}')
|
||||
|
||||
class Logger:
|
||||
"""日志管理器"""
|
||||
|
||||
@staticmethod
|
||||
def setup_logging(log_level: str = 'INFO', log_file: str = None):
|
||||
"""设置日志配置"""
|
||||
try:
|
||||
# 创建日志目录
|
||||
if log_file:
|
||||
log_path = Path(log_file)
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 配置日志格式
|
||||
formatter = logging.Formatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
|
||||
# 设置根日志器
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(getattr(logging, log_level.upper(), logging.INFO))
|
||||
|
||||
# 清除现有处理器
|
||||
for handler in root_logger.handlers[:]:
|
||||
root_logger.removeHandler(handler)
|
||||
|
||||
# 控制台处理器
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setFormatter(formatter)
|
||||
root_logger.addHandler(console_handler)
|
||||
|
||||
# 文件处理器
|
||||
if log_file:
|
||||
file_handler = logging.FileHandler(log_file, encoding='utf-8')
|
||||
file_handler.setFormatter(formatter)
|
||||
root_logger.addHandler(file_handler)
|
||||
|
||||
logger.info(f'日志系统已初始化,级别: {log_level}')
|
||||
|
||||
except Exception as e:
|
||||
print(f'日志系统初始化失败: {e}')
|
||||
|
||||
class DataValidator:
|
||||
"""数据验证器"""
|
||||
|
||||
@staticmethod
|
||||
def validate_patient_data(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""验证患者数据"""
|
||||
errors = []
|
||||
|
||||
# 必填字段检查
|
||||
required_fields = ['name', 'gender', 'birth_date']
|
||||
for field in required_fields:
|
||||
if not data.get(field):
|
||||
errors.append(f'缺少必填字段: {field}')
|
||||
|
||||
# 姓名验证
|
||||
if data.get('name'):
|
||||
name = data['name'].strip()
|
||||
if len(name) < 2 or len(name) > 50:
|
||||
errors.append('姓名长度应在2-50个字符之间')
|
||||
data['name'] = name
|
||||
|
||||
# 性别验证
|
||||
if data.get('gender'):
|
||||
if data['gender'] not in ['male', 'female', 'other']:
|
||||
errors.append('性别值无效')
|
||||
|
||||
# 出生日期验证
|
||||
if data.get('birth_date'):
|
||||
try:
|
||||
birth_date = datetime.fromisoformat(data['birth_date'].replace('Z', '+00:00'))
|
||||
if birth_date > datetime.now():
|
||||
errors.append('出生日期不能是未来时间')
|
||||
if birth_date < datetime(1900, 1, 1):
|
||||
errors.append('出生日期过早')
|
||||
except ValueError:
|
||||
errors.append('出生日期格式无效')
|
||||
|
||||
# 身高验证
|
||||
if data.get('height'):
|
||||
try:
|
||||
height = float(data['height'])
|
||||
if height < 50 or height > 250:
|
||||
errors.append('身高应在50-250cm之间')
|
||||
data['height'] = height
|
||||
except (ValueError, TypeError):
|
||||
errors.append('身高格式无效')
|
||||
|
||||
# 体重验证
|
||||
if data.get('weight'):
|
||||
try:
|
||||
weight = float(data['weight'])
|
||||
if weight < 10 or weight > 300:
|
||||
errors.append('体重应在10-300kg之间')
|
||||
data['weight'] = weight
|
||||
except (ValueError, TypeError):
|
||||
errors.append('体重格式无效')
|
||||
|
||||
# 电话验证
|
||||
if data.get('phone'):
|
||||
phone = data['phone'].strip()
|
||||
if phone and not phone.replace('-', '').replace(' ', '').isdigit():
|
||||
errors.append('电话号码格式无效')
|
||||
data['phone'] = phone
|
||||
|
||||
return {
|
||||
'valid': len(errors) == 0,
|
||||
'errors': errors,
|
||||
'data': data
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def validate_detection_config(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""验证检测配置"""
|
||||
errors = []
|
||||
|
||||
# 检测时长验证
|
||||
if 'duration' in config:
|
||||
try:
|
||||
duration = int(config['duration'])
|
||||
if duration < 10 or duration > 600:
|
||||
errors.append('检测时长应在10-600秒之间')
|
||||
config['duration'] = duration
|
||||
except (ValueError, TypeError):
|
||||
errors.append('检测时长格式无效')
|
||||
|
||||
# 采样频率验证
|
||||
if 'sampling_rate' in config:
|
||||
try:
|
||||
rate = int(config['sampling_rate'])
|
||||
if rate < 1 or rate > 100:
|
||||
errors.append('采样频率应在1-100Hz之间')
|
||||
config['sampling_rate'] = rate
|
||||
except (ValueError, TypeError):
|
||||
errors.append('采样频率格式无效')
|
||||
|
||||
# 录像设置验证
|
||||
if 'record_video' in config:
|
||||
if not isinstance(config['record_video'], bool):
|
||||
errors.append('录像设置应为布尔值')
|
||||
|
||||
return {
|
||||
'valid': len(errors) == 0,
|
||||
'errors': errors,
|
||||
'config': config
|
||||
}
|
||||
|
||||
class SecurityUtils:
|
||||
"""安全工具"""
|
||||
|
||||
@staticmethod
|
||||
def generate_session_id() -> str:
|
||||
"""生成会话ID"""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
@staticmethod
|
||||
def hash_password(password: str) -> str:
|
||||
"""密码哈希"""
|
||||
salt = secrets.token_hex(16)
|
||||
password_hash = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000)
|
||||
return f"{salt}:{password_hash.hex()}"
|
||||
|
||||
@staticmethod
|
||||
def verify_password(password: str, hashed: str) -> bool:
|
||||
"""验证密码"""
|
||||
try:
|
||||
salt, password_hash = hashed.split(':')
|
||||
return hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000).hex() == password_hash
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def sanitize_filename(filename: str) -> str:
|
||||
"""清理文件名"""
|
||||
import re
|
||||
# 移除或替换不安全的字符
|
||||
filename = re.sub(r'[<>:"/\\|?*]', '_', filename)
|
||||
# 限制长度
|
||||
if len(filename) > 255:
|
||||
name, ext = os.path.splitext(filename)
|
||||
filename = name[:255-len(ext)] + ext
|
||||
return filename
|
||||
|
||||
class FileUtils:
|
||||
"""文件工具"""
|
||||
|
||||
@staticmethod
|
||||
def ensure_directory(path: Union[str, Path]):
|
||||
"""确保目录存在"""
|
||||
Path(path).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@staticmethod
|
||||
def get_file_size(path: Union[str, Path]) -> int:
|
||||
"""获取文件大小"""
|
||||
try:
|
||||
return Path(path).stat().st_size
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def clean_old_files(directory: Union[str, Path], max_age_days: int = 30):
|
||||
"""清理旧文件"""
|
||||
try:
|
||||
directory = Path(directory)
|
||||
if not directory.exists():
|
||||
return
|
||||
|
||||
cutoff_time = datetime.now() - timedelta(days=max_age_days)
|
||||
|
||||
for file_path in directory.iterdir():
|
||||
if file_path.is_file():
|
||||
file_time = datetime.fromtimestamp(file_path.stat().st_mtime)
|
||||
if file_time < cutoff_time:
|
||||
file_path.unlink()
|
||||
logger.info(f'已删除旧文件: {file_path}')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'清理旧文件失败: {e}')
|
||||
|
||||
@staticmethod
|
||||
def backup_file(source: Union[str, Path], backup_dir: Union[str, Path] = None) -> Optional[Path]:
|
||||
"""备份文件"""
|
||||
try:
|
||||
source = Path(source)
|
||||
if not source.exists():
|
||||
return None
|
||||
|
||||
if backup_dir is None:
|
||||
backup_dir = source.parent / 'backups'
|
||||
else:
|
||||
backup_dir = Path(backup_dir)
|
||||
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
backup_name = f"{source.stem}_{timestamp}{source.suffix}"
|
||||
backup_path = backup_dir / backup_name
|
||||
|
||||
import shutil
|
||||
shutil.copy2(source, backup_path)
|
||||
|
||||
logger.info(f'文件已备份: {source} -> {backup_path}')
|
||||
return backup_path
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'文件备份失败: {e}')
|
||||
return None
|
||||
|
||||
class PerformanceMonitor:
|
||||
"""性能监控器"""
|
||||
|
||||
def __init__(self):
|
||||
self.metrics = {}
|
||||
|
||||
def start_timer(self, name: str):
|
||||
"""开始计时"""
|
||||
self.metrics[name] = {'start_time': time.time()}
|
||||
|
||||
def end_timer(self, name: str) -> float:
|
||||
"""结束计时"""
|
||||
if name in self.metrics and 'start_time' in self.metrics[name]:
|
||||
duration = time.time() - self.metrics[name]['start_time']
|
||||
self.metrics[name]['duration'] = duration
|
||||
return duration
|
||||
return 0.0
|
||||
|
||||
def get_metrics(self) -> Dict[str, Any]:
|
||||
"""获取性能指标"""
|
||||
return self.metrics.copy()
|
||||
|
||||
def reset_metrics(self):
|
||||
"""重置指标"""
|
||||
self.metrics.clear()
|
||||
|
||||
def timing_decorator(func):
|
||||
"""计时装饰器"""
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
start_time = time.time()
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
duration = time.time() - start_time
|
||||
logger.debug(f'{func.__name__} 执行时间: {duration:.3f}秒')
|
||||
return result
|
||||
except Exception as e:
|
||||
duration = time.time() - start_time
|
||||
logger.error(f'{func.__name__} 执行失败 (耗时: {duration:.3f}秒): {e}')
|
||||
raise
|
||||
return wrapper
|
||||
|
||||
def retry_decorator(max_retries: int = 3, delay: float = 1.0):
|
||||
"""重试装饰器"""
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
last_exception = e
|
||||
if attempt < max_retries:
|
||||
logger.warning(f'{func.__name__} 第{attempt + 1}次尝试失败: {e},{delay}秒后重试')
|
||||
time.sleep(delay)
|
||||
else:
|
||||
logger.error(f'{func.__name__} 所有重试均失败')
|
||||
|
||||
raise last_exception
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
class ResponseFormatter:
|
||||
"""响应格式化器"""
|
||||
|
||||
@staticmethod
|
||||
def success(data: Any = None, message: str = 'Success') -> Dict[str, Any]:
|
||||
"""成功响应"""
|
||||
response = {
|
||||
'success': True,
|
||||
'message': message,
|
||||
'timestamp': datetime.now().isoformat()
|
||||
}
|
||||
if data is not None:
|
||||
response['data'] = data
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def error(message: str, error_code: str = None, details: Any = None) -> Dict[str, Any]:
|
||||
"""错误响应"""
|
||||
response = {
|
||||
'success': False,
|
||||
'message': message,
|
||||
'timestamp': datetime.now().isoformat()
|
||||
}
|
||||
if error_code:
|
||||
response['error_code'] = error_code
|
||||
if details:
|
||||
response['details'] = details
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def paginated(data: List[Any], page: int, page_size: int, total: int) -> Dict[str, Any]:
|
||||
"""分页响应"""
|
||||
return {
|
||||
'success': True,
|
||||
'data': data,
|
||||
'pagination': {
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
'total': total,
|
||||
'total_pages': (total + page_size - 1) // page_size
|
||||
},
|
||||
'timestamp': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
# 全局配置实例
|
||||
config = Config()
|
||||
|
||||
# 性能监控实例
|
||||
performance_monitor = PerformanceMonitor()
|
||||
41
config.ini
Normal file
41
config.ini
Normal file
@ -0,0 +1,41 @@
|
||||
[APP]
|
||||
name = Body Balance Evaluation System
|
||||
version = 1.0.0
|
||||
debug = false
|
||||
log_level = INFO
|
||||
|
||||
[SERVER]
|
||||
host = 127.0.0.1
|
||||
port = 5000
|
||||
cors_origins = *
|
||||
|
||||
[DATABASE]
|
||||
path = data/balance_system.db
|
||||
backup_interval = 24
|
||||
max_backups = 7
|
||||
|
||||
[DEVICES]
|
||||
camera_index = 0
|
||||
camera_width = 640
|
||||
camera_height = 480
|
||||
camera_fps = 30
|
||||
imu_port = COM3
|
||||
pressure_port = COM4
|
||||
|
||||
[DETECTION]
|
||||
default_duration = 60
|
||||
sampling_rate = 30
|
||||
balance_threshold = 0.2
|
||||
posture_threshold = 5.0
|
||||
|
||||
[DATA_PROCESSING]
|
||||
filter_window = 5
|
||||
outlier_threshold = 2.0
|
||||
chart_dpi = 300
|
||||
export_format = csv
|
||||
|
||||
[SECURITY]
|
||||
secret_key = 79fcc4983d478c2ee672f3305d5e12c7c84fd1b58a18acb650e9f8125bfa805f
|
||||
session_timeout = 3600
|
||||
max_login_attempts = 5
|
||||
|
||||
278
config.json
Normal file
278
config.json
Normal file
@ -0,0 +1,278 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "身体平衡评估系统",
|
||||
"version": "1.0.0",
|
||||
"description": "基于多传感器融合技术的专业平衡能力评估与分析系统",
|
||||
"author": "身体平衡评估系统开发团队",
|
||||
"language": "zh-CN",
|
||||
"theme": "light",
|
||||
"auto_start": false,
|
||||
"auto_update": true,
|
||||
"window": {
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
"min_width": 1000,
|
||||
"min_height": 600,
|
||||
"resizable": true,
|
||||
"center": true,
|
||||
"show": true,
|
||||
"frame": true,
|
||||
"transparent": false,
|
||||
"always_on_top": false
|
||||
}
|
||||
},
|
||||
"server": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 5000,
|
||||
"debug": false,
|
||||
"cors": {
|
||||
"enabled": true,
|
||||
"origins": ["http://localhost:5173", "http://127.0.0.1:5173"]
|
||||
},
|
||||
"ssl": {
|
||||
"enabled": false,
|
||||
"cert_file": "",
|
||||
"key_file": ""
|
||||
},
|
||||
"rate_limit": {
|
||||
"enabled": true,
|
||||
"requests_per_minute": 100
|
||||
}
|
||||
},
|
||||
"database": {
|
||||
"type": "sqlite",
|
||||
"path": "data/database.db",
|
||||
"backup": {
|
||||
"enabled": true,
|
||||
"interval_hours": 24,
|
||||
"max_backups": 7
|
||||
},
|
||||
"connection": {
|
||||
"timeout": 30,
|
||||
"pool_size": 10
|
||||
}
|
||||
},
|
||||
"devices": {
|
||||
"camera": {
|
||||
"enabled": true,
|
||||
"device_id": 0,
|
||||
"resolution": {
|
||||
"width": 1280,
|
||||
"height": 720
|
||||
},
|
||||
"fps": 30,
|
||||
"format": "MJPG",
|
||||
"auto_exposure": true,
|
||||
"brightness": 0,
|
||||
"contrast": 0,
|
||||
"saturation": 0,
|
||||
"calibration": {
|
||||
"enabled": false,
|
||||
"matrix": null,
|
||||
"distortion": null
|
||||
}
|
||||
},
|
||||
"imu": {
|
||||
"enabled": true,
|
||||
"port": "COM3",
|
||||
"baudrate": 115200,
|
||||
"timeout": 1.0,
|
||||
"sample_rate": 100,
|
||||
"range": {
|
||||
"accelerometer": 16,
|
||||
"gyroscope": 2000,
|
||||
"magnetometer": 4800
|
||||
},
|
||||
"calibration": {
|
||||
"enabled": false,
|
||||
"offset": {
|
||||
"accel": [0, 0, 0],
|
||||
"gyro": [0, 0, 0],
|
||||
"mag": [0, 0, 0]
|
||||
},
|
||||
"scale": {
|
||||
"accel": [1, 1, 1],
|
||||
"gyro": [1, 1, 1],
|
||||
"mag": [1, 1, 1]
|
||||
}
|
||||
}
|
||||
},
|
||||
"pressure": {
|
||||
"enabled": true,
|
||||
"port": "COM4",
|
||||
"baudrate": 9600,
|
||||
"timeout": 1.0,
|
||||
"sample_rate": 50,
|
||||
"sensors": 4,
|
||||
"range": {
|
||||
"min": 0,
|
||||
"max": 1000
|
||||
},
|
||||
"calibration": {
|
||||
"enabled": false,
|
||||
"zero_offset": [0, 0, 0, 0],
|
||||
"scale_factor": [1, 1, 1, 1]
|
||||
}
|
||||
}
|
||||
},
|
||||
"detection": {
|
||||
"default_duration": 60,
|
||||
"min_duration": 10,
|
||||
"max_duration": 300,
|
||||
"sample_rate": 30,
|
||||
"recording": {
|
||||
"enabled": true,
|
||||
"format": "mp4",
|
||||
"quality": "medium",
|
||||
"fps": 30
|
||||
},
|
||||
"analysis": {
|
||||
"real_time": true,
|
||||
"pose_detection": {
|
||||
"enabled": true,
|
||||
"model": "mediapipe",
|
||||
"confidence": 0.5,
|
||||
"tracking": true
|
||||
},
|
||||
"balance_metrics": {
|
||||
"cop_analysis": true,
|
||||
"sway_analysis": true,
|
||||
"stability_index": true,
|
||||
"frequency_analysis": true
|
||||
},
|
||||
"filters": {
|
||||
"low_pass": {
|
||||
"enabled": true,
|
||||
"cutoff": 10.0
|
||||
},
|
||||
"median": {
|
||||
"enabled": true,
|
||||
"window_size": 5
|
||||
}
|
||||
}
|
||||
},
|
||||
"alerts": {
|
||||
"enabled": true,
|
||||
"thresholds": {
|
||||
"excessive_sway": 50.0,
|
||||
"instability": 0.8,
|
||||
"device_disconnect": 5.0
|
||||
}
|
||||
}
|
||||
},
|
||||
"data": {
|
||||
"storage": {
|
||||
"base_path": "data",
|
||||
"patients_path": "data/patients",
|
||||
"sessions_path": "data/sessions",
|
||||
"exports_path": "data/exports",
|
||||
"backups_path": "data/backups",
|
||||
"temp_path": "temp",
|
||||
"logs_path": "logs"
|
||||
},
|
||||
"cleanup": {
|
||||
"enabled": true,
|
||||
"temp_files_days": 7,
|
||||
"log_files_days": 30,
|
||||
"session_files_days": 365
|
||||
},
|
||||
"compression": {
|
||||
"enabled": true,
|
||||
"algorithm": "gzip",
|
||||
"level": 6
|
||||
},
|
||||
"export": {
|
||||
"formats": ["csv", "json", "pdf"],
|
||||
"include_raw_data": true,
|
||||
"include_analysis": true,
|
||||
"include_charts": true
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"level": "INFO",
|
||||
"format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
"file": {
|
||||
"enabled": true,
|
||||
"path": "logs/app.log",
|
||||
"max_size": "10MB",
|
||||
"backup_count": 5,
|
||||
"rotation": "daily"
|
||||
},
|
||||
"console": {
|
||||
"enabled": true,
|
||||
"level": "INFO"
|
||||
},
|
||||
"modules": {
|
||||
"flask": "WARNING",
|
||||
"werkzeug": "WARNING",
|
||||
"urllib3": "WARNING",
|
||||
"matplotlib": "WARNING"
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"session": {
|
||||
"timeout_minutes": 60,
|
||||
"secret_key": "your-secret-key-here",
|
||||
"secure_cookies": false
|
||||
},
|
||||
"api": {
|
||||
"rate_limiting": true,
|
||||
"request_timeout": 30,
|
||||
"max_request_size": "100MB"
|
||||
},
|
||||
"data": {
|
||||
"encryption": false,
|
||||
"backup_encryption": false,
|
||||
"anonymization": {
|
||||
"enabled": false,
|
||||
"fields": ["name", "phone", "email"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"ui": {
|
||||
"language": "zh-CN",
|
||||
"theme": "light",
|
||||
"animations": true,
|
||||
"sound_effects": true,
|
||||
"notifications": {
|
||||
"enabled": true,
|
||||
"position": "top-right",
|
||||
"duration": 5000
|
||||
},
|
||||
"charts": {
|
||||
"default_type": "line",
|
||||
"colors": {
|
||||
"primary": "#409EFF",
|
||||
"success": "#67C23A",
|
||||
"warning": "#E6A23C",
|
||||
"danger": "#F56C6C",
|
||||
"info": "#909399"
|
||||
},
|
||||
"animation_duration": 1000
|
||||
},
|
||||
"table": {
|
||||
"page_size": 20,
|
||||
"show_pagination": true,
|
||||
"sortable": true,
|
||||
"filterable": true
|
||||
}
|
||||
},
|
||||
"performance": {
|
||||
"monitoring": {
|
||||
"enabled": true,
|
||||
"interval_seconds": 60,
|
||||
"metrics": ["cpu", "memory", "disk", "network"]
|
||||
},
|
||||
"optimization": {
|
||||
"image_compression": true,
|
||||
"data_caching": true,
|
||||
"lazy_loading": true,
|
||||
"batch_processing": true
|
||||
},
|
||||
"limits": {
|
||||
"max_concurrent_sessions": 5,
|
||||
"max_file_size_mb": 100,
|
||||
"max_session_duration_minutes": 300
|
||||
}
|
||||
}
|
||||
}
|
||||
99
document/平衡体态检测系统_UI与交互功能设计说明.md
Normal file
99
document/平衡体态检测系统_UI与交互功能设计说明.md
Normal file
@ -0,0 +1,99 @@
|
||||
# 平衡体态检测系统 UI 与交互功能设计说明
|
||||
|
||||
## 1. 总体设计理念
|
||||
|
||||
- **以用户为中心**:以康复医生和技术操作员为核心用户,界面简洁直观。
|
||||
- **数据驱动展示**:所有核心模块数据实时可视化,交互响应快速。
|
||||
- **操作流程明晰**:每一步操作清晰可控,避免误操作。
|
||||
|
||||
---
|
||||
|
||||
## 2. 页面结构与交互设计
|
||||
|
||||
### 2.1 登录与注册页面
|
||||
|
||||
#### 功能点
|
||||
- 用户名密码登录,明文匹配;
|
||||
- 注册要求:手机号、密码二次确认;
|
||||
- 明文密码显示与记住密码;
|
||||
- 密码找回:显示明文密码(需优化为短信验证);
|
||||
- 修改密码支持原密码校验更新。
|
||||
|
||||
#### 交互设计
|
||||
- 所有操作均本地数据库存储;
|
||||
- 弹窗提示错误、成功状态;
|
||||
- 安全建议:建议后续版本启用密码加密存储。
|
||||
|
||||
---
|
||||
|
||||
### 2.2 实时检测模块
|
||||
|
||||
#### 起始页
|
||||
- 左侧:患者列表(支持搜索与滚动加载);
|
||||
- 右上:基础信息展示与编辑;
|
||||
- 右下:新建档案 / 查看档案 / 开始检测按钮。
|
||||
|
||||
#### 新建档案页
|
||||
- 信息采集:*为必填项,实时校验格式;
|
||||
- 按钮:
|
||||
- “退出”:弹窗确认;
|
||||
- “保存”:保存数据;
|
||||
- “保存并开始检测”:保存+跳转检测页。
|
||||
|
||||
#### 患者档案页
|
||||
- 展示所有检测记录,支持:
|
||||
- 测量数据图文并茂;
|
||||
- 操作过程视频+录屏回放;
|
||||
- 查看足底垫片配置(左右足 + 多个垫片,记录颜色/编号);
|
||||
- 截图列表(放大预览、删除、查看截图数据、导出/打印)。
|
||||
|
||||
---
|
||||
|
||||
### 2.3 检测页设计
|
||||
|
||||
#### 实时检测主界面
|
||||
|
||||
- 显示患者信息、时间、医生名称;
|
||||
- 数据区:
|
||||
- 身体姿态:深度图动态更新;
|
||||
- 头部IMU:实时Pitch/Yaw/Roll值 + 最大值记录 + 清零 + 抗干扰;
|
||||
- 足底压力:压力云图 + 数据占比展示;
|
||||
- 视频区:录制+时间戳+最大化播放。
|
||||
|
||||
#### 操作按钮区
|
||||
|
||||
- 初始状态:显示“开始”;
|
||||
- 开始后:显示“截图”“结束”;
|
||||
- 截图:保存界面+对应数据+截图图片;
|
||||
- 结束:终止录制,保存检测记录;
|
||||
- 结束后:显示“回放”;
|
||||
- 支持视频同步播放、暂停、最大化、进度条拖动。
|
||||
|
||||
#### 分屏模式
|
||||
|
||||
- 提供“分屏”按钮;
|
||||
- 支持全显(3模块)/单模块切换(滑动或左右切换按钮)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 附加功能说明
|
||||
|
||||
### 截图管理
|
||||
- 入口:截图按钮 + 截图列表;
|
||||
- 功能:图片放大预览、删除、查看数据详情、导出/打印。
|
||||
|
||||
### 数据管理
|
||||
- 所有数据按患者、检测时间分类;
|
||||
- 支持历史对比分析(V2.0 实现);
|
||||
- 自动生成PDF报告(带医生备注)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 优化建议(基于V1.2)
|
||||
|
||||
- 密码不应明文存储,应采用hash加密;
|
||||
- 注册/找回密码增加验证码机制;
|
||||
- 支持多用户权限(医生、操作员、管理员);
|
||||
- 后续建议支持远程数据云同步;
|
||||
- 视频与数据录制时钟同步机制需保证精准性;
|
||||
- 多检测记录对比模块应预留逻辑框架。
|
||||
331
document/系统UI及交互设计说明.md
Normal file
331
document/系统UI及交互设计说明.md
Normal file
@ -0,0 +1,331 @@
|
||||
# 平衡体态检测系统 UI 与交互设计说明
|
||||
|
||||
## 总体设计理念
|
||||
|
||||
- **以用户为中心**:以康复医生和技术操作员为核心用户,界面简洁直观
|
||||
- **数据驱动展示**:所有核心模块数据实时可视化,交互响应快速
|
||||
- **操作流程明晰**:每一步操作清晰可控,避免误操作
|
||||
|
||||
---
|
||||
|
||||
## 1. 登录与注册页面
|
||||
|
||||

|
||||
|
||||
### 页面结构
|
||||
- 顶部:系统Logo与名称
|
||||
- 中部:账号输入框、密码输入框、登录按钮
|
||||
- 底部:忘记密码、注册入口
|
||||
|
||||
### 主要功能
|
||||
- **登录功能**:用户名密码登录,明文匹配
|
||||
- **注册功能**:手机号、密码二次确认
|
||||
- **密码管理**:明文密码显示与记住密码
|
||||
- **密码找回**:显示明文密码(建议后续版本优化为短信验证)
|
||||
- **修改密码**:支持原密码校验更新
|
||||
|
||||
### 交互说明
|
||||
- 输入框获得焦点高亮
|
||||
- 登录按钮点击后显示加载状态
|
||||
- 错误提示信息弹出或红色提示
|
||||
- 所有操作均本地数据库存储
|
||||
- 弹窗提示错误、成功状态
|
||||
|
||||
### 组件建议
|
||||
- Input输入框
|
||||
- Button按钮
|
||||
- Message提示组件
|
||||
- Modal弹窗
|
||||
|
||||
### 安全建议
|
||||
- 建议后续版本启用密码加密存储
|
||||
- 增加验证码机制
|
||||
- 支持多用户权限(医生、操作员、管理员)
|
||||
|
||||
---
|
||||
|
||||
## 2. 实时检测模块
|
||||
|
||||
### 2.1 起始页
|
||||
|
||||

|
||||
|
||||
#### 页面结构
|
||||
- **左侧**:功能快速入口("检测、人员、系统")
|
||||
- **右侧顶部**:用户信息栏、系统Logo
|
||||
- **右侧中部**:患者列表、选中患者基础信息展示
|
||||
- **操作按钮**:粉色鲜亮按钮("查看档案、检测、新患者建档")
|
||||
|
||||
#### 主要功能
|
||||
- 患者列表管理(支持搜索与滚动加载)
|
||||
- 基础信息展示与编辑
|
||||
- 快速进入各功能模块
|
||||
|
||||
#### 交互说明
|
||||
- 患者列表可滚动显示,可选中,可输入用户名查询
|
||||
- 选中患者可显示基础信息,可点击编辑图标进行信息编辑
|
||||
- 按钮高亮显示,点击跳转对应功能页面
|
||||
|
||||
#### 组件建议
|
||||
- List列表组件
|
||||
- Search搜索框
|
||||
- Card卡片
|
||||
- Button按钮
|
||||
- Avatar头像
|
||||
|
||||
### 2.2 新建档案页
|
||||
|
||||
#### 页面结构
|
||||
- 表单区域:患者基础信息录入
|
||||
- 操作按钮区:退出、保存、保存并开始检测
|
||||
|
||||
#### 主要功能
|
||||
- **信息采集**:*为必填项,实时校验格式
|
||||
- **数据保存**:本地数据库存储
|
||||
- **快速检测**:保存后直接跳转检测页
|
||||
|
||||
#### 交互说明
|
||||
- **"退出"按钮**:弹窗确认是否保存
|
||||
- **"保存"按钮**:保存数据并返回列表
|
||||
- **"保存并开始检测"按钮**:保存数据并跳转检测页
|
||||
- 必填项校验,错误提示
|
||||
|
||||
#### 组件建议
|
||||
- Form表单
|
||||
- Input输入框
|
||||
- Select下拉选择
|
||||
- DatePicker日期选择
|
||||
- Button按钮
|
||||
- Validation校验
|
||||
|
||||
### 2.3 患者档案页
|
||||
|
||||
#### 页面结构
|
||||
- 患者基础信息展示区
|
||||
- 检测记录列表
|
||||
- 详细数据查看区
|
||||
|
||||
#### 主要功能
|
||||
- **检测记录展示**:所有历史检测记录
|
||||
- **数据可视化**:测量数据图文并茂展示
|
||||
- **视频回放**:操作过程视频+录屏回放
|
||||
- **足底垫片配置**:左右足+多个垫片,记录颜色/编号
|
||||
- **截图管理**:截图列表(放大预览、删除、查看截图数据、导出/打印)
|
||||
|
||||
#### 交互说明
|
||||
- 检测记录按时间排序,支持筛选
|
||||
- 点击记录查看详细数据
|
||||
- 视频支持播放控制
|
||||
- 截图支持放大预览和批量操作
|
||||
|
||||
#### 组件建议
|
||||
- Table表格
|
||||
- Timeline时间轴
|
||||
- Video视频播放器
|
||||
- Image图片预览
|
||||
- Chart图表
|
||||
- Export导出功能
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 3. 检测页设计
|
||||
|
||||
### 3.1 实时检测主界面
|
||||
|
||||

|
||||
|
||||
#### 页面结构
|
||||
- **左侧**:快捷操作入口
|
||||
- **顶部信息栏**:患者信息、检测时间、医生名称
|
||||
- **数据展示区**:
|
||||
- 身体姿态:深度图动态更新
|
||||
- 头部IMU:实时Pitch/Yaw/Roll值+最大值记录+清零+抗干扰
|
||||
- 足底压力:压力云图+数据占比展示
|
||||
- 视频区:录制+时间戳+最大化播放
|
||||
|
||||
#### 主要功能
|
||||
- **实时数据监控**:三大模块数据同步显示
|
||||
- **视频录制**:同步录制检测过程
|
||||
- **数据记录**:实时保存检测数据
|
||||
- **异常监测**:数据异常自动提示
|
||||
|
||||
#### 交互说明
|
||||
- 数据实时刷新,响应速度<100ms
|
||||
- 视频支持最大化播放
|
||||
- 异常情况弹窗提示
|
||||
- 支持数据清零重置
|
||||
|
||||
#### 组件建议
|
||||
- Canvas画布(深度图、压力云图)
|
||||
- Video视频组件
|
||||
- Progress进度条
|
||||
- Modal弹窗
|
||||
- Chart实时图表
|
||||
|
||||
### 3.2 操作按钮区
|
||||
|
||||
#### 按钮状态管理
|
||||
- **初始状态**:显示"开始"按钮
|
||||
- **检测中状态**:显示"截图""结束"按钮
|
||||
- **结束后状态**:显示"回放"按钮
|
||||
|
||||
#### 功能说明
|
||||
- **开始按钮**:启动检测,开始数据采集和视频录制
|
||||
- **截图按钮**:保存当前界面+对应数据+截图图片
|
||||
- **结束按钮**:终止录制,保存检测记录
|
||||
- **回放按钮**:支持视频同步播放、暂停、最大化、进度条拖动
|
||||
|
||||
#### 交互说明
|
||||
- 按钮状态根据检测流程自动切换
|
||||
- 截图操作即时响应,保存成功提示
|
||||
- 结束检测需二次确认
|
||||
- 回放支持全屏模式
|
||||
|
||||
### 3.3 分屏模式
|
||||
|
||||
#### 功能设计
|
||||
- 提供"分屏"切换按钮
|
||||
- 支持全显(3模块)/单模块切换
|
||||
- 单模块模式支持滑动或左右切换按钮
|
||||
|
||||
#### 交互说明
|
||||
- 分屏切换动画流畅
|
||||
- 单模块模式数据更大更清晰
|
||||
- 支持快速切换不同模块
|
||||
|
||||
#### 组件建议
|
||||
- Tabs标签页
|
||||
- Swiper滑动组件
|
||||
- Button切换按钮
|
||||
|
||||
---
|
||||
|
||||
## 4. 检测中的录屏界面
|
||||
|
||||

|
||||
|
||||
### 页面结构
|
||||
- **左侧**:快捷操作入口
|
||||
- **右侧顶部**:录制状态、时长显示、结束、截图按钮
|
||||
- **右侧中部**:检测结果实时展示区
|
||||
- **状态栏**:录制时长、数据同步状态
|
||||
|
||||
### 主要功能
|
||||
- **实时录制**:同步录制检测过程和数据
|
||||
- **时长显示**:显示录制时长与状态
|
||||
- **截图功能**:实时截图保存
|
||||
- **数据同步**:视频与数据时钟同步
|
||||
|
||||
### 交互说明
|
||||
- 录制中按钮高亮显示
|
||||
- 录制时长实时更新
|
||||
- 截图操作不影响录制
|
||||
- 录制结束弹窗确认保存
|
||||
- 支持录制过程中查看实时数据
|
||||
|
||||
### 组件建议
|
||||
- Timer计时器
|
||||
- Button按钮
|
||||
- Notification通知
|
||||
- StatusBar状态栏
|
||||
- ProgressBar进度条
|
||||
|
||||
---
|
||||
|
||||
## 5. 附加功能模块
|
||||
|
||||
### 5.1 截图管理
|
||||
|
||||
#### 功能入口
|
||||
- 检测页面截图按钮
|
||||
- 患者档案截图列表
|
||||
|
||||
#### 主要功能
|
||||
- **图片管理**:截图列表展示
|
||||
- **预览功能**:图片放大预览
|
||||
- **数据关联**:查看截图对应的检测数据
|
||||
- **导出打印**:支持单张或批量导出/打印
|
||||
- **删除管理**:支持单张或批量删除
|
||||
|
||||
#### 交互说明
|
||||
- 缩略图网格展示
|
||||
- 点击放大预览
|
||||
- 支持键盘快捷键操作
|
||||
- 批量操作需确认提示
|
||||
|
||||
### 5.2 数据管理
|
||||
|
||||
#### 数据分类
|
||||
- 按患者分类管理
|
||||
- 按检测时间排序
|
||||
- 支持标签分类
|
||||
|
||||
#### 功能特性
|
||||
- **历史对比**:多次检测数据对比分析(V2.0实现)
|
||||
- **报告生成**:自动生成PDF报告(带医生备注)
|
||||
- **数据导出**:支持多种格式导出
|
||||
- **备份恢复**:数据备份与恢复功能
|
||||
|
||||
#### 交互说明
|
||||
- 数据列表支持多维度筛选
|
||||
- 对比分析支持图表展示
|
||||
- 报告生成支持模板自定义
|
||||
- 导出进度实时显示
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 6. 设计规范与建议
|
||||
|
||||
### 6.1 视觉设计
|
||||
- **配色方案**:统一黑色科技风格,突出专业感
|
||||
- **布局原则**:保持页面简洁,突出核心功能区域
|
||||
- **字体规范**:统一字体族,层次分明
|
||||
- **图标设计**:简洁明了,符合医疗设备使用习惯
|
||||
|
||||
### 6.2 交互设计
|
||||
- **响应速度**:界面响应时间<100ms
|
||||
- **动画效果**:简洁流畅,提升用户体验
|
||||
- **反馈机制**:操作即时反馈,状态清晰可见
|
||||
- **容错设计**:重要操作需二次确认
|
||||
|
||||
### 6.3 技术建议
|
||||
- **数据同步**:视频与数据录制时钟同步机制需保证精准性
|
||||
- **性能优化**:大数据量处理优化,确保实时性
|
||||
- **扩展性**:预留多检测记录对比模块逻辑框架
|
||||
- **安全性**:密码加密存储,数据传输安全
|
||||
|
||||
### 6.4 优化建议(基于V1.2)
|
||||
- 密码不应明文存储,应采用hash加密
|
||||
- 注册/找回密码增加验证码机制
|
||||
- 支持多用户权限(医生、操作员、管理员)
|
||||
- 后续建议支持远程数据云同步
|
||||
- 视频与数据录制时钟同步机制需保证精准性
|
||||
- 多检测记录对比模块应预留逻辑框架
|
||||
|
||||
---
|
||||
|
||||
## 7. 开发实施建议
|
||||
|
||||
### 7.1 开发优先级
|
||||
1. **核心功能**:登录、患者管理、实时检测
|
||||
2. **数据管理**:截图、录制、数据保存
|
||||
3. **扩展功能**:报告生成、数据对比
|
||||
4. **优化功能**:性能优化、安全加固
|
||||
|
||||
### 7.2 技术栈建议
|
||||
- **前端框架**:React/Vue.js + Electron
|
||||
- **UI组件库**:Ant Design / Element Plus
|
||||
- **图表库**:ECharts / D3.js
|
||||
- **视频处理**:WebRTC / FFmpeg
|
||||
- **数据库**:SQLite(本地)+ 云端备份
|
||||
|
||||
### 7.3 测试建议
|
||||
- **功能测试**:覆盖所有用户操作流程
|
||||
- **性能测试**:实时数据处理性能验证
|
||||
- **兼容性测试**:多设备、多分辨率适配
|
||||
- **用户体验测试**:医生和操作员实际使用反馈
|
||||
|
||||
如需进一步细化任何模块的详细设计,请提供具体需求。
|
||||
BIN
document/系统界面原型.pdf
Normal file
BIN
document/系统界面原型.pdf
Normal file
Binary file not shown.
BIN
document/系统界面原型.pptx
Normal file
BIN
document/系统界面原型.pptx
Normal file
Binary file not shown.
150
document/软件开发技术方案.md
Normal file
150
document/软件开发技术方案.md
Normal file
@ -0,0 +1,150 @@
|
||||
|
||||
# 平衡体态检测系统 - 软件开发技术方案(Electron + Python,Windows单机版)
|
||||
|
||||
## 一、项目目标
|
||||
|
||||
构建一套适用于康复医学、运动健康等场景的 **单机本地运行型体态检测系统**,具备图形界面、设备数据采集、实时可视化、视频记录、数据管理与导出等能力。用户可在 Windows 系统中一键安装、双击运行,无需联网与额外配置。
|
||||
|
||||
---
|
||||
|
||||
## 二、系统架构与技术栈
|
||||
|
||||
### 2.1 总体架构图
|
||||
|
||||
```
|
||||
+---------------------------+
|
||||
| Electron 桌面客户端 |
|
||||
| +----------------------+ |
|
||||
| | Vue3/React 前端界面 | |
|
||||
| +----------------------+ |
|
||||
| ⇅ API调用 |
|
||||
| +----------------------+ |
|
||||
| | Python 后端服务(Flask)|
|
||||
| +----------------------+ |
|
||||
+---------------------------+
|
||||
⇅ 硬件通信
|
||||
+---------------------------+
|
||||
| 深度相机 / IMU / 足底压力板 |
|
||||
+---------------------------+
|
||||
```
|
||||
|
||||
### 2.2 技术选型
|
||||
|
||||
| 层级 | 技术选型 | 说明 |
|
||||
|------|----------|------|
|
||||
| 前端界面 | Electron + Vue3 / React | 构建跨平台 GUI 桌面应用 |
|
||||
| 后端服务 | Python Flask / FastAPI | 提供设备控制、数据处理、本地API |
|
||||
| 本地数据库 | SQLite | 嵌入式数据库,无需独立部署 |
|
||||
| 视频处理 | FFmpeg | 实现屏幕与摄像视频录制 |
|
||||
| 打包工具 | electron-builder | 生成 Windows `.exe` 安装包 |
|
||||
| Python打包 | PyInstaller | 将 Python 服务打包为 `.exe` 可执行程序 |
|
||||
|
||||
---
|
||||
|
||||
## 三、模块功能与实现
|
||||
|
||||
### 3.1 Electron 主程序
|
||||
|
||||
- 负责显示用户界面(患者档案、检测流程、视频回放)
|
||||
- 在启动时自动调用后端 Python `.exe` 服务
|
||||
- 与本地后端通信(HTTP API)
|
||||
- 控制设备数据展示与交互逻辑
|
||||
|
||||
### 3.2 Python 后端服务
|
||||
|
||||
- 使用 Flask/FastAPI 提供本地接口(127.0.0.1:5000)
|
||||
- 管理设备连接、数据采集、滤波处理、存储与导出
|
||||
- 可扩展实现深度图处理、压力图分析、IMU姿态分析等功能
|
||||
|
||||
### 3.3 视频录制模块
|
||||
|
||||
- 利用 FFmpeg 实现屏幕+外部摄像头录制
|
||||
- 支持时间戳叠加、截图保存、同步回放
|
||||
- 可打包进 Electron 安装目录中统一调用
|
||||
|
||||
---
|
||||
|
||||
## 四、部署方式与启动流程
|
||||
|
||||
### 4.1 打包流程
|
||||
|
||||
1. 将 Python 服务使用 PyInstaller 打包:
|
||||
```bash
|
||||
pyinstaller -F -n app backend/app.py
|
||||
```
|
||||
|
||||
2. Electron 主进程中启动 Python 服务:
|
||||
```js
|
||||
const { spawn } = require("child_process");
|
||||
const scriptPath = path.join(__dirname, "backend", "app.exe");
|
||||
const pythonProcess = spawn(scriptPath, { detached: true, stdio: 'ignore' });
|
||||
pythonProcess.unref();
|
||||
```
|
||||
|
||||
3. 使用 electron-builder 打包为安装包:
|
||||
```bash
|
||||
npm run build
|
||||
electron-builder --win --x64
|
||||
```
|
||||
|
||||
### 4.2 安装后运行流程(用户视角)
|
||||
|
||||
- 用户双击安装包 `.exe` 安装程序;
|
||||
- 桌面自动生成快捷方式;
|
||||
- 双击运行程序:
|
||||
- 启动 Electron 界面;
|
||||
- 自动后台启动 Python 服务;
|
||||
- 自动检测设备连接;
|
||||
- 进入检测界面,开始数据采集。
|
||||
|
||||
---
|
||||
|
||||
## 五、目录结构(部署后)
|
||||
|
||||
```
|
||||
体态检测系统/
|
||||
├─ main.exe # 主程序(Electron 打包)
|
||||
├─ backend/
|
||||
│ ├─ app.exe # Python 后端打包文件
|
||||
│ └─ model/ # AI模型文件(可选)
|
||||
├─ ffmpeg/ # 视频录制工具
|
||||
├─ resources/ # 前端界面构建产物
|
||||
├─ logs/ # 日志输出目录
|
||||
└─ uninstall.exe # 卸载程序
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、安全性与稳定性设计
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 端口限制 | 后端服务仅监听 127.0.0.1,防止外部访问 |
|
||||
| 自动重启 | Electron 检测后端未启动可尝试自动重启 |
|
||||
| 日志记录 | 所有错误输出写入 logs 目录,便于故障排查 |
|
||||
| 安全存储 | 用户/患者数据保存至 SQLite,本地隔离存储 |
|
||||
| 进程管理 | Python 服务进程随 Electron 一起退出或独立运行 |
|
||||
|
||||
---
|
||||
|
||||
## 七、后续拓展建议
|
||||
|
||||
| 版本 | 升级方向 |
|
||||
|------|----------|
|
||||
| V2.0 | 加入关键点识别、自动评估报告导出、双次记录对比 |
|
||||
| V3.0 | 云端同步、在线远程管理、多终端协同 |
|
||||
| V4.0 | 移动版(Android/iOS)测量与数据同步 |
|
||||
|
||||
---
|
||||
|
||||
## 八、推荐工具与资源
|
||||
|
||||
- Electron 官网:[https://www.electronjs.org/](https://www.electronjs.org/)
|
||||
- electron-builder:[https://www.electron.build/](https://www.electron.build/)
|
||||
- Flask:[https://flask.palletsprojects.com/](https://flask.palletsprojects.com/)
|
||||
- PyInstaller:[https://pyinstaller.org/](https://pyinstaller.org/)
|
||||
- FFmpeg:[https://ffmpeg.org/](https://ffmpeg.org/)
|
||||
|
||||
---
|
||||
|
||||
> 本技术方案支持通过 AI 代码生成平台辅助开发,可作为 ChatGPT Copilot、CodeWhisperer 等平台的 Prompt 基础文件。
|
||||
BIN
document/需求说明书V1.2.pdf
Normal file
BIN
document/需求说明书V1.2.pdf
Normal file
Binary file not shown.
168
frontend/src/main/main.js
Normal file
168
frontend/src/main/main.js
Normal file
@ -0,0 +1,168 @@
|
||||
const { app, BrowserWindow, ipcMain, dialog } = require('electron');
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
const axios = require('axios');
|
||||
const log = require('electron-log');
|
||||
|
||||
let mainWindow;
|
||||
let pythonProcess;
|
||||
const BACKEND_PORT = 5000;
|
||||
const BACKEND_URL = `http://127.0.0.1:${BACKEND_PORT}`;
|
||||
|
||||
// 配置日志
|
||||
log.transports.file.resolvePathFn = () => path.join(app.getPath('userData'), 'logs', 'main.log');
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
preload: path.join(__dirname, 'preload.js')
|
||||
},
|
||||
icon: path.join(__dirname, '../../assets/icon.ico'),
|
||||
show: false
|
||||
});
|
||||
|
||||
// 开发环境加载本地服务器,生产环境加载打包后的文件
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
if (isDev) {
|
||||
mainWindow.loadURL('http://localhost:3000');
|
||||
mainWindow.webContents.openDevTools();
|
||||
} else {
|
||||
mainWindow.loadFile(path.join(__dirname, '../renderer/dist/index.html'));
|
||||
}
|
||||
|
||||
mainWindow.once('ready-to-show', () => {
|
||||
mainWindow.show();
|
||||
startPythonBackend();
|
||||
});
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null;
|
||||
stopPythonBackend();
|
||||
});
|
||||
}
|
||||
|
||||
function startPythonBackend() {
|
||||
try {
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
let scriptPath;
|
||||
|
||||
if (isDev) {
|
||||
// 开发环境直接运行Python脚本
|
||||
scriptPath = path.join(__dirname, '../../backend/app.py');
|
||||
pythonProcess = spawn('python', [scriptPath], {
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
} else {
|
||||
// 生产环境运行打包后的exe
|
||||
scriptPath = path.join(process.resourcesPath, 'backend', 'app.exe');
|
||||
pythonProcess = spawn(scriptPath, {
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
}
|
||||
|
||||
log.info('启动Python后端服务:', scriptPath);
|
||||
|
||||
pythonProcess.stdout.on('data', (data) => {
|
||||
log.info('Python输出:', data.toString());
|
||||
});
|
||||
|
||||
pythonProcess.stderr.on('data', (data) => {
|
||||
log.error('Python错误:', data.toString());
|
||||
});
|
||||
|
||||
pythonProcess.on('close', (code) => {
|
||||
log.info(`Python进程退出,代码: ${code}`);
|
||||
});
|
||||
|
||||
// 等待后端启动
|
||||
setTimeout(() => {
|
||||
checkBackendHealth();
|
||||
}, 3000);
|
||||
|
||||
} catch (error) {
|
||||
log.error('启动Python后端失败:', error);
|
||||
dialog.showErrorBox('启动失败', '无法启动后端服务,请检查安装是否完整');
|
||||
}
|
||||
}
|
||||
|
||||
function stopPythonBackend() {
|
||||
if (pythonProcess) {
|
||||
pythonProcess.kill();
|
||||
pythonProcess = null;
|
||||
log.info('Python后端服务已停止');
|
||||
}
|
||||
}
|
||||
|
||||
async function checkBackendHealth() {
|
||||
try {
|
||||
const response = await axios.get(`${BACKEND_URL}/health`, { timeout: 5000 });
|
||||
if (response.status === 200) {
|
||||
log.info('后端服务健康检查通过');
|
||||
mainWindow.webContents.send('backend-ready', true);
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('后端服务健康检查失败:', error.message);
|
||||
mainWindow.webContents.send('backend-ready', false);
|
||||
|
||||
// 尝试重启后端
|
||||
setTimeout(() => {
|
||||
log.info('尝试重启后端服务');
|
||||
stopPythonBackend();
|
||||
startPythonBackend();
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// IPC 通信处理
|
||||
ipcMain.handle('get-backend-url', () => {
|
||||
return BACKEND_URL;
|
||||
});
|
||||
|
||||
ipcMain.handle('check-backend-health', async () => {
|
||||
try {
|
||||
const response = await axios.get(`${BACKEND_URL}/health`, { timeout: 3000 });
|
||||
return response.status === 200;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('restart-backend', () => {
|
||||
stopPythonBackend();
|
||||
setTimeout(() => {
|
||||
startPythonBackend();
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
// 应用事件处理
|
||||
app.whenReady().then(createWindow);
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
stopPythonBackend();
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
stopPythonBackend();
|
||||
});
|
||||
|
||||
// 处理未捕获的异常
|
||||
process.on('uncaughtException', (error) => {
|
||||
log.error('未捕获的异常:', error);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
log.error('未处理的Promise拒绝:', reason);
|
||||
});
|
||||
32
frontend/src/main/preload.js
Normal file
32
frontend/src/main/preload.js
Normal file
@ -0,0 +1,32 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
// 向渲染进程暴露安全的API
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
// 获取后端服务URL
|
||||
getBackendUrl: () => ipcRenderer.invoke('get-backend-url'),
|
||||
|
||||
// 检查后端服务健康状态
|
||||
checkBackendHealth: () => ipcRenderer.invoke('check-backend-health'),
|
||||
|
||||
// 重启后端服务
|
||||
restartBackend: () => ipcRenderer.invoke('restart-backend'),
|
||||
|
||||
// 监听后端就绪状态
|
||||
onBackendReady: (callback) => {
|
||||
ipcRenderer.on('backend-ready', (event, isReady) => {
|
||||
callback(isReady);
|
||||
});
|
||||
},
|
||||
|
||||
// 移除监听器
|
||||
removeAllListeners: (channel) => {
|
||||
ipcRenderer.removeAllListeners(channel);
|
||||
}
|
||||
});
|
||||
|
||||
// 暴露日志API
|
||||
contextBridge.exposeInMainWorld('logAPI', {
|
||||
info: (message) => console.log('[INFO]', message),
|
||||
error: (message) => console.error('[ERROR]', message),
|
||||
warn: (message) => console.warn('[WARN]', message)
|
||||
});
|
||||
35
frontend/src/renderer/index.html
Normal file
35
frontend/src/renderer/index.html
Normal file
@ -0,0 +1,35 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>平衡体态检测系统</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'Microsoft YaHei', sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
#app {
|
||||
height: 100vh;
|
||||
}
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div class="loading">
|
||||
<div>正在加载平衡体态检测系统...</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
25
frontend/src/renderer/package.json
Normal file
25
frontend/src/renderer/package.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "body-balance-renderer",
|
||||
"version": "1.0.0",
|
||||
"description": "平衡体态检测系统前端界面",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.3.4",
|
||||
"vue-router": "^4.2.4",
|
||||
"pinia": "^2.1.6",
|
||||
"element-plus": "^2.3.9",
|
||||
"@element-plus/icons-vue": "^2.1.0",
|
||||
"axios": "^1.5.0",
|
||||
"echarts": "^5.4.3",
|
||||
"vue-echarts": "^6.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^4.3.4",
|
||||
"vite": "^4.4.9",
|
||||
"sass": "^1.66.1"
|
||||
}
|
||||
}
|
||||
14
frontend/src/renderer/src/App.vue
Normal file
14
frontend/src/renderer/src/App.vue
Normal file
@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#app {
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
22
frontend/src/renderer/src/main.js
Normal file
22
frontend/src/renderer/src/main.js
Normal file
@ -0,0 +1,22 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import ElementPlus from 'element-plus';
|
||||
import 'element-plus/dist/index.css';
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
import './style.css';
|
||||
|
||||
const app = createApp(App);
|
||||
const pinia = createPinia();
|
||||
|
||||
// 注册Element Plus图标
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component);
|
||||
}
|
||||
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
app.use(ElementPlus);
|
||||
|
||||
app.mount('#app');
|
||||
94
frontend/src/renderer/src/router/index.js
Normal file
94
frontend/src/renderer/src/router/index.js
Normal file
@ -0,0 +1,94 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/index'
|
||||
import Login from '../views/Login.vue'
|
||||
import Dashboard from '../views/Dashboard.vue'
|
||||
import PatientCreate from '../views/PatientCreate.vue'
|
||||
import PatientProfile from '../views/PatientProfile.vue'
|
||||
import Detection from '../views/Detection.vue'
|
||||
import Recording from '../views/Recording.vue'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: Login,
|
||||
meta: { title: '登录 - 平衡体态检测系统' }
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
name: 'Dashboard',
|
||||
component: Dashboard,
|
||||
meta: { title: '仪表板 - 平衡体态检测系统', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/patient/create',
|
||||
name: 'PatientCreate',
|
||||
component: PatientCreate,
|
||||
meta: { title: '新建患者档案 - 平衡体态检测系统', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/patient/:id',
|
||||
name: 'PatientProfile',
|
||||
component: PatientProfile,
|
||||
meta: { title: '患者档案 - 平衡体态检测系统', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/detection/:id',
|
||||
name: 'Detection',
|
||||
component: Detection,
|
||||
meta: { title: '姿态检测 - 平衡体态检测系统', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/recording/:id',
|
||||
name: 'Recording',
|
||||
component: Recording,
|
||||
meta: { title: '录屏回放 - 平衡体态检测系统', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/detection/replay/:id',
|
||||
name: 'DetectionReplay',
|
||||
component: Recording,
|
||||
meta: { title: '检测回放 - 平衡体态检测系统', requiresAuth: true }
|
||||
}
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
// 全局前置守卫
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
// 设置页面标题
|
||||
if (to.meta.title) {
|
||||
document.title = to.meta.title
|
||||
}
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 检查是否需要认证
|
||||
if (to.meta.requiresAuth) {
|
||||
if (!authStore.isAuthenticated) {
|
||||
// 未登录,重定向到登录页
|
||||
next({ name: 'Login' })
|
||||
return
|
||||
}
|
||||
|
||||
// 验证token有效性
|
||||
const isValid = await authStore.verifyToken()
|
||||
if (!isValid) {
|
||||
next({ name: 'Login' })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 如果已登录用户访问登录页,重定向到仪表板
|
||||
if (to.name === 'Login' && authStore.isAuthenticated) {
|
||||
next({ name: 'Dashboard' })
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
592
frontend/src/renderer/src/services/api.js
Normal file
592
frontend/src/renderer/src/services/api.js
Normal file
@ -0,0 +1,592 @@
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
// 创建axios实例
|
||||
const api = axios.create({
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
// 请求拦截器
|
||||
api.interceptors.request.use(
|
||||
config => {
|
||||
// 动态设置baseURL
|
||||
if (window.electronAPI) {
|
||||
config.baseURL = window.electronAPI.getBackendUrl()
|
||||
} else {
|
||||
config.baseURL = 'http://127.0.0.1:5000'
|
||||
}
|
||||
|
||||
// 添加时间戳防止缓存
|
||||
if (config.method === 'get') {
|
||||
config.params = {
|
||||
...config.params,
|
||||
_t: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
},
|
||||
error => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
api.interceptors.response.use(
|
||||
response => {
|
||||
return response.data
|
||||
},
|
||||
error => {
|
||||
let message = '请求失败'
|
||||
|
||||
if (error.response) {
|
||||
// 服务器响应错误
|
||||
const { status, data } = error.response
|
||||
switch (status) {
|
||||
case 400:
|
||||
message = data.message || '请求参数错误'
|
||||
break
|
||||
case 401:
|
||||
message = '未授权访问'
|
||||
break
|
||||
case 403:
|
||||
message = '禁止访问'
|
||||
break
|
||||
case 404:
|
||||
message = '请求的资源不存在'
|
||||
break
|
||||
case 500:
|
||||
message = '服务器内部错误'
|
||||
break
|
||||
default:
|
||||
message = data.message || `请求失败 (${status})`
|
||||
}
|
||||
} else if (error.request) {
|
||||
// 网络错误
|
||||
message = '网络连接失败,请检查后端服务是否启动'
|
||||
} else {
|
||||
// 其他错误
|
||||
message = error.message || '未知错误'
|
||||
}
|
||||
|
||||
ElMessage.error(message)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 系统API
|
||||
export const systemAPI = {
|
||||
// 健康检查
|
||||
health() {
|
||||
return api.get('/health')
|
||||
},
|
||||
|
||||
// 获取系统信息
|
||||
getSystemInfo() {
|
||||
return api.get('/api/system/info')
|
||||
},
|
||||
|
||||
// 获取系统状态
|
||||
getSystemStatus() {
|
||||
return api.get('/api/system/status')
|
||||
}
|
||||
}
|
||||
|
||||
// 设备API
|
||||
export const deviceAPI = {
|
||||
// 获取设备状态
|
||||
getDeviceStatus() {
|
||||
return api.get('/api/devices/status')
|
||||
},
|
||||
|
||||
// 刷新设备
|
||||
refreshDevices() {
|
||||
return api.post('/api/devices/refresh')
|
||||
},
|
||||
|
||||
// 校准设备
|
||||
calibrateDevice(deviceType) {
|
||||
return api.post(`/api/devices/${deviceType}/calibrate`)
|
||||
},
|
||||
|
||||
// 测试设备
|
||||
testDevice(deviceType) {
|
||||
return api.post(`/api/devices/${deviceType}/test`)
|
||||
}
|
||||
}
|
||||
|
||||
// 用户认证API
|
||||
export const authAPI = {
|
||||
// 用户登录
|
||||
login(credentials) {
|
||||
return api.post('/api/auth/login', credentials)
|
||||
},
|
||||
|
||||
// 用户注册
|
||||
register(userData) {
|
||||
return api.post('/api/auth/register', userData)
|
||||
},
|
||||
|
||||
// 忘记密码
|
||||
forgotPassword(email) {
|
||||
return api.post('/api/auth/forgot-password', { email })
|
||||
},
|
||||
|
||||
// 重置密码
|
||||
resetPassword(token, newPassword) {
|
||||
return api.post('/api/auth/reset-password', { token, password: newPassword })
|
||||
},
|
||||
|
||||
// 退出登录
|
||||
logout() {
|
||||
return api.post('/api/auth/logout')
|
||||
},
|
||||
|
||||
// 验证token
|
||||
verifyToken() {
|
||||
return api.get('/api/auth/verify')
|
||||
}
|
||||
}
|
||||
|
||||
// 患者API
|
||||
export const patientAPI = {
|
||||
// 获取患者列表
|
||||
getPatients(params = {}) {
|
||||
return api.get('/api/patients', { params })
|
||||
},
|
||||
|
||||
// 获取患者详情
|
||||
getPatient(id) {
|
||||
return api.get(`/api/patients/${id}`)
|
||||
},
|
||||
|
||||
// 根据ID获取患者(别名方法)
|
||||
getById(id) {
|
||||
return this.getPatient(id)
|
||||
},
|
||||
|
||||
// 创建患者
|
||||
createPatient(data) {
|
||||
return api.post('/api/patients', data)
|
||||
},
|
||||
|
||||
// 创建患者(别名方法)
|
||||
create(data) {
|
||||
return this.createPatient(data)
|
||||
},
|
||||
|
||||
// 更新患者
|
||||
updatePatient(id, data) {
|
||||
return api.put(`/api/patients/${id}`, data)
|
||||
},
|
||||
|
||||
// 更新患者(别名方法)
|
||||
update(id, data) {
|
||||
return this.updatePatient(id, data)
|
||||
},
|
||||
|
||||
// 删除患者
|
||||
deletePatient(id) {
|
||||
return api.delete(`/api/patients/${id}`)
|
||||
},
|
||||
|
||||
// 搜索患者
|
||||
searchPatients(query) {
|
||||
return api.get('/api/patients/search', { params: { q: query } })
|
||||
},
|
||||
|
||||
// 获取患者统计信息
|
||||
getStatistics() {
|
||||
return api.get('/api/patients/statistics')
|
||||
}
|
||||
}
|
||||
|
||||
// 检测API
|
||||
export const detectionAPI = {
|
||||
// 开始检测
|
||||
startDetection(config) {
|
||||
return api.post('/api/detection/start', config)
|
||||
},
|
||||
|
||||
// 停止检测
|
||||
stopDetection(sessionId) {
|
||||
return api.post(`/api/detection/${sessionId}/stop`)
|
||||
},
|
||||
|
||||
// 获取检测状态
|
||||
getDetectionStatus(sessionId) {
|
||||
return api.get(`/api/detection/${sessionId}/status`)
|
||||
},
|
||||
|
||||
// 获取实时数据
|
||||
getRealTimeData(sessionId) {
|
||||
return api.get(`/api/detection/${sessionId}/realtime`)
|
||||
},
|
||||
|
||||
// 获取检测结果
|
||||
getDetectionResults(sessionId) {
|
||||
return api.get(`/api/detection/${sessionId}/results`)
|
||||
},
|
||||
|
||||
// 保存检测结果
|
||||
saveDetectionResults(sessionId, data) {
|
||||
return api.post(`/api/detection/${sessionId}/save`, data)
|
||||
},
|
||||
|
||||
// 创建检测记录
|
||||
create(data) {
|
||||
return api.post('/api/detection', data)
|
||||
},
|
||||
|
||||
// 获取检测记录列表
|
||||
getList(params = {}) {
|
||||
return api.get('/api/detection', { params })
|
||||
},
|
||||
|
||||
// 根据患者ID获取检测记录
|
||||
getByPatient(patientId) {
|
||||
return api.get(`/api/patients/${patientId}/detections`)
|
||||
}
|
||||
}
|
||||
|
||||
// 录制API
|
||||
export const recordingAPI = {
|
||||
// 创建录制
|
||||
create(data) {
|
||||
return api.post('/api/recordings', data)
|
||||
},
|
||||
|
||||
// 获取录制列表
|
||||
getList(params = {}) {
|
||||
return api.get('/api/recordings', { params })
|
||||
},
|
||||
|
||||
// 根据患者ID获取录制
|
||||
getByPatient(patientId) {
|
||||
return api.get(`/api/patients/${patientId}/recordings`)
|
||||
},
|
||||
|
||||
// 获取录制详情
|
||||
getById(id) {
|
||||
return api.get(`/api/recordings/${id}`)
|
||||
},
|
||||
|
||||
// 更新录制
|
||||
update(id, data) {
|
||||
return api.put(`/api/recordings/${id}`, data)
|
||||
},
|
||||
|
||||
// 删除录制
|
||||
delete(id) {
|
||||
return api.delete(`/api/recordings/${id}`)
|
||||
},
|
||||
|
||||
// 上传录制文件
|
||||
upload(file, onProgress) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
return api.post('/api/recordings/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (onProgress) {
|
||||
const percentCompleted = Math.round(
|
||||
(progressEvent.loaded * 100) / progressEvent.total
|
||||
)
|
||||
onProgress(percentCompleted)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 会话API
|
||||
export const sessionAPI = {
|
||||
// 获取会话列表
|
||||
getSessions(params = {}) {
|
||||
return api.get('/api/sessions', { params })
|
||||
},
|
||||
|
||||
// 获取会话详情
|
||||
getSession(id) {
|
||||
return api.get(`/api/sessions/${id}`)
|
||||
},
|
||||
|
||||
// 删除会话
|
||||
deleteSession(id) {
|
||||
return api.delete(`/api/sessions/${id}`)
|
||||
},
|
||||
|
||||
// 获取患者的会话历史
|
||||
getPatientSessions(patientId, params = {}) {
|
||||
return api.get(`/api/patients/${patientId}/sessions`, { params })
|
||||
}
|
||||
}
|
||||
|
||||
// 数据分析API
|
||||
export const analysisAPI = {
|
||||
// 分析会话数据
|
||||
analyzeSession(sessionId) {
|
||||
return api.post(`/api/analysis/session/${sessionId}`)
|
||||
},
|
||||
|
||||
// 获取分析结果
|
||||
getAnalysisResult(sessionId) {
|
||||
return api.get(`/api/analysis/session/${sessionId}/result`)
|
||||
},
|
||||
|
||||
// 比较多个会话
|
||||
compareSessions(sessionIds) {
|
||||
return api.post('/api/analysis/compare', { session_ids: sessionIds })
|
||||
},
|
||||
|
||||
// 获取统计数据
|
||||
getStatistics(params = {}) {
|
||||
return api.get('/api/analysis/statistics', { params })
|
||||
},
|
||||
|
||||
// 生成趋势报告
|
||||
generateTrendReport(patientId, params = {}) {
|
||||
return api.post(`/api/analysis/trend/${patientId}`, params)
|
||||
}
|
||||
}
|
||||
|
||||
// 报告API
|
||||
export const reportAPI = {
|
||||
// 生成PDF报告
|
||||
generatePDFReport(sessionId) {
|
||||
return api.post(`/api/reports/pdf/${sessionId}`, {}, {
|
||||
responseType: 'blob'
|
||||
})
|
||||
},
|
||||
|
||||
// 导出数据
|
||||
exportData(sessionId, format = 'csv') {
|
||||
return api.get(`/api/reports/export/${sessionId}`, {
|
||||
params: { format },
|
||||
responseType: 'blob'
|
||||
})
|
||||
},
|
||||
|
||||
// 批量导出
|
||||
batchExport(sessionIds, format = 'csv') {
|
||||
return api.post('/api/reports/batch-export', {
|
||||
session_ids: sessionIds,
|
||||
format
|
||||
}, {
|
||||
responseType: 'blob'
|
||||
})
|
||||
},
|
||||
|
||||
// 获取报告列表
|
||||
getReports(params = {}) {
|
||||
return api.get('/api/reports', { params })
|
||||
},
|
||||
|
||||
// 删除报告
|
||||
deleteReport(reportId) {
|
||||
return api.delete(`/api/reports/${reportId}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 设置API
|
||||
export const settingsAPI = {
|
||||
// 获取系统设置
|
||||
getSettings() {
|
||||
return api.get('/api/settings')
|
||||
},
|
||||
|
||||
// 更新系统设置
|
||||
updateSettings(settings) {
|
||||
return api.put('/api/settings', settings)
|
||||
},
|
||||
|
||||
// 重置设置
|
||||
resetSettings() {
|
||||
return api.post('/api/settings/reset')
|
||||
},
|
||||
|
||||
// 备份设置
|
||||
backupSettings() {
|
||||
return api.post('/api/settings/backup', {}, {
|
||||
responseType: 'blob'
|
||||
})
|
||||
},
|
||||
|
||||
// 恢复设置
|
||||
restoreSettings(file) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return api.post('/api/settings/restore', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocket连接管理
|
||||
export class WebSocketManager {
|
||||
constructor() {
|
||||
this.socket = null
|
||||
this.reconnectAttempts = 0
|
||||
this.maxReconnectAttempts = 5
|
||||
this.reconnectInterval = 3000
|
||||
this.listeners = new Map()
|
||||
}
|
||||
|
||||
connect(url) {
|
||||
try {
|
||||
this.socket = new WebSocket(url)
|
||||
|
||||
this.socket.onopen = () => {
|
||||
console.log('WebSocket连接已建立')
|
||||
this.reconnectAttempts = 0
|
||||
this.emit('connected')
|
||||
}
|
||||
|
||||
this.socket.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
this.emit('message', data)
|
||||
|
||||
// 根据消息类型分发
|
||||
if (data.type) {
|
||||
this.emit(data.type, data.payload)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('WebSocket消息解析失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
this.socket.onclose = () => {
|
||||
console.log('WebSocket连接已关闭')
|
||||
this.emit('disconnected')
|
||||
this.attemptReconnect()
|
||||
}
|
||||
|
||||
this.socket.onerror = (error) => {
|
||||
console.error('WebSocket错误:', error)
|
||||
this.emit('error', error)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('WebSocket连接失败:', error)
|
||||
this.emit('error', error)
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.socket) {
|
||||
this.socket.close()
|
||||
this.socket = null
|
||||
}
|
||||
}
|
||||
|
||||
send(data) {
|
||||
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||||
this.socket.send(JSON.stringify(data))
|
||||
} else {
|
||||
console.warn('WebSocket未连接,无法发送消息')
|
||||
}
|
||||
}
|
||||
|
||||
on(event, callback) {
|
||||
if (!this.listeners.has(event)) {
|
||||
this.listeners.set(event, [])
|
||||
}
|
||||
this.listeners.get(event).push(callback)
|
||||
}
|
||||
|
||||
off(event, callback) {
|
||||
if (this.listeners.has(event)) {
|
||||
const callbacks = this.listeners.get(event)
|
||||
const index = callbacks.indexOf(callback)
|
||||
if (index > -1) {
|
||||
callbacks.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emit(event, data) {
|
||||
if (this.listeners.has(event)) {
|
||||
this.listeners.get(event).forEach(callback => {
|
||||
try {
|
||||
callback(data)
|
||||
} catch (error) {
|
||||
console.error(`WebSocket事件处理错误 [${event}]:`, error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
attemptReconnect() {
|
||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.reconnectAttempts++
|
||||
console.log(`尝试重连 (${this.reconnectAttempts}/${this.maxReconnectAttempts})...`)
|
||||
|
||||
setTimeout(() => {
|
||||
if (window.electronAPI) {
|
||||
const backendUrl = window.electronAPI.getBackendUrl()
|
||||
const wsUrl = backendUrl.replace('http', 'ws') + '/ws'
|
||||
this.connect(wsUrl)
|
||||
}
|
||||
}, this.reconnectInterval)
|
||||
} else {
|
||||
console.error('WebSocket重连失败,已达到最大重试次数')
|
||||
this.emit('reconnect_failed')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建WebSocket管理器实例
|
||||
export const wsManager = new WebSocketManager()
|
||||
|
||||
// 文件上传工具
|
||||
export const uploadFile = (file, onProgress) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const config = {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (onProgress) {
|
||||
const percentCompleted = Math.round(
|
||||
(progressEvent.loaded * 100) / progressEvent.total
|
||||
)
|
||||
onProgress(percentCompleted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
api.post('/api/upload', formData, config)
|
||||
.then(resolve)
|
||||
.catch(reject)
|
||||
})
|
||||
}
|
||||
|
||||
// 文件下载工具
|
||||
export const downloadFile = (url, filename) => {
|
||||
return api.get(url, { responseType: 'blob' })
|
||||
.then(response => {
|
||||
const blob = new Blob([response])
|
||||
const downloadUrl = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = downloadUrl
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(downloadUrl)
|
||||
})
|
||||
}
|
||||
|
||||
export default api
|
||||
569
frontend/src/renderer/src/stores/index.js
Normal file
569
frontend/src/renderer/src/stores/index.js
Normal file
@ -0,0 +1,569 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { authAPI } from '../services/api'
|
||||
|
||||
// 认证状态管理
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
// 状态
|
||||
const authToken = ref(localStorage.getItem('authToken') || null)
|
||||
const currentUser = ref(JSON.parse(localStorage.getItem('currentUser') || 'null'))
|
||||
const isLoading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
// 计算属性
|
||||
const isAuthenticated = computed(() => !!authToken.value && !!currentUser.value)
|
||||
|
||||
// 方法
|
||||
const login = async (credentials) => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
const response = await authAPI.login(credentials)
|
||||
const { token, user } = response.data
|
||||
|
||||
authToken.value = token
|
||||
currentUser.value = user
|
||||
|
||||
// 保存到本地存储
|
||||
localStorage.setItem('authToken', token)
|
||||
localStorage.setItem('currentUser', JSON.stringify(user))
|
||||
|
||||
return { success: true, data: response.data }
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.message || '登录失败'
|
||||
return { success: false, error: error.value }
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const register = async (userData) => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
const response = await authAPI.register(userData)
|
||||
return { success: true, data: response.data }
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.message || '注册失败'
|
||||
return { success: false, error: error.value }
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const forgotPassword = async (email) => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
const response = await authAPI.forgotPassword(email)
|
||||
return { success: true, data: response.data }
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.message || '发送重置邮件失败'
|
||||
return { success: false, error: error.value }
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
if (authToken.value) {
|
||||
await authAPI.logout()
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Logout API call failed:', err)
|
||||
} finally {
|
||||
// 清除本地状态
|
||||
authToken.value = null
|
||||
currentUser.value = null
|
||||
localStorage.removeItem('authToken')
|
||||
localStorage.removeItem('currentUser')
|
||||
}
|
||||
}
|
||||
|
||||
const verifyToken = async () => {
|
||||
if (!authToken.value) return false
|
||||
|
||||
try {
|
||||
const response = await authAPI.verifyToken()
|
||||
return response.data.valid
|
||||
} catch (err) {
|
||||
// Token无效,清除本地状态
|
||||
await logout()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const clearError = () => {
|
||||
error.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
authToken,
|
||||
currentUser,
|
||||
isLoading,
|
||||
error,
|
||||
// 计算属性
|
||||
isAuthenticated,
|
||||
// 方法
|
||||
login,
|
||||
register,
|
||||
forgotPassword,
|
||||
logout,
|
||||
verifyToken,
|
||||
clearError
|
||||
}
|
||||
})
|
||||
|
||||
// 系统状态管理
|
||||
export const useSystemStore = defineStore('system', () => {
|
||||
// 状态
|
||||
const backendUrl = ref('http://127.0.0.1:5000')
|
||||
const isBackendConnected = ref(false)
|
||||
const systemInfo = ref({})
|
||||
const currentUser = ref(null)
|
||||
const isLoading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
// 计算属性
|
||||
const isReady = computed(() => isBackendConnected.value && !isLoading.value)
|
||||
|
||||
// 方法
|
||||
const setBackendConnection = (connected) => {
|
||||
isBackendConnected.value = connected
|
||||
}
|
||||
|
||||
const setSystemInfo = (info) => {
|
||||
systemInfo.value = info
|
||||
}
|
||||
|
||||
const setCurrentUser = (user) => {
|
||||
currentUser.value = user
|
||||
}
|
||||
|
||||
const setLoading = (loading) => {
|
||||
isLoading.value = loading
|
||||
}
|
||||
|
||||
const setError = (errorMsg) => {
|
||||
error.value = errorMsg
|
||||
}
|
||||
|
||||
const clearError = () => {
|
||||
error.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
backendUrl,
|
||||
isBackendConnected,
|
||||
systemInfo,
|
||||
currentUser,
|
||||
isLoading,
|
||||
error,
|
||||
// 计算属性
|
||||
isReady,
|
||||
// 方法
|
||||
setBackendConnection,
|
||||
setSystemInfo,
|
||||
setCurrentUser,
|
||||
setLoading,
|
||||
setError,
|
||||
clearError
|
||||
}
|
||||
})
|
||||
|
||||
// 患者管理状态
|
||||
export const usePatientStore = defineStore('patient', () => {
|
||||
// 状态
|
||||
const patients = ref([])
|
||||
const currentPatient = ref(null)
|
||||
const searchQuery = ref('')
|
||||
const filters = ref({
|
||||
gender: '',
|
||||
ageRange: '',
|
||||
dateRange: []
|
||||
})
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
total: 0
|
||||
})
|
||||
|
||||
// 计算属性
|
||||
const filteredPatients = computed(() => {
|
||||
let result = patients.value
|
||||
|
||||
// 搜索过滤
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
result = result.filter(patient =>
|
||||
patient.name.toLowerCase().includes(query) ||
|
||||
patient.phone?.toLowerCase().includes(query) ||
|
||||
patient.medical_history?.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
// 性别过滤
|
||||
if (filters.value.gender) {
|
||||
result = result.filter(patient => patient.gender === filters.value.gender)
|
||||
}
|
||||
|
||||
// 年龄范围过滤
|
||||
if (filters.value.ageRange) {
|
||||
const [minAge, maxAge] = filters.value.ageRange.split('-').map(Number)
|
||||
result = result.filter(patient => {
|
||||
const age = calculateAge(patient.birth_date)
|
||||
return age >= minAge && age <= maxAge
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// 方法
|
||||
const setPatients = (patientList) => {
|
||||
patients.value = patientList
|
||||
}
|
||||
|
||||
const addPatient = (patient) => {
|
||||
patients.value.unshift(patient)
|
||||
}
|
||||
|
||||
const updatePatient = (updatedPatient) => {
|
||||
const index = patients.value.findIndex(p => p.id === updatedPatient.id)
|
||||
if (index !== -1) {
|
||||
patients.value[index] = updatedPatient
|
||||
}
|
||||
}
|
||||
|
||||
const removePatient = (patientId) => {
|
||||
const index = patients.value.findIndex(p => p.id === patientId)
|
||||
if (index !== -1) {
|
||||
patients.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const setCurrentPatient = (patient) => {
|
||||
currentPatient.value = patient
|
||||
}
|
||||
|
||||
const setSearchQuery = (query) => {
|
||||
searchQuery.value = query
|
||||
}
|
||||
|
||||
const setFilters = (newFilters) => {
|
||||
filters.value = { ...filters.value, ...newFilters }
|
||||
}
|
||||
|
||||
const setPagination = (newPagination) => {
|
||||
pagination.value = { ...pagination.value, ...newPagination }
|
||||
}
|
||||
|
||||
const calculateAge = (birthDate) => {
|
||||
const today = new Date()
|
||||
const birth = new Date(birthDate)
|
||||
let age = today.getFullYear() - birth.getFullYear()
|
||||
const monthDiff = today.getMonth() - birth.getMonth()
|
||||
|
||||
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
|
||||
age--
|
||||
}
|
||||
|
||||
return age
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
patients,
|
||||
currentPatient,
|
||||
searchQuery,
|
||||
filters,
|
||||
pagination,
|
||||
// 计算属性
|
||||
filteredPatients,
|
||||
// 方法
|
||||
setPatients,
|
||||
addPatient,
|
||||
updatePatient,
|
||||
removePatient,
|
||||
setCurrentPatient,
|
||||
setSearchQuery,
|
||||
setFilters,
|
||||
setPagination,
|
||||
calculateAge
|
||||
}
|
||||
})
|
||||
|
||||
// 检测状态管理
|
||||
export const useDetectionStore = defineStore('detection', () => {
|
||||
// 状态
|
||||
const isDetecting = ref(false)
|
||||
const currentSession = ref(null)
|
||||
const detectionConfig = ref({
|
||||
duration: 60,
|
||||
samplingRate: 30,
|
||||
recordVideo: true
|
||||
})
|
||||
const realTimeData = ref({
|
||||
pressure: { left: 0, right: 0, total: 0 },
|
||||
balance: { index: 0, centerX: 0, centerY: 0 },
|
||||
posture: { pitch: 0, roll: 0, yaw: 0 },
|
||||
timestamp: null
|
||||
})
|
||||
const detectionResults = ref(null)
|
||||
const deviceStatus = ref({
|
||||
camera: { connected: false, status: 'disconnected' },
|
||||
imu: { connected: false, status: 'disconnected' },
|
||||
pressure: { connected: false, status: 'disconnected' }
|
||||
})
|
||||
|
||||
// 计算属性
|
||||
const allDevicesReady = computed(() => {
|
||||
return Object.values(deviceStatus.value).every(device => device.connected)
|
||||
})
|
||||
|
||||
const detectionProgress = computed(() => {
|
||||
if (!currentSession.value || !currentSession.value.start_time) return 0
|
||||
|
||||
const startTime = new Date(currentSession.value.start_time)
|
||||
const now = new Date()
|
||||
const elapsed = (now - startTime) / 1000
|
||||
const progress = Math.min((elapsed / detectionConfig.value.duration) * 100, 100)
|
||||
|
||||
return Math.round(progress)
|
||||
})
|
||||
|
||||
// 方法
|
||||
const setDetecting = (detecting) => {
|
||||
isDetecting.value = detecting
|
||||
}
|
||||
|
||||
const setCurrentSession = (session) => {
|
||||
currentSession.value = session
|
||||
}
|
||||
|
||||
const setDetectionConfig = (config) => {
|
||||
detectionConfig.value = { ...detectionConfig.value, ...config }
|
||||
}
|
||||
|
||||
const updateRealTimeData = (data) => {
|
||||
realTimeData.value = { ...realTimeData.value, ...data, timestamp: new Date() }
|
||||
}
|
||||
|
||||
const setDetectionResults = (results) => {
|
||||
detectionResults.value = results
|
||||
}
|
||||
|
||||
const updateDeviceStatus = (device, status) => {
|
||||
if (deviceStatus.value[device]) {
|
||||
deviceStatus.value[device] = { ...deviceStatus.value[device], ...status }
|
||||
}
|
||||
}
|
||||
|
||||
const resetDetection = () => {
|
||||
isDetecting.value = false
|
||||
currentSession.value = null
|
||||
detectionResults.value = null
|
||||
realTimeData.value = {
|
||||
pressure: { left: 0, right: 0, total: 0 },
|
||||
balance: { index: 0, centerX: 0, centerY: 0 },
|
||||
posture: { pitch: 0, roll: 0, yaw: 0 },
|
||||
timestamp: null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
isDetecting,
|
||||
currentSession,
|
||||
detectionConfig,
|
||||
realTimeData,
|
||||
detectionResults,
|
||||
deviceStatus,
|
||||
// 计算属性
|
||||
allDevicesReady,
|
||||
detectionProgress,
|
||||
// 方法
|
||||
setDetecting,
|
||||
setCurrentSession,
|
||||
setDetectionConfig,
|
||||
updateRealTimeData,
|
||||
setDetectionResults,
|
||||
updateDeviceStatus,
|
||||
resetDetection
|
||||
}
|
||||
})
|
||||
|
||||
// 数据分析状态管理
|
||||
export const useAnalysisStore = defineStore('analysis', () => {
|
||||
// 状态
|
||||
const sessions = ref([])
|
||||
const currentAnalysis = ref(null)
|
||||
const analysisFilters = ref({
|
||||
patientId: null,
|
||||
dateRange: [],
|
||||
sessionType: ''
|
||||
})
|
||||
const comparisonData = ref([])
|
||||
const reports = ref([])
|
||||
|
||||
// 计算属性
|
||||
const filteredSessions = computed(() => {
|
||||
let result = sessions.value
|
||||
|
||||
// 患者过滤
|
||||
if (analysisFilters.value.patientId) {
|
||||
result = result.filter(session => session.patient_id === analysisFilters.value.patientId)
|
||||
}
|
||||
|
||||
// 日期范围过滤
|
||||
if (analysisFilters.value.dateRange.length === 2) {
|
||||
const [startDate, endDate] = analysisFilters.value.dateRange
|
||||
result = result.filter(session => {
|
||||
const sessionDate = new Date(session.start_time)
|
||||
return sessionDate >= startDate && sessionDate <= endDate
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// 方法
|
||||
const setSessions = (sessionList) => {
|
||||
sessions.value = sessionList
|
||||
}
|
||||
|
||||
const addSession = (session) => {
|
||||
sessions.value.unshift(session)
|
||||
}
|
||||
|
||||
const setCurrentAnalysis = (analysis) => {
|
||||
currentAnalysis.value = analysis
|
||||
}
|
||||
|
||||
const setAnalysisFilters = (filters) => {
|
||||
analysisFilters.value = { ...analysisFilters.value, ...filters }
|
||||
}
|
||||
|
||||
const setComparisonData = (data) => {
|
||||
comparisonData.value = data
|
||||
}
|
||||
|
||||
const addReport = (report) => {
|
||||
reports.value.unshift(report)
|
||||
}
|
||||
|
||||
const removeReport = (reportId) => {
|
||||
const index = reports.value.findIndex(r => r.id === reportId)
|
||||
if (index !== -1) {
|
||||
reports.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
sessions,
|
||||
currentAnalysis,
|
||||
analysisFilters,
|
||||
comparisonData,
|
||||
reports,
|
||||
// 计算属性
|
||||
filteredSessions,
|
||||
// 方法
|
||||
setSessions,
|
||||
addSession,
|
||||
setCurrentAnalysis,
|
||||
setAnalysisFilters,
|
||||
setComparisonData,
|
||||
addReport,
|
||||
removeReport
|
||||
}
|
||||
})
|
||||
|
||||
// 设置状态管理
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
// 状态
|
||||
const appSettings = ref({
|
||||
theme: 'light',
|
||||
language: 'zh-CN',
|
||||
autoSave: true,
|
||||
notifications: true
|
||||
})
|
||||
const deviceSettings = ref({
|
||||
camera: {
|
||||
resolution: '640x480',
|
||||
fps: 30,
|
||||
brightness: 50,
|
||||
contrast: 50
|
||||
},
|
||||
sensors: {
|
||||
samplingRate: 30,
|
||||
filterEnabled: true,
|
||||
calibrationInterval: 24
|
||||
}
|
||||
})
|
||||
const detectionSettings = ref({
|
||||
defaultDuration: 60,
|
||||
balanceThreshold: 0.2,
|
||||
postureThreshold: 5.0,
|
||||
autoAnalysis: true
|
||||
})
|
||||
|
||||
// 方法
|
||||
const updateAppSettings = (settings) => {
|
||||
appSettings.value = { ...appSettings.value, ...settings }
|
||||
}
|
||||
|
||||
const updateDeviceSettings = (settings) => {
|
||||
deviceSettings.value = { ...deviceSettings.value, ...settings }
|
||||
}
|
||||
|
||||
const updateDetectionSettings = (settings) => {
|
||||
detectionSettings.value = { ...detectionSettings.value, ...settings }
|
||||
}
|
||||
|
||||
const resetToDefaults = () => {
|
||||
appSettings.value = {
|
||||
theme: 'light',
|
||||
language: 'zh-CN',
|
||||
autoSave: true,
|
||||
notifications: true
|
||||
}
|
||||
deviceSettings.value = {
|
||||
camera: {
|
||||
resolution: '640x480',
|
||||
fps: 30,
|
||||
brightness: 50,
|
||||
contrast: 50
|
||||
},
|
||||
sensors: {
|
||||
samplingRate: 30,
|
||||
filterEnabled: true,
|
||||
calibrationInterval: 24
|
||||
}
|
||||
}
|
||||
detectionSettings.value = {
|
||||
defaultDuration: 60,
|
||||
balanceThreshold: 0.2,
|
||||
postureThreshold: 5.0,
|
||||
autoAnalysis: true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
appSettings,
|
||||
deviceSettings,
|
||||
detectionSettings,
|
||||
// 方法
|
||||
updateAppSettings,
|
||||
updateDeviceSettings,
|
||||
updateDetectionSettings,
|
||||
resetToDefaults
|
||||
}
|
||||
})
|
||||
285
frontend/src/renderer/src/style.css
Normal file
285
frontend/src/renderer/src/style.css
Normal file
@ -0,0 +1,285 @@
|
||||
/* 全局样式重置 */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Microsoft YaHei', 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', Arial, sans-serif;
|
||||
background-color: #f5f7fa;
|
||||
color: #303133;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
/* 布局容器 */
|
||||
.app-container {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 250px;
|
||||
background: #304156;
|
||||
color: white;
|
||||
overflow-y: auto;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
width: 64px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
height: 60px;
|
||||
background: white;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
box-shadow: 0 1px 4px rgba(0,21,41,.08);
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
/* 卡片样式 */
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
/* 状态指示器 */
|
||||
.status-indicator {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.status-online {
|
||||
background-color: #67c23a;
|
||||
}
|
||||
|
||||
.status-offline {
|
||||
background-color: #f56c6c;
|
||||
}
|
||||
|
||||
.status-warning {
|
||||
background-color: #e6a23c;
|
||||
}
|
||||
|
||||
/* 数据展示 */
|
||||
.data-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.data-item {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.data-value {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: #409eff;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.data-label {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
/* 按钮组 */
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* 表单样式 */
|
||||
.form-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 图表容器 */
|
||||
.chart-container {
|
||||
height: 400px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
/* 视频容器 */
|
||||
.video-container {
|
||||
position: relative;
|
||||
background: #000;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video-player {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.video-controls {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(transparent, rgba(0,0,0,0.7));
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
z-index: 1000;
|
||||
transition: left 0.3s;
|
||||
}
|
||||
|
||||
.sidebar.open {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.data-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* 动画效果 */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.slide-enter-from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.slide-leave-to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
/* 工具类 */
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mb-20 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.mt-20 {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.p-20 {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.full-height {
|
||||
height: 100%;
|
||||
}
|
||||
618
frontend/src/renderer/src/views/Dashboard.vue
Normal file
618
frontend/src/renderer/src/views/Dashboard.vue
Normal file
@ -0,0 +1,618 @@
|
||||
<template>
|
||||
<div class="dashboard-container">
|
||||
<!-- 顶部导航栏 -->
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<img src="/logo.png" alt="Logo" class="logo" />
|
||||
<h1 class="system-title">平衡体态检测系统</h1>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="user-info">
|
||||
<el-avatar :size="40" :src="userInfo.avatar">
|
||||
<el-icon><User /></el-icon>
|
||||
</el-avatar>
|
||||
<span class="username">{{ userInfo.username }}</span>
|
||||
<el-dropdown @command="handleUserCommand">
|
||||
<el-button type="text" class="user-dropdown">
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="profile">个人信息</el-dropdown-item>
|
||||
<el-dropdown-item command="settings">系统设置</el-dropdown-item>
|
||||
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
<!-- 左侧功能导航 -->
|
||||
<div class="sidebar">
|
||||
<div class="nav-menu">
|
||||
<div class="nav-item" :class="{ active: activeNav === 'detection' }" @click="activeNav = 'detection'">
|
||||
<el-icon class="nav-icon"><Monitor /></el-icon>
|
||||
<span>检测</span>
|
||||
</div>
|
||||
<div class="nav-item" :class="{ active: activeNav === 'patient' }" @click="activeNav = 'patient'">
|
||||
<el-icon class="nav-icon"><User /></el-icon>
|
||||
<span>人员</span>
|
||||
</div>
|
||||
<div class="nav-item" :class="{ active: activeNav === 'system' }" @click="activeNav = 'system'">
|
||||
<el-icon class="nav-icon"><Setting /></el-icon>
|
||||
<span>系统</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧内容区域 -->
|
||||
<div class="content-area">
|
||||
<!-- 患者列表区域 -->
|
||||
<div class="patient-section">
|
||||
<div class="section-header">
|
||||
<h2>患者列表</h2>
|
||||
<div class="search-box">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索患者姓名"
|
||||
prefix-icon="Search"
|
||||
clearable
|
||||
@input="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="patient-list">
|
||||
<div
|
||||
v-for="patient in filteredPatients"
|
||||
:key="patient.id"
|
||||
class="patient-item"
|
||||
:class="{ selected: selectedPatient?.id === patient.id }"
|
||||
@click="selectPatient(patient)"
|
||||
>
|
||||
<el-avatar :size="50" :src="patient.avatar">
|
||||
{{ patient.name.charAt(0) }}
|
||||
</el-avatar>
|
||||
<div class="patient-info">
|
||||
<div class="patient-name">{{ patient.name }}</div>
|
||||
<div class="patient-details">
|
||||
<span>{{ patient.gender }} | {{ patient.age }}岁</span>
|
||||
<span class="patient-id">ID: {{ patient.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="patient-status">
|
||||
<el-tag :type="getStatusType(patient.lastDetection)">{{ getStatusText(patient.lastDetection) }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 患者详情区域 -->
|
||||
<div class="patient-detail" v-if="selectedPatient">
|
||||
<div class="detail-header">
|
||||
<h3>患者信息</h3>
|
||||
<el-button type="text" @click="editPatient">
|
||||
<el-icon><Edit /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="detail-content">
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<label>姓名:</label>
|
||||
<span>{{ selectedPatient.name }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>性别:</label>
|
||||
<span>{{ selectedPatient.gender }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>年龄:</label>
|
||||
<span>{{ selectedPatient.age }}岁</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>身高:</label>
|
||||
<span>{{ selectedPatient.height }}cm</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>体重:</label>
|
||||
<span>{{ selectedPatient.weight }}kg</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>联系电话:</label>
|
||||
<span>{{ selectedPatient.phone }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>创建时间:</label>
|
||||
<span>{{ formatDate(selectedPatient.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>最后检测:</label>
|
||||
<span>{{ selectedPatient.lastDetection ? formatDate(selectedPatient.lastDetection) : '暂无' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="action-buttons">
|
||||
<el-button type="primary" size="large" class="action-btn view-btn" @click="viewPatientProfile">
|
||||
查看档案
|
||||
</el-button>
|
||||
<el-button type="success" size="large" class="action-btn detect-btn" @click="startDetection">
|
||||
开始检测
|
||||
</el-button>
|
||||
<el-button type="warning" size="large" class="action-btn new-btn" @click="createNewPatient">
|
||||
新患者建档
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 无选中患者时的提示 -->
|
||||
<div class="no-selection" v-else>
|
||||
<el-empty description="请选择一个患者查看详情">
|
||||
<el-button type="primary" @click="createNewPatient">新建患者档案</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { patientAPI } from '../services/api.js'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 响应式数据
|
||||
const activeNav = ref('detection')
|
||||
const searchKeyword = ref('')
|
||||
const selectedPatient = ref(null)
|
||||
const patients = ref([])
|
||||
const userInfo = reactive({
|
||||
username: '医生',
|
||||
avatar: ''
|
||||
})
|
||||
|
||||
// 计算属性
|
||||
const filteredPatients = computed(() => {
|
||||
if (!searchKeyword.value) {
|
||||
return patients.value
|
||||
}
|
||||
return patients.value.filter(patient =>
|
||||
patient.name.toLowerCase().includes(searchKeyword.value.toLowerCase())
|
||||
)
|
||||
})
|
||||
|
||||
// 方法
|
||||
const handleSearch = () => {
|
||||
// 搜索逻辑已在计算属性中处理
|
||||
}
|
||||
|
||||
const selectPatient = (patient) => {
|
||||
selectedPatient.value = patient
|
||||
}
|
||||
|
||||
const editPatient = () => {
|
||||
router.push(`/patient/edit/${selectedPatient.value.id}`)
|
||||
}
|
||||
|
||||
const viewPatientProfile = () => {
|
||||
router.push(`/patient/profile/${selectedPatient.value.id}`)
|
||||
}
|
||||
|
||||
const startDetection = () => {
|
||||
if (!selectedPatient.value) {
|
||||
ElMessage.warning('请先选择患者')
|
||||
return
|
||||
}
|
||||
router.push(`/detection/${selectedPatient.value.id}`)
|
||||
}
|
||||
|
||||
const createNewPatient = () => {
|
||||
router.push('/patient/create')
|
||||
}
|
||||
|
||||
const handleUserCommand = (command) => {
|
||||
switch (command) {
|
||||
case 'profile':
|
||||
// 个人信息
|
||||
break
|
||||
case 'settings':
|
||||
// 系统设置
|
||||
break
|
||||
case 'logout':
|
||||
handleLogout()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要退出登录吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
localStorage.removeItem('userInfo')
|
||||
localStorage.removeItem('rememberedUser')
|
||||
ElMessage.success('已退出登录')
|
||||
router.push('/login')
|
||||
} catch {
|
||||
// 用户取消
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusType = (lastDetection) => {
|
||||
if (!lastDetection) return 'info'
|
||||
const daysDiff = Math.floor((Date.now() - new Date(lastDetection).getTime()) / (1000 * 60 * 60 * 24))
|
||||
if (daysDiff <= 7) return 'success'
|
||||
if (daysDiff <= 30) return 'warning'
|
||||
return 'danger'
|
||||
}
|
||||
|
||||
const getStatusText = (lastDetection) => {
|
||||
if (!lastDetection) return '未检测'
|
||||
const daysDiff = Math.floor((Date.now() - new Date(lastDetection).getTime()) / (1000 * 60 * 60 * 24))
|
||||
if (daysDiff === 0) return '今日检测'
|
||||
if (daysDiff <= 7) return `${daysDiff}天前`
|
||||
if (daysDiff <= 30) return `${daysDiff}天前`
|
||||
return '超过30天'
|
||||
}
|
||||
|
||||
const formatDate = (date) => {
|
||||
return new Date(date).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
const loadPatients = async () => {
|
||||
try {
|
||||
const response = await patientAPI.getList()
|
||||
if (response.success) {
|
||||
patients.value = response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载患者列表失败:', error)
|
||||
// 模拟数据
|
||||
patients.value = [
|
||||
{
|
||||
id: 1,
|
||||
name: '张三',
|
||||
gender: '男',
|
||||
age: 45,
|
||||
height: 175,
|
||||
weight: 70,
|
||||
phone: '13800138001',
|
||||
createdAt: '2024-01-15T10:30:00Z',
|
||||
lastDetection: '2024-01-20T14:20:00Z'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '李四',
|
||||
gender: '女',
|
||||
age: 38,
|
||||
height: 165,
|
||||
weight: 55,
|
||||
phone: '13800138002',
|
||||
createdAt: '2024-01-10T09:15:00Z',
|
||||
lastDetection: null
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '王五',
|
||||
gender: '男',
|
||||
age: 52,
|
||||
height: 180,
|
||||
weight: 75,
|
||||
phone: '13800138003',
|
||||
createdAt: '2024-01-05T16:45:00Z',
|
||||
lastDetection: '2024-01-18T11:30:00Z'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
// 加载用户信息
|
||||
const savedUserInfo = localStorage.getItem('userInfo')
|
||||
if (savedUserInfo) {
|
||||
Object.assign(userInfo, JSON.parse(savedUserInfo))
|
||||
}
|
||||
|
||||
// 加载患者列表
|
||||
loadPatients()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.header {
|
||||
height: 60px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.system-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.user-dropdown {
|
||||
padding: 0;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 200px;
|
||||
background: #2c3e50;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 15px 20px;
|
||||
color: #bdc3c7;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: #34495e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: #3498db;
|
||||
color: #fff;
|
||||
border-right: 3px solid #2980b9;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.patient-section {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
margin: 0;
|
||||
color: #2c3e50;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.patient-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.patient-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
padding: 15px;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.patient-item:hover {
|
||||
border-color: #3498db;
|
||||
box-shadow: 0 2px 8px rgba(52, 152, 219, 0.2);
|
||||
}
|
||||
|
||||
.patient-item.selected {
|
||||
border-color: #3498db;
|
||||
background: #f0f8ff;
|
||||
}
|
||||
|
||||
.patient-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.patient-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.patient-details {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.patient-id {
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.patient-status {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.patient-detail {
|
||||
width: 400px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.detail-header h3 {
|
||||
margin: 0;
|
||||
color: #2c3e50;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
flex: 1;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.info-item label {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-item span {
|
||||
font-size: 14px;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
height: 45px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.view-btn {
|
||||
background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.detect-btn {
|
||||
background: linear-gradient(135deg, #e91e63 0%, #c2185b 100%);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.new-btn {
|
||||
background: linear-gradient(135deg, #ff9800 0%, #f57c00 100%);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.no-selection {
|
||||
width: 400px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
:deep(.el-empty__description) {
|
||||
color: #909399;
|
||||
}
|
||||
</style>
|
||||
602
frontend/src/renderer/src/views/Detection.vue
Normal file
602
frontend/src/renderer/src/views/Detection.vue
Normal file
@ -0,0 +1,602 @@
|
||||
<template>
|
||||
<div class="dashboard-container">
|
||||
<!-- 左侧导航栏 -->
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-item active">
|
||||
<el-icon class="sidebar-icon"><UserFilled /></el-icon>
|
||||
<span class="sidebar-text">检测</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<div class="main-content">
|
||||
<!-- 顶部工具栏 -->
|
||||
|
||||
<header class="top-bar">
|
||||
<div style="display: flex;align-items: center;">
|
||||
<div class="top-bar-left">
|
||||
<el-icon class="back-icon" @click="handleBack"><ArrowLeft /></el-icon>
|
||||
<span class="page-title">实时检测</span>
|
||||
</div>
|
||||
<el-button
|
||||
type="primary"
|
||||
class="start-btn"
|
||||
style="--el-button-bg-color: #409EFF; --el-button-border-color: #409EFF"
|
||||
>
|
||||
开始
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="top-bar-right">
|
||||
<span class="info-item">测试时间:2025/5/28 下午14:38</span>
|
||||
<span class="info-item">测试医生:李四</span>
|
||||
<el-icon class="top-icon"><Clock /></el-icon>
|
||||
<el-icon class="top-icon"><Grid /></el-icon>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 核心内容区(网格布局) -->
|
||||
<div class="content-grid">
|
||||
<!-- 身体姿态模块 -->
|
||||
<el-card class="module-card body-posture">
|
||||
<template #header>
|
||||
<div class="module-header">
|
||||
<span class="module-title">身体姿态</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="posture-container">
|
||||
<div class="grid-background"></div>
|
||||
<img
|
||||
src="https://via.placeholder.com/400x600?text=身体热力图"
|
||||
alt="身体姿态热力图"
|
||||
class="posture-heatmap"
|
||||
>
|
||||
</div>
|
||||
</el-card>
|
||||
<el-card class="">
|
||||
<!-- 头部姿态模块 -->
|
||||
<el-card class="module-card head-posture">
|
||||
<template #header>
|
||||
<div class="module-header">
|
||||
<img
|
||||
src="https://via.placeholder.com/24x24?text=头部图标"
|
||||
alt="头部姿态图标"
|
||||
class="header-icon"
|
||||
>
|
||||
<span class="module-title">头部姿态</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 仪表盘区域 -->
|
||||
<div class="gauge-group">
|
||||
<!-- 旋转角 -->
|
||||
<div class="gauge-item">
|
||||
<el-gauge
|
||||
:value="55.2"
|
||||
:min="-90"
|
||||
:max="90"
|
||||
:range="[{ start: -90, end: -30, color: '#1E88E5' }, { start: -30, end: 30, color: '#4CAF50' }, { start: 30, end: 90, color: '#E53935' }]"
|
||||
class="gauge"
|
||||
/>
|
||||
<p class="gauge-desc">最大旋转角:左 -55.2°<br>右 54.2°</p>
|
||||
</div>
|
||||
<!-- 倾斜角 -->
|
||||
<div class="gauge-item">
|
||||
<el-gauge
|
||||
:value="7.7"
|
||||
:min="-90"
|
||||
:max="90"
|
||||
:range="[{ start: -90, end: -20, color: '#409EFF' }, { start: -20, end: 20, color: '#FADB14' }, { start: 20, end: 90, color: '#F56C6C' }]"
|
||||
class="gauge"
|
||||
/>
|
||||
<p class="gauge-desc">最大倾斜角:左 -7.7°<br>右 8.7°</p>
|
||||
</div>
|
||||
<!-- 俯仰角 -->
|
||||
<div class="gauge-item">
|
||||
<el-gauge
|
||||
:value="-10.5"
|
||||
:min="-90"
|
||||
:max="90"
|
||||
:range="[{ start: -90, end: -20, color: '#409EFF' }, { start: -20, end: 20, color: '#FADB14' }, { start: 20, end: 90, color: '#F56C6C' }]"
|
||||
class="gauge"
|
||||
/>
|
||||
<p class="gauge-desc">最大俯仰角:俯 -10.5°<br>仰 11.5°</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 历史数据表格 -->
|
||||
<el-table :data="historyData" border class="history-table">
|
||||
<el-table-column prop="id" label="ID" align="center" />
|
||||
<el-table-column prop="rotLeft" label="最大旋转角-左" align="center" />
|
||||
<el-table-column prop="rotRight" label="最大旋转角-右" align="center" />
|
||||
<el-table-column prop="tiltLeft" label="最大倾斜角-左" align="center" />
|
||||
<el-table-column prop="tiltRight" label="最大倾斜角-右" align="center" />
|
||||
<el-table-column prop="pitchDown" label="最大俯仰角-俯" align="center" />
|
||||
<el-table-column prop="pitchUp" label="最大俯仰角-仰" align="center" />
|
||||
</el-table>
|
||||
|
||||
<el-button
|
||||
type="primary"
|
||||
class="zero-btn"
|
||||
style="--el-button-bg-color: #C22ED0; --el-button-border-color: #C22ED0"
|
||||
>
|
||||
清零
|
||||
</el-button>
|
||||
</el-card>
|
||||
<!-- 足部压力模块 -->
|
||||
<el-card class="module-card foot-pressure">
|
||||
<template #header>
|
||||
<div class="module-header">
|
||||
<span class="module-title">足部压力</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="foot-container">
|
||||
<div class="foot-stats">
|
||||
<div class="stat-item">左前足 54%</div>
|
||||
<div class="stat-item">左后足 46%</div>
|
||||
</div>
|
||||
<div class="foot-graph">
|
||||
<img
|
||||
src="https://via.placeholder.com/120x150?text=左足热力图"
|
||||
alt="左足压力"
|
||||
class="foot-img"
|
||||
>
|
||||
<div class="foot-divider"></div>
|
||||
<img
|
||||
src="https://via.placeholder.com/120x150?text=右足热力图"
|
||||
alt="右足压力"
|
||||
class="foot-img"
|
||||
>
|
||||
</div>
|
||||
<div class="foot-stats">
|
||||
<div class="stat-item">右前足 56%</div>
|
||||
<div class="stat-item">右后足 44%</div>
|
||||
</div>
|
||||
<div class="total-pressure">
|
||||
左足总压力:47% 右足总压力:53%
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-card>
|
||||
<el-card class="">
|
||||
<!-- 基础信息模块 -->
|
||||
<el-card class="module-card basic-info">
|
||||
<template #header>
|
||||
<div class="module-header">
|
||||
<span class="module-title">基础信息</span>
|
||||
<el-icon class="edit-icon"><Edit /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<label>测试者ID</label>
|
||||
<span>2101</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>姓名</label>
|
||||
<span>张三</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>性别</label>
|
||||
<span>女</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>出生日期</label>
|
||||
<span>2011/03/07</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>年龄</label>
|
||||
<span>14</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>民族</label>
|
||||
<span>—</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>身高 cm</label>
|
||||
<span>167</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>体重 kg</label>
|
||||
<span>51</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>鞋码</label>
|
||||
<span>38</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>电话号码</label>
|
||||
<span>18011110000</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>建档时间</label>
|
||||
<span>2024/12/13</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
|
||||
|
||||
<!-- 视频模块 -->
|
||||
<el-card class="module-card video-module">
|
||||
<template #header>
|
||||
<div class="module-header">
|
||||
<span class="module-title">视频</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="video-container">
|
||||
<img
|
||||
src="https://via.placeholder.com/300x200?text=视频预览"
|
||||
alt="视频预览"
|
||||
class="video-preview"
|
||||
>
|
||||
<span class="video-time">2025/06/11 15:11:33</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-card>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
ArrowLeft,
|
||||
UserFilled,
|
||||
Clock,
|
||||
Grid,
|
||||
Edit
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
// 模拟历史数据
|
||||
const historyData = ref([
|
||||
{ id: 3, rotLeft: '-55.2°', rotRight: '54.2°', tiltLeft: '-17.7°', tiltRight: '18.2°', pitchDown: '-20.2°', pitchUp: '10.5°' },
|
||||
{ id: 2, rotLeft: '-55.8°', rotRight: '56.2°', tiltLeft: '-17.5°', tiltRight: '17.9°', pitchDown: '-21.2°', pitchUp: '12.1°' },
|
||||
{ id: 1, rotLeft: '-56.1°', rotRight: '55.7°', tiltLeft: '-17.5°', tiltRight: '18.5°', pitchDown: '-22.2°', pitchUp: '11.5°' }
|
||||
])
|
||||
|
||||
// 返回按钮逻辑
|
||||
const handleBack = () => {
|
||||
// 可添加路由跳转逻辑
|
||||
console.log('返回上一页')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 全局容器 */
|
||||
.dashboard-container {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
background-color: #1E1E1E;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
/* 左侧导航栏 */
|
||||
.sidebar {
|
||||
width: 60px;
|
||||
background-color: #2C2C2C;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.sidebar-item.active {
|
||||
color: #C22ED0;
|
||||
}
|
||||
|
||||
.sidebar-icon {
|
||||
font-size: 24px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.sidebar-text {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 主内容区域 */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 顶部工具栏 */
|
||||
.top-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 20px;
|
||||
background-color: #2C2C2C;
|
||||
}
|
||||
|
||||
.top-bar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.back-icon {
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.start-btn {
|
||||
font-size: 16px;
|
||||
padding: 8px 24px;
|
||||
margin-left: 40px;
|
||||
}
|
||||
|
||||
.top-bar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
font-size: 14px;
|
||||
margin-right: 20px;
|
||||
color: #CCCCCC;
|
||||
}
|
||||
|
||||
.top-icon {
|
||||
font-size: 20px;
|
||||
margin-left: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 核心内容网格布局 */
|
||||
.content-grid {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
display: grid;
|
||||
grid-template-columns: 3fr 2fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* 通用模块样式 */
|
||||
.module-card {
|
||||
background-color: #2C2C2C !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.module-header {
|
||||
width: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid red;
|
||||
border-radius: 20px;
|
||||
padding: 10px ;
|
||||
}
|
||||
|
||||
.module-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.edit-icon {
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
color: #CCCCCC;
|
||||
}
|
||||
|
||||
/* 身体姿态模块 */
|
||||
.body-posture {
|
||||
/* grid-row: 1 / 3; */
|
||||
width:30% ;
|
||||
}
|
||||
|
||||
.posture-container {
|
||||
position: relative;
|
||||
height: calc(100% - 40px);
|
||||
}
|
||||
|
||||
.grid-background {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image: linear-gradient(to right, #333333 1px, transparent 1px), linear-gradient(to bottom, #333333 1px, transparent 1px);
|
||||
background-size: 50px 50px;
|
||||
}
|
||||
|
||||
.posture-heatmap {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
max-width: 90%;
|
||||
max-height: 90%;
|
||||
}
|
||||
|
||||
/* 头部姿态模块 */
|
||||
.head-posture {
|
||||
grid-column: 2 / 3;
|
||||
grid-row: 1 / 2;
|
||||
}
|
||||
|
||||
.gauge-group {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.gauge-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.gauge {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
--el-gauge-label-color: #FFFFFF;
|
||||
}
|
||||
|
||||
.gauge-desc {
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
margin-top: 8px;
|
||||
color: #CCCCCC;
|
||||
}
|
||||
|
||||
.history-table {
|
||||
background-color: #333333 !important;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.el-table th {
|
||||
background-color: #333333 !important;
|
||||
color: #FFFFFF !important;
|
||||
border-bottom: 1px solid #444444 !important;
|
||||
}
|
||||
|
||||
.el-table td {
|
||||
background-color: #333333 !important;
|
||||
color: #FFFFFF !important;
|
||||
border-bottom: 1px solid #444444 !important;
|
||||
}
|
||||
|
||||
.zero-btn {
|
||||
float: right;
|
||||
padding: 6px 16px;
|
||||
}
|
||||
|
||||
/* 基础信息模块 */
|
||||
.basic-info {
|
||||
grid-column: 2 / 3;
|
||||
grid-row: 2 / 3;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.info-item label {
|
||||
color: #409EFF;
|
||||
margin-right: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 足部压力模块 */
|
||||
.foot-pressure {
|
||||
grid-column: 2 / 3;
|
||||
grid-row: 2 / 3;
|
||||
}
|
||||
|
||||
.foot-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.foot-stats {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
color: #CCCCCC;
|
||||
}
|
||||
|
||||
.foot-graph {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.foot-img {
|
||||
width: 140px;
|
||||
height: 180px;
|
||||
margin: 0 15px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.foot-divider {
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
background-color: #444444;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.total-pressure {
|
||||
font-size: 14px;
|
||||
color: #CCCCCC;
|
||||
}
|
||||
|
||||
/* 视频模块 */
|
||||
.video-module {
|
||||
grid-column: 1 / 2;
|
||||
grid-row: 2 / 3;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.video-container {
|
||||
position: relative;
|
||||
height: calc(100% - 40px);
|
||||
}
|
||||
|
||||
.video-preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.video-time {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
/* Element Plus 样式覆盖 */
|
||||
.el-card__header {
|
||||
border-bottom: 1px solid #444444 !important;
|
||||
padding: 12px 20px !important;
|
||||
}
|
||||
|
||||
.el-card__body {
|
||||
padding: 20px !important;
|
||||
}
|
||||
|
||||
.el-button--primary:hover {
|
||||
--el-button-bg-color: #A325B0 !important;
|
||||
--el-button-border-color: #A325B0 !important;
|
||||
}
|
||||
</style>
|
||||
307
frontend/src/renderer/src/views/Login.vue
Normal file
307
frontend/src/renderer/src/views/Login.vue
Normal file
@ -0,0 +1,307 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<!-- 科幻网格地板(3D效果) -->
|
||||
<div class="grid-floor"></div>
|
||||
|
||||
<!-- 两侧光柱特效 -->
|
||||
<div class="light-pillar left"></div>
|
||||
<div class="light-pillar right"></div>
|
||||
|
||||
<!-- 页面主内容 -->
|
||||
<div class="login-content">
|
||||
<!-- 系统标题 -->
|
||||
<h1 class="system-title">平衡体态检测系统</h1>
|
||||
|
||||
<!-- 登录卡片 -->
|
||||
<el-card class="login-card">
|
||||
<div class="card-header">登录</div>
|
||||
|
||||
<el-form class="login-form">
|
||||
<!-- 账号输入框 -->
|
||||
<el-form-item>
|
||||
<el-input
|
||||
v-model="form.account"
|
||||
placeholder="请输入账号"
|
||||
prefix-icon="User"
|
||||
class="custom-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 密码输入框(带显示切换) -->
|
||||
<el-form-item>
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
:type="passwordVisible ? 'text' : 'password'"
|
||||
placeholder="请输入密码"
|
||||
prefix-icon="Lock"
|
||||
class="custom-input"
|
||||
>
|
||||
<template #suffix>
|
||||
<el-icon
|
||||
:icon="passwordVisible ? Hide : View"
|
||||
class="password-icon"
|
||||
@click="passwordVisible = !passwordVisible"
|
||||
/>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 记住密码 & 忘记密码 -->
|
||||
<div class="form-footer">
|
||||
<el-checkbox v-model="form.remember" class="remember-checkbox">记住密码</el-checkbox>
|
||||
<a href="#" class="forgot-link" @click="handleForgotPassword">忘记密码?</a>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="button-group">
|
||||
<el-button type="primary" class="login-btn" @click="handleLogin" :loading="isLoading">登录</el-button>
|
||||
<el-button class="register-btn" @click="handleRegister">注册</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAuthStore } from '../stores'
|
||||
import { User, Lock, View, Hide } from '@element-plus/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 表单数据
|
||||
const form = ref({
|
||||
account: '',
|
||||
password: '',
|
||||
remember: false
|
||||
})
|
||||
|
||||
// 密码可见性控制
|
||||
const passwordVisible = ref(false)
|
||||
|
||||
// 加载状态
|
||||
const isLoading = ref(false)
|
||||
|
||||
// 登录处理
|
||||
const handleLogin = async () => {
|
||||
if (!form.value.account || !form.value.password) {
|
||||
ElMessage.warning('请输入账号和密码')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isLoading.value = true
|
||||
const result = await authStore.login({
|
||||
username: form.value.account,
|
||||
password: form.value.password,
|
||||
remember: form.value.remember
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
ElMessage.success('登录成功')
|
||||
router.push('/detection/1')
|
||||
} else {
|
||||
ElMessage.error(result.error || '登录失败')
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('登录失败:' + (error.message || '未知错误'))
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 注册处理
|
||||
const handleRegister = () => {
|
||||
router.push('/register')
|
||||
}
|
||||
|
||||
// 忘记密码处理
|
||||
const handleForgotPassword = () => {
|
||||
router.push('/forgot-password')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 页面全局样式 */
|
||||
.login-page {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 3D网格地板(核心科幻效果) */
|
||||
.grid-floor {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 200%;
|
||||
height: 60vh;
|
||||
background-image:
|
||||
linear-gradient(to right, #00ffff 1px, transparent 1px),
|
||||
linear-gradient(to bottom, #00ffff 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
transform:
|
||||
perspective(1200px)
|
||||
rotateX(65deg)
|
||||
translate(-25%, 0);
|
||||
transform-origin: bottom center;
|
||||
opacity: 0.15;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 两侧光柱特效 */
|
||||
.light-pillar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
height: 220px;
|
||||
background: linear-gradient(to top, #00ffff, transparent);
|
||||
box-shadow: 0 0 12px rgba(0, 255, 255, 0.8);
|
||||
animation: pulse 3s infinite ease-in-out;
|
||||
}
|
||||
.light-pillar.left { left: 32%; }
|
||||
.light-pillar.right { right: 32%; }
|
||||
|
||||
/* 光柱呼吸动画 */
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.6; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* 主内容容器(垂直居中) */
|
||||
.login-content {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
/* 系统标题 */
|
||||
.system-title {
|
||||
font-size: 2rem;
|
||||
color: #00ffff;
|
||||
text-shadow: 0 0 15px rgba(0, 255, 255, 0.8);
|
||||
margin-bottom: 2.5rem;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
/* 登录卡片 */
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
background-color: #003366 !important;
|
||||
border: none !important;
|
||||
border-radius: 12px !important;
|
||||
box-shadow: 0 0 30px rgba(0, 255, 255, 0.2);
|
||||
padding: 30px 25px !important;
|
||||
}
|
||||
|
||||
/* 卡片头部标题 */
|
||||
.card-header {
|
||||
font-size: 1.4rem;
|
||||
color: #00ffff;
|
||||
text-align: center;
|
||||
margin-bottom: 25px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 登录表单 */
|
||||
.login-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 自定义输入框 */
|
||||
.custom-input {
|
||||
background-color: #004080 !important;
|
||||
border-color: #00ffff !important;
|
||||
color: #fff !important;
|
||||
border-radius: 6px !important;
|
||||
}
|
||||
.custom-input::placeholder {
|
||||
color: #aaa !important;
|
||||
}
|
||||
.custom-input .el-input__icon {
|
||||
color: #00ffff !important;
|
||||
}
|
||||
|
||||
/* 密码显示图标 */
|
||||
.password-icon {
|
||||
color: #00ffff !important;
|
||||
cursor: pointer;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
/* 表单底部(记住密码+忘记密码) */
|
||||
.form-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: 10px 0 25px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* 记住密码复选框 */
|
||||
.remember-checkbox {
|
||||
color: #fff !important;
|
||||
}
|
||||
.remember-checkbox .el-checkbox__input.is-checked .el-checkbox__inner {
|
||||
background-color: #00ffff !important;
|
||||
border-color: #00ffff !important;
|
||||
}
|
||||
.remember-checkbox .el-checkbox__inner {
|
||||
border-color: #00ffff !important;
|
||||
}
|
||||
|
||||
/* 忘记密码链接 */
|
||||
.forgot-link {
|
||||
color: #00ffff;
|
||||
text-decoration: none;
|
||||
}
|
||||
.forgot-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 按钮组 */
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
/* 登录按钮 */
|
||||
.login-btn {
|
||||
flex: 1;
|
||||
background-color: #00ffff !important;
|
||||
border-color: #00ffff !important;
|
||||
color: #003366 !important;
|
||||
font-weight: 500 !important;
|
||||
border-radius: 6px !important;
|
||||
}
|
||||
.login-btn:hover {
|
||||
background-color: #00e6e6 !important;
|
||||
border-color: #00e6e6 !important;
|
||||
}
|
||||
|
||||
/* 注册按钮 */
|
||||
.register-btn {
|
||||
flex: 1;
|
||||
background-color: transparent !important;
|
||||
border-color: #00ffff !important;
|
||||
color: #00ffff !important;
|
||||
font-weight: 500 !important;
|
||||
border-radius: 6px !important;
|
||||
}
|
||||
.register-btn:hover {
|
||||
background-color: #004080 !important;
|
||||
}
|
||||
</style>
|
||||
514
frontend/src/renderer/src/views/PatientCreate.vue
Normal file
514
frontend/src/renderer/src/views/PatientCreate.vue
Normal file
@ -0,0 +1,514 @@
|
||||
<template>
|
||||
<div class="patient-create-container">
|
||||
<!-- 顶部导航 -->
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<el-button type="text" @click="goBack" class="back-btn">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
返回
|
||||
</el-button>
|
||||
<h1 class="page-title">新建患者档案</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表单内容 -->
|
||||
<div class="form-container">
|
||||
<el-form
|
||||
ref="patientFormRef"
|
||||
:model="patientForm"
|
||||
:rules="formRules"
|
||||
label-width="120px"
|
||||
class="patient-form"
|
||||
>
|
||||
<div class="form-section">
|
||||
<h3 class="section-title">基本信息</h3>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="姓名" prop="name" required>
|
||||
<el-input
|
||||
v-model="patientForm.name"
|
||||
placeholder="请输入患者姓名"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="性别" prop="gender" required>
|
||||
<el-radio-group v-model="patientForm.gender">
|
||||
<el-radio label="男">男</el-radio>
|
||||
<el-radio label="女">女</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="出生日期" prop="birthDate" required>
|
||||
<el-date-picker
|
||||
v-model="patientForm.birthDate"
|
||||
type="date"
|
||||
placeholder="选择出生日期"
|
||||
style="width: 100%"
|
||||
@change="calculateAge"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="年龄">
|
||||
<el-input
|
||||
v-model="calculatedAge"
|
||||
placeholder="自动计算"
|
||||
readonly
|
||||
suffix-icon="Calendar"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="身高" prop="height" required>
|
||||
<el-input
|
||||
v-model="patientForm.height"
|
||||
placeholder="请输入身高"
|
||||
clearable
|
||||
>
|
||||
<template #suffix>cm</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="体重" prop="weight" required>
|
||||
<el-input
|
||||
v-model="patientForm.weight"
|
||||
placeholder="请输入体重"
|
||||
clearable
|
||||
>
|
||||
<template #suffix>kg</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<h3 class="section-title">联系信息</h3>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="联系电话" prop="phone" required>
|
||||
<el-input
|
||||
v-model="patientForm.phone"
|
||||
placeholder="请输入联系电话"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="紧急联系人">
|
||||
<el-input
|
||||
v-model="patientForm.emergencyContact"
|
||||
placeholder="请输入紧急联系人"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="紧急联系电话">
|
||||
<el-input
|
||||
v-model="patientForm.emergencyPhone"
|
||||
placeholder="请输入紧急联系电话"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="身份证号">
|
||||
<el-input
|
||||
v-model="patientForm.idCard"
|
||||
placeholder="请输入身份证号"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="地址">
|
||||
<el-input
|
||||
v-model="patientForm.address"
|
||||
placeholder="请输入详细地址"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<h3 class="section-title">医疗信息</h3>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="主治医生">
|
||||
<el-input
|
||||
v-model="patientForm.doctor"
|
||||
placeholder="请输入主治医生"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="病历号">
|
||||
<el-input
|
||||
v-model="patientForm.medicalRecordNumber"
|
||||
placeholder="请输入病历号"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="既往病史">
|
||||
<el-input
|
||||
v-model="patientForm.medicalHistory"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入既往病史"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="过敏史">
|
||||
<el-input
|
||||
v-model="patientForm.allergies"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="请输入过敏史"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="备注">
|
||||
<el-input
|
||||
v-model="patientForm.notes"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入其他备注信息"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作按钮 -->
|
||||
<div class="footer-actions">
|
||||
<el-button size="large" @click="handleCancel">退出</el-button>
|
||||
<el-button type="primary" size="large" :loading="saveLoading" @click="handleSave">
|
||||
保存
|
||||
</el-button>
|
||||
<el-button type="success" size="large" :loading="saveAndDetectLoading" @click="handleSaveAndDetect">
|
||||
保存并开始检测
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { patientAPI } from '../services/api.js'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 表单引用
|
||||
const patientFormRef = ref()
|
||||
|
||||
// 加载状态
|
||||
const saveLoading = ref(false)
|
||||
const saveAndDetectLoading = ref(false)
|
||||
|
||||
// 表单数据
|
||||
const patientForm = reactive({
|
||||
name: '',
|
||||
gender: '',
|
||||
birthDate: '',
|
||||
height: '',
|
||||
weight: '',
|
||||
phone: '',
|
||||
emergencyContact: '',
|
||||
emergencyPhone: '',
|
||||
idCard: '',
|
||||
address: '',
|
||||
doctor: '',
|
||||
medicalRecordNumber: '',
|
||||
medicalHistory: '',
|
||||
allergies: '',
|
||||
notes: ''
|
||||
})
|
||||
|
||||
// 计算年龄
|
||||
const calculatedAge = computed(() => {
|
||||
if (!patientForm.birthDate) return ''
|
||||
const today = new Date()
|
||||
const birthDate = new Date(patientForm.birthDate)
|
||||
let age = today.getFullYear() - birthDate.getFullYear()
|
||||
const monthDiff = today.getMonth() - birthDate.getMonth()
|
||||
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
|
||||
age--
|
||||
}
|
||||
return age + '岁'
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const formRules = {
|
||||
name: [
|
||||
{ required: true, message: '请输入患者姓名', trigger: 'blur' },
|
||||
{ min: 2, max: 20, message: '姓名长度在 2 到 20 个字符', trigger: 'blur' }
|
||||
],
|
||||
gender: [
|
||||
{ required: true, message: '请选择性别', trigger: 'change' }
|
||||
],
|
||||
birthDate: [
|
||||
{ required: true, message: '请选择出生日期', trigger: 'change' }
|
||||
],
|
||||
height: [
|
||||
{ required: true, message: '请输入身高', trigger: 'blur' },
|
||||
{ pattern: /^\d+(\.\d+)?$/, message: '请输入有效的身高', trigger: 'blur' }
|
||||
],
|
||||
weight: [
|
||||
{ required: true, message: '请输入体重', trigger: 'blur' },
|
||||
{ pattern: /^\d+(\.\d+)?$/, message: '请输入有效的体重', trigger: 'blur' }
|
||||
],
|
||||
phone: [
|
||||
{ required: true, message: '请输入联系电话', trigger: 'blur' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 方法
|
||||
const goBack = () => {
|
||||
router.go(-1)
|
||||
}
|
||||
|
||||
const calculateAge = () => {
|
||||
// 年龄通过计算属性自动计算
|
||||
}
|
||||
|
||||
const handleCancel = async () => {
|
||||
// 检查是否有未保存的数据
|
||||
const hasData = Object.values(patientForm).some(value => value !== '')
|
||||
|
||||
if (hasData) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'您有未保存的数据,确定要退出吗?',
|
||||
'提示',
|
||||
{
|
||||
confirmButtonText: '确定退出',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
router.go(-1)
|
||||
} catch {
|
||||
// 用户取消
|
||||
}
|
||||
} else {
|
||||
router.go(-1)
|
||||
}
|
||||
}
|
||||
|
||||
const validateForm = async () => {
|
||||
try {
|
||||
await patientFormRef.value.validate()
|
||||
return true
|
||||
} catch (error) {
|
||||
ElMessage.error('请完善必填信息')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const savePatient = async () => {
|
||||
const patientData = {
|
||||
...patientForm,
|
||||
age: calculatedAge.value.replace('岁', ''),
|
||||
createdAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await patientAPI.create(patientData)
|
||||
if (response.success) {
|
||||
return response.data
|
||||
} else {
|
||||
throw new Error(response.message || '保存失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存患者信息失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!(await validateForm())) return
|
||||
|
||||
saveLoading.value = true
|
||||
try {
|
||||
await savePatient()
|
||||
ElMessage.success('患者档案保存成功')
|
||||
router.push('/dashboard')
|
||||
} catch (error) {
|
||||
ElMessage.error('保存失败:' + error.message)
|
||||
} finally {
|
||||
saveLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveAndDetect = async () => {
|
||||
if (!(await validateForm())) return
|
||||
|
||||
saveAndDetectLoading.value = true
|
||||
try {
|
||||
const patient = await savePatient()
|
||||
ElMessage.success('患者档案保存成功,即将开始检测')
|
||||
router.push(`/detection/${patient.id}`)
|
||||
} catch (error) {
|
||||
ElMessage.error('保存失败:' + error.message)
|
||||
} finally {
|
||||
saveAndDetectLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.patient-create-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.header {
|
||||
height: 60px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.patient-form {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.form-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
margin: 0 0 20px 0;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.footer-actions {
|
||||
height: 80px;
|
||||
background: #fff;
|
||||
border-top: 1px solid #e4e7ed;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
padding: 0 20px;
|
||||
box-shadow: 0 -2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.footer-actions .el-button {
|
||||
min-width: 120px;
|
||||
height: 40px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.el-form-item__label) {
|
||||
font-weight: 500;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
:deep(.el-form-item__content) {
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
:deep(.el-input__wrapper) {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
:deep(.el-textarea__inner) {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
:deep(.el-date-editor) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.el-radio-group) {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
:deep(.el-radio) {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
/* 必填项标识 */
|
||||
:deep(.el-form-item.is-required .el-form-item__label::before) {
|
||||
content: '*';
|
||||
color: #f56c6c;
|
||||
margin-right: 4px;
|
||||
}
|
||||
</style>
|
||||
763
frontend/src/renderer/src/views/PatientProfile.vue
Normal file
763
frontend/src/renderer/src/views/PatientProfile.vue
Normal file
@ -0,0 +1,763 @@
|
||||
<template>
|
||||
<div class="patient-profile-container">
|
||||
<!-- 顶部导航 -->
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<el-button type="text" @click="goBack" class="back-btn">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
返回
|
||||
</el-button>
|
||||
<h1 class="page-title">患者档案</h1>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-button type="primary" @click="startDetection">
|
||||
<el-icon><Monitor /></el-icon>
|
||||
开始检测
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-content" v-if="patient">
|
||||
<!-- 患者基本信息 -->
|
||||
<div class="patient-info-card">
|
||||
<div class="card-header">
|
||||
<h2>基本信息</h2>
|
||||
<el-button type="text" @click="editPatient">
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<label>姓名:</label>
|
||||
<span>{{ patient.name }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>性别:</label>
|
||||
<span>{{ patient.gender }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>年龄:</label>
|
||||
<span>{{ patient.age }}岁</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>身高:</label>
|
||||
<span>{{ patient.height }}cm</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>体重:</label>
|
||||
<span>{{ patient.weight }}kg</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>联系电话:</label>
|
||||
<span>{{ patient.phone }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>主治医生:</label>
|
||||
<span>{{ patient.doctor || '未设置' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>创建时间:</label>
|
||||
<span>{{ formatDate(patient.createdAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 检测记录 -->
|
||||
<div class="detection-records-card">
|
||||
<div class="card-header">
|
||||
<h2>检测记录</h2>
|
||||
<div class="header-actions">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索记录"
|
||||
prefix-icon="Search"
|
||||
clearable
|
||||
style="width: 200px; margin-right: 10px;"
|
||||
/>
|
||||
<el-button type="primary" @click="startDetection">
|
||||
新建检测
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="records-list" v-if="filteredRecords.length > 0">
|
||||
<div
|
||||
v-for="record in filteredRecords"
|
||||
:key="record.id"
|
||||
class="record-item"
|
||||
@click="viewRecord(record)"
|
||||
>
|
||||
<div class="record-header">
|
||||
<div class="record-title">
|
||||
<h4>检测记录 #{{ record.id }}</h4>
|
||||
<el-tag :type="getRecordStatusType(record.status)">{{ record.status }}</el-tag>
|
||||
</div>
|
||||
<div class="record-time">{{ formatDate(record.createdAt) }}</div>
|
||||
</div>
|
||||
<div class="record-content">
|
||||
<div class="record-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">检测时长:</span>
|
||||
<span class="stat-value">{{ formatDuration(record.duration) }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">截图数量:</span>
|
||||
<span class="stat-value">{{ record.screenshots?.length || 0 }}张</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">检测医生:</span>
|
||||
<span class="stat-value">{{ record.doctor || '未记录' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="record-actions">
|
||||
<el-button type="text" @click.stop="playVideo(record)">
|
||||
<el-icon><VideoPlay /></el-icon>
|
||||
回放视频
|
||||
</el-button>
|
||||
<el-button type="text" @click.stop="viewScreenshots(record)">
|
||||
<el-icon><Picture /></el-icon>
|
||||
查看截图
|
||||
</el-button>
|
||||
<el-button type="text" @click.stop="exportReport(record)">
|
||||
<el-icon><Download /></el-icon>
|
||||
导出报告
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-empty v-else description="暂无检测记录">
|
||||
<el-button type="primary" @click="startDetection">开始首次检测</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-else class="loading-container">
|
||||
<el-loading-service lock="true" text="加载患者信息中..." />
|
||||
</div>
|
||||
|
||||
<!-- 视频播放对话框 -->
|
||||
<el-dialog
|
||||
v-model="videoDialogVisible"
|
||||
title="检测视频回放"
|
||||
width="80%"
|
||||
:before-close="closeVideoDialog"
|
||||
>
|
||||
<div class="video-container" v-if="currentVideo">
|
||||
<video
|
||||
ref="videoPlayerRef"
|
||||
:src="currentVideo.url"
|
||||
controls
|
||||
width="100%"
|
||||
height="400"
|
||||
>
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
<div class="video-info">
|
||||
<p><strong>检测时间:</strong>{{ formatDate(currentVideo.createdAt) }}</p>
|
||||
<p><strong>检测时长:</strong>{{ formatDuration(currentVideo.duration) }}</p>
|
||||
<p><strong>文件大小:</strong>{{ formatFileSize(currentVideo.fileSize) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 截图查看对话框 -->
|
||||
<el-dialog
|
||||
v-model="screenshotDialogVisible"
|
||||
title="检测截图"
|
||||
width="90%"
|
||||
:before-close="closeScreenshotDialog"
|
||||
>
|
||||
<div class="screenshot-container" v-if="currentScreenshots.length > 0">
|
||||
<div class="screenshot-grid">
|
||||
<div
|
||||
v-for="(screenshot, index) in currentScreenshots"
|
||||
:key="screenshot.id"
|
||||
class="screenshot-item"
|
||||
@click="previewScreenshotHandler(screenshot, index)"
|
||||
>
|
||||
<img :src="screenshot.thumbnail" :alt="`截图${index + 1}`" />
|
||||
<div class="screenshot-overlay">
|
||||
<div class="screenshot-time">{{ formatTime(screenshot.timestamp) }}</div>
|
||||
<div class="screenshot-actions">
|
||||
<el-button type="primary" size="small" @click.stop="downloadScreenshot(screenshot)">
|
||||
<el-icon><Download /></el-icon>
|
||||
</el-button>
|
||||
<el-button type="danger" size="small" @click.stop="deleteScreenshot(screenshot)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无截图" />
|
||||
</el-dialog>
|
||||
|
||||
<!-- 截图预览对话框 -->
|
||||
<el-dialog
|
||||
v-model="previewDialogVisible"
|
||||
title="截图预览"
|
||||
width="70%"
|
||||
:before-close="closePreviewDialog"
|
||||
>
|
||||
<div class="preview-container" v-if="previewScreenshot">
|
||||
<img :src="previewScreenshot.url" alt="截图预览" class="preview-image" />
|
||||
<div class="preview-info">
|
||||
<p><strong>截图时间:</strong>{{ formatDate(previewScreenshot.timestamp) }}</p>
|
||||
<p><strong>检测数据:</strong></p>
|
||||
<div class="data-display" v-if="previewScreenshot.data">
|
||||
<div class="data-item">
|
||||
<span>头部姿态:</span>
|
||||
<span>Pitch: {{ previewScreenshot.data.pitch }}°, Yaw: {{ previewScreenshot.data.yaw }}°, Roll: {{ previewScreenshot.data.roll }}°</span>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<span>足底压力:</span>
|
||||
<span>左足: {{ previewScreenshot.data.leftPressure }}%, 右足: {{ previewScreenshot.data.rightPressure }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { patientAPI, detectionAPI } from '../services/api.js'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
// 响应式数据
|
||||
const patient = ref(null)
|
||||
const detectionRecords = ref([])
|
||||
const searchKeyword = ref('')
|
||||
const videoDialogVisible = ref(false)
|
||||
const screenshotDialogVisible = ref(false)
|
||||
const previewDialogVisible = ref(false)
|
||||
const currentVideo = ref(null)
|
||||
const currentScreenshots = ref([])
|
||||
const previewScreenshot = ref(null)
|
||||
const videoPlayerRef = ref()
|
||||
|
||||
// 计算属性
|
||||
const filteredRecords = computed(() => {
|
||||
if (!searchKeyword.value) {
|
||||
return detectionRecords.value
|
||||
}
|
||||
return detectionRecords.value.filter(record =>
|
||||
record.id.toString().includes(searchKeyword.value) ||
|
||||
record.doctor?.includes(searchKeyword.value) ||
|
||||
formatDate(record.createdAt).includes(searchKeyword.value)
|
||||
)
|
||||
})
|
||||
|
||||
// 方法
|
||||
const goBack = () => {
|
||||
router.go(-1)
|
||||
}
|
||||
|
||||
const editPatient = () => {
|
||||
router.push(`/patient/edit/${patient.value.id}`)
|
||||
}
|
||||
|
||||
const startDetection = () => {
|
||||
router.push(`/detection/${patient.value.id}`)
|
||||
}
|
||||
|
||||
const viewRecord = (record) => {
|
||||
router.push(`/detection/record/${record.id}`)
|
||||
}
|
||||
|
||||
const playVideo = (record) => {
|
||||
currentVideo.value = {
|
||||
url: record.videoUrl,
|
||||
createdAt: record.createdAt,
|
||||
duration: record.duration,
|
||||
fileSize: record.videoFileSize
|
||||
}
|
||||
videoDialogVisible.value = true
|
||||
}
|
||||
|
||||
const viewScreenshots = (record) => {
|
||||
currentScreenshots.value = record.screenshots || []
|
||||
screenshotDialogVisible.value = true
|
||||
}
|
||||
|
||||
const previewScreenshotHandler = (screenshot, index) => {
|
||||
previewScreenshot.value = screenshot
|
||||
previewDialogVisible.value = true
|
||||
}
|
||||
|
||||
const downloadScreenshot = (screenshot) => {
|
||||
// 下载截图逻辑
|
||||
const link = document.createElement('a')
|
||||
link.href = screenshot.url
|
||||
link.download = `screenshot_${screenshot.id}.png`
|
||||
link.click()
|
||||
}
|
||||
|
||||
const deleteScreenshot = async (screenshot) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除这张截图吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
// 删除截图逻辑
|
||||
ElMessage.success('截图已删除')
|
||||
// 重新加载截图列表
|
||||
loadDetectionRecords()
|
||||
} catch {
|
||||
// 用户取消
|
||||
}
|
||||
}
|
||||
|
||||
const exportReport = async (record) => {
|
||||
try {
|
||||
ElMessage.info('正在生成报告...')
|
||||
// 导出报告逻辑
|
||||
const response = await detectionAPI.exportReport(record.id)
|
||||
if (response.success) {
|
||||
ElMessage.success('报告导出成功')
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('报告导出失败')
|
||||
}
|
||||
}
|
||||
|
||||
const closeVideoDialog = () => {
|
||||
if (videoPlayerRef.value) {
|
||||
videoPlayerRef.value.pause()
|
||||
}
|
||||
videoDialogVisible.value = false
|
||||
currentVideo.value = null
|
||||
}
|
||||
|
||||
const closeScreenshotDialog = () => {
|
||||
screenshotDialogVisible.value = false
|
||||
currentScreenshots.value = []
|
||||
}
|
||||
|
||||
const closePreviewDialog = () => {
|
||||
previewDialogVisible.value = false
|
||||
previewScreenshot.value = null
|
||||
}
|
||||
|
||||
const getRecordStatusType = (status) => {
|
||||
switch (status) {
|
||||
case '已完成': return 'success'
|
||||
case '进行中': return 'warning'
|
||||
case '已中断': return 'danger'
|
||||
default: return 'info'
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (date) => {
|
||||
return new Date(date).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
const formatTime = (timestamp) => {
|
||||
return new Date(timestamp).toLocaleTimeString('zh-CN')
|
||||
}
|
||||
|
||||
const formatDuration = (seconds) => {
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainingSeconds = seconds % 60
|
||||
return `${minutes}分${remainingSeconds}秒`
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes) => {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const loadPatientInfo = async () => {
|
||||
try {
|
||||
const patientId = route.params.id
|
||||
const response = await patientAPI.getById(patientId)
|
||||
if (response.success) {
|
||||
patient.value = response.data
|
||||
} else {
|
||||
throw new Error(response.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载患者信息失败:', error)
|
||||
// 模拟数据
|
||||
patient.value = {
|
||||
id: route.params.id,
|
||||
name: '张三',
|
||||
gender: '男',
|
||||
age: 45,
|
||||
height: 175,
|
||||
weight: 70,
|
||||
phone: '13800138001',
|
||||
doctor: '李医生',
|
||||
createdAt: '2024-01-15T10:30:00Z'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loadDetectionRecords = async () => {
|
||||
try {
|
||||
const patientId = route.params.id
|
||||
const response = await detectionAPI.getByPatientId(patientId)
|
||||
if (response.success) {
|
||||
detectionRecords.value = response.data
|
||||
} else {
|
||||
throw new Error(response.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载检测记录失败:', error)
|
||||
// 模拟数据
|
||||
detectionRecords.value = [
|
||||
{
|
||||
id: 1,
|
||||
patientId: route.params.id,
|
||||
status: '已完成',
|
||||
duration: 300,
|
||||
doctor: '李医生',
|
||||
createdAt: '2024-01-20T14:20:00Z',
|
||||
videoUrl: '/videos/detection_1.mp4',
|
||||
videoFileSize: 52428800,
|
||||
screenshots: [
|
||||
{
|
||||
id: 1,
|
||||
timestamp: '2024-01-20T14:22:30Z',
|
||||
thumbnail: '/screenshots/thumb_1.jpg',
|
||||
url: '/screenshots/screenshot_1.png',
|
||||
data: {
|
||||
pitch: 2.5,
|
||||
yaw: -1.2,
|
||||
roll: 0.8,
|
||||
leftPressure: 45,
|
||||
rightPressure: 55
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
timestamp: '2024-01-20T14:24:15Z',
|
||||
thumbnail: '/screenshots/thumb_2.jpg',
|
||||
url: '/screenshots/screenshot_2.png',
|
||||
data: {
|
||||
pitch: 3.1,
|
||||
yaw: -0.8,
|
||||
roll: 1.2,
|
||||
leftPressure: 48,
|
||||
rightPressure: 52
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
patientId: route.params.id,
|
||||
status: '已完成',
|
||||
duration: 240,
|
||||
doctor: '王医生',
|
||||
createdAt: '2024-01-18T11:30:00Z',
|
||||
videoUrl: '/videos/detection_2.mp4',
|
||||
videoFileSize: 41943040,
|
||||
screenshots: []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
loadPatientInfo()
|
||||
loadDetectionRecords()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.patient-profile-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.header {
|
||||
height: 60px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.patient-info-card,
|
||||
.detection-records-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.card-header h2 {
|
||||
margin: 0;
|
||||
color: #2c3e50;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.info-item label {
|
||||
font-weight: 500;
|
||||
color: #909399;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.info-item span {
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.records-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.record-item {
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.record-item:hover {
|
||||
border-color: #409eff;
|
||||
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.2);
|
||||
}
|
||||
|
||||
.record-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.record-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.record-title h4 {
|
||||
margin: 0;
|
||||
color: #2c3e50;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.record-time {
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.record-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.record-stats {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 14px;
|
||||
color: #2c3e50;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.record-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.video-container {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.video-info {
|
||||
margin-top: 15px;
|
||||
text-align: left;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.screenshot-container {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.screenshot-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.screenshot-item {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.screenshot-item:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.screenshot-item img {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.screenshot-overlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
|
||||
color: white;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.screenshot-time {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.screenshot-actions {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
max-width: 100%;
|
||||
max-height: 60vh;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.preview-info {
|
||||
margin-top: 20px;
|
||||
text-align: left;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.data-display {
|
||||
margin-top: 10px;
|
||||
padding: 15px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.data-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.data-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
1483
frontend/src/renderer/src/views/Recording.vue
Normal file
1483
frontend/src/renderer/src/views/Recording.vue
Normal file
File diff suppressed because it is too large
Load Diff
26
frontend/src/renderer/vite.config.js
Normal file
26
frontend/src/renderer/vite.config.js
Normal file
@ -0,0 +1,26 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import { resolve } from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
base: './',
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
assetsDir: 'assets',
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: resolve(__dirname, 'index.html')
|
||||
}
|
||||
}
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
host: '0.0.0.0'
|
||||
}
|
||||
});
|
||||
205
install.bat
Normal file
205
install.bat
Normal file
@ -0,0 +1,205 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
echo ====================================
|
||||
echo 身体平衡评估系统 - 安装脚本
|
||||
echo ====================================
|
||||
echo.
|
||||
echo 此脚本将帮助您安装所有必要的依赖和配置环境
|
||||
echo.
|
||||
|
||||
:: 检查管理员权限(可选)
|
||||
net session >nul 2>&1
|
||||
if %errorlevel% == 0 (
|
||||
echo [信息] 检测到管理员权限
|
||||
) else (
|
||||
echo [警告] 未检测到管理员权限,某些操作可能失败
|
||||
)
|
||||
echo.
|
||||
|
||||
:: 检查Python
|
||||
echo [步骤 1/6] 检查Python环境...
|
||||
python --version >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo [错误] 未找到Python
|
||||
echo [提示] 请从 https://www.python.org/downloads/ 下载并安装Python 3.8或更高版本
|
||||
echo [提示] 安装时请勾选 "Add Python to PATH"
|
||||
pause
|
||||
exit /b 1
|
||||
) else (
|
||||
for /f "tokens=*" %%i in ('python --version') do echo [成功] 找到 %%i
|
||||
)
|
||||
|
||||
:: 检查Python版本
|
||||
for /f "tokens=2 delims= " %%i in ('python --version') do set python_version=%%i
|
||||
for /f "tokens=1,2 delims=." %%a in ("%python_version%") do (
|
||||
set major=%%a
|
||||
set minor=%%b
|
||||
)
|
||||
if %major% lss 3 (
|
||||
echo [错误] Python版本过低,需要3.8或更高版本
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
if %major% equ 3 if %minor% lss 8 (
|
||||
echo [错误] Python版本过低,需要3.8或更高版本
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo.
|
||||
|
||||
:: 检查Node.js
|
||||
echo [步骤 2/6] 检查Node.js环境...
|
||||
node --version >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo [错误] 未找到Node.js
|
||||
echo [提示] 请从 https://nodejs.org/ 下载并安装Node.js 16.0或更高版本
|
||||
pause
|
||||
exit /b 1
|
||||
) else (
|
||||
for /f "tokens=*" %%i in ('node --version') do echo [成功] 找到Node.js %%i
|
||||
for /f "tokens=*" %%i in ('npm --version') do echo [成功] 找到npm %%i
|
||||
)
|
||||
echo.
|
||||
|
||||
:: 创建虚拟环境
|
||||
echo [步骤 3/6] 创建Python虚拟环境...
|
||||
if exist "backend\venv" (
|
||||
echo [信息] 虚拟环境已存在,跳过创建
|
||||
else (
|
||||
echo [信息] 正在创建虚拟环境...
|
||||
python -m venv backend\venv
|
||||
if %errorlevel% neq 0 (
|
||||
echo [错误] 创建虚拟环境失败
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [成功] 虚拟环境创建完成
|
||||
)
|
||||
|
||||
:: 激活虚拟环境
|
||||
echo [信息] 激活虚拟环境...
|
||||
call backend\venv\Scripts\activate.bat
|
||||
if %errorlevel% neq 0 (
|
||||
echo [错误] 激活虚拟环境失败
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [成功] 虚拟环境已激活
|
||||
echo.
|
||||
|
||||
:: 升级pip
|
||||
echo [步骤 4/6] 升级pip...
|
||||
python -m pip install --upgrade pip
|
||||
if %errorlevel% neq 0 (
|
||||
echo [警告] pip升级失败,继续安装
|
||||
) else (
|
||||
echo [成功] pip升级完成
|
||||
)
|
||||
echo.
|
||||
|
||||
:: 安装Python依赖
|
||||
echo [步骤 5/6] 安装Python依赖...
|
||||
if not exist "backend\requirements.txt" (
|
||||
echo [错误] 未找到backend\requirements.txt文件
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [信息] 正在安装Python包,这可能需要几分钟...
|
||||
pip install -r backend\requirements.txt
|
||||
if %errorlevel% neq 0 (
|
||||
echo [错误] 安装Python依赖失败
|
||||
echo [提示] 请检查网络连接和requirements.txt文件
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [成功] Python依赖安装完成
|
||||
echo.
|
||||
|
||||
:: 安装前端依赖
|
||||
echo [步骤 6/6] 安装前端依赖...
|
||||
if exist "frontend\src\renderer\package.json" (
|
||||
cd frontend\src\renderer
|
||||
echo [信息] 正在安装前端包,这可能需要几分钟...
|
||||
npm install
|
||||
if %errorlevel% neq 0 (
|
||||
echo [错误] 安装前端依赖失败
|
||||
echo [提示] 请检查网络连接和package.json文件
|
||||
cd ..\..\..
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [成功] 前端依赖安装完成
|
||||
cd ..\..\..
|
||||
) else (
|
||||
echo [警告] 未找到前端package.json文件,跳过前端依赖安装
|
||||
)
|
||||
echo.
|
||||
|
||||
:: 创建目录结构
|
||||
echo [信息] 创建目录结构...
|
||||
if not exist "data" mkdir data
|
||||
if not exist "data\patients" mkdir data\patients
|
||||
if not exist "data\sessions" mkdir data\sessions
|
||||
if not exist "data\exports" mkdir data\exports
|
||||
if not exist "data\backups" mkdir data\backups
|
||||
if not exist "logs" mkdir logs
|
||||
if not exist "temp" mkdir temp
|
||||
echo [成功] 目录结构创建完成
|
||||
echo.
|
||||
|
||||
:: 检查配置文件
|
||||
echo [信息] 检查配置文件...
|
||||
if exist "config.json" (
|
||||
echo [成功] 配置文件已存在
|
||||
) else (
|
||||
echo [信息] 配置文件已创建,使用默认配置
|
||||
)
|
||||
echo.
|
||||
|
||||
:: 运行测试(可选)
|
||||
echo [信息] 运行基础测试...
|
||||
echo [测试] 检查Python导入...
|
||||
python -c "import sys; print('Python路径:', sys.executable)" 2>nul
|
||||
if %errorlevel% neq 0 (
|
||||
echo [警告] Python测试失败
|
||||
) else (
|
||||
echo [成功] Python测试通过
|
||||
)
|
||||
|
||||
echo [测试] 检查主要依赖...
|
||||
python -c "import flask, numpy, opencv-python, mediapipe; print('主要依赖检查通过')" 2>nul
|
||||
if %errorlevel% neq 0 (
|
||||
echo [警告] 依赖测试失败,某些功能可能不可用
|
||||
) else (
|
||||
echo [成功] 依赖测试通过
|
||||
)
|
||||
echo.
|
||||
|
||||
:: 安装完成
|
||||
echo ====================================
|
||||
echo 安装完成!
|
||||
echo ====================================
|
||||
echo.
|
||||
echo [成功] 所有依赖已安装完成
|
||||
echo [信息] 您现在可以使用以下命令启动应用:
|
||||
echo.
|
||||
echo 开发环境: start_dev.bat
|
||||
echo 生产环境: start_prod.bat
|
||||
echo 或直接运行: python main.py
|
||||
echo.
|
||||
echo [提示] 首次运行建议使用开发环境进行测试
|
||||
echo [提示] 如遇到问题,请查看 README.md 文件
|
||||
echo.
|
||||
|
||||
set /p choice="是否现在启动开发环境?(y/n): "
|
||||
if /i "%choice%"=="y" (
|
||||
echo.
|
||||
echo [信息] 启动开发环境...
|
||||
call start_dev.bat
|
||||
) else (
|
||||
echo.
|
||||
echo [信息] 安装完成,您可以稍后手动启动应用
|
||||
)
|
||||
|
||||
pause
|
||||
119
package.json
Normal file
119
package.json
Normal file
@ -0,0 +1,119 @@
|
||||
{
|
||||
"name": "body-balance-evaluation",
|
||||
"version": "1.0.0",
|
||||
"description": "身体平衡评估系统 - 基于多传感器融合技术的专业平衡能力评估与分析系统",
|
||||
"main": "src/main/main.js",
|
||||
"scripts": {
|
||||
"dev": "electron .",
|
||||
"build": "npm run build:renderer && npm run build:main",
|
||||
"build:renderer": "cd src/renderer && npm run build",
|
||||
"build:main": "electron-builder",
|
||||
"pack": "electron-builder --dir",
|
||||
"dist": "electron-builder",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"start": "electron .",
|
||||
"start:dev": "electron . --mode development",
|
||||
"start:prod": "electron . --mode production",
|
||||
"install:backend": "pip install -r backend/requirements.txt",
|
||||
"install:frontend": "cd src/renderer && npm install",
|
||||
"install:all": "npm run install:backend && npm run install:frontend",
|
||||
"test:backend": "cd backend && python -m pytest tests/ -v",
|
||||
"test:frontend": "cd src/renderer && npm run test",
|
||||
"lint:backend": "cd backend && flake8 . --max-line-length=88 --exclude=venv,__pycache__",
|
||||
"lint:frontend": "cd src/renderer && npm run lint",
|
||||
"format:backend": "cd backend && black . --line-length=88",
|
||||
"format:frontend": "cd src/renderer && npm run format",
|
||||
"clean": "node -e \"const fs = require('fs'); const path = require('path'); ['logs', 'temp', 'node_modules/.cache'].forEach(d => { try { fs.rmSync(d, {recursive: true, force: true}); } catch(e) {} });\"",
|
||||
"clean:data": "node -e \"const fs = require('fs'); try { fs.rmSync('data', {recursive: true, force: true}); } catch(e) {}\"",
|
||||
"backup": "node -e \"const fs = require('fs'); const path = require('path'); const archiver = require('archiver'); const date = new Date().toISOString().slice(0,19).replace(/:/g,'-'); const output = fs.createWriteStream(`backup_${date}_data.zip`); const archive = archiver('zip'); archive.pipe(output); archive.directory('data/', false); archive.finalize();\"",
|
||||
"setup": "npm run install:all && node -e \"const fs = require('fs'); ['data', 'data/patients', 'data/sessions', 'data/exports', 'data/backups', 'logs', 'temp'].forEach(d => fs.mkdirSync(d, {recursive: true}));\"",
|
||||
"check": "node --version && npm --version && electron --version",
|
||||
"test": "npm run test:backend && npm run test:frontend"
|
||||
},
|
||||
"keywords": [
|
||||
"balance",
|
||||
"posture",
|
||||
"assessment",
|
||||
"healthcare",
|
||||
"rehabilitation",
|
||||
"sensors",
|
||||
"computer-vision",
|
||||
"medical-device",
|
||||
"electron",
|
||||
"vue",
|
||||
"mediapipe"
|
||||
],
|
||||
"author": {
|
||||
"name": "zheng shunli",
|
||||
"email": "dev@example.com"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/example/body-balance-evaluation.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/example/body-balance-evaluation/issues"
|
||||
},
|
||||
"homepage": "https://github.com/example/body-balance-evaluation#readme",
|
||||
"engines": {
|
||||
"node": ">=16.0.0",
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^27.0.0",
|
||||
"electron-builder": "latest",
|
||||
"electron-packager": "^17.1.2",
|
||||
"concurrently": "^7.6.0",
|
||||
"cross-env": "^7.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.5.0",
|
||||
"electron-log": "^4.4.8"
|
||||
},
|
||||
"config": {
|
||||
"backend_host": "127.0.0.1",
|
||||
"backend_port": "5000",
|
||||
"frontend_port": "5173"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.bodybalance.evaluation",
|
||||
"productName": "平衡体态检测系统",
|
||||
"electronDist": "D:\\electron-v36.4.0-win32-x64",
|
||||
"electronVersion": "36.4.0",
|
||||
"directories": {
|
||||
"output": "dist"
|
||||
},
|
||||
"files": [
|
||||
"src/main/**/*",
|
||||
"src/renderer/dist/**/*",
|
||||
"backend/dist/**/*",
|
||||
"ffmpeg/**/*",
|
||||
"node_modules/**/*"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "backend/dist",
|
||||
"to": "backend"
|
||||
},
|
||||
{
|
||||
"from": "ffmpeg",
|
||||
"to": "ffmpeg"
|
||||
}
|
||||
],
|
||||
"win": {
|
||||
"target": "nsis",
|
||||
"icon": "assets/icon.ico",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
126
start_dev.bat
Normal file
126
start_dev.bat
Normal file
@ -0,0 +1,126 @@
|
||||
@echo off
|
||||
echo ====================================
|
||||
echo Body Balance Evaluation System - Development Mode
|
||||
echo ====================================
|
||||
echo.
|
||||
|
||||
:: Check Python
|
||||
python --version >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo [Error] Python not found, please install Python 3.8 or higher
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: Check Node.js
|
||||
node --version >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo [Error] Node.js not found, please install Node.js 16.0 or higher
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: Show version info
|
||||
echo [Info] Checking environment versions...
|
||||
for /f "tokens=*" %%i in ('python --version') do echo Python: %%i
|
||||
for /f "tokens=*" %%i in ('node --version') do echo Node.js: %%i
|
||||
for /f "tokens=*" %%i in ('npm --version') do echo npm: %%i
|
||||
echo.
|
||||
|
||||
:: Check virtual environment
|
||||
if not exist "backend\venv" (
|
||||
echo [Info] Creating Python virtual environment...
|
||||
python -m venv backend\venv
|
||||
if %errorlevel% neq 0 (
|
||||
echo [Error] Failed to create virtual environment
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
|
||||
:: Activate virtual environment
|
||||
echo [Info] Activating virtual environment...
|
||||
call backend\venv\Scripts\activate.bat
|
||||
if %errorlevel% neq 0 (
|
||||
echo [Error] Failed to activate virtual environment
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: Install Python dependencies
|
||||
echo [Info] Checking and installing Python dependencies...
|
||||
if not exist "backend\requirements.txt" (
|
||||
echo [Error] requirements.txt file not found
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
pip install -r backend\requirements.txt
|
||||
if %errorlevel% neq 0 (
|
||||
echo [Error] Failed to install Python dependencies
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: Install frontend dependencies
|
||||
echo [Info] Checking and installing frontend dependencies...
|
||||
if exist "frontend\src\renderer\package.json" (
|
||||
cd frontend\src\renderer
|
||||
if not exist "node_modules" (
|
||||
echo [Info] Installing frontend dependencies...
|
||||
npm install
|
||||
if %errorlevel% neq 0 (
|
||||
echo [Error] Failed to install frontend dependencies
|
||||
cd ..\..\..
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
) else (
|
||||
echo [Info] Frontend dependencies already exist, skipping installation
|
||||
)
|
||||
cd ..\..\..
|
||||
) else (
|
||||
echo [Warning] Frontend package.json file not found
|
||||
)
|
||||
|
||||
:: Create necessary directories
|
||||
echo [Info] Creating necessary directories...
|
||||
if not exist "data" mkdir data
|
||||
if not exist "data\patients" mkdir data\patients
|
||||
if not exist "data\sessions" mkdir data\sessions
|
||||
if not exist "data\exports" mkdir data\exports
|
||||
if not exist "data\backups" mkdir data\backups
|
||||
if not exist "logs" mkdir logs
|
||||
if not exist "temp" mkdir temp
|
||||
|
||||
:: Check config file
|
||||
if not exist "config.json" (
|
||||
echo [Warning] config.json configuration file not found
|
||||
echo [Info] Will use default configuration
|
||||
)
|
||||
|
||||
:: Start application
|
||||
echo.
|
||||
echo ====================================
|
||||
echo Starting Development Environment
|
||||
echo ====================================
|
||||
echo [Info] Starting backend server...
|
||||
echo [Info] Backend address: http://127.0.0.1:5000
|
||||
echo [Info] Frontend address: http://127.0.0.1:5173
|
||||
echo [Info] Press Ctrl+C to stop service
|
||||
echo.
|
||||
|
||||
:: Start main program
|
||||
python backend\main.py --mode development --log-level DEBUG
|
||||
|
||||
if %errorlevel% neq 0 (
|
||||
echo.
|
||||
echo [Error] Application startup failed
|
||||
echo [Tip] Please check error messages and fix issues
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo [Info] Application stopped
|
||||
pause
|
||||
126
start_dev_new.bat
Normal file
126
start_dev_new.bat
Normal file
@ -0,0 +1,126 @@
|
||||
@echo off
|
||||
echo ====================================
|
||||
echo 身体平衡评估系统 - 开发环境启动脚本
|
||||
echo ====================================
|
||||
echo.
|
||||
|
||||
:: 检查Python是否安装
|
||||
python --version >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo [错误] 未找到Python,请先安装Python 3.8或更高版本
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: 检查Node.js是否安装
|
||||
node --version >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo [错误] 未找到Node.js,请先安装Node.js 16.0或更高版本
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: 显示版本信息
|
||||
echo [信息] 检查环境版本...
|
||||
for /f "tokens=*" %%i in ('python --version') do echo Python: %%i
|
||||
for /f "tokens=*" %%i in ('node --version') do echo Node.js: %%i
|
||||
for /f "tokens=*" %%i in ('npm --version') do echo npm: %%i
|
||||
echo.
|
||||
|
||||
:: 检查虚拟环境
|
||||
if not exist "backend\venv" (
|
||||
echo [信息] 创建Python虚拟环境...
|
||||
python -m venv backend\venv
|
||||
if %errorlevel% neq 0 (
|
||||
echo [错误] 创建虚拟环境失败
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
|
||||
:: 激活虚拟环境
|
||||
echo [信息] 激活虚拟环境...
|
||||
call backend\venv\Scripts\activate.bat
|
||||
if %errorlevel% neq 0 (
|
||||
echo [错误] 激活虚拟环境失败
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: 安装Python依赖
|
||||
echo [信息] 检查并安装Python依赖...
|
||||
if not exist "backend\requirements.txt" (
|
||||
echo [错误] 未找到requirements.txt文件
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
pip install -r backend\requirements.txt
|
||||
if %errorlevel% neq 0 (
|
||||
echo [错误] 安装Python依赖失败
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: 安装前端依赖
|
||||
echo [信息] 检查并安装前端依赖...
|
||||
if exist "frontend\src\renderer\package.json" (
|
||||
cd frontend\src\renderer
|
||||
if not exist "node_modules" (
|
||||
echo [信息] 安装前端依赖...
|
||||
npm install
|
||||
if %errorlevel% neq 0 (
|
||||
echo [错误] 安装前端依赖失败
|
||||
cd ..\..\..
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
) else (
|
||||
echo [信息] 前端依赖已存在,跳过安装
|
||||
)
|
||||
cd ..\..\..
|
||||
) else (
|
||||
echo [警告] 未找到前端package.json文件
|
||||
)
|
||||
|
||||
:: 创建必要的目录
|
||||
echo [信息] 创建必要的目录...
|
||||
if not exist "data" mkdir data
|
||||
if not exist "data\patients" mkdir data\patients
|
||||
if not exist "data\sessions" mkdir data\sessions
|
||||
if not exist "data\exports" mkdir data\exports
|
||||
if not exist "data\backups" mkdir data\backups
|
||||
if not exist "logs" mkdir logs
|
||||
if not exist "temp" mkdir temp
|
||||
|
||||
:: 检查配置文件
|
||||
if not exist "config.json" (
|
||||
echo [警告] 未找到config.json配置文件
|
||||
echo [信息] 将使用默认配置
|
||||
)
|
||||
|
||||
:: 启动应用
|
||||
echo.
|
||||
echo ====================================
|
||||
echo 启动开发环境
|
||||
echo ====================================
|
||||
echo [信息] 启动后端服务器...
|
||||
echo [信息] 后端地址: http://127.0.0.1:5000
|
||||
echo [信息] 前端地址: http://127.0.0.1:5173
|
||||
echo [信息] 按 Ctrl+C 停止服务
|
||||
echo.
|
||||
|
||||
:: 启动主程序
|
||||
python backend\main.py --mode development --log-level DEBUG
|
||||
|
||||
if %errorlevel% neq 0 (
|
||||
echo.
|
||||
echo [错误] 应用启动失败
|
||||
echo [提示] 请检查错误信息并修复问题
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo [信息] 应用已停止
|
||||
pause
|
||||
53
start_dev_simple.bat
Normal file
53
start_dev_simple.bat
Normal file
@ -0,0 +1,53 @@
|
||||
@echo off
|
||||
echo Starting Body Balance Evaluation System - Development Mode
|
||||
echo.
|
||||
|
||||
:: Check Python
|
||||
python --version >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo Error: Python not found
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: Check Node.js
|
||||
node --version >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo Error: Node.js not found
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo Environment check passed
|
||||
echo.
|
||||
|
||||
:: Activate virtual environment
|
||||
if exist "backend\venv\Scripts\activate.bat" (
|
||||
echo Activating virtual environment...
|
||||
call backend\venv\Scripts\activate.bat
|
||||
) else (
|
||||
echo Warning: Virtual environment not found
|
||||
)
|
||||
|
||||
:: Install dependencies if needed
|
||||
if exist "backend\requirements.txt" (
|
||||
echo Installing Python dependencies...
|
||||
pip install -r backend\requirements.txt
|
||||
)
|
||||
|
||||
:: Create directories
|
||||
if not exist "data" mkdir data
|
||||
if not exist "logs" mkdir logs
|
||||
|
||||
:: Start application
|
||||
echo.
|
||||
echo Starting application...
|
||||
echo Backend: http://127.0.0.1:5000
|
||||
echo Frontend: http://127.0.0.1:5173
|
||||
echo.
|
||||
|
||||
python backend\main.py --mode development --log-level DEBUG
|
||||
|
||||
echo.
|
||||
echo Application stopped
|
||||
pause
|
||||
121
start_prod.bat
Normal file
121
start_prod.bat
Normal file
@ -0,0 +1,121 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
echo ====================================
|
||||
echo 身体平衡评估系统 - 生产环境启动脚本
|
||||
echo ====================================
|
||||
echo.
|
||||
|
||||
:: 检查Python是否安装
|
||||
python --version >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo [错误] 未找到Python,请先安装Python 3.8或更高版本
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: 显示版本信息
|
||||
echo [信息] 检查环境版本...
|
||||
for /f "tokens=*" %%i in ('python --version') do echo Python: %%i
|
||||
echo.
|
||||
|
||||
:: 检查虚拟环境
|
||||
if not exist "backend\venv" (
|
||||
echo [错误] 未找到虚拟环境,请先运行 start_dev.bat 进行初始化
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: 激活虚拟环境
|
||||
echo [信息] 激活虚拟环境...
|
||||
call backend\venv\Scripts\activate.bat
|
||||
if %errorlevel% neq 0 (
|
||||
echo [错误] 激活虚拟环境失败
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: 检查依赖
|
||||
echo [信息] 检查依赖安装状态...
|
||||
pip check >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo [警告] 依赖检查失败,建议重新安装依赖
|
||||
)
|
||||
|
||||
:: 检查前端构建
|
||||
if exist "frontend\src\renderer\dist" (
|
||||
echo [信息] 前端已构建
|
||||
else (
|
||||
echo [信息] 前端未构建,正在构建...
|
||||
if exist "frontend\src\renderer\package.json" (
|
||||
cd frontend\src\renderer
|
||||
npm run build
|
||||
if %errorlevel% neq 0 (
|
||||
echo [错误] 前端构建失败
|
||||
cd ..\..\..
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
cd ..\..\..
|
||||
) else (
|
||||
echo [警告] 未找到前端项目
|
||||
)
|
||||
)
|
||||
|
||||
:: 检查必要目录
|
||||
echo [信息] 检查目录结构...
|
||||
if not exist "data" mkdir data
|
||||
if not exist "data\patients" mkdir data\patients
|
||||
if not exist "data\sessions" mkdir data\sessions
|
||||
if not exist "data\exports" mkdir data\exports
|
||||
if not exist "data\backups" mkdir data\backups
|
||||
if not exist "logs" mkdir logs
|
||||
if not exist "temp" mkdir temp
|
||||
|
||||
:: 检查配置文件
|
||||
if not exist "config.json" (
|
||||
echo [错误] 未找到config.json配置文件
|
||||
echo [提示] 请确保配置文件存在
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: 清理临时文件
|
||||
echo [信息] 清理临时文件...
|
||||
if exist "temp\*" del /q temp\*
|
||||
if exist "logs\*.tmp" del /q logs\*.tmp
|
||||
|
||||
:: 备份数据(可选)
|
||||
if exist "data\database.db" (
|
||||
echo [信息] 备份数据库...
|
||||
for /f "tokens=2 delims==" %%a in ('wmic OS Get localdatetime /value') do set "dt=%%a"
|
||||
set "YY=%dt:~2,2%" & set "YYYY=%dt:~0,4%" & set "MM=%dt:~4,2%" & set "DD=%dt:~6,2%"
|
||||
set "HH=%dt:~8,2%" & set "Min=%dt:~10,2%" & set "Sec=%dt:~12,2%"
|
||||
set "datestamp=%YYYY%%MM%%DD%_%HH%%Min%%Sec%"
|
||||
copy "data\database.db" "data\backups\database_backup_%datestamp%.db" >nul 2>&1
|
||||
)
|
||||
|
||||
:: 启动应用
|
||||
echo.
|
||||
echo ====================================
|
||||
echo 启动生产环境
|
||||
echo ====================================
|
||||
echo [信息] 启动应用服务器...
|
||||
echo [信息] 服务地址: http://127.0.0.1:5000
|
||||
echo [信息] 按 Ctrl+C 停止服务
|
||||
echo [信息] 日志文件: logs\app.log
|
||||
echo.
|
||||
|
||||
:: 启动主程序
|
||||
python backend\main.py --mode production
|
||||
|
||||
if %errorlevel% neq 0 (
|
||||
echo.
|
||||
echo [错误] 应用启动失败
|
||||
echo [提示] 请检查日志文件: logs\app.log
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo [信息] 应用已停止
|
||||
pause
|
||||
41
test_start.bat
Normal file
41
test_start.bat
Normal file
@ -0,0 +1,41 @@
|
||||
@echo off
|
||||
echo Testing start_dev.bat functionality
|
||||
echo.
|
||||
|
||||
:: Check if virtual environment exists
|
||||
if exist "backend\venv\Scripts\activate.bat" (
|
||||
echo Virtual environment found
|
||||
call backend\venv\Scripts\activate.bat
|
||||
echo Virtual environment activated
|
||||
) else (
|
||||
echo Virtual environment not found
|
||||
)
|
||||
|
||||
:: Check if main.py exists
|
||||
if exist "backend\main.py" (
|
||||
echo main.py found
|
||||
) else (
|
||||
echo main.py not found
|
||||
)
|
||||
|
||||
:: Test basic Python execution
|
||||
echo Testing Python execution...
|
||||
python --version
|
||||
if %errorlevel% equ 0 (
|
||||
echo Python is working
|
||||
) else (
|
||||
echo Python test failed
|
||||
)
|
||||
|
||||
:: Test Node.js
|
||||
echo Testing Node.js...
|
||||
node --version
|
||||
if %errorlevel% equ 0 (
|
||||
echo Node.js is working
|
||||
) else (
|
||||
echo Node.js test failed
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Test completed
|
||||
pause
|
||||
Loading…
Reference in New Issue
Block a user