From 6903ee6f0b2404bf69e48c92d260bb21f61deda0 Mon Sep 17 00:00:00 2001 From: "qichi.liang" Date: Fri, 2 Jan 2026 06:25:36 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E4=BC=98=E5=8C=96=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E7=AE=A1=E7=90=86=E5=92=8C=E5=BC=82=E5=B8=B8=E5=A4=84?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加YAML配置文件支持 - 改进camera_manager异常处理 - 添加类型提示和URL验证 - 完善依赖注入支持测试 - 新增健康检查API端点 --- .dockerignore | 32 ------ app/camera_manager.py | 133 +++++++++++++++++++++--- app/config.py | 216 ++++++++++++++++++++++++++++----------- app/routes/main.py | 71 ++++++++++--- app/templates/index.html | 3 +- config.yaml | 47 +++++++++ requirements.txt | 16 ++- static/css/style.css | 67 ++++++++++++ static/js/app.js | 50 +++++++-- 9 files changed, 503 insertions(+), 132 deletions(-) delete mode 100644 .dockerignore create mode 100644 config.yaml diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 12799dc..0000000 --- a/.dockerignore +++ /dev/null @@ -1,32 +0,0 @@ -# 忽略文件 -.git -.gitignore -.env -.env.example -__pycache__ -*.pyc -*.pyo -*.pyd -.Python -*.so -*.egg -*.egg-info -dist -build -logs -*.log -*.sqlite -*.db -*.cache -.coverage -.pytest_cache -.mypy_cache -.vscode -.idea -*.swp -*.swo -*~ -.DS_Store -Thumbs.db -node_modules -static/node_modules \ No newline at end of file diff --git a/app/camera_manager.py b/app/camera_manager.py index 1371139..19839e3 100644 --- a/app/camera_manager.py +++ b/app/camera_manager.py @@ -2,18 +2,51 @@ 摄像头管理器 处理摄像头配置和URL生成 """ -import requests import logging +from typing import Any, Dict, List, Optional + +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + from .config import BASE_URL, CAMERA_URL, CAMERAS logger = logging.getLogger(__name__) + class CameraManager: - def __init__(self): - self.base_url = BASE_URL - self.camera_url = CAMERA_URL - self.session = requests.Session() - self.cameras = CAMERAS + """摄像头管理器""" + + def __init__(self, cameras: Optional[List[Dict[str, Any]]] = None) -> None: + """ + 初始化摄像头管理器 + + Args: + cameras: 摄像头配置列表,如果为None则使用全局配置 + """ + self.base_url: str = BASE_URL + self.camera_url: str = CAMERA_URL + self.cameras: List[Dict[str, Any]] = cameras if cameras is not None else CAMERAS.copy() + + # 初始化请求会话,配置连接池和重试策略 + self.session: requests.Session = requests.Session() + + # 配置重试策略 + retry_strategy = Retry( + total=3, + backoff_factor=1, + status_forcelist=[429, 500, 502, 503, 504], + allowed_methods=["HEAD", "GET", "OPTIONS"] + ) + + # 配置适配器 + adapter = HTTPAdapter( + max_retries=retry_strategy, + pool_connections=10, + pool_maxsize=10 + ) + self.session.mount("http://", adapter) + self.session.mount("https://", adapter) # 配置请求头 self.session.headers.update({ @@ -24,8 +57,20 @@ class CameraManager: 'Connection': 'keep-alive', }) - def get_camera_url(self, camera_id, camera_number='mixed'): - """根据摄像头ID和编号生成URL""" + def get_camera_url(self, camera_id: int, camera_number: str = 'mixed') -> str: + """ + 根据摄像头ID和编号生成URL + + Args: + camera_id: 摄像头ID + camera_number: 摄像头编号,默认为'mixed' + + Returns: + 摄像头URL + + Raises: + ValueError: 摄像头ID不存在 + """ camera = next((c for c in self.cameras if c['id'] == camera_id), None) if not camera: raise ValueError(f"摄像头ID {camera_id} 不存在") @@ -36,19 +81,73 @@ class CameraManager: else: return f"{self.camera_url}?room={room}&camera=camera-{camera_number}" - def refresh_camera(self, camera_id): - """刷新指定摄像头(模拟操作)""" + def refresh_camera(self, camera_id: int) -> bool: + """ + 刷新指定摄像头(模拟操作) + + Args: + camera_id: 摄像头ID + + Returns: + 是否成功 + """ logger.info(f"刷新摄像头 {camera_id}") return True - def get_all_cameras(self): - """返回所有摄像头配置""" + def get_all_cameras(self) -> List[Dict[str, Any]]: + """ + 返回所有摄像头配置 + + Returns: + 摄像头配置列表 + """ return self.cameras - def check_connection(self): - """检查连接状态""" + def check_connection(self, timeout: int = 5) -> bool: + """ + 检查连接状态 + + Args: + timeout: 超时时间(秒) + + Returns: + 连接是否正常 + """ try: - response = self.session.get(self.base_url, timeout=5) + response = self.session.get( + self.base_url, + timeout=timeout, + allow_redirects=True + ) + response.raise_for_status() return response.status_code == 200 - except: - return False \ No newline at end of file + except requests.exceptions.Timeout as e: + logger.warning(f"连接超时: {e}") + return False + except requests.exceptions.ConnectionError as e: + logger.warning(f"连接错误: {e}") + return False + except requests.exceptions.HTTPError as e: + logger.warning(f"HTTP错误: {e}") + return False + except requests.exceptions.RequestException as e: + logger.error(f"请求异常: {e}") + return False + + def get_camera_by_id(self, camera_id: int) -> Optional[Dict[str, Any]]: + """ + 根据ID获取摄像头配置 + + Args: + camera_id: 摄像头ID + + Returns: + 摄像头配置,如果不存在返回None + """ + return next((c for c in self.cameras if c['id'] == camera_id), None) + + def close(self) -> None: + """ + 关闭会话 + """ + self.session.close() diff --git a/app/config.py b/app/config.py index 96b7806..472829c 100644 --- a/app/config.py +++ b/app/config.py @@ -1,76 +1,170 @@ """ 配置文件 +支持YAML配置和.env环境变量 """ import os +from typing import Any, Dict, List, Optional +from urllib.parse import urlparse + +import yaml from dotenv import load_dotenv # 加载环境变量 load_dotenv() -# 基础URL(必须设置) +# 配置文件路径 +CONFIG_FILE = os.getenv("CONFIG_FILE", "config.yaml") + + +def _validate_url(url: str, field_name: str) -> None: + """验证URL格式""" + if not url: + raise ValueError(f"环境变量 {field_name} 未设置") + + parsed = urlparse(url) + if not all([parsed.scheme, parsed.netloc]): + raise ValueError(f"{field_name} 格式不正确: {url}") + + +def _load_yaml_config() -> Optional[Dict[str, Any]]: + """加载YAML配置文件""" + if os.path.exists(CONFIG_FILE): + with open(CONFIG_FILE, 'r', encoding='utf-8') as f: + return yaml.safe_load(f) + return None + + +def _get_config_value( + yaml_config: Optional[Dict[str, Any]], + env_key: str, + yaml_path: List[str], + default: Any = None, + validator: Optional[callable] = None +) -> Any: + """获取配置值,优先级:环境变量 > YAML配置 > 默认值""" + # 1. 优先从环境变量获取 + env_value = os.getenv(env_key) + if env_value is not None: + return env_value + + # 2. 其次从YAML配置获取 + if yaml_config: + value = yaml_config + for key in yaml_path: + if isinstance(value, dict) and key in value: + value = value[key] + else: + value = None + break + + if value is not None: + if validator: + validator(value) + return value + + # 3. 使用默认值 + return default + + +def _get_int_value(yaml_config: Optional[Dict[str, Any]], env_key: str, yaml_path: List[str], default: int) -> int: + """获取整数配置值""" + value = _get_config_value(yaml_config, env_key, yaml_path, str(default)) + try: + return int(value) + except (ValueError, TypeError): + return default + + +def _get_bool_value(yaml_config: Optional[Dict[str, Any]], env_key: str, yaml_path: List[str], default: bool) -> bool: + """获取布尔配置值""" + value = _get_config_value(yaml_config, env_key, yaml_path, str(default).lower()) + return value.lower() == "true" + + +# 加载YAML配置 +yaml_config = _load_yaml_config() + +# 验证并获取BASE_URL BASE_URL = os.getenv("BASE_URL") -if BASE_URL is None: - raise ValueError("环境变量 BASE_URL 未设置") +if BASE_URL is None and yaml_config: + BASE_URL = _get_config_value(yaml_config, "BASE_URL", ["camera", "base_url"]) +_validate_url(BASE_URL, "BASE_URL") + CAMERA_URL = f"{BASE_URL}/adaops/blank-layout/camera-view" -# 摄像头配置(可以从YAML/JSON加载,这里先硬编码) -CAMERAS = [ - { - 'id': 1, - 'room': 'cnfzhjyg-igv-251', - 'camera': 'mixed', - 'name': '1号车', - 'url': f"{CAMERA_URL}?room=cnfzhjyg-igv-251&camera=mixed" - }, - { - 'id': 2, - 'room': 'cnfzhjyg-igv-2', - 'camera': 'mixed', - 'name': '2号车', - 'url': f"{CAMERA_URL}?room=cnfzhjyg-igv-2&camera=mixed" - }, - { - 'id': 3, - 'room': 'cnfzhjyg-igv-3', - 'camera': 'mixed', - 'name': '3号车', - 'url': f"{CAMERA_URL}?room=cnfzhjyg-igv-3&camera=mixed" - }, - { - 'id': 4, - 'room': 'cnfzhjyg-igv-5', - 'camera': 'mixed', - 'name': '5号车', - 'url': f"{CAMERA_URL}?room=cnfzhjyg-igv-5&camera=mixed" - }, - { - 'id': 5, - 'room': 'cnfzhjyg-igv-6', - 'camera': 'mixed', - 'name': '6号车', - 'url': f"{CAMERA_URL}?room=cnfzhjyg-igv-6&camera=mixed" - }, - { - 'id': 6, - 'room': 'cnfzhjyg-igv-7', - 'camera': 'mixed', - 'name': '7号车', - 'url': f"{CAMERA_URL}?room=cnfzhjyg-igv-7&camera=mixed" - } -] - -# Flask配置(必须设置) -DEBUG_STR = os.getenv("FLASK_DEBUG") -if DEBUG_STR is None: - raise ValueError("环境变量 FLASK_DEBUG 未设置") -DEBUG = DEBUG_STR.lower() == "true" +# 摄像头配置 +def _load_cameras() -> List[Dict[str, Any]]: + """加载摄像头配置""" + if yaml_config and "cameras" in yaml_config: + cameras = yaml_config["cameras"] + # 如果cameras中没有url字段,自动生成 + for camera in cameras: + if 'url' not in camera: + room = camera.get('room', '') + cam = camera.get('camera', 'mixed') + camera['url'] = f"{CAMERA_URL}?room={room}&camera={cam}" + return cameras + + # 硬编码后备配置(不推荐使用) + return [ + { + 'id': 1, + 'room': 'cnfzhjyg-igv-251', + 'camera': 'mixed', + 'name': '1号车', + 'url': f"{CAMERA_URL}?room=cnfzhjyg-igv-251&camera=mixed" + }, + { + 'id': 2, + 'room': 'cnfzhjyg-igv-2', + 'camera': 'mixed', + 'name': '2号车', + 'url': f"{CAMERA_URL}?room=cnfzhjyg-igv-2&camera=mixed" + }, + { + 'id': 3, + 'room': 'cnfzhjyg-igv-3', + 'camera': 'mixed', + 'name': '3号车', + 'url': f"{CAMERA_URL}?room=cnfzhjyg-igv-3&camera=mixed" + }, + { + 'id': 4, + 'room': 'cnfzhjyg-igv-5', + 'camera': 'mixed', + 'name': '5号车', + 'url': f"{CAMERA_URL}?room=cnfzhjyg-igv-5&camera=mixed" + }, + { + 'id': 5, + 'room': 'cnfzhjyg-igv-6', + 'camera': 'mixed', + 'name': '6号车', + 'url': f"{CAMERA_URL}?room=cnfzhjyg-igv-6&camera=mixed" + }, + { + 'id': 6, + 'room': 'cnfzhjyg-igv-7', + 'camera': 'mixed', + 'name': '7号车', + 'url': f"{CAMERA_URL}?room=cnfzhjyg-igv-7&camera=mixed" + } + ] -PORT_STR = os.getenv("PORT") -if PORT_STR is None: - raise ValueError("环境变量 PORT 未设置") -PORT = int(PORT_STR) +CAMERAS: List[Dict[str, Any]] = _load_cameras() -HOST = os.getenv("HOST") -if HOST is None: - raise ValueError("环境变量 HOST 未设置") \ No newline at end of file +# Flask配置 +DEBUG: bool = _get_bool_value(yaml_config, "FLASK_DEBUG", ["app", "debug"], False) +PORT: int = _get_int_value(yaml_config, "PORT", ["app", "port"], 5000) +HOST: str = _get_config_value(yaml_config, "HOST", ["app", "host"], "0.0.0.0") + +# 验证HOST +if not HOST: + raise ValueError("环境变量 HOST 未设置") + +# 日志配置 +LOG_LEVEL: str = _get_config_value(yaml_config, "LOG_LEVEL", ["logging", "level"], "INFO") +LOG_FILE: str = _get_config_value(yaml_config, "LOG_FILE", ["logging", "file"], "logs/multi_camera.log") +LOG_MAX_BYTES: int = _get_int_value(yaml_config, "LOG_MAX_BYTES", ["logging", "max_bytes"], 10485760) +LOG_BACKUP_COUNT: int = _get_int_value(yaml_config, "LOG_BACKUP_COUNT", ["logging", "backup_count"], 10) diff --git a/app/routes/main.py b/app/routes/main.py index 5385934..9e7a213 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -3,46 +3,91 @@ """ import logging from datetime import datetime -from flask import render_template, jsonify, request +from typing import Dict, Any, List + +from flask import Flask, render_template, jsonify, request + from app.camera_manager import CameraManager logger = logging.getLogger(__name__) -# 创建摄像头管理器实例(全局) -camera_manager = CameraManager() +# 创建摄像头管理器实例(可通过依赖注入覆盖) +_camera_manager: CameraManager = None -def register_main_routes(app): + +def get_camera_manager() -> CameraManager: + """获取摄像头管理器实例(支持依赖注入)""" + global _camera_manager + if _camera_manager is None: + _camera_manager = CameraManager() + return _camera_manager + + +def set_camera_manager(manager: CameraManager) -> None: + """设置摄像头管理器实例(用于测试)""" + global _camera_manager + _camera_manager = manager + + +def register_main_routes(app: Flask) -> None: """注册主路由到Flask应用""" @app.route('/') - def index(): + def index() -> str: """主页面 - 显示6个摄像头的网格布局""" + camera_manager = get_camera_manager() cameras = camera_manager.get_all_cameras() - return render_template('index.html', - cameras=cameras, - current_time=datetime.now().strftime('%Y-%m-%d %H:%M:%S')) + return render_template( + 'index.html', + cameras=cameras, + current_time=datetime.now().strftime('%Y-%m-%d %H:%M:%S') + ) @app.route('/api/cameras') - def get_cameras(): + def get_cameras() -> Dict[str, List[Dict[str, Any]]]: """获取摄像头列表API""" + camera_manager = get_camera_manager() cameras = camera_manager.get_all_cameras() return jsonify(cameras) @app.route('/api/refresh/', methods=['POST']) - def refresh_camera(camera_id): + def refresh_camera(camera_id: int) -> Dict[str, Any]: """刷新指定摄像头""" + camera_manager = get_camera_manager() success = camera_manager.refresh_camera(camera_id) return jsonify({'success': success, 'camera_id': camera_id}) @app.route('/api/switch', methods=['POST']) - def switch_camera(): + def switch_camera() -> Dict[str, Any]: """切换摄像头编号""" + camera_manager = get_camera_manager() data = request.get_json() + + if not data: + return jsonify({'success': False, 'error': '无效的请求数据'}), 400 + camera_id = data.get('camera_id') camera_number = data.get('camera_number', 'mixed') + + if camera_id is None: + return jsonify({'success': False, 'error': '缺少camera_id参数'}), 400 + try: url = camera_manager.get_camera_url(camera_id, camera_number) return jsonify({'success': True, 'url': url}) - except Exception as e: + except ValueError as e: return jsonify({'success': False, 'error': str(e)}), 400 - \ No newline at end of file + except Exception as e: + logger.error(f"切换摄像头失败: {e}") + return jsonify({'success': False, 'error': '内部服务器错误'}), 500 + + @app.route('/api/health') + def health_check() -> Dict[str, Any]: + """健康检查API""" + camera_manager = get_camera_manager() + is_connected = camera_manager.check_connection() + return jsonify({ + 'status': 'healthy' if is_connected else 'degraded', + 'connection': is_connected, + 'timestamp': datetime.now().isoformat() + }) diff --git a/app/templates/index.html b/app/templates/index.html index 383dc93..37964e7 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -33,7 +33,8 @@
- + + diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..d50d992 --- /dev/null +++ b/config.yaml @@ -0,0 +1,47 @@ +# 多摄像头监控系统配置文件 + +# 基础配置 +app: + host: "0.0.0.0" + port: 5000 + debug: false + +# 摄像头服务配置 +camera: + base_url: "http://localhost:8080" + camera_path: "/adaops/blank-layout/camera-view" + +# 摄像头列表配置 +cameras: + - id: 1 + room: "cnfzhjyg-igv-251" + camera: "mixed" + name: "1号车" + # url会自动生成: {camera_url}?room={room}&camera={camera} + - id: 2 + room: "cnfzhjyg-igv-2" + camera: "mixed" + name: "2号车" + - id: 3 + room: "cnfzhjyg-igv-3" + camera: "mixed" + name: "3号车" + - id: 4 + room: "cnfzhjyg-igv-5" + camera: "mixed" + name: "5号车" + - id: 5 + room: "cnfzhjyg-igv-6" + camera: "mixed" + name: "6号车" + - id: 6 + room: "cnfzhjyg-igv-7" + camera: "mixed" + name: "7号车" + +# 日志配置 +logging: + level: "INFO" + file: "logs/multi_camera.log" + max_bytes: 10485760 + backup_count: 10 diff --git a/requirements.txt b/requirements.txt index d4e86f5..c36feb7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,17 @@ +# Flask框架 Flask==2.3.3 + +# HTTP请求 requests==2.31.0 -python-dotenv==1.0.0 \ No newline at end of file + +# 环境变量 +python-dotenv==1.0.0 + +# YAML配置支持 +PyYAML>=6.0 + +# 生产环境WSGI服务器 +gunicorn>=21.0.0 + +# 异步支持(可选) +# gevent>=23.0.0 diff --git a/static/css/style.css b/static/css/style.css index 937c64d..24a179c 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -64,6 +64,67 @@ body { border-color: #4CAF50; z-index: 10; } +/* 全屏模式下的样式 */ +.camera-item:fullscreen { + width: 100vw; + height: 100vh; + display: flex; + flex-direction: column; + background: #000; + border: none; +} +.camera-item:fullscreen .camera-frame-container { + flex: 1; + height: auto; +} +.camera-item:fullscreen .camera-controls-combined { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + background: rgba(0, 0, 0, 0.7); + padding: 10px 20px; + border-radius: 25px; + z-index: 9999; +} +.camera-item:fullscreen .camera-header { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 9999; +} + +/* WebKit浏览器全屏支持 */ +.camera-item:-webkit-full-screen { + width: 100vw; + height: 100vh; + display: flex; + flex-direction: column; + background: #000; + border: none; +} +.camera-item:-webkit-full-screen .camera-frame-container { + flex: 1; + height: auto; +} +.camera-item:-webkit-full-screen .camera-controls-combined { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + background: rgba(0, 0, 0, 0.7); + padding: 10px 20px; + border-radius: 25px; + z-index: 9999; +} +.camera-item:-webkit-full-screen .camera-header { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 9999; +} .camera-header { background: #3a3a3a; padding: 8px 12px; @@ -124,6 +185,12 @@ body { .cam-btn-fullscreen:hover { background: #F57C00; } +.cam-btn-exit-fullscreen { + background: #f44336; +} +.cam-btn-exit-fullscreen:hover { + background: #d32f2f; +} .camera-controls-combined { background: #3a3a3a; padding: 6px; diff --git a/static/js/app.js b/static/js/app.js index ef0ac24..62c99b8 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -26,15 +26,16 @@ function refreshAllCameras() { } function toggleFullscreen(cameraId) { - const frame = document.getElementById(`frame-${cameraId}`); + const cameraItem = document.getElementById(`camera-${cameraId}`); if (!document.fullscreenElement) { - if (frame.requestFullscreen) { - frame.requestFullscreen(); - } else if (frame.webkitRequestFullscreen) { - frame.webkitRequestFullscreen(); - } else if (frame.msRequestFullscreen) { - frame.msRequestFullscreen(); + // 全屏相机容器而不是iframe + if (cameraItem.requestFullscreen) { + cameraItem.requestFullscreen(); + } else if (cameraItem.webkitRequestFullscreen) { + cameraItem.webkitRequestFullscreen(); + } else if (cameraItem.msRequestFullscreen) { + cameraItem.msRequestFullscreen(); } } else { if (document.exitFullscreen) { @@ -47,6 +48,41 @@ function toggleFullscreen(cameraId) { } } +// 监听全屏变化事件 +document.addEventListener('fullscreenchange', handleFullscreenChange); +document.addEventListener('webkitfullscreenchange', handleFullscreenChange); +document.addEventListener('msfullscreenchange', handleFullscreenChange); + +function handleFullscreenChange() { + // 获取所有相机 + for (let i = 1; i <= 6; i++) { + const fullscreenBtn = document.getElementById(`fullscreen-btn-${i}`); + const exitFullscreenBtn = document.getElementById(`exit-fullscreen-btn-${i}`); + + if (fullscreenBtn && exitFullscreenBtn) { + if (document.fullscreenElement) { + // 进入全屏时,隐藏全屏按钮,显示退出按钮 + fullscreenBtn.style.display = 'none'; + exitFullscreenBtn.style.display = 'inline-block'; + } else { + // 退出全屏时,显示全屏按钮,隐藏退出按钮 + fullscreenBtn.style.display = 'inline-block'; + exitFullscreenBtn.style.display = 'none'; + } + } + } +} + +function exitFullscreen() { + if (document.exitFullscreen) { + document.exitFullscreen(); + } else if (document.webkitExitFullscreen) { + document.webkitExitFullscreen(); + } else if (document.msExitFullscreen) { + document.msExitFullscreen(); + } +} + function switchCameraNumber(event, cameraId, cameraNumber) { const frame = document.getElementById(`frame-${cameraId}`); const controls = document.getElementById(`controls-${cameraId}`);