网址:https://note.lazzman.com:18084/doc/1065/

yudao-cloud (opens new window)
RuoYi-Vue 全新 Cloud 版本,优化重构所有功能

基于 Spring Cloud Alibaba + MyBatis Plus + Vue & Element 实现的后台管理系统 + UniApp 微信小程序,支持 RBAC 动态权限、数据权限、SaaS 多租户、Activiti + Flowable 工作流、三方登录、支付、短信、商城等功能。

内置功能

  • 系统功能
  • 基础设施
  • 工作流程
  • 支付系统
  • 会员中心
  • 数据报表
  • 商城系统
  • 公众号系统
  • ERP 系统
  • CRM 系统

系统功能

功能	描述

用户管理 用户是系统操作者,该功能主要完成系统用户配置
⭐️ 在线用户 当前系统中活跃用户状态监控,支持手动踢下线
角色管理 角色菜单权限分配、设置角色按机构进行数据范围权限划分
菜单管理 配置系统菜单、操作权限、按钮权限标识等,本地缓存提供性能
部门管理 配置系统组织机构(公司、部门、小组),树结构展现支持数据权限
岗位管理 配置系统用户所属担任职务
🚀 租户管理 配置系统租户,支持 SaaS 场景下的多租户功能
🚀 租户套餐 配置租户套餐,自定每个租户的菜单、操作、按钮的权限
字典管理 对系统中经常使用的一些较为固定的数据进行维护
🚀 短信管理 短信渠道、短息模板、短信日志,对接阿里云、腾讯云等主流短信平台
🚀 邮件管理 邮箱账号、邮件模版、邮件发送日志,支持所有邮件平台
🚀 操作日志 系统正常操作日志记录和查询,集成 Swagger 生成日志内容
⭐️ 登录日志 系统登录日志记录查询,包含登录异常
🚀 错误码管理 系统所有错误码的管理,可在线修改错误提示,无需重启服务
通知公告 系统通知公告信息发布维护
🚀 敏感词 配置系统敏感词,支持标签分组
🚀 应用管理 管理 SSO 单点登录的应用,支持多种 OAuth2 授权方式
🚀 地区管理 展示省份、城市、区镇等城市信息,支持 IP 对应城市

基础设施

功能 描述
🚀 代码生成 前后端代码的生成(Java、Vue、SQL、单元测试),支持 CRUD 下载
🚀 系统接口 基于 Swagger 自动生成相关的 RESTful API 接口文档
🚀 数据库文档 基于 Screw 自动生成数据库文档,支持导出 Word、HTML、MD 格式
表单构建 拖动表单元素生成相应的 HTML 代码,支持导出 JSON、Vue 文件
🚀 配置管理 对系统动态配置常用参数,支持 SpringBoot 加载
🚀 文件服务 支持将文件存储到 S3(MinIO、阿里云、腾讯云、七牛云)、本地、FTP、数据库等
🚀 WebSocket 提供 WebSocket 接入示例,支持一对一、一对多发送方式
🚀 API 日志 包括 RESTful API 访问日志、异常日志两部分,方便排查 API 相关的问题
MySQL 监控 监视当前系统数据库连接池状态,可进行分析SQL找出系统性能瓶颈
Redis 监控 监控 Redis 数据库的使用情况,使用的 Redis Key 管理
🚀 消息队列 基于 Redis 实现消息队列,Stream 提供集群消费,Pub/Sub 提供广播消费
🚀 Java 监控 基于 Spring Boot Admin 实现 Java 应用的监控
🚀 链路追踪 接入 SkyWalking 组件,实现链路追踪
🚀 日志中心 接入 SkyWalking 组件,实现日志中心
🚀 服务保障 基于 Redis 实现分布式锁、幂等、限流功能,满足高并发场景
🚀 日志服务 轻量级日志中心,查看远程服务器的日志
🚀 单元测试 基于 JUnit + Mockito 实现单元测试,保证功能的正确性、代码的质量等

工作流程

功能	描述

🚀 流程模型 配置工作流的流程模型,支持文件导入与在线设计流程图,提供 7 种任务分配规则
🚀 流程表单 拖动表单元素生成相应的工作流表单,覆盖 Element UI 所有的表单组件
🚀 用户分组 自定义用户分组,可用于工作流的审批分组
🚀 我的流程 查看我发起的工作流程,支持新建、取消流程等操作,高亮流程图、审批时间线
🚀 待办任务 查看自己【未】审批的工作任务,支持通过、不通过、转发、委派、退回等操作
🚀 已办任务 查看自己【已】审批的工作任务,未来会支持回退操作
🚀 OA 请假 作为业务自定义接入工作流的使用示例,只需创建请求对应的工作流程,即可进行审批

技术选型

系统环境

Maven Java 管理与构建工具 >= 3.5.4
Nginx 高性能 Web 服务器

主框架

Spring Cloud Alibaba(opens new window) 微服务框架 2022.0.0.0 文档(opens new window)
Spring MVC(opens new window) MVC 框架 6.1.1 文档(opens new window)
Spring Security(opens new window) Spring 安全框架 6.2.0 文档(opens new window)
Hibernate Validator(opens new window) 参数校验组件 8.0.1 文档(opens new window)

存储层

MySQL >= 5.7
Druid JDBC 连接池、监控组件 1.2.20
MyBatis Plus MyBatis 增强工具包 3.5.4.1
Dynamic Datasource 动态数据源 4.2.0
Redis key-value 数据库 >= 5.0
Redisson Redis 客户端 3.25.0

中间件

Nacos 配置中心 & 注册中心 2.2.1
RocketMQ 消息队列 4.9.4
Sentinel 服务保障 1.8.6
XXL Job 定时任务 2.4.0
Spring Cloud Gateway 服务网关 4.1.0
Seata 分布式事务 1.6.1
Flowable 工作流引擎 7.0.0

系统监控

Spring Boot Admin Spring Boot 监控平台 3.6.1
SkyWalking 分布式应用追踪系统 9.0.0

单元测试

JUnit Java 单元测试框架 5.10.1 -
Mockito Java Mock 框架 5.7.0 -

其它工具

Springdoc Swagger 文档 2.2.0 文档(opens new window)
Jackson JSON 工具库 2.15.3
MapStruct Java Bean 转换 1.5.5.Final 文档(opens new window)
Lombok 消除冗长的 Java 代码 1.18.30 文档(opens new window)

前端

#管理后台
框架 说明 版本
Vue vue 框架 3.2.45
Vite 开发与构建工具 4.0.1
Element Plus Element Plus 2.2.26
TypeScript JavaScript 的超集 4.9.4
pinia Vue 存储库 替代 vuex5 2.0.28
vueuse 常用工具集 9.6.0
vxe-table vue 最强表单 4.3.7
vue-i18n 国际化 9.2.2
vue-router vue 路由 4.1.6
windicss 下一代工具优先的 CSS 框架 3.5.6
iconify 在线图标库 3.0.0
wangeditor 富文本编辑器 5.1.23

管理后台(Vue3 + Vben + Ant-Design-Vue)

Vue(opens new window) Vue 框架 3.2.47
Vite(opens new window) 开发与构建工具 4.3.0
ant-design-vue(opens new window) ant-design-vue 3.2.17
TypeScript(opens new window) JavaScript 的超集 5.0.4
pinia(opens new window) Vue 存储库 替代 vuex5 2.0.34
vueuse(opens new window) 常用工具集 9.13.0
vue-i18n(opens new window) 国际化 9.2.2
vue-router(opens new window) Vue 路由 4.1.6
windicss(opens new window) 下一代工具优先的 CSS 框架 3.5.6
iconify(opens new window) 在线图标库 3.1.0

管理后台(uni-app)

uni-app 跨平台框架 2.0.0
uni-ui(opens new window) 基于 uni-app 的 UI 框架 1.4.20

用户 App

Vue(opens new window) JavaScript 框架 2.6.12 书单(opens new window)
UniApp(opens new window) 小程序、H5、App 的统一框架 -

项目结构

后端结构

一共有四类 Maven Module:

  1. yudao-dependencies Maven 依赖版本管理
    该模块是一个 Maven Bom,只有一个 pom.xml (opens new window) 文件,定义项目中所有 Maven 依赖的版本号,解决依赖冲突问题。

  2. yudao-framework Java 框架拓展
    该模块是 yudao-cloud 项目的框架封装,其下的每个 Maven Module 都是一个组件,分成两种类型:

  • 技术组件:技术相关的组件封装,例如说 MyBatis、Redis 等等。
    Maven Module 作用
    yudao-common 定义基础 pojo 类、枚举、工具类等
    yudao-spring-boot-starter-web Web 封装,提供全局异常、访问日志等
    yudao-spring-boot-starter-websocket WebSocket 封装,提供 Token 认证、WebSocket 集群广播、Message 监听
    yudao-spring-boot-starter-security 认证授权,基于 Spring Security 实现
    yudao-spring-boot-starter-mybatis 数据库操作,基于 MyBatis Plus 实现
    yudao-spring-boot-starter-redis 缓存操作,基于 Spring Data Redis + Redisson 实现
    yudao-spring-boot-starter-mq 消息队列,基于 Redis 实现,支持集群消费和广播消费
    yudao-spring-boot-starter-job 定时任务,基于 Quartz 实现,支持集群模式
    yudao-spring-boot-starter-flowable 工作流,基于 Flowable 实现
    yudao-spring-boot-starter-protection 服务保障,提供幂等、分布式锁、限流、熔断等功能
    yudao-spring-boot-starter-excel Excel 导入导出,基于 EasyExcel 实现
    yudao-spring-boot-starter-monitor 服务监控,提供链路追踪、日志服务、指标收集等功能
    yudao-spring-boot-starter-test 单元测试,基于 Junit + Mockito 实现
    yudao-spring-boot-starter-file 【已合并】 文件客户端,支持将文件存储到 S3(MinIO、阿里云、腾讯云、七牛云)、本地、FTP、SFTP、数据库等
    yudao-spring-boot-starter-captcha 【已合并】 验证码 Captcha,提供滑块验证码

