Vue3 + Element Plus版本iot前端迁移到vben版本

This commit is contained in:
Administrator
2025-10-07 19:58:59 +08:00
parent e4707ef380
commit 877a03df4a
124 changed files with 20425 additions and 168 deletions

View File

@@ -0,0 +1,170 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { getSimpleProductList } from '#/api/iot/product/product';
import { getRangePickerDefaultProps } from '#/utils';
/** 新增/修改固件的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '固件名称',
component: 'Input',
componentProps: {
placeholder: '请输入固件名称',
},
rules: 'required',
},
{
fieldName: 'productId',
label: '所属产品',
component: 'ApiSelect',
componentProps: {
api: getSimpleProductList,
labelField: 'name',
valueField: 'id',
placeholder: '请选择产品',
},
rules: 'required',
},
{
fieldName: 'version',
label: '版本号',
component: 'Input',
componentProps: {
placeholder: '请输入版本号',
},
rules: 'required',
},
{
fieldName: 'description',
label: '固件描述',
component: 'Textarea',
componentProps: {
placeholder: '请输入固件描述',
rows: 3,
},
},
{
fieldName: 'fileUrl',
label: '固件文件',
component: 'Upload',
componentProps: {
maxCount: 1,
accept: '.bin,.hex,.zip',
},
rules: 'required',
help: '支持上传 .bin、.hex、.zip 格式的固件文件',
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '固件名称',
component: 'Input',
componentProps: {
placeholder: '请输入固件名称',
allowClear: true,
},
},
{
fieldName: 'productId',
label: '产品',
component: 'ApiSelect',
componentProps: {
api: getSimpleProductList,
labelField: 'name',
valueField: 'id',
placeholder: '请选择产品',
allowClear: true,
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: getRangePickerDefaultProps(),
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
type: 'checkbox',
width: 50,
fixed: 'left',
},
{
field: 'id',
title: '固件编号',
width: 100,
},
{
field: 'name',
title: '固件名称',
minWidth: 150,
},
{
field: 'version',
title: '版本号',
width: 120,
},
{
field: 'productName',
title: '所属产品',
minWidth: 150,
},
{
field: 'description',
title: '固件描述',
minWidth: 200,
showOverflow: 'tooltip',
},
{
field: 'fileSize',
title: '文件大小',
width: 120,
formatter: ({ cellValue }) => {
if (!cellValue) return '-';
const kb = cellValue / 1024;
if (kb < 1024) return `${kb.toFixed(2)} KB`;
return `${(kb / 1024).toFixed(2)} MB`;
},
},
{
field: 'status',
title: '状态',
width: 100,
formatter: ({ cellValue }) => {
return cellValue === 1 ? '启用' : '禁用';
},
},
{
field: 'createTime',
title: '创建时间',
width: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 160,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@@ -0,0 +1,207 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { IoTOtaFirmwareApi } from '#/api/iot/ota/firmware';
import { message } from 'ant-design-vue';
import { deleteOtaFirmware, getOtaFirmwarePage } from '#/api/iot/ota/firmware';
import { getSimpleProductList } from '#/api/iot/product/product';
import { $t } from '#/locales';
import { getRangePickerDefaultProps } from '#/utils';
/** 新增/修改固件的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '固件名称',
component: 'Input',
componentProps: {
placeholder: '请输入固件名称',
},
rules: 'required',
},
{
fieldName: 'productId',
label: '所属产品',
component: 'ApiSelect',
componentProps: {
api: getSimpleProductList,
labelField: 'name',
valueField: 'id',
placeholder: '请选择产品',
},
rules: 'required',
},
{
fieldName: 'version',
label: '版本号',
component: 'Input',
componentProps: {
placeholder: '请输入版本号',
},
rules: 'required',
},
{
fieldName: 'description',
label: '固件描述',
component: 'Textarea',
componentProps: {
placeholder: '请输入固件描述',
rows: 3,
},
},
{
fieldName: 'fileUrl',
label: '固件文件',
component: 'FileUpload',
componentProps: {
maxNumber: 1,
accept: ['bin', 'hex', 'zip'],
maxSize: 50,
helpText: '支持上传 .bin、.hex、.zip 格式的固件文件,最大 50MB',
},
rules: 'required',
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '固件名称',
component: 'Input',
componentProps: {
placeholder: '请输入固件名称',
allowClear: true,
},
},
{
fieldName: 'productId',
label: '产品',
component: 'ApiSelect',
componentProps: {
api: getSimpleProductList,
labelField: 'name',
valueField: 'id',
placeholder: '请选择产品',
allowClear: true,
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: getRangePickerDefaultProps(),
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{ type: 'checkbox', width: 40 },
{
field: 'id',
title: '固件编号',
minWidth: 80,
},
{
field: 'name',
title: '固件名称',
minWidth: 150,
},
{
field: 'version',
title: '版本号',
minWidth: 120,
},
{
field: 'description',
title: '固件描述',
minWidth: 200,
},
{
field: 'productId',
title: '所属产品',
minWidth: 150,
slots: { default: 'product' },
},
{
field: 'fileUrl',
title: '固件文件',
minWidth: 120,
slots: { default: 'fileUrl' },
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 200,
fixed: 'right',
slots: { default: 'actions' },
},
];
}
/** Grid 配置项 */
export function useGridOptions(): VxeTableGridOptions<IoTOtaFirmwareApi.Firmware> {
return {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getOtaFirmwarePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
};
}
/** 删除固件 */
export async function handleDeleteFirmware(
row: IoTOtaFirmwareApi.Firmware,
onSuccess: () => void,
) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
});
try {
await deleteOtaFirmware(row.id as number);
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
});
onSuccess();
} finally {
hideLoading();
}
}

