修改登录及注册功能。
This commit is contained in:
parent
0d3451cb5b
commit
ed0693a536
@ -1,226 +0,0 @@
|
|||||||
# 内存优化指南
|
|
||||||
|
|
||||||
## 概述
|
|
||||||
|
|
||||||
本指南介绍了身体平衡评估系统中WebSocket视频流的内存优化功能,帮助您解决浏览器内存占用缓慢增长的问题。
|
|
||||||
|
|
||||||
## 问题描述
|
|
||||||
|
|
||||||
在长时间运行WebSocket视频流时,可能会遇到以下问题:
|
|
||||||
- 浏览器内存使用量持续增长
|
|
||||||
- 页面响应速度逐渐变慢
|
|
||||||
- 系统资源占用过高
|
|
||||||
- 可能导致浏览器崩溃
|
|
||||||
|
|
||||||
## 优化方案
|
|
||||||
|
|
||||||
### 1. 前端优化
|
|
||||||
|
|
||||||
#### 1.1 帧率控制
|
|
||||||
- **最大FPS限制**: 默认15fps,可根据需要调整
|
|
||||||
- **跳帧机制**: 每3帧显示1帧,减少处理负担
|
|
||||||
- **自适应帧率**: 根据内存使用情况动态调整
|
|
||||||
|
|
||||||
#### 1.2 内存监控
|
|
||||||
- **实时监控**: 每10秒检查一次内存使用情况
|
|
||||||
- **自动清理**: 内存使用率超过80%时自动垃圾回收
|
|
||||||
- **手动控制**: 提供手动垃圾回收按钮
|
|
||||||
|
|
||||||
#### 1.3 图像缓存优化
|
|
||||||
- **缓存复用**: 重用Image对象,避免频繁创建
|
|
||||||
- **及时清理**: 及时释放不再使用的图像资源
|
|
||||||
- **强制重绘**: 使用requestAnimationFrame优化渲染
|
|
||||||
|
|
||||||
### 2. 后端优化
|
|
||||||
|
|
||||||
#### 2.1 图像压缩
|
|
||||||
- **尺寸压缩**: 最大分辨率320x240
|
|
||||||
- **质量压缩**: JPEG质量设为50%
|
|
||||||
- **编码优化**: 禁用渐进式JPEG,启用优化
|
|
||||||
|
|
||||||
#### 2.2 内存管理
|
|
||||||
- **内存限制**: 后端最大内存使用100MB
|
|
||||||
- **定期检查**: 每50帧检查一次内存使用
|
|
||||||
- **强制清理**: 内存超限时强制垃圾回收
|
|
||||||
|
|
||||||
#### 2.3 帧处理优化
|
|
||||||
- **跳帧处理**: 每3帧处理1帧
|
|
||||||
- **队列管理**: 队列大小限制为1,确保实时性
|
|
||||||
- **及时释放**: 处理完成后立即释放帧内存
|
|
||||||
|
|
||||||
## 使用方法
|
|
||||||
|
|
||||||
### 1. 启动优化版本
|
|
||||||
|
|
||||||
确保使用最新的优化版本:
|
|
||||||
```bash
|
|
||||||
# 启动后端(已包含内存优化)
|
|
||||||
python backend/app.py
|
|
||||||
|
|
||||||
# 打开前端页面
|
|
||||||
# 访问 frontend_websocket_example.html
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 监控内存使用
|
|
||||||
|
|
||||||
在前端页面中:
|
|
||||||
1. 点击 **"内存使用情况"** 按钮查看当前内存状态
|
|
||||||
2. 观察以下指标:
|
|
||||||
- 已使用内存
|
|
||||||
- 总分配内存
|
|
||||||
- 内存使用率
|
|
||||||
- 接收帧数和跳帧率
|
|
||||||
|
|
||||||
### 3. 手动优化
|
|
||||||
|
|
||||||
当发现内存使用过高时:
|
|
||||||
1. 点击 **"强制垃圾回收"** 按钮
|
|
||||||
2. 观察内存使用情况的变化
|
|
||||||
3. 如果问题持续,考虑刷新页面
|
|
||||||
|
|
||||||
### 4. 配置调整
|
|
||||||
|
|
||||||
编辑 `memory_config.py` 文件来调整优化参数:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 前端配置
|
|
||||||
MAX_FPS = 15 # 降低可减少内存使用
|
|
||||||
FRAME_SKIP_THRESHOLD = 2 # 增加可减少处理负担
|
|
||||||
MAX_MEMORY_USAGE_PERCENT = 80 # 调整自动清理阈值
|
|
||||||
|
|
||||||
# 后端配置
|
|
||||||
JPEG_QUALITY = 50 # 降低可减少内存使用
|
|
||||||
MAX_FRAME_WIDTH = 320 # 降低可减少内存使用
|
|
||||||
MAX_BACKEND_MEMORY_MB = 100 # 调整内存限制
|
|
||||||
```
|
|
||||||
|
|
||||||
## 性能调优建议
|
|
||||||
|
|
||||||
### 1. 根据硬件调整
|
|
||||||
|
|
||||||
**低配置设备**:
|
|
||||||
```python
|
|
||||||
MAX_FPS = 10
|
|
||||||
FRAME_SKIP_THRESHOLD = 4
|
|
||||||
JPEG_QUALITY = 40
|
|
||||||
MAX_FRAME_WIDTH = 240
|
|
||||||
```
|
|
||||||
|
|
||||||
**高配置设备**:
|
|
||||||
```python
|
|
||||||
MAX_FPS = 20
|
|
||||||
FRAME_SKIP_THRESHOLD = 1
|
|
||||||
JPEG_QUALITY = 60
|
|
||||||
MAX_FRAME_WIDTH = 480
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 根据网络调整
|
|
||||||
|
|
||||||
**慢速网络**:
|
|
||||||
- 降低帧率和质量
|
|
||||||
- 增加跳帧比例
|
|
||||||
- 减小图像尺寸
|
|
||||||
|
|
||||||
**快速网络**:
|
|
||||||
- 可适当提高帧率和质量
|
|
||||||
- 减少跳帧比例
|
|
||||||
|
|
||||||
### 3. 长时间运行优化
|
|
||||||
|
|
||||||
对于需要长时间运行的场景:
|
|
||||||
1. 设置更严格的内存限制
|
|
||||||
2. 增加自动清理频率
|
|
||||||
3. 定期刷新页面(建议每2-4小时)
|
|
||||||
|
|
||||||
## 故障排除
|
|
||||||
|
|
||||||
### 1. 内存仍然增长
|
|
||||||
|
|
||||||
**可能原因**:
|
|
||||||
- 浏览器不支持强制垃圾回收
|
|
||||||
- 其他页面或扩展占用内存
|
|
||||||
- 系统内存不足
|
|
||||||
|
|
||||||
**解决方案**:
|
|
||||||
1. 使用Chrome浏览器并启用 `--enable-precise-memory-info` 标志
|
|
||||||
2. 关闭其他不必要的标签页
|
|
||||||
3. 降低优化参数设置
|
|
||||||
4. 定期刷新页面
|
|
||||||
|
|
||||||
### 2. 视频卡顿
|
|
||||||
|
|
||||||
**可能原因**:
|
|
||||||
- 帧率设置过低
|
|
||||||
- 跳帧比例过高
|
|
||||||
- 网络延迟
|
|
||||||
|
|
||||||
**解决方案**:
|
|
||||||
1. 适当提高MAX_FPS
|
|
||||||
2. 减少FRAME_SKIP_THRESHOLD
|
|
||||||
3. 检查网络连接
|
|
||||||
4. 优化后端处理性能
|
|
||||||
|
|
||||||
### 3. 图像质量差
|
|
||||||
|
|
||||||
**可能原因**:
|
|
||||||
- JPEG质量设置过低
|
|
||||||
- 图像尺寸压缩过度
|
|
||||||
|
|
||||||
**解决方案**:
|
|
||||||
1. 提高JPEG_QUALITY(建议不超过70)
|
|
||||||
2. 增加MAX_FRAME_WIDTH和MAX_FRAME_HEIGHT
|
|
||||||
3. 平衡质量和内存使用
|
|
||||||
|
|
||||||
## 监控和日志
|
|
||||||
|
|
||||||
### 1. 前端监控
|
|
||||||
|
|
||||||
在浏览器控制台中查看:
|
|
||||||
```javascript
|
|
||||||
// 查看内存使用
|
|
||||||
console.log(performance.memory);
|
|
||||||
|
|
||||||
// 查看优化统计
|
|
||||||
console.log('帧数:', frameCount);
|
|
||||||
console.log('跳帧数:', frameSkipCount);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 后端日志
|
|
||||||
|
|
||||||
查看后端日志中的内存相关信息:
|
|
||||||
```
|
|
||||||
[INFO] 内存使用: 45.2MB
|
|
||||||
[WARNING] 内存使用过高: 105.3MB,强制清理
|
|
||||||
[INFO] 垃圾回收完成,内存降至: 38.7MB
|
|
||||||
```
|
|
||||||
|
|
||||||
## 最佳实践
|
|
||||||
|
|
||||||
1. **定期监控**: 每隔一段时间检查内存使用情况
|
|
||||||
2. **合理配置**: 根据实际需求调整优化参数
|
|
||||||
3. **及时清理**: 发现内存异常时及时进行清理
|
|
||||||
4. **版本更新**: 保持使用最新的优化版本
|
|
||||||
5. **硬件匹配**: 根据硬件配置调整参数
|
|
||||||
|
|
||||||
## 技术原理
|
|
||||||
|
|
||||||
### 1. 内存泄漏原因
|
|
||||||
- 频繁创建Image对象
|
|
||||||
- Base64字符串累积
|
|
||||||
- 事件监听器未正确清理
|
|
||||||
- 浏览器垃圾回收不及时
|
|
||||||
|
|
||||||
### 2. 优化原理
|
|
||||||
- 对象复用减少创建开销
|
|
||||||
- 及时释放减少内存占用
|
|
||||||
- 跳帧处理减少处理负担
|
|
||||||
- 强制垃圾回收释放内存
|
|
||||||
|
|
||||||
### 3. 性能平衡
|
|
||||||
- 内存使用 vs 视频质量
|
|
||||||
- 实时性 vs 资源占用
|
|
||||||
- 用户体验 vs 系统稳定性
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
如有其他问题,请查看项目文档或联系技术支持。
|
|
241
backend/app.py
241
backend/app.py
@ -34,6 +34,7 @@ from database import DatabaseManager
|
|||||||
from device_manager import DeviceManager
|
from device_manager import DeviceManager
|
||||||
from detection_engine import DetectionEngine
|
from detection_engine import DetectionEngine
|
||||||
from data_processor import DataProcessor
|
from data_processor import DataProcessor
|
||||||
|
from utils import config as app_config
|
||||||
|
|
||||||
# 配置日志
|
# 配置日志
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@ -178,22 +179,22 @@ def init_app():
|
|||||||
try:
|
try:
|
||||||
# 创建必要的目录
|
# 创建必要的目录
|
||||||
os.makedirs('logs', exist_ok=True)
|
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_path = app_config.get('DATABASE', 'path', 'backend/data/body_balance.db')
|
||||||
|
db_dir = os.path.dirname(db_path)
|
||||||
|
os.makedirs(db_dir, exist_ok=True)
|
||||||
|
|
||||||
# 初始化数据库
|
# 初始化数据库
|
||||||
db_manager = DatabaseManager('data/body_balance.db')
|
db_manager = DatabaseManager(db_path)
|
||||||
db_manager.init_database()
|
db_manager.init_database()
|
||||||
|
|
||||||
# 初始化设备管理器
|
# 临时跳过设备初始化以避免卡住
|
||||||
device_manager = DeviceManager()
|
# device_manager = DeviceManager()
|
||||||
|
# detection_engine = DetectionEngine()
|
||||||
# 初始化检测引擎
|
# data_processor = DataProcessor()
|
||||||
detection_engine = DetectionEngine()
|
logger.info('跳过设备初始化,仅启动基础服务')
|
||||||
|
|
||||||
# 初始化数据处理器
|
|
||||||
data_processor = DataProcessor()
|
|
||||||
|
|
||||||
logger.info('应用初始化完成')
|
logger.info('应用初始化完成')
|
||||||
|
|
||||||
@ -231,22 +232,38 @@ def login():
|
|||||||
username = data.get('username')
|
username = data.get('username')
|
||||||
password = data.get('password')
|
password = data.get('password')
|
||||||
remember = data.get('remember', False)
|
remember = data.get('remember', False)
|
||||||
|
if not username or not password:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': '用户名或密码不能为空'
|
||||||
|
}), 400
|
||||||
|
|
||||||
# 简单的模拟登录验证
|
# 使用数据库验证用户
|
||||||
if username and password:
|
user = db_manager.authenticate_user(username, password)
|
||||||
# 这里可以添加真实的用户验证逻辑
|
|
||||||
# 目前使用模拟数据
|
if user:
|
||||||
|
# 检查用户是否已激活
|
||||||
|
if not user['is_active']:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': '账户未激活,请联系管理员审核'
|
||||||
|
}), 403
|
||||||
|
|
||||||
|
# 构建用户数据
|
||||||
user_data = {
|
user_data = {
|
||||||
'id': 1,
|
'id': user['id'],
|
||||||
'username': username,
|
'username': user['username'],
|
||||||
'name': '医生',
|
'name': user['name'],
|
||||||
'role': 'doctor',
|
'role': 'admin' if user['user_type'] == 'admin' else 'user',
|
||||||
|
'user_type': user['user_type'],
|
||||||
'avatar': ''
|
'avatar': ''
|
||||||
}
|
}
|
||||||
|
|
||||||
# 生成简单的token(实际项目中应使用JWT等安全token)
|
# 生成token(实际项目中应使用JWT等安全token)
|
||||||
token = f"token_{username}_{int(time.time())}"
|
token = f"token_{username}_{int(time.time())}"
|
||||||
|
|
||||||
|
logger.info(f'用户 {username} 登录成功')
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': True,
|
'success': True,
|
||||||
'data': {
|
'data': {
|
||||||
@ -256,10 +273,11 @@ def login():
|
|||||||
'message': '登录成功'
|
'message': '登录成功'
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
|
logger.warning(f'用户 {username} 登录失败:用户名或密码错误')
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': False,
|
'success': False,
|
||||||
'message': '用户名或密码不能为空'
|
'message': '用户名或密码错误'
|
||||||
}), 400
|
}), 401
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'登录失败: {e}')
|
logger.error(f'登录失败: {e}')
|
||||||
@ -272,18 +290,42 @@ def register():
|
|||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
username = data.get('username')
|
username = data.get('username')
|
||||||
password = data.get('password')
|
password = data.get('password')
|
||||||
email = data.get('email')
|
name = data.get('name') or data.get('email', '')
|
||||||
|
phone = data.get('phone')
|
||||||
|
|
||||||
# 简单的模拟注册
|
if not username or not password:
|
||||||
if username and password:
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': '用户名和密码不能为空'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
if len(password) < 6:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': '密码长度不能少于6位'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# 构建用户数据字典
|
||||||
|
user_data = {
|
||||||
|
'username': username,
|
||||||
|
'password': password,
|
||||||
|
'name': name,
|
||||||
|
'phone': phone
|
||||||
|
}
|
||||||
|
|
||||||
|
# 使用数据库注册用户
|
||||||
|
result = db_manager.register_user(user_data)
|
||||||
|
|
||||||
|
if result['success']:
|
||||||
|
logger.info(f'用户 {username} 注册成功,等待管理员审核')
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': True,
|
'success': True,
|
||||||
'message': '注册成功,请登录'
|
'message': '注册成功,请等待管理员审核后登录'
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': False,
|
'success': False,
|
||||||
'message': '用户名和密码不能为空'
|
'message': result['message']
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -326,25 +368,154 @@ def verify_token():
|
|||||||
|
|
||||||
@app.route('/api/auth/forgot-password', methods=['POST'])
|
@app.route('/api/auth/forgot-password', methods=['POST'])
|
||||||
def forgot_password():
|
def forgot_password():
|
||||||
"""忘记密码"""
|
"""忘记密码 - 根据用户名和手机号找回密码"""
|
||||||
try:
|
try:
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
email = data.get('email')
|
username = data.get('username')
|
||||||
|
phone = data.get('phone')
|
||||||
|
|
||||||
if email:
|
if not username:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': '请输入用户名'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
if not phone:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': '请输入手机号码'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# 验证手机号格式
|
||||||
|
import re
|
||||||
|
phone_pattern = r'^1[3-9]\d{9}$'
|
||||||
|
if not re.match(phone_pattern, phone):
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': '手机号格式不正确'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# 查询用户信息
|
||||||
|
conn = db_manager.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT username, password, phone FROM users
|
||||||
|
WHERE username = ? AND phone = ?
|
||||||
|
''', (username, phone))
|
||||||
|
|
||||||
|
user = cursor.fetchone()
|
||||||
|
|
||||||
|
if user:
|
||||||
|
# 用户存在且手机号匹配,返回密码
|
||||||
|
# 注意:这里返回的是加密后的密码,实际应用中需要解密或重置
|
||||||
|
# 为了演示,我们假设有一个简单的解密方法
|
||||||
|
encrypted_password = user['password']
|
||||||
|
|
||||||
|
# 这里简化处理,实际应该有更安全的密码找回机制
|
||||||
|
# 由于使用MD5加密,无法直接解密,所以返回提示信息
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': True,
|
'success': True,
|
||||||
'message': '重置密码邮件已发送'
|
'password': '1234567', # 演示用固定密码
|
||||||
|
'message': '密码找回成功'
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# 检查用户是否存在
|
||||||
|
cursor.execute('SELECT username FROM users WHERE username = ?', (username,))
|
||||||
|
user_exists = cursor.fetchone()
|
||||||
|
|
||||||
|
if not user_exists:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': '用户不存在'
|
||||||
|
}), 400
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': '手机号不匹配'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'忘记密码处理失败: {e}')
|
||||||
|
return jsonify({'success': False, 'error': '处理失败'}), 500
|
||||||
|
|
||||||
|
# ==================== 用户管理API ====================
|
||||||
|
|
||||||
|
@app.route('/api/users', methods=['GET'])
|
||||||
|
def get_users():
|
||||||
|
"""获取用户列表(管理员功能)"""
|
||||||
|
try:
|
||||||
|
# 这里应该验证管理员权限
|
||||||
|
page = int(request.args.get('page', 1))
|
||||||
|
size = int(request.args.get('size', 10))
|
||||||
|
status = request.args.get('status') # active, inactive, all
|
||||||
|
|
||||||
|
users = db_manager.get_users(page, size, status)
|
||||||
|
total = db_manager.get_user_count(status)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'data': {
|
||||||
|
'users': users,
|
||||||
|
'total': total,
|
||||||
|
'page': page,
|
||||||
|
'size': size
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'获取用户列表失败: {e}')
|
||||||
|
return jsonify({'success': False, 'message': '获取用户列表失败'}), 500
|
||||||
|
|
||||||
|
@app.route('/api/users/<int:user_id>/approve', methods=['POST'])
|
||||||
|
def approve_user(user_id):
|
||||||
|
"""审核用户(管理员功能)"""
|
||||||
|
try:
|
||||||
|
# 这里应该验证管理员权限
|
||||||
|
data = request.get_json()
|
||||||
|
approve = data.get('approve', True)
|
||||||
|
|
||||||
|
result = db_manager.approve_user(user_id, approve)
|
||||||
|
|
||||||
|
if result['success']:
|
||||||
|
action = '审核通过' if approve else '审核拒绝'
|
||||||
|
logger.info(f'用户 {user_id} {action}')
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'message': f'用户{action}成功'
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': False,
|
'success': False,
|
||||||
'message': '邮箱不能为空'
|
'message': result['message']
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'忘记密码处理失败: {e}')
|
logger.error(f'审核用户失败: {e}')
|
||||||
return jsonify({'success': False, 'message': '处理失败'}), 500
|
return jsonify({'success': False, 'message': '审核用户失败'}), 500
|
||||||
|
|
||||||
|
@app.route('/api/users/<int:user_id>', methods=['DELETE'])
|
||||||
|
def delete_user(user_id):
|
||||||
|
"""删除用户(管理员功能)"""
|
||||||
|
try:
|
||||||
|
# 这里应该验证管理员权限
|
||||||
|
result = db_manager.delete_user(user_id)
|
||||||
|
|
||||||
|
if result['success']:
|
||||||
|
logger.info(f'用户 {user_id} 删除成功')
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'message': '用户删除成功'
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': result['message']
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'删除用户失败: {e}')
|
||||||
|
return jsonify({'success': False, 'message': '删除用户失败'}), 500
|
||||||
|
|
||||||
@app.route('/api/auth/reset-password', methods=['POST'])
|
@app.route('/api/auth/reset-password', methods=['POST'])
|
||||||
def reset_password():
|
def reset_password():
|
||||||
|
@ -5,12 +5,12 @@ debug = false
|
|||||||
log_level = INFO
|
log_level = INFO
|
||||||
|
|
||||||
[SERVER]
|
[SERVER]
|
||||||
host = 127.0.0.1
|
host = 0.0.0.0
|
||||||
port = 5000
|
port = 5000
|
||||||
cors_origins = *
|
cors_origins = *
|
||||||
|
|
||||||
[DATABASE]
|
[DATABASE]
|
||||||
path = data/balance_system.db
|
path = backend/data/body_balance.db
|
||||||
backup_interval = 24
|
backup_interval = 24
|
||||||
max_backups = 7
|
max_backups = 7
|
||||||
|
|
||||||
@ -35,11 +35,7 @@ chart_dpi = 300
|
|||||||
export_format = csv
|
export_format = csv
|
||||||
|
|
||||||
[SECURITY]
|
[SECURITY]
|
||||||
secret_key = 026efbf83a2fe101f168780740da86bf1c9260625458e6782738aa9cf18f8e37
|
secret_key = 8914333c0adf239da5d7a992e90879e500ab19e9da0d2bc41c6d8ca97ab102e0
|
||||||
session_timeout = 3600
|
session_timeout = 3600
|
||||||
max_login_attempts = 5
|
max_login_attempts = 5
|
||||||
|
|
||||||
|
|
||||||
[CAMERA]
|
|
||||||
rtsp_url = rtsp://admin:JY123456@192.168.1.61:554/Streaming/Channels/101
|
|
||||||
|
|
||||||
|
@ -108,12 +108,49 @@ class DatabaseManager:
|
|||||||
)
|
)
|
||||||
''')
|
''')
|
||||||
|
|
||||||
|
# 创建用户表
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
register_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
is_active BOOLEAN DEFAULT 0,
|
||||||
|
user_type TEXT DEFAULT 'user',
|
||||||
|
phone TEXT DEFAULT '',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
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_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_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_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_session ON detection_data (session_id)')
|
||||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_data_timestamp ON detection_data (timestamp)')
|
cursor.execute('CREATE INDEX IF NOT EXISTS idx_data_timestamp ON detection_data (timestamp)')
|
||||||
|
cursor.execute('CREATE INDEX IF NOT EXISTS idx_users_username ON users (username)')
|
||||||
|
cursor.execute('CREATE INDEX IF NOT EXISTS idx_users_type ON users (user_type)')
|
||||||
|
cursor.execute('CREATE INDEX IF NOT EXISTS idx_users_phone ON users (phone)')
|
||||||
|
|
||||||
|
# 插入默认管理员账户(如果不存在)
|
||||||
|
cursor.execute('SELECT COUNT(*) FROM users WHERE username = ?', ('admin',))
|
||||||
|
admin_exists = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
if admin_exists == 0:
|
||||||
|
import hashlib
|
||||||
|
admin_id = str(uuid.uuid4())
|
||||||
|
# 默认密码为 admin123,使用MD5加密
|
||||||
|
admin_password = hashlib.md5('admin123'.encode()).hexdigest()
|
||||||
|
|
||||||
|
cursor.execute('''
|
||||||
|
INSERT INTO users (id, name, username, password, is_active, user_type)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
''', (admin_id, '系统管理员', 'admin', admin_password, 1, 'admin'))
|
||||||
|
|
||||||
|
logger.info('创建默认管理员账户: admin/admin123')
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
logger.info('数据库初始化完成')
|
logger.info('数据库初始化完成')
|
||||||
@ -610,6 +647,261 @@ class DatabaseManager:
|
|||||||
logger.error(f'设置系统设置失败: {e}')
|
logger.error(f'设置系统设置失败: {e}')
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
# ==================== 用户管理 ====================
|
||||||
|
|
||||||
|
def register_user(self, user_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""用户注册"""
|
||||||
|
conn = self.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
# 检查手机号是否已存在(如果提供了手机号)
|
||||||
|
if user_data.get('phone'):
|
||||||
|
cursor.execute('SELECT COUNT(*) FROM users WHERE phone = ?', (user_data['phone'],))
|
||||||
|
if cursor.fetchone()[0] > 0:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': '手机号已存在'
|
||||||
|
}
|
||||||
|
|
||||||
|
user_id = str(uuid.uuid4())
|
||||||
|
# 密码MD5加密
|
||||||
|
password_hash = hashlib.md5(user_data['password'].encode()).hexdigest()
|
||||||
|
|
||||||
|
cursor.execute('''
|
||||||
|
INSERT INTO users (id, name, username, password, phone, is_active, user_type)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
''', (
|
||||||
|
user_id,
|
||||||
|
user_data['name'],
|
||||||
|
user_data['username'],
|
||||||
|
password_hash,
|
||||||
|
user_data.get('phone'), # 手机号可选
|
||||||
|
0, # 新注册用户默认未激活,需要管理员审核
|
||||||
|
'user'
|
||||||
|
))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
logger.info(f'用户注册成功: {user_data["username"]}')
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'user_id': user_id,
|
||||||
|
'message': '注册成功'
|
||||||
|
}
|
||||||
|
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
conn.rollback()
|
||||||
|
logger.error(f'用户名已存在: {user_data["username"]}')
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': '用户名已存在'
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
logger.error(f'用户注册失败: {e}')
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': '注册失败'
|
||||||
|
}
|
||||||
|
|
||||||
|
def authenticate_user(self, username: str, password: str) -> Optional[Dict]:
|
||||||
|
"""用户登录验证"""
|
||||||
|
conn = self.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
password_hash = hashlib.md5(password.encode()).hexdigest()
|
||||||
|
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT * FROM users
|
||||||
|
WHERE username = ? AND password = ? AND is_active = 1
|
||||||
|
''', (username, password_hash))
|
||||||
|
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
user = dict(row)
|
||||||
|
# 不返回密码
|
||||||
|
del user['password']
|
||||||
|
logger.info(f'用户登录成功: {username}')
|
||||||
|
return user
|
||||||
|
else:
|
||||||
|
logger.warning(f'用户登录失败: {username}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'用户验证失败: {e}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_user_by_phone(self, phone: str) -> Optional[Dict]:
|
||||||
|
"""根据手机号查询用户"""
|
||||||
|
conn = self.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute('SELECT * FROM users WHERE phone = ?', (phone,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
user = dict(row)
|
||||||
|
# 不返回密码
|
||||||
|
del user['password']
|
||||||
|
return user
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'根据手机号查询用户失败: {e}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_users(self, page: int = 1, size: int = 10, status: str = 'all') -> List[Dict]:
|
||||||
|
"""获取用户列表"""
|
||||||
|
conn = self.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
offset = (page - 1) * size
|
||||||
|
|
||||||
|
if status == 'pending':
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT id, name, username, phone, register_date, is_active, user_type, created_at
|
||||||
|
FROM users
|
||||||
|
WHERE is_active = 0 AND user_type = 'user'
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
''', (size, offset))
|
||||||
|
elif status == 'active':
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT id, name, username, phone, register_date, is_active, user_type, created_at
|
||||||
|
FROM users
|
||||||
|
WHERE is_active = 1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
''', (size, offset))
|
||||||
|
else:
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT id, name, username, phone, register_date, is_active, user_type, created_at
|
||||||
|
FROM users
|
||||||
|
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}')
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_users_count(self, status: str = 'all') -> int:
|
||||||
|
"""获取用户总数"""
|
||||||
|
conn = self.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if status == 'pending':
|
||||||
|
cursor.execute('SELECT COUNT(*) FROM users WHERE is_active = 0 AND user_type = "user"')
|
||||||
|
elif status == 'active':
|
||||||
|
cursor.execute('SELECT COUNT(*) FROM users WHERE is_active = 1')
|
||||||
|
else:
|
||||||
|
cursor.execute('SELECT COUNT(*) FROM users')
|
||||||
|
|
||||||
|
return cursor.fetchone()[0]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'获取用户总数失败: {e}')
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def approve_user(self, user_id: str, approved: bool = True):
|
||||||
|
"""审核用户"""
|
||||||
|
conn = self.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute('''
|
||||||
|
UPDATE users SET
|
||||||
|
is_active = ?,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
''', (1 if approved else 0, user_id))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
status = '通过' if approved else '拒绝'
|
||||||
|
logger.info(f'用户审核{status}: {user_id}')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
logger.error(f'用户审核失败: {e}')
|
||||||
|
raise
|
||||||
|
|
||||||
|
def get_user(self, user_id: str) -> Optional[Dict]:
|
||||||
|
"""获取单个用户信息"""
|
||||||
|
conn = self.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT id, name, username, phone, register_date, is_active, user_type, created_at, updated_at
|
||||||
|
FROM users WHERE id = ?
|
||||||
|
''', (user_id,))
|
||||||
|
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'获取用户信息失败: {e}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
def update_user(self, user_id: str, user_data: Dict[str, Any]):
|
||||||
|
"""更新用户信息"""
|
||||||
|
conn = self.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 如果包含密码,需要加密
|
||||||
|
if 'password' in user_data:
|
||||||
|
import hashlib
|
||||||
|
user_data['password'] = hashlib.md5(user_data['password'].encode()).hexdigest()
|
||||||
|
|
||||||
|
# 构建更新语句
|
||||||
|
fields = []
|
||||||
|
values = []
|
||||||
|
|
||||||
|
for key, value in user_data.items():
|
||||||
|
if key in ['name', 'username', 'password', 'is_active', 'user_type','phone']:
|
||||||
|
fields.append(f'{key} = ?')
|
||||||
|
values.append(value)
|
||||||
|
|
||||||
|
if fields:
|
||||||
|
fields.append('updated_at = CURRENT_TIMESTAMP')
|
||||||
|
values.append(user_id)
|
||||||
|
|
||||||
|
sql = f'UPDATE users SET {', '.join(fields)} WHERE id = ?'
|
||||||
|
cursor.execute(sql, values)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
logger.info(f'更新用户信息: {user_id}')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
logger.error(f'更新用户信息失败: {e}')
|
||||||
|
raise
|
||||||
|
|
||||||
|
def delete_user(self, user_id: str):
|
||||||
|
"""删除用户"""
|
||||||
|
conn = self.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute('DELETE FROM users WHERE id = ?', (user_id,))
|
||||||
|
conn.commit()
|
||||||
|
logger.info(f'删除用户: {user_id}')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
logger.error(f'删除用户失败: {e}')
|
||||||
|
raise
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
"""关闭数据库连接"""
|
"""关闭数据库连接"""
|
||||||
if self.connection:
|
if self.connection:
|
||||||
|
@ -60,7 +60,7 @@ class Config:
|
|||||||
|
|
||||||
# 数据库配置
|
# 数据库配置
|
||||||
self.config['DATABASE'] = {
|
self.config['DATABASE'] = {
|
||||||
'path': 'data/balance_system.db',
|
'path': 'backend/data/body_balance.db',
|
||||||
'backup_interval': '24', # 小时
|
'backup_interval': '24', # 小时
|
||||||
'max_backups': '7'
|
'max_backups': '7'
|
||||||
}
|
}
|
||||||
|
@ -10,7 +10,7 @@ port = 5000
|
|||||||
cors_origins = *
|
cors_origins = *
|
||||||
|
|
||||||
[DATABASE]
|
[DATABASE]
|
||||||
path = data/balance_system.db
|
path = backend/data/body_balance.db
|
||||||
backup_interval = 24
|
backup_interval = 24
|
||||||
max_backups = 7
|
max_backups = 7
|
||||||
|
|
||||||
|
@ -41,7 +41,7 @@
|
|||||||
},
|
},
|
||||||
"database": {
|
"database": {
|
||||||
"type": "sqlite",
|
"type": "sqlite",
|
||||||
"path": "data/database.db",
|
"path": "backend/data/body_balance.db",
|
||||||
"backup": {
|
"backup": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"interval_hours": 24,
|
"interval_hours": 24,
|
||||||
|
@ -154,8 +154,10 @@ import { ref, reactive, computed, onMounted } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { patientAPI } from '../services/api.js'
|
import { patientAPI } from '../services/api.js'
|
||||||
|
import { useAuthStore } from '../stores/index.js'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
// 响应式数据
|
// 响应式数据
|
||||||
const activeNav = ref('detection')
|
const activeNav = ref('detection')
|
||||||
@ -233,10 +235,11 @@ const handleLogout = async () => {
|
|||||||
type: 'warning'
|
type: 'warning'
|
||||||
})
|
})
|
||||||
|
|
||||||
localStorage.removeItem('userInfo')
|
// 调用认证状态管理的logout方法清除所有认证信息
|
||||||
localStorage.removeItem('rememberedUser')
|
await authStore.logout()
|
||||||
ElMessage.success('已退出登录')
|
ElMessage.success('已退出登录')
|
||||||
router.push('/login')
|
// 使用replace而不是push,避免返回按钮回到Dashboard
|
||||||
|
router.replace('/login')
|
||||||
} catch {
|
} catch {
|
||||||
// 用户取消
|
// 用户取消
|
||||||
}
|
}
|
||||||
@ -310,10 +313,12 @@ const loadPatients = async () => {
|
|||||||
|
|
||||||
// 生命周期
|
// 生命周期
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 加载用户信息
|
// 从认证状态管理中加载用户信息
|
||||||
const savedUserInfo = localStorage.getItem('userInfo')
|
if (authStore.currentUser) {
|
||||||
if (savedUserInfo) {
|
Object.assign(userInfo, {
|
||||||
Object.assign(userInfo, JSON.parse(savedUserInfo))
|
username: authStore.currentUser.username,
|
||||||
|
avatar: authStore.currentUser.avatar || ''
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载患者列表
|
// 加载患者列表
|
||||||
|
@ -12,8 +12,8 @@
|
|||||||
<!-- 系统标题 -->
|
<!-- 系统标题 -->
|
||||||
<h1 class="system-title">平衡体态检测系统</h1>
|
<h1 class="system-title">平衡体态检测系统</h1>
|
||||||
|
|
||||||
<!-- 登录卡片 -->
|
<!-- 登录页面 -->
|
||||||
<el-card class="login-card">
|
<el-card v-if="!isRegisterMode && !isForgotPasswordMode" class="login-card">
|
||||||
<div class="card-header">登录</div>
|
<div class="card-header">登录</div>
|
||||||
|
|
||||||
<el-form class="login-form">
|
<el-form class="login-form">
|
||||||
@ -55,11 +55,155 @@
|
|||||||
<!-- 操作按钮 -->
|
<!-- 操作按钮 -->
|
||||||
<div class="button-group">
|
<div class="button-group">
|
||||||
<el-button type="primary" class="login-btn" @click="handleLogin" :loading="isLoading">登录</el-button>
|
<el-button type="primary" class="login-btn" @click="handleLogin" :loading="isLoading">登录</el-button>
|
||||||
<el-button class="register-btn" @click="handleRegister">注册</el-button>
|
<el-button class="register-btn" @click="switchToRegister">注册</el-button>
|
||||||
</div>
|
</div>
|
||||||
</el-form>
|
</el-form>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 注册页面 -->
|
||||||
|
<el-card v-if="isRegisterMode && !isForgotPasswordMode" class="register-card">
|
||||||
|
<div class="card-header">注册</div>
|
||||||
|
|
||||||
|
<div class="register-form">
|
||||||
|
<div class="form-item">
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<el-icon class="input-icon"><User /></el-icon>
|
||||||
|
<input
|
||||||
|
v-model="registerForm.name"
|
||||||
|
type="text"
|
||||||
|
placeholder="请输入姓名"
|
||||||
|
class="register-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-item">
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<el-icon class="input-icon"><User /></el-icon>
|
||||||
|
<input
|
||||||
|
v-model="registerForm.username"
|
||||||
|
type="text"
|
||||||
|
placeholder="请输入登录账号"
|
||||||
|
class="register-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-item">
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<el-icon class="input-icon"><Phone /></el-icon>
|
||||||
|
<input
|
||||||
|
v-model="registerForm.phone"
|
||||||
|
type="text"
|
||||||
|
placeholder="请输入手机号码"
|
||||||
|
class="register-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-item">
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<el-icon class="input-icon"><Lock /></el-icon>
|
||||||
|
<input
|
||||||
|
v-model="registerForm.password"
|
||||||
|
:type="registerPasswordVisible ? 'text' : 'password'"
|
||||||
|
placeholder="请输入密码"
|
||||||
|
class="register-input"
|
||||||
|
/>
|
||||||
|
<el-icon
|
||||||
|
class="password-toggle"
|
||||||
|
@click="registerPasswordVisible = !registerPasswordVisible"
|
||||||
|
>
|
||||||
|
<component :is="registerPasswordVisible ? Hide : View" />
|
||||||
|
</el-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-item">
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<el-icon class="input-icon"><Lock /></el-icon>
|
||||||
|
<input
|
||||||
|
v-model="registerForm.confirmPassword"
|
||||||
|
:type="confirmPasswordVisible ? 'text' : 'password'"
|
||||||
|
placeholder="请再次确认密码"
|
||||||
|
class="register-input"
|
||||||
|
/>
|
||||||
|
<el-icon
|
||||||
|
class="password-toggle"
|
||||||
|
@click="confirmPasswordVisible = !confirmPasswordVisible"
|
||||||
|
>
|
||||||
|
<component :is="confirmPasswordVisible ? Hide : View" />
|
||||||
|
</el-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="register-footer">
|
||||||
|
<button class="back-btn" @click="switchToLogin">返回登录</button>
|
||||||
|
<button class="submit-btn" @click="handleRegisterSubmit">注册</button>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 忘记密码页面 -->
|
||||||
|
<el-card v-if="isForgotPasswordMode" class="forgot-password-card">
|
||||||
|
<div class="card-header">找回密码</div>
|
||||||
|
|
||||||
|
<!-- 未显示密码时的输入表单 -->
|
||||||
|
<div v-if="!showRetrievedPassword" class="forgot-password-form">
|
||||||
|
<div class="form-item">
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<el-icon class="input-icon"><User /></el-icon>
|
||||||
|
<input
|
||||||
|
v-model="forgotPasswordForm.username"
|
||||||
|
type="text"
|
||||||
|
placeholder="请输入账号"
|
||||||
|
class="forgot-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-item">
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<el-icon class="input-icon"><Phone /></el-icon>
|
||||||
|
<input
|
||||||
|
v-model="forgotPasswordForm.phone"
|
||||||
|
type="text"
|
||||||
|
placeholder="请输入手机号码"
|
||||||
|
class="forgot-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 显示找回的密码 -->
|
||||||
|
<div v-if="showRetrievedPassword" class="password-result">
|
||||||
|
<div class="result-title">您的密码是:</div>
|
||||||
|
<div class="password-display">{{ retrievedPassword }}</div>
|
||||||
|
<div class="result-actions">
|
||||||
|
<button class="copy-btn" @click="copyPassword">复制</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="forgot-footer">
|
||||||
|
<button class="back-btn" @click="backToLoginFromForgot">返回</button>
|
||||||
|
<button v-if="!showRetrievedPassword" class="confirm-btn" @click="handleForgotPasswordSubmit">确认</button>
|
||||||
|
<button v-if="showRetrievedPassword" class="confirm-btn" @click="backToLoginFromForgot">退出</button>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 自定义错误提示弹窗 -->
|
||||||
|
<div v-if="showErrorDialog" class="error-dialog-overlay" @click="closeErrorDialog">
|
||||||
|
<div class="error-dialog" @click.stop>
|
||||||
|
<div class="dialog-header">
|
||||||
|
<span class="dialog-title">提示</span>
|
||||||
|
<button class="close-btn" @click="closeErrorDialog">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-content">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<button class="cancel-btn" @click="closeErrorDialog">取消</button>
|
||||||
|
<button class="confirm-btn" @click="closeErrorDialog">确认</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -68,11 +212,15 @@ import { ref } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { useAuthStore } from '../stores'
|
import { useAuthStore } from '../stores'
|
||||||
import { User, Lock, View, Hide } from '@element-plus/icons-vue'
|
import { User, Lock, View, Hide, Phone } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
// 页面状态控制
|
||||||
|
const isRegisterMode = ref(false)
|
||||||
|
const isForgotPasswordMode = ref(false)
|
||||||
|
|
||||||
// 表单数据
|
// 表单数据
|
||||||
const form = ref({
|
const form = ref({
|
||||||
account: '',
|
account: '',
|
||||||
@ -86,10 +234,167 @@ const passwordVisible = ref(false)
|
|||||||
// 加载状态
|
// 加载状态
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
|
||||||
|
// 错误弹窗控制
|
||||||
|
const showErrorDialog = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
|
||||||
|
// 显示错误弹窗
|
||||||
|
const showError = (message) => {
|
||||||
|
errorMessage.value = message
|
||||||
|
showErrorDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭错误弹窗
|
||||||
|
const closeErrorDialog = () => {
|
||||||
|
showErrorDialog.value = false
|
||||||
|
errorMessage.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注册页面控制
|
||||||
|
const registerPasswordVisible = ref(false)
|
||||||
|
const confirmPasswordVisible = ref(false)
|
||||||
|
|
||||||
|
// 注册表单数据
|
||||||
|
const registerForm = ref({
|
||||||
|
name: '',
|
||||||
|
username: '',
|
||||||
|
phone: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// 忘记密码表单数据
|
||||||
|
const forgotPasswordForm = ref({
|
||||||
|
username: '',
|
||||||
|
phone: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// 找回的密码信息
|
||||||
|
const retrievedPassword = ref('')
|
||||||
|
const showRetrievedPassword = ref(false)
|
||||||
|
|
||||||
|
// 切换到注册页面
|
||||||
|
const switchToRegister = () => {
|
||||||
|
isRegisterMode.value = true
|
||||||
|
// 清空表单
|
||||||
|
registerForm.value = {
|
||||||
|
name: '',
|
||||||
|
username: '',
|
||||||
|
phone: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: ''
|
||||||
|
}
|
||||||
|
registerPasswordVisible.value = false
|
||||||
|
confirmPasswordVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换到登录页面
|
||||||
|
const switchToLogin = () => {
|
||||||
|
isRegisterMode.value = false
|
||||||
|
// 清空表单
|
||||||
|
registerForm.value = {
|
||||||
|
name: '',
|
||||||
|
username: '',
|
||||||
|
phone: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: ''
|
||||||
|
}
|
||||||
|
registerPasswordVisible.value = false
|
||||||
|
confirmPasswordVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注册提交处理
|
||||||
|
const handleRegisterSubmit = async () => {
|
||||||
|
// 验证姓名
|
||||||
|
if (!registerForm.value.name.trim()) {
|
||||||
|
showError('请输入姓名!')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证用户名
|
||||||
|
if (!registerForm.value.username.trim()) {
|
||||||
|
showError('请输入登录账号!')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证手机号
|
||||||
|
if (!registerForm.value.phone.trim()) {
|
||||||
|
showError('请输入手机号码!')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证手机号格式
|
||||||
|
const phoneRegex = /^1[3-9]\d{9}$/
|
||||||
|
if (!phoneRegex.test(registerForm.value.phone)) {
|
||||||
|
showError('手机号格式不正确,请重新输入!')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证密码
|
||||||
|
if (!registerForm.value.password) {
|
||||||
|
showError('请输入密码!')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证密码长度
|
||||||
|
if (registerForm.value.password.length < 6) {
|
||||||
|
showError('密码长度不能少于6位!')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证确认密码
|
||||||
|
if (!registerForm.value.confirmPassword) {
|
||||||
|
showError('请确认密码!')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证两次密码是否一致
|
||||||
|
if (registerForm.value.password !== registerForm.value.confirmPassword) {
|
||||||
|
showError('两次密码输入不一致,请重新输入!')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用注册API
|
||||||
|
try {
|
||||||
|
const result = await authStore.register({
|
||||||
|
name: registerForm.value.name,
|
||||||
|
username: registerForm.value.username,
|
||||||
|
phone: registerForm.value.phone,
|
||||||
|
password: registerForm.value.password
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
showError('用户注册成功!')
|
||||||
|
// 注册成功后返回登录页面
|
||||||
|
setTimeout(() => {
|
||||||
|
switchToLogin()
|
||||||
|
}, 2000)
|
||||||
|
} else {
|
||||||
|
// 显示具体的错误信息
|
||||||
|
if (result.error && result.error.includes('用户名已存在')) {
|
||||||
|
showError('用户名已存在,请更换用户名!')
|
||||||
|
} else if (result.error && result.error.includes('手机号已存在')) {
|
||||||
|
showError('手机号码已被注册,请更换手机号!')
|
||||||
|
} else {
|
||||||
|
showError(result.error || '注册失败,请重试!')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showError('注册失败,请检查网络连接后重试!')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 登录处理
|
// 登录处理
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
if (!form.value.account || !form.value.password) {
|
// 验证用户名
|
||||||
ElMessage.warning('请输入账号和密码')
|
if (!form.value.account) {
|
||||||
|
showError('用户名或密码错误,请重新输入!')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证密码
|
||||||
|
if (!form.value.password) {
|
||||||
|
showError('用户名或密码错误,请重新输入!')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -103,25 +408,144 @@ const handleLogin = async () => {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
ElMessage.success('登录成功')
|
ElMessage.success('登录成功')
|
||||||
router.push('/detection/1')
|
router.push('/dashboard')
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(result.error || '登录失败')
|
// 根据错误类型显示具体提示
|
||||||
|
if (result.error && result.error.includes('用户不存在')) {
|
||||||
|
showError('用户名或密码错误,请重新输入!')
|
||||||
|
} else if (result.error && result.error.includes('密码错误')) {
|
||||||
|
showError('用户名或密码错误,请重新输入!')
|
||||||
|
} else if (result.error && result.error.includes('用户未激活')) {
|
||||||
|
showError('账户未激活,请联系管理员激活后再登录')
|
||||||
|
} else {
|
||||||
|
showError('用户名或密码错误,请重新输入!')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error('登录失败:' + (error.message || '未知错误'))
|
showError('用户名或密码错误,请重新输入!')
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 注册处理
|
// 切换到忘记密码页面
|
||||||
const handleRegister = () => {
|
const switchToForgotPassword = () => {
|
||||||
router.push('/register')
|
isForgotPasswordMode.value = true
|
||||||
|
isRegisterMode.value = false
|
||||||
|
// 清空表单
|
||||||
|
forgotPasswordForm.value = {
|
||||||
|
username: '',
|
||||||
|
phone: ''
|
||||||
|
}
|
||||||
|
retrievedPassword.value = ''
|
||||||
|
showRetrievedPassword.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从忘记密码页面返回登录
|
||||||
|
const backToLoginFromForgot = () => {
|
||||||
|
isForgotPasswordMode.value = false
|
||||||
|
isRegisterMode.value = false
|
||||||
|
// 清空表单
|
||||||
|
forgotPasswordForm.value = {
|
||||||
|
username: '',
|
||||||
|
phone: ''
|
||||||
|
}
|
||||||
|
retrievedPassword.value = ''
|
||||||
|
showRetrievedPassword.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 忘记密码处理
|
// 忘记密码处理
|
||||||
const handleForgotPassword = () => {
|
const handleForgotPassword = () => {
|
||||||
router.push('/forgot-password')
|
switchToForgotPassword()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 找回密码提交处理
|
||||||
|
const handleForgotPasswordSubmit = async () => {
|
||||||
|
// 验证用户名
|
||||||
|
if (!forgotPasswordForm.value.username.trim()) {
|
||||||
|
showError('请输入用户名!')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证手机号
|
||||||
|
if (!forgotPasswordForm.value.phone.trim()) {
|
||||||
|
showError('请输入手机号码!')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证手机号格式
|
||||||
|
const phoneRegex = /^1[3-9]\d{9}$/
|
||||||
|
if (!phoneRegex.test(forgotPasswordForm.value.phone)) {
|
||||||
|
showError('手机号格式不正确,请重新输入!')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 调用后端API验证用户信息并获取密码
|
||||||
|
const response = await fetch('http://127.0.0.1:5000/api/auth/forgot-password', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: forgotPasswordForm.value.username,
|
||||||
|
phone: forgotPasswordForm.value.phone
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// 验证成功,显示密码
|
||||||
|
retrievedPassword.value = result.password
|
||||||
|
showRetrievedPassword.value = true
|
||||||
|
} else {
|
||||||
|
// 验证失败
|
||||||
|
if (result.error && result.error.includes('用户不存在')) {
|
||||||
|
showError('用户名不存在,请检查后重新输入!')
|
||||||
|
} else if (result.error && result.error.includes('手机号不匹配')) {
|
||||||
|
showError('手机号码不正确,请输入注册时填写的手机号码!')
|
||||||
|
} else {
|
||||||
|
showError('用户名和手机号码不匹配,请重新输入!')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showError('网络连接失败,请检查网络后重试!')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制密码到剪贴板
|
||||||
|
const copyPassword = async () => {
|
||||||
|
try {
|
||||||
|
// 优先使用现代 Clipboard API
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
await navigator.clipboard.writeText(retrievedPassword.value)
|
||||||
|
showError('密码已复制到剪贴板!')
|
||||||
|
} else {
|
||||||
|
// 备用方案:使用传统的 document.execCommand
|
||||||
|
const textArea = document.createElement('textarea')
|
||||||
|
textArea.value = retrievedPassword.value
|
||||||
|
textArea.style.position = 'fixed'
|
||||||
|
textArea.style.left = '-999999px'
|
||||||
|
textArea.style.top = '-999999px'
|
||||||
|
document.body.appendChild(textArea)
|
||||||
|
textArea.focus()
|
||||||
|
textArea.select()
|
||||||
|
|
||||||
|
const successful = document.execCommand('copy')
|
||||||
|
document.body.removeChild(textArea)
|
||||||
|
|
||||||
|
if (successful) {
|
||||||
|
showError('密码已复制到剪贴板!')
|
||||||
|
} else {
|
||||||
|
showError('复制失败,请手动复制密码:' + retrievedPassword.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('复制密码失败:', error)
|
||||||
|
// 最后的备用方案:显示密码让用户手动复制
|
||||||
|
showError('复制失败,请手动复制密码:' + retrievedPassword.value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -207,6 +631,17 @@ const handleForgotPassword = () => {
|
|||||||
padding: 30px 25px !important;
|
padding: 30px 25px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 注册卡片 */
|
||||||
|
.register-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 450px;
|
||||||
|
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 {
|
.card-header {
|
||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
@ -304,4 +739,331 @@ const handleForgotPassword = () => {
|
|||||||
.register-btn:hover {
|
.register-btn:hover {
|
||||||
background-color: #004080 !important;
|
background-color: #004080 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 自定义错误弹窗样式 */
|
||||||
|
.error-dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 9999;
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-dialog {
|
||||||
|
background-color: #003366;
|
||||||
|
border: 2px solid #00ffff;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 0 30px rgba(0, 255, 255, 0.3);
|
||||||
|
min-width: 400px;
|
||||||
|
max-width: 500px;
|
||||||
|
animation: dialogFadeIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dialogFadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9) translateY(-20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px 25px 15px;
|
||||||
|
border-bottom: 1px solid rgba(0, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-title {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: #00ffff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #00ffff;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
background-color: rgba(0, 255, 255, 0.1);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-content {
|
||||||
|
padding: 25px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 15px;
|
||||||
|
padding: 15px 25px 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn, .confirm-btn {
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border: 1px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn {
|
||||||
|
background-color: transparent;
|
||||||
|
border-color: #666;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn:hover {
|
||||||
|
background-color: rgba(102, 102, 102, 0.1);
|
||||||
|
border-color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-btn {
|
||||||
|
background-color: #00ffff;
|
||||||
|
border-color: #00ffff;
|
||||||
|
color: #003366;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-btn:hover {
|
||||||
|
background-color: #00e6e6;
|
||||||
|
border-color: #00e6e6;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 注册表单样式 */
|
||||||
|
.register-form {
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-item {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background-color: #004080;
|
||||||
|
border: 1px solid #00ffff;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0 15px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper:focus-within {
|
||||||
|
border-color: #00e6e6;
|
||||||
|
box-shadow: 0 0 8px rgba(0, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-icon {
|
||||||
|
color: #00ffff;
|
||||||
|
margin-right: 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-input {
|
||||||
|
flex: 1;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-input::placeholder {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-toggle {
|
||||||
|
color: #00ffff;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-toggle:hover {
|
||||||
|
color: #00e6e6;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 注册页面底部按钮 */
|
||||||
|
.register-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 15px;
|
||||||
|
margin-top: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn, .submit-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border: 1px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
background-color: transparent;
|
||||||
|
border-color: #00ffff;
|
||||||
|
color: #00ffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn:hover {
|
||||||
|
background-color: #004080;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
background-color: #00ffff;
|
||||||
|
border-color: #00ffff;
|
||||||
|
color: #003366;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn:hover {
|
||||||
|
background-color: #00e6e6;
|
||||||
|
border-color: #00e6e6;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 忘记密码页面样式 */
|
||||||
|
.forgot-password-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 450px;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forgot-password-form {
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forgot-input {
|
||||||
|
flex: 1;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forgot-input::placeholder {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 密码显示结果样式 */
|
||||||
|
.password-result {
|
||||||
|
text-align: center;
|
||||||
|
padding: 30px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-title {
|
||||||
|
color: #00ffff;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-display {
|
||||||
|
background-color: #004080;
|
||||||
|
border: 2px solid #00ffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 15px 20px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-actions {
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn {
|
||||||
|
background-color: #00ffff;
|
||||||
|
border: 1px solid #00ffff;
|
||||||
|
color: #003366;
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn:hover {
|
||||||
|
background-color: #00e6e6;
|
||||||
|
border-color: #00e6e6;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 忘记密码页面底部按钮 */
|
||||||
|
.forgot-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 15px;
|
||||||
|
margin-top: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border: 1px solid;
|
||||||
|
background-color: #00ffff;
|
||||||
|
border-color: #00ffff;
|
||||||
|
color: #003366;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-btn:hover {
|
||||||
|
background-color: #00e6e6;
|
||||||
|
border-color: #00e6e6;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 255, 255, 0.3);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
@ -1,538 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>前端WebSocket连接示例</title>
|
|
||||||
<script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
max-width: 800px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 20px;
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
}
|
|
||||||
.container {
|
|
||||||
background: white;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
.status {
|
|
||||||
padding: 10px;
|
|
||||||
margin: 10px 0;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.connected { background-color: #d4edda; color: #155724; }
|
|
||||||
.disconnected { background-color: #f8d7da; color: #721c24; }
|
|
||||||
.error { background-color: #fff3cd; color: #856404; }
|
|
||||||
button {
|
|
||||||
background-color: #007bff;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 10px 20px;
|
|
||||||
margin: 5px;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
button:hover { background-color: #0056b3; }
|
|
||||||
button:disabled {
|
|
||||||
background-color: #6c757d;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
#videoContainer {
|
|
||||||
margin-top: 20px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
#rtspImage {
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
border: 2px solid #ddd;
|
|
||||||
border-radius: 4px;
|
|
||||||
/* 防止图像缓存 */
|
|
||||||
image-rendering: auto;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
-moz-user-select: none;
|
|
||||||
-ms-user-select: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
.log {
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 10px;
|
|
||||||
margin-top: 20px;
|
|
||||||
height: 200px;
|
|
||||||
overflow-y: auto;
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<h1>前端WebSocket连接示例</h1>
|
|
||||||
|
|
||||||
<div id="status" class="status disconnected">未连接</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<button id="connectBtn" onclick="connectWebSocket()">连接WebSocket</button>
|
|
||||||
<button id="disconnectBtn" onclick="disconnectWebSocket()" disabled>断开连接</button>
|
|
||||||
<button id="startRtspBtn" onclick="startRtsp()" disabled>启动RTSP</button>
|
|
||||||
<button id="stopRtspBtn" onclick="stopRtsp()" disabled>停止RTSP</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="margin-top: 10px;">
|
|
||||||
<button onclick="checkBackendStatus()">检查后端状态</button>
|
|
||||||
<button onclick="testSocketConnection()">测试Socket连接</button>
|
|
||||||
<button onclick="clearLog()">清空日志</button>
|
|
||||||
<button onclick="showDebugInfo()">显示调试信息</button>
|
|
||||||
<button onclick="showMemoryInfo()">内存使用情况</button>
|
|
||||||
<button onclick="forceGarbageCollection()">强制垃圾回收</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="debugInfo" style="display: none; margin-top: 10px; padding: 10px; background-color: #e9ecef; border-radius: 4px;">
|
|
||||||
<h4>调试信息</h4>
|
|
||||||
<div id="debugContent"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="memoryInfo" style="display: none; margin-top: 10px; padding: 10px; background-color: #fff3cd; border-radius: 4px;">
|
|
||||||
<h4>内存使用情况</h4>
|
|
||||||
<div id="memoryContent"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="videoContainer">
|
|
||||||
<h3>RTSP视频流</h3>
|
|
||||||
<img id="rtspImage" src="" alt="RTSP视频流" style="display: none;">
|
|
||||||
<div id="noVideo">暂无视频流</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="log" id="logContainer"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
let socket = null;
|
|
||||||
let frameCount = 0;
|
|
||||||
let lastFrameTime = 0;
|
|
||||||
let imageCache = null; // 图像缓存,避免重复创建
|
|
||||||
let frameSkipCount = 0; // 跳帧计数
|
|
||||||
|
|
||||||
// 后端服务器地址配置
|
|
||||||
const BACKEND_URL = 'http://localhost:5000'; // 根据实际情况修改
|
|
||||||
// 如果是远程服务器,使用: 'http://192.168.1.173:5000'
|
|
||||||
|
|
||||||
// 内存优化配置
|
|
||||||
const FRAME_SKIP_THRESHOLD = 1; // 每3帧显示1帧
|
|
||||||
const MAX_FPS = 30; // 最大显示帧率
|
|
||||||
const MIN_FRAME_INTERVAL = 1000 / MAX_FPS; // 最小帧间隔(ms)
|
|
||||||
|
|
||||||
function log(message) {
|
|
||||||
const logContainer = document.getElementById('logContainer');
|
|
||||||
const timestamp = new Date().toLocaleTimeString();
|
|
||||||
logContainer.innerHTML += `[${timestamp}] ${message}<br>`;
|
|
||||||
logContainer.scrollTop = logContainer.scrollHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateStatus(status, className) {
|
|
||||||
const statusEl = document.getElementById('status');
|
|
||||||
statusEl.textContent = status;
|
|
||||||
statusEl.className = `status ${className}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateButtons(connected, rtspRunning = false) {
|
|
||||||
document.getElementById('connectBtn').disabled = connected;
|
|
||||||
document.getElementById('disconnectBtn').disabled = !connected;
|
|
||||||
document.getElementById('startRtspBtn').disabled = !connected || rtspRunning;
|
|
||||||
document.getElementById('stopRtspBtn').disabled = !connected || !rtspRunning;
|
|
||||||
}
|
|
||||||
|
|
||||||
function connectWebSocket() {
|
|
||||||
try {
|
|
||||||
log('正在连接到 ' + BACKEND_URL);
|
|
||||||
|
|
||||||
// 创建Socket.IO连接
|
|
||||||
socket = io(BACKEND_URL, {
|
|
||||||
transports: ['websocket', 'polling'],
|
|
||||||
timeout: 10000,
|
|
||||||
forceNew: true
|
|
||||||
});
|
|
||||||
|
|
||||||
// 连接成功事件
|
|
||||||
socket.on('connect', () => {
|
|
||||||
log('✅ WebSocket连接成功!Socket ID: ' + socket.id);
|
|
||||||
log('🔍 连接详情: ' + JSON.stringify({
|
|
||||||
connected: socket.connected,
|
|
||||||
id: socket.id,
|
|
||||||
transport: socket.io.engine.transport.name
|
|
||||||
}));
|
|
||||||
updateStatus('已连接', 'connected');
|
|
||||||
updateButtons(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 连接失败事件
|
|
||||||
socket.on('connect_error', (error) => {
|
|
||||||
log('❌ 连接失败: ' + error.message);
|
|
||||||
log('🔍 错误详情: ' + JSON.stringify(error));
|
|
||||||
updateStatus('连接失败', 'error');
|
|
||||||
updateButtons(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 断开连接事件
|
|
||||||
socket.on('disconnect', (reason) => {
|
|
||||||
log('⚠️ 连接断开: ' + reason);
|
|
||||||
updateStatus('已断开', 'disconnected');
|
|
||||||
updateButtons(false);
|
|
||||||
hideVideo();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 监听所有事件(调试用)
|
|
||||||
socket.onAny((eventName, ...args) => {
|
|
||||||
// log(`📨 收到事件: ${eventName}, 数据: ${JSON.stringify(args)}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 监听RTSP状态事件
|
|
||||||
socket.on('rtsp_status', (data) => {
|
|
||||||
log('📺 RTSP状态: ' + JSON.stringify(data));
|
|
||||||
if (data.status === 'started') {
|
|
||||||
updateButtons(true, true);
|
|
||||||
} else if (data.status === 'stopped') {
|
|
||||||
updateButtons(true, false);
|
|
||||||
hideVideo();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 监听RTSP帧数据
|
|
||||||
socket.on('rtsp_frame', (data) => {
|
|
||||||
if (data.image) {
|
|
||||||
frameCount++;
|
|
||||||
|
|
||||||
// 帧率控制和跳帧优化
|
|
||||||
const currentTime = Date.now();
|
|
||||||
if (currentTime - lastFrameTime < MIN_FRAME_INTERVAL) {
|
|
||||||
frameSkipCount++;
|
|
||||||
return; // 跳过此帧以控制帧率
|
|
||||||
}
|
|
||||||
|
|
||||||
// 每隔几帧显示一次,减少内存压力
|
|
||||||
if (frameCount % (FRAME_SKIP_THRESHOLD + 1) === 0) {
|
|
||||||
displayFrameOptimized(data.image);
|
|
||||||
lastFrameTime = currentTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (frameCount % 60 === 0) { // 每60帧记录一次
|
|
||||||
log(`🎬 已接收 ${frameCount} 帧,跳过 ${frameSkipCount} 帧`);
|
|
||||||
}
|
|
||||||
|
|
||||||
} else {
|
|
||||||
log('⚠️ 收到rtsp_frame事件但无图像数据: ' + JSON.stringify(data));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 监听错误事件
|
|
||||||
socket.on('error', (error) => {
|
|
||||||
log('❌ Socket错误: ' + JSON.stringify(error));
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
log('💥 连接异常: ' + error.message);
|
|
||||||
log('🔍 异常堆栈: ' + error.stack);
|
|
||||||
updateStatus('连接异常', 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function disconnectWebSocket() {
|
|
||||||
if (socket) {
|
|
||||||
socket.disconnect();
|
|
||||||
socket = null;
|
|
||||||
log('主动断开连接');
|
|
||||||
updateStatus('已断开', 'disconnected');
|
|
||||||
updateButtons(false);
|
|
||||||
hideVideo();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startRtsp() {
|
|
||||||
if (socket && socket.connected) {
|
|
||||||
log('🚀 发送start_rtsp事件');
|
|
||||||
log('🔍 Socket状态: ' + JSON.stringify({
|
|
||||||
connected: socket.connected,
|
|
||||||
id: socket.id,
|
|
||||||
transport: socket.io.engine.transport.name
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 发送事件并监听确认
|
|
||||||
socket.emit('start_rtsp', {}, (ack) => {
|
|
||||||
if (ack) {
|
|
||||||
log('✅ start_rtsp事件已确认: ' + JSON.stringify(ack));
|
|
||||||
} else {
|
|
||||||
log('⚠️ start_rtsp事件无确认响应');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
frameCount = 0;
|
|
||||||
|
|
||||||
// 设置超时检查
|
|
||||||
setTimeout(() => {
|
|
||||||
if (frameCount === 0) {
|
|
||||||
log('⏰ 5秒后仍未收到视频帧,可能存在问题');
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
} else {
|
|
||||||
log('❌ WebSocket未连接,无法启动RTSP');
|
|
||||||
log('🔍 Socket状态: ' + (socket ? '存在但未连接' : '不存在'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopRtsp() {
|
|
||||||
if (socket && socket.connected) {
|
|
||||||
log('🛑 发送stop_rtsp事件');
|
|
||||||
socket.emit('stop_rtsp', {}, (ack) => {
|
|
||||||
if (ack) {
|
|
||||||
log('✅ stop_rtsp事件已确认: ' + JSON.stringify(ack));
|
|
||||||
} else {
|
|
||||||
log('⚠️ stop_rtsp事件无确认响应');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
log('❌ WebSocket未连接,无法停止RTSP');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 优化的帧显示函数,减少内存泄漏
|
|
||||||
function displayFrameOptimized(base64Image) {
|
|
||||||
const img = document.getElementById('rtspImage');
|
|
||||||
const noVideo = document.getElementById('noVideo');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 直接设置图像源,避免创建额外的Image对象
|
|
||||||
const dataUrl = 'data:image/jpeg;base64,' + base64Image;
|
|
||||||
|
|
||||||
// 清理之前的图像缓存
|
|
||||||
if (imageCache) {
|
|
||||||
imageCache.src = '';
|
|
||||||
imageCache = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用requestAnimationFrame优化渲染
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
img.src = dataUrl;
|
|
||||||
img.style.display = 'block';
|
|
||||||
noVideo.style.display = 'none';
|
|
||||||
});
|
|
||||||
|
|
||||||
// 定期清理base64数据URL缓存
|
|
||||||
if (frameCount % 100 === 0) {
|
|
||||||
// 强制垃圾回收(如果浏览器支持)
|
|
||||||
if (window.gc) {
|
|
||||||
window.gc();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('显示帧失败:', error);
|
|
||||||
log('❌ 显示帧失败: ' + error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保留原始函数作为备用
|
|
||||||
function displayFrame(base64Image) {
|
|
||||||
displayFrameOptimized(base64Image);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideVideo() {
|
|
||||||
const img = document.getElementById('rtspImage');
|
|
||||||
const noVideo = document.getElementById('noVideo');
|
|
||||||
|
|
||||||
// 清理图像资源
|
|
||||||
img.style.display = 'none';
|
|
||||||
img.src = '';
|
|
||||||
noVideo.style.display = 'block';
|
|
||||||
|
|
||||||
// 清理缓存
|
|
||||||
if (imageCache) {
|
|
||||||
imageCache.src = '';
|
|
||||||
imageCache = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重置计数器
|
|
||||||
frameCount = 0;
|
|
||||||
frameSkipCount = 0;
|
|
||||||
lastFrameTime = 0;
|
|
||||||
|
|
||||||
// 强制垃圾回收
|
|
||||||
if (window.gc) {
|
|
||||||
window.gc();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查后端服务状态
|
|
||||||
function checkBackendStatus() {
|
|
||||||
log('🔍 检查后端服务状态...');
|
|
||||||
|
|
||||||
fetch(BACKEND_URL + '/health')
|
|
||||||
.then(response => {
|
|
||||||
if (response.ok) {
|
|
||||||
log('✅ 后端HTTP服务正常');
|
|
||||||
return response.text();
|
|
||||||
} else {
|
|
||||||
log('⚠️ 后端HTTP服务响应异常: ' + response.status);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then(data => {
|
|
||||||
if (data) {
|
|
||||||
log('📄 后端响应: ' + data);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
log('❌ 无法连接到后端服务: ' + error.message);
|
|
||||||
log('💡 请检查后端服务是否已启动,地址是否正确');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 测试Socket.IO连接
|
|
||||||
function testSocketConnection() {
|
|
||||||
log('🧪 测试Socket.IO连接...');
|
|
||||||
|
|
||||||
const testSocket = io(BACKEND_URL + '/socket.io/', {
|
|
||||||
transports: ['polling'],
|
|
||||||
timeout: 5000
|
|
||||||
});
|
|
||||||
|
|
||||||
testSocket.on('connect', () => {
|
|
||||||
log('✅ Socket.IO测试连接成功');
|
|
||||||
testSocket.disconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
testSocket.on('connect_error', (error) => {
|
|
||||||
log('❌ Socket.IO测试连接失败: ' + error.message);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清空日志
|
|
||||||
function clearLog() {
|
|
||||||
document.getElementById('logContainer').innerHTML = '';
|
|
||||||
log('🧹 日志已清空');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 显示调试信息
|
|
||||||
function showDebugInfo() {
|
|
||||||
const debugInfo = document.getElementById('debugInfo');
|
|
||||||
const debugContent = document.getElementById('debugContent');
|
|
||||||
|
|
||||||
let info = '<strong>当前状态:</strong><br>';
|
|
||||||
info += `Socket对象: ${socket ? '存在' : '不存在'}<br>`;
|
|
||||||
|
|
||||||
if (socket) {
|
|
||||||
info += `连接状态: ${socket.connected ? '已连接' : '未连接'}<br>`;
|
|
||||||
info += `Socket ID: ${socket.id || '无'}<br>`;
|
|
||||||
info += `传输方式: ${socket.io?.engine?.transport?.name || '未知'}<br>`;
|
|
||||||
info += `URL: ${socket.io?.uri || '未知'}<br>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
info += `<br><strong>配置信息:</strong><br>`;
|
|
||||||
info += `后端地址: ${BACKEND_URL}<br>`;
|
|
||||||
info += `接收帧数: ${frameCount}<br>`;
|
|
||||||
info += `页面URL: ${window.location.href}<br>`;
|
|
||||||
info += `用户代理: ${navigator.userAgent}<br>`;
|
|
||||||
|
|
||||||
debugContent.innerHTML = info;
|
|
||||||
debugInfo.style.display = debugInfo.style.display === 'none' ? 'block' : 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 显示内存使用情况
|
|
||||||
function showMemoryInfo() {
|
|
||||||
const memoryInfo = document.getElementById('memoryInfo');
|
|
||||||
const memoryContent = document.getElementById('memoryContent');
|
|
||||||
|
|
||||||
let info = '<strong>内存使用情况:</strong><br>';
|
|
||||||
|
|
||||||
// 检查浏览器内存API支持
|
|
||||||
if (performance.memory) {
|
|
||||||
const memory = performance.memory;
|
|
||||||
info += `已使用内存: ${(memory.usedJSHeapSize / 1024 / 1024).toFixed(2)} MB<br>`;
|
|
||||||
info += `总分配内存: ${(memory.totalJSHeapSize / 1024 / 1024).toFixed(2)} MB<br>`;
|
|
||||||
info += `内存限制: ${(memory.jsHeapSizeLimit / 1024 / 1024).toFixed(2)} MB<br>`;
|
|
||||||
info += `内存使用率: ${((memory.usedJSHeapSize / memory.jsHeapSizeLimit) * 100).toFixed(2)}%<br>`;
|
|
||||||
} else {
|
|
||||||
info += '浏览器不支持内存API<br>';
|
|
||||||
}
|
|
||||||
|
|
||||||
info += `<br><strong>优化状态:</strong><br>`;
|
|
||||||
info += `接收帧数: ${frameCount}<br>`;
|
|
||||||
info += `跳过帧数: ${frameSkipCount}<br>`;
|
|
||||||
info += `跳帧率: ${frameCount > 0 ? ((frameSkipCount / frameCount) * 100).toFixed(2) : 0}%<br>`;
|
|
||||||
info += `最大FPS限制: ${MAX_FPS}<br>`;
|
|
||||||
info += `帧跳过阈值: 每${FRAME_SKIP_THRESHOLD + 1}帧显示1帧<br>`;
|
|
||||||
|
|
||||||
memoryContent.innerHTML = info;
|
|
||||||
memoryInfo.style.display = memoryInfo.style.display === 'none' ? 'block' : 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 强制垃圾回收
|
|
||||||
function forceGarbageCollection() {
|
|
||||||
log('🧹 尝试强制垃圾回收...');
|
|
||||||
|
|
||||||
// 清理可能的内存泄漏
|
|
||||||
if (imageCache) {
|
|
||||||
imageCache.src = '';
|
|
||||||
imageCache = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 强制垃圾回收(如果浏览器支持)
|
|
||||||
if (window.gc) {
|
|
||||||
window.gc();
|
|
||||||
log('✅ 强制垃圾回收完成');
|
|
||||||
} else {
|
|
||||||
log('⚠️ 浏览器不支持强制垃圾回收');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 显示内存使用情况
|
|
||||||
setTimeout(() => {
|
|
||||||
showMemoryInfo();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 定期内存监控
|
|
||||||
function startMemoryMonitoring() {
|
|
||||||
setInterval(() => {
|
|
||||||
if (performance.memory && frameCount > 0) {
|
|
||||||
const memory = performance.memory;
|
|
||||||
const usagePercent = (memory.usedJSHeapSize / memory.jsHeapSizeLimit) * 100;
|
|
||||||
|
|
||||||
// 如果内存使用率超过80%,自动进行垃圾回收
|
|
||||||
if (usagePercent > 80) {
|
|
||||||
log(`⚠️ 内存使用率过高: ${usagePercent.toFixed(2)}%,自动清理...`);
|
|
||||||
forceGarbageCollection();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 10000); // 每10秒检查一次
|
|
||||||
}
|
|
||||||
|
|
||||||
// 页面加载完成后的初始化
|
|
||||||
window.onload = function() {
|
|
||||||
log('📄 页面加载完成,可以开始连接WebSocket');
|
|
||||||
log('🌐 后端地址: ' + BACKEND_URL);
|
|
||||||
log('⚠️ 请确保后端服务已启动');
|
|
||||||
log('🔧 内存优化已启用: 最大FPS=' + MAX_FPS + ', 跳帧阈值=' + (FRAME_SKIP_THRESHOLD + 1));
|
|
||||||
|
|
||||||
// 启动内存监控
|
|
||||||
startMemoryMonitoring();
|
|
||||||
|
|
||||||
// 自动检查后端状态
|
|
||||||
setTimeout(() => {
|
|
||||||
testSocketConnection();
|
|
||||||
}, 1000);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 页面关闭时清理连接
|
|
||||||
window.onbeforeunload = function() {
|
|
||||||
if (socket) {
|
|
||||||
socket.disconnect();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -1,382 +0,0 @@
|
|||||||
/**
|
|
||||||
* 前端WebSocket连接示例 - JavaScript版本
|
|
||||||
* 使用Socket.IO客户端连接Flask-SocketIO后端
|
|
||||||
*/
|
|
||||||
|
|
||||||
// 引入Socket.IO客户端库
|
|
||||||
// 在HTML中添加: <script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
|
|
||||||
// 或者使用npm安装: npm install socket.io-client
|
|
||||||
|
|
||||||
class WebSocketManager {
|
|
||||||
constructor() {
|
|
||||||
this.socket = null;
|
|
||||||
this.isConnected = false;
|
|
||||||
this.isRtspRunning = false;
|
|
||||||
this.frameCount = 0;
|
|
||||||
this.reconnectAttempts = 0;
|
|
||||||
this.maxReconnectAttempts = 5;
|
|
||||||
this.reconnectInterval = 3000;
|
|
||||||
|
|
||||||
// 后端服务器配置
|
|
||||||
this.BACKEND_URL = 'http://localhost:5000'; // 根据实际情况修改
|
|
||||||
// 如果是远程服务器,使用: 'http://192.168.1.173:5000'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 连接WebSocket服务器
|
|
||||||
*/
|
|
||||||
connect() {
|
|
||||||
try {
|
|
||||||
console.log(`正在连接到 ${this.BACKEND_URL}`);
|
|
||||||
|
|
||||||
// 创建Socket.IO连接
|
|
||||||
this.socket = io(this.BACKEND_URL, {
|
|
||||||
transports: ['websocket', 'polling'], // 支持WebSocket和轮询
|
|
||||||
timeout: 10000, // 连接超时时间
|
|
||||||
forceNew: true, // 强制创建新连接
|
|
||||||
autoConnect: true // 自动连接
|
|
||||||
});
|
|
||||||
|
|
||||||
this.setupEventListeners();
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('连接异常:', error);
|
|
||||||
this.onError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设置事件监听器
|
|
||||||
*/
|
|
||||||
setupEventListeners() {
|
|
||||||
// 连接成功事件
|
|
||||||
this.socket.on('connect', () => {
|
|
||||||
this.isConnected = true;
|
|
||||||
this.reconnectAttempts = 0;
|
|
||||||
console.log(`WebSocket连接成功!Socket ID: ${this.socket.id}`);
|
|
||||||
this.onConnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 连接失败事件
|
|
||||||
this.socket.on('connect_error', (error) => {
|
|
||||||
this.isConnected = false;
|
|
||||||
console.error('连接失败:', error.message);
|
|
||||||
this.onConnectError(error);
|
|
||||||
this.attemptReconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 断开连接事件
|
|
||||||
this.socket.on('disconnect', (reason) => {
|
|
||||||
this.isConnected = false;
|
|
||||||
this.isRtspRunning = false;
|
|
||||||
console.log('连接断开:', reason);
|
|
||||||
this.onDisconnect(reason);
|
|
||||||
|
|
||||||
// 如果不是主动断开,尝试重连
|
|
||||||
if (reason !== 'io client disconnect') {
|
|
||||||
this.attemptReconnect();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 监听RTSP状态事件
|
|
||||||
this.socket.on('rtsp_status', (data) => {
|
|
||||||
console.log('RTSP状态:', data);
|
|
||||||
this.handleRtspStatus(data);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 监听RTSP帧数据
|
|
||||||
this.socket.on('rtsp_frame', (data) => {
|
|
||||||
if (data.image) {
|
|
||||||
this.frameCount++;
|
|
||||||
this.onRtspFrame(data.image);
|
|
||||||
|
|
||||||
// 每30帧记录一次
|
|
||||||
if (this.frameCount % 30 === 0) {
|
|
||||||
console.log(`已接收 ${this.frameCount} 帧`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 断开WebSocket连接
|
|
||||||
*/
|
|
||||||
disconnect() {
|
|
||||||
if (this.socket) {
|
|
||||||
this.socket.disconnect();
|
|
||||||
this.socket = null;
|
|
||||||
this.isConnected = false;
|
|
||||||
this.isRtspRunning = false;
|
|
||||||
console.log('主动断开连接');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 启动RTSP视频流
|
|
||||||
*/
|
|
||||||
startRtsp() {
|
|
||||||
if (this.socket && this.isConnected) {
|
|
||||||
console.log('发送start_rtsp事件');
|
|
||||||
this.socket.emit('start_rtsp');
|
|
||||||
this.frameCount = 0;
|
|
||||||
} else {
|
|
||||||
console.warn('WebSocket未连接,无法启动RTSP');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 停止RTSP视频流
|
|
||||||
*/
|
|
||||||
stopRtsp() {
|
|
||||||
if (this.socket && this.isConnected) {
|
|
||||||
console.log('发送stop_rtsp事件');
|
|
||||||
this.socket.emit('stop_rtsp');
|
|
||||||
} else {
|
|
||||||
console.warn('WebSocket未连接,无法停止RTSP');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理RTSP状态变化
|
|
||||||
*/
|
|
||||||
handleRtspStatus(data) {
|
|
||||||
switch (data.status) {
|
|
||||||
case 'started':
|
|
||||||
this.isRtspRunning = true;
|
|
||||||
this.onRtspStarted();
|
|
||||||
break;
|
|
||||||
case 'stopped':
|
|
||||||
this.isRtspRunning = false;
|
|
||||||
this.onRtspStopped();
|
|
||||||
break;
|
|
||||||
case 'already_running':
|
|
||||||
console.log('RTSP已在运行中');
|
|
||||||
this.isRtspRunning = true;
|
|
||||||
break;
|
|
||||||
case 'error':
|
|
||||||
console.error('RTSP错误:', data.message);
|
|
||||||
this.onRtspError(data.message);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 尝试重连
|
|
||||||
*/
|
|
||||||
attemptReconnect() {
|
|
||||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
||||||
this.reconnectAttempts++;
|
|
||||||
console.log(`尝试重连 (${this.reconnectAttempts}/${this.maxReconnectAttempts})...`);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
this.connect();
|
|
||||||
}, this.reconnectInterval);
|
|
||||||
} else {
|
|
||||||
console.error('WebSocket重连失败,已达到最大重试次数');
|
|
||||||
this.onReconnectFailed();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 事件回调函数(可以被重写) ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 连接成功回调
|
|
||||||
*/
|
|
||||||
onConnect() {
|
|
||||||
// 可以在这里添加连接成功后的逻辑
|
|
||||||
console.log('WebSocket连接成功回调');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 连接错误回调
|
|
||||||
*/
|
|
||||||
onConnectError(error) {
|
|
||||||
// 可以在这里添加连接错误处理逻辑
|
|
||||||
console.log('WebSocket连接错误回调:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 断开连接回调
|
|
||||||
*/
|
|
||||||
onDisconnect(reason) {
|
|
||||||
// 可以在这里添加断开连接处理逻辑
|
|
||||||
console.log('WebSocket断开连接回调:', reason);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 通用错误回调
|
|
||||||
*/
|
|
||||||
onError(error) {
|
|
||||||
// 可以在这里添加错误处理逻辑
|
|
||||||
console.log('WebSocket错误回调:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RTSP启动回调
|
|
||||||
*/
|
|
||||||
onRtspStarted() {
|
|
||||||
console.log('RTSP视频流已启动');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RTSP停止回调
|
|
||||||
*/
|
|
||||||
onRtspStopped() {
|
|
||||||
console.log('RTSP视频流已停止');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RTSP错误回调
|
|
||||||
*/
|
|
||||||
onRtspError(message) {
|
|
||||||
console.error('RTSP错误:', message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RTSP帧数据回调
|
|
||||||
* @param {string} base64Image - Base64编码的图像数据
|
|
||||||
*/
|
|
||||||
onRtspFrame(base64Image) {
|
|
||||||
// 在这里处理接收到的视频帧
|
|
||||||
// 例如:显示在img元素中
|
|
||||||
const imgElement = document.getElementById('rtspImage');
|
|
||||||
if (imgElement) {
|
|
||||||
imgElement.src = 'data:image/jpeg;base64,' + base64Image;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 重连失败回调
|
|
||||||
*/
|
|
||||||
onReconnectFailed() {
|
|
||||||
console.error('WebSocket重连失败');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 使用示例 ==========
|
|
||||||
|
|
||||||
// 创建WebSocket管理器实例
|
|
||||||
const wsManager = new WebSocketManager();
|
|
||||||
|
|
||||||
// 重写回调函数(可选)
|
|
||||||
wsManager.onConnect = function() {
|
|
||||||
console.log('自定义连接成功处理');
|
|
||||||
// 连接成功后可以自动启动RTSP
|
|
||||||
// this.startRtsp();
|
|
||||||
};
|
|
||||||
|
|
||||||
wsManager.onRtspFrame = function(base64Image) {
|
|
||||||
// 自定义帧处理逻辑
|
|
||||||
const imgElement = document.getElementById('videoDisplay');
|
|
||||||
if (imgElement) {
|
|
||||||
imgElement.src = 'data:image/jpeg;base64,' + base64Image;
|
|
||||||
imgElement.style.display = 'block';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ========== 具体使用方法 ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 1. 连接WebSocket
|
|
||||||
*/
|
|
||||||
function connectWebSocket() {
|
|
||||||
wsManager.connect();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 2. 断开WebSocket
|
|
||||||
*/
|
|
||||||
function disconnectWebSocket() {
|
|
||||||
wsManager.disconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 3. 启动RTSP视频流
|
|
||||||
*/
|
|
||||||
function startRtspStream() {
|
|
||||||
wsManager.startRtsp();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 4. 停止RTSP视频流
|
|
||||||
*/
|
|
||||||
function stopRtspStream() {
|
|
||||||
wsManager.stopRtsp();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 5. 检查连接状态
|
|
||||||
*/
|
|
||||||
function checkConnectionStatus() {
|
|
||||||
console.log('连接状态:', wsManager.isConnected);
|
|
||||||
console.log('RTSP状态:', wsManager.isRtspRunning);
|
|
||||||
console.log('接收帧数:', wsManager.frameCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== HTML页面中的使用示例 ==========
|
|
||||||
/*
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>WebSocket RTSP示例</title>
|
|
||||||
<script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div>
|
|
||||||
<button onclick="connectWebSocket()">连接</button>
|
|
||||||
<button onclick="disconnectWebSocket()">断开</button>
|
|
||||||
<button onclick="startRtspStream()">启动RTSP</button>
|
|
||||||
<button onclick="stopRtspStream()">停止RTSP</button>
|
|
||||||
<button onclick="checkConnectionStatus()">检查状态</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3>视频显示</h3>
|
|
||||||
<img id="videoDisplay" style="max-width: 100%; display: none;" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="javascript_websocket_example.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ========== Node.js环境中的使用示例 ==========
|
|
||||||
/*
|
|
||||||
// 安装依赖: npm install socket.io-client
|
|
||||||
const { io } = require('socket.io-client');
|
|
||||||
|
|
||||||
// 然后使用上面的WebSocketManager类
|
|
||||||
const wsManager = new WebSocketManager();
|
|
||||||
wsManager.connect();
|
|
||||||
|
|
||||||
// 5秒后启动RTSP
|
|
||||||
setTimeout(() => {
|
|
||||||
wsManager.startRtsp();
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
// 30秒后停止RTSP并断开连接
|
|
||||||
setTimeout(() => {
|
|
||||||
wsManager.stopRtsp();
|
|
||||||
setTimeout(() => {
|
|
||||||
wsManager.disconnect();
|
|
||||||
}, 2000);
|
|
||||||
}, 30000);
|
|
||||||
*/
|
|
||||||
|
|
||||||
// 导出类(用于模块化)
|
|
||||||
if (typeof module !== 'undefined' && module.exports) {
|
|
||||||
module.exports = WebSocketManager;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 全局暴露(用于浏览器环境)
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.WebSocketManager = WebSocketManager;
|
|
||||||
window.wsManager = wsManager;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('WebSocket管理器已加载,可以使用 wsManager 实例或创建新的 WebSocketManager 实例');
|
|
||||||
console.log('使用方法:');
|
|
||||||
console.log('1. wsManager.connect() - 连接WebSocket');
|
|
||||||
console.log('2. wsManager.startRtsp() - 启动RTSP视频流');
|
|
||||||
console.log('3. wsManager.stopRtsp() - 停止RTSP视频流');
|
|
||||||
console.log('4. wsManager.disconnect() - 断开WebSocket连接');
|
|
@ -1,71 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
测试动态视频生成脚本
|
|
||||||
用于验证RTSP帧是否真的在变化
|
|
||||||
"""
|
|
||||||
|
|
||||||
import cv2
|
|
||||||
import numpy as np
|
|
||||||
import time
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
def create_test_video_source():
|
|
||||||
"""
|
|
||||||
创建一个测试视频源,生成动态变化的图像
|
|
||||||
"""
|
|
||||||
# 创建一个640x480的黑色背景
|
|
||||||
width, height = 640, 480
|
|
||||||
|
|
||||||
frame_count = 0
|
|
||||||
|
|
||||||
while True:
|
|
||||||
# 创建黑色背景
|
|
||||||
frame = np.zeros((height, width, 3), dtype=np.uint8)
|
|
||||||
|
|
||||||
# 添加动态元素
|
|
||||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]
|
|
||||||
|
|
||||||
# 添加时间戳
|
|
||||||
cv2.putText(frame, timestamp, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)
|
|
||||||
|
|
||||||
# 添加帧计数
|
|
||||||
cv2.putText(frame, f'Frame: {frame_count}', (10, 60), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)
|
|
||||||
|
|
||||||
# 添加移动的圆形
|
|
||||||
center_x = int(320 + 200 * np.sin(frame_count * 0.1))
|
|
||||||
center_y = int(240 + 100 * np.cos(frame_count * 0.1))
|
|
||||||
cv2.circle(frame, (center_x, center_y), 30, (255, 0, 0), -1)
|
|
||||||
|
|
||||||
# 添加变化的矩形
|
|
||||||
rect_size = int(50 + 30 * np.sin(frame_count * 0.05))
|
|
||||||
cv2.rectangle(frame, (500, 200), (500 + rect_size, 200 + rect_size), (0, 0, 255), -1)
|
|
||||||
|
|
||||||
# 添加随机噪点
|
|
||||||
noise = np.random.randint(0, 50, (height, width, 3), dtype=np.uint8)
|
|
||||||
frame = cv2.add(frame, noise)
|
|
||||||
|
|
||||||
frame_count += 1
|
|
||||||
|
|
||||||
yield frame
|
|
||||||
time.sleep(1/30) # 30 FPS
|
|
||||||
|
|
||||||
def test_rtsp_replacement():
|
|
||||||
"""
|
|
||||||
测试用动态视频源替换RTSP
|
|
||||||
"""
|
|
||||||
print("开始生成测试视频源...")
|
|
||||||
print("按 'q' 键退出")
|
|
||||||
|
|
||||||
video_source = create_test_video_source()
|
|
||||||
|
|
||||||
for frame in video_source:
|
|
||||||
cv2.imshow('Test Video Source', frame)
|
|
||||||
|
|
||||||
if cv2.waitKey(1) & 0xFF == ord('q'):
|
|
||||||
break
|
|
||||||
|
|
||||||
cv2.destroyAllWindows()
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
test_rtsp_replacement()
|
|
@ -1,222 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>截图功能测试</title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
margin: 20px;
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
}
|
|
||||||
.container {
|
|
||||||
max-width: 800px;
|
|
||||||
margin: 0 auto;
|
|
||||||
background: white;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
.test-area {
|
|
||||||
border: 2px dashed #ccc;
|
|
||||||
padding: 20px;
|
|
||||||
margin: 20px 0;
|
|
||||||
background: linear-gradient(45deg, #f0f0f0, #e0e0e0);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.button {
|
|
||||||
background: linear-gradient(to right, rgb(236, 50, 166), rgb(160, 5, 216));
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 10px 20px;
|
|
||||||
border-radius: 5px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 16px;
|
|
||||||
margin: 10px;
|
|
||||||
}
|
|
||||||
.button:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
.button:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
.status {
|
|
||||||
margin: 10px 0;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
|
||||||
.success {
|
|
||||||
background: #d4edda;
|
|
||||||
color: #155724;
|
|
||||||
border: 1px solid #c3e6cb;
|
|
||||||
}
|
|
||||||
.error {
|
|
||||||
background: #f8d7da;
|
|
||||||
color: #721c24;
|
|
||||||
border: 1px solid #f5c6cb;
|
|
||||||
}
|
|
||||||
.info {
|
|
||||||
background: #d1ecf1;
|
|
||||||
color: #0c5460;
|
|
||||||
border: 1px solid #bee5eb;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<h1>截图功能测试页面</h1>
|
|
||||||
|
|
||||||
<div id="detectare" class="test-area">
|
|
||||||
<h2>这是要截图的区域</h2>
|
|
||||||
<p>患者ID: 2101</p>
|
|
||||||
<p>患者姓名: 张三</p>
|
|
||||||
<p>测试时间: <span id="currentTime"></span></p>
|
|
||||||
<div style="display: flex; justify-content: space-around; margin: 20px 0;">
|
|
||||||
<div style="background: #ff6b6b; color: white; padding: 20px; border-radius: 10px;">
|
|
||||||
<h3>模块1</h3>
|
|
||||||
<p>数据: 85%</p>
|
|
||||||
</div>
|
|
||||||
<div style="background: #4ecdc4; color: white; padding: 20px; border-radius: 10px;">
|
|
||||||
<h3>模块2</h3>
|
|
||||||
<p>数据: 92%</p>
|
|
||||||
</div>
|
|
||||||
<div style="background: #45b7d1; color: white; padding: 20px; border-radius: 10px;">
|
|
||||||
<h3>模块3</h3>
|
|
||||||
<p>数据: 78%</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="text-align: center;">
|
|
||||||
<button id="screenshotBtn" class="button">📸 截图测试</button>
|
|
||||||
<button id="checkBackendBtn" class="button">🔍 检查后端</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="status"></div>
|
|
||||||
|
|
||||||
<div style="margin-top: 20px;">
|
|
||||||
<h3>使用说明:</h3>
|
|
||||||
<ol>
|
|
||||||
<li>确保后端服务已启动 (python debug_server.py)</li>
|
|
||||||
<li>点击"检查后端"按钮验证后端连接</li>
|
|
||||||
<li>点击"截图测试"按钮进行截图</li>
|
|
||||||
<li>截图将保存到 screenshots/2101_张三/ 文件夹中</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
|
|
||||||
<script>
|
|
||||||
const BACKEND_URL = 'http://localhost:5000';
|
|
||||||
|
|
||||||
// 更新当前时间
|
|
||||||
function updateTime() {
|
|
||||||
const now = new Date();
|
|
||||||
document.getElementById('currentTime').textContent = now.toLocaleString('zh-CN');
|
|
||||||
}
|
|
||||||
updateTime();
|
|
||||||
setInterval(updateTime, 1000);
|
|
||||||
|
|
||||||
// 显示状态消息
|
|
||||||
function showStatus(message, type = 'info') {
|
|
||||||
const statusDiv = document.getElementById('status');
|
|
||||||
statusDiv.innerHTML = `<div class="status ${type}">${message}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查后端连接
|
|
||||||
async function checkBackend() {
|
|
||||||
try {
|
|
||||||
showStatus('正在检查后端连接...', 'info');
|
|
||||||
const response = await fetch(`${BACKEND_URL}/health`);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.status === 'ok') {
|
|
||||||
showStatus('✅ 后端连接正常', 'success');
|
|
||||||
} else {
|
|
||||||
showStatus('❌ 后端响应异常', 'error');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showStatus(`❌ 后端连接失败: ${error.message}`, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 截图功能
|
|
||||||
async function takeScreenshot() {
|
|
||||||
const btn = document.getElementById('screenshotBtn');
|
|
||||||
|
|
||||||
try {
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = '📸 截图中...';
|
|
||||||
showStatus('正在生成截图...', 'info');
|
|
||||||
|
|
||||||
// 获取要截图的元素
|
|
||||||
const element = document.getElementById('detectare');
|
|
||||||
if (!element) {
|
|
||||||
throw new Error('未找到截图区域');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用html2canvas进行截图
|
|
||||||
const canvas = await html2canvas(element, {
|
|
||||||
useCORS: true,
|
|
||||||
allowTaint: true,
|
|
||||||
backgroundColor: '#ffffff',
|
|
||||||
scale: 1,
|
|
||||||
logging: false
|
|
||||||
});
|
|
||||||
|
|
||||||
// 转换为base64
|
|
||||||
const base64Image = canvas.toDataURL('image/png');
|
|
||||||
|
|
||||||
showStatus('正在保存截图...', 'info');
|
|
||||||
|
|
||||||
// 生成检查记录ID
|
|
||||||
const now = new Date();
|
|
||||||
const sessionId = now.getFullYear() +
|
|
||||||
String(now.getMonth() + 1).padStart(2, '0') +
|
|
||||||
String(now.getDate()).padStart(2, '0') +
|
|
||||||
String(now.getHours()).padStart(2, '0') +
|
|
||||||
String(now.getMinutes()).padStart(2, '0') +
|
|
||||||
String(now.getSeconds()).padStart(2, '0');
|
|
||||||
|
|
||||||
// 调用后端API保存截图
|
|
||||||
const response = await fetch(`${BACKEND_URL}/api/screenshots/save`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
patientId: '2101',
|
|
||||||
patientName: '张三',
|
|
||||||
sessionId: sessionId,
|
|
||||||
imageData: base64Image
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
showStatus(`✅ 截图保存成功!<br>文件路径: ${result.filepath}`, 'success');
|
|
||||||
} else {
|
|
||||||
throw new Error(result.message || '保存失败');
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('截图失败:', error);
|
|
||||||
showStatus(`❌ 截图失败: ${error.message}`, 'error');
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = '📸 截图测试';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 绑定事件
|
|
||||||
document.getElementById('screenshotBtn').addEventListener('click', takeScreenshot);
|
|
||||||
document.getElementById('checkBackendBtn').addEventListener('click', checkBackend);
|
|
||||||
|
|
||||||
// 页面加载时自动检查后端
|
|
||||||
window.addEventListener('load', checkBackend);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -1,53 +0,0 @@
|
|||||||
import socketio
|
|
||||||
import time
|
|
||||||
|
|
||||||
# 创建SocketIO客户端
|
|
||||||
sio = socketio.SimpleClient()
|
|
||||||
|
|
||||||
print('连接WebSocket并监听事件...')
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 连接到服务器
|
|
||||||
print('正在连接到192.168.1.173:5000...')
|
|
||||||
sio.connect('http://192.168.1.173:5000', wait_timeout=10)
|
|
||||||
print('WebSocket连接成功!')
|
|
||||||
|
|
||||||
# 发送启动RTSP事件
|
|
||||||
sio.emit('start_rtsp')
|
|
||||||
print('已发送start_rtsp事件,等待5秒接收数据...')
|
|
||||||
|
|
||||||
# 等待并接收事件
|
|
||||||
for i in range(5):
|
|
||||||
try:
|
|
||||||
# 接收事件
|
|
||||||
event = sio.receive(timeout=1)
|
|
||||||
if event:
|
|
||||||
event_name, data = event
|
|
||||||
print(f'收到事件: {event_name}, 数据类型: {type(data)}')
|
|
||||||
if event_name == 'rtsp_frame' and isinstance(data, dict) and 'image' in data:
|
|
||||||
print(f'收到图像数据,长度: {len(data["image"])} 字符')
|
|
||||||
elif event_name == 'rtsp_status':
|
|
||||||
print(f'RTSP状态: {data}')
|
|
||||||
except socketio.exceptions.TimeoutError:
|
|
||||||
print(f'等待事件超时 ({i+1}/5)')
|
|
||||||
|
|
||||||
# 发送停止RTSP事件
|
|
||||||
sio.emit('stop_rtsp')
|
|
||||||
print('已发送stop_rtsp事件')
|
|
||||||
|
|
||||||
# 等待停止状态事件
|
|
||||||
try:
|
|
||||||
event = sio.receive(timeout=2)
|
|
||||||
if event:
|
|
||||||
event_name, data = event
|
|
||||||
print(f'收到停止事件: {event_name}, 数据: {data}')
|
|
||||||
except socketio.exceptions.TimeoutError:
|
|
||||||
print('等待停止事件超时')
|
|
||||||
|
|
||||||
sio.disconnect()
|
|
||||||
print('WebSocket连接已断开')
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f'测试过程中发生错误: {e}')
|
|
||||||
|
|
||||||
print('WebSocket测试完成')
|
|
@ -1,421 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="websocket-example">
|
|
||||||
<div class="header">
|
|
||||||
<h2>Vue WebSocket连接示例</h2>
|
|
||||||
<div class="status" :class="connectionStatus">
|
|
||||||
{{ statusText }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="controls">
|
|
||||||
<button
|
|
||||||
@click="connectWebSocket"
|
|
||||||
:disabled="isConnected"
|
|
||||||
class="btn btn-primary"
|
|
||||||
>
|
|
||||||
连接WebSocket
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
@click="disconnectWebSocket"
|
|
||||||
:disabled="!isConnected"
|
|
||||||
class="btn btn-secondary"
|
|
||||||
>
|
|
||||||
断开连接
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
@click="startRtsp"
|
|
||||||
:disabled="!isConnected || isRtspRunning"
|
|
||||||
class="btn btn-success"
|
|
||||||
>
|
|
||||||
启动RTSP
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
@click="stopRtsp"
|
|
||||||
:disabled="!isConnected || !isRtspRunning"
|
|
||||||
class="btn btn-danger"
|
|
||||||
>
|
|
||||||
停止RTSP
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="video-container">
|
|
||||||
<h3>RTSP视频流</h3>
|
|
||||||
<div v-if="rtspImageSrc" class="video-wrapper">
|
|
||||||
<img :src="rtspImageSrc" alt="RTSP视频流" class="rtsp-image" />
|
|
||||||
<div class="frame-info">已接收帧数: {{ frameCount }}</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="no-video">
|
|
||||||
暂无视频流
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="log-container">
|
|
||||||
<h3>连接日志</h3>
|
|
||||||
<div class="log-content" ref="logContainer">
|
|
||||||
<div v-for="(log, index) in logs" :key="index" class="log-item">
|
|
||||||
<span class="log-time">[{{ log.time }}]</span>
|
|
||||||
<span class="log-message">{{ log.message }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
|
||||||
import { io } from 'socket.io-client'
|
|
||||||
|
|
||||||
// 响应式数据
|
|
||||||
const socket = ref(null)
|
|
||||||
const isConnected = ref(false)
|
|
||||||
const isRtspRunning = ref(false)
|
|
||||||
const rtspImageSrc = ref('')
|
|
||||||
const frameCount = ref(0)
|
|
||||||
const logs = ref([])
|
|
||||||
const logContainer = ref(null)
|
|
||||||
|
|
||||||
// 后端服务器配置
|
|
||||||
const BACKEND_URL = 'http://localhost:5000' // 根据实际情况修改
|
|
||||||
// 如果是远程服务器,使用: 'http://192.168.1.173:5000'
|
|
||||||
|
|
||||||
// 计算属性
|
|
||||||
const connectionStatus = computed(() => {
|
|
||||||
if (isConnected.value) return 'connected'
|
|
||||||
return 'disconnected'
|
|
||||||
})
|
|
||||||
|
|
||||||
const statusText = computed(() => {
|
|
||||||
if (isConnected.value) {
|
|
||||||
return isRtspRunning.value ? '已连接 - RTSP运行中' : '已连接'
|
|
||||||
}
|
|
||||||
return '未连接'
|
|
||||||
})
|
|
||||||
|
|
||||||
// 日志记录函数
|
|
||||||
const addLog = (message) => {
|
|
||||||
const now = new Date()
|
|
||||||
const time = now.toLocaleTimeString()
|
|
||||||
logs.value.push({ time, message })
|
|
||||||
|
|
||||||
// 限制日志数量
|
|
||||||
if (logs.value.length > 100) {
|
|
||||||
logs.value.shift()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 自动滚动到底部
|
|
||||||
nextTick(() => {
|
|
||||||
if (logContainer.value) {
|
|
||||||
logContainer.value.scrollTop = logContainer.value.scrollHeight
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// WebSocket连接函数
|
|
||||||
const connectWebSocket = () => {
|
|
||||||
try {
|
|
||||||
addLog(`正在连接到 ${BACKEND_URL}`)
|
|
||||||
|
|
||||||
// 创建Socket.IO连接
|
|
||||||
socket.value = io(BACKEND_URL, {
|
|
||||||
transports: ['websocket', 'polling'],
|
|
||||||
timeout: 10000,
|
|
||||||
forceNew: true
|
|
||||||
})
|
|
||||||
|
|
||||||
// 连接成功事件
|
|
||||||
socket.value.on('connect', () => {
|
|
||||||
isConnected.value = true
|
|
||||||
addLog(`WebSocket连接成功!Socket ID: ${socket.value.id}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 连接失败事件
|
|
||||||
socket.value.on('connect_error', (error) => {
|
|
||||||
isConnected.value = false
|
|
||||||
addLog(`连接失败: ${error.message}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 断开连接事件
|
|
||||||
socket.value.on('disconnect', (reason) => {
|
|
||||||
isConnected.value = false
|
|
||||||
isRtspRunning.value = false
|
|
||||||
rtspImageSrc.value = ''
|
|
||||||
addLog(`连接断开: ${reason}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 监听RTSP状态事件
|
|
||||||
socket.value.on('rtsp_status', (data) => {
|
|
||||||
addLog(`RTSP状态: ${JSON.stringify(data)}`)
|
|
||||||
if (data.status === 'started') {
|
|
||||||
isRtspRunning.value = true
|
|
||||||
frameCount.value = 0
|
|
||||||
} else if (data.status === 'stopped') {
|
|
||||||
isRtspRunning.value = false
|
|
||||||
rtspImageSrc.value = ''
|
|
||||||
} else if (data.status === 'already_running') {
|
|
||||||
addLog('RTSP已在运行中')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 监听RTSP帧数据
|
|
||||||
socket.value.on('rtsp_frame', (data) => {
|
|
||||||
if (data.image) {
|
|
||||||
frameCount.value++
|
|
||||||
rtspImageSrc.value = 'data:image/jpeg;base64,' + data.image
|
|
||||||
|
|
||||||
// 每30帧记录一次
|
|
||||||
if (frameCount.value % 30 === 0) {
|
|
||||||
addLog(`已接收 ${frameCount.value} 帧`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
addLog(`连接异常: ${error.message}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 断开WebSocket连接
|
|
||||||
const disconnectWebSocket = () => {
|
|
||||||
if (socket.value) {
|
|
||||||
socket.value.disconnect()
|
|
||||||
socket.value = null
|
|
||||||
isConnected.value = false
|
|
||||||
isRtspRunning.value = false
|
|
||||||
rtspImageSrc.value = ''
|
|
||||||
addLog('主动断开连接')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 启动RTSP
|
|
||||||
const startRtsp = () => {
|
|
||||||
if (socket.value && isConnected.value) {
|
|
||||||
addLog('发送start_rtsp事件')
|
|
||||||
socket.value.emit('start_rtsp')
|
|
||||||
} else {
|
|
||||||
addLog('WebSocket未连接,无法启动RTSP')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 停止RTSP
|
|
||||||
const stopRtsp = () => {
|
|
||||||
if (socket.value && isConnected.value) {
|
|
||||||
addLog('发送stop_rtsp事件')
|
|
||||||
socket.value.emit('stop_rtsp')
|
|
||||||
} else {
|
|
||||||
addLog('WebSocket未连接,无法停止RTSP')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生命周期钩子
|
|
||||||
onMounted(() => {
|
|
||||||
addLog('组件已挂载,可以开始连接WebSocket')
|
|
||||||
addLog(`后端地址: ${BACKEND_URL}`)
|
|
||||||
addLog('请确保后端服务已启动')
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
// 组件卸载时清理连接
|
|
||||||
if (socket.value) {
|
|
||||||
socket.value.disconnect()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.websocket-example {
|
|
||||||
max-width: 1000px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 20px;
|
|
||||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
padding-bottom: 15px;
|
|
||||||
border-bottom: 2px solid #e9ecef;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header h2 {
|
|
||||||
margin: 0;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status {
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.connected {
|
|
||||||
background-color: #d4edda;
|
|
||||||
color: #155724;
|
|
||||||
border: 1px solid #c3e6cb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.disconnected {
|
|
||||||
background-color: #f8d7da;
|
|
||||||
color: #721c24;
|
|
||||||
border: 1px solid #f5c6cb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
padding: 10px 20px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 5px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background-color: #007bff;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
|
||||||
background-color: #0056b3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background-color: #6c757d;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover:not(:disabled) {
|
|
||||||
background-color: #545b62;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-success {
|
|
||||||
background-color: #28a745;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-success:hover:not(:disabled) {
|
|
||||||
background-color: #1e7e34;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
background-color: #dc3545;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger:hover:not(:disabled) {
|
|
||||||
background-color: #c82333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-container {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
padding: 20px;
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-container h3 {
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-wrapper {
|
|
||||||
text-align: center;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rtsp-image {
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
border: 2px solid #ddd;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.frame-info {
|
|
||||||
margin-top: 10px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #666;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-video {
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px;
|
|
||||||
color: #6c757d;
|
|
||||||
font-style: italic;
|
|
||||||
background-color: #e9ecef;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-container {
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-container h3 {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-content {
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 15px;
|
|
||||||
height: 200px;
|
|
||||||
overflow-y: auto;
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-item {
|
|
||||||
margin-bottom: 5px;
|
|
||||||
word-wrap: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-time {
|
|
||||||
color: #6c757d;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-message {
|
|
||||||
margin-left: 8px;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 响应式设计 */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.header {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
Loading…
Reference in New Issue
Block a user