Просмотр исходного кода

feat:【IoT 物联网】初始化 IoT 固件详情页 70%

YunaiV 9 месяцев назад
Родитель
Сommit
667d6fc35c

+ 7 - 2
src/api/iot/device/device/index.ts

@@ -118,8 +118,13 @@ export const DeviceApi = {
   },
 
   // 获取设备的精简信息列表
-  getSimpleDeviceList: async (deviceType?: number) => {
-    return await request.get({ url: `/iot/device/simple-list?`, params: { deviceType } })
+  getSimpleDeviceList: async (deviceType?: number, productId?: number) => {
+    return await request.get({ url: `/iot/device/simple-list?`, params: { deviceType, productId } })
+  },
+
+  // 根据产品编号,获取设备的精简信息列表
+  getDeviceListByProductId: async (productId: number) => {
+    return await request.get({ url: `/iot/device/simple-list?`, params: { productId } })
   },
 
   // 获取导入模板

+ 1 - 0
src/api/iot/ota/task/index.ts

@@ -8,6 +8,7 @@ export interface OtaTask {
   firmwareId?: number // 固件编号
   status?: number // 任务状态
   deviceScope?: number // 升级范围
+  deviceIds?: number[] // 指定设备ID列表(当升级范围为指定设备时使用)
   deviceTotalCount?: number // 设备总共数量
   deviceSuccessCount?: number // 设备成功数量
   createTime?: string // 创建时间

+ 2 - 1
src/utils/dict.ts

@@ -243,5 +243,6 @@ export enum DICT_TYPE {
   IOT_ALERT_LEVEL = 'iot_alert_level', // IoT 告警级别
   IOT_ALERT_RECEIVE_TYPE = 'iot_alert_receive_type', // IoT 告警接收类型
   IOT_OTA_TASK_DEVICE_SCOPE = 'iot_ota_task_device_scope', // IoT OTA任务设备范围
-  IOT_OTA_TASK_STATUS = 'iot_ota_task_status' // IoT OTA任务状态
+  IOT_OTA_TASK_STATUS = 'iot_ota_task_status', // IoT OTA 任务状态
+  IOT_OTA_RECORD_STATUS = 'iot_ota_record_status' // IoT OTA 记录状态
 }

+ 7 - 3
src/views/iot/ota/firmware/detail/index.vue

@@ -21,8 +21,8 @@
       </el-descriptions>
     </ContentWrap>
 
-    <!-- 固件升级设备统计 -->
-    <ContentWrap title="固件升级设备统计" class="mb-20px">
+    <!-- 升级设备统计 -->
+    <ContentWrap title="升级设备统计" class="mb-20px">
       <el-row :gutter="20" class="py-20px" v-loading="firmwareStatisticsLoading">
         <el-col :span="6">
           <div class="text-center p-20px border border-solid border-gray-200 rounded bg-gray-50">
@@ -86,7 +86,11 @@
     </ContentWrap>
 
     <!-- 任务管理 -->
-    <OtaTaskList :firmware-id="firmwareId" />
+    <OtaTaskList
+      :firmware-id="firmwareId"
+      :product-id="firmware?.productId"
+      @success="getStatistics"
+    />
   </div>
 </template>
 

+ 347 - 0
src/views/iot/ota/task/OtaTaskDetail.vue

@@ -0,0 +1,347 @@
+<template>
+  <Dialog v-model="dialogVisible" title="升级任务详情" width="1200px" append-to-body>
+    <!-- 任务信息 -->
+    <ContentWrap title="任务信息" class="mb-20px">
+      <el-descriptions :column="3" v-loading="taskLoading">
+        <el-descriptions-item label="任务ID">{{ taskInfo.id }}</el-descriptions-item>
+        <el-descriptions-item label="任务名称">{{ taskInfo.name }}</el-descriptions-item>
+        <el-descriptions-item label="任务类型">版本升级</el-descriptions-item>
+        <el-descriptions-item label="设备数量">{{
+          taskInfo.deviceTotalCount
+        }}</el-descriptions-item>
+        <el-descriptions-item label="预定时间">-</el-descriptions-item>
+        <el-descriptions-item label="添加时间">{{
+          formatTime(taskInfo.createTime)
+        }}</el-descriptions-item>
+        <el-descriptions-item label="任务描述" :span="3">{{
+          taskInfo.description || '-'
+        }}</el-descriptions-item>
+      </el-descriptions>
+    </ContentWrap>
+
+    <!-- 任务升级设备统计 -->
+    <ContentWrap title="升级设备统计" class="mb-20px">
+      <el-row :gutter="20" class="py-20px" v-loading="statisticsLoading">
+        <el-col :span="4">
+          <div class="text-center p-20px border border-solid border-gray-200 rounded bg-gray-50">
+            <div class="text-32px font-bold mb-8px text-blue-500">
+              {{ statisticsData.total }}
+            </div>
+            <div class="text-14px text-gray-600">升级设备总数</div>
+          </div>
+        </el-col>
+        <el-col :span="4">
+          <div class="text-center p-20px border border-solid border-gray-200 rounded bg-gray-50">
+            <div class="text-32px font-bold mb-8px text-gray-400">
+              {{ statisticsData.pending }}
+            </div>
+            <div class="text-14px text-gray-600">待推送</div>
+          </div>
+        </el-col>
+        <el-col :span="4">
+          <div class="text-center p-20px border border-solid border-gray-200 rounded bg-gray-50">
+            <div class="text-32px font-bold mb-8px text-yellow-500">
+              {{ statisticsData.upgrading }}
+            </div>
+            <div class="text-14px text-gray-600">正在升级</div>
+          </div>
+        </el-col>
+        <el-col :span="4">
+          <div class="text-center p-20px border border-solid border-gray-200 rounded bg-gray-50">
+            <div class="text-32px font-bold mb-8px text-green-500">
+              {{ statisticsData.success }}
+            </div>
+            <div class="text-14px text-gray-600">升级成功</div>
+          </div>
+        </el-col>
+        <el-col :span="4">
+          <div class="text-center p-20px border border-solid border-gray-200 rounded bg-gray-50">
+            <div class="text-32px font-bold mb-8px text-red-500">
+              {{ statisticsData.failure }}
+            </div>
+            <div class="text-14px text-gray-600">升级失败</div>
+          </div>
+        </el-col>
+        <el-col :span="4">
+          <div class="text-center p-20px border border-solid border-gray-200 rounded bg-gray-50">
+            <div class="text-32px font-bold mb-8px text-gray-400">
+              {{ statisticsData.stopped }}
+            </div>
+            <div class="text-14px text-gray-600">停止</div>
+          </div>
+        </el-col>
+      </el-row>
+    </ContentWrap>
+
+    <!-- 设备管理 -->
+    <ContentWrap title="升级设备记录">
+      <!-- Tab 切换 -->
+      <el-tabs v-model="activeTab" @tab-click="handleTabClick" class="mb-15px">
+        <el-tab-pane v-for="tab in statusTabs" :key="tab.key" :label="tab.label" :name="tab.key" />
+      </el-tabs>
+      <!-- Tab 内容 -->
+      <div v-for="tab in statusTabs" :key="tab.key" v-show="activeTab === tab.key">
+        <!-- 设备列表 -->
+        <el-table
+          v-loading="recordLoading"
+          :data="recordList"
+          :stripe="true"
+          :show-overflow-tooltip="true"
+        >
+          <el-table-column label="设备名称" align="center" prop="deviceName" />
+          <el-table-column label="当前版本" align="center" prop="currentVersion" />
+          <el-table-column label="升级状态" align="center" prop="status" width="120">
+            <template #default="scope">
+              <dict-tag :type="DICT_TYPE.IOT_OTA_RECORD_STATUS" :value="scope.row.status" />
+            </template>
+          </el-table-column>
+          <el-table-column label="升级进度" align="center" prop="progress" width="120">
+            <template #default="scope"> {{ scope.row.progress }}% </template>
+          </el-table-column>
+          <el-table-column label="状态描述" align="center" prop="description" />
+          <el-table-column label="更新时间" align="center" prop="updateTime" width="180">
+            <template #default="scope"> {{ formatTime(scope.row.updateTime) }} </template>
+          </el-table-column>
+          <el-table-column label="操作" align="center" width="80">
+            <template #default="scope">
+              <el-button
+                v-if="scope.row.status === IoTOtaTaskRecordStatusEnum.UPGRADING.value"
+                link
+                type="danger"
+                @click="handleCancelUpgrade(scope.row)"
+              >
+                取消
+              </el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+        <!-- 分页 -->
+        <div class="flex justify-center mt-20px">
+          <Pagination
+            :total="recordTotal"
+            v-model:page="queryParams.pageNo"
+            v-model:limit="queryParams.pageSize"
+            @pagination="getRecordList"
+          />
+        </div>
+      </div>
+    </ContentWrap>
+  </Dialog>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, computed } from 'vue'
+import { TabsPaneContext } from 'element-plus'
+import { Dialog } from '@/components/Dialog'
+import { ContentWrap } from '@/components/ContentWrap'
+import Pagination from '@/components/Pagination/index.vue'
+import { IoTOtaTaskApi, OtaTask } from '@/api/iot/ota/task'
+import { IoTOtaTaskRecordApi, OtaTaskRecord } from '@/api/iot/ota/task/record'
+import { DICT_TYPE } from '@/utils/dict'
+import { IoTOtaTaskRecordStatusEnum } from '@/views/iot/utils/constants'
+import { formatDate } from '@/utils/formatTime'
+
+/** OTA 任务详情组件 */
+defineOptions({ name: 'OtaTaskDetail' })
+
+const message = useMessage()
+
+// 弹窗相关
+const dialogVisible = ref(false)
+const taskId = ref<number>()
+
+// 任务信息
+const taskLoading = ref(false)
+const taskInfo = ref<OtaTask>({})
+
+// 统计加载状态
+const statisticsLoading = ref(false)
+
+// 统计数据
+const statisticsData = ref({
+  total: 0,
+  pending: 0,
+  pushed: 0,
+  upgrading: 0,
+  success: 0,
+  failure: 0,
+  stopped: 0
+})
+
+// 当前选中的标签
+const activeTab = ref('')
+
+// 记录列表相关
+const recordLoading = ref(false)
+const recordList = ref<OtaTaskRecord[]>([])
+const recordTotal = ref(0)
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  taskId: undefined as number | undefined,
+  status: undefined as number | undefined,
+  deviceNumber: ''
+})
+
+// 状态标签配置
+const statusTabs = computed(() => [
+  { key: '', label: '全部设备' },
+  {
+    key: IoTOtaTaskRecordStatusEnum.PENDING.value.toString(),
+    label: IoTOtaTaskRecordStatusEnum.PENDING.label
+  },
+  {
+    key: IoTOtaTaskRecordStatusEnum.PUSHED.value.toString(),
+    label: IoTOtaTaskRecordStatusEnum.PUSHED.label
+  },
+  {
+    key: IoTOtaTaskRecordStatusEnum.UPGRADING.value.toString(),
+    label: IoTOtaTaskRecordStatusEnum.UPGRADING.label
+  },
+  {
+    key: IoTOtaTaskRecordStatusEnum.SUCCESS.value.toString(),
+    label: IoTOtaTaskRecordStatusEnum.SUCCESS.label
+  },
+  {
+    key: IoTOtaTaskRecordStatusEnum.FAILURE.value.toString(),
+    label: IoTOtaTaskRecordStatusEnum.FAILURE.label
+  },
+  {
+    key: IoTOtaTaskRecordStatusEnum.CANCELED.value.toString(),
+    label: IoTOtaTaskRecordStatusEnum.CANCELED.label
+  }
+])
+
+/** 时间格式化 */
+const formatTime = (time: string | undefined) => {
+  if (!time) return '-'
+  return formatDate(new Date(time))
+}
+
+/** 获取任务详情 */
+const getTaskInfo = async () => {
+  if (!taskId.value) return
+
+  taskLoading.value = true
+  try {
+    const data = await IoTOtaTaskApi.getOtaTask(taskId.value)
+    taskInfo.value = data
+  } catch (error) {
+    console.error('获取任务详情失败', error)
+  } finally {
+    taskLoading.value = false
+  }
+}
+
+/** 获取统计数据 */
+const getStatistics = async () => {
+  if (!taskId.value) return
+
+  statisticsLoading.value = true
+  try {
+    const data = await IoTOtaTaskRecordApi.getOtaTaskRecordStatusStatistics(undefined, taskId.value)
+    statisticsData.value = {
+      total: data.total || 0,
+      pending: data.pending || 0,
+      pushed: data.pushed || 0,
+      upgrading: data.upgrading || 0,
+      success: data.success || 0,
+      failure: data.failure || 0,
+      stopped: data.stopped || 0
+    }
+  } catch (error) {
+    console.error('获取统计数据失败', error)
+    // 模拟数据
+    statisticsData.value = {
+      total: 1,
+      pending: 0,
+      pushed: 0,
+      upgrading: 0,
+      success: 0,
+      failure: 1,
+      stopped: 0
+    }
+  } finally {
+    statisticsLoading.value = false
+  }
+}
+
+/** 获取记录列表 */
+const getRecordList = async () => {
+  if (!taskId.value) return
+
+  recordLoading.value = true
+  try {
+    queryParams.taskId = taskId.value
+    const data = await IoTOtaTaskRecordApi.getOtaTaskRecordPage(queryParams)
+    recordList.value = data.list || []
+    recordTotal.value = data.total || 0
+  } catch (error) {
+    console.error('获取记录列表失败', error)
+    // 模拟数据
+    recordList.value = [
+      {
+        id: 1,
+        taskId: taskId.value,
+        deviceId: '1',
+        status: IoTOtaTaskRecordStatusEnum.FAILURE.value,
+        progress: 0,
+        description: '升级失败'
+      } as OtaTaskRecord
+    ]
+    recordTotal.value = 1
+  } finally {
+    recordLoading.value = false
+  }
+}
+
+/** 切换标签 */
+const handleTabClick = (tab: TabsPaneContext) => {
+  const tabKey = tab.paneName as string
+  activeTab.value = tabKey
+  queryParams.pageNo = 1
+
+  // 设置状态过滤:使用 IoTOtaTaskRecordStatusEnum 的值作为 tab key
+  if (tabKey === '') {
+    queryParams.status = undefined // 全部
+  } else {
+    queryParams.status = parseInt(tabKey) // 直接使用枚举值
+  }
+
+  getRecordList()
+}
+
+/** 取消升级 */
+const handleCancelUpgrade = async (record: OtaTaskRecord) => {
+  try {
+    await message.confirm('确认要取消该设备的升级任务吗?')
+    // TODO: 调用取消升级接口
+    message.success('取消成功')
+    getRecordList()
+  } catch (error) {
+    console.error('取消升级失败', error)
+  }
+}
+
+/** 打开弹窗 */
+const open = (id: number) => {
+  taskId.value = id
+  dialogVisible.value = true
+
+  // 重置数据
+  activeTab.value = ''
+  queryParams.pageNo = 1
+  queryParams.status = undefined
+  queryParams.deviceNumber = ''
+
+  // 加载数据
+  getTaskInfo()
+  getStatistics()
+  getRecordList()
+}
+
+/** 暴露方法 */
+defineExpose({
+  open
+})
+</script>