View File

@@ -0,0 +1,124 @@
<script lang="ts" setup>
import type { IoTOtaFirmwareApi } from '#/api/iot/ota/firmware';
import { Page, useVbenModal } from '@vben/common-ui';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { $t } from '#/locales';
import {
handleDeleteFirmware,
useGridFormSchema,
useGridOptions,
} from './data';
import Form from '../modules/OtaFirmwareForm.vue';
defineOptions({ name: 'IoTOtaFirmware' });
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 创建固件 */
function handleCreate() {
formModalApi.setData({ type: 'create' }).open();
}
/** 编辑固件 */
function handleEdit(row: IoTOtaFirmwareApi.Firmware) {
formModalApi.setData({ type: 'update', id: row.id }).open();
}
/** 删除固件 */
async function handleDelete(row: IoTOtaFirmwareApi.Firmware) {
await handleDeleteFirmware(row, onRefresh);
}
/** 查看固件详情 */
function handleDetail(row: IoTOtaFirmwareApi.Firmware) {
formModalApi.setData({ type: 'view', id: row.id }).open();
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: useGridOptions(),
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="onRefresh" />
<Grid table-title="固件列表">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['固件']),
type: 'primary',
icon: ACTION_ICON.ADD,
onClick: handleCreate,
},
]"
/>
</template>
<!-- 产品名称列 -->
<template #product="{ row }">
<span class="text-gray-700">{{ row.productName || '未知产品' }}</span>
</template>
<!-- 固件文件列 -->
<template #fileUrl="{ row }">
<a
v-if="row.fileUrl"
:href="row.fileUrl"
target="_blank"
download
class="text-primary cursor-pointer hover:underline"
>
<Icon icon="ant-design:download-outlined" class="mr-1" />
下载固件
</a>
<span v-else class="text-gray-400">无文件</span>
</template>
<!-- 操作列 -->
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.detail'),
type: 'link',
icon: ACTION_ICON.VIEW,
onClick: handleDetail.bind(null, row),
},
{
label: $t('common.edit'),
type: 'link',
icon: ACTION_ICON.EDIT,
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,129 @@
<script setup lang="ts">
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { IoTOtaFirmwareApi } from '#/api/iot/ota/firmware';
import { Page, useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteOtaFirmware, getOtaFirmwarePage } from '#/api/iot/ota/firmware';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import OtaFirmwareForm from './modules/OtaFirmwareForm.vue';
defineOptions({ name: 'IoTOtaFirmware' });
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: OtaFirmwareForm,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 创建固件 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑固件 */
function handleEdit(row: IoTOtaFirmwareApi.Firmware) {
formModalApi.setData(row).open();
}
/** 删除固件 */
async function handleDelete(row: IoTOtaFirmwareApi.Firmware) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
});
try {
await deleteOtaFirmware(row.id as number);
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
});
onRefresh();
} finally {
hideLoading();
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getOtaFirmwarePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<IoTOtaFirmwareApi.Firmware>,
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="onRefresh" />
<Grid table-title="OTA 固件列表">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['固件']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['iot:ota-firmware:create'],
onClick: handleCreate,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'link',
icon: ACTION_ICON.EDIT,
auth: ['iot:ota-firmware:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
auth: ['iot:ota-firmware:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,90 @@
<script setup lang="ts">
import type { IoTOtaFirmwareApi } from '#/api/iot/ota/firmware';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
createOtaFirmware,
getOtaFirmware,
updateOtaFirmware,
} from '#/api/iot/ota/firmware';
import { $t } from '#/locales';
import { useFormSchema } from '../firmware/data';
defineOptions({ name: 'IoTOtaFirmwareForm' });
const emit = defineEmits<{
success: [];
}>();
const formData = ref<IoTOtaFirmwareApi.Firmware>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['固件'])
: $t('ui.actionTitle.create', ['固件']);
});
const [Form, formApi] = useVbenForm({
schema: useFormSchema(),
commonConfig: {
componentProps: {
class: 'w-full',
},
},
layout: 'horizontal',
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
// 提交表单
const data = (await formApi.getValues()) as IoTOtaFirmwareApi.Firmware;
try {
await (formData.value?.id ? updateOtaFirmware(data) : createOtaFirmware(data));
// 关闭并提示
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
// 加载数据
const data = modalApi.getData<IoTOtaFirmwareApi.Firmware>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getOtaFirmware(data.id);
// 设置到 values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal :title="getTitle" class="w-1/3">
<Form class="mx-4" />
</Modal>
</template>

View File

@@ -0,0 +1,149 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { Card, Col, Descriptions, Row } from 'ant-design-vue';
import { formatDate } from '@vben/utils';
import type { IoTOtaFirmware } from '#/api/iot/ota/firmware';
import * as IoTOtaFirmwareApi from '#/api/iot/ota/firmware';
import * as IoTOtaTaskRecordApi from '#/api/iot/ota/task/record';
import { IoTOtaTaskRecordStatusEnum } from '#/views/iot/utils/constants';
import OtaTaskList from '../task/OtaTaskList.vue';
/** IoT OTA 固件详情 */
defineOptions({ name: 'IoTOtaFirmwareDetail' });
const route = useRoute();
const firmwareId = ref(Number(route.params.id));
const firmwareLoading = ref(false);
const firmware = ref<IoTOtaFirmware>({} as IoTOtaFirmware);
const firmwareStatisticsLoading = ref(false);
const firmwareStatistics = ref<Record<string, number>>({});
/** 获取固件信息 */
const getFirmwareInfo = async () => {
firmwareLoading.value = true;
try {
firmware.value = await IoTOtaFirmwareApi.getOtaFirmware(firmwareId.value);
} finally {
firmwareLoading.value = false;
}
};
/** 获取升级统计 */
const getStatistics = async () => {
firmwareStatisticsLoading.value = true;
try {
firmwareStatistics.value = await IoTOtaTaskRecordApi.getOtaTaskRecordStatusStatistics(
firmwareId.value,
);
} finally {
firmwareStatisticsLoading.value = false;
}
};
/** 初始化 */
onMounted(() => {
getFirmwareInfo();
getStatistics();
});
</script>
<template>
<div class="p-4">
<!-- 固件信息 -->
<Card title="固件信息" class="mb-5" :loading="firmwareLoading">
<Descriptions :column="3" bordered>
<Descriptions.Item label="固件名称">
{{ firmware?.name }}
</Descriptions.Item>
<Descriptions.Item label="所属产品">
{{ firmware?.productName }}
</Descriptions.Item>
<Descriptions.Item label="固件版本">
{{ firmware?.version }}
</Descriptions.Item>
<Descriptions.Item label="创建时间">
{{ firmware?.createTime ? formatDate(firmware.createTime, 'YYYY-MM-DD HH:mm:ss') : '-' }}
</Descriptions.Item>
<Descriptions.Item label="固件描述" :span="2">
{{ firmware?.description }}
</Descriptions.Item>
</Descriptions>
</Card>
<!-- 升级设备统计 -->
<Card title="升级设备统计" class="mb-5" :loading="firmwareStatisticsLoading">
<Row :gutter="20" class="py-5">
<Col :span="6">
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
<div class="text-3xl font-bold mb-2 text-blue-500">
{{
Object.values(firmwareStatistics).reduce((sum: number, count) => sum + (count || 0), 0) ||
0
}}
</div>
<div class="text-sm text-gray-600">升级设备总数</div>
</div>
</Col>
<Col :span="3">
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
<div class="text-3xl font-bold mb-2 text-gray-400">
{{ firmwareStatistics[IoTOtaTaskRecordStatusEnum.PENDING.value] || 0 }}
</div>
<div class="text-sm text-gray-600">待推送</div>
</div>
</Col>
<Col :span="3">
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
<div class="text-3xl font-bold mb-2 text-blue-400">
{{ firmwareStatistics[IoTOtaTaskRecordStatusEnum.PUSHED.value] || 0 }}
</div>
<div class="text-sm text-gray-600">已推送</div>
</div>
</Col>
<Col :span="3">
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
<div class="text-3xl font-bold mb-2 text-yellow-500">
{{ firmwareStatistics[IoTOtaTaskRecordStatusEnum.UPGRADING.value] || 0 }}
</div>
<div class="text-sm text-gray-600">正在升级</div>
</div>
</Col>
<Col :span="3">
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
<div class="text-3xl font-bold mb-2 text-green-500">
{{ firmwareStatistics[IoTOtaTaskRecordStatusEnum.SUCCESS.value] || 0 }}
</div>
<div class="text-sm text-gray-600">升级成功</div>
</div>
</Col>
<Col :span="3">
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
<div class="text-3xl font-bold mb-2 text-red-500">
{{ firmwareStatistics[IoTOtaTaskRecordStatusEnum.FAILURE.value] || 0 }}
</div>
<div class="text-sm text-gray-600">升级失败</div>
</div>
</Col>
<Col :span="3">
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
<div class="text-3xl font-bold mb-2 text-gray-400">
{{ firmwareStatistics[IoTOtaTaskRecordStatusEnum.CANCELED.value] || 0 }}
</div>
<div class="text-sm text-gray-600">升级取消</div>
</div>
</Col>
</Row>
</Card>
<!-- 任务管理 -->
<OtaTaskList
v-if="firmware?.productId"
:firmware-id="firmwareId"
:product-id="firmware.productId"
@success="getStatistics"
/>
</div>
</template>

View File

@@ -0,0 +1,149 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { Card, Col, Descriptions, Row } from 'ant-design-vue';
import { formatDate } from '@vben/utils';
import type { IoTOtaFirmware } from '#/api/iot/ota/firmware';
import * as IoTOtaFirmwareApi from '#/api/iot/ota/firmware';
import * as IoTOtaTaskRecordApi from '#/api/iot/ota/task/record';
import { IoTOtaTaskRecordStatusEnum } from '#/views/iot/utils/constants';
import OtaTaskList from '../task/OtaTaskList.vue';
/** IoT OTA 固件详情 */
defineOptions({ name: 'IoTOtaFirmwareDetail' });
const route = useRoute();
const firmwareId = ref(Number(route.params.id));
const firmwareLoading = ref(false);
const firmware = ref<IoTOtaFirmware>({} as IoTOtaFirmware);
const firmwareStatisticsLoading = ref(false);
const firmwareStatistics = ref<Record<string, number>>({});
/** 获取固件信息 */
const getFirmwareInfo = async () => {
firmwareLoading.value = true;
try {
firmware.value = await IoTOtaFirmwareApi.getOtaFirmware(firmwareId.value);
} finally {
firmwareLoading.value = false;
}
};
/** 获取升级统计 */
const getStatistics = async () => {
firmwareStatisticsLoading.value = true;
try {
firmwareStatistics.value = await IoTOtaTaskRecordApi.getOtaTaskRecordStatusStatistics(
firmwareId.value,
);
} finally {
firmwareStatisticsLoading.value = false;
}
};
/** 初始化 */
onMounted(() => {
getFirmwareInfo();
getStatistics();
});
</script>
<template>
<div class="p-4">
<!-- 固件信息 -->
<Card title="固件信息" class="mb-5" :loading="firmwareLoading">
<Descriptions :column="3" bordered>
<Descriptions.Item label="固件名称">
{{ firmware?.name }}
</Descriptions.Item>
<Descriptions.Item label="所属产品">
{{ firmware?.productName }}
</Descriptions.Item>
<Descriptions.Item label="固件版本">
{{ firmware?.version }}
</Descriptions.Item>
<Descriptions.Item label="创建时间">
{{ firmware?.createTime ? formatDate(firmware.createTime, 'YYYY-MM-DD HH:mm:ss') : '-' }}
</Descriptions.Item>
<Descriptions.Item label="固件描述" :span="2">
{{ firmware?.description }}
</Descriptions.Item>
</Descriptions>
</Card>
<!-- 升级设备统计 -->
<Card title="升级设备统计" class="mb-5" :loading="firmwareStatisticsLoading">
<Row :gutter="20" class="py-5">
<Col :span="6">
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
<div class="text-3xl font-bold mb-2 text-blue-500">
{{
Object.values(firmwareStatistics).reduce((sum: number, count) => sum + (count || 0), 0) ||
0
}}
</div>
<div class="text-sm text-gray-600">升级设备总数</div>
</div>
</Col>
<Col :span="3">
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
<div class="text-3xl font-bold mb-2 text-gray-400">
{{ firmwareStatistics[IoTOtaTaskRecordStatusEnum.PENDING.value] || 0 }}
</div>
<div class="text-sm text-gray-600">待推送</div>
</div>
</Col>
<Col :span="3">
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
<div class="text-3xl font-bold mb-2 text-blue-400">
{{ firmwareStatistics[IoTOtaTaskRecordStatusEnum.PUSHED.value] || 0 }}
</div>
<div class="text-sm text-gray-600">已推送</div>
</div>
</Col>
<Col :span="3">
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
<div class="text-3xl font-bold mb-2 text-yellow-500">
{{ firmwareStatistics[IoTOtaTaskRecordStatusEnum.UPGRADING.value] || 0 }}
</div>
<div class="text-sm text-gray-600">正在升级</div>
</div>
</Col>
<Col :span="3">
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
<div class="text-3xl font-bold mb-2 text-green-500">
{{ firmwareStatistics[IoTOtaTaskRecordStatusEnum.SUCCESS.value] || 0 }}
</div>
<div class="text-sm text-gray-600">升级成功</div>
</div>
</Col>
<Col :span="3">
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
<div class="text-3xl font-bold mb-2 text-red-500">
{{ firmwareStatistics[IoTOtaTaskRecordStatusEnum.FAILURE.value] || 0 }}
</div>
<div class="text-sm text-gray-600">升级失败</div>
</div>
</Col>
<Col :span="3">
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
<div class="text-3xl font-bold mb-2 text-gray-400">
{{ firmwareStatistics[IoTOtaTaskRecordStatusEnum.CANCELED.value] || 0 }}
</div>
<div class="text-sm text-gray-600">升级取消</div>
</div>
</Col>
</Row>
</Card>
<!-- 任务管理 -->
<OtaTaskList
v-if="firmware?.productId"
:firmware-id="firmwareId"
:product-id="firmware.productId"
@success="getStatistics"
/>
</div>
</template>

View File

@@ -0,0 +1,349 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Card, Col, Descriptions, Modal, Row, Table, Tabs, Tag, message } from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
import type { OtaTask } from '#/api/iot/ota/task';
import * as IoTOtaTaskApi from '#/api/iot/ota/task';
import type { OtaTaskRecord } from '#/api/iot/ota/task/record';
import * as IoTOtaTaskRecordApi from '#/api/iot/ota/task/record';
import { IoTOtaTaskRecordStatusEnum } from '#/views/iot/utils/constants';
import { formatDate } from '@vben/utils';
/** OTA 任务详情组件 */
defineOptions({ name: 'OtaTaskDetail' });
const emit = defineEmits(['success']);
const taskId = ref<number>();
const taskLoading = ref(false);
const task = ref<OtaTask>({} as OtaTask);
const taskStatisticsLoading = ref(false);
const taskStatistics = ref<Record<string, number>>({});
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,
});
const activeTab = ref('');
/** 状态标签配置 */
const statusTabs = computed(() => {
const tabs = [{ key: '', label: '全部设备' }];
Object.values(IoTOtaTaskRecordStatusEnum).forEach((status) => {
tabs.push({
key: status.value.toString(),
label: status.label,
});
});
return tabs;
});
/** 表格列配置 */
const columns: TableColumnsType = [
{
title: '设备名称',
dataIndex: 'deviceName',
key: 'deviceName',
align: 'center' as const,
},
{
title: '当前版本',
dataIndex: 'fromFirmwareVersion',
key: 'fromFirmwareVersion',
align: 'center' as const,
},
{
title: '升级状态',
dataIndex: 'status',
key: 'status',
align: 'center' as const,
width: 120,
},
{
title: '升级进度',
dataIndex: 'progress',
key: 'progress',
align: 'center' as const,
width: 120,
},
{
title: '状态描述',
dataIndex: 'description',
key: 'description',
align: 'center' as const,
},
{
title: '更新时间',
dataIndex: 'updateTime',
key: 'updateTime',
align: 'center' as const,
width: 180,
customRender: ({ text }: any) => formatDate(text, 'YYYY-MM-DD HH:mm:ss'),
},
{
title: '操作',
key: 'action',
align: 'center' as const,
width: 80,
},
];
const [ModalComponent, modalApi] = useVbenModal();
/** 获取任务详情 */
const getTaskInfo = async () => {
if (!taskId.value) {
return;
}
taskLoading.value = true;
try {
task.value = await IoTOtaTaskApi.getOtaTask(taskId.value);
} finally {
taskLoading.value = false;
}
};
/** 获取统计数据 */
const getStatistics = async () => {
if (!taskId.value) {
return;
}
taskStatisticsLoading.value = true;
try {
taskStatistics.value = await IoTOtaTaskRecordApi.getOtaTaskRecordStatusStatistics(
undefined,
taskId.value,
);
} finally {
taskStatisticsLoading.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;
} finally {
recordLoading.value = false;
}
};
/** 切换标签 */
const handleTabChange = (tabKey: string | number) => {
activeTab.value = String(tabKey);
queryParams.pageNo = 1;
queryParams.status = activeTab.value === '' ? undefined : parseInt(String(tabKey));
getRecordList();
};
/** 分页变化 */
const handleTableChange = (pagination: any) => {
queryParams.pageNo = pagination.current;
queryParams.pageSize = pagination.pageSize;
getRecordList();
};
/** 取消升级 */
const handleCancelUpgrade = async (record: OtaTaskRecord) => {
Modal.confirm({
title: '确认取消',
content: '确认要取消该设备的升级任务吗?',
async onOk() {
try {
await IoTOtaTaskRecordApi.cancelOtaTaskRecord(record.id!);
message.success('取消成功');
await getRecordList();
await getStatistics();
await getTaskInfo();
emit('success');
} catch (error) {
console.error('取消升级失败', error);
}
},
});
};
/** 打开弹窗 */
const open = (id: number) => {
modalApi.open();
taskId.value = id;
activeTab.value = '';
queryParams.pageNo = 1;
queryParams.status = undefined;
// 加载数据
getTaskInfo();
getStatistics();
getRecordList();
};
/** 暴露方法 */
defineExpose({ open });
</script>
<template>
<ModalComponent title="升级任务详情" class="w-5/6">
<div class="p-4">
<!-- 任务信息 -->
<Card title="任务信息" class="mb-5" :loading="taskLoading">
<Descriptions :column="3" bordered>
<Descriptions.Item label="任务编号">{{ task.id }}</Descriptions.Item>
<Descriptions.Item label="任务名称">{{ task.name }}</Descriptions.Item>
<Descriptions.Item label="升级范围">
<Tag v-if="task.deviceScope === 1" color="blue">全部设备</Tag>
<Tag v-else-if="task.deviceScope === 2" color="green">指定设备</Tag>
<Tag v-else>{{ task.deviceScope }}</Tag>
</Descriptions.Item>
<Descriptions.Item label="任务状态">
<Tag v-if="task.status === 0" color="orange">待执行</Tag>
<Tag v-else-if="task.status === 1" color="blue">执行中</Tag>
<Tag v-else-if="task.status === 2" color="green">已完成</Tag>
<Tag v-else-if="task.status === 3" color="red">已取消</Tag>
<Tag v-else>{{ task.status }}</Tag>
</Descriptions.Item>
<Descriptions.Item label="创建时间">
{{ task.createTime ? formatDate(task.createTime, 'YYYY-MM-DD HH:mm:ss') : '-' }}
</Descriptions.Item>
<Descriptions.Item label="任务描述" :span="3">
{{ task.description }}
</Descriptions.Item>
</Descriptions>
</Card>
<!-- 任务升级设备统计 -->
<Card title="升级设备统计" class="mb-5" :loading="taskStatisticsLoading">
<Row :gutter="20" class="py-5">
<Col :span="6">
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
<div class="text-3xl font-bold mb-2 text-blue-500">
{{
Object.values(taskStatistics).reduce((sum, count) => sum + (count || 0), 0) || 0
}}
</div>
<div class="text-sm text-gray-600">升级设备总数</div>
</div>
</Col>
<Col :span="3">
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
<div class="text-3xl font-bold mb-2 text-gray-400">
{{ taskStatistics[IoTOtaTaskRecordStatusEnum.PENDING.value] || 0 }}
</div>
<div class="text-sm text-gray-600">待推送</div>
</div>
</Col>
<Col :span="3">
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
<div class="text-3xl font-bold mb-2 text-blue-400">
{{ taskStatistics[IoTOtaTaskRecordStatusEnum.PUSHED.value] || 0 }}
</div>
<div class="text-sm text-gray-600">已推送</div>
</div>
</Col>
<Col :span="3">
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
<div class="text-3xl font-bold mb-2 text-yellow-500">
{{ taskStatistics[IoTOtaTaskRecordStatusEnum.UPGRADING.value] || 0 }}
</div>
<div class="text-sm text-gray-600">正在升级</div>
</div>
</Col>
<Col :span="3">
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
<div class="text-3xl font-bold mb-2 text-green-500">
{{ taskStatistics[IoTOtaTaskRecordStatusEnum.SUCCESS.value] || 0 }}
</div>
<div class="text-sm text-gray-600">升级成功</div>
</div>
</Col>
<Col :span="3">
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
<div class="text-3xl font-bold mb-2 text-red-500">
{{ taskStatistics[IoTOtaTaskRecordStatusEnum.FAILURE.value] || 0 }}
</div>
<div class="text-sm text-gray-600">升级失败</div>
</div>
</Col>
<Col :span="3">
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
<div class="text-3xl font-bold mb-2 text-gray-400">
{{ taskStatistics[IoTOtaTaskRecordStatusEnum.CANCELED.value] || 0 }}
</div>
<div class="text-sm text-gray-600">升级取消</div>
</div>
</Col>
</Row>
</Card>
<!-- 设备管理 -->
<Card title="升级设备记录">
<Tabs v-model:activeKey="activeTab" @change="handleTabChange" class="mb-4">
<Tabs.TabPane v-for="tab in statusTabs" :key="tab.key" :tab="tab.label" />
</Tabs>
<Table
:columns="columns"
:data-source="recordList"
:loading="recordLoading"
:pagination="{
current: queryParams.pageNo,
pageSize: queryParams.pageSize,
total: recordTotal,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total: number) => `共 ${total} 条`,
}"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<!-- 升级状态 -->
<template v-if="column.key === 'status'">
<Tag v-if="record.status === 0" color="default">待推送</Tag>
<Tag v-else-if="record.status === 1" color="blue">已推送</Tag>
<Tag v-else-if="record.status === 2" color="processing">升级中</Tag>
<Tag v-else-if="record.status === 3" color="success">成功</Tag>
<Tag v-else-if="record.status === 4" color="error">失败</Tag>
<Tag v-else-if="record.status === 5" color="warning">已取消</Tag>
<Tag v-else>{{ record.status }}</Tag>
</template>
<!-- 升级进度 -->
<template v-else-if="column.key === 'progress'">
{{ record.progress }}%
</template>
<!-- 操作 -->
<template v-else-if="column.key === 'action'">
<a
v-if="
[
IoTOtaTaskRecordStatusEnum.PENDING.value,
IoTOtaTaskRecordStatusEnum.PUSHED.value,
IoTOtaTaskRecordStatusEnum.UPGRADING.value,
].includes(record.status)
"
class="text-red-500"
@click="handleCancelUpgrade(record)"
>
取消
</a>
</template>
</template>
</Table>
</Card>
</div>
</ModalComponent>
</template>