友情提示:
yudao-spring-boot-starter-file 组件:自 2.0.1 版本,合并到 yudao-module-infra-biz 模块的 framework/file 包下,一方面减少 starter 提升编译速度,一方面只有 infra 模块使用到
yudao-spring-boot-starter-captcha 组件:自 2.0.1 版本,合并到 yudao-module-system-biz 模块的 framework/captcha 包下,一方面减少 starter 提升编译速度,一方面只有 system 模块使用到

  • 业务组件:业务相关的组件封装,例如说数据字典、操作日志等等。如果是业务组件,名字会包含 biz 关键字。
    Maven Module 作用
    yudao-spring-boot-starter-biz-tenant SaaS 多租户
    yudao-spring-boot-starter-biz-data-permission 数据权限
    yudao-spring-boot-starter-biz-operatelog 操作日志
    yudao-spring-boot-starter-biz-pay 支付客户端,对接微信支付、支付宝等支付平台
    yudao-spring-boot-starter-biz-ip 地区 & IP 库

友情提示:
yudao-spring-boot-starter-biz-operatelog 组件:自 2.1.0 版本,合并到 yudao-spring-boot-starter-security 组件的 operatelog 包下,主要减少 starter 提升编译速度

每个组件,包含两部分:
core 包:组件的核心封装,拓展相关的功能。
config 包:组件的 Spring Boot 自动配置。

  1. yudao-module-xxx XXX 功能的 Module 模块
    该模块是 XXX 功能的 Module 模块,目前内置了 8 个模块。
    项目 说明 是否必须
    yudao-module-system 系统功能 √
    yudao-module-infra 基础设施 √
    yudao-module-member 会员中心 x
    yudao-module-bpm 工作流程 x
    yudao-module-pay 支付系统 x
    yudao-module-report 大屏报表 x
    yudao-module-mall 商城系统 x
    yudao-module-erp ERP 系统 x
    yudao-module-crm CRM 系统 x
    yudao-module-mp 微信公众号 x

每个模块包含两个 Maven Module,分别是:
yudao-module-xxx-api 提供给其它模块的 API 定义
yudao-module-xxx-biz 模块的功能的具体实现

  • 疑问:为什么 Controller 分成 Admin 和 App 两种?
    提供给 Admin 和 App 的 RESTful API 接口是不同的,拆分后更加清晰。

  • 疑问:为什么 VO 分成 Admin 和 App 两种?
    相同功能的 RESTful API 接口,对于 Admin 和 App 传入的参数、返回的结果都可能是不同的。
    例如说,Admin 查询某个用户的基本信息时,可以返回全部字段;而 App 查询时,不会返回 mobile 手机等敏感字段。

  • 疑问:为什么 DO 不作为 Controller 的出入参?
    明确每个 RESTful API 接口的出入参。例如说,创建部门时,只需要传入 name、parentId 字段,使用 DO 接参就会导致 type、createTime、creator 等字段可以被传入,导致前端同学一脸懵逼。
    每个 RESTful API 有自己独立的 VO,可以更好的设置 Swagger 注解、Validator 校验规则,而让 DO 保持整洁,专注映射好数据库表。

  • 疑问:为什么操作 Redis 需要通过 RedisDAO?
    Service 直接使用 RedisTemplate 操作 Redis,导致大量 Redis 的操作细节和业务逻辑杂糅在一起,导致代码不够整洁。
    通过 RedisDAO 类,将每个 Redis Key 像一个数据表一样对待,清晰易维护。

总结来说,每个模块采用三层架构 + 非严格分层

  • 疑问:如果 message 需要跨模块共享,类似 api 的效果,可以怎么做?
    可以在 yudao-module-xxx-api 子模块下,新建一个 message 包,可参考 MemberUserCreateMessage 类。
  1. yudao-server 管理后台 + 用户 App 的服务端
    该模块是后端 Server 的主项目,通过引入需要 yudao-module-xxx 业务模块,从而实现提供 RESTful API 给 yudao-ui-admin-vue3、yudao-mall-uniapp 等前端项目。
    本质上来说,它就是个空壳(容器)!

前端结构

前端一共有六个项目:

  1. yudao-ui-admin-vue3
    .
    ├── .github # github workflows 相关
    ├── .husky # husky 配置
    ├── .vscode # vscode 配置
    ├── mock # 自定义 mock 数据及配置
    ├── public # 静态资源
    ├── src # 项目代码
    │ ├── api # api接口管理
    │ ├── assets # 静态资源
    │ ├── components # 公用组件
    │ ├── hooks # 常用hooks
    │ ├── layout # 布局组件
    │ ├── locales # 语言文件
    │ ├── plugins # 外部插件
    │ ├── router # 路由配置
    │ ├── store # 状态管理
    │ ├── styles # 全局样式
    │ ├── utils # 全局工具类
    │ ├── views # 路由页面
    │ ├── App.vue # 入口vue文件
    │ ├── main.ts # 主入口文件
    │ └── permission.ts # 路由拦截
    ├── types # 全局类型
    ├── .env.base # 本地开发环境 环境变量配置
    ├── .env.dev # 打包到开发环境 环境变量配置
    ├── .env.gitee # 针对 gitee 的环境变量 可忽略
    ├── .env.pro # 打包到生产环境 环境变量配置
    ├── .env.test # 打包到测试环境 环境变量配置
    ├── .eslintignore # eslint 跳过检测配置
    ├── .eslintrc.js # eslint 配置
    ├── .gitignore # git 跳过配置
    ├── .prettierignore # prettier 跳过检测配置
    ├── .stylelintignore # stylelint 跳过检测配置
    ├── .versionrc 自动生成版本号及更新记录配置
    ├── CHANGELOG.md # 更新记录
    ├── commitlint.config.js # git commit 提交规范配置
    ├── index.html # 入口页面
    ├── package.json
    ├── .postcssrc.js # postcss 配置
    ├── prettier.config.js # prettier 配置
    ├── README.md # 英文 README
    ├── README.zh-CN.md # 中文 README
    ├── stylelint.config.js # stylelint 配置
    ├── tsconfig.json # typescript 配置
    ├── vite.config.ts # vite 配置
    └── windi.config.ts # windicss 配置

  2. yudao-ui-admin-vben
    .
    ├── build # 打包脚本相关
    │ ├── config # 配置文件
    │ ├── generate # 生成器
    │ ├── script # 脚本
    │ └── vite # vite配置
    ├── mock # mock文件夹
    ├── public # 公共静态资源目录
    ├── src # 主目录
    │ ├── api # 接口文件
    │ ├── assets # 资源文件
    │ │ ├── icons # icon sprite 图标文件夹
    │ │ ├── images # 项目存放图片的文件夹
    │ │ └── svg # 项目存放svg图片的文件夹
    │ ├── components # 公共组件
    │ ├── design # 样式文件
    │ ├── directives # 指令
    │ ├── enums # 枚举/常量
    │ ├── hooks # hook
    │ │ ├── component # 组件相关hook
    │ │ ├── core # 基础hook
    │ │ ├── event # 事件相关hook
    │ │ ├── setting # 配置相关hook
    │ │ └── web # web相关hook
    │ ├── layouts # 布局文件
    │ │ ├── default # 默认布局
    │ │ ├── iframe # iframe布局
    │ │ └── page # 页面布局
    │ ├── locales # 多语言
    │ ├── logics # 逻辑
    │ ├── main.ts # 主入口
    │ ├── router # 路由配置
    │ ├── settings # 项目配置
    │ │ ├── componentSetting.ts # 组件配置
    │ │ ├── designSetting.ts # 样式配置
    │ │ ├── encryptionSetting.ts # 加密配置
    │ │ ├── localeSetting.ts # 多语言配置
    │ │ ├── projectSetting.ts # 项目配置
    │ │ └── siteSetting.ts # 站点配置
    │ ├── store # 数据仓库
    │ ├── utils # 工具类
    │ └── views # 页面
    ├── test # 测试
    │ └── server # 测试用到的服务
    │ ├── api # 测试服务器