+ 76 - 55
src/views/iot/ota/task/OtaTaskForm.vue

@@ -1,9 +1,23 @@
 <template>
-  <el-dialog v-model="dialogVisible" title="新增升级任务" width="600px" append-to-body>
-    <el-form ref="formRef" :model="formData" :rules="formRules" label-width="100px">
+  <el-dialog v-model="dialogVisible" title="新增升级任务" width="800px" append-to-body>
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="100px"
+      v-loading="formLoading"
+    >
       <el-form-item label="任务名称" prop="name">
         <el-input v-model="formData.name" placeholder="请输入任务名称" />
       </el-form-item>
+      <el-form-item label="任务描述" prop="description">
+        <el-input
+          v-model="formData.description"
+          type="textarea"
+          :rows="3"
+          placeholder="请输入任务描述"
+        />
+      </el-form-item>
       <el-form-item label="升级范围" prop="deviceScope">
         <el-select v-model="formData.deviceScope" placeholder="请选择升级范围" class="w-full">
           <el-option
@@ -14,18 +28,33 @@
           />
         </el-select>
       </el-form-item>
-      <el-form-item label="任务描述" prop="description">
-        <el-input
-          v-model="formData.description"
-          type="textarea"
-          :rows="3"
-          placeholder="请输入任务描述"
-        />
+      <el-form-item
+        label="选择设备"
+        prop="deviceIds"
+        v-if="formData.deviceScope === IoTOtaTaskDeviceScopeEnum.SELECT.value"
+      >
+        <el-select
+          v-model="formData.deviceIds"
+          multiple
+          placeholder="请选择设备"
+          class="w-full"
+          filterable
+          reserve-keyword
+        >
+          <el-option
+            v-for="device in devices"
+            :key="device.id"
+            :label="
+              device.nickname ? `${device.deviceName} (${device.nickname})` : device.deviceName
+            "
+            :value="device.id"
+          />
+        </el-select>
       </el-form-item>
     </el-form>
     <template #footer>
