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 @@
@@ -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 @@