160 Commits

Author SHA1 Message Date
dap
4dc7543bb6 docs: changelog 2025-04-11 13:29:27 +08:00
dap
d8e7945f9f docs: changelog 2025-04-11 13:24:34 +08:00
dap
2fd1fdcb32 refactor: 更改header参数ClientID命名 2025-04-11 11:33:23 +08:00
dap
44ba945a12 fix: 无法点击遮罩关闭 2025-04-09 15:07:14 +08:00
dap
2680101872 fix: 无法点击遮罩关闭 2025-04-09 15:06:00 +08:00
dap
1c2e27613c refactor: 富文本/上传同步改为异步组件导入 2025-04-09 10:03:47 +08:00
dap
3e7a2336b0 Merge branch 'main' of https://github.com/vbenjs/vue-vben-admin into dev 2025-04-09 09:34:17 +08:00
dap
022d5182d7 fix: onCancel -> onClosed 2025-04-09 09:22:13 +08:00
Netfan
329a176a5c perf: optimize bootstrap modules to speed up first-screen loading (#5899)
优化首屏加载速度
2025-04-09 01:05:20 +08:00
dap
41962ef380 docs: changelog 2025-04-08 21:07:09 +08:00
dap
9003df713c fix: vxe新版需要单独设置headerCellConfig 2025-04-08 21:04:01 +08:00
dap
ebb4738be7 refactor: 流程定义 useBeforeCloseDiff 2025-04-08 20:58:09 +08:00
dap
ad7c33a7d6 refactor: 流程分类 useBeforeCloseDiff 2025-04-08 20:55:34 +08:00
dap
a114335a56 refactor: oss配置 useBeforeCloseDiff 2025-04-08 20:53:44 +08:00
Netfan
9379093a4f feat: customizable table separator (#5898)
* 表格的分隔条支持定制背景色或完全移除
2025-04-08 20:28:50 +08:00
ming4762
c9014d0338 perf: 优化关闭页面切换动画的tab切换性能 (#5883) 2025-04-08 20:27:03 +08:00
dap
b8ec8edb38 update: 字典 colorpicker 2025-04-08 19:33:35 +08:00
Netfan
ed26dca64e chore: update pnpm-lock.yaml 2025-04-08 16:31:41 +08:00
Netfan
08c6496e24 chore: update deps 2025-04-08 14:56:40 +08:00
Netfan
a8c5df38e9 fix: possible circular reference issue during build (#5894)
* 修复构建期间出现的循环引用警告
2025-04-08 14:50:05 +08:00
dap
5b9f647cfd update: [vxe table v4.12.5] 参数 "row-config.height" 已废弃,请使用 "cell-config.height" 2025-04-08 13:29:06 +08:00
dap
ae6bf6ee53 refactor: 用户drawer Promise逻辑重构 2025-04-08 12:03:15 +08:00
dap
77894d5df4 update: i18n更新 2025-04-08 11:09:07 +08:00
dap
ba8f36a2c0 update: 移除老版本的不需要组件/代码 2025-04-08 11:04:12 +08:00
dap
133abe9ded refactor: 角色权限 useBeforeCloseDiff 2025-04-08 11:02:36 +08:00
dap
ef390ae636 refactor: 租户套餐useBeforeCloseDiff 2025-04-08 10:57:08 +08:00
dap
6d2f4e8486 refactor: 租户管理 useBeforeCloseDiff 2025-04-08 10:54:28 +08:00
dap
c4962aaf85 refactor: 客户端管理useBeforeCloseDiff 2025-04-08 10:51:16 +08:00
dap
f7128b099e refactor: 通知公告 useBeforeCloseDiff 2025-04-08 10:47:18 +08:00
dap
5510b6dea4 refactor: 字典useBeforeCloseDiff 2025-04-08 10:40:32 +08:00
dap
98f658d46f refactor: 部门管理useBeforeCloseDiff 2025-04-08 10:34:26 +08:00
dap
e307db2f3d refactor: useBeforeCloseDiff 2025-04-08 10:30:56 +08:00
dap
e6dab8300d refactor: 角色管理 useBeforeCloseDiff 2025-04-08 10:22:21 +08:00
dap
eb9f278e7f refactor: useBeforeCloseDiff 2025-04-08 10:10:15 +08:00
dap
34e5812de9 update: vxe active color 2025-04-07 19:37:11 +08:00
dap
07587c0faf update: 用户管理 表单更新(非最终方案) 2025-04-07 19:02:28 +08:00
dap
88316d7498 refactor: useBeforeCloseDiff逻辑更新 2025-04-07 18:48:46 +08:00
dap
53e02d46c2 docs: changelog 2025-04-07 18:44:42 +08:00
dap
5e1de6fc79 fix: 表格固定高度 getVxePopupContainer 2025-04-07 18:41:23 +08:00
dap
7463df053a update: 去除字典动画 2025-04-07 18:25:21 +08:00
dap
1286b52135 fix: getVxePopupContainer 2025-04-07 17:21:49 +08:00
dap
92fe406ae9 update: 字典loading 2025-04-07 17:20:41 +08:00
dap
5b72d9b79d refactor: 移除deepWatch参数 2025-04-07 13:05:30 +08:00
dap
b97fe47afd fix: 直接使用.value无法触发useForm的更新(原生是正常的) 需要修改地址 2025-04-07 12:53:20 +08:00
dap
4f2354b53a update: 兼容以前代码 先返回body 这样会造成无法跟随滚动 2025-04-07 11:10:10 +08:00
dap
8f9006c96d fix: vxe 右上角toolbar按钮色/翻页主题色保持一致 2025-04-07 10:58:51 +08:00
Netfan
71e8d12b70 fix: improve prompt component (#5879)
* fix: prompt component render fixed

* fix: alert buttonAlign default value
2025-04-07 01:21:30 +08:00
dap
ae5d45763f refactor: getVxePopupContainer与新版Vxe不兼容 先返回body(会导致滚动不跟随)后续版本再优化 2025-04-06 17:41:00 +08:00
dap
3f037f146b refactor: getVxePopupContainer 2025-04-06 17:24:18 +08:00
dap
63db3ba143 update: wechat group image 2025-04-06 15:40:56 +08:00
dap
17ca1ef1b2 update: align 2025-04-06 15:36:14 +08:00
dap
203c2edf63 fix: dropdown 2025-04-06 13:16:45 +08:00
dap
09e0721db7 fix: dropdown 2025-04-06 13:15:04 +08:00
dap
3c2a52057e docs: changelog 2025-04-06 12:40:00 +08:00
dap
06dd17eac3 update: getVxePopupContainer选择器更新(适配新版) 2025-04-05 23:07:45 +08:00
dap
0e3eb887da update: catch 2025-04-05 22:57:52 +08:00
dap
1b2ded7421 update: 修改zIndex 2025-04-05 22:54:45 +08:00
dap
7bc36d2b84 fix: 租户选择下拉框会跟随body滚动 2025-04-05 22:52:48 +08:00
dap
21fb9c8c99 docs: 头像裁剪 私有桶会拼接timestamp参数导致sign计算异常无法上传 2025-04-05 17:12:26 +08:00
dap
84d6559f25 fix: 头像异常时无法一直Loading 无法裁剪 2025-04-05 16:51:24 +08:00
dap
78c16c1016 update: TinyMCE上传失败移除图片 2025-04-05 15:09:22 +08:00
dap
7fc284a609 fix: TinyMCE zIndex 2025-04-05 14:48:42 +08:00
dap
29c062a4a8 refactor: [vxe table v4.12.5] 参数 "row-config.height" 已废弃,请使用 "cell-config.height" 2025-04-05 14:41:20 +08:00
dap
e1aa1f7636 refactor: 流程分类 align left 2025-04-05 14:37:02 +08:00
dap
eae24d8d83 Merge branch 'main' of https://github.com/vbenjs/vue-vben-admin into dev 2025-04-05 14:29:10 +08:00
dap
88b3208afb update: 使用lock+try/catch 2025-04-05 13:38:09 +08:00
dap
26587ac09a update: 弹窗表单数据更改关闭时的提示框(可能最终不会加入) 测试页面: 参数管理 2025-04-05 13:33:15 +08:00
dap
b4d038e22f update: 还原loading逻辑 2025-04-05 13:07:57 +08:00
Netfan
d216fdca44 feat: support logo text slot (#5872)
* 基础布局中的LOGO的文字区域允许通过插槽logo-text定制
2025-04-05 13:07:52 +08:00
wyc001122
384c5d7dbb fix: 布局为双列菜单或者水平模式下, 一级菜单高亮问题 (#5870)
Co-authored-by: 王泳超 <wangyongchao@testor.com.cn>
2025-04-05 11:04:59 +08:00
dap
63630b17c1 update: 租户切换Select增加loading 2025-04-05 00:49:57 +08:00
dap
a2ed3fa48b refactor: TinyMCE组件重构 移除冗余代码/功能 增加loading 2025-04-04 22:55:07 +08:00
dap
104039cdfb docs: changelog 2025-04-04 21:19:33 +08:00
dap
16f033aa8f docs: changelog 2025-04-04 21:13:35 +08:00
dap
0c0988404e refactor: ContentTypeEnum使用const替代enum 2025-04-04 21:12:54 +08:00
dap
1a16c520a1 refactor: DictEnum使用const替代enum 2025-04-04 21:11:33 +08:00
dap
e52fec291a update: 移除不需要的description schema 2025-04-04 21:06:57 +08:00
dap
aeed0fd48e refactor: 重构操作日志drawer 2025-04-04 21:06:16 +08:00
dap
542407dcd6 refactor: 用户信息重构(Description) 2025-04-04 20:13:43 +08:00
dap
123f234971 refactor: 重置密码使用原生重构(Descriptions) 2025-04-04 19:55:49 +08:00
dap
2ca39dfdbb Merge branch 'main' of https://github.com/vbenjs/vue-vben-admin into dev 2025-04-04 19:42:43 +08:00
Netfan
b0ad08dbbc feat: use the not-found component instead of the invalid route component in the backend mode (#5871)
* 后端菜单模式下,使用not-found组件代替无效的路由组件
2025-04-04 15:21:09 +08:00
Rascal-Coder
3600603016 fix: vxeGrid height fixed #5861 (#5862) 2025-04-04 13:16:31 +08:00
dap
116fe39b8d fix: 兼容最新版本vxe的勾选api 2025-04-04 01:46:53 +08:00
superdl1996
cde1a85394 docs: typo (#5855) 2025-04-03 19:11:40 +08:00
dap
fe53c33821 update: 更新日志的加载效果 2025-04-03 17:59:31 +08:00
dap
bac71a30f0 refactor: 更新记录 菜单 2025-04-03 17:09:39 +08:00
dap
ec82510f49 docs: 更新日志 2025-04-03 15:43:58 +08:00
dap
f1c4ed1412 update: useDescription deprecated 2025-04-03 15:29:30 +08:00
dap
36683dd218 update: 兼容旧版本上传 增加ImageUploadOld/FileUploadOld(下个版本将移除) 2025-04-03 15:26:00 +08:00
dap
2577ba5500 update: commit-lint增加update: 2025-04-03 15:25:05 +08:00
dap
b5150b5863 refactor: 登录信息 使用原生重构 2025-04-03 15:02:26 +08:00
dap
5d47026908 docs: changelog 2025-04-03 14:03:38 +08:00
dap
63e13069ea Merge 2025-04-03 14:02:12 +08:00
dap
56e7e840b3 feat: 验证码loading 2025-04-03 13:57:32 +08:00
dap
2d58a2172d refactor: 附件上传改为更新后 2025-04-03 11:52:17 +08:00
Netfan
c623604ea9 docs: fix alert demo in docs 2025-04-02 20:37:41 +08:00
dap
75d7a607f5 refactor: 使用原生组件重构redis-info 2025-04-02 19:24:57 +08:00
dap
354ff7ecf6 chore: 更新文案 2025-04-02 19:05:54 +08:00
Netfan
7933da8f66 chore: update deps (#5854) 2025-04-02 18:07:06 +08:00
dap
38d39d5e3d refactor: 修改为computed 支持语言切换 2025-04-02 16:46:31 +08:00
Netfan
ecf518bb02 fix: alert beforeClose callback arguments fixed (#5845) 2025-04-01 22:55:29 +08:00
dap
98e3a4a34c refactor: modalLoading/drawerLoading改为调用内部的lock/unlock方法 2025-04-01 20:36:49 +08:00
dap
362bc84cfb refactor: 管理员租户切换不再返回首页 直接刷新当前页(除特殊页面外会回到首页) 2025-04-01 19:59:28 +08:00
dap
b67be83a19 docs: 更新注释 2025-04-01 19:10:45 +08:00
dap
af118cef71 docs: changelog 2025-04-01 18:38:16 +08:00
dap
bc2beefa7e fix: 字典重新登录unknown的情况 2025-04-01 18:36:41 +08:00
dap
44ad2c5f8d Merge branch 'main' of https://github.com/vbenjs/vue-vben-admin into dev 2025-04-01 17:16:13 +08:00
ming4762
1d9f1be004 fix: 解决AccessModeType:backend登录过期,重新登录不会重新生成路由的问题,重现步骤分析: (#5830)
1、长时间未登录登录过期,再次打开页面构开始生成动态路由
2、fetchMenuListAsync后台返回401登录过期:doReAuthenticate函数跳转到登录页面
3、异常被拦截,return []
4、gurad.ts accessStore.setIsAccessChecked(true); 被错误的标识为已生成路由
5、重新登录后,accessStore.isAccessChecked=true未能正确的重新生成路由
2025-04-01 15:50:45 +08:00
Netfan
44138f578f feat: add preset alert, confirm, prompt components that can be simple called (#5843)
* feat: add preset alert, confirm, prompt components that can be simple called

* fix: type define
2025-04-01 15:10:18 +08:00
dap
13951a0caf refactor: 修改默认radius 与antd保持一致 2025-03-31 21:58:22 +08:00
dap
a84a713eaa Revert "chore: 打包最大内存配置为4G"
This reverts commit 308853cce1.
2025-03-31 19:31:43 +08:00
Joeshu
0e3abc2b53 docs: add third-party libraries to check update methods (#5819) 2025-03-31 19:28:28 +08:00
Arthur Darkstone
a96be3db98 docs: add explanation and related script configuration to distinguish build environment (#5826)
* docs: add explanation and related script configuration to distinguish build environment

* docs: fix spell error
2025-03-31 19:28:02 +08:00
dap
308853cce1 chore: 打包最大内存配置为4G 2025-03-31 19:24:25 +08:00
dap
0a19ec3122 Merge branch 'main' of https://github.com/vbenjs/vue-vben-admin into dev 2025-03-31 15:20:14 +08:00
dap
ebc571e13f feat: list-type: picture-card动画效果关闭样式 2025-03-31 15:14:48 +08:00
dap
2b7713323e docs: update wechat group 2025-03-31 12:46:14 +08:00
Netfan
d6f239c564 docs: fix api-component demo link (#5828) 2025-03-31 12:08:45 +08:00
Netfan
166e9a0e82 chore: add demo for apiComponent with caching and concurrency (#5827)
* chore: add demo for apiComponent with caching and concurrency

* docs: update api component docs
2025-03-31 11:51:57 +08:00
dap
c0a5942c2a fix: 单文件查询到会走多文件的判断 2025-03-31 10:47:30 +08:00
dap
bc9e3a50e1 chore: list-type的提示 2025-03-31 10:26:06 +08:00
Netfan
06ccad9db0 fix: vbenTree modelValue synchronization (#5825) 2025-03-31 10:18:35 +08:00
Jin Mao
18722ce434 feat: sidebar button config (#5818)
* feat: 新增 PreferenceCheckboxItem 组件

* feat(preferences): 添加侧边栏按钮配置功能

* feat: 新增按钮点击事件触发功能

* feat(SidebarPreferences): 新增侧边栏折叠按钮与固定按钮配置

* feat(ui): 新增侧边栏固定按钮及配置选项

* fix(test): 修正侧边栏配置项缺失问题
2025-03-31 10:17:42 +08:00
Netfan
a0feeb1966 fix: watermark settings in the preferences modified accidentally (#5823) 2025-03-31 09:06:02 +08:00
dap
825c2837ea docs: 更新说明 2025-03-30 23:52:51 +08:00
dap
23ff03d40c feat: 自定义预览图/文件名 2025-03-30 21:57:01 +08:00
dap
755a30583f feat: 上传list-type 2025-03-30 18:23:37 +08:00
dap
a302fdf119 feat: TableSwitch增加切换前确认Modal(默认false) 2025-03-30 16:53:12 +08:00
Jin Mao
df6341f0b8 feat(tabbar): 添加右键菜单过滤功能 (#5820) 2025-03-30 16:23:24 +08:00
dap
062e999f35 refactor: TableSwitch组件重构 2025-03-30 14:43:37 +08:00
dap
6c4d15136f feat: 上传emit 2025-03-30 13:55:02 +08:00
dap
f16afe657e feat: 是否在组件Unmounted时取消上传 2025-03-30 12:59:18 +08:00
dap
b9843c6faf refactor: 图片/文件上传都支持拖拽 2025-03-30 12:32:58 +08:00
dap
621f79e7d8 chore: singleImageId 2025-03-29 23:02:40 +08:00
dap
467d337515 Merge branch 'main' of https://github.com/vbenjs/vue-vben-admin into dev 2025-03-29 22:53:47 +08:00
anyup
dbc0b7e4a9 fix: route root.children duplicate problem (#5755)
Co-authored-by: anyup <anyupxing@163.com>
2025-03-29 22:38:30 +08:00
zhang
aa2907323f style: 更正引用格式 (#5784) 2025-03-29 22:29:16 +08:00
dap
3655cae900 feat: 自定义accept显示 2025-03-29 22:13:58 +08:00
dap
60c398df39 refactor: 图片上传自定义预览逻辑 2025-03-29 21:59:17 +08:00
dap
69222807a4 feat: 文件上传查询不到ossId的丢弃策略 2025-03-29 21:48:46 +08:00
dap
b3e2d758f6 feat: deepWatch参数 2025-03-29 21:24:11 +08:00
Netfan
96d2bc52e9 feat: pre-set serialization methods for request parameters (#5814)
添加快捷设置请求参数序列化方法的配置
2025-03-29 19:21:21 +08:00
dap
f4a88efb0f refactor: 修改oss对应的上传代码 2025-03-29 16:22:08 +08:00
dap
b78b599a06 feat: helpMessage插槽 2025-03-29 16:13:48 +08:00
dap
ffcc21975e refactor: 文件/图片上传重构 2025-03-29 15:52:11 +08:00
dap
dd57e3c9ae feat: 支持拖拽上传 disabled样式优化 2025-03-28 18:02:54 +08:00
dap
8c1cd617ad refactor: 文件上传/图片上传重构(破坏性更新 不兼容之前的api) 2025-03-28 17:24:46 +08:00
Netfan
e91e4e0eea docs: fix form compact docs (#5811)
* docs: fix form `compact` docs

* docs: remove `compact` from FormCommonConfig
2025-03-28 16:15:35 +08:00
Netfan
c2b5f6497d fix: vben tree component warning (#5809) 2025-03-28 16:01:30 +08:00
dap
456f0e1112 style: 字典管理 字典类型 表格选中行增加bold效果 2025-03-27 21:45:35 +08:00
dap
e5fa32bbae style: 字典选中 高亮行 2025-03-27 21:41:32 +08:00
dap
024087b9b2 fix: 测试菜单 请假申请 选中删除 需要根据状态判断 2025-03-27 21:03:55 +08:00
dap
c0476613d7 Merge branch 'main' of https://github.com/vbenjs/vue-vben-admin into dev 2025-03-27 19:27:26 +08:00
Netfan
3c2d325d8c perf: improve api-component for using in form (#5796) 2025-03-27 15:51:11 +08:00
Netfan
a77bb8e68d perf: improve component packaging to enable instance method retrieval (#5795)
改进组件适配器里的包装函数,使得组件暴露的方法可以透传
2025-03-27 15:13:13 +08:00
Netfan
870cd86393 fix: auto check parent after node selected (#5794) 2025-03-27 14:22:05 +08:00
dap
f5fada20e6 Merge branch 'main' of https://github.com/vbenjs/vue-vben-admin into dev 2025-03-27 13:25:23 +08:00
Netfan
0b650367f3 fix: popover background color in dark mode (#5783)
* 修复dark主题下的弹出层背景色在某些浏览器上表现为完全透明的问题
2025-03-25 21:17:55 +08:00
zhang
1616a06bfd perf: 优化多文件上传入参是数组的情况 (#5757)
Co-authored-by: Jin Mao <50581550+jinmao88@users.noreply.github.com>
2025-03-25 10:26:19 +08:00
195 changed files with 5776 additions and 2473 deletions

14
.vscode/settings.json vendored
View File

@@ -224,10 +224,20 @@
"commentTranslate.multiLineMerge": true,
"vue.server.hybridMode": true,
"vitest.disableWorkspaceWarning": true,
"cSpell.words": ["tinymce", "vditor"],
"typescript.tsdk": "node_modules/typescript/lib",
"editor.linkedEditing": true, // 自动同步更改html标签,
"vscodeCustomCodeColor.highlightValue": "v-access", // v-access显示的颜色
"vscodeCustomCodeColor.highlightValueColor": "#CCFFFF",
"oxc.enable": false
"oxc.enable": false,
"cSpell.words": [
"archiver",
"axios",
"dotenv",
"isequal",
"jspm",
"napi",
"nolebase",
"rollup",
"vitest"
]
}

View File

@@ -1,3 +1,78 @@
# 1.3.1
**REFACTOR**
- 所有Modal/Drawer表单关闭前会进行表单数据对比来弹出提示框
- 字典项颜色选择从`原生input type=color`改为`vue3-colorpicker`组件
- 全局Header: ClientID 更改大小写 [spring的问题导致](https://gitee.com/dapppp/ruoyi-plus-vben5/issues/IC0BDS)
**BUG FIX**
- getVxePopupContainer逻辑调整 解决表格固定高度展开不全的问题
**FEATURES**
- 字典渲染支持loading(length为0情况)
**OTHERS**
- useForm的组件改为异步导入(官方更新) bootstrap.js体积从2M降到600K 首屏加载速度提升
# 1.3.0
注意: 如果你使用老版本的`文件上传`/`图片上传` 可暂时使用
- `component: 'ImageUploadOld'`
- `component: 'FileUploadOld'`
代替 **建议替换为新版本**
大致变动:
- `accept string[] -> string`
- `resultField 已经移除 统一使用ossId`
- `maxNumber -> maxCount`
具体参数查看: `apps/web-antd/src/components/upload/src/props.d.ts`
不再推荐使用useDescription, 这个版本会标记为@deprecated, 下个次版本将会移除
框架所有使用useDescription组件的会替换为原生(TODO)
**REFACTOR**
- **文件上传/图片上传重构(破坏性更新 不兼容之前的api)**
- **文件上传/图片上传不再支持url用法 强制使用ossId**
- TableSwitch组件重构
- 管理员租户切换不再返回首页 直接刷新当前页(除特殊页面外会回到首页)
- 租户切换Select增加loading
- ~~modalLoading/drawerLoading改为调用内部的lock/unlock方法~~ 有待商榷暂时按老版本逻辑不变
- 登录验证码 增加loading
- DictEnum使用const代替enum
- TinyMCE组件重构 移除冗余代码/功能 增加loading
**ALPHA功能**
- 弹窗表单数据更改关闭时的提示框(可能最终不会加入) 测试页面: 参数管理
**BUG FIX**
- 重新登录 字典会unknown的情况[详细分析](https://gitee.com/dapppp/ruoyi-plus-vben5/issues/IBY27D)
- 测试菜单 请假申请 选中删除 需要根据状态判断
- 修复文件/图片在Safari中无法上传 file-type库与Safari不兼容导致
- 头像裁剪 图片加载失败一直处于loading无法上传
- 头像裁剪 私有桶会拼接timestamp参数导致sign计算异常无法上传 感谢cropperjs作者 https://github.com/fengyuanchen/cropperjs/issues/1230
- 租户选择下拉框会跟随body滚动(将下拉框样式的默认absolute改为fixed)
**OTHER**
- 字典管理 字典类型 表格选中行增加bold效果
- 全局圆角修改 与antd保持一致
- vditor(Markdown)升级3.10.9
- 老版本的文件/图片上传将于下个版本移除
- useDescription将于下个版本移除
- getVxePopupContainer与新版Vxe不兼容 先返回body(会导致滚动不跟随)后续版本再优化
# 1.2.3
**BUG FIX**

View File

@@ -1,6 +1,6 @@
{
"name": "@vben/web-antd",
"version": "1.2.3",
"version": "1.3.0",
"homepage": "https://vben.pro",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {
@@ -54,7 +54,8 @@
"tinymce": "^7.3.0",
"unplugin-vue-components": "^0.27.3",
"vue": "catalog:",
"vue-router": "catalog:"
"vue-router": "catalog:",
"vue3-colorpicker": "^2.3.0"
},
"devDependencies": {
"@types/crypto-js": "^4.2.2",

View File

@@ -8,42 +8,84 @@ import type { Component } from 'vue';
import type { BaseFormComponentType } from '@vben/common-ui';
import type { Recordable } from '@vben/types';
import { computed, defineComponent, getCurrentInstance, h, ref } from 'vue';
import {
computed,
defineAsyncComponent,
defineComponent,
getCurrentInstance,
h,
ref,
} from 'vue';
import { ApiComponent, globalShareState, IconPicker } from '@vben/common-ui';
import { $t } from '@vben/locales';
import {
AutoComplete,
Button,
Checkbox,
CheckboxGroup,
DatePicker,
Divider,
Input,
InputNumber,
InputPassword,
Mentions,
notification,
Radio,
RadioGroup,
RangePicker,
Rate,
Select,
Space,
Switch,
Textarea,
TimePicker,
TreeSelect,
Upload,
} from 'ant-design-vue';
import { notification } from 'ant-design-vue';
import { Tinymce as RichTextarea } from '#/components/tinymce';
import { FileUpload, ImageUpload } from '#/components/upload';
import { FileUploadOld, ImageUploadOld } from '#/components/upload-old';
const RichTextarea = defineAsyncComponent(() =>
import('#/components/tinymce/index').then((res) => res.Tinymce),
);
const FileUpload = defineAsyncComponent(() =>
import('#/components/upload').then((res) => res.FileUpload),
);
const ImageUpload = defineAsyncComponent(() =>
import('#/components/upload').then((res) => res.ImageUpload),
);
const AutoComplete = defineAsyncComponent(
() => import('ant-design-vue/es/auto-complete'),
);
const Button = defineAsyncComponent(() => import('ant-design-vue/es/button'));
const Checkbox = defineAsyncComponent(
() => import('ant-design-vue/es/checkbox'),
);
const CheckboxGroup = defineAsyncComponent(() =>
import('ant-design-vue/es/checkbox').then((res) => res.CheckboxGroup),
);
const DatePicker = defineAsyncComponent(
() => import('ant-design-vue/es/date-picker'),
);
const Divider = defineAsyncComponent(() => import('ant-design-vue/es/divider'));
const Input = defineAsyncComponent(() => import('ant-design-vue/es/input'));
const InputNumber = defineAsyncComponent(
() => import('ant-design-vue/es/input-number'),
);
const InputPassword = defineAsyncComponent(() =>
import('ant-design-vue/es/input').then((res) => res.InputPassword),
);
const Mentions = defineAsyncComponent(
() => import('ant-design-vue/es/mentions'),
);
const Radio = defineAsyncComponent(() => import('ant-design-vue/es/radio'));
const RadioGroup = defineAsyncComponent(() =>
import('ant-design-vue/es/radio').then((res) => res.RadioGroup),
);
const RangePicker = defineAsyncComponent(() =>
import('ant-design-vue/es/date-picker').then((res) => res.RangePicker),
);
const Rate = defineAsyncComponent(() => import('ant-design-vue/es/rate'));
const Select = defineAsyncComponent(() => import('ant-design-vue/es/select'));
const Space = defineAsyncComponent(() => import('ant-design-vue/es/space'));
const Switch = defineAsyncComponent(() => import('ant-design-vue/es/switch'));
const Textarea = defineAsyncComponent(() =>
import('ant-design-vue/es/input').then((res) => res.Textarea),
);
const TimePicker = defineAsyncComponent(
() => import('ant-design-vue/es/time-picker'),
);
const TreeSelect = defineAsyncComponent(
() => import('ant-design-vue/es/tree-select'),
);
const Upload = defineAsyncComponent(() => import('ant-design-vue/es/upload'));
const withDefaultPlaceholder = <T extends Component>(
component: T,
type: 'input' | 'select',
componentProps: Recordable<any> = {},
) => {
return defineComponent({
inheritAttrs: false,
@@ -74,7 +116,13 @@ const withDefaultPlaceholder = <T extends Component>(
return () =>
h(
component,
{ ...props, ...attrs, placeholder: placeholder.value, ref: innerRef },
{
...componentProps,
placeholder: placeholder.value,
...props,
...attrs,
ref: innerRef,
},
slots,
);
},
@@ -92,8 +140,10 @@ export type ComponentType =
| 'DefaultButton'
| 'Divider'
| 'FileUpload'
| 'FileUploadOld'
| 'IconPicker'
| 'ImageUpload'
| 'ImageUploadOld'
| 'Input'
| 'InputNumber'
| 'InputPassword'
@@ -118,38 +168,20 @@ async function initComponentAdapter() {
// 如果你的组件体积比较大,可以使用异步加载
// Button: () =>
// import('xxx').then((res) => res.Button),
ApiSelect: (props, { attrs, slots }) => {
return h(
ApiComponent,
{
placeholder: $t('ui.placeholder.select'),
...props,
...attrs,
component: Select,
loadingSlot: 'suffixIcon',
visibleEvent: 'onDropdownVisibleChange',
modelPropName: 'value',
},
slots,
);
},
ApiTreeSelect: (props, { attrs, slots }) => {
return h(
ApiComponent,
{
placeholder: $t('ui.placeholder.select'),
...props,
...attrs,
component: TreeSelect,
fieldNames: { label: 'label', value: 'value', children: 'children' },
loadingSlot: 'suffixIcon',
modelPropName: 'value',
optionsPropName: 'treeData',
visibleEvent: 'onVisibleChange',
},
slots,
);
},
ApiSelect: withDefaultPlaceholder(ApiComponent, 'select', {
component: Select,
loadingSlot: 'suffixIcon',
visibleEvent: 'onDropdownVisibleChange',
modelPropName: 'value',
}),
ApiTreeSelect: withDefaultPlaceholder(ApiComponent, 'select', {
component: TreeSelect,
fieldNames: { label: 'label', value: 'value', children: 'children' },
loadingSlot: 'suffixIcon',
modelPropName: 'value',
optionsPropName: 'treeData',
visibleEvent: 'onVisibleChange',
}),
AutoComplete,
Checkbox,
CheckboxGroup,
@@ -159,19 +191,11 @@ async function initComponentAdapter() {
return h(Button, { ...props, attrs, type: 'default' }, slots);
},
Divider,
IconPicker: (props, { attrs, slots }) => {
return h(
IconPicker,
{
iconSlot: 'addonAfter',
inputComponent: Input,
modelValueProp: 'value',
...props,
...attrs,
},
slots,
);
},
IconPicker: withDefaultPlaceholder(IconPicker, 'select', {
iconSlot: 'addonAfter',
inputComponent: Input,
modelValueProp: 'value',
}),
Input: withDefaultPlaceholder(Input, 'input'),
InputNumber: withDefaultPlaceholder(InputNumber, 'input'),
InputPassword: withDefaultPlaceholder(InputPassword, 'input'),
@@ -194,6 +218,8 @@ async function initComponentAdapter() {
ImageUpload,
FileUpload,
RichTextarea,
ImageUploadOld,
FileUploadOld,
};
// 将组件注册到全局共享状态中

View File

@@ -7,22 +7,6 @@ import { requestClient } from '#/api/request';
*/
export type AxiosProgressEvent = AxiosRequestConfig['onUploadProgress'];
/**
* 通过单文件上传接口
* @param file 上传的文件
* @param onUploadProgress 上传进度事件 非必传
* @returns 上传结果
*/
export function uploadApi(
file: Blob | File,
onUploadProgress?: AxiosProgressEvent,
) {
return requestClient.upload(
'/resource/oss/upload',
{ file },
{ onUploadProgress, timeout: 60_000 },
);
}
/**
* 默认上传结果
*/
@@ -31,3 +15,33 @@ export interface UploadResult {
fileName: string;
ossId: string;
}
/**
* 通过单文件上传接口
* @param file 上传的文件
* @param options 一些配置项
* @param options.onUploadProgress 上传进度事件
* @param options.signal 上传取消信号
* @param options.otherData 其他请求参数 后端拓展可能会用到
* @returns 上传结果
*/
export function uploadApi(
file: Blob | File,
options?: {
onUploadProgress?: AxiosProgressEvent;
otherData?: Record<string, any>;
signal?: AbortSignal;
},
) {
const { onUploadProgress, signal, otherData = {} } = options ?? {};
return requestClient.upload<UploadResult>(
'/resource/oss/upload',
{ file, ...otherData },
{ onUploadProgress, signal, timeout: 60_000 },
);
}
/**
* 上传api type
*/
export type UploadApi = typeof uploadApi;

View File

@@ -3,14 +3,14 @@ import { requestClient } from './request';
/**
* @description: contentType
*/
export enum ContentTypeEnum {
export const ContentTypeEnum = {
// form-data upload
FORM_DATA = 'multipart/form-data;charset=UTF-8',
FORM_DATA: 'multipart/form-data;charset=UTF-8',
// form-data qs
FORM_URLENCODED = 'application/x-www-form-urlencoded;charset=UTF-8',
FORM_URLENCODED: 'application/x-www-form-urlencoded;charset=UTF-8',
// json
JSON = 'application/json;charset=UTF-8',
}
JSON: 'application/json;charset=UTF-8',
} as const;
/**
* 通用下载接口 封装一层

View File

@@ -9,4 +9,5 @@ export interface LoginLog {
os: string;
msg: string;
loginTime: string;
clientKey: string;
}

View File

@@ -2,7 +2,7 @@ export interface OperationLog {
operId: string;
tenantId: string;
title: string;
businessType: number;
businessType: string;
businessTypes?: any;
method: string;
requestMethod: string;
@@ -14,7 +14,7 @@ export interface OperationLog {
operLocation: string;
operParam: string;
jsonResult: string;
status: number;
status: string;
errorMsg: string;
operTime: string;
costTime: number;

View File

@@ -93,9 +93,12 @@ function createRequestClient(baseURL: string) {
const language = preferences.app.locale.replace('-', '_');
config.headers['Accept-Language'] = language;
config.headers['Content-Language'] = language;
// 添加全局clientId
config.headers.clientId = clientId;
/**
* 添加全局clientId
* 关于header的clientId被错误绑定到实体类
* https://gitee.com/dapppp/ruoyi-plus-vben5/issues/IC0BDS
*/
config.headers.ClientID = clientId;
/**
* 格式化get/delete参数
* 如果包含自定义的paramsSerializer则不走此逻辑
@@ -225,7 +228,7 @@ function createRequestClient(baseURL: string) {
case 401: {
// 已经在登出过程中 不再执行
if (isLogoutProcessing) {
return;
throw new Error(timeoutMsg);
}
isLogoutProcessing = true;
const _msg = $t('http.loginTimeout');
@@ -235,7 +238,7 @@ function createRequestClient(baseURL: string) {
isLogoutProcessing = false;
});
// 不再执行下面逻辑
return;
throw new Error(_msg);
}
default: {
if (msg) {

View File

@@ -59,7 +59,11 @@ export function clientUpdate(data: Partial<Client>) {
* @param data 状态
*/
export function clientChangeStatus(data: any) {
return requestClient.putWithMsg<void>(Api.clientChangeStatus, data);
const requestData = {
clientId: data.clientId,
status: data.status,
};
return requestClient.putWithMsg<void>(Api.clientChangeStatus, requestData);
}
/**

View File

@@ -37,5 +37,9 @@ export function ossConfigRemove(ossConfigIds: IDS) {
// 更改OSS配置的状态
export function ossConfigChangeStatus(data: any) {
return requestClient.putWithMsg(Api.ossConfigChangeStatus, data);
const requestData = {
ossConfigId: data.ossConfigId,
status: data.status,
};
return requestClient.putWithMsg(Api.ossConfigChangeStatus, requestData);
}

View File

@@ -29,7 +29,7 @@ export function ossList(params?: PageQuery) {
* @param ossIds id数组
* @returns 信息数组
*/
export function ossInfo(ossIds: IDS) {
export function ossInfo(ossIds: ID | IDS) {
return requestClient.get<OssFile[]>(`${Api.ossInfo}/${ossIds}`);
}

View File

@@ -72,7 +72,11 @@ export function roleUpdate(data: Partial<Role>) {
* @returns void
*/
export function roleChangeStatus(data: Partial<Role>) {
return requestClient.putWithMsg<void>(Api.roleChangeStatus, data);
const requestData = {
roleId: data.roleId,
status: data.status,
};
return requestClient.putWithMsg<void>(Api.roleChangeStatus, requestData);
}
/**

View File

@@ -74,7 +74,11 @@ export function packageUpdate(data: Partial<TenantPackage>) {
* @returns void
*/
export function packageChangeStatus(data: Partial<TenantPackage>) {
return requestClient.putWithMsg<void>(Api.packageChangeStatus, data);
const packageId = {
packageId: data.packageId,
status: data.status,
};
return requestClient.putWithMsg<void>(Api.packageChangeStatus, packageId);
}
/**

View File

@@ -67,7 +67,12 @@ export function tenantUpdate(data: Partial<Tenant>) {
* @returns void
*/
export function tenantStatusChange(data: Partial<Tenant>) {
return requestClient.putWithMsg(Api.tenantStatus, data);
const requestData = {
id: data.id,
tenantId: data.tenantId,
status: data.status,
};
return requestClient.putWithMsg(Api.tenantStatus, requestData);
}
/**

View File

@@ -110,7 +110,11 @@ export function userUpdate(data: Partial<User>) {
* @returns void
*/
export function userStatusChange(data: Partial<User>) {
return requestClient.putWithMsg<void>(Api.userStatusChange, data);
const requestData = {
userId: data.userId,
status: data.status,
};
return requestClient.putWithMsg<void>(Api.userStatusChange, requestData);
}
/**

View File

@@ -66,6 +66,7 @@ export interface User {
roleIds?: string[];
postIds?: number[];
roleId: string;
deptName: string;
}
export interface Post {

View File

@@ -1,8 +1,7 @@
import { createApp, watchEffect } from 'vue';
import { registerAccessDirective } from '@vben/access';
import { initTippy, registerLoadingDirective } from '@vben/common-ui';
import { MotionPlugin } from '@vben/plugins/motion';
import { registerLoadingDirective } from '@vben/common-ui/es/loading';
import { preferences } from '@vben/preferences';
import { initStores } from '@vben/stores';
import '@vben/styles';
@@ -50,12 +49,14 @@ async function bootstrap(namespace: string) {
registerAccessDirective(app);
// 初始化 tippy
const { initTippy } = await import('@vben/common-ui/es/tippy');
initTippy(app);
// 配置路由及路由守卫
app.use(router);
// 配置Motion插件
const { MotionPlugin } = await import('@vben/plugins/motion');
app.use(MotionPlugin);
// 动态更新标题

View File

@@ -84,6 +84,10 @@ function handleReady(cropperInstance: Cropper) {
modalLoading(false);
}
function handleReadyError() {
modalLoading(false);
}
function handlerToolbar(event: string, arg?: number) {
if (event === 'scaleX') {
scaleX = arg = scaleX === -1 ? 1 : -1;
@@ -128,9 +132,11 @@ async function handleOk() {
v-if="src"
:circled="circled"
:src="src"
crossorigin="anonymous"
height="300px"
@cropend="handleCropend"
@ready="handleReady"
@ready-error="handleReadyError"
/>
</div>
@@ -322,7 +328,8 @@ async function handleOk() {
&-cropper {
height: 300px;
background: #eee;
background-image: linear-gradient(
background-image:
linear-gradient(
45deg,
rgb(0 0 0 / 25%) 25%,
transparent 0,

View File

@@ -26,14 +26,16 @@ const props = defineProps({
src: { required: true, type: String },
});
const emit = defineEmits(['cropend', 'ready', 'cropendError']);
const emit = defineEmits(['cropend', 'ready', 'cropendError', 'readyError']);
const defaultOptions: Options = {
aspectRatio: 1,
autoCrop: true,
background: true,
center: true,
checkCrossOrigin: true,
// 需要设置为false 否则会自动拼接timestamp 导致私有桶sign错误
// 需要配合img crossorigin='anonymous'使用(默认已经做了处理)
checkCrossOrigin: false,
checkOrientation: true,
cropBoxMovable: true,
cropBoxResizable: true,
@@ -94,6 +96,15 @@ async function init() {
if (!imgEl) {
return;
}
// 判断是否为正常访问的图片
try {
const resp = await fetch(props.src);
if (resp.status !== 200) {
emit('readyError');
}
} catch {
emit('readyError');
}
cropper.value = new Cropper(imgEl, {
...defaultOptions,
crop() {

View File

@@ -34,6 +34,9 @@ const props = {
useCollapse: { default: true, type: Boolean },
};
/**
* @deprecated 使用antd原生组件替代 下个版本将会移除
*/
export default defineComponent({
emits: ['register'],
// eslint-disable-next-line vue/order-in-components

View File

@@ -6,6 +6,9 @@ import type {
import { getCurrentInstance, ref, unref } from 'vue';
/**
* @deprecated 使用antd原生组件替代 下个版本将会移除
*/
export function useDescription(
props?: Partial<DescriptionProps>,
): UseDescReturnType {

View File

@@ -4,7 +4,7 @@ import type { DictData } from '#/api/system/dict/dict-data-model';
import { computed } from 'vue';
import { Tag } from 'ant-design-vue';
import { Spin, Tag } from 'ant-design-vue';
import { tagTypes } from './data';
@@ -41,12 +41,22 @@ const label = computed<number | string>(() => {
});
const tagComponent = computed(() => (color.value ? Tag : 'div'));
const loading = computed(() => {
return props.dicts?.length === 0;
});
</script>
<template>
<div>
<component :is="tagComponent" :class="cssClass" :color="color">
<component
v-if="!loading"
:is="tagComponent"
:class="cssClass"
:color="color"
>
{{ label }}
</component>
<Spin v-else :spinning="true" size="small" />
</div>
</template>

View File

@@ -1,84 +1,134 @@
<script lang="ts">
import { defineComponent } from 'vue';
<script setup lang="ts">
import { computed, ref } from 'vue';
import { Switch } from 'ant-design-vue';
import { $t } from '@vben/locales';
import { Modal, Switch } from 'ant-design-vue';
import { isFunction } from 'lodash-es';
export default defineComponent({
name: 'TableSwitch',
components: {
Switch,
},
inheritAttrs: false,
props: {
modelValue: {
type: [Boolean, String, Number],
default: false,
},
checkedText: {
type: String,
default: '启用',
},
unCheckedText: {
type: String,
default: '禁用',
},
// 使用严格相等判断 类型要正确
checkedValue: {
type: [Boolean, String, Number],
default: '0',
},
unCheckedValue: {
type: [Boolean, String, Number],
default: '1',
},
api: {
type: Function,
required: false,
default: null,
},
reload: {
type: Function,
required: false,
default: null,
},
},
emits: ['update:modelValue'],
setup(props, { emit }) {
type CheckedType = boolean | number | string;
async function onChange(checked: CheckedType, e: Event) {
// 阻止事件冒泡 否则会跟行选中冲突
e.stopPropagation();
const { checkedValue, unCheckedValue } = props;
// 原本的状态
const lastStatus =
checked === checkedValue ? unCheckedValue : checkedValue;
// 切换状态
emit('update:modelValue', checked);
const { api, reload } = props;
type CheckedType = boolean | number | string;
interface Props {
/**
* 选中的文本
* @default i18n 启用
*/
checkedText?: string;
/**
* 未选中的文本
* @default i18n 禁用
*/
unCheckedText?: string;
checkedValue?: CheckedType;
unCheckedValue?: CheckedType;
disabled?: boolean;
/**
* 需要自己在内部处理更新的逻辑 因为status已经双向绑定了 可以直接获取
*/
api: () => PromiseLike<void>;
/**
* 更新前是否弹窗确认
* @default false
*/
confirm?: boolean;
/**
* 对应的提示内容
* @param checked 选中的值(更新后的值)
* @default string '确认要更新状态吗?'
*/
confirmText?: (checked: CheckedType) => string;
}
const props = withDefaults(defineProps<Props>(), {
checkedText: undefined,
unCheckedText: undefined,
checkedValue: '0',
unCheckedValue: '1',
confirm: false,
confirmText: undefined,
});
const emit = defineEmits<{ reload: [] }>();
// 修改为computed 支持语言切换
const checkedTextComputed = computed(() => {
return props.checkedText ?? $t('pages.common.enable');
});
const unCheckedTextComputed = computed(() => {
return props.unCheckedText ?? $t('pages.common.disable');
});
const currentChecked = defineModel<CheckedType>('value', {
default: false,
});
const loading = ref(false);
function confirmUpdate(checked: CheckedType, lastStatus: CheckedType) {
const content = isFunction(props.confirmText)
? props.confirmText(checked)
: `确认要更新状态吗?`;
Modal.confirm({
title: '提示',
content,
centered: true,
onOk: async () => {
try {
loading.value = true;
const { api } = props;
isFunction(api) && (await api());
isFunction(reload) && (await reload());
emit('reload');
} catch {
emit('update:modelValue', lastStatus);
currentChecked.value = lastStatus;
} finally {
loading.value = false;
}
},
onCancel: () => {
currentChecked.value = lastStatus;
},
});
}
async function handleChange(checked: CheckedType, e: Event) {
// 阻止事件冒泡 否则会跟行选中冲突
e.stopPropagation();
const { checkedValue, unCheckedValue } = props;
// 原本的状态
const lastStatus = checked === checkedValue ? unCheckedValue : checkedValue;
// 切换状态
currentChecked.value = checked;
const { api } = props;
try {
loading.value = true;
if (props.confirm) {
confirmUpdate(checked, lastStatus);
return;
}
return {
onChange,
};
},
});
isFunction(api) && (await api());
emit('reload');
} catch {
currentChecked.value = lastStatus;
} finally {
loading.value = false;
}
}
</script>
<template>
<Switch
v-bind="$attrs"
:checked="modelValue"
:checked-children="checkedText"
:loading="loading"
:disabled="disabled"
:checked="currentChecked"
:checked-children="checkedTextComputed"
:checked-value="checkedValue"
:un-checked-children="unCheckedText"
:un-checked-children="unCheckedTextComputed"
:un-checked-value="unCheckedValue"
@change="onChange"
@change="handleChange"
/>
</template>

View File

@@ -1,17 +1,17 @@
<script setup lang="ts">
import type { MessageType } from 'ant-design-vue/es/message';
import type { SelectHandler } from 'ant-design-vue/es/vc-select/Select';
import type { TenantOption } from '#/api';
import { computed, onMounted, ref, unref } from 'vue';
import { useRouter } from 'vue-router';
import { computed, onMounted, ref, shallowRef, unref } from 'vue';
import { useRoute } from 'vue-router';
import { useAccess } from '@vben/access';
import { DEFAULT_HOME_PATH } from '@vben/constants';
import { useTabs } from '@vben/hooks';
import { $t } from '@vben/locales';
import { message, Select } from 'ant-design-vue';
import { message, Select, Spin } from 'ant-design-vue';
import { storeToRefs } from 'pinia';
import { tenantDynamicClear, tenantDynamicToggle } from '#/api/system/tenant';
@@ -35,24 +35,39 @@ const showToggle = computed<boolean>(() => {
});
onMounted(async () => {
// 没有超级管理员权限 不会调用接口
if (!hasAccessByRoles(['superadmin'])) {
return;
}
await initTenant();
});
const { closeAllTabs } = useTabs();
const router = useRouter();
function close(checked: boolean) {
const route = useRoute();
const { closeOtherTabs, refreshTab, closeAllTabs } = useTabs();
async function close(checked: boolean) {
// store设置状态
setChecked(checked);
// 需要关闭全部标签页
closeAllTabs();
// 切换完加载首页
router.push(DEFAULT_HOME_PATH);
/**
* 切换租户需要回到首页的页面 一般为带id的页面
* 其他则直接刷新页面
*/
if (route.meta.requireHomeRedirect) {
await closeAllTabs();
} else {
// 先关闭再刷新 这里不用Promise.all()
await closeOtherTabs();
await refreshTab();
}
}
const dictStore = useDictStore();
// 用于清理上一条message
const messageInstance = shallowRef<MessageType | null>();
// loading加载中效果
const loading = ref(false);
/**
* 选中租户的处理
* @param tenantId tenantId
@@ -63,23 +78,46 @@ const onSelected: SelectHandler = async (tenantId: string, option: any) => {
// createMessage.info('选择一致');
return;
}
await tenantDynamicToggle(tenantId);
lastSelected.value = tenantId;
message.success(
`${$t('component.tenantToggle.switch')} ${option.companyName}`,
);
close(true);
// 需要放在宏队列处理 直接清空页面由于没有字典会有样式问题(标签变成unknown)
setTimeout(() => dictStore.resetCache());
try {
loading.value = true;
await tenantDynamicToggle(tenantId);
lastSelected.value = tenantId;
// 关闭之前的message 只保留一条
messageInstance.value?.();
messageInstance.value = message.success(
`${$t('component.tenantToggle.switch')} ${option.companyName}`,
);
close(true);
// 需要放在宏队列处理 直接清空页面由于没有字典会有样式问题(标签变成unknown)
setTimeout(() => dictStore.resetCache());
} catch (error) {
console.error(error);
} finally {
loading.value = false;
}
};
async function onDeselect() {
await tenantDynamicClear();
message.success($t('component.tenantToggle.reset'));
lastSelected.value = '';
close(false);
// 需要放在宏队列处理 直接清空页面由于没有字典会有样式问题(标签变成unknown)
setTimeout(() => dictStore.resetCache());
try {
loading.value = true;
await tenantDynamicClear();
// 关闭之前的message 只保留一条
messageInstance.value?.();
messageInstance.value = message.success($t('component.tenantToggle.reset'));
lastSelected.value = '';
close(false);
// 需要放在宏队列处理 直接清空页面由于没有字典会有样式问题(标签变成unknown)
setTimeout(() => dictStore.resetCache());
} catch (error) {
console.error(error);
} finally {
loading.value = false;
}
}
/**
@@ -96,16 +134,22 @@ function filterOption(input: string, option: TenantOption) {
<div v-if="showToggle" class="mr-[8px] hidden md:block">
<Select
v-model:value="selected"
:disabled="loading"
:field-names="{ label: 'companyName', value: 'tenantId' }"
:filter-option="filterOption"
:options="tenantList"
:placeholder="$t('component.tenantToggle.placeholder')"
:dropdown-style="{ position: 'fixed', zIndex: 1024 }"
allow-clear
class="w-60"
show-search
@deselect="onDeselect"
@select="onSelected"
/>
>
<template v-if="loading" #suffixIcon>
<Spin size="small" spinning />
</template>
</Select>
</div>
</template>

View File

@@ -1,80 +1,50 @@
<script lang="ts" setup>
<script setup lang="ts">
import type { IPropTypes } from '@tinymce/tinymce-vue/lib/cjs/main/ts/components/EditorPropTypes';
import type { Editor as EditorType } from 'tinymce/tinymce';
import type { PropType } from 'vue';
import type { AxiosProgressEvent, UploadResult } from '#/api';
import type { UploadResult } from '#/api/core/upload';
import {
computed,
nextTick,
onActivated,
onBeforeUnmount,
onDeactivated,
onMounted,
ref,
unref,
useAttrs,
watch,
} from 'vue';
import { computed, nextTick, ref, shallowRef, useAttrs, watch } from 'vue';
import { preferences, usePreferences } from '@vben/preferences';
import { buildShortUUID } from '@vben/utils';
import Editor from '@tinymce/tinymce-vue';
import { isNumber } from 'lodash-es';
import { Spin } from 'ant-design-vue';
import { camelCase } from 'lodash-es';
import { uploadApi } from '#/api/core/upload';
import { bindHandlers } from './helper';
import ImgUpload from './img-upload.vue';
import { uploadApi } from '#/api';
import {
plugins as defaultPlugins,
toolbar as defaultToolbar,
} from './tinymce';
defineOptions({ inheritAttrs: false });
const props = defineProps({
height: {
default: 400,
required: false,
type: [Number, String] as PropType<number | string>,
},
options: {
default: () => ({}),
// eslint-disable-next-line no-use-before-define
type: Object as PropType<Partial<InitOptions>>,
},
plugins: {
default: defaultPlugins,
type: String,
},
showImageUpload: {
default: true,
type: Boolean,
},
toolbar: {
default: defaultToolbar,
type: String,
},
width: {
default: 'auto',
required: false,
type: [Number, String] as PropType<number | string>,
},
});
const emit = defineEmits(['change']);
} from '#/components/tinymce/src/tinymce';
type InitOptions = IPropTypes['init'];
/**
* 外部使用 v-model 绑定值
*/
const modelValue = defineModel('modelValue', { default: '', type: String });
interface Props {
height?: number | string;
options?: Partial<InitOptions>;
plugins?: string;
toolbar?: string;
disabled?: boolean;
}
defineOptions({
name: 'Tinymce',
inheritAttrs: false,
});
const props = withDefaults(defineProps<Props>(), {
height: 400,
options: () => ({}),
plugins: defaultPlugins,
toolbar: defaultToolbar,
disabled: false,
});
const emit = defineEmits<{
mounted: [];
}>();
/**
* https://www.jianshu.com/p/59a9c3802443
* 使用自托管方案本地代替cdn 没有key的限制
@@ -82,21 +52,13 @@ const modelValue = defineModel('modelValue', { default: '', type: String });
*/
const tinymceScriptSrc = `${import.meta.env.VITE_BASE}tinymce/tinymce.min.js`;
const attrs = useAttrs();
const editorRef = ref<EditorType>();
const fullscreen = ref(false);
const tinymceId = ref<string>(buildShortUUID('tiny-vue'));
const elRef = ref<HTMLElement | null>(null);
const containerWidth = computed(() => {
const width = props.width;
if (isNumber(width)) {
return `${width}px`;
}
return width;
const content = defineModel<string>('modelValue', {
default: '',
});
const { isDark } = usePreferences();
const editorRef = shallowRef<EditorType | null>(null);
const { isDark, locale } = usePreferences();
const skinName = computed(() => {
return isDark.value ? 'oxide-dark' : 'oxide';
});
@@ -105,30 +67,6 @@ const contentCss = computed(() => {
return isDark.value ? 'dark' : 'default';
});
/**
* 通过v-if来挂载/卸载组件
* 来完成主题切换/语言切换
*/
const init = ref(true);
watch(
() => [preferences.theme.mode, preferences.app.locale],
() => {
if (!editorRef.value) {
return;
}
destroy();
init.value = false;
// 放在下一次tick来切换
// 需要先加载组件 也就是v-if为true 然后需要拿到editorRef 必须放在setTimeout(相当于onMounted)
nextTick(() => {
init.value = true;
setTimeout(() => {
setEditorMode();
});
});
},
);
/**
* tinymce支持 en zh_CN
*/
@@ -140,6 +78,26 @@ const langName = computed(() => {
return 'zh_CN';
});
/**
* 通过v-if来挂载/卸载组件来完成主题切换切换
* 语言切换也需要监听 不监听在切换时候会显示原始<textarea>样式
*/
const init = ref(true);
watch([isDark, locale], async () => {
if (!editorRef.value) {
return;
}
// 相当于手动unmounted清理 非常重要
editorRef.value.destroy();
init.value = false;
// 放在下一次tick来切换
// 需要先加载组件 也就是v-if为true 然后需要拿到editorRef 必须放在setTimeout(相当于onMounted)
await nextTick();
init.value = true;
});
// 加载完毕前显示spin
const loading = ref(true);
const initOptions = computed((): InitOptions => {
const { height, options, plugins, toolbar } = props;
return {
@@ -163,6 +121,7 @@ const initOptions = computed((): InitOptions => {
* images_upload_handler启用时为上传
*/
paste_data_images: true,
images_file_types: 'jpeg,jpg,png,gif,bmp,webp',
plugins,
quickbars_selection_toolbar:
'bold italic | quicklink h2 h3 blockquote quickimage quicktable',
@@ -175,12 +134,18 @@ const initOptions = computed((): InitOptions => {
* @param blobInfo
* 大坑 不要调用这两个函数 success failure:
* 使用resolve/reject代替
* (PS: 新版已经没有success failure)
*/
images_upload_handler: (blobInfo) => {
images_upload_handler: (blobInfo, progress) => {
return new Promise((resolve, reject) => {
const file = blobInfo.blob();
// const filename = blobInfo.filename();
uploadApi(file)
// 进度条事件
const progressEvent: AxiosProgressEvent = (e) => {
const percent = Math.trunc((e.loaded / e.total!) * 100);
progress(percent);
};
uploadApi(file, { onUploadProgress: progressEvent })
.then((response) => {
const { url } = response as unknown as UploadResult;
console.log('tinymce上传图片:', url);
@@ -188,168 +153,51 @@ const initOptions = computed((): InitOptions => {
})
.catch((error) => {
console.error('tinymce上传图片失败:', error);
reject(error.message);
// eslint-disable-next-line prefer-promise-reject-errors
reject({ message: error.message, remove: true });
});
});
},
setup: (editor) => {
editorRef.value = editor;
editor.on('init', (e) => initSetup(e));
editor.on('init', () => {
emit('mounted');
loading.value = false;
});
},
};
});
const attrs = useAttrs();
/**
* 监听options.readonly
* 获取透传的事件 通过v-on绑定
* 可绑定的事件 https://www.tiny.cloud/docs/tinymce/latest/vue-ref/#event-binding
*/
watch(
() => props.options,
(options) => {
const getDisabled = options && Reflect.get(options, 'readonly');
const editor = unref(editorRef);
if (editor) {
editor.mode.set(getDisabled ? 'readonly' : 'design');
const events = computed(() => {
const onEvents: Record<string, any> = {};
for (const key in attrs) {
if (key.startsWith('on')) {
const eventKey = camelCase(key.split('on')[1]!);
onEvents[eventKey] = attrs[key];
}
},
);
onMounted(() => {
if (!initOptions.value.inline) {
tinymceId.value = buildShortUUID('tiny-vue');
}
nextTick(() => {
setTimeout(() => {
initEditor();
setEditorMode();
}, 30);
});
return onEvents;
});
onBeforeUnmount(() => {
destroy();
});
onDeactivated(() => {
destroy();
});
onActivated(() => {
setEditorMode();
});
function setEditorMode() {
const editor = unref(editorRef);
if (editor) {
const mode = props.options.readonly ? 'readonly' : 'design';
editor.mode.set(mode);
}
}
function destroy() {
const editor = unref(editorRef);
editor?.destroy();
}
function initEditor() {
const el = unref(elRef);
if (el) {
el.style.visibility = '';
}
}
function initSetup(e: any) {
const editor = unref(editorRef);
if (!editor) {
return;
}
const value = modelValue.value || '';
editor.setContent(value);
bindModelHandlers(editor);
bindHandlers(e, attrs, unref(editorRef));
}
function setValue(editor: Record<string, any>, val?: string, prevVal?: string) {
if (
editor &&
typeof val === 'string' &&
val !== prevVal &&
val !== editor.getContent({ format: attrs.outputFormat })
) {
editor.setContent(val);
}
}
function bindModelHandlers(editor: any) {
const modelEvents = attrs.modelEvents ?? null;
const normalizedEvents = Array.isArray(modelEvents)
? modelEvents.join(' ')
: modelEvents;
watch(
() => modelValue.value,
(val, prevVal) => {
setValue(editor, val, prevVal);
},
);
editor.on(normalizedEvents || 'change keyup undo redo', () => {
const content = editor.getContent({ format: attrs.outputFormat });
emit('change', content);
});
editor.on('FullscreenStateChanged', (e: any) => {
fullscreen.value = e.state;
});
}
const disabled = computed(() => props.options.readonly ?? false);
function getUploadingImgName(name: string) {
return `[uploading:${name}]`;
}
function handleImageUploading(name: string) {
const editor = unref(editorRef);
if (!editor) {
return;
}
editor.execCommand('mceInsertContent', false, getUploadingImgName(name));
const content = editor?.getContent() ?? '';
setValue(editor, content);
}
function handleDone(name: string, url: string) {
const editor = unref(editorRef);
if (!editor) {
return;
}
const content = editor?.getContent() ?? '';
const val =
content?.replace(getUploadingImgName(name), `<img src="${url}"/>`) ?? '';
setValue(editor, val);
}
</script>
<template>
<div :style="{ width: containerWidth }" class="app-tinymce">
<ImgUpload
v-if="showImageUpload"
v-show="editorRef"
:disabled="disabled"
:fullscreen="fullscreen"
@done="handleDone"
@uploading="handleImageUploading"
/>
<Editor
v-if="!initOptions.inline && init"
v-model="modelValue"
:init="initOptions"
:style="{ visibility: 'hidden', zIndex: 3000 }"
:tinymce-script-src="tinymceScriptSrc"
license-key="gpl"
/>
<slot v-else></slot>
<div class="app-tinymce">
<Spin :spinning="loading">
<Editor
v-if="init"
v-model="content"
:init="initOptions"
:tinymce-script-src="tinymceScriptSrc"
:disabled="disabled"
license-key="gpl"
v-on="events"
/>
</Spin>
</div>
</template>
@@ -362,23 +210,13 @@ function handleDone(name: string, url: string) {
/** 该样式默认为1300的zIndex */
z-index: 2025;
}
</style>
<style lang="scss" scoped>
/**
隐藏右上角upgrade按钮
*/
:deep(.tox-promotion) {
display: none !important;
}
.app-tinymce {
position: relative;
line-height: normal;
:deep(.textarea) {
z-index: -1;
visibility: hidden;
/**
隐藏右上角upgrade按钮
*/
.tox-promotion {
display: none;
}
}
</style>

View File

@@ -1,85 +0,0 @@
const validEvents = new Set([
'onActivate',
'onAddUndo',
'onBeforeAddUndo',
'onBeforeExecCommand',
'onBeforeGetContent',
'onBeforePaste',
'onBeforeRenderUI',
'onBeforeSetContent',
'onBlur',
'onChange',
'onClearUndos',
'onClick',
'onContextMenu',
'onCopy',
'onCut',
'onDblclick',
'onDeactivate',
'onDirty',
'onDrag',
'onDragDrop',
'onDragEnd',
'onDragGesture',
'onDragOver',
'onDrop',
'onExecCommand',
'onFocus',
'onFocusIn',
'onFocusOut',
'onGetContent',
'onHide',
'onInit',
'onKeyDown',
'onKeyPress',
'onKeyUp',
'onLoadContent',
'onMouseDown',
'onMouseEnter',
'onMouseLeave',
'onMouseMove',
'onMouseOut',
'onMouseOver',
'onMouseUp',
'onNodeChange',
'onObjectResized',
'onObjectResizeStart',
'onObjectSelected',
'onPaste',
'onPostProcess',
'onPostRender',
'onPreProcess',
'onProgressState',
'onRedo',
'onRemove',
'onReset',
'onSaveContent',
'onSelectionChange',
'onSetAttrib',
'onSetContent',
'onShow',
'onSubmit',
'onUndo',
'onVisualAid',
]);
const isValidKey = (key: string) => validEvents.has(key);
export const bindHandlers = (
initEvent: Event,
listeners: any,
editor: any,
): void => {
Object.keys(listeners)
.filter((element) => isValidKey(element))
.forEach((key: string) => {
const handler = listeners[key];
if (typeof handler === 'function') {
if (key === 'onInit') {
handler(initEvent, editor);
} else {
editor.on(key.slice(2), (e: any) => handler(e, editor));
}
}
});
};

View File

@@ -1,115 +0,0 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { useAppConfig } from '@vben/hooks';
import { useAccessStore } from '@vben/stores';
import { message, Upload } from 'ant-design-vue';
defineOptions({ name: 'TinymceImageUpload' });
const props = defineProps({
disabled: {
default: false,
type: Boolean,
},
fullscreen: {
type: Boolean,
},
});
const emit = defineEmits(['uploading', 'done', 'error']);
let uploading = false;
const { apiURL, clientId } = useAppConfig(
import.meta.env,
import.meta.env.PROD,
);
const accessStore = useAccessStore();
const uploadUrl = `${apiURL}/resource/oss/upload`;
// 使用upload组件只能这样上传
const headers = {
Authorization: `Bearer ${accessStore.accessToken}`,
clientId,
};
const getButtonProps = computed(() => {
const { disabled } = props;
return {
disabled,
};
});
function handleChange(info: Record<string, any>) {
const file = info.file;
const status = file?.status;
// const url = file?.response?.data.url;
const name = file?.name;
switch (status) {
case 'done': {
// http 200会走到这里 需要再次判断
const { response } = file;
const { code, data, msg = '服务器错误' } = response;
if (code === 200) {
const { url } = data;
emit('done', name, url);
} else {
message.error(msg);
}
// emit('done', name, url);
uploading = false;
break;
}
case 'error': {
emit('error');
uploading = false;
break;
}
case 'uploading': {
if (!uploading) {
emit('uploading', name);
uploading = true;
}
break;
}
// No default
}
}
</script>
<template>
<div :class="[{ fullscreen }]" class="tinymce-image-upload">
<Upload
:action="uploadUrl"
:headers="headers"
:show-upload-list="false"
accept=".jpg,.jpeg,.gif,.png,.webp"
multiple
name="file"
@change="handleChange"
>
<!-- 这里要改成i18n -->
<a-button type="primary" v-bind="{ ...getButtonProps }">
图片上传
</a-button>
</Upload>
</div>
</template>
<style lang="scss" scoped>
.tinymce-image-upload {
position: absolute;
top: 4px;
right: 10px;
z-index: 20;
&.fullscreen {
position: fixed;
z-index: 10000;
}
}
</style>

View File

@@ -317,7 +317,7 @@ function getKeys(records: MenuPermissionOption[], addCurrent: boolean) {
function getCheckedKeys() {
// 节点关联
if (association.value) {
const records = tableApi?.grid?.getCheckboxRecords?.() ?? [];
const records = tableApi?.grid?.getCheckboxRecords?.(true) ?? [];
// 子节点
const nodeKeys = getKeys(records, true);
// 所有父节点
@@ -329,7 +329,7 @@ function getCheckedKeys() {
// 节点独立
// 勾选的行
const records = tableApi?.grid?.getCheckboxRecords?.() ?? [];
const records = tableApi?.grid?.getCheckboxRecords?.(true) ?? [];
// 全部数据 用于获取permissions
const allRecords = tableApi?.grid?.getData?.() ?? [];
// 表格已经选中的行ids

View File

@@ -0,0 +1,8 @@
/**
* @description: 旧版文件上传组件 使用FileUpload代替
*/
export { default as FileUploadOld } from './src/file-upload.vue';
/**
* @description: 旧版图片上传组件 使用ImageUpload代替
*/
export { default as ImageUploadOld } from './src/image-upload.vue';

View File

@@ -0,0 +1,240 @@
<script lang="ts" setup>
import type { UploadFile, UploadProps } from 'ant-design-vue';
import type { UploadRequestOption } from 'ant-design-vue/lib/vc-upload/interface';
import type { AxiosProgressEvent, UploadApi } from '#/api';
import { ref, toRefs, watch } from 'vue';
import { $t } from '@vben/locales';
import { UploadOutlined } from '@ant-design/icons-vue';
import { message, Upload } from 'ant-design-vue';
import { isArray, isFunction, isObject, isString } from 'lodash-es';
import { uploadApi } from '#/api';
import { checkFileType } from './helper';
import { UploadResultStatus } from './typing';
import { useUploadType } from './use-upload';
defineOptions({ name: 'FileUpload', inheritAttrs: false });
const props = withDefaults(
defineProps<{
/**
* 建议使用拓展名(不带.)
* 或者文件头 image/png等(测试判断不准确) 不支持image/*类似的写法
* 需自行改造 ./helper/checkFileType方法
*/
accept?: string[];
api?: UploadApi;
disabled?: boolean;
helpText?: string;
// 最大数量的文件Infinity不限制
maxNumber?: number;
// 文件最大多少MB
maxSize?: number;
// 是否支持多选
multiple?: boolean;
// support xxx.xxx.xx
// 返回的字段 默认url
resultField?: 'fileName' | 'ossId' | 'url' | string;
/**
* 是否显示下面的描述
*/
showDescription?: boolean;
value?: string[];
}>(),
{
value: () => [],
disabled: false,
helpText: '',
maxSize: 2,
maxNumber: 1,
accept: () => [],
multiple: false,
api: () => uploadApi,
resultField: '',
showDescription: true,
},
);
const emit = defineEmits(['change', 'update:value', 'delete']);
const { accept, helpText, maxNumber, maxSize } = toRefs(props);
const isInnerOperate = ref<boolean>(false);
const { getStringAccept } = useUploadType({
acceptRef: accept,
helpTextRef: helpText,
maxNumberRef: maxNumber,
maxSizeRef: maxSize,
});
const fileList = ref<UploadProps['fileList']>([]);
const isLtMsg = ref<boolean>(true);
const isActMsg = ref<boolean>(true);
const isFirstRender = ref<boolean>(true);
watch(
() => props.value,
(v) => {
if (isInnerOperate.value) {
isInnerOperate.value = false;
return;
}
let value: string[] = [];
if (v) {
if (isArray(v)) {
value = v;
} else {
value.push(v);
}
fileList.value = value.map((item, i) => {
if (item && isString(item)) {
return {
uid: `${-i}`,
name: item.slice(Math.max(0, item.lastIndexOf('/') + 1)),
status: 'done',
url: item,
};
} else if (item && isObject(item)) {
return item;
}
return null;
}) as UploadProps['fileList'];
}
if (!isFirstRender.value) {
emit('change', value);
isFirstRender.value = false;
}
},
{
immediate: true,
deep: true,
},
);
const handleRemove = async (file: UploadFile) => {
if (fileList.value) {
const index = fileList.value.findIndex((item) => item.uid === file.uid);
index !== -1 && fileList.value.splice(index, 1);
const value = getValue();
isInnerOperate.value = true;
emit('update:value', value);
emit('change', value);
emit('delete', file);
}
};
const beforeUpload = async (file: File) => {
const { maxSize, accept } = props;
const isAct = await checkFileType(file, accept);
if (!isAct) {
message.error($t('component.upload.acceptUpload', [accept]));
isActMsg.value = false;
// 防止弹出多个错误提示
setTimeout(() => (isActMsg.value = true), 1000);
}
const isLt = file.size / 1024 / 1024 > maxSize;
if (isLt) {
message.error($t('component.upload.maxSizeMultiple', [maxSize]));
isLtMsg.value = false;
// 防止弹出多个错误提示
setTimeout(() => (isLtMsg.value = true), 1000);
}
return (isAct && !isLt) || Upload.LIST_IGNORE;
};
async function customRequest(info: UploadRequestOption<any>) {
const { api } = props;
if (!api || !isFunction(api)) {
console.warn('upload api must exist and be a function');
return;
}
try {
// 进度条事件
const progressEvent: AxiosProgressEvent = (e) => {
const percent = Math.trunc((e.loaded / e.total!) * 100);
info.onProgress!({ percent });
};
const res = await api?.(info.file as File, {
onUploadProgress: progressEvent,
});
/**
* 由getValue处理 传对象过去
* 直接传string(id)会被转为Number
* 内部的逻辑由requestClient.upload处理 这里不用判断业务状态码 不符合会自动reject
*/
info.onSuccess!(res);
message.success($t('component.upload.uploadSuccess'));
// 获取
const value = getValue();
isInnerOperate.value = true;
emit('update:value', value);
emit('change', value);
} catch (error: any) {
console.error(error);
info.onError!(error);
}
}
function getValue() {
const list = (fileList.value || [])
.filter((item) => item?.status === UploadResultStatus.DONE)
.map((item: any) => {
if (item?.response && props?.resultField) {
return item?.response?.[props.resultField];
}
// 适用于已经有图片 回显的情况 会默认在init处理为{url: 'xx'}
if (item?.url) {
return item.url;
}
// 注意这里取的key为 url
return item?.response?.url;
});
return list;
}
</script>
<template>
<div>
<Upload
v-bind="$attrs"
v-model:file-list="fileList"
:accept="getStringAccept"
:before-upload="beforeUpload"
:custom-request="customRequest"
:disabled="disabled"
:max-count="maxNumber"
:multiple="multiple"
list-type="text"
:progress="{ showInfo: true }"
@remove="handleRemove"
>
<div v-if="fileList && fileList.length < maxNumber">
<a-button>
<UploadOutlined />
{{ $t('component.upload.upload') }}
</a-button>
</div>
<div v-if="showDescription" class="mt-2 flex flex-wrap items-center">
请上传不超过
<div class="text-primary mx-1 font-bold">{{ maxSize }}MB</div>
<div class="text-primary mx-1 font-bold">{{ accept.join('/') }}</div>
格式文件
</div>
</Upload>
</div>
</template>
<style>
.ant-upload-select-picture-card i {
font-size: 32px;
color: #999;
}
.ant-upload-select-picture-card .ant-upload-text {
margin-top: 8px;
color: #666;
}
</style>

View File

@@ -0,0 +1,51 @@
import { fileTypeFromBlob } from '@vben/utils';
/**
* 不支持txt文件 @see https://github.com/sindresorhus/file-type/issues/55
* 需要自行修改
* @param file file对象
* @param accepts 文件类型数组 包括拓展名(不带点) 文件头(image/png等 不包括泛写法即image/*)
* @returns 是否通过文件类型校验
*/
export async function checkFileType(file: File, accepts: string[]) {
if (!accepts || accepts?.length === 0) {
return true;
}
console.log(file);
const fileType = await fileTypeFromBlob(file);
if (!fileType) {
console.error('无法获取文件类型');
return false;
}
console.log('文件类型', fileType);
// 是否文件拓展名/文件头任意有一个匹配
return accepts.includes(fileType.ext) || accepts.includes(fileType.mime);
}
/**
* 默认图片类型
*/
export const defaultImageAccept = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
/**
* 判断文件类型是否符合要求
* @param file file对象
* @param accepts 文件类型数组 包括拓展名(不带点) 文件头(image/png等 不包括泛写法即image/*)
* @returns 是否通过文件类型校验
*/
export async function checkImageFileType(file: File, accepts: string[]) {
// 空的accepts 使用默认规则
if (!accepts || accepts.length === 0) {
accepts = defaultImageAccept;
}
const fileType = await fileTypeFromBlob(file);
if (!fileType) {
console.error('无法获取文件类型');
return false;
}
console.log('文件类型', fileType);
// 是否文件拓展名/文件头任意有一个匹配
if (accepts.includes(fileType.ext) || accepts.includes(fileType.mime)) {
return true;
}
return false;
}

View File

@@ -0,0 +1,323 @@
<script lang="ts" setup>
import type { UploadFile, UploadProps } from 'ant-design-vue';
import type { UploadRequestOption } from 'ant-design-vue/lib/vc-upload/interface';
import type { AxiosProgressEvent, UploadApi } from '#/api';
import { ref, toRefs, watch } from 'vue';
import { $t } from '@vben/locales';
import { PlusOutlined } from '@ant-design/icons-vue';
import { message, Modal, Upload } from 'ant-design-vue';
import { isArray, isFunction, isObject, isString, uniqueId } from 'lodash-es';
import { uploadApi } from '#/api';
import { ossInfo } from '#/api/system/oss';
import { checkImageFileType, defaultImageAccept } from './helper';
import { UploadResultStatus } from './typing';
import { useUploadType } from './use-upload';
defineOptions({ name: 'ImageUpload', inheritAttrs: false });
const props = withDefaults(
defineProps<{
/**
* 包括拓展名(不带点) 文件头(image/png等 不包括泛写法即image/*)
*/
accept?: string[];
api?: UploadApi;
disabled?: boolean;
helpText?: string;
// eslint-disable-next-line no-use-before-define
listType?: ListType;
// 最大数量的文件Infinity不限制
maxNumber?: number;
// 文件最大多少MB
maxSize?: number;
// 是否支持多选
multiple?: boolean;
// support xxx.xxx.xx
// 返回的字段 默认url
resultField?: 'fileName' | 'ossId' | 'url';
/**
* 是否显示下面的描述
*/
showDescription?: boolean;
value?: string | string[];
}>(),
{
value: () => [],
disabled: false,
listType: 'picture-card',
helpText: '',
maxSize: 2,
maxNumber: 1,
accept: () => defaultImageAccept,
multiple: false,
api: () => uploadApi,
resultField: 'url',
showDescription: true,
},
);
const emit = defineEmits(['change', 'update:value', 'delete']);
type ListType = 'picture' | 'picture-card' | 'text';
const { accept, helpText, maxNumber, maxSize } = toRefs(props);
const isInnerOperate = ref<boolean>(false);
const { getStringAccept } = useUploadType({
acceptRef: accept,
helpTextRef: helpText,
maxNumberRef: maxNumber,
maxSizeRef: maxSize,
});
const previewOpen = ref<boolean>(false);
const previewImage = ref<string>('');
const previewTitle = ref<string>('');
const fileList = ref<UploadProps['fileList']>([]);
const isLtMsg = ref<boolean>(true);
const isActMsg = ref<boolean>(true);
const isFirstRender = ref<boolean>(true);
watch(
() => props.value,
async (v) => {
if (isInnerOperate.value) {
isInnerOperate.value = false;
return;
}
let value: string | string[] = [];
if (v) {
const _fileList: string[] = [];
if (isString(v)) {
_fileList.push(v);
}
if (isArray(v)) {
_fileList.push(...v);
}
// 直接赋值 可能为string | string[]
value = v;
const withUrlList: UploadProps['fileList'] = [];
for (const item of _fileList) {
// ossId情况
if (props.resultField === 'ossId') {
const resp = await ossInfo([item]);
if (item && isString(item)) {
withUrlList.push({
uid: item, // ossId作为uid 方便getValue获取
name: item.slice(Math.max(0, item.lastIndexOf('/') + 1)),
status: 'done',
url: resp?.[0]?.url,
});
} else if (item && isObject(item)) {
withUrlList.push({
...(item as any),
uid: item,
url: resp?.[0]?.url,
});
}
} else {
// 非ossId情况
if (item && isString(item)) {
withUrlList.push({
uid: uniqueId(),
name: item.slice(Math.max(0, item.lastIndexOf('/') + 1)),
status: 'done',
url: item,
});
} else if (item && isObject(item)) {
withUrlList.push(item);
}
}
}
fileList.value = withUrlList;
}
if (!isFirstRender.value) {
emit('change', value);
isFirstRender.value = false;
}
},
{
immediate: true,
deep: true,
},
);
function getBase64<T extends ArrayBuffer | null | string>(file: File) {
return new Promise<T>((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.addEventListener('load', () => {
resolve(reader.result as T);
});
reader.addEventListener('error', (error) => reject(error));
});
}
const handlePreview = async (file: UploadFile) => {
if (!file.url && !file.preview) {
file.preview = await getBase64<string>(file.originFileObj!);
}
previewImage.value = file.url || file.preview || '';
previewOpen.value = true;
previewTitle.value =
file.name ||
previewImage.value.slice(
Math.max(0, previewImage.value.lastIndexOf('/') + 1),
);
};
const handleRemove = async (file: UploadFile) => {
if (fileList.value) {
const index = fileList.value.findIndex((item) => item.uid === file.uid);
index !== -1 && fileList.value.splice(index, 1);
const value = getValue();
isInnerOperate.value = true;
emit('update:value', value);
emit('change', value);
emit('delete', file);
}
};
const handleCancel = () => {
previewOpen.value = false;
previewTitle.value = '';
};
const beforeUpload = async (file: File) => {
const { maxSize, accept } = props;
const isAct = await checkImageFileType(file, accept);
if (!isAct) {
message.error($t('component.upload.acceptUpload', [accept]));
isActMsg.value = false;
// 防止弹出多个错误提示
setTimeout(() => (isActMsg.value = true), 1000);
}
const isLt = file.size / 1024 / 1024 > maxSize;
if (isLt) {
message.error($t('component.upload.maxSizeMultiple', [maxSize]));
isLtMsg.value = false;
// 防止弹出多个错误提示
setTimeout(() => (isLtMsg.value = true), 1000);
}
return (isAct && !isLt) || Upload.LIST_IGNORE;
};
async function customRequest(info: UploadRequestOption<any>) {
const { api } = props;
if (!api || !isFunction(api)) {
console.warn('upload api must exist and be a function');
return;
}
try {
// 进度条事件
const progressEvent: AxiosProgressEvent = (e) => {
const percent = Math.trunc((e.loaded / e.total!) * 100);
info.onProgress!({ percent });
};
const res = await api?.(info.file as File, {
onUploadProgress: progressEvent,
});
/**
* 由getValue处理 传对象过去
* 直接传string(id)会被转为Number
* 内部的逻辑由requestClient.upload处理 这里不用判断业务状态码 不符合会自动reject
*/
info.onSuccess!(res);
message.success($t('component.upload.uploadSuccess'));
// 获取
const value = getValue();
isInnerOperate.value = true;
emit('update:value', value);
emit('change', value);
} catch (error: any) {
console.error(error);
info.onError!(error);
}
}
function getValue() {
console.log(fileList.value);
const list = (fileList.value || [])
.filter((item) => item?.status === UploadResultStatus.DONE)
.map((item: any) => {
if (item?.response && props?.resultField) {
return item?.response?.[props.resultField];
}
// ossId兼容 uid为ossId直接返回
if (props.resultField === 'ossId' && item.uid) {
return item.uid;
}
// 适用于已经有图片 回显的情况 会默认在init处理为{url: 'xx'}
if (item?.url) {
return item.url;
}
// 注意这里取的key为 url
return item?.response?.url;
});
// 只有一张图片 默认绑定string而非string[]
if (props.maxNumber === 1 && list.length === 1) {
return list[0];
}
// 只有一张图片 && 删除图片时 可自行修改
if (props.maxNumber === 1 && list.length === 0) {
return '';
}
return list;
}
</script>
<template>
<div>
<Upload
v-bind="$attrs"
v-model:file-list="fileList"
:accept="getStringAccept"
:before-upload="beforeUpload"
:custom-request="customRequest"
:disabled="disabled"
:list-type="listType"
:max-count="maxNumber"
:multiple="multiple"
:progress="{ showInfo: true }"
@preview="handlePreview"
@remove="handleRemove"
>
<div v-if="fileList && fileList.length < maxNumber">
<PlusOutlined />
<div style="margin-top: 8px">{{ $t('component.upload.upload') }}</div>
</div>
</Upload>
<div
v-if="showDescription"
class="mt-2 flex flex-wrap items-center text-[14px]"
>
请上传不超过
<div class="text-primary mx-1 font-bold">{{ maxSize }}MB</div>
<div class="text-primary mx-1 font-bold">{{ accept.join('/') }}</div>
格式文件
</div>
<Modal
:footer="null"
:open="previewOpen"
:title="previewTitle"
@cancel="handleCancel"
>
<img :src="previewImage" alt="" style="width: 100%" />
</Modal>
</div>
</template>
<style>
.ant-upload-select-picture-card i {
font-size: 32px;
color: #999;
}
.ant-upload-select-picture-card .ant-upload-text {
margin-top: 8px;
color: #666;
}
</style>

View File

@@ -1,243 +1,150 @@
<script lang="ts" setup>
import type { UploadFile, UploadProps } from 'ant-design-vue';
import type { UploadRequestOption } from 'ant-design-vue/lib/vc-upload/interface';
<!--
不再支持url 统一使用ossId
去除使用`file-type`库进行文件类型检测 在Safari无法使用
-->
<script setup lang="ts">
import type { UploadListType } from 'ant-design-vue/es/upload/interface';
import type { AxiosResponse } from '@vben/request';
import type { BaseUploadProps, UploadEmits } from './props';
import type { AxiosProgressEvent } from '#/api';
import { computed } from 'vue';
import { ref, toRefs, watch } from 'vue';
import { $t, I18nT } from '@vben/locales';
import { $t } from '@vben/locales';
import { UploadOutlined } from '@ant-design/icons-vue';
import { message, Upload } from 'ant-design-vue';
import { isArray, isFunction, isObject, isString } from 'lodash-es';
import { InboxOutlined, UploadOutlined } from '@ant-design/icons-vue';
import { Upload } from 'ant-design-vue';
import { uploadApi } from '#/api';
import { checkFileType } from './helper';
import { UploadResultStatus } from './typing';
import { useUploadType } from './use-upload';
import { defaultFileAcceptExts, defaultFilePreview } from './helper';
import { useUpload } from './hook';
defineOptions({ name: 'FileUpload', inheritAttrs: false });
interface FileUploadProps extends BaseUploadProps {
/**
* 同antdv的listType 但是排除picture-card
* 文件上传不适合用picture-card显示
* @default text
*/
listType?: Exclude<UploadListType, 'picture-card'>;
}
const props = withDefaults(
defineProps<{
/**
* 建议使用拓展名(不带.)
* 或者文件头 image/png等(测试判断不准确) 不支持image/*类似的写法
* 需自行改造 ./helper/checkFileType方法
*/
accept?: string[];
api?: (
file: Blob | File,
onUploadProgress?: AxiosProgressEvent,
) => Promise<AxiosResponse<any>>;
disabled?: boolean;
helpText?: string;
// 最大数量的文件Infinity不限制
maxNumber?: number;
// 文件最大多少MB
maxSize?: number;
// 是否支持多选
multiple?: boolean;
// support xxx.xxx.xx
// 返回的字段 默认url
resultField?: 'fileName' | 'ossId' | 'url' | string;
/**
* 是否显示下面的描述
*/
showDescription?: boolean;
value?: string[];
}>(),
{
value: () => [],
disabled: false,
helpText: '',
maxSize: 2,
maxNumber: 1,
accept: () => [],
multiple: false,
api: uploadApi,
resultField: '',
showDescription: true,
},
);
const emit = defineEmits(['change', 'update:value', 'delete']);
const { accept, helpText, maxNumber, maxSize } = toRefs(props);
const isInnerOperate = ref<boolean>(false);
const { getStringAccept } = useUploadType({
acceptRef: accept,
helpTextRef: helpText,
maxNumberRef: maxNumber,
maxSizeRef: maxSize,
const props = withDefaults(defineProps<FileUploadProps>(), {
api: () => uploadApi,
removeOnError: true,
showSuccessMsg: true,
removeConfirm: false,
accept: defaultFileAcceptExts.join(','),
data: () => undefined,
maxCount: 1,
maxSize: 5,
disabled: false,
helpMessage: true,
preview: defaultFilePreview,
enableDragUpload: false,
directory: false,
abortOnUnmounted: true,
listType: 'text',
});
const fileList = ref<UploadProps['fileList']>([]);
const isLtMsg = ref<boolean>(true);
const isActMsg = ref<boolean>(true);
const isFirstRender = ref<boolean>(true);
const emit = defineEmits<UploadEmits>();
watch(
() => props.value,
(v) => {
if (isInnerOperate.value) {
isInnerOperate.value = false;
return;
}
let value: string[] = [];
if (v) {
if (isArray(v)) {
value = v;
} else {
value.push(v);
}
fileList.value = value.map((item, i) => {
if (item && isString(item)) {
return {
uid: `${-i}`,
name: item.slice(Math.max(0, item.lastIndexOf('/') + 1)),
status: 'done',
url: item,
};
} else if (item && isObject(item)) {
return item;
}
return null;
}) as UploadProps['fileList'];
}
if (!isFirstRender.value) {
emit('change', value);
isFirstRender.value = false;
}
},
{
immediate: true,
deep: true,
},
);
/** 返回不同的上传组件 */
const CurrentUploadComponent = computed(() => {
if (props.enableDragUpload) {
return Upload.Dragger;
}
return Upload;
});
const handleRemove = async (file: UploadFile) => {
if (fileList.value) {
const index = fileList.value.findIndex((item) => item.uid === file.uid);
index !== -1 && fileList.value.splice(index, 1);
const value = getValue();
isInnerOperate.value = true;
emit('update:value', value);
emit('change', value);
emit('delete', file);
}
};
// 双向绑定 ossId
const ossIdList = defineModel<string | string[]>('value', {
default: () => [],
});
const beforeUpload = async (file: File) => {
const { maxSize, accept } = props;
const isAct = await checkFileType(file, accept);
if (!isAct) {
message.error($t('component.upload.acceptUpload', [accept]));
isActMsg.value = false;
// 防止弹出多个错误提示
setTimeout(() => (isActMsg.value = true), 1000);
}
const isLt = file.size / 1024 / 1024 > maxSize;
if (isLt) {
message.error($t('component.upload.maxSizeMultiple', [maxSize]));
isLtMsg.value = false;
// 防止弹出多个错误提示
setTimeout(() => (isLtMsg.value = true), 1000);
}
return (isAct && !isLt) || Upload.LIST_IGNORE;
};
async function customRequest(info: UploadRequestOption<any>) {
const { api } = props;
if (!api || !isFunction(api)) {
console.warn('upload api must exist and be a function');
return;
}
try {
// 进度条事件
const progressEvent: AxiosProgressEvent = (e) => {
const percent = Math.trunc((e.loaded / e.total!) * 100);
info.onProgress!({ percent });
};
const res = await api?.(info.file as File, progressEvent);
/**
* 由getValue处理 传对象过去
* 直接传string(id)会被转为Number
* 内部的逻辑由requestClient.upload处理 这里不用判断业务状态码 不符合会自动reject
*/
info.onSuccess!(res);
message.success($t('component.upload.uploadSuccess'));
// 获取
const value = getValue();
isInnerOperate.value = true;
emit('update:value', value);
emit('change', value);
} catch (error: any) {
console.error(error);
info.onError!(error);
}
}
function getValue() {
const list = (fileList.value || [])
.filter((item) => item?.status === UploadResultStatus.DONE)
.map((item: any) => {
if (item?.response && props?.resultField) {
return item?.response?.[props.resultField];
}
// 适用于已经有图片 回显的情况 会默认在init处理为{url: 'xx'}
if (item?.url) {
return item.url;
}
// 注意这里取的key为 url
return item?.response?.url;
});
return list;
}
const {
customRequest,
acceptStr,
handleChange,
handleRemove,
beforeUpload,
innerFileList,
} = useUpload(props, emit, ossIdList, 'file');
</script>
<!--
Upload.Dragger只会影响样式
使用普通Upload也是支持拖拽上传的
-->
<template>
<div>
<Upload
v-bind="$attrs"
v-model:file-list="fileList"
:accept="getStringAccept"
<CurrentUploadComponent
v-model:file-list="innerFileList"
:accept="accept"
:list-type="listType"
:disabled="disabled"
:directory="directory"
:max-count="maxCount"
:progress="{ showInfo: true }"
:multiple="multiple"
:before-upload="beforeUpload"
:custom-request="customRequest"
:disabled="disabled"
:max-count="maxNumber"
:multiple="multiple"
list-type="text"
:progress="{ showInfo: true }"
@preview="preview"
@change="handleChange"
@remove="handleRemove"
>
<div v-if="fileList && fileList.length < maxNumber">
<a-button>
<div v-if="!enableDragUpload && innerFileList?.length < maxCount">
<a-button :disabled="disabled">
<UploadOutlined />
{{ $t('component.upload.upload') }}
</a-button>
</div>
<div v-if="showDescription" class="mt-2 flex flex-wrap items-center">
请上传不超过
<div class="text-primary mx-1 font-bold">{{ maxSize }}MB</div>
<div class="text-primary mx-1 font-bold">{{ accept.join('/') }}</div>
格式文件
<div v-if="enableDragUpload && innerFileList?.length < maxCount">
<p class="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p class="ant-upload-text">
{{ $t('component.upload.clickOrDrag') }}
</p>
</div>
</Upload>
</CurrentUploadComponent>
<slot name="helpMessage" v-bind="{ maxCount, disabled, maxSize, accept }">
<I18nT
v-if="helpMessage"
scope="global"
keypath="component.upload.uploadHelpMessage"
tag="div"
class="mt-2"
:class="{ 'upload-text__disabled': disabled }"
>
<template #size>
<span
class="text-primary mx-1 font-medium"
:class="{ 'upload-text__disabled': disabled }"
>
{{ maxSize }}MB
</span>
</template>
<template #ext>
<span
class="text-primary mx-1 font-medium"
:class="{ 'upload-text__disabled': disabled }"
>
{{ acceptStr }}
</span>
</template>
</I18nT>
</slot>
</div>
</template>
<style>
.ant-upload-select-picture-card i {
font-size: 32px;
color: #999;
}
<style lang="scss">
// 禁用的样式和antd保持一致
.upload-text__disabled {
color: rgb(50 54 57 / 25%);
cursor: not-allowed;
.ant-upload-select-picture-card .ant-upload-text {
margin-top: 8px;
color: #666;
&:where(.dark, .dark *) {
color: rgb(242 242 242 / 25%);
}
}
</style>

View File

@@ -1,51 +1,28 @@
import { fileTypeFromBlob } from '@vben/utils';
import type { UploadFile } from 'ant-design-vue';
/**
* 不支持txt文件 @see https://github.com/sindresorhus/file-type/issues/55
* 需要自行修改
* @param file file对象
* @param accepts 文件类型数组 包括拓展名(不带点) 文件头(image/png等 不包括泛写法即image/*)
* @returns 是否通过文件类型校验
* 默认支持上传的图片文件类型
*/
export async function checkFileType(file: File, accepts: string[]) {
if (!accepts || accepts?.length === 0) {
return true;
}
console.log(file);
const fileType = await fileTypeFromBlob(file);
if (!fileType) {
console.error('无法获取文件类型');
return false;
}
console.log('文件类型', fileType);
// 是否文件拓展名/文件头任意有一个匹配
return accepts.includes(fileType.ext) || accepts.includes(fileType.mime);
}
export const defaultImageAcceptExts = [
'.jpg',
'.jpeg',
'.png',
'.gif',
'.webp',
];
/**
* 默认图片类型
* 默认支持上传的文件类型
*/
export const defaultImageAccept = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
export const defaultFileAcceptExts = ['.xlsx', '.csv', '.docx', '.pdf'];
/**
* 判断文件类型是否符合要求
* @param file file对象
* @param accepts 文件类型数组 包括拓展名(不带点) 文件头(image/png等 不包括泛写法即image/*)
* @returns 是否通过文件类型校验
* 文件(非图片)的默认预览逻辑
* 默认: window.open打开 交给浏览器接管
* @param file file
*/
export async function checkImageFileType(file: File, accepts: string[]) {
// 空的accepts 使用默认规则
if (!accepts || accepts.length === 0) {
accepts = defaultImageAccept;
export function defaultFilePreview(file: UploadFile) {
if (file?.url) {
window.open(file.url);
}
const fileType = await fileTypeFromBlob(file);
if (!fileType) {
console.error('无法获取文件类型');
return false;
}
console.log('文件类型', fileType);
// 是否文件拓展名/文件头任意有一个匹配
if (accepts.includes(fileType.ext) || accepts.includes(fileType.mime)) {
return true;
}
return false;
}

View File

@@ -0,0 +1,372 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import type { UploadChangeParam, UploadFile } from 'ant-design-vue';
import type { FileType } from 'ant-design-vue/es/upload/interface';
import type {
RcFile,
UploadRequestOption,
} from 'ant-design-vue/es/vc-upload/interface';
import type { ModelRef } from 'vue';
import type {
BaseUploadProps,
CustomGetter,
UploadEmits,
UploadType,
} from './props';
import type { AxiosProgressEvent, UploadResult } from '#/api';
import type { OssFile } from '#/api/system/oss/model';
import { computed, onUnmounted, ref, watch } from 'vue';
import { $t } from '@vben/locales';
import { message, Modal } from 'ant-design-vue';
import { isFunction, isString } from 'lodash-es';
import { ossInfo } from '#/api/system/oss';
/**
* 图片预览hook
* @returns 预览
*/
export function useImagePreview() {
/**
* 获取base64字符串
* @param file 文件
* @returns base64字符串
*/
function getBase64(file: File) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.addEventListener('load', () => resolve(reader.result));
reader.addEventListener('error', (error) => reject(error));
});
}
// Modal可见
const previewVisible = ref(false);
// 预览的图片 url/base64
const previewImage = ref('');
// 预览的图片名称
const previewTitle = ref('');
function handleCancel() {
previewVisible.value = false;
previewTitle.value = '';
}
async function handlePreview(file: UploadFile) {
if (!file) {
return;
}
// 文件预览 取base64
if (!file.url && !file.preview && file.originFileObj) {
file.preview = (await getBase64(file.originFileObj)) as string;
}
// 这里不可能为空
const url = file.url ?? '';
previewImage.value = url || file.preview || '';
previewVisible.value = true;
previewTitle.value =
file.name || url.slice(Math.max(0, url.lastIndexOf('/') + 1));
}
return {
previewVisible,
previewImage,
previewTitle,
handleCancel,
handlePreview,
};
}
/**
* 图片上传和文件上传的通用hook
* @param props 组件props
* @param emit 事件
* @param bindValue 双向绑定的idList
* @param uploadType 区分是文件还是图片上传
* @returns hook
*/
export function useUpload(
props: Readonly<BaseUploadProps>,
emit: UploadEmits,
bindValue: ModelRef<string | string[]>,
uploadType: UploadType,
) {
// 组件内部维护fileList
const innerFileList = ref<UploadFile[]>([]);
const acceptStr = computed(() => {
// string类型
if (isString(props.acceptFormat)) {
return props.acceptFormat;
}
// 函数类型
if (isFunction(props.acceptFormat)) {
return props.acceptFormat(props.accept!);
}
// 默认 会对拓展名做处理
return props.accept
?.split(',')
.map((item) => {
if (item.startsWith('.')) {
return item.slice(1);
}
return item;
})
.join(', ');
});
/**
* 自定义文件显示名称 需要区分不同的接口
* @param cb callback
* @returns 文件名
*/
function transformFilename(cb: Parameters<CustomGetter<string>>[0]) {
if (isFunction(props.customFilename)) {
return props.customFilename(cb);
}
// info接口
if (cb.type === 'info') {
return cb.response.originalName;
}
// 上传接口
return cb.response.fileName;
}
/**
* 自定义缩略图 需要区分不同的接口
* @param cb callback
* @returns 缩略图地址
*/
function transformThumbUrl(cb: Parameters<CustomGetter<undefined>>[0]) {
if (isFunction(props.customThumbUrl)) {
return props.customThumbUrl(cb);
}
// image 默认返回图片链接
if (uploadType === 'image') {
// info接口
if (cb.type === 'info') {
return cb.response.url;
}
// 上传接口
return cb.response.url;
}
// 文件默认返回空 走antd默认的预览图逻辑
return undefined;
}
function handleChange(info: UploadChangeParam) {
/**
* 移除当前文件
* @param currentFile 当前文件
* @param currentFileList 当前所有文件list
*/
function removeCurrentFile(
currentFile: UploadChangeParam['file'],
currentFileList: UploadChangeParam['fileList'],
) {
if (props.removeOnError) {
currentFileList.splice(currentFileList.indexOf(currentFile), 1);
} else {
currentFile.status = 'error';
}
}
const { file: currentFile, fileList } = info;
switch (currentFile.status) {
// 上传成功 只是判断httpStatus 200 需要手动判断业务code
case 'done': {
if (!currentFile.response) {
return;
}
// 获取返回结果 为customRequest的reslove参数
// 只有success才会走到这里
const { ossId, url } = currentFile.response as UploadResult;
currentFile.url = url;
currentFile.uid = ossId;
const cb = {
type: 'upload',
response: currentFile.response as UploadResult,
} as const;
currentFile.fileName = transformFilename(cb);
currentFile.name = transformFilename(cb);
currentFile.thumbUrl = transformThumbUrl(cb);
// ossID添加 单个文件会被当做string
if (props.maxCount === 1) {
bindValue.value = ossId;
} else {
// 给默认值
if (!Array.isArray(bindValue.value)) {
bindValue.value = [];
}
// 直接使用.value无法触发useForm的更新(原生是正常的) 需要修改地址
bindValue.value = [...bindValue.value, ossId];
}
break;
}
// 上传失败 网络原因导致httpStatus 不等于200
case 'error': {
removeCurrentFile(currentFile, fileList);
}
}
emit('change', info);
}
function handleRemove(currentFile: UploadFile) {
function remove() {
// fileList会自行处理删除 这里只需要处理ossId
if (props.maxCount === 1) {
bindValue.value = '';
} else {
(bindValue.value as string[]).splice(
bindValue.value.indexOf(currentFile.uid),
1,
);
}
// 触发remove事件
emit('remove', currentFile);
}
if (!props.removeConfirm) {
remove();
return true;
}
return new Promise<boolean>((resolve) => {
Modal.confirm({
title: $t('pages.common.tip'),
content: $t('component.upload.confirmDelete', [currentFile.name]),
okButtonProps: { danger: true },
centered: true,
onOk() {
resolve(true);
remove();
},
onCancel() {
resolve(false);
},
});
});
}
/**
* 上传前检测文件大小
* 拖拽时候前置会有浏览器自身的accept校验 校验失败不会执行此方法
* @param file file
* @returns file | false
*/
function beforeUpload(file: FileType) {
const isLtMax = file.size / 1024 / 1024 < props.maxSize!;
if (!isLtMax) {
message.error($t('component.upload.maxSize', [props.maxSize]));
return false;
}
// 大坑 Safari不支持file-type库 去除文件类型的校验
return file;
}
const uploadAbort = new AbortController();
/**
* 自定义上传实现
* @param info
*/
async function customRequest(info: UploadRequestOption<any>) {
const { api } = props;
if (!isFunction(api)) {
console.warn('upload api must exist and be a function');
return;
}
try {
// 进度条事件
const progressEvent: AxiosProgressEvent = (e) => {
const percent = Math.trunc((e.loaded / e.total!) * 100);
info.onProgress!({ percent });
};
const res = await api(info.file as File, {
onUploadProgress: progressEvent,
signal: uploadAbort.signal,
otherData: props?.data,
});
info.onSuccess!(res);
if (props.showSuccessMsg) {
message.success($t('component.upload.uploadSuccess'));
}
emit('success', info.file as RcFile, res);
} catch (error: any) {
console.error(error);
info.onError!(error);
}
}
onUnmounted(() => {
props.abortOnUnmounted && uploadAbort.abort();
});
/**
* 这里默认只监听list地址变化 即重新赋值才会触发watch
* immediate用于初始化触发
*/
watch(
() => bindValue.value,
async (value) => {
if (value.length === 0) {
return;
}
const resp = await ossInfo(value);
function transformFile(info: OssFile) {
const cb = { type: 'info', response: info } as const;
const fileitem: UploadFile = {
uid: info.ossId,
name: transformFilename(cb),
fileName: transformFilename(cb),
url: info.url,
thumbUrl: transformThumbUrl(cb),
status: 'done',
};
return fileitem;
}
const transformOptions = resp.map((item) => transformFile(item));
innerFileList.value = transformOptions;
// 单文件 丢弃策略
if (props.maxCount === 1 && resp.length === 0 && !props.keepMissingId) {
bindValue.value = '';
return;
}
// 多文件
// 单文件查到了也会走这里的逻辑 filter会报错 需要maxCount判断处理
if (
resp.length !== value.length &&
!props.keepMissingId &&
props.maxCount !== 1
) {
// 给默认值
if (!Array.isArray(bindValue.value)) {
bindValue.value = [];
}
bindValue.value = bindValue.value.filter((ossId) =>
resp.map((res) => res.ossId).includes(ossId),
);
}
},
{ immediate: true },
);
return {
handleChange,
handleRemove,
beforeUpload,
customRequest,
innerFileList,
acceptStr,
};
}

View File

@@ -1,326 +1,190 @@
<script lang="ts" setup>
import type { UploadFile, UploadProps } from 'ant-design-vue';
import type { UploadRequestOption } from 'ant-design-vue/lib/vc-upload/interface';
<!--
不再支持url 统一使用ossId
去除使用`file-type`库进行文件类型检测 在Safari无法使用
-->
<script setup lang="ts">
import type {
UploadFile,
UploadListType,
} from 'ant-design-vue/es/upload/interface';
import type { AxiosResponse } from '@vben/request';
import type { BaseUploadProps, UploadEmits } from './props';
import type { AxiosProgressEvent } from '#/api';
import { $t, I18nT } from '@vben/locales';
import { ref, toRefs, watch } from 'vue';
import { $t } from '@vben/locales';
import { PlusOutlined } from '@ant-design/icons-vue';
import { message, Modal, Upload } from 'ant-design-vue';
import { isArray, isFunction, isObject, isString, uniqueId } from 'lodash-es';
import { PlusOutlined, UploadOutlined } from '@ant-design/icons-vue';
import { Image, ImagePreviewGroup, Upload } from 'ant-design-vue';
import { isFunction } from 'lodash-es';
import { uploadApi } from '#/api';
import { ossInfo } from '#/api/system/oss';
import { checkImageFileType, defaultImageAccept } from './helper';
import { UploadResultStatus } from './typing';
import { useUploadType } from './use-upload';
import { defaultImageAcceptExts } from './helper';
import { useImagePreview, useUpload } from './hook';
defineOptions({ name: 'ImageUpload', inheritAttrs: false });
interface ImageUploadProps extends BaseUploadProps {
/**
* 同antdv的listType
* @default picture-card
*/
listType?: UploadListType;
/**
* 使用list-type: picture-card时 是否显示动画
* 会有一个`弹跳`的效果 默认关闭
* @default false
*/
withAnimation?: boolean;
}
const props = withDefaults(
defineProps<{
/**
* 包括拓展名(不带点) 文件头(image/png等 不包括泛写法即image/*)
*/
accept?: string[];
api?: (
file: Blob | File,
onUploadProgress?: AxiosProgressEvent,
) => Promise<AxiosResponse<any>>;
disabled?: boolean;
helpText?: string;
// eslint-disable-next-line no-use-before-define
listType?: ListType;
// 最大数量的文件Infinity不限制
maxNumber?: number;
// 文件最大多少MB
maxSize?: number;
// 是否支持多选
multiple?: boolean;
// support xxx.xxx.xx
// 返回的字段 默认url
resultField?: 'fileName' | 'ossId' | 'url';
/**
* 是否显示下面的描述
*/
showDescription?: boolean;
value?: string | string[];
}>(),
{
value: () => [],
disabled: false,
listType: 'picture-card',
helpText: '',
maxSize: 2,
maxNumber: 1,
accept: () => defaultImageAccept,
multiple: false,
api: uploadApi,
resultField: 'url',
showDescription: true,
},
);
const emit = defineEmits(['change', 'update:value', 'delete']);
type ListType = 'picture' | 'picture-card' | 'text';
const { accept, helpText, maxNumber, maxSize } = toRefs(props);
const isInnerOperate = ref<boolean>(false);
const { getStringAccept } = useUploadType({
acceptRef: accept,
helpTextRef: helpText,
maxNumberRef: maxNumber,
maxSizeRef: maxSize,
const props = withDefaults(defineProps<ImageUploadProps>(), {
api: () => uploadApi,
removeOnError: true,
showSuccessMsg: true,
removeConfirm: false,
accept: defaultImageAcceptExts.join(','),
data: () => undefined,
maxCount: 1,
maxSize: 5,
disabled: false,
listType: 'picture-card',
helpMessage: true,
enableDragUpload: false,
abortOnUnmounted: true,
withAnimation: false,
});
const previewOpen = ref<boolean>(false);
const previewImage = ref<string>('');
const previewTitle = ref<string>('');
const fileList = ref<UploadProps['fileList']>([]);
const isLtMsg = ref<boolean>(true);
const isActMsg = ref<boolean>(true);
const isFirstRender = ref<boolean>(true);
const emit = defineEmits<UploadEmits>();
watch(
() => props.value,
async (v) => {
if (isInnerOperate.value) {
isInnerOperate.value = false;
return;
}
let value: string | string[] = [];
if (v) {
const _fileList: string[] = [];
if (isString(v)) {
_fileList.push(v);
}
if (isArray(v)) {
_fileList.push(...v);
}
// 直接赋值 可能为string | string[]
value = v;
const withUrlList: UploadProps['fileList'] = [];
for (const item of _fileList) {
// ossId情况
if (props.resultField === 'ossId') {
const resp = await ossInfo([item]);
if (item && isString(item)) {
withUrlList.push({
uid: item, // ossId作为uid 方便getValue获取
name: item.slice(Math.max(0, item.lastIndexOf('/') + 1)),
status: 'done',
url: resp?.[0]?.url,
});
} else if (item && isObject(item)) {
withUrlList.push({
...(item as any),
uid: item,
url: resp?.[0]?.url,
});
}
} else {
// 非ossId情况
if (item && isString(item)) {
withUrlList.push({
uid: uniqueId(),
name: item.slice(Math.max(0, item.lastIndexOf('/') + 1)),
status: 'done',
url: item,
});
} else if (item && isObject(item)) {
withUrlList.push(item);
}
}
}
fileList.value = withUrlList;
}
if (!isFirstRender.value) {
emit('change', value);
isFirstRender.value = false;
}
},
{
immediate: true,
deep: true,
},
);
// 双向绑定 ossId
const ossIdList = defineModel<string | string[]>('value', {
default: () => [],
});
function getBase64<T extends ArrayBuffer | null | string>(file: File) {
return new Promise<T>((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.addEventListener('load', () => {
resolve(reader.result as T);
});
reader.addEventListener('error', (error) => reject(error));
});
}
const {
acceptStr,
handleChange,
handleRemove,
beforeUpload,
innerFileList,
customRequest,
} = useUpload(props, emit, ossIdList, 'image');
const handlePreview = async (file: UploadFile) => {
if (!file.url && !file.preview) {
file.preview = await getBase64<string>(file.originFileObj!);
const { previewVisible, previewImage, handleCancel, handlePreview } =
useImagePreview();
function currentPreview(file: UploadFile) {
// 有自定义预览逻辑走自定义
if (isFunction(props.preview)) {
return props.preview(file);
}
previewImage.value = file.url || file.preview || '';
previewOpen.value = true;
previewTitle.value =
file.name ||
previewImage.value.slice(
Math.max(0, previewImage.value.lastIndexOf('/') + 1),
);
};
const handleRemove = async (file: UploadFile) => {
if (fileList.value) {
const index = fileList.value.findIndex((item) => item.uid === file.uid);
index !== -1 && fileList.value.splice(index, 1);
const value = getValue();
isInnerOperate.value = true;
emit('update:value', value);
emit('change', value);
emit('delete', file);
}
};
const handleCancel = () => {
previewOpen.value = false;
previewTitle.value = '';
};
const beforeUpload = async (file: File) => {
const { maxSize, accept } = props;
const isAct = await checkImageFileType(file, accept);
if (!isAct) {
message.error($t('component.upload.acceptUpload', [accept]));
isActMsg.value = false;
// 防止弹出多个错误提示
setTimeout(() => (isActMsg.value = true), 1000);
}
const isLt = file.size / 1024 / 1024 > maxSize;
if (isLt) {
message.error($t('component.upload.maxSizeMultiple', [maxSize]));
isLtMsg.value = false;
// 防止弹出多个错误提示
setTimeout(() => (isLtMsg.value = true), 1000);
}
return (isAct && !isLt) || Upload.LIST_IGNORE;
};
async function customRequest(info: UploadRequestOption<any>) {
const { api } = props;
if (!api || !isFunction(api)) {
console.warn('upload api must exist and be a function');
return;
}
try {
// 进度条事件
const progressEvent: AxiosProgressEvent = (e) => {
const percent = Math.trunc((e.loaded / e.total!) * 100);
info.onProgress!({ percent });
};
const res = await api?.(info.file as File, progressEvent);
/**
* 由getValue处理 传对象过去
* 直接传string(id)会被转为Number
* 内部的逻辑由requestClient.upload处理 这里不用判断业务状态码 不符合会自动reject
*/
info.onSuccess!(res);
message.success($t('component.upload.uploadSuccess'));
// 获取
const value = getValue();
isInnerOperate.value = true;
emit('update:value', value);
emit('change', value);
} catch (error: any) {
console.error(error);
info.onError!(error);
}
}
function getValue() {
console.log(fileList.value);
const list = (fileList.value || [])
.filter((item) => item?.status === UploadResultStatus.DONE)
.map((item: any) => {
if (item?.response && props?.resultField) {
return item?.response?.[props.resultField];
}
// ossId兼容 uid为ossId直接返回
if (props.resultField === 'ossId' && item.uid) {
return item.uid;
}
// 适用于已经有图片 回显的情况 会默认在init处理为{url: 'xx'}
if (item?.url) {
return item.url;
}
// 注意这里取的key为 url
return item?.response?.url;
});
// 只有一张图片 默认绑定string而非string[]
if (props.maxNumber === 1 && list.length === 1) {
return list[0];
}
// 只有一张图片 && 删除图片时 可自行修改
if (props.maxNumber === 1 && list.length === 0) {
return '';
}
return list;
// 否则走默认预览
return handlePreview(file);
}
</script>
<template>
<div>
<Upload
v-bind="$attrs"
v-model:file-list="fileList"
:accept="getStringAccept"
v-model:file-list="innerFileList"
:class="{ 'upload-animation__disabled': !withAnimation }"
:list-type="listType"
:accept="accept"
:disabled="disabled"
:directory="directory"
:max-count="maxCount"
:progress="{ showInfo: true }"
:multiple="multiple"
:before-upload="beforeUpload"
:custom-request="customRequest"
:disabled="disabled"
:list-type="listType"
:max-count="maxNumber"
:multiple="multiple"
:progress="{ showInfo: true }"
@preview="handlePreview"
@preview="currentPreview"
@change="handleChange"
@remove="handleRemove"
>
<div v-if="fileList && fileList.length < maxNumber">
<div
v-if="innerFileList?.length < maxCount && listType === 'picture-card'"
>
<PlusOutlined />
<div style="margin-top: 8px">{{ $t('component.upload.upload') }}</div>
<div class="mt-[8px]">{{ $t('component.upload.upload') }}</div>
</div>
<a-button
v-if="innerFileList?.length < maxCount && listType !== 'picture-card'"
:disabled="disabled"
>
<UploadOutlined />
{{ $t('component.upload.upload') }}
</a-button>
</Upload>
<div
v-if="showDescription"
class="mt-2 flex flex-wrap items-center text-[14px]"
<slot name="helpMessage" v-bind="{ maxCount, disabled, maxSize, accept }">
<I18nT
v-if="helpMessage"
scope="global"
keypath="component.upload.uploadHelpMessage"
tag="div"
:class="{
'upload-text__disabled': disabled,
'mt-2': listType !== 'picture-card',
}"
>
<template #size>
<span
class="text-primary mx-1 font-medium"
:class="{ 'upload-text__disabled': disabled }"
>
{{ maxSize }}MB
</span>
</template>
<template #ext>
<span
class="text-primary mx-1 font-medium"
:class="{ 'upload-text__disabled': disabled }"
>
{{ acceptStr }}
</span>
</template>
</I18nT>
</slot>
<ImagePreviewGroup
:preview="{
visible: previewVisible,
onVisibleChange: handleCancel,
}"
>
请上传不超过
<div class="text-primary mx-1 font-bold">{{ maxSize }}MB</div>
<div class="text-primary mx-1 font-bold">{{ accept.join('/') }}</div>
格式文件
</div>
<Modal
:footer="null"
:open="previewOpen"
:title="previewTitle"
@cancel="handleCancel"
>
<img :src="previewImage" alt="" style="width: 100%" />
</Modal>
<Image class="hidden" :src="previewImage" />
</ImagePreviewGroup>
</div>
</template>
<style>
.ant-upload-select-picture-card i {
font-size: 32px;
color: #999;
<style lang="scss">
.ant-upload-select-picture-card {
i {
@apply text-[32px] text-[#999];
}
.ant-upload-text {
@apply mt-[8px] text-[#666];
}
}
.ant-upload-select-picture-card .ant-upload-text {
margin-top: 8px;
color: #666;
.ant-upload-list-picture-card {
.ant-upload-list-item::before {
border-radius: 4px;
}
}
// 禁用的样式和antd保持一致
.upload-text__disabled {
color: rgb(50 54 57 / 25%);
cursor: not-allowed;
&:where(.dark, .dark *) {
color: rgb(242 242 242 / 25%);
}
}
// list-type: picture-card动画效果关闭样式
.upload-animation__disabled {
.ant-upload-animate-inline {
animation-duration: 0s !important;
}
}
</style>

View File

@@ -0,0 +1,26 @@
Safari在执行到beforeUpload方法
有两种情况
1. 不继续执行 也无法上传(没有调用上传)
2. 报错
Unhandled Promise Rejection: TypeError: ReadableStreamBYOBReader needs a ReadableByteStreamController
https://github.com/oven-sh/bun/issues/12908#issuecomment-2490151231
刚开始以为是异步的问题 由于`file-type`调用了异步方法 调试也是在这里没有后续打印了
使用别的异步代码测试结果是正常上传的
```js
return new Promise<FileType>((resolve) =>
setTimeout(() => resolve(file), 2000),
);
```
根本原因在于`file-typ`库的`fileTypeFromBlob`方法不支持Safari 去掉可以正常上传
safari不支持`ReadableStreamBYOBReader`api
详见: https://github.com/sindresorhus/file-type/issues/690

View File

@@ -0,0 +1,122 @@
import type { UploadFile } from 'ant-design-vue';
import type { RcFile } from 'ant-design-vue/es/vc-upload/interface';
import type { UploadApi, UploadResult } from '#/api';
import type { OssFile } from '#/api/system/oss/model';
import { UploadChangeParam } from 'ant-design-vue';
export type UploadType = 'file' | 'image';
/**
* 自定义返回文件名/缩略图使用 泛型控制返回是否必填
* type 为不同的接口返回值 需要自行if判断
*/
export type CustomGetter<T extends string | undefined> = (
cb:
| { response: OssFile; type: 'info' }
| { response: UploadResult; type: 'upload' },
) => T extends undefined ? string | undefined : string;
export interface BaseUploadProps {
/**
* 上传接口
*/
api?: UploadApi;
/**
* 文件上传失败 是否从展示列表中删除
* @default true
*/
removeOnError?: boolean;
/**
* 上传成功 是否展示提示信息
* @default true
*/
showSuccessMsg?: boolean;
/**
* 删除文件前是否需要确认
* @default false
*/
removeConfirm?: boolean;
/**
* 同antdv参数
*/
accept?: string;
/**
* 你可能使用的是application/pdf这种mime类型, 但是这样用户可能看不懂, 在这里自定义逻辑
* @default 原始accept
*/
acceptFormat?: ((accept: string) => string) | string;
/**
* 附带的请求参数
*/
data?: any;
/**
* 最大上传图片数量
* maxCount为1时 会被绑定为string而非string[]
* @default 1
*/
maxCount?: number;
/**
* 文件最大 单位M
* @default 5
*/
maxSize?: number;
/**
* 是否禁用
* @default false
*/
disabled?: boolean;
/**
* 是否显示文案 请上传不超过...
* @default true
*/
helpMessage?: boolean;
/**
* 是否支持多选文件ie10+ 支持。开启后按住 ctrl 可选择多个文件。
* @default false
*/
multiple?: boolean;
/**
* 是否支持上传文件夹
* @default false
*/
directory?: boolean;
/**
* 是否支持拖拽上传
* @default false
*/
enableDragUpload?: boolean;
/**
* 当ossId查询不到文件信息时 比如被删除了
* 是否保留列表对应的ossId 默认不保留
* @default false
*/
keepMissingId?: boolean;
/**
* 自定义文件/图片预览逻辑 比如: 你可以改为下载
* 图片上传默认为预览
* 文件上传默认为window.open
* @param file file
*/
preview?: (file: UploadFile) => Promise<void> | void;
/**
* 是否在组件Unmounted时取消上传
* @default true
*/
abortOnUnmounted?: boolean;
/**
* 自定义文件名 需要区分两个接口的返回值
*/
customFilename?: CustomGetter<string>;
/**
* 自定义缩略图 需要区分两个接口的返回值
*/
customThumbUrl?: CustomGetter<undefined>;
}
export interface UploadEmits {
(e: 'success', file: RcFile, response: UploadResult): void;
(e: 'remove', file: UploadFile): void;
(e: 'change', info: UploadChangeParam): void;
}

View File

@@ -116,7 +116,7 @@ watch(
async (enable) => {
if (enable) {
await updateWatermark({
content: `${userStore.userInfo?.username}`,
content: `${userStore.userInfo?.username} - ${userStore.userInfo?.realName}`,
});
} else {
destroyWatermark();

View File

@@ -50,6 +50,10 @@
"uploadError": "Upload failed",
"uploading": "Uploading",
"uploadWait": "Please wait for the file upload to finish",
"reUploadFailed": "Re-upload failed files"
"reUploadFailed": "Re-upload failed files",
"uploadHelpMessage": "Please upload a file in {ext} format that does not exceed {size} .",
"unknownFileType": "Unknown file type, unable to upload",
"confirmDelete": "Confirm file deletion {0}?",
"clickOrDrag": "Click or drag file to this area to upload"
}
}

View File

@@ -18,6 +18,10 @@
"refresh": "Refresh",
"generate": "Generate",
"downloadLoading": "Downloading... Please wait.",
"preview": "Preview"
"preview": "Preview",
"tip": "Tip",
"enable": "On",
"disable": "Off",
"beforeCloseTip": "You have unsaved changes. Are you sure you want to exit?"
}
}

View File

@@ -50,6 +50,10 @@
"uploadError": "上传失败",
"uploading": "上传中",
"uploadWait": "请等待文件上传结束后操作",
"reUploadFailed": "重新上传失败文件"
"reUploadFailed": "重新上传失败文件",
"uploadHelpMessage": "请上传不超过{size}的{ext}格式文件",
"unknownFileType": "未知的文件类型, 无法上传",
"confirmDelete": "确认删除文件 {0}?",
"clickOrDrag": "点击或拖动文件到这个区域上传"
}
}

View File

@@ -18,6 +18,10 @@
"refresh": "刷新",
"generate": "生成",
"downloadLoading": "下载中, 请稍后...",
"preview": "预览"
"preview": "预览",
"tip": "提示",
"enable": "启用",
"disable": "禁用",
"beforeCloseTip": "您有未保存的更改,确认要退出吗?"
}
}

View File

@@ -46,6 +46,11 @@ export const overridesPreferences = defineOverridesPreferences({
* 浅色sidebar
*/
semiDarkSidebar: false,
/**
* 圆角大小 换算比例为1.6px = 0.1radius
* 这里为6px 与antd保持一致
*/
radius: '0.375',
},
/**
* !!! 更改配置后请清空浏览器缓存

View File

@@ -2,10 +2,10 @@ import type { RouteRecordRaw } from 'vue-router';
import { DEFAULT_HOME_PATH, LOGIN_PATH } from '@vben/constants';
import { AuthPageLayout, BasicLayout } from '#/layouts';
import { $t } from '#/locales';
import Login from '#/views/_core/authentication/login.vue';
const BasicLayout = () => import('#/layouts/basic.vue');
const AuthPageLayout = () => import('#/layouts/auth.vue');
/** 全局404页面 */
const fallbackNotFoundRoute: RouteRecordRaw = {
component: () => import('#/views/_core/fallback/not-found.vue'),
@@ -58,7 +58,7 @@ const coreRoutes: RouteRecordRaw[] = [
{
name: 'Login',
path: 'login',
component: Login,
component: () => import('#/views/_core/authentication/login.vue'),
meta: {
title: $t('page.auth.login'),
},

View File

@@ -12,6 +12,7 @@ const localRoutes: RouteRecordStringComponent[] = [
icon: 'mingcute:profile-line',
title: $t('ui.widgets.profile'),
hideInMenu: true,
requireHomeRedirect: true,
},
name: 'Profile',
path: '/profile',
@@ -23,6 +24,7 @@ const localRoutes: RouteRecordStringComponent[] = [
icon: 'ant-design:setting-outlined',
title: 'oss配置',
hideInMenu: true,
requireHomeRedirect: true,
},
name: 'OssConfig',
path: '/system/oss-config',
@@ -34,6 +36,7 @@ const localRoutes: RouteRecordStringComponent[] = [
icon: 'tabler:code',
title: '生成配置',
hideInMenu: true,
requireHomeRedirect: true,
},
name: 'GenConfig',
path: '/code-gen/edit/:tableId',
@@ -45,6 +48,7 @@ const localRoutes: RouteRecordStringComponent[] = [
icon: 'eos-icons:role-binding-outlined',
title: '分配角色',
hideInMenu: true,
requireHomeRedirect: true,
},
name: 'RoleAssign',
path: '/system/role-assign/:roleId',
@@ -56,6 +60,7 @@ const localRoutes: RouteRecordStringComponent[] = [
icon: 'fluent-mdl2:flow',
title: '流程设计',
hideInMenu: true,
requireHomeRedirect: true,
},
name: 'WorkflowDesigner',
path: '/workflow/designer',
@@ -70,6 +75,7 @@ const localRoutes: RouteRecordStringComponent[] = [
title: '请假申请',
activePath: '/demo/leave',
hideInMenu: true,
requireHomeRedirect: true,
},
name: 'WorkflowLeaveIndex',
path: '/workflow/leaveEdit/index',
@@ -120,6 +126,18 @@ export const localMenuList: RouteRecordStringComponent[] = [
title: $t('demos.vben.document'),
},
},
{
name: 'V5UpdateLog',
path: '/changelog',
component: '/演示使用自行删除/changelog/index',
meta: {
icon: 'lucide:book-open-text',
keepAlive: true,
title: '更新记录',
badge: '1.3.0',
badgeVariants: '#CC0033',
},
},
],
},
{

View File

@@ -59,7 +59,11 @@ export const useDictStore = defineStore('app-dict', () => {
}
function resetCache() {
dictRequestCache.clear();
dictOptionsMap.clear();
/**
* 不需要清空dictRequestCache 每次请求成功/失败都清空key
*/
}
/**

View File

@@ -27,6 +27,10 @@ function fetchAndCacheDictData<T>(
// 内部处理了push的逻辑 这里不用push
setDictInfo(dictName, resp, formatNumber);
})
.catch(() => {
// 401时 移除字典缓存 下次登录重新获取
dictRequestCache.delete(dictName);
})
.finally(() => {
// 移除请求状态缓存
/**

View File

@@ -0,0 +1,126 @@
import type { ExtendedFormApi } from '@vben/common-ui';
import type { MaybePromise } from '@vben/types';
import { ref } from 'vue';
import { $t } from '@vben/locales';
import { Modal } from 'ant-design-vue';
import { isFunction } from 'lodash-es';
interface BeforeCloseDiffProps {
/**
* 初始化值如何获取
* @returns Promise<string>
*/
initializedGetter: () => MaybePromise<string>;
/**
* 当前值如何获取
* @returns Promise<string>
*/
currentGetter: () => MaybePromise<string>;
/**
* 自定义比较函数
* @param init 初始值
* @param current 当前值
* @returns boolean
*/
compare?: (init: string, current: string) => boolean;
}
/**
* 用于Drawer/Modal使用 判断表单是否有变动来决定是否弹窗提示
* @param props props
* @returns hook
*/
export function useBeforeCloseDiff(props: BeforeCloseDiffProps) {
const { initializedGetter, currentGetter, compare } = props;
/**
* 记录初始值 json
*/
const initialized = ref<string>('');
/**
* 是否已经初始化了 通过这个值判断是否需要进行对比 为false直接关闭 不弹窗
*/
const isInitialized = ref(false);
/**
* 标记是否已经完成初始化 后续需要进行对比
* @param data 自定义初始化数据 可选
*/
async function markInitialized(data?: string) {
initialized.value = data || (await initializedGetter());
isInitialized.value = true;
}
/**
* 重置初始化状态 需要在closed前调用 或者打开窗口时
*/
function resetInitialized() {
initialized.value = '';
isInitialized.value = false;
}
/**
* 提供给useVbenForm/useVbenDrawer使用
* @returns 是否允许关闭
*/
async function onBeforeClose(): Promise<boolean> {
// 如果还未初始化,直接允许关闭
if (!isInitialized.value) {
return true;
}
try {
// 获取当前表单数据
const current = await currentGetter();
// 自定义比较的情况
if (isFunction(compare) && compare(initialized.value, current)) {
return true;
} else {
// 如果数据没有变化,直接允许关闭
if (current === initialized.value) {
return true;
}
}
// 数据有变化,显示确认对话框
return new Promise<boolean>((resolve) => {
Modal.confirm({
title: $t('pages.common.tip'),
content: $t('pages.common.beforeCloseTip'),
centered: true,
okButtonProps: { danger: true },
cancelText: $t('common.cancel'),
okText: $t('common.confirm'),
onOk: () => {
resolve(true);
isInitialized.value = false;
},
onCancel: () => resolve(false),
});
});
} catch (error) {
console.error('Failed to compare data:', error);
return true;
}
}
return {
onBeforeClose,
markInitialized,
resetInitialized,
};
}
/**
* 给useVbenForm使用的 封装函数
* @param formApi 表单实例
* @returns getter
*/
export function defaultFormValueGetter(formApi: ExtendedFormApi) {
return async () => {
const v = await formApi.getValues();
return JSON.stringify(v);
};
}

View File

@@ -116,7 +116,7 @@ export function renderHttpMethodTag(type: string) {
return <Tag color={color}>{title}</Tag>;
}
export function renderDictTag(value: string, dicts: DictData[]) {
export function renderDictTag(value: number | string, dicts: DictData[]) {
return <DictTag dicts={dicts} value={value}></DictTag>;
}
@@ -155,7 +155,7 @@ export function renderDictTags(
* @param dictName dictName
* @returns tag
*/
export function renderDict(value: string, dictName: string) {
export function renderDict(value: number | string, dictName: string) {
const dictInfo = getDictOptions(dictName);
return renderDictTag(value, dictInfo);
}

View File

@@ -30,13 +30,23 @@ const captchaInfo = ref<CaptchaResponse>({
img: '',
uuid: '',
});
// 验证码loading
const captchaLoading = ref(false);
async function loadCaptcha() {
const resp = await captchaImage();
if (resp.captchaEnabled) {
resp.img = `data:image/png;base64,${resp.img}`;
try {
captchaLoading.value = true;
const resp = await captchaImage();
if (resp.captchaEnabled) {
resp.img = `data:image/png;base64,${resp.img}`;
}
captchaInfo.value = resp;
} catch (error) {
console.error(error);
} finally {
captchaLoading.value = false;
}
captchaInfo.value = resp;
}
const tenantInfo = ref<TenantResp>({
@@ -116,6 +126,7 @@ const formSchema = computed((): VbenFormSchema[] => {
class: 'focus:border-primary',
onCaptchaClick: loadCaptcha,
placeholder: $t('authentication.code'),
loading: captchaLoading.value,
},
dependencies: {
if: () => captchaInfo.value.captchaEnabled,

View File

@@ -15,10 +15,10 @@ import {
} from '@vben/icons';
import AnalyticsTrends from './analytics-trends.vue';
import AnalyticsVisits from './analytics-visits.vue';
import AnalyticsVisitsData from './analytics-visits-data.vue';
import AnalyticsVisitsSales from './analytics-visits-sales.vue';
import AnalyticsVisitsSource from './analytics-visits-source.vue';
import AnalyticsVisits from './analytics-visits.vue';
const overviewItems: AnalysisOverviewItem[] = [
{

View File

@@ -1,96 +1,58 @@
<script setup lang="ts">
import type { RedisInfo } from '#/api/monitor/cache';
import type { DescItem } from '#/components/description';
import { onMounted, watch } from 'vue';
import { Description, useDescription } from '#/components/description';
import { Descriptions, DescriptionsItem } from 'ant-design-vue';
interface IRedisInfo extends RedisInfo {
dbSize: string;
}
const props = defineProps<{ data: IRedisInfo }>();
const descSchemas: DescItem[] = [
{ field: 'redis_version', label: 'redis版本' },
{
field: 'redis_mode',
label: 'redis模式',
render(value) {
return value === 'standalone' ? '单机模式' : '集群模式';
},
},
{
field: 'tcp_port',
label: 'tcp端口',
},
{
field: 'connected_clients',
label: '客户端数',
},
{
field: 'uptime_in_days',
label: '运行时间',
render(value) {
return `${value}`;
},
},
{
field: 'used_memory_human',
label: '使用内存',
},
{
field: 'used_cpu_user_children',
label: '使用CPU',
render(value) {
return Number.parseFloat(value).toFixed(2);
},
},
{
field: 'maxmemory_human',
label: '内存配置',
},
{
field: 'aof_enabled',
label: 'AOF是否开启',
render(value) {
return value === '0' ? '否' : '是';
},
},
{
field: 'rdb_last_bgsave_status',
label: 'RDB是否成功',
},
{
field: 'dbSize',
label: 'Key数量',
},
{
field: 'instantaneous_input_kbps',
label: '网络入口/出口',
render(_, data) {
const { instantaneous_input_kbps, instantaneous_output_kbps } = data;
return `${instantaneous_input_kbps}kps/${instantaneous_output_kbps}kps`;
},
},
];
const [registerDescription, { setDescProps }] = useDescription({
column: { lg: 4, md: 3, sm: 1, xl: 4, xs: 1 },
schema: descSchemas,
});
onMounted(() => setDescProps({ data: props.data }));
watch(
() => props.data,
(data) => {
setDescProps({ data });
},
);
defineProps<{ data: IRedisInfo }>();
</script>
<template>
<Description @register="registerDescription" />
<Descriptions
bordered
:column="{ lg: 4, md: 3, sm: 1, xl: 4, xs: 1 }"
size="small"
>
<DescriptionsItem label="redis版本">
{{ data.redis_version }}
</DescriptionsItem>
<DescriptionsItem label="redis模式">
{{ data.redis_mode === 'standalone' ? '单机模式' : '集群模式' }}
</DescriptionsItem>
<DescriptionsItem label="tcp端口">
{{ data.tcp_port }}
</DescriptionsItem>
<DescriptionsItem label="客户端数">
{{ data.connected_clients }}
</DescriptionsItem>
<DescriptionsItem label="运行时间">
{{ data.uptime_in_days }}
</DescriptionsItem>
<DescriptionsItem label="使用内存">
{{ data.used_memory_human }}
</DescriptionsItem>
<DescriptionsItem label="使用CPU">
{{ Number.parseFloat(data?.used_cpu_user_children ?? '0').toFixed(2) }}
</DescriptionsItem>
<DescriptionsItem label="内存配置">
{{ data.maxmemory_human }}
</DescriptionsItem>
<DescriptionsItem label="AOF是否开启">
{{ data.aof_enabled === '0' ? '否' : '是' }}
</DescriptionsItem>
<DescriptionsItem label="RDB是否成功">
{{ data.rdb_last_bgsave_status }}
</DescriptionsItem>
<DescriptionsItem label="key数量">
{{ data.dbSize }}
</DescriptionsItem>
<DescriptionsItem label="网络入口/出口">
{{
`${data.instantaneous_input_kbps}kps/${data.instantaneous_output_kbps}kps`
}}
</DescriptionsItem>
</Descriptions>
</template>

View File

@@ -2,7 +2,6 @@ import type { VNode } from 'vue';
import type { FormSchemaGetter } from '#/adapter/form';
import type { VxeGridProps } from '#/adapter/vxe-table';
import type { DescItem } from '#/components/description';
import { DictEnum } from '@vben/constants';
@@ -107,62 +106,3 @@ export const columns: VxeGridProps['columns'] = [
width: 150,
},
];
export const modalSchema: () => DescItem[] = () => [
{
field: 'status',
label: '登录状态',
labelMinWidth: 80,
render(value) {
return renderDict(value, DictEnum.SYS_COMMON_STATUS);
},
},
{
field: 'clientKey',
label: '登录平台',
render(value) {
if (value) {
return value.toUpperCase();
}
return '';
},
},
{
field: 'ipaddr',
label: '账号信息',
render(_, data) {
const { ipaddr, loginLocation, userName } = data;
return `账号: ${userName} / ${ipaddr} / ${loginLocation}`;
},
},
{
field: 'loginTime',
label: '登录时间',
},
{
field: 'msg',
label: '登录信息',
render(_, data: any) {
const { msg, status } = data;
return (
<span class={['font-bold', status === '0' ? '' : 'text-red-500']}>
{msg}
</span>
);
},
},
{
field: 'os',
label: '登录设备',
render(value) {
return renderOsIcon(value);
},
},
{
field: 'browser',
label: '浏览器',
render(value) {
return renderBrowserIcon(value);
},
},
];

View File

@@ -1,22 +1,26 @@
<script setup lang="ts">
import type { LoginLog } from '#/api/monitor/logininfo/model';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { DictEnum } from '@vben/constants';
import { Description, useDescription } from '#/components/description';
import { Descriptions, DescriptionsItem } from 'ant-design-vue';
import { modalSchema } from './data';
const [registerDescription, { setDescProps }] = useDescription({
column: 1,
schema: modalSchema(),
});
import { renderBrowserIcon, renderDict, renderOsIcon } from '#/utils/render';
const loginInfo = ref<LoginLog>();
const [BasicModal, modalApi] = useVbenModal({
onOpenChange: (isOpen) => {
if (!isOpen) {
return null;
}
const data = modalApi.getData();
setDescProps({ data }, true);
const record = modalApi.getData() as LoginLog;
loginInfo.value = record;
},
onClosed() {
loginInfo.value = undefined;
},
});
</script>
@@ -28,6 +32,37 @@ const [BasicModal, modalApi] = useVbenModal({
class="w-[550px]"
title="登录日志"
>
<Description @register="registerDescription" />
<Descriptions v-if="loginInfo" size="small" :column="1" bordered>
<DescriptionsItem label="登录状态">
<component
:is="renderDict(loginInfo.status, DictEnum.SYS_COMMON_STATUS)"
/>
</DescriptionsItem>
<DescriptionsItem label="登录平台">
{{ loginInfo.clientKey.toLowerCase() }}
</DescriptionsItem>
<DescriptionsItem label="账号信息">
{{
`账号: ${loginInfo.userName} / ${loginInfo.ipaddr} / ${loginInfo.loginLocation}`
}}
</DescriptionsItem>
<DescriptionsItem label="登录时间">
{{ loginInfo.loginTime }}
</DescriptionsItem>
<DescriptionsItem label="登录信息">
<span
class="font-semibold"
:class="{ 'text-red-500': loginInfo.status !== '0' }"
>
{{ loginInfo.msg }}
</span>
</DescriptionsItem>
<DescriptionsItem label="登录设备">
<component :is="renderOsIcon(loginInfo.os)" />
</DescriptionsItem>
<DescriptionsItem label="浏览器">
<component :is="renderBrowserIcon(loginInfo.browser)" />
</DescriptionsItem>
</Descriptions>
</BasicModal>
</template>

View File

@@ -1,17 +1,10 @@
import type { FormSchemaGetter } from '#/adapter/form';
import type { VxeGridProps } from '#/adapter/vxe-table';
import type { DescItem } from '#/components/description';
import { DictEnum } from '@vben/constants';
import { Tag } from 'ant-design-vue';
import { getDictOptions } from '#/utils/dict';
import {
renderDict,
renderHttpMethodTag,
renderJsonPreview,
} from '#/utils/render';
import { renderDict } from '#/utils/render';
export const querySchema: FormSchemaGetter = () => [
{
@@ -96,104 +89,3 @@ export const columns: VxeGridProps['columns'] = [
width: 120,
},
];
export const descSchema: DescItem[] = [
{
field: 'operId',
label: '日志编号',
},
{
field: 'status',
label: '操作结果',
render(value) {
return renderDict(value, DictEnum.SYS_COMMON_STATUS);
},
},
{
field: 'title',
label: '操作模块',
labelMinWidth: 80,
render(value, { businessType }) {
const operType = renderDict(businessType, DictEnum.SYS_OPER_TYPE);
return (
<div class="flex items-center">
<Tag>{value}</Tag>
{operType}
</div>
);
},
},
{
field: 'operIp',
label: '操作信息',
render(_, data) {
return `账号: ${data.operName} / ${data.deptName} / ${data.operIp} / ${data.operLocation}`;
},
},
{
field: 'operUrl',
label: '请求信息',
render(_, data) {
const { operUrl, requestMethod } = data;
const methodTag = renderHttpMethodTag(requestMethod);
return (
<span>
{methodTag} {operUrl}
</span>
);
},
},
{
field: 'errorMsg',
label: '异常信息',
render(value) {
return <span class="font-bold text-red-600">{value}</span>;
},
show: (data) => {
return data && data.errorMsg !== '';
},
},
{
field: 'method',
label: '方法',
},
/**
* 默认word-break: break-word;会导致json预览样式异常
*/
{
field: 'operParam',
label: '请求参数',
render(value) {
return (
<div class="max-h-[300px] w-full overflow-y-auto">
{renderJsonPreview(value)}
</div>
);
},
},
{
field: 'jsonResult',
label: '响应参数',
render(value) {
return (
<div class="max-h-[300px] w-full overflow-y-auto">
{renderJsonPreview(value)}
</div>
);
},
show(data) {
return data && data.jsonResult;
},
},
{
field: 'costTime',
label: '耗时',
render(value) {
return `${value} ms`;
},
},
{
field: 'operTime',
label: '操作时间',
},
];

View File

@@ -1,32 +1,94 @@
<script setup lang="ts">
import type { OperationLog } from '#/api/monitor/operlog/model';
import { computed, shallowRef } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { DictEnum } from '@vben/constants';
import { Description, useDescription } from '#/components/description';
import { Descriptions, DescriptionsItem, Tag } from 'ant-design-vue';
import { descSchema } from './data';
import {
renderDict,
renderHttpMethodTag,
renderJsonPreview,
} from '#/utils/render';
const [BasicDrawer, drawerApi] = useVbenDrawer({
onOpenChange: handleOpenChange,
onClosed() {
currentLog.value = null;
},
});
const [registerDescription, { setDescProps }] = useDescription({
column: 1,
schema: descSchema,
});
const currentLog = shallowRef<null | OperationLog>(null);
function handleOpenChange(open: boolean) {
if (!open) {
return null;
}
const { record } = drawerApi.getData() as { record: OperationLog };
setDescProps({ data: record }, true);
currentLog.value = record;
}
const actionInfo = computed(() => {
if (!currentLog.value) {
return '-';
}
const data = currentLog.value;
return `账号: ${data.operName} / ${data.deptName} / ${data.operIp} / ${data.operLocation}`;
});
</script>
<template>
<BasicDrawer :footer="false" class="w-[600px]" title="查看日志">
<Description @register="registerDescription" />
<Descriptions v-if="currentLog" size="small" bordered :column="1">
<DescriptionsItem label="日志编号" :label-style="{ minWidth: '120px' }">
{{ currentLog.operId }}
</DescriptionsItem>
<DescriptionsItem label="操作结果">
<component
:is="renderDict(currentLog.status, DictEnum.SYS_COMMON_STATUS)"
/>
</DescriptionsItem>
<DescriptionsItem label="操作模块">
<div class="flex items-center">
<Tag>{{ currentLog.title }}</Tag>
<component
:is="renderDict(currentLog.businessType, DictEnum.SYS_OPER_TYPE)"
/>
</div>
</DescriptionsItem>
<DescriptionsItem label="操作信息">
{{ actionInfo }}
</DescriptionsItem>
<DescriptionsItem label="请求信息">
<component :is="renderHttpMethodTag(currentLog.requestMethod)" />
{{ currentLog.operUrl }}
</DescriptionsItem>
<DescriptionsItem v-if="currentLog.errorMsg" label="异常信息">
<span class="font-semibold text-red-600">
{{ currentLog.errorMsg }}
</span>
</DescriptionsItem>
<DescriptionsItem label="方法">
{{ currentLog.method }}
</DescriptionsItem>
<DescriptionsItem label="请求参数">
<div class="max-h-[300px] overflow-y-auto">
<component :is="renderJsonPreview(currentLog.operParam)" />
</div>
</DescriptionsItem>
<DescriptionsItem v-if="currentLog.jsonResult" label="响应参数">
<div class="max-h-[300px] overflow-y-auto">
<component :is="renderJsonPreview(currentLog.jsonResult)" />
</div>
</DescriptionsItem>
<DescriptionsItem label="请求耗时">
{{ `${currentLog.costTime} ms` }}
</DescriptionsItem>
<DescriptionsItem label="操作时间">
{{ `${currentLog.operTime}` }}
</DescriptionsItem>
</Descriptions>
</BasicDrawer>
</template>

View File

@@ -7,6 +7,7 @@ import { cloneDeep } from '@vben/utils';
import { useVbenForm } from '#/adapter/form';
import { clientAdd, clientInfo, clientUpdate } from '#/api/system/client';
import { defaultFormValueGetter, useBeforeCloseDiff } from '#/utils/popup';
import { drawerSchema } from './data';
import SecretInput from './secret-input.vue';
@@ -55,6 +56,13 @@ function setupForm(update: boolean) {
]);
}
const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
{
initializedGetter: defaultFormValueGetter(formApi),
currentGetter: defaultFormValueGetter(formApi),
},
);
// 提取生成状态字段Schema的函数
const getStatusSchema = (disabled: boolean) => [
{
@@ -64,13 +72,15 @@ const getStatusSchema = (disabled: boolean) => [
];
const [BasicDrawer, drawerApi] = useVbenDrawer({
onCancel: handleCancel,
onBeforeClose,
onClosed: handleClosed,
onConfirm: handleConfirm,
async onOpenChange(isOpen) {
if (!isOpen) {
return null;
}
drawerApi.drawerLoading(true);
const { id } = drawerApi.getData() as { id?: number | string };
isUpdate.value = !!id;
// 初始化
@@ -84,36 +94,39 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({
// 新增模式: 确保状态字段可用
formApi.updateSchema(getStatusSchema(false));
}
await markInitialized();
drawerApi.drawerLoading(false);
},
});
async function handleConfirm() {
try {
drawerApi.drawerLoading(true);
drawerApi.lock(true);
const { valid } = await formApi.validate();
if (!valid) {
return;
}
const data = cloneDeep(await formApi.getValues());
await (isUpdate.value ? clientUpdate(data) : clientAdd(data));
resetInitialized();
emit('reload');
await handleCancel();
drawerApi.close();
} catch (error) {
console.error(error);
} finally {
drawerApi.drawerLoading(false);
drawerApi.lock(false);
}
}
async function handleCancel() {
drawerApi.close();
async function handleClosed() {
await formApi.resetForm();
resetInitialized();
}
</script>
<template>
<BasicDrawer :close-on-click-modal="false" :title="title" class="w-[600px]">
<BasicDrawer :title="title" class="w-[600px]">
<BasicForm>
<template #clientSecret="slotProps">
<SecretInput v-bind="slotProps" :disabled="isUpdate" />

View File

@@ -144,10 +144,10 @@ const { hasAccessByCodes } = useAccess();
<!-- pc不允许禁用 禁用了直接登录不了 应该设置disabled -->
<!-- 登录提示: 认证权限类型已禁用 -->
<TableSwitch
v-model="row.status"
v-model:value="row.status"
:api="() => clientChangeStatus(row)"
:disabled="row.id === 1 || !hasAccessByCodes(['system:client:edit'])"
:reload="() => tableApi.query()"
@reload="tableApi.query()"
/>
</template>
<template #action="{ row }">

View File

@@ -7,6 +7,7 @@ import { cloneDeep } from '@vben/utils';
import { useVbenForm } from '#/adapter/form';
import { configAdd, configInfo, configUpdate } from '#/api/system/config';
import { defaultFormValueGetter, useBeforeCloseDiff } from '#/utils/popup';
import { modalSchema } from './data';
@@ -25,9 +26,17 @@ const [BasicForm, formApi] = useVbenForm({
showDefaultActions: false,
});
const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
{
initializedGetter: defaultFormValueGetter(formApi),
currentGetter: defaultFormValueGetter(formApi),
},
);
const [BasicModal, modalApi] = useVbenModal({
fullscreenButton: false,
onCancel: handleCancel,
onBeforeClose,
onClosed: handleClosed,
onConfirm: handleConfirm,
onOpenChange: async (isOpen) => {
if (!isOpen) {
@@ -42,6 +51,7 @@ const [BasicModal, modalApi] = useVbenModal({
const record = await configInfo(id);
await formApi.setValues(record);
}
await markInitialized();
modalApi.modalLoading(false);
},
@@ -49,30 +59,31 @@ const [BasicModal, modalApi] = useVbenModal({
async function handleConfirm() {
try {
modalApi.modalLoading(true);
modalApi.lock(true);
const { valid } = await formApi.validate();
if (!valid) {
return;
}
const data = cloneDeep(await formApi.getValues());
await (isUpdate.value ? configUpdate(data) : configAdd(data));
resetInitialized();
emit('reload');
await handleCancel();
modalApi.close();
} catch (error) {
console.error(error);
} finally {
modalApi.modalLoading(false);
modalApi.lock(false);
}
}
async function handleCancel() {
modalApi.close();
async function handleClosed() {
await formApi.resetForm();
resetInitialized();
}
</script>
<template>
<BasicModal :close-on-click-modal="false" :title="title" class="w-[550px]">
<BasicModal :title="title" class="w-[550px]">
<BasicForm />
</BasicModal>
</template>

View File

@@ -16,6 +16,7 @@ import {
deptUpdate,
} from '#/api/system/dept';
import { listUserByDeptId } from '#/api/system/user';
import { defaultFormValueGetter, useBeforeCloseDiff } from '#/utils/popup';
import { drawerSchema } from './data';
@@ -107,8 +108,16 @@ async function setLeaderOptions() {
]);
}
const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
{
initializedGetter: defaultFormValueGetter(formApi),
currentGetter: defaultFormValueGetter(formApi),
},
);
const [BasicDrawer, drawerApi] = useVbenDrawer({
onCancel: handleCancel,
onBeforeClose,
onClosed: handleClosed,
onConfirm: handleConfirm,
async onOpenChange(isOpen) {
if (!isOpen) {
@@ -130,6 +139,7 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({
await (update && id ? initDeptUsers(id) : setLeaderOptions());
/** 部门选择 下拉框 */
await initDeptSelect(id);
await markInitialized();
drawerApi.drawerLoading(false);
},
@@ -137,30 +147,31 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({
async function handleConfirm() {
try {
drawerApi.drawerLoading(true);
drawerApi.lock(true);
const { valid } = await formApi.validate();
if (!valid) {
return;
}
const data = cloneDeep(await formApi.getValues());
await (isUpdate.value ? deptUpdate(data) : deptAdd(data));
resetInitialized();
emit('reload');
await handleCancel();
drawerApi.close();
} catch (error) {
console.error(error);
} finally {
drawerApi.drawerLoading(false);
drawerApi.lock(false);
}
}
async function handleCancel() {
drawerApi.close();
async function handleClosed() {
await formApi.resetForm();
resetInitialized();
}
</script>
<template>
<BasicDrawer :close-on-click-modal="false" :title="title" class="w-[600px]">
<BasicDrawer :title="title" class="w-[600px]">
<BasicForm />
</BasicDrawer>
</template>

View File

@@ -12,6 +12,7 @@ import {
dictDetailInfo,
} from '#/api/system/dict/dict-data';
import { tagTypes } from '#/components/dict';
import { defaultFormValueGetter, useBeforeCloseDiff } from '#/utils/popup';
import { drawerSchema } from './data';
import TagStylePicker from './tag-style-picker.vue';
@@ -57,8 +58,16 @@ function setupSelectType(listClass: string) {
selectType.value = isDefault ? 'default' : 'custom';
}
const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
{
initializedGetter: defaultFormValueGetter(formApi),
currentGetter: defaultFormValueGetter(formApi),
},
);
const [BasicDrawer, drawerApi] = useVbenDrawer({
onCancel: handleCancel,
onBeforeClose,
onClosed: handleClosed,
onConfirm: handleConfirm,
async onOpenChange(isOpen) {
if (!isOpen) {
@@ -75,6 +84,7 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({
setupSelectType(record.listClass);
await formApi.setValues(record);
}
await markInitialized();
drawerApi.drawerLoading(false);
},
@@ -82,7 +92,7 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({
async function handleConfirm() {
try {
drawerApi.drawerLoading(true);
drawerApi.lock(true);
const { valid } = await formApi.validate();
if (!valid) {
return;
@@ -93,19 +103,20 @@ async function handleConfirm() {
data.listClass = '';
}
await (isUpdate.value ? dictDataUpdate(data) : dictDataAdd(data));
resetInitialized();
emit('reload');
await handleCancel();
drawerApi.close();
} catch (error) {
console.error(error);
} finally {
drawerApi.drawerLoading(false);
drawerApi.lock(false);
}
}
async function handleCancel() {
drawerApi.close();
async function handleClosed() {
await formApi.resetForm();
selectType.value = 'default';
resetInitialized();
}
/**
@@ -117,7 +128,7 @@ async function handleDeSelect() {
</script>
<template>
<BasicDrawer :close-on-click-modal="false" :title="title" class="w-[600px]">
<BasicDrawer :title="title" class="w-[600px]">
<BasicForm>
<template #listClass="slotProps">
<TagStylePicker

View File

@@ -1,14 +1,17 @@
<script setup lang="ts">
import type { RadioChangeEvent } from 'ant-design-vue';
import type { PropType } from 'vue';
import { computed } from 'vue';
import { Input, RadioGroup, Select } from 'ant-design-vue';
import { usePreferences } from '@vben/preferences';
import { RadioGroup, Select } from 'ant-design-vue';
import { ColorPicker } from 'vue3-colorpicker';
import { tagSelectOptions } from '#/components/dict';
import 'vue3-colorpicker/style.css';
/**
* 需要禁止透传
* 不禁止会有奇怪的bug 会绑定到selectType上
@@ -32,23 +35,26 @@ const computedOptions = computed(
type SelectType = (typeof options)[number]['value'];
const selectType = defineModel('selectType', {
const selectType = defineModel<SelectType>('selectType', {
default: 'default',
type: String as PropType<SelectType>,
});
/**
* color必须为hex颜色或者undefined
*/
const color = defineModel('value', {
const color = defineModel<string | undefined>('value', {
default: undefined,
type: String as PropType<string | undefined>,
});
function handleSelectTypeChange(e: RadioChangeEvent) {
// 必须给默认hex颜色 不能为空字符串
color.value = e.target.value === 'custom' ? '#000000' : undefined;
color.value = e.target.value === 'custom' ? '#1677ff' : undefined;
}
const { isDark } = usePreferences();
const theme = computed(() => {
return isDark.value ? 'black' : 'white';
});
</script>
<template>
@@ -69,15 +75,12 @@ function handleSelectTypeChange(e: RadioChangeEvent) {
placeholder="请选择标签样式"
@deselect="$emit('deselect')"
/>
<Input
<ColorPicker
v-if="selectType === 'custom'"
v-model:value="color"
class="flex-1"
disabled
>
<template #addonAfter>
<input v-model="color" class="rounded-lg" type="color" />
</template>
</Input>
disable-alpha
format="hex"
v-model:pure-color="color"
:theme="theme"
/>
</div>
</template>

View File

@@ -71,7 +71,6 @@ export const modalSchema: FormSchemaGetter = () => [
{
component: 'Textarea',
fieldName: 'remark',
formItemClass: 'items-start',
label: '备注',
},
];

View File

@@ -11,6 +11,7 @@ import {
dictTypeInfo,
dictTypeUpdate,
} from '#/api/system/dict/dict-type';
import { defaultFormValueGetter, useBeforeCloseDiff } from '#/utils/popup';
import { modalSchema } from './data';
@@ -22,6 +23,7 @@ const title = computed(() => {
});
const [BasicForm, formApi] = useVbenForm({
layout: 'vertical',
commonConfig: {
labelWidth: 100,
},
@@ -29,51 +31,63 @@ const [BasicForm, formApi] = useVbenForm({
showDefaultActions: false,
});
const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
{
initializedGetter: defaultFormValueGetter(formApi),
currentGetter: defaultFormValueGetter(formApi),
},
);
const [BasicModal, modalApi] = useVbenModal({
fullscreenButton: false,
onCancel: handleCancel,
onBeforeClose,
onClosed: handleClosed,
onConfirm: handleConfirm,
onOpenChange: async (isOpen) => {
if (!isOpen) {
return null;
}
modalApi.modalLoading(true);
const { id } = modalApi.getData() as { id?: number | string };
isUpdate.value = !!id;
if (isUpdate.value && id) {
const record = await dictTypeInfo(id);
await formApi.setValues(record);
}
await markInitialized();
modalApi.modalLoading(false);
},
});
async function handleConfirm() {
try {
modalApi.modalLoading(true);
modalApi.lock(true);
const { valid } = await formApi.validate();
if (!valid) {
return;
}
const data = cloneDeep(await formApi.getValues());
await (isUpdate.value ? dictTypeUpdate(data) : dictTypeAdd(data));
resetInitialized();
emit('reload');
await handleCancel();
modalApi.close();
} catch (error) {
console.error(error);
} finally {
modalApi.modalLoading(false);
modalApi.lock(false);
}
}
async function handleCancel() {
modalApi.close();
async function handleClosed() {
await formApi.resetForm();
resetInitialized();
}
</script>
<template>
<BasicModal :close-on-click-modal="false" :title="title">
<BasicModal :title="title">
<BasicForm />
</BasicModal>
</template>

View File

@@ -61,8 +61,11 @@ const gridOptions: VxeGridProps = {
},
rowConfig: {
keyField: 'dictId',
// 高亮当前行
isCurrent: true,
},
id: 'system-dict-type-index',
rowClassName: 'hover:cursor-pointer',
};
const lastDictType = ref('');
@@ -193,3 +196,14 @@ function handleDownloadExcel() {
<DictTypeModal @reload="tableApi.query()" />
</div>
</template>
<style lang="scss">
div#dict-type {
.vxe-body--row {
&.row--current {
// 选中行bold
@apply font-semibold;
}
}
}
</style>

View File

@@ -12,6 +12,7 @@ import {
import { useVbenForm } from '#/adapter/form';
import { menuAdd, menuInfo, menuList, menuUpdate } from '#/api/system/menu';
import { defaultFormValueGetter, useBeforeCloseDiff } from '#/utils/popup';
import { drawerSchema } from './data';
@@ -88,14 +89,23 @@ async function setupMenuSelect() {
]);
}
const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
{
initializedGetter: defaultFormValueGetter(formApi),
currentGetter: defaultFormValueGetter(formApi),
},
);
const [BasicDrawer, drawerApi] = useVbenDrawer({
onCancel: handleCancel,
onBeforeClose,
onClosed: handleClosed,
onConfirm: handleConfirm,
async onOpenChange(isOpen) {
if (!isOpen) {
return null;
}
drawerApi.drawerLoading(true);
const { id, update } = drawerApi.getData() as ModalProps;
isUpdate.value = update;
@@ -108,36 +118,39 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({
await formApi.setValues(record);
}
}
await markInitialized();
drawerApi.drawerLoading(false);
},
});
async function handleConfirm() {
try {
drawerApi.drawerLoading(true);
drawerApi.lock(true);
const { valid } = await formApi.validate();
if (!valid) {
return;
}
const data = cloneDeep(await formApi.getValues());
await (isUpdate.value ? menuUpdate(data) : menuAdd(data));
resetInitialized();
emit('reload');
await handleCancel();
drawerApi.close();
} catch (error) {
console.error(error);
} finally {
drawerApi.drawerLoading(false);
drawerApi.lock(false);
}
}
async function handleCancel() {
drawerApi.close();
async function handleClosed() {
await formApi.resetForm();
resetInitialized();
}
</script>
<template>
<BasicDrawer :close-on-click-modal="false" :title="title" class="w-[600px]">
<BasicDrawer :title="title" class="w-[600px]">
<BasicForm />
</BasicDrawer>
</template>

View File

@@ -18,6 +18,7 @@ import { pick } from 'lodash-es';
import { noticeAdd, noticeInfo, noticeUpdate } from '#/api/system/notice';
import { Tinymce } from '#/components/tinymce';
import { getDictOptions } from '#/utils/dict';
import { useBeforeCloseDiff } from '#/utils/popup';
const emit = defineEmits<{ reload: [] }>();
@@ -74,17 +75,29 @@ const { validate, validateInfos, resetFields } = Form.useForm(
formRules,
);
function customFormValueGetter() {
return JSON.stringify(formData.value);
}
const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
{
initializedGetter: customFormValueGetter,
currentGetter: customFormValueGetter,
},
);
const [BasicModal, modalApi] = useVbenModal({
class: 'w-[800px]',
fullscreenButton: true,
closeOnClickModal: false,
onClosed: handleCancel,
onBeforeClose,
onClosed: handleClosed,
onConfirm: handleConfirm,
onOpenChange: async (isOpen) => {
if (!isOpen) {
return null;
}
modalApi.modalLoading(true);
const { id } = modalApi.getData() as { id?: number | string };
isUpdate.value = !!id;
if (isUpdate.value && id) {
@@ -93,30 +106,33 @@ const [BasicModal, modalApi] = useVbenModal({
const filterRecord = pick(record, Object.keys(defaultValues));
formData.value = filterRecord;
}
await markInitialized();
modalApi.modalLoading(false);
},
});
async function handleConfirm() {
try {
modalApi.modalLoading(true);
modalApi.lock(true);
await validate();
// 可能会做数据处理 使用cloneDeep深拷贝
const data = cloneDeep(formData.value);
await (isUpdate.value ? noticeUpdate(data) : noticeAdd(data));
resetInitialized();
emit('reload');
await handleCancel();
modalApi.close();
} catch (error) {
console.error(error);
} finally {
modalApi.modalLoading(false);
modalApi.lock(false);
}
}
async function handleCancel() {
modalApi.close();
async function handleClosed() {
formData.value = defaultValues;
resetFields();
resetInitialized();
}
</script>

View File

@@ -128,10 +128,10 @@ const { hasAccessByCodes } = useAccess();
</template>
<template #status="{ row }">
<TableSwitch
v-model="row.status"
v-model:value="row.status"
:api="() => ossConfigChangeStatus(row)"
:disabled="!hasAccessByCodes(['system:ossConfig:edit'])"
:reload="() => tableApi.query()"
@reload="tableApi.query()"
/>
</template>
<template #action="{ row }">

View File

@@ -13,6 +13,7 @@ import {
ossConfigInfo,
ossConfigUpdate,
} from '#/api/system/oss-config';
import { defaultFormValueGetter, useBeforeCloseDiff } from '#/utils/popup';
import { drawerSchema } from './data';
@@ -33,27 +34,38 @@ const [BasicForm, formApi] = useVbenForm({
wrapperClass: 'grid-cols-3',
});
const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
{
initializedGetter: defaultFormValueGetter(formApi),
currentGetter: defaultFormValueGetter(formApi),
},
);
const [BasicDrawer, drawerApi] = useVbenDrawer({
onCancel: handleCancel,
onBeforeClose,
onClosed: handleClosed,
onConfirm: handleConfirm,
async onOpenChange(isOpen) {
if (!isOpen) {
return null;
}
drawerApi.drawerLoading(true);
const { id } = drawerApi.getData() as { id?: number | string };
isUpdate.value = !!id;
if (isUpdate.value && id) {
const record = await ossConfigInfo(id);
await formApi.setValues(record);
}
await markInitialized();
drawerApi.drawerLoading(false);
},
});
async function handleConfirm() {
try {
drawerApi.drawerLoading(true);
drawerApi.lock(true);
/**
* 这里解构出来的values只能获取到自定义校验参数的值
* 需要自行调用formApi.getValues()获取表单值
@@ -64,23 +76,24 @@ async function handleConfirm() {
}
const data = cloneDeep(await formApi.getValues());
await (isUpdate.value ? ossConfigUpdate(data) : ossConfigAdd(data));
resetInitialized();
emit('reload');
await handleCancel();
drawerApi.close();
} catch (error) {
console.error(error);
} finally {
drawerApi.drawerLoading(false);
drawerApi.lock(false);
}
}
async function handleCancel() {
drawerApi.close();
async function handleClosed() {
await formApi.resetForm();
resetInitialized();
}
</script>
<template>
<BasicDrawer :close-on-click-modal="false" :title="title" class="w-[650px]">
<BasicDrawer :title="title" class="w-[650px]">
<BasicForm>
<template #tip>
<div class="ml-7 w-full">

View File

@@ -1,10 +1,8 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Alert } from 'ant-design-vue';
import { FileUpload } from '#/components/upload';
const emit = defineEmits<{ reload: [] }>();
@@ -23,13 +21,6 @@ const [BasicModal, modalApi] = useVbenModal({
}
},
});
const accept = ref(['xlsx', 'word', 'pdf']);
const maxNumber = ref(3);
const message = computed(() => {
return `支持 [${accept.value.join(', ')}] 格式,最多上传 ${maxNumber.value} 个文件`;
});
</script>
<template>
@@ -40,13 +31,7 @@ const message = computed(() => {
title="文件上传"
>
<div class="flex flex-col gap-4">
<Alert :message="message" show-icon type="info">aaa</Alert>
<FileUpload
v-model:value="fileList"
:accept="accept"
:max-number="maxNumber"
:max-size="5"
/>
<FileUpload v-model:value="fileList" :enable-drag-upload="true" />
</div>
</BasicModal>
</template>

View File

@@ -1,10 +1,8 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Alert } from 'ant-design-vue';
import { ImageUpload } from '#/components/upload';
const emit = defineEmits<{ reload: [] }>();
@@ -23,13 +21,6 @@ const [BasicModal, modalApi] = useVbenModal({
}
},
});
const accept = ref(['jpg', 'jpeg', 'png', 'gif', 'webp']);
const maxNumber = ref(3);
const message = computed(() => {
return `支持 [${accept.value.join(', ')}] 格式,最多上传 ${maxNumber.value} 张图片`;
});
</script>
<template>
@@ -40,12 +31,7 @@ const message = computed(() => {
title="图片上传"
>
<div class="flex flex-col gap-4">
<Alert :message="message" show-icon type="info">aaa</Alert>
<ImageUpload
v-model:value="fileList"
:accept="accept"
:max-number="maxNumber"
/>
<ImageUpload v-model:value="fileList" />
</div>
</BasicModal>
</template>

View File

@@ -83,9 +83,14 @@ const gridOptions: VxeGridProps = {
},
},
},
headerCellConfig: {
height: 44,
},
cellConfig: {
height: 65,
},
rowConfig: {
keyField: 'ossId',
height: 65,
},
sortConfig: {
// 远程排序

View File

@@ -96,7 +96,7 @@ async function handleCancel() {
</script>
<template>
<BasicDrawer :close-on-click-modal="false" :title="title" class="w-[600px]">
<BasicDrawer :title="title" class="w-[600px]">
<BasicForm />
</BasicDrawer>
</template>

View File

@@ -177,14 +177,14 @@ function handleAssignRole(record: Role) {
</template>
<template #status="{ row }">
<TableSwitch
v-model="row.status"
v-model:value="row.status"
:api="() => roleChangeStatus(row)"
:disabled="
row.roleId === 1 ||
row.roleKey === 'admin' ||
!hasAccessByCodes(['system:role:edit'])
"
:reload="() => tableApi.query()"
@reload="tableApi.query()"
/>
</template>
<template #action="{ row }">
@@ -215,10 +215,7 @@ function handleAssignRole(record: Role) {
</ghost-button>
</Popconfirm>
</Space>
<Dropdown
:get-popup-container="getVxePopupContainer"
placement="bottomRight"
>
<Dropdown placement="bottomRight">
<template #overlay>
<Menu>
<MenuItem key="1" @click="handleAuthEdit(row)">

View File

@@ -9,6 +9,7 @@ import { cloneDeep } from '@vben/utils';
import { useVbenForm } from '#/adapter/form';
import { roleDataScope, roleDeptTree, roleInfo } from '#/api/system/role';
import { TreeSelectPanel } from '#/components/tree';
import { defaultFormValueGetter, useBeforeCloseDiff } from '#/utils/popup';
import { authModalSchemas } from './data';
@@ -33,9 +34,25 @@ async function setupDeptTree(id: number | string) {
deptTree.value = resp.depts;
}
async function customFormValueGetter() {
const v = await defaultFormValueGetter(formApi)();
// 获取勾选信息
const menuIds = deptSelectRef.value?.[0]?.getCheckedKeys() ?? [];
const mixStr = v + menuIds.join(',');
return mixStr;
}
const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
{
initializedGetter: customFormValueGetter,
currentGetter: customFormValueGetter,
},
);
const [BasicModal, modalApi] = useVbenModal({
fullscreenButton: false,
onCancel: handleCancel,
onBeforeClose,
onCancel: handleClosed,
onConfirm: handleConfirm,
onOpenChange: async (isOpen) => {
if (!isOpen) {
@@ -48,6 +65,7 @@ const [BasicModal, modalApi] = useVbenModal({
setupDeptTree(id);
const record = await roleInfo(id);
await formApi.setValues(record);
markInitialized();
modalApi.modalLoading(false);
},
@@ -60,7 +78,7 @@ const deptSelectRef = ref();
async function handleConfirm() {
try {
modalApi.modalLoading(true);
modalApi.lock(true);
const { valid } = await formApi.validate();
if (!valid) {
return;
@@ -75,18 +93,19 @@ async function handleConfirm() {
data.deptIds = [];
}
await roleDataScope(data);
resetInitialized();
emit('reload');
await handleCancel();
modalApi.close();
} catch (error) {
console.error(error);
} finally {
modalApi.modalLoading(false);
modalApi.lock(false);
}
}
async function handleCancel() {
modalApi.close();
async function handleClosed() {
await formApi.resetForm();
resetInitialized();
}
/**
@@ -99,11 +118,7 @@ function handleCheckStrictlyChange(value: boolean) {
</script>
<template>
<BasicModal
:close-on-click-modal="false"
class="min-h-[600px] w-[550px]"
title="分配权限"
>
<BasicModal class="min-h-[600px] w-[550px]" title="分配权限">
<BasicForm>
<template #deptIds="slotProps">
<TreeSelectPanel

View File

@@ -1,3 +1,6 @@
<!--
TODO: 这个页面要优化逻辑
-->
<script setup lang="ts">
import type { MenuOption } from '#/api/system/menu/model';
@@ -11,6 +14,7 @@ import { useVbenForm } from '#/adapter/form';
import { menuTreeSelect, roleMenuTreeSelect } from '#/api/system/menu';
import { roleAdd, roleInfo, roleUpdate } from '#/api/system/role';
import { MenuSelectTable } from '#/components/tree';
import { defaultFormValueGetter, useBeforeCloseDiff } from '#/utils/popup';
import { drawerSchema } from './data';
@@ -62,14 +66,31 @@ async function setupMenuTree(id?: number | string) {
}
}
async function customFormValueGetter() {
const v = await defaultFormValueGetter(formApi)();
// 获取勾选信息
const menuIds = menuSelectRef.value?.getCheckedKeys?.() ?? [];
const mixStr = v + menuIds.join(',');
return mixStr;
}
const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
{
initializedGetter: customFormValueGetter,
currentGetter: customFormValueGetter,
},
);
const [BasicDrawer, drawerApi] = useVbenDrawer({
onCancel: handleCancel,
onBeforeClose,
onClosed: handleClosed,
onConfirm: handleConfirm,
async onOpenChange(isOpen) {
if (!isOpen) {
return null;
}
drawerApi.drawerLoading(true);
const { id } = drawerApi.getData() as { id?: number | string };
isUpdate.value = !!id;
@@ -79,6 +100,7 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({
}
// init菜单 注意顺序要放在赋值record之后 内部watch会依赖record
await setupMenuTree(id);
await markInitialized();
drawerApi.drawerLoading(false);
},
@@ -87,7 +109,8 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({
const menuSelectRef = ref<InstanceType<typeof MenuSelectTable>>();
async function handleConfirm() {
try {
drawerApi.drawerLoading(true);
drawerApi.lock(true);
const { valid } = await formApi.validate();
if (!valid) {
return;
@@ -99,17 +122,18 @@ async function handleConfirm() {
data.menuIds = menuIds;
await (isUpdate.value ? roleUpdate(data) : roleAdd(data));
emit('reload');
await handleCancel();
resetInitialized();
drawerApi.close();
} catch (error) {
console.error(error);
} finally {
drawerApi.drawerLoading(false);
drawerApi.lock(false);
}
}
async function handleCancel() {
drawerApi.close();
async function handleClosed() {
await formApi.resetForm();
resetInitialized();
}
/**
@@ -122,7 +146,7 @@ function handleMenuCheckStrictlyChange(value: boolean) {
</script>
<template>
<BasicDrawer :close-on-click-modal="false" :title="title" class="w-[800px]">
<BasicDrawer :title="title" class="w-[800px]">
<BasicForm>
<template #menuIds="slotProps">
<div class="h-[600px] w-full">

View File

@@ -183,10 +183,10 @@ function handleSyncTenantDict() {
</template>
<template #status="{ row }">
<TableSwitch
v-model="row.status"
v-model:value="row.status"
:api="() => tenantStatusChange(row)"
:disabled="row.id === 1 || !hasAccessByCodes(['system:tenant:edit'])"
:reload="() => tableApi.query()"
@reload="tableApi.query()"
/>
</template>
<template #action="{ row }">

View File

@@ -9,6 +9,7 @@ import { useVbenForm } from '#/adapter/form';
import { tenantAdd, tenantInfo, tenantUpdate } from '#/api/system/tenant';
import { packageSelectList } from '#/api/system/tenant-package';
import { useTenantStore } from '#/store/tenant';
import { defaultFormValueGetter, useBeforeCloseDiff } from '#/utils/popup';
import { drawerSchema } from './data';
@@ -51,22 +52,33 @@ async function setupPackageSelect() {
]);
}
const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
{
initializedGetter: defaultFormValueGetter(formApi),
currentGetter: defaultFormValueGetter(formApi),
},
);
const [BasicDrawer, drawerApi] = useVbenDrawer({
onCancel: handleCancel,
onBeforeClose,
onClosed: handleClosed,
onConfirm: handleConfirm,
async onOpenChange(isOpen) {
if (!isOpen) {
return null;
}
drawerApi.drawerLoading(true);
const { id } = drawerApi.getData() as { id?: number | string };
isUpdate.value = !!id;
// 初始化
await setupPackageSelect();
if (isUpdate.value && id) {
const record = await tenantInfo(id);
await formApi.setValues(record);
}
formApi.updateSchema([
{
fieldName: 'packageId',
@@ -75,6 +87,8 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({
},
},
]);
await markInitialized();
drawerApi.drawerLoading(false);
},
});
@@ -82,32 +96,33 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({
const tenantStore = useTenantStore();
async function handleConfirm() {
try {
drawerApi.drawerLoading(true);
drawerApi.lock(true);
const { valid } = await formApi.validate();
if (!valid) {
return;
}
const data = cloneDeep(await formApi.getValues());
await (isUpdate.value ? tenantUpdate(data) : tenantAdd(data));
resetInitialized();
emit('reload');
await handleCancel();
drawerApi.close();
// 重新加载租户信息
tenantStore.initTenant();
} catch (error) {
console.error(error);
} finally {
drawerApi.drawerLoading(false);
drawerApi.lock(false);
}
}
async function handleCancel() {
drawerApi.close();
async function handleClosed() {
await formApi.resetForm();
resetInitialized();
}
</script>
<template>
<BasicDrawer :close-on-click-modal="false" :title="title" class="w-[600px]">
<BasicDrawer :title="title" class="w-[600px]">
<BasicForm />
</BasicDrawer>
</template>

View File

@@ -65,12 +65,6 @@ export const drawerSchema: FormSchemaGetter = () => [
{
component: 'Textarea',
fieldName: 'remark',
formItemClass: 'items-start',
label: '备注',
},
];
// 租户管理 不可分配 只有superadmin有权限操作 分配了也没用
export const excludeIds = [
6, 121, 122, 1606, 1607, 1608, 1609, 1610, 1611, 1612, 1613, 1614, 1615,
];

View File

@@ -154,10 +154,10 @@ const isSuperAdmin = computed(() => {
</template>
<template #status="{ row }">
<TableSwitch
v-model="row.status"
v-model:value="row.status"
:api="() => packageChangeStatus(row)"
:disabled="!hasAccessByCodes(['system:tenantPackage:edit'])"
:reload="() => tableApi.query()"
@reload="tableApi.query()"
/>
</template>
<template #action="{ row }">

View File

@@ -17,6 +17,7 @@ import {
packageUpdate,
} from '#/api/system/tenant-package';
import { MenuSelectTable } from '#/components/tree';
import { defaultFormValueGetter, useBeforeCloseDiff } from '#/utils/popup';
import { drawerSchema } from './data';
@@ -65,8 +66,24 @@ async function setupMenuTree(id?: number | string) {
}
}
async function customFormValueGetter() {
const v = await defaultFormValueGetter(formApi)();
// 获取勾选信息
const menuIds = menuSelectRef.value?.getCheckedKeys?.() ?? [];
const mixStr = v + menuIds.join(',');
return mixStr;
}
const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
{
initializedGetter: customFormValueGetter,
currentGetter: customFormValueGetter,
},
);
const [BasicDrawer, drawerApi] = useVbenDrawer({
onCancel: handleCancel,
onBeforeClose,
onClosed: handleClosed,
onConfirm: handleConfirm,
async onOpenChange(isOpen) {
if (!isOpen) {
@@ -84,6 +101,7 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({
}
// init菜单 注意顺序要放在赋值record之后 内部watch会依赖record
await setupMenuTree(id);
await markInitialized();
drawerApi.drawerLoading(false);
},
@@ -103,8 +121,9 @@ async function handleConfirm() {
const data = cloneDeep(await formApi.getValues());
data.menuIds = menuIds;
await (isUpdate.value ? packageUpdate(data) : packageAdd(data));
resetInitialized();
emit('reload');
await handleCancel();
drawerApi.close();
} catch (error) {
console.error(error);
} finally {
@@ -112,9 +131,9 @@ async function handleConfirm() {
}
}
async function handleCancel() {
drawerApi.close();
async function handleClosed() {
await formApi.resetForm();
resetInitialized();
}
/**
@@ -127,7 +146,7 @@ function handleMenuCheckStrictlyChange(value: boolean) {
</script>
<template>
<BasicDrawer :close-on-click-modal="false" :title="title" class="w-[800px]">
<BasicDrawer :title="title" class="w-[800px]">
<BasicForm>
<template #menuIds="slotProps">
<div class="h-[600px] w-full">

View File

@@ -1,44 +0,0 @@
import type { PropType } from 'vue';
import type { Menu } from '#/api/system/menu/model';
import { computed, defineComponent } from 'vue';
import { Tag } from 'ant-design-vue';
export default defineComponent({
name: 'TreeItem',
props: {
data: {
required: true,
type: Object as PropType<Menu>,
},
},
setup(props, { expose }) {
expose();
interface TagProp {
color: string;
text: string;
}
const menuTagProp = computed<TagProp>(() => {
// 正则判断是否为链接
if (/^https?:\/\/[^\s/$.?#].\S*$/i.test(props.data.path)) {
return { color: 'pink', text: '外链' };
}
const type = props.data.menuType;
if (type === 'M') return { color: 'green', text: '目录' };
if (type === 'C') return { color: 'blue', text: '菜单' };
if (type === 'F') return { color: '', text: '按钮' };
return { color: 'error', text: '未知' };
});
return () => (
<div class="flex gap-[6px]">
<span>{props.data.menuName}</span>
<Tag color={menuTagProp.value.color}>{menuTagProp.value.text}</Tag>
</div>
);
},
});

View File

@@ -64,7 +64,7 @@ const formOptions: VbenFormProps = {
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
handleReset: async () => {
selectDeptId.value = [];
// eslint-disable-next-line no-use-before-define
const { formApi, reload } = tableApi;
await formApi.resetForm();
const formValues = formApi.form.values;
@@ -113,9 +113,14 @@ const gridOptions: VxeGridProps = {
},
},
},
headerCellConfig: {
height: 44,
},
cellConfig: {
height: 48,
},
rowConfig: {
keyField: 'userId',
height: 48,
},
id: 'system-user-index',
};
@@ -231,12 +236,12 @@ const { hasAccessByCodes } = useAccess();
</template>
<template #status="{ row }">
<TableSwitch
v-model="row.status"
v-model:value="row.status"
:api="() => userStatusChange(row)"
:disabled="
row.userId === 1 || !hasAccessByCodes(['system:user:edit'])
"
:reload="() => tableApi.query()"
@reload="() => tableApi.query()"
/>
</template>
<template #action="{ row }">
@@ -263,10 +268,7 @@ const { hasAccessByCodes } = useAccess();
</ghost-button>
</Popconfirm>
</Space>
<Dropdown
:get-popup-container="getVxePopupContainer"
placement="bottomRight"
>
<Dropdown placement="bottomRight">
<template #overlay>
<Menu>
<MenuItem key="1" @click="handleUserInfo(row)">

View File

@@ -1,129 +0,0 @@
import type { DescItem } from '#/components/description';
import { DictEnum } from '@vben/constants';
import { Tag } from 'ant-design-vue';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime';
import { renderDict } from '#/utils/render';
dayjs.extend(duration);
dayjs.extend(relativeTime);
function renderTags(list: string[]) {
return (
<div class="flex flex-row flex-wrap gap-0.5">
{list.map((item) => (
<Tag key={item}>{item}</Tag>
))}
</div>
);
}
export const descSchema: DescItem[] = [
{
field: 'userId',
label: '用户ID',
},
{
field: 'status',
label: '用户状态',
render(value) {
return renderDict(value, DictEnum.SYS_NORMAL_DISABLE);
},
},
{
field: 'nickName',
label: '用户信息',
render(_, data) {
const { deptName = '暂无部门信息', nickName, userName } = data;
// 为了兼容新版本和旧版本
let currentDept = deptName;
if (data.dept && data.dept.deptName) {
currentDept = data.dept.deptName;
}
return `${userName} / ${nickName} / ${currentDept}`;
},
},
{
field: 'phonenumber',
label: '手机号',
render(value) {
return value || '未设置手机号码';
},
},
{
field: 'email',
label: '邮箱',
render(value) {
return value || '未设置邮箱地址';
},
},
{
field: 'postNames',
label: '岗位',
render(value) {
if (Array.isArray(value) && value.length === 0) {
return '暂无信息';
}
return renderTags(value);
},
},
{
field: 'roleNames',
label: '权限',
render(value) {
if (Array.isArray(value) && value.length === 0) {
return '暂无信息';
}
return renderTags(value);
},
},
{
field: 'createTime',
label: '创建时间',
},
{
field: 'loginIp',
label: '上次登录IP',
render(value) {
return value || <span class="text-orange-500"></span>;
},
},
{
field: 'loginDate',
label: '上次登录时间',
render(value) {
if (!value) {
return <span class="text-orange-500"></span>;
}
// 默认en显示
dayjs.locale('zh-cn');
// 计算相差秒数
const diffSeconds = dayjs().diff(dayjs(value), 'second');
/**
* 转为时间显示(x月 x天)
* https://dayjs.fenxianglu.cn/category/duration.html#%E4%BA%BA%E6%80%A7%E5%8C%96
*
*/
const diffText = dayjs.duration(diffSeconds, 'seconds').humanize();
return (
<div class="flex gap-2">
{value}
<Tag bordered={false} color="cyan">
{diffText}
</Tag>
</div>
);
},
},
{
field: 'remark',
label: '备注',
render(value) {
return value || '无';
},
},
];

View File

@@ -18,6 +18,7 @@ import {
userAdd,
userUpdate,
} from '#/api/system/user';
import { defaultFormValueGetter, useBeforeCloseDiff } from '#/utils/popup';
import { authScopeOptions } from '#/views/system/role/data';
import { drawerSchema } from './data';
@@ -134,8 +135,16 @@ async function loadDefaultPassword(update: boolean) {
}
}
const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
{
initializedGetter: defaultFormValueGetter(formApi),
currentGetter: defaultFormValueGetter(formApi),
},
);
const [BasicDrawer, drawerApi] = useVbenDrawer({
onCancel: handleCancel,
onBeforeClose,
onClosed: handleClosed,
onConfirm: handleConfirm,
async onOpenChange(isOpen) {
if (!isOpen) {
@@ -149,6 +158,7 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({
return null;
}
drawerApi.drawerLoading(true);
const { id } = drawerApi.getData() as { id?: number | string };
isUpdate.value = !!id;
/** update时 禁用用户名修改 不显示密码框 */
@@ -186,10 +196,11 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({
fieldName: 'postIds',
},
]);
// 部门选择 && 初始密码
await Promise.all([setupDeptSelect(), loadDefaultPassword(isUpdate.value)]);
// 部门选择、初始密码及用户相关操作并行处理
const promises = [setupDeptSelect(), loadDefaultPassword(isUpdate.value)];
if (user) {
await Promise.all([
promises.push(
// 添加基础信息
formApi.setValues(user),
// 添加角色和岗位
@@ -197,38 +208,43 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({
formApi.setFieldValue('roleIds', roleIds),
// 更新时不会触发onSelect 需要手动调用
setupPostOptions(user.deptId),
]);
);
}
// 并行处理 重构后会带来10-50ms的优化
await Promise.all(promises);
await markInitialized();
drawerApi.drawerLoading(false);
},
});
async function handleConfirm() {
try {
drawerApi.drawerLoading(true);
drawerApi.lock(true);
const { valid } = await formApi.validate();
if (!valid) {
return;
}
const data = cloneDeep(await formApi.getValues());
await (isUpdate.value ? userUpdate(data) : userAdd(data));
resetInitialized();
emit('reload');
await handleCancel();
drawerApi.close();
} catch (error) {
console.error(error);
} finally {
drawerApi.drawerLoading(false);
drawerApi.lock(false);
}
}
async function handleCancel() {
drawerApi.close();
await formApi.resetForm();
async function handleClosed() {
formApi.resetForm();
resetInitialized();
}
</script>
<template>
<BasicDrawer :close-on-click-modal="false" :title="title" class="w-[600px]">
<BasicDrawer :title="title" class="w-[600px]">
<BasicForm />
</BasicDrawer>
</template>

View File

@@ -1,25 +1,34 @@
<script setup lang="ts">
import type { User } from '#/api/system/user/model';
import { computed, shallowRef } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { DictEnum } from '@vben/constants';
import { Descriptions, DescriptionsItem, Tag } from 'ant-design-vue';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime';
import { findUserInfo } from '#/api/system/user';
import { Description, useDescription } from '#/components/description';
import { renderDict } from '#/utils/render';
import { descSchema } from './info';
dayjs.extend(duration);
dayjs.extend(relativeTime);
const [BasicModal, modalApi] = useVbenModal({
onOpenChange: handleOpenChange,
onClosed() {
currentUser.value = null;
},
});
const [registerDescription, { setDescProps }] = useDescription({
column: 1,
labelStyle: {
minWidth: '150px',
width: '150px',
},
schema: descSchema,
});
interface UserWithNames extends User {
postNames: string[];
roleNames: string[];
}
const currentUser = shallowRef<null | UserWithNames>(null);
async function handleOpenChange(open: boolean) {
if (!open) {
@@ -41,22 +50,103 @@ async function handleOpenChange(open: boolean) {
.filter((item) => roleIds.includes(item.roleId))
.map((item) => item.roleName);
interface UserWithNames extends User {
postNames: string[];
roleNames: string[];
}
(user as UserWithNames).postNames = postNames;
(user as UserWithNames).roleNames = roleNames;
// 赋值
setDescProps({ data: user });
currentUser.value = user as UserWithNames;
modalApi.modalLoading(false);
}
const mixInfo = computed(() => {
if (!currentUser.value) {
return '-';
}
const { deptName, nickName, userName } = currentUser.value;
return `${userName} / ${nickName} / ${deptName ?? '-'}`;
});
const diffLoginTime = computed(() => {
if (!currentUser.value) {
return '-';
}
const { loginDate } = currentUser.value;
// 默认en显示
dayjs.locale('zh-cn');
// 计算相差秒数
const diffSeconds = dayjs().diff(dayjs(loginDate), 'second');
/**
* 转为时间显示(x月 x天)
* https://dayjs.fenxianglu.cn/category/duration.html#%E4%BA%BA%E6%80%A7%E5%8C%96
*
*/
const diffText = dayjs.duration(diffSeconds, 'seconds').humanize();
return diffText;
});
</script>
<template>
<BasicModal :footer="false" :fullscreen-button="false" title="用户信息">
<Description @register="registerDescription" />
<Descriptions v-if="currentUser" size="small" :column="1" bordered>
<DescriptionsItem label="userId">
{{ currentUser.userId }}
</DescriptionsItem>
<DescriptionsItem label="用户状态">
<component
:is="renderDict(currentUser.status, DictEnum.SYS_NORMAL_DISABLE)"
/>
</DescriptionsItem>
<DescriptionsItem label="用户信息">
{{ mixInfo }}
</DescriptionsItem>
<DescriptionsItem label="手机号">
{{ currentUser.phonenumber || '-' }}
</DescriptionsItem>
<DescriptionsItem label="邮箱">
{{ currentUser.email || '-' }}
</DescriptionsItem>
<DescriptionsItem label="岗位">
<div
v-if="currentUser.postNames.length > 0"
class="flex flex-wrap gap-0.5"
>
<Tag v-for="item in currentUser.postNames" :key="item">
{{ item }}
</Tag>
</div>
<span v-else>-</span>
</DescriptionsItem>
<DescriptionsItem label="权限">
<div
v-if="currentUser.roleNames.length > 0"
class="flex flex-wrap gap-0.5"
>
<Tag v-for="item in currentUser.roleNames" :key="item">
{{ item }}
</Tag>
</div>
<span v-else>-</span>
</DescriptionsItem>
<DescriptionsItem label="创建时间">
{{ currentUser.createTime }}
</DescriptionsItem>
<DescriptionsItem label="上次登录IP">
{{ currentUser.loginIp ?? '-' }}
</DescriptionsItem>
<DescriptionsItem label="上次登录时间">
<span>{{ currentUser.loginDate ?? '-' }}</span>
<Tag
class="ml-2"
v-if="diffLoginTime"
:bordered="false"
color="processing"
>
{{ diffLoginTime }}
</Tag>
</DescriptionsItem>
<DescriptionsItem label="备注">
{{ currentUser.remark ?? '-' }}
</DescriptionsItem>
</Descriptions>
</BasicModal>
</template>

View File

@@ -1,38 +1,23 @@
<script setup lang="ts">
import type { ResetPwdParam, User } from '#/api/system/user/model';
import { ref } from 'vue';
import { useVbenModal, z } from '@vben/common-ui';
import { Descriptions, DescriptionsItem } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { userResetPassword } from '#/api/system/user';
import { Description, useDescription } from '#/components/description';
const emit = defineEmits<{ reload: [] }>();
const [BasicModal, modalApi] = useVbenModal({
onCancel: handleCancel,
onClosed: handleClosed,
onConfirm: handleSubmit,
onOpenChange: handleOpenChange,
});
const [registerDescription, { setDescProps }] = useDescription({
column: 1,
schema: [
{
field: 'userId',
label: '用户ID',
},
{
field: 'userName',
label: '用户名',
},
{
field: 'nickName',
label: '昵称',
},
],
});
const [BasicForm, formApi] = useVbenForm({
schema: [
{
@@ -64,13 +49,18 @@ const [BasicForm, formApi] = useVbenForm({
},
});
const currentUser = ref<null | User>(null);
async function handleOpenChange(open: boolean) {
if (!open) {
return null;
}
modalApi.modalLoading(true);
const { record } = modalApi.getData() as { record: User };
setDescProps({ data: record }, true);
currentUser.value = record;
await formApi.setValues({ userId: record.userId });
modalApi.modalLoading(false);
}
async function handleSubmit() {
@@ -83,7 +73,7 @@ async function handleSubmit() {
const data = await formApi.getValues();
await userResetPassword(data as ResetPwdParam);
emit('reload');
handleCancel();
handleClosed();
} catch (error) {
console.error(error);
} finally {
@@ -91,9 +81,10 @@ async function handleSubmit() {
}
}
async function handleCancel() {
async function handleClosed() {
modalApi.close();
await formApi.resetForm();
currentUser.value = null;
}
</script>
@@ -104,7 +95,17 @@ async function handleCancel() {
title="重置密码"
>
<div class="flex flex-col gap-[12px]">
<Description @register="registerDescription" />
<Descriptions v-if="currentUser" size="small" :column="1" bordered>
<DescriptionsItem label="用户ID">
{{ currentUser.userId }}
</DescriptionsItem>
<DescriptionsItem label="用户名">
{{ currentUser.userName }}
</DescriptionsItem>
<DescriptionsItem label="昵称">
{{ currentUser.nickName }}
</DescriptionsItem>
</Descriptions>
<BasicForm />
</div>
</BasicModal>

View File

@@ -17,6 +17,7 @@ import {
categoryList,
categoryUpdate,
} from '#/api/workflow/category';
import { defaultFormValueGetter, useBeforeCloseDiff } from '#/utils/popup';
import { modalSchema } from './data';
@@ -65,9 +66,17 @@ async function setupCategorySelect() {
]);
}
const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
{
initializedGetter: defaultFormValueGetter(formApi),
currentGetter: defaultFormValueGetter(formApi),
},
);
const [BasicModal, modalApi] = useVbenModal({
fullscreenButton: false,
onCancel: handleCancel,
onBeforeClose,
onClosed: handleClosed,
onConfirm: handleConfirm,
onOpenChange: async (isOpen) => {
if (!isOpen) {
@@ -89,6 +98,7 @@ const [BasicModal, modalApi] = useVbenModal({
await formApi.setValues({ parentId });
}
await setupCategorySelect();
await markInitialized();
modalApi.modalLoading(false);
},
@@ -96,7 +106,7 @@ const [BasicModal, modalApi] = useVbenModal({
async function handleConfirm() {
try {
modalApi.modalLoading(true);
modalApi.lock(true);
const { valid } = await formApi.validate();
if (!valid) {
return;
@@ -104,27 +114,24 @@ async function handleConfirm() {
// getValues获取为一个readonly的对象 需要修改必须先深拷贝一次
const data = cloneDeep(await formApi.getValues());
await (isUpdate.value ? categoryUpdate(data) : categoryAdd(data));
resetInitialized();
emit('reload');
await handleCancel();
modalApi.close();
} catch (error) {
console.error(error);
} finally {
modalApi.modalLoading(false);
modalApi.lock(false);
}
}
async function handleCancel() {
modalApi.close();
async function handleClosed() {
await formApi.resetForm();
resetInitialized();
}
</script>
<template>
<BasicModal
:close-on-click-modal="false"
:title="title"
class="min-h-[500px]"
>
<BasicModal :title="title" class="min-h-[500px]">
<BasicForm />
</BasicModal>
</template>

View File

@@ -24,6 +24,10 @@ export const columns: VxeGridProps['columns'] = [
field: 'orderNum',
title: '排序',
},
{
field: 'createTime',
title: '创建时间',
},
{
field: 'action',
fixed: 'right',

View File

@@ -87,20 +87,9 @@ const [BasicForm, formApi] = useVbenForm({
fieldName: 'attachment',
component: 'FileUpload',
componentProps: {
resultField: 'ossId',
maxNumber: 10,
maxCount: 10,
maxSize: 20,
accept: [
'png',
'jpg',
'jpeg',
'doc',
'docx',
'xlsx',
'xls',
'ppt',
'pdf',
],
accept: 'png, jpg, jpeg, doc, docx, xlsx, xls, ppt, pdf',
},
defaultValue: [],
label: '附件上传',

View File

@@ -59,20 +59,9 @@ const [BasicForm, formApi] = useVbenForm({
fieldName: 'attachment',
component: 'FileUpload',
componentProps: {
resultField: 'ossId',
maxNumber: 10,
maxCount: 10,
maxSize: 20,
accept: [
'png',
'jpg',
'jpeg',
'doc',
'docx',
'xlsx',
'xls',
'ppt',
'pdf',
],
accept: 'png, jpg, jpeg, doc, docx, xlsx, xls, ppt, pdf',
},
defaultValue: [],
label: '附件上传',

View File

@@ -54,20 +54,9 @@ const [BasicForm, formApi] = useVbenForm({
fieldName: 'attachment',
component: 'FileUpload',
componentProps: {
resultField: 'ossId',
maxNumber: 10,
maxCount: 10,
maxSize: 20,
accept: [
'png',
'jpg',
'jpeg',
'doc',
'docx',
'xlsx',
'xls',
'ppt',
'pdf',
],
accept: 'png, jpg, jpeg, doc, docx, xlsx, xls, ppt, pdf',
},
defaultValue: [],
label: '附件上传',

View File

@@ -37,8 +37,8 @@ const gridOptions: VxeGridProps = {
highlight: true,
// 翻页时保留选中状态
reserve: true,
// 点击行选中
// trigger: 'row',
// 选中 需要根据状态判断
checkMethod: ({ row }) => ['back', 'cancel', 'draft'].includes(row.status),
},
columns,
height: 'auto',

View File

@@ -93,9 +93,14 @@ const gridOptions: VxeGridProps = {
},
},
},
headerCellConfig: {
height: 44,
},
cellConfig: {
height: 100,
},
rowConfig: {
keyField: 'id',
height: 100,
},
id: 'workflow-definition-index',
};

View File

@@ -12,6 +12,7 @@ import {
workflowDefinitionInfo,
workflowDefinitionUpdate,
} from '#/api/workflow/definition';
import { defaultFormValueGetter, useBeforeCloseDiff } from '#/utils/popup';
import { modalSchema } from './data';
@@ -65,8 +66,16 @@ async function setupCategorySelect() {
]);
}
const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
{
initializedGetter: defaultFormValueGetter(formApi),
currentGetter: defaultFormValueGetter(formApi),
},
);
const [BasicDrawer, modalApi] = useVbenModal({
onCancel: handleCancel,
onBeforeClose,
onCancel: handleClosed,
onConfirm: handleConfirm,
async onOpenChange(isOpen) {
if (!isOpen) {
@@ -83,6 +92,7 @@ const [BasicDrawer, modalApi] = useVbenModal({
const record = await workflowDefinitionInfo(id);
await formApi.setValues(record);
}
await markInitialized();
modalApi.modalLoading(false);
},
@@ -90,7 +100,7 @@ const [BasicDrawer, modalApi] = useVbenModal({
async function handleConfirm() {
try {
modalApi.modalLoading(true);
modalApi.lock(true);
const { valid } = await formApi.validate();
if (!valid) {
return;
@@ -103,27 +113,23 @@ async function handleConfirm() {
await workflowDefinitionAdd(data);
emit('reload', 'add');
}
await handleCancel();
resetInitialized();
modalApi.close();
} catch (error) {
console.error(error);
} finally {
modalApi.modalLoading(false);
modalApi.lock(false);
}
}
async function handleCancel() {
modalApi.close();
async function handleClosed() {
await formApi.resetForm();
resetInitialized();
}
</script>
<template>
<BasicDrawer
:close-on-click-modal="false"
:fullscreen-button="false"
:title="title"
class="w-[550px]"
>
<BasicDrawer :fullscreen-button="false" :title="title" class="w-[550px]">
<div class="min-h-[400px]">
<BasicForm />
</div>

View File

@@ -41,7 +41,7 @@ const formOptions: VbenFormProps = {
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
handleReset: async () => {
selectedCode.value = [];
// eslint-disable-next-line no-use-before-define
const { formApi, reload } = tableApi;
await formApi.resetForm();
const formValues = formApi.form.values;
@@ -68,7 +68,7 @@ async function handleTypeChange(e: RadioChangeEvent) {
break;
}
}
// eslint-disable-next-line no-use-before-define
await tableApi.reload();
}
@@ -103,9 +103,14 @@ const gridOptions: VxeGridProps = {
},
},
},
headerCellConfig: {
height: 44,
},
cellConfig: {
height: 66,
},
rowConfig: {
keyField: 'id',
height: 66,
},
id: 'workflow-definition-index',
};

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