-      <el-button @click="handleCancel">取消</el-button>
-      <el-button type="primary" @click="handleSubmit" :loading="submitting"> 确定 </el-button>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
     </template>
   </el-dialog>
 </template>
@@ -33,79 +62,71 @@
 <script setup lang="ts">
 import { IoTOtaTaskApi, OtaTask } from '@/api/iot/ota/task'
 import { IoTOtaTaskDeviceScopeEnum } from '@/views/iot/utils/constants'
+import { DeviceApi, DeviceVO } from '@/api/iot/device/device'
 
+/** IoT OTA 升级任务表单 */
 defineOptions({ name: 'OtaTaskForm' })
 
 const props = defineProps<{
   firmwareId: number
+  productId: number
 }>()
 
-const emit = defineEmits<{
-  success: []
-}>()
-
-const message = useMessage()
+const message = useMessage() // 消息弹窗
 
-// 弹窗状态
-const dialogVisible = ref(false)
-const submitting = ref(false)
-
-// 表单数据
-const formRef = ref()
+const dialogVisible = ref(false) // 弹窗的是否展示
+const formLoading = ref(false) // 表单的加载中:修改时的数据加载
 const formData = ref<OtaTask>({
   name: '',
-  deviceScope: undefined,
+  deviceScope: IoTOtaTaskDeviceScopeEnum.ALL.value,
   firmwareId: props.firmwareId,
-  description: ''
+  description: '',
+  deviceIds: []
 })