View File

@@ -0,0 +1,148 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message, Form, Input, Select, Spin } from 'ant-design-vue';
import type { OtaTask } from '#/api/iot/ota/task';
import * as IoTOtaTaskApi from '#/api/iot/ota/task';
import { IoTOtaTaskDeviceScopeEnum } from '#/views/iot/utils/constants';
import type { DeviceVO } from '#/api/iot/device/device';
import * as DeviceApi from '#/api/iot/device/device';
/** IoT OTA 升级任务表单 */
defineOptions({ name: 'OtaTaskForm' });
const props = defineProps<{
firmwareId: number;
productId: number;
}>();
const emit = defineEmits(['success']);
const formLoading = ref(false);
const formData = ref<OtaTask>({
name: '',
deviceScope: IoTOtaTaskDeviceScopeEnum.ALL.value,
firmwareId: props.firmwareId,
description: '',
deviceIds: [],
});
const formRef = ref();
const formRules = {
name: [{ required: true, message: '请输入任务名称', trigger: 'blur' as const, type: 'string' as const }],
deviceScope: [{ required: true, message: '请选择升级范围', trigger: 'change' as const, type: 'number' as const }],
deviceIds: [{ required: true, message: '请至少选择一个设备', trigger: 'change' as const, type: 'array' as const }],
};
const devices = ref<DeviceVO[]>([]);
/** 设备选项 */
const deviceOptions = computed(() => {
return devices.value.map((device) => ({
label: device.nickname
? `${device.deviceName} (${device.nickname})`
: device.deviceName,
value: device.id,
}));
});
/** 升级范围选项 */
const deviceScopeOptions = computed(() => {
return Object.values(IoTOtaTaskDeviceScopeEnum).map((item) => ({
label: item.label,
value: item.value,
}));
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
try {
await formRef.value.validate();
modalApi.lock();
await IoTOtaTaskApi.createOtaTask(formData.value);
message.success('创建成功');
await modalApi.close();
emit('success');
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
resetForm();
return;
}
// 加载设备列表
formLoading.value = true;
try {
devices.value = (await DeviceApi.getDeviceListByProductId(props.productId)) || [];
} finally {
formLoading.value = false;
}
},
});
/** 重置表单 */
const resetForm = () => {
formData.value = {
name: '',
deviceScope: IoTOtaTaskDeviceScopeEnum.ALL.value,
firmwareId: props.firmwareId,
description: '',
deviceIds: [],
};
formRef.value?.resetFields();
};
/** 打开弹窗 */
const open = async () => {
await modalApi.open();
};
defineExpose({ open });
</script>
<template>
<Modal title="新增升级任务" class="w-3/5">
<Spin :spinning="formLoading">
<Form
ref="formRef"
:model="formData"
:rules="formRules"
:label-col="{ span: 4 }"
:wrapper-col="{ span: 20 }"
class="mx-4"
>
<Form.Item label="任务名称" name="name">
<Input v-model:value="formData.name" placeholder="请输入任务名称" />
</Form.Item>
<Form.Item label="任务描述" name="description">
<Input.TextArea
v-model:value="formData.description"
:rows="3"
placeholder="请输入任务描述"
/>
</Form.Item>
<Form.Item label="升级范围" name="deviceScope">
<Select
v-model:value="formData.deviceScope"
placeholder="请选择升级范围"
:options="deviceScopeOptions"
/>
</Form.Item>
<Form.Item
v-if="formData.deviceScope === IoTOtaTaskDeviceScopeEnum.SELECT.value"
label="选择设备"
name="deviceIds"
>
<Select
v-model:value="formData.deviceIds"
mode="multiple"
placeholder="请选择设备"
:options="deviceOptions"
:filter-option="true"
show-search
/>
</Form.Item>
</Form>
</Spin>
</Modal>
</template>

