107 Commits

Author SHA1 Message Date
9fc63fa294 Merge branch 'dev' into flow 2025-09-10 17:13:07 +08:00
AN
19bf543485 fix(projects): 修改代码生成功能模块名为驼峰时,路由错误问题 2025-09-10 10:31:18 +08:00
AN
34ad79e815 feat(projects): 补充发起流程字段参数 2025-09-08 14:20:00 +08:00
AN
7de471aa68 Merge branch 'dev' of https://gitee.com/xlsea/ruoyi-plus-soybean into flow 2025-09-08 13:59:44 +08:00
AN
1eb0a3dac2 feat(projects): 补充Task的业务扩展字段 2025-09-05 13:53:07 +08:00
AN
bc44d2cddc feat(projects): 添加修改办理人,催办功能,优化组件样式 2025-09-05 13:28:47 +08:00
AN
b07729edd1 Merge branch 'dev' of https://gitee.com/xlsea/ruoyi-plus-soybean into flow 2025-09-05 09:29:24 +08:00
AN
9b60512165 feat(projects): 流程实例添加变量修改功能 2025-09-04 14:09:05 +08:00
AN
f739e1e031 feat(projects): 流程定义添加设计器样式等拓展字段,优化流程分类下拉组件 2025-09-03 14:31:50 +08:00
b70095e5ff Merge branch 'dev' into flow 2025-09-02 15:46:13 +08:00
8a453f5852 chore(projects): release v1.1.3 2025-08-16 11:29:13 +08:00
4d120ffe63 Merge branch 'dev' into flow 2025-08-16 11:15:57 +08:00
AN
71297cf2e4 optimize(types): 补充标签类型 2025-08-07 20:44:13 +08:00
AN
30316d7e4a optimize(types): 补充标签类型 2025-08-07 20:39:17 +08:00
AN
aaad817131 Merge branch 'dev' of https://gitee.com/xlsea/ruoyi-plus-soybean into flow 2025-08-07 20:36:14 +08:00
AN
41c74db1cd Merge branch 'dev' of https://gitee.com/xlsea/ruoyi-plus-soybean into flow 2025-07-30 20:44:52 +08:00
AN
befc61a4ff Merge branch 'flow' of https://gitee.com/xlsea/ruoyi-plus-soybean into flow 2025-07-29 16:16:55 +08:00
AN
d1f0ce28cb Merge branch 'dev' of https://gitee.com/xlsea/ruoyi-plus-soybean into flow 2025-07-29 16:15:56 +08:00
c87aa6652c Merge branch 'dev' into flow 2025-07-24 17:45:50 +08:00
AN
7484f79b8f docs(projects): 流程表达式菜单sql更新 2025-07-23 20:55:40 +08:00
AN
a9c58b2590 fix(projects): 修复抽屉问题 2025-07-23 14:20:46 +08:00
AN
d562f8c155 feat(projects): 新增流程表达式功能 2025-07-22 17:44:04 +08:00
2e02992906 fix(projects): 修复动态组件弹窗动画问题 2025-07-21 10:54:16 +08:00
f138e34ef9 Merge remote-tracking branch 'origin/flow' into flow 2025-07-21 09:16:33 +08:00
AN
94fafe3938 optimize(projects): 优化搜索FormItem展示 2025-07-19 21:20:14 +08:00
c7c4e23be5 Merge remote-tracking branch 'origin/flow' into flow 2025-07-19 15:35:55 +08:00
AN
e64d9b38c9 Merge branch 'dev' of https://gitee.com/xlsea/ruoyi-plus-soybean into flow 2025-07-19 13:13:29 +08:00
AN
b6c7b1b383 feat(projects): 补充搜索条件 2025-07-18 23:13:19 +08:00
AN
b46f172637 Merge branch 'dev' of https://gitee.com/xlsea/ruoyi-plus-soybean into flow 2025-07-18 22:54:47 +08:00
7ffaac5893 refactor(projects): 移动工作流组件位置 2025-07-18 11:55:23 +08:00
0dcfc893dd Merge branch 'dev' into flow 2025-07-17 11:08:53 +08:00
AN
22710ecbd5 refactor(utils): 简化加载组件方法 2025-07-16 15:24:14 +08:00
AN
6c6086f89d optimize(projects): 优化代码 2025-07-16 14:13:11 +08:00
AN
59a69dd9f0 optimize(projects): 优化代码 2025-07-15 22:49:55 +08:00
AN
01d42722f5 feat(projects): 新增我的已办,我的抄送功能 2025-07-15 21:45:23 +08:00
AN
bd6b575af6 feat(types): 补充类型定义 2025-07-15 16:24:42 +08:00
AN
6dc7b2a234 Merge branch 'dev' of https://gitee.com/xlsea/ruoyi-plus-soybean into flow 2025-07-15 16:14:31 +08:00
AN
30878fb4d3 Merge branch 'dev' of https://gitee.com/xlsea/ruoyi-plus-soybean into flow 2025-07-14 22:38:08 +08:00
AN
5e49622ea8 refactor(projects): 文件命名修正 2025-07-14 22:36:30 +08:00
AN
130ee1dcec feat(projects): 新增我的待办功能,新增审批,驳回组件 2025-07-14 22:29:59 +08:00
AN
523aca6b75 feat(projects): 新增抄送、下一审批人提交功能,优化组件通用性 2025-07-13 22:07:40 +08:00
AN
3a506df9b9 feat-wip(projects): 办理功能弹窗适配 2025-07-10 22:22:14 +08:00
AN
39b0c636f2 Merge branch 'dev' of https://gitee.com/xlsea/ruoyi-plus-soybean into flow 2025-07-10 22:20:40 +08:00
AN
400eaf8990 fix(projects): 修复更新后产生问题 2025-07-09 13:43:44 +08:00
AN
2dbf8b3dfa Merge branch 'dev' of https://gitee.com/xlsea/ruoyi-plus-soybean into flow 2025-07-08 22:18:32 +08:00
AN
fbec787a99 Merge branch 'dev' of https://gitee.com/xlsea/ruoyi-plus-soybean into flow 2025-07-01 22:17:02 +08:00
AN
4e1f539576 feat-wip(projects): 新增我的待办列表 2025-07-01 22:13:57 +08:00
baa0584bdc Merge branch 'master' into flow 2025-07-01 10:54:32 +08:00
AN
a77edc2e36 feat(projects): 新增 '我发起的' 功能 2025-06-30 22:37:29 +08:00
AN
c3ea81dc0d Merge branch 'dev' of https://gitee.com/xlsea/ruoyi-plus-soybean into flow 2025-06-30 20:57:32 +08:00
AN
f1d7b9733f feat(projects): 新增减签功能 2025-06-28 22:07:02 +08:00
AN
55dceca28b feat(projects): 新增加签功能 2025-06-27 18:22:21 +08:00
AN
188533adc9 Merge branch 'dev' of https://gitee.com/xlsea/ruoyi-plus-soybean into flow 2025-06-26 17:14:41 +08:00
AN
80faf4b47c feat(projects): 新增转办和终止功能 2025-06-25 23:19:32 +08:00
AN
89e7edb380 docs(projects): 新增工作流更新sql 2025-06-25 13:33:14 +08:00
AN
b8c771cd1d feat(projects): 新增用户选择器组件,添加流程干预按钮 2025-06-24 23:46:15 +08:00
AN
81449ea77a feat-wip(projects): 新增流程干预,组件命名统一 2025-06-23 22:35:44 +08:00
AN
1c322e28ef feat(projects): 新增group-tag组件,待办任务查看功能 2025-06-22 18:59:08 +08:00
AN
54fa7caf03 Merge branch 'dev' of https://gitee.com/xlsea/ruoyi-plus-soybean into flow 2025-06-22 16:10:08 +08:00
AN
b3dccb542e feat-wip(projects): 新增待办任务功能,优化代码 2025-06-22 12:33:56 +08:00
AN
ae5c7e8372 feat(components): 面板添加流程图 2025-06-21 17:20:33 +08:00
AN
2f8a6b4b84 optimize(projects): 动态加载组件方法抽取为公共函数 2025-06-21 13:48:11 +08:00
AN
e86a6d1b7a refactor(types): 移除无用type 2025-06-21 11:05:54 +08:00
AN
a3dcee4a11 refactor(projects): 修改流程实例动态引入组件 2025-06-21 10:50:48 +08:00
AN
50e7b5158d feat(projects): 优化组件,完成流程实例-流程预览 2025-06-19 23:54:21 +08:00
AN
2b5735ab34 Merge branch 'dev' of https://gitee.com/xlsea/ruoyi-plus-soybean into flow 2025-06-19 23:18:02 +08:00
AN
56bfd64fbe Merge branch 'flow' of https://gitee.com/xlsea/ruoyi-plus-soybean into flow 2025-06-19 23:17:22 +08:00
47d5c1c71b revert(projects): 单点登录回调页面回滚 2025-06-19 23:15:03 +08:00
AN
49224afe2d feat(components): 增强审批信息面板,优化附件处理 2025-06-19 23:14:25 +08:00
8ce02aa15a Merge branch 'dev' into flow 2025-06-18 23:22:36 +08:00
AN
496ed978ca Merge branch 'dev' of https://gitee.com/xlsea/ruoyi-plus-soybean into flow 2025-06-18 23:09:07 +08:00
AN
fb652ce7ff feat-wip(components): 审批信息流程图组件 2025-06-18 23:06:40 +08:00
AN
90ebf83501 Merge branch 'dev' of https://gitee.com/xlsea/ruoyi-plus-soybean into flow 2025-06-18 17:25:13 +08:00
AN
b3f81ba3f0 optimize(projects): 统一button的添加方式 2025-06-18 15:15:19 +08:00
AN
20b96ac54b Merge branch 'dev' of https://gitee.com/xlsea/ruoyi-plus-soybean into flow 2025-06-18 14:23:38 +08:00
AN
85115ce327 feat-wip(components): 抽取leaveEdit组件,适配流程编辑和预览功能 2025-06-17 23:57:04 +08:00
1cbeb59c0b Merge branch 'dev' into flow 2025-06-17 14:07:36 +08:00
9aa6597d5e fix(other): 修复代码生成字典相关问题 2025-06-17 11:42:08 +08:00
d1377baaed Merge branch 'dev' into flow 2025-06-16 17:59:28 +08:00
AN
b6f4fb5aec refactor(projects): 重构流程设计菜单层级 2025-06-14 10:07:08 +08:00
AN
1af4e96382 docs(projects): 流程定义菜单更新 2025-06-13 09:23:53 +08:00
AN
997f4a2d61 feat-wip(projects): 流程定义完成 2025-06-13 00:20:45 +08:00
AN
f52fa40326 Merge branch 'dev' of https://gitee.com/xlsea/ruoyi-plus-soybean into flow 2025-06-12 21:26:49 +08:00
AN
7b2c857f6d feat-wip(projects): 对接流程定义功能ing 2025-06-11 23:32:59 +08:00
AN
4f6c14f358 Merge branch 'dev' of https://gitee.com/xlsea/ruoyi-plus-soybean into flow 2025-06-11 20:51:46 +08:00
AN
ffd6211e4d refactor(projects): 调整为批量上传文件 2025-06-08 22:36:33 +08:00
AN
56d6d77da5 refactor: 优化代码 2025-06-03 21:48:38 +08:00
AN
394db6fec2 refactor: remove WorkflowLeaveForm type from Vue typings 2025-06-01 00:08:33 +08:00
AN
4614b97796 feat: 更新工作流任务申请模态框样式,添加消息类型选择禁用功能 2025-06-01 00:06:54 +08:00
AN
49521b667d feat: 更新请假申请表单,添加流程类型选择和流程启动功能 2025-05-31 23:48:36 +08:00
AN
ab1d3a237e feat: 添加请假申请详情接口,优化请假操作表单样式 2025-05-30 17:48:43 +08:00
AN
3629c7a9dd Merge branch 'dev' of https://gitee.com/xlsea/ruoyi-plus-soybean into flow 2025-05-30 17:28:44 +08:00
AN
89a2a6cbf4 feat: 更新请假申请功能,添加日期范围选择和请假天数自动计算 2025-05-29 22:35:29 +08:00
AN
d7e0516cfb feat: 添加请假申请功能 2025-05-29 10:34:00 +08:00
AN
b265f590e4 Merge branch 'dev' of https://gitee.com/xlsea/ruoyi-plus-soybean into flow 2025-05-28 18:37:15 +08:00
AN
210c00c686 Merge branch 'dev' of https://gitee.com/xlsea/ruoyi-plus-soybean into flow 2025-05-28 11:25:26 +08:00
AN
f004e75cc1 fix: 修复添加行时的操作顺序问题 2025-05-27 23:13:35 +08:00
AN
14a29070c9 feat: 更新工作流分类选择组件,修复值回显问题并优化添加数据操作 2025-05-27 22:41:48 +08:00
AN
92b9c213d5 feat: 流程实例,查看变量功能 2025-05-26 22:19:39 +08:00
AN
ed1180696f fix: 修复运行状态变化时数据加载顺序问题 2025-05-25 17:01:04 +08:00
AN
1c64693774 fix: 修复控制台报错:Message compilation error,i18n的@为特殊符号 2025-05-25 09:28:54 +08:00
AN
350de08f8f feat: 增加流程作废功能并优化按钮组件 2025-05-25 09:10:49 +08:00
AN
f9d57f1b71 feat: 添加流程实例功能 2025-05-24 00:49:15 +08:00
11aba9e2c8 feat: 新增流程定义页面 2025-05-22 22:56:59 +08:00
32c241564d Merge branch 'dev' into flow 2025-05-22 19:14:20 +08:00
AN
b1cb10581e Merge branch 'dev' of https://gitee.com/xlsea/ruoyi-plus-soybean into flow 2025-05-20 20:54:25 +08:00
AN
25790d4b0f feat: 对接工作流分类模块 2025-05-19 23:51:32 +08:00
205 changed files with 22987 additions and 3515 deletions

View File

@ -16,7 +16,7 @@
},
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest"]
"args": ["@playwright/mcp@0.0.29"]
},
"mcp-server-time": {
"command": "uvx",
@ -26,21 +26,14 @@
"command": "npx",
"args": ["-y", "mcp-shrimp-task-manager"],
"env": {
"DATA_DIR": "D:/workspace/tools/mcp-shrimp-task-manager/data",
"DATA_DIR": "D:/workspace/mcp-shrimp-task-manager/data",
"TEMPLATES_USE": "en",
"ENABLE_GUI": "true"
"ENABLE_GUI": "false"
}
},
"mcp-deepwiki": {
"command": "npx",
"args": ["-y", "mcp-deepwiki@latest"]
},
"memory": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-memory"],
"env": {
"MEMORY_FILE_PATH": "D:/workspace/tools/server-memory/memory.json"
}
}
}
}

View File

@ -1,6 +1,9 @@
---
description:
globs:
alwaysApply: false
---
**# RIPER-5 + 多维度思维 + 代理执行协议 (v4.9.1 - MCP工具驱动版)**
**元指令:** 此协议旨在最大化你的战略规划与执行效率。你的核心任务是**指挥和利用MCP工具集**来驱动项目进展。严格遵守核心原则,利用 `mcp-shrimp-task-manager` 进行项目规划与追踪,使用 `deepwiki-mcp` 进行深度研究。主动管理 `/project_document` 作为知识库。**每轮主要响应后,调用 `mcp.feedback_enhanced` 进行交互或通知。**
@ -164,4 +167,5 @@ alwaysApply: false
* **极致效率:** AI应最大限度地减少手动干预让MCP工具处理所有可以自动化的工作。
* **战略聚焦:** 将AI的“思考”集中在无法被工具替代的领域战略决策、创新构想、复杂问题诊断 (`mcp.sequential_thinking`) 和最终质量把关。
* **无缝集成:** 期望AI能流畅地在不同MCP工具之间传递信息形成一个高度整合的自动化工作流。

View File

@ -31,8 +31,7 @@
"vue.server.hybridMode": true,
"files.exclude": { "/docs": true },
"search.exclude": {
"/docs": true,
"**/dist/**": true
"/docs": true
},
"cSpell.words": ["Axios", "tinymce"]
}

View File

@ -2,15 +2,55 @@
## [v1.1.3](https://gitee.com/xlsea/ruoyi-plus-soybean/compare/v1.1.2...v1.1.3) (2025-08-16)
###    🚀 新功能
- 对接工作流分类模块 &nbsp;-&nbsp; by **AN** [<samp>(25790)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/25790d4b)
- 新增流程定义页面 &nbsp;-&nbsp; by @m-xlsea [<samp>(11aba)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/11aba9e2)
- 添加流程实例功能 &nbsp;-&nbsp; by **AN** [<samp>(f9d57)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/f9d57f1b)
- 增加流程作废功能并优化按钮组件 &nbsp;-&nbsp; by **AN** [<samp>(350de)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/350de08f)
- 流程实例,查看变量功能 &nbsp;-&nbsp; by **AN** [<samp>(92b9c)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/92b9c213)
- 更新工作流分类选择组件,修复值回显问题并优化添加数据操作 &nbsp;-&nbsp; by **AN** [<samp>(14a29)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/14a29070)
- 添加请假申请功能 &nbsp;-&nbsp; by **AN** [<samp>(d7e05)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/d7e0516c)
- 更新请假申请功能,添加日期范围选择和请假天数自动计算 &nbsp;-&nbsp; by **AN** [<samp>(89a2a)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/89a2a6cb)
- 添加请假申请详情接口,优化请假操作表单样式 &nbsp;-&nbsp; by **AN** [<samp>(ab1d3)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/ab1d3a23)
- 更新请假申请表单,添加流程类型选择和流程启动功能 &nbsp;-&nbsp; by **AN** [<samp>(49521)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/49521b66)
- 更新工作流任务申请模态框样式,添加消息类型选择禁用功能 &nbsp;-&nbsp; by **AN** [<samp>(4614b)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/4614b977)
- **components**:
- 增强审批信息面板,优化附件处理 &nbsp;-&nbsp; by **AN** [<samp>(49224)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/49224afe)
- 面板添加流程图 &nbsp;-&nbsp; by **AN** [<samp>(ae5c7)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/ae5c7e83)
- **projects**:
- 优化组件,完成流程实例-流程预览 &nbsp;-&nbsp; by **AN** [<samp>(50e7b)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/50e7b515)
- 新增group-tag组件待办任务查看功能 &nbsp;-&nbsp; by **AN** [<samp>(1c322)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/1c322e28)
- 新增用户选择器组件,添加流程干预按钮 &nbsp;-&nbsp; by **AN** [<samp>(b8c77)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/b8c771cd)
- 新增转办和终止功能 &nbsp;-&nbsp; by **AN** [<samp>(80faf)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/80faf4b4)
- 新增加签功能 &nbsp;-&nbsp; by **AN** [<samp>(55dce)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/55dceca2)
- 新增减签功能 &nbsp;-&nbsp; by **AN** [<samp>(f1d7b)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/f1d7b973)
- 新增 '我发起的' 功能 &nbsp;-&nbsp; by **AN** [<samp>(a77ed)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/a77edc2e)
- 新增抄送、下一审批人提交功能,优化组件通用性 &nbsp;-&nbsp; by **AN** [<samp>(523ac)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/523aca6b)
- 新增我的待办功能,新增审批,驳回组件 &nbsp;-&nbsp; by **AN** [<samp>(130ee)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/130ee1dc)
- 新增我的已办,我的抄送功能 &nbsp;-&nbsp; by **AN** [<samp>(01d42)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/01d42722)
- 补充搜索条件 &nbsp;-&nbsp; by **AN** [<samp>(b6c7b)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/b6c7b1b3)
- 新增流程表达式功能 &nbsp;-&nbsp; by **AN** [<samp>(d562f)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/d562f8c1)
- **types**:
- 补充类型定义 &nbsp;-&nbsp; by **AN** [<samp>(bd6b5)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/bd6b575a)
### &nbsp;&nbsp;&nbsp;🐞 Bug 修复
- 修复控制台报错Message compilation errori18n的@为特殊符号 &nbsp;-&nbsp; by **AN** [<samp>(1c646)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/1c646937)
- 修复运行状态变化时数据加载顺序问题 &nbsp;-&nbsp; by **AN** [<samp>(ed118)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/ed118069)
- 修复添加行时的操作顺序问题 &nbsp;-&nbsp; by **AN** [<samp>(f004e)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/f004e75c)
- **hooks**:
- 非安全环境下不使用流式下载 &nbsp;-&nbsp; by @m-xlsea [<samp>(f8983)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/f8983557)
- 修复oss下载时未转码问题 &nbsp;-&nbsp; by **AN** [<samp>(2d31d)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/2d31d7dc)
- **other**:
- 修复代码生成字典相关问题 &nbsp;-&nbsp; by @m-xlsea [<samp>(9aa65)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/9aa6597d)
- **project**:
- 关闭多租户功能后仍然遍历租户列表导致控制台报错的问题 &nbsp;-&nbsp; by **wang_rui** [<samp>(b96c4)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/b96c46ba)
- 关闭多租户功能后仍然遍历租户列表导致控制台报错的问题 Merge pull request !25 from littleghost2016/dev &nbsp;-&nbsp; by **不寻俗** [<samp>(90276)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/9027632b)
- **projects**:
- 修复更新后产生问题 &nbsp;-&nbsp; by **AN** [<samp>(400ea)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/400eaf89)
- 修复动态组件弹窗动画问题 &nbsp;-&nbsp; by @m-xlsea [<samp>(2e029)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/2e029929)
- 修复抽屉问题 &nbsp;-&nbsp; by **AN** [<samp>(a9c58)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/a9c58b25)
- 修复一级菜单隐藏失效问题 &nbsp;-&nbsp; by **AN** [<samp>(8fcc7)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/8fcc70d7)
- 修复日期搜索条件清除问题 &nbsp;-&nbsp; by **AN** [<samp>(52318)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/52318c10)
- 修复登录过期事件监听未被重置 &nbsp;-&nbsp; by @m-xlsea [<samp>(71037)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/71037439)
@ -21,13 +61,43 @@
- **readme**:
- update GitHub stars and forks links for gitee &nbsp;-&nbsp; by @soybeanjs [<samp>(923eb)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/923eb98a)
### &nbsp;&nbsp;&nbsp;🛠 优化
- **projects**:
- 统一button的添加方式 &nbsp;-&nbsp; by **AN** [<samp>(b3f81)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/b3f81ba3)
- 动态加载组件方法抽取为公共函数 &nbsp;-&nbsp; by **AN** [<samp>(2f8a6)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/2f8a6b4b)
- 优化代码 &nbsp;-&nbsp; by **AN** [<samp>(59a69)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/59a69dd9)
- 优化代码 &nbsp;-&nbsp; by **AN** [<samp>(6c608)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/6c6086f8)
- 优化搜索FormItem展示 &nbsp;-&nbsp; by **AN** [<samp>(94faf)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/94fafe39)
- **types**:
- 补充标签类型 &nbsp;-&nbsp; by **AN** [<samp>(30316)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/30316d7e)
- 补充标签类型 &nbsp;-&nbsp; by **AN** [<samp>(71297)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/71297cf2)
### &nbsp;&nbsp;&nbsp;💅 重构
- remove WorkflowLeaveForm type from Vue typings &nbsp;-&nbsp; by **AN** [<samp>(394db)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/394db6fe)
- 优化代码 &nbsp;-&nbsp; by **AN** [<samp>(56d6d)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/56d6d77d)
- **menu**:
- 菜单管理中隐藏的菜单显示灰色 &nbsp;-&nbsp; by **NicholasLD** [<samp>(adca2)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/adca2e26)
- 菜单管理中隐藏的菜单显示灰色 Merge pull request !24 from NicholasLD/N/A &nbsp;-&nbsp; by **不寻俗** [<samp>(4eb77)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/4eb77eac)
- **projects**:
- 调整为批量上传文件 &nbsp;-&nbsp; by **AN** [<samp>(ffd62)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/ffd6211e)
- 重构流程设计菜单层级 &nbsp;-&nbsp; by **AN** [<samp>(b6f4f)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/b6f4fb5a)
- 修改流程实例动态引入组件 &nbsp;-&nbsp; by **AN** [<samp>(a3dce)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/a3dcee4a)
- 文件命名修正 &nbsp;-&nbsp; by **AN** [<samp>(5e496)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/5e49622e)
- 移动工作流组件位置 &nbsp;-&nbsp; by @m-xlsea [<samp>(7ffaa)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/7ffaac58)
- 菜单列表新增禁用菜单样式 &nbsp;-&nbsp; by @m-xlsea [<samp>(e5383)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/e538355f)
- **types**:
- 移除无用type &nbsp;-&nbsp; by **AN** [<samp>(e86a6)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/e86a6d1b)
- **utils**:
- 简化加载组件方法 &nbsp;-&nbsp; by **AN** [<samp>(22710)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/22710ecb)
### &nbsp;&nbsp;&nbsp;📖 文档
- **projects**:
- 流程定义菜单更新 &nbsp;-&nbsp; by **AN** [<samp>(1af4e)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/1af4e963)
- 新增工作流更新sql &nbsp;-&nbsp; by **AN** [<samp>(89e7e)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/89e7edb3)
- 流程表达式菜单sql更新 &nbsp;-&nbsp; by **AN** [<samp>(7484f)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/7484f79b)
### &nbsp;&nbsp;&nbsp;🏡 杂项

View File

@ -35,4 +35,23 @@ UPDATE `sys_menu` SET `component` = 'FrameView', `query_param` = 'https://previe
UPDATE `sys_menu` SET `path` = 'https://gitee.com/xlsea/ruoyi-plus-soybean', `component` = 'FrameView', `icon` = 'local-icon-gitee', `menu_name` = 'RuoYi-Plus-Soybean' WHERE `menu_id` = 4;
-- plus-ui 需要禁用的页面
UPDATE `sys_menu` SET `status` = '1' WHERE `menu_id` IN ( '116', '130', '131', '132', '11700', '11701' );
UPDATE `sys_menu` SET `status` = '1' WHERE `menu_id` IN ( '116', '130', '131', '132' );
-- 工作流要启用的页面
UPDATE `sys_menu` SET `status` = '0' WHERE `menu_id` IN ( '11616', '11618', '11700', '11701' );
-- 工作流菜单
UPDATE `sys_menu` SET `component` = 'Layout', `icon` = 'hugeicons:flow-square' WHERE `menu_id` = 11616;
UPDATE `sys_menu` SET `component` = 'Layout', `icon` = 'fluent:notepad-person-16-regular' WHERE `menu_id` = 11618;
UPDATE `sys_menu` SET `component` = 'workflow/task/taskWaiting/index', `icon` = 'ri:todo-line' WHERE `menu_id` = 11619;
UPDATE `sys_menu` SET `icon` = 'weui:setting-outlined' WHERE `menu_id` = 11620;
UPDATE `sys_menu` SET `icon` = 'ri:instance-line' WHERE `menu_id` = 11621;
UPDATE `sys_menu` SET `icon` = 'carbon:category' WHERE `menu_id` = 11622;
UPDATE `sys_menu` SET `component` = 'workflow/task/myDocument/index', `icon` = 'hugeicons:start-up-02' WHERE `menu_id` = 11629;
UPDATE `sys_menu` SET `component` = 'Layout', `icon` = 'lucide:monitor-cog' WHERE `menu_id` = 11630;
UPDATE `sys_menu` SET `component` = 'workflow/task/allTaskWaiting/index', `icon` = 'ri:todo-line' WHERE `menu_id` = 11631;
UPDATE `sys_menu` SET `component` = 'workflow/task/taskFinish/index', `icon` = 'hugeicons:task-done-01' WHERE `menu_id` = 11632;
UPDATE `sys_menu` SET `path` = 'taskCopy', `component` = 'workflow/task/taskCopy/index', `icon` = 'mynaui:copy' WHERE `menu_id` = 11633;
UPDATE `sys_menu` SET `icon` = 'ic:twotone-time-to-leave' WHERE `menu_id` = 11638;
UPDATE `sys_menu` SET `icon` = 'material-symbols:design-services-outline', `path` = 'design', `component` = 'workflow/design/index' WHERE `menu_id` = 11700;
UPDATE `sys_menu` SET `icon` = 'ic:twotone-time-to-leave' WHERE `menu_id` = 11701;
UPDATE `sys_menu` SET `icon` = 'material-symbols:regular-expression-rounded' WHERE `menu_id` = 11801;

View File

