Add OTA package upload functionality and UI enhancements

- Introduced a new endpoint for uploading OTA packages in backend/routes/ota.py.
- Implemented file handling and MD5 checksum calculation for OTA packages.
- Updated frontend API to support OTA package uploads with a new uploadOtaPackage function.
- Enhanced the OTA management UI to include file upload options and improved layout.
- Updated requirements.txt to include boto3 for S3 interactions.
- Made various UI adjustments for better user experience and consistency.
This commit is contained in:
yangy
2026-05-15 17:34:54 +08:00
parent 661b85ce62
commit 1a6c96da8c
9 changed files with 511 additions and 132 deletions
+124
View File
@@ -0,0 +1,124 @@
"""OTA 升级包存储:Luxsin-X8 -> S3Luxsin-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:
"""X8http://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:
"""X9http://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
对外 URLhttp://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 Keyota/{yyyyMM}/x8/{md5前5位}/LUXSIN_X8.PKG
对外 URLhttp://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
+1
View File
@@ -8,3 +8,4 @@ python-dotenv==1.0.0
requests==2.31.0 requests==2.31.0
python-multipart==0.0.6 python-multipart==0.0.6
PyJWT==2.8.0 PyJWT==2.8.0
boto3==1.34.162
+72 -1
View File
@@ -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 sqlalchemy.orm import Session
from typing import List, Optional from typing import List, Optional
import logging import logging
from dotenv import load_dotenv
from database import get_db from database import get_db
from models.ota import Ota from models.ota import Ota
from schemas import OtaCreate, OtaUpdate, OtaResponse from schemas import OtaCreate, OtaUpdate, OtaResponse
from response import ApiResponse, PageData 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 # Configure logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -13,6 +24,66 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/ota", tags=["ota"]) 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 升级包:计算 MD5X8 上传 S3 为 LUXSIN_X8.PKGX9 本地保存为 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) @router.get("/", response_model=ApiResponse)
def get_ota_list( def get_ota_list(
skip: int = Query(0, ge=0, description="跳过记录数"), skip: int = Query(0, ge=0, description="跳过记录数"),
+18
View File
@@ -47,3 +47,21 @@ export function deleteOta(id) {
method: 'delete' 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
})
}
+17 -16
View File
@@ -9,7 +9,7 @@
<el-container class="lux-shell-body"> <el-container class="lux-shell-body">
<el-aside width="220px" class="sidebar-glass"> <el-aside width="220px" class="sidebar-glass">
<div class="sidebar-logo"> <div class="sidebar-logo">
<h2>耳机管理平台</h2> <h2>Luxsin CMS</h2>
</div> </div>
<el-menu <el-menu
class="lux-menu" class="lux-menu"
@@ -48,16 +48,23 @@
<el-container class="lux-right-col"> <el-container class="lux-right-col">
<el-header class="lux-header"> <el-header class="lux-header">
<div class="lux-header-title">耳机品牌与型号管理系统</div> <el-button
<div class="header-right"> circle
<el-button type="primary" size="small" class="logout-btn" @click="handleLogout"> type="primary"
退出登录 class="lux-header-power-btn"
title="退出登录"
aria-label="退出登录"
@click="handleLogout"
>
<el-icon :size="18"><SwitchButton /></el-icon>
</el-button> </el-button>
</div>
</el-header> </el-header>
<el-main class="lux-main"> <el-main class="lux-main">
<router-view /> <router-view />
</el-main> </el-main>
<el-footer class="lux-footer" height="auto">
<span class="lux-footer-copy">&copy;Powered By EafonYang</span>
</el-footer>
</el-container> </el-container>
</el-container> </el-container>
</div> </div>
@@ -67,7 +74,7 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' 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' import { clearToken } from '@/utils/auth'
const route = useRoute() const route = useRoute()
@@ -85,14 +92,8 @@ const handleLogout = () => {
.lux-right-col { .lux-right-col {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
} min-height: 0;
display: flex;
.header-right { flex-direction: column;
flex-shrink: 0;
}
.logout-btn {
color: #ffffff !important;
font-weight: 600;
} }
</style> </style>
+72 -17
View File
@@ -89,7 +89,10 @@ body {
.lux-shell .sidebar-glass { .lux-shell .sidebar-glass {
position: relative; position: relative;
width: 220px !important; width: 220px !important;
overflow-x: hidden; height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--lux-glass-sidebar) !important; background: var(--lux-glass-sidebar) !important;
backdrop-filter: blur(18px); backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px);
@@ -119,6 +122,9 @@ body {
} }
.lux-shell .lux-menu.el-menu { .lux-shell .lux-menu.el-menu {
flex: 1;
min-height: 0;
overflow-y: auto;
border-right: none !important; border-right: none !important;
background: transparent !important; background: transparent !important;
padding: 8px 6px 16px; padding: 8px 6px 16px;
@@ -159,36 +165,71 @@ body {
color: inherit; color: inherit;
} }
/* —— 顶栏 —— */ /* —— 顶栏(与内容卡片一致的浅色底) —— */
.lux-shell .lux-header { .lux-shell .lux-header {
height: 56px !important; height: 44px !important;
min-height: 44px !important;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: flex-end;
padding: 0 22px !important; padding: 0 14px !important;
margin: 12px 16px 0 8px; margin: 8px 16px 0 8px;
border-radius: 14px; border-radius: 10px;
background: var(--lux-glass-header); background: var(--lux-card-grad) !important;
backdrop-filter: blur(14px); backdrop-filter: blur(14px);
-webkit-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: box-shadow:
0 4px 6px -1px rgba(15, 23, 42, 0.06), 0 2px 4px -1px rgba(15, 23, 42, 0.05),
0 0 0 1px rgba(255, 255, 255, 0.7) inset; 0 0 0 1px rgba(255, 255, 255, 0.65) inset;
} }
.lux-shell .lux-header-title { .lux-shell .lux-header-power-btn {
font-size: 17px; width: 34px !important;
font-weight: 700; height: 34px !important;
letter-spacing: -0.01em; padding: 0 !important;
color: #0f172a; 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 { .lux-shell .lux-main {
background: transparent !important; background: transparent !important;
padding: 16px 16px 24px 12px !important; padding: 12px 16px 24px 12px !important;
overflow: auto; 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 { .lux-shell .card-header .title {
@@ -219,6 +260,20 @@ body {
color: var(--lux-text-strong); 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 { .lux-shell .el-table {
--el-table-border-color: rgba(148, 163, 184, 0.22); --el-table-border-color: rgba(148, 163, 184, 0.22);
--el-table-header-bg-color: #eef2f7; --el-table-header-bg-color: #eef2f7;
-4
View File
@@ -279,10 +279,6 @@ onMounted(() => {
height: 100%; height: 100%;
} }
.search-card {
margin-bottom: 20px;
}
.search-form { .search-form {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
-4
View File
@@ -628,10 +628,6 @@ onMounted(() => {
height: 100%; height: 100%;
} }
.search-card {
margin-bottom: 20px;
}
.search-form { .search-form {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
+204 -87
View File
@@ -88,11 +88,10 @@
<el-tag v-else type="info" size="small">停用</el-tag> <el-tag v-else type="info" size="small">停用</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="startTime" label="开始时间" width="160" show-overflow-tooltip /> <el-table-column label="操作" width="230" fixed="right">
<el-table-column prop="endTime" label="结束时间" width="160" show-overflow-tooltip />
<el-table-column label="操作" width="160" fixed="right">
<template #default="scope"> <template #default="scope">
<el-button type="primary" size="small" @click="handleEdit(scope.row)">编辑</el-button> <el-button type="primary" size="small" @click="handleEdit(scope.row)">编辑</el-button>
<el-button type="success" size="small" class="ota-copy-btn" @click="handleCopy(scope.row)">复制</el-button>
<el-button type="danger" size="small" @click="handleDelete(scope.row)">删除</el-button> <el-button type="danger" size="small" @click="handleDelete(scope.row)">删除</el-button>
</template> </template>
</el-table-column> </el-table-column>
@@ -126,7 +125,6 @@
label-width="120px" label-width="120px"
class="ota-form" class="ota-form"
> >
<el-divider content-position="left">主升级包</el-divider>
<el-row :gutter="16"> <el-row :gutter="16">
<el-col :span="12"> <el-col :span="12">
<el-form-item label="版本号" prop="verCode"> <el-form-item label="版本号" prop="verCode">
@@ -146,83 +144,92 @@
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="24">
<el-form-item label="下载地址" prop="url">
<el-input v-model="formData.url" maxlength="255" show-word-limit />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="MD532位)" prop="md5">
<el-input v-model="formData.md5" maxlength="32" show-word-limit placeholder="32 位 MD5" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="强升" prop="force">
<el-select v-model="formData.force" style="width: 100%">
<el-option label="否" :value="0" />
<el-option label="是" :value="1" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="状态" prop="status">
<el-select v-model="formData.status" style="width: 100%">
<el-option label="可用" :value="1" />
<el-option label="停用" :value="0" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="硬件版本" prop="hw"> <el-form-item label="硬件版本" prop="hw">
<el-input-number v-model="formData.hw" :min="0" :step="1" style="width: 100%" /> <el-input-number v-model="formData.hw" :min="0" :step="1" style="width: 100%" />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="24" v-if="isPackageUploadModel">
<el-form-item label="定向" prop="target"> <el-form-item label="升级包" prop="url">
<el-select v-model="formData.target" style="width: 100%"> <el-upload
<el-option label="否(面向所有用户)" :value="0" /> ref="packageUploadRef"
<el-option label="是" :value="1" /> :auto-upload="false"
</el-select> :limit="1"
accept=".pkg,.bin,.zip"
:file-list="packageFileList"
:on-change="handlePackageChange"
:on-remove="handlePackageRemove"
:disabled="packageUploading"
>
<el-button type="primary" :loading="packageUploading">选择文件</el-button>
</el-upload>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="24" v-if="isPackageUploadModel && formData.url">
<el-form-item label="灰度" prop="beta"> <el-form-item label="下载地址">
<el-select v-model="formData.beta" style="width: 100%"> <el-input v-model="formData.url" readonly style="width: 100%" />
<el-option label="否" :value="0" />
<el-option label="是" :value="1" />
</el-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="24"> <el-col :span="24">
<el-form-item label="描述" prop="desc">
<el-input v-model="formData.desc" type="textarea" :rows="2" maxlength="255" show-word-limit />
</el-form-item>
</el-col>
</el-row>
<el-divider content-position="left">时间窗口</el-divider>
<el-row :gutter="16"> <el-row :gutter="16">
<el-col :span="12"> <el-col :span="12">
<el-form-item label="开始时间" prop="startTime"> <el-form-item label="MD532位)" prop="md5">
<el-date-picker <el-input
v-model="formData.startTime" v-model="formData.md5"
type="datetime" maxlength="32"
value-format="YYYY-MM-DDTHH:mm:ss" show-word-limit
placeholder="可选"
style="width: 100%" style="width: 100%"
clearable :readonly="isPackageUploadModel"
/> />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> </el-row>
<el-form-item label="结束时间" prop="endTime"> </el-col>
<el-date-picker <el-col :span="6">
v-model="formData.endTime" <el-form-item label="强升" prop="force">
type="datetime" <el-switch
value-format="YYYY-MM-DDTHH:mm:ss" v-model="formData.force"
placeholder="可选" :active-value="1"
style="width: 100%" :inactive-value="0"
clearable inline-prompt
active-text=""
inactive-text=""
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="状态" prop="status">
<el-switch
v-model="formData.status"
:active-value="1"
:inactive-value="0"
inline-prompt
active-text="可用"
inactive-text="停用"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="定向" prop="target">
<el-switch
v-model="formData.target"
:active-value="1"
:inactive-value="0"
inline-prompt
active-text="定向"
inactive-text="全量"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="灰度" prop="beta">
<el-switch
v-model="formData.beta"
:active-value="1"
:inactive-value="0"
inline-prompt
active-text=""
inactive-text=""
/> />
</el-form-item> </el-form-item>
</el-col> </el-col>
@@ -238,18 +245,25 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, computed, onMounted, watch, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Refresh, Plus } from '@element-plus/icons-vue' import { Search, Refresh, Plus } from '@element-plus/icons-vue'
import { getOtaList, createOta, updateOta, deleteOta } from '@/api/ota' import { getOtaList, createOta, updateOta, deleteOta, uploadOtaPackage } from '@/api/ota'
const PAW_MD5_PLACEHOLDER = '0'.repeat(32) const PAW_MD5_PLACEHOLDER = '0'.repeat(32)
const PACKAGE_UPLOAD_MODELS = ['Luxsin-X8', 'Luxsin-X9']
const loading = ref(false) const loading = ref(false)
const submitLoading = ref(false) const submitLoading = ref(false)
const dialogVisible = ref(false) const dialogVisible = ref(false)
const dialogTitle = ref('新增 OTA') const dialogTitle = ref('新增 OTA')
const formRef = ref(null) const formRef = ref(null)
const packageUploadRef = ref(null)
const packageFileList = ref([])
const packageUploading = ref(false)
/** 从列表行填充表单时跳过 model watch,避免误清 url/md5(默认型号与行型号不一致时) */
const skipPackageClearOnModelWatch = ref(false)
const searchForm = reactive({ const searchForm = reactive({
verName: '', verName: '',
@@ -274,23 +288,24 @@ function emptyForm() {
url: '', url: '',
md5: '', md5: '',
force: 0, force: 0,
desc: '',
model: 'Luxsin-X8', model: 'Luxsin-X8',
hw: 0, hw: 6,
target: 0, target: 0,
beta: 0, beta: 0,
pawVerCode: 0, pawVerCode: 0,
pawVerName: '', pawVerName: '',
pawUrl: '', pawUrl: '',
pawMd5: '', pawMd5: '',
startTime: '',
endTime: '',
status: 1 status: 1
} }
} }
const formData = reactive(emptyForm()) const formData = reactive(emptyForm())
const isPackageUploadModel = computed(() =>
PACKAGE_UPLOAD_MODELS.includes(formData.model)
)
const formRules = { const formRules = {
verCode: [{ required: true, message: '请输入版本号', trigger: 'change' }], verCode: [{ required: true, message: '请输入版本号', trigger: 'change' }],
verName: [ verName: [
@@ -298,9 +313,25 @@ const formRules = {
{ min: 1, max: 20, message: '120 字符', trigger: 'blur' } { min: 1, max: 20, message: '120 字符', trigger: 'blur' }
], ],
model: [{ required: true, message: '请选择设备型号', trigger: 'change' }], model: [{ required: true, message: '请选择设备型号', trigger: 'change' }],
url: [{ required: true, message: '请输入下载地址', trigger: 'blur' }], url: [
{
validator: (_rule, value, callback) => {
if (isPackageUploadModel.value) {
if (!value || !String(value).trim()) {
callback(new Error('请上传升级包'))
return
}
} else if (!value || !String(value).trim()) {
callback(new Error('请输入下载地址'))
return
}
callback()
},
trigger: 'change'
}
],
md5: [ md5: [
{ required: true, message: '请输入 MD5', trigger: 'blur' }, { required: true, message: '请填写 MD5', trigger: 'blur' },
{ len: 32, message: '须为 32 位', trigger: 'blur' }, { len: 32, message: '须为 32 位', trigger: 'blur' },
{ {
pattern: /^[0-9a-fA-F]{32}$/, pattern: /^[0-9a-fA-F]{32}$/,
@@ -315,7 +346,6 @@ function buildPayload() {
if (pawMd5.length !== 32) { if (pawMd5.length !== 32) {
pawMd5 = PAW_MD5_PLACEHOLDER pawMd5 = PAW_MD5_PLACEHOLDER
} }
const desc = (formData.desc || '').trim()
const model = (formData.model == null || formData.model === '') ? '' : String(formData.model).trim() const model = (formData.model == null || formData.model === '') ? '' : String(formData.model).trim()
return { return {
verCode: Number(formData.verCode), verCode: Number(formData.verCode),
@@ -323,7 +353,7 @@ function buildPayload() {
url: (formData.url || '').trim(), url: (formData.url || '').trim(),
md5: (formData.md5 || '').trim(), md5: (formData.md5 || '').trim(),
force: Number(formData.force ?? 0), force: Number(formData.force ?? 0),
desc: desc === '' ? null : desc, desc: null,
model: model === '' ? null : model, model: model === '' ? null : model,
hw: Number(formData.hw ?? 0), hw: Number(formData.hw ?? 0),
target: Number(formData.target ?? 0), target: Number(formData.target ?? 0),
@@ -332,14 +362,71 @@ function buildPayload() {
pawVerName: (formData.pawVerName || '').trim(), pawVerName: (formData.pawVerName || '').trim(),
pawUrl: (formData.pawUrl || '').trim(), pawUrl: (formData.pawUrl || '').trim(),
pawMd5, pawMd5,
startTime: formData.startTime || null, startTime: null,
endTime: formData.endTime || null, endTime: null,
status: Number(formData.status ?? 1) status: Number(formData.status ?? 1)
} }
} }
function resetForm() { function resetForm() {
Object.assign(formData, emptyForm()) Object.assign(formData, emptyForm())
packageFileList.value = []
packageUploadRef.value?.clearFiles?.()
}
function clearPackageFields() {
formData.url = ''
formData.md5 = ''
packageFileList.value = []
packageUploadRef.value?.clearFiles?.()
}
watch(
() => formData.model,
(val, oldVal) => {
if (skipPackageClearOnModelWatch.value) return
const wasUpload = PACKAGE_UPLOAD_MODELS.includes(oldVal)
const isUpload = PACKAGE_UPLOAD_MODELS.includes(val)
if (wasUpload && (!isUpload || val !== oldVal)) {
clearPackageFields()
}
}
)
const handlePackageChange = async (file) => {
if (!file?.raw) return
if (!isPackageUploadModel.value) {
ElMessage.warning('当前型号不支持上传升级包')
packageFileList.value = []
return
}
packageFileList.value = [file]
packageUploading.value = true
try {
const res = await uploadOtaPackage(file.raw, formData.model)
if (res.code === 1 && res.data) {
formData.md5 = res.data.md5 || ''
formData.url = res.data.url || res.data.filename || ''
ElMessage.success('升级包上传成功')
formRef.value?.validateField?.('url')
formRef.value?.validateField?.('md5')
} else {
packageFileList.value = []
ElMessage.error(res.msg || '上传失败')
}
} catch (e) {
console.error(e)
packageFileList.value = []
ElMessage.error('上传失败')
} finally {
packageUploading.value = false
}
}
const handlePackageRemove = () => {
formData.url = ''
formData.md5 = ''
packageFileList.value = []
} }
const loadData = async () => { const loadData = async () => {
@@ -393,17 +480,14 @@ const handleAdd = () => {
dialogVisible.value = true dialogVisible.value = true
} }
const handleEdit = (row) => { function applyOtaRowToForm(row, isCopy) {
dialogTitle.value = '编辑 OTA'
resetForm()
Object.assign(formData, { Object.assign(formData, {
id: row.id, id: isCopy ? null : row.id,
verCode: row.verCode, verCode: row.verCode,
verName: row.verName ?? '', verName: row.verName ?? '',
url: row.url ?? '', url: row.url ?? '',
md5: row.md5 ?? '', md5: row.md5 ?? '',
force: row.force ?? 0, force: row.force ?? 0,
desc: row.desc ?? '',
model: model:
row.model && ['Luxsin-X8', 'Luxsin-X9'].includes(row.model) row.model && ['Luxsin-X8', 'Luxsin-X9'].includes(row.model)
? row.model ? row.model
@@ -415,11 +499,32 @@ const handleEdit = (row) => {
pawVerName: row.pawVerName ?? '', pawVerName: row.pawVerName ?? '',
pawUrl: row.pawUrl ?? '', pawUrl: row.pawUrl ?? '',
pawMd5: row.pawMd5 || '', pawMd5: row.pawMd5 || '',
startTime: row.startTime || '',
endTime: row.endTime || '',
status: row.status ?? 1 status: row.status ?? 1
}) })
}
const handleEdit = (row) => {
dialogTitle.value = '编辑 OTA'
skipPackageClearOnModelWatch.value = true
resetForm()
applyOtaRowToForm(row, false)
dialogVisible.value = true dialogVisible.value = true
nextTick(() => {
skipPackageClearOnModelWatch.value = false
})
}
const handleCopy = (row) => {
dialogTitle.value = '新增 OTA'
skipPackageClearOnModelWatch.value = true
resetForm()
applyOtaRowToForm(row, true)
dialogVisible.value = true
nextTick(() => {
skipPackageClearOnModelWatch.value = false
formRef.value?.clearValidate?.()
})
ElMessage.info('已复制当前行数据,请修改版本号等信息后保存')
} }
const handleDelete = (row) => { const handleDelete = (row) => {
@@ -494,9 +599,6 @@ onMounted(() => {
.ota-container { .ota-container {
height: 100%; height: 100%;
} }
.search-card {
margin-bottom: 20px;
}
.search-form { .search-form {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -521,6 +623,21 @@ onMounted(() => {
justify-content: flex-end; justify-content: flex-end;
} }
.ota-form { .ota-form {
padding-top: 16px;
padding-right: 12px; padding-right: 12px;
} }
/* 复制:绿底白字(避免主题覆盖为渐变) */
.ota-copy-btn {
color: #ffffff !important;
background-color: var(--el-color-success) !important;
border-color: var(--el-color-success) !important;
font-weight: 600;
}
.ota-copy-btn:hover,
.ota-copy-btn:focus {
color: #ffffff !important;
background-color: var(--el-color-success-light-3) !important;
border-color: var(--el-color-success-light-3) !important;
}
</style> </style>