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 {
/** MES 生产工序 */
export interface Process {
id: number;
id?: number;
code?: string;
name?: string;
attention?: string;
@ -36,3 +36,23 @@ export function getProcess(id: number) {
`/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() {
loading.value = true;
@ -438,7 +428,7 @@ defineExpose({ open }); // 提供 open 方法,用于打开弹窗
<Table
:columns="tableColumns"
:data-source="list"
:pagination="paginationConfig"
:pagination="false"
:scroll="{ y: 500 }"
row-key="_rowKey"
size="small"

View File

@ -75,11 +75,6 @@ const [Modal, modalApi] = useVbenModal({
/** 上传前 */
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);
return false;
}
@ -97,18 +92,18 @@ async function handleDownload() {
<template #file>
<div class="w-full">
<Upload
:before-upload="beforeUpload"
:max-count="1"
accept=".xls,.xlsx"
:before-upload="beforeUpload"
>
<Button type="primary"> 选择 Excel 文件</Button>
<Button type="primary"> 选择 Excel 文件 </Button>
</Upload>
</div>
</template>
</Form>
<template #prepend-footer>
<div class="flex flex-auto items-center">
<Button @click="handleDownload"> </Button>
<Button @click="handleDownload"> </Button>
</div>
</template>
</Modal>

View File

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

View File

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

View File

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

View File

@ -79,7 +79,7 @@ async function handleViewModeChange(mode: 'card' | 'list') {
/** 导出表格 */
async function handleExport() {
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';
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';
interface Props {
@ -55,6 +57,24 @@ function getCategoryName(item: any) {
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() {
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"
>
<img
v-if="isHttpUrl(item.icon)"
:src="item.icon"
v-if="isImageIcon(item.icon)"
:src="getProductIcon(item.icon)"
alt=""
class="size-6 object-contain"
/>
<IconifyIcon
v-else
:icon="item.icon || 'lucide:box'"
:icon="item.icon"
class="text-xl"
/>
</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]"
>
<Image
v-if="item.picUrl"
:src="item.picUrl"
:src="getProductPic(item.picUrl)"
:preview="true"
class="size-full rounded object-cover"
/>
<IconifyIcon
v-else
icon="lucide:image"
class="text-2xl opacity-50"
/>
</div>
</div>
<!-- 按钮组 -->

View File

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

View File

@ -1,10 +1,10 @@
<script lang="ts" setup>
import { computed, onMounted } from 'vue';
import { onMounted } from 'vue';
import { isEmpty } from '@vben/utils';
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';
@ -12,23 +12,16 @@ const props = defineProps<{ modelValue: any }>();
const emit = defineEmits(['update:modelValue']);
const config = useVModel(props, 'modelValue', emit);
const REDIS_DATA_STRUCTURE_OPTIONS = [
{ label: 'Stream', value: 1 },
{ label: 'Hash', value: 2 },
{ label: 'List', value: 3 },
{ label: 'Set', value: 4 },
{ 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);
/** 移除当前 Redis Stream API 类型未声明的旧扩展字段 */
function removeUnsupportedFields() {
delete config.value.dataStructure;
delete config.value.hashField;
delete config.value.scoreField;
}
onMounted(() => {
if (!isEmpty(config.value)) {
if (config.value.dataStructure == null) {
config.value.dataStructure = 1;
}
removeUnsupportedFields();
return;
}
config.value = {
@ -38,7 +31,6 @@ onMounted(() => {
password: '',
database: 0,
topic: '',
dataStructure: 1,
};
});
</script>
@ -111,37 +103,4 @@ onMounted(() => {
>
<Input v-model:value="config.topic" placeholder="请输入主题" />
</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>

View File

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

View File

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

View File

@ -133,10 +133,70 @@ export const MesAutoCodeRuleCode = {
MD_VENDOR_CODE: 'MD_VENDOR_CODE',
MD_WORKSTATION_CODE: 'MD_WORKSTATION_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_CODE: 'TM_TOOL_CODE',
} 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 编码规则分段类型枚举 */
export const MesAutoCodePartTypeEnum = {
INPUT: 1,

View File

@ -5,7 +5,7 @@ import { requestClient } from '#/api/request';
export namespace MesProProcessApi {
/** MES 生产工序 */
export interface Process {
id: number;
id?: number;
code?: string;
name?: string;
attention?: string;
@ -36,3 +36,23 @@ export function getProcess(id: number) {
`/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 rangePickerProps = getRangePickerDefaultProps();
const timeRangeOptions = [
{
label: '昨天',
value: () => [
dayjs().subtract(1, 'day').startOf('day'),
dayjs().subtract(1, 'day').endOf('day'),
],
label: rangePickerProps.shortcuts[1]!.text,
value: () => rangePickerProps.shortcuts[1]!.value() as [Dayjs, Dayjs],
},
{
label: '最近 7 天',
label: rangePickerProps.shortcuts[2]!.text,
value: () => [
dayjs().subtract(7, 'day').startOf('day'),
dayjs().subtract(1, 'day').endOf('day'),
],
},
{
label: '最近 30 天',
label: rangePickerProps.shortcuts[3]!.text,
value: () => [
dayjs().subtract(30, 'day').startOf('day'),
dayjs().subtract(1, 'day').endOf('day'),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -79,7 +79,7 @@ async function handleViewModeChange(mode: 'card' | 'list') {
/** 导出表格 */
async function handleExport() {
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';
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';
interface Props {
@ -55,6 +57,24 @@ function getCategoryName(item: any) {
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() {
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"
>
<img
v-if="isHttpUrl(item.icon)"
:src="item.icon"
v-if="isImageIcon(item.icon)"
:src="getProductIcon(item.icon)"
alt=""
class="size-6 object-contain"
/>
<IconifyIcon
v-else
:icon="item.icon || 'lucide:box'"
:icon="item.icon"
class="text-xl"
/>
</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]"
>
<ElImage
v-if="item.picUrl"
:src="item.picUrl"
:preview-src-list="[item.picUrl]"
:src="getProductPic(item.picUrl)"
:preview-src-list="[getProductPic(item.picUrl)]"
class="size-full rounded object-cover"
/>
<IconifyIcon
v-else
icon="lucide:image"
class="text-2xl opacity-50"
/>
</div>
</div>
<!-- 按钮组 -->
@ -216,6 +230,9 @@ onMounted(() => {
物模型
</ElButton>
<template v-if="hasAccessByCodes(['iot:product:delete'])">
<div
class="h-5 w-px self-center bg-[#dcdfe6] dark:bg-[#3a3a3a]"
></div>
<ElTooltip
v-if="item.status === ProductStatusEnum.PUBLISHED"
content="已发布的产品不能删除"

View File

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

View File

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

View File

@ -1,16 +1,10 @@
<script lang="ts" setup>
import { computed, onMounted } from 'vue';
import { onMounted } from 'vue';
import { isEmpty } from '@vben/utils';
import { useVModel } from '@vueuse/core';
import {
ElFormItem,
ElInput,
ElInputNumber,
ElOption,
ElSelect,
} from 'element-plus';
import { ElFormItem, ElInput, ElInputNumber } from 'element-plus';
import { IotDataSinkTypeEnum } from '#/api/iot/rule/data/sink';
@ -18,23 +12,16 @@ const props = defineProps<{ modelValue: any }>();
const emit = defineEmits(['update:modelValue']);
const config = useVModel(props, 'modelValue', emit);
const REDIS_DATA_STRUCTURE_OPTIONS = [
{ label: 'Stream', value: 1 },
{ label: 'Hash', value: 2 },
{ label: 'List', value: 3 },
{ label: 'Set', value: 4 },
{ 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);
/** 移除当前 Redis Stream API 类型未声明的旧扩展字段 */
function removeUnsupportedFields() {
delete config.value.dataStructure;
delete config.value.hashField;
delete config.value.scoreField;
}
onMounted(() => {
if (!isEmpty(config.value)) {
if (config.value.dataStructure == null) {
config.value.dataStructure = 1;
}
removeUnsupportedFields();
return;
}
config.value = {
@ -44,7 +31,6 @@ onMounted(() => {
password: '',
database: 0,
topic: '',
dataStructure: 1,
};
});
</script>
@ -122,44 +108,4 @@ onMounted(() => {
>
<ElInput v-model="config.topic" placeholder="请输入主题" />
</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>

View File

@ -42,7 +42,6 @@ const emit = defineEmits<{
(e: 'change', value: { config: any; type: string }): void;
}>();
// TODO
/** 属性选择器内部使用的统一数据结构 */
interface PropertySelectorItem {
identifier: string;
@ -65,89 +64,67 @@ interface PropertySelectorItem {
const localValue = useVModel(props, 'modelValue', emit);
const loading = ref(false); //
const propertyList = ref<PropertySelectorItem[]>([]); //
const thingModelTSL = ref<null | ThingModelApi.ThingModelTSL>(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.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 groups: { label: string; options: any[] }[] = [];
if (
props.triggerType === IotRuleSceneTriggerTypeEnum.DEVICE_PROPERTY_POST ||
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,
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 }] : [];
});
/**
* 处理选择变化事件
* @param value 选中的属性标识符
*/
/** 当前选中的属性 */
const selectedProperty = computed(() =>
propertyList.value.find(
(property) => property.identifier === localValue.value,
),
);
/** 处理选择变化事件 */
function handleChange(value: any) {
const property = propertyList.value.find((item) => item.identifier === value);
if (property) {
emit('change', {
type: property.dataType,
config: property,
});
emit('change', { type: property.dataType, config: property });
}
}
/**
* 获取物模型 TSL 数据
*/
async function fetchThingModelTSL() {
/** 获取物模型 TSL 数据 */
async function loadThingModelTSL() {
if (!props.productId) {
thingModelTSL.value = null;
propertyList.value = [];
return;
}
loading.value = true;
try {
const tslData = await getThingModelTSLByProductId(props.productId);
if (tslData) {
thingModelTSL.value = tslData;
parseThingModelData();
} else {
console.error('获取物模型 TSL 失败 :返回数据为空');
propertyList.value = [];
}
const tsl = await getThingModelTSLByProductId(props.productId);
propertyList.value = parseThingModelData(tsl);
} catch (error) {
console.error('获取物模型 TSL 失败 ', error);
propertyList.value = [];
@ -156,104 +133,62 @@ async function fetchThingModelTSL() {
}
}
/** 解析物模型 TSL 数据 */
function parseThingModelData() {
const tsl = thingModelTSL.value;
const properties: PropertySelectorItem[] = [];
if (!tsl) {
propertyList.value = properties;
return;
}
//
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,
});
});
}
//
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(', ');
@ -266,7 +201,7 @@ function getPropertyRange(property: any) {
watch(
() => props.productId,
() => {
fetchThingModelTSL();
loadThingModelTSL();
},
{ immediate: true },
);

View File

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

View File

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

View File

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

View File

@ -133,10 +133,70 @@ export const MesAutoCodeRuleCode = {
MD_VENDOR_CODE: 'MD_VENDOR_CODE',
MD_WORKSTATION_CODE: 'MD_WORKSTATION_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_CODE: 'TM_TOOL_CODE',
} 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 编码规则分段类型枚举 */
export const MesAutoCodePartTypeEnum = {
INPUT: 1,

View File

@ -4,6 +4,7 @@ import {
getFirstNonNullOrUndefined,
isBoolean,
isEmpty,
isEmptyVal,
isHttpUrl,
isObject,
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', () => {
it('should return true for the window object', () => {
expect(isWindow(window)).toBe(true);

View File

@ -53,6 +53,21 @@ function isEmpty<T = unknown>(value?: T): boolean {
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
*
@ -152,6 +167,7 @@ export {
getFirstNonNullOrUndefined,
isBoolean,
isEmpty,
isEmptyVal,
isFunction,
isHttpUrl,
isMacOs,

View File

@ -200,6 +200,19 @@ const MES_DICT = {
MES_DV_REPAIR_RESULT: 'mes_dv_repair_result', // MES 维修结果
MES_DV_CHECK_RECORD_STATUS: 'mes_dv_check_record_status', // 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_FORMAT: 'mes_wm_barcode_format', // MES 条码格式
MES_WM_PRODUCT_SALES_STATUS: 'mes_wm_product_sales_status', // MES 销售出库单状态