│ ├── upload # 测试上传服务器
│ └── websocket # 测试ws服务器
├── types # 类型文件
├── vite.config.ts # vite配置文件
└── windi.config.ts # windcss配置文件

  1. yudao-admin-ui
    ├── bin // 执行脚本
    ├── build // 构建相关
    ├── public // 公共文件
    │ ├── favicon.ico // favicon 图标
    │ └── index.html // html 模板
    │ └── robots.txt // 反爬虫
    ├── src // 源代码
    │ ├── api // 所有请求【重要】
    │ ├── assets // 主题、字体等静态资源
    │ ├── components // 全局公用组件
    │ ├── directive // 全局指令
    │ ├── icons // 图标
    │ ├── layout // 布局
    │ ├── plugins // 插件
    │ ├── router // 路由
    │ ├── store // 全局 store 管理
    │ ├── utils // 全局公用方法
    │ ├── views // 视图【重要】
    │ ├── App.vue // 入口页面
    │ ├── main.js // 入口 JS,加载组件、初始化等
    │ ├── permission.js // 权限管理
    │ └── settings.js // 系统配置
    ├── .editorconfig // 编码格式
    ├── .env.development // 开发环境配置
    ├── .env.production // 生产环境配置
    ├── .env.staging // 测试环境配置
    ├── .eslintignore // 忽略语法检查
    ├── .eslintrc.js // eslint 配置项
    ├── .gitignore // git 忽略项
    ├── babel.config.js // babel.config.js
    ├── package.json // package.json
    └── vue.config.js // vue.config.js

  2. yudao-admin-ui-uniapp

  3. yudao-mall-uniapp
    ├── pages // 页面
    │ ├── index // 入口页面

│ ├── user // 用户相关
│ ├── public // 公共页面
│ ├── activity // 活动页面
│ ├── app // 积分、签到页面
│ ├── chat // 客服页面
│ ├── commission // 分销页面
│ ├── coupon // 优惠券页面
│ ├── goods // 商品页面
│ ├── order // 订单页面
│ ├── pay // 支付页面
├── sheep // 底层依赖/工具库
│ ├── api // 服务端接口
│ ├── components // 自定义功能组件
│ ├── config // 配置文件
│ ├── helper // 助手函数
│ ├── hooks // vue-hooks
│ ├── libs // 自定义依赖
│ ├── platform // 第三方平台登录、分享、支付
│ ├── request // 请求类库
│ ├── router // 自定义路由跳转
│ ├── scss // 主样式库
│ ├── store // pinia状态管理模块
│ ├── ui // 自定义UI组件
│ ├── url // cdn图片地址格式化
│ ├── validate // 通用验证器
│ ├── index.js // Shopro入口文件
├── uni_modules // dcloud第三方插件

  1. yudao-ui-go-view

代码热加载

实现方案有三种:

  1. spring-boot-devtools【不推荐】

  2. IDEA 自带 HowSwap 功能【推荐】
    IDEA Ultimate 旗舰版的专属功能,不支持 IDEA Community 社区版

  3. JRebel 插件【最推荐】
    https://plugins.jetbrains.com/plugin/4441-jrebel-and-xrebel/versions 地址,必须下载 2022.4.1 版本。

微服务手册

微服务调试(注册中心 Nacos)

在微服务架构下,多服务的调试是个非常大的痛点:在大家使用 同一个 注册中心时,如果多个人在本地启动 相同 服务,可能需要调试的一个请求会打到其他开发的本地服务,实际是期望达到自己本地的服务。

一般的解决方案是,使用 不同 注册中心,避免出现这个情况。但是,服务一多之后,就会产生新的痛点,同时本地启动所有服务很占用电脑内存。

通过给 yudao-spring-boot-starter-env 组件服务打标 Tag,实现使用同一个注册中心的情况下,本地只需要启动需要调试的服务,并且保证自己的请求,必须达到自己本地的服务。

测试环境:启动了 gateway、system、infra 服务;
本地环境:只启动了 system 服务
请求时,带上 tag = yunai,优先请求本地环境 + tag = yunai 的服务:

  1. 由于本地未启动 gateway 服务,所以请求打到测试环境的 gateway 服务
  2. 由于请求 tag = yunai,所以转发到本地环境的 system 服务
  3. 由于本地未启动 infra 服务,所以转发回测试环境的 infra 服务

测试:
第一步,启动 gateway 服务
直接运行 GatewayServerApplication 类,启动gateway服务。
可以在 Nacos 看到该实例,无 tag 属性。
第二步,启动 system 服务【有tag】
运行SystemServerApplication类,启动 system 服务。
可以在 Nacos 看到该实例,有 tag 属性。
因为默认在 application-local.yaml,添加了 yudao.env.tag 配置项为 ${HOSTNAME}。
第三步,启动 system 服务【无tag】
修改 system 服务的端口为 28081,yudao.env.tag 配置项为空。’
再一个 SystemServerApplication,额外启动 system 服务。
可以在 Nacos 看到该实例,它是没 tag 属性。
第四步,请求测试
打开 AuthController.http 文件,设置第一个请求的 tag 为 Yunai.local(要替换成你的 hostname)
点击前面的绿色小箭头,发起请求。从 IDEA 控制台的日志可以看到,只有有 tag 的 system 服务才会被调用。

实现原理

  1. 在服务注册时,会将 yudao.env.tag 配置项,写到 Nacos 服务实例的元数据,通过 EnvEnvironmentPostProcessor 类实现。
  2. 在服务调用时,通过 EnvLoadBalancerClient 类,筛选服务实例,通过服务实例的 tag 元数据,匹配请求的 tag 请求头。
  3. 在网关转发时,通过 GrayLoadBalancer 类,效果和 EnvLoadBalancerClient 一致。

配置中心 Nacos

使用 Nacos 作为配置中心,实现配置的动态管理。

搭建 Nacos Server
点击 Nacos 控制台的 [命名空间] 菜单,创建一个 ID 和名字都为 dev 的命名空间,稍后会使用到。

项目接入 Nacos

工作流手册

流程表单

业务表单

  1. 建表
    根据业务需要,业务通过建立独立的数据库表(业务表)记录申请信息,而流程引擎只负责推动流程的前进或者结束。

两者需要进行双向的关联:
每一条业务表记录,通过它的流程实例的编号( process_instance_id )指向对应的流程实例
每一个流程实例,通过它的业务键( BUSINESS_KEY_ ) 指向对应的业务表记录
以项目中提供的 OALeave请假举例子,它的业务表 bpm_oa_leave 和流程引擎的流程实例的关系如下图:

  1. 【后端】实现业务逻辑
    实现业务表的【后端】业务逻辑,具体代码可以看看如下两个类:
    BpmOALeaveController
    BpmOALeaveServiceImpl

  2. 【前端】实现业务逻辑
    实现业务表的【前端】业务逻辑,具体代码可以看看如下三个页面:
    请假发起界面:leave/create.vue
    请假详情界面:leave/detail.vue
    请假列表界面:leave/index.vue
    另外,在 router/modules/remaining.ts 中定义 create.vue 和 detail.vue 的路由

  3. 【实现】实现审批结果的监听
    审批结束时(例如说流程实例最终被审批通过、不通过、取消),后端需要监听审批结果,然后更新业务表的状态。
    具体可见 BpmOALeaveStatusListener监听器,它实现流程引擎定义的 BpmProcessInstanceStatusEventListener抽象类,在流程实例结束时,回调通知它最终的结果是通过还是不通过。

流程标识需要填 oa_leave
因为在 BpmOALeaveServiceImpl 类中,发起流程的标识是oa_leave

表单提交路由为 /bpm/oa/leave/create(用于“发起流程”时,跳转的业务表单的路由)
表单查看路由为 /bpm/oa/leave/detail(用于在“流程详情”中,点击查看表单的路由)

流程设计器(BPMN)

两非两资当前流程

/sasac/schedule/create
/sasac/schedule/BpmScheduleDetail

获取顶级父部门

SasacDeptService.java

1
2
3
4
5
6
7
/**
* 获取指定部门的顶级父部门
*
* @param deptId 部门ID
* @return 顶级父部门,如果找不到则返回 null
*/
SasacDeptDO getTopLevelParentDept(Long deptId);

SasacDeptServiceImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Override
public SasacDeptDO getTopLevelParentDept(Long deptId) {
if (deptId == null || deptId == 0L) {
return null;
}

SasacDeptDO currentDept = sasacDeptMapper.selectById(deptId);
if (currentDept == null) {
return null;
}

// 如果当前已经是顶级部门(parentId 为 null 或 0),直接返回自身
if (currentDept.getParentId() == null || currentDept.getParentId() == 0L) {
return currentDept;
}

// 循环向上查找,直到找到顶级父部门
while (currentDept != null) {
Long parentId = currentDept.getParentId();
// 到达顶级(parentId 为 null 或 0)
if (parentId == null || parentId == 0L) {
break;
}
currentDept = sasacDeptMapper.selectById(parentId);
}

return currentDept;
}

示例用法:

1
2
3
4
5
6
7
8
9
10
// 获取指定部门的顶级父部门
Long deptId = 123L; // 子部门ID
SasacDeptDO topLevelDept = sasacDeptService.getTopLevelParentDept(deptId);

if (topLevelDept != null) {
System.out.println("顶级父部门ID: " + topLevelDept.getId());
System.out.println("顶级父部门名称: " + topLevelDept.getDeptName());
} else {
System.out.println("未找到顶级父部门");
}

代码暂存

BpmProcessInstanceCreateReqVO.java
添加

1
2
3
4
5
@Schema(description = "业务标识")
private String businessKey;

@Schema(description = "业务编号")
private Long businessId;

BpmProcessInstanceServiceImpl.java
修改 - 762行

