180 Commits

Author SHA1 Message Date
芋道源码
9be7bb5065 !311 (〃'▽'〃) v2025.12 发布:极大极大完善 vben5 的 antd、vben 版本的功能,新增 admin uniapp vue3 版本
Merge pull request !311 from 芋道源码/dev
2025-12-28 03:08:53 +00:00
YunaiV
63a8d562ce feat:【antd/ele】文件上传的组件优化 2025-12-27 18:50:42 +08:00
YunaiV
a109168c66 fix:【pay】修复钱包支付、模拟支付配置的修改报错问题 2025-12-27 17:29:08 +08:00
YunaiV
304f2442eb feat:【antd/ele】【infra】统一 cron-tab 的封装 2025-12-27 17:13:40 +08:00
YunaiV
826a1b355a feat:【antd/ele】对齐两侧的代码 2025-12-27 17:06:15 +08:00
YunaiV
9ef218f930 refactor: 【crm】【antd/ele】提取图表配置生成函数,统一 legend/grid/tooltip 处理,优化饼图面板生成 2025-12-27 17:02:11 +08:00
YunaiV
5a5d2f17da feat:【bpm】【antd/ele】业务表单,支持重新发起流程 2025-12-27 12:49:14 +08:00
芋道源码
70f075b003 !310 chore: new tag
Merge pull request !310 from xingyu/dev
2025-12-27 03:40:30 +00:00
xingyu4j
ab7b77989f fix: lint 2025-12-26 14:25:55 +08:00
xingyu4j
eacff553bd docs: README 2025-12-26 14:18:47 +08:00
xingyu4j
331cb2ca70 fix: sort 2025-12-26 14:18:38 +08:00
xingyu4j
703561ea33 chore: update deps 2025-12-26 14:18:29 +08:00
xingyu4j
c32a4c3e05 feat: remove playground 2025-12-26 14:11:34 +08:00
xingyu4j
77cd814c99 Merge branch 'main' of https://github.com/vbenjs/vue-vben-admin into dev 2025-12-26 14:00:55 +08:00
芋道源码
3908f03418 !309 feat:【antd】【ele】大量更新同步
Merge pull request !309 from 芋道源码/dev
2025-12-26 01:03:05 +00:00
YunaiV
68bb90a503 review:【antd】【iot】代码实现 2025-12-25 09:19:17 +08:00
YunaiV
11b5068e91 review:【antd】【bpm】评审流程详情界面 2025-12-25 00:58:16 +08:00
芋道源码
cb8a633f1f !308 refactor:【antd】【iot】代码优化
Merge pull request !308 from haohaoMT/dev
2025-12-22 14:21:49 +00:00
JyQAQ
a1bb132233 feat(api-cascader): 添加联级组件ApiCascader (#7031) 2025-12-22 20:00:31 +08:00
zhenghaoyang24
022d538940 Fix formatting in thin.md for clarity (#7008)
修改一些语句错误
2025-12-22 19:58:05 +08:00
xueyitt
ccf70a1b76 feat: 修正菜单排序在二级菜单不生效问题 (#7007)
* treeUtil增加对树形结构数据进行递归排序

* 菜单sort排序各级菜单均生效
2025-12-22 19:57:21 +08:00
haohao
6bf9acbfb2 refactor:【antd】【iot】代码优化 2025-12-22 17:30:59 +08:00
YunaiV
13f81b3130 review:【antd】【iot】代码实现 2025-12-21 23:04:57 +08:00
YunaiV
835da00f2c review:【antd/ele】【bpm】流程模型的迁移 2025-12-21 22:41:03 +08:00
YunaiV
bc654c9d45 fix:【ele】【mall】商品列表的“商品分类”不展示的问题,对应 https://t.zsxq.com/JCOWV 2025-12-21 21:24:59 +08:00
YunaiV
0f864b22c1 fix:【ele】【infra】代码生成时,无法选中菜单,对应 https://t.zsxq.com/xZ4fL 2025-12-21 21:15:49 +08:00
芋道源码
08f6cb7d14 !305 fix: [ele] 树形控件展开和选择属性配置问题
Merge pull request !305 from Lcp/dev
2025-12-21 12:53:39 +00:00
YunaiV
3ea5510a21 review:【antd/ele】【mall】营销模块的迁移 2025-12-21 20:50:27 +08:00
芋道源码
a3f282cba3 !307 mall 商城优化完善
Merge pull request !307 from puhui999/dev-mall
2025-12-21 11:13:33 +00:00
YunaiV
7ab917dc48 fix:【system】邮箱移除无用的 remark 字段及相关表单项 2025-12-20 20:48:24 +08:00
YunaiV
e850ffb038 fix:【bpm】修复流程模型的业务表单,路径的 tooltip 不对 2025-12-20 15:11:54 +08:00
芋道源码
1fc39e3fbb !306 Merge remote-tracking branch 'yudao/dev' into dev
Merge pull request !306 from Jason/dev
2025-12-20 07:10:35 +00:00
puhui999
568d5aa4cf feat:【antd】【mall】修复满减送编辑时表单打开异常的问题,原因:antd RangePicker 需要 dayjs 对象 2025-12-20 11:35:01 +08:00
puhui999
535c82c844 feat:【ele】【mall】seckill 代码对齐 antd 2025-12-20 11:20:34 +08:00
puhui999
c1b1343794 feat:【ele】【mall】rewardActivity 代码对齐 antd 2025-12-20 11:11:00 +08:00
puhui999
41f0a9465d feat:【ele】【mall】point 代码对齐 antd 2025-12-20 10:59:40 +08:00
puhui999
a7054ec09c feat:【ele】【mall】coupon 代码对齐 antd 2025-12-20 10:46:59 +08:00
puhui999
ebf7221fd2 feat:【ele】【mall】ElDialog :append-to-body="true" 2025-12-20 10:40:27 +08:00
puhui999
fad560db52 feat:【ele】【mall】discountActivity 代码对齐 antd 2025-12-20 10:36:50 +08:00
puhui999
503ed01c57 feat:【ele】【mall】combination 代码对齐 antd 2025-12-20 10:32:38 +08:00
puhui999
b9466282fc feat:【ele】【mall】bargain 代码对齐 antd 2025-12-20 10:10:19 +08:00
puhui999
d3a7a874a6 feat:【ele】【mall】comment 代码对齐 antd 2025-12-20 10:04:24 +08:00
YunaiV
d4b99f321d fix:【system】登录日志的“操作类型”改为“登录类型” 2025-12-20 09:23:34 +08:00
puhui999
e1e0554aca feat:【ele】【mall】spu 代码对齐 antd 2025-12-20 09:13:38 +08:00
puhui999
f429e74e79 feat:【antd】【mall】point 优化完善 2025-12-20 08:56:32 +08:00
puhui999
f99d708c97 feat:【antd】【mall】rewardActivity 优化完善 2025-12-20 08:53:47 +08:00
puhui999
57855eff06 feat:【antd】【mall】seckill 优化完善 2025-12-20 08:40:09 +08:00
puhui999
348cc35aec feat:【antd】【mall】combination 优化完善 2025-12-20 08:33:40 +08:00
puhui999
6922ab13fc feat:【antd】【mall】bargain 优化完善 2025-12-20 08:24:30 +08:00
jason
01dd8171e9 Merge remote-tracking branch 'yudao/dev' into dev 2025-12-18 23:32:39 +08:00
jason
0d043bca94 feat: [bpm][ele] bpmn 设计器问题修复 2025-12-18 23:28:59 +08:00
jason
9504fa3980 feat: [bpm][ele] bpmn 设计器流程监听器选择弹窗组件 2025-12-18 23:27:38 +08:00
jason
a91be61c21 feat: [bpm][ele] bpmn 设计器流程表达式选择弹窗组件 2025-12-18 23:25:37 +08:00
jason
b18353e171 feat: [bpm][ele] bpmn 设计器时间事件配置优化 2025-12-18 11:15:35 +08:00
YunaiV
fbcb498f5b feat:【system】操作日志增加 userType 的展示 2025-12-17 13:13:22 +08:00
Liu
32263c2b09 fix: [ele] 树形控件展开和选择属性配置问题 2025-12-17 11:12:51 +08:00
芋道源码
2202ef3b3c !304 refactor:【antd】【iot】重构设备详情页面,优化组件结构与路径,优化设备配置、属性、事件管理等功能
Merge pull request !304 from haohaoMT/dev
2025-12-16 14:55:09 +00:00
haohao
191e15975c refactor:【antd】【iot】重构设备详情页面,优化组件结构与路径,优化设备配置、属性、事件管理等功能 2025-12-16 16:45:35 +08:00
YunaiV
eb17976e96 merge: 合并远程 origin/dev 分支代码 2025-12-16 13:35:38 +08:00
YunaiV
70982eff92 fix:【ele】promotion activity 漏了一个 } 导致报错 2025-12-16 13:33:26 +08:00
芋道源码
5532f59c40 !303 refactor:【antd】【iot】统一列表视图和卡片视图的查询接口
Merge pull request !303 from haohaoMT/dev
2025-12-16 05:28:16 +00:00
haohao
439a35c165 refactor:【antd】【iot】统一列表视图和卡片视图的查询接口 2025-12-16 09:58:16 +08:00
芋道源码
42bdc15df5 !302 Merge remote-tracking branch 'yudao/dev' into dev
Merge pull request !302 from Jason/dev
2025-12-16 00:55:46 +00:00
haohao
3744069aa2 refactor:【antd】【iot】产品详情修改路径到 detail 2025-12-15 22:48:09 +08:00
jason
936e127c0d Merge remote-tracking branch 'yudao/dev' into dev 2025-12-15 22:42:17 +08:00
jason
ba126288a0 feat: [bpm][ele] bpmn 设计器 用户任务配置迁移优化 2025-12-15 22:39:59 +08:00
芋道源码
1d8d70e71a !301 refactor:【antd】【iot】更新首页必要的 ReqVO、RespVO
Merge pull request !301 from haohaoMT/dev
2025-12-15 14:28:31 +00:00
haohao
5cb412a4da refactor:【antd】【iot】更新首页必要的 ReqVO、RespVO 2025-12-15 21:51:09 +08:00
jason
dde16e26fe feat: [bpm][ele] bpmn 设计器迁移 2025-12-15 21:21:54 +08:00
jason
ca8ac99b6e feat: [bpm][ele] bpmn 设计器迁移 2025-12-15 21:03:28 +08:00
YunaiV
e3c1676523 review:【antd/ele】【mall】商品模块的迁移 2025-12-15 19:44:11 +08:00
YunaiV
4ec82f0fd0 review:【antd/ele】【mall】营销模块的迁移 2025-12-15 19:29:47 +08:00
芋道源码
ef0f0a9a9d !300 feat:【ele】【mall】promotion 代码迁移
Merge pull request !300 from puhui999/dev-mall
2025-12-15 09:01:19 +00:00
puhui999
b36c3c4209 feat:【ele】【mall】combination 代码迁移 2025-12-15 16:26:23 +08:00
puhui999
f137a66b6c feat:【ele】【mall】bargain 代码迁移 2025-12-15 16:18:14 +08:00
puhui999
e8526674c5 feat:【ele】【mall】seckill 代码迁移 2025-12-15 16:16:50 +08:00
puhui999
5417b19a8b feat:【ele】【mall】spu 选择组件优化 2025-12-15 16:07:47 +08:00
puhui999
e0d3fac19e feat:【ele】【mall】rewardActivity 代码迁移 2025-12-15 15:55:56 +08:00
puhui999
f943b175eb feat:【ele】【mall】point 代码迁移 2025-12-15 15:29:37 +08:00
YunaiV
3088fb3d46 review:【antd/ele】【MP】代码迁移的 review 2025-12-15 14:27:09 +08:00
芋道源码
43f3303ad2 !299 Merge branch 'dev' of <a href="https://gitee.com/yudaocode/yudao-ui-admin-vben">https://gitee.com/yudaocode/yudao-ui-admin-vben</a> into dev
Merge pull request !299 from dylanmay/dev
2025-12-15 06:17:56 +00:00
dylanmay
bdf1c293bd Merge branch 'dev' of https://gitee.com/yudaocode/yudao-ui-admin-vben into dev 2025-12-15 10:18:46 +08:00
dylanmay
12b0575ca1 fix: resolve todo 2025-12-15 10:13:13 +08:00
puhui999
3102eb511f feat:【ele】【mall】spu 代码迁移 2025-12-14 16:35:58 +08:00
puhui999
d8c87c0f7c feat:【antd】【mall】砍价活动商品选择优化 2025-12-14 15:05:46 +08:00
puhui999
f849f3ad3d feat:【antd】【mall】拼团活动商品选择优化 2025-12-14 14:59:24 +08:00
芋道源码
47dfccd9eb Merge pull request #187 from solante1012/dev
fix: [bpm][antd] BPM 设计器用户任务, 点击查不到数据问题
2025-12-13 10:27:01 +08:00
芋道源码
1d0b14bbe5 !298 Merge remote-tracking branch 'yudao/dev' into dev
Merge pull request !298 from Jason/dev
2025-12-13 01:15:08 +00:00
jason
07c8763fae Merge remote-tracking branch 'yudao/dev' into dev 2025-12-13 00:43:26 +08:00
jason
8df5fbc843 feat: [bpm][antd] bpmn 设计器时间事件定义优化 2025-12-13 00:39:00 +08:00
chencan
51ce864dbd fix: [bpm][antd] BPM 设计器用户任务, 点击查不到数据问题 2025-12-12 15:12:39 +08:00
jason
d50b9fae60 fix: [bpm][antd] bpmn 设计器审批人超时未处理自定义配置问题修复 2025-12-12 13:36:50 +08:00
jason
0db2710e80 fix: [bpm][antd] bpmn 设计器流转条件问题修复 2025-12-11 18:05:58 +08:00
芋道源码
380f74015e !297 Merge remote-tracking branch 'yudao/dev' into dev
Merge pull request !297 from Jason/dev
2025-12-09 15:26:58 +00:00
jason
2fc76789e2 Merge remote-tracking branch 'yudao/dev' into dev 2025-12-09 23:15:54 +08:00
jason
43ed7aeefb feat: [bpm][antd] bpmn 设计器子流程调用问题修复 2025-12-09 23:14:35 +08:00
jason
28e4305916 feat: [bpm][antd] bpm 设计器接收任务优化 2025-12-09 20:20:47 +08:00
芋道源码
223be87dc8 !296 feat: [bpm][antd] bpmn设计器脚本任务优化
Merge pull request !296 from Jason/dev
2025-12-09 01:11:31 +00:00
jason
70bcc5ea0f feat: [bpm][antd] bpmn设计器脚本任务优化 2025-12-08 23:41:34 +08:00
jason
fba43de19f fix: [bpm][antd] bpm设计器 ServiceTask 问题修复 2025-12-08 22:55:27 +08:00
YunaiV
f1c7a4ebfb review:【antd】【BPM】代码迁移的 review 2025-12-08 08:59:55 +08:00
芋道源码
67ed1753a7 !295 feat: [bpm][antd] bpm设计器 用户任务自定义配置优化
Merge pull request !295 from Jason/dev
2025-12-08 00:54:24 +00:00
jason
1c17746864 feat: [bpm][antd] bpm设计器 用户任务自定义配置优化 2025-12-08 00:01:44 +08:00
jason
2cf7e70b70 feat: [bpm][antd] bpm设计器 多人审批方式优化 2025-12-07 23:59:50 +08:00
jason
cfb9a9b3c9 fix: 冲突解决 2025-12-07 21:22:27 +08:00
YunaiV
2a4c774aca review:【antd/ele】【iot】代码迁移的 review 2025-12-07 16:36:55 +08:00
芋道源码
250109507f !294 refactor:【antd】【iot】将物联网设备和产品枚举整合为常量,优化设备导入功能,简化设备管理UI组件
Merge pull request !294 from haohaoMT/dev
2025-12-07 04:53:11 +00:00
YunaiV
03d25bf85a review:【antd/ele】【mp】代码迁移的 review 2025-12-07 12:51:51 +08:00
芋道源码
2fc86b7bda !292 fix: todo 处理
Merge pull request !292 from dylanmay/dev
2025-12-07 04:36:19 +00:00
YunaiV
274aa7da73 fix: 【bpm】bpmn设计器: 消息与信号不能保存,对应issure:#202 #206【同步自 vue3 + element-plus】 2025-12-07 12:28:24 +08:00
YunaiV
7366b948a3 fix: 【bpm】bpmn设计器: 组件部分属性第一次失去焦点丢失数据问题 #204 【同步自 vue3 + element-plus】 2025-12-07 12:26:03 +08:00
jason
89f93d0291 feat: [bpm][antd] bpm 设计器消息与信号,文档描述优化 2025-12-06 21:56:27 +08:00
haohao
2b270caf30 refactor:【antd】【iot】将物联网设备和产品枚举整合为常量,优化设备导入功能,简化设备管理UI组件 2025-12-06 17:54:46 +08:00
jason
21b5dc255e fix: [bpm][antd] bpm 设计器添加属性问题修复 2025-12-06 16:59:02 +08:00
JyQAQ
1479f159aa feat(CellImage): CellImage组件支持图片属性写入 (#6992) 2025-12-06 10:12:58 +08:00
jason
5e1abfb08a Merge remote-tracking branch 'yudao/dev' into dev 2025-12-05 22:54:07 +08:00
jason
604517b2ab feat: [bpm][antd] bpm 设计器执行监听器优化 2025-12-05 22:52:42 +08:00
jason
d3cfc67bd7 feat: [bpm][antd] BPM 设计器选择监听器 2025-12-05 21:24:32 +08:00
dylanmay
5bae28516c fix: todo 处理 2025-12-05 11:00:48 +08:00
dylanmay
5a6122ab75 Merge branch 'dev' of https://gitee.com/yudaocode/yudao-ui-admin-vben into dev 2025-12-05 09:37:41 +08:00
dylanmay
5021f2487d fix: ele mp 素材管理 2025-12-05 09:35:49 +08:00
jason
75a2b331b7 feat: [bpm][antd] 用户和部门选择组件位置优化 2025-12-04 22:35:40 +08:00
xingyu
cc375100cb !290 refactor:【antd】【iot】产品管理问题修复
Merge pull request !290 from haohaoMT/dev
2025-12-04 03:07:26 +00:00
xingyu
dea8bf4631 !291 Merge remote-tracking branch 'yudao/dev' into dev
Merge pull request !291 from Jason/dev
2025-12-04 03:05:40 +00:00
jason
d7d883a54c Merge remote-tracking branch 'yudao/dev' into dev 2025-12-03 23:57:54 +08:00
jason
943a8e0cee fix: [bpm][antd] BPM 设计器任务监听器问题修复 2025-12-03 23:55:28 +08:00
JyQAQ
9105d4d14a feat(api-component): api-component组件的options支持指定disabled值 (#6991) 2025-12-03 10:03:23 +08:00
haohao
62b12235f7 refactor:【antd】【iot】产品管理问题修复 2025-12-02 17:54:18 +08:00
haohao
00ee233f14 refactor:【antd】【iot】设备分组和产品分类表单简化 2025-12-02 16:17:00 +08:00
芋道源码
ae1c75ae9a !289 Merge remote-tracking branch 'yudao/dev' into dev
Merge pull request !289 from Jason/dev
2025-12-01 16:50:15 +00:00
jason
b94513dee4 Merge remote-tracking branch 'yudao/dev' into dev 2025-12-01 20:47:51 +08:00
YunaiV
16f9057e1c review:【antd】【mall】diy 店铺装修 2025-12-01 19:40:07 +08:00
芋道源码
60400525cc !287 fix: ele wx-material-select组件同步
Merge pull request !287 from hw/dev
2025-12-01 11:23:59 +00:00
YunaiV
c05463ca0a review:【antd】【mall】营销活动的商品选择 2025-12-01 19:21:30 +08:00
YunaiV
a2e6e5097d review:【antd】【mall】营销活动的商品选择 2025-12-01 19:20:28 +08:00
芋道源码
68fc2f6a33 !288 feat:【antd】【mall】商城活动优化
Merge pull request !288 from puhui999/dev-mall
2025-12-01 10:54:59 +00:00
puhui999
a5b51f45da feat:【antd】【mall】商城活动优化 2025-12-01 18:37:05 +08:00
xingyu4j
05c064a250 fix: lint 2025-12-01 17:03:38 +08:00
xingyu4j
d16ebea639 fix: #ID9R98 2025-12-01 16:56:22 +08:00
jason
29e79448e4 feat: [bpm][antd] todo 修改, 一些优化 2025-12-01 15:53:57 +08:00
hw
a9a075346f fix: 【装修】todo修复 2025-12-01 15:48:27 +08:00
jason
0731999e7d feat: [bpm][antd] bpmn 设计器问题修复 2025-12-01 12:52:18 +08:00
hw
aedcf2d05c fix: ele wx-material-select组件同步 2025-12-01 10:40:36 +08:00
luoqiz
c76db7d8d1 fix: 修复icon丢失根属性导致的样式错误 (#6986) 2025-12-01 09:51:27 +08:00
芋道源码
df7135b288 !286 Merge remote-tracking branch 'yudao/dev' into dev
Merge pull request !286 from Jason/dev
2025-11-30 02:07:47 +00:00
jason
867ebf2967 Merge remote-tracking branch 'yudao/dev' into dev 2025-11-30 00:12:03 +08:00
jason
56669b134c feat: [bpm][ele] bpm 迁移优化 2025-11-30 00:10:54 +08:00
jason
e18bbca376 feat: [bpm][ele] bpm 迁移优化 2025-11-30 00:08:25 +08:00
YunaiV
86894d6e66 fix:【antd】【bpm】simple 设计器:画布的 CSS transition 属性以解决拖拽卡顿问题,来自:https://github.com/yudaocode/yudao-ui-admin-vue3/pull/173 2025-11-29 19:51:19 +08:00
YunaiV
2548db3fda feat:【antd】【bpm】bpmn 设计器:服务任务中新增执行类型,来自:https://github.com/yudaocode/yudao-ui-admin-vue3/pull/200/ 2025-11-29 18:46:40 +08:00
YunaiV
3f9dc0becc feat:【antd】【bpm】bpmn 设计器:修复一些bpmn-js标签typo问题,新增一些翻译,来自:https://github.com/yudaocode/yudao-ui-admin-vue3/pull/201 2025-11-29 18:36:58 +08:00
YunaiV
22aefe72f4 feat:【antd】【bpm】bpmn 设计器:优化 消息和信号 的新增,自动生成符合BPMN规范的id,来自:https://github.com/yudaocode/yudao-ui-admin-vue3/pull/203 2025-11-29 18:30:23 +08:00
YunaiV
6c9affae76 feat:【antd】【bpm】bpmn 设计器:工作流节点操作按钮的操作不起作用,来自:9f1c4f2578 2025-11-29 18:14:41 +08:00
YunaiV
71c80efab0 feat:【antd】【bpm】bpmn 设计器:增加消息与信号的编辑、删除功能,来自:c568d45180 2025-11-29 18:09:49 +08:00
YunaiV
f7ce553771 feat:【antd】【bpm】bpmn 设计器:bpmn 设计器:使用定时中间捕获事件,部署流程图提示校验失败:flowable-event-timer-missing-configuration,来自:b666e1bdd4 2025-11-29 18:05:03 +08:00
YunaiV
f9913692f0 feat:【antd】【bpm】bpmn 设计器:保留非监听器类型的扩展属性,避免移除监听器时清空其他配置,来自:33e489ebfc 2025-11-29 18:01:22 +08:00
YunaiV
72bbfd4a9c fix:【infra】数据源 id = 0 可以被编辑的错误 2025-11-29 16:12:38 +08:00
YunaiV
7aab11b984 feat:【system】支付宝小程序登录补充 2025-11-29 16:06:23 +08:00
YunaiV
09300af7bc feat:【infra】文件配置,增加 region 区域 2025-11-29 15:59:55 +08:00
YunaiV
6fb3480676 review:【antd/ele】【mp】代码迁移的 review 2025-11-29 11:54:15 +08:00
芋道源码
3409a8a88f !284 Merge branch 'dev' of <a href="https://gitee.com/yudaocode/yudao-ui-admin-vben">https://gitee.com/yudaocode/yudao-ui-admin-vben</a> into dev
Merge pull request !284 from dylanmay/dev
2025-11-29 03:34:46 +00:00
YunaiV
bdb63cb293 review:【antd】【mall】店铺装修 2025-11-29 11:32:37 +08:00
YunaiV
0ffebd6de4 feat:【antd】【mall】优化满减送的界面 2025-11-29 11:17:37 +08:00
YunaiV
56409edff4 Merge remote-tracking branch 'origin/dev' 2025-11-29 10:42:13 +08:00
YunaiV
d868e4abfc review:【antd】【mall】商品选择相关逻辑 2025-11-29 10:30:48 +08:00
dylanmay
6214c33c86 Merge branch 'dev' of https://gitee.com/yudaocode/yudao-ui-admin-vben into dev 2025-11-28 15:51:53 +08:00
dylanmay
1dc2a31f84 fix: resolve ele to do 2025-11-28 15:42:19 +08:00
dylanmay
0d0d9e30c0 fix: resolve antd to do 2025-11-28 15:41:17 +08:00
dylanmay
ffc48fa171 fix: resolve antd to do 2025-11-28 15:39:39 +08:00
Jin Mao
6f39e9136e Merge branch 'main' into feature/antd上传组件支持调用Image组件查看图片 2025-11-24 21:59:34 +08:00
milletpeak
1f1ba16ead Merge branch 'main' into milletpeak-fontsize 2025-11-24 08:55:54 +08:00
yuan.ji
1d77b018bb feat(function): add antd上传组件支持调用Image组件查看图片 2025-11-21 17:33:59 +08:00
米山
f7d9d1b1af chore: update package.json and app.vue imports, and ensure global styles are included 2025-11-19 11:13:06 +08:00
米山
aaf0274fe9 feat: add menu font size variable and update related components
- Introduced a new CSS variable `--menu-font-size` calculated from the base font size.
- Updated `PreferenceManager` to trigger CSS variable updates when `fontSize` is modified.
- Adjusted `updateCSSVariables` to set the new `--menu-font-size` based on the theme's font size.
- Ensured that the menu components utilize the updated font size with `!important` to maintain styling consistency.
2025-11-19 10:51:10 +08:00
米山
c142af482b fix: update snapshot for defaultPreferences immutability test to reflect fontSize change
- Adjusted the snapshot to ensure consistency with the updated defaultPreferences configuration, specifically retaining the fontSize property.
2025-11-19 10:19:16 +08:00
米山
cd7c11c7d0 fix: run 'pnpm format' update various components and improve layout structure
- Updated demo-preview and preview-group components for better error handling and layout.
- Enhanced drawer and modal components for improved auto-height functionality.
- Refactored layout components including header, footer, sidebar, and tabbar for better responsiveness and usability.
- Adjusted tooltip and help tooltip components for better user guidance.
- Fixed issues in various UI components to ensure consistent styling and functionality across the application.
2025-11-19 10:14:04 +08:00
milletpeak
fb8f36eeec Merge branch 'main' into milletpeak-fontsize 2025-11-19 09:41:55 +08:00
Jin Mao
c3a7562e2c Merge branch 'main' into milletpeak-fontsize 2025-11-13 17:01:42 +08:00
米山
0bc7169698 feat: add global font size adjustment 2025-11-12 17:39:07 +08:00
米山
24b6e7a835 feat: add global font size adjustment 2025-11-12 17:38:41 +08:00
467 changed files with 33399 additions and 14762 deletions

View File

@@ -9,7 +9,7 @@
## 🐶 新手必读
- nodejs > 20.12.0 && pnpm > 10.14.0 (强制使用pnpm)
- nodejs > 20.12.0 && pnpm > 10.22.0 (强制使用pnpm)
- 演示地址【Vue3 + element-plus】<http://dashboard-vue3.yudao.iocoder.cn>
- 演示地址【Vue3 + vben5(ant-design-vue)】:<http://dashboard-vben.yudao.iocoder.cn>
- 演示地址【Vue2 + element-ui】<http://dashboard.yudao.iocoder.cn>
@@ -41,22 +41,22 @@
| 框架 | 说明 | 版本 |
| --- | --- | --- |
| [Vue](https://staging-cn.vuejs.org/) | vue框架 | 3.5.17 |
| [Vite](https://cn.vitejs.dev//) | 开发与构建工具 | 7.1.2 |
| [Vue](https://staging-cn.vuejs.org/) | vue框架 | 3.5.24 |
| [Vite](https://cn.vitejs.dev//) | 开发与构建工具 | 7.2.2 |
| [Ant Design Vue](https://www.antdv.com/) | Ant Design Vue | 4.2.6 |
| [Element Plus](https://element-plus.org/zh-CN/) | Element Plus | 2.10.2 |
| [Naive UI](https://www.naiveui.com/) | Naive UI | 2.42.0 |
| [TDesign](https://tdesign.tencent.com/) | TDesign | 1.17.1 |
| [TypeScript](https://www.typescriptlang.org/docs/) | JavaScript 超集 | 5.8.3 |
| [TypeScript](https://www.typescriptlang.org/docs/) | JavaScript 超集 | 5.9.3 |
| [pinia](https://pinia.vuejs.org/) | Vue 存储库替代 vuex5 | 3.0.3 |
| [vueuse](https://vueuse.org/) | 常用工具集 | 13.4.0 |
| [vue-i18n](https://kazupon.github.io/vue-i18n/zh/introduction.html/) | 国际化 | 11.1.7 |
| [vue-router](https://router.vuejs.org/) | Vue 路由 | 4.5.1 |
| [Tailwind CSS](https://tailwindcss.com/) | 原子 CSS | 3.4.17 |
| [Tailwind CSS](https://tailwindcss.com/) | 原子 CSS | 3.4.18 |
| [Iconify](https://iconify.design/) | 图标组件 | 5.0.0 |
| [Iconify](https://icon-sets.iconify.design/) | 在线图标库 | 2.2.354 |
| [Iconify](https://icon-sets.iconify.design/) | 在线图标库 | 2.2.406 |
| [TinyMCE](https://www.tiny.cloud/) | 富文本编辑器 | 6.1.0 |
| [Echarts](https://echarts.apache.org/) | 图表库 | 5.6.0 |
| [Echarts](https://echarts.apache.org/) | 图表库 | 6.0.0 |
| [axios](https://axios-http.com/) | http客户端 | 1.10.0 |
| [dayjs](https://day.js.org/) | 日期处理库 | 1.11.13 |
| [vee-validate](https://vee-validate.logaretm.com/) | 表单验证 | 4.15.1 |

View File

@@ -3,15 +3,31 @@
* 可用于 vben-form、vben-modal、vben-drawer 等组件使用,
*/
import type { Component } from 'vue';
import type {
UploadChangeParam,
UploadFile,
UploadProps,
} from 'ant-design-vue';
import type { Component, Ref } from 'vue';
import type { BaseFormComponentType } from '@vben/common-ui';
import type { Recordable } from '@vben/types';
import { defineAsyncComponent, defineComponent, h, ref } from 'vue';
import {
defineAsyncComponent,
defineComponent,
h,
ref,
render,
unref,
watch,
} from 'vue';
import { ApiComponent, globalShareState, IconPicker } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { $t } from '@vben/locales';
import { isEmpty } from '@vben/utils';
import { notification } from 'ant-design-vue';
@@ -22,9 +38,6 @@ const AutoComplete = defineAsyncComponent(
() => import('ant-design-vue/es/auto-complete'),
);
const Button = defineAsyncComponent(() => import('ant-design-vue/es/button'));
const Cascader = defineAsyncComponent(
() => import('ant-design-vue/es/cascader'),
);
const Checkbox = defineAsyncComponent(
() => import('ant-design-vue/es/checkbox'),
);
@@ -68,7 +81,14 @@ const TimeRangePicker = defineAsyncComponent(() =>
const TreeSelect = defineAsyncComponent(
() => import('ant-design-vue/es/tree-select'),
);
const Cascader = defineAsyncComponent(
() => import('ant-design-vue/es/cascader'),
);
const Upload = defineAsyncComponent(() => import('ant-design-vue/es/upload'));
const Image = defineAsyncComponent(() => import('ant-design-vue/es/image'));
const PreviewGroup = defineAsyncComponent(() =>
import('ant-design-vue/es/image').then((res) => res.ImagePreviewGroup),
);
const withDefaultPlaceholder = <T extends Component>(
component: T,
@@ -104,12 +124,223 @@ const withDefaultPlaceholder = <T extends Component>(
});
};
const withPreviewUpload = () => {
return defineComponent({
name: Upload.name,
emits: ['change', 'update:modelValue'],
setup: (
props: any,
{ attrs, slots, emit }: { attrs: any; emit: any; slots: any },
) => {
const previewVisible = ref<boolean>(false);
const placeholder = attrs?.placeholder || $t(`ui.placeholder.upload`);
const listType = attrs?.listType || attrs?.['list-type'] || 'text';
const fileList = ref<UploadProps['fileList']>(
attrs?.fileList || attrs?.['file-list'] || [],
);
const handleChange = async (event: UploadChangeParam) => {
fileList.value = event.fileList;
emit('change', event);
emit(
'update:modelValue',
event.fileList?.length ? fileList.value : undefined,
);
};
const handlePreview = async (file: UploadFile) => {
previewVisible.value = true;
await previewImage(file, previewVisible, fileList);
};
const renderUploadButton = (): any => {
const isDisabled = attrs.disabled;
// 如果禁用,不渲染上传按钮
if (isDisabled) {
return null;
}
// 否则渲染默认上传按钮
return isEmpty(slots)
? createDefaultSlotsWithUpload(listType, placeholder)
: slots;
};
// 可以监听到表单API设置的值
watch(
() => attrs.modelValue,
(res) => {
fileList.value = res;
},
);
return () =>
h(
Upload,
{
...props,
...attrs,
fileList: fileList.value,
onChange: handleChange,
onPreview: handlePreview,
},
renderUploadButton(),
);
},
});
};
const createDefaultSlotsWithUpload = (
listType: string,
placeholder: string,
) => {
switch (listType) {
case 'picture-card': {
return {
default: () => placeholder,
};
}
default: {
return {
default: () =>
h(
Button,
{
icon: h(IconifyIcon, {
icon: 'ant-design:upload-outlined',
class: 'mb-1 size-4',
}),
},
() => placeholder,
),
};
}
}
};
const previewImage = async (
file: UploadFile,
visible: Ref<boolean>,
fileList: Ref<UploadProps['fileList']>,
) => {
// 检查是否为图片文件的辅助函数
const isImageFile = (file: UploadFile): boolean => {
const imageExtensions = new Set([
'bmp',
'gif',
'jpeg',
'jpg',
'png',
'webp',
]);
if (file.url) {
const ext = file.url?.split('.').pop()?.toLowerCase();
return ext ? imageExtensions.has(ext) : false;
}
if (!file.type) {
const ext = file.name?.split('.').pop()?.toLowerCase();
return ext ? imageExtensions.has(ext) : false;
}
return file.type.startsWith('image/');
};
// 如果当前文件不是图片,直接打开
if (!isImageFile(file)) {
if (file.url) {
window.open(file.url, '_blank');
} else if (file.preview) {
window.open(file.preview, '_blank');
} else {
console.warn('无法打开文件没有可用的URL或预览地址');
}
return;
}
// 对于图片文件,继续使用预览组
const [ImageComponent, PreviewGroupComponent] = await Promise.all([
Image,
PreviewGroup,
]);
const getBase64 = (file: File) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.addEventListener('load', () => resolve(reader.result));
reader.addEventListener('error', (error) => reject(error));
});
};
// 从fileList中过滤出所有图片文件
const imageFiles = (unref(fileList) || []).filter((element) =>
isImageFile(element),
);
// 为所有没有预览地址的图片生成预览
for (const imgFile of imageFiles) {
if (!imgFile.url && !imgFile.preview && imgFile.originFileObj) {
imgFile.preview = (await getBase64(imgFile.originFileObj)) as string;
}
}
const container: HTMLElement | null = document.createElement('div');
document.body.append(container);
// 用于追踪组件是否已卸载
let isUnmounted = false;
const PreviewWrapper = {
setup() {
return () => {
if (isUnmounted) return null;
return h(
PreviewGroupComponent,
{
class: 'hidden',
preview: {
visible: visible.value,
// 设置初始显示的图片索引
current: imageFiles.findIndex((f) => f.uid === file.uid),
onVisibleChange: (value: boolean) => {
visible.value = value;
if (!value) {
// 延迟清理,确保动画完成
setTimeout(() => {
if (!isUnmounted && container) {
isUnmounted = true;
render(null, container);
container.remove();
}
}, 300);
}
},
},
},
() =>
// 渲染所有图片文件
imageFiles.map((imgFile) =>
h(ImageComponent, {
key: imgFile.uid,
src: imgFile.url || imgFile.preview,
}),
),
);
};
},
};
render(h(PreviewWrapper), container);
};
// 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明
export type ComponentType =
| 'ApiCascader'
| 'ApiSelect'
| 'ApiTreeSelect'
| 'AutoComplete'
| 'Cascader'
| 'Checkbox'
| 'CheckboxGroup'
| 'DatePicker'
@@ -143,21 +374,13 @@ async function initComponentAdapter() {
// 如果你的组件体积比较大,可以使用异步加载
// Button: () =>
// import('xxx').then((res) => res.Button),
ApiCascader: withDefaultPlaceholder(
{
...ApiComponent,
name: 'ApiCascader',
},
'select',
{
component: Cascader,
fieldNames: { label: 'label', value: 'value', children: 'children' },
loadingSlot: 'suffixIcon',
modelPropName: 'value',
optionsPropName: 'treeData',
visibleEvent: 'onVisibleChange',
},
),
ApiCascader: withDefaultPlaceholder(ApiComponent, 'select', {
component: Cascader,
fieldNames: { label: 'label', value: 'value', children: 'children' },
loadingSlot: 'suffixIcon',
modelPropName: 'value',
visibleEvent: 'onVisibleChange',
}),
ApiSelect: withDefaultPlaceholder(
{
...ApiComponent,
@@ -187,6 +410,7 @@ async function initComponentAdapter() {
},
),
AutoComplete,
Cascader,
Checkbox,
CheckboxGroup,
DatePicker,
@@ -221,6 +445,7 @@ async function initComponentAdapter() {
TimeRangePicker,
TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'),
Upload,
PreviewUpload: withPreviewUpload(),
FileUpload,
ImageUpload,
};

View File

@@ -84,9 +84,10 @@ setupVbenVxeTable({
// 表格配置项可以用 cellRender: { name: 'CellImage' },
vxeUI.renderer.add('CellImage', {
renderTableDefault(_renderOpts, params) {
renderTableDefault(renderOpts, params) {
const { props } = renderOpts;
const { column, row } = params;
return h(Image, { src: row[column.field] });
return h(Image, { src: row[column.field], ...props });
},
});

View File

@@ -30,6 +30,7 @@ export namespace BpmModelApi {
deploymentTime: number;
suspensionState: number;
formType?: number;
formCustomCreatePath?: string;
formCustomViewPath?: string;
formFields?: string[];
}

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

@@ -51,12 +51,12 @@ const { getStringAccept } = useUploadType({
maxSizeRef: maxSize,
});
// 计算当前绑定的值,优先使用 modelValue
/** 计算当前绑定的值,优先使用 modelValue */
const currentValue = computed(() => {
return props.modelValue === undefined ? props.value : props.modelValue;
});
// 判断是否使用 modelValue
/** 判断是否使用 modelValue */
const isUsingModelValue = computed(() => {
return props.modelValue !== undefined;
});
@@ -82,19 +82,21 @@ watch(
} else {
value.push(v);
}
fileList.value = value.map((item, i) => {
if (item && isString(item)) {
return {
uid: `${-i}`,
name: item.slice(Math.max(0, item.lastIndexOf('/') + 1)),
status: UploadResultStatus.DONE,
url: item,
};
} else if (item && isObject(item)) {
return item;
}
return null;
}) as UploadProps['fileList'];
fileList.value = value
.map((item, i) => {
if (item && isString(item)) {
return {
uid: `${-i}`,
name: item.slice(Math.max(0, item.lastIndexOf('/') + 1)),
status: UploadResultStatus.DONE,
url: item,
};
} else if (item && isObject(item)) {
return item;
}
return null;
})
.filter(Boolean) as UploadProps['fileList'];
}
if (!isFirstRender.value) {
emit('change', value);
@@ -107,6 +109,7 @@ watch(
},
);
/** 处理文件删除 */
async function handleRemove(file: UploadFile) {
if (fileList.value) {
const index = fileList.value.findIndex((item) => item.uid === file.uid);
@@ -120,17 +123,17 @@ async function handleRemove(file: UploadFile) {
}
}
// 处理文件预览
/** 处理文件预览 */
function handlePreview(file: UploadFile) {
emit('preview', file);
}
// 处理文件数量超限
/** 处理文件数量超限 */
function handleExceed() {
message.error($t('ui.upload.maxNumber', [maxNumber.value]));
}
// 处理上传错误
/** 处理上传错误 */
function handleUploadError(error: any) {
console.error('上传错误:', error);
message.error($t('ui.upload.uploadError'));
@@ -138,6 +141,11 @@ function handleUploadError(error: any) {
uploadNumber.value = Math.max(0, uploadNumber.value - 1);
}
/**
* 上传前校验
* @param file 待上传的文件
* @returns 是否允许上传
*/
async function beforeUpload(file: File) {
const fileContent = await file.text();
emit('returnText', fileContent);
@@ -171,7 +179,8 @@ async function beforeUpload(file: File) {
return true;
}
async function customRequest(info: UploadRequestOption<any>) {
/** 自定义上传请求 */
async function customRequest(info: UploadRequestOption) {
let { api } = props;
if (!api || !isFunction(api)) {
api = useUpload(props.directory).httpRequest;
@@ -196,7 +205,11 @@ async function customRequest(info: UploadRequestOption<any>) {
}
}
// 处理上传成功
/**
* 处理上传成功
* @param res 上传响应结果
* @param file 上传的文件
*/
function handleUploadSuccess(res: any, file: File) {
// 删除临时文件
const index = fileList.value?.findIndex((item) => item.name === file.name);
@@ -228,6 +241,10 @@ function handleUploadSuccess(res: any, file: File) {
}
}
/**
* 获取当前文件列表的值
* @returns 文件 URL 列表或字符串
*/
function getValue() {
const list = (fileList.value || [])
.filter((item) => item?.status === UploadResultStatus.DONE)

View File

@@ -55,12 +55,12 @@ const { getStringAccept } = useUploadType({
maxSizeRef: maxSize,
});
// 计算当前绑定的值,优先使用 modelValue
/** 计算当前绑定的值,优先使用 modelValue */
const currentValue = computed(() => {
return props.modelValue === undefined ? props.value : props.modelValue;
});
// 判断是否使用 modelValue
/** 判断是否使用 modelValue */
const isUsingModelValue = computed(() => {
return props.modelValue !== undefined;
});
@@ -89,19 +89,21 @@ watch(
} else {
value.push(v);
}
fileList.value = value.map((item, i) => {
if (item && isString(item)) {
return {
uid: `${-i}`,
name: item.slice(Math.max(0, item.lastIndexOf('/') + 1)),
status: UploadResultStatus.DONE,
url: item,
};
} else if (item && isObject(item)) {
return item;
}
return null;
}) as UploadProps['fileList'];
fileList.value = value
.map((item, i) => {
if (item && isString(item)) {
return {
uid: `${-i}`,
name: item.slice(Math.max(0, item.lastIndexOf('/') + 1)),
status: UploadResultStatus.DONE,
url: item,
};
} else if (item && isObject(item)) {
return item;
}
return null;
})
.filter(Boolean) as UploadProps['fileList'];
}
if (!isFirstRender.value) {
emit('change', value);
@@ -114,6 +116,7 @@ watch(
},
);
/** 将文件转换为 Base64 格式 */
function getBase64<T extends ArrayBuffer | null | string>(file: File) {
return new Promise<T>((resolve, reject) => {
const reader = new FileReader();
@@ -125,6 +128,7 @@ function getBase64<T extends ArrayBuffer | null | string>(file: File) {
});
}
/** 处理图片预览 */
async function handlePreview(file: UploadFile) {
if (!file.url && !file.preview) {
file.preview = await getBase64<string>(file.originFileObj!);
@@ -138,6 +142,7 @@ async function handlePreview(file: UploadFile) {
);
}
/** 处理文件删除 */
async function handleRemove(file: UploadFile) {
if (fileList.value) {
const index = fileList.value.findIndex((item) => item.uid === file.uid);
@@ -151,11 +156,17 @@ async function handleRemove(file: UploadFile) {
}
}
/** 关闭预览弹窗 */
function handleCancel() {
previewOpen.value = false;
previewTitle.value = '';
}
/**
* 上传前校验
* @param file 待上传的文件
* @returns 是否允许上传
*/
async function beforeUpload(file: File) {
// 检查文件数量限制
if (fileList.value!.length >= props.maxNumber) {
@@ -186,7 +197,8 @@ async function beforeUpload(file: File) {
return true;
}
async function customRequest(info: UploadRequestOption<any>) {
/** 自定义上传请求 */
async function customRequest(info: UploadRequestOption) {
let { api } = props;
if (!api || !isFunction(api)) {
api = useUpload(props.directory).httpRequest;
@@ -211,7 +223,11 @@ async function customRequest(info: UploadRequestOption<any>) {
}
}
// 处理上传成功
/**
* 处理上传成功
* @param res 上传响应结果
* @param file 上传的文件
*/
function handleUploadSuccess(res: any, file: File) {
// 删除临时文件
const index = fileList.value?.findIndex((item) => item.name === file.name);
@@ -243,14 +259,18 @@ function handleUploadSuccess(res: any, file: File) {
}
}
// 处理上传错误
/** 处理上传错误 */
function handleUploadError(error: any) {
console.error('上传错误:', error);
message.error('上传错误!!!');
message.error($t('ui.upload.uploadError'));
// 上传失败时减少计数器
uploadNumber.value = Math.max(0, uploadNumber.value - 1);
}
/**
* 获取当前文件列表的值
* @returns 文件 URL 列表或字符串
*/
function getValue() {
const list = (fileList.value || [])
.filter((item) => item?.status === UploadResultStatus.DONE)

View File

@@ -6,7 +6,7 @@ import type { FileUploadProps } from './typing';
import { computed } from 'vue';
import { useVModel } from '@vueuse/core';
import { Col, Input, Row, Textarea } from 'ant-design-vue';
import { Input, Textarea } from 'ant-design-vue';
import FileUpload from './file-upload.vue';
@@ -30,6 +30,7 @@ const modelValue = useVModel(props, 'modelValue', emits, {
passive: true,
});
/** 处理文件内容返回 */
function handleReturnText(text: string) {
modelValue.value = text;
emits('change', modelValue.value);
@@ -37,6 +38,7 @@ function handleReturnText(text: string) {
emits('update:modelValue', modelValue.value);
}
/** 计算输入框属性 */
const inputProps = computed(() => {
return {
...props.inputProps,
@@ -44,6 +46,7 @@ const inputProps = computed(() => {
};
});
/** 计算文本域属性 */
const textareaProps = computed(() => {
return {
...props.textareaProps,
@@ -51,6 +54,7 @@ const textareaProps = computed(() => {
};
});
/** 计算文件上传属性 */
const fileUploadProps = computed(() => {
return {
...props.fileUploadProps,
@@ -58,17 +62,17 @@ const fileUploadProps = computed(() => {
});
</script>
<template>
<Row>
<Col :span="18">
<Input readonly v-if="inputType === 'input'" v-bind="inputProps" />
<Textarea readonly v-else :row="4" v-bind="textareaProps" />
</Col>
<Col :span="6">
<FileUpload
class="ml-4"
v-bind="fileUploadProps"
@return-text="handleReturnText"
/>
</Col>
</Row>
<div class="w-full">
<Input v-if="inputType === 'input'" readonly v-bind="inputProps">
<template #suffix>
<FileUpload v-bind="fileUploadProps" @return-text="handleReturnText" />
</template>
</Input>
<div v-else class="relative w-full">
<Textarea readonly :rows="4" v-bind="textareaProps" />
<div class="absolute bottom-2 right-2">
<FileUpload v-bind="fileUploadProps" @return-text="handleReturnText" />
</div>
</div>
</div>
</template>

View File

@@ -12,28 +12,21 @@ export enum UploadResultStatus {
export type UploadListType = 'picture' | 'picture-card' | 'text';
export interface FileUploadProps {
// 根据后缀,或者其他
accept?: string[];
accept?: string[]; // 根据后缀,或者其他
api?: (
file: File,
onUploadProgress?: AxiosProgressEvent,
) => Promise<AxiosResponse<any>>;
// 上传的目录
directory?: string;
) => Promise<AxiosResponse>;
directory?: string; // 上传的目录
disabled?: boolean;
drag?: boolean; // 是否支持拖拽上传
helpText?: string;
listType?: UploadListType;
// 最大数量的文件Infinity不限制
maxNumber?: number;
maxNumber?: number; // 最大数量的文件Infinity不限制
modelValue?: string | string[]; // v-model 支持
// 文件最大多少MB
maxSize?: number;
// 是否支持多选
multiple?: boolean;
// support xxx.xxx.xx
resultField?: string;
// 是否显示下面的描述
showDescription?: boolean;
maxSize?: number; // 文件最大多少MB
multiple?: boolean; // 是否支持多选
resultField?: string; // support xxx.xxx.xx
showDescription?: boolean; // 是否显示下面的描述
value?: string | string[];
}

View File

@@ -22,6 +22,14 @@ enum UPLOAD_TYPE {
SERVER = 'server',
}
/**
* 上传类型钩子函数
* @param acceptRef 接受的文件类型
* @param helpTextRef 帮助文本
* @param maxNumberRef 最大文件数量
* @param maxSizeRef 最大文件大小
* @returns 文件类型限制和帮助文本的计算属性
*/
export function useUploadType({
acceptRef,
helpTextRef,
@@ -78,7 +86,11 @@ export function useUploadType({
return { getAccept, getStringAccept, getHelpText };
}
// TODO @芋艿:目前保持和 admin-vue3 一致,后续可能重构
/**
* 上传钩子函数
* @param directory 上传目录
* @returns 上传 URL 和自定义上传方法
*/
export function useUpload(directory?: string) {
// 后端上传地址
const uploadUrl = getUploadUrl();

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

@@ -3,6 +3,7 @@ import type { BpmOALeaveApi } from '#/api/bpm/oa/leave';
import type { BpmProcessInstanceApi } from '#/api/bpm/processInstance';
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { confirm, Page, useVbenForm } from '@vben/common-ui';
import { BpmCandidateStrategyEnum, BpmNodeIdEnum } from '@vben/constants';
@@ -13,7 +14,7 @@ import { Button, Card, message, Space } from 'ant-design-vue';
import dayjs from 'dayjs';
import { getProcessDefinition } from '#/api/bpm/definition';
import { createLeave, updateLeave } from '#/api/bpm/oa/leave';
import { createLeave, getLeave, updateLeave } from '#/api/bpm/oa/leave';
import { getApprovalDetail as getApprovalDetailApi } from '#/api/bpm/processInstance';
import { $t } from '#/locales';
import { router } from '#/router';
@@ -22,6 +23,7 @@ import ProcessInstanceTimeline from '#/views/bpm/processInstance/detail/modules/
import { useFormSchema } from './data';
const { closeCurrentTab } = useTabs();
const { query } = useRoute();
const formLoading = ref(false); // 表单的加载中1修改时的数据加载2提交的按钮禁用
@@ -35,7 +37,7 @@ const processDefinitionId = ref('');
const formData = ref<BpmOALeaveApi.Leave>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['请假'])
? '重新发起请假'
: $t('ui.actionTitle.create', ['请假']);
});
@@ -157,6 +159,34 @@ function selectUserConfirm(id: string, userList: any[]) {
startUserSelectAssignees.value[id] = userList?.map((item: any) => item.id);
}
/** 获取请假数据,用于重新发起时自动填充 */
async function getDetail(id: number) {
try {
formLoading.value = true;
const data = await getLeave(id);
if (!data) {
message.error('重新发起请假失败,原因:请假数据不存在');
return;
}
formData.value = {
...formData.value,
id: data.id,
type: data.type,
reason: data.reason,
startTime: data.startTime,
endTime: data.endTime,
} as BpmOALeaveApi.Leave;
await formApi.setValues({
type: data.type,
reason: data.reason,
startTime: data.startTime,
endTime: data.endTime,
});
} finally {
formLoading.value = false;
}
}
/** 审批相关:预测流程节点会因为输入的参数值而产生新的预测结果值,所以需重新预测一次, formData.value可改成实际业务中的特定字段 */
watch(
formData.value as object,
@@ -190,6 +220,11 @@ onMounted(async () => {
processDefinitionId.value = processDefinitionDetail.id;
startUserSelectTasks.value = processDefinitionDetail.startUserSelectTasks;
// 如果是重新发起,则加载请假数据
if (query.id) {
await getDetail(Number(query.id));
}
await getApprovalDetail();
});
</script>

View File

@@ -168,7 +168,7 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
},
{
title: '操作',
width: 220,
width: 240,
fixed: 'right',
slots: { default: 'actions' },
},

View File

@@ -17,6 +17,7 @@ import { router } from '#/router';
import { useGridColumns, useGridFormSchema } from './data';
// TODO @jason这里是不是要迁移下
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
@@ -32,6 +33,16 @@ function handleCreate() {
});
}
/** 重新发起请假 */
function handleReCreate(row: BpmOALeaveApi.Leave) {
router.push({
name: 'OALeaveCreate',
query: {
id: row.id,
},
});
}
/** 取消请假 */
function handleCancel(row: BpmOALeaveApi.Leave) {
prompt({
@@ -160,9 +171,16 @@ const [Grid, gridApi] = useVbenVxeGrid({
type: 'link',
danger: true,
icon: ACTION_ICON.DELETE,
ifShow: row.result === BpmProcessInstanceStatus.RUNNING,
ifShow: row.status === BpmProcessInstanceStatus.RUNNING,
onClick: handleCancel.bind(null, row),
},
{
label: '重新发起',
type: 'link',
icon: ACTION_ICON.ADD,
ifShow: row.status !== BpmProcessInstanceStatus.RUNNING,
onClick: handleReCreate.bind(null, row),
},
]"
/>
</template>

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

@@ -5,7 +5,11 @@ import type { BpmProcessInstanceApi } from '#/api/bpm/processInstance';
import { h } from 'vue';
import { DocAlert, Page, prompt } from '@vben/common-ui';
import { BpmProcessInstanceStatus, DICT_TYPE } from '@vben/constants';
import {
BpmModelFormType,
BpmProcessInstanceStatus,
DICT_TYPE,
} from '@vben/constants';
import { Button, message, Textarea } from 'ant-design-vue';
@@ -37,23 +41,34 @@ function handleDetail(row: BpmProcessInstanceApi.ProcessInstance) {
}
/** 重新发起流程 */
async function handleCreate(row: BpmProcessInstanceApi.ProcessInstance) {
// 如果是【业务表单】,不支持重新发起
async function handleCreate(row?: BpmProcessInstanceApi.ProcessInstance) {
if (row?.id) {
const processDefinitionDetail = await getProcessDefinition(
row.processDefinitionId,
);
if (processDefinitionDetail.formType === 20) {
message.error(
'重新发起流程失败,原因:该流程使用业务表单,不支持重新发起',
);
if (processDefinitionDetail?.formType === BpmModelFormType.CUSTOM) {
if (!processDefinitionDetail.formCustomCreatePath) {
message.error('未配置业务表单的提交路由,无法重新发起');
return;
}
await router.push({
path: processDefinitionDetail.formCustomCreatePath,
query: {
id: row.businessKey,
},
});
return;
} else if (processDefinitionDetail?.formType === BpmModelFormType.NORMAL) {
await router.push({
name: 'BpmProcessInstanceCreate',
query: { processInstanceId: row.id },
});
return;
}
}
// 跳转发起流程界面
await router.push({
name: 'BpmProcessInstanceCreate',
query: { processInstanceId: row?.id },
query: row?.id ? { processInstanceId: row.id } : {},
});
}

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

@@ -1,17 +1,34 @@
import { DICT_TYPE } from '@vben/constants';
import { getDictLabel } from '@vben/hooks';
const getLegend = (extra: Record<string, any> = {}) => ({
top: 10,
...extra,
});
const getGrid = (extra: Record<string, any> = {}) => ({
left: 20,
right: 20,
bottom: 20,
containLabel: true,
...extra,
});
const getTooltip = (extra: Record<string, any> = {}) => ({
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
...extra,
});
export function getChartOptions(activeTabName: any, res: any): any {
switch (activeTabName) {
// 客户转化率分析
case 'conversionStat': {
return {
grid: {
left: 20,
right: 40, // 让 X 轴右侧显示完整
bottom: 20,
containLabel: true,
},
legend: {},
grid: getGrid(),
legend: getLegend(),
series: [
{
name: '客户转化率',
@@ -40,12 +57,7 @@ export function getChartOptions(activeTabName: any, res: any): any {
saveAsImage: { show: true, name: '客户转化率分析' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
tooltip: getTooltip(),
yAxis: {
type: 'value',
name: '转化率(%)',
@@ -59,14 +71,13 @@ export function getChartOptions(activeTabName: any, res: any): any {
}
case 'customerSummary': {
return {
grid: {
bottom: '5%',
containLabel: true,
grid: getGrid({
bottom: '8%',
left: '5%',
right: '5%',
top: '5 %',
},
legend: {},
top: 80,
}),
legend: getLegend(),
series: [
{
name: '新增客户数',
@@ -92,12 +103,7 @@ export function getChartOptions(activeTabName: any, res: any): any {
saveAsImage: { show: true, name: '客户总量分析' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
tooltip: getTooltip(),
yAxis: [
{
type: 'value',
@@ -134,13 +140,8 @@ export function getChartOptions(activeTabName: any, res: any): any {
};
});
return {
grid: {
left: 20,
right: 40, // 让 X 轴右侧显示完整
bottom: 20,
containLabel: true,
},
legend: {},
grid: getGrid(),
legend: getLegend(),
series: [
{
name: '成交周期(天)',
@@ -166,12 +167,7 @@ export function getChartOptions(activeTabName: any, res: any): any {
saveAsImage: { show: true, name: '成交周期分析' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
tooltip: getTooltip(),
yAxis: [
{
type: 'value',
@@ -208,13 +204,8 @@ export function getChartOptions(activeTabName: any, res: any): any {
};
});
return {
grid: {
left: 20,
right: 40, // 让 X 轴右侧显示完整
bottom: 20,
containLabel: true,
},
legend: {},
grid: getGrid(),
legend: getLegend(),
series: [
{
name: '成交周期(天)',
@@ -240,12 +231,7 @@ export function getChartOptions(activeTabName: any, res: any): any {
saveAsImage: { show: true, name: '成交周期分析' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
tooltip: getTooltip(),
yAxis: [
{
type: 'value',
@@ -277,13 +263,8 @@ export function getChartOptions(activeTabName: any, res: any): any {
const customerDealCycleByDate = res.customerDealCycleByDate;
const customerDealCycleByUser = res.customerDealCycleByUser;
return {
grid: {
left: 20,
right: 40, // 让 X 轴右侧显示完整
bottom: 20,
containLabel: true,
},
legend: {},
grid: getGrid(),
legend: getLegend(),
series: [
{
name: '成交周期(天)',
@@ -309,12 +290,7 @@ export function getChartOptions(activeTabName: any, res: any): any {
saveAsImage: { show: true, name: '成交周期分析' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
tooltip: getTooltip(),
yAxis: [
{
type: 'value',
@@ -342,15 +318,13 @@ export function getChartOptions(activeTabName: any, res: any): any {
},
};
}
// 客户跟进次数分析
case 'followUpSummary': {
return {
grid: {
left: 20,
grid: getGrid({
right: 30, // 让 X 轴右侧显示完整
bottom: 20,
containLabel: true,
},
legend: {},
}),
legend: getLegend(),
series: [
{
name: '跟进客户数',
@@ -376,12 +350,7 @@ export function getChartOptions(activeTabName: any, res: any): any {
saveAsImage: { show: true, name: '客户跟进次数分析' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
tooltip: getTooltip(),
yAxis: [
{
type: 'value',
@@ -412,20 +381,21 @@ export function getChartOptions(activeTabName: any, res: any): any {
},
};
}
// 客户跟进方式分析
case 'followUpType': {
return {
title: {
text: '客户跟进方式分析',
left: 'center',
},
legend: {
orient: 'vertical',
legend: getLegend({
left: 'left',
},
tooltip: {
}),
tooltip: getTooltip({
trigger: 'item',
axisPointer: undefined,
formatter: '{b} : {c}% ',
},
}),
toolbox: {
feature: {
saveAsImage: { show: true, name: '客户跟进方式分析' }, // 保存为图片
@@ -458,13 +428,8 @@ export function getChartOptions(activeTabName: any, res: any): any {
}
case 'poolSummary': {
return {
grid: {
left: 20,
right: 40, // 让 X 轴右侧显示完整
bottom: 20,
containLabel: true,
},
legend: {},
grid: getGrid(),
legend: getLegend(),
series: [
{
name: '进入公海客户数',
@@ -490,12 +455,7 @@ export function getChartOptions(activeTabName: any, res: any): any {
saveAsImage: { show: true, name: '公海客户分析' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
tooltip: getTooltip(),
yAxis: [
{
type: 'value',

View File

@@ -1,5 +1,26 @@
import { erpCalculatePercentage } from '@vben/utils';
const getLegend = (extra: Record<string, any> = {}) => ({
top: 10,
...extra,
});
const getGrid = (extra: Record<string, any> = {}) => ({
left: 20,
right: 20,
bottom: 20,
containLabel: true,
...extra,
});
const getTooltip = (extra: Record<string, any> = {}) => ({
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
...extra,
});
export function getChartOptions(
activeTabName: any,
active: boolean,
@@ -9,26 +30,19 @@ export function getChartOptions(
case 'businessInversionRateSummary': {
return {
color: ['#6ca2ff', '#6ac9d7', '#ff7474'],
tooltip: {
trigger: 'axis',
axisPointer: {
// 坐标轴指示器,坐标轴触发有效
type: 'shadow', // 默认为直线,可选为:'line' | 'shadow'
},
},
legend: {
tooltip: getTooltip(),
legend: getLegend({
data: ['赢单转化率', '商机总数', '赢单商机数'],
bottom: '0px',
itemWidth: 14,
},
grid: {
}),
grid: getGrid({
top: '40px',
left: '40px',
right: '40px',
bottom: '40px',
containLabel: true,
borderColor: '#fff',
},
}),
xAxis: [
{
type: 'category',
@@ -117,13 +131,11 @@ export function getChartOptions(
}
case 'businessSummary': {
return {
grid: {
grid: getGrid({
left: 30,
right: 30, // 让 X 轴右侧显示完整
bottom: 20,
containLabel: true,
},
legend: {},
}),
legend: getLegend(),
series: [
{
name: '新增商机数量',
@@ -149,12 +161,7 @@ export function getChartOptions(
saveAsImage: { show: true, name: '新增商机分析' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
tooltip: getTooltip(),
yAxis: [
{
type: 'value',
@@ -211,10 +218,11 @@ export function getChartOptions(
title: {
text: '销售漏斗',
},
tooltip: {
tooltip: getTooltip({
trigger: 'item',
axisPointer: undefined,
formatter: '{a} <br/>{b}',
},
}),
toolbox: {
feature: {
dataView: { readOnly: false },
@@ -222,9 +230,9 @@ export function getChartOptions(
saveAsImage: {},
},
},
legend: {
legend: getLegend({
data: ['客户', '商机', '赢单'],
},
}),
series: [
{
name: '销售漏斗',

View File

@@ -1,14 +1,30 @@
const getLegend = (extra: Record<string, any> = {}) => ({
top: 10,
...extra,
});
const getGrid = (extra: Record<string, any> = {}) => ({
left: 20,
right: 20,
bottom: 20,
containLabel: true,
...extra,
});
const getTooltip = (extra: Record<string, any> = {}) => ({
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
...extra,
});
export function getChartOptions(activeTabName: any, res: any): any {
switch (activeTabName) {
case 'ContractCountPerformance': {
return {
grid: {
left: 20,
right: 20,
bottom: 20,
containLabel: true,
},
legend: {},
grid: getGrid(),
legend: getLegend(),
series: [
{
name: '当月合同数量(个)',
@@ -65,12 +81,7 @@ export function getChartOptions(activeTabName: any, res: any): any {
saveAsImage: { show: true, name: '客户总量分析' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
tooltip: getTooltip(),
yAxis: [
{
type: 'value',
@@ -131,13 +142,8 @@ export function getChartOptions(activeTabName: any, res: any): any {
}
case 'ContractPricePerformance': {
return {
grid: {
left: 20,
right: 20,
bottom: 20,
containLabel: true,
},
legend: {},
grid: getGrid(),
legend: getLegend(),
series: [
{
name: '当月合同金额(元)',
@@ -260,13 +266,8 @@ export function getChartOptions(activeTabName: any, res: any): any {
}
case 'ReceivablePricePerformance': {
return {
grid: {
left: 20,
right: 20,
bottom: 20,
containLabel: true,
},
legend: {},
grid: getGrid(),
legend: getLegend(),
series: [
{
name: '当月回款金额(元)',

View File

@@ -13,6 +13,71 @@ function areaReplace(areaName: string) {
.replace('省', '');
}
const getPieTooltip = (extra: Record<string, any> = {}) => ({
trigger: 'item',
...extra,
});
const getPieLegend = (extra: Record<string, any> = {}) => ({
orient: 'vertical',
left: 'left',
...extra,
});
const getPieSeries = (name: string, data: any[]) => ({
name,
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2,
},
label: {
show: false,
position: 'center',
},
emphasis: {
label: {
show: true,
fontSize: 40,
fontWeight: 'bold',
},
},
labelLine: {
show: false,
},
data,
});
const getPiePanel = ({
data,
legendExtra,
seriesName,
title,
tooltipExtra,
}: {
data: any[];
legendExtra?: Record<string, any>;
seriesName: string;
title: string;
tooltipExtra?: Record<string, any>;
}) => ({
title: {
text: title,
left: 'center',
},
tooltip: getPieTooltip(tooltipExtra),
legend: getPieLegend(legendExtra),
toolbox: {
feature: {
saveAsImage: { show: true, name: title },
},
},
series: [getPieSeries(seriesName, data)],
});
export function getChartOptions(activeTabName: any, res: any): any {
switch (activeTabName) {
case 'area': {
@@ -111,326 +176,62 @@ export function getChartOptions(activeTabName: any, res: any): any {
}
case 'industry': {
return {
left: {
title: {
text: '全部客户',
left: 'center',
},
tooltip: {
trigger: 'item',
},
legend: {
orient: 'vertical',
left: 'left',
},
toolbox: {
feature: {
saveAsImage: { show: true, name: '全部客户' }, // 保存为图片
},
},
series: [
{
name: '全部客户',
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2,
},
label: {
show: false,
position: 'center',
},
emphasis: {
label: {
show: true,
fontSize: 40,
fontWeight: 'bold',
},
},
labelLine: {
show: false,
},
data: res.map((r: any) => {
return {
name: getDictLabel(
DICT_TYPE.CRM_CUSTOMER_INDUSTRY,
r.industryId,
),
value: r.customerCount,
};
}),
},
],
},
right: {
title: {
text: '成交客户',
left: 'center',
},
tooltip: {
trigger: 'item',
},
legend: {
orient: 'vertical',
left: 'left',
},
toolbox: {
feature: {
saveAsImage: { show: true, name: '成交客户' }, // 保存为图片
},
},
series: [
{
name: '成交客户',
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2,
},
label: {
show: false,
position: 'center',
},
emphasis: {
label: {
show: true,
fontSize: 40,
fontWeight: 'bold',
},
},
labelLine: {
show: false,
},
data: res.map((r: any) => {
return {
name: getDictLabel(
DICT_TYPE.CRM_CUSTOMER_INDUSTRY,
r.industryId,
),
value: r.dealCount,
};
}),
},
],
},
left: getPiePanel({
title: '全部客户',
seriesName: '全部客户',
data: res.map((r: any) => ({
name: getDictLabel(DICT_TYPE.CRM_CUSTOMER_INDUSTRY, r.industryId),
value: r.customerCount,
})),
}),
right: getPiePanel({
title: '成交客户',
seriesName: '成交客户',
data: res.map((r: any) => ({
name: getDictLabel(DICT_TYPE.CRM_CUSTOMER_INDUSTRY, r.industryId),
value: r.dealCount,
})),
}),
};
}
case 'level': {
return {
left: {
title: {
text: '全部客户',
left: 'center',
},
tooltip: {
trigger: 'item',
},
legend: {
orient: 'vertical',
left: 'left',
},
toolbox: {
feature: {
saveAsImage: { show: true, name: '全部客户' }, // 保存为图片
},
},
series: [
{
name: '全部客户',
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2,
},
label: {
show: false,
position: 'center',
},
emphasis: {
label: {
show: true,
fontSize: 40,
fontWeight: 'bold',
},
},
labelLine: {
show: false,
},
data: res.map((r: any) => {
return {
name: getDictLabel(DICT_TYPE.CRM_CUSTOMER_LEVEL, r.level),
value: r.customerCount,
};
}),
},
],
},
right: {
title: {
text: '成交客户',
left: 'center',
},
tooltip: {
trigger: 'item',
},
legend: {
orient: 'vertical',
left: 'left',
},
toolbox: {
feature: {
saveAsImage: { show: true, name: '成交客户' }, // 保存为图片
},
},
series: [
{
name: '成交客户',
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2,
},
label: {
show: false,
position: 'center',
},
emphasis: {
label: {
show: true,
fontSize: 40,
fontWeight: 'bold',
},
},
labelLine: {
show: false,
},
data: res.map((r: any) => {
return {
name: getDictLabel(DICT_TYPE.CRM_CUSTOMER_LEVEL, r.level),
value: r.dealCount,
};
}),
},
],
},
left: getPiePanel({
title: '全部客户',
seriesName: '全部客户',
data: res.map((r: any) => ({
name: getDictLabel(DICT_TYPE.CRM_CUSTOMER_LEVEL, r.level),
value: r.customerCount,
})),
}),
right: getPiePanel({
title: '成交客户',
seriesName: '成交客户',
data: res.map((r: any) => ({
name: getDictLabel(DICT_TYPE.CRM_CUSTOMER_LEVEL, r.level),
value: r.dealCount,
})),
}),
};
}
case 'source': {
return {
left: {
title: {
text: '全部客户',
left: 'center',
},
tooltip: {
trigger: 'item',
},
legend: {
orient: 'vertical',
left: 'left',
},
toolbox: {
feature: {
saveAsImage: { show: true, name: '全部客户' }, // 保存为图片
},
},
series: [
{
name: '全部客户',
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2,
},
label: {
show: false,
position: 'center',
},
emphasis: {
label: {
show: true,
fontSize: 40,
fontWeight: 'bold',
},
},
labelLine: {
show: false,
},
data: res.map((r: any) => {
return {
name: getDictLabel(DICT_TYPE.CRM_CUSTOMER_SOURCE, r.source),
value: r.customerCount,
};
}),
},
],
},
right: {
title: {
text: '成交客户',
left: 'center',
},
tooltip: {
trigger: 'item',
},
legend: {
orient: 'vertical',
left: 'left',
},
toolbox: {
feature: {
saveAsImage: { show: true, name: '成交客户' }, // 保存为图片
},
},
series: [
{
name: '成交客户',
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2,
},
label: {
show: false,
position: 'center',
},
emphasis: {
label: {
show: true,
fontSize: 40,
fontWeight: 'bold',
},
},
labelLine: {
show: false,
},
data: res.map((r: any) => {
return {
name: getDictLabel(DICT_TYPE.CRM_CUSTOMER_SOURCE, r.source),
value: r.dealCount,
};
}),
},
],
},
left: getPiePanel({
title: '全部客户',
seriesName: '全部客户',
data: res.map((r: any) => ({
name: getDictLabel(DICT_TYPE.CRM_CUSTOMER_SOURCE, r.source),
value: r.customerCount,
})),
}),
right: getPiePanel({
title: '成交客户',
seriesName: '成交客户',
data: res.map((r: any) => ({
name: getDictLabel(DICT_TYPE.CRM_CUSTOMER_SOURCE, r.source),
value: r.dealCount,
})),
}),
};
}
default: {

View File

@@ -1,5 +1,25 @@
import { cloneDeep } from '@vben/utils';
const getLegend = (extra: Record<string, any> = {}) => ({
top: 10,
...extra,
});
const getGrid = (extra: Record<string, any> = {}) => ({
left: 20,
right: 20,
bottom: 20,
containLabel: true,
...extra,
});
const getTooltip = () => ({
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
});
export function getChartOptions(activeTabName: any, res: any): any {
switch (activeTabName) {
case 'contactCountRank': {
@@ -8,15 +28,8 @@ export function getChartOptions(activeTabName: any, res: any): any {
dimensions: ['nickname', 'count'],
source: cloneDeep(res).toReversed(),
},
grid: {
left: 20,
right: 20,
bottom: 20,
containLabel: true,
},
legend: {
top: 50,
},
grid: getGrid(),
legend: getLegend(),
series: [
{
name: '新增联系人数排行',
@@ -34,12 +47,7 @@ export function getChartOptions(activeTabName: any, res: any): any {
saveAsImage: { show: true, name: '新增联系人数排行' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
tooltip: getTooltip(),
xAxis: {
type: 'value',
name: '新增联系人数(个)',
@@ -56,15 +64,8 @@ export function getChartOptions(activeTabName: any, res: any): any {
dimensions: ['nickname', 'count'],
source: cloneDeep(res).toReversed(),
},
grid: {
left: 20,
right: 20,
bottom: 20,
containLabel: true,
},
legend: {
top: 50,
},
grid: getGrid(),
legend: getLegend(),
series: [
{
name: '签约合同排行',
@@ -82,12 +83,7 @@ export function getChartOptions(activeTabName: any, res: any): any {
saveAsImage: { show: true, name: '签约合同排行' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
tooltip: getTooltip(),
xAxis: {
type: 'value',
name: '签约合同数(个)',
@@ -104,15 +100,8 @@ export function getChartOptions(activeTabName: any, res: any): any {
dimensions: ['nickname', 'count'],
source: cloneDeep(res).toReversed(),
},
grid: {
left: 20,
right: 20,
bottom: 20,
containLabel: true,
},
legend: {
top: 50,
},
grid: getGrid(),
legend: getLegend(),
series: [
{
name: '合同金额排行',
@@ -130,12 +119,7 @@ export function getChartOptions(activeTabName: any, res: any): any {
saveAsImage: { show: true, name: '合同金额排行' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
tooltip: getTooltip(),
xAxis: {
type: 'value',
name: '合同金额(元)',
@@ -152,15 +136,8 @@ export function getChartOptions(activeTabName: any, res: any): any {
dimensions: ['nickname', 'count'],
source: cloneDeep(res).toReversed(),
},
grid: {
left: 20,
right: 20,
bottom: 20,
containLabel: true,
},
legend: {
top: 50,
},
grid: getGrid(),
legend: getLegend(),
series: [
{
name: '新增客户数排行',
@@ -178,12 +155,7 @@ export function getChartOptions(activeTabName: any, res: any): any {
saveAsImage: { show: true, name: '新增客户数排行' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
tooltip: getTooltip(),
xAxis: {
type: 'value',
name: '新增客户数(个)',
@@ -226,12 +198,7 @@ export function getChartOptions(activeTabName: any, res: any): any {
saveAsImage: { show: true, name: '跟进次数排行' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
tooltip: getTooltip(),
xAxis: {
type: 'value',
name: '跟进次数(次)',
@@ -274,12 +241,7 @@ export function getChartOptions(activeTabName: any, res: any): any {
saveAsImage: { show: true, name: '跟进客户数排行' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
tooltip: getTooltip(),
xAxis: {
type: 'value',
name: '跟进客户数(个)',
@@ -322,12 +284,7 @@ export function getChartOptions(activeTabName: any, res: any): any {
saveAsImage: { show: true, name: '产品销量排行' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
tooltip: getTooltip(),
xAxis: {
type: 'value',
name: '产品销量',
@@ -370,12 +327,7 @@ export function getChartOptions(activeTabName: any, res: any): any {
saveAsImage: { show: true, name: '回款金额排行' }, // 保存为图片
},
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
tooltip: getTooltip(),
xAxis: {
type: 'value',
name: '回款金额(元)',

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>

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