-
-// 表单验证规则
+const formRef = ref() // 表单 Ref
 const formRules = {
   name: [{ required: true, message: '请输入任务名称', trigger: 'blur' }],
-  deviceScope: [{ required: true, message: '请选择升级范围', trigger: 'change' }]
+  deviceScope: [{ required: true, message: '请选择升级范围', trigger: 'change' }],
+  deviceIds: [{ required: true, message: '请至少选择一个设备', trigger: 'change' }]
 }
+const devices = ref<DeviceVO[]>([]) // 设备选择相关
 
 /** 打开弹窗 */
-const open = () => {
+const open = async () => {
   dialogVisible.value = true
   resetForm()
+  // 加载设备列表
+  devices.value = (await DeviceApi.getDeviceListByProductId(props.productId)) || []
 }
-
-/** 重置表单 */
-const resetForm = () => {
-  formData.value = {
-    name: '',
-    deviceScope: undefined,
-    firmwareId: props.firmwareId,
-    description: ''
-  }
-  nextTick(() => {
-    formRef.value?.clearValidate()
-  })
-}
-
-/** 取消 */
-const handleCancel = () => {
-  dialogVisible.value = false
-}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
 
 /** 提交表单 */
-const handleSubmit = async () => {
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  await formRef.value.validate()
+  // 提交请求
+  formLoading.value = true
   try {
-    await formRef.value.validate()
-    submitting.value = true
     await IoTOtaTaskApi.createOtaTask(formData.value)
     message.success('创建成功')
     dialogVisible.value = false
+    // 发送操作成功的事件
     emit('success')
-  } catch (error) {
-    console.error('创建任务失败', error)
   } finally {
-    submitting.value = false
+    formLoading.value = false
   }
 }
 
-defineExpose({
-  open
-})
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    name: '',
+    deviceScope: IoTOtaTaskDeviceScopeEnum.ALL.value,
+    firmwareId: props.firmwareId,
+    description: '',
+    deviceIds: []
+  }
+  formRef.value?.resetFields()
+}
 </script>

