Merge branch 'dev-v15' of http://121.37.111.42:3000/ThbTech/BodyBalanceEvaluation into dev-v15
This commit is contained in:
commit
c71f07f931
@ -60,7 +60,7 @@ pressure_port = COM5
|
||||
pressure_baudrate = 115200
|
||||
|
||||
[REMOTE]
|
||||
enable = False
|
||||
enable = True
|
||||
port = COM6
|
||||
baudrate = 115200
|
||||
timeout = 0.1
|
||||
|
||||
@ -830,7 +830,7 @@ class DatabaseManager:
|
||||
cursor.execute('''
|
||||
SELECT * FROM detection_data
|
||||
WHERE session_id = ?
|
||||
ORDER BY timestamp
|
||||
ORDER BY timestamp desc
|
||||
''', (session_id,))
|
||||
|
||||
data_rows = cursor.fetchall()
|
||||
@ -849,7 +849,7 @@ class DatabaseManager:
|
||||
cursor.execute('''
|
||||
SELECT * FROM detection_video
|
||||
WHERE session_id = ?
|
||||
ORDER BY timestamp
|
||||
ORDER BY timestamp desc
|
||||
''', (session_id,))
|
||||
|
||||
video_rows = cursor.fetchall()
|
||||
@ -1103,12 +1103,15 @@ class DatabaseManager:
|
||||
|
||||
cursor.execute('''
|
||||
INSERT INTO detection_video (
|
||||
id, session_id, screen_video, timestamp
|
||||
) VALUES (?, ?, ?, ?)
|
||||
id, session_id, screen_video, body_video, foot_video1, foot_video2, timestamp
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
''', (
|
||||
video_id,
|
||||
session_id,
|
||||
video.get('screen_video_path'),
|
||||
video.get('body_video_path'),
|
||||
video.get('foot_video1_path'),
|
||||
video.get('foot_video2_path'),
|
||||
china_time
|
||||
))
|
||||
|
||||
@ -1120,6 +1123,50 @@ class DatabaseManager:
|
||||
logger.error(f'保存检测视频失败: {e}')
|
||||
return False
|
||||
|
||||
def update_detection_video_latest(self, session_id: str, video: Dict[str, Any]) -> bool:
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
cursor.execute('''
|
||||
SELECT id, screen_video, body_video, foot_video1, foot_video2
|
||||
FROM detection_video
|
||||
WHERE session_id = ?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 1
|
||||
''', (session_id,))
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
return self.save_detection_video(session_id, video)
|
||||
|
||||
row_dict = dict(row)
|
||||
video_id = row_dict.get('id')
|
||||
|
||||
screen_video = video.get('screen_video_path')
|
||||
body_video = video.get('body_video_path')
|
||||
foot_video1 = video.get('foot_video1_path')
|
||||
foot_video2 = video.get('foot_video2_path')
|
||||
|
||||
if not screen_video:
|
||||
screen_video = row_dict.get('screen_video')
|
||||
if not body_video:
|
||||
body_video = row_dict.get('body_video')
|
||||
if not foot_video1:
|
||||
foot_video1 = row_dict.get('foot_video1')
|
||||
if not foot_video2:
|
||||
foot_video2 = row_dict.get('foot_video2')
|
||||
|
||||
cursor.execute('''
|
||||
UPDATE detection_video
|
||||
SET screen_video = ?, body_video = ?, foot_video1 = ?, foot_video2 = ?
|
||||
WHERE id = ?
|
||||
''', (screen_video, body_video, foot_video1, foot_video2, video_id))
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error(f'更新检测视频失败: {e}')
|
||||
return False
|
||||
|
||||
def delete_detection_video(self, video_ids: Union[str, List[str]]) -> bool:
|
||||
"""删除检测视频记录(支持单个或多个ID)"""
|
||||
conn = self.get_connection()
|
||||
|
||||
@ -14,6 +14,7 @@ from typing import Optional, Dict, Any
|
||||
import logging
|
||||
import queue
|
||||
import gc
|
||||
import os
|
||||
|
||||
try:
|
||||
from .base_device import BaseDevice
|
||||
@ -110,7 +111,19 @@ class CameraManager(BaseDevice):
|
||||
}
|
||||
|
||||
# 全局帧队列(用于录制)
|
||||
self.frame_queue = queue.Queue(maxsize=10) # 最大长度10,自动丢弃旧帧
|
||||
self.frame_queue = queue.Queue(maxsize=10)
|
||||
|
||||
self._recording_enabled = False
|
||||
self._recording_session_id = None
|
||||
self._recording_frames_dir = None
|
||||
self._recording_target_fps = None
|
||||
self._recording_last_ts = 0.0
|
||||
self._recording_index = 0
|
||||
self._recording_written = 0
|
||||
self._recording_drop = 0
|
||||
self._recording_queue = queue.Queue(maxsize=300)
|
||||
self._recording_thread = None
|
||||
self._recording_stop_event = threading.Event()
|
||||
|
||||
# 属性缓存机制 - 避免重复设置相同属性值
|
||||
self._property_cache = {}
|
||||
@ -126,6 +139,130 @@ class CameraManager(BaseDevice):
|
||||
|
||||
self.logger.info(f"相机管理器初始化完成 - 设备索引: {self.device_index}")
|
||||
|
||||
def start_jpeg_recording(self, session_id: str, frames_dir: str, record_fps: Optional[int] = None) -> Dict[str, Any]:
|
||||
try:
|
||||
if not session_id:
|
||||
return {'success': False, 'message': '缺少session_id'}
|
||||
if not frames_dir:
|
||||
return {'success': False, 'message': '缺少frames_dir'}
|
||||
|
||||
try:
|
||||
os.makedirs(frames_dir, exist_ok=True)
|
||||
except Exception as e:
|
||||
return {'success': False, 'message': f'创建录制目录失败: {e}'}
|
||||
|
||||
if record_fps is None:
|
||||
record_fps = int(self.config_manager.get_config_value('CAMERA_RECORDING', 'fps', fallback='10'))
|
||||
record_fps = max(1, int(record_fps))
|
||||
|
||||
self._recording_session_id = session_id
|
||||
self._recording_frames_dir = frames_dir
|
||||
self._recording_target_fps = record_fps
|
||||
self._recording_last_ts = 0.0
|
||||
self._recording_index = 0
|
||||
self._recording_written = 0
|
||||
self._recording_drop = 0
|
||||
self._recording_stop_event.clear()
|
||||
self._recording_enabled = True
|
||||
|
||||
if not self._recording_thread or not self._recording_thread.is_alive():
|
||||
self._recording_thread = threading.Thread(
|
||||
target=self._recording_writer_loop,
|
||||
name=f"{self.device_id}-JpegWriter",
|
||||
daemon=True
|
||||
)
|
||||
self._recording_thread.start()
|
||||
|
||||
return {'success': True, 'message': '相机JPEG录制已启动', 'device_id': self.device_id, 'fps': record_fps, 'frames_dir': frames_dir}
|
||||
except Exception as e:
|
||||
return {'success': False, 'message': str(e)}
|
||||
|
||||
def stop_jpeg_recording(self, session_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
try:
|
||||
if session_id and self._recording_session_id and session_id != self._recording_session_id:
|
||||
return {'success': False, 'message': 'session_id不匹配'}
|
||||
|
||||
self._recording_enabled = False
|
||||
self._recording_session_id = None
|
||||
frames_dir = self._recording_frames_dir
|
||||
fps = self._recording_target_fps
|
||||
written = int(self._recording_written)
|
||||
dropped = int(self._recording_drop)
|
||||
|
||||
self._recording_frames_dir = None
|
||||
self._recording_target_fps = None
|
||||
self._recording_last_ts = 0.0
|
||||
|
||||
self._recording_stop_event.set()
|
||||
if self._recording_thread and self._recording_thread.is_alive():
|
||||
self._recording_thread.join(timeout=2.0)
|
||||
self._recording_thread = None
|
||||
|
||||
while not self._recording_queue.empty():
|
||||
try:
|
||||
self._recording_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
self._recording_stop_event.clear()
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': '相机JPEG录制已停止',
|
||||
'device_id': self.device_id,
|
||||
'frames_dir': frames_dir,
|
||||
'fps': fps,
|
||||
'frames_written': written,
|
||||
'frames_dropped': dropped
|
||||
}
|
||||
except Exception as e:
|
||||
return {'success': False, 'message': str(e)}
|
||||
|
||||
def _recording_writer_loop(self):
|
||||
while not self._recording_stop_event.is_set():
|
||||
try:
|
||||
item = self._recording_queue.get(timeout=0.2)
|
||||
except queue.Empty:
|
||||
continue
|
||||
|
||||
try:
|
||||
frames_dir, idx, jpeg_bytes = item
|
||||
if not frames_dir or not jpeg_bytes:
|
||||
continue
|
||||
filename = f"frame_{idx:06d}.jpg"
|
||||
fpath = os.path.join(frames_dir, filename)
|
||||
with open(fpath, 'wb') as f:
|
||||
f.write(jpeg_bytes)
|
||||
self._recording_written += 1
|
||||
except Exception:
|
||||
self._recording_drop += 1
|
||||
finally:
|
||||
try:
|
||||
self._recording_queue.task_done()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _maybe_enqueue_recording(self, timestamp: float, frame_bytes: bytes):
|
||||
if not self._recording_enabled:
|
||||
return
|
||||
frames_dir = self._recording_frames_dir
|
||||
target_fps = self._recording_target_fps
|
||||
if not frames_dir or not target_fps or target_fps <= 0:
|
||||
return
|
||||
|
||||
if self._recording_last_ts > 0:
|
||||
min_interval = 1.0 / float(target_fps)
|
||||
if (timestamp - self._recording_last_ts) < min_interval:
|
||||
return
|
||||
|
||||
idx = self._recording_index
|
||||
self._recording_index += 1
|
||||
self._recording_last_ts = timestamp
|
||||
try:
|
||||
self._recording_queue.put_nowait((frames_dir, idx, frame_bytes))
|
||||
except queue.Full:
|
||||
self._recording_drop += 1
|
||||
|
||||
def _set_property_optimized(self, prop, value):
|
||||
"""
|
||||
优化的属性设置方法,使用缓存避免重复设置
|
||||
@ -649,23 +786,6 @@ class CameraManager(BaseDevice):
|
||||
# 更新心跳时间,防止连接监控线程判定为超时
|
||||
self.update_heartbeat()
|
||||
|
||||
# 保存原始帧到队列(用于录制)
|
||||
try:
|
||||
self.frame_queue.put_nowait({
|
||||
'frame': frame.copy(),
|
||||
'timestamp': time.time()
|
||||
})
|
||||
except queue.Full:
|
||||
# 队列满时丢弃最旧的帧,添加新帧
|
||||
try:
|
||||
self.frame_queue.get_nowait() # 移除最旧的帧
|
||||
self.frame_queue.put_nowait({
|
||||
'frame': frame.copy(),
|
||||
'timestamp': time.time()
|
||||
})
|
||||
except queue.Empty:
|
||||
pass # 队列为空,忽略
|
||||
|
||||
# 处理帧(降采样以优化传输负载)
|
||||
processed_frame = self._process_frame(frame)
|
||||
|
||||
@ -740,6 +860,7 @@ class CameraManager(BaseDevice):
|
||||
|
||||
# 转换为bytes再做base64,减少中间numpy对象的长生命周期
|
||||
frame_bytes = buffer.tobytes()
|
||||
self._maybe_enqueue_recording(time.time(), frame_bytes)
|
||||
frame_data = base64.b64encode(frame_bytes).decode('utf-8')
|
||||
|
||||
# 发送数据
|
||||
@ -986,6 +1107,21 @@ class CameraManager(BaseDevice):
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
self._recording_enabled = False
|
||||
self._recording_session_id = None
|
||||
self._recording_frames_dir = None
|
||||
self._recording_target_fps = None
|
||||
self._recording_last_ts = 0.0
|
||||
self._recording_stop_event.set()
|
||||
if self._recording_thread and self._recording_thread.is_alive():
|
||||
self._recording_thread.join(timeout=2.0)
|
||||
self._recording_thread = None
|
||||
while not self._recording_queue.empty():
|
||||
try:
|
||||
self._recording_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
self.last_frame = None
|
||||
|
||||
super().cleanup()
|
||||
|
||||
@ -291,9 +291,11 @@ class DeviceCoordinator:
|
||||
from .remote_control_manager import RemoteControlManager
|
||||
remote = RemoteControlManager(self.socketio, self.config_manager)
|
||||
self.devices['remote'] = remote
|
||||
if remote.initialize():
|
||||
return True
|
||||
return False
|
||||
if not remote.initialize():
|
||||
return False
|
||||
if hasattr(remote, 'start_streaming'):
|
||||
return bool(remote.start_streaming())
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"初始化遥控器失败: {e}")
|
||||
return False
|
||||
@ -743,6 +745,12 @@ class DeviceCoordinator:
|
||||
except ImportError:
|
||||
from femtobolt_manager import FemtoBoltManager
|
||||
new_device = FemtoBoltManager(self.socketio, self.config_manager)
|
||||
elif device_name == 'remote':
|
||||
try:
|
||||
from .remote_control_manager import RemoteControlManager
|
||||
except ImportError:
|
||||
from remote_control_manager import RemoteControlManager
|
||||
new_device = RemoteControlManager(self.socketio, self.config_manager)
|
||||
else:
|
||||
raise ValueError(f"未知的设备类型: {device_name}")
|
||||
|
||||
@ -777,6 +785,17 @@ class DeviceCoordinator:
|
||||
init_time = (time.time() - init_start) * 1000
|
||||
self.logger.info(f"{device_name} 设备初始化成功 (耗时: {init_time:.1f}ms)")
|
||||
|
||||
if device_name == 'remote' and hasattr(new_device, 'start_streaming'):
|
||||
self.logger.info(f"正在启动 {device_name} 设备推流...")
|
||||
try:
|
||||
if not new_device.start_streaming():
|
||||
self.logger.error(f"启动 {device_name} 设备推流失败")
|
||||
return False
|
||||
was_streaming = True
|
||||
except Exception as e:
|
||||
self.logger.error(f"启动 {device_name} 推流异常: {e}")
|
||||
return False
|
||||
|
||||
# 设备初始化成功后,确保状态广播正确
|
||||
# 此时设备应该已经通过initialize()方法中的set_connected(True)触发了状态变化通知
|
||||
# 但为了确保状态一致性,我们再次确认状态
|
||||
|
||||
@ -66,7 +66,7 @@ class RemoteControlManager(BaseDevice):
|
||||
def initialize(self) -> bool:
|
||||
try:
|
||||
self.logger.info(f"初始化遥控器串口: {self.port}, {self.baudrate}bps, 8N1")
|
||||
self.set_connected(True)
|
||||
self.set_connected(False)
|
||||
self._device_info['initialized_at'] = time.time()
|
||||
return True
|
||||
except Exception as e:
|
||||
@ -88,6 +88,9 @@ class RemoteControlManager(BaseDevice):
|
||||
stopbits=self.stopbits,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
self.set_connected(True)
|
||||
self.update_heartbeat()
|
||||
self.is_streaming = True
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._worker_loop, daemon=True)
|
||||
self._thread.start()
|
||||
@ -110,6 +113,9 @@ class RemoteControlManager(BaseDevice):
|
||||
self._thread.join(timeout=2.0)
|
||||
if self._ser and self._ser.is_open:
|
||||
self._ser.close()
|
||||
self._ser = None
|
||||
self.set_connected(False)
|
||||
self.is_streaming = False
|
||||
self.logger.info("遥控器串口监听已停止")
|
||||
return True
|
||||
except Exception as e:
|
||||
@ -248,6 +254,7 @@ class RemoteControlManager(BaseDevice):
|
||||
time.sleep(0.05)
|
||||
continue
|
||||
chunk = self._ser.read(64)
|
||||
self.update_heartbeat()
|
||||
if chunk:
|
||||
try:
|
||||
hexstr = ' '.join(f'{b:02X}' for b in chunk)
|
||||
@ -262,5 +269,14 @@ class RemoteControlManager(BaseDevice):
|
||||
self.logger.debug("遥控器串口暂无数据")
|
||||
except Exception as e:
|
||||
self.logger.error(f"遥控器串口读取异常: {e}")
|
||||
try:
|
||||
if self._ser and self._ser.is_open:
|
||||
self._ser.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._ser = None
|
||||
self.set_connected(False)
|
||||
self.is_streaming = False
|
||||
self._running = False
|
||||
time.sleep(0.1)
|
||||
self.logger.info("遥控器串口线程结束")
|
||||
|
||||
@ -13,7 +13,8 @@ import signal
|
||||
import base64
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Optional
|
||||
from typing import Dict, Any, List, Optional, Callable
|
||||
import threading
|
||||
|
||||
try:
|
||||
import pyautogui
|
||||
@ -39,11 +40,38 @@ class RecordingManager:
|
||||
# FFmpeg进程管理
|
||||
self._ffmpeg_processes = {}
|
||||
self._ffmpeg_meta = {}
|
||||
self._transcode_threads = {}
|
||||
|
||||
# 默认参数
|
||||
self.screen_fps = 25
|
||||
self.screen_size = self._get_screen_size()
|
||||
|
||||
def _resolve_ffmpeg_path(self) -> Optional[str]:
|
||||
ffmpeg_path = None
|
||||
if self.config_manager:
|
||||
ffmpeg_path = (
|
||||
self.config_manager.get_config_value('SCREEN_RECORDING', 'ffmpeg_path', fallback=None) or
|
||||
self.config_manager.get_config_value('RECORDING', 'ffmpeg_path', fallback=None)
|
||||
)
|
||||
if ffmpeg_path and os.path.isfile(str(ffmpeg_path)):
|
||||
return str(ffmpeg_path)
|
||||
base_dir = os.path.dirname(sys.executable) if getattr(sys, 'frozen', False) else os.path.dirname(os.path.abspath(__file__))
|
||||
alt_path = os.path.join(base_dir, 'ffmpeg', 'bin', 'ffmpeg.exe')
|
||||
if os.path.isfile(alt_path):
|
||||
return alt_path
|
||||
return None
|
||||
|
||||
def get_active_video_base(self, session_id: str) -> Optional[Dict[str, Any]]:
|
||||
meta = self._ffmpeg_meta.get('screen') or {}
|
||||
if meta.get('session_id') != session_id:
|
||||
return None
|
||||
return {
|
||||
'base_path': meta.get('base_path'),
|
||||
'file_dir': meta.get('file_dir'),
|
||||
'patient_id': meta.get('patient_id'),
|
||||
'session_id': meta.get('session_id')
|
||||
}
|
||||
|
||||
def _get_screen_size(self):
|
||||
try:
|
||||
import pyautogui
|
||||
@ -82,20 +110,10 @@ class RecordingManager:
|
||||
os.makedirs(base_path, exist_ok=True)
|
||||
screen_video_path = os.path.join(base_path, 'screen.mp4')
|
||||
target_fps = fps or self.screen_fps
|
||||
ffmpeg_path = None
|
||||
if self.config_manager:
|
||||
ffmpeg_path = (
|
||||
self.config_manager.get_config_value('SCREEN_RECORDING', 'ffmpeg_path', fallback=None) or
|
||||
self.config_manager.get_config_value('RECORDING', 'ffmpeg_path', fallback=None)
|
||||
)
|
||||
if not ffmpeg_path or not os.path.isfile(str(ffmpeg_path)):
|
||||
base_dir = os.path.dirname(sys.executable) if getattr(sys, 'frozen', False) else os.path.dirname(os.path.abspath(__file__))
|
||||
alt_path = os.path.join(base_dir, 'ffmpeg', 'bin', 'ffmpeg.exe')
|
||||
if os.path.isfile(alt_path):
|
||||
ffmpeg_path = alt_path
|
||||
else:
|
||||
result['message'] = '未配置有效的ffmpeg_path,请在配置中设置 SCREEN_RECORDING.ffmpeg_path 或 RECORDING.ffmpeg_path'
|
||||
return result
|
||||
ffmpeg_path = self._resolve_ffmpeg_path()
|
||||
if not ffmpeg_path:
|
||||
result['message'] = '未配置有效的ffmpeg_path,请在配置中设置 SCREEN_RECORDING.ffmpeg_path 或 RECORDING.ffmpeg_path'
|
||||
return result
|
||||
|
||||
cmd = [
|
||||
str(ffmpeg_path),
|
||||
@ -136,7 +154,7 @@ class RecordingManager:
|
||||
creationflags=getattr(subprocess, 'CREATE_NEW_PROCESS_GROUP', 0)
|
||||
)
|
||||
self._ffmpeg_processes['screen'] = proc
|
||||
self._ffmpeg_meta['screen'] = {'base_path': base_path, 'patient_id': patient_id, 'session_id': session_id, 'video_path': screen_video_path}
|
||||
self._ffmpeg_meta['screen'] = {'base_path': base_path, 'file_dir': file_dir, 'patient_id': patient_id, 'session_id': session_id, 'video_path': screen_video_path}
|
||||
result['success'] = True
|
||||
result['message'] = 'ffmpeg录制已启动'
|
||||
result['database_updates'] = {
|
||||
@ -151,6 +169,86 @@ class RecordingManager:
|
||||
result['message'] = f'ffmpeg启动失败: {e}'
|
||||
return result
|
||||
|
||||
def transcode_jpeg_sequence_async(
|
||||
self,
|
||||
name: str,
|
||||
frames_dir: str,
|
||||
output_mp4_path: str,
|
||||
fps: int,
|
||||
on_done: Optional[Callable[[int], None]] = None
|
||||
) -> Dict[str, Any]:
|
||||
try:
|
||||
if not frames_dir or not os.path.isdir(frames_dir):
|
||||
return {'success': False, 'message': 'frames_dir不存在'}
|
||||
try:
|
||||
os.makedirs(os.path.dirname(output_mp4_path), exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ffmpeg_path = self._resolve_ffmpeg_path()
|
||||
if not ffmpeg_path:
|
||||
return {'success': False, 'message': 'ffmpeg_path无效'}
|
||||
|
||||
codec = (
|
||||
self.config_manager.get_config_value('CAMERA_RECORDING', 'ffmpeg_codec', fallback=None) or
|
||||
self.config_manager.get_config_value('SCREEN_RECORDING', 'ffmpeg_codec', fallback=None) or
|
||||
'libx264'
|
||||
)
|
||||
preset = (
|
||||
self.config_manager.get_config_value('CAMERA_RECORDING', 'ffmpeg_preset', fallback=None) or
|
||||
self.config_manager.get_config_value('SCREEN_RECORDING', 'ffmpeg_preset', fallback=None) or
|
||||
('p1' if codec == 'h264_nvenc' else 'ultrafast')
|
||||
)
|
||||
threads = int(self.config_manager.get_config_value('CAMERA_RECORDING', 'ffmpeg_threads', fallback='1') or '1')
|
||||
bframes = int(self.config_manager.get_config_value('CAMERA_RECORDING', 'ffmpeg_bframes', fallback='0') or '0')
|
||||
gop = int(self.config_manager.get_config_value('CAMERA_RECORDING', 'ffmpeg_gop', fallback=str(max(1, int(fps * 2)))) or str(max(1, int(fps * 2))))
|
||||
|
||||
input_pattern = os.path.join(frames_dir, 'frame_%06d.jpg')
|
||||
cmd = [
|
||||
str(ffmpeg_path),
|
||||
'-y',
|
||||
'-framerate', str(int(max(1, fps))),
|
||||
'-start_number', '0',
|
||||
'-i', input_pattern,
|
||||
'-c:v', str(codec),
|
||||
'-preset', str(preset),
|
||||
'-bf', str(bframes),
|
||||
'-g', str(gop),
|
||||
'-pix_fmt', 'yuv420p',
|
||||
'-threads', str(threads),
|
||||
str(output_mp4_path)
|
||||
]
|
||||
|
||||
def worker():
|
||||
code = 1
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=getattr(subprocess, 'CREATE_NEW_PROCESS_GROUP', 0)
|
||||
)
|
||||
self._ffmpeg_processes[name] = proc
|
||||
code = proc.wait()
|
||||
except Exception:
|
||||
code = 1
|
||||
finally:
|
||||
self._ffmpeg_processes.pop(name, None)
|
||||
self._transcode_threads.pop(name, None)
|
||||
if on_done:
|
||||
try:
|
||||
on_done(int(code))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
t = threading.Thread(target=worker, name=f"FFmpegTranscode-{name}", daemon=True)
|
||||
self._transcode_threads[name] = t
|
||||
t.start()
|
||||
return {'success': True, 'message': '转码任务已启动', 'name': name, 'output': output_mp4_path}
|
||||
except Exception as e:
|
||||
return {'success': False, 'message': str(e)}
|
||||
|
||||
def stop_recording_ffmpeg(self, session_id: str = None) -> Dict[str, Any]:
|
||||
result = {'success': False, 'message': ''}
|
||||
try:
|
||||
|
||||
@ -434,6 +434,8 @@ class ConfigManager:
|
||||
self.set_config_value('DEVICES', 'imu_use_mock', str(config_data['use_mock']))
|
||||
if 'mac_address' in config_data:
|
||||
self.set_config_value('DEVICES', 'imu_mac_address', config_data['mac_address'])
|
||||
if 'ble_name' in config_data:
|
||||
self.set_config_value('DEVICES', 'imu_ble_name', config_data['ble_name'])
|
||||
|
||||
results['imu'] = {
|
||||
'success': True,
|
||||
|
||||
@ -16,6 +16,7 @@ from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa, padding
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
import base64
|
||||
import shutil
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -51,85 +52,345 @@ class LicenseManager:
|
||||
def __init__(self, config_manager=None):
|
||||
self.config_manager = config_manager
|
||||
self._machine_id = None
|
||||
self._machine_id_candidates = None
|
||||
self._license_cache = None
|
||||
self._cache_timestamp = None
|
||||
|
||||
def _get_backend_base_dir(self) -> str:
|
||||
return os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
def _get_persistent_license_dir(self) -> str:
|
||||
system = platform.system()
|
||||
if system == "Windows":
|
||||
base = os.environ.get("PROGRAMDATA") or os.environ.get("ALLUSERSPROFILE") or ""
|
||||
if not base:
|
||||
base = os.path.expanduser("~\\AppData\\Local")
|
||||
else:
|
||||
base = os.path.expanduser("~/.config")
|
||||
return os.path.join(base, "BodyCheck", "license")
|
||||
|
||||
def _get_configured_license_paths(self) -> Tuple[str, str, int]:
|
||||
license_path = "data/license.json"
|
||||
public_key_path = "backend/license_pub.pem"
|
||||
grace_days = 3
|
||||
if self.config_manager:
|
||||
license_path = self.config_manager.get_config_value("LICENSE", "path", license_path)
|
||||
public_key_path = self.config_manager.get_config_value("LICENSE", "public_key", public_key_path)
|
||||
grace_days = int(self.config_manager.get_config_value("LICENSE", "grace_days", str(grace_days)))
|
||||
|
||||
if not os.path.isabs(license_path):
|
||||
license_path = os.path.join(self._get_backend_base_dir(), license_path)
|
||||
if not os.path.isabs(public_key_path):
|
||||
public_key_path = os.path.join(self._get_backend_base_dir(), public_key_path)
|
||||
return license_path, public_key_path, grace_days
|
||||
|
||||
def _get_persistent_license_paths(self) -> Tuple[str, str]:
|
||||
pdir = self._get_persistent_license_dir()
|
||||
return os.path.join(pdir, "license.json"), os.path.join(pdir, "license_public_key.pem")
|
||||
|
||||
def _resolve_license_paths(self) -> Tuple[str, str, int]:
|
||||
cfg_license_path, cfg_public_key_path, grace_days = self._get_configured_license_paths()
|
||||
p_license_path, p_public_key_path = self._get_persistent_license_paths()
|
||||
|
||||
effective_license_path = cfg_license_path
|
||||
if not os.path.exists(effective_license_path) and os.path.exists(p_license_path):
|
||||
effective_license_path = p_license_path
|
||||
|
||||
effective_public_key_path = cfg_public_key_path
|
||||
if not os.path.exists(effective_public_key_path) and os.path.exists(p_public_key_path):
|
||||
effective_public_key_path = p_public_key_path
|
||||
|
||||
return effective_license_path, effective_public_key_path, grace_days
|
||||
|
||||
def _mirror_license_assets(self, license_path: Optional[str] = None, public_key_path: Optional[str] = None) -> None:
|
||||
p_license_path, p_public_key_path = self._get_persistent_license_paths()
|
||||
pdir = os.path.dirname(p_license_path)
|
||||
try:
|
||||
os.makedirs(pdir, exist_ok=True)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
if license_path and os.path.exists(license_path):
|
||||
try:
|
||||
shutil.copyfile(license_path, p_license_path)
|
||||
except Exception:
|
||||
pass
|
||||
if public_key_path and os.path.exists(public_key_path):
|
||||
try:
|
||||
shutil.copyfile(public_key_path, p_public_key_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def install_license_file(self, source_license_path: str) -> Tuple[bool, str]:
|
||||
try:
|
||||
if not os.path.exists(source_license_path):
|
||||
return False, "源授权文件不存在"
|
||||
|
||||
cfg_license_path, cfg_public_key_path, _ = self._get_configured_license_paths()
|
||||
os.makedirs(os.path.dirname(cfg_license_path), exist_ok=True)
|
||||
shutil.copyfile(source_license_path, cfg_license_path)
|
||||
self._mirror_license_assets(license_path=cfg_license_path, public_key_path=cfg_public_key_path)
|
||||
return True, cfg_license_path
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
def mirror_license_dir(self, source_dir: str) -> None:
|
||||
if not source_dir or not os.path.isdir(source_dir):
|
||||
return
|
||||
license_candidate = os.path.join(source_dir, "license.json")
|
||||
pub_candidates = [
|
||||
os.path.join(source_dir, "license_public_key.pem"),
|
||||
os.path.join(source_dir, "license_pub.pem"),
|
||||
os.path.join(source_dir, "public_key.pem"),
|
||||
]
|
||||
pub_path = ""
|
||||
for c in pub_candidates:
|
||||
if os.path.exists(c):
|
||||
pub_path = c
|
||||
break
|
||||
self._mirror_license_assets(license_path=license_candidate if os.path.exists(license_candidate) else None, public_key_path=pub_path or None)
|
||||
|
||||
def _run_powershell(self, command: str, timeout: int = 10) -> str:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"powershell",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
command,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
return (result.stdout or "").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def _get_windows_cpu_id(self) -> str:
|
||||
out = self._run_powershell(
|
||||
"(Get-CimInstance -ClassName Win32_Processor | Select-Object -First 1 -ExpandProperty ProcessorId)"
|
||||
)
|
||||
return out.strip()
|
||||
|
||||
def _get_windows_baseboard_serial(self) -> str:
|
||||
out = self._run_powershell(
|
||||
"(Get-CimInstance -ClassName Win32_BaseBoard | Select-Object -First 1 -ExpandProperty SerialNumber)"
|
||||
)
|
||||
serial = out.strip()
|
||||
if serial and serial != "To be filled by O.E.M.":
|
||||
return serial
|
||||
return ""
|
||||
|
||||
def _get_windows_disk_serials(self) -> list:
|
||||
raw = self._run_powershell(
|
||||
"$d=Get-CimInstance -ClassName Win32_DiskDrive | Select-Object SerialNumber,InterfaceType,PNPDeviceID,MediaType; $d | ConvertTo-Json -Compress"
|
||||
)
|
||||
if not raw:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except Exception:
|
||||
return []
|
||||
disks = data if isinstance(data, list) else ([data] if isinstance(data, dict) else [])
|
||||
serials = []
|
||||
for d in disks:
|
||||
serial = str((d.get("SerialNumber") or "")).strip()
|
||||
iface = str((d.get("InterfaceType") or "")).strip().upper()
|
||||
pnp = str((d.get("PNPDeviceID") or "")).strip().upper()
|
||||
media = str((d.get("MediaType") or "")).strip().upper()
|
||||
if serial and iface != "USB" and not pnp.startswith("USBSTOR") and "REMOVABLE" not in media:
|
||||
serials.append(serial)
|
||||
serials = sorted(set(serials))
|
||||
return serials
|
||||
|
||||
def _get_windows_identifiers(self) -> Dict[str, Any]:
|
||||
cpu_id = self._get_windows_cpu_id()
|
||||
board_serial = self._get_windows_baseboard_serial()
|
||||
disk_serials = self._get_windows_disk_serials()
|
||||
|
||||
if not cpu_id:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["wmic", "cpu", "get", "ProcessorId", "/value"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
for line in (result.stdout or "").split("\n"):
|
||||
if "ProcessorId=" in line:
|
||||
cpu_id = line.split("=", 1)[1].strip()
|
||||
if cpu_id:
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not board_serial:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["wmic", "baseboard", "get", "SerialNumber", "/value"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
for line in (result.stdout or "").split("\n"):
|
||||
if "SerialNumber=" in line:
|
||||
board_serial = line.split("=", 1)[1].strip()
|
||||
if board_serial and board_serial != "To be filled by O.E.M.":
|
||||
break
|
||||
board_serial = ""
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not disk_serials:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"wmic",
|
||||
"path",
|
||||
"Win32_DiskDrive",
|
||||
"get",
|
||||
"SerialNumber,InterfaceType,PNPDeviceID,MediaType",
|
||||
"/value",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
block = {}
|
||||
serials = []
|
||||
for line in (result.stdout or "").split("\n"):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
serial = (block.get("SerialNumber") or "").strip()
|
||||
iface = (block.get("InterfaceType") or "").strip().upper()
|
||||
pnp = (block.get("PNPDeviceID") or "").strip().upper()
|
||||
media = (block.get("MediaType") or "").strip().upper()
|
||||
if serial and iface != "USB" and not pnp.startswith("USBSTOR") and "REMOVABLE" not in media:
|
||||
serials.append(serial)
|
||||
block = {}
|
||||
continue
|
||||
if "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
block[k] = v
|
||||
if block:
|
||||
serial = (block.get("SerialNumber") or "").strip()
|
||||
iface = (block.get("InterfaceType") or "").strip().upper()
|
||||
pnp = (block.get("PNPDeviceID") or "").strip().upper()
|
||||
media = (block.get("MediaType") or "").strip().upper()
|
||||
if serial and iface != "USB" and not pnp.startswith("USBSTOR") and "REMOVABLE" not in media:
|
||||
serials.append(serial)
|
||||
disk_serials = sorted(set(serials))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
mac = ""
|
||||
try:
|
||||
import uuid
|
||||
|
||||
mac = ":".join(
|
||||
["{:02x}".format((uuid.getnode() >> elements) & 0xFF) for elements in range(0, 2 * 6, 2)][::-1]
|
||||
)
|
||||
except Exception:
|
||||
mac = ""
|
||||
|
||||
return {
|
||||
"cpu_id": cpu_id.strip() if cpu_id else "",
|
||||
"board_serial": board_serial.strip() if board_serial else "",
|
||||
"disk_serials": disk_serials or [],
|
||||
"mac": mac,
|
||||
"node": platform.node(),
|
||||
"processor": platform.processor(),
|
||||
}
|
||||
|
||||
def _hash_core_info(self, core_info: list) -> str:
|
||||
combined_info = "|".join(sorted(core_info))
|
||||
return hashlib.sha256(combined_info.encode("utf-8")).hexdigest()[:16].upper()
|
||||
|
||||
def _build_machine_id_candidates(self) -> list:
|
||||
system = platform.system()
|
||||
info: Dict[str, Any] = {}
|
||||
if system == "Windows":
|
||||
info = self._get_windows_identifiers()
|
||||
else:
|
||||
info = {
|
||||
"cpu_id": "",
|
||||
"board_serial": "",
|
||||
"disk_serials": [],
|
||||
"mac": "",
|
||||
"node": platform.node(),
|
||||
"processor": platform.processor(),
|
||||
}
|
||||
|
||||
cpu_id = info.get("cpu_id") or ""
|
||||
board_serial = info.get("board_serial") or ""
|
||||
disk_serials = info.get("disk_serials") or []
|
||||
mac = info.get("mac") or ""
|
||||
|
||||
variants = []
|
||||
|
||||
full_core = []
|
||||
if cpu_id:
|
||||
full_core.append(f"CPU:{cpu_id}")
|
||||
if board_serial:
|
||||
full_core.append(f"BOARD:{board_serial}")
|
||||
for s in disk_serials:
|
||||
full_core.append(f"DISK:{s}")
|
||||
if full_core:
|
||||
variants.append(full_core)
|
||||
|
||||
no_disk = []
|
||||
if cpu_id:
|
||||
no_disk.append(f"CPU:{cpu_id}")
|
||||
if board_serial:
|
||||
no_disk.append(f"BOARD:{board_serial}")
|
||||
if no_disk:
|
||||
variants.append(no_disk)
|
||||
|
||||
if cpu_id:
|
||||
variants.append([f"CPU:{cpu_id}"])
|
||||
if board_serial:
|
||||
variants.append([f"BOARD:{board_serial}"])
|
||||
|
||||
if mac:
|
||||
variants.append([f"MAC:{mac}"])
|
||||
if cpu_id and mac:
|
||||
variants.append([f"CPU:{cpu_id}", f"MAC:{mac}"])
|
||||
|
||||
fallback_core = []
|
||||
node = (info.get("node") or "").strip()
|
||||
proc = (info.get("processor") or "").strip()
|
||||
if node:
|
||||
fallback_core.append(f"NODE:{node}")
|
||||
if proc:
|
||||
fallback_core.append(f"PROCESSOR:{proc}")
|
||||
if fallback_core:
|
||||
variants.append(fallback_core)
|
||||
|
||||
prefix = "W10-" if system == "Windows" else "FB-"
|
||||
candidates = []
|
||||
for core in variants:
|
||||
try:
|
||||
mid = f"{prefix}{self._hash_core_info(core)}"
|
||||
except Exception:
|
||||
continue
|
||||
if mid not in candidates:
|
||||
candidates.append(mid)
|
||||
return candidates
|
||||
|
||||
def get_machine_id(self) -> str:
|
||||
"""生成机器硬件指纹"""
|
||||
if self._machine_id:
|
||||
return self._machine_id
|
||||
try:
|
||||
core_info = []
|
||||
aux_info = []
|
||||
try:
|
||||
if platform.system() == "Windows":
|
||||
result = subprocess.run(['wmic', 'cpu', 'get', 'ProcessorId', '/value'], capture_output=True, text=True, timeout=10)
|
||||
for line in result.stdout.split('\n'):
|
||||
if 'ProcessorId=' in line:
|
||||
cpu_id = line.split('=')[1].strip()
|
||||
if cpu_id:
|
||||
core_info.append(f"CPU:{cpu_id}")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"获取CPU信息失败: {e}")
|
||||
try:
|
||||
if platform.system() == "Windows":
|
||||
result = subprocess.run(['wmic', 'baseboard', 'get', 'SerialNumber', '/value'], capture_output=True, text=True, timeout=10)
|
||||
for line in result.stdout.split('\n'):
|
||||
if 'SerialNumber=' in line:
|
||||
board_serial = line.split('=')[1].strip()
|
||||
if board_serial and board_serial != "To be filled by O.E.M.":
|
||||
core_info.append(f"BOARD:{board_serial}")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"获取主板信息失败: {e}")
|
||||
try:
|
||||
if platform.system() == "Windows":
|
||||
# 获取磁盘信息并过滤掉USB/可移动介质,收集所有内部磁盘序列号
|
||||
result = subprocess.run(
|
||||
['wmic', 'path', 'Win32_DiskDrive', 'get', 'SerialNumber,InterfaceType,PNPDeviceID,MediaType', '/value'],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
block = {}
|
||||
for line in result.stdout.split('\n'):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
# 结束一个块
|
||||
serial = (block.get('SerialNumber') or '').strip()
|
||||
iface = (block.get('InterfaceType') or '').strip().upper()
|
||||
pnp = (block.get('PNPDeviceID') or '').strip().upper()
|
||||
media = (block.get('MediaType') or '').strip().upper()
|
||||
if serial and iface != 'USB' and not pnp.startswith('USBSTOR') and 'REMOVABLE' not in media:
|
||||
core_info.append(f"DISK:{serial}")
|
||||
block = {}
|
||||
continue
|
||||
if '=' in line:
|
||||
k, v = line.split('=', 1)
|
||||
block[k] = v
|
||||
# 处理最后一个块
|
||||
if block:
|
||||
serial = (block.get('SerialNumber') or '').strip()
|
||||
iface = (block.get('InterfaceType') or '').strip().upper()
|
||||
pnp = (block.get('PNPDeviceID') or '').strip().upper()
|
||||
media = (block.get('MediaType') or '').strip().upper()
|
||||
if serial and iface != 'USB' and not pnp.startswith('USBSTOR') and 'REMOVABLE' not in media:
|
||||
core_info.append(f"DISK:{serial}")
|
||||
except Exception as e:
|
||||
logger.warning(f"获取磁盘信息失败: {e}")
|
||||
try:
|
||||
import uuid
|
||||
mac = ':'.join(['{:02x}'.format((uuid.getnode() >> elements) & 0xff) for elements in range(0, 2 * 6, 2)][::-1])
|
||||
aux_info.append(f"MAC:{mac}")
|
||||
except Exception as e:
|
||||
logger.warning(f"获取MAC地址失败: {e}")
|
||||
aux_info.append(f"OS:{platform.system()}")
|
||||
aux_info.append(f"MACHINE:{platform.machine()}")
|
||||
if len(core_info) < 1:
|
||||
core_info.append(f"NODE:{platform.node()}")
|
||||
core_info.append(f"PROCESSOR:{platform.processor()}")
|
||||
combined_info = "|".join(sorted(core_info))
|
||||
machine_id = hashlib.sha256(combined_info.encode('utf-8')).hexdigest()[:16].upper()
|
||||
self._machine_id = f"W10-{machine_id}"
|
||||
candidates = self._build_machine_id_candidates()
|
||||
if not candidates:
|
||||
raise RuntimeError("无法生成机器指纹")
|
||||
self._machine_id_candidates = candidates
|
||||
self._machine_id = candidates[0]
|
||||
logger.info(f"生成机器指纹: {self._machine_id}")
|
||||
return self._machine_id
|
||||
except Exception as e:
|
||||
@ -137,8 +398,14 @@ class LicenseManager:
|
||||
fallback_info = f"{platform.system()}-{platform.node()}-{platform.machine()}"
|
||||
fallback_id = hashlib.md5(fallback_info.encode('utf-8')).hexdigest()[:12].upper()
|
||||
self._machine_id = f"FB-{fallback_id}"
|
||||
self._machine_id_candidates = [self._machine_id]
|
||||
return self._machine_id
|
||||
|
||||
def get_machine_id_candidates(self) -> list:
|
||||
if self._machine_id_candidates is None:
|
||||
self.get_machine_id()
|
||||
return list(self._machine_id_candidates or [])
|
||||
|
||||
def load_license(self, license_path: str) -> Optional[Dict[str, Any]]:
|
||||
"""加载授权文件"""
|
||||
try:
|
||||
@ -231,7 +498,13 @@ class LicenseManager:
|
||||
|
||||
# 检查机器绑定
|
||||
license_machine_id = license_data.get('machine_id', '')
|
||||
if license_machine_id != machine_id:
|
||||
candidates = []
|
||||
if machine_id:
|
||||
candidates.append(machine_id)
|
||||
for mid in self.get_machine_id_candidates():
|
||||
if mid not in candidates:
|
||||
candidates.append(mid)
|
||||
if license_machine_id not in candidates:
|
||||
return False, f"授权文件与当前机器不匹配 (当前: {machine_id}, 授权: {license_machine_id})"
|
||||
|
||||
# 检查有效期
|
||||
@ -269,18 +542,7 @@ class LicenseManager:
|
||||
if not self.config_manager:
|
||||
return LicenseStatus(valid=False, message="配置管理器未初始化")
|
||||
|
||||
license_path = self.config_manager.get_config_value('LICENSE', 'path', 'data/license.json')
|
||||
public_key_path = self.config_manager.get_config_value('LICENSE', 'public_key', 'backend/license_pub.pem')
|
||||
grace_days = int(self.config_manager.get_config_value('LICENSE', 'grace_days', '3'))
|
||||
|
||||
# 转换为绝对路径
|
||||
if not os.path.isabs(license_path):
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) # backend目录
|
||||
license_path = os.path.join(base_dir, license_path)
|
||||
|
||||
if not os.path.isabs(public_key_path):
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) # backend目录
|
||||
public_key_path = os.path.join(base_dir, public_key_path)
|
||||
license_path, public_key_path, grace_days = self._resolve_license_paths()
|
||||
|
||||
# 获取机器指纹
|
||||
machine_id = self.get_machine_id()
|
||||
@ -309,6 +571,8 @@ class LicenseManager:
|
||||
self._cache_timestamp = datetime.now().timestamp()
|
||||
return status
|
||||
|
||||
self._mirror_license_assets(license_path=license_path, public_key_path=public_key_path)
|
||||
|
||||
# 检查有效性
|
||||
is_valid, message = self.check_validity(license_data, machine_id, grace_days)
|
||||
|
||||
@ -362,14 +626,7 @@ class LicenseManager:
|
||||
if not self.config_manager:
|
||||
return False, "配置管理器未初始化"
|
||||
|
||||
# 解析公钥路径与宽限期
|
||||
public_key_path = self.config_manager.get_config_value('LICENSE', 'public_key', 'backend/license_pub.pem')
|
||||
grace_days = int(self.config_manager.get_config_value('LICENSE', 'grace_days', '3'))
|
||||
|
||||
# 转换为绝对路径(相对backend目录)
|
||||
if not os.path.isabs(public_key_path):
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
public_key_path = os.path.join(base_dir, public_key_path)
|
||||
_, public_key_path, grace_days = self._resolve_license_paths()
|
||||
|
||||
if not os.path.exists(license_path):
|
||||
return False, f"授权文件不存在: {license_path}"
|
||||
|
||||
125
backend/main.py
125
backend/main.py
@ -10,6 +10,7 @@ import sys
|
||||
import json
|
||||
import time
|
||||
import threading
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from flask import Flask, jsonify
|
||||
from flask import request as flask_request
|
||||
@ -378,6 +379,10 @@ class AppServer:
|
||||
'error': '授权管理器未初始化'
|
||||
}), 500
|
||||
|
||||
if self.license_manager:
|
||||
self.license_status = self.license_manager.get_license_status(force_reload=True)
|
||||
self.app.license_status = self.license_status
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
@ -548,22 +553,14 @@ class AppServer:
|
||||
|
||||
if is_valid:
|
||||
# 覆盖系统授权文件为上传的文件
|
||||
try:
|
||||
license_path_cfg = self.config_manager.get_config_value('LICENSE', 'path', 'data/license.json') if self.config_manager else 'data/license.json'
|
||||
# 解析目标路径为绝对路径
|
||||
if not os.path.isabs(license_path_cfg):
|
||||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
license_path_cfg = os.path.join(base_dir, license_path_cfg)
|
||||
os.makedirs(os.path.dirname(license_path_cfg), exist_ok=True)
|
||||
# 移动/覆盖授权文件
|
||||
import shutil
|
||||
shutil.copyfile(temp_path, license_path_cfg)
|
||||
except Exception as e:
|
||||
self.logger.error(f'保存授权文件失败: {e}')
|
||||
return jsonify({'success': False, 'error': f'保存授权文件失败: {str(e)}'}), 500
|
||||
ok, result = self.license_manager.install_license_file(temp_path)
|
||||
if not ok:
|
||||
self.logger.error(f'保存授权文件失败: {result}')
|
||||
return jsonify({'success': False, 'error': f'保存授权文件失败: {result}'}), 500
|
||||
|
||||
# 更新授权状态(强制刷新)
|
||||
self.license_status = self.license_manager.get_license_status(force_reload=True)
|
||||
self.app.license_status = self.license_status
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
@ -631,8 +628,11 @@ class AppServer:
|
||||
return jsonify({'success': False, 'error': '压缩包包含非法路径'}), 400
|
||||
zip_ref.extractall(target_dir)
|
||||
|
||||
self.license_manager.mirror_license_dir(target_dir)
|
||||
|
||||
# 刷新授权
|
||||
self.license_status = self.license_manager.get_license_status(force_reload=True)
|
||||
self.app.license_status = self.license_status
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
@ -1449,6 +1449,30 @@ class AppServer:
|
||||
# 使用新的ffmpeg录制方法
|
||||
recording_response = self.recording_manager.start_recording_ffmpeg(session_id, patient_id, screen_location)
|
||||
|
||||
base_info = None
|
||||
try:
|
||||
base_info = self.recording_manager.get_active_video_base(session_id)
|
||||
except Exception:
|
||||
base_info = None
|
||||
|
||||
if base_info and base_info.get('base_path'):
|
||||
base_path = base_info.get('base_path')
|
||||
try:
|
||||
cam1 = self.device_coordinator.devices.get('camera1') if self.device_coordinator and hasattr(self.device_coordinator, 'devices') else None
|
||||
if cam1 and getattr(cam1, 'is_streaming', False):
|
||||
cam1_frames_dir = os.path.join(base_path, 'camera1_frames')
|
||||
cam1.start_jpeg_recording(session_id=session_id, frames_dir=cam1_frames_dir)
|
||||
except Exception as e:
|
||||
self.logger.error(f'启动camera1 JPEG录制失败: {e}')
|
||||
|
||||
try:
|
||||
cam2 = self.device_coordinator.devices.get('camera2') if self.device_coordinator and hasattr(self.device_coordinator, 'devices') else None
|
||||
if cam2 and getattr(cam2, 'is_streaming', False):
|
||||
cam2_frames_dir = os.path.join(base_path, 'camera2_frames')
|
||||
cam2.start_jpeg_recording(session_id=session_id, frames_dir=cam2_frames_dir)
|
||||
except Exception as e:
|
||||
self.logger.error(f'启动camera2 JPEG录制失败: {e}')
|
||||
|
||||
# 处理录制管理器返回的数据库更新信息
|
||||
if recording_response and recording_response.get('success') and 'database_updates' in recording_response:
|
||||
db_updates = recording_response['database_updates']
|
||||
@ -1456,7 +1480,10 @@ class AppServer:
|
||||
# 保存检测视频记录(映射到 detection_video 表字段)
|
||||
video_paths = db_updates.get('video_paths', {})
|
||||
video_record = {
|
||||
'screen_video_path': video_paths.get('screen_video_path')
|
||||
'screen_video_path': video_paths.get('screen_video_path'),
|
||||
'body_video_path': None,
|
||||
'foot_video1_path': None,
|
||||
'foot_video2_path': None
|
||||
}
|
||||
|
||||
try:
|
||||
@ -1495,6 +1522,76 @@ class AppServer:
|
||||
except Exception as rec_e:
|
||||
self.logger.error(f'停止同步录制失败: {rec_e}', exc_info=True)
|
||||
raise
|
||||
|
||||
base_info = None
|
||||
try:
|
||||
base_info = self.recording_manager.get_active_video_base(session_id)
|
||||
except Exception:
|
||||
base_info = None
|
||||
|
||||
if base_info and base_info.get('base_path') and base_info.get('file_dir'):
|
||||
base_path = base_info.get('base_path')
|
||||
file_dir = base_info.get('file_dir')
|
||||
|
||||
cam1 = self.device_coordinator.devices.get('camera1') if self.device_coordinator and hasattr(self.device_coordinator, 'devices') else None
|
||||
cam2 = self.device_coordinator.devices.get('camera2') if self.device_coordinator and hasattr(self.device_coordinator, 'devices') else None
|
||||
|
||||
if cam1:
|
||||
try:
|
||||
cam1_stop = cam1.stop_jpeg_recording(session_id=session_id)
|
||||
cam1_frames_dir = cam1_stop.get('frames_dir')
|
||||
cam1_fps = int(cam1_stop.get('fps') or 10)
|
||||
if cam1_frames_dir and os.path.isdir(cam1_frames_dir):
|
||||
out1 = os.path.join(base_path, 'foot1.mp4')
|
||||
rel1 = os.path.relpath(out1, file_dir).replace('\\', '/')
|
||||
def _done1(code: int):
|
||||
if code == 0:
|
||||
try:
|
||||
self.db_manager.update_detection_video_latest(session_id, {'foot_video1_path': rel1})
|
||||
except Exception as e:
|
||||
self.logger.error(f'更新foot_video1失败: {e}')
|
||||
try:
|
||||
shutil.rmtree(cam1_frames_dir, ignore_errors=True)
|
||||
except Exception as e:
|
||||
self.logger.error(f'删除camera1_frames失败: {e}')
|
||||
self.recording_manager.transcode_jpeg_sequence_async(
|
||||
name=f'{session_id}-camera1',
|
||||
frames_dir=cam1_frames_dir,
|
||||
output_mp4_path=out1,
|
||||
fps=cam1_fps,
|
||||
on_done=_done1
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f'停止/转码camera1录制失败: {e}')
|
||||
|
||||
if cam2:
|
||||
try:
|
||||
cam2_stop = cam2.stop_jpeg_recording(session_id=session_id)
|
||||
cam2_frames_dir = cam2_stop.get('frames_dir')
|
||||
cam2_fps = int(cam2_stop.get('fps') or 10)
|
||||
if cam2_frames_dir and os.path.isdir(cam2_frames_dir):
|
||||
out2 = os.path.join(base_path, 'foot2.mp4')
|
||||
rel2 = os.path.relpath(out2, file_dir).replace('\\', '/')
|
||||
def _done2(code: int):
|
||||
if code == 0:
|
||||
try:
|
||||
self.db_manager.update_detection_video_latest(session_id, {'foot_video2_path': rel2})
|
||||
except Exception as e:
|
||||
self.logger.error(f'更新foot_video2失败: {e}')
|
||||
try:
|
||||
shutil.rmtree(cam2_frames_dir, ignore_errors=True)
|
||||
except Exception as e:
|
||||
self.logger.error(f'删除camera2_frames失败: {e}')
|
||||
self.recording_manager.transcode_jpeg_sequence_async(
|
||||
name=f'{session_id}-camera2',
|
||||
frames_dir=cam2_frames_dir,
|
||||
output_mp4_path=out2,
|
||||
fps=cam2_fps,
|
||||
on_done=_done2
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f'停止/转码camera2录制失败: {e}')
|
||||
|
||||
return jsonify({'success': True,'msg': '停止录制成功'})
|
||||
except Exception as e:
|
||||
self.logger.error(f'停止检测失败: {e}', exc_info=True)
|
||||
|
||||
64
backend/tests/test_license_manager_unit.py
Normal file
64
backend/tests/test_license_manager_unit.py
Normal file
@ -0,0 +1,64 @@
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class DummyConfigManager:
|
||||
def __init__(self, values):
|
||||
self._values = values
|
||||
|
||||
def get_config_value(self, section, key, fallback=None):
|
||||
return self._values.get((section, key), fallback)
|
||||
|
||||
|
||||
def test_check_validity_accepts_candidate_machine_id(monkeypatch):
|
||||
from devices.utils.license_manager import LicenseManager
|
||||
|
||||
lm = LicenseManager(config_manager=DummyConfigManager({}))
|
||||
monkeypatch.setattr(lm, "get_machine_id_candidates", lambda: ["MID-PRIMARY", "MID-ALT"])
|
||||
|
||||
license_data = {
|
||||
"product": "BodyBalanceEvaluation",
|
||||
"license_id": "L1",
|
||||
"license_type": "full",
|
||||
"machine_id": "MID-ALT",
|
||||
"issued_at": datetime.now(timezone.utc).isoformat(),
|
||||
"expires_at": (datetime.now(timezone.utc) + timedelta(days=1)).isoformat().replace("+00:00", "Z"),
|
||||
"signature": "x",
|
||||
"features": {"export": True},
|
||||
}
|
||||
|
||||
ok, msg = lm.check_validity(license_data, machine_id="MID-PRIMARY", grace_days=0)
|
||||
assert ok is True, msg
|
||||
|
||||
|
||||
def test_resolve_license_paths_falls_back_to_persistent(monkeypatch, tmp_path):
|
||||
from devices.utils.license_manager import LicenseManager
|
||||
|
||||
programdata = tmp_path / "programdata"
|
||||
persistent_dir = programdata / "BodyCheck" / "license"
|
||||
persistent_dir.mkdir(parents=True)
|
||||
(persistent_dir / "license.json").write_text('{"a":1}', encoding="utf-8")
|
||||
(persistent_dir / "license_public_key.pem").write_text("PUB", encoding="utf-8")
|
||||
|
||||
cfg_dir = tmp_path / "cfg"
|
||||
cfg_license_path = str(cfg_dir / "license.json")
|
||||
cfg_pub_path = str(cfg_dir / "license_public_key.pem")
|
||||
|
||||
cfg = DummyConfigManager(
|
||||
{
|
||||
("LICENSE", "path"): cfg_license_path,
|
||||
("LICENSE", "public_key"): cfg_pub_path,
|
||||
("LICENSE", "grace_days"): "3",
|
||||
}
|
||||
)
|
||||
lm = LicenseManager(config_manager=cfg)
|
||||
|
||||
monkeypatch.setenv("PROGRAMDATA", str(programdata))
|
||||
monkeypatch.setattr("platform.system", lambda: "Windows")
|
||||
|
||||
license_path, pub_path, grace_days = lm._resolve_license_paths()
|
||||
assert license_path == str(persistent_dir / "license.json")
|
||||
assert pub_path == str(persistent_dir / "license_public_key.pem")
|
||||
assert grace_days == 3
|
||||
@ -3,7 +3,7 @@ const path = require('path');
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const url = require('url');
|
||||
const { spawn } = require('child_process');
|
||||
const { spawn, exec, execSync } = require('child_process');
|
||||
let mainWindow;
|
||||
let localServer;
|
||||
let backendProcess;
|
||||
@ -11,6 +11,24 @@ let splashWindow;
|
||||
// app.disableHardwareAcceleration();
|
||||
app.disableDomainBlockingFor3DAPIs();
|
||||
console.log('Electron version:', process.versions.electron);
|
||||
|
||||
const gotSingleInstanceLock = app.requestSingleInstanceLock();
|
||||
if (!gotSingleInstanceLock) {
|
||||
app.quit();
|
||||
} else {
|
||||
app.on('second-instance', () => {
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) {
|
||||
mainWindow.restore();
|
||||
}
|
||||
mainWindow.focus();
|
||||
return;
|
||||
}
|
||||
if (app.isReady()) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
}
|
||||
ipcMain.handle('generate-report-pdf', async (event, payload) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
if (!win) throw new Error('窗口未找到');
|
||||
@ -134,6 +152,21 @@ ipcMain.handle('generate-report-pdf', async (event, payload) => {
|
||||
});
|
||||
|
||||
function startBackendService() {
|
||||
if (backendProcess) {
|
||||
console.log('Backend service already started (tracked).');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const tasklistOut = execSync('tasklist /FI "IMAGENAME eq BodyBalanceBackend.exe" /NH', { windowsHide: true }).toString();
|
||||
if (tasklistOut && tasklistOut.toLowerCase().includes('bodybalancebackend.exe')) {
|
||||
console.log('Backend service already running (tasklist). Skip spawning a new instance.');
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Backend process detection failed, continue to spawn:', e && e.message ? e.message : e);
|
||||
}
|
||||
|
||||
// 在打包后的应用中,使用process.resourcesPath获取resources目录
|
||||
const resourcesPath = process.resourcesPath || path.join(__dirname, '../..');
|
||||
const backendPath = path.join(resourcesPath, 'backend/BodyBalanceBackend/BodyBalanceBackend.exe');
|
||||
@ -199,7 +232,6 @@ function stopBackendService() {
|
||||
|
||||
// 强制杀死所有BodyBalanceBackend.exe进程
|
||||
try {
|
||||
const { exec } = require('child_process');
|
||||
exec('taskkill /f /im BodyBalanceBackend.exe', (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
// 如果没有找到进程,taskkill会返回错误,这是正常的
|
||||
@ -300,6 +332,11 @@ function createWindow() {
|
||||
}
|
||||
|
||||
function startLocalServer(callback) {
|
||||
if (localServer) {
|
||||
console.log('Local server already started on http://localhost:3000');
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
const staticPath = path.join(__dirname, '../dist/');
|
||||
|
||||
localServer = http.createServer((req, res) => {
|
||||
@ -360,26 +397,28 @@ function startLocalServer(callback) {
|
||||
// 应用事件处理
|
||||
// 关闭硬件加速以规避 GPU 进程异常导致的闪烁
|
||||
// app.disableHardwareAcceleration();
|
||||
app.whenReady().then(createWindow);
|
||||
if (gotSingleInstanceLock) {
|
||||
app.whenReady().then(createWindow);
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
if (localServer) {
|
||||
localServer.close();
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
if (localServer) {
|
||||
localServer.close();
|
||||
}
|
||||
// 关闭后端服务
|
||||
stopBackendService();
|
||||
app.quit();
|
||||
}
|
||||
// 关闭后端服务
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
|
||||
// 应用退出前清理资源
|
||||
app.on('before-quit', () => {
|
||||
stopBackendService();
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
|
||||
// 应用退出前清理资源
|
||||
app.on('before-quit', () => {
|
||||
stopBackendService();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
<Header />
|
||||
<div class="displaycontainer">
|
||||
<div class="displayleft" style="width: 550px;">
|
||||
|
||||
<img src="@/assets/detection/progress.png" alt="" style=" margin-left:10px;margin-right:15px">
|
||||
<div style="
|
||||
font-size: 18px;
|
||||
@ -47,7 +48,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="width:100%;height: calc(100% - 115px);" ref="contenGridRef">
|
||||
<div style="width:100%;height: calc(100% - 131px);" ref="contenGridRef">
|
||||
<!-- 主内容区域 -->
|
||||
<el-row :gutter="15" style="padding: 10px;padding-top:0" >
|
||||
<el-col :span="6" style="flex: 0 0 24%;height: calc(100% - 0px);">
|
||||
@ -192,93 +193,74 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="body-footbottom-box" ref="pressureRef">
|
||||
<div style="width: 100%;height: calc(100% - 57px);display: flex; align-items: center;justify-content: center;">
|
||||
<div class="body-footbottom-left">
|
||||
<div style="width:100%;height: 50px;"></div>
|
||||
<div class="body-footbottom-leftbottom">
|
||||
<div class="body-footbottom-leftbox">
|
||||
<span class="currencytext1">左前足</span>
|
||||
<span class="currencytext2">
|
||||
{{ footPressure.left_front }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="body-footbottom-leftbox">
|
||||
<span class="currencytext1">左后足</span>
|
||||
<span class="currencytext2">
|
||||
{{ footPressure.left_rear }}%
|
||||
</span>
|
||||
</div>
|
||||
<!-- <div class="body-footbottom-leftbox">
|
||||
<span class="currencytext1">左足总压力</span>
|
||||
<span class="currencytext2">
|
||||
{{ footPressure.left_total}}%
|
||||
</span>
|
||||
</div> -->
|
||||
<div class="body-footbottom-left">
|
||||
<div style="width:100%;height: 50px;"></div>
|
||||
<div class="body-footbottom-leftbottom">
|
||||
<div class="body-footbottom-leftbox">
|
||||
<span class="currencytext1">左前足</span>
|
||||
<span class="currencytext2">
|
||||
{{ footPressure.left_front }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="body-footbottom-center">
|
||||
<div class="body-footbottom-topbox">
|
||||
<div class="currencytext1" style="font-size:22px;text-align:center;">左足</div>
|
||||
<div class="currencytext1" style="font-size:22px;text-align:center;">右足</div>
|
||||
<div class="body-footbottom-leftbox">
|
||||
<span class="currencytext1">左后足</span>
|
||||
<span class="currencytext2">
|
||||
{{ footPressure.left_rear }}%
|
||||
</span>
|
||||
</div>
|
||||
<div style="position: relative;width: 100%;height:calc(100% - 60px) ;"
|
||||
:class="(pressureStatus === '已连接' && footImgSrc)?'':'noImageSvg-bg'">
|
||||
|
||||
<img v-if="(pressureStatus === '已连接' && footImgSrc)" :src="footImgSrc" style="width: 100%;height: 100%;" alt="">
|
||||
<div v-else style="width:90px;height:60px">
|
||||
<img :src="noImageSvg" style="margin-left: 15px;">
|
||||
<div style="font-size:14px;color:#ffffff99;text-align: center;">连接已断开!</div>
|
||||
</div>
|
||||
<div class="xline"></div>
|
||||
<div class="yline"></div>
|
||||
<!-- <div v-if="(pressureStatus === '已连接' && footImgSrc)" class="xline"></div>
|
||||
<div v-if="(pressureStatus === '已连接' && footImgSrc)" class="yline"></div> -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="body-footbottom-left">
|
||||
<div style="width:100%;height: 50px;"></div>
|
||||
<div class="body-footbottom-leftbottom">
|
||||
<div class="body-footbottom-leftbox">
|
||||
<span class="currencytext1">右前足</span>
|
||||
<span class="currencytext2">
|
||||
{{ footPressure.right_front }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="body-footbottom-leftbox">
|
||||
<span class="currencytext1">右后足</span>
|
||||
<span class="currencytext2">
|
||||
{{ footPressure.right_rear }}%
|
||||
</span>
|
||||
</div>
|
||||
<!-- <div class="body-footbottom-leftbox">
|
||||
<span class="currencytext1">右足总压力</span>
|
||||
<span class="currencytext2">
|
||||
{{ footPressure.right_total}}%
|
||||
</span>
|
||||
</div> -->
|
||||
<div class="body-footbottom-leftbox">
|
||||
<span class="currencytext1">左足总压力</span>
|
||||
<span class="currencytext2">
|
||||
{{ footPressure.left_total}}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex;justify-content: center; width: 100%;">
|
||||
<div class="body-footbottom-leftbox" style="width:calc(22% + 2px)">
|
||||
<span class="currencytext1">左足总压力</span>
|
||||
<span class="currencytext2">
|
||||
{{ footPressure.left_total}}%
|
||||
</span>
|
||||
<div class="body-footbottom-center">
|
||||
<div class="body-footbottom-topbox">
|
||||
<div class="currencytext1" style="font-size:22px;text-align:center;">左足</div>
|
||||
<div class="currencytext1" style="font-size:22px;text-align:center;">右足</div>
|
||||
</div>
|
||||
<div class="body-footbottom-leftbox" style="width:calc(22% + 2px);margin-left: 20px">
|
||||
<span class="currencytext1">右足总压力</span>
|
||||
<span class="currencytext2">
|
||||
{{ footPressure.right_total}}%
|
||||
</span>
|
||||
<div style="position: relative;width: 100%;height:calc(100% - 60px) ;"
|
||||
:class="(pressureStatus === '已连接' && footImgSrc)?'':'noImageSvg-bg'">
|
||||
|
||||
<img v-if="(pressureStatus === '已连接' && footImgSrc)" :src="footImgSrc" style="width: 100%;height: 100%;" alt="">
|
||||
<div v-else style="width:90px;height:60px">
|
||||
<img :src="noImageSvg" style="margin-left: 15px;">
|
||||
<div style="font-size:14px;color:#ffffff99;text-align: center;">连接已断开!</div>
|
||||
</div>
|
||||
<div v-if="(pressureStatus === '已连接' && footImgSrc)" class="xline"></div>
|
||||
<div v-if="(pressureStatus === '已连接' && footImgSrc)" class="yline"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="body-footbottom-left">
|
||||
<div style="width:100%;height: 50px;"></div>
|
||||
<div class="body-footbottom-leftbottom">
|
||||
<div class="body-footbottom-leftbox">
|
||||
<span class="currencytext1">右前足</span>
|
||||
<span class="currencytext2">
|
||||
{{ footPressure.right_front }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="body-footbottom-leftbox">
|
||||
<span class="currencytext1">右后足</span>
|
||||
<span class="currencytext2">
|
||||
{{ footPressure.right_rear }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="body-footbottom-leftbox">
|
||||
<span class="currencytext1">右足总压力</span>
|
||||
<span class="currencytext2">
|
||||
{{ footPressure.right_total}}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col v-if=" false || camera1Status === '已连接' && camera2Status === '已连接'"
|
||||
<el-col v-if="camera1Status === '已连接' && camera2Status === '已连接'"
|
||||
:span="6" style="flex: 0 0 24%;height: calc(100% - 0px); position: relative;">
|
||||
<div class="body-userinfo-box" :class="isExpand == true?'body-userinfo-expandbox':''">
|
||||
<div class="body-title-display">
|
||||
@ -448,9 +430,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col v-if="true || camera1Status === '已连接' || camera2Status === '已连接'"
|
||||
:span="6" style="flex: 0 0 24%;height: calc(100% - 0px);position: relative;">
|
||||
<div class="body-userinfo-box1" :class="isExpand == true?'body-userinfo-expandbox':''">
|
||||
<el-col v-if="camera1Status === '已连接' || camera2Status === '已连接'"
|
||||
:span="6" style="flex: 0 0 24%;height: calc(100% - 0px);">
|
||||
<div class="body-userinfo-box1">
|
||||
<div class="body-title-display">
|
||||
<div class="body-son-display">
|
||||
<img src="@/assets/detection/title4.png" alt="" style="margin-right: 8px;">
|
||||
@ -462,7 +444,7 @@
|
||||
<div class="body-userinfo-content-top">
|
||||
|
||||
<img src="@/assets/detection/useredit.png" alt="" title="编辑患者信息"
|
||||
class="userinfo-edit-img" style="cursor: pointer;" @click="handleEditUserInfo">
|
||||
class="userinfo-edit-img" style="cursor: pointer;" @click="handleEditUserInfo">
|
||||
<div class="useravatar-box">
|
||||
<img src="@/assets/detection/useravatar.svg" alt="">
|
||||
</div>
|
||||
@ -479,116 +461,61 @@
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="body-userinfo-content-bottom0" v-if="isExpand == false">
|
||||
<img src="@/assets/detection/userinfo.png" alt=""
|
||||
class="userinfo-edit-img" style="cursor: pointer;"
|
||||
@click="viewClick(true)">
|
||||
<div class="userinfo-disyplaypadding1 ">
|
||||
<div class="userinfo-text4 padding10">出生日期</div>
|
||||
<div class="body-userinfo-content-bottom1">
|
||||
<div class="userinfo-disyplaypadding4">
|
||||
<div class="userinfo-text4">出生日期</div>
|
||||
<div class="userinfo-text5">
|
||||
<span v-if="patientInfo && patientInfo.birth_date">
|
||||
{{ formatDate(patientInfo.birth_date) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="userinfo-disyplaypadding2">
|
||||
<div class="userinfo-text4 padding10">身高</div>
|
||||
<div class="userinfo-disyplaypadding5">
|
||||
<div class="userinfo-text4">身高</div>
|
||||
<div class="userinfo-text5">
|
||||
{{ patientInfo.height ==''||patientInfo.height ==null ?'—':patientInfo.height}}cm
|
||||
</div>
|
||||
</div>
|
||||
<div class="userinfo-disyplaypadding1">
|
||||
<div class="userinfo-text4 padding10">体重</div>
|
||||
<div class="userinfo-disyplaypadding4">
|
||||
<div class="userinfo-text4">体重</div>
|
||||
<div class="userinfo-text5">
|
||||
{{ patientInfo.weight ==''||patientInfo.weight ==null ?'—':patientInfo.weight}}kg
|
||||
</div>
|
||||
</div>
|
||||
<div class="userinfo-disyplaypadding2">
|
||||
<div class="userinfo-text4 padding10">鞋码</div>
|
||||
<div class="userinfo-disyplaypadding5">
|
||||
<div class="userinfo-text4">鞋码</div>
|
||||
<div class="userinfo-text5">
|
||||
{{ patientInfo.shoe_size ==''||patientInfo.shoe_size ==null ?'—':patientInfo.shoe_size}}码</div>
|
||||
</div>
|
||||
<div class="userinfo-disyplaypadding1">
|
||||
<div class="userinfo-text4 padding10">电话</div>
|
||||
<div class="userinfo-disyplaypadding4">
|
||||
<div class="userinfo-text4">电话</div>
|
||||
<div class="userinfo-text5">
|
||||
{{ patientInfo.phone ==''||patientInfo.phone ==null ?'—':patientInfo.phone}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="userinfo-disyplaypadding2">
|
||||
<div class="userinfo-text4 padding10">民族</div>
|
||||
<div class="userinfo-disyplaypadding5">
|
||||
<div class="userinfo-text4">民族</div>
|
||||
<div class="userinfo-text5">
|
||||
{{ patientInfo.nationality ==''||patientInfo.nationality ==null ?'—':patientInfo.nationality}}</div>
|
||||
</div>
|
||||
<div class="userinfo-disyplaypadding1">
|
||||
<div class="userinfo-text4 padding10">身份证号</div>
|
||||
<div class="userinfo-disyplaypadding4">
|
||||
<div class="userinfo-text4">身份证号</div>
|
||||
<div class="userinfo-text5">
|
||||
{{ patientInfo.idcode ==''||patientInfo.idcode ==null ?'—':patientInfo.idcode}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="userinfo-disyplaypadding2">
|
||||
<div class="userinfo-text4 padding10">职业</div>
|
||||
<div class="userinfo-disyplaypadding5">
|
||||
<div class="userinfo-text4">职业</div>
|
||||
<div class="userinfo-text5">
|
||||
{{ patientInfo.occupation ==''||patientInfo.occupation ==null ?'—':patientInfo.occupation}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="body-userinfo-content-bottom2" v-if="isExpand == true">
|
||||
<img src="@/assets/detection/userinfo.png" alt=""
|
||||
class="userinfo-edit-img" style="cursor: pointer;"
|
||||
@click="viewClick(false)">
|
||||
<div class="userinfo-disyplaypadding1 ">
|
||||
<div class="userinfo-text4 padding10">出生日期</div>
|
||||
<div class="userinfo-text5">
|
||||
<span v-if="patientInfo && patientInfo.birth_date">
|
||||
{{ formatDate(patientInfo.birth_date) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="userinfo-disyplaypadding2">
|
||||
<div class="userinfo-text4 padding10">身高</div>
|
||||
<div class="userinfo-text5">
|
||||
{{ patientInfo.height ==''||patientInfo.height ==null ?'—':patientInfo.height}}cm
|
||||
</div>
|
||||
</div>
|
||||
<div class="userinfo-disyplaypadding1">
|
||||
<div class="userinfo-text4 padding10">体重</div>
|
||||
<div class="userinfo-text5">
|
||||
{{ patientInfo.weight ==''||patientInfo.weight ==null ?'—':patientInfo.weight}}kg
|
||||
</div>
|
||||
</div>
|
||||
<div class="userinfo-disyplaypadding2">
|
||||
<div class="userinfo-text4 padding10">鞋码</div>
|
||||
<div class="userinfo-text5">
|
||||
{{ patientInfo.shoe_size ==''||patientInfo.shoe_size ==null ?'—':patientInfo.shoe_size}}码</div>
|
||||
</div>
|
||||
<div class="userinfo-disyplaypadding1">
|
||||
<div class="userinfo-text4 padding10">电话</div>
|
||||
<div class="userinfo-text5">
|
||||
{{ patientInfo.phone ==''||patientInfo.phone ==null ?'—':patientInfo.phone}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="userinfo-disyplaypadding2">
|
||||
<div class="userinfo-text4 padding10">民族</div>
|
||||
<div class="userinfo-text5">
|
||||
{{ patientInfo.nationality ==''||patientInfo.nationality ==null ?'—':patientInfo.nationality}}</div>
|
||||
</div>
|
||||
<div class="userinfo-disyplaypadding1">
|
||||
<div class="userinfo-text4 padding10">身份证号</div>
|
||||
<div class="userinfo-text5">
|
||||
{{ patientInfo.idcode ==''||patientInfo.idcode ==null ?'—':patientInfo.idcode}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="userinfo-disyplaypadding2">
|
||||
<div class="userinfo-text4 padding10">职业</div>
|
||||
<div class="userinfo-text5">
|
||||
{{ patientInfo.occupation ==''||patientInfo.occupation ==null ?'—':patientInfo.occupation}}</div>
|
||||
</div>
|
||||
<div class="userinfo-disyplaypadding3">
|
||||
<div class="userinfo-text4 padding10">居住地</div>
|
||||
<div class="userinfo-disyplaypadding6">
|
||||
<div class="userinfo-text4">居住地</div>
|
||||
<div class="userinfo-text5">
|
||||
{{ patientInfo.residence ==''||patientInfo.residence ==null ?'—':patientInfo.residence}}</div>
|
||||
</div>
|
||||
<div class="userinfo-disyplaypadding3">
|
||||
<div class="userinfo-text4 padding10">邮箱</div>
|
||||
<div class="userinfo-disyplaypadding6">
|
||||
<div class="userinfo-text4">邮箱</div>
|
||||
<div class="userinfo-text5">
|
||||
{{ patientInfo.email ==''||patientInfo.email ==null ?'—':patientInfo.email}}</div>
|
||||
</div>
|
||||
@ -596,8 +523,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="body-video-box1" style="position: absolute; top: 522px; width: calc(100% - 15px);">
|
||||
|
||||
<div class="body-video-box1">
|
||||
<div class="body-title-display">
|
||||
<div class="body-son-display">
|
||||
<img src="@/assets/detection/title5.png" alt="" style="margin-right: 8px;">
|
||||
@ -612,14 +538,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="body-video-content" style="padding: 0;">
|
||||
<div class="body-video-content">
|
||||
<div v-show="camera1Status === '已连接'" class="body-video-imgbox3" ref="camera1Ref" :class="(camera1Status === '已连接' && camera1ImgSrc)?'':'noImageSvg-bg'">
|
||||
<div v-if="(camera1Status === '已连接' && camera1ImgSrc)"
|
||||
@click="isBig1 = true" class="big-img">
|
||||
<img src="@/assets/detection/big.png">
|
||||
</div>
|
||||
<img v-if="(camera1Status === '已连接' && camera1ImgSrc)" :src="camera1ImgSrc" alt="camera1"
|
||||
style="width: 100%; height: 100%;object-fit:contain;" />
|
||||
style="width: 100%; height: 100%;" />
|
||||
<div v-else style="width:90px;height:60px">
|
||||
<img :src="noImageSvg" style="margin-left: 15px;">
|
||||
<div style="font-size:14px;color:#ffffff99;text-align: center;">连接已断开!</div>
|
||||
@ -631,7 +557,7 @@
|
||||
<img src="@/assets/detection/big.png">
|
||||
</div>
|
||||
<img v-if="(camera2Status === '已连接' && camera2ImgSrc)" :src="camera2ImgSrc" alt="camera2"
|
||||
style="width: 100%; height: 100%;object-fit:contain;" />
|
||||
style="width: 100%; height: 100%;" />
|
||||
<div v-else style="width:90px;height:60px">
|
||||
<img :src="noImageSvg" style="margin-left: 15px;">
|
||||
<div style="font-size:14px;color:#ffffff99;text-align: center;">连接已断开!</div>
|
||||
@ -640,7 +566,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col v-if="false || camera1Status !== '已连接' && camera2Status !== '已连接'" :span="6" style="flex: 0 0 24%;height: calc(100% - 0px);">
|
||||
<el-col v-if="camera1Status !== '已连接' && camera2Status !== '已连接'" :span="6" style="flex: 0 0 24%;height: calc(100% - 0px);">
|
||||
<div class="body-userinfo-box3">
|
||||
<div class="body-title-display">
|
||||
<div class="body-son-display">
|
||||
@ -787,7 +713,6 @@
|
||||
<div class="pop-up-tip-text" v-if="!isVideoOperation">本次检测未截图或录像操作,不予存档记录!</div>
|
||||
<div class="pop-up-tip-text" v-if="isVideoOperation">本次检测未截图操作,存档记录不可生成报告!</div>
|
||||
<div class="tipconfirmbutton-box">
|
||||
<div class="tipclosebutton" @click="handleCancel">取消</div>
|
||||
<el-button type="primary" class="tipconfirmbutton" @click="closeTipClick">确定</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@ -2709,23 +2634,9 @@ const isPhotoAlbum = ref(false)
|
||||
function closePhotoAlbum(){
|
||||
isPhotoAlbum.value = false
|
||||
}
|
||||
function closecreatbox(e,info){
|
||||
if(e === '编辑'){
|
||||
patientInfo.value.age = info.age
|
||||
patientInfo.value.birth_date = info.birth_date
|
||||
patientInfo.value.email = info.email
|
||||
patientInfo.value.gender = info.gender
|
||||
patientInfo.value.height = info.height
|
||||
patientInfo.value.id = info.id
|
||||
patientInfo.value.idcode = info.idcode
|
||||
patientInfo.value.name = info.name
|
||||
patientInfo.value.nationality = info.nationality
|
||||
patientInfo.value.occupation = info.occupation
|
||||
patientInfo.value.phone = info.phone
|
||||
patientInfo.value.residence = info.residence
|
||||
patientInfo.value.shoe_size = info.shoe_size
|
||||
patientInfo.value.weight = info.weight
|
||||
// loadPatientInfo()
|
||||
function closecreatbox(e){
|
||||
if(e === true){
|
||||
loadPatientInfo()
|
||||
}
|
||||
isCloseCreat.value = false
|
||||
}
|
||||
@ -2767,7 +2678,7 @@ function viewClick(e){
|
||||
|
||||
.displaycontainer {
|
||||
width: 100%;
|
||||
height: 46px;
|
||||
height: 62px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@ -2944,8 +2855,10 @@ function viewClick(e){
|
||||
text-align: left;
|
||||
}
|
||||
.body-footbottom-box{
|
||||
height: calc(100% - 50px);
|
||||
padding-bottom: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: calc(100% - 70px);
|
||||
}
|
||||
.body-footbottom-left{
|
||||
width: 28%;
|
||||
@ -2958,8 +2871,8 @@ function viewClick(e){
|
||||
.body-footbottom-leftbox{
|
||||
min-width: 215px;
|
||||
width: 80%;
|
||||
min-height: 57px;
|
||||
height: 57px;
|
||||
min-height: 60px;
|
||||
height: 20%;
|
||||
background: inherit;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
@ -2969,8 +2882,8 @@ function viewClick(e){
|
||||
padding: 0px 20px;
|
||||
}
|
||||
.body-footbottom-center{
|
||||
width: 37%;
|
||||
height: calc(100% - 0px);
|
||||
width: 40%;
|
||||
height: calc(100%);
|
||||
}
|
||||
.body-footbottom-topbox{
|
||||
display: flex;
|
||||
@ -3005,7 +2918,7 @@ function viewClick(e){
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
align-content:space-around;
|
||||
align-content:space-between ;
|
||||
}
|
||||
|
||||
.body-userinfo-box{
|
||||
@ -3013,18 +2926,18 @@ function viewClick(e){
|
||||
z-index: 10;
|
||||
width: 100%;
|
||||
height: 346px;
|
||||
/* background: linear-gradient(135deg, rgba(42, 54, 73, 1) 0%, rgba(42, 54, 73, 1) 0%, rgba(34, 43, 56, 1) 100%, rgba(34, 43, 56, 1) 100%); */
|
||||
background: linear-gradient(135deg, #1a1e2a 0%, #222b38 100%);
|
||||
border: 1px solid #242E3D;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.body-userinfo-expandbox{
|
||||
height: 680px !important;
|
||||
height: 638px ;
|
||||
}
|
||||
.body-userinfo-box1{
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
width: 100%;
|
||||
height: 524px ;
|
||||
height: 534px ;
|
||||
/* background: linear-gradient(135deg, rgba(42, 54, 73, 1) 0%, rgba(42, 54, 73, 1) 0%, rgba(34, 43, 56, 1) 100%, rgba(34, 43, 56, 1) 100%); */
|
||||
background: linear-gradient(135deg, #1a1e2a 0%, #222b38 100%);
|
||||
border: 1px solid #242E3D;
|
||||
border-radius: 4px;
|
||||
@ -3050,7 +2963,7 @@ function viewClick(e){
|
||||
}
|
||||
.body-video-box1{
|
||||
width: 100%;
|
||||
height: calc(100% - 534px - 4px) ;
|
||||
height: calc(100% - 534px - 14px) ;
|
||||
background: linear-gradient(135deg, #1a1e2a 0%, #222b38 100%);
|
||||
border: 1px solid #242E3D;
|
||||
border-radius: 4px;
|
||||
@ -3187,10 +3100,10 @@ function viewClick(e){
|
||||
}
|
||||
.userinfo-disyplaypadding1{
|
||||
width: calc(64%);
|
||||
padding-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
}
|
||||
.padding10{
|
||||
padding-bottom: 10px;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
.userinfo-disyplaypadding2{
|
||||
width: calc(36%);
|
||||
@ -3425,25 +3338,6 @@ function viewClick(e){
|
||||
background:#14aaff;
|
||||
border:1px solid #14aaff;
|
||||
}
|
||||
.tipclosebutton{
|
||||
width: 80px;
|
||||
height: 40px;
|
||||
background-color: #597194;
|
||||
border-radius: 4px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tipclosebutton:hover{
|
||||
background-color: #14aaff;
|
||||
color: #fff;
|
||||
}
|
||||
.pop-up-tip-text{
|
||||
width:100%;
|
||||
font-weight: 400;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user