View File

@@ -0,0 +1,242 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { formatDate } from '@vben/utils';
import type { OtaTask } from '#/api/iot/ota/task';
import * as IoTOtaTaskApi from '#/api/iot/ota/task';
import { IoTOtaTaskStatusEnum } from '#/views/iot/utils/constants';
import OtaTaskForm from './OtaTaskForm.vue';
import OtaTaskDetail from './OtaTaskDetail.vue';
import { Card, Input, Table, Space, Modal, message, Tag } from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
import { VbenButton } from '@vben/common-ui';
/** IoT OTA 任务列表 */
defineOptions({ name: 'OtaTaskList' });
const props = defineProps<{
firmwareId: number;
productId: number;
}>();
const emit = defineEmits(['success']);
// 任务列表
const taskLoading = ref(false);
const taskList = ref<OtaTask[]>([]);
const taskTotal = ref(0);
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
name: '',
firmwareId: props.firmwareId,
});
const taskFormRef = ref(); // 任务表单引用
const taskDetailRef = ref(); // 任务详情引用
/** 获取任务列表 */
const getTaskList = async () => {
taskLoading.value = true;
try {
const data = await IoTOtaTaskApi.getOtaTaskPage(queryParams);
taskList.value = data.list;
taskTotal.value = data.total;
} finally {
taskLoading.value = false;
}
};
/** 搜索 */
const handleQuery = () => {
queryParams.pageNo = 1;
getTaskList();
};
/** 打开任务表单 */
const openTaskForm = () => {
taskFormRef.value?.open();
};
/** 处理任务创建成功 */
const handleTaskCreateSuccess = () => {
getTaskList();
emit('success');
};
/** 查看任务详情 */
const handleTaskDetail = (id: number) => {
taskDetailRef.value?.open(id);
};
/** 取消任务 */
const handleCancelTask = async (id: number) => {
Modal.confirm({
title: '确认取消',
content: '确认要取消该升级任务吗?',
async onOk() {
try {
await IoTOtaTaskApi.cancelOtaTask(id);
message.success('取消成功');
await refresh();
} catch (error) {
console.error('取消任务失败', error);
}
},
});
};
/** 刷新数据 */
const refresh = async () => {
await getTaskList();
emit('success');
};
/** 分页变化 */
const handleTableChange = (pagination: any) => {
queryParams.pageNo = pagination.current;
queryParams.pageSize = pagination.pageSize;
getTaskList();
};
/** 表格列配置 */
const columns: TableColumnsType = [
{
title: '任务编号',
dataIndex: 'id',
key: 'id',
width: 80,
align: 'center' as const,
},
{
title: '任务名称',
dataIndex: 'name',
key: 'name',
align: 'center' as const,
},
{
title: '升级范围',
dataIndex: 'deviceScope',
key: 'deviceScope',
align: 'center' as const,
},
{
title: '升级进度',
key: 'progress',
align: 'center' as const,
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
align: 'center' as const,
customRender: ({ text }: any) => formatDate(text, 'YYYY-MM-DD HH:mm:ss'),
},
{
title: '任务描述',
dataIndex: 'description',
key: 'description',
align: 'center' as const,
ellipsis: true,
},
{
title: '任务状态',
dataIndex: 'status',
key: 'status',
align: 'center' as const,
},
{
title: '操作',
key: 'action',
align: 'center' as const,
width: 120,
},
];
/** 初始化 */
onMounted(() => {
getTaskList();
});
</script>
<template>
<Card title="升级任务管理" class="mb-5">
<!-- 搜索栏 -->
<div class="mb-4 flex justify-between items-center">
<VbenButton type="primary" @click="openTaskForm">
<Icon icon="ant-design:plus-outlined" class="mr-1" />
新增
</VbenButton>
<Input
v-model:value="queryParams.name"
placeholder="请输入任务名称"
allow-clear
@press-enter="handleQuery"
style="width: 240px"
/>
</div>
<!-- 任务列表 -->
<Table
:columns="columns"
:data-source="taskList"
:loading="taskLoading"
:pagination="{
current: queryParams.pageNo,
pageSize: queryParams.pageSize,
total: taskTotal,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total: number) => `共 ${total} 条`,
}"
:scroll="{ x: 'max-content' }"
@change="handleTableChange"
>
<!-- 升级范围 -->
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'deviceScope'">
<Tag v-if="record.deviceScope === 1" color="blue">全部设备</Tag>
<Tag v-else-if="record.deviceScope === 2" color="green">指定设备</Tag>
<Tag v-else>{{ record.deviceScope }}</Tag>
</template>
<!-- 升级进度 -->
<template v-else-if="column.key === 'progress'">
{{ record.deviceSuccessCount }}/{{ record.deviceTotalCount }}
</template>
<!-- 任务状态 -->
<template v-else-if="column.key === 'status'">
<Tag v-if="record.status === 0" color="orange">待执行</Tag>
<Tag v-else-if="record.status === 1" color="blue">执行中</Tag>
<Tag v-else-if="record.status === 2" color="green">已完成</Tag>
<Tag v-else-if="record.status === 3" color="red">已取消</Tag>
<Tag v-else>{{ record.status }}</Tag>
</template>
<!-- 操作 -->
<template v-else-if="column.key === 'action'">
<Space>
<a @click="handleTaskDetail(record.id)">详情</a>
<a
v-if="record.status === IoTOtaTaskStatusEnum.IN_PROGRESS.value"
class="text-red-500"
@click="handleCancelTask(record.id)"
>
取消
</a>
</Space>
</template>
</template>
</Table>
<!-- 新增任务弹窗 -->
<OtaTaskForm
ref="taskFormRef"
:firmware-id="firmwareId"
:product-id="productId"
@success="handleTaskCreateSuccess"
/>
<!-- 任务详情弹窗 -->
<OtaTaskDetail ref="taskDetailRef" @success="refresh" />
</Card>
</template>