!309 feat:【antd】【ele】大量更新同步

Merge pull request !309 from 芋道源码/dev
This commit is contained in:
芋道源码
2025-12-26 01:03:05 +00:00
committed by Gitee
405 changed files with 29347 additions and 10623 deletions

View File

@@ -7,13 +7,29 @@ import { requestClient } from '#/api/request';
export namespace BpmTaskApi {
/** 流程任务 */
export interface Task {
id: number; // 编号
name: string; // 监听器名字
type: string; // 监听器类型
status: number; // 监听器状态
event: string; // 监听事件
valueType: string; // 监听器值类型
processInstance?: BpmProcessInstanceApi.ProcessInstance; // 流程实例
id: string; // 编号
name: string; // 任务名字
status: number; // 任务状态
createTime: number; // 创建时间
endTime: number; // 结束时间
durationInMillis: number; // 持续时间
reason: string; // 审批理由
ownerUser: any; // 负责人
assigneeUser: any; // 处理人
taskDefinitionKey: string; // 任务定义的标识
processInstanceId: string; // 流程实例id
processInstance: BpmProcessInstanceApi.ProcessInstance; // 流程实例
parentTaskId: any; // 父任务id
children: any; // 子任务
formId: any; // 表单id
formName: any; // 表单名称
formConf: any; // 表单配置
formFields: any; // 表单字段
formVariables: any; // 表单变量
buttonsSetting: any; // 按钮设置
signEnable: any; // 签名设置
reasonRequire: any; // 原因设置
nodeType: any; // 节点类型
}
}

View File

@@ -17,6 +17,7 @@ export namespace InfraFileConfigApi {
accessSecret?: string;
pathStyle?: boolean;
enablePublicAccess?: boolean;
region?: string;
domain: string;
}

View File

@@ -77,14 +77,6 @@ export namespace IotDeviceApi {
}
}
/** IoT 设备状态枚举 */
// TODO @haohaopackages/constants/src/biz-iot-enum.ts 枚举;
export enum DeviceStateEnum {
INACTIVE = 0, // 未激活
OFFLINE = 2, // 离线
ONLINE = 1, // 在线
}
/** 查询设备分页 */
export function getDevicePage(params: PageParam) {
return requestClient.get<PageResult<IotDeviceApi.Device>>(
@@ -154,6 +146,14 @@ export function importDeviceTemplate() {
return requestClient.download('/iot/device/get-import-template');
}
/** 导入设备 */
export function importDevice(file: File, updateSupport: boolean) {
return requestClient.upload('/iot/device/import', {
file,
updateSupport,
});
}
/** 获取设备属性最新数据 */
export function getLatestDeviceProperties(params: any) {
return requestClient.get<IotDeviceApi.DevicePropertyDetail[]>(

View File

@@ -27,27 +27,6 @@ export namespace IotProductApi {
}
}
// TODO @haohaopackages/constants/src/biz-iot-enum.ts 枚举;
/** IOT 产品设备类型枚举类 */
export enum DeviceTypeEnum {
DEVICE = 0, // 直连设备
GATEWAY = 2, // 网关设备
GATEWAY_SUB = 1, // 网关子设备
}
/** IOT 产品定位类型枚举类 */
export enum LocationTypeEnum {
IP = 1, // IP 定位
MANUAL = 3, // 手动定位
MODULE = 2, // 设备定位
}
/** IOT 数据格式(编解码器类型)枚举类 */
export enum CodecTypeEnum {
ALINK = 'Alink', // 阿里云 Alink 协议
}
/** 查询产品分页 */
export function getProductPage(params: PageParam) {
return requestClient.get<PageResult<IotProductApi.Product>>(

View File

@@ -1,21 +1,20 @@
import { requestClient } from '#/api/request';
export namespace IotStatisticsApi {
// TODO @haohao需要跟后端对齐必要的 ReqVO、RespVO
/** 统计摘要数据 */
export interface StatisticsSummary {
productCategoryCount: number;
productCount: number;
deviceCount: number;
deviceMessageCount: number;
productCategoryTodayCount: number;
productTodayCount: number;
deviceTodayCount: number;
deviceMessageTodayCount: number;
deviceOnlineCount: number;
deviceOfflineCount: number;
deviceInactiveCount: number;
productCategoryDeviceCounts: Record<string, number>;
export interface StatisticsSummaryRespVO {
productCategoryCount: number; // 品类数量
productCount: number; // 产品数量
deviceCount: number; // 设备数量
deviceMessageCount: number; // 上报数量
productCategoryTodayCount: number; // 今日新增品类数量
productTodayCount: number; // 今日新增产品数量
deviceTodayCount: number; // 今日新增设备数量
deviceMessageTodayCount: number; // 今日新增上报数量
deviceOnlineCount: number; // 在线数量
deviceOfflineCount: number; // 离线数量
deviceInactiveCount: number; // 待激活设备数量
productCategoryDeviceCounts: Record<string, number>; // 按品类统计的设备数量
}
/** 时间戳-数值的键值对类型 */
@@ -30,15 +29,15 @@ export namespace IotStatisticsApi {
downstreamCounts: TimeValueItem[];
}
/** 消息统计数据项(按日期) */
export interface DeviceMessageSummaryByDate {
time: string;
upstreamCount: number;
downstreamCount: number;
/** 设备消息数量统计(按日期) */
export interface DeviceMessageSummaryByDateRespVO {
time: string; // 时间轴
upstreamCount: number; // 上行消息数量
downstreamCount: number; // 下行消息数量
}
/** 消息统计接口参数 */
export interface DeviceMessageReq {
/** 设备消息统计请求 */
export interface DeviceMessageReqVO {
interval: number;
times?: string[];
}
@@ -46,26 +45,17 @@ export namespace IotStatisticsApi {
/** 获取 IoT 统计摘要数据 */
export function getStatisticsSummary() {
return requestClient.get<IotStatisticsApi.StatisticsSummary>(
return requestClient.get<IotStatisticsApi.StatisticsSummaryRespVO>(
'/iot/statistics/get-summary',
);
}
/** 获取设备消息的数据统计(按日期) */
export function getDeviceMessageSummaryByDate(
params: IotStatisticsApi.DeviceMessageReq,
params: IotStatisticsApi.DeviceMessageReqVO,
) {
return requestClient.get<IotStatisticsApi.DeviceMessageSummaryByDate[]>(
return requestClient.get<IotStatisticsApi.DeviceMessageSummaryByDateRespVO[]>(
'/iot/statistics/get-device-message-summary-by-date',
{ params },
);
}
// TODO @haohao貌似这里没用到是不是后面哪里用或者可以删除哈
/** 获取设备消息统计摘要 */
export function getDeviceMessageSummary(statType: number) {
return requestClient.get<IotStatisticsApi.DeviceMessageSummary>(
'/iot/statistics/get-device-message-summary',
{ params: { statType } },
);
}

View File

@@ -21,6 +21,7 @@ export namespace MallCombinationActivityApi {
limitDuration?: number; // 限制时长
combinationPrice?: number; // 拼团价格
products: CombinationProduct[]; // 商品列表
picUrl?: any;
}
/** 拼团活动所需属性 */

View File

@@ -31,6 +31,7 @@ export namespace MallSeckillActivityApi {
totalStock?: number; // 秒杀总库存
seckillPrice?: number; // 秒杀价格
products?: SeckillProduct[]; // 秒杀商品列表
picUrl?: any;
}
}

View File

@@ -14,7 +14,6 @@ export namespace SystemMailTemplateApi {
content: string;
params: string[];
status: number;
remark: string;
createTime: Date;
}

View File

@@ -12,6 +12,7 @@ export namespace SystemSocialClientApi {
clientId: string;
clientSecret: string;
agentId?: string;
publicKey?: string;
status: number;
createTime?: Date;
}

View File

@@ -1,2 +0,0 @@
export { default as DeptSelectModal } from './dept-select-modal.vue';
export { default as UserSelectModal } from './user-select-modal.vue';

View File

@@ -9,24 +9,6 @@ const routes: RouteRecordRaw[] = [
hideInMenu: true,
},
children: [
{
path: 'task',
name: 'BpmTask',
meta: {
title: '审批中心',
icon: 'ant-design:history-outlined',
},
children: [
{
path: 'my',
name: 'BpmTaskMy',
component: () => import('#/views/bpm/processInstance/index.vue'),
meta: {
title: '我的流程',
},
},
],
},
{
path: 'process-instance/detail',
component: () => import('#/views/bpm/processInstance/detail/index.vue'),

View File

@@ -18,8 +18,7 @@ const routes: RouteRecordRaw[] = [
title: '产品详情',
activePath: '/iot/device/product',
},
component: () =>
import('#/views/iot/product/product/modules/detail/index.vue'),
component: () => import('#/views/iot/product/product/detail/index.vue'),
},
{
path: 'device/detail/:id',
@@ -28,8 +27,7 @@ const routes: RouteRecordRaw[] = [
title: '设备详情',
activePath: '/iot/device/device',
},
component: () =>
import('#/views/iot/device/device/modules/detail/index.vue'),
component: () => import('#/views/iot/device/device/detail/index.vue'),
},
{
path: 'ota/firmware/detail/:id',

View File

@@ -11,7 +11,7 @@ export default {
'Append Gateway': '追加网关',
'Append Task': '追加任务',
'Append Intermediate/Boundary Event': '追加中间抛出事件/边界事件',
TextAnnotation: '文本注释',
'Activate the global connect tool': '激活全局连接工具',
'Append {type}': '添加 {type}',
'Add Lane above': '在上面添加道',
@@ -31,10 +31,16 @@ export default {
'Create expanded SubProcess': '创建扩展子过程',
'Create IntermediateThrowEvent/BoundaryEvent': '创建中间抛出事件/边界事件',
'Create Pool/Participant': '创建池/参与者',
'Parallel Multi Instance': '并行多重事件',
'Sequential Multi Instance': '时序多重事件',
'Participant Multiplicity': '参与者多重性',
'Empty pool/participant (removes content)': '清空池/参与者(移除内容)',
'Empty pool/participant': '收缩池/参与者',
'Expanded pool/participant': '展开池/参与者',
'Parallel Multi-Instance': '并行多重事件',
'Sequential Multi-Instance': '时序多重事件',
DataObjectReference: '数据对象参考',
DataStoreReference: '数据存储参考',
'Data object reference': '数据对象引用 ',
'Data store reference': '数据存储引用 ',
Loop: '循环',
'Ad-hoc': '即席',
'Create {type}': '创建 {type}',
@@ -49,6 +55,9 @@ export default {
'Call Activity': '调用活动',
'Sub-Process (collapsed)': '子流程(折叠的)',
'Sub-Process (expanded)': '子流程(展开的)',
'Ad-hoc sub-process': '即席子流程',
'Ad-hoc sub-process (collapsed)': '即席子流程(折叠的)',
'Ad-hoc sub-process (expanded)': '即席子流程(展开的)',
'Start Event': '开始事件',
StartEvent: '开始事件',
'Intermediate Throw Event': '中间事件',
@@ -111,10 +120,10 @@ export default {
'Parallel Gateway': '并行网关',
'Inclusive Gateway': '相容网关',
'Complex Gateway': '复杂网关',
'Event based Gateway': '事件网关',
'Event-based Gateway': '事件网关',
Transaction: '转运',
'Sub Process': '子流程',
'Event Sub Process': '事件子流程',
'sub-process': '子流程',
'Event sub-process': '事件子流程',
'Collapsed Pool': '折叠池',
'Expanded Pool': '展开池',

View File

@@ -3,9 +3,7 @@ import 'bpmn-js/dist/assets/diagram-js.css';
import 'bpmn-js/dist/assets/bpmn-font/css/bpmn.css';
import 'bpmn-js/dist/assets/bpmn-font/css/bpmn-codes.css';
import 'bpmn-js/dist/assets/bpmn-font/css/bpmn-embedded.css';
// TODO @puhui999样式问题设计器那位置不太对
export { default as MyProcessDesigner } from './designer';
// TODO @puhui999流程发起时预览相关的需要使用
export { default as MyProcessViewer } from './designer/index2';
export { default as MyProcessPenal } from './penal';

View File

@@ -191,7 +191,7 @@ const initFormOnChanged = (element: any) => {
conditionFormVisible.value =
elementType.value === 'SequenceFlow' &&
activatedElement.source &&
(activatedElement.source.type as string).includes('StartEvent');
!(activatedElement.source.type as string).includes('StartEvent');
formVisible.value =
elementType.value === 'UserTask' || elementType.value === 'StartEvent';
} catch (error) {
@@ -390,8 +390,9 @@ watch(() => props.businessObject, syncFromBusinessObject, { deep: true });
<template #extra>
<IconifyIcon icon="ep:timer" />
</template>
<!-- 相关 issuehttps://gitee.com/yudaocode/yudao-ui-admin-vue3/issues/ICNRW2 -->
<TimeEventConfig
:business-object="bpmnElement.value?.businessObject"
:business-object="elementBusinessObject"
:key="elementId"
/>
</CollapsePanel>

View File

@@ -2,14 +2,17 @@
import { inject, nextTick, ref, toRaw, watch } from 'vue';
import {
Col,
Divider,
FormItem,
InputNumber,
RadioButton,
RadioGroup,
Row,
Select,
SelectOption,
Switch,
TypographyText,
} from 'ant-design-vue';
import { convertTimeUnit } from '#/views/bpm/components/simple-process-design/components/nodes-config/utils';
@@ -73,7 +76,7 @@ const resetElement = () => {
// 执行动作
timeoutHandlerType.value = elExtensionElements.value.values?.find(
(ex: any) => ex.$type === `${prefix}:TimeoutHandlerType`,
)?.[0];
);
if (timeoutHandlerType.value) {
configExtensions.value.push(timeoutHandlerType.value);
if (eventDefinition.value.timeCycle) {
@@ -243,38 +246,54 @@ watch(
</RadioButton>
</RadioGroup>
</FormItem>
<FormItem label="超时时间设置" v-if="timeoutHandlerEnable">
<span class="mr-2">当超过</span>
<FormItem name="timeDuration">
<InputNumber
class="mr-2"
:style="{ width: '100px' }"
v-model:value="timeDuration"
:min="1"
:controls="true"
@change="
() => {
updateTimeModdle();
updateElementExtensions();
}
"
/>
</FormItem>
<Select
v-model:value="timeUnit"
class="mr-2"
:style="{ width: '100px' }"
@change="onTimeUnitChange"
>
<SelectOption
v-for="item in TIME_UNIT_TYPES"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</SelectOption>
</Select>
未处理
<FormItem
label="超时时间设置"
v-if="timeoutHandlerEnable"
:label-col="{ span: 24 }"
:wrapper-col="{ span: 24 }"
>
<Row :gutter="[0, 0]">
<Col>
<TypographyText class="mr-2 mt-2 inline-flex text-sm">
当超过
</TypographyText>
</Col>
<Col>
<FormItem name="timeDuration" class="mb-0">
<InputNumber
class="mr-2 mt-0.5"
v-model:value="timeDuration"
:min="1"
controls-position="right"
@change="
() => {
updateTimeModdle();
updateElementExtensions();
}
"
/>
</FormItem>
</Col>
<Col>
<Select
v-model:value="timeUnit"
class="mr-2 !w-24"
@change="onTimeUnitChange"
>
<SelectOption
v-for="item in TIME_UNIT_TYPES"
:key="item.value"
:label="item.label"
:value="item.value"
>
{{ item.label }}
</SelectOption>
</Select>
<TypographyText class="mr-2 mt-2 inline-flex text-sm">
未处理
</TypographyText>
</Col>
</Row>
</FormItem>
<FormItem
label="最大提醒次数"
@@ -295,5 +314,3 @@ watch(
</FormItem>
</div>
</template>
<style lang="scss" scoped></style>

View File

@@ -8,17 +8,20 @@
7. 是否需要签名
-->
<script lang="ts" setup>
import type { ComponentPublicInstance } from 'vue';
import type { SystemUserApi } from '#/api/system/user';
import type { ButtonSetting } from '#/views/bpm/components/simple-process-design/consts';
import { inject, nextTick, onMounted, ref, toRaw, watch } from 'vue';
import { BpmModelFormType } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import {
Button,
Divider,
Form,
Input,
Radio,
RadioGroup,
Select,
@@ -74,9 +77,67 @@ const assignEmptyUserIdsEl = ref<any>();
const assignEmptyUserIds = ref<any>();
// 操作按钮
const buttonsSettingEl = ref<any>();
const { btnDisplayNameEdit, changeBtnDisplayName, btnDisplayNameBlurEvent } =
useButtonsSetting();
// const buttonsSettingEl = ref<any>();
// const { btnDisplayNameEdit, changeBtnDisplayName } = useButtonsSetting();
// const btnDisplayNameBlurEvent = (index: number) => {
// btnDisplayNameEdit.value[index] = false;
// const buttonItem = buttonsSettingEl.value[index];
// buttonItem.displayName =
// buttonItem.displayName || OPERATION_BUTTON_NAME.get(buttonItem.id)!;
// updateElementExtensions();
// };
// 操作按钮设置
const {
buttonsSetting,
btnDisplayNameEdit,
changeBtnDisplayName,
btnDisplayNameBlurEvent,
setInputRef,
} = useButtonsSetting();
/** 操作按钮设置 */
function useButtonsSetting() {
const buttonsSetting = ref<any[]>([]);
// 操作按钮显示名称可编辑
const btnDisplayNameEdit = ref<boolean[]>([]);
// 输入框的引用数组 - 内部使用,不暴露出去
const _btnDisplayNameInputRefs = ref<Array<HTMLInputElement | null>>([]);
const changeBtnDisplayName = (index: number) => {
btnDisplayNameEdit.value[index] = true;
// 输入框自动聚集
nextTick(() => {
if (_btnDisplayNameInputRefs.value[index]) {
_btnDisplayNameInputRefs.value[index]?.focus();
}
});
};
const btnDisplayNameBlurEvent = (index: number) => {
btnDisplayNameEdit.value[index] = false;
const buttonItem = buttonsSetting.value![index];
if (buttonItem)
buttonItem.displayName =
buttonItem.displayName || OPERATION_BUTTON_NAME.get(buttonItem.id)!;
};
// 设置 ref 引用的方法
const setInputRef = (
el: ComponentPublicInstance | Element | null,
index: number,
) => {
_btnDisplayNameInputRefs.value[index] = el as HTMLInputElement;
};
return {
buttonsSetting,
btnDisplayNameEdit,
changeBtnDisplayName,
btnDisplayNameBlurEvent,
setInputRef,
};
}
// 字段权限
const fieldsPermissionEl = ref<any[]>([]);
@@ -172,12 +233,12 @@ const resetCustomConfigList = () => {
});
// 操作按钮
buttonsSettingEl.value = elExtensionElements.value.values?.find(
buttonsSetting.value = elExtensionElements.value.values?.filter(
(ex: any) => ex.$type === `${prefix}:ButtonsSetting`,
);
if (buttonsSettingEl.value.length === 0) {
if (buttonsSetting.value.length === 0) {
DEFAULT_BUTTON_SETTING.forEach((item) => {
buttonsSettingEl.value.push(
buttonsSetting.value.push(
bpmnInstances().moddle.create(`${prefix}:ButtonsSetting`, {
'flowable:id': item.id,
'flowable:displayName': item.displayName,
@@ -189,7 +250,7 @@ const resetCustomConfigList = () => {
// 字段权限
if (formType.value === BpmModelFormType.NORMAL) {
const fieldsPermissionList = elExtensionElements.value.values?.find(
const fieldsPermissionList = elExtensionElements.value.values?.filter(
(ex: any) => ex.$type === `${prefix}:FieldsPermission`,
);
fieldsPermissionEl.value = [];
@@ -220,7 +281,7 @@ const resetCustomConfigList = () => {
// 保留剩余扩展元素,便于后面更新该元素对应属性
otherExtensions.value =
elExtensionElements.value.values?.find(
elExtensionElements.value.values?.filter(
(ex: any) =>
ex.$type !== `${prefix}:AssignStartUserHandlerType` &&
ex.$type !== `${prefix}:RejectHandlerType` &&
@@ -281,7 +342,7 @@ const updateElementExtensions = () => {
assignEmptyHandlerTypeEl.value,
assignEmptyUserIdsEl.value,
approveType.value,
...buttonsSettingEl.value,
...buttonsSetting.value,
...fieldsPermissionEl.value,
signEnable.value,
reasonRequire.value,
@@ -351,31 +412,21 @@ function findAllPredecessorsExcludingStart(elementId: string, modeler: any) {
return [...predecessors]; // 返回前置节点数组
}
function useButtonsSetting() {
const buttonsSetting = ref<ButtonSetting[]>();
// 操作按钮显示名称可编辑
const btnDisplayNameEdit = ref<boolean[]>([]);
const changeBtnDisplayName = (index: number) => {
btnDisplayNameEdit.value[index] = true;
};
const btnDisplayNameBlurEvent = (index: number) => {
btnDisplayNameEdit.value[index] = false;
const buttonItem = buttonsSetting.value?.[index];
if (buttonItem) {
buttonItem.displayName =
buttonItem.displayName || OPERATION_BUTTON_NAME.get(buttonItem.id)!;
}
};
return {
buttonsSetting,
btnDisplayNameEdit,
changeBtnDisplayName,
btnDisplayNameBlurEvent,
};
}
// function useButtonsSetting() {
// const buttonsSetting = ref<ButtonSetting[]>();
// // 操作按钮显示名称可编辑
// const btnDisplayNameEdit = ref<boolean[]>([]);
// const changeBtnDisplayName = (index: number) => {
// btnDisplayNameEdit.value[index] = true;
// };
// return {
// buttonsSetting,
// btnDisplayNameEdit,
// changeBtnDisplayName,
// };
// }
/** 批量更新权限 */
// TODO @lesan这个页面有一些 idea 红色报错,咱要不要 fix 下!
const updatePermission = (type: string) => {
fieldsPermissionEl.value.forEach((field: any) => {
if (type === 'READ') {
@@ -417,13 +468,13 @@ onMounted(async () => {
:disabled="returnTaskList.length === 0"
@change="updateRejectHandlerType"
>
<div class="flex-col">
<div v-for="(item, index) in REJECT_HANDLER_TYPES" :key="index">
<Radio :key="item.value" :value="item.value">
{{ item.label }}
</Radio>
</div>
</div>
<Radio
v-for="(item, index) in REJECT_HANDLER_TYPES"
:key="index"
:value="item.value"
>
{{ item.label }}
</Radio>
</RadioGroup>
</Form.Item>
<Form.Item
@@ -449,12 +500,12 @@ onMounted(async () => {
</Form.Item>
<Divider orientation="left">审批人为空时</Divider>
<Form.Item prop="assignEmptyHandlerType">
<Form.Item name="assignEmptyHandlerType">
<RadioGroup
v-model:value="assignEmptyHandlerType"
@change="updateAssignEmptyHandlerType"
>
<div class="flex-col">
<div class="flex flex-col gap-2">
<div v-for="(item, index) in ASSIGN_EMPTY_HANDLER_TYPES" :key="index">
<Radio :key="item.value" :value="item.value">
{{ item.label }}
@@ -466,7 +517,7 @@ onMounted(async () => {
<Form.Item
v-if="assignEmptyHandlerType === AssignEmptyHandlerType.ASSIGN_USER"
label="指定用户"
prop="assignEmptyHandlerUserIds"
name="assignEmptyHandlerUserIds"
>
<Select
v-model:value="assignEmptyUserIds"
@@ -490,7 +541,7 @@ onMounted(async () => {
v-model:value="assignStartUserHandlerType"
@change="updateAssignStartUserHandlerType"
>
<div class="flex-col">
<div class="flex flex-col gap-2">
<div
v-for="(item, index) in ASSIGN_START_USER_HANDLER_TYPES"
:key="index"
@@ -503,75 +554,97 @@ onMounted(async () => {
</RadioGroup>
<Divider orientation="left">操作按钮</Divider>
<div class="button-setting-pane">
<div class="button-setting-title">
<div class="button-title-label">操作按钮</div>
<div class="button-title-label pl-4">显示名称</div>
<div class="button-title-label">启用</div>
</div>
<div class="mt-2 text-sm">
<!-- 头部标题行 -->
<div
class="button-setting-item"
v-for="(item, index) in buttonsSettingEl"
:key="index"
class="flex items-center justify-between border border-slate-200 bg-slate-50 px-3 py-2 text-xs font-semibold text-slate-900"
>
<div class="button-setting-item-label">
<div class="w-28 text-left">操作按钮</div>
<div class="w-40 pl-2 text-left">显示名称</div>
<div class="w-20 text-center">启用</div>
</div>
<!-- 按钮配置行 -->
<div
v-for="(item, index) in buttonsSetting"
:key="index"
class="flex items-center justify-between border border-t-0 border-slate-200 px-3 py-2 text-sm"
>
<div class="w-28 truncate text-left">
{{ OPERATION_BUTTON_NAME.get(item.id) }}
</div>
<div class="button-setting-item-label">
<input
type="text"
class="editable-title-input"
@blur="btnDisplayNameBlurEvent(index)"
v-mounted-focus
v-model="item.displayName"
:placeholder="item.displayName"
<div class="flex w-40 items-center truncate text-left">
<Input
v-if="btnDisplayNameEdit[index]"
:ref="(el) => setInputRef(el, index)"
@blur="btnDisplayNameBlurEvent(index)"
@press-enter="btnDisplayNameBlurEvent(index)"
type="text"
v-model:value="item.displayName"
:placeholder="item.displayName"
class="max-w-32 focus:border-blue-500 focus:shadow-[0_0_0_2px_rgba(24,144,255,0.2)] focus:outline-none"
/>
<Button v-else type="text" @click="changeBtnDisplayName(index)">
{{ item.displayName }}
<Button v-else @click="changeBtnDisplayName(index)">
<div class="flex items-center">
{{ item.displayName }}
<IconifyIcon icon="lucide:edit" class="ml-2" />
</div>
</Button>
</div>
<div class="button-setting-item-label">
<Switch v-model:checked="item.enable" />
<div class="flex w-20 items-center justify-center">
<Switch
v-model:checked="item.enable"
@change="updateElementExtensions"
/>
</div>
</div>
</div>
<Divider orientation="left">字段权限</Divider>
<div class="field-setting-pane" v-if="formType === BpmModelFormType.NORMAL">
<div class="field-permit-title">
<div class="setting-title-label first-title">字段名称</div>
<div class="other-titles">
<div v-if="formType === BpmModelFormType.NORMAL" class="mt-2 text-sm">
<!-- 头部标题行 -->
<div
class="flex items-center justify-between border border-slate-200 bg-slate-50 px-3 py-2 text-xs font-semibold text-slate-900"
>
<div class="w-28 text-left">字段名称</div>
<div class="flex flex-1 justify-between">
<span
class="setting-title-label cursor-pointer"
class="inline-block w-24 cursor-pointer text-center hover:text-blue-500"
@click="updatePermission('READ')"
>只读
>
只读
</span>
<span
class="setting-title-label cursor-pointer"
class="inline-block w-24 cursor-pointer text-center hover:text-blue-500"
@click="updatePermission('WRITE')"
>
可编辑
</span>
<span
class="setting-title-label cursor-pointer"
class="inline-block w-24 cursor-pointer text-center hover:text-blue-500"
@click="updatePermission('NONE')"
>隐藏
>
隐藏
</span>
</div>
</div>
<!-- 字段权限行 -->
<div
class="field-setting-item"
v-for="(item, index) in fieldsPermissionEl"
:key="index"
class="flex items-center justify-between border border-t-0 border-slate-200 px-3 py-2 text-sm"
>
<div class="field-setting-item-label">{{ item.title }}</div>
<div class="w-28 truncate text-left" :title="item.title">
{{ item.title }}
</div>
<RadioGroup
class="field-setting-item-group"
v-model:value="item.permission"
class="flex flex-1 justify-between"
>
<div class="item-radio-wrap">
<div class="flex w-24 items-center justify-center">
<Radio
class="ml-5"
:value="FieldPermissionType.READ"
size="large"
@change="updateElementExtensions"
@@ -579,8 +652,9 @@ onMounted(async () => {
<span></span>
</Radio>
</div>
<div class="item-radio-wrap">
<div class="flex w-24 items-center justify-center">
<Radio
class="ml-5"
:value="FieldPermissionType.WRITE"
size="large"
@change="updateElementExtensions"
@@ -588,8 +662,9 @@ onMounted(async () => {
<span></span>
</Radio>
</div>
<div class="item-radio-wrap">
<div class="flex w-24 items-center justify-center">
<Radio
class="ml-5"
:value="FieldPermissionType.NONE"
size="large"
@change="updateElementExtensions"
@@ -602,7 +677,7 @@ onMounted(async () => {
</div>
<Divider orientation="left">是否需要签名</Divider>
<Form.Item prop="signEnable">
<Form.Item name="signEnable">
<Switch
v-model:checked="signEnable.value"
checked-children=""
@@ -612,7 +687,7 @@ onMounted(async () => {
</Form.Item>
<Divider orientation="left">审批意见</Divider>
<Form.Item prop="reasonRequire">
<Form.Item name="reasonRequire">
<Switch
v-model:checked="reasonRequire.value"
checked-children="必填"
@@ -622,162 +697,3 @@ onMounted(async () => {
</Form.Item>
</div>
</template>
<style lang="scss" scoped>
.button-setting-pane {
display: flex;
flex-direction: column;
margin-top: 8px;
font-size: 14px;
.button-setting-desc {
padding-right: 8px;
margin-bottom: 16px;
font-size: 16px;
font-weight: 700;
}
.button-setting-title {
display: flex;
align-items: center;
justify-content: space-between;
height: 45px;
padding-left: 12px;
background-color: #f8fafc0a;
border: 1px solid #1f38581a;
& > :first-child {
width: 100px !important;
text-align: left !important;
}
& > :last-child {
text-align: center !important;
}
.button-title-label {
width: 150px;
font-size: 13px;
font-weight: 700;
color: #000;
text-align: left;
}
}
.button-setting-item {
display: flex;
align-items: center;
justify-content: space-between;
height: 38px;
padding-left: 12px;
border: 1px solid #1f38581a;
border-top: 0;
& > :first-child {
width: 100px !important;
}
& > :last-child {
text-align: center !important;
}
.button-setting-item-label {
width: 150px;
overflow: hidden;
text-overflow: ellipsis;
text-align: left;
white-space: nowrap;
}
.editable-title-input {
max-width: 130px;
height: 24px;
margin-left: 4px;
line-height: 24px;
border: 1px solid #d9d9d9;
border-radius: 4px;
transition: all 0.3s;
&:focus {
outline: 0;
border-color: #40a9ff;
box-shadow: 0 0 0 2px rgb(24 144 255 / 20%);
}
}
}
}
.field-setting-pane {
display: flex;
flex-direction: column;
font-size: 14px;
.field-setting-desc {
padding-right: 8px;
margin-bottom: 16px;
font-size: 16px;
font-weight: 700;
}
.field-permit-title {
display: flex;
align-items: center;
justify-content: space-between;
height: 45px;
padding-left: 12px;
line-height: 45px;
background-color: #f8fafc0a;
border: 1px solid #1f38581a;
.first-title {
text-align: left !important;
}
.other-titles {
display: flex;
justify-content: space-between;
}
.setting-title-label {
display: inline-block;
width: 100px;
padding: 5px 0;
font-size: 13px;
font-weight: 700;
color: #000;
text-align: center;
}
}
.field-setting-item {
display: flex;
align-items: center;
justify-content: space-between;
height: 38px;
padding-left: 12px;
border: 1px solid #1f38581a;
border-top: 0;
.field-setting-item-label {
display: inline-block;
width: 100px;
min-height: 16px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: text;
}
.field-setting-item-group {
display: flex;
justify-content: space-between;
.item-radio-wrap {
display: inline-block;
width: 100px;
text-align: center;
}
}
}
}
</style>

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import { nextTick, onBeforeUnmount, ref, toRaw, watch } from 'vue';
import { Form, Input, Select } from 'ant-design-vue';
import { Form, FormItem, Input, Select, Textarea } from 'ant-design-vue';
defineOptions({ name: 'FlowCondition' });
@@ -16,8 +16,6 @@ const props = defineProps({
},
});
const { TextArea } = Input;
const flowConditionForm = ref<any>({});
const bpmnElement = ref();
const bpmnElementSource = ref();
@@ -153,15 +151,19 @@ watch(
<template>
<div class="panel-tab__content">
<Form :model="flowConditionForm">
<Form.Item label="流转类型">
<Form
:model="flowConditionForm"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 18 }"
>
<FormItem label="流转类型">
<Select v-model:value="flowConditionForm.type" @change="updateFlowType">
<Select.Option value="normal">普通流转路径</Select.Option>
<Select.Option value="default">默认流转路径</Select.Option>
<Select.Option value="condition">条件流转路径</Select.Option>
</Select>
</Form.Item>
<Form.Item
</FormItem>
<FormItem
label="条件格式"
v-if="flowConditionForm.type === 'condition'"
key="condition"
@@ -170,8 +172,8 @@ watch(
<Select.Option value="expression">表达式</Select.Option>
<Select.Option value="script">脚本</Select.Option>
</Select>
</Form.Item>
<Form.Item
</FormItem>
<FormItem
label="表达式"
v-if="
flowConditionForm.conditionType &&
@@ -179,45 +181,45 @@ watch(
"
key="express"
>
<Input
<Textarea
v-model:value="flowConditionForm.body"
style="width: 192px"
:auto-size="{ minRows: 2, maxRows: 6 }"
allow-clear
@change="updateFlowCondition"
/>
</Form.Item>
</FormItem>
<template
v-if="
flowConditionForm.conditionType &&
flowConditionForm.conditionType === 'script'
"
>
<Form.Item label="脚本语言" key="language">
<FormItem label="脚本语言" key="language">
<Input
v-model:value="flowConditionForm.language"
allow-clear
@change="updateFlowCondition"
/>
</Form.Item>
<Form.Item label="脚本类型" key="scriptType">
</FormItem>
<FormItem label="脚本类型" key="scriptType">
<Select v-model:value="flowConditionForm.scriptType">
<Select.Option value="inlineScript">内联脚本</Select.Option>
<Select.Option value="externalScript">外部脚本</Select.Option>
</Select>
</Form.Item>
<Form.Item
</FormItem>
<FormItem
label="脚本"
v-if="flowConditionForm.scriptType === 'inlineScript'"
key="body"
>
<TextArea
<Textarea
v-model:value="flowConditionForm.body"
:auto-size="{ minRows: 2, maxRows: 6 }"
allow-clear
@change="updateFlowCondition"
/>
</Form.Item>
<Form.Item
</FormItem>
<FormItem
label="资源地址"
v-if="flowConditionForm.scriptType === 'externalScript'"
key="resource"
@@ -227,7 +229,7 @@ watch(
allow-clear
@change="updateFlowCondition"
/>
</Form.Item>
</FormItem>
</template>
</Form>
</div>

View File

@@ -1,25 +1,25 @@
<script lang="ts" setup>
import { inject, nextTick, ref, watch } from 'vue';
import { confirm, useVbenDrawer, useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { cloneDeep } from '@vben/utils';
import {
Button,
Divider,
Drawer,
Form,
FormItem,
Input,
Modal,
Select,
SelectOption,
Table,
TableColumn,
} from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import ProcessListenerSelectModal from '#/views/bpm/processListener/components/process-listener-select-modal.vue';
import { createListenerObject, updateElementExtensions } from '../../utils';
import ProcessListenerDialog from './ProcessListenerDialog.vue';
import ListenerFieldModal from './ListenerFieldModal.vue';
import {
fieldType,
initListenerForm,
@@ -41,29 +41,32 @@ const props = defineProps({
},
});
const prefix = inject('prefix');
const width = inject('width');
const elementListenersList = ref<any[]>([]); // 监听器列表
const listenerForm = ref<any>({}); // 监听器详情表单
const listenerFormModelVisible = ref(false); // 监听器 编辑 侧边栏显示状态
const fieldsListOfListener = ref<any[]>([]);
const listenerFieldForm = ref<any>({}); // 监听器 注入字段 详情表单
const listenerFieldFormModelVisible = ref(false); // 监听器 注入字段表单弹窗 显示状态
const editingListenerIndex = ref(-1); // 监听器所在下标,-1 为新增
const editingListenerFieldIndex = ref(-1); // 字段所在下标,-1 为新增
const listenerTypeObject = ref(listenerType);
const fieldTypeObject = ref(fieldType);
const bpmnElement = ref();
const otherExtensionList = ref();
const bpmnElementListeners = ref();
const listenerFormRef = ref();
const listenerFieldFormRef = ref();
const bpmnInstances = () => (window as any)?.bpmnInstances;
const resetListenersList = () => {
bpmnElement.value = bpmnInstances().bpmnElement;
otherExtensionList.value = [];
const instances = bpmnInstances();
if (!instances || !instances.bpmnElement) return;
// 直接使用原始BPMN元素避免Vue响应式代理问题
const bpmnElement = instances.bpmnElement;
const businessObject = bpmnElement.businessObject;
otherExtensionList.value =
businessObject?.extensionElements?.values?.filter(
(ex: any) => ex.$type !== `${prefix}:ExecutionListener`,
) ?? []; // 保留非监听器类型的扩展属性避免移除监听器时清空其他配置如审批人等。相关案例https://gitee.com/yudaocode/yudao-ui-admin-vue3/issues/ICMSYC
bpmnElementListeners.value =
bpmnElement.value.businessObject?.extensionElements?.values?.filter(
businessObject?.extensionElements?.values?.filter(
(ex: any) => ex.$type === `${prefix}:ExecutionListener`,
) ?? [];
elementListenersList.value = bpmnElementListeners.value.map((listener: any) =>
@@ -72,13 +75,12 @@ const resetListenersList = () => {
};
// 打开 监听器详情 侧边栏
const openListenerForm = (listener: any, index: number) => {
// debugger
if (listener) {
listenerForm.value = initListenerForm(listener);
editingListenerIndex.value = index;
} else {
listenerForm.value = {};
editingListenerIndex.value = -1; // 标记为新增
editingListenerIndex.value = -1;
}
if (listener && listener.fields) {
fieldsListOfListener.value = listener.fields.map((field: any) => ({
@@ -89,8 +91,7 @@ const openListenerForm = (listener: any, index: number) => {
fieldsListOfListener.value = [];
listenerForm.value.fields = [];
}
// 打开侧边栏并清楚验证状态
listenerFormModelVisible.value = true;
listenerDrawerApi.open();
nextTick(() => {
if (listenerFormRef.value) {
listenerFormRef.value.clearValidate();
@@ -100,87 +101,64 @@ const openListenerForm = (listener: any, index: number) => {
// 打开监听器字段编辑弹窗
const openListenerFieldForm = (field: any, index: number) => {
listenerFieldForm.value = field ? cloneDeep(field) : {};
const data = field ? cloneDeep(field) : {};
editingListenerFieldIndex.value = field ? index : -1;
listenerFieldFormModelVisible.value = true;
nextTick(() => {
if (listenerFieldFormRef.value) {
listenerFieldFormRef.value.clearValidate();
}
});
fieldModalApi.setData(data).open();
};
// 保存监听器注入字段
const saveListenerFiled = async () => {
// debugger
const validateStatus = await listenerFieldFormRef.value.validate();
if (!validateStatus) return; // 验证不通过直接返回
const saveListenerFiled = async (data: any) => {
if (editingListenerFieldIndex.value === -1) {
fieldsListOfListener.value.push(listenerFieldForm.value);
listenerForm.value.fields.push(listenerFieldForm.value);
fieldsListOfListener.value.push(data);
listenerForm.value.fields.push(data);
} else {
fieldsListOfListener.value.splice(
editingListenerFieldIndex.value,
1,
listenerFieldForm.value,
);
listenerForm.value.fields.splice(
editingListenerFieldIndex.value,
1,
listenerFieldForm.value,
);
fieldsListOfListener.value.splice(editingListenerFieldIndex.value, 1, data);
listenerForm.value.fields.splice(editingListenerFieldIndex.value, 1, data);
}
listenerFieldFormModelVisible.value = false;
nextTick(() => {
listenerFieldForm.value = {};
});
};
// 移除监听器字段
const removeListenerField = (index: number) => {
// debugger
Modal.confirm({
title: '确认移除该字段吗?',
content: '此操作不可撤销',
okText: '确 认',
cancelText: '取 消',
onOk() {
fieldsListOfListener.value.splice(index, 1);
listenerForm.value.fields.splice(index, 1);
},
onCancel() {
console.warn('操作取消');
},
confirm({
title: '提示',
content: '确认移除该字段吗?',
}).then(() => {
fieldsListOfListener.value.splice(index, 1);
listenerForm.value.fields.splice(index, 1);
});
};
// 移除监听器
const removeListener = (index: number) => {
Modal.confirm({
title: '确认移除该监听器吗?',
content: '此操作不可撤销',
okText: '确 认',
cancelText: '取 消',
onOk() {
bpmnElementListeners.value.splice(index, 1);
elementListenersList.value.splice(index, 1);
updateElementExtensions(bpmnElement.value, [
...otherExtensionList.value,
...bpmnElementListeners.value,
]);
},
onCancel() {
console.warn('操作取消');
},
confirm({
title: '提示',
content: '确认移除该监听器吗?',
}).then(() => {
const instances = bpmnInstances();
if (!instances || !instances.bpmnElement) return;
bpmnElementListeners.value.splice(index, 1);
elementListenersList.value.splice(index, 1);
updateElementExtensions(instances.bpmnElement, [
...otherExtensionList.value,
...bpmnElementListeners.value,
]);
});
};
// 保存监听器配置
const saveListenerConfig = async () => {
// debugger
const validateStatus = await listenerFormRef.value.validate();
if (!validateStatus) return; // 验证不通过直接返回
try {
await listenerFormRef.value.validate();
} catch {
return;
}
const listenerObject = createListenerObject(
listenerForm.value,
false,
prefix,
);
const instances = bpmnInstances();
if (!instances || !instances.bpmnElement) return;
const bpmnElement = instances.bpmnElement;
if (editingListenerIndex.value === -1) {
bpmnElementListeners.value.push(listenerObject);
elementListenersList.value.push(listenerForm.value);
@@ -196,26 +174,115 @@ const saveListenerConfig = async () => {
listenerForm.value,
);
}
// 保存其他配置
otherExtensionList.value =
bpmnElement.value.businessObject?.extensionElements?.values?.filter(
bpmnElement.businessObject?.extensionElements?.values?.filter(
(ex: any) => ex.$type !== `${prefix}:ExecutionListener`,
) ?? [];
updateElementExtensions(bpmnElement.value, [
updateElementExtensions(bpmnElement, [
...otherExtensionList.value,
...bpmnElementListeners.value,
]);
// 4. 隐藏侧边栏
listenerFormModelVisible.value = false;
listenerDrawerApi.close();
listenerForm.value = {};
};
// 配置主列表 Grid
const [ListenerGrid, listenerGridApi] = useVbenVxeGrid({
gridOptions: {
columns: [
{ type: 'seq', width: 50, title: '序号' },
{ field: 'event', title: '事件类型', minWidth: 100 },
{
field: 'listenerType',
title: '监听器类型',
minWidth: 100,
formatter: ({ cellValue }: { cellValue: string }) =>
(listenerTypeObject.value as Record<string, any>)[cellValue],
},
{
title: '操作',
width: 120,
slots: { default: 'action' },
fixed: 'right',
},
],
border: true,
showOverflow: true,
height: 'auto',
toolbarConfig: {
enabled: false,
},
pagerConfig: {
enabled: false,
},
},
});
// 配置字段列表 Grid
const [FieldsGrid, fieldsGridApi] = useVbenVxeGrid({
gridOptions: {
columns: [
{ type: 'seq', width: 50, title: '序号' },
{ field: 'name', title: '字段名称', minWidth: 100 },
{
field: 'fieldType',
title: '字段类型',
minWidth: 80,
formatter: ({ cellValue }: { cellValue: string }) =>
(fieldTypeObject.value as Record<string, any>)[cellValue],
},
{
title: '字段值/表达式',
minWidth: 100,
formatter: ({ row }: { row: any }) => row.string || row.expression,
},
{
title: '操作',
width: 120,
slots: { default: 'action' },
fixed: 'right',
},
],
border: true,
showOverflow: true,
maxHeight: 200,
toolbarConfig: {
enabled: false,
},
pagerConfig: {
enabled: false,
},
},
});
// 配置 Drawer
const [ListenerDrawer, listenerDrawerApi] = useVbenDrawer({
title: '执行监听器',
destroyOnClose: true,
onConfirm: saveListenerConfig,
});
// 配置字段 Modal
const [FieldModal, fieldModalApi] = useVbenModal({
connectedComponent: ListenerFieldModal,
});
// 配置选择监听器 Modal
const [ProcessListenerSelectModalComp, processListenerSelectModalApi] =
useVbenModal({
connectedComponent: ProcessListenerSelectModal,
destroyOnClose: true,
});
// 打开监听器弹窗
const processListenerDialogRef = ref();
const openProcessListenerDialog = async () => {
processListenerDialogRef.value.open('execution');
processListenerSelectModalApi.setData({ type: 'execution' }).open();
};
const selectProcessListener = (listener: any) => {
const instances = bpmnInstances();
if (!instances || !instances.bpmnElement) return;
const bpmnElement = instances.bpmnElement;
const listenerForm = initListenerForm2(listener);
const listenerObject = createListenerObject(listenerForm, false, prefix);
bpmnElementListeners.value.push(listenerObject);
@@ -223,15 +290,31 @@ const selectProcessListener = (listener: any) => {
// 保存其他配置
otherExtensionList.value =
bpmnElement.value.businessObject?.extensionElements?.values?.filter(
bpmnElement.businessObject?.extensionElements?.values?.filter(
(ex: any) => ex.$type !== `${prefix}:ExecutionListener`,
) ?? [];
updateElementExtensions(bpmnElement.value, [
updateElementExtensions(bpmnElement, [
...otherExtensionList.value,
...bpmnElementListeners.value,
]);
};
watch(
elementListenersList,
(val) => {
listenerGridApi.setGridOptions({ data: val });
},
{ deep: true },
);
watch(
fieldsListOfListener,
(val) => {
fieldsGridApi.setGridOptions({ data: val });
},
{ deep: true },
);
watch(
() => props.id,
(val: string) => {
@@ -245,56 +328,44 @@ watch(
);
</script>
<template>
<div class="panel-tab__content">
<Table
:data-source="elementListenersList"
size="small"
bordered
:pagination="false"
>
<TableColumn title="序号" width="50px">
<template #default="{ index }">
{{ index + 1 }}
</template>
</TableColumn>
<TableColumn title="事件类型" width="100px" data-index="event" />
<TableColumn
title="监听器类型"
width="100px"
:custom-render="
({ record }: any) =>
listenerTypeObject[record.listenerType as keyof typeof listenerType]
"
/>
<TableColumn title="操作" width="100px">
<template #default="{ record, index }">
<Button
size="small"
type="link"
@click="openListenerForm(record, index)"
>
编辑
</Button>
<Divider type="vertical" />
<Button
size="small"
type="link"
danger
@click="removeListener(index)"
>
移除
</Button>
</template>
</TableColumn>
</Table>
<div class="element-drawer__button">
<Button type="primary" size="small" @click="openListenerForm(null, -1)">
<div class="-mx-2">
<ListenerGrid :data="elementListenersList">
<template #action="{ row, rowIndex }">
<Button
size="small"
type="link"
@click="openListenerForm(row, rowIndex)"
>
编辑
</Button>
<Divider type="vertical" />
<Button
size="small"
type="link"
danger
@click="removeListener(rowIndex)"
>
移除
</Button>
</template>
</ListenerGrid>
<div class="mt-1 flex w-full items-center justify-center gap-2 px-2">
<Button
class="flex flex-1 items-center justify-center"
type="primary"
size="small"
@click="openListenerForm(null, -1)"
>
<template #icon>
<IconifyIcon icon="ep:plus" />
</template>
添加监听器
</Button>
<Button size="small" @click="openProcessListenerDialog">
<Button
class="flex flex-1 items-center justify-center"
size="small"
@click="openProcessListenerDialog"
>
<template #icon>
<IconifyIcon icon="ep:select" />
</template>
@@ -303,13 +374,13 @@ watch(
</div>
<!-- 监听器 编辑/创建 部分 -->
<Drawer
v-model:open="listenerFormModelVisible"
title="执行监听器"
:width="width as any"
:destroy-on-close="true"
>
<Form :model="listenerForm" ref="listenerFormRef">
<ListenerDrawer>
<Form
:model="listenerForm"
ref="listenerFormRef"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 19 }"
>
<FormItem
label="事件类型"
name="event"
@@ -463,8 +534,9 @@ watch(
注入字段
</span>
<Button
type="primary"
title="添加字段"
class="flex items-center"
size="small"
type="link"
@click="openListenerFieldForm(null, -1)"
>
<template #icon>
@@ -473,143 +545,32 @@ watch(
添加字段
</Button>
</div>
<Table :data-source="fieldsListOfListener" size="small" bordered>
<TableColumn title="序号" width="50px">
<template #default="{ index }">
{{ index + 1 }}
</template>
</TableColumn>
<TableColumn title="字段名称" width="100px" data-index="name" />
<TableColumn
title="字段类型"
width="80px"
:custom-render="
({ record }: any) =>
fieldTypeObject[record.fieldType as keyof typeof fieldType]
"
/>
<TableColumn
title="字段值/表达式"
width="120px"
:custom-render="
({ record }: any) => record.string || record.expression
"
/>
<TableColumn title="操作" width="80px" fixed="right">
<template #default="{ record, index }">
<Button
size="small"
type="link"
@click="openListenerFieldForm(record, index)"
>
编辑
</Button>
<Divider type="vertical" />
<Button
size="small"
type="link"
danger
@click="removeListenerField(index)"
>
移除
</Button>
</template>
</TableColumn>
</Table>
<div class="element-drawer__button">
<Button @click="listenerFormModelVisible = false">取 消</Button>
<Button type="primary" @click="saveListenerConfig">保 存</Button>
</div>
</Drawer>
<FieldsGrid :data="fieldsListOfListener">
<template #action="{ row, rowIndex }">
<Button
size="small"
type="link"
@click="openListenerFieldForm(row, rowIndex)"
>
编辑
</Button>
<Divider type="vertical" />
<Button
size="small"
type="link"
danger
@click="removeListenerField(rowIndex)"
>
移除
</Button>
</template>
</FieldsGrid>
</ListenerDrawer>
<!-- 注入字段 编辑/创建 部分 -->
<Modal
title="字段配置"
v-model:open="listenerFieldFormModelVisible"
width="600px"
:destroy-on-close="true"
>
<Form :model="listenerFieldForm" ref="listenerFieldFormRef">
<FormItem
label="字段名称"
name="name"
:rules="[
{
required: true,
message: '请填写字段名称',
trigger: ['blur', 'change'],
},
]"
>
<Input v-model:value="listenerFieldForm.name" allow-clear />
</FormItem>
<FormItem
label="字段类型"
name="fieldType"
:rules="[
{
required: true,
message: '请选择字段类型',
trigger: ['blur', 'change'],
},
]"
>
<Select v-model:value="listenerFieldForm.fieldType">
<SelectOption
v-for="i in Object.keys(fieldTypeObject)"
:key="i"
:value="i"
>
{{ fieldTypeObject[i as keyof typeof fieldType] }}
</SelectOption>
</Select>
</FormItem>
<FormItem
v-if="listenerFieldForm.fieldType === 'string'"
label="字段值"
name="string"
key="field-string"
:rules="[
{
required: true,
message: '请填写字段值',
trigger: ['blur', 'change'],
},
]"
>
<Input v-model:value="listenerFieldForm.string" allow-clear />
</FormItem>
<FormItem
v-if="listenerFieldForm.fieldType === 'expression'"
label="表达式"
name="expression"
key="field-expression"
:rules="[
{
required: true,
message: '请填写表达式',
trigger: ['blur', 'change'],
},
]"
>
<Input v-model:value="listenerFieldForm.expression" allow-clear />
</FormItem>
</Form>
<template #footer>
<Button size="small" @click="listenerFieldFormModelVisible = false">
取 消
</Button>
<Button size="small" type="primary" @click="saveListenerFiled">
确 定
</Button>
</template>
</Modal>
<FieldModal @confirm="saveListenerFiled" />
</div>
<!-- 选择弹窗 -->
<ProcessListenerDialog
ref="processListenerDialogRef"
@select="selectProcessListener"
/>
<ProcessListenerSelectModalComp @select="selectProcessListener" />
</template>

View File

@@ -0,0 +1,117 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Form, FormItem, Input, Select, SelectOption } from 'ant-design-vue';
import { fieldType } from './utilSelf';
defineOptions({ name: 'ListenerFieldModal' });
const emit = defineEmits<{
confirm: [data: any];
}>();
const fieldTypeObject = ref(fieldType);
const form = ref<any>({});
const formRef = ref();
const [Modal, modalApi] = useVbenModal({
onOpenChange(isOpen) {
if (isOpen) {
const data = modalApi.getData<any>();
form.value = data || {};
// clear validate
setTimeout(() => {
formRef.value?.clearValidate();
}, 50);
}
},
onConfirm: async () => {
try {
await formRef.value?.validate();
emit('confirm', { ...form.value });
await modalApi.close();
} catch {
// validate failed
}
},
});
</script>
<template>
<Modal title="字段配置" class="w-3/5">
<Form
ref="formRef"
:model="form"
:label-col="{ span: 4 }"
:wrapper-col="{ span: 18 }"
>
<FormItem
label="字段名称:"
name="name"
:rules="[
{
required: true,
message: '请填写字段名称',
trigger: ['blur', 'change'],
},
]"
>
<Input v-model:value="form.name" allow-clear />
</FormItem>
<FormItem
label="字段类型:"
name="fieldType"
:rules="[
{
required: true,
message: '请选择字段类型',
trigger: ['blur', 'change'],
},
]"
>
<Select v-model:value="form.fieldType">
<SelectOption
v-for="i in Object.keys(fieldTypeObject)"
:key="i"
:value="i"
>
{{ fieldTypeObject[i as keyof typeof fieldType] }}
</SelectOption>
</Select>
</FormItem>
<FormItem
v-if="form.fieldType === 'string'"
label="字段值:"
name="string"
key="field-string"
:rules="[
{
required: true,
message: '请填写字段值',
trigger: ['blur', 'change'],
},
]"
>
<Input v-model:value="form.string" allow-clear />
</FormItem>
<FormItem
v-if="form.fieldType === 'expression'"
label="表达式:"
name="expression"
key="field-expression"
:rules="[
{
required: true,
message: '请填写表达式',
trigger: ['blur', 'change'],
},
]"
>
<Input v-model:value="form.expression" allow-clear />
</FormItem>
</Form>
</Modal>
</template>

View File

@@ -1,110 +0,0 @@
<!-- 执行器选择 -->
<script setup lang="ts">
import type { BpmProcessListenerApi } from '#/api/bpm/processListener';
import { reactive, ref } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { CommonStatusEnum, DICT_TYPE } from '@vben/constants';
import { Button, Modal, Pagination, Table } from 'ant-design-vue';
import { getProcessListenerPage } from '#/api/bpm/processListener';
import { DictTag } from '#/components/dict-tag';
/** BPM 流程 表单 */
defineOptions({ name: 'ProcessListenerDialog' });
/** 提交表单 */
const emit = defineEmits(['success', 'select']);
const dialogVisible = ref(false); // 弹窗的是否展示
const loading = ref(true); // 列表的加载中
const list = ref<BpmProcessListenerApi.ProcessListener[]>([]); // 列表的数据
const total = ref(0); // 列表的总页数
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
type: '',
status: CommonStatusEnum.ENABLE,
});
/** 打开弹窗 */
const open = async (type: string) => {
queryParams.pageNo = 1;
queryParams.type = type;
await getList();
dialogVisible.value = true;
};
defineExpose({ open }); // 提供 open 方法,用于打开弹窗
/** 查询列表 */
const getList = async () => {
loading.value = true;
try {
const data = await getProcessListenerPage(queryParams);
list.value = data.list;
total.value = data.total;
} finally {
loading.value = false;
}
};
// 定义 success 事件,用于操作成功后的回调
const select = async (row: BpmProcessListenerApi.ProcessListener) => {
dialogVisible.value = false;
// 发送操作成功的事件
emit('select', row);
};
</script>
<template>
<Modal
title="请选择监听器"
v-model:open="dialogVisible"
width="1024px"
:footer="null"
>
<ContentWrap>
<Table
:loading="loading"
:data-source="list"
:pagination="false"
:scroll="{ x: 'max-content' }"
>
<Table.Column title="名字" align="center" data-index="name" />
<Table.Column title="类型" align="center" data-index="type">
<template #default="{ record }">
<DictTag
:type="DICT_TYPE.BPM_PROCESS_LISTENER_TYPE"
:value="record.type"
/>
</template>
</Table.Column>
<Table.Column title="事件" align="center" data-index="event" />
<Table.Column title="值类型" align="center" data-index="valueType">
<template #default="{ record }">
<DictTag
:type="DICT_TYPE.BPM_PROCESS_LISTENER_VALUE_TYPE"
:value="record.valueType"
/>
</template>
</Table.Column>
<Table.Column title="值" align="center" data-index="value" />
<Table.Column title="操作" align="center" fixed="right">
<template #default="{ record }">
<Button type="primary" @click="select(record)"> 选择 </Button>
</template>
</Table.Column>
</Table>
<!-- 分页 -->
<div class="mt-4 flex justify-end">
<Pagination
:total="total"
v-model:current="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
show-size-changer
@change="getList"
/>
</div>
</ContentWrap>
</Modal>
</template>

View File

@@ -1,26 +1,25 @@
<script lang="ts" setup>
import { inject, nextTick, ref, watch } from 'vue';
import { confirm, useVbenDrawer, useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { cloneDeep } from '@vben/utils';
import {
Button,
Divider,
Drawer,
Form,
FormItem,
Input,
Modal,
Select,
SelectOption,
Table,
TableColumn,
} from 'ant-design-vue';
import ProcessListenerDialog from '#/views/bpm/components/bpmn-process-designer/package/penal/listeners/ProcessListenerDialog.vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import ProcessListenerSelectModal from '#/views/bpm/processListener/components/process-listener-select-modal.vue';
import { createListenerObject, updateElementExtensions } from '../../utils';
import ListenerFieldModal from './ListenerFieldModal.vue';
import {
eventType,
fieldType,
@@ -40,59 +39,49 @@ interface Props {
}
const prefix = inject<string>('prefix');
const width = inject<number>('width');
const elementListenersList = ref<any[]>([]);
const listenerEventTypeObject = ref(eventType);
const listenerTypeObject = ref(listenerType);
const listenerFormModelVisible = ref(false);
const listenerForm = ref<any>({});
const fieldTypeObject = ref(fieldType);
const fieldsListOfListener = ref<any[]>([]);
const listenerFieldFormModelVisible = ref(false); // 监听器 注入字段表单弹窗 显示状态
const editingListenerIndex = ref(-1); // 监听器所在下标,-1 为新增
const editingListenerFieldIndex = ref<any>(-1); // 字段所在下标,-1 为新增
const listenerFieldForm = ref<any>({}); // 监听器 注入字段 详情表单
const bpmnElement = ref<any>();
const editingListenerIndex = ref(-1);
const editingListenerFieldIndex = ref<any>(-1);
const bpmnElementListeners = ref<any[]>([]);
const otherExtensionList = ref<any[]>([]);
const listenerFormRef = ref<any>({});
const listenerFieldFormRef = ref<any>({});
interface BpmnInstances {
bpmnElement: any;
[key: string]: any;
}
declare global {
interface Window {
bpmnInstances?: BpmnInstances;
}
}
const bpmnInstances = () => window.bpmnInstances;
const bpmnInstances = () => (window as any)?.bpmnInstances;
const resetListenersList = () => {
// console.log(
// bpmnInstances().bpmnElement,
// 'window.bpmnInstances.bpmnElementwindow.bpmnInstances.bpmnElementwindow.bpmnInstances.bpmnElementwindow.bpmnInstances.bpmnElementwindow.bpmnInstances.bpmnElementwindow.bpmnInstances.bpmnElement',
// );
bpmnElement.value = bpmnInstances()?.bpmnElement;
otherExtensionList.value = [];
const instances = bpmnInstances();
if (!instances || !instances.bpmnElement) return;
// 直接使用原始BPMN元素避免Vue响应式代理问题
const bpmnElement = instances.bpmnElement;
const businessObject = bpmnElement.businessObject;
otherExtensionList.value =
businessObject?.extensionElements?.values?.filter(
(ex: any) => ex.$type !== `${prefix}:TaskListener`,
) ?? [];
bpmnElementListeners.value =
bpmnElement.value.businessObject?.extensionElements?.values.filter(
businessObject?.extensionElements?.values?.filter(
(ex: any) => ex.$type === `${prefix}:TaskListener`,
) ?? [];
elementListenersList.value = bpmnElementListeners.value.map((listener) =>
initListenerType(listener),
);
};
const openListenerForm = (listener: any, index?: number) => {
if (listener) {
listenerForm.value = initListenerForm(listener);
editingListenerIndex.value = index || -1;
} else {
listenerForm.value = {};
editingListenerIndex.value = -1; // 标记为新增
editingListenerIndex.value = -1;
}
if (listener && listener.fields) {
fieldsListOfListener.value = listener.fields.map((field: any) => ({
@@ -103,38 +92,42 @@ const openListenerForm = (listener: any, index?: number) => {
fieldsListOfListener.value = [];
listenerForm.value.fields = [];
}
// 打开侧边栏并清楚验证状态
listenerFormModelVisible.value = true;
listenerDrawerApi.open();
nextTick(() => {
if (listenerFormRef.value) listenerFormRef.value.clearValidate();
});
};
// 移除监听器
const removeListener = (_: any, index: number) => {
// console.log(listener, 'listener');
Modal.confirm({
confirm({
title: '提示',
content: '确认移除该监听器吗?',
okText: '确 认',
cancelText: '取 消',
onOk() {
bpmnElementListeners.value.splice(index, 1);
elementListenersList.value.splice(index, 1);
updateElementExtensions(bpmnElement.value, [
...otherExtensionList.value,
...bpmnElementListeners.value,
]);
},
onCancel() {
// console.info('操作取消');
},
}).then(() => {
const instances = bpmnInstances();
if (!instances || !instances.bpmnElement) return;
bpmnElementListeners.value.splice(index, 1);
elementListenersList.value.splice(index, 1);
updateElementExtensions(instances.bpmnElement, [
...otherExtensionList.value,
...bpmnElementListeners.value,
]);
});
};
// 保存监听器
const saveListenerConfig = async () => {
const validateStatus = await listenerFormRef.value.validate();
if (!validateStatus) return; // 验证不通过直接返回
async function saveListenerConfig() {
try {
await listenerFormRef.value.validate();
} catch {
return;
}
const instances = bpmnInstances();
if (!instances || !instances.bpmnElement) return;
const bpmnElement = instances.bpmnElement;
const listenerObject = createListenerObject(listenerForm.value, true, prefix);
if (editingListenerIndex.value === -1) {
bpmnElementListeners.value.push(listenerObject);
elementListenersList.value.push(listenerForm.value);
@@ -150,93 +143,174 @@ const saveListenerConfig = async () => {
listenerForm.value,
);
}
// 保存其他配置
otherExtensionList.value =
bpmnElement.value.businessObject?.extensionElements?.values?.filter(
bpmnElement.businessObject?.extensionElements?.values?.filter(
(ex: any) => ex.$type !== `${prefix}:TaskListener`,
) ?? [];
updateElementExtensions(bpmnElement.value, [
updateElementExtensions(bpmnElement, [
...otherExtensionList.value,
...bpmnElementListeners.value,
]);
// 4. 隐藏侧边栏
listenerFormModelVisible.value = false;
listenerDrawerApi.close();
listenerForm.value = {};
}
const openListenerFieldForm = (field: any, index?: number) => {
const data = field ? cloneDeep(field) : {};
editingListenerFieldIndex.value = field ? index : -1;
fieldModalApi.setData(data).open();
};
// 打开监听器字段编辑弹窗
const openListenerFieldForm = (field: any, index?: number) => {
listenerFieldForm.value = field ? cloneDeep(field) : {};
editingListenerFieldIndex.value = field ? index : -1;
listenerFieldFormModelVisible.value = true;
nextTick(() => {
if (listenerFieldFormRef.value) listenerFieldFormRef.value.clearValidate();
});
};
// 保存监听器注入字段
const saveListenerFiled = async () => {
const validateStatus = await listenerFieldFormRef.value.validate();
if (!validateStatus) return; // 验证不通过直接返回
const [ListenerGrid, listenerGridApi] = useVbenVxeGrid({
gridOptions: {
columns: [
{ type: 'seq', width: 50, title: '序号' },
{
field: 'event',
title: '事件类型',
minWidth: 80,
formatter: ({ cellValue }: { cellValue: string }) =>
(listenerEventTypeObject.value as Record<string, any>)[cellValue],
},
{ field: 'id', title: '事件id', minWidth: 80, showOverflow: true },
{
field: 'listenerType',
title: '监听器类型',
minWidth: 80,
formatter: ({ cellValue }: { cellValue: string }) =>
(listenerTypeObject.value as Record<string, any>)[cellValue],
},
{
title: '操作',
width: 120,
slots: { default: 'action' },
fixed: 'right',
},
],
border: true,
showOverflow: true,
height: 'auto',
toolbarConfig: {
enabled: false,
},
pagerConfig: {
enabled: false,
},
},
});
async function saveListenerField(data: any) {
if (editingListenerFieldIndex.value === -1) {
fieldsListOfListener.value.push(listenerFieldForm.value);
listenerForm.value.fields.push(listenerFieldForm.value);
fieldsListOfListener.value.push(data);
listenerForm.value.fields.push(data);
} else {
fieldsListOfListener.value.splice(
editingListenerFieldIndex.value,
1,
listenerFieldForm.value,
);
listenerForm.value.fields.splice(
editingListenerFieldIndex.value,
1,
listenerFieldForm.value,
);
fieldsListOfListener.value.splice(editingListenerFieldIndex.value, 1, data);
listenerForm.value.fields.splice(editingListenerFieldIndex.value, 1, data);
}
listenerFieldFormModelVisible.value = false;
nextTick(() => {
listenerFieldForm.value = {};
});
};
// 移除监听器字段
}
const removeListenerField = (_: any, index: number) => {
// console.log(field, 'field');
Modal.confirm({
confirm({
title: '提示',
content: '确认移除该字段吗?',
okText: '确 认',
cancelText: '取 消',
onOk() {
fieldsListOfListener.value.splice(index, 1);
listenerForm.value.fields.splice(index, 1);
},
onCancel() {
// console.info('操作取消');
},
}).then(() => {
fieldsListOfListener.value.splice(index, 1);
listenerForm.value.fields.splice(index, 1);
});
};
// 打开监听器弹窗
const processListenerDialogRef = ref<any>();
const openProcessListenerDialog = async () => {
processListenerDialogRef.value.open('task');
processListenerSelectModalApi.setData({ type: 'task' }).open();
};
const selectProcessListener = (listener: any) => {
const instances = bpmnInstances();
if (!instances || !instances.bpmnElement) return;
const bpmnElement = instances.bpmnElement;
const listenerForm = initListenerForm2(listener);
listenerForm.id = listener.id;
const listenerObject = createListenerObject(listenerForm, true, prefix);
bpmnElementListeners.value.push(listenerObject);
elementListenersList.value.push(listenerForm);
// 保存其他配置
otherExtensionList.value =
bpmnElement.value.businessObject?.extensionElements?.values?.filter(
bpmnElement.businessObject?.extensionElements?.values?.filter(
(ex: any) => ex.$type !== `${prefix}:TaskListener`,
) ?? [];
updateElementExtensions(
bpmnElement.value,
bpmnElement,
otherExtensionList.value?.concat(bpmnElementListeners.value),
);
};
const [ListenerDrawer, listenerDrawerApi] = useVbenDrawer({
title: '任务监听器',
destroyOnClose: true,
onConfirm: saveListenerConfig,
});
const [FieldModal, fieldModalApi] = useVbenModal({
connectedComponent: ListenerFieldModal,
});
const [ProcessListenerSelectModalComp, processListenerSelectModalApi] =
useVbenModal({
connectedComponent: ProcessListenerSelectModal,
destroyOnClose: true,
});
const [FieldsGrid, fieldsGridApi] = useVbenVxeGrid({
gridOptions: {
columns: [
{ type: 'seq', width: 50, title: '序号' },
{ field: 'name', title: '字段名称', minWidth: 100 },
{
field: 'fieldType',
title: '字段类型',
width: 80,
formatter: ({ cellValue }: { cellValue: string }) =>
fieldTypeObject.value[cellValue as keyof typeof fieldType],
},
{
title: '字段值/表达式',
width: 100,
formatter: ({ row }: { row: any }) => row.string || row.expression,
},
{
title: '操作',
width: 120,
slots: { default: 'action' },
fixed: 'right',
},
],
border: true,
showOverflow: true,
minHeight: 200,
toolbarConfig: {
enabled: false,
},
pagerConfig: {
enabled: false,
},
},
});
watch(
elementListenersList,
(val) => {
listenerGridApi.setGridOptions({ data: val });
},
{ deep: true },
);
watch(
fieldsListOfListener,
(val) => {
fieldsGridApi.setGridOptions({ data: val });
},
{ deep: true },
);
watch(
() => props.id,
(val) => {
@@ -250,257 +324,218 @@ watch(
);
</script>
<template>
<div class="panel-tab__content">
<Table :data="elementListenersList" size="small" bordered>
<TableColumn title="序号" width="50px" type="index" />
<TableColumn
title="事件类型"
width="80px"
:ellipsis="{ showTitle: true }"
:custom-render="
({ record }: any) =>
listenerEventTypeObject[record.event as keyof typeof eventType]
"
/>
<TableColumn
title="事件id"
width="80px"
data-index="id"
:ellipsis="{ showTitle: true }"
/>
<TableColumn
title="监听器类型"
width="80px"
:ellipsis="{ showTitle: true }"
:custom-render="
({ record }: any) =>
listenerTypeObject[record.listenerType as keyof typeof listenerType]
"
/>
<TableColumn title="操作" width="90px">
<template #default="{ record, index }">
<Button
size="small"
type="link"
@click="openListenerForm(record, index)"
>
编辑
</Button>
<Divider type="vertical" />
<Button
size="small"
type="link"
danger
@click="removeListener(record, index)"
>
移除
</Button>
</template>
</TableColumn>
</Table>
<div class="element-drawer__button">
<Button size="small" type="primary" @click="openListenerForm(null)">
<div class="-mx-2">
<ListenerGrid>
<template #action="{ row, rowIndex }">
<Button
size="small"
type="link"
@click="openListenerForm(row, rowIndex)"
>
编辑
</Button>
<Divider type="vertical" />
<Button
size="small"
type="link"
danger
@click="removeListener(row, rowIndex)"
>
移除
</Button>
</template>
</ListenerGrid>
<div class="mt-1 flex w-full items-center justify-center gap-2 px-2">
<Button
class="flex flex-1 items-center justify-center"
size="small"
type="primary"
@click="openListenerForm(null)"
>
<template #icon> <IconifyIcon icon="ep:plus" /></template>
添加监听器
</Button>
<Button size="small" @click="openProcessListenerDialog">
<Button
class="flex flex-1 items-center justify-center"
size="small"
@click="openProcessListenerDialog"
>
<template #icon> <IconifyIcon icon="ep:select" /></template>
选择监听器
</Button>
</div>
<!-- 监听器 编辑/创建 部分 -->
<Drawer
v-model:open="listenerFormModelVisible"
title="任务监听器"
:width="width"
:destroy-on-close="true"
>
<Form :model="listenerForm" ref="listenerFormRef">
<FormItem
label="事件类型"
name="event"
:rules="[{ required: true, message: '请选择事件类型' }]"
<ListenerDrawer class="w-2/5">
<template #default>
<Form
:label-col="{ span: 6 }"
:model="listenerForm"
:wrapper-col="{ span: 18 }"
ref="listenerFormRef"
>
<Select v-model:value="listenerForm.event">
<SelectOption
v-for="i in Object.keys(listenerEventTypeObject)"
:key="i"
:value="i"
>
{{ listenerEventTypeObject[i as keyof typeof eventType] }}
</SelectOption>
</Select>
</FormItem>
<FormItem
label="监听器ID"
name="id"
:rules="[{ required: true, message: '请输入监听器ID' }]"
>
<Input v-model:value="listenerForm.id" allow-clear />
</FormItem>
<FormItem
label="监听器类型"
name="listenerType"
:rules="[{ required: true, message: '请选择监听器类型' }]"
>
<Select v-model:value="listenerForm.listenerType">
<SelectOption
v-for="i in Object.keys(listenerTypeObject)"
:key="i"
:value="i"
>
{{ listenerTypeObject[i as keyof typeof listenerType] }}
</SelectOption>
</Select>
</FormItem>
<FormItem
v-if="listenerForm.listenerType === 'classListener'"
label="Java类"
name="class"
key="listener-class"
:rules="[{ required: true, message: '请输入Java类' }]"
>
<Input v-model:value="listenerForm.class" allow-clear />
</FormItem>
<FormItem
v-if="listenerForm.listenerType === 'expressionListener'"
label="表达式"
name="expression"
key="listener-expression"
:rules="[{ required: true, message: '请输入表达式' }]"
>
<Input v-model:value="listenerForm.expression" allow-clear />
</FormItem>
<FormItem
v-if="listenerForm.listenerType === 'delegateExpressionListener'"
label="代理表达式"
name="delegateExpression"
key="listener-delegate"
:rules="[{ required: true, message: '请输入代理表达式' }]"
>
<Input v-model:value="listenerForm.delegateExpression" allow-clear />
</FormItem>
<template v-if="listenerForm.listenerType === 'scriptListener'">
<FormItem
label="脚本格式"
name="scriptFormat"
key="listener-script-format"
:rules="[{ required: true, message: '请填写脚本格式' }]"
label="事件类型"
name="event"
:rules="[{ required: true, message: '请选择事件类型' }]"
>
<Input v-model:value="listenerForm.scriptFormat" allow-clear />
</FormItem>
<FormItem
label="脚本类型"
name="scriptType"
key="listener-script-type"
:rules="[{ required: true, message: '请选择脚本类型' }]"
>
<Select v-model:value="listenerForm.scriptType">
<SelectOption value="inlineScript">内联脚本</SelectOption>
<SelectOption value="externalScript">外部脚本</SelectOption>
<Select v-model:value="listenerForm.event">
<SelectOption
v-for="i in Object.keys(listenerEventTypeObject)"
:key="i"
:value="i"
>
{{ listenerEventTypeObject[i as keyof typeof eventType] }}
</SelectOption>
</Select>
</FormItem>
<FormItem
v-if="listenerForm.scriptType === 'inlineScript'"
label="脚本内容"
name="value"
key="listener-script"
:rules="[{ required: true, message: '请填写脚本内容' }]"
label="监听器ID"
name="id"
:rules="[{ required: true, message: '请输入监听器ID' }]"
>
<Input v-model:value="listenerForm.value" allow-clear />
<Input v-model:value="listenerForm.id" allow-clear />
</FormItem>
<FormItem
v-if="listenerForm.scriptType === 'externalScript'"
label="资源地址"
name="resource"
key="listener-resource"
:rules="[{ required: true, message: '请填写资源地址' }]"
label="监听器类型"
name="listenerType"
:rules="[{ required: true, message: '请选择监听器类型' }]"
>
<Input v-model:value="listenerForm.resource" allow-clear />
</FormItem>
</template>
<template v-if="listenerForm.event === 'timeout'">
<FormItem
label="定时器类型"
name="eventDefinitionType"
key="eventDefinitionType"
>
<Select v-model:value="listenerForm.eventDefinitionType">
<SelectOption value="date">日期</SelectOption>
<SelectOption value="duration">持续时长</SelectOption>
<SelectOption value="cycle">循环</SelectOption>
<SelectOption value="null">无</SelectOption>
<Select v-model:value="listenerForm.listenerType">
<SelectOption
v-for="i in Object.keys(listenerTypeObject)"
:key="i"
:value="i"
>
{{ listenerTypeObject[i as keyof typeof listenerType] }}
</SelectOption>
</Select>
</FormItem>
<FormItem
v-if="
!!listenerForm.eventDefinitionType &&
listenerForm.eventDefinitionType !== 'null'
"
label="定时器"
name="eventTimeDefinitions"
key="eventTimeDefinitions"
:rules="[{ required: true, message: '请填写定时器配置' }]"
v-if="listenerForm.listenerType === 'classListener'"
label="Java类"
name="class"
key="listener-class"
:rules="[{ required: true, message: '请输入Java类' }]"
>
<Input v-model:value="listenerForm.class" allow-clear />
</FormItem>
<FormItem
v-if="listenerForm.listenerType === 'expressionListener'"
label="表达式"
name="expression"
key="listener-expression"
:rules="[{ required: true, message: '请输入表达式' }]"
>
<Input v-model:value="listenerForm.expression" allow-clear />
</FormItem>
<FormItem
v-if="listenerForm.listenerType === 'delegateExpressionListener'"
label="代理表达式"
name="delegateExpression"
key="listener-delegate"
:rules="[{ required: true, message: '请输入代理表达式' }]"
>
<Input
v-model:value="listenerForm.eventTimeDefinitions"
v-model:value="listenerForm.delegateExpression"
allow-clear
/>
</FormItem>
</template>
</Form>
<Divider />
<div class="mb-2 flex justify-between">
<span class="flex items-center">
<IconifyIcon icon="ep:menu" class="mr-2 text-gray-600" />
注入字段
</span>
<Button
type="primary"
title="添加字段"
@click="openListenerFieldForm(null)"
>
<template #icon>
<IconifyIcon icon="ep:plus" />
<template v-if="listenerForm.listenerType === 'scriptListener'">
<FormItem
label="脚本格式"
name="scriptFormat"
key="listener-script-format"
:rules="[{ required: true, message: '请填写脚本格式' }]"
>
<Input v-model:value="listenerForm.scriptFormat" allow-clear />
</FormItem>
<FormItem
label="脚本类型"
name="scriptType"
key="listener-script-type"
:rules="[{ required: true, message: '请选择脚本类型' }]"
>
<Select v-model:value="listenerForm.scriptType">
<SelectOption value="inlineScript">内联脚本</SelectOption>
<SelectOption value="externalScript">外部脚本</SelectOption>
</Select>
</FormItem>
<FormItem
v-if="listenerForm.scriptType === 'inlineScript'"
label="脚本内容"
name="value"
key="listener-script"
:rules="[{ required: true, message: '请填写脚本内容' }]"
>
<Input v-model:value="listenerForm.value" allow-clear />
</FormItem>
<FormItem
v-if="listenerForm.scriptType === 'externalScript'"
label="资源地址"
name="resource"
key="listener-resource"
:rules="[{ required: true, message: '请填写资源地址' }]"
>
<Input v-model:value="listenerForm.resource" allow-clear />
</FormItem>
</template>
添加字段
</Button>
</div>
<Table
:data="fieldsListOfListener"
size="small"
:scroll="{ y: 240 }"
bordered
style="flex: none"
>
<TableColumn title="序号" width="50px" type="index" />
<TableColumn title="字段名称" width="100px" data-index="name" />
<TableColumn
title="字段类型"
width="80px"
:ellipsis="{ showTitle: true }"
:custom-render="
({ record }: any) =>
fieldTypeObject[record.fieldType as keyof typeof fieldType]
"
/>
<TableColumn
title="字段值/表达式"
width="100px"
:ellipsis="{ showTitle: true }"
:custom-render="
({ record }: any) => record.string || record.expression
"
/>
<TableColumn title="操作" width="100px">
<template #default="{ record, index }">
<template v-if="listenerForm.event === 'timeout'">
<FormItem
label="定时器类型"
name="eventDefinitionType"
key="eventDefinitionType"
>
<Select v-model:value="listenerForm.eventDefinitionType">
<SelectOption value="date">日期</SelectOption>
<SelectOption value="duration">持续时长</SelectOption>
<SelectOption value="cycle">循环</SelectOption>
<SelectOption value="null"></SelectOption>
</Select>
</FormItem>
<FormItem
v-if="
!!listenerForm.eventDefinitionType &&
listenerForm.eventDefinitionType !== 'null'
"
label="定时器"
name="eventTimeDefinitions"
key="eventTimeDefinitions"
:rules="[{ required: true, message: '请填写定时器配置' }]"
>
<Input
v-model:value="listenerForm.eventTimeDefinitions"
allow-clear
/>
</FormItem>
</template>
</Form>
<Divider />
<div class="mb-2 flex justify-between">
<span class="flex items-center">
<IconifyIcon icon="ep:menu" class="mr-2 text-gray-600" />
注入字段
</span>
<Button
class="flex items-center"
size="small"
type="link"
@click="openListenerFieldForm(null)"
>
<template #icon>
<IconifyIcon class="size-4" icon="ep:plus" />
</template>
添加字段
</Button>
</div>
<FieldsGrid>
<template #action="{ row, rowIndex }">
<Button
size="small"
type="link"
@click="openListenerFieldForm(record, index)"
@click="openListenerFieldForm(row, rowIndex)"
>
编辑
</Button>
@@ -509,87 +544,19 @@ watch(
size="small"
type="link"
danger
@click="removeListenerField(record, index)"
@click="removeListenerField(row, rowIndex)"
>
移除
</Button>
</template>
</TableColumn>
</Table>
<div class="element-drawer__button">
<Button size="small" @click="listenerFormModelVisible = false">
取 消
</Button>
<Button size="small" type="primary" @click="saveListenerConfig">
保 存
</Button>
</div>
</Drawer>
</FieldsGrid>
</template>
</ListenerDrawer>
<!-- 注入字段 编辑/创建 部分 -->
<Modal
title="字段配置"
v-model:open="listenerFieldFormModelVisible"
:width="600"
:destroy-on-close="true"
>
<Form :model="listenerFieldForm" ref="listenerFieldFormRef">
<FormItem
label="字段名称"
name="name"
:rules="[{ required: true, message: '请输入字段名称' }]"
>
<Input v-model:value="listenerFieldForm.name" allow-clear />
</FormItem>
<FormItem
label="字段类型"
name="fieldType"
:rules="[{ required: true, message: '请选择字段类型' }]"
>
<Select v-model:value="listenerFieldForm.fieldType">
<SelectOption
v-for="i in Object.keys(fieldTypeObject)"
:key="i"
:value="i"
>
{{ fieldTypeObject[i as keyof typeof fieldType] }}
</SelectOption>
</Select>
</FormItem>
<FormItem
v-if="listenerFieldForm.fieldType === 'string'"
label="字段值"
name="string"
key="field-string"
:rules="[{ required: true, message: '请输入字段值' }]"
>
<Input v-model:value="listenerFieldForm.string" allow-clear />
</FormItem>
<FormItem
v-if="listenerFieldForm.fieldType === 'expression'"
label="表达式"
name="expression"
key="field-expression"
:rules="[{ required: true, message: '请输入表达式' }]"
>
<Input v-model:value="listenerFieldForm.expression" allow-clear />
</FormItem>
</Form>
<template #footer>
<Button size="small" @click="listenerFieldFormModelVisible = false">
取 消
</Button>
<Button size="small" type="primary" @click="saveListenerFiled">
确 定
</Button>
</template>
</Modal>
<FieldModal @confirm="saveListenerField" />
</div>
<!-- 选择弹窗 -->
<ProcessListenerDialog
ref="processListenerDialogRef"
@select="selectProcessListener"
/>
<ProcessListenerSelectModalComp @select="selectProcessListener" />
</template>

View File

@@ -53,6 +53,7 @@ export function initListenerForm2(processListener: any) {
class: processListener.value,
event: processListener.event,
fields: [],
id: undefined,
};
}
case 'delegateExpression': {
@@ -61,6 +62,7 @@ export function initListenerForm2(processListener: any) {
delegateExpression: processListener.value,
event: processListener.event,
fields: [],
id: undefined,
};
}
case 'expression': {
@@ -69,6 +71,7 @@ export function initListenerForm2(processListener: any) {
expression: processListener.value,
event: processListener.event,
fields: [],
id: undefined,
};
}
// No default

View File

@@ -1,4 +1,4 @@
<!-- eslint-disable unused-imports/no-unused-vars -->
<!-- eslint-disable no-unused-vars -->
<script lang="ts" setup>
import { inject, nextTick, onBeforeUnmount, ref, toRaw, watch } from 'vue';
@@ -73,6 +73,7 @@ declare global {
const bpmnInstances = () => (window as any)?.bpmnInstances;
// eslint-disable-next-line unused-imports/no-unused-vars
const getElementLoop = (businessObject: any): void => {
if (!businessObject.loopCharacteristics) {
loopCharacteristics.value = 'Null';
@@ -278,6 +279,8 @@ const approveRatio = ref<number>(100);
const otherExtensions = ref<any[]>([]);
const getElementLoopNew = (): void => {
if (props.type === 'UserTask') {
const loopCharacteristics =
bpmnElement.value.businessObject?.loopCharacteristics;
const extensionElements =
bpmnElement.value.businessObject?.extensionElements ??
bpmnInstances().moddle.create('bpmn:ExtensionElements', { values: [] });
@@ -294,10 +297,25 @@ const getElementLoopNew = (): void => {
approveMethod.value = ApproveMethodType.SEQUENTIAL_APPROVE;
updateLoopCharacteristics();
}
// 如果是按比例会签,从现有 completionCondition 中解析比例,反推到 approveRatio
if (
approveMethod.value === ApproveMethodType.APPROVE_BY_RATIO &&
loopCharacteristics?.completionCondition?.body
) {
const body = loopCharacteristics.completionCondition.body as string;
// 形如 "${ nrOfCompletedInstances/nrOfInstances >= 0.9 }"
const match = body.match(/>=\s*(\d+(?:\.\d+)?)/);
if (match) {
const ratio = Number(match[1]);
if (!Number.isNaN(ratio)) {
approveRatio.value = ratio * 100;
}
}
}
}
};
const onApproveMethodChange = (): void => {
approveRatio.value = 100;
updateLoopCharacteristics();
};
const onApproveRatioChange = (): void => {
@@ -393,31 +411,29 @@ watch(
</script>
<template>
<div class="panel-tab__content">
<div class="-mx-2 px-2">
<RadioGroup
v-if="type === 'UserTask'"
v-model:value="approveMethod"
@change="onApproveMethodChange"
>
<div class="flex-col">
<div class="flex flex-col gap-3">
<div v-for="(item, index) in APPROVE_METHODS" :key="index">
<Radio :value="item.value">
{{ item.label }}
</Radio>
<FormItem prop="approveRatio">
<InputNumber
v-model:value="approveRatio"
:min="10"
:max="100"
:step="10"
size="small"
v-if="
item.value === ApproveMethodType.APPROVE_BY_RATIO &&
approveMethod === ApproveMethodType.APPROVE_BY_RATIO
"
@change="onApproveRatioChange"
/>
</FormItem>
<InputNumber
v-if="
item.value === ApproveMethodType.APPROVE_BY_RATIO &&
approveMethod === ApproveMethodType.APPROVE_BY_RATIO
"
v-model:value="approveRatio"
:min="10"
:max="100"
:step="10"
size="small"
@change="onApproveRatioChange"
/>
</div>
</div>
</RadioGroup>
@@ -510,7 +526,7 @@ watch(
</FormItem>
<FormItem
label="重试周期"
prop="timeCycle"
name="timeCycle"
v-if="loopInstanceForm.asyncAfter || loopInstanceForm.asyncBefore"
key="timeCycle"
>

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import { nextTick, onBeforeUnmount, ref, toRaw, watch } from 'vue';
import { Input } from 'ant-design-vue';
import { Textarea } from 'ant-design-vue';
defineOptions({ name: 'ElementOtherConfig' });
@@ -12,8 +12,6 @@ const props = defineProps({
},
});
const { Textarea } = Input;
const documentation = ref('');
const bpmnElement = ref();
@@ -58,10 +56,10 @@ watch(
</script>
<template>
<div class="panel-tab__content">
<div class="element-property input-property">
<div class="element-property__label">元素文档</div>
<div class="element-property__value">
<div class="px-2 py-1">
<div class="flex items-start gap-2">
<div class="w-20 pt-1 text-sm text-gray-700">元素文档</div>
<div class="flex-1">
<Textarea
v-model:value="documentation"
:auto-size="{ minRows: 2, maxRows: 4 }"

View File

@@ -1,19 +1,13 @@
<script lang="ts" setup>
import { inject, nextTick, ref, toRaw, watch } from 'vue';
import { inject, nextTick, ref, watch } from 'vue';
import { confirm, useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { cloneDeep } from '@vben/utils';
import {
Button,
Divider,
Form,
FormItem,
Input,
Modal,
Table,
TableColumn,
} from 'ant-design-vue';
import { Button, Divider, Form, FormItem, Input } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
defineOptions({ name: 'ElementProperties' });
@@ -29,13 +23,10 @@ const props = defineProps({
});
const prefix = inject('prefix');
// const width = inject('width')
const elementPropertyList = ref<Array<{ name: string; value: string }>>([]);
const propertyForm = ref<{ name?: string; value?: string }>({});
const editingPropertyIndex = ref(-1);
const propertyFormModelVisible = ref(false);
const bpmnElement = ref<any>();
const otherExtensionList = ref<any[]>([]);
const bpmnElementProperties = ref<any[]>([]);
const bpmnElementPropertyList = ref<any[]>([]);
@@ -43,116 +34,156 @@ const attributeFormRef = ref<any>();
const bpmnInstances = () => (window as any)?.bpmnInstances;
const resetAttributesList = () => {
bpmnElement.value = bpmnInstances().bpmnElement;
const instances = bpmnInstances();
if (!instances || !instances.bpmnElement) return;
// 直接使用原始BPMN元素避免Vue响应式代理问题
const bpmnElement = instances.bpmnElement;
const businessObject = bpmnElement.businessObject;
otherExtensionList.value = []; // 其他扩展配置
bpmnElementProperties.value =
// bpmnElement.value.businessObject?.extensionElements?.filter((ex) => {
bpmnElement.value.businessObject?.extensionElements?.values?.filter(
(ex: any) => {
if (ex.$type !== `${prefix}:Properties`) {
otherExtensionList.value.push(ex);
}
return ex.$type === `${prefix}:Properties`;
},
) ?? [];
businessObject?.extensionElements?.values?.filter((ex: any) => {
if (ex.$type !== `${prefix}:Properties`) {
otherExtensionList.value.push(ex);
}
return ex.$type === `${prefix}:Properties`;
}) ?? [];
// 保存所有的 扩展属性字段
bpmnElementPropertyList.value = bpmnElementProperties.value.flatMap(
(current: any) => current.values,
);
// 复制 显示
elementPropertyList.value = cloneDeep(bpmnElementPropertyList.value ?? []);
};
const openAttributesForm = (
attr: null | { name: string; value: string },
index: number,
) => {
editingPropertyIndex.value = index;
// @ts-ignore
propertyForm.value = index === -1 ? {} : cloneDeep(attr);
propertyFormModelVisible.value = true;
nextTick(() => {
if (attributeFormRef.value) attributeFormRef.value.clearValidate();
});
};
const removeAttributes = (
_attr: { name: string; value: string },
index: number,
) => {
Modal.confirm({
confirm({
title: '提示',
content: '确认移除该属性吗?',
okText: '确 认',
cancelText: '取 消',
onOk() {
elementPropertyList.value.splice(index, 1);
bpmnElementPropertyList.value.splice(index, 1);
// 新建一个属性字段的保存列表
const propertiesObject = bpmnInstances().moddle.create(
`${prefix}:Properties`,
{
values: bpmnElementPropertyList.value,
},
);
updateElementExtensions(propertiesObject);
resetAttributesList();
},
onCancel() {
// console.info('操作取消');
},
});
};
const saveAttribute = () => {
// console.log(propertyForm.value, 'propertyForm.value');
const { name, value } = propertyForm.value;
if (editingPropertyIndex.value === -1) {
// 新建属性字段
const newPropertyObject = bpmnInstances().moddle.create(
`${prefix}:Property`,
{
name,
value,
},
);
// 新建一个属性字段的保存列表
}).then(() => {
elementPropertyList.value.splice(index, 1);
bpmnElementPropertyList.value.splice(index, 1);
const propertiesObject = bpmnInstances().moddle.create(
`${prefix}:Properties`,
{
values: [...bpmnElementPropertyList.value, newPropertyObject],
values: bpmnElementPropertyList.value,
},
);
updateElementExtensions(propertiesObject);
resetAttributesList();
});
};
const saveAttribute = async () => {
try {
await attributeFormRef.value?.validate();
} catch {
// 校验未通过,直接返回
return;
}
const { name, value } = propertyForm.value;
const instances = bpmnInstances();
if (!instances || !instances.bpmnElement) return;
const bpmnElement = instances.bpmnElement;
if (editingPropertyIndex.value === -1) {
// 新建属性字段
const newPropertyObject = instances.moddle.create(`${prefix}:Property`, {
name,
value,
});
// 新建一个属性字段的保存列表
const propertiesObject = instances.moddle.create(`${prefix}:Properties`, {
values: [...bpmnElementPropertyList.value, newPropertyObject],
});
updateElementExtensions(propertiesObject);
} else {
bpmnInstances().modeling.updateModdleProperties(
toRaw(bpmnElement.value),
toRaw(bpmnElementPropertyList.value)[toRaw(editingPropertyIndex.value)],
instances.modeling.updateModdleProperties(
bpmnElement,
bpmnElementPropertyList.value[editingPropertyIndex.value],
{
name,
value,
},
);
}
propertyFormModelVisible.value = false;
fieldModalApi.close();
resetAttributesList();
};
const updateElementExtensions = (properties: any) => {
const extensions = bpmnInstances().moddle.create('bpmn:ExtensionElements', {
const instances = bpmnInstances();
if (!instances || !instances.bpmnElement) return;
const bpmnElement = instances.bpmnElement;
const extensions = instances.moddle.create('bpmn:ExtensionElements', {
values: [...otherExtensionList.value, properties],
});
bpmnInstances().modeling.updateProperties(toRaw(bpmnElement.value), {
instances.modeling.updateProperties(bpmnElement, {
extensionElements: extensions,
});
};
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: [
{ type: 'seq', width: 50, title: '序号' },
{ field: 'name', title: '属性名', minWidth: 120 },
{ field: 'value', title: '属性值', minWidth: 120 },
{
title: '操作',
width: 120,
slots: { default: 'action' },
fixed: 'right',
},
],
border: true,
showOverflow: true,
height: 'auto',
toolbarConfig: {
enabled: false,
},
pagerConfig: {
enabled: false,
},
},
});
const [FieldModal, fieldModalApi] = useVbenModal({
title: '属性配置',
onConfirm: saveAttribute,
});
const openAttributesForm = (
attr: null | { name: string; value: string },
index: number,
) => {
editingPropertyIndex.value = index;
propertyForm.value = index === -1 ? {} : cloneDeep(attr || {});
fieldModalApi.open();
nextTick(() => {
if (attributeFormRef.value) attributeFormRef.value.clearValidate();
});
};
watch(
elementPropertyList,
(val) => {
gridApi.setGridOptions({ data: val });
},
{ deep: true },
);
watch(
() => props.id,
(val) => {
if (val) {
val && val.length > 0 && resetAttributesList();
if (val && val.length > 0) {
resetAttributesList();
}
},
{ immediate: true },
@@ -160,38 +191,34 @@ watch(
</script>
<template>
<div class="panel-tab__content">
<Table :data="elementPropertyList" size="small" bordered>
<TableColumn title="序号" width="50">
<template #default="{ index }">
{{ index + 1 }}
</template>
</TableColumn>
<TableColumn title="属性名" data-index="name" />
<TableColumn title="属性值" data-index="value" />
<TableColumn title="操作">
<template #default="{ record, index }">
<Button
type="link"
@click="openAttributesForm(record, index)"
size="small"
>
编辑
</Button>
<Divider type="vertical" />
<Button
type="link"
size="small"
danger
@click="removeAttributes(record, index)"
>
移除
</Button>
</template>
</TableColumn>
</Table>
<div class="element-drawer__button">
<Button type="primary" @click="openAttributesForm(null, -1)">
<div class="-mx-2">
<Grid :data="elementPropertyList">
<template #action="{ row, rowIndex }">
<Button
size="small"
type="link"
@click="openAttributesForm(row, rowIndex)"
>
编辑
</Button>
<Divider type="vertical" />
<Button
size="small"
type="link"
danger
@click="removeAttributes(row, rowIndex)"
>
移除
</Button>
</template>
</Grid>
<div class="mt-1 flex w-full items-center justify-center gap-2 px-2">
<Button
class="flex flex-1 items-center justify-center"
type="primary"
size="small"
@click="openAttributesForm(null, -1)"
>
<template #icon>
<IconifyIcon icon="ep:plus" />
</template>
@@ -199,24 +226,28 @@ watch(
</Button>
</div>
<Modal
v-model:open="propertyFormModelVisible"
title="属性配置"
:width="600"
:destroy-on-close="true"
>
<Form :model="propertyForm" ref="attributeFormRef">
<FormItem label="属性名:" name="name">
<FieldModal class="w-3/5">
<Form
:model="propertyForm"
ref="attributeFormRef"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 17 }"
>
<FormItem
label="属性名:"
name="name"
:rules="[{ required: true, message: '请输入属性名' }]"
>
<Input v-model:value="propertyForm.name" allow-clear />
</FormItem>
<FormItem label="属性值:" name="value">
<FormItem
label="属性值:"
name="value"
:rules="[{ required: true, message: '请输入属性值' }]"
>
<Input v-model:value="propertyForm.value" allow-clear />
</FormItem>
</Form>
<template #footer>
<Button @click="propertyFormModelVisible = false"> </Button>
<Button type="primary" @click="saveAttribute"> </Button>
</template>
</Modal>
</FieldModal>
</div>
</template>

View File

@@ -1,37 +1,34 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { onMounted, ref, watch } from 'vue';
import { confirm, useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import {
Button,
Form,
FormItem,
Input,
message,
Modal,
Table,
TableColumn,
} from 'ant-design-vue';
import { Button, Divider, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import SignalMessageModal from './SignalMessageModal.vue';
defineOptions({ name: 'SignalAndMassage' });
const signalList = ref<any[]>([]);
const messageList = ref<any[]>([]);
const dialogVisible = ref(false);
const modelType = ref('');
const modelObjectForm = ref<any>({});
const modelType = ref<'message' | 'signal'>('message');
const rootElements = ref();
const messageIdMap = ref();
const signalIdMap = ref();
const modelConfig = computed(() => {
return modelType.value === 'message'
? { title: '创建消息', idLabel: '消息ID', nameLabel: '消息名称' }
: { title: '创建信号', idLabel: '信号ID', nameLabel: '信号名称' };
});
const editingIndex = ref(-1); // 正在编辑的索引,-1 表示新建
const bpmnInstances = () => (window as any)?.bpmnInstances;
// 生成规范化的ID
const generateStandardId = (type: string): string => {
const prefix = type === 'message' ? 'Message_' : 'Signal_';
const timestamp = Date.now();
const random = Math.random().toString(36).slice(2, 6).toUpperCase();
return `${prefix}${timestamp}_${random}`;
};
const initDataList = () => {
// console.log(window, 'window');
rootElements.value = bpmnInstances().modeler.getDefinitions().rootElements;
messageIdMap.value = {};
signalIdMap.value = {};
@@ -48,103 +45,289 @@ const initDataList = () => {
}
});
};
const openModel = (type: any) => {
const openModel = (type: 'message' | 'signal') => {
modelType.value = type;
modelObjectForm.value = {};
dialogVisible.value = true;
editingIndex.value = -1;
modalApi
.setData({
id: generateStandardId(type),
isEdit: false,
name: '',
type,
})
.open();
};
const addNewObject = () => {
const openEditModel = (type: 'message' | 'signal', row: any, index: number) => {
modelType.value = type;
editingIndex.value = index;
modalApi
.setData({
id: row.id,
isEdit: true,
name: row.name,
type,
})
.open();
};
const handleConfirm = (formData: { id: string; name: string }) => {
if (modelType.value === 'message') {
if (messageIdMap.value[modelObjectForm.value.id]) {
message.error('该消息已存在请修改id后重新保存');
if (editingIndex.value === -1) {
// 新建模式
if (messageIdMap.value[formData.id]) {
message.error('该消息已存在请修改id后重新保存');
return;
}
const messageRef = bpmnInstances().moddle.create(
'bpmn:Message',
formData,
);
rootElements.value.push(messageRef);
} else {
// 编辑模式
const targetMessage = messageList.value[editingIndex.value];
const rootMessage = rootElements.value.find(
(el: any) => el.$type === 'bpmn:Message' && el.id === targetMessage.id,
);
if (rootMessage) {
rootMessage.id = formData.id;
rootMessage.name = formData.name;
}
}
const messageRef = bpmnInstances().moddle.create(
'bpmn:Message',
modelObjectForm.value,
);
rootElements.value.push(messageRef);
} else {
if (signalIdMap.value[modelObjectForm.value.id]) {
message.error('该信号已存在请修改id后重新保存');
if (editingIndex.value === -1) {
// 新建模式
if (signalIdMap.value[formData.id]) {
message.error('该信号已存在请修改id后重新保存');
return;
}
const signalRef = bpmnInstances().moddle.create('bpmn:Signal', formData);
rootElements.value.push(signalRef);
} else {
// 编辑模式
const targetSignal = signalList.value[editingIndex.value];
const rootSignal = rootElements.value.find(
(el: any) => el.$type === 'bpmn:Signal' && el.id === targetSignal.id,
);
if (rootSignal) {
rootSignal.id = formData.id;
rootSignal.name = formData.name;
}
}
const signalRef = bpmnInstances().moddle.create(
'bpmn:Signal',
modelObjectForm.value,
);
rootElements.value.push(signalRef);
}
dialogVisible.value = false;
// 触发建模器更新以保存更改
saveChanges();
initDataList();
};
// 补充"编辑"、"移除"功能。相关 issuehttps://github.com/YunaiV/yudao-cloud/issues/270
const removeObject = (type: any, row: any) => {
confirm({
title: '提示',
content: `确认移除该${type === 'message' ? '消息' : '信号'}吗?`,
}).then(() => {
// 从 rootElements 中移除
const targetType = type === 'message' ? 'bpmn:Message' : 'bpmn:Signal';
const elementIndex = rootElements.value.findIndex(
(el: any) => el.$type === targetType && el.id === row.id,
);
if (elementIndex !== -1) {
rootElements.value.splice(elementIndex, 1);
}
// 刷新列表
initDataList();
message.success('移除成功');
});
};
// 触发建模器更新以保存更改
const saveChanges = () => {
const modeler = bpmnInstances().modeler;
if (!modeler) return;
try {
// 获取 canvas通过它来触发图表的重新渲染
const canvas = modeler.get('canvas');
// 获取根元素Process
const rootElement = canvas.getRootElement();
// 触发 changed 事件,通知建模器数据已更改
const eventBus = modeler.get('eventBus');
if (eventBus) {
eventBus.fire('root.added', { element: rootElement });
eventBus.fire('elements.changed', { elements: [rootElement] });
}
// 标记建模器为已修改状态
const commandStack = modeler.get('commandStack');
if (commandStack && commandStack._stack) {
// 添加一个空命令以标记为已修改
commandStack.execute('element.updateProperties', {
element: rootElement,
properties: {},
});
}
} catch (error) {
console.warn('保存更改时出错:', error);
}
};
const [MessageGrid, messageGridApi] = useVbenVxeGrid({
gridOptions: {
columns: [
{ type: 'seq', width: 50, title: '序号' },
{ field: 'id', title: '消息ID', minWidth: 120 },
{ field: 'name', title: '消息名称', minWidth: 100 },
{
title: '操作',
width: 120,
slots: { default: 'action' },
fixed: 'right',
},
],
border: true,
showOverflow: true,
height: 'auto',
toolbarConfig: {
enabled: false,
},
pagerConfig: {
enabled: false,
},
},
});
const [SignalGrid, signalGridApi] = useVbenVxeGrid({
gridOptions: {
columns: [
{ type: 'seq', width: 50, title: '序号' },
{ field: 'id', title: '信号ID', minWidth: 120 },
{ field: 'name', title: '信号名称', minWidth: 100 },
{
title: '操作',
width: 120,
slots: { default: 'action' },
fixed: 'right',
},
],
border: true,
showOverflow: true,
height: 'auto',
toolbarConfig: {
enabled: false,
},
pagerConfig: {
enabled: false,
},
},
});
const [Modal, modalApi] = useVbenModal({
connectedComponent: SignalMessageModal,
});
onMounted(() => {
initDataList();
});
watch(
messageList,
(val) => {
messageGridApi.setGridOptions({ data: val });
},
{ deep: true },
);
watch(
signalList,
(val) => {
signalGridApi.setGridOptions({ data: val });
},
{ deep: true },
);
</script>
<template>
<div class="panel-tab__content">
<div class="panel-tab__content--title">
<div class="-mx-2">
<div class="mb-2 flex items-center justify-between">
<span class="flex items-center">
<IconifyIcon icon="ep:menu" class="mr-2 text-gray-600" />
消息列表
</span>
<Button type="primary" title="创建新消息" @click="openModel('message')">
<Button
class="flex items-center"
size="small"
type="link"
@click="openModel('message')"
>
<template #icon>
<IconifyIcon icon="ep:plus" />
</template>
创建新消息
</Button>
</div>
<Table :data-source="messageList" size="small" bordered>
<TableColumn title="序号" width="60px">
<template #default="{ index }">
{{ index + 1 }}
</template>
</TableColumn>
<TableColumn title="消息ID" data-index="id" />
<TableColumn title="消息名称" data-index="name" />
</Table>
<div class="panel-tab__content--title mt-2 border-t border-gray-200 pt-2">
<MessageGrid :data="messageList">
<template #action="{ row, rowIndex }">
<Button
size="small"
type="link"
@click="openEditModel('message', row, rowIndex)"
>
编辑
</Button>
<Divider type="vertical" />
<Button
size="small"
type="link"
danger
@click="removeObject('message', row)"
>
移除
</Button>
</template>
</MessageGrid>
<div
class="mb-2 mt-2 flex items-center justify-between border-t border-gray-200 pt-2"
>
<span class="flex items-center">
<IconifyIcon icon="ep:menu" class="mr-2 text-gray-600" />
信号列表
</span>
<Button type="primary" title="创建新信号" @click="openModel('signal')">
<Button
class="flex items-center"
size="small"
type="link"
@click="openModel('signal')"
>
<template #icon>
<IconifyIcon icon="ep:plus" />
</template>
创建新信号
</Button>
</div>
<Table :data-source="signalList" size="small" bordered>
<TableColumn title="序号" width="60px">
<template #default="{ index }">
{{ index + 1 }}
</template>
</TableColumn>
<TableColumn title="信号ID" data-index="id" />
<TableColumn title="信号名称" data-index="name" />
</Table>
<Modal
v-model:open="dialogVisible"
:title="modelConfig.title"
:mask-closable="false"
width="400px"
:destroy-on-close="true"
>
<Form :model="modelObjectForm">
<FormItem :label="modelConfig.idLabel">
<Input v-model:value="modelObjectForm.id" allow-clear />
</FormItem>
<FormItem :label="modelConfig.nameLabel">
<Input v-model:value="modelObjectForm.name" allow-clear />
</FormItem>
</Form>
<template #footer>
<Button @click="dialogVisible = false"> </Button>
<Button type="primary" @click="addNewObject"> </Button>
<SignalGrid :data="signalList">
<template #action="{ row, rowIndex }">
<Button
size="small"
type="link"
@click="openEditModel('signal', row, rowIndex)"
>
编辑
</Button>
<Divider type="vertical" />
<Button
size="small"
type="link"
danger
@click="removeObject('signal', row)"
>
移除
</Button>
</template>
</Modal>
</SignalGrid>
<Modal @confirm="handleConfirm" />
</div>
</template>

View File

@@ -0,0 +1,90 @@
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Form, FormItem, Input } from 'ant-design-vue';
defineOptions({ name: 'SignalMessageModal' });
const emit = defineEmits<{
confirm: [data: { id: string; name: string }];
}>();
const formRef = ref();
const form = ref<{ id: string; name: string }>({ id: '', name: '' });
const modelType = ref<'message' | 'signal'>('message');
const isEdit = ref(false);
const config = computed(() => {
return modelType.value === 'message'
? {
title: isEdit.value ? '编辑消息' : '创建消息',
idLabel: '消息 ID',
nameLabel: '消息名称',
}
: {
title: isEdit.value ? '编辑信号' : '创建信号',
idLabel: '信号 ID',
nameLabel: '信号名称',
};
});
const [Modal, modalApi] = useVbenModal({
onOpenChange(isOpen) {
if (isOpen) {
const data = modalApi.getData<{
id?: string;
isEdit?: boolean;
name?: string;
type: 'message' | 'signal';
}>();
modelType.value = data?.type || 'message';
isEdit.value = data?.isEdit || false;
form.value = {
id: data?.id || '',
name: data?.name || '',
};
// 清除校验
setTimeout(() => {
formRef.value?.clearValidate();
}, 50);
}
},
async onConfirm() {
try {
await formRef.value?.validate();
emit('confirm', { ...form.value });
modalApi.close();
} catch {
// 校验未通过
}
},
});
</script>
<template>
<Modal :title="config.title" class="w-3/5">
<Form
ref="formRef"
:model="form"
:label-col="{ span: 4 }"
:wrapper-col="{ span: 18 }"
>
<FormItem
:label="config.idLabel"
name="id"
:rules="[{ required: true, message: '请输入 ID' }]"
>
<Input v-model:value="form.id" allow-clear />
</FormItem>
<FormItem
:label="config.nameLabel"
name="name"
:rules="[{ required: true, message: '请输入名称' }]"
>
<Input v-model:value="form.name" allow-clear />
</FormItem>
</Form>
</Modal>
</template>

View File

@@ -1,8 +1,8 @@
<script lang="ts" setup>
import { h, inject, nextTick, ref, toRaw, watch } from 'vue';
import { inject, nextTick, onMounted, ref, toRaw, watch } from 'vue';
import { alert } from '@vben/common-ui';
import { PlusOutlined } from '@vben/icons';
import { confirm, useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import {
Button,
@@ -10,12 +10,14 @@ import {
Form,
FormItem,
Input,
Modal,
Select,
SelectOption,
Switch,
Table,
TableColumn,
} from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getModelList } from '#/api/bpm/model';
interface FormData {
processInstanceName: string;
calledElement: string;
@@ -44,8 +46,7 @@ const inVariableList = ref<any[]>([]);
const outVariableList = ref<any[]>([]);
const variableType = ref<string>(); // 参数类型
const editingVariableIndex = ref<number>(-1); // 编辑参数下标
const variableDialogVisible = ref<boolean>(false);
const varialbeFormRef = ref<any>();
const varialbeFormRef = ref();
const varialbeFormData = ref<{
source: string;
target: string;
@@ -57,10 +58,10 @@ const varialbeFormData = ref<{
const bpmnInstances = () => (window as any)?.bpmnInstances;
const bpmnElement = ref<any>();
const otherExtensionList = ref<any[]>([]);
const childProcessOptions = ref<{ key: string; name: string }[]>([]);
const initCallActivity = () => {
bpmnElement.value = bpmnInstances().bpmnElement;
// console.log(bpmnElement.value.businessObject, 'callActivity');
// 初始化所有配置项
Object.keys(formData.value).forEach((key: string) => {
@@ -85,11 +86,6 @@ const initCallActivity = () => {
}
},
);
// 默认添加
// bpmnInstances().modeling.updateProperties(toRaw(bpmnElement.value), {
// calledElementType: 'key'
// })
};
const updateCallActivityAttr = (attr: keyof FormData) => {
@@ -98,16 +94,26 @@ const updateCallActivityAttr = (attr: keyof FormData) => {
});
};
const [VariableModal, variableModalApi] = useVbenModal({
title: '参数配置',
onConfirm: () => {
saveVariable();
},
});
const openVariableForm = (type: string, data: any, index: number) => {
editingVariableIndex.value = index;
variableType.value = type;
varialbeFormData.value = index === -1 ? {} : { ...data };
variableDialogVisible.value = true;
variableModalApi.open();
};
const removeVariable = async (type: string, index: number) => {
try {
await alert('是否确认删除?');
await confirm({
title: '提示',
content: '是否确认删除?',
});
if (type === 'in') {
inVariableList.value.splice(index, 1);
}
@@ -115,10 +121,19 @@ const removeVariable = async (type: string, index: number) => {
outVariableList.value.splice(index, 1);
}
updateElementExtensions();
} catch {}
} catch (error: any) {
console.error(`[removeVariable error ]: ${error.message || error}`);
}
};
const saveVariable = () => {
const saveVariable = async () => {
try {
await varialbeFormRef.value?.validate();
} catch {
// 验证失败直接返回
return;
}
if (editingVariableIndex.value === -1) {
if (variableType.value === 'in') {
inVariableList.value.push(
@@ -149,7 +164,7 @@ const saveVariable = () => {
varialbeFormData.value.target;
}
}
variableDialogVisible.value = false;
variableModalApi.close();
};
const updateElementExtensions = () => {
@@ -176,28 +191,93 @@ watch(
},
{ immediate: true },
);
const gridOptions = {
columns: [
{ title: '源', field: 'source', minWidth: 100 },
{ title: '目标', field: 'target', minWidth: 100 },
{
title: '操作',
width: 130,
slots: { default: 'action' },
fixed: 'right' as const,
},
],
border: true,
showOverflow: true,
height: 'auto',
toolbarConfig: { enabled: false },
pagerConfig: { enabled: false },
};
const [InVariableGrid, inVariableGridApi] = useVbenVxeGrid({
gridOptions,
});
const [OutVariableGrid, outVariableGridApi] = useVbenVxeGrid({
gridOptions,
});
// 使用浅层监听,避免无限循环
watch(
() => [...inVariableList.value],
(val) => {
inVariableGridApi.setGridOptions({ data: val });
},
);
watch(
() => [...outVariableList.value],
(val) => {
outVariableGridApi.setGridOptions({ data: val });
},
);
/** 选择子流程, 更新 bpmn callActivity calledElement 和 processInstanceName 属性 */
const handleChildProcessChange = (key: any) => {
if (!key) return;
const selected = childProcessOptions.value.find((item) => item.key === key);
if (selected) {
formData.value.calledElement = selected.key;
formData.value.processInstanceName = selected.name;
updateCallActivityAttr('calledElement');
updateCallActivityAttr('processInstanceName');
}
};
onMounted(async () => {
try {
// 获取流程模型列表
const list = await getModelList(undefined);
childProcessOptions.value = list.map((item) => ({
key: item.key,
name: item.name,
}));
} catch (error) {
console.error('获取子流程列表失败', error);
}
});
</script>
<template>
<div>
<Form>
<FormItem label="实例名称">
<Input
v-model:value="formData.processInstanceName"
allow-clear
placeholder="请输入实例名称"
@change="updateCallActivityAttr('processInstanceName')"
/>
</FormItem>
<!-- TODO 需要可选择已存在的流程 -->
<FormItem label="被调用流程">
<Input
<div class="-mx-2">
<Form :label-col="{ span: 6 }" :wrapper-col="{ span: 18 }">
<FormItem label="被调用子流程">
<Select
v-model:value="formData.calledElement"
placeholder="请选择子流程"
allow-clear
placeholder="请输入被调用流程"
@change="updateCallActivityAttr('calledElement')"
/>
@change="handleChildProcessChange"
>
<SelectOption
v-for="item in childProcessOptions"
:key="item.key"
:value="item.key"
:label="item.name"
>
{{ item.name }}
</SelectOption>
</Select>
</FormItem>
<FormItem label="继承变量">
@@ -223,134 +303,115 @@ watch(
/>
</FormItem>
<Divider />
<div>
<div class="mb-10px flex">
<span>输入参数</span>
<Button
class="ml-auto"
type="primary"
:icon="h(PlusOutlined)"
title="添加参数"
size="small"
@click="openVariableForm('in', null, -1)"
/>
</div>
<Table
:data-source="inVariableList"
:scroll="{ y: 240 }"
bordered
:pagination="false"
<div
class="mb-1 mt-2 flex items-center justify-between border-t border-gray-200 pt-2"
>
<span class="flex items-center text-sm font-medium"> 输入参数 </span>
<Button
class="flex items-center"
size="small"
type="link"
@click="openVariableForm('in', null, -1)"
>
<TableColumn
title="源"
data-index="source"
:min-width="100"
:ellipsis="true"
/>
<TableColumn
title="目标"
data-index="target"
:min-width="100"
:ellipsis="true"
/>
<TableColumn title="操作" :width="110">
<template #default="{ record, index }">
<Button
type="link"
@click="openVariableForm('in', record, index)"
size="small"
>
编辑
</Button>
<Divider type="vertical" />
<Button
type="link"
size="small"
danger
@click="removeVariable('in', index)"
>
移除
</Button>
</template>
</TableColumn>
</Table>
<template #icon>
<IconifyIcon icon="ep:plus" />
</template>
添加参数
</Button>
</div>
<InVariableGrid class="-mx-2 mb-4">
<template #action="{ row, rowIndex }">
<Button
size="small"
type="link"
@click="openVariableForm('in', row, rowIndex)"
>
编辑
</Button>
<Divider type="vertical" />
<Button
size="small"
type="link"
danger
@click="removeVariable('in', rowIndex)"
>
移除
</Button>
</template>
</InVariableGrid>
<Divider />
<div>
<div class="mb-10px flex">
<span>输出参数</span>
<Button
class="ml-auto"
type="primary"
:icon="h(PlusOutlined)"
title="添加参数"
size="small"
@click="openVariableForm('out', null, -1)"
/>
</div>
<Table
:data-source="outVariableList"
:scroll="{ y: 240 }"
bordered
:pagination="false"
<div
class="mb-1 mt-2 flex items-center justify-between border-t border-gray-200 pt-2"
>
<span class="flex items-center text-sm font-medium"> 输出参数 </span>
<Button
class="flex items-center"
size="small"
type="link"
@click="openVariableForm('out', null, -1)"
>
<TableColumn
title="源"
data-index="source"
:min-width="100"
:ellipsis="true"
/>
<TableColumn
title="目标"
data-index="target"
:min-width="100"
:ellipsis="true"
/>
<TableColumn title="操作" :width="110">
<template #default="{ record, index }">
<Button
type="link"
@click="openVariableForm('out', record, index)"
size="small"
>
编辑
</Button>
<Divider type="vertical" />
<Button
type="link"
size="small"
danger
@click="removeVariable('out', index)"
>
移除
</Button>
</template>
</TableColumn>
</Table>
<template #icon>
<IconifyIcon icon="lucide:plus" class="size-4" />
</template>
添加参数
</Button>
</div>
<OutVariableGrid class="-mx-2">
<template #action="{ row, rowIndex }">
<Button
size="small"
type="link"
@click="openVariableForm('out', row, rowIndex)"
>
编辑
</Button>
<Divider type="vertical" />
<Button
size="small"
type="link"
danger
@click="removeVariable('out', rowIndex)"
>
移除
</Button>
</template>
</OutVariableGrid>
</Form>
<!-- 添加或修改参数 -->
<Modal
v-model:open="variableDialogVisible"
title="参数配置"
:width="600"
:destroy-on-close="true"
@ok="saveVariable"
@cancel="variableDialogVisible = false"
>
<Form :model="varialbeFormData" ref="varialbeFormRef">
<FormItem label="源:" name="source">
<VariableModal>
<Form
:model="varialbeFormData"
ref="varialbeFormRef"
:label-col="{ span: 4 }"
:wrapper-col="{ span: 18 }"
>
<FormItem
label="源"
name="source"
:rules="[
{
required: true,
message: '源不能为空',
trigger: ['blur', 'change'],
},
]"
>
<Input v-model:value="varialbeFormData.source" allow-clear />
</FormItem>
<FormItem label="目标:" name="target">
<FormItem
label="目标"
name="target"
:rules="[
{
required: true,
message: '目标不能为空',
trigger: ['blur', 'change'],
},
]"
>
<Input v-model:value="varialbeFormData.target" allow-clear />
</FormItem>
</Form>
</Modal>
</VariableModal>
</div>
</template>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,121 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { Button, Input } from 'ant-design-vue';
defineOptions({ name: 'HttpHeaderEditor' });
const emit = defineEmits(['save']);
interface HeaderItem {
key: string;
value: string;
}
const headerList = ref<HeaderItem[]>([]);
// 解析请求头字符串为列表
const parseHeaders = (headersStr: string): HeaderItem[] => {
if (!headersStr || !headersStr.trim()) {
return [{ key: '', value: '' }];
}
const lines = headersStr.split('\n').filter((line) => line.trim());
const parsed = lines.map((line) => {
const colonIndex = line.indexOf(':');
if (colonIndex > 0) {
return {
key: line.slice(0, Math.max(0, colonIndex)).trim(),
value: line.slice(Math.max(0, colonIndex + 1)).trim(),
};
}
return { key: line.trim(), value: '' };
});
return parsed.length > 0 ? parsed : [{ key: '', value: '' }];
};
// 将列表转换为请求头字符串
const stringifyHeaders = (headers: HeaderItem[]): string => {
return headers
.filter((item) => item.key.trim())
.map((item) => `${item.key}: ${item.value}`)
.join('\n');
};
// 添加请求头
const addHeader = () => {
headerList.value.push({ key: '', value: '' });
};
// 移除请求头
const removeHeader = (index: number) => {
if (headerList.value.length === 1) {
// 至少保留一行
headerList.value = [{ key: '', value: '' }];
} else {
headerList.value.splice(index, 1);
}
};
// 保存
const handleSave = () => {
const headersStr = stringifyHeaders(headerList.value);
emit('save', headersStr);
modalApi.close();
};
const [Modal, modalApi] = useVbenModal({
destroyOnClose: true,
onOpenChange(isOpen) {
if (!isOpen) {
return;
}
const { headers } = modalApi.getData();
headerList.value = parseHeaders(headers);
},
onConfirm: handleSave,
});
</script>
<template>
<Modal title="编辑请求头" class="w-3/5">
<div class="space-y-4">
<div class="mb-2 space-y-3 overflow-y-auto">
<div
v-for="(item, index) in headerList"
:key="index"
class="flex items-center gap-2"
>
<Input
v-model:value="item.key"
placeholder="请输入参数名"
class="w-48"
allow-clear
/>
<span class="font-medium text-gray-600">:</span>
<Input
v-model:value="item.value"
placeholder="请输入参数值 (支持表达式 ${变量名})"
class="flex-1"
allow-clear
/>
<Button type="text" danger size="small" @click="removeHeader(index)">
<template #icon>
<IconifyIcon icon="ep:delete" />
</template>
</Button>
</div>
</div>
<Button type="primary" class="w-full" @click="addHeader">
<template #icon>
<IconifyIcon icon="ep:plus" />
</template>
添加请求头
</Button>
</div>
</Modal>
</template>

View File

@@ -1,96 +0,0 @@
<!-- 表达式选择 -->
<script setup lang="ts">
import type { BpmProcessExpressionApi } from '#/api/bpm/processExpression';
import { reactive, ref } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { CommonStatusEnum } from '@vben/constants';
import { Button, Modal, Pagination, Table, TableColumn } from 'ant-design-vue';
import { getProcessExpressionPage } from '#/api/bpm/processExpression';
/** BPM 流程 表单 */
defineOptions({ name: 'ProcessExpressionDialog' });
/** 提交表单 */
const emit = defineEmits(['select']);
const dialogVisible = ref(false); // 弹窗的是否展示
const loading = ref(true); // 列表的加载中
const list = ref<BpmProcessExpressionApi.ProcessExpression[]>([]); // 列表的数据
const total = ref(0); // 列表的总页数
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
type: '',
status: CommonStatusEnum.ENABLE,
});
/** 打开弹窗 */
const open = (type: string) => {
queryParams.pageNo = 1;
queryParams.type = type;
getList();
dialogVisible.value = true;
};
defineExpose({ open }); // 提供 open 方法,用于打开弹窗
/** 查询列表 */
const getList = async () => {
loading.value = true;
try {
const data = await getProcessExpressionPage(queryParams);
list.value = data.list;
total.value = data.total;
} finally {
loading.value = false;
}
};
// 定义 select 事件,用于操作成功后的回调
const select = async (row: BpmProcessExpressionApi.ProcessExpression) => {
dialogVisible.value = false;
// 发送操作成功的事件
emit('select', row);
};
// const handleCancel = () => {
// dialogVisible.value = false;
// };
</script>
<template>
<Modal
title="请选择表达式"
v-model:open="dialogVisible"
width="1024px"
:footer="null"
>
<ContentWrap>
<Table
:loading="loading"
:data-source="list"
:pagination="false"
:scroll="{ x: 'max-content' }"
>
<TableColumn title="名字" align="center" data-index="name" />
<TableColumn title="表达式" align="center" data-index="expression" />
<TableColumn title="操作" align="center">
<template #default="{ record }">
<Button type="primary" @click="select(record)"> 选择 </Button>
</template>
</TableColumn>
</Table>
<!-- 分页 -->
<div class="mt-4 flex justify-end">
<Pagination
:total="total"
v-model:current="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
show-size-changer
@change="getList"
/>
</div>
</ContentWrap>
</Modal>
</template>

View File

@@ -1,25 +1,12 @@
<script lang="ts" setup>
import {
h,
nextTick,
onBeforeUnmount,
onMounted,
ref,
toRaw,
watch,
} from 'vue';
import { nextTick, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue';
import { PlusOutlined } from '@vben/icons';
import { useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import {
Button,
Form,
Input,
message,
Modal,
Select,
SelectOption,
} from 'ant-design-vue';
import { Button, message, Select, SelectOption } from 'ant-design-vue';
import SignalMessageModal from '../../signal-message/SignalMessageModal.vue';
defineOptions({ name: 'ReceiveTask' });
const props = defineProps({
@@ -28,40 +15,54 @@ const props = defineProps({
});
const bindMessageId = ref('');
const newMessageForm = ref<Record<string, any>>({});
const messageMap = ref<Record<string, any>>({});
const messageModelVisible = ref(false);
const bpmnElement = ref<any>();
const bpmnMessageRefsMap = ref<Record<string, any>>();
const bpmnRootElements = ref<any>();
const bpmnInstances = () => (window as any).bpmnInstances;
const getBindMessage = () => {
bpmnElement.value = bpmnInstances().bpmnElement;
bindMessageId.value =
bpmnElement.value.businessObject?.messageRef?.id || '-1';
};
const openMessageModel = () => {
messageModelVisible.value = true;
newMessageForm.value = {};
/** 生成消息 ID */
const generateMessageId = (): string => {
const timestamp = Date.now();
const random = Math.random().toString(36).slice(2, 6).toUpperCase();
return `Message_${timestamp}_${random}`;
};
const createNewMessage = () => {
if (messageMap.value[newMessageForm.value.id]) {
message.error('该消息已存在请修改id后重新保存');
/** 打开创建消息弹窗 */
const openCreateModal = () => {
modalApi
.setData({
id: generateMessageId(),
isEdit: false,
name: '',
type: 'message',
})
.open();
};
const handleConfirm = (formData: { id: string; name: string }) => {
if (messageMap.value[formData.id]) {
message.error('该消息已存在, 请修改id后重新保存');
return;
}
const newMessage = bpmnInstances().moddle.create(
'bpmn:Message',
newMessageForm.value,
);
const newMessage = bpmnInstances().moddle.create('bpmn:Message', formData);
bpmnRootElements.value.push(newMessage);
messageMap.value[newMessageForm.value.id] = newMessageForm.value.name;
// @ts-ignore
messageMap.value[formData.id] = formData.name;
if (bpmnMessageRefsMap.value) {
bpmnMessageRefsMap.value[newMessageForm.value.id] = newMessage;
bpmnMessageRefsMap.value[formData.id] = newMessage;
}
messageModelVisible.value = false;
};
const [Modal, modalApi] = useVbenModal({
connectedComponent: SignalMessageModal,
});
const updateTaskMessage = (messageId: string) => {
if (messageId === '-1') {
bpmnInstances().modeling.updateProperties(toRaw(bpmnElement.value), {
@@ -96,7 +97,6 @@ onBeforeUnmount(() => {
watch(
() => props.id,
() => {
// bpmnElement.value = bpmnInstances().bpmnElement
nextTick(() => {
getBindMessage();
});
@@ -106,56 +106,31 @@ watch(
</script>
<template>
<div style="margin-top: 16px">
<Form.Item label="消息实例">
<div
style="
display: flex;
flex-wrap: nowrap;
align-items: center;
justify-content: space-between;
"
<div class="mt-2">
<div class="mb-2 flex justify-end">
<Button type="link" size="small" class="p-0" @click="openCreateModal">
<template #icon>
<IconifyIcon class="size-4" icon="lucide:plus" />
</template>
创建新消息
</Button>
</div>
<div class="mb-1 flex items-center">
<span class="w-20 text-foreground">消息实例:</span>
<Select
v-model:value="bindMessageId"
class="w-full"
@change="(value: any) => updateTaskMessage(value)"
>
<Select
v-model:value="bindMessageId"
@change="(value: any) => updateTaskMessage(value)"
<SelectOption
v-for="key in Object.keys(messageMap)"
:key="key"
:value="key"
>
<SelectOption
v-for="key in Object.keys(messageMap)"
:value="key"
:key="key"
>
{{ messageMap[key] }}
</SelectOption>
</Select>
<Button
type="primary"
:icon="h(PlusOutlined)"
style="margin-left: 8px"
@click="openMessageModel"
/>
</div>
</Form.Item>
<Modal
v-model:open="messageModelVisible"
:mask-closable="false"
title="创建新消息"
width="400px"
:destroy-on-close="true"
>
<Form :model="newMessageForm" size="small">
<Form.Item label="消息ID">
<Input v-model:value="newMessageForm.id" allow-clear />
</Form.Item>
<Form.Item label="消息名称">
<Input v-model:value="newMessageForm.name" allow-clear />
</Form.Item>
</Form>
<template #footer>
<Button size="small" type="primary" @click="createNewMessage">
</Button>
</template>
</Modal>
{{ messageMap[key] }}
</SelectOption>
</Select>
</div>
<Modal @confirm="handleConfirm" />
</div>
</template>

View File

@@ -2,6 +2,7 @@
import { nextTick, onBeforeUnmount, ref, toRaw, watch } from 'vue';
import {
Form,
FormItem,
Input,
Select,
@@ -75,47 +76,50 @@ watch(
<template>
<div class="mt-4">
<FormItem label="脚本格式">
<Input
v-model:value="scriptTaskForm.scriptFormat"
allow-clear
@input="updateElementTask()"
@change="updateElementTask()"
/>
</FormItem>
<FormItem label="脚本类型">
<Select v-model:value="scriptTaskForm.scriptType">
<SelectOption value="inline">内联脚本</SelectOption>
<SelectOption value="external">外部资源</SelectOption>
</Select>
</FormItem>
<FormItem label="脚本" v-show="scriptTaskForm.scriptType === 'inline'">
<Textarea
v-model:value="scriptTaskForm.script"
:auto-size="{ minRows: 2, maxRows: 4 }"
allow-clear
@input="updateElementTask()"
@change="updateElementTask()"
/>
</FormItem>
<FormItem
label="资源地址"
v-show="scriptTaskForm.scriptType === 'external'"
>
<Input
v-model:value="scriptTaskForm.resource"
allow-clear
@input="updateElementTask()"
@change="updateElementTask()"
/>
</FormItem>
<FormItem label="结果变量">
<Input
v-model:value="scriptTaskForm.resultVariable"
allow-clear
@input="updateElementTask()"
@change="updateElementTask()"
/>
</FormItem>
<Form :label-col="{ span: 6 }" :wrapper-col="{ span: 18 }">
<FormItem label="脚本格式">
<Input
v-model:value="scriptTaskForm.scriptFormat"
allow-clear
@input="updateElementTask()"
@change="updateElementTask()"
/>
</FormItem>
<!-- TODO scriptType 外部资源 内联脚本 flowable 文档 https://www.flowable.com/open-source/docs/bpmn/ch07b-BPMN-Constructs#script-task 没看到到有相应的属性 -->
<FormItem label="脚本类型">
<Select v-model:value="scriptTaskForm.scriptType">
<SelectOption value="inline">内联脚本</SelectOption>
<SelectOption value="external">外部资源</SelectOption>
</Select>
</FormItem>
<FormItem label="脚本" v-show="scriptTaskForm.scriptType === 'inline'">
<Textarea
v-model:value="scriptTaskForm.script"
:auto-size="{ minRows: 2, maxRows: 4 }"
allow-clear
@input="updateElementTask()"
@change="updateElementTask()"
/>
</FormItem>
<FormItem
label="资源地址"
v-show="scriptTaskForm.scriptType === 'external'"
>
<Input
v-model:value="scriptTaskForm.resource"
allow-clear
@input="updateElementTask()"
@change="updateElementTask()"
/>
</FormItem>
<FormItem label="结果变量">
<Input
v-model:value="scriptTaskForm.resultVariable"
allow-clear
@input="updateElementTask()"
@change="updateElementTask()"
/>
</FormItem>
</Form>
</div>
</template>

View File

@@ -1,7 +1,24 @@
<!-- eslint-disable prettier/prettier -->
<script lang="ts" setup>
import { nextTick, onBeforeUnmount, ref, toRaw, watch } from 'vue';
import { inject, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { FormItem, Input, Select } from 'ant-design-vue';
import { useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import {
Button,
Form,
FormItem,
Input,
RadioButton,
RadioGroup,
Select,
Switch,
Textarea,
} from 'ant-design-vue';
import { updateElementExtensions } from '../../../utils';
import HttpHeaderEditor from './HttpHeaderEditor.vue';
defineOptions({ name: 'ServiceTask' });
const props = defineProps({
@@ -9,40 +26,305 @@ const props = defineProps({
type: { type: String, default: '' },
});
const defaultTaskForm = ref({
const prefix = (inject('prefix', 'flowable') || 'flowable') as string;
const flowableTypeKey = `${prefix}:type`;
const flowableFieldType = `${prefix}:Field`;
const HTTP_FIELD_NAMES = [
'requestMethod',
'requestUrl',
'requestHeaders',
'disallowRedirects',
'ignoreException',
'saveResponseParameters',
'resultVariablePrefix',
'saveResponseParametersTransient',
'saveResponseVariableAsJson',
];
const HTTP_BOOLEAN_FIELDS = new Set([
'disallowRedirects',
'ignoreException',
'saveResponseParameters',
'saveResponseParametersTransient',
'saveResponseVariableAsJson',
]);
const DEFAULT_TASK_FORM = {
executeType: '',
class: '',
expression: '',
delegateExpression: '',
});
};
const serviceTaskForm = ref<any>({});
const DEFAULT_HTTP_FORM = {
requestMethod: 'GET',
requestUrl: '',
requestHeaders: 'Content-Type: application/json',
resultVariablePrefix: '',
disallowRedirects: false,
ignoreException: false,
saveResponseParameters: false,
saveResponseParametersTransient: false,
saveResponseVariableAsJson: false,
};
const serviceTaskForm = ref({ ...DEFAULT_TASK_FORM });
const httpTaskForm = ref<any>({ ...DEFAULT_HTTP_FORM });
const bpmnElement = ref();
const httpInitializing = ref(false);
const bpmnInstances = () => (window as any)?.bpmnInstances;
const resetTaskForm = () => {
for (const key in defaultTaskForm.value) {
const value =
// @ts-ignore
bpmnElement.value?.businessObject[key] || defaultTaskForm.value[key];
serviceTaskForm.value[key] = value;
if (value) {
serviceTaskForm.value.executeType = key;
// 判断字符串是否包含表达式
const isExpression = (value: string): boolean => {
if (!value) return false;
// 检测 ${...} 或 #{...} 格式的表达式
return /\$\{[^}]+\}/.test(value) || /#\{[^}]+\}/.test(value);
};
const collectHttpExtensionInfo = () => {
const businessObject = bpmnElement.value?.businessObject;
const extensionElements = businessObject?.extensionElements;
const httpFields = new Map<string, string>();
const httpFieldTypes = new Map<string, 'expression' | 'string'>();
const otherExtensions: any[] = [];
extensionElements?.values?.forEach((item: any) => {
if (
item?.$type === flowableFieldType &&
HTTP_FIELD_NAMES.includes(item.name)
) {
const value = item.string ?? item.stringValue ?? item.expression ?? '';
const fieldType = item.expression ? 'expression' : 'string';
httpFields.set(item.name, value);
httpFieldTypes.set(item.name, fieldType);
} else {
otherExtensions.push(item);
}
});
return { httpFields, httpFieldTypes, otherExtensions };
};
const resetHttpDefaults = () => {
httpInitializing.value = true;
httpTaskForm.value = { ...DEFAULT_HTTP_FORM };
nextTick(() => {
httpInitializing.value = false;
});
};
const resetHttpForm = () => {
httpInitializing.value = true;
const { httpFields } = collectHttpExtensionInfo();
const nextForm: any = { ...DEFAULT_HTTP_FORM };
HTTP_FIELD_NAMES.forEach((name) => {
const stored = httpFields.get(name);
if (stored !== undefined) {
nextForm[name] = HTTP_BOOLEAN_FIELDS.has(name)
? stored === 'true'
: stored;
}
});
httpTaskForm.value = nextForm;
nextTick(() => {
httpInitializing.value = false;
updateHttpExtensions(true);
});
};
const resetServiceTaskForm = () => {
const businessObject = bpmnElement.value?.businessObject;
const nextForm = { ...DEFAULT_TASK_FORM };
if (businessObject) {
if (businessObject.class) {
nextForm.class = businessObject.class;
nextForm.executeType = 'class';
}
if (businessObject.expression) {
nextForm.expression = businessObject.expression;
nextForm.executeType = 'expression';
}
if (businessObject.delegateExpression) {
nextForm.delegateExpression = businessObject.delegateExpression;
nextForm.executeType = 'delegateExpression';
}
if (businessObject.$attrs?.[flowableTypeKey] === 'http') {
nextForm.executeType = 'http';
} else {
// 兜底:如缺少 flowable:type=http但扩展里已有 HTTP 的字段,也认为是 HTTP
const { httpFields } = collectHttpExtensionInfo();
if (httpFields.size > 0) {
nextForm.executeType = 'http';
}
}
}
serviceTaskForm.value = nextForm;
if (nextForm.executeType === 'http') {
resetHttpForm();
} else {
resetHttpDefaults();
}
};
const updateElementTask = () => {
const taskAttr = Object.create(null);
const type = serviceTaskForm.value.executeType;
for (const key in serviceTaskForm.value) {
if (key !== 'executeType' && key !== type) taskAttr[key] = null;
}
taskAttr[type] = serviceTaskForm.value[type] || '';
bpmnInstances().modeling.updateProperties(toRaw(bpmnElement.value), taskAttr);
const shouldPersistField = (name: string, value: any) => {
if (HTTP_BOOLEAN_FIELDS.has(name)) return true;
if (name === 'requestMethod') return true;
if (name === 'requestUrl') return !!value;
return value !== undefined && value !== '';
};
const updateHttpExtensions = (force = false) => {
const instances = bpmnInstances();
if (!instances || !instances.bpmnElement) return;
// 直接使用原始BPMN元素避免Vue响应式代理问题
const bpmnElement = instances.bpmnElement;
if (
!force &&
(httpInitializing.value || serviceTaskForm.value.executeType !== 'http')
) {
return;
}
const {
httpFields: existingFields,
httpFieldTypes: existingTypes,
otherExtensions,
} = collectHttpExtensionInfo();
const desiredEntries: [string, string][] = [];
HTTP_FIELD_NAMES.forEach((name) => {
const rawValue = httpTaskForm.value[name];
if (!shouldPersistField(name, rawValue)) {
return;
}
const persisted = HTTP_BOOLEAN_FIELDS.has(name)
? String(!!rawValue)
: (rawValue === undefined
? ''
: rawValue.toString());
desiredEntries.push([name, persisted]);
});
// 检查是否有变化不仅比较值还要比较字段类型string vs expression
if (!force && desiredEntries.length === existingFields.size) {
let noChange = true;
for (const [name, value] of desiredEntries) {
const existingValue = existingFields.get(name);
const existingType = existingTypes.get(name);
const currentType = isExpression(value) ? 'expression' : 'string';
if (existingValue !== value || existingType !== currentType) {
noChange = false;
break;
}
}
if (noChange) {
return;
}
}
const moddle = bpmnInstances().moddle;
const httpFieldElements = desiredEntries.map(([name, value]) => {
// 根据值是否包含表达式来决定使用 string 还是 expression 属性
const isExpr = isExpression(value);
return moddle.create(flowableFieldType, {
name,
...(isExpr ? { expression: value } : { string: value }),
});
});
updateElementExtensions(bpmnElement, [
...otherExtensions,
...httpFieldElements,
]);
};
const removeHttpExtensions = () => {
const instances = bpmnInstances();
if (!instances || !instances.bpmnElement) return;
// 直接使用原始BPMN元素避免Vue响应式代理问题
const bpmnElement = instances.bpmnElement;
const { httpFields, otherExtensions } = collectHttpExtensionInfo();
if (httpFields.size === 0) {
return;
}
if (otherExtensions.length === 0) {
bpmnInstances().modeling.updateProperties(bpmnElement, {
extensionElements: null,
});
return;
}
updateElementExtensions(bpmnElement, otherExtensions);
};
const updateElementTask = () => {
const instances = bpmnInstances();
if (!instances || !instances.bpmnElement) return;
// 直接使用原始BPMN元素避免Vue响应式代理问题
const bpmnElement = instances.bpmnElement;
const taskAttr: Record<string, any> = {
class: null,
expression: null,
delegateExpression: null,
[flowableTypeKey]: null,
};
const type = serviceTaskForm.value.executeType;
if (
type === 'class' ||
type === 'expression' ||
type === 'delegateExpression'
) {
taskAttr[type] = serviceTaskForm.value[type] || null;
} else if (type === 'http') {
taskAttr[flowableTypeKey] = 'http';
}
bpmnInstances().modeling.updateProperties(bpmnElement, taskAttr);
if (type === 'http') {
updateHttpExtensions(true);
} else {
removeHttpExtensions();
}
};
const handleExecuteTypeChange = (value: any) => {
serviceTaskForm.value.executeType = value;
if (value === 'http') {
resetHttpForm();
}
updateElementTask();
};
/** 打开请求头编辑器 */
const openHttpHeaderEditor = () => {
httpHeaderEditorApi
.setData({
headers: httpTaskForm.value.requestHeaders,
})
.open();
};
/** 保存请求头 */
const handleHeadersSave = (headersStr: string) => {
httpTaskForm.value.requestHeaders = headersStr;
};
const [HttpHeaderEditorModal, httpHeaderEditorApi] = useVbenModal({
connectedComponent: HttpHeaderEditor,
});
onBeforeUnmount(() => {
bpmnElement.value = null;
});
@@ -52,60 +334,157 @@ watch(
() => {
bpmnElement.value = bpmnInstances().bpmnElement;
nextTick(() => {
resetTaskForm();
resetServiceTaskForm();
});
},
{ immediate: true },
);
watch(
() => httpTaskForm.value,
() => {
updateHttpExtensions();
},
{ deep: true },
);
</script>
<template>
<div>
<FormItem label="执行类型" key="executeType">
<Select
v-model:value="serviceTaskForm.executeType"
:options="[
{ label: 'Java类', value: 'class' },
{ label: '表达式', value: 'expression' },
{ label: '代理表达式', value: 'delegateExpression' },
]"
/>
</FormItem>
<FormItem
v-if="serviceTaskForm.executeType === 'class'"
label="Java类"
name="class"
key="execute-class"
>
<Input
v-model:value="serviceTaskForm.class"
allow-clear
@change="updateElementTask"
/>
</FormItem>
<FormItem
v-if="serviceTaskForm.executeType === 'expression'"
label="表达式"
name="expression"
key="execute-expression"
>
<Input
v-model:value="serviceTaskForm.expression"
allow-clear
@change="updateElementTask"
/>
</FormItem>
<FormItem
v-if="serviceTaskForm.executeType === 'delegateExpression'"
label="代理表达式"
name="delegateExpression"
key="execute-delegate"
>
<Input
v-model:value="serviceTaskForm.delegateExpression"
allow-clear
@change="updateElementTask"
/>
</FormItem>
<Form :label-col="{ span: 6 }" :wrapper-col="{ span: 18 }">
<FormItem label="执行类型" key="executeType">
<Select
v-model:value="serviceTaskForm.executeType"
:options="[
{ label: 'Java类', value: 'class' },
{ label: '表达式', value: 'expression' },
{ label: '代理表达式', value: 'delegateExpression' },
{ label: 'HTTP 调用', value: 'http' },
]"
@change="handleExecuteTypeChange"
/>
</FormItem>
<FormItem
v-if="serviceTaskForm.executeType === 'class'"
label="Java类"
name="class"
key="execute-class"
>
<Input
v-model:value="serviceTaskForm.class"
allow-clear
@change="updateElementTask"
/>
</FormItem>
<FormItem
v-if="serviceTaskForm.executeType === 'expression'"
label="表达式"
name="expression"
key="execute-expression"
>
<Input
v-model:value="serviceTaskForm.expression"
allow-clear
@change="updateElementTask"
/>
</FormItem>
<FormItem
v-if="serviceTaskForm.executeType === 'delegateExpression'"
label="代理表达式"
name="delegateExpression"
key="execute-delegate"
>
<Input
v-model:value="serviceTaskForm.delegateExpression"
allow-clear
@change="updateElementTask"
/>
</FormItem>
<template v-if="serviceTaskForm.executeType === 'http'">
<FormItem label="请求方法" key="http-method" name="requestMethod">
<RadioGroup v-model:value="httpTaskForm.requestMethod">
<RadioButton value="GET">GET</RadioButton>
<RadioButton value="POST">POST</RadioButton>
<RadioButton value="PUT">PUT</RadioButton>
<RadioButton value="DELETE">DELETE</RadioButton>
</RadioGroup>
</FormItem>
<FormItem label="请求地址" key="http-url" name="requestUrl">
<Input v-model:value="httpTaskForm.requestUrl" allow-clear />
</FormItem>
<FormItem label="请求头" key="http-headers" name="requestHeaders">
<div class="flex w-full flex-col gap-2">
<Textarea
v-model:value="httpTaskForm.requestHeaders"
:auto-size="{ minRows: 4, maxRows: 8 }"
readonly
placeholder="点击右侧编辑按钮添加请求头"
class="min-w-0 flex-1"
/>
<div class="flex w-full items-center justify-center">
<Button
class="flex flex-1 items-center justify-center"
size="small"
type="primary"
@click="openHttpHeaderEditor"
>
<template #icon>
<IconifyIcon icon="ep:edit" />
</template>
编辑
</Button>
</div>
</div>
</FormItem>
<FormItem
label="禁止重定向"
key="http-disallow-redirects"
name="disallowRedirects"
>
<Switch v-model:checked="httpTaskForm.disallowRedirects" />
</FormItem>
<FormItem
label="忽略异常"
key="http-ignore-exception"
name="ignoreException"
>
<Switch v-model:checked="httpTaskForm.ignoreException" />
</FormItem>
<FormItem
label="保存返回变量"
key="http-save-response"
name="saveResponseParameters"
>
<Switch v-model:checked="httpTaskForm.saveResponseParameters" />
</FormItem>
<FormItem
label="是否瞬间变量"
key="http-save-transient"
name="saveResponseParametersTransient"
>
<Switch
v-model:checked="httpTaskForm.saveResponseParametersTransient"
/>
</FormItem>
<FormItem
label="返回变量前缀"
key="http-result-variable-prefix"
name="resultVariablePrefix"
>
<Input v-model:value="httpTaskForm.resultVariablePrefix" />
</FormItem>
<FormItem
label="保存为 JSON 变量"
:label-col="{ span: 8 }"
:wrapper-col="{ span: 16 }"
key="http-save-json"
name="saveResponseVariableAsJson"
>
<Switch v-model:checked="httpTaskForm.saveResponseVariableAsJson" />
</FormItem>
</template>
</Form>
<!-- 请求头编辑器 -->
<HttpHeaderEditorModal @save="handleHeadersSave" />
</div>
</template>

View File

@@ -17,6 +17,7 @@ import {
watch,
} from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { SelectOutlined } from '@vben/icons';
import { handleTree } from '@vben/utils';
@@ -42,8 +43,7 @@ import {
MULTI_LEVEL_DEPT,
} from '#/views/bpm/components/simple-process-design/consts';
import { useFormFieldsPermission } from '#/views/bpm/components/simple-process-design/helpers';
import ProcessExpressionDialog from './ProcessExpressionDialog.vue';
import ProcessExpressionSelectModal from '#/views/bpm/processExpression/components/process-expression-select-modal.vue';
defineOptions({ name: 'UserTask' });
const props = defineProps({
@@ -120,10 +120,10 @@ const resetTaskForm = () => {
bpmnInstances().moddle.create('bpmn:ExtensionElements', { values: [] });
userTaskForm.value.candidateStrategy = extensionElements.values?.find(
(ex: any) => ex.$type === `${prefix}:CandidateStrategy`,
)?.[0]?.value;
)?.value;
const candidateParamStr = extensionElements.values?.find(
(ex: any) => ex.$type === `${prefix}:CandidateParam`,
)?.[0]?.value;
)?.value;
if (candidateParamStr && candidateParamStr.length > 0) {
// eslint-disable-next-line unicorn/prefer-switch
if (userTaskForm.value.candidateStrategy === CandidateStrategy.EXPRESSION) {
@@ -292,9 +292,13 @@ const updateSkipExpression = () => {
};
// 打开监听器弹窗
const processExpressionDialogRef = ref<any>();
const [ProcessExpressionSelectModalComp, ProcessExpressionSelectModalApi] =
useVbenModal({
connectedComponent: ProcessExpressionSelectModal,
destroyOnClose: true,
});
const openProcessExpressionDialog = async () => {
processExpressionDialogRef.value.open();
ProcessExpressionSelectModalApi.open();
};
const selectProcessExpression = (
expression: BpmProcessExpressionApi.ProcessExpression,
@@ -344,7 +348,7 @@ onBeforeUnmount(() => {
</script>
<template>
<Form>
<Form :label-col="{ span: 6 }" :wrapper-col="{ span: 18 }">
<FormItem label="规则类型" name="candidateStrategy">
<Select
v-model:value="userTaskForm.candidateStrategy"
@@ -544,19 +548,19 @@ onBeforeUnmount(() => {
style="width: 100%"
@change="updateElementTask"
/>
<Button
class="!w-1/1 mt-5px"
type="primary"
:icon="h(SelectOutlined)"
@click="openProcessExpressionDialog"
>
选择表达式
</Button>
<div class="mt-2 flex w-full items-center justify-center">
<Button
class="flex flex-1 items-center justify-center"
type="primary"
size="small"
:icon="h(SelectOutlined)"
@click="openProcessExpressionDialog"
>
选择表达式
</Button>
</div>
<!-- 选择弹窗 -->
<ProcessExpressionDialog
ref="processExpressionDialogRef"
@select="selectProcessExpression"
/>
<ProcessExpressionSelectModalComp @select="selectProcessExpression" />
</FormItem>
<FormItem label="跳过表达式" name="skipExpression">

View File

@@ -8,8 +8,10 @@ import {
Input,
InputNumber,
Radio,
TabPane,
Tabs,
} from 'ant-design-vue';
import dayjs from 'dayjs';
const props = defineProps({
value: {
@@ -41,7 +43,7 @@ const cronFieldList = [
];
const activeField = ref('second');
const cronMode = ref({
second: 'appoint',
second: 'every',
minute: 'every',
hour: 'every',
day: 'every',
@@ -50,7 +52,7 @@ const cronMode = ref({
year: 'every',
});
const cronAppoint = ref({
second: ['00', '01'],
second: [],
minute: [],
hour: [],
day: [],
@@ -107,103 +109,156 @@ watch(
const isoStr = ref('');
const repeat = ref(1);
const isoDate = ref('');
const durationUnits = [
{ key: 'Y', label: '年', presets: [1, 2, 3, 4] },
{ key: 'M', label: '月', presets: [1, 2, 3, 4] },
{ key: 'D', label: '天', presets: [1, 2, 3, 4] },
{ key: 'H', label: '时', presets: [4, 8, 12, 24] },
{ key: 'm', label: '分', presets: [5, 10, 30, 50] },
{ key: 'S', label: '秒', presets: [5, 10, 30, 50] },
];
const durationCustom = ref({ Y: '', M: '', D: '', H: '', m: '', S: '' });
const isoDuration = ref('');
function setDuration(type, val) {
// 组装ISO 8601字符串
let d = isoDuration.value;
if (d.includes(type)) {
d = d.replace(new RegExp(String.raw`\d+${type}`), val + type);
} else {
d += val + type;
}
isoDuration.value = d;
function setDuration(key, val) {
durationCustom.value[key] = !val || Number.isNaN(val) ? '' : val;
updateDurationStr();
}
function updateDurationStr() {
let str = 'P';
str += durationCustom.value.Y ? `${durationCustom.value.Y}Y` : '';
str += durationCustom.value.M ? `${durationCustom.value.M}M` : '';
str += durationCustom.value.D ? `${durationCustom.value.D}D` : '';
str +=
durationCustom.value.H || durationCustom.value.m || durationCustom.value.S
? 'T'
: '';
str += durationCustom.value.H ? `${durationCustom.value.H}H` : '';
str += durationCustom.value.m ? `${durationCustom.value.m}M` : '';
str += durationCustom.value.S ? `${durationCustom.value.S}S` : '';
isoDuration.value = str === 'P' ? '' : str;
updateIsoStr();
}
function updateIsoStr() {
let str = `R${repeat.value}`;
if (isoDate.value)
str += `/${
if (isoDate.value) {
const dateStr =
typeof isoDate.value === 'string'
? isoDate.value
: new Date(isoDate.value).toISOString()
}`;
: isoDate.value.toISOString();
str += `/${dateStr}`;
}
if (isoDuration.value) str += `/${isoDuration.value}`;
isoStr.value = str;
if (tab.value === 'iso') emit('change', isoStr.value);
}
watch([repeat, isoDate, isoDuration], updateIsoStr);
watch([repeat, isoDate], updateIsoStr);
watch(durationCustom, updateDurationStr, { deep: true });
watch(
() => props.value,
(val) => {
if (!val) return;
if (tab.value === 'cron') cronStr.value = val;
if (tab.value === 'iso') isoStr.value = val;
// 自动检测格式以R开头的是ISO 8601格式否则是CRON表达式
if (val.startsWith('R')) {
tab.value = 'iso';
isoStr.value = val;
// 解析ISO格式R{repeat}/{date}/{duration}
const parts = val.split('/');
if (parts[0]) {
const repeatMatch = parts[0].match(/^R(\d+)$/);
if (repeatMatch) repeat.value = Number.parseInt(repeatMatch[1], 10);
}
// 解析date部分ISO 8601日期时间格式
const datePart = parts.find(
(p) => p.includes('T') && !p.startsWith('P') && !p.startsWith('R'),
);
if (datePart) {
isoDate.value = dayjs(datePart);
}
// 解析duration部分
const durationPart = parts.find((p) => p.startsWith('P'));
if (durationPart) {
const match = durationPart.match(
/^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/,
);
if (match) {
durationCustom.value.Y = match[1] || '';
durationCustom.value.M = match[2] || '';
durationCustom.value.D = match[3] || '';
durationCustom.value.H = match[4] || '';
durationCustom.value.m = match[5] || '';
durationCustom.value.S = match[6] || '';
isoDuration.value = durationPart;
}
}
} else {
tab.value = 'cron';
cronStr.value = val;
}
},
{ immediate: true },
);
</script>
<template>
<Tabs v-model:active-key="tab">
<Tabs.TabPane key="cron" tab="CRON表达式">
<div style="margin-bottom: 10px">
<TabPane key="cron" tab="CRON表达式">
<div class="mb-2.5">
<Input
v-model:value="cronStr"
readonly
style="width: 400px; font-weight: bold"
class="w-[400px] font-bold"
key="cronStr"
/>
</div>
<div style="display: flex; gap: 8px; margin-bottom: 8px">
<div class="mb-2 flex gap-2">
<Input
v-model:value="fields.second"
placeholder="秒"
style="width: 80px"
class="w-20"
key="second"
/>
<Input
v-model:value="fields.minute"
placeholder="分"
style="width: 80px"
class="w-20"
key="minute"
/>
<Input
v-model:value="fields.hour"
placeholder="时"
style="width: 80px"
class="w-20"
key="hour"
/>
<Input
v-model:value="fields.day"
placeholder="天"
style="width: 80px"
class="w-20"
key="day"
/>
<Input
v-model:value="fields.month"
placeholder="月"
style="width: 80px"
class="w-20"
key="month"
/>
<Input
v-model:value="fields.week"
placeholder="周"
style="width: 80px"
class="w-20"
key="week"
/>
<Input
v-model:value="fields.year"
placeholder="年"
style="width: 80px"
class="w-20"
key="year"
/>
</div>
<Tabs
v-model:active-key="activeField"
type="card"
style="margin-bottom: 8px"
>
<Tabs v-model:active-key="activeField" type="card" class="mb-2">
<Tabs.TabPane v-for="f in cronFieldList" :key="f.key" :tab="f.label">
<div style="margin-bottom: 8px">
<div class="mb-2">
<Radio.Group
v-model:value="cronMode[f.key]"
:key="`radio-${f.key}`"
@@ -218,7 +273,7 @@ watch(
:min="f.min"
:max="f.max"
size="small"
style="width: 60px"
class="w-[60px]"
:key="`range0-${f.key}`"
/>
@@ -227,7 +282,7 @@ watch(
:min="f.min"
:max="f.max"
size="small"
style="width: 60px"
class="w-[60px]"
:key="`range1-${f.key}`"
/>
之间每{{ f.label }}
@@ -239,7 +294,7 @@ watch(
:min="f.min"
:max="f.max"
size="small"
style="width: 60px"
class="w-[60px]"
:key="`step0-${f.key}`"
/>
开始每
@@ -248,7 +303,7 @@ watch(
:min="1"
:max="f.max"
size="small"
style="width: 60px"
class="w-[60px]"
:key="`step1-${f.key}`"
/>
{{ f.label }}
@@ -272,109 +327,64 @@ watch(
</div>
</Tabs.TabPane>
</Tabs>
</Tabs.TabPane>
<Tabs.TabPane key="iso" title="标准格式" tab="iso-tab">
<div style="margin-bottom: 10px">
</TabPane>
<TabPane key="iso" tab="标准格式">
<div class="mb-2.5">
<Input
v-model:value="isoStr"
placeholder="如R1/2025-05-21T21:59:54/P3DT30M30S"
style="width: 400px; font-weight: bold"
class="w-[400px] font-bold"
key="isoStr"
/>
</div>
<div style="margin-bottom: 10px">
<div class="mb-2.5">
循环次数<InputNumber
v-model:value="repeat"
:min="1"
style="width: 100px"
class="w-[100px]"
key="repeat"
/>
</div>
<div style="margin-bottom: 10px">
日期时间<DatePicker
<div class="mb-2.5">
开始时间<DatePicker
v-model:value="isoDate"
show-time
placeholder="选择日期时间"
style="width: 200px"
placeholder="选择开始时间"
class="w-[200px]"
key="isoDate"
/>
</div>
<div style="margin-bottom: 10px">
当前时长<Input
<div class="mb-2.5">
间隔时长<Input
v-model:value="isoDuration"
readonly
placeholder="如P3DT30M30S"
style="width: 200px"
class="w-[200px]"
key="isoDuration"
/>
</div>
<div>
<div>
<Button
v-for="s in [5, 10, 30, 50]"
@click="setDuration('S', s)"
:key="`sec-${s}`"
>
{{ s }}
</Button>
自定义
</div>
<div>
<Button
v-for="m in [5, 10, 30, 50]"
@click="setDuration('M', m)"
:key="`min-${m}`"
>
{{ m }}
</Button>
自定义
</div>
<div>
小时
<Button
v-for="h in [4, 8, 12, 24]"
@click="setDuration('H', h)"
:key="`hour-${h}`"
>
{{ h }}
</Button>
自定义
</div>
<div>
<Button
v-for="d in [1, 2, 3, 4]"
@click="setDuration('D', d)"
:key="`day-${d}`"
>
{{ d }}
</Button>
自定义
</div>
<div>
<Button
v-for="mo in [1, 2, 3, 4]"
@click="setDuration('M', mo)"
:key="`mon-${mo}`"
>
{{ mo }}
</Button>
自定义
</div>
<div>
<Button
v-for="y in [1, 2, 3, 4]"
@click="setDuration('Y', y)"
:key="`year-${y}`"
>
{{ y }}
</Button>
自定义
<div v-for="unit in durationUnits" :key="unit.key" class="mb-2">
<span>{{ unit.label }}</span>
<Button.Group>
<Button
v-for="val in unit.presets"
:key="val"
size="small"
@click="setDuration(unit.key, val)"
>
{{ val }}
</Button>
<Input
v-model:value="durationCustom[unit.key]"
size="small"
class="ml-2 w-[60px]"
placeholder="自定义"
@change="setDuration(unit.key, durationCustom[unit.key])"
/>
</Button.Group>
</div>
</div>
</Tabs.TabPane>
</TabPane>
</Tabs>
</template>

View File

@@ -68,14 +68,10 @@ watch(
<template>
<div>
<div style="margin-bottom: 10px">
当前选择<Input
v-model:value="isoString"
readonly
style="width: 300px"
/>
<div class="mb-2.5">
当前选择<Input v-model:value="isoString" readonly class="w-[300px]" />
</div>
<div v-for="unit in units" :key="unit.key" style="margin-bottom: 8px">
<div v-for="unit in units" :key="unit.key" class="mb-2">
<span>{{ unit.label }}</span>
<Button.Group>
<Button
@@ -89,7 +85,7 @@ watch(
<Input
v-model:value="custom[unit.key]"
size="small"
style="width: 60px; margin-left: 8px"
class="ml-2 w-[60px]"
placeholder="自定义"
@change="setUnit(unit.key, custom[unit.key])"
/>

View File

@@ -1,11 +1,14 @@
<script lang="ts" setup>
import type { Dayjs } from 'dayjs';
import type { Ref } from 'vue';
import { computed, nextTick, onMounted, ref, toRaw, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { Button, DatePicker, Input, Modal, Tooltip } from 'ant-design-vue';
import { Button, DatePicker, Input, Tooltip } from 'ant-design-vue';
import CycleConfig from './CycleConfig.vue';
import DurationConfig from './DurationConfig.vue';
@@ -20,13 +23,8 @@ const props = defineProps({
const bpmnInstances = () => (window as any).bpmnInstances;
const type: Ref<string> = ref('time');
const condition: Ref<string> = ref('');
const valid: Ref<boolean> = ref(true);
const showDatePicker: Ref<boolean> = ref(false);
const showDurationDialog: Ref<boolean> = ref(false);
const showCycleDialog: Ref<boolean> = ref(false);
const showHelp: Ref<boolean> = ref(false);
const dateValue: Ref<Date | null> = ref(null);
// const bpmnElement = ref(null);
const valid: Ref<boolean> = ref(false);
const dateValue = ref<Dayjs>();
const placeholder = computed<string>(() => {
if (type.value === 'time') return '请输入时间';
@@ -49,6 +47,9 @@ const helpHtml = computed<string>(() => {
if (type.value === 'cycle') {
return `支持CRON表达式如0 0/30 * * * ?或ISO 8601周期如R3/PT10M`;
}
if (type.value === 'time') {
return `支持ISO 8601格式的时间如2024-12-12T12:12:12`;
}
return '';
});
@@ -82,7 +83,6 @@ function setType(t: string) {
// 输入校验
watch([type, condition], () => {
valid.value = validate();
// updateNode() // 可以注释掉,避免频繁触发
});
function validate(): boolean {
@@ -93,46 +93,74 @@ function validate(): boolean {
return /^P.*$/.test(condition.value);
}
if (type.value === 'cycle') {
return /^(?:[0-9*/?, ]+|R\d*\/P.*)$/.test(condition.value);
// 支持CRON表达式或ISO 8601周期格式R{n}/P... 或 R{n}/{date}/P...
return /^(?:[0-9*/?, ]+|R\d+(?:\/[^/]+)*\/P.*)$/.test(condition.value);
}
return true;
}
// 选择时间
// 选择时间 Modal
const [DateModal, dateModalApi] = useVbenModal({
title: '选择时间',
class: 'w-[400px]',
onConfirm: onDateConfirm,
});
function onDateChange(val: any) {
dateValue.value = val;
dateValue.value = val || undefined;
}
function onDateConfirm(): void {
if (dateValue.value) {
condition.value = new Date(dateValue.value).toISOString();
showDatePicker.value = false;
condition.value = dateValue.value.toISOString();
dateModalApi.close();
updateNode();
}
}
// 持续时长
// 持续时长 Modal
const [DurationModal, durationModalApi] = useVbenModal({
title: '时间配置',
class: 'w-[600px]',
onConfirm: onDurationConfirm,
});
function onDurationChange(val: string) {
condition.value = val;
}
function onDurationConfirm(): void {
showDurationDialog.value = false;
durationModalApi.close();
updateNode();
}
// 循环
// 循环配置 Modal
const [CycleModal, cycleModalApi] = useVbenModal({
title: '时间配置',
class: 'w-[800px]',
onConfirm: onCycleConfirm,
});
function onCycleChange(val: string) {
condition.value = val;
}
function onCycleConfirm(): void {
showCycleDialog.value = false;
cycleModalApi.close();
updateNode();
}
// 输入框聚焦时弹窗(可选)
function handleInputFocus(): void {
if (type.value === 'time') showDatePicker.value = true;
if (type.value === 'duration') showDurationDialog.value = true;
if (type.value === 'cycle') showCycleDialog.value = true;
// 帮助说明 Modal
const [HelpModal, helpModalApi] = useVbenModal({
class: 'w-[600px]',
title: '格式说明',
showCancelButton: false,
confirmText: '关闭',
onConfirm: () => helpModalApi.close(),
});
// 点击输入框时弹窗
function handleInputClick(): void {
if (type.value === 'time') dateModalApi.open();
if (type.value === 'duration') durationModalApi.open();
if (type.value === 'cycle') cycleModalApi.open();
}
// 同步到节点
@@ -210,8 +238,8 @@ watch(
<template>
<div class="panel-tab__content">
<div style="margin-top: 10px">
<span>类型</span>
<div class="mt-2 flex items-center">
<span class="w-14">类型</span>
<Button.Group>
<Button
size="small"
@@ -238,17 +266,17 @@ watch(
<IconifyIcon
icon="ant-design:check-circle-filled"
v-if="valid"
style="margin-left: 8px; color: green"
class="ml-2 text-green-500"
/>
</div>
<div style="display: flex; align-items: center; margin-top: 10px">
<span>条件</span>
<div class="mt-2 flex items-center gap-1">
<span class="w-14">条件</span>
<Input
v-model:value="condition"
:placeholder="placeholder"
class="w-[calc(100vw-25%)]"
class="w-full"
:readonly="type !== 'duration' && type !== 'cycle'"
@focus="handleInputFocus"
@click="handleInputClick"
@blur="updateNode"
>
<template #suffix>
@@ -262,13 +290,13 @@ watch(
<IconifyIcon
icon="ant-design:question-circle-filled"
class="cursor-pointer text-[#409eff]"
@click="showHelp = true"
@click="helpModalApi.open()"
/>
</Tooltip>
<Button
v-if="type === 'time'"
@click="showDatePicker = true"
style="margin-left: 4px"
@click="dateModalApi.open()"
class="ml-1 flex items-center justify-center"
shape="circle"
size="small"
>
@@ -276,8 +304,8 @@ watch(
</Button>
<Button
v-if="type === 'duration'"
@click="showDurationDialog = true"
style="margin-left: 4px"
@click="durationModalApi.open()"
class="ml-1 flex items-center justify-center"
shape="circle"
size="small"
>
@@ -285,8 +313,8 @@ watch(
</Button>
<Button
v-if="type === 'cycle'"
@click="showCycleDialog = true"
style="margin-left: 4px"
@click="cycleModalApi.open()"
class="ml-1 flex items-center justify-center"
shape="circle"
size="small"
>
@@ -295,62 +323,32 @@ watch(
</template>
</Input>
</div>
<!-- 时间选择器 -->
<Modal
v-model:open="showDatePicker"
title="选择时间"
width="400px"
@cancel="showDatePicker = false"
>
<DateModal>
<DatePicker
v-model:value="dateValue"
show-time
placeholder="选择日期时间"
style="width: 100%"
class="w-full"
@change="onDateChange"
/>
<template #footer>
<Button @click="showDatePicker = false">取消</Button>
<Button type="primary" @click="onDateConfirm">确定</Button>
</template>
</Modal>
</DateModal>
<!-- 持续时长选择器 -->
<Modal
v-model:open="showDurationDialog"
title="时间配置"
width="600px"
@cancel="showDurationDialog = false"
>
<DurationModal>
<DurationConfig :value="condition" @change="onDurationChange" />
<template #footer>
<Button @click="showDurationDialog = false">取消</Button>
<Button type="primary" @click="onDurationConfirm">确定</Button>
</template>
</Modal>
</DurationModal>
<!-- 循环配置器 -->
<Modal
v-model:open="showCycleDialog"
title="时间配置"
width="800px"
@cancel="showCycleDialog = false"
>
<CycleModal>
<CycleConfig :value="condition" @change="onCycleChange" />
<template #footer>
<Button @click="showCycleDialog = false">取消</Button>
<Button type="primary" @click="onCycleConfirm">确定</Button>
</template>
</Modal>
</CycleModal>
<!-- 帮助说明 -->
<Modal
v-model:open="showHelp"
title="格式说明"
width="600px"
@cancel="showHelp = false"
>
<HelpModal>
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-html="helpHtml"></div>
<template #footer>
<Button @click="showHelp = false">关闭</Button>
</template>
</Modal>
</HelpModal>
</div>
</template>

View File

@@ -1,5 +1,3 @@
import { toRaw } from 'vue';
const bpmnInstances = () => (window as any)?.bpmnInstances;
// 创建监听器实例
export function createListenerObject(options, isTask, prefix) {
@@ -76,7 +74,8 @@ export function updateElementExtensions(element, extensionList) {
const extensions = bpmnInstances().moddle.create('bpmn:ExtensionElements', {
values: extensionList,
});
bpmnInstances().modeling.updateProperties(toRaw(element), {
// 直接使用原始元素对象不需要toRaw包装
bpmnInstances().modeling.updateProperties(element, {
extensionElements: extensions,
});
}

View File

@@ -193,7 +193,6 @@ const childFormFieldOptions = ref<any[]>([]);
const saveConfig = async () => {
activeTabName.value = 'child';
if (!formRef.value) return false;
const valid = await formRef.value.validate().catch(() => false);
if (!valid) return false;

View File

@@ -137,7 +137,7 @@ const {
} = useNodeForm(BpmNodeTypeEnum.COPY_TASK_NODE);
const configForm = tempConfigForm as Ref<CopyTaskFormType>;
// 抄送人策略, 去掉发起人自选 和 发起人自己
// 抄送人策略,去掉发起人自选 和 发起人自己
const copyUserStrategies = computed(() => {
return CANDIDATE_STRATEGY.filter(
(item) => item.value !== CandidateStrategy.START_USER,

View File

@@ -348,7 +348,7 @@ function getShowText(): string {
return showText;
}
/** 显示触发器节点配置, 由父组件传过来 */
/** 显示触发器节点配置,由父组件传过来 */
function showTriggerNodeConfig(node: SimpleFlowNode) {
nodeName.value = node.name;
originalSetting = node.triggerSetting

View File

@@ -532,7 +532,7 @@ function useTimeoutHandler() {
if (timeUnit.value === TimeUnitType.HOUR) {
configForm.value.timeDuration = 6;
}
// 天, 默认 1天
// 天, 默认 1
if (timeUnit.value === TimeUnitType.DAY) {
configForm.value.timeDuration = 1;
}

View File

@@ -19,7 +19,7 @@ const props = defineProps<{
flowNode: SimpleFlowNode;
}>();
/** 定义事件,更新父组件 */
/** 定义事件,更新父组件 */
const emits = defineEmits<{
'update:flowNode': [node: SimpleFlowNode | undefined];
}>();

View File

@@ -22,7 +22,7 @@ const props = defineProps({
required: true,
},
});
// 定义事件,更新父组件
// 定义事件,更新父组件
const emits = defineEmits<{
'update:flowNode': [node: SimpleFlowNode | undefined];
}>();

View File

@@ -20,7 +20,7 @@ const props = defineProps({
required: true,
},
});
// 定义事件,更新父组件
// 定义事件,更新父组件
const emits = defineEmits<{
'update:flowNode': [node: SimpleFlowNode | undefined];
}>();

View File

@@ -289,7 +289,7 @@ function recursiveFindParentNode(
:condition-node="item"
:ref="item.id"
/>
<!-- 递归显示子节点 -->
<!-- 递归显示子节点 -->
<ProcessNodeTree
v-if="item && item.childNode"
:parent-node="item"

View File

@@ -291,7 +291,7 @@ function recursiveFindParentNode(
:condition-node="item"
:ref="item.id"
/>
<!-- 递归显示子节点 -->
<!-- 递归显示子节点 -->
<ProcessNodeTree
v-if="item && item.childNode"
:parent-node="item"

View File

@@ -30,7 +30,7 @@ const [Modal, modalApi] = useVbenModal({
try {
const data = modalApi.getData<any[]>();
// 填充列表数据
await gridApi.setGridOptions({ data });
gridApi.setGridOptions({ data });
} finally {
modalApi.unlock();
}

View File

@@ -33,7 +33,7 @@ const [Modal, modalApi] = useVbenModal({
try {
const data = modalApi.getData<any[]>();
// 填充列表数据
await gridApi.setGridOptions({ data });
gridApi.setGridOptions({ data });
} finally {
modalApi.unlock();
}

View File

@@ -53,10 +53,10 @@ const showInputs = ref<boolean[]>([]);
watch(
showInputs,
(newValues) => {
// 当输入框显示时, 自动聚焦
// 当输入框显示时 自动聚焦
newValues.forEach((value, index) => {
if (value) {
// 当显示状态从 false 变为 true 时, 自动聚焦
// 当显示状态从 false 变为 true 时 自动聚焦
nextTick(() => {
inputRefs.value[index]?.focus();
});
@@ -212,7 +212,7 @@ function recursiveFindParentNode(
/>
</div>
</div>
<!-- 递归显示子节点 -->
<!-- 递归显示子节点 -->
<ProcessNodeTree
v-if="item && item.childNode"
:parent-node="item"

View File

@@ -27,7 +27,7 @@ const props = defineProps({
},
});
// 定义事件,更新父组件
// 定义事件,更新父组件
defineEmits<{
'update:modelValue': [node: SimpleFlowNode | undefined];
}>();

View File

@@ -146,7 +146,6 @@
background: url('./svg/simple-process-bg.svg') 0 0 repeat;
transform: scale(1);
transform-origin: 50% 0 0;
transition: transform 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
// 节点容器 定义节点宽度
.node-container {
width: 200px;

View File

@@ -259,9 +259,11 @@ async function validateAllSteps() {
return true;
}
const saveLoading = ref<boolean>(false);
/** 保存操作 */
async function handleSave() {
try {
saveLoading.value = true;
// 保存前校验所有步骤的数据
const result = await validateAllSteps();
if (!result) {
@@ -309,9 +311,12 @@ async function handleSave() {
}
} catch (error: any) {
console.error('保存失败:', error);
} finally {
saveLoading.value = false;
}
}
// 发布加载中状态
const deployLoading = ref<boolean>(false);
/** 发布操作 */
async function handleDeploy() {
try {
@@ -319,6 +324,7 @@ async function handleDeploy() {
if (!formData.value.id) {
await confirm('是否确认发布该流程?');
}
deployLoading.value = true;
// 1.2 校验所有步骤
await validateAllSteps();
@@ -342,6 +348,8 @@ async function handleDeploy() {
} catch (error: any) {
console.error('发布失败:', error);
message.warning(error.message || '发布失败');
} finally {
deployLoading.value = false;
}
}
@@ -448,11 +456,12 @@ onBeforeUnmount(() => {
<Button
v-if="actionType === 'update'"
type="primary"
:loading="deployLoading"
@click="handleDeploy"
>
</Button>
<Button type="primary" @click="handleSave">
<Button type="primary" @click="handleSave" :loading="saveLoading">
<span v-if="actionType === 'definition'"> </span>
<span v-else> </span>
</Button>

View File

@@ -25,8 +25,9 @@ import {
Tooltip,
} from 'ant-design-vue';
import { DeptSelectModal, UserSelectModal } from '#/components/select-modal';
import { ImageUpload } from '#/components/upload';
import { DeptSelectModal } from '#/views/system/dept/components';
import { UserSelectModal } from '#/views/system/user/components';
const props = defineProps({
categoryList: {

View File

@@ -132,7 +132,7 @@ defineExpose({ validate });
placeholder="请输入表单提交路由"
/>
<Tooltip
title="自定义表单的提交路径,使用 Vue 的路由地址, 例如说: bpm/oa/leave/create.vue"
title="自定义表单的提交路径,使用 Vue 的路由地址例如说: /bpm/oa/leave/create.vue"
placement="top"
>
<IconifyIcon
@@ -154,7 +154,7 @@ defineExpose({ validate });
placeholder="请输入表单查看的组件地址"
/>
<Tooltip
title="自定义表单的查看组件地址,使用 Vue 的组件地址例如说bpm/oa/leave/detail.vue"
title="自定义表单的查看组件地址,使用 Vue 的组件地址,例如说:/bpm/oa/leave/detail.vue"
placement="top"
>
<IconifyIcon

View File

@@ -17,6 +17,7 @@ import { router } from '#/router';
import { useGridColumns, useGridFormSchema } from './data';
// TODO @jason这里是不是要迁移下
/** 刷新表格 */
function handleRefresh() {
gridApi.query();

View File

@@ -0,0 +1,92 @@
<script lang="ts" setup>
import type { VxeGridPropTypes } from '#/adapter/vxe-table';
import type { BpmProcessExpressionApi } from '#/api/bpm/processExpression';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { CommonStatusEnum } from '@vben/constants';
import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getProcessExpressionPage } from '#/api/bpm/processExpression';
defineOptions({ name: 'ProcessExpressionSelectModal' });
const emit = defineEmits<{
select: [expression: BpmProcessExpressionApi.ProcessExpression];
}>();
// TODO @jason这里是不是要迁移下
// 查询参数
const queryParams = ref({
status: CommonStatusEnum.ENABLE,
});
// 配置 VxeGrid
const [Grid] = useVbenVxeGrid({
gridOptions: {
columns: [
{ field: 'name', title: '名字', minWidth: 160 },
{ field: 'expression', title: '表达式', minWidth: 260 },
{
field: 'action',
title: '操作',
width: 120,
slots: { default: 'action' },
},
],
showOverflow: true,
minHeight: 300,
proxyConfig: {
ajax: {
// 查询表达式列表
query: async ({ page }) => {
return await getProcessExpressionPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
status: queryParams.value.status,
});
},
},
} as VxeGridPropTypes.ProxyConfig,
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
enabled: false,
},
},
});
// 配置 Modal
const [Modal, modalApi] = useVbenModal({
showConfirmButton: false,
destroyOnClose: true,
});
// 选择表达式
function handleSelect(row: BpmProcessExpressionApi.ProcessExpression) {
emit('select', row);
modalApi.close();
}
</script>
<template>
<Modal class="w-4/5" title="请选择表达式">
<Grid>
<template #action="{ row }">
<TableAction
:actions="[
{
label: '选择',
type: 'link',
icon: 'lucide:pointer',
onClick: handleSelect.bind(null, row),
},
]"
/>
</template>
</Grid>
</Modal>
</template>

View File

@@ -228,9 +228,10 @@ onMounted(() => {
>
<Card
hoverable
class="definition-item-card w-full cursor-pointer"
class="w-full cursor-pointer"
:class="{
'search-match': searchName.trim().length > 0,
'animate-bounce-once !bg-[rgb(63_115_247_/_10%)]':
searchName.trim().length > 0,
}"
:body-style="{
width: '100%',
@@ -241,10 +242,13 @@ onMounted(() => {
<img
v-if="definition.icon"
:src="definition.icon"
class="flow-icon-img object-contain"
class="size-12 rounded object-contain"
alt="流程图标"
/>
<div v-else class="flow-icon flex-shrink-0">
<div
v-else
class="flex size-12 flex-shrink-0 items-center justify-center rounded bg-primary"
>
<span class="text-xs text-white">
{{ definition.name?.slice(0, 2) }}
</span>
@@ -283,7 +287,6 @@ onMounted(() => {
</template>
<style lang="scss" scoped>
// @jason看看能不能通过 tailwindcss 简化下
@keyframes bounce {
0%,
50% {
@@ -295,30 +298,7 @@ onMounted(() => {
}
}
.process-definition-container {
.definition-item-card {
.flow-icon-img {
width: 48px;
height: 48px;
border-radius: 0.25rem;
}
.flow-icon {
@apply bg-primary;
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 0.25rem;
}
&.search-match {
background-color: rgb(63 115 247 / 10%);
border: 1px solid var(--primary);
animation: bounce 0.5s ease;
}
}
.animate-bounce-once {
animation: bounce 0.5s ease;
}
</style>

View File

@@ -104,7 +104,7 @@ async function submitForm() {
// 关闭并提示
message.success('发起流程成功');
await closeCurrentTab();
await router.push({ name: 'BpmTaskMy' });
await router.push({ name: 'BpmProcessInstanceMy' });
} finally {
processInstanceStartLoading.value = false;
}
@@ -169,6 +169,7 @@ async function initProcessInfo(row: any, formVariables?: any) {
path: row.formCustomCreatePath,
});
// 返回选择流程
// TODO @jason这里为啥要有个 cancel 事件哈?目前看 vue3 + element-plus 貌似不需要呀;
emit('cancel');
}
}

View File

@@ -212,20 +212,27 @@ watch(
}
},
);
const loading = ref(false);
/** 初始化 */
onMounted(async () => {
await getDetail();
// 获得用户列表
userOptions.value = await getSimpleUserList();
try {
loading.value = true;
await getDetail();
// 获得用户列表
userOptions.value = await getSimpleUserList();
} finally {
loading.value = false;
}
});
</script>
<template>
<Page auto-content-height>
<Page auto-content-height v-loading="loading">
<Card
class="flex h-full flex-col"
:body-style="{
overflowY: 'auto',
flex: 1,
overflowY: 'hidden',
paddingTop: '12px',
}"
>
@@ -286,24 +293,16 @@ onMounted(async () => {
</div>
<!-- 流程操作 -->
<div class="process-tabs-container flex flex-1 flex-col">
<Tabs v-model:active-key="activeTab" class="mt-0 h-full">
<TabPane tab="审批详情" key="form" class="tab-pane-content">
<Row :gutter="[48, 24]" class="h-full">
<Col
:xs="24"
:sm="24"
:md="18"
:lg="18"
:xl="16"
class="h-full"
>
<div class="flex h-full flex-1 flex-col">
<Tabs v-model:active-key="activeTab">
<TabPane tab="审批详情" key="form" class="pb-20 pr-3">
<Row :gutter="[48, 24]">
<Col :xs="24" :sm="24" :md="18" :lg="18" :xl="16">
<!-- 流程表单 -->
<div
v-if="
processDefinition?.formType === BpmModelFormType.NORMAL
"
class="h-full"
>
<form-create
v-model="detailForm.value"
@@ -316,13 +315,12 @@ onMounted(async () => {
v-else-if="
processDefinition?.formType === BpmModelFormType.CUSTOM
"
class="h-full"
>
<BusinessFormComponent :id="processInstance?.businessKey" />
</div>
</Col>
<Col :xs="24" :sm="24" :md="6" :lg="6" :xl="8" class="h-full">
<div class="mt-4 h-full">
<Col :xs="24" :sm="24" :md="6" :lg="6" :xl="8">
<div class="mt-4">
<ProcessInstanceTimeline :activity-nodes="activityNodes" />
</div>
</Col>
@@ -331,44 +329,35 @@ onMounted(async () => {
<TabPane
tab="流程图"
key="diagram"
class="tab-pane-content"
class="pb-20 pr-3"
:force-render="true"
>
<div class="h-full">
<ProcessInstanceSimpleViewer
v-show="
processDefinition.modelType &&
processDefinition.modelType === BpmModelType.SIMPLE
"
:loading="processInstanceLoading"
:model-view="processModelView"
/>
<ProcessInstanceBpmnViewer
v-show="
processDefinition.modelType &&
processDefinition.modelType === BpmModelType.BPMN
"
:loading="processInstanceLoading"
:model-view="processModelView"
/>
</div>
<ProcessInstanceSimpleViewer
v-show="
processDefinition.modelType &&
processDefinition.modelType === BpmModelType.SIMPLE
"
:loading="processInstanceLoading"
:model-view="processModelView"
/>
<ProcessInstanceBpmnViewer
v-show="
processDefinition.modelType &&
processDefinition.modelType === BpmModelType.BPMN
"
:loading="processInstanceLoading"
:model-view="processModelView"
/>
</TabPane>
<TabPane tab="流转记录" key="record" class="tab-pane-content">
<div class="h-full">
<BpmProcessInstanceTaskList
ref="taskListRef"
:loading="processInstanceLoading"
:id="id"
/>
</div>
<TabPane tab="流转记录" key="record" class="pb-20 pr-3">
<BpmProcessInstanceTaskList
ref="taskListRef"
:loading="processInstanceLoading"
:id="id"
/>
</TabPane>
<!-- TODO 待开发 -->
<TabPane
tab="流转评论"
key="comment"
v-if="false"
class="tab-pane-content"
>
<TabPane tab="流转评论" key="comment" v-if="false" class="pr-3">
<div class="h-full">待开发</div>
</TabPane>
</Tabs>
@@ -396,35 +385,18 @@ onMounted(async () => {
</template>
<style lang="scss" scoped>
// @jason看看能不能通过 tailwindcss 简化下
.ant-tabs-content {
height: 100%;
}
.process-tabs-container {
display: flex;
flex-direction: column;
height: 100%;
}
:deep(.ant-tabs) {
display: flex;
flex-direction: column;
height: 100%;
}
:deep(.ant-tabs-content) {
flex: 1;
overflow-y: auto;
.ant-tabs-content {
height: 100%;
}
}
:deep(.ant-tabs-tabpane) {
height: 100%;
}
.tab-pane-content {
height: calc(100vh - 420px);
padding-right: 12px;
overflow: hidden auto;
overflow-y: auto;
}
</style>

View File

@@ -5,7 +5,7 @@ import { useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { base64ToFile } from '@vben/utils';
import { Button, Space, Tooltip } from 'ant-design-vue';
import { Button, Tooltip } from 'ant-design-vue';
import Vue3Signature from 'vue3-signature';
import { uploadFile } from '#/api/infra/file';
@@ -36,30 +36,29 @@ const [Modal, modalApi] = useVbenModal({
<template>
<Modal title="流程签名" class="w-3/5">
<div class="mb-2 flex justify-end">
<Space>
<div class="flex h-[50vh] flex-col">
<div class="mb-2 flex justify-end gap-2">
<Tooltip title="撤销上一步操作">
<Button @click="signature?.undo()">
<Button @click="signature?.undo()" size="small">
<template #icon>
<IconifyIcon icon="lucide:undo" class="mb-1 size-4" />
<IconifyIcon icon="lucide:undo" class="mb-1 size-3" />
</template>
撤销
</Button>
</Tooltip>
<Tooltip title="清空画布">
<Button @click="signature?.clear()">
<Button @click="signature?.clear()" size="small">
<template #icon>
<IconifyIcon icon="lucide:trash" class="mb-1 size-4" />
<IconifyIcon icon="lucide:trash" class="mb-1 size-3" />
</template>
<span>清除</span>
</Button>
</Tooltip>
</Space>
</div>
<Vue3Signature
class="h-full flex-1 border border-solid border-gray-300"
ref="signature"
/>
</div>
<Vue3Signature
class="mx-auto !h-80 border border-solid border-gray-300"
ref="signature"
/>
</Modal>
</template>

View File

@@ -44,7 +44,7 @@ function useGridColumns(): VxeTableGridOptions['columns'] {
field: 'approver',
title: '审批人',
slots: {
default: ({ row }: { row: BpmTaskApi.TaskManager }) => {
default: ({ row }: { row: BpmTaskApi.Task }) => {
return row.assigneeUser?.nickname || row.ownerUser?.nickname;
},
},
@@ -106,7 +106,7 @@ function handleRefresh() {
}
/** 显示表单详情 */
async function handleShowFormDetail(row: BpmTaskApi.TaskManager) {
async function handleShowFormDetail(row: BpmTaskApi.Task) {
// 设置表单配置和表单字段
taskForm.value = {
rule: [],
@@ -141,7 +141,6 @@ const [Grid, gridApi] = useVbenVxeGrid({
keepSource: true,
showFooter: true,
border: true,
height: 'auto',
proxyConfig: {
ajax: {
query: async () => {
@@ -159,7 +158,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
toolbarConfig: {
enabled: false,
},
} as VxeTableGridOptions<BpmTaskApi.TaskManager>,
} as VxeTableGridOptions<BpmTaskApi.Task>,
});
defineExpose({
@@ -168,7 +167,7 @@ defineExpose({
</script>
<template>
<div class="flex h-full flex-col">
<div>
<Grid>
<template #slot-reason="{ row }">
<div class="flex flex-wrap items-center justify-center">
@@ -188,13 +187,13 @@ defineExpose({
</div>
</template>
</Grid>
<Modal class="w-[800px]">
<form-create
ref="formRef"
v-model="taskForm.value"
:option="taskForm.option"
:rule="taskForm.rule"
/>
</Modal>
</div>
<Modal class="w-3/5">
<form-create
ref="formRef"
v-model="taskForm.value"
:option="taskForm.option"
:rule="taskForm.rule"
/>
</Modal>
</template>

View File

@@ -16,7 +16,7 @@ import { formatDateTime, isEmpty } from '@vben/utils';
import { Avatar, Button, Image, Timeline, Tooltip } from 'ant-design-vue';
import { UserSelectModal } from '#/components/select-modal';
import { UserSelectModal } from '#/views/system/user/components';
defineOptions({ name: 'BpmProcessInstanceTimeline' });

View File

@@ -0,0 +1,36 @@
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DICT_TYPE } from '@vben/constants';
/** 选择监听器弹窗的列表字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{ field: 'name', title: '名字', minWidth: 120 },
{
field: 'type',
title: '类型',
minWidth: 200,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.BPM_PROCESS_LISTENER_TYPE },
},
},
{ field: 'event', title: '事件', minWidth: 200 },
{
field: 'valueType',
title: '值类型',
minWidth: 200,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.BPM_PROCESS_LISTENER_VALUE_TYPE },
},
},
{ field: 'value', title: '值', minWidth: 150 },
{
title: '操作',
width: 100,
slots: { default: 'action' },
fixed: 'right',
},
];
}

View File

@@ -0,0 +1,96 @@
<script lang="ts" setup>
import type { VxeGridPropTypes } from '#/adapter/vxe-table';
import type { BpmProcessListenerApi } from '#/api/bpm/processListener';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { CommonStatusEnum } from '@vben/constants';
import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getProcessListenerPage } from '#/api/bpm/processListener';
import { useGridColumns } from './data';
defineOptions({ name: 'ProcessListenerSelectModal' });
const emit = defineEmits<{
select: [listener: BpmProcessListenerApi.ProcessListener];
}>();
// TODO @jason这里是不是要迁移下
// 查询参数
const queryParams = ref({
type: '',
status: CommonStatusEnum.ENABLE,
});
// 配置 VxeGrid
const [Grid] = useVbenVxeGrid({
gridOptions: {
columns: useGridColumns(),
showOverflow: true,
minHeight: 300,
proxyConfig: {
ajax: {
query: async ({ page }) => {
return await getProcessListenerPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
type: queryParams.value.type,
status: queryParams.value.status,
});
},
},
} as VxeGridPropTypes.ProxyConfig,
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
enabled: false,
},
},
});
// 配置 Modal
const [Modal, modalApi] = useVbenModal({
showConfirmButton: false,
onOpenChange: async (isOpen: boolean) => {
if (!isOpen) {
queryParams.value.type = '';
return;
}
const data = modalApi.getData<{ type: string }>();
if (data?.type) {
queryParams.value.type = data.type;
}
},
destroyOnClose: true,
});
// 选择监听器
function handleSelect(row: BpmProcessListenerApi.ProcessListener) {
emit('select', row);
modalApi.close();
}
</script>
<template>
<Modal class="w-4/5" title="请选择监听器">
<Grid>
<template #action="{ row }">
<TableAction
:actions="[
{
label: '选择',
type: 'link',
icon: 'lucide:pointer',
onClick: handleSelect.bind(null, row),
},
]"
/>
</template>
</Grid>
</Modal>
</template>

View File

@@ -8,22 +8,22 @@ import { z } from '#/adapter/form';
export const EVENT_EXECUTION_OPTIONS = [
{
label: 'start',
label: '开始',
value: 'start',
},
{
label: 'end',
label: '结束',
value: 'end',
},
];
export const EVENT_OPTIONS = [
{ label: 'create', value: 'create' },
{ label: 'assignment', value: 'assignment' },
{ label: 'complete', value: 'complete' },
{ label: 'delete', value: 'delete' },
{ label: 'update', value: 'update' },
{ label: 'timeout', value: 'timeout' },
{ label: '创建', value: 'create' },
{ label: '指派', value: 'assignment' },
{ label: '完成', value: 'complete' },
{ label: '删除', value: 'delete' },
{ label: '更新', value: 'update' },
{ label: '超时', value: 'timeout' },
];
/** 新增/修改的表单 */

View File

@@ -15,7 +15,7 @@ import { useGridColumns, useGridFormSchema } from './data';
defineOptions({ name: 'BpmDoneTask' });
/** 查看历史 */
function handleHistory(row: BpmTaskApi.TaskManager) {
function handleHistory(row: BpmTaskApi.Task) {
router.push({
name: 'BpmProcessInstanceDetail',
query: {
@@ -26,7 +26,7 @@ function handleHistory(row: BpmTaskApi.TaskManager) {
}
/** 撤回任务 */
async function handleWithdraw(row: BpmTaskApi.TaskManager) {
async function handleWithdraw(row: BpmTaskApi.Task) {
const hideLoading = message.loading({
content: '正在撤回中...',
duration: 0,
@@ -67,7 +67,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
refresh: true,
search: true,
},
} as VxeTableGridOptions<BpmTaskApi.TaskManager>,
} as VxeTableGridOptions<BpmTaskApi.Task>,
});
</script>

View File

@@ -13,7 +13,7 @@ import { useGridColumns, useGridFormSchema } from './data';
defineOptions({ name: 'BpmManagerTask' });
/** 查看历史 */
function handleHistory(row: BpmTaskApi.TaskManager) {
function handleHistory(row: BpmTaskApi.Task) {
router.push({
name: 'BpmProcessInstanceDetail',
query: {

View File

@@ -78,7 +78,8 @@ function handleRowCheckboxChange({
}: {
records: InfraDataSourceConfigApi.DataSourceConfig[];
}) {
checkedIds.value = records.map((item) => item.id!);
// 过滤掉id为 0 的主数据源
checkedIds.value = records.map((item) => item.id!).filter((id) => id !== 0);
}
const [Grid, gridApi] = useVbenVxeGrid({
@@ -140,6 +141,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
type: 'link',
icon: ACTION_ICON.EDIT,
auth: ['infra:data-source-config:update'],
disabled: row.id === 0,
onClick: handleEdit.bind(null, row),
},
{
@@ -148,6 +150,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
danger: true,
icon: ACTION_ICON.DELETE,
auth: ['infra:data-source-config:delete'],
disabled: row.id === 0,
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),

View File

@@ -229,6 +229,18 @@ export function useFormSchema(): VbenFormSchema[] {
},
defaultValue: false,
},
{
fieldName: 'config.region',
label: '区域',
component: 'Input',
componentProps: {
placeholder: '请填写区域,一般仅 AWS 需要填写',
},
dependencies: {
triggerFields: ['storage'],
show: (formValues) => formValues.storage === 20,
},
},
// 通用
{
fieldName: 'config.domain',

View File

@@ -1,16 +1,13 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DICT_TYPE } from '@vben/constants';
import { DeviceTypeEnum, DICT_TYPE, LocationTypeEnum } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { z } from '#/adapter/form';
import { getSimpleDeviceList } from '#/api/iot/device/device';
import { getSimpleDeviceGroupList } from '#/api/iot/device/group';
import {
DeviceTypeEnum,
getSimpleProductList,
} from '#/api/iot/product/product';
import { getSimpleProductList } from '#/api/iot/product/product';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
@@ -33,6 +30,10 @@ export function useFormSchema(): VbenFormSchema[] {
valueField: 'id',
placeholder: '请选择产品',
},
dependencies: {
triggerFields: ['id'],
disabled: (values: any) => !!values?.id,
},
rules: 'required',
},
{
@@ -42,6 +43,10 @@ export function useFormSchema(): VbenFormSchema[] {
componentProps: {
placeholder: '请输入 DeviceName',
},
dependencies: {
triggerFields: ['id'],
disabled: (values: any) => !!values?.id,
},
rules: z
.string()
.min(4, 'DeviceName 长度不能少于 4 个字符')
@@ -63,7 +68,7 @@ export function useFormSchema(): VbenFormSchema[] {
},
dependencies: {
triggerFields: ['deviceType'],
show: (values) => values.deviceType === 1, // GATEWAY_SUB
show: (values) => values.deviceType === DeviceTypeEnum.GATEWAY_SUB,
},
},
{
@@ -129,20 +134,20 @@ export function useFormSchema(): VbenFormSchema[] {
},
dependencies: {
triggerFields: ['locationType'],
show: (values) => values.locationType === 3, // MANUAL
show: (values) => values.locationType === LocationTypeEnum.MANUAL,
},
},
{
fieldName: 'latitude',
label: '设备度',
label: '设备度',
component: 'InputNumber',
componentProps: {
placeholder: '请输入设备度',
placeholder: '请输入设备度',
class: 'w-full',
},
dependencies: {
triggerFields: ['locationType'],
show: (values) => values.locationType === 3, // MANUAL
show: (values) => values.locationType === LocationTypeEnum.MANUAL,
},
},
];

View File

@@ -1,4 +1,4 @@
<script setup lang="ts">
<script lang="ts" setup>
import type { IotDeviceApi } from '#/api/iot/device/device';
import type { IotProductApi } from '#/api/iot/product/product';
import type { ThingModelData } from '#/api/iot/thingmodel';
@@ -7,20 +7,21 @@ import { onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { DeviceTypeEnum } from '@vben/constants';
import { message, Tabs } from 'ant-design-vue';
import { getDevice } from '#/api/iot/device/device';
import { DeviceTypeEnum, getProduct } from '#/api/iot/product/product';
import { getProduct } from '#/api/iot/product/product';
import { getThingModelListByProductId } from '#/api/iot/thingmodel';
import DeviceDetailConfig from './device-detail-config.vue';
import DeviceDetailsHeader from './device-details-header.vue';
import DeviceDetailsInfo from './device-details-info.vue';
import DeviceDetailsMessage from './device-details-message.vue';
import DeviceDetailsSimulator from './device-details-simulator.vue';
import DeviceDetailsSubDevice from './device-details-sub-device.vue';
import DeviceDetailsThingModel from './device-details-thing-model.vue';
import DeviceDetailConfig from './modules/config.vue';
import DeviceDetailsHeader from './modules/header.vue';
import DeviceDetailsInfo from './modules/info.vue';
import DeviceDetailsMessage from './modules/message.vue';
import DeviceDetailsSimulator from './modules/simulator.vue';
import DeviceDetailsSubDevice from './modules/sub-device.vue';
import DeviceDetailsThingModel from './modules/thing-model.vue';
defineOptions({ name: 'IoTDeviceDetail' });
@@ -52,8 +53,8 @@ async function getDeviceData(deviceId: number) {
async function getProductData(productId: number) {
try {
product.value = await getProduct(productId);
} catch (error) {
console.error('获取产品详情失败:', error);
} catch {
message.error('获取产品详情失败');
}
}
@@ -62,8 +63,8 @@ async function getThingModelList(productId: number) {
try {
const data = await getThingModelListByProductId(productId);
thingModelList.value = data || [];
} catch (error) {
console.error('获取物模型列表失败:', error);
} catch {
message.error('获取物模型列表失败');
thingModelList.value = [];
}
}
@@ -88,9 +89,9 @@ onMounted(async () => {
<template>
<Page>
<DeviceDetailsHeader
:device="device"
:loading="loading"
:product="product"
:device="device"
@refresh="() => getDeviceData(id)"
/>
@@ -98,8 +99,8 @@ onMounted(async () => {
<Tabs.TabPane key="info" tab="设备信息">
<DeviceDetailsInfo
v-if="activeTab === 'info'"
:product="product"
:device="device"
:product="product"
/>
</Tabs.TabPane>
<Tabs.TabPane key="model" tab="物模型数据">
@@ -128,8 +129,8 @@ onMounted(async () => {
<Tabs.TabPane key="simulator" tab="模拟设备">
<DeviceDetailsSimulator
v-if="activeTab === 'simulator'"
:product="product"
:device="device"
:product="product"
:thing-model-list="thingModelList"
/>
</Tabs.TabPane>

View File

@@ -21,6 +21,7 @@ const emit = defineEmits<{
const loading = ref(false); //
const pushLoading = ref(false); //
const saveLoading = ref(false); //
const config = ref<any>({}); // config
const configString = ref(''); //
@@ -50,20 +51,15 @@ const formattedConfig = computed(() => {
}
});
/** 判断配置是否有数据 */
const hasConfigData = computed(() => {
return config.value && Object.keys(config.value).length > 0;
});
/** 启用编辑模式的函数 */
function enableEdit() {
function handleEdit() {
isEditing.value = true;
//
configString.value = JSON.stringify(config.value, null, 2);
}
/** 取消编辑的函数 */
function cancelEdit() {
function handleCancelEdit() {
try {
config.value = props.device.config ? JSON.parse(props.device.config) : {};
configString.value = JSON.stringify(config.value, null, 2);
@@ -84,29 +80,27 @@ async function saveConfig() {
message.error({ content: 'JSON格式错误请修正后再提交' });
return;
}
await updateDeviceConfig();
isEditing.value = false;
saveLoading.value = true;
try {
await updateDeviceConfig();
isEditing.value = false;
} finally {
saveLoading.value = false;
}
}
/** 配置推送处理函数 */
async function handleConfigPush() {
pushLoading.value = true;
try {
pushLoading.value = true;
//
await sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.CONFIG_PUSH.method,
params: config.value,
});
//
message.success({ content: '配置推送成功!' });
} catch (error) {
if (error !== 'cancel') {
message.error({ content: '配置推送失败!' });
console.error('配置推送错误:', error);
}
} finally {
pushLoading.value = false;
}
@@ -124,8 +118,6 @@ async function updateDeviceConfig() {
message.success({ content: '更新成功!' });
// success
emit('success');
} catch (error) {
console.error(error);
} finally {
loading.value = false;
}
@@ -134,35 +126,14 @@ async function updateDeviceConfig() {
<template>
<div>
<!-- 只在没有配置数据时显示提示 -->
<!-- 使用说明提示 -->
<Alert
v-if="!hasConfigData"
message="支持远程更新设备的配置文件(JSON 格式),可以在下方编辑配置模板,对设备的系统参数、网络参数等进行远程配置。配置完成后,需点击「下发」按钮,设备即可进行远程配置。"
type="info"
show-icon
class="my-4"
description="如需编辑文件,请点击下方编辑按钮"
message="支持远程更新设备的配置文件(JSON 格式),可以在下方编辑配置模板,对设备的系统参数、网络参数等进行远程配置。配置完成后,需点击「配置推送」按钮,设备即可进行远程配置。"
show-icon
type="info"
/>
<div class="mt-5 text-center">
<Button v-if="isEditing" @click="cancelEdit">取消</Button>
<Button
v-if="isEditing"
type="primary"
@click="saveConfig"
:loading="loading"
>
保存
</Button>
<Button v-else @click="enableEdit">编辑</Button>
<Button
v-if="!isEditing"
type="primary"
@click="handleConfigPush"
:loading="pushLoading"
>
配置推送
</Button>
</div>
<!-- 代码视图 - 只读展示 -->
<div v-if="!isEditing" class="json-viewer-container">
@@ -174,9 +145,31 @@ async function updateDeviceConfig() {
v-else
v-model:value="configString"
:rows="20"
placeholder="请输入 JSON 格式的配置信息"
class="json-editor"
placeholder="请输入 JSON 格式的配置信息"
/>
<!-- 操作按钮 -->
<div class="mt-5 text-center">
<Button v-if="isEditing" @click="handleCancelEdit">取消</Button>
<Button
v-if="isEditing"
:loading="saveLoading"
type="primary"
@click="saveConfig"
>
保存
</Button>
<Button v-else @click="handleEdit">编辑</Button>
<Button
v-if="!isEditing"
:loading="pushLoading"
type="primary"
@click="handleConfigPush"
>
配置推送
</Button>
</div>
</div>
</template>

View File

@@ -1,14 +1,14 @@
<!-- 设备信息头部 -->
<script setup lang="ts">
<script lang="ts" setup>
import type { IotDeviceApi } from '#/api/iot/device/device';
import type { IotProductApi } from '#/api/iot/product/product';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useVbenModal } from '@vben/common-ui';
import { Button, Card, Descriptions, message } from 'ant-design-vue';
import DeviceForm from '../device-form.vue';
import DeviceForm from '../../modules/form.vue';
interface Props {
product: IotProductApi.Product;
@@ -26,20 +26,19 @@ const emit = defineEmits<{
const router = useRouter();
/** 操作修改 */
const formRef = ref();
function openForm(type: string, id?: number) {
formRef.value.open(type, id);
}
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: DeviceForm,
destroyOnClose: true,
});
/** 复制到剪贴板方法 */
/** 复制到剪贴板 */
async function copyToClipboard(text: string | undefined) {
if (!text) return;
try {
await navigator.clipboard.writeText(text);
message.success({ content: '复制成功' });
message.success('复制成功');
} catch {
message.error({ content: '复制失败' });
message.error('复制失败');
}
}
@@ -49,19 +48,25 @@ function goToProductDetail(productId: number | undefined) {
router.push({ name: 'IoTProductDetail', params: { id: productId } });
}
}
/** 打开编辑表单 */
function openEditForm(row: IotDeviceApi.Device) {
formModalApi.setData(row).open();
}
</script>
<template>
<div class="mb-4">
<FormModal @success="emit('refresh')" />
<div class="flex items-start justify-between">
<div>
<h2 class="text-xl font-bold">{{ device.deviceName }}</h2>
</div>
<div class="space-x-2">
<!-- 右上按钮 -->
<Button
v-if="product.status === 0"
v-access:code="['iot:device:update']"
@click="openForm('update', device.id)"
@click="openEditForm(device)"
>
编辑
</Button>
@@ -69,11 +74,11 @@ function goToProductDetail(productId: number | undefined) {
</div>
<Card class="mt-4">
<Descriptions :column="1">
<Descriptions :column="2">
<Descriptions.Item label="产品">
<a
@click="goToProductDetail(product.id)"
class="cursor-pointer text-blue-600"
@click="goToProductDetail(product.id)"
>
{{ product.name }}
</a>
@@ -81,8 +86,8 @@ function goToProductDetail(productId: number | undefined) {
<Descriptions.Item label="ProductKey">
{{ product.productKey }}
<Button
size="small"
class="ml-2"
size="small"
@click="copyToClipboard(product.productKey)"
>
复制
@@ -90,8 +95,5 @@ function goToProductDetail(productId: number | undefined) {
</Descriptions.Item>
</Descriptions>
</Card>
<!-- 表单弹窗添加/修改 -->
<DeviceForm ref="formRef" @success="emit('refresh')" />
</div>
</template>

View File

@@ -1,5 +1,4 @@
<!-- 设备信息 -->
<script setup lang="ts">
<script lang="ts" setup>
import type { IotDeviceApi } from '#/api/iot/device/device';
import type { IotProductApi } from '#/api/iot/product/product';
@@ -7,7 +6,7 @@ import { computed, ref } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { formatDate } from '@vben/utils';
import { formatDateTime } from '@vben/utils';
import {
Button,
@@ -24,51 +23,46 @@ import {
import { getDeviceAuthInfo } from '#/api/iot/device/device';
import { DictTag } from '#/components/dict-tag';
//
const { product, device } = defineProps<{
interface Props {
device: IotDeviceApi.Device;
product: IotProductApi.Product;
}>(); // Props
// const emit = defineEmits(['refresh']); // Emits
}
const authDialogVisible = ref(false); //
const authPasswordVisible = ref(false); //
const props = defineProps<Props>();
const authDialogVisible = ref(false);
const authPasswordVisible = ref(false);
const authInfo = ref<IotDeviceApi.DeviceAuthInfo>(
{} as IotDeviceApi.DeviceAuthInfo,
); //
);
/** 控制地图显示的标志 */
const showMap = computed(() => {
return !!(device.longitude && device.latitude);
return !!(props.device.longitude && props.device.latitude);
});
/** 复制到剪贴板方法 */
/** 复制到剪贴板 */
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text);
message.success({ content: '复制成功' });
message.success('复制成功');
} catch {
message.error({ content: '复制失败' });
message.error('复制失败');
}
}
/** 打开设备认证信息弹框的方法 */
/** 打开设备认证信息弹框 */
async function handleAuthInfoDialogOpen() {
if (!device.id) return;
if (!props.device.id) return;
try {
authInfo.value = await getDeviceAuthInfo(device.id);
//
authInfo.value = await getDeviceAuthInfo(props.device.id);
authDialogVisible.value = true;
} catch (error) {
console.error('获取设备认证信息出错:', error);
message.error({
content: '获取设备认证信息失败,请检查网络连接或联系管理员',
});
} catch {
message.error('获取设备认证信息失败,请检查网络连接或联系管理员');
}
}
/** 关闭设备认证信息弹框的方法 */
/** 关闭设备认证信息弹框 */
function handleAuthInfoDialogClose() {
authDialogVisible.value = false;
}
@@ -81,52 +75,59 @@ function handleAuthInfoDialogClose() {
<Card class="h-full">
<template #title>
<div class="flex items-center">
<IconifyIcon icon="ep:info-filled" class="mr-2 text-primary" />
<!-- TODO @haohao图标尽量使用中立的这样 ep 版本呢好迁移 -->
<IconifyIcon class="mr-2 text-primary" icon="ep:info-filled" />
<span>设备信息</span>
</div>
</template>
<Descriptions :column="1" bordered size="small">
<Descriptions.Item label="产品名称">
{{ product.name }}
{{ props.product.name }}
</Descriptions.Item>
<Descriptions.Item label="ProductKey">
{{ product.productKey }}
{{ props.product.productKey }}
</Descriptions.Item>
<Descriptions.Item label="设备类型">
<DictTag
:type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE"
:value="product.deviceType"
:value="props.product.deviceType"
/>
</Descriptions.Item>
<Descriptions.Item label="定位类型">
<DictTag
:type="DICT_TYPE.IOT_LOCATION_TYPE"
:value="props.product.locationType"
/>
</Descriptions.Item>
<Descriptions.Item label="DeviceName">
{{ device.deviceName }}
{{ props.device.deviceName }}
</Descriptions.Item>
<Descriptions.Item label="备注名称">
{{ device.nickname || '--' }}
{{ props.device.nickname || '--' }}
</Descriptions.Item>
<Descriptions.Item label="当前状态">
<DictTag
:type="DICT_TYPE.IOT_DEVICE_STATE"
:value="device.state"
:value="props.device.state"
/>
</Descriptions.Item>
<Descriptions.Item label="创建时间">
{{ formatDate(device.createTime) }}
{{ formatDateTime(props.device.createTime) }}
</Descriptions.Item>
<Descriptions.Item label="激活时间">
{{ formatDate(device.activeTime) }}
{{ formatDateTime(props.device.activeTime) }}
</Descriptions.Item>
<Descriptions.Item label="最后上线时间">
{{ formatDate(device.onlineTime) }}
{{ formatDateTime(props.device.onlineTime) }}
</Descriptions.Item>
<Descriptions.Item label="最后离线时间">
{{ formatDate(device.offlineTime) }}
{{ formatDateTime(props.device.offlineTime) }}
</Descriptions.Item>
<Descriptions.Item label="MQTT 连接参数">
<Button
size="small"
type="link"
@click="handleAuthInfoDialogOpen"
size="small"
>
查看
</Button>
@@ -141,9 +142,13 @@ function handleAuthInfoDialogClose() {
<template #title>
<div class="flex items-center justify-between">
<div class="flex items-center">
<IconifyIcon icon="ep:location" class="mr-2 text-primary" />
<!-- TODO @haohao图标尽量使用中立的这样 ep 版本呢好迁移 -->
<IconifyIcon class="mr-2 text-primary" icon="ep:location" />
<span>设备位置</span>
</div>
<div class="text-sm text-gray-500">
最后上线{{ formatDateTime(props.device.onlineTime) || '--' }}
</div>
</div>
</template>
<div class="h-[500px] w-full">
@@ -157,7 +162,8 @@ function handleAuthInfoDialogClose() {
v-else
class="flex h-full w-full items-center justify-center rounded bg-gray-50 text-gray-400"
>
<IconifyIcon icon="ep:warning" class="mr-2" />
<!-- TODO @haohao图标尽量使用中立的这样 ep 版本呢好迁移 -->
<IconifyIcon class="mr-2" icon="ep:warning" />
<span>暂无位置信息</span>
</div>
</div>
@@ -168,9 +174,9 @@ function handleAuthInfoDialogClose() {
<!-- 认证信息弹框 -->
<Modal
v-model:open="authDialogVisible"
:footer="null"
title="MQTT 连接参数"
width="640px"
:footer="null"
>
<Form :label-col="{ span: 6 }">
<Form.Item label="clientId">
@@ -180,7 +186,7 @@ function handleAuthInfoDialogClose() {
readonly
style="width: calc(100% - 80px)"
/>
<Button @click="copyToClipboard(authInfo.clientId)" type="primary">
<Button type="primary" @click="copyToClipboard(authInfo.clientId)">
<IconifyIcon icon="lucide:copy" />
</Button>
</Input.Group>
@@ -192,7 +198,7 @@ function handleAuthInfoDialogClose() {
readonly
style="width: calc(100% - 80px)"
/>
<Button @click="copyToClipboard(authInfo.username)" type="primary">
<Button type="primary" @click="copyToClipboard(authInfo.username)">
<IconifyIcon icon="lucide:copy" />
</Button>
</Input.Group>
@@ -201,19 +207,19 @@ function handleAuthInfoDialogClose() {
<Input.Group compact>
<Input
v-model:value="authInfo.password"
readonly
:type="authPasswordVisible ? 'text' : 'password'"
readonly
style="width: calc(100% - 160px)"
/>
<Button
@click="authPasswordVisible = !authPasswordVisible"
type="primary"
@click="authPasswordVisible = !authPasswordVisible"
>
<IconifyIcon
:icon="authPasswordVisible ? 'lucide:eye-off' : 'lucide:eye'"
/>
</Button>
<Button @click="copyToClipboard(authInfo.password)" type="primary">
<Button type="primary" @click="copyToClipboard(authInfo.password)">
<IconifyIcon icon="lucide:copy" />
</Button>
</Input.Group>

View File

@@ -0,0 +1,244 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import {
computed,
onBeforeUnmount,
onMounted,
reactive,
ref,
watch,
} from 'vue';
import { Page } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { formatDateTime } from '@vben/utils';
import { Button, Select, Space, Switch, Tag } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getDeviceMessagePage } from '#/api/iot/device/device';
import { IotDeviceMessageMethodEnum } from '#/views/iot/utils/constants';
const props = defineProps<{
deviceId: number;
}>();
/** 查询参数 */
const queryParams = reactive({
method: undefined,
upstream: undefined,
});
/** 自动刷新开关 */
const autoRefresh = ref(false);
/** 自动刷新定时器 */
let autoRefreshTimer: any = null;
/** 消息方法选项 */
const methodOptions = computed(() => {
return Object.values(IotDeviceMessageMethodEnum).map((item) => ({
label: item.name,
value: item.method,
}));
});
/** Grid 列定义 */
function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'ts',
title: '时间',
width: 160,
slots: { default: 'ts' },
},
{
field: 'upstream',
title: '上行/下行',
width: 100,
slots: { default: 'upstream' },
},
{
field: 'reply',
title: '是否回复',
width: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
},
},
{
field: 'requestId',
title: '请求编号',
width: 280,
showOverflow: 'tooltip',
},
{
field: 'method',
title: '请求方法',
width: 120,
slots: { default: 'method' },
},
{
field: 'params',
title: '请求/响应数据',
minWidth: 200,
showOverflow: 'tooltip',
slots: { default: 'params' },
},
];
}
/** 创建 Grid 实例 */
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useGridColumns(),
height: 'auto',
proxyConfig: {
ajax: {
query: async ({ page }) => {
if (!props.deviceId) {
return { list: [], total: 0 };
}
return await getDeviceMessagePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
deviceId: props.deviceId,
method: queryParams.method,
upstream: queryParams.upstream,
});
},
},
},
toolbarConfig: {
refresh: false,
search: false,
},
pagerConfig: {
enabled: true,
},
} as VxeTableGridOptions,
});
/** 搜索操作 */
function handleQuery() {
gridApi.query();
}
/** 监听自动刷新 */
watch(autoRefresh, (newValue) => {
if (newValue) {
autoRefreshTimer = setInterval(() => {
gridApi.query();
}, 5000);
} else {
clearInterval(autoRefreshTimer);
autoRefreshTimer = null;
}
});
/** 监听设备标识变化 */
watch(
() => props.deviceId,
(newValue) => {
if (newValue) {
handleQuery();
}
},
);
/** 组件卸载时清除定时器 */
onBeforeUnmount(() => {
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer);
autoRefreshTimer = null;
}
});
/** 初始化 */
onMounted(() => {
if (props.deviceId) {
handleQuery();
}
});
/** 刷新消息列表 */
function refresh(delay = 0) {
if (delay > 0) {
setTimeout(() => {
gridApi.query();
}, delay);
} else {
gridApi.query();
}
}
/** 暴露方法给父组件 */
defineExpose({
refresh,
});
</script>
<template>
<Page auto-content-height>
<!-- 搜索区域 -->
<div class="mb-4 flex flex-wrap items-center gap-3">
<Select
v-model:value="queryParams.method"
allow-clear
placeholder="所有方法"
style="width: 160px"
>
<Select.Option
v-for="item in methodOptions"
:key="item.value"
:label="item.label"
:value="item.value"
>
{{ item.label }}
</Select.Option>
</Select>
<Select
v-model:value="queryParams.upstream"
allow-clear
placeholder="上行/下行"
style="width: 160px"
>
<Select.Option label="上行" value="true">上行</Select.Option>
<Select.Option label="下行" value="false">下行</Select.Option>
</Select>
<Space>
<Button type="primary" @click="handleQuery">
<IconifyIcon icon="ep:search" class="mr-5px" /> 搜索
</Button>
<Switch
v-model:checked="autoRefresh"
checked-children="定时刷新"
un-checked-children="定时刷新"
/>
</Space>
</div>
<!-- 消息列表 -->
<Grid>
<template #ts="{ row }">
{{ formatDateTime(row.ts) }}
</template>
<template #upstream="{ row }">
<Tag :color="row.upstream ? 'blue' : 'green'">
{{ row.upstream ? '上行' : '下行' }}
</Tag>
</template>
<template #method="{ row }">
{{ methodOptions.find((item) => item.value === row.method)?.label }}
</template>
<template #params="{ row }">
<span v-if="row.reply">
{{ `{"code":${row.code},"msg":"${row.msg}","data":${row.data}\}` }}
</span>
<span v-else>{{ row.params }}</span>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,618 @@
<!-- 模拟设备 -->
<script lang="ts" setup>
import type { TableColumnType } from 'ant-design-vue';
import type { IotDeviceApi } from '#/api/iot/device/device';
import type { IotProductApi } from '#/api/iot/product/product';
import type { ThingModelData } from '#/api/iot/thingmodel';
import { computed, ref } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { DeviceStateEnum } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import {
Button,
Card,
Col,
Input,
message,
Row,
Table,
Tabs,
Textarea,
} from 'ant-design-vue';
import { sendDeviceMessage } from '#/api/iot/device/device';
import {
IotDeviceMessageMethodEnum,
IoTThingModelTypeEnum,
} from '#/views/iot/utils/constants';
import DataDefinition from '../../../../thingmodel/modules/components/data-definition.vue';
import DeviceDetailsMessage from './message.vue';
const props = defineProps<{
device: IotDeviceApi.Device;
product: IotProductApi.Product;
thingModelList: ThingModelData[];
}>();
// 消息弹窗
const activeTab = ref('upstream'); // 上行upstream、下行downstream
const upstreamTab = ref(IotDeviceMessageMethodEnum.PROPERTY_POST.method); // 上行子标签
const downstreamTab = ref(IotDeviceMessageMethodEnum.PROPERTY_SET.method); // 下行子标签
const deviceMessageRef = ref(); // 设备消息组件引用
const deviceMessageRefreshDelay = 2000; // 延迟 N 秒,保证模拟上行的消息被处理
// 折叠状态
const debugCollapsed = ref(false); // 指令调试区域折叠状态
const messageCollapsed = ref(false); // 设备消息区域折叠状态
// 表单数据:存储用户输入的模拟值
const formData = ref<Record<string, string>>({});
// 根据类型过滤物模型数据
const getFilteredThingModelList = (type: number) => {
return props.thingModelList.filter(
(item) => String(item.type) === String(type),
);
};
// 计算属性:属性列表
const propertyList = computed(() =>
getFilteredThingModelList(IoTThingModelTypeEnum.PROPERTY),
);
// 计算属性:事件列表
const eventList = computed(() =>
getFilteredThingModelList(IoTThingModelTypeEnum.EVENT),
);
// 计算属性:服务列表
const serviceList = computed(() =>
getFilteredThingModelList(IoTThingModelTypeEnum.SERVICE),
);
// 属性表格列定义
const propertyColumns: TableColumnType[] = [
{
title: '功能名称',
dataIndex: 'name',
key: 'name',
width: 100,
fixed: 'left' as any,
},
{
title: '标识符',
dataIndex: 'identifier',
key: 'identifier',
width: 120,
fixed: 'left' as any,
},
{
title: '数据类型',
key: 'dataType',
width: 90,
},
{
title: '数据定义',
key: 'dataDefinition',
minWidth: 150,
},
{
title: '值',
key: 'value',
width: 180,
fixed: 'right' as any,
},
];
// 事件表格列定义
const eventColumns = [
{
title: '功能名称',
dataIndex: 'name',
key: 'name',
width: 100,
fixed: 'left' as any,
},
{
title: '标识符',
dataIndex: 'identifier',
key: 'identifier',
width: 120,
fixed: 'left' as any,
},
{
title: '数据类型',
key: 'dataType',
width: 90,
},
{
title: '数据定义',
key: 'dataDefinition',
minWidth: 150,
},
{
title: '值',
key: 'value',
width: 180,
},
{
title: '操作',
key: 'action',
width: 100,
fixed: 'right' as any,
},
];
// 服务表格列定义
const serviceColumns = [
{
title: '服务名称',
dataIndex: 'name',
key: 'name',
width: 100,
fixed: 'left' as any,
},
{
title: '标识符',
dataIndex: 'identifier',
key: 'identifier',
width: 120,
fixed: 'left' as any,
},
{
title: '输入参数',
key: 'dataDefinition',
minWidth: 150,
},
{
title: '参数值',
key: 'value',
width: 180,
},
{
title: '操作',
key: 'action',
width: 100,
fixed: 'right' as any,
},
];
// 获取表单值
function getFormValue(identifier: string) {
return formData.value[identifier] || '';
}
// 设置表单值
function setFormValue(identifier: string, value: string) {
formData.value[identifier] = value;
}
// 属性上报
async function handlePropertyPost() {
try {
const params: Record<string, any> = {};
propertyList.value.forEach((item) => {
const value = formData.value[item.identifier!];
if (value) {
params[item.identifier!] = value;
}
});
if (Object.keys(params).length === 0) {
message.warning({ content: '请至少输入一个属性值' });
return;
}
await sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.PROPERTY_POST.method,
params,
});
message.success({ content: '属性上报成功' });
// 延迟刷新设备消息列表
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay);
} catch (error) {
message.error({ content: '属性上报失败' });
console.error(error);
}
}
// 事件上报
async function handleEventPost(row: ThingModelData) {
try {
const valueStr = formData.value[row.identifier!];
let params: any = {};
if (valueStr) {
try {
params = JSON.parse(valueStr);
} catch {
message.error({ content: '事件参数格式错误请输入有效的JSON格式' });
return;
}
}
await sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.EVENT_POST.method,
params: {
identifier: row.identifier,
params,
},
});
message.success({ content: '事件上报成功' });
// 延迟刷新设备消息列表
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay);
} catch (error) {
message.error({ content: '事件上报失败' });
console.error(error);
}
}
// 状态变更
async function handleDeviceState(state: number) {
try {
await sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.STATE_UPDATE.method,
params: { state },
});
message.success({ content: '状态变更成功' });
// 延迟刷新设备消息列表
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay);
} catch (error) {
message.error({ content: '状态变更失败' });
console.error(error);
}
}
// 属性设置
async function handlePropertySet() {
try {
const params: Record<string, any> = {};
propertyList.value.forEach((item) => {
const value = formData.value[item.identifier!];
if (value) {
params[item.identifier!] = value;
}
});
if (Object.keys(params).length === 0) {
message.warning({ content: '请至少输入一个属性值' });
return;
}
await sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.PROPERTY_SET.method,
params,
});
message.success({ content: '属性设置成功' });
// 延迟刷新设备消息列表
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay);
} catch (error) {
message.error({ content: '属性设置失败' });
console.error(error);
}
}
// 服务调用
async function handleServiceInvoke(row: ThingModelData) {
try {
const valueStr = formData.value[row.identifier!];
let params: any = {};
if (valueStr) {
try {
params = JSON.parse(valueStr);
} catch {
message.error({ content: '服务参数格式错误请输入有效的JSON格式' });
return;
}
}
await sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.SERVICE_INVOKE.method,
params: {
identifier: row.identifier,
params,
},
});
message.success({ content: '服务调用成功' });
// 延迟刷新设备消息列表
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay);
} catch (error) {
message.error({ content: '服务调用失败' });
console.error(error);
}
}
</script>
<template>
<ContentWrap>
<Row :gutter="16">
<!-- 左侧指令调试区域 -->
<Col :lg="12" :md="24" :sm="24" :xl="12" :xs="24">
<Card class="simulator-tabs h-full">
<template #title>
<div class="flex items-center justify-between">
<span>指令调试</span>
<Button
size="small"
type="text"
@click="debugCollapsed = !debugCollapsed"
>
<IconifyIcon v-if="!debugCollapsed" icon="lucide:chevron-up" />
<IconifyIcon v-if="debugCollapsed" icon="lucide:chevron-down" />
</Button>
</div>
</template>
<div v-show="!debugCollapsed">
<Tabs v-model:active-key="activeTab" size="small">
<!-- 上行指令调试 -->
<Tabs.TabPane key="upstream" tab="上行指令调试">
<Tabs
v-if="activeTab === 'upstream'"
v-model:active-key="upstreamTab"
size="small"
>
<!-- 属性上报 -->
<Tabs.TabPane
:key="IotDeviceMessageMethodEnum.PROPERTY_POST.method"
tab="属性上报"
>
<ContentWrap>
<Table
:columns="propertyColumns"
:data-source="propertyList"
:pagination="false"
:scroll="{ x: 'max-content', y: 300 }"
align="center"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'dataType'">
{{ record.property?.dataType ?? '-' }}
</template>
<template v-else-if="column.key === 'dataDefinition'">
<DataDefinition :data="record" />
</template>
<template v-else-if="column.key === 'value'">
<Input
:value="getFormValue(record.identifier)"
placeholder="输入值"
size="small"
@update:value="
setFormValue(record.identifier, $event)
"
/>
</template>
</template>
</Table>
<div class="mt-4 flex items-center justify-between">
<span class="text-sm text-gray-600">
设置属性值后点击发送属性上报按钮
</span>
<Button type="primary" @click="handlePropertyPost">
发送属性上报
</Button>
</div>
</ContentWrap>
</Tabs.TabPane>
<!-- 事件上报 -->
<Tabs.TabPane
:key="IotDeviceMessageMethodEnum.EVENT_POST.method"
tab="事件上报"
>
<ContentWrap>
<Table
:columns="eventColumns"
:data-source="eventList"
:pagination="false"
:scroll="{ x: 'max-content', y: 300 }"
align="center"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'dataType'">
{{ record.event?.dataType ?? '-' }}
</template>
<template v-else-if="column.key === 'dataDefinition'">
<DataDefinition :data="record" />
</template>
<template v-else-if="column.key === 'value'">
<Textarea
:rows="3"
:value="getFormValue(record.identifier)"
placeholder="输入事件参数JSON格式"
size="small"
@update:value="
setFormValue(record.identifier, $event)
"
/>
</template>
<template v-else-if="column.key === 'action'">
<Button
size="small"
type="primary"
@click="handleEventPost(record)"
>
上报事件
</Button>
</template>
</template>
</Table>
</ContentWrap>
</Tabs.TabPane>
<!-- 状态变更 -->
<Tabs.TabPane
:key="IotDeviceMessageMethodEnum.STATE_UPDATE.method"
tab="状态变更"
>
<ContentWrap>
<div class="flex gap-4">
<Button
type="primary"
@click="handleDeviceState(DeviceStateEnum.ONLINE)"
>
设备上线
</Button>
<Button
danger
@click="handleDeviceState(DeviceStateEnum.OFFLINE)"
>
设备下线
</Button>
</div>
</ContentWrap>
</Tabs.TabPane>
</Tabs>
</Tabs.TabPane>
<!-- 下行指令调试 -->
<Tabs.TabPane key="downstream" tab="下行指令调试">
<Tabs
v-if="activeTab === 'downstream'"
v-model:active-key="downstreamTab"
size="small"
>
<!-- 属性调试 -->
<Tabs.TabPane
:key="IotDeviceMessageMethodEnum.PROPERTY_SET.method"
tab="属性设置"
>
<ContentWrap>
<Table
:columns="propertyColumns"
:data-source="propertyList"
:pagination="false"
:scroll="{ x: 'max-content', y: 300 }"
align="center"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'dataType'">
{{ record.property?.dataType ?? '-' }}
</template>
<template v-else-if="column.key === 'dataDefinition'">
<DataDefinition :data="record" />
</template>
<template v-else-if="column.key === 'value'">
<Input
:value="getFormValue(record.identifier)"
placeholder="输入值"
size="small"
@update:value="
setFormValue(record.identifier, $event)
"
/>
</template>
</template>
</Table>
<div class="mt-4 flex items-center justify-between">
<span class="text-sm text-gray-600">
设置属性值后点击发送属性设置按钮
</span>
<Button type="primary" @click="handlePropertySet">
发送属性设置
</Button>
</div>
</ContentWrap>
</Tabs.TabPane>
<!-- 服务调用 -->
<Tabs.TabPane
:key="IotDeviceMessageMethodEnum.SERVICE_INVOKE.method"
tab="设备服务调用"
>
<ContentWrap>
<Table
:columns="serviceColumns"
:data-source="serviceList"
:pagination="false"
:scroll="{ x: 'max-content', y: 300 }"
align="center"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'dataDefinition'">
<DataDefinition :data="record" />
</template>
<template v-else-if="column.key === 'value'">
<Textarea
:rows="3"
:value="getFormValue(record.identifier)"
placeholder="输入服务参数JSON格式"
size="small"
@update:value="
setFormValue(record.identifier, $event)
"
/>
</template>
<template v-else-if="column.key === 'action'">
<Button
size="small"
type="primary"
@click="handleServiceInvoke(record)"
>
服务调用
</Button>
</template>
</template>
</Table>
</ContentWrap>
</Tabs.TabPane>
</Tabs>
</Tabs.TabPane>
</Tabs>
</div>
</Card>
</Col>
<!-- 右侧设备消息区域 -->
<Col :lg="12" :md="24" :sm="24" :xl="12" :xs="24">
<Card class="h-full">
<template #title>
<div class="flex items-center justify-between">
<span>设备消息</span>
<Button
size="small"
type="text"
@click="messageCollapsed = !messageCollapsed"
>
<IconifyIcon
v-if="!messageCollapsed"
icon="lucide:chevron-down"
/>
<IconifyIcon
v-if="messageCollapsed"
icon="lucide:chevron-down"
/>
</Button>
</div>
</template>
<div v-show="!messageCollapsed">
<DeviceDetailsMessage
v-if="device.id"
ref="deviceMessageRef"
:device-id="device.id"
/>
</div>
</Card>
</Col>
</Row>
</ContentWrap>
</template>

View File

@@ -3,6 +3,8 @@ import { onMounted, ref } from 'vue';
import { Card, Empty } from 'ant-design-vue';
// TODO @haohao
interface Props {
deviceId: number;
}
@@ -35,8 +37,8 @@ onMounted(() => {
<template>
<Card :loading="loading" title="子设备管理">
<Empty
description="暂无子设备数据,此功能待实现"
:image="Empty.PRESENTED_IMAGE_SIMPLE"
description="暂无子设备数据,此功能待实现"
/>
<!-- TODO: 实现子设备列表展示和管理功能 -->
</Card>

View File

@@ -0,0 +1,256 @@
<!-- 设备事件管理 -->
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ThingModelData } from '#/api/iot/thingmodel';
import { computed, onMounted, reactive, watch } from 'vue';
import { Page } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { formatDateTime } from '@vben/utils';
import { Button, RangePicker, Select, Space, Tag } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getDeviceMessagePairPage } from '#/api/iot/device/device';
import {
getEventTypeLabel,
IotDeviceMessageMethodEnum,
IoTThingModelTypeEnum,
} from '#/views/iot/utils/constants';
const props = defineProps<{
deviceId: number;
thingModelList: ThingModelData[];
}>();
/** 查询参数 */
const queryParams = reactive({
identifier: '',
times: undefined as [string, string] | undefined,
});
/** 事件类型的物模型数据 */
const eventThingModels = computed(() => {
return props.thingModelList.filter(
(item: ThingModelData) =>
String(item.type) === String(IoTThingModelTypeEnum.EVENT),
);
});
/** Grid 列定义 */
function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'reportTime',
title: '上报时间',
width: 180,
slots: { default: 'reportTime' },
},
{
field: 'identifier',
title: '标识符',
width: 160,
slots: { default: 'identifier' },
},
{
field: 'eventName',
title: '事件名称',
width: 160,
slots: { default: 'eventName' },
},
{
field: 'eventType',
title: '事件类型',
width: 100,
slots: { default: 'eventType' },
},
{
field: 'params',
title: '输入参数',
minWidth: 200,
showOverflow: 'tooltip',
slots: { default: 'params' },
},
];
}
/** 创建 Grid 实例 */
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useGridColumns(),
height: 'auto',
proxyConfig: {
ajax: {
query: async ({ page }) => {
if (!props.deviceId) {
return { list: [], total: 0 };
}
return await getDeviceMessagePairPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
deviceId: props.deviceId,
method: IotDeviceMessageMethodEnum.EVENT_POST.method,
identifier: queryParams.identifier || undefined,
times: queryParams.times,
});
},
},
},
toolbarConfig: {
refresh: false,
search: false,
},
pagerConfig: {
enabled: true,
},
} as VxeTableGridOptions,
});
/** 搜索按钮操作 */
function handleQuery() {
gridApi.query();
}
/** 重置按钮操作 */
function resetQuery() {
queryParams.identifier = '';
queryParams.times = undefined;
handleQuery();
}
/** 获取事件名称 */
function getEventName(identifier: string | undefined) {
if (!identifier) return '-';
const event = eventThingModels.value.find(
(item: ThingModelData) => item.identifier === identifier,
);
return event?.name || identifier;
}
/** 获取事件类型 */
function getEventType(identifier: string | undefined) {
if (!identifier) return '-';
const event = eventThingModels.value.find(
(item: ThingModelData) => item.identifier === identifier,
);
if (!event?.event?.type) return '-';
return getEventTypeLabel(event.event.type) || '-';
}
/** 解析参数 */
function parseParams(params: string) {
try {
const parsed = JSON.parse(params);
if (parsed.params) {
return parsed.params;
}
return parsed;
} catch {
return {};
}
}
/** 刷新列表 */
function refresh(delay = 0) {
if (delay > 0) {
setTimeout(() => gridApi.query(), delay);
} else {
gridApi.query();
}
}
/** 监听设备标识变化 */
watch(
() => props.deviceId,
(newValue) => {
if (newValue) {
handleQuery();
}
},
);
/** 初始化 */
onMounted(() => {
if (props.deviceId) {
handleQuery();
}
});
/** 暴露方法给父组件 */
defineExpose({
refresh,
});
</script>
<template>
<Page auto-content-height>
<!-- 搜索区域 -->
<div class="mb-4 flex flex-wrap items-center gap-3">
<div class="flex items-center gap-2">
<span>标识符</span>
<Select
v-model:value="queryParams.identifier"
allow-clear
placeholder="请选择事件标识符"
style="width: 240px"
>
<Select.Option
v-for="event in eventThingModels"
:key="event.identifier"
:value="event.identifier!"
>
{{ event.name }}({{ event.identifier }})
</Select.Option>
</Select>
</div>
<div class="flex items-center gap-2">
<span>时间范围</span>
<RangePicker
v-model:value="queryParams.times"
format="YYYY-MM-DD HH:mm:ss"
show-time
style="width: 360px"
value-format="YYYY-MM-DD HH:mm:ss"
/>
</div>
<Space>
<Button type="primary" @click="handleQuery">
<template #icon>
<IconifyIcon icon="ep:search" />
</template>
搜索
</Button>
<Button @click="resetQuery">
<template #icon>
<IconifyIcon icon="ep:refresh" />
</template>
重置
</Button>
</Space>
</div>
<!-- 事件列表 -->
<Grid>
<template #reportTime="{ row }">
{{
row.request?.reportTime ? formatDateTime(row.request.reportTime) : '-'
}}
</template>
<template #identifier="{ row }">
<Tag color="blue" size="small">
{{ row.request?.identifier }}
</Tag>
</template>
<template #eventName="{ row }">
{{ getEventName(row.request?.identifier) }}
</template>
<template #eventType="{ row }">
{{ getEventType(row.request?.identifier) }}
</template>
<template #params="{ row }">
{{ parseParams(row.request?.params) }}
</template>
</Grid>
</Page>
</template>

View File

@@ -1,6 +1,5 @@
<!-- 设备物模型 -> 运行状态 -> 查看数据设备的属性值历史-->
// ,
<script setup lang="ts">
<script lang="ts" setup>
import type { Dayjs } from 'dayjs';
import type { EchartsUIType } from '@vben/plugins/echarts';
@@ -11,14 +10,13 @@ import { computed, nextTick, reactive, ref, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { beginOfDay, endOfDay, formatDate, formatDateTime } from '@vben/utils';
import { formatDate, formatDateTime } from '@vben/utils';
import {
Button,
Empty,
message,
Modal,
RangePicker,
Space,
Spin,
Table,
@@ -27,6 +25,7 @@ import {
import dayjs from 'dayjs';
import { getHistoryDevicePropertyList } from '#/api/iot/device/device';
import ShortcutDateRangePicker from '#/components/shortcut-date-range-picker/shortcut-date-range-picker.vue';
import { IoTDataSpecsDataTypeEnum } from '#/views/iot/utils/constants';
/** IoT 设备属性历史数据详情 */
@@ -42,52 +41,70 @@ const list = ref<IotDeviceApi.DevicePropertyDetail[]>([]); // 列表的数据
const total = ref(0); //
const thingModelDataType = ref<string>(''); //
const propertyIdentifier = ref<string>(''); //
const dateRange = ref<[Dayjs, Dayjs]>([
dayjs().subtract(7, 'day').startOf('day'),
dayjs().endOf('day'),
]);
const dateRange = ref<[string, string]>([
dayjs().subtract(6, 'day').format('YYYY-MM-DD'),
dayjs().format('YYYY-MM-DD'),
]); //
const queryParams = reactive({
deviceId: -1,
identifier: '',
times: [
formatDateTime(beginOfDay(new Date(Date.now() - 3600 * 1000 * 24 * 7))),
formatDateTime(endOfDay(new Date())),
],
times: formatDateRangeWithTime(dateRange.value),
});
// Echarts
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
// struct array
const isComplexDataType = computed(() => {
/** 不支持图表展示的数据类型列表 */
const CHART_DISABLED_DATA_TYPES = [
IoTDataSpecsDataTypeEnum.ARRAY, //
IoTDataSpecsDataTypeEnum.STRUCT, //
IoTDataSpecsDataTypeEnum.TEXT, //
IoTDataSpecsDataTypeEnum.BOOL, //
IoTDataSpecsDataTypeEnum.ENUM, //
IoTDataSpecsDataTypeEnum.DATE, //
] as const;
/** 判断是否支持图表展示仅数值类型支持int、float、double */
const canShowChart = computed(() => {
if (!thingModelDataType.value) return false;
return [
IoTDataSpecsDataTypeEnum.ARRAY,
IoTDataSpecsDataTypeEnum.STRUCT,
].includes(thingModelDataType.value as any);
return !CHART_DISABLED_DATA_TYPES.includes(
thingModelDataType.value as (typeof CHART_DISABLED_DATA_TYPES)[number],
);
});
//
/** 判断是否为复杂数据类型(用于格式化显示) */
const isComplexDataType = computed(() => {
if (!thingModelDataType.value) return false;
return (
thingModelDataType.value === IoTDataSpecsDataTypeEnum.ARRAY ||
thingModelDataType.value === IoTDataSpecsDataTypeEnum.STRUCT
);
});
/** 最大值统计 */
const maxValue = computed(() => {
if (isComplexDataType.value || list.value.length === 0) return '-';
if (!canShowChart.value || list.value.length === 0) return '-';
const values = list.value
.map((item) => Number(item.value))
.filter((v) => !Number.isNaN(v));
return values.length > 0 ? Math.max(...values).toFixed(2) : '-';
});
/** 最小值统计 */
const minValue = computed(() => {
if (isComplexDataType.value || list.value.length === 0) return '-';
if (!canShowChart.value || list.value.length === 0) return '-';
const values = list.value
.map((item) => Number(item.value))
.filter((v) => !Number.isNaN(v));
return values.length > 0 ? Math.min(...values).toFixed(2) : '-';
});
/** 平均值统计 */
const avgValue = computed(() => {
if (isComplexDataType.value || list.value.length === 0) return '-';
if (!canShowChart.value || list.value.length === 0) return '-';
const values = list.value
.map((item) => Number(item.value))
.filter((v) => !Number.isNaN(v));
@@ -96,7 +113,11 @@ const avgValue = computed(() => {
return (sum / values.length).toFixed(2);
});
//
/** 将日期范围转换为带时分秒的格式 */
function formatDateRangeWithTime(dates: [string, string]): [string, string] {
return [`${dates[0]} 00:00:00`, `${dates[1]} 23:59:59`];
}
const tableColumns = computed(() => [
{
title: '序号',
@@ -118,9 +139,8 @@ const tableColumns = computed(() => [
dataIndex: 'value',
align: 'center' as const,
},
]);
]); //
//
const paginationConfig = computed(() => ({
current: 1,
pageSize: 10,
@@ -129,7 +149,7 @@ const paginationConfig = computed(() => ({
showQuickJumper: true,
pageSizeOptions: ['10', '20', '50', '100'],
showTotal: (total: number) => `${total} 条数据`,
}));
})); //
/** 获得设备历史数据 */
async function getList() {
@@ -142,16 +162,13 @@ async function getList() {
) as IotDeviceApi.DevicePropertyDetail[];
total.value = list.value.length;
//
//
if (
viewMode.value === 'chart' &&
!isComplexDataType.value &&
canShowChart.value &&
list.value.length > 0
) {
// DOM
await nextTick();
await nextTick(); // nextTick DOM
renderChart();
await renderChartWhenReady();
}
} catch {
message.error('获取数据失败');
@@ -162,126 +179,115 @@ async function getList() {
}
}
/** 确保图表容器已经可见后再渲染 */
async function renderChartWhenReady() {
if (!list.value || list.value.length === 0) {
return;
}
// ModalCard loading v-show DOM
await nextTick();
await nextTick();
renderChart();
}
/** 渲染图表 */
function renderChart() {
if (!list.value || list.value.length === 0) {
return;
}
const chartData = list.value.map((item) => [item.updateTime, item.value]);
const times = list.value.map((item) =>
formatDate(new Date(item.updateTime), 'YYYY-MM-DD HH:mm:ss'),
);
const values = list.value.map((item) => Number(item.value));
// 使 setTimeout ECharts
setTimeout(() => {
// chartRef
if (!chartRef.value || !chartRef.value.$el) {
return;
}
renderEcharts({
title: {
text: '属性值趋势',
left: 'center',
textStyle: {
fontSize: 16,
fontWeight: 'normal',
},
renderEcharts({
title: {
text: '属性值趋势',
left: 'center',
textStyle: {
fontSize: 16,
fontWeight: 'normal',
},
grid: {
left: 60,
right: 60,
bottom: 100,
top: 80,
containLabel: true,
},
grid: {
left: 60,
right: 60,
bottom: 100,
top: 80,
containLabel: true,
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
},
formatter: (params: any) => {
const param = params[0];
return `
<div style="padding: 8px;">
<div style="margin-bottom: 4px; font-weight: bold;">
${formatDate(new Date(param.value[0]), 'YYYY-MM-DD HH:mm:ss')}
</div>
<div>
<span style="display:inline-block;margin-right:5px;border-radius:50%;width:10px;height:10px;background-color:${param.color};"></span>
<span>属性值: <strong>${param.value[1]}</strong></span>
</div>
</div>
`;
},
},
xAxis: {
type: 'category',
boundaryGap: false,
name: '时间',
nameTextStyle: {
padding: [10, 0, 0, 0],
},
xAxis: {
type: 'time',
name: '时间',
nameTextStyle: {
padding: [10, 0, 0, 0],
},
axisLabel: {
formatter: (value: number) => {
return String(formatDate(new Date(value), 'MM-DD HH:mm') || '');
},
},
data: times,
},
yAxis: {
type: 'value',
name: '属性值',
nameTextStyle: {
padding: [0, 0, 10, 0],
},
yAxis: {
type: 'value',
},
series: [
{
name: '属性值',
nameTextStyle: {
padding: [0, 0, 10, 0],
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 6,
lineStyle: {
width: 2,
color: '#1890FF',
},
itemStyle: {
color: '#1890FF',
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{
offset: 0,
color: 'rgba(24, 144, 255, 0.3)',
},
{
offset: 1,
color: 'rgba(24, 144, 255, 0.05)',
},
],
},
},
data: values,
},
series: [
{
name: '属性值',
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 6,
lineStyle: {
width: 2,
color: '#1890FF',
},
itemStyle: {
color: '#1890FF',
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{
offset: 0,
color: 'rgba(24, 144, 255, 0.3)',
},
{
offset: 1,
color: 'rgba(24, 144, 255, 0.05)',
},
],
},
},
data: chartData,
},
],
dataZoom: [
{
type: 'inside',
start: 0,
end: 100,
},
{
type: 'slider',
height: 30,
bottom: 20,
},
],
});
}, 300); // 300ms DOM
],
dataZoom: [
{
type: 'inside',
start: 0,
end: 100,
},
{
type: 'slider',
height: 30,
bottom: 20,
},
],
});
}
/** 打开弹窗 */
@@ -294,42 +300,33 @@ async function open(deviceId: number, identifier: string, dataType: string) {
// 7
dateRange.value = [
dayjs().subtract(7, 'day').startOf('day'),
dayjs().endOf('day'),
dayjs().subtract(6, 'day').format('YYYY-MM-DD'),
dayjs().format('YYYY-MM-DD'),
];
//
queryParams.times = [
formatDateTime(dateRange.value[0].toDate()),
formatDateTime(dateRange.value[1].toDate()),
];
queryParams.times = formatDateRangeWithTime(dateRange.value);
// structarray使 list
viewMode.value = isComplexDataType.value ? 'list' : 'chart';
// 使
viewMode.value = canShowChart.value ? 'chart' : 'list';
//
await nextTick();
await nextTick(); // nextTick Modal
await getList();
//
if (viewMode.value === 'chart' && !isComplexDataType.value) {
setTimeout(() => {
renderChart();
}, 500);
}
}
/** 时间变化处理 */
function handleTimeChange() {
if (!dateRange.value || dateRange.value.length !== 2) {
/** 处理时间范围变化 */
function handleDateRangeChange(times?: [Dayjs, Dayjs]) {
if (!times || times.length !== 2) {
return;
}
queryParams.times = [
formatDateTime(dateRange.value[0].toDate()),
formatDateTime(dateRange.value[1].toDate()),
dateRange.value = [
dayjs(times[0]).format('YYYY-MM-DD'),
dayjs(times[1]).format('YYYY-MM-DD'),
];
// 00:00:00 23:59:59
queryParams.times = formatDateRangeWithTime(dateRange.value);
getList();
}
@@ -403,19 +400,8 @@ function formatComplexValue(value: any) {
/** 监听视图模式变化,重新渲染图表 */
watch(viewMode, async (newMode) => {
if (
newMode === 'chart' &&
!isComplexDataType.value &&
list.value.length > 0
) {
// DOM
await nextTick();
await nextTick();
//
setTimeout(() => {
renderChart();
}, 300);
if (newMode === 'chart' && canShowChart.value && list.value.length > 0) {
await renderChartWhenReady();
}
});
@@ -426,7 +412,6 @@ defineExpose({ open }); // 提供 open 方法,用于打开弹窗
v-model:open="dialogVisible"
title="查看数据"
width="1200px"
:destroy-on-close="true"
@cancel="handleClose"
>
<div class="property-history-container">
@@ -434,17 +419,15 @@ defineExpose({ open }); // 提供 open 方法,用于打开弹窗
<div class="toolbar-wrapper mb-4">
<Space :size="12" class="w-full" wrap>
<!-- 时间选择 -->
<RangePicker
v-model:value="dateRange"
:show-time="{ format: 'HH:mm:ss' }"
format="YYYY-MM-DD HH:mm:ss"
:placeholder="['开始时间', '结束时间']"
class="!w-[400px]"
@change="handleTimeChange"
/>
<div class="flex items-center gap-3">
<span class="whitespace-nowrap text-sm text-gray-500">
时间范围
</span>
<ShortcutDateRangePicker @change="handleDateRangeChange" />
</div>
<!-- 刷新按钮 -->
<Button @click="handleRefresh" :loading="loading">
<Button :loading="loading" @click="handleRefresh">
<template #icon>
<IconifyIcon icon="ant-design:reload-outlined" />
</template>
@@ -453,9 +436,9 @@ defineExpose({ open }); // 提供 open 方法,用于打开弹窗
<!-- 导出按钮 -->
<Button
@click="handleExport"
:loading="exporting"
:disabled="list.length === 0"
:loading="exporting"
@click="handleExport"
>
<template #icon>
<IconifyIcon icon="ant-design:export-outlined" />
@@ -466,9 +449,9 @@ defineExpose({ open }); // 提供 open 方法,用于打开弹窗
<!-- 视图切换 -->
<Button.Group class="ml-auto">
<Button
:disabled="!canShowChart"
:type="viewMode === 'chart' ? 'primary' : 'default'"
@click="viewMode = 'chart'"
:disabled="isComplexDataType"
>
<template #icon>
<IconifyIcon icon="ant-design:line-chart-outlined" />
@@ -491,7 +474,7 @@ defineExpose({ open }); // 提供 open 方法,用于打开弹窗
<div v-if="list.length > 0" class="mt-3 text-sm text-gray-600">
<Space :size="16">
<span> {{ total }} 条数据</span>
<span v-if="viewMode === 'chart' && !isComplexDataType">
<span v-if="viewMode === 'chart' && canShowChart">
最大值: {{ maxValue }} | 最小值: {{ minValue }} | 平均值:
{{ avgValue }}
</span>
@@ -500,16 +483,16 @@ defineExpose({ open }); // 提供 open 方法,用于打开弹窗
</div>
<!-- 数据展示区域 -->
<Spin :spinning="loading" :delay="200">
<!-- 图表模式 -->
<Spin :delay="200" :spinning="loading">
<!-- 图表模式 - 使用 v-show 确保图表组件始终挂载 -->
<div v-show="viewMode === 'chart'" class="chart-container">
<Empty
v-if="list.length === 0"
:image="Empty.PRESENTED_IMAGE_SIMPLE"
description="暂无数据"
class="py-20"
:description="$t('common.noData')"
/>
<div v-else>
<div v-show="list.length > 0">
<EchartsUI ref="chartRef" height="500px" />
</div>
</div>
@@ -517,8 +500,8 @@ defineExpose({ open }); // 提供 open 方法,用于打开弹窗
<!-- 表格模式 -->
<div v-show="viewMode === 'list'" class="table-container">
<Table
:data-source="list"
:columns="tableColumns"
:data-source="list"
:pagination="paginationConfig"
:scroll="{ y: 500 }"
row-key="updateTime"
@@ -546,7 +529,7 @@ defineExpose({ open }); // 提供 open 方法,用于打开弹窗
</Modal>
</template>
<style scoped lang="scss">
<style lang="scss" scoped>
.property-history-container {
max-height: 70vh;
overflow: auto;
@@ -561,7 +544,7 @@ defineExpose({ open }); // 提供 open 方法,用于打开弹窗
.chart-container,
.table-container {
padding: 16px;
background-color: hsl(var(--card));
background-color: hsl(var(--card) / 100%);
border: 1px solid hsl(var(--border) / 60%);
border-radius: 8px;
}

View File

@@ -1,12 +1,20 @@
<!-- 设备属性管理 -->
<script setup lang="ts">
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { IotDeviceApi } from '#/api/iot/device/device';
import { onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import {
nextTick,
onBeforeUnmount,
onMounted,
reactive,
ref,
watch,
} from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { Page } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { formatDate } from '@vben/utils';
import { formatDateTime } from '@vben/utils';
import {
Button,
@@ -16,13 +24,13 @@ import {
Input,
Row,
Switch,
Table,
Tag,
} from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getLatestDeviceProperties } from '#/api/iot/device/device';
import DeviceDetailsThingModelPropertyHistory from './device-details-thing-model-property-history.vue';
import DeviceDetailsThingModelPropertyHistory from './thing-model-property-history.vue';
const props = defineProps<{ deviceId: number }>();
@@ -31,22 +39,146 @@ const list = ref<IotDeviceApi.DevicePropertyDetail[]>([]); // 显示的列表数
const filterList = ref<IotDeviceApi.DevicePropertyDetail[]>([]); //
const queryParams = reactive({
keyword: '' as string,
});
}); //
const autoRefresh = ref(false); //
let autoRefreshTimer: any = null; //
const viewMode = ref<'card' | 'list'>('card'); //
/** Grid 列定义 */
function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'identifier',
title: '属性标识符',
},
{
field: 'name',
title: '属性名称',
},
{
field: 'dataType',
title: '数据类型',
},
{
field: 'value',
title: '属性值',
slots: { default: 'value' },
},
{
field: 'updateTime',
title: '更新时间',
width: 180,
slots: { default: 'updateTime' },
},
{
title: '操作',
width: 120,
fixed: 'right',
slots: { default: 'actions' },
},
];
}
/** 创建 Grid 实例 */
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useGridColumns(),
height: 'auto',
rowConfig: {
keyField: 'identifier',
isHover: true,
},
proxyConfig: {
ajax: {
query: async () => {
if (!props.deviceId) {
return { list: [], total: 0 };
}
const data = await getLatestDeviceProperties({
deviceId: props.deviceId,
identifier: undefined,
name: undefined,
});
//
let filteredData = data;
if (queryParams.keyword.trim()) {
const keyword = queryParams.keyword.toLowerCase();
filteredData = data.filter(
(item: IotDeviceApi.DevicePropertyDetail) =>
item.identifier?.toLowerCase().includes(keyword) ||
item.name?.toLowerCase().includes(keyword),
);
}
//
filterList.value = data;
list.value = filteredData;
return {
list: filteredData,
total: filteredData.length,
};
},
},
},
toolbarConfig: {
refresh: false,
search: false,
},
pagerConfig: {
enabled: false,
},
} as VxeTableGridOptions<IotDeviceApi.DevicePropertyDetail>,
});
// gridApi.query()
gridApi.query = async () => {
if (viewMode.value === 'list') {
// Grid
if (!props.deviceId) {
return;
}
const data = await getLatestDeviceProperties({
deviceId: props.deviceId,
identifier: undefined,
name: undefined,
});
const dataArray = Array.isArray(data) ? data : [];
let filteredData = dataArray;
if (queryParams.keyword.trim()) {
const keyword = queryParams.keyword.toLowerCase();
filteredData = dataArray.filter(
(item: IotDeviceApi.DevicePropertyDetail) =>
item.identifier?.toLowerCase().includes(keyword) ||
item.name?.toLowerCase().includes(keyword),
);
}
filterList.value = dataArray;
list.value = filteredData;
// Grid
if (gridApi.grid) {
gridApi.grid.loadData(filteredData);
}
} else {
// getList
await getList();
}
};
/** 查询列表 */
async function getList() {
loading.value = true;
try {
const params = {
deviceId: props.deviceId,
identifier: undefined as string | undefined,
name: undefined as string | undefined,
};
filterList.value = await getLatestDeviceProperties(params);
handleFilter();
if (viewMode.value === 'list') {
await gridApi.query();
} else {
//
const params = {
deviceId: props.deviceId,
identifier: undefined as string | undefined,
name: undefined as string | undefined,
};
filterList.value = await getLatestDeviceProperties(params);
handleFilter();
}
} finally {
loading.value = false;
}
@@ -68,7 +200,21 @@ function handleFilter() {
/** 搜索按钮操作 */
function handleQuery() {
handleFilter();
if (viewMode.value === 'list') {
gridApi.query();
} else {
handleFilter();
}
}
/** 视图切换 */
async function handleViewModeChange(mode: 'card' | 'list') {
if (viewMode.value === mode) {
return;
}
viewMode.value = mode;
await nextTick();
gridApi.query();
}
/** 历史操作 */
@@ -90,56 +236,70 @@ function formatValueWithUnit(item: IotDeviceApi.DevicePropertyDetail) {
watch(autoRefresh, (newValue) => {
if (newValue) {
autoRefreshTimer = setInterval(() => {
getList();
}, 5000); // 5
gridApi.query();
}, 5000);
} else {
clearInterval(autoRefreshTimer);
autoRefreshTimer = null;
}
});
/** 监听设备标识变化 */
watch(
() => props.deviceId,
(newValue) => {
if (newValue) {
gridApi.query();
}
},
);
/** 初始化 */
onMounted(async () => {
if (props.deviceId) {
await nextTick();
gridApi.query();
}
});
/** 组件卸载时清除定时器 */
onBeforeUnmount(() => {
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer);
autoRefreshTimer = null;
}
});
/** 初始化 */
onMounted(() => {
getList();
});
</script>
<template>
<ContentWrap>
<Page auto-content-height>
<!-- 搜索工作栏 -->
<div class="flex items-center justify-between" style="margin-bottom: 16px">
<div class="flex items-center" style="gap: 16px">
<Input
v-model:value="queryParams.keyword"
placeholder="请输入属性名称、标识符"
allow-clear
placeholder="请输入属性名称、标识符"
style="width: 240px"
@press-enter="handleQuery"
/>
<Switch
v-model:checked="autoRefresh"
class="ml-20px"
checked-children="定时刷新"
class="ml-20px"
un-checked-children="定时刷新"
/>
</div>
<Button.Group>
<Button
:type="viewMode === 'card' ? 'primary' : 'default'"
@click="viewMode = 'card'"
@click="handleViewModeChange('card')"
>
<IconifyIcon icon="ep:grid" />
</Button>
<Button
:type="viewMode === 'list' ? 'primary' : 'default'"
@click="viewMode = 'list'"
@click="handleViewModeChange('list')"
>
<IconifyIcon icon="ep:list" />
</Button>
@@ -151,19 +311,19 @@ onMounted(() => {
<!-- 卡片视图 -->
<template v-if="viewMode === 'card'">
<Row :gutter="16" v-loading="loading">
<Row v-loading="loading" :gutter="16">
<Col
v-for="item in list"
:key="item.identifier"
:xs="24"
:sm="12"
:md="12"
:lg="6"
:md="12"
:sm="12"
:xs="24"
class="mb-4"
>
<Card
class="relative h-full overflow-hidden transition-colors"
:body-style="{ padding: '0' }"
class="relative h-full overflow-hidden transition-colors"
>
<!-- 添加渐变背景层 -->
<div
@@ -173,12 +333,12 @@ onMounted(() => {
<!-- 标题区域 -->
<div class="mb-3 flex items-center">
<div class="mr-2.5 flex items-center">
<IconifyIcon icon="ep:cpu" class="text-lg text-primary" />
<IconifyIcon class="text-lg text-primary" icon="ep:cpu" />
</div>
<div class="flex-1 text-base font-bold">{{ item.name }}</div>
<!-- 标识符 -->
<div class="mr-2 inline-flex items-center">
<Tag size="small" color="blue">
<Tag color="blue" size="small">
{{ item.identifier }}
</Tag>
</div>
@@ -196,8 +356,8 @@ onMounted(() => {
"
>
<IconifyIcon
icon="ep:data-line"
class="text-lg text-primary"
icon="ep:data-line"
/>
</div>
</div>
@@ -213,7 +373,9 @@ onMounted(() => {
<div class="mb-2.5 last:mb-0">
<span class="mr-2.5 text-muted-foreground">更新时间</span>
<span class="text-sm text-foreground">
{{ item.updateTime ? formatDate(item.updateTime) : '-' }}
{{
item.updateTime ? formatDateTime(item.updateTime) : '-'
}}
</span>
</div>
</div>
@@ -224,45 +386,29 @@ onMounted(() => {
</template>
<!-- 列表视图 -->
<Table v-else v-loading="loading" :data-source="list" :pagination="false">
<Table.Column title="属性标识符" align="center" data-index="identifier" />
<Table.Column title="属性名称" align="center" data-index="name" />
<Table.Column title="数据类型" align="center" data-index="dataType" />
<Table.Column title="属性值" align="center" data-index="value">
<template #default="{ record }">
{{ formatValueWithUnit(record) }}
</template>
</Table.Column>
<Table.Column
title="更新时间"
align="center"
data-index="updateTime"
:width="180"
>
<template #default="{ record }">
{{ record.updateTime ? formatDate(record.updateTime) : '-' }}
</template>
</Table.Column>
<Table.Column title="操作" align="center">
<template #default="{ record }">
<Button
type="link"
@click="
openHistory(props.deviceId, record.identifier, record.dataType)
"
>
查看数据
</Button>
</template>
</Table.Column>
</Table>
<Grid v-show="viewMode === 'list'">
<template #value="{ row }">
{{ formatValueWithUnit(row) }}
</template>
<template #updateTime="{ row }">
{{ row.updateTime ? formatDateTime(row.updateTime) : '-' }}
</template>
<template #actions="{ row }">
<Button
type="link"
@click="openHistory(props.deviceId, row.identifier, row.dataType)"
>
查看数据
</Button>
</template>
</Grid>
<!-- 表单弹窗添加/修改 -->
<DeviceDetailsThingModelPropertyHistory
ref="historyRef"
:device-id="props.deviceId"
/>
</ContentWrap>
</Page>
</template>
<style scoped>
/* 移除 a-row 的额外边距 */

View File

@@ -0,0 +1,281 @@
<!-- 设备服务调用 -->
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ThingModelData } from '#/api/iot/thingmodel';
import { computed, onMounted, reactive, watch } from 'vue';
import { Page } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { formatDateTime } from '@vben/utils';
import { Button, RangePicker, Select, Space, Tag } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getDeviceMessagePairPage } from '#/api/iot/device/device';
import {
getThingModelServiceCallTypeLabel,
IotDeviceMessageMethodEnum,
IoTThingModelTypeEnum,
} from '#/views/iot/utils/constants';
const props = defineProps<{
deviceId: number;
thingModelList: ThingModelData[];
}>();
/** 查询参数 */
const queryParams = reactive({
identifier: '',
times: undefined as [string, string] | undefined,
});
/** 服务类型的物模型数据 */
const serviceThingModels = computed(() => {
return props.thingModelList.filter(
(item: ThingModelData) =>
String(item.type) === String(IoTThingModelTypeEnum.SERVICE),
);
});
/** Grid 列定义 */
function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'requestTime',
title: '调用时间',
width: 180,
slots: { default: 'requestTime' },
},
{
field: 'responseTime',
title: '响应时间',
width: 180,
slots: { default: 'responseTime' },
},
{
field: 'identifier',
title: '标识符',
width: 160,
slots: { default: 'identifier' },
},
{
field: 'serviceName',
title: '服务名称',
width: 160,
slots: { default: 'serviceName' },
},
{
field: 'callType',
title: '调用方式',
width: 100,
slots: { default: 'callType' },
},
{
field: 'inputParams',
title: '输入参数',
minWidth: 200,
showOverflow: 'tooltip',
slots: { default: 'inputParams' },
},
{
field: 'outputParams',
title: '输出参数',
minWidth: 200,
showOverflow: 'tooltip',
slots: { default: 'outputParams' },
},
];
}
/** 创建 Grid 实例 */
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useGridColumns(),
height: 'auto',
proxyConfig: {
ajax: {
query: async ({ page }) => {
if (!props.deviceId) {
return { list: [], total: 0 };
}
return await getDeviceMessagePairPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
deviceId: props.deviceId,
method: IotDeviceMessageMethodEnum.SERVICE_INVOKE.method,
identifier: queryParams.identifier || undefined,
times: queryParams.times,
});
},
},
},
toolbarConfig: {
refresh: false,
search: false,
},
pagerConfig: {
enabled: true,
},
} as VxeTableGridOptions,
});
/** 搜索按钮操作 */
function handleQuery() {
gridApi.query();
}
/** 重置按钮操作 */
function resetQuery() {
queryParams.identifier = '';
queryParams.times = undefined;
handleQuery();
}
/** 获取服务名称 */
function getServiceName(identifier: string | undefined) {
if (!identifier) return '-';
const service = serviceThingModels.value.find(
(item: ThingModelData) => item.identifier === identifier,
);
return service?.name || identifier;
}
/** 获取调用方式 */
function getCallType(identifier: string | undefined) {
if (!identifier) return '-';
const service = serviceThingModels.value.find(
(item: ThingModelData) => item.identifier === identifier,
);
if (!service?.service?.callType) return '-';
return getThingModelServiceCallTypeLabel(service.service.callType) || '-';
}
/** 解析参数 */
function parseParams(params: string) {
if (!params) return '-';
try {
const parsed = JSON.parse(params);
if (parsed.params) {
return JSON.stringify(parsed.params, null, 2);
}
return JSON.stringify(parsed, null, 2);
} catch {
return params;
}
}
/** 刷新列表 */
function refresh(delay = 0) {
if (delay > 0) {
setTimeout(() => gridApi.query(), delay);
} else {
gridApi.query();
}
}
/** 监听设备标识变化 */
watch(
() => props.deviceId,
(newValue) => {
if (newValue) {
handleQuery();
}
},
);
/** 初始化 */
onMounted(() => {
if (props.deviceId) {
handleQuery();
}
});
/** 暴露方法给父组件 */
defineExpose({
refresh,
});
</script>
<template>
<Page auto-content-height>
<!-- 搜索区域 -->
<div class="mb-4 flex flex-wrap items-center gap-3">
<div class="flex items-center gap-2">
<span>标识符</span>
<Select
v-model:value="queryParams.identifier"
allow-clear
placeholder="请选择服务标识符"
style="width: 240px"
>
<Select.Option
v-for="service in serviceThingModels"
:key="service.identifier"
:value="service.identifier!"
>
{{ service.name }}({{ service.identifier }})
</Select.Option>
</Select>
</div>
<div class="flex items-center gap-2">
<span>时间范围</span>
<RangePicker
v-model:value="queryParams.times"
format="YYYY-MM-DD HH:mm:ss"
show-time
style="width: 360px"
value-format="YYYY-MM-DD HH:mm:ss"
/>
</div>
<Space>
<Button type="primary" @click="handleQuery">
<template #icon>
<IconifyIcon icon="ep:search" />
</template>
搜索
</Button>
<Button @click="resetQuery">
<template #icon>
<IconifyIcon icon="ep:refresh" />
</template>
重置
</Button>
</Space>
</div>
<!-- 服务调用列表 -->
<Grid>
<template #requestTime="{ row }">
{{
row.request?.reportTime ? formatDateTime(row.request.reportTime) : '-'
}}
</template>
<template #responseTime="{ row }">
{{ row.reply?.reportTime ? formatDateTime(row.reply.reportTime) : '-' }}
</template>
<template #identifier="{ row }">
<Tag color="blue" size="small">
{{ row.request?.identifier }}
</Tag>
</template>
<template #serviceName="{ row }">
{{ getServiceName(row.request?.identifier) }}
</template>
<template #callType="{ row }">
{{ getCallType(row.request?.identifier) }}
</template>
<template #inputParams="{ row }">
{{ parseParams(row.request?.params) }}
</template>
<template #outputParams="{ row }">
<span v-if="row.reply">
{{
`{"code":${row.reply.code},"msg":"${row.reply.msg}","data":${row.reply.data}\}`
}}
</span>
<span v-else>-</span>
</template>
</Grid>
</Page>
</template>

View File

@@ -1,5 +1,5 @@
<!-- 设备物模型设备属性事件管理服务调用 -->
<script setup lang="ts">
<script lang="ts" setup>
import type { ThingModelData } from '#/api/iot/thingmodel';
import { ref } from 'vue';
@@ -8,9 +8,9 @@ import { ContentWrap } from '@vben/common-ui';
import { Tabs } from 'ant-design-vue';
import DeviceDetailsThingModelEvent from './device-details-thing-model-event.vue';
import DeviceDetailsThingModelProperty from './device-details-thing-model-property.vue';
import DeviceDetailsThingModelService from './device-details-thing-model-service.vue';
import DeviceDetailsThingModelEvent from './thing-model-event.vue';
import DeviceDetailsThingModelProperty from './thing-model-property.vue';
import DeviceDetailsThingModelService from './thing-model-service.vue';
const props = defineProps<{
deviceId: number;

View File

@@ -1,14 +1,17 @@
<script setup lang="ts">
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { IotDeviceApi } from '#/api/iot/device/device';
import type { IotDeviceGroupApi } from '#/api/iot/device/group';
import type { IotProductApi } from '#/api/iot/product/product';
import { onMounted, ref } from 'vue';
import { nextTick, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { IconifyIcon } from '@vben/icons';
import { downloadFileFromBlobPart } from '@vben/utils';
import { downloadFileFromBlobPart, isEmpty } from '@vben/utils';
import {
Button,
@@ -32,23 +35,22 @@ import { getSimpleProductList } from '#/api/iot/product/product';
import { $t } from '#/locales';
import { useGridColumns } from './data';
import DeviceCardView from './modules/device-card-view.vue';
import DeviceForm from './modules/device-form.vue';
import DeviceGroupForm from './modules/device-group-form.vue';
import DeviceImportForm from './modules/device-import-form.vue';
import DeviceCardView from './modules/card-view.vue';
import DeviceForm from './modules/form.vue';
import DeviceGroupForm from './modules/group-form.vue';
import DeviceImportForm from './modules/import-form.vue';
/** IoT 设备列表 */
defineOptions({ name: 'IoTDevice' });
const route = useRoute();
const router = useRouter();
const products = ref<any[]>([]);
const deviceGroups = ref<any[]>([]);
const products = ref<IotProductApi.Product[]>([]);
const deviceGroups = ref<IotDeviceGroupApi.DeviceGroup[]>([]);
const viewMode = ref<'card' | 'list'>('card');
const cardViewRef = ref();
const checkedIds = ref<number[]>([]);
// Modal instances
// TODO @haohao这个界面等 product 改完,在一起看看怎么弄更好。
const [DeviceFormModal, deviceFormModalApi] = useVbenModal({
connectedComponent: DeviceForm,
destroyOnClose: true,
@@ -64,56 +66,53 @@ const [DeviceImportFormModal, deviceImportFormModalApi] = useVbenModal({
destroyOnClose: true,
});
// 搜索参数
const searchParams = ref({
const queryParams = ref({
deviceName: '',
nickname: '',
productId: undefined as number | undefined,
deviceType: undefined as number | undefined,
status: undefined as number | undefined,
groupId: undefined as number | undefined,
});
// 获取字典选项
const getIntDictOptions = (dictType: string) => {
return getDictOptions(dictType, 'number');
};
}); // 搜索参数
/** 搜索 */
function handleSearch() {
if (viewMode.value === 'list') {
gridApi.formApi.setValues(searchParams.value);
gridApi.query();
} else {
cardViewRef.value?.search(searchParams.value);
gridApi.formApi.setValues(queryParams.value);
}
gridApi.query();
}
/** 重置 */
/** 重置搜索 */
function handleReset() {
searchParams.value = {
deviceName: '',
nickname: '',
productId: undefined,
deviceType: undefined,
status: undefined,
groupId: undefined,
};
queryParams.value.deviceName = '';
queryParams.value.nickname = '';
queryParams.value.productId = undefined;
queryParams.value.deviceType = undefined;
queryParams.value.status = undefined;
queryParams.value.groupId = undefined;
handleSearch();
}
/** 刷新 */
/** 刷新表格 */
function handleRefresh() {
if (viewMode.value === 'list') {
gridApi.query();
} else {
cardViewRef.value?.reload();
gridApi.query();
}
/** 视图切换 */
async function handleViewModeChange(mode: 'card' | 'list') {
if (viewMode.value === mode) {
return; // 如果已经是目标视图,不需要切换
}
viewMode.value = mode;
// 等待视图更新后再触发查询
await nextTick();
gridApi.query();
}
/** 导出表格 */
async function handleExport() {
const data = await exportDeviceExcel(searchParams.value);
const data = await exportDeviceExcel(queryParams.value);
downloadFileFromBlobPart({ fileName: '物联网设备.xls', source: data });
}
@@ -142,19 +141,19 @@ function handleCreate() {
}
/** 编辑设备 */
function handleEdit(row: any) {
function handleEdit(row: IotDeviceApi.Device) {
deviceFormModalApi.setData(row).open();
}
/** 删除设备 */
async function handleDelete(row: any) {
async function handleDelete(row: IotDeviceApi.Device) {
const hideLoading = message.loading({
content: `正在删除设备...`,
content: $t('ui.actionMessage.deleting', [row.deviceName]),
duration: 0,
});
try {
await deleteDevice(row.id);
message.success($t('ui.actionMessage.deleteSuccess'));
await deleteDevice(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', [row.deviceName]));
handleRefresh();
} finally {
hideLoading();
@@ -163,19 +162,18 @@ async function handleDelete(row: any) {
/** 批量删除设备 */
async function handleDeleteBatch() {
const checkedRows = gridApi.grid?.getCheckboxRecords() || [];
if (checkedRows.length === 0) {
if (checkedIds.value.length === 0) {
message.warning('请选择要删除的设备');
return;
}
const hideLoading = message.loading({
content: '正在批量删除...',
content: $t('ui.actionMessage.deletingBatch'),
duration: 0,
});
try {
const ids = checkedRows.map((row: any) => row.id);
await deleteDeviceList(ids);
await deleteDeviceList(checkedIds.value);
message.success($t('ui.actionMessage.deleteSuccess'));
checkedIds.value = [];
handleRefresh();
} finally {
hideLoading();
@@ -184,13 +182,11 @@ async function handleDeleteBatch() {
/** 添加到分组 */
function handleAddToGroup() {
const checkedRows = gridApi.grid?.getCheckboxRecords() || [];
if (checkedRows.length === 0) {
if (checkedIds.value.length === 0) {
message.warning('请选择要添加到分组的设备');
return;
}
const ids = checkedRows.map((row: any) => row.id);
deviceGroupFormModalApi.setData(ids).open();
deviceGroupFormModalApi.setData(checkedIds.value).open();
}
/** 设备导入 */
@@ -198,10 +194,15 @@ function handleImport() {
deviceImportFormModalApi.open();
}
function handleRowCheckboxChange({
records,
}: {
records: IotDeviceApi.Device[];
}) {
checkedIds.value = records.map((item) => item.id!);
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: [],
},
gridOptions: {
checkboxConfig: {
highlight: true,
@@ -216,7 +217,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
return await getDevicePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...searchParams.value,
...queryParams.value,
});
},
},
@@ -229,9 +230,24 @@ const [Grid, gridApi] = useVbenVxeGrid({
refresh: true,
search: true,
},
} as VxeTableGridOptions,
} as VxeTableGridOptions<IotDeviceApi.Device>,
gridEvents: {
checkboxAll: handleRowCheckboxChange,
checkboxChange: handleRowCheckboxChange,
},
});
/** 包装 gridApi.query() 方法,统一列表视图和卡片视图的查询接口 */
const originalQuery = gridApi.query.bind(gridApi);
gridApi.query = async (params?: Record<string, any>) => {
if (viewMode.value === 'list') {
return await originalQuery(params);
} else {
// 卡片视图:调用卡片组件的 query 方法
cardViewRef.value?.query();
}
};
/** 初始化 */
onMounted(async () => {
// 获取产品列表
@@ -242,7 +258,7 @@ onMounted(async () => {
// 处理 productId 参数
const { productId } = route.query;
if (productId) {
searchParams.value.productId = Number(productId);
queryParams.value.productId = Number(productId);
// 自动触发搜索
handleSearch();
}
@@ -260,7 +276,7 @@ onMounted(async () => {
<!-- 搜索表单 -->
<div class="mb-3 flex flex-wrap items-center gap-3">
<Select
v-model:value="searchParams.productId"
v-model:value="queryParams.productId"
placeholder="请选择产品"
allow-clear
style="width: 200px"
@@ -274,27 +290,30 @@ onMounted(async () => {
</Select.Option>
</Select>
<Input
v-model:value="searchParams.deviceName"
v-model:value="queryParams.deviceName"
placeholder="请输入 DeviceName"
allow-clear
style="width: 200px"
@press-enter="handleSearch"
/>
<Input
v-model:value="searchParams.nickname"
v-model:value="queryParams.nickname"
placeholder="请输入备注名称"
allow-clear
style="width: 200px"
@press-enter="handleSearch"
/>
<Select
v-model:value="searchParams.deviceType"
v-model:value="queryParams.deviceType"
placeholder="请选择设备类型"
allow-clear
style="width: 200px"
>
<Select.Option
v-for="dict in getIntDictOptions(DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE)"
v-for="dict in getDictOptions(
DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE,
'number',
)"
:key="dict.value"
:value="dict.value"
>
@@ -302,13 +321,13 @@ onMounted(async () => {
</Select.Option>
</Select>
<Select
v-model:value="searchParams.status"
v-model:value="queryParams.status"
placeholder="请选择设备状态"
allow-clear
style="width: 200px"
>
<Select.Option
v-for="dict in getIntDictOptions(DICT_TYPE.IOT_DEVICE_STATE)"
v-for="dict in getDictOptions(DICT_TYPE.IOT_DEVICE_STATE, 'number')"
:key="dict.value"
:value="dict.value"
>
@@ -316,7 +335,7 @@ onMounted(async () => {
</Select.Option>
</Select>
<Select
v-model:value="searchParams.groupId"
v-model:value="queryParams.groupId"
placeholder="请选择设备分组"
allow-clear
style="width: 200px"
@@ -331,67 +350,72 @@ onMounted(async () => {
</Select>
<Button type="primary" @click="handleSearch">
<IconifyIcon icon="ant-design:search-outlined" class="mr-1" />
搜索
{{ $t('common.search') }}
</Button>
<Button @click="handleReset">
<IconifyIcon icon="ant-design:reload-outlined" class="mr-1" />
重置
{{ $t('common.reset') }}
</Button>
</div>
<!-- 操作按钮 -->
<div class="flex items-center justify-between">
<Space :size="12">
<Button
type="primary"
@click="handleCreate"
v-access:code="['iot:device:create']"
>
<IconifyIcon icon="ant-design:plus-outlined" class="mr-1" />
新增
</Button>
<Button
type="primary"
@click="handleExport"
v-access:code="['iot:device:export']"
>
<IconifyIcon icon="ant-design:download-outlined" class="mr-1" />
导出
</Button>
<Button @click="handleImport" v-access:code="['iot:device:import']">
<IconifyIcon icon="ant-design:upload-outlined" class="mr-1" />
导入
</Button>
<Button
v-show="viewMode === 'list'"
@click="handleAddToGroup"
v-access:code="['iot:device:update']"
>
<IconifyIcon icon="ant-design:folder-add-outlined" class="mr-1" />
添加到分组
</Button>
<Button
v-show="viewMode === 'list'"
danger
@click="handleDeleteBatch"
v-access:code="['iot:device:delete']"
>
<IconifyIcon icon="ant-design:delete-outlined" class="mr-1" />
批量删除
</Button>
</Space>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['设备']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['iot:device:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['iot:device:export'],
onClick: handleExport,
},
{
label: $t('ui.actionTitle.import'),
type: 'primary',
icon: ACTION_ICON.UPLOAD,
auth: ['iot:device:import'],
onClick: handleImport,
},
{
label: '添加到分组',
type: 'primary',
icon: 'ant-design:folder-add-outlined',
auth: ['iot:device:update'],
ifShow: () => viewMode === 'list',
disabled: isEmpty(checkedIds),
onClick: handleAddToGroup,
},
{
label: $t('ui.actionTitle.deleteBatch'),
type: 'primary',
danger: true,
icon: ACTION_ICON.DELETE,
auth: ['iot:device:delete'],
ifShow: () => viewMode === 'list',
disabled: isEmpty(checkedIds),
onClick: handleDeleteBatch,
},
]"
/>
<!-- 视图切换 -->
<Space :size="4">
<Button
:type="viewMode === 'card' ? 'primary' : 'default'"
@click="viewMode = 'card'"
@click="handleViewModeChange('card')"
>
<IconifyIcon icon="ant-design:appstore-outlined" />
</Button>
<Button
:type="viewMode === 'list' ? 'primary' : 'default'"
@click="viewMode = 'list'"
@click="handleViewModeChange('list')"
>
<IconifyIcon icon="ant-design:unordered-list-outlined" />
</Button>
@@ -399,18 +423,14 @@ onMounted(async () => {
</div>
</Card>
<Grid v-show="viewMode === 'list'">
<template #toolbar-tools>
<div></div>
</template>
<Grid table-title="设备列表" v-show="viewMode === 'list'">
<!-- 所属产品列 -->
<template #product="{ row }">
<a
class="cursor-pointer text-primary"
@click="openProductDetail(row.productId)"
>
{{ products.find((p: any) => p.id === row.productId)?.name || '-' }}
{{ products.find((p) => p.id === row.productId)?.name || '-' }}
</a>
</template>
@@ -423,7 +443,7 @@ onMounted(async () => {
size="small"
class="mr-1"
>
{{ deviceGroups.find((g: any) => g.id === groupId)?.name }}
{{ deviceGroups.find((g) => g.id === groupId)?.name }}
</Tag>
</template>
<span v-else>-</span>
@@ -434,14 +454,14 @@ onMounted(async () => {
<TableAction
:actions="[
{
label: '查看',
label: $t('common.detail'),
type: 'link',
onClick: openDetail.bind(null, row.id),
onClick: openDetail.bind(null, row.id!),
},
{
label: '日志',
type: 'link',
onClick: openModel.bind(null, row.id),
onClick: openModel.bind(null, row.id!),
},
{
label: $t('common.edit'),
@@ -455,7 +475,7 @@ onMounted(async () => {
danger: true,
icon: ACTION_ICON.DELETE,
popConfirm: {
title: `确认删除设备吗?`,
title: $t('ui.actionMessage.deleteConfirm', [row.deviceName]),
confirm: handleDelete.bind(null, row),
},
},
@@ -470,7 +490,7 @@ onMounted(async () => {
ref="cardViewRef"
:products="products"
:device-groups="deviceGroups"
:search-params="searchParams"
:search-params="queryParams"
@create="handleCreate"
@edit="handleEdit"
@delete="handleDelete"
@@ -482,10 +502,6 @@ onMounted(async () => {
</template>
<style scoped>
:deep(.vxe-toolbar div) {
z-index: 1;
}
/* 隐藏 VxeGrid 自带的搜索表单区域 */
:deep(.vxe-grid--form-wrapper) {
display: none !important;

View File

@@ -0,0 +1,391 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import {
Button,
Card,
Col,
Empty,
Pagination,
Popconfirm,
Row,
Tooltip,
} from 'ant-design-vue';
import { DictTag } from '#/components/dict-tag';
import { getDevicePage } from '#/api/iot/device/device';
interface Props {
products: any[];
deviceGroups: any[];
searchParams?: {
deviceName: string;
deviceType?: number;
groupId?: number;
nickname: string;
productId?: number;
status?: number;
};
}
const props = defineProps<Props>();
const emit = defineEmits<{
create: [];
delete: [row: any];
detail: [id: number];
edit: [row: any];
model: [id: number];
productDetail: [productId: number];
}>();
const loading = ref(false);
const list = ref<any[]>([]);
const total = ref(0);
const queryParams = ref({
pageNo: 1,
pageSize: 12,
});
/** 获取产品名称 */
function getProductName(productId: number) {
const product = props.products.find((p: any) => p.id === productId);
return product?.name || '-';
}
/** 获取设备列表 */
async function getList() {
loading.value = true;
try {
const data = await getDevicePage({
...queryParams.value,
...props.searchParams,
});
list.value = data.list || [];
total.value = data.total || 0;
} finally {
loading.value = false;
}
}
/** 处理页码变化 */
function handlePageChange(page: number, pageSize: number) {
queryParams.value.pageNo = page;
queryParams.value.pageSize = pageSize;
getList();
}
defineExpose({
reload: getList,
search: () => {
queryParams.value.pageNo = 1;
getList();
},
query: () => {
queryParams.value.pageNo = 1;
getList();
},
});
/** 初始化 */
onMounted(() => {
getList();
});
</script>
<template>
<div class="device-card-view">
<!-- 设备卡片列表 -->
<div v-loading="loading" class="min-h-96">
<Row v-if="list.length > 0" :gutter="[16, 16]">
<Col
v-for="item in list"
:key="item.id"
:xs="24"
:sm="12"
:md="12"
:lg="6"
>
<Card
:body-style="{ padding: '16px' }"
class="device-card h-full rounded-lg transition-all duration-300 hover:-translate-y-0.5 hover:shadow-lg"
>
<!-- 顶部标题区域 -->
<div class="mb-3 flex items-center">
<div class="device-icon">
<IconifyIcon icon="mdi:chip" class="text-xl" />
</div>
<div class="ml-3 min-w-0 flex-1">
<div class="device-title">{{ item.deviceName }}</div>
</div>
<DictTag
:type="DICT_TYPE.IOT_DEVICE_STATE"
:value="item.state"
class="status-tag"
/>
</div>
<!-- 内容区域 -->
<div class="mb-3">
<div class="info-list">
<div class="info-item">
<span class="info-label">所属产品</span>
<a
class="info-value cursor-pointer text-primary"
@click="
(e) => {
e.stopPropagation();
emit('productDetail', item.productId);
}
"
>
{{ getProductName(item.productId) }}
</a>
</div>
<div class="info-item">
<span class="info-label">设备类型</span>
<DictTag
:type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE"
:value="item.deviceType"
class="info-tag m-0"
/>
</div>
<div class="info-item">
<span class="info-label">Deviceid</span>
<Tooltip :title="item.Deviceid || item.id" placement="top">
<span class="info-value device-id cursor-pointer">
{{ item.Deviceid || item.id }}
</span>
</Tooltip>
</div>
</div>
</div>
<!-- 按钮组 -->
<div class="action-buttons">
<Button
size="small"
class="action-btn action-btn-edit"
@click="emit('edit', item)"
>
<IconifyIcon icon="lucide:edit" class="mr-1" />
编辑
</Button>
<Button
size="small"
class="action-btn action-btn-detail"
@click="emit('detail', item.id)"
>
<IconifyIcon icon="lucide:eye" class="mr-1" />
详情
</Button>
<Button
size="small"
class="action-btn action-btn-data"
@click="emit('model', item.id)"
>
<IconifyIcon icon="lucide:database" class="mr-1" />
数据
</Button>
<Popconfirm
:title="`确认删除设备 ${item.deviceName} 吗?`"
@confirm="emit('delete', item)"
>
<Button
size="small"
danger
class="action-btn action-btn-delete !w-8"
>
<IconifyIcon icon="lucide:trash-2" class="text-sm" />
</Button>
</Popconfirm>
</div>
</Card>
</Col>
</Row>
<!-- 空状态 -->
<Empty v-else description="暂无设备数据" class="my-20" />
</div>
<!-- 分页 -->
<div v-if="list.length > 0" class="mt-3 flex justify-end">
<Pagination
v-model:current="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
:total="total"
:show-total="(total) => `${total}`"
show-quick-jumper
show-size-changer
:page-size-options="['12', '24', '36', '48']"
@change="handlePageChange"
/>
</div>
</div>
</template>
<style scoped lang="scss">
.device-card-view {
.device-card {
overflow: hidden;
:deep(.ant-card-body) {
display: flex;
flex-direction: column;
height: 100%;
}
// 设备图标
.device-icon {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
color: white;
background: linear-gradient(135deg, #40a9ff 0%, #1890ff 100%);
border-radius: 8px;
}
// 设备标题
.device-title {
overflow: hidden;
text-overflow: ellipsis;
font-size: 15px;
font-weight: 600;
line-height: 36px;
white-space: nowrap;
}
// 状态标签
.status-tag {
font-size: 12px;
}
// 信息列表
.info-list {
.info-item {
display: flex;
align-items: center;
margin-bottom: 8px;
font-size: 13px;
&:last-child {
margin-bottom: 0;
}
.info-label {
flex-shrink: 0;
margin-right: 8px;
opacity: 0.65;
}
.info-value {
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
white-space: nowrap;
&.text-primary {
color: #1890ff;
}
}
.device-id {
display: inline-block;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
font-family: 'Courier New', monospace;
font-size: 12px;
vertical-align: middle;
white-space: nowrap;
opacity: 0.85;
}
.info-tag {
font-size: 12px;
}
}
}
// 按钮组
.action-buttons {
display: flex;
gap: 8px;
padding-top: 12px;
margin-top: auto;
border-top: 1px solid var(--ant-color-split);
.action-btn {
flex: 1;
height: 32px;
font-size: 13px;
border-radius: 6px;
transition: all 0.2s;
&.action-btn-edit {
color: #1890ff;
border-color: #1890ff;
&:hover {
color: white;
background: #1890ff;
}
}
&.action-btn-detail {
color: #52c41a;
border-color: #52c41a;
&:hover {
color: white;
background: #52c41a;
}
}
&.action-btn-data {
color: #fa8c16;
border-color: #fa8c16;
&:hover {
color: white;
background: #fa8c16;
}
}
&.action-btn-delete {
flex: 0 0 32px;
padding: 0;
}
}
}
}
}
// 夜间模式适配
html.dark {
.device-card-view {
.device-card {
.device-title {
color: rgb(255 255 255 / 85%);
}
.info-list {
.info-label {
color: rgb(255 255 255 / 65%);
}
.info-value {
color: rgb(255 255 255 / 85%);
}
.device-id {
color: rgb(255 255 255 / 75%);
}
}
}
}
}
</style>

View File

@@ -1,402 +0,0 @@
<!-- IoT 设备选择使用弹窗展示 -->
<script setup lang="ts">
import type { IotDeviceApi } from '#/api/iot/device/device';
import type { IotDeviceGroupApi } from '#/api/iot/device/group';
import type { IotProductApi } from '#/api/iot/product/product';
import { computed, onMounted, reactive, ref } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { IconifyIcon } from '@vben/icons';
import { formatDate } from '@vben/utils';
import {
Button,
Form,
Input,
message,
Modal,
Pagination,
Radio,
Select,
Table,
Tag,
} from 'ant-design-vue';
import { getDevicePage } from '#/api/iot/device/device';
import { getSimpleDeviceGroupList } from '#/api/iot/device/group';
import { getSimpleProductList } from '#/api/iot/product/product';
import { DictTag } from '#/components/dict-tag';
defineOptions({ name: 'IoTDeviceTableSelect' });
const props = defineProps({
multiple: {
type: Boolean,
default: false,
},
productId: {
type: Number,
default: null,
},
});
/** 提交表单 */
const emit = defineEmits(['success']);
// 获取字典选项
function getIntDictOptions(dictType: string) {
return getDictOptions(dictType, 'number');
}
// 日期格式化
function dateFormatter(_row: any, _column: any, cellValue: any) {
return cellValue ? formatDate(cellValue, 'YYYY-MM-DD HH:mm:ss') : '';
}
const dialogVisible = ref(false);
const dialogTitle = ref('设备选择器');
const formLoading = ref(false);
const loading = ref(true); // 列表的加载中
const list = ref<IotDeviceApi.Device[]>([]); // 列表的数据
const total = ref(0); // 列表的总页数
const selectedDevices = ref<IotDeviceApi.Device[]>([]); // 选中的设备列表
const selectedId = ref<number>(); // 单选模式下选中的ID
const products = ref<IotProductApi.Product[]>([]); // 产品列表
const deviceGroups = ref<IotDeviceGroupApi.DeviceGroup[]>([]); // 设备分组列表
const selectedRowKeys = ref<number[]>([]); // 多选模式下选中的keys
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
deviceName: undefined as string | undefined,
productId: undefined as number | undefined,
deviceType: undefined as number | undefined,
nickname: undefined as string | undefined,
status: undefined as number | undefined,
groupId: undefined as number | undefined,
});
const queryFormRef = ref(); // 搜索的表单
// 表格列定义
const columns = computed(() => {
const baseColumns = [
{
title: 'DeviceName',
dataIndex: 'deviceName',
key: 'deviceName',
},
{
title: '备注名称',
dataIndex: 'nickname',
key: 'nickname',
},
{
title: '所属产品',
key: 'productId',
},
{
title: '设备类型',
key: 'deviceType',
},
{
title: '所属分组',
key: 'groupIds',
},
{
title: '设备状态',
key: 'status',
},
{
title: '最后上线时间',
key: 'onlineTime',
width: 180,
},
];
// 单选模式添加单选列
if (!props.multiple) {
baseColumns.unshift({
title: '',
key: 'radio',
width: 55,
align: 'center',
} as any);
}
return baseColumns;
});
// 多选配置
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (keys: any[], rows: IotDeviceApi.Device[]) => {
selectedRowKeys.value = keys;
selectedDevices.value = rows;
},
}));
/** 查询列表 */
async function getList() {
loading.value = true;
try {
if (props.productId) {
queryParams.productId = props.productId;
}
const data = await getDevicePage(queryParams);
list.value = data.list;
total.value = data.total;
} finally {
loading.value = false;
}
}
/** 搜索按钮操作 */
function handleQuery() {
queryParams.pageNo = 1;
getList();
}
/** 重置按钮操作 */
function resetQuery() {
queryFormRef.value.resetFields();
handleQuery();
}
/** 打开弹窗 */
async function open() {
dialogVisible.value = true;
// 重置选择状态
selectedDevices.value = [];
selectedId.value = undefined;
selectedRowKeys.value = [];
if (!props.productId) {
// 获取产品列表
products.value = await getSimpleProductList();
}
// 获取设备列表
await getList();
}
defineExpose({ open });
/** 处理行点击事件 */
const tableRef = ref();
function handleRowClick(row: IotDeviceApi.Device) {
if (!props.multiple) {
selectedId.value = row.id;
selectedDevices.value = [row];
}
}
/** 处理单选变更事件 */
function handleRadioChange(row: IotDeviceApi.Device) {
selectedId.value = row.id;
selectedDevices.value = [row];
}
async function submitForm() {
if (selectedDevices.value.length === 0) {
message.warning({
content: props.multiple ? '请至少选择一个设备' : '请选择一个设备',
});
return;
}
emit(
'success',
props.multiple ? selectedDevices.value : selectedDevices.value[0],
);
dialogVisible.value = false;
}
/** 初始化 */
onMounted(async () => {
// 获取产品列表
products.value = await getSimpleProductList();
// 获取分组列表
deviceGroups.value = await getSimpleDeviceGroupList();
});
</script>
<template>
<Modal
:title="dialogTitle"
v-model:open="dialogVisible"
width="60%"
:footer="null"
>
<ContentWrap>
<!-- 搜索工作栏 -->
<Form
ref="queryFormRef"
layout="inline"
:model="queryParams"
class="-mb-15px"
>
<Form.Item v-if="!props.productId" label="产品" name="productId">
<Select
v-model:value="queryParams.productId"
placeholder="请选择产品"
allow-clear
style="width: 240px"
>
<Select.Option
v-for="product in products"
:key="product.id"
:value="product.id"
>
{{ product.name }}
</Select.Option>
</Select>
</Form.Item>
<Form.Item label="DeviceName" name="deviceName">
<Input
v-model:value="queryParams.deviceName"
placeholder="请输入 DeviceName"
allow-clear
@press-enter="handleQuery"
style="width: 240px"
/>
</Form.Item>
<Form.Item label="备注名称" name="nickname">
<Input
v-model:value="queryParams.nickname"
placeholder="请输入备注名称"
allow-clear
@press-enter="handleQuery"
style="width: 240px"
/>
</Form.Item>
<Form.Item label="设备类型" name="deviceType">
<Select
v-model:value="queryParams.deviceType"
placeholder="请选择设备类型"
allow-clear
style="width: 240px"
>
<Select.Option
v-for="dict in getIntDictOptions(
DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE,
)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</Select.Option>
</Select>
</Form.Item>
<Form.Item label="设备状态" name="status">
<Select
v-model:value="queryParams.status"
placeholder="请选择设备状态"
allow-clear
style="width: 240px"
>
<Select.Option
v-for="dict in getIntDictOptions(DICT_TYPE.IOT_DEVICE_STATE)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</Select.Option>
</Select>
</Form.Item>
<Form.Item label="设备分组" name="groupId">
<Select
v-model:value="queryParams.groupId"
placeholder="请选择设备分组"
allow-clear
style="width: 240px"
>
<Select.Option
v-for="group in deviceGroups"
:key="group.id"
:value="group.id"
>
{{ group.name }}
</Select.Option>
</Select>
</Form.Item>
<Form.Item>
<Button @click="handleQuery">
<IconifyIcon class="mr-5px" icon="ep:search" />
搜索
</Button>
<Button @click="resetQuery">
<IconifyIcon class="mr-5px" icon="ep:refresh" />
重置
</Button>
</Form.Item>
</Form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap>
<Table
ref="tableRef"
:loading="loading"
:data-source="list"
:columns="columns"
:pagination="false"
:row-selection="multiple ? rowSelection : undefined"
@row-click="handleRowClick"
:row-key="(record: IotDeviceApi.Device) => record.id?.toString() ?? ''"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'radio'">
<Radio
:checked="selectedId === record.id"
@click="() => handleRadioChange(record as IotDeviceApi.Device)"
/>
</template>
<template v-else-if="column.key === 'productId'">
{{ products.find((p) => p.id === record.productId)?.name || '-' }}
</template>
<template v-else-if="column.key === 'deviceType'">
<DictTag
:type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE"
:value="record.deviceType"
/>
</template>
<template v-else-if="column.key === 'groupIds'">
<template v-if="record.groupIds?.length">
<Tag
v-for="id in record.groupIds"
:key="id"
class="ml-5px"
size="small"
>
{{ deviceGroups.find((g) => g.id === id)?.name }}
</Tag>
</template>
</template>
<template v-else-if="column.key === 'status'">
<DictTag
:type="DICT_TYPE.IOT_DEVICE_STATE"
:value="record.status"
/>
</template>
<template v-else-if="column.key === 'onlineTime'">
{{ dateFormatter(null, null, record.onlineTime) }}
</template>
</template>
</Table>
<!-- 分页 -->
<Pagination
v-model:limit="queryParams.pageSize"
v-model:page="queryParams.pageNo"
:total="total"
@pagination="getList"
/>
</ContentWrap>
<template #footer>
<Button @click="submitForm" type="primary" :disabled="formLoading">
确 定
</Button>
<Button @click="dialogVisible = false"> </Button>
</template>
</Modal>
</template>

View File

@@ -1,271 +0,0 @@
<script setup lang="ts">
import {
computed,
onBeforeUnmount,
onMounted,
reactive,
ref,
watch,
} from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { formatDate } from '@vben/utils';
import {
Button,
Form,
Pagination,
Select,
Switch,
Table,
Tag,
} from 'ant-design-vue';
import { getDeviceMessagePage } from '#/api/iot/device/device';
import { DictTag } from '#/components/dict-tag';
import { IotDeviceMessageMethodEnum } from '#/views/iot/utils/constants';
const props = defineProps<{
deviceId: number;
}>();
// 查询参数
const queryParams = reactive({
deviceId: props.deviceId,
method: undefined,
upstream: undefined,
pageNo: 1,
pageSize: 10,
});
// 列表数据
const loading = ref(false);
const total = ref(0);
const list = ref<any[]>([]);
const autoRefresh = ref(false); // 自动刷新开关
let autoRefreshTimer: any = null; // 自动刷新定时器
// 消息方法选项
const methodOptions = computed(() => {
return Object.values(IotDeviceMessageMethodEnum).map((item) => ({
label: item.name,
value: item.method,
}));
});
// 表格列定义
const columns = [
{
title: '时间',
dataIndex: 'ts',
key: 'ts',
align: 'center' as const,
width: 180,
},
{
title: '上行/下行',
dataIndex: 'upstream',
key: 'upstream',
align: 'center' as const,
width: 140,
},
{
title: '是否回复',
dataIndex: 'reply',
key: 'reply',
align: 'center' as const,
width: 140,
},
{
title: '请求编号',
dataIndex: 'requestId',
key: 'requestId',
align: 'center' as const,
width: 300,
},
{
title: '请求方法',
dataIndex: 'method',
key: 'method',
align: 'center' as const,
width: 140,
},
{
title: '请求/响应数据',
dataIndex: 'params',
key: 'params',
align: 'center' as const,
ellipsis: true,
},
];
/** 查询消息列表 */
async function getMessageList() {
if (!props.deviceId) return;
loading.value = true;
try {
const data = await getDeviceMessagePage(queryParams);
total.value = data.total;
list.value = data.list;
} finally {
loading.value = false;
}
}
/** 搜索操作 */
function handleQuery() {
queryParams.pageNo = 1;
getMessageList();
}
/** 监听自动刷新 */
watch(autoRefresh, (newValue) => {
if (newValue) {
autoRefreshTimer = setInterval(() => {
getMessageList();
}, 5000);
} else {
clearInterval(autoRefreshTimer);
autoRefreshTimer = null;
}
});
/** 监听设备标识变化 */
watch(
() => props.deviceId,
(newValue) => {
if (newValue) {
handleQuery();
}
},
);
/** 组件卸载时清除定时器 */
onBeforeUnmount(() => {
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer);
autoRefreshTimer = null;
}
});
/** 初始化 */
onMounted(() => {
if (props.deviceId) {
getMessageList();
}
});
/** 刷新消息列表 */
function refresh(delay = 0) {
if (delay > 0) {
setTimeout(() => {
handleQuery();
}, delay);
} else {
handleQuery();
}
}
/** 暴露方法给父组件 */
defineExpose({
refresh,
});
</script>
<template>
<ContentWrap>
<!-- 搜索区域 -->
<Form :model="queryParams" layout="inline">
<Form.Item>
<Select
v-model:value="queryParams.method"
placeholder="所有方法"
style="width: 160px"
allow-clear
>
<Select.Option
v-for="item in methodOptions"
:key="item.value"
:label="item.label"
:value="item.value"
>
{{ item.label }}
</Select.Option>
</Select>
</Form.Item>
<Form.Item>
<Select
v-model:value="queryParams.upstream"
placeholder="上行/下行"
style="width: 160px"
allow-clear
>
<Select.Option label="上行" value="true">上行</Select.Option>
<Select.Option label="下行" value="false">下行</Select.Option>
</Select>
</Form.Item>
<Form.Item>
<Button type="primary" @click="handleQuery">
<IconifyIcon icon="ep:search" class="mr-5px" /> 搜索
</Button>
<Switch
v-model:checked="autoRefresh"
class="ml-20px"
checked-children="定时刷新"
un-checked-children="定时刷新"
/>
</Form.Item>
</Form>
<!-- 消息列表 -->
<Table
:loading="loading"
:data-source="list"
:columns="columns"
:pagination="false"
class="whitespace-nowrap"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'ts'">
{{ formatDate(record.ts) }}
</template>
<template v-else-if="column.key === 'upstream'">
<Tag :color="record.upstream ? 'blue' : 'green'">
{{ record.upstream ? '上行' : '下行' }}
</Tag>
</template>
<template v-else-if="column.key === 'reply'">
<DictTag
:type="DICT_TYPE.INFRA_BOOLEAN_STRING"
:value="record.reply"
/>
</template>
<template v-else-if="column.key === 'method'">
{{
methodOptions.find((item) => item.value === record.method)?.label
}}
</template>
<template v-else-if="column.key === 'params'">
<span v-if="record.reply">
{{
`{"code":${record.code},"msg":"${record.msg}","data":${record.data}\}`
}}
</span>
<span v-else>{{ record.params }}</span>
</template>
</template>
</Table>
<!-- 分页 -->
<div class="mt-10px flex justify-end">
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getMessageList"
/>
</div>
</ContentWrap>
</template>

View File

@@ -1,603 +0,0 @@
<!-- 模拟设备 -->
<script lang="ts" setup>
import type { TableColumnType } from 'ant-design-vue';
import type { IotDeviceApi } from '#/api/iot/device/device';
import type { IotProductApi } from '#/api/iot/product/product';
import type { ThingModelData } from '#/api/iot/thingmodel';
import { computed, ref } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import {
Button,
Card,
Input,
message,
Table,
Tabs,
Textarea,
} from 'ant-design-vue';
import { DeviceStateEnum, sendDeviceMessage } from '#/api/iot/device/device';
import DataDefinition from '#/views/iot/thingmodel/modules/components/data-definition.vue';
import {
IotDeviceMessageMethodEnum,
IoTThingModelTypeEnum,
} from '#/views/iot/utils/constants';
import DeviceDetailsMessage from './device-details-message.vue';
const props = defineProps<{
device: IotDeviceApi.Device;
product: IotProductApi.Product;
thingModelList: ThingModelData[];
}>();
// 消息弹窗
const activeTab = ref('upstream'); // 上行upstream、下行downstream
const upstreamTab = ref(IotDeviceMessageMethodEnum.PROPERTY_POST.method); // 上行子标签
const downstreamTab = ref(IotDeviceMessageMethodEnum.PROPERTY_SET.method); // 下行子标签
const deviceMessageRef = ref(); // 设备消息组件引用
const deviceMessageRefreshDelay = 2000; // 延迟 N 秒,保证模拟上行的消息被处理
// 折叠状态
const debugCollapsed = ref(false); // 指令调试区域折叠状态
const messageCollapsed = ref(false); // 设备消息区域折叠状态
// 表单数据:存储用户输入的模拟值
const formData = ref<Record<string, string>>({});
// 根据类型过滤物模型数据
const getFilteredThingModelList = (type: number) => {
return props.thingModelList.filter(
(item) => String(item.type) === String(type),
);
};
// 计算属性:属性列表
const propertyList = computed(() =>
getFilteredThingModelList(IoTThingModelTypeEnum.PROPERTY),
);
// 计算属性:事件列表
const eventList = computed(() =>
getFilteredThingModelList(IoTThingModelTypeEnum.EVENT),
);
// 计算属性:服务列表
const serviceList = computed(() =>
getFilteredThingModelList(IoTThingModelTypeEnum.SERVICE),
);
// 属性表格列定义
const propertyColumns: TableColumnType[] = [
{
title: '功能名称',
dataIndex: 'name',
key: 'name',
width: 120,
fixed: 'left' as any,
},
{
title: '标识符',
dataIndex: 'identifier',
key: 'identifier',
width: 120,
fixed: 'left' as any,
},
{
title: '数据类型',
key: 'dataType',
width: 100,
},
{
title: '数据定义',
key: 'dataDefinition',
minWidth: 100,
},
{
title: '值',
key: 'value',
width: 300,
fixed: 'right' as any,
},
];
// 事件表格列定义
const eventColumns = [
{
title: '功能名称',
dataIndex: 'name',
key: 'name',
width: 120,
fixed: 'left' as any,
},
{
title: '标识符',
dataIndex: 'identifier',
key: 'identifier',
width: 120,
fixed: 'left' as any,
},
{
title: '数据类型',
key: 'dataType',
width: 100,
},
{
title: '数据定义',
key: 'dataDefinition',
minWidth: 200,
},
{
title: '值',
key: 'value',
width: 200,
},
{
title: '操作',
key: 'action',
width: 100,
fixed: 'right' as any,
},
];
// 服务表格列定义
const serviceColumns = [
{
title: '服务名称',
dataIndex: 'name',
key: 'name',
width: 120,
fixed: 'left' as any,
},
{
title: '标识符',
dataIndex: 'identifier',
key: 'identifier',
width: 120,
fixed: 'left' as any,
},
{
title: '输入参数',
key: 'dataDefinition',
minWidth: 200,
},
{
title: '参数值',
key: 'value',
width: 200,
},
{
title: '操作',
key: 'action',
width: 100,
fixed: 'right' as any,
},
];
// 获取表单值
function getFormValue(identifier: string) {
return formData.value[identifier] || '';
}
// 设置表单值
function setFormValue(identifier: string, value: string) {
formData.value[identifier] = value;
}
// 属性上报
async function handlePropertyPost() {
try {
const params: Record<string, any> = {};
propertyList.value.forEach((item) => {
const value = formData.value[item.identifier!];
if (value) {
params[item.identifier!] = value;
}
});
if (Object.keys(params).length === 0) {
message.warning({ content: '请至少输入一个属性值' });
return;
}
await sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.PROPERTY_POST.method,
params,
});
message.success({ content: '属性上报成功' });
// 延迟刷新设备消息列表
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay);
} catch (error) {
message.error({ content: '属性上报失败' });
console.error(error);
}
}
// 事件上报
async function handleEventPost(row: ThingModelData) {
try {
const valueStr = formData.value[row.identifier!];
let params: any = {};
if (valueStr) {
try {
params = JSON.parse(valueStr);
} catch {
message.error({ content: '事件参数格式错误请输入有效的JSON格式' });
return;
}
}
await sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.EVENT_POST.method,
params: {
identifier: row.identifier,
params,
},
});
message.success({ content: '事件上报成功' });
// 延迟刷新设备消息列表
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay);
} catch (error) {
message.error({ content: '事件上报失败' });
console.error(error);
}
}
// 状态变更
async function handleDeviceState(state: number) {
try {
await sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.STATE_UPDATE.method,
params: { state },
});
message.success({ content: '状态变更成功' });
// 延迟刷新设备消息列表
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay);
} catch (error) {
message.error({ content: '状态变更失败' });
console.error(error);
}
}
// 属性设置
async function handlePropertySet() {
try {
const params: Record<string, any> = {};
propertyList.value.forEach((item) => {
const value = formData.value[item.identifier!];
if (value) {
params[item.identifier!] = value;
}
});
if (Object.keys(params).length === 0) {
message.warning({ content: '请至少输入一个属性值' });
return;
}
await sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.PROPERTY_SET.method,
params,
});
message.success({ content: '属性设置成功' });
// 延迟刷新设备消息列表
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay);
} catch (error) {
message.error({ content: '属性设置失败' });
console.error(error);
}
}
// 服务调用
async function handleServiceInvoke(row: ThingModelData) {
try {
const valueStr = formData.value[row.identifier!];
let params: any = {};
if (valueStr) {
try {
params = JSON.parse(valueStr);
} catch {
message.error({ content: '服务参数格式错误请输入有效的JSON格式' });
return;
}
}
await sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.SERVICE_INVOKE.method,
params: {
identifier: row.identifier,
params,
},
});
message.success({ content: '服务调用成功' });
// 延迟刷新设备消息列表
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay);
} catch (error) {
message.error({ content: '服务调用失败' });
console.error(error);
}
}
</script>
<template>
<ContentWrap>
<!-- 上方指令调试区域 -->
<Card class="simulator-tabs mb-4">
<template #title>
<div class="flex items-center justify-between">
<span>指令调试</span>
<Button
type="text"
size="small"
@click="debugCollapsed = !debugCollapsed"
>
<IconifyIcon icon="lucide:chevron-up" v-if="!debugCollapsed" />
<IconifyIcon icon="lucide:chevron-down" v-if="debugCollapsed" />
</Button>
</div>
</template>
<div v-show="!debugCollapsed">
<Tabs v-model:active-key="activeTab" size="small">
<!-- 上行指令调试 -->
<Tabs.TabPane key="upstream" tab="上行指令调试">
<Tabs
v-if="activeTab === 'upstream'"
v-model:active-key="upstreamTab"
size="small"
>
<!-- 属性上报 -->
<Tabs.TabPane
:key="IotDeviceMessageMethodEnum.PROPERTY_POST.method"
tab="属性上报"
>
<ContentWrap>
<Table
:data-source="propertyList"
align="center"
:columns="propertyColumns"
:pagination="false"
:scroll="{ y: 300 }"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'dataType'">
{{ record.property?.dataType ?? '-' }}
</template>
<template v-else-if="column.key === 'dataDefinition'">
<DataDefinition :data="record" />
</template>
<template v-else-if="column.key === 'value'">
<Input
:value="getFormValue(record.identifier)"
@update:value="
setFormValue(record.identifier, $event)
"
placeholder="输入值"
size="small"
/>
</template>
</template>
</Table>
<div class="mt-4 flex items-center justify-between">
<span class="text-sm text-gray-600">
设置属性值后点击发送属性上报按钮
</span>
<Button type="primary" @click="handlePropertyPost">
发送属性上报
</Button>
</div>
</ContentWrap>
</Tabs.TabPane>
<!-- 事件上报 -->
<Tabs.TabPane
:key="IotDeviceMessageMethodEnum.EVENT_POST.method"
tab="事件上报"
>
<ContentWrap>
<Table
:data-source="eventList"
align="center"
:columns="eventColumns"
:pagination="false"
:scroll="{ y: 300 }"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'dataType'">
{{ record.event?.dataType ?? '-' }}
</template>
<template v-else-if="column.key === 'dataDefinition'">
<DataDefinition :data="record" />
</template>
<template v-else-if="column.key === 'value'">
<Textarea
:value="getFormValue(record.identifier)"
@update:value="
setFormValue(record.identifier, $event)
"
:rows="3"
placeholder="输入事件参数JSON格式"
size="small"
/>
</template>
<template v-else-if="column.key === 'action'">
<Button
type="primary"
size="small"
@click="handleEventPost(record)"
>
上报事件
</Button>
</template>
</template>
</Table>
</ContentWrap>
</Tabs.TabPane>
<!-- 状态变更 -->
<Tabs.TabPane
:key="IotDeviceMessageMethodEnum.STATE_UPDATE.method"
tab="状态变更"
>
<ContentWrap>
<div class="flex gap-4">
<Button
type="primary"
@click="handleDeviceState(DeviceStateEnum.ONLINE)"
>
设备上线
</Button>
<Button
danger
@click="handleDeviceState(DeviceStateEnum.OFFLINE)"
>
设备下线
</Button>
</div>
</ContentWrap>
</Tabs.TabPane>
</Tabs>
</Tabs.TabPane>
<!-- 下行指令调试 -->
<Tabs.TabPane key="downstream" tab="下行指令调试">
<Tabs
v-if="activeTab === 'downstream'"
v-model:active-key="downstreamTab"
size="small"
>
<!-- 属性调试 -->
<Tabs.TabPane
:key="IotDeviceMessageMethodEnum.PROPERTY_SET.method"
tab="属性设置"
>
<ContentWrap>
<Table
:data-source="propertyList"
align="center"
:columns="propertyColumns"
:pagination="false"
:scroll="{ y: 300 }"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'dataType'">
{{ record.property?.dataType ?? '-' }}
</template>
<template v-else-if="column.key === 'dataDefinition'">
<DataDefinition :data="record" />
</template>
<template v-else-if="column.key === 'value'">
<Input
:value="getFormValue(record.identifier)"
@update:value="
setFormValue(record.identifier, $event)
"
placeholder="输入值"
size="small"
/>
</template>
</template>
</Table>
<div class="mt-4 flex items-center justify-between">
<span class="text-sm text-gray-600">
设置属性值后点击发送属性设置按钮
</span>
<Button type="primary" @click="handlePropertySet">
发送属性设置
</Button>
</div>
</ContentWrap>
</Tabs.TabPane>
<!-- 服务调用 -->
<Tabs.TabPane
:key="IotDeviceMessageMethodEnum.SERVICE_INVOKE.method"
tab="设备服务调用"
>
<ContentWrap>
<Table
:data-source="serviceList"
align="center"
:columns="serviceColumns"
:pagination="false"
:scroll="{ y: 300 }"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'dataDefinition'">
<DataDefinition :data="record" />
</template>
<template v-else-if="column.key === 'value'">
<Textarea
:value="getFormValue(record.identifier)"
@update:value="
setFormValue(record.identifier, $event)
"
:rows="3"
placeholder="输入服务参数JSON格式"
size="small"
/>
</template>
<template v-else-if="column.key === 'action'">
<Button
type="primary"
size="small"
@click="handleServiceInvoke(record)"
>
服务调用
</Button>
</template>
</template>
</Table>
</ContentWrap>
</Tabs.TabPane>
</Tabs>
</Tabs.TabPane>
</Tabs>
</div>
</Card>
<!-- 下方设备消息区域 -->
<Card>
<template #title>
<div class="flex items-center justify-between">
<span>设备消息</span>
<Button
type="text"
size="small"
@click="messageCollapsed = !messageCollapsed"
>
<IconifyIcon icon="lucide:chevron-down" v-if="!messageCollapsed" />
<IconifyIcon icon="lucide:chevron-down" v-if="messageCollapsed" />
</Button>
</div>
</template>
<div v-show="!messageCollapsed">
<DeviceDetailsMessage
v-if="device.id"
ref="deviceMessageRef"
:device-id="device.id"
/>
</div>
</Card>
</ContentWrap>
</template>

View File

@@ -1,236 +0,0 @@
<!-- 设备事件管理 -->
<script setup lang="ts">
import type { ThingModelData } from '#/api/iot/thingmodel';
import { computed, onMounted, reactive, ref } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { formatDate } from '@vben/utils';
import {
Button,
Divider,
Form,
Pagination,
RangePicker,
Select,
Table,
Tag,
} from 'ant-design-vue';
import { getDeviceMessagePairPage } from '#/api/iot/device/device';
import {
getEventTypeLabel,
IotDeviceMessageMethodEnum,
IoTThingModelTypeEnum,
} from '#/views/iot/utils/constants';
const props = defineProps<{
deviceId: number;
thingModelList: ThingModelData[];
}>();
const loading = ref(false); // 列表的加载中
const total = ref(0); // 列表的总页数
const list = ref([] as any[]); // 列表的数据
const queryParams = reactive({
deviceId: props.deviceId,
method: IotDeviceMessageMethodEnum.EVENT_POST.method, // 固定筛选事件消息
identifier: '',
times: undefined,
pageNo: 1,
pageSize: 10,
});
const queryFormRef = ref(); // 搜索的表单
/** 事件类型的物模型数据 */
const eventThingModels = computed(() => {
return props.thingModelList.filter(
(item: ThingModelData) =>
String(item.type) === String(IoTThingModelTypeEnum.EVENT),
);
});
/** 查询列表 */
async function getList() {
if (!props.deviceId) return;
loading.value = true;
try {
const data = await getDeviceMessagePairPage(queryParams);
list.value = data.list || [];
total.value = data.total || 0;
} finally {
loading.value = false;
}
}
/** 搜索按钮操作 */
function handleQuery() {
queryParams.pageNo = 1;
getList();
}
/** 重置按钮操作 */
function resetQuery() {
queryFormRef.value?.resetFields();
queryParams.identifier = '';
queryParams.times = undefined;
handleQuery();
}
/** 获取事件名称 */
function getEventName(identifier: string | undefined) {
if (!identifier) return '-';
const event = eventThingModels.value.find(
(item: ThingModelData) => item.identifier === identifier,
);
return event?.name || identifier;
}
/** 获取事件类型 */
function getEventType(identifier: string | undefined) {
if (!identifier) return '-';
const event = eventThingModels.value.find(
(item: ThingModelData) => item.identifier === identifier,
);
if (!event?.event?.type) return '-';
return getEventTypeLabel(event.event.type) || '-';
}
/** 解析参数 */
function parseParams(params: string) {
try {
const parsed = JSON.parse(params);
if (parsed.params) {
return parsed.params;
}
return parsed;
} catch {
return {};
}
}
/** 初始化 */
onMounted(() => {
getList();
});
</script>
<template>
<ContentWrap>
<!-- 搜索工作栏 -->
<Form
:model="queryParams"
ref="queryFormRef"
layout="inline"
@submit.prevent
style="margin-bottom: 16px"
>
<Form.Item label="标识符" name="identifier">
<Select
v-model:value="queryParams.identifier"
placeholder="请选择事件标识符"
allow-clear
style="width: 240px"
>
<Select.Option
v-for="event in eventThingModels"
:key="event.identifier"
:value="event.identifier!"
>
{{ event.name }}({{ event.identifier }})
</Select.Option>
</Select>
</Form.Item>
<Form.Item label="时间范围" name="times">
<RangePicker
v-model:value="queryParams.times"
show-time
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 360px"
/>
</Form.Item>
<Form.Item>
<Button type="primary" @click="handleQuery">
<template #icon>
<IconifyIcon icon="ep:search" />
</template>
搜索
</Button>
<Button @click="resetQuery" style="margin-left: 8px">
<template #icon>
<IconifyIcon icon="ep:refresh" />
</template>
重置
</Button>
</Form.Item>
</Form>
<Divider style="margin: 16px 0" />
<!-- 事件列表 -->
<Table v-loading="loading" :data-source="list" :pagination="false">
<Table.Column
title="上报时间"
align="center"
data-index="reportTime"
:width="180"
>
<template #default="{ record }">
{{
record.request?.reportTime
? formatDate(record.request.reportTime)
: '-'
}}
</template>
</Table.Column>
<Table.Column
title="标识符"
align="center"
data-index="identifier"
:width="160"
>
<template #default="{ record }">
<Tag color="blue" size="small">
{{ record.request?.identifier }}
</Tag>
</template>
</Table.Column>
<Table.Column
title="事件名称"
align="center"
data-index="eventName"
:width="160"
>
<template #default="{ record }">
{{ getEventName(record.request?.identifier) }}
</template>
</Table.Column>
<Table.Column
title="事件类型"
align="center"
data-index="eventType"
:width="100"
>
<template #default="{ record }">
{{ getEventType(record.request?.identifier) }}
</template>
</Table.Column>
<Table.Column title="输入参数" align="center" data-index="params">
<template #default="{ record }">
{{ parseParams(record.request.params) }}
</template>
</Table.Column>
</Table>
<!-- 分页 -->
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</ContentWrap>
</template>

View File

@@ -1,258 +0,0 @@
<!-- 设备服务调用 -->
<script setup lang="ts">
import type { ThingModelData } from '#/api/iot/thingmodel';
import { computed, onMounted, reactive, ref } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { formatDate } from '@vben/utils';
import {
Button,
Divider,
Form,
Pagination,
Select,
Table,
Tag,
} from 'ant-design-vue';
import { getDeviceMessagePairPage } from '#/api/iot/device/device';
import {
getThingModelServiceCallTypeLabel,
IotDeviceMessageMethodEnum,
IoTThingModelTypeEnum,
} from '#/views/iot/utils/constants';
const props = defineProps<{
deviceId: number;
thingModelList: ThingModelData[];
}>();
const loading = ref(false); // 列表的加载中
const total = ref(0); // 列表的总页数
const list = ref([] as any[]); // 列表的数据
const queryParams = reactive({
deviceId: props.deviceId,
method: IotDeviceMessageMethodEnum.SERVICE_INVOKE.method, // 固定筛选服务调用消息
identifier: '',
times: [] as any[],
pageNo: 1,
pageSize: 10,
});
const queryFormRef = ref(); // 搜索的表单
/** 服务类型的物模型数据 */
const serviceThingModels = computed(() => {
return props.thingModelList.filter(
(item: ThingModelData) =>
String(item.type) === String(IoTThingModelTypeEnum.SERVICE),
);
});
/** 查询列表 */
async function getList() {
if (!props.deviceId) return;
loading.value = true;
try {
const data = await getDeviceMessagePairPage(queryParams);
list.value = data.list || [];
total.value = data.total;
} finally {
loading.value = false;
}
}
/** 搜索按钮操作 */
function handleQuery() {
queryParams.pageNo = 1;
getList();
}
/** 重置按钮操作 */
function resetQuery() {
queryFormRef.value?.resetFields();
queryParams.identifier = '';
queryParams.times = [];
handleQuery();
}
/** 获取服务名称 */
function getServiceName(identifier: string | undefined) {
if (!identifier) return '-';
const service = serviceThingModels.value.find(
(item: ThingModelData) => item.identifier === identifier,
);
return service?.name || identifier;
}
/** 获取调用方式 */
function getCallType(identifier: string | undefined) {
if (!identifier) return '-';
const service = serviceThingModels.value.find(
(item: ThingModelData) => item.identifier === identifier,
);
if (!service?.service?.callType) return '-';
return getThingModelServiceCallTypeLabel(service.service.callType) || '-';
}
/** 解析参数 */
function parseParams(params: string) {
if (!params) return '-';
try {
const parsed = JSON.parse(params);
if (parsed.params) {
return JSON.stringify(parsed.params, null, 2);
}
return JSON.stringify(parsed, null, 2);
} catch {
return params;
}
}
/** 初始化 */
onMounted(() => {
getList();
});
</script>
<template>
<ContentWrap>
<!-- 搜索工作栏 -->
<Form
:model="queryParams"
ref="queryFormRef"
layout="inline"
@submit.prevent
style="margin-bottom: 16px"
>
<Form.Item label="标识符" name="identifier">
<Select
v-model:value="queryParams.identifier"
placeholder="请选择服务标识符"
allow-clear
style="width: 240px"
>
<Select.Option
v-for="service in serviceThingModels"
:key="service.identifier"
:value="service.identifier!"
>
{{ service.name }}({{ service.identifier }})
</Select.Option>
</Select>
</Form.Item>
<Form.Item label="时间范围" name="times">
<RangePicker
v-model:value="queryParams.times"
show-time
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 360px"
/>
</Form.Item>
<Form.Item>
<Button type="primary" @click="handleQuery">
<template #icon>
<IconifyIcon icon="ep:search" />
</template>
搜索
</Button>
<Button @click="resetQuery" style="margin-left: 8px">
<template #icon>
<IconifyIcon icon="ep:refresh" />
</template>
重置
</Button>
</Form.Item>
</Form>
<Divider style="margin: 16px 0" />
<!-- 服务调用列表 -->
<Table v-loading="loading" :data-source="list" :pagination="false">
<Table.Column
title="调用时间"
align="center"
data-index="requestTime"
:width="180"
>
<template #default="{ record }">
{{
record.request?.reportTime
? formatDate(record.request.reportTime)
: '-'
}}
</template>
</Table.Column>
<Table.Column
title="响应时间"
align="center"
data-index="responseTime"
:width="180"
>
<template #default="{ record }">
{{
record.reply?.reportTime ? formatDate(record.reply.reportTime) : '-'
}}
</template>
</Table.Column>
<Table.Column
title="标识符"
align="center"
data-index="identifier"
:width="160"
>
<template #default="{ record }">
<Tag color="blue" size="small">
{{ record.request?.identifier }}
</Tag>
</template>
</Table.Column>
<Table.Column
title="服务名称"
align="center"
data-index="serviceName"
:width="160"
>
<template #default="{ record }">
{{ getServiceName(record.request?.identifier) }}
</template>
</Table.Column>
<Table.Column
title="调用方式"
align="center"
data-index="callType"
:width="100"
>
<template #default="{ record }">
{{ getCallType(record.request?.identifier) }}
</template>
</Table.Column>
<Table.Column title="输入参数" align="center" data-index="inputParams">
<template #default="{ record }">
{{ parseParams(record.request?.params) }}
</template>
</Table.Column>
<Table.Column title="输出参数" align="center" data-index="outputParams">
<template #default="{ record }">
<span v-if="record.reply">
{{
`{"code":${record.reply.code},"msg":"${record.reply.msg}","data":${record.reply.data}\}`
}}
</span>
<span v-else>-</span>
</template>
</Table.Column>
</Table>
<!-- 分页 -->
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</ContentWrap>
</template>

View File

@@ -1,619 +0,0 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { getDictLabel, getDictObj } from '@vben/hooks';
import { IconifyIcon } from '@vben/icons';
import { isValidColor, TinyColor } from '@vben/utils';
import {
Button,
Card,
Col,
Empty,
Pagination,
Popconfirm,
Row,
Tag,
} from 'ant-design-vue';
import { DeviceStateEnum, getDevicePage } from '#/api/iot/device/device';
defineOptions({ name: 'DeviceCardView' });
const props = defineProps<Props>();
const emit = defineEmits<{
create: [];
delete: [row: any];
detail: [id: number];
edit: [row: any];
model: [id: number];
productDetail: [productId: number];
}>();
interface Props {
products: any[];
deviceGroups: any[];
searchParams?: {
deviceName: string;
deviceType?: number;
groupId?: number;
nickname: string;
productId?: number;
status?: number;
};
}
const loading = ref(false);
const list = ref<any[]>([]);
const total = ref(0);
const queryParams = ref({
pageNo: 1,
pageSize: 12,
});
const DEFAULT_STATUS_MAP: Record<
'default' | number,
{ bgColor: string; borderColor: string; color: string; text: string }
> = {
[DeviceStateEnum.ONLINE]: {
text: '在线',
color: '#52c41a',
bgColor: '#f6ffed',
borderColor: '#b7eb8f',
},
[DeviceStateEnum.OFFLINE]: {
text: '离线',
color: '#faad14',
bgColor: '#fffbe6',
borderColor: '#ffe58f',
},
[DeviceStateEnum.INACTIVE]: {
text: '未激活',
color: '#ff4d4f',
bgColor: '#fff1f0',
borderColor: '#ffccc7',
},
default: {
text: '未知状态',
color: '#595959',
bgColor: '#fafafa',
borderColor: '#d9d9d9',
},
};
const COLOR_TYPE_PRESETS: Record<
string,
{ bgColor: string; borderColor: string; color: string }
> = {
success: {
color: '#52c41a',
bgColor: '#f6ffed',
borderColor: '#b7eb8f',
},
processing: {
color: '#1890ff',
bgColor: '#e6f7ff',
borderColor: '#91d5ff',
},
warning: {
color: '#faad14',
bgColor: '#fffbe6',
borderColor: '#ffe58f',
},
error: {
color: '#ff4d4f',
bgColor: '#fff1f0',
borderColor: '#ffccc7',
},
default: {
color: '#595959',
bgColor: '#fafafa',
borderColor: '#d9d9d9',
},
};
function normalizeColorType(colorType?: string) {
switch (colorType) {
case 'danger': {
return 'error';
}
case 'default':
case 'error':
case 'processing':
case 'success':
case 'warning': {
return colorType;
}
case 'info': {
return 'default';
}
case 'primary': {
return 'processing';
}
default: {
return 'default';
}
}
}
// 获取产品名称
function getProductName(productId: number) {
const product = props.products.find((p: any) => p.id === productId);
return product?.name || '-';
}
// 获取设备列表
async function getList() {
loading.value = true;
try {
const data = await getDevicePage({
...queryParams.value,
...props.searchParams,
});
list.value = data.list || [];
total.value = data.total || 0;
} finally {
loading.value = false;
}
}
// 处理页码变化
function handlePageChange(page: number, pageSize: number) {
queryParams.value.pageNo = page;
queryParams.value.pageSize = pageSize;
getList();
}
// 获取设备类型颜色
function getDeviceTypeColor(deviceType: number) {
const colors: Record<number, string> = {
0: 'blue',
1: 'cyan',
};
return colors[deviceType] || 'default';
}
// 获取设备状态信息
function getStatusInfo(state: null | number | string | undefined) {
const parsedState = Number(state);
const hasNumericState = Number.isFinite(parsedState);
const fallback = hasNumericState
? DEFAULT_STATUS_MAP[parsedState] || DEFAULT_STATUS_MAP.default
: DEFAULT_STATUS_MAP.default;
const dict = getDictObj(
DICT_TYPE.IOT_DEVICE_STATE,
hasNumericState ? parsedState : state,
);
if (dict) {
if (!dict.colorType && !dict.cssClass) {
return {
...fallback,
text: dict.label || fallback.text,
};
}
const presetKey = normalizeColorType(dict.colorType);
if (isValidColor(dict.cssClass)) {
const baseColor = new TinyColor(dict.cssClass);
return {
text: dict.label || fallback.text,
color: baseColor.toHexString(),
bgColor: baseColor.clone().setAlpha(0.15).toRgbString(),
borderColor: baseColor.clone().lighten(30).toHexString(),
};
}
const preset = COLOR_TYPE_PRESETS[presetKey] || COLOR_TYPE_PRESETS.default;
return {
text: dict.label || fallback.text,
...preset,
};
}
return fallback;
}
onMounted(() => {
getList();
});
// 暴露方法供父组件调用
defineExpose({
reload: getList,
search: () => {
queryParams.value.pageNo = 1;
getList();
},
});
</script>
<template>
<div class="device-card-view">
<!-- 设备卡片列表 -->
<div v-loading="loading" class="min-h-[400px]">
<Row v-if="list.length > 0" :gutter="[16, 16]">
<Col
v-for="item in list"
:key="item.id"
:xs="24"
:sm="12"
:md="8"
:lg="6"
>
<Card
:body-style="{ padding: 0 }"
class="device-card"
:bordered="false"
>
<!-- 卡片内容 -->
<div class="card-content">
<!-- 头部图标和状态 -->
<div class="card-header">
<div class="device-icon">
<IconifyIcon icon="mdi:chip" />
</div>
<div
class="status-badge"
:style="{
color: getStatusInfo(item.state).color,
backgroundColor: getStatusInfo(item.state).bgColor,
borderColor: getStatusInfo(item.state).borderColor,
}"
>
<span class="status-dot"></span>
{{ getStatusInfo(item.state).text }}
</div>
</div>
<!-- 设备名称 -->
<div class="device-name" :title="item.deviceName">
{{ item.deviceName }}
</div>
<!-- 信息区域 -->
<div class="info-section">
<div class="info-item">
<span class="label">所属产品</span>
<a
class="value link"
@click="
(e) => {
e.stopPropagation();
emit('productDetail', item.productId);
}
"
>
{{ getProductName(item.productId) }}
</a>
</div>
<div class="info-item">
<span class="label">设备类型</span>
<Tag
:color="getDeviceTypeColor(item.deviceType)"
size="small"
>
{{
getDictLabel(
DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE,
item.deviceType,
)
}}
</Tag>
</div>
<div class="info-item">
<span class="label">Deviceid</span>
<span class="value code" :title="item.Deviceid || item.id">
{{ item.Deviceid || item.id }}
</span>
</div>
</div>
<!-- 操作按钮 -->
<div class="action-bar">
<Button
type="default"
size="small"
class="action-btn btn-edit"
@click="
(e) => {
e.stopPropagation();
emit('edit', item);
}
"
>
<IconifyIcon icon="ph:note-pencil" />
编辑
</Button>
<Button
type="default"
size="small"
class="action-btn btn-view"
@click="
(e: MouseEvent) => {
e.stopPropagation();
emit('detail', item.id);
}
"
>
<IconifyIcon icon="ph:eye" />
详情
</Button>
<Button
type="default"
size="small"
class="action-btn btn-data"
@click="
(e: MouseEvent) => {
e.stopPropagation();
emit('model', item.id);
}
"
>
<IconifyIcon icon="ph:database" />
数据
</Button>
<Popconfirm
title="确认删除该设备吗?"
@confirm="() => emit('delete', item)"
>
<Button
type="default"
size="small"
class="action-btn btn-delete"
@click="(e: MouseEvent) => e.stopPropagation()"
>
<IconifyIcon icon="ph:trash" />
</Button>
</Popconfirm>
</div>
</div>
</Card>
</Col>
</Row>
<!-- 空状态 -->
<Empty v-else description="暂无设备数据" class="my-20" />
</div>
<!-- 分页 -->
<div v-if="list.length > 0" class="mt-6 flex justify-center">
<Pagination
v-model:current="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
:total="total"
:show-total="(total) => `${total}`"
show-quick-jumper
show-size-changer
:page-size-options="['12', '24', '36', '48']"
@change="handlePageChange"
/>
</div>
</div>
</template>
<style scoped lang="scss">
.device-card-view {
.device-card {
height: 100%;
overflow: hidden;
background: hsl(var(--card) / 95%);
border: 1px solid hsl(var(--border) / 60%);
border-radius: 8px;
box-shadow:
0 1px 2px 0 hsl(var(--foreground) / 4%),
0 1px 6px -1px hsl(var(--foreground) / 5%),
0 2px 4px 0 hsl(var(--foreground) / 5%);
transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
&:hover {
border-color: hsl(var(--border));
box-shadow:
0 1px 2px -2px hsl(var(--foreground) / 12%),
0 3px 6px 0 hsl(var(--foreground) / 10%),
0 5px 12px 4px hsl(var(--foreground) / 8%);
transform: translateY(-4px);
}
:deep(.ant-card-body) {
padding: 0;
}
.card-content {
display: flex;
flex-direction: column;
height: 100%;
padding: 16px;
}
// 头部区域
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
.device-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
font-size: 18px;
color: #fff;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 6px;
box-shadow: 0 2px 8px rgb(102 126 234 / 25%);
}
.status-badge {
display: flex;
gap: 4px;
align-items: center;
padding: 2px 10px;
font-size: 12px;
font-weight: 500;
line-height: 18px;
border: 1px solid;
border-radius: 12px;
.status-dot {
width: 6px;
height: 6px;
background: currentcolor;
border-radius: 50%;
}
}
}
// 设备名称
.device-name {
margin-bottom: 16px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 16px;
font-weight: 600;
line-height: 24px;
color: hsl(var(--foreground) / 90%);
white-space: nowrap;
}
// 信息区域
.info-section {
flex: 1;
margin-bottom: 16px;
.info-item {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
&:last-child {
margin-bottom: 0;
}
.label {
flex-shrink: 0;
font-size: 13px;
color: hsl(var(--foreground) / 60%);
}
.value {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 13px;
color: hsl(var(--foreground) / 85%);
text-align: right;
white-space: nowrap;
&.link {
color: hsl(var(--primary));
cursor: pointer;
transition: color 0.2s;
&:hover {
color: hsl(var(--primary) / 85%);
}
}
&.code {
font-family:
'SF Mono', Monaco, Inconsolata, 'Fira Code', Consolas, monospace;
font-size: 12px;
font-weight: 500;
color: hsl(var(--foreground) / 60%);
}
}
}
}
// 操作按钮栏
.action-bar {
position: relative;
z-index: 1;
display: flex;
gap: 8px;
padding-top: 12px;
border-top: 1px solid hsl(var(--border) / 40%);
.action-btn {
display: flex;
flex: 1;
gap: 4px;
align-items: center;
justify-content: center;
height: 32px;
padding: 4px 8px;
font-size: 13px;
font-weight: 400;
pointer-events: auto;
cursor: pointer;
border: 1px solid transparent;
border-radius: 6px;
transition: all 0.2s;
:deep(.anticon) {
font-size: 16px;
}
&.btn-edit {
color: hsl(var(--primary));
background: hsl(var(--primary) / 12%);
border-color: hsl(var(--primary) / 25%);
&:hover {
color: hsl(var(--primary-foreground));
background: hsl(var(--primary));
border-color: hsl(var(--primary));
}
}
&.btn-view {
color: hsl(var(--warning));
background: hsl(var(--warning) / 12%);
border-color: hsl(var(--warning) / 25%);
&:hover {
color: #fff;
background: hsl(var(--warning));
border-color: hsl(var(--warning));
}
}
&.btn-data {
color: hsl(var(--accent-foreground));
background: color-mix(
in srgb,
hsl(var(--accent)) 40%,
hsl(var(--card)) 60%
);
border-color: color-mix(in srgb, hsl(var(--accent)) 55%, transparent);
&:hover {
color: hsl(var(--accent-foreground));
background: hsl(var(--accent));
border-color: hsl(var(--accent));
}
}
&.btn-delete {
flex: 0 0 32px;
padding: 4px;
color: hsl(var(--destructive));
background: hsl(var(--destructive) / 12%);
border-color: hsl(var(--destructive) / 30%);
&:hover {
color: hsl(var(--destructive-foreground));
background: hsl(var(--destructive));
border-color: hsl(var(--destructive));
}
}
}
}
}
}
</style>

View File

@@ -1,133 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useVbenForm, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { message } from 'ant-design-vue';
import { importDeviceTemplate } from '#/api/iot/device/device';
import { useImportFormSchema } from '../data';
defineOptions({ name: 'IoTDeviceImportForm' });
const emit = defineEmits(['success']);
const getTitle = computed(() => '设备导入');
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
},
layout: 'horizontal',
schema: useImportFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
const values = await formApi.getValues();
const file = values.file;
if (!file || file.length === 0) {
message.error('请上传文件');
return;
}
modalApi.lock();
try {
// 构建表单数据
const formData = new FormData();
formData.append('file', file[0].originFileObj);
formData.append('updateSupport', values.updateSupport ? 'true' : 'false');
// 使用 fetch 上传文件
const accessToken = localStorage.getItem('accessToken') || '';
const response = await fetch(
`${import.meta.env.VITE_GLOB_API_URL}/iot/device/import?updateSupport=${values.updateSupport}`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
},
body: formData,
},
);
const result = await response.json();
if (result.code !== 0) {
message.error(result.msg || '导入失败');
return;
}
// 拼接提示语
const data = result.data;
let text = `上传成功数量:${data.createDeviceNames?.length || 0};`;
if (data.createDeviceNames) {
for (const deviceName of data.createDeviceNames) {
text += `< ${deviceName} >`;
}
}
text += `更新成功数量:${data.updateDeviceNames?.length || 0};`;
if (data.updateDeviceNames) {
for (const deviceName of data.updateDeviceNames) {
text += `< ${deviceName} >`;
}
}
text += `更新失败数量:${Object.keys(data.failureDeviceNames || {}).length};`;
if (data.failureDeviceNames) {
for (const deviceName in data.failureDeviceNames) {
text += `< ${deviceName}: ${data.failureDeviceNames[deviceName]} >`;
}
}
message.info(text);
// 关闭并提示
await modalApi.close();
emit('success');
} catch (error: any) {
message.error(error.message || '导入失败');
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (isOpen) {
// 重置表单
await formApi.resetForm();
await formApi.setValues({
updateSupport: false,
});
}
},
});
/** 下载模板 */
async function handleDownloadTemplate() {
try {
const res = await importDeviceTemplate();
downloadFileFromBlobPart({ fileName: '设备导入模版.xls', source: res });
} catch (error: any) {
message.error(error.message || '下载失败');
}
}
</script>
<template>
<Modal :title="getTitle" class="w-1/3">
<Form class="mx-4" />
<div class="mx-4 mt-4 text-center">
<a class="cursor-pointer text-primary" @click="handleDownloadTemplate">
下载导入模板
</a>
</div>
</Modal>
</template>

View File

@@ -3,10 +3,11 @@ import type { IotDeviceApi } from '#/api/iot/device/device';
import { computed, ref } from 'vue';
import { useVbenForm, useVbenModal } from '@vben/common-ui';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { createDevice, getDevice, updateDevice } from '#/api/iot/device/device';
import { $t } from '#/locales';
@@ -15,9 +16,11 @@ import { useFormSchema } from '../data';
defineOptions({ name: 'IoTDeviceForm' });
const emit = defineEmits(['success']);
const formData = ref<any>();
const formData = ref<IotDeviceApi.Device>();
const getTitle = computed(() => {
return formData.value?.id ? '编辑设备' : '新增设备';
return formData.value?.id
? $t('ui.actionTitle.edit', ['设备'])
: $t('ui.actionTitle.create', ['设备']);
});
const [Form, formApi] = useVbenForm({
@@ -26,7 +29,7 @@ const [Form, formApi] = useVbenForm({
class: 'w-full',
},
},
wrapperClass: 'grid-cols-2',
wrapperClass: 'grid-cols-1',
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
@@ -57,14 +60,14 @@ const [Modal, modalApi] = useVbenModal({
return;
}
//
const data = modalApi.getData<any>();
const data = modalApi.getData<IotDeviceApi.Device>();
if (!data || !data.id) {
return;
}
//
modalApi.lock();
try {
formData.value = await getDevice(data.id);
// values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();

View File

@@ -1,10 +1,11 @@
<script setup lang="ts">
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { useVbenForm, useVbenModal } from '@vben/common-ui';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { updateDeviceGroup } from '#/api/iot/device/device';
import { $t } from '#/locales';

View File

@@ -0,0 +1,107 @@
<script lang="ts" setup>
import type { FileType } from 'ant-design-vue/es/upload/interface';
import { useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message, Upload } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { importDevice, importDeviceTemplate } from '#/api/iot/device/device';
import { $t } from '#/locales';
import { useImportFormSchema } from '../data';
defineOptions({ name: 'IoTDeviceImportForm' });
const emit = defineEmits(['success']);
const [Form, formApi] = useVbenForm({
commonConfig: {
formItemClass: 'col-span-2',
labelWidth: 120,
},
layout: 'horizontal',
schema: useImportFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
// 提交表单
const data = await formApi.getValues();
try {
const result = await importDevice(data.file, data.updateSupport);
// 处理导入结果提示
const importData = result.data || result;
if (importData) {
let text = `上传成功数量:${importData.createDeviceNames?.length || 0};`;
if (importData.createDeviceNames?.length) {
for (const deviceName of importData.createDeviceNames) {
text += `< ${deviceName} >`;
}
}
text += `更新成功数量:${importData.updateDeviceNames?.length || 0};`;
if (importData.updateDeviceNames?.length) {
for (const deviceName of importData.updateDeviceNames) {
text += `< ${deviceName} >`;
}
}
text += `更新失败数量:${Object.keys(importData.failureDeviceNames || {}).length};`;
if (importData.failureDeviceNames) {
for (const deviceName in importData.failureDeviceNames) {
text += `< ${deviceName}: ${importData.failureDeviceNames[deviceName]} >`;
}
}
message.info(text);
}
// 关闭并提示
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
});
/** 上传前 */
function beforeUpload(file: FileType) {
formApi.setFieldValue('file', file);
return false;
}
/** 下载模版 */
async function handleDownload() {
const data = await importDeviceTemplate();
downloadFileFromBlobPart({ fileName: '设备导入模板.xls', source: data });
}
</script>
<template>
<Modal :title="$t('ui.actionTitle.import', ['设备'])" class="w-1/3">
<Form class="mx-4">
<template #file>
<div class="w-full">
<Upload
:before-upload="beforeUpload"
:max-count="1"
accept=".xls,.xlsx"
>
<Button type="primary"> 选择 Excel 文件</Button>
</Upload>
</div>
</template>
</Form>
<template #prepend-footer>
<div class="flex flex-auto items-center">
<Button @click="handleDownload"> 下载导入模板</Button>
</div>
</template>
</Modal>
</template>

View File

@@ -1,4 +1,4 @@
<script setup lang="ts">
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { IotDeviceGroupApi } from '#/api/iot/device/group';
@@ -11,12 +11,12 @@ import { deleteDeviceGroup, getDeviceGroupPage } from '#/api/iot/device/group';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import DeviceGroupForm from './modules/device-group-form.vue';
import Form from './modules/form.vue';
defineOptions({ name: 'IoTDeviceGroup' });
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: DeviceGroupForm,
connectedComponent: Form,
destroyOnClose: true,
});

View File

@@ -1,4 +1,4 @@
<script setup lang="ts">
<script lang="ts" setup>
import type { IotDeviceGroupApi } from '#/api/iot/device/group';
import { computed, ref } from 'vue';
@@ -17,17 +17,9 @@ import { $t } from '#/locales';
import { useFormSchema } from '../data';
defineOptions({ name: 'IoTDeviceGroupForm' });
// TODO @haohaoweb-antd/src/views/iot/product/category/modules/product-category-form.vue
const emit = defineEmits<{
success: [];
}>();
const emit = defineEmits(['success']);
const formData = ref<IotDeviceGroupApi.DeviceGroup>();
const modalTitle = computed(() => {
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['设备分组'])
: $t('ui.actionTitle.create', ['设备分组']);
@@ -40,11 +32,9 @@ const [Form, formApi] = useVbenForm({
},
},
schema: useFormSchema(),
showCollapseButton: false,
showDefaultActions: false,
});
// TODO @haohao form12
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
@@ -52,17 +42,13 @@ const [Modal, modalApi] = useVbenModal({
return;
}
modalApi.lock();
//
const data = (await formApi.getValues()) as IotDeviceGroupApi.DeviceGroup;
try {
const values = await formApi.getValues();
await (formData.value?.id
? updateDeviceGroup({
...values,
id: formData.value.id,
} as IotDeviceGroupApi.DeviceGroup)
: createDeviceGroup(values as IotDeviceGroupApi.DeviceGroup));
? updateDeviceGroup(data)
: createDeviceGroup(data));
//
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
@@ -70,28 +56,20 @@ const [Modal, modalApi] = useVbenModal({
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
await formApi.resetForm();
return;
}
//
await formApi.resetForm();
//
const data = modalApi.getData<IotDeviceGroupApi.DeviceGroup>();
// id
if (!data || !data.id) {
formData.value = undefined;
return;
}
//
modalApi.lock();
try {
formData.value = await getDeviceGroup(data.id);
// values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
@@ -101,7 +79,7 @@ const [Modal, modalApi] = useVbenModal({
</script>
<template>
<Modal :title="modalTitle" class="w-2/5">
<Modal :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>

View File

@@ -1,7 +1,10 @@
import type { IotStatisticsApi } from '#/api/iot/statistics';
/** 统计数据 */
export type StatsData = IotStatisticsApi.StatisticsSummaryRespVO;
/** 默认统计数据 */
export const defaultStatsData: IotStatisticsApi.StatisticsSummary = {
export const defaultStatsData: StatsData = {
productCategoryCount: 0,
productCount: 0,
deviceCount: 0,

View File

@@ -1,5 +1,4 @@
<script setup lang="ts">
// TODO @芋艿
import type { StatsData } from './data';
import { onMounted, ref } from 'vue';
@@ -22,38 +21,7 @@ const statsData = ref<StatsData>(defaultStatsData);
/** 加载统计数据 */
async function loadStatisticsData(): Promise<StatsData> {
try {
return await getStatisticsSummary();
} catch (error) {
// TODO @haohao后续记得删除下哈。catch 部分可以删除
// 开发环境:记录错误信息,便于调试
console.error('获取统计数据出错:', error);
// 开发环境:提示使用 Mock 数据,提醒检查后端接口
console.warn('使用 Mock 数据,请检查后端接口是否已实现');
// TODO @haohao后续记得删除下哈。
// 开发调试:返回 Mock 数据,确保前端功能正常开发
// 生产环境:建议移除 Mock 数据,直接抛出错误或返回空数据
return {
productCategoryCount: 12,
productCount: 45,
deviceCount: 328,
deviceMessageCount: 15_678,
productCategoryTodayCount: 2,
productTodayCount: 5,
deviceTodayCount: 23,
deviceMessageTodayCount: 1234,
deviceOnlineCount: 256,
deviceOfflineCount: 48,
deviceInactiveCount: 24,
productCategoryDeviceCounts: {
智能家居: 120,
工业设备: 98,
环境监测: 65,
智能穿戴: 45,
},
};
}
return await getStatisticsSummary();
}
/** 加载数据 */
@@ -61,9 +29,6 @@ async function loadData() {
loading.value = true;
try {
statsData.value = await loadStatisticsData();
} catch (error) {
// TODO @haohao后续记得删除下哈。catch 部分可以删除
console.error('获取统计数据出错:', error);
} finally {
loading.value = false;
}
@@ -139,9 +104,3 @@ onMounted(() => {
</Row>
</Page>
</template>
<style scoped>
:deep(.vben-page-content) {
padding: 16px;
}
</style>

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