|
@@ -0,0 +1,304 @@
|
|
|
+<template>
|
|
|
+ <div ref="mod">
|
|
|
+ <a-modal
|
|
|
+ :get-container="() => $refs.mod"
|
|
|
+ :open="open"
|
|
|
+ :mask-closable="false"
|
|
|
+ :width="600"
|
|
|
+ @cancel="cancel"
|
|
|
+ :footer="null"
|
|
|
+ >
|
|
|
+ <template #title>
|
|
|
+ <div class="header">
|
|
|
+ <div class="title">{{ title }}</div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ <a-upload-dragger
|
|
|
+ v-model:fileList="fileList"
|
|
|
+ name="file"
|
|
|
+ :multiple="false"
|
|
|
+ :action="action"
|
|
|
+ :accept="acceptType"
|
|
|
+ :headers="headers"
|
|
|
+ :customRequest="customRequest"
|
|
|
+ :before-upload="beforeUpload"
|
|
|
+ @change="handleChange"
|
|
|
+ @drop="handleDrop"
|
|
|
+ >
|
|
|
+ <p class="ant-upload-drag-icon">
|
|
|
+ <inbox-outlined></inbox-outlined>
|
|
|
+ </p>
|
|
|
+ <p class="ant-upload-text">单击或拖动文件到此区域进行上传</p>
|
|
|
+ <p class="ant-upload-hint">支持 {{ allowedExt.join(', ') }} 格式文件,最大 10MB </p>
|
|
|
+ </a-upload-dragger>
|
|
|
+
|
|
|
+ <div class="notes">
|
|
|
+ <div class="title">固件列表 ({{ otaList && otaList.length }})</div>
|
|
|
+ <div v-for="(ota, index) in otaList" :key="ota.fileId as string" class="item">
|
|
|
+ <div class="item-index" :title="ota.fileName">{{ index + 1 }}</div>
|
|
|
+ <div class="item-name" :title="ota.fileName">{{ ota.fileName }}</div>
|
|
|
+ <div class="item-time">{{ ota.createTime }}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="footer"> </div>
|
|
|
+ </a-modal>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+import { ref, watch } from 'vue'
|
|
|
+import { message, type UploadChangeParam, Upload } from 'ant-design-vue'
|
|
|
+import { InboxOutlined } from '@ant-design/icons-vue'
|
|
|
+import { useUserStore } from '@/stores/user'
|
|
|
+import axios, { type AxiosProgressEvent, type AxiosResponse } from 'axios'
|
|
|
+import type { UploadRequestOption } from 'ant-design-vue/es/vc-upload/interface'
|
|
|
+import * as deviceApi from '@/api/device'
|
|
|
+import type { OtaItem } from '@/api/device/types'
|
|
|
+
|
|
|
+defineOptions({
|
|
|
+ name: 'OTADevice',
|
|
|
+})
|
|
|
+
|
|
|
+const userStore = useUserStore()
|
|
|
+
|
|
|
+type Props = {
|
|
|
+ open: boolean
|
|
|
+ title?: string
|
|
|
+}
|
|
|
+const emit = defineEmits<{
|
|
|
+ (e: 'update:open', value: boolean): void
|
|
|
+ (e: 'success', value: void): void
|
|
|
+}>()
|
|
|
+
|
|
|
+const props = withDefaults(defineProps<Props>(), {
|
|
|
+ open: false,
|
|
|
+ title: 'OTA升级',
|
|
|
+})
|
|
|
+
|
|
|
+const fileList = ref([])
|
|
|
+const action = `${import.meta.env.VITE_API_BASE_URL}/system/OTAUpload`
|
|
|
+const acceptType = '.bin,.hex,.fw'
|
|
|
+
|
|
|
+const handleChange = (info: UploadChangeParam) => {
|
|
|
+ console.log('handleChange', info)
|
|
|
+ const status = info.file.status
|
|
|
+ if (status !== 'uploading') {
|
|
|
+ console.log(info.file, info.fileList)
|
|
|
+ }
|
|
|
+ if (status === 'done') {
|
|
|
+ console.log(`${info.file.name} file uploaded successfully.`)
|
|
|
+ } else if (status === 'error') {
|
|
|
+ console.log(`${info.file.name} file upload failed.`)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function handleDrop(e: DragEvent) {
|
|
|
+ console.log(e)
|
|
|
+ // 获取拖拽的文件
|
|
|
+ if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length) {
|
|
|
+ const file = e.dataTransfer.files[0]
|
|
|
+ // 调用beforeUpload进行验证
|
|
|
+ const isValid = beforeUpload(file)
|
|
|
+ if (isValid !== true) {
|
|
|
+ // 如果验证失败,清空文件列表
|
|
|
+ fileList.value = []
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const headers = {
|
|
|
+ 'content-type': 'multipart/form-data',
|
|
|
+ Authorization: userStore.userInfo.tokenValue,
|
|
|
+}
|
|
|
+
|
|
|
+// 自定义上传文件类型(扩展原生 File 类型)
|
|
|
+interface CustomUploadFile extends File {
|
|
|
+ uid: string
|
|
|
+ readonly lastModifiedDate: Date
|
|
|
+}
|
|
|
+
|
|
|
+// 进度回调函数类型
|
|
|
+type UploadProgressHandler = (progressEvent: {
|
|
|
+ percent: number
|
|
|
+ loaded: number
|
|
|
+ total: number
|
|
|
+ event: ProgressEvent
|
|
|
+}) => void
|
|
|
+
|
|
|
+// 导入设备ID重复列表
|
|
|
+const repeateIds = ref<string[]>([])
|
|
|
+// 成功回调函数类型
|
|
|
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
+type UploadSuccessHandler = <T = any>(response: T, event?: ProgressEvent) => void
|
|
|
+
|
|
|
+const customRequest = (options: UploadRequestOption) => {
|
|
|
+ const { file, onProgress, onSuccess, onError } = options
|
|
|
+ const customFile = file as CustomUploadFile
|
|
|
+
|
|
|
+ // 创建 FormData
|
|
|
+ const formData = new FormData()
|
|
|
+ formData.append('file', customFile)
|
|
|
+
|
|
|
+ // 处理进度事件
|
|
|
+ const handleProgress: UploadProgressHandler = (progressEvent) => {
|
|
|
+ // 确保进度在 0-100 范围内
|
|
|
+ const percent = Math.min(100, Math.max(0, progressEvent.percent))
|
|
|
+ onProgress?.({ percent })
|
|
|
+ }
|
|
|
+
|
|
|
+ // 处理成功响应
|
|
|
+ const handleSuccess: UploadSuccessHandler = (response) => {
|
|
|
+ onSuccess?.(response)
|
|
|
+ }
|
|
|
+
|
|
|
+ repeateIds.value = []
|
|
|
+ // 发送请求
|
|
|
+ axios
|
|
|
+ .post(action, formData, {
|
|
|
+ headers: { 'Content-Type': 'multipart/form-data' },
|
|
|
+ onUploadProgress: (progressEvent: AxiosProgressEvent) => {
|
|
|
+ if (!progressEvent.total) return
|
|
|
+
|
|
|
+ const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total)
|
|
|
+
|
|
|
+ handleProgress({
|
|
|
+ percent,
|
|
|
+ loaded: progressEvent.loaded,
|
|
|
+ total: progressEvent.total,
|
|
|
+ event: progressEvent.event,
|
|
|
+ })
|
|
|
+ },
|
|
|
+ })
|
|
|
+ .then((response: AxiosResponse) => {
|
|
|
+ if (['200', 200].includes(response.data.code)) {
|
|
|
+ handleSuccess(response.data)
|
|
|
+ console.log('✅上传成功', response.data)
|
|
|
+ message.success('上传成功')
|
|
|
+ fetchOTAList()
|
|
|
+ } else {
|
|
|
+ const errorMsg = response.data.message
|
|
|
+ message.error(errorMsg)
|
|
|
+ onError?.(response.data)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .catch((error) => {
|
|
|
+ console.log('❌上传失败', error)
|
|
|
+ const errorMsg = error.code === 'ERR_NETWORK' ? '网络异常,请重试' : error.message
|
|
|
+ error.message = errorMsg
|
|
|
+ message.error(errorMsg)
|
|
|
+ onError?.(error)
|
|
|
+ })
|
|
|
+
|
|
|
+ return {
|
|
|
+ abort() {
|
|
|
+ console.log('上传取消', customFile.name)
|
|
|
+ },
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const allowedExt = ['.bin', '.hex', '.fw']
|
|
|
+const beforeUpload = (file: File) => {
|
|
|
+ const lowerName = file.name.toLowerCase()
|
|
|
+ const hasAllowedExt = allowedExt.some((ext) => lowerName.endsWith(ext))
|
|
|
+
|
|
|
+ if (!hasAllowedExt) {
|
|
|
+ message.error(`只能上传 ${allowedExt.join(', ')} 格式的文件!`)
|
|
|
+ return Upload.LIST_IGNORE
|
|
|
+ }
|
|
|
+
|
|
|
+ const isLt10M = file.size / 1024 / 1024 <= 10
|
|
|
+ if (!isLt10M) {
|
|
|
+ message.error('文件大小不能超过10MB!')
|
|
|
+ return Upload.LIST_IGNORE
|
|
|
+ }
|
|
|
+
|
|
|
+ return true
|
|
|
+}
|
|
|
+
|
|
|
+const cancel = () => {
|
|
|
+ fileList.value = []
|
|
|
+ repeateIds.value = []
|
|
|
+ emit('update:open', false)
|
|
|
+}
|
|
|
+
|
|
|
+const otaList = ref<OtaItem[]>([])
|
|
|
+const fetchOTAList = async () => {
|
|
|
+ try {
|
|
|
+ const response = await deviceApi.getOtaList()
|
|
|
+ otaList.value = response.data
|
|
|
+ console.log('获取OTA列表 成功 ✅', response)
|
|
|
+ } catch (error) {
|
|
|
+ console.log('获取OTA列表 失败 ❌', error)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+watch(
|
|
|
+ () => props.open,
|
|
|
+ (newVal) => {
|
|
|
+ if (newVal) {
|
|
|
+ fetchOTAList()
|
|
|
+ }
|
|
|
+ }
|
|
|
+)
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped lang="less">
|
|
|
+:deep(.ant-modal) {
|
|
|
+ .header {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ }
|
|
|
+
|
|
|
+ .notes {
|
|
|
+ background-color: #f7f7f7;
|
|
|
+ padding: 16px;
|
|
|
+ border-radius: 8px;
|
|
|
+ margin-top: 24px;
|
|
|
+ margin-top: 24px;
|
|
|
+ height: 400px;
|
|
|
+ overflow-y: auto;
|
|
|
+
|
|
|
+ .title {
|
|
|
+ font-size: 16px;
|
|
|
+ font-weight: 600;
|
|
|
+ }
|
|
|
+
|
|
|
+ .item {
|
|
|
+ display: flex;
|
|
|
+ color: #333;
|
|
|
+ opacity: 0.6;
|
|
|
+ line-height: 2;
|
|
|
+ &:hover {
|
|
|
+ opacity: 1;
|
|
|
+ }
|
|
|
+ &-index {
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 600;
|
|
|
+ margin-right: 10px;
|
|
|
+ flex-shrink: 0;
|
|
|
+ color: #1677ff;
|
|
|
+ }
|
|
|
+ &-name {
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 600;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+ flex-shrink: 1;
|
|
|
+ margin-right: 10px;
|
|
|
+ }
|
|
|
+ &-time {
|
|
|
+ font-size: 12px;
|
|
|
+ margin-left: auto;
|
|
|
+ flex-shrink: 0;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .footer {
|
|
|
+ margin-top: 24px;
|
|
|
+ text-align: right;
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|