refactor:【antd】【iot】将物联网设备和产品枚举整合为常量,优化设备导入功能,简化设备管理UI组件

This commit is contained in:
haohao
2025-12-06 17:54:46 +08:00
parent cc375100cb
commit 2b270caf30
27 changed files with 1027 additions and 1513 deletions

View File

@@ -77,14 +77,6 @@ export namespace IotDeviceApi {
} }
} }
/** IoT 设备状态枚举 */
// TODO @haohaopackages/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[]>(

View File

@@ -27,33 +27,6 @@ export namespace IotProductApi {
} }
} }
// TODO @haohaopackages/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>>(

View File

@@ -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 个字符')

View File

@@ -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),
}, },
}, },

View 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>

View File

@@ -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>

View File

@@ -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 = [];
} }
} }

View File

@@ -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>

View File

@@ -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

View File

@@ -1,4 +1,4 @@
<script setup lang="ts"> <script lang="ts" setup>
import { import {
computed, computed,
onBeforeUnmount, onBeforeUnmount,

View File

@@ -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;

View File

@@ -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';

View File

@@ -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;
}
// ModalCard 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()),
];
// structarray使 list // structarray使 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>

View File

@@ -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 }>();

View File

@@ -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';

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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();

View File

@@ -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 = [];

View File

@@ -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>

View File

@@ -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),

View File

@@ -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';

View File

@@ -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 {

View File

@@ -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;