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