@ -13,12 +13,11 @@ import type {
ResponseType
} from './type';
function createCommonRequest<
ResponseData,
ApiData = ResponseData,
State extends Record<string, unknown> = Record<string, unknown>
>(axiosConfig?: CreateAxiosDefaults, options?: Partial<RequestOption<ResponseData, ApiData, State>>) {
const opts = createDefaultOptions<ResponseData, ApiData, State>(options);
function createCommonRequest<ResponseData = any>(
axiosConfig?: CreateAxiosDefaults,
options?: Partial<RequestOption<ResponseData>>
) {
const opts = createDefaultOptions<ResponseData>(options);
const axiosConf = createAxiosConfig(axiosConfig);
const instance = axios.create(axiosConf);
@ -81,6 +80,14 @@ function createCommonRequest<
}
);
function cancelRequest(requestId: string) {
const abortController = abortControllerMap.get(requestId);
if (abortController) {
abortController.abort();
abortControllerMap.delete(requestId);
}
}
function cancelAllRequest() {
abortControllerMap.forEach(abortController => {
abortController.abort();
@ -91,6 +98,7 @@ function createCommonRequest<
return {
instance,
opts,
cancelRequest,
cancelAllRequest
};
}
@ -101,27 +109,27 @@ function createCommonRequest<
* @param axiosConfig axios config
* @param options request options
*/
export function createRequest<ResponseData, ApiData, State extends Record<string, unknown>>(
export function createRequest<ResponseData = any, State = Record<string, unknown>>(
axiosConfig?: CreateAxiosDefaults,
options?: Partial<RequestOption<ResponseData, ApiData, State>>
options?: Partial<RequestOption<ResponseData>>
) {
const { instance, opts, cancelAllRequest } = createCommonRequest<ResponseData, ApiData, State>(axiosConfig, options);
const { instance, opts, cancelRequest, cancelAllRequest } = createCommonRequest<ResponseData>(axiosConfig, options);
const request: RequestInstance<ApiData, State> = async function request<
T extends ApiData = ApiData,
R extends ResponseType = 'json'
>(config: CustomAxiosRequestConfig) {
const request: RequestInstance<State> = async function request<T = any, R extends ResponseType = 'json'>(
config: CustomAxiosRequestConfig
) {
const response: AxiosResponse<ResponseData> = await instance(config);
const responseType = response.config?.responseType || 'json';
if (responseType === 'json') {
return opts.transform(response);
return opts.transformBackendResponse(response);
}
return response.data as MappedType<R, T>;
} as RequestInstance<ApiData, State>;
} as RequestInstance<State>;
request.cancelRequest = cancelRequest;
request.cancelAllRequest = cancelAllRequest;
request.state = {} as State;
@ -136,14 +144,14 @@ export function createRequest<ResponseData, ApiData, State extends Record<string
* @param axiosConfig axios config
* @param options request options
*/
export function createFlatRequest<ResponseData, ApiData, State extends Record<string, unknown>>(
export function createFlatRequest<ResponseData = any, State = Record<string, unknown>>(
axiosConfig?: CreateAxiosDefaults,
options?: Partial<RequestOption<ResponseData, ApiData, State>>
options?: Partial<RequestOption<ResponseData>>
) {
const { instance, opts, cancelAllRequest } = createCommonRequest<ResponseData, ApiData, State>(axiosConfig, options);
const { instance, opts, cancelRequest, cancelAllRequest } = createCommonRequest<ResponseData>(axiosConfig, options);
const flatRequest: FlatRequestInstance<ResponseData, ApiData, State> = async function flatRequest<
T extends ApiData = ApiData,
const flatRequest: FlatRequestInstance<State, ResponseData> = async function flatRequest<
T = any,
R extends ResponseType = 'json'
>(config: CustomAxiosRequestConfig) {
try {
@ -152,21 +160,20 @@ export function createFlatRequest<ResponseData, ApiData, State extends Record<st
const responseType = response.config?.responseType || 'json';
if (responseType === 'json') {
const data = await opts.transform(response);
const data = opts.transformBackendResponse(response);
return { data, error: null, response };
}
return { data: response.data as MappedType<R, T>, error: null, response };
return { data: response.data as MappedType<R, T>, error: null };
} catch (error) {
return { data: null, error, response: (error as AxiosError<ResponseData>).response };
}
} as FlatRequestInstance<ResponseData, ApiData, State>;
} as FlatRequestInstance<State, ResponseData>;
flatRequest.cancelRequest = cancelRequest;
flatRequest.cancelAllRequest = cancelAllRequest;
flatRequest.state = {
...opts.defaultState
} as State;
flatRequest.state = {} as State;
return flatRequest;
}

View File

@ -4,27 +4,15 @@ import { stringify } from 'qs';
import { isHttpSuccess } from './shared';
import type { RequestOption } from './type';
export function createDefaultOptions<
ResponseData,
ApiData = ResponseData,
State extends Record<string, unknown> = Record<string, unknown>
>(options?: Partial<RequestOption<ResponseData, ApiData, State>>) {
const opts: RequestOption<ResponseData, ApiData, State> = {
defaultState: {} as State,
transform: async response => response.data as unknown as ApiData,
transformBackendResponse: async response => response.data as unknown as ApiData,
export function createDefaultOptions<ResponseData = any>(options?: Partial<RequestOption<ResponseData>>) {
const opts: RequestOption<ResponseData> = {
onRequest: async config => config,
isBackendSuccess: _response => true,
onBackendFail: async () => {},
transformBackendResponse: async response => response.data,
onError: async () => {}
};
if (options?.transform) {
opts.transform = options.transform;
} else {
opts.transform = options?.transformBackendResponse || opts.transform;
}
Object.assign(opts, options);
return opts;

View File

@ -8,30 +8,7 @@ export type ContentType =
| 'application/x-www-form-urlencoded'
| 'application/octet-stream';
export type ResponseTransform<Input = any, Output = any> = (input: Input) => Output | Promise<Output>;
export interface RequestOption<
ResponseData,
ApiData = ResponseData,
State extends Record<string, unknown> = Record<string, unknown>
> {
/**
* The default state
*/
defaultState?: State;
/**
* transform the response data to the api data
*
* @param response Axios response
*/
transform: ResponseTransform<AxiosResponse<ResponseData>, ApiData>;
/**
* transform the response data to the api data
*
* @deprecated use `transform` instead, will be removed in the next major version v3
* @param response Axios response
*/
transformBackendResponse: ResponseTransform<AxiosResponse<ResponseData>, ApiData>;
export interface RequestOption<ResponseData = any> {
/**
* The hook before request
*
@ -58,6 +35,12 @@ export interface RequestOption<
response: AxiosResponse<ResponseData>,
instance: AxiosInstance
) => Promise<AxiosResponse | null> | Promise<void>;
/**
* transform backend response when the responseType is json
*
* @param response Axios response
*/
transformBackendResponse(response: AxiosResponse<ResponseData>): any | Promise<any>;
/**
* The hook to handle error
*
@ -85,7 +68,15 @@ export type CustomAxiosRequestConfig<R extends ResponseType = 'json'> = Omit<Axi
responseType?: R;
};
export interface RequestInstanceCommon<State extends Record<string, unknown>> {
export interface RequestInstanceCommon<T> {
/**
* cancel the request by request id
*
* if the request provide abort controller sign from config, it will not collect in the abort controller map
*
* @param requestId
*/
cancelRequest: (requestId: string) => void;
/**
* cancel all request
*
@ -93,35 +84,32 @@ export interface RequestInstanceCommon<State extends Record<string, unknown>> {
*/
cancelAllRequest: () => void;
/** you can set custom state in the request instance */
state: State;
state: T;
}
/** The request instance */
export interface RequestInstance<ApiData, State extends Record<string, unknown>> extends RequestInstanceCommon<State> {
<T extends ApiData = ApiData, R extends ResponseType = 'json'>(
config: CustomAxiosRequestConfig<R>
): Promise<MappedType<R, T>>;
export interface RequestInstance<S = Record<string, unknown>> extends RequestInstanceCommon<S> {
<T = any, R extends ResponseType = 'json'>(config: CustomAxiosRequestConfig<R>): Promise<MappedType<R, T>>;
}
export type FlatResponseSuccessData<ResponseData, ApiData> = {
data: ApiData;
export type FlatResponseSuccessData<T = any, ResponseData = any> = {
data: T;
error: null;
response: AxiosResponse<ResponseData>;
};
export type FlatResponseFailData<ResponseData> = {
export type FlatResponseFailData<ResponseData = any> = {
data: null;
error: AxiosError<ResponseData>;
response: AxiosResponse<ResponseData>;
};
export type FlatResponseData<ResponseData, ApiData> =
| FlatResponseSuccessData<ResponseData, ApiData>
export type FlatResponseData<T = any, ResponseData = any> =
| FlatResponseSuccessData<T, ResponseData>
| FlatResponseFailData<ResponseData>;
export interface FlatRequestInstance<ResponseData, ApiData, State extends Record<string, unknown>>
extends RequestInstanceCommon<State> {
<T extends ApiData = ApiData, R extends ResponseType = 'json'>(
export interface FlatRequestInstance<S = Record<string, unknown>, ResponseData = any> extends RequestInstanceCommon<S> {
<T = any, R extends ResponseType = 'json'>(
config: CustomAxiosRequestConfig<R>
): Promise<FlatResponseData<ResponseData, MappedType<R, T>>>;
): Promise<FlatResponseData<MappedType<R, T>, ResponseData>>;
}

View File

@ -3,7 +3,9 @@ import useLoading from './use-loading';
import useCountDown from './use-count-down';
import useContext from './use-context';
import useSvgIconRender from './use-svg-icon-render';
import useTable from './use-table';
import useHookTable from './use-table';
export { useBoolean, useLoading, useCountDown, useContext, useSvgIconRender, useTable };
export type * from './use-table';
export { useBoolean, useLoading, useCountDown, useContext, useSvgIconRender, useHookTable };
export * from './use-signal';
export * from './use-table';

View File

@ -1,4 +1,5 @@
import { inject, provide } from 'vue';
import type { InjectionKey } from 'vue';
/**
* Use context
@ -11,7 +12,7 @@ import { inject, provide } from 'vue';
* import { ref } from 'vue';
* import { useContext } from '@sa/hooks';
*
* export const [provideDemoContext, useDemoContext] = useContext('demo', () => {
* export const { setupStore, useStore } = useContext('demo', () => {
* const count = ref(0);
*
* function increment() {
@ -34,10 +35,10 @@ import { inject, provide } from 'vue';
* <div>A</div>
* </template>
* <script setup lang="ts">
* import { provideDemoContext } from './context';
* import { setupStore } from './context';
*
* provideDemoContext();
* // const { increment } = provideDemoContext(); // also can control the store in the parent component
* setupStore();
* // const { increment } = setupStore(); // also can control the store in the parent component
* </script>
* ``` // B.vue
* ```vue
@ -45,9 +46,9 @@ import { inject, provide } from 'vue';
* <div>B</div>
* </template>
* <script setup lang="ts">
* import { useDemoContext } from './context';
* import { useStore } from './context';
*
* const { count, increment } = useDemoContext();
* const { count, increment } = useStore();
* </script>
* ```;
*
@ -56,41 +57,40 @@ import { inject, provide } from 'vue';
* @param contextName Context name
* @param fn Context function
*/
export default function useContext<Arguments extends Array<any>, T>(
contextName: string,
composable: (...args: Arguments) => T
) {
const key = Symbol(contextName);
export default function useContext<T extends (...args: any[]) => any>(contextName: string, fn: T) {
type Context = ReturnType<T>;
/**
* Injects the context value.
*
* @param consumerName - The name of the component that is consuming the context. If provided, the component must be
* used within the context provider.
* @param defaultValue - The default value to return if the context is not provided.
* @returns The context value.
*/
const useInject = <N extends string | null | undefined = undefined>(
consumerName?: N,
defaultValue?: T
): N extends null | undefined ? T | null : T => {
const value = inject(key, defaultValue);
const { useProvide, useInject: useStore } = createContext<Context>(contextName);
if (consumerName && !value) {
throw new Error(`\`${consumerName}\` must be used within \`${contextName}\``);
}
function setupStore(...args: Parameters<T>) {
const context: Context = fn(...args);
return useProvide(context);
}
// @ts-expect-error - we want to return null if the value is undefined or null
return value || null;
return {
/** Setup store in the parent component */
setupStore,
/** Use store in the child component */
useStore
};
}
/** Create context */
function createContext<T>(contextName: string) {
const injectKey: InjectionKey<T> = Symbol(contextName);
function useProvide(context: T) {
provide(injectKey, context);
return context;
}
function useInject() {
return inject(injectKey) as T;
}
return {
useProvide,
useInject
};
const useProvide = (...args: Arguments) => {
const value = composable(...args);
provide(key, value);
return value;
};
return [useProvide, useInject] as const;
}

View File

@ -6,31 +6,31 @@ import type {
CreateAxiosDefaults,
CustomAxiosRequestConfig,
MappedType,
RequestInstanceCommon,
RequestOption,
ResponseType
} from '@sa/axios';
import useLoading from './use-loading';
export type HookRequestInstanceResponseSuccessData<ApiData> = {
data: Ref<ApiData>;
export type HookRequestInstanceResponseSuccessData<T = any> = {
data: Ref<T>;
error: Ref<null>;
};
export type HookRequestInstanceResponseFailData<ResponseData> = {
export type HookRequestInstanceResponseFailData<ResponseData = any> = {
data: Ref<null>;
error: Ref<AxiosError<ResponseData>>;
};
export type HookRequestInstanceResponseData<ResponseData, ApiData> = {
export type HookRequestInstanceResponseData<T = any, ResponseData = any> = {
loading: Ref<boolean>;
} & (HookRequestInstanceResponseSuccessData<ApiData> | HookRequestInstanceResponseFailData<ResponseData>);
} & (HookRequestInstanceResponseSuccessData<T> | HookRequestInstanceResponseFailData<ResponseData>);
export interface HookRequestInstance<ResponseData, ApiData, State extends Record<string, unknown>>
extends RequestInstanceCommon<State> {
<T extends ApiData = ApiData, R extends ResponseType = 'json'>(
export interface HookRequestInstance<ResponseData = any> {
<T = any, R extends ResponseType = 'json'>(
config: CustomAxiosRequestConfig
): HookRequestInstanceResponseData<ResponseData, MappedType<R, T>>;
): HookRequestInstanceResponseData<MappedType<R, T>, ResponseData>;
cancelRequest: (requestId: string) => void;
cancelAllRequest: () => void;
}
/**
@ -39,26 +39,25 @@ export interface HookRequestInstance<ResponseData, ApiData, State extends Record
* @param axiosConfig
* @param options
*/
export default function createHookRequest<ResponseData, ApiData, State extends Record<string, unknown>>(
export default function createHookRequest<ResponseData = any>(
axiosConfig?: CreateAxiosDefaults,
options?: Partial<RequestOption<ResponseData, ApiData, State>>
options?: Partial<RequestOption<ResponseData>>
) {
const request = createFlatRequest<ResponseData, ApiData, State>(axiosConfig, options);
const request = createFlatRequest<ResponseData>(axiosConfig, options);
const hookRequest: HookRequestInstance<ResponseData, ApiData, State> = function hookRequest<
T extends ApiData = ApiData,
R extends ResponseType = 'json'
>(config: CustomAxiosRequestConfig) {
const hookRequest: HookRequestInstance<ResponseData> = function hookRequest<T = any, R extends ResponseType = 'json'>(
config: CustomAxiosRequestConfig
) {
const { loading, startLoading, endLoading } = useLoading();
const data = ref(null) as Ref<MappedType<R, T>>;
const error = ref(null) as Ref<AxiosError<ResponseData> | null>;
const data = ref<MappedType<R, T> | null>(null) as Ref<MappedType<R, T>>;
const error = ref<AxiosError<ResponseData> | null>(null) as Ref<AxiosError<ResponseData> | null>;
startLoading();
request(config).then(res => {
if (res.data) {
data.value = res.data as MappedType<R, T>;
data.value = res.data;
} else {
error.value = res.error;
}
@ -71,8 +70,9 @@ export default function createHookRequest<ResponseData, ApiData, State extends R
data,
error
};
} as HookRequestInstance<ResponseData, ApiData, State>;
} as HookRequestInstance<ResponseData>;
hookRequest.cancelRequest = request.cancelRequest;
hookRequest.cancelAllRequest = request.cancelAllRequest;
return hookRequest;

View File

@ -0,0 +1,144 @@
import { computed, ref, shallowRef, triggerRef } from 'vue';
import type {
ComputedGetter,
DebuggerOptions,
Ref,
ShallowRef,
WritableComputedOptions,
WritableComputedRef
} from 'vue';
type Updater<T> = (value: T) => T;
type Mutator<T> = (value: T) => void;
/**
* Signal is a reactive value that can be set, updated or mutated
*
* @example
* ```ts
* const count = useSignal(0);
*
* // `watchEffect`
* watchEffect(() => {
* console.log(count());
* });
*
* // watch
* watch(count, value => {
* console.log(value);
* });
*
* // useComputed
* const double = useComputed(() => count() * 2);
* const writeableDouble = useComputed({
* get: () => count() * 2,
* set: value => count.set(value / 2)
* });
* ```
*/
export interface Signal<T> {
(): Readonly<T>;
/**
* Set the value of the signal
*
* It recommend use `set` for primitive values
*
* @param value
*/
set(value: T): void;
/**
* Update the value of the signal using an updater function
*
* It recommend use `update` for non-primitive values, only the first level of the object will be reactive.
*
* @param updater
*/
update(updater: Updater<T>): void;
/**
* Mutate the value of the signal using a mutator function
*
* this action will call `triggerRef`, so the value will be tracked on `watchEffect`.
*
* It recommend use `mutate` for non-primitive values, all levels of the object will be reactive.
*
* @param mutator
*/
mutate(mutator: Mutator<T>): void;
/**
* Get the reference of the signal
*
* Sometimes it can be useful to make `v-model` work with the signal
*
* ```vue
* <template>
* <input v-model="model.count" />
* </template>;
*
* <script setup lang="ts">
* const state = useSignal({ count: 0 }, { useRef: true });
*
* const model = state.getRef();
* </script>
* ```
*/
getRef(): Readonly<ShallowRef<Readonly<T>>>;
}
export interface ReadonlySignal<T> {
(): Readonly<T>;
}
export interface SignalOptions {
/**
* Whether to use `ref` to store the value
*
* @default false use `sharedRef` to store the value
*/
useRef?: boolean;
}
export function useSignal<T>(initialValue: T, options?: SignalOptions): Signal<T> {
const { useRef } = options || {};
const state = useRef ? (ref(initialValue) as Ref<T>) : shallowRef(initialValue);
return createSignal(state);
}
export function useComputed<T>(getter: ComputedGetter<T>, debugOptions?: DebuggerOptions): ReadonlySignal<T>;
export function useComputed<T>(options: WritableComputedOptions<T>, debugOptions?: DebuggerOptions): Signal<T>;
export function useComputed<T>(
getterOrOptions: ComputedGetter<T> | WritableComputedOptions<T>,
debugOptions?: DebuggerOptions
) {
const isGetter = typeof getterOrOptions === 'function';
const computedValue = computed(getterOrOptions as any, debugOptions);
if (isGetter) {
return () => computedValue.value as ReadonlySignal<T>;
}
return createSignal(computedValue);
}
function createSignal<T>(state: ShallowRef<T> | WritableComputedRef<T>): Signal<T> {
const signal = () => state.value;
signal.set = (value: T) => {
state.value = value;
};
signal.update = (updater: Updater<T>) => {
state.value = updater(state.value);
};
signal.mutate = (mutator: Mutator<T>) => {
mutator(state.value);
triggerRef(state);
};
signal.getRef = () => state as Readonly<ShallowRef<Readonly<T>>>;
return signal;
}

View File

@ -1,20 +1,12 @@
import { computed, ref } from 'vue';
import { computed, reactive, ref } from 'vue';
import type { Ref, VNodeChild } from 'vue';
import { jsonClone } from '@sa/utils';
import useBoolean from './use-boolean';
import useLoading from './use-loading';
export interface PaginationData<T> {
data: T[];
pageNum: number;
pageSize: number;
total: number;
}
export type MaybePromise<T> = T | Promise<T>;
type GetApiData<ApiData, Pagination extends boolean> = Pagination extends true ? PaginationData<ApiData> : ApiData[];
type Transform<ResponseData, ApiData, Pagination extends boolean> = (
response: ResponseData
) => GetApiData<ApiData, Pagination>;
export type ApiFn = (args: any) => Promise<unknown>;
export type TableColumnCheckTitle = string | ((...args: any) => VNodeChild);
@ -22,64 +14,77 @@ export type TableColumnCheck = {
key: string;
title: TableColumnCheckTitle;
checked: boolean;
visible: boolean;
};
export interface UseTableOptions<ResponseData, ApiData, Column, Pagination extends boolean> {
/**
* api function to get table data
*/
api: () => Promise<ResponseData>;
/**
* whether to enable pagination
*/
pagination?: Pagination;
/**
* transform api response to table data
*/
transform: Transform<ResponseData, ApiData, Pagination>;
/**
* columns factory
*/
columns: () => Column[];
export type TableDataWithIndex<T> = T & { index: number };
export type TransformedData<T> = {
data: TableDataWithIndex<T>[];
total?: number;
};
export type Transformer<T, Response> = (response: Response) => TransformedData<T>;
export type TableConfig<A extends ApiFn, T, C> = {
/** api function to get table data */
apiFn: A;
/** api params */
apiParams?: Parameters<A>[0];
/** transform api response to table data */
transformer: Transformer<T, Awaited<ReturnType<A>>>;
/** columns factory */
columns: () => C[];
/**
* get column checks
*
* @param columns
*/
getColumnChecks: (columns: Column[]) => TableColumnCheck[];
getColumnChecks: (columns: C[]) => TableColumnCheck[];
/**
* get columns
*
* @param columns
*/
getColumns: (columns: Column[], checks: TableColumnCheck[]) => Column[];
getColumns: (columns: C[], checks: TableColumnCheck[]) => C[];
/**
* callback when response fetched
*
* @param transformed transformed data
*/
onFetched?: (data: GetApiData<ApiData, Pagination>) => void | Promise<void>;
onFetched?: (transformed: TransformedData<T>) => MaybePromise<void>;
/**
* whether to get data immediately
*
* @default true
*/
immediate?: boolean;
}
};
export default function useTable<ResponseData, ApiData, Column, Pagination extends boolean>(
options: UseTableOptions<ResponseData, ApiData, Column, Pagination>
) {
export default function useHookTable<A extends ApiFn, T, C>(config: TableConfig<A, T, C>) {
const { loading, startLoading, endLoading } = useLoading();
const { bool: empty, setBool: setEmpty } = useBoolean();
const { api, pagination, transform, columns, getColumnChecks, getColumns, onFetched, immediate = true } = options;
const { transformer, immediate = true, getColumnChecks, getColumns } = config;
const data = ref([]) as Ref<ApiData[]>;
let currentApiFn = config.apiFn;
const apiParams = config.apiParams;
const columnChecks = ref(getColumnChecks(columns())) as Ref<TableColumnCheck[]>;
const searchParams: NonNullable<Parameters<A>[0]> = reactive(jsonClone({ ...apiParams }));
const $columns = computed(() => getColumns(columns(), columnChecks.value));
const allColumns = ref(config.columns()) as Ref<C[]>;
const data: Ref<TableDataWithIndex<T>[]> = ref([]);
const columnChecks: Ref<TableColumnCheck[]> = ref(getColumnChecks(config.columns()));
const columns = computed(() => getColumns(allColumns.value, columnChecks.value));
function reloadColumns() {
allColumns.value = config.columns();
const checkMap = new Map(columnChecks.value.map(col => [col.key, col.checked]));
const defaultChecks = getColumnChecks(columns());
const defaultChecks = getColumnChecks(allColumns.value);
columnChecks.value = defaultChecks.map(col => ({
...col,
@ -88,21 +93,52 @@ export default function useTable<ResponseData, ApiData, Column, Pagination exten
}
async function getData() {
try {
startLoading();
startLoading();
const response = await api();
const formattedParams = formatSearchParams(searchParams);
const transformed = transform(response);
const response = await currentApiFn(formattedParams);
data.value = getTableData(transformed, pagination);
const transformed = transformer(response as Awaited<ReturnType<A>>);
setEmpty(data.value.length === 0);
data.value = transformed.data;
await onFetched?.(transformed);
} finally {
endLoading();
}
setEmpty(transformed.data.length === 0);
await config.onFetched?.(transformed);
endLoading();
}
function formatSearchParams(params: Record<string, unknown>) {
const formattedParams: Record<string, unknown> = {};
Object.entries(params).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
formattedParams[key] = value;
}
});
return formattedParams;
}
function updateApiFn(newApiFn: A) {
currentApiFn = newApiFn;
}
/**
* update search params
*
* @param params
*/
function updateSearchParams(params: Partial<Parameters<A>[0]>) {
Object.assign(searchParams, params);
}
/** reset search params */
function resetSearchParams() {
Object.assign(searchParams, jsonClone(apiParams));
getData();
}
if (immediate) {
@ -113,20 +149,13 @@ export default function useTable<ResponseData, ApiData, Column, Pagination exten
loading,
empty,
data,
columns: $columns,
columns,
columnChecks,
reloadColumns,
getData
getData,
searchParams,
updateSearchParams,
resetSearchParams,
updateApiFn
};
}
function getTableData<ApiData, Pagination extends boolean>(
data: GetApiData<ApiData, Pagination>,
pagination?: Pagination
) {
if (pagination) {
return (data as PaginationData<ApiData>).data;
}
return data as ApiData[];
}

View File

@ -127,6 +127,7 @@ function handleClickMask() {
:class="[
style['layout-header'],
commonClass,
headerClass,
headerLeftGapClass,
{ 'absolute top-0 left-0 w-full': fixedHeaderAndTab }
]"

View File

@ -6,6 +6,12 @@ interface AdminLayoutHeaderConfig {
* @default true
*/
headerVisible?: boolean;
/**
* Header class
*
* @default ''
*/
headerClass?: string;
/**
* Header height
*

View File

@ -0,0 +1,15 @@
{
"name": "@sa/fetch",
"version": "1.3.15",
"exports": {
".": "./src/index.ts"
},
"typesVersions": {
"*": {
"*": ["./src/*"]
}
},
"dependencies": {
"ofetch": "1.4.1"
}
}

View File

@ -0,0 +1,10 @@
import { ofetch } from 'ofetch';
import type { FetchOptions } from 'ofetch';
export function createRequest(options: FetchOptions) {
const request = ofetch.create(options);
return request;
}
export default createRequest;

View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ESNext",
"jsx": "preserve",
"lib": ["DOM", "ESNext"],
"baseUrl": ".",
"module": "ESNext",
"moduleResolution": "node",
"resolveJsonModule": true,
"types": ["node"],
"strict": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@ -22,7 +22,6 @@
"execa": "9.6.0",
"kolorist": "1.8.0",
"npm-check-updates": "18.0.3",
"picomatch": "4.0.3",
"rimraf": "6.0.1"
}
}

View File

@ -32,8 +32,7 @@ export function createStorage<T extends object>(type: StorageType, storagePrefix
storageData = JSON.parse(json);
} catch {}
// storageData may be `false` if it is boolean type
if (storageData !== null) {
if (storageData) {
return storageData as T[K];
}
}

690
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,7 @@ import { NConfigProvider, darkTheme } from 'naive-ui';
import type { WatermarkProps } from 'naive-ui';
import { useAppStore } from './store/modules/app';
import { useThemeStore } from './store/modules/theme';
import { useAuthStore } from './store/modules/auth';
import { naiveDateLocales, naiveLocales } from './locales/naive';
defineOptions({
@ -12,6 +13,7 @@ defineOptions({
const appStore = useAppStore();
const themeStore = useThemeStore();
const { userInfo } = useAuthStore();
const naiveDarkTheme = computed(() => (themeStore.darkMode ? darkTheme : undefined));
@ -24,19 +26,24 @@ const naiveDateLocale = computed(() => {
});
const watermarkProps = computed<WatermarkProps>(() => {
const appTitle = import.meta.env.VITE_APP_TITLE || 'RuoYi-Vue-Plus';
const content =
themeStore.watermark.enableUserName && userInfo.user?.userName
? `${userInfo.user?.nickName}@${appTitle} ${userInfo.user?.userName}`
: appTitle;
return {
content: themeStore.watermarkContent,
content,
cross: true,
fullscreen: true,
fontSize: 14,
fontColor: themeStore.darkMode ? 'rgba(200, 200, 200, 0.03)' : 'rgba(200, 200, 200, 0.2)',
lineHeight: 14,
width: 384,
height: 384,
width: 200,
height: 300,
xOffset: 12,
yOffset: 60,
rotate: -13,
zIndex: 9999,
fontColor: themeStore.darkMode ? 'rgba(200, 200, 200, 0.03)' : 'rgba(200, 200, 200, 0.2)'
rotate: -18,
zIndex: 9999
};
});
</script>

View File

@ -22,12 +22,7 @@ const columns = defineModel<NaiveUI.TableColumnCheck[]>('columns', {
</NButton>
</template>
<VueDraggable v-model="columns" :animation="150" filter=".none_draggable">
<div
v-for="item in columns"
:key="item.key"
class="h-36px flex-y-center rd-4px hover:(bg-primary bg-opacity-20)"
:class="{ hidden: !item.visible }"
>
<div v-for="item in columns" :key="item.key" class="h-36px flex-y-center rd-4px hover:(bg-primary bg-opacity-20)">
<icon-mdi-drag class="mr-8px h-full cursor-move text-icon" />
<NCheckbox v-model:checked="item.checked" class="none_draggable flex-1">
<template v-if="typeof item.title === 'function'">

View File

@ -1,42 +0,0 @@
<script lang="ts" setup>
import { computed, useSlots } from 'vue';
import type { PopoverPlacement } from 'naive-ui';
defineOptions({ name: 'IconTooltip' });
interface Props {
icon?: string;
localIcon?: string;
desc?: string;
placement?: PopoverPlacement;
}
const props = withDefaults(defineProps<Props>(), {
icon: 'mdi-help-circle',
localIcon: '',
desc: '',
placement: 'top'
});
const slots = useSlots();
const hasCustomTrigger = computed(() => Boolean(slots.trigger));
if (!hasCustomTrigger.value && !props.icon && !props.localIcon) {
throw new Error('icon or localIcon is required when no custom trigger slot is provided');
}
</script>
<template>
<NTooltip :placement="placement">
<template #trigger>
<slot name="trigger">
<div class="cursor-pointer">
<SvgIcon :icon="icon" :local-icon="localIcon" />
</div>
</slot>
</template>
<slot>
<span>{{ desc }}</span>
</slot>
</NTooltip>
</template>

View File

@ -0,0 +1,79 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { NPopover, NSpace, NTag } from 'naive-ui';
interface Props {
value: string | any[];
type?: NaiveUI.ThemeColor;
size?: 'small' | 'medium' | 'large';
placeholder?: string;
closable?: boolean;
threadshold?: number; // 超过该数量显示popover
}
const props = withDefaults(defineProps<Props>(), {
type: 'info',
size: 'small',
placeholder: '无',
closable: false,
threadshold: 1 // 默认超过1个就显示popover
});
interface Emits {
(e: 'close', index?: number): void;
}
const emit = defineEmits<Emits>();
// 统一解析 value 成数组
const tags = computed(() => {
if (!props.value) return [];
return Array.isArray(props.value) ? props.value : props.value.split(',');
});
function handleClose(index?: number) {
emit('close', index);
}
</script>
<template>
<template v-if="tags.length === 0">
<NTag :size="size">
{{ placeholder }}
</NTag>
</template>
<template v-else-if="tags.length <= threadshold">
<NTag
v-for="(tag, index) in tags"
:key="index"
:type="type"
class="m-1"
:size="size"
:closable="closable"
@close="handleClose(index)"
>
{{ tag }}
</NTag>
</template>
<template v-else>
<NPopover trigger="hover" placement="bottom">
<template #trigger>
<NTag :type="type" :size="size" class="cursor-pointer">{{ tags[0] }}...({{ tags.length }})</NTag>
</template>
<NSpace vertical size="small">
<NTag
v-for="(tag, index) in tags"
:key="index"
:type="type"
:size="size"
:closable="closable"
@close="handleClose(index)"
>
{{ tag }}
</NTag>
</NSpace>
</NPopover>
</template>
</template>

View File

@ -0,0 +1,370 @@
<script setup lang="tsx">
import { computed, ref, watch } from 'vue';
import { NButton } from 'naive-ui';
import { useLoading } from '@sa/hooks';
import { fetchGetDeptTree, fetchGetUserList } from '@/service/api/system';
import { useAppStore } from '@/store/modules/app';
import { useTable, useTableOperate } from '@/hooks/common/table';
import { useDict } from '@/hooks/business/dict';
import { $t } from '@/locales';
import UserSearch from '@/views/system/user/modules/user-search.vue';
import DictTag from './dict-tag.vue';
defineOptions({
name: 'UserSelectModal'
});
interface Props {
title?: string;
multiple?: boolean;
/** 禁选用户ID */
disabledIds?: CommonType.IdType[];
rowKeys?: CommonType.IdType[];
searchUserIds?: string | null;
}
const props = withDefaults(defineProps<Props>(), {
title: '用户选择',
multiple: false,
disabledIds: () => [],
rowKeys: () => [],
searchUserIds: null
});
interface Emits {
(e: 'confirm', value: CommonType.IdType[], rows?: Api.System.User[]): void;
}
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
useDict('sys_normal_disable');
const appStore = useAppStore();
const {
columns,
columnChecks,
data,
getData,
getDataByPage,
loading,
mobilePagination,
searchParams,
resetSearchParams
} = useTable({
apiFn: fetchGetUserList,
apiParams: {
pageNum: 1,
pageSize: 10,
// if you want to use the searchParams in Form, you need to define the following properties, and the value is null
// the value can not be undefined, otherwise the property in Form will not be reactive
deptId: null,
userName: null,
nickName: null,
phonenumber: null,
status: null,
params: {}
},
immediate: false,
columns: () => [
{
type: 'selection',
multiple: props.multiple,
align: 'center',
width: 48,
disabled: row => props.disabledIds.includes(row.userId.toString())
},
{
key: 'index',
title: $t('common.index'),
align: 'center',
width: 64
},
{
key: 'userName',
title: $t('page.system.user.userName'),
align: 'center',
minWidth: 120,
ellipsis: true
},
{
key: 'nickName',
title: $t('page.system.user.nickName'),
align: 'center',
minWidth: 120,
ellipsis: true
},
{
key: 'deptName',
title: $t('page.system.user.deptName'),
align: 'center',
minWidth: 120,
ellipsis: true
},
{
key: 'phonenumber',
title: $t('page.system.user.phonenumber'),
align: 'center',
minWidth: 120,
ellipsis: true
},
{
key: 'status',
title: $t('page.system.user.status'),
align: 'center',
minWidth: 80,
render(row) {
return <DictTag dict-code="sys_normal_disable" value={row.status} />;
}
},
{
key: 'createTime',
title: $t('page.system.user.createTime'),
align: 'center',
minWidth: 120
}
]
});
const { checkedRowKeys } = useTableOperate(data, getData);
// 存储所有页面的用户数据,用于跨页选择
const allPagesData = ref<Api.System.User[]>([]);
// 更新allPagesData保存当前页数据
function updateAllPagesData() {
// 将当前页数据添加到allPagesData中避免重复
data.value.forEach(user => {
const existIndex = allPagesData.value.findIndex(item => item.userId === user.userId);
if (existIndex === -1) {
allPagesData.value.push(user);
} else {
// 更新已存在的数据
allPagesData.value[existIndex] = user;
}
});
}
const { loading: treeLoading, startLoading: startTreeLoading, endLoading: endTreeLoading } = useLoading();
const deptPattern = ref<string>();
const deptData = ref<Api.Common.CommonTreeRecord>([]);
const selectedKeys = ref<string[]>([]);
async function getTreeData() {
startTreeLoading();
const { data: tree, error } = await fetchGetDeptTree();
if (!error) {
deptData.value = tree;
}
endTreeLoading();
}
function handleClickTree(keys: string[]) {
searchParams.deptId = keys.length ? keys[0] : null;
checkedRowKeys.value = [];
getDataByPage();
}
function handleResetTreeData() {
deptPattern.value = undefined;
getTreeData();
}
const expandedKeys = ref<CommonType.IdType[]>([100]);
const selectable = computed(() => {
return !loading.value;
});
function handleResetSearch() {
resetSearchParams();
selectedKeys.value = [];
}
function closeModal() {
checkedRowKeys.value = [];
allPagesData.value = [];
visible.value = false;
}
function handleConfirm() {
if (checkedRowKeys.value.length === 0) {
window.$message?.error('请选择用户');
return;
}
// 获取选中行对应的用户对象(从所有页面数据中筛选)
const selectedUsers = allPagesData.value.filter(item => checkedRowKeys.value.includes(item.userId.toString()));
emit('confirm', checkedRowKeys.value, selectedUsers);
closeModal();
}
function getRowProps(row: Api.System.User) {
return {
onClick: (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (target.closest('.n-data-table-td--selection')) {
return;
}
if (props.disabledIds.includes(row.userId.toString())) {
return;
}
// 将userId转为字符串
const userId = row.userId.toString();
if (props.multiple) {
const index = checkedRowKeys.value.findIndex(key => key === userId);
if (index > -1) {
checkedRowKeys.value.splice(index, 1);
} else {
checkedRowKeys.value.push(userId);
}
} else {
checkedRowKeys.value = [userId];
}
}
};
}
// 监听数据变化(页面切换时)
watch(
data,
() => {
updateAllPagesData();
},
{ deep: true }
);
watch(visible, () => {
if (visible.value) {
getTreeData();
if (props.searchUserIds) {
searchParams.userIds = props.searchUserIds;
}
allPagesData.value = [];
getDataByPage();
checkedRowKeys.value = [...props.rowKeys];
}
});
</script>
<template>
<NModal
v-model:show="visible"
class="user-select-modal max-h-800px max-w-90% w-1400px"
preset="card"
size="medium"
:title="props.title"
>
<TableSiderLayout class="bg-gray-50 p-2" :sider-title="$t('page.system.dept.title')">
<template #header-extra>
<NButton size="small" text class="h-18px" @click.stop="() => handleResetTreeData()">
<template #icon>
<SvgIcon icon="ic:round-refresh" />
</template>
</NButton>
</template>
<template #sider>
<NInput v-model:value="deptPattern" clearable :placeholder="$t('common.keywordSearch')" />
<NSpin class="dept-tree" :show="treeLoading">
<NTree
v-model:expanded-keys="expandedKeys"
v-model:selected-keys="selectedKeys"
block-node
show-line
:data="deptData as []"
:show-irrelevant-nodes="false"
:pattern="deptPattern"
class="infinite-scroll h-full min-h-200px py-3"
key-field="id"
label-field="label"
virtual-scroll
:selectable="selectable"
@update:selected-keys="handleClickTree"
>
<template #empty>
<NEmpty :description="$t('page.system.dept.empty')" class="h-full min-h-200px justify-center" />
</template>
</NTree>
</NSpin>
</template>
<div class="h-full flex-col-stretch gap-12px overflow-hidden lt-sm:max-h-500px lt-sm:overflow-auto">
<UserSearch v-model:model="searchParams" @reset="handleResetSearch" @search="getDataByPage" />
<TableRowCheckAlert v-model:checked-row-keys="checkedRowKeys" />
<NAlert v-if="props.disabledIds.length > 0" type="warning">
<span>已存在的用户无法被选择</span>
</NAlert>
<NCard
:title="$t('page.system.user.title')"
:bordered="false"
size="small"
class="card-wrapper sm:flex-1-hidden lt-sm:overflow-auto"
>
<template #header-extra>
<TableHeaderOperation
v-model:columns="columnChecks"
:loading="loading"
:show-add="false"
:show-delete="false"
:show-export="false"
@refresh="getData"
></TableHeaderOperation>
</template>
<NDataTable
v-model:checked-row-keys="checkedRowKeys"
:columns="columns"
:data="data"
size="small"
:flex-height="!appStore.isMobile"
:scroll-x="962"
:loading="loading"
:row-props="getRowProps"
remote
:row-key="row => row.userId.toString()"
:pagination="mobilePagination"
class="h-full lt-sm:max-h-300px"
/>
</NCard>
</div>
</TableSiderLayout>
<template #footer>
<NSpace justify="end" :size="16">
<NButton @click="closeModal">{{ $t('common.cancel') }}</NButton>
<NButton type="primary" @click="handleConfirm">{{ $t('common.confirm') }}</NButton>
</NSpace>
</template>
</NModal>
</template>
<style scoped lang="scss">
:deep(.n-layout) {
height: 600px;
@media (max-width: 639px) {
height: auto;
max-height: 500px;
}
}
.user-select-modal {
@media (max-width: 639px) {
:deep(.n-card-content) {
overflow: hidden;
}
:deep(.n-data-table) {
max-height: 300px;
}
}
}
.n-alert {
--n-padding: 5px 13px !important;
--n-icon-margin: 6px 8px 0 12px !important;
--n-icon-size: 20px !important;
}
</style>

View File

@ -0,0 +1,178 @@
<script setup lang="tsx">
import { ref } from 'vue';
import { NPopover, NSpace, NTag } from 'naive-ui';
import { useLoading } from '@sa/hooks';
import { fetchGetFlowHisTaskList } from '@/service/api/workflow/instance';
import { fetchGetOssListByIds } from '@/service/api/system/oss';
import { useDict } from '@/hooks/business/dict';
import { useDownload } from '@/hooks/business/download';
import DictTag from '@/components/custom/dict-tag.vue';
import TagGroup from '@/components/custom/tag-group.vue';
defineOptions({
name: 'ApprovalInfoPanel'
});
interface Props {
/** 业务id */
businessId: CommonType.IdType;
}
const props = defineProps<Props>();
useDict('wf_task_status');
const activeTab = ref('image');
const { loading, startLoading, endLoading } = useLoading();
const { oss } = useDownload();
const columns = ref<NaiveUI.TableColumn<Api.Workflow.HisTask>[]>([
{
title: '任务名称',
key: 'nodeName',
align: 'center',
width: 100
},
{
title: '办理人',
key: 'approveName',
align: 'center',
width: 100,
render: row => {
return <TagGroup value={row.approveName} />;
}
},
{
title: '任务状态',
key: 'flowStatus',
align: 'center',
width: 100,
render: row => {
return <DictTag size="small" value={row.flowStatus} dict-code="wf_task_status" />;
}
},
{
title: '审批意见',
key: 'message',
align: 'center',
width: 100
},
{
title: '开始时间',
key: 'createTime',
align: 'center',
width: 120
},
{
title: '结束时间',
key: 'updateTime',
align: 'center',
width: 120
},
{
title: '运行时间',
key: 'runDuration',
align: 'center',
width: 100
},
{
title: '附件',
key: 'attachmentList',
align: 'center',
width: 120,
render: row => {
if (!row.attachmentList || row.attachmentList.length === 0) return null;
if (row.attachmentList.length === 1) {
return (
<NTag size="small" type="info" class="cursor-pointer">
<div class="flex items-center gap-2" onClick={() => oss(row.attachmentList[0].ossId)}>
{row.attachmentList[0].originalName}
</div>
</NTag>
);
}
return (
<NPopover trigger="hover" placement="bottom">
{{
trigger: () => (
<NTag size="small" type="info" class="cursor-pointer">
{row.attachmentList[0].originalName}...({row.attachmentList.length})
</NTag>
),
default: () => (
<NSpace vertical size="small">
{row.attachmentList.map(item => (
<NTag key={item.ossId} size="small" type="info" class="cursor-pointer">
<div class="flex items-center gap-2" onClick={() => oss(item.ossId)}>
{item.originalName}
</div>
</NTag>
))}
</NSpace>
)
}}
</NPopover>
);
}
}
]);
const instanceId = ref<CommonType.IdType>();
const hisTask = ref<Api.Workflow.HisTask[]>([]);
/** 初始化数据 */
async function initData() {
activeTab.value = 'image';
instanceId.value = undefined;
hisTask.value = [];
await getData();
}
async function getData() {
startLoading();
const { error, data } = await fetchGetFlowHisTaskList(props.businessId);
if (error) {
window.$message?.error(error.message);
return;
}
instanceId.value = data?.instanceId || '';
const rawList = data?.list || [];
if (rawList.length === 0) {
hisTask.value = [];
return;
}
const promises = rawList.map(async (item: Api.Workflow.HisTask) => {
if (item.ext) {
const { error: err, data: ossList } = await fetchGetOssListByIds(item.ext.split(','));
if (!err) {
item.attachmentList = ossList;
}
}
});
await Promise.all(promises);
hisTask.value = rawList;
endLoading();
}
defineExpose({
initData
});
</script>
<template>
<NDivider />
<div class="h-full">
<NTabs v-model:value="activeTab" type="segment" animated>
<NTabPane display-directive="show" bar-width="100px" name="image" tab="流程图">
<FlowPreview v-if="instanceId" :instance-id="instanceId" />
</NTabPane>
<NTabPane bar-width="100px" name="info" tab="审批信息">
<NDataTable size="small" :scroll-x="760" :columns="columns" :data="hisTask" :loading="loading" />
</NTabPane>
</NTabs>
</div>
</template>

View File

@ -0,0 +1,142 @@
<script lang="ts" setup>
import { reactive, ref, watch } from 'vue';
import type { UploadFileInfo } from 'naive-ui';
import { useLoading } from '@sa/hooks';
import { messageTypeOptions } from '@/constants/workflow';
import { fetchBackTask, fetchGetBackNode } from '@/service/api/workflow/task';
defineOptions({
name: 'BackTaskModal'
});
interface Props {
task: Api.Workflow.Task;
}
const props = defineProps<Props>();
interface Emits {
(e: 'submit'): void;
}
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const title = defineModel<string>('title', {
default: '驳回'
});
const { loading: backFormLoading, startLoading: startBackFormLoading, endLoading: endBackFormLoading } = useLoading();
const { loading: backBtnLoading, startLoading: startBackBtnLoading, endLoading: endBackBtnLoading } = useLoading();
const accept = ref<string>('.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.pdf,.jpg,.jpeg,.png,.gif,.bmp,.webp');
type Model = Api.Workflow.BackOperateParams;
const backModel = reactive(createBackModel());
function createBackModel(): Model {
return {
taskId: null,
fileId: null,
messageType: ['1'],
nodeCode: null,
message: null,
notice: null,
variables: null
};
}
const fileList = ref<UploadFileInfo[]>([]);
const backTaskNodeOptions = ref<CommonType.Option<string, string>[]>([]);
async function initDefault() {
startBackFormLoading();
startBackBtnLoading();
Object.assign(backModel, createBackModel());
const { error, data } = await fetchGetBackNode(props.task.definitionId, props.task.nodeCode);
endBackFormLoading();
endBackBtnLoading();
if (error) return;
backTaskNodeOptions.value = data.map(item => ({
label: item.nodeName,
value: item.nodeCode
}));
backModel.nodeCode = data[0].nodeCode;
}
async function handleSubmit() {
backModel.taskId = props.task.id;
if (fileList.value?.length) {
const fileIds = fileList.value.map(item => item.id);
backModel.fileId = fileIds.join(',');
}
window.$dialog?.warning({
title: '提示',
content: `是否确认驳回?`,
positiveText: `确认驳回`,
positiveButtonProps: {
type: 'primary'
},
negativeText: '取消',
onPositiveClick: async () => {
startBackBtnLoading();
startBackFormLoading();
const { error } = await fetchBackTask(backModel);
endBackBtnLoading();
endBackFormLoading();
if (error) return;
window.$message?.success('驳回成功');
closeDrawer();
emit('submit');
}
});
}
function closeDrawer() {
visible.value = false;
}
watch(visible, () => {
if (visible.value) {
initDefault();
}
});
</script>
<template>
<NModal v-model:show="visible" preset="card" class="w-800px" :title="title" :native-scrollbar="false" closable>
<NSpin :show="backFormLoading">
<NForm v-if="task.flowStatus === 'waiting'" :model="backModel">
<NFormItem label="驳回节点" path="nodeCode">
<NSelect v-model:value="backModel.nodeCode" :options="backTaskNodeOptions" />
</NFormItem>
<NFormItem label="通知方式" path="messageType">
<NCheckboxGroup v-model:value="backModel.messageType">
<NSpace item-style="display: flex;">
<NCheckbox
v-for="item in messageTypeOptions"
:key="item.value"
:disabled="item.value === '1'"
:value="item.value"
:label="item.label"
/>
</NSpace>
</NCheckboxGroup>
</NFormItem>
<NFormItem label="附件" path="fileId">
<FileUpload v-model:file-list="fileList" :file-size="20" :max="20" upload-type="file" :accept="accept" />
</NFormItem>
<NFormItem label="审批意见" path="message">
<NInput v-model:value="backModel.message" type="textarea" />
</NFormItem>
</NForm>
</NSpin>
<template #footer>
<NSpace justify="end" :size="16">
<NButton @click="closeDrawer">{{ $t('common.cancel') }}</NButton>
<NButton :loading="backBtnLoading" type="primary" @click="handleSubmit">{{ $t('common.confirm') }}</NButton>
</NSpace>
</template>
</NModal>
</template>

View File

@ -0,0 +1,62 @@
<script setup lang="tsx">
import { computed, useAttrs } from 'vue';
import type { TreeSelectProps } from 'naive-ui';
import { useLoading } from '@sa/hooks';
import { fetchGetCategoryTree } from '@/service/api/workflow';
import { isNull } from '@/utils/common';
defineOptions({ name: 'FlowCategorySelect' });
interface Props {
[key: string]: any;
}
defineProps<Props>();
const rawValue = defineModel<CommonType.IdType | null>('value', { required: false });
const options = defineModel<Api.Common.CommonTreeRecord>('options', { required: false, default: [] });
const expandedKeys = defineModel<CommonType.IdType[]>('expandedKeys', { required: false, default: [] });
const attrs: TreeSelectProps = useAttrs();
const { loading, startLoading, endLoading } = useLoading();
/** 转换为strid可能是number类型或者String类型导致回显失败 */
const strValue = computed({
get() {
return isNull(rawValue.value) ? null : rawValue.value?.toString();
},
set(val) {
rawValue.value = val;
}
});
async function getCategoryList() {
startLoading();
const { error, data } = await fetchGetCategoryTree();
if (error) return;
options.value = data;
// 设置默认展开的节点
if (data?.length && !expandedKeys.value.length) {
expandedKeys.value = [data[0].id];
}
endLoading();
}
getCategoryList();
</script>
<template>
<NTreeSelect
v-model:value="strValue"
v-model:expanded-keys="expandedKeys"
filterable
class="h-full"
:loading="loading"
key-field="id"
label-field="label"
:options="options as []"
v-bind="attrs"
/>
</template>
<style scoped></style>

View File

@ -0,0 +1,96 @@
<script setup lang="ts">
import { computed } from 'vue';
import { $t } from '@/locales';
interface Props {
/** 抽屉是否可见 */
visible?: boolean;
/** 抽屉标题 */
title: string;
/** 是否显示加载状态 */
loading?: boolean;
/** 抽屉宽度 */
width?: number;
operateType: CommonType.WorkflowTableOperateType;
status?: string | null;
}
const props = withDefaults(defineProps<Props>(), {
visible: false,
loading: false,
width: 1200,
status: null
});
interface Emits {
(e: 'update:visible', visible: boolean): void;
(e: 'close'): void;
(e: 'saveDraft'): void;
(e: 'submit'): void;
(e: 'approval'): void;
}
const emit = defineEmits<Emits>();
const visibleValue = computed({
get: () => props.visible,
set: value => {
emit('update:visible', value);
}
});
const showSubmit = computed(
() =>
props.operateType === 'add' ||
(props.operateType === 'edit' &&
props.status &&
(props.status === 'draft' || props.status === 'cancel' || props.status === 'back'))
);
const showApproval = computed(() => props.operateType === 'approval' && props.status && props.status === 'waiting');
function handleClose() {
emit('close');
}
function handleSaveDraft() {
emit('saveDraft');
}
function handleSubmit() {
emit('submit');
}
function handleApproval() {
emit('approval');
}
defineExpose({
handleClose,
handleSaveDraft,
handleSubmit,
handleApproval
});
</script>
<template>
<NDrawer v-model:show="visibleValue" :title="title" display-directive="show" :width="width" class="max-w-90%">
<NDrawerContent :title="title" :native-scrollbar="false" closable @close="handleClose">
<NSpin :show="loading">
<slot></slot>
</NSpin>
<template #footer>
<slot name="footer">
<div>
<NSpace :size="16">
<NButton v-if="showSubmit || showApproval" @click="handleClose">{{ $t('common.cancel') }}</NButton>
<NButton v-if="showSubmit" type="warning" @click="handleSaveDraft">暂存</NButton>
<NButton v-if="showSubmit" type="primary" @click="handleSubmit">{{ $t('common.confirm') }}</NButton>
<NButton v-if="showApproval" type="primary" @click="handleApproval">办理</NButton>
</NSpace>
</div>
</slot>
</template>
</NDrawerContent>
</NDrawer>
</template>
<style scoped></style>

View File

@ -0,0 +1,222 @@
<script lang="ts" setup>
import { computed, reactive, ref, watch } from 'vue';
import { useBoolean, useLoading } from '@sa/hooks';
import { fetchGetTask, fetchTaskOperate, fetchTerminateTask } from '@/service/api/workflow/task';
import ReduceSignatureModal from './reduce-signature-modal.vue';
defineOptions({
name: 'FlowInterveneModal'
});
const { loading, startLoading, endLoading } = useLoading();
const { bool: addSignatureVisible, setTrue: openAddSignatureModal } = useBoolean();
const { bool: transferVisible, setTrue: openTransferModal } = useBoolean();
const { bool: reduceSignatureVisible, setTrue: openReduceSignatureModal } = useBoolean();
interface Props {
taskId: CommonType.IdType;
assigneeIds: CommonType.IdType[];
assigneeNames: string[];
}
const props = defineProps<Props>();
interface Emits {
(e: 'refresh'): void;
}
const emit = defineEmits<Emits>();
const taskInfo = ref<Api.Workflow.Task>();
const isWaiting = computed(() => taskInfo.value?.flowStatus === 'waiting');
// 流程签署比例值 大于0为票签会签
const isTicketOrSignInstance = computed(() => Number(taskInfo.value?.nodeRatio) > 0);
const visible = defineModel<boolean>('visible', {
default: false
});
type Model = Api.Workflow.TaskOperateParams;
const model: Model = reactive(createDefaultModel());
function createDefaultModel(): Model {
return {
taskId: null,
userId: undefined,
userIds: undefined,
message: ''
};
}
type TerminateModel = Api.Workflow.TerminateTaskOperateParams;
const terminateModel: TerminateModel = reactive(createDefaultTerminateModel());
function createDefaultTerminateModel(): TerminateModel {
return {
taskId: null,
comment: ''
};
}
function handleTransferConfirm(ids: CommonType.IdType[]) {
model.userId = ids[0];
model.taskId = props.taskId;
window.$dialog?.warning({
title: '提示',
content: '是否确认转办?',
positiveText: '确认转办',
positiveButtonProps: {
type: 'primary'
},
negativeText: '取消',
onPositiveClick: async () => {
const { error } = await fetchTaskOperate(model, 'transferTask');
if (error) return;
window.$message?.success('转办成功');
visible.value = false;
emit('refresh');
}
});
}
function handleAddSignatureConfirm(ids: CommonType.IdType[]) {
model.userIds = ids;
window.$dialog?.warning({
title: '提示',
content: '是否确认加签?',
positiveText: '确认加签',
positiveButtonProps: {
type: 'primary'
},
negativeText: '取消',
onPositiveClick: async () => {
const { error } = await fetchTaskOperate(model, 'addSignature');
if (error) return;
window.$message?.success('加签成功');
visible.value = false;
emit('refresh');
}
});
}
function handleTerminate() {
terminateModel.taskId = props.taskId;
window.$dialog?.warning({
title: '提示',
content: '是否确认终止?',
positiveText: '确认',
positiveButtonProps: {
type: 'primary'
},
negativeText: '取消',
onPositiveClick: async () => {
const { error } = await fetchTerminateTask(terminateModel);
if (error) return;
window.$message?.success('终止成功');
visible.value = false;
emit('refresh');
}
});
}
function handleReduceSubmit() {
visible.value = false;
emit('refresh');
}
async function getTaskInfo() {
startLoading();
const { error, data } = await fetchGetTask(props.taskId);
if (error) return;
taskInfo.value = data;
endLoading();
}
watch(visible, () => {
if (visible.value) {
getTaskInfo();
}
});
</script>
<template>
<NModal
v-model:show="visible"
class="max-h-520px max-w-90% w-700px"
title="流程干预"
preset="card"
size="medium"
:native-scrollbar="false"
>
<NSpin :show="loading">
<NDescriptions
:title="`${taskInfo?.flowName} (${taskInfo?.flowCode})`"
label-placement="left"
:column="2"
size="small"
bordered
>
<NDescriptionsItem label="任务名称">
{{ taskInfo?.nodeName }}
</NDescriptionsItem>
<NDescriptionsItem label="节点编码">
{{ taskInfo?.nodeCode }}
</NDescriptionsItem>
<NDescriptionsItem label="开始时间">
{{ taskInfo?.createTime }}
</NDescriptionsItem>
<NDescriptionsItem label="流程实例ID">
{{ taskInfo?.instanceId }}
</NDescriptionsItem>
<NDescriptionsItem label="办理人">
<TagGroup :value="assigneeNames" />
</NDescriptionsItem>
<NDescriptionsItem label="版本号">
<NTag type="info" size="small">v{{ taskInfo?.version }}.0</NTag>
</NDescriptionsItem>
<NDescriptionsItem label="业务ID">
{{ taskInfo?.businessId }}
</NDescriptionsItem>
</NDescriptions>
</NSpin>
<template #footer>
<NSpace justify="end" :size="16">
<NButton v-if="isWaiting" :loading="loading" type="primary" @click="openTransferModal">转办</NButton>
<NButton
v-if="isWaiting && isTicketOrSignInstance"
:loading="loading"
type="primary"
@click="openAddSignatureModal"
>
加签
</NButton>
<NButton
v-if="isWaiting && isTicketOrSignInstance"
:loading="loading"
type="primary"
@click="openReduceSignatureModal"
>
减签
</NButton>
<NButton v-if="isWaiting" :loading="loading" type="error" @click="handleTerminate">终止</NButton>
</NSpace>
</template>
<!-- 转办用户选择器 -->
<UserSelectModal v-model:visible="transferVisible" :disabled-ids="assigneeIds" @confirm="handleTransferConfirm" />
<!-- 加签用户选择器 -->
<UserSelectModal
v-model:visible="addSignatureVisible"
multiple
:disabled-ids="assigneeIds"
@confirm="handleAddSignatureConfirm"
/>
<!-- 减签用户 -->
<ReduceSignatureModal
v-model:visible="reduceSignatureVisible"
:task="taskInfo!"
@reduce-submit="handleReduceSubmit"
/>
</NModal>
</template>

View File

@ -0,0 +1,30 @@
<script setup lang="ts">
import { stringify } from 'qs';
import { getToken } from '@/store/modules/auth/shared';
import { getServiceBaseURL } from '@/utils/service';
defineOptions({
name: 'FlowPreview'
});
interface Props {
instanceId: CommonType.IdType;
}
const props = defineProps<Props>();
const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y';
const { baseURL } = getServiceBaseURL(import.meta.env, isHttpProxy);
const urlParams = {
type: 'FlowChart',
id: props.instanceId,
Authorization: `Bearer ${getToken()}`,
clientid: import.meta.env.VITE_APP_CLIENT_ID || ''
};
const iframeUrl = `${baseURL}/warm-flow-ui/index.html?${stringify(urlParams)}`;
</script>
<template>
<div>
<iframe :src="iframeUrl" class="h-[450px] w-full" />
</div>
</template>

View File

@ -0,0 +1,496 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import type { UploadFileInfo } from 'naive-ui';
import { useBoolean, useLoading } from '@sa/hooks';
import { messageTypeOptions } from '@/constants/workflow';
import {
fetchCompleteTask,
fetchGetTask,
fetchGetkNextNode,
fetchTaskOperate,
fetchTerminateTask
} from '@/service/api/workflow';
import FileUpload from '@/components/custom/file-upload.vue';
import ReduceSignatureModal from './reduce-signature-modal.vue';
import BackTaskModal from './back-task-modal.vue';
defineOptions({
name: 'FlowTaskApprovalModal'
});
interface Props {
/** 任务id */
taskId: CommonType.IdType;
/** 任务变量 */
taskVariables: { [key: string]: any };
}
const props = defineProps<Props>();
interface Emits {
(e: 'finished'): void;
}
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const { loading: baseFormLoading, startLoading: startBaseFormLoading, endLoading: endBaseFormLoading } = useLoading();
const { loading: btnLoading, startLoading: startBtnLoading, endLoading: endBtnLoading } = useLoading();
const { bool: copyVisible, setTrue: openCopyModal } = useBoolean();
const { bool: assigneeVisible, setTrue: openAssigneeModal } = useBoolean();
const { bool: delegateVisible, setTrue: openDelegateModal, setFalse: closeDelegateModal } = useBoolean();
const { bool: transferVisible, setTrue: openTransferModal, setFalse: closeTransferModal } = useBoolean();
const { bool: addSignatureVisible, setTrue: openAddSignatureModal, setFalse: closeAddSignatureModal } = useBoolean();
const { bool: reduceSignatureVisible, setTrue: openReduceSignatureModal } = useBoolean();
const { bool: backVisible, setTrue: openBackModal } = useBoolean();
const title = defineModel<string>('title', {
default: '流程发起'
});
const accept = ref<string>('.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.pdf,.jpg,.jpeg,.png,.gif,.bmp,.webp');
type Model = Api.Workflow.CompleteTaskOperateParams;
const task = ref<Api.Workflow.Task>();
const isWaiting = computed(() => task.value?.flowStatus === 'waiting');
const model: Model = reactive(createDefaultModel());
function createDefaultModel(): Model {
return {
taskId: null,
fileId: null,
flowCopyList: [],
messageType: ['1'],
taskVariables: null,
variables: null,
assigneeMap: null
};
}
const fileList = ref<UploadFileInfo[]>([]);
// 抄送人
const selectCopyUserList = ref<Api.System.User[]>([]);
// 抄送人id
const selectCopyUserIds = ref<CommonType.IdType[]>([]);
// 下一节点列表
const nestNodeList = ref<Api.Workflow.FlowNode[]>([]);
const nickNameMap = ref<{ [key: string]: string }>({});
const assigneeSearchUserIds = ref<string | null>(null);
const selectAssigneeIds = ref<string[]>([]);
// 节点编码
const nodeCode = ref<string>('');
// 按钮权限
interface ButtonPerm {
pop: boolean;
trust: boolean;
transfer: boolean;
addSign: boolean;
subSign: boolean;
termination: boolean;
back: boolean;
copy: boolean;
}
const buttonPerm = reactive<ButtonPerm>(createDefaultButtonPerm());
function createDefaultButtonPerm(): ButtonPerm {
return {
pop: false,
trust: false,
transfer: false,
addSign: false,
subSign: false,
termination: false,
back: false,
copy: false
};
}
function initDefault() {
selectCopyUserList.value = [];
selectCopyUserIds.value = [];
nickNameMap.value = {};
assigneeSearchUserIds.value = null;
selectAssigneeIds.value = [];
nodeCode.value = '';
Object.assign(model, createDefaultModel());
Object.assign(buttonPerm, createDefaultButtonPerm());
}
async function getTask() {
startBtnLoading();
startBaseFormLoading();
const { error, data } = await fetchGetTask(props.taskId);
if (error) {
endBtnLoading();
endBaseFormLoading();
return;
}
task.value = data;
task.value.buttonList?.forEach(item => {
buttonPerm[item.code as keyof ButtonPerm] = item.show!;
});
endBtnLoading();
const { error: nextNodeError, data: nextNodeData } = await fetchGetkNextNode({
taskId: props.taskId,
taskVariables: props.taskVariables
});
endBaseFormLoading();
if (nextNodeError) {
return;
}
nestNodeList.value = nextNodeData;
}
async function handleSubmit() {
if (buttonPerm.pop && nestNodeList.value?.length) {
const hasEmptyAssignee = nestNodeList.value.some(e => !model.assigneeMap || !model.assigneeMap[e.nodeCode]);
if (hasEmptyAssignee) {
window.$message?.error('请选择审批人!');
return;
}
} else {
model.assigneeMap = {};
}
if (selectCopyUserList.value?.length) {
model.flowCopyList = selectCopyUserList.value.map(e => ({
userId: e.userId,
userName: e.nickName
}));
}
if (fileList.value?.length) {
const fileIds = fileList.value.map(item => item.id);
model.fileId = fileIds.join(',');
}
model.taskId = props.taskId;
model.variables = props.taskVariables;
startBtnLoading();
startBaseFormLoading();
try {
const { error } = await fetchCompleteTask(model);
if (error) return;
window.$message?.success('提交成功');
visible.value = false;
emit('finished');
} catch (error) {
window.$message?.error(`提交失败,请稍后重试,${error}`);
} finally {
endBtnLoading();
endBaseFormLoading();
}
}
function handleCopyConfirm(userIds: CommonType.IdType[], users?: Api.System.User[]) {
selectCopyUserList.value = users || [];
selectCopyUserIds.value = userIds;
}
function handleAssigneeOpen(item: Api.Workflow.FlowNode) {
if (!item.permissionFlag) {
window.$message?.error('没有可选人员,请联系管理员!');
return;
}
assigneeSearchUserIds.value = item.permissionFlag;
nodeCode.value = item.nodeCode;
selectAssigneeIds.value = model.assigneeMap?.[item.nodeCode]?.split(',') || [];
openAssigneeModal();
}
function handleAssigneeConfirm(userIds: CommonType.IdType[], users?: Api.System.User[]) {
// 更新当前节点的审批人
if (!model.assigneeMap) model.assigneeMap = {};
model.assigneeMap[nodeCode.value] = userIds.join(',');
nickNameMap.value[nodeCode.value] = users?.map(item => item.nickName).join(',') || '';
}
function handleCopyTagClose(index?: number) {
if (index !== undefined) {
// 删除指定索引的用户
selectCopyUserIds.value = selectCopyUserIds.value.filter((_, i) => i !== index);
selectCopyUserList.value = selectCopyUserList.value.filter((_, i) => i !== index);
} else {
// 清空所有用户
selectCopyUserList.value = [];
selectCopyUserIds.value = [];
model.flowCopyList = [];
}
}
function handleAssigneeTagClose(code: string, index?: number) {
if (!model.assigneeMap?.[code]) return;
// 获取当前节点的用户ID列表和名称列表
const userIds = model.assigneeMap[code].split(',');
const nickNames = nickNameMap.value[code]?.split(',') || [];
if (index !== undefined) {
// 删除指定索引的用户
// 使用filter方式移除指定索引的元素
const newUserIds = userIds.filter((_, i) => i !== index);
const newNickNames = nickNames.filter((_, i) => i !== index);
// 更新数据
model.assigneeMap[code] = newUserIds.join(',');
nickNameMap.value[code] = newNickNames.join(',');
} else {
// 清空所有用户
model.assigneeMap[code] = '';
nickNameMap.value[code] = '';
}
}
interface TaskOperationOptions {
userIds: CommonType.IdType[];
operation: 'delegateTask' | 'transferTask' | 'addSignature';
confirmText: string;
successMessage: string;
closeModal: () => void;
}
function handleTaskOperationConfirm(options: TaskOperationOptions) {
const { userIds, operation, confirmText, successMessage, closeModal } = options;
const taskModel = {
taskId: props.taskId,
userId: userIds[0],
message: model.message
};
window.$dialog?.warning({
title: '提示',
content: `是否确认${confirmText}?`,
positiveText: `确认${confirmText}`,
positiveButtonProps: {
type: 'primary'
},
negativeText: '取消',
onPositiveClick: async () => {
startBtnLoading();
startBaseFormLoading();
const { error } = await fetchTaskOperate(taskModel, operation);
endBtnLoading();
endBaseFormLoading();
if (error) return;
window.$message?.success(successMessage);
closeModal();
visible.value = false;
emit('finished');
}
});
}
// 委托
function handleDelegateConfirm(userIds: CommonType.IdType[]) {
handleTaskOperationConfirm({
userIds,
operation: 'delegateTask',
confirmText: '委托',
successMessage: '委托成功',
closeModal: closeDelegateModal
});
}
// 转办
function handleTransferConfirm(userIds: CommonType.IdType[]) {
handleTaskOperationConfirm({
userIds,
operation: 'transferTask',
confirmText: '转办',
successMessage: '转办成功',
closeModal: closeTransferModal
});
}
// 加签
function handleAddSignatureConfirm(userIds: CommonType.IdType[]) {
handleTaskOperationConfirm({
userIds,
operation: 'addSignature',
confirmText: '加签',
successMessage: '加签成功',
closeModal: closeAddSignatureModal
});
}
// 减签
function handleReduceSubmit() {
visible.value = false;
emit('finished');
}
// 终止
function handleTerminate() {
const terminateModel = {
taskId: props.taskId,
comment: model.message
};
window.$dialog?.warning({
title: '提示',
content: '是否确认终止?',
positiveText: '确认',
positiveButtonProps: {
type: 'primary'
},
negativeText: '取消',
onPositiveClick: async () => {
startBtnLoading();
startBaseFormLoading();
const { error } = await fetchTerminateTask(terminateModel);
endBtnLoading();
endBaseFormLoading();
if (error) return;
window.$message?.success('终止成功');
visible.value = false;
emit('finished');
}
});
}
function handleBackSubmit() {
visible.value = false;
emit('finished');
}
watch(visible, () => {
if (visible.value) {
initDefault();
getTask();
}
});
</script>
<template>
<NModal v-model:show="visible" preset="card" class="w-800px" :title="title" :native-scrollbar="false" closable>
<NSpin :show="baseFormLoading">
<NForm :model="model" label-placement="left" :label-width="100">
<NFormItem label="通知方式" path="messageType">
<NCheckboxGroup v-model:value="model.messageType">
<NSpace item-style="display: flex;">
<NCheckbox
v-for="item in messageTypeOptions"
:key="item.value"
:disabled="item.value === '1'"
:value="item.value"
:label="item.label"
/>
</NSpace>
</NCheckboxGroup>
</NFormItem>
<NFormItem label="附件" path="fileId">
<FileUpload v-model:file-list="fileList" :file-size="20" :max="20" upload-type="file" :accept="accept" />
</NFormItem>
<NFormItem v-if="buttonPerm.copy" label="抄送人员">
<NSpace>
<NButton ghost type="primary" @click="openCopyModal">选择抄送人员</NButton>
<TagGroup
size="large"
:value="selectCopyUserList.map(item => item.nickName)"
:closable="true"
@close="handleCopyTagClose"
/>
</NSpace>
</NFormItem>
<NFormItem
v-if="buttonPerm.pop && nestNodeList && nestNodeList.length > 0"
label="下一步审批人"
path="assigneeMap"
>
<NSpace>
<div v-for="(item, index) in nestNodeList" :key="index">
<span>{{ item.nodeName }}</span>
<NSpace>
<NButton ghost type="primary" @click="handleAssigneeOpen(item)">选择审批人员</NButton>
<NInput v-if="false" v-model:value="model.assigneeMap![item.nodeCode]" />
<TagGroup
size="large"
:value="nickNameMap[item.nodeCode]"
:closable="true"
@close="index => handleAssigneeTagClose(item.nodeCode, index)"
/>
</NSpace>
</div>
</NSpace>
</NFormItem>
<NFormItem v-if="isWaiting" label="审批意见" path="message">
<NInput v-model:value="model.message" type="textarea" />
</NFormItem>
</NForm>
</NSpin>
<template #footer>
<NSpace justify="end" :size="16">
<NButton @click="visible = false">{{ $t('common.cancel') }}</NButton>
<!-- 委托 -->
<NButton v-if="isWaiting && buttonPerm.trust" :loading="btnLoading" type="warning" @click="openDelegateModal">
委托
</NButton>
<!-- 转办 -->
<NButton
v-if="isWaiting && buttonPerm.transfer"
:loading="btnLoading"
type="warning"
@click="openTransferModal"
>
转办
</NButton>
<!-- 加签 -->
<NButton
v-if="isWaiting && buttonPerm.addSign && Number(task?.nodeRatio) > 0"
:loading="btnLoading"
type="warning"
@click="openAddSignatureModal"
>
加签
</NButton>
<!-- 减签 -->
<NButton
v-if="isWaiting && buttonPerm.subSign && Number(task?.nodeRatio) > 0"
:loading="btnLoading"
type="warning"
@click="openReduceSignatureModal"
>
减签
</NButton>
<!-- 终止 -->
<NButton v-if="isWaiting && buttonPerm.termination" :loading="btnLoading" type="error" @click="handleTerminate">
终止
</NButton>
<!-- 驳回 -->
<NButton v-if="isWaiting && buttonPerm.back" :loading="btnLoading" type="error" @click="openBackModal">
驳回
</NButton>
<NButton :loading="btnLoading" type="primary" @click="handleSubmit">提交</NButton>
</NSpace>
</template>
<!-- 抄送人员选择 -->
<UserSelectModal
v-model:visible="copyVisible"
:row-keys="selectCopyUserIds"
multiple
@confirm="handleCopyConfirm"
/>
<!-- 下一步审批人员选择 -->
<UserSelectModal
v-model:visible="assigneeVisible"
:row-keys="selectAssigneeIds"
:search-user-ids="assigneeSearchUserIds"
multiple
@confirm="handleAssigneeConfirm"
/>
<!-- 转办 -->
<UserSelectModal v-model:visible="transferVisible" @confirm="handleTransferConfirm" />
<!-- 委托 -->
<UserSelectModal v-model:visible="delegateVisible" @confirm="handleDelegateConfirm" />
<!-- 加签 -->
<UserSelectModal v-model:visible="addSignatureVisible" @confirm="handleAddSignatureConfirm" />
<!-- 减签 -->
<ReduceSignatureModal v-model:visible="reduceSignatureVisible" :task="task!" @reduce-submit="handleReduceSubmit" />
<!-- 驳回 -->
<BackTaskModal v-model:visible="backVisible" :task="task!" @submit="handleBackSubmit" />
</NModal>
</template>

View File

@ -0,0 +1,95 @@
<script lang="ts" setup>
import { reactive, watch } from 'vue';
import { messageTypeOptions } from '@/constants/workflow';
import { fetchTaskUrge } from '@/service/api/workflow';
import { useFormRules, useNaiveForm } from '@/hooks/common/form';
defineOptions({
name: 'FlowUrgeModal'
});
const { createRequiredRule } = useFormRules();
const { formRef, validate } = useNaiveForm();
interface Props {
taskIds: CommonType.IdType[];
}
const props = defineProps<Props>();
interface Emits {
(e: 'submit'): void;
}
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
type Model = Api.Workflow.TaskUrgeOperateParams;
const model = reactive(createDefaultModel());
function createDefaultModel(): Model {
return {
taskIdList: props.taskIds,
messageType: ['1'],
message: ''
};
}
type RuleKey = Extract<keyof Model, 'taskIdList' | 'messageType' | 'message'>;
const rules: Record<RuleKey, App.Global.FormRule> = {
taskIdList: createRequiredRule('任务ID不能为空'),
messageType: createRequiredRule('消息提醒不能为空'),
message: createRequiredRule('消息内容不能为空')
};
function closeDrawer() {
visible.value = false;
}
watch(visible, () => {
if (visible.value) {
Object.assign(model, createDefaultModel());
}
});
async function handleSubmit() {
await validate();
const { error } = await fetchTaskUrge(model);
if (error) return;
window.$message?.success('催办成功');
closeDrawer();
emit('submit');
}
</script>
<template>
<NModal v-model:show="visible" preset="card" class="w-800px" title="催办" :native-scrollbar="false" closable>
<NForm ref="formRef" :model="model" :rules="rules">
<NFormItem label="消息提醒" path="messageType">
<NCheckboxGroup v-model:value="model.messageType">
<NSpace item-style="display: flex;">
<NCheckbox
v-for="item in messageTypeOptions"
:key="item.value"
:disabled="item.value === '1'"
:value="item.value"
:label="item.label"
/>
</NSpace>
</NCheckboxGroup>
</NFormItem>
<NFormItem label="消息呢用" path="message">
<NInput v-model:value="model.message" type="textarea" />
</NFormItem>
</NForm>
<template #footer>
<NSpace justify="end" :size="16">
<NButton @click="closeDrawer">{{ $t('common.cancel') }}</NButton>
<NButton type="primary" @click="handleSubmit">{{ $t('common.confirm') }}</NButton>
</NSpace>
</template>
</NModal>
</template>

View File

@ -0,0 +1,317 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import dayjs from 'dayjs';
import { useBoolean, useLoading } from '@sa/hooks';
import { flowCodeTypeOptions, flowCodeTypeRecord, leaveTypeOptions, leaveTypeRecord } from '@/constants/workflow';
import { fetchCreateLeave, fetchGetLeaveDetail, fetchStartWorkflow, fetchUpdateLeave } from '@/service/api/workflow';
import { useFormRules, useNaiveForm } from '@/hooks/common/form';
import { useDict } from '@/hooks/business/dict';
import { $t } from '@/locales';
import ApprovalInfoPanel from '@/components/workflow/approval-info-panel.vue';
import FlowTaskApprovalModal from '@/components/workflow/flow-task-approval-modal.vue';
import FlowDrawer from '@/components/workflow/flow-drawer.vue';
defineOptions({
name: 'LeaveEdit'
});
useDict('wf_task_status');
interface Props {
operateType: CommonType.WorkflowTableOperateType;
/** 业务ID */
businessId?: CommonType.IdType;
taskId?: CommonType.IdType;
/** the edit row data */
rowData?: Api.Workflow.Leave | null;
}
const props = withDefaults(defineProps<Props>(), {
rowData: null,
businessId: undefined,
taskId: undefined
});
interface Emits {
(e: 'submitted'): void;
}
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const approvalInfoPanelRef = ref<InstanceType<typeof ApprovalInfoPanel>>();
const { bool: taskApplyVisible, setTrue: setTaskApplyVisible } = useBoolean();
const { loading, startLoading, endLoading } = useLoading();
const { formRef, validate, restoreValidation } = useNaiveForm();
const { createRequiredRule } = useFormRules();
const title = computed(() => {
const titles: Record<CommonType.WorkflowTableOperateType, string> = {
add: '新增请假申请',
edit: '编辑请假申请',
detail: '查看请假申请',
approval: '审批请假申请'
};
return titles[props.operateType];
});
const readonly = computed(() => {
return props.operateType === 'detail' || props.operateType === 'approval';
});
const taskId = ref<CommonType.IdType>(props.taskId!);
const respLeave = ref<Api.Workflow.Leave>();
const startWorkflowResult = ref<Api.Workflow.StartWorkflowResult>();
type Model = Api.Workflow.LeaveOperateParams & {
flowCode: Api.Workflow.FlowCodeType;
};
const model: Model = reactive(createDefaultModel());
function createDefaultModel(): Model {
return {
flowCode: 'leave1',
leaveType: null,
startDate: null,
endDate: null,
leaveDays: null,
remark: ''
};
}
type ModelDetail = Api.Workflow.LeaveDetail & {
flowCode: Api.Workflow.FlowCodeType;
};
const modelDetail: ModelDetail = reactive(createDefaultModelDetail());
function createDefaultModelDetail(): ModelDetail {
return {
flowCode: 'leave1',
status: null,
id: null,
leaveType: null,
startDate: null,
endDate: null,
leaveDays: null,
remark: ''
};
}
const showApprovalInfoPanel = computed(() => {
return modelDetail.status !== 'draft';
});
type StartWorkflowModel = Api.Workflow.StartWorkflowOperateParams;
const startWorkflowModel: StartWorkflowModel = reactive(createDefaultStartWorkflowModel());
function createDefaultStartWorkflowModel(): StartWorkflowModel {
return {
flowCode: null,
businessId: null,
flowInstanceBizExtBo: null,
variables: {}
};
}
const dateRange = computed<[string, string] | null>({
get: () => {
if (!model.startDate || !model.endDate) return null;
return [model.startDate, model.endDate] as [string, string];
},
set: (value: [string, string] | null) => {
if (value) {
model.startDate = value[0];
model.endDate = value[1];
// 计算请假天数
const start = dayjs(value[0]);
const end = dayjs(value[1]);
model.leaveDays = end.diff(start, 'day') + 1;
} else {
model.startDate = null;
model.endDate = null;
model.leaveDays = null;
}
}
});
type RuleKey = Extract<keyof Model, 'id' | 'leaveType' | 'leaveDays' | 'startDate' | 'endDate'> | 'flowCode';
const rules: Record<RuleKey, App.Global.FormRule> = {
id: createRequiredRule('id不能为空'),
flowCode: createRequiredRule('流程类型不能为空'),
leaveType: createRequiredRule('请假类型不能为空'),
startDate: createRequiredRule('请假时间不能为空'),
endDate: createRequiredRule('结束时间不能为空'),
leaveDays: createRequiredRule('请假天数不能为空')
};
async function handleUpdateModelWhenEdit() {
if (props.operateType === 'add') {
Object.assign(model, createDefaultModel());
return;
}
if (props.rowData) {
Object.assign(model, props.rowData);
Object.assign(modelDetail, props.rowData);
} else {
const { error, data } = await fetchGetLeaveDetail(props.businessId!);
if (error) {
window.$message?.error(error.message);
return;
}
Object.assign(model, data);
Object.assign(modelDetail, data);
}
}
function closeDrawer() {
visible.value = false;
}
async function handleOperate() {
await validate();
// request
if (props.operateType === 'add') {
const { leaveType, startDate, endDate, leaveDays, remark } = model;
const { error, data } = await fetchCreateLeave({ leaveType, startDate, endDate, leaveDays, remark });
if (error) return;
respLeave.value = data;
}
if (props.operateType === 'edit') {
const { id, leaveType, startDate, endDate, leaveDays, remark } = model;
const { error, data } = await fetchUpdateLeave({ id, leaveType, startDate, endDate, leaveDays, remark });
if (error) return;
respLeave.value = data;
}
}
async function handleSaveDraft() {
await handleOperate();
window.$message?.success($t('common.updateSuccess'));
closeDrawer();
emit('submitted');
}
const taskVariables = ref<{ [key: string]: any }>({});
async function handleSubmit() {
await handleOperate();
window.$message?.success($t('common.updateSuccess'));
// 提交流程
startWorkflowModel.businessId = respLeave.value?.id;
startWorkflowModel.flowCode = model.flowCode;
startWorkflowModel.flowInstanceBizExtBo = {
businessCode: respLeave.value?.applyCode,
businessTitle: '请假申请'
};
taskVariables.value = {
leaveDays: respLeave.value?.leaveDays,
userList: ['1', '3', '4']
};
startWorkflowModel.variables = taskVariables.value;
const { error, data } = await fetchStartWorkflow(startWorkflowModel);
if (error) return;
startWorkflowResult.value = data;
taskId.value = data.taskId!;
setTaskApplyVisible();
}
function handleTaskFinished() {
closeDrawer();
emit('submitted');
}
function handleApproval() {
setTaskApplyVisible();
}
async function initializeData() {
if (visible.value) {
startLoading();
await handleUpdateModelWhenEdit();
restoreValidation();
if (showApprovalInfoPanel.value) {
approvalInfoPanelRef.value?.initData();
}
endLoading();
}
}
watch(visible, initializeData, { immediate: true });
</script>
<template>
<FlowDrawer
v-model:visible="visible"
:title="title"
:loading="loading"
:operate-type="operateType"
:status="modelDetail.status"
@close="closeDrawer"
@save-draft="handleSaveDraft"
@submit="handleSubmit"
@approval="handleApproval"
>
<div :class="loading ? 'hidden' : ''">
<div v-if="!readonly" class="h-full">
<NForm ref="formRef" :model="model" :rules="rules">
<NFormItem label="流程类型" path="flowCode">
<NSelect v-model:value="model.flowCode" placeholder="请输入流程类型" :options="flowCodeTypeOptions" />
</NFormItem>
<NFormItem label="请假类型" path="leaveType">
<NSelect v-model:value="model.leaveType" placeholder="请输入请假类型" :options="leaveTypeOptions" />
</NFormItem>
<NFormItem label="请假时间" path="startDate">
<NDatePicker
v-model:formatted-value="dateRange"
class="w-full"
type="datetimerange"
format="yyyy-MM-dd HH:mm:ss"
clearable
/>
</NFormItem>
<NFormItem label="请假天数" path="leaveDays">
<NInputNumber v-model:value="model.leaveDays" class="w-full" disabled placeholder="请输入请假天数" />
</NFormItem>
<NFormItem label="请假原因" path="remark">
<NInput v-model:value="model.remark" placeholder="请输入请假原因" />
</NFormItem>
</NForm>
</div>
<div v-else>
<NDescriptions size="small" bordered :column="2" label-placement="left">
<NDescriptionsItem label="流程类型">
{{ flowCodeTypeRecord[modelDetail.flowCode] }}
</NDescriptionsItem>
<NDescriptionsItem label="请假类型">
<NTag type="info">{{ leaveTypeRecord[modelDetail.leaveType!] }}</NTag>
</NDescriptionsItem>
<NDescriptionsItem label="请假时间">
{{ `${modelDetail.startDate}${modelDetail.endDate}` }}
</NDescriptionsItem>
<NDescriptionsItem label="请假天数">{{ modelDetail.leaveDays }} </NDescriptionsItem>
<NDescriptionsItem label="请假原因">
{{ modelDetail.remark || '-' }}
</NDescriptionsItem>
</NDescriptions>
<!-- 审批信息 -->
<ApprovalInfoPanel v-if="showApprovalInfoPanel" ref="approvalInfoPanelRef" :business-id="modelDetail.id!" />
</div>
</div>
</FlowDrawer>
<FlowTaskApprovalModal
v-model:visible="taskApplyVisible"
:task-id="taskId"
:task-variables="taskVariables"
@finished="handleTaskFinished"
/>
</template>
<style scoped></style>

View File

@ -0,0 +1,154 @@
<script lang="tsx" setup>
import { reactive, ref, watch } from 'vue';
import { useLoading } from '@sa/hooks';
import { fetchGetCurrentTaskAllUser, fetchTaskOperate } from '@/service/api/workflow/task';
import { $t } from '@/locales';
import ButtonIcon from '@/components/custom/button-icon.vue';
defineOptions({
name: 'ReduceSignatureModal'
});
interface Props {
task: Api.Workflow.Task;
}
const props = defineProps<Props>();
const visible = defineModel<boolean>('visible', {
default: false
});
interface Emits {
(e: 'reduceSubmit'): void;
}
const emit = defineEmits<Emits>();
const { loading, startLoading, endLoading } = useLoading();
type UserTaskModel = Api.System.User & { nodeName: string };
const userData = ref<UserTaskModel[]>([]);
type Model = Api.Workflow.TaskOperateParams;
const model: Model = reactive(createDefaultModel());
const checkedRowKeys = ref<CommonType.IdType[]>([]);
function createDefaultModel(): Model {
return {
taskId: null,
userId: undefined,
userIds: undefined,
message: ''
};
}
const columns = ref<NaiveUI.TableColumn<UserTaskModel>[]>([
{
type: 'selection',
align: 'center',
width: 50
},
{
title: '节点名称',
key: 'nodeName',
align: 'center',
minWidth: 120
},
{
title: '办理人员',
key: 'nickName',
align: 'center',
minWidth: 120
},
{
key: 'operate',
title: $t('common.operate'),
align: 'center',
width: 130,
render(row) {
return (
<ButtonIcon
text
type="error"
icon="material-symbols:delete-outline"
tooltipContent={'减签'}
popconfirmContent={'是否确认减签?'}
onPositiveClick={() => handleReduceSignature([row.userId])}
/>
);
}
}
]);
async function handleReduceSignature(userIds: CommonType.IdType[]) {
model.taskId = props.task.id;
model.userIds = userIds;
const { error } = await fetchTaskOperate(model, 'reductionSignature');
if (error) return;
window.$message?.success('减签成功');
handleCloseDrawer();
}
async function getTaskAllUser() {
startLoading();
const { error, data } = await fetchGetCurrentTaskAllUser(props.task.id);
if (error) return;
userData.value = data.map(item => ({
...item,
nodeName: props.task.nodeName
}));
endLoading();
}
function handleCloseDrawer() {
visible.value = false;
emit('reduceSubmit');
}
watch(visible, async () => {
if (visible.value) {
await getTaskAllUser();
}
});
</script>
<template>
<NModal v-model:show="visible" class="w-700px" preset="card" title="待减签人员">
<NCard class="h-full card-wrapper">
<NSpace wrap justify="space-between" class="mb-16px lt-sm:w-200px">
<TableRowCheckAlert v-model:checked-row-keys="checkedRowKeys" />
<NButton
size="small"
ghost
type="error"
:disabled="checkedRowKeys.length === 0"
@click="handleReduceSignature(checkedRowKeys)"
>
<template #icon>
<icon-material-symbols:delete-outline class="text-icon" />
</template>
批量减签
</NButton>
</NSpace>
<NDataTable
v-model:checked-row-keys="checkedRowKeys"
class="h-400px"
flex-height
:row-key="row => row.userId"
size="small"
:columns="columns"
:data="userData"
:loading="loading"
/>
</NCard>
</NModal>
</template>
<style scoped lang="scss">
.n-alert {
--n-padding: 5px 13px !important;
--n-icon-margin: 6px 8px 0 12px !important;
--n-icon-size: 20px !important;
}
</style>

View File

@ -5,9 +5,9 @@ export const GLOBAL_HEADER_MENU_ID = '__GLOBAL_HEADER_MENU__';
export const GLOBAL_SIDER_MENU_ID = '__GLOBAL_SIDER_MENU__';
export const themeSchemaRecord: Record<UnionKey.ThemeScheme, App.I18n.I18nKey> = {
light: 'theme.appearance.themeSchema.light',
dark: 'theme.appearance.themeSchema.dark',
auto: 'theme.appearance.themeSchema.auto'
light: 'theme.themeSchema.light',
dark: 'theme.themeSchema.dark',
auto: 'theme.themeSchema.auto'
};
export const themeSchemaOptions = transformRecordToOption(themeSchemaRecord);
@ -21,61 +21,49 @@ export const loginModuleRecord: Record<UnionKey.LoginModule, App.I18n.I18nKey> =
};
export const themeLayoutModeRecord: Record<UnionKey.ThemeLayoutMode, App.I18n.I18nKey> = {
vertical: 'theme.layout.layoutMode.vertical',
'vertical-mix': 'theme.layout.layoutMode.vertical-mix',
'vertical-hybrid-header-first': 'theme.layout.layoutMode.vertical-hybrid-header-first',
horizontal: 'theme.layout.layoutMode.horizontal',
'top-hybrid-sidebar-first': 'theme.layout.layoutMode.top-hybrid-sidebar-first',
'top-hybrid-header-first': 'theme.layout.layoutMode.top-hybrid-header-first'
vertical: 'theme.layoutMode.vertical',
'vertical-mix': 'theme.layoutMode.vertical-mix',
horizontal: 'theme.layoutMode.horizontal',
'horizontal-mix': 'theme.layoutMode.horizontal-mix'
};
export const themeLayoutModeOptions = transformRecordToOption(themeLayoutModeRecord);
export const themeScrollModeRecord: Record<UnionKey.ThemeScrollMode, App.I18n.I18nKey> = {
wrapper: 'theme.layout.content.scrollMode.wrapper',
content: 'theme.layout.content.scrollMode.content'
wrapper: 'theme.scrollMode.wrapper',
content: 'theme.scrollMode.content'
};
export const themeScrollModeOptions = transformRecordToOption(themeScrollModeRecord);
export const themeTabModeRecord: Record<UnionKey.ThemeTabMode, App.I18n.I18nKey> = {
chrome: 'theme.layout.tab.mode.chrome',
button: 'theme.layout.tab.mode.button'
chrome: 'theme.tab.mode.chrome',
button: 'theme.tab.mode.button'
};
export const themeTabModeOptions = transformRecordToOption(themeTabModeRecord);
export const themePageAnimationModeRecord: Record<UnionKey.ThemePageAnimateMode, App.I18n.I18nKey> = {
'fade-slide': 'theme.layout.content.page.mode.fade-slide',
fade: 'theme.layout.content.page.mode.fade',
'fade-bottom': 'theme.layout.content.page.mode.fade-bottom',
'fade-scale': 'theme.layout.content.page.mode.fade-scale',
'zoom-fade': 'theme.layout.content.page.mode.zoom-fade',
'zoom-out': 'theme.layout.content.page.mode.zoom-out',
none: 'theme.layout.content.page.mode.none'
'fade-slide': 'theme.page.mode.fade-slide',
fade: 'theme.page.mode.fade',
'fade-bottom': 'theme.page.mode.fade-bottom',
'fade-scale': 'theme.page.mode.fade-scale',
'zoom-fade': 'theme.page.mode.zoom-fade',
'zoom-out': 'theme.page.mode.zoom-out',
none: 'theme.page.mode.none'
};
export const themePageAnimationModeOptions = transformRecordToOption(themePageAnimationModeRecord);
export const resetCacheStrategyRecord: Record<UnionKey.ResetCacheStrategy, App.I18n.I18nKey> = {
refresh: 'theme.layout.resetCacheStrategy.refresh',
close: 'theme.layout.resetCacheStrategy.close'
close: 'theme.resetCacheStrategy.close',
refresh: 'theme.resetCacheStrategy.refresh'
};
export const resetCacheStrategyOptions = transformRecordToOption(resetCacheStrategyRecord);
export const DARK_CLASS = 'dark';
export const watermarkTimeFormatOptions = [
{ label: 'YYYY-MM-DD HH:mm', value: 'YYYY-MM-DD HH:mm' },
{ label: 'YYYY-MM-DD HH:mm:ss', value: 'YYYY-MM-DD HH:mm:ss' },
{ label: 'YYYY/MM/DD HH:mm', value: 'YYYY/MM/DD HH:mm' },
{ label: 'YYYY/MM/DD HH:mm:ss', value: 'YYYY/MM/DD HH:mm:ss' },
{ label: 'HH:mm', value: 'HH:mm' },
{ label: 'HH:mm:ss', value: 'HH:mm:ss' },
{ label: 'MM-DD HH:mm', value: 'MM-DD HH:mm' }
];
export const themeTableSizeRecord: Record<UnionKey.ThemeTableSize, App.I18n.I18nKey> = {
small: 'theme.table.size.small',
medium: 'theme.table.size.medium',

90
src/constants/workflow.ts Normal file
View File

@ -0,0 +1,90 @@
import { transformRecordToOption } from '@/utils/common';
export const cooperateTypeRecord: Record<Api.Workflow.CooperateType, string> = {
1: '审批',
2: '转办',
3: '委派',
4: '会签',
5: '票签',
6: '加签',
7: '减签'
};
export const cooperateTypeOptions = transformRecordToOption(cooperateTypeRecord);
export const businessStatusRecord: Record<Api.Workflow.BusinessStatus, string> = {
cancel: '已撤销',
draft: '草稿',
waiting: '待审批',
finish: '已完成',
invalid: '已作废',
back: '已退回',
termination: '已终止'
};
export const businessStatusOptions = transformRecordToOption(businessStatusRecord);
export const messageTypeRecord: Record<Api.Workflow.MessageType, string> = {
'1': '站内信',
'2': '邮件',
'3': '短信'
};
export const messageTypeOptions = transformRecordToOption(messageTypeRecord);
export const flowCodeTypeRecord: Record<Api.Workflow.FlowCodeType, string> = {
leave1: '请假申请-普通',
leave2: '请假申请-排他网关',
leave3: '请假申请-并行网关',
leave4: '请假申请-会签',
leave5: '请假申请-并行会签网关',
leave6: '请假申请-排他并行会签'
};
export const flowCodeTypeOptions = transformRecordToOption(flowCodeTypeRecord);
/** leave type */
export const leaveTypeRecord: Record<Api.Workflow.LeaveType, string> = {
'1': '事假',
'2': '调休',
'3': '病假',
'4': '婚假'
};
export const leaveTypeOptions = transformRecordToOption(leaveTypeRecord);
/** workflow publish status */
export const workflowPublishStatusRecord: Record<Api.Workflow.WorkflowPublishStatus, string> = {
'0': '未发布',
'1': '已发布',
'9': '失效'
};
export const workflowPublishStatusOptions = transformRecordToOption(workflowPublishStatusRecord);
/** node type */
export const workflowNodeTypeRecord: Record<Api.Workflow.WorkflowNodeType, string> = {
0: '开始节点',
1: '中间节点',
2: '结束节点',
3: '互斥网关',
4: '并行网关'
};
export const workflowNodeTypeOptions = transformRecordToOption(workflowNodeTypeRecord);
/** definition designer mode */
export const definitionDesignerModeRecord: Record<Api.Workflow.DefinitionDesignerMode, string> = {
CLASSICS: '经典模式',
MIMIC: '仿钉钉模式'
};
export const definitionDesignerModeOptions = transformRecordToOption(definitionDesignerModeRecord);
/** activity status */
export const workflowActivityStatusRecord: Record<Api.Workflow.WorkflowActivityStatus, string> = {
0: '挂起',
1: '激活'
};
export const workflowActivityStatusOptions = transformRecordToOption(workflowActivityStatusRecord);

View File

@ -4,6 +4,5 @@ export enum SetupStoreId {
Auth = 'auth-store',
Route = 'route-store',
Tab = 'tab-store',
Notice = 'notice-store',
Dict = 'dict-store'
Notice = 'notice-store'
}

View File

@ -1,4 +1,4 @@
import { computed, effectScope, nextTick, onScopeDispose, shallowRef, watch } from 'vue';
import { computed, effectScope, nextTick, onScopeDispose, ref, watch } from 'vue';
import { useElementSize } from '@vueuse/core';
import * as echarts from 'echarts/core';
import { BarChart, GaugeChart, LineChart, PictorialBarChart, PieChart, RadarChart, ScatterChart } from 'echarts/charts';
@ -86,11 +86,11 @@ export function useEcharts<T extends ECOption>(optionsFactory: () => T, hooks: C
const themeStore = useThemeStore();
const darkMode = computed(() => themeStore.darkMode);
const domRef = shallowRef<HTMLElement | null>(null);
const domRef = ref<HTMLElement | null>(null);
const initialSize = { width: 0, height: 0 };
const { width, height } = useElementSize(domRef, initialSize);
const chart = shallowRef<echarts.ECharts | null>(null);
let chart: echarts.ECharts | null = null;
const chartOptions: T = optionsFactory();
const {
@ -111,9 +111,18 @@ export function useEcharts<T extends ECOption>(optionsFactory: () => T, hooks: C
onDestroy
} = hooks;
/**
* whether can render chart
*
* when domRef is ready and initialSize is valid
*/
function canRender() {
return domRef.value && initialSize.width > 0 && initialSize.height > 0;
}
/** is chart rendered */
function isRendered() {
return Boolean(domRef.value && chart.value);
return Boolean(domRef.value && chart);
}
/**
@ -122,59 +131,59 @@ export function useEcharts<T extends ECOption>(optionsFactory: () => T, hooks: C
* @param callback callback function
*/
async function updateOptions(callback: (opts: T, optsFactory: () => T) => ECOption = () => chartOptions) {
if (!isRendered()) return;
const updatedOpts = callback(chartOptions, optionsFactory);
Object.assign(chartOptions, updatedOpts);
await nextTick();
if (!isRendered()) return;
if (isRendered()) {
chart.value?.clear();
chart?.clear();
}
chart.value?.setOption({ ...updatedOpts, backgroundColor: 'transparent' });
chart?.setOption({ ...updatedOpts, backgroundColor: 'transparent' });
await onUpdated?.(chart.value!);
await onUpdated?.(chart!);
}
function setOptions(options: T) {
chart.value?.setOption(options);
chart?.setOption(options);
}
/** render chart */
async function render() {
if (isRendered()) return;
if (!isRendered()) {
const chartTheme = darkMode.value ? 'dark' : 'light';
const chartTheme = darkMode.value ? 'dark' : 'light';
await nextTick();
chart.value = echarts.init(domRef.value, chartTheme);
chart = echarts.init(domRef.value, chartTheme);
chart.value?.setOption({ ...chartOptions, backgroundColor: 'transparent' });
chart.setOption({ ...chartOptions, backgroundColor: 'transparent' });
await onRender?.(chart.value!);
await onRender?.(chart);
}
}
/** resize chart */
function resize() {
chart.value?.resize();
chart?.resize();
}
/** destroy chart */
async function destroy() {
if (!chart.value) return;
if (!chart) return;
await onDestroy?.(chart.value);
chart.value?.dispose();
chart.value = null;
await onDestroy?.(chart);
chart?.dispose();
chart = null;
}
/** change chart theme */
async function changeTheme() {
await destroy();
await render();
await onUpdated?.(chart.value!);
await onUpdated?.(chart!);
}
/**
@ -187,29 +196,30 @@ export function useEcharts<T extends ECOption>(optionsFactory: () => T, hooks: C
initialSize.width = w;
initialSize.height = h;
// size is abnormal, destroy chart
if (!canRender()) {
await destroy();
return;
}
// resize chart
if (isRendered()) {
resize();
return;
}
// render chart
await render();
if (chart.value) {
await onUpdated?.(chart.value);
if (chart) {
await onUpdated?.(chart);
}
}
scope.run(() => {
watch(
[width, height],
([newWidth, newHeight]) => {
renderChartBySize(newWidth, newHeight);
},
{ flush: 'post' }
);
watch([width, height], ([newWidth, newHeight]) => {
renderChartBySize(newWidth, newHeight);
});
watch(darkMode, () => {
changeTheme();
@ -223,7 +233,6 @@ export function useEcharts<T extends ECOption>(optionsFactory: () => T, hooks: C
return {
domRef,
chart,
updateOptions,
setOptions
};

View File

@ -1,312 +1,196 @@
import { computed, effectScope, onScopeDispose, reactive, ref, shallowRef, watch } from 'vue';
import { computed, effectScope, onScopeDispose, reactive, ref, watch } from 'vue';
import type { Ref } from 'vue';
import type { PaginationProps } from 'naive-ui';
import { useBoolean, useTable } from '@sa/hooks';
import type { PaginationData, TableColumnCheck, UseTableOptions } from '@sa/hooks';
import type { FlatResponseData } from '@sa/axios';
import { jsonClone } from '@sa/utils';
import { useBoolean, useHookTable } from '@sa/hooks';
import { useAppStore } from '@/store/modules/app';
import { $t } from '@/locales';
export type UseNaiveTableOptions<ResponseData, ApiData, Pagination extends boolean> = Omit<
UseTableOptions<ResponseData, ApiData, NaiveUI.TableColumn<ApiData>, Pagination>,
'pagination' | 'getColumnChecks' | 'getColumns'
> & {
/**
* get column visible
*
* @param column
*
* @default true
*
* @returns true if the column is visible, false otherwise
*/
getColumnVisible?: (column: NaiveUI.TableColumn<ApiData>) => boolean;
};
type TableData = NaiveUI.TableData;
type GetTableData<A extends NaiveUI.TableApiFn> = NaiveUI.GetTableData<A>;
type TableColumn<T> = NaiveUI.TableColumn<T>;
const SELECTION_KEY = '__selection__';
const EXPAND_KEY = '__expand__';
export function useNaiveTable<ResponseData, ApiData>(options: UseNaiveTableOptions<ResponseData, ApiData, false>) {
const scope = effectScope();
const appStore = useAppStore();
const result = useTable<ResponseData, ApiData, NaiveUI.TableColumn<ApiData>, false>({
...options,
getColumnChecks: cols => getColumnChecks(cols, options.getColumnVisible),
getColumns
});
// calculate the total width of the table this is used for horizontal scrolling
const scrollX = computed(() => {
return result.columns.value.reduce((acc, column) => {
return acc + Number(column.width ?? column.minWidth ?? 120);
}, 0);
});
scope.run(() => {
watch(
() => appStore.locale,
() => {
result.reloadColumns();
}
);
});
onScopeDispose(() => {
scope.stop();
});
return {
...result,
scrollX
};
}
type PaginationParams = Pick<PaginationProps, 'page' | 'pageSize'>;
type UseNaivePaginatedTableOptions<ResponseData, ApiData> = UseNaiveTableOptions<ResponseData, ApiData, true> & {
paginationProps?: Omit<PaginationProps, 'page' | 'pageSize' | 'itemCount'>;
/**
* whether to show the total count of the table
*
* @default true
*/
showTotal?: boolean;
onPaginationParamsChange?: (params: PaginationParams) => void | Promise<void>;
};
export function useNaivePaginatedTable<ResponseData, ApiData>(
options: UseNaivePaginatedTableOptions<ResponseData, ApiData>
) {
export function useTable<A extends NaiveUI.TableApiFn>(config: NaiveUI.NaiveTableConfig<A>) {
const scope = effectScope();
const appStore = useAppStore();
const isMobile = computed(() => appStore.isMobile);
const showTotal = computed(() => options.showTotal ?? true);
const { apiFn, apiParams, immediate, showTotal = true } = config;
const pagination = reactive({
const SELECTION_KEY = '__selection__';
const EXPAND_KEY = '__expand__';
const {
loading,
empty,
data,
columns,
columnChecks,
reloadColumns,
getData,
searchParams,
updateSearchParams,
resetSearchParams,
updateApiFn
} = useHookTable<A, GetTableData<A>, TableColumn<NaiveUI.TableDataWithIndex<GetTableData<A>>>>({
apiFn,
apiParams,
columns: config.columns,
transformer: res => {
const { rows: records = [], total = 0 } = res.data || {};
const current = searchParams.pageNum as number;
const size = (searchParams.pageSize || 0) as number;
// Ensure that the size is greater than 0, If it is less than 0, it will cause paging calculation errors.
const pageSize = size <= 0 ? 10 : size;
const recordsWithIndex = records.map((item, index) => {
return {
...item,
index: (current - 1) * pageSize + index + 1
};
});
return {
data: recordsWithIndex,
pageNum: current,
pageSize,
total
};
},
getColumnChecks: cols => {
const checks: NaiveUI.TableColumnCheck[] = [];
cols.forEach(column => {
if (isTableColumnHasKey(column)) {
checks.push({
key: column.key as string,
title: column.title!,
checked: true
});
} else if (column.type === 'selection') {
checks.push({
key: SELECTION_KEY,
title: $t('common.check'),
checked: true
});
} else if (column.type === 'expand') {
checks.push({
key: EXPAND_KEY,
title: $t('common.expandColumn'),
checked: true
});
}
});
return checks;
},
getColumns: (cols, checks) => {
const columnMap = new Map<string, TableColumn<GetTableData<A>>>();
cols.forEach(column => {
if (isTableColumnHasKey(column)) {
columnMap.set(column.key as string, column);
} else if (column.type === 'selection') {
columnMap.set(SELECTION_KEY, column);
} else if (column.type === 'expand') {
columnMap.set(EXPAND_KEY, column);
}
});
const filteredColumns = checks
.filter(item => item.checked)
.map(check => columnMap.get(check.key) as TableColumn<GetTableData<A>>);
return filteredColumns;
},
onFetched: async transformed => {
const { total } = transformed;
updatePagination({
page: searchParams.pageNum,
pageSize: searchParams.pageSize,
itemCount: total
});
},
immediate
});
const pagination: PaginationProps = reactive({
page: 1,
pageSize: 10,
itemCount: 0,
showSizePicker: true,
itemCount: 0,
pageSizes: [10, 15, 20, 25, 30],
prefix: showTotal.value ? page => $t('datatable.itemCount', { total: page.itemCount }) : undefined,
onUpdatePage(page) {
onUpdatePage: async (page: number) => {
pagination.page = page;
updateSearchParams({
pageNum: page,
pageSize: pagination.pageSize!
});
getData();
},
onUpdatePageSize(pageSize) {
onUpdatePageSize: async (pageSize: number) => {
pagination.pageSize = pageSize;
pagination.page = 1;
updateSearchParams({
pageNum: pagination.page,
pageSize
});
getData();
},
...options.paginationProps
}) as PaginationProps;
...(showTotal
? {
prefix: page => $t('datatable.itemCount', { total: page.itemCount })
}
: {})
});
// this is for mobile, if the system does not support mobile, you can use `pagination` directly
const mobilePagination = computed(() => {
const p: PaginationProps = {
...pagination,
pageSlot: isMobile.value ? 3 : 9,
prefix: !isMobile.value && showTotal.value ? pagination.prefix : undefined
prefix: !isMobile.value && showTotal ? pagination.prefix : undefined
};
return p;
});
const paginationParams = computed(() => {
const { page, pageSize } = pagination;
return {
page,
pageSize
};
});
const result = useTable<ResponseData, ApiData, NaiveUI.TableColumn<ApiData>, true>({
...options,
pagination: true,
getColumnChecks: cols => getColumnChecks(cols, options.getColumnVisible),
getColumns,
onFetched: data => {
pagination.itemCount = data.total;
}
});
async function getDataByPage(page: number = 1) {
if (page !== pagination.page) {
pagination.page = page;
return;
}
await result.getData();
function updatePagination(update: Partial<PaginationProps>) {
Object.assign(pagination, update);
}
scope.run(() => {
watch(
() => appStore.locale,
() => {
result.reloadColumns();
}
);
watch(paginationParams, async newVal => {
await options.onPaginationParamsChange?.(newVal);
await result.getData();
/**
* get data by page number
*
* @param pageNum the page number. default is 1
*/
async function getDataByPage(pageNum: number = 1) {
updatePagination({
page: pageNum
});
});
onScopeDispose(() => {
scope.stop();
});
return {
...result,
getDataByPage,
pagination,
mobilePagination
};
}
export function useTableOperate<TableData>(
data: Ref<TableData[]>,
idKey: keyof TableData,
getData: () => Promise<void>
) {
const { bool: drawerVisible, setTrue: openDrawer, setFalse: closeDrawer } = useBoolean();
const operateType = shallowRef<NaiveUI.TableOperateType>('add');
function handleAdd() {
operateType.value = 'add';
openDrawer();
}
/** the editing row data */
const editingData = shallowRef<TableData | null>(null);
function handleEdit(id: TableData[keyof TableData]) {
operateType.value = 'edit';
const findItem = data.value.find(item => item[idKey] === id) || null;
editingData.value = jsonClone(findItem);
openDrawer();
}
/** the checked row keys of table */
const checkedRowKeys = shallowRef<string[]>([]);
/** the hook after the batch delete operation is completed */
async function onBatchDeleted() {
window.$message?.success($t('common.deleteSuccess'));
checkedRowKeys.value = [];
updateSearchParams({
pageNum,
pageSize: pagination.pageSize!
});
await getData();
}
/** the hook after the delete operation is completed */
async function onDeleted() {
window.$message?.success($t('common.deleteSuccess'));
await getData();
}
return {
drawerVisible,
openDrawer,
closeDrawer,
operateType,
handleAdd,
editingData,
handleEdit,
checkedRowKeys,
onBatchDeleted,
onDeleted
};
}
export function defaultTransform<ApiData>(
response: FlatResponseData<any, Api.Common.PaginatingQueryRecord<ApiData>>
): PaginationData<ApiData> {
const { data, error } = response;
if (error) {
return {
data: [],
pageNum: 1,
pageSize: 10,
total: 0
};
}
const { rows: records, pageSize: current, pageNum: size, total } = data;
return {
data: records,
pageNum: current,
pageSize: size,
total
};
}
type UseNaiveTreeTableOptions<ResponseData, ApiData> = UseNaiveTableOptions<ResponseData, ApiData, false> & {
keyField: keyof ApiData;
defaultExpandAll?: boolean;
};
export function useNaiveTreeTable<ResponseData, ApiData>(options: UseNaiveTreeTableOptions<ResponseData, ApiData>) {
const scope = effectScope();
const appStore = useAppStore();
const rows: Ref<ApiData[]> = ref([]);
const result = useTable<ResponseData, ApiData, NaiveUI.TableColumn<ApiData>, false>({
...options,
pagination: false,
getColumnChecks: cols => getColumnChecks(cols, options.getColumnVisible),
getColumns,
onFetched: transformData => {
const data: ApiData[] = [];
const collect = (nodes: any[]) => {
nodes.forEach(node => {
data.push(node);
if (node?.children?.length) {
collect(node.children);
}
});
};
collect(transformData);
rows.value = data;
}
});
const { keyField = 'id', defaultExpandAll = false } = options;
const expandedRowKeys = ref<ApiData[keyof ApiData][]>([]);
const { bool: isCollapse, toggle: toggleCollapse } = useBoolean(defaultExpandAll);
/** expand all nodes */
function expandAll() {
toggleCollapse();
expandedRowKeys.value = rows.value.map(item => item[keyField as keyof ApiData]);
}
/** collapse all nodes */
function collapseAll() {
toggleCollapse();
expandedRowKeys.value = [];
}
scope.run(() => {
watch(
() => appStore.locale,
() => {
result.reloadColumns();
reloadColumns();
}
);
});
@ -316,19 +200,28 @@ export function useNaiveTreeTable<ResponseData, ApiData>(options: UseNaiveTreeTa
});
return {
...result,
rows,
isCollapse,
expandedRowKeys,
expandAll,
collapseAll
loading,
empty,
data,
columns,
columnChecks,
reloadColumns,
pagination,
mobilePagination,
updatePagination,
getData,
getDataByPage,
searchParams,
updateSearchParams,
resetSearchParams,
updateApiFn
};
}
export function useTreeTableOperate<ApiData>(data: Ref<ApiData[]>, idKey: keyof ApiData, getData: () => Promise<void>) {
export function useTableOperate<T extends TableData = TableData>(data: Ref<T[]>, getData: () => Promise<void>) {
const { bool: drawerVisible, setTrue: openDrawer, setFalse: closeDrawer } = useBoolean();
const operateType = shallowRef<NaiveUI.TableOperateType>('add');
const operateType = ref<NaiveUI.TableOperateType>('add');
function handleAdd() {
operateType.value = 'add';
@ -336,18 +229,18 @@ export function useTreeTableOperate<ApiData>(data: Ref<ApiData[]>, idKey: keyof
}
/** the editing row data */
const editingData = shallowRef<ApiData | null>(null);
const editingData: Ref<T | null> = ref(null);
function handleEdit(id: ApiData[keyof ApiData]) {
function handleEdit(field: keyof T, id: CommonType.IdType) {
operateType.value = 'edit';
const findItem = data.value.find(item => item[idKey] === id) || null;
const findItem = data.value.find(item => item[field] === id) || null;
editingData.value = jsonClone(findItem);
openDrawer();
}
/** the checked row keys of table */
const checkedRowKeys = shallowRef<string[]>([]);
const checkedRowKeys = ref<CommonType.IdType[]>([]);
/** the hook after the batch delete operation is completed */
async function onBatchDeleted() {
@ -379,135 +272,6 @@ export function useTreeTableOperate<ApiData>(data: Ref<ApiData[]>, idKey: keyof
};
}
type TreeTableOptions<ApiData> = {
/** id field name */
idField?: keyof ApiData;
/** parent id field name */
parentIdField?: keyof ApiData;
/** children field name */
childrenField?: keyof ApiData;
/** filter function */
filterFn?: (node: ApiData) => boolean;
};
export function treeTransform<ApiData>(
response: FlatResponseData<any, ApiData[]>,
options: TreeTableOptions<ApiData>
): ApiData[] {
const { data, error } = response;
if (error || !data.length) {
return [];
}
const { idField = 'id', parentIdField = 'parentId', childrenField = 'children', filterFn = () => true } = options;
// 使用 Map 替代普通对象,提高性能
const childrenMap = new Map<ApiData[keyof ApiData], ApiData[]>();
const nodeMap = new Map<ApiData[keyof ApiData], ApiData>();
const tree: ApiData[] = [];
// 第一遍遍历:构建节点映射
for (const item of data) {
const id = item[idField as keyof ApiData];
const parentId = item[parentIdField as keyof ApiData];
nodeMap.set(id, item);
if (!childrenMap.has(parentId)) {
childrenMap.set(parentId, []);
}
// 应用过滤函数
if (filterFn(item)) {
childrenMap.get(parentId)!.push(item);
}
}
// 第二遍遍历:找出根节点
for (const item of data) {
const parentId = item[parentIdField as keyof ApiData];
if (!nodeMap.has(parentId) && filterFn(item)) {
tree.push(item);
}
}
// 递归构建树形结构
const buildTree = (node: ApiData) => {
const id = node[idField as keyof ApiData];
const children = childrenMap.get(id);
if (children?.length) {
// 使用类型断言确保类型安全
(node as any)[childrenField] = children;
for (const child of children) {
buildTree(child);
}
} else {
// 如果没有子节点,设置为 undefined
(node as any)[childrenField] = undefined;
}
};
// 从根节点开始构建树
for (const root of tree) {
buildTree(root);
}
return tree;
}
function getColumnChecks<Column extends NaiveUI.TableColumn<any>>(
cols: Column[],
getColumnVisible?: (column: Column) => boolean
) {
const checks: TableColumnCheck[] = [];
cols.forEach(column => {
if (isTableColumnHasKey(column)) {
checks.push({
key: column.key as string,
title: column.title!,
checked: true,
visible: getColumnVisible?.(column) ?? true
});
} else if (column.type === 'selection') {
checks.push({
key: SELECTION_KEY,
title: $t('common.check'),
checked: true,
visible: getColumnVisible?.(column) ?? false
});
} else if (column.type === 'expand') {
checks.push({
key: EXPAND_KEY,
title: $t('common.expandColumn'),
checked: true,
visible: getColumnVisible?.(column) ?? false
});
}
});
return checks;
}
function getColumns<Column extends NaiveUI.TableColumn<any>>(cols: Column[], checks: TableColumnCheck[]) {
const columnMap = new Map<string, Column>();
cols.forEach(column => {
if (isTableColumnHasKey(column)) {
columnMap.set(column.key as string, column);
} else if (column.type === 'selection') {
columnMap.set(SELECTION_KEY, column);
} else if (column.type === 'expand') {
columnMap.set(EXPAND_KEY, column);
}
});
const filteredColumns = checks.filter(item => item.checked).map(check => columnMap.get(check.key) as Column);
return filteredColumns;
}
export function isTableColumnHasKey<T>(column: NaiveUI.TableColumn<T>): column is NaiveUI.TableColumnWithKey<T> {
function isTableColumnHasKey<T>(column: TableColumn<T>): column is NaiveUI.TableColumnWithKey<T> {
return Boolean((column as NaiveUI.TableColumnWithKey<T>).key);
}

View File

@ -0,0 +1,237 @@
import { effectScope, onScopeDispose, ref, watch } from 'vue';
import type { Ref } from 'vue';
import { jsonClone } from '@sa/utils';
import { useBoolean, useHookTable } from '@sa/hooks';
import { useAppStore } from '@/store/modules/app';
import { handleTree } from '@/utils/common';
import { $t } from '@/locales';
type TableData = NaiveUI.TableData;
type GetTableData<A extends NaiveUI.TreeTableApiFn> = NaiveUI.GetTreeTableData<A>;
type TableColumn<T> = NaiveUI.TableColumn<T>;
export function useTreeTable<A extends NaiveUI.TreeTableApiFn>(
config: NaiveUI.NaiveTreeTableConfig<A> & CommonType.TreeConfig & { defaultExpandAll?: boolean }
) {
const scope = effectScope();
const appStore = useAppStore();
const {
apiFn,
apiParams,
immediate,
idField,
parentIdField = 'parentId',
childrenField = 'children',
defaultExpandAll = false
} = config;
const SELECTION_KEY = '__selection__';
const EXPAND_KEY = '__expand__';
const expandedRowKeys = ref<CommonType.IdType[]>([]);
const {
loading,
empty,
data,
columns,
columnChecks,
reloadColumns,
getData,
searchParams,
updateSearchParams,
resetSearchParams
} = useHookTable<A, GetTableData<A>, TableColumn<NaiveUI.TableDataWithIndex<GetTableData<A>>>>({
apiFn,
apiParams,
columns: config.columns,
transformer: res => {
const records = res.data || [];
if (!records.length) return { data: [] };
const treeData = handleTree(records, {
idField,
parentIdField,
childrenField
});
// if defaultExpandAll is true, expand all nodes
expandedRowKeys.value = defaultExpandAll
? records.map(item => item[idField])
: records.filter(item => item[parentIdField] === 0).map(item => item[idField]) || [];
return { data: treeData };
},
getColumnChecks: cols => {
const checks: NaiveUI.TableColumnCheck[] = [];
cols.forEach(column => {
if (isTableColumnHasKey(column)) {
checks.push({
key: column.key as string,
title: column.title as string,
checked: true
});
} else if (column.type === 'selection') {
checks.push({
key: SELECTION_KEY,
title: $t('common.check'),
checked: true
});
} else if (column.type === 'expand') {
checks.push({
key: EXPAND_KEY,
title: $t('common.expandColumn'),
checked: true
});
}
});
return checks;
},
getColumns: (cols, checks) => {
const columnMap = new Map<string, TableColumn<GetTableData<A>>>();
cols.forEach(column => {
if (isTableColumnHasKey(column)) {
columnMap.set(column.key as string, column);
} else if (column.type === 'selection') {
columnMap.set(SELECTION_KEY, column);
} else if (column.type === 'expand') {
columnMap.set(EXPAND_KEY, column);
}
});
const filteredColumns = checks
.filter(item => item.checked)
.map(check => columnMap.get(check.key) as TableColumn<GetTableData<A>>);
return filteredColumns;
},
immediate
});
/** 收集所有节点的key */
function collectAllNodeKeys(treeNodes: any[]): CommonType.IdType[] {
const keys: CommonType.IdType[] = [];
const collect = (nodes: any[]) => {
nodes.forEach(node => {
keys.push(node[idField]);
if (node[childrenField]?.length) {
collect(node[childrenField]);
}
});
};
collect(treeNodes);
return keys;
}
const { bool: isCollapse, toggle: toggleCollapse } = useBoolean(defaultExpandAll);
/** expand all nodes */
function expandAll() {
toggleCollapse();
expandedRowKeys.value = collectAllNodeKeys(data.value);
}
/** collapse all nodes */
function collapseAll() {
toggleCollapse();
expandedRowKeys.value = [];
}
scope.run(() => {
watch(
() => appStore.locale,
() => {
reloadColumns();
}
);
});
onScopeDispose(() => {
scope.stop();
});
return {
loading,
empty,
data,
columns,
columnChecks,
reloadColumns,
getData,
searchParams,
updateSearchParams,
resetSearchParams,
expandedRowKeys,
isCollapse,
expandAll,
collapseAll
};
}
export function useTreeTableOperate<T extends TableData = TableData>(_: Ref<T[]>, getData: () => Promise<void>) {
const { bool: drawerVisible, setTrue: openDrawer, setFalse: closeDrawer } = useBoolean();
const operateType = ref<NaiveUI.TableOperateType>('add');
function handleAdd() {
operateType.value = 'add';
openDrawer();
}
/** the editing row data */
const editingData: Ref<T | null> = ref(null);
function handleEdit(row: T) {
operateType.value = 'edit';
editingData.value = jsonClone(row);
openDrawer();
}
/** the checked row keys of table */
const checkedRowKeys = ref<CommonType.IdType[]>([]);
function clearCheckedRowKeys() {
checkedRowKeys.value = [];
}
/** the hook after the batch delete operation is completed */
async function onBatchDeleted() {
window.$message?.success($t('common.deleteSuccess'));
checkedRowKeys.value = [];
await getData();
}
/** the hook after the delete operation is completed */
async function onDeleted() {
window.$message?.success($t('common.deleteSuccess'));
await getData();
}
return {
drawerVisible,
openDrawer,
closeDrawer,
operateType,
handleAdd,
editingData,
handleEdit,
checkedRowKeys,
onBatchDeleted,
onDeleted,
clearCheckedRowKeys
};
}
function isTableColumnHasKey<T>(column: TableColumn<T>): column is NaiveUI.TableColumnWithKey<T> {
return Boolean((column as NaiveUI.TableColumnWithKey<T>).key);
}

View File

@ -12,7 +12,7 @@ import GlobalTab from '../modules/global-tab/index.vue';
import GlobalContent from '../modules/global-content/index.vue';
import GlobalFooter from '../modules/global-footer/index.vue';
import ThemeDrawer from '../modules/theme-drawer/index.vue';
import { provideMixMenuContext } from '../modules/global-menu/context';
import { setupMixMenuContext } from '../context';
defineOptions({
name: 'BaseLayout'
@ -20,7 +20,7 @@ defineOptions({
const appStore = useAppStore();
const themeStore = useThemeStore();
const { childLevelMenus, isActiveFirstLevelMenuHasChildren } = provideMixMenuContext();
const { childLevelMenus, isActiveFirstLevelMenuHasChildren } = setupMixMenuContext();
const GlobalMenu = defineAsyncComponent(() => import('../modules/global-menu/index.vue'));
@ -31,7 +31,7 @@ const layoutMode = computed(() => {
});
const headerProps = computed(() => {
const { mode } = themeStore.layout;
const { mode, reverseHorizontalMix } = themeStore.layout;
const headerPropsConfig: Record<UnionKey.ThemeLayoutMode, App.Global.HeaderProps> = {
vertical: {
@ -44,25 +44,15 @@ const headerProps = computed(() => {
showMenu: false,
showMenuToggler: false
},
'vertical-hybrid-header-first': {
showLogo: !isActiveFirstLevelMenuHasChildren.value,
showMenu: true,
showMenuToggler: false
},
horizontal: {
showLogo: true,
showMenu: true,
showMenuToggler: false
},
'top-hybrid-sidebar-first': {
'horizontal-mix': {
showLogo: true,
showMenu: true,
showMenuToggler: false
},
'top-hybrid-header-first': {
showLogo: true,
showMenu: true,
showMenuToggler: isActiveFirstLevelMenuHasChildren.value
showMenuToggler: reverseHorizontalMix && isActiveFirstLevelMenuHasChildren.value
}
};
@ -73,56 +63,44 @@ const siderVisible = computed(() => themeStore.layout.mode !== 'horizontal');
const isVerticalMix = computed(() => themeStore.layout.mode === 'vertical-mix');
const isVerticalHybridHeaderFirst = computed(() => themeStore.layout.mode === 'vertical-hybrid-header-first');
const isTopHybridSidebarFirst = computed(() => themeStore.layout.mode === 'top-hybrid-sidebar-first');
const isTopHybridHeaderFirst = computed(() => themeStore.layout.mode === 'top-hybrid-header-first');
const isHorizontalMix = computed(() => themeStore.layout.mode === 'horizontal-mix');
const siderWidth = computed(() => getSiderWidth());
const siderCollapsedWidth = computed(() => getSiderCollapsedWidth());
function getSiderAndCollapsedWidth(isCollapsed: boolean) {
const {
mixChildMenuWidth,
collapsedWidth,
width: themeWidth,
mixCollapsedWidth,
mixWidth: themeMixWidth
} = themeStore.sider;
function getSiderWidth() {
const { reverseHorizontalMix } = themeStore.layout;
const { width, mixWidth, mixChildMenuWidth } = themeStore.sider;
const width = isCollapsed ? collapsedWidth : themeWidth;
const mixWidth = isCollapsed ? mixCollapsedWidth : themeMixWidth;
if (isTopHybridHeaderFirst.value) {
if (isHorizontalMix.value && reverseHorizontalMix) {
return isActiveFirstLevelMenuHasChildren.value ? width : 0;
}
if (isVerticalHybridHeaderFirst.value && !isActiveFirstLevelMenuHasChildren.value) {
return 0;
}
const isMixMode = isVerticalMix.value || isTopHybridSidebarFirst.value || isVerticalHybridHeaderFirst.value;
let finalWidth = isMixMode ? mixWidth : width;
let w = isVerticalMix.value || isHorizontalMix.value ? mixWidth : width;
if (isVerticalMix.value && appStore.mixSiderFixed && childLevelMenus.value.length) {
finalWidth += mixChildMenuWidth;
w += mixChildMenuWidth;
}
if (isVerticalHybridHeaderFirst.value && appStore.mixSiderFixed && childLevelMenus.value.length) {
finalWidth += mixChildMenuWidth;
}
return finalWidth;
}
function getSiderWidth() {
return getSiderAndCollapsedWidth(false);
return w;
}
function getSiderCollapsedWidth() {
return getSiderAndCollapsedWidth(true);
const { reverseHorizontalMix } = themeStore.layout;
const { collapsedWidth, mixCollapsedWidth, mixChildMenuWidth } = themeStore.sider;
if (isHorizontalMix.value && reverseHorizontalMix) {
return isActiveFirstLevelMenuHasChildren.value ? collapsedWidth : 0;
}
let w = isVerticalMix.value || isHorizontalMix.value ? mixCollapsedWidth : collapsedWidth;
if (isVerticalMix.value && appStore.mixSiderFixed && childLevelMenus.value.length) {
w += mixChildMenuWidth;
}
return w;
}
onMounted(() => {

View File

@ -0,0 +1,83 @@
import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useContext } from '@sa/hooks';
import { useRouteStore } from '@/store/modules/route';
export const { setupStore: setupMixMenuContext, useStore: useMixMenuContext } = useContext('mix-menu', useMixMenu);
function useMixMenu() {
const route = useRoute();
const routeStore = useRouteStore();
const { selectedKey } = useMenu();
const activeFirstLevelMenuKey = ref('');
function setActiveFirstLevelMenuKey(key: string) {
activeFirstLevelMenuKey.value = key;
}
function getActiveFirstLevelMenuKey() {
const [firstLevelRouteName] = selectedKey.value.split('_');
setActiveFirstLevelMenuKey(firstLevelRouteName);
}
const allMenus = computed<App.Global.Menu[]>(() => routeStore.menus);
const firstLevelMenus = computed<App.Global.Menu[]>(() =>
routeStore.menus.map(menu => {
const { children: _, ...rest } = menu;
return rest;
})
);
const childLevelMenus = computed<App.Global.Menu[]>(
() => routeStore.menus.find(menu => menu.key === activeFirstLevelMenuKey.value)?.children || []
);
const isActiveFirstLevelMenuHasChildren = computed(() => {
if (!activeFirstLevelMenuKey.value) {
return false;
}
const findItem = allMenus.value.find(item => item.key === activeFirstLevelMenuKey.value);
return Boolean(findItem?.children?.length);
});
watch(
() => route.name,
() => {
getActiveFirstLevelMenuKey();
},
{ immediate: true }
);
return {
allMenus,
firstLevelMenus,
childLevelMenus,
isActiveFirstLevelMenuHasChildren,
activeFirstLevelMenuKey,
setActiveFirstLevelMenuKey,
getActiveFirstLevelMenuKey
};
}
export function useMenu() {
const route = useRoute();
const selectedKey = computed(() => {
const { hideInMenu, activeMenu } = route.meta;
const name = route.name as string;
const routeName = (hideInMenu ? activeMenu : name) || name;
return routeName;
});
return {
selectedKey
};
}

View File

@ -3,7 +3,6 @@ import { computed } from 'vue';
import { createReusableTemplate } from '@vueuse/core';
import { SimpleScrollbar } from '@sa/materials';
import { transformColorWithOpacity } from '@sa/color';
import type { RouteKey } from '@elegant-router/types';
defineOptions({
name: 'FirstLevelMenu'
@ -21,7 +20,7 @@ interface Props {
const props = defineProps<Props>();
interface Emits {
(e: 'select', menuKey: RouteKey): boolean;
(e: 'select', menu: App.Global.Menu): boolean;
(e: 'toggleSiderCollapse'): void;
}
@ -48,8 +47,8 @@ const selectedBgColor = computed(() => {
return darkMode ? dark : light;
});
function handleClickMixMenu(menuKey: RouteKey) {
emit('select', menuKey);
function handleClickMixMenu(menu: App.Global.Menu) {
emit('select', menu);
}
function toggleSiderCollapse() {
@ -89,7 +88,7 @@ function toggleSiderCollapse() {
:icon="menu.icon"
:active="menu.key === activeMenuKey"
:is-mini="siderCollapse"
@click="handleClickMixMenu(menu.routeKey)"
@click="handleClickMixMenu(menu)"
/>
</SimpleScrollbar>
<MenuToggler

View File

@ -1,143 +0,0 @@
import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useContext } from '@sa/hooks';
import type { RouteKey } from '@elegant-router/types';
import { useRouteStore } from '@/store/modules/route';
import { useRouterPush } from '@/hooks/common/router';
export const [provideMixMenuContext, useMixMenuContext] = useContext('MixMenu', useMixMenu);
function useMixMenu() {
const route = useRoute();
const routeStore = useRouteStore();
const { selectedKey } = useMenu();
const { routerPushByKeyWithMetaQuery } = useRouterPush();
const allMenus = computed<App.Global.Menu[]>(() => routeStore.menus);
const firstLevelMenus = computed<App.Global.Menu[]>(() =>
routeStore.menus.map(menu => {
const { children: _, ...rest } = menu;
return rest;
})
);
const activeFirstLevelMenuKey = ref('');
function setActiveFirstLevelMenuKey(key: string) {
activeFirstLevelMenuKey.value = key;
}
function getActiveFirstLevelMenuKey() {
const [firstLevelRouteName] = selectedKey.value.split('_');
setActiveFirstLevelMenuKey(firstLevelRouteName);
}
const isActiveFirstLevelMenuHasChildren = computed(() => {
if (!activeFirstLevelMenuKey.value) {
return false;
}
const findItem = allMenus.value.find(item => item.key === activeFirstLevelMenuKey.value);
return Boolean(findItem?.children?.length);
});
function handleSelectFirstLevelMenu(key: RouteKey) {
setActiveFirstLevelMenuKey(key);
if (!isActiveFirstLevelMenuHasChildren.value) {
routerPushByKeyWithMetaQuery(key);
}
}
const secondLevelMenus = computed<App.Global.Menu[]>(
() => allMenus.value.find(menu => menu.key === activeFirstLevelMenuKey.value)?.children || []
);
const activeSecondLevelMenuKey = ref('');
function setActiveSecondLevelMenuKey(key: string) {
activeSecondLevelMenuKey.value = key;
}
function getActiveSecondLevelMenuKey() {
const keys = selectedKey.value.split('_');
if (keys.length < 2) {
setActiveSecondLevelMenuKey('');
return;
}
const [firstLevelRouteName, level2SuffixName] = keys;
const secondLevelRouteName = `${firstLevelRouteName}_${level2SuffixName}`;
setActiveSecondLevelMenuKey(secondLevelRouteName);
}
const isActiveSecondLevelMenuHasChildren = computed(() => {
if (!activeSecondLevelMenuKey.value) {
return false;
}
const findItem = secondLevelMenus.value.find(item => item.key === activeSecondLevelMenuKey.value);
return Boolean(findItem?.children?.length);
});
function handleSelectSecondLevelMenu(key: RouteKey) {
setActiveSecondLevelMenuKey(key);
if (!isActiveSecondLevelMenuHasChildren.value) {
routerPushByKeyWithMetaQuery(key);
}
}
const childLevelMenus = computed<App.Global.Menu[]>(
() => secondLevelMenus.value.find(menu => menu.key === activeSecondLevelMenuKey.value)?.children || []
);
watch(
() => route.name,
() => {
getActiveFirstLevelMenuKey();
},
{ immediate: true }
);
return {
firstLevelMenus,
activeFirstLevelMenuKey,
setActiveFirstLevelMenuKey,
isActiveFirstLevelMenuHasChildren,
handleSelectFirstLevelMenu,
getActiveFirstLevelMenuKey,
secondLevelMenus,
activeSecondLevelMenuKey,
setActiveSecondLevelMenuKey,
isActiveSecondLevelMenuHasChildren,
handleSelectSecondLevelMenu,
getActiveSecondLevelMenuKey,
childLevelMenus
};
}
export function useMenu() {
const route = useRoute();
const selectedKey = computed(() => {
const { hideInMenu, activeMenu } = route.meta;
const name = route.name as string;
const routeName = (hideInMenu ? activeMenu : name) || name;
return routeName;
});
return {
selectedKey
};
}

View File

@ -5,10 +5,9 @@ import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme';
import VerticalMenu from './modules/vertical-menu.vue';
import VerticalMixMenu from './modules/vertical-mix-menu.vue';
import VerticalHybridHeaderFirst from './modules/vertical-hybrid-header-first.vue';
import HorizontalMenu from './modules/horizontal-menu.vue';
import TopHybridSidebarFirst from './modules/top-hybrid-sidebar-first.vue';
import TopHybridHeaderFirst from './modules/top-hybrid-header-first.vue';
import HorizontalMixMenu from './modules/horizontal-mix-menu.vue';
import ReversedHorizontalMixMenu from './modules/reversed-horizontal-mix-menu.vue';
defineOptions({
name: 'GlobalMenu'
@ -21,10 +20,8 @@ const activeMenu = computed(() => {
const menuMap: Record<UnionKey.ThemeLayoutMode, Component> = {
vertical: VerticalMenu,
'vertical-mix': VerticalMixMenu,
'vertical-hybrid-header-first': VerticalHybridHeaderFirst,
horizontal: HorizontalMenu,
'top-hybrid-sidebar-first': TopHybridSidebarFirst,
'top-hybrid-header-first': TopHybridHeaderFirst
'horizontal-mix': themeStore.layout.reverseHorizontalMix ? ReversedHorizontalMixMenu : HorizontalMixMenu
};
return menuMap[themeStore.layout.mode];

View File

@ -2,7 +2,7 @@
import { GLOBAL_HEADER_MENU_ID } from '@/constants/app';
import { useRouteStore } from '@/store/modules/route';
import { useRouterPush } from '@/hooks/common/router';
import { useMenu } from '../context';
import { useMenu } from '../../../context';
defineOptions({
name: 'HorizontalMenu'

View File

@ -4,18 +4,25 @@ import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme';
import { useRouterPush } from '@/hooks/common/router';
import FirstLevelMenu from '../components/first-level-menu.vue';
import { useMenu, useMixMenuContext } from '../context';
import { useMenu, useMixMenuContext } from '../../../context';
defineOptions({
name: 'TopHybridSidebarFirst'
name: 'HorizontalMixMenu'
});
const appStore = useAppStore();
const themeStore = useThemeStore();
const { routerPushByKeyWithMetaQuery } = useRouterPush();
const { firstLevelMenus, secondLevelMenus, activeFirstLevelMenuKey, handleSelectFirstLevelMenu } =
useMixMenuContext('TopHybridSidebarFirst');
const { allMenus, childLevelMenus, activeFirstLevelMenuKey, setActiveFirstLevelMenuKey } = useMixMenuContext();
const { selectedKey } = useMenu();
function handleSelectMixMenu(menu: App.Global.Menu) {
setActiveFirstLevelMenuKey(menu.key);
if (!menu.children?.length) {
routerPushByKeyWithMetaQuery(menu.routeKey);
}
}
</script>
<template>
@ -23,24 +30,22 @@ const { selectedKey } = useMenu();
<NMenu
mode="horizontal"
:value="selectedKey"
:options="secondLevelMenus"
:options="childLevelMenus"
:indent="18"
responsive
@update:value="routerPushByKeyWithMetaQuery"
/>
</Teleport>
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
<div class="h-full pt-2">
<FirstLevelMenu
:menus="firstLevelMenus"
:active-menu-key="activeFirstLevelMenuKey"
:sider-collapse="appStore.siderCollapse"
:dark-mode="themeStore.darkMode"
:theme-color="themeStore.themeColor"
@select="handleSelectFirstLevelMenu"
@toggle-sider-collapse="appStore.toggleSiderCollapse"
/>
</div>
<FirstLevelMenu
:menus="allMenus"
:active-menu-key="activeFirstLevelMenuKey"
:sider-collapse="appStore.siderCollapse"
:dark-mode="themeStore.darkMode"
:theme-color="themeStore.themeColor"
@select="handleSelectMixMenu"
@toggle-sider-collapse="appStore.toggleSiderCollapse"
/>
</Teleport>
</template>

View File

@ -1,16 +1,17 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import type { RouteKey } from '@elegant-router/types';
import { SimpleScrollbar } from '@sa/materials';
import { GLOBAL_HEADER_MENU_ID, GLOBAL_SIDER_MENU_ID } from '@/constants/app';
import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme';
import { useRouteStore } from '@/store/modules/route';
import { useRouterPush } from '@/hooks/common/router';
import { useMenu, useMixMenuContext } from '../context';
import { useMenu, useMixMenuContext } from '../../../context';
defineOptions({
name: 'TopHybridHeaderFirst'
name: 'ReversedHorizontalMixMenu'
});
const route = useRoute();
@ -18,10 +19,23 @@ const appStore = useAppStore();
const themeStore = useThemeStore();
const routeStore = useRouteStore();
const { routerPushByKeyWithMetaQuery } = useRouterPush();
const { firstLevelMenus, secondLevelMenus, activeFirstLevelMenuKey, handleSelectFirstLevelMenu } =
useMixMenuContext('TopHybridHeaderFirst');
const {
firstLevelMenus,
childLevelMenus,
activeFirstLevelMenuKey,
setActiveFirstLevelMenuKey,
isActiveFirstLevelMenuHasChildren
} = useMixMenuContext();
const { selectedKey } = useMenu();
function handleSelectMixMenu(key: RouteKey) {
setActiveFirstLevelMenuKey(key);
if (!isActiveFirstLevelMenuHasChildren.value) {
routerPushByKeyWithMetaQuery(key);
}
}
const expandedKeys = ref<string[]>([]);
function updateExpandedKeys() {
@ -49,7 +63,7 @@ watch(
:options="firstLevelMenus"
:indent="18"
responsive
@update:value="handleSelectFirstLevelMenu"
@update:value="handleSelectMixMenu"
/>
</Teleport>
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
@ -61,7 +75,7 @@ watch(
:collapsed="appStore.siderCollapse"
:collapsed-width="themeStore.sider.collapsedWidth"
:collapsed-icon-size="22"
:options="secondLevelMenus"
:options="childLevelMenus"
:indent="18"
@update:value="routerPushByKeyWithMetaQuery"
/>

View File

@ -1,149 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import type { RouteKey } from '@elegant-router/types';
import { SimpleScrollbar } from '@sa/materials';
import { useBoolean } from '@sa/hooks';
import { GLOBAL_HEADER_MENU_ID, GLOBAL_SIDER_MENU_ID } from '@/constants/app';
import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme';
import { useRouteStore } from '@/store/modules/route';
import { useRouterPush } from '@/hooks/common/router';
import { useMenu, useMixMenuContext } from '../context';
import FirstLevelMenu from '../components/first-level-menu.vue';
import GlobalLogo from '../../global-logo/index.vue';
defineOptions({
name: 'VerticalHybridHeaderFirst'
});
const route = useRoute();
const appStore = useAppStore();
const themeStore = useThemeStore();
const routeStore = useRouteStore();
const { routerPushByKeyWithMetaQuery } = useRouterPush();
const { bool: drawerVisible, setBool: setDrawerVisible } = useBoolean();
const {
firstLevelMenus,
activeFirstLevelMenuKey,
handleSelectFirstLevelMenu,
getActiveFirstLevelMenuKey,
secondLevelMenus,
activeSecondLevelMenuKey,
isActiveSecondLevelMenuHasChildren,
handleSelectSecondLevelMenu,
getActiveSecondLevelMenuKey,
childLevelMenus
} = useMixMenuContext('VerticalHybridHeaderFirst');
const { selectedKey } = useMenu();
const inverted = computed(() => !themeStore.darkMode && themeStore.sider.inverted);
const hasChildMenus = computed(() => childLevelMenus.value.length > 0);
const showDrawer = computed(() => hasChildMenus.value && (drawerVisible.value || appStore.mixSiderFixed));
function handleSelectMixMenu(key: RouteKey) {
handleSelectSecondLevelMenu(key);
if (isActiveSecondLevelMenuHasChildren.value) {
setDrawerVisible(true);
}
}
function handleSelectMenu(key: RouteKey) {
handleSelectFirstLevelMenu(key);
if (secondLevelMenus.value.length > 0) {
handleSelectMixMenu(secondLevelMenus.value[0].routeKey);
}
}
function handleResetActiveMenu() {
setDrawerVisible(false);
if (!appStore.mixSiderFixed) {
getActiveFirstLevelMenuKey();
getActiveSecondLevelMenuKey();
}
}
const expandedKeys = ref<string[]>([]);
function updateExpandedKeys() {
if (appStore.siderCollapse || !selectedKey.value) {
expandedKeys.value = [];
return;
}
expandedKeys.value = routeStore.getSelectedMenuKeyPath(selectedKey.value);
}
watch(
() => route.name,
() => {
updateExpandedKeys();
},
{ immediate: true }
);
</script>
<template>
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`">
<NMenu
mode="horizontal"
:value="activeFirstLevelMenuKey"
:options="firstLevelMenus"
:indent="18"
responsive
@update:value="handleSelectMenu"
/>
</Teleport>
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
<div class="h-full flex" @mouseleave="handleResetActiveMenu">
<FirstLevelMenu
:menus="secondLevelMenus"
:active-menu-key="activeSecondLevelMenuKey"
:inverted="inverted"
:sider-collapse="appStore.siderCollapse"
:dark-mode="themeStore.darkMode"
:theme-color="themeStore.themeColor"
@select="handleSelectMixMenu"
@toggle-sider-collapse="appStore.toggleSiderCollapse"
>
<GlobalLogo :show-title="false" :style="{ height: themeStore.header.height + 'px' }" />
</FirstLevelMenu>
<div
class="relative h-full transition-width-300"
:style="{ width: appStore.mixSiderFixed && hasChildMenus ? themeStore.sider.mixChildMenuWidth + 'px' : '0px' }"
>
<DarkModeContainer
class="absolute-lt h-full flex-col-stretch nowrap-hidden shadow-sm transition-all-300"
:inverted="inverted"
:style="{ width: showDrawer ? themeStore.sider.mixChildMenuWidth + 'px' : '0px' }"
>
<header class="flex-y-center justify-between px-12px" :style="{ height: themeStore.header.height + 'px' }">
<h2 class="text-16px text-primary font-bold">{{ $t('system.title') }}</h2>
<PinToggler
:pin="appStore.mixSiderFixed"
:class="{ 'text-white:88 !hover:text-white': inverted }"
@click="appStore.toggleMixSiderFixed"
/>
</header>
<SimpleScrollbar>
<NMenu
v-model:expanded-keys="expandedKeys"
mode="vertical"
:value="selectedKey"
:options="childLevelMenus"
:inverted="inverted"
:indent="18"
@update:value="routerPushByKeyWithMetaQuery"
/>
</SimpleScrollbar>
</DarkModeContainer>
</div>
</div>
</Teleport>
</template>
<style scoped></style>

View File

@ -7,7 +7,7 @@ import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme';
import { useRouteStore } from '@/store/modules/route';
import { useRouterPush } from '@/hooks/common/router';
import { useMenu } from '../context';
import { useMenu } from '../../../context';
defineOptions({
name: 'VerticalMenu'

View File

@ -3,14 +3,13 @@ import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { SimpleScrollbar } from '@sa/materials';
import { useBoolean } from '@sa/hooks';
import type { RouteKey } from '@elegant-router/types';
import { GLOBAL_SIDER_MENU_ID } from '@/constants/app';
import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme';
import { useRouteStore } from '@/store/modules/route';
import { useRouterPush } from '@/hooks/common/router';
import { $t } from '@/locales';
import { useMenu, useMixMenuContext } from '../context';
import { useMenu, useMixMenuContext } from '../../../context';
import FirstLevelMenu from '../components/first-level-menu.vue';
import GlobalLogo from '../../global-logo/index.vue';
@ -25,26 +24,28 @@ const routeStore = useRouteStore();
const { routerPushByKeyWithMetaQuery } = useRouterPush();
const { bool: drawerVisible, setBool: setDrawerVisible } = useBoolean();
const {
firstLevelMenus,
secondLevelMenus,
allMenus,
childLevelMenus,
activeFirstLevelMenuKey,
isActiveFirstLevelMenuHasChildren,
getActiveFirstLevelMenuKey,
handleSelectFirstLevelMenu
} = useMixMenuContext('VerticalMixMenu');
setActiveFirstLevelMenuKey,
getActiveFirstLevelMenuKey
//
} = useMixMenuContext();
const { selectedKey } = useMenu();
const inverted = computed(() => !themeStore.darkMode && themeStore.sider.inverted);
const hasChildMenus = computed(() => secondLevelMenus.value.length > 0);
const hasChildMenus = computed(() => childLevelMenus.value.length > 0);
const showDrawer = computed(() => hasChildMenus.value && (drawerVisible.value || appStore.mixSiderFixed));
function handleSelectMenu(key: RouteKey) {
handleSelectFirstLevelMenu(key);
function handleSelectMixMenu(menu: App.Global.Menu) {
setActiveFirstLevelMenuKey(menu.key);
if (isActiveFirstLevelMenuHasChildren.value) {
if (menu.children?.length) {
setDrawerVisible(true);
} else {
routerPushByKeyWithMetaQuery(menu.routeKey);
}
}
@ -79,13 +80,13 @@ watch(
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
<div class="h-full flex" @mouseleave="handleResetActiveMenu">
<FirstLevelMenu
:menus="firstLevelMenus"
:menus="allMenus"
:active-menu-key="activeFirstLevelMenuKey"
:inverted="inverted"
:sider-collapse="appStore.siderCollapse"
:dark-mode="themeStore.darkMode"
:theme-color="themeStore.themeColor"
@select="handleSelectMenu"
@select="handleSelectMixMenu"
@toggle-sider-collapse="appStore.toggleSiderCollapse"
>
<GlobalLogo :show-title="false" :style="{ height: themeStore.header.height + 'px' }" />
@ -112,7 +113,7 @@ watch(
v-model:expanded-keys="expandedKeys"
mode="vertical"
:value="selectedKey"
:options="secondLevelMenus"
:options="childLevelMenus"
:inverted="inverted"
:indent="18"
@update:value="routerPushByKeyWithMetaQuery"

View File

@ -12,13 +12,10 @@ defineOptions({
const appStore = useAppStore();
const themeStore = useThemeStore();
const isTopHybridSidebarFirst = computed(() => themeStore.layout.mode === 'top-hybrid-sidebar-first');
const isTopHybridHeaderFirst = computed(() => themeStore.layout.mode === 'top-hybrid-header-first');
const darkMenu = computed(
() =>
!themeStore.darkMode && !isTopHybridSidebarFirst.value && !isTopHybridHeaderFirst.value && themeStore.sider.inverted
);
const showLogo = computed(() => themeStore.layout.mode === 'vertical');
const isVerticalMix = computed(() => themeStore.layout.mode === 'vertical-mix');
const isHorizontalMix = computed(() => themeStore.layout.mode === 'horizontal-mix');
const darkMenu = computed(() => !themeStore.darkMode && !isHorizontalMix.value && themeStore.sider.inverted);
const showLogo = computed(() => !isVerticalMix.value && !isHorizontalMix.value);
const menuWrapperClass = computed(() => (showLogo.value ? 'flex-1-hidden' : 'h-full'));
</script>

View File

@ -27,6 +27,7 @@ type LayoutConfig = Record<
UnionKey.ThemeLayoutMode,
{
placement: PopoverPlacement;
headerClass: string;
menuClass: string;
mainClass: string;
}
@ -35,31 +36,25 @@ type LayoutConfig = Record<
const layoutConfig: LayoutConfig = {
vertical: {
placement: 'bottom',
headerClass: '',
menuClass: 'w-1/3 h-full',
mainClass: 'w-2/3 h-3/4'
},
'vertical-mix': {
placement: 'bottom',
menuClass: 'w-1/4 h-full',
mainClass: 'w-2/3 h-3/4'
},
'vertical-hybrid-header-first': {
placement: 'bottom',
headerClass: '',
menuClass: 'w-1/4 h-full',
mainClass: 'w-2/3 h-3/4'
},
horizontal: {
placement: 'bottom',
headerClass: '',
menuClass: 'w-full h-1/4',
mainClass: 'w-full h-3/4'
},
'top-hybrid-sidebar-first': {
placement: 'bottom',
menuClass: 'w-full h-1/4',
mainClass: 'w-2/3 h-3/4'
},
'top-hybrid-header-first': {
'horizontal-mix': {
placement: 'bottom',
headerClass: '',
menuClass: 'w-full h-1/4',
mainClass: 'w-2/3 h-3/4'
}
@ -73,27 +68,25 @@ function handleChangeMode(mode: UnionKey.ThemeLayoutMode) {
</script>
<template>
<div class="grid grid-cols-2 gap-x-16px gap-y-12px md:grid-cols-3">
<div class="flex-center flex-wrap gap-x-32px gap-y-16px">
<div
v-for="(item, key) in layoutConfig"
:key="key"
class="flex-col-center cursor-pointer"
class="flex cursor-pointer border-2px rounded-6px hover:border-primary"
:class="[mode === key ? 'border-primary' : 'border-transparent']"
@click="handleChangeMode(key)"
>
<IconTooltip :placement="item.placement">
<NTooltip :placement="item.placement">
<template #trigger>
<div
class="h-64px w-96px gap-6px rd-4px p-6px shadow ring-2 ring-transparent transition-all hover:ring-primary"
:class="{ '!ring-primary': mode === key }"
class="h-64px w-96px gap-6px rd-4px p-6px shadow dark:shadow-coolGray-5"
:class="[key.includes('vertical') ? 'flex' : 'flex-col']"
>
<div class="h-full w-full gap-1" :class="[key.includes('vertical') ? 'flex' : 'flex-col']">
<slot :name="key"></slot>
</div>
<slot :name="key"></slot>
</div>
</template>
{{ $t(`theme.layout.layoutMode.${key}_detail`) }}
</IconTooltip>
<p class="mt-8px text-12px">{{ $t(themeLayoutModeRecord[key]) }}</p>
{{ $t(themeLayoutModeRecord[key]) }}
</NTooltip>
</div>
</div>
</template>

View File

@ -13,7 +13,7 @@ defineProps<Props>();
<template>
<div class="w-full flex-y-center justify-between">
<div class="flex-y-center">
<div>
<span class="pr-8px text-base-text">{{ label }}</span>
<slot name="suffix"></slot>
</div>

View File

@ -1,51 +1,28 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useAppStore } from '@/store/modules/app';
import { $t } from '@/locales';
import AppearanceSettings from './modules/appearance/index.vue';
import LayoutSettings from './modules/layout/index.vue';
import GeneralSettings from './modules/general/index.vue';
import DarkMode from './modules/dark-mode.vue';
import LayoutMode from './modules/layout-mode.vue';
import ThemeColor from './modules/theme-color.vue';
import PageFun from './modules/page-fun.vue';
import ConfigOperation from './modules/config-operation.vue';
import PresetSettings from './modules/preset/index.vue';
import TableProps from './modules/table-props.vue';
defineOptions({
name: 'ThemeDrawer'
});
const appStore = useAppStore();
const activeTab = ref('appearance');
const drawerWidth = computed(() => {
const width = 400;
// On mobile devices, use 90% of viewport width with a maximum of 400px
if (appStore.isMobile) {
return `min(90vw, ${width}px)`;
}
return width;
});
</script>
<template>
<NDrawer v-model:show="appStore.themeDrawerVisible" display-directive="show" :width="drawerWidth">
<NDrawer v-model:show="appStore.themeDrawerVisible" display-directive="show" :width="360">
<NDrawerContent :title="$t('theme.themeDrawerTitle')" :native-scrollbar="false" closable>
<NTabs v-model:value="activeTab" type="segment" size="medium" class="mb-16px">
<NTab name="appearance" :tab="$t('theme.tabs.appearance')"></NTab>
<NTab name="layout" :tab="$t('theme.tabs.layout')"></NTab>
<NTab name="general" :tab="$t('theme.tabs.general')"></NTab>
<NTab name="preset" :tab="$t('theme.tabs.preset')"></NTab>
</NTabs>
<div class="min-h-400px">
<KeepAlive>
<AppearanceSettings v-if="activeTab === 'appearance'" />
<LayoutSettings v-else-if="activeTab === 'layout'" />
<GeneralSettings v-else-if="activeTab === 'general'" />
<PresetSettings v-else-if="activeTab === 'preset'" />
</KeepAlive>
</div>
<DarkMode />
<LayoutMode />
<ThemeColor />
<PageFun />
<TableProps />
<template #footer>
<ConfigOperation />
</template>
@ -53,14 +30,4 @@ const drawerWidth = computed(() => {
</NDrawer>
</template>
<style scoped>
:deep(.n-tab) {
display: flex;
align-items: center;
gap: 8px;
}
:deep(.n-tab-pane) {
padding: 0;
}
</style>
<style scoped></style>

View File

@ -1,17 +0,0 @@
<script setup lang="ts">
import ThemeSchema from './modules/theme-schema.vue';
import ThemeColor from './modules/theme-color.vue';
defineOptions({
name: 'AppearanceSettings'
});
</script>
<template>
<div class="flex-col-stretch gap-16px">
<ThemeSchema />
<ThemeColor />
</div>
</template>
<style scoped></style>

View File

@ -3,10 +3,10 @@ import { computed } from 'vue';
import { themeSchemaRecord } from '@/constants/app';
import { useThemeStore } from '@/store/modules/theme';
import { $t } from '@/locales';
import SettingItem from '../../../components/setting-item.vue';
import SettingItem from '../components/setting-item.vue';
defineOptions({
name: 'ThemeSchema'
name: 'DarkMode'
});
const themeStore = useThemeStore();
@ -33,7 +33,7 @@ const showSiderInverted = computed(() => !themeStore.darkMode && themeStore.layo
</script>
<template>
<NDivider>{{ $t('theme.appearance.themeSchema.title') }}</NDivider>
<NDivider>{{ $t('theme.themeSchema.title') }}</NDivider>
<div class="flex-col-stretch gap-16px">
<div class="i-flex-center">
<NTabs
@ -50,14 +50,14 @@ const showSiderInverted = computed(() => !themeStore.darkMode && themeStore.layo
</NTabs>
</div>
<Transition name="sider-inverted">
<SettingItem v-if="showSiderInverted" :label="$t('theme.layout.sider.inverted')">
<SettingItem v-if="showSiderInverted" :label="$t('theme.sider.inverted')">
<NSwitch v-model:value="themeStore.sider.inverted" />
</SettingItem>
</Transition>
<SettingItem :label="$t('theme.appearance.grayscale')">
<SettingItem :label="$t('theme.grayscale')">
<NSwitch :value="themeStore.grayscale" @update:value="handleGrayscaleChange" />
</SettingItem>
<SettingItem :label="$t('theme.appearance.colourWeakness')">
<SettingItem :label="$t('theme.colourWeakness')">
<NSwitch :value="themeStore.colourWeakness" @update:value="handleColourWeaknessChange" />
</SettingItem>
</div>

View File

@ -1,17 +0,0 @@
<script setup lang="ts">
import GlobalSettings from './modules/global-settings.vue';
import WatermarkSettings from './modules/watermark-settings.vue';
defineOptions({
name: 'GeneralSettings'
});
</script>
<template>
<div class="flex-col-stretch gap-16px">
<GlobalSettings />
<WatermarkSettings />
</div>
</template>
<style scoped></style>

View File

@ -1,39 +0,0 @@
<script setup lang="ts">
import { useThemeStore } from '@/store/modules/theme';
import { $t } from '@/locales';
import SettingItem from '../../../components/setting-item.vue';
defineOptions({
name: 'GlobalSettings'
});
const themeStore = useThemeStore();
</script>
<template>
<NDivider>{{ $t('theme.general.title') }}</NDivider>
<SettingItem :label="$t('theme.general.multilingual.visible')">
<NSwitch v-model:value="themeStore.header.multilingual.visible" />
</SettingItem>
<SettingItem :label="$t('theme.general.globalSearch.visible')">
<NSwitch v-model:value="themeStore.header.globalSearch.visible" />
</SettingItem>
</template>
<style scoped>
.setting-list-move,
.setting-list-enter-active,
.setting-list-leave-active {
--uno: transition-all-300;
}
.setting-list-enter-from,
.setting-list-leave-to {
--uno: opacity-0 -translate-x-30px;
}
.setting-list-leave-active {
--uno: absolute;
}
</style>

View File

@ -1,66 +0,0 @@
<script setup lang="ts">
import { watermarkTimeFormatOptions } from '@/constants/app';
import { useThemeStore } from '@/store/modules/theme';
import { $t } from '@/locales';
import SettingItem from '../../../components/setting-item.vue';
defineOptions({
name: 'WatermarkSettings'
});
const themeStore = useThemeStore();
</script>
<template>
<NDivider>{{ $t('theme.general.watermark.title') }}</NDivider>
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
<SettingItem key="1" :label="$t('theme.general.watermark.visible')">
<NSwitch v-model:value="themeStore.watermark.visible" />
</SettingItem>
<SettingItem v-if="themeStore.watermark.visible" key="2" :label="$t('theme.general.watermark.enableUserName')">
<NSwitch :value="themeStore.watermark.enableUserName" @update:value="themeStore.setWatermarkEnableUserName" />
</SettingItem>
<SettingItem v-if="themeStore.watermark.visible" key="3" :label="$t('theme.general.watermark.enableTime')">
<NSwitch :value="themeStore.watermark.enableTime" @update:value="themeStore.setWatermarkEnableTime" />
</SettingItem>
<SettingItem
v-if="themeStore.watermark.visible && themeStore.watermark.enableTime"
key="4"
:label="$t('theme.general.watermark.timeFormat')"
>
<NSelect
v-model:value="themeStore.watermark.timeFormat"
:options="watermarkTimeFormatOptions"
size="small"
class="w-210px"
/>
</SettingItem>
<SettingItem key="5" :label="$t('theme.general.watermark.text')">
<NInput
v-model:value="themeStore.watermark.text"
autosize
type="text"
size="small"
class="w-120px"
placeholder="SoybeanAdmin"
/>
</SettingItem>
</TransitionGroup>
</template>
<style scoped>
.setting-list-move,
.setting-list-enter-active,
.setting-list-leave-active {
--uno: transition-all-300;
}
.setting-list-enter-from,
.setting-list-leave-to {
--uno: opacity-0 -translate-x-30px;
}
.setting-list-leave-active {
--uno: absolute;
}
</style>

View File

@ -2,7 +2,8 @@
import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme';
import { $t } from '@/locales';
import LayoutModeCard from '../../../components/layout-mode-card.vue';
import LayoutModeCard from '../components/layout-mode-card.vue';
import SettingItem from '../components/setting-item.vue';
defineOptions({
name: 'LayoutMode'
@ -10,60 +11,56 @@ defineOptions({
const appStore = useAppStore();
const themeStore = useThemeStore();
function handleReverseHorizontalMixChange(value: boolean) {
themeStore.setLayoutReverseHorizontalMix(value);
}
</script>
<template>
<NDivider>{{ $t('theme.layout.layoutMode.title') }}</NDivider>
<NDivider>{{ $t('theme.layoutMode.title') }}</NDivider>
<LayoutModeCard v-model:mode="themeStore.layout.mode" :disabled="appStore.isMobile">
<template #vertical>
<div class="layout-sider h-full w-18px !bg-primary"></div>
<div class="layout-sider h-full w-18px"></div>
<div class="vertical-wrapper">
<div class="layout-header bg-primary-200"></div>
<div class="layout-header"></div>
<div class="layout-main"></div>
</div>
</template>
<template #vertical-mix>
<div class="layout-sider h-full w-8px !bg-primary"></div>
<div class="layout-sider h-full w-16px !bg-primary-300"></div>
<div class="layout-sider h-full w-8px"></div>
<div class="layout-sider h-full w-16px"></div>
<div class="vertical-wrapper">
<div class="layout-header bg-primary-200"></div>
<div class="layout-main"></div>
</div>
</template>
<template #vertical-hybrid-header-first>
<div class="layout-sider h-full w-8px !bg-primary"></div>
<div class="layout-sider h-full w-16px !bg-primary-300"></div>
<div class="vertical-wrapper">
<div class="layout-header bg-primary"></div>
<div class="layout-header"></div>
<div class="layout-main"></div>
</div>
</template>
<template #horizontal>
<div class="layout-header !bg-primary"></div>
<div class="layout-header"></div>
<div class="horizontal-wrapper">
<div class="layout-main"></div>
</div>
</template>
<template #top-hybrid-sidebar-first>
<div class="layout-header !bg-primary-300"></div>
<div class="horizontal-wrapper">
<div class="layout-sider w-18px !bg-primary"></div>
<div class="layout-main"></div>
</div>
</template>
<template #top-hybrid-header-first>
<div class="layout-header bg-primary"></div>
<template #horizontal-mix>
<div class="layout-header"></div>
<div class="horizontal-wrapper">
<div class="layout-sider w-18px"></div>
<div class="layout-main"></div>
</div>
</template>
</LayoutModeCard>
<SettingItem
v-if="themeStore.layout.mode === 'horizontal-mix'"
:label="$t('theme.layoutMode.reverseHorizontalMix')"
class="mt-16px"
>
<NSwitch :value="themeStore.layout.reverseHorizontalMix" @update:value="handleReverseHorizontalMixChange" />
</SettingItem>
</template>
<style scoped>
.layout-header {
--uno: h-16px rd-4px;
--uno: h-16px bg-primary rd-4px;
}
.layout-sider {

View File

@ -1,31 +0,0 @@
<script setup lang="ts">
import { useThemeStore } from '@/store/modules/theme';
import LayoutMode from './modules/layout-mode.vue';
import TabSettings from './modules/tab-settings.vue';
import HeaderSettings from './modules/header-settings.vue';
import SiderSettings from './modules/sider-settings.vue';
import FooterSettings from './modules/footer-settings.vue';
import ContentSettings from './modules/content-settings.vue';
import TableSettings from './modules/table-settings.vue';
defineOptions({
name: 'LayoutSettings'
});
const themeStore = useThemeStore();
</script>
<template>
<div class="flex-col-stretch gap-16px">
<LayoutMode />
<TabSettings />
<HeaderSettings />
<!-- The top menu mode does not have a sidebar -->
<SiderSettings v-if="themeStore.layout.mode !== 'horizontal'" />
<FooterSettings />
<ContentSettings />
<TableSettings />
</div>
</template>
<style scoped></style>

View File

@ -1,64 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { themePageAnimationModeOptions, themeScrollModeOptions } from '@/constants/app';
import { useThemeStore } from '@/store/modules/theme';
import { translateOptions } from '@/utils/common';
import { $t } from '@/locales';
import SettingItem from '../../../components/setting-item.vue';
defineOptions({
name: 'ContentSettings'
});
const themeStore = useThemeStore();
const isWrapperScrollMode = computed(() => themeStore.layout.scrollMode === 'wrapper');
</script>
<template>
<NDivider>{{ $t('theme.layout.content.title') }}</NDivider>
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
<SettingItem key="1" :label="$t('theme.layout.content.scrollMode.title')">
<template #suffix>
<IconTooltip :desc="$t('theme.layout.content.scrollMode.tip')" />
</template>
<NSelect
v-model:value="themeStore.layout.scrollMode"
:options="translateOptions(themeScrollModeOptions)"
size="small"
class="w-120px"
/>
</SettingItem>
<SettingItem key="2" :label="$t('theme.layout.content.page.animate')">
<NSwitch v-model:value="themeStore.page.animate" />
</SettingItem>
<SettingItem v-if="themeStore.page.animate" key="3" :label="$t('theme.layout.content.page.mode.title')">
<NSelect
v-model:value="themeStore.page.animateMode"
:options="translateOptions(themePageAnimationModeOptions)"
size="small"
class="w-120px"
/>
</SettingItem>
<SettingItem v-if="isWrapperScrollMode" key="4" :label="$t('theme.layout.content.fixedHeaderAndTab')">
<NSwitch v-model:value="themeStore.fixedHeaderAndTab" />
</SettingItem>
</TransitionGroup>
</template>
<style scoped>
.setting-list-move,
.setting-list-enter-active,
.setting-list-leave-active {
--uno: transition-all-300;
}
.setting-list-enter-from,
.setting-list-leave-to {
--uno: opacity-0 -translate-x-30px;
}
.setting-list-leave-active {
--uno: absolute;
}
</style>

View File

@ -1,61 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useThemeStore } from '@/store/modules/theme';
import { $t } from '@/locales';
import SettingItem from '../../../components/setting-item.vue';
defineOptions({
name: 'FooterSettings'
});
const themeStore = useThemeStore();
const layoutMode = computed(() => themeStore.layout.mode);
const isWrapperScrollMode = computed(() => themeStore.layout.scrollMode === 'wrapper');
const isMixHorizontalMode = computed(() =>
['top-hybrid-sidebar-first', 'top-hybrid-header-first'].includes(layoutMode.value)
);
</script>
<template>
<NDivider>{{ $t('theme.layout.footer.title') }}</NDivider>
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
<SettingItem key="1" :label="$t('theme.layout.footer.visible')">
<NSwitch v-model:value="themeStore.footer.visible" />
</SettingItem>
<SettingItem
v-if="themeStore.footer.visible && isWrapperScrollMode"
key="2"
:label="$t('theme.layout.footer.fixed')"
>
<NSwitch v-model:value="themeStore.footer.fixed" />
</SettingItem>
<SettingItem v-if="themeStore.footer.visible" key="3" :label="$t('theme.layout.footer.height')">
<NInputNumber v-model:value="themeStore.footer.height" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem
v-if="themeStore.footer.visible && isMixHorizontalMode"
key="4"
:label="$t('theme.layout.footer.right')"
>
<NSwitch v-model:value="themeStore.footer.right" />
</SettingItem>
</TransitionGroup>
</template>
<style scoped>
.setting-list-move,
.setting-list-enter-active,
.setting-list-leave-active {
--uno: transition-all-300;
}
.setting-list-enter-from,
.setting-list-leave-to {
--uno: opacity-0 -translate-x-30px;
}
.setting-list-leave-active {
--uno: absolute;
}
</style>

View File

@ -1,47 +0,0 @@
<script setup lang="ts">
import { useThemeStore } from '@/store/modules/theme';
import { $t } from '@/locales';
import SettingItem from '../../../components/setting-item.vue';
defineOptions({
name: 'HeaderSettings'
});
const themeStore = useThemeStore();
</script>
<template>
<NDivider>{{ $t('theme.layout.header.title') }}</NDivider>
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
<SettingItem key="1" :label="$t('theme.layout.header.height')">
<NInputNumber v-model:value="themeStore.header.height" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem key="2" :label="$t('theme.layout.header.breadcrumb.visible')">
<NSwitch v-model:value="themeStore.header.breadcrumb.visible" />
</SettingItem>
<SettingItem
v-if="themeStore.header.breadcrumb.visible"
key="3"
:label="$t('theme.layout.header.breadcrumb.showIcon')"
>
<NSwitch v-model:value="themeStore.header.breadcrumb.showIcon" />
</SettingItem>
</TransitionGroup>
</template>
<style scoped>
.setting-list-move,
.setting-list-enter-active,
.setting-list-leave-active {
--uno: transition-all-300;
}
.setting-list-enter-from,
.setting-list-leave-to {
--uno: opacity-0 -translate-x-30px;
}
.setting-list-leave-active {
--uno: absolute;
}
</style>

View File

@ -1,53 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useThemeStore } from '@/store/modules/theme';
import { $t } from '@/locales';
import SettingItem from '../../../components/setting-item.vue';
defineOptions({
name: 'SiderSettings'
});
const themeStore = useThemeStore();
const layoutMode = computed(() => themeStore.layout.mode);
const isMixLayoutMode = computed(() => layoutMode.value.includes('mix') || layoutMode.value.includes('hybrid'));
</script>
<template>
<NDivider>{{ $t('theme.layout.sider.title') }}</NDivider>
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
<SettingItem v-if="layoutMode === 'vertical'" key="1" :label="$t('theme.layout.sider.width')">
<NInputNumber v-model:value="themeStore.sider.width" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="layoutMode === 'vertical'" key="2" :label="$t('theme.layout.sider.collapsedWidth')">
<NInputNumber v-model:value="themeStore.sider.collapsedWidth" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="isMixLayoutMode" key="3" :label="$t('theme.layout.sider.mixWidth')">
<NInputNumber v-model:value="themeStore.sider.mixWidth" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="isMixLayoutMode" key="4" :label="$t('theme.layout.sider.mixCollapsedWidth')">
<NInputNumber v-model:value="themeStore.sider.mixCollapsedWidth" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="layoutMode === 'vertical-mix'" key="5" :label="$t('theme.layout.sider.mixChildMenuWidth')">
<NInputNumber v-model:value="themeStore.sider.mixChildMenuWidth" size="small" :step="1" class="w-120px" />
</SettingItem>
</TransitionGroup>
</template>
<style scoped>
.setting-list-move,
.setting-list-enter-active,
.setting-list-leave-active {
--uno: transition-all-300;
}
.setting-list-enter-from,
.setting-list-leave-to {
--uno: opacity-0 -translate-x-30px;
}
.setting-list-leave-active {
--uno: absolute;
}
</style>

View File

@ -1,64 +0,0 @@
<script setup lang="ts">
import { resetCacheStrategyOptions, themeTabModeOptions } from '@/constants/app';
import { useThemeStore } from '@/store/modules/theme';
import { translateOptions } from '@/utils/common';
import { $t } from '@/locales';
import SettingItem from '../../../components/setting-item.vue';
defineOptions({
name: 'TabSettings'
});
const themeStore = useThemeStore();
</script>
<template>
<NDivider>{{ $t('theme.layout.tab.title') }}</NDivider>
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
<SettingItem key="0" :label="$t('theme.layout.resetCacheStrategy.title')">
<NSelect
v-model:value="themeStore.resetCacheStrategy"
:options="translateOptions(resetCacheStrategyOptions)"
size="small"
class="w-120px"
/>
</SettingItem>
<SettingItem key="1" :label="$t('theme.layout.tab.visible')">
<NSwitch v-model:value="themeStore.tab.visible" />
</SettingItem>
<SettingItem v-if="themeStore.tab.visible" key="2" :label="$t('theme.layout.tab.cache')">
<template #suffix>
<IconTooltip :desc="$t('theme.layout.tab.cacheTip')" />
</template>
<NSwitch v-model:value="themeStore.tab.cache" />
</SettingItem>
<SettingItem v-if="themeStore.tab.visible" key="3" :label="$t('theme.layout.tab.height')">
<NInputNumber v-model:value="themeStore.tab.height" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="themeStore.tab.visible" key="4" :label="$t('theme.layout.tab.mode.title')">
<NSelect
v-model:value="themeStore.tab.mode"
:options="translateOptions(themeTabModeOptions)"
size="small"
class="w-120px"
/>
</SettingItem>
</TransitionGroup>
</template>
<style scoped>
.setting-list-move,
.setting-list-enter-active,
.setting-list-leave-active {
--uno: transition-all-300;
}
.setting-list-enter-from,
.setting-list-leave-to {
--uno: opacity-0 -translate-x-30px;
}
.setting-list-leave-active {
--uno: absolute;
}
</style>

View File

@ -1,44 +0,0 @@
<script setup lang="ts">
import { themeTableSizeOptions } from '@/constants/app';
import { useThemeStore } from '@/store/modules/theme';
import { translateOptions } from '@/utils/common';
import { $t } from '@/locales';
import SettingItem from '../../../components/setting-item.vue';
defineOptions({
name: 'TableProps'
});
const themeStore = useThemeStore();
</script>
<template>
<NDivider>{{ $t('theme.tablePropsTitle') }}</NDivider>
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
<SettingItem key="0" :label="$t('theme.table.size.title')">
<NSelect
v-model:value="themeStore.table.size"
:options="translateOptions(themeTableSizeOptions)"
size="small"
class="w-120px"
/>
</SettingItem>
<SettingItem key="1" :label="$t('theme.table.bordered')">
<NSwitch v-model:value="themeStore.table.bordered" />
</SettingItem>
<SettingItem key="2" :label="$t('theme.table.bottomBordered')">
<NSwitch v-model:value="themeStore.table.bottomBordered" />
</SettingItem>
<SettingItem key="3" :label="$t('theme.table.singleColumn')">
<NSwitch v-model:value="themeStore.table.singleColumn" :checked-value="false" :unchecked-value="true" />
</SettingItem>
<SettingItem key="4" :label="$t('theme.table.singleLine')">
<NSwitch v-model:value="themeStore.table.singleLine" :checked-value="false" :unchecked-value="true" />
</SettingItem>
<SettingItem key="5" :label="$t('theme.table.striped')">
<NSwitch v-model:value="themeStore.table.striped" />
</SettingItem>
</TransitionGroup>
</template>
<style scoped></style>

View File

@ -0,0 +1,157 @@
<script setup lang="ts">
import { computed } from 'vue';
import {
resetCacheStrategyOptions,
themePageAnimationModeOptions,
themeScrollModeOptions,
themeTabModeOptions
} from '@/constants/app';
import { useThemeStore } from '@/store/modules/theme';
import { translateOptions } from '@/utils/common';
import { $t } from '@/locales';
import SettingItem from '../components/setting-item.vue';
defineOptions({
name: 'PageFun'
});
const themeStore = useThemeStore();
const layoutMode = computed(() => themeStore.layout.mode);
const isMixLayoutMode = computed(() => layoutMode.value.includes('mix'));
const isWrapperScrollMode = computed(() => themeStore.layout.scrollMode === 'wrapper');
</script>
<template>
<NDivider>{{ $t('theme.pageFunTitle') }}</NDivider>
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
<SettingItem key="0" :label="$t('theme.resetCacheStrategy.title')">
<NSelect
v-model:value="themeStore.resetCacheStrategy"
:options="translateOptions(resetCacheStrategyOptions)"
size="small"
class="w-120px"
/>
</SettingItem>
<SettingItem key="1" :label="$t('theme.scrollMode.title')">
<NSelect
v-model:value="themeStore.layout.scrollMode"
:options="translateOptions(themeScrollModeOptions)"
size="small"
class="w-120px"
/>
</SettingItem>
<SettingItem key="1-1" :label="$t('theme.page.animate')">
<NSwitch v-model:value="themeStore.page.animate" />
</SettingItem>
<SettingItem v-if="themeStore.page.animate" key="1-2" :label="$t('theme.page.mode.title')">
<NSelect
v-model:value="themeStore.page.animateMode"
:options="translateOptions(themePageAnimationModeOptions)"
size="small"
class="w-120px"
/>
</SettingItem>
<SettingItem v-if="isWrapperScrollMode" key="2" :label="$t('theme.fixedHeaderAndTab')">
<NSwitch v-model:value="themeStore.fixedHeaderAndTab" />
</SettingItem>
<SettingItem key="3" :label="$t('theme.header.height')">
<NInputNumber v-model:value="themeStore.header.height" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem key="4" :label="$t('theme.header.breadcrumb.visible')">
<NSwitch v-model:value="themeStore.header.breadcrumb.visible" />
</SettingItem>
<SettingItem v-if="themeStore.header.breadcrumb.visible" key="4-1" :label="$t('theme.header.breadcrumb.showIcon')">
<NSwitch v-model:value="themeStore.header.breadcrumb.showIcon" />
</SettingItem>
<SettingItem key="5" :label="$t('theme.tab.visible')">
<NSwitch v-model:value="themeStore.tab.visible" />
</SettingItem>
<SettingItem v-if="themeStore.tab.visible" key="5-1" :label="$t('theme.tab.cache')">
<NSwitch v-model:value="themeStore.tab.cache" />
</SettingItem>
<SettingItem v-if="themeStore.tab.visible" key="5-2" :label="$t('theme.tab.height')">
<NInputNumber v-model:value="themeStore.tab.height" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="themeStore.tab.visible" key="5-3" :label="$t('theme.tab.mode.title')">
<NSelect
v-model:value="themeStore.tab.mode"
:options="translateOptions(themeTabModeOptions)"
size="small"
class="w-120px"
/>
</SettingItem>
<SettingItem v-if="layoutMode === 'vertical'" key="6-1" :label="$t('theme.sider.width')">
<NInputNumber v-model:value="themeStore.sider.width" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="layoutMode === 'vertical'" key="6-2" :label="$t('theme.sider.collapsedWidth')">
<NInputNumber v-model:value="themeStore.sider.collapsedWidth" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="isMixLayoutMode" key="6-3" :label="$t('theme.sider.mixWidth')">
<NInputNumber v-model:value="themeStore.sider.mixWidth" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="isMixLayoutMode" key="6-4" :label="$t('theme.sider.mixCollapsedWidth')">
<NInputNumber v-model:value="themeStore.sider.mixCollapsedWidth" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="layoutMode === 'vertical-mix'" key="6-5" :label="$t('theme.sider.mixChildMenuWidth')">
<NInputNumber v-model:value="themeStore.sider.mixChildMenuWidth" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem key="7" :label="$t('theme.footer.visible')">
<NSwitch v-model:value="themeStore.footer.visible" />
</SettingItem>
<SettingItem v-if="themeStore.footer.visible && isWrapperScrollMode" key="7-1" :label="$t('theme.footer.fixed')">
<NSwitch v-model:value="themeStore.footer.fixed" />
</SettingItem>
<SettingItem v-if="themeStore.footer.visible" key="7-2" :label="$t('theme.footer.height')">
<NInputNumber v-model:value="themeStore.footer.height" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem
v-if="themeStore.footer.visible && layoutMode === 'horizontal-mix'"
key="7-3"
:label="$t('theme.footer.right')"
>
<NSwitch v-model:value="themeStore.footer.right" />
</SettingItem>
<SettingItem key="8" :label="$t('theme.watermark.visible')">
<NSwitch v-model:value="themeStore.watermark.visible" />
</SettingItem>
<SettingItem v-if="themeStore.watermark.visible" key="8-1" :label="$t('theme.watermark.enableUserName')">
<NSwitch v-model:value="themeStore.watermark.enableUserName" />
</SettingItem>
<SettingItem v-if="themeStore.watermark.visible" key="8-2" :label="$t('theme.watermark.text')">
<NInput
v-model:value="themeStore.watermark.text"
autosize
type="text"
size="small"
class="w-120px"
placeholder="SoybeanAdmin"
/>
</SettingItem>
<SettingItem key="9" :label="$t('theme.header.multilingual.visible')">
<NSwitch v-model:value="themeStore.header.multilingual.visible" />
</SettingItem>
<SettingItem key="10" :label="$t('theme.header.globalSearch.visible')">
<NSwitch v-model:value="themeStore.header.globalSearch.visible" />
</SettingItem>
</TransitionGroup>
</template>
<style scoped>
.setting-list-move,
.setting-list-enter-active,
.setting-list-leave-active {
--uno: transition-all-300;
}
.setting-list-enter-from,
.setting-list-leave-to {
--uno: opacity-0 -translate-x-30px;
}
.setting-list-leave-active {
--uno: absolute;
}
</style>

View File

@ -1,15 +0,0 @@
<script setup lang="ts">
import ThemePreset from './modules/theme-preset.vue';
defineOptions({
name: 'PresetSettings'
});
</script>
<template>
<div class="flex-col-stretch gap-16px">
<ThemePreset />
</div>
</template>
<style scoped></style>

View File

@ -1,148 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useThemeStore } from '@/store/modules/theme';
import { $t } from '@/locales';
defineOptions({
name: 'ThemePreset'
});
type ThemePreset = Pick<
App.Theme.ThemeSetting,
| 'themeScheme'
| 'grayscale'
| 'colourWeakness'
| 'recommendColor'
| 'themeColor'
| 'otherColor'
| 'isInfoFollowPrimary'
| 'resetCacheStrategy'
| 'layout'
| 'page'
| 'header'
| 'tab'
| 'fixedHeaderAndTab'
| 'sider'
| 'footer'
| 'watermark'
| 'tokens'
> & {
name: string;
desc: string;
i18nkey?: string;
version: string;
};
const presetModules = import.meta.glob('@/theme/preset/*.json', { eager: true, import: 'default' });
const themeStore = useThemeStore();
// Extract preset data
const presets = computed(() =>
Object.entries(presetModules)
.map(([path, presetData]) => {
const fileName = path.split('/').pop()?.replace('.json', '') || '';
return {
id: fileName,
...(presetData as ThemePreset)
};
})
.sort((a, b) => {
if (a.name === 'default') return -1;
if (b.name === 'default') return 1;
return a.name.localeCompare(b.name);
})
);
const getPresetName = (preset: ThemePreset): string => {
if (!preset.i18nkey) return preset.name;
try {
const key = `${preset.i18nkey}.name` as App.I18n.I18nKey;
const translated = $t(key);
return translated !== key ? translated : preset.name;
} catch {
return preset.name;
}
};
const getPresetDesc = (preset: ThemePreset): string => {
if (!preset.i18nkey) return preset.desc;
try {
const key = `${preset.i18nkey}.desc` as App.I18n.I18nKey;
const translated = $t(key);
return translated !== key ? translated : preset.desc;
} catch {
return preset.desc;
}
};
const applyPreset = ({ themeScheme, grayscale, colourWeakness, layout, watermark, ...rest }: ThemePreset): void => {
themeStore.setThemeScheme(themeScheme);
themeStore.setGrayscale(grayscale);
themeStore.setColourWeakness(colourWeakness);
themeStore.setThemeLayout(layout.mode);
themeStore.setWatermarkEnableUserName(watermark.enableUserName);
themeStore.setWatermarkEnableTime(watermark.enableTime);
Object.assign(themeStore, {
...rest,
layout: { ...themeStore.layout, scrollMode: layout.scrollMode },
page: { ...rest.page },
header: { ...rest.header },
tab: { ...rest.tab },
sider: { ...rest.sider },
footer: { ...rest.footer },
watermark: { ...watermark },
tokens: { ...rest.tokens }
});
window.$message?.success($t('theme.appearance.preset.applySuccess'));
};
</script>
<template>
<NDivider>{{ $t('theme.appearance.preset.title') }}</NDivider>
<div class="flex flex-col gap-3">
<div
v-for="preset in presets"
:key="preset.id"
class="border border-primary/10 rounded-lg border-solid bg-white/5 p-3 backdrop-blur-10 transition-all duration-300 hover:(shadow-md -translate-y-0.5)"
>
<div class="mb-2 flex items-center justify-between">
<div class="min-w-0 w-full flex flex-1 items-center justify-between gap-2">
<h5 class="m-0 truncate text-sm text-primary font-600">
{{ getPresetName(preset) }}
</h5>
<NBadge :value="`v${preset.version}`" type="info" size="small" class="flex-shrink-0 opacity-80" />
</div>
<NButton type="primary" size="tiny" ghost round class="ml-2 flex-shrink-0" @click="applyPreset(preset)">
{{ $t('theme.appearance.preset.apply') }}
</NButton>
</div>
<p class="line-clamp-2 mb-3 text-xs text-gray-500 leading-4">{{ getPresetDesc(preset) }}</p>
<div class="flex items-center justify-between">
<div class="flex gap-1">
<div
v-for="(color, key) in { primary: preset.themeColor, ...preset.otherColor }"
:key="key"
class="h-3 w-3 cursor-pointer border border-white/30 rounded-full transition-transform hover:scale-110"
:style="{ backgroundColor: color }"
:class="{ 'ring-1 ring-primary/50': key === 'primary' }"
:title="key"
/>
</div>
<div class="flex items-center gap-1">
<div class="text-lg">
{{ preset.themeScheme === 'dark' ? '🌙' : '☀️' }}
</div>
<div class="text-lg">
{{ preset.grayscale ? '🎨' : '' }}
</div>
</div>
</div>
</div>
</div>
</template>

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { useThemeStore } from '@/store/modules/theme';
import { $t } from '@/locales';
import SettingItem from '../../../components/setting-item.vue';
import SettingItem from '../components/setting-item.vue';
defineOptions({
name: 'ThemeColor'
@ -34,38 +34,33 @@ const swatches: string[] = [
</script>
<template>
<NDivider>{{ $t('theme.appearance.themeColor.title') }}</NDivider>
<NDivider>{{ $t('theme.themeColor.title') }}</NDivider>
<div class="flex-col-stretch gap-12px">
<SettingItem key="recommend-color" :label="$t('theme.appearance.recommendColor')">
<template #suffix>
<IconTooltip>
<p>
<span class="pr-12px">{{ $t('theme.appearance.recommendColorDesc') }}</span>
<br />
<NButton
text
tag="a"
href="https://uicolors.app/create"
target="_blank"
rel="noopener noreferrer"
class="text-gray"
>
https://uicolors.app/create
</NButton>
</p>
</IconTooltip>
<NTooltip placement="top-start">
<template #trigger>
<SettingItem key="recommend-color" :label="$t('theme.recommendColor')">
<NSwitch v-model:value="themeStore.recommendColor" />
</SettingItem>
</template>
<NSwitch v-model:value="themeStore.recommendColor" />
</SettingItem>
<SettingItem
v-for="(_, key) in themeStore.themeColors"
:key="key"
:label="$t(`theme.appearance.themeColor.${key}`)"
>
<p>
<span class="pr-12px">{{ $t('theme.recommendColorDesc') }}</span>
<br />
<NButton
text
tag="a"
href="https://uicolors.app/create"
target="_blank"
rel="noopener noreferrer"
class="text-gray"
>
https://uicolors.app/create
</NButton>
</p>
</NTooltip>
<SettingItem v-for="(_, key) in themeStore.themeColors" :key="key" :label="$t(`theme.themeColor.${key}`)">
<template v-if="key === 'info'" #suffix>
<NCheckbox v-model:checked="themeStore.isInfoFollowPrimary">
{{ $t('theme.appearance.themeColor.followPrimary') }}
{{ $t('theme.themeColor.followPrimary') }}
</NCheckbox>
</template>
<NColorPicker

View File

@ -82,164 +82,93 @@ const local: App.I18n.Schema = {
tokenExpired: 'The requested token has expired'
},
theme: {
themeDrawerTitle: 'Theme Configuration',
tabs: {
appearance: 'Appearance',
layout: 'Layout',
general: 'General',
preset: 'Preset'
themeSchema: {
title: 'Theme Schema',
light: 'Light',
dark: 'Dark',
auto: 'Follow System'
},
appearance: {
themeSchema: {
title: 'Theme Schema',
light: 'Light',
dark: 'Dark',
auto: 'Follow System'
},
grayscale: 'Grayscale',
colourWeakness: 'Colour Weakness',
themeColor: {
title: 'Theme Color',
primary: 'Primary',
info: 'Info',
success: 'Success',
warning: 'Warning',
error: 'Error',
followPrimary: 'Follow Primary'
},
recommendColor: 'Apply Recommended Color Algorithm',
recommendColorDesc: 'The recommended color algorithm refers to',
preset: {
title: 'Theme Presets',
apply: 'Apply',
applySuccess: 'Preset applied successfully',
default: {
name: 'Default Preset',
desc: 'Default theme preset with balanced settings'
},
dark: {
name: 'Dark Preset',
desc: 'Dark theme preset for night time usage'
},
compact: {
name: 'Compact Preset',
desc: 'Compact layout preset for small screens'
},
azir: {
name: "Azir's Preset",
desc: 'It is a cold and elegant preset that Azir likes'
}
grayscale: 'Grayscale',
colourWeakness: 'Colour Weakness',
layoutMode: {
title: 'Layout Mode',
vertical: 'Vertical Menu Mode',
horizontal: 'Horizontal Menu Mode',
'vertical-mix': 'Vertical Mix Menu Mode',
'horizontal-mix': 'Horizontal Mix menu Mode',
reverseHorizontalMix: 'Reverse first level menus and child level menus position'
},
recommendColor: 'Apply Recommended Color Algorithm',
recommendColorDesc: 'The recommended color algorithm refers to',
themeColor: {
title: 'Theme Color',
primary: 'Primary',
info: 'Info',
success: 'Success',
warning: 'Warning',
error: 'Error',
followPrimary: 'Follow Primary'
},
scrollMode: {
title: 'Scroll Mode',
wrapper: 'Wrapper',
content: 'Content'
},
page: {
animate: 'Page Animate',
mode: {
title: 'Page Animate Mode',
fade: 'Fade',
'fade-slide': 'Slide',
'fade-bottom': 'Fade Zoom',
'fade-scale': 'Fade Scale',
'zoom-fade': 'Zoom Fade',
'zoom-out': 'Zoom Out',
none: 'None'
}
},
layout: {
layoutMode: {
title: 'Layout Mode',
vertical: 'Vertical Mode',
horizontal: 'Horizontal Mode',
'vertical-mix': 'Vertical Mix Mode',
'vertical-hybrid-header-first': 'Left Hybrid Header-First',
'top-hybrid-sidebar-first': 'Top-Hybrid Sidebar-First',
'top-hybrid-header-first': 'Top-Hybrid Header-First',
vertical_detail: 'Vertical menu layout, with the menu on the left and content on the right.',
'vertical-mix_detail':
'Vertical mix-menu layout, with the primary menu on the dark left side and the secondary menu on the lighter left side.',
'vertical-hybrid-header-first_detail':
'Left hybrid layout, with the primary menu at the top, the secondary menu on the dark left side, and the tertiary menu on the lighter left side.',
horizontal_detail: 'Horizontal menu layout, with the menu at the top and content below.',
'top-hybrid-sidebar-first_detail':
'Top hybrid layout, with the primary menu on the left and the secondary menu at the top.',
'top-hybrid-header-first_detail':
'Top hybrid layout, with the primary menu at the top and the secondary menu on the left.'
},
tab: {
title: 'Tab Settings',
visible: 'Tab Visible',
cache: 'Tag Bar Info Cache',
cacheTip: 'One-click to open/close global keepalive',
height: 'Tab Height',
mode: {
title: 'Tab Mode',
chrome: 'Chrome',
button: 'Button'
}
},
header: {
title: 'Header Settings',
height: 'Header Height',
breadcrumb: {
visible: 'Breadcrumb Visible',
showIcon: 'Breadcrumb Icon Visible'
}
},
sider: {
title: 'Sider Settings',
inverted: 'Dark Sider',
width: 'Sider Width',
collapsedWidth: 'Sider Collapsed Width',
mixWidth: 'Mix Sider Width',
mixCollapsedWidth: 'Mix Sider Collapse Width',
mixChildMenuWidth: 'Mix Child Menu Width'
},
footer: {
title: 'Footer Settings',
visible: 'Footer Visible',
fixed: 'Fixed Footer',
height: 'Footer Height',
right: 'Right Footer'
},
content: {
title: 'Content Area Settings',
scrollMode: {
title: 'Scroll Mode',
tip: 'The theme scroll only scrolls the main part, the outer scroll can carry the header and footer together',
wrapper: 'Wrapper',
content: 'Content'
},
page: {
animate: 'Page Animate',
mode: {
title: 'Page Animate Mode',
fade: 'Fade',
'fade-slide': 'Slide',
'fade-bottom': 'Fade Zoom',
'fade-scale': 'Fade Scale',
'zoom-fade': 'Zoom Fade',
'zoom-out': 'Zoom Out',
none: 'None'
}
},
fixedHeaderAndTab: 'Fixed Header And Tab'
},
resetCacheStrategy: {
title: 'Reset Cache Strategy',
close: 'Close Page',
refresh: 'Refresh Page'
}
},
general: {
title: 'General Settings',
watermark: {
title: 'Watermark Settings',
visible: 'Watermark Full Screen Visible',
text: 'Custom Watermark Text',
enableUserName: 'Enable User Name Watermark',
enableTime: 'Show Current Time',
timeFormat: 'Time Format'
fixedHeaderAndTab: 'Fixed Header And Tab',
header: {
height: 'Header Height',
breadcrumb: {
visible: 'Breadcrumb Visible',
showIcon: 'Breadcrumb Icon Visible'
},
multilingual: {
title: 'Multilingual Settings',
visible: 'Display multilingual button'
},
globalSearch: {
title: 'Global Search Settings',
visible: 'Display GlobalSearch button'
}
},
configOperation: {
copyConfig: 'Copy Config',
copySuccessMsg: 'Copy Success, Please replace the variable "themeSettings" in "src/theme/settings.ts"',
resetConfig: 'Reset Config',
resetSuccessMsg: 'Reset Success'
tab: {
visible: 'Tab Visible',
cache: 'Tag Bar Info Cache',
height: 'Tab Height',
mode: {
title: 'Tab Mode',
chrome: 'Chrome',
button: 'Button'
}
},
sider: {
inverted: 'Dark Sider',
width: 'Sider Width',
collapsedWidth: 'Sider Collapsed Width',
mixWidth: 'Mix Sider Width',
mixCollapsedWidth: 'Mix Sider Collapse Width',
mixChildMenuWidth: 'Mix Child Menu Width'
},
footer: {
visible: 'Footer Visible',
fixed: 'Fixed Footer',
height: 'Footer Height',
right: 'Right Footer'
},
watermark: {
visible: 'Watermark Full Screen Visible',
text: 'Watermark Text',
enableUserName: 'Enable User Name Watermark'
},
tablePropsTitle: 'Table Props',
table: {
@ -254,6 +183,19 @@ const local: App.I18n.Schema = {
singleColumn: 'Single Column',
singleLine: 'Single Line',
striped: 'Striped'
},
themeDrawerTitle: 'Theme Configuration',
pageFunTitle: 'Page Function',
resetCacheStrategy: {
title: 'Reset Cache Strategy',
close: 'Close Page',
refresh: 'Refresh Page'
},
configOperation: {
copyConfig: 'Copy Config',
copySuccessMsg: 'Copy Success, Please replace the variable "themeSettings" in "src/theme/settings.ts"',
resetConfig: 'Reset Config',
resetSuccessMsg: 'Reset Success'
}
},
route: {
@ -263,17 +205,49 @@ const local: App.I18n.Schema = {
500: 'Server Error',
'iframe-page': 'Iframe',
home: 'Home',
monitor: 'Monitor',
system: 'System Management',
system_menu: 'Menu Management',
tool: 'System Tools',
tool_gen: 'Code Generation',
system_user: 'User Management',
system_dict: 'Dict Management',
system_tenant: 'Tenant Management',
'system_tenant-package': 'Tenant Package Management',
system_config: 'Config Management',
system_dept: 'Dept Management',
system_post: 'Post Management',
monitor: 'Monitor Management',
monitor_logininfor: 'Login Log',
monitor_operlog: 'Operate Log',
system_client: 'Client Management',
system_notice: 'Notice Management',
'social-callback': 'Social Callback',
system_oss: 'File Management',
'system_oss-config': 'OSS Config',
monitor_cache: 'Cache Monitor',
monitor_online: 'Online User',
'user-center': 'User Center',
system_role: 'Role Management',
demo: 'Demo',
demo_demo: 'Demo Table',
demo_tree: 'Demo Tree',
workflow: 'Workflow',
workflow_category: 'Workflow Category',
exception: 'Exception',
exception_403: '403',
exception_404: '404',
exception_500: '500'
exception_500: '500',
workflow_design: 'Process Design',
workflow_spel: 'Process Spel',
'workflow_process-definition': 'Process Definition',
'workflow_process-instance': 'Process Instance',
workflow_task: 'Task',
'workflow_task_all-task-waiting': 'All Task Waiting',
workflow_leave: 'Leave Apply',
'workflow_task_my-document': 'My Document',
'workflow_task_task-waiting': 'My Task Waiting',
'workflow_task_task-finish': 'My Task Finish',
'workflow_task_task-copy': 'My Task Copy'
},
menu: {
system_tenant: 'Tenant Management',

View File

@ -82,161 +82,93 @@ const local: App.I18n.Schema = {
tokenExpired: 'token已过期'
},
theme: {
themeDrawerTitle: '主题配置',
tabs: {
appearance: '外观',
layout: '布局',
general: '通用',
preset: '预设'
themeSchema: {
title: '主题模式',
light: '亮色模式',
dark: '暗黑模式',
auto: '跟随系统'
},
appearance: {
themeSchema: {
title: '主题模式',
light: '亮色模式',
dark: '暗黑模式',
auto: '跟随系统'
},
grayscale: '灰色模式',
colourWeakness: '色弱模式',
themeColor: {
title: '主题颜色',
primary: '主色',
info: '信息色',
success: '成功色',
warning: '警告色',
error: '错误色',
followPrimary: '跟随主色'
},
recommendColor: '应用推荐算法的颜色',
recommendColorDesc: '推荐颜色的算法参照',
preset: {
title: '主题预设',
apply: '应用',
applySuccess: '预设应用成功',
default: {
name: '默认预设',
desc: 'Soybean 默认主题预设'
},
dark: {
name: '暗色预设',
desc: '适用于夜间使用的暗色主题预设'
},
compact: {
name: '紧凑型',
desc: '适用于小屏幕的紧凑布局预设'
},
azir: {
name: 'Azir的预设',
desc: '是 Azir 比较喜欢的莫兰迪色系冷淡风'
}
grayscale: '灰色模式',
colourWeakness: '色弱模式',
layoutMode: {
title: '布局模式',
vertical: '左侧菜单模式',
'vertical-mix': '左侧菜单混合模式',
horizontal: '顶部菜单模式',
'horizontal-mix': '顶部菜单混合模式',
reverseHorizontalMix: '一级菜单与子级菜单位置反转'
},
recommendColor: '应用推荐算法的颜色',
recommendColorDesc: '推荐颜色的算法参照',
themeColor: {
title: '主题颜色',
primary: '色',
info: '信息色',
success: '成功色',
warning: '警告色',
error: '错误色',
followPrimary: '跟随主色'
},
scrollMode: {
title: '滚动模式',
wrapper: '外层滚动',
content: '主体滚动'
},
page: {
animate: '页面切换动画',
mode: {
title: '页面切换动画类型',
'fade-slide': '滑动',
fade: '淡入淡出',
'fade-bottom': '底部消退',
'fade-scale': '缩放消退',
'zoom-fade': '渐变',
'zoom-out': '闪现',
none: '无'
}
},
layout: {
layoutMode: {
title: '布局模式',
vertical: '左侧菜单模式',
'vertical-mix': '左侧菜单混合模式',
'vertical-hybrid-header-first': '左侧混合-顶部优先',
horizontal: '顶部菜单模式',
'top-hybrid-sidebar-first': '顶部混合-侧边优先',
'top-hybrid-header-first': '顶部混合-顶部优先',
vertical_detail: '左侧菜单布局,菜单在左,内容在右。',
'vertical-mix_detail': '左侧双菜单布局,一级菜单在左侧深色区域,二级菜单在左侧浅色区域。',
'vertical-hybrid-header-first_detail':
'左侧混合布局,一级菜单在顶部,二级菜单在左侧深色区域,三级菜单在左侧浅色区域。',
horizontal_detail: '顶部菜单布局,菜单在顶部,内容在下方。',
'top-hybrid-sidebar-first_detail': '顶部混合布局,一级菜单在左侧,二级菜单在顶部。',
'top-hybrid-header-first_detail': '顶部混合布局,一级菜单在顶部,二级菜单在左侧。'
},
tab: {
title: '标签栏设置',
visible: '显示标签栏',
cache: '标签栏信息缓存',
cacheTip: '一键开启/关闭全局 keepalive',
height: '标签栏高度',
mode: {
title: '标签栏风格',
chrome: '谷歌风格',
button: '按钮风格'
}
},
header: {
title: '头部设置',
height: '头部高度',
breadcrumb: {
visible: '显示面包屑',
showIcon: '显示面包屑图标'
}
},
sider: {
title: '侧边栏设置',
inverted: '深色侧边栏',
width: '侧边栏宽度',
collapsedWidth: '侧边栏折叠宽度',
mixWidth: '混合布局侧边栏宽度',
mixCollapsedWidth: '混合布局侧边栏折叠宽度',
mixChildMenuWidth: '混合布局子菜单宽度'
},
footer: {
title: '底部设置',
visible: '显示底部',
fixed: '固定底部',
height: '底部高度',
right: '底部局右'
},
content: {
title: '内容区域设置',
scrollMode: {
title: '滚动模式',
tip: '主题滚动仅 main 部分滚动,外层滚动可携带头部底部一起滚动',
wrapper: '外层滚动',
content: '主体滚动'
},
page: {
animate: '页面切换动画',
mode: {
title: '页面切换动画类型',
'fade-slide': '滑动',
fade: '淡入淡出',
'fade-bottom': '底部消退',
'fade-scale': '缩放消退',
'zoom-fade': '渐变',
'zoom-out': '闪现',
none: '无'
}
},
fixedHeaderAndTab: '固定头部和标签栏'
},
resetCacheStrategy: {
title: '重置缓存策略',
close: '关闭页面',
refresh: '刷新页面'
}
},
general: {
title: '通用设置',
watermark: {
title: '水印设置',
visible: '显示全屏水印',
text: '自定义水印文本',
enableUserName: '启用用户名水印',
enableTime: '显示当前时间',
timeFormat: '时间格式'
fixedHeaderAndTab: '固定头部和标签栏',
header: {
height: '头部高度',
breadcrumb: {
visible: '显示面包屑',
showIcon: '显示面包屑图标'
},
multilingual: {
title: '多语言设置',
visible: '显示多语言按钮'
},
globalSearch: {
title: '全局搜索设置',
visible: '显示全局搜索按钮'
}
},
configOperation: {
copyConfig: '复制配置',
copySuccessMsg: '复制成功,请替换 src/theme/settings.ts 中的变量 themeSettings',
resetConfig: '重置配置',
resetSuccessMsg: '重置成功'
tab: {
visible: '显示标签栏',
cache: '标签栏信息缓存',
height: '标签栏高度',
mode: {
title: '标签栏风格',
chrome: '谷歌风格',
button: '按钮风格'
}
},
sider: {
inverted: '深色侧边栏',
width: '侧边栏宽度',
collapsedWidth: '侧边栏折叠宽度',
mixWidth: '混合布局侧边栏宽度',
mixCollapsedWidth: '混合布局侧边栏折叠宽度',
mixChildMenuWidth: '混合布局子菜单宽度'
},
footer: {
visible: '显示底部',
fixed: '固定底部',
height: '底部高度',
right: '底部局右'
},
watermark: {
visible: '显示全屏水印',
text: '水印文本',
enableUserName: '启用用户名水印'
},
tablePropsTitle: '表格配置',
table: {
@ -251,6 +183,19 @@ const local: App.I18n.Schema = {
singleColumn: '设定行的分割线',
singleLine: '设定列的分割线',
striped: '斑马线条纹'
},
themeDrawerTitle: '主题配置',
pageFunTitle: '页面功能',
resetCacheStrategy: {
title: '重置缓存策略',
close: '关闭页面',
refresh: '刷新页面'
},
configOperation: {
copyConfig: '复制配置',
copySuccessMsg: '复制成功,请替换 src/theme/settings.ts 中的变量 themeSettings',
resetConfig: '重置配置',
resetSuccessMsg: '重置成功'
}
},
route: {
@ -260,17 +205,49 @@ const local: App.I18n.Schema = {
500: '服务器错误',
'iframe-page': '外链页面',
home: '首页',
system: '系统管理',
system_menu: '菜单管理',
tool: '系统工具',
tool_gen: '代码生成',
system_user: '用户管理',
system_dict: '字典管理',
system_tenant: '租户管理',
'system_tenant-package': '租户套餐',
system_config: '参数设置',
system_dept: '部门管理',
system_post: '岗位管理',
monitor: '系统监控',
monitor_logininfor: '登录日志',
monitor_operlog: '操作日志',
system_client: '客户端管理',
system_notice: '通知公告',
'social-callback': '单点登录回调',
system_oss: '文件管理',
'system_oss-config': 'OSS 配置',
monitor_cache: '缓存监控',
monitor_online: '在线用户',
'user-center': '个人中心',
system_role: '角色管理',
demo: '测试',
demo_demo: '测试单表',
demo_tree: '测试树表',
workflow: '流程管理',
workflow_category: '流程分类',
exception: '异常页',
exception_403: '403',
exception_404: '404',
exception_500: '500'
exception_500: '500',
workflow_design: '流程设计',
workflow_spel: '流程表达式',
'workflow_process-definition': '流程定义',
'workflow_process-instance': '流程实例',
workflow_task: '任务',
'workflow_task_all-task-waiting': '待办任务',
workflow_leave: '请假申请',
'workflow_task_my-document': '我发起的',
'workflow_task_task-waiting': '我的待办',
'workflow_task_task-finish': '我的已办',
'workflow_task_task-copy': '我的抄送'
},
menu: {
system_tenant: '租户管理',

View File

@ -26,4 +26,32 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
demo_tree: () => import("@/views/demo/tree/index.vue"),
home: () => import("@/views/home/index.vue"),
monitor_cache: () => import("@/views/monitor/cache/index.vue"),
monitor_logininfor: () => import("@/views/monitor/logininfor/index.vue"),
monitor_online: () => import("@/views/monitor/online/index.vue"),
monitor_operlog: () => import("@/views/monitor/operlog/index.vue"),
system_client: () => import("@/views/system/client/index.vue"),
system_config: () => import("@/views/system/config/index.vue"),
system_dept: () => import("@/views/system/dept/index.vue"),
system_dict: () => import("@/views/system/dict/index.vue"),
system_menu: () => import("@/views/system/menu/index.vue"),
system_notice: () => import("@/views/system/notice/index.vue"),
"system_oss-config": () => import("@/views/system/oss-config/index.vue"),
system_oss: () => import("@/views/system/oss/index.vue"),
system_post: () => import("@/views/system/post/index.vue"),
system_role: () => import("@/views/system/role/index.vue"),
"system_tenant-package": () => import("@/views/system/tenant-package/index.vue"),
system_tenant: () => import("@/views/system/tenant/index.vue"),
system_user: () => import("@/views/system/user/index.vue"),
tool_gen: () => import("@/views/tool/gen/index.vue"),
workflow_category: () => import("@/views/workflow/category/index.vue"),
workflow_design: () => import("@/views/workflow/design/index.vue"),
workflow_leave: () => import("@/views/workflow/leave/index.vue"),
"workflow_process-definition": () => import("@/views/workflow/process-definition/index.vue"),
"workflow_process-instance": () => import("@/views/workflow/process-instance/index.vue"),
workflow_spel: () => import("@/views/workflow/spel/index.vue"),
"workflow_task_all-task-waiting": () => import("@/views/workflow/task/all-task-waiting/index.vue"),
"workflow_task_my-document": () => import("@/views/workflow/task/my-document/index.vue"),
"workflow_task_task-copy": () => import("@/views/workflow/task/task-copy/index.vue"),
"workflow_task_task-finish": () => import("@/views/workflow/task/task-finish/index.vue"),
"workflow_task_task-waiting": () => import("@/views/workflow/task/task-waiting/index.vue"),
};

View File

@ -121,6 +121,33 @@ export const generatedRoutes: GeneratedRoute[] = [
title: 'monitor_cache',
i18nKey: 'route.monitor_cache'
}
},
{
name: 'monitor_logininfor',
path: '/monitor/logininfor',
component: 'view.monitor_logininfor',
meta: {
title: 'monitor_logininfor',
i18nKey: 'route.monitor_logininfor'
}
},
{
name: 'monitor_online',
path: '/monitor/online',
component: 'view.monitor_online',
meta: {
title: 'monitor_online',
i18nKey: 'route.monitor_online'
}
},
{
name: 'monitor_operlog',
path: '/monitor/operlog',
component: 'view.monitor_operlog',
meta: {
title: 'monitor_operlog',
i18nKey: 'route.monitor_operlog'
}
}
]
},
@ -135,6 +162,165 @@ export const generatedRoutes: GeneratedRoute[] = [
hideInMenu: true
}
},
{
name: 'system',
path: '/system',
component: 'layout.base',
meta: {
title: 'system',
i18nKey: 'route.system',
localIcon: 'menu-system',
order: 1
},
children: [
{
name: 'system_client',
path: '/system/client',
component: 'view.system_client',
meta: {
title: 'system_client',
i18nKey: 'route.system_client'
}
},
{
name: 'system_config',
path: '/system/config',
component: 'view.system_config',
meta: {
title: 'system_config',
i18nKey: 'route.system_config'
}
},
{
name: 'system_dept',
path: '/system/dept',
component: 'view.system_dept',
meta: {
title: 'system_dept',
i18nKey: 'route.system_dept'
}
},
{
name: 'system_dict',
path: '/system/dict',
component: 'view.system_dict',
meta: {
title: 'system_dict',
i18nKey: 'route.system_dict'
}
},
{
name: 'system_menu',
path: '/system/menu',
component: 'view.system_menu',
meta: {
title: 'system_menu',
i18nKey: 'route.system_menu',
localIcon: 'menu-tree-table',
order: 3
}
},
{
name: 'system_notice',
path: '/system/notice',
component: 'view.system_notice',
meta: {
title: 'system_notice',
i18nKey: 'route.system_notice'
}
},
{
name: 'system_oss',
path: '/system/oss',
component: 'view.system_oss',
meta: {
title: 'system_oss',
i18nKey: 'route.system_oss'
}
},
{
name: 'system_oss-config',
path: '/system/oss-config',
component: 'view.system_oss-config',
meta: {
title: 'system_oss-config',
i18nKey: 'route.system_oss-config',
constant: true,
hideInMenu: true,
icon: 'hugeicons:configuration-01'
}
},
{
name: 'system_post',
path: '/system/post',
component: 'view.system_post',
meta: {
title: 'system_post',
i18nKey: 'route.system_post'
}
},
{
name: 'system_role',
path: '/system/role',
component: 'view.system_role',
meta: {
title: 'system_role',
i18nKey: 'route.system_role'
}
},
{
name: 'system_tenant',
path: '/system/tenant',
component: 'view.system_tenant',
meta: {
title: 'system_tenant',
i18nKey: 'route.system_tenant'
}
},
{
name: 'system_tenant-package',
path: '/system/tenant-package',
component: 'view.system_tenant-package',
meta: {
title: 'system_tenant-package',
i18nKey: 'route.system_tenant-package'
}
},
{
name: 'system_user',
path: '/system/user',
component: 'view.system_user',
meta: {
title: 'system_user',
i18nKey: 'route.system_user'
}
}
]
},
{
name: 'tool',
path: '/tool',
component: 'layout.base',
meta: {
title: 'tool',
i18nKey: 'route.tool',
localIcon: 'menu-tool',
order: 4
},
children: [
{
name: 'tool_gen',
path: '/tool/gen',
component: 'view.tool_gen',
meta: {
title: 'tool_gen',
i18nKey: 'route.tool_gen',
localIcon: 'menu-code',
order: 2
}
}
]
},
{
name: 'user-center',
path: '/user-center',
@ -145,5 +331,125 @@ export const generatedRoutes: GeneratedRoute[] = [
icon: 'material-symbols:account-circle-full',
hideInMenu: true
}
},
{
name: 'workflow',
path: '/workflow',
component: 'layout.base',
meta: {
title: 'workflow',
i18nKey: 'route.workflow'
},
children: [
{
name: 'workflow_category',
path: '/workflow/category',
component: 'view.workflow_category',
meta: {
title: 'workflow_category',
i18nKey: 'route.workflow_category'
}
},
{
name: 'workflow_design',
path: '/workflow/design',
component: 'view.workflow_design',
meta: {
title: 'workflow_design',
i18nKey: 'route.workflow_design'
}
},
{
name: 'workflow_leave',
path: '/workflow/leave',
component: 'view.workflow_leave',
meta: {
title: 'workflow_leave',
i18nKey: 'route.workflow_leave'
}
},
{
name: 'workflow_process-definition',
path: '/workflow/process-definition',
component: 'view.workflow_process-definition',
meta: {
title: 'workflow_process-definition',
i18nKey: 'route.workflow_process-definition'
}
},
{
name: 'workflow_process-instance',
path: '/workflow/process-instance',
component: 'view.workflow_process-instance',
meta: {
title: 'workflow_process-instance',
i18nKey: 'route.workflow_process-instance'
}
},
{
name: 'workflow_spel',
path: '/workflow/spel',
component: 'view.workflow_spel',
meta: {
title: 'workflow_spel',
i18nKey: 'route.workflow_spel'
}
},
{
name: 'workflow_task',
path: '/workflow/task',
meta: {
title: 'workflow_task',
i18nKey: 'route.workflow_task'
},
children: [
{
name: 'workflow_task_all-task-waiting',
path: '/workflow/task/all-task-waiting',
component: 'view.workflow_task_all-task-waiting',
meta: {
title: 'workflow_task_all-task-waiting',
i18nKey: 'route.workflow_task_all-task-waiting'
}
},
{
name: 'workflow_task_my-document',
path: '/workflow/task/my-document',
component: 'view.workflow_task_my-document',
meta: {
title: 'workflow_task_my-document',
i18nKey: 'route.workflow_task_my-document'
}
},
{
name: 'workflow_task_task-copy',
path: '/workflow/task/task-copy',
component: 'view.workflow_task_task-copy',
meta: {
title: 'workflow_task_task-copy',
i18nKey: 'route.workflow_task_task-copy'
}
},
{
name: 'workflow_task_task-finish',
path: '/workflow/task/task-finish',
component: 'view.workflow_task_task-finish',
meta: {
title: 'workflow_task_task-finish',
i18nKey: 'route.workflow_task_task-finish'
}
},
{
name: 'workflow_task_task-waiting',
path: '/workflow/task/task-waiting',
component: 'view.workflow_task_task-waiting',
meta: {
title: 'workflow_task_task-waiting',
i18nKey: 'route.workflow_task_task-waiting'
}
}
]
}
]
}
];

View File

@ -178,8 +178,40 @@ const routeMap: RouteMap = {
"login": "/login/:module(pwd-login|code-login|register|reset-pwd|bind-wechat)?",
"monitor": "/monitor",
"monitor_cache": "/monitor/cache",
"monitor_logininfor": "/monitor/logininfor",
"monitor_online": "/monitor/online",
"monitor_operlog": "/monitor/operlog",
"social-callback": "/social-callback",
"user-center": "/user-center"
"system": "/system",
"system_client": "/system/client",
"system_config": "/system/config",
"system_dept": "/system/dept",
"system_dict": "/system/dict",
"system_menu": "/system/menu",
"system_notice": "/system/notice",
"system_oss": "/system/oss",
"system_oss-config": "/system/oss-config",
"system_post": "/system/post",
"system_role": "/system/role",
"system_tenant": "/system/tenant",
"system_tenant-package": "/system/tenant-package",
"system_user": "/system/user",
"tool": "/tool",
"tool_gen": "/tool/gen",
"user-center": "/user-center",
"workflow": "/workflow",
"workflow_category": "/workflow/category",
"workflow_design": "/workflow/design",
"workflow_leave": "/workflow/leave",
"workflow_process-definition": "/workflow/process-definition",
"workflow_process-instance": "/workflow/process-instance",
"workflow_spel": "/workflow/spel",
"workflow_task": "/workflow/task",
"workflow_task_all-task-waiting": "/workflow/task/all-task-waiting",
"workflow_task_my-document": "/workflow/task/my-document",
"workflow_task_task-copy": "/workflow/task/task-copy",
"workflow_task_task-finish": "/workflow/task/task-finish",
"workflow_task_task-waiting": "/workflow/task/task-waiting"
};
/**

View File

@ -0,0 +1,53 @@
import { request } from '@/service/request';
/** 获取测试树列表 */
export function fetchGetCategoryList(params?: Api.Workflow.WorkflowCategorySearchParams) {
return request<Api.Workflow.WorkflowCategoryList>({
url: '/workflow/category/list',
method: 'get',
params
});
}
/** 新增测试树 */
export function fetchCreateCategory(data: Api.Workflow.WorkflowCategoryOperateParams) {
return request<boolean>({
url: '/workflow/category',
method: 'post',
data
});
}
/** 修改测试树 */
export function fetchUpdateCategory(data: Api.Workflow.WorkflowCategoryOperateParams) {
return request<boolean>({
url: '/workflow/category',
method: 'put',
data
});
}
/** 删除分类 */
export function fetchDeleteCategory(id: CommonType.IdType) {
return request<boolean>({
url: `/workflow/category/${id}`,
method: 'delete'
});
}
/** 导出工作流分类 */
export function fetchExportCategory(params?: Api.Workflow.WorkflowCategorySearchParams) {
return request<boolean>({
url: '/workflow/category/export',
method: 'post',
params
});
}
/** 获取分类树 */
export function fetchGetCategoryTree() {
return request<Api.Common.CommonTreeRecord>({
url: '/workflow/category/categoryTree',
method: 'get'
});
}

View File

@ -0,0 +1,72 @@
import { request } from '@/service/request';
/** 获取流程定义列表 */
export function fetchGetDefinitionList(params?: Api.Workflow.DefinitionSearchParams) {
return request<Api.Workflow.DefinitionList>({
url: '/workflow/definition/list',
method: 'get',
params
});
}
/** 获取未发布流程定义列表 */
export function fetchGetUnPublishDefinitionList(params?: Api.Workflow.DefinitionSearchParams) {
return request<Api.Workflow.DefinitionList>({
url: '/workflow/definition/unPublishList',
method: 'get',
params
});
}
/** 新增流程定义 */
export function fetchCreateDefinition(data: Api.Workflow.DefinitionOperateParams) {
return request<boolean>({
url: '/workflow/definition',
method: 'post',
data
});
}
/** 修改流程定义 */
export function fetchUpdateDefinition(data: Api.Workflow.DefinitionOperateParams) {
return request<boolean>({
url: '/workflow/definition',
method: 'put',
data
});
}
/** 批量删除流程定义 */
export function fetchBatchDeleteDefinition(ids: CommonType.IdType[]) {
return request<boolean>({
url: `/workflow/definition/${ids.join(',')}`,
method: 'delete'
});
}
/** 激活/挂起流程定义 */
export function fetchActiveDefinition(id: CommonType.IdType, active: boolean) {
return request<boolean>({
url: `/workflow/definition/active/${id}`,
method: 'put',
params: {
active
}
});
}
/** 发布流程定义 */
export function fetchPublishDefinition(id: CommonType.IdType) {
return request<boolean>({
url: `/workflow/definition/publish/${id}`,
method: 'put'
});
}
/** 复制流程定义 */
export function fetchCopyDefinition(id: CommonType.IdType) {
return request<boolean>({
url: `/workflow/definition/copy/${id}`,
method: 'post'
});
}

View File

@ -0,0 +1,5 @@
export * from './category';
export * from './leave';
export * from './instance';
export * from './definition';
export * from './task';

View File

@ -0,0 +1,78 @@
import { request } from '@/service/request';
/** 查询正在运行的流程实例列表 */
export function fetchGetRunningInstanceList(params: Api.Workflow.InstanceSearchParams) {
return request<Api.Workflow.InstanceList>({
url: '/workflow/instance/pageByRunning',
method: 'get',
params
});
}
/** 查询已结束的流程实例列表 */
export function fetchGetFinishedInstanceList(params: Api.Workflow.InstanceSearchParams) {
return request<Api.Workflow.InstanceList>({
url: '/workflow/instance/pageByFinish',
method: 'get',
params
});
}
/** 按照实例id删除流程实例 */
export function fetchBatchDeleteInstance(instanceIds: CommonType.IdType[]) {
return request<boolean>({
url: `/workflow/instance/deleteByInstanceIds/${instanceIds.join(',')}`,
method: 'delete'
});
}
/** 流程作废操作 */
export function fetchFlowInvalidOperate(data: Api.Workflow.FlowInvalidOperateParams) {
return request<boolean>({
url: '/workflow/instance/invalid',
method: 'post',
data
});
}
/** 获取流程记录 */
export function fetchGetFlowHisTaskList(businessId: CommonType.IdType) {
return request<Api.Workflow.InstanceIdWithHisTask>({
url: `/workflow/instance/flowHisTaskList/${businessId}`,
method: 'get'
});
}
/** 流程作废操作 */
export function fetchCancelProcessApply(data: Api.Workflow.CancelProcessApplyParams) {
return request<boolean>({
url: '/workflow/instance/cancelProcessApply',
method: 'put',
data
});
}
/** 获取我的待办 */
export function fetchGetMyDocument(data: Api.Workflow.InstanceSearchParams) {
return request<Api.Workflow.InstanceList>({
url: '/workflow/instance/pageByCurrent',
method: 'get',
params: data
});
}
/** 更新流程变量信息 */
export function fetchUpdateInstanceVariable(data: Api.Workflow.InstanceVariableOperateParams) {
return request<boolean>({
url: '/workflow/instance/updateVariable',
method: 'put',
data
});
}
/** 获取流程变量信息 */
export function fetchGetInstanceVariable(instanceId: CommonType.IdType) {
return request<Api.Workflow.InstanceVariableInfo>({
url: `/workflow/instance/instanceVariable/${instanceId}`,
method: 'get'
});
}

View File

@ -0,0 +1,51 @@
import { request } from '@/service/request';
/** 获取请假申请列表 */
export function fetchGetLeaveList(params?: Api.Workflow.LeaveSearchParams) {
return request<Api.Workflow.LeaveList>({
url: '/workflow/leave/list',
method: 'get',
params
});
}
/** 获取请假申请详情 */
export function fetchGetLeaveDetail(id: CommonType.IdType) {
return request<Api.Workflow.LeaveDetail>({
url: `/workflow/leave/${id}`,
method: 'get'
});
}
/** 新增请假申请 */
export function fetchCreateLeave(data: Api.Workflow.LeaveOperateParams) {
return request<Api.Workflow.Leave>({
url: '/workflow/leave',
method: 'post',
data
});
}
/** 修改请假申请 */
export function fetchUpdateLeave(data: Api.Workflow.LeaveOperateParams) {
return request<Api.Workflow.Leave>({
url: '/workflow/leave',
method: 'put',
data
});
}
/** 批量删除请假申请 */
export function fetchBatchDeleteLeave(ids: CommonType.IdType[]) {
return request<boolean>({
url: `/workflow/leave/${ids.join(',')}`,
method: 'delete'
});
}
/** 撤销请假申请 */
export function fetchCancelLeave(id: CommonType.IdType) {
return request<boolean>({
url: `/workflow/leave/cancel/${id}`,
method: 'put'
});
}

View File

@ -0,0 +1,36 @@
import { request } from '@/service/request';
/** 获取流程spel达式定义列表 */
export function fetchGetSpelList(params?: Api.Workflow.SpelSearchParams) {
return request<Api.Workflow.SpelList>({
url: '/workflow/spel/list',
method: 'get',
params
});
}
/** 新增流程spel达式定义 */
export function fetchCreateSpel(data: Api.Workflow.SpelOperateParams) {
return request<boolean>({
url: '/workflow/spel',
method: 'post',
data
});
}
/** 修改流程spel达式定义 */
export function fetchUpdateSpel(data: Api.Workflow.SpelOperateParams) {
return request<boolean>({
url: '/workflow/spel',
method: 'put',
data
});
}
/** 批量删除流程spel达式定义 */
export function fetchBatchDeleteSpel(ids: CommonType.IdType[]) {
return request<boolean>({
url: `/workflow/spel/${ids.join(',')}`,
method: 'delete'
});
}

View File

@ -0,0 +1,142 @@
import { request } from '@/service/request';
/** 启动任务 */
export function fetchStartWorkflow(data: Api.Workflow.StartWorkflowOperateParams) {
return request<Api.Workflow.StartWorkflowResult>({
url: '/workflow/task/startWorkFlow',
method: 'post',
data
});
}
/** 获取任务 */
export function fetchGetTask(taskId: CommonType.IdType) {
return request<Api.Workflow.Task>({
url: `/workflow/task/getTask/${taskId}`,
method: 'get'
});
}
/** 获取任务下一个节点 */
export function fetchGetkNextNode(data: Api.Workflow.TaskNextNodeSearchParams) {
return request<Api.Workflow.FlowNodeList>({
url: '/workflow/task/getNextNodeList',
method: 'post',
data
});
}
/** 完成任务 */
export function fetchCompleteTask(data: Api.Workflow.CompleteTaskOperateParams) {
return request<boolean>({
url: '/workflow/task/completeTask',
method: 'post',
data
});
}
/** 获取所有待办任务 */
export function fetchGetAllWaitingTask(data: Api.Workflow.TaskSearchParams) {
return request<Api.Workflow.TaskList>({
url: '/workflow/task/pageByAllTaskWait',
method: 'get',
params: data
});
}
/** 获取所有已办任务 */
export function fetchGetAllFinishedTask(data: Api.Workflow.TaskSearchParams) {
return request<Api.Workflow.HisTaskList>({
url: '/workflow/task/pageByAllTaskFinish',
method: 'get',
params: data
});
}
/** 任务操作 */
export function fetchTaskOperate(data: Api.Workflow.TaskOperateParams, operateType: Api.Workflow.TaskOperateType) {
return request<boolean>({
url: `/workflow/task/taskOperation/${operateType}`,
method: 'post',
data
});
}
/** 终止任务 */
export function fetchTerminateTask(data: Api.Workflow.TerminateTaskOperateParams) {
return request<boolean>({
url: '/workflow/task/terminationTask',
method: 'post',
data
});
}
/** 获取当前任务所有人员 */
export function fetchGetCurrentTaskAllUser(taskId: CommonType.IdType) {
return request<Api.System.User[]>({
url: `/workflow/task/currentTaskAllUser/${taskId}`,
method: 'get'
});
}
/** 获取我的待办 */
export function fetchGetTaskWaitList(data: Api.Workflow.TaskSearchParams) {
return request<Api.Workflow.TaskList>({
url: '/workflow/task/pageByTaskWait',
method: 'get',
params: data
});
}
/** 获取可驳回节点 */
export function fetchGetBackNode(definitionId: CommonType.IdType, nodeCode: string) {
return request<Api.Workflow.FlowNodeList>({
url: `/workflow/task/getBackTaskNode/${definitionId}/${nodeCode}`,
method: 'get'
});
}
/** 驳回任务 */
export function fetchBackTask(data: Api.Workflow.BackOperateParams) {
return request<boolean>({
url: '/workflow/task/backProcess',
method: 'post',
data
});
}
/** 获取我的已办任务 */
export function fetchGetFinishedTask(data: Api.Workflow.TaskSearchParams) {
return request<Api.Workflow.HisTaskList>({
url: '/workflow/task/pageByTaskFinish',
method: 'get',
params: data
});
}
/** 获取我的抄送任务 */
export function fetchGetCopyTask(data: Api.Workflow.TaskSearchParams) {
return request<Api.Workflow.TaskList>({
url: '/workflow/task/pageByTaskCopy',
method: 'get',
params: data
});
}
/** 修改办理人 */
export function fetchTaskAssignee(taskIds: CommonType.IdType[], userId: CommonType.IdType) {
return request<boolean>({
url: `/workflow/task/updateAssignee/${userId}`,
method: 'put',
data: taskIds
});
}
/** 任务催办 */
export function fetchTaskUrge(data: Api.Workflow.TaskUrgeOperateParams) {
return request<boolean>({
url: '/workflow/task/urgeTask',
method: 'post',
data
});
}

View File

@ -12,7 +12,7 @@ const encryptHeader = import.meta.env.VITE_HEADER_FLAG || 'encrypt-key';
const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y';
const { baseURL } = getServiceBaseURL(import.meta.env, isHttpProxy);
export const request = createFlatRequest(
export const request = createFlatRequest<App.Service.Response, RequestInstanceState>(
{
baseURL,
'axios-retry': {
@ -20,22 +20,6 @@ export const request = createFlatRequest(
}
},
{
defaultState: {
errMsgStack: [],
refreshTokenPromise: null
} as RequestInstanceState,
transform(response: AxiosResponse<App.Service.Response<any>>) {
// 二进制数据则直接返回
if (response.request.responseType === 'blob' || response.request.responseType === 'arraybuffer') {
return response.data;
}
if (response.data.rows) {
return response.data;
}
return response.data.data;
},
async onRequest(config) {
const isToken = config.headers?.isToken === false;
// set token
@ -148,6 +132,18 @@ export const request = createFlatRequest(
return null;
},
transformBackendResponse(response) {
// 二进制数据则直接返回
if (response.request.responseType === 'blob' || response.request.responseType === 'arraybuffer') {
return response.data;
}
if (response.data.rows) {
return response.data;
}
return response.data.data;
},
onError(error) {
// when the request is fail, you can show error message

View File

@ -27,14 +27,14 @@ async function handleRefreshToken() {
}
export async function handleExpiredRequest(state: RequestInstanceState) {
if (!state.refreshTokenPromise) {
state.refreshTokenPromise = handleRefreshToken();
if (!state.refreshTokenFn) {
state.refreshTokenFn = handleRefreshToken();
}
const success = await state.refreshTokenPromise;
const success = await state.refreshTokenFn;
setTimeout(() => {
state.refreshTokenPromise = null;
state.refreshTokenFn = null;
}, 1000);
return success;

View File

@ -1,7 +1,6 @@
export interface RequestInstanceState {
/** the promise of refreshing token */
refreshTokenPromise: Promise<boolean> | null;
/** whether the request is refreshing token */
refreshTokenFn: Promise<boolean> | null;
/** the request error message stack */
errMsgStack: string[];
[key: string]: unknown;
}

View File

@ -8,7 +8,6 @@ import { localStg } from '@/utils/storage';
import { SetupStoreId } from '@/enum';
import { useRouteStore } from '../route';
import { useTabStore } from '../tab';
import useNoticeStore from '../notice';
import { clearAuthStorage, getToken } from './shared';
export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
@ -16,7 +15,6 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
const authStore = useAuthStore();
const routeStore = useRouteStore();
const tabStore = useTabStore();
const noticeStore = useNoticeStore();
const { toLogin, redirectFromLogin } = useRouterPush(false);
const { loading: loginLoading, startLoading, endLoading } = useLoading();
@ -50,7 +48,6 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
await toLogin();
}
noticeStore.clearNotice();
tabStore.cacheTabs();
routeStore.resetStore();
}

View File

@ -1,9 +1,8 @@
import { ref } from 'vue';
import { defineStore } from 'pinia';
import { $t } from '@/locales';
import { SetupStoreId } from '@/enum';
export const useDictStore = defineStore(SetupStoreId.Dict, () => {
export const useDictStore = defineStore('dict', () => {
const dictData = ref<{ [key: string]: Api.System.DictData[] }>({});
const getDict = (key: string) => {

View File

@ -389,7 +389,7 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
}
async function onRouteSwitchWhenLoggedIn() {
// some global init logic when logged in and switch route
// await authStore.initUserInfo();
}
async function onRouteSwitchWhenNotLoggedIn() {

View File

@ -1,11 +1,10 @@
import { computed, effectScope, onScopeDispose, ref, toRefs, watch } from 'vue';
import type { Ref } from 'vue';
import { useDateFormat, useEventListener, useNow, usePreferredColorScheme } from '@vueuse/core';
import { useEventListener, usePreferredColorScheme } from '@vueuse/core';
import { defineStore } from 'pinia';
import { getPaletteColorByNumber } from '@sa/color';
import { localStg } from '@/utils/storage';
import { SetupStoreId } from '@/enum';
import { useAuthStore } from '../auth';
import {
addThemeVarsToGlobal,
createThemeToken,
@ -19,14 +18,10 @@ import {
export const useThemeStore = defineStore(SetupStoreId.Theme, () => {
const scope = effectScope();
const osTheme = usePreferredColorScheme();
const authStore = useAuthStore();
/** Theme settings */
const settings: Ref<App.Theme.ThemeSetting> = ref(initThemeSettings());
/** Watermark time instance with controls */
const { now: watermarkTime, pause: pauseWatermarkTime, resume: resumeWatermarkTime } = useNow({ controls: true });
/** Dark mode */
const darkMode = computed(() => {
if (settings.value.themeScheme === 'auto') {
@ -62,29 +57,6 @@ export const useThemeStore = defineStore(SetupStoreId.Theme, () => {
*/
const settingsJson = computed(() => JSON.stringify(settings.value));
/** Watermark time date formatter */
const formattedWatermarkTime = computed(() => {
const { watermark } = settings.value;
const date = useDateFormat(watermarkTime, watermark.timeFormat);
return date.value;
});
/** Watermark content */
const watermarkContent = computed(() => {
const { watermark } = settings.value;
let content = watermark.text;
if (watermark.enableUserName && authStore.userInfo.user?.userName) {
content = `${authStore.userInfo.user.userName}@${content}`;
}
if (watermark.enableTime) {
content = `${content} ${formattedWatermarkTime.value}`;
}
return content;
});
/** Reset store */
function resetStore() {
const themeStore = useThemeStore();
@ -172,43 +144,13 @@ export const useThemeStore = defineStore(SetupStoreId.Theme, () => {
);
addThemeVarsToGlobal(themeTokens, darkThemeTokens);
}
/**
* Set watermark enable user name
* Set layout reverse horizontal mix
*
* @param enable Whether to enable user name watermark
* @param reverse Reverse horizontal mix
*/
function setWatermarkEnableUserName(enable: boolean) {
settings.value.watermark.enableUserName = enable;
if (enable) {
// settings.value.watermark.enableTime = false;
}
}
/**
* Set watermark enable time
*
* @param enable Whether to enable time watermark
*/
function setWatermarkEnableTime(enable: boolean) {
settings.value.watermark.enableTime = enable;
if (enable) {
// settings.value.watermark.enableUserName = false;
}
}
/** Only run timer when watermark is visible and time display is enabled */
function updateWatermarkTimer() {
const { watermark } = settings.value;
const shouldRunTimer = watermark.visible && watermark.enableTime;
if (shouldRunTimer) {
resumeWatermarkTime();
} else {
pauseWatermarkTime();
}
function setLayoutReverseHorizontalMix(reverse: boolean) {
settings.value.layout.reverseHorizontalMix = reverse;
}
/** Cache theme settings */
@ -254,15 +196,6 @@ export const useThemeStore = defineStore(SetupStoreId.Theme, () => {
},
{ immediate: true }
);
// watch watermark settings to control timer
watch(
() => [settings.value.watermark.visible, settings.value.watermark.enableTime],
() => {
updateWatermarkTimer();
},
{ immediate: true }
);
});
/** On scope dispose */
@ -276,7 +209,6 @@ export const useThemeStore = defineStore(SetupStoreId.Theme, () => {
themeColors,
naiveTheme,
settingsJson,
watermarkContent,
setGrayscale,
setColourWeakness,
resetStore,
@ -284,7 +216,6 @@ export const useThemeStore = defineStore(SetupStoreId.Theme, () => {
toggleThemeScheme,
updateThemeColors,
setThemeLayout,
setWatermarkEnableUserName,
setWatermarkEnableTime
setLayoutReverseHorizontalMix
};
});

View File

@ -1,90 +0,0 @@
{
"name": "Azir's Preset",
"desc": "It is a cold and elegant preset that Azir likes",
"i18nkey": "theme.appearance.preset.azir",
"version": "1.0.0",
"themeScheme": "light",
"grayscale": false,
"colourWeakness": false,
"recommendColor": true,
"themeColor": "#78a878",
"otherColor": {
"info": "#89b989",
"success": "#99c299",
"warning": "#d4bb9d",
"error": "#c49a9a"
},
"isInfoFollowPrimary": true,
"resetCacheStrategy": "refresh",
"layout": {
"mode": "vertical-mix",
"scrollMode": "wrapper"
},
"page": {
"animate": true,
"animateMode": "zoom-fade"
},
"header": {
"height": 64,
"breadcrumb": {
"visible": true,
"showIcon": true
},
"multilingual": {
"visible": true
},
"globalSearch": {
"visible": true
}
},
"tab": {
"visible": true,
"cache": true,
"height": 48,
"mode": "chrome"
},
"fixedHeaderAndTab": true,
"sider": {
"inverted": false,
"width": 220,
"collapsedWidth": 64,
"mixWidth": 90,
"mixCollapsedWidth": 64,
"mixChildMenuWidth": 200
},
"footer": {
"visible": true,
"fixed": true,
"height": 56,
"right": true
},
"watermark": {
"visible": false,
"text": "SoybeanAdmin",
"enableUserName": false,
"enableTime": true,
"timeFormat": "YYYY-MM-DD HH:mm:ss"
},
"tokens": {
"light": {
"colors": {
"container": "rgb(255, 255, 255)",
"layout": "rgb(247, 250, 252)",
"inverted": "rgb(0, 20, 40)",
"base-text": "rgb(31, 31, 31)"
},
"boxShadow": {
"header": "0 1px 2px rgb(0, 21, 41, 0.08)",
"sider": "2px 0 8px 0 rgb(29, 35, 41, 0.05)",
"tab": "0 1px 2px rgb(0, 21, 41, 0.08)"
}
},
"dark": {
"colors": {
"container": "rgb(28, 28, 28)",
"layout": "rgb(18, 18, 18)",
"base-text": "rgb(224, 224, 224)"
}
}
}
}

View File

@ -1,90 +0,0 @@
{
"name": "Compact Preset",
"desc": "Compact layout preset for small screens",
"i18nkey": "theme.appearance.preset.compact",
"version": "1.0.0",
"themeScheme": "light",
"grayscale": false,
"colourWeakness": false,
"recommendColor": false,
"themeColor": "#646cff",
"otherColor": {
"info": "#2080f0",
"success": "#52c41a",
"warning": "#faad14",
"error": "#f5222d"
},
"isInfoFollowPrimary": true,
"resetCacheStrategy": "close",
"layout": {
"mode": "vertical",
"scrollMode": "content"
},
"page": {
"animate": true,
"animateMode": "fade-slide"
},
"header": {
"height": 48,
"breadcrumb": {
"visible": true,
"showIcon": true
},
"multilingual": {
"visible": false
},
"globalSearch": {
"visible": false
}
},
"tab": {
"visible": true,
"cache": true,
"height": 36,
"mode": "button"
},
"fixedHeaderAndTab": true,
"sider": {
"inverted": false,
"width": 180,
"collapsedWidth": 48,
"mixWidth": 80,
"mixCollapsedWidth": 48,
"mixChildMenuWidth": 180
},
"footer": {
"visible": false,
"fixed": false,
"height": 40,
"right": true
},
"watermark": {
"visible": false,
"text": "SoybeanAdmin",
"enableUserName": false,
"enableTime": false,
"timeFormat": "YYYY-MM-DD HH:mm"
},
"tokens": {
"light": {
"colors": {
"container": "rgb(255, 255, 255)",
"layout": "rgb(247, 250, 252)",
"inverted": "rgb(0, 20, 40)",
"base-text": "rgb(31, 31, 31)"
},
"boxShadow": {
"header": "0 1px 2px rgb(0, 21, 41, 0.08)",
"sider": "2px 0 8px 0 rgb(29, 35, 41, 0.05)",
"tab": "0 1px 2px rgb(0, 21, 41, 0.08)"
}
},
"dark": {
"colors": {
"container": "rgb(28, 28, 28)",
"layout": "rgb(18, 18, 18)",
"base-text": "rgb(224, 224, 224)"
}
}
}
}

View File

@ -1,90 +0,0 @@
{
"name": "Dark Preset",
"desc": "Dark theme preset for night time usage",
"i18nkey": "theme.appearance.preset.dark",
"version": "1.0.0",
"themeScheme": "dark",
"grayscale": false,
"colourWeakness": false,
"recommendColor": false,
"themeColor": "#409eff",
"otherColor": {
"info": "#2080f0",
"success": "#52c41a",
"warning": "#faad14",
"error": "#f5222d"
},
"isInfoFollowPrimary": true,
"resetCacheStrategy": "close",
"layout": {
"mode": "vertical",
"scrollMode": "content"
},
"page": {
"animate": true,
"animateMode": "fade-slide"
},
"header": {
"height": 56,
"breadcrumb": {
"visible": true,
"showIcon": true
},
"multilingual": {
"visible": true
},
"globalSearch": {
"visible": true
}
},
"tab": {
"visible": true,
"cache": true,
"height": 44,
"mode": "chrome"
},
"fixedHeaderAndTab": true,
"sider": {
"inverted": true,
"width": 220,
"collapsedWidth": 64,
"mixWidth": 90,
"mixCollapsedWidth": 64,
"mixChildMenuWidth": 200
},
"footer": {
"visible": true,
"fixed": false,
"height": 48,
"right": true
},
"watermark": {
"visible": false,
"text": "SoybeanAdmin",
"enableUserName": false,
"enableTime": false,
"timeFormat": "YYYY-MM-DD HH:mm"
},
"tokens": {
"light": {
"colors": {
"container": "rgb(255, 255, 255)",
"layout": "rgb(247, 250, 252)",
"inverted": "rgb(0, 20, 40)",
"base-text": "rgb(31, 31, 31)"
},
"boxShadow": {
"header": "0 1px 2px rgb(0, 21, 41, 0.08)",
"sider": "2px 0 8px 0 rgb(29, 35, 41, 0.05)",
"tab": "0 1px 2px rgb(0, 21, 41, 0.08)"
}
},
"dark": {
"colors": {
"container": "rgb(28, 28, 28)",
"layout": "rgb(18, 18, 18)",
"base-text": "rgb(224, 224, 224)"
}
}
}
}

View File

@ -1,90 +0,0 @@
{
"name": "default",
"desc": "Default theme preset with balanced settings",
"i18nkey": "theme.appearance.preset.default",
"version": "1.0.0",
"themeScheme": "light",
"grayscale": false,
"colourWeakness": false,
"recommendColor": false,
"themeColor": "#646cff",
"otherColor": {
"info": "#2080f0",
"success": "#52c41a",
"warning": "#faad14",
"error": "#f5222d"
},
"isInfoFollowPrimary": true,
"resetCacheStrategy": "close",
"layout": {
"mode": "vertical",
"scrollMode": "content"
},
"page": {
"animate": true,
"animateMode": "fade-slide"
},
"header": {
"height": 56,
"breadcrumb": {
"visible": true,
"showIcon": true
},
"multilingual": {
"visible": true
},
"globalSearch": {
"visible": true
}
},
"tab": {
"visible": true,
"cache": true,
"height": 44,
"mode": "chrome"
},
"fixedHeaderAndTab": true,
"sider": {
"inverted": false,
"width": 220,
"collapsedWidth": 64,
"mixWidth": 90,
"mixCollapsedWidth": 64,
"mixChildMenuWidth": 200
},
"footer": {
"visible": true,
"fixed": false,
"height": 48,
"right": true
},
"watermark": {
"visible": false,
"text": "SoybeanAdmin",
"enableUserName": false,
"enableTime": false,
"timeFormat": "YYYY-MM-DD HH:mm"
},
"tokens": {
"light": {
"colors": {
"container": "rgb(255, 255, 255)",
"layout": "rgb(247, 250, 252)",
"inverted": "rgb(0, 20, 40)",
"base-text": "rgb(31, 31, 31)"
},
"boxShadow": {
"header": "0 1px 2px rgb(0, 21, 41, 0.08)",
"sider": "2px 0 8px 0 rgb(29, 35, 41, 0.05)",
"tab": "0 1px 2px rgb(0, 21, 41, 0.08)"
}
},
"dark": {
"colors": {
"container": "rgb(28, 28, 28)",
"layout": "rgb(18, 18, 18)",
"base-text": "rgb(224, 224, 224)"
}
}
}
}

View File

@ -12,10 +12,11 @@ export const themeSettings: App.Theme.ThemeSetting = {
error: '#CB2634'
},
isInfoFollowPrimary: true,
resetCacheStrategy: 'refresh',
resetCacheStrategy: 'close',
layout: {
mode: 'vertical',
scrollMode: 'content'
scrollMode: 'content',
reverseHorizontalMix: false
},
page: {
animate: true,
@ -57,10 +58,8 @@ export const themeSettings: App.Theme.ThemeSetting = {
},
watermark: {
visible: import.meta.env.VITE_WATERMARK === 'Y',
text: 'RuoYi-Plus-Soybean',
enableUserName: true,
enableTime: false,
timeFormat: 'YYYY-MM-DD HH:mm'
text: 'RuoYi-Vue-Plus',
enableUserName: false
},
table: {
bordered: true,

View File

@ -85,4 +85,131 @@ declare namespace Api {
children: CommonTreeRecord[];
}[];
}
/**
* namespace Auth
*
* backend api module: "auth"
*/
namespace Auth {
/** base login form */
interface LoginForm {
/** 客户端 ID */
clientId?: string;
/** 授权类型 */
grantType?: string;
/** 租户ID */
tenantId?: string;
/** 验证码 */
code?: string;
/** 唯一标识 */
uuid?: string;
}
/** password login form */
interface PwdLoginForm extends LoginForm {
/** 用户名 */
username?: string;
/** 密码 */
password?: string;
}
/** social login form */
interface SocialLoginForm extends LoginForm {
/** 授权码 */
socialCode?: string;
/** 授权状态 */
socialState?: string;
/** 来源 */
source?: string;
}
/** register form */
interface RegisterForm extends LoginForm {
/** 用户名 */
username?: string;
/** 密码 */
password?: string;
/** 确认密码 */
confirmPassword?: string;
/** 用户类型 */
userType?: string;
}
/** login token data */
interface LoginToken {
/** 授权令牌 */
access_token?: string;
/** 应用id */
client_id?: string;
/** 授权令牌 access_token 的有效期 */
expire_in?: number;
/** 用户 openid */
openid?: string;
/** 刷新令牌 refresh_token 的有效期 */
refresh_expire_in?: number;
/** 刷新令牌 */
refresh_token?: string;
/** 令牌权限 */
scope?: string;
}
/** userinfo */
interface UserInfo {
/** 用户信息 */
user?: Api.System.User & {
/** 所属角色 */
roles: Api.System.Role[];
};
/** 角色列表 */
roles: string[];
/** 菜单权限 */
permissions: string[];
}
/** tenant */
interface Tenant {
/** 企业名称 */
companyName: string;
/** 域名 */
domain: string;
/** 租户编号 */
tenantId: string;
}
/** login tenant */
interface LoginTenant {
/** 租户开关 */
tenantEnabled: boolean;
/** 租户列表 */
voList: Tenant[];
}
interface CaptchaCode {
/** 是否开启验证码 */
captchaEnabled: boolean;
/** 唯一标识 */
uuid?: string;
/** 验证码图片 */
img?: string;
}
}
/**
* namespace Route
*
* backend api module: "route"
*/
namespace Route {
type ElegantConstRoute = import('@elegant-router/types').ElegantConstRoute;
interface MenuRoute extends ElegantConstRoute {
id: string;
}
interface UserRoute {
routes: MenuRoute[];
home: import('@elegant-router/types').LastLevelRouteKey;
}
}
}

View File

@ -1,110 +0,0 @@
declare namespace Api {
/**
* namespace Auth
*
* backend api module: "auth"
*/
namespace Auth {
/** base login form */
interface LoginForm {
/** 客户端 ID */
clientId?: string;
/** 授权类型 */
grantType?: string;
/** 租户ID */
tenantId?: string;
/** 验证码 */
code?: string;
/** 唯一标识 */
uuid?: string;
}
/** password login form */
interface PwdLoginForm extends LoginForm {
/** 用户名 */
username?: string;
/** 密码 */
password?: string;
}
/** social login form */
interface SocialLoginForm extends LoginForm {
/** 授权码 */
socialCode?: string;
/** 授权状态 */
socialState?: string;
/** 来源 */
source?: string;
}
/** register form */
interface RegisterForm extends LoginForm {
/** 用户名 */
username?: string;
/** 密码 */
password?: string;
/** 确认密码 */
confirmPassword?: string;
/** 用户类型 */
userType?: string;
}
/** login token data */
interface LoginToken {
/** 授权令牌 */
access_token?: string;
/** 应用id */
client_id?: string;
/** 授权令牌 access_token 的有效期 */
expire_in?: number;
/** 用户 openid */
openid?: string;
/** 刷新令牌 refresh_token 的有效期 */
refresh_expire_in?: number;
/** 刷新令牌 */
refresh_token?: string;
/** 令牌权限 */
scope?: string;
}
/** userinfo */
interface UserInfo {
/** 用户信息 */
user?: Api.System.User & {
/** 所属角色 */
roles: Api.System.Role[];
};
/** 角色列表 */
roles: string[];
/** 菜单权限 */
permissions: string[];
}
/** tenant */
interface Tenant {
/** 企业名称 */
companyName: string;
/** 域名 */
domain: string;
/** 租户编号 */
tenantId: string;
}
/** login tenant */
interface LoginTenant {
/** 租户开关 */
tenantEnabled: boolean;
/** 租户列表 */
voList: Tenant[];
}
interface CaptchaCode {
/** 是否开启验证码 */
captchaEnabled: boolean;
/** 唯一标识 */
uuid?: string;
/** 验证码图片 */
img?: string;
}
}
}

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