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:
@@ -279,10 +279,6 @@ onMounted(() => {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.search-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -628,10 +628,6 @@ onMounted(() => {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.search-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -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="MD5(32位)" 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="MD5(32位)" 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: '1~20 字符', 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>
|
||||
|
||||
Reference in New Issue
Block a user