1
2
// 发起流程
return createProcessInstance0(userId, definition, createReqVO.getVariables(), createReqVO.getBusinessKey(),

SasacDeptService.java

添加

1
2
3
4
5
6
7
/**
* 获取指定部门的顶级父部门
*
* @param deptId 部门ID
* @return 顶级父部门,如果找不到则返回 null
*/
SasacDeptDO getTopLevelParentDept(Long deptId);

SasacDeptServiceImpl.java
最后添加

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Override
public SasacDeptDO getTopLevelParentDept(Long deptId) {
if (deptId == null || deptId == 0L) {
return null;
}
SasacDeptDO currentDept = sasacDeptMapper.selectById(deptId);
if (currentDept == null) {
return null;
}
// 如果当前已经是顶级部门(parentId 为 null 或 0),直接返回自身
if (currentDept.getParentId() == null || currentDept.getParentId() == 0L) {
return currentDept;
}
// 循环向上查找,直到找到顶级父部门
while (currentDept != null) {
Long parentId = currentDept.getParentId();
// 到达顶级(parentId 为 null 或 0)
if (parentId == null || parentId == 0L) {
break;
}
currentDept = sasacDeptMapper.selectById(parentId);
}
return currentDept;
}

SasacScheduleResultListener.java
解决:

1
2
3
4
5
6
7
8
// 1. 获取业务 ID
String businessKey = execution.getProcessInstanceBusinessKey();
if (businessKey == null) return;
// 解析业务键,格式为 "业务类型:业务ID"
String[] parts = businessKey.split(":");
if (parts.length < 2) return;
Long id = Long.parseLong(parts[1].trim());
// 打印所有变量,看看结果到底存在哪

解决审批详情问题

BpmApprovalDetailRespVO.java
添加

1
2
@Schema(description = "流程变量(包含表单数据)")
private Map<String, Object> processVariables;

BpmProcessInstanceConvert.java
添加
Map<String, Object> processVariables

  1. 拼接起来
    .setProcessVariables(processVariables)

BpmProcessInstanceServiceImpl.java
修改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 1. 获取所有需要读取用户信息的 userIds
List<ActivityNode> approveNodes = newArrayList(
asList(endApprovalNodeInfos, runningApprovalNodeInfos, simulateApprovalNodeInfos));
Set<Long> userIds = BpmProcessInstanceConvert.INSTANCE.parseUserIds(processInstance, approveNodes, todoTask);
Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(userIds);
Map<Long, DeptRespDTO> deptMap = deptApi.getDeptMap(convertSet(userMap.values(), AdminUserRespDTO::getDeptId));

// 2. 表单权限
String taskId = reqVO.getTaskId() == null && todoTask != null ? todoTask.getId() : reqVO.getTaskId();
Map<String, String> formFieldsPermission = getFormFieldsPermission(bpmnModel, reqVO.getActivityId(), taskId);

// 3. 拼接数据
return BpmProcessInstanceConvert.INSTANCE.buildApprovalDetail(bpmnModel, processDefinition,
processDefinitionInfo, processInstance,
processInstanceStatus, approveNodes, todoTask, formFieldsPermission, userMap, deptMap);

核心实现架构

审批详情的表单显示采用两种类型、动态加载的设计模式,核心实现在
index.vue

一、表单显示的两种类型

根据 processDefinition.formType 字段判断:

类型 枚举值 实现方式 适用场景
流程表单 BpmModelFormType.NORMAL (10) form-create 动态渲染 通用审批表单
业务表单 BpmModelFormType.CUSTOM (20) 异步组件加载 定制化业务页面

二、实现流程

1. 数据获取

页面初始化时调用 getApprovalDetail API 获取审批详情:

1
2
3
4
5
6
const param = {
processInstanceId: props.id,
activityId: props.activityId,
taskId: props.taskId
}
const data = await ProcessInstanceApi.getApprovalDetail(param)

2. 流程表单(NORMAL)实现

通过 form-create 组件动态渲染表单:

1
2
3
4
5
6
7
8
9
10
11
if (processDefinition.value.formType === BpmModelFormType.NORMAL) {
setConfAndFields2(
detailForm,
processDefinition.value.formConf, // 表单配置(JSON 字符串)
processDefinition.value.formFields, // 字段规则(JSON 数组)
processInstance.value.formVariables // 表单数据
)
// 禁用表单按钮
fApi.value?.btn.show(false)
fApi.value?.disabled(true)
}

setConfAndFields2 函数负责解码配置并设置到表单:

1
2
3
4
5
6
7
export const setConfAndFields2 = (detailPreview, conf, fields, value) => {
detailPreview.option = decodeConf(conf) // JSON.parse 解码配置
detailPreview.rule = decodeFields(fields) // 解码字段规则
if (value) {
detailPreview.value = value // 设置表单数据
}
}

3. 业务表单(CUSTOM)实现

通过动态组件注册加载自定义业务页面:

1
2
3
4
if (processDefinition.value.formType === BpmModelFormType.CUSTOM) {
// formCustomViewPath 示例: /crm/contract/detail/index.vue
BusinessFormComponent.value = registerComponent(data.processDefinition.formCustomViewPath)
}

registerComponent 函数通过组件路径匹配并动态导入:

1
2
3
4
5
6
7
8
9
const modules = import.meta.glob('../views/**/*.{vue,tsx}')

export const registerComponent = (componentPath: string) => {
for (const item in modules) {
if (item.includes(componentPath)) {
return defineAsyncComponent(modules[item])
}
}
}

三、前端模板渲染

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- 流程表单 -->
<div v-if="processDefinition?.formType === BpmModelFormType.NORMAL">
<form-create
v-model="detailForm.value"
v-model:api="fApi"
:option="detailForm.option"
:rule="detailForm.rule"
/>
</div>

<!-- 业务表单 -->
<div v-if="processDefinition?.formType === BpmModelFormType.CUSTOM">
<BusinessFormComponent :id="processInstance.businessKey" />
</div>

四、字段权限控制

表单支持字段级别的权限控制:

1
2
3
4
5
6
7
8
9
10
11
12
const setFieldPermission = (field: string, permission: string) => {
if (permission === FieldPermissionType.READ) { // 只读
fApi.value?.disabled(true, field)
}
if (permission === FieldPermissionType.WRITE) { // 可写
fApi.value?.disabled(false, field)
writableFields.push(field)
}
if (permission === FieldPermissionType.NONE) { // 隐藏
fApi.value?.hidden(true, field)
}
}

五、数据流向图

1
2
3
4
5
6
7
8
9
10
┌─────────────────┐     ┌──────────────────┐     ┌─────────────────┐
│ 后端 API │ │ 前端处理 │ │ 表单渲染 │
├─────────────────┤ ├──────────────────┤ ├─────────────────┤
│getApprovalDetail│───▶│ 判断 formType │───▶│ form-create │
│ │ │ NORMAL = 10 │ │ (流程表单) │
│ - formConf │ │ CUSTOM = 20 │ │ │
│ - formFields │ │ │ │ BusinessForm │
│ - formVariables │ │ setConfAndFields2│ │ (业务表单) │
│ - formCustom... │ │ registerComponent│ │ │
└─────────────────┘ └──────────────────┘ └─────────────────┘

六、设计亮点

  1. 灵活性:支持通用表单和定制业务页面两种模式
  2. 扩展性:通过配置而非硬编码实现表单定义
  3. 权限细粒度:支持字段级别的读写权限控制
  4. 异步加载:业务表单按需加载,优化首屏性能
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
<template>
<ContentWrap>
<el-descriptions :column="2" border>
<el-descriptions-item label="记录类型" prop="recordType">
{{ getDictLabel(DICT_TYPE.SASAC_SCHEDULE_NEW_RECORD_TYPE, detailData.recordType) }}
</el-descriptions-item>
<el-descriptions-item label="启动状态" prop="launchStatus">
{{ getDictLabel(DICT_TYPE.SASAC_SCHEDULE_NEW_LAUNCH_STATUS, detailData.launchStatus) }}
</el-descriptions-item>
<el-descriptions-item label="年度" prop="planYear">
{{ formatDate(detailData.planYear, 'YYYY-MM-DD') }}
</el-descriptions-item>
<el-descriptions-item label="发布日期" prop="publishDate">
{{ formatDate(detailData.publishDate, 'YYYY-MM-DD') }}
</el-descriptions-item>
<el-descriptions-item label="单位名称" prop="deptId">
{{ deptName || detailData.deptId || '-' }}
</el-descriptions-item>
<el-descriptions-item label="项目名称" prop="planName">
{{ detailData.planName || '-' }}
</el-descriptions-item>
</el-descriptions>

<el-divider content-position="left">企业/项目基本情况</el-divider>
<el-descriptions :column="2" border>
<el-descriptions-item label="股权结构" prop="equityStructure">
{{ getDictLabel(DICT_TYPE.SASAC_EQUITY_STRUCTURE, detailData.equityStructure) }}
</el-descriptions-item>
<el-descriptions-item label="企业级次" prop="enterpriseLevel">
{{ getDictLabel(DICT_TYPE.SASAC_ENTERPRISE_LEVEL, detailData.enterpriseLevel) }}
</el-descriptions-item>
<el-descriptions-item label="是否集团主业" prop="isCoreBusiness">
{{ getDictLabel(DICT_TYPE.SASAC_SCHEDULE_NEW_IS_CORE_BUSINESS, detailData.isCoreBusiness) }}
</el-descriptions-item>
<el-descriptions-item label="目前经营状态" prop="operatingStatus">
{{ getDictLabel(DICT_TYPE.SASAC_OPERATING_STATUS, detailData.operatingStatus) }}
</el-descriptions-item>
<el-descriptions-item label="在册人员" prop="registeredStaffCount">
{{ detailData.registeredStaffCount || '-' }}
</el-descriptions-item>
<el-descriptions-item label="资产总额(万)" prop="totalAssets">
{{ detailData.totalAssets || '-' }}
</el-descriptions-item>
<el-descriptions-item label="负债总额(万)" prop="totalLiabilities">
{{ detailData.totalLiabilities || '-' }}
</el-descriptions-item>
<el-descriptions-item label="净资产(万)" prop="netAssets">
{{ detailData.netAssets || '-' }}
</el-descriptions-item>
<el-descriptions-item label="停业/停缓建原因" prop="closureReason">
{{ detailData.closureReason || detailData.projHaltReason || '-' }}
</el-descriptions-item>
<el-descriptions-item label="项目总投资(万)" prop="projTotalInvestment">
{{ detailData.projTotalInvestment || '-' }}
</el-descriptions-item>
</el-descriptions>

<el-divider content-position="left">资产基本情况</el-divider>
<el-descriptions :column="2" border>
<el-descriptions-item label="资产类型" prop="assetPropertyType">
{{ getDictLabel(DICT_TYPE.SASAC_SCHEDULE_NEW_ASSET_PROPERTY_TYPE, detailData.assetPropertyType) }}
</el-descriptions-item>
<el-descriptions-item label="使用权(所有权)人" prop="titleHolder">
{{ detailData.titleHolder || '-' }}
</el-descriptions-item>
<el-descriptions-item label="账面价值(万)" prop="bookValue">
{{ detailData.bookValue || '-' }}
</el-descriptions-item>
</el-descriptions>

<el-divider content-position="left">处置情况</el-divider>
<el-descriptions :column="2" border>
<el-descriptions-item label="采用的处置方式" prop="adoptedDisposalMethod">
{{ detailData.adoptedDisposalMethod || '-' }}
</el-descriptions-item>
<el-descriptions-item label="拟采用的处置方式" prop="proposedDisposalMethod">
{{ detailData.proposedDisposalMethod || '-' }}
</el-descriptions-item>
<el-descriptions-item label="处置目前进展" prop="disposalProgress">
{{ detailData.disposalProgress || '-' }}
</el-descriptions-item>
<el-descriptions-item label="面临难点问题" prop="disposalDifficulties">
{{ detailData.disposalDifficulties || '-' }}
</el-descriptions-item>
<el-descriptions-item label="预计处置损失" prop="estimatedDisposalLoss">
{{ detailData.estimatedDisposalLoss || '-' }}
</el-descriptions-item>
<el-descriptions-item label="预计完成时间" prop="estimatedCompleteDate">
{{ formatDate(detailData.estimatedCompleteDate, 'YYYY-MM-DD') }}
</el-descriptions-item>
</el-descriptions>

<el-divider content-position="left">责任人</el-divider>
<el-descriptions :column="2" border>
<el-descriptions-item label="包联领导" prop="coordinatingLeader">
{{ detailData.coordinatingLeader || '-' }}
</el-descriptions-item>
<el-descriptions-item label="牵头部门及负责人" prop="groupLeadDeptHead">
{{ detailData.groupLeadDeptHead || '-' }}
</el-descriptions-item>
<el-descriptions-item label="联系电话" prop="contactPhone">
{{ detailData.contactPhone || '-' }}
</el-descriptions-item>
</el-descriptions>

<el-divider content-position="left">其他信息</el-divider>
<el-descriptions :column="2" border>
<el-descriptions-item label="创建时间" prop="createTime">
{{ formatDate(detailData.createTime, 'YYYY-MM-DD HH:mm:ss') }}
</el-descriptions-item>
<el-descriptions-item label="更新时间" prop="updateTime">
{{ formatDate(detailData.updateTime, 'YYYY-MM-DD HH:mm:ss') }}
</el-descriptions-item>
</el-descriptions>
</ContentWrap>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getDictLabel, DICT_TYPE } from '@/utils/dict'
import { formatDate } from '@/utils/formatTime'
import { ScheduleNewApi } from '@/api/sasac/schedulenew'
import { SasacDeptApi } from '@/api/sasac/sasacdept'

