feat(iot): 优化 iot 的代码风格(迁移 constants.ts)地址

pull/345/head
YunaiV 2026-05-20 23:11:02 +08:00
parent e816288b82
commit 3f09fc1498
115 changed files with 3628 additions and 3035 deletions

View File

@ -11,6 +11,7 @@ export namespace RuleSceneApi {
status?: number;
triggers?: Trigger[];
actions?: Action[];
lastTriggeredTime?: Date;
createTime?: Date;
}

View File

@ -19,7 +19,7 @@ export namespace IotStatisticsApi {
/** 设备消息数量统计(按日期) */
export interface DeviceMessageSummaryByDateRespVO {
time: Date; // 时间轴
time: string; // 时间轴
upstreamCount: number; // 上行消息数量
downstreamCount: number; // 下行消息数量
}

View File

@ -68,6 +68,15 @@ export namespace ThingModelApi {
dataSpecsList?: any[];
}
/** IoT 物模型 TSL树形响应 */
export interface ThingModelTSL {
productId?: number;
productKey?: string;
properties?: Property[];
events?: Event[];
services?: Service[];
}
/** IoT 数据定义(数值型) */
export interface DataSpecsNumberData {
min?: number | string;
@ -236,7 +245,10 @@ export function deleteThingModel(id: number) {
/** 获取物模型 TSL */
export function getThingModelTSL(productId: number) {
return requestClient.get<any>('/iot/thing-model/get-tsl', {
params: { productId },
});
return requestClient.get<ThingModelApi.ThingModelTSL>(
'/iot/thing-model/get-tsl',
{
params: { productId },
},
);
}

View File

@ -115,20 +115,20 @@ export function useGridColumns(): VxeTableGridOptions<AlertRecordApi.AlertRecord
props: { type: DICT_TYPE.IOT_ALERT_LEVEL },
},
},
// TODO @AI非必要不缩写product、device
{
field: 'productId',
title: '产品名称',
minWidth: 120,
formatter: ({ cellValue }) =>
productList.find((p) => p.id === cellValue)?.name || '-',
productList.find((product) => product.id === cellValue)?.name || '-',
},
{
field: 'deviceId',
title: '设备名称',
minWidth: 120,
formatter: ({ cellValue }) =>
deviceList.find((d) => d.id === cellValue)?.deviceName || '-',
deviceList.find((device) => device.id === cellValue)?.deviceName ||
'-',
},
{
field: 'deviceMessage',

View File

@ -24,16 +24,13 @@ const props = defineProps<{
deviceId: number;
}>();
/** 查询参数 */
const queryParams = reactive({
method: undefined,
upstream: undefined,
});
}); //
/** 自动刷新开关 */
const autoRefresh = ref(false);
/** 自动刷新定时器 */
let autoRefreshTimer: any = null;
const autoRefresh = ref(false); //
let autoRefreshTimer: any = null; //
/** 消息方法选项 */
const methodOptions = computed(() => {

View File

@ -5,7 +5,12 @@ import type { IotDeviceModbusConfigApi } from '#/api/iot/device/modbus/config';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { CommonStatusEnum, DICT_TYPE } from '@vben/constants';
import {
CommonStatusEnum,
DICT_TYPE,
ModbusFrameFormatEnum,
ModbusModeEnum,
} from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { message } from 'ant-design-vue';
@ -14,10 +19,6 @@ import { useVbenForm, z } from '#/adapter/form';
import { saveModbusConfig } from '#/api/iot/device/modbus/config';
import { ProtocolTypeEnum } from '#/api/iot/product/product';
import { $t } from '#/locales';
import {
ModbusFrameFormatEnum,
ModbusModeEnum,
} from '#/views/iot/utils/constants';
const emit = defineEmits(['success']);

View File

@ -12,7 +12,7 @@ import type { DescriptionItemSchema } from '#/components/description';
import { computed, h, onMounted, ref } from 'vue';
import { confirm, useVbenModal } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { DICT_TYPE, ModbusFunctionCodeOptions } from '@vben/constants';
import { Button, message } from 'ant-design-vue';
@ -25,7 +25,6 @@ import {
import { ProtocolTypeEnum } from '#/api/iot/product/product';
import { useDescription } from '#/components/description';
import { DictTag } from '#/components/dict-tag';
import { ModbusFunctionCodeOptions } from '#/views/iot/utils/constants';
import DeviceModbusConfigForm from './modbus-config-form.vue';
import DeviceModbusPointForm from './modbus-point-form.vue';

View File

@ -7,7 +7,14 @@ import type { ThingModelApi } from '#/api/iot/thingmodel';
import { computed, h, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { CommonStatusEnum, DICT_TYPE } from '@vben/constants';
import {
CommonStatusEnum,
DICT_TYPE,
getByteOrderOptions,
IoTThingModelTypeEnum,
ModbusFunctionCodeOptions,
ModbusRawDataTypeOptions,
} from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { message } from 'ant-design-vue';
@ -19,12 +26,6 @@ import {
updateModbusPoint,
} from '#/api/iot/device/modbus/point';
import { $t } from '#/locales';
import {
getByteOrderOptions,
IoTThingModelTypeEnum,
ModbusFunctionCodeOptions,
ModbusRawDataTypeOptions,
} from '#/views/iot/utils/constants';
const emit = defineEmits(['success']);

View File

@ -9,7 +9,11 @@ import type { ThingModelApi } from '#/api/iot/thingmodel';
import { computed, ref } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { IotDeviceMessageMethodEnum } from '@vben/constants';
import {
DeviceStateEnum,
IotDeviceMessageMethodEnum,
IoTThingModelTypeEnum,
} from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import {
@ -25,10 +29,6 @@ import {
} from 'ant-design-vue';
import { sendDeviceMessage } from '#/api/iot/device/device';
import {
DeviceStateEnum,
IoTThingModelTypeEnum,
} from '#/views/iot/utils/constants';
import DataDefinition from '../../../../thingmodel/modules/components/data-definition.vue';
import DeviceDetailsMessage from './message.vue';

View File

@ -6,7 +6,11 @@ import type { ThingModelApi } from '#/api/iot/thingmodel';
import { computed, onMounted, reactive, watch } from 'vue';
import { Page } from '@vben/common-ui';
import { IotDeviceMessageMethodEnum } from '@vben/constants';
import {
getEventTypeLabel,
IotDeviceMessageMethodEnum,
IoTThingModelTypeEnum,
} from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { formatDateTime } from '@vben/utils';
@ -14,10 +18,6 @@ import { Button, RangePicker, Select, Space, Tag } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getDeviceMessagePairPage } from '#/api/iot/device/device';
import {
getEventTypeLabel,
IoTThingModelTypeEnum,
} from '#/views/iot/utils/constants';
const props = defineProps<{
deviceId: number;

View File

@ -8,9 +8,10 @@ import type { IotDeviceApi } from '#/api/iot/device/device';
import { computed, nextTick, reactive, ref, watch } from 'vue';
import { IoTDataSpecsDataTypeEnum } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { formatDate, formatDateTime } from '@vben/utils';
import { formatDate } from '@vben/utils';
import {
Button,
@ -26,13 +27,11 @@ import dayjs from 'dayjs';
import { getHistoryDevicePropertyList } from '#/api/iot/device/device';
import ShortcutDateRangePicker from '#/components/shortcut-date-range-picker/shortcut-date-range-picker.vue';
import { IoTDataSpecsDataTypeEnum } from '#/views/iot/utils/constants';
defineProps<{ deviceId: number }>();
const dialogVisible = ref(false); //
const loading = ref(false);
const exporting = ref(false);
const viewMode = ref<'chart' | 'list'>('chart'); //
const list = ref<IotDeviceApi.DevicePropertyDetail[]>([]); //
const total = ref(0); //
@ -330,54 +329,6 @@ function handleRefresh() {
getList();
}
/** 导出数据 */
async function handleExport() {
if (list.value.length === 0) {
message.warning('暂无数据可导出');
return;
}
exporting.value = true;
try {
// CSV
const headers = ['序号', '时间', '属性值'];
const csvContent = [
headers.join(','),
...list.value.map((item, index) => {
return [
index + 1,
formatDateTime(new Date(item.updateTime)),
isComplexDataType.value
? `"${JSON.stringify(item.value)}"`
: item.value,
].join(',');
}),
].join('\n');
// BOM ,
const BOM = '\uFEFF';
const blob = new Blob([BOM + csvContent], {
type: 'text/csv;charset=utf-8',
});
//
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `设备属性历史_${propertyIdentifier.value}_${formatDate(new Date(), 'YYYYMMDDHHmmss')}.csv`;
document.body.append(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
message.success('导出成功');
} catch {
message.error('导出失败');
} finally {
exporting.value = false;
}
}
/** 关闭弹窗 */
function handleClose() {
dialogVisible.value = false;
@ -429,18 +380,6 @@ defineExpose({ open }); // 提供 open 方法,用于打开弹窗
刷新
</Button>
<!-- 导出按钮 -->
<Button
:disabled="list.length === 0"
:loading="exporting"
@click="handleExport"
>
<template #icon>
<IconifyIcon icon="ant-design:export-outlined" />
</template>
导出
</Button>
<!-- 视图切换 -->
<Button.Group class="ml-auto">
<Button

View File

@ -6,7 +6,11 @@ import type { ThingModelApi } from '#/api/iot/thingmodel';
import { computed, onMounted, reactive, watch } from 'vue';
import { Page } from '@vben/common-ui';
import { IotDeviceMessageMethodEnum } from '@vben/constants';
import {
getThingModelServiceCallTypeLabel,
IotDeviceMessageMethodEnum,
IoTThingModelTypeEnum,
} from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { formatDateTime } from '@vben/utils';
@ -14,10 +18,6 @@ import { Button, RangePicker, Select, Space, Tag } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getDeviceMessagePairPage } from '#/api/iot/device/device';
import {
getThingModelServiceCallTypeLabel,
IoTThingModelTypeEnum,
} from '#/views/iot/utils/constants';
const props = defineProps<{
deviceId: number;

View File

@ -105,7 +105,7 @@ onMounted(() => {
</script>
<template>
<div class="device-card-view">
<div>
<!-- 设备卡片列表 -->
<div v-loading="loading" class="min-h-96">
<Row v-if="list.length > 0" :gutter="[16, 16]">
@ -118,30 +118,43 @@ onMounted(() => {
: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"
:body-style="{
padding: '16px',
display: 'flex',
flexDirection: 'column',
height: '100%',
}"
class="h-full overflow-hidden 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">
<div
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-[#40a9ff] to-[#1890ff] text-white"
>
<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
class="truncate text-[15px] font-semibold leading-9 dark:text-white/85"
>
{{ item.deviceName }}
</div>
</div>
<DictTag
:type="DICT_TYPE.IOT_DEVICE_STATE"
:value="item.state"
class="status-tag"
class="text-xs"
/>
</div>
<!-- 内容区域 -->
<div class="mb-3 flex items-start">
<div class="info-list flex-1">
<div class="info-item">
<span class="info-label">所属产品</span>
<div class="flex-1">
<div class="mb-2 flex items-center text-[13px]">
<span class="mr-2 shrink-0 opacity-65 dark:text-white/65">
所属产品
</span>
<a
class="info-value cursor-pointer text-primary"
class="cursor-pointer truncate font-medium text-primary"
@click="
(e) => {
e.stopPropagation();
@ -152,25 +165,33 @@ onMounted(() => {
{{ getProductName(item.productId) }}
</a>
</div>
<div class="info-item">
<span class="info-label">设备类型</span>
<div class="mb-2 flex items-center text-[13px]">
<span class="mr-2 shrink-0 opacity-65 dark:text-white/65">
设备类型
</span>
<DictTag
:type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE"
:value="item.deviceType"
class="info-tag m-0"
class="m-0 text-xs"
/>
</div>
<div class="info-item">
<span class="info-label">Deviceid</span>
<div class="flex items-center text-[13px]">
<span class="mr-2 shrink-0 opacity-65 dark:text-white/65">
Deviceid
</span>
<Tooltip :title="String(item.id)" placement="top">
<span class="info-value device-id cursor-pointer">
<span
class="inline-block max-w-[150px] cursor-pointer truncate align-middle font-mono text-xs opacity-85 dark:text-white/75"
>
{{ item.id }}
</span>
</Tooltip>
</div>
</div>
<!-- 设备图片 -->
<div class="device-image">
<div
class="flex size-20 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-[#40a9ff15] to-[#1890ff15] text-[#1890ff] dark:from-[#40a9ff25] dark:to-[#1890ff25] dark:text-[#69c0ff]"
>
<Image
v-if="item.picUrl"
:src="item.picUrl"
@ -185,11 +206,13 @@ onMounted(() => {
</div>
</div>
<!-- 按钮组 -->
<div class="action-buttons">
<div
class="mt-auto flex gap-2 border-t border-border pt-3"
>
<Button
v-if="hasAccessByCodes(['iot:device:update'])"
size="small"
class="action-btn action-btn-edit"
class="!h-8 flex-1 rounded-md !border-[#1890ff] !text-[13px] !text-[#1890ff] transition-all duration-200 hover:!bg-[#1890ff] hover:!text-white"
@click="emit('edit', item)"
>
<IconifyIcon icon="lucide:edit" class="mr-1" />
@ -198,7 +221,7 @@ onMounted(() => {
<Button
v-if="hasAccessByCodes(['iot:device:query'])"
size="small"
class="action-btn action-btn-detail"
class="!h-8 flex-1 rounded-md !border-[#52c41a] !text-[13px] !text-[#52c41a] transition-all duration-200 hover:!bg-[#52c41a] hover:!text-white"
@click="emit('detail', item.id!)"
>
<IconifyIcon icon="lucide:eye" class="mr-1" />
@ -207,7 +230,7 @@ onMounted(() => {
<Button
v-if="hasAccessByCodes(['iot:device:message-query'])"
size="small"
class="action-btn action-btn-data"
class="!h-8 flex-1 rounded-md !border-[#fa8c16] !text-[13px] !text-[#fa8c16] transition-all duration-200 hover:!bg-[#fa8c16] hover:!text-white"
@click="emit('model', item.id!)"
>
<IconifyIcon icon="lucide:database" class="mr-1" />
@ -221,7 +244,7 @@ onMounted(() => {
<Button
size="small"
danger
class="action-btn action-btn-delete !w-8"
class="h-8 rounded-md p-0 text-[13px] transition-all duration-200 !w-8"
>
<IconifyIcon icon="lucide:trash-2" class="text-sm" />
</Button>
@ -249,187 +272,3 @@ onMounted(() => {
</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, #40a9ff 0%, #1890ff 100%);
border-radius: 8px;
}
//
.device-title {
overflow: hidden;
text-overflow: ellipsis;
font-size: 15px;
font-weight: 600;
line-height: 36px;
white-space: nowrap;
}
//
.status-tag {
font-size: 12px;
}
//
.device-image {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
color: #1890ff;
background: linear-gradient(135deg, #40a9ff15 0%, #1890ff15 100%);
border-radius: 8px;
}
//
.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: #fa8c16;
border-color: #fa8c16;
&:hover {
color: white;
background: #fa8c16;
}
}
&.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%);
}
}
.device-image {
color: #69c0ff;
background: linear-gradient(135deg, #40a9ff25 0%, #1890ff25 100%);
}
}
}
}
</style>

View File

@ -73,9 +73,7 @@ export function getMessageTrendChartOptions(
};
}
/**
*
*/
/** 设备状态仪表盘图表配置 */
export function getDeviceStateGaugeChartOptions(
value: number,
max: number,
@ -129,9 +127,7 @@ export function getDeviceStateGaugeChartOptions(
};
}
/**
*
*/
/** 设备数量饼图配置 */
export function getDeviceCountPieChartOptions(
data: Array<{ name: string; value: number }>,
): any {

View File

@ -28,6 +28,7 @@ async function loadData() {
}
}
/** 初始化 */
onMounted(() => {
loadData();
});

View File

@ -21,7 +21,9 @@ const { renderEcharts } = useEcharts(deviceCountChartRef);
/** 是否有数据 */
const hasData = computed(() => {
if (!props.statsData) return false;
if (!props.statsData) {
return false;
}
const categories = Object.entries(
props.statsData.productCategoryDeviceCounts || {},
);

View File

@ -1,17 +1,16 @@
<script lang="ts" setup>
import type { IotDeviceApi } from '#/api/iot/device/device';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { DICT_TYPE } from '@vben/constants';
import { DeviceStateEnum, DICT_TYPE } from '@vben/constants';
import { getDictLabel } from '@vben/hooks';
import { Card, Empty, Spin } from 'ant-design-vue';
import { getDeviceLocationList } from '#/api/iot/device/device';
import { loadBaiduMapSdk } from '#/components/map';
import { DeviceStateEnum } from '#/views/iot/utils/constants';
defineOptions({ name: 'DeviceMapCard' });
@ -152,6 +151,8 @@ async function init() {
return;
}
await loadBaiduMapSdk();
// v-show SDK resolveDOM
await nextTick();
initMap();
}

View File

@ -7,7 +7,6 @@ import { useDescription } from '#/components/description';
import { useDetailSchema } from '../../data';
/** IoT OTA 固件基本信息 */
defineProps<{
firmware?: IoTOtaFirmwareApi.Firmware;
loading?: boolean;

View File

@ -2,12 +2,10 @@ import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { DescriptionItemSchema } from '#/components/description';
import { DICT_TYPE } from '@vben/constants';
import { DICT_TYPE, IoTOtaTaskDeviceScopeEnum } from '@vben/constants';
import { getDictLabel, getDictOptions } from '@vben/hooks';
import { formatDateTime } from '@vben/utils';
import { IoTOtaTaskDeviceScopeEnum } from '#/views/iot/utils/constants';
/** 任务详情的描述字段 */
export function useDetailSchema(): DescriptionItemSchema[] {
return [

View File

@ -5,13 +5,13 @@ import type { IoTOtaTaskApi } from '#/api/iot/ota/task';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { IoTOtaTaskStatusEnum } from '@vben/constants';
import { Input, message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { cancelOtaTask, getOtaTaskPage } from '#/api/iot/ota/task';
import { $t } from '#/locales';
import { IoTOtaTaskStatusEnum } from '#/views/iot/utils/constants';
import { useGridColumns } from '../data';
import OtaTaskDetail from './detail.vue';

View File

@ -1,13 +1,11 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { DICT_TYPE, IoTOtaTaskRecordStatusEnum } from '@vben/constants';
import { getDictLabel } from '@vben/hooks';
import { Card, Col, Row } from 'ant-design-vue';
import { IoTOtaTaskRecordStatusEnum } from '#/views/iot/utils/constants';
const props = defineProps<{
loading?: boolean;
statistics: Record<string, number>;

View File

@ -4,6 +4,8 @@ import type { IoTOtaTaskRecordApi } from '#/api/iot/ota/task/record';
import { computed, ref, watch } from 'vue';
import { IoTOtaTaskRecordStatusEnum } from '@vben/constants';
import { Card, message, Tabs } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
@ -12,7 +14,6 @@ import {
getOtaTaskRecordPage,
} from '#/api/iot/ota/task/record';
import { $t } from '#/locales';
import { IoTOtaTaskRecordStatusEnum } from '#/views/iot/utils/constants';
import { useGridColumns } from '../data';

View File

@ -5,13 +5,13 @@ import { onMounted, provide, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { IOT_PROVIDE_KEY } from '@vben/constants';
import { message, Tabs } from 'ant-design-vue';
import { getDeviceCount } from '#/api/iot/device/device';
import { getProduct } from '#/api/iot/product/product';
import IoTProductThingModel from '#/views/iot/thingmodel/index.vue';
import { IOT_PROVIDE_KEY } from '#/views/iot/utils/constants';
import ProductDetailsHeader from './modules/header.vue';
import ProductDetailsInfo from './modules/info.vue';

View File

@ -19,7 +19,9 @@ const showProductSecret = ref(false); // 是否显示产品密钥
/** 格式化日期 */
function formatDate(date?: Date | string) {
if (!date) return '-';
if (!date) {
return '-';
}
return new Date(date).toLocaleString('zh-CN');
}

View File

@ -91,7 +91,7 @@ onMounted(() => {
</script>
<template>
<div class="product-card-view">
<div>
<!-- 产品卡片列表 -->
<div v-loading="loading" class="min-h-96">
<Row v-if="list.length > 0" :gutter="[16, 16]">
@ -104,49 +104,70 @@ onMounted(() => {
:lg="6"
>
<Card
:body-style="{ padding: '16px' }"
class="product-card h-full rounded-lg transition-all duration-300 hover:-translate-y-0.5 hover:shadow-lg"
:body-style="{
padding: '16px',
display: 'flex',
flexDirection: 'column',
height: '100%',
}"
class="h-full overflow-hidden rounded-lg transition-all duration-300 hover:-translate-y-0.5 hover:shadow-lg"
>
<!-- 顶部标题区域 -->
<div class="mb-3 flex items-center">
<div class="product-icon">
<div
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-[#40a9ff] to-[#1890ff] text-white"
>
<IconifyIcon
:icon="item.icon || 'lucide:box'"
class="text-xl"
/>
</div>
<div class="ml-3 min-w-0 flex-1">
<div class="product-title">{{ item.name }}</div>
<div
class="truncate text-[15px] font-semibold leading-9 dark:text-white/85"
>
{{ item.name }}
</div>
</div>
</div>
<!-- 内容区域 -->
<div class="mb-3 flex items-start">
<div class="info-list flex-1">
<div class="info-item">
<span class="info-label">产品分类</span>
<span class="info-value text-primary">
<div class="flex-1">
<div class="mb-2 flex items-center text-[13px]">
<span class="mr-2 shrink-0 opacity-65 dark:text-white/65">
产品分类
</span>
<span class="truncate font-medium text-primary">
{{ getCategoryName(item.categoryId) }}
</span>
</div>
<div class="info-item">
<span class="info-label">产品类型</span>
<div class="mb-2 flex items-center text-[13px]">
<span class="mr-2 shrink-0 opacity-65 dark:text-white/65">
产品类型
</span>
<DictTag
:type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE"
:value="item.deviceType"
class="info-tag m-0"
class="m-0 text-xs"
/>
</div>
<div class="info-item">
<span class="info-label">产品标识</span>
<div class="flex items-center text-[13px]">
<span class="mr-2 shrink-0 opacity-65 dark:text-white/65">
产品标识
</span>
<Tooltip :title="item.productKey || item.id" placement="top">
<span class="info-value product-key cursor-pointer">
<span
class="inline-block max-w-[150px] cursor-pointer truncate align-middle font-mono text-xs opacity-85 dark:text-white/75"
>
{{ item.productKey || item.id }}
</span>
</Tooltip>
</div>
</div>
<!-- 产品图片 -->
<div class="product-image">
<div
class="flex size-20 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-[#40a9ff15] to-[#1890ff15] text-[#1890ff] dark:from-[#40a9ff25] dark:to-[#1890ff25] dark:text-[#69c0ff]"
>
<Image
v-if="item.picUrl"
:src="item.picUrl"
@ -161,11 +182,13 @@ onMounted(() => {
</div>
</div>
<!-- 按钮组 -->
<div class="action-buttons">
<div
class="mt-auto flex gap-2 border-t border-border pt-3"
>
<Button
v-if="hasAccessByCodes(['iot:product:update'])"
size="small"
class="action-btn action-btn-edit"
class="!h-8 flex-1 rounded-md !border-[#1890ff] !text-[13px] !text-[#1890ff] transition-all duration-200 hover:!bg-[#1890ff] hover:!text-white"
@click="emit('edit', item)"
>
<IconifyIcon icon="lucide:edit" class="mr-1" />
@ -174,7 +197,7 @@ onMounted(() => {
<Button
v-if="hasAccessByCodes(['iot:product:query'])"
size="small"
class="action-btn action-btn-detail"
class="!h-8 flex-1 rounded-md !border-[#52c41a] !text-[13px] !text-[#52c41a] transition-all duration-200 hover:!bg-[#52c41a] hover:!text-white"
@click="emit('detail', item.id)"
>
<IconifyIcon icon="lucide:eye" class="mr-1" />
@ -183,7 +206,7 @@ onMounted(() => {
<Button
v-if="hasAccessByCodes(['iot:thing-model:query'])"
size="small"
class="action-btn action-btn-model"
class="!h-8 flex-1 rounded-md !border-[#fa8c16] !text-[13px] !text-[#fa8c16] transition-all duration-200 hover:!bg-[#fa8c16] hover:!text-white"
@click="emit('thingModel', item.id)"
>
<IconifyIcon icon="lucide:git-branch" class="mr-1" />
@ -198,7 +221,7 @@ onMounted(() => {
size="small"
danger
disabled
class="action-btn action-btn-delete !w-8"
class="h-8 rounded-md p-0 text-[13px] transition-all duration-200 !w-8"
>
<IconifyIcon icon="lucide:trash-2" class="text-sm" />
</Button>
@ -211,7 +234,7 @@ onMounted(() => {
<Button
size="small"
danger
class="action-btn action-btn-delete !w-8"
class="h-8 rounded-md p-0 text-[13px] transition-all duration-200 !w-8"
>
<IconifyIcon icon="lucide:trash-2" class="text-sm" />
</Button>
@ -240,182 +263,3 @@ onMounted(() => {
</div>
</div>
</template>
<style scoped lang="scss">
.product-card-view {
.product-card {
overflow: hidden;
:deep(.ant-card-body) {
display: flex;
flex-direction: column;
height: 100%;
}
//
.product-icon {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
color: white;
background: linear-gradient(135deg, #40a9ff 0%, #1890ff 100%);
border-radius: 8px;
}
//
.product-title {
overflow: hidden;
text-overflow: ellipsis;
font-size: 15px;
font-weight: 600;
line-height: 36px;
white-space: nowrap;
}
//
.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;
}
}
.product-key {
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;
}
}
}
//
.product-image {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
color: #1890ff;
background: linear-gradient(135deg, #40a9ff15 0%, #1890ff15 100%);
border-radius: 8px;
}
//
.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-model {
color: #fa8c16;
border-color: #fa8c16;
&:hover {
color: white;
background: #fa8c16;
}
}
&.action-btn-delete {
flex: 0 0 32px;
padding: 0;
}
}
}
}
}
//
html.dark {
.product-card-view {
.product-card {
.product-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%);
}
.product-key {
color: rgb(255 255 255 / 75%);
}
}
.product-image {
color: #69c0ff;
background: linear-gradient(135deg, #40a9ff25 0%, #1890ff25 100%);
}
}
}
}
</style>

View File

@ -1,7 +1,7 @@
<script lang="ts" setup>
import { computed, nextTick, onMounted, ref } from 'vue';
import { IotDeviceMessageMethodEnum } from '@vben/constants';
import { IotDeviceMessageMethodEnum, IoTThingModelTypeEnum } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { Button, message, Select } from 'ant-design-vue';
@ -10,7 +10,6 @@ import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getSimpleDeviceList } from '#/api/iot/device/device';
import { getSimpleProductList } from '#/api/iot/product/product';
import { getThingModelListByProductId } from '#/api/iot/thingmodel';
import { IoTThingModelTypeEnum } from '#/views/iot/utils/constants';
import { useSourceConfigColumns } from '../data';
@ -184,13 +183,12 @@ async function setData(data: any[]) {
...item,
identifierLoading: false,
}));
//
// TODO @AI linter Promise returned from forEach argument is ignored
data?.forEach(async (item) => {
if (item.productId && shouldShowIdentifierSelect(item)) {
await loadThingModel(item.productId);
}
});
// reloadGrid
await Promise.all(
(data || [])
.filter((item) => item.productId && shouldShowIdentifierSelect(item))
.map((item) => loadThingModel(item.productId)),
);
await reloadGrid();
}
@ -274,7 +272,7 @@ defineExpose({ validate, getData, setData });
:options="getThingModelOptions(formData[rowIndex])"
class="w-full"
/>
<span v-else class="text-xs text-secondary">-</span>
<span v-else class="text-xs text-muted-foreground">-</span>
</template>
<template #actions="{ rowIndex }">
<Button danger type="link" @click="handleDelete(rowIndex)"></Button>

View File

@ -92,39 +92,42 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
{
field: 'name',
title: '规则名称',
minWidth: 180,
minWidth: 200,
align: 'left',
showOverflow: false,
slots: { default: 'name' },
},
{
field: 'triggers',
title: '触发条件',
minWidth: 260,
minWidth: 280,
align: 'left',
showOverflow: false,
slots: { default: 'triggers' },
},
{
field: 'actions',
title: '执行动作',
minWidth: 220,
minWidth: 250,
align: 'left',
showOverflow: false,
slots: { default: 'actionsCol' },
},
{
field: 'status',
title: '规则状态',
width: 90,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
field: 'lastTriggeredTime',
title: '最近触发',
width: 180,
slots: { default: 'lastTriggeredTime' },
},
{
field: 'createTime',
title: '创建时间',
width: 160,
width: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 200,
width: 210,
fixed: 'right',
slots: { default: 'actions' },
},

View File

@ -57,8 +57,8 @@ onMounted(() => {
<Select
v-model:value="localValue"
placeholder="请选择告警配置"
filterable
clearable
show-search
allow-clear
@change="handleChange"
class="w-full"
:loading="loading"

View File

@ -4,15 +4,15 @@ import type { TriggerCondition } from '#/api/iot/rule/scene';
import { computed, ref } from 'vue';
import { useVModel } from '@vueuse/core';
import { Col, Form, Row, Select } from 'ant-design-vue';
import {
getConditionTypeOptions,
IoTDeviceStatusEnum,
IotRuleSceneTriggerConditionParameterOperatorEnum,
IotRuleSceneTriggerConditionTypeEnum,
} from '#/views/iot/utils/constants';
} from '@vben/constants';
import { useVModel } from '@vueuse/core';
import { Col, Form, Row, Select } from 'ant-design-vue';
import ValueInput from '../inputs/value-input.vue';
import DeviceSelector from '../selectors/device-selector.vue';
@ -226,7 +226,7 @@ function handleOperatorChange() {
:value="condition.operator"
@change="
(value: any) => updateConditionField('operator', value)
"
placeholder="请选择操作符"
class="w-full"
@ -249,7 +249,7 @@ function handleOperatorChange() {
:value="condition.param"
@change="
(value: any) => updateConditionField('param', value)
"
placeholder="请选择设备状态"
class="w-full"

View File

@ -4,6 +4,7 @@ import type { TriggerCondition } from '#/api/iot/rule/scene';
import { computed, watch } from 'vue';
import { IotRuleSceneTriggerTimeOperatorEnum } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { useVModel } from '@vueuse/core';
@ -17,8 +18,6 @@ import {
TimePicker,
} from 'ant-design-vue';
import { IotRuleSceneTriggerTimeOperatorEnum } from '#/views/iot/utils/constants';
/** 当前时间条件配置组件 */
defineOptions({ name: 'CurrentTimeConditionConfig' });
@ -225,7 +224,7 @@ watch(
value-format="YYYY-MM-DD HH:mm:ss"
class="w-full"
/>
<div v-else class="text-sm text-secondary">无需设置时间值</div>
<div v-else class="text-sm text-muted-foreground">无需设置时间值</div>
</Form.Item>
</Col>

View File

@ -5,17 +5,17 @@ import type { ThingModelApi } from '#/api/iot/thingmodel';
import { computed, onMounted, ref, watch } from 'vue';
import {
IoTDataSpecsDataTypeEnum,
IotRuleSceneActionTypeEnum,
IoTThingModelAccessModeEnum,
} from '@vben/constants';
import { isObject } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import { Col, Form, Row, Select, Tag } from 'ant-design-vue';
import { getThingModelListByProductId } from '#/api/iot/thingmodel';
import {
IoTDataSpecsDataTypeEnum,
IotRuleSceneActionTypeEnum,
IoTThingModelAccessModeEnum,
} from '#/views/iot/utils/constants';
import { getThingModelTSL } from '#/api/iot/thingmodel';
import JsonParamsInput from '../inputs/json-params-input.vue';
import DeviceSelector from '../selectors/device-selector.vue';
@ -133,18 +133,13 @@ function handleServiceChange(serviceIdentifier?: any) {
}
}
/**
* 获取物模型TSL数据
* @param productId 产品ID
* @returns 物模型TSL数据
*/
async function getThingModelTSL(productId: number): Promise<any> {
/** 获取物模型 TSL 数据 */
async function fetchThingModelTSL(productId: number) {
if (!productId) return null;
try {
return await getThingModelListByProductId(productId);
return await getThingModelTSL(productId);
} catch (error) {
console.error('获取物模型TSL数据失败:', error);
console.error('获取物模型 TSL 数据失败:', error);
return null;
}
}
@ -161,7 +156,7 @@ async function loadThingModelProperties(productId: number) {
try {
loadingThingModel.value = true;
const tslData = await getThingModelTSL(productId);
const tslData = await fetchThingModelTSL(productId);
// TODO DONE @AI linter
if (!tslData?.properties) {
@ -196,7 +191,7 @@ async function loadServiceList(productId: number) {
try {
loadingServices.value = true;
const tslData = await getThingModelTSL(productId);
const tslData = await fetchThingModelTSL(productId);
// TODO DONE @AI linter
if (!tslData?.services) {
@ -368,8 +363,8 @@ watch(
<Select
v-model:value="action.identifier"
placeholder="请选择服务"
filterable
clearable
show-search
allow-clear
class="w-full"
:loading="loadingServices"
@change="handleServiceChange"

View File

@ -123,7 +123,7 @@ function removeConditionGroup() {
<MainConditionInnerConfig
:model-value="trigger"
@update:model-value="updateCondition"
:trigger-type="(trigger.type as number)"
:trigger-type="trigger.type as number"
@trigger-type-change="handleTriggerTypeChange"
/>
</div>
@ -228,7 +228,7 @@ function removeConditionGroup() {
@update:model-value="
(value) => updateSubGroup(subGroupIndex, value)
"
:trigger-type="(trigger.type as number)"
:trigger-type="trigger.type as number"
:max-conditions="maxConditionsPerGroup"
/>
</div>

View File

@ -3,16 +3,16 @@ import type { Trigger } from '#/api/iot/rule/scene';
import { computed, ref } from 'vue';
import { useVModel } from '@vueuse/core';
import { Col, Form, Row, Select } from 'ant-design-vue';
import {
getTriggerTypeLabel,
IoTDeviceStatusEnum,
IotRuleSceneTriggerConditionParameterOperatorEnum,
IotRuleSceneTriggerTypeEnum,
triggerTypeOptions,
} from '#/views/iot/utils/constants';
} from '@vben/constants';
import { useVModel } from '@vueuse/core';
import { Col, Form, Row, Select } from 'ant-design-vue';
import JsonParamsInput from '../inputs/json-params-input.vue';
import ValueInput from '../inputs/value-input.vue';
@ -368,10 +368,10 @@ function handlePropertyChange(propertyInfo: any) {
<!-- 其他触发类型的提示 -->
<div v-else class="py-5 text-center">
<p class="mb-1 text-sm text-secondary">
<p class="mb-1 text-sm text-muted-foreground">
当前触发事件类型{{ getTriggerTypeLabel(triggerType) }}
</p>
<p class="text-xs text-secondary">此触发类型暂不需要配置额外条件</p>
<p class="text-xs text-muted-foreground">此触发类型暂不需要配置额外条件</p>
</div>
</div>
</template>

View File

@ -3,16 +3,15 @@ import type { TriggerCondition } from '#/api/iot/rule/scene';
import { computed, nextTick } from 'vue';
import {
IotRuleSceneTriggerConditionParameterOperatorEnum,
IotRuleSceneTriggerConditionTypeEnum,
} from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { useVModel } from '@vueuse/core';
import { Button } from 'ant-design-vue';
import {
IotRuleSceneTriggerConditionParameterOperatorEnum,
IotRuleSceneTriggerConditionTypeEnum,
} from '#/views/iot/utils/constants';
import ConditionConfig from './condition-config.vue';
/** 子条件组配置组件 */
@ -87,8 +86,8 @@ function updateCondition(index: number, condition: TriggerCondition) {
<!-- 空状态 -->
<div v-if="!subGroup || subGroup.length === 0" class="py-6 text-center">
<div class="flex flex-col items-center gap-3">
<IconifyIcon icon="lucide:plus" class="text-8 text-secondary" />
<div class="text-secondary">
<IconifyIcon icon="lucide:plus" class="text-8 text-muted-foreground" />
<div class="text-muted-foreground">
<p class="mb-1 text-base font-bold">暂无条件</p>
<p class="text-xs">点击下方按钮添加第一个条件</p>
</div>
@ -119,7 +118,7 @@ function updateCondition(index: number, condition: TriggerCondition) {
>
{{ conditionIndex + 1 }}
</div>
<span class="text-base font-bold text-primary">
<span class="text-base font-bold text-foreground">
条件 {{ conditionIndex + 1 }}
</span>
</div>
@ -159,7 +158,7 @@ function updateCondition(index: number, condition: TriggerCondition) {
<IconifyIcon icon="lucide:plus" />
继续添加条件
</Button>
<span class="mt-2 block text-xs text-secondary">
<span class="mt-2 block text-xs text-muted-foreground">
最多可添加 {{ maxConditions }} 个条件
</span>
</div>

View File

@ -3,13 +3,12 @@ import type { TriggerCondition } from '#/api/iot/rule/scene';
import { nextTick } from 'vue';
import { IotRuleSceneTriggerTypeEnum } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { useVModel } from '@vueuse/core';
import { Button, Tag } from 'ant-design-vue';
import { IotRuleSceneTriggerTypeEnum } from '#/views/iot/utils/constants';
import SubConditionGroupConfig from './sub-condition-group-config.vue';
const props = defineProps<{

View File

@ -1,21 +1,20 @@
<!-- JSON参数输入组件 - 通用版本 -->
<script setup lang="ts">
import type { JsonParamsInputType } from '#/views/iot/utils/constants';
import type { JsonParamsInputType } from '@vben/constants';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { useVModel } from '@vueuse/core';
import { Button, Input, Popover, Tag } from 'ant-design-vue';
import {
IoTDataSpecsDataTypeEnum,
JSON_PARAMS_EXAMPLE_VALUES,
JSON_PARAMS_INPUT_CONSTANTS,
JSON_PARAMS_INPUT_ICONS,
JsonParamsInputTypeEnum,
} from '#/views/iot/utils/constants';
} from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { useVModel } from '@vueuse/core';
import { Button, Input, Popover, Tag } from 'ant-design-vue';
/** JSON参数输入组件 - 通用版本 */
defineOptions({ name: 'JsonParamsInput' });
@ -429,28 +428,17 @@ watch(
<div class="absolute right-2 top-2">
<Popover
placement="leftTop"
:width="450"
:overlay-style="{ width: '450px' }"
trigger="click"
:show-arrow="true"
:offset="8"
popper-class="json-params-detail-popover"
:arrow="true"
overlay-class-name="json-params-detail-popover"
>
<template #reference>
<Button
type="link"
shape="circle"
size="small"
:title="JSON_PARAMS_INPUT_CONSTANTS.VIEW_EXAMPLE_TITLE"
>
<IconifyIcon icon="ep:info-filled" />
</Button>
</template>
<!-- 弹出层内容 -->
<div class="json-params-detail-content">
<template #content>
<!-- 弹出层内容 -->
<div class="json-params-detail-content">
<div class="mb-4 flex items-center gap-2">
<IconifyIcon :icon="titleIcon" class="text-lg text-primary" />
<span class="text-base font-bold text-primary">
<span class="text-base font-bold text-foreground">
{{ title }}
</span>
</div>
@ -463,7 +451,7 @@ watch(
:icon="paramsIcon"
class="text-base text-primary"
/>
<span class="text-base font-bold text-primary">
<span class="text-base font-bold text-foreground">
{{ paramsLabel }}
</span>
</div>
@ -474,7 +462,7 @@ watch(
class="flex items-center justify-between rounded-lg bg-card p-2"
>
<div class="flex-1">
<div class="text-base font-bold text-primary">
<div class="text-base font-bold text-foreground">
{{ param.name }}
<Tag
v-if="param.required"
@ -484,7 +472,7 @@ watch(
{{ JSON_PARAMS_INPUT_CONSTANTS.REQUIRED_TAG }}
</Tag>
</div>
<div class="text-xs text-secondary">
<div class="text-xs text-muted-foreground">
{{ param.identifier }}
</div>
</div>
@ -492,7 +480,7 @@ watch(
<Tag :color="getParamTypeTag(param.dataType)">
{{ getParamTypeName(param.dataType) }}
</Tag>
<span class="text-xs text-secondary">
<span class="text-xs text-muted-foreground">
{{ getExampleValue(param) }}
</span>
</div>
@ -500,11 +488,11 @@ watch(
</div>
<div class="ml-6 mt-3">
<div class="mb-1 text-xs text-secondary">
<div class="mb-1 text-xs text-muted-foreground">
{{ JSON_PARAMS_INPUT_CONSTANTS.COMPLETE_JSON_FORMAT }}
</div>
<pre
class="border-l-[3px] overflow-x-auto rounded-lg border-primary bg-card p-3 text-sm text-primary"
class="border-l-[3px] overflow-x-auto rounded-lg border-primary bg-card p-3 text-sm text-foreground"
>
<code>{{ generateExampleJson() }}</code>
</pre>
@ -514,13 +502,22 @@ watch(
<!-- 无参数提示 -->
<div v-else>
<div class="py-4 text-center">
<p class="text-sm text-secondary">
<p class="text-sm text-muted-foreground">
{{ emptyMessage }}
</p>
</div>
</div>
</div>
</div>
</div>
</template>
<Button
type="link"
shape="circle"
size="small"
:title="JSON_PARAMS_INPUT_CONSTANTS.VIEW_EXAMPLE_TITLE"
>
<IconifyIcon icon="ep:info-filled" />
</Button>
</Popover>
</div>
</div>
@ -547,7 +544,7 @@ watch(
<!-- 快速填充按钮 -->
<div v-if="paramsList.length > 0" class="flex items-center gap-2">
<span class="text-xs text-secondary">
<span class="text-xs text-muted-foreground">
{{ JSON_PARAMS_INPUT_CONSTANTS.QUICK_FILL_LABEL }}
</span>
<Button size="small" type="primary" plain @click="fillExampleJson">

View File

@ -2,13 +2,13 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useVModel } from '@vueuse/core';
import { DatePicker, Input, Select, Tag, Tooltip } from 'ant-design-vue';
import {
IoTDataSpecsDataTypeEnum,
IotRuleSceneTriggerConditionParameterOperatorEnum,
} from '#/views/iot/utils/constants';
} from '@vben/constants';
import { useVModel } from '@vueuse/core';
import { DatePicker, Input, Select, Tag, Tooltip } from 'ant-design-vue';
/** 值输入组件 */
defineOptions({ name: 'ValueInput' });
@ -197,7 +197,7 @@ watch(
class="min-w-0 flex-1"
style="width: auto !important"
/>
<span class="whitespace-nowrap text-xs text-secondary"> </span>
<span class="whitespace-nowrap text-xs text-muted-foreground"> </span>
<Input
v-model:value="rangeEnd"
:type="getInputType()"
@ -232,7 +232,7 @@ watch(
v-if="listPreview.length > 0"
class="mt-2 flex flex-wrap items-center gap-1"
>
<span class="text-xs text-secondary"> 解析结果 </span>
<span class="text-xs text-muted-foreground"> 解析结果 </span>
<Tag
v-for="(item, index) in listPreview"
:key="index"
@ -282,7 +282,7 @@ watch(
:content="`单位:${propertyConfig.unit}`"
placement="top"
>
<span class="px-1 text-xs text-secondary">
<span class="px-1 text-xs text-muted-foreground">
{{ propertyConfig.unit }}
</span>
</Tooltip>

View File

@ -2,16 +2,15 @@
<script setup lang="ts">
import type { Action } from '#/api/iot/rule/scene';
import { IconifyIcon } from '@vben/icons';
import { useVModel } from '@vueuse/core';
import { Button, Card, Empty, Form, Select, Tag } from 'ant-design-vue';
import {
getActionTypeLabel,
getActionTypeOptions,
IotRuleSceneActionTypeEnum,
} from '#/views/iot/utils/constants';
} from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { useVModel } from '@vueuse/core';
import { Button, Card, Empty, Form, Select, Tag } from 'ant-design-vue';
import AlertConfig from '../configs/alert-config.vue';
import DeviceControlConfig from '../configs/device-control-config.vue';
@ -154,7 +153,7 @@ function onActionTypeChange(action: Action, type: number) {
<div class="flex items-center justify-between">
<div class="gap-[8px] flex items-center">
<IconifyIcon icon="ep:setting" class="text-[18px] text-primary" />
<span class="text-[16px] font-semibold text-primary"> 执行器配置 </span>
<span class="text-[16px] font-semibold text-foreground"> 执行器配置 </span>
<Tag color="default"> {{ actions.length }} 个执行器 </Tag>
</div>
<div class="gap-[8px] flex items-center">
@ -229,9 +228,9 @@ function onActionTypeChange(action: Action, type: number) {
<Select
:model-value="action.type"
@update:model-value="
(value: number) => updateActionType(index, value)
(value: any) => updateActionType(index, value as number)
"
@change="(value) => onActionTypeChange(action, value)"
@change="(value: any) => onActionTypeChange(action, value as number)"
placeholder="请选择执行类型"
class="w-full"
>
@ -275,10 +274,10 @@ function onActionTypeChange(action: Action, type: number) {
>
<div class="mb-2 flex items-center gap-2">
<IconifyIcon icon="ep:warning" class="text-base text-warning" />
<span class="font-semibold text-sm text-primary">触发告警</span>
<span class="font-semibold text-sm text-foreground">触发告警</span>
<Tag color="warning">自动执行</Tag>
</div>
<div class="text-xs leading-relaxed text-secondary">
<div class="text-xs leading-relaxed text-muted-foreground">
当触发条件满足时系统将自动发送告警通知可在菜单 [告警中心 ->
告警配置] 管理
</div>

View File

@ -31,7 +31,7 @@ const formData = useVModel(props, 'modelValue', emit); // 表单数据
<div class="flex items-center justify-between">
<div class="gap-[8px] flex items-center">
<IconifyIcon icon="ep:info-filled" class="text-[18px] text-primary" />
<span class="text-[16px] font-semibold text-primary">基础信息</span>
<span class="text-[16px] font-semibold text-foreground">基础信息</span>
</div>
<div class="gap-[8px] flex items-center">
<DictTag :type="DICT_TYPE.COMMON_STATUS" :value="formData.status" />
@ -48,7 +48,7 @@ const formData = useVModel(props, 'modelValue', emit); // 表单数据
placeholder="请输入场景名称"
:maxlength="50"
show-word-limit
clearable
allow-clear
/>
</Form.Item>
</Col>

View File

@ -3,17 +3,17 @@ import type { Trigger, TriggerCondition } from '#/api/iot/rule/scene';
import { onMounted } from 'vue';
import {
getTriggerTypeLabel,
IotRuleSceneTriggerTypeEnum,
isDeviceTrigger,
} from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { useVModel } from '@vueuse/core';
import { Button, Card, Empty, Form, Tag } from 'ant-design-vue';
import { CronTab } from '#/components/cron-tab';
import {
getTriggerTypeLabel,
IotRuleSceneTriggerTypeEnum,
isDeviceTrigger,
} from '#/views/iot/utils/constants';
import DeviceTriggerConfig from '../configs/device-trigger-config.vue';
import TimerConditionGroupConfig from '../configs/timer-condition-group-config.vue';
@ -66,7 +66,7 @@ function removeTrigger(index: number) {
/** 更新触发器类型 */
function updateTriggerType(index: number, type: number) {
triggers.value[index]!.type = type;
onTriggerTypeChange(index, type);
onTriggerTypeChange(index);
}
/** 更新触发器设备配置 */
@ -88,7 +88,7 @@ function updateTriggerConditionGroups(
}
/** 触发器类型切换后清空相关字段 */
function onTriggerTypeChange(index: number, _: number) {
function onTriggerTypeChange(index: number) {
const triggerItem = triggers.value[index]!;
triggerItem.productId = undefined;
triggerItem.deviceId = undefined;
@ -113,7 +113,7 @@ onMounted(() => {
<div class="flex items-center justify-between">
<div class="gap-[8px] flex items-center">
<IconifyIcon icon="ep:lightning" class="text-[18px] text-primary" />
<span class="text-[16px] font-semibold text-primary">触发器配置</span>
<span class="text-[16px] font-semibold text-foreground">触发器配置</span>
<Tag color="default"> {{ triggers.length }} 个触发器 </Tag>
</div>
<Button type="primary" size="small" @click="addTrigger">
@ -196,7 +196,7 @@ onMounted(() => {
icon="lucide:timer"
class="text-[18px] text-danger"
/>
<span class="text-[14px] font-medium text-primary">
<span class="text-[14px] font-medium text-foreground">
定时触发配置
</span>
</div>
@ -232,8 +232,8 @@ onMounted(() => {
<Empty description="暂无触发器">
<template #description>
<div class="space-y-[8px]">
<p class="text-secondary">暂无触发器配置</p>
<p class="text-[12px] text-primary">
<p class="text-muted-foreground">暂无触发器配置</p>
<p class="text-[12px] text-muted-foreground">
请使用上方的"添加触发器"按钮来设置触发规则
</p>
</div>

View File

@ -2,13 +2,12 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { DEVICE_SELECTOR_OPTIONS, DICT_TYPE } from '@vben/constants';
import { Select } from 'ant-design-vue';
import { getDeviceListByProductId } from '#/api/iot/device/device';
import { DictTag } from '#/components/dict-tag';
import { DEVICE_SELECTOR_OPTIONS } from '#/views/iot/utils/constants';
/** 设备选择器组件 */
defineOptions({ name: 'DeviceSelector' });
@ -26,13 +25,11 @@ const emit = defineEmits<{
const deviceLoading = ref(false); //
const deviceList = ref<any[]>([]); //
/**
* 处理选择变化事件
* @param value 选中的设备ID
*/
function handleChange(value?: number) {
emit('update:modelValue', value);
emit('change', value);
/** 处理选择变化事件 */
// TODO @AI value
function handleChange(value: any) {
emit('update:modelValue', value as number | undefined);
emit('change', value as number | undefined);
}
/**
@ -46,13 +43,12 @@ async function getDeviceList() {
try {
deviceLoading.value = true;
const res = await getDeviceListByProductId(props.productId);
deviceList.value = res || [];
const data = await getDeviceListByProductId(props.productId);
deviceList.value = [DEVICE_SELECTOR_OPTIONS.ALL_DEVICES, ...(data || [])];
} catch (error) {
console.error('获取设备列表失败:', error);
deviceList.value = [];
deviceList.value = [DEVICE_SELECTOR_OPTIONS.ALL_DEVICES];
} finally {
deviceList.value.unshift(DEVICE_SELECTOR_OPTIONS.ALL_DEVICES);
deviceLoading.value = false;
}
}
@ -81,8 +77,8 @@ watch(
:value="modelValue"
@change="handleChange"
placeholder="请选择设备"
filterable
clearable
show-search
allow-clear
class="w-full"
:loading="deviceLoading"
:disabled="!productId"
@ -95,10 +91,10 @@ watch(
>
<div class="py-[4px] flex w-full items-center justify-between">
<div class="flex-1">
<div class="text-[14px] font-medium mb-[2px] text-primary">
<div class="text-[14px] font-medium mb-[2px] text-foreground">
{{ device.deviceName }}
</div>
<div class="text-[12px] text-primary">
<div class="text-[12px] text-muted-foreground">
{{ device.deviceKey }}
</div>
</div>

View File

@ -2,13 +2,13 @@
<script setup lang="ts">
import { computed, watch } from 'vue';
import { useVModel } from '@vueuse/core';
import { Select } from 'ant-design-vue';
import {
IoTDataSpecsDataTypeEnum,
IotRuleSceneTriggerConditionParameterOperatorEnum,
} from '#/views/iot/utils/constants';
} from '@vben/constants';
import { useVModel } from '@vueuse/core';
import { Select } from 'ant-design-vue';
/** 操作符选择器组件 */
defineOptions({ name: 'OperatorSelector' });
@ -252,7 +252,7 @@ watch(
>
<div class="py-[4px] flex w-full items-center justify-between">
<div class="gap-[8px] flex items-center">
<div class="text-[14px] font-medium text-primary">
<div class="text-[14px] font-medium text-foreground">
{{ operator.label }}
</div>
<div
@ -261,7 +261,7 @@ watch(
{{ operator.symbol }}
</div>
</div>
<div class="text-[12px] text-secondary">
<div class="text-[12px] text-muted-foreground">
{{ operator.description }}
</div>
</div>

View File

@ -58,8 +58,8 @@ onMounted(() => {
:value="modelValue"
@change="(value: any) => handleChange(value)"
placeholder="请选择产品"
filterable
clearable
show-search
allow-clear
class="w-full"
:loading="productLoading"
>
@ -71,10 +71,10 @@ onMounted(() => {
>
<div class="py-[4px] flex w-full items-center justify-between">
<div class="flex-1">
<div class="text-[14px] font-medium mb-[2px] text-primary">
<div class="text-[14px] font-medium mb-[2px] text-foreground">
{{ product.name }}
</div>
<div class="text-[12px] text-secondary">
<div class="text-[12px] text-muted-foreground">
{{ product.productKey }}
</div>
</div>

View File

@ -4,22 +4,21 @@ import type { ThingModelApi } from '#/api/iot/thingmodel';
import { computed, ref, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { useVModel } from '@vueuse/core';
import { Button, Popover, Select, Tag } from 'ant-design-vue';
import { getThingModelListByProductId } from '#/api/iot/thingmodel';
import {
getAccessModeLabel,
getDataTypeName,
getDataTypeTagColor,
getEventTypeLabel,
getThingModelServiceCallTypeLabel,
IotRuleSceneTriggerTypeEnum,
IoTThingModelTypeEnum,
THING_MODEL_GROUP_LABELS,
} from '#/views/iot/utils/constants';
} from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { useVModel } from '@vueuse/core';
import { Button, Popover, Select, Tag } from 'ant-design-vue';
import { getThingModelTSL } from '#/api/iot/thingmodel';
/** 属性选择器组件 */
defineOptions({ name: 'PropertySelector' });
@ -58,197 +57,125 @@ interface PropertySelectorItem {
const localValue = useVModel(props, 'modelValue', emit);
const loading = ref(false); //
const propertyList = ref<ThingModelApi.Property[]>([]); //
const thingModelTSL = ref<null | ThingModelApi.ThingModel>(null); // TSL
const loading = ref(false);
const propertyList = ref<PropertySelectorItem[]>([]);
//
/** 触发类型 → 物模型类型 + 分组标签 */
const TRIGGER_TYPE_TO_GROUP: Record<
number,
{ label: string; modelType: number }
> = {
[IotRuleSceneTriggerTypeEnum.DEVICE_PROPERTY_POST]: {
label: THING_MODEL_GROUP_LABELS.PROPERTY,
modelType: IoTThingModelTypeEnum.PROPERTY,
},
[IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST]: {
label: THING_MODEL_GROUP_LABELS.EVENT,
modelType: IoTThingModelTypeEnum.EVENT,
},
[IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE]: {
label: THING_MODEL_GROUP_LABELS.SERVICE,
modelType: IoTThingModelTypeEnum.SERVICE,
},
};
/** 属性分组:按触发类型筛选属性 / 事件 / 服务 */
const propertyGroups = computed(() => {
const groups: { label: string; options: any[] }[] = [];
if (props.triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_PROPERTY_POST) {
groups.push({
label: THING_MODEL_GROUP_LABELS.PROPERTY,
options: propertyList.value.filter(
(property: any) => property.type === IoTThingModelTypeEnum.PROPERTY,
),
});
}
if (props.triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST) {
groups.push({
label: THING_MODEL_GROUP_LABELS.EVENT,
options: propertyList.value.filter(
(property: any) => property.type === IoTThingModelTypeEnum.EVENT,
),
});
}
if (props.triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE) {
groups.push({
label: THING_MODEL_GROUP_LABELS.SERVICE,
options: propertyList.value.filter(
(property: any) => property.type === IoTThingModelTypeEnum.SERVICE,
),
});
}
return groups.filter((group) => group.options.length > 0);
const config = TRIGGER_TYPE_TO_GROUP[props.triggerType];
if (!config) return [];
const options = propertyList.value.filter(
(item) => item.type === config.modelType,
);
return options.length > 0 ? [{ label: config.label, options }] : [];
});
//
const selectedProperty = computed(() => {
return propertyList.value.find((p) => p.identifier === localValue.value);
});
/** 当前选中的属性 */
const selectedProperty = computed(() =>
propertyList.value.find((p) => p.identifier === localValue.value),
);
/**
* 处理选择变化事件
* @param value 选中的属性标识符
*/
/** 处理选择变化事件 */
function handleChange(value: any) {
const property = propertyList.value.find((p) => p.identifier === value);
if (property) {
emit('change', {
type: property.dataType,
config: property,
});
emit('change', { type: property.dataType, config: property });
}
}
/**
* 获取物模型TSL数据
*/
async function getThingModelTSL() {
/** 获取物模型 TSL 数据 */
async function loadThingModelTSL() {
if (!props.productId) {
thingModelTSL.value = null;
propertyList.value = [];
return;
}
loading.value = true;
try {
const tslData = await getThingModelListByProductId(props.productId);
if (tslData) {
thingModelTSL.value = tslData;
parseThingModelData();
} else {
console.error('获取物模型TSL失败: 返回数据为空');
propertyList.value = [];
}
const tsl = await getThingModelTSL(props.productId);
propertyList.value = parseThingModelData(tsl);
} catch (error) {
console.error('获取物模型TSL失败:', error);
console.error('获取物模型 TSL 失败:', error);
propertyList.value = [];
} finally {
loading.value = false;
}
}
/** 解析物模型 TSL 数据 */
function parseThingModelData() {
const tsl = thingModelTSL.value;
const properties: PropertySelectorItem[] = [];
if (!tsl) {
propertyList.value = properties;
return;
}
//
// TODO @AI linter
if (tsl.properties && Array.isArray(tsl.properties)) {
tsl.properties.forEach((prop) => {
properties.push({
identifier: prop.identifier,
name: prop.name,
description: prop.description,
dataType: prop.dataType,
type: IoTThingModelTypeEnum.PROPERTY,
accessMode: prop.accessMode,
required: prop.required,
unit: getPropertyUnit(prop),
range: getPropertyRange(prop),
property: prop,
});
});
}
//
// TODO @AI linter
if (tsl.events && Array.isArray(tsl.events)) {
tsl.events.forEach((event) => {
properties.push({
identifier: event.identifier,
name: event.name,
description: event.description,
dataType: 'struct',
type: IoTThingModelTypeEnum.EVENT,
eventType: event.type,
required: event.required,
outputParams: event.outputParams,
event,
});
});
}
//
if (tsl.services && Array.isArray(tsl.services)) {
tsl.services.forEach((service) => {
properties.push({
identifier: service.identifier,
name: service.name,
description: service.description,
dataType: 'struct',
type: IoTThingModelTypeEnum.SERVICE,
callType: service.callType,
required: service.required,
inputParams: service.inputParams,
outputParams: service.outputParams,
service,
});
});
}
propertyList.value = properties;
/** 把 TSL 树展平为 PropertySelectorItem[] */
function parseThingModelData(
tsl?: null | ThingModelApi.ThingModelTSL,
): PropertySelectorItem[] {
if (!tsl) return [];
const properties = (tsl.properties ?? []).map<PropertySelectorItem>(
(prop) => ({
identifier: prop.identifier!,
name: prop.name!,
description: prop.description,
dataType: prop.dataType!,
type: IoTThingModelTypeEnum.PROPERTY,
accessMode: prop.accessMode,
required: prop.required,
unit: prop.dataSpecs?.unit,
range: getPropertyRange(prop),
property: prop,
}),
);
const events = (tsl.events ?? []).map<PropertySelectorItem>((event) => ({
identifier: event.identifier!,
name: event.name!,
description: event.description,
dataType: 'struct',
type: IoTThingModelTypeEnum.EVENT,
eventType: event.type,
required: event.required,
outputParams: event.outputParams,
event,
}));
const services = (tsl.services ?? []).map<PropertySelectorItem>((service) => ({
identifier: service.identifier!,
name: service.name!,
description: service.description,
dataType: 'struct',
type: IoTThingModelTypeEnum.SERVICE,
callType: service.callType,
required: service.required,
inputParams: service.inputParams,
outputParams: service.outputParams,
service,
}));
return [...properties, ...events, ...services];
}
/**
* 获取属性单位
* @param property 属性对象
* @returns 属性单位
*/
function getPropertyUnit(property: any) {
if (!property) return undefined;
//
if (property.dataSpecs && property.dataSpecs.unit) {
return property.dataSpecs.unit;
/** 获取属性取值范围:数值型给 min~max枚举 / 布尔给选项列表 */
function getPropertyRange(property: ThingModelApi.Property): string | undefined {
const specs = property.dataSpecs;
if (specs && specs.min !== undefined && specs.max !== undefined) {
return `${specs.min}~${specs.max}`;
}
return undefined;
}
/**
* 获取属性范围描述
* @param property 属性对象
* @returns 属性范围描述
*/
function getPropertyRange(property: any) {
if (!property) return undefined;
//
if (property.dataSpecs) {
const specs = property.dataSpecs;
if (specs.min !== undefined && specs.max !== undefined) {
return `${specs.min}~${specs.max}`;
}
}
//
if (property.dataSpecsList && Array.isArray(property.dataSpecsList)) {
if (property.dataSpecsList?.length) {
return property.dataSpecsList
.map((item: any) => `${item.name}(${item.value})`)
.join(', ');
}
return undefined;
}
@ -256,7 +183,7 @@ function getPropertyRange(property: any) {
watch(
() => props.productId,
() => {
getThingModelTSL();
loadThingModelTSL();
},
{ immediate: true },
);
@ -275,13 +202,13 @@ watch(
<Select
v-model:value="localValue"
placeholder="请选择监控项"
filterable
clearable
show-search
allow-clear
@change="handleChange"
class="!w-[150px]"
:loading="loading"
>
<Select.OptionGroup
<Select.OptGroup
v-for="group in propertyGroups"
:key="group.label"
:label="group.label"
@ -293,60 +220,45 @@ watch(
:value="property.identifier"
>
<div class="py-[2px] flex w-full items-center justify-between">
<span class="text-[14px] font-medium flex-1 truncate text-primary">
<span class="text-[14px] font-medium flex-1 truncate text-foreground">
{{ property.name }}
</span>
<Tag
:color="getDataTypeTagColor(property.dataType)"
class="ml-[8px] flex-shrink-0"
>
<Tag class="ml-[8px] flex-shrink-0">
{{ property.identifier }}
</Tag>
</div>
</Select.Option>
</Select.OptionGroup>
</Select.OptGroup>
</Select>
<!-- 属性详情弹出层 -->
<Popover
v-if="selectedProperty"
placement="rightTop"
:width="350"
:overlay-style="{ width: '350px' }"
trigger="click"
:show-arrow="true"
:offset="8"
popper-class="property-detail-popover"
:arrow="true"
overlay-class-name="property-detail-popover"
>
<template #reference>
<Button
type="link"
shape="circle"
size="small"
class="flex-shrink-0"
title="查看属性详情"
>
<IconifyIcon icon="ep:info-filled" />
</Button>
</template>
<!-- 弹出层内容 -->
<div class="property-detail-content">
<template #content>
<!-- 弹出层内容 -->
<div class="property-detail-content">
<div class="gap-[8px] mb-[12px] flex items-center">
<IconifyIcon icon="ep:info-filled" class="text-[16px] text-info" />
<span class="text-[14px] font-medium text-primary">
<span class="text-[14px] font-medium text-foreground">
{{ selectedProperty.name }}
</span>
<Tag :color="getDataTypeTagColor(selectedProperty.dataType)">
<Tag>
{{ getDataTypeName(selectedProperty.dataType) }}
</Tag>
</div>
<div class="space-y-[8px] ml-[24px]">
<div class="gap-[8px] flex items-start">
<span class="text-[12px] min-w-[60px] flex-shrink-0 text-secondary">
<span class="text-[12px] min-w-[60px] flex-shrink-0 text-muted-foreground">
标识符
</span>
<span class="text-[12px] flex-1 text-primary">
<span class="text-[12px] flex-1 text-foreground">
{{ selectedProperty.identifier }}
</span>
</div>
@ -355,28 +267,28 @@ watch(
v-if="selectedProperty.description"
class="gap-[8px] flex items-start"
>
<span class="text-[12px] min-w-[60px] flex-shrink-0 text-secondary">
<span class="text-[12px] min-w-[60px] flex-shrink-0 text-muted-foreground">
描述
</span>
<span class="text-[12px] flex-1 text-primary">
<span class="text-[12px] flex-1 text-foreground">
{{ selectedProperty.description }}
</span>
</div>
<div v-if="selectedProperty.unit" class="gap-[8px] flex items-start">
<span class="text-[12px] min-w-[60px] flex-shrink-0 text-secondary">
<span class="text-[12px] min-w-[60px] flex-shrink-0 text-muted-foreground">
单位
</span>
<span class="text-[12px] flex-1 text-primary">
<span class="text-[12px] flex-1 text-foreground">
{{ selectedProperty.unit }}
</span>
</div>
<div v-if="selectedProperty.range" class="gap-[8px] flex items-start">
<span class="text-[12px] min-w-[60px] flex-shrink-0 text-secondary">
<span class="text-[12px] min-w-[60px] flex-shrink-0 text-muted-foreground">
取值范围
</span>
<span class="text-[12px] flex-1 text-primary">
<span class="text-[12px] flex-1 text-foreground">
{{ selectedProperty.range }}
</span>
</div>
@ -389,10 +301,10 @@ watch(
"
class="gap-[8px] flex items-start"
>
<span class="text-[12px] min-w-[60px] flex-shrink-0 text-secondary">
<span class="text-[12px] min-w-[60px] flex-shrink-0 text-muted-foreground">
访问模式
</span>
<span class="text-[12px] flex-1 text-primary">
<span class="text-[12px] flex-1 text-foreground">
{{ getAccessModeLabel(selectedProperty.accessMode) }}
</span>
</div>
@ -404,10 +316,10 @@ watch(
"
class="gap-[8px] flex items-start"
>
<span class="text-[12px] min-w-[60px] flex-shrink-0 text-secondary">
<span class="text-[12px] min-w-[60px] flex-shrink-0 text-muted-foreground">
事件类型
</span>
<span class="text-[12px] flex-1 text-primary">
<span class="text-[12px] flex-1 text-foreground">
{{ getEventTypeLabel(selectedProperty.eventType) }}
</span>
</div>
@ -419,15 +331,25 @@ watch(
"
class="gap-[8px] flex items-start"
>
<span class="text-[12px] min-w-[60px] flex-shrink-0 text-secondary">
<span class="text-[12px] min-w-[60px] flex-shrink-0 text-muted-foreground">
调用类型
</span>
<span class="text-[12px] flex-1 text-primary">
<span class="text-[12px] flex-1 text-foreground">
{{ getThingModelServiceCallTypeLabel(selectedProperty.callType) }}
</span>
</div>
</div>
</div>
</div>
</template>
<Button
type="link"
shape="circle"
size="small"
class="flex-shrink-0"
title="查看属性详情"
>
<IconifyIcon icon="ep:info-filled" />
</Button>
</Popover>
</div>
</template>

View File

@ -5,7 +5,13 @@ import type { RuleSceneApi } from '#/api/iot/rule/scene';
import { ref } from 'vue';
import { Page, useVbenDrawer } from '@vben/common-ui';
import { CommonStatusEnum } from '@vben/constants';
import {
CommonStatusEnum,
DICT_TYPE,
getActionTypeLabel,
getTriggerTypeLabel,
IotRuleSceneTriggerTypeEnum,
} from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { formatDateTime } from '@vben/utils';
@ -17,14 +23,9 @@ import {
getSceneRulePage,
updateSceneRuleStatus,
} from '#/api/iot/rule/scene';
import { DictTag } from '#/components/dict-tag';
import { $t } from '#/locales';
import { CronUtils } from '#/utils/cron';
import {
getActionTypeLabel,
getTriggerTypeLabel,
IotRuleSceneActionTypeEnum,
IotRuleSceneTriggerTypeEnum,
} from '#/views/iot/utils/constants';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
@ -105,109 +106,50 @@ function hasTimerTrigger(row: RuleSceneApi.SceneRule): boolean {
);
}
/** 触发器列表项(用于列内多 tag 渲染) */
interface TriggerCellItem {
color: string;
label: string;
meta?: string;
/** 触发条件摘要文本(拼接所有触发器) */
function getTriggerSummary(row: RuleSceneApi.SceneRule): string {
if (!row.triggers?.length) return '无触发器';
return row.triggers
.map((trigger) => {
const type = trigger.type ?? 0;
let description = getTriggerTypeLabel(type);
if (
(type === IotRuleSceneTriggerTypeEnum.DEVICE_PROPERTY_POST ||
type === IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST ||
type === IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE) &&
trigger.identifier
) {
description += ` (${trigger.identifier})`;
} else if (type === IotRuleSceneTriggerTypeEnum.TIMER) {
description = `${getTriggerTypeLabel(type)} (${CronUtils.format(trigger.cronExpression || '')})`;
}
if (trigger.deviceId) {
description += ` [设备 ID: ${trigger.deviceId}]`;
} else if (trigger.productId) {
description += ` [产品 ID: ${trigger.productId}]`;
}
return description;
})
.join(', ');
}
/** 动作列表项 */
interface ActionCellItem {
color: string;
label: string;
meta?: string;
}
/** 触发器 → tag 颜色(按 5 种类型区分) */
function colorOfTrigger(type?: number): string {
switch (type) {
case IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST: {
return 'orange';
}
case IotRuleSceneTriggerTypeEnum.DEVICE_PROPERTY_POST: {
return 'blue';
}
case IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE: {
return 'purple';
}
case IotRuleSceneTriggerTypeEnum.DEVICE_STATE_UPDATE: {
return 'cyan';
}
case IotRuleSceneTriggerTypeEnum.TIMER: {
return 'gold';
}
default: {
return 'default';
}
}
}
/** 动作 → tag 颜色(按 4 种类型区分) */
function colorOfAction(type?: number): string {
switch (type) {
case IotRuleSceneActionTypeEnum.ALERT_RECOVER: {
return 'green';
}
case IotRuleSceneActionTypeEnum.ALERT_TRIGGER: {
return 'red';
}
case IotRuleSceneActionTypeEnum.DEVICE_PROPERTY_SET: {
return 'blue';
}
case IotRuleSceneActionTypeEnum.DEVICE_SERVICE_INVOKE: {
return 'purple';
}
default: {
return 'default';
}
}
}
/** 触发器列:每个触发器一项 */
function getTriggerCellItems(row: RuleSceneApi.SceneRule): TriggerCellItem[] {
if (!row.triggers?.length) {
return [];
}
return row.triggers.map((trigger) => {
const type = trigger.type ?? 0;
let label = getTriggerTypeLabel(type);
if (
(type === IotRuleSceneTriggerTypeEnum.DEVICE_PROPERTY_POST ||
type === IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST ||
type === IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE) &&
trigger.identifier
) {
label += ` · ${trigger.identifier}`;
} else if (type === IotRuleSceneTriggerTypeEnum.TIMER) {
label += ` · ${CronUtils.format(trigger.cronExpression || '')}`;
}
const meta = trigger.deviceId
? `设备 #${trigger.deviceId}`
: (trigger.productId
? `产品 #${trigger.productId}`
: '');
return { color: colorOfTrigger(type), label, meta };
});
}
/** 动作列:每个动作一项 */
function getActionCellItems(row: RuleSceneApi.SceneRule): ActionCellItem[] {
if (!row.actions?.length) {
return [];
}
return row.actions.map((action) => {
const type = action.type ?? 0;
const label = getActionTypeLabel(type);
const meta = action.deviceId
? `设备 #${action.deviceId}`
: action.productId
? `产品 #${action.productId}`
: action.alertConfigId
? `告警 #${action.alertConfigId}`
: '';
return { color: colorOfAction(type), label, meta };
});
/** 执行动作摘要文本(拼接所有动作) */
function getActionSummary(row: RuleSceneApi.SceneRule): string {
if (!row.actions?.length) return '无执行器';
return row.actions
.map((action) => {
let description = getActionTypeLabel(action.type ?? 0);
if (action.deviceId) {
description += ` [设备 ID: ${action.deviceId}]`;
} else if (action.productId) {
description += ` [产品 ID: ${action.productId}]`;
}
if (action.alertConfigId) {
description += ` [告警配置 ID: ${action.alertConfigId}]`;
}
return description;
})
.join(', ');
}
/** 取定时触发器的 CRON 频率描述 */
@ -304,7 +246,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
<div class="text-xl font-semibold">
{{ statistics.total }}
</div>
<div class="text-xs text-secondary">总规则数</div>
<div class="text-xs text-muted-foreground">总规则数</div>
</div>
</div>
</Card>
@ -321,7 +263,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
<div class="text-xl font-semibold">
{{ statistics.enabled }}
</div>
<div class="text-xs text-secondary">启用规则</div>
<div class="text-xs text-muted-foreground">启用规则</div>
</div>
</div>
</Card>
@ -338,7 +280,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
<div class="text-xl font-semibold">
{{ statistics.disabled }}
</div>
<div class="text-xs text-secondary">禁用规则</div>
<div class="text-xs text-muted-foreground">禁用规则</div>
</div>
</div>
</Card>
@ -355,7 +297,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
<div class="text-xl font-semibold">
{{ statistics.timerRules }}
</div>
<div class="text-xs text-secondary">定时规则</div>
<div class="text-xs text-muted-foreground">定时规则</div>
</div>
</div>
</Card>
@ -375,65 +317,51 @@ const [Grid, gridApi] = useVbenVxeGrid({
]"
/>
</template>
<!-- 规则名称列名称 + 状态 + 描述 -->
<!-- 规则名称列名称 + 状态 tag inline + 描述 -->
<template #name="{ row }">
<div class="gap-2 flex items-center">
<span class="font-medium">{{ row.name }}</span>
<div class="flex items-center gap-2">
<span class="font-medium text-foreground">{{ row.name }}</span>
<DictTag :type="DICT_TYPE.COMMON_STATUS" :value="row.status" />
</div>
<Tooltip
v-if="row.description"
:title="row.description"
placement="top"
>
<div class="text-xs text-secondary mt-1 truncate max-w-[160px]">
<div class="mt-1 max-w-[200px] truncate text-xs text-muted-foreground">
{{ row.description }}
</div>
</Tooltip>
</template>
<!-- 触发条件列按触发器各显示一项 -->
<!-- 触发条件列tag 汇总 + 定时触发额外信息 -->
<template #triggers="{ row }">
<div v-if="getTriggerCellItems(row).length > 0" class="flex flex-col gap-1">
<div
v-for="(item, i) in getTriggerCellItems(row)"
:key="`trigger-${i}`"
class="flex items-center gap-1"
>
<Tag :color="item.color" class="m-0">{{ item.label }}</Tag>
<span v-if="item.meta" class="text-xs text-secondary">
{{ item.meta }}
</span>
</div>
<div class="space-y-1">
<Tag color="processing" class="m-0">{{ getTriggerSummary(row) }}</Tag>
<Tooltip
v-if="hasTimerTrigger(row)"
:title="getCronExpression(row)"
placement="top"
>
<span class="text-xs text-secondary">
<div class="text-xs text-muted-foreground">
<IconifyIcon icon="lucide:clock" class="mr-1 inline" />
{{ getCronFrequency(row) }}
<template v-if="getNextExecutionTime(row)">
· 下次 {{ formatDateTime(getNextExecutionTime(row) as Date) }}
</template>
</span>
</div>
</Tooltip>
</div>
<span v-else class="text-xs text-secondary">无触发器</span>
</template>
<!-- 执行动作列按动作各显示一项 -->
<!-- 执行动作列tag 汇总 -->
<template #actionsCol="{ row }">
<div v-if="getActionCellItems(row).length > 0" class="flex flex-col gap-1">
<div
v-for="(item, i) in getActionCellItems(row)"
:key="`action-${i}`"
class="flex items-center gap-1"
>
<Tag :color="item.color" class="m-0">{{ item.label }}</Tag>
<span v-if="item.meta" class="text-xs text-secondary">
{{ item.meta }}
</span>
</div>
</div>
<span v-else class="text-xs text-secondary">无动作</span>
<Tag color="success" class="m-0">{{ getActionSummary(row) }}</Tag>
</template>
<!-- 最近触发列 -->
<template #lastTriggeredTime="{ row }">
<span v-if="row.lastTriggeredTime">
{{ formatDateTime(row.lastTriggeredTime) }}
</span>
<span v-else class="text-muted-foreground">未触发</span>
</template>
<template #actions="{ row }">
<TableAction

View File

@ -4,7 +4,12 @@ import type { IotSceneRule, RuleSceneApi } from '#/api/iot/rule/scene';
import { computed, nextTick, reactive, ref } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { CommonStatusEnum } from '@vben/constants';
import {
CommonStatusEnum,
IotRuleSceneActionTypeEnum,
IotRuleSceneTriggerTypeEnum,
isDeviceTrigger,
} from '@vben/constants';
import { Form, message } from 'ant-design-vue';
@ -14,11 +19,6 @@ import {
updateSceneRule,
} from '#/api/iot/rule/scene';
import { $t } from '#/locales';
import {
IotRuleSceneActionTypeEnum,
IotRuleSceneTriggerTypeEnum,
isDeviceTrigger,
} from '#/views/iot/utils/constants';
import ActionSection from '../form/sections/action-section.vue';
import BasicInfoSection from '../form/sections/basic-info-section.vue';

View File

@ -2,10 +2,9 @@ import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ThingModelApi } from '#/api/iot/thingmodel';
import { DICT_TYPE } from '@vben/constants';
import { DICT_TYPE, getDataTypeOptionsLabel } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { getDataTypeOptionsLabel } from '#/views/iot/utils/constants';
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {

View File

@ -8,13 +8,13 @@ import type { ThingModelApi } from '#/api/iot/thingmodel';
import { computed, inject } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { IOT_PROVIDE_KEY } from '@vben/constants';
import { message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteThingModel, getThingModelPage } from '#/api/iot/thingmodel';
import { $t } from '#/locales';
import { IOT_PROVIDE_KEY } from '#/views/iot/utils/constants';
import { useGridColumns, useGridFormSchema } from './data';
import { DataDefinition } from './modules/components';

View File

@ -3,32 +3,31 @@ import type { ThingModelApi } from '#/api/iot/thingmodel';
import { computed } from 'vue';
import { Tooltip } from 'ant-design-vue';
import {
getEventTypeLabel,
getThingModelServiceCallTypeLabel,
IoTDataSpecsDataTypeEnum,
IoTThingModelTypeEnum,
} from '#/views/iot/utils/constants';
} from '@vben/constants';
import { Tooltip } from 'ant-design-vue';
const props = defineProps<{ data: ThingModelApi.ThingModel }>();
const NUMBER_TYPES = new Set<string>([
IoTDataSpecsDataTypeEnum.INT,
IoTDataSpecsDataTypeEnum.DOUBLE,
IoTDataSpecsDataTypeEnum.FLOAT,
IoTDataSpecsDataTypeEnum.INT,
]);
const PLACEHOLDER_TYPES = new Set<string>([
IoTDataSpecsDataTypeEnum.ARRAY,
IoTDataSpecsDataTypeEnum.STRUCT,
IoTDataSpecsDataTypeEnum.DATE,
IoTDataSpecsDataTypeEnum.STRUCT,
]);
const LIST_TYPES = new Set<string>([
IoTDataSpecsDataTypeEnum.BOOL,
IoTDataSpecsDataTypeEnum.ENUM,
]);
const props = defineProps<{ data: ThingModelApi.ThingModel }>();
const formattedDataSpecsList = computed(() => {
if (!props.data.property?.dataSpecsList?.length) {
return '';

View File

@ -2,29 +2,30 @@
<script lang="ts" setup>
import type { Ref } from 'vue';
import {
getDataTypeOptions,
IoTDataSpecsDataTypeEnum,
} from '@vben/constants';
import { useVModel } from '@vueuse/core';
import { Form, Input, Radio } from 'ant-design-vue';
import { ThingModelFormRules } from '#/api/iot/thingmodel';
import {
getDataTypeOptions,
IoTDataSpecsDataTypeEnum,
} from '#/views/iot/utils/constants';
import ThingModelStructDataSpecs from './struct.vue';
const props = defineProps<{ modelValue: any }>();
const emits = defineEmits(['update:modelValue']);
/** 数组元素禁止选择的类型 */
const EXCLUDED_CHILD_TYPES = new Set<string>([
IoTDataSpecsDataTypeEnum.ENUM,
IoTDataSpecsDataTypeEnum.ARRAY,
IoTDataSpecsDataTypeEnum.DATE,
IoTDataSpecsDataTypeEnum.ENUM,
]);
const childDataTypeOptions = getDataTypeOptions().filter(
(item) => !EXCLUDED_CHILD_TYPES.has(item.value),
);
const props = defineProps<{ modelValue: any }>();
const emits = defineEmits(['update:modelValue']);
const dataSpecs = useVModel(props, 'modelValue', emits) as Ref<any>;
/** 元素类型切到 struct 时,初始化 dataSpecsList 占位 */

View File

@ -4,13 +4,13 @@ import type { Ref } from 'vue';
import { onMounted, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { IoTDataSpecsDataTypeEnum } from '@vben/constants';
import { isEmpty } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import { Button, Divider, Form, Input } from 'ant-design-vue';
import { ThingModelFormRules } from '#/api/iot/thingmodel';
import { IoTDataSpecsDataTypeEnum } from '#/views/iot/utils/constants';
import ThingModelProperty from '../property.vue';

View File

@ -4,16 +4,16 @@ import type { Ref } from 'vue';
import { watch } from 'vue';
import {
IoTThingModelEventTypeEnum,
IoTThingModelParamDirectionEnum,
} from '@vben/constants';
import { isEmpty } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import { Form, Radio } from 'ant-design-vue';
import { ThingModelFormRules } from '#/api/iot/thingmodel';
import {
IoTThingModelEventTypeEnum,
IoTThingModelParamDirectionEnum,
} from '#/views/iot/utils/constants';
import ThingModelInputOutputParam from './input-output-param.vue';

View File

@ -7,7 +7,12 @@ import type { ThingModelApi } from '#/api/iot/thingmodel';
import { computed, inject, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import {
DICT_TYPE,
IOT_PROVIDE_KEY,
IoTDataSpecsDataTypeEnum,
IoTThingModelTypeEnum,
} from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { $t } from '@vben/locales';
import { cloneDeep, isEmpty } from '@vben/utils';
@ -20,11 +25,6 @@ import {
ThingModelFormRules,
updateThingModel,
} from '#/api/iot/thingmodel';
import {
IOT_PROVIDE_KEY,
IoTDataSpecsDataTypeEnum,
IoTThingModelTypeEnum,
} from '#/views/iot/utils/constants';
import ThingModelEvent from './event.vue';
import ThingModelProperty from './property.vue';
@ -51,12 +51,14 @@ const [Modal, modalApi] = useVbenModal({
return;
}
modalApi.lock();
//
const data = cloneDeep(formData.value);
data.productId = product!.value.id;
data.productKey = product!.value.productKey;
fillExtraAttributes(data);
try {
const data = cloneDeep(formData.value);
data.productId = product!.value.id;
data.productKey = product!.value.productKey;
fillExtraAttributes(data);
await (data.id ? updateThingModel(data) : createThingModel(data));
//
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
@ -68,9 +70,10 @@ const [Modal, modalApi] = useVbenModal({
if (!isOpen) {
return;
}
//
//
formData.value = buildEmptyFormData();
formRef.value?.clearValidate?.();
//
const data = modalApi.getData<{ id?: number }>();
if (!data?.id) {
return;
@ -78,6 +81,7 @@ const [Modal, modalApi] = useVbenModal({
modalApi.lock();
try {
const result = await getThingModel(data.id);
// values
formData.value = normalizeFormData(result);
} finally {
modalApi.unlock();
@ -136,14 +140,31 @@ function normalizeFormData(result: ThingModelApi.ThingModel): ThingModelApi.Thin
/** 按功能类型将子表单数据回写到顶层,并清理无关分支 */
function fillExtraAttributes(data: any) {
if (data.type === IoTThingModelTypeEnum.PROPERTY) {
switch (data.type) {
case IoTThingModelTypeEnum.EVENT: {
removeDataSpecs(data.event);
data.dataType = data.event.dataType;
data.event.identifier = data.identifier;
data.event.name = data.name;
if (isEmpty(data.event.outputParams)) {
delete data.event.outputParams;
}
delete data.property;
delete data.service;
break;
}
case IoTThingModelTypeEnum.PROPERTY: {
removeDataSpecs(data.property);
data.dataType = data.property.dataType;
data.property.identifier = data.identifier;
data.property.name = data.name;
delete data.service;
delete data.event;
} else if (data.type === IoTThingModelTypeEnum.SERVICE) {
break;
}
case IoTThingModelTypeEnum.SERVICE: {
removeDataSpecs(data.service);
data.dataType = data.service.dataType;
data.service.identifier = data.identifier;
@ -156,16 +177,10 @@ function fillExtraAttributes(data: any) {
}
delete data.property;
delete data.event;
} else if (data.type === IoTThingModelTypeEnum.EVENT) {
removeDataSpecs(data.event);
data.dataType = data.event.dataType;
data.event.identifier = data.identifier;
data.event.name = data.name;
if (isEmpty(data.event.outputParams)) {
delete data.event.outputParams;
}
delete data.property;
delete data.service;
break;
}
// No default
}
}

View File

@ -4,13 +4,13 @@ import type { Ref } from 'vue';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { IoTDataSpecsDataTypeEnum } from '@vben/constants';
import { isEmpty } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import { Button, Divider, Form, Input } from 'ant-design-vue';
import { ThingModelFormRules } from '#/api/iot/thingmodel';
import { IoTDataSpecsDataTypeEnum } from '#/views/iot/utils/constants';
import ThingModelProperty from './property.vue';
@ -31,6 +31,7 @@ const [Modal, modalApi] = useVbenModal({
if (!thingModelParams.value) {
thingModelParams.value = [];
}
//
const data = formData.value;
const item = {
identifier: data.identifier,
@ -48,7 +49,7 @@ const [Modal, modalApi] = useVbenModal({
? undefined
: data.property.dataSpecsList,
};
// identifier
// identifier
const existingIndex = thingModelParams.value.findIndex(
(spec) => spec.identifier === data.identifier,
);
@ -57,6 +58,7 @@ const [Modal, modalApi] = useVbenModal({
} else {
thingModelParams.value[existingIndex] = item;
}
//
await modalApi.close();
},
onOpenChange(isOpen: boolean) {
@ -65,10 +67,12 @@ const [Modal, modalApi] = useVbenModal({
}
formData.value = buildEmptyFormData();
paramFormRef.value?.clearValidate?.();
//
const data = modalApi.getData<any>();
if (isEmpty(data)) {
return;
}
// values
formData.value = {
identifier: data.identifier ?? '',
name: data.name ?? '',

View File

@ -6,17 +6,17 @@ import type { ThingModelApi } from '#/api/iot/thingmodel';
import { computed, watch } from 'vue';
import {
getDataTypeOptions,
IoTDataSpecsDataTypeEnum,
IoTThingModelAccessModeEnum,
} from '@vben/constants';
import { isEmpty } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import { Form, Input, Radio, Select } from 'ant-design-vue';
import { ThingModelFormRules, validateBoolName } from '#/api/iot/thingmodel';
import {
getDataTypeOptions,
IoTDataSpecsDataTypeEnum,
IoTThingModelAccessModeEnum,
} from '#/views/iot/utils/constants';
import {
ThingModelArrayDataSpecs,
@ -25,6 +25,12 @@ import {
ThingModelStructDataSpecs,
} from './data-specs';
const props = defineProps<{
isParams?: boolean;
isStructDataSpecs?: boolean;
modelValue: any;
}>();
const emits = defineEmits(['update:modelValue']);
/** 嵌套在结构体里时,禁止再选数组 / 结构体(最多支持两层嵌套) */
const NESTED_EXCLUDED_TYPES = new Set<string>([
IoTDataSpecsDataTypeEnum.ARRAY,
@ -35,17 +41,11 @@ const STRUCT_CHILD_OPTIONS = getDataTypeOptions().filter(
);
/** 数值型数据类型集合 */
const NUMERIC_TYPES = new Set<string>([
IoTDataSpecsDataTypeEnum.INT,
IoTDataSpecsDataTypeEnum.DOUBLE,
IoTDataSpecsDataTypeEnum.FLOAT,
IoTDataSpecsDataTypeEnum.INT,
]);
const props = defineProps<{
isParams?: boolean;
isStructDataSpecs?: boolean;
modelValue: any;
}>();
const emits = defineEmits(['update:modelValue']);
const property = useVModel(
props,
'modelValue',

View File

@ -4,16 +4,16 @@ import type { Ref } from 'vue';
import { watch } from 'vue';
import {
IoTThingModelParamDirectionEnum,
IoTThingModelServiceCallTypeEnum,
} from '@vben/constants';
import { isEmpty } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import { Form, Radio } from 'ant-design-vue';
import { ThingModelFormRules } from '#/api/iot/thingmodel';
import {
IoTThingModelParamDirectionEnum,
IoTThingModelServiceCallTypeEnum,
} from '#/views/iot/utils/constants';
import ThingModelInputOutputParam from './input-output-param.vue';

View File

@ -6,11 +6,11 @@ import type { IotProductApi } from '#/api/iot/product/product';
import { computed, inject, ref, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { IOT_PROVIDE_KEY } from '@vben/constants';
import { Radio, Textarea } from 'ant-design-vue';
import { getThingModelTSL } from '#/api/iot/thingmodel';
import { IOT_PROVIDE_KEY } from '#/views/iot/utils/constants';
const product = inject<Ref<IotProductApi.Product>>(IOT_PROVIDE_KEY.PRODUCT);
@ -25,7 +25,9 @@ const [Modal, modalApi] = useVbenModal({
}
modalApi.lock();
try {
//
thingModelTSL.value = await getThingModelTSL(product?.value?.id || 0);
// values
tslString.value = JSON.stringify(thingModelTSL.value, null, 2);
} finally {
modalApi.unlock();
@ -38,12 +40,12 @@ const formattedTSL = computed(() =>
JSON.stringify(thingModelTSL.value, null, 2),
);
/** 编辑器内容变化时,同步到数据对象 */
/** 编辑器内容变化时,同步到数据对象;编辑过程中 JSON 可能是中间态,解析失败保留原值 */
watch(tslString, (newValue) => {
try {
thingModelTSL.value = JSON.parse(newValue);
} catch {
// JSON
//
}
});
</script>
@ -57,14 +59,12 @@ watch(tslString, (newValue) => {
<Radio.Button value="editor">编辑器视图</Radio.Button>
</Radio.Group>
</div>
<!-- 代码视图只读展示 -->
<!-- 代码视图只读展示pre / code 必须紧贴避免显示出空白 -->
<div
v-if="viewMode === 'view'"
class="max-h-[600px] overflow-y-auto rounded border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800"
>
<pre
class="m-0 whitespace-pre-wrap break-words font-mono text-[13px] leading-normal"
><code>{{ formattedTSL }}</code></pre>
<pre class="m-0 whitespace-pre-wrap break-words font-mono text-[13px] leading-normal"><code>{{ formattedTSL }}</code></pre>
</div>
<!-- 编辑器视图可编辑 -->
<Textarea

View File

@ -1,653 +0,0 @@
// TODO @AI感觉这块放到 biz-iot-enum 里好点。
import { isEmpty } from '@vben/utils';
/** IoT 依赖注入 KEY */
export const IOT_PROVIDE_KEY = {
PRODUCT: 'IOT_PRODUCT',
};
/** IoT 设备状态枚举 */
export enum DeviceStateEnum {
INACTIVE = 0, // 未激活
ONLINE = 1, // 在线
OFFLINE = 2, // 离线
}
/** IoT 产品物模型类型枚举类 */
export const IoTThingModelTypeEnum = {
PROPERTY: 1, // 属性
SERVICE: 2, // 服务
EVENT: 3, // 事件
};
// IoT 产品物模型服务调用方式枚举
export const IoTThingModelServiceCallTypeEnum = {
ASYNC: {
label: '异步',
value: 'async',
},
SYNC: {
label: '同步',
value: 'sync',
},
};
export const getThingModelServiceCallTypeLabel = (
value: string,
): string | undefined =>
Object.values(IoTThingModelServiceCallTypeEnum).find(
(type) => type.value === value,
)?.label;
// IoT 产品物模型事件类型枚举
export const IoTThingModelEventTypeEnum = {
INFO: {
label: '信息',
value: 'info',
},
ALERT: {
label: '告警',
value: 'alert',
},
ERROR: {
label: '故障',
value: 'error',
},
};
export const getEventTypeLabel = (value: string): string | undefined =>
Object.values(IoTThingModelEventTypeEnum).find((type) => type.value === value)
?.label;
// IoT 产品物模型参数是输入参数还是输出参数
export const IoTThingModelParamDirectionEnum = {
INPUT: 'input', // 输入参数
OUTPUT: 'output', // 输出参数
};
// IoT 产品物模型访问模式枚举类
export const IoTThingModelAccessModeEnum = {
READ_WRITE: {
label: '读写',
value: 'rw',
},
READ_ONLY: {
label: '只读',
value: 'r',
},
WRITE_ONLY: {
label: '只写',
value: 'w',
},
};
/** 获取访问模式标签 */
export const getAccessModeLabel = (value: string): string => {
const mode = Object.values(IoTThingModelAccessModeEnum).find(
(mode) => mode.value === value,
);
return mode?.label || value;
};
/** 属性值的数据类型 */
export const IoTDataSpecsDataTypeEnum = {
INT: 'int',
FLOAT: 'float',
DOUBLE: 'double',
ENUM: 'enum',
BOOL: 'bool',
TEXT: 'text',
DATE: 'date',
STRUCT: 'struct',
ARRAY: 'array',
};
const DATA_TYPE_OPTIONS = Object.freeze([
{ value: IoTDataSpecsDataTypeEnum.INT, label: '整数型' },
{ value: IoTDataSpecsDataTypeEnum.FLOAT, label: '单精度浮点型' },
{ value: IoTDataSpecsDataTypeEnum.DOUBLE, label: '双精度浮点型' },
{ value: IoTDataSpecsDataTypeEnum.ENUM, label: '枚举型' },
{ value: IoTDataSpecsDataTypeEnum.BOOL, label: '布尔型' },
{ value: IoTDataSpecsDataTypeEnum.TEXT, label: '文本型' },
{ value: IoTDataSpecsDataTypeEnum.DATE, label: '时间型' },
{ value: IoTDataSpecsDataTypeEnum.STRUCT, label: '结构体' },
{ value: IoTDataSpecsDataTypeEnum.ARRAY, label: '数组' },
]);
export const getDataTypeOptions = () => DATA_TYPE_OPTIONS;
/** 获得物体模型数据类型配置项名称 */
export const getDataTypeOptionsLabel = (value: string) => {
if (isEmpty(value)) {
return value;
}
const dataType = getDataTypeOptions().find(
(option) => option.value === value,
);
return dataType && `${dataType.value}(${dataType.label})`;
};
/** 获取数据类型显示名称(用于属性选择器) */
export const getDataTypeName = (dataType: string): string => {
const typeMap: Record<string, string> = {
[IoTDataSpecsDataTypeEnum.INT]: '整数',
[IoTDataSpecsDataTypeEnum.FLOAT]: '浮点数',
[IoTDataSpecsDataTypeEnum.DOUBLE]: '双精度',
[IoTDataSpecsDataTypeEnum.TEXT]: '字符串',
[IoTDataSpecsDataTypeEnum.BOOL]: '布尔值',
[IoTDataSpecsDataTypeEnum.ENUM]: '枚举',
[IoTDataSpecsDataTypeEnum.DATE]: '日期',
[IoTDataSpecsDataTypeEnum.STRUCT]: '结构体',
[IoTDataSpecsDataTypeEnum.ARRAY]: '数组',
};
return typeMap[dataType] || dataType;
};
/** 获取数据类型标签颜色antd Tag `color` */
export const getDataTypeTagColor = (
dataType: string,
): 'default' | 'error' | 'processing' | 'success' | 'warning' => {
const tagMap: Record<
string,
'default' | 'error' | 'processing' | 'success' | 'warning'
> = {
[IoTDataSpecsDataTypeEnum.INT]: 'processing',
[IoTDataSpecsDataTypeEnum.FLOAT]: 'success',
[IoTDataSpecsDataTypeEnum.DOUBLE]: 'success',
[IoTDataSpecsDataTypeEnum.TEXT]: 'default',
[IoTDataSpecsDataTypeEnum.BOOL]: 'warning',
[IoTDataSpecsDataTypeEnum.ENUM]: 'error',
[IoTDataSpecsDataTypeEnum.DATE]: 'processing',
[IoTDataSpecsDataTypeEnum.STRUCT]: 'default',
[IoTDataSpecsDataTypeEnum.ARRAY]: 'warning',
};
return tagMap[dataType] || 'default';
};
/** 物模型组标签常量 */
export const THING_MODEL_GROUP_LABELS = {
PROPERTY: '设备属性',
EVENT: '设备事件',
SERVICE: '设备服务',
};
// IoT OTA 任务设备范围枚举
export const IoTOtaTaskDeviceScopeEnum = {
ALL: {
label: '全部设备',
value: 1,
},
SELECT: {
label: '指定设备',
value: 2,
},
};
// IoT OTA 任务状态枚举
export const IoTOtaTaskStatusEnum = {
IN_PROGRESS: {
label: '进行中',
value: 10,
},
END: {
label: '已结束',
value: 20,
},
CANCELED: {
label: '已取消',
value: 30,
},
};
// IoT OTA 升级记录状态枚举
export const IoTOtaTaskRecordStatusEnum = {
PENDING: {
label: '待推送',
value: 0,
},
PUSHED: {
label: '已推送',
value: 10,
},
UPGRADING: {
label: '升级中',
value: 20,
},
SUCCESS: {
label: '升级成功',
value: 30,
},
FAILURE: {
label: '升级失败',
value: 40,
},
CANCELED: {
label: '升级取消',
value: 50,
},
};
// ========== 场景联动规则相关常量 ==========
/** IoT 场景联动触发器类型枚举 */
export const IotRuleSceneTriggerTypeEnum = {
DEVICE_STATE_UPDATE: 1, // 设备上下线变更
DEVICE_PROPERTY_POST: 2, // 物模型属性上报
DEVICE_EVENT_POST: 3, // 设备事件上报
DEVICE_SERVICE_INVOKE: 4, // 设备服务调用
TIMER: 100, // 定时触发
};
/** 触发器类型选项配置 */
export const triggerTypeOptions = [
{
value: IotRuleSceneTriggerTypeEnum.DEVICE_STATE_UPDATE,
label: '设备状态变更',
},
{
value: IotRuleSceneTriggerTypeEnum.DEVICE_PROPERTY_POST,
label: '设备属性上报',
},
{
value: IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST,
label: '设备事件上报',
},
{
value: IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE,
label: '设备服务调用',
},
{
value: IotRuleSceneTriggerTypeEnum.TIMER,
label: '定时触发',
},
];
/** 判断是否为设备触发器类型 */
export function isDeviceTrigger(type: number): boolean {
const deviceTriggerTypes = [
IotRuleSceneTriggerTypeEnum.DEVICE_STATE_UPDATE,
IotRuleSceneTriggerTypeEnum.DEVICE_PROPERTY_POST,
IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST,
IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE,
] as number[];
return deviceTriggerTypes.includes(type);
}
// ========== 场景联动规则执行器相关常量 ==========
/** IoT 场景联动执行器类型枚举 */
export const IotRuleSceneActionTypeEnum = {
DEVICE_PROPERTY_SET: 1, // 设备属性设置
DEVICE_SERVICE_INVOKE: 2, // 设备服务调用
ALERT_TRIGGER: 100, // 告警触发
ALERT_RECOVER: 101, // 告警恢复
};
/** 执行器类型选项配置 */
export const getActionTypeOptions = () => [
{
value: IotRuleSceneActionTypeEnum.DEVICE_PROPERTY_SET,
label: '设备属性设置',
},
{
value: IotRuleSceneActionTypeEnum.DEVICE_SERVICE_INVOKE,
label: '设备服务调用',
},
{
value: IotRuleSceneActionTypeEnum.ALERT_TRIGGER,
label: '触发告警',
},
{
value: IotRuleSceneActionTypeEnum.ALERT_RECOVER,
label: '恢复告警',
},
];
/** 获取执行器类型标签 */
export const getActionTypeLabel = (type: number): string => {
const option = getActionTypeOptions().find((opt) => opt.value === type);
return option?.label || '未知类型';
};
/** IoT 场景联动触发条件参数操作符枚举 */
export const IotRuleSceneTriggerConditionParameterOperatorEnum = {
EQUALS: { name: '等于', value: '=' }, // 等于
NOT_EQUALS: { name: '不等于', value: '!=' }, // 不等于
GREATER_THAN: { name: '大于', value: '>' }, // 大于
GREATER_THAN_OR_EQUALS: { name: '大于等于', value: '>=' }, // 大于等于
LESS_THAN: { name: '小于', value: '<' }, // 小于
LESS_THAN_OR_EQUALS: { name: '小于等于', value: '<=' }, // 小于等于
IN: { name: '在...之中', value: 'in' }, // 在...之中
NOT_IN: { name: '不在...之中', value: 'not in' }, // 不在...之中
BETWEEN: { name: '在...之间', value: 'between' }, // 在...之间
NOT_BETWEEN: { name: '不在...之间', value: 'not between' }, // 不在...之间
LIKE: { name: '字符串匹配', value: 'like' }, // 字符串匹配
NOT_NULL: { name: '非空', value: 'not null' }, // 非空
};
/** IoT 场景联动触发条件类型枚举 */
export const IotRuleSceneTriggerConditionTypeEnum = {
DEVICE_STATUS: 1, // 设备状态
DEVICE_PROPERTY: 2, // 设备属性
CURRENT_TIME: 3, // 当前时间
};
/** 获取条件类型选项 */
export const getConditionTypeOptions = () => [
{
value: IotRuleSceneTriggerConditionTypeEnum.DEVICE_STATUS,
label: '设备状态',
},
{
value: IotRuleSceneTriggerConditionTypeEnum.DEVICE_PROPERTY,
label: '设备属性',
},
{
value: IotRuleSceneTriggerConditionTypeEnum.CURRENT_TIME,
label: '当前时间',
},
];
/** 设备状态枚举 - 统一的设备状态管理 */
export const IoTDeviceStatusEnum = {
// 在线状态
ONLINE: {
label: '在线',
value: 'online',
tagType: 'success',
},
OFFLINE: {
label: '离线',
value: 'offline',
tagType: 'danger',
},
// 启用状态
ENABLED: {
label: '正常',
value: 0,
value2: 'enabled',
tagType: 'success',
},
DISABLED: {
label: '禁用',
value: 1,
value2: 'disabled',
tagType: 'danger',
},
// 激活状态
ACTIVATED: {
label: '已激活',
value2: 'activated',
tagType: 'success',
},
NOT_ACTIVATED: {
label: '未激活',
value2: 'not_activated',
tagType: 'info',
},
};
/** 设备选择器特殊选项 */
export const DEVICE_SELECTOR_OPTIONS = {
ALL_DEVICES: {
id: 0,
deviceName: '全部设备',
},
};
/** IoT 场景联动触发时间操作符枚举 */
export const IotRuleSceneTriggerTimeOperatorEnum = {
BEFORE_TIME: { name: '在时间之前', value: 'before_time' }, // 在时间之前
AFTER_TIME: { name: '在时间之后', value: 'after_time' }, // 在时间之后
BETWEEN_TIME: { name: '在时间之间', value: 'between_time' }, // 在时间之间
AT_TIME: { name: '在指定时间', value: 'at_time' }, // 在指定时间
BEFORE_TODAY: { name: '在今日之前', value: 'before_today' }, // 在今日之前
AFTER_TODAY: { name: '在今日之后', value: 'after_today' }, // 在今日之后
TODAY: { name: '在今日之间', value: 'today' }, // 在今日之间
};
/** 获取触发器类型标签 */
export const getTriggerTypeLabel = (type: number): string => {
const option = triggerTypeOptions.find((item) => item.value === type);
return option?.label || '未知类型';
};
// ========== JSON 参数输入组件相关常量 ==========
/** JSON 参数输入组件类型枚举 */
export const JsonParamsInputTypeEnum = {
SERVICE: 'service',
EVENT: 'event',
PROPERTY: 'property',
CUSTOM: 'custom',
};
/** JSON 参数输入组件类型 */
export type JsonParamsInputType =
(typeof JsonParamsInputTypeEnum)[keyof typeof JsonParamsInputTypeEnum];
/** JSON 参数输入组件文本常量 */
export const JSON_PARAMS_INPUT_CONSTANTS = {
// 基础文本
PLACEHOLDER: '请输入JSON格式的参数',
JSON_FORMAT_CORRECT: 'JSON 格式正确',
QUICK_FILL_LABEL: '快速填充:',
EXAMPLE_DATA_BUTTON: '示例数据',
CLEAR_BUTTON: '清空',
VIEW_EXAMPLE_TITLE: '查看参数示例',
COMPLETE_JSON_FORMAT: '完整 JSON 格式:',
REQUIRED_TAG: '必填',
// 错误信息
PARAMS_MUST_BE_OBJECT: '参数必须是一个有效的 JSON 对象',
PARAM_REQUIRED_ERROR: (paramName: string) => `参数 ${paramName} 为必填项`,
JSON_FORMAT_ERROR: (error: string) => `JSON格式错误: ${error}`,
UNKNOWN_ERROR: '未知错误',
// 类型相关标题
TITLES: {
SERVICE: (name?: string) => `${name || '服务'} - 输入参数示例`,
EVENT: (name?: string) => `${name || '事件'} - 输出参数示例`,
PROPERTY: '属性设置 - 参数示例',
CUSTOM: (name?: string) => `${name || '自定义'} - 参数示例`,
DEFAULT: '参数示例',
},
// 参数标签
PARAMS_LABELS: {
SERVICE: '输入参数',
EVENT: '输出参数',
PROPERTY: '属性参数',
CUSTOM: '参数列表',
DEFAULT: '参数',
},
// 空状态消息
EMPTY_MESSAGES: {
SERVICE: '此服务无需输入参数',
EVENT: '此事件无输出参数',
PROPERTY: '无可设置的属性',
CUSTOM: '无参数配置',
DEFAULT: '无参数',
},
// 无配置消息
NO_CONFIG_MESSAGES: {
SERVICE: '请先选择服务',
EVENT: '请先选择事件',
PROPERTY: '请先选择产品',
CUSTOM: '请先进行配置',
DEFAULT: '请先进行配置',
},
};
/** JSON 参数输入组件图标常量 */
export const JSON_PARAMS_INPUT_ICONS = {
// 标题图标
TITLE_ICONS: {
SERVICE: 'ep:service',
EVENT: 'ep:bell',
PROPERTY: 'ep:edit',
CUSTOM: 'ep:document',
DEFAULT: 'ep:document',
},
// 参数图标
PARAMS_ICONS: {
SERVICE: 'ep:edit',
EVENT: 'ep:upload',
PROPERTY: 'ep:setting',
CUSTOM: 'ep:list',
DEFAULT: 'ep:edit',
},
// 状态图标
STATUS_ICONS: {
ERROR: 'ep:warning',
SUCCESS: 'ep:circle-check',
},
};
/** JSON 参数输入组件示例值常量 */
export const JSON_PARAMS_EXAMPLE_VALUES: Record<string, any> = {
[IoTDataSpecsDataTypeEnum.INT]: { display: '25', value: 25 },
[IoTDataSpecsDataTypeEnum.FLOAT]: { display: '25.5', value: 25.5 },
[IoTDataSpecsDataTypeEnum.DOUBLE]: { display: '25.5', value: 25.5 },
[IoTDataSpecsDataTypeEnum.BOOL]: { display: 'false', value: false },
[IoTDataSpecsDataTypeEnum.TEXT]: { display: '"auto"', value: 'auto' },
[IoTDataSpecsDataTypeEnum.ENUM]: { display: '"option1"', value: 'option1' },
[IoTDataSpecsDataTypeEnum.STRUCT]: { display: '{}', value: {} },
[IoTDataSpecsDataTypeEnum.ARRAY]: { display: '[]', value: [] },
DEFAULT: { display: '""', value: '' },
};
// ========== Modbus 通用常量 ==========
/** Modbus 模式枚举 */
export const ModbusModeEnum = {
POLLING: 1, // 云端轮询
ACTIVE_REPORT: 2, // 主动上报
} as const;
/** Modbus 帧格式枚举 */
export const ModbusFrameFormatEnum = {
MODBUS_TCP: 1, // Modbus TCP
MODBUS_RTU: 2, // Modbus RTU
} as const;
/** Modbus 功能码枚举 */
export const ModbusFunctionCodeEnum = {
READ_COILS: 1, // 读线圈
READ_DISCRETE_INPUTS: 2, // 读离散输入
READ_HOLDING_REGISTERS: 3, // 读保持寄存器
READ_INPUT_REGISTERS: 4, // 读输入寄存器
} as const;
/** Modbus 功能码选项 */
export const ModbusFunctionCodeOptions = [
{ value: 1, label: '01 - 读线圈 (Coils)', description: '可读写布尔值' },
{
value: 2,
label: '02 - 读离散输入 (Discrete Inputs)',
description: '只读布尔值',
},
{
value: 3,
label: '03 - 读保持寄存器 (Holding Registers)',
description: '可读写 16 位数据',
},
{
value: 4,
label: '04 - 读输入寄存器 (Input Registers)',
description: '只读 16 位数据',
},
];
/** Modbus 原始数据类型枚举 */
export const ModbusRawDataTypeEnum = {
INT16: 'INT16',
UINT16: 'UINT16',
INT32: 'INT32',
UINT32: 'UINT32',
FLOAT: 'FLOAT',
DOUBLE: 'DOUBLE',
BOOLEAN: 'BOOLEAN',
STRING: 'STRING',
} as const;
/** Modbus 原始数据类型选项 */
export const ModbusRawDataTypeOptions = [
{
value: 'INT16',
label: 'INT16',
description: '有符号16位整数',
registerCount: 1,
},
{
value: 'UINT16',
label: 'UINT16',
description: '无符号16位整数',
registerCount: 1,
},
{
value: 'INT32',
label: 'INT32',
description: '有符号32位整数',
registerCount: 2,
},
{
value: 'UINT32',
label: 'UINT32',
description: '无符号32位整数',
registerCount: 2,
},
{
value: 'FLOAT',
label: 'FLOAT',
description: '32位浮点数',
registerCount: 2,
},
{
value: 'DOUBLE',
label: 'DOUBLE',
description: '64位浮点数',
registerCount: 4,
},
{
value: 'BOOLEAN',
label: 'BOOLEAN',
description: '布尔值',
registerCount: 1,
},
{
value: 'STRING',
label: 'STRING',
description: '字符串',
registerCount: 0,
},
];
/** Modbus 字节序选项 - 16位 */
export const ModbusByteOrder16Options = [
{ value: 'AB', label: 'AB', description: '大端序' },
{ value: 'BA', label: 'BA', description: '小端序' },
];
/** Modbus 字节序选项 - 32位 */
export const ModbusByteOrder32Options = [
{ value: 'ABCD', label: 'ABCD', description: '大端序' },
{ value: 'CDAB', label: 'CDAB', description: '大端字交换' },
{ value: 'DCBA', label: 'DCBA', description: '小端序' },
{ value: 'BADC', label: 'BADC', description: '小端字交换' },
];
/** 根据数据类型获取字节序选项 */
export const getByteOrderOptions = (rawDataType: string) => {
if (['FLOAT', 'INT32', 'UINT32'].includes(rawDataType)) {
return ModbusByteOrder32Options;
}
if (rawDataType === 'DOUBLE') {
// 64 位暂时复用 32 位字节序
return ModbusByteOrder32Options;
}
return ModbusByteOrder16Options;
};

View File

@ -22,6 +22,9 @@ VITE_APP_DOCALERT_ENABLE=true
# 百度统计
VITE_APP_BAIDU_CODE = b79d8f49e2d38b26503b92810b740f45
# 百度地图
VITE_BAIDU_MAP_KEY=Y2aJXiswwPxy6mwFs1z9c7U5gwX9WfUN
# GoView域名
VITE_GOVIEW_URL='http://127.0.0.1:3000'

View File

@ -0,0 +1,46 @@
import type { RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
{
path: '/iot',
name: 'IoTCenter',
meta: {
title: 'IoT 物联网',
icon: 'lucide:cpu',
keepAlive: true,
hideInMenu: true,
},
children: [
{
path: 'product/detail/:id',
name: 'IoTProductDetail',
meta: {
title: '产品详情',
activePath: '/iot/device/product',
},
component: () => import('#/views/iot/product/product/detail/index.vue'),
},
{
path: 'device/detail/:id',
name: 'IoTDeviceDetail',
meta: {
title: '设备详情',
activePath: '/iot/device/device',
},
component: () => import('#/views/iot/device/device/detail/index.vue'),
},
{
path: 'ota/firmware/detail/:id',
name: 'IoTOtaFirmwareDetail',
meta: {
title: '固件详情',
activePath: '/iot/ota',
},
component: () =>
import('#/views/iot/ota/firmware/detail/index.vue'),
},
],
},
];
export default routes;

View File

@ -120,14 +120,15 @@ export function useGridColumns(): VxeTableGridOptions<AlertRecordApi.AlertRecord
title: '产品名称',
minWidth: 120,
formatter: ({ cellValue }) =>
productList.find((p) => p.id === cellValue)?.name || '-',
productList.find((product) => product.id === cellValue)?.name || '-',
},
{
field: 'deviceId',
title: '设备名称',
minWidth: 120,
formatter: ({ cellValue }) =>
deviceList.find((d) => d.id === cellValue)?.deviceName || '-',
deviceList.find((device) => device.id === cellValue)?.deviceName ||
'-',
},
{
field: 'deviceMessage',

View File

@ -101,7 +101,7 @@ export function useAdvancedFormSchema(): VbenFormSchema[] {
api: getSimpleDeviceGroupList,
labelField: 'name',
valueField: 'id',
mode: 'multiple',
multiple: true,
placeholder: '请选择设备分组',
},
},
@ -168,7 +168,7 @@ export function useGroupFormSchema(): VbenFormSchema[] {
api: getSimpleDeviceGroupList,
labelField: 'name',
valueField: 'id',
mode: 'multiple',
multiple: true,
placeholder: '请选择设备分组',
},
rules: 'required',

View File

@ -30,17 +30,13 @@ const props = defineProps<{
deviceId: number;
}>();
/** 查询参数 */
const queryParams = reactive({
method: undefined,
upstream: undefined,
});
}); //
// TODO @AI //
/** 自动刷新开关 */
const autoRefresh = ref(false);
/** 自动刷新定时器 */
let autoRefreshTimer: any = null;
const autoRefresh = ref(false); //
let autoRefreshTimer: any = null; //
/** 消息方法选项 */
const methodOptions = computed(() => {

View File

@ -5,7 +5,12 @@ import type { IotDeviceModbusConfigApi } from '#/api/iot/device/modbus/config';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { CommonStatusEnum, DICT_TYPE } from '@vben/constants';
import {
CommonStatusEnum,
DICT_TYPE,
ModbusFrameFormatEnum,
ModbusModeEnum,
} from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { ElMessage } from 'element-plus';
@ -14,10 +19,6 @@ import { useVbenForm, z } from '#/adapter/form';
import { saveModbusConfig } from '#/api/iot/device/modbus/config';
import { ProtocolTypeEnum } from '#/api/iot/product/product';
import { $t } from '#/locales';
import {
ModbusFrameFormatEnum,
ModbusModeEnum,
} from '#/views/iot/utils/constants';
const emit = defineEmits(['success']);

View File

@ -12,7 +12,7 @@ import type { DescriptionItemSchema } from '#/components/description';
import { computed, h, onMounted, ref } from 'vue';
import { confirm, useVbenModal } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { DICT_TYPE, ModbusFunctionCodeOptions } from '@vben/constants';
import { ElButton, ElMessage } from 'element-plus';
@ -25,7 +25,6 @@ import {
import { ProtocolTypeEnum } from '#/api/iot/product/product';
import { useDescription } from '#/components/description';
import { DictTag } from '#/components/dict-tag';
import { ModbusFunctionCodeOptions } from '#/views/iot/utils/constants';
import DeviceModbusConfigForm from './modbus-config-form.vue';
import DeviceModbusPointForm from './modbus-point-form.vue';

View File

@ -7,7 +7,14 @@ import type { ThingModelApi } from '#/api/iot/thingmodel';
import { computed, h, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { CommonStatusEnum, DICT_TYPE } from '@vben/constants';
import {
CommonStatusEnum,
DICT_TYPE,
getByteOrderOptions,
IoTThingModelTypeEnum,
ModbusFunctionCodeOptions,
ModbusRawDataTypeOptions,
} from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { ElMessage } from 'element-plus';
@ -19,12 +26,6 @@ import {
updateModbusPoint,
} from '#/api/iot/device/modbus/point';
import { $t } from '#/locales';
import {
getByteOrderOptions,
IoTThingModelTypeEnum,
ModbusFunctionCodeOptions,
ModbusRawDataTypeOptions,
} from '#/views/iot/utils/constants';
const emit = defineEmits(['success']);

View File

@ -7,7 +7,11 @@ import type { ThingModelApi } from '#/api/iot/thingmodel';
import { computed, ref } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { IotDeviceMessageMethodEnum } from '@vben/constants';
import {
DeviceStateEnum,
IotDeviceMessageMethodEnum,
IoTThingModelTypeEnum,
} from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import {
@ -24,10 +28,6 @@ import {
} from 'element-plus';
import { sendDeviceMessage } from '#/api/iot/device/device';
import {
DeviceStateEnum,
IoTThingModelTypeEnum,
} from '#/views/iot/utils/constants';
import DataDefinition from '../../../../thingmodel/modules/components/data-definition.vue';
import DeviceDetailsMessage from './message.vue';

View File

@ -6,7 +6,11 @@ import type { ThingModelApi } from '#/api/iot/thingmodel';
import { computed, onMounted, reactive, watch } from 'vue';
import { Page } from '@vben/common-ui';
import { IotDeviceMessageMethodEnum } from '@vben/constants';
import {
getEventTypeLabel,
IotDeviceMessageMethodEnum,
IoTThingModelTypeEnum,
} from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { formatDateTime } from '@vben/utils';
@ -20,10 +24,6 @@ import {
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getDeviceMessagePairPage } from '#/api/iot/device/device';
import {
getEventTypeLabel,
IoTThingModelTypeEnum,
} from '#/views/iot/utils/constants';
const props = defineProps<{
deviceId: number;

View File

@ -8,9 +8,10 @@ import type { IotDeviceApi } from '#/api/iot/device/device';
import { computed, nextTick, reactive, ref, watch } from 'vue';
import { IoTDataSpecsDataTypeEnum } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { formatDate, formatDateTime } from '@vben/utils';
import { formatDate } from '@vben/utils';
import dayjs from 'dayjs';
import {
@ -26,13 +27,11 @@ import {
import { getHistoryDevicePropertyList } from '#/api/iot/device/device';
import ShortcutDateRangePicker from '#/components/shortcut-date-range-picker/shortcut-date-range-picker.vue';
import { IoTDataSpecsDataTypeEnum } from '#/views/iot/utils/constants';
defineProps<{ deviceId: number }>();
const dialogVisible = ref(false); //
const loading = ref(false);
const exporting = ref(false);
const viewMode = ref<'chart' | 'list'>('chart'); //
const list = ref<IotDeviceApi.DevicePropertyDetail[]>([]); //
const total = ref(0); //
@ -297,54 +296,6 @@ function handleRefresh() {
getList();
}
/** 导出数据 */
async function handleExport() {
if (list.value.length === 0) {
ElMessage.warning('暂无数据可导出');
return;
}
exporting.value = true;
try {
// CSV
const headers = ['序号', '时间', '属性值'];
const csvContent = [
headers.join(','),
...list.value.map((item, index) => {
return [
index + 1,
formatDateTime(new Date(item.updateTime)),
isComplexDataType.value
? `"${JSON.stringify(item.value)}"`
: item.value,
].join(',');
}),
].join('\n');
// BOM ,
const BOM = '\uFEFF';
const blob = new Blob([BOM + csvContent], {
type: 'text/csv;charset=utf-8',
});
//
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `设备属性历史_${propertyIdentifier.value}_${formatDate(new Date(), 'YYYYMMDDHHmmss')}.csv`;
document.body.append(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
ElMessage.success('导出成功');
} catch {
ElMessage.error('导出失败');
} finally {
exporting.value = false;
}
}
/** 关闭弹窗 */
function handleClose() {
dialogVisible.value = false;
@ -394,16 +345,6 @@ defineExpose({ open }); // 提供 open 方法,用于打开弹窗
刷新
</ElButton>
<!-- 导出按钮 -->
<ElButton
:disabled="list.length === 0"
:loading="exporting"
@click="handleExport"
>
<IconifyIcon icon="ant-design:export-outlined" class="mr-1" />
导出
</ElButton>
<!-- 视图切换 -->
<ElButtonGroup class="ml-auto">
<ElButton

View File

@ -6,7 +6,11 @@ import type { ThingModelApi } from '#/api/iot/thingmodel';
import { computed, onMounted, reactive, watch } from 'vue';
import { Page } from '@vben/common-ui';
import { IotDeviceMessageMethodEnum } from '@vben/constants';
import {
getThingModelServiceCallTypeLabel,
IotDeviceMessageMethodEnum,
IoTThingModelTypeEnum,
} from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { formatDateTime } from '@vben/utils';
@ -20,10 +24,6 @@ import {
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getDeviceMessagePairPage } from '#/api/iot/device/device';
import {
getThingModelServiceCallTypeLabel,
IoTThingModelTypeEnum,
} from '#/views/iot/utils/constants';
const props = defineProps<{
deviceId: number;

View File

@ -105,7 +105,7 @@ onMounted(() => {
</script>
<template>
<div class="device-card-view">
<div>
<!-- 设备卡片列表 -->
<div v-loading="loading" class="min-h-96">
<ElRow v-if="list.length > 0" :gutter="16">
@ -119,29 +119,38 @@ onMounted(() => {
class="mb-4"
>
<ElCard
class="device-card h-full rounded-lg transition-all duration-300 hover:-translate-y-0.5 hover:shadow-lg"
body-class="!p-4 !flex !flex-col !h-full"
class="h-full overflow-hidden 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">
<div
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-[#40a9ff] to-[#1890ff] text-white"
>
<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
class="truncate text-[15px] font-semibold leading-9 dark:text-white/85"
>
{{ item.deviceName }}
</div>
</div>
<DictTag
:type="DICT_TYPE.IOT_DEVICE_STATE"
:value="item.state"
class="status-tag"
class="text-xs"
/>
</div>
<!-- 内容区域 -->
<div class="mb-3 flex items-start">
<div class="info-list flex-1">
<div class="info-item">
<span class="info-label">所属产品</span>
<div class="flex-1">
<div class="mb-2 flex items-center text-[13px]">
<span class="mr-2 shrink-0 opacity-65 dark:text-white/65">
所属产品
</span>
<a
class="info-value cursor-pointer text-primary"
class="cursor-pointer truncate font-medium text-primary"
@click="
(e) => {
e.stopPropagation();
@ -152,25 +161,33 @@ onMounted(() => {
{{ getProductName(item.productId) }}
</a>
</div>
<div class="info-item">
<span class="info-label">设备类型</span>
<div class="mb-2 flex items-center text-[13px]">
<span class="mr-2 shrink-0 opacity-65 dark:text-white/65">
设备类型
</span>
<DictTag
:type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE"
:value="item.deviceType"
class="info-tag m-0"
class="m-0 text-xs"
/>
</div>
<div class="info-item">
<span class="info-label">Deviceid</span>
<div class="flex items-center text-[13px]">
<span class="mr-2 shrink-0 opacity-65 dark:text-white/65">
Deviceid
</span>
<ElTooltip :content="String(item.id)" placement="top">
<span class="info-value device-id cursor-pointer">
<span
class="inline-block max-w-[150px] cursor-pointer truncate align-middle font-mono text-xs opacity-85 dark:text-white/75"
>
{{ item.id }}
</span>
</ElTooltip>
</div>
</div>
<!-- 设备图片 -->
<div class="device-image">
<div
class="flex size-20 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-[#40a9ff15] to-[#1890ff15] text-[#1890ff] dark:from-[#40a9ff25] dark:to-[#1890ff25] dark:text-[#69c0ff]"
>
<ElImage
v-if="item.picUrl"
:src="item.picUrl"
@ -185,11 +202,13 @@ onMounted(() => {
</div>
</div>
<!-- 按钮组 -->
<div class="action-buttons">
<div
class="mt-auto flex gap-2 border-t border-border pt-3"
>
<ElButton
v-if="hasAccessByCodes(['iot:device:update'])"
size="small"
class="action-btn action-btn-edit"
class="!h-8 flex-1 rounded-md !border-[#1890ff] !text-[13px] !text-[#1890ff] transition-all duration-200 hover:!bg-[#1890ff] hover:!text-white"
@click="emit('edit', item)"
>
<IconifyIcon icon="lucide:edit" class="mr-1" />
@ -198,7 +217,7 @@ onMounted(() => {
<ElButton
v-if="hasAccessByCodes(['iot:device:query'])"
size="small"
class="action-btn action-btn-detail"
class="!h-8 flex-1 rounded-md !border-[#52c41a] !text-[13px] !text-[#52c41a] transition-all duration-200 hover:!bg-[#52c41a] hover:!text-white"
@click="emit('detail', item.id!)"
>
<IconifyIcon icon="lucide:eye" class="mr-1" />
@ -207,7 +226,7 @@ onMounted(() => {
<ElButton
v-if="hasAccessByCodes(['iot:device:message-query'])"
size="small"
class="action-btn action-btn-data"
class="!h-8 flex-1 rounded-md !border-[#fa8c16] !text-[13px] !text-[#fa8c16] transition-all duration-200 hover:!bg-[#fa8c16] hover:!text-white"
@click="emit('model', item.id!)"
>
<IconifyIcon icon="lucide:database" class="mr-1" />
@ -222,7 +241,7 @@ onMounted(() => {
<ElButton
size="small"
type="danger"
class="action-btn action-btn-delete !w-8"
class="h-8 rounded-md p-0 text-[13px] transition-all duration-200 !w-8"
>
<IconifyIcon icon="lucide:trash-2" class="text-sm" />
</ElButton>
@ -250,188 +269,3 @@ onMounted(() => {
</div>
</div>
</template>
<style scoped lang="scss">
.device-card-view {
.device-card {
overflow: hidden;
:deep(.el-card__body) {
display: flex;
flex-direction: column;
height: 100%;
padding: 16px;
}
//
.device-icon {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
color: white;
background: linear-gradient(135deg, #40a9ff 0%, #1890ff 100%);
border-radius: 8px;
}
//
.device-title {
overflow: hidden;
text-overflow: ellipsis;
font-size: 15px;
font-weight: 600;
line-height: 36px;
white-space: nowrap;
}
//
.status-tag {
font-size: 12px;
}
//
.device-image {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
color: #1890ff;
background: linear-gradient(135deg, #40a9ff15 0%, #1890ff15 100%);
border-radius: 8px;
}
//
.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(--el-border-color);
.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: #fa8c16;
border-color: #fa8c16;
&:hover {
color: white;
background: #fa8c16;
}
}
&.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%);
}
}
.device-image {
color: #69c0ff;
background: linear-gradient(135deg, #40a9ff25 0%, #1890ff25 100%);
}
}
}
}
</style>

View File

@ -73,9 +73,7 @@ export function getMessageTrendChartOptions(
};
}
/**
*
*/
/** 设备状态仪表盘图表配置 */
export function getDeviceStateGaugeChartOptions(
value: number,
max: number,
@ -129,9 +127,7 @@ export function getDeviceStateGaugeChartOptions(
};
}
/**
*
*/
/** 设备数量饼图配置 */
export function getDeviceCountPieChartOptions(
data: Array<{ name: string; value: number }>,
): any {

View File

@ -28,7 +28,6 @@ async function loadData() {
}
}
// TODO @AIantd /** */
/** 初始化 */
onMounted(() => {
loadData();

View File

@ -21,8 +21,9 @@ const { renderEcharts } = useEcharts(deviceCountChartRef);
/** 是否有数据 */
const hasData = computed(() => {
// TODO @AI使 return
if (!props.statsData) return false;
if (!props.statsData) {
return false;
}
const categories = Object.entries(
props.statsData.productCategoryDeviceCounts || {},
);

View File

@ -1,17 +1,16 @@
<script lang="ts" setup>
import type { IotDeviceApi } from '#/api/iot/device/device';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { DICT_TYPE } from '@vben/constants';
import { DeviceStateEnum, DICT_TYPE } from '@vben/constants';
import { getDictLabel } from '@vben/hooks';
import { ElCard, ElEmpty } from 'element-plus';
import { getDeviceLocationList } from '#/api/iot/device/device';
import { loadBaiduMapSdk } from '#/components/map';
import { DeviceStateEnum } from '#/views/iot/utils/constants';
defineOptions({ name: 'DeviceMapCard' });
@ -152,6 +151,8 @@ async function init() {
return;
}
await loadBaiduMapSdk();
// v-show SDK resolveDOM
await nextTick();
initMap();
}

View File

@ -7,7 +7,6 @@ import { useDescription } from '#/components/description';
import { useDetailSchema } from '../../data';
/** IoT OTA 固件基本信息 */ // TODO @AI
defineProps<{
firmware?: IoTOtaFirmwareApi.Firmware;
loading?: boolean;

View File

@ -2,11 +2,10 @@ import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { DescriptionItemSchema } from '#/components/description';
import { DICT_TYPE } from '@vben/constants';
import { DICT_TYPE, IoTOtaTaskDeviceScopeEnum } from '@vben/constants';
import { getDictLabel, getDictOptions } from '@vben/hooks';
import { formatDateTime } from '@vben/utils';
import { IoTOtaTaskDeviceScopeEnum } from '#/views/iot/utils/constants';
/** 任务详情的描述字段 */
export function useDetailSchema(): DescriptionItemSchema[] {
@ -77,7 +76,7 @@ export function useFormSchema(): VbenFormSchema[] {
label: '选择设备',
component: 'Select',
componentProps: {
mode: 'multiple',
multiple: true,
placeholder: '请选择设备',
showSearch: true,
filterOption: true,

View File

@ -5,13 +5,13 @@ import type { IoTOtaTaskApi } from '#/api/iot/ota/task';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { IoTOtaTaskStatusEnum } from '@vben/constants';
import { ElInput, ElMessage } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { cancelOtaTask, getOtaTaskPage } from '#/api/iot/ota/task';
import { $t } from '#/locales';
import { IoTOtaTaskStatusEnum } from '#/views/iot/utils/constants';
import { useGridColumns } from '../data';
import OtaTaskDetail from './detail.vue';

View File

@ -1,12 +1,11 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { DICT_TYPE, IoTOtaTaskRecordStatusEnum } from '@vben/constants';
import { getDictLabel } from '@vben/hooks';
import { ElCard, ElCol, ElRow } from 'element-plus';
import { IoTOtaTaskRecordStatusEnum } from '#/views/iot/utils/constants';
const props = defineProps<{
loading?: boolean;

View File

@ -4,6 +4,8 @@ import type { IoTOtaTaskRecordApi } from '#/api/iot/ota/task/record';
import { computed, ref, watch } from 'vue';
import { IoTOtaTaskRecordStatusEnum } from '@vben/constants';
import { ElCard, ElMessage, ElTabPane, ElTabs } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
@ -12,7 +14,6 @@ import {
getOtaTaskRecordPage,
} from '#/api/iot/ota/task/record';
import { $t } from '#/locales';
import { IoTOtaTaskRecordStatusEnum } from '#/views/iot/utils/constants';
import { useGridColumns } from '../data';

View File

@ -5,13 +5,13 @@ import { onMounted, provide, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { IOT_PROVIDE_KEY } from '@vben/constants';
import { ElMessage, ElTabPane, ElTabs } from 'element-plus';
import { getDeviceCount } from '#/api/iot/device/device';
import { getProduct } from '#/api/iot/product/product';
import IoTProductThingModel from '#/views/iot/thingmodel/index.vue';
import { IOT_PROVIDE_KEY } from '#/views/iot/utils/constants';
import ProductDetailsHeader from './modules/header.vue';
import ProductDetailsInfo from './modules/info.vue';

View File

@ -25,8 +25,9 @@ const showProductSecret = ref(false); // 是否显示产品密钥
/** 格式化日期 */
function formatDate(date?: Date | string) {
// TODO @AI使 return
if (!date) return '-';
if (!date) {
return '-';
}
return new Date(date).toLocaleString('zh-CN');
}

View File

@ -89,7 +89,7 @@ onMounted(() => {
</script>
<template>
<div class="product-card-view">
<div>
<!-- 产品卡片列表 -->
<div v-loading="loading" class="min-h-96">
<ElRow v-if="list.length > 0" :gutter="16">
@ -103,52 +103,68 @@ onMounted(() => {
class="mb-4"
>
<ElCard
body-class="!p-4"
class="product-card h-full rounded-lg transition-all duration-300 hover:-translate-y-0.5 hover:shadow-lg"
body-class="!p-4 !flex !flex-col !h-full"
class="h-full overflow-hidden rounded-lg transition-all duration-300 hover:-translate-y-0.5 hover:shadow-lg"
>
<!-- 顶部标题区域 -->
<div class="mb-3 flex items-center">
<div class="product-icon">
<div
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-[#40a9ff] to-[#1890ff] text-white"
>
<IconifyIcon
:icon="item.icon || 'lucide:box'"
class="text-xl"
/>
</div>
<div class="ml-3 min-w-0 flex-1">
<div class="product-title">{{ item.name }}</div>
<div
class="truncate text-[15px] font-semibold leading-9 dark:text-white/85"
>
{{ item.name }}
</div>
</div>
</div>
<!-- 内容区域 -->
<div class="mb-3 flex items-start">
<div class="info-list flex-1">
<div class="info-item">
<span class="info-label">产品分类</span>
<span class="info-value text-primary">
<div class="flex-1">
<div class="mb-2 flex items-center text-[13px]">
<span class="mr-2 shrink-0 opacity-65 dark:text-white/65">
产品分类
</span>
<span class="truncate font-medium text-primary">
{{ getCategoryName(item.categoryId) }}
</span>
</div>
<div class="info-item">
<span class="info-label">产品类型</span>
<div class="mb-2 flex items-center text-[13px]">
<span class="mr-2 shrink-0 opacity-65 dark:text-white/65">
产品类型
</span>
<DictTag
:type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE"
:value="item.deviceType"
class="info-tag m-0"
class="m-0 text-xs"
/>
</div>
<div class="info-item">
<span class="info-label">产品标识</span>
<div class="flex items-center text-[13px]">
<span class="mr-2 shrink-0 opacity-65 dark:text-white/65">
产品标识
</span>
<ElTooltip
:content="item.productKey || item.id"
placement="top"
>
<span class="info-value product-key cursor-pointer">
<span
class="inline-block max-w-[150px] cursor-pointer truncate align-middle font-mono text-xs opacity-85 dark:text-white/75"
>
{{ item.productKey || item.id }}
</span>
</ElTooltip>
</div>
</div>
<!-- 产品图片 -->
<div class="product-image">
<div
class="flex size-20 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-[#40a9ff15] to-[#1890ff15] text-[#1890ff] dark:from-[#40a9ff25] dark:to-[#1890ff25] dark:text-[#69c0ff]"
>
<ElImage
v-if="item.picUrl"
:src="item.picUrl"
@ -163,11 +179,13 @@ onMounted(() => {
</div>
</div>
<!-- 按钮组 -->
<div class="action-buttons">
<div
class="mt-auto flex gap-2 border-t border-border pt-3"
>
<ElButton
v-if="hasAccessByCodes(['iot:product:update'])"
size="small"
class="action-btn action-btn-edit"
class="!h-8 flex-1 rounded-md !border-[#1890ff] !text-[13px] !text-[#1890ff] transition-all duration-200 hover:!bg-[#1890ff] hover:!text-white"
@click="emit('edit', item)"
>
<IconifyIcon icon="lucide:edit" class="mr-1" />
@ -176,7 +194,7 @@ onMounted(() => {
<ElButton
v-if="hasAccessByCodes(['iot:product:query'])"
size="small"
class="action-btn action-btn-detail"
class="!h-8 flex-1 rounded-md !border-[#52c41a] !text-[13px] !text-[#52c41a] transition-all duration-200 hover:!bg-[#52c41a] hover:!text-white"
@click="emit('detail', item.id)"
>
<IconifyIcon icon="lucide:eye" class="mr-1" />
@ -185,7 +203,7 @@ onMounted(() => {
<ElButton
v-if="hasAccessByCodes(['iot:thing-model:query'])"
size="small"
class="action-btn action-btn-model"
class="!h-8 flex-1 rounded-md !border-[#fa8c16] !text-[13px] !text-[#fa8c16] transition-all duration-200 hover:!bg-[#fa8c16] hover:!text-white"
@click="emit('thingModel', item.id)"
>
<IconifyIcon icon="lucide:git-branch" class="mr-1" />
@ -201,7 +219,7 @@ onMounted(() => {
size="small"
type="danger"
disabled
class="action-btn action-btn-delete !w-8"
class="h-8 rounded-md p-0 text-[13px] transition-all duration-200 !w-8"
>
<IconifyIcon icon="lucide:trash-2" class="text-sm" />
</ElButton>
@ -215,7 +233,7 @@ onMounted(() => {
<ElButton
size="small"
type="danger"
class="action-btn action-btn-delete !w-8"
class="h-8 rounded-md p-0 text-[13px] transition-all duration-200 !w-8"
>
<IconifyIcon icon="lucide:trash-2" class="text-sm" />
</ElButton>
@ -244,182 +262,3 @@ onMounted(() => {
</div>
</div>
</template>
<style scoped lang="scss">
.product-card-view {
.product-card {
overflow: hidden;
:deep(.el-card__body) {
display: flex;
flex-direction: column;
height: 100%;
}
//
.product-icon {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
color: white;
background: linear-gradient(135deg, #40a9ff 0%, #1890ff 100%);
border-radius: 8px;
}
//
.product-title {
overflow: hidden;
text-overflow: ellipsis;
font-size: 15px;
font-weight: 600;
line-height: 36px;
white-space: nowrap;
}
//
.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;
}
}
.product-key {
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;
}
}
}
//
.product-image {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
color: #1890ff;
background: linear-gradient(135deg, #40a9ff15 0%, #1890ff15 100%);
border-radius: 8px;
}
//
.action-buttons {
display: flex;
gap: 8px;
padding-top: 12px;
margin-top: auto;
border-top: 1px solid var(--el-border-color);
.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-model {
color: #fa8c16;
border-color: #fa8c16;
&:hover {
color: white;
background: #fa8c16;
}
}
&.action-btn-delete {
flex: 0 0 32px;
padding: 0;
}
}
}
}
}
//
html.dark {
.product-card-view {
.product-card {
.product-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%);
}
.product-key {
color: rgb(255 255 255 / 75%);
}
}
.product-image {
color: #69c0ff;
background: linear-gradient(135deg, #40a9ff25 0%, #1890ff25 100%);
}
}
}
}
</style>

View File

@ -0,0 +1,26 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { Page } from '@vben/common-ui';
import { ElTabPane, ElTabs } from 'element-plus';
import DataRuleList from './rule/index.vue';
import DataSinkList from './sink/index.vue';
// TODO DONE @AI"/** IoT / */"线
const activeTabName = ref('rule');
</script>
<template>
<Page auto-content-height>
<ElTabs v-model="activeTabName">
<ElTabPane name="rule" label="规则">
<DataRuleList />
</ElTabPane>
<ElTabPane name="sink" label="目的">
<DataSinkList />
</ElTabPane>
</ElTabs>
</Page>
</template>

View File

@ -0,0 +1,190 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { DataRuleApi } from '#/api/iot/rule/data/rule';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { getDataSinkSimpleList } from '#/api/iot/rule/data/sink';
import { getRangePickerDefaultProps } from '#/utils';
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '规则名称',
component: 'Input',
componentProps: {
placeholder: '请输入规则名称',
clearable: true,
},
},
{
fieldName: 'status',
label: '规则状态',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
placeholder: '请选择状态',
clearable: true,
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
clearable: true,
},
},
];
}
/** 规则表单 Schema */
export function useRuleFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '规则名称',
component: 'Input',
componentProps: {
placeholder: '请输入规则名称',
},
rules: 'required',
},
{
fieldName: 'description',
label: '规则描述',
component: 'Textarea',
componentProps: {
placeholder: '请输入规则描述',
rows: 3,
},
},
{
fieldName: 'status',
label: '规则状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
},
defaultValue: 0,
rules: 'required',
},
{
fieldName: 'sinkIds',
label: '数据目的',
component: 'ApiSelect',
componentProps: {
api: getDataSinkSimpleList,
labelField: 'name',
valueField: 'id',
multiple: true,
clearable: true,
placeholder: '请选择数据目的',
},
rules: 'required',
},
];
}
/** 数据源配置(行编辑表)的字段 */
export function useSourceConfigColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'productId',
title: '产品',
minWidth: 200,
slots: { default: 'productId' },
},
{
field: 'deviceId',
title: '设备',
minWidth: 200,
slots: { default: 'deviceId' },
},
{
field: 'method',
title: '消息',
minWidth: 200,
slots: { default: 'method' },
},
{
field: 'identifier',
title: '标识符',
minWidth: 250,
slots: { default: 'identifier' },
},
{
title: '操作',
width: 80,
fixed: 'right',
slots: { default: 'actions' },
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions<DataRuleApi.DataRule>['columns'] {
return [
{ type: 'checkbox', width: 40 },
{
field: 'id',
title: '规则编号',
minWidth: 80,
},
{
field: 'name',
title: '规则名称',
minWidth: 150,
},
{
field: 'description',
title: '规则描述',
minWidth: 200,
},
{
field: 'status',
title: '规则状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
field: 'sourceConfigs',
title: '数据源',
minWidth: 100,
formatter: ({ cellValue }) => `${cellValue?.length || 0}`,
},
{
field: 'sinkIds',
title: '数据目的',
minWidth: 100,
formatter: ({ cellValue }) => `${cellValue?.length || 0}`,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 160,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -0,0 +1,125 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { DataRuleApi } from '#/api/iot/rule/data/rule';
import { Page, useVbenModal } from '@vben/common-ui';
import { ElLoading, ElMessage } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteDataRule, getDataRulePage } from '#/api/iot/rule/data/rule';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
}
/** 创建规则 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑规则 */
function handleEdit(row: DataRuleApi.DataRule) {
formModalApi.setData({ id: row.id }).open();
}
/** 删除规则 */
async function handleDelete(row: DataRuleApi.DataRule) {
const loadingInstance = ElLoading.service({
text: $t('ui.actionMessage.deleting', [row.name]),
});
try {
await deleteDataRule(row.id!);
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.name]));
handleRefresh();
} finally {
loadingInstance.close();
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getDataRulePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<DataRuleApi.DataRule>,
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="handleRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['规则']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['iot:data-rule:create'],
onClick: handleCreate,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
link: true,
icon: ACTION_ICON.EDIT,
auth: ['iot:data-rule:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'danger',
link: true,
icon: ACTION_ICON.DELETE,
auth: ['iot:data-rule:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,99 @@
<script lang="ts" setup>
import type { DataRuleApi } from '#/api/iot/rule/data/rule';
import { computed, nextTick, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { ElMessage } from 'element-plus';
import { useVbenForm } from '#/adapter/form';
import {
createDataRule,
getDataRule,
updateDataRule,
} from '#/api/iot/rule/data/rule';
import { $t } from '#/locales';
import { useRuleFormSchema } from '../data';
import SourceConfigForm from './source-config-form.vue';
const emit = defineEmits(['success']);
const formData = ref<DataRuleApi.DataRule>();
const sourceConfigRef = ref<InstanceType<typeof SourceConfigForm>>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['数据规则'])
: $t('ui.actionTitle.create', ['数据规则']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 100,
},
layout: 'horizontal',
schema: useRuleFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
//
await sourceConfigRef.value?.validate();
modalApi.lock();
//
const data = (await formApi.getValues()) as DataRuleApi.DataRule;
data.sourceConfigs = sourceConfigRef.value?.getData() || [];
try {
await (formData.value?.id ? updateDataRule(data) : createDataRule(data));
//
await modalApi.close();
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
sourceConfigRef.value?.setData([]);
return;
}
//
const data = modalApi.getData<DataRuleApi.DataRule>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getDataRule(data.id);
// values
await formApi.setValues(formData.value);
//
await nextTick();
sourceConfigRef.value?.setData(formData.value.sourceConfigs || []);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal class="w-4/5" :title="getTitle">
<Form class="mx-4" />
<div class="mx-4 mt-4">
<div class="mb-2 text-sm font-medium">数据源配置</div>
<SourceConfigForm ref="sourceConfigRef" />
</div>
</Modal>
</template>

View File

@ -0,0 +1,284 @@
<script lang="ts" setup>
import { computed, nextTick, onMounted, ref } from 'vue';
import { IotDeviceMessageMethodEnum, IoTThingModelTypeEnum } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { ElButton, ElMessage, ElOption, ElSelect } from 'element-plus';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getSimpleDeviceList } from '#/api/iot/device/device';
import { getSimpleProductList } from '#/api/iot/product/product';
import { getThingModelListByProductId } from '#/api/iot/thingmodel';
import { useSourceConfigColumns } from '../data';
const formData = ref<any[]>([]);
const productList = ref<any[]>([]); //
const deviceList = ref<any[]>([]); //
const thingModelCache = ref<Map<number, any[]>>(new Map()); // key productId
/** 上行消息方法列表 */
const upstreamMethods = computed(() => {
return Object.values(IotDeviceMessageMethodEnum).filter(
(item) => item.upstream,
);
});
/** 根据产品 ID 过滤设备 */
function getFilteredDevices(productId: number) {
if (!productId) return [];
return deviceList.value.filter(
(device: any) => device.productId === productId,
);
}
/** 判断是否需要显示标识符选择器 */
function shouldShowIdentifierSelect(row: any) {
return [
IotDeviceMessageMethodEnum.EVENT_POST.method,
IotDeviceMessageMethodEnum.PROPERTY_POST.method,
].includes(row.method);
}
/** 获取物模型选项 */
function getThingModelOptions(row: any) {
if (!row.productId || !shouldShowIdentifierSelect(row)) {
return [];
}
const thingModels: any[] = thingModelCache.value.get(row.productId) || [];
let filteredModels: any[] = [];
if (row.method === IotDeviceMessageMethodEnum.EVENT_POST.method) {
filteredModels = thingModels.filter(
(item: any) => item.type === IoTThingModelTypeEnum.EVENT,
);
} else if (row.method === IotDeviceMessageMethodEnum.PROPERTY_POST.method) {
filteredModels = thingModels.filter(
(item: any) => item.type === IoTThingModelTypeEnum.PROPERTY,
);
}
return filteredModels.map((item: any) => ({
label: `${item.name} (${item.identifier})`,
value: item.identifier,
}));
}
/** 加载产品列表 */
async function loadProductList() {
try {
productList.value = await getSimpleProductList();
} catch (error) {
console.error('加载产品列表失败:', error);
}
}
/** 加载设备列表 */
async function loadDeviceList() {
try {
deviceList.value = await getSimpleDeviceList();
} catch (error) {
console.error('加载设备列表失败:', error);
}
}
/** 加载物模型数据 */
async function loadThingModel(productId: number) {
if (thingModelCache.value.has(productId)) {
return;
}
try {
const thingModels = await getThingModelListByProductId(productId);
thingModelCache.value.set(productId, thingModels);
} catch (error) {
console.error('加载物模型失败:', error);
}
}
/** 产品变化时清空设备 / 消息 / 标识符 */
function handleProductChange(rowIndex: number) {
const row = formData.value[rowIndex];
row.deviceId = 0;
row.method = undefined;
row.identifier = undefined;
row.identifierLoading = false;
}
/** 消息方法变化时清空标识符 + 按需加载物模型 */
async function handleMethodChange(rowIndex: number) {
const row = formData.value[rowIndex];
row.identifier = undefined;
if (shouldShowIdentifierSelect(row) && row.productId) {
row.identifierLoading = true;
await loadThingModel(row.productId);
row.identifierLoading = false;
}
}
/** 表格配置 */
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useSourceConfigColumns(),
data: formData.value,
minHeight: 160,
border: true,
showOverflow: false,
rowConfig: { isHover: true, height: 64 },
pagerConfig: { enabled: false },
toolbarConfig: { enabled: false },
},
});
/** 同步 formData 到 vxe-grid */
async function reloadGrid() {
await nextTick();
await gridApi.grid?.reloadData(formData.value);
}
/** 新增一行数据源 */
async function handleAdd() {
formData.value.push({
productId: undefined,
deviceId: undefined,
method: undefined,
identifier: undefined,
identifierLoading: false,
});
await reloadGrid();
}
/** 删除一行数据源 */
async function handleDelete(rowIndex: number) {
formData.value.splice(rowIndex, 1);
await reloadGrid();
}
/** 校验全部行;返回 Promise失败时 reject 第一条错误信息 */
function validate() {
for (let i = 0; i < formData.value.length; i++) {
const row = formData.value[i];
if (!row.productId) {
ElMessage.error(`${i + 1} 行:产品不能为空`);
return Promise.reject(new Error('产品不能为空'));
}
if (row.deviceId === undefined || row.deviceId === null) {
ElMessage.error(`${i + 1} 行:设备不能为空`);
return Promise.reject(new Error('设备不能为空'));
}
if (!row.method) {
ElMessage.error(`${i + 1} 行:消息方法不能为空`);
return Promise.reject(new Error('消息方法不能为空'));
}
}
return Promise.resolve();
}
/** 取当前所有行的值 */
function getData() {
return formData.value;
}
/** 设置初始数据 */
async function setData(data: any[]) {
formData.value = (data || []).map((item) => ({
...item,
identifierLoading: false,
}));
// reloadGrid
await Promise.all(
(data || [])
.filter((item) => item.productId && shouldShowIdentifierSelect(item))
.map((item) => loadThingModel(item.productId)),
);
await reloadGrid();
}
onMounted(async () => {
await Promise.all([loadProductList(), loadDeviceList()]);
});
defineExpose({ validate, getData, setData });
</script>
<template>
<div>
<Grid>
<template #productId="{ rowIndex }">
<ElSelect
v-model="formData[rowIndex].productId"
placeholder="请选择产品"
filterable
class="w-full"
@change="() => handleProductChange(rowIndex)"
>
<ElOption
v-for="p in productList"
:key="p.id"
:label="p.name"
:value="p.id"
/>
</ElSelect>
</template>
<template #deviceId="{ rowIndex }">
<ElSelect
v-model="formData[rowIndex].deviceId"
placeholder="请选择设备"
filterable
class="w-full"
>
<ElOption label="全部设备" :value="0" />
<ElOption
v-for="d in getFilteredDevices(formData[rowIndex].productId)"
:key="d.id"
:label="d.deviceName"
:value="d.id"
/>
</ElSelect>
</template>
<template #method="{ rowIndex }">
<ElSelect
v-model="formData[rowIndex].method"
placeholder="请选择消息"
filterable
class="w-full"
@change="() => handleMethodChange(rowIndex)"
>
<ElOption
v-for="m in upstreamMethods"
:key="m.method"
:label="m.name"
:value="m.method"
/>
</ElSelect>
</template>
<template #identifier="{ rowIndex }">
<ElSelect
v-if="shouldShowIdentifierSelect(formData[rowIndex])"
v-model="formData[rowIndex].identifier"
placeholder="请选择标识符"
filterable
:loading="formData[rowIndex].identifierLoading"
class="w-full"
>
<ElOption
v-for="option in getThingModelOptions(formData[rowIndex])"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
<span v-else class="text-xs text-muted-foreground">-</span>
</template>
<template #actions="{ rowIndex }">
<ElButton type="danger" link @click="handleDelete(rowIndex)">
删除
</ElButton>
</template>
</Grid>
<div class="mt-3 text-center">
<ElButton type="primary" @click="handleAdd">
<IconifyIcon icon="ant-design:plus-outlined" class="mr-1" />
添加数据源
</ElButton>
</div>
</div>
</template>

View File

@ -0,0 +1,78 @@
<script lang="ts" setup>
import { ref, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { isEmpty } from '@vben/utils';
import { ElButton, ElInput } from 'element-plus';
defineOptions({ name: 'KeyValueEditor' });
const props = defineProps<{
addButtonText: string;
modelValue: Record<string, string>;
}>();
const emit = defineEmits(['update:modelValue']);
interface KeyValueItem {
key: string;
value: string;
}
const items = ref<KeyValueItem[]>([]); // key-value
/** 添加项目 */
function addItem() {
items.value.push({ key: '', value: '' });
updateModelValue();
}
/** 移除项目 */
function removeItem(index: number) {
items.value.splice(index, 1);
updateModelValue();
}
/** 更新 modelValue */
function updateModelValue() {
const result: Record<string, string> = {};
items.value.forEach((item) => {
if (item.key) {
result[item.key] = item.value;
}
});
emit('update:modelValue', result);
}
/** 监听项目变化 */
watch(items, updateModelValue, { deep: true });
watch(
() => props.modelValue,
(val) => {
//
if (isEmpty(val) || !isEmpty(items.value)) {
return;
}
items.value = Object.entries(props.modelValue).map(([key, value]) => ({
key,
value,
}));
},
);
</script>
<template>
<div v-for="(item, index) in items" :key="index" class="mb-2 flex w-full">
<ElInput v-model="item.key" class="mr-2" placeholder="键" />
<ElInput v-model="item.value" placeholder="值" />
<ElButton class="ml-2" type="danger" link @click="removeItem(index)">
<IconifyIcon icon="ant-design:delete-outlined" />
删除
</ElButton>
</div>
<ElButton type="primary" link @click="addItem">
<IconifyIcon icon="ant-design:plus-outlined" />
{{ addButtonText }}
</ElButton>
</template>

View File

@ -0,0 +1,129 @@
<script lang="ts" setup>
// TODO DONE @AI function system user indexrule/data
import { onMounted, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { isEmpty } from '@vben/utils';
import { useClipboard, useVModel } from '@vueuse/core';
import { ElButton, ElFormItem, ElInput, ElMessage } from 'element-plus';
import { IotDataSinkTypeEnum } from '#/api/iot/rule/data/sink';
const props = defineProps<{ modelValue: any }>();
const emit = defineEmits(['update:modelValue']);
const TABLE_SQL = `CREATE TABLE iot_device_message_sink (
id VARCHAR(64) NOT NULL COMMENT '消息ID',
device_id BIGINT NOT NULL COMMENT '设备编号',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户编号',
method VARCHAR(128) COMMENT '请求方法',
report_time DATETIME COMMENT '上报时间',
data TEXT COMMENT '完整消息JSON',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (id) USING BTREE,
INDEX idx_create_time (create_time ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = 'IoT 设备消息流转目标表';`;
const config = useVModel(props, 'modelValue', emit);
const showSqlTip = ref(false);
const copied = ref(false);
const { copy } = useClipboard();
async function handleCopySql() {
await copy(TABLE_SQL);
copied.value = true;
ElMessage.success('建表 SQL 已复制到剪贴板');
setTimeout(() => (copied.value = false), 2000);
}
onMounted(() => {
if (!isEmpty(config.value)) {
return;
}
config.value = {
type: `${IotDataSinkTypeEnum.DATABASE}`,
jdbcUrl: '',
username: '',
password: '',
tableName: 'iot_device_message_sink',
};
});
</script>
<template>
<ElFormItem
prop="config.jdbcUrl"
:rules="[{ required: true, message: 'JDBC 连接地址不能为空', trigger: 'blur' }]"
label="JDBC 地址"
>
<ElInput
v-model="config.jdbcUrl"
placeholder="请输入 JDBC 连接地址jdbc:mysql://localhost:3306/iot_data"
/>
</ElFormItem>
<ElFormItem
prop="config.username"
:rules="[{ required: true, message: '用户名不能为空', trigger: 'blur' }]"
label="用户名"
>
<ElInput v-model="config.username" placeholder="请输入数据库用户名" />
</ElFormItem>
<ElFormItem
prop="config.password"
:rules="[{ required: true, message: '密码不能为空', trigger: 'blur' }]"
label="密码"
>
<ElInput
v-model="config.password"
type="password"
show-password
placeholder="请输入数据库密码"
/>
</ElFormItem>
<ElFormItem
prop="config.tableName"
:rules="[{ required: true, message: '目标表名不能为空', trigger: 'blur' }]"
label="目标表名"
>
<div class="flex items-center gap-3">
<ElInput
v-model="config.tableName"
placeholder="目标表名"
class="w-[240px]"
/>
<ElButton type="primary" link @click="showSqlTip = !showSqlTip">
<IconifyIcon
:icon="showSqlTip ? 'lucide:chevron-up' : 'lucide:file-text'"
class="mr-1"
/>
{{ showSqlTip ? '收起表结构提示' : '查看表结构提示' }}
</ElButton>
</div>
</ElFormItem>
<div
v-if="showSqlTip"
class="mt-2 overflow-hidden rounded border border-gray-200 dark:border-gray-700"
>
<div
class="flex items-center justify-between bg-gray-100 px-3 py-2 dark:bg-gray-800"
>
<span class="text-xs text-gray-600 dark:text-gray-300">
目标数据库需包含以下结构的表才能正常接收数据流转的消息
</span>
<ElButton size="small" @click="handleCopySql">
<IconifyIcon
:icon="copied ? 'lucide:check' : 'lucide:copy'"
class="mr-1"
/>
{{ copied ? '已复制' : '复制 SQL' }}
</ElButton>
</div>
<pre
class="m-0 overflow-x-auto bg-gray-50 p-3 font-mono text-[12px] leading-normal text-gray-800 dark:bg-gray-900 dark:text-gray-200"
><code>{{ TABLE_SQL }}</code></pre>
</div>
</template>

View File

@ -0,0 +1,94 @@
<!--suppress HttpUrlsUsage -->
<script lang="ts" setup>
import { computed, onMounted, ref, watch } from 'vue';
import { isEmpty } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import { ElFormItem, ElInput, ElOption, ElSelect } from 'element-plus';
import { IotDataSinkTypeEnum } from '#/api/iot/rule/data/sink';
import KeyValueEditor from './components/key-value-editor.vue';
const props = defineProps<{ modelValue: any }>();
const emit = defineEmits(['update:modelValue']);
const config = useVModel(props, 'modelValue', emit);
const urlPrefix = ref<'http://' | 'https://'>('http://');
const urlPath = ref('');
const fullUrl = computed(() =>
urlPath.value ? urlPrefix.value + urlPath.value : '',
);
watch([urlPrefix, urlPath], () => {
config.value.url = fullUrl.value;
});
onMounted(() => {
if (!isEmpty(config.value)) {
if (config.value.url?.startsWith('https://')) {
urlPrefix.value = 'https://';
urlPath.value = config.value.url.slice(8);
} else if (config.value.url?.startsWith('http://')) {
urlPrefix.value = 'http://';
urlPath.value = config.value.url.slice(7);
} else {
urlPath.value = config.value.url ?? '';
}
return;
}
config.value = {
type: `${IotDataSinkTypeEnum.HTTP}`,
url: '',
method: 'POST',
headers: {},
query: {},
body: '',
};
});
</script>
<template>
<ElFormItem
prop="config.url"
:rules="[{ required: true, message: '请求地址不能为空', trigger: 'blur' }]"
label="请求地址"
>
<ElInput v-model="urlPath" placeholder="请输入请求地址">
<template #prepend>
<ElSelect v-model="urlPrefix" class="w-[100px]">
<ElOption label="http://" value="http://" />
<ElOption label="https://" value="https://" />
</ElSelect>
</template>
</ElInput>
</ElFormItem>
<ElFormItem
prop="config.method"
:rules="[{ required: true, message: '请求方法不能为空', trigger: 'change' }]"
label="请求方法"
>
<ElSelect v-model="config.method" placeholder="请选择请求方法">
<ElOption label="GET" value="GET" />
<ElOption label="POST" value="POST" />
<ElOption label="PUT" value="PUT" />
<ElOption label="DELETE" value="DELETE" />
</ElSelect>
</ElFormItem>
<ElFormItem label="请求头">
<KeyValueEditor v-model="config.headers" add-button-text="" />
</ElFormItem>
<ElFormItem label="请求参数">
<KeyValueEditor v-model="config.query" add-button-text="" />
</ElFormItem>
<ElFormItem label="请求体">
<ElInput
v-model="config.body"
type="textarea"
placeholder="请输入内容"
:rows="4"
/>
</ElFormItem>
</template>

View File

@ -0,0 +1,9 @@
export { default as DatabaseConfigForm } from './database-config-form.vue';
export { default as HttpConfigForm } from './http-config-form.vue';
export { default as KafkaMqConfigForm } from './kafka-mq-config-form.vue';
export { default as MqttConfigForm } from './mqtt-config-form.vue';
export { default as RabbitMqConfigForm } from './rabbit-mq-config-form.vue';
export { default as RedisStreamConfigForm } from './redis-stream-config-form.vue';
export { default as RocketMqConfigForm } from './rocket-mq-config-form.vue';
export { default as TcpConfigForm } from './tcp-config-form.vue';
export { default as WebSocketConfigForm } from './websocket-config-form.vue';

View File

@ -0,0 +1,71 @@
<script lang="ts" setup>
import { onMounted } from 'vue';
import { isEmpty } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import { ElFormItem, ElInput, ElSwitch } from 'element-plus';
import { IotDataSinkTypeEnum } from '#/api/iot/rule/data/sink';
const props = defineProps<{ modelValue: any }>();
const emit = defineEmits(['update:modelValue']);
const config = useVModel(props, 'modelValue', emit);
onMounted(() => {
if (!isEmpty(config.value)) {
return;
}
config.value = {
type: `${IotDataSinkTypeEnum.KAFKA}`,
bootstrapServers: '',
username: '',
password: '',
ssl: false,
topic: '',
};
});
</script>
<template>
<ElFormItem
prop="config.bootstrapServers"
:rules="[{ required: true, message: '服务地址不能为空', trigger: 'blur' }]"
label="服务地址"
>
<ElInput
v-model="config.bootstrapServers"
placeholder="请输入服务地址localhost:9092"
/>
</ElFormItem>
<ElFormItem
prop="config.username"
:rules="[{ required: true, message: '用户名不能为空', trigger: 'blur' }]"
label="用户名"
>
<ElInput v-model="config.username" placeholder="请输入用户名" />
</ElFormItem>
<ElFormItem
prop="config.password"
:rules="[{ required: true, message: '密码不能为空', trigger: 'blur' }]"
label="密码"
>
<ElInput
v-model="config.password"
type="password"
show-password
placeholder="请输入密码"
/>
</ElFormItem>
<ElFormItem prop="config.ssl" label="启用 SSL">
<ElSwitch v-model="config.ssl" />
</ElFormItem>
<ElFormItem
prop="config.topic"
:rules="[{ required: true, message: '主题不能为空', trigger: 'blur' }]"
label="主题"
>
<ElInput v-model="config.topic" placeholder="请输入主题" />
</ElFormItem>
</template>

View File

@ -0,0 +1,75 @@
<script lang="ts" setup>
import { onMounted } from 'vue';
import { isEmpty } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import { ElFormItem, ElInput } from 'element-plus';
import { IotDataSinkTypeEnum } from '#/api/iot/rule/data/sink';
const props = defineProps<{ modelValue: any }>();
const emit = defineEmits(['update:modelValue']);
const config = useVModel(props, 'modelValue', emit);
onMounted(() => {
if (!isEmpty(config.value)) {
return;
}
config.value = {
type: `${IotDataSinkTypeEnum.MQTT}`,
url: '',
username: '',
password: '',
clientId: '',
topic: '',
};
});
</script>
<template>
<ElFormItem
prop="config.url"
:rules="[{ required: true, message: '服务地址不能为空', trigger: 'blur' }]"
label="服务地址"
>
<ElInput
v-model="config.url"
placeholder="请输入 MQTT 服务地址mqtt://localhost:1883"
/>
</ElFormItem>
<ElFormItem
prop="config.username"
:rules="[{ required: true, message: '用户名不能为空', trigger: 'blur' }]"
label="用户名"
>
<ElInput v-model="config.username" placeholder="请输入用户名" />
</ElFormItem>
<ElFormItem
prop="config.password"
:rules="[{ required: true, message: '密码不能为空', trigger: 'blur' }]"
label="密码"
>
<ElInput
v-model="config.password"
type="password"
show-password
placeholder="请输入密码"
/>
</ElFormItem>
<ElFormItem
prop="config.clientId"
:rules="[{ required: true, message: '客户端 ID 不能为空', trigger: 'blur' }]"
label="客户端 ID"
>
<ElInput v-model="config.clientId" placeholder="请输入客户端 ID" />
</ElFormItem>
<ElFormItem
prop="config.topic"
:rules="[{ required: true, message: '主题不能为空', trigger: 'blur' }]"
label="主题"
>
<ElInput v-model="config.topic" placeholder="请输入主题" />
</ElFormItem>
</template>

View File

@ -0,0 +1,111 @@
<script lang="ts" setup>
import { onMounted } from 'vue';
import { isEmpty } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import { ElFormItem, ElInput, ElInputNumber } from 'element-plus';
import { IotDataSinkTypeEnum } from '#/api/iot/rule/data/sink';
const props = defineProps<{ modelValue: any }>();
const emit = defineEmits(['update:modelValue']);
const config = useVModel(props, 'modelValue', emit);
onMounted(() => {
if (!isEmpty(config.value)) {
return;
}
config.value = {
type: `${IotDataSinkTypeEnum.RABBITMQ}`,
host: '',
port: 5672,
virtualHost: '/',
username: '',
password: '',
exchange: '',
routingKey: '',
queue: '',
};
});
</script>
<template>
<ElFormItem
prop="config.host"
:rules="[{ required: true, message: '主机地址不能为空', trigger: 'blur' }]"
label="主机地址"
>
<ElInput v-model="config.host" placeholder="请输入主机地址localhost" />
</ElFormItem>
<ElFormItem
prop="config.port"
:rules="[
{ required: true, message: '端口不能为空', trigger: 'blur' },
{
type: 'number',
min: 1,
max: 65_535,
message: '端口号范围 1-65535',
trigger: 'blur',
},
]"
label="端口"
>
<ElInputNumber
v-model="config.port"
:max="65535"
:min="1"
placeholder="请输入端口"
class="w-full"
/>
</ElFormItem>
<ElFormItem
prop="config.virtualHost"
:rules="[{ required: true, message: '虚拟主机不能为空', trigger: 'blur' }]"
label="虚拟主机"
>
<ElInput v-model="config.virtualHost" placeholder="请输入虚拟主机" />
</ElFormItem>
<ElFormItem
prop="config.username"
:rules="[{ required: true, message: '用户名不能为空', trigger: 'blur' }]"
label="用户名"
>
<ElInput v-model="config.username" placeholder="请输入用户名" />
</ElFormItem>
<ElFormItem
prop="config.password"
:rules="[{ required: true, message: '密码不能为空', trigger: 'blur' }]"
label="密码"
>
<ElInput
v-model="config.password"
type="password"
show-password
placeholder="请输入密码"
/>
</ElFormItem>
<ElFormItem
prop="config.exchange"
:rules="[{ required: true, message: '交换机不能为空', trigger: 'blur' }]"
label="交换机"
>
<ElInput v-model="config.exchange" placeholder="请输入交换机" />
</ElFormItem>
<ElFormItem
prop="config.routingKey"
:rules="[{ required: true, message: '路由键不能为空', trigger: 'blur' }]"
label="路由键"
>
<ElInput v-model="config.routingKey" placeholder="请输入路由键" />
</ElFormItem>
<ElFormItem
prop="config.queue"
:rules="[{ required: true, message: '队列不能为空', trigger: 'blur' }]"
label="队列"
>
<ElInput v-model="config.queue" placeholder="请输入队列" />
</ElFormItem>
</template>

View File

@ -0,0 +1,97 @@
<script lang="ts" setup>
import { onMounted } from 'vue';
import { isEmpty } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import { ElFormItem, ElInput, ElInputNumber } from 'element-plus';
import { IotDataSinkTypeEnum } from '#/api/iot/rule/data/sink';
const props = defineProps<{ modelValue: any }>();
const emit = defineEmits(['update:modelValue']);
const config = useVModel(props, 'modelValue', emit);
onMounted(() => {
if (!isEmpty(config.value)) {
return;
}
config.value = {
type: `${IotDataSinkTypeEnum.REDIS_STREAM}`,
host: '',
port: 6379,
password: '',
database: 0,
topic: '',
};
});
</script>
<template>
<ElFormItem
prop="config.host"
:rules="[{ required: true, message: '主机地址不能为空', trigger: 'blur' }]"
label="主机地址"
>
<ElInput v-model="config.host" placeholder="请输入主机地址localhost" />
</ElFormItem>
<ElFormItem
prop="config.port"
:rules="[
{ required: true, message: '端口不能为空', trigger: 'blur' },
{
type: 'number',
min: 1,
max: 65_535,
message: '端口号范围 1-65535',
trigger: 'blur',
},
]"
label="端口"
>
<ElInputNumber
v-model="config.port"
:max="65535"
:min="1"
placeholder="请输入端口"
class="w-full"
/>
</ElFormItem>
<ElFormItem prop="config.password" label="密码">
<ElInput
v-model="config.password"
type="password"
show-password
placeholder="请输入密码"
/>
</ElFormItem>
<ElFormItem
prop="config.database"
:rules="[
{ required: true, message: '数据库索引不能为空', trigger: 'blur' },
{
type: 'number',
min: 0,
message: '数据库索引必须是非负整数',
trigger: 'blur',
},
]"
label="数据库"
>
<ElInputNumber
v-model="config.database"
:max="15"
:min="0"
placeholder="请输入数据库索引"
class="w-full"
/>
</ElFormItem>
<ElFormItem
prop="config.topic"
:rules="[{ required: true, message: '主题不能为空', trigger: 'blur' }]"
label="主题"
>
<ElInput v-model="config.topic" placeholder="请输入主题" />
</ElFormItem>
</template>

View File

@ -0,0 +1,79 @@
<script lang="ts" setup>
import { onMounted } from 'vue';
import { isEmpty } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import { ElFormItem, ElInput } from 'element-plus';
import { IotDataSinkTypeEnum } from '#/api/iot/rule/data/sink';
const props = defineProps<{ modelValue: any }>();
const emit = defineEmits(['update:modelValue']);
const config = useVModel(props, 'modelValue', emit);
onMounted(() => {
if (!isEmpty(config.value)) {
return;
}
config.value = {
type: `${IotDataSinkTypeEnum.ROCKETMQ}`,
nameServer: '',
accessKey: '',
secretKey: '',
group: '',
topic: '',
tags: '',
};
});
</script>
<template>
<ElFormItem
prop="config.nameServer"
:rules="[{ required: true, message: 'NameServer 地址不能为空', trigger: 'blur' }]"
label="NameServer"
>
<ElInput
v-model="config.nameServer"
placeholder="请输入 NameServer 地址127.0.0.1:9876"
/>
</ElFormItem>
<ElFormItem
prop="config.accessKey"
:rules="[{ required: true, message: 'AccessKey 不能为空', trigger: 'blur' }]"
label="AccessKey"
>
<ElInput v-model="config.accessKey" placeholder="请输入 AccessKey" />
</ElFormItem>
<ElFormItem
prop="config.secretKey"
:rules="[{ required: true, message: 'SecretKey 不能为空', trigger: 'blur' }]"
label="SecretKey"
>
<ElInput
v-model="config.secretKey"
type="password"
show-password
placeholder="请输入 SecretKey"
/>
</ElFormItem>
<ElFormItem
prop="config.group"
:rules="[{ required: true, message: '消费组不能为空', trigger: 'blur' }]"
label="消费组"
>
<ElInput v-model="config.group" placeholder="请输入消费组" />
</ElFormItem>
<ElFormItem
prop="config.topic"
:rules="[{ required: true, message: '主题不能为空', trigger: 'blur' }]"
label="主题"
>
<ElInput v-model="config.topic" placeholder="请输入主题" />
</ElFormItem>
<ElFormItem prop="config.tags" label="标签">
<ElInput v-model="config.tags" placeholder="请输入标签" />
</ElFormItem>
</template>

View File

@ -0,0 +1,144 @@
<script lang="ts" setup>
import { onMounted } from 'vue';
import { isEmpty } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import {
ElFormItem,
ElInput,
ElInputNumber,
ElOption,
ElSelect,
ElSwitch,
} from 'element-plus';
import { IotDataSinkTypeEnum } from '#/api/iot/rule/data/sink';
const props = defineProps<{ modelValue: any }>();
const emit = defineEmits(['update:modelValue']);
const config = useVModel(props, 'modelValue', emit);
onMounted(() => {
if (!isEmpty(config.value)) {
return;
}
config.value = {
type: `${IotDataSinkTypeEnum.TCP}`,
host: '',
port: 8080,
connectTimeoutMs: 5000,
readTimeoutMs: 10_000,
ssl: false,
sslCertPath: '',
dataFormat: 'JSON',
heartbeatIntervalMs: 30_000,
reconnectIntervalMs: 5000,
maxReconnectAttempts: 3,
};
});
</script>
<template>
<ElFormItem
prop="config.host"
:rules="[{ required: true, message: '主机地址不能为空', trigger: 'blur' }]"
label="服务器地址"
>
<ElInput
v-model="config.host"
placeholder="请输入 TCP 服务器地址localhost"
/>
</ElFormItem>
<ElFormItem
prop="config.port"
:rules="[
{ required: true, message: '端口不能为空', trigger: 'blur' },
{
type: 'number',
min: 1,
max: 65_535,
message: '端口号范围 1-65535',
trigger: 'blur',
},
]"
label="端口"
>
<ElInputNumber
v-model="config.port"
:max="65535"
:min="1"
placeholder="请输入端口"
class="w-full"
/>
</ElFormItem>
<ElFormItem
prop="config.connectTimeoutMs"
:rules="[{ required: true, message: '连接超时时间不能为空', trigger: 'blur' }]"
label="连接超时(ms)"
>
<ElInputNumber
v-model="config.connectTimeoutMs"
:min="1000"
:step="1000"
class="w-full"
/>
</ElFormItem>
<ElFormItem
prop="config.readTimeoutMs"
:rules="[{ required: true, message: '读取超时时间不能为空', trigger: 'blur' }]"
label="读取超时(ms)"
>
<ElInputNumber
v-model="config.readTimeoutMs"
:min="1000"
:step="1000"
class="w-full"
/>
</ElFormItem>
<ElFormItem prop="config.ssl" label="启用 SSL">
<ElSwitch v-model="config.ssl" />
</ElFormItem>
<ElFormItem
v-if="config.ssl"
prop="config.sslCertPath"
label="SSL 证书路径"
>
<ElInput v-model="config.sslCertPath" placeholder="请输入 SSL 证书路径" />
</ElFormItem>
<ElFormItem
prop="config.dataFormat"
:rules="[{ required: true, message: '数据格式不能为空', trigger: 'change' }]"
label="数据格式"
>
<ElSelect v-model="config.dataFormat" placeholder="请选择数据格式">
<ElOption label="JSON" value="JSON" />
<ElOption label="BINARY" value="BINARY" />
</ElSelect>
</ElFormItem>
<ElFormItem prop="config.heartbeatIntervalMs" label="心跳间隔(ms)">
<ElInputNumber
v-model="config.heartbeatIntervalMs"
:min="0"
:step="1000"
placeholder="0 表示不启用心跳"
class="w-full"
/>
</ElFormItem>
<ElFormItem prop="config.reconnectIntervalMs" label="重连间隔(ms)">
<ElInputNumber
v-model="config.reconnectIntervalMs"
:min="1000"
:step="1000"
class="w-full"
/>
</ElFormItem>
<ElFormItem prop="config.maxReconnectAttempts" label="最大重连次数">
<ElInputNumber
v-model="config.maxReconnectAttempts"
:min="0"
class="w-full"
/>
</ElFormItem>
</template>

View File

@ -0,0 +1,159 @@
<script lang="ts" setup>
import { onMounted } from 'vue';
import { isEmpty } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import {
ElFormItem,
ElInput,
ElInputNumber,
ElOption,
ElSelect,
ElSwitch,
} from 'element-plus';
import { IotDataSinkTypeEnum } from '#/api/iot/rule/data/sink';
const props = defineProps<{ modelValue: any }>();
const emit = defineEmits(['update:modelValue']);
const config = useVModel(props, 'modelValue', emit);
onMounted(() => {
if (!isEmpty(config.value)) {
return;
}
config.value = {
type: `${IotDataSinkTypeEnum.WEBSOCKET}`,
serverUrl: '',
connectTimeoutMs: 5000,
sendTimeoutMs: 10_000,
heartbeatIntervalMs: 30_000,
heartbeatMessage: '{"type":"heartbeat"}',
subprotocols: '',
customHeaders: '',
verifySslCert: true,
dataFormat: 'JSON',
reconnectIntervalMs: 5000,
maxReconnectAttempts: 3,
enableCompression: false,
sendRetryCount: 1,
sendRetryIntervalMs: 1000,
};
});
</script>
<template>
<ElFormItem
prop="config.serverUrl"
:rules="[
{ required: true, message: 'WebSocket 服务器地址不能为空', trigger: 'blur' },
]"
label="服务器地址"
>
<ElInput
v-model="config.serverUrl"
placeholder="请输入 WebSocket 地址ws://localhost:8080/ws"
/>
</ElFormItem>
<ElFormItem
prop="config.connectTimeoutMs"
:rules="[{ required: true, message: '连接超时时间不能为空', trigger: 'blur' }]"
label="连接超时(ms)"
>
<ElInputNumber
v-model="config.connectTimeoutMs"
:min="1000"
:step="1000"
class="w-full"
/>
</ElFormItem>
<ElFormItem
prop="config.sendTimeoutMs"
:rules="[{ required: true, message: '发送超时时间不能为空', trigger: 'blur' }]"
label="发送超时(ms)"
>
<ElInputNumber
v-model="config.sendTimeoutMs"
:min="1000"
:step="1000"
class="w-full"
/>
</ElFormItem>
<ElFormItem prop="config.heartbeatIntervalMs" label="心跳间隔(ms)">
<ElInputNumber
v-model="config.heartbeatIntervalMs"
:min="0"
:step="1000"
placeholder="0 表示不启用心跳"
class="w-full"
/>
</ElFormItem>
<ElFormItem prop="config.heartbeatMessage" label="心跳消息">
<ElInput
v-model="config.heartbeatMessage"
placeholder="请输入心跳消息内容JSON 格式)"
/>
</ElFormItem>
<ElFormItem prop="config.subprotocols" label="子协议">
<ElInput
v-model="config.subprotocols"
placeholder="请输入子协议列表,多个用逗号分隔"
/>
</ElFormItem>
<ElFormItem prop="config.customHeaders" label="自定义请求头">
<ElInput
v-model="config.customHeaders"
type="textarea"
placeholder="请输入自定义请求头JSON 格式)"
:rows="3"
/>
</ElFormItem>
<ElFormItem prop="config.verifySslCert" label="验证 SSL 证书">
<ElSwitch v-model="config.verifySslCert" />
</ElFormItem>
<ElFormItem
prop="config.dataFormat"
:rules="[{ required: true, message: '数据格式不能为空', trigger: 'change' }]"
label="数据格式"
>
<ElSelect v-model="config.dataFormat" placeholder="请选择数据格式">
<ElOption label="JSON" value="JSON" />
<ElOption label="TEXT" value="TEXT" />
</ElSelect>
</ElFormItem>
<ElFormItem prop="config.reconnectIntervalMs" label="重连间隔(ms)">
<ElInputNumber
v-model="config.reconnectIntervalMs"
:min="1000"
:step="1000"
class="w-full"
/>
</ElFormItem>
<ElFormItem prop="config.maxReconnectAttempts" label="最大重连次数">
<ElInputNumber
v-model="config.maxReconnectAttempts"
:min="0"
class="w-full"
/>
</ElFormItem>
<ElFormItem prop="config.enableCompression" label="启用压缩">
<ElSwitch v-model="config.enableCompression" />
</ElFormItem>
<ElFormItem prop="config.sendRetryCount" label="发送重试次数">
<ElInputNumber
v-model="config.sendRetryCount"
:min="0"
class="w-full"
/>
</ElFormItem>
<ElFormItem prop="config.sendRetryIntervalMs" label="重试间隔(ms)">
<ElInputNumber
v-model="config.sendRetryIntervalMs"
:min="100"
:step="500"
class="w-full"
/>
</ElFormItem>
</template>

View File

@ -0,0 +1,104 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { DataSinkApi } from '#/api/iot/rule/data/sink';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { getRangePickerDefaultProps } from '#/utils';
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '目的名称',
component: 'Input',
componentProps: {
placeholder: '请输入目的名称',
clearable: true,
},
},
{
fieldName: 'status',
label: '目的状态',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
placeholder: '请选择状态',
clearable: true,
},
},
{
fieldName: 'type',
label: '目的类型',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.IOT_DATA_SINK_TYPE_ENUM, 'number'),
placeholder: '请选择目的类型',
clearable: true,
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
clearable: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions<DataSinkApi.DataSink>['columns'] {
return [
{ type: 'checkbox', width: 40 },
{
field: 'id',
title: '目的编号',
minWidth: 80,
},
{
field: 'name',
title: '目的名称',
minWidth: 150,
},
{
field: 'description',
title: '目的描述',
minWidth: 200,
},
{
field: 'status',
title: '目的状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
field: 'type',
title: '目的类型',
minWidth: 120,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.IOT_DATA_SINK_TYPE_ENUM },
},
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 160,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

Some files were not shown because too many files have changed in this diff Show More