+ 36 - 7
src/views/iot/ota/task/OtaTaskList.vue

@@ -1,5 +1,5 @@
 <template>
-  <ContentWrap title="固件任务管理" class="mb-20px">
+  <ContentWrap title="升级任务管理" class="mb-20px">
     <!-- 搜索栏 -->
     <el-form
       class="-mb-15px"
@@ -14,6 +14,7 @@
         </el-button>
       </el-form-item>
       <el-form-item style="float: right">
+        <!--TODO @AI:有个 bug:回车后,会刷新,修复下 -->
         <el-input
           v-model="queryParams.name"
           placeholder="请输入任务名称"
@@ -56,8 +57,10 @@
           <dict-tag :type="DICT_TYPE.IOT_OTA_TASK_STATUS" :value="scope.row.status" />
         </template>
       </el-table-column>
-      <el-table-column label="操作" align="center" width="80">
+      <el-table-column label="操作" align="center" width="120">
+        <!-- TODO @AI:可能要参考别的模块,处理下?-->
         <template #default="scope">
+          <el-button link type="primary" @click="handleViewDetail(scope.row.id)"> 详情 </el-button>
           <el-button
             v-if="scope.row.status === IoTOtaTaskStatusEnum.IN_PROGRESS.value"
             link
@@ -80,7 +83,15 @@
     />
 
     <!-- 新增任务弹窗 -->
