From e4e6529577edcab91c7a58ff4ae4b099f25d631b Mon Sep 17 00:00:00 2001 From: eafonyang Date: Tue, 9 Jun 2026 16:36:23 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E5=90=8E=E7=AB=AF=EF=BC=8C?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=20node=20=E6=8A=80=E6=9C=AF=E6=A0=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + backend/.env.example | 32 +- backend/.gitignore | 1 + backend/Dockerfile | 11 +- backend/curve_client.py | 148 -- backend/database.py | 41 - backend/main.py | 98 -- backend/models/__init__.py | 4 - backend/models/brand.py | 21 - backend/models/model.py | 37 - backend/models/ota.py | 54 - backend/ota_storage.py | 124 -- backend/package.json | 26 + backend/pnpm-lock.yaml | 2070 +++++++++++++++++++++++++++ backend/requirements.txt | 11 - backend/response.py | 36 - backend/restart.bat | 48 - backend/restart.sh | 42 + backend/routes/__init__.py | 5 - backend/routes/auth.py | 36 - backend/routes/brands.py | 148 -- backend/routes/models.py | 390 ----- backend/routes/ota.py | 266 ---- backend/schemas.py | 106 -- backend/security.py | 64 - backend/src/app.js | 57 + backend/src/config/database.js | 22 + backend/src/config/logger.js | 28 + backend/src/middleware/auth.js | 24 + backend/src/middleware/bodyLimit.js | 11 + backend/src/models/Brand.js | 22 + backend/src/models/Model.js | 52 + backend/src/models/Ota.js | 118 ++ backend/src/models/index.js | 5 + backend/src/routes/auth.js | 33 + backend/src/routes/brands.js | 146 ++ backend/src/routes/index.js | 9 + backend/src/routes/models.js | 416 ++++++ backend/src/routes/ota.js | 294 ++++ backend/src/services/curveClient.js | 146 ++ backend/src/services/otaStorage.js | 116 ++ backend/src/utils/jwt.js | 28 + backend/src/utils/response.js | 24 + backend/src/validators/brand.js | 11 + backend/src/validators/model.js | 21 + backend/src/validators/ota.js | 43 + backend/start.bat | 5 - backend/start.sh | 4 + backend/stop.bat | 19 - backend/stop.sh | 13 + convert_to_frequency_db.py | 163 --- frontend/index.html | 40 +- frontend/package.json | 2 +- frontend/pnpm-lock.yaml | 913 ++++++++++++ frontend/pnpm-workspace.yaml | 2 + frontend/src/main.js | 11 + frontend/src/styles/lux-theme.css | 142 +- frontend/src/views/login/index.vue | 53 +- frontend/src/views/model/index.vue | 4 +- 59 files changed, 4901 insertions(+), 1916 deletions(-) delete mode 100644 backend/curve_client.py delete mode 100644 backend/database.py delete mode 100644 backend/main.py delete mode 100644 backend/models/__init__.py delete mode 100644 backend/models/brand.py delete mode 100644 backend/models/model.py delete mode 100644 backend/models/ota.py delete mode 100644 backend/ota_storage.py create mode 100644 backend/package.json create mode 100644 backend/pnpm-lock.yaml delete mode 100644 backend/requirements.txt delete mode 100644 backend/response.py delete mode 100644 backend/restart.bat create mode 100755 backend/restart.sh delete mode 100644 backend/routes/__init__.py delete mode 100644 backend/routes/auth.py delete mode 100644 backend/routes/brands.py delete mode 100644 backend/routes/models.py delete mode 100644 backend/routes/ota.py delete mode 100644 backend/schemas.py delete mode 100644 backend/security.py create mode 100644 backend/src/app.js create mode 100644 backend/src/config/database.js create mode 100644 backend/src/config/logger.js create mode 100644 backend/src/middleware/auth.js create mode 100644 backend/src/middleware/bodyLimit.js create mode 100644 backend/src/models/Brand.js create mode 100644 backend/src/models/Model.js create mode 100644 backend/src/models/Ota.js create mode 100644 backend/src/models/index.js create mode 100644 backend/src/routes/auth.js create mode 100644 backend/src/routes/brands.js create mode 100644 backend/src/routes/index.js create mode 100644 backend/src/routes/models.js create mode 100644 backend/src/routes/ota.js create mode 100644 backend/src/services/curveClient.js create mode 100644 backend/src/services/otaStorage.js create mode 100644 backend/src/utils/jwt.js create mode 100644 backend/src/utils/response.js create mode 100644 backend/src/validators/brand.js create mode 100644 backend/src/validators/model.js create mode 100644 backend/src/validators/ota.js delete mode 100644 backend/start.bat create mode 100755 backend/start.sh delete mode 100644 backend/stop.bat create mode 100755 backend/stop.sh delete mode 100644 convert_to_frequency_db.py create mode 100644 frontend/pnpm-lock.yaml create mode 100644 frontend/pnpm-workspace.yaml diff --git a/.gitignore b/.gitignore index 4c38d4f..02d6ab2 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ wheels/ *.egg *.manifest *.spec +autoeq/ # IDE .vscode/ diff --git a/backend/.env.example b/backend/.env.example index b603cfc..dde6f8b 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,10 +1,38 @@ # Database Configuration -DATABASE_HOST=localhost +DATABASE_HOST=192.168.9.137 DATABASE_PORT=3306 DATABASE_NAME=audio DATABASE_USER=root -DATABASE_PASSWORD=root1 +DATABASE_PASSWORD=root123 # Application Settings APP_NAME=Audio Dashboard API DEBUG=True +PORT=8083 + +# JWT +JWT_SECRET=dev-only-change-me-for-production +DASHBOARD_ADMIN_USERNAME=admin +DASHBOARD_ADMIN_PASSWORD=Eafon123 + +# Meilisearch +MEILISEARCH_URL=http://localhost:7700 +MEILISEARCH_API_KEY= +MEILISEARCH_INDEX=models + +# AWS S3 (OTA X8) +AWS_REGION=eu-central-1 +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_S3_OTA_BUCKET=luxsin-app-bucket + +# OTA URLs +OTA_X8_PUBLIC_BASE=http://am.luxsinaudio.com +OTA_X9_URL_BASE=http://source.luxsin.net + +# OTA Upload Directories +OTA_UPLOAD_DIR_DEV= +OTA_UPLOAD_DIR_PROD=/data/project/dashboard/upload + +# File Upload +UPLOAD_FOLDER=/data/project/autoeq/measurements diff --git a/backend/.gitignore b/backend/.gitignore index 4e7c0c6..39b9ea3 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -22,6 +22,7 @@ wheels/ *.egg-info/ .installed.cfg *.egg +autoeq/ # IDE .vscode/ diff --git a/backend/Dockerfile b/backend/Dockerfile index 73f5bdd..4da02a1 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.11-slim +FROM node:20-alpine WORKDIR /app @@ -6,14 +6,15 @@ WORKDIR /app RUN mkdir -p /app/logs # Install dependencies -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +COPY package.json pnpm-lock.yaml ./ +RUN corepack enable pnpm && pnpm install --frozen-lockfile --prod # Copy application code -COPY . . +COPY src/ ./src/ +COPY .env ./ # Expose port EXPOSE 8000 # Run the application -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["node", "src/app.js"] diff --git a/backend/curve_client.py b/backend/curve_client.py deleted file mode 100644 index 71e4de0..0000000 --- a/backend/curve_client.py +++ /dev/null @@ -1,148 +0,0 @@ -"""Luxsin 曲线 API:拉取、自定义 Base64 解码与 parametric_eq 校验。""" -import base64 -import json -import logging -from typing import Any, Optional, Tuple -from urllib.parse import urlencode - -import requests - -logger = logging.getLogger(__name__) - -LUXSIN_CURVE_API_BASE = "https://api.luxsin.com.cn/audio/getCurve" - -CUSTOM_CHARS = "KLMPQRSTUVWXYZABCGHdefIJjkNOlmnopqrstuvwxyzabcghiDEF34501289+67/" -STANDARD_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" - -TARGET_OVER_EAR = "Harman over-ear 2018" -TARGET_IN_EAR = "Harman in-ear 2019" - -FORM_TARGET_MAP = { - "over-ear": TARGET_OVER_EAR, - "in-ear": TARGET_IN_EAR, -} - - -def curve_target_for_form(form: Optional[str]) -> Optional[str]: - if not form: - return None - return FORM_TARGET_MAP.get(form.strip().lower()) - - -def custom_base64_to_string(encoded: str) -> str: - """将 Luxsin 自定义 Base64 字母表映射为标准 Base64 后解码为 UTF-8 字符串。""" - if not encoded or not isinstance(encoded, str): - raise ValueError("曲线数据为空") - - standard_b64 = [] - for c in encoded: - idx = CUSTOM_CHARS.find(c) - if idx != -1: - standard_b64.append(STANDARD_CHARS[idx]) - else: - standard_b64.append(c) - - try: - raw = base64.b64decode("".join(standard_b64), validate=False) - except Exception as e: - raise ValueError(f"Base64 解码失败:{e}") from e - - try: - return raw.decode("utf-8") - except UnicodeDecodeError as e: - raise ValueError(f"UTF-8 解码失败:{e}") from e - - -def is_valid_parametric_eq_payload(data: Any) -> bool: - if not isinstance(data, dict): - return False - peq = data.get("parametric_eq") - if not isinstance(peq, dict): - return False - filters = peq.get("filters") - return isinstance(filters, list) and len(filters) == 10 - - -def extract_encoded_payload(response: requests.Response) -> str: - """从 getCurve 响应中提取待解码字符串。""" - text = (response.text or "").strip() - if not text: - raise ValueError("曲线接口响应为空") - - try: - body = response.json() - except ValueError: - return text - - if isinstance(body, str): - return body.strip() - - if not isinstance(body, dict): - raise ValueError("曲线接口响应格式异常") - - for key in ("data", "curve", "result", "content", "body"): - val = body.get(key) - if isinstance(val, str) and val.strip(): - return val.strip() - - nested = body.get("data") - if isinstance(nested, dict): - for key in ("curve", "data", "content", "encoded"): - val = nested.get(key) - if isinstance(val, str) and val.strip(): - return val.strip() - - if isinstance(nested, str) and nested.strip(): - return nested.strip() - - raise ValueError("曲线接口响应中未找到可解码数据") - - -def fetch_and_validate_curve( - brand: str, - name: str, - form: str, - *, - timeout: float = 20.0, -) -> Tuple[bool, str]: - """ - 拉取并校验曲线。返回 (是否通过, 失败原因);通过时原因为空字符串。 - """ - brand = (brand or "").strip() - name = (name or "").strip() - if not brand or not name: - return False, "品牌名称或型号名称为空" - - target = curve_target_for_form(form) - if not target: - return False, "佩戴方式须为入耳式(in-ear)或头戴式(over-ear)才能校验曲线" - - query = urlencode({"brand": brand, "name": name, "target": target}) - url = f"{LUXSIN_CURVE_API_BASE}?{query}" - - try: - resp = requests.get(url, timeout=timeout) - except requests.RequestException as e: - logger.warning("getCurve request failed: %s %s", url, e) - return False, f"无法连接曲线接口:{e}" - - if resp.status_code != 200: - return False, f"曲线接口返回 HTTP {resp.status_code}" - - try: - encoded = extract_encoded_payload(resp) - except ValueError as e: - return False, str(e) - - try: - decoded_text = custom_base64_to_string(encoded) - payload = json.loads(decoded_text) - except json.JSONDecodeError: - return False, "解码后的数据不是合法 JSON" - except ValueError as e: - return False, str(e) - - if not is_valid_parametric_eq_payload(payload): - return False, "曲线数据异常" - - return True, "" diff --git a/backend/database.py b/backend/database.py deleted file mode 100644 index 2e69c60..0000000 --- a/backend/database.py +++ /dev/null @@ -1,41 +0,0 @@ -from sqlalchemy import create_engine -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import sessionmaker -import os -from dotenv import load_dotenv - -# Load environment variables(override=True:.env 覆盖已有环境变量,避免系统里 DEBUG=true 导致与 .env 中 DEBUG=False 冲突) -load_dotenv(override=True) - -# Database configuration -DATABASE_HOST = os.getenv("DATABASE_HOST", "localhost") -DATABASE_PORT = os.getenv("DATABASE_PORT", "3306") -DATABASE_NAME = os.getenv("DATABASE_NAME", "audio") -DATABASE_USER = os.getenv("DATABASE_USER", "root") -DATABASE_PASSWORD = os.getenv("DATABASE_PASSWORD", "root123") - -# Create database URL (only charset parameter) -DATABASE_URL = f"mysql+pymysql://{DATABASE_USER}:{DATABASE_PASSWORD}@{DATABASE_HOST}:{DATABASE_PORT}/{DATABASE_NAME}?charset=utf8mb4" - -# Create engine with additional connection arguments -# Use connect_args for parameters that shouldn't be in the URL -engine = create_engine( - DATABASE_URL, - echo=os.getenv("DEBUG", "False") == "True", - connect_args={} -) - -# Create session factory -SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) - -# Base class for models -Base = declarative_base() - - -def get_db(): - """Database session dependency""" - db = SessionLocal() - try: - yield db - finally: - db.close() diff --git a/backend/main.py b/backend/main.py deleted file mode 100644 index 65dd5d6..0000000 --- a/backend/main.py +++ /dev/null @@ -1,98 +0,0 @@ -from fastapi import FastAPI, Depends, Request -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse -import os -import logging -from dotenv import load_dotenv - -from database import engine, Base -from routes import brands_router, models_router, ota_router -from routes.auth import router as auth_router -from security import get_current_user - -# Load environment variables -load_dotenv(override=True) - -# Configure logging -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - handlers=[ - logging.StreamHandler(), # 输出到控制台 - logging.FileHandler('logs/app.log', encoding='utf-8') # 输出到文件 - ] -) -logger = logging.getLogger(__name__) - -# Create database tables (only if tables don't exist) -try: - logger.info("Creating database tables...") - Base.metadata.create_all(bind=engine) - logger.info("Database tables created successfully") -except Exception as e: - logger.error(f"Warning: Could not create tables: {e}") - logger.error("Continuing without table creation...") - -# Create FastAPI app -app = FastAPI( - title=os.getenv("APP_NAME", "Audio Dashboard API"), - description="耳机品牌与型号管理平台后端 API", - version="1.0.0" -) - -# 限制请求体最大 8MB -MAX_BODY_SIZE = 8 * 1024 * 1024 # 8MB - - -@app.middleware("http") -async def limit_body_size(request: Request, call_next): - content_length = request.headers.get("content-length") - if content_length and int(content_length) > MAX_BODY_SIZE: - return JSONResponse( - status_code=413, - content={"detail": "上传文件大小超过 8MB 限制"}, - ) - response = await call_next(request) - return response - - -# Configure CORS -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], # 生产环境应该限制具体域名 - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -# Include routers(业务接口需登录;登录接口除外) -app.include_router(auth_router) -app.include_router(brands_router, dependencies=[Depends(get_current_user)]) -app.include_router(models_router, dependencies=[Depends(get_current_user)]) -app.include_router(ota_router, dependencies=[Depends(get_current_user)]) - - -@app.get("/") -def root(): - """根路径""" - return { - "message": "欢迎使用 Audio Dashboard API", - "docs": "/docs", - "redoc": "/redoc" - } - - -@app.get("/health") -def health_check(): - """健康检查1""" - return {"status": "healthy"} - - -if __name__ == "__main__": - import uvicorn - uvicorn.run( - "main:app", - host="0.0.0.0", - port=8083, - reload=False - ) diff --git a/backend/models/__init__.py b/backend/models/__init__.py deleted file mode 100644 index 8ed3601..0000000 --- a/backend/models/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from models.brand import Brand -from models.model import Model - -__all__ = ["Brand", "Model"] diff --git a/backend/models/brand.py b/backend/models/brand.py deleted file mode 100644 index 2e1b1a2..0000000 --- a/backend/models/brand.py +++ /dev/null @@ -1,21 +0,0 @@ -from sqlalchemy import Column, Integer, String -from database import Base - - -class Brand(Base): - """耳机品牌模型""" - __tablename__ = "brand" - __table_args__ = {"comment": "耳机品牌","extend_existing": True} - - id = Column(Integer, primary_key=True, autoincrement=True, comment="品牌 ID") - name = Column(String(100), unique=True, nullable=False, comment="品牌名称") - - def __repr__(self): - return f"" - - def to_dict(self): - """Convert to dictionary""" - return { - "id": self.id, - "name": self.name - } diff --git a/backend/models/model.py b/backend/models/model.py deleted file mode 100644 index d75e926..0000000 --- a/backend/models/model.py +++ /dev/null @@ -1,37 +0,0 @@ -from sqlalchemy import Column, Integer, String, DateTime, func -from database import Base - - -class Model(Base): - """耳机型号模型""" - __tablename__ = "model" - __table_args__ = {"comment": "耳机型号"} - - id = Column(Integer, primary_key=True, autoincrement=True, comment="型号 ID") - brand_name = Column(String(100), nullable=False, comment="品牌名称") - name = Column(String(100), nullable=False, comment="型号名称") - form = Column(String(100), nullable=True, comment="形式") - rig = Column(String(100), nullable=True, comment="阻抗") - source = Column(String(100), nullable=True, comment="来源") - eq_key = Column(String(255), nullable=True, comment="EQ 键") - create_at = Column(DateTime, nullable=False, default=func.now(), comment="创建时间") - - __table_args__ = ( - {"comment": "耳机型号"}, - ) - - def __repr__(self): - return f"" - - def to_dict(self): - """Convert to dictionary""" - return { - "id": self.id, - "brand_name": self.brand_name, - "name": self.name, - "form": self.form, - "rig": self.rig, - "source": self.source, - "eq_key": self.eq_key, - "create_at": self.create_at.isoformat() if self.create_at else None - } diff --git a/backend/models/ota.py b/backend/models/ota.py deleted file mode 100644 index e796986..0000000 --- a/backend/models/ota.py +++ /dev/null @@ -1,54 +0,0 @@ -from sqlalchemy import Column, Integer, String, DateTime, Boolean, SmallInteger, Text -from database import Base -from datetime import datetime - - -class Ota(Base): - """OTA 升级版本模型""" - __tablename__ = "ota" - __table_args__ = {"comment": "OTA 升级版本"} - - id = Column(Integer, primary_key=True, autoincrement=True, comment="ID") - verCode = Column(Integer, nullable=False, comment="版本号(整数)") - verName = Column(String(20), nullable=False, comment="版本名称") - url = Column(String(255), nullable=False, comment="升级包 URL") - md5 = Column(String(32), nullable=False, comment="升级包 MD5") - force = Column(SmallInteger, nullable=False, default=0, comment="是否强升;0-否") - desc = Column(String(255), nullable=True, comment="描述") - model = Column(String(100), nullable=True, comment="对应的设备型号") - hw = Column(Integer, nullable=False, default=0, comment="硬件版本号") - target = Column(SmallInteger, nullable=False, default=0, comment="是否定向,1-是,0-否;否表示面向所有用户") - beta = Column(SmallInteger, nullable=False, default=0, comment="是否灰度,1-是,0-否") - pawVerCode = Column(Integer, nullable=False, default=0, comment="配对版本号") - pawVerName = Column(String(20), nullable=False, default='', comment="配对版本名称") - pawUrl = Column(String(255), nullable=False, default='', comment="配对版本 URL") - pawMd5 = Column(String(32), nullable=False, default='', comment="配对版本 MD5") - startTime = Column(DateTime, nullable=True, comment="升级开始时间") - endTime = Column(DateTime, nullable=True, comment="升级结束时间") - status = Column(SmallInteger, nullable=False, default=1, comment="是否可用") - - def __repr__(self): - return f"" - - def to_dict(self): - """Convert to dictionary""" - return { - "id": self.id, - "verCode": self.verCode, - "verName": self.verName, - "url": self.url, - "md5": self.md5, - "force": self.force, - "desc": self.desc, - "model": self.model, - "hw": self.hw, - "target": self.target, - "beta": self.beta, - "pawVerCode": self.pawVerCode, - "pawVerName": self.pawVerName, - "pawUrl": self.pawUrl, - "pawMd5": self.pawMd5, - "startTime": self.startTime.isoformat() if self.startTime else None, - "endTime": self.endTime.isoformat() if self.endTime else None, - "status": self.status - } diff --git a/backend/ota_storage.py b/backend/ota_storage.py deleted file mode 100644 index 248e08b..0000000 --- a/backend/ota_storage.py +++ /dev/null @@ -1,124 +0,0 @@ -"""OTA 升级包存储:Luxsin-X8 -> S3,Luxsin-X9 -> 本地目录""" -import hashlib -import io -import logging -import os -from datetime import datetime -from pathlib import Path - -import boto3 -from botocore.exceptions import ClientError -from fastapi import UploadFile - -logger = logging.getLogger(__name__) - -OTA_MODEL_X8 = "Luxsin-X8" -OTA_MODEL_X9 = "Luxsin-X9" -OTA_UPLOAD_MODELS = {OTA_MODEL_X8, OTA_MODEL_X9} - -# 默认本地根目录;实际路径为 {根}/{md5前5位}/LUXSIN.PKG(具体目录在 get_ota_upload_dir 内按当前环境读取) -_OTA_UPLOAD_DIR_DEV_DEFAULT = "H:/soft/projects/luxsin/dashboard/ota" -_OTA_UPLOAD_DIR_PROD_DEFAULT = "/data/project/dashboard/upload" - -AWS_REGION = os.getenv("AWS_REGION", "eu-central-1") -AWS_ACCESS_KEY_ID = os.getenv("AWS_ACCESS_KEY_ID", "") -AWS_SECRET_ACCESS_KEY = os.getenv("AWS_SECRET_ACCESS_KEY", "") -AWS_S3_OTA_BUCKET = os.getenv("AWS_S3_OTA_BUCKET", "luxsin-app-bucket") - -OTA_X8_PUBLIC_BASE = (os.getenv("OTA_X8_PUBLIC_BASE") or "http://am.luxsinaudio.com").rstrip( - "/" -) -OTA_X9_URL_BASE = (os.getenv("OTA_X9_URL_BASE") or "http://source.luxsin.net").rstrip("/") - -OTA_FILENAME_X8 = "LUXSIN_X8.PKG" -OTA_FILENAME_X9 = "LUXSIN.PKG" - - -def get_ota_upload_dir() -> Path: - """按 DEBUG 选开发/生产目录;每次调用重新读环境变量,避免 import 早于 load_dotenv。""" - debug_val = (os.getenv("DEBUG") or "false").strip().lower() - is_dev = debug_val in ("true", "1", "yes", "on") - dev = os.getenv("OTA_UPLOAD_DIR_DEV", _OTA_UPLOAD_DIR_DEV_DEFAULT) - prod = os.getenv("OTA_UPLOAD_DIR_PROD", _OTA_UPLOAD_DIR_PROD_DEFAULT) - raw = dev if is_dev else prod - return Path(raw) - - -def md5_prefix5(md5_hex: str) -> str: - return (md5_hex or "")[:5] - - -def build_x8_public_url(s3_key: str) -> str: - """X8:http://am.luxsinaudio.com/ota/{yyyyMM}/x8/{md5前5位}/LUXSIN_X8.PKG""" - return f"{OTA_X8_PUBLIC_BASE}/{s3_key}" - - -def build_x9_public_url(ym: str, prefix: str) -> str: - """X9:http://source.luxsin.net/ota/{yyyyMM}/x9/{md5前5位}/LUXSIN.PKG""" - return f"{OTA_X9_URL_BASE}/ota/{ym}/x9/{prefix}/{OTA_FILENAME_X9}" - - -async def read_upload_content_and_md5(package_file: UploadFile) -> tuple[bytes, str]: - md5_hash = hashlib.md5() - buf = io.BytesIO() - chunk_size = 1024 * 1024 - while True: - chunk = await package_file.read(chunk_size) - if not chunk: - break - md5_hash.update(chunk) - buf.write(chunk) - return buf.getvalue(), md5_hash.hexdigest() - - -def save_x9_package_local(content: bytes, md5_hex: str) -> tuple[str, str]: - """ - 保存到 {OTA_UPLOAD_DIR}/{md5前5位}/LUXSIN.PKG - 对外 URL:http://source.luxsin.net/ota/{yyyyMM}/x9/{md5前5位}/LUXSIN.PKG - """ - root = get_ota_upload_dir() - prefix = md5_prefix5(md5_hex) - ym = datetime.now().strftime("%Y%m") - dest_dir = root / prefix - dest_dir.mkdir(parents=True, exist_ok=True) - final_path = dest_dir / OTA_FILENAME_X9 - if final_path.exists(): - final_path.unlink() - final_path.write_bytes(content) - download_url = build_x9_public_url(ym, prefix) - logger.info("OTA X9 package saved locally: %s", final_path) - return OTA_FILENAME_X9, download_url - - -def upload_x8_package_to_s3(content: bytes, md5_hex: str) -> tuple[str, str, str]: - """ - S3 Key:ota/{yyyyMM}/x8/{md5前5位}/LUXSIN_X8.PKG - 对外 URL:http://am.luxsinaudio.com/ota/{yyyyMM}/x8/{md5前5位}/LUXSIN_X8.PKG - """ - if not AWS_ACCESS_KEY_ID or not AWS_SECRET_ACCESS_KEY: - raise ValueError("未配置 AWS 访问密钥(AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY)") - - ym = datetime.now().strftime("%Y%m") - prefix = md5_prefix5(md5_hex) - s3_key = f"ota/{ym}/x8/{prefix}/{OTA_FILENAME_X8}" - - client = boto3.client( - "s3", - region_name=AWS_REGION, - aws_access_key_id=AWS_ACCESS_KEY_ID, - aws_secret_access_key=AWS_SECRET_ACCESS_KEY, - ) - try: - client.put_object( - Bucket=AWS_S3_OTA_BUCKET, - Key=s3_key, - Body=content, - ContentType="application/octet-stream", - ) - except ClientError as e: - logger.error("S3 upload failed: %s", e, exc_info=True) - raise ValueError(f"S3 上传失败:{e}") from e - - download_url = build_x8_public_url(s3_key) - logger.info("OTA X8 package uploaded to s3://%s/%s", AWS_S3_OTA_BUCKET, s3_key) - return OTA_FILENAME_X8, download_url, s3_key diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..83fe38f --- /dev/null +++ b/backend/package.json @@ -0,0 +1,26 @@ +{ + "name": "audio-dashboard-api", + "version": "1.0.0", + "description": "耳机品牌与型号管理平台后端 API", + "main": "src/app.js", + "scripts": { + "start": "node src/app.js", + "dev": "nodemon src/app.js" + }, + "dependencies": { + "express": "^4.21", + "cors": "^2.8", + "sequelize": "^6.37", + "mysql2": "^3.11", + "jsonwebtoken": "^9.0", + "multer": "^1.4", + "axios": "^1.7", + "dotenv": "^16.4", + "zod": "^3.23", + "@aws-sdk/client-s3": "^3.650", + "winston": "^3.14" + }, + "devDependencies": { + "nodemon": "^3.1" + } +} diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml new file mode 100644 index 0000000..14d30e4 --- /dev/null +++ b/backend/pnpm-lock.yaml @@ -0,0 +1,2070 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@aws-sdk/client-s3': + specifier: ^3.650 + version: 3.1063.0 + axios: + specifier: ^1.7 + version: 1.17.0 + cors: + specifier: ^2.8 + version: 2.8.6 + dotenv: + specifier: ^16.4 + version: 16.6.1 + express: + specifier: ^4.21 + version: 4.22.2 + jsonwebtoken: + specifier: ^9.0 + version: 9.0.3 + multer: + specifier: ^1.4 + version: 1.4.4 + mysql2: + specifier: ^3.11 + version: 3.22.5(@types/node@25.9.2) + sequelize: + specifier: ^6.37 + version: 6.37.8(mysql2@3.22.5(@types/node@25.9.2)) + winston: + specifier: ^3.14 + version: 3.19.0 + zod: + specifier: ^3.23 + version: 3.25.76 + devDependencies: + nodemon: + specifier: ^3.1 + version: 3.1.14 + +packages: + + '@aws-crypto/crc32@5.2.0': + resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/crc32c@5.2.0': + resolution: {integrity: sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==} + + '@aws-crypto/sha1-browser@5.2.0': + resolution: {integrity: sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==} + + '@aws-crypto/sha256-browser@5.2.0': + resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} + + '@aws-crypto/sha256-js@5.2.0': + resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/supports-web-crypto@5.2.0': + resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} + + '@aws-crypto/util@5.2.0': + resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + + '@aws-sdk/checksums@3.1000.2': + resolution: {integrity: sha512-PIha+kauTbp6IRmOpYktPTrlfrrSqDVixvhO/EUOFOf62DPX81CaJoHJreuA1m9HYpSKyXf99BKjU1dvJPeUfw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/client-s3@3.1063.0': + resolution: {integrity: sha512-ETn+vvmZVK1MmOZwVBXmWANpmD5iTbzojIqyEIoZ86qo+8oWy35S8QyQNE/ZDI+WHgMU1dS+VSYbpRl1QkEySg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.974.18': + resolution: {integrity: sha512-JDYCPI0j7zGrzXTDFsLB346cxss7J/AxH7+O0MzWlqppJBEyB9Qe6TQXRL6iwLUo/xZkNv9KFmBL2hqElmwW0g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.44': + resolution: {integrity: sha512-3hKJVrZ7bqXzDAXCQp+OaQ1ASN+vWstaNuEH418wQVl//cRZhqhfR9Bjk1qIWmgUGe8/D3gdO73PgidRj378EQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.46': + resolution: {integrity: sha512-VhwC9pGAZHhiQ2xSViyOPDFqvr9aRxGCAXZtADsUhU3R65nad7y//CwynE6mQnWNR+suRlqE79W36IVayL+m1g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.972.50': + resolution: {integrity: sha512-09Xi6ovxiK42+De/qBGF71sT5F2bWgYM+1fFyDwSOpy1xpsQ5R/naIu7MVDpH6Dic36QNc8dAv4KADtMGK2JYg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.49': + resolution: {integrity: sha512-EfJF/1Fh9mI4pZyoheU2RY9xUhTcugIZNkD63+orXMkYj/QXacJNbKVDUK90Yv5hE+aX+rt9J/EZ9Qr3vKOa7g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.52': + resolution: {integrity: sha512-7QX+PbyiWBEOVipJq8Nke/TqXT6lAPLE7fvTaopa39/IVWuLfS+Fzdy71sZJONf/mLGgmtj6aU17+REw3+aRrw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.44': + resolution: {integrity: sha512-V+UUhZpRP7QDRhi+qgBDisM9tUBnYmMje8Bk77A6MZsfeGeGdMsQXmaHP1CDYFcept0o/Rz5g2Y0TMeVlG9dzg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.972.49': + resolution: {integrity: sha512-9QqOYGuh5tZ76OzaT68kwI78AH+5lS/uZGGvkfxb3fc8FzRrIz2jOufNTliEBEeSAwmgK2rWLNsK+IB3zbtNPA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.49': + resolution: {integrity: sha512-IYx1lN38MnnPXv+NBLpuATu0cZakbZ321TAfjW+aVkw7HIJF38YnEwdeEO55MSl3pl7hIX1IvvnD6EmnAzmAJw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-flexible-checksums@3.974.27': + resolution: {integrity: sha512-bZqezPLdllFC4VAeV/f+EIc/hz56ab3TD/+4zNCgOgmG5ZHAE5dMHrX1gtTwdcQXbPr3KR7x3zTC3zuCTE6+ng==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-sdk-s3@3.972.48': + resolution: {integrity: sha512-MRTqx8wD/T3REt6LTT3/yN8rrp6+xIHrbUekkDYJTYWVch70mwtdJBovR4qKJz1jIPlbN+9R/Sn6R04BfsglzA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.997.17': + resolution: {integrity: sha512-lDRgraoTfKRawUyc176Ow93mrNrOho/x+EoK4C+lKU+vKkHWhNhzvSMVAx0WEJUJoeQxxDN5ZdKMfiGEyNejig==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.32': + resolution: {integrity: sha512-llvApLcsWtmRFhG2wT3WIp1CmDeRaIYutqty1ZZXoMzK7TiJ6MOLOimk9eXUS8PwgG4ew4pa4QAbt0lfhn++1w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1063.0': + resolution: {integrity: sha512-nYDaWWdzjKiDP5xj8k4oUgcYd4WPgzfAOgdU5vJsaqH/07Dfvm7ffisHCFJ+NEl7kUC9JEIUxh0kznvenbo3NQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.973.11': + resolution: {integrity: sha512-YjS0qFuECClRh4qhEyW8XagW0fwEPBeZ1cfsW/gU73Kh/ExFILxbzxOfPCmzF/2DwEvhvsHYt0b0qnvStwKYrg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-locate-window@3.965.6': + resolution: {integrity: sha512-ZfHjfwSzeXj+Lg9AK5ZNmeDkXev6V+w2tn1t4kgDdRtUaRCthepTQiFwbD06EF9oNGH4LaLg+Mb6U16Ypv5bSw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/xml-builder@3.972.28': + resolution: {integrity: sha512-lI/l3c/vPvsxmspzV63NfS3x9q4CkMmdhJy4QiM+NThAufVkDvi/PZZQ6xETnICL0UD7jI808pY83gllf86RFg==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.2.4': + resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} + engines: {node: '>=18.0.0'} + + '@colors/colors@1.6.0': + resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} + engines: {node: '>=0.1.90'} + + '@dabh/diagnostics@2.0.8': + resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} + + '@nodable/entities@2.1.1': + resolution: {integrity: sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==} + + '@smithy/core@3.24.6': + resolution: {integrity: sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.3.8': + resolution: {integrity: sha512-5cAM+KZC02sTqDt6NaLXyu50M/GNMd1eTzDVR8Lb0BBsVtu7RWHo47VPPEEv1vt3Yub6uzr+M5FHC+GtoT0USg==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.4.6': + resolution: {integrity: sha512-FEwEYJ1jlBKdhe9TPzfghEi1bP55ZeEImlDkEa62bBBYzUcnB6RUCyuiS2mqKt6ZVjUbBgcNhzfIctH+Hevx9g==} + engines: {node: '>=18.0.0'} + + '@smithy/is-array-buffer@2.2.0': + resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} + engines: {node: '>=14.0.0'} + + '@smithy/node-http-handler@4.7.7': + resolution: {integrity: sha512-ZAFvHXrEk6K180EVhmZVg8GU5pUH5BSFqRs27JW3j1qEFx9YyYwWFx17x/MHcjALYimGAji7qEOlF1++be+G5A==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.4.6': + resolution: {integrity: sha512-Ojg4B6oIDlIr1R86xCDJt1zJWnYa0VINmqdjfe9qxWjdRivHalZ3iSlQgVqYbW0MdpFOC5XfHEWsnbmdnpIILQ==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.14.3': + resolution: {integrity: sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-buffer-from@2.2.0': + resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-utf8@2.3.0': + resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} + engines: {node: '>=14.0.0'} + + '@so-ric/colorspace@1.1.6': + resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==} + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@25.9.2': + resolution: {integrity: sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==} + + '@types/triple-beam@1.3.5': + resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} + + '@types/validator@13.15.10': + resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + append-field@1.0.0: + resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + aws-ssl-profiles@1.1.2: + resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} + engines: {node: '>= 6.0.0'} + + axios@1.17.0: + resolution: {integrity: sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + body-parser@1.20.5: + resolution: {integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + busboy@0.2.14: + resolution: {integrity: sha512-InWFDomvlkEj+xWLBfU3AvnbVYqeTWmQopiW0tWWEy5yehYm2YkGEc59sUmw/4ty5Zj/b0WHGs1LgecuBSBGrg==} + engines: {node: '>=0.8.0'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + color-convert@3.1.3: + resolution: {integrity: sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==} + engines: {node: '>=14.6'} + + color-name@2.1.0: + resolution: {integrity: sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==} + engines: {node: '>=12.20'} + + color-string@2.1.4: + resolution: {integrity: sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==} + engines: {node: '>=18'} + + color@5.0.3: + resolution: {integrity: sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==} + engines: {node: '>=18'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + concat-stream@1.6.2: + resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} + engines: {'0': node >= 0.8} + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + cookie-signature@1.0.7: + resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + dicer@0.2.5: + resolution: {integrity: sha512-FDvbtnq7dzlPz0wyYlOExifDEZcu8h+rErEXgfxqmLfRfC/kJidEFh4+effJRO3P0xmfqyPbSMG0LveNRfTKVg==} + engines: {node: '>=0.8.0'} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dottie@2.0.7: + resolution: {integrity: sha512-7lAK2A0b3zZr3UC5aE69CPdCFR4RHW1o2Dr74TqFykxkUCBXSRJum/yPc7g8zRHJqWKomPLHwFLLoUnn8PXXRg==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + enabled@2.0.0: + resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + express@4.22.2: + resolution: {integrity: sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==} + engines: {node: '>= 0.10.0'} + + fast-xml-builder@1.2.0: + resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} + + fast-xml-parser@5.7.3: + resolution: {integrity: sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==} + hasBin: true + + fecha@4.2.3: + resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@1.3.2: + resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} + engines: {node: '>= 0.8'} + + fn.name@1.1.0: + resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + generate-function@2.3.1: + resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + ignore-by-default@1.0.1: + resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==} + + inflection@1.13.4: + resolution: {integrity: sha512-6I/HUDeYFfuNCVS3td055BaXBwKYuzw7K3ExVMStBowKo9oOAMJIXIHvdyR3iboTCp1b+1i5DSkIZTcwIktuDw==} + engines: {'0': node >= 0.4.0} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-property@1.0.2: + resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + isarray@0.0.1: + resolution: {integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + + kuler@2.0.0: + resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} + + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + logform@2.7.0: + resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} + engines: {node: '>= 12.0.0'} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + lru.min@1.1.4: + resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} + engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + + moment-timezone@0.5.48: + resolution: {integrity: sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==} + + moment@2.30.1: + resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + multer@1.4.4: + resolution: {integrity: sha512-2wY2+xD4udX612aMqMcB8Ws2Voq6NIUPEtD1be6m411T4uDH/VtL9i//xvcyFlTVfRdaBsk7hV5tgrGQqhuBiw==} + engines: {node: '>= 0.10.0'} + deprecated: Multer 1.x is affected by CVE-2022-24434. This is fixed in v1.4.4-lts.1 which drops support for versions of Node.js before 6. Please upgrade to at least Node.js 6 and version 1.4.4-lts.1 of Multer. If you need support for older versions of Node.js, we are open to accepting patches that would fix the CVE on the main 1.x release line, whilst maintaining compatibility with Node.js 0.10. + + mysql2@3.22.5: + resolution: {integrity: sha512-95uZ2TrPWAZdwpB3vvvDbmEMcNG8yIeNCyu6GUcr/QnWEE/wXm7+mhOCsdQfWQDTV7qYT/PDUZ4U4UPP4AsXqQ==} + engines: {node: '>= 8.0'} + peerDependencies: + '@types/node': '>= 8' + + named-placeholders@1.1.6: + resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} + engines: {node: '>=8.0.0'} + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + nodemon@3.1.14: + resolution: {integrity: sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==} + engines: {node: '>=10'} + hasBin: true + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + one-time@1.0.0: + resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-expression-matcher@1.5.0: + resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} + engines: {node: '>=14.0.0'} + + path-to-regexp@0.1.13: + resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} + + pg-connection-string@2.13.0: + resolution: {integrity: sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + pstree.remy@1.1.8: + resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} + + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@2.5.3: + resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} + engines: {node: '>= 0.8'} + + readable-stream@1.1.14: + resolution: {integrity: sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + retry-as-promised@7.1.1: + resolution: {integrity: sha512-hMD7odLOt3LkTjcif8aRZqi/hybjpLNgSk5oF5FCowfCjok6LukpN2bDX7R5wDmbgBQFn7YoBxSagmtXHaJYJw==} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + semver@7.8.2: + resolution: {integrity: sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==} + engines: {node: '>=10'} + hasBin: true + + send@0.19.2: + resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} + engines: {node: '>= 0.8.0'} + + sequelize-pool@7.1.0: + resolution: {integrity: sha512-G9c0qlIWQSK29pR/5U2JF5dDQeqqHRragoyahj/Nx4KOOQ3CPPfzxnfqFPCSB7x5UgjOgnZ61nSxz+fjDpRlJg==} + engines: {node: '>= 10.0.0'} + + sequelize@6.37.8: + resolution: {integrity: sha512-HJ0IQFqcTsTiqbEgiuioYFMSD00TP6Cz7zoTti+zVVBwVe9fEhev9cH6WnM3XU31+ABS356durAb99ZuOthnKw==} + engines: {node: '>=10.0.0'} + peerDependencies: + ibm_db: '*' + mariadb: '*' + mysql2: '*' + oracledb: '*' + pg: '*' + pg-hstore: '*' + snowflake-sdk: '*' + sqlite3: '*' + tedious: '*' + peerDependenciesMeta: + ibm_db: + optional: true + mariadb: + optional: true + mysql2: + optional: true + oracledb: + optional: true + pg: + optional: true + pg-hstore: + optional: true + snowflake-sdk: + optional: true + sqlite3: + optional: true + tedious: + optional: true + + serve-static@1.16.3: + resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} + engines: {node: '>= 0.8.0'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + simple-update-notifier@2.0.0: + resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} + engines: {node: '>=10'} + + sql-escaper@1.3.3: + resolution: {integrity: sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==} + engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'} + + stack-trace@0.0.10: + resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + streamsearch@0.1.2: + resolution: {integrity: sha512-jos8u++JKm0ARcSUTAZXOVC0mSox7Bhn6sBgty73P1f3JGf7yG2clTbBNHUdde/kdvP2FESam+vM6l8jBrNxHA==} + engines: {node: '>=0.8.0'} + + string_decoder@0.10.31: + resolution: {integrity: sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strnum@2.3.0: + resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==} + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + text-hex@1.0.0: + resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + toposort-class@1.0.1: + resolution: {integrity: sha512-OsLcGGbYF3rMjPUf8oKktyvCiUxSbqMMS39m33MAjLTC1DVIH6x3WSt63/M77ihI09+Sdfk1AXvfhCEeUmC7mg==} + + touch@3.1.1: + resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==} + hasBin: true + + triple-beam@1.4.1: + resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} + engines: {node: '>= 14.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + + undefsafe@2.0.5: + resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + validator@13.15.35: + resolution: {integrity: sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==} + engines: {node: '>= 0.10'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + winston-transport@4.9.0: + resolution: {integrity: sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==} + engines: {node: '>= 12.0.0'} + + winston@3.19.0: + resolution: {integrity: sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==} + engines: {node: '>= 12.0.0'} + + wkx@0.5.0: + resolution: {integrity: sha512-Xng/d4Ichh8uN4l0FToV/258EjMGU9MGcA0HV2d9B/ZpZB3lqQm7nkOdZdm5GhKtLLhAE7PiVQwN4eN+2YJJUg==} + + xml-naming@0.1.0: + resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} + engines: {node: '>=16.0.0'} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@aws-crypto/crc32@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.11 + tslib: 2.8.1 + + '@aws-crypto/crc32c@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.11 + tslib: 2.8.1 + + '@aws-crypto/sha1-browser@5.2.0': + dependencies: + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.11 + '@aws-sdk/util-locate-window': 3.965.6 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-browser@5.2.0': + dependencies: + '@aws-crypto/sha256-js': 5.2.0 + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.11 + '@aws-sdk/util-locate-window': 3.965.6 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-js@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.11 + tslib: 2.8.1 + + '@aws-crypto/supports-web-crypto@5.2.0': + dependencies: + tslib: 2.8.1 + + '@aws-crypto/util@5.2.0': + dependencies: + '@aws-sdk/types': 3.973.11 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-sdk/checksums@3.1000.2': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@aws-crypto/crc32c': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/core': 3.974.18 + '@aws-sdk/types': 3.973.11 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/client-s3@3.1063.0': + dependencies: + '@aws-crypto/sha1-browser': 5.2.0 + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.18 + '@aws-sdk/credential-provider-node': 3.972.52 + '@aws-sdk/middleware-flexible-checksums': 3.974.27 + '@aws-sdk/middleware-sdk-s3': 3.972.48 + '@aws-sdk/signature-v4-multi-region': 3.996.32 + '@aws-sdk/types': 3.973.11 + '@smithy/core': 3.24.6 + '@smithy/fetch-http-handler': 5.4.6 + '@smithy/node-http-handler': 4.7.7 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/core@3.974.18': + dependencies: + '@aws-sdk/types': 3.973.11 + '@aws-sdk/xml-builder': 3.972.28 + '@aws/lambda-invoke-store': 0.2.4 + '@smithy/core': 3.24.6 + '@smithy/signature-v4': 5.4.6 + '@smithy/types': 4.14.3 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.44': + dependencies: + '@aws-sdk/core': 3.974.18 + '@aws-sdk/types': 3.973.11 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.46': + dependencies: + '@aws-sdk/core': 3.974.18 + '@aws-sdk/types': 3.973.11 + '@smithy/core': 3.24.6 + '@smithy/fetch-http-handler': 5.4.6 + '@smithy/node-http-handler': 4.7.7 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.972.50': + dependencies: + '@aws-sdk/core': 3.974.18 + '@aws-sdk/credential-provider-env': 3.972.44 + '@aws-sdk/credential-provider-http': 3.972.46 + '@aws-sdk/credential-provider-login': 3.972.49 + '@aws-sdk/credential-provider-process': 3.972.44 + '@aws-sdk/credential-provider-sso': 3.972.49 + '@aws-sdk/credential-provider-web-identity': 3.972.49 + '@aws-sdk/nested-clients': 3.997.17 + '@aws-sdk/types': 3.973.11 + '@smithy/core': 3.24.6 + '@smithy/credential-provider-imds': 4.3.8 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-login@3.972.49': + dependencies: + '@aws-sdk/core': 3.974.18 + '@aws-sdk/nested-clients': 3.997.17 + '@aws-sdk/types': 3.973.11 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-node@3.972.52': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.44 + '@aws-sdk/credential-provider-http': 3.972.46 + '@aws-sdk/credential-provider-ini': 3.972.50 + '@aws-sdk/credential-provider-process': 3.972.44 + '@aws-sdk/credential-provider-sso': 3.972.49 + '@aws-sdk/credential-provider-web-identity': 3.972.49 + '@aws-sdk/types': 3.973.11 + '@smithy/core': 3.24.6 + '@smithy/credential-provider-imds': 4.3.8 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.972.44': + dependencies: + '@aws-sdk/core': 3.974.18 + '@aws-sdk/types': 3.973.11 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.972.49': + dependencies: + '@aws-sdk/core': 3.974.18 + '@aws-sdk/nested-clients': 3.997.17 + '@aws-sdk/token-providers': 3.1063.0 + '@aws-sdk/types': 3.973.11 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-web-identity@3.972.49': + dependencies: + '@aws-sdk/core': 3.974.18 + '@aws-sdk/nested-clients': 3.997.17 + '@aws-sdk/types': 3.973.11 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/middleware-flexible-checksums@3.974.27': + dependencies: + '@aws-sdk/checksums': 3.1000.2 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.972.48': + dependencies: + '@aws-sdk/core': 3.974.18 + '@aws-sdk/signature-v4-multi-region': 3.996.32 + '@aws-sdk/types': 3.973.11 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.17': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.18 + '@aws-sdk/signature-v4-multi-region': 3.996.32 + '@aws-sdk/types': 3.973.11 + '@smithy/core': 3.24.6 + '@smithy/fetch-http-handler': 5.4.6 + '@smithy/node-http-handler': 4.7.7 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.32': + dependencies: + '@aws-sdk/types': 3.973.11 + '@smithy/signature-v4': 5.4.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1063.0': + dependencies: + '@aws-sdk/core': 3.974.18 + '@aws-sdk/nested-clients': 3.997.17 + '@aws-sdk/types': 3.973.11 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/types@3.973.11': + dependencies: + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/util-locate-window@3.965.6': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.28': + dependencies: + '@smithy/types': 4.14.3 + fast-xml-parser: 5.7.3 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.2.4': {} + + '@colors/colors@1.6.0': {} + + '@dabh/diagnostics@2.0.8': + dependencies: + '@so-ric/colorspace': 1.1.6 + enabled: 2.0.0 + kuler: 2.0.0 + + '@nodable/entities@2.1.1': {} + + '@smithy/core@3.24.6': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.3.8': + dependencies: + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.4.6': + dependencies: + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@smithy/is-array-buffer@2.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/node-http-handler@4.7.7': + dependencies: + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@smithy/signature-v4@5.4.6': + dependencies: + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@smithy/types@4.14.3': + dependencies: + tslib: 2.8.1 + + '@smithy/util-buffer-from@2.2.0': + dependencies: + '@smithy/is-array-buffer': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-utf8@2.3.0': + dependencies: + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.8.1 + + '@so-ric/colorspace@1.1.6': + dependencies: + color: 5.0.3 + text-hex: 1.0.0 + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/ms@2.1.0': {} + + '@types/node@25.9.2': + dependencies: + undici-types: 7.24.6 + + '@types/triple-beam@1.3.5': {} + + '@types/validator@13.15.10': {} + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + agent-base@6.0.2: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + append-field@1.0.0: {} + + array-flatten@1.1.1: {} + + async@3.2.6: {} + + asynckit@0.4.0: {} + + aws-ssl-profiles@1.1.2: {} + + axios@1.17.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.5 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + + balanced-match@4.0.4: {} + + binary-extensions@2.3.0: {} + + body-parser@1.20.5: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.15.2 + raw-body: 2.5.3 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + bowser@2.14.1: {} + + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + buffer-equal-constant-time@1.0.1: {} + + buffer-from@1.1.2: {} + + busboy@0.2.14: + dependencies: + dicer: 0.2.5 + readable-stream: 1.1.14 + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + color-convert@3.1.3: + dependencies: + color-name: 2.1.0 + + color-name@2.1.0: {} + + color-string@2.1.4: + dependencies: + color-name: 2.1.0 + + color@5.0.3: + dependencies: + color-convert: 3.1.3 + color-string: 2.1.4 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + concat-stream@1.6.2: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 2.3.8 + typedarray: 0.0.6 + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + cookie-signature@1.0.7: {} + + cookie@0.7.2: {} + + core-util-is@1.0.3: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@4.4.3(supports-color@5.5.0): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 5.5.0 + + delayed-stream@1.0.0: {} + + denque@2.1.0: {} + + depd@2.0.0: {} + + destroy@1.2.0: {} + + dicer@0.2.5: + dependencies: + readable-stream: 1.1.14 + streamsearch: 0.1.2 + + dotenv@16.6.1: {} + + dottie@2.0.7: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + ee-first@1.1.1: {} + + enabled@2.0.0: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + escape-html@1.0.3: {} + + etag@1.8.1: {} + + express@4.22.2: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.5 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.0.7 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.2 + fresh: 0.5.2 + http-errors: 2.0.1 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.13 + proxy-addr: 2.0.7 + qs: 6.15.2 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.2 + serve-static: 1.16.3 + setprototypeof: 1.2.0 + statuses: 2.0.2 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-xml-builder@1.2.0: + dependencies: + path-expression-matcher: 1.5.0 + xml-naming: 0.1.0 + + fast-xml-parser@5.7.3: + dependencies: + '@nodable/entities': 2.1.1 + fast-xml-builder: 1.2.0 + path-expression-matcher: 1.5.0 + strnum: 2.3.0 + + fecha@4.2.3: {} + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@1.3.2: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + fn.name@1.1.0: {} + + follow-redirects@1.16.0: {} + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + forwarded@0.2.0: {} + + fresh@0.5.2: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + generate-function@2.3.1: + dependencies: + is-property: 1.0.2 + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + gopd@1.2.0: {} + + has-flag@3.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + ignore-by-default@1.0.1: {} + + inflection@1.13.4: {} + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-property@1.0.2: {} + + is-stream@2.0.1: {} + + isarray@0.0.1: {} + + isarray@1.0.0: {} + + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.8.2 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + + kuler@2.0.0: {} + + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.once@4.1.1: {} + + lodash@4.18.1: {} + + logform@2.7.0: + dependencies: + '@colors/colors': 1.6.0 + '@types/triple-beam': 1.3.5 + fecha: 4.2.3 + ms: 2.1.3 + safe-stable-stringify: 2.5.0 + triple-beam: 1.4.1 + + long@5.3.2: {} + + lru.min@1.1.4: {} + + math-intrinsics@1.1.0: {} + + media-typer@0.3.0: {} + + merge-descriptors@1.0.3: {} + + methods@1.1.2: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@1.6.0: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + + minimist@1.2.8: {} + + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + + moment-timezone@0.5.48: + dependencies: + moment: 2.30.1 + + moment@2.30.1: {} + + ms@2.0.0: {} + + ms@2.1.3: {} + + multer@1.4.4: + dependencies: + append-field: 1.0.0 + busboy: 0.2.14 + concat-stream: 1.6.2 + mkdirp: 0.5.6 + object-assign: 4.1.1 + on-finished: 2.4.1 + type-is: 1.6.18 + xtend: 4.0.2 + + mysql2@3.22.5(@types/node@25.9.2): + dependencies: + '@types/node': 25.9.2 + aws-ssl-profiles: 1.1.2 + denque: 2.1.0 + generate-function: 2.3.1 + iconv-lite: 0.7.2 + long: 5.3.2 + lru.min: 1.1.4 + named-placeholders: 1.1.6 + sql-escaper: 1.3.3 + + named-placeholders@1.1.6: + dependencies: + lru.min: 1.1.4 + + negotiator@0.6.3: {} + + nodemon@3.1.14: + dependencies: + chokidar: 3.6.0 + debug: 4.4.3(supports-color@5.5.0) + ignore-by-default: 1.0.1 + minimatch: 10.2.5 + pstree.remy: 1.1.8 + semver: 7.8.2 + simple-update-notifier: 2.0.0 + supports-color: 5.5.0 + touch: 3.1.1 + undefsafe: 2.0.5 + + normalize-path@3.0.0: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + one-time@1.0.0: + dependencies: + fn.name: 1.1.0 + + parseurl@1.3.3: {} + + path-expression-matcher@1.5.0: {} + + path-to-regexp@0.1.13: {} + + pg-connection-string@2.13.0: {} + + picomatch@2.3.2: {} + + process-nextick-args@2.0.1: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + proxy-from-env@2.1.0: {} + + pstree.remy@1.1.8: {} + + qs@6.15.2: + dependencies: + side-channel: 1.1.0 + + range-parser@1.2.1: {} + + raw-body@2.5.3: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + + readable-stream@1.1.14: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 0.0.1 + string_decoder: 0.10.31 + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + + retry-as-promised@7.1.1: {} + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safe-stable-stringify@2.5.0: {} + + safer-buffer@2.1.2: {} + + semver@7.8.2: {} + + send@0.19.2: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.1 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + sequelize-pool@7.1.0: {} + + sequelize@6.37.8(mysql2@3.22.5(@types/node@25.9.2)): + dependencies: + '@types/debug': 4.1.13 + '@types/validator': 13.15.10 + debug: 4.4.3(supports-color@5.5.0) + dottie: 2.0.7 + inflection: 1.13.4 + lodash: 4.18.1 + moment: 2.30.1 + moment-timezone: 0.5.48 + pg-connection-string: 2.13.0 + retry-as-promised: 7.1.1 + semver: 7.8.2 + sequelize-pool: 7.1.0 + toposort-class: 1.0.1 + uuid: 8.3.2 + validator: 13.15.35 + wkx: 0.5.0 + optionalDependencies: + mysql2: 3.22.5(@types/node@25.9.2) + transitivePeerDependencies: + - supports-color + + serve-static@1.16.3: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.2 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + simple-update-notifier@2.0.0: + dependencies: + semver: 7.8.2 + + sql-escaper@1.3.3: {} + + stack-trace@0.0.10: {} + + statuses@2.0.2: {} + + streamsearch@0.1.2: {} + + string_decoder@0.10.31: {} + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strnum@2.3.0: {} + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + text-hex@1.0.0: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + toposort-class@1.0.1: {} + + touch@3.1.1: {} + + triple-beam@1.4.1: {} + + tslib@2.8.1: {} + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + typedarray@0.0.6: {} + + undefsafe@2.0.5: {} + + undici-types@7.24.6: {} + + unpipe@1.0.0: {} + + util-deprecate@1.0.2: {} + + utils-merge@1.0.1: {} + + uuid@8.3.2: {} + + validator@13.15.35: {} + + vary@1.1.2: {} + + winston-transport@4.9.0: + dependencies: + logform: 2.7.0 + readable-stream: 3.6.2 + triple-beam: 1.4.1 + + winston@3.19.0: + dependencies: + '@colors/colors': 1.6.0 + '@dabh/diagnostics': 2.0.8 + async: 3.2.6 + is-stream: 2.0.1 + logform: 2.7.0 + one-time: 1.0.0 + readable-stream: 3.6.2 + safe-stable-stringify: 2.5.0 + stack-trace: 0.0.10 + triple-beam: 1.4.1 + winston-transport: 4.9.0 + + wkx@0.5.0: + dependencies: + '@types/node': 25.9.2 + + xml-naming@0.1.0: {} + + xtend@4.0.2: {} + + zod@3.25.76: {} diff --git a/backend/requirements.txt b/backend/requirements.txt deleted file mode 100644 index 0184e7c..0000000 --- a/backend/requirements.txt +++ /dev/null @@ -1,11 +0,0 @@ -fastapi==0.104.1 -sqlalchemy==2.0.23 -pymysql==1.1.1 -cryptography==41.0.7 -pydantic==2.5.2 -uvicorn==0.24.0 -python-dotenv==1.0.0 -requests==2.31.0 -python-multipart==0.0.6 -PyJWT==2.8.0 -boto3==1.34.162 diff --git a/backend/response.py b/backend/response.py deleted file mode 100644 index 56f8f50..0000000 --- a/backend/response.py +++ /dev/null @@ -1,36 +0,0 @@ -from typing import Optional, Any, List, Union -from pydantic import BaseModel, ConfigDict - - -class PageData(BaseModel): - """分页数据""" - model_config = ConfigDict(from_attributes=True) - - items: List[Any] - total: int - skip: int - limit: int - - -class ApiResponse(BaseModel): - """统一 API 响应格式""" - model_config = ConfigDict(from_attributes=True) - - code: int = 0 - msg: str = "success" - data: Optional[Any] = None - - @classmethod - def success(cls, data: Any = None, msg: str = "success"): - """成功响应""" - return cls(code=1, msg=msg, data=data) - - @classmethod - def error(cls, msg: str = "error", code: int = 0): - """错误响应""" - return cls(code=code, msg=msg, data=None) - - @classmethod - def no_data(cls, msg: str = "no data"): - """无数据响应""" - return cls(code=2, msg=msg, data=None) diff --git a/backend/restart.bat b/backend/restart.bat deleted file mode 100644 index 4075089..0000000 --- a/backend/restart.bat +++ /dev/null @@ -1,48 +0,0 @@ -@echo off -echo ================================================ -echo Audio Dashboard API - 重启服务 -echo ================================================ -echo. - -REM 切换到脚本所在目录 -cd /d "%~dp0" - -echo [1/3] 停止服务... -echo. - -REM 查找占用 8002 端口的进程 -set PID= -for /f "tokens=5" %%a in ('netstat -ano ^| findstr :8002') do ( - set PID=%%a - goto :found -) - -:found -if defined PID ( - echo 找到进程 PID: %PID% - taskkill /F /PID %PID% >nul 2>&1 - if errorlevel 1 ( - echo 无法终止进程 %PID%,可能已经停止 - ) else ( - echo 服务已停止 - ) - timeout /t 2 /nobreak >nul -) else ( - echo 端口 8002 没有被占用 -) - -echo. -echo [2/3] 等待端口释放... -timeout /t 2 /nobreak >nul - -echo. -echo [3/3] 启动服务... -echo. -echo 服务运行在 http://localhost:8002 -echo API 文档:http://localhost:8002/docs -echo. -echo 按 Ctrl+C 停止服务 -echo ================================================ -echo. - -python main.py diff --git a/backend/restart.sh b/backend/restart.sh new file mode 100755 index 0000000..659132d --- /dev/null +++ b/backend/restart.sh @@ -0,0 +1,42 @@ +#!/bin/bash +echo "================================================" +echo "Audio Dashboard API - 重启服务" +echo "================================================" +echo "" + +# 切换到脚本所在目录 +cd "$(dirname "$0")" + +echo "[1/3] 停止服务..." +echo "" + +# 查找占用 8083 端口的进程 +PID=$(lsof -ti :8083 2>/dev/null) + +if [ -n "$PID" ]; then + echo "找到进程 PID: $PID" + kill -9 "$PID" 2>/dev/null + if [ $? -eq 0 ]; then + echo "服务已停止" + else + echo "无法终止进程 $PID,可能已经停止" + fi + sleep 2 +else + echo "端口 8083 没有被占用" +fi + +echo "" +echo "[2/3] 等待端口释放..." +sleep 2 + +echo "" +echo "[3/3] 启动服务..." +echo "" +echo "服务运行在 http://localhost:8083" +echo "" +echo "按 Ctrl+C 停止服务" +echo "================================================" +echo "" + +node src/app.js diff --git a/backend/routes/__init__.py b/backend/routes/__init__.py deleted file mode 100644 index 8da2ac9..0000000 --- a/backend/routes/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from routes.brands import router as brands_router -from routes.models import router as models_router -from routes.ota import router as ota_router - -__all__ = ["brands_router", "models_router", "ota_router"] diff --git a/backend/routes/auth.py b/backend/routes/auth.py deleted file mode 100644 index 384709e..0000000 --- a/backend/routes/auth.py +++ /dev/null @@ -1,36 +0,0 @@ -import logging - -from fastapi import APIRouter -from pydantic import BaseModel, Field - -from response import ApiResponse -from security import create_access_token, verify_credentials - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/api/auth", tags=["auth"]) - - -class LoginBody(BaseModel): - username: str = Field(..., min_length=1, max_length=64) - password: str = Field(..., min_length=1, max_length=128) - - -@router.post("/login", response_model=ApiResponse) -def login(body: LoginBody): - """控制台登录,成功后返回 JWT(有效期 12 小时)""" - if not verify_credentials(body.username.strip(), body.password): - logger.warning("Login failed for username=%s", body.username) - return ApiResponse(code=0, msg="用户名或密码错误", data=None) - token = create_access_token() - ttl_seconds = 12 * 60 * 60 - logger.info("User %s logged in", body.username.strip()) - return ApiResponse( - code=1, - msg="success", - data={ - "access_token": token, - "token_type": "bearer", - "expires_in": ttl_seconds, - }, - ) diff --git a/backend/routes/brands.py b/backend/routes/brands.py deleted file mode 100644 index 02301e5..0000000 --- a/backend/routes/brands.py +++ /dev/null @@ -1,148 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy.orm import Session -from typing import List, Optional -import logging -from database import get_db -from models.brand import Brand -from schemas import BrandCreate, BrandUpdate, BrandResponse -from response import ApiResponse, PageData - -# Configure logging -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/api/brands", tags=["brands"]) - - -@router.get("/", response_model=ApiResponse) -def get_brands( - skip: int = Query(0, ge=0, description="跳过记录数"), - limit: int = Query(100, ge=1, le=1000, description="返回记录数"), - name: Optional[str] = Query(None, description="品牌名称(支持模糊查询)"), - db: Session = Depends(get_db) -): - """获取所有品牌列表(支持按名称模糊查询)""" - try: - logger.info(f"Getting brands: skip={skip}, limit={limit}, name={name}") - query = db.query(Brand) - if name: - query = query.filter(Brand.name.like(f"%{name}%")) - - total = query.count() - brands = query.offset(skip).limit(limit).all() - - logger.info(f"Found {len(brands)} brands, total={total}") - - if not brands: - logger.warning("No brands found") - return ApiResponse(code=2, msg="empty", data=None) - - # 转换为字典列表 - brands_data = [brand.to_dict() for brand in brands] - - return ApiResponse(code=1, msg="success", data={"items": brands_data, "total": total, "skip": skip, "limit": limit}) - except Exception as e: - logger.error(f"Error getting brands: {str(e)}", exc_info=True) - return ApiResponse(code=0, msg="error", data=None) - - -@router.get("/{brand_id}", response_model=ApiResponse) -def get_brand(brand_id: int, db: Session = Depends(get_db)): - """获取单个品牌""" - try: - logger.info(f"Getting brand: id={brand_id}") - brand = db.query(Brand).filter(Brand.id == brand_id).first() - if not brand: - logger.warning(f"Brand not found: id={brand_id}") - return ApiResponse(code=2, msg="empty", data=None) - logger.info(f"Brand found: {brand.to_dict()}") - return ApiResponse(code=1, msg="success", data=brand.to_dict()) - except Exception as e: - logger.error(f"Error getting brand {brand_id}: {str(e)}", exc_info=True) - return ApiResponse(code=0, msg="error", data=None) - - -@router.post("/", response_model=ApiResponse) -def create_brand(brand: BrandCreate, db: Session = Depends(get_db)): - """创建新品牌""" - try: - name = (brand.name or "").strip() - if not name: - return ApiResponse.error(msg="品牌名称不能为空", code=0) - logger.info(f"Creating brand: name={name}") - existing = db.query(Brand).filter(Brand.name == name).first() - if existing: - logger.warning(f"Brand already exists: name={name}") - return ApiResponse.error(msg="品牌名称已存在", code=0) - - db_brand = Brand(name=name) - db.add(db_brand) - db.commit() - db.refresh(db_brand) - logger.info(f"Brand created successfully: id={db_brand.id}, name={db_brand.name}") - return ApiResponse.success(data=db_brand.to_dict(), msg="品牌创建成功") - except Exception as e: - logger.error(f"Error creating brand: {str(e)}", exc_info=True) - db.rollback() - return ApiResponse.error(msg=f"创建失败:{str(e)}", code=0) - - -@router.put("/{brand_id}", response_model=ApiResponse) -def update_brand(brand_id: int, brand: BrandUpdate, db: Session = Depends(get_db)): - """更新品牌""" - try: - logger.info(f"Updating brand: id={brand_id}, data={brand.model_dump()}") - db_brand = db.query(Brand).filter(Brand.id == brand_id).first() - if not db_brand: - logger.warning(f"Brand not found: id={brand_id}") - return ApiResponse.no_data(msg="品牌不存在") - - if brand.name is None: - db.commit() - db.refresh(db_brand) - return ApiResponse.success(data=db_brand.to_dict(), msg="品牌更新成功") - - new_name = brand.name.strip() - if not new_name: - return ApiResponse.error(msg="品牌名称不能为空", code=0) - - current_name = (db_brand.name or "").strip() - if new_name != current_name: - # 排除当前记录;MySQL 大小写不敏感时,仅改大小写也会命中自身 - existing = ( - db.query(Brand) - .filter(Brand.name == new_name, Brand.id != brand_id) - .first() - ) - if existing: - logger.warning(f"Brand name already exists: name={new_name}") - return ApiResponse.error(msg="品牌名称已存在", code=0) - db_brand.name = new_name - - db.commit() - db.refresh(db_brand) - logger.info(f"Brand updated successfully: id={db_brand.id}") - return ApiResponse.success(data=db_brand.to_dict(), msg="品牌更新成功") - except Exception as e: - logger.error(f"Error updating brand {brand_id}: {str(e)}", exc_info=True) - db.rollback() - return ApiResponse.error(msg=f"更新失败:{str(e)}", code=0) - - -@router.delete("/{brand_id}", response_model=ApiResponse) -def delete_brand(brand_id: int, db: Session = Depends(get_db)): - """删除品牌""" - try: - logger.info(f"Deleting brand: id={brand_id}") - db_brand = db.query(Brand).filter(Brand.id == brand_id).first() - if not db_brand: - logger.warning(f"Brand not found: id={brand_id}") - return ApiResponse.no_data(msg="品牌不存在") - - db.delete(db_brand) - db.commit() - logger.info(f"Brand deleted successfully: id={brand_id}") - return ApiResponse.success(msg="删除成功") - except Exception as e: - logger.error(f"Error deleting brand {brand_id}: {str(e)}", exc_info=True) - db.rollback() - return ApiResponse.error(msg=f"删除失败:{str(e)}", code=0) diff --git a/backend/routes/models.py b/backend/routes/models.py deleted file mode 100644 index cb8f151..0000000 --- a/backend/routes/models.py +++ /dev/null @@ -1,390 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException, Query, Body, UploadFile, File, Form -from pydantic import BaseModel, ConfigDict, Field -from sqlalchemy.orm import Session -from sqlalchemy import asc, desc -from typing import List, Optional -import logging -from database import get_db -from models.model import Model -from response import ApiResponse, PageData -from curve_client import fetch_and_validate_curve -import requests -import os -import shutil -from pathlib import Path - -# Configure logging -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/api/models", tags=["models"]) - - -class PushToSearchBody(BaseModel): - """推送 Meilisearch 请求体;字段名 model_ids 需关闭 protected_namespaces 避免 Pydantic 警告。""" - - model_config = ConfigDict(protected_namespaces=()) - - model_ids: List[int] = Field(..., min_length=1, description="型号 ID 列表") - - -# Meilisearch 配置(从环境变量读取) -MEILISEARCH_URL = os.getenv("MEILISEARCH_URL", "http://localhost:7700") -MEILISEARCH_API_KEY = os.getenv("MEILISEARCH_API_KEY", "") -MEILISEARCH_INDEX = os.getenv("MEILISEARCH_INDEX", "models") - -# 文件上传配置 -UPLOAD_FOLDER = Path("/data/project/autoeq/measurements") -ALLOWED_EXTENSIONS = {'.csv', '.txt', '.json'} - -# 记录上传路径配置 -logger.info(f"UPLOAD_FOLDER configured as: {UPLOAD_FOLDER}") -logger.info(f"UPLOAD_FOLDER absolute path: {UPLOAD_FOLDER.absolute()}") - - -@router.get("/", response_model=ApiResponse) -def get_models( - skip: int = Query(0, ge=0, description="跳过记录数"), - limit: int = Query(100, ge=1, le=1000, description="返回记录数"), - brand_name: Optional[str] = Query(None, description="按品牌名称模糊查询"), - name: Optional[str] = Query(None, description="按型号名称模糊查询"), - sort_by: str = Query("id", description="排序字段:id 或 create_at"), - sort_order: str = Query("desc", description="排序方向:asc 或 desc"), - db: Session = Depends(get_db) -): - """获取所有型号列表(支持按品牌名称和型号名称模糊查询;支持按 id、create_at 排序)""" - try: - logger.info( - f"Getting models: skip={skip}, limit={limit}, brand_name={brand_name}, name={name}, " - f"sort_by={sort_by}, sort_order={sort_order}" - ) - query = db.query(Model) - if brand_name: - query = query.filter(Model.brand_name.like(f"%{brand_name}%")) - if name: - query = query.filter(Model.name.like(f"%{name}%")) - - total = query.count() - - sort_columns = {"id": Model.id, "create_at": Model.create_at} - order_col = sort_columns.get(sort_by, Model.id) - order_dir = (sort_order or "desc").lower() - if order_dir not in ("asc", "desc"): - order_dir = "desc" - if order_dir == "desc": - query = query.order_by(desc(order_col)) - else: - query = query.order_by(asc(order_col)) - - models = query.offset(skip).limit(limit).all() - - logger.info(f"Found {len(models)} models, total={total}") - - if not models: - logger.warning("No models found") - return ApiResponse(code=2, msg="empty", data=None) - - # 转换为字典列表 - models_data = [model.to_dict() for model in models] - - return ApiResponse(code=1, msg="success", data={"items": models_data, "total": total, "skip": skip, "limit": limit}) - except Exception as e: - logger.error(f"Error getting models: {str(e)}", exc_info=True) - return ApiResponse(code=0, msg="error", data=None) - - -@router.get("/{model_id}", response_model=ApiResponse) -def get_model(model_id: int, db: Session = Depends(get_db)): - """获取单个型号""" - try: - logger.info(f"Getting model: id={model_id}") - model = db.query(Model).filter(Model.id == model_id).first() - if not model: - logger.warning(f"Model not found: id={model_id}") - return ApiResponse(code=2, msg="empty", data=None) - logger.info(f"Model found: {model.to_dict()}") - return ApiResponse(code=1, msg="success", data=model.to_dict()) - except Exception as e: - logger.error(f"Error getting model {model_id}: {str(e)}", exc_info=True) - return ApiResponse(code=0, msg="error", data=None) - - -@router.post("/", response_model=ApiResponse) -def create_model( - brand_name: str = Form(...), - name: str = Form(...), - form: str = Form(None), - rig: str = Form(None), - source: str = Form(None), - eq_key: str = Form(None), - measurement_file: UploadFile = File(None), - db: Session = Depends(get_db) -): - """创建新型号(支持文件上传)""" - try: - logger.info(f"Creating model: brand_name={brand_name}, name={name}, form={form}, source={source}") - # 检查是否已存在 - existing = db.query(Model).filter( - Model.brand_name == brand_name, - Model.name == name - ).first() - if existing: - logger.warning(f"Model already exists: brand_name={brand_name}, name={name}") - return ApiResponse(code=0, msg="该品牌下型号名称已存在", data=None) - - # 处理文件上传 - measurement_filename = None - if measurement_file and measurement_file.filename: - logger.info(f"Uploading measurement file: {measurement_file.filename}") - logger.info(f"UPLOAD_FOLDER is: {UPLOAD_FOLDER}") - # 验证文件扩展名 - file_ext = os.path.splitext(measurement_file.filename)[1].lower() - if file_ext not in ALLOWED_EXTENSIONS: - logger.error(f"Unsupported file format: {file_ext}") - return ApiResponse(code=0, msg=f"不支持的文件格式:{file_ext}", data=None) - - # 创建保存路径:autoeq/measurements/{source}/data/{form}/{filename} - save_dir = UPLOAD_FOLDER / source / "data" / form - logger.info(f"Creating directory: {save_dir}") - save_dir.mkdir(parents=True, exist_ok=True) - - # 保存文件(保留原文件名) - file_path = save_dir / measurement_file.filename - logger.info(f"Saving file to: {file_path}") - with open(file_path, "wb") as buffer: - shutil.copyfileobj(measurement_file.file, buffer) - - measurement_filename = measurement_file.filename - logger.info(f"File saved: {file_path}") - - db_model = Model( - brand_name=brand_name, - name=name, - form=form, - rig=rig, - source=source, - eq_key=eq_key - ) - db.add(db_model) - db.commit() - db.refresh(db_model) - logger.info(f"Model created successfully: id={db_model.id}") - return ApiResponse(code=1, msg="success", data=db_model.to_dict()) - except Exception as e: - logger.error(f"Error creating model: {str(e)}", exc_info=True) - db.rollback() - return ApiResponse(code=0, msg="error", data=None) - - -@router.put("/{model_id}", response_model=ApiResponse) -def update_model( - model_id: int, - brand_name: Optional[str] = Form(None), - name: Optional[str] = Form(None), - form: Optional[str] = Form(None), - rig: Optional[str] = Form(None), - source: Optional[str] = Form(None), - eq_key: Optional[str] = Form(None), - measurement_file: UploadFile = File(None), - db: Session = Depends(get_db), -): - """更新型号(与 POST 一致,支持 multipart/form-data,可选上传频响文件)""" - try: - logger.info( - f"Updating model: id={model_id}, brand_name={brand_name}, name={name}, form={form}" - ) - db_model = db.query(Model).filter(Model.id == model_id).first() - if not db_model: - logger.warning(f"Model not found: id={model_id}") - return ApiResponse(code=2, msg="empty", data=None) - - new_brand_name = db_model.brand_name if brand_name is None else brand_name - new_name = db_model.name if name is None else name - if new_brand_name != db_model.brand_name or new_name != db_model.name: - existing = ( - db.query(Model) - .filter(Model.brand_name == new_brand_name, Model.name == new_name) - .first() - ) - if existing: - logger.warning( - f"Model already exists: brand_name={new_brand_name}, name={new_name}" - ) - return ApiResponse(code=0, msg="该品牌下型号名称已存在", data=None) - - eff_source = db_model.source if source is None else source - eff_form = db_model.form if form is None else form - if measurement_file and measurement_file.filename: - logger.info(f"Uploading measurement file: {measurement_file.filename}") - file_ext = os.path.splitext(measurement_file.filename)[1].lower() - if file_ext not in ALLOWED_EXTENSIONS: - logger.error(f"Unsupported file format: {file_ext}") - return ApiResponse(code=0, msg=f"不支持的文件格式:{file_ext}", data=None) - if not eff_source or not eff_form: - return ApiResponse(code=0, msg="上传频响文件需要来源与形式字段", data=None) - save_dir = UPLOAD_FOLDER / eff_source / "data" / eff_form - save_dir.mkdir(parents=True, exist_ok=True) - file_path = save_dir / measurement_file.filename - with open(file_path, "wb") as buffer: - shutil.copyfileobj(measurement_file.file, buffer) - logger.info(f"File saved: {file_path}") - - if brand_name is not None: - db_model.brand_name = brand_name - if name is not None: - db_model.name = name - if form is not None: - db_model.form = form - if rig is not None: - db_model.rig = rig - if source is not None: - db_model.source = source - if eq_key is not None: - db_model.eq_key = eq_key - - db.commit() - db.refresh(db_model) - logger.info(f"Model updated successfully: id={db_model.id}") - return ApiResponse(code=1, msg="success", data=db_model.to_dict()) - except Exception as e: - logger.error(f"Error updating model {model_id}: {str(e)}", exc_info=True) - db.rollback() - return ApiResponse(code=0, msg="error", data=None) - - -@router.delete("/{model_id}", response_model=ApiResponse) -def delete_model(model_id: int, db: Session = Depends(get_db)): - """删除型号""" - try: - logger.info(f"Deleting model: id={model_id}") - db_model = db.query(Model).filter(Model.id == model_id).first() - if not db_model: - logger.warning(f"Model not found: id={model_id}") - return ApiResponse(code=2, msg="empty", data=None) - - db.delete(db_model) - db.commit() - logger.info(f"Model deleted successfully: id={model_id}") - return ApiResponse(code=1, msg="success", data=None) - except Exception as e: - logger.error(f"Error deleting model {model_id}: {str(e)}", exc_info=True) - db.rollback() - return ApiResponse(code=0, msg="error", data=None) - - -def _get_models_by_ids(model_ids: List[int], db: Session): - if not model_ids: - return None, ApiResponse(code=0, msg="请选择要推送的型号", data=None) - models = db.query(Model).filter(Model.id.in_(model_ids)).all() - if not models: - return None, ApiResponse(code=0, msg="未找到选中的型号数据", data=None) - return models, None - - -def _validate_models_curve(models) -> List[dict]: - validation_errors = [] - for model in models: - ok, reason = fetch_and_validate_curve( - model.brand_name, - model.name, - model.form or "", - ) - if not ok: - validation_errors.append( - { - "id": model.id, - "brand_name": model.brand_name, - "name": model.name, - "form": model.form, - "reason": reason, - } - ) - return validation_errors - - -def _build_meilisearch_documents(models) -> List[dict]: - push_data = [] - for model in models: - model_dict = { - "id": model.id, - "brand_name": model.brand_name, - "name": model.name, - "rig": model.rig, - "form": model.form, - "source": model.source, - } - push_data.append({k: v for k, v in model_dict.items() if v is not None}) - return push_data - - -def _push_documents_to_meilisearch(push_data: List[dict]) -> ApiResponse: - headers = { - "Authorization": f"Bearer {MEILISEARCH_API_KEY}", - "Content-Type": "application/json", - } - try: - response = requests.post( - f"{MEILISEARCH_URL}/indexes/{MEILISEARCH_INDEX}/documents", - json=push_data, - headers=headers, - timeout=30, - ) - if response.status_code not in [200, 202]: - return ApiResponse(code=0, msg=f"推送到 Meilisearch 失败:{response.text}", data=None) - - task_info = response.json() - return ApiResponse( - code=1, - msg="success", - data={ - "pushed_count": len(push_data), - "task_uid": task_info.get("taskUid"), - "models": push_data, - }, - ) - except requests.exceptions.RequestException as e: - return ApiResponse(code=0, msg=f"连接 Meilisearch 失败:{str(e)}", data=None) - - -@router.post("/push-to-search/validate", response_model=ApiResponse) -def validate_push_to_search(body: PushToSearchBody, db: Session = Depends(get_db)): - """推送前校验:拉取并验证曲线 parametric_eq 数据""" - try: - models, err = _get_models_by_ids(body.model_ids, db) - if err: - return err - - validation_errors = _validate_models_curve(models) - if validation_errors: - names = "、".join( - f"{e['brand_name']} {e['name']}" for e in validation_errors[:5] - ) - suffix = " 等" if len(validation_errors) > 5 else "" - return ApiResponse( - code=0, - msg=f"曲线数据校验未通过:{names}{suffix}", - data={"errors": validation_errors, "validated_count": 0}, - ) - - return ApiResponse( - code=1, - msg="success", - data={"validated_count": len(models)}, - ) - except Exception as e: - logger.error("validate_push_to_search failed: %s", e, exc_info=True) - return ApiResponse(code=0, msg=f"校验失败:{str(e)}", data=None) - - -@router.post("/push-to-search", response_model=ApiResponse) -def push_to_search(body: PushToSearchBody, db: Session = Depends(get_db)): - """推送型号数据到 Meilisearch(需先通过 validate 接口)""" - try: - models, err = _get_models_by_ids(body.model_ids, db) - if err: - return err - - push_data = _build_meilisearch_documents(models) - return _push_documents_to_meilisearch(push_data) - except Exception as e: - logger.error("push_to_search failed: %s", e, exc_info=True) - return ApiResponse(code=0, msg=f"推送失败:{str(e)}", data=None) diff --git a/backend/routes/ota.py b/backend/routes/ota.py deleted file mode 100644 index 23e210f..0000000 --- a/backend/routes/ota.py +++ /dev/null @@ -1,266 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException, Query, Body, UploadFile, File, Form -from sqlalchemy.orm import Session -from typing import List, Optional -import logging -from dotenv import load_dotenv -from database import get_db -from models.ota import Ota -from schemas import OtaCreate, OtaUpdate, OtaResponse -from response import ApiResponse, PageData -from ota_storage import ( - OTA_MODEL_X8, - OTA_MODEL_X9, - OTA_UPLOAD_MODELS, - read_upload_content_and_md5, - save_x9_package_local, - upload_x8_package_to_s3, -) - -load_dotenv(override=True) - -# Configure logging -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/api/ota", tags=["ota"]) - - -@router.post("/upload-package", response_model=ApiResponse) -async def upload_ota_package( - model: str = Form(..., description="设备型号"), - package_file: UploadFile = File(..., description="OTA 升级包"), -): - """上传 OTA 升级包:计算 MD5;X8 上传 S3 为 LUXSIN_X8.PKG,X9 本地保存为 LUXSIN.PKG""" - try: - model = (model or "").strip() - if model not in OTA_UPLOAD_MODELS: - return ApiResponse( - code=0, - msg=f"当前仅支持为 {OTA_MODEL_X8}、{OTA_MODEL_X9} 上传升级包", - data=None, - ) - if not package_file or not package_file.filename: - return ApiResponse(code=0, msg="请选择升级包文件", data=None) - - logger.info( - "Uploading OTA package: model=%s, filename=%s", - model, - package_file.filename, - ) - - content, md5_hex = await read_upload_content_and_md5(package_file) - - if model == OTA_MODEL_X9: - saved_name, download_url = save_x9_package_local(content, md5_hex) - return ApiResponse( - code=1, - msg="success", - data={ - "md5": md5_hex, - "filename": saved_name, - "url": download_url, - }, - ) - - if model == OTA_MODEL_X8: - saved_name, download_url, s3_key = upload_x8_package_to_s3( - content, md5_hex - ) - return ApiResponse( - code=1, - msg="success", - data={ - "md5": md5_hex, - "filename": saved_name, - "url": download_url, - "s3_key": s3_key, - }, - ) - - return ApiResponse(code=0, msg="不支持的设备型号", data=None) - except ValueError as e: - return ApiResponse(code=0, msg=str(e), data=None) - except Exception as e: - logger.error("Error uploading OTA package: %s", e, exc_info=True) - return ApiResponse(code=0, msg=f"上传失败:{e}", data=None) - - -@router.get("/", response_model=ApiResponse) -def get_ota_list( - skip: int = Query(0, ge=0, description="跳过记录数"), - limit: int = Query(100, ge=1, le=1000, description="返回记录数"), - verCode: Optional[int] = Query(None, description="按版本号查询"), - verName: Optional[str] = Query(None, description="按版本名称模糊查询"), - model: Optional[str] = Query(None, description="按设备型号模糊查询"), - status: Optional[int] = Query(None, ge=0, le=1, description="按状态查询"), - db: Session = Depends(get_db) -): - """获取 OTA 版本列表(支持多种筛选条件)""" - try: - logger.info(f"Getting OTA list: skip={skip}, limit={limit}, verCode={verCode}, verName={verName}, model={model}, status={status}") - query = db.query(Ota) - - # 应用筛选条件 - if verCode is not None: - query = query.filter(Ota.verCode == verCode) - if verName: - query = query.filter(Ota.verName.like(f"%{verName}%")) - if model: - query = query.filter(Ota.model.like(f"%{model}%")) - if status is not None: - query = query.filter(Ota.status == status) - - total = query.count() - query = query.order_by(Ota.verCode.desc()) - ota_list = query.offset(skip).limit(limit).all() - - logger.info(f"Found {len(ota_list)} OTA records, total={total}") - - if not ota_list: - logger.warning("No OTA records found") - return ApiResponse(code=2, msg="empty", data=None) - - # 转换为字典列表 - ota_data = [ota.to_dict() for ota in ota_list] - - return ApiResponse(code=1, msg="success", data={"items": ota_data, "total": total, "skip": skip, "limit": limit}) - except Exception as e: - logger.error(f"Error getting OTA list: {str(e)}", exc_info=True) - return ApiResponse(code=0, msg="error", data=None) - - -@router.get("/{ota_id}", response_model=ApiResponse) -def get_ota(ota_id: int, db: Session = Depends(get_db)): - """获取单个 OTA 版本""" - try: - logger.info(f"Getting OTA: id={ota_id}") - ota = db.query(Ota).filter(Ota.id == ota_id).first() - if not ota: - logger.warning(f"OTA not found: id={ota_id}") - return ApiResponse(code=2, msg="empty", data=None) - logger.info(f"OTA found: {ota.to_dict()}") - return ApiResponse(code=1, msg="success", data=ota.to_dict()) - except Exception as e: - logger.error(f"Error getting OTA {ota_id}: {str(e)}", exc_info=True) - return ApiResponse(code=0, msg="error", data=None) - - -@router.post("/", response_model=ApiResponse) -def create_ota(ota: OtaCreate, db: Session = Depends(get_db)): - """创建新 OTA 版本""" - try: - logger.info(f"Creating OTA: verCode={ota.verCode}, verName={ota.verName}, model={ota.model}") - - # 检查版本号是否已存在 - existing = db.query(Ota).filter( - Ota.verCode == ota.verCode, - Ota.model == ota.model - ).first() - if existing: - logger.warning(f"OTA version already exists: verCode={ota.verCode}, model={ota.model}") - return ApiResponse(code=0, msg="该版本已存在", data=None) - - db_ota = Ota(**ota.model_dump()) - db.add(db_ota) - db.commit() - db.refresh(db_ota) - logger.info(f"OTA created successfully: id={db_ota.id}, verCode={db_ota.verCode}") - return ApiResponse(code=1, msg="success", data=db_ota.to_dict()) - except Exception as e: - logger.error(f"Error creating OTA: {str(e)}", exc_info=True) - db.rollback() - return ApiResponse(code=0, msg="error", data=None) - - -@router.put("/{ota_id}", response_model=ApiResponse) -def update_ota(ota_id: int, ota: OtaUpdate, db: Session = Depends(get_db)): - """更新 OTA 版本""" - try: - logger.info(f"Updating OTA: id={ota_id}, data={ota.model_dump()}") - db_ota = db.query(Ota).filter(Ota.id == ota_id).first() - if not db_ota: - logger.warning(f"OTA not found: id={ota_id}") - return ApiResponse(code=2, msg="empty", data=None) - - # 如果更新版本号,检查是否冲突 - if ota.verCode or ota.model: - new_verCode = ota.verCode if ota.verCode is not None else db_ota.verCode - new_model = ota.model if ota.model is not None else db_ota.model - - if new_verCode != db_ota.verCode or new_model != db_ota.model: - existing = db.query(Ota).filter( - Ota.verCode == new_verCode, - Ota.model == new_model - ).first() - if existing: - logger.warning(f"OTA version already exists: verCode={new_verCode}, model={new_model}") - return ApiResponse(code=0, msg="该版本已存在", data=None) - - # 更新字段 - update_data = ota.model_dump(exclude_unset=True) - for field, value in update_data.items(): - setattr(db_ota, field, value) - - db.commit() - db.refresh(db_ota) - logger.info(f"OTA updated successfully: id={db_ota.id}") - return ApiResponse(code=1, msg="success", data=db_ota.to_dict()) - except Exception as e: - logger.error(f"Error updating OTA {ota_id}: {str(e)}", exc_info=True) - db.rollback() - return ApiResponse(code=0, msg="error", data=None) - - -@router.delete("/{ota_id}", response_model=ApiResponse) -def delete_ota(ota_id: int, db: Session = Depends(get_db)): - """删除 OTA 版本""" - try: - logger.info(f"Deleting OTA: id={ota_id}") - db_ota = db.query(Ota).filter(Ota.id == ota_id).first() - if not db_ota: - logger.warning(f"OTA not found: id={ota_id}") - return ApiResponse(code=2, msg="empty", data=None) - - db.delete(db_ota) - db.commit() - logger.info(f"OTA deleted successfully: id={ota_id}") - return ApiResponse(code=1, msg="success", data=None) - except Exception as e: - logger.error(f"Error deleting OTA {ota_id}: {str(e)}", exc_info=True) - db.rollback() - return ApiResponse(code=0, msg="error", data=None) - - -@router.get("/latest/check", response_model=ApiResponse) -def check_latest_ta( - currentVerCode: int = Query(..., description="当前版本号"), - model: str = Query(..., description="设备型号"), - hw: Optional[int] = Query(None, description="硬件版本号"), - db: Session = Depends(get_db) -): - """检查是否有可用的 OTA 升级""" - try: - logger.info(f"Checking latest OTA: currentVerCode={currentVerCode}, model={model}, hw={hw}") - - # 构建查询条件 - query = db.query(Ota).filter( - Ota.status == 1, - Ota.verCode > currentVerCode, - Ota.model == model - ) - - # 如果提供了硬件版本号,添加筛选条件 - if hw is not None: - query = query.filter(Ota.hw == hw) - - # 按版本号降序排列,获取最新版本 - latest_ota = query.order_by(Ota.verCode.desc()).first() - - if not latest_ota: - logger.info(f"No available OTA found for model={model}, currentVerCode={currentVerCode}") - return ApiResponse(code=2, msg="empty", data=None) - - logger.info(f"Latest OTA found: verCode={latest_ota.verCode}, verName={latest_ota.verName}") - return ApiResponse(code=1, msg="success", data=latest_ota.to_dict()) - except Exception as e: - logger.error(f"Error checking latest OTA: {str(e)}", exc_info=True) - return ApiResponse(code=0, msg="error", data=None) diff --git a/backend/schemas.py b/backend/schemas.py deleted file mode 100644 index 650983b..0000000 --- a/backend/schemas.py +++ /dev/null @@ -1,106 +0,0 @@ -from pydantic import BaseModel, Field -from typing import Optional -from datetime import datetime - - -# Brand Schemas -class BrandBase(BaseModel): - name: str = Field(..., min_length=1, max_length=100, description="品牌名称") - - -class BrandCreate(BrandBase): - pass - - -class BrandUpdate(BaseModel): - name: Optional[str] = Field(None, min_length=1, max_length=100, description="品牌名称") - - -class BrandResponse(BrandBase): - id: int - - class Config: - from_attributes = True - - -# Model Schemas -class ModelBase(BaseModel): - brand_name: str = Field(..., min_length=1, max_length=100, description="品牌名称") - name: str = Field(..., min_length=1, max_length=100, description="型号名称") - form: Optional[str] = Field(None, max_length=100, description="形式") - rig: Optional[str] = Field(None, max_length=100, description="阻抗") - source: Optional[str] = Field(None, max_length=100, description="来源") - eq_key: Optional[str] = Field(None, max_length=255, description="EQ 键") - - -class ModelCreate(ModelBase): - pass - - -class ModelUpdate(BaseModel): - brand_name: Optional[str] = Field(None, min_length=1, max_length=100, description="品牌名称") - name: Optional[str] = Field(None, min_length=1, max_length=100, description="型号名称") - form: Optional[str] = Field(None, max_length=100, description="形式") - rig: Optional[str] = Field(None, max_length=100, description="阻抗") - source: Optional[str] = Field(None, max_length=100, description="来源") - eq_key: Optional[str] = Field(None, max_length=255, description="EQ 键") - - -class ModelResponse(ModelBase): - id: int - create_at: datetime - - class Config: - from_attributes = True - - -# OTA Schemas -class OtaBase(BaseModel): - verCode: int = Field(..., description="版本号(整数)") - verName: str = Field(..., min_length=1, max_length=20, description="版本名称") - url: str = Field(..., max_length=255, description="升级包 URL") - md5: str = Field(..., min_length=32, max_length=32, description="升级包 MD5") - force: Optional[int] = Field(0, ge=0, le=1, description="是否强升;0-否") - desc: Optional[str] = Field(None, max_length=255, description="描述") - model: Optional[str] = Field(None, max_length=100, description="对应的设备型号") - hw: Optional[int] = Field(0, description="硬件版本号") - target: Optional[int] = Field(0, ge=0, le=1, description="是否定向,1-是,0-否") - beta: Optional[int] = Field(0, ge=0, le=1, description="是否灰度,1-是,0-否") - pawVerCode: Optional[int] = Field(0, description="配对版本号") - pawVerName: Optional[str] = Field("", max_length=20, description="配对版本名称") - pawUrl: Optional[str] = Field("", max_length=255, description="配对版本 URL") - pawMd5: Optional[str] = Field("", min_length=32, max_length=32, description="配对版本 MD5") - startTime: Optional[datetime] = Field(None, description="升级开始时间") - endTime: Optional[datetime] = Field(None, description="升级结束时间") - status: Optional[int] = Field(1, ge=0, le=1, description="是否可用") - - -class OtaCreate(OtaBase): - pass - - -class OtaUpdate(BaseModel): - verCode: Optional[int] = Field(None, description="版本号(整数)") - verName: Optional[str] = Field(None, min_length=1, max_length=20, description="版本名称") - url: Optional[str] = Field(None, max_length=255, description="升级包 URL") - md5: Optional[str] = Field(None, min_length=32, max_length=32, description="升级包 MD5") - force: Optional[int] = Field(None, ge=0, le=1, description="是否强升;0-否") - desc: Optional[str] = Field(None, max_length=255, description="描述") - model: Optional[str] = Field(None, max_length=100, description="对应的设备型号") - hw: Optional[int] = Field(None, description="硬件版本号") - target: Optional[int] = Field(None, ge=0, le=1, description="是否定向,1-是,0-否") - beta: Optional[int] = Field(None, ge=0, le=1, description="是否灰度,1-是,0-否") - pawVerCode: Optional[int] = Field(None, description="配对版本号") - pawVerName: Optional[str] = Field(None, max_length=20, description="配对版本名称") - pawUrl: Optional[str] = Field(None, max_length=255, description="配对版本 URL") - pawMd5: Optional[str] = Field(None, min_length=32, max_length=32, description="配对版本 MD5") - startTime: Optional[datetime] = Field(None, description="升级开始时间") - endTime: Optional[datetime] = Field(None, description="升级结束时间") - status: Optional[int] = Field(None, ge=0, le=1, description="是否可用") - - -class OtaResponse(OtaBase): - id: int - - class Config: - from_attributes = True diff --git a/backend/security.py b/backend/security.py deleted file mode 100644 index fbec419..0000000 --- a/backend/security.py +++ /dev/null @@ -1,64 +0,0 @@ -import os -import logging -import secrets -from datetime import datetime, timedelta, timezone - -import jwt -from dotenv import load_dotenv -from fastapi import HTTPException, Security -from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer - -load_dotenv(override=True) - -logger = logging.getLogger(__name__) - -JWT_SECRET = os.getenv("JWT_SECRET", "dev-only-change-me-for-production") -JWT_ALGORITHM = "HS256" -TOKEN_TTL_HOURS = 12 - -ADMIN_USERNAME = os.getenv("DASHBOARD_ADMIN_USERNAME", "admin") -ADMIN_PASSWORD = os.getenv("DASHBOARD_ADMIN_PASSWORD", "Eafon123") - -security_bearer = HTTPBearer(auto_error=False) - - -def create_access_token() -> str: - now = datetime.now(timezone.utc) - exp = now + timedelta(hours=TOKEN_TTL_HOURS) - payload = { - "sub": ADMIN_USERNAME, - "iat": int(now.timestamp()), - "exp": exp, - } - return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) - - -def decode_token(token: str) -> dict: - return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) - - -def verify_credentials(username: str, password: str) -> bool: - if username != ADMIN_USERNAME: - return False - try: - return secrets.compare_digest(password, ADMIN_PASSWORD) - except ValueError: - return False - - -def get_current_user( - credentials: HTTPAuthorizationCredentials | None = Security(security_bearer), -) -> str: - if credentials is None or (credentials.scheme or "").lower() != "bearer": - raise HTTPException(status_code=401, detail="未登录或缺少凭证") - token = credentials.credentials - try: - payload = decode_token(token) - sub = payload.get("sub") - if not sub: - raise HTTPException(status_code=401, detail="无效凭证") - return str(sub) - except jwt.ExpiredSignatureError: - raise HTTPException(status_code=401, detail="登录已过期,请重新登录") - except jwt.InvalidTokenError: - raise HTTPException(status_code=401, detail="无效凭证") diff --git a/backend/src/app.js b/backend/src/app.js new file mode 100644 index 0000000..70b0e0d --- /dev/null +++ b/backend/src/app.js @@ -0,0 +1,57 @@ +/** + * 入口文件 — 对应 Python: main.py + */ +require('dotenv').config({ override: true }); + +const express = require('express'); +const cors = require('cors'); +const sequelize = require('./config/database'); +const logger = require('./config/logger'); +const routes = require('./routes'); +const { bodyLimit } = require('./middleware/bodyLimit'); + +const app = express(); + +// 中间件 +app.use(cors({ origin: '*', credentials: true })); +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); +app.use(bodyLimit); + +// 根路径(无需登录) +app.get('/', (req, res) => { + res.json({ + message: '欢迎使用 Audio Dashboard API', + docs: '/docs', + redoc: '/redoc', + }); +}); + +// 健康检查(无需登录) +app.get('/health', (req, res) => { + res.json({ status: 'healthy' }); +}); + +// 业务路由(需登录的路由在各自文件中挂载 authMiddleware) +routes.forEach((r) => app.use(r)); + +// 同步数据库并启动 +const PORT = process.env.PORT || 8083; + +async function start() { + try { + logger.info('Creating database tables...'); + await sequelize.sync(); + logger.info('Database tables created successfully'); + } catch (e) { + logger.error(`Warning: Could not create tables: ${e.message}`); + logger.error('Continuing without table creation...'); + } + + app.listen(PORT, '0.0.0.0', () => { + logger.info(`Server running on http://localhost:${PORT}`); + logger.info(`API docs: http://localhost:${PORT}/docs`); + }); +} + +start(); diff --git a/backend/src/config/database.js b/backend/src/config/database.js new file mode 100644 index 0000000..851138e --- /dev/null +++ b/backend/src/config/database.js @@ -0,0 +1,22 @@ +const { Sequelize } = require('sequelize'); + +const sequelize = new Sequelize( + process.env.DATABASE_NAME || 'audio', + process.env.DATABASE_USER || 'root', + process.env.DATABASE_PASSWORD || 'root123', + { + host: process.env.DATABASE_HOST || 'localhost', + port: parseInt(process.env.DATABASE_PORT || '3306', 10), + dialect: 'mysql', + dialectOptions: { + charset: 'utf8mb4', + }, + logging: process.env.DEBUG === 'True' ? (msg) => console.log(msg) : false, + define: { + timestamps: false, + freezeTableName: true, + }, + } +); + +module.exports = sequelize; diff --git a/backend/src/config/logger.js b/backend/src/config/logger.js new file mode 100644 index 0000000..92833ec --- /dev/null +++ b/backend/src/config/logger.js @@ -0,0 +1,28 @@ +const winston = require('winston'); +const path = require('path'); +const fs = require('fs'); + +const logsDir = path.join(__dirname, '..', '..', 'logs'); +if (!fs.existsSync(logsDir)) { + fs.mkdirSync(logsDir, { recursive: true }); +} + +const logger = winston.createLogger({ + level: 'info', + format: winston.format.combine( + winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + winston.format.printf(({ timestamp, level, message, ...meta }) => { + const metaStr = Object.keys(meta).length ? ` ${JSON.stringify(meta)}` : ''; + return `${timestamp} - ${level} - ${message}${metaStr}`; + }) + ), + transports: [ + new winston.transports.Console(), + new winston.transports.File({ + filename: path.join(logsDir, 'app.log'), + encoding: 'utf-8', + }), + ], +}); + +module.exports = logger; diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js new file mode 100644 index 0000000..94c467d --- /dev/null +++ b/backend/src/middleware/auth.js @@ -0,0 +1,24 @@ +const { decodeToken } = require('../utils/jwt'); + +function authMiddleware(req, res, next) { + const auth = req.headers.authorization; + if (!auth || !auth.startsWith('Bearer ')) { + return res.status(401).json({ detail: '未登录或缺少凭证' }); + } + const token = auth.slice(7); + try { + const payload = decodeToken(token); + if (!payload.sub) { + return res.status(401).json({ detail: '无效凭证' }); + } + req.user = payload.sub; + next(); + } catch (e) { + if (e.name === 'TokenExpiredError') { + return res.status(401).json({ detail: '登录已过期,请重新登录' }); + } + return res.status(401).json({ detail: '无效凭证' }); + } +} + +module.exports = { authMiddleware }; diff --git a/backend/src/middleware/bodyLimit.js b/backend/src/middleware/bodyLimit.js new file mode 100644 index 0000000..d192280 --- /dev/null +++ b/backend/src/middleware/bodyLimit.js @@ -0,0 +1,11 @@ +const MAX_BODY_SIZE = 8 * 1024 * 1024; // 8MB + +function bodyLimit(req, res, next) { + const contentLength = req.headers['content-length']; + if (contentLength && parseInt(contentLength, 10) > MAX_BODY_SIZE) { + return res.status(413).json({ detail: '上传文件大小超过 8MB 限制' }); + } + next(); +} + +module.exports = { bodyLimit }; diff --git a/backend/src/models/Brand.js b/backend/src/models/Brand.js new file mode 100644 index 0000000..a48c813 --- /dev/null +++ b/backend/src/models/Brand.js @@ -0,0 +1,22 @@ +const { DataTypes } = require('sequelize'); +const sequelize = require('../config/database'); + +const Brand = sequelize.define('Brand', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + comment: '品牌 ID', + }, + name: { + type: DataTypes.STRING(100), + unique: true, + allowNull: false, + comment: '品牌名称', + }, +}, { + tableName: 'brand', + comment: '耳机品牌', +}); + +module.exports = Brand; diff --git a/backend/src/models/Model.js b/backend/src/models/Model.js new file mode 100644 index 0000000..c3c04b2 --- /dev/null +++ b/backend/src/models/Model.js @@ -0,0 +1,52 @@ +const { DataTypes } = require('sequelize'); +const sequelize = require('../config/database'); + +const Model = sequelize.define('Model', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + comment: '型号 ID', + }, + brand_name: { + type: DataTypes.STRING(100), + allowNull: false, + comment: '品牌名称', + }, + name: { + type: DataTypes.STRING(100), + allowNull: false, + comment: '型号名称', + }, + form: { + type: DataTypes.STRING(100), + allowNull: true, + comment: '形式', + }, + rig: { + type: DataTypes.STRING(100), + allowNull: true, + comment: '阻抗', + }, + source: { + type: DataTypes.STRING(100), + allowNull: true, + comment: '来源', + }, + eq_key: { + type: DataTypes.STRING(255), + allowNull: true, + comment: 'EQ 键', + }, + create_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + comment: '创建时间', + }, +}, { + tableName: 'model', + comment: '耳机型号', +}); + +module.exports = Model; diff --git a/backend/src/models/Ota.js b/backend/src/models/Ota.js new file mode 100644 index 0000000..aa51371 --- /dev/null +++ b/backend/src/models/Ota.js @@ -0,0 +1,118 @@ +const { DataTypes } = require('sequelize'); +const sequelize = require('../config/database'); + +const Ota = sequelize.define('Ota', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + comment: 'ID', + }, + verCode: { + type: DataTypes.INTEGER, + allowNull: false, + field: 'verCode', + comment: '版本号(整数)', + }, + verName: { + type: DataTypes.STRING(20), + allowNull: false, + field: 'verName', + comment: '版本名称', + }, + url: { + type: DataTypes.STRING(255), + allowNull: false, + comment: '升级包 URL', + }, + md5: { + type: DataTypes.STRING(32), + allowNull: false, + comment: '升级包 MD5', + }, + force: { + type: DataTypes.SMALLINT, + allowNull: false, + defaultValue: 0, + comment: '是否强升;0-否', + }, + desc: { + type: DataTypes.STRING(255), + allowNull: true, + comment: '描述', + }, + model: { + type: DataTypes.STRING(100), + allowNull: true, + comment: '对应的设备型号', + }, + hw: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0, + comment: '硬件版本号', + }, + target: { + type: DataTypes.SMALLINT, + allowNull: false, + defaultValue: 0, + comment: '是否定向,1-是,0-否', + }, + beta: { + type: DataTypes.SMALLINT, + allowNull: false, + defaultValue: 0, + comment: '是否灰度,1-是,0-否', + }, + pawVerCode: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0, + field: 'pawVerCode', + comment: '配对版本号', + }, + pawVerName: { + type: DataTypes.STRING(20), + allowNull: false, + defaultValue: '', + field: 'pawVerName', + comment: '配对版本名称', + }, + pawUrl: { + type: DataTypes.STRING(255), + allowNull: false, + defaultValue: '', + field: 'pawUrl', + comment: '配对版本 URL', + }, + pawMd5: { + type: DataTypes.STRING(32), + allowNull: false, + defaultValue: '', + field: 'pawMd5', + comment: '配对版本 MD5', + }, + startTime: { + type: DataTypes.DATE, + allowNull: true, + field: 'startTime', + comment: '升级开始时间', + }, + endTime: { + type: DataTypes.DATE, + allowNull: true, + field: 'endTime', + comment: '升级结束时间', + }, + status: { + type: DataTypes.SMALLINT, + allowNull: false, + defaultValue: 1, + comment: '是否可用', + }, +}, { + tableName: 'ota', + comment: 'OTA 升级版本', +}); + +module.exports = Ota; diff --git a/backend/src/models/index.js b/backend/src/models/index.js new file mode 100644 index 0000000..b365f9a --- /dev/null +++ b/backend/src/models/index.js @@ -0,0 +1,5 @@ +const Brand = require('./Brand'); +const Model = require('./Model'); +const Ota = require('./Ota'); + +module.exports = { Brand, Model, Ota }; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js new file mode 100644 index 0000000..7ca7b85 --- /dev/null +++ b/backend/src/routes/auth.js @@ -0,0 +1,33 @@ +/** + * 认证路由 — 对应 Python: routes/auth.py + */ +const router = require('express').Router(); +const logger = require('../config/logger'); +const { ApiResponse } = require('../utils/response'); +const { createAccessToken, verifyCredentials } = require('../utils/jwt'); + +router.post('/api/auth/login', (req, res) => { + try { + const { username, password } = req.body || {}; + if (!username || !password) { + return res.json(ApiResponse.error('用户名或密码不能为空')); + } + if (!verifyCredentials(username.trim(), password)) { + logger.warn(`Login failed for username=${username}`); + return res.json(ApiResponse.error('用户名或密码错误')); + } + const token = createAccessToken(); + const ttlSeconds = 12 * 60 * 60; + logger.info(`User ${username.trim()} logged in`); + return res.json(ApiResponse.success({ + access_token: token, + token_type: 'bearer', + expires_in: ttlSeconds, + })); + } catch (e) { + logger.error(`Login error: ${e.message}`); + return res.json(ApiResponse.error('登录失败')); + } +}); + +module.exports = router; diff --git a/backend/src/routes/brands.js b/backend/src/routes/brands.js new file mode 100644 index 0000000..51a5a75 --- /dev/null +++ b/backend/src/routes/brands.js @@ -0,0 +1,146 @@ +/** + * 品牌路由 — 对应 Python: routes/brands.py + */ +const router = require('express').Router(); +const { Op } = require('sequelize'); +const Brand = require('../models/Brand'); +const logger = require('../config/logger'); +const { ApiResponse } = require('../utils/response'); +const { authMiddleware } = require('../middleware/auth'); + +// 所有品牌接口需登录 +router.use(authMiddleware); + +// GET /api/brands/ +router.get('/api/brands/', async (req, res) => { + try { + const skip = parseInt(req.query.skip || '0', 10); + const limit = Math.min(parseInt(req.query.limit || '100', 10), 1000); + const name = req.query.name; + + const where = name ? { name: { [Op.like]: `%${name}%` } } : {}; + const { rows, count } = await Brand.findAndCountAll({ + where, + offset: skip, + limit, + }); + + logger.info(`Found ${rows.length} brands, total=${count}`); + + if (!rows.length) { + return res.json(ApiResponse.noData('empty')); + } + + const items = rows.map((b) => ({ id: b.id, name: b.name })); + return res.json(ApiResponse.success({ items, total: count, skip, limit })); + } catch (e) { + logger.error(`Error getting brands: ${e.message}`); + return res.json(ApiResponse.error('error')); + } +}); + +// GET /api/brands/:brand_id +router.get('/api/brands/:brand_id', async (req, res) => { + try { + const brandId = parseInt(req.params.brand_id, 10); + logger.info(`Getting brand: id=${brandId}`); + const brand = await Brand.findByPk(brandId); + if (!brand) { + logger.warn(`Brand not found: id=${brandId}`); + return res.json(ApiResponse.noData('empty')); + } + return res.json(ApiResponse.success({ id: brand.id, name: brand.name })); + } catch (e) { + logger.error(`Error getting brand ${req.params.brand_id}: ${e.message}`); + return res.json(ApiResponse.error('error')); + } +}); + +// POST /api/brands/ +router.post('/api/brands/', async (req, res) => { + try { + const name = (req.body.name || '').trim(); + if (!name) { + return res.json(ApiResponse.error('品牌名称不能为空')); + } + logger.info(`Creating brand: name=${name}`); + + const existing = await Brand.findOne({ where: { name } }); + if (existing) { + logger.warn(`Brand already exists: name=${name}`); + return res.json(ApiResponse.error('品牌名称已存在')); + } + + const brand = await Brand.create({ name }); + logger.info(`Brand created successfully: id=${brand.id}, name=${brand.name}`); + return res.json(ApiResponse.success({ id: brand.id, name: brand.name }, '品牌创建成功')); + } catch (e) { + logger.error(`Error creating brand: ${e.message}`); + return res.json(ApiResponse.error(`创建失败:${e.message}`)); + } +}); + +// PUT /api/brands/:brand_id +router.put('/api/brands/:brand_id', async (req, res) => { + try { + const brandId = parseInt(req.params.brand_id, 10); + logger.info(`Updating brand: id=${brandId}`); + + const dbBrand = await Brand.findByPk(brandId); + if (!dbBrand) { + logger.warn(`Brand not found: id=${brandId}`); + return res.json(ApiResponse.noData('品牌不存在')); + } + + if (req.body.name === undefined || req.body.name === null) { + return res.json(ApiResponse.success({ id: dbBrand.id, name: dbBrand.name }, '品牌更新成功')); + } + + const newName = req.body.name.trim(); + if (!newName) { + return res.json(ApiResponse.error('品牌名称不能为空')); + } + + const currentName = (dbBrand.name || '').trim(); + if (newName !== currentName) { + const existing = await Brand.findOne({ + where: { name: newName, id: { [Op.ne]: brandId } }, + }); + if (existing) { + logger.warn(`Brand name already exists: name=${newName}`); + return res.json(ApiResponse.error('品牌名称已存在')); + } + dbBrand.name = newName; + } + + await dbBrand.save(); + logger.info(`Brand updated successfully: id=${dbBrand.id}`); + return res.json(ApiResponse.success({ id: dbBrand.id, name: dbBrand.name }, '品牌更新成功')); + } catch (e) { + logger.error(`Error updating brand ${req.params.brand_id}: ${e.message}`); + return res.json(ApiResponse.error(`更新失败:${e.message}`)); + } +}); + +// DELETE /api/brands/:brand_id +router.delete('/api/brands/:brand_id', async (req, res) => { + try { + const brandId = parseInt(req.params.brand_id, 10); + logger.info(`Deleting brand: id=${brandId}`); + + const dbBrand = await Brand.findByPk(brandId); + if (!dbBrand) { + logger.warn(`Brand not found: id=${brandId}`); + return res.json(ApiResponse.noData('品牌不存在')); + } + + await dbBrand.destroy(); + logger.info(`Brand deleted successfully: id=${brandId}`); + return res.json(ApiResponse.success(null, '删除成功')); + } catch (e) { + logger.error(`Error deleting brand ${req.params.brand_id}: ${e.message}`); + return res.json(ApiResponse.error(`删除失败:${e.message}`)); + } +}); + +module.exports = router; diff --git a/backend/src/routes/index.js b/backend/src/routes/index.js new file mode 100644 index 0000000..e8f36cc --- /dev/null +++ b/backend/src/routes/index.js @@ -0,0 +1,9 @@ +/** + * 路由汇总 — 对应 Python: routes/__init__.py + */ +const authRouter = require('./auth'); +const brandsRouter = require('./brands'); +const modelsRouter = require('./models'); +const otaRouter = require('./ota'); + +module.exports = [authRouter, brandsRouter, modelsRouter, otaRouter]; diff --git a/backend/src/routes/models.js b/backend/src/routes/models.js new file mode 100644 index 0000000..965a7fc --- /dev/null +++ b/backend/src/routes/models.js @@ -0,0 +1,416 @@ +/** + * 型号路由 — 对应 Python: routes/models.py + */ +const router = require('express').Router(); +const { Op } = require('sequelize'); +const multer = require('multer'); +const path = require('path'); +const fs = require('fs'); +const axios = require('axios'); +const Model = require('../models/Model'); +const logger = require('../config/logger'); +const { ApiResponse } = require('../utils/response'); +const { authMiddleware } = require('../middleware/auth'); +const { fetchAndValidateCurve } = require('../services/curveClient'); + +/** + * 将 TXT 频响文件内容转换为 CSV 格式 + * 支持两种格式: + * 1. REW 导出格式:以 "* Freq(Hz)" 行作为数据起点,空格分隔 + * 2. 纯数据格式:直接 tab/空格分隔,无前导注释 + * @param {Buffer} buffer - TXT 文件 Buffer + * @returns {string} CSV 内容(含 frequency,raw 表头) + */ +function convertTxtToCsv(buffer) { + const text = buffer.toString('utf-8'); + const lines = text.split(/\r?\n/); + + // 查找 "* Freq(Hz)" 头行索引 + let startIdx = -1; + for (let i = 0; i < lines.length; i++) { + if (lines[i].startsWith('* Freq(Hz)')) { + startIdx = i; + break; + } + } + + // 数据行:头行之后,或无前导注释时从第一行开始 + const dataLines = startIdx >= 0 ? lines.slice(startIdx + 1) : lines; + + const csvRows = ['frequency,raw']; + for (const line of dataLines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('*')) continue; + const parts = trimmed.split(/\s+/); + if (parts.length >= 2) { + csvRows.push(`${parts[0]},${parts[1]}`); + } + } + + return csvRows.join('\n'); +} + +/** + * 处理上传的频响文件: + * - .txt → 转换为 CSV,返回 { buffer, filename } (扩展名改为 .csv) + * - 其他格式 → 原样返回 + */ +function processUploadedFile(buffer, originalname) { + const ext = path.extname(originalname).toLowerCase(); + if (ext === '.txt') { + const csvContent = convertTxtToCsv(buffer); + const csvFilename = originalname.replace(/\.txt$/i, '.csv'); + logger.info(`Converted TXT to CSV: ${originalname} -> ${csvFilename}`); + return { buffer: Buffer.from(csvContent, 'utf-8'), filename: csvFilename }; + } + return { buffer, filename: originalname }; +} + +// 所有型号接口需登录 +router.use(authMiddleware); + +// Multer 配置 — 内存存储 +const upload = multer({ storage: multer.memoryStorage() }); + +// Meilisearch 配置 +const MEILISEARCH_URL = process.env.MEILISEARCH_URL || 'http://localhost:7700'; +const MEILISEARCH_API_KEY = process.env.MEILISEARCH_API_KEY || ''; +const MEILISEARCH_INDEX = process.env.MEILISEARCH_INDEX || 'models'; + +// 文件上传配置 +const UPLOAD_FOLDER = process.env.UPLOAD_FOLDER || '/data/project/autoeq/measurements'; +const ALLOWED_EXTENSIONS = ['.csv', '.txt', '.json']; + +// GET /api/models/ +router.get('/api/models/', async (req, res) => { + try { + const skip = parseInt(req.query.skip || '0', 10); + const limit = Math.min(parseInt(req.query.limit || '100', 10), 1000); + const brandName = req.query.brand_name; + const name = req.query.name; + const sortBy = req.query.sort_by || 'id'; + const sortOrder = (req.query.sort_order || 'desc').toLowerCase(); + + const where = {}; + if (brandName) where.brand_name = { [Op.like]: `%${brandName}%` }; + if (name) where.name = { [Op.like]: `%${name}%` }; + + const total = await Model.count({ where }); + + const orderDir = sortOrder === 'asc' ? 'ASC' : 'DESC'; + const orderCol = sortBy === 'create_at' ? 'create_at' : 'id'; + + const rows = await Model.findAll({ + where, + offset: skip, + limit, + order: [[orderCol, orderDir]], + }); + + logger.info(`Found ${rows.length} models, total=${total}`); + + if (!rows.length) { + return res.json(ApiResponse.noData('empty')); + } + + const items = rows.map((m) => ({ + id: m.id, + brand_name: m.brand_name, + name: m.name, + form: m.form, + rig: m.rig, + source: m.source, + eq_key: m.eq_key, + create_at: m.create_at ? m.create_at.toISOString() : null, + })); + + return res.json(ApiResponse.success({ items, total, skip, limit })); + } catch (e) { + logger.error(`Error getting models: ${e.message}`); + return res.json(ApiResponse.error('error')); + } +}); + +// GET /api/models/:model_id +router.get('/api/models/:model_id', async (req, res) => { + try { + const modelId = parseInt(req.params.model_id, 10); + logger.info(`Getting model: id=${modelId}`); + const model = await Model.findByPk(modelId); + if (!model) { + logger.warn(`Model not found: id=${modelId}`); + return res.json(ApiResponse.noData('empty')); + } + return res.json(ApiResponse.success({ + id: model.id, + brand_name: model.brand_name, + name: model.name, + form: model.form, + rig: model.rig, + source: model.source, + eq_key: model.eq_key, + create_at: model.create_at ? model.create_at.toISOString() : null, + })); + } catch (e) { + logger.error(`Error getting model ${req.params.model_id}: ${e.message}`); + return res.json(ApiResponse.error('error')); + } +}); + +// POST /api/models/ (multipart/form-data) +router.post('/api/models/', upload.single('measurement_file'), async (req, res) => { + try { + const { brand_name, name, form, rig, source, eq_key } = req.body; + logger.info(`Creating model: brand_name=${brand_name}, name=${name}, form=${form}, source=${source}`); + + // 检查是否已存在 + const existing = await Model.findOne({ where: { brand_name, name } }); + if (existing) { + logger.warn(`Model already exists: brand_name=${brand_name}, name=${name}`); + return res.json(ApiResponse.error('该品牌下型号名称已存在')); + } + + // 处理文件上传 + if (req.file && req.file.originalname) { + const fileExt = path.extname(req.file.originalname).toLowerCase(); + if (!ALLOWED_EXTENSIONS.includes(fileExt)) { + logger.error(`Unsupported file format: ${fileExt}`); + return res.json(ApiResponse.error(`不支持的文件格式:${fileExt}`)); + } + + const { buffer: fileBuffer, filename: savedFilename } = processUploadedFile(req.file.buffer, req.file.originalname); + const saveDir = path.join(UPLOAD_FOLDER, source || '', 'data', form || ''); + fs.mkdirSync(saveDir, { recursive: true }); + const filePath = path.join(saveDir, savedFilename); + fs.writeFileSync(filePath, fileBuffer); + logger.info(`File saved: ${filePath}`); + } + + const dbModel = await Model.create({ + brand_name, + name, + form: form || null, + rig: rig || null, + source: source || null, + eq_key: eq_key || null, + }); + + logger.info(`Model created successfully: id=${dbModel.id}`); + return res.json(ApiResponse.success({ + id: dbModel.id, + brand_name: dbModel.brand_name, + name: dbModel.name, + form: dbModel.form, + rig: dbModel.rig, + source: dbModel.source, + eq_key: dbModel.eq_key, + create_at: dbModel.create_at ? dbModel.create_at.toISOString() : null, + })); + } catch (e) { + logger.error(`Error creating model: ${e.message}`); + return res.json(ApiResponse.error('error')); + } +}); + +// PUT /api/models/:model_id (multipart/form-data) +router.put('/api/models/:model_id', upload.single('measurement_file'), async (req, res) => { + try { + const modelId = parseInt(req.params.model_id, 10); + const { brand_name, name, form, rig, source, eq_key } = req.body; + + logger.info(`Updating model: id=${modelId}, brand_name=${brand_name}, name=${name}, form=${form}`); + + const dbModel = await Model.findByPk(modelId); + if (!dbModel) { + logger.warn(`Model not found: id=${modelId}`); + return res.json(ApiResponse.noData('empty')); + } + + const newBrandName = brand_name === undefined || brand_name === 'null' ? dbModel.brand_name : brand_name; + const newName = name === undefined || name === 'null' ? dbModel.name : name; + + if (newBrandName !== dbModel.brand_name || newName !== dbModel.name) { + const existing = await Model.findOne({ + where: { brand_name: newBrandName, name: newName }, + }); + if (existing) { + logger.warn(`Model already exists: brand_name=${newBrandName}, name=${newName}`); + return res.json(ApiResponse.error('该品牌下型号名称已存在')); + } + } + + const effSource = source === undefined || source === 'null' ? dbModel.source : source; + const effForm = form === undefined || form === 'null' ? dbModel.form : form; + + if (req.file && req.file.originalname) { + const fileExt = path.extname(req.file.originalname).toLowerCase(); + if (!ALLOWED_EXTENSIONS.includes(fileExt)) { + logger.error(`Unsupported file format: ${fileExt}`); + return res.json(ApiResponse.error(`不支持的文件格式:${fileExt}`)); + } + if (!effSource || !effForm) { + return res.json(ApiResponse.error('上传频响文件需要来源与形式字段')); + } + const { buffer: fileBuffer, filename: savedFilename } = processUploadedFile(req.file.buffer, req.file.originalname); + const saveDir = path.join(UPLOAD_FOLDER, effSource, 'data', effForm); + fs.mkdirSync(saveDir, { recursive: true }); + const filePath = path.join(saveDir, savedFilename); + fs.writeFileSync(filePath, fileBuffer); + logger.info(`File saved: ${filePath}`); + } + + if (brand_name !== undefined && brand_name !== 'null') dbModel.brand_name = brand_name; + if (name !== undefined && name !== 'null') dbModel.name = name; + if (form !== undefined && form !== 'null') dbModel.form = form; + if (rig !== undefined && rig !== 'null') dbModel.rig = rig; + if (source !== undefined && source !== 'null') dbModel.source = source; + if (eq_key !== undefined && eq_key !== 'null') dbModel.eq_key = eq_key; + + await dbModel.save(); + logger.info(`Model updated successfully: id=${dbModel.id}`); + + return res.json(ApiResponse.success({ + id: dbModel.id, + brand_name: dbModel.brand_name, + name: dbModel.name, + form: dbModel.form, + rig: dbModel.rig, + source: dbModel.source, + eq_key: dbModel.eq_key, + create_at: dbModel.create_at ? dbModel.create_at.toISOString() : null, + })); + } catch (e) { + logger.error(`Error updating model ${req.params.model_id}: ${e.message}`); + return res.json(ApiResponse.error('error')); + } +}); + +// DELETE /api/models/:model_id +router.delete('/api/models/:model_id', async (req, res) => { + try { + const modelId = parseInt(req.params.model_id, 10); + logger.info(`Deleting model: id=${modelId}`); + + const dbModel = await Model.findByPk(modelId); + if (!dbModel) { + logger.warn(`Model not found: id=${modelId}`); + return res.json(ApiResponse.noData('empty')); + } + + await dbModel.destroy(); + logger.info(`Model deleted successfully: id=${modelId}`); + return res.json(ApiResponse.success(null, 'success')); + } catch (e) { + logger.error(`Error deleting model ${req.params.model_id}: ${e.message}`); + return res.json(ApiResponse.error('error')); + } +}); + +// POST /api/models/push-to-search/validate +router.post('/api/models/push-to-search/validate', async (req, res) => { + try { + const { model_ids } = req.body || {}; + if (!Array.isArray(model_ids) || !model_ids.length) { + return res.json(ApiResponse.error('请选择要推送的型号')); + } + + const models = await Model.findAll({ where: { id: { [Op.in]: model_ids } } }); + if (!models.length) { + return res.json(ApiResponse.error('未找到选中的型号数据')); + } + + const validationErrors = []; + for (const model of models) { + const [ok, reason] = await fetchAndValidateCurve( + model.brand_name, + model.name, + model.form || '' + ); + if (!ok) { + validationErrors.push({ + id: model.id, + brand_name: model.brand_name, + name: model.name, + form: model.form, + reason, + }); + } + } + + if (validationErrors.length) { + const names = validationErrors.slice(0, 5) + .map((e) => `${e.brand_name} ${e.name}`) + .join('、'); + const suffix = validationErrors.length > 5 ? ' 等' : ''; + return res.json({ + code: 0, + msg: `曲线数据校验未通过:${names}${suffix}`, + data: { errors: validationErrors, validated_count: 0 }, + }); + } + + return res.json(ApiResponse.success({ validated_count: models.length })); + } catch (e) { + logger.error(`validate_push_to_search failed: ${e.message}`); + return res.json(ApiResponse.error(`校验失败:${e.message}`)); + } +}); + +// POST /api/models/push-to-search +router.post('/api/models/push-to-search', async (req, res) => { + try { + const { model_ids } = req.body || {}; + if (!Array.isArray(model_ids) || !model_ids.length) { + return res.json(ApiResponse.error('请选择要推送的型号')); + } + + const models = await Model.findAll({ where: { id: { [Op.in]: model_ids } } }); + if (!models.length) { + return res.json(ApiResponse.error('未找到选中的型号数据')); + } + + const pushData = models.map((m) => { + const doc = { + id: m.id, + brand_name: m.brand_name, + name: m.name, + rig: m.rig, + form: m.form, + source: m.source, + }; + // 移除 null 值 + Object.keys(doc).forEach((k) => doc[k] === null && delete doc[k]); + return doc; + }); + + const headers = { + Authorization: `Bearer ${MEILISEARCH_API_KEY}`, + 'Content-Type': 'application/json', + }; + + try { + const response = await axios.post( + `${MEILISEARCH_URL}/indexes/${MEILISEARCH_INDEX}/documents`, + pushData, + { headers, timeout: 30000 } + ); + + if (![200, 202].includes(response.status)) { + return res.json(ApiResponse.error(`推送到 Meilisearch 失败:${JSON.stringify(response.data)}`)); + } + + return res.json(ApiResponse.success({ + pushed_count: pushData.length, + task_uid: response.data?.taskUid, + models: pushData, + })); + } catch (e) { + return res.json(ApiResponse.error(`连接 Meilisearch 失败:${e.message}`)); + } + } catch (e) { + logger.error(`push_to_search failed: ${e.message}`); + return res.json(ApiResponse.error(`推送失败:${e.message}`)); + } +}); + +module.exports = router; diff --git a/backend/src/routes/ota.js b/backend/src/routes/ota.js new file mode 100644 index 0000000..8b7a50d --- /dev/null +++ b/backend/src/routes/ota.js @@ -0,0 +1,294 @@ +/** + * OTA 路由 — 对应 Python: routes/ota.py + */ +const router = require('express').Router(); +const { Op } = require('sequelize'); +const multer = require('multer'); +const Ota = require('../models/Ota'); +const logger = require('../config/logger'); +const { ApiResponse } = require('../utils/response'); +const { authMiddleware } = require('../middleware/auth'); +const { + OTA_MODEL_X8, + OTA_MODEL_X9, + OTA_UPLOAD_MODELS, + readUploadContentAndMd5, + saveX9PackageLocal, + uploadX8PackageToS3, +} = require('../services/otaStorage'); +const { OtaCreateSchema, OtaUpdateSchema } = require('../validators/ota'); + +// OTA 业务接口需登录(/latest/check 除外,单独处理) +const upload = multer({ storage: multer.memoryStorage() }); + +// POST /api/ota/upload-package (需登录) +router.post('/api/ota/upload-package', authMiddleware, upload.single('package_file'), async (req, res) => { + try { + const model = (req.body.model || '').trim(); + if (!OTA_UPLOAD_MODELS.has(model)) { + return res.json(ApiResponse.error(`当前仅支持为 ${OTA_MODEL_X8}、${OTA_MODEL_X9} 上传升级包`)); + } + if (!req.file) { + return res.json(ApiResponse.error('请选择升级包文件')); + } + + logger.info(`Uploading OTA package: model=${model}, filename=${req.file.originalname}`); + + const { content, md5Hex } = await readUploadContentAndMd5(req.file.buffer); + + if (model === OTA_MODEL_X9) { + const { savedName, downloadUrl } = saveX9PackageLocal(content, md5Hex); + return res.json(ApiResponse.success({ + md5: md5Hex, + filename: savedName, + url: downloadUrl, + })); + } + + if (model === OTA_MODEL_X8) { + const { savedName, downloadUrl, s3Key } = await uploadX8PackageToS3(content, md5Hex); + return res.json(ApiResponse.success({ + md5: md5Hex, + filename: savedName, + url: downloadUrl, + s3_key: s3Key, + })); + } + + return res.json(ApiResponse.error('不支持的设备型号')); + } catch (e) { + if (e.message.includes('未配置') || e.message.includes('S3 上传失败')) { + return res.json(ApiResponse.error(e.message)); + } + logger.error(`Error uploading OTA package: ${e.message}`); + return res.json(ApiResponse.error(`上传失败:${e.message}`)); + } +}); + +// GET /api/ota/latest/check (无需登录 — 设备端调用) +router.get('/api/ota/latest/check', async (req, res) => { + try { + const currentVerCode = parseInt(req.query.currentVerCode, 10); + const model = req.query.model; + const hw = req.query.hw !== undefined ? parseInt(req.query.hw, 10) : null; + + logger.info(`Checking latest OTA: currentVerCode=${currentVerCode}, model=${model}, hw=${hw}`); + + const where = { + status: 1, + verCode: { [Op.gt]: currentVerCode }, + model, + }; + if (hw !== null && !isNaN(hw)) { + where.hw = hw; + } + + const latestOta = await Ota.findOne({ + where, + order: [['verCode', 'DESC']], + }); + + if (!latestOta) { + logger.info(`No available OTA found for model=${model}, currentVerCode=${currentVerCode}`); + return res.json(ApiResponse.noData('empty')); + } + + logger.info(`Latest OTA found: verCode=${latestOta.verCode}, verName=${latestOta.verName}`); + return res.json(ApiResponse.success(otaToDict(latestOta))); + } catch (e) { + logger.error(`Error checking latest OTA: ${e.message}`); + return res.json(ApiResponse.error('error')); + } +}); + +// 以下接口需登录 +router.use('/api/ota', authMiddleware); + +// GET /api/ota/ +router.get('/api/ota/', async (req, res) => { + try { + const skip = parseInt(req.query.skip || '0', 10); + const limit = Math.min(parseInt(req.query.limit || '100', 10), 1000); + const verCode = req.query.verCode !== undefined ? parseInt(req.query.verCode, 10) : null; + const verName = req.query.verName; + const model = req.query.model; + const status = req.query.status !== undefined ? parseInt(req.query.status, 10) : null; + + const where = {}; + if (verCode !== null && !isNaN(verCode)) where.verCode = verCode; + if (verName) where.verName = { [Op.like]: `%${verName}%` }; + if (model) where.model = { [Op.like]: `%${model}%` }; + if (status !== null && !isNaN(status)) where.status = status; + + const total = await Ota.count({ where }); + const rows = await Ota.findAll({ + where, + offset: skip, + limit, + order: [['verCode', 'DESC']], + }); + + logger.info(`Found ${rows.length} OTA records, total=${total}`); + + if (!rows.length) { + return res.json(ApiResponse.noData('empty')); + } + + const items = rows.map(otaToDict); + return res.json(ApiResponse.success({ items, total, skip, limit })); + } catch (e) { + logger.error(`Error getting OTA list: ${e.message}`); + return res.json(ApiResponse.error('error')); + } +}); + +// GET /api/ota/:ota_id +router.get('/api/ota/:ota_id', async (req, res) => { + try { + const otaId = parseInt(req.params.ota_id, 10); + logger.info(`Getting OTA: id=${otaId}`); + const ota = await Ota.findByPk(otaId); + if (!ota) { + logger.warn(`OTA not found: id=${otaId}`); + return res.json(ApiResponse.noData('empty')); + } + return res.json(ApiResponse.success(otaToDict(ota))); + } catch (e) { + logger.error(`Error getting OTA ${req.params.ota_id}: ${e.message}`); + return res.json(ApiResponse.error('error')); + } +}); + +// POST /api/ota/ +router.post('/api/ota/', async (req, res) => { + try { + const parsed = OtaCreateSchema.safeParse(req.body); + if (!parsed.success) { + const errors = parsed.error.errors.map((e) => e.message).join('; '); + return res.json(ApiResponse.error(errors)); + } + + const data = parsed.data; + logger.info(`Creating OTA: verCode=${data.verCode}, verName=${data.verName}, model=${data.model}`); + + // 检查版本号是否已存在 + const existing = await Ota.findOne({ + where: { verCode: data.verCode, model: data.model }, + }); + if (existing) { + logger.warn(`OTA version already exists: verCode=${data.verCode}, model=${data.model}`); + return res.json(ApiResponse.error('该版本已存在')); + } + + // 处理日期字段 + if (data.startTime) data.startTime = new Date(data.startTime); + if (data.endTime) data.endTime = new Date(data.endTime); + + const dbOta = await Ota.create(data); + logger.info(`OTA created successfully: id=${dbOta.id}, verCode=${dbOta.verCode}`); + return res.json(ApiResponse.success(otaToDict(dbOta))); + } catch (e) { + logger.error(`Error creating OTA: ${e.message}`); + return res.json(ApiResponse.error('error')); + } +}); + +// PUT /api/ota/:ota_id +router.put('/api/ota/:ota_id', async (req, res) => { + try { + const otaId = parseInt(req.params.ota_id, 10); + + const parsed = OtaUpdateSchema.safeParse(req.body); + if (!parsed.success) { + const errors = parsed.error.errors.map((e) => e.message).join('; '); + return res.json(ApiResponse.error(errors)); + } + + const data = parsed.data; + logger.info(`Updating OTA: id=${otaId}`); + + const dbOta = await Ota.findByPk(otaId); + if (!dbOta) { + logger.warn(`OTA not found: id=${otaId}`); + return res.json(ApiResponse.noData('empty')); + } + + // 如果更新版本号,检查是否冲突 + const newVerCode = data.verCode !== undefined && data.verCode !== null ? data.verCode : dbOta.verCode; + const newModel = data.model !== undefined && data.model !== null ? data.model : dbOta.model; + + if (newVerCode !== dbOta.verCode || newModel !== dbOta.model) { + const existing = await Ota.findOne({ + where: { verCode: newVerCode, model: newModel }, + }); + if (existing) { + logger.warn(`OTA version already exists: verCode=${newVerCode}, model=${newModel}`); + return res.json(ApiResponse.error('该版本已存在')); + } + } + + // 更新字段(仅非 null 的字段) + const updateFields = Object.entries(data).filter(([_, v]) => v !== undefined && v !== null); + for (const [field, value] of updateFields) { + if (field === 'startTime' || field === 'endTime') { + dbOta[field] = new Date(value); + } else { + dbOta[field] = value; + } + } + + await dbOta.save(); + logger.info(`OTA updated successfully: id=${dbOta.id}`); + return res.json(ApiResponse.success(otaToDict(dbOta))); + } catch (e) { + logger.error(`Error updating OTA ${req.params.ota_id}: ${e.message}`); + return res.json(ApiResponse.error('error')); + } +}); + +// DELETE /api/ota/:ota_id +router.delete('/api/ota/:ota_id', async (req, res) => { + try { + const otaId = parseInt(req.params.ota_id, 10); + logger.info(`Deleting OTA: id=${otaId}`); + + const dbOta = await Ota.findByPk(otaId); + if (!dbOta) { + logger.warn(`OTA not found: id=${otaId}`); + return res.json(ApiResponse.noData('empty')); + } + + await dbOta.destroy(); + logger.info(`OTA deleted successfully: id=${otaId}`); + return res.json(ApiResponse.success(null, 'success')); + } catch (e) { + logger.error(`Error deleting OTA ${req.params.ota_id}: ${e.message}`); + return res.json(ApiResponse.error('error')); + } +}); + +// OTA 对象转字典 +function otaToDict(ota) { + return { + id: ota.id, + verCode: ota.verCode, + verName: ota.verName, + url: ota.url, + md5: ota.md5, + force: ota.force, + desc: ota.desc, + model: ota.model, + hw: ota.hw, + target: ota.target, + beta: ota.beta, + pawVerCode: ota.pawVerCode, + pawVerName: ota.pawVerName, + pawUrl: ota.pawUrl, + pawMd5: ota.pawMd5, + startTime: ota.startTime ? ota.startTime.toISOString() : null, + endTime: ota.endTime ? ota.endTime.toISOString() : null, + status: ota.status, + }; +} + +module.exports = router; diff --git a/backend/src/services/curveClient.js b/backend/src/services/curveClient.js new file mode 100644 index 0000000..93e75b1 --- /dev/null +++ b/backend/src/services/curveClient.js @@ -0,0 +1,146 @@ +/** + * Luxsin 曲线 API:拉取、自定义 Base64 解码与 parametric_eq 校验。 + * 对应 Python: curve_client.py + */ +const axios = require('axios'); +const logger = require('../config/logger'); + +const LUXSIN_CURVE_API_BASE = 'https://api.luxsin.com.cn/audio/getCurve'; + +const CUSTOM_CHARS = 'KLMPQRSTUVWXYZABCGHdefIJjkNOlmnopqrstuvwxyzabcghiDEF34501289+67/'; +const STANDARD_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + +const TARGET_OVER_EAR = 'Harman over-ear 2018'; +const TARGET_IN_EAR = 'Harman in-ear 2019'; + +const FORM_TARGET_MAP = { + 'over-ear': TARGET_OVER_EAR, + 'in-ear': TARGET_IN_EAR, +}; + +function curveTargetForForm(form) { + if (!form) return null; + return FORM_TARGET_MAP[form.trim().toLowerCase()] || null; +} + +function customBase64ToString(encoded) { + if (!encoded || typeof encoded !== 'string') { + throw new Error('曲线数据为空'); + } + + const standardB64 = [...encoded].map((c) => { + const idx = CUSTOM_CHARS.indexOf(c); + return idx !== -1 ? STANDARD_CHARS[idx] : c; + }).join(''); + + try { + return Buffer.from(standardB64, 'base64').toString('utf-8'); + } catch (e) { + throw new Error(`Base64 解码失败:${e.message}`); + } +} + +function isValidParametricEqPayload(data) { + if (typeof data !== 'object' || data === null) return false; + const peq = data.parametric_eq; + if (typeof peq !== 'object' || peq === null) return false; + return Array.isArray(peq.filters) && peq.filters.length === 10; +} + +function extractEncodedPayload(responseData, responseText) { + const text = (responseText || '').trim(); + if (!text) throw new Error('曲线接口响应为空'); + + // 如果响应是纯字符串 + if (typeof responseData === 'string') { + return responseData.trim(); + } + + if (typeof responseData !== 'object' || responseData === null) { + throw new Error('曲线接口响应格式异常'); + } + + for (const key of ['data', 'curve', 'result', 'content', 'body']) { + const val = responseData[key]; + if (typeof val === 'string' && val.trim()) return val.trim(); + } + + const nested = responseData.data; + if (typeof nested === 'object' && nested !== null) { + for (const key of ['curve', 'data', 'content', 'encoded']) { + const val = nested[key]; + if (typeof val === 'string' && val.trim()) return val.trim(); + } + } + + if (typeof nested === 'string' && nested.trim()) return nested.trim(); + + throw new Error('曲线接口响应中未找到可解码数据'); +} + +async function fetchAndValidateCurve(brand, name, form, timeout = 20000) { + brand = (brand || '').trim(); + name = (name || '').trim(); + if (!brand || !name) return [false, '品牌名称或型号名称为空']; + + const target = curveTargetForForm(form); + if (!target) return [false, '佩戴方式须为入耳式(in-ear)或头戴式(over-ear)才能校验曲线']; + + const params = new URLSearchParams({ brand, name, target }); + const url = `${LUXSIN_CURVE_API_BASE}?${params.toString()}`; + + logger.info(`getCurve request: brand=${brand}, name=${name}, form=${form}, target=${target}, url=${url}`); + + let resp; + try { + resp = await axios.get(url, { timeout }); + } catch (e) { + logger.warn(`getCurve request failed: ${url} ${e.message}`); + return [false, `无法连接曲线接口:${e.message}`]; + } + + if (resp.status !== 200) { + return [false, `曲线接口返回 HTTP ${resp.status}`]; + } + + // 打印 getCurve 原始响应数据用于调试 + logger.info(`getCurve response status=${resp.status}`); + logger.info(`getCurve response data type=${typeof resp.data}`); + logger.info(`getCurve response data=${typeof resp.data === 'string' ? resp.data : JSON.stringify(resp.data).slice(0, 2000)}`); + + let encoded; + try { + encoded = extractEncodedPayload(resp.data, typeof resp.data === 'string' ? resp.data : JSON.stringify(resp.data)); + } catch (e) { + logger.error(`extractEncodedPayload failed: ${e.message}, raw data=${typeof resp.data === 'string' ? resp.data.slice(0, 500) : JSON.stringify(resp.data).slice(0, 500)}`); + return [false, e.message]; + } + + let decodedText; + try { + decodedText = customBase64ToString(encoded); + } catch (e) { + return [false, e.message]; + } + + let payload; + try { + payload = JSON.parse(decodedText); + } catch (e) { + return [false, '解码后的数据不是合法 JSON']; + } + + if (!isValidParametricEqPayload(payload)) { + return [false, '曲线数据异常']; + } + + return [true, '']; +} + +module.exports = { + curveTargetForForm, + customBase64ToString, + isValidParametricEqPayload, + extractEncodedPayload, + fetchAndValidateCurve, +}; diff --git a/backend/src/services/otaStorage.js b/backend/src/services/otaStorage.js new file mode 100644 index 0000000..8a759e4 --- /dev/null +++ b/backend/src/services/otaStorage.js @@ -0,0 +1,116 @@ +/** + * OTA 升级包存储:Luxsin-X8 -> S3,Luxsin-X9 -> 本地目录 + * 对应 Python: ota_storage.py + */ +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3'); +const logger = require('../config/logger'); + +const OTA_MODEL_X8 = 'Luxsin-X8'; +const OTA_MODEL_X9 = 'Luxsin-X9'; +const OTA_UPLOAD_MODELS = new Set([OTA_MODEL_X8, OTA_MODEL_X9]); + +const _OTA_UPLOAD_DIR_DEV_DEFAULT = 'H:/soft/projects/luxsin/dashboard/ota'; +const _OTA_UPLOAD_DIR_PROD_DEFAULT = '/data/project/dashboard/upload'; + +const AWS_REGION = process.env.AWS_REGION || 'eu-central-1'; +const AWS_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID || ''; +const AWS_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY || ''; +const AWS_S3_OTA_BUCKET = process.env.AWS_S3_OTA_BUCKET || 'luxsin-app-bucket'; + +const OTA_X8_PUBLIC_BASE = (process.env.OTA_X8_PUBLIC_BASE || 'http://am.luxsinaudio.com').replace(/\/$/, ''); +const OTA_X9_URL_BASE = (process.env.OTA_X9_URL_BASE || 'http://source.luxsin.net').replace(/\/$/, ''); + +const OTA_FILENAME_X8 = 'LUXSIN_X8.PKG'; +const OTA_FILENAME_X9 = 'LUXSIN.PKG'; + +function getOtaUploadDir() { + const debugVal = (process.env.DEBUG || 'false').trim().toLowerCase(); + const isDev = ['true', '1', 'yes', 'on'].includes(debugVal); + const dev = process.env.OTA_UPLOAD_DIR_DEV || _OTA_UPLOAD_DIR_DEV_DEFAULT; + const prod = process.env.OTA_UPLOAD_DIR_PROD || _OTA_UPLOAD_DIR_PROD_DEFAULT; + return isDev ? dev : prod; +} + +function md5Prefix5(md5Hex) { + return (md5Hex || '').slice(0, 5); +} + +function buildX8PublicUrl(s3Key) { + return `${OTA_X8_PUBLIC_BASE}/${s3Key}`; +} + +function buildX9PublicUrl(ym, prefix) { + return `${OTA_X9_URL_BASE}/ota/${ym}/x9/${prefix}/${OTA_FILENAME_X9}`; +} + +async function readUploadContentAndMd5(fileBuffer) { + const md5Hash = crypto.createHash('md5').update(fileBuffer).digest('hex'); + return { content: fileBuffer, md5Hex: md5Hash }; +} + +function saveX9PackageLocal(content, md5Hex) { + const root = getOtaUploadDir(); + const prefix = md5Prefix5(md5Hex); + const ym = new Date().toISOString().slice(0, 7).replace('-', ''); + const destDir = path.join(root, prefix); + + fs.mkdirSync(destDir, { recursive: true }); + const finalPath = path.join(destDir, OTA_FILENAME_X9); + + if (fs.existsSync(finalPath)) { + fs.unlinkSync(finalPath); + } + fs.writeFileSync(finalPath, content); + + const downloadUrl = buildX9PublicUrl(ym, prefix); + logger.info(`OTA X9 package saved locally: ${finalPath}`); + return { savedName: OTA_FILENAME_X9, downloadUrl }; +} + +async function uploadX8PackageToS3(content, md5Hex) { + if (!AWS_ACCESS_KEY_ID || !AWS_SECRET_ACCESS_KEY) { + throw new Error('未配置 AWS 访问密钥(AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY)'); + } + + const ym = new Date().toISOString().slice(0, 7).replace('-', ''); + const prefix = md5Prefix5(md5Hex); + const s3Key = `ota/${ym}/x8/${prefix}/${OTA_FILENAME_X8}`; + + const client = new S3Client({ + region: AWS_REGION, + credentials: { + accessKeyId: AWS_ACCESS_KEY_ID, + secretAccessKey: AWS_SECRET_ACCESS_KEY, + }, + }); + + try { + await client.send( + new PutObjectCommand({ + Bucket: AWS_S3_OTA_BUCKET, + Key: s3Key, + Body: content, + ContentType: 'application/octet-stream', + }) + ); + } catch (e) { + logger.error(`S3 upload failed: ${e.message}`); + throw new Error(`S3 上传失败:${e.message}`); + } + + const downloadUrl = buildX8PublicUrl(s3Key); + logger.info(`OTA X8 package uploaded to s3://${AWS_S3_OTA_BUCKET}/${s3Key}`); + return { savedName: OTA_FILENAME_X8, downloadUrl, s3Key }; +} + +module.exports = { + OTA_MODEL_X8, + OTA_MODEL_X9, + OTA_UPLOAD_MODELS, + readUploadContentAndMd5, + saveX9PackageLocal, + uploadX8PackageToS3, +}; diff --git a/backend/src/utils/jwt.js b/backend/src/utils/jwt.js new file mode 100644 index 0000000..34ff418 --- /dev/null +++ b/backend/src/utils/jwt.js @@ -0,0 +1,28 @@ +const jwt = require('jsonwebtoken'); + +const JWT_SECRET = process.env.JWT_SECRET || 'dev-only-change-me-for-production'; +const JWT_ALGORITHM = 'HS256'; +const TOKEN_TTL_HOURS = 12; + +function createAccessToken() { + const now = Math.floor(Date.now() / 1000); + const payload = { + sub: process.env.DASHBOARD_ADMIN_USERNAME || 'admin', + iat: now, + exp: now + TOKEN_TTL_HOURS * 3600, + }; + return jwt.sign(payload, JWT_SECRET, { algorithm: JWT_ALGORITHM }); +} + +function decodeToken(token) { + return jwt.verify(token, JWT_SECRET, { algorithms: [JWT_ALGORITHM] }); +} + +function verifyCredentials(username, password) { + const adminUsername = process.env.DASHBOARD_ADMIN_USERNAME || 'admin'; + const adminPassword = process.env.DASHBOARD_ADMIN_PASSWORD || 'Eafon123'; + if (username !== adminUsername) return false; + return password === adminPassword; +} + +module.exports = { createAccessToken, decodeToken, verifyCredentials }; diff --git a/backend/src/utils/response.js b/backend/src/utils/response.js new file mode 100644 index 0000000..50d3e7a --- /dev/null +++ b/backend/src/utils/response.js @@ -0,0 +1,24 @@ +const ApiResponse = { + success(data = null, msg = 'success') { + return { code: 1, msg, data }; + }, + + error(msg = 'error', code = 0) { + return { code, msg, data: null }; + }, + + noData(msg = 'no data') { + return { code: 2, msg, data: null }; + }, +}; + +class PageData { + constructor(items, total, skip, limit) { + this.items = items; + this.total = total; + this.skip = skip; + this.limit = limit; + } +} + +module.exports = { ApiResponse, PageData }; diff --git a/backend/src/validators/brand.js b/backend/src/validators/brand.js new file mode 100644 index 0000000..223921c --- /dev/null +++ b/backend/src/validators/brand.js @@ -0,0 +1,11 @@ +const { z } = require('zod'); + +const BrandCreateSchema = z.object({ + name: z.string().min(1).max(100, '品牌名称最多100字符'), +}); + +const BrandUpdateSchema = z.object({ + name: z.string().min(1).max(100, '品牌名称最多100字符').optional(), +}); + +module.exports = { BrandCreateSchema, BrandUpdateSchema }; diff --git a/backend/src/validators/model.js b/backend/src/validators/model.js new file mode 100644 index 0000000..a17fba7 --- /dev/null +++ b/backend/src/validators/model.js @@ -0,0 +1,21 @@ +const { z } = require('zod'); + +const ModelCreateSchema = z.object({ + brand_name: z.string().min(1).max(100, '品牌名称最多100字符'), + name: z.string().min(1).max(100, '型号名称最多100字符'), + form: z.string().max(100).optional().nullable(), + rig: z.string().max(100).optional().nullable(), + source: z.string().max(100).optional().nullable(), + eq_key: z.string().max(255).optional().nullable(), +}); + +const ModelUpdateSchema = z.object({ + brand_name: z.string().min(1).max(100).optional().nullable(), + name: z.string().min(1).max(100).optional().nullable(), + form: z.string().max(100).optional().nullable(), + rig: z.string().max(100).optional().nullable(), + source: z.string().max(100).optional().nullable(), + eq_key: z.string().max(255).optional().nullable(), +}); + +module.exports = { ModelCreateSchema, ModelUpdateSchema }; diff --git a/backend/src/validators/ota.js b/backend/src/validators/ota.js new file mode 100644 index 0000000..f6c57d2 --- /dev/null +++ b/backend/src/validators/ota.js @@ -0,0 +1,43 @@ +const { z } = require('zod'); + +const OtaCreateSchema = z.object({ + verCode: z.number().int({ message: '版本号须为整数' }), + verName: z.string().min(1).max(20, '版本名称最多20字符'), + url: z.string().max(255, 'URL最多255字符'), + md5: z.string().length(32, 'MD5须为32字符'), + force: z.number().int().min(0).max(1).optional().default(0), + desc: z.string().max(255).optional().nullable(), + model: z.string().max(100).optional().nullable(), + hw: z.number().int().optional().default(0), + target: z.number().int().min(0).max(1).optional().default(0), + beta: z.number().int().min(0).max(1).optional().default(0), + pawVerCode: z.number().int().optional().default(0), + pawVerName: z.string().max(20).optional().default(''), + pawUrl: z.string().max(255).optional().default(''), + pawMd5: z.string().length(32).optional().default(''), + startTime: z.string().optional().nullable(), + endTime: z.string().optional().nullable(), + status: z.number().int().min(0).max(1).optional().default(1), +}); + +const OtaUpdateSchema = z.object({ + verCode: z.number().int().optional().nullable(), + verName: z.string().min(1).max(20).optional().nullable(), + url: z.string().max(255).optional().nullable(), + md5: z.string().length(32).optional().nullable(), + force: z.number().int().min(0).max(1).optional().nullable(), + desc: z.string().max(255).optional().nullable(), + model: z.string().max(100).optional().nullable(), + hw: z.number().int().optional().nullable(), + target: z.number().int().min(0).max(1).optional().nullable(), + beta: z.number().int().min(0).max(1).optional().nullable(), + pawVerCode: z.number().int().optional().nullable(), + pawVerName: z.string().max(20).optional().nullable(), + pawUrl: z.string().max(255).optional().nullable(), + pawMd5: z.string().length(32).optional().nullable(), + startTime: z.string().optional().nullable(), + endTime: z.string().optional().nullable(), + status: z.number().int().min(0).max(1).optional().nullable(), +}); + +module.exports = { OtaCreateSchema, OtaUpdateSchema }; diff --git a/backend/start.bat b/backend/start.bat deleted file mode 100644 index e2ff1c1..0000000 --- a/backend/start.bat +++ /dev/null @@ -1,5 +0,0 @@ -@echo off -echo Starting Audio Dashboard API... -cd /d "%~dp0" -python main.py -pause diff --git a/backend/start.sh b/backend/start.sh new file mode 100755 index 0000000..cd1e301 --- /dev/null +++ b/backend/start.sh @@ -0,0 +1,4 @@ +#!/bin/bash +echo "Starting Audio Dashboard API..." +cd "$(dirname "$0")" +node src/app.js diff --git a/backend/stop.bat b/backend/stop.bat deleted file mode 100644 index ae73f7c..0000000 --- a/backend/stop.bat +++ /dev/null @@ -1,19 +0,0 @@ -@echo off -echo Stopping Audio Dashboard API... - -REM 查找占用 8002 端口的进程 -for /f "tokens=5" %%a in ('netstat -ano ^| findstr :8002') do ( - set PID=%%a - goto :found -) - -:found -if defined PID ( - echo Found process PID: %PID% - taskkill /F /PID %PID% - echo Service stopped. -) else ( - echo No service running on port 8002. -) - -pause diff --git a/backend/stop.sh b/backend/stop.sh new file mode 100755 index 0000000..1ab204f --- /dev/null +++ b/backend/stop.sh @@ -0,0 +1,13 @@ +#!/bin/bash +echo "Stopping Audio Dashboard API..." + +# 查找占用 8083 端口的进程 +PID=$(lsof -ti :8083 2>/dev/null) + +if [ -n "$PID" ]; then + echo "Found process PID: $PID" + kill -9 "$PID" 2>/dev/null + echo "Service stopped." +else + echo "No service running on port 8083." +fi diff --git a/convert_to_frequency_db.py b/convert_to_frequency_db.py deleted file mode 100644 index ff82892..0000000 --- a/convert_to_frequency_db.py +++ /dev/null @@ -1,163 +0,0 @@ -import cv2 -import numpy as np -import matplotlib.pyplot as plt -import json - -# 读取图片 -img_path = r"C:\Users\yangy\.cursor\projects\h-soft-projects-luxsin-dashboard/assets/c__Users_yangy_AppData_Roaming_Cursor_User_workspaceStorage_b134a9df77916b35c1e5b1ece8dc14fe_images_Arcona-avg-0b78da44-4aaa-465b-9b2b-fba051a28742.png" -img = cv2.imread(img_path) -img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) -img_copy = img.copy() -gray = cv2.cvtColor(img_copy, cv2.COLOR_RGB2GRAY) - -height, width = gray.shape -print(f"图片尺寸:{height}x{width}") - -# 定义图表区域 -chart_top = int(height * 0.05) -chart_bottom = int(height * 0.92) -chart_left = int(width * 0.02) -chart_right = int(width * 0.98) - -chart_height = chart_bottom - chart_top -chart_width = chart_right - chart_left - -print(f"图表区域:top={chart_top}, bottom={chart_bottom}, left={chart_left}, right={chart_right}") -print(f"图表尺寸:{chart_width}x{chart_height}") - -# 频率轴映射(对数刻度) -# 从图中可以看到:20Hz 在最左边,20kHz 在最右边 -# 使用对数刻度映射 -freq_min = 20 # Hz -freq_max = 20000 # Hz - -# dB 轴映射(线性刻度) -# 从图中可以看到:顶部约 120dB,底部约 70dB -db_max = 120 # 图表顶部对应的 dB 值(y 坐标最小) -db_min = 70 # 图表底部对应的 dB 值(y 坐标最大) - -# 提取白色线条 -chart_roi = gray[chart_top:chart_bottom, chart_left:chart_right] -_, thresh = cv2.threshold(chart_roi, 200, 255, cv2.THRESH_BINARY) - -kernel = np.ones((3,3), np.uint8) -dilated_thresh = cv2.dilate(thresh, kernel, iterations=2) -eroded_thresh = cv2.erode(dilated_thresh, kernel, iterations=1) - -contours, _ = cv2.findContours(eroded_thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) - -largest_contour = None -max_area = 0 -for contour in contours: - area = cv2.contourArea(contour) - if area > max_area: - max_area = area - largest_contour = contour - -frequency_data = [] - -if largest_contour is not None: - x_coords = {} - - for point in largest_contour: - x, y = point[0] - global_x = int(x) + chart_left - global_y = int(y) + chart_top - - if global_x not in x_coords: - x_coords[global_x] = [] - x_coords[global_x].append(global_y) - - # 对每个 x,计算平均 y 值,并转换为频率和 dB 值 - for x in sorted(x_coords.keys()): - y_values = x_coords[x] - avg_y = sum(y_values) // len(y_values) - - # 计算在图表中的相对位置 - relative_x = x - chart_left - relative_y = avg_y - chart_top - - # 转换为频率(对数刻度) - # log10(f) = log10(f_min) + (relative_x / chart_width) * (log10(f_max) - log10(f_min)) - log_freq = np.log10(freq_min) + (relative_x / chart_width) * (np.log10(freq_max) - np.log10(freq_min)) - frequency_hz = 10 ** log_freq - - # 转换为 dB 值(线性刻度,注意 y 轴是反向的) - db_value = db_max - (relative_y / chart_height) * (db_max - db_min) - - frequency_data.append({ - "pixel_x": int(x), - "pixel_y": int(avg_y), - "frequency_hz": round(frequency_hz, 2), - "db_value": round(db_value, 2) - }) - - print(f"\n提取的频响曲线点数:{len(frequency_data)}") - - # 创建可视化结果 - result = img_copy.copy() - for point in frequency_data: - cv2.circle(result, (point["pixel_x"], point["pixel_y"]), 1, (255, 0, 0), -1) - - plt.figure(figsize=(20, 10)) - plt.imshow(result) - plt.title(f'Extracted Frequency Response ({len(frequency_data)} points)') - plt.axis('off') - plt.tight_layout() - plt.show() - - # 保存详细数据到 JSON - output_data = { - "metadata": { - "image_size": {"width": width, "height": height}, - "chart_area": { - "top": chart_top, - "bottom": chart_bottom, - "left": chart_left, - "right": chart_right - }, - "frequency_range": {"min": freq_min, "max": freq_max}, - "db_range": {"min": db_min, "max": db_max} - }, - "data_points": frequency_data - } - - with open('frequency_response_detailed.json', 'w', encoding='utf-8') as f: - json.dump(output_data, f, ensure_ascii=False, indent=2) - - print(f"\n详细数据已保存到 frequency_response_detailed.json") - - # 显示前 50 个点 - print(f"\n前 50 个频响点位(像素坐标 -> 实际值):") - print(f"{'序号':<6} {'X':<8} {'Y':<8} {'频率 (Hz)':<12} {'dB 值':<8}") - print("-" * 50) - for i, point in enumerate(frequency_data[:50]): - print(f"{i+1:<6} {point['pixel_x']:<8} {point['pixel_y']:<8} {point['frequency_hz']:<12.2f} {point['db_value']:<8.2f}") - - # 保存为 CSV 格式 - with open('frequency_response.csv', 'w', encoding='utf-8') as f: - f.write("index,pixel_x,pixel_y,frequency_hz,db_value\n") - for i, point in enumerate(frequency_data): - f.write(f"{i+1},{point['pixel_x']},{point['pixel_y']},{point['frequency_hz']},{point['db_value']}\n") - - print(f"\nCSV 数据已保存到 frequency_response.csv") - - # 绘制频率响应曲线图 - frequencies = [p["frequency_hz"] for p in frequency_data] - db_values = [p["db_value"] for p in frequency_data] - - plt.figure(figsize=(15, 8)) - plt.semilogx(frequencies, db_values, linewidth=1) - plt.grid(True, which='both', linestyle='-', alpha=0.7) - plt.xlabel('Frequency (Hz)') - plt.ylabel('Amplitude (dB)') - plt.title('Extracted Frequency Response Curve') - plt.xlim(20, 20000) - plt.ylim(70, 120) - plt.tight_layout() - plt.savefig('frequency_response_curve.png', dpi=150) - plt.show() - - print(f"\n频响曲线图已保存到 frequency_response_curve.png") -else: - print("未找到频响曲线") diff --git a/frontend/index.html b/frontend/index.html index 3821548..c517803 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,16 +1,50 @@ - + - + 耳机管理平台 + + - +
+
+
加载中...
+
diff --git a/frontend/package.json b/frontend/package.json index 032b4a0..9a16ff1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,5 +18,5 @@ "@vitejs/plugin-vue": "^4.3.4", "vite": "^4.4.9" }, - "packageManager": "pnpm@8.0.0" + "packageManager": "pnpm@11.5.2+sha512.71c631e382066efc25625d5cf029075de07b61b37f6e27350fbd84b1bda5864c8c1967adc280776b45c30a715c0359a3be08fef42d5bb09e2b99029979692916" } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 0000000..dd9879d --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,913 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@element-plus/icons-vue': + specifier: ^2.1.0 + version: 2.3.2(vue@3.5.35) + axios: + specifier: ^1.5.0 + version: 1.17.0 + element-plus: + specifier: ^2.3.14 + version: 2.14.1(vue@3.5.35) + vue: + specifier: ^3.3.4 + version: 3.5.35 + vue-router: + specifier: ^4.2.4 + version: 4.6.4(vue@3.5.35) + devDependencies: + '@vitejs/plugin-vue': + specifier: ^4.3.4 + version: 4.3.4(vite@4.4.9)(vue@3.5.35) + vite: + specifier: ^4.4.9 + version: 4.4.9 + +packages: + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@ctrl/tinycolor@4.2.0': + resolution: {integrity: sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==} + engines: {node: '>=14'} + + '@element-plus/icons-vue@2.3.2': + resolution: {integrity: sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==} + peerDependencies: + vue: ^3.2.0 + + '@esbuild/android-arm64@0.18.10': + resolution: {integrity: sha512-ynm4naLbNbK0ajf9LUWtQB+6Vfg1Z/AplArqr4tGebC00Z6m9Y91OVIcjDa461wGcZwcaHYaZAab4yJxfhisTQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.18.10': + resolution: {integrity: sha512-3KClmVNd+Fku82uZJz5C4Rx8m1PPmWUFz5Zkw8jkpZPOmsq+EG1TTOtw1OXkHuX3WczOFQigrtf60B1ijKwNsg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.18.10': + resolution: {integrity: sha512-vFfXj8P9Yfjh54yqUDEHKzqzYuEfPyAOl3z7R9hjkwt+NCvbn9VMxX+IILnAfdImRBfYVItgSUsqGKhJFnBwZw==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.18.10': + resolution: {integrity: sha512-k2OJQ7ZxE6sVc91+MQeZH9gFeDAH2uIYALPAwTjTCvcPy9Dzrf7V7gFUQPYkn09zloWhQ+nvxWHia2x2ZLR0sQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.18.10': + resolution: {integrity: sha512-tnz/mdZk1L1Z3WpGjin/L2bKTe8/AKZpI8fcCLtH+gq8WXWsCNJSxlesAObV4qbtTl6pG5vmqFXfWUQ5hV8PAQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.18.10': + resolution: {integrity: sha512-QJluV0LwBrbHnYYwSKC+K8RGz0g/EyhpQH1IxdoFT0nM7PfgjE+aS8wxq/KFEsU0JkL7U/EEKd3O8xVBxXb2aA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.18.10': + resolution: {integrity: sha512-Hi/ycUkS6KTw+U9G5PK5NoK7CZboicaKUSVs0FSiPNtuCTzK6HNM4DIgniH7hFaeuszDS9T4dhAHWiLSt/Y5Ng==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.18.10': + resolution: {integrity: sha512-Nz6XcfRBOO7jSrVpKAyEyFOPGhySPNlgumSDhWAspdQQ11ub/7/NZDMhWDFReE9QH/SsCOCLQbdj0atAk/HMOQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.18.10': + resolution: {integrity: sha512-HfFoxY172tVHPIvJy+FHxzB4l8xU7e5cxmNS11cQ2jt4JWAukn/7LXaPdZid41UyTweqa4P/1zs201gRGCTwHw==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.18.10': + resolution: {integrity: sha512-otMdmSmkMe+pmiP/bZBjfphyAsTsngyT9RCYwoFzqrveAbux9nYitDTpdgToG0Z0U55+PnH654gCH2GQ1aB6Yw==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.18.10': + resolution: {integrity: sha512-t8tjFuON1koxskzQ4VFoh0T5UDUMiLYjwf9Wktd0tx8AoK6xgU+5ubKOpWpcnhEQ2tESS5u0v6QuN8PX/ftwcQ==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.18.10': + resolution: {integrity: sha512-+dUkcVzcfEJHz3HEnVpIJu8z8Wdn2n/nWMWdl6FVPFGJAVySO4g3+XPzNKFytVFwf8hPVDwYXzVcu8GMFqsqZw==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.18.10': + resolution: {integrity: sha512-sO3PjjxEGy+PY2qkGe2gwJbXdZN9wAYpVBZWFD0AwAoKuXRkWK0/zaMQ5ekUFJDRDCRm8x5U0Axaub7ynH/wVg==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.18.10': + resolution: {integrity: sha512-JDtdbJg3yjDeXLv4lZYE1kiTnxv73/8cbPHY9T/dUKi8rYOM/k5b3W4UJLMUksuQ6nTm5c89W1nADsql6FW75A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.18.10': + resolution: {integrity: sha512-NLuSKcp8WckjD2a7z5kzLiCywFwBTMlIxDNuud1AUGVuwBBJSkuubp6cNjJ0p5c6CZaA3QqUGwjHJBiG1SoOFw==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.18.10': + resolution: {integrity: sha512-wj2KRsCsFusli+6yFgNO/zmmLslislAWryJnodteRmGej7ZzinIbMdsyp13rVGde88zxJd5vercNYK9kuvlZaQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.18.10': + resolution: {integrity: sha512-pQ9QqxEPI3cVRZyUtCoZxhZK3If+7RzR8L2yz2+TDzdygofIPOJFaAPkEJ5rYIbUO101RaiYxfdOBahYexLk5A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.18.10': + resolution: {integrity: sha512-k8GTIIW9I8pEEfoOUm32TpPMgSg06JhL5DO+ql66aLTkOQUs0TxCA67Wi7pv6z8iF8STCGcNbm3UWFHLuci+ag==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.18.10': + resolution: {integrity: sha512-vIGYJIdEI6d4JBucAx8py792G8J0GP40qSH+EvSt80A4zvGd6jph+5t1g+eEXcS2aRpgZw6CrssNCFZxTdEsxw==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.18.10': + resolution: {integrity: sha512-kRhNcMZFGMW+ZHCarAM1ypr8OZs0k688ViUCetVCef9p3enFxzWeBg9h/575Y0nsFu0ZItluCVF5gMR2pwOEpA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.18.10': + resolution: {integrity: sha512-AR9PX1whYaYh9p0EOaKna0h48F/A101Mt/ag72+kMkkBZXPQ7cjbz2syXI/HI3OlBdUytSdHneljfjvUoqwqiQ==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.18.10': + resolution: {integrity: sha512-5sTkYhAGHNRr6bVf4RM0PsscqVr6/DBYdrlMh168oph3usid3lKHcHEEHmr34iZ9GHeeg2juFOxtpl6XyC3tpw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@sxzz/popperjs-es@2.11.8': + resolution: {integrity: sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==} + + '@types/lodash-es@4.17.12': + resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} + + '@types/lodash@4.17.24': + resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} + + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + + '@vitejs/plugin-vue@4.3.4': + resolution: {integrity: sha512-ciXNIHKPriERBisHFBvnTbfKa6r9SAesOYXeGDzgegcvy9Q4xdScSHAmKbNT0M3O0S9LKhIf5/G+UYG4NnnzYw==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.0.0 + vue: ^3.2.25 + + '@vue/compiler-core@3.5.35': + resolution: {integrity: sha512-BUmHaR1J+O+CKZ9uJucdVTEr1LHsdyvv7vG3eNRhK3CczEHeMd/LtsHAuD7PbrxvI2envCY2v7HI1vC1aBRzKw==} + + '@vue/compiler-dom@3.5.35': + resolution: {integrity: sha512-k+bprkXxuqhVajgTx5mUHuir7TwQzUKOWR40ng1ncAqQRPnrLngGGgqVEEhOnTMlc8btHYVKmrP8s5Qyg0hvYA==} + + '@vue/compiler-sfc@3.5.35': + resolution: {integrity: sha512-G5VPMcXTSywXBgtFOZOnHKBxKSrwXUcvY1iaF5/hRcy7t0J6CH/d8ha9F4nzi00Fax1eLV0QHM7v4mQu68jydw==} + + '@vue/compiler-ssr@3.5.35': + resolution: {integrity: sha512-rGhAeXgdM7/ffTJGXT69rCCdTmjDewnFuUZfBQQHTdcEBeWdT5HCGY60y2ytLJr9/Dsu7IntUi5z/w0h6Rjnzw==} + + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + + '@vue/reactivity@3.5.35': + resolution: {integrity: sha512-tVc+SsHConvh/Lz64qq1pP3rYArBmK42xonovEcxY74SQtvctZodG/zhq54P5dr38cVuw25d27cPNRdlMidpGQ==} + + '@vue/runtime-core@3.5.35': + resolution: {integrity: sha512-A/xFNX9loIcWDygeQuNCfKuh0CoYBzxhqEMNah5TSFg9Z53DrFYEN2qi5CU9necjM1OWYegYREUTHmXTmhfXtg==} + + '@vue/runtime-dom@3.5.35': + resolution: {integrity: sha512-odrJ1C391dbGnyDRh8U+rnP7J2amIEzfmRk5vXy7xi3aZhEXofTvpi0T4HJb6jlNqQZTNPR5MPHSB3RHNkIORA==} + + '@vue/server-renderer@3.5.35': + resolution: {integrity: sha512-NkebSOYdB97wi8OQcO3HqzZSlymJi/aWsN/7h74OSVhRTm6qGs3Jp3e0rCXynmWwSlKeRrnlIug+ilYoHBmQDA==} + peerDependencies: + vue: 3.5.35 + + '@vue/shared@3.5.35': + resolution: {integrity: sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA==} + + '@vueuse/core@14.3.0': + resolution: {integrity: sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==} + peerDependencies: + vue: ^3.5.0 + + '@vueuse/metadata@14.3.0': + resolution: {integrity: sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==} + + '@vueuse/shared@14.3.0': + resolution: {integrity: sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==} + peerDependencies: + vue: ^3.5.0 + + agent-base@6.0.0: + resolution: {integrity: sha512-j1Q7cSCqN+AwrmDd+pzgqc0/NpC655x2bUf5ZjRIO77DcNBFmh+OgRNzF6OKdCC9RSCb19fGd99+bhXFdkRNqw==} + engines: {node: '>= 6.0.0'} + + async-validator@4.2.5: + resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.17.0: + resolution: {integrity: sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + element-plus@2.14.1: + resolution: {integrity: sha512-UFnm1+BckNi+azkKJ7L32q1uXs9ekr99Z9pWTQPeDR05jqEWUwQq51ro4kZMVrANbjknX3Z7ukCZwTi2T6Tr9A==} + peerDependencies: + vue: ^3.3.7 + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.18.10: + resolution: {integrity: sha512-33WKo67auOXzZHBY/9DTJRo7kIvfU12S+D4sp2wIz39N88MDIaCGyCwbW01RR70pK6Iya0I74lHEpyLfFqOHPA==} + engines: {node: '>=12'} + hasBin: true + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + + lodash-unified@1.0.3: + resolution: {integrity: sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==} + peerDependencies: + '@types/lodash-es': '*' + lodash: '*' + lodash-es: '*' + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + memoize-one@6.0.0: + resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + normalize-wheel-es@1.2.0: + resolution: {integrity: sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + rollup@3.30.0: + resolution: {integrity: sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA==} + engines: {node: '>=14.18.0', npm: '>=8.0.0'} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + vite@4.4.9: + resolution: {integrity: sha512-2mbUn2LlUmNASWwSCNSJ/EG2HuSRTnVNaydp6vMCm5VIqJsjMfbIWtbH2kDuwUVW5mMUKKZvGPX/rqeqVvv1XA==} + engines: {node: ^14.18.0 || >=16.0.0} + hasBin: true + peerDependencies: + '@types/node': '>= 14' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vue-component-type-helpers@3.3.3: + resolution: {integrity: sha512-x4nsFpy5Pe8fqPzp/5vkTPeTTDBpAx4WVtV47Ejt0+2FQrq4pRRsJs7JmYRqMFzTu/LW+pCWEjQ3YVCkPV7f9g==} + + vue-router@4.6.4: + resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==} + peerDependencies: + vue: ^3.5.0 + + vue@3.5.35: + resolution: {integrity: sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + +snapshots: + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@ctrl/tinycolor@4.2.0': {} + + '@element-plus/icons-vue@2.3.2(vue@3.5.35)': + dependencies: + vue: 3.5.35 + + '@esbuild/android-arm64@0.18.10': + optional: true + + '@esbuild/android-arm@0.18.10': + optional: true + + '@esbuild/android-x64@0.18.10': + optional: true + + '@esbuild/darwin-arm64@0.18.10': + optional: true + + '@esbuild/darwin-x64@0.18.10': + optional: true + + '@esbuild/freebsd-arm64@0.18.10': + optional: true + + '@esbuild/freebsd-x64@0.18.10': + optional: true + + '@esbuild/linux-arm64@0.18.10': + optional: true + + '@esbuild/linux-arm@0.18.10': + optional: true + + '@esbuild/linux-ia32@0.18.10': + optional: true + + '@esbuild/linux-loong64@0.18.10': + optional: true + + '@esbuild/linux-mips64el@0.18.10': + optional: true + + '@esbuild/linux-ppc64@0.18.10': + optional: true + + '@esbuild/linux-riscv64@0.18.10': + optional: true + + '@esbuild/linux-s390x@0.18.10': + optional: true + + '@esbuild/linux-x64@0.18.10': + optional: true + + '@esbuild/netbsd-x64@0.18.10': + optional: true + + '@esbuild/openbsd-x64@0.18.10': + optional: true + + '@esbuild/sunos-x64@0.18.10': + optional: true + + '@esbuild/win32-arm64@0.18.10': + optional: true + + '@esbuild/win32-ia32@0.18.10': + optional: true + + '@esbuild/win32-x64@0.18.10': + optional: true + + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/utils@0.2.11': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@sxzz/popperjs-es@2.11.8': {} + + '@types/lodash-es@4.17.12': + dependencies: + '@types/lodash': 4.17.24 + + '@types/lodash@4.17.24': {} + + '@types/web-bluetooth@0.0.21': {} + + '@vitejs/plugin-vue@4.3.4(vite@4.4.9)(vue@3.5.35)': + dependencies: + vite: 4.4.9 + vue: 3.5.35 + + '@vue/compiler-core@3.5.35': + dependencies: + '@babel/parser': 7.29.7 + '@vue/shared': 3.5.35 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.35': + dependencies: + '@vue/compiler-core': 3.5.35 + '@vue/shared': 3.5.35 + + '@vue/compiler-sfc@3.5.35': + dependencies: + '@babel/parser': 7.29.7 + '@vue/compiler-core': 3.5.35 + '@vue/compiler-dom': 3.5.35 + '@vue/compiler-ssr': 3.5.35 + '@vue/shared': 3.5.35 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.15 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.35': + dependencies: + '@vue/compiler-dom': 3.5.35 + '@vue/shared': 3.5.35 + + '@vue/devtools-api@6.6.4': {} + + '@vue/reactivity@3.5.35': + dependencies: + '@vue/shared': 3.5.35 + + '@vue/runtime-core@3.5.35': + dependencies: + '@vue/reactivity': 3.5.35 + '@vue/shared': 3.5.35 + + '@vue/runtime-dom@3.5.35': + dependencies: + '@vue/reactivity': 3.5.35 + '@vue/runtime-core': 3.5.35 + '@vue/shared': 3.5.35 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.35(vue@3.5.35)': + dependencies: + '@vue/compiler-ssr': 3.5.35 + '@vue/shared': 3.5.35 + vue: 3.5.35 + + '@vue/shared@3.5.35': {} + + '@vueuse/core@14.3.0(vue@3.5.35)': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 14.3.0 + '@vueuse/shared': 14.3.0(vue@3.5.35) + vue: 3.5.35 + + '@vueuse/metadata@14.3.0': {} + + '@vueuse/shared@14.3.0(vue@3.5.35)': + dependencies: + vue: 3.5.35 + + agent-base@6.0.0: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + async-validator@4.2.5: {} + + asynckit@0.4.0: {} + + axios@1.17.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.5 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + csstype@3.2.3: {} + + dayjs@1.11.21: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + delayed-stream@1.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + element-plus@2.14.1(vue@3.5.35): + dependencies: + '@ctrl/tinycolor': 4.2.0 + '@element-plus/icons-vue': 2.3.2(vue@3.5.35) + '@floating-ui/dom': 1.7.6 + '@popperjs/core': '@sxzz/popperjs-es@2.11.8' + '@types/lodash': 4.17.24 + '@types/lodash-es': 4.17.12 + '@vueuse/core': 14.3.0(vue@3.5.35) + async-validator: 4.2.5 + dayjs: 1.11.21 + lodash: 4.18.1 + lodash-es: 4.18.1 + lodash-unified: 1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.18.1)(lodash@4.18.1) + memoize-one: 6.0.0 + normalize-wheel-es: 1.2.0 + vue: 3.5.35 + vue-component-type-helpers: 3.3.3 + + entities@7.0.1: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + esbuild@0.18.10: + optionalDependencies: + '@esbuild/android-arm': 0.18.10 + '@esbuild/android-arm64': 0.18.10 + '@esbuild/android-x64': 0.18.10 + '@esbuild/darwin-arm64': 0.18.10 + '@esbuild/darwin-x64': 0.18.10 + '@esbuild/freebsd-arm64': 0.18.10 + '@esbuild/freebsd-x64': 0.18.10 + '@esbuild/linux-arm': 0.18.10 + '@esbuild/linux-arm64': 0.18.10 + '@esbuild/linux-ia32': 0.18.10 + '@esbuild/linux-loong64': 0.18.10 + '@esbuild/linux-mips64el': 0.18.10 + '@esbuild/linux-ppc64': 0.18.10 + '@esbuild/linux-riscv64': 0.18.10 + '@esbuild/linux-s390x': 0.18.10 + '@esbuild/linux-x64': 0.18.10 + '@esbuild/netbsd-x64': 0.18.10 + '@esbuild/openbsd-x64': 0.18.10 + '@esbuild/sunos-x64': 0.18.10 + '@esbuild/win32-arm64': 0.18.10 + '@esbuild/win32-ia32': 0.18.10 + '@esbuild/win32-x64': 0.18.10 + + estree-walker@2.0.2: {} + + follow-redirects@1.16.0: {} + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + lodash-es@4.18.1: {} + + lodash-unified@1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.18.1)(lodash@4.18.1): + dependencies: + '@types/lodash-es': 4.17.12 + lodash: 4.18.1 + lodash-es: 4.18.1 + + lodash@4.18.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + memoize-one@6.0.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + ms@2.1.3: {} + + nanoid@3.3.12: {} + + normalize-wheel-es@1.2.0: {} + + picocolors@1.1.1: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + proxy-from-env@2.1.0: {} + + rollup@3.30.0: + optionalDependencies: + fsevents: 2.3.3 + + source-map-js@1.2.1: {} + + vite@4.4.9: + dependencies: + esbuild: 0.18.10 + postcss: 8.5.15 + rollup: 3.30.0 + optionalDependencies: + fsevents: 2.3.3 + + vue-component-type-helpers@3.3.3: {} + + vue-router@4.6.4(vue@3.5.35): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.35 + + vue@3.5.35: + dependencies: + '@vue/compiler-dom': 3.5.35 + '@vue/compiler-sfc': 3.5.35 + '@vue/runtime-dom': 3.5.35 + '@vue/server-renderer': 3.5.35(vue@3.5.35) + '@vue/shared': 3.5.35 diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml new file mode 100644 index 0000000..5ed0b5a --- /dev/null +++ b/frontend/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true diff --git a/frontend/src/main.js b/frontend/src/main.js index 3c2dbfe..b3abcc5 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -1,5 +1,6 @@ import { createApp } from 'vue' import ElementPlus from 'element-plus' +import 'element-plus/theme-chalk/dark/css-vars.css' import 'element-plus/dist/index.css' import '@/styles/lux-theme.css' import zhCn from 'element-plus/dist/locale/zh-cn.mjs' @@ -12,3 +13,13 @@ app.use(ElementPlus, { locale: zhCn }) app.use(router) app.mount('#app') + +// 等浏览器完成绘制后显示应用并移除遮罩 +requestAnimationFrame(() => { + requestAnimationFrame(() => { + const appEl = document.getElementById('app') + if (appEl) appEl.classList.add('app-visible') + const loadingEl = document.getElementById('app-loading') + if (loadingEl) loadingEl.style.display = 'none' + }) +}) diff --git a/frontend/src/styles/lux-theme.css b/frontend/src/styles/lux-theme.css index 116098c..3e32c18 100644 --- a/frontend/src/styles/lux-theme.css +++ b/frontend/src/styles/lux-theme.css @@ -5,22 +5,23 @@ body { } /** - * 全站主题(与登录页一致):深色宇宙底、浅色内容卡片、青紫渐变主按钮 - * 仅作用于 .lux-shell 内,避免影响登录页 + * 全站主题:深色宇宙底、深色卡片、青紫渐变主按钮 */ :root { --lux-font: 'Inter', 'Segoe UI', system-ui, -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif; --lux-page-bg: #0a0e14; - --lux-text-strong: #0f172a; - --lux-text-muted: #64748b; + --lux-text-strong: #e2e8f0; + --lux-text-muted: #94a3b8; --lux-cyan: #38bdf8; --lux-indigo: #6366f1; --lux-violet: #7c3aed; - --lux-card-grad: linear-gradient(165deg, #fafbfc 0%, #f0f3f8 100%); - --lux-glass-sidebar: rgba(15, 23, 42, 0.52); - --lux-glass-header: rgba(250, 251, 252, 0.9); + --lux-card-bg: #1e293b; + --lux-card-border: rgba(148, 163, 184, 0.12); + --lux-glass-sidebar: rgba(15, 23, 42, 0.6); + --lux-glass-header: rgba(30, 41, 59, 0.8); + --lux-input-bg: #0f172a; } .lux-shell { @@ -165,7 +166,7 @@ body { color: inherit; } -/* —— 顶栏(与内容卡片一致的浅色底) —— */ +/* —— 顶栏(深色玻璃底) —— */ .lux-shell .lux-header { height: 44px !important; min-height: 44px !important; @@ -175,13 +176,11 @@ body { padding: 0 14px !important; margin: 8px 16px 0 8px; border-radius: 10px; - background: var(--lux-card-grad) !important; + background: rgba(30, 41, 59, 0.75) !important; backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); - border: 1px solid rgba(148, 163, 184, 0.22) !important; - box-shadow: - 0 2px 4px -1px rgba(15, 23, 42, 0.05), - 0 0 0 1px rgba(255, 255, 255, 0.65) inset; + border: 1px solid rgba(148, 163, 184, 0.12) !important; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); } .lux-shell .lux-header-power-btn { @@ -236,28 +235,28 @@ body { font-size: 18px; font-weight: 700; letter-spacing: -0.02em; - color: #0f172a; + color: #e2e8f0; } .lux-shell .brand-not-in-db-tip { - color: #b45309; + color: #f59e0b; } -/* —— 卡片 / 表格 —— */ +/* —— 卡片 / 表格(深色风格) —— */ .lux-shell .el-card { border-radius: 16px !important; - background: var(--lux-card-grad) !important; - border: 1px solid rgba(148, 163, 184, 0.22) !important; + background: #1e293b !important; + border: 1px solid rgba(148, 163, 184, 0.12) !important; box-shadow: - 0 4px 6px -1px rgba(15, 23, 42, 0.06), - 0 14px 32px -10px rgba(15, 23, 42, 0.14), - 0 0 0 1px rgba(255, 255, 255, 0.65) inset !important; + 0 4px 6px -1px rgba(0, 0, 0, 0.2), + 0 14px 32px -10px rgba(0, 0, 0, 0.3) !important; + color: #e2e8f0; } .lux-shell .el-card__header { - border-bottom: 1px solid rgba(148, 163, 184, 0.18) !important; + border-bottom: 1px solid rgba(148, 163, 184, 0.12) !important; font-weight: 600; - color: var(--lux-text-strong); + color: #e2e8f0; } /* 列表页顶部筛选栏:压低整体高度 */ @@ -275,56 +274,60 @@ body { } .lux-shell .el-table { - --el-table-border-color: rgba(148, 163, 184, 0.22); - --el-table-header-bg-color: #eef2f7; - --el-table-row-hover-bg-color: rgba(56, 189, 248, 0.06); - color: var(--lux-text-strong); + --el-table-border-color: rgba(148, 163, 184, 0.1); + --el-table-header-bg-color: #0f172a; + --el-table-row-hover-bg-color: rgba(56, 189, 248, 0.08); + --el-table-bg-color: #1e293b; + --el-table-tr-bg-color: #1e293b; + --el-table-header-text-color: #cbd5e1; + --el-table-text-color: #cbd5e1; + color: #e2e8f0; border-radius: 12px; overflow: hidden; } .lux-shell .el-table th.el-table__cell { font-weight: 600; - color: #334155; + color: #94a3b8; + background: #0f172a !important; } .lux-shell .el-pagination { --el-pagination-font-size: 13px; - --el-pagination-button-color: #475569; + --el-pagination-button-color: #94a3b8; --el-pagination-hover-color: var(--lux-cyan); + --el-pagination-bg-color: transparent; +} + +.lux-shell .el-pagination .el-pager li { + color: #94a3b8; } .lux-shell .el-pagination .el-pager li.is-active { color: #fff !important; - background: linear-gradient(135deg, var(--lux-cyan), var(--lux-indigo)) !important; + background: #38bdf8 !important; border-radius: 8px; } -/* 主按钮渐变 */ +/* 主按钮 */ .lux-shell .el-button--primary { --el-button-bg-color: transparent; --el-button-border-color: transparent; - background: linear-gradient( - 92deg, - #38bdf8 0%, - #4f8ff7 38%, - #6366f1 72%, - #7c3aed 100% - ) !important; + background: #38bdf8 !important; border: none !important; - box-shadow: 0 8px 20px rgba(56, 189, 248, 0.28); + box-shadow: 0 4px 12px rgba(56, 189, 248, 0.25); } .lux-shell .el-button--primary:hover, .lux-shell .el-button--primary:focus { - filter: brightness(1.06); - box-shadow: 0 10px 26px rgba(56, 189, 248, 0.35); + background: #22d3ee !important; + box-shadow: 0 6px 16px rgba(56, 189, 248, 0.3); } .lux-shell .el-button--primary.is-plain { - background: rgba(56, 189, 248, 0.12) !important; - color: #0369a1 !important; - border: 1px solid rgba(56, 189, 248, 0.45) !important; + background: rgba(56, 189, 248, 0.15) !important; + color: #7dd3fc !important; + border: 1px solid rgba(56, 189, 248, 0.35) !important; box-shadow: none; } @@ -341,18 +344,28 @@ body { box-shadow: 0 4px 14px rgba(239, 68, 68, 0.2); } -/* 输入框(搜索栏等) */ +/* 输入框(深色风格) */ .lux-shell .el-input__wrapper { border-radius: 10px; - box-shadow: 0 0 0 1px rgba(148, 163, 184, 0.25) inset; + background-color: #0f172a !important; + box-shadow: 0 0 0 1px rgba(148, 163, 184, 0.15) inset !important; } .lux-shell .el-input__wrapper.is-focus { - box-shadow: 0 0 0 1px rgba(56, 189, 248, 0.45) inset, 0 0 0 3px rgba(56, 189, 248, 0.15); + box-shadow: 0 0 0 1px rgba(56, 189, 248, 0.45) inset, 0 0 0 3px rgba(56, 189, 248, 0.15) !important; +} + +.lux-shell .el-input__inner { + color: #e2e8f0 !important; +} + +.lux-shell .el-input__inner::placeholder { + color: #64748b !important; } .lux-shell .el-select .el-select__wrapper { border-radius: 10px; + background-color: #0f172a !important; } /* 标签 */ @@ -360,16 +373,43 @@ body { border-radius: 8px; } -/* 对话框(Teleport 到 body,不用 .lux-shell 前缀) */ +/* 对话框(Teleport 到 body,不用 .lux-shell 前缀,深色风格) */ .el-overlay .el-dialog { border-radius: 18px; overflow: hidden; + background: #1e293b !important; box-shadow: - 0 25px 50px -12px rgba(0, 0, 0, 0.35), - 0 0 0 1px rgba(255, 255, 255, 0.5) inset; + 0 25px 50px -12px rgba(0, 0, 0, 0.5), + 0 0 0 1px rgba(148, 163, 184, 0.1) inset; } .el-overlay .el-dialog__header { - border-bottom: 1px solid rgba(148, 163, 184, 0.18); + border-bottom: 1px solid rgba(148, 163, 184, 0.12); font-weight: 700; + color: #e2e8f0; +} + +.el-overlay .el-dialog__title { + color: #e2e8f0; +} + +.el-overlay .el-dialog__body { + color: #cbd5e1; +} + +/* 表单标签深色 */ +.lux-shell .el-form-item__label { + color: #94a3b8 !important; +} + +/* 下拉菜单深色 */ +.lux-shell .el-dropdown-menu { + background: #1e293b !important; + border-color: rgba(148, 163, 184, 0.12) !important; +} + +/* 消息框深色 */ +.el-message-box { + background: #1e293b !important; + border-color: rgba(148, 163, 184, 0.12) !important; } diff --git a/frontend/src/views/login/index.vue b/frontend/src/views/login/index.vue index d39ff8e..b9f9021 100644 --- a/frontend/src/views/login/index.vue +++ b/frontend/src/views/login/index.vue @@ -217,11 +217,11 @@ const handleSubmit = async () => { max-width: 420px; padding: 44px 40px 36px; border-radius: 24px; - background: linear-gradient(165deg, #fafbfc 0%, #f0f3f8 100%); + background: #1e293b; + border: 1px solid rgba(148, 163, 184, 0.12); box-shadow: - 0 4px 6px -1px rgba(15, 23, 42, 0.06), - 0 25px 50px -12px rgba(0, 0, 0, 0.42), - 0 0 0 1px rgba(255, 255, 255, 0.65) inset; + 0 4px 6px -1px rgba(0, 0, 0, 0.2), + 0 25px 50px -12px rgba(0, 0, 0, 0.5); } .logo-ring { @@ -232,11 +232,11 @@ const handleSubmit = async () => { align-items: center; justify-content: center; border-radius: 16px; - color: #1e3a5f; - background: linear-gradient(145deg, #e8eef6 0%, #dce5f0 100%); + color: #38bdf8; + background: linear-gradient(145deg, #0f172a 0%, #1e293b 100%); box-shadow: - 0 2px 8px rgba(30, 58, 95, 0.12), - 0 0 0 1px rgba(255, 255, 255, 0.9) inset; + 0 2px 8px rgba(56, 189, 248, 0.15), + 0 0 0 1px rgba(56, 189, 248, 0.2) inset; } .title { @@ -245,14 +245,14 @@ const handleSubmit = async () => { font-size: 26px; font-weight: 700; letter-spacing: -0.02em; - color: #0f172a; + color: #e2e8f0; } .subtitle { margin: 10px 0 0; text-align: center; font-size: 13px; - color: #64748b; + color: #94a3b8; font-weight: 500; } @@ -267,7 +267,7 @@ const handleSubmit = async () => { .field-label { font-size: 12px; font-weight: 600; - color: #475569; + color: #94a3b8; letter-spacing: 0.02em; } @@ -283,8 +283,8 @@ const handleSubmit = async () => { .glass-input :deep(.el-input__wrapper) { border-radius: 14px; - background-color: #eef1f6; - box-shadow: none; + background-color: #0f172a !important; + box-shadow: 0 0 0 1px rgba(148, 163, 184, 0.15) inset !important; border: 1px solid transparent; transition: box-shadow 0.2s ease, @@ -293,25 +293,26 @@ const handleSubmit = async () => { } .glass-input :deep(.el-input__wrapper:hover) { - background-color: #e8ecf2; + background-color: #1e293b !important; + box-shadow: 0 0 0 1px rgba(148, 163, 184, 0.25) inset !important; } .glass-input :deep(.el-input__wrapper.is-focus) { - background-color: #fff; + background-color: #0f172a !important; border-color: rgba(56, 189, 248, 0.45); - box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.22); + box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.22) !important; } .glass-input :deep(.el-input__inner) { - color: #0f172a; + color: #e2e8f0 !important; } .glass-input :deep(.el-input__inner::placeholder) { - color: #94a3b8; + color: #64748b !important; } .glass-input :deep(.el-input__prefix) { - color: #64748b; + color: #94a3b8 !important; } .btn-wrap { @@ -333,21 +334,17 @@ const handleSubmit = async () => { font-weight: 700; letter-spacing: 0.12em; color: #fff !important; - background: linear-gradient(92deg, #38bdf8 0%, #4f8ff7 38%, #6366f1 72%, #7c3aed 100%) !important; - box-shadow: - 0 10px 28px rgba(56, 189, 248, 0.38), - 0 4px 12px rgba(99, 102, 241, 0.25); + background: #38bdf8 !important; + box-shadow: 0 4px 16px rgba(56, 189, 248, 0.3); transition: transform 0.15s ease, box-shadow 0.2s ease, - filter 0.2s ease; + background 0.2s ease; } .login-btn:hover { - filter: brightness(1.05); - box-shadow: - 0 14px 36px rgba(56, 189, 248, 0.45), - 0 6px 16px rgba(99, 102, 241, 0.3); + background: #22d3ee !important; + box-shadow: 0 6px 20px rgba(56, 189, 248, 0.4); } .login-btn:active { diff --git a/frontend/src/views/model/index.vue b/frontend/src/views/model/index.vue index 7c6110e..944d7b4 100644 --- a/frontend/src/views/model/index.vue +++ b/frontend/src/views/model/index.vue @@ -842,7 +842,7 @@ onMounted(() => { .title { font-size: 18px; font-weight: 600; - color: #333; + color: #e2e8f0; } .pagination { @@ -913,7 +913,7 @@ onMounted(() => { .step-label { font-size: 15px; font-weight: 600; - color: #0f172a; + color: #e2e8f0; } .step-status {