mirror of
https://gitee.com/yudaocode/yudao-ui-admin-vben.git
synced 2025-12-30 02:22:25 +00:00
refactor:【antd】【iot】将物联网设备和产品枚举整合为常量,优化设备导入功能,简化设备管理UI组件
This commit is contained in:
@@ -77,14 +77,6 @@ export namespace IotDeviceApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** IoT 设备状态枚举 */
|
|
||||||
// TODO @haohao:packages/constants/src/biz-iot-enum.ts 枚举;
|
|
||||||
export enum DeviceStateEnum {
|
|
||||||
INACTIVE = 0, // 未激活
|
|
||||||
OFFLINE = 2, // 离线
|
|
||||||
ONLINE = 1, // 在线
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 查询设备分页 */
|
/** 查询设备分页 */
|
||||||
export function getDevicePage(params: PageParam) {
|
export function getDevicePage(params: PageParam) {
|
||||||
return requestClient.get<PageResult<IotDeviceApi.Device>>(
|
return requestClient.get<PageResult<IotDeviceApi.Device>>(
|
||||||
@@ -154,6 +146,14 @@ export function importDeviceTemplate() {
|
|||||||
return requestClient.download('/iot/device/get-import-template');
|
return requestClient.download('/iot/device/get-import-template');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 导入设备 */
|
||||||
|
export function importDevice(file: File, updateSupport: boolean) {
|
||||||
|
return requestClient.upload('/iot/device/import', {
|
||||||
|
file,
|
||||||
|
updateSupport,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** 获取设备属性最新数据 */
|
/** 获取设备属性最新数据 */
|
||||||
export function getLatestDeviceProperties(params: any) {
|
export function getLatestDeviceProperties(params: any) {
|
||||||
return requestClient.get<IotDeviceApi.DevicePropertyDetail[]>(
|
return requestClient.get<IotDeviceApi.DevicePropertyDetail[]>(
|
||||||
|
|||||||
@@ -27,33 +27,6 @@ export namespace IotProductApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO @haohao:packages/constants/src/biz-iot-enum.ts 枚举;
|
|
||||||
|
|
||||||
/** IOT 产品设备类型枚举类 */
|
|
||||||
export enum DeviceTypeEnum {
|
|
||||||
DEVICE = 0, // 直连设备
|
|
||||||
GATEWAY = 2, // 网关设备
|
|
||||||
GATEWAY_SUB = 1, // 网关子设备
|
|
||||||
}
|
|
||||||
|
|
||||||
/** IOT 产品定位类型枚举类 */
|
|
||||||
export enum LocationTypeEnum {
|
|
||||||
IP = 1, // IP 定位
|
|
||||||
MANUAL = 3, // 手动定位
|
|
||||||
MODULE = 2, // 设备定位
|
|
||||||
}
|
|
||||||
|
|
||||||
/** IOT 数据格式(编解码器类型)枚举类 */
|
|
||||||
export enum CodecTypeEnum {
|
|
||||||
ALINK = 'Alink', // 阿里云 Alink 协议
|
|
||||||
}
|
|
||||||
|
|
||||||
/** IOT 产品状态枚举类 */
|
|
||||||
export enum ProductStatusEnum {
|
|
||||||
UNPUBLISHED = 0, // 开发中
|
|
||||||
PUBLISHED = 1, // 已发布
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 查询产品分页 */
|
/** 查询产品分页 */
|
||||||
export function getProductPage(params: PageParam) {
|
export function getProductPage(params: PageParam) {
|
||||||
return requestClient.get<PageResult<IotProductApi.Product>>(
|
return requestClient.get<PageResult<IotProductApi.Product>>(
|
||||||
|
|||||||
@@ -7,10 +7,9 @@ import { getDictOptions } from '@vben/hooks';
|
|||||||
import { z } from '#/adapter/form';
|
import { z } from '#/adapter/form';
|
||||||
import { getSimpleDeviceList } from '#/api/iot/device/device';
|
import { getSimpleDeviceList } from '#/api/iot/device/device';
|
||||||
import { getSimpleDeviceGroupList } from '#/api/iot/device/group';
|
import { getSimpleDeviceGroupList } from '#/api/iot/device/group';
|
||||||
import {
|
import { DeviceTypeEnum } from '@vben/constants';
|
||||||
DeviceTypeEnum,
|
|
||||||
getSimpleProductList,
|
import { getSimpleProductList } from '#/api/iot/product/product';
|
||||||
} from '#/api/iot/product/product';
|
|
||||||
|
|
||||||
/** 新增/修改的表单 */
|
/** 新增/修改的表单 */
|
||||||
export function useFormSchema(): VbenFormSchema[] {
|
export function useFormSchema(): VbenFormSchema[] {
|
||||||
@@ -33,6 +32,10 @@ export function useFormSchema(): VbenFormSchema[] {
|
|||||||
valueField: 'id',
|
valueField: 'id',
|
||||||
placeholder: '请选择产品',
|
placeholder: '请选择产品',
|
||||||
},
|
},
|
||||||
|
dependencies: {
|
||||||
|
triggerFields: ['id'],
|
||||||
|
disabled: (values: any) => !!values?.id,
|
||||||
|
},
|
||||||
rules: 'required',
|
rules: 'required',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -42,6 +45,10 @@ export function useFormSchema(): VbenFormSchema[] {
|
|||||||
componentProps: {
|
componentProps: {
|
||||||
placeholder: '请输入 DeviceName',
|
placeholder: '请输入 DeviceName',
|
||||||
},
|
},
|
||||||
|
dependencies: {
|
||||||
|
triggerFields: ['id'],
|
||||||
|
disabled: (values: any) => !!values?.id,
|
||||||
|
},
|
||||||
rules: z
|
rules: z
|
||||||
.string()
|
.string()
|
||||||
.min(4, 'DeviceName 长度不能少于 4 个字符')
|
.min(4, 'DeviceName 长度不能少于 4 个字符')
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||||
|
import type { IotDeviceApi } from '#/api/iot/device/device';
|
||||||
|
|
||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
@@ -32,10 +33,10 @@ import { getSimpleProductList } from '#/api/iot/product/product';
|
|||||||
import { $t } from '#/locales';
|
import { $t } from '#/locales';
|
||||||
|
|
||||||
import { useGridColumns } from './data';
|
import { useGridColumns } from './data';
|
||||||
import DeviceCardView from './modules/device-card-view.vue';
|
import DeviceCardView from './modules/card-view.vue';
|
||||||
import DeviceForm from './modules/device-form.vue';
|
import DeviceForm from './modules/form.vue';
|
||||||
import DeviceGroupForm from './modules/device-group-form.vue';
|
import DeviceGroupForm from './modules/group-form.vue';
|
||||||
import DeviceImportForm from './modules/device-import-form.vue';
|
import DeviceImportForm from './modules/import-form.vue';
|
||||||
|
|
||||||
/** IoT 设备列表 */
|
/** IoT 设备列表 */
|
||||||
defineOptions({ name: 'IoTDevice' });
|
defineOptions({ name: 'IoTDevice' });
|
||||||
@@ -47,8 +48,6 @@ const deviceGroups = ref<any[]>([]);
|
|||||||
const viewMode = ref<'card' | 'list'>('card');
|
const viewMode = ref<'card' | 'list'>('card');
|
||||||
const cardViewRef = ref();
|
const cardViewRef = ref();
|
||||||
|
|
||||||
// Modal instances
|
|
||||||
// TODO @haohao:这个界面,等 product 改完,在一起看看怎么弄更好。
|
|
||||||
const [DeviceFormModal, deviceFormModalApi] = useVbenModal({
|
const [DeviceFormModal, deviceFormModalApi] = useVbenModal({
|
||||||
connectedComponent: DeviceForm,
|
connectedComponent: DeviceForm,
|
||||||
destroyOnClose: true,
|
destroyOnClose: true,
|
||||||
@@ -91,14 +90,12 @@ function handleSearch() {
|
|||||||
|
|
||||||
/** 重置 */
|
/** 重置 */
|
||||||
function handleReset() {
|
function handleReset() {
|
||||||
searchParams.value = {
|
searchParams.value.deviceName = '';
|
||||||
deviceName: '',
|
searchParams.value.nickname = '';
|
||||||
nickname: '',
|
searchParams.value.productId = undefined;
|
||||||
productId: undefined,
|
searchParams.value.deviceType = undefined;
|
||||||
deviceType: undefined,
|
searchParams.value.status = undefined;
|
||||||
status: undefined,
|
searchParams.value.groupId = undefined;
|
||||||
groupId: undefined,
|
|
||||||
};
|
|
||||||
handleSearch();
|
handleSearch();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,18 +139,18 @@ function handleCreate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 编辑设备 */
|
/** 编辑设备 */
|
||||||
function handleEdit(row: any) {
|
function handleEdit(row: IotDeviceApi.Device) {
|
||||||
deviceFormModalApi.setData(row).open();
|
deviceFormModalApi.setData(row).open();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 删除设备 */
|
/** 删除设备 */
|
||||||
async function handleDelete(row: any) {
|
async function handleDelete(row: IotDeviceApi.Device) {
|
||||||
const hideLoading = message.loading({
|
const hideLoading = message.loading({
|
||||||
content: `正在删除设备...`,
|
content: $t('ui.actionMessage.deleting', [row.deviceName]),
|
||||||
duration: 0,
|
duration: 0,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await deleteDevice(row.id);
|
await deleteDevice(row.id!);
|
||||||
message.success($t('ui.actionMessage.deleteSuccess'));
|
message.success($t('ui.actionMessage.deleteSuccess'));
|
||||||
handleRefresh();
|
handleRefresh();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -163,7 +160,8 @@ async function handleDelete(row: any) {
|
|||||||
|
|
||||||
/** 批量删除设备 */
|
/** 批量删除设备 */
|
||||||
async function handleDeleteBatch() {
|
async function handleDeleteBatch() {
|
||||||
const checkedRows = gridApi.grid?.getCheckboxRecords() || [];
|
const checkedRows = (gridApi.grid?.getCheckboxRecords() ||
|
||||||
|
[]) as IotDeviceApi.Device[];
|
||||||
if (checkedRows.length === 0) {
|
if (checkedRows.length === 0) {
|
||||||
message.warning('请选择要删除的设备');
|
message.warning('请选择要删除的设备');
|
||||||
return;
|
return;
|
||||||
@@ -173,7 +171,7 @@ async function handleDeleteBatch() {
|
|||||||
duration: 0,
|
duration: 0,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const ids = checkedRows.map((row: any) => row.id);
|
const ids = checkedRows.map((row) => row.id!);
|
||||||
await deleteDeviceList(ids);
|
await deleteDeviceList(ids);
|
||||||
message.success($t('ui.actionMessage.deleteSuccess'));
|
message.success($t('ui.actionMessage.deleteSuccess'));
|
||||||
handleRefresh();
|
handleRefresh();
|
||||||
@@ -184,12 +182,13 @@ async function handleDeleteBatch() {
|
|||||||
|
|
||||||
/** 添加到分组 */
|
/** 添加到分组 */
|
||||||
function handleAddToGroup() {
|
function handleAddToGroup() {
|
||||||
const checkedRows = gridApi.grid?.getCheckboxRecords() || [];
|
const checkedRows = (gridApi.grid?.getCheckboxRecords() ||
|
||||||
|
[]) as IotDeviceApi.Device[];
|
||||||
if (checkedRows.length === 0) {
|
if (checkedRows.length === 0) {
|
||||||
message.warning('请选择要添加到分组的设备');
|
message.warning('请选择要添加到分组的设备');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ids = checkedRows.map((row: any) => row.id);
|
const ids = checkedRows.map((row) => row.id!);
|
||||||
deviceGroupFormModalApi.setData(ids).open();
|
deviceGroupFormModalApi.setData(ids).open();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,9 +198,6 @@ function handleImport() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [Grid, gridApi] = useVbenVxeGrid({
|
const [Grid, gridApi] = useVbenVxeGrid({
|
||||||
formOptions: {
|
|
||||||
schema: [],
|
|
||||||
},
|
|
||||||
gridOptions: {
|
gridOptions: {
|
||||||
checkboxConfig: {
|
checkboxConfig: {
|
||||||
highlight: true,
|
highlight: true,
|
||||||
@@ -229,7 +225,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
|||||||
refresh: true,
|
refresh: true,
|
||||||
search: true,
|
search: true,
|
||||||
},
|
},
|
||||||
} as VxeTableGridOptions,
|
} as VxeTableGridOptions<IotDeviceApi.Device>,
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 初始化 */
|
/** 初始化 */
|
||||||
@@ -341,45 +337,48 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<!-- 操作按钮 -->
|
<!-- 操作按钮 -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<Space :size="12">
|
<TableAction
|
||||||
<Button
|
:actions="[
|
||||||
type="primary"
|
{
|
||||||
@click="handleCreate"
|
label: '新增',
|
||||||
v-access:code="['iot:device:create']"
|
type: 'primary',
|
||||||
>
|
icon: ACTION_ICON.ADD,
|
||||||
<IconifyIcon icon="ant-design:plus-outlined" class="mr-1" />
|
auth: ['iot:device:create'],
|
||||||
新增
|
onClick: handleCreate,
|
||||||
</Button>
|
},
|
||||||
<Button
|
{
|
||||||
type="primary"
|
label: '导出',
|
||||||
@click="handleExport"
|
type: 'primary',
|
||||||
v-access:code="['iot:device:export']"
|
icon: ACTION_ICON.DOWNLOAD,
|
||||||
>
|
auth: ['iot:device:export'],
|
||||||
<IconifyIcon icon="ant-design:download-outlined" class="mr-1" />
|
onClick: handleExport,
|
||||||
导出
|
},
|
||||||
</Button>
|
{
|
||||||
<Button @click="handleImport" v-access:code="['iot:device:import']">
|
label: '导入',
|
||||||
<IconifyIcon icon="ant-design:upload-outlined" class="mr-1" />
|
type: 'primary',
|
||||||
导入
|
icon: ACTION_ICON.UPLOAD,
|
||||||
</Button>
|
auth: ['iot:device:import'],
|
||||||
<Button
|
onClick: handleImport,
|
||||||
v-show="viewMode === 'list'"
|
},
|
||||||
@click="handleAddToGroup"
|
{
|
||||||
v-access:code="['iot:device:update']"
|
label: '添加到分组',
|
||||||
>
|
type: 'primary',
|
||||||
<IconifyIcon icon="ant-design:folder-add-outlined" class="mr-1" />
|
icon: 'ant-design:folder-add-outlined',
|
||||||
添加到分组
|
auth: ['iot:device:update'],
|
||||||
</Button>
|
ifShow: () => viewMode === 'list',
|
||||||
<Button
|
onClick: handleAddToGroup,
|
||||||
v-show="viewMode === 'list'"
|
},
|
||||||
danger
|
{
|
||||||
@click="handleDeleteBatch"
|
label: '批量删除',
|
||||||
v-access:code="['iot:device:delete']"
|
type: 'primary',
|
||||||
>
|
color: 'error',
|
||||||
<IconifyIcon icon="ant-design:delete-outlined" class="mr-1" />
|
icon: ACTION_ICON.DELETE,
|
||||||
批量删除
|
auth: ['iot:device:delete'],
|
||||||
</Button>
|
ifShow: () => viewMode === 'list',
|
||||||
</Space>
|
onClick: handleDeleteBatch,
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 视图切换 -->
|
<!-- 视图切换 -->
|
||||||
<Space :size="4">
|
<Space :size="4">
|
||||||
@@ -436,12 +435,12 @@ onMounted(async () => {
|
|||||||
{
|
{
|
||||||
label: '查看',
|
label: '查看',
|
||||||
type: 'link',
|
type: 'link',
|
||||||
onClick: openDetail.bind(null, row.id),
|
onClick: openDetail.bind(null, row.id!),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '日志',
|
label: '日志',
|
||||||
type: 'link',
|
type: 'link',
|
||||||
onClick: openModel.bind(null, row.id),
|
onClick: openModel.bind(null, row.id!),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: $t('common.edit'),
|
label: $t('common.edit'),
|
||||||
@@ -455,7 +454,7 @@ onMounted(async () => {
|
|||||||
danger: true,
|
danger: true,
|
||||||
icon: ACTION_ICON.DELETE,
|
icon: ACTION_ICON.DELETE,
|
||||||
popConfirm: {
|
popConfirm: {
|
||||||
title: `确认删除设备吗?`,
|
title: `确认删除设备 ${row.deviceName} 吗?`,
|
||||||
confirm: handleDelete.bind(null, row),
|
confirm: handleDelete.bind(null, row),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
556
apps/web-antd/src/views/iot/device/device/modules/card-view.vue
Normal file
556
apps/web-antd/src/views/iot/device/device/modules/card-view.vue
Normal file
@@ -0,0 +1,556 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
import { DICT_TYPE } from '@vben/constants';
|
||||||
|
import { getDictLabel, getDictObj } from '@vben/hooks';
|
||||||
|
import { IconifyIcon } from '@vben/icons';
|
||||||
|
import { isValidColor, TinyColor } from '@vben/utils';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
Empty,
|
||||||
|
Pagination,
|
||||||
|
Popconfirm,
|
||||||
|
Row,
|
||||||
|
Tag,
|
||||||
|
Tooltip,
|
||||||
|
} from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { DeviceStateEnum } from '@vben/constants';
|
||||||
|
|
||||||
|
import { getDevicePage } from '#/api/iot/device/device';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
products: any[];
|
||||||
|
deviceGroups: any[];
|
||||||
|
searchParams?: {
|
||||||
|
deviceName: string;
|
||||||
|
deviceType?: number;
|
||||||
|
groupId?: number;
|
||||||
|
nickname: string;
|
||||||
|
productId?: number;
|
||||||
|
status?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
create: [];
|
||||||
|
delete: [row: any];
|
||||||
|
detail: [id: number];
|
||||||
|
edit: [row: any];
|
||||||
|
model: [id: number];
|
||||||
|
productDetail: [productId: number];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const list = ref<any[]>([]);
|
||||||
|
const total = ref(0);
|
||||||
|
const queryParams = ref({
|
||||||
|
pageNo: 1,
|
||||||
|
pageSize: 12,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 默认状态映射 */
|
||||||
|
const DEFAULT_STATUS_MAP: Record<
|
||||||
|
'default' | number,
|
||||||
|
{ bgColor: string; borderColor: string; color: string; text: string }
|
||||||
|
> = {
|
||||||
|
[DeviceStateEnum.ONLINE]: {
|
||||||
|
text: '在线',
|
||||||
|
color: '#52c41a',
|
||||||
|
bgColor: '#f6ffed',
|
||||||
|
borderColor: '#b7eb8f',
|
||||||
|
},
|
||||||
|
[DeviceStateEnum.OFFLINE]: {
|
||||||
|
text: '离线',
|
||||||
|
color: '#faad14',
|
||||||
|
bgColor: '#fffbe6',
|
||||||
|
borderColor: '#ffe58f',
|
||||||
|
},
|
||||||
|
[DeviceStateEnum.INACTIVE]: {
|
||||||
|
text: '未激活',
|
||||||
|
color: '#ff4d4f',
|
||||||
|
bgColor: '#fff1f0',
|
||||||
|
borderColor: '#ffccc7',
|
||||||
|
},
|
||||||
|
default: {
|
||||||
|
text: '未知状态',
|
||||||
|
color: '#595959',
|
||||||
|
bgColor: '#fafafa',
|
||||||
|
borderColor: '#d9d9d9',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 颜色类型预设 */
|
||||||
|
const COLOR_TYPE_PRESETS: Record<
|
||||||
|
string,
|
||||||
|
{ bgColor: string; borderColor: string; color: string }
|
||||||
|
> = {
|
||||||
|
success: {
|
||||||
|
color: '#52c41a',
|
||||||
|
bgColor: '#f6ffed',
|
||||||
|
borderColor: '#b7eb8f',
|
||||||
|
},
|
||||||
|
processing: {
|
||||||
|
color: '#1890ff',
|
||||||
|
bgColor: '#e6f7ff',
|
||||||
|
borderColor: '#91d5ff',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
color: '#faad14',
|
||||||
|
bgColor: '#fffbe6',
|
||||||
|
borderColor: '#ffe58f',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
color: '#ff4d4f',
|
||||||
|
bgColor: '#fff1f0',
|
||||||
|
borderColor: '#ffccc7',
|
||||||
|
},
|
||||||
|
default: {
|
||||||
|
color: '#595959',
|
||||||
|
bgColor: '#fafafa',
|
||||||
|
borderColor: '#d9d9d9',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 规范化颜色类型 */
|
||||||
|
function normalizeColorType(colorType?: string) {
|
||||||
|
switch (colorType) {
|
||||||
|
case 'danger': {
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
|
case 'default':
|
||||||
|
case 'error':
|
||||||
|
case 'processing':
|
||||||
|
case 'success':
|
||||||
|
case 'warning': {
|
||||||
|
return colorType;
|
||||||
|
}
|
||||||
|
case 'info': {
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
case 'primary': {
|
||||||
|
return 'processing';
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取产品名称 */
|
||||||
|
function getProductName(productId: number) {
|
||||||
|
const product = props.products.find((p: any) => p.id === productId);
|
||||||
|
return product?.name || '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取设备列表 */
|
||||||
|
async function getList() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const data = await getDevicePage({
|
||||||
|
...queryParams.value,
|
||||||
|
...props.searchParams,
|
||||||
|
});
|
||||||
|
list.value = data.list || [];
|
||||||
|
total.value = data.total || 0;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 处理页码变化 */
|
||||||
|
function handlePageChange(page: number, pageSize: number) {
|
||||||
|
queryParams.value.pageNo = page;
|
||||||
|
queryParams.value.pageSize = pageSize;
|
||||||
|
getList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取设备类型颜色 */
|
||||||
|
function getDeviceTypeColor(deviceType: number) {
|
||||||
|
const colors: Record<number, string> = {
|
||||||
|
0: 'blue',
|
||||||
|
1: 'cyan',
|
||||||
|
};
|
||||||
|
return colors[deviceType] || 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取设备状态信息 */
|
||||||
|
function getStatusInfo(state: null | number | string | undefined) {
|
||||||
|
const parsedState = Number(state);
|
||||||
|
const hasNumericState = Number.isFinite(parsedState);
|
||||||
|
const fallback = hasNumericState
|
||||||
|
? DEFAULT_STATUS_MAP[parsedState] || DEFAULT_STATUS_MAP.default
|
||||||
|
: DEFAULT_STATUS_MAP.default;
|
||||||
|
const dict = getDictObj(
|
||||||
|
DICT_TYPE.IOT_DEVICE_STATE,
|
||||||
|
hasNumericState ? parsedState : state,
|
||||||
|
);
|
||||||
|
if (dict) {
|
||||||
|
if (!dict.colorType && !dict.cssClass) {
|
||||||
|
return {
|
||||||
|
...fallback,
|
||||||
|
text: dict.label || fallback.text,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const presetKey = normalizeColorType(dict.colorType);
|
||||||
|
if (isValidColor(dict.cssClass)) {
|
||||||
|
const baseColor = new TinyColor(dict.cssClass);
|
||||||
|
return {
|
||||||
|
text: dict.label || fallback.text,
|
||||||
|
color: baseColor.toHexString(),
|
||||||
|
bgColor: baseColor.clone().setAlpha(0.15).toRgbString(),
|
||||||
|
borderColor: baseColor.clone().lighten(30).toHexString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const preset = COLOR_TYPE_PRESETS[presetKey] || COLOR_TYPE_PRESETS.default;
|
||||||
|
return {
|
||||||
|
text: dict.label || fallback.text,
|
||||||
|
...preset,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
reload: getList,
|
||||||
|
search: () => {
|
||||||
|
queryParams.value.pageNo = 1;
|
||||||
|
getList();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 初始化 */
|
||||||
|
onMounted(() => {
|
||||||
|
getList();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="device-card-view">
|
||||||
|
<!-- 设备卡片列表 -->
|
||||||
|
<div v-loading="loading" class="min-h-96">
|
||||||
|
<Row v-if="list.length > 0" :gutter="[16, 16]">
|
||||||
|
<Col
|
||||||
|
v-for="item in list"
|
||||||
|
:key="item.id"
|
||||||
|
:xs="24"
|
||||||
|
:sm="12"
|
||||||
|
:md="12"
|
||||||
|
:lg="6"
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
:body-style="{ padding: '16px' }"
|
||||||
|
class="device-card h-full rounded-lg transition-all duration-300 hover:-translate-y-0.5 hover:shadow-lg"
|
||||||
|
>
|
||||||
|
<!-- 顶部标题区域 -->
|
||||||
|
<div class="mb-3 flex items-center">
|
||||||
|
<div class="device-icon">
|
||||||
|
<IconifyIcon icon="mdi:chip" class="text-xl" />
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 min-w-0 flex-1">
|
||||||
|
<div class="device-title">{{ item.deviceName }}</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="status-badge"
|
||||||
|
:style="{
|
||||||
|
color: getStatusInfo(item.state).color,
|
||||||
|
backgroundColor: getStatusInfo(item.state).bgColor,
|
||||||
|
borderColor: getStatusInfo(item.state).borderColor,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<span class="status-dot"></span>
|
||||||
|
{{ getStatusInfo(item.state).text }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 内容区域 -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="info-list">
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">所属产品</span>
|
||||||
|
<a
|
||||||
|
class="info-value text-primary cursor-pointer"
|
||||||
|
@click="
|
||||||
|
(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
emit('productDetail', item.productId);
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ getProductName(item.productId) }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">设备类型</span>
|
||||||
|
<Tag
|
||||||
|
:color="getDeviceTypeColor(item.deviceType)"
|
||||||
|
class="info-tag m-0"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
getDictLabel(
|
||||||
|
DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE,
|
||||||
|
item.deviceType,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Deviceid</span>
|
||||||
|
<Tooltip
|
||||||
|
:title="item.Deviceid || item.id"
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<span class="info-value device-id cursor-pointer">
|
||||||
|
{{ item.Deviceid || item.id }}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 按钮组 -->
|
||||||
|
<div class="action-buttons">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
class="action-btn action-btn-edit"
|
||||||
|
@click="emit('edit', item)"
|
||||||
|
>
|
||||||
|
<IconifyIcon icon="lucide:edit" class="mr-1" />
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
class="action-btn action-btn-detail"
|
||||||
|
@click="emit('detail', item.id)"
|
||||||
|
>
|
||||||
|
<IconifyIcon icon="lucide:eye" class="mr-1" />
|
||||||
|
详情
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
class="action-btn action-btn-data"
|
||||||
|
@click="emit('model', item.id)"
|
||||||
|
>
|
||||||
|
<IconifyIcon icon="lucide:database" class="mr-1" />
|
||||||
|
数据
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
:title="`确认删除设备 ${item.deviceName} 吗?`"
|
||||||
|
@confirm="emit('delete', item)"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
class="action-btn action-btn-delete !w-8"
|
||||||
|
>
|
||||||
|
<IconifyIcon icon="lucide:trash-2" class="text-sm" />
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<Empty v-else description="暂无设备数据" class="my-20" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<div v-if="list.length > 0" class="flex justify-end">
|
||||||
|
<Pagination
|
||||||
|
v-model:current="queryParams.pageNo"
|
||||||
|
v-model:page-size="queryParams.pageSize"
|
||||||
|
:total="total"
|
||||||
|
:show-total="(total) => `共 ${total} 条`"
|
||||||
|
show-quick-jumper
|
||||||
|
show-size-changer
|
||||||
|
:page-size-options="['12', '24', '36', '48']"
|
||||||
|
@change="handlePageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.device-card-view {
|
||||||
|
.device-card {
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
:deep(.ant-card-body) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设备图标
|
||||||
|
.device-icon {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
color: white;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设备标题
|
||||||
|
.device-title {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 36px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 状态徽章
|
||||||
|
.status-badge {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 18px;
|
||||||
|
border: 1px solid;
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
background: currentcolor;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 信息列表
|
||||||
|
.info-list {
|
||||||
|
.info-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 8px;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&.text-primary {
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-id {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 150px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
vertical-align: middle;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-tag {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按钮组
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 12px;
|
||||||
|
margin-top: auto;
|
||||||
|
border-top: 1px solid var(--ant-color-split);
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
flex: 1;
|
||||||
|
height: 32px;
|
||||||
|
font-size: 13px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&.action-btn-edit {
|
||||||
|
color: #1890ff;
|
||||||
|
border-color: #1890ff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: white;
|
||||||
|
background: #1890ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.action-btn-detail {
|
||||||
|
color: #52c41a;
|
||||||
|
border-color: #52c41a;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: white;
|
||||||
|
background: #52c41a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.action-btn-data {
|
||||||
|
color: #722ed1;
|
||||||
|
border-color: #722ed1;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: white;
|
||||||
|
background: #722ed1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.action-btn-delete {
|
||||||
|
flex: 0 0 32px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 夜间模式适配
|
||||||
|
html.dark {
|
||||||
|
.device-card-view {
|
||||||
|
.device-card {
|
||||||
|
.device-title {
|
||||||
|
color: rgb(255 255 255 / 85%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-list {
|
||||||
|
.info-label {
|
||||||
|
color: rgb(255 255 255 / 65%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
color: rgb(255 255 255 / 85%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-id {
|
||||||
|
color: rgb(255 255 255 / 75%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,402 +0,0 @@
|
|||||||
<!-- IoT 设备选择,使用弹窗展示 -->
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { IotDeviceApi } from '#/api/iot/device/device';
|
|
||||||
import type { IotDeviceGroupApi } from '#/api/iot/device/group';
|
|
||||||
import type { IotProductApi } from '#/api/iot/product/product';
|
|
||||||
|
|
||||||
import { computed, onMounted, reactive, ref } from 'vue';
|
|
||||||
|
|
||||||
import { ContentWrap } from '@vben/common-ui';
|
|
||||||
import { DICT_TYPE } from '@vben/constants';
|
|
||||||
import { getDictOptions } from '@vben/hooks';
|
|
||||||
import { IconifyIcon } from '@vben/icons';
|
|
||||||
import { formatDate } from '@vben/utils';
|
|
||||||
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Form,
|
|
||||||
Input,
|
|
||||||
message,
|
|
||||||
Modal,
|
|
||||||
Pagination,
|
|
||||||
Radio,
|
|
||||||
Select,
|
|
||||||
Table,
|
|
||||||
Tag,
|
|
||||||
} from 'ant-design-vue';
|
|
||||||
|
|
||||||
import { getDevicePage } from '#/api/iot/device/device';
|
|
||||||
import { getSimpleDeviceGroupList } from '#/api/iot/device/group';
|
|
||||||
import { getSimpleProductList } from '#/api/iot/product/product';
|
|
||||||
import { DictTag } from '#/components/dict-tag';
|
|
||||||
|
|
||||||
defineOptions({ name: 'IoTDeviceTableSelect' });
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
multiple: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
productId: {
|
|
||||||
type: Number,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
/** 提交表单 */
|
|
||||||
const emit = defineEmits(['success']);
|
|
||||||
|
|
||||||
// 获取字典选项
|
|
||||||
function getIntDictOptions(dictType: string) {
|
|
||||||
return getDictOptions(dictType, 'number');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 日期格式化
|
|
||||||
function dateFormatter(_row: any, _column: any, cellValue: any) {
|
|
||||||
return cellValue ? formatDate(cellValue, 'YYYY-MM-DD HH:mm:ss') : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const dialogVisible = ref(false);
|
|
||||||
const dialogTitle = ref('设备选择器');
|
|
||||||
const formLoading = ref(false);
|
|
||||||
const loading = ref(true); // 列表的加载中
|
|
||||||
const list = ref<IotDeviceApi.Device[]>([]); // 列表的数据
|
|
||||||
const total = ref(0); // 列表的总页数
|
|
||||||
const selectedDevices = ref<IotDeviceApi.Device[]>([]); // 选中的设备列表
|
|
||||||
const selectedId = ref<number>(); // 单选模式下选中的ID
|
|
||||||
const products = ref<IotProductApi.Product[]>([]); // 产品列表
|
|
||||||
const deviceGroups = ref<IotDeviceGroupApi.DeviceGroup[]>([]); // 设备分组列表
|
|
||||||
const selectedRowKeys = ref<number[]>([]); // 多选模式下选中的keys
|
|
||||||
|
|
||||||
const queryParams = reactive({
|
|
||||||
pageNo: 1,
|
|
||||||
pageSize: 10,
|
|
||||||
deviceName: undefined as string | undefined,
|
|
||||||
productId: undefined as number | undefined,
|
|
||||||
deviceType: undefined as number | undefined,
|
|
||||||
nickname: undefined as string | undefined,
|
|
||||||
status: undefined as number | undefined,
|
|
||||||
groupId: undefined as number | undefined,
|
|
||||||
});
|
|
||||||
const queryFormRef = ref(); // 搜索的表单
|
|
||||||
|
|
||||||
// 表格列定义
|
|
||||||
const columns = computed(() => {
|
|
||||||
const baseColumns = [
|
|
||||||
{
|
|
||||||
title: 'DeviceName',
|
|
||||||
dataIndex: 'deviceName',
|
|
||||||
key: 'deviceName',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '备注名称',
|
|
||||||
dataIndex: 'nickname',
|
|
||||||
key: 'nickname',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '所属产品',
|
|
||||||
key: 'productId',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '设备类型',
|
|
||||||
key: 'deviceType',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '所属分组',
|
|
||||||
key: 'groupIds',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '设备状态',
|
|
||||||
key: 'status',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '最后上线时间',
|
|
||||||
key: 'onlineTime',
|
|
||||||
width: 180,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// 单选模式添加单选列
|
|
||||||
if (!props.multiple) {
|
|
||||||
baseColumns.unshift({
|
|
||||||
title: '',
|
|
||||||
key: 'radio',
|
|
||||||
width: 55,
|
|
||||||
align: 'center',
|
|
||||||
} as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseColumns;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 多选配置
|
|
||||||
const rowSelection = computed(() => ({
|
|
||||||
selectedRowKeys: selectedRowKeys.value,
|
|
||||||
onChange: (keys: any[], rows: IotDeviceApi.Device[]) => {
|
|
||||||
selectedRowKeys.value = keys;
|
|
||||||
selectedDevices.value = rows;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
/** 查询列表 */
|
|
||||||
async function getList() {
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
if (props.productId) {
|
|
||||||
queryParams.productId = props.productId;
|
|
||||||
}
|
|
||||||
const data = await getDevicePage(queryParams);
|
|
||||||
list.value = data.list;
|
|
||||||
total.value = data.total;
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 搜索按钮操作 */
|
|
||||||
function handleQuery() {
|
|
||||||
queryParams.pageNo = 1;
|
|
||||||
getList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 重置按钮操作 */
|
|
||||||
function resetQuery() {
|
|
||||||
queryFormRef.value.resetFields();
|
|
||||||
handleQuery();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 打开弹窗 */
|
|
||||||
async function open() {
|
|
||||||
dialogVisible.value = true;
|
|
||||||
// 重置选择状态
|
|
||||||
selectedDevices.value = [];
|
|
||||||
selectedId.value = undefined;
|
|
||||||
selectedRowKeys.value = [];
|
|
||||||
if (!props.productId) {
|
|
||||||
// 获取产品列表
|
|
||||||
products.value = await getSimpleProductList();
|
|
||||||
}
|
|
||||||
// 获取设备列表
|
|
||||||
await getList();
|
|
||||||
}
|
|
||||||
defineExpose({ open });
|
|
||||||
|
|
||||||
/** 处理行点击事件 */
|
|
||||||
const tableRef = ref();
|
|
||||||
function handleRowClick(row: IotDeviceApi.Device) {
|
|
||||||
if (!props.multiple) {
|
|
||||||
selectedId.value = row.id;
|
|
||||||
selectedDevices.value = [row];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 处理单选变更事件 */
|
|
||||||
function handleRadioChange(row: IotDeviceApi.Device) {
|
|
||||||
selectedId.value = row.id;
|
|
||||||
selectedDevices.value = [row];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitForm() {
|
|
||||||
if (selectedDevices.value.length === 0) {
|
|
||||||
message.warning({
|
|
||||||
content: props.multiple ? '请至少选择一个设备' : '请选择一个设备',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
emit(
|
|
||||||
'success',
|
|
||||||
props.multiple ? selectedDevices.value : selectedDevices.value[0],
|
|
||||||
);
|
|
||||||
dialogVisible.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 初始化 */
|
|
||||||
onMounted(async () => {
|
|
||||||
// 获取产品列表
|
|
||||||
products.value = await getSimpleProductList();
|
|
||||||
// 获取分组列表
|
|
||||||
deviceGroups.value = await getSimpleDeviceGroupList();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Modal
|
|
||||||
:title="dialogTitle"
|
|
||||||
v-model:open="dialogVisible"
|
|
||||||
width="60%"
|
|
||||||
:footer="null"
|
|
||||||
>
|
|
||||||
<ContentWrap>
|
|
||||||
<!-- 搜索工作栏 -->
|
|
||||||
<Form
|
|
||||||
ref="queryFormRef"
|
|
||||||
layout="inline"
|
|
||||||
:model="queryParams"
|
|
||||||
class="-mb-15px"
|
|
||||||
>
|
|
||||||
<Form.Item v-if="!props.productId" label="产品" name="productId">
|
|
||||||
<Select
|
|
||||||
v-model:value="queryParams.productId"
|
|
||||||
placeholder="请选择产品"
|
|
||||||
allow-clear
|
|
||||||
style="width: 240px"
|
|
||||||
>
|
|
||||||
<Select.Option
|
|
||||||
v-for="product in products"
|
|
||||||
:key="product.id"
|
|
||||||
:value="product.id"
|
|
||||||
>
|
|
||||||
{{ product.name }}
|
|
||||||
</Select.Option>
|
|
||||||
</Select>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label="DeviceName" name="deviceName">
|
|
||||||
<Input
|
|
||||||
v-model:value="queryParams.deviceName"
|
|
||||||
placeholder="请输入 DeviceName"
|
|
||||||
allow-clear
|
|
||||||
@press-enter="handleQuery"
|
|
||||||
style="width: 240px"
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label="备注名称" name="nickname">
|
|
||||||
<Input
|
|
||||||
v-model:value="queryParams.nickname"
|
|
||||||
placeholder="请输入备注名称"
|
|
||||||
allow-clear
|
|
||||||
@press-enter="handleQuery"
|
|
||||||
style="width: 240px"
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label="设备类型" name="deviceType">
|
|
||||||
<Select
|
|
||||||
v-model:value="queryParams.deviceType"
|
|
||||||
placeholder="请选择设备类型"
|
|
||||||
allow-clear
|
|
||||||
style="width: 240px"
|
|
||||||
>
|
|
||||||
<Select.Option
|
|
||||||
v-for="dict in getIntDictOptions(
|
|
||||||
DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE,
|
|
||||||
)"
|
|
||||||
:key="dict.value"
|
|
||||||
:value="dict.value"
|
|
||||||
>
|
|
||||||
{{ dict.label }}
|
|
||||||
</Select.Option>
|
|
||||||
</Select>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label="设备状态" name="status">
|
|
||||||
<Select
|
|
||||||
v-model:value="queryParams.status"
|
|
||||||
placeholder="请选择设备状态"
|
|
||||||
allow-clear
|
|
||||||
style="width: 240px"
|
|
||||||
>
|
|
||||||
<Select.Option
|
|
||||||
v-for="dict in getIntDictOptions(DICT_TYPE.IOT_DEVICE_STATE)"
|
|
||||||
:key="dict.value"
|
|
||||||
:value="dict.value"
|
|
||||||
>
|
|
||||||
{{ dict.label }}
|
|
||||||
</Select.Option>
|
|
||||||
</Select>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label="设备分组" name="groupId">
|
|
||||||
<Select
|
|
||||||
v-model:value="queryParams.groupId"
|
|
||||||
placeholder="请选择设备分组"
|
|
||||||
allow-clear
|
|
||||||
style="width: 240px"
|
|
||||||
>
|
|
||||||
<Select.Option
|
|
||||||
v-for="group in deviceGroups"
|
|
||||||
:key="group.id"
|
|
||||||
:value="group.id"
|
|
||||||
>
|
|
||||||
{{ group.name }}
|
|
||||||
</Select.Option>
|
|
||||||
</Select>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item>
|
|
||||||
<Button @click="handleQuery">
|
|
||||||
<IconifyIcon class="mr-5px" icon="ep:search" />
|
|
||||||
搜索
|
|
||||||
</Button>
|
|
||||||
<Button @click="resetQuery">
|
|
||||||
<IconifyIcon class="mr-5px" icon="ep:refresh" />
|
|
||||||
重置
|
|
||||||
</Button>
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
</ContentWrap>
|
|
||||||
|
|
||||||
<!-- 列表 -->
|
|
||||||
<ContentWrap>
|
|
||||||
<Table
|
|
||||||
ref="tableRef"
|
|
||||||
:loading="loading"
|
|
||||||
:data-source="list"
|
|
||||||
:columns="columns"
|
|
||||||
:pagination="false"
|
|
||||||
:row-selection="multiple ? rowSelection : undefined"
|
|
||||||
@row-click="handleRowClick"
|
|
||||||
:row-key="(record: IotDeviceApi.Device) => record.id?.toString() ?? ''"
|
|
||||||
>
|
|
||||||
<template #bodyCell="{ column, record }">
|
|
||||||
<template v-if="column.key === 'radio'">
|
|
||||||
<Radio
|
|
||||||
:checked="selectedId === record.id"
|
|
||||||
@click="() => handleRadioChange(record as IotDeviceApi.Device)"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="column.key === 'productId'">
|
|
||||||
{{ products.find((p) => p.id === record.productId)?.name || '-' }}
|
|
||||||
</template>
|
|
||||||
<template v-else-if="column.key === 'deviceType'">
|
|
||||||
<DictTag
|
|
||||||
:type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE"
|
|
||||||
:value="record.deviceType"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="column.key === 'groupIds'">
|
|
||||||
<template v-if="record.groupIds?.length">
|
|
||||||
<Tag
|
|
||||||
v-for="id in record.groupIds"
|
|
||||||
:key="id"
|
|
||||||
class="ml-5px"
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
{{ deviceGroups.find((g) => g.id === id)?.name }}
|
|
||||||
</Tag>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="column.key === 'status'">
|
|
||||||
<DictTag
|
|
||||||
:type="DICT_TYPE.IOT_DEVICE_STATE"
|
|
||||||
:value="record.status"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="column.key === 'onlineTime'">
|
|
||||||
{{ dateFormatter(null, null, record.onlineTime) }}
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
</Table>
|
|
||||||
|
|
||||||
<!-- 分页 -->
|
|
||||||
<Pagination
|
|
||||||
v-model:limit="queryParams.pageSize"
|
|
||||||
v-model:page="queryParams.pageNo"
|
|
||||||
:total="total"
|
|
||||||
@pagination="getList"
|
|
||||||
/>
|
|
||||||
</ContentWrap>
|
|
||||||
|
|
||||||
<template #footer>
|
|
||||||
<Button @click="submitForm" type="primary" :disabled="formLoading">
|
|
||||||
确 定
|
|
||||||
</Button>
|
|
||||||
<Button @click="dialogVisible = false">取 消</Button>
|
|
||||||
</template>
|
|
||||||
</Modal>
|
|
||||||
</template>
|
|
||||||
@@ -10,17 +10,19 @@ import { Page } from '@vben/common-ui';
|
|||||||
|
|
||||||
import { message, Tabs } from 'ant-design-vue';
|
import { message, Tabs } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { DeviceTypeEnum } from '@vben/constants';
|
||||||
|
|
||||||
import { getDevice } from '#/api/iot/device/device';
|
import { getDevice } from '#/api/iot/device/device';
|
||||||
import { DeviceTypeEnum, getProduct } from '#/api/iot/product/product';
|
import { getProduct } from '#/api/iot/product/product';
|
||||||
import { getThingModelListByProductId } from '#/api/iot/thingmodel';
|
import { getThingModelListByProductId } from '#/api/iot/thingmodel';
|
||||||
|
|
||||||
import DeviceDetailConfig from './device-detail-config.vue';
|
import DeviceDetailConfig from './modules/config.vue';
|
||||||
import DeviceDetailsHeader from './device-details-header.vue';
|
import DeviceDetailsHeader from './modules/header.vue';
|
||||||
import DeviceDetailsInfo from './device-details-info.vue';
|
import DeviceDetailsInfo from './modules/info.vue';
|
||||||
import DeviceDetailsMessage from './device-details-message.vue';
|
import DeviceDetailsMessage from './modules/message.vue';
|
||||||
import DeviceDetailsSimulator from './device-details-simulator.vue';
|
import DeviceDetailsSimulator from './modules/simulator.vue';
|
||||||
import DeviceDetailsSubDevice from './device-details-sub-device.vue';
|
import DeviceDetailsSubDevice from './modules/sub-device.vue';
|
||||||
import DeviceDetailsThingModel from './device-details-thing-model.vue';
|
import DeviceDetailsThingModel from './modules/thing-model.vue';
|
||||||
|
|
||||||
defineOptions({ name: 'IoTDeviceDetail' });
|
defineOptions({ name: 'IoTDeviceDetail' });
|
||||||
|
|
||||||
@@ -52,8 +54,8 @@ async function getDeviceData(deviceId: number) {
|
|||||||
async function getProductData(productId: number) {
|
async function getProductData(productId: number) {
|
||||||
try {
|
try {
|
||||||
product.value = await getProduct(productId);
|
product.value = await getProduct(productId);
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('获取产品详情失败:', error);
|
message.error('获取产品详情失败');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,8 +64,8 @@ async function getThingModelList(productId: number) {
|
|||||||
try {
|
try {
|
||||||
const data = await getThingModelListByProductId(productId);
|
const data = await getThingModelListByProductId(productId);
|
||||||
thingModelList.value = data || [];
|
thingModelList.value = data || [];
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('获取物模型列表失败:', error);
|
message.error('获取物模型列表失败');
|
||||||
thingModelList.value = [];
|
thingModelList.value = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<!-- 设备信息(头部) -->
|
<script lang="ts" setup>
|
||||||
<script setup lang="ts">
|
|
||||||
import type { IotDeviceApi } from '#/api/iot/device/device';
|
import type { IotDeviceApi } from '#/api/iot/device/device';
|
||||||
import type { IotProductApi } from '#/api/iot/product/product';
|
import type { IotProductApi } from '#/api/iot/product/product';
|
||||||
|
|
||||||
import { ref } from 'vue';
|
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
import { Button, Card, Descriptions, message } from 'ant-design-vue';
|
import { Button, Card, Descriptions, message } from 'ant-design-vue';
|
||||||
|
|
||||||
import DeviceForm from '../device-form.vue';
|
import DeviceForm from '../../form.vue';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
product: IotProductApi.Product;
|
product: IotProductApi.Product;
|
||||||
@@ -26,20 +26,19 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
/** 操作修改 */
|
const [FormModal, formModalApi] = useVbenModal({
|
||||||
const formRef = ref();
|
connectedComponent: DeviceForm,
|
||||||
function openForm(type: string, id?: number) {
|
destroyOnClose: true,
|
||||||
formRef.value.open(type, id);
|
});
|
||||||
}
|
|
||||||
|
|
||||||
/** 复制到剪贴板方法 */
|
/** 复制到剪贴板 */
|
||||||
async function copyToClipboard(text: string | undefined) {
|
async function copyToClipboard(text: string | undefined) {
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
message.success({ content: '复制成功' });
|
message.success('复制成功');
|
||||||
} catch {
|
} catch {
|
||||||
message.error({ content: '复制失败' });
|
message.error('复制失败');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,19 +48,25 @@ function goToProductDetail(productId: number | undefined) {
|
|||||||
router.push({ name: 'IoTProductDetail', params: { id: productId } });
|
router.push({ name: 'IoTProductDetail', params: { id: productId } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 打开编辑表单 */
|
||||||
|
function openEditForm(row: IotDeviceApi.Device) {
|
||||||
|
formModalApi.setData(row).open();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
|
<FormModal @success="emit('refresh')" />
|
||||||
|
|
||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-bold">{{ device.deviceName }}</h2>
|
<h2 class="text-xl font-bold">{{ device.deviceName }}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-x-2">
|
<div class="space-x-2">
|
||||||
<!-- 右上:按钮 -->
|
|
||||||
<Button
|
<Button
|
||||||
v-if="product.status === 0"
|
v-if="product.status === 0"
|
||||||
v-access:code="['iot:device:update']"
|
v-access:code="['iot:device:update']"
|
||||||
@click="openForm('update', device.id)"
|
@click="openEditForm(device)"
|
||||||
>
|
>
|
||||||
编辑
|
编辑
|
||||||
</Button>
|
</Button>
|
||||||
@@ -72,8 +77,8 @@ function goToProductDetail(productId: number | undefined) {
|
|||||||
<Descriptions :column="1">
|
<Descriptions :column="1">
|
||||||
<Descriptions.Item label="产品">
|
<Descriptions.Item label="产品">
|
||||||
<a
|
<a
|
||||||
@click="goToProductDetail(product.id)"
|
|
||||||
class="cursor-pointer text-blue-600"
|
class="cursor-pointer text-blue-600"
|
||||||
|
@click="goToProductDetail(product.id)"
|
||||||
>
|
>
|
||||||
{{ product.name }}
|
{{ product.name }}
|
||||||
</a>
|
</a>
|
||||||
@@ -90,8 +95,5 @@ function goToProductDetail(productId: number | undefined) {
|
|||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- 表单弹窗:添加/修改 -->
|
|
||||||
<DeviceForm ref="formRef" @success="emit('refresh')" />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
<!-- 设备信息 -->
|
<script lang="ts" setup>
|
||||||
<script setup lang="ts">
|
|
||||||
import type { IotDeviceApi } from '#/api/iot/device/device';
|
import type { IotDeviceApi } from '#/api/iot/device/device';
|
||||||
import type { IotProductApi } from '#/api/iot/product/product';
|
import type { IotProductApi } from '#/api/iot/product/product';
|
||||||
|
|
||||||
@@ -24,51 +23,46 @@ import {
|
|||||||
import { getDeviceAuthInfo } from '#/api/iot/device/device';
|
import { getDeviceAuthInfo } from '#/api/iot/device/device';
|
||||||
import { DictTag } from '#/components/dict-tag';
|
import { DictTag } from '#/components/dict-tag';
|
||||||
|
|
||||||
// 消息提示
|
interface Props {
|
||||||
|
|
||||||
const { product, device } = defineProps<{
|
|
||||||
device: IotDeviceApi.Device;
|
device: IotDeviceApi.Device;
|
||||||
product: IotProductApi.Product;
|
product: IotProductApi.Product;
|
||||||
}>(); // 定义 Props
|
}
|
||||||
// const emit = defineEmits(['refresh']); // 定义 Emits
|
|
||||||
|
|
||||||
const authDialogVisible = ref(false); // 定义设备认证信息弹框的可见性
|
const props = defineProps<Props>();
|
||||||
const authPasswordVisible = ref(false); // 定义密码可见性状态
|
|
||||||
|
const authDialogVisible = ref(false);
|
||||||
|
const authPasswordVisible = ref(false);
|
||||||
const authInfo = ref<IotDeviceApi.DeviceAuthInfo>(
|
const authInfo = ref<IotDeviceApi.DeviceAuthInfo>(
|
||||||
{} as IotDeviceApi.DeviceAuthInfo,
|
{} as IotDeviceApi.DeviceAuthInfo,
|
||||||
); // 定义设备认证信息对象
|
);
|
||||||
|
|
||||||
/** 控制地图显示的标志 */
|
/** 控制地图显示的标志 */
|
||||||
const showMap = computed(() => {
|
const showMap = computed(() => {
|
||||||
return !!(device.longitude && device.latitude);
|
return !!(props.device.longitude && props.device.latitude);
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 复制到剪贴板方法 */
|
/** 复制到剪贴板 */
|
||||||
async function copyToClipboard(text: string) {
|
async function copyToClipboard(text: string) {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
message.success({ content: '复制成功' });
|
message.success('复制成功');
|
||||||
} catch {
|
} catch {
|
||||||
message.error({ content: '复制失败' });
|
message.error('复制失败');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 打开设备认证信息弹框的方法 */
|
/** 打开设备认证信息弹框 */
|
||||||
async function handleAuthInfoDialogOpen() {
|
async function handleAuthInfoDialogOpen() {
|
||||||
if (!device.id) return;
|
if (!props.device.id) return;
|
||||||
try {
|
try {
|
||||||
authInfo.value = await getDeviceAuthInfo(device.id);
|
authInfo.value = await getDeviceAuthInfo(props.device.id);
|
||||||
// 显示设备认证信息弹框
|
|
||||||
authDialogVisible.value = true;
|
authDialogVisible.value = true;
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('获取设备认证信息出错:', error);
|
message.error('获取设备认证信息失败,请检查网络连接或联系管理员');
|
||||||
message.error({
|
|
||||||
content: '获取设备认证信息失败,请检查网络连接或联系管理员',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 关闭设备认证信息弹框的方法 */
|
/** 关闭设备认证信息弹框 */
|
||||||
function handleAuthInfoDialogClose() {
|
function handleAuthInfoDialogClose() {
|
||||||
authDialogVisible.value = false;
|
authDialogVisible.value = false;
|
||||||
}
|
}
|
||||||
@@ -87,40 +81,40 @@ function handleAuthInfoDialogClose() {
|
|||||||
</template>
|
</template>
|
||||||
<Descriptions :column="1" bordered size="small">
|
<Descriptions :column="1" bordered size="small">
|
||||||
<Descriptions.Item label="产品名称">
|
<Descriptions.Item label="产品名称">
|
||||||
{{ product.name }}
|
{{ props.product.name }}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="ProductKey">
|
<Descriptions.Item label="ProductKey">
|
||||||
{{ product.productKey }}
|
{{ props.product.productKey }}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="设备类型">
|
<Descriptions.Item label="设备类型">
|
||||||
<DictTag
|
<DictTag
|
||||||
:type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE"
|
:type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE"
|
||||||
:value="product.deviceType"
|
:value="props.product.deviceType"
|
||||||
/>
|
/>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="DeviceName">
|
<Descriptions.Item label="DeviceName">
|
||||||
{{ device.deviceName }}
|
{{ props.device.deviceName }}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="备注名称">
|
<Descriptions.Item label="备注名称">
|
||||||
{{ device.nickname || '--' }}
|
{{ props.device.nickname || '--' }}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="当前状态">
|
<Descriptions.Item label="当前状态">
|
||||||
<DictTag
|
<DictTag
|
||||||
:type="DICT_TYPE.IOT_DEVICE_STATE"
|
:type="DICT_TYPE.IOT_DEVICE_STATE"
|
||||||
:value="device.state"
|
:value="props.device.state"
|
||||||
/>
|
/>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="创建时间">
|
<Descriptions.Item label="创建时间">
|
||||||
{{ formatDate(device.createTime) }}
|
{{ formatDate(props.device.createTime) }}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="激活时间">
|
<Descriptions.Item label="激活时间">
|
||||||
{{ formatDate(device.activeTime) }}
|
{{ formatDate(props.device.activeTime) }}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="最后上线时间">
|
<Descriptions.Item label="最后上线时间">
|
||||||
{{ formatDate(device.onlineTime) }}
|
{{ formatDate(props.device.onlineTime) }}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="最后离线时间">
|
<Descriptions.Item label="最后离线时间">
|
||||||
{{ formatDate(device.offlineTime) }}
|
{{ formatDate(props.device.offlineTime) }}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="MQTT 连接参数">
|
<Descriptions.Item label="MQTT 连接参数">
|
||||||
<Button
|
<Button
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import {
|
import {
|
||||||
computed,
|
computed,
|
||||||
onBeforeUnmount,
|
onBeforeUnmount,
|
||||||
@@ -21,14 +21,16 @@ import {
|
|||||||
Textarea,
|
Textarea,
|
||||||
} from 'ant-design-vue';
|
} from 'ant-design-vue';
|
||||||
|
|
||||||
import { DeviceStateEnum, sendDeviceMessage } from '#/api/iot/device/device';
|
import { DeviceStateEnum } from '@vben/constants';
|
||||||
import DataDefinition from '#/views/iot/thingmodel/modules/components/data-definition.vue';
|
|
||||||
|
import { sendDeviceMessage } from '#/api/iot/device/device';
|
||||||
|
import DataDefinition from '../../../../../thingmodel/modules/components/data-definition.vue';
|
||||||
import {
|
import {
|
||||||
IotDeviceMessageMethodEnum,
|
IotDeviceMessageMethodEnum,
|
||||||
IoTThingModelTypeEnum,
|
IoTThingModelTypeEnum,
|
||||||
} from '#/views/iot/utils/constants';
|
} from '#/views/iot/utils/constants';
|
||||||
|
|
||||||
import DeviceDetailsMessage from './device-details-message.vue';
|
import DeviceDetailsMessage from './message.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
device: IotDeviceApi.Device;
|
device: IotDeviceApi.Device;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- 设备事件管理 -->
|
<!-- 设备事件管理 -->
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import type { ThingModelData } from '#/api/iot/thingmodel';
|
import type { ThingModelData } from '#/api/iot/thingmodel';
|
||||||
|
|
||||||
import { computed, onMounted, reactive, ref } from 'vue';
|
import { computed, onMounted, reactive, ref } from 'vue';
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
<!-- 设备物模型 -> 运行状态 -> 查看数据(设备的属性值历史)-->
|
<!-- 设备物模型 -> 运行状态 -> 查看数据(设备的属性值历史)-->
|
||||||
// 重新关闭打开图表,图表不显示可能图例注销失败等大佬修复
|
<script lang="ts" setup>
|
||||||
<script setup lang="ts">
|
|
||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
|
|
||||||
import type { EchartsUIType } from '@vben/plugins/echarts';
|
import type { EchartsUIType } from '@vben/plugins/echarts';
|
||||||
@@ -11,14 +10,13 @@ import { computed, nextTick, reactive, ref, watch } from 'vue';
|
|||||||
|
|
||||||
import { IconifyIcon } from '@vben/icons';
|
import { IconifyIcon } from '@vben/icons';
|
||||||
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
||||||
import { beginOfDay, endOfDay, formatDate, formatDateTime } from '@vben/utils';
|
import { formatDate, formatDateTime } from '@vben/utils';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Empty,
|
Empty,
|
||||||
message,
|
message,
|
||||||
Modal,
|
Modal,
|
||||||
RangePicker,
|
|
||||||
Space,
|
Space,
|
||||||
Spin,
|
Spin,
|
||||||
Table,
|
Table,
|
||||||
@@ -26,6 +24,8 @@ import {
|
|||||||
} from 'ant-design-vue';
|
} from 'ant-design-vue';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
import ShortcutDateRangePicker from '#/components/shortcut-date-range-picker/shortcut-date-range-picker.vue';
|
||||||
|
|
||||||
import { getHistoryDevicePropertyList } from '#/api/iot/device/device';
|
import { getHistoryDevicePropertyList } from '#/api/iot/device/device';
|
||||||
import { IoTDataSpecsDataTypeEnum } from '#/views/iot/utils/constants';
|
import { IoTDataSpecsDataTypeEnum } from '#/views/iot/utils/constants';
|
||||||
|
|
||||||
@@ -42,18 +42,22 @@ const list = ref<IotDeviceApi.DevicePropertyDetail[]>([]); // 列表的数据
|
|||||||
const total = ref(0); // 总数据量
|
const total = ref(0); // 总数据量
|
||||||
const thingModelDataType = ref<string>(''); // 物模型数据类型
|
const thingModelDataType = ref<string>(''); // 物模型数据类型
|
||||||
const propertyIdentifier = ref<string>(''); // 属性标识符
|
const propertyIdentifier = ref<string>(''); // 属性标识符
|
||||||
const dateRange = ref<[Dayjs, Dayjs]>([
|
|
||||||
dayjs().subtract(7, 'day').startOf('day'),
|
/** 时间范围(仅日期,不包含时分秒) */
|
||||||
dayjs().endOf('day'),
|
const dateRange = ref<[string, string]>([
|
||||||
|
dayjs().subtract(6, 'day').format('YYYY-MM-DD'),
|
||||||
|
dayjs().format('YYYY-MM-DD'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/** 将日期范围转换为带时分秒的格式 */
|
||||||
|
function formatDateRangeWithTime(dates: [string, string]): [string, string] {
|
||||||
|
return [`${dates[0]} 00:00:00`, `${dates[1]} 23:59:59`];
|
||||||
|
}
|
||||||
|
|
||||||
const queryParams = reactive({
|
const queryParams = reactive({
|
||||||
deviceId: -1,
|
deviceId: -1,
|
||||||
identifier: '',
|
identifier: '',
|
||||||
times: [
|
times: formatDateRangeWithTime(dateRange.value),
|
||||||
formatDateTime(beginOfDay(new Date(Date.now() - 3600 * 1000 * 24 * 7))),
|
|
||||||
formatDateTime(endOfDay(new Date())),
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Echarts 相关
|
// Echarts 相关
|
||||||
@@ -142,16 +146,13 @@ async function getList() {
|
|||||||
) as IotDeviceApi.DevicePropertyDetail[];
|
) as IotDeviceApi.DevicePropertyDetail[];
|
||||||
total.value = list.value.length;
|
total.value = list.value.length;
|
||||||
|
|
||||||
// 如果是图表模式且不是复杂数据类型,渲染图表
|
// 如果是图表模式且不是复杂数据类型,等待渲染图表
|
||||||
if (
|
if (
|
||||||
viewMode.value === 'chart' &&
|
viewMode.value === 'chart' &&
|
||||||
!isComplexDataType.value &&
|
!isComplexDataType.value &&
|
||||||
list.value.length > 0
|
list.value.length > 0
|
||||||
) {
|
) {
|
||||||
// 等待 DOM 更新完成后再渲染图表
|
await renderChartWhenReady();
|
||||||
await nextTick();
|
|
||||||
await nextTick(); // 双重 nextTick 确保 DOM 完全准备好
|
|
||||||
renderChart();
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
message.error('获取数据失败');
|
message.error('获取数据失败');
|
||||||
@@ -162,126 +163,115 @@ async function getList() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 确保图表容器已经可见后再渲染 */
|
||||||
|
async function renderChartWhenReady() {
|
||||||
|
if (!list.value || list.value.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 等待 Modal、Card loading 状态、v-show 等 DOM 更新完成
|
||||||
|
await nextTick();
|
||||||
|
await nextTick();
|
||||||
|
renderChart();
|
||||||
|
}
|
||||||
|
|
||||||
/** 渲染图表 */
|
/** 渲染图表 */
|
||||||
function renderChart() {
|
function renderChart() {
|
||||||
if (!list.value || list.value.length === 0) {
|
if (!list.value || list.value.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const chartData = list.value.map((item) => [item.updateTime, item.value]);
|
const times = list.value.map((item) =>
|
||||||
|
formatDate(new Date(item.updateTime), 'YYYY-MM-DD HH:mm:ss'),
|
||||||
|
);
|
||||||
|
const values = list.value.map((item) => Number(item.value));
|
||||||
|
|
||||||
// 使用 setTimeout 延迟渲染,避免 ECharts 主进程冲突
|
renderEcharts({
|
||||||
setTimeout(() => {
|
title: {
|
||||||
// 检查 chartRef 是否存在且已挂载
|
text: '属性值趋势',
|
||||||
if (!chartRef.value || !chartRef.value.$el) {
|
left: 'center',
|
||||||
return;
|
textStyle: {
|
||||||
}
|
fontSize: 16,
|
||||||
|
fontWeight: 'normal',
|
||||||
renderEcharts({
|
|
||||||
title: {
|
|
||||||
text: '属性值趋势',
|
|
||||||
left: 'center',
|
|
||||||
textStyle: {
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: 'normal',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
grid: {
|
},
|
||||||
left: 60,
|
grid: {
|
||||||
right: 60,
|
left: 60,
|
||||||
bottom: 100,
|
right: 60,
|
||||||
top: 80,
|
bottom: 100,
|
||||||
containLabel: true,
|
top: 80,
|
||||||
|
containLabel: true,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
axisPointer: {
|
||||||
|
type: 'cross',
|
||||||
},
|
},
|
||||||
tooltip: {
|
},
|
||||||
trigger: 'axis',
|
xAxis: {
|
||||||
axisPointer: {
|
type: 'category',
|
||||||
type: 'cross',
|
boundaryGap: false,
|
||||||
},
|
name: '时间',
|
||||||
formatter: (params: any) => {
|
nameTextStyle: {
|
||||||
const param = params[0];
|
padding: [10, 0, 0, 0],
|
||||||
return `
|
|
||||||
<div style="padding: 8px;">
|
|
||||||
<div style="margin-bottom: 4px; font-weight: bold;">
|
|
||||||
${formatDate(new Date(param.value[0]), 'YYYY-MM-DD HH:mm:ss')}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span style="display:inline-block;margin-right:5px;border-radius:50%;width:10px;height:10px;background-color:${param.color};"></span>
|
|
||||||
<span>属性值: <strong>${param.value[1]}</strong></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
xAxis: {
|
data: times,
|
||||||
type: 'time',
|
},
|
||||||
name: '时间',
|
yAxis: {
|
||||||
nameTextStyle: {
|
type: 'value',
|
||||||
padding: [10, 0, 0, 0],
|
name: '属性值',
|
||||||
},
|
nameTextStyle: {
|
||||||
axisLabel: {
|
padding: [0, 0, 10, 0],
|
||||||
formatter: (value: number) => {
|
|
||||||
return String(formatDate(new Date(value), 'MM-DD HH:mm') || '');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
yAxis: {
|
},
|
||||||
type: 'value',
|
series: [
|
||||||
|
{
|
||||||
name: '属性值',
|
name: '属性值',
|
||||||
nameTextStyle: {
|
type: 'line',
|
||||||
padding: [0, 0, 10, 0],
|
smooth: true,
|
||||||
|
symbol: 'circle',
|
||||||
|
symbolSize: 6,
|
||||||
|
lineStyle: {
|
||||||
|
width: 2,
|
||||||
|
color: '#1890FF',
|
||||||
},
|
},
|
||||||
|
itemStyle: {
|
||||||
|
color: '#1890FF',
|
||||||
|
},
|
||||||
|
areaStyle: {
|
||||||
|
color: {
|
||||||
|
type: 'linear',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
x2: 0,
|
||||||
|
y2: 1,
|
||||||
|
colorStops: [
|
||||||
|
{
|
||||||
|
offset: 0,
|
||||||
|
color: 'rgba(24, 144, 255, 0.3)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
offset: 1,
|
||||||
|
color: 'rgba(24, 144, 255, 0.05)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: values,
|
||||||
},
|
},
|
||||||
series: [
|
],
|
||||||
{
|
dataZoom: [
|
||||||
name: '属性值',
|
{
|
||||||
type: 'line',
|
type: 'inside',
|
||||||
smooth: true,
|
start: 0,
|
||||||
symbol: 'circle',
|
end: 100,
|
||||||
symbolSize: 6,
|
},
|
||||||
lineStyle: {
|
{
|
||||||
width: 2,
|
type: 'slider',
|
||||||
color: '#1890FF',
|
height: 30,
|
||||||
},
|
bottom: 20,
|
||||||
itemStyle: {
|
},
|
||||||
color: '#1890FF',
|
],
|
||||||
},
|
});
|
||||||
areaStyle: {
|
|
||||||
color: {
|
|
||||||
type: 'linear',
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
x2: 0,
|
|
||||||
y2: 1,
|
|
||||||
colorStops: [
|
|
||||||
{
|
|
||||||
offset: 0,
|
|
||||||
color: 'rgba(24, 144, 255, 0.3)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
offset: 1,
|
|
||||||
color: 'rgba(24, 144, 255, 0.05)',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
data: chartData,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
dataZoom: [
|
|
||||||
{
|
|
||||||
type: 'inside',
|
|
||||||
start: 0,
|
|
||||||
end: 100,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'slider',
|
|
||||||
height: 30,
|
|
||||||
bottom: 20,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}, 300); // 延迟300ms渲染,确保 DOM 完全准备好
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 打开弹窗 */
|
/** 打开弹窗 */
|
||||||
@@ -294,42 +284,33 @@ async function open(deviceId: number, identifier: string, dataType: string) {
|
|||||||
|
|
||||||
// 重置时间范围为最近7天
|
// 重置时间范围为最近7天
|
||||||
dateRange.value = [
|
dateRange.value = [
|
||||||
dayjs().subtract(7, 'day').startOf('day'),
|
dayjs().subtract(6, 'day').format('YYYY-MM-DD'),
|
||||||
dayjs().endOf('day'),
|
dayjs().format('YYYY-MM-DD'),
|
||||||
];
|
];
|
||||||
|
|
||||||
// 更新查询参数的时间
|
// 更新查询参数的时间
|
||||||
queryParams.times = [
|
queryParams.times = formatDateRangeWithTime(dateRange.value);
|
||||||
formatDateTime(dateRange.value[0].toDate()),
|
|
||||||
formatDateTime(dateRange.value[1].toDate()),
|
|
||||||
];
|
|
||||||
|
|
||||||
// 如果物模型是 struct、array,需要默认使用 list 模式
|
// 如果物模型是 struct、array,需要默认使用 list 模式
|
||||||
viewMode.value = isComplexDataType.value ? 'list' : 'chart';
|
viewMode.value = isComplexDataType.value ? 'list' : 'chart';
|
||||||
|
|
||||||
// 等待弹窗完全渲染后再获取数据
|
// 等待弹窗完全渲染后再获取数据
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
await nextTick(); // 双重 nextTick 确保 Modal 完全渲染
|
||||||
await getList();
|
await getList();
|
||||||
|
|
||||||
// 如果是图表模式,延迟渲染图表
|
|
||||||
if (viewMode.value === 'chart' && !isComplexDataType.value) {
|
|
||||||
setTimeout(() => {
|
|
||||||
renderChart();
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 时间变化处理 */
|
/** 处理时间范围变化 */
|
||||||
function handleTimeChange() {
|
function handleDateRangeChange(times?: [Dayjs, Dayjs]) {
|
||||||
if (!dateRange.value || dateRange.value.length !== 2) {
|
if (!times || times.length !== 2) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
dateRange.value = [
|
||||||
queryParams.times = [
|
dayjs(times[0]).format('YYYY-MM-DD'),
|
||||||
formatDateTime(dateRange.value[0].toDate()),
|
dayjs(times[1]).format('YYYY-MM-DD'),
|
||||||
formatDateTime(dateRange.value[1].toDate()),
|
|
||||||
];
|
];
|
||||||
|
// 将选择的日期转换为带时分秒的格式(开始日期 00:00:00,结束日期 23:59:59)
|
||||||
|
queryParams.times = formatDateRangeWithTime(dateRange.value);
|
||||||
getList();
|
getList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,14 +389,7 @@ watch(viewMode, async (newMode) => {
|
|||||||
!isComplexDataType.value &&
|
!isComplexDataType.value &&
|
||||||
list.value.length > 0
|
list.value.length > 0
|
||||||
) {
|
) {
|
||||||
// 等待 DOM 显示完成
|
await renderChartWhenReady();
|
||||||
await nextTick();
|
|
||||||
await nextTick();
|
|
||||||
|
|
||||||
// 延迟渲染图表
|
|
||||||
setTimeout(() => {
|
|
||||||
renderChart();
|
|
||||||
}, 300);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -434,14 +408,10 @@ defineExpose({ open }); // 提供 open 方法,用于打开弹窗
|
|||||||
<div class="toolbar-wrapper mb-4">
|
<div class="toolbar-wrapper mb-4">
|
||||||
<Space :size="12" class="w-full" wrap>
|
<Space :size="12" class="w-full" wrap>
|
||||||
<!-- 时间选择 -->
|
<!-- 时间选择 -->
|
||||||
<RangePicker
|
<div class="flex items-center gap-3">
|
||||||
v-model:value="dateRange"
|
<span class="whitespace-nowrap text-sm text-gray-500">时间范围</span>
|
||||||
:show-time="{ format: 'HH:mm:ss' }"
|
<ShortcutDateRangePicker @change="handleDateRangeChange" />
|
||||||
format="YYYY-MM-DD HH:mm:ss"
|
</div>
|
||||||
:placeholder="['开始时间', '结束时间']"
|
|
||||||
class="!w-[400px]"
|
|
||||||
@change="handleTimeChange"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 刷新按钮 -->
|
<!-- 刷新按钮 -->
|
||||||
<Button @click="handleRefresh" :loading="loading">
|
<Button @click="handleRefresh" :loading="loading">
|
||||||
@@ -501,7 +471,7 @@ defineExpose({ open }); // 提供 open 方法,用于打开弹窗
|
|||||||
|
|
||||||
<!-- 数据展示区域 -->
|
<!-- 数据展示区域 -->
|
||||||
<Spin :spinning="loading" :delay="200">
|
<Spin :spinning="loading" :delay="200">
|
||||||
<!-- 图表模式 -->
|
<!-- 图表模式 - 使用 v-show 确保图表组件始终挂载 -->
|
||||||
<div v-show="viewMode === 'chart'" class="chart-container">
|
<div v-show="viewMode === 'chart'" class="chart-container">
|
||||||
<Empty
|
<Empty
|
||||||
v-if="list.length === 0"
|
v-if="list.length === 0"
|
||||||
@@ -509,7 +479,7 @@ defineExpose({ open }); // 提供 open 方法,用于打开弹窗
|
|||||||
description="暂无数据"
|
description="暂无数据"
|
||||||
class="py-20"
|
class="py-20"
|
||||||
/>
|
/>
|
||||||
<div v-else>
|
<div v-show="list.length > 0">
|
||||||
<EchartsUI ref="chartRef" height="500px" />
|
<EchartsUI ref="chartRef" height="500px" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- 设备属性管理 -->
|
<!-- 设备属性管理 -->
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import type { IotDeviceApi } from '#/api/iot/device/device';
|
import type { IotDeviceApi } from '#/api/iot/device/device';
|
||||||
|
|
||||||
import { onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
import { onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
|
|
||||||
import { getLatestDeviceProperties } from '#/api/iot/device/device';
|
import { getLatestDeviceProperties } from '#/api/iot/device/device';
|
||||||
|
|
||||||
import DeviceDetailsThingModelPropertyHistory from './device-details-thing-model-property-history.vue';
|
import DeviceDetailsThingModelPropertyHistory from './thing-model-property-history.vue';
|
||||||
|
|
||||||
const props = defineProps<{ deviceId: number }>();
|
const props = defineProps<{ deviceId: number }>();
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- 设备服务调用 -->
|
<!-- 设备服务调用 -->
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import type { ThingModelData } from '#/api/iot/thingmodel';
|
import type { ThingModelData } from '#/api/iot/thingmodel';
|
||||||
|
|
||||||
import { computed, onMounted, reactive, ref } from 'vue';
|
import { computed, onMounted, reactive, ref } from 'vue';
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- 设备物模型:设备属性、事件管理、服务调用 -->
|
<!-- 设备物模型:设备属性、事件管理、服务调用 -->
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import type { ThingModelData } from '#/api/iot/thingmodel';
|
import type { ThingModelData } from '#/api/iot/thingmodel';
|
||||||
|
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
@@ -8,9 +8,9 @@ import { ContentWrap } from '@vben/common-ui';
|
|||||||
|
|
||||||
import { Tabs } from 'ant-design-vue';
|
import { Tabs } from 'ant-design-vue';
|
||||||
|
|
||||||
import DeviceDetailsThingModelEvent from './device-details-thing-model-event.vue';
|
import DeviceDetailsThingModelEvent from './thing-model-event.vue';
|
||||||
import DeviceDetailsThingModelProperty from './device-details-thing-model-property.vue';
|
import DeviceDetailsThingModelProperty from './thing-model-property.vue';
|
||||||
import DeviceDetailsThingModelService from './device-details-thing-model-service.vue';
|
import DeviceDetailsThingModelService from './thing-model-service.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
deviceId: number;
|
deviceId: number;
|
||||||
@@ -1,619 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { onMounted, ref } from 'vue';
|
|
||||||
|
|
||||||
import { DICT_TYPE } from '@vben/constants';
|
|
||||||
import { getDictLabel, getDictObj } from '@vben/hooks';
|
|
||||||
import { IconifyIcon } from '@vben/icons';
|
|
||||||
import { isValidColor, TinyColor } from '@vben/utils';
|
|
||||||
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Col,
|
|
||||||
Empty,
|
|
||||||
Pagination,
|
|
||||||
Popconfirm,
|
|
||||||
Row,
|
|
||||||
Tag,
|
|
||||||
} from 'ant-design-vue';
|
|
||||||
|
|
||||||
import { DeviceStateEnum, getDevicePage } from '#/api/iot/device/device';
|
|
||||||
|
|
||||||
defineOptions({ name: 'DeviceCardView' });
|
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
create: [];
|
|
||||||
delete: [row: any];
|
|
||||||
detail: [id: number];
|
|
||||||
edit: [row: any];
|
|
||||||
model: [id: number];
|
|
||||||
productDetail: [productId: number];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
products: any[];
|
|
||||||
deviceGroups: any[];
|
|
||||||
searchParams?: {
|
|
||||||
deviceName: string;
|
|
||||||
deviceType?: number;
|
|
||||||
groupId?: number;
|
|
||||||
nickname: string;
|
|
||||||
productId?: number;
|
|
||||||
status?: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const loading = ref(false);
|
|
||||||
const list = ref<any[]>([]);
|
|
||||||
const total = ref(0);
|
|
||||||
const queryParams = ref({
|
|
||||||
pageNo: 1,
|
|
||||||
pageSize: 12,
|
|
||||||
});
|
|
||||||
|
|
||||||
const DEFAULT_STATUS_MAP: Record<
|
|
||||||
'default' | number,
|
|
||||||
{ bgColor: string; borderColor: string; color: string; text: string }
|
|
||||||
> = {
|
|
||||||
[DeviceStateEnum.ONLINE]: {
|
|
||||||
text: '在线',
|
|
||||||
color: '#52c41a',
|
|
||||||
bgColor: '#f6ffed',
|
|
||||||
borderColor: '#b7eb8f',
|
|
||||||
},
|
|
||||||
[DeviceStateEnum.OFFLINE]: {
|
|
||||||
text: '离线',
|
|
||||||
color: '#faad14',
|
|
||||||
bgColor: '#fffbe6',
|
|
||||||
borderColor: '#ffe58f',
|
|
||||||
},
|
|
||||||
[DeviceStateEnum.INACTIVE]: {
|
|
||||||
text: '未激活',
|
|
||||||
color: '#ff4d4f',
|
|
||||||
bgColor: '#fff1f0',
|
|
||||||
borderColor: '#ffccc7',
|
|
||||||
},
|
|
||||||
default: {
|
|
||||||
text: '未知状态',
|
|
||||||
color: '#595959',
|
|
||||||
bgColor: '#fafafa',
|
|
||||||
borderColor: '#d9d9d9',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const COLOR_TYPE_PRESETS: Record<
|
|
||||||
string,
|
|
||||||
{ bgColor: string; borderColor: string; color: string }
|
|
||||||
> = {
|
|
||||||
success: {
|
|
||||||
color: '#52c41a',
|
|
||||||
bgColor: '#f6ffed',
|
|
||||||
borderColor: '#b7eb8f',
|
|
||||||
},
|
|
||||||
processing: {
|
|
||||||
color: '#1890ff',
|
|
||||||
bgColor: '#e6f7ff',
|
|
||||||
borderColor: '#91d5ff',
|
|
||||||
},
|
|
||||||
warning: {
|
|
||||||
color: '#faad14',
|
|
||||||
bgColor: '#fffbe6',
|
|
||||||
borderColor: '#ffe58f',
|
|
||||||
},
|
|
||||||
error: {
|
|
||||||
color: '#ff4d4f',
|
|
||||||
bgColor: '#fff1f0',
|
|
||||||
borderColor: '#ffccc7',
|
|
||||||
},
|
|
||||||
default: {
|
|
||||||
color: '#595959',
|
|
||||||
bgColor: '#fafafa',
|
|
||||||
borderColor: '#d9d9d9',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeColorType(colorType?: string) {
|
|
||||||
switch (colorType) {
|
|
||||||
case 'danger': {
|
|
||||||
return 'error';
|
|
||||||
}
|
|
||||||
case 'default':
|
|
||||||
case 'error':
|
|
||||||
case 'processing':
|
|
||||||
case 'success':
|
|
||||||
case 'warning': {
|
|
||||||
return colorType;
|
|
||||||
}
|
|
||||||
case 'info': {
|
|
||||||
return 'default';
|
|
||||||
}
|
|
||||||
case 'primary': {
|
|
||||||
return 'processing';
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
return 'default';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取产品名称
|
|
||||||
function getProductName(productId: number) {
|
|
||||||
const product = props.products.find((p: any) => p.id === productId);
|
|
||||||
return product?.name || '-';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取设备列表
|
|
||||||
async function getList() {
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
const data = await getDevicePage({
|
|
||||||
...queryParams.value,
|
|
||||||
...props.searchParams,
|
|
||||||
});
|
|
||||||
list.value = data.list || [];
|
|
||||||
total.value = data.total || 0;
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理页码变化
|
|
||||||
function handlePageChange(page: number, pageSize: number) {
|
|
||||||
queryParams.value.pageNo = page;
|
|
||||||
queryParams.value.pageSize = pageSize;
|
|
||||||
getList();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取设备类型颜色
|
|
||||||
function getDeviceTypeColor(deviceType: number) {
|
|
||||||
const colors: Record<number, string> = {
|
|
||||||
0: 'blue',
|
|
||||||
1: 'cyan',
|
|
||||||
};
|
|
||||||
return colors[deviceType] || 'default';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取设备状态信息
|
|
||||||
function getStatusInfo(state: null | number | string | undefined) {
|
|
||||||
const parsedState = Number(state);
|
|
||||||
const hasNumericState = Number.isFinite(parsedState);
|
|
||||||
const fallback = hasNumericState
|
|
||||||
? DEFAULT_STATUS_MAP[parsedState] || DEFAULT_STATUS_MAP.default
|
|
||||||
: DEFAULT_STATUS_MAP.default;
|
|
||||||
const dict = getDictObj(
|
|
||||||
DICT_TYPE.IOT_DEVICE_STATE,
|
|
||||||
hasNumericState ? parsedState : state,
|
|
||||||
);
|
|
||||||
if (dict) {
|
|
||||||
if (!dict.colorType && !dict.cssClass) {
|
|
||||||
return {
|
|
||||||
...fallback,
|
|
||||||
text: dict.label || fallback.text,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const presetKey = normalizeColorType(dict.colorType);
|
|
||||||
if (isValidColor(dict.cssClass)) {
|
|
||||||
const baseColor = new TinyColor(dict.cssClass);
|
|
||||||
return {
|
|
||||||
text: dict.label || fallback.text,
|
|
||||||
color: baseColor.toHexString(),
|
|
||||||
bgColor: baseColor.clone().setAlpha(0.15).toRgbString(),
|
|
||||||
borderColor: baseColor.clone().lighten(30).toHexString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const preset = COLOR_TYPE_PRESETS[presetKey] || COLOR_TYPE_PRESETS.default;
|
|
||||||
return {
|
|
||||||
text: dict.label || fallback.text,
|
|
||||||
...preset,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
getList();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 暴露方法供父组件调用
|
|
||||||
defineExpose({
|
|
||||||
reload: getList,
|
|
||||||
search: () => {
|
|
||||||
queryParams.value.pageNo = 1;
|
|
||||||
getList();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="device-card-view">
|
|
||||||
<!-- 设备卡片列表 -->
|
|
||||||
<div v-loading="loading" class="min-h-[400px]">
|
|
||||||
<Row v-if="list.length > 0" :gutter="[16, 16]">
|
|
||||||
<Col
|
|
||||||
v-for="item in list"
|
|
||||||
:key="item.id"
|
|
||||||
:xs="24"
|
|
||||||
:sm="12"
|
|
||||||
:md="8"
|
|
||||||
:lg="6"
|
|
||||||
>
|
|
||||||
<Card
|
|
||||||
:body-style="{ padding: 0 }"
|
|
||||||
class="device-card"
|
|
||||||
:bordered="false"
|
|
||||||
>
|
|
||||||
<!-- 卡片内容 -->
|
|
||||||
<div class="card-content">
|
|
||||||
<!-- 头部:图标和状态 -->
|
|
||||||
<div class="card-header">
|
|
||||||
<div class="device-icon">
|
|
||||||
<IconifyIcon icon="mdi:chip" />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="status-badge"
|
|
||||||
:style="{
|
|
||||||
color: getStatusInfo(item.state).color,
|
|
||||||
backgroundColor: getStatusInfo(item.state).bgColor,
|
|
||||||
borderColor: getStatusInfo(item.state).borderColor,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<span class="status-dot"></span>
|
|
||||||
{{ getStatusInfo(item.state).text }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 设备名称 -->
|
|
||||||
<div class="device-name" :title="item.deviceName">
|
|
||||||
{{ item.deviceName }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 信息区域 -->
|
|
||||||
<div class="info-section">
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="label">所属产品</span>
|
|
||||||
<a
|
|
||||||
class="value link"
|
|
||||||
@click="
|
|
||||||
(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
emit('productDetail', item.productId);
|
|
||||||
}
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{ getProductName(item.productId) }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="label">设备类型</span>
|
|
||||||
<Tag
|
|
||||||
:color="getDeviceTypeColor(item.deviceType)"
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
getDictLabel(
|
|
||||||
DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE,
|
|
||||||
item.deviceType,
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</Tag>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="label">Deviceid</span>
|
|
||||||
<span class="value code" :title="item.Deviceid || item.id">
|
|
||||||
{{ item.Deviceid || item.id }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 操作按钮 -->
|
|
||||||
<div class="action-bar">
|
|
||||||
<Button
|
|
||||||
type="default"
|
|
||||||
size="small"
|
|
||||||
class="action-btn btn-edit"
|
|
||||||
@click="
|
|
||||||
(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
emit('edit', item);
|
|
||||||
}
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<IconifyIcon icon="ph:note-pencil" />
|
|
||||||
编辑
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="default"
|
|
||||||
size="small"
|
|
||||||
class="action-btn btn-view"
|
|
||||||
@click="
|
|
||||||
(e: MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
emit('detail', item.id);
|
|
||||||
}
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<IconifyIcon icon="ph:eye" />
|
|
||||||
详情
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="default"
|
|
||||||
size="small"
|
|
||||||
class="action-btn btn-data"
|
|
||||||
@click="
|
|
||||||
(e: MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
emit('model', item.id);
|
|
||||||
}
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<IconifyIcon icon="ph:database" />
|
|
||||||
数据
|
|
||||||
</Button>
|
|
||||||
<Popconfirm
|
|
||||||
title="确认删除该设备吗?"
|
|
||||||
@confirm="() => emit('delete', item)"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="default"
|
|
||||||
size="small"
|
|
||||||
class="action-btn btn-delete"
|
|
||||||
@click="(e: MouseEvent) => e.stopPropagation()"
|
|
||||||
>
|
|
||||||
<IconifyIcon icon="ph:trash" />
|
|
||||||
</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<!-- 空状态 -->
|
|
||||||
<Empty v-else description="暂无设备数据" class="my-20" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 分页 -->
|
|
||||||
<div v-if="list.length > 0" class="mt-6 flex justify-center">
|
|
||||||
<Pagination
|
|
||||||
v-model:current="queryParams.pageNo"
|
|
||||||
v-model:page-size="queryParams.pageSize"
|
|
||||||
:total="total"
|
|
||||||
:show-total="(total) => `共 ${total} 条`"
|
|
||||||
show-quick-jumper
|
|
||||||
show-size-changer
|
|
||||||
:page-size-options="['12', '24', '36', '48']"
|
|
||||||
@change="handlePageChange"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.device-card-view {
|
|
||||||
.device-card {
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
background: hsl(var(--card) / 95%);
|
|
||||||
border: 1px solid hsl(var(--border) / 60%);
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow:
|
|
||||||
0 1px 2px 0 hsl(var(--foreground) / 4%),
|
|
||||||
0 1px 6px -1px hsl(var(--foreground) / 5%),
|
|
||||||
0 2px 4px 0 hsl(var(--foreground) / 5%);
|
|
||||||
transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: hsl(var(--border));
|
|
||||||
box-shadow:
|
|
||||||
0 1px 2px -2px hsl(var(--foreground) / 12%),
|
|
||||||
0 3px 6px 0 hsl(var(--foreground) / 10%),
|
|
||||||
0 5px 12px 4px hsl(var(--foreground) / 8%);
|
|
||||||
transform: translateY(-4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ant-card-body) {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 头部区域
|
|
||||||
.card-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
|
|
||||||
.device-icon {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
font-size: 18px;
|
|
||||||
color: #fff;
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
border-radius: 6px;
|
|
||||||
box-shadow: 0 2px 8px rgb(102 126 234 / 25%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
align-items: center;
|
|
||||||
padding: 2px 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 18px;
|
|
||||||
border: 1px solid;
|
|
||||||
border-radius: 12px;
|
|
||||||
|
|
||||||
.status-dot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
background: currentcolor;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设备名称
|
|
||||||
.device-name {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 24px;
|
|
||||||
color: hsl(var(--foreground) / 90%);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 信息区域
|
|
||||||
.info-section {
|
|
||||||
flex: 1;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
|
|
||||||
.info-item {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 13px;
|
|
||||||
color: hsl(var(--foreground) / 60%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.value {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
font-size: 13px;
|
|
||||||
color: hsl(var(--foreground) / 85%);
|
|
||||||
text-align: right;
|
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
&.link {
|
|
||||||
color: hsl(var(--primary));
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color 0.2s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: hsl(var(--primary) / 85%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.code {
|
|
||||||
font-family:
|
|
||||||
'SF Mono', Monaco, Inconsolata, 'Fira Code', Consolas, monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: hsl(var(--foreground) / 60%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 操作按钮栏
|
|
||||||
.action-bar {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
padding-top: 12px;
|
|
||||||
border-top: 1px solid hsl(var(--border) / 40%);
|
|
||||||
|
|
||||||
.action-btn {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
gap: 4px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 32px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 400;
|
|
||||||
pointer-events: auto;
|
|
||||||
cursor: pointer;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: 6px;
|
|
||||||
transition: all 0.2s;
|
|
||||||
|
|
||||||
:deep(.anticon) {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.btn-edit {
|
|
||||||
color: hsl(var(--primary));
|
|
||||||
background: hsl(var(--primary) / 12%);
|
|
||||||
border-color: hsl(var(--primary) / 25%);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: hsl(var(--primary-foreground));
|
|
||||||
background: hsl(var(--primary));
|
|
||||||
border-color: hsl(var(--primary));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.btn-view {
|
|
||||||
color: hsl(var(--warning));
|
|
||||||
background: hsl(var(--warning) / 12%);
|
|
||||||
border-color: hsl(var(--warning) / 25%);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: #fff;
|
|
||||||
background: hsl(var(--warning));
|
|
||||||
border-color: hsl(var(--warning));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.btn-data {
|
|
||||||
color: hsl(var(--accent-foreground));
|
|
||||||
background: color-mix(
|
|
||||||
in srgb,
|
|
||||||
hsl(var(--accent)) 40%,
|
|
||||||
hsl(var(--card)) 60%
|
|
||||||
);
|
|
||||||
border-color: color-mix(in srgb, hsl(var(--accent)) 55%, transparent);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: hsl(var(--accent-foreground));
|
|
||||||
background: hsl(var(--accent));
|
|
||||||
border-color: hsl(var(--accent));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.btn-delete {
|
|
||||||
flex: 0 0 32px;
|
|
||||||
padding: 4px;
|
|
||||||
color: hsl(var(--destructive));
|
|
||||||
background: hsl(var(--destructive) / 12%);
|
|
||||||
border-color: hsl(var(--destructive) / 30%);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: hsl(var(--destructive-foreground));
|
|
||||||
background: hsl(var(--destructive));
|
|
||||||
border-color: hsl(var(--destructive));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue';
|
|
||||||
|
|
||||||
import { useVbenForm, useVbenModal } from '@vben/common-ui';
|
|
||||||
import { downloadFileFromBlobPart } from '@vben/utils';
|
|
||||||
|
|
||||||
import { message } from 'ant-design-vue';
|
|
||||||
|
|
||||||
import { importDeviceTemplate } from '#/api/iot/device/device';
|
|
||||||
|
|
||||||
import { useImportFormSchema } from '../data';
|
|
||||||
|
|
||||||
defineOptions({ name: 'IoTDeviceImportForm' });
|
|
||||||
|
|
||||||
const emit = defineEmits(['success']);
|
|
||||||
const getTitle = computed(() => '设备导入');
|
|
||||||
|
|
||||||
const [Form, formApi] = useVbenForm({
|
|
||||||
commonConfig: {
|
|
||||||
componentProps: {
|
|
||||||
class: 'w-full',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
layout: 'horizontal',
|
|
||||||
schema: useImportFormSchema(),
|
|
||||||
showDefaultActions: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [Modal, modalApi] = useVbenModal({
|
|
||||||
async onConfirm() {
|
|
||||||
const { valid } = await formApi.validate();
|
|
||||||
if (!valid) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const values = await formApi.getValues();
|
|
||||||
const file = values.file;
|
|
||||||
|
|
||||||
if (!file || file.length === 0) {
|
|
||||||
message.error('请上传文件');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
modalApi.lock();
|
|
||||||
try {
|
|
||||||
// 构建表单数据
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file[0].originFileObj);
|
|
||||||
formData.append('updateSupport', values.updateSupport ? 'true' : 'false');
|
|
||||||
|
|
||||||
// 使用 fetch 上传文件
|
|
||||||
const accessToken = localStorage.getItem('accessToken') || '';
|
|
||||||
const response = await fetch(
|
|
||||||
`${import.meta.env.VITE_GLOB_API_URL}/iot/device/import?updateSupport=${values.updateSupport}`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
},
|
|
||||||
body: formData,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.code !== 0) {
|
|
||||||
message.error(result.msg || '导入失败');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 拼接提示语
|
|
||||||
const data = result.data;
|
|
||||||
let text = `上传成功数量:${data.createDeviceNames?.length || 0};`;
|
|
||||||
if (data.createDeviceNames) {
|
|
||||||
for (const deviceName of data.createDeviceNames) {
|
|
||||||
text += `< ${deviceName} >`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
text += `更新成功数量:${data.updateDeviceNames?.length || 0};`;
|
|
||||||
if (data.updateDeviceNames) {
|
|
||||||
for (const deviceName of data.updateDeviceNames) {
|
|
||||||
text += `< ${deviceName} >`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
text += `更新失败数量:${Object.keys(data.failureDeviceNames || {}).length};`;
|
|
||||||
if (data.failureDeviceNames) {
|
|
||||||
for (const deviceName in data.failureDeviceNames) {
|
|
||||||
text += `< ${deviceName}: ${data.failureDeviceNames[deviceName]} >`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
message.info(text);
|
|
||||||
|
|
||||||
// 关闭并提示
|
|
||||||
await modalApi.close();
|
|
||||||
emit('success');
|
|
||||||
} catch (error: any) {
|
|
||||||
message.error(error.message || '导入失败');
|
|
||||||
} finally {
|
|
||||||
modalApi.unlock();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async onOpenChange(isOpen: boolean) {
|
|
||||||
if (isOpen) {
|
|
||||||
// 重置表单
|
|
||||||
await formApi.resetForm();
|
|
||||||
await formApi.setValues({
|
|
||||||
updateSupport: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
/** 下载模板 */
|
|
||||||
async function handleDownloadTemplate() {
|
|
||||||
try {
|
|
||||||
const res = await importDeviceTemplate();
|
|
||||||
downloadFileFromBlobPart({ fileName: '设备导入模版.xls', source: res });
|
|
||||||
} catch (error: any) {
|
|
||||||
message.error(error.message || '下载失败');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Modal :title="getTitle" class="w-1/3">
|
|
||||||
<Form class="mx-4" />
|
|
||||||
<div class="mx-4 mt-4 text-center">
|
|
||||||
<a class="cursor-pointer text-primary" @click="handleDownloadTemplate">
|
|
||||||
下载导入模板
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
</template>
|
|
||||||
@@ -3,11 +3,16 @@ import type { IotDeviceApi } from '#/api/iot/device/device';
|
|||||||
|
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
import { useVbenForm, useVbenModal } from '@vben/common-ui';
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
import { message } from 'ant-design-vue';
|
import { message } from 'ant-design-vue';
|
||||||
|
|
||||||
import { createDevice, getDevice, updateDevice } from '#/api/iot/device/device';
|
import { useVbenForm } from '#/adapter/form';
|
||||||
|
import {
|
||||||
|
createDevice,
|
||||||
|
getDevice,
|
||||||
|
updateDevice,
|
||||||
|
} from '#/api/iot/device/device';
|
||||||
import { $t } from '#/locales';
|
import { $t } from '#/locales';
|
||||||
|
|
||||||
import { useFormSchema } from '../data';
|
import { useFormSchema } from '../data';
|
||||||
@@ -15,9 +20,11 @@ import { useFormSchema } from '../data';
|
|||||||
defineOptions({ name: 'IoTDeviceForm' });
|
defineOptions({ name: 'IoTDeviceForm' });
|
||||||
|
|
||||||
const emit = defineEmits(['success']);
|
const emit = defineEmits(['success']);
|
||||||
const formData = ref<any>();
|
const formData = ref<IotDeviceApi.Device>();
|
||||||
const getTitle = computed(() => {
|
const getTitle = computed(() => {
|
||||||
return formData.value?.id ? '编辑设备' : '新增设备';
|
return formData.value?.id
|
||||||
|
? $t('ui.actionTitle.edit', ['设备'])
|
||||||
|
: $t('ui.actionTitle.create', ['设备']);
|
||||||
});
|
});
|
||||||
|
|
||||||
const [Form, formApi] = useVbenForm({
|
const [Form, formApi] = useVbenForm({
|
||||||
@@ -33,6 +40,7 @@ const [Form, formApi] = useVbenForm({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [Modal, modalApi] = useVbenModal({
|
const [Modal, modalApi] = useVbenModal({
|
||||||
|
/** 提交表单 */
|
||||||
async onConfirm() {
|
async onConfirm() {
|
||||||
const { valid } = await formApi.validate();
|
const { valid } = await formApi.validate();
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
@@ -43,7 +51,6 @@ const [Modal, modalApi] = useVbenModal({
|
|||||||
const data = (await formApi.getValues()) as IotDeviceApi.Device;
|
const data = (await formApi.getValues()) as IotDeviceApi.Device;
|
||||||
try {
|
try {
|
||||||
await (formData.value?.id ? updateDevice(data) : createDevice(data));
|
await (formData.value?.id ? updateDevice(data) : createDevice(data));
|
||||||
// 关闭并提示
|
|
||||||
await modalApi.close();
|
await modalApi.close();
|
||||||
emit('success');
|
emit('success');
|
||||||
message.success($t('ui.actionMessage.operationSuccess'));
|
message.success($t('ui.actionMessage.operationSuccess'));
|
||||||
@@ -51,20 +58,23 @@ const [Modal, modalApi] = useVbenModal({
|
|||||||
modalApi.unlock();
|
modalApi.unlock();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
/** 弹窗打开/关闭 */
|
||||||
async onOpenChange(isOpen: boolean) {
|
async onOpenChange(isOpen: boolean) {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
formData.value = undefined;
|
formData.value = undefined;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 加载数据
|
// 加载数据
|
||||||
const data = modalApi.getData<any>();
|
const data = modalApi.getData<IotDeviceApi.Device>();
|
||||||
if (!data || !data.id) {
|
if (!data || !data.id) {
|
||||||
|
// 新增模式:设置默认值(如果需要)
|
||||||
|
formData.value = undefined;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 编辑模式:加载数据
|
||||||
modalApi.lock();
|
modalApi.lock();
|
||||||
try {
|
try {
|
||||||
formData.value = await getDevice(data.id);
|
formData.value = await getDevice(data.id);
|
||||||
// 设置到 values
|
|
||||||
await formApi.setValues(formData.value);
|
await formApi.setValues(formData.value);
|
||||||
} finally {
|
} finally {
|
||||||
modalApi.unlock();
|
modalApi.unlock();
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
import { useVbenForm, useVbenModal } from '@vben/common-ui';
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
import { message } from 'ant-design-vue';
|
import { message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { useVbenForm } from '#/adapter/form';
|
||||||
import { updateDeviceGroup } from '#/api/iot/device/device';
|
import { updateDeviceGroup } from '#/api/iot/device/device';
|
||||||
import { $t } from '#/locales';
|
import { $t } from '#/locales';
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ const [Form, formApi] = useVbenForm({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [Modal, modalApi] = useVbenModal({
|
const [Modal, modalApi] = useVbenModal({
|
||||||
|
/** 提交表单 */
|
||||||
async onConfirm() {
|
async onConfirm() {
|
||||||
const { valid } = await formApi.validate();
|
const { valid } = await formApi.validate();
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
@@ -41,7 +43,6 @@ const [Modal, modalApi] = useVbenModal({
|
|||||||
ids: deviceIds.value,
|
ids: deviceIds.value,
|
||||||
groupIds: data.groupIds as number[],
|
groupIds: data.groupIds as number[],
|
||||||
});
|
});
|
||||||
// 关闭并提示
|
|
||||||
await modalApi.close();
|
await modalApi.close();
|
||||||
emit('success');
|
emit('success');
|
||||||
message.success($t('ui.actionMessage.operationSuccess'));
|
message.success($t('ui.actionMessage.operationSuccess'));
|
||||||
@@ -49,6 +50,7 @@ const [Modal, modalApi] = useVbenModal({
|
|||||||
modalApi.unlock();
|
modalApi.unlock();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
/** 弹窗打开/关闭 */
|
||||||
async onOpenChange(isOpen: boolean) {
|
async onOpenChange(isOpen: boolean) {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
deviceIds.value = [];
|
deviceIds.value = [];
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { FileType } from 'ant-design-vue/es/upload/interface';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
import { downloadFileFromBlobPart } from '@vben/utils';
|
||||||
|
|
||||||
|
import { Button, message, Upload } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { useVbenForm } from '#/adapter/form';
|
||||||
|
import { importDevice, importDeviceTemplate } from '#/api/iot/device/device';
|
||||||
|
import { $t } from '#/locales';
|
||||||
|
|
||||||
|
import { useImportFormSchema } from '../data';
|
||||||
|
|
||||||
|
defineOptions({ name: 'IoTDeviceImportForm' });
|
||||||
|
|
||||||
|
const emit = defineEmits(['success']);
|
||||||
|
|
||||||
|
const [Form, formApi] = useVbenForm({
|
||||||
|
commonConfig: {
|
||||||
|
formItemClass: 'col-span-2',
|
||||||
|
labelWidth: 120,
|
||||||
|
},
|
||||||
|
layout: 'horizontal',
|
||||||
|
schema: useImportFormSchema(),
|
||||||
|
showDefaultActions: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [Modal, modalApi] = useVbenModal({
|
||||||
|
async onConfirm() {
|
||||||
|
const { valid } = await formApi.validate();
|
||||||
|
if (!valid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
modalApi.lock();
|
||||||
|
// 提交表单
|
||||||
|
const data = await formApi.getValues();
|
||||||
|
try {
|
||||||
|
const result = await importDevice(data.file, data.updateSupport);
|
||||||
|
// 处理导入结果提示
|
||||||
|
const importData = result.data || result;
|
||||||
|
if (importData) {
|
||||||
|
let text = `上传成功数量:${importData.createDeviceNames?.length || 0};`;
|
||||||
|
if (importData.createDeviceNames?.length) {
|
||||||
|
for (const deviceName of importData.createDeviceNames) {
|
||||||
|
text += `< ${deviceName} >`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
text += `更新成功数量:${importData.updateDeviceNames?.length || 0};`;
|
||||||
|
if (importData.updateDeviceNames?.length) {
|
||||||
|
for (const deviceName of importData.updateDeviceNames) {
|
||||||
|
text += `< ${deviceName} >`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
text += `更新失败数量:${Object.keys(importData.failureDeviceNames || {}).length};`;
|
||||||
|
if (importData.failureDeviceNames) {
|
||||||
|
for (const deviceName in importData.failureDeviceNames) {
|
||||||
|
text += `< ${deviceName}: ${importData.failureDeviceNames[deviceName]} >`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message.info(text);
|
||||||
|
}
|
||||||
|
// 关闭并提示
|
||||||
|
await modalApi.close();
|
||||||
|
emit('success');
|
||||||
|
message.success($t('ui.actionMessage.operationSuccess'));
|
||||||
|
} finally {
|
||||||
|
modalApi.unlock();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 上传前 */
|
||||||
|
function beforeUpload(file: FileType) {
|
||||||
|
formApi.setFieldValue('file', file);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下载模版 */
|
||||||
|
async function handleDownload() {
|
||||||
|
const data = await importDeviceTemplate();
|
||||||
|
downloadFileFromBlobPart({ fileName: '设备导入模板.xls', source: data });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal :title="$t('ui.actionTitle.import', ['设备'])" class="w-1/3">
|
||||||
|
<Form class="mx-4">
|
||||||
|
<template #file>
|
||||||
|
<div class="w-full">
|
||||||
|
<Upload
|
||||||
|
:max-count="1"
|
||||||
|
accept=".xls,.xlsx"
|
||||||
|
:before-upload="beforeUpload"
|
||||||
|
>
|
||||||
|
<Button type="primary"> 选择 Excel 文件</Button>
|
||||||
|
</Upload>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Form>
|
||||||
|
<template #prepend-footer>
|
||||||
|
<div class="flex flex-auto items-center">
|
||||||
|
<Button @click="handleDownload"> 下载导入模板</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
@@ -12,6 +12,8 @@ import { downloadFileFromBlobPart } from '@vben/utils';
|
|||||||
|
|
||||||
import { Button, Card, Input, message, Space } from 'ant-design-vue';
|
import { Button, Card, Input, message, Space } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { ProductStatusEnum } from '@vben/constants';
|
||||||
|
|
||||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
import { getSimpleProductCategoryList } from '#/api/iot/product/category';
|
import { getSimpleProductCategoryList } from '#/api/iot/product/category';
|
||||||
import {
|
import {
|
||||||
@@ -253,6 +255,7 @@ onMounted(() => {
|
|||||||
type: 'link',
|
type: 'link',
|
||||||
danger: true,
|
danger: true,
|
||||||
icon: ACTION_ICON.DELETE,
|
icon: ACTION_ICON.DELETE,
|
||||||
|
disabled: row.status === ProductStatusEnum.PUBLISHED,
|
||||||
popConfirm: {
|
popConfirm: {
|
||||||
title: `确认删除产品 ${row.name} 吗?`,
|
title: `确认删除产品 ${row.name} 吗?`,
|
||||||
confirm: handleDelete.bind(null, row),
|
confirm: handleDelete.bind(null, row),
|
||||||
|
|||||||
@@ -7,10 +7,9 @@ import { useVbenModal } from '@vben/common-ui';
|
|||||||
|
|
||||||
import { Button, Card, Descriptions, message, Modal } from 'ant-design-vue';
|
import { Button, Card, Descriptions, message, Modal } from 'ant-design-vue';
|
||||||
|
|
||||||
import {
|
import { ProductStatusEnum } from '@vben/constants';
|
||||||
ProductStatusEnum,
|
|
||||||
updateProductStatus,
|
import { updateProductStatus } from '#/api/iot/product/product';
|
||||||
} from '#/api/iot/product/product';
|
|
||||||
|
|
||||||
import Form from '../../form.vue';
|
import Form from '../../form.vue';
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { DICT_TYPE } from '@vben/constants';
|
|||||||
|
|
||||||
import { Card, Descriptions } from 'ant-design-vue';
|
import { Card, Descriptions } from 'ant-design-vue';
|
||||||
|
|
||||||
import { DeviceTypeEnum } from '#/api/iot/product/product';
|
import { DeviceTypeEnum } from '@vben/constants';
|
||||||
import { DictTag } from '#/components/dict-tag';
|
import { DictTag } from '#/components/dict-tag';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -1 +1,43 @@
|
|||||||
// TODO @haohao:枚举可以放到这里;
|
// ========== IOT - 设备模块 ==========
|
||||||
|
/**
|
||||||
|
* 设备状态枚举
|
||||||
|
*/
|
||||||
|
export const DeviceStateEnum = {
|
||||||
|
INACTIVE: 0, // 未激活
|
||||||
|
OFFLINE: 2, // 离线
|
||||||
|
ONLINE: 1, // 在线
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ========== IOT - 产品模块 ==========
|
||||||
|
/**
|
||||||
|
* 产品设备类型枚举
|
||||||
|
*/
|
||||||
|
export const DeviceTypeEnum = {
|
||||||
|
DEVICE: 0, // 直连设备
|
||||||
|
GATEWAY: 2, // 网关设备
|
||||||
|
GATEWAY_SUB: 1, // 网关子设备
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 产品状态枚举
|
||||||
|
*/
|
||||||
|
export const ProductStatusEnum = {
|
||||||
|
UNPUBLISHED: 0, // 开发中
|
||||||
|
PUBLISHED: 1, // 已发布
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 产品定位类型枚举
|
||||||
|
*/
|
||||||
|
export const LocationTypeEnum = {
|
||||||
|
IP: 1, // IP 定位
|
||||||
|
MANUAL: 3, // 手动定位
|
||||||
|
MODULE: 2, // 设备定位
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据格式(编解码器类型)枚举
|
||||||
|
*/
|
||||||
|
export const CodecTypeEnum = {
|
||||||
|
ALINK: 'Alink', // 阿里云 Alink 协议
|
||||||
|
} as const;
|
||||||
|
|||||||
Reference in New Issue
Block a user