diff --git a/backend/ota_storage.py b/backend/ota_storage.py new file mode 100644 index 0000000..face3a6 --- /dev/null +++ b/backend/ota_storage.py @@ -0,0 +1,124 @@ +"""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 +OTA_UPLOAD_DIR_DEV = os.getenv( + "OTA_UPLOAD_DIR_DEV", "H:/soft/projects/luxsin/dashboard/ota" +) +OTA_UPLOAD_DIR_PROD = os.getenv( + "OTA_UPLOAD_DIR_PROD", "/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: + is_dev = os.getenv("DEBUG", "False").lower() in ("true", "1", "yes") + raw = OTA_UPLOAD_DIR_DEV if is_dev else OTA_UPLOAD_DIR_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/requirements.txt b/backend/requirements.txt index 7adc59f..0184e7c 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -8,3 +8,4 @@ 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/routes/ota.py b/backend/routes/ota.py index f76d0f6..41ddce1 100644 --- a/backend/routes/ota.py +++ b/backend/routes/ota.py @@ -1,11 +1,22 @@ -from fastapi import APIRouter, Depends, HTTPException, Query, Body +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() # Configure logging logger = logging.getLogger(__name__) @@ -13,6 +24,66 @@ 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="跳过记录数"), diff --git a/frontend/src/api/ota.js b/frontend/src/api/ota.js index 5da57e0..8406d29 100644 --- a/frontend/src/api/ota.js +++ b/frontend/src/api/ota.js @@ -47,3 +47,21 @@ export function deleteOta(id) { method: 'delete' }) } + +/** + * 上传 OTA 升级包(Luxsin-X9) + * @param {File} file + * @param {string} model 设备型号 + */ +export function uploadOtaPackage(file, model) { + const formData = new FormData() + formData.append('package_file', file) + formData.append('model', model) + return request({ + url: '/ota/upload-package', + method: 'post', + data: formData, + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: 300000 + }) +} diff --git a/frontend/src/layout/index.vue b/frontend/src/layout/index.vue index bf7dc6d..6211b92 100644 --- a/frontend/src/layout/index.vue +++ b/frontend/src/layout/index.vue @@ -9,7 +9,7 @@ -
耳机品牌与型号管理系统
-
- - 退出登录 - -
+ + +
+ + ©Powered By EafonYang +
@@ -67,7 +74,7 @@ import { computed } from 'vue' import { useRoute, useRouter } from 'vue-router' import { ElMessage } from 'element-plus' -import { Headset, Collection, List, Upload, Document } from '@element-plus/icons-vue' +import { Headset, Collection, List, Upload, Document, SwitchButton } from '@element-plus/icons-vue' import { clearToken } from '@/utils/auth' const route = useRoute() @@ -85,14 +92,8 @@ const handleLogout = () => { .lux-right-col { flex: 1; min-width: 0; -} - -.header-right { - flex-shrink: 0; -} - -.logout-btn { - color: #ffffff !important; - font-weight: 600; + min-height: 0; + display: flex; + flex-direction: column; } diff --git a/frontend/src/styles/lux-theme.css b/frontend/src/styles/lux-theme.css index 16d16fa..116098c 100644 --- a/frontend/src/styles/lux-theme.css +++ b/frontend/src/styles/lux-theme.css @@ -89,7 +89,10 @@ body { .lux-shell .sidebar-glass { position: relative; width: 220px !important; - overflow-x: hidden; + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; background: var(--lux-glass-sidebar) !important; backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); @@ -119,6 +122,9 @@ body { } .lux-shell .lux-menu.el-menu { + flex: 1; + min-height: 0; + overflow-y: auto; border-right: none !important; background: transparent !important; padding: 8px 6px 16px; @@ -159,36 +165,71 @@ body { color: inherit; } -/* —— 顶栏 —— */ +/* —— 顶栏(与内容卡片一致的浅色底) —— */ .lux-shell .lux-header { - height: 56px !important; + height: 44px !important; + min-height: 44px !important; display: flex; align-items: center; - justify-content: space-between; - padding: 0 22px !important; - margin: 12px 16px 0 8px; - border-radius: 14px; - background: var(--lux-glass-header); + justify-content: flex-end; + padding: 0 14px !important; + margin: 8px 16px 0 8px; + border-radius: 10px; + background: var(--lux-card-grad) !important; backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); - border: 1px solid rgba(148, 163, 184, 0.2); + border: 1px solid rgba(148, 163, 184, 0.22) !important; box-shadow: - 0 4px 6px -1px rgba(15, 23, 42, 0.06), - 0 0 0 1px rgba(255, 255, 255, 0.7) inset; + 0 2px 4px -1px rgba(15, 23, 42, 0.05), + 0 0 0 1px rgba(255, 255, 255, 0.65) inset; } -.lux-shell .lux-header-title { - font-size: 17px; - font-weight: 700; - letter-spacing: -0.01em; - color: #0f172a; +.lux-shell .lux-header-power-btn { + width: 34px !important; + height: 34px !important; + padding: 0 !important; + box-shadow: 0 4px 12px rgba(56, 189, 248, 0.22) !important; +} + +.lux-shell .lux-header-power-btn .el-icon { + margin: 0; +} + +.lux-shell .lux-header-power-btn:hover, +.lux-shell .lux-header-power-btn:focus { + box-shadow: 0 6px 16px rgba(56, 189, 248, 0.3) !important; } /* —— 主内容区 —— */ .lux-shell .lux-main { background: transparent !important; - padding: 16px 16px 24px 12px !important; + padding: 12px 16px 24px 12px !important; overflow: auto; + flex: 1; + min-height: 0; +} + +.lux-shell .lux-footer { + flex-shrink: 0; + height: auto !important; + padding: 10px 16px 14px !important; + margin: 0 16px 12px 8px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(15, 23, 42, 0.35) !important; + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border-radius: 12px; + border: none !important; + box-shadow: none; +} + +.lux-shell .lux-footer-copy { + font-size: 12px; + color: rgba(203, 213, 225, 0.85); + letter-spacing: 0.03em; + user-select: none; } .lux-shell .card-header .title { @@ -219,6 +260,20 @@ body { color: var(--lux-text-strong); } +/* 列表页顶部筛选栏:压低整体高度 */ +.lux-shell .el-card.search-card { + margin-bottom: 14px !important; +} + +.lux-shell .el-card.search-card .el-card__body { + padding: 8px 14px 10px !important; +} + +.lux-shell .search-form.el-form--inline .el-form-item { + margin-bottom: 4px; + margin-top: 0; +} + .lux-shell .el-table { --el-table-border-color: rgba(148, 163, 184, 0.22); --el-table-header-bg-color: #eef2f7; diff --git a/frontend/src/views/brand/index.vue b/frontend/src/views/brand/index.vue index 73685bc..fdb59fa 100644 --- a/frontend/src/views/brand/index.vue +++ b/frontend/src/views/brand/index.vue @@ -279,10 +279,6 @@ onMounted(() => { height: 100%; } -.search-card { - margin-bottom: 20px; -} - .search-form { display: flex; flex-wrap: wrap; diff --git a/frontend/src/views/model/index.vue b/frontend/src/views/model/index.vue index f0246a4..4c20d42 100644 --- a/frontend/src/views/model/index.vue +++ b/frontend/src/views/model/index.vue @@ -628,10 +628,6 @@ onMounted(() => { height: 100%; } -.search-card { - margin-bottom: 20px; -} - .search-form { display: flex; flex-wrap: wrap; diff --git a/frontend/src/views/ota/index.vue b/frontend/src/views/ota/index.vue index 3247ce9..ac52936 100644 --- a/frontend/src/views/ota/index.vue +++ b/frontend/src/views/ota/index.vue @@ -88,11 +88,10 @@ 停用 - - - + @@ -126,7 +125,6 @@ label-width="120px" class="ota-form" > - 主升级包 @@ -146,83 +144,92 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + 选择文件 + - - - - - - + + + - - - + + + + + + + - - - 时间窗口 - - - - + + - - - + + + + + + + + + + + + @@ -238,18 +245,25 @@