defineOptions({ name: 'BpmScheduleNewDetail' })

// 处理 businessKey,格式为 "业务类型:业务ID",如 "sasac_schedule_new:349"
const getBusinessId = (businessKey: string | number): number | undefined => {
if (typeof businessKey === 'number') {
return businessKey
}
if (typeof businessKey === 'string' && businessKey.includes(':')) {
const parts = businessKey.split(':')
if (parts.length >= 2) {
return parseInt(parts[1], 10)
}
}
return undefined
}

const props = defineProps({
id: {
type: [Number, String],
default: undefined
}
})

// 解析业务ID
const resolvedId = computed(() => {
if (props.id) {
return getBusinessId(props.id as string | number)
}
return undefined
})

const detailLoading = ref(false)
const detailData = ref<any>({})
const deptName = ref('')

/** 获取详情 */
const getInfo = async () => {
if (!resolvedId.value) return
detailLoading.value = true
try {
detailData.value = await ScheduleNewApi.getScheduleNew(resolvedId.value)
if (detailData.value.deptId) {
await getDeptName(detailData.value.deptId)
}
} finally {
detailLoading.value = false
}
}

/** 获取部门名称 */
const getDeptName = async (deptId: number) => {
try {
const dept = await SasacDeptApi.getSasacDept(deptId)
deptName.value = dept?.deptName || ''
} catch (error) {
console.error('获取部门信息失败:', error)
deptName.value = ''
}
}

onMounted(() => {
getInfo()
})
</script>

<style scoped>
:deep(.el-descriptions__label) {
font-weight: bold;
}
</style>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
<template>
<ContentWrap :bodyStyle="{ padding: '10px 20px 0' }" class="position-relative">
<div class="processInstance-wrap-main">
<el-scrollbar>
<img
class="position-absolute right-20px"
width="150"
:src="auditIconsMap[processInstance.status]"
alt=""
/>
<div class="flex">
<div class="text-#878c93 h-15px">编号:{{ id }}</div>
<Icon icon="ep:printer" class="ml-15px cursor-pointer" @click="handlePrint" />
</div>
<el-divider class="!my-8px" />
<div class="flex items-center gap-5 mb-10px h-40px">
<div class="text-26px font-bold mb-5px">{{ processInstance.name }}</div>
<dict-tag
v-if="processInstance.status"
:type="DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS"
:value="processInstance.status"
/>
</div>

<div class="flex items-center gap-5 mb-10px text-13px h-35px">
<div
class="bg-gray-100 h-35px rounded-3xl flex items-center p-8px gap-2 dark:color-gray-600"
>
<el-avatar
:size="28"
v-if="processInstance?.startUser?.avatar"
:src="processInstance?.startUser?.avatar"
/>
<el-avatar :size="28" v-else-if="processInstance?.startUser?.nickname">
{{ processInstance?.startUser?.nickname.substring(0, 1) }}
</el-avatar>
{{ processInstance?.startUser?.nickname }}
</div>
<div class="text-#878c93"> {{ formatDate(processInstance.startTime) }} 提交 </div>
</div>

<el-tabs v-model="activeTab">
<!-- 表单信息 -->
<el-tab-pane label="审批详情" name="form">
<div class="form-scroll-area">
<el-scrollbar>
<el-row>
<el-col :span="17" class="!flex !flex-col formCol">
<!-- 表单信息 -->
<div
v-loading="processInstanceLoading"
class="form-box flex flex-col mb-30px flex-1"
>
<!-- 情况一:流程表单 -->
<el-col v-if="processDefinition?.formType === BpmModelFormType.NORMAL">
<form-create
v-model="detailForm.value"
v-model:api="fApi"
:option="detailForm.option"
:rule="detailForm.rule"
/>
</el-col>
<!-- 情况二:业务表单 -->
<div v-if="processDefinition?.formType === BpmModelFormType.CUSTOM">
<BusinessFormComponent :id="processInstance.businessKey" />
</div>
</div>
</el-col>
<el-col :span="7">
<!-- 审批记录时间线 -->
<ProcessInstanceTimeline :activity-nodes="activityNodes" />
</el-col>
</el-row>
</el-scrollbar>
</div>
</el-tab-pane>

<!-- 流程图 -->
<el-tab-pane label="流程图" name="diagram">
<div class="form-scroll-area">
<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>
</el-tab-pane>

<!-- 流转记录 -->
<el-tab-pane label="流转记录" name="record">
<div class="form-scroll-area">
<el-scrollbar>
<ProcessInstanceTaskList :loading="processInstanceLoading" :id="id" />
</el-scrollbar>
</div>
</el-tab-pane>

<!-- 进度 -->
<el-tab-pane label="进度" name="comment">
<div class="form-scroll-area">
<el-scrollbar>
<!-- 已有进度区域 -->
<div class="comment-list-container mb-20px">
<div class="flex justify-between items-center mb-15px">
<h3 class="text-lg font-semibold">进度 ({{ comments.length }})</h3>
</div>

