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

|

|
||||||
|
|
||||||
@@ -126,32 +126,32 @@
|
|||||||
>
|
>
|
||||||
> 前者支持轻量配置简单流程,后者实现复杂场景深度编排
|
> 前者支持轻量配置简单流程,后者实现复杂场景深度编排
|
||||||
|
|
||||||
| 功能列表 | 功能描述 | 是否完成 |
|
| 功能列表 | 功能描述 | 是否完成 |
|
||||||
| --- | --- | --- |
|
|------------|-------------------------------------------------------------------------------------|------|
|
||||||
| SIMPLE 设计器 | 仿钉钉/飞书设计器,支持拖拽搭建表单流程,10 分钟快速完成审批流程配置 | ✅ |
|
| SIMPLE 设计器 | 仿钉钉/飞书设计器,支持拖拽搭建表单流程,10 分钟快速完成审批流程配置 | ✅ |
|
||||||
| BPMN 设计器 | 基于 BPMN 标准开发,适配复杂业务场景,满足多层级审批及流程自动化需求 | ✅ |
|
| BPMN 设计器 | 基于 BPMN 标准开发,适配复杂业务场景,满足多层级审批及流程自动化需求 | ✅ |
|
||||||
| 会签 | 同一个审批节点设置多个人(如 A、B、C 三人,三人会同时收到待办任务),需全部同意之后,审批才可到下一审批节点 | ✅ |
|
| 会签 | 同一个审批节点设置多个人(如 A、B、C 三人,三人会同时收到待办任务),需全部同意之后,审批才可到下一审批节点 | ✅ |
|
||||||
| 或签 | 同一个审批节点设置多个人,任意一个人处理后,就能进入下一个节点 | ✅ |
|
| 或签 | 同一个审批节点设置多个人,任意一个人处理后,就能进入下一个节点 | ✅ |
|
||||||
| 依次审批 | (顺序会签)同一个审批节点设置多个人(如 A、B、C 三人),三人按顺序依次收到待办,即 A 先审批,A 提交后 B 才能审批,需全部同意之后,审批才可到下一审批节点 | ✅ |
|
| 依次审批 | (顺序会签)同一个审批节点设置多个人(如 A、B、C 三人),三人按顺序依次收到待办,即 A 先审批,A 提交后 B 才能审批,需全部同意之后,审批才可到下一审批节点 | ✅ |
|
||||||
| 抄送 | 将审批结果通知给抄送人,同一个审批默认排重,不重复抄送给同一人 | ✅ |
|
| 抄送 | 将审批结果通知给抄送人,同一个审批默认排重,不重复抄送给同一人 | ✅ |
|
||||||
| 驳回 | (退回)将审批重置发送给某节点,重新审批。可驳回至发起人、上一节点、任意节点 | ✅ |
|
| 驳回 | (退回)将审批重置发送给某节点,重新审批。可驳回至发起人、上一节点、任意节点 | ✅ |
|
||||||
| 转办 | A 转给其 B 审批,B 审批后,进入下一节点 | ✅ |
|
| 转办 | A 转给其 B 审批,B 审批后,进入下一节点 | ✅ |
|
||||||
| 委派 | A 转给其 B 审批,B 审批后,转给 A,A 继续审批后进入下一节点 | ✅ |
|
| 委派 | A 转给其 B 审批,B 审批后,转给 A,A 继续审批后进入下一节点 | ✅ |
|
||||||
| 加签 | 允许当前审批人根据需要,自行增加当前节点的审批人,支持向前、向后加签 | ✅ |
|
| 加签 | 允许当前审批人根据需要,自行增加当前节点的审批人,支持向前、向后加签 | ✅ |
|
||||||
| 减签 | (取消加签)在当前审批人操作之前,减少审批人 | ✅ |
|
| 减签 | (取消加签)在当前审批人操作之前,减少审批人 | ✅ |
|
||||||
| 撤销 | (取消流程)流程发起人,可以对流程进行撤销处理 | ✅ |
|
| 撤销 | (取消流程)流程发起人,可以对流程进行撤销处理 | ✅ |
|
||||||
| 终止 | 系统管理员,在任意节点终止流程实例 | ✅ |
|
| 终止 | 系统管理员,在任意节点终止流程实例 | ✅ |
|
||||||
| 表单权限 | 支持拖拉拽配置表单,每个审批节点可配置只读、编辑、隐藏权限 | ✅ |
|
| 表单权限 | 支持拖拉拽配置表单,每个审批节点可配置只读、编辑、隐藏权限 | ✅ |
|
||||||
| 超时审批 | 配置超时审批时间,超时后自动触发审批通过、不通过、驳回等操作 | ✅ |
|
| 超时审批 | 配置超时审批时间,超时后自动触发审批通过、不通过、驳回等操作 | ✅ |
|
||||||
| 自动提醒 | 配置提醒时间,到达时间后自动触发短信、邮箱、站内信等通知提醒,支持自定义重复提醒频次 | ✅ |
|
| 自动提醒 | 配置提醒时间,到达时间后自动触发短信、邮箱、站内信等通知提醒,支持自定义重复提醒频次 | ✅ |
|
||||||
| 父子流程 | 主流程设置子流程节点,子流程节点会自动触发子流程。子流程结束后,主流程才会执行(继续往下下执行),支持同步子流程、异步子流程 | ✅ |
|
| 父子流程 | 主流程设置子流程节点,子流程节点会自动触发子流程。子流程结束后,主流程才会执行(继续往下下执行),支持同步子流程、异步子流程 | ✅ |
|
||||||
| 条件分支 | (排它分支)用于在流程中实现决策,即根据条件选择一个分支执行 | ✅ |
|
| 条件分支 | (排它分支)用于在流程中实现决策,即根据条件选择一个分支执行 | ✅ |
|
||||||
| 并行分支 | 允许将流程分成多条分支,不进行条件判断,所有分支都会执行 | ✅ |
|
| 并行分支 | 允许将流程分成多条分支,不进行条件判断,所有分支都会执行 | ✅ |
|
||||||
| 包容分支 | (条件分支 + 并行分支的结合体)允许基于条件选择多条分支执行,但如果没有任何一个分支满足条件,则可以选择默认分支 | ✅ |
|
| 包容分支 | (条件分支 + 并行分支的结合体)允许基于条件选择多条分支执行,但如果没有任何一个分支满足条件,则可以选择默认分支 | ✅ |
|
||||||
| 路由分支 | 根据条件选择一个分支执行(重定向到指定配置节点),也可以选择默认分支执行(继续往下执行) | ✅ |
|
| 路由分支 | 根据条件选择一个分支执行(重定向到指定配置节点),也可以选择默认分支执行(继续往下执行) | ✅ |
|
||||||
| 触发节点 | 执行到该节点,触发 HTTP 请求、HTTP 回调、更新数据、删除数据等 | ✅ |
|
| 触发节点 | 执行到该节点,触发 HTTP 请求、HTTP 回调、更新数据、删除数据等 | ✅ |
|
||||||
| 延迟节点 | 执行到该节点,审批等待一段时间再执行,支持固定时长、固定日期等 | ✅ |
|
| 延迟节点 | 执行到该节点,审批等待一段时间再执行,支持固定时长、固定日期等 | ✅ |
|
||||||
| 拓展设置 | 流程前置/后置通知,节点(任务)前置、后置通知,流程报表,自动审批去重,自定流程编号、标题、摘要,流程报表等 | ✅ |
|
| 拓展设置 | 流程前置/后置通知,节点(任务)前置、后置通知,流程报表,自动审批去重,自定流程编号、标题、摘要,流程报表等 | ✅ |
|
||||||
|
|
||||||
### 支付系统
|
### 支付系统
|
||||||
|
|
||||||
@@ -165,26 +165,26 @@
|
|||||||
|
|
||||||
### 基础设施
|
### 基础设施
|
||||||
|
|
||||||
| | 功能 | 描述 |
|
| | 功能 | 描述 |
|
||||||
| --- | --- | --- |
|
|----|-----------|----------------------------------------------|
|
||||||
| 🚀 | 代码生成 | 前后端代码的生成(Java、Vue、SQL、单元测试),支持 CRUD 下载 |
|
| 🚀 | 代码生成 | 前后端代码的生成(Java、Vue、SQL、单元测试),支持 CRUD 下载 |
|
||||||
| 🚀 | 系统接口 | 基于 Swagger 自动生成相关的 RESTful API 接口文档 |
|
| 🚀 | 系统接口 | 基于 Swagger 自动生成相关的 RESTful API 接口文档 |
|
||||||
| 🚀 | 数据库文档 | 基于 Screw 自动生成数据库文档,支持导出 Word、HTML、MD 格式 |
|
| 🚀 | 数据库文档 | 基于 Screw 自动生成数据库文档,支持导出 Word、HTML、MD 格式 |
|
||||||
| | 表单构建 | 拖动表单元素生成相应的 HTML 代码,支持导出 JSON、Vue 文件 |
|
| | 表单构建 | 拖动表单元素生成相应的 HTML 代码,支持导出 JSON、Vue 文件 |
|
||||||
| 🚀 | 配置管理 | 对系统动态配置常用参数,支持 SpringBoot 加载 |
|
| 🚀 | 配置管理 | 对系统动态配置常用参数,支持 SpringBoot 加载 |
|
||||||
| ⭐️ | 定时任务 | 在线(添加、修改、删除)任务调度包含执行结果日志 |
|
| ⭐️ | 定时任务 | 在线(添加、修改、删除)任务调度包含执行结果日志 |
|
||||||
| 🚀 | 文件服务 | 支持将文件存储到 S3(MinIO、阿里云、腾讯云、七牛云)、本地、FTP、数据库等 |
|
| 🚀 | 文件服务 | 支持将文件存储到 S3(MinIO、阿里云、腾讯云、七牛云)、本地、FTP、数据库等 |
|
||||||
| 🚀 | WebSocket | 提供 WebSocket 接入示例,支持一对一、一对多发送方式 |
|
| 🚀 | WebSocket | 提供 WebSocket 接入示例,支持一对一、一对多发送方式 |
|
||||||
| 🚀 | API 日志 | 包括 RESTful API 访问日志、异常日志两部分,方便排查 API 相关的问题 |
|
| 🚀 | API 日志 | 包括 RESTful API 访问日志、异常日志两部分,方便排查 API 相关的问题 |
|
||||||
| | MySQL 监控 | 监视当前系统数据库连接池状态,可进行分析SQL找出系统性能瓶颈 |
|
| | MySQL 监控 | 监视当前系统数据库连接池状态,可进行分析SQL找出系统性能瓶颈 |
|
||||||
| | Redis 监控 | 监控 Redis 数据库的使用情况,使用的 Redis Key 管理 |
|
| | Redis 监控 | 监控 Redis 数据库的使用情况,使用的 Redis Key 管理 |
|
||||||
| 🚀 | 消息队列 | 基于 Redis 实现消息队列,Stream 提供集群消费,Pub/Sub 提供广播消费 |
|
| 🚀 | 消息队列 | 基于 Redis 实现消息队列,Stream 提供集群消费,Pub/Sub 提供广播消费 |
|
||||||
| 🚀 | Java 监控 | 基于 Spring Boot Admin 实现 Java 应用的监控 |
|
| 🚀 | Java 监控 | 基于 Spring Boot Admin 实现 Java 应用的监控 |
|
||||||
| 🚀 | 链路追踪 | 接入 SkyWalking 组件,实现链路追踪 |
|
| 🚀 | 链路追踪 | 接入 SkyWalking 组件,实现链路追踪 |
|
||||||
| 🚀 | 日志中心 | 接入 SkyWalking 组件,实现日志中心 |
|
| 🚀 | 日志中心 | 接入 SkyWalking 组件,实现日志中心 |
|
||||||
| 🚀 | 服务保障 | 基于 Redis 实现分布式锁、幂等、限流功能,满足高并发场景 |
|
| 🚀 | 服务保障 | 基于 Redis 实现分布式锁、幂等、限流功能,满足高并发场景 |
|
||||||
| 🚀 | 日志服务 | 轻量级日志中心,查看远程服务器的日志 |
|
| 🚀 | 日志服务 | 轻量级日志中心,查看远程服务器的日志 |
|
||||||
| 🚀 | 单元测试 | 基于 JUnit + Mockito 实现单元测试,保证功能的正确性、代码的质量等 |
|
| 🚀 | 单元测试 | 基于 JUnit + Mockito 实现单元测试,保证功能的正确性、代码的质量等 |
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -197,18 +197,19 @@
|
|||||||
|
|
||||||
### 微信公众号
|
### 微信公众号
|
||||||
|
|
||||||
| | 功能 | 描述 |
|
| | 功能 | 描述 |
|
||||||
| --- | --- | --- |
|
|----|--------|-------------------------------|
|
||||||
| 🚀 | 账号管理 | 配置接入的微信公众号,可支持多个公众号 |
|
| 🚀 | 账号管理 | 配置接入的微信公众号,可支持多个公众号 |
|
||||||
| 🚀 | 数据统计 | 统计公众号的用户增减、累计用户、消息概况、接口分析等数据 |
|
| 🚀 | 数据统计 | 统计公众号的用户增减、累计用户、消息概况、接口分析等数据 |
|
||||||
| 🚀 | 粉丝管理 | 查看已关注、取关的粉丝列表,可对粉丝进行同步、打标签等操作 |
|
| 🚀 | 粉丝管理 | 查看已关注、取关的粉丝列表,可对粉丝进行同步、打标签等操作 |
|
||||||
| 🚀 | 消息管理 | 查看粉丝发送的消息列表,可主动回复粉丝消息 |
|
| 🚀 | 消息管理 | 查看粉丝发送的消息列表,可主动回复粉丝消息 |
|
||||||
| 🚀 | 自动回复 | 自动回复粉丝发送的消息,支持关注回复、消息回复、关键字回复 |
|
| 🚀 | 模版消息 | 配置和发送模版消息,用于向粉丝推送通知类消息 |
|
||||||
| 🚀 | 标签管理 | 对公众号的标签进行创建、查询、修改、删除等操作 |
|
| 🚀 | 自动回复 | 自动回复粉丝发送的消息,支持关注回复、消息回复、关键字回复 |
|
||||||
| 🚀 | 菜单管理 | 自定义公众号的菜单,也可以从公众号同步菜单 |
|
| 🚀 | 标签管理 | 对公众号的标签进行创建、查询、修改、删除等操作 |
|
||||||
| 🚀 | 素材管理 | 管理公众号的图片、语音、视频等素材,支持在线播放语音、视频 |
|
| 🚀 | 菜单管理 | 自定义公众号的菜单,也可以从公众号同步菜单 |
|
||||||
| 🚀 | 图文草稿箱 | 新增常用的图文素材到草稿箱,可发布到公众号 |
|
| 🚀 | 素材管理 | 管理公众号的图片、语音、视频等素材,支持在线播放语音、视频 |
|
||||||
| 🚀 | 图文发表记录 | 查看已发布成功的图文素材,支持删除操作 |
|
| 🚀 | 图文草稿箱 | 新增常用的图文素材到草稿箱,可发布到公众号 |
|
||||||
|
| 🚀 | 图文发表记录 | 查看已发布成功的图文素材,支持删除操作 |
|
||||||
|
|
||||||
### 商城系统
|
### 商城系统
|
||||||
|
|
||||||
|
|||||||
@@ -62,13 +62,6 @@ export namespace MallSpuApi {
|
|||||||
valueName?: string; // 属性值名称
|
valueName?: string; // 属性值名称
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO @puhui999:这个还要么?
|
|
||||||
/** 优惠券模板 */
|
|
||||||
export interface GiveCouponTemplate {
|
|
||||||
id?: number; // 优惠券编号
|
|
||||||
name?: string; // 优惠券名称
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 商品状态更新请求 */
|
/** 商品状态更新请求 */
|
||||||
export interface SpuStatusUpdateReqVO {
|
export interface SpuStatusUpdateReqVO {
|
||||||
id: number; // 商品编号
|
id: number; // 商品编号
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import type { PageParam, PageResult } from '@vben/request';
|
import type { PageParam, PageResult } from '@vben/request';
|
||||||
|
|
||||||
import type { MallSpuApi } from '#/api/mall/product/spu';
|
|
||||||
|
|
||||||
import { requestClient } from '#/api/request';
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
export namespace MallBargainActivityApi {
|
export namespace MallBargainActivityApi {
|
||||||
@@ -32,17 +30,6 @@ export namespace MallBargainActivityApi {
|
|||||||
bargainMinPrice: number; // 砍价底价
|
bargainMinPrice: number; // 砍价底价
|
||||||
stock: number; // 活动库存
|
stock: number; // 活动库存
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO @puhui999:要不要删除?
|
|
||||||
/** 扩展 SKU 配置 */
|
|
||||||
export type SkuExtension = {
|
|
||||||
productConfig: BargainProduct; // 砍价活动配置
|
|
||||||
} & MallSpuApi.Sku;
|
|
||||||
|
|
||||||
/** 扩展 SPU 配置 */
|
|
||||||
export interface SpuExtension extends MallSpuApi.Spu {
|
|
||||||
skus: SkuExtension[]; // SKU 列表
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 查询砍价活动列表 */
|
/** 查询砍价活动列表 */
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import type { PageParam, PageResult } from '@vben/request';
|
import type { PageParam, PageResult } from '@vben/request';
|
||||||
|
|
||||||
import type { MallSpuApi } from '#/api/mall/product/spu';
|
|
||||||
|
|
||||||
import { requestClient } from '#/api/request';
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
export namespace MallCombinationActivityApi {
|
export namespace MallCombinationActivityApi {
|
||||||
@@ -25,23 +23,12 @@ export namespace MallCombinationActivityApi {
|
|||||||
products: CombinationProduct[]; // 商品列表
|
products: CombinationProduct[]; // 商品列表
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO @puhui999:要不要删除?
|
|
||||||
/** 拼团活动所需属性 */
|
/** 拼团活动所需属性 */
|
||||||
export interface CombinationProduct {
|
export interface CombinationProduct {
|
||||||
spuId: number; // 商品 SPU 编号
|
spuId: number; // 商品 SPU 编号
|
||||||
skuId: number; // 商品 SKU 编号
|
skuId: number; // 商品 SKU 编号
|
||||||
combinationPrice: number; // 拼团价格
|
combinationPrice: number; // 拼团价格
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 扩展 SKU 配置 */
|
|
||||||
export type SkuExtension = {
|
|
||||||
productConfig: CombinationProduct; // 拼团活动配置
|
|
||||||
} & MallSpuApi.Sku;
|
|
||||||
|
|
||||||
/** 扩展 SPU 配置 */
|
|
||||||
export interface SpuExtension extends MallSpuApi.Spu {
|
|
||||||
skus: SkuExtension[]; // SKU 列表
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 查询拼团活动列表 */
|
/** 查询拼团活动列表 */
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import type { PageParam, PageResult } from '@vben/request';
|
import type { PageParam, PageResult } from '@vben/request';
|
||||||
|
|
||||||
import type { MallSpuApi } from '#/api/mall/product/spu';
|
|
||||||
|
|
||||||
import { requestClient } from '#/api/request';
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
export namespace MallDiscountActivityApi {
|
export namespace MallDiscountActivityApi {
|
||||||
@@ -25,17 +23,6 @@ export namespace MallDiscountActivityApi {
|
|||||||
endTime?: Date; // 结束时间
|
endTime?: Date; // 结束时间
|
||||||
products?: DiscountProduct[]; // 商品列表
|
products?: DiscountProduct[]; // 商品列表
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO @puhui999:要不要删除?
|
|
||||||
/** 扩展 SKU 配置 */
|
|
||||||
export type SkuExtension = {
|
|
||||||
productConfig: DiscountProduct; // 限时折扣配置
|
|
||||||
} & MallSpuApi.Sku;
|
|
||||||
|
|
||||||
/** 扩展 SPU 配置 */
|
|
||||||
export interface SpuExtension extends MallSpuApi.Spu {
|
|
||||||
skus: SkuExtension[]; // SKU 列表
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 查询限时折扣活动列表 */
|
/** 查询限时折扣活动列表 */
|
||||||
|
|||||||
@@ -36,17 +36,6 @@ export namespace MallPointActivityApi {
|
|||||||
price: number; // 兑换金额,单位:分
|
price: number; // 兑换金额,单位:分
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO @puhui999:这些还需要么?
|
|
||||||
/** 扩展 SKU 配置 */
|
|
||||||
export type SkuExtension = {
|
|
||||||
productConfig: PointProduct; // 积分商城商品配置
|
|
||||||
} & MallSpuApi.Sku;
|
|
||||||
|
|
||||||
/** 扩展 SPU 配置 */
|
|
||||||
export interface SpuExtension extends MallSpuApi.Spu {
|
|
||||||
skus: SkuExtension[]; // SKU 列表
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 扩展 SPU 配置(带积分信息) */
|
/** 扩展 SPU 配置(带积分信息) */
|
||||||
export interface SpuExtensionWithPoint extends MallSpuApi.Spu {
|
export interface SpuExtensionWithPoint extends MallSpuApi.Spu {
|
||||||
pointStock: number; // 积分商城活动库存
|
pointStock: number; // 积分商城活动库存
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import type { PageParam, PageResult } from '@vben/request';
|
import type { PageParam, PageResult } from '@vben/request';
|
||||||
|
|
||||||
import type { MallSpuApi } from '#/api/mall/product/spu';
|
|
||||||
|
|
||||||
import { requestClient } from '#/api/request';
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
export namespace MallSeckillActivityApi {
|
export namespace MallSeckillActivityApi {
|
||||||
@@ -34,17 +32,6 @@ export namespace MallSeckillActivityApi {
|
|||||||
seckillPrice?: number; // 秒杀价格
|
seckillPrice?: number; // 秒杀价格
|
||||||
products?: SeckillProduct[]; // 秒杀商品列表
|
products?: SeckillProduct[]; // 秒杀商品列表
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO @puhui999:这些还需要么?
|
|
||||||
/** 扩展 SKU 配置 */
|
|
||||||
export type SkuExtension = {
|
|
||||||
productConfig: SeckillProduct; // 秒杀商品配置
|
|
||||||
} & MallSpuApi.Sku;
|
|
||||||
|
|
||||||
/** 扩展 SPU 配置 */
|
|
||||||
export interface SpuExtension extends MallSpuApi.Spu {
|
|
||||||
skus: SkuExtension[]; // SKU 列表
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 查询秒杀活动列表 */
|
/** 查询秒杀活动列表 */
|
||||||
|
|||||||
57
apps/web-antd/src/api/mp/messageTemplate/index.ts
Normal file
57
apps/web-antd/src/api/mp/messageTemplate/index.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
|
export namespace MpMessageTemplateApi {
|
||||||
|
/** 消息模板信息 */
|
||||||
|
export interface MessageTemplate {
|
||||||
|
id: number;
|
||||||
|
accountId: number;
|
||||||
|
appId: string;
|
||||||
|
templateId: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
example: string;
|
||||||
|
primaryIndustry: string;
|
||||||
|
deputyIndustry: string;
|
||||||
|
createTime?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发送消息模板请求 */
|
||||||
|
export interface MessageTemplateSendVO {
|
||||||
|
id: number;
|
||||||
|
userId: number;
|
||||||
|
data?: Record<string, string>;
|
||||||
|
url?: string;
|
||||||
|
miniProgramAppId?: string;
|
||||||
|
miniProgramPagePath?: string;
|
||||||
|
miniprogram?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询消息模板列表 */
|
||||||
|
export function getMessageTemplateList(params: { accountId: number }) {
|
||||||
|
return requestClient.get<MpMessageTemplateApi.MessageTemplate[]>(
|
||||||
|
'/mp/message-template/list',
|
||||||
|
{ params },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除消息模板 */
|
||||||
|
export function deleteMessageTemplate(id: number) {
|
||||||
|
return requestClient.delete('/mp/message-template/delete', {
|
||||||
|
params: { id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同步公众号模板 */
|
||||||
|
export function syncMessageTemplate(accountId: number) {
|
||||||
|
return requestClient.post('/mp/message-template/sync', null, {
|
||||||
|
params: { accountId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发送消息模板 */
|
||||||
|
export function sendMessageTemplate(
|
||||||
|
data: MpMessageTemplateApi.MessageTemplateSendVO,
|
||||||
|
) {
|
||||||
|
return requestClient.post('/mp/message-template/send', data);
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
|||||||
import type { MallCommentApi } from '#/api/mall/product/comment';
|
import type { MallCommentApi } from '#/api/mall/product/comment';
|
||||||
|
|
||||||
import { z } from '#/adapter/form';
|
import { z } from '#/adapter/form';
|
||||||
import { getSpuSimpleList } from '#/api/mall/product/spu';
|
|
||||||
import { getRangePickerDefaultProps } from '#/utils';
|
import { getRangePickerDefaultProps } from '#/utils';
|
||||||
|
|
||||||
/** 新增/修改的表单 */
|
/** 新增/修改的表单 */
|
||||||
@@ -17,19 +16,16 @@ export function useFormSchema(): VbenFormSchema[] {
|
|||||||
show: () => false,
|
show: () => false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// TODO @puhui999:商品的选择
|
|
||||||
{
|
{
|
||||||
fieldName: 'spuId',
|
fieldName: 'spuId',
|
||||||
label: '商品',
|
label: '商品',
|
||||||
component: 'ApiSelect',
|
component: 'Input',
|
||||||
componentProps: {
|
componentProps: {
|
||||||
api: getSpuSimpleList,
|
|
||||||
labelField: 'name',
|
|
||||||
valueField: 'id',
|
|
||||||
placeholder: '请选择商品',
|
placeholder: '请选择商品',
|
||||||
},
|
},
|
||||||
rules: 'required',
|
rules: 'required',
|
||||||
},
|
},
|
||||||
|
// TODO @puhui999:商品的选择:上面 spuId 可以选择了,下面的 skuId 打开后,没商品。
|
||||||
{
|
{
|
||||||
fieldName: 'skuId',
|
fieldName: 'skuId',
|
||||||
label: '商品规格',
|
label: '商品规格',
|
||||||
|
|||||||
@@ -1,21 +1,31 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { MallCommentApi } from '#/api/mall/product/comment';
|
import type { MallCommentApi } from '#/api/mall/product/comment';
|
||||||
|
import type { MallSpuApi } from '#/api/mall/product/spu';
|
||||||
|
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
import { useVbenModal } from '@vben/common-ui';
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
import { message } from 'ant-design-vue';
|
import { Button, message } from 'ant-design-vue';
|
||||||
|
|
||||||
import { useVbenForm } from '#/adapter/form';
|
import { useVbenForm } from '#/adapter/form';
|
||||||
import { createComment, getComment } from '#/api/mall/product/comment';
|
import { createComment, getComment } from '#/api/mall/product/comment';
|
||||||
|
import { getSpu } from '#/api/mall/product/spu';
|
||||||
import { $t } from '#/locales';
|
import { $t } from '#/locales';
|
||||||
|
import {
|
||||||
|
SkuTableSelect,
|
||||||
|
SpuShowcase,
|
||||||
|
} from '#/views/mall/product/spu/components';
|
||||||
|
|
||||||
import { useFormSchema } from '../data';
|
import { useFormSchema } from '../data';
|
||||||
|
|
||||||
const emit = defineEmits(['success']);
|
const emit = defineEmits(['success']);
|
||||||
|
|
||||||
const formData = ref<MallCommentApi.Comment>();
|
// 初始化 formData,确保始终有值
|
||||||
|
const formData = ref<Partial<MallCommentApi.Comment>>({
|
||||||
|
descriptionScores: 5,
|
||||||
|
benefitScores: 5,
|
||||||
|
});
|
||||||
const getTitle = computed(() => {
|
const getTitle = computed(() => {
|
||||||
return formData.value?.id
|
return formData.value?.id
|
||||||
? $t('ui.actionTitle.edit', ['虚拟评论'])
|
? $t('ui.actionTitle.edit', ['虚拟评论'])
|
||||||
@@ -35,6 +45,40 @@ const [Form, formApi] = useVbenForm({
|
|||||||
showDefaultActions: false,
|
showDefaultActions: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const skuTableSelectRef = ref<InstanceType<typeof SkuTableSelect>>();
|
||||||
|
const selectedSku = ref<MallSpuApi.Sku>();
|
||||||
|
|
||||||
|
/** 处理商品的选择变化 */
|
||||||
|
async function handleSpuChange(spu?: MallSpuApi.Spu | null) {
|
||||||
|
// 处理商品选择:如果 spu 为 null 或 id 为 0,表示清空选择
|
||||||
|
const spuId = spu?.id && spu.id ? spu.id : undefined;
|
||||||
|
formData.value.spuId = spuId;
|
||||||
|
await formApi.setFieldValue('spuId', spuId);
|
||||||
|
// 清空已选规格
|
||||||
|
selectedSku.value = undefined;
|
||||||
|
formData.value.skuId = undefined;
|
||||||
|
await formApi.setFieldValue('skuId', undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 打开商品规格的选择弹框 */
|
||||||
|
async function openSkuSelect() {
|
||||||
|
const currentValues =
|
||||||
|
(await formApi.getValues()) as Partial<MallCommentApi.Comment>;
|
||||||
|
const currentSpuId = currentValues.spuId ?? formData.value?.spuId;
|
||||||
|
if (!currentSpuId) {
|
||||||
|
message.warning('请先选择商品');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
skuTableSelectRef.value?.open({ spuId: currentSpuId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 处理商品规格的选择 */
|
||||||
|
async function handleSkuSelected(sku: MallSpuApi.Sku) {
|
||||||
|
selectedSku.value = sku;
|
||||||
|
formData.value.skuId = sku.id;
|
||||||
|
await formApi.setFieldValue('skuId', sku.id);
|
||||||
|
}
|
||||||
|
|
||||||
const [Modal, modalApi] = useVbenModal({
|
const [Modal, modalApi] = useVbenModal({
|
||||||
async onConfirm() {
|
async onConfirm() {
|
||||||
const { valid } = await formApi.validate();
|
const { valid } = await formApi.validate();
|
||||||
@@ -56,19 +100,44 @@ const [Modal, modalApi] = useVbenModal({
|
|||||||
},
|
},
|
||||||
async onOpenChange(isOpen: boolean) {
|
async onOpenChange(isOpen: boolean) {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
formData.value = undefined;
|
// 重置表单数据
|
||||||
|
// TODO @puhui999:105 到 108 的代码,不需要的呀?(可以测试下)
|
||||||
|
formData.value = {
|
||||||
|
descriptionScores: 5,
|
||||||
|
benefitScores: 5,
|
||||||
|
} as Partial<MallCommentApi.Comment>;
|
||||||
|
selectedSku.value = undefined;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 加载数据
|
// 加载数据
|
||||||
const data = modalApi.getData<MallCommentApi.Comment>();
|
const data = modalApi.getData<MallCommentApi.Comment>();
|
||||||
if (!data || !data.id) {
|
if (!data || !data.id) {
|
||||||
|
// TODO @puhui999:115 到 121 的代码,不需要的呀?(可以测试下)
|
||||||
|
// 新建模式:重置表单
|
||||||
|
formData.value = {
|
||||||
|
descriptionScores: 5,
|
||||||
|
benefitScores: 5,
|
||||||
|
} as Partial<MallCommentApi.Comment>;
|
||||||
|
selectedSku.value = undefined;
|
||||||
|
await formApi.setValues({ spuId: undefined, skuId: undefined });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 编辑模式:加载数据
|
||||||
modalApi.lock();
|
modalApi.lock();
|
||||||
try {
|
try {
|
||||||
formData.value = await getComment(data.id);
|
formData.value = await getComment(data.id);
|
||||||
// 设置到 values
|
// 设置到 values
|
||||||
await formApi.setValues(formData.value);
|
await formApi.setValues(formData.value);
|
||||||
|
// 回显已选的商品规格
|
||||||
|
if (formData.value?.spuId && formData.value?.skuId) {
|
||||||
|
const spu = await getSpu(formData.value.spuId);
|
||||||
|
const sku = spu.skus?.find((item) => item.id === formData.value!.skuId);
|
||||||
|
if (sku) {
|
||||||
|
selectedSku.value = sku;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
selectedSku.value = undefined;
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
modalApi.unlock();
|
modalApi.unlock();
|
||||||
}
|
}
|
||||||
@@ -78,6 +147,37 @@ const [Modal, modalApi] = useVbenModal({
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Modal class="w-2/5" :title="getTitle">
|
<Modal class="w-2/5" :title="getTitle">
|
||||||
<Form class="mx-4" />
|
<Form class="mx-4">
|
||||||
|
<template #spuId>
|
||||||
|
<SpuShowcase
|
||||||
|
v-model="(formData as any).spuId"
|
||||||
|
:limit="1"
|
||||||
|
@change="handleSpuChange"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #skuId>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
:disabled="!formData?.spuId"
|
||||||
|
@click="openSkuSelect"
|
||||||
|
>
|
||||||
|
选择规格
|
||||||
|
</Button>
|
||||||
|
<span
|
||||||
|
v-if="
|
||||||
|
selectedSku &&
|
||||||
|
selectedSku.properties &&
|
||||||
|
selectedSku.properties.length > 0
|
||||||
|
"
|
||||||
|
>
|
||||||
|
已选:
|
||||||
|
{{ selectedSku.properties.map((p: any) => p.valueName).join('/') }}
|
||||||
|
</span>
|
||||||
|
<span v-else-if="selectedSku">已选:{{ selectedSku.id }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Form>
|
||||||
|
<SkuTableSelect ref="skuTableSelectRef" @change="handleSkuSelected" />
|
||||||
</Modal>
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
export * from './property-util';
|
||||||
|
export { default as SkuList } from './sku-list.vue';
|
||||||
export { default as SkuTableSelect } from './sku-table-select.vue';
|
export { default as SkuTableSelect } from './sku-table-select.vue';
|
||||||
|
export { default as SpuAndSkuList } from './spu-and-sku-list.vue';
|
||||||
|
export { default as SpuSkuSelect } from './spu-select.vue';
|
||||||
export { default as SpuShowcase } from './spu-showcase.vue';
|
export { default as SpuShowcase } from './spu-showcase.vue';
|
||||||
export { default as SpuTableSelect } from './spu-table-select.vue';
|
export { default as SpuTableSelect } from './spu-table-select.vue';
|
||||||
|
export * from './type';
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
|
import type { MallSpuApi } from '#/api/mall/product/spu';
|
||||||
|
import type { PropertyAndValues } from '#/views/mall/product/spu/components/type';
|
||||||
|
|
||||||
|
/** 获得商品的规格列表 - 商品相关的公共函数(被其它模块如 promotion 使用) */
|
||||||
|
const getPropertyList = (spu: MallSpuApi.Spu): PropertyAndValues[] => {
|
||||||
|
// 直接拿返回的 skus 属性逆向生成出 propertyList
|
||||||
|
const properties: PropertyAndValues[] = [];
|
||||||
|
// 只有是多规格才处理
|
||||||
|
if (spu.specType) {
|
||||||
|
spu.skus?.forEach((sku) => {
|
||||||
|
sku.properties?.forEach(
|
||||||
|
({ propertyId, propertyName, valueId, valueName }) => {
|
||||||
|
// 添加属性
|
||||||
|
if (!properties?.some((item) => item.id === propertyId)) {
|
||||||
|
properties.push({
|
||||||
|
id: propertyId!,
|
||||||
|
name: propertyName!,
|
||||||
|
values: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 添加属性值
|
||||||
|
const index = properties?.findIndex((item) => item.id === propertyId);
|
||||||
|
if (
|
||||||
|
!properties[index]?.values?.some((value) => value.id === valueId)
|
||||||
|
) {
|
||||||
|
properties[index]?.values?.push({ id: valueId!, name: valueName! });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return properties;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { getPropertyList };
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { Ref } from 'vue';
|
import type { Ref } from 'vue';
|
||||||
|
|
||||||
import type { PropertyAndValues, RuleConfig } from '../index';
|
|
||||||
|
|
||||||
import type { MallSpuApi } from '#/api/mall/product/spu';
|
import type { MallSpuApi } from '#/api/mall/product/spu';
|
||||||
|
import type {
|
||||||
|
PropertyAndValues,
|
||||||
|
RuleConfig,
|
||||||
|
} from '#/views/mall/product/spu/components';
|
||||||
|
|
||||||
import { ref, watch } from 'vue';
|
import { ref, watch } from 'vue';
|
||||||
|
|
||||||
@@ -463,7 +465,7 @@ defineExpose({
|
|||||||
@checkbox-change="handleSelectionChange"
|
@checkbox-change="handleSelectionChange"
|
||||||
@checkbox-all="handleSelectionChange"
|
@checkbox-all="handleSelectionChange"
|
||||||
>
|
>
|
||||||
<VxeColumn v-if="isComponent" type="checkbox" width="45" />
|
<VxeColumn v-if="isComponent" type="checkbox" width="45" fixed="left" />
|
||||||
<VxeColumn align="center" title="图片" max-width="140" fixed="left">
|
<VxeColumn align="center" title="图片" max-width="140" fixed="left">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<Image
|
<Image
|
||||||
@@ -3,11 +3,12 @@
|
|||||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||||
import type { MallSpuApi } from '#/api/mall/product/spu';
|
import type { MallSpuApi } from '#/api/mall/product/spu';
|
||||||
|
|
||||||
import { computed, ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
|
|
||||||
import { useVbenModal } from '@vben/common-ui';
|
|
||||||
import { fenToYuan } from '@vben/utils';
|
import { fenToYuan } from '@vben/utils';
|
||||||
|
|
||||||
|
import { Modal } from 'ant-design-vue';
|
||||||
|
|
||||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
import { getSpu } from '#/api/mall/product/spu';
|
import { getSpu } from '#/api/mall/product/spu';
|
||||||
|
|
||||||
@@ -19,10 +20,11 @@ const emit = defineEmits<{
|
|||||||
change: [sku: MallSpuApi.Sku];
|
change: [sku: MallSpuApi.Sku];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const visible = ref(false);
|
||||||
const spuId = ref<number>();
|
const spuId = ref<number>();
|
||||||
|
|
||||||
/** 表格列配置 */
|
/** 表格列配置 */
|
||||||
const gridColumns = computed<VxeGridProps['columns']>(() => [
|
const gridColumns: VxeGridProps['columns'] = [
|
||||||
{
|
{
|
||||||
type: 'radio',
|
type: 'radio',
|
||||||
width: 55,
|
width: 55,
|
||||||
@@ -57,27 +59,35 @@ const gridColumns = computed<VxeGridProps['columns']>(() => [
|
|||||||
return fenToYuan(cellValue);
|
return fenToYuan(cellValue);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
];
|
||||||
|
|
||||||
// TODO @芋艿:要不要直接非 pager?
|
|
||||||
const [Grid, gridApi] = useVbenVxeGrid({
|
const [Grid, gridApi] = useVbenVxeGrid({
|
||||||
gridOptions: {
|
gridOptions: {
|
||||||
columns: gridColumns.value,
|
columns: gridColumns,
|
||||||
height: 400,
|
height: 400,
|
||||||
border: true,
|
border: true,
|
||||||
showOverflow: true,
|
showOverflow: true,
|
||||||
radioConfig: {
|
radioConfig: {
|
||||||
reserve: true,
|
reserve: true,
|
||||||
},
|
},
|
||||||
|
rowConfig: {
|
||||||
|
keyField: 'id',
|
||||||
|
isHover: true,
|
||||||
|
},
|
||||||
|
pagerConfig: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
proxyConfig: {
|
proxyConfig: {
|
||||||
|
// TODO @puhui999:看看注释的部分,后续要不要删除
|
||||||
|
// autoLoad: false, // 禁用自动加载,手动触发查询
|
||||||
ajax: {
|
ajax: {
|
||||||
query: async () => {
|
query: async () => {
|
||||||
if (!spuId.value) {
|
if (!spuId.value) {
|
||||||
return { items: [], total: 0 };
|
return { list: [], total: 0 };
|
||||||
}
|
}
|
||||||
const spu = await getSpu(spuId.value);
|
const spu = await getSpu(spuId.value);
|
||||||
return {
|
return {
|
||||||
items: spu.skus || [],
|
list: spu.skus || [],
|
||||||
total: spu.skus?.length || 0,
|
total: spu.skus?.length || 0,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -85,39 +95,56 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
gridEvents: {
|
gridEvents: {
|
||||||
radioChange: handleRadioChange,
|
radioChange: () => {
|
||||||
|
const selectedRow = gridApi.grid.getRadioRecord() as MallSpuApi.Sku;
|
||||||
|
if (selectedRow) {
|
||||||
|
emit('change', selectedRow);
|
||||||
|
// 关闭弹窗
|
||||||
|
visible.value = false;
|
||||||
|
gridApi.grid.clearRadioRow();
|
||||||
|
spuId.value = undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 处理选中 */
|
/** 关闭弹窗 */
|
||||||
function handleRadioChange() {
|
function closeModal() {
|
||||||
const selectedRow = gridApi.grid.getRadioRecord() as MallSpuApi.Sku;
|
visible.value = false;
|
||||||
if (selectedRow) {
|
gridApi.grid.clearRadioRow();
|
||||||
emit('change', selectedRow);
|
spuId.value = undefined;
|
||||||
modalApi.close();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const [Modal, modalApi] = useVbenModal({
|
/** 打开弹窗 */
|
||||||
destroyOnClose: true,
|
async function openModal(data?: SpuData) {
|
||||||
onOpenChange: async (isOpen: boolean) => {
|
if (!data?.spuId) {
|
||||||
if (!isOpen) {
|
return;
|
||||||
gridApi.grid.clearRadioRow();
|
}
|
||||||
spuId.value = undefined;
|
spuId.value = data.spuId;
|
||||||
return;
|
visible.value = true;
|
||||||
}
|
// TODO @puhui999:看看注释的部分,后续要不要删除
|
||||||
const data = modalApi.getData<SpuData>();
|
// // 等待弹窗和 Grid 组件完全渲染后再查询数据
|
||||||
if (!data?.spuId) {
|
// await nextTick();
|
||||||
return;
|
// if (gridApi.grid) {
|
||||||
}
|
// await gridApi.query();
|
||||||
spuId.value = data.spuId;
|
// }
|
||||||
await gridApi.query();
|
}
|
||||||
},
|
|
||||||
|
/** 对外暴露的方法 */
|
||||||
|
defineExpose({
|
||||||
|
open: openModal,
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Modal class="w-[700px]" title="选择规格">
|
<Modal
|
||||||
|
v-model:open="visible"
|
||||||
|
title="选择规格"
|
||||||
|
width="700px"
|
||||||
|
:destroy-on-close="true"
|
||||||
|
:footer="null"
|
||||||
|
@cancel="closeModal"
|
||||||
|
>
|
||||||
<Grid />
|
<Grid />
|
||||||
</Modal>
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
<script generic="T extends MallSpuApi.Spu" lang="ts" setup>
|
||||||
|
import type { MallSpuApi, RuleConfig, SpuProperty } from './type';
|
||||||
|
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { confirm } from '@vben/common-ui';
|
||||||
|
import { formatToFraction } from '@vben/utils';
|
||||||
|
|
||||||
|
import { Button, Image } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { VxeColumn, VxeTable } from '#/adapter/vxe-table';
|
||||||
|
|
||||||
|
import SkuList from './sku-list.vue';
|
||||||
|
|
||||||
|
defineOptions({ name: 'PromotionSpuAndSkuList' });
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
deletable?: boolean; // SPU 是否可删除
|
||||||
|
ruleConfig: RuleConfig[];
|
||||||
|
spuList: T[];
|
||||||
|
spuPropertyListP: SpuProperty<T>[];
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
deletable: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'delete', spuId: number): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const spuData = ref<MallSpuApi.Spu[]>([]); // spu 详情数据列表
|
||||||
|
const skuListRef = ref<InstanceType<typeof SkuList> | undefined>(); // 商品属性列表Ref
|
||||||
|
const spuPropertyList = ref<SpuProperty<T>[]>([]); // spuId 对应的 sku 的属性列表
|
||||||
|
const expandRowKeys = ref<string[]>([]); // 控制展开行需要设置 row-key 属性才能使用,该属性为展开行的 keys 数组。
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有 sku 活动配置
|
||||||
|
*
|
||||||
|
* @param extendedAttribute 在 sku 上扩展的属性,例:秒杀活动 sku 扩展属性 productConfig 请参考 seckillActivity.ts
|
||||||
|
*/
|
||||||
|
function getSkuConfigs(extendedAttribute: string) {
|
||||||
|
// 验证 SKU 数据(如果有 ref 的话)
|
||||||
|
if (skuListRef.value) {
|
||||||
|
skuListRef.value.validateSku();
|
||||||
|
}
|
||||||
|
const seckillProducts: unknown[] = [];
|
||||||
|
spuPropertyList.value.forEach((item) => {
|
||||||
|
item.spuDetail.skus?.forEach((sku) => {
|
||||||
|
const extendedValue = (sku as Record<string, unknown>)[extendedAttribute];
|
||||||
|
if (extendedValue) {
|
||||||
|
seckillProducts.push(extendedValue);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return seckillProducts;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ getSkuConfigs }); // 暴露出给表单提交时使用
|
||||||
|
|
||||||
|
/** 多选时可以删除 SPU */
|
||||||
|
async function deleteSpu(spuId: number) {
|
||||||
|
await confirm(`是否删除商品编号为${spuId}的数据?`);
|
||||||
|
const index = spuData.value.findIndex((item) => item.id === spuId);
|
||||||
|
if (index !== -1) {
|
||||||
|
spuData.value.splice(index, 1);
|
||||||
|
emit('delete', spuId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将传进来的值赋值给 spuData */
|
||||||
|
watch(
|
||||||
|
() => props.spuList,
|
||||||
|
(data) => {
|
||||||
|
if (!data) return;
|
||||||
|
spuData.value = data as MallSpuApi.Spu[];
|
||||||
|
},
|
||||||
|
{
|
||||||
|
deep: true,
|
||||||
|
immediate: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 将传进来的值赋值给 spuPropertyList */
|
||||||
|
watch(
|
||||||
|
() => props.spuPropertyListP,
|
||||||
|
(data) => {
|
||||||
|
if (!data) return;
|
||||||
|
spuPropertyList.value = data as SpuProperty<T>[];
|
||||||
|
// 解决如果之前选择的是单规格 spu 的话后面选择多规格 sku 多规格属性信息不展示的问题。解决方法:让 SkuList 组件重新渲染(行折叠会干掉包含的组件展开时会重新加载)
|
||||||
|
setTimeout(() => {
|
||||||
|
expandRowKeys.value = data.map((item) => String(item.spuId));
|
||||||
|
}, 200);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
deep: true,
|
||||||
|
immediate: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VxeTable
|
||||||
|
:data="spuData"
|
||||||
|
:expand-row-keys="expandRowKeys"
|
||||||
|
:row-config="{
|
||||||
|
keyField: 'id',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<VxeColumn type="expand" width="30">
|
||||||
|
<template #content="{ row }">
|
||||||
|
<SkuList
|
||||||
|
ref="skuListRef"
|
||||||
|
:is-activity-component="true"
|
||||||
|
:prop-form-data="
|
||||||
|
spuPropertyList.find((item) => item.spuId === row.id)?.spuDetail
|
||||||
|
"
|
||||||
|
:property-list="
|
||||||
|
spuPropertyList.find((item) => item.spuId === row.id)?.propertyList
|
||||||
|
"
|
||||||
|
:rule-config="ruleConfig"
|
||||||
|
>
|
||||||
|
<template #extension>
|
||||||
|
<slot></slot>
|
||||||
|
</template>
|
||||||
|
</SkuList>
|
||||||
|
</template>
|
||||||
|
</VxeColumn>
|
||||||
|
<VxeColumn field="id" align="center" title="商品编号" />
|
||||||
|
<VxeColumn title="商品图" min-width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<Image
|
||||||
|
v-if="row.picUrl"
|
||||||
|
:src="row.picUrl"
|
||||||
|
class="h-[30px] w-[30px] cursor-pointer"
|
||||||
|
:preview="true"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</VxeColumn>
|
||||||
|
<VxeColumn
|
||||||
|
field="name"
|
||||||
|
title="商品名称"
|
||||||
|
min-width="300"
|
||||||
|
show-overflow="tooltip"
|
||||||
|
/>
|
||||||
|
<VxeColumn align="center" title="商品售价" min-width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatToFraction(row.price) }}
|
||||||
|
</template>
|
||||||
|
</VxeColumn>
|
||||||
|
<VxeColumn field="salesCount" align="center" title="销量" min-width="90" />
|
||||||
|
<VxeColumn field="stock" align="center" title="库存" min-width="90" />
|
||||||
|
<VxeColumn
|
||||||
|
v-if="spuData.length > 1 && deletable"
|
||||||
|
align="center"
|
||||||
|
title="操作"
|
||||||
|
min-width="90"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<Button type="link" danger @click="deleteSpu(row.id)"> 删除</Button>
|
||||||
|
</template>
|
||||||
|
</VxeColumn>
|
||||||
|
</VxeTable>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import type { Ref } from 'vue';
|
||||||
|
|
||||||
|
import type { VbenFormSchema } from '#/adapter/form';
|
||||||
|
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||||
|
import type { MallCategoryApi } from '#/api/mall/product/category';
|
||||||
|
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import { getRangePickerDefaultProps } from '#/utils';
|
||||||
|
|
||||||
|
/** 列表的搜索表单 */
|
||||||
|
export function useGridFormSchema(
|
||||||
|
categoryTreeList: Ref<MallCategoryApi.Category[] | unknown[]>,
|
||||||
|
): VbenFormSchema[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
fieldName: 'name',
|
||||||
|
label: '商品名称',
|
||||||
|
component: 'Input',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入商品名称',
|
||||||
|
allowClear: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'categoryId',
|
||||||
|
label: '商品分类',
|
||||||
|
component: 'TreeSelect',
|
||||||
|
componentProps: {
|
||||||
|
treeData: computed(() => categoryTreeList.value),
|
||||||
|
fieldNames: {
|
||||||
|
label: 'name',
|
||||||
|
value: 'id',
|
||||||
|
},
|
||||||
|
treeCheckStrictly: true,
|
||||||
|
placeholder: '请选择商品分类',
|
||||||
|
allowClear: true,
|
||||||
|
showSearch: true,
|
||||||
|
treeNodeFilterProp: 'name',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'createTime',
|
||||||
|
label: '创建时间',
|
||||||
|
component: 'RangePicker',
|
||||||
|
componentProps: {
|
||||||
|
...getRangePickerDefaultProps(),
|
||||||
|
allowClear: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表的字段 */
|
||||||
|
export function useGridColumns(
|
||||||
|
isSelectSku: boolean,
|
||||||
|
): VxeTableGridOptions['columns'] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'expand',
|
||||||
|
width: 30,
|
||||||
|
visible: isSelectSku,
|
||||||
|
slots: { content: 'expand_content' },
|
||||||
|
},
|
||||||
|
{ type: 'checkbox', width: 55 },
|
||||||
|
{
|
||||||
|
field: 'id',
|
||||||
|
title: '商品编号',
|
||||||
|
minWidth: 100,
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'picUrl',
|
||||||
|
title: '商品图',
|
||||||
|
width: 100,
|
||||||
|
align: 'center',
|
||||||
|
cellRender: {
|
||||||
|
name: 'CellImage',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'name',
|
||||||
|
title: '商品名称',
|
||||||
|
minWidth: 300,
|
||||||
|
showOverflow: 'tooltip',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'price',
|
||||||
|
title: '商品售价',
|
||||||
|
minWidth: 90,
|
||||||
|
align: 'center',
|
||||||
|
formatter: 'formatAmount2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'salesCount',
|
||||||
|
title: '销量',
|
||||||
|
minWidth: 90,
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'stock',
|
||||||
|
title: '库存',
|
||||||
|
minWidth: 90,
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'sort',
|
||||||
|
title: '排序',
|
||||||
|
minWidth: 70,
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'createTime',
|
||||||
|
title: '创建时间',
|
||||||
|
width: 180,
|
||||||
|
align: 'center',
|
||||||
|
formatter: 'formatDateTime',
|
||||||
|
},
|
||||||
|
] as VxeTableGridOptions['columns'];
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { PropertyAndValues } from './type';
|
||||||
|
|
||||||
|
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||||
|
import type { MallCategoryApi } from '#/api/mall/product/category';
|
||||||
|
import type { MallSpuApi } from '#/api/mall/product/spu';
|
||||||
|
|
||||||
|
import { computed, nextTick, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
import { handleTree } from '@vben/utils';
|
||||||
|
|
||||||
|
import { message, Modal } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
|
import { getCategoryList } from '#/api/mall/product/category';
|
||||||
|
import { getSpu, getSpuPage } from '#/api/mall/product/spu';
|
||||||
|
|
||||||
|
import { getPropertyList } from './property-util';
|
||||||
|
import SkuList from './sku-list.vue';
|
||||||
|
import { useGridColumns, useGridFormSchema } from './spu-select-data';
|
||||||
|
|
||||||
|
defineOptions({ name: 'SpuSelect' });
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<SpuSelectProps>(), {
|
||||||
|
isSelectSku: false,
|
||||||
|
radio: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'select', spuId: number, skuIds?: number[]): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
interface SpuSelectProps {
|
||||||
|
// 默认不需要(不需要的情况下只返回 spu,需要的情况下返回 选中的 spu 和 sku 列表)
|
||||||
|
// 其它活动需要选择商品和商品属性导入此组件即可,需添加组件属性 :isSelectSku='true'
|
||||||
|
isSelectSku?: boolean; // 是否需要选择 sku 属性
|
||||||
|
radio?: boolean; // 是否单选 sku
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 数据状态 ============
|
||||||
|
const categoryList = ref<MallCategoryApi.Category[]>([]); // 分类列表
|
||||||
|
const categoryTreeList = ref<MallCategoryApi.Category[]>([]); // 分类树
|
||||||
|
const propertyList = ref<PropertyAndValues[]>([]); // 商品属性列表
|
||||||
|
const spuData = ref<MallSpuApi.Spu>(); // 当前展开的商品详情
|
||||||
|
const isExpand = ref(false); // 控制 SKU 列表显示
|
||||||
|
|
||||||
|
// ============ 商品选择相关 ============
|
||||||
|
const selectedSpuId = ref<number>(0); // 选中的商品 spuId
|
||||||
|
const selectedSkuIds = ref<number[]>([]); // 选中的商品 skuIds
|
||||||
|
const skuListRef = ref<InstanceType<typeof SkuList>>(); // 商品属性选择 Ref
|
||||||
|
|
||||||
|
/** 处理 SKU 选择变化 */
|
||||||
|
function selectSku(val: MallSpuApi.Sku[]) {
|
||||||
|
const skuTable = skuListRef.value?.getSkuTableRef();
|
||||||
|
if (selectedSpuId.value === 0) {
|
||||||
|
message.warning('请先选择商品再选择相应的规格!!!');
|
||||||
|
skuTable?.clearSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (val.length === 0) {
|
||||||
|
selectedSkuIds.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (props.radio) {
|
||||||
|
// 只选择一个
|
||||||
|
const firstId = val[0]?.id;
|
||||||
|
if (firstId !== undefined) {
|
||||||
|
selectedSkuIds.value = [firstId];
|
||||||
|
}
|
||||||
|
// 如果大于1个
|
||||||
|
if (val.length > 1) {
|
||||||
|
// 清空选择
|
||||||
|
skuTable?.clearSelection();
|
||||||
|
// 变更为最后一次选择的
|
||||||
|
const lastItem = val.pop();
|
||||||
|
if (lastItem) {
|
||||||
|
skuTable?.toggleRowSelection(lastItem, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
selectedSkuIds.value = val
|
||||||
|
.map((sku) => sku.id!)
|
||||||
|
.filter((id): id is number => id !== undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 处理 SPU 选择变化 */
|
||||||
|
function selectSpu(row: MallSpuApi.Spu) {
|
||||||
|
if (!row) {
|
||||||
|
selectedSpuId.value = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectedSpuId.value = row.id!;
|
||||||
|
|
||||||
|
// 切换选择 spu 如果有选择的 sku 则清空,确保选择的 sku 是对应的 spu 下面的
|
||||||
|
if (selectedSkuIds.value.length > 0) {
|
||||||
|
selectedSkuIds.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 处理行展开变化 */
|
||||||
|
async function expandChange(
|
||||||
|
row: MallSpuApi.Spu,
|
||||||
|
expandedRows?: MallSpuApi.Spu[],
|
||||||
|
) {
|
||||||
|
// 判断需要展开的 spuId === 选择的 spuId。如果选择了 A 就展开 A 的 skuList。如果选择了 A 手动展开 B 则阻断
|
||||||
|
// 目的:防止误选 sku
|
||||||
|
if (selectedSpuId.value !== 0) {
|
||||||
|
if (row.id !== selectedSpuId.value) {
|
||||||
|
message.warning('你已选择商品请先取消');
|
||||||
|
// 阻止展开,通过重新设置展开状态来保持当前选中行的展开
|
||||||
|
if (row.id !== undefined) {
|
||||||
|
const tableData = gridApi.grid.getTableData().fullData;
|
||||||
|
const selectedRow = tableData.find(
|
||||||
|
(item: MallSpuApi.Spu) => item.id === selectedSpuId.value,
|
||||||
|
);
|
||||||
|
if (selectedRow) {
|
||||||
|
// 关闭当前行,重新展开选中行
|
||||||
|
gridApi.grid.setRowExpand(selectedRow, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 如果已展开 skuList 则选择此对应的 spu 不需要重新获取渲染 skuList
|
||||||
|
if (isExpand.value && spuData.value?.id === row.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
spuData.value = undefined;
|
||||||
|
propertyList.value = [];
|
||||||
|
isExpand.value = false;
|
||||||
|
if (expandedRows?.length === 0) {
|
||||||
|
// 如果展开个数为 0,直接返回
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 获取 SPU 详情
|
||||||
|
if (row.id === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = (await getSpu(row.id)) as MallSpuApi.Spu;
|
||||||
|
res.skus?.forEach((item) => {
|
||||||
|
if (typeof item.price === 'number') {
|
||||||
|
item.price = Math.round(item.price * 100);
|
||||||
|
}
|
||||||
|
if (typeof item.marketPrice === 'number') {
|
||||||
|
item.marketPrice = Math.round(item.marketPrice * 100);
|
||||||
|
}
|
||||||
|
if (typeof item.costPrice === 'number') {
|
||||||
|
item.costPrice = Math.round(item.costPrice * 100);
|
||||||
|
}
|
||||||
|
if (typeof item.firstBrokeragePrice === 'number') {
|
||||||
|
item.firstBrokeragePrice = Math.round(item.firstBrokeragePrice * 100);
|
||||||
|
}
|
||||||
|
if (typeof item.secondBrokeragePrice === 'number') {
|
||||||
|
item.secondBrokeragePrice = Math.round(item.secondBrokeragePrice * 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
propertyList.value = getPropertyList(res);
|
||||||
|
spuData.value = res;
|
||||||
|
isExpand.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formSchema = computed(() => useGridFormSchema(categoryTreeList)); // 搜索表单 Schema
|
||||||
|
const gridColumns = computed<VxeTableGridOptions['columns']>(() => {
|
||||||
|
const columns = useGridColumns(props.isSelectSku);
|
||||||
|
// 将 checkbox 替换为 radio
|
||||||
|
return columns?.map((col) => {
|
||||||
|
if (col.type === 'checkbox') {
|
||||||
|
return { ...col, type: 'radio' };
|
||||||
|
}
|
||||||
|
return col;
|
||||||
|
});
|
||||||
|
}); // 表格列配置
|
||||||
|
|
||||||
|
const [Grid, gridApi] = useVbenVxeGrid({
|
||||||
|
formOptions: {
|
||||||
|
schema: formSchema.value,
|
||||||
|
layout: 'horizontal',
|
||||||
|
collapsed: false,
|
||||||
|
},
|
||||||
|
gridOptions: {
|
||||||
|
columns: gridColumns.value,
|
||||||
|
height: 800,
|
||||||
|
border: true,
|
||||||
|
radioConfig: {
|
||||||
|
reserve: true,
|
||||||
|
highlight: true,
|
||||||
|
},
|
||||||
|
rowConfig: {
|
||||||
|
keyField: 'id',
|
||||||
|
isHover: true,
|
||||||
|
},
|
||||||
|
// TODO @puhui999:貌似直接 { trigger: 'row', reserve: true } 就可以了?不会影响 radio 的哈。(可以测试下。)
|
||||||
|
expandConfig: props.isSelectSku
|
||||||
|
? {
|
||||||
|
trigger: 'row',
|
||||||
|
reserve: true,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
proxyConfig: {
|
||||||
|
ajax: {
|
||||||
|
async query({ page }: any, formValues: any) {
|
||||||
|
return await getSpuPage({
|
||||||
|
pageNo: page.currentPage,
|
||||||
|
pageSize: page.pageSize,
|
||||||
|
tabType: 0,
|
||||||
|
...formValues,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
gridEvents: {
|
||||||
|
radioChange: ({ row, $grid }: { $grid: any; row: MallSpuApi.Spu }) => {
|
||||||
|
selectSpu(row);
|
||||||
|
if (props.isSelectSku) {
|
||||||
|
$grid.clearRowExpand();
|
||||||
|
$grid.setRowExpand(row, true);
|
||||||
|
expandChange(row, [row]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
toggleRowExpand: ({
|
||||||
|
row,
|
||||||
|
expanded,
|
||||||
|
}: {
|
||||||
|
expanded: boolean;
|
||||||
|
row: unknown;
|
||||||
|
}) => {
|
||||||
|
if (expanded) {
|
||||||
|
expandChange(row as MallSpuApi.Spu, [row as MallSpuApi.Spu]);
|
||||||
|
} else {
|
||||||
|
expandChange(row as MallSpuApi.Spu, []);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const visible = ref(false); // 弹窗显示状态
|
||||||
|
|
||||||
|
/** 打开弹窗 */
|
||||||
|
async function openModal() {
|
||||||
|
visible.value = true;
|
||||||
|
// 等待 Grid 组件完全初始化后再查询数据
|
||||||
|
await nextTick();
|
||||||
|
if (gridApi.grid) {
|
||||||
|
await gridApi.query();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关闭弹窗 */
|
||||||
|
function closeModal() {
|
||||||
|
visible.value = false;
|
||||||
|
selectedSpuId.value = 0;
|
||||||
|
selectedSkuIds.value = [];
|
||||||
|
spuData.value = undefined;
|
||||||
|
propertyList.value = [];
|
||||||
|
isExpand.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 确认选择 */
|
||||||
|
function handleConfirm() {
|
||||||
|
if (selectedSpuId.value === 0) {
|
||||||
|
message.warning('没有选择任何商品');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (props.isSelectSku && selectedSkuIds.value.length === 0) {
|
||||||
|
message.warning('没有选择任何商品属性');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 返回各自 id 列表
|
||||||
|
props.isSelectSku
|
||||||
|
? emit('select', selectedSpuId.value, selectedSkuIds.value)
|
||||||
|
: emit('select', selectedSpuId.value);
|
||||||
|
|
||||||
|
// 重置选中状态
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对外暴露的方法 */
|
||||||
|
defineExpose({
|
||||||
|
open: openModal,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 初始化分类数据 */
|
||||||
|
onMounted(async () => {
|
||||||
|
categoryList.value = await getCategoryList({});
|
||||||
|
categoryTreeList.value = handleTree(
|
||||||
|
categoryList.value,
|
||||||
|
'id',
|
||||||
|
'parentId',
|
||||||
|
) as MallCategoryApi.Category[];
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal
|
||||||
|
v-model:open="visible"
|
||||||
|
title="商品选择"
|
||||||
|
width="70%"
|
||||||
|
:destroy-on-close="true"
|
||||||
|
@ok="handleConfirm"
|
||||||
|
@cancel="closeModal"
|
||||||
|
>
|
||||||
|
<Grid>
|
||||||
|
<!-- 展开列内容(SKU 列表) -->
|
||||||
|
<template v-if="isSelectSku" #expand_content="{ row }">
|
||||||
|
<SkuList
|
||||||
|
v-if="isExpand && spuData?.id === row.id"
|
||||||
|
ref="skuListRef"
|
||||||
|
:is-component="true"
|
||||||
|
:is-detail="true"
|
||||||
|
:prop-form-data="spuData"
|
||||||
|
:property-list="propertyList"
|
||||||
|
@selection-change="selectSku"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Grid>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
@@ -5,11 +5,12 @@ import type { VxeGridProps } from '#/adapter/vxe-table';
|
|||||||
import type { MallCategoryApi } from '#/api/mall/product/category';
|
import type { MallCategoryApi } from '#/api/mall/product/category';
|
||||||
import type { MallSpuApi } from '#/api/mall/product/spu';
|
import type { MallSpuApi } from '#/api/mall/product/spu';
|
||||||
|
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, nextTick, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
import { useVbenModal } from '@vben/common-ui';
|
|
||||||
import { handleTree } from '@vben/utils';
|
import { handleTree } from '@vben/utils';
|
||||||
|
|
||||||
|
import { Modal } from 'ant-design-vue';
|
||||||
|
|
||||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
import { getCategoryList } from '#/api/mall/product/category';
|
import { getCategoryList } from '#/api/mall/product/category';
|
||||||
import { getSpuPage } from '#/api/mall/product/spu';
|
import { getSpuPage } from '#/api/mall/product/spu';
|
||||||
@@ -30,12 +31,16 @@ const emit = defineEmits<{
|
|||||||
const categoryList = ref<MallCategoryApi.Category[]>([]); // 分类列表
|
const categoryList = ref<MallCategoryApi.Category[]>([]); // 分类列表
|
||||||
const categoryTreeList = ref<any[]>([]); // 分类树
|
const categoryTreeList = ref<any[]>([]); // 分类树
|
||||||
|
|
||||||
|
/** 弹窗显示状态 */
|
||||||
|
const visible = ref(false);
|
||||||
|
const initData = ref<MallSpuApi.Spu | MallSpuApi.Spu[]>();
|
||||||
|
|
||||||
/** 单选:处理选中变化 */
|
/** 单选:处理选中变化 */
|
||||||
function handleRadioChange() {
|
function handleRadioChange() {
|
||||||
const selectedRow = gridApi.grid.getRadioRecord() as MallSpuApi.Spu;
|
const selectedRow = gridApi.grid.getRadioRecord() as MallSpuApi.Spu;
|
||||||
if (selectedRow) {
|
if (selectedRow) {
|
||||||
emit('change', selectedRow);
|
emit('change', selectedRow);
|
||||||
modalApi.close();
|
closeModal();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,25 +164,16 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const [Modal, modalApi] = useVbenModal({
|
/** 打开弹窗 */
|
||||||
destroyOnClose: true,
|
async function openModal(data?: MallSpuApi.Spu | MallSpuApi.Spu[]) {
|
||||||
showConfirmButton: props.multiple, // 特殊:radio 单选情况下,走 handleRadioChange 处理。
|
initData.value = data;
|
||||||
onConfirm: () => {
|
visible.value = true;
|
||||||
const selectedRows = gridApi.grid.getCheckboxRecords() as MallSpuApi.Spu[];
|
// 等待 Grid 组件完全初始化后再查询数据
|
||||||
emit('change', selectedRows);
|
await nextTick();
|
||||||
modalApi.close();
|
if (gridApi.grid) {
|
||||||
},
|
|
||||||
async onOpenChange(isOpen: boolean) {
|
|
||||||
if (!isOpen) {
|
|
||||||
await gridApi.grid.clearCheckboxRow();
|
|
||||||
await gridApi.grid.clearRadioRow();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. 先查询数据
|
// 1. 先查询数据
|
||||||
await gridApi.query();
|
await gridApi.query();
|
||||||
// 2. 设置已选中行
|
// 2. 设置已选中行
|
||||||
const data = modalApi.getData<MallSpuApi.Spu | MallSpuApi.Spu[]>();
|
|
||||||
if (props.multiple && Array.isArray(data) && data.length > 0) {
|
if (props.multiple && Array.isArray(data) && data.length > 0) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const tableData = gridApi.grid.getTableData().fullData;
|
const tableData = gridApi.grid.getTableData().fullData;
|
||||||
@@ -201,15 +197,27 @@ const [Modal, modalApi] = useVbenModal({
|
|||||||
}
|
}
|
||||||
}, 300);
|
}, 300);
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
/** 关闭弹窗 */
|
||||||
|
async function closeModal() {
|
||||||
|
visible.value = false;
|
||||||
|
await gridApi.grid.clearCheckboxRow();
|
||||||
|
await gridApi.grid.clearRadioRow();
|
||||||
|
initData.value = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 确认选择(多选模式) */
|
||||||
|
function handleConfirm() {
|
||||||
|
const selectedRows = gridApi.grid.getCheckboxRecords() as MallSpuApi.Spu[];
|
||||||
|
emit('change', selectedRows);
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
|
||||||
/** 对外暴露的方法 */
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
open: (data?: MallSpuApi.Spu | MallSpuApi.Spu[]) => {
|
open: openModal,
|
||||||
modalApi.setData(data).open();
|
}); // 对外暴露的方法
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
/** 初始化分类数据 */
|
/** 初始化分类数据 */
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -219,7 +227,15 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Modal title="选择商品" class="w-[950px]">
|
<Modal
|
||||||
|
v-model:open="visible"
|
||||||
|
title="选择商品"
|
||||||
|
width="950px"
|
||||||
|
:destroy-on-close="true"
|
||||||
|
:footer="props.multiple ? undefined : null"
|
||||||
|
@ok="handleConfirm"
|
||||||
|
@cancel="closeModal"
|
||||||
|
>
|
||||||
<Grid />
|
<Grid />
|
||||||
</Modal>
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
33
apps/web-antd/src/views/mall/product/spu/components/type.ts
Normal file
33
apps/web-antd/src/views/mall/product/spu/components/type.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import type { MallSpuApi } from '#/api/mall/product/spu';
|
||||||
|
|
||||||
|
/** 商品属性及其值的树形结构(用于前端展示和操作) */
|
||||||
|
export interface PropertyAndValues {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
values?: PropertyAndValues[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuleConfig {
|
||||||
|
// 需要校验的字段
|
||||||
|
// 例:name: 'name' 则表示校验 sku.name 的值
|
||||||
|
// 例:name: 'productConfig.stock' 则表示校验 sku.productConfig.name 的值,此处 productConfig 表示我在 Sku 上扩展的属性
|
||||||
|
name: string;
|
||||||
|
// 校验规格为一个毁掉函数,其中 arg 为需要校验的字段的值。
|
||||||
|
// 例:需要校验价格必须大于0.01
|
||||||
|
// {
|
||||||
|
// name:'price',
|
||||||
|
// rule:(arg: number) => arg > 0.01
|
||||||
|
// }
|
||||||
|
rule: (arg: any) => boolean;
|
||||||
|
// 校验不通过时的消息提示
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpuProperty<T> {
|
||||||
|
propertyList: PropertyAndValues[];
|
||||||
|
spuDetail: T;
|
||||||
|
spuId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export for use in generic constraint
|
||||||
|
export type { MallSpuApi };
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
|
||||||
import type { MallSpuApi } from '#/api/mall/product/spu';
|
|
||||||
|
|
||||||
// TODO @puhui999:这个是不是 api 后端有定义类似的?如果是,是不是放到 api 哈?
|
|
||||||
export interface PropertyAndValues {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
values?: PropertyAndValues[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RuleConfig {
|
|
||||||
// 需要校验的字段
|
|
||||||
// 例:name: 'name' 则表示校验 sku.name 的值
|
|
||||||
// 例:name: 'productConfig.stock' 则表示校验 sku.productConfig.name 的值,此处 productConfig 表示我在 Sku 上扩展的属性
|
|
||||||
name: string;
|
|
||||||
// 校验规格为一个毁掉函数,其中 arg 为需要校验的字段的值。
|
|
||||||
// 例:需要校验价格必须大于0.01
|
|
||||||
// {
|
|
||||||
// name:'price',
|
|
||||||
// rule:(arg: number) => arg > 0.01
|
|
||||||
// }
|
|
||||||
rule: (arg: any) => boolean;
|
|
||||||
// 校验不通过时的消息提示
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO @puhui999:这个是只有 index.ts 在用么?还是别的模块也会用
|
|
||||||
/** 获得商品的规格列表 - 商品相关的公共函数 */
|
|
||||||
const getPropertyList = (spu: MallSpuApi.Spu): PropertyAndValues[] => {
|
|
||||||
// 直接拿返回的 skus 属性逆向生成出 propertyList
|
|
||||||
const properties: PropertyAndValues[] = [];
|
|
||||||
// 只有是多规格才处理
|
|
||||||
if (spu.specType) {
|
|
||||||
spu.skus?.forEach((sku) => {
|
|
||||||
sku.properties?.forEach(
|
|
||||||
({ propertyId, propertyName, valueId, valueName }) => {
|
|
||||||
// 添加属性
|
|
||||||
if (!properties?.some((item) => item.id === propertyId)) {
|
|
||||||
properties.push({
|
|
||||||
id: propertyId!,
|
|
||||||
name: propertyName!,
|
|
||||||
values: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// 添加属性值
|
|
||||||
const index = properties?.findIndex((item) => item.id === propertyId);
|
|
||||||
if (
|
|
||||||
!properties[index]?.values?.some((value) => value.id === valueId)
|
|
||||||
) {
|
|
||||||
properties[index]?.values?.push({ id: valueId!, name: valueName! });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return properties;
|
|
||||||
};
|
|
||||||
|
|
||||||
export { getPropertyList };
|
|
||||||
|
|
||||||
// 导出组件
|
|
||||||
// TODO @puhui999:如果 sku-list.vue 要对外,可以考虑在 spu 下面,搞个 components 模块;(目前看,别的模块应该会用到哈。);modules 是当前模块用到的,components 是跨模块要用到的。
|
|
||||||
export { default as SkuList } from './modules/sku-list.vue';
|
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { PropertyAndValues, RuleConfig } from './index';
|
|
||||||
|
|
||||||
import type { MallSpuApi } from '#/api/mall/product/spu';
|
import type { MallSpuApi } from '#/api/mall/product/spu';
|
||||||
|
import type {
|
||||||
|
PropertyAndValues,
|
||||||
|
RuleConfig,
|
||||||
|
} from '#/views/mall/product/spu/components';
|
||||||
|
|
||||||
import { onMounted, ref, watch } from 'vue';
|
import { onMounted, ref, watch } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
@@ -14,6 +16,7 @@ import { Button, Card, message } from 'ant-design-vue';
|
|||||||
|
|
||||||
import { useVbenForm } from '#/adapter/form';
|
import { useVbenForm } from '#/adapter/form';
|
||||||
import { createSpu, getSpu, updateSpu } from '#/api/mall/product/spu';
|
import { createSpu, getSpu, updateSpu } from '#/api/mall/product/spu';
|
||||||
|
import { getPropertyList, SkuList } from '#/views/mall/product/spu/components';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
useDeliveryFormSchema,
|
useDeliveryFormSchema,
|
||||||
@@ -22,10 +25,8 @@ import {
|
|||||||
useOtherFormSchema,
|
useOtherFormSchema,
|
||||||
useSkuFormSchema,
|
useSkuFormSchema,
|
||||||
} from './data';
|
} from './data';
|
||||||
import { getPropertyList } from './index';
|
|
||||||
import ProductAttributes from './modules/product-attributes.vue';
|
import ProductAttributes from './modules/product-attributes.vue';
|
||||||
import ProductPropertyAddForm from './modules/product-property-add-form.vue';
|
import ProductPropertyAddForm from './modules/product-property-add-form.vue';
|
||||||
import SkuList from './modules/sku-list.vue';
|
|
||||||
|
|
||||||
const spuId = ref<number>();
|
const spuId = ref<number>();
|
||||||
const { params, name } = useRoute();
|
const { params, name } = useRoute();
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
<!-- 商品发布 - 库存价格 - 属性列表 -->
|
<!-- 商品发布 - 库存价格 - 属性列表 -->
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { PropertyAndValues } from '../index';
|
|
||||||
|
|
||||||
import type { MallPropertyApi } from '#/api/mall/product/property';
|
import type { MallPropertyApi } from '#/api/mall/product/property';
|
||||||
|
import type { PropertyAndValues } from '#/views/mall/product/spu/components';
|
||||||
|
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
@@ -83,10 +82,10 @@ watch(
|
|||||||
|
|
||||||
/** 删除属性值 */
|
/** 删除属性值 */
|
||||||
function handleCloseValue(index: number, value: PropertyAndValues) {
|
function handleCloseValue(index: number, value: PropertyAndValues) {
|
||||||
if (attributeList.value[index]) {
|
if (attributeList.value[index]?.values) {
|
||||||
attributeList.value[index].values = attributeList.value?.[
|
attributeList.value[index].values = attributeList.value[
|
||||||
index
|
index
|
||||||
]?.values?.filter((item) => item.id !== value.id);
|
].values?.filter((item) => item.id !== value.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,9 +166,8 @@ async function getAttributeOptions(propertyId: number) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Col v-for="(attribute, index) in attributeList" :key="index">
|
<Col v-for="(attribute, index) in attributeList" :key="index">
|
||||||
<Divider class="my-4" />
|
<Divider class="my-3" />
|
||||||
<!-- TODO @puhui999:1)间隙可以看看;2)vue3 + element-plus 添加属性这个按钮,是和属性名在一排,感觉更好看点。 -->
|
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||||
<div class="mt-1">
|
|
||||||
<span class="mx-1">属性名:</span>
|
<span class="mx-1">属性名:</span>
|
||||||
<Tag
|
<Tag
|
||||||
:closable="!isDetail"
|
:closable="!isDetail"
|
||||||
@@ -180,7 +178,7 @@ async function getAttributeOptions(propertyId: number) {
|
|||||||
{{ attribute.name }}
|
{{ attribute.name }}
|
||||||
</Tag>
|
</Tag>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2">
|
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||||
<span class="mx-1">属性值:</span>
|
<span class="mx-1">属性值:</span>
|
||||||
<Tag
|
<Tag
|
||||||
v-for="(value, valueIndex) in attribute.values"
|
v-for="(value, valueIndex) in attribute.values"
|
||||||
@@ -189,8 +187,7 @@ async function getAttributeOptions(propertyId: number) {
|
|||||||
class="mx-1"
|
class="mx-1"
|
||||||
@close="handleCloseValue(index, value)"
|
@close="handleCloseValue(index, value)"
|
||||||
>
|
>
|
||||||
<!-- TODO @puhui999:这里貌似爆红?!idea -->
|
{{ value?.name }}
|
||||||
{{ value.name }}
|
|
||||||
</Tag>
|
</Tag>
|
||||||
<Select
|
<Select
|
||||||
v-show="inputVisible(index)"
|
v-show="inputVisible(index)"
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ function handleHotAreaSelected(
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-40px flex items-center justify-center">
|
<div class="h-40px flex items-center justify-center">
|
||||||
<MagicCubeEditor
|
<MagicCubeEditor
|
||||||
v-model="cellList as any"
|
v-model="cellList"
|
||||||
:cols="cellCount"
|
:cols="cellCount"
|
||||||
:cube-size="38"
|
:cube-size="38"
|
||||||
:rows="1"
|
:rows="1"
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ export function useFormSchema(): VbenFormSchema[] {
|
|||||||
placeholder: '请输入排序',
|
placeholder: '请输入排序',
|
||||||
class: '!w-full',
|
class: '!w-full',
|
||||||
},
|
},
|
||||||
|
defaultValue: 0,
|
||||||
rules: 'required',
|
rules: 'required',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import type { MallSpuApi } from '#/api/mall/product/spu';
|
||||||
import type { MallPointActivityApi } from '#/api/mall/promotion/point';
|
import type { MallPointActivityApi } from '#/api/mall/promotion/point';
|
||||||
import type { RuleConfig } from '#/views/mall/product/spu/form';
|
import type {
|
||||||
// TODO @puhui999:有问题
|
RuleConfig,
|
||||||
// import type { SpuProperty } from '#/views/mall/promotion/components/types';
|
SpuProperty,
|
||||||
|
} from '#/views/mall/product/spu/components';
|
||||||
|
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
import { useVbenModal } from '@vben/common-ui';
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
import { convertToInteger, formatToFraction } from '@vben/utils';
|
import { cloneDeep, convertToInteger, formatToFraction } from '@vben/utils';
|
||||||
|
|
||||||
import { Button, InputNumber, message } from 'ant-design-vue';
|
import { Button, InputNumber, message } from 'ant-design-vue';
|
||||||
|
|
||||||
@@ -20,10 +22,12 @@ import {
|
|||||||
updatePointActivity,
|
updatePointActivity,
|
||||||
} from '#/api/mall/promotion/point';
|
} from '#/api/mall/promotion/point';
|
||||||
import { $t } from '#/locales';
|
import { $t } from '#/locales';
|
||||||
import { getPropertyList } from '#/views/mall/product/spu/form';
|
import {
|
||||||
|
getPropertyList,
|
||||||
|
SpuAndSkuList,
|
||||||
|
SpuSkuSelect,
|
||||||
|
} from '#/views/mall/product/spu/components';
|
||||||
|
|
||||||
// TODO @puhui999:有问题
|
|
||||||
// import { SpuAndSkuList, SpuSkuSelect } from '../../../components';
|
|
||||||
import { useFormSchema } from '../data';
|
import { useFormSchema } from '../data';
|
||||||
|
|
||||||
const emit = defineEmits(['success']);
|
const emit = defineEmits(['success']);
|
||||||
@@ -70,15 +74,12 @@ const ruleConfig: RuleConfig[] = [
|
|||||||
},
|
},
|
||||||
]; // SKU 规则配置
|
]; // SKU 规则配置
|
||||||
|
|
||||||
const spuList = ref<any[]>([]); // 选择的 SPU 列表
|
const spuList = ref<MallSpuApi.Spu[]>([]); // 选择的 SPU 列表
|
||||||
// TODO @puhui999:有问题
|
const spuPropertyList = ref<SpuProperty<MallSpuApi.Spu>[]>([]); // SPU 属性列表
|
||||||
// const spuPropertyList = ref<SpuProperty<any>[]>([]); // SPU 属性列表
|
|
||||||
const spuPropertyList = ref<any[]>([]); // SPU 属性列表
|
|
||||||
|
|
||||||
/** 打开商品选择器 */
|
/** 打开商品选择器 */
|
||||||
// TODO @puhui999:spuSkuSelectRef.value.open is not a function
|
|
||||||
function openSpuSelect() {
|
function openSpuSelect() {
|
||||||
spuSkuSelectRef.value.open();
|
spuSkuSelectRef.value?.open();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 选择商品后的回调 */
|
/** 选择商品后的回调 */
|
||||||
@@ -106,7 +107,7 @@ async function getSpuDetails(
|
|||||||
? res.skus
|
? res.skus
|
||||||
: res.skus?.filter((sku) => skuIds.includes(sku.id!));
|
: res.skus?.filter((sku) => skuIds.includes(sku.id!));
|
||||||
// 为每个 SKU 配置积分商城相关的配置
|
// 为每个 SKU 配置积分商城相关的配置
|
||||||
selectSkus?.forEach((sku: any) => {
|
selectSkus?.forEach((sku) => {
|
||||||
let config: MallPointActivityApi.PointProduct = {
|
let config: MallPointActivityApi.PointProduct = {
|
||||||
skuId: sku.id!,
|
skuId: sku.id!,
|
||||||
stock: 0,
|
stock: 0,
|
||||||
@@ -122,22 +123,25 @@ async function getSpuDetails(
|
|||||||
}
|
}
|
||||||
config = product || config;
|
config = product || config;
|
||||||
}
|
}
|
||||||
sku.productConfig = config;
|
// 动态添加 productConfig 属性到 SKU
|
||||||
|
(
|
||||||
|
sku as MallSpuApi.Sku & {
|
||||||
|
productConfig: MallPointActivityApi.PointProduct;
|
||||||
|
}
|
||||||
|
).productConfig = config;
|
||||||
});
|
});
|
||||||
res.skus = selectSkus;
|
res.skus = selectSkus;
|
||||||
|
|
||||||
// TODO @puhui999:有问题
|
const spuProperties: SpuProperty<MallSpuApi.Spu>[] = [
|
||||||
// const spuProperties: SpuProperty[] = [];
|
{
|
||||||
const spuProperties: any[] = [];
|
spuId: res.id!,
|
||||||
spuProperties.push({
|
spuDetail: res,
|
||||||
spuId: res.id!,
|
propertyList: getPropertyList(res),
|
||||||
spuDetail: res,
|
},
|
||||||
propertyList: getPropertyList(res),
|
]; // 构建 SPU 属性列表
|
||||||
});
|
|
||||||
|
|
||||||
// TODO @puhui999:貌似直接 = 下面的,不用 push?
|
// 直接赋值,因为每次只选择一个 SPU
|
||||||
spuList.value.push(res);
|
spuList.value = [res];
|
||||||
// TODO @puhui999:貌似直接 = 下面的,不用 push?
|
|
||||||
spuPropertyList.value = spuProperties;
|
spuPropertyList.value = spuProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,9 +155,10 @@ const [Modal, modalApi] = useVbenModal({
|
|||||||
}
|
}
|
||||||
modalApi.lock();
|
modalApi.lock();
|
||||||
try {
|
try {
|
||||||
// 获取积分商城商品配置
|
// 获取积分商城商品配置(深拷贝避免直接修改原对象)
|
||||||
const products: MallPointActivityApi.PointProduct[] =
|
const products: MallPointActivityApi.PointProduct[] = cloneDeep(
|
||||||
spuAndSkuListRef.value?.getSkuConfigs('productConfig') || [];
|
spuAndSkuListRef.value?.getSkuConfigs('productConfig') || [],
|
||||||
|
);
|
||||||
// 价格需要转为分
|
// 价格需要转为分
|
||||||
products.forEach((item) => {
|
products.forEach((item) => {
|
||||||
item.price = convertToInteger(item.price);
|
item.price = convertToInteger(item.price);
|
||||||
@@ -180,11 +185,24 @@ const [Modal, modalApi] = useVbenModal({
|
|||||||
spuPropertyList.value = [];
|
spuPropertyList.value = [];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 重置表单数据(新增和编辑模式都需要)
|
||||||
|
// TODO @puhui999:这里的重置,是不是在 183 到 185 已经处理了呀。
|
||||||
|
formData.value = undefined;
|
||||||
|
spuList.value = [];
|
||||||
|
spuPropertyList.value = [];
|
||||||
// 加载数据
|
// 加载数据
|
||||||
const data = modalApi.getData<MallPointActivityApi.PointActivity>();
|
const data = modalApi.getData<MallPointActivityApi.PointActivity>();
|
||||||
if (!data || !data.id) {
|
if (!data || !data.id) {
|
||||||
|
// 新增模式:重置表单字段
|
||||||
|
// TODO @puhui999:197 到 201 这块的 setValues 的设置,是不是必要哈。可以看看。
|
||||||
|
await formApi.setValues({
|
||||||
|
sort: 0,
|
||||||
|
remark: '',
|
||||||
|
spuId: undefined,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 加载数据
|
||||||
modalApi.lock();
|
modalApi.lock();
|
||||||
try {
|
try {
|
||||||
formData.value = await getPointActivity(data.id);
|
formData.value = await getPointActivity(data.id);
|
||||||
@@ -203,76 +221,77 @@ const [Modal, modalApi] = useVbenModal({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Modal :title="getTitle" class="w-[70%]">
|
<div>
|
||||||
<Form class="mx-4">
|
<Modal :title="getTitle" class="w-[70%]">
|
||||||
<!-- 商品选择 -->
|
<Form class="mx-4">
|
||||||
<template #spuId>
|
<!-- 商品选择 -->
|
||||||
<div class="w-full">
|
<template #spuId>
|
||||||
<Button v-if="!formData?.id" type="primary" @click="openSpuSelect">
|
<div class="w-full">
|
||||||
选择商品
|
<Button v-if="!formData?.id" type="primary" @click="openSpuSelect">
|
||||||
</Button>
|
选择商品
|
||||||
|
</Button>
|
||||||
|
|
||||||
<!-- SPU 和 SKU 列表展示 -->
|
<!-- SPU 和 SKU 列表展示 -->
|
||||||
<SpuAndSkuList
|
<SpuAndSkuList
|
||||||
v-if="spuList.length > 0"
|
ref="spuAndSkuListRef"
|
||||||
ref="spuAndSkuListRef"
|
:rule-config="ruleConfig"
|
||||||
:rule-config="ruleConfig"
|
:spu-list="spuList"
|
||||||
:spu-list="spuList"
|
:spu-property-list-p="spuPropertyList"
|
||||||
:spu-property-list="spuPropertyList"
|
class="mt-4"
|
||||||
class="mt-4"
|
>
|
||||||
>
|
<!-- 扩展列:积分商城特有配置 -->
|
||||||
<!-- 扩展列:积分商城特有配置 -->
|
<template #default>
|
||||||
<template #default>
|
<VxeColumn align="center" min-width="168" title="可兑换库存">
|
||||||
<VxeColumn align="center" min-width="168" title="可兑换库存">
|
<template #default="{ row: sku }">
|
||||||
<template #default="{ row: sku }">
|
<InputNumber
|
||||||
<InputNumber
|
v-model:value="sku.productConfig.stock"
|
||||||
v-model:value="sku.productConfig.stock"
|
:max="sku.stock"
|
||||||
:max="sku.stock"
|
:min="0"
|
||||||
:min="0"
|
class="w-full"
|
||||||
class="w-full"
|
/>
|
||||||
/>
|
</template>
|
||||||
</template>
|
</VxeColumn>
|
||||||
</VxeColumn>
|
<VxeColumn align="center" min-width="168" title="可兑换次数">
|
||||||
<VxeColumn align="center" min-width="168" title="可兑换次数">
|
<template #default="{ row: sku }">
|
||||||
<template #default="{ row: sku }">
|
<InputNumber
|
||||||
<InputNumber
|
v-model:value="sku.productConfig.count"
|
||||||
v-model:value="sku.productConfig.count"
|
:min="0"
|
||||||
:min="0"
|
class="w-full"
|
||||||
class="w-full"
|
/>
|
||||||
/>
|
</template>
|
||||||
</template>
|
</VxeColumn>
|
||||||
</VxeColumn>
|
<VxeColumn align="center" min-width="168" title="所需积分">
|
||||||
<VxeColumn align="center" min-width="168" title="所需积分">
|
<template #default="{ row: sku }">
|
||||||
<template #default="{ row: sku }">
|
<InputNumber
|
||||||
<InputNumber
|
v-model:value="sku.productConfig.point"
|
||||||
v-model:value="sku.productConfig.point"
|
:min="0"
|
||||||
:min="0"
|
class="w-full"
|
||||||
class="w-full"
|
/>
|
||||||
/>
|
</template>
|
||||||
</template>
|
</VxeColumn>
|
||||||
</VxeColumn>
|
<VxeColumn align="center" min-width="168" title="所需金额(元)">
|
||||||
<VxeColumn align="center" min-width="168" title="所需金额(元)">
|
<template #default="{ row: sku }">
|
||||||
<template #default="{ row: sku }">
|
<InputNumber
|
||||||
<InputNumber
|
v-model:value="sku.productConfig.price"
|
||||||
v-model:value="sku.productConfig.price"
|
:min="0"
|
||||||
:min="0"
|
:precision="2"
|
||||||
:precision="2"
|
:step="0.1"
|
||||||
:step="0.1"
|
class="w-full"
|
||||||
class="w-full"
|
/>
|
||||||
/>
|
</template>
|
||||||
</template>
|
</VxeColumn>
|
||||||
</VxeColumn>
|
</template>
|
||||||
</template>
|
</SpuAndSkuList>
|
||||||
</SpuAndSkuList>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</template>
|
</Form>
|
||||||
</Form>
|
</Modal>
|
||||||
|
|
||||||
<!-- 商品选择器弹窗 -->
|
<!-- 商品选择器弹窗 -->
|
||||||
<SpuSkuSelect
|
<SpuSkuSelect
|
||||||
ref="spuSkuSelectRef"
|
ref="spuSkuSelectRef"
|
||||||
:is-select-sku="true"
|
:is-select-sku="true"
|
||||||
@confirm="handleSpuSelected"
|
@select="handleSpuSelected"
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -12,11 +12,16 @@ import {
|
|||||||
|
|
||||||
import MsgEvent from './msg-event.vue';
|
import MsgEvent from './msg-event.vue';
|
||||||
|
|
||||||
defineOptions({ name: 'Msg' });
|
defineOptions({ name: 'WxMsg' });
|
||||||
|
|
||||||
defineProps<{
|
withDefaults(
|
||||||
item: any;
|
defineProps<{
|
||||||
}>();
|
item?: any;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
item: {},
|
||||||
|
},
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { Typography } from 'ant-design-vue';
|
|||||||
|
|
||||||
/** 微信消息 - 音乐 */
|
/** 微信消息 - 音乐 */
|
||||||
defineOptions({ name: 'WxMusic' });
|
defineOptions({ name: 'WxMusic' });
|
||||||
// TODO @hw:antd 和 ele 的代码风格不一致,例如说:props;
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<WxMusicProps>(), {
|
const props = withDefaults(defineProps<WxMusicProps>(), {
|
||||||
title: '',
|
title: '',
|
||||||
@@ -17,8 +16,6 @@ const props = withDefaults(defineProps<WxMusicProps>(), {
|
|||||||
thumbMediaUrl: '',
|
thumbMediaUrl: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const { Link } = Typography;
|
|
||||||
|
|
||||||
const href = computed(() => props.hqMusicUrl || props.musicUrl);
|
const href = computed(() => props.hqMusicUrl || props.musicUrl);
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
@@ -28,7 +25,11 @@ defineExpose({
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<Link :href="href" target="_blank" class="text-success no-underline">
|
<Typography.Link
|
||||||
|
:href="href"
|
||||||
|
target="_blank"
|
||||||
|
class="text-success no-underline"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="flex items-center rounded-[5px] border border-[#e8e8e8] bg-white p-2.5 transition hover:border-black/10 hover:shadow-sm"
|
class="flex items-center rounded-[5px] border border-[#e8e8e8] bg-white p-2.5 transition hover:border-black/10 hover:shadow-sm"
|
||||||
>
|
>
|
||||||
@@ -50,6 +51,6 @@ defineExpose({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Typography.Link>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -86,7 +86,6 @@ function plusNews() {
|
|||||||
<Layout>
|
<Layout>
|
||||||
<Layout.Sider width="40%" theme="light">
|
<Layout.Sider width="40%" theme="light">
|
||||||
<div class="mx-auto mb-[10px] w-[60%] border border-[#eaeaea] p-[10px]">
|
<div class="mx-auto mb-[10px] w-[60%] border border-[#eaeaea] p-[10px]">
|
||||||
<!-- TODO @hw:头条、次条的展示不对。微信聊过的呀~ -->
|
|
||||||
<div v-for="(news, index) in newsList" :key="index">
|
<div v-for="(news, index) in newsList" :key="index">
|
||||||
<div
|
<div
|
||||||
class="group relative mx-auto mb-[10px] w-full cursor-pointer border-[2px] bg-white"
|
class="group relative mx-auto mb-[10px] w-full cursor-pointer border-[2px] bg-white"
|
||||||
@@ -99,15 +98,18 @@ function plusNews() {
|
|||||||
@click="activeNewsIndex = index"
|
@click="activeNewsIndex = index"
|
||||||
>
|
>
|
||||||
<div class="relative w-full bg-[#acadae]">
|
<div class="relative w-full bg-[#acadae]">
|
||||||
<img class="h-full w-full" :src="news.thumbUrl" />
|
<img
|
||||||
|
class="max-h-[200px] min-h-[100px] w-full object-cover"
|
||||||
|
:src="news.thumbUrl"
|
||||||
|
/>
|
||||||
<div
|
<div
|
||||||
class="absolute bottom-0 left-0 inline-block h-[25px] w-[100%] overflow-hidden text-ellipsis whitespace-nowrap bg-black p-[1%] text-center text-[15px] text-white opacity-65"
|
class="absolute bottom-0 left-0 mb-[5px] ml-[5px] inline-block h-[25px] w-[100%] overflow-hidden text-ellipsis whitespace-nowrap p-[1%] text-[18px] text-white"
|
||||||
>
|
>
|
||||||
{{ news.title }}
|
{{ news.title }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="relative flex justify-center gap-[10px] py-[5px] text-center"
|
class="absolute bottom-0 right-[-45px] top-0 flex flex-col justify-center gap-[10px] py-[5px] text-center"
|
||||||
v-if="newsList.length > 1"
|
v-if="newsList.length > 1"
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
@@ -140,18 +142,19 @@ function plusNews() {
|
|||||||
"
|
"
|
||||||
@click="activeNewsIndex = index"
|
@click="activeNewsIndex = index"
|
||||||
>
|
>
|
||||||
<div class="relative">
|
<div class="relative flex items-center justify-between">
|
||||||
<div class="bg-[#acadae]">
|
<div
|
||||||
<img class="block h-full w-full" :src="news.thumbUrl" />
|
class="mb-[5px] ml-[5px] h-[25px] flex-1 overflow-hidden text-ellipsis whitespace-nowrap p-[1%] text-[16px]"
|
||||||
<div
|
>
|
||||||
class="absolute bottom-0 left-0 inline-block h-[25px] w-[100%] overflow-hidden text-ellipsis whitespace-nowrap bg-black p-[1%] text-center text-[15px] text-white opacity-65"
|
{{ news.title }}
|
||||||
>
|
|
||||||
{{ news.title }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<img
|
||||||
|
class="block h-[90px] w-[90px] object-cover"
|
||||||
|
:src="news.thumbUrl"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="relative flex justify-center gap-[10px] py-[5px] text-center"
|
class="absolute bottom-0 right-[-45px] top-0 flex flex-col justify-center gap-[10px] py-[5px] text-center"
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
v-if="newsList.length > index + 1"
|
v-if="newsList.length > index + 1"
|
||||||
|
|||||||
@@ -77,9 +77,8 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// TODO @jawe:Article 类型,报错;
|
|
||||||
return {
|
return {
|
||||||
list: res.list as unknown as Article[],
|
list: res.list as unknown as MpFreePublishApi.FreePublish[],
|
||||||
total: res.total,
|
total: res.total,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -93,7 +92,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
|||||||
refresh: true,
|
refresh: true,
|
||||||
search: true,
|
search: true,
|
||||||
},
|
},
|
||||||
} as VxeTableGridOptions<Article>,
|
} as VxeTableGridOptions<FreePublish.FreePublish>,
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
143
apps/web-antd/src/views/mp/messageTemplate/data.ts
Normal file
143
apps/web-antd/src/views/mp/messageTemplate/data.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import type { VbenFormSchema } from '#/adapter/form';
|
||||||
|
import type { VxeGridPropTypes } from '#/adapter/vxe-table';
|
||||||
|
|
||||||
|
import { getUserPage } from '#/api/mp/user';
|
||||||
|
|
||||||
|
/** 列表的搜索表单 */
|
||||||
|
export function useGridFormSchema(): VbenFormSchema[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
fieldName: 'accountId',
|
||||||
|
label: '公众号',
|
||||||
|
component: 'Input',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发送消息模板表单 */
|
||||||
|
export function useSendFormSchema(accountId?: number): VbenFormSchema[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
fieldName: 'id',
|
||||||
|
label: '模板编号',
|
||||||
|
component: 'Input',
|
||||||
|
componentProps: {
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'title',
|
||||||
|
label: '模板标题',
|
||||||
|
component: 'Input',
|
||||||
|
componentProps: {
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'userId',
|
||||||
|
label: '用户',
|
||||||
|
component: 'ApiSelect',
|
||||||
|
componentProps: {
|
||||||
|
api: async () => {
|
||||||
|
if (!accountId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const data = await getUserPage({
|
||||||
|
pageNo: 1,
|
||||||
|
pageSize: 100,
|
||||||
|
accountId,
|
||||||
|
});
|
||||||
|
return (data.list || []).map((user) => ({
|
||||||
|
label: user.nickname || user.openid,
|
||||||
|
value: user.id,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
showSearch: true,
|
||||||
|
placeholder: '请选择用户',
|
||||||
|
},
|
||||||
|
rules: 'required',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'data',
|
||||||
|
label: '模板数据',
|
||||||
|
component: 'Textarea',
|
||||||
|
componentProps: {
|
||||||
|
rows: 4,
|
||||||
|
placeholder:
|
||||||
|
'请输入模板数据(JSON 格式),例如:{"keyword1": {"value": "测试内容"}}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'url',
|
||||||
|
label: '跳转链接',
|
||||||
|
component: 'Input',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入跳转链接',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'miniProgramAppId',
|
||||||
|
label: '小程序 appId',
|
||||||
|
component: 'Input',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入小程序 appId',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'miniProgramPagePath',
|
||||||
|
label: '小程序页面路径',
|
||||||
|
component: 'Input',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入小程序页面路径',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 表格列配置 */
|
||||||
|
export function useGridColumns(): VxeGridPropTypes.Columns {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: '公众号模板 ID',
|
||||||
|
field: 'templateId',
|
||||||
|
minWidth: 400,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '标题',
|
||||||
|
field: 'title',
|
||||||
|
minWidth: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '模板内容',
|
||||||
|
field: 'content',
|
||||||
|
minWidth: 400,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '模板示例',
|
||||||
|
field: 'example',
|
||||||
|
minWidth: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '一级行业',
|
||||||
|
field: 'primaryIndustry',
|
||||||
|
minWidth: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '二级行业',
|
||||||
|
field: 'deputyIndustry',
|
||||||
|
minWidth: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
field: 'createTime',
|
||||||
|
formatter: 'formatDateTime',
|
||||||
|
minWidth: 180,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 140,
|
||||||
|
fixed: 'right',
|
||||||
|
slots: { default: 'actions' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
166
apps/web-antd/src/views/mp/messageTemplate/index.vue
Normal file
166
apps/web-antd/src/views/mp/messageTemplate/index.vue
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||||
|
import type { MpMessageTemplateApi } from '#/api/mp/messageTemplate';
|
||||||
|
|
||||||
|
import { confirm, DocAlert, Page, useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
|
import {
|
||||||
|
deleteMessageTemplate,
|
||||||
|
getMessageTemplateList,
|
||||||
|
syncMessageTemplate,
|
||||||
|
} from '#/api/mp/messageTemplate';
|
||||||
|
import { $t } from '#/locales';
|
||||||
|
import { WxAccountSelect } from '#/views/mp/components';
|
||||||
|
|
||||||
|
import { useGridColumns, useGridFormSchema } from './data';
|
||||||
|
import SendForm from './modules/send-form.vue';
|
||||||
|
|
||||||
|
const [SendFormModal, sendFormModalApi] = useVbenModal({
|
||||||
|
connectedComponent: SendForm,
|
||||||
|
destroyOnClose: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 刷新表格 */
|
||||||
|
function handleRefresh() {
|
||||||
|
gridApi.query();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 公众号变化时查询数据 */
|
||||||
|
function handleAccountChange(accountId: number) {
|
||||||
|
gridApi.formApi.setValues({ accountId });
|
||||||
|
gridApi.formApi.submitForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同步模板 */
|
||||||
|
async function handleSync() {
|
||||||
|
const formValues = await gridApi.formApi.getValues();
|
||||||
|
const accountId = formValues.accountId;
|
||||||
|
if (!accountId) {
|
||||||
|
message.warning('请先选择公众号');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await confirm('是否确认同步消息模板?');
|
||||||
|
const hideLoading = message.loading({
|
||||||
|
content: '正在同步消息模板...',
|
||||||
|
duration: 0,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await syncMessageTemplate(accountId);
|
||||||
|
message.success('同步消息模板成功');
|
||||||
|
handleRefresh();
|
||||||
|
} finally {
|
||||||
|
hideLoading();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发送消息 */
|
||||||
|
function handleSend(row: MpMessageTemplateApi.MessageTemplate) {
|
||||||
|
sendFormModalApi.setData(row).open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除模板 */
|
||||||
|
async function handleDelete(row: MpMessageTemplateApi.MessageTemplate) {
|
||||||
|
const hideLoading = message.loading({
|
||||||
|
content: $t('ui.actionMessage.deleting', [row.title]),
|
||||||
|
duration: 0,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await deleteMessageTemplate(row.id);
|
||||||
|
message.success($t('ui.actionMessage.deleteSuccess', [row.title]));
|
||||||
|
handleRefresh();
|
||||||
|
} finally {
|
||||||
|
hideLoading();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [Grid, gridApi] = useVbenVxeGrid({
|
||||||
|
formOptions: {
|
||||||
|
schema: useGridFormSchema(),
|
||||||
|
},
|
||||||
|
gridOptions: {
|
||||||
|
columns: useGridColumns(),
|
||||||
|
height: 'auto',
|
||||||
|
keepSource: true,
|
||||||
|
proxyConfig: {
|
||||||
|
ajax: {
|
||||||
|
query: async (_params, formValues) => {
|
||||||
|
return await getMessageTemplateList({
|
||||||
|
accountId: formValues.accountId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
autoLoad: false,
|
||||||
|
},
|
||||||
|
pagerConfig: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
rowConfig: {
|
||||||
|
keyField: 'id',
|
||||||
|
isHover: true,
|
||||||
|
},
|
||||||
|
toolbarConfig: {
|
||||||
|
refresh: true,
|
||||||
|
search: true,
|
||||||
|
},
|
||||||
|
} as VxeTableGridOptions<MpMessageTemplateApi.MessageTemplate>,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Page auto-content-height>
|
||||||
|
<template #doc>
|
||||||
|
<DocAlert
|
||||||
|
title="模版消息"
|
||||||
|
url="https://doc.iocoder.cn/mp/message-template/"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<SendFormModal @success="handleRefresh" />
|
||||||
|
<Grid table-title="公众号消息模板列表">
|
||||||
|
<template #form-accountId>
|
||||||
|
<WxAccountSelect @change="handleAccountChange" />
|
||||||
|
</template>
|
||||||
|
<template #toolbar-tools>
|
||||||
|
<TableAction
|
||||||
|
:actions="[
|
||||||
|
{
|
||||||
|
label: '同步',
|
||||||
|
type: 'primary',
|
||||||
|
icon: 'lucide:refresh-ccw',
|
||||||
|
auth: ['mp:message-template:sync'],
|
||||||
|
onClick: handleSync,
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<TableAction
|
||||||
|
:actions="[
|
||||||
|
{
|
||||||
|
label: '发送',
|
||||||
|
type: 'link',
|
||||||
|
icon: 'lucide:send',
|
||||||
|
auth: ['mp:message-template:send'],
|
||||||
|
onClick: handleSend.bind(null, row),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: $t('common.delete'),
|
||||||
|
type: 'link',
|
||||||
|
danger: true,
|
||||||
|
icon: ACTION_ICON.DELETE,
|
||||||
|
auth: ['mp:message-template:delete'],
|
||||||
|
popConfirm: {
|
||||||
|
title: $t('ui.actionMessage.deleteConfirm', [row.title]),
|
||||||
|
confirm: handleDelete.bind(null, row),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Grid>
|
||||||
|
</Page>
|
||||||
|
</template>
|
||||||
103
apps/web-antd/src/views/mp/messageTemplate/modules/send-form.vue
Normal file
103
apps/web-antd/src/views/mp/messageTemplate/modules/send-form.vue
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { MpMessageTemplateApi } from '#/api/mp/messageTemplate';
|
||||||
|
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { useVbenForm } from '#/adapter/form';
|
||||||
|
import { sendMessageTemplate } from '#/api/mp/messageTemplate';
|
||||||
|
|
||||||
|
import { useSendFormSchema } from '../data';
|
||||||
|
|
||||||
|
const emit = defineEmits(['success']);
|
||||||
|
const formData = ref<MpMessageTemplateApi.MessageTemplate>();
|
||||||
|
|
||||||
|
const [Form, formApi] = useVbenForm({
|
||||||
|
commonConfig: {
|
||||||
|
componentProps: {
|
||||||
|
class: 'w-full',
|
||||||
|
},
|
||||||
|
formItemClass: 'col-span-2',
|
||||||
|
labelWidth: 120,
|
||||||
|
},
|
||||||
|
layout: 'horizontal',
|
||||||
|
showDefaultActions: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [Modal, modalApi] = useVbenModal({
|
||||||
|
async onConfirm() {
|
||||||
|
const { valid } = await formApi.validate();
|
||||||
|
if (!valid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
modalApi.lock();
|
||||||
|
// 构建发送请求
|
||||||
|
const values = await formApi.getValues();
|
||||||
|
const sendData: MpMessageTemplateApi.MessageTemplateSendVO = {
|
||||||
|
id: formData.value?.id || 0,
|
||||||
|
userId: values.userId,
|
||||||
|
data: values.data || undefined,
|
||||||
|
url: values.url || undefined,
|
||||||
|
miniProgramAppId: values.miniProgramAppId || undefined,
|
||||||
|
miniProgramPagePath: values.miniProgramPagePath || undefined,
|
||||||
|
};
|
||||||
|
// 如果填写了小程序信息,需要拼接成 miniprogram 字段
|
||||||
|
if (sendData.miniProgramAppId && sendData.miniProgramPagePath) {
|
||||||
|
sendData.miniprogram = JSON.stringify({
|
||||||
|
appid: sendData.miniProgramAppId,
|
||||||
|
pagepath: sendData.miniProgramPagePath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 如果填写了 data 字段
|
||||||
|
if (sendData.data && typeof sendData.data === 'string') {
|
||||||
|
try {
|
||||||
|
sendData.data = JSON.parse(sendData.data);
|
||||||
|
} catch {
|
||||||
|
message.error('模板数据格式不正确,请输入有效的 JSON 格式');
|
||||||
|
modalApi.unlock();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交表单
|
||||||
|
try {
|
||||||
|
await sendMessageTemplate(sendData);
|
||||||
|
// 关闭并提示
|
||||||
|
await modalApi.close();
|
||||||
|
emit('success');
|
||||||
|
message.success('发送成功');
|
||||||
|
} finally {
|
||||||
|
modalApi.unlock();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async onOpenChange(isOpen: boolean) {
|
||||||
|
if (!isOpen) {
|
||||||
|
formData.value = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 获取数据
|
||||||
|
const data = modalApi.getData<MpMessageTemplateApi.MessageTemplate>();
|
||||||
|
if (!data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
formData.value = data;
|
||||||
|
// 更新 form schema
|
||||||
|
const schema = useSendFormSchema(data.accountId);
|
||||||
|
formApi.setState({ schema });
|
||||||
|
// 设置到 values
|
||||||
|
await formApi.setValues({
|
||||||
|
id: data.id,
|
||||||
|
title: data.title,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal class="w-[600px]" title="发送消息模板">
|
||||||
|
<Form class="mx-4" />
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
@@ -57,6 +57,8 @@
|
|||||||
"vue": "catalog:",
|
"vue": "catalog:",
|
||||||
"vue-dompurify-html": "catalog:",
|
"vue-dompurify-html": "catalog:",
|
||||||
"vue-router": "catalog:",
|
"vue-router": "catalog:",
|
||||||
|
"vue3-print-nb": "catalog:",
|
||||||
|
"vue3-signature": "catalog:",
|
||||||
"vuedraggable": "catalog:"
|
"vuedraggable": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -15,6 +15,33 @@ export namespace BpmTaskApi {
|
|||||||
valueType: string; // 监听器值类型
|
valueType: string; // 监听器值类型
|
||||||
processInstance?: BpmProcessInstanceApi.ProcessInstance; // 流程实例
|
processInstance?: BpmProcessInstanceApi.ProcessInstance; // 流程实例
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 流程任务
|
||||||
|
export interface TaskManager {
|
||||||
|
id: string; // 编号
|
||||||
|
name: string; // 任务名称
|
||||||
|
createTime: number; // 创建时间
|
||||||
|
endTime: number; // 结束时间
|
||||||
|
durationInMillis: number; // 持续时间
|
||||||
|
status: number; // 状态
|
||||||
|
reason: string; // 原因
|
||||||
|
ownerUser: any; // 负责人
|
||||||
|
assigneeUser: any; // 处理人
|
||||||
|
taskDefinitionKey: string; // 任务定义key
|
||||||
|
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; // 节点类型
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 查询待办任务分页 */
|
/** 查询待办任务分页 */
|
||||||
|
|||||||
57
apps/web-ele/src/api/mp/messageTemplate/index.ts
Normal file
57
apps/web-ele/src/api/mp/messageTemplate/index.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
|
export namespace MpMessageTemplateApi {
|
||||||
|
/** 消息模板信息 */
|
||||||
|
export interface MessageTemplate {
|
||||||
|
id: number;
|
||||||
|
accountId: number;
|
||||||
|
appId: string;
|
||||||
|
templateId: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
example: string;
|
||||||
|
primaryIndustry: string;
|
||||||
|
deputyIndustry: string;
|
||||||
|
createTime?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发送消息模板请求 */
|
||||||
|
export interface MessageTemplateSendVO {
|
||||||
|
id: number;
|
||||||
|
userId: number;
|
||||||
|
data?: Record<string, string>;
|
||||||
|
url?: string;
|
||||||
|
miniProgramAppId?: string;
|
||||||
|
miniProgramPagePath?: string;
|
||||||
|
miniprogram?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询消息模板列表 */
|
||||||
|
export function getMessageTemplateList(params: { accountId: number }) {
|
||||||
|
return requestClient.get<MpMessageTemplateApi.MessageTemplate[]>(
|
||||||
|
'/mp/message-template/list',
|
||||||
|
{ params },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除消息模板 */
|
||||||
|
export function deleteMessageTemplate(id: number) {
|
||||||
|
return requestClient.delete('/mp/message-template/delete', {
|
||||||
|
params: { id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同步公众号模板 */
|
||||||
|
export function syncMessageTemplate(accountId: number) {
|
||||||
|
return requestClient.post('/mp/message-template/sync', null, {
|
||||||
|
params: { accountId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发送消息模板 */
|
||||||
|
export function sendMessageTemplate(
|
||||||
|
data: MpMessageTemplateApi.MessageTemplateSendVO,
|
||||||
|
) {
|
||||||
|
return requestClient.post('/mp/message-template/send', data);
|
||||||
|
}
|
||||||
@@ -27,25 +27,25 @@ const routes: RouteRecordRaw[] = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// {
|
{
|
||||||
// path: 'process-instance/detail',
|
path: 'process-instance/detail',
|
||||||
// component: () => import('#/views/bpm/processInstance/detail/index.vue'),
|
component: () => import('#/views/bpm/processInstance/detail/index.vue'),
|
||||||
// name: 'BpmProcessInstanceDetail',
|
name: 'BpmProcessInstanceDetail',
|
||||||
// meta: {
|
meta: {
|
||||||
// title: '流程详情',
|
title: '流程详情',
|
||||||
// activePath: '/bpm/task/my',
|
activePath: '/bpm/task/my',
|
||||||
// icon: 'ant-design:history-outlined',
|
icon: 'ant-design:history-outlined',
|
||||||
// keepAlive: false,
|
keepAlive: false,
|
||||||
// hideInMenu: true,
|
hideInMenu: true,
|
||||||
// },
|
},
|
||||||
// props: (route) => {
|
props: (route) => {
|
||||||
// return {
|
return {
|
||||||
// id: route.query.id,
|
id: route.query.id,
|
||||||
// taskId: route.query.taskId,
|
taskId: route.query.taskId,
|
||||||
// activityId: route.query.activityId,
|
activityId: route.query.activityId,
|
||||||
// };
|
};
|
||||||
// },
|
},
|
||||||
// },
|
},
|
||||||
{
|
{
|
||||||
path: '/bpm/manager/form/edit',
|
path: '/bpm/manager/form/edit',
|
||||||
name: 'BpmFormEditor',
|
name: 'BpmFormEditor',
|
||||||
|
|||||||
423
apps/web-ele/src/views/bpm/processInstance/detail/index.vue
Normal file
423
apps/web-ele/src/views/bpm/processInstance/detail/index.vue
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { BpmProcessInstanceApi } from '#/api/bpm/processInstance';
|
||||||
|
import type { SystemUserApi } from '#/api/system/user';
|
||||||
|
|
||||||
|
import { nextTick, onMounted, ref, shallowRef, watch } from 'vue';
|
||||||
|
|
||||||
|
import { Page, useVbenModal } from '@vben/common-ui';
|
||||||
|
import {
|
||||||
|
BpmFieldPermissionType,
|
||||||
|
BpmModelFormType,
|
||||||
|
BpmModelType,
|
||||||
|
BpmTaskStatusEnum,
|
||||||
|
DICT_TYPE,
|
||||||
|
} from '@vben/constants';
|
||||||
|
import {
|
||||||
|
IconifyIcon,
|
||||||
|
SvgBpmApproveIcon,
|
||||||
|
SvgBpmCancelIcon,
|
||||||
|
SvgBpmRejectIcon,
|
||||||
|
SvgBpmRunningIcon,
|
||||||
|
} from '@vben/icons';
|
||||||
|
import { formatDateTime } from '@vben/utils';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ElAvatar,
|
||||||
|
ElCard,
|
||||||
|
ElCol,
|
||||||
|
ElMessage,
|
||||||
|
ElRow,
|
||||||
|
ElTabPane,
|
||||||
|
ElTabs,
|
||||||
|
} from 'element-plus';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getApprovalDetail as getApprovalDetailApi,
|
||||||
|
getProcessInstanceBpmnModelView,
|
||||||
|
} from '#/api/bpm/processInstance';
|
||||||
|
import { getSimpleUserList } from '#/api/system/user';
|
||||||
|
import DictTag from '#/components/dict-tag/dict-tag.vue';
|
||||||
|
import { setConfAndFields2 } from '#/components/form-create';
|
||||||
|
import { registerComponent } from '#/utils';
|
||||||
|
|
||||||
|
import ProcessInstanceBpmnViewer from './modules/bpm-viewer.vue';
|
||||||
|
import ProcessInstanceOperationButton from './modules/operation-button.vue';
|
||||||
|
import ProcessssPrint from './modules/process-print.vue';
|
||||||
|
import ProcessInstanceSimpleViewer from './modules/simple-bpm-viewer.vue';
|
||||||
|
import BpmProcessInstanceTaskList from './modules/task-list.vue';
|
||||||
|
import ProcessInstanceTimeline from './modules/time-line.vue';
|
||||||
|
|
||||||
|
defineOptions({ name: 'BpmProcessInstanceDetail' });
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
activityId?: string; // 流程活动编号,用于抄送查看
|
||||||
|
id: string; // 流程实例的编号
|
||||||
|
taskId?: string; // 任务编号
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const processInstanceLoading = ref(false); // 流程实例的加载中
|
||||||
|
const processInstance = ref<BpmProcessInstanceApi.ProcessInstance>(); // 流程实例
|
||||||
|
const processDefinition = ref<any>({}); // 流程定义
|
||||||
|
const processModelView = ref<any>({}); // 流程模型视图
|
||||||
|
const operationButtonRef = ref(); // 操作按钮组件 ref
|
||||||
|
const activeTab = ref('form');
|
||||||
|
const taskListRef = ref();
|
||||||
|
const auditIconsMap: {
|
||||||
|
[key: string]:
|
||||||
|
| typeof SvgBpmApproveIcon
|
||||||
|
| typeof SvgBpmCancelIcon
|
||||||
|
| typeof SvgBpmRejectIcon
|
||||||
|
| typeof SvgBpmRunningIcon;
|
||||||
|
} = {
|
||||||
|
[BpmTaskStatusEnum.RUNNING]: SvgBpmRunningIcon,
|
||||||
|
[BpmTaskStatusEnum.APPROVE]: SvgBpmApproveIcon,
|
||||||
|
[BpmTaskStatusEnum.REJECT]: SvgBpmRejectIcon,
|
||||||
|
[BpmTaskStatusEnum.CANCEL]: SvgBpmCancelIcon,
|
||||||
|
[BpmTaskStatusEnum.APPROVING]: SvgBpmApproveIcon,
|
||||||
|
[BpmTaskStatusEnum.RETURN]: SvgBpmRejectIcon,
|
||||||
|
[BpmTaskStatusEnum.WAIT]: SvgBpmRunningIcon,
|
||||||
|
};
|
||||||
|
const activityNodes = ref<BpmProcessInstanceApi.ApprovalNodeInfo[]>([]); // 审批节点信息
|
||||||
|
const userOptions = ref<SystemUserApi.User[]>([]); // 用户列表
|
||||||
|
|
||||||
|
const fApi = ref<any>();
|
||||||
|
const detailForm = ref({
|
||||||
|
rule: [],
|
||||||
|
option: {},
|
||||||
|
value: {},
|
||||||
|
}); // 流程实例的表单详情
|
||||||
|
const writableFields: Array<string> = []; // 表单可以编辑的字段
|
||||||
|
|
||||||
|
const BusinessFormComponent = shallowRef<any>(null); // 异步组件(业务表单)
|
||||||
|
|
||||||
|
/** 获取详情 */
|
||||||
|
async function getDetail() {
|
||||||
|
// 获得审批详情
|
||||||
|
await getApprovalDetail();
|
||||||
|
// 获得流程模型视图
|
||||||
|
await getProcessModelView();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获得审批详情 */
|
||||||
|
async function getApprovalDetail() {
|
||||||
|
processInstanceLoading.value = true;
|
||||||
|
try {
|
||||||
|
const param = {
|
||||||
|
processInstanceId: props.id,
|
||||||
|
activityId: props.activityId,
|
||||||
|
taskId: props.taskId,
|
||||||
|
};
|
||||||
|
const data = await getApprovalDetailApi(param);
|
||||||
|
if (!data) {
|
||||||
|
ElMessage.error('查询不到审批详情信息!');
|
||||||
|
}
|
||||||
|
if (!data.processDefinition || !data.processInstance) {
|
||||||
|
ElMessage.error('查询不到流程信息!');
|
||||||
|
}
|
||||||
|
|
||||||
|
processInstance.value = data.processInstance;
|
||||||
|
processDefinition.value = data.processDefinition;
|
||||||
|
|
||||||
|
// 设置表单信息
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await nextTick();
|
||||||
|
fApi.value?.btn.show(false);
|
||||||
|
fApi.value?.resetBtn.show(false);
|
||||||
|
fApi.value?.disabled(true);
|
||||||
|
// 设置表单字段权限
|
||||||
|
if (formFieldsPermission) {
|
||||||
|
Object.keys(data.formFieldsPermission).forEach((item) => {
|
||||||
|
setFieldPermission(item, formFieldsPermission[item]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 注意:data.processDefinition.formCustomViewPath 是组件的全路径,例如说:/crm/contract/detail/index.vue
|
||||||
|
BusinessFormComponent.value = registerComponent(
|
||||||
|
data?.processDefinition?.formCustomViewPath || '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取审批节点,显示 Timeline 的数据
|
||||||
|
activityNodes.value = data.activityNodes;
|
||||||
|
|
||||||
|
// 获取待办任务显示操作按钮
|
||||||
|
operationButtonRef.value?.loadTodoTask(data.todoTask);
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('获取审批详情失败!');
|
||||||
|
} finally {
|
||||||
|
processInstanceLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取流程模型视图*/
|
||||||
|
async function getProcessModelView() {
|
||||||
|
if (BpmModelType.BPMN === processDefinition.value?.modelType) {
|
||||||
|
// 重置,解决 BPMN 流程图刷新不会重新渲染问题
|
||||||
|
processModelView.value = {
|
||||||
|
bpmnXml: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const data = await getProcessInstanceBpmnModelView(props.id);
|
||||||
|
if (data) {
|
||||||
|
processModelView.value = data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设置表单权限 */
|
||||||
|
function setFieldPermission(field: string, permission: string) {
|
||||||
|
if (permission === BpmFieldPermissionType.READ) {
|
||||||
|
fApi.value?.disabled(true, field);
|
||||||
|
}
|
||||||
|
if (permission === BpmFieldPermissionType.WRITE) {
|
||||||
|
fApi.value?.disabled(false, field);
|
||||||
|
// 加入可以编辑的字段
|
||||||
|
writableFields.push(field);
|
||||||
|
}
|
||||||
|
if (permission === BpmFieldPermissionType.NONE) {
|
||||||
|
fApi.value?.hidden(true, field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 操作成功后刷新 */
|
||||||
|
const refresh = () => {
|
||||||
|
// 重新获取详情
|
||||||
|
getDetail();
|
||||||
|
};
|
||||||
|
|
||||||
|
const [PrintModal, printModalApi] = useVbenModal({
|
||||||
|
connectedComponent: ProcessssPrint,
|
||||||
|
destroyOnClose: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 打开打印对话框 */
|
||||||
|
function handlePrint() {
|
||||||
|
printModalApi.setData({ processInstanceId: props.id }).open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 监听 Tab 切换,当切换到 "record" 标签时刷新任务列表 */
|
||||||
|
watch(
|
||||||
|
() => activeTab.value,
|
||||||
|
async (newVal) => {
|
||||||
|
if (newVal === 'record') {
|
||||||
|
// 如果切换到流转记录标签,刷新任务列表
|
||||||
|
await nextTick();
|
||||||
|
taskListRef.value?.refresh();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 初始化 */
|
||||||
|
onMounted(async () => {
|
||||||
|
await getDetail();
|
||||||
|
// 获得用户列表
|
||||||
|
userOptions.value = await getSimpleUserList();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Page auto-content-height>
|
||||||
|
<ElCard
|
||||||
|
class="flex h-full flex-col"
|
||||||
|
:body-style="{
|
||||||
|
flex: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
paddingTop: '12px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<span class="text-gray-500">编号:{{ id || '-' }}</span>
|
||||||
|
<IconifyIcon
|
||||||
|
icon="lucide:printer"
|
||||||
|
class="hover:text-primary cursor-pointer"
|
||||||
|
@click="handlePrint"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="flex h-full flex-col">
|
||||||
|
<!-- 流程基本信息 -->
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="flex h-10 items-center gap-5">
|
||||||
|
<div class="mb-1 text-2xl font-bold">
|
||||||
|
{{ processInstance?.name }}
|
||||||
|
</div>
|
||||||
|
<DictTag
|
||||||
|
v-if="processInstance?.status"
|
||||||
|
:type="DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS"
|
||||||
|
:value="processInstance.status"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex h-12 items-center gap-5 text-sm">
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 rounded-3xl bg-gray-100 px-2.5 py-1 dark:bg-gray-600"
|
||||||
|
>
|
||||||
|
<ElAvatar
|
||||||
|
:size="28"
|
||||||
|
v-if="processInstance?.startUser?.avatar"
|
||||||
|
:src="processInstance?.startUser?.avatar"
|
||||||
|
/>
|
||||||
|
<ElAvatar
|
||||||
|
:size="28"
|
||||||
|
v-else-if="processInstance?.startUser?.nickname"
|
||||||
|
>
|
||||||
|
{{ processInstance?.startUser?.nickname.substring(0, 1) }}
|
||||||
|
</ElAvatar>
|
||||||
|
<span class="text-sm">
|
||||||
|
{{ processInstance?.startUser?.nickname }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-500">
|
||||||
|
{{ formatDateTime(processInstance?.startTime) }} 提交
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<component
|
||||||
|
v-if="processInstance?.status"
|
||||||
|
:is="auditIconsMap[processInstance?.status]"
|
||||||
|
class="absolute right-5 top-2.5 size-36"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 流程操作 -->
|
||||||
|
<div class="flex h-full flex-1 flex-col">
|
||||||
|
<ElTabs v-model="activeTab">
|
||||||
|
<ElTabPane label="审批详情" name="form" class="pb-20 pr-3">
|
||||||
|
<ElRow :gutter="24">
|
||||||
|
<ElCol
|
||||||
|
:xs="24"
|
||||||
|
:sm="24"
|
||||||
|
:md="18"
|
||||||
|
:lg="18"
|
||||||
|
:xl="16"
|
||||||
|
class="h-full"
|
||||||
|
>
|
||||||
|
<!-- 流程表单 -->
|
||||||
|
<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-else-if="
|
||||||
|
processDefinition?.formType === BpmModelFormType.CUSTOM
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<BusinessFormComponent :id="processInstance?.businessKey" />
|
||||||
|
</div>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :xs="24" :sm="24" :md="6" :lg="6" :xl="8">
|
||||||
|
<div class="mt-2">
|
||||||
|
<ProcessInstanceTimeline :activity-nodes="activityNodes" />
|
||||||
|
</div>
|
||||||
|
</ElCol>
|
||||||
|
</ElRow>
|
||||||
|
</ElTabPane>
|
||||||
|
<ElTabPane label="流程图" name="diagram" class="pb-20 pr-3">
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ElTabPane>
|
||||||
|
<ElTabPane label="流转记录" name="record" class="pb-20 pr-3">
|
||||||
|
<BpmProcessInstanceTaskList
|
||||||
|
ref="taskListRef"
|
||||||
|
:loading="processInstanceLoading"
|
||||||
|
:id="id"
|
||||||
|
/>
|
||||||
|
</ElTabPane>
|
||||||
|
<!-- TODO 待开发 -->
|
||||||
|
<ElTabPane
|
||||||
|
label="流转评论"
|
||||||
|
name="comment"
|
||||||
|
v-if="false"
|
||||||
|
class="pr-3"
|
||||||
|
>
|
||||||
|
<div>待开发</div>
|
||||||
|
</ElTabPane>
|
||||||
|
</ElTabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="px-4">
|
||||||
|
<ProcessInstanceOperationButton
|
||||||
|
ref="operationButtonRef"
|
||||||
|
:process-instance="processInstance"
|
||||||
|
:process-definition="processDefinition"
|
||||||
|
:user-options="userOptions"
|
||||||
|
:normal-form="detailForm"
|
||||||
|
:normal-form-api="fApi"
|
||||||
|
:writable-fields="writableFields"
|
||||||
|
@success="refresh"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElCard>
|
||||||
|
<!-- 打印对话框 -->
|
||||||
|
<PrintModal />
|
||||||
|
</Page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
:deep(.el-tabs) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-card) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-card__body) {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-tabs__content) {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
|
||||||
|
// import { MyProcessViewer } from '#/views/bpm/components/bpmn-process-designer/package';
|
||||||
|
|
||||||
|
defineOptions({ name: 'ProcessInstanceBpmnViewer' });
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
bpmnXml?: string;
|
||||||
|
loading?: boolean; // 是否加载中
|
||||||
|
modelView?: Object;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
loading: false,
|
||||||
|
modelView: () => ({}),
|
||||||
|
bpmnXml: '',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// BPMN 流程图数据
|
||||||
|
const view = ref({
|
||||||
|
bpmnXml: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 监控 modelView 更新 */
|
||||||
|
watch(
|
||||||
|
() => props.modelView,
|
||||||
|
async (newModelView) => {
|
||||||
|
// 加载最新
|
||||||
|
if (newModelView) {
|
||||||
|
// @ts-ignore
|
||||||
|
view.value = newModelView;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 监听 bpmnXml */
|
||||||
|
watch(
|
||||||
|
() => props.bpmnXml,
|
||||||
|
(value) => {
|
||||||
|
view.value.bpmnXml = value;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-loading="loading"
|
||||||
|
class="h-full w-full overflow-auto rounded-lg border border-gray-200 bg-white p-4"
|
||||||
|
>
|
||||||
|
<!-- <MyProcessViewer
|
||||||
|
key="processViewer"
|
||||||
|
:xml="view.bpmnXml"
|
||||||
|
:view="view"
|
||||||
|
class="h-full min-h-[500px] w-full"
|
||||||
|
/> -->
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,299 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { BpmProcessInstanceApi } from '#/api/bpm/processInstance';
|
||||||
|
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
import { DICT_TYPE } from '@vben/constants';
|
||||||
|
import { getDictLabel } from '@vben/hooks';
|
||||||
|
import { useUserStore } from '@vben/stores';
|
||||||
|
import { formatDate } from '@vben/utils';
|
||||||
|
|
||||||
|
// @ts-ignore - 安装 vue3-print-nb 局部指令 v-print
|
||||||
|
import vPrint from 'vue3-print-nb';
|
||||||
|
|
||||||
|
import { getProcessInstancePrintData } from '#/api/bpm/processInstance';
|
||||||
|
import { decodeFields } from '#/components/form-create';
|
||||||
|
|
||||||
|
const userStore = useUserStore();
|
||||||
|
|
||||||
|
const printData = ref<BpmProcessInstanceApi.ProcessPrintDataRespVO>();
|
||||||
|
const userName = computed(() => userStore.userInfo?.nickname ?? '');
|
||||||
|
const printTime = ref(formatDate(new Date(), 'YYYY-MM-DD HH:mm'));
|
||||||
|
const formFields = ref<any[]>([]);
|
||||||
|
const printDataMap = ref<Record<string, any>>({});
|
||||||
|
|
||||||
|
/** 打印配置 */
|
||||||
|
const printObj = ref({
|
||||||
|
id: 'printDivTag',
|
||||||
|
popTitle: ' ',
|
||||||
|
extraCss: '/print.css',
|
||||||
|
extraHead: '',
|
||||||
|
zIndex: 20_003,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [Modal, modalApi] = useVbenModal({
|
||||||
|
closable: true,
|
||||||
|
footer: false,
|
||||||
|
title: '打印流程',
|
||||||
|
async onOpenChange(isOpen: boolean) {
|
||||||
|
if (!isOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
modalApi.lock();
|
||||||
|
try {
|
||||||
|
const { processInstanceId } = modalApi.getData<{
|
||||||
|
processInstanceId: string;
|
||||||
|
}>();
|
||||||
|
if (processInstanceId) {
|
||||||
|
await fetchPrintData(processInstanceId);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
modalApi.unlock();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 获取打印数据 */
|
||||||
|
async function fetchPrintData(id: string) {
|
||||||
|
printData.value = await getProcessInstancePrintData(id);
|
||||||
|
initPrintDataMap();
|
||||||
|
parseFormFields();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析表单字段 */
|
||||||
|
function parseFormFields() {
|
||||||
|
if (!printData.value) return;
|
||||||
|
|
||||||
|
const formFieldsObj = decodeFields(
|
||||||
|
printData.value.processInstance.processDefinition?.formFields || [],
|
||||||
|
);
|
||||||
|
const processVariables = printData.value.processInstance.formVariables;
|
||||||
|
const res: any = [];
|
||||||
|
|
||||||
|
for (const item of formFieldsObj) {
|
||||||
|
const id = item.field;
|
||||||
|
const name = item.title;
|
||||||
|
const fieldKey = item.field as string;
|
||||||
|
const variable = processVariables[fieldKey];
|
||||||
|
let html = variable;
|
||||||
|
|
||||||
|
switch (item.type) {
|
||||||
|
case 'checkbox':
|
||||||
|
case 'radio':
|
||||||
|
case 'select': {
|
||||||
|
const options = item.options;
|
||||||
|
const temp: any = [];
|
||||||
|
if (Array.isArray(options)) {
|
||||||
|
if (Array.isArray(variable)) {
|
||||||
|
const labels = options
|
||||||
|
.filter((o: any) => variable.includes(o.value))
|
||||||
|
.map((o: any) => o.label);
|
||||||
|
temp.push(...labels);
|
||||||
|
} else {
|
||||||
|
const opt = options.find((o: any) => o.value === variable);
|
||||||
|
if (opt) {
|
||||||
|
temp.push(opt.label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
html = temp.join(',');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'UploadImg': {
|
||||||
|
const imgEl = document.createElement('img');
|
||||||
|
imgEl.setAttribute('src', variable);
|
||||||
|
imgEl.setAttribute('style', 'max-width: 600px;');
|
||||||
|
html = imgEl.outerHTML;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// TODO 更多表单打印展示
|
||||||
|
}
|
||||||
|
|
||||||
|
printDataMap.value[fieldKey] = html;
|
||||||
|
res.push({ id, name, html });
|
||||||
|
}
|
||||||
|
|
||||||
|
formFields.value = res;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 初始化打印数据映射 */
|
||||||
|
function initPrintDataMap() {
|
||||||
|
if (!printData.value) return;
|
||||||
|
|
||||||
|
printDataMap.value.startUser =
|
||||||
|
printData.value.processInstance.startUser?.nickname || '';
|
||||||
|
printDataMap.value.startUserDept =
|
||||||
|
printData.value.processInstance.startUser?.deptName || '';
|
||||||
|
printDataMap.value.processName = printData.value.processInstance.name;
|
||||||
|
printDataMap.value.processNum = printData.value.processInstance.id;
|
||||||
|
printDataMap.value.startTime = formatDate(
|
||||||
|
printData.value.processInstance.startTime,
|
||||||
|
);
|
||||||
|
printDataMap.value.endTime = formatDate(
|
||||||
|
printData.value.processInstance.endTime,
|
||||||
|
);
|
||||||
|
printDataMap.value.processStatus = getDictLabel(
|
||||||
|
DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS,
|
||||||
|
printData.value.processInstance.status,
|
||||||
|
);
|
||||||
|
printDataMap.value.printUser = userName.value;
|
||||||
|
printDataMap.value.printTime = printTime.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取打印模板 HTML */
|
||||||
|
function getPrintTemplateHTML() {
|
||||||
|
if (!printData.value?.printTemplateHtml) return '';
|
||||||
|
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(
|
||||||
|
printData.value.printTemplateHtml,
|
||||||
|
'text/html',
|
||||||
|
);
|
||||||
|
|
||||||
|
// 替换 mentions
|
||||||
|
const mentions = doc.querySelectorAll('[data-w-e-type="mention"]');
|
||||||
|
mentions.forEach((item) => {
|
||||||
|
const htmlElement = item as HTMLElement;
|
||||||
|
const mentionId = JSON.parse(
|
||||||
|
decodeURIComponent(htmlElement.dataset.info ?? ''),
|
||||||
|
).id;
|
||||||
|
item.innerHTML = printDataMap.value[mentionId] ?? '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// 替换流程记录
|
||||||
|
const processRecords = doc.querySelectorAll(
|
||||||
|
'[data-w-e-type="process-record"]',
|
||||||
|
);
|
||||||
|
const processRecordTable: Element = document.createElement('table');
|
||||||
|
|
||||||
|
if (processRecords.length > 0) {
|
||||||
|
// 构建流程记录 html
|
||||||
|
processRecordTable.setAttribute('class', 'w-full border-collapse');
|
||||||
|
|
||||||
|
const headTr = document.createElement('tr');
|
||||||
|
const headTd = document.createElement('td');
|
||||||
|
headTd.setAttribute('colspan', '2');
|
||||||
|
headTd.setAttribute('class', 'border border-black p-1.5 text-center');
|
||||||
|
headTd.innerHTML = '流程记录';
|
||||||
|
headTr.append(headTd);
|
||||||
|
processRecordTable.append(headTr);
|
||||||
|
|
||||||
|
printData.value?.tasks.forEach((item) => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
const td1 = document.createElement('td');
|
||||||
|
td1.setAttribute('class', 'border border-black p-1.5');
|
||||||
|
td1.innerHTML = item.name;
|
||||||
|
const td2 = document.createElement('td');
|
||||||
|
td2.setAttribute('class', 'border border-black p-1.5');
|
||||||
|
td2.innerHTML = item.description;
|
||||||
|
tr.append(td1);
|
||||||
|
tr.append(td2);
|
||||||
|
processRecordTable.append(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
processRecords.forEach((item) => {
|
||||||
|
item.innerHTML = processRecordTable.outerHTML;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 返回 html
|
||||||
|
return doc.body.innerHTML;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal class="w-2/3">
|
||||||
|
<div id="printDivTag" class="break-all">
|
||||||
|
<!-- eslint-disable vue/no-v-html 使用自定义打印模板 -->
|
||||||
|
<div
|
||||||
|
v-if="printData?.printTemplateEnable"
|
||||||
|
v-html="getPrintTemplateHTML()"
|
||||||
|
></div>
|
||||||
|
<div v-else-if="printData">
|
||||||
|
<h2 class="mb-3 text-center text-xl font-bold">
|
||||||
|
{{ printData.processInstance.name }}
|
||||||
|
</h2>
|
||||||
|
<div class="mb-2 flex justify-between text-sm">
|
||||||
|
<div>
|
||||||
|
{{ `流程编号: ${printData.processInstance.id}` }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ `打印人员: ${userName}` }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table class="mt-3 w-full border-collapse">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="w-1/4 border border-black p-1.5">发起人</td>
|
||||||
|
<td class="w-1/4 border border-black p-1.5">
|
||||||
|
{{ printData.processInstance.startUser?.nickname }}
|
||||||
|
</td>
|
||||||
|
<td class="w-1/4 border border-black p-1.5">发起时间</td>
|
||||||
|
<td class="w-1/4 border border-black p-1.5">
|
||||||
|
{{ formatDate(printData.processInstance.startTime) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="w-1/4 border border-black p-1.5">所属部门</td>
|
||||||
|
<td class="w-1/4 border border-black p-1.5">
|
||||||
|
{{ printData.processInstance.startUser?.deptName }}
|
||||||
|
</td>
|
||||||
|
<td class="w-1/4 border border-black p-1.5">流程状态</td>
|
||||||
|
<td class="w-1/4 border border-black p-1.5">
|
||||||
|
{{
|
||||||
|
getDictLabel(
|
||||||
|
DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS,
|
||||||
|
printData.processInstance.status,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
class="w-full border border-black p-1.5 text-center"
|
||||||
|
colspan="4"
|
||||||
|
>
|
||||||
|
<h4>表单内容</h4>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="item in formFields" :key="item.id">
|
||||||
|
<td class="w-1/5 border border-black p-1.5">
|
||||||
|
{{ item.name }}
|
||||||
|
</td>
|
||||||
|
<td class="w-4/5 border border-black p-1.5" colspan="3">
|
||||||
|
<div v-html="item.html"></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
class="w-full border border-black p-1.5 text-center"
|
||||||
|
colspan="4"
|
||||||
|
>
|
||||||
|
<h4>流程记录</h4>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="item in printData.tasks" :key="item.id">
|
||||||
|
<td class="w-1/5 border border-black p-1.5">
|
||||||
|
{{ item.name }}
|
||||||
|
</td>
|
||||||
|
<td class="w-4/5 border border-black p-1.5" colspan="3">
|
||||||
|
{{ item.description }}
|
||||||
|
<div v-if="item.signPicUrl && item.signPicUrl.length > 0">
|
||||||
|
<img class="h-10 w-[90px]" :src="item.signPicUrl" alt="" />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<ElButton @click="modalApi.close()">取 消</ElButton>
|
||||||
|
<ElButton v-print="printObj" type="primary">打 印</ElButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
import { IconifyIcon } from '@vben/icons';
|
||||||
|
import { base64ToFile } from '@vben/utils';
|
||||||
|
|
||||||
|
import Vue3Signature from 'vue3-signature';
|
||||||
|
|
||||||
|
import { uploadFile } from '#/api/infra/file';
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'BpmProcessInstanceSignature',
|
||||||
|
});
|
||||||
|
|
||||||
|
const emits = defineEmits(['success']);
|
||||||
|
|
||||||
|
const signature = ref<InstanceType<typeof Vue3Signature>>();
|
||||||
|
|
||||||
|
const [Modal, modalApi] = useVbenModal({
|
||||||
|
async onConfirm() {
|
||||||
|
modalApi.lock();
|
||||||
|
try {
|
||||||
|
const signFileUrl = await uploadFile({
|
||||||
|
file: base64ToFile(signature?.value?.save('image/jpeg') || '', '签名'),
|
||||||
|
});
|
||||||
|
emits('success', signFileUrl);
|
||||||
|
await modalApi.close();
|
||||||
|
} finally {
|
||||||
|
modalApi.unlock();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal title="流程签名" class="w-3/5">
|
||||||
|
<div class="mb-2 flex justify-end">
|
||||||
|
<ElSpace>
|
||||||
|
<ElTooltip content="撤销上一步操作">
|
||||||
|
<ElButton @click="signature?.undo()">
|
||||||
|
<template #icon>
|
||||||
|
<IconifyIcon icon="lucide:undo" class="mb-1 size-4" />
|
||||||
|
</template>
|
||||||
|
撤销
|
||||||
|
</ElButton>
|
||||||
|
</ElTooltip>
|
||||||
|
<ElTooltip content="清空画布">
|
||||||
|
<ElButton @click="signature?.clear()">
|
||||||
|
<template #icon>
|
||||||
|
<IconifyIcon icon="lucide:trash" class="mb-1 size-4" />
|
||||||
|
</template>
|
||||||
|
<span>清除</span>
|
||||||
|
</ElButton>
|
||||||
|
</ElTooltip>
|
||||||
|
</ElSpace>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Vue3Signature
|
||||||
|
class="mx-auto !h-80 border border-solid border-gray-300"
|
||||||
|
ref="signature"
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { SimpleFlowNode } from '#/views/bpm/components/simple-process-design';
|
||||||
|
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { BpmNodeTypeEnum, BpmTaskStatusEnum } from '@vben/constants';
|
||||||
|
|
||||||
|
import { SimpleProcessViewer } from '#/views/bpm/components/simple-process-design';
|
||||||
|
|
||||||
|
defineOptions({ name: 'BpmProcessInstanceSimpleViewer' });
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
loading?: boolean; // 是否加载中
|
||||||
|
modelView?: any;
|
||||||
|
simpleJson?: string; // Simple 模型结构数据 (json 格式)
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
loading: false,
|
||||||
|
modelView: () => ({}),
|
||||||
|
simpleJson: '',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const simpleModel = ref<any>({});
|
||||||
|
const tasks = ref([]); // 用户任务
|
||||||
|
const processInstance = ref(); // 流程实例
|
||||||
|
|
||||||
|
/** 监控模型视图 包括任务列表、进行中的活动节点编号等 */
|
||||||
|
watch(
|
||||||
|
() => props.modelView,
|
||||||
|
async (newModelView) => {
|
||||||
|
if (newModelView) {
|
||||||
|
tasks.value = newModelView.tasks;
|
||||||
|
processInstance.value = newModelView.processInstance;
|
||||||
|
// 已经拒绝的活动节点编号集合,只包括 UserTask
|
||||||
|
const rejectedTaskActivityIds: string[] =
|
||||||
|
newModelView.rejectedTaskActivityIds;
|
||||||
|
// 进行中的活动节点编号集合, 只包括 UserTask
|
||||||
|
const unfinishedTaskActivityIds: string[] =
|
||||||
|
newModelView.unfinishedTaskActivityIds;
|
||||||
|
// 已经完成的活动节点编号集合, 包括 UserTask、Gateway 等
|
||||||
|
const finishedActivityIds: string[] =
|
||||||
|
newModelView.finishedTaskActivityIds;
|
||||||
|
// 已经完成的连线节点编号集合,只包括 SequenceFlow
|
||||||
|
const finishedSequenceFlowActivityIds: string[] =
|
||||||
|
newModelView.finishedSequenceFlowActivityIds;
|
||||||
|
setSimpleModelNodeTaskStatus(
|
||||||
|
newModelView.simpleModel,
|
||||||
|
newModelView.processInstance?.status,
|
||||||
|
rejectedTaskActivityIds,
|
||||||
|
unfinishedTaskActivityIds,
|
||||||
|
finishedActivityIds,
|
||||||
|
finishedSequenceFlowActivityIds,
|
||||||
|
);
|
||||||
|
simpleModel.value = newModelView.simpleModel || {};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 监控模型结构数据 */
|
||||||
|
watch(
|
||||||
|
() => props.simpleJson,
|
||||||
|
async (value) => {
|
||||||
|
if (value) {
|
||||||
|
simpleModel.value = JSON.parse(value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const setSimpleModelNodeTaskStatus = (
|
||||||
|
simpleModel: SimpleFlowNode | undefined,
|
||||||
|
processStatus: number,
|
||||||
|
rejectedTaskActivityIds: string[],
|
||||||
|
unfinishedTaskActivityIds: string[],
|
||||||
|
finishedActivityIds: string[],
|
||||||
|
finishedSequenceFlowActivityIds: string[],
|
||||||
|
) => {
|
||||||
|
if (!simpleModel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 结束节点
|
||||||
|
if (simpleModel.type === BpmNodeTypeEnum.END_EVENT_NODE) {
|
||||||
|
simpleModel.activityStatus = finishedActivityIds.includes(simpleModel.id)
|
||||||
|
? processStatus
|
||||||
|
: BpmTaskStatusEnum.NOT_START;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 审批节点
|
||||||
|
if (
|
||||||
|
simpleModel.type === BpmNodeTypeEnum.START_USER_NODE ||
|
||||||
|
simpleModel.type === BpmNodeTypeEnum.USER_TASK_NODE ||
|
||||||
|
simpleModel.type === BpmNodeTypeEnum.TRANSACTOR_NODE ||
|
||||||
|
simpleModel.type === BpmNodeTypeEnum.CHILD_PROCESS_NODE
|
||||||
|
) {
|
||||||
|
simpleModel.activityStatus = BpmTaskStatusEnum.NOT_START;
|
||||||
|
if (rejectedTaskActivityIds.includes(simpleModel.id)) {
|
||||||
|
simpleModel.activityStatus = BpmTaskStatusEnum.REJECT;
|
||||||
|
} else if (unfinishedTaskActivityIds.includes(simpleModel.id)) {
|
||||||
|
simpleModel.activityStatus = BpmTaskStatusEnum.RUNNING;
|
||||||
|
} else if (finishedActivityIds.includes(simpleModel.id)) {
|
||||||
|
simpleModel.activityStatus = BpmTaskStatusEnum.APPROVE;
|
||||||
|
}
|
||||||
|
// TODO 是不是还缺一个 cancel 的状态 @jason:
|
||||||
|
}
|
||||||
|
// 抄送节点
|
||||||
|
if (simpleModel.type === BpmNodeTypeEnum.COPY_TASK_NODE) {
|
||||||
|
// 抄送节点,只有通过和未执行状态
|
||||||
|
simpleModel.activityStatus = finishedActivityIds.includes(simpleModel.id)
|
||||||
|
? BpmTaskStatusEnum.APPROVE
|
||||||
|
: BpmTaskStatusEnum.NOT_START;
|
||||||
|
}
|
||||||
|
// 延迟器节点
|
||||||
|
if (simpleModel.type === BpmNodeTypeEnum.DELAY_TIMER_NODE) {
|
||||||
|
// 延迟器节点,只有通过和未执行状态
|
||||||
|
simpleModel.activityStatus = finishedActivityIds.includes(simpleModel.id)
|
||||||
|
? BpmTaskStatusEnum.APPROVE
|
||||||
|
: BpmTaskStatusEnum.NOT_START;
|
||||||
|
}
|
||||||
|
// 触发器节点
|
||||||
|
if (simpleModel.type === BpmNodeTypeEnum.TRIGGER_NODE) {
|
||||||
|
// 触发器节点,只有通过和未执行状态
|
||||||
|
simpleModel.activityStatus = finishedActivityIds.includes(simpleModel.id)
|
||||||
|
? BpmTaskStatusEnum.APPROVE
|
||||||
|
: BpmTaskStatusEnum.NOT_START;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 条件节点对应 SequenceFlow
|
||||||
|
if (simpleModel.type === BpmNodeTypeEnum.CONDITION_NODE) {
|
||||||
|
// 条件节点,只有通过和未执行状态
|
||||||
|
simpleModel.activityStatus = finishedSequenceFlowActivityIds.includes(
|
||||||
|
simpleModel.id,
|
||||||
|
)
|
||||||
|
? BpmTaskStatusEnum.APPROVE
|
||||||
|
: BpmTaskStatusEnum.NOT_START;
|
||||||
|
}
|
||||||
|
// 网关节点
|
||||||
|
if (
|
||||||
|
simpleModel.type === BpmNodeTypeEnum.CONDITION_BRANCH_NODE ||
|
||||||
|
simpleModel.type === BpmNodeTypeEnum.PARALLEL_BRANCH_NODE ||
|
||||||
|
simpleModel.type === BpmNodeTypeEnum.INCLUSIVE_BRANCH_NODE ||
|
||||||
|
simpleModel.type === BpmNodeTypeEnum.ROUTER_BRANCH_NODE
|
||||||
|
) {
|
||||||
|
// 网关节点。只有通过和未执行状态
|
||||||
|
simpleModel.activityStatus = finishedActivityIds.includes(simpleModel.id)
|
||||||
|
? BpmTaskStatusEnum.APPROVE
|
||||||
|
: BpmTaskStatusEnum.NOT_START;
|
||||||
|
simpleModel.conditionNodes?.forEach((node) => {
|
||||||
|
setSimpleModelNodeTaskStatus(
|
||||||
|
node,
|
||||||
|
processStatus,
|
||||||
|
rejectedTaskActivityIds,
|
||||||
|
unfinishedTaskActivityIds,
|
||||||
|
finishedActivityIds,
|
||||||
|
finishedSequenceFlowActivityIds,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setSimpleModelNodeTaskStatus(
|
||||||
|
simpleModel.childNode,
|
||||||
|
processStatus,
|
||||||
|
rejectedTaskActivityIds,
|
||||||
|
unfinishedTaskActivityIds,
|
||||||
|
finishedActivityIds,
|
||||||
|
finishedSequenceFlowActivityIds,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div v-loading="loading">
|
||||||
|
<SimpleProcessViewer
|
||||||
|
:flow-node="simpleModel"
|
||||||
|
:tasks="tasks"
|
||||||
|
:process-instance="processInstance"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||||
|
import type { BpmTaskApi } from '#/api/bpm/task';
|
||||||
|
|
||||||
|
import { nextTick, ref } from 'vue';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
import { DICT_TYPE } from '@vben/constants';
|
||||||
|
import { IconifyIcon } from '@vben/icons';
|
||||||
|
|
||||||
|
import FormCreate from '@form-create/element-ui';
|
||||||
|
|
||||||
|
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
|
import { getTaskListByProcessInstanceId } from '#/api/bpm/task';
|
||||||
|
import { setConfAndFields2 } from '#/components/form-create';
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'BpmProcessInstanceTaskList',
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
id: string;
|
||||||
|
loading: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
/** 表单类型定义 */
|
||||||
|
interface TaskForm {
|
||||||
|
rule: any[];
|
||||||
|
option: Record<string, any>;
|
||||||
|
value: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取表格列配置 */
|
||||||
|
function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
field: 'name',
|
||||||
|
title: '审批节点',
|
||||||
|
minWidth: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'approver',
|
||||||
|
title: '审批人',
|
||||||
|
slots: {
|
||||||
|
default: ({ row }: { row: BpmTaskApi.TaskManager }) => {
|
||||||
|
return row.assigneeUser?.nickname || row.ownerUser?.nickname;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
minWidth: 180,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'createTime',
|
||||||
|
title: '开始时间',
|
||||||
|
formatter: 'formatDateTime',
|
||||||
|
minWidth: 180,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'endTime',
|
||||||
|
title: '结束时间',
|
||||||
|
formatter: 'formatDateTime',
|
||||||
|
minWidth: 180,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'status',
|
||||||
|
title: '审批状态',
|
||||||
|
minWidth: 150,
|
||||||
|
cellRender: {
|
||||||
|
name: 'CellDict',
|
||||||
|
props: { type: DICT_TYPE.BPM_TASK_STATUS },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'reason',
|
||||||
|
title: '审批建议',
|
||||||
|
slots: {
|
||||||
|
default: 'slot-reason',
|
||||||
|
},
|
||||||
|
minWidth: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'durationInMillis',
|
||||||
|
title: '耗时',
|
||||||
|
minWidth: 180,
|
||||||
|
formatter: 'formatPast2',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const formRef = ref();
|
||||||
|
const taskForm = ref<TaskForm>({
|
||||||
|
rule: [],
|
||||||
|
option: {},
|
||||||
|
value: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [Modal, modalApi] = useVbenModal({
|
||||||
|
title: '查看表单',
|
||||||
|
footer: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 刷新表格 */
|
||||||
|
function handleRefresh() {
|
||||||
|
gridApi.query();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 显示表单详情 */
|
||||||
|
async function handleShowFormDetail(row: BpmTaskApi.TaskManager) {
|
||||||
|
// 设置表单配置和表单字段
|
||||||
|
taskForm.value = {
|
||||||
|
rule: [],
|
||||||
|
option: {},
|
||||||
|
value: row,
|
||||||
|
};
|
||||||
|
setConfAndFields2(
|
||||||
|
taskForm,
|
||||||
|
row.formConf,
|
||||||
|
row.formFields || [],
|
||||||
|
row.formVariables || {},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 打开弹窗
|
||||||
|
modalApi.open();
|
||||||
|
// 等待表单渲染
|
||||||
|
await nextTick();
|
||||||
|
// 获取表单 API 实例
|
||||||
|
const formApi = formRef.value?.fapi;
|
||||||
|
if (!formApi) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 设置表单不可编辑
|
||||||
|
formApi.btn.show(false);
|
||||||
|
formApi.resetBtn.show(false);
|
||||||
|
formApi.disabled(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [Grid, gridApi] = useVbenVxeGrid({
|
||||||
|
gridOptions: {
|
||||||
|
columns: useGridColumns(),
|
||||||
|
keepSource: true,
|
||||||
|
showFooter: true,
|
||||||
|
border: true,
|
||||||
|
proxyConfig: {
|
||||||
|
ajax: {
|
||||||
|
query: async () => {
|
||||||
|
return await getTaskListByProcessInstanceId(props.id);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rowConfig: {
|
||||||
|
keyField: 'id',
|
||||||
|
isHover: true,
|
||||||
|
},
|
||||||
|
pagerConfig: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
toolbarConfig: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
} as VxeTableGridOptions<BpmTaskApi.TaskManager>,
|
||||||
|
});
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
refresh: handleRefresh,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="h-full">
|
||||||
|
<Grid>
|
||||||
|
<template #slot-reason="{ row }">
|
||||||
|
<div class="flex flex-wrap items-center justify-center">
|
||||||
|
<span v-if="row.reason">{{ row.reason }}</span>
|
||||||
|
<span v-else>-</span>
|
||||||
|
<ElButton
|
||||||
|
v-if="row.formId > 0"
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
plain
|
||||||
|
class="ml-1"
|
||||||
|
@click="handleShowFormDetail(row)"
|
||||||
|
>
|
||||||
|
<IconifyIcon icon="lucide:file-text" />
|
||||||
|
<span class="!ml-0.5 text-xs">查看表单</span>
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Grid>
|
||||||
|
</div>
|
||||||
|
<Modal class="w-[800px]">
|
||||||
|
<FormCreate
|
||||||
|
ref="formRef"
|
||||||
|
v-model="taskForm.value"
|
||||||
|
:option="taskForm.option"
|
||||||
|
:rule="taskForm.rule"
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
<!-- 审批详情的右侧:审批流 -->
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { BpmProcessInstanceApi } from '#/api/bpm/processInstance';
|
||||||
|
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
import {
|
||||||
|
BpmCandidateStrategyEnum,
|
||||||
|
BpmNodeTypeEnum,
|
||||||
|
BpmTaskStatusEnum,
|
||||||
|
} from '@vben/constants';
|
||||||
|
import { IconifyIcon } from '@vben/icons';
|
||||||
|
import { formatDateTime, isEmpty } from '@vben/utils';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ElAvatar,
|
||||||
|
ElButton,
|
||||||
|
ElImage,
|
||||||
|
ElTimeline,
|
||||||
|
ElTimelineItem,
|
||||||
|
ElTooltip,
|
||||||
|
} from 'element-plus';
|
||||||
|
|
||||||
|
import { UserSelectModal } from '#/views/system/user/components';
|
||||||
|
|
||||||
|
defineOptions({ name: 'BpmProcessInstanceTimeline' });
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
activityNodes: BpmProcessInstanceApi.ApprovalNodeInfo[]; // 审批节点信息
|
||||||
|
enableApproveUserSelect?: boolean; // 是否开启审批人自选功能
|
||||||
|
showStatusIcon?: boolean; // 是否显示头像右下角状态图标
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
showStatusIcon: true, // 默认值为 true
|
||||||
|
enableApproveUserSelect: false, // 默认值为 false
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
selectUserConfirm: [activityId: string, userList: any[]];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { push } = useRouter();
|
||||||
|
|
||||||
|
const statusIconMap: Record<
|
||||||
|
string,
|
||||||
|
{ animation?: string; color: string; icon: string }
|
||||||
|
> = {
|
||||||
|
'-2': { color: '#909398', icon: 'mdi:skip-forward-outline' }, // 跳过
|
||||||
|
'-1': { color: '#909398', icon: 'mdi:clock-outline' }, // 审批未开始
|
||||||
|
'0': { color: '#ff943e', icon: 'mdi:loading', animation: 'animate-spin' }, // 待审批
|
||||||
|
'1': { color: '#448ef7', icon: 'mdi:loading', animation: 'animate-spin' }, // 审批中
|
||||||
|
'2': { color: '#00b32a', icon: 'mdi:check' }, // 审批通过
|
||||||
|
'3': { color: '#f46b6c', icon: 'mdi:close' }, // 审批不通过
|
||||||
|
'4': { color: '#cccccc', icon: 'mdi:trash-can-outline' }, // 已取消
|
||||||
|
'5': { color: '#f46b6c', icon: 'mdi:arrow-left' }, // 退回
|
||||||
|
'6': { color: '#448ef7', icon: 'mdi:clock-outline' }, // 委派中
|
||||||
|
'7': { color: '#00b32a', icon: 'mdi:check' }, // 审批通过中
|
||||||
|
}; // 状态图标映射
|
||||||
|
const nodeTypeSvgMap = {
|
||||||
|
// 结束节点
|
||||||
|
[BpmNodeTypeEnum.END_EVENT_NODE]: {
|
||||||
|
color: '#909398',
|
||||||
|
icon: 'mdi:power',
|
||||||
|
},
|
||||||
|
// 开始节点
|
||||||
|
[BpmNodeTypeEnum.START_USER_NODE]: {
|
||||||
|
color: '#909398',
|
||||||
|
icon: 'mdi:account-outline',
|
||||||
|
},
|
||||||
|
// 用户任务节点
|
||||||
|
[BpmNodeTypeEnum.USER_TASK_NODE]: {
|
||||||
|
color: '#ff943e',
|
||||||
|
icon: 'tdesign:seal',
|
||||||
|
},
|
||||||
|
// 事务节点
|
||||||
|
[BpmNodeTypeEnum.TRANSACTOR_NODE]: {
|
||||||
|
color: '#ff943e',
|
||||||
|
icon: 'mdi:file-edit-outline',
|
||||||
|
},
|
||||||
|
// 复制任务节点
|
||||||
|
[BpmNodeTypeEnum.COPY_TASK_NODE]: {
|
||||||
|
color: '#3296fb',
|
||||||
|
icon: 'mdi:content-copy',
|
||||||
|
},
|
||||||
|
// 条件分支节点
|
||||||
|
[BpmNodeTypeEnum.CONDITION_NODE]: {
|
||||||
|
color: '#14bb83',
|
||||||
|
icon: 'carbon:flow',
|
||||||
|
},
|
||||||
|
// 并行分支节点
|
||||||
|
[BpmNodeTypeEnum.PARALLEL_BRANCH_NODE]: {
|
||||||
|
color: '#14bb83',
|
||||||
|
icon: 'si:flow-parallel-line',
|
||||||
|
},
|
||||||
|
// 子流程节点
|
||||||
|
[BpmNodeTypeEnum.CHILD_PROCESS_NODE]: {
|
||||||
|
color: '#14bb83',
|
||||||
|
icon: 'icon-park-outline:tree-diagram',
|
||||||
|
},
|
||||||
|
} as Record<BpmNodeTypeEnum, { color: string; icon: string }>; // 节点类型图标映射
|
||||||
|
const onlyStatusIconShow = [-1, 0, 1]; // 只有状态是 -1、0、1 才展示头像右小角状态小 icon
|
||||||
|
|
||||||
|
/** 获取审批节点类型图标 */
|
||||||
|
function getApprovalNodeTypeIcon(nodeType: BpmNodeTypeEnum) {
|
||||||
|
return nodeTypeSvgMap[nodeType]?.icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取审批节点图标 */
|
||||||
|
function getApprovalNodeIcon(taskStatus: number, nodeType: BpmNodeTypeEnum) {
|
||||||
|
if (taskStatus === BpmTaskStatusEnum.NOT_START) {
|
||||||
|
return statusIconMap[taskStatus]?.icon || 'mdi:clock-outline';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
BpmNodeTypeEnum.CHILD_PROCESS_NODE,
|
||||||
|
BpmNodeTypeEnum.END_EVENT_NODE,
|
||||||
|
BpmNodeTypeEnum.START_USER_NODE,
|
||||||
|
BpmNodeTypeEnum.TRANSACTOR_NODE,
|
||||||
|
BpmNodeTypeEnum.USER_TASK_NODE,
|
||||||
|
].includes(nodeType)
|
||||||
|
) {
|
||||||
|
return statusIconMap[taskStatus]?.icon || 'mdi:clock-outline';
|
||||||
|
}
|
||||||
|
return 'mdi:clock-outline';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取审批节点颜色 */
|
||||||
|
function getApprovalNodeColor(taskStatus: number) {
|
||||||
|
return statusIconMap[taskStatus]?.color;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取审批节点时间 */
|
||||||
|
function getApprovalNodeTime(node: BpmProcessInstanceApi.ApprovalNodeInfo) {
|
||||||
|
if (node.nodeType === BpmNodeTypeEnum.START_USER_NODE && node.startTime) {
|
||||||
|
return formatDateTime(node.startTime);
|
||||||
|
}
|
||||||
|
if (node.endTime) {
|
||||||
|
return formatDateTime(node.endTime);
|
||||||
|
}
|
||||||
|
if (node.startTime) {
|
||||||
|
return formatDateTime(node.startTime);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const [UserSelectModalComp, userSelectModalApi] = useVbenModal({
|
||||||
|
connectedComponent: UserSelectModal,
|
||||||
|
destroyOnClose: true,
|
||||||
|
});
|
||||||
|
const selectedActivityNodeId = ref<string>();
|
||||||
|
const customApproveUsers = ref<Record<string, any[]>>({}); // key:activityId,value:用户列表
|
||||||
|
|
||||||
|
/** 打开选择用户弹窗 */
|
||||||
|
const handleSelectUser = (activityId: string, selectedList: any[]) => {
|
||||||
|
selectedActivityNodeId.value = activityId;
|
||||||
|
userSelectModalApi
|
||||||
|
.setData({ userIds: selectedList.map((item) => item.id) })
|
||||||
|
.open();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 选择用户完成 */
|
||||||
|
const selectedUsers = ref<number[]>([]);
|
||||||
|
function handleUserSelectConfirm(userList: any[]) {
|
||||||
|
if (!selectedActivityNodeId.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
customApproveUsers.value[selectedActivityNodeId.value] = userList || [];
|
||||||
|
|
||||||
|
emit('selectUserConfirm', selectedActivityNodeId.value, userList);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 跳转子流程 */
|
||||||
|
function handleChildProcess(activity: any) {
|
||||||
|
if (!activity.processInstanceId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
push({
|
||||||
|
name: 'BpmProcessInstanceDetail',
|
||||||
|
query: {
|
||||||
|
id: activity.processInstanceId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断是否需要显示自定义选择审批人 */
|
||||||
|
function shouldShowCustomUserSelect(
|
||||||
|
activity: BpmProcessInstanceApi.ApprovalNodeInfo,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
isEmpty(activity.tasks) &&
|
||||||
|
((BpmCandidateStrategyEnum.START_USER_SELECT ===
|
||||||
|
activity.candidateStrategy &&
|
||||||
|
isEmpty(activity.candidateUsers)) ||
|
||||||
|
(props.enableApproveUserSelect &&
|
||||||
|
BpmCandidateStrategyEnum.APPROVE_USER_SELECT ===
|
||||||
|
activity.candidateStrategy))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断是否需要显示审批意见 */
|
||||||
|
function shouldShowApprovalReason(task: any, nodeType: BpmNodeTypeEnum) {
|
||||||
|
return (
|
||||||
|
task.reason &&
|
||||||
|
[BpmNodeTypeEnum.END_EVENT_NODE, BpmNodeTypeEnum.USER_TASK_NODE].includes(
|
||||||
|
nodeType,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户选择弹窗关闭 */
|
||||||
|
function handleUserSelectClosed() {
|
||||||
|
selectedUsers.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户选择弹窗取消 */
|
||||||
|
function handleUserSelectCancel() {
|
||||||
|
selectedUsers.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设置自定义审批人 */
|
||||||
|
const setCustomApproveUsers = (activityId: string, users: any[]) => {
|
||||||
|
customApproveUsers.value[activityId] = users || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 批量设置多个节点的自定义审批人 */
|
||||||
|
const batchSetCustomApproveUsers = (data: Record<string, any[]>) => {
|
||||||
|
Object.keys(data).forEach((activityId) => {
|
||||||
|
customApproveUsers.value[activityId] = data[activityId] || [];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({ setCustomApproveUsers, batchSetCustomApproveUsers });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<ElTimeline class="pt-5">
|
||||||
|
<!-- 遍历每个审批节点 -->
|
||||||
|
<ElTimelineItem
|
||||||
|
v-for="(activity, index) in activityNodes"
|
||||||
|
:key="index"
|
||||||
|
:color="getApprovalNodeColor(activity.status)"
|
||||||
|
>
|
||||||
|
<template #dot>
|
||||||
|
<div class="relative">
|
||||||
|
<div
|
||||||
|
class="absolute -left-2.5 -top-1.5 flex h-8 w-8 items-center justify-center rounded-full border border-solid border-gray-200 bg-blue-500 p-1.5"
|
||||||
|
>
|
||||||
|
<IconifyIcon
|
||||||
|
:icon="getApprovalNodeTypeIcon(activity.nodeType)"
|
||||||
|
class="size-6 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="showStatusIcon"
|
||||||
|
class="absolute left-1.5 top-2.5 flex size-4 items-center rounded-full border-2 border-solid border-white p-0.5"
|
||||||
|
:style="{
|
||||||
|
backgroundColor: getApprovalNodeColor(activity.status),
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<IconifyIcon
|
||||||
|
:icon="getApprovalNodeIcon(activity.status, activity.nodeType)"
|
||||||
|
class="text-white"
|
||||||
|
:class="[statusIconMap[activity.status]?.animation]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="ml-2 flex flex-col items-start gap-2"
|
||||||
|
:id="`activity-task-${activity.id}-${index}`"
|
||||||
|
>
|
||||||
|
<!-- 第一行:节点名称、时间 -->
|
||||||
|
<div class="flex w-full items-center">
|
||||||
|
<div class="font-bold">
|
||||||
|
{{ activity.name }}
|
||||||
|
<span v-if="activity.status === BpmTaskStatusEnum.SKIP">
|
||||||
|
【跳过】
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- 信息:时间 -->
|
||||||
|
<div
|
||||||
|
v-if="activity.status !== BpmTaskStatusEnum.NOT_START"
|
||||||
|
class="ml-auto text-sm text-gray-500"
|
||||||
|
>
|
||||||
|
{{ getApprovalNodeTime(activity) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 子流程节点 -->
|
||||||
|
<div v-if="activity.nodeType === BpmNodeTypeEnum.CHILD_PROCESS_NODE">
|
||||||
|
<ElButton
|
||||||
|
type="primary"
|
||||||
|
plain
|
||||||
|
size="small"
|
||||||
|
@click="handleChildProcess(activity)"
|
||||||
|
:disabled="!activity.processInstanceId"
|
||||||
|
>
|
||||||
|
查看子流程
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 需要自定义选择审批人 -->
|
||||||
|
<div
|
||||||
|
v-if="shouldShowCustomUserSelect(activity)"
|
||||||
|
class="flex flex-wrap items-center gap-2"
|
||||||
|
>
|
||||||
|
<ElTooltip content="添加用户" placement="left">
|
||||||
|
<ElButton
|
||||||
|
type="primary"
|
||||||
|
size="default"
|
||||||
|
plain
|
||||||
|
class="flex items-center justify-center"
|
||||||
|
@click="
|
||||||
|
handleSelectUser(
|
||||||
|
activity.id,
|
||||||
|
customApproveUsers[activity.id] ?? [],
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<IconifyIcon icon="lucide:user-plus" class="size-4" />
|
||||||
|
</ElButton>
|
||||||
|
</ElTooltip>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="(user, userIndex) in customApproveUsers[activity.id]"
|
||||||
|
:key="user.id || userIndex"
|
||||||
|
class="relative flex h-9 items-center gap-2 rounded-3xl bg-gray-100 pr-2 dark:bg-gray-600"
|
||||||
|
>
|
||||||
|
<ElAvatar
|
||||||
|
class="!m-1"
|
||||||
|
:size="28"
|
||||||
|
v-if="user.avatar"
|
||||||
|
:src="user.avatar"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ElAvatar class="!m-1" :size="28" v-else>
|
||||||
|
<span>{{ user.nickname.substring(0, 1) }}</span>
|
||||||
|
</ElAvatar>
|
||||||
|
<span class="text-sm">{{ user.nickname }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="mt-1 flex flex-wrap items-center gap-2">
|
||||||
|
<!-- 情况一:遍历每个审批节点下的【进行中】task 任务 -->
|
||||||
|
<div
|
||||||
|
v-for="(task, idx) in activity.tasks"
|
||||||
|
:key="idx"
|
||||||
|
class="flex flex-col gap-2 pr-2"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="relative flex flex-wrap gap-2"
|
||||||
|
v-if="task.assigneeUser || task.ownerUser"
|
||||||
|
>
|
||||||
|
<!-- 信息:头像昵称 -->
|
||||||
|
<div class="relative flex h-8 items-center rounded-3xl pr-2">
|
||||||
|
<template
|
||||||
|
v-if="
|
||||||
|
task.assigneeUser?.avatar || task.assigneeUser?.nickname
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<ElAvatar
|
||||||
|
class="!m-1"
|
||||||
|
:size="28"
|
||||||
|
v-if="task.assigneeUser?.avatar"
|
||||||
|
:src="task.assigneeUser?.avatar"
|
||||||
|
/>
|
||||||
|
<ElAvatar class="!m-1" :size="28" v-else>
|
||||||
|
{{ task.assigneeUser?.nickname.substring(0, 1) }}
|
||||||
|
</ElAvatar>
|
||||||
|
{{ task.assigneeUser?.nickname }}
|
||||||
|
</template>
|
||||||
|
<template
|
||||||
|
v-else-if="
|
||||||
|
task.ownerUser?.avatar || task.ownerUser?.nickname
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<ElAvatar
|
||||||
|
class="!m-1"
|
||||||
|
:size="28"
|
||||||
|
v-if="task.ownerUser?.avatar"
|
||||||
|
:src="task.ownerUser?.avatar"
|
||||||
|
/>
|
||||||
|
<ElAvatar class="!m-1" :size="28" v-else>
|
||||||
|
{{ task.ownerUser?.nickname.substring(0, 1) }}
|
||||||
|
</ElAvatar>
|
||||||
|
{{ task.ownerUser?.nickname }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 信息:任务状态图标 -->
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
showStatusIcon && onlyStatusIconShow.includes(task.status)
|
||||||
|
"
|
||||||
|
class="absolute left-5 top-5 flex items-center rounded-full border-2 border-solid border-white p-1"
|
||||||
|
:style="{
|
||||||
|
backgroundColor: statusIconMap[task.status]?.color,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<IconifyIcon
|
||||||
|
:icon="statusIconMap[task.status]?.icon || 'lucide:clock'"
|
||||||
|
class="size-1.5 text-white"
|
||||||
|
:class="[statusIconMap[task.status]?.animation]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 审批意见和签名 -->
|
||||||
|
<teleport defer :to="`#activity-task-${activity.id}-${index}`">
|
||||||
|
<div
|
||||||
|
v-if="shouldShowApprovalReason(task, activity.nodeType)"
|
||||||
|
class="mt-1 w-full rounded-md bg-gray-100 p-2 text-sm text-gray-500"
|
||||||
|
>
|
||||||
|
审批意见:{{ task.reason }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
task.signPicUrl &&
|
||||||
|
activity.nodeType === BpmNodeTypeEnum.USER_TASK_NODE
|
||||||
|
"
|
||||||
|
class="mt-1 w-full rounded-md bg-gray-100 p-2 text-sm text-gray-500"
|
||||||
|
>
|
||||||
|
签名:
|
||||||
|
<ElImage
|
||||||
|
class="ml-1 h-10 w-24"
|
||||||
|
:src="task.signPicUrl"
|
||||||
|
:preview-src-list="[task.signPicUrl]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</teleport>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 情况二:遍历每个审批节点下的【候选的】task 任务 -->
|
||||||
|
<div
|
||||||
|
v-for="(user, userIndex) in activity.candidateUsers"
|
||||||
|
:key="userIndex"
|
||||||
|
class="relative flex h-8 items-center rounded-3xl pr-2"
|
||||||
|
>
|
||||||
|
<ElAvatar
|
||||||
|
class="!m-1"
|
||||||
|
:size="28"
|
||||||
|
v-if="user.avatar"
|
||||||
|
:src="user.avatar"
|
||||||
|
/>
|
||||||
|
<ElAvatar class="!m-1" :size="28" v-else>
|
||||||
|
{{ user.nickname.substring(0, 1) }}
|
||||||
|
</ElAvatar>
|
||||||
|
<span class="text-sm">
|
||||||
|
{{ user.nickname }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- 候选任务状态图标 -->
|
||||||
|
<div
|
||||||
|
v-if="showStatusIcon"
|
||||||
|
class="absolute left-6 top-5 flex items-center rounded-full border-2 border-solid border-white p-1"
|
||||||
|
:style="{ backgroundColor: statusIconMap['-1']?.color }"
|
||||||
|
>
|
||||||
|
<IconifyIcon
|
||||||
|
class="text-xs text-white"
|
||||||
|
:icon="statusIconMap['-1']?.icon || 'lucide:clock'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElTimelineItem>
|
||||||
|
</ElTimeline>
|
||||||
|
|
||||||
|
<!-- 用户选择弹窗 -->
|
||||||
|
<UserSelectModalComp
|
||||||
|
class="w-3/5"
|
||||||
|
v-model="selectedUsers"
|
||||||
|
:multiple="true"
|
||||||
|
title="选择用户"
|
||||||
|
@confirm="handleUserSelectConfirm"
|
||||||
|
@closed="handleUserSelectClosed"
|
||||||
|
@cancel="handleUserSelectCancel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -75,7 +75,7 @@ function handleHotAreaSelected(
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-40px flex items-center justify-center">
|
<div class="h-40px flex items-center justify-center">
|
||||||
<MagicCubeEditor
|
<MagicCubeEditor
|
||||||
v-model="cellList as any"
|
v-model="cellList"
|
||||||
:cols="cellCount"
|
:cols="cellCount"
|
||||||
:cube-size="38"
|
:cube-size="38"
|
||||||
:rows="1"
|
:rows="1"
|
||||||
|
|||||||
@@ -1,24 +1,26 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref } from 'vue';
|
|
||||||
|
|
||||||
import { MpMsgType } from '@vben/constants';
|
import { MpMsgType } from '@vben/constants';
|
||||||
|
|
||||||
import Location from '#/views/mp/components/wx-location/wx-location.vue';
|
import {
|
||||||
import Music from '#/views/mp/components/wx-music/wx-music.vue';
|
WxLocation,
|
||||||
import News from '#/views/mp/components/wx-news/wx-news.vue';
|
WxMusic,
|
||||||
import VideoPlayer from '#/views/mp/components/wx-video-play/wx-video-play.vue';
|
WxNews,
|
||||||
import VoicePlayer from '#/views/mp/components/wx-voice-play/wx-voice-play.vue';
|
WxVideoPlayer,
|
||||||
|
WxVoicePlayer,
|
||||||
|
} from '#/views/mp/components';
|
||||||
|
|
||||||
import MsgEvent from './msg-event.vue';
|
import MsgEvent from './msg-event.vue';
|
||||||
|
|
||||||
// TODO @hw:antd 和 ele 保持一致;例如说:1)props;2)WxVoicePlayer 这种;
|
defineOptions({ name: 'WxMsg' });
|
||||||
defineOptions({ name: 'Msg' });
|
|
||||||
|
|
||||||
const props = defineProps<{
|
withDefaults(
|
||||||
item: any;
|
defineProps<{
|
||||||
}>();
|
item?: any;
|
||||||
|
}>(),
|
||||||
const item = ref<any>(props.item);
|
{
|
||||||
|
item: {},
|
||||||
|
},
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -28,7 +30,7 @@ const item = ref<any>(props.item);
|
|||||||
<div v-else-if="item.type === MpMsgType.Text">{{ item.content }}</div>
|
<div v-else-if="item.type === MpMsgType.Text">{{ item.content }}</div>
|
||||||
|
|
||||||
<div v-else-if="item.type === MpMsgType.Voice">
|
<div v-else-if="item.type === MpMsgType.Voice">
|
||||||
<VoicePlayer :url="item.mediaUrl" :content="item.recognition" />
|
<WxVideoPlayer :url="item.mediaUrl" :content="item.recognition" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="item.type === MpMsgType.Image">
|
<div v-else-if="item.type === MpMsgType.Image">
|
||||||
@@ -41,7 +43,7 @@ const item = ref<any>(props.item);
|
|||||||
v-else-if="item.type === MpMsgType.Video || item.type === 'shortvideo'"
|
v-else-if="item.type === MpMsgType.Video || item.type === 'shortvideo'"
|
||||||
class="text-center"
|
class="text-center"
|
||||||
>
|
>
|
||||||
<VideoPlayer :url="item.mediaUrl" />
|
<WxVoicePlayer :url="item.mediaUrl" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="item.type === MpMsgType.Link" class="flex-1">
|
<div v-else-if="item.type === MpMsgType.Link" class="flex-1">
|
||||||
@@ -66,7 +68,7 @@ const item = ref<any>(props.item);
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="item.type === MpMsgType.Location">
|
<div v-else-if="item.type === MpMsgType.Location">
|
||||||
<Location
|
<WxLocation
|
||||||
:label="item.label"
|
:label="item.label"
|
||||||
:location-y="item.locationY"
|
:location-y="item.locationY"
|
||||||
:location-x="item.locationX"
|
:location-x="item.locationX"
|
||||||
@@ -74,11 +76,11 @@ const item = ref<any>(props.item);
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="item.type === MpMsgType.News" class="w-[300px]">
|
<div v-else-if="item.type === MpMsgType.News" class="w-[300px]">
|
||||||
<News :articles="item.articles" />
|
<WxNews :articles="item.articles" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="item.type === MpMsgType.Music">
|
<div v-else-if="item.type === MpMsgType.Music">
|
||||||
<Music
|
<WxMusic
|
||||||
:title="item.title"
|
:title="item.title"
|
||||||
:description="item.description"
|
:description="item.description"
|
||||||
:thumb-media-url="item.thumbMediaUrl"
|
:thumb-media-url="item.thumbMediaUrl"
|
||||||
|
|||||||
7
apps/web-ele/src/views/mp/components/wx-music/types.ts
Normal file
7
apps/web-ele/src/views/mp/components/wx-music/types.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export interface WxMusicProps {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
musicUrl?: string;
|
||||||
|
hqMusicUrl?: string;
|
||||||
|
thumbMediaUrl: string;
|
||||||
|
}
|
||||||
@@ -1,37 +1,23 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import type { WxMusicProps } from './types';
|
||||||
|
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
import { ElLink } from 'element-plus';
|
import { ElLink } from 'element-plus';
|
||||||
|
|
||||||
/** 微信消息 - 音乐 */
|
/** 微信消息 - 音乐 */
|
||||||
defineOptions({ name: 'Music' });
|
defineOptions({ name: 'WxMusic' });
|
||||||
// TODO @hw:antd 和 ele 的代码风格不一致,例如说:props;
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = withDefaults(defineProps<WxMusicProps>(), {
|
||||||
title: {
|
title: '',
|
||||||
required: false,
|
description: '',
|
||||||
type: String,
|
musicUrl: '',
|
||||||
default: '',
|
hqMusicUrl: '',
|
||||||
},
|
thumbMediaUrl: '',
|
||||||
description: {
|
|
||||||
required: false,
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
musicUrl: {
|
|
||||||
required: false,
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
hqMusicUrl: {
|
|
||||||
required: false,
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
thumbMediaUrl: {
|
|
||||||
required: true,
|
|
||||||
type: String,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const href = computed(() => props.hqMusicUrl || props.musicUrl);
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
musicUrl: props.musicUrl,
|
musicUrl: props.musicUrl,
|
||||||
});
|
});
|
||||||
@@ -43,7 +29,7 @@ defineExpose({
|
|||||||
type="success"
|
type="success"
|
||||||
:underline="false"
|
:underline="false"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
:href="hqMusicUrl ? hqMusicUrl : musicUrl"
|
:href="href"
|
||||||
class="block"
|
class="block"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { IconifyIcon } from '@vben/icons';
|
|||||||
|
|
||||||
// 因为微信语音是 amr 格式,所以需要用到 amr 解码器:https://www.npmjs.com/package/benz-amr-recorder
|
// 因为微信语音是 amr 格式,所以需要用到 amr 解码器:https://www.npmjs.com/package/benz-amr-recorder
|
||||||
import BenzAMRRecorder from 'benz-amr-recorder';
|
import BenzAMRRecorder from 'benz-amr-recorder';
|
||||||
|
import { ElTag } from 'element-plus';
|
||||||
|
|
||||||
/** 微信消息 - 语音 */
|
/** 微信消息 - 语音 */
|
||||||
defineOptions({ name: 'WxVoicePlayer' });
|
defineOptions({ name: 'WxVoicePlayer' });
|
||||||
@@ -82,7 +83,7 @@ function amrStop() {
|
|||||||
</span>
|
</span>
|
||||||
</el-icon>
|
</el-icon>
|
||||||
<div v-if="content">
|
<div v-if="content">
|
||||||
<el-tag type="success" size="small">语音识别</el-tag>
|
<ElTag type="success" size="small">语音识别</ElTag>
|
||||||
{{ content }}
|
{{ content }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -106,15 +106,18 @@ function plusNews() {
|
|||||||
@click="activeNewsIndex = index"
|
@click="activeNewsIndex = index"
|
||||||
>
|
>
|
||||||
<div class="relative w-full bg-[#acadae]">
|
<div class="relative w-full bg-[#acadae]">
|
||||||
<img class="h-full w-full" :src="news.thumbUrl" />
|
<img
|
||||||
|
class="max-h-[200px] min-h-[100px] w-full object-cover"
|
||||||
|
:src="news.thumbUrl"
|
||||||
|
/>
|
||||||
<div
|
<div
|
||||||
class="absolute bottom-0 left-0 inline-block h-[25px] w-[100%] overflow-hidden text-ellipsis whitespace-nowrap bg-black p-[1%] text-center text-[15px] text-white opacity-65"
|
class="absolute bottom-0 left-0 mb-[5px] ml-[5px] inline-block h-[25px] w-[100%] overflow-hidden text-ellipsis whitespace-nowrap p-[1%] text-[18px] text-white"
|
||||||
>
|
>
|
||||||
{{ news.title }}
|
{{ news.title }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="relative flex justify-center gap-[10px] py-[5px] text-center"
|
class="absolute bottom-0 right-[-45px] top-0 flex flex-col justify-center gap-[10px] py-[5px] text-center"
|
||||||
v-if="newsList.length > 1"
|
v-if="newsList.length > 1"
|
||||||
>
|
>
|
||||||
<ElButton
|
<ElButton
|
||||||
@@ -130,6 +133,7 @@ function plusNews() {
|
|||||||
type="danger"
|
type="danger"
|
||||||
circle
|
circle
|
||||||
size="small"
|
size="small"
|
||||||
|
class="!ml-0"
|
||||||
@click="() => removeNews(index)"
|
@click="() => removeNews(index)"
|
||||||
>
|
>
|
||||||
<IconifyIcon icon="lucide:trash-2" />
|
<IconifyIcon icon="lucide:trash-2" />
|
||||||
@@ -146,19 +150,19 @@ function plusNews() {
|
|||||||
"
|
"
|
||||||
@click="activeNewsIndex = index"
|
@click="activeNewsIndex = index"
|
||||||
>
|
>
|
||||||
<div class="relative">
|
<div class="relative flex items-center justify-between">
|
||||||
<div class="bg-[#acadae]">
|
<div
|
||||||
<img class="block h-full w-full" :src="news.thumbUrl" />
|
class="mb-[5px] ml-[5px] h-[25px] flex-1 overflow-hidden text-ellipsis whitespace-nowrap p-[1%] text-[16px]"
|
||||||
<div
|
>
|
||||||
class="absolute bottom-0 left-0 inline-block h-[25px] w-[100%] overflow-hidden text-ellipsis whitespace-nowrap bg-black p-[1%] text-center text-[15px] text-white opacity-65"
|
{{ news.title }}
|
||||||
>
|
|
||||||
{{ news.title }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<img
|
||||||
|
class="block h-[90px] w-[90px] object-cover"
|
||||||
|
:src="news.thumbUrl"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="relative flex justify-center gap-[10px] py-[5px] text-center"
|
class="absolute bottom-0 right-[-45px] top-0 flex flex-col justify-center gap-[10px] py-[5px] text-center"
|
||||||
>
|
>
|
||||||
<ElButton
|
<ElButton
|
||||||
v-if="newsList.length > index + 1"
|
v-if="newsList.length > index + 1"
|
||||||
@@ -174,6 +178,7 @@ function plusNews() {
|
|||||||
type="info"
|
type="info"
|
||||||
circle
|
circle
|
||||||
size="small"
|
size="small"
|
||||||
|
class="!ml-0"
|
||||||
@click="() => moveUpNews(index)"
|
@click="() => moveUpNews(index)"
|
||||||
>
|
>
|
||||||
<IconifyIcon icon="lucide:arrow-up" />
|
<IconifyIcon icon="lucide:arrow-up" />
|
||||||
@@ -183,6 +188,7 @@ function plusNews() {
|
|||||||
type="danger"
|
type="danger"
|
||||||
size="small"
|
size="small"
|
||||||
circle
|
circle
|
||||||
|
class="!ml-0"
|
||||||
@click="() => removeNews(index)"
|
@click="() => removeNews(index)"
|
||||||
>
|
>
|
||||||
<IconifyIcon icon="lucide:trash-2" />
|
<IconifyIcon icon="lucide:trash-2" />
|
||||||
|
|||||||
@@ -76,9 +76,8 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// TODO @jawe:Article 类型,报错;
|
|
||||||
return {
|
return {
|
||||||
list: res.list as unknown as Article[],
|
list: res.list as unknown as MpFreePublishApi.FreePublish[],
|
||||||
total: res.total,
|
total: res.total,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -92,7 +91,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
|||||||
refresh: true,
|
refresh: true,
|
||||||
search: true,
|
search: true,
|
||||||
},
|
},
|
||||||
} as VxeTableGridOptions<Article>,
|
} as VxeTableGridOptions<MpFreePublishApi.FreePublish>,
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
143
apps/web-ele/src/views/mp/messageTemplate/data.ts
Normal file
143
apps/web-ele/src/views/mp/messageTemplate/data.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import type { VbenFormSchema } from '#/adapter/form';
|
||||||
|
import type { VxeGridPropTypes } from '#/adapter/vxe-table';
|
||||||
|
|
||||||
|
import { getUserPage } from '#/api/mp/user';
|
||||||
|
|
||||||
|
/** 列表的搜索表单 */
|
||||||
|
export function useGridFormSchema(): VbenFormSchema[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
fieldName: 'accountId',
|
||||||
|
label: '公众号',
|
||||||
|
component: 'Input',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发送消息模板表单 */
|
||||||
|
export function useSendFormSchema(accountId?: number): VbenFormSchema[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
fieldName: 'id',
|
||||||
|
label: '模板编号',
|
||||||
|
component: 'Input',
|
||||||
|
componentProps: {
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'title',
|
||||||
|
label: '模板标题',
|
||||||
|
component: 'Input',
|
||||||
|
componentProps: {
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'userId',
|
||||||
|
label: '用户',
|
||||||
|
component: 'ApiSelect',
|
||||||
|
componentProps: {
|
||||||
|
api: async () => {
|
||||||
|
if (!accountId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const data = await getUserPage({
|
||||||
|
pageNo: 1,
|
||||||
|
pageSize: 100,
|
||||||
|
accountId,
|
||||||
|
});
|
||||||
|
return (data.list || []).map((user) => ({
|
||||||
|
label: user.nickname || user.openid,
|
||||||
|
value: user.id,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
filterable: true,
|
||||||
|
placeholder: '请选择用户',
|
||||||
|
},
|
||||||
|
rules: 'required',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'data',
|
||||||
|
label: '模板数据',
|
||||||
|
component: 'Textarea',
|
||||||
|
componentProps: {
|
||||||
|
rows: 4,
|
||||||
|
placeholder:
|
||||||
|
'请输入模板数据(JSON 格式),例如:{"keyword1": {"value": "测试内容"}}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'url',
|
||||||
|
label: '跳转链接',
|
||||||
|
component: 'Input',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入跳转链接',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'miniProgramAppId',
|
||||||
|
label: '小程序 appId',
|
||||||
|
component: 'Input',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入小程序 appId',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'miniProgramPagePath',
|
||||||
|
label: '小程序页面路径',
|
||||||
|
component: 'Input',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入小程序页面路径',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 表格列配置 */
|
||||||
|
export function useGridColumns(): VxeGridPropTypes.Columns {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: '公众号模板 ID',
|
||||||
|
field: 'templateId',
|
||||||
|
minWidth: 400,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '标题',
|
||||||
|
field: 'title',
|
||||||
|
minWidth: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '模板内容',
|
||||||
|
field: 'content',
|
||||||
|
minWidth: 400,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '模板示例',
|
||||||
|
field: 'example',
|
||||||
|
minWidth: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '一级行业',
|
||||||
|
field: 'primaryIndustry',
|
||||||
|
minWidth: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '二级行业',
|
||||||
|
field: 'deputyIndustry',
|
||||||
|
minWidth: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
field: 'createTime',
|
||||||
|
formatter: 'formatDateTime',
|
||||||
|
minWidth: 180,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 140,
|
||||||
|
fixed: 'right',
|
||||||
|
slots: { default: 'actions' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
165
apps/web-ele/src/views/mp/messageTemplate/index.vue
Normal file
165
apps/web-ele/src/views/mp/messageTemplate/index.vue
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||||
|
import type { MpMessageTemplateApi } from '#/api/mp/messageTemplate';
|
||||||
|
|
||||||
|
import { confirm, DocAlert, Page, useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { ElLoading, ElMessage } from 'element-plus';
|
||||||
|
|
||||||
|
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
|
import {
|
||||||
|
deleteMessageTemplate,
|
||||||
|
getMessageTemplateList,
|
||||||
|
syncMessageTemplate,
|
||||||
|
} from '#/api/mp/messageTemplate';
|
||||||
|
import { $t } from '#/locales';
|
||||||
|
import { WxAccountSelect } from '#/views/mp/components';
|
||||||
|
|
||||||
|
import { useGridColumns, useGridFormSchema } from './data';
|
||||||
|
import SendForm from './modules/send-form.vue';
|
||||||
|
|
||||||
|
const [SendFormModal, sendFormModalApi] = useVbenModal({
|
||||||
|
connectedComponent: SendForm,
|
||||||
|
destroyOnClose: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 刷新表格 */
|
||||||
|
function handleRefresh() {
|
||||||
|
gridApi.query();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 公众号变化时查询数据 */
|
||||||
|
function handleAccountChange(accountId: number) {
|
||||||
|
gridApi.formApi.setValues({ accountId });
|
||||||
|
gridApi.formApi.submitForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同步模板 */
|
||||||
|
async function handleSync() {
|
||||||
|
const formValues = await gridApi.formApi.getValues();
|
||||||
|
const accountId = formValues.accountId;
|
||||||
|
if (!accountId) {
|
||||||
|
ElMessage.warning('请先选择公众号');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await confirm('是否确认同步消息模板?');
|
||||||
|
const loadingInstance = ElLoading.service({
|
||||||
|
text: '正在同步消息模板...',
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await syncMessageTemplate(accountId);
|
||||||
|
ElMessage.success('同步消息模板成功');
|
||||||
|
handleRefresh();
|
||||||
|
} finally {
|
||||||
|
loadingInstance.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发送消息 */
|
||||||
|
function handleSend(row: MpMessageTemplateApi.MessageTemplate) {
|
||||||
|
sendFormModalApi.setData(row).open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除模板 */
|
||||||
|
async function handleDelete(row: MpMessageTemplateApi.MessageTemplate) {
|
||||||
|
const loadingInstance = ElLoading.service({
|
||||||
|
text: $t('ui.actionMessage.deleting', [row.title]),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await deleteMessageTemplate(row.id);
|
||||||
|
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.title]));
|
||||||
|
handleRefresh();
|
||||||
|
} finally {
|
||||||
|
loadingInstance.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [Grid, gridApi] = useVbenVxeGrid({
|
||||||
|
formOptions: {
|
||||||
|
schema: useGridFormSchema(),
|
||||||
|
},
|
||||||
|
gridOptions: {
|
||||||
|
columns: useGridColumns(),
|
||||||
|
height: 'auto',
|
||||||
|
keepSource: true,
|
||||||
|
proxyConfig: {
|
||||||
|
ajax: {
|
||||||
|
query: async (_params, formValues) => {
|
||||||
|
return await getMessageTemplateList({
|
||||||
|
accountId: formValues.accountId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
autoLoad: false,
|
||||||
|
},
|
||||||
|
pagerConfig: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
rowConfig: {
|
||||||
|
keyField: 'id',
|
||||||
|
isHover: true,
|
||||||
|
},
|
||||||
|
toolbarConfig: {
|
||||||
|
refresh: true,
|
||||||
|
search: true,
|
||||||
|
},
|
||||||
|
} as VxeTableGridOptions<MpMessageTemplateApi.MessageTemplate>,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Page auto-content-height>
|
||||||
|
<template #doc>
|
||||||
|
<DocAlert
|
||||||
|
title="模版消息"
|
||||||
|
url="https://doc.iocoder.cn/mp/message-template/"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<SendFormModal @success="handleRefresh" />
|
||||||
|
<Grid table-title="公众号消息模板列表">
|
||||||
|
<template #form-accountId>
|
||||||
|
<WxAccountSelect @change="handleAccountChange" />
|
||||||
|
</template>
|
||||||
|
<template #toolbar-tools>
|
||||||
|
<TableAction
|
||||||
|
:actions="[
|
||||||
|
{
|
||||||
|
label: '同步',
|
||||||
|
type: 'primary',
|
||||||
|
icon: 'lucide:refresh-ccw',
|
||||||
|
auth: ['mp:message-template:sync'],
|
||||||
|
onClick: handleSync,
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<TableAction
|
||||||
|
:actions="[
|
||||||
|
{
|
||||||
|
label: '发送',
|
||||||
|
type: 'primary',
|
||||||
|
link: true,
|
||||||
|
icon: 'lucide:send',
|
||||||
|
auth: ['mp:message-template:send'],
|
||||||
|
onClick: handleSend.bind(null, row),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: $t('common.delete'),
|
||||||
|
type: 'danger',
|
||||||
|
link: true,
|
||||||
|
icon: ACTION_ICON.DELETE,
|
||||||
|
auth: ['mp:message-template:delete'],
|
||||||
|
popConfirm: {
|
||||||
|
title: $t('ui.actionMessage.deleteConfirm', [row.title]),
|
||||||
|
confirm: handleDelete.bind(null, row),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Grid>
|
||||||
|
</Page>
|
||||||
|
</template>
|
||||||
103
apps/web-ele/src/views/mp/messageTemplate/modules/send-form.vue
Normal file
103
apps/web-ele/src/views/mp/messageTemplate/modules/send-form.vue
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { MpMessageTemplateApi } from '#/api/mp/messageTemplate';
|
||||||
|
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
|
||||||
|
import { useVbenForm } from '#/adapter/form';
|
||||||
|
import { sendMessageTemplate } from '#/api/mp/messageTemplate';
|
||||||
|
|
||||||
|
import { useSendFormSchema } from '../data';
|
||||||
|
|
||||||
|
const emit = defineEmits(['success']);
|
||||||
|
const formData = ref<MpMessageTemplateApi.MessageTemplate>();
|
||||||
|
|
||||||
|
const [Form, formApi] = useVbenForm({
|
||||||
|
commonConfig: {
|
||||||
|
componentProps: {
|
||||||
|
class: 'w-full',
|
||||||
|
},
|
||||||
|
formItemClass: 'col-span-2',
|
||||||
|
labelWidth: 120,
|
||||||
|
},
|
||||||
|
layout: 'horizontal',
|
||||||
|
showDefaultActions: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [Modal, modalApi] = useVbenModal({
|
||||||
|
async onConfirm() {
|
||||||
|
const { valid } = await formApi.validate();
|
||||||
|
if (!valid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
modalApi.lock();
|
||||||
|
// 构建发送请求
|
||||||
|
const values = await formApi.getValues();
|
||||||
|
const sendData: MpMessageTemplateApi.MessageTemplateSendVO = {
|
||||||
|
id: formData.value?.id || 0,
|
||||||
|
userId: values.userId,
|
||||||
|
data: values.data || undefined,
|
||||||
|
url: values.url || undefined,
|
||||||
|
miniProgramAppId: values.miniProgramAppId || undefined,
|
||||||
|
miniProgramPagePath: values.miniProgramPagePath || undefined,
|
||||||
|
};
|
||||||
|
// 如果填写了小程序信息,需要拼接成 miniprogram 字段
|
||||||
|
if (sendData.miniProgramAppId && sendData.miniProgramPagePath) {
|
||||||
|
sendData.miniprogram = JSON.stringify({
|
||||||
|
appid: sendData.miniProgramAppId,
|
||||||
|
pagepath: sendData.miniProgramPagePath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 如果填写了 data 字段
|
||||||
|
if (sendData.data && typeof sendData.data === 'string') {
|
||||||
|
try {
|
||||||
|
sendData.data = JSON.parse(sendData.data);
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('模板数据格式不正确,请输入有效的 JSON 格式');
|
||||||
|
modalApi.unlock();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交表单
|
||||||
|
try {
|
||||||
|
await sendMessageTemplate(sendData);
|
||||||
|
// 关闭并提示
|
||||||
|
await modalApi.close();
|
||||||
|
emit('success');
|
||||||
|
ElMessage.success('发送成功');
|
||||||
|
} finally {
|
||||||
|
modalApi.unlock();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async onOpenChange(isOpen: boolean) {
|
||||||
|
if (!isOpen) {
|
||||||
|
formData.value = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 获取数据
|
||||||
|
const data = modalApi.getData<MpMessageTemplateApi.MessageTemplate>();
|
||||||
|
if (!data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
formData.value = data;
|
||||||
|
// 更新 form schema
|
||||||
|
const schema = useSendFormSchema(data.accountId);
|
||||||
|
formApi.setState({ schema });
|
||||||
|
// 设置到 values
|
||||||
|
await formApi.setValues({
|
||||||
|
id: data.id,
|
||||||
|
title: data.title,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal class="w-[600px]" title="发送消息模板">
|
||||||
|
<Form class="mx-4" />
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -938,6 +938,12 @@ importers:
|
|||||||
vue-router:
|
vue-router:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 4.6.3(vue@3.5.24(typescript@5.9.3))
|
version: 4.6.3(vue@3.5.24(typescript@5.9.3))
|
||||||
|
vue3-print-nb:
|
||||||
|
specifier: 'catalog:'
|
||||||
|
version: 0.1.4(typescript@5.9.3)
|
||||||
|
vue3-signature:
|
||||||
|
specifier: 'catalog:'
|
||||||
|
version: 0.2.4(vue@3.5.24(typescript@5.9.3))
|
||||||
vuedraggable:
|
vuedraggable:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 4.1.0(vue@3.5.24(typescript@5.9.3))
|
version: 4.1.0(vue@3.5.24(typescript@5.9.3))
|
||||||
|
|||||||
Reference in New Issue
Block a user