ソースを参照

feat: 新增设备OTA弹窗(上传固件、批量更新);

liujia 3 週間 前
コミット
08c529a

+ 2 - 0
components.d.ts

@@ -29,6 +29,7 @@ declare module 'vue' {
     AInput: typeof import('ant-design-vue/es')['Input']
     AInputNumber: typeof import('ant-design-vue/es')['InputNumber']
     AInputPassword: typeof import('ant-design-vue/es')['InputPassword']
+    AInputSearch: typeof import('ant-design-vue/es')['InputSearch']
     ALayout: typeof import('ant-design-vue/es')['Layout']
     ALayoutContent: typeof import('ant-design-vue/es')['LayoutContent']
     ALayoutFooter: typeof import('ant-design-vue/es')['LayoutFooter']
@@ -44,6 +45,7 @@ declare module 'vue' {
     ARadioButton: typeof import('ant-design-vue/es')['RadioButton']
     ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup']
     ARangePicker: typeof import('ant-design-vue/es')['RangePicker']
+    AResult: typeof import('ant-design-vue/es')['Result']
     ASelect: typeof import('ant-design-vue/es')['Select']
     ASelectOption: typeof import('ant-design-vue/es')['SelectOption']
     ASkeleton: typeof import('ant-design-vue/es')['Skeleton']

+ 461 - 141
src/views/device/list/components/OTADevice/index.vue

@@ -4,7 +4,7 @@
       :get-container="() => $refs.mod"
       :open="open"
       :mask-closable="false"
-      :width="600"
+      :width="900"
       @cancel="cancel"
       :footer="null"
     >
@@ -13,6 +13,7 @@
           <div class="title">{{ title }}</div>
         </div>
       </template>
+
       <a-upload-dragger
         v-model:fileList="fileList"
         name="file"
@@ -25,30 +26,129 @@
         @change="handleChange"
         @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-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 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 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>
   </div>
 </template>
 
 <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 { useUserStore } from '@/stores/user'
 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 type { OtaItem } from '@/api/device/types'
 
-defineOptions({
-  name: 'OTADevice',
-})
+defineOptions({ name: 'OTADevice' })
 
 const userStore = useUserStore()
 
-type Props = {
-  open: boolean
-  title?: string
-}
+type Props = { open: boolean; title?: string }
 const emit = defineEmits<{
   (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 acceptType = '.bin,.hex,.fw'
-
-const handleChange = (info: UploadChangeParam) => {
-  console.log('handleChange', info)
+const handleChange = (info: { file: UploadFileItem; fileList: UploadFileItem[] }) => {
   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) {
-  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 = []
-    }
+    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 repeateIds = ref<string[]>()
+type UploadSuccessHandler = <T = unknown>(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)
-  }
-
+  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,
+          event: progressEvent.event as ProgressEvent,
         })
       },
     })
     .then((response: AxiosResponse) => {
-      if (['200', 200].includes(response.data.code)) {
+      if (['200', 200].includes(response.data?.code)) {
         handleSuccess(response.data)
-        console.log('✅上传成功', response.data)
         message.success('上传成功')
-        fetchOTAList()
+        void fetchOTAList()
       } else {
-        const errorMsg = response.data.message
+        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
+    .catch((error: unknown) => {
+      const err = error as { code?: string; message?: string }
+      const errorMsg =
+        err?.code === 'ERR_NETWORK' ? '网络异常,请重试' : (err?.message ?? '上传出错')
       message.error(errorMsg)
-      onError?.(error)
+      onError?.(error as 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 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 {
-    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(
   () => 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>
 
 <style scoped lang="less">
@@ -250,55 +532,93 @@ watch(
     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;
     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;
+    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>

+ 1 - 1
src/views/device/list/index.vue

@@ -58,7 +58,7 @@
         </div>
         <div class="tableCard-header-extra">
           <a-space>
-            <!-- <a-button @click="otaDeviceOpen = true">OTA升级</a-button> -->
+            <a-button @click="otaDeviceOpen = true">OTA升级</a-button>
             <a-button @click="addDeviceOpen = true">添加设备</a-button>
             <a-button type="primary" @click="uploadDeviceOpen = true">批量上传设备</a-button>
           </a-space>