<!-- 进度列表 -->
<div v-if="comments.length > 0" class="space-y-15px">
<div
v-for="comment in comments"
:key="comment.id"
class="comment-item bg-white p-15px rounded-lg shadow-sm border border-gray-200"
>
<div class="flex items-start">
<el-avatar
:size="36"
:src="comment.avatar || ''"
class="mr-10px"
>
{{ comment.nickName ? comment.nickName.substring(0, 1) : 'U' }}
</el-avatar>
<div class="flex-1">
<div class="flex justify-between items-center">
<div class="font-medium">{{ comment.nickName || '匿名用户' }}</div>
<div class="text-xs text-gray-500">{{ formatDate(comment.createTime) }}</div>
</div>
<div class="mt-5px text-gray-700">{{ comment.content }}</div>
</div>
</div>
</div>
</div>
<div v-else class="text-center py-20px text-gray-500">
暂无进度内容
</div>
</div>

<!-- 新增进度区域 -->
<div class="add-comment-container">
<div class="flex justify-between items-center mb-15px">
<h3 class="text-lg font-semibold">发表进度</h3>
</div>
<el-form :model="commentForm" :rules="commentRules" ref="commentFormRef">
<el-form-item prop="content">
<el-input
v-model="commentForm.content"
type="textarea"
:rows="4"
placeholder="请输入进度内容..."
maxlength="500"
show-word-limit
/>
</el-form-item>
<div class="flex justify-end mt-10px">
<el-button @click="resetCommentForm">清空</el-button>
<el-button type="primary" @click="submitComment" :loading="submittingComment">
发表进度
</el-button>
</div>
</el-form>
</div>
</el-scrollbar>
</div>
</el-tab-pane>
</el-tabs>

<div class="b-t-solid border-t-1px border-[var(--el-border-color)]">
<!-- 操作栏按钮 -->
<ProcessInstanceOperationButton
ref="operationButtonRef"
:process-instance="processInstance"
:process-definition="processDefinition"
:userOptions="userOptions"
:normal-form="detailForm"
:normal-form-api="fApi"
:writable-fields="writableFields"
@success="refresh"
/>
</div>
</el-scrollbar>
</div>
</ContentWrap>

<!-- 打印预览弹窗 -->
<PrintDialog ref="printRef" />
</template>
<script lang="ts" setup>
import { formatDate } from '@/utils/formatTime'
import { DICT_TYPE } from '@/utils/dict'
import { BpmModelType, BpmModelFormType } from '@/utils/constants'
import { setConfAndFields2 } from '@/utils/formCreate'
import { registerComponent } from '@/utils/routerHelper'
import type { ApiAttrs } from '@form-create/element-ui/types/config'
import * as ProcessInstanceApi from '@/api/bpm/processInstance'
import * as UserApi from '@/api/system/user'
import ProcessInstanceBpmnViewer from './ProcessInstanceBpmnViewer.vue'
import ProcessInstanceSimpleViewer from './ProcessInstanceSimpleViewer.vue'
import ProcessInstanceTaskList from './ProcessInstanceTaskList.vue'
import ProcessInstanceOperationButton from './ProcessInstanceOperationButton.vue'
import ProcessInstanceTimeline from './ProcessInstanceTimeline.vue'
import { FieldPermissionType } from '@/components/SimpleProcessDesignerV2/src/consts'
import { TaskStatusEnum } from '@/api/bpm/task'
import runningSvg from '@/assets/svgs/bpm/running.svg'
import approveSvg from '@/assets/svgs/bpm/approve.svg'
import rejectSvg from '@/assets/svgs/bpm/reject.svg'
import cancelSvg from '@/assets/svgs/bpm/cancel.svg'
import PrintDialog from './PrintDialog.vue'
import {CommentApi} from "@/api/sasac/comment";

defineOptions({ name: 'BpmProcessInstanceDetail' })
const props = defineProps<{
id: string // 流程实例的编号
taskId?: string // 任务编号
activityId?: string //流程活动编号,用于抄送查看
}>()
const message = useMessage() // 消息弹窗
const processInstanceLoading = ref(false) // 流程实例的加载中
const processInstance = ref<any>({}) // 流程实例
const processDefinition = ref<any>({}) // 流程定义
const processModelView = ref<any>({}) // 流程模型视图
const operationButtonRef = ref() // 操作按钮组件 ref
const auditIconsMap = {
[TaskStatusEnum.RUNNING]: runningSvg,
[TaskStatusEnum.APPROVE]: approveSvg,
[TaskStatusEnum.REJECT]: rejectSvg,
[TaskStatusEnum.CANCEL]: cancelSvg
}

// ========== 申请信息 ==========
const fApi = ref<ApiAttrs>() //
const detailForm = ref({
rule: [],
option: {},
value: {}
}) // 流程实例的表单详情

const writableFields: Array<string> = [] // 表单可以编辑的字段

// ========== 进度功能 ==========
const comments = ref<any[]>([]) // 进度列表
const commentForm = ref({
content: ''
})
const commentFormRef = ref()
const submittingComment = ref(false) // 提交进度中

// 进度表单验证规则
const commentRules = {
content: [
{ required: true, message: '请输入进度内容', trigger: 'blur' },
{ min: 1, max: 500, message: '进度长度在1到500个字符之间', trigger: 'blur' }
]
}

