|
@@ -4,7 +4,7 @@
|
|
:get-container="() => $refs.mod"
|
|
:get-container="() => $refs.mod"
|
|
:open="open"
|
|
:open="open"
|
|
:mask-closable="false"
|
|
:mask-closable="false"
|
|
- :width="600"
|
|
|
|
|
|
+ :width="900"
|
|
@cancel="cancel"
|
|
@cancel="cancel"
|
|
:footer="null"
|
|
:footer="null"
|
|
>
|
|
>
|
|
@@ -13,6 +13,7 @@
|
|
<div class="title">{{ title }}</div>
|
|
<div class="title">{{ title }}</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</template>
|
|
|
|
+
|
|
<a-upload-dragger
|
|
<a-upload-dragger
|
|
v-model:fileList="fileList"
|
|
v-model:fileList="fileList"
|
|
name="file"
|
|
name="file"
|
|
@@ -25,30 +26,129 @@
|
|
@change="handleChange"
|
|
@change="handleChange"
|
|
@drop="handleDrop"
|
|
@drop="handleDrop"
|
|
>
|
|
>
|
|
- <p class="ant-upload-drag-icon">
|
|
|
|
- <inbox-outlined></inbox-outlined>
|
|
|
|
- </p>
|
|
|
|
|
|
+ <p class="ant-upload-drag-icon"><inbox-outlined /></p>
|
|
<p class="ant-upload-text">单击或拖动文件到此区域进行上传</p>
|
|
<p class="ant-upload-text">单击或拖动文件到此区域进行上传</p>
|
|
<p class="ant-upload-hint">支持 {{ allowedExt.join(', ') }} 格式文件,最大 10MB </p>
|
|
<p class="ant-upload-hint">支持 {{ allowedExt.join(', ') }} 格式文件,最大 10MB </p>
|
|
</a-upload-dragger>
|
|
</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 class="update">
|
|
|
|
+ <a-steps progress-dot :current="currentStep" :items="steps" />
|
|
|
|
+
|
|
|
|
+ <div v-if="currentStep === 0" class="list">
|
|
|
|
+ <template v-if="otaList && otaList.length">
|
|
|
|
+ <div
|
|
|
|
+ v-for="(ota, index) in otaList"
|
|
|
|
+ :key="String(ota.fileId)"
|
|
|
|
+ class="item"
|
|
|
|
+ :class="{ selected: String((ota as any).fileId) === selectedFileId }"
|
|
|
|
+ @click="selectOta(ota)"
|
|
|
|
+ @dblclick="onNext"
|
|
|
|
+ role="button"
|
|
|
|
+ tabindex="0"
|
|
|
|
+ @keydown.enter.prevent="selectOta(ota)"
|
|
|
|
+ :title="ota.fileName"
|
|
|
|
+ >
|
|
|
|
+ <div class="item-index">{{ index + 1 }}</div>
|
|
|
|
+ <div class="item-name">{{ ota.fileName }}</div>
|
|
|
|
+ <div class="item-time">{{ ota.createTime }}</div>
|
|
|
|
+ </div>
|
|
|
|
+ </template>
|
|
|
|
+ <template v-else>
|
|
|
|
+ <div class="item"
|
|
|
|
+ >暂无数据 <a-button type="link" @click="fetchOTAList">刷新</a-button></div
|
|
|
|
+ >
|
|
|
|
+ </template>
|
|
|
|
+ </div>
|
|
|
|
+
|
|
|
|
+ <div v-if="currentStep === 1" class="device-select">
|
|
|
|
+ <a-space direction="vertical" style="width: 100%; margin-top: 12px">
|
|
|
|
+ <div style="display: flex; gap: 8px; align-items: center">
|
|
|
|
+ <a-input
|
|
|
|
+ v-model:value="searchName"
|
|
|
|
+ placeholder="设备名称"
|
|
|
|
+ allowClear
|
|
|
|
+ style="width: 150px"
|
|
|
|
+ @keyup.enter="onSearchDevices"
|
|
|
|
+ />
|
|
|
|
+ <a-input
|
|
|
|
+ v-model:value="searchId"
|
|
|
|
+ placeholder="设备ID"
|
|
|
|
+ allowClear
|
|
|
|
+ style="width: 150px"
|
|
|
|
+ @keyup.enter="onSearchDevices"
|
|
|
|
+ />
|
|
|
|
+ <a-button type="primary" @click="onSearchDevices">搜索</a-button>
|
|
|
|
+ <a-button @click="reloadDevices">重置</a-button>
|
|
|
|
+ </div>
|
|
|
|
+
|
|
|
|
+ <div
|
|
|
|
+ style="display: flex; justify-content: space-between; margin-top: 8px; color: #666"
|
|
|
|
+ >
|
|
|
|
+ <div v-if="selectedOta">
|
|
|
|
+ <strong>已选择固件:</strong>
|
|
|
|
+ <span
|
|
|
|
+ >{{ selectedOta.fileName }}
|
|
|
|
+ <span style="color: #aaa">({{ selectedOta.createTime }})</span></span
|
|
|
|
+ >
|
|
|
|
+ </div>
|
|
|
|
+ 已选中 {{ selectedClientIds.length }} 台设备
|
|
|
|
+ </div>
|
|
|
|
+
|
|
|
|
+ <a-table
|
|
|
|
+ :columns="deviceColumns"
|
|
|
|
+ :data-source="deviceList"
|
|
|
|
+ :row-key="rowKey"
|
|
|
|
+ :pagination="false"
|
|
|
|
+ :loading="deviceLoading"
|
|
|
|
+ :row-selection="rowSelection"
|
|
|
|
+ :row-class-name="rowClassName"
|
|
|
|
+ :scroll="{ y: 320 }"
|
|
|
|
+ >
|
|
|
|
+ <template #bodyCell="{ column, record }">
|
|
|
|
+ <template v-if="column.key === 'name'">{{ record.name }}</template>
|
|
|
|
+ <template v-else>{{ record[column.dataIndex as string] }}</template>
|
|
|
|
+ </template>
|
|
|
|
+ </a-table>
|
|
|
|
+ </a-space>
|
|
|
|
+ </div>
|
|
|
|
+
|
|
|
|
+ <div v-if="currentStep === 2" class="finish">
|
|
|
|
+ <a-result status="success" title="指令已发送" sub-title="设备升级时长约30秒,请耐心等待">
|
|
|
|
+ <template #extra>
|
|
|
|
+ <a-space direction="vertical">
|
|
|
|
+ <a-button type="primary" @click="closeAfterFinish">我知道了</a-button>
|
|
|
|
+ </a-space>
|
|
|
|
+ </template>
|
|
|
|
+ </a-result>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
- <div class="footer"> </div>
|
|
|
|
|
|
+ <div class="footer" v-if="currentStep !== 2">
|
|
|
|
+ <div class="footer-left">
|
|
|
|
+ <a-button v-if="currentStep > 0" @click="onPrev">上一步</a-button>
|
|
|
|
+ </div>
|
|
|
|
+ <div class="footer-right">
|
|
|
|
+ <a-button @click="cancel">取消</a-button>
|
|
|
|
+
|
|
|
|
+ <a-button
|
|
|
|
+ type="primary"
|
|
|
|
+ :disabled="nextDisabled || upgrading"
|
|
|
|
+ :loading="upgrading"
|
|
|
|
+ @click="onPrimaryClick"
|
|
|
|
+ >
|
|
|
|
+ <span v-if="currentStep === 0">下一步</span>
|
|
|
|
+ <span v-else-if="currentStep === 1">开始升级</span>
|
|
|
|
+ <span v-else>完成</span>
|
|
|
|
+ </a-button>
|
|
|
|
+ </div>
|
|
|
|
+ </div>
|
|
</a-modal>
|
|
</a-modal>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</template>
|
|
|
|
|
|
<script setup lang="ts">
|
|
<script setup lang="ts">
|
|
-import { ref, watch } from 'vue'
|
|
|
|
-import { message, type UploadChangeParam, Upload } from 'ant-design-vue'
|
|
|
|
|
|
+import { ref, watch, computed, reactive } from 'vue'
|
|
|
|
+import { message, Upload } from 'ant-design-vue'
|
|
import { InboxOutlined } from '@ant-design/icons-vue'
|
|
import { InboxOutlined } from '@ant-design/icons-vue'
|
|
import { useUserStore } from '@/stores/user'
|
|
import { useUserStore } from '@/stores/user'
|
|
import axios, { type AxiosProgressEvent, type AxiosResponse } from 'axios'
|
|
import axios, { type AxiosProgressEvent, type AxiosResponse } from 'axios'
|
|
@@ -56,191 +156,373 @@ import type { UploadRequestOption } from 'ant-design-vue/es/vc-upload/interface'
|
|
import * as deviceApi from '@/api/device'
|
|
import * as deviceApi from '@/api/device'
|
|
import type { OtaItem } from '@/api/device/types'
|
|
import type { OtaItem } from '@/api/device/types'
|
|
|
|
|
|
-defineOptions({
|
|
|
|
- name: 'OTADevice',
|
|
|
|
-})
|
|
|
|
|
|
+defineOptions({ name: 'OTADevice' })
|
|
|
|
|
|
const userStore = useUserStore()
|
|
const userStore = useUserStore()
|
|
|
|
|
|
-type Props = {
|
|
|
|
- open: boolean
|
|
|
|
- title?: string
|
|
|
|
-}
|
|
|
|
|
|
+type Props = { open: boolean; title?: string }
|
|
const emit = defineEmits<{
|
|
const emit = defineEmits<{
|
|
(e: 'update:open', value: boolean): void
|
|
(e: 'update:open', value: boolean): void
|
|
- (e: 'success', value: void): void
|
|
|
|
|
|
+ (e: 'success', payload: { ota: OtaItem; clientIds: string[] } | null): void
|
|
}>()
|
|
}>()
|
|
|
|
|
|
-const props = withDefaults(defineProps<Props>(), {
|
|
|
|
- open: false,
|
|
|
|
- title: 'OTA升级',
|
|
|
|
-})
|
|
|
|
|
|
+const props = withDefaults(defineProps<Props>(), { open: false, title: 'OTA升级' })
|
|
|
|
|
|
-const fileList = ref([])
|
|
|
|
|
|
+interface ApiResponse<T = unknown> {
|
|
|
|
+ code?: number | string
|
|
|
|
+ message?: string
|
|
|
|
+ data?: T
|
|
|
|
+}
|
|
|
|
+type OtaFileId = string | number
|
|
|
|
+type DeviceApiRowRaw = Record<string, unknown>
|
|
|
|
+type DeviceRow = { clientId: string; id: string; name: string; status?: string }
|
|
|
|
+
|
|
|
|
+type UploadFileItem = {
|
|
|
|
+ uid?: string
|
|
|
|
+ name?: string
|
|
|
|
+ status?: string
|
|
|
|
+ response?: unknown
|
|
|
|
+ [k: string]: unknown
|
|
|
|
+}
|
|
|
|
+const fileList = ref<UploadFileItem[]>([])
|
|
const action = `${import.meta.env.VITE_API_BASE_URL}/system/OTAUpload`
|
|
const action = `${import.meta.env.VITE_API_BASE_URL}/system/OTAUpload`
|
|
const acceptType = '.bin,.hex,.fw'
|
|
const acceptType = '.bin,.hex,.fw'
|
|
-
|
|
|
|
-const handleChange = (info: UploadChangeParam) => {
|
|
|
|
- console.log('handleChange', info)
|
|
|
|
|
|
+const handleChange = (info: { file: UploadFileItem; fileList: UploadFileItem[] }) => {
|
|
const status = info.file.status
|
|
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.`)
|
|
|
|
- }
|
|
|
|
|
|
+ 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) {
|
|
function handleDrop(e: DragEvent) {
|
|
- console.log(e)
|
|
|
|
- // 获取拖拽的文件
|
|
|
|
if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length) {
|
|
if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length) {
|
|
const file = e.dataTransfer.files[0]
|
|
const file = e.dataTransfer.files[0]
|
|
- // 调用beforeUpload进行验证
|
|
|
|
const isValid = beforeUpload(file)
|
|
const isValid = beforeUpload(file)
|
|
- if (isValid !== true) {
|
|
|
|
- // 如果验证失败,清空文件列表
|
|
|
|
- fileList.value = []
|
|
|
|
- }
|
|
|
|
|
|
+ if (isValid !== true) fileList.value = []
|
|
}
|
|
}
|
|
}
|
|
}
|
|
-
|
|
|
|
const headers = {
|
|
const headers = {
|
|
'content-type': 'multipart/form-data',
|
|
'content-type': 'multipart/form-data',
|
|
Authorization: userStore.userInfo.tokenValue,
|
|
Authorization: userStore.userInfo.tokenValue,
|
|
}
|
|
}
|
|
-
|
|
|
|
-// 自定义上传文件类型(扩展原生 File 类型)
|
|
|
|
interface CustomUploadFile extends File {
|
|
interface CustomUploadFile extends File {
|
|
uid: string
|
|
uid: string
|
|
readonly lastModifiedDate: Date
|
|
readonly lastModifiedDate: Date
|
|
}
|
|
}
|
|
-
|
|
|
|
-// 进度回调函数类型
|
|
|
|
type UploadProgressHandler = (progressEvent: {
|
|
type UploadProgressHandler = (progressEvent: {
|
|
percent: number
|
|
percent: number
|
|
loaded: number
|
|
loaded: number
|
|
total: number
|
|
total: number
|
|
event: ProgressEvent
|
|
event: ProgressEvent
|
|
}) => void
|
|
}) => 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 repeateIds = ref<string[]>()
|
|
|
|
+type UploadSuccessHandler = <T = unknown>(response: T, event?: ProgressEvent) => void
|
|
|
|
|
|
const customRequest = (options: UploadRequestOption) => {
|
|
const customRequest = (options: UploadRequestOption) => {
|
|
const { file, onProgress, onSuccess, onError } = options
|
|
const { file, onProgress, onSuccess, onError } = options
|
|
const customFile = file as CustomUploadFile
|
|
const customFile = file as CustomUploadFile
|
|
-
|
|
|
|
- // 创建 FormData
|
|
|
|
const formData = new FormData()
|
|
const formData = new FormData()
|
|
formData.append('file', customFile)
|
|
formData.append('file', customFile)
|
|
-
|
|
|
|
- // 处理进度事件
|
|
|
|
const handleProgress: UploadProgressHandler = (progressEvent) => {
|
|
const handleProgress: UploadProgressHandler = (progressEvent) => {
|
|
- // 确保进度在 0-100 范围内
|
|
|
|
const percent = Math.min(100, Math.max(0, progressEvent.percent))
|
|
const percent = Math.min(100, Math.max(0, progressEvent.percent))
|
|
onProgress?.({ percent })
|
|
onProgress?.({ percent })
|
|
}
|
|
}
|
|
-
|
|
|
|
- // 处理成功响应
|
|
|
|
- const handleSuccess: UploadSuccessHandler = (response) => {
|
|
|
|
- onSuccess?.(response)
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
|
|
+ const handleSuccess: UploadSuccessHandler = (response) => onSuccess?.(response)
|
|
repeateIds.value = []
|
|
repeateIds.value = []
|
|
- // 发送请求
|
|
|
|
axios
|
|
axios
|
|
.post(action, formData, {
|
|
.post(action, formData, {
|
|
headers: { 'Content-Type': 'multipart/form-data' },
|
|
headers: { 'Content-Type': 'multipart/form-data' },
|
|
onUploadProgress: (progressEvent: AxiosProgressEvent) => {
|
|
onUploadProgress: (progressEvent: AxiosProgressEvent) => {
|
|
if (!progressEvent.total) return
|
|
if (!progressEvent.total) return
|
|
-
|
|
|
|
const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total)
|
|
const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total)
|
|
-
|
|
|
|
handleProgress({
|
|
handleProgress({
|
|
percent,
|
|
percent,
|
|
loaded: progressEvent.loaded,
|
|
loaded: progressEvent.loaded,
|
|
total: progressEvent.total,
|
|
total: progressEvent.total,
|
|
- event: progressEvent.event,
|
|
|
|
|
|
+ event: progressEvent.event as ProgressEvent,
|
|
})
|
|
})
|
|
},
|
|
},
|
|
})
|
|
})
|
|
.then((response: AxiosResponse) => {
|
|
.then((response: AxiosResponse) => {
|
|
- if (['200', 200].includes(response.data.code)) {
|
|
|
|
|
|
+ if (['200', 200].includes(response.data?.code)) {
|
|
handleSuccess(response.data)
|
|
handleSuccess(response.data)
|
|
- console.log('✅上传成功', response.data)
|
|
|
|
message.success('上传成功')
|
|
message.success('上传成功')
|
|
- fetchOTAList()
|
|
|
|
|
|
+ void fetchOTAList()
|
|
} else {
|
|
} else {
|
|
- const errorMsg = response.data.message
|
|
|
|
|
|
+ const errorMsg = response.data?.message ?? '上传接口返回错误'
|
|
message.error(errorMsg)
|
|
message.error(errorMsg)
|
|
onError?.(response.data)
|
|
onError?.(response.data)
|
|
}
|
|
}
|
|
})
|
|
})
|
|
- .catch((error) => {
|
|
|
|
- console.log('❌上传失败', error)
|
|
|
|
- const errorMsg = error.code === 'ERR_NETWORK' ? '网络异常,请重试' : error.message
|
|
|
|
- error.message = errorMsg
|
|
|
|
|
|
+ .catch((error: unknown) => {
|
|
|
|
+ const err = error as { code?: string; message?: string }
|
|
|
|
+ const errorMsg =
|
|
|
|
+ err?.code === 'ERR_NETWORK' ? '网络异常,请重试' : (err?.message ?? '上传出错')
|
|
message.error(errorMsg)
|
|
message.error(errorMsg)
|
|
- onError?.(error)
|
|
|
|
|
|
+ onError?.(error as Error)
|
|
})
|
|
})
|
|
-
|
|
|
|
return {
|
|
return {
|
|
abort() {
|
|
abort() {
|
|
console.log('上传取消', customFile.name)
|
|
console.log('上传取消', customFile.name)
|
|
},
|
|
},
|
|
}
|
|
}
|
|
}
|
|
}
|
|
-
|
|
|
|
const allowedExt = ['.bin', '.hex', '.fw']
|
|
const allowedExt = ['.bin', '.hex', '.fw']
|
|
const beforeUpload = (file: File) => {
|
|
const beforeUpload = (file: File) => {
|
|
const lowerName = file.name.toLowerCase()
|
|
const lowerName = file.name.toLowerCase()
|
|
const hasAllowedExt = allowedExt.some((ext) => lowerName.endsWith(ext))
|
|
const hasAllowedExt = allowedExt.some((ext) => lowerName.endsWith(ext))
|
|
-
|
|
|
|
if (!hasAllowedExt) {
|
|
if (!hasAllowedExt) {
|
|
message.error(`只能上传 ${allowedExt.join(', ')} 格式的文件!`)
|
|
message.error(`只能上传 ${allowedExt.join(', ')} 格式的文件!`)
|
|
return Upload.LIST_IGNORE
|
|
return Upload.LIST_IGNORE
|
|
}
|
|
}
|
|
-
|
|
|
|
const isLt10M = file.size / 1024 / 1024 <= 10
|
|
const isLt10M = file.size / 1024 / 1024 <= 10
|
|
if (!isLt10M) {
|
|
if (!isLt10M) {
|
|
message.error('文件大小不能超过10MB!')
|
|
message.error('文件大小不能超过10MB!')
|
|
return Upload.LIST_IGNORE
|
|
return Upload.LIST_IGNORE
|
|
}
|
|
}
|
|
-
|
|
|
|
return true
|
|
return true
|
|
}
|
|
}
|
|
|
|
|
|
-const cancel = () => {
|
|
|
|
- fileList.value = []
|
|
|
|
- repeateIds.value = []
|
|
|
|
- emit('update:open', false)
|
|
|
|
|
|
+const otaList = ref<OtaItem[]>([])
|
|
|
|
+const selectedFileId = ref<string | null>(null)
|
|
|
|
+const selectedOta = computed(() => {
|
|
|
|
+ const found = otaList.value.find((i) => {
|
|
|
|
+ const fid = (i as { fileId?: OtaFileId }).fileId
|
|
|
|
+ return fid !== undefined && String(fid) === selectedFileId.value
|
|
|
|
+ })
|
|
|
|
+ return found ?? null
|
|
|
|
+})
|
|
|
|
+
|
|
|
|
+const currentStep = ref<number>(0)
|
|
|
|
+const lastStep = 2
|
|
|
|
+const steps = [{ title: '选择固件' }, { title: '选择设备' }, { title: '升级结束' }]
|
|
|
|
+
|
|
|
|
+// 设备数据 & 搜索
|
|
|
|
+const deviceList = ref<DeviceRow[]>([])
|
|
|
|
+const deviceLoading = ref(false)
|
|
|
|
+const searchName = ref<string>('')
|
|
|
|
+const searchId = ref<string>('')
|
|
|
|
+
|
|
|
|
+// 选中 clientId 集合
|
|
|
|
+const selectedClientIds = ref<string[]>([])
|
|
|
|
+
|
|
|
|
+// 升级状态
|
|
|
|
+const upgrading = ref(false)
|
|
|
|
+
|
|
|
|
+// 表格列
|
|
|
|
+const deviceColumns = [
|
|
|
|
+ { title: '设备名称', dataIndex: 'name', key: 'name' },
|
|
|
|
+ { title: '设备ID', dataIndex: 'clientId', key: 'clientId', width: 220 },
|
|
|
|
+ { title: '设备状态', dataIndex: 'status', key: 'status', width: 120 },
|
|
|
|
+]
|
|
|
|
+
|
|
|
|
+const rowKey = (r: DeviceRow) => r.clientId
|
|
|
|
+
|
|
|
|
+type RowSelection = {
|
|
|
|
+ type: 'checkbox'
|
|
|
|
+ selectedRowKeys: string[]
|
|
|
|
+ onChange: (selectedRowKeys: string[]) => void
|
|
|
|
+ preserveSelectedRowKeys?: boolean
|
|
}
|
|
}
|
|
|
|
+const rowSelection = reactive<RowSelection>({
|
|
|
|
+ type: 'checkbox',
|
|
|
|
+ selectedRowKeys: [],
|
|
|
|
+ onChange: (selectedRowKeys: string[]) => {
|
|
|
|
+ selectedClientIds.value = selectedRowKeys
|
|
|
|
+ },
|
|
|
|
+ preserveSelectedRowKeys: true,
|
|
|
|
+})
|
|
|
|
+watch(
|
|
|
|
+ selectedClientIds,
|
|
|
|
+ (keys) => {
|
|
|
|
+ rowSelection.selectedRowKeys = Array.isArray(keys) ? [...keys] : []
|
|
|
|
+ },
|
|
|
|
+ { deep: true, immediate: true }
|
|
|
|
+)
|
|
|
|
|
|
-const otaList = ref<OtaItem[]>([])
|
|
|
|
-const fetchOTAList = async () => {
|
|
|
|
|
|
+function rowClassName(record: DeviceRow) {
|
|
|
|
+ return selectedClientIds.value.includes(record.clientId) ? 'custom-row-selected' : ''
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+const nextDisabled = computed(() => {
|
|
|
|
+ if (currentStep.value === 0) return !selectedFileId.value
|
|
|
|
+ if (currentStep.value === 1) return selectedClientIds.value.length === 0
|
|
|
|
+ return false
|
|
|
|
+})
|
|
|
|
+
|
|
|
|
+const fetchOTAList = async (): Promise<void> => {
|
|
try {
|
|
try {
|
|
- const response = await deviceApi.getOtaList()
|
|
|
|
- otaList.value = response.data
|
|
|
|
- console.log('获取OTA列表 成功 ✅', response)
|
|
|
|
- } catch (error) {
|
|
|
|
- console.log('获取OTA列表 失败 ❌', error)
|
|
|
|
|
|
+ const resp = (await deviceApi.getOtaList()) as ApiResponse<OtaItem[]>
|
|
|
|
+ if (Array.isArray(resp?.data)) {
|
|
|
|
+ otaList.value = resp.data
|
|
|
|
+ if (otaList.value.length && !selectedFileId.value) {
|
|
|
|
+ selectedFileId.value = String((otaList.value[0] as { fileId?: OtaFileId }).fileId ?? '')
|
|
|
|
+ }
|
|
|
|
+ } else {
|
|
|
|
+ otaList.value = []
|
|
|
|
+ }
|
|
|
|
+ } catch (err) {
|
|
|
|
+ console.error('获取 OTA 列表失败', err)
|
|
|
|
+ otaList.value = []
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
|
|
+const fetchDeviceList = async (): Promise<void> => {
|
|
|
|
+ deviceLoading.value = true
|
|
|
|
+ try {
|
|
|
|
+ const params = {
|
|
|
|
+ pageNo: 1,
|
|
|
|
+ pageSize: 1000,
|
|
|
|
+ clientId: searchId.value || undefined,
|
|
|
|
+ devName: searchName.value || undefined,
|
|
|
|
+ createTimeStart: undefined,
|
|
|
|
+ createTimeEnd: undefined,
|
|
|
|
+ online: null,
|
|
|
|
+ }
|
|
|
|
+ const res = (await deviceApi.getDeviceList(params)) as ApiResponse<unknown>
|
|
|
|
+ const data = res?.data as Record<string, unknown> | undefined
|
|
|
|
+ const rows = (data?.rows ?? data?.list) as unknown
|
|
|
|
+ if (Array.isArray(rows)) {
|
|
|
|
+ deviceList.value = rows.map((r) => {
|
|
|
|
+ const row = r as DeviceApiRowRaw
|
|
|
|
+ const clientId = String(row.clientId ?? '')
|
|
|
|
+ const name = String(row.devName ?? '')
|
|
|
|
+ const onlineVal = row.online
|
|
|
|
+ let status = '未知'
|
|
|
|
+ if (onlineVal === 1 || onlineVal === '1') {
|
|
|
|
+ status = '在线'
|
|
|
|
+ } else if (onlineVal === 0 || onlineVal === '0') {
|
|
|
|
+ status = '离线'
|
|
|
|
+ } else if (onlineVal === 9 || onlineVal === '9') {
|
|
|
|
+ status = '未激活'
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ return { clientId, id: clientId, name, status }
|
|
|
|
+ })
|
|
|
|
+ selectedClientIds.value = selectedClientIds.value.filter((id) =>
|
|
|
|
+ deviceList.value.some((d) => d.clientId === id)
|
|
|
|
+ )
|
|
|
|
+ } else {
|
|
|
|
+ deviceList.value = []
|
|
|
|
+ selectedClientIds.value = []
|
|
|
|
+ }
|
|
|
|
+ } catch (err) {
|
|
|
|
+ console.error('获取设备列表失败', err)
|
|
|
|
+ deviceList.value = []
|
|
|
|
+ selectedClientIds.value = []
|
|
|
|
+ } finally {
|
|
|
|
+ deviceLoading.value = false
|
|
|
|
+ }
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+const onSearchDevices = async (): Promise<void> => {
|
|
|
|
+ await fetchDeviceList()
|
|
|
|
+}
|
|
|
|
+const reloadDevices = (): void => {
|
|
|
|
+ searchName.value = ''
|
|
|
|
+ searchId.value = ''
|
|
|
|
+ void fetchDeviceList()
|
|
|
|
+}
|
|
|
|
+
|
|
watch(
|
|
watch(
|
|
() => props.open,
|
|
() => props.open,
|
|
- (newVal) => {
|
|
|
|
- if (newVal) {
|
|
|
|
- fetchOTAList()
|
|
|
|
|
|
+ (val) => {
|
|
|
|
+ if (val) {
|
|
|
|
+ void fetchOTAList()
|
|
|
|
+ currentStep.value = 0
|
|
|
|
+ selectedFileId.value = null
|
|
|
|
+ selectedClientIds.value = []
|
|
|
|
+ deviceList.value = []
|
|
|
|
+ searchName.value = ''
|
|
|
|
+ searchId.value = ''
|
|
}
|
|
}
|
|
}
|
|
}
|
|
)
|
|
)
|
|
|
|
+
|
|
|
|
+// 选择固件
|
|
|
|
+function selectOta(ota: OtaItem): void {
|
|
|
|
+ const id = String((ota as { fileId?: OtaFileId }).fileId ?? '')
|
|
|
|
+ selectedFileId.value = selectedFileId.value === id ? null : id
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+function onPrev(): void {
|
|
|
|
+ if (currentStep.value > 0) currentStep.value -= 1
|
|
|
|
+}
|
|
|
|
+async function onNext(): Promise<void> {
|
|
|
|
+ if (currentStep.value === 0) {
|
|
|
|
+ if (!selectedFileId.value) {
|
|
|
|
+ message.warn('请先选择固件')
|
|
|
|
+ return
|
|
|
|
+ }
|
|
|
|
+ currentStep.value = 1
|
|
|
|
+ await fetchDeviceList()
|
|
|
|
+ return
|
|
|
|
+ }
|
|
|
|
+ if (currentStep.value === 1) {
|
|
|
|
+ message.info('请点击底部主按钮“开始升级”以发起升级')
|
|
|
|
+ return
|
|
|
|
+ }
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+// 主按钮点击处理
|
|
|
|
+async function onPrimaryClick(): Promise<void> {
|
|
|
|
+ if (currentStep.value === 0) {
|
|
|
|
+ await onNext()
|
|
|
|
+ return
|
|
|
|
+ }
|
|
|
|
+ if (currentStep.value === 1) {
|
|
|
|
+ await startUpgrade()
|
|
|
|
+ return
|
|
|
|
+ }
|
|
|
|
+ if (currentStep.value === lastStep) {
|
|
|
|
+ closeAfterFinish()
|
|
|
|
+ }
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+const startUpgrade = async (): Promise<void> => {
|
|
|
|
+ if (!selectedOta.value) {
|
|
|
|
+ message.warn('请选择固件')
|
|
|
|
+ return
|
|
|
|
+ }
|
|
|
|
+ if (selectedClientIds.value.length === 0) {
|
|
|
|
+ message.warn('请至少选择一台设备')
|
|
|
|
+ return
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ const ossUrl = (selectedOta.value as unknown as { ossUrl?: string }).ossUrl ?? ''
|
|
|
|
+ if (!ossUrl) {
|
|
|
|
+ message.error('所选固件未包含下载地址 ossUrl,无法发起升级')
|
|
|
|
+ return
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ upgrading.value = true
|
|
|
|
+
|
|
|
|
+ try {
|
|
|
|
+ const payload = { clientIds: selectedClientIds.value, ossUrl }
|
|
|
|
+ await deviceApi.updateOta(payload)
|
|
|
|
+
|
|
|
|
+ // 延迟 3 秒再进入结果页
|
|
|
|
+ await new Promise((resolve) => setTimeout(resolve, 3000))
|
|
|
|
+ currentStep.value = 2
|
|
|
|
+ emit('success', { ota: selectedOta.value as OtaItem, clientIds: selectedClientIds.value })
|
|
|
|
+ } catch (err) {
|
|
|
|
+ console.error('发起升级失败', err)
|
|
|
|
+ message.error('发起升级失败,请重试')
|
|
|
|
+ } finally {
|
|
|
|
+ upgrading.value = false
|
|
|
|
+ }
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+function closeAfterFinish(): void {
|
|
|
|
+ emit('update:open', false)
|
|
|
|
+}
|
|
|
|
+function cancel(): void {
|
|
|
|
+ fileList.value = []
|
|
|
|
+ repeateIds.value = []
|
|
|
|
+ selectedFileId.value = null
|
|
|
|
+ selectedClientIds.value = []
|
|
|
|
+ currentStep.value = 0
|
|
|
|
+ emit('update:open', false)
|
|
|
|
+}
|
|
</script>
|
|
</script>
|
|
|
|
|
|
<style scoped lang="less">
|
|
<style scoped lang="less">
|
|
@@ -250,55 +532,93 @@ watch(
|
|
align-items: center;
|
|
align-items: center;
|
|
}
|
|
}
|
|
|
|
|
|
- .notes {
|
|
|
|
- background-color: #f7f7f7;
|
|
|
|
- padding: 16px;
|
|
|
|
- border-radius: 8px;
|
|
|
|
- margin-top: 24px;
|
|
|
|
- margin-top: 24px;
|
|
|
|
|
|
+ .update {
|
|
|
|
+ margin-top: 16px;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ .list {
|
|
height: 400px;
|
|
height: 400px;
|
|
overflow-y: auto;
|
|
overflow-y: auto;
|
|
|
|
+ }
|
|
|
|
|
|
- .title {
|
|
|
|
- font-size: 16px;
|
|
|
|
- font-weight: 600;
|
|
|
|
- }
|
|
|
|
|
|
+ .list,
|
|
|
|
+ .device-select,
|
|
|
|
+ .finish {
|
|
|
|
+ background-color: #f7f7f7;
|
|
|
|
+ padding: 12px;
|
|
|
|
+ border-radius: 8px;
|
|
|
|
+ margin-top: 16px;
|
|
|
|
+ min-height: 400px;
|
|
|
|
+ }
|
|
|
|
|
|
- .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;
|
|
|
|
- }
|
|
|
|
- }
|
|
|
|
|
|
+ .item {
|
|
|
|
+ display: flex;
|
|
|
|
+ gap: 12px;
|
|
|
|
+ align-items: center;
|
|
|
|
+ padding: 8px;
|
|
|
|
+ border: 1px solid transparent;
|
|
|
|
+ border-radius: 6px;
|
|
|
|
+ cursor: pointer;
|
|
|
|
+ color: #333;
|
|
|
|
+ opacity: 0.8;
|
|
}
|
|
}
|
|
|
|
|
|
- .footer {
|
|
|
|
- margin-top: 24px;
|
|
|
|
|
|
+ .item:hover {
|
|
|
|
+ opacity: 1;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ .item.selected {
|
|
|
|
+ background: #e6f7ff;
|
|
|
|
+ border-color: #91d5ff;
|
|
|
|
+ box-shadow: 0 0 0 2px rgba(145, 213, 255, 0.12);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ .item-index {
|
|
|
|
+ width: 36px;
|
|
|
|
+ text-align: center;
|
|
|
|
+ color: #1677ff;
|
|
|
|
+ font-weight: 600;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ .item-name {
|
|
|
|
+ flex: 1;
|
|
|
|
+ overflow: hidden;
|
|
|
|
+ text-overflow: ellipsis;
|
|
|
|
+ white-space: nowrap;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ .item-time {
|
|
|
|
+ width: 180px;
|
|
text-align: right;
|
|
text-align: right;
|
|
|
|
+ color: #888;
|
|
|
|
+ font-size: 12px;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ .placeholder {
|
|
|
|
+ padding: 12px;
|
|
|
|
+ color: #666;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ .footer {
|
|
|
|
+ margin-top: 12px;
|
|
|
|
+ display: flex;
|
|
|
|
+ justify-content: space-between;
|
|
|
|
+ align-items: center;
|
|
|
|
+ gap: 12px;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ .footer-right {
|
|
|
|
+ display: flex;
|
|
|
|
+ gap: 8px;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ .custom-row-selected {
|
|
|
|
+ background: #e6f7ff !important;
|
|
|
|
+ box-shadow: inset 0 0 0 1px rgba(24, 144, 255, 0.12);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ .ant-result {
|
|
|
|
+ padding: 16px;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</style>
|
|
</style>
|