fix(iot): 修复产品、设备、规则与首页对标差异

- 对齐产品卡片默认图标和图片资源,修正产品导出文件名
- 对齐设备导入、属性历史、分组校验和物模型编辑行为
- 对齐首页统计空态、设备地图图例和快捷日期范围实现
- 对齐数据规则 source/sink 配置、Redis Stream 字段契约和场景联动选择器
- 补充空值判断工具测试,并将剩余 IoT 对标项迁入 done
pull/348/head
YunaiV 2026-05-25 08:22:59 +08:00
parent fab333fbb7
commit 272757995e
39 changed files with 493 additions and 488 deletions

View File

@ -5,7 +5,7 @@ import { requestClient } from '#/api/request';
export namespace MesProProcessApi { export namespace MesProProcessApi {
/** MES 生产工序 */ /** MES 生产工序 */
export interface Process { export interface Process {
id: number; id?: number;
code?: string; code?: string;
name?: string; name?: string;
attention?: string; attention?: string;
@ -36,3 +36,23 @@ export function getProcess(id: number) {
`/mes/pro/process/get?id=${id}`, `/mes/pro/process/get?id=${id}`,
); );
} }
/** 新增生产工序 */
export function createProcess(data: MesProProcessApi.Process) {
return requestClient.post('/mes/pro/process/create', data);
}
/** 修改生产工序 */
export function updateProcess(data: MesProProcessApi.Process) {
return requestClient.put('/mes/pro/process/update', data);
}
/** 删除生产工序 */
export function deleteProcess(id: number) {
return requestClient.delete(`/mes/pro/process/delete?id=${id}`);
}
/** 导出生产工序 Excel */
export function exportProcess(params: any) {
return requestClient.download('/mes/pro/process/export-excel', { params });
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 12 12"><g clip-path="url(#a)"><path fill="url(#b)" fill-rule="evenodd" d="M6.958.42C6.444.216 5.61.216 5.098.42L1.15 1.975c-.77.304-.77.797 0 1.1l3.947 1.558c.514.202 1.347.202 1.86 0l3.948-1.557c.77-.304.77-.797 0-1.1L6.958.418ZM4.715 11.788a.857.857 0 0 0 .3.056c.383 0 .671-.295.671-.7V6.404c0-.49-.364-1.007-.817-1.177L1.09 3.805a.808.808 0 0 0-.284-.056c-.353 0-.581.275-.581.7V9.19c0 .508.33 1.014.763 1.177l3.726 1.422Zm2.229-.024h-.02l.073.003c.074.004.154.009.227-.019L11 10.367c.45-.168.83-.686.83-1.177V4.45c0-.413-.29-.7-.673-.7a.965.965 0 0 0-.317.055l-3.72 1.422c-.44.165-.75.67-.75 1.177v4.74c0 .42.218.621.575.621Z" clip-rule="evenodd"/></g><defs><linearGradient id="b" x1=".226" x2="11.803" y1=".267" y2="11.871" gradientUnits="userSpaceOnUse"><stop stop-color="#1B3149"/><stop offset="1" stop-color="#717D8A"/></linearGradient><clipPath id="a"><path fill="#fff" d="M0 0h12v12H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1011 B

View File

@ -137,16 +137,6 @@ const tableColumns = computed(() => [
}, },
]); // ]); //
const paginationConfig = computed(() => ({
current: 1,
pageSize: 10,
total: total.value,
showSizeChanger: true,
showQuickJumper: true,
pageSizeOptions: ['10', '20', '50', '100'],
showTotal: (total: number) => `${total} 条数据`,
})); //
/** 获得设备历史数据 */ /** 获得设备历史数据 */
async function getList() { async function getList() {
loading.value = true; loading.value = true;
@ -438,7 +428,7 @@ defineExpose({ open }); // 提供 open 方法,用于打开弹窗
<Table <Table
:columns="tableColumns" :columns="tableColumns"
:data-source="list" :data-source="list"
:pagination="paginationConfig" :pagination="false"
:scroll="{ y: 500 }" :scroll="{ y: 500 }"
row-key="_rowKey" row-key="_rowKey"
size="small" size="small"

View File

@ -75,11 +75,6 @@ const [Modal, modalApi] = useVbenModal({
/** 上传前 */ /** 上传前 */
function beforeUpload(file: FileType) { function beforeUpload(file: FileType) {
const fileName = file.name?.toLowerCase() ?? '';
if (!fileName.endsWith('.xls') && !fileName.endsWith('.xlsx')) {
message.error('只能上传 Excel 文件(.xls / .xlsx');
return Upload.LIST_IGNORE;
}
formApi.setFieldValue('file', file); formApi.setFieldValue('file', file);
return false; return false;
} }
@ -97,18 +92,18 @@ async function handleDownload() {
<template #file> <template #file>
<div class="w-full"> <div class="w-full">
<Upload <Upload
:before-upload="beforeUpload"
:max-count="1" :max-count="1"
accept=".xls,.xlsx" accept=".xls,.xlsx"
:before-upload="beforeUpload"
> >
<Button type="primary"> 选择 Excel 文件</Button> <Button type="primary"> 选择 Excel 文件 </Button>
</Upload> </Upload>
</div> </div>
</template> </template>
</Form> </Form>
<template #prepend-footer> <template #prepend-footer>
<div class="flex flex-auto items-center"> <div class="flex flex-auto items-center">
<Button @click="handleDownload"> </Button> <Button @click="handleDownload"> </Button>
</div> </div>
</template> </template>
</Modal> </Modal>

View File

@ -26,10 +26,7 @@ export function useFormSchema(): VbenFormSchema[] {
componentProps: { componentProps: {
placeholder: '请输入分组名称', placeholder: '请输入分组名称',
}, },
rules: z rules: z.string().min(1, '分组名称不能为空'),
.string()
.min(1, '分组名称不能为空')
.max(64, '分组名称长度不能超过 64 个字符'),
}, },
{ {
fieldName: 'status', fieldName: 'status',

View File

@ -9,12 +9,12 @@ export const defaultStatsData: StatsData = {
productCount: -1, productCount: -1,
deviceCount: -1, deviceCount: -1,
deviceMessageCount: -1, deviceMessageCount: -1,
productCategoryTodayCount: 0, productCategoryTodayCount: -1,
productTodayCount: 0, productTodayCount: -1,
deviceTodayCount: 0, deviceTodayCount: -1,
deviceMessageTodayCount: 0, deviceMessageTodayCount: -1,
deviceOnlineCount: 0, deviceOnlineCount: -1,
deviceOfflineCount: 0, deviceOfflineCount: -1,
deviceInactiveCount: 0, deviceInactiveCount: -1,
productCategoryDeviceCounts: {}, productCategoryDeviceCounts: {},
}; };

View File

@ -1,11 +1,13 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { NumberDictDataType } from '@vben/hooks';
import type { IotDeviceApi } from '#/api/iot/device/device'; import type { IotDeviceApi } from '#/api/iot/device/device';
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'; import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { DeviceStateEnum, DICT_TYPE } from '@vben/constants'; import { DeviceStateEnum, DICT_TYPE } from '@vben/constants';
import { getDictLabel } from '@vben/hooks'; import { getDictLabel, getDictOptions } from '@vben/hooks';
import { Card, Empty, Spin } from 'ant-design-vue'; import { Card, Empty, Spin } from 'ant-design-vue';
@ -20,15 +22,20 @@ let mapInstance: any = null; // 百度地图实例
const loading = ref(true); // const loading = ref(true); //
const deviceList = ref<IotDeviceApi.Device[]>([]); // const deviceList = ref<IotDeviceApi.Device[]>([]); //
/** 是否有数据 */ const hasData = computed(() => deviceList.value.length > 0); //
const hasData = computed(() => deviceList.value.length > 0);
const stateOptions = computed(() =>
getDictOptions(
DICT_TYPE.IOT_DEVICE_STATE,
'number',
) as NumberDictDataType[],
); //
/** 设备状态颜色映射 */
const stateColorMap: Record<number, string> = { const stateColorMap: Record<number, string> = {
[DeviceStateEnum.INACTIVE]: '#EAB308', // - [DeviceStateEnum.INACTIVE]: '#EAB308', // -
[DeviceStateEnum.ONLINE]: '#22C55E', // 线 - 绿 [DeviceStateEnum.ONLINE]: '#22C55E', // 线 - 绿
[DeviceStateEnum.OFFLINE]: '#9CA3AF', // 线 - [DeviceStateEnum.OFFLINE]: '#9CA3AF', // 线 -
}; }; //
/** 获取设备状态配置;名称走字典,颜色用本地映射 */ /** 获取设备状态配置;名称走字典,颜色用本地映射 */
function getStateConfig(state: number): { color: string; name: string } { function getStateConfig(state: number): { color: string; name: string } {
@ -177,28 +184,18 @@ onUnmounted(() => {
<Card class="h-full" title="设备分布地图"> <Card class="h-full" title="设备分布地图">
<template #extra> <template #extra>
<div class="flex items-center gap-4 text-sm"> <div class="flex items-center gap-4 text-sm">
<span class="flex items-center gap-1"> <span
<span v-for="item in stateOptions"
class="inline-block h-3 w-3 rounded-full" :key="item.value"
:style="{ backgroundColor: stateColorMap[DeviceStateEnum.ONLINE] }" class="flex items-center gap-1"
></span> >
<span class="text-gray-500">在线</span>
</span>
<span class="flex items-center gap-1">
<span
class="inline-block h-3 w-3 rounded-full"
:style="{ backgroundColor: stateColorMap[DeviceStateEnum.OFFLINE] }"
></span>
<span class="text-gray-500">离线</span>
</span>
<span class="flex items-center gap-1">
<span <span
class="inline-block h-3 w-3 rounded-full" class="inline-block h-3 w-3 rounded-full"
:style="{ :style="{
backgroundColor: stateColorMap[DeviceStateEnum.INACTIVE], backgroundColor: stateColorMap[item.value],
}" }"
></span> ></span>
<span class="text-gray-500">待激活</span> <span class="text-gray-500">{{ item.label }}</span>
</span> </span>
</div> </div>
</template> </template>

View File

@ -79,7 +79,7 @@ async function handleViewModeChange(mode: 'card' | 'list') {
/** 导出表格 */ /** 导出表格 */
async function handleExport() { async function handleExport() {
const data = await exportProduct(queryParams.value); const data = await exportProduct(queryParams.value);
downloadFileFromBlobPart({ fileName: '产品列表.xls', source: data }); downloadFileFromBlobPart({ fileName: '物联网产品.xls', source: data });
} }
/** 打开产品详情 */ /** 打开产品详情 */

View File

@ -19,6 +19,8 @@ import {
} from 'ant-design-vue'; } from 'ant-design-vue';
import { getProductPage } from '#/api/iot/product/product'; import { getProductPage } from '#/api/iot/product/product';
import defaultPicUrl from '#/assets/imgs/iot/device.png';
import defaultIconUrl from '#/assets/svgs/iot/cube.svg';
import { DictTag } from '#/components/dict-tag'; import { DictTag } from '#/components/dict-tag';
interface Props { interface Props {
@ -55,6 +57,24 @@ function getCategoryName(item: any) {
return item.categoryName || category?.name || '未分类'; return item.categoryName || category?.name || '未分类';
} }
/** 是否按图片 URL 渲染产品图标 */
function isImageIcon(icon?: string) {
if (!icon) {
return true;
}
return isHttpUrl(icon);
}
/** 产品图标 fallback */
function getProductIcon(icon?: string) {
return icon || defaultIconUrl;
}
/** 产品图片 fallback */
function getProductPic(picUrl?: string) {
return picUrl || defaultPicUrl;
}
/** 获取产品列表 */ /** 获取产品列表 */
async function getList() { async function getList() {
loading.value = true; loading.value = true;
@ -119,14 +139,14 @@ onMounted(() => {
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-[#40a9ff] to-[#1890ff] text-white" class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-[#40a9ff] to-[#1890ff] text-white"
> >
<img <img
v-if="isHttpUrl(item.icon)" v-if="isImageIcon(item.icon)"
:src="item.icon" :src="getProductIcon(item.icon)"
alt="" alt=""
class="size-6 object-contain" class="size-6 object-contain"
/> />
<IconifyIcon <IconifyIcon
v-else v-else
:icon="item.icon || 'lucide:box'" :icon="item.icon"
class="text-xl" class="text-xl"
/> />
</div> </div>
@ -177,16 +197,10 @@ onMounted(() => {
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]" 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 <Image
v-if="item.picUrl" :src="getProductPic(item.picUrl)"
:src="item.picUrl"
:preview="true" :preview="true"
class="size-full rounded object-cover" class="size-full rounded object-cover"
/> />
<IconifyIcon
v-else
icon="lucide:image"
class="text-2xl opacity-50"
/>
</div> </div>
</div> </div>
<!-- 按钮组 --> <!-- 按钮组 -->

View File

@ -216,6 +216,7 @@ defineExpose({ validate, getData, setData });
v-model:value="formData[rowIndex].productId" v-model:value="formData[rowIndex].productId"
placeholder="请选择产品" placeholder="请选择产品"
show-search show-search
allow-clear
:filter-option=" :filter-option="
(input: string, option: any) => (input: string, option: any) =>
option.label.toLowerCase().includes(input.toLowerCase()) option.label.toLowerCase().includes(input.toLowerCase())
@ -232,6 +233,7 @@ defineExpose({ validate, getData, setData });
v-model:value="formData[rowIndex].deviceId" v-model:value="formData[rowIndex].deviceId"
placeholder="请选择设备" placeholder="请选择设备"
show-search show-search
allow-clear
:filter-option=" :filter-option="
(input: string, option: any) => (input: string, option: any) =>
option.label.toLowerCase().includes(input.toLowerCase()) option.label.toLowerCase().includes(input.toLowerCase())
@ -253,6 +255,7 @@ defineExpose({ validate, getData, setData });
v-model:value="formData[rowIndex].method" v-model:value="formData[rowIndex].method"
placeholder="请选择消息" placeholder="请选择消息"
show-search show-search
allow-clear
:filter-option=" :filter-option="
(input: string, option: any) => (input: string, option: any) =>
option.label.toLowerCase().includes(input.toLowerCase()) option.label.toLowerCase().includes(input.toLowerCase())
@ -273,6 +276,7 @@ defineExpose({ validate, getData, setData });
v-model:value="formData[rowIndex].identifier" v-model:value="formData[rowIndex].identifier"
placeholder="请选择标识符" placeholder="请选择标识符"
show-search show-search
allow-clear
:loading="formData[rowIndex].identifierLoading" :loading="formData[rowIndex].identifierLoading"
:filter-option=" :filter-option="
(input: string, option: any) => (input: string, option: any) =>

View File

@ -1,10 +1,10 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, onMounted } from 'vue'; import { onMounted } from 'vue';
import { isEmpty } from '@vben/utils'; import { isEmpty } from '@vben/utils';
import { useVModel } from '@vueuse/core'; import { useVModel } from '@vueuse/core';
import { Form, Input, InputNumber, Select } from 'ant-design-vue'; import { Form, Input, InputNumber } from 'ant-design-vue';
import { IotDataSinkTypeEnum } from '#/api/iot/rule/data/sink'; import { IotDataSinkTypeEnum } from '#/api/iot/rule/data/sink';
@ -12,23 +12,16 @@ const props = defineProps<{ modelValue: any }>();
const emit = defineEmits(['update:modelValue']); const emit = defineEmits(['update:modelValue']);
const config = useVModel(props, 'modelValue', emit); const config = useVModel(props, 'modelValue', emit);
const REDIS_DATA_STRUCTURE_OPTIONS = [ /** 移除当前 Redis Stream API 类型未声明的旧扩展字段 */
{ label: 'Stream', value: 1 }, function removeUnsupportedFields() {
{ label: 'Hash', value: 2 }, delete config.value.dataStructure;
{ label: 'List', value: 3 }, delete config.value.hashField;
{ label: 'Set', value: 4 }, delete config.value.scoreField;
{ label: 'ZSet', value: 5 }, }
{ label: 'String', value: 6 },
]; // Redis IotRedisDataStructureEnum
const isHash = computed(() => Number(config.value?.dataStructure) === 2);
const isZSet = computed(() => Number(config.value?.dataStructure) === 5);
onMounted(() => { onMounted(() => {
if (!isEmpty(config.value)) { if (!isEmpty(config.value)) {
if (config.value.dataStructure == null) { removeUnsupportedFields();
config.value.dataStructure = 1;
}
return; return;
} }
config.value = { config.value = {
@ -38,7 +31,6 @@ onMounted(() => {
password: '', password: '',
database: 0, database: 0,
topic: '', topic: '',
dataStructure: 1,
}; };
}); });
</script> </script>
@ -111,37 +103,4 @@ onMounted(() => {
> >
<Input v-model:value="config.topic" placeholder="请输入主题" /> <Input v-model:value="config.topic" placeholder="请输入主题" />
</Form.Item> </Form.Item>
<Form.Item
:name="['config', 'dataStructure']"
:rules="[
{ required: true, message: 'Redis 数据结构不能为空', trigger: 'change' },
]"
label="数据结构"
>
<Select
v-model:value="config.dataStructure"
:options="REDIS_DATA_STRUCTURE_OPTIONS"
placeholder="请选择 Redis 数据结构"
/>
</Form.Item>
<Form.Item
v-if="isHash"
:name="['config', 'hashField']"
label="Hash 字段"
>
<Input
v-model:value="config.hashField"
placeholder="留空使用 deviceId 作为 Hash 字段"
/>
</Form.Item>
<Form.Item
v-if="isZSet"
:name="['config', 'scoreField']"
label="Score 字段"
>
<Input
v-model:value="config.scoreField"
placeholder="留空使用当前时间戳作为 Score"
/>
</Form.Item>
</template> </template>

View File

@ -1,8 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { ThingModelApi } from '#/api/iot/thingmodel'; import type { ThingModelApi } from '#/api/iot/thingmodel';
import { computed } from 'vue';
import { import {
getEventTypeLabel, getEventTypeLabel,
getThingModelServiceCallTypeLabel, getThingModelServiceCallTypeLabel,
@ -10,9 +8,7 @@ import {
IoTThingModelTypeEnum, IoTThingModelTypeEnum,
} from '@vben/constants'; } from '@vben/constants';
import { Tooltip } from 'ant-design-vue'; defineProps<{ data: ThingModelApi.ThingModel }>();
const props = defineProps<{ data: ThingModelApi.ThingModel }>();
const NUMBER_TYPES = new Set<string>([ const NUMBER_TYPES = new Set<string>([
IoTDataSpecsDataTypeEnum.DOUBLE, IoTDataSpecsDataTypeEnum.DOUBLE,
IoTDataSpecsDataTypeEnum.FLOAT, IoTDataSpecsDataTypeEnum.FLOAT,
@ -27,26 +23,6 @@ const LIST_TYPES = new Set<string>([
IoTDataSpecsDataTypeEnum.BOOL, IoTDataSpecsDataTypeEnum.BOOL,
IoTDataSpecsDataTypeEnum.ENUM, IoTDataSpecsDataTypeEnum.ENUM,
]); ]);
const formattedDataSpecsList = computed(() => {
if (!props.data.property?.dataSpecsList?.length) {
return '';
}
return props.data.property.dataSpecsList
.map((item) => `${item.name}-${item.value}`)
.join('、');
});
const shortText = computed(() => {
const list = props.data.property?.dataSpecsList;
if (!list?.length) {
return '-';
}
const first = list[0];
return list.length > 1
? `${first.name}-${first.value}${list.length}`
: `${first.name}-${first.value}`;
});
</script> </script>
<template> <template>
@ -62,17 +38,19 @@ const shortText = computed(() => {
</div> </div>
<div v-if="PLACEHOLDER_TYPES.has(data.property?.dataType as any)">-</div> <div v-if="PLACEHOLDER_TYPES.has(data.property?.dataType as any)">-</div>
<div v-if="LIST_TYPES.has(data.property?.dataType as any)"> <div v-if="LIST_TYPES.has(data.property?.dataType as any)">
<Tooltip :title="formattedDataSpecsList" placement="topLeft"> <div>
<span {{
class="cursor-help border-b border-dashed border-gray-300 hover:border-blue-500 hover:text-blue-500" data.property?.dataType === IoTDataSpecsDataTypeEnum.BOOL
> ? '布尔值'
{{ : '枚举值'
data.property?.dataType === IoTDataSpecsDataTypeEnum.BOOL }}
? '布尔值' </div>
: '枚举值' <div
}}{{ shortText }} v-for="item in data.property?.dataSpecsList || []"
</span> :key="String(item.value)"
</Tooltip> >
{{ item.name }}-{{ item.value }}
</div>
</div> </div>
</template> </template>
<!-- 服务 --> <!-- 服务 -->

View File

@ -218,7 +218,6 @@ function removeDataSpecs(val: any) {
> >
<Input <Input
v-model:value="formData.identifier" v-model:value="formData.identifier"
:disabled="formData.id != null"
placeholder="请输入标识符" placeholder="请输入标识符"
/> />
</Form.Item> </Form.Item>

View File

@ -133,10 +133,70 @@ export const MesAutoCodeRuleCode = {
MD_VENDOR_CODE: 'MD_VENDOR_CODE', MD_VENDOR_CODE: 'MD_VENDOR_CODE',
MD_WORKSTATION_CODE: 'MD_WORKSTATION_CODE', MD_WORKSTATION_CODE: 'MD_WORKSTATION_CODE',
MD_WORKSHOP_CODE: 'MD_WORKSHOP_CODE', MD_WORKSHOP_CODE: 'MD_WORKSHOP_CODE',
PRO_CARD_CODE: 'PRO_CARD_CODE',
PRO_FEEDBACK_CODE: 'PRO_FEEDBACK_CODE',
PRO_PROCESS_CODE: 'PRO_PROCESS_CODE',
PRO_ROUTE_CODE: 'PRO_ROUTE_CODE',
PRO_TASK_CODE: 'PRO_TASK_CODE',
PRO_WORK_ORDER_CODE: 'PRO_WORK_ORDER_CODE',
TM_TOOL_TYPE_CODE: 'TM_TOOL_TYPE_CODE', TM_TOOL_TYPE_CODE: 'TM_TOOL_TYPE_CODE',
TM_TOOL_CODE: 'TM_TOOL_CODE', TM_TOOL_CODE: 'TM_TOOL_CODE',
} as const; } as const;
/** MES 生产工单状态枚举 */
export const MesProWorkOrderStatusEnum = {
PREPARE: MesOrderStatusConstants.DRAFT,
CONFIRMED: MesOrderStatusConstants.CONFIRMED,
APPROVING: MesOrderStatusConstants.APPROVING,
PRODUCING: MesOrderStatusConstants.APPROVED,
FINISHED: MesOrderStatusConstants.FINISHED,
CANCELLED: MesOrderStatusConstants.CANCELLED,
} as const;
/** MES 生产任务状态枚举 */
export const MesProTaskStatusEnum = {
PREPARE: MesOrderStatusConstants.DRAFT,
CONFIRMED: MesOrderStatusConstants.CONFIRMED,
APPROVING: MesOrderStatusConstants.APPROVING,
PRODUCING: MesOrderStatusConstants.APPROVED,
FINISHED: MesOrderStatusConstants.FINISHED,
CANCELLED: MesOrderStatusConstants.CANCELLED,
} as const;
/** MES 生产报工状态枚举 */
export const MesProFeedbackStatusEnum = {
PREPARE: MesOrderStatusConstants.DRAFT,
CONFIRMED: MesOrderStatusConstants.CONFIRMED,
APPROVING: MesOrderStatusConstants.APPROVING,
FINISHED: MesOrderStatusConstants.FINISHED,
CANCELLED: MesOrderStatusConstants.CANCELLED,
} as const;
/** MES 流转卡状态枚举 */
export const MesProCardStatusEnum = {
PREPARE: MesOrderStatusConstants.DRAFT,
ISSUED: MesOrderStatusConstants.CONFIRMED,
PRODUCING: MesOrderStatusConstants.APPROVED,
FINISHED: MesOrderStatusConstants.FINISHED,
CANCELLED: MesOrderStatusConstants.CANCELLED,
} as const;
/** MES 安灯类型枚举 */
export const MesProAndonTypeEnum = {
QUALITY: 1,
EQUIPMENT: 2,
MATERIAL: 3,
PROCESS: 4,
OTHER: 9,
} as const;
/** MES 安灯状态枚举 */
export const MesProAndonStatusEnum = {
TRIGGERED: 1,
HANDLING: 2,
CLOSED: 3,
} as const;
/** MES 编码规则分段类型枚举 */ /** MES 编码规则分段类型枚举 */
export const MesAutoCodePartTypeEnum = { export const MesAutoCodePartTypeEnum = {
INPUT: 1, INPUT: 1,

View File

@ -5,7 +5,7 @@ import { requestClient } from '#/api/request';
export namespace MesProProcessApi { export namespace MesProProcessApi {
/** MES 生产工序 */ /** MES 生产工序 */
export interface Process { export interface Process {
id: number; id?: number;
code?: string; code?: string;
name?: string; name?: string;
attention?: string; attention?: string;
@ -36,3 +36,23 @@ export function getProcess(id: number) {
`/mes/pro/process/get?id=${id}`, `/mes/pro/process/get?id=${id}`,
); );
} }
/** 新增生产工序 */
export function createProcess(data: MesProProcessApi.Process) {
return requestClient.post('/mes/pro/process/create', data);
}
/** 修改生产工序 */
export function updateProcess(data: MesProProcessApi.Process) {
return requestClient.put('/mes/pro/process/update', data);
}
/** 删除生产工序 */
export function deleteProcess(id: number) {
return requestClient.delete(`/mes/pro/process/delete?id=${id}`);
}
/** 导出生产工序 Excel */
export function exportProcess(params: any) {
return requestClient.download('/mes/pro/process/export-excel', { params });
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 12 12"><g clip-path="url(#a)"><path fill="url(#b)" fill-rule="evenodd" d="M6.958.42C6.444.216 5.61.216 5.098.42L1.15 1.975c-.77.304-.77.797 0 1.1l3.947 1.558c.514.202 1.347.202 1.86 0l3.948-1.557c.77-.304.77-.797 0-1.1L6.958.418ZM4.715 11.788a.857.857 0 0 0 .3.056c.383 0 .671-.295.671-.7V6.404c0-.49-.364-1.007-.817-1.177L1.09 3.805a.808.808 0 0 0-.284-.056c-.353 0-.581.275-.581.7V9.19c0 .508.33 1.014.763 1.177l3.726 1.422Zm2.229-.024h-.02l.073.003c.074.004.154.009.227-.019L11 10.367c.45-.168.83-.686.83-1.177V4.45c0-.413-.29-.7-.673-.7a.965.965 0 0 0-.317.055l-3.72 1.422c-.44.165-.75.67-.75 1.177v4.74c0 .42.218.621.575.621Z" clip-rule="evenodd"/></g><defs><linearGradient id="b" x1=".226" x2="11.803" y1=".267" y2="11.871" gradientUnits="userSpaceOnUse"><stop stop-color="#1B3149"/><stop offset="1" stop-color="#717D8A"/></linearGradient><clipPath id="a"><path fill="#fff" d="M0 0h12v12H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1011 B

View File

@ -18,24 +18,20 @@ const emits = defineEmits<{
const times = ref<[Dayjs, Dayjs]>(); // const times = ref<[Dayjs, Dayjs]>(); //
const rangePickerProps = getRangePickerDefaultProps(); const rangePickerProps = getRangePickerDefaultProps();
const timeRangeOptions = [ const timeRangeOptions = [
{ {
label: '昨天', label: rangePickerProps.shortcuts[1]!.text,
value: () => [ value: () => rangePickerProps.shortcuts[1]!.value() as [Dayjs, Dayjs],
dayjs().subtract(1, 'day').startOf('day'),
dayjs().subtract(1, 'day').endOf('day'),
],
}, },
{ {
label: '最近 7 天', label: rangePickerProps.shortcuts[2]!.text,
value: () => [ value: () => [
dayjs().subtract(7, 'day').startOf('day'), dayjs().subtract(7, 'day').startOf('day'),
dayjs().subtract(1, 'day').endOf('day'), dayjs().subtract(1, 'day').endOf('day'),
], ],
}, },
{ {
label: '最近 30 天', label: rangePickerProps.shortcuts[3]!.text,
value: () => [ value: () => [
dayjs().subtract(30, 'day').startOf('day'), dayjs().subtract(30, 'day').startOf('day'),
dayjs().subtract(1, 'day').endOf('day'), dayjs().subtract(1, 'day').endOf('day'),

View File

@ -438,7 +438,6 @@ defineExpose({ open }); // 提供 open 方法,用于打开弹窗
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
/** 同别的地方,将 style 改成 unocss 的诉求。如果不好改,就注释说明; */
.property-history-container { .property-history-container {
max-height: 70vh; max-height: 70vh;
overflow: auto; overflow: auto;

View File

@ -71,15 +71,11 @@ const [Modal, modalApi] = useVbenModal({
}, },
}); });
/** 上传前 */ /** 文件改变时 */
function beforeUpload(file: File) { function handleChange(file: any) {
const fileName = file.name?.toLowerCase() ?? ''; if (file.raw) {
if (!fileName.endsWith('.xls') && !fileName.endsWith('.xlsx')) { formApi.setFieldValue('file', file.raw);
ElMessage.error('只能上传 Excel 文件(.xls / .xlsx');
return false;
} }
formApi.setFieldValue('file', file);
return false;
} }
/** 下载模版 */ /** 下载模版 */
@ -95,19 +91,19 @@ async function handleDownload() {
<template #file> <template #file>
<div class="w-full"> <div class="w-full">
<ElUpload <ElUpload
:before-upload="beforeUpload"
:limit="1" :limit="1"
accept=".xls,.xlsx" accept=".xls,.xlsx"
:show-file-list="false" :on-change="handleChange"
:auto-upload="false"
> >
<ElButton type="primary">选择 Excel 文件</ElButton> <ElButton type="primary"> 选择 Excel 文件 </ElButton>
</ElUpload> </ElUpload>
</div> </div>
</template> </template>
</Form> </Form>
<template #prepend-footer> <template #prepend-footer>
<div class="flex flex-auto items-center"> <div class="flex flex-auto items-center">
<ElButton @click="handleDownload"></ElButton> <ElButton @click="handleDownload"> </ElButton>
</div> </div>
</template> </template>
</Modal> </Modal>

View File

@ -26,10 +26,7 @@ export function useFormSchema(): VbenFormSchema[] {
componentProps: { componentProps: {
placeholder: '请输入分组名称', placeholder: '请输入分组名称',
}, },
rules: z rules: z.string().min(1, '分组名称不能为空'),
.string()
.min(1, '分组名称不能为空')
.max(64, '分组名称长度不能超过 64 个字符'),
}, },
{ {
fieldName: 'status', fieldName: 'status',

View File

@ -9,12 +9,12 @@ export const defaultStatsData: StatsData = {
productCount: -1, productCount: -1,
deviceCount: -1, deviceCount: -1,
deviceMessageCount: -1, deviceMessageCount: -1,
productCategoryTodayCount: 0, productCategoryTodayCount: -1,
productTodayCount: 0, productTodayCount: -1,
deviceTodayCount: 0, deviceTodayCount: -1,
deviceMessageTodayCount: 0, deviceMessageTodayCount: -1,
deviceOnlineCount: 0, deviceOnlineCount: -1,
deviceOfflineCount: 0, deviceOfflineCount: -1,
deviceInactiveCount: 0, deviceInactiveCount: -1,
productCategoryDeviceCounts: {}, productCategoryDeviceCounts: {},
}; };

View File

@ -1,11 +1,13 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { NumberDictDataType } from '@vben/hooks';
import type { IotDeviceApi } from '#/api/iot/device/device'; import type { IotDeviceApi } from '#/api/iot/device/device';
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'; import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { DeviceStateEnum, DICT_TYPE } from '@vben/constants'; import { DeviceStateEnum, DICT_TYPE } from '@vben/constants';
import { getDictLabel } from '@vben/hooks'; import { getDictLabel, getDictOptions } from '@vben/hooks';
import { ElCard, ElEmpty } from 'element-plus'; import { ElCard, ElEmpty } from 'element-plus';
@ -20,15 +22,20 @@ let mapInstance: any = null; // 百度地图实例
const loading = ref(true); // const loading = ref(true); //
const deviceList = ref<IotDeviceApi.Device[]>([]); // const deviceList = ref<IotDeviceApi.Device[]>([]); //
/** 是否有数据 */ const hasData = computed(() => deviceList.value.length > 0); //
const hasData = computed(() => deviceList.value.length > 0);
const stateOptions = computed(() =>
getDictOptions(
DICT_TYPE.IOT_DEVICE_STATE,
'number',
) as NumberDictDataType[],
); //
/** 设备状态颜色映射 */
const stateColorMap: Record<number, string> = { const stateColorMap: Record<number, string> = {
[DeviceStateEnum.INACTIVE]: '#EAB308', // - [DeviceStateEnum.INACTIVE]: '#EAB308', // -
[DeviceStateEnum.ONLINE]: '#22C55E', // 线 - 绿 [DeviceStateEnum.ONLINE]: '#22C55E', // 线 - 绿
[DeviceStateEnum.OFFLINE]: '#9CA3AF', // 线 - [DeviceStateEnum.OFFLINE]: '#9CA3AF', // 线 -
}; }; //
/** 获取设备状态配置;名称走字典,颜色用本地映射 */ /** 获取设备状态配置;名称走字典,颜色用本地映射 */
function getStateConfig(state: number): { color: string; name: string } { function getStateConfig(state: number): { color: string; name: string } {
@ -177,32 +184,18 @@ onUnmounted(() => {
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span>设备分布地图</span> <span>设备分布地图</span>
<div class="flex items-center gap-4 text-sm"> <div class="flex items-center gap-4 text-sm">
<span class="flex items-center gap-1"> <span
v-for="item in stateOptions"
:key="item.value"
class="flex items-center gap-1"
>
<span <span
class="inline-block h-3 w-3 rounded-full" class="inline-block h-3 w-3 rounded-full"
:style="{ :style="{
backgroundColor: stateColorMap[DeviceStateEnum.ONLINE], backgroundColor: stateColorMap[item.value],
}" }"
></span> ></span>
<span class="text-gray-500">在线</span> <span class="text-gray-500">{{ item.label }}</span>
</span>
<span class="flex items-center gap-1">
<span
class="inline-block h-3 w-3 rounded-full"
:style="{
backgroundColor: stateColorMap[DeviceStateEnum.OFFLINE],
}"
></span>
<span class="text-gray-500">离线</span>
</span>
<span class="flex items-center gap-1">
<span
class="inline-block h-3 w-3 rounded-full"
:style="{
backgroundColor: stateColorMap[DeviceStateEnum.INACTIVE],
}"
></span>
<span class="text-gray-500">待激活</span>
</span> </span>
</div> </div>
</div> </div>

View File

@ -24,7 +24,11 @@ export function getProductName(productId?: number): string {
export function useDetailSchema(): DescriptionItemSchema[] { export function useDetailSchema(): DescriptionItemSchema[] {
return [ return [
{ field: 'name', label: '固件名称' }, { field: 'name', label: '固件名称' },
{ field: 'productName', label: '所属产品' }, {
field: 'productName',
label: '所属产品',
render: (val) => val || '-',
},
{ field: 'version', label: '固件版本' }, { field: 'version', label: '固件版本' },
{ {
field: 'createTime', field: 'createTime',

View File

@ -37,6 +37,7 @@ export function useFormSchema(): VbenFormSchema[] {
component: 'InputNumber', component: 'InputNumber',
componentProps: { componentProps: {
class: '!w-full', class: '!w-full',
controlsPosition: 'right',
placeholder: '请输入分类排序', placeholder: '请输入分类排序',
min: 0, min: 0,
precision: 0, precision: 0,

View File

@ -79,7 +79,7 @@ async function handleViewModeChange(mode: 'card' | 'list') {
/** 导出表格 */ /** 导出表格 */
async function handleExport() { async function handleExport() {
const data = await exportProduct(queryParams.value); const data = await exportProduct(queryParams.value);
downloadFileFromBlobPart({ fileName: '产品列表.xls', source: data }); downloadFileFromBlobPart({ fileName: '物联网产品.xls', source: data });
} }
/** 打开产品详情 */ /** 打开产品详情 */

View File

@ -19,6 +19,8 @@ import {
} from 'element-plus'; } from 'element-plus';
import { getProductPage } from '#/api/iot/product/product'; import { getProductPage } from '#/api/iot/product/product';
import defaultPicUrl from '#/assets/imgs/iot/device.png';
import defaultIconUrl from '#/assets/svgs/iot/cube.svg';
import { DictTag } from '#/components/dict-tag'; import { DictTag } from '#/components/dict-tag';
interface Props { interface Props {
@ -55,6 +57,24 @@ function getCategoryName(item: any) {
return item.categoryName || category?.name || '未分类'; return item.categoryName || category?.name || '未分类';
} }
/** 是否按图片 URL 渲染产品图标 */
function isImageIcon(icon?: string) {
if (!icon) {
return true;
}
return isHttpUrl(icon);
}
/** 产品图标 fallback */
function getProductIcon(icon?: string) {
return icon || defaultIconUrl;
}
/** 产品图片 fallback */
function getProductPic(picUrl?: string) {
return picUrl || defaultPicUrl;
}
/** 获取产品列表 */ /** 获取产品列表 */
async function getList() { async function getList() {
loading.value = true; loading.value = true;
@ -113,14 +133,14 @@ onMounted(() => {
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-[#40a9ff] to-[#1890ff] text-white" class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-[#40a9ff] to-[#1890ff] text-white"
> >
<img <img
v-if="isHttpUrl(item.icon)" v-if="isImageIcon(item.icon)"
:src="item.icon" :src="getProductIcon(item.icon)"
alt="" alt=""
class="size-6 object-contain" class="size-6 object-contain"
/> />
<IconifyIcon <IconifyIcon
v-else v-else
:icon="item.icon || 'lucide:box'" :icon="item.icon"
class="text-xl" class="text-xl"
/> />
</div> </div>
@ -174,16 +194,10 @@ onMounted(() => {
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]" 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 <ElImage
v-if="item.picUrl" :src="getProductPic(item.picUrl)"
:src="item.picUrl" :preview-src-list="[getProductPic(item.picUrl)]"
:preview-src-list="[item.picUrl]"
class="size-full rounded object-cover" class="size-full rounded object-cover"
/> />
<IconifyIcon
v-else
icon="lucide:image"
class="text-2xl opacity-50"
/>
</div> </div>
</div> </div>
<!-- 按钮组 --> <!-- 按钮组 -->
@ -216,6 +230,9 @@ onMounted(() => {
物模型 物模型
</ElButton> </ElButton>
<template v-if="hasAccessByCodes(['iot:product:delete'])"> <template v-if="hasAccessByCodes(['iot:product:delete'])">
<div
class="h-5 w-px self-center bg-[#dcdfe6] dark:bg-[#3a3a3a]"
></div>
<ElTooltip <ElTooltip
v-if="item.status === ProductStatusEnum.PUBLISHED" v-if="item.status === ProductStatusEnum.PUBLISHED"
content="已发布的产品不能删除" content="已发布的产品不能删除"

View File

@ -17,7 +17,7 @@ const activeTabName = ref('rule');
<ElTabPane name="rule" label="规则"> <ElTabPane name="rule" label="规则">
<DataRuleList /> <DataRuleList />
</ElTabPane> </ElTabPane>
<ElTabPane name="sink" label="目的"> <ElTabPane name="sink" label="目的" lazy>
<DataSinkList /> <DataSinkList />
</ElTabPane> </ElTabPane>
</ElTabs> </ElTabs>

View File

@ -216,6 +216,7 @@ defineExpose({ validate, getData, setData });
v-model="formData[rowIndex].productId" v-model="formData[rowIndex].productId"
placeholder="请选择产品" placeholder="请选择产品"
filterable filterable
clearable
class="w-full" class="w-full"
@change="() => handleProductChange(rowIndex)" @change="() => handleProductChange(rowIndex)"
> >
@ -232,6 +233,7 @@ defineExpose({ validate, getData, setData });
v-model="formData[rowIndex].deviceId" v-model="formData[rowIndex].deviceId"
placeholder="请选择设备" placeholder="请选择设备"
filterable filterable
clearable
class="w-full" class="w-full"
> >
<ElOption label="全部设备" :value="0" /> <ElOption label="全部设备" :value="0" />
@ -248,6 +250,7 @@ defineExpose({ validate, getData, setData });
v-model="formData[rowIndex].method" v-model="formData[rowIndex].method"
placeholder="请选择消息" placeholder="请选择消息"
filterable filterable
clearable
class="w-full" class="w-full"
@change="() => handleMethodChange(rowIndex)" @change="() => handleMethodChange(rowIndex)"
> >
@ -265,6 +268,7 @@ defineExpose({ validate, getData, setData });
v-model="formData[rowIndex].identifier" v-model="formData[rowIndex].identifier"
placeholder="请选择标识符" placeholder="请选择标识符"
filterable filterable
clearable
:loading="formData[rowIndex].identifierLoading" :loading="formData[rowIndex].identifierLoading"
class="w-full" class="w-full"
> >

View File

@ -1,16 +1,10 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, onMounted } from 'vue'; import { onMounted } from 'vue';
import { isEmpty } from '@vben/utils'; import { isEmpty } from '@vben/utils';
import { useVModel } from '@vueuse/core'; import { useVModel } from '@vueuse/core';
import { import { ElFormItem, ElInput, ElInputNumber } from 'element-plus';
ElFormItem,
ElInput,
ElInputNumber,
ElOption,
ElSelect,
} from 'element-plus';
import { IotDataSinkTypeEnum } from '#/api/iot/rule/data/sink'; import { IotDataSinkTypeEnum } from '#/api/iot/rule/data/sink';
@ -18,23 +12,16 @@ const props = defineProps<{ modelValue: any }>();
const emit = defineEmits(['update:modelValue']); const emit = defineEmits(['update:modelValue']);
const config = useVModel(props, 'modelValue', emit); const config = useVModel(props, 'modelValue', emit);
const REDIS_DATA_STRUCTURE_OPTIONS = [ /** 移除当前 Redis Stream API 类型未声明的旧扩展字段 */
{ label: 'Stream', value: 1 }, function removeUnsupportedFields() {
{ label: 'Hash', value: 2 }, delete config.value.dataStructure;
{ label: 'List', value: 3 }, delete config.value.hashField;
{ label: 'Set', value: 4 }, delete config.value.scoreField;
{ label: 'ZSet', value: 5 }, }
{ label: 'String', value: 6 },
]; // Redis IotRedisDataStructureEnum
const isHash = computed(() => Number(config.value?.dataStructure) === 2);
const isZSet = computed(() => Number(config.value?.dataStructure) === 5);
onMounted(() => { onMounted(() => {
if (!isEmpty(config.value)) { if (!isEmpty(config.value)) {
if (config.value.dataStructure == null) { removeUnsupportedFields();
config.value.dataStructure = 1;
}
return; return;
} }
config.value = { config.value = {
@ -44,7 +31,6 @@ onMounted(() => {
password: '', password: '',
database: 0, database: 0,
topic: '', topic: '',
dataStructure: 1,
}; };
}); });
</script> </script>
@ -122,44 +108,4 @@ onMounted(() => {
> >
<ElInput v-model="config.topic" placeholder="请输入主题" /> <ElInput v-model="config.topic" placeholder="请输入主题" />
</ElFormItem> </ElFormItem>
<ElFormItem
prop="config.dataStructure"
:rules="[
{ required: true, message: 'Redis 数据结构不能为空', trigger: 'change' },
]"
label="数据结构"
>
<ElSelect
v-model="config.dataStructure"
placeholder="请选择 Redis 数据结构"
class="w-full"
>
<ElOption
v-for="item in REDIS_DATA_STRUCTURE_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
v-if="isHash"
prop="config.hashField"
label="Hash 字段"
>
<ElInput
v-model="config.hashField"
placeholder="留空使用 deviceId 作为 Hash 字段"
/>
</ElFormItem>
<ElFormItem
v-if="isZSet"
prop="config.scoreField"
label="Score 字段"
>
<ElInput
v-model="config.scoreField"
placeholder="留空使用当前时间戳作为 Score"
/>
</ElFormItem>
</template> </template>

View File

@ -42,7 +42,6 @@ const emit = defineEmits<{
(e: 'change', value: { config: any; type: string }): void; (e: 'change', value: { config: any; type: string }): void;
}>(); }>();
// TODO
/** 属性选择器内部使用的统一数据结构 */ /** 属性选择器内部使用的统一数据结构 */
interface PropertySelectorItem { interface PropertySelectorItem {
identifier: string; identifier: string;
@ -65,89 +64,67 @@ interface PropertySelectorItem {
const localValue = useVModel(props, 'modelValue', emit); const localValue = useVModel(props, 'modelValue', emit);
const loading = ref(false); // const loading = ref(false);
const propertyList = ref<PropertySelectorItem[]>([]); // const propertyList = ref<PropertySelectorItem[]>([]);
const thingModelTSL = ref<null | ThingModelApi.ThingModelTSL>(null); // TSL
// /** 触发类型 → 物模型类型 + 分组标签 */
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.TIMER]: {
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 propertyGroups = computed(() => {
const groups: { label: string; options: any[] }[] = []; const config = TRIGGER_TYPE_TO_GROUP[props.triggerType];
if (!config) return [];
if ( const options = propertyList.value.filter(
props.triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_PROPERTY_POST || (item) => item.type === config.modelType,
props.triggerType === IotRuleSceneTriggerTypeEnum.TIMER
) {
groups.push({
label: THING_MODEL_GROUP_LABELS.PROPERTY,
options: propertyList.value.filter(
(p) => p.type === IoTThingModelTypeEnum.PROPERTY,
),
});
}
if (props.triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_EVENT_POST) {
groups.push({
label: THING_MODEL_GROUP_LABELS.EVENT,
options: propertyList.value.filter(
(p) => p.type === IoTThingModelTypeEnum.EVENT,
),
});
}
if (props.triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_SERVICE_INVOKE) {
groups.push({
label: THING_MODEL_GROUP_LABELS.SERVICE,
options: propertyList.value.filter(
(p) => p.type === IoTThingModelTypeEnum.SERVICE,
),
});
}
return groups.filter((group) => group.options.length > 0);
});
//
const selectedProperty = computed(() => {
return propertyList.value.find(
(property) => property.identifier === localValue.value,
); );
return options.length > 0 ? [{ label: config.label, options }] : [];
}); });
/** /** 当前选中的属性 */
* 处理选择变化事件 const selectedProperty = computed(() =>
* @param value 选中的属性标识符 propertyList.value.find(
*/ (property) => property.identifier === localValue.value,
),
);
/** 处理选择变化事件 */
function handleChange(value: any) { function handleChange(value: any) {
const property = propertyList.value.find((item) => item.identifier === value); const property = propertyList.value.find((item) => item.identifier === value);
if (property) { if (property) {
emit('change', { emit('change', { type: property.dataType, config: property });
type: property.dataType,
config: property,
});
} }
} }
/** /** 获取物模型 TSL 数据 */
* 获取物模型 TSL 数据 async function loadThingModelTSL() {
*/
async function fetchThingModelTSL() {
if (!props.productId) { if (!props.productId) {
thingModelTSL.value = null;
propertyList.value = []; propertyList.value = [];
return; return;
} }
loading.value = true; loading.value = true;
try { try {
const tslData = await getThingModelTSLByProductId(props.productId); const tsl = await getThingModelTSLByProductId(props.productId);
propertyList.value = parseThingModelData(tsl);
if (tslData) {
thingModelTSL.value = tslData;
parseThingModelData();
} else {
console.error('获取物模型 TSL 失败 :返回数据为空');
propertyList.value = [];
}
} catch (error) { } catch (error) {
console.error('获取物模型 TSL 失败 ', error); console.error('获取物模型 TSL 失败 ', error);
propertyList.value = []; propertyList.value = [];
@ -156,104 +133,62 @@ async function fetchThingModelTSL() {
} }
} }
/** 解析物模型 TSL 数据 */ /** 把 TSL 树展平为 PropertySelectorItem[] */
function parseThingModelData() { function parseThingModelData(
const tsl = thingModelTSL.value; tsl?: null | ThingModelApi.ThingModelTSL,
const properties: PropertySelectorItem[] = []; ): PropertySelectorItem[] {
if (!tsl) return [];
if (!tsl) { const properties = (tsl.properties ?? []).map<PropertySelectorItem>(
propertyList.value = properties; (prop) => ({
return; identifier: prop.identifier!,
} name: prop.name!,
// description: prop.description,
if (tsl.properties && Array.isArray(tsl.properties)) { dataType: prop.dataType!,
tsl.properties.forEach((prop) => { type: IoTThingModelTypeEnum.PROPERTY,
properties.push({ accessMode: prop.accessMode,
identifier: prop.identifier!, required: prop.required,
name: prop.name!, unit: prop.dataSpecs?.unit,
description: prop.description, range: getPropertyRange(prop),
dataType: prop.dataType!, property: prop,
type: IoTThingModelTypeEnum.PROPERTY, }),
accessMode: prop.accessMode, );
required: prop.required, const events = (tsl.events ?? []).map<PropertySelectorItem>((event) => ({
unit: getPropertyUnit(prop), identifier: event.identifier!,
range: getPropertyRange(prop), name: event.name!,
property: prop, description: event.description,
}); dataType: 'struct',
}); type: IoTThingModelTypeEnum.EVENT,
} eventType: event.type,
required: event.required,
// outputParams: event.outputParams,
if (tsl.events && Array.isArray(tsl.events)) { event,
tsl.events.forEach((event) => { }));
properties.push({ const services = (tsl.services ?? []).map<PropertySelectorItem>(
identifier: event.identifier!, (service) => ({
name: event.name!, identifier: service.identifier!,
description: event.description, name: service.name!,
dataType: 'struct', description: service.description,
type: IoTThingModelTypeEnum.EVENT, dataType: 'struct',
eventType: event.type, type: IoTThingModelTypeEnum.SERVICE,
required: event.required, callType: service.callType,
outputParams: event.outputParams, required: service.required,
event, inputParams: service.inputParams,
}); outputParams: service.outputParams,
}); service,
} }),
);
// return [...properties, ...events, ...services];
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;
} }
/** /** 获取属性取值范围:数值型给 min~max枚举 / 布尔给选项列表 */
* 获取属性单位 function getPropertyRange(
* @param property 属性对象 property: ThingModelApi.Property,
* @returns 属性单位 ): string | undefined {
*/ const specs = property.dataSpecs;
function getPropertyUnit(property: any) { if (specs && specs.min !== undefined && specs.max !== undefined) {
if (!property) return undefined; return `${specs.min}~${specs.max}`;
//
if (property.dataSpecs && property.dataSpecs.unit) {
return property.dataSpecs.unit;
} }
if (property.dataSpecsList?.length) {
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)) {
return property.dataSpecsList return property.dataSpecsList
.map((item: any) => `${item.name}(${item.value})`) .map((item: any) => `${item.name}(${item.value})`)
.join(', '); .join(', ');
@ -266,7 +201,7 @@ function getPropertyRange(property: any) {
watch( watch(
() => props.productId, () => props.productId,
() => { () => {
fetchThingModelTSL(); loadThingModelTSL();
}, },
{ immediate: true }, { immediate: true },
); );

View File

@ -1,8 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { ThingModelApi } from '#/api/iot/thingmodel'; import type { ThingModelApi } from '#/api/iot/thingmodel';
import { computed } from 'vue';
import { import {
getEventTypeLabel, getEventTypeLabel,
getThingModelServiceCallTypeLabel, getThingModelServiceCallTypeLabel,
@ -10,9 +8,7 @@ import {
IoTThingModelTypeEnum, IoTThingModelTypeEnum,
} from '@vben/constants'; } from '@vben/constants';
import { ElTooltip } from 'element-plus'; defineProps<{ data: ThingModelApi.ThingModel }>();
const props = defineProps<{ data: ThingModelApi.ThingModel }>();
const NUMBER_TYPES = new Set<string>([ const NUMBER_TYPES = new Set<string>([
IoTDataSpecsDataTypeEnum.DOUBLE, IoTDataSpecsDataTypeEnum.DOUBLE,
IoTDataSpecsDataTypeEnum.FLOAT, IoTDataSpecsDataTypeEnum.FLOAT,
@ -27,26 +23,6 @@ const LIST_TYPES = new Set<string>([
IoTDataSpecsDataTypeEnum.BOOL, IoTDataSpecsDataTypeEnum.BOOL,
IoTDataSpecsDataTypeEnum.ENUM, IoTDataSpecsDataTypeEnum.ENUM,
]); ]);
const formattedDataSpecsList = computed(() => {
if (!props.data.property?.dataSpecsList?.length) {
return '';
}
return props.data.property.dataSpecsList
.map((item) => `${item.name}-${item.value}`)
.join('、');
});
const shortText = computed(() => {
const list = props.data.property?.dataSpecsList;
if (!list?.length) {
return '-';
}
const first = list[0];
return list.length > 1
? `${first.name}-${first.value}${list.length}`
: `${first.name}-${first.value}`;
});
</script> </script>
<template> <template>
@ -62,17 +38,19 @@ const shortText = computed(() => {
</div> </div>
<div v-if="PLACEHOLDER_TYPES.has(data.property?.dataType as any)">-</div> <div v-if="PLACEHOLDER_TYPES.has(data.property?.dataType as any)">-</div>
<div v-if="LIST_TYPES.has(data.property?.dataType as any)"> <div v-if="LIST_TYPES.has(data.property?.dataType as any)">
<ElTooltip :content="formattedDataSpecsList" placement="top-start"> <div>
<span {{
class="cursor-help border-b border-dashed border-gray-300 hover:border-blue-500 hover:text-blue-500" data.property?.dataType === IoTDataSpecsDataTypeEnum.BOOL
> ? '布尔值'
{{ : '枚举值'
data.property?.dataType === IoTDataSpecsDataTypeEnum.BOOL }}
? '布尔值' </div>
: '枚举值' <div
}}{{ shortText }} v-for="item in data.property?.dataSpecsList || []"
</span> :key="String(item.value)"
</ElTooltip> >
{{ item.name }}-{{ item.value }}
</div>
</div> </div>
</template> </template>
<!-- 服务 --> <!-- 服务 -->

View File

@ -227,7 +227,6 @@ function removeDataSpecs(val: any) {
> >
<ElInput <ElInput
v-model="formData.identifier" v-model="formData.identifier"
:disabled="formData.id != null"
placeholder="请输入标识符" placeholder="请输入标识符"
/> />
</ElFormItem> </ElFormItem>

View File

@ -106,7 +106,7 @@ onMounted(async () => {
v-for="item in filteredList" v-for="item in filteredList"
:key="item.id" :key="item.id"
:label="item.name" :label="item.name"
:value="item.id" :value="item.id!"
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span>{{ item.name }}</span> <span>{{ item.name }}</span>

View File

@ -133,10 +133,70 @@ export const MesAutoCodeRuleCode = {
MD_VENDOR_CODE: 'MD_VENDOR_CODE', MD_VENDOR_CODE: 'MD_VENDOR_CODE',
MD_WORKSTATION_CODE: 'MD_WORKSTATION_CODE', MD_WORKSTATION_CODE: 'MD_WORKSTATION_CODE',
MD_WORKSHOP_CODE: 'MD_WORKSHOP_CODE', MD_WORKSHOP_CODE: 'MD_WORKSHOP_CODE',
PRO_CARD_CODE: 'PRO_CARD_CODE',
PRO_FEEDBACK_CODE: 'PRO_FEEDBACK_CODE',
PRO_PROCESS_CODE: 'PRO_PROCESS_CODE',
PRO_ROUTE_CODE: 'PRO_ROUTE_CODE',
PRO_TASK_CODE: 'PRO_TASK_CODE',
PRO_WORK_ORDER_CODE: 'PRO_WORK_ORDER_CODE',
TM_TOOL_TYPE_CODE: 'TM_TOOL_TYPE_CODE', TM_TOOL_TYPE_CODE: 'TM_TOOL_TYPE_CODE',
TM_TOOL_CODE: 'TM_TOOL_CODE', TM_TOOL_CODE: 'TM_TOOL_CODE',
} as const; } as const;
/** MES 生产工单状态枚举 */
export const MesProWorkOrderStatusEnum = {
PREPARE: MesOrderStatusConstants.DRAFT,
CONFIRMED: MesOrderStatusConstants.CONFIRMED,
APPROVING: MesOrderStatusConstants.APPROVING,
PRODUCING: MesOrderStatusConstants.APPROVED,
FINISHED: MesOrderStatusConstants.FINISHED,
CANCELLED: MesOrderStatusConstants.CANCELLED,
} as const;
/** MES 生产任务状态枚举 */
export const MesProTaskStatusEnum = {
PREPARE: MesOrderStatusConstants.DRAFT,
CONFIRMED: MesOrderStatusConstants.CONFIRMED,
APPROVING: MesOrderStatusConstants.APPROVING,
PRODUCING: MesOrderStatusConstants.APPROVED,
FINISHED: MesOrderStatusConstants.FINISHED,
CANCELLED: MesOrderStatusConstants.CANCELLED,
} as const;
/** MES 生产报工状态枚举 */
export const MesProFeedbackStatusEnum = {
PREPARE: MesOrderStatusConstants.DRAFT,
CONFIRMED: MesOrderStatusConstants.CONFIRMED,
APPROVING: MesOrderStatusConstants.APPROVING,
FINISHED: MesOrderStatusConstants.FINISHED,
CANCELLED: MesOrderStatusConstants.CANCELLED,
} as const;
/** MES 流转卡状态枚举 */
export const MesProCardStatusEnum = {
PREPARE: MesOrderStatusConstants.DRAFT,
ISSUED: MesOrderStatusConstants.CONFIRMED,
PRODUCING: MesOrderStatusConstants.APPROVED,
FINISHED: MesOrderStatusConstants.FINISHED,
CANCELLED: MesOrderStatusConstants.CANCELLED,
} as const;
/** MES 安灯类型枚举 */
export const MesProAndonTypeEnum = {
QUALITY: 1,
EQUIPMENT: 2,
MATERIAL: 3,
PROCESS: 4,
OTHER: 9,
} as const;
/** MES 安灯状态枚举 */
export const MesProAndonStatusEnum = {
TRIGGERED: 1,
HANDLING: 2,
CLOSED: 3,
} as const;
/** MES 编码规则分段类型枚举 */ /** MES 编码规则分段类型枚举 */
export const MesAutoCodePartTypeEnum = { export const MesAutoCodePartTypeEnum = {
INPUT: 1, INPUT: 1,

View File

@ -4,6 +4,7 @@ import {
getFirstNonNullOrUndefined, getFirstNonNullOrUndefined,
isBoolean, isBoolean,
isEmpty, isEmpty,
isEmptyVal,
isHttpUrl, isHttpUrl,
isObject, isObject,
isUndefined, isUndefined,
@ -85,6 +86,21 @@ describe('isEmpty', () => {
}); });
}); });
describe('isEmptyVal', () => {
it('should return true for empty value', () => {
expect(isEmptyVal('')).toBe(true);
expect(isEmptyVal(null)).toBe(true);
expect(isEmptyVal()).toBe(true);
});
it('should return false for valid falsy and collection values', () => {
expect(isEmptyVal(0)).toBe(false);
expect(isEmptyVal(false)).toBe(false);
expect(isEmptyVal([])).toBe(false);
expect(isEmptyVal({})).toBe(false);
});
});
describe('isWindow', () => { describe('isWindow', () => {
it('should return true for the window object', () => { it('should return true for the window object', () => {
expect(isWindow(window)).toBe(true); expect(isWindow(window)).toBe(true);

View File

@ -53,6 +53,21 @@ function isEmpty<T = unknown>(value?: T): boolean {
return false; return false;
} }
/**
*
*
*
* - null
* - undefined
* -
*
* @param value
* @returns true false
*/
function isEmptyVal(value?: unknown): value is '' | null | undefined {
return value === '' || value === null || value === undefined;
}
/** /**
* HTTPHTTPS URL * HTTPHTTPS URL
* *
@ -152,6 +167,7 @@ export {
getFirstNonNullOrUndefined, getFirstNonNullOrUndefined,
isBoolean, isBoolean,
isEmpty, isEmpty,
isEmptyVal,
isFunction, isFunction,
isHttpUrl, isHttpUrl,
isMacOs, isMacOs,

View File

@ -200,6 +200,19 @@ const MES_DICT = {
MES_DV_REPAIR_RESULT: 'mes_dv_repair_result', // MES 维修结果 MES_DV_REPAIR_RESULT: 'mes_dv_repair_result', // MES 维修结果
MES_DV_CHECK_RECORD_STATUS: 'mes_dv_check_record_status', // MES 点检记录状态 MES_DV_CHECK_RECORD_STATUS: 'mes_dv_check_record_status', // MES 点检记录状态
MES_DV_CHECK_RESULT: 'mes_dv_check_result', // MES 点检结果 MES_DV_CHECK_RESULT: 'mes_dv_check_result', // MES 点检结果
MES_PRO_LINK_TYPE: 'mes_pro_link_type', // MES 工序关系类型
MES_PRO_WORK_ORDER_STATUS: 'mes_pro_work_order_status', // MES 生产工单状态
MES_PRO_WORK_ORDER_TYPE: 'mes_pro_work_order_type', // MES 工单类型
MES_PRO_WORK_ORDER_SOURCE_TYPE: 'mes_pro_work_order_source_type', // MES 工单来源类型
MES_PRO_TASK_STATUS: 'mes_pro_task_status', // MES 生产任务状态
MES_PRO_FEEDBACK_STATUS: 'mes_pro_feedback_status', // MES 生产报工状态
MES_PRO_FEEDBACK_TYPE: 'mes_pro_feedback_type', // MES 生产报工类型
MES_PRO_FEEDBACK_CHANNEL: 'mes_pro_feedback_channel', // MES 生产报工途径
MES_PRO_ANDON_STATUS: 'mes_pro_andon_status', // MES 安灯处置状态
MES_PRO_ANDON_LEVEL: 'mes_pro_andon_level', // MES 安灯级别
MES_PRO_WORK_RECORD_TYPE: 'mes_pro_work_record_type', // MES 上下工状态类型
MES_TIME_UNIT_TYPE: 'mes_time_unit_type', // MES 时间单位
MES_ORDER_STATUS: 'mes_order_status', // MES 单据状态
MES_WM_BARCODE_BIZ_TYPE: 'mes_wm_barcode_biz_type', // MES 条码业务类型 MES_WM_BARCODE_BIZ_TYPE: 'mes_wm_barcode_biz_type', // MES 条码业务类型
MES_WM_BARCODE_FORMAT: 'mes_wm_barcode_format', // MES 条码格式 MES_WM_BARCODE_FORMAT: 'mes_wm_barcode_format', // MES 条码格式
MES_WM_PRODUCT_SALES_STATUS: 'mes_wm_product_sales_status', // MES 销售出库单状态 MES_WM_PRODUCT_SALES_STATUS: 'mes_wm_product_sales_status', // MES 销售出库单状态