/** 获取进度列表 */
const getComments = async () => {
try {
// 这里的 response 根据控制台输出,实际上已经是后端的 data 数组了
const response = await CommentApi.getProcessComment(props.id)
console.log('后端返回的完整响应:', response)

// 【关键修改】直接判断 response 是否为数组
if (Array.isArray(response)) {
comments.value = response.map((comment: any) => {
// 处理 createTime:后端返回的是 [时间戳],需要提取并格式化
let formattedTime = ''
if (comment.createTime && Array.isArray(comment.createTime) && comment.createTime.length > 0) {
// 【关键修改】必须通过 提取数组里的第一个元素,否则 new Date() 无法正确解析数组
const timestamp = comment.createTime

// 防御性编程:确保提取出来的是数字类型。如果后端传的是字符串 "1779...",new Date 也能处理,但转成数字更稳妥
const date = new Date(Number(timestamp))

// 检查日期是否有效,防止出现 NaN-NaN-NaN
if (!isNaN(date.getTime())) {
formattedTime = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`
}
}

return {
id: comment.id,
nickName: comment.creatorName || '未知用户',
avatar: comment.avatar || '',
content: comment.content,
createTime: formattedTime
}
})
} else {
// 如果后端接口异常,拦截器可能会返回 null 或 undefined
console.error('获取进度失败: 返回数据不是数组', response)
message.error('获取进度失败')
}
} catch (error) {
console.error('获取进度失败: 网络或系统异常', error)
message.error('获取进度失败')
}
}

/** 提交进度 */
const submitComment = async () => {
if (!commentForm.value.content.trim()) {
message.warning('请输入进度内容')
return
}

try {
submittingComment.value = true

// 调用提交进度的API
// 注意:如果你的 request 拦截器在 code!==0 时会直接抛出异常,那么能走到下面这行就说明提交成功了
await CommentApi.addProcessComment({
processInstanceId: props.id,
content: commentForm.value.content,
creatorName: processInstance.value.startUser?.nickname || '当前用户',
})

await getComments()
resetCommentForm()
message.success('进度发表成功')

} catch (error) {
console.error('提交进度失败:', error)
message.error('进度发表失败')
} finally {
submittingComment.value = false
}
}

/** 重置进度表单 */
const resetCommentForm = () => {
commentForm.value.content = ''
if (commentFormRef.value) {
commentFormRef.value.clearValidate()
}
}

// ========== 其他功能 ==========
/** 获得详情 */
const getDetail = () => {
// 获得审批详情
getApprovalDetail()
// 获得流程模型视图
getProcessModelView()
// 获取进度列表
getComments()
}

/** 加载流程实例 */
const BusinessFormComponent = ref<any>(null) // 异步组件
/** 获取审批详情 */
const activityNodes = ref<ProcessInstanceApi.ApprovalNodeInfo[]>([]) // 审批节点信息
const getApprovalDetail = async () => {
processInstanceLoading.value = true
try {
const param = {
processInstanceId: props.id,
activityId: props.activityId,
taskId: props.taskId
}
const data = await ProcessInstanceApi.getApprovalDetail(param)
if (!data) {
message.error('查询不到审批详情信息!')
return
}
if (!data.processDefinition || !data.processInstance) {
message.error('查询不到流程信息!')
return
}
processInstance.value = data.processInstance
processDefinition.value = data.processDefinition

// 调试信息
console.log('=== 审批详情调试信息 ===')
console.log('processDefinition:', processDefinition.value)
console.log('processDefinition.formType:', processDefinition.value?.formType)
console.log('processDefinition.formConf:', processDefinition.value?.formConf)
console.log('processDefinition.formFields:', processDefinition.value?.formFields)
console.log('processInstance.formVariables:', processInstance.value?.formVariables)
console.log('BpmModelFormType.NORMAL:', BpmModelFormType.NORMAL)
console.log('========================')

// 设置表单信息
if (processDefinition.value.formType === BpmModelFormType.NORMAL) {
// 获取表单字段权限
const formFieldsPermission = data.formFieldsPermission
// 清空可编辑字段为空
writableFields.splice(0)
if (detailForm.value.rule?.length > 0) {
// 避免刷新 form-create 显示不了
detailForm.value.value = processInstance.value.formVariables
} else {
setConfAndFields2(
detailForm,
processDefinition.value.formConf,
processDefinition.value.formFields,
processInstance.value.formVariables
)
}
nextTick().then(() => {
fApi.value?.btn.show(false)
fApi.value?.resetBtn.show(false)
//@ts-ignore
fApi.value?.disabled(true)
// 设置表单字段权限
if (formFieldsPermission) {
Object.keys(data.formFieldsPermission).forEach((item) => {
setFieldPermission(item, formFieldsPermission[item])
})
}
})
} else if (processDefinition.value.formType === BpmModelFormType.CUSTOM) {
// 业务表单:根据流程定义Key匹配对应的详情页面
// 例如:sasac_bmp_schedule -> /sasac/schedulenew/BpmScheduleNewDetail/index.vue
const formCustomPath = processDefinition.value.formCustomViewPath
if (formCustomPath) {
BusinessFormComponent.value = registerComponent(formCustomPath)
} else {
console.warn('formCustomViewPath 为空,无法加载业务表单')
}
}

// 获取审批节点,显示 Timeline 的数据
activityNodes.value = data.activityNodes

// 获取待办任务显示操作按钮
operationButtonRef.value?.loadTodoTask(data.todoTask)
} finally {
processInstanceLoading.value = false
}
}

/** 获取流程模型视图*/
const getProcessModelView = async () => {
if (BpmModelType.BPMN === processDefinition.value?.modelType) {
// 重置,解决 BPMN 流程图刷新不会重新渲染问题
processModelView.value = {
bpmnXml: ''
}
}
const data = await ProcessInstanceApi.getProcessInstanceBpmnModelView(props.id)
if (data) {
console.log("获取流程实例:",data)
processModelView.value = data
}
}

/** 设置表单权限 */
const setFieldPermission = (field: string, permission: string) => {
if (permission === FieldPermissionType.READ) {
//@ts-ignore
fApi.value?.disabled(true, field)
}
if (permission === FieldPermissionType.WRITE) {
//@ts-ignore
fApi.value?.disabled(false, field)
// 加入可以编辑的字段
writableFields.push(field)
}
if (permission === FieldPermissionType.NONE) {
//@ts-ignore
fApi.value?.hidden(true, field)
}
}

/** 操作成功后刷新 */
const refresh = () => {
// 重新获取详情
getDetail()
}

/** 处理打印 */
const printRef = ref()
const handlePrint = async () => {
printRef.value.open(props.id)
}

/** 当前的 Tab */
const activeTab = ref('form')

/** 初始化 */
const userOptions = ref<UserApi.UserVO[]>([]) // 用户列表
onMounted(async () => {
getDetail()
// 获得用户列表
userOptions.value = await UserApi.getSimpleUserList()
})
</script>

<style lang="scss" scoped>
$wrap-padding-height: 20px;
$wrap-margin-height: 15px;
$button-height: 51px;
$process-header-height: 194px;

.processInstance-wrap-main {
height: calc(
100vh - var(--top-tool-height) - var(--tags-view-height) - var(--app-footer-height) - 35px
);
max-height: calc(
100vh - var(--top-tool-height) - var(--tags-view-height) - var(--app-footer-height) - 35px
);
overflow: auto;

.form-scroll-area {
display: flex;
height: calc(
100vh - var(--top-tool-height) - var(--tags-view-height) - var(--app-footer-height) - 35px -
$process-header-height - 40px
);
max-height: calc(
100vh - var(--top-tool-height) - var(--tags-view-height) - var(--app-footer-height) - 35px -
$process-header-height - 40px
);
overflow: auto;
flex-direction: column;

:deep(.box-card) {
height: 100%;
flex: 1;

.el-card__body {
height: 100%;
padding: 0;
}
}
}
}

.form-box {
:deep(.el-card) {
border: none;
}
}

.comment-list-container {
border-bottom: 1px solid #eee;
padding-bottom: 20px;
}

.comment-item {
border-left: 3px solid var(--el-color-primary);
}

.add-comment-container {
padding-top: 20px;
}
</style>

流程模型

${bpmTaskAssignDeptRoleExpression.calculateUsersByStartUserTopDeptChildrenAndRoles(execution, "112")}
BpmTaskAssignDeptRoleExpression.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
package cn.iocoder.yudao.module.bpm.framework.flowable.core.candidate.expression;

import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
import cn.iocoder.yudao.framework.common.util.collection.SetUtils;
import cn.iocoder.yudao.module.bpm.service.task.BpmProcessInstanceService;
import org.flowable.engine.runtime.ProcessInstance;
import cn.iocoder.yudao.module.system.api.dept.DeptApi;
import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO;
import cn.iocoder.yudao.module.system.api.permission.PermissionApi;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import org.flowable.engine.delegate.DelegateExecution;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;

import jakarta.annotation.Resource;

import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.Collections;

import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;

/**
* 根据部门与角色分配任务的表达式
*/
@Component("bpmTaskAssignDeptRoleExpression")
public class BpmTaskAssignDeptRoleExpression {

private static final Logger log = LoggerFactory.getLogger(BpmTaskAssignDeptRoleExpression.class);

@Resource
private AdminUserApi adminUserApi;
@Resource
private PermissionApi permissionApi;
@Resource
private DeptApi deptApi;
@Resource
private BpmProcessInstanceService processInstanceService;
@Resource
private JdbcTemplate jdbcTemplate;

/**
* 计算两个集合的交集
*/
private Set<Long> intersection(Set<Long> set1, Set<Long> set2) {
Set<Long> result = new HashSet<>(set1);
result.retainAll(set2);
return result;
}

/**
* 核心方法:根据部门ID和角色ID查询用户
*
* @param deptVariableId 部门ID
* @param roleId 角色ID
* @return 用户ID集合
*/
public Set<Long> calculateUsersByDeptAndRole(DelegateExecution execution,String deptVariableId, Long roleId) {
Object deptVariable = execution.getVariable(deptVariableId);
if (deptVariable == null) {
throw new IllegalArgumentException("流程变量 " + deptVariableId + " 不存在");
}
Long deptId = Long.parseLong(deptVariable.toString());

if (deptId == null || roleId == null) {
log.warn("部门ID或角色ID为空: deptId={}, roleId={}", deptId, roleId);
throw new IllegalArgumentException("部门ID或角色ID为空" );
}

try {
// 2.1 根据部门ID查询该部门下的所有用户ID
// 注意:芋道系统中,一个用户可能有多个部门,这里查询指定部门下的用户

List<AdminUserRespDTO> users = adminUserApi.getUserListByDeptIds(Collections.singleton(deptId)).getCheckedData();
Set<Long> userIdsByDept = convertSet(users, AdminUserRespDTO::getId);


// 2.2 根据角色ID查询拥有该角色的所有用户ID
// 芋道系统的权限API提供了根据角色查询用户的方法
Set<Long> userIdsByRole = permissionApi.getUserRoleIdListByRoleIds(Collections.singleton(roleId)).getCheckedData();

// 2.3 取交集:既是该部门用户,又拥有该角色的用户
Set<Long> intersection = intersection(userIdsByDept, userIdsByRole);

log.info("部门[{}]角色[{}]查询结果: 部门用户数={}, 角色用户数={}, 交集用户数={}",
deptId, roleId,
userIdsByDept.size(), userIdsByRole.size(),
intersection.size());

return intersection;

} catch (Exception e) {
log.error("根据部门和角色查询用户失败: deptId={}, roleId={}", deptId, roleId, e);
throw e;
}
}

/**
* 递归查找顶级部门ID
*/
private Long findTopDeptId(Long deptId) {
if (deptId == null) {
return null;
}
DeptRespDTO dept = deptApi.getDept(deptId).getCheckedData();
if (dept == null) {
return deptId;
}
// 如果parentId为null或0,则当前部门就是顶级部门
if (dept.getParentId() == null || dept.getParentId() == 0L) {
return deptId;
}
// 递归查找父部门
return findTopDeptId(dept.getParentId());
}

/**
* 根据部门ID和角色ID查询用户(内部方法)
*/
private Set<Long> calculateUsersByDeptIdAndRole(Long deptId, Long roleId) {
if (deptId == null || roleId == null) {
log.warn("部门ID或角色ID为空: deptId={}, roleId={}", deptId, roleId);
return Collections.emptySet();
}

try {
// 根据部门ID查询该部门下的所有用户ID
List<AdminUserRespDTO> users = adminUserApi.getUserListByDeptIds(Collections.singleton(deptId)).getCheckedData();
Set<Long> userIdsByDept = convertSet(users, AdminUserRespDTO::getId);

if (userIdsByDept.isEmpty()) {
log.warn("部门[{}]下没有用户", deptId);
return Collections.emptySet();
}

// 根据角色ID查询拥有该角色的所有用户ID
Set<Long> userIdsByRole = permissionApi.getUserRoleIdListByRoleIds(Collections.singleton(roleId)).getCheckedData();

if (userIdsByRole.isEmpty()) {
log.warn("角色[{}]下没有用户", roleId);
return Collections.emptySet();
}

// 取交集:既是该部门用户,又拥有该角色的用户
Set<Long> intersection = intersection(userIdsByDept, userIdsByRole);

if (intersection.isEmpty()) {
log.warn("部门[{}]和角色[{}]的交集为空,没有符合条件的用户", deptId, roleId);
return Collections.emptySet();
}

log.info("部门[{}]角色[{}]查询结果: 部门用户数={}, 角色用户数={}, 交集用户数={}",
deptId, roleId,
userIdsByDept.size(), userIdsByRole.size(),
intersection.size());

return intersection;

} catch (Exception e) {
log.error("根据部门和角色查询用户失败: deptId={}, roleId={}", deptId, roleId, e);
return Collections.emptySet();
}
}

/**
* 根据部门ID和多个角色ID查询用户(内部方法)
*
* @param deptId 部门ID
* @param roleIds 角色ID列表(逗号分隔)
* @return 用户ID集合
*/
private Set<Long> calculateUsersByDeptIdAndRoles(Long deptId, String roleIds) {
return calculateUsersByDeptIdsAndRoles(Collections.singleton(deptId), roleIds);
}

/**
* 根据多个部门ID和多个角色ID查询用户(内部方法)
*
* @param deptIds 部门ID集合
* @param roleIds 角色ID列表(逗号分隔)
* @return 用户ID集合
*/
private Set<Long> calculateUsersByDeptIdsAndRoles(Set<Long> deptIds, String roleIds) {
if (deptIds == null || deptIds.isEmpty() || StrUtil.isEmpty(roleIds)) {
log.warn("部门ID或角色ID为空: deptIds={}, roleIds={}", deptIds, roleIds);
return Collections.emptySet();
}

try {
String[] roleIdArray = roleIds.split(",");
Set<Long> roleIdSet = new HashSet<>();
for (String roleIdStr : roleIdArray) {
try {
roleIdSet.add(Long.parseLong(roleIdStr.trim()));
} catch (NumberFormatException e) {
log.warn("无效的角色ID: {}", roleIdStr);
}
}

if (roleIdSet.isEmpty()) {
log.warn("没有有效的角色ID");
return Collections.emptySet();
}

List<AdminUserRespDTO> users = adminUserApi.getUserListByDeptIds(deptIds).getCheckedData();
Set<Long> userIdsByDept = convertSet(users, AdminUserRespDTO::getId);

if (userIdsByDept.isEmpty()) {
log.warn("部门[{}]下没有用户", deptIds);
return Collections.emptySet();
}

Set<Long> userIdsByRoles = permissionApi.getUserRoleIdListByRoleIds(roleIdSet).getCheckedData();

if (userIdsByRoles.isEmpty()) {
log.warn("角色[{}]下没有用户", roleIds);
return Collections.emptySet();
}

Set<Long> result = intersection(userIdsByDept, userIdsByRoles);

if (result.isEmpty()) {
log.warn("部门[{}]和角色[{}]的交集为空,没有符合条件的用户", deptIds, roleIds);
return Collections.emptySet();
}

log.info("部门[{}]角色[{}]查询结果: 部门用户数={}, 角色用户数={}, 交集用户数={}",
deptIds, roleIds,
userIdsByDept.size(), userIdsByRoles.size(),
result.size());

return result;

} catch (Exception e) {
log.error("根据部门和角色查询用户失败: deptIds={}, roleIds={}", deptIds, roleIds, e);
return Collections.emptySet();
}
}

/**
* 根据发起人的顶级部门和角色ID查询用户
*
* @param execution 流程执行上下文
* @param roleIds 角色ID(单个角色ID字符串)
* @return 用户ID集合
*/
public Set<Long> calculateUsersByStartUserTopDeptAndRole(DelegateExecution execution, String roleIds) {
return calculateUsersByStartUserTopDeptAndRoles(execution, roleIds);
}

/**
* 根据发起人的顶级部门和多个角色ID查询用户
*
* @param execution 流程执行上下文
* @param roleIds 角色ID列表(逗号分隔)
* @return 用户ID集合
*/
public Set<Long> calculateUsersByStartUserTopDeptAndRoles(DelegateExecution execution, String roleIds) {
if (execution == null || StrUtil.isEmpty(roleIds)) {
log.warn("execution或roleIds为空");
return Collections.emptySet();
}

try {
// 1. 获取发起人ID
String startUserId = execution.getVariable("startUserId", String.class);
if (startUserId == null) {
// 尝试从流程实例中获取
ProcessInstance processInstance = processInstanceService.getProcessInstance(execution.getProcessInstanceId());
if (processInstance != null) {
startUserId = processInstance.getStartUserId();
}
}
if (startUserId == null) {
log.warn("无法获取发起人ID");
return Collections.emptySet();
}

// 2. 获取发起人的部门ID
AdminUserRespDTO startUser = adminUserApi.getUser(Long.parseLong(startUserId)).getCheckedData();
if (startUser == null || startUser.getDeptId() == null) {
log.warn("发起人不存在或没有部门: startUserId={}", startUserId);
return Collections.emptySet();
}

// 3. 查找发起人的顶级部门
Long topDeptId = findTopDeptId(startUser.getDeptId());
log.info("发起人[{}]的部门[{}]的顶级部门为[{}]", startUserId, startUser.getDeptId(), topDeptId);

// 4. 根据顶级部门和角色查询用户
return calculateUsersByDeptIdAndRoles(topDeptId, roleIds);

} catch (Exception e) {
log.error("根据发起人顶级部门和角色查询用户失败: roleIds={}", roleIds, e);
return Collections.emptySet();
}
}

/**
* 根据发起人的顶级部门的下一级部门和多个角色ID查询用户
*
* 如果发起人已经在顶级部门的下一级,则自己审核自己
*
* @param execution 流程执行上下文
* @param roleIds 角色ID列表(逗号分隔)
* @return 用户ID集合
*/
public Set<Long> calculateUsersByStartUserTopDeptChildrenAndRoles(DelegateExecution execution, String roleIds) {
if (execution == null || StrUtil.isEmpty(roleIds)) {
log.warn("execution或roleIds为空");
return Collections.emptySet();
}

try {
String startUserId = execution.getVariable("startUserId", String.class);
if (startUserId == null) {
ProcessInstance processInstance = processInstanceService.getProcessInstance(execution.getProcessInstanceId());
if (processInstance != null) {
startUserId = processInstance.getStartUserId();
}
}
if (startUserId == null) {
log.warn("无法获取发起人ID");
return Collections.emptySet();
}

AdminUserRespDTO startUser = adminUserApi.getUser(Long.parseLong(startUserId)).getCheckedData();
if (startUser == null || startUser.getDeptId() == null) {
log.warn("发起人不存在或没有部门: startUserId={}", startUserId);
return Collections.emptySet();
}

Long topDeptId = findTopDeptId(startUser.getDeptId());
log.info("发起人[{}]的部门[{}]的顶级部门为[{}]", startUserId, startUser.getDeptId(), topDeptId);

// 使用递归SQL从发起人部门向上查找顶级部门的下一级部门
Set<Long> childrenDeptIds = findChildrenDeptIds(startUser.getDeptId());
log.info("发起人部门[{}]的顶级部门[{}]的下一级部门为[{}]", startUser.getDeptId(), topDeptId, childrenDeptIds);

if (childrenDeptIds.isEmpty()) {
log.warn("顶级部门[{}]没有下一级部门", topDeptId);
return Collections.emptySet();
}

// 如果发起人已经在顶级部门的下一级,则自己审核自己
if (childrenDeptIds.contains(startUser.getDeptId())) {
log.info("发起人[{}]的部门[{}]已经是顶级部门[{}]的下一级,自己审核自己",
startUserId, startUser.getDeptId(), topDeptId);
Set<Long> selfSet = new HashSet<>();
selfSet.add(Long.parseLong(startUserId));
return selfSet;
}

return calculateUsersByDeptIdsAndRoles(childrenDeptIds, roleIds);

} catch (Exception e) {
log.error("根据发起人顶级部门的下一级部门和角色查询用户失败: roleIds={}", roleIds, e);
return Collections.emptySet();
}
}

/**
* 查找指定部门的顶级部门的下一级部门ID
* 使用递归SQL从deptId向上查找直到根部门,然后返回根部门的下一级部门
*
* @param deptId 部门ID(发起人的部门)
* @return 顶级部门的下一级部门ID集合
*/
private Set<Long> findChildrenDeptIds(Long deptId) {
if (deptId == null) {
return Collections.emptySet();
}

try {
String sql = """
WITH RECURSIVE parent_node_cte AS (
SELECT
d.id,
d.parent_id,
d.name,
CAST(0 AS bigint) AS level
FROM system_dept d
WHERE d.id = ?
UNION ALL
SELECT
p.id,
p.parent_id,
p.name,
cte.level + 1 AS level
FROM system_dept p
INNER JOIN parent_node_cte cte ON cte.parent_id = p.id
WHERE p.id IS NOT NULL
AND cte.parent_id IS NOT NULL
),
max_level_info AS (
SELECT MAX(level) AS top_level FROM parent_node_cte
)
SELECT t.id, t.name, t.level
FROM parent_node_cte t, max_level_info m
WHERE t.level = m.top_level - 1
""";

List<Map<String, Object>> results = jdbcTemplate.queryForList(sql, deptId);
Set<Long> childrenDeptIds = new HashSet<>();

for (Map<String, Object> row : results) {
Object idValue = row.get("id");
if (idValue != null) {
childrenDeptIds.add(Long.parseLong(idValue.toString()));
}
}

log.info("递归查找部门[{}]的顶级部门下一级部门,结果: {}", deptId, childrenDeptIds);
return childrenDeptIds;

} catch (Exception e) {
log.error("递归查找部门[{}]的子部门失败", deptId, e);
return Collections.emptySet();
}
}
}