-    <OtaTaskForm ref="taskFormRef" :firmware-id="firmwareId" @success="getTaskList" />
+    <OtaTaskForm
+      ref="taskFormRef"
+      :firmware-id="firmwareId"
+      :product-id="productId"
+      @success="handleTaskSuccess"
+    />
+
+    <!-- 任务详情弹窗 -->
+    <OtaTaskDetail ref="taskDetailRef" />
   </ContentWrap>
 </template>
 
@@ -90,12 +101,18 @@ import { IoTOtaTaskApi, OtaTask } from '@/api/iot/ota/task'
 import { DICT_TYPE } from '@/utils/dict'
 import { IoTOtaTaskStatusEnum } from '@/views/iot/utils/constants'
 import OtaTaskForm from './OtaTaskForm.vue'
+import OtaTaskDetail from './OtaTaskDetail.vue'
 
 /** IoT OTA 任务列表 */
 defineOptions({ name: 'OtaTaskList' })
 
 const props = defineProps<{
   firmwareId: number
+  productId?: number
+}>()
+
+const emit = defineEmits<{
+  success: []
 }>()
 
 const message = useMessage() // 消息弹窗
@@ -110,10 +127,9 @@ const queryParams = reactive({
   name: '',
   firmwareId: props.firmwareId
 })
-const queryFormRef = ref()
-
-// 任务表单引用
-const taskFormRef = ref()
+const queryFormRef = ref() // 查询表单引用
+const taskFormRef = ref() // 任务表单引用
+const taskDetailRef = ref() // 任务详情引用
 
 /** 获取任务列表 */
 const getTaskList = async () => {
@@ -138,6 +154,19 @@ const openTaskForm = () => {
   taskFormRef.value?.open()
 }
 
+/** 处理任务创建成功 */
+const handleTaskSuccess = () => {
+  getTaskList()
+  emit('success')
+}
+
+/** 查看任务详情 */
+const handleViewDetail = (id: number | undefined) => {
+  if (id) {
+    taskDetailRef.value?.open(id)
+  }
+}
+
 /** 取消任务 */
 const handleCancelTask = async (id: number) => {
   try {

+ 1 - 1
src/views/iot/utils/constants.ts

@@ -154,7 +154,7 @@ export const IoTOtaTaskDeviceScopeEnum = {
     label: '全部设备',
     value: 1
   },
-  SPECIFIC: {
+  SELECT: {
     label: '指定设备',
     value: 2
   }