Kaynağa Gözat

初始化项目

eric.w 2 hafta önce
ebeveyn
işleme
5655c17638
100 değiştirilmiş dosya ile 16001 ekleme ve 128 silme
  1. 15 0
      .dockerignore
  2. 8 0
      .env.example
  3. 83 0
      .gitignore
  4. 41 0
      BridgeDiseaseBackend-main/.gitignore
  5. 99 0
      BridgeDiseaseBackend-main/app/__init__.py
  6. 58 0
      BridgeDiseaseBackend-main/app/config.py
  7. 79 0
      BridgeDiseaseBackend-main/app/constants.py
  8. 1 0
      BridgeDiseaseBackend-main/app/decorators/__init__.py
  9. 24 0
      BridgeDiseaseBackend-main/app/decorators/auth_decorators.py
  10. 40 0
      BridgeDiseaseBackend-main/app/errors.py
  11. 26 0
      BridgeDiseaseBackend-main/app/models/__init__.py
  12. 120 0
      BridgeDiseaseBackend-main/app/models/detection.py
  13. 82 0
      BridgeDiseaseBackend-main/app/models/media.py
  14. 119 0
      BridgeDiseaseBackend-main/app/models/model.py
  15. 72 0
      BridgeDiseaseBackend-main/app/models/operation.py
  16. 89 0
      BridgeDiseaseBackend-main/app/models/user.py
  17. 37 0
      BridgeDiseaseBackend-main/app/routes/__init__.py
  18. 536 0
      BridgeDiseaseBackend-main/app/routes/detection_route.py
  19. 36 0
      BridgeDiseaseBackend-main/app/routes/file_route.py
  20. 399 0
      BridgeDiseaseBackend-main/app/routes/media_route.py
  21. 327 0
      BridgeDiseaseBackend-main/app/routes/model_route.py
  22. 157 0
      BridgeDiseaseBackend-main/app/routes/operation_route.py
  23. 824 0
      BridgeDiseaseBackend-main/app/routes/user_route.py
  24. 8 0
      BridgeDiseaseBackend-main/app/utils/__init__.py
  25. 140 0
      BridgeDiseaseBackend-main/app/utils/disease_metrics.py
  26. 80 0
      BridgeDiseaseBackend-main/app/utils/field_check.py
  27. 98 0
      BridgeDiseaseBackend-main/app/utils/file_util.py
  28. 19 0
      BridgeDiseaseBackend-main/app/utils/json_util.py
  29. 17 0
      BridgeDiseaseBackend-main/app/utils/jwt.py
  30. 25 0
      BridgeDiseaseBackend-main/app/utils/operation_util.py
  31. 16 0
      BridgeDiseaseBackend-main/app/utils/pagination.py
  32. 197 0
      BridgeDiseaseBackend-main/app/utils/rate_limiter.py
  33. 17 0
      BridgeDiseaseBackend-main/requirements.txt
  34. 14 0
      BridgeDiseaseBackend-main/run.py
  35. 129 0
      BridgeDiseaseBackend-main/scripts/download_real_medias.py
  36. 80 0
      BridgeDiseaseBackend-main/scripts/fix_model_texts.py
  37. 47 0
      BridgeDiseaseBackend-main/scripts/fix_user_names.py
  38. 229 0
      BridgeDiseaseBackend-main/scripts/seed_detections.py
  39. 224 0
      BridgeDiseaseBackend-main/scripts/seed_medias.py
  40. BIN
      BridgeDiseaseBackend-main/seed_assets/medias/01_concrete_crack_bridge.jpg
  41. BIN
      BridgeDiseaseBackend-main/seed_assets/medias/02_bridge_concrete_cracks.jpg
  42. BIN
      BridgeDiseaseBackend-main/seed_assets/medias/03_steel_bridge_corrosion.jpg
  43. BIN
      BridgeDiseaseBackend-main/seed_assets/medias/04_concrete_bending_cracks.jpg
  44. BIN
      BridgeDiseaseBackend-main/seed_assets/medias/05_bridge_substructure.jpg
  45. BIN
      BridgeDiseaseBackend-main/seed_assets/medias/06_shrinkage_cracks_concrete.jpg
  46. BIN
      BridgeDiseaseBackend-main/seed_assets/medias/07_asphalt_crocodile_cracking.jpg
  47. BIN
      BridgeDiseaseBackend-main/seed_assets/medias/08_concrete_rebar_corrosion.jpg
  48. BIN
      BridgeDiseaseBackend-main/seed_assets/medias/09_steel_beam_site.jpg
  49. BIN
      BridgeDiseaseBackend-main/seed_assets/medias/10_rust_metal_texture.jpg
  50. 25 0
      BridgeDiseaseBackend-main/seed_assets/medias/ATTRIBUTION.md
  51. 33 0
      BridgeDiseaseBackend-main/sql/docker_patch_admin_login.sql
  52. 417 0
      BridgeDiseaseBackend-main/sql/init_db.sql
  53. 187 128
      README.md
  54. 36 0
      bridge-disease-frontend-main/.gitignore
  55. 3 0
      bridge-disease-frontend-main/.vscode/extensions.json
  56. 8 0
      bridge-disease-frontend-main/.vscode/settings.json
  57. 5 0
      bridge-disease-frontend-main/build.bat
  58. 16 0
      bridge-disease-frontend-main/index.html
  59. 8 0
      bridge-disease-frontend-main/jsconfig.json
  60. 3441 0
      bridge-disease-frontend-main/package-lock.json
  61. 25 0
      bridge-disease-frontend-main/package.json
  62. 21 0
      bridge-disease-frontend-main/public/bridge-disease.svg
  63. 70 0
      bridge-disease-frontend-main/src/App.vue
  64. 69 0
      bridge-disease-frontend-main/src/components/BreadcrumbNav.vue
  65. 217 0
      bridge-disease-frontend-main/src/components/ContactSupportCard.vue
  66. 25 0
      bridge-disease-frontend-main/src/components/FooterComponent.vue
  67. 206 0
      bridge-disease-frontend-main/src/components/HeaderComponent.vue
  68. 148 0
      bridge-disease-frontend-main/src/components/ParticleBackground.vue
  69. 318 0
      bridge-disease-frontend-main/src/components/SidebarMenu.vue
  70. 656 0
      bridge-disease-frontend-main/src/components/StatisticsCharts.vue
  71. 359 0
      bridge-disease-frontend-main/src/components/shell/AgentAssistantDrawer.vue
  72. 66 0
      bridge-disease-frontend-main/src/components/shell/CommunityQuickEntry.vue
  73. 17 0
      bridge-disease-frontend-main/src/components/shell/HudPageHero.vue
  74. 306 0
      bridge-disease-frontend-main/src/components/shell/InsightDashboard.vue
  75. 33 0
      bridge-disease-frontend-main/src/components/shell/insightMockData.js
  76. 43 0
      bridge-disease-frontend-main/src/composables/useHudTheme.js
  77. 17 0
      bridge-disease-frontend-main/src/main.js
  78. 401 0
      bridge-disease-frontend-main/src/mocks/detectionAndMediaMockData.js
  79. 232 0
      bridge-disease-frontend-main/src/mocks/iotMonitoringMockData.js
  80. 136 0
      bridge-disease-frontend-main/src/mocks/professionalModulesMockData.js
  81. 121 0
      bridge-disease-frontend-main/src/router/index.js
  82. 44 0
      bridge-disease-frontend-main/src/shellConstants.js
  83. 511 0
      bridge-disease-frontend-main/src/stores/iotMonitoringStore.js
  84. 306 0
      bridge-disease-frontend-main/src/stores/professionalModulesStore.js
  85. 340 0
      bridge-disease-frontend-main/src/stores/resourceStore.js
  86. 22 0
      bridge-disease-frontend-main/src/stores/sidebarStore.js
  87. 11 0
      bridge-disease-frontend-main/src/stores/uiShellStore.js
  88. 40 0
      bridge-disease-frontend-main/src/stores/userStore.js
  89. 51 0
      bridge-disease-frontend-main/src/styles/hud-ep-bridge.css
  90. 817 0
      bridge-disease-frontend-main/src/styles/hud-layout.css
  91. 72 0
      bridge-disease-frontend-main/src/styles/iot-page.css
  92. 37 0
      bridge-disease-frontend-main/src/styles/shell-theme.css
  93. 37 0
      bridge-disease-frontend-main/src/utils/avatarUtils.js
  94. 119 0
      bridge-disease-frontend-main/src/utils/dateTimeFormatter.js
  95. 133 0
      bridge-disease-frontend-main/src/utils/detailFetcher.js
  96. 125 0
      bridge-disease-frontend-main/src/utils/request.js
  97. 244 0
      bridge-disease-frontend-main/src/views/AlertManagementView.vue
  98. 306 0
      bridge-disease-frontend-main/src/views/BatchDetectionView.vue
  99. 264 0
      bridge-disease-frontend-main/src/views/DataCollectionView.vue
  100. 245 0
      bridge-disease-frontend-main/src/views/DataProcessingView.vue

+ 15 - 0
.dockerignore

@@ -0,0 +1,15 @@
+.git
+**/.git
+**/node_modules
+**/__pycache__
+**/*.pyc
+**/.venv
+**/venv
+**/logs
+*.log
+**/.idea
+**/.vscode
+BridgeDiseaseBackend-main/app/static/medias/**
+BridgeDiseaseBackend-main/app/static/results/**
+BridgeDiseaseBackend-main/app/static/models/**/*.pt
+BridgeDiseaseBackend-main/logs

+ 8 - 0
.env.example

@@ -0,0 +1,8 @@
+# Copy to .env if missing. Prefer: npm run up (see root package.json).
+COMPOSE_BAKE=false
+DOCKER_BUILDKIT=0
+
+MYSQL_ROOT_PASSWORD=bridgedisease_root
+MYSQL_DATABASE=bridge_disease
+MYSQL_HOST_PORT=3307
+VITE_API_BASE_URL=http://127.0.0.1:5000

+ 83 - 0
.gitignore

@@ -0,0 +1,83 @@
+# =============================================================================
+# 检澜 DockScope — 仓库根 .gitignore(推送远程前勿提交密钥与本地构建产物)
+# =============================================================================
+
+# ----- 环境变量与密钥(务必忽略;团队用 .env.example 约定变量名) -----
+.env
+.env.*.local
+
+# ----- 操作系统 -----
+.DS_Store
+Thumbs.db
+Desktop.ini
+
+# ----- 编辑器 / IDE -----
+.idea/
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+*.swp
+*.swo
+
+# VS Code:默认忽略个人配置,需要团队共享时可取消注释下面两行
+.vscode/*
+!.vscode/extensions.json
+
+# ----- Node(根目录编排脚本、前端等任意位置的 node_modules) -----
+node_modules/
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+.pnpm-store/
+*.tsbuildinfo
+
+# ----- 全仓库前端构建 -----
+dist/
+dist-ssr/
+
+# ----- Python(后端与任意脚本) -----
+__pycache__/
+*.py[cod]
+*$py.class
+.Python
+*.so
+.venv/
+venv/
+ENV/
+env/
+*.egg-info/
+.eggs/
+pip-wheel-metadata/
+*.egg
+.pytest_cache/
+.mypy_cache/
+.ruff_cache/
+.coverage
+htmlcov/
+.tox/
+.nox/
+
+# ----- 日志 -----
+logs/
+*.log
+
+# ----- 测试与覆盖率 -----
+coverage/
+.cypress/
+cypress/videos/
+cypress/screenshots/
+
+# ----- 临时与缓存 -----
+.cache/
+.temp/
+tmp/
+*.tmp
+*.bak
+*.orig
+
+# ----- 可选:本地 Docker / 数据卷目录名(若在仓库根创建) -----
+mysql_data/

+ 41 - 0
BridgeDiseaseBackend-main/.gitignore

@@ -0,0 +1,41 @@
+# IDE specific
+.idea/
+
+# Python bytecode
+*.pyc
+*.pyo
+*.pyd
+__pycache__/
+*.so
+
+# Flask-specific
+logs/
+*.log
+
+# Database files
+*.sqlite
+
+# Test coverage and Pytest
+.pytest_cache/
+.coverage
+htmlcov/
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# 功能结果文件
+static/

+ 99 - 0
BridgeDiseaseBackend-main/app/__init__.py

@@ -0,0 +1,99 @@
+import os
+from datetime import datetime
+from logging.config import dictConfig
+
+from flask import Flask
+from flask_cors import CORS
+
+from .config import Config
+from .errors import *
+from .models import init_db
+from .routes import register_routes
+from .utils import init_jwt
+from .utils.rate_limiter import configure_rate_limiting
+
+
+def create_app():
+    """
+    创建和配置 Flask 应用实例。
+
+    :return: 配置好的 Flask 应用实例
+    :rtype: Flask
+    """
+    app = Flask(__name__, instance_relative_config=True)
+
+    # 启用 CORS 支持
+    CORS(app)
+
+    # 配置日志记录
+    configure_logging()
+
+    # 加载配置
+    app.config.from_object(Config)
+    app.logger.info(f'初始化服务配置:{dict(app.config)}')
+
+    # 初始化 JWT
+    init_jwt(app)
+
+    # 初始化数据库
+    init_db(app)
+
+    # 注册蓝图
+    register_routes(app)
+
+    # 配置 API 限流 - 只使用内存存储作为限流后端
+    configure_rate_limiting(app)
+
+    # 注册全局错误处理器
+    register_error_handlers(app)
+
+    return app
+
+
+def configure_logging():
+    """
+    配置日志记录。
+
+    创建一个名为 'logs' 的文件夹(如果不存在),并配置日志记录的格式和处理器。
+    日志记录将保存到 'logs' 文件夹中一个以时间戳命名的文件中,以确保每次运行时都有一个新的日志文件。
+
+    日志记录器包括:
+    - `StreamHandler` 用于输出到控制台(WSGI 错误流)
+    - `FileHandler` 用于将日志写入文件
+
+    :return: None
+    """
+    log_folder = 'logs'
+    if not os.path.exists(log_folder):
+        os.makedirs(log_folder)
+
+    timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
+    log_filename = os.path.join(log_folder, f'app_{timestamp}.log')
+
+    dictConfig({
+        'version': 1,
+        'formatters': {
+            'default': {
+                'format': '[%(asctime)s] %(levelname)s at %(filename)s:%(lineno)d: %(message)s',
+                'datefmt': '%Y-%m-%d %H:%M:%S'
+            }
+        },
+        'handlers': {
+            'wsgi': {
+                'class': 'logging.StreamHandler',
+                'stream': 'ext://flask.logging.wsgi_errors_stream',
+                'formatter': 'default'
+            },
+            'file': {
+                'class': 'logging.FileHandler',
+                'filename': log_filename,
+                'formatter': 'default',
+                'encoding': 'utf-8',
+                'level': 'DEBUG'
+            }
+        },
+        'root': {
+            'level': 'DEBUG',
+            'handlers': ['wsgi', 'file']
+        }
+    })

+ 58 - 0
BridgeDiseaseBackend-main/app/config.py

@@ -0,0 +1,58 @@
+import os
+from datetime import timedelta
+from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
+
+
+def mysql_uri_with_utf8mb4(uri: str) -> str:
+    """为 MySQL 连接补上 charset=utf8mb4,避免中文姓名等写入变成 ??。"""
+    if not uri or 'mysql' not in uri.split('://', 1)[0].lower():
+        return uri
+    parsed = urlparse(uri)
+    params = dict(parse_qsl(parsed.query, keep_blank_values=True))
+    if params.get('charset', '').lower() not in ('utf8', 'utf8mb4'):
+        params['charset'] = 'utf8mb4'
+    query = urlencode(params)
+    return urlunparse(parsed._replace(query=query))
+
+
+class Config:
+    SECRET_KEY = 'WZY'  # Flask 密钥,用于签名 cookies 和其他需要加密的操作
+    # 支援 Docker:以環境變數覆寫(建議使用 mysql+pymysql://...)
+    SQLALCHEMY_DATABASE_URI = mysql_uri_with_utf8mb4(
+        os.environ.get(
+            'SQLALCHEMY_DATABASE_URI',
+            'mysql://root:YHJYgain9420.@localhost/bridge_disease',
+        )
+    )  # SQLAlchemy 数据库 URI
+    SQLALCHEMY_TRACK_MODIFICATIONS = False  # 禁用 SQLAlchemy 的修改追踪
+    ALLOWED_IMAGE_EXTENSIONS = {'png', 'jpg', 'jpeg'}  # 允许上传的图片文件扩展名
+    ALLOWED_VIDEO_EXTENSIONS = {'mp4'}  # 允许上传的视频文件扩展名
+    ALLOWED_MODEL_EXTENSIONS = {'pt'}  # 允许上传的模型文件扩展名
+    AVATARS_FOLDER = os.path.join(os.getcwd(), 'app', 'static', 'avatars')  # 头像存储的文件夹路径
+    MODELS_FOLDER = os.path.join(os.getcwd(), 'app', 'static', 'models')  # 模型存储的文件夹路径
+    MEDIAS_FOLDER = os.path.join(os.getcwd(), 'app', 'static', 'medias')  # 媒体存储的文件夹路径
+    RESULTS_FOLDER = os.path.join(os.getcwd(), 'app', 'static', 'results')  # 结果存储的文件夹路径
+    MAX_AVATAR_SIZE = 5 * 1024 * 1024  # 最大头像文件大小:5MB
+    # HS256 需足夠長的密鑰;過短會觸發 PyJWT InsecureKeyLengthWarning,且驗證可能失敗(客戶端 422)
+    JWT_SECRET_KEY = os.environ.get(
+        'JWT_SECRET_KEY',
+        'bridge-disease-dev-jwt-secret-min-32-chars-change-me',
+    )
+    JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=1)  # access token 过期时间
+    JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30)  # refresh token 过期时间
+    CRACK_SCALA_FACTOR = 0.1  # 裂缝缩放因子
+    
+    # 病害指标权重配置
+    DISEASE_INDEX_WEIGHTS = {
+        'disease_count': 0.15,
+        'disease_perimeter': 0.15,
+        'disease_area': 0.2,
+        'shape_complexity': 0.15,
+        'texture_roughness': 0.15,
+        'crack_width': 0.1,
+        'avg_hue': 0.1,
+    }
+
+    # API 限流配置
+    RATE_LIMIT_DEFAULT_LIMIT = 60  # 默认每分钟允许的请求次数
+    RATE_LIMIT_DEFAULT_PERIOD = 60  # 默认限流时间窗口(s)

+ 79 - 0
BridgeDiseaseBackend-main/app/constants.py

@@ -0,0 +1,79 @@
+from enum import Enum
+
+
+class UserRole(Enum):
+    """用户角色枚举"""
+    ADMIN = 'admin'  # 管理员
+    DEVELOPER = 'developer'  # 开发者
+    USER = 'user'  # 普通用户
+
+    @classmethod
+    def list(cls):
+        """返回所有用户角色"""
+        return [item.value for item in cls]
+
+
+class UserStatus(Enum):
+    """用户状态枚举"""
+    ACTIVE = 'active'  # 在线
+    INACTIVE = 'inactive'  # 离线
+    BANNED = 'banned'  # 封禁
+    DELETED = 'deleted'  # 注销
+
+    @classmethod
+    def list(cls):
+        """返回所有用户状态"""
+        return [item.value for item in cls]
+
+
+class DiseaseGrade(Enum):
+    """病害评估等级枚举"""
+    MILD = 'mild'  # 轻度
+    MODERATE = 'moderate'  # 中度
+    SEVERE = 'severe'  # 重度
+    CRITICAL = 'critical'  # 严重
+
+    @classmethod
+    def list(cls):
+        """返回所有病害等级"""
+        return [item.value for item in cls]
+
+
+class TaskStatus(Enum):
+    """检测任务状态枚举"""
+    PENDING = 'pending'  # 待处理
+    IN_PROGRESS = 'in_progress'  # 检测中
+    COMPLETED = 'completed'  # 已完成
+    FAILED = 'failed'  # 失败
+
+    @classmethod
+    def list(cls):
+        """返回所有任务状态"""
+        return [item.value for item in cls]
+
+
+class OperationType(Enum):
+    """操作类型枚举"""
+    AUTHENTICATE = 'authenticate'  # 鉴权
+    CREATE = 'create'  # 创建
+    READ = 'read'  # 读取
+    UPDATE = 'update'  # 更新
+    DELETE = 'delete'  # 删除
+    EXECUTE = 'execute'  # 执行任务
+    MANAGE = 'manage'  # 管理操作
+
+    @classmethod
+    def list(cls):
+        """获取所有操作类型的列表"""
+        return [item.value for item in cls]
+
+
+class OperationStatus(Enum):
+    """操作状态枚举"""
+    SUCCESS = 'success'  # 操作成功
+    FAILURE = 'failure'  # 操作失败
+
+    @classmethod
+    def list(cls):
+        """获取所有操作状态的列表"""
+        return [item.value for item in cls]

+ 1 - 0
BridgeDiseaseBackend-main/app/decorators/__init__.py

@@ -0,0 +1 @@
+from .auth_decorators import *

+ 24 - 0
BridgeDiseaseBackend-main/app/decorators/auth_decorators.py

@@ -0,0 +1,24 @@
+from functools import wraps
+
+from flask import jsonify, current_app
+from flask_jwt_extended import get_jwt_identity
+
+from app.models import User
+
+
+def login_required(f):
+    @wraps(f)
+    def decorated_function(*args, **kwargs):
+        current_user_id = get_jwt_identity()
+        try:
+            pk = int(current_user_id) if current_user_id is not None else None
+        except (TypeError, ValueError):
+            pk = None
+        current_user = User.query.get(pk) if pk is not None else None
+        if not current_user:
+            failure_message = f"【登录用户验证失败】尚未登录或服务器数据异常(用户 ID: {current_user_id} 不存在)"
+            current_app.logger.error(failure_message)
+            return jsonify({'error': failure_message}), 404
+        return f(*args, **kwargs)
+
+    return decorated_function

+ 40 - 0
BridgeDiseaseBackend-main/app/errors.py

@@ -0,0 +1,40 @@
+import traceback
+
+from flask import jsonify, current_app, request
+from sqlalchemy.exc import SQLAlchemyError
+from werkzeug.exceptions import HTTPException
+
+
+def register_error_handlers(app):
+    @app.errorhandler(Exception)
+    def handle_error(error):
+        # 获取详细的堆栈追踪信息
+        stack_trace = traceback.format_exc()
+
+        # 获取请求的相关信息
+        request_method = request.method
+        request_url = request.url
+        request_data = request.get_data(as_text=True)
+
+        # 记录日志,帮助排查问题
+        current_app.logger.error(f"Error: {str(error)}\nStack Trace: {stack_trace}\n"
+                                 f"Request Method: {request_method}\n"
+                                 f"Request URL: {request_url}\n"
+                                 f"Request Data: {request_data}")
+
+        # 如果是数据库相关的错误
+        if isinstance(error, SQLAlchemyError):
+            return jsonify({"error": "Database operation failed"}), 500
+
+        # 如果是 HTTP 异常(如 404, 405 等)
+        elif isinstance(error, HTTPException):
+            return jsonify({"error": error.description}), error.code
+
+        # 其他标准异常(例如 ValueError, KeyError 等)
+        elif isinstance(error, ValueError):
+            return jsonify({"error": "Invalid value provided"}), 400
+        elif isinstance(error, KeyError):
+            return jsonify({"error": "Missing key in the request"}), 400
+
+        # 捕获所有其他错误
+        return jsonify({"error": "An unexpected error occurred"}), 500

+ 26 - 0
BridgeDiseaseBackend-main/app/models/__init__.py

@@ -0,0 +1,26 @@
+from flask_sqlalchemy import SQLAlchemy
+
+db = SQLAlchemy()
+
+
+def init_db(app):
+    """
+    初始化数据库并将其绑定到 Flask 应用。
+
+    该函数会在 Flask 应用初始化时调用,负责设置数据库连接,并根据
+    定义的模型创建相应的数据库表。
+
+    :param app: Flask 应用实例
+    """
+    db.init_app(app)
+    with app.app_context():
+        db.create_all()  # 创建数据库表
+
+    app.logger.info('初始化数据库。')
+
+
+from .user import *
+from .model import *
+from .media import *
+from .detection import *
+from .operation import *

+ 120 - 0
BridgeDiseaseBackend-main/app/models/detection.py

@@ -0,0 +1,120 @@
+from datetime import datetime
+from zoneinfo import ZoneInfo
+
+from app.constants import DiseaseGrade, TaskStatus
+from app.models import db
+
+
+class Detection(db.Model):
+    """
+    检测分割记录类,表示数据库中的 'detection' 表。
+
+    每一条记录对应一次检测任务,包含检测分割结果图像,
+    以及与检测分割任务相关的各类统计信息(如病害数量、面积、形状复杂度等)及评估描述。
+
+    Attributes:
+        detection_id (int): 检测记录的唯一标识符(主键)。
+        result_path (str): 检测和分割的结果存储路径。
+        disease_count (int): 病害的数量。
+        disease_perimeter (float): 病害的周长。
+        disease_area (float): 病害的面积。
+        shape_complexity (float): 病害形状的复杂度。
+        texture_roughness (float): 病害的纹理粗糙度。
+        crack_width (float): 裂缝的宽度(适用于裂缝病害)。
+        avg_hue (float): 病害的平均色调(适用于锈蚀等病害)。
+        disease_severity_score (float): 病害严重性得分。
+        disease_grade (str): 病害的评估等级,使用枚举类型('mild', 'moderate', 'severe', 'critical')。
+        disease_description (str): 病害评估的描述信息。
+        detection_duration (float): 检测分割耗时(ms)。
+        avg_frame_detection_duration (float): 帧平均检测分割耗时(ms)。
+        status (str): 任务状态,使用枚举类型('pending', 'in_progress', 'completed', 'failed')。
+        detection_at (datetime): 检测任务执行的时间,自动生成。
+        updated_at (datetime): 记录最后更新时间,自动更新。
+        owner_id (int): 执行该检测任务的用户 ID(外键)。
+        model_id (int): 使用的模型 ID(外键)。
+        media_id (int): 使用的媒体文件 ID(外键)。
+
+    Relationships:
+        owner (User): 每条检测记录关联一个用户,表示该任务由哪个用户执行。
+        model (Model): 每条记录关联一个模型,表示该任务使用的模型。
+        media (Media): 每条记录关联一个媒体文件,表示该任务使用的媒体文件。
+    """
+    __tablename__ = 'detection'
+
+    detection_id = db.Column(db.Integer, primary_key=True, autoincrement=True)  # 检测分割记录 ID
+    result_path = db.Column(db.String(255))  # 检测分割结果路径
+    disease_count = db.Column(db.Integer, default=0)  # 病害数量
+    disease_perimeter = db.Column(db.Float, default=0.0)  # 病害周长
+    disease_area = db.Column(db.Float, default=0.0)  # 病害面积
+    shape_complexity = db.Column(db.Float, default=0.0)  # 形状复杂度
+    texture_roughness = db.Column(db.Float, default=0.0)  # 纹理粗糙度
+    crack_width = db.Column(db.Float, default=0.0)  # 裂缝宽度(适用裂缝等)
+    avg_hue = db.Column(db.Float, default=0.0)  # 平均色调(适用锈蚀等)
+    disease_severity_score = db.Column(db.Float, default=0.0, nullable=False)  # 病害严重性得分
+    disease_grade = db.Column(db.Enum(DiseaseGrade), default=DiseaseGrade.MILD, nullable=False)  # 病害评估等级
+    disease_description = db.Column(db.Text, default='暂无描述')  # 病害评估描述
+    detection_duration = db.Column(db.Float, default=0.0)  # 检测分割耗时(ms)
+    avg_frame_detection_duration = db.Column(db.Float, default=0.0)  # 帧平均检测分割耗时(ms)
+    status = db.Column(db.Enum(TaskStatus), default=TaskStatus.PENDING, nullable=False)  # 任务状态
+    detection_at = db.Column(db.DateTime, default=lambda: datetime.now(ZoneInfo("Asia/Shanghai")))  # 检测时间
+    updated_at = db.Column(db.DateTime, default=lambda: datetime.now(ZoneInfo("Asia/Shanghai")),
+                           onupdate=lambda: datetime.now(ZoneInfo("Asia/Shanghai")))  # 最后更新时间
+    owner_id = db.Column(db.Integer, db.ForeignKey('user.user_id'), nullable=False)  # 所属用户 ID(外键)
+    model_id = db.Column(db.Integer, db.ForeignKey('model.model_id'), nullable=False)  # 使用模型 ID(外键)
+    media_id = db.Column(db.Integer, db.ForeignKey('media.media_id'), nullable=False)  # 使用媒体 ID(外键)
+
+    # 设置与 User 表的关系:一次检测分割只属于一个用户
+    owner = db.relationship('User', backref=db.backref('detections', lazy=True))
+    # 设置与 Model 表的关系:一次检测分割只使用一个模型
+    model = db.relationship('Model', backref=db.backref('detections', lazy=True))
+    # 设置与 Media 表的关系:一次检测分割只使用一份媒体文件
+    media = db.relationship('Media', backref=db.backref('detections', lazy=True))
+
+    def __repr__(self):
+        return (f"Detection(detection_id={self.detection_id}, "
+                f"result_path={self.result_path}, "
+                f"disease_count={self.disease_count}, "
+                f"disease_perimeter={self.disease_perimeter}, "
+                f"disease_area={self.disease_area}, "
+                f"shape_complexity={self.shape_complexity}, "
+                f"texture_roughness={self.texture_roughness}, "
+                f"crack_width={self.crack_width}, "
+                f"avg_hue={self.avg_hue}, "
+                f"disease_severity_score={self.disease_severity_score}, "
+                f"disease_grade={self.disease_grade.name}, "
+                f"disease_description={self.disease_description}, "
+                f"detection_duration={self.detection_duration}, "
+                f"avg_frame_detection_duration={self.avg_frame_detection_duration}, "
+                f"status={self.status.name}, "
+                f"detection_at={self.detection_at}, "
+                f"updated_at={self.updated_at}, "
+                f"owner_id={self.owner_id}, "
+                f"model_id={self.model_id}, "
+                f"media_id={self.media_id})")
+
+    def to_dict(self):
+        """
+        将 Detection 实例转化为字典。
+        """
+        return {
+            'detection_id': self.detection_id,
+            'result_path': self.result_path,
+            'disease_count': self.disease_count,
+            'disease_perimeter': self.disease_perimeter,
+            'disease_area': self.disease_area,
+            'shape_complexity': self.shape_complexity,
+            'texture_roughness': self.texture_roughness,
+            'crack_width': self.crack_width,
+            'avg_hue': self.avg_hue,
+            'disease_severity_score': self.disease_severity_score,
+            'disease_grade': self.disease_grade.name,
+            'disease_description': self.disease_description,
+            'detection_duration': self.detection_duration,
+            'avg_frame_detection_duration': self.avg_frame_detection_duration,
+            'status': self.status.name,
+            'detection_at': self.detection_at,
+            'updated_at': self.updated_at,
+            'owner_id': self.owner_id,
+            'model_id': self.model_id,
+            'media_id': self.media_id
+        }

+ 82 - 0
BridgeDiseaseBackend-main/app/models/media.py

@@ -0,0 +1,82 @@
+from datetime import datetime
+from zoneinfo import ZoneInfo
+
+from app.models import db
+
+
+class Media(db.Model):
+    """
+    媒体模型类,表示数据库中的 'media' 表。
+
+    该类存储与用户相关联的媒体的基本信息,如文件名、路径、文件类型、分辨率等。
+    媒体可以是图片或视频,并关联到用户。每个媒体只能属于一个用户,且可进行状态管理。
+
+    Attributes:
+        media_id (int): 媒体的唯一标识符(主键)。
+        media_name (str): 媒体名称,必须唯一,不能为空。
+        media_path (str): 媒体存储路径,必须唯一,不能为空。
+        description (str): 媒体的描述信息(可选)。
+        file_size (int): 媒体的大小(字节)。
+        file_type (str): 媒体的类型,通常为图片或视频。
+        resolution_width (int): 媒体的宽度(像素)。
+        resolution_height (int): 媒体的高度(像素)。
+        frame_count (int): 媒体的帧数(图片为 1,视频为实际帧数)。
+        upload_at (datetime): 媒体的上传时间,默认为当前时间。
+        updated_at (datetime): 媒体的最后更新时间,默认为当前时间,并在更新时自动更新。
+        owner_id (int): 所属用户的唯一标识符(外键)。
+
+    Relationships:
+        owner (User): 一个媒体只属于一个用户,表示该媒体的所有者。
+        detections (Detection): 一个媒体可以有多个检测分割记录(反向关系,表示该媒体参与的所有检测任务)。
+    """
+    __tablename__ = 'media'
+
+    media_id = db.Column(db.Integer, primary_key=True, autoincrement=True)  # 媒体 ID
+    media_name = db.Column(db.String(255), unique=True, nullable=False)  # 媒体名称
+    media_path = db.Column(db.String(255), unique=True, nullable=False)  # 媒体存储路径
+    description = db.Column(db.Text, default='暂无描述')  # 媒体描述
+    file_size = db.Column(db.Float, default=0.0)  # 文件大小(KB)
+    file_type = db.Column(db.String(50), nullable=False)  # 文件类型(图片或视频)
+    resolution_width = db.Column(db.Integer, default=0)  # 分辨率宽度
+    resolution_height = db.Column(db.Integer, default=0)  # 分辨率高度
+    frame_count = db.Column(db.Integer, default=1, nullable=False)  # 帧数(图片为 1,视频为实际帧数)
+    upload_at = db.Column(db.DateTime, default=lambda: datetime.now(ZoneInfo("Asia/Shanghai")))  # 上传时间
+    updated_at = db.Column(db.DateTime, default=lambda: datetime.now(ZoneInfo("Asia/Shanghai")),
+                           onupdate=lambda: datetime.now(ZoneInfo("Asia/Shanghai")))  # 最后更新时间
+    owner_id = db.Column(db.Integer, db.ForeignKey('user.user_id'), nullable=False)  # 所属用户 ID(外键)
+
+    # 设置与 User 表的关系:一份媒体文件只属于一个用户
+    owner = db.relationship('User', backref=db.backref('medias', lazy=True))
+
+    def __repr__(self):
+        return (f"Media(media_id={self.media_id}, "
+                f"media_name={self.media_name}, "
+                f"media_path={self.media_path}, "
+                f"description={self.description}, "
+                f"file_size={self.file_size}, "
+                f"file_type={self.file_type}, "
+                f"resolution_width={self.resolution_width}, "
+                f"resolution_height={self.resolution_height}, "
+                f"frame_count={self.frame_count}, "
+                f"upload_at={self.upload_at}, "
+                f"updated_at={self.updated_at}, "
+                f"owner_id={self.owner_id})")
+
+    def to_dict(self):
+        """
+        将 Media 实例转化为字典。
+        """
+        return {
+            'media_id': self.media_id,
+            'media_name': self.media_name,
+            'media_path': self.media_path,
+            'description': self.description,
+            'file_size': self.file_size,
+            'file_type': self.file_type,
+            'resolution_width': self.resolution_width,
+            'resolution_height': self.resolution_height,
+            'frame_count': self.frame_count,
+            'upload_at': self.upload_at,
+            'updated_at': self.updated_at,
+            'owner_id': self.owner_id
+        }

+ 119 - 0
BridgeDiseaseBackend-main/app/models/model.py

@@ -0,0 +1,119 @@
+from datetime import datetime
+from zoneinfo import ZoneInfo
+
+from app.models import db
+
+
+class Model(db.Model):
+    """
+    模型类,表示数据库中的 'model' 表。
+
+    该类存储不同训练模型的基本信息和性能评估指标。
+    包括模型名称、存储路径、训练数据增强方法、计算量、目标检测精度与召回率、分割掩膜性能等。
+    模型还包含与用户、检测分割记录的关系,支持对不同模型进行评估和管理。
+
+    Attributes:
+        model_id (int): 模型的唯一标识符(主键)。
+        model_name (str): 模型名称,必须唯一,不能为空。
+        model_path (str): 模型存储路径,必须唯一,不能为空。
+        disease_category (str): 模型所处理的病害类别。
+        augmentation (str): 使用的数据增强方式。
+        layers (int): 模型的层数。
+        parameters (int): 模型的参数量。
+        GFLOPs (float): 模型的计算量(Giga Floating-Point Operations)。
+        box_p (float): 目标检测框的精度。
+        box_r (float): 目标检测框的召回率。
+        box_mAP50 (float): 目标检测框在 IoU=0.5 时的 mAP(mean Average Precision)。
+        box_mAP50_95 (float): 目标检测框在 IoU 从 0.5 到 0.95 的 mAP。
+        mask_p (float): 分割掩膜的精度。
+        mask_r (float): 分割掩膜的召回率。
+        mask_mAP50 (float): 分割掩膜在 IoU=0.5 时的 mAP。
+        mask_mAP50_95 (float): 分割掩膜在 IoU 从 0.5 到 0.95 的 mAP。
+        f1_score (float): 模型的 F1 分数,综合精度和召回率的性能指标。
+        fitness_score (float): 模型的适应度分数,用于评估模型的整体性能。
+        created_at (datetime): 模型记录的创建时间,自动生成。
+        updated_at (datetime): 模型记录的最后更新时间,自动更新。
+        owner_id (int): 所属用户的 ID(外键)。
+
+    Relationships:
+        owner (User): 一个模型只属于一个用户,表示该模型的所有者。
+        detections (Detection): 一个模型可以有多个检测分割记录(反向关系,表示该模型应用于的检测任务)。
+    """
+    __tablename__ = 'model'  # 表名
+
+    model_id = db.Column(db.Integer, primary_key=True, autoincrement=True)  # 模型 ID
+    model_name = db.Column(db.String(255), unique=True, nullable=False)  # 模型名称
+    model_path = db.Column(db.String(255), unique=True, nullable=False)  # 存储路径
+    disease_category = db.Column(db.String(100), nullable=False)  # 病害类别
+    augmentation = db.Column(db.String(255), default='原图')  # 数据增强方式
+    layers = db.Column(db.Integer, nullable=False, default=0)  # 层数
+    parameters = db.Column(db.Integer, nullable=False, default=0)  # 参数量
+    GFLOPs = db.Column(db.Float, nullable=False, default=lambda: round(0.0, 1))  # 计算量
+    box_p = db.Column(db.Float, default=lambda: round(0.0, 3))  # 目标检测框的精度
+    box_r = db.Column(db.Float, default=lambda: round(0.0, 3))  # 目标检测框的召回率
+    box_mAP50 = db.Column(db.Float, default=lambda: round(0.0, 3))  # 目标检测框在 IoU=0.5 时的 mAP
+    box_mAP50_95 = db.Column(db.Float, default=lambda: round(0.0, 3))  # 目标检测框在 IoU 从 0.5 到 0.95 的 mAP
+    mask_p = db.Column(db.Float, default=lambda: round(0.0, 3))  # 分割掩膜的精度
+    mask_r = db.Column(db.Float, default=lambda: round(0.0, 3))  # 分割掩膜的召回率
+    mask_mAP50 = db.Column(db.Float, default=lambda: round(0.0, 3))  # 分割掩膜在 IoU=0.5 时的 mAP
+    mask_mAP50_95 = db.Column(db.Float, default=lambda: round(0.0, 3))  # 分割掩膜在 IoU 从 0.5 到 0.95 的 mAP
+    f1_score = db.Column(db.Float, default=lambda: round(0.0, 5), nullable=False)  # F1 分数
+    fitness_score = db.Column(db.Float, default=lambda: round(0.0, 5), nullable=False)  # 适应度分数
+    created_at = db.Column(db.DateTime, default=lambda: datetime.now(ZoneInfo("Asia/Shanghai")))  # 创建时间
+    updated_at = db.Column(db.DateTime, default=lambda: datetime.now(ZoneInfo("Asia/Shanghai")),
+                           onupdate=lambda: datetime.now(ZoneInfo("Asia/Shanghai")))  # 最后更新时间
+    owner_id = db.Column(db.Integer, db.ForeignKey('user.user_id'), nullable=False)  # 所属用户 ID(外键)
+
+    # 设置与 User 表的关系:一个模型只属于一个用户
+    owner = db.relationship('User', backref=db.backref('models', lazy=True))
+
+    def __repr__(self):
+        return (f"Model(model_id={self.model_id}, "
+                f"model_name={self.model_name}, "
+                f"model_path={self.model_path}, "
+                f"disease_category={self.disease_category}, "
+                f"augmentation={self.augmentation}, "
+                f"layers={self.layers}, "
+                f"parameters={self.parameters}, "
+                f"GFLOPs={self.GFLOPs}, "
+                f"box_p={self.box_p}, "
+                f"box_r={self.box_r}, "
+                f"box_mAP50={self.box_mAP50}, "
+                f"box_mAP50_95={self.box_mAP50_95}, "
+                f"mask_p={self.mask_p}, "
+                f"mask_r={self.mask_r}, "
+                f"mask_mAP50={self.mask_mAP50}, "
+                f"mask_mAP50_95={self.mask_mAP50_95}, "
+                f"f1_score={self.f1_score}, "
+                f"fitness_score={self.fitness_score}, "
+                f"created_at={self.created_at}, "
+                f"updated_at={self.updated_at}, "
+                f"owner_id={self.owner_id})")
+
+    def to_dict(self):
+        """
+        将 Model 实例转化为字典。
+        """
+        return {
+            'model_id': self.model_id,
+            'model_name': self.model_name,
+            'model_path': self.model_path,
+            'disease_category': self.disease_category,
+            'augmentation': self.augmentation,
+            'layers': self.layers,
+            'parameters': self.parameters,
+            'GFLOPs': self.GFLOPs,
+            'box_p': self.box_p,
+            'box_r': self.box_r,
+            'box_mAP50': self.box_mAP50,
+            'box_mAP50_95': self.box_mAP50_95,
+            'mask_p': self.mask_p,
+            'mask_r': self.mask_r,
+            'mask_mAP50': self.mask_mAP50,
+            'mask_mAP50_95': self.mask_mAP50_95,
+            'f1_score': self.f1_score,
+            'fitness_score': self.fitness_score,
+            'created_at': self.created_at,
+            'updated_at': self.updated_at,
+            'owner_id': self.owner_id
+        }

+ 72 - 0
BridgeDiseaseBackend-main/app/models/operation.py

@@ -0,0 +1,72 @@
+from datetime import datetime
+from zoneinfo import ZoneInfo
+
+from app.constants import OperationType, OperationStatus
+from app.models import db
+
+
+class Operation(db.Model):
+    """
+    操作模型类,表示数据库中的 'operation' 表。
+
+    包括操作类型、描述、耗时、状态、用户信息等,用于系统的审计、监控和分析。
+
+    Attributes:
+        operation_id (int): 操作记录 ID,自动增长的主键。
+        operation_type (str): 操作类型,使用枚举定义,如 'authenticate', 'create', 'read', 'update', 'delete' 等。
+        description (str): 对操作的详细描述,帮助理解操作的背景和过程。
+        duration (float): 操作耗时,单位为秒(s)。
+        failure_message (str): 当操作失败时,记录失败信息,帮助诊断问题。
+        ip_address (str): 用户进行操作时的 IP 地址,记录操作来源的网络地址。
+        device_info (str): 用户操作时的设备信息,通常为浏览器类型、操作系统等。
+        status (str): 操作状态,表示操作是否成功,使用枚举定义,'success' 或 'failure'。
+        created_at (datetime): 操作记录的时间戳,自动记录每条操作的创建时间。
+        owner_id (int): 操作执行者的用户 ID,外键关联用户表,标识执行该操作的用户。
+
+    Relationships:
+        owner (User): 每条操作日志记录一个用户,表示该操作由哪个用户执行。
+    """
+    __tablename__ = 'operation'
+
+    operation_id = db.Column(db.Integer, primary_key=True, autoincrement=True)  # 操作记录 ID
+    operation_type = db.Column(db.Enum(OperationType), default=OperationType.READ, nullable=False)  # 操作类型
+    description = db.Column(db.Text, nullable=False)  # 操作描述
+    duration = db.Column(db.Float, default=0.0, nullable=False)  # 操作耗时(s)
+    failure_message = db.Column(db.Text, default='无')  # 失败信息
+    ip_address = db.Column(db.String(45), nullable=False)  # IP 地址
+    device_info = db.Column(db.String(255), nullable=False)  # 设备信息
+    status = db.Column(db.Enum(OperationStatus), default=OperationStatus.SUCCESS, nullable=False)  # 操作状态
+    created_at = db.Column(db.DateTime, default=lambda: datetime.now(ZoneInfo("Asia/Shanghai")))  # 操作时间
+    owner_id = db.Column(db.Integer, db.ForeignKey('user.user_id'))  # 所属用户 ID(外键)
+
+    # 设置与 User 的关系:一个操作记录只属于一个用户
+    owner = db.relationship('User', backref=db.backref('operations', lazy=True))
+
+    def __repr__(self):
+        return (f"Operation(operation_id: {self.operation_id}, "
+                f"operation_type: {self.operation_type.name}, "
+                f"description: {self.description}, "
+                f"duration: {self.duration}, "
+                f"failure_message: {self.failure_message}, "
+                f"ip_address: {self.ip_address}, "
+                f"device_info: {self.device_info}, "
+                f"status: {self.status.name}, "
+                f"created_at: {self.created_at}, "
+                f"owner_id: {self.owner_id})")
+
+    def to_dict(self):
+        """
+        将 Operation 实例转化为字典。
+        """
+        return {
+            'operation_id': self.operation_id,
+            'operation_type': self.operation_type.name,
+            'description': self.description,
+            'duration': self.duration,
+            'failure_message': self.failure_message,
+            'ip_address': self.ip_address,
+            'device_info': self.device_info,
+            'status': self.status.name,
+            'created_at': self.created_at,
+            'owner_id': self.owner_id
+        }

+ 89 - 0
BridgeDiseaseBackend-main/app/models/user.py

@@ -0,0 +1,89 @@
+from datetime import datetime
+from zoneinfo import ZoneInfo
+
+from app.constants import UserRole, UserStatus
+from app.models import db
+
+
+class User(db.Model):
+    """
+    用户模型类,表示数据库中的 'user' 表。
+
+    该类存储用户的基本信息,包括用户名、邮箱、密码(加密存储)、角色、状态等。
+    用户可以是不同的角色(管理员、开发者、普通用户),并且支持多个外键关联,
+    如模型、媒体文件、检测分割记录和系统操作等。
+
+    Attributes:
+        user_id (int): 用户的唯一标识符(主键)。自动递增。
+        username (str): 用户名,必须唯一,不能为空。
+        email (str): 用户的邮箱,必须唯一,不能为空。
+        password (str): 用户的密码,经过加密存储,不能为空。
+        first_name (str): 用户的名字(可选)。
+        last_name (str): 用户的姓氏(可选)。
+        role (str): 用户的角色,使用枚举类型('admin', 'developer', 'user'),默认是 'user'。
+        avatar_path (str): 用户头像的存储路径(可选)。
+        phone (str): 用户的手机号,必须唯一(可选)。
+        last_login (datetime): 用户最后一次登录的时间(可选)。
+        status (str): 用户的状态,使用枚举类型('active', 'inactive', 'banned'),默认是 'active'。
+        created_at (datetime): 用户记录的创建时间,自动生成。
+        updated_at (datetime): 用户记录的最后更新时间,自动更新。
+        deleted_at (datetime): 用户记录的注销时间(可选)。
+
+    Relationships:
+        models (Model): 一个用户可以拥有多个模型(反向关系,表示用户的模型)。
+        medias (Media): 一个用户可以拥有多个媒体文件(反向关系,表示用户的媒体文件)。
+        detections (Detection): 一个用户可以有多个检测分割记录(反向关系,表示用户的检测任务)。
+        operations (Operation): 一个用户可以执行多个操作(反向关系,表示用户的操作记录)。
+    """
+    __tablename__ = 'user'  # 表名
+
+    user_id = db.Column(db.Integer, primary_key=True, autoincrement=True)  # 用户 ID
+    username = db.Column(db.String(255), unique=True, nullable=False)  # 用户名,唯一
+    email = db.Column(db.String(255), unique=True, nullable=False)  # 用户邮箱,唯一
+    password = db.Column(db.String(255), nullable=False)  # 密码(加密)
+    first_name = db.Column(db.String(100), default="名字")  # 名字
+    last_name = db.Column(db.String(100), default="姓氏")  # 姓氏
+    role = db.Column(db.Enum(UserRole), default=UserRole.USER, nullable=False)  # 用户角色
+    avatar_path = db.Column(db.String(255))  # 头像路径
+    phone = db.Column(db.String(20), unique=True)  # 手机号
+    last_login = db.Column(db.DateTime)  # 最后登录时间
+    status = db.Column(db.Enum(UserStatus), default=UserStatus.INACTIVE, nullable=False)  # 用户状态
+    created_at = db.Column(db.DateTime, default=lambda: datetime.now(ZoneInfo("Asia/Shanghai")))  # 用户创建时间
+    updated_at = db.Column(db.DateTime, default=lambda: datetime.now(ZoneInfo("Asia/Shanghai")),
+                           onupdate=lambda: datetime.now(ZoneInfo("Asia/Shanghai")))  # 最后更新时间
+    deleted_at = db.Column(db.DateTime, nullable=True)  # 用户注销时间
+
+    def __repr__(self):
+        return (f"User(user_id={self.user_id}, "
+                f"username='{self.username}', "
+                f"email='{self.email}', "
+                f"first_name='{self.first_name}', "
+                f"last_name='{self.last_name}', "
+                f"role='{self.role.name}', "
+                f"avatar_path='{self.avatar_path}', "
+                f"phone='{self.phone}', "
+                f"last_login={self.last_login}, "
+                f"status='{self.status.name}', "
+                f"created_at={self.created_at}, "
+                f"updated_at={self.updated_at}, "
+                f"deleted_at={self.deleted_at})")
+
+    def to_dict(self):
+        """
+        将 User 实例转化为字典。
+        """
+        return {
+            'user_id': self.user_id,
+            'username': self.username,
+            'email': self.email,
+            'first_name': self.first_name,
+            'last_name': self.last_name,
+            'role': self.role.name,
+            'avatar_path': self.avatar_path,
+            'phone': self.phone,
+            'last_login': self.last_login,
+            'status': self.status.name,
+            'created_at': self.created_at,
+            'updated_at': self.updated_at,
+            'deleted_at': self.deleted_at
+        }

+ 37 - 0
BridgeDiseaseBackend-main/app/routes/__init__.py

@@ -0,0 +1,37 @@
+from flask import Blueprint
+
+# 创建蓝图
+user_routes = Blueprint('user', __name__, url_prefix='/user')
+model_routes = Blueprint('model', __name__, url_prefix='/model')
+media_routes = Blueprint('media', __name__, url_prefix='/media')
+detection_routes = Blueprint('detection', __name__, url_prefix='/detection')
+operation_routes = Blueprint('operation', __name__, url_prefix='/operation')
+file_routes = Blueprint('file', __name__, url_prefix='/file')
+
+
+def register_routes(app):
+    """
+    将所有的蓝图注册到 Flask 应用中。
+
+    该函数用于将定义的蓝图(例如 user_routes)与 Flask 应用绑定,
+    使得在应用中能够通过指定的 URL 前缀访问相关视图函数。
+
+    :param app: Flask 应用实例
+    :type app: Flask
+    :return: None
+    """
+    # 注册蓝图
+    app.register_blueprint(user_routes)
+    app.register_blueprint(model_routes)
+    app.register_blueprint(media_routes)
+    app.register_blueprint(detection_routes)
+    app.register_blueprint(operation_routes)
+    app.register_blueprint(file_routes)
+
+
+from .user_route import *
+from .model_route import *
+from .media_route import *
+from .detection_route import *
+from .operation_route import *
+from .file_route import *

+ 536 - 0
BridgeDiseaseBackend-main/app/routes/detection_route.py

@@ -0,0 +1,536 @@
+import base64
+import json
+import os
+import time
+import traceback
+from datetime import datetime
+from io import BytesIO
+from pathlib import Path
+from zoneinfo import ZoneInfo
+
+import numpy as np
+from PIL import Image
+from flask import jsonify, request, current_app, Response, stream_with_context
+from flask_jwt_extended import jwt_required, get_jwt_identity
+from ultralytics import YOLO
+
+from app import Config
+from app.constants import TaskStatus, OperationType, UserRole
+from app.decorators import login_required
+from app.models import Detection, Media, Model, Operation, User, db
+from app.routes import detection_routes
+from app.utils import handle_operation_success, handle_operation_failure, compute_count, compute_perimeter, \
+    compute_area, compute_shape_complexity, compute_texture_roughness, compute_crack_width, compute_avg_hue, \
+    evaluate_disease_severity, get_pagination_params, adjust_page_if_needed, unify_result_media_format, delete_file, \
+    user_rate_limit, DateTimeEncoder
+
+# 获取文件夹配置,并确保目录存在
+MODELS_FOLDER = Config.MODELS_FOLDER
+MEDIAS_FOLDER = Config.MEDIAS_FOLDER
+RESULTS_FOLDER = Config.RESULTS_FOLDER
+os.makedirs(MODELS_FOLDER, exist_ok=True)
+os.makedirs(MEDIAS_FOLDER, exist_ok=True)
+os.makedirs(RESULTS_FOLDER, exist_ok=True)
+
+
+def _resolve_static_path(raw_path, base_folder):
+    """
+    兼容数据库中不同格式的路径:
+    - static\\models\\xxx.pt
+    - static/models/xxx.pt
+    - models/xxx.pt
+    - 仅文件名 xxx.pt
+    """
+    if not raw_path:
+        return None
+
+    normalized = str(raw_path).replace('\\', '/').lstrip('/')
+    if normalized.startswith('static/'):
+        return Path(current_app.root_path) / normalized
+    return Path(base_folder) / os.path.basename(normalized)
+
+
+@detection_routes.route('/detection_segmentation', methods=['POST'])
+@jwt_required()
+@login_required
+def detection_segmentation():
+    start_time = time.time()  # 记录操作开始时间
+
+    # 获取请求中的 JSON 参数
+    data = request.get_json()
+    model_id = data.get('model_id')
+    media_id = data.get('media_id')
+
+    # 创建一个新的操作记录
+    new_operation = Operation(
+        operation_type=OperationType.EXECUTE,
+        description="执行病害检测分割",
+        ip_address=request.remote_addr,
+        device_info=request.user_agent.string,
+    )
+
+    # 获取当前用户身份(使用 access token)
+    current_user_id = get_jwt_identity()
+    current_user = User.query.get(current_user_id)
+
+    # 获取模型和媒体
+    model = Model.query.get(model_id)
+    media = Media.query.get(media_id)
+
+    # 校验字段
+    validation_checks = [
+        (not model_id or not media_id, "【检测分割失败】媒体/模型 ID 为空", 400),
+        (not model, f"【检测分割失败】模型 ID={model_id} 不存在", 404),
+        (not media, f"【检测分割失败】媒体 ID={media_id} 不存在", 404),
+    ]
+    for condition, message, code in validation_checks:
+        if condition:
+            new_operation = handle_operation_failure(new_operation, start_time, message, current_user_id)
+            current_app.logger.warning(message)
+            return jsonify({
+                'operation': new_operation.to_dict(),
+            }), code
+
+    # 查找是否已存在相同 owner_id 和 media_id 的检测分割记录
+    existing_detection = Detection.query.filter_by(owner_id=current_user_id, media_id=media_id).first()
+    if existing_detection:
+        # 如果存在则更新检测时间,后续会更新其他字段
+        new_detection = existing_detection
+        new_detection.model_id = model_id
+        new_detection.detection_at = datetime.now(ZoneInfo("Asia/Shanghai"))
+        current_app.logger.info("【检测分割】找到已有记录,进行更新")
+    else:
+        # 如果不存在则创建新的检测分割记录
+        new_detection = Detection(
+            detection_at=datetime.now(ZoneInfo("Asia/Shanghai")),
+            owner_id=current_user_id,
+            model_id=model_id,
+            media_id=media_id,
+        )
+        db.session.add(new_detection)
+    db.session.commit()
+
+    try:
+        # 更新任务状态为进行中
+        new_detection.status = TaskStatus.IN_PROGRESS
+        db.session.commit()
+
+        # 导入模型/媒体并执行预测
+        model_path = _resolve_static_path(model.model_path, MODELS_FOLDER)
+        source_path = _resolve_static_path(media.media_path, MEDIAS_FOLDER)
+        if not model_path or not os.path.isfile(model_path):
+            failure_message = f"【检测分割失败】模型文件不存在:{model.model_path}"
+            new_operation = handle_operation_failure(new_operation, start_time, failure_message, current_user_id)
+            current_app.logger.warning(failure_message)
+            return jsonify({'operation': new_operation.to_dict()}), 400
+        if not source_path or not os.path.isfile(source_path):
+            failure_message = f"【检测分割失败】媒体文件不存在:{media.media_path}"
+            new_operation = handle_operation_failure(new_operation, start_time, failure_message, current_user_id)
+            current_app.logger.warning(failure_message)
+            return jsonify({'operation': new_operation.to_dict()}), 400
+        yolo_model = YOLO(model_path)
+        results = yolo_model.predict(
+            source=source_path,
+            imgsz=1024,
+            half=True,
+            retina_masks=True,
+            save=True,
+            project=RESULTS_FOLDER,
+            name=current_user.username,
+            stream=True,
+            exist_ok=True,  # 每次都保存在同一文件夹
+        )
+
+        def generate():
+            # 初始化媒体帧数
+            frame_count = media.frame_count
+            disease_frame_count = frame_count
+
+            # 初始化病害指标
+            total_disease_count = 0
+            total_disease_perimeter = 0.0
+            total_disease_area = 0.0
+            total_shape_complexity = 0.0
+            total_texture_roughness = 0.0
+            total_crack_width = 0.0
+            total_avg_hue = 0.0
+
+            # 初始化检测分割耗时
+            total_detection_duration = 0.0
+
+            # 初始消息
+            init_msg = {
+                'type': 'START',
+                'existing_detection': bool(existing_detection),
+            }
+            yield json.dumps(init_msg, cls=DateTimeEncoder) + '\n'
+
+            for idx, result in enumerate(results):
+                # 计算帧检测时长
+                frame_detection_duration = sum(result.speed.values())
+                total_detection_duration += frame_detection_duration
+
+                # 统计帧级指标
+                if result.masks is None:
+                    disease_frame_count = max(disease_frame_count - 1, 0)
+                else:
+                    masks = result.masks
+                    masks_data = masks.data.cpu().numpy()  # 确保在 CPU 上
+                    combined_masks = np.any(masks_data, axis=0).astype(np.uint8)  # 确保重复区域只计算一次
+
+                    frame_disease_count = compute_count(masks)  # 病害数量
+                    frame_disease_perimeter = compute_perimeter(combined_masks)  # 病害周长(像素)
+                    frame_disease_area = compute_area(combined_masks)  # 病害面积(像素)
+                    frame_shape_complexity = compute_shape_complexity(frame_disease_perimeter,
+                                                                      frame_disease_area)  # 形状复杂度
+                    frame_texture_roughness = compute_texture_roughness(combined_masks)  # 纹理粗糙度
+                    frame_crack_width = compute_crack_width(
+                        combined_masks) if "裂缝" in model.disease_category else 0.0  # 裂缝宽度
+                    frame_avg_hue = compute_avg_hue(combined_masks,
+                                                    result.orig_img) if "锈蚀" in model.disease_category else 0.0  # 平均色调
+
+                    total_disease_count += frame_disease_count
+                    total_disease_perimeter += frame_disease_perimeter
+                    total_disease_area += frame_disease_area
+                    total_shape_complexity += frame_shape_complexity
+                    total_texture_roughness += frame_texture_roughness
+                    total_crack_width += frame_crack_width
+                    total_avg_hue += frame_avg_hue
+
+                # 绘制并编码图像
+                annotated_img = result.plot()  # ndarray
+                annotated_pil = Image.fromarray(annotated_img)  # 转为 PIL.Image
+                buf = BytesIO()
+                annotated_pil.save(buf, format='JPEG')  # 正确保存
+                b64 = base64.b64encode(buf.getvalue()).decode('ascii')  # 编码为 base64 字符串
+
+                frame_msg = {
+                    'type': 'FRAME',
+                    'frame_index': idx,
+                    'frame_image': b64,
+                    'frame_detection_duration': frame_detection_duration,
+                }
+                yield json.dumps(frame_msg, cls=DateTimeEncoder) + '\n'
+
+            current_app.logger.info(
+                f"【检测分割】total_disease_count: {total_disease_count}, disease_frame_count: {disease_frame_count}")
+
+            # 计算平均病害指标
+            average_disease_count = total_disease_count // disease_frame_count if disease_frame_count != 0 else 0
+            average_disease_perimeter = total_disease_perimeter / disease_frame_count if disease_frame_count != 0 else 0.0
+            average_disease_area = total_disease_area / disease_frame_count if disease_frame_count != 0 else 0.0
+            average_shape_complexity = total_shape_complexity / disease_frame_count if disease_frame_count != 0 else 0.0
+            average_texture_roughness = total_texture_roughness / disease_frame_count if disease_frame_count != 0 else 0.0
+            average_crack_width = total_crack_width / disease_frame_count if disease_frame_count != 0 else 0.0
+            average_avg_hue = total_avg_hue / disease_frame_count if disease_frame_count != 0 else 0.0
+
+            # 计算帧平均检测分割耗时
+            avg_frame_detection_duration = total_detection_duration / frame_count if frame_count != 0 else 0.0
+
+            # 根据检测结果计算病害严重性得分、病害等级、病害描述
+            disease_severity_score, disease_grade, disease_description = evaluate_disease_severity(
+                average_disease_count,
+                average_disease_perimeter,
+                average_disease_area,
+                average_shape_complexity,
+                average_texture_roughness,
+                average_crack_width,
+                average_avg_hue, media,
+            )
+
+            # 检测分割结果路径
+            result_path = unify_result_media_format(media, current_user)
+
+            # 更新检测分割信息
+            new_detection.status = TaskStatus.COMPLETED
+            new_detection.result_path = result_path
+            new_detection.disease_count = average_disease_count
+            new_detection.disease_perimeter = average_disease_perimeter
+            new_detection.disease_area = average_disease_area
+            new_detection.shape_complexity = average_shape_complexity
+            new_detection.texture_roughness = average_texture_roughness
+            new_detection.crack_width = average_crack_width
+            new_detection.avg_hue = average_avg_hue
+            new_detection.disease_severity_score = disease_severity_score
+            new_detection.disease_grade = disease_grade
+            new_detection.disease_description = disease_description
+            new_detection.detection_duration = total_detection_duration
+            new_detection.avg_frame_detection_duration = avg_frame_detection_duration
+            db.session.commit()
+
+            # 记录操作
+            success_op = handle_operation_success(new_operation, start_time, current_user_id)
+
+            end_msg = {
+                'type': 'END',
+                'existing_detection': bool(existing_detection),
+                'new_detection': new_detection.to_dict(),
+                'operation': success_op.to_dict(),
+            }
+
+            current_app.logger.info(f"【检测分割成功】new_detection: {success_op}")
+            yield json.dumps(end_msg, cls=DateTimeEncoder) + '\n'
+
+        return Response(stream_with_context(generate()), mimetype='application/json')
+    except Exception as error:
+        # 发生错误,更新任务状态为失败
+        new_detection.status = TaskStatus.FAILED
+        db.session.commit()
+
+        # 记录操作失败
+        failure_message = f"【检测分割错误】服务器内部发生错误,请联系管理员"
+        new_operation = handle_operation_failure(new_operation, start_time, failure_message, current_user_id)
+
+        # 获取详细的堆栈追踪信息
+        stack_trace = traceback.format_exc()
+
+        # 获取请求的相关信息
+        request_method = request.method
+        request_url = request.url
+        request_data = request.get_data(as_text=True)
+
+        # 记录日志,帮助排查问题
+        current_app.logger.error(f"Error: {str(error)}\nStack Trace: {stack_trace}\n"
+                                 f"Request Method: {request_method}\n"
+                                 f"Request URL: {request_url}\n"
+                                 f"Request Data: {request_data}")
+        return jsonify({
+            'operation': new_operation.to_dict(),
+        }), 500
+
+
+@detection_routes.route('/detail/<int:detection_id>', methods=['GET'])
+@jwt_required()
+@login_required
+def detail(detection_id):
+    # 获取当前用户身份(使用 access token)
+    current_user_id = get_jwt_identity()
+    current_user = User.query.get(current_user_id)
+
+    # 获取指定检测分割记录
+    detection = Detection.query.get(detection_id)
+
+    # 校验字段
+    validation_checks = [
+        (not detection, f"【获取检测分割 ID={detection_id} 详情失败】该检测分割记录不存在", 404),
+        (detection and detection.owner_id != current_user_id and current_user.role != UserRole.ADMIN
+         and current_user.role != UserRole.DEVELOPER,
+         f"【获取检测分割 ID={detection_id} 详情失败】您非管理员/开发人员,无法查看他人的检测分割记录详情", 403),
+    ]
+    for condition, message, code in validation_checks:
+        if condition:
+            current_app.logger.warning(message + f', operator: {current_user}')
+            return jsonify({
+                'failure_message': message,
+            }), code
+
+    return jsonify({
+        'detection': detection.to_dict(),
+    }), 200
+
+
+@detection_routes.route('/delete/<int:detection_id>', methods=['GET'])
+@jwt_required()
+@login_required
+def delete_detection(detection_id):
+    start_time = time.time()  # 记录操作开始时间
+
+    # 创建一个新的操作记录
+    new_operation = Operation(
+        operation_type=OperationType.DELETE,
+        description=f"删除检测分割 ID={detection_id} 记录",
+        ip_address=request.remote_addr,
+        device_info=request.user_agent.string,
+    )
+
+    # 获取当前用户身份(使用 access token)
+    current_user_id = get_jwt_identity()
+    current_user = User.query.get(current_user_id)
+
+    # 获取指定媒体
+    deleted_detection = Detection.query.get(detection_id)
+
+    # 校验字段
+    validation_checks = [
+        (not deleted_detection, f"【删除检测分割 ID={detection_id} 记录失败】该检测分割记录不存在", 404),
+        (deleted_detection and deleted_detection.owner_id != current_user_id and current_user.role != UserRole.ADMIN
+         and current_user.role != UserRole.DEVELOPER,
+         f"【删除检测分割 ID={detection_id} 记录失败】您非管理员/开发人员,无法删除他人的检测分割记录", 403),
+    ]
+    for condition, message, code in validation_checks:
+        if condition:
+            new_operation = handle_operation_failure(new_operation, start_time, message, current_user_id)
+            current_app.logger.warning(message + f', operator: {current_user}')
+            return jsonify({
+                'operation': new_operation.to_dict(),
+            }), code
+
+    # 删除实际文件
+    file_abs_path = _resolve_static_path(deleted_detection.result_path, RESULTS_FOLDER)
+    delete_file(file_abs_path)
+
+    # 删除数据库记录
+    db.session.delete(deleted_detection)
+    db.session.commit()
+
+    # 记录操作
+    new_operation = handle_operation_success(new_operation, start_time, current_user_id)
+
+    current_app.logger.info(
+        f"【删除检测分割 ID={detection_id} 记录成功】deleted_detection: {deleted_detection}, operator: {current_user}")
+    return jsonify({
+        'operation': new_operation.to_dict(),
+        'deleted_detection': deleted_detection.to_dict(),
+    }), 200
+
+
+@detection_routes.route('/detections/<int:user_id>', methods=['GET'])
+@jwt_required()
+@login_required
+def user_detections(user_id):
+    # 获取分页参数(从请求中获取,默认为第 1 页,每页 5 条记录)
+    default_page = request.args.get('page', 1, type=int)
+    default_per_page = request.args.get('per_page', 5, type=int)
+    page, per_page = get_pagination_params(default_page, default_per_page)
+
+    # 获取当前用户身份(使用 access token)
+    current_user_id = get_jwt_identity()
+    current_user = User.query.get(current_user_id)
+
+    # 获取指定用户身份
+    user = User.query.get(user_id)
+
+    # 校验字段
+    validation_checks = [
+        (not user, f"【获取用户 ID={user_id} 检测分割记录失败】该用户不存在", 404),
+        (current_user_id != user_id and current_user.role != UserRole.ADMIN and current_user.role != UserRole.DEVELOPER,
+         f"【获取用户 ID={user_id} 检测分割记录失败】您非管理员/开发人员,无法查看他人的检测分割记录", 403),
+    ]
+    for condition, message, code in validation_checks:
+        if condition:
+            current_app.logger.warning(message)
+            return jsonify({
+                'failure_message': message,
+            }), code
+
+    # 获取指定用户检测分割记录
+    query = (
+        Detection.query
+        .filter(Detection.owner_id == user_id)
+        .join(Model, Detection.model_id == Model.model_id)
+        .join(Media, Detection.media_id == Media.media_id)
+        .join(User, Detection.owner_id == User.user_id)
+        .add_columns(
+            Model.model_name.label('model_name'),
+            Media.media_name.label('media_name'),
+            Media.file_type.label('media_type'),
+            User.username.label('owner_username'),
+        )
+    )
+    page, detections_total, pages = adjust_page_if_needed(query, page, per_page)
+    paginated = query.paginate(page=page, per_page=per_page, error_out=False)
+    detections = []
+    for detection, model_name, media_name, media_type, owner_username in paginated.items:
+        detection_dict = detection.to_dict()
+        detection_dict.update({
+            'model_name': model_name,
+            'media_name': media_name,
+            'media_type': media_type,
+            'owner_username': owner_username,
+        })
+        detections.append(detection_dict)
+
+    current_app.logger.info(
+        f"【获取用户 ID={user_id} 检测分割记录成功】total: {detections_total}, per_page: {per_page}, page: {page}, pages: {pages}, detections: {detections}, operator: {current_user}")
+    return jsonify({
+        'detections': detections,
+        'total': detections_total,
+        'per_page': per_page,
+        'page': page,
+        'pages': pages,
+    }), 200
+
+
+@detection_routes.route('/detections/all', methods=['GET'])
+@jwt_required()
+@login_required
+def all_detections():
+    # 获取分页参数(从请求中获取,默认为第 1 页,每页 5 条记录)
+    default_page = request.args.get('page', 1, type=int)
+    default_per_page = request.args.get('per_page', 5, type=int)
+    page, per_page = get_pagination_params(default_page, default_per_page)
+
+    # 获取当前用户身份(使用 access token)
+    current_user_id = get_jwt_identity()
+    current_user = User.query.get(current_user_id)
+
+    if current_user.role != UserRole.ADMIN and current_user.role != UserRole.DEVELOPER:
+        failure_message = f"【获取所有检测分割记录失败】您非管理员/开发人员,权限不足"
+        current_app.logger.warning(failure_message)
+        return jsonify({
+            'failure_message': failure_message,
+        }), 403
+
+    # 获取所有媒体
+    query = (
+        Detection.query
+        .join(Model, Detection.model_id == Model.model_id)
+        .join(Media, Detection.media_id == Media.media_id)
+        .join(User, Detection.owner_id == User.user_id)
+        .add_columns(
+            Model.model_name.label('model_name'),
+            Media.media_name.label('media_name'),
+            Media.file_type.label('media_type'),
+            User.username.label('owner_username'),
+        )
+        .order_by(Detection.detection_id.asc())
+    )
+    page, detections_total, pages = adjust_page_if_needed(query, page, per_page)
+    paginated = query.paginate(page=page, per_page=per_page, error_out=False)
+    detections = []
+    for detection, model_name, media_name, media_type, owner_username in paginated.items:
+        detection_dict = detection.to_dict()
+        detection_dict.update({
+            'model_name': model_name,
+            'media_name': media_name,
+            'media_type': media_type,
+            'owner_username': owner_username,
+        })
+        detections.append(detection_dict)
+
+    current_app.logger.info(
+        f"【获取所有检测分割记录成功】total: {detections_total}, per_page: {per_page}, page: {page}, pages: {pages}, detections: {detections}, operator: {current_user}")
+    return jsonify({
+        'detections': detections,
+        'total': detections_total,
+        'per_page': per_page,
+        'page': page,
+        'pages': pages,
+    }), 200
+
+
+@detection_routes.route('/statistics', methods=['GET'])
+@jwt_required()
+@login_required
+def statistics():
+    # 查询检测记录总数
+    total_detections = Detection.query.count()
+
+    # 查询不同状态的检测记录数量
+    pending_detections = Detection.query.filter(Detection.status == TaskStatus.PENDING).count()
+    in_progress_detections = Detection.query.filter(Detection.status == TaskStatus.IN_PROGRESS).count()
+    completed_detections = Detection.query.filter(Detection.status == TaskStatus.COMPLETED).count()
+    failed_detections = Detection.query.filter(Detection.status == TaskStatus.FAILED).count()
+
+    # 构建统计数据
+    detections_statistics = {
+        'total': total_detections,
+        'pending': pending_detections,
+        'in_progress': in_progress_detections,
+        'completed': completed_detections,
+        'failed': failed_detections,
+    }
+
+    return jsonify({
+        "detections_statistics": detections_statistics,
+    }), 200

+ 36 - 0
BridgeDiseaseBackend-main/app/routes/file_route.py

@@ -0,0 +1,36 @@
+import os
+
+from flask import abort, send_from_directory, current_app, jsonify
+
+from app.routes import file_routes
+
+STATIC_FOLDER = os.path.join(os.getcwd(), 'app', 'static')
+
+
+@file_routes.route('/static/<path:filepath>')
+def serve_static(filepath):
+    """
+    自定义路由处理 static 路径下的所有静态文件请求(绕过 Flask 默认 static 机制)
+    """
+
+    # 安全性检查
+    if '..' in filepath or filepath.startswith('/'):
+        abort(400)
+
+    abs_path = os.path.join(STATIC_FOLDER, filepath)
+    if not os.path.isfile(abs_path):
+        abort(404)
+
+    current_app.logger.info(f'Serving static file: {abs_path}')
+    return send_from_directory(STATIC_FOLDER, filepath)
+
+
+@file_routes.route('/static')
+def static():
+    """
+    静态文件路由
+    """
+    current_app.logger.info(f'Serving static file')
+    return jsonify({
+        'operation': 'Serving static file',
+    }), 201

+ 399 - 0
BridgeDiseaseBackend-main/app/routes/media_route.py

@@ -0,0 +1,399 @@
+import os
+import time
+
+from flask import request, jsonify, current_app
+from flask_jwt_extended import jwt_required, get_jwt_identity
+from werkzeug.utils import secure_filename
+
+from app.constants import OperationType, UserRole
+from app.decorators import login_required
+from app.models import Operation, Media, db, User, Detection
+from app.routes import media_routes
+from app.utils import handle_operation_failure, allowed_image_file, handle_file_upload, handle_operation_success, \
+    adjust_page_if_needed, get_pagination_params, allowed_video_file, get_media_info, delete_file, user_rate_limit
+
+
+def _current_user_context():
+    """兼容 JWT identity 为字符串的场景,统一转换为整型主键。"""
+    raw_identity = get_jwt_identity()
+    try:
+        user_id = int(raw_identity)
+    except (TypeError, ValueError):
+        user_id = None
+    user = User.query.get(user_id) if user_id is not None else None
+    return user_id, user
+
+
+def _resolve_static_path(raw_path):
+    if not raw_path:
+        return None
+    normalized = str(raw_path).replace('\\', '/').lstrip('/')
+    if not normalized.startswith('static/'):
+        normalized = f'static/{normalized}'
+    return os.path.join(current_app.root_path, normalized)
+
+
+@media_routes.route('/upload', methods=['POST'])
+@jwt_required()
+@login_required
+def upload():
+    start_time = time.time()  # 记录操作开始时间
+
+    # 获取请求中的表单数据
+    media_file = request.files.get('media_file')
+    description = request.form.get('description', '暂无描述')
+
+    # 创建一个新的操作记录
+    new_operation = Operation(
+        operation_type=OperationType.CREATE,
+        description="上传媒体",
+        ip_address=request.remote_addr,
+        device_info=request.user_agent.string,
+    )
+
+    # 获取当前用户身份(使用 access token)
+    current_user_id, current_user = _current_user_context()
+
+    # 先获取文件名
+    file_name = secure_filename(media_file.filename) if media_file else None
+
+    # 检查文件名是否已存在
+    existing_media = Media.query.filter_by(media_name=file_name).first() if file_name else None
+
+    # 兼容“数据库有记录但物理文件丢失”的场景:自动清理殭尸记录,允许重新上传同名文件
+    if existing_media:
+        existing_abs_path = _resolve_static_path(existing_media.media_path)
+        if not os.path.isfile(existing_abs_path):
+            current_app.logger.warning(
+                f"【上传媒体】检测到殭尸记录,自动清理:media_id={existing_media.media_id}, media_name={existing_media.media_name}"
+            )
+            db.session.delete(existing_media)
+            db.session.commit()
+            existing_media = None
+
+    # 校验字段
+    validation_checks = [
+        (media_file and not (allowed_image_file(media_file) or allowed_video_file(media_file)),
+         "【上传媒体失败】媒体文件不合规", 400),
+        (media_file and existing_media, f"【上传媒体失败】媒体 {file_name} 已存在,请重新上传", 400),
+    ]
+    for condition, message, code in validation_checks:
+        if condition:
+            new_operation = handle_operation_failure(new_operation, start_time, message, current_user_id)
+            current_app.logger.warning(message + f', operator: {current_user}')
+            return jsonify({
+                'operation': new_operation.to_dict(),
+            }), code
+
+    # 保存文件到指定目录(返回相对路径)
+    file_path = handle_file_upload(media_file, 'medias')
+
+    # 获取文件类型(文件后缀)
+    file_type = file_name.rsplit('.', 1)[1].lower()
+
+    # 获取文件绝对路径
+    abs_path = os.path.join(current_app.root_path, file_path)
+
+    # 获取媒体大小、分辨率、帧数
+    file_size, resolution_width, resolution_height, frame_count = get_media_info(abs_path)
+
+    new_media = Media(
+        media_name=file_name,
+        media_path=file_path,
+        description=description,
+        file_size=file_size,
+        file_type=file_type,
+        resolution_width=resolution_width,
+        resolution_height=resolution_height,
+        frame_count=frame_count,
+        owner_id=current_user_id,
+    )
+    db.session.add(new_media)
+    db.session.commit()
+
+    # 记录操作
+    new_operation = handle_operation_success(new_operation, start_time, current_user_id)
+
+    current_app.logger.info(f"【上传媒体成功】new_media: {new_media}, operator: {current_user}")
+    return jsonify({
+        'operation': new_operation.to_dict(),
+        'new_media': new_media.to_dict(),
+    }), 201
+
+
+@media_routes.route('/detail/<int:media_id>', methods=['GET'])
+@jwt_required()
+@login_required
+def detail(media_id):
+    # 获取当前用户身份(使用 access token)
+    current_user_id, current_user = _current_user_context()
+
+    # 获取指定媒体
+    media = Media.query.get(media_id)
+
+    # 校验字段
+    validation_checks = [
+        (not media, f"【获取媒体 ID={media_id} 详情失败】该媒体不存在", 404),
+        (media and media.owner_id != current_user_id and current_user.role != UserRole.ADMIN
+         and current_user.role != UserRole.DEVELOPER,
+         f"【获取媒体 ID={media_id} 详情失败】您非管理员/开发人员,无法查看他人的媒体详情", 403),
+    ]
+    for condition, message, code in validation_checks:
+        if condition:
+            current_app.logger.warning(message + f', operator: {current_user}')
+            return jsonify({
+                'failure_message': message,
+            }), code
+
+    return jsonify({
+        'media': media.to_dict(),
+    }), 200
+
+
+@media_routes.route('/update/<int:media_id>', methods=['PUT'])
+@jwt_required()
+@login_required
+def update(media_id):
+    start_time = time.time()  # 记录操作开始时间
+
+    # 获取请求中的表单数据
+    description = request.form.get('description')
+
+    # 创建一个新的操作记录
+    new_operation = Operation(
+        operation_type=OperationType.UPDATE,
+        description=f"更新媒体 ID={media_id} 信息",
+        ip_address=request.remote_addr,
+        device_info=request.user_agent.string,
+    )
+
+    # 获取当前用户的身份(使用 access token)
+    current_user_id, current_user = _current_user_context()
+
+    # 获取指定媒体
+    updated_media = Media.query.get(media_id)
+
+    # 校验字段
+    validation_checks = [
+        (not updated_media, f"【更新媒体 ID={media_id} 信息失败】该媒体不存在", 404),
+        (updated_media and updated_media.owner_id != current_user_id and current_user.role != UserRole.ADMIN
+         and current_user.role != UserRole.DEVELOPER,
+         f"【更新媒体 ID={media_id} 信息失败】您非管理员/开发人员,无法更新他人的媒体信息", 403),
+    ]
+    for condition, message, code in validation_checks:
+        if condition:
+            new_operation = handle_operation_failure(new_operation, start_time, message, current_user_id)
+            current_app.logger.warning(message + f', operator: {current_user}')
+            return jsonify({
+                'operation': new_operation.to_dict(),
+            }), code
+
+    # 更新媒体信息
+    updated_media.description = description if description else updated_media.description
+    db.session.commit()
+
+    # 记录操作
+    new_operation = handle_operation_success(new_operation, start_time, current_user_id)
+
+    current_app.logger.info(
+        f"【更新媒体 ID={media_id} 信息成功】updated_media: {updated_media}, operator: {current_user}")
+    return jsonify({
+        'operation': new_operation.to_dict(),
+        'updated_media': updated_media.to_dict(),
+    }), 200
+
+
+@media_routes.route('/delete/<int:media_id>', methods=['DELETE'])
+@jwt_required()
+@login_required
+def delete_media(media_id):
+    start_time = time.time()  # 记录操作开始时间
+
+    # 创建一个新的操作记录
+    new_operation = Operation(
+        operation_type=OperationType.DELETE,
+        description=f"删除媒体 ID={media_id}",
+        ip_address=request.remote_addr,
+        device_info=request.user_agent.string,
+    )
+
+    # 获取当前用户身份(使用 access token)
+    current_user_id, current_user = _current_user_context()
+
+    # 获取指定媒体
+    deleted_media = Media.query.get(media_id)
+
+    # 校验字段
+    validation_checks = [
+        (not deleted_media, f"【删除媒体 ID={media_id} 失败】该媒体不存在", 404),
+        (deleted_media and deleted_media.owner_id != current_user_id and current_user.role != UserRole.ADMIN
+         and current_user.role != UserRole.DEVELOPER,
+         f"【删除媒体 ID={media_id} 失败】您非管理员/开发人员,无法删除他人的媒体", 403),
+        (deleted_media and Detection.query.filter_by(media_id=media_id).first(),
+         f"【删除媒体 ID={media_id} 失败】该媒体存在关联的检测分割记录,无法删除", 400),
+    ]
+    for condition, message, code in validation_checks:
+        if condition:
+            new_operation = handle_operation_failure(new_operation, start_time, message, current_user_id)
+            current_app.logger.warning(message + f', operator: {current_user}')
+            return jsonify({
+                'operation': new_operation.to_dict(),
+            }), code
+
+    # 删除实际文件
+    file_abs_path = os.path.join(current_app.root_path, deleted_media.media_path)
+    delete_file(file_abs_path)
+
+    # 删除数据库记录
+    db.session.delete(deleted_media)
+    db.session.commit()
+
+    # 记录操作
+    new_operation = handle_operation_success(new_operation, start_time, current_user_id)
+
+    current_app.logger.info(f"【删除媒体 ID={media_id} 成功】deleted_media: {deleted_media}, operator: {current_user}")
+    return jsonify({
+        'operation': new_operation.to_dict(),
+        'deleted_media': deleted_media.to_dict(),
+    }), 200
+
+
+@media_routes.route('/medias/<int:user_id>', methods=['GET'])
+@jwt_required()
+@login_required
+def user_medias(user_id):
+    # 获取分页参数(从请求中获取,默认为第 1 页,每页 5 条记录)
+    default_page = request.args.get('page', 1, type=int)
+    default_per_page = request.args.get('per_page', 5, type=int)
+    page, per_page = get_pagination_params(default_page, default_per_page)
+
+    # 获取当前用户身份(使用 access token)
+    current_user_id, current_user = _current_user_context()
+
+    # 获取指定用户身份
+    user = User.query.get(user_id)
+
+    # 校验字段
+    validation_checks = [
+        (not user, f"【获取用户 ID={user_id} 媒体失败】该用户不存在", 404),
+        (current_user_id != user_id and current_user.role != UserRole.ADMIN and current_user.role != UserRole.DEVELOPER,
+         f"【获取用户 ID={user_id} 媒体失败】您非管理员/开发人员,无法查看他人的媒体", 403),
+    ]
+    for condition, message, code in validation_checks:
+        if condition:
+            current_app.logger.warning(message + f', operator: {current_user}')
+            return jsonify({
+                'failure_message': message,
+            }), code
+
+    # 获取指定用户媒体
+    query = (
+        Media.query
+        .filter(Media.owner_id == user_id)
+        .join(User, Media.owner_id == User.user_id)
+        .add_columns(User.username.label('owner_username'))
+        .order_by(Media.media_id.desc())
+    )
+    page, medias_total, pages = adjust_page_if_needed(query, page, per_page)
+    paginated = query.paginate(page=page, per_page=per_page, error_out=False)
+    medias = []
+    stale_media_ids = []
+    for media, owner_username in paginated.items:
+        media_abs_path = _resolve_static_path(media.media_path)
+        if not os.path.isfile(media_abs_path):
+            stale_media_ids.append(media.media_id)
+            continue
+        media_dict = media.to_dict()
+        media_dict.update({'owner_username': owner_username})
+        medias.append(media_dict)
+    if stale_media_ids:
+        Media.query.filter(Media.media_id.in_(stale_media_ids)).delete(synchronize_session=False)
+        db.session.commit()
+
+    current_app.logger.info(
+        f"【获取用户 ID={user_id} 媒体成功】total: {medias_total}, per_page: {per_page}, page: {page}, pages: {pages}, medias: {medias}, operator: {current_user}")
+    return jsonify({
+        'medias': medias,
+        'total': medias_total,
+        'per_page': per_page,
+        'page': page,
+        'pages': pages,
+    }), 200
+
+
+@media_routes.route('/medias/all', methods=['GET'])
+@jwt_required()
+@login_required
+def all_medias():
+    # 获取分页参数(从请求中获取,默认为第 1 页,每页 5 条记录)
+    default_page = request.args.get('page', 1, type=int)
+    default_per_page = request.args.get('per_page', 5, type=int)
+    page, per_page = get_pagination_params(default_page, default_per_page)
+
+    # 获取当前用户身份(使用 access token)
+    current_user_id, current_user = _current_user_context()
+
+    if current_user.role != UserRole.ADMIN and current_user.role != UserRole.DEVELOPER:
+        failure_message = f"【获取所有媒体失败】您非管理员/开发人员,权限不足"
+        current_app.logger.warning(failure_message + f', operator: {current_user}')
+        return jsonify({
+            'failure_message': failure_message,
+        }), 403
+
+    # 获取所有媒体
+    query = (
+        Media.query
+        .join(User, Media.owner_id == User.user_id)
+        .add_columns(User.username.label('owner_username'))
+        .order_by(Media.media_id.desc())
+    )
+    page, medias_total, pages = adjust_page_if_needed(query, page, per_page)
+    paginated = query.paginate(page=page, per_page=per_page, error_out=False)
+    medias = []
+    stale_media_ids = []
+    for media, owner_username in paginated.items:
+        media_abs_path = _resolve_static_path(media.media_path)
+        if not os.path.isfile(media_abs_path):
+            stale_media_ids.append(media.media_id)
+            continue
+        media_dict = media.to_dict()
+        media_dict.update({'owner_username': owner_username})
+        medias.append(media_dict)
+    if stale_media_ids:
+        Media.query.filter(Media.media_id.in_(stale_media_ids)).delete(synchronize_session=False)
+        db.session.commit()
+
+    current_app.logger.info(
+        f"【获取所有媒体成功】total: {medias_total}, per_page: {per_page}, page: {page}, pages: {pages}, medias: {medias}, operator: {current_user}")
+    return jsonify({
+        'medias': medias,
+        'total': medias_total,
+        'per_page': per_page,
+        'page': page,
+        'pages': pages,
+    }), 200
+
+
+@media_routes.route('/statistics', methods=['GET'])
+@jwt_required()
+@login_required
+def statistics():
+    # 查询媒体总数
+    total_medias = Media.query.count()
+
+    # 图片类型(png, jpg, jpeg)
+    image_count = Media.query.filter(Media.file_type.in_(['png', 'jpg', 'jpeg'])).count()
+
+    # 视频类型(mp4)
+    video_count = Media.query.filter(Media.file_type.in_(['mp4'])).count()
+
+    # 构建返回数据
+    medias_statistics = {
+        'total': total_medias,
+        'image': image_count,
+        'video': video_count,
+    }
+
+    return jsonify({
+        "medias_statistics": medias_statistics,
+    }), 200

+ 327 - 0
BridgeDiseaseBackend-main/app/routes/model_route.py

@@ -0,0 +1,327 @@
+import os
+import time
+
+from flask import request, jsonify, current_app
+from flask_jwt_extended import jwt_required, get_jwt_identity
+from werkzeug.utils import secure_filename
+
+from app.constants import OperationType, UserRole
+from app.decorators import login_required
+from app.models import Operation, Model, db, User, Detection
+from app.routes import model_routes
+from app.utils import handle_operation_failure, allowed_model_file, handle_file_upload, handle_operation_success, \
+    adjust_page_if_needed, get_pagination_params, delete_file, user_rate_limit
+
+
+@model_routes.route('/upload', methods=['POST'])
+@jwt_required()
+@login_required
+def upload():
+    start_time = time.time()  # 记录操作开始时间
+
+    # 获取请求中的表单数据
+    model_file = request.files.get('model_file')
+    disease_category = request.form.get('disease_category')
+    augmentation = request.form.get('augmentation', '原图')
+    layers = int(request.form.get('layers'))
+    parameters = int(request.form.get('parameters'))
+    GFLOPs = float(request.form.get('GFLOPs'))
+    box_p = float(request.form.get('box_p', 0.0))
+    box_r = float(request.form.get('box_r', 0.0))
+    box_mAP50 = float(request.form.get('box_mAP50', 0.0))
+    box_mAP50_95 = float(request.form.get('box_mAP50_95', 0.0))
+    mask_p = float(request.form.get('mask_p', 0.0))
+    mask_r = float(request.form.get('mask_r', 0.0))
+    mask_mAP50 = float(request.form.get('mask_mAP50', 0.0))
+    mask_mAP50_95 = float(request.form.get('mask_mAP50_95', 0.0))
+    f1_score = float(request.form.get('f1_score', 0.0))
+    fitness_score = float(request.form.get('fitness_score', 0.0))
+
+    # 创建一个新的操作记录
+    new_operation = Operation(
+        operation_type=OperationType.CREATE,
+        description="上传模型",
+        ip_address=request.remote_addr,
+        device_info=request.user_agent.string,
+    )
+
+    # 获取当前用户身份(使用 access token)
+    current_user_id = get_jwt_identity()
+    current_user = User.query.get(current_user_id)
+
+    # 先获取文件名
+    file_name = secure_filename(model_file.filename) if model_file else None
+
+    # 检查文件名是否已存在
+    existing_model = Model.query.filter_by(model_name=file_name).first() if file_name else None
+
+    # 校验字段
+    validation_checks = [
+        (model_file and not allowed_model_file(model_file), "【上传模型失败】模型文件不合规", 400),
+        (current_user.role != UserRole.DEVELOPER, f"【上传模型失败】您非开发人员,无权上传模型", 403),
+        (model_file and existing_model, f"【上传模型失败】模型 {file_name} 已存在,请重新上传", 400),
+        (not disease_category or not layers or not parameters or not GFLOPs,
+         "【上传模型失败】模型病害类别、层数、参数量或计算量为空", 400),
+    ]
+    for condition, message, code in validation_checks:
+        if condition:
+            new_operation = handle_operation_failure(new_operation, start_time, message, current_user_id)
+            current_app.logger.warning(message + f', operator: {current_user}')
+            return jsonify({
+                'operation': new_operation.to_dict(),
+            }), code
+
+    # 保存文件到指定目录(返回相对路径)
+    file_path = handle_file_upload(model_file, 'models')
+
+    new_model = Model(
+        model_name=file_name,
+        model_path=file_path,
+        disease_category=disease_category,
+        augmentation=augmentation,
+        layers=layers,
+        parameters=parameters,
+        GFLOPs=GFLOPs,
+        box_p=box_p,
+        box_r=box_r,
+        box_mAP50=box_mAP50,
+        box_mAP50_95=box_mAP50_95,
+        mask_p=mask_p,
+        mask_r=mask_r,
+        mask_mAP50=mask_mAP50,
+        mask_mAP50_95=mask_mAP50_95,
+        f1_score=f1_score,
+        fitness_score=fitness_score,
+        owner_id=current_user_id,
+    )
+    db.session.add(new_model)
+    db.session.commit()
+
+    # 记录操作
+    new_operation = handle_operation_success(new_operation, start_time, current_user_id)
+
+    current_app.logger.info(f"【上传模型成功】new_model: {new_model}, operator: {current_user}")
+    return jsonify({
+        'operation': new_operation.to_dict(),
+        'new_model': new_model.to_dict(),
+    }), 201
+
+
+@model_routes.route('/detail/<int:model_id>', methods=['GET'])
+@jwt_required()
+@login_required
+def detail(model_id):
+    # 获取当前用户身份(使用 access token)
+    current_user_id = get_jwt_identity()
+    current_user = User.query.get(current_user_id)
+
+    # 获取指定模型文件
+    model = Model.query.get(model_id)
+
+    # 校验字段
+    validation_checks = [
+        (not model, f"【获取模型 ID={model_id} 详情失败】该模型不存在", 404),
+    ]
+    for condition, message, code in validation_checks:
+        if condition:
+            current_app.logger.warning(message + f', operator: {current_user}')
+            return jsonify({
+                'failure_message': message,
+            }), code
+
+    return jsonify({
+        'model': model.to_dict(),
+    }), 200
+
+
+@model_routes.route('/update/<int:model_id>', methods=['PUT'])
+@jwt_required()
+@login_required
+def update(model_id):
+    start_time = time.time()  # 记录操作开始时间
+
+    # 获取请求中的表单数据
+    disease_category = request.form.get('disease_category')
+    augmentation = request.form.get('augmentation')
+    layers = int(request.form.get('layers') or 0)
+    parameters = int(request.form.get('parameters') or 0)
+    GFLOPs = float(request.form.get('GFLOPs') or 0.0)
+    box_p = float(request.form.get('box_p') or 0.0)
+    box_r = float(request.form.get('box_r') or 0.0)
+    box_mAP50 = float(request.form.get('box_mAP50') or 0.0)
+    box_mAP50_95 = float(request.form.get('box_mAP50_95') or 0.0)
+    mask_p = float(request.form.get('mask_p') or 0.0)
+    mask_r = float(request.form.get('mask_r') or 0.0)
+    mask_mAP50 = float(request.form.get('mask_mAP50') or 0.0)
+    mask_mAP50_95 = float(request.form.get('mask_mAP50_95') or 0.0)
+    f1_score = float(request.form.get('f1_score') or 0.0)
+    fitness_score = float(request.form.get('fitness_score') or 0.0)
+
+    # 创建一个新的操作记录
+    new_operation = Operation(
+        operation_type=OperationType.UPDATE,
+        description=f"更新模型 ID={model_id} 信息",
+        ip_address=request.remote_addr,
+        device_info=request.user_agent.string,
+    )
+
+    # 获取当前用户的身份(使用 access token)
+    current_user_id = get_jwt_identity()
+    current_user = User.query.get(current_user_id)
+
+    # 获取指定模型文件
+    updated_model = Model.query.get(model_id)
+
+    # 校验字段
+    validation_checks = [
+        (not disease_category, f"【更新模型 ID={model_id} 信息失败】病害类别为空", 400),
+        (not updated_model, f"【更新模型 ID={model_id} 信息失败】该模型不存在", 404),
+        (updated_model and current_user.role != UserRole.DEVELOPER,
+         f"【更新模型 ID={model_id} 信息失败】您非开发人员,权限不足", 403),
+    ]
+    for condition, message, code in validation_checks:
+        if condition:
+            new_operation = handle_operation_failure(new_operation, start_time, message, current_user_id)
+            current_app.logger.warning(message + f', operator: {current_user}')
+            return jsonify({
+                'operation': new_operation.to_dict(),
+            }), code
+
+    # 更新模型文件信息
+    updated_model.disease_category = disease_category
+    updated_model.augmentation = augmentation if augmentation else updated_model.augmentation
+    updated_model.layers = layers
+    updated_model.parameters = parameters
+    updated_model.GFLOPs = GFLOPs
+    updated_model.box_p = box_p if box_p else updated_model.box_p
+    updated_model.box_r = box_r if box_r else updated_model.box_r
+    updated_model.box_mAP50 = box_mAP50 if box_mAP50 else updated_model.box_mAP50
+    updated_model.box_mAP50_95 = box_mAP50_95 if box_mAP50_95 else updated_model.box_mAP50_95
+    updated_model.mask_p = mask_p if mask_p else updated_model.mask_p
+    updated_model.mask_r = mask_r if mask_r else updated_model.mask_r
+    updated_model.mask_mAP50 = mask_mAP50 if mask_mAP50 else updated_model.mask_mAP50
+    updated_model.mask_mAP50_95 = mask_mAP50_95 if mask_mAP50_95 else updated_model.mask_mAP50_95
+    updated_model.f1_score = f1_score if f1_score else updated_model.f1_score
+    updated_model.fitness_score = fitness_score if fitness_score else updated_model.fitness_score
+    db.session.commit()
+
+    # 记录操作
+    new_operation = handle_operation_success(new_operation, start_time, current_user_id)
+    current_app.logger.info(
+        f"【更新模型 ID={model_id} 信息成功】updated_model: {updated_model}, operator: {current_user}")
+    return jsonify({
+        'operation': new_operation.to_dict(),
+        'updated_model': updated_model.to_dict(),
+    }), 200
+
+
+@model_routes.route('/delete/<int:model_id>', methods=['DELETE'])
+@jwt_required()
+@login_required
+def delete_model(model_id):
+    start_time = time.time()  # 记录操作开始时间
+
+    # 创建一个新的操作记录
+    new_operation = Operation(
+        operation_type=OperationType.DELETE,
+        description=f"删除模型 ID={model_id}",
+        ip_address=request.remote_addr,
+        device_info=request.user_agent.string,
+    )
+
+    # 获取当前用户身份(使用 access token)
+    current_user_id = get_jwt_identity()
+    current_user = User.query.get(current_user_id)
+
+    # 获取指定模型
+    deleted_model = Model.query.get(model_id)
+
+    # 校验字段
+    validation_checks = [
+        (not deleted_model, f"【删除模型 ID={model_id} 失败】该模型不存在", 404),
+        (deleted_model and current_user.role != UserRole.DEVELOPER,
+         f"【删除模型 ID={model_id} 失败】您非开发人员,权限不足", 403),
+        (deleted_model and Detection.query.filter_by(model_id=model_id).first(),
+         f"【删除模型 ID={model_id} 失败】该模型存在关联的检测分割记录,无法删除", 400),
+    ]
+    for condition, message, code in validation_checks:
+        if condition:
+            new_operation = handle_operation_failure(new_operation, start_time, message, current_user_id)
+            current_app.logger.warning(message + f', operator: {current_user}')
+            return jsonify({
+                'operation': new_operation.to_dict(),
+            }), code
+
+    # 删除实际文件
+    file_abs_path = os.path.join(current_app.root_path, deleted_model.model_path)
+    delete_file(file_abs_path)
+
+    # 删除数据库记录
+    db.session.delete(deleted_model)
+    db.session.commit()
+
+    # 记录操作
+    new_operation = handle_operation_success(new_operation, start_time, current_user_id)
+
+    current_app.logger.info(
+        f"【删除模型 ID={model_id} 成功】deleted_model: {deleted_model}, operator: {current_user}")
+    return jsonify({
+        'operation': new_operation.to_dict(),
+        'deleted_model': deleted_model.to_dict(),
+    }), 200
+
+
+@model_routes.route('/models/all', methods=['GET'])
+@jwt_required()
+@login_required
+def all_models():
+    # 获取分页参数(从请求中获取,默认为第 1 页,每页 5 条记录)
+    default_page = request.args.get('page', 1, type=int)
+    default_per_page = request.args.get('per_page', 5, type=int)
+    page, per_page = get_pagination_params(default_page, default_per_page)
+
+    # 获取当前用户身份(使用 access token)
+    current_user_id = get_jwt_identity()
+    current_user = User.query.get(current_user_id)
+
+    # 获取所有模型文件
+    query = (
+        Model.query
+        .join(User, Model.owner_id == User.user_id)
+        .add_columns(User.username.label('owner_username'))
+        .order_by(Model.model_id.asc())
+    )
+    page, models_total, pages = adjust_page_if_needed(query, page, per_page)
+    paginated = query.paginate(page=page, per_page=per_page, error_out=False)
+    models = []
+    for model, owner_username in paginated.items:
+        model_dict = model.to_dict()
+        model_dict.update({'owner_username': owner_username})
+        models.append(model_dict)
+
+    current_app.logger.info(
+        f"【获取所有模型成功】total: {models_total}, per_page: {per_page}, page: {page}, pages: {pages}, models: {models}, operator: {current_user}")
+    return jsonify({
+        'models': models,
+        'total': models_total,
+        'per_page': per_page,
+        'page': page,
+        'pages': pages,
+    }), 200
+
+
+@model_routes.route('/statistics', methods=['GET'])
+@jwt_required()
+@login_required
+def statistics():
+    # 查询模型总数
+    total_models = Model.query.count()
+
+    # 构建统计数据
+    models_statistics = {
+        'total': total_models,
+    }
+
+    return jsonify({
+        "models_statistics": models_statistics,
+    }), 200

+ 157 - 0
BridgeDiseaseBackend-main/app/routes/operation_route.py

@@ -0,0 +1,157 @@
+from flask import request, current_app, jsonify
+from flask_jwt_extended import jwt_required, get_jwt_identity
+
+from app.constants import UserRole
+from app.decorators import login_required
+from app.models import Operation, User, db
+from app.routes import operation_routes
+from app.utils import adjust_page_if_needed, get_pagination_params
+
+
+@operation_routes.route('/detail/<int:operation_id>', methods=['GET'])
+@jwt_required()
+@login_required
+def detail(operation_id):
+    # 获取当前用户身份(使用 access token)
+    current_user_id = get_jwt_identity()
+    current_user = User.query.get(current_user_id)
+
+    # 获取指定操作日志
+    operation = Operation.query.get(operation_id)
+
+    # 校验字段
+    validation_checks = [
+        (not operation, f"【获取操作 ID={operation_id} 详情失败】该操作不存在", 404),
+        (operation and operation.owner_id != current_user_id and current_user.role != UserRole.ADMIN
+         and current_user.role != UserRole.DEVELOPER,
+         f"【获取操作 ID={operation_id} 详情失败】您非管理员/开发人员,权限不足", 403),
+    ]
+    for condition, message, code in validation_checks:
+        if condition:
+            current_app.logger.warning(message + f', operator: {current_user}')
+            return jsonify({
+                'failure_message': message,
+            }), code
+
+    return jsonify({
+        'operation': operation.to_dict(),
+    }), 200
+
+
+@operation_routes.route('/delete/<int:operation_id>', methods=['DELETE'])
+@jwt_required()
+@login_required
+def delete_operation(operation_id):
+    # 获取当前用户身份(使用 access token)
+    current_user_id = get_jwt_identity()
+    current_user = User.query.get(current_user_id)
+
+    # 获取指定操作
+    deleted_operation = Operation.query.get(operation_id)
+
+    # 校验字段
+    validation_checks = [
+        (current_user.role != UserRole.ADMIN and current_user.role != UserRole.DEVELOPER,
+         f"【删除操作 ID={operation_id} 日志失败】您非管理员/开发人员,权限不足", 403),
+        (not deleted_operation, f"【删除操作 ID={operation_id} 日志失败】该操作不存在", 404),
+    ]
+    for condition, message, code in validation_checks:
+        if condition:
+            current_app.logger.warning(message + f', operator: {current_user}')
+            return jsonify({
+                'failure_message': message,
+            }), code
+
+    # 删除操作日志
+    db.session.delete(deleted_operation)
+    db.session.commit()
+
+    current_app.logger.info(
+        f"【删除操作 ID={operation_id} 日志成功】deleted_operation: {deleted_operation}, operator: {current_user}")
+    return jsonify({
+        'deleted_operation': deleted_operation.to_dict(),
+    }), 200
+
+
+@operation_routes.route('/clear', methods=['DELETE'])
+@jwt_required()
+@login_required
+def clear():
+    # 获取当前用户身份(使用 access token)
+    current_user_id = get_jwt_identity()
+    current_user = User.query.get(current_user_id)
+
+    # 校验字段
+    validation_checks = [
+        (current_user.role != UserRole.ADMIN and current_user.role != UserRole.DEVELOPER,
+         f"【清空操作日志失败】您非管理员/开发人员,权限不足", 403),
+    ]
+    for condition, message, code in validation_checks:
+        if condition:
+            current_app.logger.warning(message + f', operator: {current_user}')
+            return jsonify({
+                'failure_message': message,
+            }), code
+
+    # 清空所有操作日志
+    Operation.query.delete()
+    db.session.commit()
+
+    current_app.logger.info(f"【清空操作日志成功】operator: {current_user}")
+    return jsonify({
+        'operator': current_user.to_dict(),
+    }), 200
+
+
+@operation_routes.route('/operations/all', methods=['GET'])
+@jwt_required()
+@login_required
+def all_operations():
+    # 获取分页参数(从请求中获取,默认为第 1 页,每页 5 条记录)
+    default_page = request.args.get('page', 1, type=int)
+    default_per_page = request.args.get('per_page', 5, type=int)
+    page, per_page = get_pagination_params(default_page, default_per_page)
+
+    # 获取当前用户身份(使用 access token)
+    current_user_id = get_jwt_identity()
+    current_user = User.query.get(current_user_id)
+
+    # 校验字段
+    validation_checks = [
+        (current_user.role != UserRole.ADMIN and current_user.role != UserRole.DEVELOPER,
+         f"【获取所有操作失败】您非管理员/开发人员,权限不足", 403),
+    ]
+    for condition, message, code in validation_checks:
+        if condition:
+            current_app.logger.warning(message + f', operator: {current_user}')
+            return jsonify({
+                'failure_message': message,
+            }), code
+
+    # 获取所有操作(包括 owner_id 为 NULL 的记录)
+    query = (
+        Operation.query
+        .outerjoin(User, Operation.owner_id == User.user_id)
+        .add_columns(User.username.label('owner_username'))
+        .order_by(Operation.operation_id.asc())
+    )
+    page, operations_total, pages = adjust_page_if_needed(query, page, per_page)
+    paginated = query.paginate(page=page, per_page=per_page, error_out=False)
+    operations = []
+    for operation, owner_username in paginated.items:
+        operation_dict = operation.to_dict()
+        # 如果 owner_id 为 NULL,设置为 0
+        if operation.owner_id is None:
+            operation_dict['owner_id'] = 0
+        operation_dict.update({'owner_username': owner_username or '无'})
+        operations.append(operation_dict)
+
+    current_app.logger.info(
+        f"【获取所有操作成功】total: {operations_total}, per_page: {per_page}, page: {page}, pages: {pages}, operations: {operations}, operator: {current_user}")
+    return jsonify({
+        'operations': operations,
+        'total': operations_total,
+        'per_page': per_page,
+        'page': page,
+        'pages': pages,
+    }), 200

+ 824 - 0
BridgeDiseaseBackend-main/app/routes/user_route.py

@@ -0,0 +1,824 @@
+import random
+import time
+from datetime import datetime
+from zoneinfo import ZoneInfo
+
+from flask import request, jsonify, current_app
+from flask_jwt_extended import create_access_token, jwt_required, get_jwt_identity, create_refresh_token
+from werkzeug.security import generate_password_hash, check_password_hash
+
+from app.constants import OperationType, UserRole, UserStatus
+from app.decorators import login_required
+from app.models import Operation, User, db
+from app.routes import user_routes
+from app.utils import is_valid_email, is_valid_avatar_file, is_valid_phone, handle_operation_failure, \
+    handle_operation_success, handle_file_upload, get_pagination_params, adjust_page_if_needed, rate_limit, \
+    user_rate_limit
+
+
+@user_routes.route('/register', methods=['POST'])
+def register():
+    start_time = time.time()  # 记录操作开始时间
+
+    # 获取请求中的表单数据
+    username = request.form.get('username')
+    email = request.form.get('email')
+    password = request.form.get('password')
+    first_name = request.form.get('first_name', '名字')
+    last_name = request.form.get('last_name', '姓氏')
+    role = request.form.get('role', 'user').lower()
+    avatar_file = request.files.get('avatar_file')
+    phone = request.form.get('phone')
+
+    # 创建一个新的操作记录
+    new_operation = Operation(
+        operation_type=OperationType.CREATE,
+        description="用户注册",
+        ip_address=request.remote_addr,
+        device_info=request.user_agent.string,
+    )
+
+    # 根据用户名、邮箱、手机号查找用户(因为这三个字段具有唯一性)
+    user = User.query.filter((User.username == username) | (User.email == email) | (User.phone == phone)).first()
+
+    # 校验字段
+    validation_checks = [
+        (not username or not email or not password, "【注册失败】用户名、邮箱或密码为空", 400),
+        (user and user.status == UserStatus.BANNED,
+         f"【注册失败】您的账号已被封禁,如有疑问请联系管理员/开发人员", 403),
+        (user and (user.status == UserStatus.DELETED or user.deleted_at),
+         f"【注册失败】您的账号已注销,若要重新注册请联系管理员/开发人员", 403),
+        (user and (user.status != UserStatus.DELETED or not user.deleted_at), f"【注册失败】您已注册过,请直接登录", 400),
+        (not is_valid_email(email), f"【注册失败】无效的邮箱格式:{email}", 400),
+        (role not in UserRole.list(), f"【注册失败】无效的角色:{role},只限 'admin', 'developer', 'user'", 400),
+        (avatar_file and not is_valid_avatar_file(avatar_file), "【注册失败】头像文件不合规", 400),
+        (phone and not is_valid_phone(phone), f"【注册失败】无效的手机号格式:{phone}", 400),
+    ]
+    for condition, message, code in validation_checks:
+        if condition:
+            new_operation = handle_operation_failure(new_operation, start_time, message)
+            current_app.logger.warning(message)
+            return jsonify({
+                'operation': new_operation.to_dict(),
+            }), code
+
+    # 新用户,创建新记录
+    user = User(
+        username=username,
+        email=email,
+        password=generate_password_hash(password),
+        first_name=first_name,
+        last_name=last_name,
+        role=UserRole(role),
+        avatar_path=handle_file_upload(avatar_file, 'avatars'),
+        phone=phone,
+    )
+    db.session.add(user)
+    db.session.commit()
+
+    # 记录操作
+    new_operation = handle_operation_success(new_operation, start_time, user.user_id)
+
+    current_app.logger.info(f"【注册成功】new_user: {user}")
+    return jsonify({
+        'operation': new_operation.to_dict(),
+        'new_user': user.to_dict(),
+    }), 201
+
+
+@user_routes.route('/login', methods=['POST'])
+def login():
+    start_time = time.time()  # 记录操作开始时间
+
+    # 获取请求中的表单数据
+    username_or_email = request.form.get('username_or_email')
+    password = request.form.get('password')
+
+    # 创建一个新的操作记录
+    new_operation = Operation(
+        operation_type=OperationType.AUTHENTICATE,
+        description="用户登录",
+        ip_address=request.remote_addr,
+        device_info=request.user_agent.string,
+    )
+
+    # 根据用户名或邮箱查找用户
+    user = User.query.filter((User.username == username_or_email) | (User.email == username_or_email)).first()
+    user_id = user.user_id if user is not None else None
+
+    # 校验字段
+    validation_checks = [
+        (not username_or_email or not password, "【登录失败】用户名或邮箱和密码是必填项", 400),
+        (not user, f"【登录失败】用户 {username_or_email} 尚未注册,请先注册", 400),
+        (user and user.status == UserStatus.BANNED, f"【登录失败】您已被封禁,如有疑问请联系管理员/开发人员", 403),
+        (user and (user.status == UserStatus.DELETED or user.deleted_at),
+         f"【登录失败】您的账号已注销,若需重新注册请联系管理员/开发人员", 403),
+        (user and not check_password_hash(user.password, password), "【登录失败】密码错误", 400),
+    ]
+    for condition, message, code in validation_checks:
+        if condition:
+            new_operation = handle_operation_failure(new_operation, start_time, message, user_id)
+            current_app.logger.warning(message)
+            return jsonify({
+                'operation': new_operation.to_dict(),
+            }), code
+
+    # 更新用户的最后登录时间和状态
+    user.last_login = datetime.now(ZoneInfo("Asia/Shanghai"))
+    user.status = UserStatus.ACTIVE
+    db.session.commit()
+
+    # 根据 user_id 创建 JWT 令牌
+    # PyJWT 要求 JWT「sub」為字串,identity 不可傳整數
+    uid = str(user.user_id)
+    access_token = create_access_token(identity=uid)
+    refresh_token = create_refresh_token(identity=uid)
+
+    # 记录操作
+    new_operation = handle_operation_success(new_operation, start_time, user.user_id)
+
+    current_app.logger.info(
+        f"【登录成功】login_user: {user}, access_token: {access_token}, refresh_token: {refresh_token}")
+    return jsonify({
+        'operation': new_operation.to_dict(),
+        'login_user': user.to_dict(),
+        'access_token': access_token,
+        'refresh_token': refresh_token,
+    }), 200
+
+
+@user_routes.route('/logout', methods=['POST'])
+@jwt_required()
+@login_required
+def logout():
+    start_time = time.time()  # 记录操作开始时间
+
+    # 创建一个新的操作记录
+    new_operation = Operation(
+        operation_type=OperationType.AUTHENTICATE,
+        description="用户登出",
+        ip_address=request.remote_addr,
+        device_info=request.user_agent.string,
+    )
+
+    # 获取当前用户身份(使用 access token)
+    current_user_id = get_jwt_identity()
+    current_user = User.query.get(current_user_id)
+
+    # 更新用户的最后登录时间和状态
+    current_user.last_login = datetime.now(ZoneInfo("Asia/Shanghai"))
+    current_user.status = UserStatus.INACTIVE
+    db.session.commit()
+
+    # 记录操作
+    new_operation = handle_operation_success(new_operation, start_time, current_user_id)
+
+    current_app.logger.info(f"【登出成功】logout_user: {current_user}")
+    return jsonify({
+        'operation': new_operation.to_dict(),
+        'logout_user': current_user.to_dict(),
+    }), 200
+
+
+@user_routes.route('/refresh', methods=['POST'])
+@jwt_required(refresh=True)
+def refresh():
+    start_time = time.time()  # 记录操作开始时间
+
+    # 创建一个新的操作记录
+    new_operation = Operation(
+        operation_type=OperationType.AUTHENTICATE,
+        description="刷新用户 token",
+        ip_address=request.remote_addr,
+        device_info=request.user_agent.string,
+    )
+
+    # 获取当前用户身份(使用 refresh token)
+    current_user_id = get_jwt_identity()
+    current_user = User.query.get(current_user_id)
+
+    # 生成新的 access token
+    access_token = create_access_token(identity=str(current_user_id))
+
+    # 记录操作
+    new_operation = handle_operation_success(new_operation, start_time, current_user_id)
+
+    current_app.logger.info(f"【刷新 token 成功】current_user: {current_user}, access_token: {access_token}")
+    return jsonify({
+        'operation': new_operation.to_dict(),
+        'access_token': access_token,
+    }), 200
+
+
+@user_routes.route('/profile', methods=['GET'])
+@jwt_required()
+@login_required
+def profile():
+    # 获取当前用户身份(使用 access token)
+    current_user_id = get_jwt_identity()
+    current_user = User.query.get(current_user_id)
+
+    return jsonify({
+        'current_user': current_user.to_dict(),
+    }), 200
+
+
+@user_routes.route('/detail/<int:user_id>', methods=['GET'])
+@jwt_required()
+@login_required
+def detail(user_id):
+    # 获取当前用户身份(使用 access token)
+    current_user_id = get_jwt_identity()
+    current_user = User.query.get(current_user_id)
+
+    # 获取指定用户
+    user = User.query.get(user_id)
+
+    # 校验字段
+    validation_checks = [
+        (not user, f"【获取用户 ID={user_id} 详情失败】该用户不存在", 404),
+        (user and user.user_id != current_user_id and current_user.role != UserRole.ADMIN
+         and current_user.role != UserRole.DEVELOPER,
+         f"【获取用户 ID={user_id} 详情失败】当前登录用户非管理员/开发人员,无权查看其他用户信息", 403),
+    ]
+    for condition, message, code in validation_checks:
+        if condition:
+            current_app.logger.warning(message + f', operator: {current_user}')
+            return jsonify({
+                'failure_message': message,
+            }), code
+
+    return jsonify({
+        'user': user.to_dict(),
+    }), 200
+
+
+@user_routes.route('/update', methods=['PUT'])
+@jwt_required()
+@login_required
+def update():
+    start_time = time.time()  # 记录操作开始时间
+
+    # 获取请求中的更新数据
+    username = request.form.get('username')
+    email = request.form.get('email')
+    first_name = request.form.get('first_name')
+    last_name = request.form.get('last_name')
+    avatar_file = request.files.get('avatar_file')
+    phone = request.form.get('phone')
+
+    # 创建一个新的操作记录
+    new_operation = Operation(
+        operation_type=OperationType.UPDATE,
+        description="更新用户资料",
+        ip_address=request.remote_addr,
+        device_info=request.user_agent.string,
+    )
+
+    # 获取当前用户身份(使用 access token)
+    current_user_id = get_jwt_identity()
+    current_user = User.query.get(current_user_id)
+
+    # 校验字段
+    validation_checks = [
+        (not username or not email, "【更新用户资料失败】用户名或邮箱为空", 400),
+        (not is_valid_email(email), f"【更新用户资料失败】无效的邮箱格式:{email}", 400),
+        (avatar_file and not is_valid_avatar_file(avatar_file), "【更新用户资料失败】头像文件类型或大小不合规", 400),
+        (phone and not is_valid_phone(phone), f"【更新用户资料失败】无效的手机号格式:{phone}", 400),
+        (current_user.username != username and User.query.filter_by(username=username).first(),
+         f"【更新用户资料失败】用户名 {username} 已注册过", 400),
+        (current_user.email != email and User.query.filter_by(email=email).first(),
+         f"【更新用户资料失败】邮箱 {email} 已注册过", 400),
+        (phone and current_user.phone != phone and User.query.filter_by(phone=phone).first(),
+         f"【更新用户资料失败】手机号 {phone} 已注册过", 400),
+    ]
+    for condition, message, code in validation_checks:
+        if condition:
+            new_operation = handle_operation_failure(new_operation, start_time, message, current_user_id)
+            current_app.logger.warning(message + f', operator: {current_user}')
+            return jsonify({
+                'operation': new_operation.to_dict(),
+            }), code
+
+    # 更新用户信息
+    current_user.username = username
+    current_user.email = email
+    current_user.first_name = first_name if first_name else current_user.first_name
+    current_user.last_name = last_name if last_name else current_user.last_name
+    if avatar_file:  # 如果有头像文件,则上传并更新头像路径;如果没有,则说明用户不需要更新头像
+        current_user.avatar_path = handle_file_upload(avatar_file, 'avatars')
+    current_user.phone = phone if phone else current_user.phone
+    db.session.commit()
+
+    # 记录操作
+    new_operation = handle_operation_success(new_operation, start_time, current_user_id)
+
+    current_app.logger.info(f"【更新用户资料成功】updated_user: {current_user}")
+    return jsonify({
+        'operation': new_operation.to_dict(),
+        'updated_user': current_user.to_dict(),
+    }), 200
+
+
+@user_routes.route('/update/<int:user_id>', methods=['PUT'])
+@jwt_required()
+@login_required
+def update_user(user_id):
+    start_time = time.time()  # 记录操作开始时间
+
+    # 获取请求中的更新数据
+    username = request.form.get('username')
+    email = request.form.get('email')
+    password = request.form.get('password')
+    first_name = request.form.get('first_name')
+    last_name = request.form.get('last_name')
+    role = request.form.get('role').lower()
+    avatar_file = request.files.get('avatar_file')
+    phone = request.form.get('phone')
+
+    # 创建一个新的操作记录
+    new_operation = Operation(
+        operation_type=OperationType.UPDATE,
+        description=f"更新用户 ID={user_id} 资料",
+        ip_address=request.remote_addr,
+        device_info=request.user_agent.string,
+    )
+
+    # 获取当前用户身份(使用 access token)
+    current_user_id = get_jwt_identity()
+    current_user = User.query.get(current_user_id)
+
+    # 获取指定用户
+    updated_user = User.query.get(user_id)
+
+    # 校验字段
+    validation_checks = [
+        (current_user.role != UserRole.ADMIN and current_user.role != UserRole.DEVELOPER,
+         f"【更新用户 ID={user_id} 资料失败】您非管理员/开发人员,无权修改其他用户信息", 403),
+        (not username or not email or not role, f"【更新用户 ID={user_id} 资料失败】用户名、邮箱或角色为空", 400),
+        (not is_valid_email(email), f"【更新用户 ID={user_id} 资料失败】无效的邮箱格式:{email}", 400),
+        (avatar_file and not is_valid_avatar_file(avatar_file),
+         f"【更新用户 ID={user_id} 资料失败】头像文件类型或大小不合规", 400),
+        (phone and not is_valid_phone(phone), f"【更新用户 ID={user_id} 资料失败】无效的手机号格式:{phone}", 400),
+        (not updated_user, f"【更新用户 ID={user_id} 资料失败】该用户不存在", 404),
+        (updated_user.username != username and User.query.filter_by(username=username).first(),
+         f"【更新用户 ID={user_id} 资料失败】用户名 {username} 已注册过", 400),
+        (updated_user.email != email and User.query.filter_by(email=email).first(),
+         f"【更新用户 ID={user_id} 资料失败】邮箱 {email} 已注册过", 400),
+        (phone and updated_user.phone != phone and User.query.filter_by(phone=phone).first(),
+         f"【更新用户 ID={user_id} 资料失败】手机号 {phone} 已注册过", 400),
+    ]
+    for condition, message, code in validation_checks:
+        if condition:
+            new_operation = handle_operation_failure(new_operation, start_time, message, current_user_id)
+            current_app.logger.warning(message + f', operator: {current_user}')
+            return jsonify({
+                'operation': new_operation.to_dict(),
+            }), code
+
+    # 更新用户信息
+    updated_user.username = username
+    updated_user.email = email
+    if password:  # 如果有密码,则更新密码;如果没有,则说明用户不需要更新密码
+        updated_user.password = generate_password_hash(password)
+    updated_user.first_name = first_name if first_name else updated_user.first_name
+    updated_user.last_name = last_name if last_name else updated_user.last_name
+    updated_user.role = UserRole(role)
+    if avatar_file:  # 如果有头像文件,则上传并更新头像路径;如果没有,则说明用户不需要更新头像
+        updated_user.avatar_path = handle_file_upload(avatar_file, 'avatars')
+    updated_user.phone = phone if phone else updated_user.phone
+    db.session.commit()
+
+    # 记录操作
+    new_operation = handle_operation_success(new_operation, start_time, current_user_id)
+
+    current_app.logger.info(f"【更新用户 ID={user_id} 资料成功】updated_user: {updated_user}, operator: {current_user}")
+    return jsonify({
+        'operation': new_operation.to_dict(),
+        'updated_user': updated_user.to_dict(),
+    }), 200
+
+
+@user_routes.route('/change_password', methods=['PUT'])
+@jwt_required()
+@login_required
+def change_password():
+    start_time = time.time()  # 记录操作开始时间
+
+    # 获取请求中的当前密码和新密码
+    current_password = request.form.get('current_password')
+    new_password = request.form.get('new_password')
+
+    # 创建一个新的操作记录
+    new_operation = Operation(
+        operation_type=OperationType.UPDATE,
+        description="修改密码",
+        ip_address=request.remote_addr,
+        device_info=request.user_agent.string,
+    )
+
+    # 获取当前用户身份(使用 access token)
+    current_user_id = get_jwt_identity()
+    current_user = User.query.get(current_user_id)
+
+    # 校验字段
+    validation_checks = [
+        (not current_password or not new_password, "【修改密码失败】当前密码或新密码为空", 400),
+        (not check_password_hash(current_user.password, current_password), "【修改密码失败】当前密码错误", 400),
+    ]
+    for condition, message, code in validation_checks:
+        if condition:
+            new_operation = handle_operation_failure(new_operation, start_time, message, current_user_id)
+            current_app.logger.warning(message + f', operator: {current_user}')
+            return jsonify({
+                'operation': new_operation.to_dict(),
+            }), code
+
+    # 更新密码
+    current_user.password = generate_password_hash(new_password)
+    db.session.commit()
+
+    # 记录操作
+    new_operation = handle_operation_success(new_operation, start_time, current_user_id)
+
+    current_app.logger.info(f"【修改密码成功】current_user: {current_user}, old_password: {current_password}")
+    return jsonify({
+        'operation': new_operation.to_dict(),
+        'current_user': current_user.to_dict(),
+        "old_password": current_password,
+    }), 200
+
+
+@user_routes.route('/delete', methods=['DELETE'])
+@jwt_required()
+@login_required
+def delete():
+    start_time = time.time()  # 记录操作开始时间
+
+    # 创建一个新的操作记录
+    new_operation = Operation(
+        operation_type=OperationType.DELETE,
+        description="注销账户",
+        ip_address=request.remote_addr,
+        device_info=request.user_agent.string,
+    )
+
+    # 获取当前用户身份(使用 access token)
+    current_user_id = get_jwt_identity()
+    current_user = User.query.get(current_user_id)
+
+    # 软删除用户
+    current_user.deleted_at = datetime.now(ZoneInfo("Asia/Shanghai"))
+    current_user.status = UserStatus.DELETED
+    db.session.commit()
+
+    # 记录操作
+    new_operation = handle_operation_success(new_operation, start_time, current_user_id)
+
+    current_app.logger.info(f"【注销账户成功】deleted_user: {current_user}")
+    return jsonify({
+        'operation': new_operation.to_dict(),
+        'deleted_user': current_user.to_dict(),
+    }), 200
+
+
+@user_routes.route('/delete/<int:user_id>', methods=['DELETE'])
+@jwt_required()
+@login_required
+def delete_user(user_id):
+    start_time = time.time()  # 记录操作开始时间
+
+    # 创建一个新的操作记录
+    new_operation = Operation(
+        operation_type=OperationType.DELETE,
+        description=f"注销用户 ID={user_id}",
+        ip_address=request.remote_addr,
+        device_info=request.user_agent.string,
+    )
+
+    # 获取当前用户身份(使用 access token)
+    current_user_id = get_jwt_identity()
+    current_user = User.query.get(current_user_id)
+
+    # 获取指定用户
+    deleted_user = User.query.get(user_id)
+
+    # 校验字段
+    validation_checks = [
+        (current_user.role != UserRole.ADMIN and current_user.role != UserRole.DEVELOPER,
+         f"【注销用户 ID={user_id} 失败】您非管理员/开发人员,无权注销其他用户", 403),
+        (not deleted_user, f"【注销用户 ID={user_id} 失败】该用户不存在", 404),
+        (current_user.role != UserRole.DEVELOPER and (deleted_user.role == UserRole.ADMIN
+                                                      or deleted_user.role == UserRole.DEVELOPER),
+         f"【注销用户 ID={user_id} 失败】您非开发人员,无权注销管理员/开发人员", 400),
+        (deleted_user.status == UserStatus.DELETED or deleted_user.deleted_at,
+         f"【注销用户 ID={user_id} 失败】该用户已注销", 400),
+    ]
+    for condition, message, code in validation_checks:
+        if condition:
+            new_operation = handle_operation_failure(new_operation, start_time, message, current_user_id)
+            current_app.logger.warning(message + f', operator: {current_user}')
+            return jsonify({
+                'operation': new_operation.to_dict(),
+            }), code
+
+    # 软删除用户
+    deleted_user.deleted_at = datetime.now(ZoneInfo("Asia/Shanghai"))
+    deleted_user.status = UserStatus.DELETED
+    db.session.commit()
+
+    # 记录操作
+    new_operation = handle_operation_success(new_operation, start_time, current_user_id)
+
+    current_app.logger.info(f"【注销用户 ID={user_id} 成功】deleted_user: {deleted_user}, operator: {current_user}")
+    return jsonify({
+        'operation': new_operation.to_dict(),
+        'deleted_user': deleted_user.to_dict(),
+    }), 200
+
+
+@user_routes.route('/undelete/<int:user_id>', methods=['PUT'])
+@jwt_required()
+@login_required
+def undelete_user(user_id):
+    start_time = time.time()  # 记录操作开始时间
+
+    # 创建一个新的操作记录
+    new_operation = Operation(
+        operation_type=OperationType.DELETE,
+        description=f"恢复注销用户 ID={user_id}",
+        ip_address=request.remote_addr,
+        device_info=request.user_agent.string,
+    )
+
+    # 获取当前用户身份(使用 access token)
+    current_user_id = get_jwt_identity()
+    current_user = User.query.get(current_user_id)
+
+    # 获取指定用户
+    undeleted_user = User.query.get(user_id)
+
+    # 校验字段
+    validation_checks = [
+        (current_user.role != UserRole.ADMIN and current_user.role != UserRole.DEVELOPER,
+         f"【恢复注销用户 ID={user_id} 失败】您非管理员/开发人员,无权恢复注销其他用户", 403),
+        (not undeleted_user, f"【恢复注销用户 ID={user_id} 失败】该用户不存在", 404),
+        (current_user.role != UserRole.DEVELOPER and (undeleted_user.role == UserRole.ADMIN
+                                                      or undeleted_user.role == UserRole.DEVELOPER),
+         f"【恢复注销用户 ID={user_id} 失败】您非开发人员,无权恢复注销管理员/开发人员", 400),
+        (undeleted_user.status != UserStatus.DELETED or not undeleted_user.deleted_at,
+         f"【恢复注销用户 ID={user_id} 失败】该用户未注销", 400),
+    ]
+    for condition, message, code in validation_checks:
+        if condition:
+            new_operation = handle_operation_failure(new_operation, start_time, message, current_user_id)
+            current_app.logger.warning(message + f', operator: {current_user}')
+            return jsonify({
+                'operation': new_operation.to_dict(),
+            }), code
+
+    # 恢复注销用户
+    undeleted_user.deleted_at = None
+    undeleted_user.status = UserStatus.INACTIVE
+    db.session.commit()
+
+    # 记录操作
+    new_operation = handle_operation_success(new_operation, start_time, current_user_id)
+
+    current_app.logger.info(
+        f"【恢复注销用户 ID={user_id} 成功】undeleted_user: {undeleted_user}, operator: {current_user}")
+    return jsonify({
+        'operation': new_operation.to_dict(),
+        'undeleted_user': undeleted_user.to_dict(),
+    }), 200
+
+
+@user_routes.route('/ban/<int:user_id>', methods=['PUT'])
+@jwt_required()
+@login_required
+def ban(user_id):
+    start_time = time.time()  # 记录操作开始时间
+
+    # 创建一个新的操作记录
+    new_operation = Operation(
+        operation_type=OperationType.UPDATE,
+        description=f"封禁用户 ID={user_id}",
+        ip_address=request.remote_addr,
+        device_info=request.user_agent.string,
+    )
+
+    # 获取当前用户身份(使用 access token)
+    current_user_id = get_jwt_identity()
+    current_user = User.query.get(current_user_id)
+
+    # 获取指定用户
+    baned_user = User.query.get(user_id)
+
+    # 校验字段
+    validation_checks = [
+        (current_user.role != UserRole.ADMIN and current_user.role != UserRole.DEVELOPER,
+         f"【封禁用户 ID={user_id} 失败】您非管理员/开发人员,无权封禁其他用户", 403),
+        (not baned_user, f"【封禁用户 ID={user_id} 失败】该用户不存在", 404),
+        (current_user.role != UserRole.DEVELOPER and (baned_user.role == UserRole.ADMIN
+                                                      or baned_user.role == UserRole.DEVELOPER),
+         f"【封禁用户 ID={user_id} 失败】您非开发人员,无权封禁管理员/开发人员", 400),
+        (baned_user.status == UserStatus.BANNED, f"【封禁用户 ID={user_id} 失败】该用户已被封禁", 400),
+        (baned_user.status == UserStatus.DELETED, f"【封禁用户 ID={user_id} 失败】该用户已注销", 400),
+    ]
+    for condition, message, code in validation_checks:
+        if condition:
+            new_operation = handle_operation_failure(new_operation, start_time, message, current_user_id)
+            current_app.logger.warning(message + f', operator: {current_user}')
+            return jsonify({
+                'operation': new_operation.to_dict(),
+            }), code
+
+    # 封禁用户
+    baned_user.status = UserStatus.BANNED
+    db.session.commit()
+
+    # 记录操作
+    new_operation = handle_operation_success(new_operation, start_time, current_user_id)
+
+    current_app.logger.info(f"【封禁用户 ID={user_id} 成功】baned_user: {baned_user}, operator: {current_user}")
+    return jsonify({
+        'operation': new_operation.to_dict(),
+        'baned_user': baned_user.to_dict(),
+    }), 200
+
+
+@user_routes.route('/unban/<int:user_id>', methods=['PUT'])
+@jwt_required()
+@login_required
+def unban(user_id):
+    start_time = time.time()  # 记录操作开始时间
+
+    # 创建一个新的操作记录
+    new_operation = Operation(
+        operation_type=OperationType.UPDATE,
+        description=f"解封用户 ID={user_id}",
+        ip_address=request.remote_addr,
+        device_info=request.user_agent.string,
+    )
+
+    # 获取当前用户身份(使用 access token)
+    current_user_id = get_jwt_identity()
+    current_user = User.query.get(current_user_id)
+
+    # 获取指定用户
+    unbaned_user = User.query.get(user_id)
+
+    # 校验字段
+    validation_checks = [
+        (current_user.role != UserRole.ADMIN and current_user.role != UserRole.DEVELOPER,
+         f"【解封用户 ID={user_id} 失败】您非管理员/开发人员,无权解封其他用户", 403),
+        (not unbaned_user, f"【解封用户 ID={user_id} 失败】该用户不存在", 404),
+        (current_user.role != UserRole.DEVELOPER and (unbaned_user.role == UserRole.ADMIN
+                                                      or unbaned_user.role == UserRole.DEVELOPER),
+         f"【解封用户 ID={user_id} 失败】您非开发人员,无权解封管理员/开发人员", 400),
+        (unbaned_user.status != UserStatus.BANNED, f"【解封用户 ID={user_id} 失败】该用户未被封禁", 400),
+        (unbaned_user.status == UserStatus.DELETED, f"【解封用户 ID={user_id} 失败】该用户已注销", 400),
+    ]
+    for condition, message, code in validation_checks:
+        if condition:
+            new_operation = handle_operation_failure(new_operation, start_time, message, current_user_id)
+            current_app.logger.warning(message + f', operator: {current_user}')
+            return jsonify({
+                'operation': new_operation.to_dict(),
+            }), code
+
+    # 解封用户
+    unbaned_user.status = UserStatus.INACTIVE
+    db.session.commit()
+
+    # 记录操作
+    new_operation = handle_operation_success(new_operation, start_time, current_user_id)
+
+    current_app.logger.info(f"【解封用户 ID={user_id} 成功】unbaned_user: {unbaned_user}, operator: {current_user}")
+    return jsonify({
+        'operation': new_operation.to_dict(),
+        'unbaned_user': unbaned_user.to_dict(),
+    }), 200
+
+
+@user_routes.route('/users/all', methods=['GET'])
+@jwt_required()
+@login_required
+def all_users():
+    # 获取分页参数(从请求中获取,默认为第 1 页,每页 5 条记录)
+    default_page = request.args.get('page', 1, type=int)
+    default_per_page = request.args.get('per_page', 5, type=int)
+    page, per_page = get_pagination_params(default_page, default_per_page)
+
+    # 获取当前用户身份(使用 access token)
+    current_user_id = get_jwt_identity()
+    current_user = User.query.get(current_user_id)
+
+    if current_user.role != UserRole.ADMIN and current_user.role != UserRole.DEVELOPER:
+        failure_message = f"【获取所有用户失败】您非管理员/开发人员,权限不足"
+        current_app.logger.warning(failure_message + f', operator: {current_user}')
+        return jsonify({
+            'failure_message': failure_message,
+        }), 403
+
+    # 查询所有用户
+    query = User.query.order_by(User.user_id.asc())
+    page, users_total, pages = adjust_page_if_needed(query, page, per_page)
+    users = query.paginate(page=page, per_page=per_page, error_out=False)
+
+    current_app.logger.info(
+        f"【获取所有用户成功】total: {users_total}, per_page: {per_page}, page: {page}, pages: {pages}, users: {[user for user in users]}, operator: {current_user}")
+    return jsonify({
+        'users': [user.to_dict() for user in users],
+        'total': users_total,
+        'per_page': per_page,
+        'page': page,
+        'pages': pages,
+    }), 200
+
+
+@user_routes.route('/admin_info', methods=['GET'])
+def get_admin_info():
+    # 查询所有管理员用户
+    admins = User.query.filter(User.role == UserRole.ADMIN, User.status != UserStatus.BANNED,
+                               User.status != UserStatus.DELETED).all()
+
+    # 如果没有管理员,返回 None
+    if not admins:
+        return jsonify({
+            'admin_info': None,
+        }), 200
+
+    # 随机选择一个管理员
+    random_admin = random.choice(admins)
+
+    # 提取管理员的基本信息(只包含必要的联系信息)
+    admin_info = {
+        'username': random_admin.username,
+        'email': random_admin.email,
+        'phone': random_admin.phone,
+        'role': random_admin.role.name,
+        'first_name': random_admin.first_name,
+        'last_name': random_admin.last_name,
+    }
+
+    # 将单个管理员信息放入列表中返回,保持 API 兼容性
+    return jsonify({
+        'admin_info': admin_info,
+    }), 200
+
+
+@user_routes.route('/developer_info', methods=['GET'])
+def get_developer_info():
+    # 查询所有开发人员用户
+    developers = User.query.filter(User.role == UserRole.DEVELOPER, User.status != UserStatus.BANNED,
+                                   User.status != UserStatus.DELETED).all()
+
+    # 如果没有开发人员,返回 None
+    if not developers:
+        return jsonify({
+            'developer_info': None,
+        }), 200
+
+    # 随机选择一个开发人员
+    random_developer = random.choice(developers)
+
+    # 提取开发人员的基本信息(只包含必要的联系信息)
+    developer_info = {
+        'username': random_developer.username,
+        'email': random_developer.email,
+        'phone': random_developer.phone,
+        'role': random_developer.role.name,
+        'first_name': random_developer.first_name,
+        'last_name': random_developer.last_name,
+    }
+
+    # 将单个开发人员信息放入列表中返回,保持 API 兼容性
+    return jsonify({
+        'developer_info': developer_info,
+    }), 200
+
+
+@user_routes.route('/statistics', methods=['GET'])
+@jwt_required()
+@login_required
+def statistics():
+    # 查询用户总数(不包括软删除的用户)
+    total_users = User.query.filter(User.status != UserStatus.DELETED).count()
+
+    # 查询不同角色的用户数量(不包括软删除的用户)
+    admin_users = User.query.filter(User.role == UserRole.ADMIN, User.status != UserStatus.DELETED).count()
+    developer_users = User.query.filter(User.role == UserRole.DEVELOPER, User.status != UserStatus.DELETED).count()
+    normal_users = User.query.filter(User.role == UserRole.USER, User.status != UserStatus.DELETED).count()
+
+    # 构建统计数据
+    users_statistics = {
+        'total': total_users,
+        'admin': admin_users,
+        'developer': developer_users,
+        'user': normal_users,
+    }
+
+    return jsonify({
+        "users_statistics": users_statistics,
+    }), 200

+ 8 - 0
BridgeDiseaseBackend-main/app/utils/__init__.py

@@ -0,0 +1,8 @@
+from .disease_metrics import *
+from .field_check import *
+from .file_util import *
+from .json_util import *
+from .jwt import *
+from .operation_util import *
+from .pagination import *
+from .rate_limiter import *

+ 140 - 0
BridgeDiseaseBackend-main/app/utils/disease_metrics.py

@@ -0,0 +1,140 @@
+import cv2
+import numpy as np
+from flask import current_app
+from skimage.morphology import skeletonize
+
+from app.constants import DiseaseGrade
+
+
+def compute_count(masks):
+    """计算病害数量(通用)"""
+    return masks.shape[0]
+
+
+def compute_perimeter(masks):
+    """计算病害周长(通用)"""
+    contours, _ = cv2.findContours(masks, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
+    total_perimeter = sum(cv2.arcLength(cnt, closed=True) for cnt in contours)
+    return float(total_perimeter)
+
+
+def compute_area(masks):
+    """计算病害面积(通用)"""
+    return float(np.sum(masks))
+
+
+def compute_shape_complexity(perimeter, area):
+    """计算形状复杂度(通用)"""
+    if perimeter == 0:  # 避免除零错误
+        return 0.0
+    return float(1 - (4 * np.pi * area) / (perimeter ** 2))
+
+
+def compute_texture_roughness(masks):
+    """计算纹理粗糙度,使用 Laplacian 变换后的标准差(通用)"""
+    # 将掩码转换为灰度图像(0 和 1 转换到 0 和 255)
+    gray_mask = (masks * 255).astype(np.uint8)
+    # 计算 Laplacian 变换结果
+    laplacian = cv2.Laplacian(gray_mask, cv2.CV_64F)
+    # 计算 Laplacian 值的标准差作为纹理粗糙度
+    texture_roughness = np.std(laplacian)
+    return float(texture_roughness)
+
+
+def compute_crack_width(masks):
+    """计算裂缝宽度(仅针对裂缝)"""
+    # 骨架化
+    skeleton = skeletonize(masks.astype(bool))
+    # 距离变换
+    dist_transform = cv2.distanceTransform((masks * 255).astype(np.uint8), cv2.DIST_L2, 5)
+    crack_widths = dist_transform[skeleton]
+    return float(2 * np.median(crack_widths)) if crack_widths.size > 0 else 0.0
+
+
+def compute_avg_hue(masks, image):
+    """计算平均色调(仅针对锈蚀)"""
+    hsv_image = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
+    hue_values = hsv_image[masks == 1, 0]  # 取病害区域的 hue 通道
+    if hue_values.size == 0:
+        return 0.0
+    H_max = 179  # OpenCV HSV 色调最大值
+    adjusted_hue = H_max - np.median(hue_values)
+    return float(adjusted_hue)
+
+
+def min_max_normalize(value, min_value, max_value):
+    """ 归一化函数,使数据在 0 到 1 之间 """
+    if max_value == min_value:
+        return 0  # 避免除零错误
+    return (value - min_value) / (max_value - min_value)
+
+
+def evaluate_disease_severity(disease_count, disease_perimeter, disease_area, shape_complexity, texture_roughness,
+                              crack_width, avg_hue, media):
+    """
+    根据病害的多个指标,计算病害严重性分数和病害等级(轻度、中度、重度、严重)。
+
+    :param disease_count: 病害数量
+    :param disease_perimeter: 病害周长
+    :param disease_area: 病害面积
+    :param shape_complexity: 形状复杂度
+    :param texture_roughness: 纹理粗糙度
+    :param crack_width: 裂缝宽度
+    :param avg_hue: 平均色调
+    :param media: 媒体
+    :return: 病害严重性分数和病害等级(轻度、中度、重度、严重)
+    """
+
+    # 读取指标权重配置
+    weights = current_app.config['DISEASE_INDEX_WEIGHTS']
+
+    # 读取裂缝缩放因子配置
+    scale_factor = current_app.config['CRACK_SCALA_FACTOR']
+
+    # 动态计算各指标最大值
+    max_disease_count = (media.resolution_width * media.resolution_height * disease_count) // disease_area \
+        if disease_area else 0
+    max_disease_perimeter = float((2 * (media.resolution_width + media.resolution_height)))
+    max_disease_area = float(media.resolution_width * media.resolution_height)
+    max_crack_width = float(min(media.resolution_width, media.resolution_height) * scale_factor)
+    min_max_values = {
+        'disease_count': (0, max_disease_count),
+        'disease_perimeter': (0.0, max_disease_perimeter),
+        'disease_area': (0.0, max_disease_area),
+        'shape_complexity': (0.0, 1.0),
+        'texture_roughness': (0.0, 1020.0),
+        'crack_width': (0.0, max_crack_width),
+        'avg_hue': (0.0, 179.0)
+    }
+    current_app.logger.debug(f'【评估病害】部分动态指标最大值:{min_max_values}')
+
+    # 构造一个参数字典,确保所有需要的键都在其中
+    params = {
+        'disease_count': disease_count,
+        'disease_perimeter': disease_perimeter,
+        'disease_area': disease_area,
+        'shape_complexity': shape_complexity,
+        'texture_roughness': texture_roughness,
+        'crack_width': crack_width,
+        'avg_hue': avg_hue,
+    }
+
+    # 归一化
+    normalized_values = {
+        key: min_max_normalize(params[key], *min_max_values[key])
+        for key in weights.keys()
+    }
+    current_app.logger.debug(f'【评估病害】归一化病害指标:{normalized_values}')
+
+    # 计算加权总分
+    weighted_score = sum(normalized_values[key] * weights[key] for key in weights)
+
+    # 根据得分确定病害等级和描述
+    if weighted_score >= 0.8:
+        return weighted_score, DiseaseGrade.CRITICAL.value, '病害情况严重,需要立即采取修复措施。'
+    elif weighted_score >= 0.5:
+        return weighted_score, DiseaseGrade.SEVERE.value, '病害情况重度,应尽快安排修复。'
+    elif weighted_score >= 0.2:
+        return weighted_score, DiseaseGrade.MODERATE.value, '病害情况中等,应安排维护。'
+    else:
+        return weighted_score, DiseaseGrade.MILD.value, '病害情况轻微,定期观察即可。'

+ 80 - 0
BridgeDiseaseBackend-main/app/utils/field_check.py

@@ -0,0 +1,80 @@
+import re
+
+from flask import current_app
+
+from app import Config
+
+# 读取文件类型配置
+ALLOWED_IMAGE_EXTENSIONS = Config.ALLOWED_IMAGE_EXTENSIONS
+ALLOWED_VIDEO_EXTENSIONS = Config.ALLOWED_VIDEO_EXTENSIONS
+ALLOWED_MODEL_EXTENSIONS = Config.ALLOWED_MODEL_EXTENSIONS
+
+
+def allowed_image_file(file):
+    """
+    检查文件是否为允许的图片类型
+    """
+    return '.' in file.filename and file.filename.rsplit('.', 1)[1].lower() in ALLOWED_IMAGE_EXTENSIONS
+
+
+def allowed_video_file(file):
+    """
+    检查文件是否为允许的视频类型
+    """
+    return '.' in file.filename and file.filename.rsplit('.', 1)[1].lower() in ALLOWED_VIDEO_EXTENSIONS
+
+
+def allowed_model_file(file):
+    """
+    检查文件是否为允许的模型类型
+    """
+    return '.' in file.filename and file.filename.rsplit('.', 1)[1].lower() in ALLOWED_MODEL_EXTENSIONS
+
+
+# 头像文件校验
+def is_valid_avatar_file(avatar_file):
+    """
+    校验头像文件是否合规。
+
+    :param avatar_file: 头像文件
+    :return: 如果合规,返回 True,否则返回 False。
+    """
+    # 读取头像文件配置
+    max_avatar_size = current_app.config['MAX_AVATAR_SIZE'] / (1024 ** 2)  # MB
+
+    # 校验文件类型
+    if not allowed_image_file(avatar_file):
+        return False
+
+    # 校验文件大小
+    avatar_size = len(avatar_file.read()) / (1024 ** 2)  # MB
+    if avatar_size > max_avatar_size:
+        return False
+
+    # 重置文件读取指针
+    avatar_file.seek(0)
+    return True
+
+
+# 邮箱校验
+def is_valid_email(email):
+    """
+    校验邮箱格式是否有效。
+
+    :param email: 邮箱地址
+    :return: 如果邮箱格式正确,返回 True,否则返回 False。
+    """
+    email_regex = r'^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$'
+    return re.match(email_regex, email) is not None
+
+
+# 手机号校验
+def is_valid_phone(phone):
+    """
+    校验手机号格式是否有效。
+
+    :param phone: 手机号码
+    :return: 如果手机号格式正确,返回 True,否则返回 False。
+    """
+    phone_regex = r'^\+?\d{10,15}$'
+    return re.match(phone_regex, phone) is not None

+ 98 - 0
BridgeDiseaseBackend-main/app/utils/file_util.py

@@ -0,0 +1,98 @@
+import os
+from pathlib import Path
+
+import cv2
+from PIL import Image
+from flask import current_app
+from moviepy import VideoFileClip
+from werkzeug.utils import secure_filename
+
+from app import Config
+
+# 读取文件类型配置
+ALLOWED_IMAGE_EXTENSIONS = Config.ALLOWED_IMAGE_EXTENSIONS
+ALLOWED_VIDEO_EXTENSIONS = Config.ALLOWED_VIDEO_EXTENSIONS
+
+
+def handle_file_upload(file, file_location):
+    if not file:
+        current_app.logger.warning(f"{file_location} 文件为空")
+        return None
+
+    folder = current_app.config[f'{file_location.upper()}_FOLDER']  # 动态获取文件夹配置
+
+    # 确保文件夹存在
+    os.makedirs(folder, exist_ok=True)
+
+    # 保存文件
+    filename = secure_filename(file.filename)
+    file_path = os.path.join('static', file_location, filename)  # 存储相对路径
+    file.save(os.path.join(folder, filename))  # 存储文件
+
+    current_app.logger.debug(f"{file_location} 文件已保存:{file_path}")
+    return file_path
+
+
+def get_media_info(file_abs_path):
+    file_type = Path(file_abs_path).suffix[1:].lower()
+
+    file_size = os.path.getsize(file_abs_path) / 1024  # 文件大小(KB)
+    resolution_width = resolution_height = 0  # 默认分辨率为 0
+    frame_count = 1  # 默认是图片,帧数为 1
+
+    if file_type in ALLOWED_IMAGE_EXTENSIONS:  # 图片
+        with Image.open(file_abs_path) as img:
+            resolution_width, resolution_height = img.size
+            frame_count = 1
+    elif file_type in ALLOWED_VIDEO_EXTENSIONS:  # 视频
+        with VideoFileClip(file_abs_path) as video:
+            resolution_width, resolution_height = video.size
+        video = cv2.VideoCapture(file_abs_path)
+        frame_count = int(video.get(cv2.CAP_PROP_FRAME_COUNT))  # 获取视频的帧数
+        video.release()  # 释放视频文件
+    else:
+        current_app.logger.warning(f"获取媒体信息失败,不支持的文件类型:{file_type}")
+
+    return file_size, resolution_width, resolution_height, frame_count
+
+
+def delete_file(file_abs_path):
+    if os.path.exists(file_abs_path):
+        os.remove(file_abs_path)
+        current_app.logger.debug(f"文件已删除:{file_abs_path}")
+    else:
+        current_app.logger.warning(f"文件不存在,无法删除:{file_abs_path}")
+
+
+def unify_result_media_format(media, current_user):
+    original_result_path = os.path.join('static', 'results', current_user.username, os.path.basename(media.media_path))
+
+    # 根据文件类型更改扩展名
+    if media.file_type in ALLOWED_VIDEO_EXTENSIONS:  # 视频文件
+        avi_result_path = os.path.splitext(original_result_path)[0] + '.avi'
+        new_result_path = os.path.splitext(original_result_path)[0] + '.mp4'  # 默认视频格式为 .mp4
+
+        avi_abs_path = os.path.join(current_app.root_path, avi_result_path)
+        new_abs_path = os.path.join(current_app.root_path, new_result_path)
+
+        convert_avi_to_mp4(avi_abs_path, new_abs_path)
+    elif media.file_type in ALLOWED_IMAGE_EXTENSIONS:  # 图片文件
+        new_result_path = os.path.splitext(original_result_path)[0] + '.jpg'  # 默认图片格式为 .jpg
+    else:
+        new_result_path = original_result_path
+
+    return new_result_path
+
+
+def convert_avi_to_mp4(avi_path, mp4_path):
+    # 使用 moviepy 读取 avi 文件
+    video_clip = VideoFileClip(avi_path)
+
+    # 写入 mp4 格式
+    video_clip.write_videofile(mp4_path, codec="libx264")
+
+    # 显式释放资源
+    video_clip.close()
+
+    # 删除原 avi 文件
+    os.remove(avi_path)

+ 19 - 0
BridgeDiseaseBackend-main/app/utils/json_util.py

@@ -0,0 +1,19 @@
+import json
+from datetime import datetime
+from json import JSONEncoder
+
+
+class DateTimeEncoder(JSONEncoder):
+    """
+    自定义 JSON 编码器,用于处理 datetime 对象的序列化
+    
+    在将包含 datetime 对象的数据结构转换为 JSON 字符串时使用此编码器,
+    可以正确处理 datetime 对象,将其转换为 ISO 格式的字符串。
+    
+    示例:
+        json.dumps(data, cls=DateTimeEncoder)
+    """
+    def default(self, obj):
+        if isinstance(obj, datetime):
+            return obj.isoformat()
+        return JSONEncoder.default(self, obj)

+ 17 - 0
BridgeDiseaseBackend-main/app/utils/jwt.py

@@ -0,0 +1,17 @@
+from flask_jwt_extended import JWTManager
+
+
+def init_jwt(app):
+    jwt = JWTManager(app)
+
+    # 处理 refresh token 过期,强制用户登出
+    @jwt.unauthorized_loader
+    def unauthorized_callback(error):
+        # 延迟导入,避免循环导入
+        from app.routes import logout
+
+        # 模拟调用登出接口
+        with app.test_request_context():
+            return logout()
+
+    return jwt

+ 25 - 0
BridgeDiseaseBackend-main/app/utils/operation_util.py

@@ -0,0 +1,25 @@
+import time
+
+from app.constants import OperationStatus
+from app.models import db
+
+
+def handle_operation_success(new_operation, start_time, owner_id=None):
+    new_operation.duration = time.time() - start_time
+    new_operation.status = OperationStatus.SUCCESS
+    new_operation.owner_id = owner_id
+    db.session.add(new_operation)
+    db.session.commit()
+
+    return new_operation
+
+
+def handle_operation_failure(new_operation, start_time, failure_message, owner_id=None):
+    new_operation.duration = time.time() - start_time
+    new_operation.failure_message = failure_message
+    new_operation.status = OperationStatus.FAILURE
+    new_operation.owner_id = owner_id
+    db.session.add(new_operation)
+    db.session.commit()
+
+    return new_operation

+ 16 - 0
BridgeDiseaseBackend-main/app/utils/pagination.py

@@ -0,0 +1,16 @@
+from flask import request
+
+
+def get_pagination_params(default_page=1, default_per_page=5):
+    """通用分页参数获取函数"""
+    page = request.args.get('page', default_page, type=int)
+    per_page = request.args.get('per_page', default_per_page, type=int)
+    return page, per_page
+
+def adjust_page_if_needed(query, page, per_page):
+    """如果请求的页码大于总页数,调整为最后一页"""
+    operations_total = query.count()
+    pages = (operations_total // per_page) + (1 if operations_total % per_page > 0 else 0)
+    if page > pages:
+        page = pages
+    return page, operations_total, pages

+ 197 - 0
BridgeDiseaseBackend-main/app/utils/rate_limiter.py

@@ -0,0 +1,197 @@
+import time
+from collections import defaultdict
+from functools import wraps
+
+from flask import request, jsonify
+
+from app import Config
+
+RATE_LIMIT_DEFAULT_LIMIT = Config.RATE_LIMIT_DEFAULT_LIMIT
+RATE_LIMIT_DEFAULT_PERIOD = Config.RATE_LIMIT_DEFAULT_PERIOD
+
+
+class RateLimiter:
+    """
+    API 限流器类,用于限制 API 请求频率。
+    支持基于 IP、用户 ID 或 API 端点的限流策略。
+    
+    Attributes:
+        storage (dict): 存储请求记录的字典
+        default_limit (int): 默认的请求限制次数
+        default_period (int): 默认的时间窗口(s)
+    """
+
+    def __init__(self):
+        # 使用内存存储请求记录
+        # 格式: {key: [(timestamp1, count1), (timestamp2, count2), ...]}
+        self.storage = defaultdict(list)
+        self.default_limit = RATE_LIMIT_DEFAULT_LIMIT  # 默认每分钟 60 次请求
+        self.default_period = RATE_LIMIT_DEFAULT_PERIOD  # 默认时间窗口为 60 s(1 min)
+
+    def _generate_key(self, key_func):
+        """
+        生成限流的键
+        
+        Args:
+            key_func: 生成键的函数或字符串
+            
+        Returns:
+            str: 限流键
+        """
+        if callable(key_func):
+            return key_func()
+        elif key_func == 'ip':
+            return request.remote_addr
+        elif key_func == 'endpoint':
+            return request.endpoint
+        else:
+            return f"{request.remote_addr}:{request.endpoint}"
+
+    def _clean_old_requests(self, key, period):
+        """
+        清理过期的请求记录
+        
+        Args:
+            key (str): 限流键
+            period (int): 时间窗口(s)
+        """
+        current_time = time.time()
+        self.storage[key] = [(ts, count) for ts, count in self.storage[key]
+                             if current_time - ts < period]
+
+    def is_allowed(self, key_func='ip', limit=None, period=None):
+        """
+        检查请求是否被允许
+        
+        Args:
+            key_func: 生成键的函数或字符串
+            limit (int): 时间窗口内允许的最大请求次数
+            period (int): 时间窗口(s)
+            
+        Returns:
+            tuple: (是否允许, 剩余可用请求数, 重置时间)
+        """
+        limit = limit or self.default_limit
+        period = period or self.default_period
+
+        key = self._generate_key(key_func)
+        self._clean_old_requests(key, period)
+
+        current_time = time.time()
+        request_count = sum(count for _, count in self.storage[key])
+
+        # 如果没有记录或者请求数量未达到限制
+        if not self.storage[key] or request_count < limit:
+            self.storage[key].append((current_time, 1))
+            return True, limit - request_count - 1, period
+
+        # 计算重置时间
+        oldest_timestamp = min(ts for ts, _ in self.storage[key])
+        reset_time = oldest_timestamp + period - current_time
+
+        return False, 0, max(0, reset_time)
+
+
+# 创建全局限流器实例
+rate_limiter = RateLimiter()
+
+
+def rate_limit(key_func='ip', limit=None, period=None):
+    """
+    API限流装饰器
+    
+    Args:
+        key_func: 生成限流键的函数或预定义字符串('ip'或'endpoint')
+        limit (int): 时间窗口内允许的最大请求次数
+        period (int): 时间窗口(s)
+        
+    Returns:
+        function: 装饰器函数
+    """
+
+    def decorator(f):
+        @wraps(f)
+        def decorated_function(*args, **kwargs):
+            allowed, remaining, reset_time = rate_limiter.is_allowed(key_func, limit, period)
+
+            # 设置响应头部,包含限流信息
+            response_headers = {
+                'X-RateLimit-Limit': str(limit or rate_limiter.default_limit),
+                'X-RateLimit-Remaining': str(remaining),
+                'X-RateLimit-Reset': str(int(reset_time))
+            }
+
+            if not allowed:
+                error_response = jsonify({
+                    'error': 'Too Many Requests',
+                    'failure_message': '请求频率超过限制,请稍后再试',
+                    'retry_after': int(reset_time)
+                })
+
+                # 添加响应头部
+                for header, value in response_headers.items():
+                    error_response.headers[header] = value
+
+                error_response.status_code = 429
+                return error_response
+
+            # 执行原始函数
+            response = f(*args, **kwargs)
+
+            # 如果响应是元组 (response, status_code),则只修改 response 部分
+            if isinstance(response, tuple) and len(response) >= 1:
+                response_obj = response[0]
+                if hasattr(response_obj, 'headers'):
+                    for header, value in response_headers.items():
+                        response_obj.headers[header] = value
+                return response
+
+            # 如果响应是直接的响应对象
+            if hasattr(response, 'headers'):
+                for header, value in response_headers.items():
+                    response.headers[header] = value
+
+            return response
+
+        return decorated_function
+
+    return decorator
+
+
+def user_rate_limit(limit=None, period=None):
+    """
+    基于用户ID的限流装饰器,需要在JWT认证之后使用
+    
+    Args:
+        limit (int): 时间窗口内允许的最大请求次数
+        period (int): 时间窗口(秒)
+        
+    Returns:
+        function: 装饰器函数
+    """
+    from flask_jwt_extended import get_jwt_identity
+
+    def get_user_key():
+        try:
+            user_id = get_jwt_identity()
+            return f"user:{user_id}"
+        except Exception:
+            # 如果无法获取用户ID,则回退到IP限流
+            return request.remote_addr
+
+    # 将函数对象传递给rate_limit,而不是立即调用它
+    return rate_limit(get_user_key, limit, period)
+
+
+def configure_rate_limiting(app):
+    """
+    配置应用的全局限流设置
+    
+    Args:
+        app: Flask 应用实例
+    """
+    # 从配置中读取限流设置
+    rate_limiter.default_limit = app.config.get('RATE_LIMIT_DEFAULT_LIMIT', 60)
+    rate_limiter.default_period = app.config.get('RATE_LIMIT_DEFAULT_PERIOD', 60)
+
+    app.logger.info(f"已配置 API 限流: {rate_limiter.default_limit}次/{rate_limiter.default_period}秒")

+ 17 - 0
BridgeDiseaseBackend-main/requirements.txt

@@ -0,0 +1,17 @@
+Flask
+tzdata
+pymysql
+flask_cors
+Flask_JWT_Extended
+flask_sqlalchemy
+moviepy
+numpy
+opencv_python
+Pillow
+scikit-image
+SQLAlchemy
+torch
+torchvision
+torchaudio
+ultralytics
+Werkzeug

+ 14 - 0
BridgeDiseaseBackend-main/run.py

@@ -0,0 +1,14 @@
+import os
+
+from app import create_app
+
+app = create_app()
+
+if __name__ == '__main__':
+    """
+    启动 Flask 应用。当脚本直接执行时,运行 Flask 应用的开发服务器。
+    """
+    app.logger.info('检澜(DockScope)后端服务开始运行……')
+    host = os.environ.get('FLASK_RUN_HOST', '127.0.0.1')
+    port = int(os.environ.get('FLASK_RUN_PORT', '5000'))
+    app.run(host=host, port=port)

+ 129 - 0
BridgeDiseaseBackend-main/scripts/download_real_medias.py

@@ -0,0 +1,129 @@
+# -*- coding: utf-8 -*-
+"""从 Wikimedia Commons / Pexels 下载真实照片到 seed_assets/medias。"""
+import json
+import time
+import urllib.parse
+import urllib.request
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+OUT = ROOT / 'seed_assets' / 'medias'
+OUT.mkdir(parents=True, exist_ok=True)
+
+UA = 'DockScope/1.0 (educational demo)'
+
+# (本地文件名, Wikimedia 文件名 或 None, 备用直链 URL)
+ITEMS = [
+    (
+        '01_concrete_crack_bridge.jpg',
+        'Darmsheim_Brücke03_2010-06-29.jpg',
+        None,
+    ),
+    (
+        '02_bridge_concrete_cracks.jpg',
+        'Darmsheim_Brücke04_2010-06-29.jpg',
+        None,
+    ),
+    (
+        '03_steel_bridge_corrosion.jpg',
+        'Nandu_River_Iron_Bridge_corrosion_-_02.jpg',
+        None,
+    ),
+    (
+        '04_concrete_bending_cracks.jpg',
+        'PHOTO_B_EMC_CemPozz_Feb_13.jpg',
+        None,
+    ),
+    (
+        '05_bridge_substructure.jpg',
+        'I-35W_bridge_structure_before_collapse.jpg',
+        None,
+    ),
+    (
+        '06_shrinkage_cracks_concrete.jpg',
+        'Beton-Schwindrisse.png',
+        None,
+    ),
+    (
+        '07_asphalt_crocodile_cracking.jpg',
+        'Cracked_asphalt.jpg',
+        None,
+    ),
+    (
+        '08_concrete_rebar_corrosion.jpg',
+        'Concrete_bridge_surface_reinforcement_corrosion_due_to_chlorides.jpg',
+        'https://images.pexels.com/photos/2219024/pexels-photo-2219024.jpeg?auto=compress&cs=tinysrgb&w=1600',
+    ),
+    (
+        '09_steel_beam_site.jpg',
+        'Steel_beams.jpg',
+        None,
+    ),
+    (
+        '10_rust_metal_texture.jpg',
+        None,
+        'https://images.pexels.com/photos/1157255/pexels-photo-1157255.jpeg?auto=compress&cs=tinysrgb&w=1600',
+    ),
+]
+
+API = 'https://commons.wikimedia.org/w/api.php'
+
+
+def commons_url(file_name: str) -> str | None:
+    params = urllib.parse.urlencode(
+        {
+            'action': 'query',
+            'titles': f'File:{file_name}',
+            'prop': 'imageinfo',
+            'iiprop': 'url',
+            'format': 'json',
+        },
+        encoding='utf-8',
+    )
+    req = urllib.request.Request(f'{API}?{params}', headers={'User-Agent': UA})
+    with urllib.request.urlopen(req, timeout=60) as resp:
+        data = json.loads(resp.read().decode('utf-8'))
+    for page in data.get('query', {}).get('pages', {}).values():
+        if 'missing' in page:
+            return None
+        info = page.get('imageinfo') or []
+        if info:
+            return info[0].get('url')
+    return None
+
+
+def download(url: str, dest: Path) -> bool:
+    if dest.exists() and dest.stat().st_size > 20_000:
+        print(f'  skip {dest.name} ({dest.stat().st_size // 1024} KB)')
+        return True
+    req = urllib.request.Request(url, headers={'User-Agent': UA})
+    with urllib.request.urlopen(req, timeout=180) as resp:
+        dest.write_bytes(resp.read())
+    print(f'  ok   {dest.name} ({dest.stat().st_size // 1024} KB)')
+    return True
+
+
+def main():
+    for local, wiki, fallback in ITEMS:
+        print(local)
+        url = None
+        if wiki:
+            try:
+                url = commons_url(wiki)
+            except Exception as e:
+                print(f'  api error: {e}')
+        if not url:
+            url = fallback
+        if not url:
+            print('  no url')
+            continue
+        try:
+            download(url, OUT / local)
+        except Exception as e:
+            print(f'  fail: {e}')
+        time.sleep(2.5)
+    print('saved to', OUT)
+
+
+if __name__ == '__main__':
+    main()

+ 80 - 0
BridgeDiseaseBackend-main/scripts/fix_model_texts.py

@@ -0,0 +1,80 @@
+"""修复 model 表 disease_category / augmentation 及 detection 描述中的 ?? 乱码。"""
+import os
+import sys
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+if ROOT not in sys.path:
+    sys.path.insert(0, ROOT)
+
+from app import create_app
+from app.config import mysql_uri_with_utf8mb4
+from app.constants import DiseaseGrade
+from app.models import Detection, Model, db
+
+# 与 sql/init_db.sql 一致
+MODEL_CATEGORIES = {
+    'steel_corrosion.pt': '钢构件锈蚀',
+    'steel_coating_peel_bubble.pt': '钢构件涂层剥脱/鼓包',
+    'concrete_peeling_rebar.pt': '混凝土剥落露筋',
+    'steel_crack.pt': '钢构件裂缝',
+    'concrete_crack.pt': '混凝土裂缝',
+    'concrete_weathering.pt': '混凝土风化',
+    'road_pothole.pt': '路面坑凼',
+}
+
+AUGMENTATION = '随机点+颜色扭曲+高斯模糊'
+
+DESCRIPTIONS = {
+    DiseaseGrade.MILD: '隐患程度较轻,建议纳入日常巡检观察。',
+    DiseaseGrade.MODERATE: '存在中等程度结构隐患,建议安排专项复核与养护。',
+    DiseaseGrade.SEVERE: '隐患较为明显,应尽快组织检测评估并制定处置方案。',
+    DiseaseGrade.CRITICAL: '隐患严重,需立即采取限载或封闭措施并启动应急处置。',
+}
+
+
+def main() -> None:
+    uri = os.environ.get('SQLALCHEMY_DATABASE_URI')
+    if uri:
+        os.environ['SQLALCHEMY_DATABASE_URI'] = mysql_uri_with_utf8mb4(uri)
+
+    app = create_app()
+    with app.app_context():
+        model_updated = 0
+        for model in Model.query.all():
+            category = MODEL_CATEGORIES.get(model.model_name)
+            if not category:
+                print(f'[skip] 未知模型: {model.model_name}')
+                continue
+            changed = False
+            if model.disease_category != category:
+                model.disease_category = category
+                changed = True
+            if model.augmentation != AUGMENTATION:
+                model.augmentation = AUGMENTATION
+                changed = True
+            if changed:
+                model_updated += 1
+                print(f'[model] {model.model_name} -> {category}')
+
+        det_updated = 0
+        for det in Detection.query.all():
+            model = Model.query.get(det.model_id)
+            if not model:
+                continue
+            grade = det.disease_grade
+            if isinstance(grade, str):
+                grade = DiseaseGrade(grade.lower())
+            desc = (
+                f'【{model.disease_category}】{DESCRIPTIONS.get(grade, "暂无评估。")} '
+                f'检出隐患 {det.disease_count} 处,覆盖面积约 {int(det.disease_area)} 像素。'
+            )
+            if det.disease_description != desc:
+                det.disease_description = desc
+                det_updated += 1
+
+        db.session.commit()
+        print(f'完成:更新模型 {model_updated} 条,更新检测描述 {det_updated} 条。')
+
+
+if __name__ == '__main__':
+    main()

+ 47 - 0
BridgeDiseaseBackend-main/scripts/fix_user_names.py

@@ -0,0 +1,47 @@
+"""修复 user 表中因连接未使用 utf8mb4 而变成 ?? 的姓名(与 sql/init_db.sql 种子一致)。"""
+import os
+import sys
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+if ROOT not in sys.path:
+    sys.path.insert(0, ROOT)
+
+from app import create_app
+from app.config import mysql_uri_with_utf8mb4
+from app.models import User, db
+
+# user_id -> (first_name 名字, last_name 姓氏)
+NAMES = {
+    1: ('系统', '管理员'),
+    2: ('开发', '人员'),
+    3: ('演示', '用户'),
+}
+
+
+def main() -> None:
+    uri = os.environ.get('SQLALCHEMY_DATABASE_URI')
+    if uri:
+        os.environ['SQLALCHEMY_DATABASE_URI'] = mysql_uri_with_utf8mb4(uri)
+
+    app = create_app()
+    with app.app_context():
+        updated = 0
+        for user_id, (first_name, last_name) in NAMES.items():
+            user = db.session.get(User, user_id)
+            if not user:
+                continue
+            if user.first_name == first_name and user.last_name == last_name:
+                continue
+            user.first_name = first_name
+            user.last_name = last_name
+            updated += 1
+            print(f'user_id={user_id} ({user.username}): {first_name!r} / {last_name!r}')
+        if updated:
+            db.session.commit()
+            print(f'已更新 {updated} 条用户姓名。')
+        else:
+            print('无需更新(姓名已正确)。')
+
+
+if __name__ == '__main__':
+    main()

+ 229 - 0
BridgeDiseaseBackend-main/scripts/seed_detections.py

@@ -0,0 +1,229 @@
+# -*- coding: utf-8 -*-
+"""插入 20 条桥梁安全隐患检测记录(需已有 user / model / media 种子数据)。"""
+from __future__ import annotations
+
+import os
+import random
+import shutil
+import sys
+from datetime import datetime, timedelta
+from pathlib import Path
+from zoneinfo import ZoneInfo
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+
+os.environ.setdefault(
+    'SQLALCHEMY_DATABASE_URI',
+    'mysql+pymysql://root:bridgedisease_root@127.0.0.1:3307/bridge_disease?charset=utf8mb4',
+)
+
+from app import create_app
+from app.constants import DiseaseGrade, TaskStatus
+from app.models import Detection, Media, Model, User, db
+
+TZ = ZoneInfo('Asia/Shanghai')
+
+# (模型 ID, 隐患数量, 周长, 面积, 形状复杂度, 纹理粗糙度, 裂缝宽度, 色调, 状态)
+# 模型:1锈蚀 2涂层 3剥落露筋 4钢裂缝 5混凝土裂缝 6风化 7坑凼
+SEED_ROWS = [
+  # admin 巡检 7 条
+  (5, 3, 842.5, 12560.0, 0.41, 128.0, 4.2, 42.0, TaskStatus.COMPLETED),
+  (5, 2, 610.0, 9800.0, 0.35, 95.0, 3.1, 38.0, TaskStatus.COMPLETED),
+  (7, 5, 1200.0, 18200.0, 0.52, 210.0, 0.0, 55.0, TaskStatus.COMPLETED),
+  (1, 4, 920.0, 15400.0, 0.48, 165.0, 0.0, 12.0, TaskStatus.COMPLETED),
+  (3, 2, 480.0, 6200.0, 0.29, 88.0, 5.8, 28.0, TaskStatus.COMPLETED),
+  (5, 1, 320.0, 4100.0, 0.22, 62.0, 2.4, 40.0, TaskStatus.COMPLETED),
+  (6, 3, 750.0, 11200.0, 0.38, 142.0, 0.0, 48.0, TaskStatus.COMPLETED),
+  # developer 7 条
+  (5, 4, 1105.0, 16800.0, 0.45, 155.0, 3.8, 36.0, TaskStatus.COMPLETED),
+  (4, 2, 540.0, 8900.0, 0.33, 102.0, 6.1, 15.0, TaskStatus.COMPLETED),
+  (2, 3, 680.0, 9400.0, 0.36, 118.0, 0.0, 22.0, TaskStatus.COMPLETED),
+  (1, 5, 1350.0, 22100.0, 0.58, 198.0, 0.0, 8.0, TaskStatus.COMPLETED),
+  (7, 6, 1580.0, 24500.0, 0.61, 225.0, 0.0, 60.0, TaskStatus.COMPLETED),
+  (3, 1, 210.0, 2800.0, 0.18, 45.0, 4.5, 30.0, TaskStatus.COMPLETED),
+  (5, 2, 590.0, 7600.0, 0.31, 91.0, 2.9, 41.0, TaskStatus.FAILED),
+  # demo 6 条
+  (5, 2, 505.0, 7200.0, 0.27, 78.0, 3.2, 39.0, TaskStatus.COMPLETED),
+  (3, 3, 890.0, 13100.0, 0.42, 136.0, 7.2, 25.0, TaskStatus.COMPLETED),
+  (1, 4, 1020.0, 14900.0, 0.44, 148.0, 0.0, 10.0, TaskStatus.COMPLETED),
+  (6, 2, 430.0, 5500.0, 0.24, 58.0, 0.0, 52.0, TaskStatus.COMPLETED),
+  (4, 1, 180.0, 2200.0, 0.16, 40.0, 5.2, 18.0, TaskStatus.COMPLETED),
+  (7, 4, 990.0, 14200.0, 0.39, 125.0, 0.0, 58.0, TaskStatus.COMPLETED),
+]
+
+# 按媒体名称关键字匹配默认模型(SEED_ROWS 中 None 时回填)
+MEDIA_MODEL_HINTS = [
+    (('锈蚀', '钢'), 1),
+    (('涂层', '剥落', '鼓包'), 2),
+    (('露筋', '剥落'), 3),
+    (('钢', '施工'), 4),
+    (('裂缝', '收缩', '弯曲', '下部'), 5),
+    (('风化',), 6),
+    (('龟裂', '铺装', '坑'), 7),
+]
+
+DESCRIPTIONS = {
+    DiseaseGrade.MILD: '隐患程度较轻,建议纳入日常巡检观察。',
+    DiseaseGrade.MODERATE: '存在中等程度结构隐患,建议安排专项复核与养护。',
+    DiseaseGrade.SEVERE: '隐患较为明显,应尽快组织检测评估并制定处置方案。',
+    DiseaseGrade.CRITICAL: '隐患严重,需立即采取限载或封闭措施并启动应急处置。',
+}
+
+
+def pick_grade(score: float) -> DiseaseGrade:
+    if score >= 0.8:
+        return DiseaseGrade.CRITICAL
+    if score >= 0.5:
+        return DiseaseGrade.SEVERE
+    if score >= 0.2:
+        return DiseaseGrade.MODERATE
+    return DiseaseGrade.MILD
+
+
+def score_from_metrics(count, area, perimeter, complexity) -> float:
+    base = min(1.0, (count * 0.08 + area / 50000 + perimeter / 3000 + complexity * 0.3))
+    return round(max(0.05, min(0.95, base + random.uniform(-0.05, 0.08))), 3)
+
+
+def resolve_model_id(media: Media, hinted: int | None) -> int:
+    if hinted is not None:
+        return hinted
+    name = media.media_name + (media.description or '')
+    for keys, mid in MEDIA_MODEL_HINTS:
+        if any(k in name for k in keys):
+            return mid
+    return 5
+
+
+def ensure_result_image(media: Media, username: str) -> str:
+    """复制媒体图为检测结果占位图,便于列表预览。"""
+    rel = os.path.join('static', 'results', username, Path(media.media_path).name)
+    rel = rel.replace('\\', '/')
+    if rel.lower().endswith('.jpeg'):
+        rel = rel[:-5] + '.jpg'
+    elif not rel.lower().endswith(('.jpg', '.png', '.mp4')):
+        rel = str(Path(rel).with_suffix('.jpg'))
+
+    src = ROOT / 'app' / media.media_path.replace('/', os.sep)
+    dest = ROOT / 'app' / rel.replace('/', os.sep)
+    dest.parent.mkdir(parents=True, exist_ok=True)
+    if src.is_file():
+        shutil.copy2(src, dest)
+    return rel
+
+
+def assign_media_pairs():
+    """为 20 条记录分配互不重复的 (owner_id, media_id)。"""
+    medias = Media.query.order_by(Media.media_id.asc()).all()
+    if len(medias) < 3:
+        print('媒体库为空或过少,请先运行: python scripts/seed_medias.py')
+        sys.exit(1)
+
+    users = [1, 2, 3]
+    pairs: list[tuple[int, Media]] = []
+    for uid in users:
+        for m in medias:
+            pairs.append((uid, m))
+            if len(pairs) >= 20:
+                return pairs[:20]
+    return pairs[:20]
+
+
+def main() -> None:
+    random.seed(20260401)
+    app = create_app()
+    with app.app_context():
+        existing = Detection.query.count()
+        if existing >= 20:
+            print(f'检测记录已有 {existing} 条,跳过(如需重建请先清空 detection 表)。')
+            return
+
+        users = {u.user_id: u for u in User.query.all()}
+        models = {m.model_id: m for m in Model.query.all()}
+        media_pairs = assign_media_pairs()
+
+        if len(SEED_ROWS) != len(media_pairs):
+            print('内部配置条数与媒体分配不一致')
+            sys.exit(1)
+
+        base_time = datetime.now(TZ) - timedelta(days=28)
+        added = 0
+
+        for idx, (pair, row) in enumerate(zip(media_pairs, SEED_ROWS)):
+            owner_id, media = pair
+            (
+                model_id,
+                disease_count,
+                disease_perimeter,
+                disease_area,
+                shape_complexity,
+                texture_roughness,
+                crack_width,
+                avg_hue,
+                status,
+            ) = row
+
+            owner = users.get(owner_id)
+            if not owner:
+                print(f'跳过:用户 {owner_id} 不存在')
+                continue
+
+            if Detection.query.filter_by(owner_id=owner_id, media_id=media.media_id).first():
+                print(f'[skip] 已存在 owner={owner_id} media={media.media_id}')
+                continue
+
+            mid = model_id if model_id in models else resolve_model_id(media, None)
+            severity = score_from_metrics(
+                disease_count, disease_area, disease_perimeter, shape_complexity
+            )
+            grade = pick_grade(severity)
+            model = models[mid]
+
+            t = base_time + timedelta(
+                days=idx % 27,
+                hours=9 + (idx % 8),
+                minutes=(idx * 13) % 60,
+            )
+
+            result_path = (
+                ensure_result_image(media, owner.username) if status == TaskStatus.COMPLETED else None
+            )
+
+            det = Detection(
+                result_path=result_path,
+                disease_count=disease_count,
+                disease_perimeter=round(disease_perimeter, 2),
+                disease_area=round(disease_area, 2),
+                shape_complexity=round(shape_complexity, 3),
+                texture_roughness=round(texture_roughness, 2),
+                crack_width=round(crack_width, 2),
+                avg_hue=round(avg_hue, 2),
+                disease_severity_score=severity,
+                disease_grade=grade,
+                disease_description=(
+                    f'【{model.disease_category}】{DESCRIPTIONS[grade]} '
+                    f'检出隐患 {disease_count} 处,覆盖面积约 {int(disease_area)} 像素。'
+                ),
+                detection_duration=round(1200 + random.uniform(200, 3500), 2),
+                avg_frame_detection_duration=round(800 + random.uniform(50, 400), 2),
+                status=status,
+                detection_at=t,
+                updated_at=t + timedelta(minutes=random.randint(1, 45)),
+                owner_id=owner_id,
+                model_id=mid,
+                media_id=media.media_id,
+            )
+            db.session.add(det)
+            added += 1
+            print(
+                f'[insert] #{added} {owner.username} | {media.media_name} | '
+                f'{model.model_name} | {grade.name} | {status.name}'
+            )
+
+        db.session.commit()
+        print(f'完成:新增 {added} 条检测记录,当前共 {Detection.query.count()} 条。')
+
+
+if __name__ == '__main__':
+    main()

+ 224 - 0
BridgeDiseaseBackend-main/scripts/seed_medias.py

@@ -0,0 +1,224 @@
+# -*- coding: utf-8 -*-
+"""将 seed_assets/medias 中的真实照片导入 static/medias 并同步 media 表。"""
+import os
+import shutil
+import sys
+from pathlib import Path
+
+from PIL import Image
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+
+os.environ.setdefault(
+    'SQLALCHEMY_DATABASE_URI',
+    'mysql+pymysql://root:bridgedisease_root@127.0.0.1:3307/bridge_disease',
+)
+
+from app import create_app
+from app.models import Media, db
+
+ASSETS_DIR = ROOT / 'seed_assets' / 'medias'
+MEDIAS_DIR = ROOT / 'app' / 'static' / 'medias'
+MAX_WIDTH = 1600
+
+# 源文件(seed_assets 内) -> 目标与元数据
+SEED_ITEMS = [
+    {
+        'source': '01_concrete_crack_bridge.jpg',
+        'filename': '01_concrete_crack_bridge.jpg',
+        'media_name': '混凝土桥面裂缝_01.jpg',
+        'description': '桥梁混凝土底板裂缝(德国 Darmsheim 桥,实景)',
+        'owner_id': 1,
+    },
+    {
+        'source': '02_bridge_concrete_cracks.jpg',
+        'filename': '02_bridge_concrete_cracks.jpg',
+        'media_name': '桥梁混凝土结构裂缝_02.jpg',
+        'description': '桥梁混凝土结构多处裂缝(实景)',
+        'owner_id': 2,
+    },
+    {
+        'source': '03_steel_bridge_corrosion.jpg',
+        'filename': '03_steel_bridge_corrosion.jpg',
+        'media_name': '钢构件锈蚀_03.jpg',
+        'description': '金属构件锈蚀与氧化皮(实景)',
+        'owner_id': 2,
+    },
+    {
+        'source': '04_concrete_bending_cracks.jpg',
+        'filename': '04_concrete_bending_cracks.jpg',
+        'media_name': '混凝土弯曲裂缝_04.jpg',
+        'description': '混凝土试件弯曲裂缝(RILEM 三点弯曲试验实景)',
+        'owner_id': 2,
+    },
+    {
+        'source': '05_bridge_substructure.jpg',
+        'filename': '05_bridge_substructure.jpg',
+        'media_name': '桥梁下部结构_05.jpg',
+        'description': '桥梁混凝土底板与下部结构裂缝(实景)',
+        'owner_id': 1,
+        'fallback_source': '01_concrete_crack_bridge.jpg',
+    },
+    {
+        'source': '06_shrinkage_cracks_concrete.jpg',
+        'filename': '06_shrinkage_cracks_concrete.jpg',
+        'media_name': '混凝土收缩裂缝_06.jpg',
+        'description': '钢筋混凝土收缩裂缝(实景)',
+        'owner_id': 3,
+    },
+    {
+        'source': '07_asphalt_crocodile_cracking.jpg',
+        'filename': '07_asphalt_crocodile_cracking.jpg',
+        'media_name': '桥面铺装龟裂_07.jpg',
+        'description': '沥青路面龟裂网状裂缝(实景)',
+        'owner_id': 1,
+    },
+    {
+        'source': '08_concrete_rebar_corrosion.jpg',
+        'filename': '08_concrete_rebar_corrosion.jpg',
+        'media_name': '混凝土钢筋锈蚀_08.jpg',
+        'description': '混凝土结构钢筋锈蚀与表面劣化(实景)',
+        'owner_id': 3,
+    },
+    {
+        'source': '09_steel_beam_site.jpg',
+        'filename': '09_steel_beam_site.jpg',
+        'media_name': '施工现场钢梁_09.jpg',
+        'description': '施工现场钢梁吊装与拼装(实景)',
+        'owner_id': 1,
+    },
+]
+
+LEGACY_NAMES = [
+    'main_span_deck_inspection.png',
+    'steel_box_girder_u_rib.png',
+    'expansion_joint_j03.png',
+    'pier_cap_east_view.png',
+    'bearing_pad_top_view.png',
+    'deck_pavement_drone_01.png',
+    'cable_saddle_interior.png',
+    'parapet_root_seepage.png',
+    'concrete_crack_deck.jpg',
+    'steel_corrosion_girder.jpg',
+    'expansion_joint_spalling.jpg',
+    'steel_beam_construction_site.jpg',
+    'bearing_pad_surface.jpg',
+    'coating_peel_steel.jpg',
+    'concrete_rebar_exposure.jpg',
+    'pavement_distress.jpg',
+    '混凝土桥面裂缝_01.jpg',
+    '钢箱梁锈蚀_02.jpg',
+    '伸缩缝剥落_03.jpg',
+    '施工现场钢梁_04.jpg',
+    '支座垫石开裂_05.jpg',
+    '涂层剥落_06.jpg',
+    '混凝土露筋_07.jpg',
+    '桥面铺装破损_08.jpg',
+]
+
+
+def prepare_image(src: Path, dest: Path) -> None:
+    """压缩并统一为 JPEG(PNG 源文件)。"""
+    dest.parent.mkdir(parents=True, exist_ok=True)
+    with Image.open(src) as im:
+        im = im.convert('RGB')
+        w, h = im.size
+        if w > MAX_WIDTH:
+            nh = int(h * MAX_WIDTH / w)
+            im = im.resize((MAX_WIDTH, nh), Image.Resampling.LANCZOS)
+        out = dest
+        if dest.suffix.lower() == '.png':
+            out = dest.with_suffix('.jpg')
+        im.save(out, format='JPEG', quality=88, optimize=True)
+        if out != dest and dest.exists():
+            dest.unlink()
+        return out
+
+
+def resolve_source(item: dict) -> Path | None:
+    p = ASSETS_DIR / item['source']
+    if p.is_file():
+        return p
+    fb = item.get('fallback_source')
+    if fb:
+        p2 = ASSETS_DIR / fb
+        if p2.is_file():
+            print(f"  [fallback] {item['source']} -> {fb}")
+            return p2
+    return None
+
+
+def main():
+    if not ASSETS_DIR.is_dir():
+        print(f'缺少目录 {ASSETS_DIR},请先运行: python scripts/download_real_medias.py')
+        sys.exit(1)
+
+    MEDIAS_DIR.mkdir(parents=True, exist_ok=True)
+
+    app = create_app()
+    with app.app_context():
+        Media.query.filter(Media.media_name.in_(LEGACY_NAMES)).delete(synchronize_session=False)
+        for legacy in LEGACY_NAMES:
+            p = MEDIAS_DIR / legacy
+            if p.is_file():
+                p.unlink()
+        db.session.commit()
+
+        added, updated = 0, 0
+        for item in SEED_ITEMS:
+            src = resolve_source(item)
+            if not src:
+                print(f'[skip] 缺少源图: {item["source"]}')
+                continue
+
+            dest_name = Path(item['filename'])
+            if dest_name.suffix.lower() == '.png':
+                dest_name = dest_name.with_suffix('.jpg')
+                item = {**item, 'filename': dest_name.name, 'media_name': item['media_name'].replace('.png', '.jpg')}
+
+            dest = MEDIAS_DIR / dest_name.name
+            saved = prepare_image(src, dest)
+            rel_path = f'static/medias/{saved.name}'.replace('\\', '/')
+            abs_path = ROOT / 'app' / rel_path.replace('/', os.sep)
+
+            with Image.open(abs_path) as im:
+                w, h = im.size
+            file_size = os.path.getsize(abs_path) / 1024
+            file_type = 'jpeg'
+
+            existing = Media.query.filter_by(media_name=item['media_name']).first()
+            if existing:
+                existing.media_path = rel_path
+                existing.description = item['description']
+                existing.file_size = round(file_size, 2)
+                existing.file_type = file_type
+                existing.resolution_width = w
+                existing.resolution_height = h
+                existing.frame_count = 1
+                updated += 1
+                print(f'[update] {item["media_name"]}')
+            else:
+                db.session.add(
+                    Media(
+                        media_name=item['media_name'],
+                        media_path=rel_path,
+                        description=item['description'],
+                        file_size=round(file_size, 2),
+                        file_type=file_type,
+                        resolution_width=w,
+                        resolution_height=h,
+                        frame_count=1,
+                        owner_id=item['owner_id'],
+                    )
+                )
+                added += 1
+                print(f'[insert] {item["media_name"]}')
+
+        db.session.commit()
+        print(f'完成:新增 {added},更新 {updated},媒体库共 {Media.query.count()} 条。')
+        print('照片来源说明见 seed_assets/medias/ATTRIBUTION.md')
+
+
+if __name__ == '__main__':
+    main()

BIN
BridgeDiseaseBackend-main/seed_assets/medias/01_concrete_crack_bridge.jpg


BIN
BridgeDiseaseBackend-main/seed_assets/medias/02_bridge_concrete_cracks.jpg


BIN
BridgeDiseaseBackend-main/seed_assets/medias/03_steel_bridge_corrosion.jpg


BIN
BridgeDiseaseBackend-main/seed_assets/medias/04_concrete_bending_cracks.jpg


BIN
BridgeDiseaseBackend-main/seed_assets/medias/05_bridge_substructure.jpg


BIN
BridgeDiseaseBackend-main/seed_assets/medias/06_shrinkage_cracks_concrete.jpg


BIN
BridgeDiseaseBackend-main/seed_assets/medias/07_asphalt_crocodile_cracking.jpg


BIN
BridgeDiseaseBackend-main/seed_assets/medias/08_concrete_rebar_corrosion.jpg


BIN
BridgeDiseaseBackend-main/seed_assets/medias/09_steel_beam_site.jpg


BIN
BridgeDiseaseBackend-main/seed_assets/medias/10_rust_metal_texture.jpg


+ 25 - 0
BridgeDiseaseBackend-main/seed_assets/medias/ATTRIBUTION.md

@@ -0,0 +1,25 @@
+# 媒体库真实照片来源
+
+演示数据来自 **Wikimedia Commons**(CC 许可)与 **Pexels**(免费商用),已压缩后导入 `app/static/medias/`。
+
+| 本地文件 | 内容 | 来源 |
+|----------|------|------|
+| 01_concrete_crack_bridge.jpg | 桥梁混凝土裂缝 | Wikimedia: Darmsheim Brücke03, KlausFoehl, CC BY-SA 3.0 |
+| 02_bridge_concrete_cracks.jpg | 桥梁混凝土裂缝 | Wikimedia: Darmsheim Brücke04, KlausFoehl, CC BY-SA 3.0 |
+| 03_steel_bridge_corrosion.jpg | 金属锈蚀 | Pexels / 实景素材 |
+| 04_concrete_bending_cracks.jpg | 混凝土弯曲裂缝 | Wikimedia: PHOTO_B_EMC_CemPozz_Feb_13.jpg |
+| 05_bridge_substructure.jpg | 桥梁下部混凝土裂缝 | 同 01(Darmsheim 桥底板) |
+| 06_shrinkage_cracks_concrete.jpg | 混凝土收缩裂缝 | Wikimedia: Beton-Schwindrisse.png |
+| 07_asphalt_crocodile_cracking.jpg | 沥青路面开裂 | Wikimedia: Cracked_asphalt.jpg |
+| 08_concrete_rebar_corrosion.jpg | 混凝土/钢筋劣化 | Pexels |
+| 09_steel_beam_site.jpg | 施工现场钢梁 | Wikimedia: Steel_beams.jpg |
+
+## 命令
+
+```powershell
+# 1. 下载到 seed_assets(需联网,约 1 分钟)
+python scripts/download_real_medias.py
+
+# 2. 写入数据库与 static/medias
+python scripts/seed_medias.py
+```

+ 33 - 0
BridgeDiseaseBackend-main/sql/docker_patch_admin_login.sql

@@ -0,0 +1,33 @@
+-- 将管理员改为 README 默认账号(适配当前库中 role/status 为大写枚举)
+USE bridge_disease;
+
+UPDATE `user` SET
+  `username` = 'admin',
+  `email` = 'admin@example.com',
+  `password` = 'scrypt:32768:8:1$D3KfZgegKywPsVOR$9a87dafc3391fb8339e79b1b8c433b073d31dc63e9407dcc09a6cffa118582b0a5334cd07d71d18c924a59faf14e727507f75bc881176459c853d1f2190a0445',
+  `first_name` = '系统',
+  `last_name` = '管理员',
+  `role` = 'ADMIN',
+  `status` = 'INACTIVE'
+WHERE `user_id` = 1;
+
+-- 可选:第二账号改为 developer 便于模型 owner_id=2
+UPDATE `user` SET
+  `username` = 'developer',
+  `email` = 'dev@example.com',
+  `password` = 'scrypt:32768:8:1$UtMwYLX1KSnmu6lk$3d7404f212eda78413ccab1c8a20a1836306d169cda9f5da635f92b3f3bd1c7649246d30feef87e093bb49508441eca5218c1bcec3638d33e0d68640732bc331',
+  `first_name` = '开发',
+  `last_name` = '人员',
+  `role` = 'DEVELOPER',
+  `status` = 'INACTIVE'
+WHERE `user_id` = 2;
+
+UPDATE `user` SET
+  `username` = 'demo',
+  `email` = 'user@example.com',
+  `password` = 'scrypt:32768:8:1$mWhiEV1orl2PKEmM$2a43af98b53f934dfec2841394dd5e5b40bd913b973f0c8b0aba7929a16e215f7aa03001378ea8c23a5d575f992a377eafa0789f19f6359d3d5a876ba973de84',
+  `first_name` = '演示',
+  `last_name` = '用户',
+  `role` = 'USER',
+  `status` = 'INACTIVE'
+WHERE `user_id` = 3;

+ 417 - 0
BridgeDiseaseBackend-main/sql/init_db.sql

@@ -0,0 +1,417 @@
+-- 初始化管理员、开发人员、普通用户
+-- 创建时间:2025-03-14 10:50:08
+
+-- 添加管理员用户
+INSERT INTO `user` (
+    `username`, 
+    `email`, 
+    `password`, 
+    `first_name`, 
+    `last_name`, 
+    `role`, 
+    `avatar_path`, 
+    `phone`, 
+    `status`, 
+    `created_at`, 
+    `updated_at`
+) VALUES (
+    'admin', 
+    'admin@example.com', 
+    'scrypt:32768:8:1$D3KfZgegKywPsVOR$9a87dafc3391fb8339e79b1b8c433b073d31dc63e9407dcc09a6cffa118582b0a5334cd07d71d18c924a59faf14e727507f75bc881176459c853d1f2190a0445', -- 密码:Admin123456(上线前务必修改)
+    '系统', 
+    '管理员', 
+    'admin',
+    'static\\avatars\\1.jpg',
+    '10000000000', 
+    'inactive', 
+    NOW(), 
+    NOW()
+);
+
+-- 开发人员(owner_id=2,与下方模型种子一致)
+INSERT INTO `user` (
+    `username`, 
+    `email`, 
+    `password`, 
+    `first_name`, 
+    `last_name`, 
+    `role`, 
+    `avatar_path`, 
+    `phone`, 
+    `status`, 
+    `created_at`, 
+    `updated_at`
+) VALUES (
+    'developer',
+    'dev@example.com',
+    'scrypt:32768:8:1$UtMwYLX1KSnmu6lk$3d7404f212eda78413ccab1c8a20a1836306d169cda9f5da635f92b3f3bd1c7649246d30feef87e093bb49508441eca5218c1bcec3638d33e0d68640732bc331', -- 密码:developer-WZY
+    '开发',
+    '人员',
+    'developer',
+    'static\\avatars\\2.jpg',
+    '10000000001',
+    'inactive',
+    NOW(),
+    NOW()
+);
+
+-- 普通用户
+INSERT INTO `user` (
+    `username`, 
+    `email`, 
+    `password`, 
+    `first_name`, 
+    `last_name`, 
+    `role`, 
+    `avatar_path`, 
+    `phone`, 
+    `status`, 
+    `created_at`, 
+    `updated_at`
+) VALUES (
+    'demo',
+    'user@example.com',
+    'scrypt:32768:8:1$mWhiEV1orl2PKEmM$2a43af98b53f934dfec2841394dd5e5b40bd913b973f0c8b0aba7929a16e215f7aa03001378ea8c23a5d575f992a377eafa0789f19f6359d3d5a876ba973de84', -- 密码:user-WZY
+    '演示',
+    '用户',
+    'user',
+    'static\\avatars\\3.jpg',
+    '10000000002',
+    'inactive',
+    NOW(),
+    NOW()
+);
+
+
+
+-- 注意:
+-- 1. 密码字段使用了 werkzeug.security.generate_password_hash 生成的哈希值
+-- 2. 实际使用时,建议修改密码为更安全的值
+-- 3. 执行此 SQL 前,请确保 user 表已经创建
+-- 4. 此 SQL 适用于 MySQL 数据库
+
+-- 初始化模型
+-- 创建时间:2025-04-16 15:00:45
+
+-- 添加钢构件锈蚀模型
+INSERT INTO `model` (
+    `model_name`,
+    `model_path`,
+    `disease_category`,
+    `augmentation`,
+    `layers`,
+    `parameters`,
+    `GFLOPs`,
+    `box_p`,
+    `box_r`,
+    `box_mAP50`,
+    `box_mAP50_95`,
+    `mask_p`,
+    `mask_r`,
+    `mask_mAP50`,
+    `mask_mAP50_95`,
+    `f1_score`,
+    `fitness_score`,
+    `created_at`,
+    `updated_at`,
+    `owner_id`
+) VALUES (
+    'steel_corrosion.pt',
+    'static\\models\\steel_corrosion.pt',
+    '钢构件锈蚀',
+    '随机点+颜色扭曲+高斯模糊',
+    113,
+    10070299,
+    35.3,
+    0.972,
+    0.925,
+    0.966,
+    0.878,
+    0.973,
+    0.919,
+    0.956,
+    0.66,
+    1.8931,
+    1.57629,
+    NOW(),
+    NOW(),
+    2
+);
+
+-- 添加钢构件涂层剥脱/鼓包模型
+INSERT INTO `model` (
+    `model_name`,
+    `model_path`,
+    `disease_category`,
+    `augmentation`,
+    `layers`,
+    `parameters`,
+    `GFLOPs`,
+    `box_p`,
+    `box_r`,
+    `box_mAP50`,
+    `box_mAP50_95`,
+    `mask_p`,
+    `mask_r`,
+    `mask_mAP50`,
+    `mask_mAP50_95`,
+    `f1_score`,
+    `fitness_score`,
+    `created_at`,
+    `updated_at`,
+    `owner_id`
+) VALUES (
+    'steel_coating_peel_bubble.pt',
+    'static\\models\\steel_coating_peel_bubble.pt',
+    '钢构件涂层剥脱/鼓包',
+    '随机点+颜色扭曲+高斯模糊',
+    113,
+    10070299,
+    35.3,
+    0.832,
+    0.803,
+    0.844,
+    0.661,
+    0.831,
+    0.759,
+    0.799,
+    0.473,
+    1.6103,
+    1.18434,
+    NOW(),
+    NOW(),
+    2
+);
+
+-- 添加混凝土剥落露筋模型
+INSERT INTO `model` (
+    `model_name`,
+    `model_path`,
+    `disease_category`,
+    `augmentation`,
+    `layers`,
+    `parameters`,
+    `GFLOPs`,
+    `box_p`,
+    `box_r`,
+    `box_mAP50`,
+    `box_mAP50_95`,
+    `mask_p`,
+    `mask_r`,
+    `mask_mAP50`,
+    `mask_mAP50_95`,
+    `f1_score`,
+    `fitness_score`,
+    `created_at`,
+    `updated_at`,
+    `owner_id`
+) VALUES (
+    'concrete_peeling_rebar.pt',
+    'static\\models\\concrete_peeling_rebar.pt',
+    '混凝土剥落露筋',
+    '随机点+颜色扭曲+高斯模糊',
+    113,
+    10070299,
+    35.3,
+    0.924,
+    0.76,
+    0.851,
+    0.655,
+    0.884,
+    0.731,
+    0.803,
+    0.475,
+    1.6338,
+    1.18312,
+    NOW(),
+    NOW(),
+    2
+);
+
+-- 添加钢构件裂缝模型
+INSERT INTO `model` (
+    `model_name`,
+    `model_path`,
+    `disease_category`,
+    `augmentation`,
+    `layers`,
+    `parameters`,
+    `GFLOPs`,
+    `box_p`,
+    `box_r`,
+    `box_mAP50`,
+    `box_mAP50_95`,
+    `mask_p`,
+    `mask_r`,
+    `mask_mAP50`,
+    `mask_mAP50_95`,
+    `f1_score`,
+    `fitness_score`,
+    `created_at`,
+    `updated_at`,
+    `owner_id`
+) VALUES (
+    'steel_crack.pt',
+    'static\\models\\steel_crack.pt',
+    '钢构件裂缝',
+    '随机点+颜色扭曲+高斯模糊',
+    113,
+    10070299,
+    35.3,
+    0.805,
+    0.768,
+    0.778,
+    0.608,
+    0.646,
+    0.546,
+    0.489,
+    0.197,
+    1.378,
+    0.85097,
+    NOW(),
+    NOW(),
+    2
+);
+
+-- 添加混凝土裂缝模型
+INSERT INTO `model` (
+    `model_name`,
+    `model_path`,
+    `disease_category`,
+    `augmentation`,
+    `layers`,
+    `parameters`,
+    `GFLOPs`,
+    `box_p`,
+    `box_r`,
+    `box_mAP50`,
+    `box_mAP50_95`,
+    `mask_p`,
+    `mask_r`,
+    `mask_mAP50`,
+    `mask_mAP50_95`,
+    `f1_score`,
+    `fitness_score`,
+    `created_at`,
+    `updated_at`,
+    `owner_id`
+) VALUES (
+    'concrete_crack.pt',
+    'static\\models\\concrete_crack.pt',
+    '混凝土裂缝',
+    '随机点+颜色扭曲+高斯模糊',
+    113,
+    10070299,
+    35.3,
+    0.909,
+    0.89,
+    0.941,
+    0.771,
+    0.759,
+    0.628,
+    0.615,
+    0.197,
+    1.5863,
+    1.02686,
+    NOW(),
+    NOW(),
+    2
+);
+
+-- 添加混凝土风化模型
+INSERT INTO `model` (
+    `model_name`,
+    `model_path`,
+    `disease_category`,
+    `augmentation`,
+    `layers`,
+    `parameters`,
+    `GFLOPs`,
+    `box_p`,
+    `box_r`,
+    `box_mAP50`,
+    `box_mAP50_95`,
+    `mask_p`,
+    `mask_r`,
+    `mask_mAP50`,
+    `mask_mAP50_95`,
+    `f1_score`,
+    `fitness_score`,
+    `created_at`,
+    `updated_at`,
+    `owner_id`
+) VALUES (
+    'concrete_weathering.pt',
+    'static\\models\\concrete_weathering.pt',
+    '混凝土风化',
+    '随机点+颜色扭曲+高斯模糊',
+    113,
+    10070299,
+    35.3,
+    0.884,
+    0.737,
+    0.804,
+    0.634,
+    0.87,
+    0.72,
+    0.782,
+    0.476,
+    1.5919,
+    1.15776,
+    NOW(),
+    NOW(),
+    2
+);
+
+-- 添加路面坑凼模型
+INSERT INTO `model` (
+    `model_name`,
+    `model_path`,
+    `disease_category`,
+    `augmentation`,
+    `layers`,
+    `parameters`,
+    `GFLOPs`,
+    `box_p`,
+    `box_r`,
+    `box_mAP50`,
+    `box_mAP50_95`,
+    `mask_p`,
+    `mask_r`,
+    `mask_mAP50`,
+    `mask_mAP50_95`,
+    `f1_score`,
+    `fitness_score`,
+    `created_at`,
+    `updated_at`,
+    `owner_id`
+) VALUES (
+    'road_pothole.pt',
+    'static\\models\\road_pothole.pt',
+    '路面坑凼',
+    '随机点+颜色扭曲+高斯模糊',
+    113,
+    10070299,
+    35.3,
+    0.954,
+    0.935,
+    0.973,
+    0.809,
+    0.958,
+    0.931,
+    0.973,
+    0.735,
+    1.8882,
+    1.58462,
+    NOW(),
+    NOW(),
+    2
+);
+
+-- 注意:
+-- 1. 执行此 SQL 前,请确保 model 表已经创建
+-- 2. 此 SQL 适用于 MySQL 数据库
+-- 3. 所有模型均属于开发人员用户(user_id = 2)
+
+-- 初始化媒体库(需先执行 scripts/seed_medias.py 复制 static/medias 下的示例文件)
+-- 若仅导入 SQL 而无物理文件,列表接口会自动清理无效记录

+ 187 - 128
README.md

@@ -1,66 +1,97 @@
-# 康桥 -  检澜(DockScope)
+# 检澜(DockScope)
 
-** 康桥  桥梁检测     检澜 DockScope** 是面向**桥梁结构缺陷智能分析**的一体化工作台:围绕「媒体 / 模型 / 检测任务 / 结果与指标」闭环,提供 Web 端界面与 REST API。本仓库即检澜的**前后端源码**——前端为 **Vue 3 + Element Plus + ECharts**(`bridge-disease-frontend-main`);后端 **Flask + SQLAlchemy + MySQL**(`BridgeDiseaseBackend-main`),检测分割管线使用 **Ultralytics YOLO** 等完成推理与结果落盘
+**检澜 DockScope** 是面向**桥梁安全隐患智能检测**的一体化工作台:围绕「媒体 / 模型 / 检测任务 / 结果与指标 / 台账与报告」闭环,提供 Web 端界面与 REST API。本仓库为检澜**前后端源码**——前端 **Vue 3 + Element Plus + ECharts**(`bridge-disease-frontend-main`);后端 **Flask + SQLAlchemy + MySQL**(`BridgeDiseaseBackend-main`),检测推理基于 **Ultralytics YOLO**,对影像中的结构隐患区域进行识别、标注与量化评估
 
 ![检澜 界面](images/0数据看板.png)
 
+---
+
 ## 检澜核心功能
 
 ### 账号与权限
 
 - **注册 / 登录 / 忘记密码**:表单提交至后端,成功后下发 **JWT**(Access + Refresh),前端持久化于 `localStorage` 并在 Axios 拦截器中自动携带 `Authorization: Bearer …`。
-- **个人中心**:查看与维护当前用户资料、头像等(具体字段以后端 `User` 模型与路由为准)
-- **角色模型**:`ADMIN`(管理员)、`DEVELOPER`(开发人员)、`USER`(普通用户)。部分菜单与列表接口仅对管理员或开发开放(见下节「角色与界面」)。
+- **个人中心**:查看与维护当前用户资料、头像等。
+- **角色模型**:`ADMIN`(管理员)、`DEVELOPER`(开发人员)、`USER`(普通用户)。部分菜单与列表接口按角色区分(见下节「角色与界面」)。
 
 ### 数据看板(`/home`)
 
-- 登录后的默认工作台:侧栏导航、面包屑、**数据洞察**与**社群快捷入口**等壳层组件,以及基于 ECharts 的**历史统计图表**(`StatisticsCharts`),用于总览业务数据趋势(依赖后端统计数据接口)。
+- 登录后的默认工作台:侧栏导航、面包屑、**数据洞察**与**社群快捷入口**等壳层组件,以及基于 ECharts 的**历史统计图表**(`StatisticsCharts`)。
+
+### 桥梁安全隐患检测(`/disease-detection`)
+
+- 三步向导:选择 **检测模型**(`.pt`)→ 选择 **媒体**(图像或视频)→ 发起 **安全隐患检测**。
+- 后端调用 YOLO 推理,生成**标注结果图**并计算隐患指标(数量、周长、面积、形状复杂度、纹理粗糙度、裂缝宽度、平均色调等),综合得到**严重度得分与隐患等级**(轻 / 中 / 重 / 严重)。
+- 支持任务状态(待处理、检测中、已完成、失败)与实时进度展示。
+
+### 安全隐患检测记录(`/detection-records`)
 
-### 病害检测分割(`/disease-detection`)
+- 分页列表:模型名、媒体名、任务状态、严重度、隐患等级、检测标注结果、检测时间、所属用户等。
+- 支持筛选、导出 Excel、详情对话框查看指标明细与耗时。
+- 管理员 / 开发人员可查看**全量**记录;普通用户通常仅能看到**本人**记录。
 
-- 选择已上传的 **模型**(`.pt`)与 **媒体**(图像或视频),发起一次检测分割任务。
-- 后端创建或更新 `Detection` 记录,加载 YOLO 权重与媒体文件,执行推理后将**结果图/掩码**等写入配置的 `RESULTS_FOLDER`,并计算多项**病害形态与严重度指标**(数量、周长、面积、形状复杂度、纹理粗糙度、裂缝宽度、平均色调等),综合得到**严重度分数与等级**(轻 / 中 / 重 / 危急等枚举)。
-- 支持任务状态流转(如待处理、进行中、已完成、失败),便于列表筛选与详情展示。
+### 批量安全隐患检测(`/batch-detection`)
 
-### 检测分割记录(`/detection-records`)
+- 一次选择模型与多条媒体,以队列方式模拟批量检测任务(开发演示)。
+- 支持自动 / 手动推进进度,完成后可将结果写入**安全隐患台账**。
+- 数据持久化于浏览器 `localStorage`(`dockscope_professional_v1`),**非后端 API**。
 
-- 分页列表:模型名、媒体名、状态、严重度、等级、结果路径、媒体类型、更新时间、所有者等。
-- 条件筛选与**详情对话框**:查看单次任务的指标明细与耗时(总耗时、帧均耗时等)。
-- 管理员 / 开发人员可查看**全量**记录;普通用户通常仅能看到**本人**相关记录(以后端分页接口逻辑为准)。
+### 安全隐患台账(`/defect-ledger`)
+
+- 汇聚 AI 检测与批量任务发现的隐患,支持复核、确认、处置与销项。
+- 可从检测记录导入、按状态 / 等级筛选。
+- 数据同样为前端 `localStorage` 演示,便于产品演示与流程验证。
+
+### 报告中心(`/report-center`)
+
+- 巡检周报、专项检测报告、监测预警汇总等模板,生成记录并导出 **Excel**。
+- 报告数据基于上述前端专业模块 store 汇总,**非后端持久化**。
 
 ### 媒体库(`/media-library`)
 
-- 上传与管理检澜业务中的巡检**图片**(如 png/jpg/jpeg)与**视频**(mp4),写入服务器静态目录并在数据库中登记路径与元数据。
-- 列表预览通过 **`/file/...`** 静态访问路径拼接媒体相对路径(需后端可访问对应文件)。
+- 上传与管理巡检**图片**(png/jpg/jpeg)与**视频**(mp4),写入服务器静态目录并在数据库登记元数据。
+- 列表预览通过 **`/file/...`** 访问(需后端可访问对应文件)。
+- 可使用 `scripts/seed_medias.py` 导入真实桥梁照片种子(见「数据种子脚本」)。
 
 ### 模型库(`/model-library`)
 
-- 上传与管理 **YOLO 权重**(`.pt`),供检测页下拉选用。
-- 侧栏中该菜单**仅对 `DEVELOPER` 角色可见**(`visible: isDeveloper`);`ADMIN` 与普通用户**不展示**该入口(见 `SidebarMenu.vue`)。
+- 上传与管理 **YOLO 权重**(`.pt`),供检测页选用。
+- 侧栏**仅对 `DEVELOPER` 可见**;`ADMIN` 与普通用户不展示该入口。
+
+### 物联网监测(前端演示)
+
+以下四个模块共用 `localStorage`(`dockscope_iot_monitoring_v1`),用于展示监测业务界面,**未接后端**:
+
+| 路由 | 功能 |
+|------|------|
+| `/sensor-management` | 传感器登记、阈值与在线状态 |
+| `/data-collection` | 采集任务与原始数据浏览 |
+| `/data-processing` | 清洗、聚合与特征处理 |
+| `/alert-management` | 预警规则、分级与处置 |
 
 ### 用户管理(`/user-management`)
 
-- 面向 **管理员或开发人员**:用户列表、状态与角色相关维护能力(具体能力以后端 `user_route` 为准)。
+- 面向 **管理员或开发人员**:用户列表、状态与角色维护。
 
 ### 系统操作日志(`/operation-logs`)
 
-- 面向 **管理员或开发人员**:审计类操作记录(登录、执行检测、失败原因等),与业务上的 `Operation` 实体对应,便于排障与合规留痕。
+- 面向 **管理员或开发人员**:登录、检测执行、失败原因等审计记录(`Operation` 实体)
 
 ### 开发辅助:列表 Mock
 
-- 当前端设置 `VITE_USE_LIST_MOCK=true` 时,**检测分割记录**与**媒体库**列表可走本地 `src/mocks/detectionAndMediaMockData.js` 分页数据,无需后端即可调试表格布局(预览图仍依赖 `/file/...` 时可能裂图)
+- 当前端设置 `VITE_USE_LIST_MOCK=true` 时,**安全隐患检测记录**与**媒体库**列表可走本地 `src/mocks/detectionAndMediaMockData.js`,无需后端即可调试表格布局。
 
 ---
 
 ## 角色与界面
 
-| 角色 | 典型可见菜单(与当前侧栏逻辑一致) |
-|------|--------------------------------------|
-| `USER` | 数据看板、病害检测分割、检测分割记录、媒体库 |
+| 角色 | 典型可见菜单 |
+|------|----------------|
+| `USER` | 数据看板、桥梁安全隐患检测、安全隐患检测记录、批量检测、安全隐患台账、报告中心、媒体库、物联网监测四模块 |
 | `DEVELOPER` | 上述 + **模型库** + 用户管理 + 系统操作日志 |
-| `ADMIN` | 数据看板、病害检测分割、检测分割记录、媒体库 + 用户管理 + 系统操作日志(**无模型库入口**) |
+| `ADMIN` | 与 `USER` 相同业务菜单 + 用户管理 + 系统操作日志(**无模型库**) |
 
-路由定义见 `bridge-disease-frontend-main/src/router/index.js`;菜单过滤见 `src/components/SidebarMenu.vue`。
+路由见 `bridge-disease-frontend-main/src/router/index.js`;菜单见 `src/components/SidebarMenu.vue`。产品对外文案常量见 `src/shellConstants.js`(副标题:**桥梁安全隐患智能检测工作台**)。
 
 ---
 
@@ -78,7 +109,7 @@ flowchart LR
     API[检澜 API Flask :5000]
   end
   subgraph Data[数据层]
-    MySQL[(MySQL 8)]
+    MySQL[(MySQL 8 utf8mb4)]
     FS[本地静态目录\nmedias / models / results / avatars]
   end
   SPA -->|页面与 REST JSON| NG
@@ -88,35 +119,27 @@ flowchart LR
   API --> FS
 ```
 
-说明:
-
-- **Docker 生产形态**:前端为构建后的静态资源,由容器内 **Nginx** 提供;浏览器根据构建期注入的 `VITE_API_BASE_URL` 直接请求后端(常见为宿主机 `http://127.0.0.1:5000`)。
-- **Vite 开发形态**:前端开发服务器与后端分离,通过环境变量将 API 指到本机 Flask。
-
 ### 逻辑分层
 
-| 层级 | 前端(`bridge-disease-frontend-main`) | 后端(`BridgeDiseaseBackend-main`) |
-|------|----------------------------------------|-------------------------------------|
-| 表现层 | `views/*.vue`、`components/**` | Flask 路由函数(各 `*_route.py`) |
-| 应用状态 | `stores/*`(用户、侧栏、资源列表等) | JWT 身份、`Operation` 记录、限流状态 |
-| 领域与数据 | Axios `src/utils/request.js` | SQLAlchemy **Models**(`User`、`Media`、`Model`、`Detection`、`Operation`) |
-| 基础设施 | Vue Router、Vite、ECharts | `app/config.py` 路径与上传限制、`app/utils/*`(分页、指标、文件等) |
+| 层级 | 前端 | 后端 |
+|------|------|------|
+| 表现层 | `views/*.vue`、`components/**` | Flask 路由(各 `*_route.py`) |
+| 应用状态 | `stores/*`(用户、资源列表、IoT、专业模块等) | JWT、`Operation`、限流 |
+| 领域与数据 | Axios `src/utils/request.js` | SQLAlchemy Models(`User`、`Media`、`Model`、`Detection`、`Operation`) |
 | 推理 | — | `detection_route` 中 **Ultralytics YOLO**、OpenCV / Pillow 等 |
 
-应用工厂与横切能力见 `app/__init__.py`:**CORS**、**JWT**、**数据库初始化**、**蓝图注册**、**全局限流**、**统一错误处理**、日志落盘到 `logs/`。
-
 ### 后端 API 蓝图前缀
 
 | URL 前缀 | 职责概要 |
 |----------|-----------|
-| `/user` | 注册、登录、令牌刷新、用户资料与权限相关接口 |
+| `/user` | 注册、登录、令牌刷新、用户资料 |
 | `/media` | 媒体上传与分页列表 |
 | `/model` | 模型上传与分页列表 |
-| `/detection` | 发起检测分割、检测记录分页查询等 |
+| `/detection` | 发起安全隐患检测、检测记录分页查询等 |
 | `/operation` | 操作日志分页(高权限) |
 | `/file` | 静态文件访问(头像、媒体、结果图等) |
 
-### 检测任务数据流(简图)
+### 检测任务数据流
 
 ```mermaid
 sequenceDiagram
@@ -143,29 +166,29 @@ sequenceDiagram
 
 | 路径 | 说明 |
 |------|------|
-| `bridge-disease-frontend-main/` | **检澜** Web 前端(Vite + Vue 3) |
-| `BridgeDiseaseBackend-main/` | **检澜** API 与推理服务(Flask、模型与工具、`sql/init_db.sql`) |
-| `docker/` | 镜像构建、Nginx 配置、后端入口与 DB bootstrap |
-| `docker-compose.yml` | MySQL、后端、前端编排(镜像由根脚本构建,见下) |
-| `images/` | **界面截图**(README 下图集引用,文件名多为导出时的 UUID) |
-| `.env` | Compose 与构建变量示例 |
-| `package.json` | 根目录:`npm run up` / `npm run down` |
+| `bridge-disease-frontend-main/` | 检澜 Web 前端(Vite + Vue 3) |
+| `BridgeDiseaseBackend-main/` | 检澜 API 与推理服务 |
+| `BridgeDiseaseBackend-main/scripts/` | 媒体/检测种子、编码修复等运维脚本 |
+| `BridgeDiseaseBackend-main/seed_assets/` | 真实照片源文件(媒体种子) |
+| `BridgeDiseaseBackend-main/sql/` | `init_db.sql`、Docker 补丁 SQL |
+| `docker/` | 镜像构建、Nginx、后端 bootstrap |
+| `docker-compose.yml` | MySQL、后端、前端编排 |
+| `images/` | README 界面截图 |
+| `package.json` | 根目录 `npm run up` / `npm run down` |
 
 ---
 
 ## 技术栈概览
 
-- **前端**:Vue 3、Vue Router、Axios、Element Plus、ECharts、Vite 6  
-- **后端**:Flask、Flask-JWT-Extended、Flask-CORS、SQLAlchemy、PyMySQL  
-- **推理与影像**:OpenCV、Ultralytics、PyTorch、Pillow、scikit-image、moviepy 等(完整列表见 `BridgeDiseaseBackend-main/requirements.txt`)  
-- **数据库**:MySQL 8  
+- **前端**:Vue 3、Vue Router、Axios、Element Plus、ECharts、Vite 6
+- **后端**:Flask、Flask-JWT-Extended、Flask-CORS、SQLAlchemy、PyMySQL
+- **推理与影像**:OpenCV、Ultralytics、PyTorch、Pillow、scikit-image、moviepy 等(见 `BridgeDiseaseBackend-main/requirements.txt`)
+- **数据库**:MySQL 8(建议 `utf8mb4`)
 
 ---
 
 ## 检澜界面截图
 
-以下为 **`images/`** 目录中的 **检澜(DockScope)** 实际界面截图(按文件名排序)。若需与具体页面一一对应,可将文件重命名为可读名称并同步修改下列路径。
-
 ![检澜 界面 0](images/0数据看板.png)
 
 ![检澜 界面 1](images/1桥梁安全隐患检测.png)
@@ -194,146 +217,182 @@ sequenceDiagram
 
 ![检澜 界面 10](images/10系统操作日志.png)
 
-
 ---
 
-## 启动检澜:Docker 一键堆栈(建议)
+## 启动检澜
 
-在**项目根目录**执行(需已安装 [Docker](https://docs.docker.com/get-docker/) 与 [Node.js](https://nodejs.org/),用于执行根目录脚本):
+### 方式 A:本地开发(推荐,启动快)
 
-```bash
-npm install
-npm run up
-```
+完整 Docker 堆栈需构建 PyTorch/CUDA 镜像,在 Windows 上可能极慢。日常开发建议 **仅 Docker 跑 MySQL**,本机启动前后端。
 
-此流程会:
+**1. MySQL(Docker)**
 
-1. 以 `DOCKER_BUILDKIT=0` 构建镜像(降低在**含中文路径**的 Windows 环境下,Compose Bake / gRPC 相关错误的概率)。  
-2. 启动 **MySQL**、**后端**(端口 `5000`)、**前端 Nginx**(端口 `8080`)。  
-3. 后端首次启动且数据库为空时,会执行 `db.create_all()` 并以 `BridgeDiseaseBackend-main/sql/init_db.sql` **种子数据**初始化。
+```powershell
+# 项目根目录
+docker compose -p bridge-disease up -d db
+```
 
-启动完成后:
+- 端口:**3307** → 容器 3306
+- 密码:`bridgedisease_root`
+- 库名:`bridge_disease`
 
-- **检澜** Web:<http://localhost:8080>  
-- **检澜** API(Flask):<http://127.0.0.1:5000>  
+**2. 后端**
 
-停止并移除容器:
+```powershell
+cd BridgeDiseaseBackend-main
+python -m venv .venv
+.venv\Scripts\activate
+pip install -r requirements.txt
 
-```bash
-npm run down
+$env:SQLALCHEMY_DATABASE_URI="mysql+pymysql://root:bridgedisease_root@127.0.0.1:3307/bridge_disease?charset=utf8mb4"
+python run.py
 ```
 
-### 根目录环境变量(`.env`)
+- API:<http://127.0.0.1:5000>
+- Windows 下若登录报 `ZoneInfoNotFoundError: Asia/Shanghai`,需已安装 `tzdata`(已写入 `requirements.txt`)。
 
-可复制或按项目内已有 `.env` 调整,常见项如下:
+**3. 前端**
 
-| 变量 | 说明 |
-|------|------|
-| `MYSQL_ROOT_PASSWORD` | MySQL root 密码 |
-| `MYSQL_DATABASE` | 数据库名称(默认 `bridge_disease`) |
-| `MYSQL_HOST_PORT` | 主机映射 MySQL 的端口(默认 `3307`,对应容器 `3306`) |
-| `VITE_API_BASE_URL` | **构建前端镜像时**传入的 API 基地址(浏览器会直接请求此 URL,本机开发通常为 `http://127.0.0.1:5000`) |
+```powershell
+cd bridge-disease-frontend-main
+npm install
+```
 
-**注意**:请勿仅依赖 `docker compose build` 在中文路径下构建;请优先使用根目录的 `npm run up`,或参考 `docker-compose.yml` 顶部注释中的等价命令。
+创建 `bridge-disease-frontend-main/.env.local`:
 
----
+```env
+VITE_API_BASE_URL=http://127.0.0.1:5000
+```
 
-## 本地开发检澜(不使用 Docker)
+```powershell
+npm run serve
+```
 
-### 1. 数据库与后端
+- Web:<http://localhost:5173>
 
-1. 安装 **MySQL 8**,创建数据库(例如 `bridge_disease`)。  
-2. Python **3.11+** 建议与 `Dockerfile` 一致。  
+**4. 初始化种子数据(首次或空库)**
 
-```bash
+```powershell
 cd BridgeDiseaseBackend-main
-python -m venv .venv
-# Windows: .venv\Scripts\activate
-pip install -r requirements.txt
-```
+$env:SQLALCHEMY_DATABASE_URI="mysql+pymysql://root:bridgedisease_root@127.0.0.1:3307/bridge_disease?charset=utf8mb4"
 
-3. 设置连接字符串(覆盖默认本地 URI),例如:
+# 建表后导入用户/模型(若 user 表为空,也可手动导入 sql/init_db.sql)
+python run.py   # 首次会 db.create_all()
 
-```bash
-set SQLALCHEMY_DATABASE_URI=mysql+pymysql://root:你的密码@127.0.0.1:3306/bridge_disease
+# 可选:下载真实桥梁照片到 seed_assets(需联网)
+python scripts/download_real_medias.py
+
+# 媒体库种子(真实照片 → static/medias + media 表)
+python scripts/seed_medias.py
+
+# 安全隐患检测记录种子(20 条演示记录 + 结果预览图)
+python scripts/seed_detections.py
 ```
 
-4. 启动应用:
+若中文姓名、隐患类别显示为 `??`,说明历史写入时连接未带 UTF-8,可执行修复脚本
 
-```bash
-python run.py
+```powershell
+python scripts/fix_user_names.py
+python scripts/fix_model_texts.py
 ```
 
-默认监听由环境变量 `FLASK_RUN_HOST` / `FLASK_RUN_PORT` 控制(见 `run.py`)。首次若需种子,可在空库时自行导入 `sql/init_db.sql`,或参考 `docker/backend/bootstrap.py` 的流程
+> **注意**:连接字符串务必包含 `?charset=utf8mb4`;后端 `config.py` 会在未指定时自动补全
 
-### 2. 前端
+---
+
+### 方式 B:Docker 一键堆栈
+
+在**项目根目录**(需 Docker + Node.js):
 
 ```bash
-cd bridge-disease-frontend-main
 npm install
+npm run up
 ```
 
-在 `bridge-disease-frontend-main` 下新增 `.env.local`(或 `.env.development`),指向本机后端
+此流程会
 
-```env
-VITE_API_BASE_URL=http://127.0.0.1:5000
-```
+1. 以 `DOCKER_BUILDKIT=0` 构建镜像(降低中文路径下 Compose Bake 错误概率)。
+2. 启动 **MySQL**、**后端**(`:5000`)、**前端 Nginx**(`:8080`)。
+3. 数据库为空时由 bootstrap 执行 `db.create_all()` 并导入 `sql/init_db.sql`。
 
-启动开发服务器:
+| 服务 | 地址 |
+|------|------|
+| 检澜 Web | <http://localhost:8080> |
+| 检澜 API | <http://127.0.0.1:5000> |
+
+停止:
 
 ```bash
-npm run serve
+npm run down
 ```
 
-浏览器打开终端显示的本机 URL(Vite 默认多为 `http://localhost:5173`)。API 请求会带上 `Authorization: Bearer <access_token>`(登录后由前端写入 `localStorage`)。
+### 根目录环境变量(`.env`)
+
+| 变量 | 说明 |
+|------|------|
+| `MYSQL_ROOT_PASSWORD` | MySQL root 密码(默认 `bridgedisease_root`) |
+| `MYSQL_DATABASE` | 数据库名(默认 `bridge_disease`) |
+| `MYSQL_HOST_PORT` | 主机映射端口(默认 `3307`) |
+| `VITE_API_BASE_URL` | 构建前端镜像时的 API 基地址 |
 
-### 3. 列表模拟数据(无后端时调 UI)
+---
 
-在 `bridge-disease-frontend-main/.env.local` 设置:
+## 数据种子脚本
 
-```env
-VITE_USE_LIST_MOCK=true
-```
+| 脚本 | 作用 |
+|------|------|
+| `scripts/download_real_medias.py` | 从 Wikimedia / Pexels 下载真实照片到 `seed_assets/medias/` |
+| `scripts/seed_medias.py` | 导入媒体文件并同步 `media` 表 |
+| `scripts/seed_detections.py` | 插入 20 条安全隐患检测演示记录(需已有 user/model/media) |
+| `scripts/fix_user_names.py` | 修复用户姓名 `??` 乱码 |
+| `scripts/fix_model_texts.py` | 修复模型隐患类别、检测描述乱码 |
 
-则「检测分割记录」与「媒体库」列表会使用 `src/mocks/detectionAndMediaMockData.js` 的本地分页数据,**不**调用对应列表 API。预览图仍可能请求 `/file/...`,若无实体文件则图片可能无法显示。
+照片版权说明见 `BridgeDiseaseBackend-main/seed_assets/medias/ATTRIBUTION.md`
 
 ---
 
 ## 首次登录与种子账号
 
-数据库由 `sql/init_db.sql` 初始化后,可使用下列测试账号(**登录框填「用户名邮箱均可**;密码见 SQL 内注释;登录成功后状态变为已启用):
+数据库由 `sql/init_db.sql` 初始化后,可使用下列账号(**用户名或邮箱均可登录**;登录成功后状态变为已启用):
 
-| 角色 | 用户名 | 邮箱(也可用于登录) | 密码 |
-|------|--------|----------------------|------|
+| 角色 | 用户名 | 邮箱 | 密码 |
+|------|--------|------|------|
 | 管理员 | `admin` | `admin@example.com` | `Admin123456` |
+| 开发人员 | `developer` | `dev@example.com` | `developer-WZY` |
+| 普通用户 | `demo` | `user@example.com` | `user-WZY` |
 
-**若提示「用户 xxx 尚未注册」**:说明当前 MySQL 里**没有对应种子用户**,常见原因包括:① 从未成功执行过 `init_db.sql`(或 Docker 首次启动时种子失败);② 数据库里已有旧数据,bootstrap 因「用户表非空」跳过了种子;③ 旧版种子里 `role`/`status` 与枚举不一致导致插入失败。处理方式:**清空该库后重新导入** `BridgeDiseaseBackend-main/sql/init_db.sql`,或删除 `user` 表数据后重启后端让 bootstrap 重新播种(Docker 可删卷后 `npm run up`)。**上线前务必修改全部默认密码与密钥。**
-
-实际部署请务必**更换密钥与密码**,并通过环境变量设置 `JWT_SECRET_KEY`、`SECRET_KEY` 与数据库 URI,不要沿用示例默认值。
+**若提示「用户尚未注册」**:说明种子未成功导入。可清空库后重新导入 `init_db.sql`,或 Docker 删卷后 `npm run up`。**上线前务必修改全部默认密码与密钥**(`JWT_SECRET_KEY`、`SECRET_KEY`、数据库密码等)。
 
 ---
 
-## 常用命令整理
+## 常用命令
 
 | 场景 | 命令 |
 |------|------|
-| Docker 一键启动 | 根目录:`npm run up` |
-| Docker 停止 | 根目录:`npm run down` |
+| 仅启动 MySQL | `docker compose -p bridge-disease up -d db` |
+| Docker 全栈 | 根目录 `npm run up` |
+| Docker 停止 | 根目录 `npm run down` |
+| 前端开发 | `cd bridge-disease-frontend-main && npm run serve` |
 | 前端构建 | `cd bridge-disease-frontend-main && npm run build` |
-| 前端预览构建结果 | `cd bridge-disease-frontend-main && npm run preview` |
 | 后端开发 | `cd BridgeDiseaseBackend-main && python run.py` |
 
 ---
 
 ## 疑难排解
 
-- **Docker 在中文路径构建失败**:使用根目录 `npm run up`(已关闭 BuildKit 并分步 `docker build`),或将项目复制到纯 ASCII 路径再构建。  
-- **前端能开、列表无数据**:确认 `VITE_API_BASE_URL` 与后端实际地址一致,且后端已启动;或暂时开启 `VITE_USE_LIST_MOCK=true` 验证界面。  
-- **推理很慢或内存不足**:后端依赖 PyTorch / Ultralytics,请在具备足够 RAM / 可选 GPU 的环境执行,并参考官方文档调整批次与模型。  
-- **README 中 Mermaid 图不渲染**:请使用支持 Mermaid 的 Markdown 预览(如 VS Code / Cursor 插件、GitHub 网页)查看;不影响项目运行。
+| 现象 | 处理 |
+|------|------|
+| 中文显示 `??`(姓名、隐患类别等) | 连接串加 `charset=utf8mb4`,运行 `fix_user_names.py` / `fix_model_texts.py` |
+| 登录报 `Asia/Shanghai` 时区错误 | Windows 安装 `tzdata`:`pip install tzdata` |
+| Docker 在中文路径构建失败 | 使用 `npm run up`,或项目移到 ASCII 路径 |
+| 列表无数据 | 确认后端已启动、`VITE_API_BASE_URL` 正确;或运行 `seed_medias.py` / `seed_detections.py` |
+| 媒体库无图 | 运行 `seed_medias.py`;`static/` 已在 `.gitignore`,需本地生成 |
+| 推理慢或内存不足 | PyTorch/YOLO 需足够 RAM;可选 GPU 环境 |
+| 批量检测 / 台账 / 报告 / IoT 数据丢失 | 上述模块数据在浏览器 `localStorage`,清缓存会重置 |
+| Mermaid 图不渲染 | 使用支持 Mermaid 的 Markdown 预览(GitHub、VS Code 插件等) |
 
 ---
 
 ## 授权与声明
 
-各子目录可能包含第三方依赖,版权归原作者所有。若 **检澜** 或本仓库需对外发布,请补充适当的开源授权文件与数据使用声明。
+各子目录包含第三方依赖,版权归原作者所有。若检澜或本仓库需对外发布,请补充适当的开源授权文件与数据使用声明。

+ 36 - 0
bridge-disease-frontend-main/.gitignore

@@ -0,0 +1,36 @@
+# 日志文件
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+# 依赖包和缓存
+node_modules
+*.tsbuildinfo
+
+# 临时文件 & 系统文件
+.DS_Store
+*.local
+
+# 构建产物
+dist
+dist-ssr
+coverage
+
+# 测试工具输出
+/cypress/videos/
+/cypress/screenshots/
+
+# 编辑器和 IDE 的配置
+.vscode/*
+!.vscode/extensions.json
+!.vscode/settings.json
+.idea
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?

+ 3 - 0
bridge-disease-frontend-main/.vscode/extensions.json

@@ -0,0 +1,3 @@
+{
+  "recommendations": ["Vue.volar"]
+}

+ 8 - 0
bridge-disease-frontend-main/.vscode/settings.json

@@ -0,0 +1,8 @@
+{
+  "explorer.fileNesting.enabled": true,
+  "explorer.fileNesting.patterns": {
+    "tsconfig.json": "tsconfig.*.json, env.d.ts",
+    "vite.config.*": "jsconfig*, vitest.config.*, cypress.config.*, playwright.config.*",
+    "package.json": "package-lock.json, pnpm*, .yarnrc*, yarn*, .eslint*, eslint*, .prettier*, prettier*, .editorconfig"
+  }
+}

+ 5 - 0
bridge-disease-frontend-main/build.bat

@@ -0,0 +1,5 @@
+@echo off
+echo 正在打包前端项目...
+npm run build
+echo 打包完成!
+pause

+ 16 - 0
bridge-disease-frontend-main/index.html

@@ -0,0 +1,16 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+
+<head>
+  <meta charset="UTF-8">
+  <link rel="icon" href="https://www.gitcc.com/uploads/-/system/appearance/header_logo/1/gitpp.png">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>检澜 DockScope · 桥梁安全隐患智能检测工作台</title>
+</head>
+
+<body>
+  <div id="app"></div>
+  <script type="module" src="/src/main.js"></script>
+</body>
+
+</html>

+ 8 - 0
bridge-disease-frontend-main/jsconfig.json

@@ -0,0 +1,8 @@
+{
+  "compilerOptions": {
+    "paths": {
+      "@/*": ["./src/*"]
+    }
+  },
+  "exclude": ["node_modules", "dist"]
+}

+ 3441 - 0
bridge-disease-frontend-main/package-lock.json

@@ -0,0 +1,3441 @@
+{
+  "name": "dockscope",
+  "version": "0.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "dockscope",
+      "version": "0.0.0",
+      "dependencies": {
+        "axios": "^1.8.1",
+        "echarts": "^5.6.0",
+        "element-plus": "^2.9.5",
+        "vue": "^3.5.13",
+        "vue-router": "^4.5.0",
+        "xlsx": "^0.18.5"
+      },
+      "devDependencies": {
+        "@vitejs/plugin-vue": "^5.2.1",
+        "vite": "^6.1.0",
+        "vite-plugin-vue-devtools": "^7.7.2"
+      }
+    },
+    "node_modules/@ampproject/remapping": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmmirror.com/@ampproject/remapping/-/remapping-2.3.0.tgz",
+      "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@jridgewell/gen-mapping": "^0.3.5",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@antfu/utils": {
+      "version": "0.7.10",
+      "resolved": "https://registry.npmmirror.com/@antfu/utils/-/utils-0.7.10.tgz",
+      "integrity": "sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
+    "node_modules/@babel/code-frame": {
+      "version": "7.26.2",
+      "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.26.2.tgz",
+      "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-validator-identifier": "^7.25.9",
+        "js-tokens": "^4.0.0",
+        "picocolors": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/compat-data": {
+      "version": "7.26.8",
+      "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.26.8.tgz",
+      "integrity": "sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/core": {
+      "version": "7.26.9",
+      "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.26.9.tgz",
+      "integrity": "sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@ampproject/remapping": "^2.2.0",
+        "@babel/code-frame": "^7.26.2",
+        "@babel/generator": "^7.26.9",
+        "@babel/helper-compilation-targets": "^7.26.5",
+        "@babel/helper-module-transforms": "^7.26.0",
+        "@babel/helpers": "^7.26.9",
+        "@babel/parser": "^7.26.9",
+        "@babel/template": "^7.26.9",
+        "@babel/traverse": "^7.26.9",
+        "@babel/types": "^7.26.9",
+        "convert-source-map": "^2.0.0",
+        "debug": "^4.1.0",
+        "gensync": "^1.0.0-beta.2",
+        "json5": "^2.2.3",
+        "semver": "^6.3.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/babel"
+      }
+    },
+    "node_modules/@babel/generator": {
+      "version": "7.26.9",
+      "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.26.9.tgz",
+      "integrity": "sha512-kEWdzjOAUMW4hAyrzJ0ZaTOu9OmpyDIQicIh0zg0EEcEkYXZb2TjtBhnHi2ViX7PKwZqF4xwqfAm299/QMP3lg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.26.9",
+        "@babel/types": "^7.26.9",
+        "@jridgewell/gen-mapping": "^0.3.5",
+        "@jridgewell/trace-mapping": "^0.3.25",
+        "jsesc": "^3.0.2"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-annotate-as-pure": {
+      "version": "7.25.9",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz",
+      "integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.25.9"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-compilation-targets": {
+      "version": "7.26.5",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.26.5.tgz",
+      "integrity": "sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/compat-data": "^7.26.5",
+        "@babel/helper-validator-option": "^7.25.9",
+        "browserslist": "^4.24.0",
+        "lru-cache": "^5.1.1",
+        "semver": "^6.3.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-create-class-features-plugin": {
+      "version": "7.26.9",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.26.9.tgz",
+      "integrity": "sha512-ubbUqCofvxPRurw5L8WTsCLSkQiVpov4Qx0WMA+jUN+nXBK8ADPlJO1grkFw5CWKC5+sZSOfuGMdX1aI1iT9Sg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-annotate-as-pure": "^7.25.9",
+        "@babel/helper-member-expression-to-functions": "^7.25.9",
+        "@babel/helper-optimise-call-expression": "^7.25.9",
+        "@babel/helper-replace-supers": "^7.26.5",
+        "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9",
+        "@babel/traverse": "^7.26.9",
+        "semver": "^6.3.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
+      }
+    },
+    "node_modules/@babel/helper-member-expression-to-functions": {
+      "version": "7.25.9",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.25.9.tgz",
+      "integrity": "sha512-wbfdZ9w5vk0C0oyHqAJbc62+vet5prjj01jjJ8sKn3j9h3MQQlflEdXYvuqRWjHnM12coDEqiC1IRCi0U/EKwQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/traverse": "^7.25.9",
+        "@babel/types": "^7.25.9"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-module-imports": {
+      "version": "7.25.9",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz",
+      "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/traverse": "^7.25.9",
+        "@babel/types": "^7.25.9"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-module-transforms": {
+      "version": "7.26.0",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz",
+      "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-module-imports": "^7.25.9",
+        "@babel/helper-validator-identifier": "^7.25.9",
+        "@babel/traverse": "^7.25.9"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
+      }
+    },
+    "node_modules/@babel/helper-optimise-call-expression": {
+      "version": "7.25.9",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.25.9.tgz",
+      "integrity": "sha512-FIpuNaz5ow8VyrYcnXQTDRGvV6tTjkNtCK/RYNDXGSLlUD6cBuQTSw43CShGxjvfBTfcUA/r6UhUCbtYqkhcuQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.25.9"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-plugin-utils": {
+      "version": "7.26.5",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz",
+      "integrity": "sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-replace-supers": {
+      "version": "7.26.5",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-replace-supers/-/helper-replace-supers-7.26.5.tgz",
+      "integrity": "sha512-bJ6iIVdYX1YooY2X7w1q6VITt+LnUILtNk7zT78ykuwStx8BauCzxvFqFaHjOpW1bVnSUM1PN1f0p5P21wHxvg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-member-expression-to-functions": "^7.25.9",
+        "@babel/helper-optimise-call-expression": "^7.25.9",
+        "@babel/traverse": "^7.26.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
+      }
+    },
+    "node_modules/@babel/helper-skip-transparent-expression-wrappers": {
+      "version": "7.25.9",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.25.9.tgz",
+      "integrity": "sha512-K4Du3BFa3gvyhzgPcntrkDgZzQaq6uozzcpGbOO1OEJaI+EJdqWIMTLgFgQf6lrfiDFo5FU+BxKepI9RmZqahA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/traverse": "^7.25.9",
+        "@babel/types": "^7.25.9"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-string-parser": {
+      "version": "7.25.9",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz",
+      "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-identifier": {
+      "version": "7.25.9",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz",
+      "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-option": {
+      "version": "7.25.9",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz",
+      "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helpers": {
+      "version": "7.26.9",
+      "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.26.9.tgz",
+      "integrity": "sha512-Mz/4+y8udxBKdmzt/UjPACs4G3j5SshJJEFFKxlCGPydG4JAHXxjWjAwjd09tf6oINvl1VfMJo+nB7H2YKQ0dA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/template": "^7.26.9",
+        "@babel/types": "^7.26.9"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/parser": {
+      "version": "7.26.9",
+      "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.26.9.tgz",
+      "integrity": "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.26.9"
+      },
+      "bin": {
+        "parser": "bin/babel-parser.js"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@babel/plugin-proposal-decorators": {
+      "version": "7.25.9",
+      "resolved": "https://registry.npmmirror.com/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.25.9.tgz",
+      "integrity": "sha512-smkNLL/O1ezy9Nhy4CNosc4Va+1wo5w4gzSZeLe6y6dM4mmHfYOCPolXQPHQxonZCF+ZyebxN9vqOolkYrSn5g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-create-class-features-plugin": "^7.25.9",
+        "@babel/helper-plugin-utils": "^7.25.9",
+        "@babel/plugin-syntax-decorators": "^7.25.9"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-decorators": {
+      "version": "7.25.9",
+      "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.25.9.tgz",
+      "integrity": "sha512-ryzI0McXUPJnRCvMo4lumIKZUzhYUO/ScI+Mz4YVaTLt04DHNSjEUjKVvbzQjZFLuod/cYEc07mJWhzl6v4DPg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.25.9"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-import-attributes": {
+      "version": "7.26.0",
+      "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.26.0.tgz",
+      "integrity": "sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.25.9"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-import-meta": {
+      "version": "7.10.4",
+      "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz",
+      "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.10.4"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-jsx": {
+      "version": "7.25.9",
+      "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.25.9.tgz",
+      "integrity": "sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.25.9"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-typescript": {
+      "version": "7.25.9",
+      "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.25.9.tgz",
+      "integrity": "sha512-hjMgRy5hb8uJJjUcdWunWVcoi9bGpJp8p5Ol1229PoN6aytsLwNMgmdftO23wnCLMfVmTwZDWMPNq/D1SY60JQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.25.9"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-typescript": {
+      "version": "7.26.8",
+      "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.26.8.tgz",
+      "integrity": "sha512-bME5J9AC8ChwA7aEPJ6zym3w7aObZULHhbNLU0bKUhKsAkylkzUdq+0kdymh9rzi8nlNFl2bmldFBCKNJBUpuw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-annotate-as-pure": "^7.25.9",
+        "@babel/helper-create-class-features-plugin": "^7.25.9",
+        "@babel/helper-plugin-utils": "^7.26.5",
+        "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9",
+        "@babel/plugin-syntax-typescript": "^7.25.9"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/template": {
+      "version": "7.26.9",
+      "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.26.9.tgz",
+      "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/code-frame": "^7.26.2",
+        "@babel/parser": "^7.26.9",
+        "@babel/types": "^7.26.9"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/traverse": {
+      "version": "7.26.9",
+      "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.26.9.tgz",
+      "integrity": "sha512-ZYW7L+pL8ahU5fXmNbPF+iZFHCv5scFak7MZ9bwaRPLUhHh7QQEMjZUg0HevihoqCM5iSYHN61EyCoZvqC+bxg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/code-frame": "^7.26.2",
+        "@babel/generator": "^7.26.9",
+        "@babel/parser": "^7.26.9",
+        "@babel/template": "^7.26.9",
+        "@babel/types": "^7.26.9",
+        "debug": "^4.3.1",
+        "globals": "^11.1.0"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/types": {
+      "version": "7.26.9",
+      "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.26.9.tgz",
+      "integrity": "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-string-parser": "^7.25.9",
+        "@babel/helper-validator-identifier": "^7.25.9"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@ctrl/tinycolor": {
+      "version": "3.6.1",
+      "resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz",
+      "integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/@element-plus/icons-vue": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.1.tgz",
+      "integrity": "sha512-XxVUZv48RZAd87ucGS48jPf6pKu0yV5UCg9f4FFwtrYxXOwWuVJo6wOvSLKEoMQKjv8GsX/mhP6UsC1lRwbUWg==",
+      "license": "MIT",
+      "peerDependencies": {
+        "vue": "^3.2.0"
+      }
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.25.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz",
+      "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.25.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.0.tgz",
+      "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.25.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz",
+      "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.25.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.0.tgz",
+      "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.25.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz",
+      "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.25.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz",
+      "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.25.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz",
+      "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.25.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz",
+      "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.25.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz",
+      "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.25.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz",
+      "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.25.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz",
+      "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.25.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz",
+      "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.25.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz",
+      "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.25.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz",
+      "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.25.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz",
+      "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.25.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz",
+      "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.25.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz",
+      "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-arm64": {
+      "version": "0.25.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz",
+      "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.25.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz",
+      "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-arm64": {
+      "version": "0.25.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz",
+      "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.25.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz",
+      "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.25.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz",
+      "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.25.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz",
+      "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.25.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz",
+      "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.25.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz",
+      "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@floating-ui/core": {
+      "version": "1.6.9",
+      "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.6.9.tgz",
+      "integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==",
+      "license": "MIT",
+      "dependencies": {
+        "@floating-ui/utils": "^0.2.9"
+      }
+    },
+    "node_modules/@floating-ui/dom": {
+      "version": "1.6.13",
+      "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.6.13.tgz",
+      "integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==",
+      "license": "MIT",
+      "dependencies": {
+        "@floating-ui/core": "^1.6.0",
+        "@floating-ui/utils": "^0.2.9"
+      }
+    },
+    "node_modules/@floating-ui/utils": {
+      "version": "0.2.9",
+      "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.9.tgz",
+      "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
+      "license": "MIT"
+    },
+    "node_modules/@jridgewell/gen-mapping": {
+      "version": "0.3.8",
+      "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
+      "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/set-array": "^1.2.1",
+        "@jridgewell/sourcemap-codec": "^1.4.10",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@jridgewell/resolve-uri": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+      "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@jridgewell/set-array": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/@jridgewell/set-array/-/set-array-1.2.1.tgz",
+      "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
+      "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==",
+      "license": "MIT"
+    },
+    "node_modules/@jridgewell/trace-mapping": {
+      "version": "0.3.25",
+      "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
+      "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/resolve-uri": "^3.1.0",
+        "@jridgewell/sourcemap-codec": "^1.4.14"
+      }
+    },
+    "node_modules/@polka/url": {
+      "version": "1.0.0-next.28",
+      "resolved": "https://registry.npmmirror.com/@polka/url/-/url-1.0.0-next.28.tgz",
+      "integrity": "sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@popperjs/core": {
+      "name": "@sxzz/popperjs-es",
+      "version": "2.11.7",
+      "resolved": "https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.7.tgz",
+      "integrity": "sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ==",
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/popperjs"
+      }
+    },
+    "node_modules/@rollup/pluginutils": {
+      "version": "5.1.4",
+      "resolved": "https://registry.npmmirror.com/@rollup/pluginutils/-/pluginutils-5.1.4.tgz",
+      "integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "^1.0.0",
+        "estree-walker": "^2.0.2",
+        "picomatch": "^4.0.2"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      },
+      "peerDependencies": {
+        "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
+      },
+      "peerDependenciesMeta": {
+        "rollup": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@rollup/rollup-android-arm-eabi": {
+      "version": "4.34.8",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.8.tgz",
+      "integrity": "sha512-q217OSE8DTp8AFHuNHXo0Y86e1wtlfVrXiAlwkIvGRQv9zbc6mE3sjIVfwI8sYUyNxwOg0j/Vm1RKM04JcWLJw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-android-arm64": {
+      "version": "4.34.8",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.34.8.tgz",
+      "integrity": "sha512-Gigjz7mNWaOL9wCggvoK3jEIUUbGul656opstjaUSGC3eT0BM7PofdAJaBfPFWWkXNVAXbaQtC99OCg4sJv70Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-arm64": {
+      "version": "4.34.8",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.34.8.tgz",
+      "integrity": "sha512-02rVdZ5tgdUNRxIUrFdcMBZQoaPMrxtwSb+/hOfBdqkatYHR3lZ2A2EGyHq2sGOd0Owk80oV3snlDASC24He3Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-x64": {
+      "version": "4.34.8",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.8.tgz",
+      "integrity": "sha512-qIP/elwR/tq/dYRx3lgwK31jkZvMiD6qUtOycLhTzCvrjbZ3LjQnEM9rNhSGpbLXVJYQ3rq39A6Re0h9tU2ynw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-arm64": {
+      "version": "4.34.8",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.34.8.tgz",
+      "integrity": "sha512-IQNVXL9iY6NniYbTaOKdrlVP3XIqazBgJOVkddzJlqnCpRi/yAeSOa8PLcECFSQochzqApIOE1GHNu3pCz+BDA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-x64": {
+      "version": "4.34.8",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.34.8.tgz",
+      "integrity": "sha512-TYXcHghgnCqYFiE3FT5QwXtOZqDj5GmaFNTNt3jNC+vh22dc/ukG2cG+pi75QO4kACohZzidsq7yKTKwq/Jq7Q==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+      "version": "4.34.8",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.34.8.tgz",
+      "integrity": "sha512-A4iphFGNkWRd+5m3VIGuqHnG3MVnqKe7Al57u9mwgbyZ2/xF9Jio72MaY7xxh+Y87VAHmGQr73qoKL9HPbXj1g==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+      "version": "4.34.8",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.34.8.tgz",
+      "integrity": "sha512-S0lqKLfTm5u+QTxlFiAnb2J/2dgQqRy/XvziPtDd1rKZFXHTyYLoVL58M/XFwDI01AQCDIevGLbQrMAtdyanpA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-gnu": {
+      "version": "4.34.8",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.8.tgz",
+      "integrity": "sha512-jpz9YOuPiSkL4G4pqKrus0pn9aYwpImGkosRKwNi+sJSkz+WU3anZe6hi73StLOQdfXYXC7hUfsQlTnjMd3s1A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-musl": {
+      "version": "4.34.8",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.8.tgz",
+      "integrity": "sha512-KdSfaROOUJXgTVxJNAZ3KwkRc5nggDk+06P6lgi1HLv1hskgvxHUKZ4xtwHkVYJ1Rep4GNo+uEfycCRRxht7+Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loongarch64-gnu": {
+      "version": "4.34.8",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.34.8.tgz",
+      "integrity": "sha512-NyF4gcxwkMFRjgXBM6g2lkT58OWztZvw5KkV2K0qqSnUEqCVcqdh2jN4gQrTn/YUpAcNKyFHfoOZEer9nwo6uQ==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
+      "version": "4.34.8",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.34.8.tgz",
+      "integrity": "sha512-LMJc999GkhGvktHU85zNTDImZVUCJ1z/MbAJTnviiWmmjyckP5aQsHtcujMjpNdMZPT2rQEDBlJfubhs3jsMfw==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+      "version": "4.34.8",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.34.8.tgz",
+      "integrity": "sha512-xAQCAHPj8nJq1PI3z8CIZzXuXCstquz7cIOL73HHdXiRcKk8Ywwqtx2wrIy23EcTn4aZ2fLJNBB8d0tQENPCmw==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-s390x-gnu": {
+      "version": "4.34.8",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.34.8.tgz",
+      "integrity": "sha512-DdePVk1NDEuc3fOe3dPPTb+rjMtuFw89gw6gVWxQFAuEqqSdDKnrwzZHrUYdac7A7dXl9Q2Vflxpme15gUWQFA==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-gnu": {
+      "version": "4.34.8",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.8.tgz",
+      "integrity": "sha512-8y7ED8gjxITUltTUEJLQdgpbPh1sUQ0kMTmufRF/Ns5tI9TNMNlhWtmPKKHCU0SilX+3MJkZ0zERYYGIVBYHIA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-musl": {
+      "version": "4.34.8",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.34.8.tgz",
+      "integrity": "sha512-SCXcP0ZpGFIe7Ge+McxY5zKxiEI5ra+GT3QRxL0pMMtxPfpyLAKleZODi1zdRHkz5/BhueUrYtYVgubqe9JBNQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-arm64-msvc": {
+      "version": "4.34.8",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.8.tgz",
+      "integrity": "sha512-YHYsgzZgFJzTRbth4h7Or0m5O74Yda+hLin0irAIobkLQFRQd1qWmnoVfwmKm9TXIZVAD0nZ+GEb2ICicLyCnQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-ia32-msvc": {
+      "version": "4.34.8",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.34.8.tgz",
+      "integrity": "sha512-r3NRQrXkHr4uWy5TOjTpTYojR9XmF0j/RYgKCef+Ag46FWUTltm5ziticv8LdNsDMehjJ543x/+TJAek/xBA2w==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-msvc": {
+      "version": "4.34.8",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.8.tgz",
+      "integrity": "sha512-U0FaE5O1BCpZSeE6gBl3c5ObhePQSfk9vDRToMmTkbhCOgW4jqvtS5LGyQ76L1fH8sM0keRp4uDTsbjiUyjk0g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@sec-ant/readable-stream": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmmirror.com/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz",
+      "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@sindresorhus/merge-streams": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmmirror.com/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
+      "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/@types/estree": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.6.tgz",
+      "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/lodash": {
+      "version": "4.17.15",
+      "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.15.tgz",
+      "integrity": "sha512-w/P33JFeySuhN6JLkysYUK2gEmy9kHHFN7E8ro0tkfmlDOgxBDzWEZ/J8cWA+fHqFevpswDTFZnDx+R9lbL6xw==",
+      "license": "MIT"
+    },
+    "node_modules/@types/lodash-es": {
+      "version": "4.17.12",
+      "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
+      "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/lodash": "*"
+      }
+    },
+    "node_modules/@types/web-bluetooth": {
+      "version": "0.0.16",
+      "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.16.tgz",
+      "integrity": "sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==",
+      "license": "MIT"
+    },
+    "node_modules/@vitejs/plugin-vue": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.1.tgz",
+      "integrity": "sha512-cxh314tzaWwOLqVes2gnnCtvBDcM1UMdn+iFR+UjAn411dPT3tOmqrJjbMd7koZpMAmBM/GqeV4n9ge7JSiJJQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^18.0.0 || >=20.0.0"
+      },
+      "peerDependencies": {
+        "vite": "^5.0.0 || ^6.0.0",
+        "vue": "^3.2.25"
+      }
+    },
+    "node_modules/@vue/babel-helper-vue-transform-on": {
+      "version": "1.2.5",
+      "resolved": "https://registry.npmmirror.com/@vue/babel-helper-vue-transform-on/-/babel-helper-vue-transform-on-1.2.5.tgz",
+      "integrity": "sha512-lOz4t39ZdmU4DJAa2hwPYmKc8EsuGa2U0L9KaZaOJUt0UwQNjNA3AZTq6uEivhOKhhG1Wvy96SvYBoFmCg3uuw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@vue/babel-plugin-jsx": {
+      "version": "1.2.5",
+      "resolved": "https://registry.npmmirror.com/@vue/babel-plugin-jsx/-/babel-plugin-jsx-1.2.5.tgz",
+      "integrity": "sha512-zTrNmOd4939H9KsRIGmmzn3q2zvv1mjxkYZHgqHZgDrXz5B1Q3WyGEjO2f+JrmKghvl1JIRcvo63LgM1kH5zFg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-module-imports": "^7.24.7",
+        "@babel/helper-plugin-utils": "^7.24.8",
+        "@babel/plugin-syntax-jsx": "^7.24.7",
+        "@babel/template": "^7.25.0",
+        "@babel/traverse": "^7.25.6",
+        "@babel/types": "^7.25.6",
+        "@vue/babel-helper-vue-transform-on": "1.2.5",
+        "@vue/babel-plugin-resolve-type": "1.2.5",
+        "html-tags": "^3.3.1",
+        "svg-tags": "^1.0.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      },
+      "peerDependenciesMeta": {
+        "@babel/core": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@vue/babel-plugin-resolve-type": {
+      "version": "1.2.5",
+      "resolved": "https://registry.npmmirror.com/@vue/babel-plugin-resolve-type/-/babel-plugin-resolve-type-1.2.5.tgz",
+      "integrity": "sha512-U/ibkQrf5sx0XXRnUZD1mo5F7PkpKyTbfXM3a3rC4YnUz6crHEz9Jg09jzzL6QYlXNto/9CePdOg/c87O4Nlfg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/code-frame": "^7.24.7",
+        "@babel/helper-module-imports": "^7.24.7",
+        "@babel/helper-plugin-utils": "^7.24.8",
+        "@babel/parser": "^7.25.6",
+        "@vue/compiler-sfc": "^3.5.3"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@vue/compiler-core": {
+      "version": "3.5.13",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.13.tgz",
+      "integrity": "sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.25.3",
+        "@vue/shared": "3.5.13",
+        "entities": "^4.5.0",
+        "estree-walker": "^2.0.2",
+        "source-map-js": "^1.2.0"
+      }
+    },
+    "node_modules/@vue/compiler-dom": {
+      "version": "3.5.13",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.13.tgz",
+      "integrity": "sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-core": "3.5.13",
+        "@vue/shared": "3.5.13"
+      }
+    },
+    "node_modules/@vue/compiler-sfc": {
+      "version": "3.5.13",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.13.tgz",
+      "integrity": "sha512-6VdaljMpD82w6c2749Zhf5T9u5uLBWKnVue6XWxprDobftnletJ8+oel7sexFfM3qIxNmVE7LSFGTpv6obNyaQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.25.3",
+        "@vue/compiler-core": "3.5.13",
+        "@vue/compiler-dom": "3.5.13",
+        "@vue/compiler-ssr": "3.5.13",
+        "@vue/shared": "3.5.13",
+        "estree-walker": "^2.0.2",
+        "magic-string": "^0.30.11",
+        "postcss": "^8.4.48",
+        "source-map-js": "^1.2.0"
+      }
+    },
+    "node_modules/@vue/compiler-ssr": {
+      "version": "3.5.13",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.13.tgz",
+      "integrity": "sha512-wMH6vrYHxQl/IybKJagqbquvxpWCuVYpoUJfCqFZwa/JY1GdATAQ+TgVtgrwwMZ0D07QhA99rs/EAAWfvG6KpA==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.13",
+        "@vue/shared": "3.5.13"
+      }
+    },
+    "node_modules/@vue/devtools-api": {
+      "version": "6.6.4",
+      "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
+      "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
+      "license": "MIT"
+    },
+    "node_modules/@vue/devtools-core": {
+      "version": "7.7.2",
+      "resolved": "https://registry.npmmirror.com/@vue/devtools-core/-/devtools-core-7.7.2.tgz",
+      "integrity": "sha512-lexREWj1lKi91Tblr38ntSsy6CvI8ba7u+jmwh2yruib/ltLUcsIzEjCnrkh1yYGGIKXbAuYV2tOG10fGDB9OQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vue/devtools-kit": "^7.7.2",
+        "@vue/devtools-shared": "^7.7.2",
+        "mitt": "^3.0.1",
+        "nanoid": "^5.0.9",
+        "pathe": "^2.0.2",
+        "vite-hot-client": "^0.2.4"
+      },
+      "peerDependencies": {
+        "vue": "^3.0.0"
+      }
+    },
+    "node_modules/@vue/devtools-core/node_modules/nanoid": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-5.1.2.tgz",
+      "integrity": "sha512-b+CiXQCNMUGe0Ri64S9SXFcP9hogjAJ2Rd6GdVxhPLRm7mhGaM7VgOvCAJ1ZshfHbqVDI3uqTI5C8/GaKuLI7g==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.js"
+      },
+      "engines": {
+        "node": "^18 || >=20"
+      }
+    },
+    "node_modules/@vue/devtools-kit": {
+      "version": "7.7.2",
+      "resolved": "https://registry.npmmirror.com/@vue/devtools-kit/-/devtools-kit-7.7.2.tgz",
+      "integrity": "sha512-CY0I1JH3Z8PECbn6k3TqM1Bk9ASWxeMtTCvZr7vb+CHi+X/QwQm5F1/fPagraamKMAHVfuuCbdcnNg1A4CYVWQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vue/devtools-shared": "^7.7.2",
+        "birpc": "^0.2.19",
+        "hookable": "^5.5.3",
+        "mitt": "^3.0.1",
+        "perfect-debounce": "^1.0.0",
+        "speakingurl": "^14.0.1",
+        "superjson": "^2.2.1"
+      }
+    },
+    "node_modules/@vue/devtools-shared": {
+      "version": "7.7.2",
+      "resolved": "https://registry.npmmirror.com/@vue/devtools-shared/-/devtools-shared-7.7.2.tgz",
+      "integrity": "sha512-uBFxnp8gwW2vD6FrJB8JZLUzVb6PNRG0B0jBnHsOH8uKyva2qINY8PTF5Te4QlTbMDqU5K6qtJDr6cNsKWhbOA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "rfdc": "^1.4.1"
+      }
+    },
+    "node_modules/@vue/reactivity": {
+      "version": "3.5.13",
+      "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.13.tgz",
+      "integrity": "sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/shared": "3.5.13"
+      }
+    },
+    "node_modules/@vue/runtime-core": {
+      "version": "3.5.13",
+      "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.13.tgz",
+      "integrity": "sha512-Fj4YRQ3Az0WTZw1sFe+QDb0aXCerigEpw418pw1HBUKFtnQHWzwojaukAs2X/c9DQz4MQ4bsXTGlcpGxU/RCIw==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/reactivity": "3.5.13",
+        "@vue/shared": "3.5.13"
+      }
+    },
+    "node_modules/@vue/runtime-dom": {
+      "version": "3.5.13",
+      "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.13.tgz",
+      "integrity": "sha512-dLaj94s93NYLqjLiyFzVs9X6dWhTdAlEAciC3Moq7gzAc13VJUdCnjjRurNM6uTLFATRHexHCTu/Xp3eW6yoog==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/reactivity": "3.5.13",
+        "@vue/runtime-core": "3.5.13",
+        "@vue/shared": "3.5.13",
+        "csstype": "^3.1.3"
+      }
+    },
+    "node_modules/@vue/server-renderer": {
+      "version": "3.5.13",
+      "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.13.tgz",
+      "integrity": "sha512-wAi4IRJV/2SAW3htkTlB+dHeRmpTiVIK1OGLWV1yeStVSebSQQOwGwIq0D3ZIoBj2C2qpgz5+vX9iEBkTdk5YA==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-ssr": "3.5.13",
+        "@vue/shared": "3.5.13"
+      },
+      "peerDependencies": {
+        "vue": "3.5.13"
+      }
+    },
+    "node_modules/@vue/shared": {
+      "version": "3.5.13",
+      "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.13.tgz",
+      "integrity": "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==",
+      "license": "MIT"
+    },
+    "node_modules/@vueuse/core": {
+      "version": "9.13.0",
+      "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-9.13.0.tgz",
+      "integrity": "sha512-pujnclbeHWxxPRqXWmdkKV5OX4Wk4YeK7wusHqRwU0Q7EFusHoqNA/aPhB6KCh9hEqJkLAJo7bb0Lh9b+OIVzw==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/web-bluetooth": "^0.0.16",
+        "@vueuse/metadata": "9.13.0",
+        "@vueuse/shared": "9.13.0",
+        "vue-demi": "*"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
+    "node_modules/@vueuse/core/node_modules/vue-demi": {
+      "version": "0.14.10",
+      "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz",
+      "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "vue-demi-fix": "bin/vue-demi-fix.js",
+        "vue-demi-switch": "bin/vue-demi-switch.js"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      },
+      "peerDependencies": {
+        "@vue/composition-api": "^1.0.0-rc.1",
+        "vue": "^3.0.0-0 || ^2.6.0"
+      },
+      "peerDependenciesMeta": {
+        "@vue/composition-api": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@vueuse/metadata": {
+      "version": "9.13.0",
+      "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-9.13.0.tgz",
+      "integrity": "sha512-gdU7TKNAUVlXXLbaF+ZCfte8BjRJQWPCa2J55+7/h+yDtzw3vOoGQDRXzI6pyKyo6bXFT5/QoPE4hAknExjRLQ==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
+    "node_modules/@vueuse/shared": {
+      "version": "9.13.0",
+      "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-9.13.0.tgz",
+      "integrity": "sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==",
+      "license": "MIT",
+      "dependencies": {
+        "vue-demi": "*"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
+    "node_modules/@vueuse/shared/node_modules/vue-demi": {
+      "version": "0.14.10",
+      "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz",
+      "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "vue-demi-fix": "bin/vue-demi-fix.js",
+        "vue-demi-switch": "bin/vue-demi-switch.js"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      },
+      "peerDependencies": {
+        "@vue/composition-api": "^1.0.0-rc.1",
+        "vue": "^3.0.0-0 || ^2.6.0"
+      },
+      "peerDependenciesMeta": {
+        "@vue/composition-api": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/adler-32": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmmirror.com/adler-32/-/adler-32-1.3.1.tgz",
+      "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
+    "node_modules/async-validator": {
+      "version": "4.2.5",
+      "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz",
+      "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
+      "license": "MIT"
+    },
+    "node_modules/asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+      "license": "MIT"
+    },
+    "node_modules/axios": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmmirror.com/axios/-/axios-1.8.1.tgz",
+      "integrity": "sha512-NN+fvwH/kV01dYUQ3PTOZns4LWtWhOFCAhQ/pHb88WQ1hNe5V/dvFwc4VJcDL11LT9xSX0QtsR8sWUuyOuOq7g==",
+      "license": "MIT",
+      "dependencies": {
+        "follow-redirects": "^1.15.6",
+        "form-data": "^4.0.0",
+        "proxy-from-env": "^1.1.0"
+      }
+    },
+    "node_modules/birpc": {
+      "version": "0.2.19",
+      "resolved": "https://registry.npmmirror.com/birpc/-/birpc-0.2.19.tgz",
+      "integrity": "sha512-5WeXXAvTmitV1RqJFppT5QtUiz2p1mRSYU000Jkft5ZUCLJIk4uQriYNO50HknxKwM6jd8utNc66K1qGIwwWBQ==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
+    "node_modules/browserslist": {
+      "version": "4.24.4",
+      "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.24.4.tgz",
+      "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/browserslist"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "caniuse-lite": "^1.0.30001688",
+        "electron-to-chromium": "^1.5.73",
+        "node-releases": "^2.0.19",
+        "update-browserslist-db": "^1.1.1"
+      },
+      "bin": {
+        "browserslist": "cli.js"
+      },
+      "engines": {
+        "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+      }
+    },
+    "node_modules/bundle-name": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmmirror.com/bundle-name/-/bundle-name-4.1.0.tgz",
+      "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "run-applescript": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/caniuse-lite": {
+      "version": "1.0.30001700",
+      "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001700.tgz",
+      "integrity": "sha512-2S6XIXwaE7K7erT8dY+kLQcpa5ms63XlRkMkReXjle+kf6c5g38vyMl+Z5y8dSxOFDhcFe+nxnn261PLxBSQsQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "CC-BY-4.0"
+    },
+    "node_modules/cfb": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmmirror.com/cfb/-/cfb-1.2.2.tgz",
+      "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "adler-32": "~1.3.0",
+        "crc-32": "~1.2.0"
+      },
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
+    "node_modules/codepage": {
+      "version": "1.15.0",
+      "resolved": "https://registry.npmmirror.com/codepage/-/codepage-1.15.0.tgz",
+      "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
+    "node_modules/combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "license": "MIT",
+      "dependencies": {
+        "delayed-stream": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/convert-source-map": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz",
+      "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/copy-anything": {
+      "version": "3.0.5",
+      "resolved": "https://registry.npmmirror.com/copy-anything/-/copy-anything-3.0.5.tgz",
+      "integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-what": "^4.1.8"
+      },
+      "engines": {
+        "node": ">=12.13"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/mesqueeb"
+      }
+    },
+    "node_modules/crc-32": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz",
+      "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
+      "license": "Apache-2.0",
+      "bin": {
+        "crc32": "bin/crc32.njs"
+      },
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
+    "node_modules/cross-spawn": {
+      "version": "7.0.6",
+      "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz",
+      "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "path-key": "^3.1.0",
+        "shebang-command": "^2.0.0",
+        "which": "^2.0.1"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/csstype": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.1.3.tgz",
+      "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
+      "license": "MIT"
+    },
+    "node_modules/dayjs": {
+      "version": "1.11.13",
+      "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.13.tgz",
+      "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==",
+      "license": "MIT"
+    },
+    "node_modules/debug": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.0.tgz",
+      "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/default-browser": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmmirror.com/default-browser/-/default-browser-5.2.1.tgz",
+      "integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "bundle-name": "^4.1.0",
+        "default-browser-id": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/default-browser-id": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmmirror.com/default-browser-id/-/default-browser-id-5.0.0.tgz",
+      "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/define-lazy-prop": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmmirror.com/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz",
+      "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/echarts": {
+      "version": "5.6.0",
+      "resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.6.0.tgz",
+      "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "tslib": "2.3.0",
+        "zrender": "5.6.1"
+      }
+    },
+    "node_modules/electron-to-chromium": {
+      "version": "1.5.104",
+      "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.104.tgz",
+      "integrity": "sha512-Us9M2L4cO/zMBqVkJtnj353nQhMju9slHm62NprKTmdF3HH8wYOtNvDFq/JB2+ZRoGLzdvYDiATlMHs98XBM1g==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/element-plus": {
+      "version": "2.9.5",
+      "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.9.5.tgz",
+      "integrity": "sha512-r+X79oogLbYq8p9L5f9fHSHhUFNM0AL72aikqiZVxSc2/08mK6m/PotiB9e/D90QmWTIHIaFnFmW65AcXmneig==",
+      "license": "MIT",
+      "dependencies": {
+        "@ctrl/tinycolor": "^3.4.1",
+        "@element-plus/icons-vue": "^2.3.1",
+        "@floating-ui/dom": "^1.0.1",
+        "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7",
+        "@types/lodash": "^4.14.182",
+        "@types/lodash-es": "^4.17.6",
+        "@vueuse/core": "^9.1.0",
+        "async-validator": "^4.2.5",
+        "dayjs": "^1.11.13",
+        "escape-html": "^1.0.3",
+        "lodash": "^4.17.21",
+        "lodash-es": "^4.17.21",
+        "lodash-unified": "^1.0.2",
+        "memoize-one": "^6.0.0",
+        "normalize-wheel-es": "^1.2.0"
+      },
+      "peerDependencies": {
+        "vue": "^3.2.0"
+      }
+    },
+    "node_modules/entities": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz",
+      "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/error-stack-parser-es": {
+      "version": "0.1.5",
+      "resolved": "https://registry.npmmirror.com/error-stack-parser-es/-/error-stack-parser-es-0.1.5.tgz",
+      "integrity": "sha512-xHku1X40RO+fO8yJ8Wh2f2rZWVjqyhb1zgq1yZ8aZRQkv6OOKhKWRUaht3eSCUbAOBaKIgM+ykwFLE+QUxgGeg==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-set-tostringtag": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+      "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.6",
+        "has-tostringtag": "^1.0.2",
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/esbuild": {
+      "version": "0.25.0",
+      "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.0.tgz",
+      "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.25.0",
+        "@esbuild/android-arm": "0.25.0",
+        "@esbuild/android-arm64": "0.25.0",
+        "@esbuild/android-x64": "0.25.0",
+        "@esbuild/darwin-arm64": "0.25.0",
+        "@esbuild/darwin-x64": "0.25.0",
+        "@esbuild/freebsd-arm64": "0.25.0",
+        "@esbuild/freebsd-x64": "0.25.0",
+        "@esbuild/linux-arm": "0.25.0",
+        "@esbuild/linux-arm64": "0.25.0",
+        "@esbuild/linux-ia32": "0.25.0",
+        "@esbuild/linux-loong64": "0.25.0",
+        "@esbuild/linux-mips64el": "0.25.0",
+        "@esbuild/linux-ppc64": "0.25.0",
+        "@esbuild/linux-riscv64": "0.25.0",
+        "@esbuild/linux-s390x": "0.25.0",
+        "@esbuild/linux-x64": "0.25.0",
+        "@esbuild/netbsd-arm64": "0.25.0",
+        "@esbuild/netbsd-x64": "0.25.0",
+        "@esbuild/openbsd-arm64": "0.25.0",
+        "@esbuild/openbsd-x64": "0.25.0",
+        "@esbuild/sunos-x64": "0.25.0",
+        "@esbuild/win32-arm64": "0.25.0",
+        "@esbuild/win32-ia32": "0.25.0",
+        "@esbuild/win32-x64": "0.25.0"
+      }
+    },
+    "node_modules/escalade": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz",
+      "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/escape-html": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz",
+      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+      "license": "MIT"
+    },
+    "node_modules/estree-walker": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz",
+      "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+      "license": "MIT"
+    },
+    "node_modules/execa": {
+      "version": "9.5.2",
+      "resolved": "https://registry.npmmirror.com/execa/-/execa-9.5.2.tgz",
+      "integrity": "sha512-EHlpxMCpHWSAh1dgS6bVeoLAXGnJNdR93aabr4QCGbzOM73o5XmRfM/e5FUqsw3aagP8S8XEWUWFAxnRBnAF0Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@sindresorhus/merge-streams": "^4.0.0",
+        "cross-spawn": "^7.0.3",
+        "figures": "^6.1.0",
+        "get-stream": "^9.0.0",
+        "human-signals": "^8.0.0",
+        "is-plain-obj": "^4.1.0",
+        "is-stream": "^4.0.1",
+        "npm-run-path": "^6.0.0",
+        "pretty-ms": "^9.0.0",
+        "signal-exit": "^4.1.0",
+        "strip-final-newline": "^4.0.0",
+        "yoctocolors": "^2.0.0"
+      },
+      "engines": {
+        "node": "^18.19.0 || >=20.5.0"
+      },
+      "funding": {
+        "url": "https://github.com/sindresorhus/execa?sponsor=1"
+      }
+    },
+    "node_modules/figures": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmmirror.com/figures/-/figures-6.1.0.tgz",
+      "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-unicode-supported": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/follow-redirects": {
+      "version": "1.15.9",
+      "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.9.tgz",
+      "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://github.com/sponsors/RubenVerborgh"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=4.0"
+      },
+      "peerDependenciesMeta": {
+        "debug": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/form-data": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.2.tgz",
+      "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
+      "license": "MIT",
+      "dependencies": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "es-set-tostringtag": "^2.1.0",
+        "mime-types": "^2.1.12"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/frac": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/frac/-/frac-1.1.2.tgz",
+      "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
+    "node_modules/fs-extra": {
+      "version": "11.3.0",
+      "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-11.3.0.tgz",
+      "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "graceful-fs": "^4.2.0",
+        "jsonfile": "^6.0.1",
+        "universalify": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=14.14"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/gensync": {
+      "version": "1.0.0-beta.2",
+      "resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz",
+      "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/get-stream": {
+      "version": "9.0.1",
+      "resolved": "https://registry.npmmirror.com/get-stream/-/get-stream-9.0.1.tgz",
+      "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@sec-ant/readable-stream": "^0.4.1",
+        "is-stream": "^4.0.1"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/globals": {
+      "version": "11.12.0",
+      "resolved": "https://registry.npmmirror.com/globals/-/globals-11.12.0.tgz",
+      "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/graceful-fs": {
+      "version": "4.2.11",
+      "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz",
+      "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-tostringtag": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+      "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+      "license": "MIT",
+      "dependencies": {
+        "has-symbols": "^1.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz",
+      "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/hookable": {
+      "version": "5.5.3",
+      "resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz",
+      "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/html-tags": {
+      "version": "3.3.1",
+      "resolved": "https://registry.npmmirror.com/html-tags/-/html-tags-3.3.1.tgz",
+      "integrity": "sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/human-signals": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmmirror.com/human-signals/-/human-signals-8.0.0.tgz",
+      "integrity": "sha512-/1/GPCpDUCCYwlERiYjxoczfP0zfvZMU/OWgQPMya9AbAE24vseigFdhAMObpc8Q4lc/kjutPfUddDYyAmejnA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=18.18.0"
+      }
+    },
+    "node_modules/is-docker": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmmirror.com/is-docker/-/is-docker-3.0.0.tgz",
+      "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "is-docker": "cli.js"
+      },
+      "engines": {
+        "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/is-inside-container": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/is-inside-container/-/is-inside-container-1.0.0.tgz",
+      "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-docker": "^3.0.0"
+      },
+      "bin": {
+        "is-inside-container": "cli.js"
+      },
+      "engines": {
+        "node": ">=14.16"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/is-plain-obj": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmmirror.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
+      "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/is-stream": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmmirror.com/is-stream/-/is-stream-4.0.1.tgz",
+      "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/is-unicode-supported": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmmirror.com/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz",
+      "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/is-what": {
+      "version": "4.1.16",
+      "resolved": "https://registry.npmmirror.com/is-what/-/is-what-4.1.16.tgz",
+      "integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.13"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/mesqueeb"
+      }
+    },
+    "node_modules/is-wsl": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmmirror.com/is-wsl/-/is-wsl-3.1.0.tgz",
+      "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-inside-container": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=16"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/isexe": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz",
+      "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/js-tokens": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
+      "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/jsesc": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz",
+      "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "jsesc": "bin/jsesc"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/json5": {
+      "version": "2.2.3",
+      "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz",
+      "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "json5": "lib/cli.js"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/jsonfile": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-6.1.0.tgz",
+      "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "universalify": "^2.0.0"
+      },
+      "optionalDependencies": {
+        "graceful-fs": "^4.1.6"
+      }
+    },
+    "node_modules/kolorist": {
+      "version": "1.8.0",
+      "resolved": "https://registry.npmmirror.com/kolorist/-/kolorist-1.8.0.tgz",
+      "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/lodash": {
+      "version": "4.17.21",
+      "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",
+      "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+      "license": "MIT"
+    },
+    "node_modules/lodash-es": {
+      "version": "4.17.21",
+      "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz",
+      "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
+      "license": "MIT"
+    },
+    "node_modules/lodash-unified": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz",
+      "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/lodash-es": "*",
+        "lodash": "*",
+        "lodash-es": "*"
+      }
+    },
+    "node_modules/lru-cache": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz",
+      "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "yallist": "^3.0.2"
+      }
+    },
+    "node_modules/magic-string": {
+      "version": "0.30.17",
+      "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.17.tgz",
+      "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==",
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.0"
+      }
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/memoize-one": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz",
+      "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
+      "license": "MIT"
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mitt": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz",
+      "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/mrmime": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmmirror.com/mrmime/-/mrmime-2.0.1.tgz",
+      "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.8",
+      "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.8.tgz",
+      "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/node-releases": {
+      "version": "2.0.19",
+      "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.19.tgz",
+      "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/normalize-wheel-es": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz",
+      "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==",
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/npm-run-path": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmmirror.com/npm-run-path/-/npm-run-path-6.0.0.tgz",
+      "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "path-key": "^4.0.0",
+        "unicorn-magic": "^0.3.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/npm-run-path/node_modules/path-key": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmmirror.com/path-key/-/path-key-4.0.0.tgz",
+      "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/open": {
+      "version": "10.1.0",
+      "resolved": "https://registry.npmmirror.com/open/-/open-10.1.0.tgz",
+      "integrity": "sha512-mnkeQ1qP5Ue2wd+aivTD3NHd/lZ96Lu0jgf0pwktLPtx6cTZiH7tyeGRRHs0zX0rbrahXPnXlUnbeXyaBBuIaw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "default-browser": "^5.2.1",
+        "define-lazy-prop": "^3.0.0",
+        "is-inside-container": "^1.0.0",
+        "is-wsl": "^3.1.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/parse-ms": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmmirror.com/parse-ms/-/parse-ms-4.0.0.tgz",
+      "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/path-key": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz",
+      "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/pathe": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz",
+      "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/perfect-debounce": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
+      "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "license": "ISC"
+    },
+    "node_modules/picomatch": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.2.tgz",
+      "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.5.3",
+      "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.3.tgz",
+      "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==",
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.8",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/pretty-ms": {
+      "version": "9.2.0",
+      "resolved": "https://registry.npmmirror.com/pretty-ms/-/pretty-ms-9.2.0.tgz",
+      "integrity": "sha512-4yf0QO/sllf/1zbZWYnvWw3NxCQwLXKzIj0G849LSufP15BXKM0rbD2Z3wVnkMfjdn/CB0Dpp444gYAACdsplg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "parse-ms": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/proxy-from-env": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+      "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
+      "license": "MIT"
+    },
+    "node_modules/rfdc": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz",
+      "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/rollup": {
+      "version": "4.34.8",
+      "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.34.8.tgz",
+      "integrity": "sha512-489gTVMzAYdiZHFVA/ig/iYFllCcWFHMvUHI1rpFmkoUtRlQxqh6/yiNqnYibjMZ2b/+FUQwldG+aLsEt6bglQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "1.0.6"
+      },
+      "bin": {
+        "rollup": "dist/bin/rollup"
+      },
+      "engines": {
+        "node": ">=18.0.0",
+        "npm": ">=8.0.0"
+      },
+      "optionalDependencies": {
+        "@rollup/rollup-android-arm-eabi": "4.34.8",
+        "@rollup/rollup-android-arm64": "4.34.8",
+        "@rollup/rollup-darwin-arm64": "4.34.8",
+        "@rollup/rollup-darwin-x64": "4.34.8",
+        "@rollup/rollup-freebsd-arm64": "4.34.8",
+        "@rollup/rollup-freebsd-x64": "4.34.8",
+        "@rollup/rollup-linux-arm-gnueabihf": "4.34.8",
+        "@rollup/rollup-linux-arm-musleabihf": "4.34.8",
+        "@rollup/rollup-linux-arm64-gnu": "4.34.8",
+        "@rollup/rollup-linux-arm64-musl": "4.34.8",
+        "@rollup/rollup-linux-loongarch64-gnu": "4.34.8",
+        "@rollup/rollup-linux-powerpc64le-gnu": "4.34.8",
+        "@rollup/rollup-linux-riscv64-gnu": "4.34.8",
+        "@rollup/rollup-linux-s390x-gnu": "4.34.8",
+        "@rollup/rollup-linux-x64-gnu": "4.34.8",
+        "@rollup/rollup-linux-x64-musl": "4.34.8",
+        "@rollup/rollup-win32-arm64-msvc": "4.34.8",
+        "@rollup/rollup-win32-ia32-msvc": "4.34.8",
+        "@rollup/rollup-win32-x64-msvc": "4.34.8",
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/run-applescript": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmmirror.com/run-applescript/-/run-applescript-7.0.0.tgz",
+      "integrity": "sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/semver": {
+      "version": "6.3.1",
+      "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz",
+      "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+      "dev": true,
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      }
+    },
+    "node_modules/shebang-command": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz",
+      "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "shebang-regex": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/shebang-regex": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz",
+      "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/signal-exit": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-4.1.0.tgz",
+      "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": ">=14"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/sirv": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmmirror.com/sirv/-/sirv-3.0.1.tgz",
+      "integrity": "sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@polka/url": "^1.0.0-next.24",
+        "mrmime": "^2.0.0",
+        "totalist": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/speakingurl": {
+      "version": "14.0.1",
+      "resolved": "https://registry.npmmirror.com/speakingurl/-/speakingurl-14.0.1.tgz",
+      "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/ssf": {
+      "version": "0.11.2",
+      "resolved": "https://registry.npmmirror.com/ssf/-/ssf-0.11.2.tgz",
+      "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "frac": "~1.1.2"
+      },
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
+    "node_modules/strip-final-newline": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmmirror.com/strip-final-newline/-/strip-final-newline-4.0.0.tgz",
+      "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/superjson": {
+      "version": "2.2.2",
+      "resolved": "https://registry.npmmirror.com/superjson/-/superjson-2.2.2.tgz",
+      "integrity": "sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "copy-anything": "^3.0.2"
+      },
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/svg-tags": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/svg-tags/-/svg-tags-1.0.0.tgz",
+      "integrity": "sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==",
+      "dev": true
+    },
+    "node_modules/totalist": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmmirror.com/totalist/-/totalist-3.0.1.tgz",
+      "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/tslib": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz",
+      "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
+      "license": "0BSD"
+    },
+    "node_modules/unicorn-magic": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmmirror.com/unicorn-magic/-/unicorn-magic-0.3.0.tgz",
+      "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/universalify": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmmirror.com/universalify/-/universalify-2.0.1.tgz",
+      "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 10.0.0"
+      }
+    },
+    "node_modules/update-browserslist-db": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.1.2.tgz",
+      "integrity": "sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/browserslist"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "escalade": "^3.2.0",
+        "picocolors": "^1.1.1"
+      },
+      "bin": {
+        "update-browserslist-db": "cli.js"
+      },
+      "peerDependencies": {
+        "browserslist": ">= 4.21.0"
+      }
+    },
+    "node_modules/vite": {
+      "version": "6.2.0",
+      "resolved": "https://registry.npmmirror.com/vite/-/vite-6.2.0.tgz",
+      "integrity": "sha512-7dPxoo+WsT/64rDcwoOjk76XHj+TqNTIvHKcuMQ1k4/SeHDaQt5GFAeLYzrimZrMpn/O6DtdI03WUjdxuPM0oQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "esbuild": "^0.25.0",
+        "postcss": "^8.5.3",
+        "rollup": "^4.30.1"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+        "jiti": ">=1.21.0",
+        "less": "*",
+        "lightningcss": "^1.21.0",
+        "sass": "*",
+        "sass-embedded": "*",
+        "stylus": "*",
+        "sugarss": "*",
+        "terser": "^5.16.0",
+        "tsx": "^4.8.1",
+        "yaml": "^2.4.2"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "jiti": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "lightningcss": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        },
+        "tsx": {
+          "optional": true
+        },
+        "yaml": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vite-hot-client": {
+      "version": "0.2.4",
+      "resolved": "https://registry.npmmirror.com/vite-hot-client/-/vite-hot-client-0.2.4.tgz",
+      "integrity": "sha512-a1nzURqO7DDmnXqabFOliz908FRmIppkBKsJthS8rbe8hBEXwEwe4C3Pp33Z1JoFCYfVL4kTOMLKk0ZZxREIeA==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      },
+      "peerDependencies": {
+        "vite": "^2.6.0 || ^3.0.0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0"
+      }
+    },
+    "node_modules/vite-plugin-inspect": {
+      "version": "0.8.9",
+      "resolved": "https://registry.npmmirror.com/vite-plugin-inspect/-/vite-plugin-inspect-0.8.9.tgz",
+      "integrity": "sha512-22/8qn+LYonzibb1VeFZmISdVao5kC22jmEKm24vfFE8siEn47EpVcCLYMv6iKOYMJfjSvSJfueOwcFCkUnV3A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@antfu/utils": "^0.7.10",
+        "@rollup/pluginutils": "^5.1.3",
+        "debug": "^4.3.7",
+        "error-stack-parser-es": "^0.1.5",
+        "fs-extra": "^11.2.0",
+        "open": "^10.1.0",
+        "perfect-debounce": "^1.0.0",
+        "picocolors": "^1.1.1",
+        "sirv": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=14"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      },
+      "peerDependencies": {
+        "vite": "^3.1.0 || ^4.0.0 || ^5.0.0-0 || ^6.0.1"
+      },
+      "peerDependenciesMeta": {
+        "@nuxt/kit": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vite-plugin-vue-devtools": {
+      "version": "7.7.2",
+      "resolved": "https://registry.npmmirror.com/vite-plugin-vue-devtools/-/vite-plugin-vue-devtools-7.7.2.tgz",
+      "integrity": "sha512-5V0UijQWiSBj32blkyPEqIbzc6HO9c1bwnBhx+ay2dzU0FakH+qMdNUT8nF9BvDE+i6I1U8CqCuJiO20vKEdQw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vue/devtools-core": "^7.7.2",
+        "@vue/devtools-kit": "^7.7.2",
+        "@vue/devtools-shared": "^7.7.2",
+        "execa": "^9.5.1",
+        "sirv": "^3.0.0",
+        "vite-plugin-inspect": "0.8.9",
+        "vite-plugin-vue-inspector": "^5.3.1"
+      },
+      "engines": {
+        "node": ">=v14.21.3"
+      },
+      "peerDependencies": {
+        "vite": "^3.1.0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0"
+      }
+    },
+    "node_modules/vite-plugin-vue-inspector": {
+      "version": "5.3.1",
+      "resolved": "https://registry.npmmirror.com/vite-plugin-vue-inspector/-/vite-plugin-vue-inspector-5.3.1.tgz",
+      "integrity": "sha512-cBk172kZKTdvGpJuzCCLg8lJ909wopwsu3Ve9FsL1XsnLBiRT9U3MePcqrgGHgCX2ZgkqZmAGR8taxw+TV6s7A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/core": "^7.23.0",
+        "@babel/plugin-proposal-decorators": "^7.23.0",
+        "@babel/plugin-syntax-import-attributes": "^7.22.5",
+        "@babel/plugin-syntax-import-meta": "^7.10.4",
+        "@babel/plugin-transform-typescript": "^7.22.15",
+        "@vue/babel-plugin-jsx": "^1.1.5",
+        "@vue/compiler-dom": "^3.3.4",
+        "kolorist": "^1.8.0",
+        "magic-string": "^0.30.4"
+      },
+      "peerDependencies": {
+        "vite": "^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0"
+      }
+    },
+    "node_modules/vue": {
+      "version": "3.5.13",
+      "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.13.tgz",
+      "integrity": "sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.13",
+        "@vue/compiler-sfc": "3.5.13",
+        "@vue/runtime-dom": "3.5.13",
+        "@vue/server-renderer": "3.5.13",
+        "@vue/shared": "3.5.13"
+      },
+      "peerDependencies": {
+        "typescript": "*"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vue-router": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.5.0.tgz",
+      "integrity": "sha512-HDuk+PuH5monfNuY+ct49mNmkCRK4xJAV9Ts4z9UFc4rzdDnxQLyCMGGc8pKhZhHTVzfanpNwB/lwqevcBwI4w==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/devtools-api": "^6.6.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/posva"
+      },
+      "peerDependencies": {
+        "vue": "^3.2.0"
+      }
+    },
+    "node_modules/which": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz",
+      "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "isexe": "^2.0.0"
+      },
+      "bin": {
+        "node-which": "bin/node-which"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/wmf": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/wmf/-/wmf-1.0.2.tgz",
+      "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
+    "node_modules/word": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmmirror.com/word/-/word-0.3.0.tgz",
+      "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
+    "node_modules/xlsx": {
+      "version": "0.18.5",
+      "resolved": "https://registry.npmmirror.com/xlsx/-/xlsx-0.18.5.tgz",
+      "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "adler-32": "~1.3.0",
+        "cfb": "~1.2.1",
+        "codepage": "~1.15.0",
+        "crc-32": "~1.2.1",
+        "ssf": "~0.11.2",
+        "wmf": "~1.0.1",
+        "word": "~0.3.0"
+      },
+      "bin": {
+        "xlsx": "bin/xlsx.njs"
+      },
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
+    "node_modules/yallist": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz",
+      "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/yoctocolors": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmmirror.com/yoctocolors/-/yoctocolors-2.1.1.tgz",
+      "integrity": "sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/zrender": {
+      "version": "5.6.1",
+      "resolved": "https://registry.npmmirror.com/zrender/-/zrender-5.6.1.tgz",
+      "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "tslib": "2.3.0"
+      }
+    }
+  }
+}

+ 25 - 0
bridge-disease-frontend-main/package.json

@@ -0,0 +1,25 @@
+{
+  "name": "dockscope",
+  "version": "0.0.0",
+  "private": true,
+  "description": "检澜 DockScope — 桥梁安全隐患智能检测工作台(前端)",
+  "type": "module",
+  "scripts": {
+    "serve": "vite",
+    "build": "vite build",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "axios": "^1.8.1",
+    "echarts": "^5.6.0",
+    "element-plus": "^2.9.5",
+    "vue": "^3.5.13",
+    "vue-router": "^4.5.0",
+    "xlsx": "^0.18.5"
+  },
+  "devDependencies": {
+    "@vitejs/plugin-vue": "^5.2.1",
+    "vite": "^6.1.0",
+    "vite-plugin-vue-devtools": "^7.7.2"
+  }
+}

+ 21 - 0
bridge-disease-frontend-main/public/bridge-disease.svg

@@ -0,0 +1,21 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
+  <!-- 背景圆形 -->
+  <circle cx="32" cy="32" r="30" fill="#3498db" />
+  
+  <!-- 桥梁主体 -->
+  <path d="M10 36 L54 36" stroke="#ffffff" stroke-width="4" fill="none" />
+  
+  <!-- 桥梁支柱 -->
+  <path d="M16 36 L16 46" stroke="#ffffff" stroke-width="3" fill="none" />
+  <path d="M48 36 L48 46" stroke="#ffffff" stroke-width="3" fill="none" />
+  
+  <!-- 桥梁拱形 -->
+  <path d="M16 36 Q32 24 48 36" stroke="#ffffff" stroke-width="3" fill="none" />
+  
+  <!-- 检测元素 - 放大镜 -->
+  <circle cx="40" cy="28" r="8" fill="none" stroke="#ffffff" stroke-width="2" />
+  <path d="M46 34 L52 40" stroke="#ffffff" stroke-width="3" />
+  
+  <!-- 病害标记 -->
+  <circle cx="24" cy="32" r="3" fill="#ff5252" />
+</svg>

+ 70 - 0
bridge-disease-frontend-main/src/App.vue

@@ -0,0 +1,70 @@
+<script setup>
+import { computed } from 'vue'
+import { RouterView, useRoute } from 'vue-router'
+import HeaderComponent from './components/HeaderComponent.vue'
+import FooterComponent from './components/FooterComponent.vue'
+import AgentAssistantDrawer from './components/shell/AgentAssistantDrawer.vue'
+import { useHudTheme } from './composables/useHudTheme'
+
+useHudTheme()
+
+const route = useRoute()
+const isAuthPage = computed(() => {
+  const path = route.path
+  return path === '/login' || path === '/register' || path === '/forgot-password'
+})
+</script>
+
+<template>
+  <div class="app-container hud-app">
+    <HeaderComponent v-if="!isAuthPage" />
+    <RouterView v-slot="{ Component }" class="router-view-container">
+      <Transition name="hud-route" mode="out-in">
+        <component :is="Component" :key="route.fullPath" />
+      </Transition>
+    </RouterView>
+    <FooterComponent />
+    <AgentAssistantDrawer />
+  </div>
+</template>
+
+<style>
+:root,
+body,
+#app {
+  margin: 0;
+  padding: 0;
+  width: 100%;
+  height: 100%;
+  overflow-x: hidden;
+}
+
+.app-container {
+  min-height: 100vh;
+  min-height: 100dvh;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  overflow-y: auto;
+}
+
+/* 让登录等全屏页在「顶栏/页尾」之间拿到确定高度,子级才能稳定做左右分栏 */
+.router-view-container {
+  flex: 1 1 0%;
+  min-height: 0;
+  width: 100%;
+  max-width: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: stretch;
+}
+
+.router-view-container > * {
+  flex: 1 1 auto;
+  width: 100%;
+  max-width: 100%;
+  min-width: 0;
+  min-height: 0;
+  box-sizing: border-box;
+}
+</style>

+ 69 - 0
bridge-disease-frontend-main/src/components/BreadcrumbNav.vue

@@ -0,0 +1,69 @@
+<script setup>
+import { computed } from 'vue'
+import { useRoute } from 'vue-router'
+
+const route = useRoute()
+
+// 根据当前路由生成面包屑数据
+const breadcrumbs = computed(() => {
+  const pathArray = route.path.split('/').filter(Boolean)
+  const result = [{ name: '数据看板', path: '/home' }]
+
+  let currentPath = ''
+
+  pathArray.forEach((segment) => {
+    currentPath += `/${segment}`
+
+    // 根据路径生成名称(可以根据实际路由配置调整)
+    let name = segment.charAt(0).toUpperCase() + segment.slice(1)
+
+    // 针对特定路径的名称映射
+    const nameMap = {
+      'home': '数据看板',
+      'disease-detection': '桥梁安全隐患检测',
+      'detection-records': '安全隐患检测记录',
+      'batch-detection': '批量安全隐患检测',
+      'defect-ledger': '安全隐患台账',
+      'report-center': '报告中心',
+      'media-library': '媒体库',
+      'sensor-management': '传感器管理',
+      'data-collection': '数据采集',
+      'data-processing': '数据处理',
+      'alert-management': '预警管理',
+      'model-library': '模型库',
+      'user-management': '用户管理',
+      'operation-logs': '系统操作日志',
+      'user-center': '个人中心'
+    }
+
+    if (nameMap[segment]) {
+      name = nameMap[segment]
+    }
+
+    // 不重复添加数据看板根节点
+    if (segment !== 'home') {
+      result.push({ name, path: currentPath })
+    }
+  })
+
+  return result
+})
+</script>
+
+<template>
+  <div class="breadcrumb-container">
+    <el-breadcrumb separator="/">
+      <el-breadcrumb-item v-for="(item, index) in breadcrumbs" :key="index"
+        :to="index < breadcrumbs.length - 1 ? { path: item.path } : null">
+        {{ item.name }}
+      </el-breadcrumb-item>
+    </el-breadcrumb>
+  </div>
+</template>
+
+<style scoped>
+.breadcrumb-container {
+  padding: 2px 0;
+  background-color: transparent;
+}
+</style>

+ 217 - 0
bridge-disease-frontend-main/src/components/ContactSupportCard.vue

@@ -0,0 +1,217 @@
+<script setup>
+import { ref, computed, onMounted } from 'vue'
+import { Message, Phone, ChatLineRound, Loading } from '@element-plus/icons-vue'
+import { ElMessage } from 'element-plus'
+import request from '../utils/request'
+import { useUserStore } from '../stores/userStore'
+
+const props = defineProps({
+  // 卡片标题
+  title: {
+    type: String,
+    default: '联系支持'
+  },
+  // 卡片描述
+  description: {
+    type: String,
+    default: '如需帮助,请通过以下方式联系:'
+  }
+})
+
+const { userInfo } = useUserStore()
+const contactInfo = ref(null)
+const loading = ref(false)
+
+// 判断用户角色
+const isAdmin = computed(() => userInfo.value?.role === 'ADMIN')
+
+// 获取联系人信息
+const getContactInfo = async () => {
+  try {
+    loading.value = true
+    
+    // 根据用户角色获取不同的联系人信息
+    const endpoint = isAdmin.value ? '/user/developer_info' : '/user/admin_info'
+    const data = await request.get(endpoint)
+    console.info(`【随机获取${isAdmin.value ? '开发人员' : '管理员'}信息响应数据】`, data)
+    
+    // 根据接口返回的字段名称设置联系人信息
+    if (isAdmin.value && data && data.developer_info) {
+      contactInfo.value = data.developer_info
+    } else if (!isAdmin.value && data && data.admin_info) {
+      contactInfo.value = data.admin_info
+    }
+  } catch (error) {
+    console.error(`【随机获取${isAdmin.value ? '开发人员' : '管理员'}信息错误】`, error)
+    ElMessage.error({
+      message: `【随机获取${isAdmin.value ? '开发人员' : '管理员'}信息错误】${error?.message || '请稍后重试'}`,
+      duration: 5000
+    })
+  } finally {
+    loading.value = false
+  }
+}
+
+// 组件挂载时获取联系人信息
+onMounted(() => {
+  getContactInfo()
+})
+</script>
+
+<template>
+  <div class="contact-card">
+    <h3 class="card-title">{{ title }}</h3>
+    <div class="contact-info">
+      <div class="info-icon">
+        <el-icon size="50">
+          <ChatLineRound />
+        </el-icon>
+      </div>
+      <p class="info-desc">{{ description }}</p>
+      
+      <div v-if="loading" class="loading-container">
+        <el-icon class="is-loading" size="24">
+          <Loading />
+        </el-icon>
+        <span>正在加载{{ isAdmin ? '开发人员' : '管理员' }}信息...</span>
+      </div>
+      
+      <div v-else-if="!contactInfo" class="no-contact-info">
+        <p>暂无可用的{{ isAdmin ? '开发人员' : '管理员' }}联系信息,请稍后再试</p>
+      </div>
+      
+      <div v-else class="contact-group">
+        <div class="contact-title">{{ contactInfo.role === 'ADMIN' ? '管理员' : '开发人员' }}: {{ contactInfo.last_name }}{{ contactInfo.first_name }}</div>
+        
+        <div v-if="contactInfo.email" class="contact-item">
+          <el-icon>
+            <Message />
+          </el-icon>
+          <span>邮箱:{{ contactInfo.email }}</span>
+        </div>
+        
+        <div v-if="contactInfo.phone" class="contact-item">
+          <el-icon>
+            <Phone />
+          </el-icon>
+          <span>联系电话:{{ contactInfo.phone }}</span>
+        </div>
+      </div>
+
+      <p class="contact-note">联系时请提供您的用户名/注册邮箱,以便{{ isAdmin ? '开发人员' : '管理员' }}核实您的身份</p>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.contact-card {
+  background: rgba(255, 255, 255, 0.1);
+  backdrop-filter: blur(10px);
+  border-radius: 12px;
+  padding: 20px;
+  width: 100%;
+  box-sizing: border-box;
+  box-shadow: 0 4px 16px rgba(31, 38, 135, 0.2);
+  border: 1px solid rgba(255, 255, 255, 0.18);
+  margin-bottom: 20px;
+  transition: all 0.3s ease;
+}
+
+.contact-card:hover {
+  transform: translateY(-5px);
+  box-shadow: 0 6px 20px rgba(31, 38, 135, 0.3);
+}
+
+.card-title {
+  color: var(--el-color-primary);
+  margin-bottom: 15px;
+  font-size: 1.2em;
+  font-weight: 500;
+}
+
+.contact-info {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  text-align: center;
+}
+
+.info-icon {
+  margin-bottom: 15px;
+  color: var(--el-color-primary);
+}
+
+.info-desc {
+  font-size: 14px;
+  margin-bottom: 20px;
+  color: var(--el-text-color-regular);
+}
+
+.contact-item {
+  display: flex;
+  align-items: center;
+  margin-bottom: 12px;
+  background: rgba(var(--el-color-primary-rgb), 0.1);
+  padding: 10px 15px;
+  border-radius: 8px;
+  transition: all 0.3s ease;
+}
+
+.contact-item:hover {
+  background: rgba(var(--el-color-primary-rgb), 0.15);
+  transform: translateY(-2px);
+}
+
+.contact-item .el-icon {
+  margin-right: 10px;
+  color: var(--el-color-primary);
+}
+
+.contact-note {
+  font-size: 12px;
+  margin: 10px 0;
+  color: var(--el-text-color-secondary);
+  font-style: italic;
+}
+
+.loading-container {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  margin: 15px 0;
+  color: var(--el-text-color-secondary);
+}
+
+.loading-container .el-icon {
+  margin-bottom: 8px;
+}
+
+.no-contact-info {
+  background: rgba(var(--el-color-info-rgb), 0.1);
+  padding: 12px;
+  border-radius: 8px;
+  margin: 12px 0;
+  width: 100%;
+  text-align: center;
+  color: var(--el-text-color-secondary);
+}
+
+.contact-group {
+  width: 100%;
+  margin-bottom: 15px;
+  background: rgba(var(--el-color-primary-rgb), 0.05);
+  border-radius: 10px;
+  padding: 12px;
+  border: 1px solid rgba(var(--el-color-primary-rgb), 0.1);
+}
+
+.contact-title {
+  font-weight: 500;
+  margin-bottom: 10px;
+  color: var(--el-text-color-primary);
+  font-size: 15px;
+  text-align: left;
+  padding-bottom: 8px;
+  border-bottom: 1px solid rgba(var(--el-color-primary-rgb), 0.1);
+}
+</style>

+ 25 - 0
bridge-disease-frontend-main/src/components/FooterComponent.vue

@@ -0,0 +1,25 @@
+<script setup>
+import { computed } from 'vue'
+import { PRODUCT_TITLE, PRODUCT_TAGLINE } from '../shellConstants'
+
+const currentYear = computed(() => new Date().getFullYear())
+</script>
+
+<template>
+  <footer class="shell-footer">
+    <p>© {{ currentYear }} {{ PRODUCT_TITLE }} · {{ PRODUCT_TAGLINE }}</p>
+  </footer>
+</template>
+
+<style scoped>
+.shell-footer {
+  width: 100%;
+  text-align: center;
+  padding: 10px 12px 14px;
+  font-size: 12px;
+  color: color-mix(in srgb, var(--foreground) 42%, transparent);
+  position: relative;
+  z-index: 20;
+  background: linear-gradient(180deg, transparent, color-mix(in srgb, var(--card-solid) 88%, transparent));
+}
+</style>

+ 206 - 0
bridge-disease-frontend-main/src/components/HeaderComponent.vue

@@ -0,0 +1,206 @@
+<script setup>
+import { useRouter } from 'vue-router'
+import { ElMessage } from 'element-plus'
+import {
+  User,
+  Setting,
+  SwitchButton,
+  ChatDotRound,
+  Promotion,
+  Sunny,
+  Moon,
+} from '@element-plus/icons-vue'
+import request from '../utils/request'
+import { useUserStore } from '../stores/userStore'
+import { openAgentDrawer } from '../stores/uiShellStore'
+import { useHudTheme } from '../composables/useHudTheme'
+import {
+  GITCC_COMMUNITY_URL,
+  GITCC_LOGO_URL,
+  PRODUCT_TITLE,
+  PRODUCT_TAGLINE,
+} from '../shellConstants'
+
+const requestBaseURL = request.defaults.baseURL
+const router = useRouter()
+const { userInfo, userLoading } = useUserStore()
+const { theme, toggleTheme } = useHudTheme()
+
+const goToUserCenter = () => {
+  router.push('/user-center')
+}
+
+const handleLogout = async () => {
+  try {
+    const data = await request.post('/user/logout')
+    console.info('【登出响应数据】', data)
+    const operation = data.operation
+    if (operation?.status === 'SUCCESS') {
+      ElMessage.success({ message: '【登出成功】', duration: 3000 })
+    }
+  } catch (error) {
+    console.warning('【登出警告】', error)
+    ElMessage.warning({
+      message: '【登出警告】登出可能未完全成功,但您已在本地退出',
+      duration: 4000,
+    })
+  } finally {
+    localStorage.removeItem('access_token')
+    localStorage.removeItem('refresh_token')
+    localStorage.removeItem('login_user')
+    router.push('/login')
+  }
+}
+
+const openCommunity = () => {
+  window.open(GITCC_COMMUNITY_URL, '_blank', 'noopener,noreferrer')
+}
+</script>
+
+<template>
+  <header class="hud-top-chrome">
+    <div class="hud-top-chrome-inner">
+      <div class="brand">
+        <img
+          :src="GITCC_LOGO_URL"
+          :alt="PRODUCT_TITLE"
+          class="brand-mark"
+          width="40"
+          height="40"
+        />
+        <div class="brand-text">
+          <span class="name">{{ PRODUCT_TITLE }}</span>
+          <span class="tag">{{ PRODUCT_TAGLINE }}</span>
+        </div>
+      </div>
+
+      <div class="actions" v-if="userInfo && !userLoading">
+        <el-tooltip content="GitCC 门户(浏览器跳转)" placement="bottom">
+          <el-button class="hud-btn-ghost" @click="openCommunity" :icon="Promotion">开源社区</el-button>
+        </el-tooltip>
+        <el-button type="primary" class="hud-btn-primary" @click="openAgentDrawer" :icon="ChatDotRound">
+          智能助手
+        </el-button>
+        <el-button class="hud-btn-ghost" circle @click="toggleTheme" :title="theme === 'dark' ? '浅色' : '深色'">
+          <el-icon><Moon v-if="theme === 'light'" /><Sunny v-else /></el-icon>
+        </el-button>
+        <el-dropdown trigger="click">
+          <div class="user-chip">
+            <el-avatar :size="36" :src="userInfo.avatar_path ? `${requestBaseURL}/file/${userInfo.avatar_path}` : ''">
+              <el-icon><User /></el-icon>
+            </el-avatar>
+            <span class="uname">{{ userInfo.username }}</span>
+          </div>
+          <template #dropdown>
+            <el-dropdown-menu>
+              <el-dropdown-item @click="goToUserCenter">
+                <el-icon><Setting /></el-icon>
+                <span>个人中心</span>
+              </el-dropdown-item>
+              <el-dropdown-item divided @click="handleLogout">
+                <el-icon><SwitchButton /></el-icon>
+                <span>退出登录</span>
+              </el-dropdown-item>
+            </el-dropdown-menu>
+          </template>
+        </el-dropdown>
+      </div>
+
+      <div v-else-if="userLoading" class="sk">
+        <el-skeleton style="width: 220px" :rows="1" animated />
+      </div>
+    </div>
+  </header>
+</template>
+
+<style scoped>
+.brand {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  min-width: 0;
+}
+.brand-mark {
+  border-radius: 10px;
+  flex-shrink: 0;
+  box-shadow: 0 0 0 1px color-mix(in srgb, var(--primary) 25%, transparent);
+}
+.brand-text {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+  min-width: 0;
+}
+.name {
+  font-weight: 800;
+  font-size: 1.05rem;
+  color: var(--foreground);
+  letter-spacing: -0.02em;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.tag {
+  font-size: 0.72rem;
+  color: var(--muted);
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.actions {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+.hud-btn-ghost {
+  border-color: color-mix(in srgb, var(--primary) 32%, var(--border));
+  color: var(--foreground);
+  background: color-mix(in srgb, var(--card-solid) 65%, transparent);
+}
+.hud-btn-primary {
+  background: linear-gradient(180deg, color-mix(in srgb, var(--primary) 100%, #000), var(--primary));
+  border-color: color-mix(in srgb, var(--primary) 80%, #000);
+  box-shadow: 0 6px 20px color-mix(in srgb, var(--primary) 35%, transparent);
+}
+.hud-btn-primary:active {
+  transform: translateY(1px) scale(0.98);
+  filter: brightness(0.95);
+}
+.user-chip {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  cursor: pointer;
+  padding: 6px 10px;
+  border-radius: 999px;
+  background: color-mix(in srgb, var(--foreground) 5%, transparent);
+  border: 1px solid var(--border);
+  transition: background 0.2s, box-shadow 0.2s;
+}
+.user-chip:hover {
+  background: color-mix(in srgb, var(--primary) 10%, transparent);
+  box-shadow: 0 0 0 1px color-mix(in srgb, var(--primary) 25%, transparent);
+}
+.uname {
+  font-size: 0.9rem;
+  font-weight: 600;
+  color: var(--foreground);
+  max-width: 120px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.sk {
+  width: 220px;
+}
+@media (max-width: 900px) {
+  .tag {
+    display: none;
+  }
+}
+@media (max-width: 640px) {
+  .hud-btn-ghost:not(.is-circle) :deep(span) {
+    display: none;
+  }
+}
+</style>

+ 148 - 0
bridge-disease-frontend-main/src/components/ParticleBackground.vue

@@ -0,0 +1,148 @@
+<script setup>
+import { ref, onMounted, onUnmounted } from 'vue'
+
+const props = defineProps({
+  particleCount: {
+    type: Number,
+    default: 180
+  }
+})
+
+const canvasRef = ref(null)
+
+let particles = []
+let mouse = { x: 0, y: 0 }
+let animationFrameId = null
+
+class Particle {
+  constructor(x, y) {
+    this.x = x
+    this.y = y
+    this.vx = (Math.random() - 0.5) * 1.0
+    this.vy = (Math.random() - 0.5) * 1.0
+    this.radius = Math.random() * 1.5 + 0.5
+  }
+
+  update(width, height) {
+    this.x += this.vx
+    this.y += this.vy
+
+    if (this.x < 0 || this.x > width) this.vx = -this.vx
+    if (this.y < 0 || this.y > height) this.vy = -this.vy
+  }
+}
+
+const initCanvas = () => {
+  const canvas = canvasRef.value
+  const ctx = canvas.getContext('2d')
+  const dpr = window.devicePixelRatio || 1
+
+  const updateCanvasSize = () => {
+    canvas.width = window.innerWidth * dpr
+    canvas.height = window.innerHeight * dpr
+    ctx.scale(dpr, dpr)
+    canvas.style.width = `${window.innerWidth}px`
+    canvas.style.height = `${window.innerHeight}px`
+  }
+  
+  // 初始化或重新初始化粒子
+  const initParticles = () => {
+    particles = Array(props.particleCount).fill().map(() => {
+      return new Particle(
+        Math.random() * window.innerWidth,
+        Math.random() * window.innerHeight
+      )
+    })
+  }
+
+  updateCanvasSize()
+  initParticles()
+  
+  // 监听窗口大小变化,更新画布尺寸并重新初始化粒子
+  window.addEventListener('resize', () => {
+    updateCanvasSize()
+    initParticles()
+  })
+
+  const animate = () => {
+    ctx.clearRect(0, 0, canvas.width, canvas.height)
+
+    // 更新和绘制粒子
+    particles.forEach(particle => {
+      particle.update(window.innerWidth, window.innerHeight)
+
+      ctx.beginPath()
+      ctx.arc(particle.x, particle.y, particle.radius, 0, Math.PI * 2)
+      ctx.fillStyle = 'rgba(255, 255, 255, 0.5)'
+      ctx.fill()
+
+      // 绘制粒子之间的连线
+      particles.forEach(otherParticle => {
+        const dx = particle.x - otherParticle.x
+        const dy = particle.y - otherParticle.y
+        const distance = Math.sqrt(dx * dx + dy * dy)
+
+        if (distance < 120) {
+          ctx.beginPath()
+          ctx.moveTo(particle.x, particle.y)
+          ctx.lineTo(otherParticle.x, otherParticle.y)
+          ctx.strokeStyle = `rgba(255, 255, 255, ${0.25 - distance / 500})`
+          ctx.stroke()
+        }
+      })
+
+      // 绘制与鼠标的连线
+      const dx = particle.x - mouse.x
+      const dy = particle.y - mouse.y
+      const distance = Math.sqrt(dx * dx + dy * dy)
+
+      if (distance < 120) {
+        ctx.beginPath()
+        ctx.moveTo(particle.x, particle.y)
+        ctx.lineTo(mouse.x, mouse.y)
+        ctx.strokeStyle = `rgba(255, 255, 255, ${0.35 - distance / 500})`
+        ctx.stroke()
+      }
+    })
+
+    animationFrameId = requestAnimationFrame(animate)
+  }
+
+  animate()
+
+  return () => {
+    window.removeEventListener('resize', updateCanvasSize)
+    if (animationFrameId) {
+      cancelAnimationFrame(animationFrameId)
+    }
+  }
+}
+
+const handleMouseMove = (event) => {
+  mouse.x = event.clientX
+  mouse.y = event.clientY
+}
+
+onMounted(() => {
+  const cleanup = initCanvas()
+  window.addEventListener('mousemove', handleMouseMove)
+
+  onUnmounted(() => {
+    cleanup()
+    window.removeEventListener('mousemove', handleMouseMove)
+  })
+})
+</script>
+
+<template>
+  <canvas ref="canvasRef" class="particles-canvas"></canvas>
+</template>
+
+<style scoped>
+.particles-canvas {
+  position: absolute;
+  top: 0;
+  left: 0;
+  pointer-events: none;
+}
+</style>

+ 318 - 0
bridge-disease-frontend-main/src/components/SidebarMenu.vue

@@ -0,0 +1,318 @@
+<script setup>
+import { computed } from 'vue'
+import { useRouter, useRoute } from 'vue-router'
+import {
+  Picture,
+  List,
+  User,
+  Operation,
+  Fold,
+  Expand,
+  Aim,
+  Coin,
+  DataAnalysis,
+  Cpu,
+  Download,
+  SetUp,
+  Bell,
+  Files,
+  Notebook,
+  FolderOpened,
+} from '@element-plus/icons-vue'
+import { isCollapsed, toggleCollapse } from '../stores/sidebarStore'
+
+const router = useRouter()
+const route = useRoute()
+
+// 使用全局状态管理的折叠状态
+const isCollapse = isCollapsed
+
+// 从 localStorage 获取用户信息
+const userInfo = computed(() => {
+  const storedUser = localStorage.getItem('login_user')
+  return storedUser ? JSON.parse(storedUser) : {}
+})
+
+// 判断用户角色
+const isAdmin = computed(() => userInfo.value?.role === 'ADMIN')
+const isDeveloper = computed(() => userInfo.value?.role === 'DEVELOPER')
+const isAdminOrDeveloper = computed(() => isAdmin.value || isDeveloper.value)
+
+// 导航菜单项
+const menuItems = computed(() => [
+  {
+    name: '数据看板',
+    path: '/home',
+    icon: DataAnalysis,
+    visible: true
+  },
+  {
+    name: '桥梁安全隐患检测',
+    path: '/disease-detection',
+    icon: Aim,
+    visible: true
+  },
+  {
+    name: '安全隐患检测记录',
+    path: '/detection-records',
+    icon: List,
+    visible: true
+  },
+  {
+    name: '批量安全隐患检测',
+    path: '/batch-detection',
+    icon: Files,
+    visible: true
+  },
+  {
+    name: '安全隐患台账',
+    path: '/defect-ledger',
+    icon: Notebook,
+    visible: true
+  },
+  {
+    name: '报告中心',
+    path: '/report-center',
+    icon: FolderOpened,
+    visible: true
+  },
+  {
+    name: '媒体库',
+    path: '/media-library',
+    icon: Picture,
+    visible: true
+  },
+  {
+    name: '传感器管理',
+    path: '/sensor-management',
+    icon: Cpu,
+    visible: true
+  },
+  {
+    name: '数据采集',
+    path: '/data-collection',
+    icon: Download,
+    visible: true
+  },
+  {
+    name: '数据处理',
+    path: '/data-processing',
+    icon: SetUp,
+    visible: true
+  },
+  {
+    name: '预警管理',
+    path: '/alert-management',
+    icon: Bell,
+    visible: true
+  },
+  {
+    name: '模型库',
+    path: '/model-library',
+    icon: Coin,
+    visible: isDeveloper.value
+  },
+  {
+    name: '用户管理',
+    path: '/user-management',
+    icon: User,
+    visible: isAdminOrDeveloper.value
+  },
+  {
+    name: '系统操作日志',
+    path: '/operation-logs',
+    icon: Operation,
+    visible: isAdminOrDeveloper.value
+  }
+])
+
+// 当前活动菜单项
+const activeMenu = computed(() => route.path)
+
+// 导航到指定路径
+const navigateTo = (path) => {
+  router.push(path)
+}
+</script>
+
+<template>
+  <div class="sidebar-menu" :class="{ 'collapsed': isCollapse }">
+    <div class="collapse-btn" @click="toggleCollapse">
+      <el-icon class="collapse-icon">
+        <component :is="isCollapse ? Expand : Fold" />
+      </el-icon>
+      <span class="collapse-text" :class="{ 'hidden': isCollapse }">收起菜单</span>
+    </div>
+    <el-menu :default-active="activeMenu" class="el-menu-vertical" :collapse="isCollapse" :collapse-transition="true">
+      <el-menu-item v-for="(item, index) in menuItems.filter(item => item.visible)" :key="index" :index="item.path"
+        @click="navigateTo(item.path)">
+        <el-icon>
+          <component :is="item.icon" />
+        </el-icon>
+        <template #title>
+          <span class="menu-text">{{ item.name }}</span>
+        </template>
+      </el-menu-item>
+    </el-menu>
+  </div>
+</template>
+
+<style scoped>
+.sidebar-menu {
+  height: 100%;
+  width: 200px;
+  flex-shrink: 0;
+  background: linear-gradient(
+    185deg,
+    var(--sidebar) 0%,
+    color-mix(in srgb, var(--sidebar) 55%, var(--primary) 45%) 52%,
+    var(--sidebar) 100%
+  );
+  transition: width 0.42s cubic-bezier(0.32, 1.38, 0.55, 1);
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  box-shadow: 4px 0 32px rgba(15, 23, 42, 0.35);
+  overflow: hidden;
+  border-radius: 0 16px 16px 0;
+}
+
+.sidebar-menu.collapsed {
+  width: 64px;
+}
+
+.el-menu-vertical {
+  border-right: none;
+  width: 100%;
+  flex: 1;
+  background-color: transparent !important;
+}
+
+.el-menu-vertical :deep(.el-menu-item) {
+  height: 50px;
+  line-height: 50px;
+  color: rgba(248, 250, 252, 0.82);
+  margin: 4px 0;
+  border-radius: 10px;
+  padding: 0 20px !important;
+  transition: all 0.3s ease;
+  display: flex;
+  align-items: center;
+  justify-content: flex-start;
+}
+
+.el-menu-vertical :deep(.el-menu-item.is-active) {
+  background: linear-gradient(90deg, rgba(79, 70, 229, 0.38), rgba(99, 102, 241, 0.12));
+  color: #f8fafc;
+  font-weight: 600;
+  box-shadow:
+    inset 3px 0 0 0 rgba(129, 140, 248, 0.95),
+    0 0 28px rgba(79, 70, 229, 0.28),
+    0 6px 18px rgba(0, 0, 0, 0.22);
+  transform: translateX(2px);
+}
+
+.el-menu-vertical :deep(.el-menu-item:hover:not(.is-active)) {
+  background: rgba(248, 250, 252, 0.08);
+  color: #ffffff;
+  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.2);
+}
+
+.collapse-btn {
+  height: 40px;
+  display: flex;
+  align-items: center;
+  justify-content: flex-start;
+  padding: 0 20px;
+  cursor: pointer;
+  color: rgba(226, 232, 240, 0.75);
+  transition: color 0.3s ease, background-color 0.3s ease;
+  margin: 8px 0;
+}
+
+.collapse-icon {
+  width: 24px;
+  text-align: center;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  flex-shrink: 0;
+}
+
+.collapse-text {
+  margin-left: 8px;
+  font-size: 14px;
+  transition: opacity 0.25s ease 0.05s, transform 0.3s ease;
+  white-space: nowrap;
+  opacity: 1;
+  transform: translateX(0);
+}
+
+.collapse-text.hidden {
+  opacity: 0;
+  transform: translateX(-10px);
+}
+
+.menu-text {
+  transition: opacity 0.25s ease 0.05s, transform 0.3s ease;
+  opacity: 1;
+  transform: translateX(0);
+  display: inline-block;
+}
+
+.el-menu--collapse :deep(.el-menu-item) .menu-text {
+  opacity: 0;
+  transform: translateX(-10px);
+}
+
+.el-menu-vertical :deep(.el-icon) {
+  width: 24px;
+  text-align: center;
+  transition: all 0.3s ease;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  margin-right: 8px;
+  flex-shrink: 0;
+}
+
+.el-menu--collapse :deep(.el-menu-item) .el-icon {
+  margin: 0 auto;
+  justify-content: center;
+  transform: translateX(0);
+  left: 0;
+  width: 24px;
+  text-align: center;
+  display: flex;
+  align-items: center;
+}
+
+.collapse-btn:hover {
+  color: #ffffff;
+  background-color: rgba(255, 255, 255, 0.15);
+  border-left: 3px solid rgba(201, 224, 255, 0.7);
+}
+
+/* 添加图标样式 */
+.el-menu-vertical :deep(.el-menu-item) .el-icon {
+  color: rgba(226, 232, 240, 0.75);
+  margin-right: 10px;
+  transition: all 0.3s ease;
+}
+
+.el-menu-vertical :deep(.el-menu-item.is-active) .el-icon {
+  color: #ffffff;
+  transform: scale(1.05);
+}
+
+.el-menu-vertical :deep(.el-menu-item:hover) .el-icon {
+  color: #ffffff;
+  transform: scale(1.1);
+}
+
+.collapse-btn:hover {
+  color: #ffffff;
+  background-color: rgba(255, 255, 255, 0.15);
+  border-left: 3px solid rgba(201, 224, 255, 0.7);
+}
+</style>

+ 656 - 0
bridge-disease-frontend-main/src/components/StatisticsCharts.vue

@@ -0,0 +1,656 @@
+<script setup>
+import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
+import { ElMessage } from 'element-plus'
+import * as echarts from 'echarts'
+import request from '../utils/request'
+import { hudThemeRef } from '../composables/useHudTheme'
+
+/** 与 HUD / Element 主色一致(ECharts 需具体色值) */
+function chartPrimaryHex() {
+  return hudThemeRef.value === 'dark' ? '#818cf8' : '#4f46e5'
+}
+
+function chartPieBorder() {
+  return hudThemeRef.value === 'dark' ? '#1e293b' : '#ffffff'
+}
+
+const props = defineProps({
+  userInfo: {
+    type: Object,
+    default: () => ({})
+  }
+})
+
+// 图表 DOM 引用
+const userChartRef = ref(null)
+const detectionChartRef = ref(null)
+const mediaChartRef = ref(null)
+const modelChartRef = ref(null)
+
+// 图表实例
+let userChart = null
+let detectionChart = null
+let mediaChart = null
+let modelChart = null
+
+// 统计数据
+const statistics = ref({
+  users: {
+    total: 0,
+    admin: 0,
+    developer: 0,
+    user: 0
+  },
+  detections: {
+    total: 0,
+    pending: 0,
+    in_progress: 0,
+    completed: 0,
+    failed: 0
+  },
+  medias: {
+    total: 0,
+    image: 0,
+    video: 0
+  },
+  models: {
+    total: 0
+  }
+})
+
+// 加载状态
+const loading = ref(true)
+
+// 获取用户统计数据
+const fetchUserStatistics = async () => {
+  try {
+    // 调用用户统计接口
+    const data = await request.get('/user/statistics')
+    console.info('【获取用户统计数据响应数据】', data)
+    if (data?.users_statistics) {
+      statistics.value.users = { ...statistics.value.users, ...data.users_statistics }
+    }
+
+    return true
+  } catch (error) {
+    console.error('【获取用户统计数据错误】', error)
+    ElMessage.error({
+      message: '【获取用户统计数据错误】' + (error?.message || '请重试'),
+      duration: 5000
+    })
+
+    return false
+  }
+}
+
+// 获取检测统计数据
+const fetchDetectionStatistics = async () => {
+  try {
+    // 调用检测统计接口
+    const data = await request.get('/detection/statistics')
+    console.info('【获取检测统计数据响应数据】', data)
+    if (data?.detections_statistics) {
+      statistics.value.detections = { ...statistics.value.detections, ...data.detections_statistics }
+    }
+
+    return true
+  } catch (error) {
+    console.error('【获取检测统计数据错误】', error)
+    ElMessage.error({
+      message: '【获取检测统计数据错误】' + (error?.message || '请重试'),
+      duration: 5000
+    })
+
+    return false
+  }
+}
+
+// 获取媒体统计数据
+const fetchMediaStatistics = async () => {
+  try {
+    // 调用媒体统计接口
+    const data = await request.get('/media/statistics')
+    console.info('【获取媒体统计数据响应数据】', data)
+    if (data?.medias_statistics) {
+      statistics.value.medias = { ...statistics.value.medias, ...data.medias_statistics }
+    }
+
+    return true
+  } catch (error) {
+    console.error('【获取媒体统计数据错误】', error)
+    ElMessage.error({
+      message: '【获取媒体统计数据错误】' + (error?.message || '请重试'),
+      duration: 5000
+    })
+
+    return false
+  }
+}
+
+// 获取模型统计数据
+const fetchModelStatistics = async () => {
+  try {
+    // 调用模型统计接口
+    const data = await request.get('/model/statistics')
+    console.info('【获取模型统计数据响应数据】', data)
+    if (data?.models_statistics) {
+      statistics.value.models = { ...statistics.value.models, ...data.models_statistics }
+    }
+
+    return true
+  } catch (error) {
+    console.error('【获取模型统计数据错误】', error)
+    ElMessage.error({
+      message: '【获取模型统计数据错误】' + (error?.message || '请重试'),
+      duration: 5000
+    })
+
+    return false
+  }
+}
+
+// 获取所有统计数据
+const fetchStatistics = async () => {
+  try {
+    loading.value = true
+
+    // 并行调用四个统计接口
+    await Promise.all([
+      fetchUserStatistics(),
+      fetchDetectionStatistics(),
+      fetchMediaStatistics(),
+      fetchModelStatistics()
+    ])
+  } catch (error) {
+    console.error('【获取统计数据错误】', error)
+    ElMessage.error({
+      message: '【获取统计数据错误】' + (error?.message || '请重试'),
+      duration: 5000
+    })
+  } finally {
+    loading.value = false
+    // 确保数据更新后重新渲染图表
+    await nextTick()
+    initCharts()
+  }
+}
+
+// 初始化用户统计图表
+const initUserChart = () => {
+  if (!userChartRef.value) return
+
+  try {
+    userChart = echarts.init(userChartRef.value)
+
+    const option = {
+      title: {
+        text: '用户统计',
+        left: 'center'
+      },
+      tooltip: {
+        trigger: 'item',
+        formatter: '{a} <br/>{b}: {c} ({d}%)'
+      },
+      legend: {
+        orient: 'vertical',
+        left: 'left',
+        data: ['管理员', '开发人员', '普通用户']
+      },
+      series: [
+        {
+          name: '用户类型',
+          type: 'pie',
+          radius: ['50%', '70%'],
+          avoidLabelOverlap: false,
+          itemStyle: {
+            borderRadius: 10,
+            borderColor: chartPieBorder(),
+            borderWidth: 2
+          },
+          label: {
+            show: false,
+            position: 'center'
+          },
+          emphasis: {
+            label: {
+              show: true,
+              fontSize: 16,
+              fontWeight: 'bold'
+            }
+          },
+          labelLine: {
+            show: false
+          },
+          data: [
+            { value: statistics.value.users.admin, name: '管理员' },
+            { value: statistics.value.users.developer, name: '开发人员' },
+            { value: statistics.value.users.user, name: '普通用户' }
+          ]
+        }
+      ]
+    }
+
+    userChart.setOption(option)
+  } catch (error) {
+    console.error('【初始化用户图表错误】', error)
+    ElMessage.error({
+      message: '【初始化用户图表错误】' + (error?.message || '请重试'),
+      duration: 5000
+    })
+  }
+}
+
+// 初始化检测统计图表
+const initDetectionChart = () => {
+  if (!detectionChartRef.value) return
+
+  try {
+    detectionChart = echarts.init(detectionChartRef.value)
+
+    const option = {
+      title: {
+        text: '安全隐患检测记录统计',
+        left: 'center'
+      },
+      tooltip: {
+        trigger: 'item'
+      },
+      legend: {
+        orient: 'vertical',
+        left: 'left',
+        data: ['待处理', '处理中', '已完成', '失败']
+      },
+      series: [
+        {
+          name: '检测状态',
+          type: 'pie',
+          radius: '50%',
+          itemStyle: {
+            borderRadius: 6,
+            borderColor: chartPieBorder(),
+            borderWidth: 2
+          },
+          data: [
+            { value: statistics.value.detections.pending, name: '待处理' },
+            { value: statistics.value.detections.in_progress, name: '处理中' },
+            { value: statistics.value.detections.completed, name: '已完成' },
+            { value: statistics.value.detections.failed, name: '失败' }
+          ],
+          emphasis: {
+            itemStyle: {
+              shadowBlur: 10,
+              shadowOffsetX: 0,
+              shadowColor: 'rgba(0, 0, 0, 0.5)'
+            }
+          }
+        }
+      ]
+    }
+
+    detectionChart.setOption(option)
+  } catch (error) {
+    console.error('【初始化检测图表错误】', error)
+    ElMessage.error({
+      message: '【初始化检测图表错误】' + (error?.message || '请重试'),
+      duration: 5000
+    })
+  }
+}
+
+// 初始化媒体统计图表
+const initMediaChart = () => {
+  if (!mediaChartRef.value) return
+
+  try {
+    mediaChart = echarts.init(mediaChartRef.value)
+
+    const option = {
+      title: {
+        text: '媒体统计',
+        left: 'center'
+      },
+      tooltip: {
+        trigger: 'axis',
+        axisPointer: {
+          type: 'shadow'
+        }
+      },
+      grid: {
+        left: '3%',
+        right: '4%',
+        bottom: '3%',
+        containLabel: true
+      },
+      xAxis: [
+        {
+          type: 'category',
+          data: ['图片', '视频'],
+          axisTick: {
+            alignWithLabel: true
+          }
+        }
+      ],
+      yAxis: [
+        {
+          type: 'value'
+        }
+      ],
+      series: [
+        {
+          name: '数量',
+          type: 'bar',
+          barWidth: '60%',
+          itemStyle: {
+            color: chartPrimaryHex(),
+            borderRadius: [6, 6, 0, 0]
+          },
+          data: [
+            { value: statistics.value.medias.image, name: '图片' },
+            { value: statistics.value.medias.video, name: '视频' }
+          ]
+        }
+      ]
+    }
+
+    mediaChart.setOption(option)
+  } catch (error) {
+    console.error('【初始化媒体图表错误】', error)
+    ElMessage.error({
+      message: '【初始化媒体图表错误】' + (error?.message || '请重试'),
+      duration: 5000
+    })
+  }
+}
+
+// 初始化模型统计图表
+const initModelChart = () => {
+  if (!modelChartRef.value) return
+
+  try {
+    modelChart = echarts.init(modelChartRef.value)
+
+    const option = {
+      title: {
+        text: '模型统计',
+        left: 'center'
+      },
+      tooltip: {
+        trigger: 'item'
+      },
+      series: [
+        {
+          name: '模型总数',
+          type: 'gauge',
+          radius: '70%',
+          center: ['50%', '60%'],
+          startAngle: 180,
+          endAngle: 0,
+          min: 0,
+          max: statistics.value.models.total > 0 ? statistics.value.models.total * 2 : 10,
+          splitNumber: 5,
+          itemStyle: {
+            color: chartPrimaryHex()
+          },
+          progress: {
+            show: true,
+            roundCap: true,
+            width: 18
+          },
+          pointer: {
+            show: false
+          },
+          axisLine: {
+            roundCap: true,
+            lineStyle: {
+              width: 18
+            }
+          },
+          axisTick: {
+            show: false
+          },
+          splitLine: {
+            show: false
+          },
+          axisLabel: {
+            show: false
+          },
+          title: {
+            show: false
+          },
+          detail: {
+            valueAnimation: true,
+            fontSize: 30,
+            offsetCenter: [0, 0],
+            formatter: '{value}',
+            color: chartPrimaryHex()
+          },
+          data: [
+            {
+              value: statistics.value.models.total
+            }
+          ]
+        }
+      ]
+    }
+
+    modelChart.setOption(option)
+  } catch (error) {
+    console.error('【初始化模型图表错误】', error)
+    ElMessage.error({
+      message: '【初始化模型图表错误】' + (error?.message || '请重试'),
+      duration: 5000
+    })
+  }
+}
+
+// 初始化所有图表
+const initCharts = async () => {
+  // 确保 DOM 已经渲染完成
+  await nextTick()
+
+  // 添加一个小延时,确保 DOM 元素已完全渲染
+  setTimeout(() => {
+    // 先清理旧的图表实例
+    userChart?.dispose()
+    detectionChart?.dispose()
+    mediaChart?.dispose()
+    modelChart?.dispose()
+
+    // 重新初始化图表
+    initUserChart()
+    initDetectionChart()
+    initMediaChart()
+    initModelChart()
+  }, 100)
+}
+
+// 监听窗口大小变化,调整图表大小
+const handleResize = () => {
+  try {
+    // 添加防抖处理,避免频繁调整大小
+    if (window.resizeTimer) {
+      clearTimeout(window.resizeTimer)
+    }
+
+    window.resizeTimer = setTimeout(() => {
+      // 确保图表容器存在
+      if (userChartRef.value && userChart) {
+        userChart.resize()
+      }
+      if (detectionChartRef.value && detectionChart) {
+        detectionChart.resize()
+      }
+      if (mediaChartRef.value && mediaChart) {
+        mediaChart.resize()
+      }
+      if (modelChartRef.value && modelChart) {
+        modelChart.resize()
+      }
+    }, 100)
+  } catch (error) {
+    console.error('【调整图表大小错误】', error)
+    ElMessage.error({
+      message: '【调整图表大小错误】' + (error?.message || '请重试'),
+      duration: 5000
+    })
+  }
+}
+
+// 监听统计数据变化,更新图表
+watch(
+  () => statistics.value,
+  () => {
+    if (!loading.value) {
+      initCharts()
+    }
+  },
+  { deep: true }
+)
+
+// 监听用户信息变化,获取统计数据
+watch(
+  () => props.userInfo,
+  (newVal) => {
+    if (newVal) {
+      fetchStatistics()
+    }
+  },
+  { immediate: true }
+)
+
+watch(hudThemeRef, () => {
+  if (!loading.value && props.userInfo) {
+    initCharts()
+  }
+})
+
+onMounted(() => {
+  // 访问首页时,如果当前无登录用户,避免获取统计数据
+  if (props.userInfo) {
+    fetchStatistics()
+  }
+  window.addEventListener('resize', handleResize)
+})
+
+// 组件卸载时移除事件监听
+onBeforeUnmount(() => {
+  window.removeEventListener('resize', handleResize)
+  userChart?.dispose()
+  detectionChart?.dispose()
+  mediaChart?.dispose()
+  modelChart?.dispose()
+})
+</script>
+
+<template>
+  <div class="statistics-container">
+    <el-row :gutter="20">
+      <el-col :span="24">
+        <el-card class="statistics-card" shadow="hover">
+          <div class="card-header">
+            <h3>系统统计数据</h3>
+            <el-button type="primary" size="small" @click="fetchStatistics" :loading="loading">
+              刷新数据
+            </el-button>
+          </div>
+
+          <el-skeleton :rows="4" animated v-if="loading" />
+
+          <div v-else>
+            <el-row :gutter="20">
+              <el-col :xs="24" :sm="12" :md="12" :lg="6" :xl="6">
+                <el-card shadow="never" class="info-card">
+                  <div class="info-title">用户总数</div>
+                  <div class="info-value">{{ statistics.users.total }}</div>
+                </el-card>
+              </el-col>
+
+              <el-col :xs="24" :sm="12" :md="12" :lg="6" :xl="6">
+                <el-card shadow="never" class="info-card">
+                  <div class="info-title">安全隐患检测记录总数</div>
+                  <div class="info-value">{{ statistics.detections.total }}</div>
+                </el-card>
+              </el-col>
+
+              <el-col :xs="24" :sm="12" :md="12" :lg="6" :xl="6">
+                <el-card shadow="never" class="info-card">
+                  <div class="info-title">媒体总数</div>
+                  <div class="info-value">{{ statistics.medias.total }}</div>
+                </el-card>
+              </el-col>
+
+              <el-col :xs="24" :sm="12" :md="12" :lg="6" :xl="6">
+                <el-card shadow="never" class="info-card">
+                  <div class="info-title">模型总数</div>
+                  <div class="info-value">{{ statistics.models.total }}</div>
+                </el-card>
+              </el-col>
+            </el-row>
+
+            <el-divider />
+
+            <el-row :gutter="20">
+              <el-col :xs="24" :sm="24" :md="12" :lg="12" :xl="12">
+                <div ref="userChartRef" class="chart-container"></div>
+              </el-col>
+
+              <el-col :xs="24" :sm="24" :md="12" :lg="12" :xl="12">
+                <div ref="detectionChartRef" class="chart-container"></div>
+              </el-col>
+            </el-row>
+
+            <el-row :gutter="20">
+              <el-col :xs="24" :sm="24" :md="12" :lg="12" :xl="12">
+                <div ref="mediaChartRef" class="chart-container"></div>
+              </el-col>
+
+              <el-col :xs="24" :sm="24" :md="12" :lg="12" :xl="12">
+                <div ref="modelChartRef" class="chart-container"></div>
+              </el-col>
+            </el-row>
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+  </div>
+</template>
+
+<style scoped>
+.statistics-container {
+  padding: 1px;
+}
+
+.statistics-card {
+  border-radius: 12px;
+}
+
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-top: -17px;
+}
+
+.info-card {
+  text-align: center;
+  margin-bottom: 8px;
+  background-color: color-mix(in srgb, var(--card-solid) 94%, var(--primary) 6%);
+  border: 1px solid color-mix(in srgb, var(--primary) 18%, var(--border));
+}
+
+.info-title {
+  font-size: 17px;
+  color: var(--el-text-color-regular);
+}
+
+.info-value {
+  font-size: 24px;
+  font-weight: bold;
+  color: var(--el-color-primary);
+}
+
+.chart-container {
+  margin-bottom: 20px;
+  height: 22vh;
+}
+</style>

+ 359 - 0
bridge-disease-frontend-main/src/components/shell/AgentAssistantDrawer.vue

@@ -0,0 +1,359 @@
+<script setup>
+import { ref, computed, watch, nextTick } from 'vue'
+import { ElMessage } from 'element-plus'
+import { ChatDotRound, Key, Link, Delete } from '@element-plus/icons-vue'
+import { agentDrawerOpen } from '../../stores/uiShellStore'
+import {
+  GITCC_API_BASE,
+  GITCC_API_KEY_STORAGE,
+  GITCC_MODEL_DEFAULT,
+} from '../../shellConstants'
+
+function setDrawerOpen(v) {
+  agentDrawerOpen.value = v
+}
+
+const apiKeyInput = ref('')
+const keyStatus = ref('empty')
+const keyMasked = ref('')
+const messages = ref([])
+const inputText = ref('')
+const sending = ref(false)
+const messagesEl = ref(null)
+const settingsOpen = ref(['settings'])
+
+function maskKey(k) {
+  if (!k || k.length < 6) return '******'
+  return `${k.slice(0, 2)}******${k.slice(-2)}`
+}
+
+function loadStoredKey() {
+  const k = localStorage.getItem(GITCC_API_KEY_STORAGE)
+  if (k) {
+    keyMasked.value = maskKey(k)
+    keyStatus.value = 'ready'
+    apiKeyInput.value = ''
+  } else {
+    keyMasked.value = ''
+    keyStatus.value = 'empty'
+  }
+}
+
+watch(agentDrawerOpen, (open) => {
+  if (open) {
+    loadStoredKey()
+    if (messages.value.length === 0) {
+      messages.value.push({
+        role: 'assistant',
+        content: '你好,我是智能助手。请先完成 API Key 配置,即可开始对话。',
+      })
+    }
+    nextTick(scrollBottom)
+  }
+})
+
+function scrollBottom() {
+  const el = messagesEl.value
+  if (!el) return
+  el.scrollTop = el.scrollHeight
+}
+
+async function verifyAndSave() {
+  const key = apiKeyInput.value.trim()
+  if (!key) {
+    ElMessage.warning('请先粘贴 API Key')
+    return
+  }
+  try {
+    const res = await fetch(`${GITCC_API_BASE}/models`, {
+      method: 'GET',
+      headers: { Authorization: `Bearer ${key}` },
+    })
+    if (!res.ok) {
+      keyStatus.value = 'invalid'
+      ElMessage.error(`验证失败(HTTP ${res.status}),请检查 Key 是否有效`)
+      return
+    }
+    localStorage.setItem(GITCC_API_KEY_STORAGE, key)
+    keyMasked.value = maskKey(key)
+    keyStatus.value = 'ready'
+    apiKeyInput.value = ''
+    ElMessage.success('Key 已验证并保存到本机浏览器')
+  } catch (e) {
+    keyStatus.value = 'invalid'
+    ElMessage.error('网络异常或跨域被拦截,请检查网络或联系管理员配置代理')
+    console.error(e)
+  }
+}
+
+function clearKey() {
+  localStorage.removeItem(GITCC_API_KEY_STORAGE)
+  keyStatus.value = 'empty'
+  keyMasked.value = ''
+  apiKeyInput.value = ''
+  messages.value = [
+    { role: 'assistant', content: '已清除本地 Key。重新配置后可继续对话。' },
+  ]
+  ElMessage.info('已清除本地存储的 Key')
+}
+
+function openGetKey() {
+  window.open('https://api.gitcc.com/', '_blank', 'noopener,noreferrer')
+}
+
+function openCommunity() {
+  window.open('https://www.gitcc.com/', '_blank', 'noopener,noreferrer')
+}
+
+function getStoredKey() {
+  return localStorage.getItem(GITCC_API_KEY_STORAGE) || ''
+}
+
+async function sendMessage() {
+  const text = inputText.value.trim()
+  if (!text || sending.value) return
+  const key = getStoredKey()
+  if (!key) {
+    ElMessage.warning('请先配置并验证 API Key')
+    return
+  }
+  messages.value.push({ role: 'user', content: text })
+  inputText.value = ''
+  sending.value = true
+  await nextTick()
+  scrollBottom()
+  try {
+    const res = await fetch(`${GITCC_API_BASE}/chat/completions`, {
+      method: 'POST',
+      headers: {
+        Authorization: `Bearer ${key}`,
+        'Content-Type': 'application/json',
+      },
+      body: JSON.stringify({
+        model: GITCC_MODEL_DEFAULT,
+        messages: [{ role: 'user', content: text }],
+        temperature: 0.7,
+        max_tokens: 256,
+      }),
+    })
+    const data = await res.json().catch(() => ({}))
+    if (!res.ok) {
+      const msg = data?.error?.message || `请求失败(${res.status})`
+      messages.value.push({ role: 'assistant', content: `调用失败:${msg}。请检查 Key 或稍后重试。` })
+      return
+    }
+    const reply = data?.choices?.[0]?.message?.content || '(无内容返回)'
+    messages.value.push({ role: 'assistant', content: reply })
+  } catch (e) {
+    messages.value.push({
+      role: 'assistant',
+      content: '网络错误:无法连接智能体服务。若浏览器拦截跨域请求,需由网关代理 GitCC API。',
+    })
+    console.error(e)
+  } finally {
+    sending.value = false
+    await nextTick()
+    scrollBottom()
+  }
+}
+
+function onKeydown(e) {
+  if (e.key === 'Enter' && !e.shiftKey) {
+    e.preventDefault()
+    sendMessage()
+  }
+}
+
+const chatDisabled = computed(() => keyStatus.value !== 'ready' || sending.value)
+</script>
+
+<template>
+  <el-drawer
+    :model-value="agentDrawerOpen"
+    append-to-body
+    direction="rtl"
+    size="min(520px, 100vw)"
+    class="agent-drawer"
+    :show-close="true"
+    @update:model-value="setDrawerOpen"
+  >
+    <template #header>
+      <div class="drawer-title">
+        <el-icon><ChatDotRound /></el-icon>
+        <span>智能体助手</span>
+      </div>
+    </template>
+
+    <el-collapse v-model="settingsOpen">
+      <el-collapse-item title="API Key 配置" name="settings">
+        <div class="settings-body">
+          <p class="tip">
+            Key 仅保存在您本机浏览器的 localStorage,用于直接调用 GitCC 接口,不会发送到检澜业务后端。
+          </p>
+          <el-alert
+            class="proxy-alert"
+            type="info"
+            :closable="false"
+            show-icon
+            title="直连与跨域"
+            description="浏览器直连 api.gitcc.com 可能受 CORS 限制,且 Key 暴露于前端。生产环境建议由后端网关代理 GitCC OpenAI 兼容接口(/v1)。"
+          />
+          <div class="link-row">
+            <el-button type="primary" link :icon="Link" @click="openGetKey">获取 Key</el-button>
+            <el-button type="info" link @click="openCommunity">前往 GitCC 社区</el-button>
+          </div>
+          <el-input
+            v-model="apiKeyInput"
+            type="password"
+            show-password
+            placeholder="粘贴 GitCC API Key"
+            class="key-input"
+          />
+          <div class="btn-row">
+            <el-button type="primary" @click="verifyAndSave">验证并保存</el-button>
+            <el-button v-if="keyStatus === 'ready'" @click="clearKey" :icon="Delete">清除 Key</el-button>
+          </div>
+          <div v-if="keyStatus === 'ready'" class="key-ok">
+            <el-icon><Key /></el-icon>
+            已配置:{{ keyMasked }}
+          </div>
+          <div v-else-if="keyStatus === 'invalid'" class="key-bad">上次验证未通过,请检查 Key 后重试。</div>
+        </div>
+      </el-collapse-item>
+    </el-collapse>
+
+    <div ref="messagesEl" class="messages">
+      <div
+        v-for="(m, i) in messages"
+        :key="i"
+        class="bubble-row"
+        :class="m.role"
+      >
+        <div class="bubble">{{ m.content }}</div>
+      </div>
+      <div v-if="sending" class="bubble-row assistant">
+        <div class="bubble typing">正在输入...</div>
+      </div>
+    </div>
+
+    <div class="composer">
+      <el-input
+        v-model="inputText"
+        type="textarea"
+        :rows="3"
+        :disabled="chatDisabled"
+        placeholder="输入消息,Enter 发送,Shift+Enter 换行"
+        @keydown="onKeydown"
+      />
+      <el-button type="primary" class="send" :loading="sending" :disabled="chatDisabled" @click="sendMessage">
+        发送
+      </el-button>
+    </div>
+  </el-drawer>
+</template>
+
+<style scoped>
+.drawer-title {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-weight: 700;
+  font-size: 1.05rem;
+  color: var(--el-text-color-primary);
+}
+.settings-body {
+  padding: 4px 0 8px;
+}
+.tip {
+  font-size: 0.82rem;
+  color: var(--el-text-color-secondary);
+  line-height: 1.5;
+  margin: 0 0 10px;
+}
+.proxy-alert {
+  margin-bottom: 12px;
+}
+.link-row {
+  display: flex;
+  gap: 8px;
+  margin-bottom: 10px;
+}
+.key-input {
+  margin-bottom: 10px;
+}
+.btn-row {
+  display: flex;
+  gap: 8px;
+  flex-wrap: wrap;
+}
+.key-ok {
+  margin-top: 10px;
+  font-size: 0.85rem;
+  color: var(--el-color-primary);
+  display: flex;
+  align-items: center;
+  gap: 6px;
+}
+.key-bad {
+  margin-top: 8px;
+  font-size: 0.85rem;
+  color: #dc2626;
+}
+.messages {
+  margin-top: 12px;
+  padding: 12px;
+  height: min(46vh, 420px);
+  overflow-y: auto;
+  background: var(--el-fill-color-light);
+  border-radius: 12px;
+  border: 1px solid var(--el-border-color-lighter);
+}
+.bubble-row {
+  display: flex;
+  margin-bottom: 10px;
+}
+.bubble-row.user {
+  justify-content: flex-end;
+}
+.bubble-row.assistant {
+  justify-content: flex-start;
+}
+.bubble {
+  max-width: 88%;
+  padding: 10px 12px;
+  border-radius: 12px;
+  font-size: 0.9rem;
+  line-height: 1.5;
+  white-space: pre-wrap;
+  word-break: break-word;
+}
+.user .bubble {
+  background: linear-gradient(
+    135deg,
+    color-mix(in srgb, var(--el-color-primary) 88%, #0f172a),
+    var(--el-color-primary)
+  );
+  color: #f8fafc;
+  border-bottom-right-radius: 4px;
+}
+.assistant .bubble {
+  background: var(--el-bg-color);
+  color: var(--el-text-color-primary);
+  border: 1px solid var(--el-border-color-lighter);
+  border-bottom-left-radius: 4px;
+}
+.typing {
+  font-style: italic;
+  color: var(--el-text-color-secondary);
+}
+.composer {
+  margin-top: 14px;
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+.send {
+  align-self: flex-end;
+  min-width: 100px;
+}
+</style>

+ 66 - 0
bridge-disease-frontend-main/src/components/shell/CommunityQuickEntry.vue

@@ -0,0 +1,66 @@
+<script setup>
+import { Promotion } from '@element-plus/icons-vue'
+import { GITCC_COMMUNITY_URL, GITCC_LOGO_URL, PRODUCT_NAME_CN } from '../../shellConstants'
+</script>
+
+<template>
+  <a
+    class="community-card shell-card-elevated"
+    :href="GITCC_COMMUNITY_URL"
+    target="_blank"
+    rel="noopener noreferrer"
+  >
+    <div class="row">
+      <img :src="GITCC_LOGO_URL" alt="" class="mark" width="44" height="44" />
+      <div class="text">
+        <div class="title">开源社区</div>
+        <div class="sub">GitCC · 与 {{ PRODUCT_NAME_CN }} 生态互通</div>
+      </div>
+      <el-icon class="go"><Promotion /></el-icon>
+    </div>
+    <p class="hint">在新窗口打开社区首页,获取扩展工具与协作资讯。</p>
+  </a>
+</template>
+
+<style scoped>
+.community-card {
+  display: block;
+  text-decoration: none;
+  color: inherit;
+  padding: 18px 20px;
+  margin-bottom: 20px;
+}
+.row {
+  display: flex;
+  align-items: center;
+  gap: 14px;
+}
+.mark {
+  border-radius: 10px;
+  object-fit: contain;
+}
+.text {
+  flex: 1;
+  min-width: 0;
+}
+.title {
+  font-weight: 700;
+  font-size: 1.05rem;
+  color: var(--foreground);
+}
+.sub {
+  font-size: 0.85rem;
+  color: var(--muted);
+  margin-top: 4px;
+}
+.go {
+  font-size: 22px;
+  color: var(--primary);
+}
+.hint {
+  margin: 12px 0 0;
+  font-size: 0.8rem;
+  color: var(--muted);
+  line-height: 1.5;
+}
+</style>

+ 17 - 0
bridge-disease-frontend-main/src/components/shell/HudPageHero.vue

@@ -0,0 +1,17 @@
+<script setup>
+defineProps({
+  title: { type: String, required: true },
+  description: { type: String, default: '' },
+})
+</script>
+
+<template>
+  <section class="hud-page-hero" role="region" :aria-label="title">
+    <div class="hud-hud-corners" aria-hidden="true" />
+    <div class="hud-page-hero-inner">
+      <h2>{{ title }}</h2>
+      <p v-if="description">{{ description }}</p>
+      <div class="hud-hero-divider" aria-hidden="true" />
+    </div>
+  </section>
+</template>

+ 306 - 0
bridge-disease-frontend-main/src/components/shell/InsightDashboard.vue

@@ -0,0 +1,306 @@
+<script setup>
+import { ref, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
+import * as echarts from 'echarts'
+import { buildInsightMockData } from './insightMockData'
+import { hudThemeRef } from '../../composables/useHudTheme'
+
+const mock = buildInsightMockData()
+
+const lineRef = ref(null)
+const barRef = ref(null)
+const pieRef = ref(null)
+const areaRef = ref(null)
+let charts = []
+let ro
+
+function getPalette() {
+  const dark = hudThemeRef.value === 'dark'
+  if (dark) {
+    return {
+      primary: '#818cf8',
+      secondary: '#6366f1',
+      warn: '#fbbf24',
+      grid: '#334155',
+      text: '#94a3b8',
+      pieStroke: '#1e293b',
+      areaTop: 'rgba(129, 140, 248, 0.38)',
+      areaBottom: 'rgba(129, 140, 248, 0.02)',
+    }
+  }
+  return {
+    primary: '#4f46e5',
+    secondary: '#6366f1',
+    warn: '#d97706',
+    grid: '#e2e8f0',
+    text: '#475569',
+    pieStroke: '#fff',
+    areaTop: 'rgba(79, 70, 229, 0.35)',
+    areaBottom: 'rgba(79, 70, 229, 0.02)',
+  }
+}
+
+function baseChart(dom, option) {
+  const c = echarts.init(dom, null, { renderer: 'canvas' })
+  c.setOption(option)
+  return c
+}
+
+function disposeCharts() {
+  charts.forEach((c) => c && c.dispose())
+  charts = []
+}
+
+function initAll() {
+  if (!lineRef.value || !barRef.value || !pieRef.value || !areaRef.value) return
+
+  disposeCharts()
+
+  const palette = getPalette()
+
+  const line = baseChart(lineRef.value, {
+    animationDuration: 900,
+    animationEasing: 'cubicOut',
+    color: [palette.primary],
+    grid: { left: 48, right: 16, top: 28, bottom: 32 },
+    tooltip: { trigger: 'axis' },
+    xAxis: {
+      type: 'category',
+      data: mock.trendLine.categories,
+      axisLine: { lineStyle: { color: palette.grid } },
+      axisLabel: { color: palette.text },
+    },
+    yAxis: {
+      type: 'value',
+      splitLine: { lineStyle: { color: palette.grid } },
+      axisLabel: { color: palette.text },
+    },
+    series: [
+      {
+        name: '检测任务',
+        type: 'line',
+        smooth: true,
+        areaStyle: {
+          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+            { offset: 0, color: palette.areaTop },
+            { offset: 1, color: palette.areaBottom },
+          ]),
+        },
+        data: mock.trendLine.series,
+      },
+    ],
+  })
+
+  const bar = baseChart(barRef.value, {
+    animationDuration: 800,
+    color: [palette.secondary],
+    grid: { left: 48, right: 16, top: 28, bottom: 32 },
+    tooltip: { trigger: 'axis' },
+    xAxis: { type: 'category', data: mock.barMedia.categories, axisLabel: { color: palette.text } },
+    yAxis: {
+      type: 'value',
+      splitLine: { lineStyle: { color: palette.grid } },
+      axisLabel: { color: palette.text },
+    },
+    series: [
+      {
+        name: '数量',
+        type: 'bar',
+        barMaxWidth: 36,
+        itemStyle: { borderRadius: [6, 6, 0, 0] },
+        data: mock.barMedia.values,
+      },
+    ],
+  })
+
+  const pie = baseChart(pieRef.value, {
+    animationDuration: 900,
+    color: [palette.primary, palette.secondary, palette.warn, '#22d3ee', '#94a3b8'],
+    tooltip: { trigger: 'item' },
+    series: [
+      {
+        name: '占比',
+        type: 'pie',
+        radius: ['42%', '68%'],
+        itemStyle: { borderRadius: 6, borderColor: palette.pieStroke, borderWidth: 2 },
+        label: { color: palette.text },
+        data: mock.pieDefect.names.map((n, i) => ({ name: n, value: mock.pieDefect.values[i] })),
+      },
+    ],
+  })
+
+  const area = baseChart(areaRef.value, {
+    animationDuration: 900,
+    color: [palette.primary, palette.warn],
+    legend: { textStyle: { color: palette.text } },
+    grid: { left: 48, right: 16, top: 36, bottom: 28 },
+    tooltip: { trigger: 'axis' },
+    xAxis: {
+      type: 'category',
+      boundaryGap: false,
+      data: mock.areaCompletion.months,
+      axisLabel: { color: palette.text },
+    },
+    yAxis: {
+      type: 'value',
+      splitLine: { lineStyle: { color: palette.grid } },
+      axisLabel: { color: palette.text },
+    },
+    series: [
+      { name: '已完成', type: 'line', stack: 't', smooth: true, areaStyle: { opacity: 0.22 }, data: mock.areaCompletion.completed },
+      { name: '待处理', type: 'line', stack: 't', smooth: true, areaStyle: { opacity: 0.18 }, data: mock.areaCompletion.pending },
+    ],
+  })
+
+  charts = [line, bar, pie, area]
+}
+
+function resizeCharts() {
+  charts.forEach((c) => c && c.resize())
+}
+
+watch(hudThemeRef, () => {
+  nextTick(() => {
+    disposeCharts()
+    initAll()
+    resizeCharts()
+  })
+})
+
+onMounted(() => {
+  nextTick(() => {
+    initAll()
+    ro = new ResizeObserver(() => resizeCharts())
+    ;[lineRef, barRef, pieRef, areaRef].forEach((r) => {
+      if (r.value) ro.observe(r.value)
+    })
+    window.addEventListener('resize', resizeCharts)
+  })
+})
+
+onBeforeUnmount(() => {
+  window.removeEventListener('resize', resizeCharts)
+  if (ro) ro.disconnect()
+  disposeCharts()
+})
+</script>
+
+<template>
+  <section class="insight">
+    <div class="kpi-grid">
+      <div class="kpi shell-card-elevated">
+        <span class="label">近7日检测任务</span>
+        <span class="val shell-kpi-num">{{ mock.kpi.detectionWeek }}</span>
+        <span class="delta">环比 +{{ mock.kpi.detectionDelta }}%</span>
+      </div>
+      <div class="kpi shell-card-elevated">
+        <span class="label">媒体资产</span>
+        <span class="val shell-kpi-num">{{ mock.kpi.mediaAssets }}</span>
+        <span class="delta muted">图像 / 视频统一管理</span>
+      </div>
+      <div class="kpi shell-card-elevated">
+        <span class="label">模型版本</span>
+        <span class="val shell-kpi-num">{{ mock.kpi.modelVersions }}</span>
+        <span class="delta muted">可部署检测模型</span>
+      </div>
+      <div class="kpi shell-card-elevated">
+        <span class="label">任务成功率</span>
+        <span class="val shell-kpi-num">{{ mock.kpi.taskSuccessRate }}%</span>
+        <span class="delta">流水线健康度</span>
+      </div>
+    </div>
+
+    <div class="chart-grid">
+      <div class="chart shell-card-elevated">
+        <h3>周度检测趋势</h3>
+        <div ref="lineRef" class="chart-dom" />
+      </div>
+      <div class="chart shell-card-elevated">
+        <h3>媒介类型分布</h3>
+        <div ref="barRef" class="chart-dom" />
+      </div>
+      <div class="chart shell-card-elevated">
+        <h3>隐患类型占比</h3>
+        <div ref="pieRef" class="chart-dom" />
+      </div>
+      <div class="chart shell-card-elevated">
+        <h3>任务完成与积压</h3>
+        <div ref="areaRef" class="chart-dom" />
+      </div>
+    </div>
+  </section>
+</template>
+
+<style scoped>
+.insight {
+  margin-bottom: 8px;
+}
+.kpi-grid {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: 14px;
+  margin: 18px 0 20px;
+}
+@media (max-width: 1200px) {
+  .kpi-grid {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+}
+@media (max-width: 640px) {
+  .kpi-grid {
+    grid-template-columns: 1fr;
+  }
+}
+.kpi {
+  padding: 16px 18px;
+}
+.kpi .label {
+  display: block;
+  font-size: 0.78rem;
+  color: var(--muted);
+  margin-bottom: 8px;
+}
+.kpi .val {
+  display: block;
+  font-size: 1.65rem;
+  color: var(--foreground);
+}
+.kpi .delta {
+  display: block;
+  margin-top: 8px;
+  font-size: 0.78rem;
+  color: var(--accent);
+  font-weight: 500;
+}
+.kpi .delta.muted {
+  color: var(--muted);
+  font-weight: 400;
+}
+.chart-grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 16px;
+}
+@media (max-width: 1100px) {
+  .chart-grid {
+    grid-template-columns: 1fr;
+  }
+}
+.chart {
+  padding: 14px 16px 8px;
+}
+.chart h3 {
+  margin: 0 0 6px;
+  font-size: 0.95rem;
+  font-weight: 600;
+  color: var(--foreground);
+}
+.chart-dom {
+  height: 260px;
+  width: 100%;
+}
+@media (max-width: 1366px) {
+  .chart-dom {
+    height: 220px;
+  }
+}
+</style>

+ 33 - 0
bridge-disease-frontend-main/src/components/shell/insightMockData.js

@@ -0,0 +1,33 @@
+/**
+ * 数据看板 — 演示数据集
+ * 接入真实接口:在父组件请求后端后,将结果映射为下列结构并传入 InsightDashboard 的 live prop(预留)。
+ */
+export function buildInsightMockData() {
+  const days = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
+  return {
+    kpi: {
+      detectionWeek: 186,
+      detectionDelta: 12.4,
+      mediaAssets: 428,
+      modelVersions: 14,
+      taskSuccessRate: 94.2,
+    },
+    trendLine: {
+      categories: days,
+      series: [42, 38, 55, 61, 48, 33, 29],
+    },
+    barMedia: {
+      categories: ['图像', '视频', '待处理', '归档'],
+      values: [220, 86, 34, 88],
+    },
+    pieDefect: {
+      names: ['裂缝', '锈蚀', '剥落', '涂层缺陷', '其他'],
+      values: [38, 26, 18, 12, 6],
+    },
+    areaCompletion: {
+      months: ['1月', '2月', '3月', '4月', '5月', '6月'],
+      completed: [62, 71, 68, 84, 79, 91],
+      pending: [18, 14, 22, 12, 16, 9],
+    },
+  }
+}

+ 43 - 0
bridge-disease-frontend-main/src/composables/useHudTheme.js

@@ -0,0 +1,43 @@
+import { ref, watch, onMounted } from 'vue'
+
+const STORAGE_KEY = 'hud_theme'
+
+/** 供图表等订阅,与 data-theme / html.dark 同步 */
+export const hudThemeRef = ref('light')
+
+function applyDom() {
+  const root = document.documentElement
+  root.dataset.theme = hudThemeRef.value
+  root.classList.toggle('dark', hudThemeRef.value === 'dark')
+}
+
+if (typeof window !== 'undefined') {
+  const saved = localStorage.getItem(STORAGE_KEY)
+  if (saved === 'dark' || saved === 'light') {
+    hudThemeRef.value = saved
+  }
+  applyDom()
+}
+
+let watchStarted = false
+
+function startWatch() {
+  if (watchStarted) return
+  watchStarted = true
+  watch(hudThemeRef, (v) => {
+    localStorage.setItem(STORAGE_KEY, v)
+    applyDom()
+  })
+}
+
+export function useHudTheme() {
+  onMounted(() => {
+    startWatch()
+  })
+
+  function toggleTheme() {
+    hudThemeRef.value = hudThemeRef.value === 'dark' ? 'light' : 'dark'
+  }
+
+  return { theme: hudThemeRef, toggleTheme }
+}

+ 17 - 0
bridge-disease-frontend-main/src/main.js

@@ -0,0 +1,17 @@
+import { createApp } from 'vue'
+import ElementPlus from 'element-plus'
+import 'element-plus/dist/index.css'
+import 'element-plus/theme-chalk/dark/css-vars.css'
+import './styles/hud-layout.css'
+import './styles/shell-theme.css'
+/* 最后加载:覆盖 EP dark 纯黑底与默认蓝主色,对齐 HUD */
+import './styles/hud-ep-bridge.css'
+import App from './App.vue'
+import router from './router'
+
+const app = createApp(App)
+
+app.use(router)
+app.use(ElementPlus)
+
+app.mount('#app')

+ 401 - 0
bridge-disease-frontend-main/src/mocks/detectionAndMediaMockData.js

@@ -0,0 +1,401 @@
+/**
+ * 检测分割记录 / 媒体库 列表模拟数据
+ *
+ * 启用方式:在 bridge-disease-frontend-main 目录创建 `.env.local`(或 `.env.development`)写入:
+ *   VITE_USE_LIST_MOCK=true
+ * 然后重启 `npm run dev`。`resourceStore` 将走本地分页,不再请求后端列表接口。
+ *
+ * 预览图/结果图仍请求 `request.defaults.baseURL + '/file/' + path`,若无对应文件会显示裂图,属预期。
+ */
+
+function iso(d) {
+  return new Date(d).toISOString()
+}
+
+/** 全量检测分割记录(分页前) */
+export const MOCK_DETECTIONS = [
+  {
+    detection_id: 10001,
+    model_id: 201,
+    model_name: 'BridgeSeg-YOLOv8-L',
+    media_id: 301,
+    media_name: '主跨箱梁底板_S12.jpg',
+    status: 'COMPLETED',
+    disease_severity_score: 0.38,
+    disease_grade: 'MODERATE',
+    result_path: 'results/2026/04/r10001_mask.png',
+    media_type: 'jpeg',
+    updated_at: iso('2026-04-12T09:15:00'),
+    owner_id: 2,
+    owner_username: '巡检员A',
+    disease_count: 9,
+    disease_perimeter: 1620,
+    disease_area: 48200,
+    shape_complexity: 0.41,
+    texture_roughness: 165,
+    crack_width: 5.1,
+    avg_hue: 108,
+    detection_duration: 2280,
+    avg_frame_detection_duration: 38,
+  },
+  {
+    detection_id: 10002,
+    model_id: 202,
+    model_name: 'CrackNet-Res50',
+    media_name: 'Pier3_立面_北.mp4',
+    media_id: 302,
+    status: 'COMPLETED',
+    disease_severity_score: 0.62,
+    disease_grade: 'SEVERE',
+    result_path: null,
+    media_type: 'mp4',
+    updated_at: iso('2026-04-11T16:40:22'),
+    owner_id: 2,
+    owner_username: '巡检员A',
+    disease_count: 21,
+    disease_perimeter: 3100,
+    disease_area: 91000,
+    shape_complexity: 0.55,
+    texture_roughness: 240,
+    crack_width: 9.4,
+    avg_hue: 95,
+    detection_duration: 8120,
+    avg_frame_detection_duration: 55,
+  },
+  {
+    detection_id: 10003,
+    model_id: 201,
+    model_name: 'BridgeSeg-YOLOv8-L',
+    media_name: '伸缩缝_J03.png',
+    media_id: 303,
+    status: 'IN_PROGRESS',
+    disease_severity_score: 0.22,
+    disease_grade: 'MILD',
+    result_path: null,
+    media_type: 'png',
+    updated_at: iso('2026-04-11T11:05:00'),
+    owner_id: 3,
+    owner_username: '结构所_李工',
+    disease_count: 3,
+    disease_perimeter: 420,
+    disease_area: 8900,
+    shape_complexity: 0.19,
+    texture_roughness: 88,
+    crack_width: 2.0,
+    avg_hue: 120,
+    detection_duration: null,
+    avg_frame_detection_duration: null,
+  },
+  {
+    detection_id: 10004,
+    model_id: 203,
+    model_name: 'SegFormer-BridgeTiny',
+    media_name: '桥面铺装_DRONE_01.jpg',
+    media_id: 304,
+    status: 'PENDING',
+    disease_severity_score: null,
+    disease_grade: null,
+    result_path: null,
+    media_type: 'jpeg',
+    updated_at: iso('2026-04-10T08:00:00'),
+    owner_id: 3,
+    owner_username: '结构所_李工',
+    disease_count: null,
+    disease_perimeter: null,
+    disease_area: null,
+    shape_complexity: null,
+    texture_roughness: null,
+    crack_width: null,
+    avg_hue: null,
+    detection_duration: null,
+    avg_frame_detection_duration: null,
+  },
+  {
+    detection_id: 10005,
+    model_id: 202,
+    model_name: 'CrackNet-Res50',
+    media_name: '索塔锚室_内窥.jpg',
+    media_id: 305,
+    status: 'FAILED',
+    disease_severity_score: null,
+    disease_grade: null,
+    result_path: null,
+    media_type: 'jpeg',
+    updated_at: iso('2026-04-09T19:22:11'),
+    owner_id: 4,
+    owner_username: '养护中心',
+    disease_count: 0,
+    disease_perimeter: 0,
+    disease_area: 0,
+    shape_complexity: 0,
+    texture_roughness: 0,
+    crack_width: 0,
+    avg_hue: 0,
+    detection_duration: 120,
+    avg_frame_detection_duration: 0,
+  },
+  {
+    detection_id: 10006,
+    model_id: 201,
+    model_name: 'BridgeSeg-YOLOv8-L',
+    media_name: '钢箱梁_U肋焊缝区.png',
+    media_id: 306,
+    status: 'COMPLETED',
+    disease_severity_score: 0.71,
+    disease_grade: 'CRITICAL',
+    result_path: 'results/2026/04/r10006_mask.png',
+    media_type: 'png',
+    updated_at: iso('2026-04-09T10:00:00'),
+    owner_id: 4,
+    owner_username: '养护中心',
+    disease_count: 34,
+    disease_perimeter: 5200,
+    disease_area: 128000,
+    shape_complexity: 0.68,
+    texture_roughness: 310,
+    crack_width: 12.8,
+    avg_hue: 88,
+    detection_duration: 3100,
+    avg_frame_detection_duration: 48,
+  },
+  {
+    detection_id: 10007,
+    model_id: 204,
+    model_name: 'DeepLabV3-Deck',
+    media_name: '引桥T梁_腹板裂缝.jpg',
+    media_id: 307,
+    status: 'COMPLETED',
+    disease_severity_score: 0.29,
+    disease_grade: 'MILD',
+    result_path: null,
+    media_type: 'jpeg',
+    updated_at: iso('2026-04-08T14:30:00'),
+    owner_id: 2,
+    owner_username: '巡检员A',
+    disease_count: 5,
+    disease_perimeter: 890,
+    disease_area: 21000,
+    shape_complexity: 0.28,
+    texture_roughness: 120,
+    crack_width: 3.2,
+    avg_hue: 102,
+    detection_duration: 1890,
+    avg_frame_detection_duration: 35,
+  },
+  {
+    detection_id: 10008,
+    model_id: 202,
+    model_name: 'CrackNet-Res50',
+    media_name: '夜间补光_护栏根部.mp4',
+    media_id: 308,
+    status: 'IN_PROGRESS',
+    disease_severity_score: 0.45,
+    disease_grade: 'MODERATE',
+    result_path: null,
+    media_type: 'mp4',
+    updated_at: iso('2026-04-08T06:12:00'),
+    owner_id: 5,
+    owner_username: '夜间班组',
+    disease_count: 11,
+    disease_perimeter: 2100,
+    disease_area: 56000,
+    shape_complexity: 0.44,
+    texture_roughness: 200,
+    crack_width: 6.0,
+    avg_hue: 70,
+    detection_duration: null,
+    avg_frame_detection_duration: null,
+  },
+  {
+    detection_id: 10009,
+    model_id: 201,
+    model_name: 'BridgeSeg-YOLOv8-L',
+    media_name: '支座垫石_俯视.jpg',
+    media_id: 309,
+    status: 'COMPLETED',
+    disease_severity_score: 0.51,
+    disease_grade: 'SEVERE',
+    result_path: null,
+    media_type: 'jpeg',
+    updated_at: iso('2026-04-07T13:45:00'),
+    owner_id: 5,
+    owner_username: '夜间班组',
+    disease_count: 16,
+    disease_perimeter: 2680,
+    disease_area: 72000,
+    shape_complexity: 0.5,
+    texture_roughness: 255,
+    crack_width: 8.1,
+    avg_hue: 99,
+    detection_duration: 2650,
+    avg_frame_detection_duration: 41,
+  },
+  {
+    detection_id: 10010,
+    model_id: 203,
+    model_name: 'SegFormer-BridgeTiny',
+    media_name: '人行道板_渗水.jpg',
+    media_id: 310,
+    status: 'PENDING',
+    disease_severity_score: null,
+    disease_grade: null,
+    result_path: null,
+    media_type: 'jpeg',
+    updated_at: iso('2026-04-06T09:00:00'),
+    owner_id: 3,
+    owner_username: '结构所_李工',
+    disease_count: null,
+    disease_perimeter: null,
+    disease_area: null,
+    shape_complexity: null,
+    texture_roughness: null,
+    crack_width: null,
+    avg_hue: null,
+    detection_duration: null,
+    avg_frame_detection_duration: null,
+  },
+]
+
+/** 全量媒体库(分页前) */
+export const MOCK_MEDIAS = [
+  {
+    media_id: 50001,
+    media_name: '主跨底板_巡检S1.jpg',
+    description: '主跨区段箱梁底板高清照,含排水孔周边',
+    file_type: 'jpeg',
+    media_path: 'medias/2026/04/m50001.jpg',
+    updated_at: iso('2026-04-12T10:00:00'),
+    owner_id: 2,
+    owner_username: '巡检员A',
+  },
+  {
+    media_id: 50002,
+    media_name: '墩顶航拍_东向.mp4',
+    description: '4K 无人机东向推进,覆盖墩顶与支座',
+    file_type: 'mp4',
+    media_path: 'medias/2026/04/m50002.mp4',
+    updated_at: iso('2026-04-12T09:30:00'),
+    owner_id: 2,
+    owner_username: '巡检员A',
+  },
+  {
+    media_id: 50003,
+    media_name: '伸缩缝_J03.png',
+    description: '伸缩缝钢纤维混凝土局部剥落',
+    file_type: 'png',
+    media_path: 'medias/2026/04/m50003.png',
+    updated_at: iso('2026-04-11T15:20:00'),
+    owner_id: 3,
+    owner_username: '结构所_李工',
+  },
+  {
+    media_id: 50004,
+    media_name: '索塔锚室_内窥.jpg',
+    description: '内窥镜进入锚室,弱光增强',
+    file_type: 'jpeg',
+    media_path: 'medias/2026/04/m50004.jpg',
+    updated_at: iso('2026-04-11T12:00:00'),
+    owner_id: 3,
+    owner_username: '结构所_李工',
+  },
+  {
+    media_id: 50005,
+    media_name: '桥面铺装_DRONE_01.jpg',
+    description: '正射拼图切片之一',
+    file_type: 'jpeg',
+    media_path: 'medias/2026/04/m50005.jpg',
+    updated_at: iso('2026-04-10T18:00:00'),
+    owner_id: 4,
+    owner_username: '养护中心',
+  },
+  {
+    media_id: 50006,
+    media_name: '护栏根部_渗水.jpg',
+    description: '护栏与桥面交接长期渗水痕迹',
+    file_type: 'jpg',
+    media_path: 'medias/2026/04/m50006.jpg',
+    updated_at: iso('2026-04-10T11:11:00'),
+    owner_id: 4,
+    owner_username: '养护中心',
+  },
+  {
+    media_id: 50007,
+    media_name: 'U肋焊缝_磁粉复检.jpg',
+    description: '焊缝区磁粉复检后留档',
+    file_type: 'jpeg',
+    media_path: 'medias/2026/04/m50007.jpg',
+    updated_at: iso('2026-04-09T16:45:00'),
+    owner_id: 5,
+    owner_username: '夜间班组',
+  },
+  {
+    media_id: 50008,
+    media_name: '引桥T梁_腹板.mp4',
+    description: '车载云台沿引桥慢速采集',
+    file_type: 'mp4',
+    media_path: 'medias/2026/04/m50008.mp4',
+    updated_at: iso('2026-04-09T08:00:00'),
+    owner_id: 2,
+    owner_username: '巡检员A',
+  },
+  {
+    media_id: 50009,
+    media_name: '支座垫石_俯视.jpg',
+    description: '垫石开裂与脱空疑似区域',
+    file_type: 'jpeg',
+    media_path: 'medias/2026/04/m50009.jpg',
+    updated_at: iso('2026-04-08T14:00:00'),
+    owner_id: 3,
+    owner_username: '结构所_李工',
+  },
+  {
+    media_id: 50010,
+    media_name: '人行道板_积水.jpg',
+    description: '雨后积水反光,用于铺装隐患对比',
+    file_type: 'jpeg',
+    media_path: 'medias/2026/04/m50010.jpg',
+    updated_at: iso('2026-04-07T17:30:00'),
+    owner_id: 4,
+    owner_username: '养护中心',
+  },
+  {
+    media_id: 50011,
+    media_name: '夜间补光_护栏.mp4',
+    description: '补光灯固定机位 30s',
+    file_type: 'mp4',
+    media_path: 'medias/2026/04/m50011.mp4',
+    updated_at: iso('2026-04-07T03:15:00'),
+    owner_id: 5,
+    owner_username: '夜间班组',
+  },
+  {
+    media_id: 50012,
+    media_name: '检修道栏杆_锈蚀.png',
+    description: '栏杆立柱锈蚀等级标注样张',
+    file_type: 'png',
+    media_path: 'medias/2026/04/m50012.png',
+    updated_at: iso('2026-04-06T09:45:00'),
+    owner_id: 2,
+    owner_username: '巡检员A',
+  },
+]
+
+export function paginateMock(list, page, perPage) {
+  const p = Math.max(1, Number(page) || 1)
+  const size = Math.max(1, Number(perPage) || 10)
+  const start = (p - 1) * size
+  return {
+    items: list.slice(start, start + size),
+    total: list.length,
+  }
+}
+
+export function getMockDetectionsPage(page, perPage) {
+  const { items, total } = paginateMock(MOCK_DETECTIONS, page, perPage)
+  return { detections: items, total }
+}
+
+export function getMockMediasPage(page, perPage) {
+  const { items, total } = paginateMock(MOCK_MEDIAS, page, perPage)
+  return { medias: items, total }
+}

+ 232 - 0
bridge-disease-frontend-main/src/mocks/iotMonitoringMockData.js

@@ -0,0 +1,232 @@
+/** 物联网监测模块 — 初始种子数据(localStorage 持久化) */
+
+export const SENSOR_TYPES = [
+  { value: 'strain', label: '应变计' },
+  { value: 'vibration', label: '振动传感器' },
+  { value: 'tilt', label: '倾角仪' },
+  { value: 'temperature', label: '温度传感器' },
+  { value: 'humidity', label: '湿度传感器' },
+]
+
+export const SENSOR_STATUS = {
+  ONLINE: 'online',
+  OFFLINE: 'offline',
+  MAINTENANCE: 'maintenance',
+}
+
+export const COLLECTION_STATUS = {
+  IDLE: 'idle',
+  RUNNING: 'running',
+  PAUSED: 'paused',
+  ERROR: 'error',
+}
+
+export const PROCESSING_STATUS = {
+  PENDING: 'pending',
+  RUNNING: 'running',
+  COMPLETED: 'completed',
+  FAILED: 'failed',
+}
+
+export const ALERT_LEVEL = {
+  INFO: 'info',
+  WARNING: 'warning',
+  CRITICAL: 'critical',
+}
+
+export const ALERT_STATUS = {
+  OPEN: 'open',
+  ACKNOWLEDGED: 'acknowledged',
+  RESOLVED: 'resolved',
+}
+
+const now = () => new Date().toISOString()
+
+export function createInitialIotState() {
+  const t = now()
+  return {
+    sensors: [
+      {
+        sensor_id: 1,
+        sensor_code: 'STR-ND-01',
+        name: '南塔应变计-01',
+        bridge_section: '南塔主梁',
+        sensor_type: 'strain',
+        status: SENSOR_STATUS.ONLINE,
+        install_location: '南塔 L2 腹板',
+        sampling_hz: 50,
+        threshold_max: 1200,
+        threshold_min: -50,
+        last_heartbeat: t,
+        firmware_version: 'v2.3.1',
+        remark: '主监测点',
+        created_at: t,
+        updated_at: t,
+      },
+      {
+        sensor_id: 2,
+        sensor_code: 'VIB-SP-02',
+        name: '跨中振动-02',
+        bridge_section: '跨中箱梁',
+        sensor_type: 'vibration',
+        status: SENSOR_STATUS.ONLINE,
+        install_location: '跨中底板',
+        sampling_hz: 200,
+        threshold_max: 8.5,
+        threshold_min: 0,
+        last_heartbeat: t,
+        firmware_version: 'v1.8.0',
+        remark: '',
+        created_at: t,
+        updated_at: t,
+      },
+      {
+        sensor_id: 3,
+        sensor_code: 'TIL-NP-03',
+        name: '北塔倾角-03',
+        bridge_section: '北塔墩台',
+        sensor_type: 'tilt',
+        status: SENSOR_STATUS.OFFLINE,
+        install_location: '北塔承台',
+        sampling_hz: 10,
+        threshold_max: 0.35,
+        threshold_min: -0.35,
+        last_heartbeat: '2026-05-30T08:12:00.000Z',
+        firmware_version: 'v2.1.4',
+        remark: '待现场检修',
+        created_at: t,
+        updated_at: t,
+      },
+      {
+        sensor_id: 4,
+        sensor_code: 'TMP-AB-04',
+        name: '环境温度-04',
+        bridge_section: '全桥环境监测',
+        sensor_type: 'temperature',
+        status: SENSOR_STATUS.ONLINE,
+        install_location: '桥面气象站',
+        sampling_hz: 1,
+        threshold_max: 45,
+        threshold_min: -10,
+        last_heartbeat: t,
+        firmware_version: 'v3.0.2',
+        remark: '',
+        created_at: t,
+        updated_at: t,
+      },
+    ],
+    collectionTasks: [
+      {
+        task_id: 1,
+        task_name: '南塔应变连续采集',
+        sensor_id: 1,
+        interval_sec: 60,
+        batch_size: 100,
+        status: COLLECTION_STATUS.RUNNING,
+        last_run_at: t,
+        sample_count: 18420,
+        error_message: '',
+        created_at: t,
+        updated_at: t,
+      },
+      {
+        task_id: 2,
+        task_name: '跨中振动高频采集',
+        sensor_id: 2,
+        interval_sec: 30,
+        batch_size: 500,
+        status: COLLECTION_STATUS.PAUSED,
+        last_run_at: '2026-06-01T06:00:00.000Z',
+        sample_count: 9620,
+        error_message: '',
+        created_at: t,
+        updated_at: t,
+      },
+      {
+        task_id: 3,
+        task_name: '北塔倾角巡检采集',
+        sensor_id: 3,
+        interval_sec: 300,
+        batch_size: 50,
+        status: COLLECTION_STATUS.ERROR,
+        last_run_at: '2026-05-30T08:15:00.000Z',
+        sample_count: 2100,
+        error_message: '传感器离线,连续 3 次心跳超时',
+        created_at: t,
+        updated_at: t,
+      },
+    ],
+    processingJobs: [
+      {
+        job_id: 1,
+        job_name: '应变去噪与特征提取',
+        source_task_id: 1,
+        pipeline: 'denoise → resample → feature_extract',
+        status: PROCESSING_STATUS.COMPLETED,
+        progress: 100,
+        input_records: 12000,
+        output_records: 11840,
+        started_at: '2026-06-01T02:00:00.000Z',
+        finished_at: '2026-06-01T02:18:00.000Z',
+        created_at: t,
+        updated_at: t,
+      },
+      {
+        job_id: 2,
+        job_name: '振动频谱分析',
+        source_task_id: 2,
+        pipeline: 'fft → band_energy → anomaly_score',
+        status: PROCESSING_STATUS.RUNNING,
+        progress: 62,
+        input_records: 8000,
+        output_records: 4960,
+        started_at: '2026-06-01T08:00:00.000Z',
+        finished_at: null,
+        created_at: t,
+        updated_at: t,
+      },
+    ],
+    alerts: [
+      {
+        alert_id: 1,
+        sensor_id: 3,
+        level: ALERT_LEVEL.CRITICAL,
+        title: '传感器离线',
+        message: '北塔倾角-03 超过 15 分钟未上报心跳,采集任务已自动暂停。',
+        status: ALERT_STATUS.OPEN,
+        trigger_value: null,
+        threshold_value: null,
+        created_at: '2026-05-30T08:20:00.000Z',
+        acknowledged_at: null,
+        resolved_at: null,
+      },
+      {
+        alert_id: 2,
+        sensor_id: 1,
+        level: ALERT_LEVEL.WARNING,
+        title: '应变接近阈值',
+        message: '南塔应变计-01 最新峰值 1086 με,超过预警线 90%。',
+        status: ALERT_STATUS.ACKNOWLEDGED,
+        trigger_value: 1086,
+        threshold_value: 1200,
+        created_at: '2026-06-01T07:30:00.000Z',
+        acknowledged_at: '2026-06-01T08:00:00.000Z',
+        resolved_at: null,
+      },
+      {
+        alert_id: 3,
+        sensor_id: 4,
+        level: ALERT_LEVEL.INFO,
+        title: '温度波动提醒',
+        message: '桥面温度 1 小时内变化 6.2℃,已记录至环境日志。',
+        status: ALERT_STATUS.RESOLVED,
+        trigger_value: 6.2,
+        threshold_value: 5,
+        created_at: '2026-05-31T14:00:00.000Z',
+        acknowledged_at: '2026-05-31T14:10:00.000Z',
+        resolved_at: '2026-05-31T16:00:00.000Z',
+      },
+    ],
+    nextIds: { sensor: 5, task: 4, job: 3, alert: 4 },
+  }
+}

+ 136 - 0
bridge-disease-frontend-main/src/mocks/professionalModulesMockData.js

@@ -0,0 +1,136 @@
+/** 报告中心 / 安全隐患台账 / 批量检测 — 初始数据 */
+
+function iso(d) {
+  return new Date(d).toISOString()
+}
+
+export const DEFECT_STATUS = {
+  DISCOVERED: 'discovered',
+  REVIEWING: 'reviewing',
+  CONFIRMED: 'confirmed',
+  REMEDIATING: 'remediating',
+  CLOSED: 'closed',
+}
+
+export const BATCH_STATUS = {
+  PENDING: 'pending',
+  RUNNING: 'running',
+  COMPLETED: 'completed',
+  FAILED: 'failed',
+  CANCELLED: 'cancelled',
+}
+
+export const REPORT_TEMPLATES = [
+  {
+    template_id: 'weekly_inspection',
+    name: '桥梁巡检周报',
+    description: '汇总周期内安全隐患检测任务、安全隐患台账与预警处置情况',
+    sections: ['检测任务统计', '隐患分级分布', '预警闭环', '建议措施'],
+  },
+  {
+    template_id: 'special_detection',
+    name: '专项检测报告',
+    description: '针对单次或批量桥梁安全隐患检测输出详细指标与结果图索引',
+    sections: ['检测对象', '模型与参数', '指标明细', '结论建议'],
+  },
+  {
+    template_id: 'alert_summary',
+    name: '监测预警汇总',
+    description: '传感器预警、阈值越限与处置时效统计',
+    sections: ['预警概览', '分级统计', '未关闭事项', '趋势说明'],
+  },
+]
+
+export function createInitialProfessionalState() {
+  const t = iso('2026-06-01T10:00:00')
+  return {
+    defects: [
+      {
+        defect_id: 1,
+        defect_code: 'DEF-2026-001',
+        bridge_section: '主跨箱梁',
+        component: '底板',
+        disease_type: '混凝土裂缝',
+        grade: 'MODERATE',
+        severity_score: 0.38,
+        status: DEFECT_STATUS.CONFIRMED,
+        detection_id: 10001,
+        media_name: '主跨箱梁底板_S12.jpg',
+        model_name: 'BridgeSeg-YOLOv8-L',
+        reviewer: '',
+        review_note: '',
+        discovered_at: iso('2026-04-12T09:20:00'),
+        updated_at: t,
+      },
+      {
+        defect_id: 2,
+        defect_code: 'DEF-2026-002',
+        bridge_section: '3号墩',
+        component: '立面',
+        disease_type: '裂缝/剥落',
+        grade: 'SEVERE',
+        severity_score: 0.62,
+        status: DEFECT_STATUS.REVIEWING,
+        detection_id: 10002,
+        media_name: 'Pier3_立面_北.mp4',
+        model_name: 'CrackNet-Res50',
+        reviewer: '',
+        review_note: '',
+        discovered_at: iso('2026-04-11T17:00:00'),
+        updated_at: t,
+      },
+      {
+        defect_id: 3,
+        defect_code: 'DEF-2026-003',
+        bridge_section: '伸缩缝 J03',
+        component: '填缝区',
+        disease_type: '剥落渗水',
+        grade: 'MILD',
+        severity_score: 0.22,
+        status: DEFECT_STATUS.DISCOVERED,
+        detection_id: 10003,
+        media_name: '伸缩缝_J03.png',
+        model_name: 'BridgeSeg-YOLOv8-L',
+        reviewer: '',
+        review_note: '',
+        discovered_at: iso('2026-04-11T11:30:00'),
+        updated_at: t,
+      },
+    ],
+    batchJobs: [
+      {
+        batch_id: 1,
+        batch_name: '四月上旬箱梁专项批量检测',
+        model_id: 201,
+        model_name: 'BridgeSeg-YOLOv8-L',
+        status: BATCH_STATUS.COMPLETED,
+        progress: 100,
+        total_count: 3,
+        success_count: 3,
+        fail_count: 0,
+        items: [
+          { media_id: 301, media_name: '主跨箱梁底板_S12.jpg', status: 'completed', detection_id: 10001 },
+          { media_id: 303, media_name: '伸缩缝_J03.png', status: 'completed', detection_id: 10003 },
+          { media_id: 305, media_name: '桥面铺装_DRONE_01.jpg', status: 'completed', detection_id: null },
+        ],
+        created_at: iso('2026-04-10T08:00:00'),
+        finished_at: iso('2026-04-10T09:15:00'),
+      },
+    ],
+    reports: [
+      {
+        report_id: 1,
+        template_id: 'weekly_inspection',
+        template_name: '桥梁巡检周报',
+        title: '检澜巡检周报_2026-W14',
+        bridge_name: '示例长江大桥',
+        period: '2026-04-01 ~ 2026-04-07',
+        status: 'generated',
+        file_name: '检澜巡检周报_2026-W14.xlsx',
+        created_at: iso('2026-04-08T10:00:00'),
+        created_by: 'admin',
+      },
+    ],
+    nextIds: { defect: 4, batch: 2, report: 2 },
+  }
+}

+ 121 - 0
bridge-disease-frontend-main/src/router/index.js

@@ -0,0 +1,121 @@
+import { createRouter, createWebHistory } from 'vue-router'
+import LoginView from '../views/LoginView.vue'
+import HomeView from '../views/HomeView.vue'
+import RegisterView from '../views/RegisterView.vue'
+import ForgotPasswordView from '../views/ForgotPasswordView.vue'
+import UserCenterView from '../views/UserCenterView.vue'
+import DiseaseDetectionView from '../views/DiseaseDetectionView.vue'
+import DetectionRecordsView from '../views/DetectionRecordsView.vue'
+import MediaLibraryView from '../views/MediaLibraryView.vue'
+import ModelLibraryView from '../views/ModelLibraryView.vue'
+import UserManagementView from '../views/UserManagementView.vue'
+import OperationLogsView from '../views/OperationLogsView.vue'
+import SensorManagementView from '../views/SensorManagementView.vue'
+import DataCollectionView from '../views/DataCollectionView.vue'
+import DataProcessingView from '../views/DataProcessingView.vue'
+import AlertManagementView from '../views/AlertManagementView.vue'
+import BatchDetectionView from '../views/BatchDetectionView.vue'
+import DefectLedgerView from '../views/DefectLedgerView.vue'
+import ReportCenterView from '../views/ReportCenterView.vue'
+
+const router = createRouter({
+  history: createWebHistory(import.meta.env.BASE_URL),
+  routes: [
+    {
+      path: '/',
+      redirect: '/login',
+    },
+    {
+      path: '/login',
+      name: 'login',
+      component: LoginView,
+    },
+    {
+      path: '/home',
+      name: 'home',
+      component: HomeView,
+    },
+    {
+      path: '/register',
+      name: 'register',
+      component: RegisterView,
+    },
+    {
+      path: '/forgot-password',
+      name: 'forgot-password',
+      component: ForgotPasswordView,
+    },
+    {
+      path: '/user-center',
+      name: 'user-center',
+      component: UserCenterView,
+    },
+    {
+      path: '/disease-detection',
+      name: 'disease-detection',
+      component: DiseaseDetectionView,
+    },
+    {
+      path: '/detection-records',
+      name: 'detection-records',
+      component: DetectionRecordsView,
+    },
+    {
+      path: '/batch-detection',
+      name: 'batch-detection',
+      component: BatchDetectionView,
+    },
+    {
+      path: '/defect-ledger',
+      name: 'defect-ledger',
+      component: DefectLedgerView,
+    },
+    {
+      path: '/report-center',
+      name: 'report-center',
+      component: ReportCenterView,
+    },
+    {
+      path: '/media-library',
+      name: 'media-library',
+      component: MediaLibraryView,
+    },
+    {
+      path: '/sensor-management',
+      name: 'sensor-management',
+      component: SensorManagementView,
+    },
+    {
+      path: '/data-collection',
+      name: 'data-collection',
+      component: DataCollectionView,
+    },
+    {
+      path: '/data-processing',
+      name: 'data-processing',
+      component: DataProcessingView,
+    },
+    {
+      path: '/alert-management',
+      name: 'alert-management',
+      component: AlertManagementView,
+    },
+    {
+      path: '/model-library',
+      name: 'model-library',
+      component: ModelLibraryView,
+    },
+    {
+      path: '/user-management',
+      name: 'user-management',
+      component: UserManagementView,
+    },
+    {
+      path: '/operation-logs',
+      name: 'operation-logs',
+      component: OperationLogsView,
+    },
+  ],
+})
+
+export default router

+ 44 - 0
bridge-disease-frontend-main/src/shellConstants.js

@@ -0,0 +1,44 @@
+/** 产品标识(对外展示) */
+export const PRODUCT_NAME_CN = '检澜'
+export const PRODUCT_NAME_EN = 'DockScope'
+export const PRODUCT_TITLE = '检澜 DockScope'
+export const PRODUCT_TAGLINE = '桥梁安全隐患智能检测工作台'
+
+/** 桥梁安全隐患检测 — 统一用户可见文案 */
+export const COPY = {
+  navDetection: '桥梁安全隐患检测',
+  navDetectionRecords: '安全隐患检测记录',
+  navDefectLedger: '安全隐患台账',
+  navBatchDetection: '批量安全隐患检测',
+  hazardCategory: '隐患类别',
+  hazardType: '隐患类型',
+  hazardGrade: '隐患等级',
+  msgDetectionPrefix: '【安全隐患检测】',
+}
+
+export const GITCC_LOGO_URL =
+  'https://www.gitcc.com/uploads/-/system/appearance/header_logo/1/gitpp.png'
+export const GITCC_COMMUNITY_URL = 'https://www.gitcc.com/'
+
+/** 规范化 GitCC OpenAI 兼容 Base,必须以 /v1 结尾(默认 https) */
+export function normalizeGitccV1Base(raw) {
+  const fallback = 'https://api.gitcc.com/v1'
+  let u = (raw || import.meta.env?.VITE_GITCC_API_BASE || fallback).trim()
+  if (!/^https?:\/\//i.test(u)) u = `https://${u}`
+  try {
+    const url = new URL(u)
+    if (url.hostname === 'api.gitcc.com') {
+      return `${url.origin}/v1`
+    }
+    return url.toString().replace(/\/+$/, '')
+  } catch {
+    return fallback
+  }
+}
+
+export const GITCC_API_BASE = normalizeGitccV1Base()
+export const GITCC_API_KEY_STORAGE = 'gitcc_api_key'
+export const GITCC_MODEL_DEFAULT = 'gpt-4.1-mini'
+
+/** 导出报表等文件名前缀 */
+export const EXPORT_PREFIX = '检澜'

+ 511 - 0
bridge-disease-frontend-main/src/stores/iotMonitoringStore.js

@@ -0,0 +1,511 @@
+import { ref, computed } from 'vue'
+import {
+  createInitialIotState,
+  SENSOR_STATUS,
+  COLLECTION_STATUS,
+  PROCESSING_STATUS,
+  ALERT_LEVEL,
+  ALERT_STATUS,
+  SENSOR_TYPES,
+} from '../mocks/iotMonitoringMockData.js'
+
+const STORAGE_KEY = 'dockscope_iot_monitoring_v1'
+
+function loadState() {
+  try {
+    const raw = localStorage.getItem(STORAGE_KEY)
+    if (raw) return JSON.parse(raw)
+  } catch (e) {
+    console.warn('【IoT 数据】读取 localStorage 失败,使用种子数据', e)
+  }
+  return createInitialIotState()
+}
+
+function saveState(state) {
+  localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
+}
+
+const state = ref(loadState())
+const loading = ref(false)
+
+function persist() {
+  saveState(state.value)
+}
+
+function sensorById(id) {
+  return state.value.sensors.find((s) => s.sensor_id === id)
+}
+
+function taskById(id) {
+  return state.value.collectionTasks.find((t) => t.task_id === id)
+}
+
+function enrichTask(task) {
+  const sensor = sensorById(task.sensor_id)
+  return {
+    ...task,
+    sensor_name: sensor?.name ?? '—',
+    sensor_code: sensor?.sensor_code ?? '—',
+    bridge_section: sensor?.bridge_section ?? '—',
+  }
+}
+
+function enrichJob(job) {
+  const task = taskById(job.source_task_id)
+  return {
+    ...job,
+    source_task_name: task?.task_name ?? '—',
+  }
+}
+
+function enrichAlert(alert) {
+  const sensor = sensorById(alert.sensor_id)
+  return {
+    ...alert,
+    sensor_name: sensor?.name ?? '—',
+    sensor_code: sensor?.sensor_code ?? '—',
+  }
+}
+
+function nextId(key) {
+  const id = state.value.nextIds[key]
+  state.value.nextIds[key] = id + 1
+  return id
+}
+
+function pushAlert({ sensor_id, level, title, message, trigger_value, threshold_value }) {
+  const t = new Date().toISOString()
+  state.value.alerts.unshift({
+    alert_id: nextId('alert'),
+    sensor_id,
+    level,
+    title,
+    message,
+    status: ALERT_STATUS.OPEN,
+    trigger_value: trigger_value ?? null,
+    threshold_value: threshold_value ?? null,
+    created_at: t,
+    acknowledged_at: null,
+    resolved_at: null,
+  })
+}
+
+export function useIotMonitoringStore() {
+  const sensors = computed(() => state.value.sensors)
+  const collectionTasks = computed(() => state.value.collectionTasks.map(enrichTask))
+  const processingJobs = computed(() => state.value.processingJobs.map(enrichJob))
+  const alerts = computed(() => state.value.alerts.map(enrichAlert))
+
+  const onlineSensorCount = computed(
+    () => state.value.sensors.filter((s) => s.status === SENSOR_STATUS.ONLINE).length,
+  )
+  const runningTaskCount = computed(
+    () => state.value.collectionTasks.filter((t) => t.status === COLLECTION_STATUS.RUNNING).length,
+  )
+  const openAlertCount = computed(
+    () => state.value.alerts.filter((a) => a.status === ALERT_STATUS.OPEN).length,
+  )
+
+  function resetToSeed() {
+    state.value = createInitialIotState()
+    persist()
+  }
+
+  function listSensors(filters = {}) {
+    let list = [...state.value.sensors]
+    const { keyword, status, sensor_type } = filters
+    if (keyword) {
+      const k = keyword.toLowerCase()
+      list = list.filter(
+        (s) =>
+          s.sensor_code.toLowerCase().includes(k) ||
+          s.name.toLowerCase().includes(k) ||
+          s.bridge_section.toLowerCase().includes(k),
+      )
+    }
+    if (status) list = list.filter((s) => s.status === status)
+    if (sensor_type) list = list.filter((s) => s.sensor_type === sensor_type)
+    return list
+  }
+
+  function upsertSensor(payload) {
+    const t = new Date().toISOString()
+    if (payload.sensor_id) {
+      const idx = state.value.sensors.findIndex((s) => s.sensor_id === payload.sensor_id)
+      if (idx === -1) return { ok: false, message: '传感器不存在' }
+      state.value.sensors[idx] = { ...state.value.sensors[idx], ...payload, updated_at: t }
+      persist()
+      return { ok: true, sensor: state.value.sensors[idx] }
+    }
+    const sensor = {
+      sensor_id: nextId('sensor'),
+      sensor_code: payload.sensor_code,
+      name: payload.name,
+      bridge_section: payload.bridge_section,
+      sensor_type: payload.sensor_type,
+      status: payload.status || SENSOR_STATUS.OFFLINE,
+      install_location: payload.install_location || '',
+      sampling_hz: Number(payload.sampling_hz) || 10,
+      threshold_max: Number(payload.threshold_max) ?? 100,
+      threshold_min: Number(payload.threshold_min) ?? 0,
+      last_heartbeat: payload.status === SENSOR_STATUS.ONLINE ? t : null,
+      firmware_version: payload.firmware_version || 'v1.0.0',
+      remark: payload.remark || '',
+      created_at: t,
+      updated_at: t,
+    }
+    state.value.sensors.push(sensor)
+    persist()
+    return { ok: true, sensor }
+  }
+
+  function deleteSensor(sensorId) {
+    const used = state.value.collectionTasks.some((t) => t.sensor_id === sensorId)
+    if (used) return { ok: false, message: '该传感器仍有关联采集任务,请先删除或改绑任务' }
+    state.value.sensors = state.value.sensors.filter((s) => s.sensor_id !== sensorId)
+    state.value.alerts = state.value.alerts.filter((a) => a.sensor_id !== sensorId)
+    persist()
+    return { ok: true }
+  }
+
+  function toggleSensorStatus(sensorId) {
+    const sensor = sensorById(sensorId)
+    if (!sensor) return { ok: false, message: '传感器不存在' }
+    const t = new Date().toISOString()
+    if (sensor.status === SENSOR_STATUS.ONLINE) {
+      sensor.status = SENSOR_STATUS.OFFLINE
+      pushAlert({
+        sensor_id: sensorId,
+        level: ALERT_LEVEL.WARNING,
+        title: '传感器手动下线',
+        message: `${sensor.name} 已被设为离线,关联采集任务将暂停。`,
+      })
+      state.value.collectionTasks
+        .filter((task) => task.sensor_id === sensorId && task.status === COLLECTION_STATUS.RUNNING)
+        .forEach((task) => {
+          task.status = COLLECTION_STATUS.PAUSED
+          task.updated_at = t
+        })
+    } else {
+      sensor.status = SENSOR_STATUS.ONLINE
+      sensor.last_heartbeat = t
+    }
+    sensor.updated_at = t
+    persist()
+    return { ok: true, sensor }
+  }
+
+  function simulateHeartbeat(sensorId) {
+    const sensor = sensorById(sensorId)
+    if (!sensor) return { ok: false, message: '传感器不存在' }
+    const t = new Date().toISOString()
+    sensor.last_heartbeat = t
+    if (sensor.status !== SENSOR_STATUS.MAINTENANCE) {
+      sensor.status = SENSOR_STATUS.ONLINE
+    }
+    sensor.updated_at = t
+    persist()
+    return { ok: true, sensor }
+  }
+
+  function listCollectionTasks(filters = {}) {
+    let list = state.value.collectionTasks.map(enrichTask)
+    const { keyword, status, sensor_id } = filters
+    if (keyword) {
+      const k = keyword.toLowerCase()
+      list = list.filter(
+        (t) =>
+          t.task_name.toLowerCase().includes(k) ||
+          t.sensor_name.toLowerCase().includes(k) ||
+          String(t.task_id).includes(k),
+      )
+    }
+    if (status) list = list.filter((t) => t.status === status)
+    if (sensor_id) list = list.filter((t) => t.sensor_id === sensor_id)
+    return list
+  }
+
+  function upsertCollectionTask(payload) {
+    const sensor = sensorById(payload.sensor_id)
+    if (!sensor) return { ok: false, message: '请选择有效传感器' }
+    const t = new Date().toISOString()
+    if (payload.task_id) {
+      const idx = state.value.collectionTasks.findIndex((x) => x.task_id === payload.task_id)
+      if (idx === -1) return { ok: false, message: '采集任务不存在' }
+      state.value.collectionTasks[idx] = {
+        ...state.value.collectionTasks[idx],
+        ...payload,
+        updated_at: t,
+      }
+      persist()
+      return { ok: true, task: enrichTask(state.value.collectionTasks[idx]) }
+    }
+    const task = {
+      task_id: nextId('task'),
+      task_name: payload.task_name,
+      sensor_id: payload.sensor_id,
+      interval_sec: Number(payload.interval_sec) || 60,
+      batch_size: Number(payload.batch_size) || 100,
+      status: COLLECTION_STATUS.IDLE,
+      last_run_at: null,
+      sample_count: 0,
+      error_message: '',
+      created_at: t,
+      updated_at: t,
+    }
+    state.value.collectionTasks.push(task)
+    persist()
+    return { ok: true, task: enrichTask(task) }
+  }
+
+  function deleteCollectionTask(taskId) {
+    const linked = state.value.processingJobs.some((j) => j.source_task_id === taskId)
+    if (linked) return { ok: false, message: '该任务已有数据处理作业,无法删除' }
+    state.value.collectionTasks = state.value.collectionTasks.filter((t) => t.task_id !== taskId)
+    persist()
+    return { ok: true }
+  }
+
+  function startCollection(taskId) {
+    const task = taskById(taskId)
+    if (!task) return { ok: false, message: '任务不存在' }
+    const sensor = sensorById(task.sensor_id)
+    if (!sensor || sensor.status !== SENSOR_STATUS.ONLINE) {
+      return { ok: false, message: '关联传感器未在线,无法启动采集' }
+    }
+    const t = new Date().toISOString()
+    task.status = COLLECTION_STATUS.RUNNING
+    task.last_run_at = t
+    task.error_message = ''
+    task.updated_at = t
+    persist()
+    return { ok: true, task: enrichTask(task) }
+  }
+
+  function pauseCollection(taskId) {
+    const task = taskById(taskId)
+    if (!task) return { ok: false, message: '任务不存在' }
+    const t = new Date().toISOString()
+    task.status = COLLECTION_STATUS.PAUSED
+    task.updated_at = t
+    persist()
+    return { ok: true, task: enrichTask(task) }
+  }
+
+  function runCollectionOnce(taskId) {
+    const task = taskById(taskId)
+    if (!task) return { ok: false, message: '任务不存在' }
+    const sensor = sensorById(task.sensor_id)
+    if (!sensor || sensor.status !== SENSOR_STATUS.ONLINE) {
+      task.status = COLLECTION_STATUS.ERROR
+      task.error_message = '传感器离线,采集失败'
+      task.updated_at = new Date().toISOString()
+      pushAlert({
+        sensor_id: task.sensor_id,
+        level: ALERT_LEVEL.CRITICAL,
+        title: '采集失败',
+        message: task.error_message,
+      })
+      persist()
+      return { ok: false, message: task.error_message }
+    }
+    const t = new Date().toISOString()
+    const added = task.batch_size
+    task.sample_count += added
+    task.last_run_at = t
+    task.updated_at = t
+    if (task.status === COLLECTION_STATUS.IDLE) task.status = COLLECTION_STATUS.PAUSED
+
+    const mockValue =
+      sensor.sensor_type === 'strain'
+        ? 800 + Math.random() * 400
+        : sensor.sensor_type === 'vibration'
+          ? 2 + Math.random() * 5
+          : 10 + Math.random() * 20
+
+    if (mockValue > sensor.threshold_max * 0.9) {
+      pushAlert({
+        sensor_id: sensor.sensor_id,
+        level: ALERT_LEVEL.WARNING,
+        title: '监测值接近阈值',
+        message: `${sensor.name} 本次采样值 ${mockValue.toFixed(2)},已超过预警线 90%。`,
+        trigger_value: Number(mockValue.toFixed(2)),
+        threshold_value: sensor.threshold_max,
+      })
+    }
+    if (mockValue > sensor.threshold_max) {
+      pushAlert({
+        sensor_id: sensor.sensor_id,
+        level: ALERT_LEVEL.CRITICAL,
+        title: '监测值超阈值',
+        message: `${sensor.name} 本次采样值 ${mockValue.toFixed(2)},已超过上限 ${sensor.threshold_max}。`,
+        trigger_value: Number(mockValue.toFixed(2)),
+        threshold_value: sensor.threshold_max,
+      })
+    }
+    persist()
+    return { ok: true, task: enrichTask(task), added }
+  }
+
+  function listProcessingJobs(filters = {}) {
+    let list = state.value.processingJobs.map(enrichJob)
+    const { keyword, status } = filters
+    if (keyword) {
+      const k = keyword.toLowerCase()
+      list = list.filter(
+        (j) => j.job_name.toLowerCase().includes(k) || j.pipeline.toLowerCase().includes(k),
+      )
+    }
+    if (status) list = list.filter((j) => j.status === status)
+    return list
+  }
+
+  function createProcessingJob(payload) {
+    const task = taskById(payload.source_task_id)
+    if (!task) return { ok: false, message: '请选择有效采集任务' }
+    if (task.sample_count < 1) return { ok: false, message: '采集任务尚无样本,请先执行采集' }
+    const t = new Date().toISOString()
+    const job = {
+      job_id: nextId('job'),
+      job_name: payload.job_name,
+      source_task_id: payload.source_task_id,
+      pipeline: payload.pipeline || 'clean → aggregate → export',
+      status: PROCESSING_STATUS.PENDING,
+      progress: 0,
+      input_records: task.sample_count,
+      output_records: 0,
+      started_at: null,
+      finished_at: null,
+      created_at: t,
+      updated_at: t,
+    }
+    state.value.processingJobs.unshift(job)
+    persist()
+    return { ok: true, job: enrichJob(job) }
+  }
+
+  function startProcessing(jobId) {
+    const job = state.value.processingJobs.find((j) => j.job_id === jobId)
+    if (!job) return { ok: false, message: '作业不存在' }
+    if (job.status === PROCESSING_STATUS.RUNNING) return { ok: false, message: '作业已在运行中' }
+    const t = new Date().toISOString()
+    job.status = PROCESSING_STATUS.RUNNING
+    job.started_at = t
+    job.progress = 5
+    job.updated_at = t
+    persist()
+    return { ok: true, job: enrichJob(job) }
+  }
+
+  function advanceProcessing(jobId) {
+    const job = state.value.processingJobs.find((j) => j.job_id === jobId)
+    if (!job || job.status !== PROCESSING_STATUS.RUNNING) {
+      return { ok: false, message: '作业未在运行' }
+    }
+    const t = new Date().toISOString()
+    job.progress = Math.min(100, job.progress + 25)
+    job.output_records = Math.floor((job.input_records * job.progress) / 100)
+    job.updated_at = t
+    if (job.progress >= 100) {
+      job.status = PROCESSING_STATUS.COMPLETED
+      job.finished_at = t
+    }
+    persist()
+    return { ok: true, job: enrichJob(job) }
+  }
+
+  function deleteProcessingJob(jobId) {
+    const job = state.value.processingJobs.find((j) => j.job_id === jobId)
+    if (job?.status === PROCESSING_STATUS.RUNNING) {
+      return { ok: false, message: '运行中的作业无法删除' }
+    }
+    state.value.processingJobs = state.value.processingJobs.filter((j) => j.job_id !== jobId)
+    persist()
+    return { ok: true }
+  }
+
+  function listAlerts(filters = {}) {
+    let list = state.value.alerts.map(enrichAlert)
+    const { keyword, status, level, sensor_id } = filters
+    if (keyword) {
+      const k = keyword.toLowerCase()
+      list = list.filter(
+        (a) =>
+          a.title.toLowerCase().includes(k) ||
+          a.message.toLowerCase().includes(k) ||
+          a.sensor_name.toLowerCase().includes(k),
+      )
+    }
+    if (status) list = list.filter((a) => a.status === status)
+    if (level) list = list.filter((a) => a.level === level)
+    if (sensor_id) list = list.filter((a) => a.sensor_id === sensor_id)
+    return list.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
+  }
+
+  function acknowledgeAlert(alertId) {
+    const alert = state.value.alerts.find((a) => a.alert_id === alertId)
+    if (!alert) return { ok: false, message: '预警不存在' }
+    if (alert.status === ALERT_STATUS.RESOLVED) {
+      return { ok: false, message: '已关闭的预警无法确认' }
+    }
+    alert.status = ALERT_STATUS.ACKNOWLEDGED
+    alert.acknowledged_at = new Date().toISOString()
+    persist()
+    return { ok: true, alert: enrichAlert(alert) }
+  }
+
+  function resolveAlert(alertId) {
+    const alert = state.value.alerts.find((a) => a.alert_id === alertId)
+    if (!alert) return { ok: false, message: '预警不存在' }
+    const t = new Date().toISOString()
+    alert.status = ALERT_STATUS.RESOLVED
+    alert.resolved_at = t
+    if (!alert.acknowledged_at) alert.acknowledged_at = t
+    persist()
+    return { ok: true, alert: enrichAlert(alert) }
+  }
+
+  function getSensorTypeLabel(value) {
+    return SENSOR_TYPES.find((t) => t.value === value)?.label ?? value
+  }
+
+  return {
+    loading,
+    sensors,
+    collectionTasks,
+    processingJobs,
+    alerts,
+    onlineSensorCount,
+    runningTaskCount,
+    openAlertCount,
+    SENSOR_STATUS,
+    COLLECTION_STATUS,
+    PROCESSING_STATUS,
+    ALERT_LEVEL,
+    ALERT_STATUS,
+    SENSOR_TYPES,
+    resetToSeed,
+    listSensors,
+    upsertSensor,
+    deleteSensor,
+    toggleSensorStatus,
+    simulateHeartbeat,
+    listCollectionTasks,
+    upsertCollectionTask,
+    deleteCollectionTask,
+    startCollection,
+    pauseCollection,
+    runCollectionOnce,
+    listProcessingJobs,
+    createProcessingJob,
+    startProcessing,
+    advanceProcessing,
+    deleteProcessingJob,
+    listAlerts,
+    acknowledgeAlert,
+    resolveAlert,
+    getSensorTypeLabel,
+    sensorById,
+    taskById,
+  }
+}

+ 306 - 0
bridge-disease-frontend-main/src/stores/professionalModulesStore.js

@@ -0,0 +1,306 @@
+import { ref, computed } from 'vue'
+import {
+  createInitialProfessionalState,
+  DEFECT_STATUS,
+  BATCH_STATUS,
+  REPORT_TEMPLATES,
+} from '../mocks/professionalModulesMockData.js'
+
+const STORAGE_KEY = 'dockscope_professional_v1'
+
+function loadState() {
+  try {
+    const raw = localStorage.getItem(STORAGE_KEY)
+    if (raw) return JSON.parse(raw)
+  } catch (e) {
+    console.warn('【专业模块】读取 localStorage 失败', e)
+  }
+  return createInitialProfessionalState()
+}
+
+function saveState(state) {
+  localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
+}
+
+const state = ref(loadState())
+
+const gradeLabel = {
+  MILD: '轻度',
+  MODERATE: '中度',
+  SEVERE: '重度',
+  CRITICAL: '严重',
+}
+
+const defectStatusLabel = {
+  discovered: '待复核',
+  reviewing: '复核中',
+  confirmed: '已确认',
+  remediating: '处置中',
+  closed: '已销项',
+}
+
+const batchStatusLabel = {
+  pending: '待执行',
+  running: '执行中',
+  completed: '已完成',
+  failed: '失败',
+  cancelled: '已取消',
+}
+
+export function useProfessionalModulesStore() {
+  const defects = computed(() => state.value.defects)
+  const batchJobs = computed(() => state.value.batchJobs)
+  const reports = computed(() => state.value.reports)
+
+  function persist() {
+    saveState(state.value)
+  }
+
+  function nextId(key) {
+    const id = state.value.nextIds[key]
+    state.value.nextIds[key] = id + 1
+    return id
+  }
+
+  function listDefects(filters = {}) {
+    let list = [...state.value.defects]
+    const { keyword, status, grade } = filters
+    if (keyword) {
+      const k = keyword.toLowerCase()
+      list = list.filter(
+        (d) =>
+          d.defect_code.toLowerCase().includes(k) ||
+          d.bridge_section.toLowerCase().includes(k) ||
+          d.disease_type.toLowerCase().includes(k) ||
+          (d.media_name || '').toLowerCase().includes(k),
+      )
+    }
+    if (status) list = list.filter((d) => d.status === status)
+    if (grade) list = list.filter((d) => d.grade === grade)
+    return list.sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at))
+  }
+
+  function updateDefectStatus(defectId, status, extra = {}) {
+    const d = state.value.defects.find((x) => x.defect_id === defectId)
+    if (!d) return { ok: false, message: '安全隐患记录不存在' }
+    d.status = status
+    d.updated_at = new Date().toISOString()
+    Object.assign(d, extra)
+    persist()
+    return { ok: true, defect: d }
+  }
+
+  function reviewDefect(defectId, { reviewer, review_note, pass }) {
+    const d = state.value.defects.find((x) => x.defect_id === defectId)
+    if (!d) return { ok: false, message: '安全隐患记录不存在' }
+    d.reviewer = reviewer
+    d.review_note = review_note
+    d.status = pass ? DEFECT_STATUS.CONFIRMED : DEFECT_STATUS.DISCOVERED
+    d.updated_at = new Date().toISOString()
+    persist()
+    return { ok: true, defect: d }
+  }
+
+  function importDefectsFromDetections(detections) {
+    let added = 0
+    const existingIds = new Set(
+      state.value.defects.map((d) => d.detection_id).filter(Boolean),
+    )
+    for (const det of detections) {
+      if (det.status !== 'COMPLETED' || !det.disease_grade) continue
+      if (existingIds.has(det.detection_id)) continue
+      const id = nextId('defect')
+      state.value.defects.unshift({
+        defect_id: id,
+        defect_code: `DEF-2026-${String(id).padStart(3, '0')}`,
+        bridge_section: det.media_name?.split('_')[0] || '未分区',
+        component: '—',
+        disease_type: 'AI 检测隐患',
+        grade: det.disease_grade,
+        severity_score: det.disease_severity_score,
+        status: DEFECT_STATUS.DISCOVERED,
+        detection_id: det.detection_id,
+        media_name: det.media_name,
+        model_name: det.model_name,
+        reviewer: '',
+        review_note: '',
+        discovered_at: det.updated_at || new Date().toISOString(),
+        updated_at: new Date().toISOString(),
+      })
+      existingIds.add(det.detection_id)
+      added++
+    }
+    persist()
+    return { ok: true, added }
+  }
+
+  function listBatchJobs() {
+    return [...state.value.batchJobs].sort(
+      (a, b) => new Date(b.created_at) - new Date(a.created_at),
+    )
+  }
+
+  function createBatchJob({ batch_name, model_id, model_name, mediaItems }) {
+    if (!mediaItems?.length) return { ok: false, message: '请至少选择一条媒体' }
+    const t = new Date().toISOString()
+    const job = {
+      batch_id: nextId('batch'),
+      batch_name: batch_name || `批量检测_${new Date().toLocaleString('zh-CN')}`,
+      model_id,
+      model_name,
+      status: BATCH_STATUS.PENDING,
+      progress: 0,
+      total_count: mediaItems.length,
+      success_count: 0,
+      fail_count: 0,
+      items: mediaItems.map((m) => ({
+        media_id: m.media_id,
+        media_name: m.media_name,
+        status: 'pending',
+        detection_id: null,
+        error: '',
+      })),
+      created_at: t,
+      finished_at: null,
+    }
+    state.value.batchJobs.unshift(job)
+    persist()
+    return { ok: true, job }
+  }
+
+  function startBatchJob(batchId) {
+    const job = state.value.batchJobs.find((j) => j.batch_id === batchId)
+    if (!job) return { ok: false, message: '任务不存在' }
+    if (job.status === BATCH_STATUS.RUNNING) {
+      return { ok: false, message: '任务已在执行中' }
+    }
+    job.status = BATCH_STATUS.RUNNING
+    job.progress = 0
+    job.success_count = 0
+    job.fail_count = 0
+    persist()
+    return { ok: true, job }
+  }
+
+  function advanceBatchJob(batchId) {
+    const job = state.value.batchJobs.find((j) => j.batch_id === batchId)
+    if (!job || job.status !== BATCH_STATUS.RUNNING) {
+      return { ok: false, message: '任务未在执行' }
+    }
+    const pending = job.items.find((i) => i.status === 'pending')
+    if (!pending) {
+      job.status = BATCH_STATUS.COMPLETED
+      job.progress = 100
+      job.finished_at = new Date().toISOString()
+      persist()
+      return { ok: true, job, done: true }
+    }
+    const fail = Math.random() < 0.08
+    if (fail) {
+      pending.status = 'failed'
+      pending.error = '推理超时或媒体不可读'
+      job.fail_count += 1
+    } else {
+      pending.status = 'completed'
+      pending.detection_id = 10000 + job.batch_id * 10 + job.success_count
+      job.success_count += 1
+      const detId = pending.detection_id
+      importDefectsFromDetections([
+        {
+          detection_id: detId,
+          status: 'COMPLETED',
+          disease_grade: ['MILD', 'MODERATE', 'SEVERE'][job.success_count % 3],
+          disease_severity_score: 0.2 + Math.random() * 0.6,
+          media_name: pending.media_name,
+          model_name: job.model_name,
+          updated_at: new Date().toISOString(),
+        },
+      ])
+    }
+    const done = job.items.filter((i) => i.status !== 'pending').length
+    job.progress = Math.round((done / job.total_count) * 100)
+    if (done >= job.total_count) {
+      job.status = job.fail_count === job.total_count ? BATCH_STATUS.FAILED : BATCH_STATUS.COMPLETED
+      job.finished_at = new Date().toISOString()
+    }
+    persist()
+    return { ok: true, job, done: job.status !== BATCH_STATUS.RUNNING }
+  }
+
+  function cancelBatchJob(batchId) {
+    const job = state.value.batchJobs.find((j) => j.batch_id === batchId)
+    if (!job) return { ok: false, message: '任务不存在' }
+    if (job.status === BATCH_STATUS.COMPLETED) {
+      return { ok: false, message: '已完成任务无法取消' }
+    }
+    job.status = BATCH_STATUS.CANCELLED
+    job.finished_at = new Date().toISOString()
+    persist()
+    return { ok: true, job }
+  }
+
+  function generateReport({ template_id, title, bridge_name, period, created_by }) {
+    const tpl = REPORT_TEMPLATES.find((t) => t.template_id === template_id)
+    if (!tpl) return { ok: false, message: '模板不存在' }
+    const t = new Date().toISOString()
+    const report = {
+      report_id: nextId('report'),
+      template_id,
+      template_name: tpl.name,
+      title: title || `${tpl.name}_${new Date().toISOString().slice(0, 10)}`,
+      bridge_name: bridge_name || '示例桥梁',
+      period: period || '最近 7 日',
+      status: 'generated',
+      file_name: `${title || tpl.name}_${Date.now()}.xlsx`,
+      created_at: t,
+      created_by: created_by || 'admin',
+      summary: {
+        detection_count: state.value.batchJobs.reduce((s, j) => s + (j.success_count || 0), 0) + 12,
+        defect_count: state.value.defects.length,
+        open_defects: state.value.defects.filter((d) => d.status !== DEFECT_STATUS.CLOSED).length,
+        alert_count: 3,
+      },
+    }
+    state.value.reports.unshift(report)
+    persist()
+    return { ok: true, report }
+  }
+
+  function getReportExportRows(report) {
+    const defects = state.value.defects
+    return defects.map((d) => ({
+      隐患编号: d.defect_code,
+      桥区: d.bridge_section,
+      构件: d.component,
+      隐患类型: d.disease_type,
+      等级: gradeLabel[d.grade] || d.grade,
+      严重度: d.severity_score,
+      状态: defectStatusLabel[d.status] || d.status,
+      关联检测ID: d.detection_id || '',
+      媒体: d.media_name || '',
+    }))
+  }
+
+  return {
+    defects,
+    batchJobs,
+    reports,
+    REPORT_TEMPLATES,
+    DEFECT_STATUS,
+    BATCH_STATUS,
+    gradeLabel,
+    defectStatusLabel,
+    batchStatusLabel,
+    listDefects,
+    updateDefectStatus,
+    reviewDefect,
+    importDefectsFromDetections,
+    listBatchJobs,
+    createBatchJob,
+    startBatchJob,
+    advanceBatchJob,
+    cancelBatchJob,
+    generateReport,
+    getReportExportRows,
+  }
+}

+ 340 - 0
bridge-disease-frontend-main/src/stores/resourceStore.js

@@ -0,0 +1,340 @@
+import { ref, readonly } from 'vue'
+import request from '../utils/request'
+import { getMockDetectionsPage, getMockMediasPage } from '../mocks/detectionAndMediaMockData.js'
+
+/**
+ * 仅在开发模式允许启用列表 Mock,避免线上/容器环境误用假数据。
+ * 需要同时满足:VITE_USE_LIST_MOCK=true 且 import.meta.env.DEV=true
+ */
+const USE_LIST_MOCK =
+  import.meta.env.DEV && String(import.meta.env.VITE_USE_LIST_MOCK).toLowerCase() === 'true'
+
+// 创建一个简单的状态存储,用于缓存媒体列表、模型列表、检测分割记录数据和用户列表数据
+const mediaList = ref([])
+const modelList = ref([])
+const detectionList = ref([])
+const userList = ref([])
+const operationList = ref([])
+const mediaTotal = ref(0)
+const modelTotal = ref(0)
+const detectionTotal = ref(0)
+const userTotal = ref(0)
+const operationTotal = ref(0)
+const mediaLoading = ref(false)
+const modelLoading = ref(false)
+const detectionLoading = ref(false)
+const userLoading = ref(false)
+const operationLoading = ref(false)
+const lastMediaFetchTime = ref(null)
+const lastModelFetchTime = ref(null)
+const lastDetectionFetchTime = ref(null)
+const lastUserFetchTime = ref(null)
+const lastOperationFetchTime = ref(null)
+
+// 缓存过期时间(毫秒)
+const CACHE_EXPIRY_TIME = 5 * 60 * 1000 // 5 分钟
+
+// 统一静态文件路径格式,兼容历史数据中的反斜线/缺少 static 前缀
+const normalizeStaticPath = (path) => {
+  if (!path || typeof path !== 'string') return path
+  const normalized = path.replace(/\\/g, '/').replace(/^\/+/, '')
+  if (normalized.startsWith('static/')) return normalized
+  if (normalized.startsWith('medias/') || normalized.startsWith('models/') || normalized.startsWith('results/') || normalized.startsWith('avatars/')) {
+    return `static/${normalized}`
+  }
+  return normalized
+}
+
+// 检查缓存是否过期
+const isCacheExpired = (lastFetchTime) => {
+  if (!lastFetchTime.value) return true
+  return Date.now() - lastFetchTime.value > CACHE_EXPIRY_TIME
+}
+
+// 媒体列表获取函数
+const fetchMediaList = async (userInfo, currentPage = 1, pageSize = 5, forceRefresh = false) => {
+  // 如果数据已加载且缓存未过期,则直接返回缓存的数据
+  if (!USE_LIST_MOCK && mediaList.value.length > 0 && !forceRefresh && !isCacheExpired(lastMediaFetchTime)) {
+    return { medias: mediaList.value, total: mediaTotal.value }
+  }
+
+  try {
+    mediaLoading.value = true
+
+    if (USE_LIST_MOCK) {
+      const mock = getMockMediasPage(currentPage, pageSize)
+      mediaList.value = mock.medias
+      mediaTotal.value = mock.total
+      lastMediaFetchTime.value = Date.now()
+      return { medias: mock.medias, total: mock.total }
+    }
+
+    let data = null
+
+    // 根据用户角色来决定获取媒体列表的方式(兼容枚举名 ADMIN 与值 admin 等)
+    const roleKey = String(userInfo?.role ?? '').toUpperCase()
+    const isAdmin = roleKey === 'ADMIN'
+    const isDeveloper = roleKey === 'DEVELOPER'
+    const isAdminOrDeveloper = isAdmin || isDeveloper
+
+    if (isAdminOrDeveloper) {
+      // 获取所有用户的媒体列表
+      data = await request.get('/media/medias/all', {
+        params: {
+          page: currentPage,
+          per_page: pageSize
+        }
+      })
+    } else {
+      // 获取当前用户的媒体列表
+      data = await request.get(`/media/medias/${userInfo.user_id}`, {
+        params: {
+          page: currentPage,
+          per_page: pageSize
+        }
+      })
+    }
+
+    if (data && !data.failure_message) {
+      const normalizedMedias = (data.medias || []).map(media => ({
+        ...media,
+        media_path: normalizeStaticPath(media.media_path),
+      }))
+      mediaList.value = normalizedMedias
+      mediaTotal.value = data.total
+      lastMediaFetchTime.value = Date.now()
+      return { medias: normalizedMedias, total: data.total }
+    }
+    return { medias: [], total: 0 }
+  } catch (error) {
+    return { medias: [], total: 0, error }
+  } finally {
+    mediaLoading.value = false
+  }
+}
+
+// 模型列表获取函数
+const fetchModelList = async (currentPage = 1, pageSize = 5, forceRefresh = false) => {
+  // 如果数据已加载且缓存未过期,则直接返回缓存的数据
+  if (modelList.value.length > 0 && !forceRefresh && !isCacheExpired(lastModelFetchTime)) {
+    return { models: modelList.value, total: modelTotal.value }
+  }
+
+  try {
+    modelLoading.value = true
+
+    // 发送获取模型列表请求
+    const data = await request.get('/model/models/all', {
+      params: {
+        page: currentPage,
+        per_page: pageSize
+      }
+    })
+
+    if (data && !data.failure_message) {
+      modelList.value = data.models
+      modelTotal.value = data.total
+      lastModelFetchTime.value = Date.now()
+      return { models: data.models, total: data.total }
+    }
+    return { models: [], total: 0 }
+  } catch (error) {
+    return { models: [], total: 0, error }
+  } finally {
+    modelLoading.value = false
+  }
+}
+
+// 检测分割记录列表获取函数
+const fetchDetectionList = async (userInfo, currentPage = 1, pageSize = 5, forceRefresh = false) => {
+  // 如果数据已加载且缓存未过期,则直接返回缓存的数据
+  if (!USE_LIST_MOCK && detectionList.value.length > 0 && !forceRefresh && !isCacheExpired(lastDetectionFetchTime)) {
+    return { detections: detectionList.value, total: detectionTotal.value }
+  }
+
+  try {
+    detectionLoading.value = true
+
+    if (USE_LIST_MOCK) {
+      const mock = getMockDetectionsPage(currentPage, pageSize)
+      detectionList.value = mock.detections
+      detectionTotal.value = mock.total
+      lastDetectionFetchTime.value = Date.now()
+      return { detections: mock.detections, total: mock.total }
+    }
+
+    let data = null
+
+    // 根据用户角色来决定获取检测分割记录列表的方式
+    const roleKey = String(userInfo?.role ?? '').toUpperCase()
+    const isAdmin = roleKey === 'ADMIN'
+    const isDeveloper = roleKey === 'DEVELOPER'
+    const isAdminOrDeveloper = isAdmin || isDeveloper
+
+    if (isAdminOrDeveloper) {
+      // 获取所有用户的检测分割记录列表
+      data = await request.get('/detection/detections/all', {
+        params: {
+          page: currentPage,
+          per_page: pageSize
+        }
+      })
+    } else {
+      // 获取当前用户的检测分割记录列表
+      data = await request.get(`/detection/detections/${userInfo.user_id}`, {
+        params: {
+          page: currentPage,
+          per_page: pageSize
+        }
+      })
+    }
+
+    if (data && !data.failure_message) {
+      detectionList.value = data.detections
+      detectionTotal.value = data.total
+      lastDetectionFetchTime.value = Date.now()
+      return { detections: data.detections, total: data.total }
+    }
+    return { detections: [], total: 0 }
+  } catch (error) {
+    return { detections: [], total: 0, error }
+  } finally {
+    detectionLoading.value = false
+  }
+}
+
+// 用户列表获取函数
+const fetchUserList = async (userInfo, currentPage = 1, pageSize = 5, forceRefresh = false) => {
+  // 检查用户权限,只有管理员或开发人员才能获取用户列表
+  const roleKey = String(userInfo?.role ?? '').toUpperCase()
+  const isAdmin = roleKey === 'ADMIN'
+  const isDeveloper = roleKey === 'DEVELOPER'
+  const isAdminOrDeveloper = isAdmin || isDeveloper
+
+  // 如果不是管理员或开发人员,拒绝请求
+  if (!isAdminOrDeveloper) {
+    return { users: [], total: 0, error: '权限不足,只有管理员或开发人员才能查看用户列表' }
+  }
+
+  // 如果数据已加载且缓存未过期,则直接返回缓存的数据
+  if (userList.value.length > 0 && !forceRefresh && !isCacheExpired(lastUserFetchTime)) {
+    return { users: userList.value, total: userTotal.value }
+  }
+
+  try {
+    userLoading.value = true
+
+    // 发送获取用户列表请求
+    const data = await request.get('/user/users/all', {
+      params: {
+        page: currentPage,
+        per_page: pageSize
+      }
+    })
+
+    if (data && !data.failure_message) {
+      userList.value = data.users
+      userTotal.value = data.total
+      lastUserFetchTime.value = Date.now()
+      return { users: data.users, total: data.total }
+    }
+    return { users: [], total: 0 }
+  } catch (error) {
+    return { users: [], total: 0, error }
+  } finally {
+    userLoading.value = false
+  }
+}
+
+// 操作日志列表获取函数
+const fetchOperationList = async (userInfo, currentPage = 1, pageSize = 5, forceRefresh = false) => {
+  // 检查用户权限,只有管理员或开发人员才能获取操作日志列表
+  const roleKey = String(userInfo?.role ?? '').toUpperCase()
+  const isAdmin = roleKey === 'ADMIN'
+  const isDeveloper = roleKey === 'DEVELOPER'
+  const isAdminOrDeveloper = isAdmin || isDeveloper
+
+  // 如果不是管理员或开发人员,拒绝请求
+  if (!isAdminOrDeveloper) {
+    return { operations: [], total: 0, error: '权限不足,只有管理员或开发人员才能查看操作日志' }
+  }
+
+  // 如果数据已加载且缓存未过期,则直接返回缓存的数据
+  if (operationList.value.length > 0 && !forceRefresh && !isCacheExpired(lastOperationFetchTime)) {
+    return { operations: operationList.value, total: operationTotal.value }
+  }
+
+  try {
+    operationLoading.value = true
+    let data = null
+
+    // 获取当前用户的操作日志列表
+    data = await request.get(`/operation/operations/all`, {
+      params: {
+        page: currentPage,
+        per_page: pageSize
+      }
+    })
+
+    if (data && !data.failure_message) {
+      operationList.value = data.operations
+      operationTotal.value = data.total
+      lastOperationFetchTime.value = Date.now()
+      return { operations: data.operations, total: data.total }
+    }
+    return { operations: [], total: 0 }
+  } catch (error) {
+    return { operations: [], total: 0, error }
+  } finally {
+    operationLoading.value = false
+  }
+}
+
+// 清除缓存
+const clearCache = () => {
+  mediaList.value = []
+  modelList.value = []
+  detectionList.value = []
+  userList.value = []
+  operationList.value = []
+  mediaTotal.value = 0
+  modelTotal.value = 0
+  detectionTotal.value = 0
+  userTotal.value = 0
+  operationTotal.value = 0
+  lastMediaFetchTime.value = null
+  lastModelFetchTime.value = null
+  lastDetectionFetchTime.value = null
+  lastUserFetchTime.value = null
+  lastOperationFetchTime.value = null
+}
+
+// 导出 composable 函数
+export function useResourceStore() {
+  return {
+    // 状态
+    mediaList: readonly(mediaList),
+    modelList: readonly(modelList),
+    detectionList: readonly(detectionList),
+    userList: readonly(userList),
+    operationList: readonly(operationList),
+    mediaTotal: readonly(mediaTotal),
+    modelTotal: readonly(modelTotal),
+    detectionTotal: readonly(detectionTotal),
+    userTotal: readonly(userTotal),
+    operationTotal: readonly(operationTotal),
+    mediaLoading: readonly(mediaLoading),
+    modelLoading: readonly(modelLoading),
+    detectionLoading: readonly(detectionLoading),
+    userLoading: readonly(userLoading),
+    operationLoading: readonly(operationLoading),
+    
+    // 方法
+    fetchMediaList,
+    fetchModelList,
+    fetchDetectionList,
+    fetchUserList,
+    fetchOperationList,
+    clearCache
+  }
+}

+ 22 - 0
bridge-disease-frontend-main/src/stores/sidebarStore.js

@@ -0,0 +1,22 @@
+// 侧边栏状态管理
+import { ref } from 'vue'
+
+// 创建一个全局状态,用于存储侧边栏的折叠状态
+const isCollapsed = ref(false)
+
+// 初始化时从 localStorage 读取状态
+if (typeof window !== 'undefined') {
+  const savedState = localStorage.getItem('sidebar_collapsed')
+  if (savedState !== null) {
+    isCollapsed.value = savedState === 'true'
+  }
+}
+
+// 切换折叠状态
+function toggleCollapse() {
+  isCollapsed.value = !isCollapsed.value
+  // 保存到 localStorage
+  localStorage.setItem('sidebar_collapsed', isCollapsed.value.toString())
+}
+
+export { isCollapsed, toggleCollapse }

+ 11 - 0
bridge-disease-frontend-main/src/stores/uiShellStore.js

@@ -0,0 +1,11 @@
+import { ref } from 'vue'
+
+export const agentDrawerOpen = ref(false)
+
+export function openAgentDrawer() {
+  agentDrawerOpen.value = true
+}
+
+export function closeAgentDrawer() {
+  agentDrawerOpen.value = false
+}

+ 40 - 0
bridge-disease-frontend-main/src/stores/userStore.js

@@ -0,0 +1,40 @@
+import { ref } from 'vue'
+import { ElMessage } from 'element-plus'
+
+const userInfo = ref(null)
+const userLoading = ref(false)
+
+const getUserInfo = async () => {
+  try {
+    userLoading.value = true
+    // 检查是否有 token
+    const token = localStorage.getItem('access_token')
+    if (!token) {
+      ElMessage.warning({
+        message: '【获取用户信息失败】未登录或登录已过期,请重新登录',
+        duration: 4000
+      })
+      return
+    }
+
+    // 从 localStorage 中获取用户信息
+    const storedUser = localStorage.getItem('login_user')
+    userInfo.value = JSON.parse(storedUser)
+  } catch (error) {
+    console.error('【获取用户信息错误】', error)
+    ElMessage.error({
+      message: '【获取用户信息错误】' + (error?.message || '请重试'),
+      duration: 5000
+    })
+  } finally {
+    userLoading.value = false
+  }
+}
+
+export function useUserStore() {
+  return {
+    userInfo,
+    userLoading: userLoading,
+    getUserInfo
+  }
+}

+ 51 - 0
bridge-disease-frontend-main/src/styles/hud-ep-bridge.css

@@ -0,0 +1,51 @@
+/**
+ * 在 element-plus/theme-chalk/dark/css-vars.css 之后加载,覆盖其纯黑 #141414 中性面,
+ * 使表格/卡片/输入框/分页等与 HUD 的 --card-solid + --primary 靛紫石板调一致。
+ * main.js 中必须放在 hud-layout.css、shell-theme.css 之后最后引入。
+ */
+
+html.dark {
+  /* 主色链(dark/css-vars 会把 html.dark 上的 primary 设回 #409eff,此处强制对齐 HUD) */
+  --el-color-primary: #818cf8;
+  --el-color-primary-light-3: #9aa4fa;
+  --el-color-primary-light-5: #a8b0fb;
+  --el-color-primary-light-7: #c7ccf9;
+  --el-color-primary-light-8: #d8dbf9;
+  --el-color-primary-light-9: #e9ebfd;
+  --el-color-primary-dark-2: #4c51bf;
+
+  /* 页面与容器底:由纯黑改为靛青石板调 */
+  --el-bg-color-page: var(--background);
+  --el-bg-color: color-mix(in srgb, var(--card-solid) 92%, var(--primary) 8%);
+  --el-bg-color-overlay: color-mix(in srgb, var(--card-solid) 86%, var(--primary) 14%);
+  --el-dialog-bg-color: color-mix(in srgb, var(--card-solid) 90%, var(--primary) 10%);
+  --el-drawer-bg-color: color-mix(in srgb, var(--card-solid) 88%, var(--primary) 12%);
+
+  --el-fill-color-darker: color-mix(in srgb, var(--card-solid) 72%, var(--primary) 16%);
+  --el-fill-color-dark: color-mix(in srgb, var(--card-solid) 78%, var(--primary) 14%);
+  --el-fill-color: color-mix(in srgb, var(--card-solid) 84%, var(--primary) 12%);
+  --el-fill-color-light: color-mix(in srgb, var(--card-solid) 88%, var(--primary) 10%);
+  --el-fill-color-lighter: color-mix(in srgb, var(--card-solid) 91%, var(--primary) 8%);
+  --el-fill-color-extra-light: color-mix(in srgb, var(--card-solid) 94%, var(--primary) 6%);
+
+  /* 边框:带一点主色,避免与纯灰黑完全脱节 */
+  --el-border-color: color-mix(in srgb, var(--primary) 28%, rgba(148, 163, 184, 0.35));
+  --el-border-color-light: color-mix(in srgb, var(--primary) 22%, rgba(148, 163, 184, 0.28));
+  --el-border-color-lighter: color-mix(in srgb, var(--primary) 16%, rgba(148, 163, 184, 0.22));
+  --el-border-color-extra-light: color-mix(in srgb, var(--primary) 12%, rgba(148, 163, 184, 0.16));
+
+  /* 弱化 EP 默认过重阴影 */
+  --el-box-shadow: 0 12px 32px 4px rgba(0, 0, 0, 0.22), 0 8px 20px rgba(0, 0, 0, 0.28);
+  --el-box-shadow-light: 0 0 12px rgba(0, 0, 0, 0.28);
+}
+
+/* 浅色:统一主色链(index.css 默认蓝) */
+html:not(.dark) {
+  --el-color-primary: #4f46e5;
+  --el-color-primary-light-3: #7c73ed;
+  --el-color-primary-light-5: #9890f0;
+  --el-color-primary-light-7: #b4aef5;
+  --el-color-primary-light-8: #c7c2f7;
+  --el-color-primary-light-9: #e8e6fc;
+  --el-color-primary-dark-2: #3730a3;
+}

+ 817 - 0
bridge-disease-frontend-main/src/styles/hud-layout.css

@@ -0,0 +1,817 @@
+/* HUD / 控制台壳层 — 仅视觉,勿改业务脚本 */
+
+@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap');
+
+:root {
+  --font-mono: 'JetBrains Mono', ui-monospace, 'Cascadia Mono', 'Segoe UI Mono', monospace;
+  --font-sans: var(--font-mono);
+  --el-font-family: var(--font-mono);
+}
+
+:root[data-theme='light'],
+:root:not([data-theme]) {
+  --background: #f4f6fb;
+  --foreground: #0b1220;
+  --muted: #64748b;
+  --card: rgba(255, 255, 255, 0.82);
+  --card-solid: #fcfcfc;
+  --border: rgba(15, 23, 42, 0.1);
+  --ring: rgba(79, 70, 229, 0.35);
+  --primary: #4f46e5;
+  --primary-foreground: #f8fafc;
+  --secondary: #0f172a;
+  --accent: #22d3ee;
+  --destructive: #dc2626;
+  --sidebar: #0c1222;
+  --sidebar-foreground: rgba(248, 250, 252, 0.88);
+  --sidebar-border: rgba(148, 163, 184, 0.12);
+  --hud-grid: rgba(79, 70, 229, 0.07);
+  --hud-glow: rgba(79, 70, 229, 0.12);
+  --radius: 0.5rem;
+  --radius-lg: 1rem;
+  --radius-xl: 1.25rem;
+  /* Element Plus 主色链与 HUD 对齐(避免按钮/链接仍为默认蓝) */
+  --el-color-primary: #4f46e5;
+  --el-color-primary-light-3: #7c73ed;
+  --el-color-primary-light-5: #9890f0;
+  --el-color-primary-light-7: #b4aef5;
+  --el-color-primary-light-8: #c7c2f7;
+  --el-color-primary-light-9: #e8e6fc;
+  --el-color-primary-dark-2: #3730a3;
+}
+
+:root[data-theme='dark'] {
+  --background: #070b12;
+  --foreground: #e2e8f0;
+  --muted: #94a3b8;
+  --card: rgba(15, 23, 42, 0.72);
+  --card-solid: #0f172a;
+  --border: rgba(148, 163, 184, 0.14);
+  --ring: rgba(129, 140, 248, 0.45);
+  --primary: #818cf8;
+  --primary-foreground: #0b1220;
+  --secondary: #f1f5f9;
+  --accent: #22d3ee;
+  --destructive: #f87171;
+  --sidebar: #050810;
+  --sidebar-foreground: rgba(248, 250, 252, 0.9);
+  --sidebar-border: rgba(148, 163, 184, 0.1);
+  --hud-grid: rgba(129, 140, 248, 0.08);
+  --hud-glow: rgba(129, 140, 248, 0.15);
+  --el-color-primary: #818cf8;
+  --el-color-primary-light-3: #9aa4fa;
+  --el-color-primary-light-5: #a8b0fb;
+  --el-color-primary-light-7: #c7ccf9;
+  --el-color-primary-light-8: #d8dbf9;
+  --el-color-primary-light-9: #e9ebfd;
+  --el-color-primary-dark-2: #4c51bf;
+}
+
+html,
+body,
+#app {
+  font-family: var(--font-mono);
+  color: var(--foreground);
+  background-color: var(--background);
+}
+
+/* 4.1 基底 + 坐标网格 */
+.hud-app::before {
+  content: '';
+  position: fixed;
+  inset: 0;
+  z-index: 0;
+  pointer-events: none;
+  background:
+    radial-gradient(900px 520px at 12% -8%, var(--hud-glow), transparent 58%),
+    radial-gradient(760px 480px at 96% 4%, rgba(34, 211, 238, 0.06), transparent 52%),
+    linear-gradient(180deg, var(--background), var(--background));
+  animation: hud-ambient 22s ease-in-out infinite alternate;
+}
+
+.hud-app::after {
+  content: '';
+  position: fixed;
+  inset: 0;
+  z-index: 0;
+  pointer-events: none;
+  background-image:
+    linear-gradient(var(--hud-grid) 1px, transparent 1px),
+    linear-gradient(90deg, var(--hud-grid) 1px, transparent 1px);
+  background-size: 44px 44px;
+  mask-image: radial-gradient(ellipse 70% 55% at 50% 42%, #000 22%, transparent 72%);
+  opacity: 0.85;
+  animation: hud-grid-drift 56s linear infinite;
+}
+
+@keyframes hud-ambient {
+  from {
+    filter: hue-rotate(0deg);
+  }
+  to {
+    filter: hue-rotate(12deg);
+  }
+}
+
+@keyframes hud-grid-drift {
+  from {
+    background-position: 0 0, 0 0;
+  }
+  to {
+    background-position: 44px 22px, 44px 22px;
+  }
+}
+
+.hud-app > * {
+  position: relative;
+  z-index: 1;
+}
+
+/* 5.1 顶栏铬层 + 扫光 */
+.hud-top-chrome {
+  position: sticky;
+  top: 0;
+  z-index: 60;
+  min-height: 56px;
+  padding: 0 18px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  border-bottom: 1px solid color-mix(in srgb, var(--primary) 35%, transparent);
+  background: linear-gradient(
+      115deg,
+      color-mix(in srgb, var(--card-solid) 88%, transparent),
+      color-mix(in srgb, var(--primary) 8%, transparent),
+      color-mix(in srgb, var(--card-solid) 92%, transparent)
+    ),
+    var(--card-solid);
+  backdrop-filter: blur(16px);
+  -webkit-backdrop-filter: blur(16px);
+  box-shadow: 0 1px 0 color-mix(in srgb, var(--primary) 22%, transparent);
+}
+
+.hud-top-chrome::before {
+  content: '';
+  position: absolute;
+  inset: 0;
+  pointer-events: none;
+  background: linear-gradient(
+    105deg,
+    transparent 0%,
+    rgba(255, 255, 255, 0.35) 45%,
+    transparent 55%
+  );
+  mix-blend-mode: overlay;
+  opacity: 0.38;
+  transform: translateX(-40%);
+  animation: hud-chrome-sweep 12s ease-in-out infinite;
+}
+
+@keyframes hud-chrome-sweep {
+  0%,
+  100% {
+    transform: translateX(-55%);
+  }
+  50% {
+    transform: translateX(55%);
+  }
+}
+
+.hud-top-chrome-inner {
+  position: relative;
+  z-index: 2;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  width: 100%;
+  gap: 12px;
+  min-height: 56px;
+}
+
+@media (min-width: 900px) {
+  .hud-top-chrome,
+  .hud-top-chrome-inner {
+    min-height: 64px;
+  }
+}
+
+/* 主内容区高度:扣除顶栏,单滚动 */
+.hud-app .router-view-container {
+  flex: 1;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+}
+
+/* 5.2 舞台容器 — 背景/阴影由文末「业务页 shell」高优先级规则统一(避免与 scoped 灰底冲突) */
+.shell-layout-root .content-area {
+  position: relative;
+  border-radius: var(--radius-xl) !important;
+  border: 1px solid color-mix(in srgb, var(--primary) 14%, var(--border)) !important;
+  animation: hud-stage-breathe 5.5s ease-in-out infinite alternate;
+}
+
+@keyframes hud-stage-breathe {
+  from {
+    box-shadow:
+      inset 0 1px 0 rgba(255, 255, 255, 0.5),
+      0 0 0 1px color-mix(in srgb, var(--primary) 5%, transparent),
+      0 20px 50px color-mix(in srgb, var(--primary) 8%, transparent),
+      0 36px 80px rgba(15, 23, 42, 0.05);
+  }
+  to {
+    box-shadow:
+      inset 0 1px 0 rgba(255, 255, 255, 0.62),
+      0 0 0 1px color-mix(in srgb, var(--primary) 12%, transparent),
+      0 26px 64px color-mix(in srgb, var(--primary) 14%, transparent),
+      0 44px 100px rgba(15, 23, 42, 0.07);
+  }
+}
+
+@keyframes hud-stage-breathe-dark {
+  from {
+    box-shadow:
+      inset 0 1px 0 rgba(255, 255, 255, 0.04),
+      0 0 0 1px color-mix(in srgb, var(--primary) 10%, transparent),
+      0 18px 48px color-mix(in srgb, var(--primary) 14%, transparent),
+      0 32px 72px rgba(0, 0, 0, 0.45);
+  }
+  to {
+    box-shadow:
+      inset 0 1px 0 rgba(255, 255, 255, 0.08),
+      0 0 0 1px color-mix(in srgb, var(--primary) 20%, transparent),
+      0 24px 56px color-mix(in srgb, var(--primary) 22%, transparent),
+      0 40px 88px rgba(0, 0, 0, 0.55);
+  }
+}
+
+/* 页首 HUD */
+.hud-page-hero {
+  position: relative;
+  padding: 22px 22px 20px 28px;
+  margin-bottom: 18px;
+  border-radius: var(--radius-xl);
+  border: 1px solid color-mix(in srgb, var(--primary) 18%, var(--border));
+  background: linear-gradient(
+    125deg,
+    color-mix(in srgb, var(--card-solid) 92%, transparent),
+    color-mix(in srgb, var(--primary) 6%, transparent)
+  );
+  box-shadow: 0 16px 48px rgba(15, 23, 42, 0.07);
+  overflow: hidden;
+}
+
+.hud-page-hero::before {
+  content: '';
+  position: absolute;
+  left: 0;
+  top: 12px;
+  bottom: 12px;
+  width: 4px;
+  border-radius: 4px;
+  background: linear-gradient(180deg, var(--primary), var(--accent));
+  opacity: 0.85;
+}
+
+.hud-hud-corners {
+  position: absolute;
+  inset: 10px;
+  pointer-events: none;
+  border: 2px solid color-mix(in srgb, var(--primary) 45%, transparent);
+  border-radius: calc(var(--radius-xl) - 6px);
+  clip-path: polygon(
+    0 0,
+    18px 0,
+    18px 2px,
+    2px 2px,
+    2px 18px,
+    0 18px,
+    0 0,
+    100% 0,
+    calc(100% - 18px) 0,
+    calc(100% - 18px) 2px,
+    calc(100% - 2px) 2px,
+    calc(100% - 2px) 18px,
+    100% 18px,
+    100% 0,
+    100% 100%,
+    calc(100% - 18px) 100%,
+    calc(100% - 18px) calc(100% - 2px),
+    calc(100% - 2px) calc(100% - 2px),
+    calc(100% - 2px) calc(100% - 18px),
+    100% calc(100% - 18px),
+    100% 100%,
+    0 100%,
+    18px 100%,
+    18px calc(100% - 2px),
+    2px calc(100% - 2px),
+    2px calc(100% - 18px),
+    0 calc(100% - 18px),
+    0 100%
+  );
+  opacity: 0.35;
+}
+
+.hud-page-hero h2 {
+  margin: 0 0 6px;
+  font-size: 1.45rem;
+  font-weight: 800;
+  letter-spacing: -0.03em;
+  background: linear-gradient(90deg, var(--foreground), var(--primary), var(--accent));
+  -webkit-background-clip: text;
+  background-clip: text;
+  color: transparent;
+}
+
+.hud-page-hero p {
+  margin: 0;
+  color: var(--muted);
+  max-width: 52ch;
+  line-height: 1.55;
+}
+
+.hud-hero-divider {
+  margin-top: 14px;
+  height: 2px;
+  max-width: 360px;
+  border-radius: 2px;
+  background: linear-gradient(90deg, var(--primary), var(--accent), transparent);
+  opacity: 0.65;
+}
+
+/* 路由进场 */
+.hud-route-enter-active {
+  transition:
+    opacity 0.42s cubic-bezier(0.16, 1, 0.3, 1),
+    transform 0.42s cubic-bezier(0.16, 1, 0.3, 1);
+}
+.hud-route-leave-active {
+  transition: opacity 0.2s ease;
+}
+.hud-route-enter-from {
+  opacity: 0;
+  transform: translateY(18px) scale(0.985);
+}
+.hud-route-leave-to {
+  opacity: 0;
+}
+
+/* 表格 HUD(全局,依赖 .hud-app) */
+.hud-app .el-table {
+  --el-table-border-color: var(--border);
+  --el-table-header-bg-color: color-mix(in srgb, var(--card-solid) 88%, transparent);
+}
+
+.hud-app .el-table__header-wrapper th.el-table__cell {
+  font-family: var(--font-mono);
+  font-size: 11px;
+  font-weight: 600;
+  letter-spacing: 0.14em;
+  text-transform: uppercase;
+  color: var(--muted) !important;
+  background: transparent !important;
+}
+
+.hud-app .el-table__body td.el-table__cell {
+  font-family: var(--font-mono);
+}
+
+.hud-app .el-table__body tr:hover > td.el-table__cell {
+  background-color: color-mix(in srgb, var(--primary) 6%, transparent) !important;
+  box-shadow: inset 4px 0 0 0 color-mix(in srgb, var(--primary) 55%, transparent);
+}
+
+/* 减少动态 */
+@media (prefers-reduced-motion: reduce) {
+  .hud-app::before,
+  .hud-app::after,
+  .hud-top-chrome::before,
+  .shell-layout-root .content-area,
+  .hud-app .shell-layout-root > .main-container > .content-area {
+    animation: none !important;
+  }
+  .hud-route-enter-active {
+    transition: opacity 0.18s ease;
+  }
+  .hud-route-enter-from {
+    transform: none;
+    scale: 1;
+  }
+  .shell-card-elevated:hover {
+    transform: none !important;
+  }
+  .sidebar-menu {
+    transition: width 0.2s linear !important;
+  }
+  .sidebar-menu .el-menu-item,
+  .sidebar-menu .el-menu-item.is-active {
+    transform: none !important;
+  }
+}
+
+/* 登录/注册:左右分栏(Grid 固定两列,避免 flex 在嵌套列布局下被压成上下块) */
+.hud-auth {
+  display: grid;
+  grid-template-columns: minmax(260px, min(44vw, 520px)) minmax(0, 1fr);
+  grid-template-rows: 1fr;
+  align-items: stretch;
+  width: 100%;
+  max-width: 100%;
+  height: 100%;
+  min-height: 0;
+  box-sizing: border-box;
+  position: relative;
+  overflow-x: hidden;
+}
+
+.hud-auth-brand {
+  grid-column: 1;
+  grid-row: 1;
+  min-width: 0;
+  min-height: 0;
+  background: var(--background);
+  color: var(--foreground);
+  padding: 48px 40px;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  text-align: center;
+  position: relative;
+  overflow: hidden;
+}
+
+.hud-auth-brand-inner {
+  position: relative;
+  z-index: 1;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  width: 100%;
+}
+
+.hud-auth-brand h2 {
+  margin: 0 0 12px;
+  font-size: clamp(1.6rem, 3vw, 2.1rem);
+  font-weight: 800;
+  letter-spacing: -0.02em;
+  line-height: 1.2;
+}
+
+.hud-auth-brand .accent {
+  color: var(--primary);
+}
+
+.hud-auth-brand ul {
+  margin: 20px 0 0;
+  padding: 0;
+  list-style: none;
+  color: var(--muted);
+  font-size: 0.92rem;
+  line-height: 1.7;
+  width: 100%;
+}
+
+.hud-auth-brand ul li {
+  display: flex;
+  align-items: flex-start;
+  justify-content: center;
+  gap: 8px;
+  text-align: left;
+}
+
+.hud-auth-brand li::before {
+  content: '▍';
+  color: var(--primary);
+  margin-right: 0;
+  font-weight: 700;
+  flex-shrink: 0;
+}
+
+.hud-auth-panel {
+  grid-column: 2;
+  grid-row: 1;
+  min-width: 0;
+  min-height: 0;
+  position: relative;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 32px 20px;
+  background: var(--background);
+}
+
+.hud-page-hero-inner {
+  position: relative;
+  z-index: 1;
+}
+
+.hud-auth-form-card {
+  position: relative;
+  z-index: 1;
+  width: 100%;
+  max-width: 460px;
+  padding: 28px 26px;
+  border-radius: var(--radius-xl);
+  border: 1px solid color-mix(in srgb, var(--primary) 16%, var(--border));
+  background: color-mix(in srgb, var(--card-solid) 88%, transparent);
+  backdrop-filter: blur(14px);
+  -webkit-backdrop-filter: blur(14px);
+  box-shadow:
+    inset 0 1px 0 rgba(255, 255, 255, 0.5),
+    0 18px 48px rgba(15, 23, 42, 0.08),
+    0 0 0 1px color-mix(in srgb, var(--primary) 8%, transparent);
+}
+
+/* ========== 夜间全局:html.dark 与 data-theme=dark 同步(useHudTheme) ========== */
+
+html.dark .hud-top-chrome::before {
+  background: linear-gradient(
+    105deg,
+    transparent 0%,
+    rgba(129, 140, 248, 0.28) 45%,
+    transparent 55%
+  );
+  mix-blend-mode: screen;
+  opacity: 0.26;
+}
+
+html.dark .hud-page-hero {
+  box-shadow: 0 18px 48px rgba(0, 0, 0, 0.35);
+}
+
+html.dark .hud-app .el-breadcrumb__inner {
+  color: var(--el-text-color-regular) !important;
+  font-weight: 500;
+}
+
+html.dark .hud-app .el-breadcrumb__item:last-child .el-breadcrumb__inner,
+html.dark .hud-app .el-breadcrumb__item:last-child .el-breadcrumb__inner:hover {
+  color: var(--el-text-color-primary) !important;
+  cursor: default;
+}
+
+html.dark .hud-app .shell-layout-root .el-card {
+  --el-card-bg-color: color-mix(in srgb, var(--card-solid) 88%, transparent);
+  background-color: var(--el-card-bg-color) !important;
+  border-color: var(--el-border-color) !important;
+}
+
+html.dark .hud-app .shell-layout-root .el-card__header {
+  border-bottom-color: var(--el-border-color);
+  color: var(--el-text-color-primary);
+}
+
+html.dark .hud-app .el-table {
+  --el-table-bg-color: color-mix(in srgb, var(--card-solid) 91%, var(--primary) 9%);
+  --el-table-tr-bg-color: color-mix(in srgb, var(--card-solid) 93%, var(--primary) 7%);
+  --el-table-header-bg-color: color-mix(in srgb, var(--card-solid) 82%, var(--primary) 14%);
+  --el-table-row-hover-bg-color: color-mix(in srgb, var(--primary) 16%, var(--card-solid));
+  --el-table-current-row-bg-color: color-mix(in srgb, var(--primary) 12%, var(--card-solid));
+}
+
+html.dark .hud-app .el-pagination {
+  --el-pagination-bg-color: transparent;
+  --el-pagination-text-color: var(--el-text-color-regular);
+  --el-pagination-button-color: var(--el-text-color-regular);
+  --el-pagination-button-disabled-bg-color: color-mix(in srgb, var(--card-solid) 90%, var(--primary) 5%);
+}
+
+html.dark .hud-app .el-pagination .el-input__wrapper {
+  background-color: color-mix(in srgb, var(--card-solid) 88%, var(--primary) 10%);
+  box-shadow: 0 0 0 1px color-mix(in srgb, var(--primary) 22%, var(--el-border-color)) inset;
+}
+
+/* 默认按钮:避免与纯黑底融在一起 */
+html.dark .hud-app .el-button--default {
+  --el-button-bg-color: color-mix(in srgb, var(--card-solid) 88%, var(--primary) 10%);
+  --el-button-border-color: color-mix(in srgb, var(--primary) 32%, var(--el-border-color));
+  --el-button-hover-bg-color: color-mix(in srgb, var(--primary) 18%, var(--card-solid));
+  --el-button-hover-border-color: color-mix(in srgb, var(--primary) 45%, var(--el-border-color));
+}
+
+html.dark .hud-app .el-input__wrapper {
+  background-color: color-mix(in srgb, var(--card-solid) 90%, var(--primary) 8%);
+  box-shadow: 0 0 0 1px color-mix(in srgb, var(--primary) 20%, var(--el-border-color)) inset;
+}
+
+html.dark .detection-records-container,
+html.dark .operation-logs-container,
+html.dark .media-library-container,
+html.dark .model-library-container,
+html.dark .user-management-container,
+html.dark .disease-detection-container,
+html.dark .home-container {
+  background-color: transparent !important;
+}
+
+html.dark .agent-drawer .drawer-title {
+  color: var(--el-text-color-primary);
+}
+
+html.dark .agent-drawer .messages {
+  background: var(--el-fill-color-dark);
+  border-color: var(--el-border-color);
+}
+
+html.dark .agent-drawer .assistant .bubble {
+  background: var(--el-bg-color-overlay);
+  color: var(--el-text-color-primary);
+  border-color: var(--el-border-color);
+}
+
+html.dark .agent-drawer .typing {
+  color: var(--el-text-color-secondary);
+}
+
+html.dark .hud-app .card-header h2 {
+  color: var(--el-text-color-primary);
+}
+
+html.dark .hud-app .model-info-card,
+html.dark .hud-app .detection-summary,
+html.dark .hud-app .disease-detection-container .preview-container {
+  background-color: var(--el-fill-color-dark) !important;
+  border: 1px solid var(--el-border-color) !important;
+  color: var(--el-text-color-primary);
+}
+
+html.dark .hud-app .model-info-card h3,
+html.dark .hud-app .model-info-card h4,
+html.dark .hud-app .detection-summary h4,
+html.dark .hud-app .model-info-card .info-label,
+html.dark .hud-app .detection-summary .summary-label {
+  color: var(--el-text-color-secondary);
+}
+
+html.dark .hud-app .model-info-card .info-value,
+html.dark .hud-app .detection-summary .summary-value {
+  color: var(--el-text-color-primary);
+}
+
+html.dark .hud-app .preview-placeholder {
+  color: var(--el-text-color-secondary);
+}
+
+/* 列表页「条件筛选」:各视图 scoped 内写死 #f8f9fa,夜间统一拉回 Element 深色面 */
+html.dark .hud-app .search-form {
+  background-color: var(--el-fill-color-dark) !important;
+  border: 1px solid var(--el-border-color) !important;
+}
+
+html.dark .hud-app .search-form .el-form-item__label {
+  color: var(--el-text-color-regular) !important;
+}
+
+html.dark .hud-app .date-separator {
+  color: var(--el-text-color-secondary);
+}
+
+/* 检测分割记录详情里与筛选同色的信息块 */
+html.dark .hud-app .result-content,
+html.dark .hud-app .detection-params,
+html.dark .hud-app .detection-stats {
+  background-color: var(--el-fill-color-dark) !important;
+  border: 1px solid var(--el-border-color);
+  color: var(--el-text-color-primary);
+}
+
+/* ========== 业务页 shell:统一覆盖各视图 scoped 内灰/蓝灰底,与 HUD 靛紫 + 青色点缀一致 ========== */
+
+.hud-app .home-container,
+.hud-app .media-library-container,
+.hud-app .model-library-container,
+.hud-app .user-management-container,
+.hud-app .disease-detection-container,
+.hud-app .detection-records-container,
+.hud-app .operation-logs-container {
+  background-color: transparent !important;
+}
+
+.hud-app .shell-layout-root > .main-container > .content-area {
+  background-color: transparent !important;
+  background-image: linear-gradient(
+    155deg,
+    color-mix(in srgb, var(--card-solid) 96%, var(--primary) 4%) 0%,
+    color-mix(in srgb, var(--card-solid) 86%, var(--primary) 12%) 52%,
+    color-mix(in srgb, var(--card-solid) 93%, var(--accent) 6%) 100%
+  ) !important;
+  border-radius: var(--radius-xl) !important;
+  border: 1px solid color-mix(in srgb, var(--primary) 14%, var(--border)) !important;
+  box-shadow:
+    inset 0 1px 0 rgba(255, 255, 255, 0.52),
+    0 0 0 1px color-mix(in srgb, var(--primary) 7%, transparent),
+    0 22px 56px color-mix(in srgb, var(--primary) 11%, rgba(15, 23, 42, 0.06)) !important;
+  animation: hud-stage-breathe 5.5s ease-in-out infinite alternate;
+}
+
+html.dark .hud-app .shell-layout-root > .main-container > .content-area {
+  background-image: linear-gradient(
+    155deg,
+    color-mix(in srgb, var(--card-solid) 91%, var(--primary) 9%) 0%,
+    color-mix(in srgb, var(--card-solid) 72%, var(--primary) 24%) 48%,
+    color-mix(in srgb, var(--card-solid) 86%, var(--accent) 10%) 100%
+  ) !important;
+  border-color: color-mix(in srgb, var(--primary) 22%, var(--border)) !important;
+  box-shadow:
+    inset 0 1px 0 rgba(255, 255, 255, 0.05),
+    0 0 0 1px color-mix(in srgb, var(--primary) 12%, transparent),
+    0 20px 48px rgba(0, 0, 0, 0.42) !important;
+  animation: hud-stage-breathe-dark 5.5s ease-in-out infinite alternate;
+}
+
+.hud-app .shell-layout-root .media-card,
+.hud-app .shell-layout-root .model-card,
+.hud-app .shell-layout-root .user-card,
+.hud-app .shell-layout-root .detection-card,
+.hud-app .shell-layout-root .records-card,
+.hud-app .shell-layout-root .logs-card {
+  background: color-mix(in srgb, var(--card-solid) 93%, var(--primary) 5%) !important;
+  border: 1px solid color-mix(in srgb, var(--primary) 12%, var(--border)) !important;
+  box-shadow: 0 6px 22px color-mix(in srgb, var(--primary) 9%, rgba(15, 23, 42, 0.07)) !important;
+}
+
+html.dark .hud-app .shell-layout-root .media-card,
+html.dark .hud-app .shell-layout-root .model-card,
+html.dark .hud-app .shell-layout-root .user-card,
+html.dark .hud-app .shell-layout-root .detection-card,
+html.dark .hud-app .shell-layout-root .records-card,
+html.dark .hud-app .shell-layout-root .logs-card {
+  background: color-mix(in srgb, var(--card-solid) 90%, var(--primary) 8%) !important;
+  border-color: var(--border) !important;
+  box-shadow: 0 10px 32px rgba(0, 0, 0, 0.38) !important;
+}
+
+/* 浅色:筛选条与深色模式现有规则互补 */
+html:not(.dark) .hud-app .search-form {
+  background: color-mix(in srgb, var(--card-solid) 93%, var(--primary) 6%) !important;
+  border: 1px solid color-mix(in srgb, var(--primary) 20%, var(--border)) !important;
+  border-radius: var(--radius-lg) !important;
+}
+
+html:not(.dark) .hud-app .model-info-card,
+html:not(.dark) .hud-app .detection-summary,
+html:not(.dark) .hud-app .disease-detection-container .preview-container {
+  background-color: color-mix(in srgb, var(--card-solid) 95%, var(--primary) 5%) !important;
+  border: 1px solid var(--border) !important;
+  color: var(--foreground);
+}
+
+html:not(.dark) .hud-app .model-info-card .info-value,
+html:not(.dark) .hud-app .detection-summary .summary-value {
+  color: var(--foreground);
+}
+
+html:not(.dark) .hud-app .media-preview-container {
+  background-color: color-mix(in srgb, var(--card-solid) 96%, var(--primary) 3%) !important;
+  border-color: color-mix(in srgb, var(--primary) 22%, var(--border)) !important;
+}
+
+/* 个人中心:与 HUD 主色统一(覆盖 scoped 灰蓝渐变与白盒) */
+.hud-app .user-center-container {
+  background: linear-gradient(
+    135deg,
+    color-mix(in srgb, var(--background) 94%, var(--primary) 6%),
+    color-mix(in srgb, var(--card-solid) 90%, var(--primary) 10%)
+  ) !important;
+}
+
+html.dark .hud-app .user-center-container {
+  background: linear-gradient(
+    165deg,
+    var(--background) 0%,
+    color-mix(in srgb, var(--card-solid) 82%, var(--primary) 18%) 100%
+  ) !important;
+}
+
+html:not(.dark) .hud-app .user-center-container .profile-header,
+html:not(.dark) .hud-app .user-center-container .profile-info,
+html:not(.dark) .hud-app .user-center-container .user-actions,
+html:not(.dark) .hud-app .user-center-container .loading-container {
+  background: color-mix(in srgb, var(--card-solid) 96%, var(--primary) 4%) !important;
+  border: 1px solid var(--border) !important;
+  color: var(--foreground);
+}
+
+html.dark .hud-app .user-center-container .profile-header,
+html.dark .hud-app .user-center-container .profile-info,
+html.dark .hud-app .user-center-container .user-actions,
+html.dark .hud-app .user-center-container .loading-container {
+  background: color-mix(in srgb, var(--card-solid) 92%, transparent) !important;
+  border: 1px solid var(--el-border-color) !important;
+}
+
+.hud-app .user-center-container .back-btn {
+  background: linear-gradient(
+    90deg,
+    var(--primary),
+    color-mix(in srgb, var(--primary) 72%, var(--accent) 28%)
+  ) !important;
+  border: none !important;
+  color: #f8fafc !important;
+  box-shadow: 0 6px 18px color-mix(in srgb, var(--primary) 35%, transparent) !important;
+}
+
+.hud-app .user-center-container .back-btn:hover {
+  box-shadow: 0 8px 22px color-mix(in srgb, var(--primary) 45%, transparent) !important;
+}

+ 72 - 0
bridge-disease-frontend-main/src/styles/iot-page.css

@@ -0,0 +1,72 @@
+.iot-page .main-container {
+  display: flex;
+  flex: 1;
+  min-height: 0;
+}
+
+.iot-page .sidebar {
+  flex-shrink: 0;
+}
+
+.iot-page .content-area {
+  flex: 1;
+  padding: 16px 20px 24px;
+  overflow: auto;
+  min-width: 0;
+}
+
+.iot-page .stat-row {
+  margin-bottom: 12px;
+}
+
+.iot-page .stat-card {
+  text-align: center;
+  background: var(--card);
+  border: 1px solid var(--border);
+}
+
+.iot-page .stat-num {
+  font-size: 28px;
+  font-weight: 700;
+  color: var(--primary);
+}
+
+.iot-page .stat-num.stat-success {
+  color: #16a34a;
+}
+
+.iot-page .stat-num.stat-warn {
+  color: #ea580c;
+}
+
+.iot-page .stat-label {
+  font-size: 13px;
+  color: var(--muted);
+  margin-top: 4px;
+}
+
+.iot-page .iot-card {
+  margin-top: 12px;
+  background: var(--card);
+  border: 1px solid var(--border);
+}
+
+.iot-page .card-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.iot-page .card-header h2 {
+  margin: 0;
+  font-size: 16px;
+}
+
+.iot-page .search-form {
+  margin-bottom: 12px;
+}
+
+.iot-page .date-separator {
+  margin: 0 8px;
+  color: var(--muted);
+}

+ 37 - 0
bridge-disease-frontend-main/src/styles/shell-theme.css

@@ -0,0 +1,37 @@
+/* 与 hud-layout 令牌对齐的组件糖衣 */
+.shell-card-elevated {
+  background: var(--card-solid);
+  border-radius: var(--radius-lg);
+  border: 1px solid var(--border);
+  box-shadow:
+    inset 0 1px 0 rgba(255, 255, 255, 0.45),
+    0 10px 36px rgba(15, 23, 42, 0.06);
+  transition: transform 0.22s ease, box-shadow 0.22s ease;
+}
+
+.shell-card-elevated:hover {
+  transform: translateY(-1px);
+  box-shadow:
+    inset 0 1px 0 rgba(255, 255, 255, 0.55),
+    0 16px 44px color-mix(in srgb, var(--primary) 12%, transparent);
+}
+
+.shell-kpi-num {
+  font-family: var(--font-mono);
+  font-weight: 600;
+  letter-spacing: -0.02em;
+}
+
+html.dark .shell-card-elevated {
+  background: var(--card-solid);
+  border-color: var(--border);
+  box-shadow:
+    inset 0 1px 0 rgba(255, 255, 255, 0.05),
+    0 10px 36px rgba(0, 0, 0, 0.35);
+}
+
+html.dark .shell-card-elevated:hover {
+  box-shadow:
+    inset 0 1px 0 rgba(255, 255, 255, 0.07),
+    0 16px 44px color-mix(in srgb, var(--primary) 22%, transparent);
+}

+ 37 - 0
bridge-disease-frontend-main/src/utils/avatarUtils.js

@@ -0,0 +1,37 @@
+/**
+ * 头像上传工具函数
+ * 提供头像上传相关的通用功能
+ */
+import { ElMessage } from 'element-plus'
+
+/**
+ * 处理头像上传
+ * @param {File} file - 上传的文件对象
+ * @param {Function} updateCallback - 更新头像文件的回调函数
+ * @returns {boolean} - 返回 false 阻止自动上传
+ */
+export const handleAvatarUpload = (file, updateCallback) => {
+  // 检查文件类型
+  const allowedTypes = ['image/png', 'image/jpg', 'image/jpeg']
+  if (!allowedTypes.includes(file.raw.type)) {
+    ElMessage.warning({
+      message: '请上传 JPG/PNG/JEPG 格式的图片',
+      duration: 4000
+    })
+    return false
+  }
+
+  // 检查文件大小(限制为 5MB)
+  const isLt5M = file.raw.size / 1024 / 1024 < 5
+  if (!isLt5M) {
+    ElMessage.warning({
+      message: '图片大小不能超过 5MB',
+      duration: 4000
+    })
+    return false
+  }
+
+  // 验证通过,调用回调函数更新头像文件
+  updateCallback(file.raw)
+  return false // 阻止自动上传
+}

+ 119 - 0
bridge-disease-frontend-main/src/utils/dateTimeFormatter.js

@@ -0,0 +1,119 @@
+/**
+ * 日期时间格式化工具函数
+ * 提供通用的日期时间格式化功能,支持不同格式和时区处理
+ */
+
+/**
+ * 格式化日期时间为中文时区的字符串
+ * @param {string} dateTimeStr - 日期时间字符串
+ * @param {string} defaultValue - 当日期时间为空时返回的默认值,默认为'暂无数据'
+ * @returns {string} - 格式化后的日期时间字符串
+ */
+export const formatDateTime = (dateTimeStr, defaultValue = '暂无数据') => {
+  if (!dateTimeStr) return defaultValue
+  
+  // 创建一个 UTC 日期对象
+  const date = new Date(dateTimeStr);
+  // 手动调整时区偏移,中国是 UTC+8,所以需要减去 8 小时
+  const adjustedDate = new Date(date.getTime() - 8 * 60 * 60 * 1000)
+  return adjustedDate.toLocaleString('zh-CN') // 格式化为中文时区的日期字符串
+}
+
+/**
+ * 格式化日期时间为指定格式的字符串
+ * @param {string} dateTimeStr - 日期时间字符串
+ * @param {object} options - 格式化选项
+ * @param {string} options.locale - 区域设置,默认为'zh-CN'
+ * @param {object} options.formatOptions - 格式化选项,参考 Intl.DateTimeFormat 的选项
+ * @param {string} defaultValue - 当日期时间为空时返回的默认值,默认为'暂无数据'
+ * @returns {string} - 格式化后的日期时间字符串
+ */
+export const formatDateTimeWithOptions = (dateTimeStr, options = {}, defaultValue = '暂无数据') => {
+  if (!dateTimeStr) return defaultValue
+  
+  const { locale = 'zh-CN', formatOptions = {} } = options
+  
+  // 创建一个 UTC 日期对象
+  const date = new Date(dateTimeStr)
+  // 手动调整时区偏移,中国是 UTC+8,所以需要减去 8 小时
+  const adjustedDate = new Date(date.getTime() - 8 * 60 * 60 * 1000)
+  
+  // 使用 Intl.DateTimeFormat 进行格式化
+  return new Intl.DateTimeFormat(locale, formatOptions).format(adjustedDate)
+}
+
+/**
+ * 格式化日期时间为年月日格式
+ * @param {string} dateTimeStr - 日期时间字符串
+ * @param {string} defaultValue - 当日期时间为空时返回的默认值,默认为'暂无数据'
+ * @returns {string} - 格式化后的日期字符串,如:2023 年 1 月 1 日
+ */
+export const formatDate = (dateTimeStr, defaultValue = '暂无数据') => {
+  return formatDateTimeWithOptions(dateTimeStr, {
+    formatOptions: {
+      year: 'numeric',
+      month: 'long',
+      day: 'numeric'
+    }
+  }, defaultValue)
+}
+
+/**
+ * 格式化日期时间为时分秒格式
+ * @param {string} dateTimeStr - 日期时间字符串
+ * @param {string} defaultValue - 当日期时间为空时返回的默认值,默认为'暂无数据'
+ * @returns {string} - 格式化后的时间字符串,如:14:30:45
+ */
+export const formatTime = (dateTimeStr, defaultValue = '暂无数据') => {
+  return formatDateTimeWithOptions(dateTimeStr, {
+    formatOptions: {
+      hour: '2-digit',
+      minute: '2-digit',
+      second: '2-digit',
+      hour12: false
+    }
+  }, defaultValue)
+};
+
+/**
+ * 格式化为相对时间(如:3 小时前,2 天前)
+ * @param {string} dateTimeStr - 日期时间字符串
+ * @param {string} defaultValue - 当日期时间为空时返回的默认值,默认为'暂无数据'
+ * @returns {string} - 格式化后的相对时间字符串
+ */
+export const formatRelativeTime = (dateTimeStr, defaultValue = '暂无数据') => {
+  if (!dateTimeStr) return defaultValue
+  
+  const date = new Date(dateTimeStr)
+  const now = new Date()
+  const diffInSeconds = Math.floor((now - date) / 1000)
+  
+  // 定义时间单位和对应的秒数
+  const timeUnits = [
+    { unit: '年', seconds: 60 * 60 * 24 * 365 },
+    { unit: '个月', seconds: 60 * 60 * 24 * 30 },
+    { unit: '天', seconds: 60 * 60 * 24 },
+    { unit: '小时', seconds: 60 * 60 },
+    { unit: '分钟', seconds: 60 },
+    { unit: '秒', seconds: 1 }
+  ]
+  
+  // 查找最合适的时间单位
+  for (const { unit, seconds } of timeUnits) {
+    const value = Math.floor(diffInSeconds / seconds);
+    if (value >= 1) {
+      return `${value}${unit}前`;
+    }
+  }
+  
+  return '刚刚'
+};
+
+// 默认导出所有格式化函数
+export default {
+  formatDateTime,
+  formatDateTimeWithOptions,
+  formatDate,
+  formatTime,
+  formatRelativeTime
+}

+ 133 - 0
bridge-disease-frontend-main/src/utils/detailFetcher.js

@@ -0,0 +1,133 @@
+import request from './request'
+import { ElMessage } from 'element-plus'
+
+/**
+ * 通用错误处理函数
+ * @param {Error} error - 错误对象
+ * @param {string} entityType - 实体类型名称
+ * @returns {Object} 包含错误信息的对象
+ */
+const handleError = (error, entityType, entityId) => {
+    console.error(`【获取${entityType} ID=${entityId} 详情错误】`, error)
+    const errorMessage = `【获取${entityType} ID=${entityId} 详情错误】${error?.message || '请重试'}`
+    ElMessage.error({
+        message: errorMessage,
+        duration: 5000
+    })
+    return { error: errorMessage }
+}
+
+/**
+ * 根据用户 ID 获取用户详情
+ * @param {number} userId - 用户 ID
+ * @returns {Promise<Object>} 用户详情对象
+ */
+export const getUserDetail = async (userId) => {
+    if (!userId) {
+        return { error: '【获取用户详情失败】用户 ID 为空' }
+    }
+
+    try {
+        const response = await request.get(`/user/detail/${userId}`)
+        console.info(`【获取用户 ID=${userId} 详情响应数据】`, response)
+
+        if (response && !response.failure_message) {
+            return { user: response.user }
+        }
+        return { error: `【获取用户 ID=${userId} 详情错误】${response?.failure_message || '请重试'}` }
+    } catch (error) {
+        return handleError(error, '用户', userId)
+    }
+}
+
+/**
+ * 根据模型 ID 获取模型详情
+ * @param {number} modelId - 模型 ID
+ * @returns {Promise<Object>} 模型详情对象
+ */
+export const getModelDetail = async (modelId) => {
+    if (!modelId) {
+        return { error: '【获取模型详情失败】模型 ID 为空' }
+    }
+
+    try {
+        const response = await request.get(`/model/detail/${modelId}`)
+        console.info(`【获取模型 ID=${modelId} 详情响应数据】`, response)
+
+        if (response && !response.failure_message) {
+            return { model: response.model }
+        }
+        return { error: `【获取模型 ID=${modelId} 详情错误】${response?.failure_message || '请重试'}` }
+    } catch (error) {
+        return handleError(error, '模型', modelId)
+    }
+}
+
+/**
+ * 根据媒体 ID 获取媒体详情
+ * @param {number} mediaId - 媒体 ID
+ * @returns {Promise<Object>} 媒体详情对象
+ */
+export const getMediaDetail = async (mediaId) => {
+    if (!mediaId) {
+        return { error: '【获取媒体详情失败】媒体 ID 为空' }
+    }
+
+    try {
+        const response = await request.get(`/media/detail/${mediaId}`)
+        console.info(`【获取媒体 ID=${mediaId} 详情响应数据】`, response)
+
+        if (response && !response.failure_message) {
+            return { media: response.media }
+        }
+        return { error: `【获取媒体 ID=${mediaId} 详情错误】${response?.failure_message || '请重试'}` }
+    } catch (error) {
+        return handleError(error, '媒体', mediaId)
+    }
+}
+
+/**
+ * 根据检测分割 ID 获取检测分割详情
+ * @param {number} detectionId - 检测分割 ID
+ * @returns {Promise<Object>} 检测分割详情对象
+ */
+export const getDetectionDetail = async (detectionId) => {
+    if (!detectionId) {
+        return { error: '【获取安全隐患检测详情失败】检测记录 ID 为空' }
+    }
+
+    try {
+        const response = await request.get(`/detection/detail/${detectionId}`)
+        console.info(`【获取检测分割 ID=${detectionId} 详情响应数据】`, response)
+
+        if (response && !response.failure_message) {
+            return { detection: response.detection }
+        }
+        return { error: `【获取安全隐患检测记录 ID=${detectionId} 详情错误】${response?.failure_message || '请重试'}`  }
+    } catch (error) {
+        return handleError(error, '检测记录', detectionId)
+    }
+}
+
+/**
+ * 根据操作 ID 获取操作详情
+ * @param {number} operationId - 操作 ID
+ * @returns {Promise<Object>} 操作日志详情对象
+ */
+export const getOperationLogDetail = async (operationId) => {
+    if (!operationId) {
+        return { error: '【获取操作详情失败】操作 ID 为空' }
+    }
+
+    try {
+        const response = await request.get(`/operation/detail/${operationId}`)
+        console.info(`【获取操作 ID=${operationId} 详情响应数据】`, response)
+
+        if (response && !response.failure_message) {
+            return { operation: response.operation }
+        }
+        return { error: `【获取操作 ID=${operationId} 详情错误】${response?.failure_message || '请重试'}` }
+    } catch (error) {
+        return handleError(error, '操作日志', operationId)
+    }
+}

+ 125 - 0
bridge-disease-frontend-main/src/utils/request.js

@@ -0,0 +1,125 @@
+import axios from 'axios'
+import { ElMessage } from 'element-plus'
+import router from '../router'
+
+const apiBaseURL =
+  import.meta.env.VITE_API_BASE_URL || 'http://127.0.0.1:5000'
+
+const request = axios.create({
+  baseURL: apiBaseURL,
+  timeout: 600000,
+  headers: {
+    'ngrok-skip-browser-warning': 'true',
+  },
+})
+
+// 是否正在刷新 token
+let isRefreshing = false
+// 请求队列
+let requestQueue = []
+
+// 请求拦截器
+request.interceptors.request.use(
+  config => {
+    // 从 localStorage 获取 token
+    const token = localStorage.getItem('access_token')
+    if (token) {
+      config.headers.Authorization = `Bearer ${token}`
+    }
+    return config
+  },
+  error => {
+    return Promise.reject(error)
+  }
+)
+
+// 响应拦截器
+request.interceptors.response.use(
+  response => {
+    return response.data
+  },
+  async error => {
+    // 如果响应中包含数据,则返回数据
+    if (error.response?.data) {
+      // 处理 token 过期情况 (401 错误)
+      if (error.response.status === 401) {
+        const originalRequest = error.config
+        const refreshToken = localStorage.getItem('refresh_token')
+
+        // 如果没有 refresh_token,直接跳转到登录页
+        if (!refreshToken) {
+          ElMessage.error({
+            message: '登录已过期,请重新登录',
+            duration: 5000
+          })
+          localStorage.clear()
+          router.push('/login')
+          return Promise.reject(error)
+        }
+
+        // 如果已经在刷新 token,将请求加入队列
+        if (isRefreshing) {
+          return new Promise(resolve => {
+            requestQueue.push(() => {
+              originalRequest.headers.Authorization = `Bearer ${localStorage.getItem('access_token')}`
+              resolve(request(originalRequest))
+            })
+          })
+        }
+
+        isRefreshing = true
+
+        try {
+          // 创建一个新的 axios 实例来刷新 token,避免进入拦截器循环
+          const refreshResponse = await axios.post(`${apiBaseURL}/user/refresh`, {}, {
+            headers: {
+              'Authorization': `Bearer ${refreshToken}`
+            }
+          })
+
+          // 更新 token
+          const newToken = refreshResponse.data.access_token
+          localStorage.setItem('access_token', newToken)
+
+          // 重新发送队列中的请求
+          requestQueue.forEach(callback => callback())
+          requestQueue = []
+
+          // 重新发送原始请求
+          originalRequest.headers.Authorization = `Bearer ${newToken}`
+          return request(originalRequest)
+        } catch (refreshError) {
+          // 刷新 token 失败,说明 refresh_token 过期,清除 token 并跳转到登录页
+          console.error('刷新 token 失败', refreshError)
+          ElMessage.error('登录已过期,请重新登录')
+          ElMessage.error({
+            message: '登录已过期,请重新登录',
+            duration: 5000
+          })
+          localStorage.clear()
+          router.push('/login')
+          return Promise.reject(refreshError)
+        } finally {
+          isRefreshing = false
+        }
+      }
+
+      // 显示失败信息
+      ElMessage.warning({
+        message: error.response.data.operation?.failure_message || error.response.data.failure_message || '请求失败',
+        duration: 4000
+      })
+      // 返回响应数据,让业务代码可以继续处理
+      return error.response.data
+    }
+
+    // 如果没有响应数据,则拒绝 Promise
+    ElMessage.error({
+      message: '网络错误,请稍后重试',
+      duration: 5000
+    })
+    return Promise.reject(error)
+  }
+)
+
+export default request

+ 244 - 0
bridge-disease-frontend-main/src/views/AlertManagementView.vue

@@ -0,0 +1,244 @@
+<script setup>
+import { ref, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+import { ElMessage } from 'element-plus'
+import { Refresh, Check, CircleCheck } from '@element-plus/icons-vue'
+import { useUserStore } from '../stores/userStore'
+import { useIotMonitoringStore } from '../stores/iotMonitoringStore'
+import { formatDateTime } from '../utils/dateTimeFormatter'
+import SidebarMenu from '../components/SidebarMenu.vue'
+import BreadcrumbNav from '../components/BreadcrumbNav.vue'
+import HudPageHero from '../components/shell/HudPageHero.vue'
+
+const router = useRouter()
+const { userInfo, getUserInfo } = useUserStore()
+const iot = useIotMonitoringStore()
+
+const tableData = ref([])
+const detailVisible = ref(false)
+const currentAlert = ref(null)
+const searchForm = ref({ keyword: '', status: '', level: '', sensor_id: null })
+
+const levelLabel = { info: '提示', warning: '预警', critical: '严重' }
+const levelType = { info: 'info', warning: 'warning', critical: 'danger' }
+const statusLabel = { open: '待处理', acknowledged: '已确认', resolved: '已关闭' }
+const statusType = { open: 'danger', acknowledged: 'warning', resolved: 'success' }
+
+const refreshList = () => {
+  tableData.value = iot.listAlerts(searchForm.value)
+}
+
+const resetSearch = () => {
+  searchForm.value = { keyword: '', status: '', level: '', sensor_id: null }
+  refreshList()
+}
+
+const showDetail = (row) => {
+  currentAlert.value = row
+  detailVisible.value = true
+}
+
+const handleAck = (row) => {
+  const { ok, message } = iot.acknowledgeAlert(row.alert_id)
+  if (!ok) {
+    ElMessage.error(message)
+    return
+  }
+  ElMessage.success('预警已确认')
+  refreshList()
+  if (detailVisible.value && currentAlert.value?.alert_id === row.alert_id) {
+    currentAlert.value = iot.listAlerts().find((a) => a.alert_id === row.alert_id)
+  }
+}
+
+const handleResolve = (row) => {
+  const { ok, message } = iot.resolveAlert(row.alert_id)
+  if (!ok) {
+    ElMessage.error(message)
+    return
+  }
+  ElMessage.success('预警已关闭')
+  refreshList()
+  detailVisible.value = false
+}
+
+const fmtTime = (v) => (v ? formatDateTime(v) : '—')
+
+onMounted(() => {
+  getUserInfo().then(() => {
+    if (!userInfo.value) router.push('/login')
+    else refreshList()
+  })
+})
+</script>
+
+<template>
+  <div class="iot-page shell-layout-root">
+    <div class="main-container">
+      <div class="sidebar"><SidebarMenu /></div>
+      <div class="content-area">
+        <BreadcrumbNav />
+        <HudPageHero
+          title="预警管理"
+          description="汇聚传感器离线、阈值越限、采集失败等事件;支持确认、关闭与关联设备追溯。"
+        />
+
+        <el-row :gutter="12" class="stat-row">
+          <el-col :span="8">
+            <el-card shadow="never" class="stat-card">
+              <div class="stat-num stat-warn">{{ iot.openAlertCount }}</div>
+              <div class="stat-label">待处理</div>
+            </el-card>
+          </el-col>
+          <el-col :span="8">
+            <el-card shadow="never" class="stat-card">
+              <div class="stat-num">
+                {{ tableData.filter((a) => a.status === 'acknowledged').length }}
+              </div>
+              <div class="stat-label">已确认</div>
+            </el-card>
+          </el-col>
+          <el-col :span="8">
+            <el-card shadow="never" class="stat-card">
+              <div class="stat-num stat-success">
+                {{ tableData.filter((a) => a.status === 'resolved').length }}
+              </div>
+              <div class="stat-label">已关闭</div>
+            </el-card>
+          </el-col>
+        </el-row>
+
+        <el-card class="iot-card">
+          <template #header>
+            <div class="card-header">
+              <h2>预警列表</h2>
+              <el-button @click="refreshList"><el-icon><Refresh /></el-icon> 刷新</el-button>
+            </div>
+          </template>
+
+          <el-form :model="searchForm" inline class="search-form">
+            <el-form-item label="关键词">
+              <el-input v-model="searchForm.keyword" placeholder="标题/内容/传感器" clearable style="width: 200px" />
+            </el-form-item>
+            <el-form-item label="级别">
+              <el-select v-model="searchForm.level" placeholder="全部" clearable style="width: 110px">
+                <el-option v-for="(label, key) in levelLabel" :key="key" :label="label" :value="key" />
+              </el-select>
+            </el-form-item>
+            <el-form-item label="状态">
+              <el-select v-model="searchForm.status" placeholder="全部" clearable style="width: 110px">
+                <el-option v-for="(label, key) in statusLabel" :key="key" :label="label" :value="key" />
+              </el-select>
+            </el-form-item>
+            <el-form-item label="传感器">
+              <el-select v-model="searchForm.sensor_id" placeholder="全部" clearable style="width: 180px">
+                <el-option
+                  v-for="s in iot.sensors.value"
+                  :key="s.sensor_id"
+                  :label="s.name"
+                  :value="s.sensor_id"
+                />
+              </el-select>
+            </el-form-item>
+            <el-form-item>
+              <el-button type="primary" @click="refreshList">搜索</el-button>
+              <el-button @click="resetSearch">重置</el-button>
+            </el-form-item>
+          </el-form>
+
+          <el-table :data="tableData" style="width: 100%" @row-click="showDetail">
+            <el-table-column prop="alert_id" label="ID" width="60" />
+            <el-table-column label="级别" width="80">
+              <template #default="{ row }">
+                <el-tag :type="levelType[row.level]" size="small">{{ levelLabel[row.level] }}</el-tag>
+              </template>
+            </el-table-column>
+            <el-table-column prop="title" label="标题" min-width="140" show-overflow-tooltip />
+            <el-table-column prop="sensor_name" label="关联传感器" width="130" show-overflow-tooltip />
+            <el-table-column prop="message" label="详情" min-width="200" show-overflow-tooltip />
+            <el-table-column label="状态" width="90">
+              <template #default="{ row }">
+                <el-tag :type="statusType[row.status]" size="small">{{ statusLabel[row.status] }}</el-tag>
+              </template>
+            </el-table-column>
+            <el-table-column label="触发值" width="90">
+              <template #default="{ row }">{{ row.trigger_value ?? '—' }}</template>
+            </el-table-column>
+            <el-table-column label="创建时间" width="165">
+              <template #default="{ row }">{{ fmtTime(row.created_at) }}</template>
+            </el-table-column>
+            <el-table-column label="操作" width="200" fixed="right" @click.stop>
+              <template #default="{ row }">
+                <el-button link type="primary" @click.stop="showDetail(row)">详情</el-button>
+                <el-button
+                  v-if="row.status === 'open'"
+                  link
+                  type="warning"
+                  @click.stop="handleAck(row)"
+                >
+                  <el-icon><Check /></el-icon> 确认
+                </el-button>
+                <el-button
+                  v-if="row.status !== 'resolved'"
+                  link
+                  type="success"
+                  @click.stop="handleResolve(row)"
+                >
+                  <el-icon><CircleCheck /></el-icon> 关闭
+                </el-button>
+              </template>
+            </el-table-column>
+          </el-table>
+        </el-card>
+      </div>
+    </div>
+
+    <el-dialog v-model="detailVisible" title="预警详情" width="520px">
+      <template v-if="currentAlert">
+        <el-descriptions :column="1" border>
+          <el-descriptions-item label="级别">
+            <el-tag :type="levelType[currentAlert.level]">{{ levelLabel[currentAlert.level] }}</el-tag>
+          </el-descriptions-item>
+          <el-descriptions-item label="标题">{{ currentAlert.title }}</el-descriptions-item>
+          <el-descriptions-item label="传感器">
+            {{ currentAlert.sensor_code }} · {{ currentAlert.sensor_name }}
+          </el-descriptions-item>
+          <el-descriptions-item label="说明">{{ currentAlert.message }}</el-descriptions-item>
+          <el-descriptions-item label="触发值 / 阈值">
+            {{ currentAlert.trigger_value ?? '—' }} / {{ currentAlert.threshold_value ?? '—' }}
+          </el-descriptions-item>
+          <el-descriptions-item label="状态">
+            <el-tag :type="statusType[currentAlert.status]">{{ statusLabel[currentAlert.status] }}</el-tag>
+          </el-descriptions-item>
+          <el-descriptions-item label="创建">{{ fmtTime(currentAlert.created_at) }}</el-descriptions-item>
+          <el-descriptions-item label="确认">{{ fmtTime(currentAlert.acknowledged_at) }}</el-descriptions-item>
+          <el-descriptions-item label="关闭">{{ fmtTime(currentAlert.resolved_at) }}</el-descriptions-item>
+        </el-descriptions>
+      </template>
+      <template #footer>
+        <el-button @click="detailVisible = false">关闭</el-button>
+        <el-button
+          v-if="currentAlert?.status === 'open'"
+          type="warning"
+          @click="handleAck(currentAlert)"
+        >
+          确认
+        </el-button>
+        <el-button
+          v-if="currentAlert?.status !== 'resolved'"
+          type="success"
+          @click="handleResolve(currentAlert)"
+        >
+          关闭预警
+        </el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<style scoped>
+@import '../styles/iot-page.css';
+.iot-page :deep(.el-table__row) {
+  cursor: pointer;
+}
+</style>

+ 306 - 0
bridge-disease-frontend-main/src/views/BatchDetectionView.vue

@@ -0,0 +1,306 @@
+<script setup>
+import { ref, onMounted, computed } from 'vue'
+import { useRouter } from 'vue-router'
+import { ElMessage } from 'element-plus'
+import { Plus, VideoPlay, Promotion, CircleClose, Refresh } from '@element-plus/icons-vue'
+import { useUserStore } from '../stores/userStore'
+import { useResourceStore } from '../stores/resourceStore'
+import { useProfessionalModulesStore } from '../stores/professionalModulesStore'
+import SidebarMenu from '../components/SidebarMenu.vue'
+import BreadcrumbNav from '../components/BreadcrumbNav.vue'
+import HudPageHero from '../components/shell/HudPageHero.vue'
+import { formatDateTime } from '../utils/dateTimeFormatter'
+
+const router = useRouter()
+const { userInfo, getUserInfo } = useUserStore()
+const resourceStore = useResourceStore()
+const pro = useProfessionalModulesStore()
+
+const mediaList = ref([])
+const modelOptions = ref([])
+const selectedModelId = ref(null)
+const selectedMedias = ref([])
+const batchName = ref('')
+const jobs = ref([])
+const detailVisible = ref(false)
+const currentJob = ref(null)
+let advanceTimer = null
+
+const selectedModelName = computed(() => {
+  const m = modelOptions.value.find((x) => x.model_id === selectedModelId.value)
+  return m?.model_name || ''
+})
+
+const statusType = {
+  pending: 'info',
+  running: 'warning',
+  completed: 'success',
+  failed: 'danger',
+  cancelled: 'info',
+}
+
+const loadMedia = async () => {
+  const { medias, error } = await resourceStore.fetchMediaList(userInfo.value, 1, 50, true)
+  if (error) throw error
+  mediaList.value = medias
+}
+
+const loadModels = async () => {
+  const { models, error } = await resourceStore.fetchModelList(1, 50, true)
+  if (error) throw error
+  modelOptions.value = models
+  if (models.length && !selectedModelId.value) {
+    selectedModelId.value = models[0].model_id
+  }
+}
+
+const refreshJobs = () => {
+  jobs.value = pro.listBatchJobs()
+}
+
+const handleSelectionChange = (rows) => {
+  selectedMedias.value = rows
+}
+
+const createBatch = () => {
+  if (!selectedModelId.value) {
+    ElMessage.warning('请选择模型')
+    return
+  }
+  if (!selectedMedias.value.length) {
+    ElMessage.warning('请勾选至少一条媒体')
+    return
+  }
+  const { ok, message, job } = pro.createBatchJob({
+    batch_name: batchName.value,
+    model_id: selectedModelId.value,
+    model_name: selectedModelName.value,
+    mediaItems: selectedMedias.value.map((m) => ({
+      media_id: m.media_id,
+      media_name: m.media_name,
+    })),
+  })
+  if (!ok) {
+    ElMessage.error(message)
+    return
+  }
+  ElMessage.success('批量任务已创建')
+  batchName.value = ''
+  refreshJobs()
+  currentJob.value = job
+  detailVisible.value = true
+}
+
+const startJob = (row) => {
+  const { ok, message } = pro.startBatchJob(row.batch_id)
+  if (!ok) {
+    ElMessage.error(message)
+    return
+  }
+  ElMessage.success('已开始执行')
+  refreshJobs()
+  autoAdvance(row.batch_id)
+}
+
+const autoAdvance = (batchId) => {
+  if (advanceTimer) clearInterval(advanceTimer)
+  advanceTimer = setInterval(() => {
+    const job = pro.listBatchJobs().find((j) => j.batch_id === batchId)
+    if (!job || job.status !== 'running') {
+      clearInterval(advanceTimer)
+      advanceTimer = null
+      refreshJobs()
+      return
+    }
+    const { ok, done } = pro.advanceBatchJob(batchId)
+    refreshJobs()
+    if (currentJob.value?.batch_id === batchId) {
+      currentJob.value = pro.listBatchJobs().find((j) => j.batch_id === batchId)
+    }
+    if (done) {
+      clearInterval(advanceTimer)
+      advanceTimer = null
+      ElMessage.success('批量检测已完成,相关安全隐患已写入安全隐患台账')
+    }
+    if (!ok) clearInterval(advanceTimer)
+  }, 800)
+}
+
+const stepAdvance = (row) => {
+  const { ok, message, done } = pro.advanceBatchJob(row.batch_id)
+  if (!ok) {
+    ElMessage.error(message)
+    return
+  }
+  refreshJobs()
+  if (done) ElMessage.success('本批次已全部处理完成')
+}
+
+const cancelJob = (row) => {
+  const { ok, message } = pro.cancelBatchJob(row.batch_id)
+  if (!ok) {
+    ElMessage.error(message)
+    return
+  }
+  ElMessage.info('任务已取消')
+  refreshJobs()
+}
+
+const showDetail = (row) => {
+  currentJob.value = { ...row }
+  detailVisible.value = true
+}
+
+const fmt = (v) => (v ? formatDateTime(v) : '—')
+
+onMounted(() => {
+  getUserInfo().then(async () => {
+    if (!userInfo.value) {
+      router.push('/login')
+      return
+    }
+    try {
+      await Promise.all([loadMedia(), loadModels()])
+      refreshJobs()
+    } catch (e) {
+      ElMessage.error('加载媒体/模型失败')
+    }
+  })
+})
+</script>
+
+<template>
+  <div class="iot-page shell-layout-root">
+    <div class="main-container">
+      <div class="sidebar"><SidebarMenu /></div>
+      <div class="content-area">
+        <BreadcrumbNav />
+        <HudPageHero
+          title="批量安全隐患检测"
+          description="一次选择模型与多条桥梁影像,队列执行安全隐患检测;完成后可自动写入安全隐患台账。"
+        />
+
+        <el-card class="iot-card">
+          <template #header>
+            <div class="card-header"><h2>新建批量任务</h2></div>
+          </template>
+          <el-form inline>
+            <el-form-item label="任务名称">
+              <el-input v-model="batchName" placeholder="可选" style="width: 220px" clearable />
+            </el-form-item>
+            <el-form-item label="检测模型">
+              <el-select v-model="selectedModelId" placeholder="选择模型" style="width: 240px" filterable>
+                <el-option
+                  v-for="m in modelOptions"
+                  :key="m.model_id"
+                  :label="m.model_name"
+                  :value="m.model_id"
+                />
+              </el-select>
+            </el-form-item>
+            <el-form-item>
+              <el-button type="primary" @click="createBatch">
+                <el-icon><Plus /></el-icon> 创建任务
+              </el-button>
+            </el-form-item>
+          </el-form>
+
+          <el-table
+            :data="mediaList"
+            max-height="280"
+            @selection-change="handleSelectionChange"
+          >
+            <el-table-column type="selection" width="48" />
+            <el-table-column prop="media_id" label="ID" width="70" />
+            <el-table-column prop="media_name" label="媒体名称" show-overflow-tooltip />
+            <el-table-column prop="file_type" label="类型" width="80" />
+            <el-table-column prop="description" label="描述" show-overflow-tooltip />
+          </el-table>
+        </el-card>
+
+        <el-card class="iot-card">
+          <template #header>
+            <div class="card-header">
+              <h2>批量任务列表</h2>
+              <el-button @click="refreshJobs"><el-icon><Refresh /></el-icon> 刷新</el-button>
+            </div>
+          </template>
+          <el-table :data="jobs">
+            <el-table-column prop="batch_id" label="ID" width="60" />
+            <el-table-column prop="batch_name" label="任务名称" min-width="160" show-overflow-tooltip />
+            <el-table-column prop="model_name" label="模型" width="140" show-overflow-tooltip />
+            <el-table-column label="进度" width="160">
+              <template #default="{ row }">
+                <el-progress :percentage="row.progress" :status="row.status === 'completed' ? 'success' : undefined" />
+              </template>
+            </el-table-column>
+            <el-table-column label="成功/失败/总数" width="130">
+              <template #default="{ row }">
+                {{ row.success_count }} / {{ row.fail_count }} / {{ row.total_count }}
+              </template>
+            </el-table-column>
+            <el-table-column label="状态" width="90">
+              <template #default="{ row }">
+                <el-tag :type="statusType[row.status]" size="small">
+                  {{ pro.batchStatusLabel[row.status] }}
+                </el-tag>
+              </template>
+            </el-table-column>
+            <el-table-column label="创建时间" width="165">
+              <template #default="{ row }">{{ fmt(row.created_at) }}</template>
+            </el-table-column>
+            <el-table-column label="操作" width="260" fixed="right">
+              <template #default="{ row }">
+                <el-button link type="primary" @click="showDetail(row)">明细</el-button>
+                <el-button
+                  v-if="row.status === 'pending'"
+                  link
+                  type="success"
+                  @click="startJob(row)"
+                >
+                  <el-icon><VideoPlay /></el-icon> 执行
+                </el-button>
+                <el-button
+                  v-if="row.status === 'running'"
+                  link
+                  type="primary"
+                  @click="stepAdvance(row)"
+                >
+                  <el-icon><Promotion /></el-icon> 推进
+                </el-button>
+                <el-button
+                  v-if="row.status === 'pending' || row.status === 'running'"
+                  link
+                  type="danger"
+                  @click="cancelJob(row)"
+                >
+                  <el-icon><CircleClose /></el-icon> 取消
+                </el-button>
+              </template>
+            </el-table-column>
+          </el-table>
+        </el-card>
+      </div>
+    </div>
+
+    <el-dialog v-model="detailVisible" title="批量任务明细" width="640px">
+      <template v-if="currentJob">
+        <p><strong>模型:</strong>{{ currentJob.model_name }}</p>
+        <el-progress :percentage="currentJob.progress" class="mb-12" />
+        <el-table :data="currentJob.items" size="small">
+          <el-table-column prop="media_name" label="媒体" show-overflow-tooltip />
+          <el-table-column prop="status" label="状态" width="100" />
+          <el-table-column prop="detection_id" label="检测ID" width="90" />
+          <el-table-column prop="error" label="备注" show-overflow-tooltip />
+        </el-table>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<style scoped>
+@import '../styles/iot-page.css';
+.mb-12 {
+  margin: 12px 0;
+}
+</style>

+ 264 - 0
bridge-disease-frontend-main/src/views/DataCollectionView.vue

@@ -0,0 +1,264 @@
+<script setup>
+import { ref, onMounted, computed } from 'vue'
+import { useRouter } from 'vue-router'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { Plus, VideoPlay, VideoPause, Download, Refresh } from '@element-plus/icons-vue'
+import { useUserStore } from '../stores/userStore'
+import { useIotMonitoringStore } from '../stores/iotMonitoringStore'
+import { formatDateTime } from '../utils/dateTimeFormatter'
+import SidebarMenu from '../components/SidebarMenu.vue'
+import BreadcrumbNav from '../components/BreadcrumbNav.vue'
+import HudPageHero from '../components/shell/HudPageHero.vue'
+
+const router = useRouter()
+const { userInfo, getUserInfo } = useUserStore()
+const iot = useIotMonitoringStore()
+
+const tableData = ref([])
+const searchForm = ref({ keyword: '', status: '', sensor_id: null })
+const dialogVisible = ref(false)
+const isEdit = ref(false)
+const formRef = ref(null)
+
+const defaultForm = () => ({
+  task_id: null,
+  task_name: '',
+  sensor_id: null,
+  interval_sec: 60,
+  batch_size: 100,
+})
+
+const form = ref(defaultForm())
+
+const formRules = {
+  task_name: [{ required: true, message: '请输入任务名称', trigger: 'blur' }],
+  sensor_id: [{ required: true, message: '请选择传感器', trigger: 'change' }],
+}
+
+const statusLabel = { idle: '空闲', running: '采集中', paused: '已暂停', error: '异常' }
+const statusType = { idle: 'info', running: 'success', paused: 'warning', error: 'danger' }
+
+const onlineSensors = computed(() =>
+  iot.sensors.value.filter((s) => s.status === iot.SENSOR_STATUS.ONLINE),
+)
+
+const refreshList = () => {
+  tableData.value = iot.listCollectionTasks(searchForm.value)
+}
+
+const resetSearch = () => {
+  searchForm.value = { keyword: '', status: '', sensor_id: null }
+  refreshList()
+}
+
+const openCreate = () => {
+  isEdit.value = false
+  form.value = defaultForm()
+  dialogVisible.value = true
+}
+
+const openEdit = (row) => {
+  isEdit.value = true
+  form.value = {
+    task_id: row.task_id,
+    task_name: row.task_name,
+    sensor_id: row.sensor_id,
+    interval_sec: row.interval_sec,
+    batch_size: row.batch_size,
+  }
+  dialogVisible.value = true
+}
+
+const submitForm = async () => {
+  await formRef.value?.validate()
+  const { ok, message } = iot.upsertCollectionTask(form.value)
+  if (!ok) {
+    ElMessage.error(message)
+    return
+  }
+  ElMessage.success(isEdit.value ? '任务已更新' : '任务已创建')
+  dialogVisible.value = false
+  refreshList()
+}
+
+const handleDelete = async (row) => {
+  await ElMessageBox.confirm(`确定删除采集任务「${row.task_name}」?`, '删除确认', { type: 'warning' })
+  const { ok, message } = iot.deleteCollectionTask(row.task_id)
+  if (!ok) {
+    ElMessage.error(message)
+    return
+  }
+  ElMessage.success('已删除')
+  refreshList()
+}
+
+const handleStart = (row) => {
+  const { ok, message } = iot.startCollection(row.task_id)
+  if (!ok) {
+    ElMessage.error(message)
+    return
+  }
+  ElMessage.success('采集已启动')
+  refreshList()
+}
+
+const handlePause = (row) => {
+  const { ok, message } = iot.pauseCollection(row.task_id)
+  if (!ok) {
+    ElMessage.error(message)
+    return
+  }
+  ElMessage.success('采集已暂停')
+  refreshList()
+}
+
+const handleRunOnce = (row) => {
+  const { ok, message, added } = iot.runCollectionOnce(row.task_id)
+  if (!ok) {
+    ElMessage.error(message)
+    refreshList()
+    return
+  }
+  ElMessage.success(`本次采集成功,新增 ${added} 条样本`)
+  refreshList()
+}
+
+const fmtTime = (row, col) => (row[col.property] ? formatDateTime(row[col.property]) : '—')
+
+onMounted(() => {
+  getUserInfo().then(() => {
+    if (!userInfo.value) router.push('/login')
+    else refreshList()
+  })
+})
+</script>
+
+<template>
+  <div class="iot-page shell-layout-root">
+    <div class="main-container">
+      <div class="sidebar"><SidebarMenu /></div>
+      <div class="content-area">
+        <BreadcrumbNav />
+        <HudPageHero
+          title="数据采集"
+          description="配置传感器采集任务,启动/暂停连续采集或单次采样;超阈值将自动写入预警管理。"
+        />
+
+        <el-alert
+          type="info"
+          :closable="false"
+          show-icon
+          class="iot-tip"
+          title="交互说明:仅「在线」传感器可启动采集;「立即采集」会累加样本数,并可能触发预警;处理作业需引用已有样本的采集任务。"
+        />
+
+        <el-card class="iot-card">
+          <template #header>
+            <div class="card-header">
+              <h2>采集任务 · 运行中 {{ iot.runningTaskCount }}</h2>
+              <div>
+                <el-button type="primary" @click="openCreate"><el-icon><Plus /></el-icon> 新建任务</el-button>
+                <el-button @click="refreshList"><el-icon><Refresh /></el-icon> 刷新</el-button>
+              </div>
+            </div>
+          </template>
+
+          <el-form :model="searchForm" inline class="search-form">
+            <el-form-item label="关键词">
+              <el-input v-model="searchForm.keyword" placeholder="任务名/传感器" clearable style="width: 200px" />
+            </el-form-item>
+            <el-form-item label="状态">
+              <el-select v-model="searchForm.status" placeholder="全部" clearable style="width: 120px">
+                <el-option v-for="(label, key) in statusLabel" :key="key" :label="label" :value="key" />
+              </el-select>
+            </el-form-item>
+            <el-form-item label="传感器">
+              <el-select v-model="searchForm.sensor_id" placeholder="全部" clearable style="width: 180px">
+                <el-option
+                  v-for="s in iot.sensors.value"
+                  :key="s.sensor_id"
+                  :label="`${s.sensor_code} · ${s.name}`"
+                  :value="s.sensor_id"
+                />
+              </el-select>
+            </el-form-item>
+            <el-form-item>
+              <el-button type="primary" @click="refreshList">搜索</el-button>
+              <el-button @click="resetSearch">重置</el-button>
+            </el-form-item>
+          </el-form>
+
+          <el-table :data="tableData" style="width: 100%">
+            <el-table-column prop="task_id" label="ID" width="60" />
+            <el-table-column prop="task_name" label="任务名称" min-width="150" show-overflow-tooltip />
+            <el-table-column prop="sensor_name" label="传感器" width="140" show-overflow-tooltip />
+            <el-table-column prop="bridge_section" label="桥区" width="110" show-overflow-tooltip />
+            <el-table-column prop="interval_sec" label="周期(秒)" width="95" />
+            <el-table-column prop="batch_size" label="批次量" width="85" />
+            <el-table-column prop="sample_count" label="累计样本" width="100" sortable />
+            <el-table-column label="状态" width="95">
+              <template #default="{ row }">
+                <el-tag :type="statusType[row.status]" size="small">{{ statusLabel[row.status] }}</el-tag>
+              </template>
+            </el-table-column>
+            <el-table-column prop="last_run_at" label="上次采集" width="165" :formatter="fmtTime" />
+            <el-table-column prop="error_message" label="异常信息" min-width="160" show-overflow-tooltip />
+            <el-table-column label="操作" width="300" fixed="right">
+              <template #default="{ row }">
+                <el-button
+                  v-if="row.status !== 'running'"
+                  link
+                  type="success"
+                  @click="handleStart(row)"
+                >
+                  <el-icon><VideoPlay /></el-icon> 启动
+                </el-button>
+                <el-button v-else link type="warning" @click="handlePause(row)">
+                  <el-icon><VideoPause /></el-icon> 暂停
+                </el-button>
+                <el-button link type="primary" @click="handleRunOnce(row)">
+                  <el-icon><Download /></el-icon> 立即采集
+                </el-button>
+                <el-button link type="primary" @click="openEdit(row)">编辑</el-button>
+                <el-button link type="danger" @click="handleDelete(row)">删除</el-button>
+              </template>
+            </el-table-column>
+          </el-table>
+        </el-card>
+      </div>
+    </div>
+
+    <el-dialog v-model="dialogVisible" :title="isEdit ? '编辑采集任务' : '新建采集任务'" width="480px">
+      <el-form ref="formRef" :model="form" :rules="formRules" label-width="100px">
+        <el-form-item label="任务名称" prop="task_name"><el-input v-model="form.task_name" /></el-form-item>
+        <el-form-item label="传感器" prop="sensor_id">
+          <el-select v-model="form.sensor_id" placeholder="选择传感器" style="width: 100%" filterable>
+            <el-option
+              v-for="s in iot.sensors.value"
+              :key="s.sensor_id"
+              :label="`${s.sensor_code} · ${s.name} (${statusLabel[s.status] || s.status})`"
+              :value="s.sensor_id"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="采集周期(秒)">
+          <el-input-number v-model="form.interval_sec" :min="5" :max="3600" />
+        </el-form-item>
+        <el-form-item label="每批样本数">
+          <el-input-number v-model="form.batch_size" :min="1" :max="10000" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="submitForm">保存</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<style scoped>
+@import '../styles/iot-page.css';
+.iot-tip {
+  margin-bottom: 12px;
+}
+</style>

+ 245 - 0
bridge-disease-frontend-main/src/views/DataProcessingView.vue

@@ -0,0 +1,245 @@
+<script setup>
+import { ref, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { Plus, VideoPlay, Promotion, Refresh } from '@element-plus/icons-vue'
+import { useUserStore } from '../stores/userStore'
+import { useIotMonitoringStore } from '../stores/iotMonitoringStore'
+import { formatDateTime } from '../utils/dateTimeFormatter'
+import SidebarMenu from '../components/SidebarMenu.vue'
+import BreadcrumbNav from '../components/BreadcrumbNav.vue'
+import HudPageHero from '../components/shell/HudPageHero.vue'
+
+const router = useRouter()
+const { userInfo, getUserInfo } = useUserStore()
+const iot = useIotMonitoringStore()
+
+const tableData = ref([])
+const searchForm = ref({ keyword: '', status: '' })
+const dialogVisible = ref(false)
+const formRef = ref(null)
+
+const form = ref({
+  job_name: '',
+  source_task_id: null,
+  pipeline: 'denoise → resample → feature_extract',
+})
+
+const formRules = {
+  job_name: [{ required: true, message: '请输入作业名称', trigger: 'blur' }],
+  source_task_id: [{ required: true, message: '请选择采集任务', trigger: 'change' }],
+}
+
+const statusLabel = {
+  pending: '待处理',
+  running: '处理中',
+  completed: '已完成',
+  failed: '失败',
+}
+const statusType = {
+  pending: 'info',
+  running: 'warning',
+  completed: 'success',
+  failed: 'danger',
+}
+
+const tasksWithSamples = () =>
+  iot.collectionTasks.value.filter((t) => t.sample_count > 0)
+
+const refreshList = () => {
+  tableData.value = iot.listProcessingJobs(searchForm.value)
+}
+
+const resetSearch = () => {
+  searchForm.value = { keyword: '', status: '' }
+  refreshList()
+}
+
+const openCreate = () => {
+  form.value = {
+    job_name: '',
+    source_task_id: null,
+    pipeline: 'denoise → resample → feature_extract',
+  }
+  dialogVisible.value = true
+}
+
+const submitForm = async () => {
+  await formRef.value?.validate()
+  const { ok, message } = iot.createProcessingJob(form.value)
+  if (!ok) {
+    ElMessage.error(message)
+    return
+  }
+  ElMessage.success('处理作业已创建')
+  dialogVisible.value = false
+  refreshList()
+}
+
+const handleDelete = async (row) => {
+  await ElMessageBox.confirm(`确定删除作业「${row.job_name}」?`, '删除确认', { type: 'warning' })
+  const { ok, message } = iot.deleteProcessingJob(row.job_id)
+  if (!ok) {
+    ElMessage.error(message)
+    return
+  }
+  ElMessage.success('已删除')
+  refreshList()
+}
+
+const handleStart = (row) => {
+  const { ok, message } = iot.startProcessing(row.job_id)
+  if (!ok) {
+    ElMessage.error(message)
+    return
+  }
+  ElMessage.success('处理已开始')
+  refreshList()
+}
+
+const handleAdvance = (row) => {
+  const { ok, message, job } = iot.advanceProcessing(row.job_id)
+  if (!ok) {
+    ElMessage.error(message)
+    return
+  }
+  if (job.status === 'completed') {
+    ElMessage.success('处理已完成,可前往检测或导出结果')
+  } else {
+    ElMessage.info(`处理进度 ${job.progress}%`)
+  }
+  refreshList()
+}
+
+const fmtTime = (row, col) => (row[col.property] ? formatDateTime(row[col.property]) : '—')
+
+onMounted(() => {
+  getUserInfo().then(() => {
+    if (!userInfo.value) router.push('/login')
+    else refreshList()
+  })
+})
+</script>
+
+<template>
+  <div class="iot-page shell-layout-root">
+    <div class="main-container">
+      <div class="sidebar"><SidebarMenu /></div>
+      <div class="content-area">
+        <BreadcrumbNav />
+        <HudPageHero
+          title="数据处理"
+          description="基于采集任务样本创建清洗、特征提取等流水线作业,推进处理进度并产出结构化记录。"
+        />
+
+        <el-card class="iot-card">
+          <template #header>
+            <div class="card-header">
+              <h2>处理作业</h2>
+              <div>
+                <el-button type="primary" @click="openCreate"><el-icon><Plus /></el-icon> 新建作业</el-button>
+                <el-button @click="refreshList"><el-icon><Refresh /></el-icon> 刷新</el-button>
+              </div>
+            </div>
+          </template>
+
+          <el-form :model="searchForm" inline class="search-form">
+            <el-form-item label="关键词">
+              <el-input v-model="searchForm.keyword" placeholder="作业名/流水线" clearable style="width: 220px" />
+            </el-form-item>
+            <el-form-item label="状态">
+              <el-select v-model="searchForm.status" placeholder="全部" clearable style="width: 120px">
+                <el-option v-for="(label, key) in statusLabel" :key="key" :label="label" :value="key" />
+              </el-select>
+            </el-form-item>
+            <el-form-item>
+              <el-button type="primary" @click="refreshList">搜索</el-button>
+              <el-button @click="resetSearch">重置</el-button>
+            </el-form-item>
+          </el-form>
+
+          <el-table :data="tableData" style="width: 100%">
+            <el-table-column prop="job_id" label="ID" width="60" />
+            <el-table-column prop="job_name" label="作业名称" min-width="150" show-overflow-tooltip />
+            <el-table-column prop="source_task_name" label="来源采集任务" width="150" show-overflow-tooltip />
+            <el-table-column prop="pipeline" label="处理流水线" min-width="200" show-overflow-tooltip />
+            <el-table-column label="进度" width="160">
+              <template #default="{ row }">
+                <el-progress :percentage="row.progress" :status="row.status === 'completed' ? 'success' : undefined" />
+              </template>
+            </el-table-column>
+            <el-table-column label="输入/输出" width="120">
+              <template #default="{ row }">{{ row.input_records }} / {{ row.output_records }}</template>
+            </el-table-column>
+            <el-table-column label="状态" width="95">
+              <template #default="{ row }">
+                <el-tag :type="statusType[row.status]" size="small">{{ statusLabel[row.status] }}</el-tag>
+              </template>
+            </el-table-column>
+            <el-table-column prop="started_at" label="开始时间" width="165" :formatter="fmtTime" />
+            <el-table-column prop="finished_at" label="完成时间" width="165" :formatter="fmtTime" />
+            <el-table-column label="操作" width="220" fixed="right">
+              <template #default="{ row }">
+                <el-button
+                  v-if="row.status === 'pending'"
+                  link
+                  type="success"
+                  @click="handleStart(row)"
+                >
+                  <el-icon><VideoPlay /></el-icon> 开始
+                </el-button>
+                <el-button
+                  v-if="row.status === 'running'"
+                  link
+                  type="primary"
+                  @click="handleAdvance(row)"
+                >
+                  <el-icon><Promotion /></el-icon> 推进 (+25%)
+                </el-button>
+                <el-button
+                  v-if="row.status !== 'running'"
+                  link
+                  type="danger"
+                  @click="handleDelete(row)"
+                >
+                  删除
+                </el-button>
+              </template>
+            </el-table-column>
+          </el-table>
+        </el-card>
+      </div>
+    </div>
+
+    <el-dialog v-model="dialogVisible" title="新建数据处理作业" width="500px">
+      <el-form ref="formRef" :model="form" :rules="formRules" label-width="110px">
+        <el-form-item label="作业名称" prop="job_name"><el-input v-model="form.job_name" /></el-form-item>
+        <el-form-item label="来源采集任务" prop="source_task_id">
+          <el-select v-model="form.source_task_id" placeholder="需有样本的采集任务" style="width: 100%" filterable>
+            <el-option
+              v-for="t in tasksWithSamples()"
+              :key="t.task_id"
+              :label="`${t.task_name}(样本 ${t.sample_count})`"
+              :value="t.task_id"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="处理流水线">
+          <el-select v-model="form.pipeline" style="width: 100%">
+            <el-option label="去噪 → 重采样 → 特征提取" value="denoise → resample → feature_extract" />
+            <el-option label="FFT → 频带能量 → 异常评分" value="fft → band_energy → anomaly_score" />
+            <el-option label="清洗 → 聚合 → 导出" value="clean → aggregate → export" />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="submitForm">创建</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<style scoped>
+@import '../styles/iot-page.css';
+</style>

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor