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
+18
View File
@@ -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
})
}
+18 -17
View File
@@ -9,7 +9,7 @@
<el-container class="lux-shell-body">
<el-aside width="220px" class="sidebar-glass">
<div class="sidebar-logo">
<h2>耳机管理平台</h2>
<h2>Luxsin CMS</h2>
</div>
<el-menu
class="lux-menu"
@@ -48,16 +48,23 @@
<el-container class="lux-right-col">
<el-header class="lux-header">
<div class="lux-header-title">耳机品牌与型号管理系统</div>
<div class="header-right">
<el-button type="primary" size="small" class="logout-btn" @click="handleLogout">
退出登录
</el-button>
</div>
<el-button
circle
type="primary"
class="lux-header-power-btn"
title="退出登录"
aria-label="退出登录"
@click="handleLogout"
>
<el-icon :size="18"><SwitchButton /></el-icon>
</el-button>
</el-header>
<el-main class="lux-main">
<router-view />
</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>
</div>
@@ -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;
}
</style>
+72 -17
View File
@@ -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;
-4
View File
@@ -279,10 +279,6 @@ onMounted(() => {
height: 100%;
}
.search-card {
margin-bottom: 20px;
}
.search-form {
display: flex;
flex-wrap: wrap;
-4
View File
@@ -628,10 +628,6 @@ onMounted(() => {
height: 100%;
}
.search-card {
margin-bottom: 20px;
}
.search-form {
display: flex;
flex-wrap: wrap;
+206 -89
View File
@@ -88,11 +88,10 @@
<el-tag v-else type="info" size="small">停用</el-tag>
</template>
</el-table-column>
<el-table-column prop="startTime" label="开始时间" width="160" show-overflow-tooltip />
<el-table-column prop="endTime" label="结束时间" width="160" show-overflow-tooltip />
<el-table-column label="操作" width="160" fixed="right">
<el-table-column label="操作" width="230" fixed="right">
<template #default="scope">
<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>
</template>
</el-table-column>
@@ -126,7 +125,6 @@
label-width="120px"
class="ota-form"
>
<el-divider content-position="left">主升级包</el-divider>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="版本号" prop="verCode">
@@ -146,83 +144,92 @@
</el-select>
</el-form-item>
</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-form-item label="硬件版本" prop="hw">
<el-input-number v-model="formData.hw" :min="0" :step="1" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="定向" prop="target">
<el-select v-model="formData.target" style="width: 100%">
<el-option label="否(面向所有用户)" :value="0" />
<el-option label="是" :value="1" />
</el-select>
<el-col :span="24" v-if="isPackageUploadModel">
<el-form-item label="升级包" prop="url">
<el-upload
ref="packageUploadRef"
:auto-upload="false"
: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-col>
<el-col :span="12">
<el-form-item label="灰度" prop="beta">
<el-select v-model="formData.beta" style="width: 100%">
<el-option label="否" :value="0" />
<el-option label="是" :value="1" />
</el-select>
<el-col :span="24" v-if="isPackageUploadModel && formData.url">
<el-form-item label="下载地址">
<el-input v-model="formData.url" readonly style="width: 100%" />
</el-form-item>
</el-col>
<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-row :gutter="16">
<el-col :span="12">
<el-form-item label="MD532位)" prop="md5">
<el-input
v-model="formData.md5"
maxlength="32"
show-word-limit
style="width: 100%"
:readonly="isPackageUploadModel"
/>
</el-form-item>
</el-col>
</el-row>
</el-col>
</el-row>
<el-divider content-position="left">时间窗口</el-divider>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="开始时间" prop="startTime">
<el-date-picker
v-model="formData.startTime"
type="datetime"
value-format="YYYY-MM-DDTHH:mm:ss"
placeholder="可选"
style="width: 100%"
clearable
<el-col :span="6">
<el-form-item label="强升" prop="force">
<el-switch
v-model="formData.force"
:active-value="1"
:inactive-value="0"
inline-prompt
active-text=""
inactive-text=""
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="结束时间" prop="endTime">
<el-date-picker
v-model="formData.endTime"
type="datetime"
value-format="YYYY-MM-DDTHH:mm:ss"
placeholder="可选"
style="width: 100%"
clearable
<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-col>
@@ -238,18 +245,25 @@
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, computed, onMounted, watch, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
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 PACKAGE_UPLOAD_MODELS = ['Luxsin-X8', 'Luxsin-X9']
const loading = ref(false)
const submitLoading = ref(false)
const dialogVisible = ref(false)
const dialogTitle = ref('新增 OTA')
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({
verName: '',
@@ -274,23 +288,24 @@ function emptyForm() {
url: '',
md5: '',
force: 0,
desc: '',
model: 'Luxsin-X8',
hw: 0,
hw: 6,
target: 0,
beta: 0,
pawVerCode: 0,
pawVerName: '',
pawUrl: '',
pawMd5: '',
startTime: '',
endTime: '',
status: 1
}
}
const formData = reactive(emptyForm())
const isPackageUploadModel = computed(() =>
PACKAGE_UPLOAD_MODELS.includes(formData.model)
)
const formRules = {
verCode: [{ required: true, message: '请输入版本号', trigger: 'change' }],
verName: [
@@ -298,9 +313,25 @@ const formRules = {
{ min: 1, max: 20, message: '120 字符', trigger: 'blur' }
],
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: [
{ required: true, message: '请输入 MD5', trigger: 'blur' },
{ required: true, message: '请填写 MD5', trigger: 'blur' },
{ len: 32, message: '须为 32 位', trigger: 'blur' },
{
pattern: /^[0-9a-fA-F]{32}$/,
@@ -315,7 +346,6 @@ function buildPayload() {
if (pawMd5.length !== 32) {
pawMd5 = PAW_MD5_PLACEHOLDER
}
const desc = (formData.desc || '').trim()
const model = (formData.model == null || formData.model === '') ? '' : String(formData.model).trim()
return {
verCode: Number(formData.verCode),
@@ -323,7 +353,7 @@ function buildPayload() {
url: (formData.url || '').trim(),
md5: (formData.md5 || '').trim(),
force: Number(formData.force ?? 0),
desc: desc === '' ? null : desc,
desc: null,
model: model === '' ? null : model,
hw: Number(formData.hw ?? 0),
target: Number(formData.target ?? 0),
@@ -332,14 +362,71 @@ function buildPayload() {
pawVerName: (formData.pawVerName || '').trim(),
pawUrl: (formData.pawUrl || '').trim(),
pawMd5,
startTime: formData.startTime || null,
endTime: formData.endTime || null,
startTime: null,
endTime: null,
status: Number(formData.status ?? 1)
}
}
function resetForm() {
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 () => {
@@ -393,17 +480,14 @@ const handleAdd = () => {
dialogVisible.value = true
}
const handleEdit = (row) => {
dialogTitle.value = '编辑 OTA'
resetForm()
function applyOtaRowToForm(row, isCopy) {
Object.assign(formData, {
id: row.id,
id: isCopy ? null : row.id,
verCode: row.verCode,
verName: row.verName ?? '',
url: row.url ?? '',
md5: row.md5 ?? '',
force: row.force ?? 0,
desc: row.desc ?? '',
model:
row.model && ['Luxsin-X8', 'Luxsin-X9'].includes(row.model)
? row.model
@@ -415,11 +499,32 @@ const handleEdit = (row) => {
pawVerName: row.pawVerName ?? '',
pawUrl: row.pawUrl ?? '',
pawMd5: row.pawMd5 || '',
startTime: row.startTime || '',
endTime: row.endTime || '',
status: row.status ?? 1
})
}
const handleEdit = (row) => {
dialogTitle.value = '编辑 OTA'
skipPackageClearOnModelWatch.value = true
resetForm()
applyOtaRowToForm(row, false)
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) => {
@@ -494,9 +599,6 @@ onMounted(() => {
.ota-container {
height: 100%;
}
.search-card {
margin-bottom: 20px;
}
.search-form {
display: flex;
flex-wrap: wrap;
@@ -521,6 +623,21 @@ onMounted(() => {
justify-content: flex-end;
}
.ota-form {
padding-top: 16px;
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>