100 Commits
Author SHA1 Message Date
YunaiV 3e549a6862 v1.7.3 版本发布! 2023-05-29 23:04:11 +08:00
芋道源码 fa6bcacaef !157 合并商城部分功能
Merge pull request !157 from 芋道源码/dev
2023-05-29 15:03:34 +00:00
YunaiV ee12fa01c9 Merge branch 'master' of https://github.com/yudaocode/yudao-ui-admin-vue3
 Conflicts:
	package.json
2023-05-29 22:34:07 +08:00
芋道源码 20426c43e3 !156 新增 路由搜索
Merge pull request !156 from 卡农ding/addRouterSearch
2023-05-29 14:33:03 +00:00
芋道源码 22c022bf90 Merge pull request #8 from HarryQQ/master
fix: 解决vue版本与pinia版本兼容问题
2023-05-29 22:31:56 +08:00
芋道源码 22eeb9c7c8 Merge pull request #9 from cn3lfs/master
fix: 公告内容不能为空
2023-05-29 22:29:05 +08:00
lour6498 257ad9f79f fix: 公告内容不能为空 2023-05-29 18:44:55 +08:00
qiuhuanran 12e6f08f1f fix: 解决vue版本与pinia版本兼容问题 2023-05-29 16:58:56 +08:00
wangding 43f3c67434 add router search 2023-05-29 11:26:51 +08:00
wangding 6768cdea05 新增路由搜索功能 2023-05-29 11:05:46 +08:00
jason 70c5082776 运费模板区域改成多选 2023-05-27 11:32:16 +08:00
YunaiV b12aefe46e review + mall:快递公司管理 2023-05-24 23:40:46 +08:00
YunaiV 349a39e72f review + mall:商品管理 2023-05-24 20:32:38 +08:00
YunaiV ae49aaffcf Merge branch 'dev-to-dev' of https://gitee.com/puhui999/yudao-ui-admin-vue3 into dev
 Conflicts:
	src/views/mall/product/spu/index.vue
2023-05-24 20:31:56 +08:00
YunaiV 0fba3fd503 review + mall:商品管理 2023-05-24 20:27:45 +08:00
puhui999 d877bfd26e fix: 完善善品导出 2023-05-23 12:04:21 +08:00
jason c296c9e53d 运费模板表格样式调整 2023-05-22 23:57:41 +08:00
jason af2fb4bf5f 运费模板性能优化。区域选择使用懒加载 2023-05-22 23:10:22 +08:00
芋道源码 855e4d3d02 !153 提交商品管理第二版
Merge pull request !153 from puhui999/dev-to-dev
2023-05-21 16:01:53 +00:00
jason 4e69f15128 mall: 订单中心->运费模板(第一版 性能有问题) 2023-05-21 23:41:36 +08:00
puhui999 39e329b8a3 Merge remote-tracking branch 'yudao/dev' into dev-to-dev 2023-05-20 12:06:00 +08:00
puhui999 38545e89c6 fix: 优化相关逻辑,修复遗留bug 2023-05-20 12:05:18 +08:00
puhui999 0c42b76a52 fix:解决商品上一版遗留的各种小bug关键部分已添加fix注释。完成的TODO也已添加fix标记 2023-05-19 17:04:13 +08:00
xingyu 9f0065f89f fix: echart color 2023-05-19 15:37:01 +08:00
xingyu 8b3980400a chore: preview 2023-05-19 15:27:07 +08:00
xingyu ba33d8bfd5 docs: update deps 2023-05-19 15:25:09 +08:00
xingyu 45ee4116b7 fix: echart 2023-05-19 15:24:01 +08:00
xingyu 2bd750a3d6 chore: update deps 2023-05-19 15:23:41 +08:00
jason b0984c9013 新增快递公司菜单 2023-05-18 23:40:39 +08:00
puhui999 4ddba9d454 fix: 解决商品上一版遗留的各种小bug关键部分已添加fix注释。完成的TODO也已添加fix标记 2023-05-17 18:24:34 +08:00
puhui999 3c4a39df01 fix: 修复 OtherSettingsForm TODO 优化相关算法,解决上一版本遗留的bug 2023-05-17 01:41:30 +08:00
puhui999 9c06832d7e fix: 修复 SkuList TODO 优化相关算法 2023-05-17 01:39:51 +08:00
puhui999 6ac8c78d57 fix: 添加商品属性去掉备注 2023-05-17 01:37:21 +08:00
puhui999 52bfc4dac0 fix: 进一步完善商品列表,修复TODO 2023-05-17 01:17:17 +08:00
puhui999 50b181318b fix: 整合spu,移动相关类型到spu删除对应文件和文件夹 2023-05-16 10:53:36 +08:00
puhui999 43e541f944 fix: 移动copyValueToTarget方法到utils/index中 2023-05-16 10:12:10 +08:00
芋道源码 47f20e6d4c !152 合并最新的 Vue3 重构
Merge pull request !152 from 芋道源码/dev
2023-05-15 15:00:12 +00:00
YunaiV 16561b1238 Merge branch 'master' of https://gitee.com/yudaocode/yudao-ui-admin-vue3 into dev
 Conflicts:
	build/vite/optimize.ts
2023-05-15 22:58:36 +08:00
YunaiV 3feb4828cd !148 【重构】Vue3 管理后台:[基础管理 -> Redis监控] 使用Echart组件实现 2023-05-15 22:57:31 +08:00
芋道源码 ab7032f1ca !148 【重构】Vue3 管理后台:[基础管理 -> Redis监控] 使用Echart组件实现
Merge pull request !148 from Chika/dev
2023-05-15 14:51:53 +00:00
芋道源码 c47cc18b24 !147 refactor: mp/autoReply 拆分ReplyForm组件
Merge pull request !147 from dhb52/dev
2023-05-15 14:49:49 +00:00
芋道源码 fa618ca44a !151 修复后台”在线用户强退“功能
Merge pull request !151 from cxr/master
2023-05-15 14:47:49 +00:00
cxr01 a788921d51 修复后台”在线用户强退“功能 2023-05-14 13:46:09 +08:00
Chika fbac522149 Redis监控用Echart组件整改 2023-05-12 16:10:14 +08:00
Chika 8456c671e8 Merge branch 'dev' of https://gitee.com/wangbosheng/yudao-ui-admin-vue3 into dev 2023-05-12 15:50:45 +08:00
Chika bf629a9554 Redis监控图标使用Echart组件整合 2023-05-12 15:30:01 +08:00
YunaiV e92361ed40 code review 商品管理的实现 2023-05-06 22:54:41 +08:00
dhb52 be49c381f6 refactor: mp/autoReply 拆分ReplyForm组件 2023-05-05 23:04:47 +08:00
芋道源码 4a965b8c14 !141 商品管理: 打通所有接口(第一版)
Merge pull request !141 from puhui999/dev-to-dev
2023-05-05 11:03:28 +00:00
芋道源码 f1cf125f0d !146 refactor: mp/wx-msg拆分Msg组件
Merge pull request !146 from dhb52/dev
2023-05-05 11:02:19 +00:00
芋道源码 63cb62e697 !139 fix(styles/index): Update .el-scrollbar__bar style
Merge pull request !139 from AhJindeg/fix_tab-scrollbar-style
2023-05-05 11:00:53 +00:00
芋道源码 7a64bbdea0 !145 修复【站内信】测试模板消息发送,接口调用错误问题
Merge pull request !145 from ZanGe丶/master
2023-05-05 10:54:50 +00:00
dhb52 2b84489969 refactor: mp/wx-msg拆分Msg组件 2023-05-04 22:45:12 +08:00
YunaiV 1c77ba8e04 code review 微信的实现 2023-05-04 21:00:49 +08:00
puhui999 0c6e3a39c9 商品管理: 打通所有接口(第一版) 2023-05-04 01:38:53 +08:00
puhui999 9ee35fc165 商品管理: 调整相关组件优化逻辑,完成表单保存和数据回显 2023-05-03 02:28:35 +08:00
puhui999 1116fb278b 商品管理: 调整相关组件优化逻辑 2023-05-02 02:20:58 +08:00
puhui999 6478d295df 商品管理: 调整相关组件优化逻辑 2023-05-01 19:01:24 +08:00
puhui999 64f6f67ddd 商品管理: 完成商品属性批量添加 2023-04-30 17:28:16 +08:00
puhui999 ab1120b0ff 商品管理: 完成商品属性排列组合算法生成对应表数据 2023-04-30 17:06:30 +08:00
puhui999 35c3545e7c 商品管理: 保存属性值到数据库 2023-04-30 17:04:31 +08:00
puhui999 538d1e0b6c 商品管理: fix:根据商品属性动态生成表格值 2023-04-30 02:26:35 +08:00
puhui999 7a64eb5198 商品管理: 修正添加属性和添加属性值时输入框显隐控制、输入框焦点获取错乱等bug 2023-04-29 23:10:18 +08:00
puhui999 61218ae711 商品管理: 初步完成相关组件 2023-04-29 21:39:25 +08:00
咱哥丶 d3b5535df9 修复【站内信】测试模板消息发送,接口调用错误问题 2023-04-29 16:44:58 +08:00
puhui999 ab1685a741 商品管理: 完善选择商品分类 2023-04-26 17:53:17 +08:00
puhui999 5c52c17227 Merge remote-tracking branch 'yudao/dev' into dev-to-dev 2023-04-26 17:21:46 +08:00
puhui999 c38abc365c 商品管理: 完善表单校验,优化信息提示,完成新建、编辑、提交逻辑 2023-04-26 17:17:39 +08:00
芋道源码 0ceddc9c91 !137 MP模块重构,功能增强
Merge pull request !137 from dhb52/dev
2023-04-25 13:38:22 +00:00
YunaiV 9680a204ca 调整商品分类的实现 2023-04-25 21:02:35 +08:00
AhJindeg ace8f9e302 🐞 fix(styles/index): Update .el-scrollbar__bar style
- 解决表格内容超过表格总宽度后,横向滚动条前端顶不到表格边缘的问题
2023-04-24 11:50:30 +08:00
puhui999 a0014bed65 商品管理初始界面结构设计 2023-04-24 11:42:44 +08:00
YunaiV 2ab8ea45da 增加 vben 预览 2023-04-24 09:13:31 +08:00
xingyu 53b6f0bdf4 docs: update deps 2023-04-23 19:36:34 +08:00
xingyu fc366f18c8 fix: type warn 2023-04-23 19:35:14 +08:00
xingyu 6b7d092c29 fix: api error 2023-04-23 19:35:06 +08:00
xingyu 148602556a fix: type error 2023-04-23 19:00:25 +08:00
xingyu 4b6103a6f7 chore: optimize 2023-04-23 18:59:09 +08:00
xingyu 462b5b55d8 chore: remove stylelint-config-prettier 2023-04-23 18:48:11 +08:00
xingyu e31f922b82 chore: update deps 2023-04-23 18:46:40 +08:00
AhJindeg 9dc361e708 🌈 style(Form/src): Modifying word spelling errors
- 修改ts类型 PlaceholderModel单词拼写
- 修改initModel方法 FormModel注释拼写
2023-04-23 11:44:03 +08:00
dhb52 88f4502715 Merge remote-tracking branch 'yudao/dev' into dev 2023-04-22 23:46:32 +08:00
dhb52 cb4527748e refactor: mp/wx-msg 采用ref来实现滚动 2023-04-22 23:45:14 +08:00
dhb52 198752868c refactor: mp模块统一accountId未初始化值为-1,删除QueryParams定义 2023-04-22 23:43:35 +08:00
芋道源码 118f32ad83 !136 同步 2023-04-22 最新迭代的内容
Merge pull request !136 from 芋道源码/dev
2023-04-22 13:22:52 +00:00
YunaiV c14b5c5bff 登录界面,增加“萌新必读” 2023-04-22 21:21:45 +08:00
dhb52 036c9b3366 fix: mp模块的一些小修复 2023-04-22 20:52:11 +08:00
芋道源码 4cf734d244 !135 MP模块重构,功能增强
Merge pull request !135 from dhb52/dev
2023-04-22 12:51:56 +00:00
dhb52 f848f3ba91 refactor: mp/wx-msg 拆分组件 2023-04-22 20:51:25 +08:00
dhb52 917b9d180a fix: mp/menu菜单拖动后激活菜单 2023-04-22 20:45:03 +08:00
YunaiV f27b93db59 菜单管理:添加刷新菜单缓存按钮 2023-04-22 20:44:32 +08:00
YunaiV 9f35ea9e82 Merge branch 'master' of https://gitee.com/yudaocode/yudao-ui-admin-vue3 into dev 2023-04-22 19:48:58 +08:00
芋道源码 36d0916f56 !134 菜单管理:添加刷新菜单缓存按钮
Merge pull request !134 from puhui999/dev-to-dev
2023-04-22 11:48:35 +00:00
dhb52 ebacbbb9cf perf: mp/menu使用vuedraggable替换拖动的原生实现 2023-04-22 09:04:21 +08:00
dhb52 08eb72e568 style: mp/WxReply objCache => tabCache 2023-04-22 01:23:52 +08:00
dhb52 d99f9e98fb feat: mp/menu 拖动排序 2023-04-21 23:35:31 +08:00
dhb52 4925a66cc5 style: mp模块stylelint 2023-04-21 23:19:04 +08:00
dhb52 b45b85984c refactor: mp模块,一个大大的重构+fix 2023-04-21 20:22:11 +08:00
puhui999 ab52f8b18f 菜单管理:添加刷新菜单缓存按钮,提高易用性 2023-04-20 14:17:21 +08:00
dhb52 6662b04d12 Merge remote-tracking branch 'yudao/dev' into dev 2023-04-19 23:12:11 +08:00
117 changed files with 4691 additions and 1351 deletions

No files matched your search

+1 -1
View File
@@ -8,7 +8,7 @@
"source.fixAll.eslint": true
},
"[vue]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
"editor.defaultFormatter": "Vue.volar"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
+10 -9
View File
@@ -11,6 +11,7 @@
* nodejs > 16.0.0 && pnpm > 7.30.0
* 演示地址【Vue3 + element-plus】:<http://dashboard-vue3.yudao.iocoder.cn>
* 演示地址【Vue3 + vben(ant-design-vue)】:<http://dashboard-vben.yudao.iocoder.cn>
* 演示地址【Vue2 + element-ui】:<http://dashboard.yudao.iocoder.cn>
* 启动文档:<https://doc.iocoder.cn/quick-start/>
* 视频教程:<https://doc.iocoder.cn/video/>
@@ -19,8 +20,8 @@
**芋道**,以开发者为中心,打造中国第一流的快速开发平台,全部开源,个人与企业可 100% 免费使用。
* 采用 [vue-element-plus-admin](https://gitee.com/kailong110120130/vue-element-plus-admin)
* 改换saas,自动引入等功能 [vue-element-plus-admin](https://gitee.com/yudaocode/vue-element-plus-admin)
* 采用 [vue-element-plus-admin](https://gitee.com/kailong110120130/vue-element-plus-admin) 实现
* 改换 saas,自动引入等功能
* 使用 Element Plus 免费开源的中后台模版,具备如下特性:
![首页](preview/home.png)
@@ -37,14 +38,14 @@
| 框架 | 说明 | 版本 |
|----------------------------------------------------------------------|------------------|--------|
| [Vue](https://staging-cn.vuejs.org/) | Vue 框架 | 3.2.47 |
| [Vite](https://cn.vitejs.dev//) | 开发与构建工具 | 4.1.4 |
| [Element Plus](https://element-plus.org/zh-CN/) | Element Plus | 2.2.34 |
| [TypeScript](https://www.typescriptlang.org/docs/) | JavaScript 的超集 | 4.9.5 |
| [pinia](https://pinia.vuejs.org/) | Vue 存储库 替代 vuex5 | 2.0.33 |
| [vueuse](https://vueuse.org/) | 常用工具集 | 9.13.0 |
| [Vue](https://staging-cn.vuejs.org/) | Vue 框架 | 3.3.4 |
| [Vite](https://cn.vitejs.dev//) | 开发与构建工具 | 4.3.8 |
| [Element Plus](https://element-plus.org/zh-CN/) | Element Plus | 2.3.4 |
| [TypeScript](https://www.typescriptlang.org/docs/) | JavaScript 的超集 | 5.0.4 |
| [pinia](https://pinia.vuejs.org/) | Vue 存储库 替代 vuex5 | 2.1.3 |
| [vueuse](https://vueuse.org/) | 常用工具集 | 10.1.2 |
| [vue-i18n](https://kazupon.github.io/vue-i18n/zh/introduction.html/) | 国际化 | 9.2.2 |
| [vue-router](https://router.vuejs.org/) | Vue 路由 | 4.1.6 |
| [vue-router](https://router.vuejs.org/) | Vue 路由 | 4.2.1 |
| [windicss](https://cn.windicss.org/) | 下一代工具优先的 CSS 框架 | 3.5.6 |
| [iconify](https://icon-sets.iconify.design/) | 在线图标库 | 3.1.0 |
| [wangeditor](https://www.wangeditor.com/) | 富文本编辑器 | 5.1.23 |
+60 -33
View File
@@ -17,7 +17,6 @@ const include = [
'cropperjs',
'lodash-es',
'nprogress',
'animate.css',
'web-storage-cache',
'@iconify/iconify',
'@vueuse/core',
@@ -33,37 +32,58 @@ const include = [
'element-plus/es',
'element-plus/es/locale/lang/zh-cn',
'element-plus/es/locale/lang/en',
'element-plus/es/components/backtop/style/index',
'element-plus/es/components/form/style/index',
'element-plus/es/components/radio-group/style/index',
'element-plus/es/components/radio/style/index',
'element-plus/es/components/checkbox/style/index',
'element-plus/es/components/checkbox-group/style/index',
'element-plus/es/components/switch/style/index',
'element-plus/es/components/time-picker/style/index',
'element-plus/es/components/date-picker/style/index',
'element-plus/es/components/col/style/index',
'element-plus/es/components/form-item/style/index',
'element-plus/es/components/alert/style/index',
'element-plus/es/components/breadcrumb/style/index',
'element-plus/es/components/select/style/index',
'element-plus/es/components/input/style/index',
'element-plus/es/components/breadcrumb-item/style/index',
'element-plus/es/components/tag/style/index',
'element-plus/es/components/pagination/style/index',
'element-plus/es/components/table/style/index',
'element-plus/es/components/table-column/style/index',
'element-plus/es/components/card/style/index',
'element-plus/es/components/row/style/index',
'element-plus/es/components/button/style/index',
'element-plus/es/components/menu/style/index',
'element-plus/es/components/sub-menu/style/index',
'element-plus/es/components/menu-item/style/index',
'element-plus/es/components/option/style/index',
'element-plus/es/components/dropdown/style/index',
'element-plus/es/components/dropdown-menu/style/index',
'element-plus/es/components/dropdown-item/style/index',
'element-plus/es/components/skeleton/style/index',
'element-plus/es/components/backtop/style/css',
'element-plus/es/components/form/style/css',
'element-plus/es/components/radio-group/style/css',
'element-plus/es/components/radio/style/css',
'element-plus/es/components/checkbox/style/css',
'element-plus/es/components/checkbox-group/style/css',
'element-plus/es/components/switch/style/css',
'element-plus/es/components/time-picker/style/css',
'element-plus/es/components/date-picker/style/css',
'element-plus/es/components/descriptions/style/css',
'element-plus/es/components/descriptions-item/style/css',
'element-plus/es/components/link/style/css',
'element-plus/es/components/tooltip/style/css',
'element-plus/es/components/drawer/style/css',
'element-plus/es/components/dialog/style/css',
'element-plus/es/components/checkbox-button/style/css',
'element-plus/es/components/option-group/style/css',
'element-plus/es/components/radio-button/style/css',
'element-plus/es/components/cascader/style/css',
'element-plus/es/components/color-picker/style/css',
'element-plus/es/components/input-number/style/css',
'element-plus/es/components/rate/style/css',
'element-plus/es/components/select-v2/style/css',
'element-plus/es/components/tree-select/style/css',
'element-plus/es/components/slider/style/css',
'element-plus/es/components/time-select/style/css',
'element-plus/es/components/autocomplete/style/css',
'element-plus/es/components/image-viewer/style/css',
'element-plus/es/components/upload/style/css',
'element-plus/es/components/col/style/css',
'element-plus/es/components/form-item/style/css',
'element-plus/es/components/alert/style/css',
'element-plus/es/components/breadcrumb/style/css',
'element-plus/es/components/select/style/css',
'element-plus/es/components/input/style/css',
'element-plus/es/components/breadcrumb-item/style/css',
'element-plus/es/components/tag/style/css',
'element-plus/es/components/pagination/style/css',
'element-plus/es/components/table/style/css',
'element-plus/es/components/table-v2/style/css',
'element-plus/es/components/table-column/style/css',
'element-plus/es/components/card/style/css',
'element-plus/es/components/row/style/css',
'element-plus/es/components/button/style/css',
'element-plus/es/components/menu/style/css',
'element-plus/es/components/sub-menu/style/css',
'element-plus/es/components/menu-item/style/css',
'element-plus/es/components/option/style/css',
'element-plus/es/components/dropdown/style/css',
'element-plus/es/components/dropdown-menu/style/css',
'element-plus/es/components/dropdown-item/style/css',
'element-plus/es/components/skeleton/style/css',
'element-plus/es/components/skeleton/style/css',
'element-plus/es/components/backtop/style/css',
'element-plus/es/components/menu/style/css',
@@ -76,7 +96,14 @@ const include = [
'element-plus/es/components/badge/style/css',
'element-plus/es/components/breadcrumb/style/css',
'element-plus/es/components/breadcrumb-item/style/css',
'element-plus/es/components/image/style/css'
'element-plus/es/components/image/style/css',
'element-plus/es/components/collapse-transition/style/css',
'element-plus/es/components/timeline/style/css',
'element-plus/es/components/timeline-item/style/css',
'element-plus/es/components/collapse/style/css',
'element-plus/es/components/collapse-item/style/css',
'element-plus/es/components/button-group/style/css',
'element-plus/es/components/text/style/css'
]
const exclude = ['@iconify/json']
+46 -46
View File
@@ -1,6 +1,6 @@
{
"name": "yudao-ui-admin-vue3",
"version": "1.7.2-snapshot",
"version": "1.7.3-snapshot",
"description": "基于vue3、vite4、element-plus、typesScript",
"author": "xingyu",
"private": false,
@@ -18,6 +18,7 @@
"serve:pro": "vite preview --mode pro",
"serve:dev": "vite preview --mode dev",
"serve:test": "vite preview --mode test",
"preview": "pnpm build && vite preview",
"npm:check": "npx npm-check-updates",
"clean": "npx rimraf node_modules",
"clean:cache": "npx rimraf node_modules/.cache",
@@ -33,12 +34,12 @@
"@form-create/element-ui": "^3.1.17",
"@iconify/iconify": "^3.1.0",
"@videojs-player/vue": "^1.0.0",
"@vueuse/core": "^9.13.0",
"@vueuse/core": "^10.1.2",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.10",
"@zxcvbn-ts/core": "^2.2.1",
"@zxcvbn-ts/core": "^3.0.1",
"animate.css": "^4.1.1",
"axios": "^1.3.5",
"axios": "^1.4.0",
"benz-amr-recorder": "^1.1.5",
"bpmn-js-token-simulation": "^0.10.0",
"camunda-bpmn-moddle": "^7.0.1",
@@ -46,88 +47,87 @@
"crypto-js": "^4.1.1",
"dayjs": "^1.11.7",
"diagram-js": "^11.6.0",
"echarts": "^5.4.1",
"echarts": "^5.4.2",
"echarts-wordcloud": "^2.1.0",
"element-plus": "2.3.3",
"fast-xml-parser": "^4.1.3",
"highlight.js": "^11.7.0",
"element-plus": "2.3.4",
"fast-xml-parser": "^4.2.2",
"highlight.js": "^11.8.0",
"intro.js": "^7.0.1",
"jsencrypt": "^3.3.2",
"lodash-es": "^4.17.21",
"min-dash": "^4.0.0",
"min-dash": "^4.1.1",
"mitt": "^3.0.0",
"nprogress": "^0.2.0",
"pinia": "^2.0.34",
"qrcode": "^1.5.1",
"qs": "^6.11.1",
"pinia": "^2.1.3",
"qrcode": "^1.5.3",
"qs": "^6.11.2",
"steady-xml": "^0.1.0",
"url": "^0.11.0",
"video.js": "^8.0.4",
"vue": "3.2.47",
"video.js": "^8.3.0",
"vue": "3.3.4",
"vue-i18n": "9.2.2",
"vue-router": "^4.1.6",
"vue-types": "^5.0.2",
"vue-router": "^4.2.1",
"vue-types": "^5.0.3",
"vuedraggable": "^4.1.0",
"web-storage-cache": "^1.1.1",
"xe-utils": "^3.5.7",
"xml-js": "^1.6.11"
},
"devDependencies": {
"@commitlint/cli": "^17.5.0",
"@commitlint/config-conventional": "^17.4.4",
"@iconify/json": "^2.2.38",
"@commitlint/cli": "^17.6.3",
"@commitlint/config-conventional": "^17.6.3",
"@iconify/json": "^2.2.67",
"@intlify/unplugin-vue-i18n": "^0.10.0",
"@purge-icons/generated": "^0.9.0",
"@types/intro.js": "^5.1.1",
"@types/lodash-es": "^4.17.7",
"@types/node": "^18.15.5",
"@types/node": "^18.16.0",
"@types/nprogress": "^0.2.0",
"@types/qrcode": "^1.5.0",
"@types/qs": "^6.9.7",
"@typescript-eslint/eslint-plugin": "^5.56.0",
"@typescript-eslint/parser": "^5.56.0",
"@vitejs/plugin-legacy": "^4.0.2",
"@vitejs/plugin-vue": "^4.1.0",
"@typescript-eslint/eslint-plugin": "^5.59.6",
"@typescript-eslint/parser": "^5.59.6",
"@vitejs/plugin-legacy": "^4.0.3",
"@vitejs/plugin-vue": "^4.2.3",
"@vitejs/plugin-vue-jsx": "^3.0.1",
"autoprefixer": "^10.4.14",
"bpmn-js": "^8.9.0",
"bpmn-js-properties-panel": "^0.46.0",
"consola": "^2.15.3",
"eslint": "^8.36.0",
"consola": "^3.1.0",
"eslint": "^8.40.0",
"eslint-config-prettier": "^8.8.0",
"eslint-define-config": "^1.17.0",
"eslint-define-config": "^1.20.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-vue": "^9.9.0",
"lint-staged": "^13.2.0",
"postcss": "^8.4.21",
"eslint-plugin-vue": "^9.13.0",
"lint-staged": "^13.2.2",
"postcss": "^8.4.23",
"postcss-html": "^1.5.0",
"postcss-scss": "^4.0.6",
"prettier": "^2.8.6",
"rimraf": "^4.4.1",
"rollup": "^3.20.0",
"sass": "^1.59.3",
"stylelint": "^15.3.0",
"prettier": "^2.8.8",
"rimraf": "^5.0.1",
"rollup": "^3.22.0",
"sass": "^1.62.1",
"stylelint": "^15.6.2",
"stylelint-config-html": "^1.1.0",
"stylelint-config-prettier": "^9.0.5",
"stylelint-config-recommended": "^11.0.0",
"stylelint-config-standard": "^31.0.0",
"stylelint-config-recommended": "^12.0.0",
"stylelint-config-standard": "^33.0.0",
"stylelint-order": "^6.0.3",
"terser": "^5.16.6",
"typescript": "5.0.2",
"unplugin-auto-import": "^0.15.1",
"unplugin-element-plus": "^0.7.0",
"terser": "^5.17.4",
"typescript": "5.0.4",
"unplugin-auto-import": "^0.16.0",
"unplugin-element-plus": "^0.7.1",
"unplugin-vue-components": "^0.24.1",
"vite": "4.2.1",
"vite": "4.3.8",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-ejs": "^1.6.4",
"vite-plugin-eslint": "^1.8.1",
"vite-plugin-progress": "^0.0.6",
"vite-plugin-progress": "^0.0.7",
"vite-plugin-purge-icons": "^0.9.2",
"vite-plugin-svg-icons": "^2.0.1",
"vite-plugin-top-level-await": "^1.3.0",
"vite-plugin-vue-setup-extend-plus": "^0.1.0",
"vite-plugin-windicss": "^1.8.10",
"vue-tsc": "^1.2.0",
"vite-plugin-windicss": "^1.9.0",
"vue-tsc": "^1.6.5",
"windicss": "^3.5.6"
},
"engines": {
+3
View File
@@ -3,6 +3,7 @@ import { isDark } from '@/utils/is'
import { useAppStore } from '@/store/modules/app'
import { useDesign } from '@/hooks/web/useDesign'
import { CACHE_KEY, useCache } from '@/hooks/web/useCache'
import routerSearch from '@/components/RouterSearch/index.vue'
const { getPrefixCls } = useDesign()
const prefixCls = getPrefixCls('app')
@@ -24,10 +25,12 @@ setDefaultTheme()
<template>
<ConfigGlobal :size="currentSize">
<RouterView :class="greyMode ? `${prefixCls}-grey-mode` : ''" />
<routerSearch />
</ConfigGlobal>
</template>
<style lang="scss">
$prefix-cls: #{$namespace}-app;
.size {
width: 100%;
height: 100%;
+5
View File
@@ -54,3 +54,8 @@ export const getBrand = (id: number) => {
export const getBrandParam = (params: PageParam) => {
return request.get({ url: '/product/brand/page', params })
}
// 获得商品品牌精简信息列表
export const getSimpleBrandList = () => {
return request.get({ url: '/product/brand/list-all-simple' })
}
+6 -6
View File
@@ -17,17 +17,17 @@ export interface CategoryVO {
*/
name: string
/**
* 分类图片
* 移动端分类图
*/
picUrl: string
/**
* PC 端分类图
*/
bigPicUrl?: string
/**
* 分类排序
*/
sort?: number
/**
* 分类描述
*/
description?: string
sort: number
/**
* 开启状态
*/
+2 -2
View File
@@ -71,8 +71,8 @@ export const getPropertyList = (params: any) => {
}
// 获得属性项列表
export const getPropertyListAndValue = (params: any) => {
return request.get({ url: '/product/property/get-value-list', params })
export const getPropertyListAndValue = (data: any) => {
return request.post({ url: '/product/property/get-value-list', data })
}
// ------------------------ 属性值 -------------------
+92
View File
@@ -0,0 +1,92 @@
import request from '@/config/axios'
export interface Property {
propertyId?: number // 属性编号
propertyName?: string // 属性名称
valueId?: number // 属性值编号
valueName?: string // 属性值名称
}
// TODO puhui999:是不是直接叫 Sku 更简洁一点哈。type 待后面,总感觉有个类型?
export interface SkuType {
id?: number // 商品 SKU 编号
spuId?: number // SPU 编号
properties?: Property[] // 属性数组
price?: number // 商品价格
marketPrice?: number // 市场价
costPrice?: number // 成本价
barCode?: string // 商品条码
picUrl?: string // 图片地址
stock?: number // 库存
weight?: number // 商品重量,单位:kg 千克
volume?: number // 商品体积,单位:m^3 平米
subCommissionFirstPrice?: number // 一级分销的佣金
subCommissionSecondPrice?: number // 二级分销的佣金
salesCount?: number // 商品销量
}
// TODO puhui999:是不是直接叫 Spu 更简洁一点哈。type 待后面,总感觉有个类型?
export interface SpuType {
id?: number
name?: string // 商品名称
categoryId?: number | null // 商品分类
keyword?: string // 关键字
unit?: number | null // 单位
picUrl?: string // 商品封面图
sliderPicUrls?: string[] // 商品轮播图
introduction?: string // 商品简介
deliveryTemplateId?: number | null // 运费模版
brandId?: number | null // 商品品牌编号
specType?: boolean // 商品规格
subCommissionType?: boolean // 分销类型
skus: SkuType[] // sku数组
description?: string // 商品详情
sort?: string // 商品排序
giveIntegral?: number // 赠送积分
virtualSalesCount?: number // 虚拟销量
recommendHot?: boolean // 是否热卖
recommendBenefit?: boolean // 是否优惠
recommendBest?: boolean // 是否精品
recommendNew?: boolean // 是否新品
recommendGood?: boolean // 是否优品
}
// 获得 Spu 列表
export const getSpuPage = (params: PageParam) => {
return request.get({ url: '/product/spu/page', params })
}
// 获得 Spu 列表 tabsCount
export const getTabsCount = () => {
return request.get({ url: '/product/spu/get-count' })
}
// 创建商品 Spu
export const createSpu = (data: SpuType) => {
return request.post({ url: '/product/spu/create', data })
}
// 更新商品 Spu
export const updateSpu = (data: SpuType) => {
return request.put({ url: '/product/spu/update', data })
}
// 更新商品 Spu status
export const updateStatus = (data: { id: number; status: number }) => {
return request.put({ url: '/product/spu/update-status', data })
}
// 获得商品 Spu
export const getSpu = (id: number) => {
return request.get({ url: `/product/spu/get-detail?id=${id}` })
}
// 删除商品 Spu
export const deleteSpu = (id: number) => {
return request.delete({ url: `/product/spu/delete?id=${id}` })
}
// 导出商品 Spu Excel
export const exportSpu = async (params) => {
return await request.download({ url: '/product/spu/export', params })
}
@@ -0,0 +1,40 @@
import request from '@/config/axios'
export interface DeliveryExpressVO {
id: number
code: string
name: string
logo: string
sort: number
status: number
}
// 查询快递公司列表
export const getDeliveryExpressPage = async (params: PageParam) => {
return await request.get({ url: '/trade/delivery/express/page', params })
}
// 查询快递公司详情
export const getDeliveryExpress = async (id: number) => {
return await request.get({ url: '/trade/delivery/express/get?id=' + id })
}
// 新增快递公司
export const createDeliveryExpress = async (data: DeliveryExpressVO) => {
return await request.post({ url: '/trade/delivery/express/create', data })
}
// 修改快递公司
export const updateDeliveryExpress = async (data: DeliveryExpressVO) => {
return await request.put({ url: '/trade/delivery/express/update', data })
}
// 删除快递公司
export const deleteDeliveryExpress = async (id: number) => {
return await request.delete({ url: '/trade/delivery/express/delete?id=' + id })
}
// 导出快递公司 Excel
export const exportDeliveryExpressApi = async (params) => {
return await request.download({ url: '/trade/delivery/express/export-excel', params })
}
@@ -0,0 +1,54 @@
import request from '@/config/axios'
export interface DeliveryExpressTemplateVO {
id: number
name: string
chargeMode: number
sort: number
templateCharge: ExpressTemplateChargeVO[]
templateFree: ExpressTemplateFreeVO[]
}
export declare type ExpressTemplateChargeVO = {
areaIds: number[]
startCount: number
startPrice: number
extraCount: number
extraPrice: number
}
export declare type ExpressTemplateFreeVO = {
areaIds: number[]
freeCount: number
freePrice: number
}
// 查询快递运费模板列表
export const getDeliveryExpressTemplatePage = async (params: PageParam) => {
return await request.get({ url: '/trade/delivery/express-template/page', params })
}
// 查询快递运费模板详情
export const getDeliveryExpressTemplate = async (id: number) => {
return await request.get({ url: '/trade/delivery/express-template/get?id=' + id })
}
// 新增快递运费模板
export const createDeliveryExpressTemplate = async (data: DeliveryExpressTemplateVO) => {
return await request.post({ url: '/trade/delivery/express-template/create', data })
}
// 修改快递运费模板
export const updateDeliveryExpressTemplate = async (data: DeliveryExpressTemplateVO) => {
return await request.put({ url: '/trade/delivery/express-template/update', data })
}
// 删除快递运费模板
export const deleteDeliveryExpressTemplate = async (id: number) => {
return await request.delete({ url: '/trade/delivery/express-template/delete?id=' + id })
}
// 导出快递运费模板 Excel
export const exportDeliveryExpressTemplateApi = async (params) => {
return await request.download({ url: '/trade/delivery/express-template/export-excel', params })
}
+1 -1
View File
@@ -1,7 +1,7 @@
import request from '@/config/axios'
export interface AccountVO {
id?: number
id: number
name: string
}
+8
View File
@@ -5,6 +5,14 @@ export const getAreaTree = async () => {
return await request.get({ url: '/system/area/tree' })
}
export const getChildrenArea = async (id: number) => {
return await request.get({ url: '/system/area/get-children?id=' + id })
}
export const getAreaListByIds = async (ids) => {
return await request.get({ url: '/system/area/get-by-ids?ids=' + ids })
}
// 获得 IP 对应的地区名
export const getAreaByIp = async (ip: string) => {
return await request.get({ url: '/system/area/get-by-ip?ip=' + ip })
+1 -1
View File
@@ -39,7 +39,7 @@ export const updateNotifyTemplate = async (data: NotifyTemplateVO) => {
}
// 删除站内信模板
export const deleteNotifyTemplateApi = async (id: number) => {
export const deleteNotifyTemplate = async (id: number) => {
return await request.delete({ url: '/system/notify-template/delete?id=' + id })
}
+1 -1
View File
@@ -17,6 +17,6 @@ export const getAccessTokenPage = (params: PageParam) => {
}
// 删除 token
export const deleteAccessToken = (accessToken: number) => {
export const deleteAccessToken = (accessToken: string) => {
return request.delete({ url: '/system/oauth2-token/delete?accessToken=' + accessToken })
}
+4 -4
View File
@@ -1,6 +1,6 @@
import type { Slots } from 'vue'
import { getSlot } from '@/utils/tsxHelper'
import { PlaceholderMoel } from './types'
import { PlaceholderModel } from './types'
import { FormSchema } from '@/types/form'
import { ColProps } from '@/types/components'
@@ -10,7 +10,7 @@ import { ColProps } from '@/types/components'
* @returns 返回提示信息对象
* @description 用于自动设置placeholder
*/
export const setTextPlaceholder = (schema: FormSchema): PlaceholderMoel => {
export const setTextPlaceholder = (schema: FormSchema): PlaceholderModel => {
const { t } = useI18n()
const textMap = ['Input', 'Autocomplete', 'InputNumber', 'InputPassword']
const selectMap = ['Select', 'SelectV2', 'TimePicker', 'DatePicker', 'TimeSelect', 'TimeSelect']
@@ -108,8 +108,8 @@ export const setItemComponentSlots = (
/**
*
* @param schema Form表单结构化数组
* @param formModel FormMoel
* @returns FormMoel
* @param formModel FormModel
* @returns FormModel
* @description 生成对应的formModel
*/
export const initModel = (schema: FormSchema[], formModel: Recordable) => {
+1 -1
View File
@@ -1,6 +1,6 @@
import { FormSchema } from '@/types/form'
export interface PlaceholderMoel {
export interface PlaceholderModel {
placeholder?: string
startPlaceholder?: string
endPlaceholder?: string
+76
View File
@@ -0,0 +1,76 @@
<template>
<ElDialog v-model="showSearch" :show-close="false" title="菜单搜索">
<el-select
filterable
:reserve-keyword="false"
remote
placeholder="请输入菜单内容"
:remote-method="remoteMethod"
style="width: 100%"
@change="handleChange"
>
<el-option
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</ElDialog>
</template>
<script setup lang="ts">
const router = useRouter() // 路由对象
const showSearch = ref(false) // 是否显示弹框
const value: Ref = ref('') // 用户输入的值
const routers = router.getRoutes() // 路由对象
const options = computed(() => {
// 提示选项
if (!value.value) {
return []
}
const list = routers.filter((item: any) => {
if (item.meta.title?.indexOf(value.value) > -1 || item.path.indexOf(value.value) > -1) {
return true
}
})
return list.map((item) => {
return {
label: `${item.meta.title}${item.path}`,
value: item.path
}
})
})
function remoteMethod(data) {
// 这里可以执行相应的操作(例如打开搜索框等)
value.value = data
}
function handleChange(path) {
router.push({ path })
}
onMounted(() => {
window.addEventListener('keydown', listenKey)
})
onUnmounted(() => {
window.removeEventListener('keydown', listenKey)
})
// 监听 ctrl + k
function listenKey(event) {
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
showSearch.value = !showSearch.value
// 这里可以执行相应的操作(例如打开搜索框等)
}
}
defineExpose({
openSearch: () => {
showSearch.value = true
}
})
</script>
+45 -15
View File
@@ -1,19 +1,19 @@
<template>
<div class="upload-box">
<el-upload
:action="updateUrl"
list-type="picture-card"
:class="['upload', drag ? 'no-border' : '']"
v-model:file-list="fileList"
:multiple="true"
:limit="limit"
:headers="uploadHeaders"
:accept="fileType.join(',')"
:action="updateUrl"
:before-upload="beforeUpload"
:class="['upload', drag ? 'no-border' : '']"
:drag="drag"
:headers="uploadHeaders"
:limit="limit"
:multiple="true"
:on-error="uploadError"
:on-exceed="handleExceed"
:on-success="uploadSuccess"
:on-error="uploadError"
:drag="drag"
:accept="fileType.join(',')"
list-type="picture-card"
>
<div class="upload-empty">
<slot name="empty">
@@ -40,15 +40,15 @@
</div>
<el-image-viewer
v-if="imgViewVisible"
@close="imgViewVisible = false"
:url-list="[viewImageUrl]"
@close="imgViewVisible = false"
/>
</div>
</template>
<script setup lang="ts" name="UploadImgs">
<script lang="ts" name="UploadImgs" setup>
import { PropType } from 'vue'
import type { UploadFile, UploadProps, UploadUserFile } from 'element-plus'
import { ElNotification } from 'element-plus'
import type { UploadProps, UploadFile, UploadUserFile } from 'element-plus'
import { propTypes } from '@/utils/propTypes'
import { getAccessToken, getTenantId } from '@/utils/auth'
@@ -88,8 +88,19 @@ const uploadHeaders = ref({
'tenant-id': getTenantId()
})
const fileList = ref<UploadUserFile[]>(props.modelValue)
const fileList = ref<UploadUserFile[]>()
// fix: 改为动态监听赋值解决图片回显问题
watch(
() => props.modelValue,
(data) => {
if (!data) return
fileList.value = data
},
{
deep: true,
immediate: true
}
)
/**
* @description 文件上传之前判断
* @param rawFile 上传的文件
@@ -116,9 +127,11 @@ const beforeUpload: UploadProps['beforeUpload'] = (rawFile) => {
interface UploadEmits {
(e: 'update:modelValue', value: UploadUserFile[]): void
}
const emit = defineEmits<UploadEmits>()
const uploadSuccess = (response, uploadFile: UploadFile) => {
if (!response) return
// TODO 多图上传组件成功后只是把保存成功后的url替换掉组件选图时的文件路径,所以返回的fileList包含的是一个包含文件信息的对象列表
uploadFile.url = response.data
emit('update:modelValue', fileList.value)
message.success('上传成功')
@@ -159,35 +172,40 @@ const handlePictureCardPreview: UploadProps['onPreview'] = (uploadFile) => {
}
</script>
<style scoped lang="scss">
<style lang="scss" scoped>
.is-error {
.upload {
:deep(.el-upload--picture-card),
:deep(.el-upload-dragger) {
border: 1px dashed var(--el-color-danger) !important;
&:hover {
border-color: var(--el-color-primary) !important;
}
}
}
}
:deep(.disabled) {
.el-upload--picture-card,
.el-upload-dragger {
cursor: not-allowed;
background: var(--el-disabled-bg-color) !important;
border: 1px dashed var(--el-border-color-darker);
&:hover {
border-color: var(--el-border-color-darker) !important;
}
}
}
.upload-box {
.no-border {
:deep(.el-upload--picture-card) {
border: none !important;
}
}
:deep(.upload) {
.el-upload-dragger {
display: flex;
@@ -199,14 +217,17 @@ const handlePictureCardPreview: UploadProps['onPreview'] = (uploadFile) => {
overflow: hidden;
border: 1px dashed var(--el-border-color-darker);
border-radius: v-bind(borderRadius);
&:hover {
border: 1px dashed var(--el-color-primary);
}
}
.el-upload-dragger.is-dragover {
background-color: var(--el-color-primary-light-9);
border: 2px dashed var(--el-color-primary) !important;
}
.el-upload-list__item,
.el-upload--picture-card {
width: v-bind(width);
@@ -214,11 +235,13 @@ const handlePictureCardPreview: UploadProps['onPreview'] = (uploadFile) => {
background-color: transparent;
border-radius: v-bind(borderRadius);
}
.upload-image {
width: 100%;
height: 100%;
object-fit: contain;
}
.upload-handle {
position: absolute;
top: 0;
@@ -233,6 +256,7 @@ const handlePictureCardPreview: UploadProps['onPreview'] = (uploadFile) => {
background: rgb(0 0 0 / 60%);
opacity: 0;
transition: var(--el-transition-duration-fast);
.handle-icon {
display: flex;
flex-direction: column;
@@ -240,15 +264,18 @@ const handlePictureCardPreview: UploadProps['onPreview'] = (uploadFile) => {
justify-content: center;
padding: 0 6%;
color: aliceblue;
.el-icon {
margin-bottom: 15%;
font-size: 140%;
}
span {
font-size: 100%;
}
}
}
.el-upload-list__item {
&:hover {
.upload-handle {
@@ -256,6 +283,7 @@ const handlePictureCardPreview: UploadProps['onPreview'] = (uploadFile) => {
}
}
}
.upload-empty {
display: flex;
flex-direction: column;
@@ -263,12 +291,14 @@ const handlePictureCardPreview: UploadProps['onPreview'] = (uploadFile) => {
font-size: 12px;
line-height: 30px;
color: var(--el-color-info);
.el-icon {
font-size: 28px;
color: var(--el-text-color-secondary);
}
}
}
.el-upload__tip {
line-height: 15px;
text-align: center;
+7 -3
View File
@@ -6,7 +6,8 @@ import {
PieChart,
MapChart,
PictorialBarChart,
RadarChart
RadarChart,
GaugeChart
} from 'echarts/charts'
import {
@@ -16,7 +17,8 @@ import {
PolarComponent,
AriaComponent,
ParallelComponent,
LegendComponent
LegendComponent,
ToolboxComponent
} from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
@@ -25,6 +27,7 @@ echarts.use([
LegendComponent,
TitleComponent,
TooltipComponent,
ToolboxComponent,
GridComponent,
PolarComponent,
AriaComponent,
@@ -35,7 +38,8 @@ echarts.use([
MapChart,
CanvasRenderer,
PictorialBarChart,
RadarChart
RadarChart,
GaugeChart
])
export default echarts
+40 -4
View File
@@ -2,9 +2,9 @@ import { Layout } from '@/utils/routerHelper'
const { t } = useI18n()
/**
* redirect: noredirect 当设置 noredirect 的时候该路由在面包屑导航中不可被点击
* name:'router-name' 设定路由的名字,一定要填写不然使用<keep-alive>时会出现各种问题
* meta : {
* redirect: noredirect 当设置 noredirect 的时候该路由在面包屑导航中不可被点击
* name:'router-name' 设定路由的名字,一定要填写不然使用<keep-alive>时会出现各种问题
* meta : {
hidden: true 当设置 true 的时候该路由不会再侧边栏出现 如404,login等页面(默认 false)
alwaysShow: true 当你一个路由下面的 children 声明的路由大于1个时,自动会变成嵌套的模式,
@@ -31,7 +31,7 @@ const { t } = useI18n()
canTo: true 设置为true即使hidden为true,也依然可以进行路由跳转(默认 false)
}
**/
**/
const remainingRouter: AppRouteRecordRaw[] = [
{
path: '/redirect',
@@ -345,6 +345,42 @@ const remainingRouter: AppRouteRecordRaw[] = [
meta: { title: '商品属性值', icon: '', activeMenu: '/product/property' }
}
]
},
{
path: '/product',
component: Layout,
name: 'Product',
meta: {
hidden: true
},
children: [
{
path: 'productSpuAdd', // TODO @puhui999:最好拆成 add 和 edit 两个路由;添加商品;修改商品 fix
component: () => import('@/views/mall/product/spu/addForm.vue'),
name: 'ProductSpuAdd',
meta: {
noCache: true,
hidden: true,
canTo: true,
icon: 'ep:edit',
title: '添加商品',
activeMenu: '/product/product-spu'
}
},
{
path: 'productSpuEdit/:spuId(\\d+)',
component: () => import('@/views/mall/product/spu/addForm.vue'),
name: 'productSpuEdit',
meta: {
noCache: true,
hidden: true,
canTo: true,
icon: 'ep:edit',
title: '编辑商品',
activeMenu: '/product/product-spu'
}
}
]
}
]
+6
View File
@@ -10,6 +10,12 @@
width: 100% !important;
}
// 解决表格内容超过表格总宽度后,横向滚动条前端顶不到表格边缘的问题
.el-scrollbar__bar {
display: flex;
justify-content: flex-start;
}
/* nprogress 适配 element-plus 的主题色 */
#nprogress {
& .bar {
+18
View File
@@ -220,3 +220,21 @@ export const PayRefundStatusEnum = {
name: '退款关闭'
}
}
/**
* 商品SPU枚举类
*/
export const ProductSpuStatusEnum = {
RECYCLE: {
status: -1,
name: '回收站'
},
DISABLE: {
status: 0,
name: '下架'
},
ENABLE: {
status: 1,
name: '上架'
}
}
+7 -1
View File
@@ -144,5 +144,11 @@ export enum DICT_TYPE {
// ========== MP 模块 ==========
MP_AUTO_REPLY_REQUEST_MATCH = 'mp_auto_reply_request_match', // 自动回复请求匹配类型
MP_MESSAGE_TYPE = 'mp_message_type' // 消息类型
MP_MESSAGE_TYPE = 'mp_message_type', // 消息类型
// ========== MALL 模块 ==========
PRODUCT_UNIT = 'product_unit', // 商品单位
PRODUCT_SPU_STATUS = 'product_spu_status', //商品状态
// ========== MALL 交易模块 ==========
EXPRESS_CHARGE_MODE = 'trade_delivery_express_charge_mode' //快递的计费方式
}
+54
View File
@@ -155,3 +155,57 @@ export const fileSizeFormatter = (row, column, cellValue) => {
const sizeStr = size.toFixed(2) //保留的小数位数
return sizeStr + ' ' + unitArr[index]
}
/**
* 将值复制到目标对象,且以目标对象属性为准,例:target: {a:1} source:{a:2,b:3} 结果为:{a:2}
* @param target 目标对象
* @param source 源对象
*/
export const copyValueToTarget = (target, source) => {
const newObj = Object.assign({}, target, source)
// 删除多余属性
Object.keys(newObj).forEach((key) => {
// 如果不是target中的属性则删除
if (Object.keys(target).indexOf(key) === -1) {
delete newObj[key]
}
})
// 更新目标对象值
Object.assign(target, newObj)
}
// TODO @puhui999:返回要带上 .00 哈.例如说 1.00
/**
* 将一个整数转换为分数保留两位小数
* @param num
*/
export const formatToFraction = (num: number | string | undefined): number => {
if (typeof num === 'undefined') return 0
const parsedNumber = typeof num === 'string' ? parseFloat(num) : num
return parseFloat((parsedNumber / 100).toFixed(2))
}
/**
* 将一个分数转换为整数
* @param num
*/
export const convertToInteger = (num: number | string | undefined): number => {
if (typeof num === 'undefined') return 0
const parsedNumber = typeof num === 'string' ? parseFloat(num) : num
// TODO 分转元后还有小数则四舍五入
return Math.round(parsedNumber * 100)
}
/**
* 元转分
*/
export const yuanToFen = (amount: string | number): number => {
return Math.round(Number(amount) * 100)
}
/**
* 分转元
*/
export const fenToYuan = (amount: string | number): number => {
return Number((Number(amount) / 100).toFixed(2))
}
+2 -1
View File
@@ -11,7 +11,8 @@ const DEFAULT_CONFIG: TreeHelperConfig = {
export const defaultProps = {
children: 'children',
label: 'name',
value: 'id'
value: 'id',
isLeaf: 'leaf'
}
const getConfig = (config: Partial<TreeHelperConfig>) => Object.assign({}, DEFAULT_CONFIG, config)
+15
View File
@@ -125,6 +125,21 @@
</div>
</el-form-item>
</el-col>
<el-divider content-position="center">萌新必读</el-divider>
<el-col :span="24" style="padding-left: 10px; padding-right: 10px">
<el-form-item>
<div class="flex justify-between w-[100%]">
<el-link href="https://doc.iocoder.cn/" target="_blank">📚开发指南</el-link>
<el-link href="https://doc.iocoder.cn/video/" target="_blank">🔥视频教程</el-link>
<el-link href="https://www.iocoder.cn/Interview/good-collection/" target="_blank">
⚡面试手册
</el-link>
<el-link href="http://static.yudao.iocoder.cn/mp/Aix9975.jpeg" target="_blank">
🤝外包咨询
</el-link>
</div>
</el-form-item>
</el-col>
</el-row>
</el-form>
</template>
+1 -1
View File
@@ -87,7 +87,7 @@
<MyProcessViewer
key="designer"
v-model="bpmnXML"
:value="bpmnXML"
:value="bpmnXML as any"
v-bind="bpmnControlForm"
:prefix="bpmnControlForm.prefix"
/>
+1 -1
View File
@@ -68,7 +68,7 @@ const formRules = reactive({
status: [{ required: true, message: '状态不能为空', trigger: 'blur' }]
})
const formRef = ref() // 表单 Ref
const userList = ref([]) // 用户列表
const userList = ref<any[]>([]) // 用户列表
/** 打开弹窗 */
const open = async (type: string, id?: number) => {
+2 -1
View File
@@ -117,6 +117,7 @@ import { dateFormatter } from '@/utils/formatTime'
import * as UserGroupApi from '@/api/bpm/userGroup'
import * as UserApi from '@/api/system/user'
import UserGroupForm from './UserGroupForm.vue'
import { UserVO } from '@/api/system/user'
const message = useMessage() // 消息弹窗
const { t } = useI18n() // 国际化
@@ -131,7 +132,7 @@ const queryParams = reactive({
createTime: []
})
const queryFormRef = ref() // 搜索的表单
const userList = ref([]) // 用户列表
const userList = ref<UserVO[]>([]) // 用户列表
/** 查询列表 */
const getList = async () => {
+1 -1
View File
@@ -16,7 +16,7 @@
<!-- 流程属性器,负责编辑每个流程节点的属性 -->
<MyProcessPenal
key="penal"
:bpmnModeler="modeler"
:bpmnModeler="modeler as any"
:prefix="controlForm.prefix"
class="process-panel"
:model="model"
+1 -1
View File
@@ -219,7 +219,7 @@
<MyProcessViewer
key="designer"
v-model="bpmnXML"
:value="bpmnXML"
:value="bpmnXML as any"
v-bind="bpmnControlForm"
:prefix="bpmnControlForm.prefix"
/>
+1 -1
View File
@@ -28,7 +28,7 @@ const props = defineProps({
id: propTypes.number.def(undefined)
})
const detailLoading = ref(false) // 表单的加载中
const detailData = ref({}) // 详情数据
const detailData = ref<any>({}) // 详情数据
const queryId = query.id as unknown as number // 从 URL 传递过来的 id 编号
/** 获得数据 */
@@ -43,7 +43,7 @@
</el-col>
</el-card>
<!-- 流程图预览 -->
<ProcessInstanceBpmnViewer :bpmn-xml="bpmnXML" />
<ProcessInstanceBpmnViewer :bpmn-xml="bpmnXML as any" />
</ContentWrap>
</template>
<script setup lang="ts" name="BpmProcessInstanceCreate">
+6 -4
View File
@@ -33,6 +33,12 @@
import FcDesigner from '@form-create/designer'
import { useClipboard } from '@vueuse/core'
import { isString } from '@/utils/is'
import hljs from 'highlight.js' // 导入代码高亮文件
import 'highlight.js/styles/github.css' // 导入代码高亮样式
import xml from 'highlight.js/lib/languages/java'
import json from 'highlight.js/lib/languages/json'
const { t } = useI18n() // 国际化
const message = useMessage() // 消息
@@ -112,10 +118,6 @@ const copy = async (text: string) => {
/**
* 代码高亮
*/
import hljs from 'highlight.js' // 导入代码高亮文件
import 'highlight.js/styles/github.css' // 导入代码高亮样式
import xml from 'highlight.js/lib/languages/java'
import json from 'highlight.js/lib/languages/json'
const highlightedCode = (code) => {
// 处理语言和代码
let language = 'json'
+8 -7
View File
@@ -59,6 +59,14 @@ import { useClipboard } from '@vueuse/core'
import { handleTree2 } from '@/utils/tree'
import * as CodegenApi from '@/api/infra/codegen'
import hljs from 'highlight.js' // 导入代码高亮文件
import 'highlight.js/styles/github.css' // 导入代码高亮样式
import java from 'highlight.js/lib/languages/java'
import xml from 'highlight.js/lib/languages/java'
import javascript from 'highlight.js/lib/languages/javascript'
import sql from 'highlight.js/lib/languages/sql'
import typescript from 'highlight.js/lib/languages/typescript'
const { t } = useI18n() // 国际化
const message = useMessage() // 消息弹窗
@@ -184,13 +192,6 @@ const copy = async (text: string) => {
/**
* 代码高亮
*/
import hljs from 'highlight.js' // 导入代码高亮文件
import 'highlight.js/styles/github.css' // 导入代码高亮样式
import java from 'highlight.js/lib/languages/java'
import xml from 'highlight.js/lib/languages/java'
import javascript from 'highlight.js/lib/languages/javascript'
import sql from 'highlight.js/lib/languages/sql'
import typescript from 'highlight.js/lib/languages/typescript'
const highlightedCode = (item) => {
const language = item.filePath.substring(item.filePath.lastIndexOf('.') + 1)
const result = hljs.highlight(language, item.code || '', true)
+184 -93
View File
@@ -1,7 +1,6 @@
<template>
<doc-alert title="Redis 缓存" url="https://doc.iocoder.cn/redis-cache/" />
<doc-alert title="本地缓存" url="https://doc.iocoder.cn/local-cache/" />
<el-scrollbar height="calc(100vh - 88px - 40px - 50px)">
<el-row>
<!-- 基本信息 -->
@@ -51,127 +50,219 @@
<!-- 命令统计 -->
<el-col :span="12" class="mt-3">
<el-card :gutter="12" shadow="hover">
<div ref="commandStatsRef" class="h-88"></div>
<Echart :options="commandStatsRefChika" :height="420" />
</el-card>
</el-col>
<!-- 内存使用量统计 -->
<el-col :span="12" class="mt-3">
<el-card class="ml-3" :gutter="12" shadow="hover">
<div ref="usedmemory" class="h-88"></div>
<Echart :options="usedmemoryEchartChika" :height="420" />
</el-card>
</el-col>
</el-row>
</el-scrollbar>
</template>
<script setup lang="ts" name="InfraRedis">
import * as echarts from 'echarts'
<script setup lang="ts">
import * as RedisApi from '@/api/infra/redis'
import { RedisMonitorInfoVO } from '@/api/infra/redis/types'
const cache = ref<RedisMonitorInfoVO>()
// 基本信息
const readRedisInfo = async () => {
const data = await RedisApi.getCache()
cache.value = data
loadEchartOptions(data.commandStats)
}
// 图表
const commandStatsRef = ref<HTMLElement>()
const usedmemory = ref<HTMLDivElement>()
const loadEchartOptions = (stats) => {
const commandStats = [] as any[]
const nameList = [] as string[]
stats.forEach((row) => {
commandStats.push({
name: row.command,
value: row.calls
})
nameList.push(row.command)
})
const commandStatsInstance = echarts.init(commandStatsRef.value!, 'macarons')
commandStatsInstance.setOption({
title: {
text: '命令统计',
left: 'center'
},
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b} : {c} ({d}%)'
},
legend: {
type: 'scroll',
orient: 'vertical',
right: 30,
top: 10,
bottom: 20,
data: nameList,
textStyle: {
color: '#a1a1a1'
// 内存使用情况
const usedmemoryEchartChika = reactive<any>({
title: {
// 仪表盘标题。
text: '内存使用情况',
left: 'center',
show: true, // 是否显示标题,默认 true。
offsetCenter: [0, '20%'], //相对于仪表盘中心的偏移位置,数组第一项是水平方向的偏移,第二项是垂直方向的偏移。可以是绝对的数值,也可以是相对于仪表盘半径的百分比。
color: 'yellow', // 文字的颜色,默认 #333。
fontSize: 20 // 文字的字体大小,默认 15。
},
toolbox: {
show: false,
feature: {
restore: { show: true },
saveAsImage: { show: true }
}
},
series: [
{
name: '峰值',
type: 'gauge',
min: 0,
max: 50,
splitNumber: 10,
//这是指针的颜色
color: '#F5C74E',
radius: '85%',
center: ['50%', '50%'],
startAngle: 225,
endAngle: -45,
axisLine: {
// 坐标轴线
lineStyle: {
// 属性lineStyle控制线条样式
color: [
[0.2, '#7FFF00'],
[0.8, '#00FFFF'],
[1, '#FF0000']
],
//width: 6 外框的大小(环的宽度)
width: 10
}
},
axisTick: {
// 坐标轴小标记
//里面的线长是5(短线)
length: 5, // 属性length控制线长
lineStyle: {
// 属性lineStyle控制线条样式
color: '#76D9D7'
}
},
splitLine: {
// 分隔线
length: 20, // 属性length控制线长
lineStyle: {
// 属性lineStyle(详见lineStyle)控制线条样式
color: '#76D9D7'
}
},
axisLabel: {
color: '#76D9D7',
distance: 15,
fontSize: 15
},
pointer: {
// 指针的大小
width: 7,
show: true
},
detail: {
textStyle: {
fontWeight: 'normal',
// 里面文字下的数值大小(50)
fontSize: 15,
color: '#FFFFFF'
},
valueAnimation: true
},
progress: {
show: true
}
},
series: [
{
name: '命令',
type: 'pie',
radius: [20, 120],
center: ['40%', '60%'],
data: commandStats,
roseType: 'radius',
}
]
})
// 指令使用情况
const commandStatsRefChika = reactive({
title: {
text: '命令统计',
left: 'center'
},
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b} : {c} ({d}%)'
},
legend: {
type: 'scroll',
orient: 'vertical',
right: 30,
top: 10,
bottom: 20,
data: [] as any[],
textStyle: {
color: '#a1a1a1'
}
},
series: [
{
name: '命令',
type: 'pie',
radius: [20, 120],
center: ['40%', '60%'],
data: [] as any[],
roseType: 'radius',
label: {
show: true
},
emphasis: {
label: {
show: true
},
emphasis: {
label: {
show: true
},
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
}
]
})
}
]
})
const usedMemoryInstance = echarts.init(usedmemory.value!, 'macarons')
usedMemoryInstance.setOption({
title: {
text: '内存使用情况',
left: 'center'
},
tooltip: {
formatter: '{b} <br/>{a} : ' + cache.value!.info.used_memory_human
},
series: [
{
name: '峰值',
type: 'gauge',
min: 0,
max: 100,
progress: {
show: true
},
detail: {
formatter: cache.value!.info.used_memory_human
},
data: [
{
value: parseFloat(cache.value!.info.used_memory_human),
name: '内存消耗'
}
]
}
]
})
/** 加载数据 */
const getSummary = () => {
// 初始化命令图表
initCommandStatsChart()
usedMemoryInstance()
}
onBeforeMount(() => {
// TODO @hiiwbs 微信,优化使用 Echart 组件
/** 命令使用情况 */
const initCommandStatsChart = async () => {
usedmemoryEchartChika.series[0].data = []
// 发起请求
try {
const data = await RedisApi.getCache()
cache.value = data
// 处理数据
const commandStats = [] as any[]
const nameList = [] as string[]
data.commandStats.forEach((row) => {
commandStats.push({
name: row.command,
value: row.calls
})
nameList.push(row.command)
})
commandStatsRefChika.legend.data = nameList
commandStatsRefChika.series[0].data = commandStats
} catch {}
}
const usedMemoryInstance = async () => {
try {
const data = await RedisApi.getCache()
cache.value = data
// 仪表盘详情,用于显示数据。
usedmemoryEchartChika.series[0].detail = {
show: true, // 是否显示详情,默认 true。
offsetCenter: [0, '50%'], // 相对于仪表盘中心的偏移位置,数组第一项是水平方向的偏移,第二项是垂直方向的偏移。可以是绝对的数值,也可以是相对于仪表盘半径的百分比。
color: 'auto', // 文字的颜色,默认 auto。
fontSize: 30, // 文字的字体大小,默认 15。
formatter: cache.value!.info.used_memory_human // 格式化函数或者字符串
}
usedmemoryEchartChika.series[0].data[0] = {
value: cache.value!.info.used_memory_human,
name: '内存消耗'
}
console.log(cache.value!.info)
usedmemoryEchartChika.tooltip = {
formatter: '{b} <br/>{a} : ' + cache.value!.info.used_memory_human
}
} catch {}
}
/** 初始化 **/
onMounted(() => {
// 读取 redis 信息
readRedisInfo()
// 加载数据
getSummary()
})
</script>
@@ -4,27 +4,30 @@
ref="formRef"
:model="formData"
:rules="formRules"
label-width="80px"
label-width="120px"
v-loading="formLoading"
>
<el-form-item label="上级分类" prop="parentId">
<el-tree-select
v-model="formData.parentId"
:data="categoryTree"
:props="{ label: 'name', value: 'id' }"
:render-after-expand="false"
placeholder="请选择上级分类"
check-strictly
default-expand-all
/>
<el-select v-model="formData.parentId" placeholder="请选择上级分类">
<el-option :key="0" label="顶级分类" :value="0" />
<el-option
v-for="item in categoryList"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="分类名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入分类名称" />
</el-form-item>
<el-form-item label="分类图片" prop="picUrl">
<el-form-item label="移动端分类图" prop="picUrl">
<UploadImg v-model="formData.picUrl" :limit="1" :is-show-tip="false" />
<div v-if="formData.parentId === 0" style="font-size: 10px">推荐 200x100 图片分辨率</div>
<div v-else style="font-size: 10px">推荐 100x100 图片分辨率</div>
<div style="font-size: 10px" class="pl-10px">推荐 180x180 图片分辨率</div>
</el-form-item>
<el-form-item label="PC 端分类图" prop="bigPicUrl">
<UploadImg v-model="formData.bigPicUrl" :limit="1" :is-show-tip="false" />
<div style="font-size: 10px" class="pl-10px">推荐 468x340 图片分辨率</div>
</el-form-item>
<el-form-item label="分类排序" prop="sort">
<el-input-number v-model="formData.sort" controls-position="right" :min="0" />
@@ -40,9 +43,6 @@
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="分类描述">
<el-input v-model="formData.description" type="textarea" placeholder="请输入分类描述" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
@@ -53,7 +53,6 @@
<script setup lang="ts" name="ProductCategory">
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import { CommonStatusEnum } from '@/utils/constants'
import { handleTree } from '@/utils/tree'
import * as ProductCategoryApi from '@/api/mall/product/category'
const { t } = useI18n() // 国际化
const message = useMessage() // 消息弹窗
@@ -66,8 +65,8 @@ const formData = ref({
id: undefined,
name: '',
picUrl: '',
status: CommonStatusEnum.ENABLE,
description: ''
bigPicUrl: '',
status: CommonStatusEnum.ENABLE
})
const formRules = reactive({
parentId: [{ required: true, message: '请选择上级分类', trigger: 'blur' }],
@@ -77,7 +76,7 @@ const formRules = reactive({
status: [{ required: true, message: '开启状态不能为空', trigger: 'blur' }]
})
const formRef = ref() // 表单 Ref
const categoryTree = ref<any[]>([]) // 分类树
const categoryList = ref<any[]>([]) // 分类树
/** 打开弹窗 */
const open = async (type: string, id?: number) => {
@@ -95,7 +94,7 @@ const open = async (type: string, id?: number) => {
}
}
// 获得分类树
await getTree()
categoryList.value = await ProductCategoryApi.getCategoryList({ parentId: 0 })
}
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
@@ -131,17 +130,9 @@ const resetForm = () => {
id: undefined,
name: '',
picUrl: '',
status: CommonStatusEnum.ENABLE,
description: ''
bigPicUrl: '',
status: CommonStatusEnum.ENABLE
}
formRef.value?.resetFields()
}
/** 获得分类树 */
const getTree = async () => {
const data = await ProductCategoryApi.getCategoryList({})
const tree = handleTree(data, 'id', 'parentId')
const menu = { id: 0, name: '顶级分类', children: tree }
categoryTree.value = [menu]
}
</script>
+2 -2
View File
@@ -36,9 +36,9 @@
<ContentWrap>
<el-table v-loading="loading" :data="list" row-key="id" default-expand-all>
<el-table-column label="分类名称" prop="name" sortable />
<el-table-column label="分类图片" align="center" prop="picUrl">
<el-table-column label="移动端分类图" align="center" prop="picUrl">
<template #default="scope">
<img v-if="scope.row.picUrl" :src="scope.row.picUrl" alt="分类图片" class="h-100px" />
<img v-if="scope.row.picUrl" :src="scope.row.picUrl" alt="移动端分类图" class="h-100px" />
</template>
</el-table-column>
<el-table-column label="分类排序" align="center" prop="sort" />
+32 -24
View File
@@ -2,42 +2,49 @@
<!-- 搜索工作栏 -->
<ContentWrap>
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
:model="queryParams"
class="-mb-15px"
label-width="68px"
>
<el-form-item label="名称" prop="name">
<el-input
v-model="queryParams.name"
placeholder="请输入名称"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
clearable
placeholder="请输入名称"
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="创建时间" prop="createTime">
<el-date-picker
v-model="queryParams.createTime"
value-format="YYYY-MM-DD HH:mm:ss"
type="daterange"
start-placeholder="开始日期"
end-placeholder="结束日期"
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
class="!w-240px"
end-placeholder="结束日期"
start-placeholder="开始日期"
type="daterange"
value-format="YYYY-MM-DD HH:mm:ss"
/>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
<el-button @click="handleQuery">
<Icon class="mr-5px" icon="ep:search" />
搜索
</el-button>
<el-button @click="resetQuery">
<Icon class="mr-5px" icon="ep:refresh" />
重置
</el-button>
<el-button
v-hasPermi="['product:property:create']"
plain
type="primary"
@click="openForm('create')"
v-hasPermi="['product:property:create']"
>
<Icon icon="ep:plus" class="mr-5px" /> 新增
<Icon class="mr-5px" icon="ep:plus" />
新增
</el-button>
</el-form-item>
</el-form>
@@ -46,23 +53,23 @@
<!-- 列表 -->
<ContentWrap>
<el-table v-loading="loading" :data="list">
<el-table-column label="编号" align="center" prop="id" />
<el-table-column label="名称" align="center" />
<el-table-column label="备注" align="center" prop="remark" :show-overflow-tooltip="true" />
<el-table-column align="center" label="编号" prop="id" />
<el-table-column align="center" label="名称" prop="name" />
<el-table-column :show-overflow-tooltip="true" align="center" label="备注" prop="remark" />
<el-table-column
label="创建时间"
:formatter="dateFormatter"
align="center"
label="创建时间"
prop="createTime"
width="180"
:formatter="dateFormatter"
/>
<el-table-column label="操作" align="center">
<el-table-column align="center" label="操作">
<template #default="scope">
<el-button
v-hasPermi="['product:property:update']"
link
type="primary"
@click="openForm('update', scope.row.id)"
v-hasPermi="['product:property:update']"
>
编辑
</el-button>
@@ -70,10 +77,10 @@
<router-link :to="'/property/value/' + scope.row.id">属性值</router-link>
</el-button>
<el-button
v-hasPermi="['product:property:delete']"
link
type="danger"
@click="handleDelete(scope.row.id)"
v-hasPermi="['product:property:delete']"
>
删除
</el-button>
@@ -82,9 +89,9 @@
</el-table>
<!-- 分页 -->
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
v-model:page="queryParams.pageNo"
:total="total"
@pagination="getList"
/>
</ContentWrap>
@@ -92,10 +99,11 @@
<!-- 表单弹窗:添加/修改 -->
<PropertyForm ref="formRef" @success="getList" />
</template>
<script setup lang="ts" name="ProductProperty">
<script lang="ts" name="ProductProperty" setup>
import { dateFormatter } from '@/utils/formatTime'
import * as PropertyApi from '@/api/mall/product/property'
import PropertyForm from './PropertyForm.vue'
const message = useMessage() // 消息弹窗
const { t } = useI18n() // 国际化
+178
View File
@@ -0,0 +1,178 @@
<template>
<ContentWrap v-loading="formLoading">
<el-tabs v-model="activeName">
<el-tab-pane label="商品信息" name="basicInfo">
<BasicInfoForm
ref="basicInfoRef"
v-model:activeName="activeName"
:propFormData="formData"
/>
</el-tab-pane>
<el-tab-pane label="商品详情" name="description">
<DescriptionForm
ref="descriptionRef"
v-model:activeName="activeName"
:propFormData="formData"
/>
</el-tab-pane>
<el-tab-pane label="其他设置" name="otherSettings">
<OtherSettingsForm
ref="otherSettingsRef"
v-model:activeName="activeName"
:propFormData="formData"
/>
</el-tab-pane>
</el-tabs>
<el-form>
<el-form-item style="float: right">
<el-button :loading="formLoading" type="primary" @click="submitForm">保存</el-button>
<el-button @click="close">返回</el-button>
</el-form-item>
</el-form>
</ContentWrap>
</template>
<script lang="ts" name="ProductSpuForm" setup>
import { cloneDeep } from 'lodash-es'
import { useTagsViewStore } from '@/store/modules/tagsView'
import { BasicInfoForm, DescriptionForm, OtherSettingsForm } from './components'
// 业务api
import * as ProductSpuApi from '@/api/mall/product/spu'
import { convertToInteger, formatToFraction } from '@/utils'
const { t } = useI18n() // 国际化
const message = useMessage() // 消息弹窗
const { push, currentRoute } = useRouter() // 路由
const { params } = useRoute() // 查询参数
const { delView } = useTagsViewStore() // 视图操作
const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
const activeName = ref('basicInfo') // Tag 激活的窗口
const basicInfoRef = ref<ComponentRef<typeof BasicInfoForm>>() // 商品信息Ref
const descriptionRef = ref<ComponentRef<typeof DescriptionForm>>() // 商品详情Ref
const otherSettingsRef = ref<ComponentRef<typeof OtherSettingsForm>>() // 其他设置Ref
// spu 表单数据
const formData = ref<ProductSpuApi.SpuType>({
name: '', // 商品名称
categoryId: null, // 商品分类
keyword: '', // 关键字
unit: null, // 单位
picUrl: '', // 商品封面图
sliderPicUrls: [], // 商品轮播图
introduction: '', // 商品简介
deliveryTemplateId: 1, // 运费模版
brandId: null, // 商品品牌
specType: false, // 商品规格
subCommissionType: false, // 分销类型
skus: [
{
price: 0, // 商品价格
marketPrice: 0, // 市场价
costPrice: 0, // 成本价
barCode: '', // 商品条码
picUrl: '', // 图片地址
stock: 0, // 库存
weight: 0, // 商品重量
volume: 0, // 商品体积
subCommissionFirstPrice: 0, // 一级分销的佣金
subCommissionSecondPrice: 0 // 二级分销的佣金
}
],
description: '', // 商品详情
sort: 0, // 商品排序
giveIntegral: 0, // 赠送积分
virtualSalesCount: 0, // 虚拟销量
recommendHot: false, // 是否热卖
recommendBenefit: false, // 是否优惠
recommendBest: false, // 是否精品
recommendNew: false, // 是否新品
recommendGood: false // 是否优品
})
/** 获得详情 */
const getDetail = async () => {
const id = params.spuId as number
if (id) {
formLoading.value = true
try {
const res = (await ProductSpuApi.getSpu(id)) as ProductSpuApi.SpuType
res.skus.forEach((item) => {
// 回显价格分转元
item.price = formatToFraction(item.price)
item.marketPrice = formatToFraction(item.marketPrice)
item.costPrice = formatToFraction(item.costPrice)
item.subCommissionFirstPrice = formatToFraction(item.subCommissionFirstPrice)
item.subCommissionSecondPrice = formatToFraction(item.subCommissionSecondPrice)
})
formData.value = res
} finally {
formLoading.value = false
}
}
}
/** 提交按钮 */
const submitForm = async () => {
// 提交请求
formLoading.value = true
// 三个表单逐一校验,如果有一个表单校验不通过则切换到对应表单,如果有两个及以上的情况则切换到最前面的一个并弹出提示消息
// 校验各表单
try {
await unref(basicInfoRef)?.validate()
await unref(descriptionRef)?.validate()
await unref(otherSettingsRef)?.validate()
const deepCopyFormData = cloneDeep(unref(formData.value)) // 深拷贝一份 fix:这样最终 server 端不满足,不需要恢复,
// TODO 兜底处理 sku 空数据
formData.value.skus.forEach((sku) => {
// 因为是空数据这里判断一下商品条码是否为空就行
if (sku.barCode === '') {
const index = deepCopyFormData.skus.findIndex(
(item) => JSON.stringify(item.properties) === JSON.stringify(sku.properties)
)
// 删除这条 sku
deepCopyFormData.skus.splice(index, 1)
}
})
deepCopyFormData.skus.forEach((item) => {
// 给sku name赋值
item.name = deepCopyFormData.name
// sku相关价格元转分
item.price = convertToInteger(item.price)
item.marketPrice = convertToInteger(item.marketPrice)
item.costPrice = convertToInteger(item.costPrice)
item.subCommissionFirstPrice = convertToInteger(item.subCommissionFirstPrice)
item.subCommissionSecondPrice = convertToInteger(item.subCommissionSecondPrice)
})
// 处理轮播图列表
const newSliderPicUrls = []
deepCopyFormData.sliderPicUrls.forEach((item) => {
// 如果是前端选的图
typeof item === 'object' ? newSliderPicUrls.push(item.url) : newSliderPicUrls.push(item)
})
deepCopyFormData.sliderPicUrls = newSliderPicUrls
// 校验都通过后提交表单
const data = deepCopyFormData as ProductSpuApi.SpuType
const id = params.spuId as number
if (!id) {
await ProductSpuApi.createSpu(data)
message.success(t('common.createSuccess'))
} else {
await ProductSpuApi.updateSpu(data)
message.success(t('common.updateSuccess'))
}
close()
} finally {
formLoading.value = false
}
}
/** 关闭按钮 */
const close = () => {
delView(unref(currentRoute))
push('/product/product-spu')
}
/** 初始化 */
onMounted(async () => {
await getDetail()
})
</script>
@@ -0,0 +1,274 @@
<template>
<el-form ref="productSpuBasicInfoRef" :model="formData" :rules="rules" label-width="120px">
<el-row>
<el-col :span="12">
<el-form-item label="商品名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入商品名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<!-- TODO @puhui999:只能选根节点 -->
<el-form-item label="商品分类" prop="categoryId">
<el-tree-select
v-model="formData.categoryId"
:data="categoryList"
:props="defaultProps"
check-strictly
class="w-1/1"
node-key="id"
placeholder="请选择商品分类"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="商品关键字" prop="keyword">
<el-input v-model="formData.keyword" placeholder="请输入商品关键字" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="单位" prop="unit">
<el-select v-model="formData.unit" class="w-1/1" placeholder="请选择单位">
<el-option
v-for="dict in getIntDictOptions(DICT_TYPE.PRODUCT_UNIT)"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="商品简介" prop="introduction">
<el-input
v-model="formData.introduction"
:rows="3"
placeholder="请输入商品简介"
type="textarea"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="商品封面图" prop="picUrl">
<UploadImg v-model="formData.picUrl" height="80px" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="商品轮播图" prop="sliderPicUrls">
<UploadImgs v-model:modelValue="formData.sliderPicUrls" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="运费模板" prop="deliveryTemplateId">
<el-select v-model="formData.deliveryTemplateId" placeholder="请选择">
<el-option v-for="item in []" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-button class="ml-20px">运费模板</el-button>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="品牌" prop="brandId">
<el-select v-model="formData.brandId" placeholder="请选择">
<el-option
v-for="item in brandList"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="商品规格" props="specType">
<el-radio-group v-model="formData.specType" @change="onChangeSpec">
<el-radio :label="false" class="radio">单规格</el-radio>
<el-radio :label="true">多规格</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="分销类型" props="subCommissionType">
<el-radio-group v-model="formData.subCommissionType" @change="changeSubCommissionType">
<el-radio :label="false">默认设置</el-radio>
<el-radio :label="true" class="radio">自行设置</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<!-- 多规格添加-->
<el-col :span="24">
<el-form-item v-if="formData.specType" label="商品属性">
<el-button class="mr-15px mb-10px" @click="attributesAddFormRef.open">添加规格</el-button>
<ProductAttributes :propertyList="propertyList" @success="generateSkus" />
</el-form-item>
<template v-if="formData.specType && propertyList.length > 0">
<el-form-item label="批量设置">
<SkuList :is-batch="true" :prop-form-data="formData" :propertyList="propertyList" />
</el-form-item>
<el-form-item label="属性列表">
<SkuList ref="skuListRef" :prop-form-data="formData" :propertyList="propertyList" />
</el-form-item>
</template>
<el-form-item v-if="!formData.specType">
<SkuList :prop-form-data="formData" :propertyList="propertyList" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<ProductAttributesAddForm ref="attributesAddFormRef" :propertyList="propertyList" />
</template>
<script lang="ts" name="ProductSpuBasicInfoForm" setup>
import { PropType } from 'vue'
import { copyValueToTarget } from '@/utils'
import { propTypes } from '@/utils/propTypes'
import { defaultProps, handleTree } from '@/utils/tree'
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import type { SpuType } from '@/api/mall/product/spu'
import { UploadImg, UploadImgs } from '@/components/UploadFile'
import { ProductAttributes, ProductAttributesAddForm, SkuList } from './index'
import * as ProductCategoryApi from '@/api/mall/product/category'
import { getSimpleBrandList } from '@/api/mall/product/brand'
const message = useMessage() // 消息弹窗
const props = defineProps({
propFormData: {
type: Object as PropType<SpuType>,
default: () => {}
},
activeName: propTypes.string.def('')
})
const attributesAddFormRef = ref() // 添加商品属性表单
const productSpuBasicInfoRef = ref() // 表单 Ref
const propertyList = ref([]) // 商品属性列表
const skuListRef = ref() // 商品属性列表Ref
/** 调用 SkuList generateTableData 方法*/
const generateSkus = (propertyList) => {
skuListRef.value.generateTableData(propertyList)
}
const formData = reactive<SpuType>({
name: '', // 商品名称
categoryId: null, // 商品分类
keyword: '', // 关键字
unit: '', // 单位
picUrl: '', // 商品封面图
sliderPicUrls: [], // 商品轮播图
introduction: '', // 商品简介
deliveryTemplateId: 1, // 运费模版
brandId: null, // 商品品牌
specType: false, // 商品规格
subCommissionType: false, // 分销类型
skus: []
})
const rules = reactive({
name: [required],
categoryId: [required],
keyword: [required],
unit: [required],
introduction: [required],
picUrl: [required],
sliderPicUrls: [required],
// deliveryTemplateId: [required],
brandId: [required],
specType: [required],
subCommissionType: [required]
})
/**
* 将传进来的值赋值给 formData
*/
watch(
() => props.propFormData,
(data) => {
if (!data) {
return
}
copyValueToTarget(formData, data)
formData.sliderPicUrls = data['sliderPicUrls'].map((item) => ({
url: item
}))
// TODO @puhui999:if return,减少嵌套层级
// 只有是多规格才处理
if (formData.specType) {
// 直接拿返回的 skus 属性逆向生成出 propertyList
const properties = []
formData.skus.forEach((sku) => {
sku.properties.forEach(({ propertyId, propertyName, valueId, valueName }) => {
// 添加属性
if (!properties.some((item) => item.id === propertyId)) {
properties.push({ id: propertyId, name: propertyName, values: [] })
}
// 添加属性值
const index = properties.findIndex((item) => item.id === propertyId)
if (!properties[index].values.some((value) => value.id === valueId)) {
properties[index].values.push({ id: valueId, name: valueName })
}
})
})
propertyList.value = properties
}
},
{
immediate: true
}
)
/**
* 表单校验
*/
const emit = defineEmits(['update:activeName'])
const validate = async () => {
// 校验表单
if (!productSpuBasicInfoRef) return
return await unref(productSpuBasicInfoRef).validate((valid) => {
if (!valid) {
message.warning('商品信息未完善!!')
emit('update:activeName', 'basicInfo')
// 目的截断之后的校验
throw new Error('商品信息未完善!!')
} else {
// 校验通过更新数据
Object.assign(props.propFormData, formData)
}
})
}
defineExpose({ validate })
/** 分销类型 */
const changeSubCommissionType = () => {
// 默认为零,类型切换后也要重置为零
for (const item of formData.skus) {
item.subCommissionFirstPrice = 0
item.subCommissionSecondPrice = 0
}
}
/** 选择规格 */
const onChangeSpec = () => {
// 重置商品属性列表
propertyList.value = []
// 重置sku列表
formData.skus = [
{
price: 0,
marketPrice: 0,
costPrice: 0,
barCode: '',
picUrl: '',
stock: 0,
weight: 0,
volume: 0,
subCommissionFirstPrice: 0,
subCommissionSecondPrice: 0
}
]
}
const categoryList = ref([]) // 分类树
const brandList = ref([]) // 精简商品品牌列表
onMounted(async () => {
// 获得分类树
const data = await ProductCategoryApi.getCategoryList({})
categoryList.value = handleTree(data, 'id', 'parentId')
// 获取商品品牌列表
brandList.value = await getSimpleBrandList()
})
</script>
@@ -0,0 +1,83 @@
<template>
<el-form ref="descriptionFormRef" :model="formData" :rules="rules" label-width="120px">
<!--富文本编辑器组件-->
<el-form-item label="商品详情" prop="description">
<Editor v-model:modelValue="formData.description" />
</el-form-item>
</el-form>
</template>
<script lang="ts" name="DescriptionForm" setup>
import type { SpuType } from '@/api/mall/product/spu'
import { Editor } from '@/components/Editor'
import { PropType } from 'vue'
import { propTypes } from '@/utils/propTypes'
import { copyValueToTarget } from '@/utils'
const message = useMessage() // 消息弹窗
const props = defineProps({
propFormData: {
type: Object as PropType<SpuType>,
default: () => {}
},
activeName: propTypes.string.def('')
})
const descriptionFormRef = ref() // 表单Ref
const formData = ref<SpuType>({
description: '' // 商品详情
})
// 表单规则
const rules = reactive({
description: [required]
})
/**
* 富文本编辑器如果输入过再清空会有残留,需再重置一次
*/
watch(
() => formData.value.description,
(newValue) => {
if ('<p><br></p>' === newValue) {
formData.value.description = ''
}
},
{
deep: true,
immediate: true
}
)
/**
* 将传进来的值赋值给formData
*/
watch(
() => props.propFormData,
(data) => {
if (!data) return
// fix:三个表单组件监听赋值必须使用 copyValueToTarget 使用 formData.value = data 会监听非常多次
copyValueToTarget(formData.value, data)
},
{
// fix: 去掉深度监听只有对象引用发生改变的时候才执行,解决改一动多的问题
immediate: true
}
)
/**
* 表单校验
*/
const emit = defineEmits(['update:activeName'])
const validate = async () => {
// 校验表单
if (!descriptionFormRef) return
return await unref(descriptionFormRef).validate((valid) => {
if (!valid) {
message.warning('商品详情为完善!!')
emit('update:activeName', 'description')
// 目的截断之后的校验
throw new Error('商品详情为完善!!')
} else {
// 校验通过更新数据
Object.assign(props.propFormData, formData.value)
}
})
}
defineExpose({ validate })
</script>
@@ -0,0 +1,145 @@
<template>
<el-form ref="otherSettingsFormRef" :model="formData" :rules="rules" label-width="120px">
<el-row>
<el-col :span="24">
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="商品排序" prop="sort">
<el-input-number v-model="formData.sort" :min="0" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="赠送积分" prop="giveIntegral">
<el-input-number v-model="formData.giveIntegral" :min="0" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="虚拟销量" prop="virtualSalesCount">
<el-input-number
v-model="formData.virtualSalesCount"
:min="0"
placeholder="请输入虚拟销量"
/>
</el-form-item>
</el-col>
</el-row>
</el-col>
<el-col :span="24">
<el-form-item label="商品推荐">
<el-checkbox-group v-model="checkboxGroup" @change="onChangeGroup">
<el-checkbox v-for="(item, index) in recommendOptions" :key="index" :label="item.value">
{{ item.name }}
</el-checkbox>
</el-checkbox-group>
</el-form-item>
</el-col>
<el-col :span="24">
<!-- TODO tag展示暂时不考虑排序 -->
<el-form-item label="活动优先级">
<el-tag>默认</el-tag>
<el-tag class="ml-2" type="success">秒杀</el-tag>
<el-tag class="ml-2" type="info">砍价</el-tag>
<el-tag class="ml-2" type="warning">拼团</el-tag>
</el-form-item>
</el-col>
<!-- TODO @puhui999:等优惠劵 ok 在搞 -->
<el-col :span="24">
<el-form-item label="赠送优惠劵">
<el-button>选择优惠券</el-button>
</el-form-item>
</el-col>
</el-row>
</el-form>
</template>
<script lang="ts" name="OtherSettingsForm" setup>
import type { SpuType } from '@/api/mall/product/spu'
import { PropType } from 'vue'
import { propTypes } from '@/utils/propTypes'
import { copyValueToTarget } from '@/utils'
const message = useMessage() // 消息弹窗
const props = defineProps({
propFormData: {
type: Object as PropType<SpuType>,
default: () => {}
},
activeName: propTypes.string.def('')
})
const otherSettingsFormRef = ref() // 表单Ref
// 表单数据
const formData = ref<SpuType>({
sort: 1, // 商品排序
giveIntegral: 1, // 赠送积分
virtualSalesCount: 1, // 虚拟销量
recommendHot: false, // 是否热卖
recommendBenefit: false, // 是否优惠
recommendBest: false, // 是否精品
recommendNew: false, // 是否新品
recommendGood: false // 是否优品
})
// 表单规则
const rules = reactive({
sort: [required],
giveIntegral: [required],
virtualSalesCount: [required]
})
const recommendOptions = [
{ name: '是否热卖', value: 'recommendHot' },
{ name: '是否优惠', value: 'recommendBenefit' },
{ name: '是否精品', value: 'recommendBest' },
{ name: '是否新品', value: 'recommendNew' },
{ name: '是否优品', value: 'recommendGood' }
] // 商品推荐选项
const checkboxGroup = ref<string[]>([]) // 选中的推荐选项
/** 选择商品后赋值 */
const onChangeGroup = () => {
recommendOptions.forEach(({ value }) => {
formData.value[value] = checkboxGroup.value.includes(value)
})
}
/**
* 将传进来的值赋值给formData
*/
watch(
() => props.propFormData,
(data) => {
if (!data) {
return
}
copyValueToTarget(formData.value, data)
recommendOptions.forEach(({ value }) => {
if (formData.value[value] && !checkboxGroup.value.includes(value)) {
checkboxGroup.value.push(value)
}
})
},
{
immediate: true
}
)
/**
* 表单校验
*/
const emit = defineEmits(['update:activeName'])
const validate = async () => {
// 校验表单
if (!otherSettingsFormRef) return
return await unref(otherSettingsFormRef).validate((valid) => {
if (!valid) {
message.warning('商品其他设置未完善!!')
emit('update:activeName', 'otherSettings')
// 目的截断之后的校验
throw new Error('商品其他设置未完善!!')
} else {
// 校验通过更新数据
Object.assign(props.propFormData, formData.value)
}
})
}
defineExpose({ validate })
</script>
@@ -0,0 +1,117 @@
<template>
<el-col v-for="(item, index) in attributeList" :key="index">
<div>
<el-text class="mx-1">属性名:</el-text>
<el-tag class="mx-1" closable type="success" @close="handleCloseProperty(index)"
>{{ item.name }}
</el-tag>
</div>
<div>
<el-text class="mx-1">属性值:</el-text>
<el-tag
v-for="(value, valueIndex) in item.values"
:key="value.id"
class="mx-1"
closable
@close="handleCloseValue(index, valueIndex)"
>
{{ value.name }}
</el-tag>
<el-input
v-show="inputVisible(index)"
:id="`input${index}`"
:ref="setInputRef"
v-model="inputValue"
class="!w-20"
size="small"
@blur="handleInputConfirm(index, item.id)"
@keyup.enter="handleInputConfirm(index, item.id)"
/>
<el-button
v-show="!inputVisible(index)"
class="button-new-tag ml-1"
size="small"
@click="showInput(index)"
>
+ 添加
</el-button>
</div>
<el-divider class="my-10px" />
</el-col>
</template>
<script lang="ts" name="ProductAttributes" setup>
import { ElInput } from 'element-plus'
import * as PropertyApi from '@/api/mall/product/property'
const { t } = useI18n() // 国际化
const message = useMessage() // 消息弹窗
const inputValue = ref('') // 输入框值
const attributeIndex = ref<number | null>(null) // 获取焦点时记录当前属性项的index
// 输入框显隐控制
const inputVisible = computed(() => (index) => {
if (attributeIndex.value === null) return false
if (attributeIndex.value === index) return true
})
const inputRef = ref([]) //标签输入框Ref
/** 解决 ref 在 v-for 中的获取问题*/
const setInputRef = (el) => {
if (el === null || typeof el === 'undefined') return
// 如果不存在id相同的元素才添加
if (!inputRef.value.some((item) => item.input?.attributes.id === el.input?.attributes.id)) {
inputRef.value.push(el)
}
}
const attributeList = ref([]) // 商品属性列表
const props = defineProps({
propertyList: {
type: Array,
default: () => {}
}
})
watch(
() => props.propertyList,
(data) => {
if (!data) return
attributeList.value = data
},
{
deep: true,
immediate: true
}
)
/** 删除属性值*/
const handleCloseValue = (index, valueIndex) => {
attributeList.value[index].values?.splice(valueIndex, 1)
}
/** 删除属性*/
const handleCloseProperty = (index) => {
attributeList.value?.splice(index, 1)
}
/** 显示输入框并获取焦点 */
const showInput = async (index) => {
attributeIndex.value = index
inputRef.value[index].focus()
}
const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
/** 输入框失去焦点或点击回车时触发 */
const handleInputConfirm = async (index, propertyId) => {
if (inputValue.value) {
// 保存属性值
try {
const id = await PropertyApi.createPropertyValue({ propertyId, name: inputValue.value })
attributeList.value[index].values.push({ id, name: inputValue.value })
message.success(t('common.createSuccess'))
emit('success', attributeList.value)
} catch {
message.error('添加失败,请重试') // TODO 缺少国际化
}
}
attributeIndex.value = null
inputValue.value = ''
}
</script>
@@ -0,0 +1,98 @@
<template>
<Dialog v-model="dialogVisible" :title="dialogTitle">
<el-form
ref="formRef"
v-loading="formLoading"
:model="formData"
:rules="formRules"
label-width="80px"
>
<el-form-item label="属性名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入名称" />
</el-form-item>
</el-form>
<template #footer>
<el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
<el-button @click="dialogVisible = false">取 消</el-button>
</template>
</Dialog>
</template>
<script lang="ts" name="ProductPropertyForm" setup>
import * as PropertyApi from '@/api/mall/product/property'
const { t } = useI18n() // 国际化
const message = useMessage() // 消息弹窗
const dialogVisible = ref(false) // 弹窗的是否展示
const dialogTitle = ref('添加商品属性') // 弹窗的标题
const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
const formData = ref({
name: ''
})
const formRules = reactive({
name: [{ required: true, message: '名称不能为空', trigger: 'blur' }]
})
const formRef = ref() // 表单 Ref
const attributeList = ref([]) // 商品属性列表
const props = defineProps({
propertyList: {
type: Array,
default: () => {}
}
})
watch(
() => props.propertyList,
(data) => {
if (!data) return
attributeList.value = data
},
{
deep: true,
immediate: true
}
)
/** 打开弹窗 */
const open = async () => {
dialogVisible.value = true
resetForm()
}
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
/** 提交表单 */
const submitForm = async () => {
// 校验表单
if (!formRef) return
const valid = await formRef.value.validate()
if (!valid) return
// 提交请求
formLoading.value = true
try {
const data = formData.value as PropertyApi.PropertyVO
// 检查属性是否已存在,如果有则返回属性和其下属性值
const res = await PropertyApi.getPropertyListAndValue({ name: data.name })
if (res.length === 0) {
const propertyId = await PropertyApi.createProperty(data)
attributeList.value.push({ id: propertyId, ...formData.value, values: [] })
} else {
if (res[0].values === null) {
res[0].values = []
}
attributeList.value.push(res[0]) // 因为只用一个
}
message.success(t('common.createSuccess'))
dialogVisible.value = false
} finally {
formLoading.value = false
}
}
/** 重置表单 */
const resetForm = () => {
formData.value = {
name: '',
remark: ''
}
formRef.value?.resetFields()
}
</script>
@@ -0,0 +1,317 @@
<template>
<el-table
:data="isBatch ? skuList : formData.skus"
border
class="tabNumWidth"
max-height="500"
size="small"
>
<el-table-column align="center" fixed="left" label="图片" min-width="100">
<template #default="{ row }">
<UploadImg v-model="row.picUrl" height="80px" width="100%" />
</template>
</el-table-column>
<template v-if="formData.specType && !isBatch">
<!-- 根据商品属性动态添加 -->
<el-table-column
v-for="(item, index) in tableHeaders"
:key="index"
:label="item.label"
align="center"
min-width="120"
>
<template #default="{ row }">
<!-- TODO puhui999:展示成蓝色,有点区分度哈 -->
{{ row.properties[index]?.valueName }}
</template>
</el-table-column>
</template>
<el-table-column align="center" label="商品条码" min-width="168">
<template #default="{ row }">
<el-input v-model="row.barCode" class="w-100%" />
</template>
</el-table-column>
<el-table-column align="center" label="销售价(元)" min-width="168">
<template #default="{ row }">
<el-input-number v-model="row.price" :min="0" :precision="2" :step="0.1" class="w-100%" />
</template>
</el-table-column>
<el-table-column align="center" label="市场价(元)" min-width="168">
<template #default="{ row }">
<el-input-number
v-model="row.marketPrice"
:min="0"
:precision="2"
:step="0.1"
class="w-100%"
/>
</template>
</el-table-column>
<el-table-column align="center" label="成本价(元)" min-width="168">
<template #default="{ row }">
<el-input-number
v-model="row.costPrice"
:min="0"
:precision="2"
:step="0.1"
class="w-100%"
/>
</template>
</el-table-column>
<el-table-column align="center" label="库存" min-width="168">
<template #default="{ row }">
<el-input-number v-model="row.stock" :min="0" class="w-100%" />
</template>
</el-table-column>
<el-table-column align="center" label="重量(kg)" min-width="168">
<template #default="{ row }">
<el-input-number v-model="row.weight" :min="0" :precision="2" :step="0.1" class="w-100%" />
</template>
</el-table-column>
<el-table-column align="center" label="体积(m^3)" min-width="168">
<template #default="{ row }">
<el-input-number v-model="row.volume" :min="0" :precision="2" :step="0.1" class="w-100%" />
</template>
</el-table-column>
<template v-if="formData.subCommissionType">
<el-table-column align="center" label="一级返佣(元)" min-width="168">
<template #default="{ row }">
<el-input-number
v-model="row.subCommissionFirstPrice"
:min="0"
:precision="2"
:step="0.1"
class="w-100%"
/>
</template>
</el-table-column>
<el-table-column align="center" label="二级返佣(元)" min-width="168">
<template #default="{ row }">
<el-input-number
v-model="row.subCommissionSecondPrice"
:min="0"
:precision="2"
:step="0.1"
class="w-100%"
/>
</template>
</el-table-column>
</template>
<el-table-column v-if="formData.specType" align="center" fixed="right" label="操作" width="80">
<template #default="{ row }">
<el-button v-if="isBatch" link size="small" type="primary" @click="batchAdd">
批量添加
</el-button>
<el-button v-else link size="small" type="primary" @click="deleteSku(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</template>
<script lang="ts" name="SkuList" setup>
import { PropType } from 'vue'
import { copyValueToTarget } from '@/utils'
import { propTypes } from '@/utils/propTypes'
import { UploadImg } from '@/components/UploadFile'
import type { Property, SkuType, SpuType } from '@/api/mall/product/spu'
const props = defineProps({
propFormData: {
type: Object as PropType<SpuType>,
default: () => {}
},
propertyList: {
type: Array,
default: () => []
},
isBatch: propTypes.bool.def(false) // 是否作为批量操作组件
})
const formData = ref<SpuType>() // 表单数据
const skuList = ref<SkuType[]>([
{
price: 0, // 商品价格
marketPrice: 0, // 市场价
costPrice: 0, // 成本价
barCode: '', // 商品条码
picUrl: '', // 图片地址
stock: 0, // 库存
weight: 0, // 商品重量
volume: 0, // 商品体积
subCommissionFirstPrice: 0, // 一级分销的佣金
subCommissionSecondPrice: 0 // 二级分销的佣金
}
]) // 批量添加时的临时数据
// TODO @puhui999:保存时,每个商品规格的表单要校验下。例如说,销售金额最低是 0.01 这种。
/** 批量添加 */
const batchAdd = () => {
formData.value.skus.forEach((item) => {
copyValueToTarget(item, skuList.value[0])
})
}
/** 删除 sku */
const deleteSku = (row) => {
const index = formData.value.skus.findIndex(
// 直接把列表转成字符串比较
(sku) => JSON.stringify(sku.properties) === JSON.stringify(row.properties)
)
formData.value.skus.splice(index, 1)
}
const tableHeaders = ref<{ prop: string; label: string }[]>([]) // 多属性表头
/**
* 将传进来的值赋值给 skuList
*/
watch(
() => props.propFormData,
(data) => {
if (!data) return
formData.value = data
},
{
deep: true,
immediate: true
}
)
/** 生成表数据 */
const generateTableData = (propertyList: any[]) => {
// 构建数据结构
const propertyValues = propertyList.map((item) =>
item.values.map((v) => ({
propertyId: item.id,
propertyName: item.name,
valueId: v.id,
valueName: v.name
}))
)
// TODO @puhui:是不是 buildSkuList,这样容易理解一点哈。item 改成 sku
const buildList = build(propertyValues)
// 如果回显的 sku 属性和添加的属性不一致则重置 skus 列表
if (!validateData(propertyList)) {
// 如果不一致则重置表数据,默认添加新的属性重新生成 sku 列表
formData.value!.skus = []
}
for (const item of buildList) {
const row = {
properties: Array.isArray(item) ? item : [item], // 如果只有一个属性的话返回的是一个 property 对象
price: 0,
marketPrice: 0,
costPrice: 0,
barCode: '',
picUrl: '',
stock: 0,
weight: 0,
volume: 0,
subCommissionFirstPrice: 0,
subCommissionSecondPrice: 0
}
// 如果存在属性相同的 sku 则不做处理
const index = formData.value!.skus.findIndex(
(sku) => JSON.stringify(sku.properties) === JSON.stringify(row.properties)
)
if (index !== -1) {
continue
}
formData.value.skus.push(row)
}
}
/**
* 生成 skus 前置校验
*/
const validateData = (propertyList: any[]) => {
const skuPropertyIds = []
formData.value.skus.forEach((sku) =>
sku.properties
?.map((property) => property.propertyId)
.forEach((propertyId) => {
if (skuPropertyIds.indexOf(propertyId) === -1) {
skuPropertyIds.push(propertyId)
}
})
)
const propertyIds = propertyList.map((item) => item.id)
return skuPropertyIds.length === propertyIds.length
}
/** 构建所有排列组合 */
const build = (propertyValuesList: Property[][]) => {
if (propertyValuesList.length === 0) {
return []
} else if (propertyValuesList.length === 1) {
return propertyValuesList[0]
} else {
const result: Property[][] = []
const rest = build(propertyValuesList.slice(1))
for (let i = 0; i < propertyValuesList[0].length; i++) {
for (let j = 0; j < rest.length; j++) {
// 第一次不是数组结构,后面的都是数组结构
if (Array.isArray(rest[j])) {
result.push([propertyValuesList[0][i], ...rest[j]])
} else {
result.push([propertyValuesList[0][i], rest[j]])
}
}
}
return result
}
}
/** 监听属性列表,生成相关参数和表头 */
watch(
() => props.propertyList,
(propertyList) => {
// 如果不是多规格则结束
if (!formData.value.specType) {
return
}
// 如果当前组件作为批量添加数据使用,则重置表数据
if (props.isBatch) {
skuList.value = [
{
price: 0,
marketPrice: 0,
costPrice: 0,
barCode: '',
picUrl: '',
stock: 0,
weight: 0,
volume: 0,
subCommissionFirstPrice: 0,
subCommissionSecondPrice: 0
}
]
}
// 判断代理对象是否为空
if (JSON.stringify(propertyList) === '[]') {
return
}
// 重置表头
tableHeaders.value = []
// 生成表头
propertyList.forEach((item, index) => {
// name加属性项index区分属性值
tableHeaders.value.push({ prop: `name${index}`, label: item.name })
})
// 如果回显的 sku 属性和添加的属性一致则不处理
if (validateData(propertyList)) {
return
}
// 添加新属性没有属性值也不做处理
if (propertyList.some((item) => item.values.length === 0)) {
return
}
// 生成 table 数据,即 sku 列表
generateTableData(propertyList)
},
{
deep: true,
immediate: true
}
)
// 暴露出生成 sku 方法,给添加属性成功时调用
defineExpose({ generateTableData })
</script>
@@ -0,0 +1,15 @@
import BasicInfoForm from './BasicInfoForm.vue'
import DescriptionForm from './DescriptionForm.vue'
import OtherSettingsForm from './OtherSettingsForm.vue'
import ProductAttributes from './ProductAttributes.vue'
import ProductAttributesAddForm from './ProductAttributesAddForm.vue'
import SkuList from './SkuList.vue'
export {
BasicInfoForm,
DescriptionForm,
OtherSettingsForm,
ProductAttributes,
ProductAttributesAddForm,
SkuList
}
+422
View File
@@ -0,0 +1,422 @@
<template>
<!-- 搜索工作栏 -->
<ContentWrap>
<el-form
ref="queryFormRef"
:inline="true"
:model="queryParams"
class="-mb-15px"
label-width="68px"
>
<!-- TODO @puhui999:品牌应该是数据下拉哈 -->
<el-form-item label="品牌名称" prop="name">
<el-input
v-model="queryParams.name"
class="!w-240px"
clearable
placeholder="请输入品牌名称"
@keyup.enter="handleQuery"
/>
</el-form-item>
<!-- TODO 分类只能选择二级分类目前还没做,还是先以联调通顺为主 -->
<!-- TODO puhui999:我们要不改成支持选择一级。如果选择一级,后端要递归查询下子分类,然后去 in? -->
<el-form-item label="商品分类" prop="categoryId">
<el-tree-select
v-model="queryParams.categoryId"
:data="categoryList"
:props="defaultProps"
check-strictly
class="w-1/1"
node-key="id"
placeholder="请选择商品分类"
/>
</el-form-item>
<el-form-item label="创建时间" prop="createTime">
<el-date-picker
v-model="queryParams.createTime"
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
class="!w-240px"
end-placeholder="结束日期"
start-placeholder="开始日期"
type="daterange"
value-format="YYYY-MM-DD HH:mm:ss"
/>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery">
<Icon class="mr-5px" icon="ep:search" />
搜索
</el-button>
<el-button @click="resetQuery">
<Icon class="mr-5px" icon="ep:refresh" />
重置
</el-button>
<el-button v-hasPermi="['product:spu:create']" plain type="primary" @click="openForm">
<Icon class="mr-5px" icon="ep:plus" />
新增
</el-button>
<el-button
v-hasPermi="['product:spu:export']"
:loading="exportLoading"
plain
type="success"
@click="handleExport"
>
<Icon class="mr-5px" icon="ep:download" />
导出
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap>
<el-tabs v-model="queryParams.tabType" @tab-click="handleTabClick">
<el-tab-pane
v-for="item in tabsData"
:key="item.type"
:label="item.name + '(' + item.count + ')'"
:name="item.type"
/>
</el-tabs>
<el-table v-loading="loading" :data="list">
<!-- TODO puhui:这几个属性哈,一行三个
商品分类:服装鞋包/箱包
商品市场价格:100.00
成本价:0.00
收藏:5
虚拟销量:999 -->
<el-table-column type="expand" width="30">
<template #default="{ row }">
<el-form class="demo-table-expand" inline label-position="left">
<el-form-item label="市场价:">
<span>{{ formatToFraction(row.marketPrice) }}</span>
</el-form-item>
<el-form-item label="成本价:">
<span>{{ formatToFraction(row.costPrice) }}</span>
</el-form-item>
<el-form-item label="虚拟销量:">
<span>{{ row.virtualSalesCount }}</span>
</el-form-item>
</el-form>
</template>
</el-table-column>
<el-table-column key="id" align="center" label="商品编号" prop="id" />
<el-table-column label="商品图" min-width="80">
<template #default="{ row }">
<el-image :src="row.picUrl" @click="imagePreview(row.picUrl)" class="w-30px h-30px" />
</template>
</el-table-column>
<el-table-column :show-overflow-tooltip="true" label="商品名称" min-width="300" prop="name" />
<el-table-column align="center" label="商品售价" min-width="90" prop="price">
<template #default="{ row }">
{{ formatToFraction(row.price) }}
</template>
</el-table-column>
<el-table-column align="center" label="销量" min-width="90" prop="salesCount" />
<el-table-column align="center" label="库存" min-width="90" prop="stock" />
<el-table-column align="center" label="排序" min-width="70" prop="sort" />
<el-table-column
:formatter="dateFormatter"
align="center"
label="创建时间"
prop="createTime"
width="180"
/>
<el-table-column align="center" label="状态" min-width="80">
<template #default="{ row }">
<template v-if="row.status >= 0">
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
active-text="上架"
inactive-text="下架"
inline-prompt
@change="changeStatus(row)"
/>
</template>
<template v-else>
<el-tag type="info">回收站</el-tag>
</template>
</template>
</el-table-column>
<el-table-column align="center" fixed="right" label="操作" min-width="200">
<template #default="{ row }">
<!-- TODO @puhui999:【详情】,可以后面点做哈 -->
<el-button v-hasPermi="['product:spu:update']" link type="primary" @click="openDetail">
详情
</el-button>
<template v-if="queryParams.tabType === 4">
<el-button
v-hasPermi="['product:spu:delete']"
link
type="danger"
@click="handleDelete(row.id)"
>
删除
</el-button>
<el-button
v-hasPermi="['product:spu:update']"
link
type="primary"
@click="changeStatus(row, ProductSpuStatusEnum.DISABLE.status)"
>
恢复到仓库
</el-button>
</template>
<template v-else>
<!-- 只有不是上架和回收站的商品可以编辑 -->
<el-button
v-if="queryParams.tabType !== 0"
v-hasPermi="['product:spu:update']"
link
type="primary"
@click="openForm(row.id)"
>
修改
</el-button>
<el-button
v-hasPermi="['product:spu:update']"
link
type="primary"
@click="changeStatus(row, ProductSpuStatusEnum.RECYCLE.status)"
>
加入回收站
</el-button>
</template>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<Pagination
v-model:limit="queryParams.pageSize"
v-model:page="queryParams.pageNo"
:total="total"
@pagination="getList"
/>
</ContentWrap>
</template>
<script lang="ts" name="ProductSpu" setup>
import { TabsPaneContext } from 'element-plus'
import { cloneDeep } from 'lodash-es'
import { createImageViewer } from '@/components/ImageViewer'
import { dateFormatter } from '@/utils/formatTime'
import { defaultProps, handleTree } from '@/utils/tree'
import { ProductSpuStatusEnum } from '@/utils/constants'
import { formatToFraction } from '@/utils'
import download from '@/utils/download'
import * as ProductSpuApi from '@/api/mall/product/spu'
import * as ProductCategoryApi from '@/api/mall/product/category'
const message = useMessage() // 消息弹窗
const { t } = useI18n() // 国际化
const { currentRoute, push } = useRouter() // 路由跳转
const loading = ref(false) // 列表的加载中
const exportLoading = ref(false) // 导出的加载中
const total = ref(0) // 列表的总页数
const list = ref<any[]>([]) // 列表的数据
// tabs 数据
const tabsData = ref([
{
count: 0,
name: '出售中商品',
type: 0
},
{
count: 0,
name: '仓库中商品',
type: 1
},
{
count: 0,
name: '已经售空商品',
type: 2
},
{
count: 0,
name: '警戒库存',
type: 3
},
{
count: 0,
name: '商品回收站',
type: 4
}
])
/** 获得每个 Tab 的数量 */
const getTabsCount = async () => {
const res = await ProductSpuApi.getTabsCount()
for (let objName in res) {
tabsData.value[Number(objName)].count = res[objName]
}
}
const queryParams = ref({
pageNo: 1,
pageSize: 10,
tabType: 0
}) // 查询参数
const queryFormRef = ref() // 搜索的表单Ref
const handleTabClick = (tab: TabsPaneContext) => {
queryParams.value.tabType = tab.paneName
getList()
}
/** 查询列表 */
const getList = async () => {
loading.value = true
try {
const data = await ProductSpuApi.getSpuPage(queryParams.value)
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
/**
* 更改 SPU 状态
*
* @param row
* @param status 更改前的值
*/
const changeStatus = async (row, status?: number) => {
const deepCopyValue = cloneDeep(unref(row))
if (typeof status !== 'undefined') deepCopyValue.status = status
try {
let text = ''
switch (deepCopyValue.status) {
case ProductSpuStatusEnum.DISABLE.status:
text = ProductSpuStatusEnum.DISABLE.name
break
case ProductSpuStatusEnum.ENABLE.status:
text = ProductSpuStatusEnum.ENABLE.name
break
case ProductSpuStatusEnum.RECYCLE.status:
text = `加入${ProductSpuStatusEnum.RECYCLE.name}`
break
}
await message.confirm(
deepCopyValue.status === -1
? `确认要将[${row.name}]${text}吗?`
: row.status === -1 // 再判断一次原对象是否等于-1,例: 把回收站中的商品恢复到仓库中,事件触发时原对象status为-1 深拷贝对象status被赋值为0
? `确认要将[${row.name}]恢复到仓库吗?`
: `确认要${text}[${row.name}]吗?`
)
await ProductSpuApi.updateStatus({ id: deepCopyValue.id, status: deepCopyValue.status })
message.success('更新状态成功')
// 刷新 tabs 数据
await getTabsCount()
// 刷新列表
await getList()
} catch {
// 取消更改状态时回显数据
row.status =
row.status === ProductSpuStatusEnum.DISABLE.status
? ProductSpuStatusEnum.ENABLE.status
: ProductSpuStatusEnum.DISABLE.status
}
}
/** 删除按钮操作 */
const handleDelete = async (id: number) => {
try {
// 删除的二次确认
await message.delConfirm()
// 发起删除
await ProductSpuApi.deleteSpu(id)
message.success(t('common.delSuccess'))
// 刷新tabs数据
await getTabsCount()
// 刷新列表
await getList()
} catch {}
}
/** 商品图预览 */
const imagePreview = (imgUrl: string) => {
createImageViewer({
urlList: [imgUrl]
})
}
/** 搜索按钮操作 */
const handleQuery = () => {
getList()
}
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value.resetFields()
handleQuery()
}
/**
* 新增或修改
*
* @param id 商品 SPU 编号
*/
const openForm = (id?: number) => {
// 修改
if (typeof id === 'number') {
push('/product/productSpuEdit/' + id)
return
}
// 新增
push('/product/productSpuAdd')
}
/**
* 查看商品详情
*/
const openDetail = () => {
message.alert('查看详情未完善!!!')
}
/** 导出按钮操作 */
const handleExport = async () => {
try {
// 导出的二次确认
await message.exportConfirm()
// 发起导出
exportLoading.value = true
const data = await ProductSpuApi.exportSpu(queryParams)
download.excel(data, '商品列表.xls')
} catch {
} finally {
exportLoading.value = false
}
}
// 监听路由变化更新列表 TODO @puhui999:这个是必须加的么?fix: 因为编辑表单是以路由的方式打开,保存表单后列表不会刷新
watch(
() => currentRoute.value,
() => {
getList()
}
)
const categoryList = ref() // 分类树
/** 初始化 **/
onMounted(async () => {
await getTabsCount()
await getList()
// 获得分类树
const data = await ProductCategoryApi.getCategoryList({})
categoryList.value = handleTree(data, 'id', 'parentId')
})
</script>
<style lang="scss" scoped>
.demo-table-expand {
padding-left: 42px;
:deep(.el-form-item__label) {
width: 82px;
font-weight: bold;
color: #99a9bf;
}
}
</style>
@@ -0,0 +1,124 @@
<template>
<Dialog :title="dialogTitle" v-model="dialogVisible">
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="120px"
v-loading="formLoading"
>
<el-form-item label="快递公司编码" prop="code">
<el-input v-model="formData.code" placeholder="请输入快递编码" />
</el-form-item>
<el-form-item label="快递公司名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入快递名称" />
</el-form-item>
<el-form-item label="快递公司 logo" prop="logo">
<UploadImg v-model="formData.logo" :limit="1" :is-show-tip="false" />
<div style="font-size: 10px" class="pl-10px">推荐 180x180 图片分辨率</div>
</el-form-item>
<el-form-item label="分类排序" prop="sort">
<el-input-number v-model="formData.sort" controls-position="right" :min="0" />
</el-form-item>
<el-form-item label="开启状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
:key="dict.value"
:label="dict.value"
>
{{ dict.label }}
</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
<el-button @click="dialogVisible = false">取 消</el-button>
</template>
</Dialog>
</template>
<script setup lang="ts" name="ExpressForm">
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import { CommonStatusEnum } from '@/utils/constants'
import * as DeliveryExpressApi from '@/api/mall/trade/delivery/express'
const { t } = useI18n() // 国际化
const message = useMessage() // 消息弹窗
const dialogVisible = ref(false) // 弹窗的是否展示
const dialogTitle = ref('') // 弹窗的标题
const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
const formType = ref('') // 表单的类型:create - 新增;update - 修改
const formData = ref({
id: undefined,
code: '',
name: '',
logo: '',
sort: 0,
status: CommonStatusEnum.ENABLE
})
const formRules = reactive({
code: [{ required: true, message: '快递编码不能为空', trigger: 'blur' }],
name: [{ required: true, message: '分类名称不能为空', trigger: 'blur' }],
logo: [{ required: true, message: '分类图片不能为空', trigger: 'blur' }],
sort: [{ required: true, message: '分类排序不能为空', trigger: 'blur' }],
status: [{ required: true, message: '开启状态不能为空', trigger: 'blur' }]
})
const formRef = ref() // 表单 Ref
/** 打开弹窗 */
const open = async (type: string, id?: number) => {
dialogVisible.value = true
dialogTitle.value = t('action.' + type)
formType.value = type
resetForm()
// 修改时,设置数据
if (id) {
formLoading.value = true
try {
formData.value = await DeliveryExpressApi.getDeliveryExpress(id)
} finally {
formLoading.value = false
}
}
}
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
/** 提交表单 */
const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
const submitForm = async () => {
// 校验表单
if (!formRef) return
const valid = await formRef.value.validate()
if (!valid) return
// 提交请求
formLoading.value = true
try {
const data = formData.value as DeliveryExpressApi.DeliveryExpressVO
if (formType.value === 'create') {
await DeliveryExpressApi.createDeliveryExpress(data)
message.success(t('common.createSuccess'))
} else {
await DeliveryExpressApi.updateDeliveryExpress(data)
message.success(t('common.updateSuccess'))
}
dialogVisible.value = false
// 发送操作成功的事件
emit('success')
} finally {
formLoading.value = false
}
}
/** 重置表单 */
const resetForm = () => {
formData.value = {
id: undefined,
name: '',
picUrl: '',
bigPicUrl: '',
status: CommonStatusEnum.ENABLE
}
formRef.value?.resetFields()
}
</script>
@@ -0,0 +1,184 @@
<template>
<!-- 搜索工作栏 -->
<ContentWrap>
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="100px"
>
<el-form-item label="快递公司编号" prop="code">
<el-input
v-model="queryParams.code"
placeholder="请输快递公司编号"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="快递公司名称" prop="name">
<el-input
v-model="queryParams.name"
placeholder="请输快递公司名称"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
<el-button
type="primary"
plain
@click="openForm('create')"
v-hasPermi="['trade:delivery:express:create']"
>
<Icon icon="ep:plus" class="mr-5px" /> 新增
</el-button>
<el-button
type="success"
plain
@click="handleExport"
:loading="exportLoading"
v-hasPermi="['trade:delivery:express:export']"
>
<Icon icon="ep:download" class="mr-5px" /> 导出
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap>
<el-table v-loading="loading" :data="list">
<el-table-column label="快递公司编号" prop="code" />
<el-table-column label="快递公司名称" prop="name" />
<el-table-column label="快递公司 logo " prop="logo">
<template #default="scope">
<img v-if="scope.row.logo" :src="scope.row.logo" alt="快递公司logo" class="h-100px" />
</template>
</el-table-column>
<el-table-column label="排序" align="center" prop="sort" />
<el-table-column label="开启状态" align="center" prop="status">
<template #default="scope">
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
</template>
</el-table-column>
<el-table-column
label="创建时间"
align="center"
prop="createTime"
width="180"
:formatter="dateFormatter"
/>
<el-table-column label="操作" align="center">
<template #default="scope">
<el-button
link
type="primary"
@click="openForm('update', scope.row.id)"
v-hasPermi="['trade:delivery:express:update']"
>
编辑
</el-button>
<el-button
link
type="danger"
@click="handleDelete(scope.row.id)"
v-hasPermi="['trade:delivery:express:delete']"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</ContentWrap>
<!-- 表单弹窗:添加/修改 -->
<ExpressForm ref="formRef" @success="getList" />
</template>
<script setup lang="ts" name="Express">
import { DICT_TYPE } from '@/utils/dict'
import { dateFormatter } from '@/utils/formatTime'
import download from '@/utils/download'
import * as DeliveryExpressApi from '@/api/mall/trade/delivery/express'
import ExpressForm from './ExpressForm.vue'
const message = useMessage() // 消息弹窗
const { t } = useI18n() // 国际化
const total = ref(0) // 列表的总页数
const loading = ref(true) // 列表的加载中
const list = ref<any[]>([]) // 列表的数据
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
code: '',
name: ''
})
const queryFormRef = ref() // 搜索的表单
const exportLoading = ref(false) // 导出的加载中
/** 查询列表 */
const getList = async () => {
loading.value = true
try {
const data = await DeliveryExpressApi.getDeliveryExpressPage(queryParams)
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value.resetFields()
handleQuery()
}
/** 添加/修改操作 */
const formRef = ref()
const openForm = (type: string, id?: number) => {
formRef.value.open(type, id)
}
/** 删除按钮操作 */
const handleDelete = async (id: number) => {
try {
// 删除的二次确认
await message.delConfirm()
// 发起删除
await DeliveryExpressApi.deleteDeliveryExpress(id)
message.success(t('common.delSuccess'))
// 刷新列表
await getList()
} catch {}
}
/** 导出按钮操作 */
const handleExport = async () => {
try {
// 导出的二次确认
await message.exportConfirm()
// 发起导出
exportLoading.value = true
const data = await DeliveryExpressApi.exportDeliveryExpressApi(queryParams)
download.excel(data, '快递公司.xls')
} catch {
} finally {
exportLoading.value = false
}
}
/** 初始化 **/
onMounted(() => {
getList()
})
</script>
@@ -0,0 +1,405 @@
<template>
<Dialog :title="dialogTitle" v-model="dialogVisible" width="80%">
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="80px"
v-loading="formLoading"
>
<el-form-item label="模板名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入模板名称" />
</el-form-item>
<el-form-item label="计费方式" prop="chargeMode">
<el-radio-group v-model="formData.chargeMode" @change="changeChargeMode">
<el-radio
v-for="dict in getIntDictOptions(DICT_TYPE.EXPRESS_CHARGE_MODE)"
:key="dict.value"
:label="dict.value"
>
{{ dict.label }}
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="运费" prop="templateCharge">
<el-table border style="width: 100%" :data="formData.templateCharge">
<el-table-column align="center" label="区域" width="180">
<template #default="{ row }">
<!-- 区域数据太多,用赖加载方式,要不然性能有问题 -->
<el-tree-select
v-model="row.areaIds"
lazy
:load="loadChargeArea"
:props="defaultProps"
multiple
node-key="id"
check-strictly
show-checkbox
check-on-click-node
:render-after-expand="false"
:cache-data="areaCache"
/>
</template>
</el-table-column>
<el-table-column
align="center"
:label="columnTitle.startCountTitle"
width="180"
prop="startCount"
>
<template #default="{ row }">
<el-input-number v-model="row.startCount" :min="1" />
</template>
</el-table-column>
<el-table-column width="180" align="center" label="运费(元)" prop="startPrice">
<template #default="{ row }">
<el-input-number v-model="row.startPrice" :min="1" />
</template>
</el-table-column>
<el-table-column
width="180"
align="center"
:label="columnTitle.extraCountTitle"
prop="extraCount"
>
<template #default="{ row }">
<el-input-number v-model="row.extraCount" :min="1" />
</template>
</el-table-column>
<el-table-column width="180" align="center" label="续费(元)" prop="extraPrice">
<template #default="{ row }">
<el-input-number v-model="row.extraPrice" :min="1" />
</template>
</el-table-column>
<el-table-column label="操作" align="center">
<template #default="scope">
<el-button link type="danger" @click="deleteChargeArea(scope.$index)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-form-item>
<el-form-item>
<el-button type="primary" plain @click="addChargeArea()">
<Icon icon="ep:plus" class="mr-5px" /> 添加区域
</el-button>
</el-form-item>
<el-form-item label="包邮区域" prop="templateFree">
<el-table border style="width: 100%" :data="formData.templateFree">
<el-table-column align="center" label="区域">
<template #default="{ row }">
<!-- 区域数据太多,用赖加载方式,要不然性能有问题 -->
<el-tree-select
v-model="row.areaIds"
multiple
lazy
:load="loadFreeArea"
:props="defaultProps"
node-key="id"
check-strictly
show-checkbox
check-on-click-node
:render-after-expand="true"
:cache-data="areaCache"
/>
</template>
</el-table-column>
<el-table-column align="center" :label="columnTitle.freeCountTitle" prop="freeCount">
<template #default="{ row }">
<el-input-number v-model="row.freeCount" :min="1" />
</template>
</el-table-column>
<el-table-column align="center" label="包邮金额(元)" prop="freePrice">
<template #default="{ row }">
<el-input-number v-model="row.freePrice" :min="1" />
</template>
</el-table-column>
<el-table-column label="操作" align="center">
<template #default="scope">
<el-button link type="danger" @click="deleteFreeArea(scope.$index)"> 删除 </el-button>
</template>
</el-table-column>
</el-table>
</el-form-item>
<el-form-item>
<el-button type="primary" plain @click="addFreeArea()">
<Icon icon="ep:plus" class="mr-5px" /> 添加区域
</el-button>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="formData.sort" controls-position="right" :min="0" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
<el-button @click="dialogVisible = false">取 消</el-button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import * as DeliveryExpressTemplateApi from '@/api/mall/trade/delivery/expressTemplate'
import { defaultProps } from '@/utils/tree'
import { yuanToFen, fenToYuan } from '@/utils'
import { getChildrenArea, getAreaListByIds } from '@/api/system/area'
import { cloneDeep } from 'lodash-es'
const { t } = useI18n() // 国际化
const message = useMessage() // 消息弹窗
const dialogVisible = ref(false) // 弹窗的是否展示
const dialogTitle = ref('') // 弹窗的标题
const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
const formType = ref('') // 表单的类型:create - 新增;update - 修改
const formData = ref({
id: undefined,
name: '',
chargeMode: 1,
sort: 0,
templateCharge: [],
templateFree: []
})
const columnTitleMap = new Map()
const columnTitle = ref({
startCountTitle: '首件',
extraCountTitle: '续件',
freeCountTitle: '包邮件数'
})
const formRules = reactive({
name: [{ required: true, message: '模板名称不能为空', trigger: 'blur' }],
chargeMode: [{ required: true, message: '配送计费方式不能为空', trigger: 'blur' }],
sort: [{ required: true, message: '分类排序不能为空', trigger: 'blur' }]
})
const formRef = ref() // 表单 Ref
const areaCache = ref([]) //由于区域节点懒加载,已选区域节点需要缓存展示
/** 打开弹窗 */
const open = async (type: string, id?: number) => {
dialogVisible.value = true
dialogTitle.value = t('action.' + type)
formType.value = type
resetForm()
try {
// 修改时,设置数据
if (id) {
formLoading.value = true
formData.value = await DeliveryExpressTemplateApi.getDeliveryExpressTemplate(id)
columnTitle.value = columnTitleMap.get(formData.value.chargeMode)
const chargeAreaIds = []
const freeAreaIds = []
formData.value.templateCharge.forEach((item) => {
for (let i = 0; i < item.areaIds.length; i++) {
if (!chargeAreaIds.includes(item.areaIds[i])) {
chargeAreaIds.push(item.areaIds[i])
}
}
//前端价格以元展示
item.startPrice = fenToYuan(item.startPrice)
item.extraPrice = fenToYuan(item.extraPrice)
})
formData.value.templateFree.forEach((item) => {
for (let i = 0; i < item.areaIds.length; i++) {
if (!chargeAreaIds.includes(item.areaIds[i]) && !freeAreaIds.includes(item.areaIds[i])) {
freeAreaIds.push(item.areaIds[i])
}
}
item.freePrice = fenToYuan(item.freePrice)
})
//已选的区域节点
const areaIds = chargeAreaIds.concat(freeAreaIds)
//区域节点,懒加载方式。 已选节点需要缓存展示
areaCache.value = await getAreaListByIds(areaIds.join(','))
}
} finally {
formLoading.value = false
}
}
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
/** 提交表单 */
const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
const submitForm = async () => {
// 校验表单
if (!formRef) return
const valid = await formRef.value.validate()
if (!valid) return
// 提交请求
formLoading.value = true
try {
const data = formData.value as DeliveryExpressTemplateApi.DeliveryExpressTemplateVO
data.templateCharge.forEach((item) => {
//前端价格以元展示,提交到后端。用分计算
item.startPrice = yuanToFen(item.startPrice)
item.extraPrice = yuanToFen(item.extraPrice)
})
data.templateFree.forEach((item) => {
item.freePrice = yuanToFen(item.freePrice)
})
if (formType.value === 'create') {
await DeliveryExpressTemplateApi.createDeliveryExpressTemplate(data)
message.success(t('common.createSuccess'))
} else {
await DeliveryExpressTemplateApi.updateDeliveryExpressTemplate(data)
message.success(t('common.updateSuccess'))
}
dialogVisible.value = false
// 发送操作成功的事件
emit('success')
} finally {
formLoading.value = false
}
}
/** 重置表单 */
const resetForm = () => {
formData.value = {
id: undefined,
name: '',
chargeMode: 1,
templateCharge: [
{
areaIds: [1],
startCount: 2,
startPrice: 5,
extraCount: 5,
extraPrice: 10
}
],
templateFree: [],
sort: 0
}
columnTitle.value = columnTitleMap.get(1)
formRef.value?.resetFields()
}
/** 配送计费方法改变 */
const changeChargeMode = (chargeMode: number) => {
columnTitle.value = columnTitleMap.get(chargeMode)
}
const defaultArea = [{ id: 1, name: '全国', disabled: false }]
/** 初始化数据 */
const initData = async () => {
// TODO 从服务端全量加载数据, 后面看懒加载是不是可以从前端获取数据。 目前从后端获取数据
// formLoading.value = true
// try {
// const data = await getAreaTree()
// areaTree = data
// console.log('areaTree', areaTree)
// } finally {
// formLoading.value = false
// }
//表头标题和计费方式的映射
columnTitleMap.set(1, {
startCountTitle: '首件',
extraCountTitle: '续件',
freeCountTitle: '包邮件数'
})
columnTitleMap.set(2, {
startCountTitle: '首件重量(kg)',
extraCountTitle: '续件重量(kg)',
freeCountTitle: '包邮重量(kg)'
})
columnTitleMap.set(3, {
startCountTitle: '首件体积(m³)',
extraCountTitle: '续件体积(m³)',
freeCountTitle: '包邮体积(m³)'
})
}
/** 懒加载运费区域树 */
const loadChargeArea = async (node, resolve) => {
//已选区域需要禁止再次选择
const areaIds = []
formData.value.templateCharge.forEach((item) => {
if (item.areaIds.length > 0) {
item.areaIds.forEach((areaId) => areaIds.push(areaId))
}
})
if (node.isLeaf) return resolve([])
const length = node.data.length
if (length === 0) {
const data = cloneDeep(defaultArea)
const item = data[0]
if (areaIds.includes(item.id)) {
// TODO 禁止选中的区域有些问题, 导致修改时候不能重新选择 不知道如何处理。 暂时注释掉 @芋艿 有空瞅瞅
//item.disabled = true
}
resolve(data)
} else {
const id = node.data.id
const data = await getChildrenArea(id)
data.forEach((item) => {
if (areaIds.includes(item.id)) {
//item.disabled = true
}
})
resolve(data)
}
}
/** 懒加载包邮区域树 */
const loadFreeArea = async (node, resolve) => {
if (node.isLeaf) return resolve([])
//已选区域需要禁止再次选择
const areaIds = []
formData.value.templateFree.forEach((item) => {
if (item.areaIds.length > 0) {
item.areaIds.forEach((areaId) => areaIds.push(areaId))
}
})
const length = node.data.length
if (length === 0) {
// 为空,从全国开始选择。全国 id == 1
const data = cloneDeep(defaultArea)
const item = data[0]
if (areaIds.includes(item.id)) {
//item.disabled = true
}
resolve(data)
} else {
const id = node.data.id
const data = await getChildrenArea(id)
//已选区域需要禁止再次选择
data.forEach((item) => {
if (areaIds.includes(item.id)) {
// TODO 禁止选中的区域有些问题, 导致修改时候不能重新选择 不知道如何处理。 暂时注释掉 @芋艿 有空瞅瞅
//item.disabled = true
}
})
resolve(data)
}
}
/** 添加计费区域 */
const addChargeArea = () => {
const data = formData.value
data.templateCharge.push({
areaIds: [],
startCount: 1,
startPrice: 1,
extraCount: 1,
extraPrice: 1
})
}
/** 删除计费区域 */
const deleteChargeArea = (index) => {
const data = formData.value
data.templateCharge.splice(index, 1)
}
/** 添加包邮区域 */
const addFreeArea = () => {
const data = formData.value
data.templateFree.push({
areaIds: [],
freeCount: 1,
freePrice: 1
})
}
/** 删除包邮区域 */
const deleteFreeArea = (index) => {
const data = formData.value
data.templateFree.splice(index, 1)
}
/** 初始化 **/
onMounted(() => {
initData()
})
</script>
@@ -0,0 +1,160 @@
<template>
<!-- 搜索工作栏 -->
<ContentWrap>
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="100px"
>
<el-form-item label="模板名称" prop="name">
<el-input
v-model="queryParams.name"
placeholder="请输入模板名称"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="计费方式" prop="chargeMode">
<el-select
v-model="queryParams.chargeMode"
placeholder="计费方式"
clearable
class="!w-240px"
>
<el-option
v-for="dict in getIntDictOptions(DICT_TYPE.EXPRESS_CHARGE_MODE)"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
<el-button
type="primary"
plain
@click="openForm('create')"
v-hasPermi="['trade:delivery:express-template:create']"
>
<Icon icon="ep:plus" class="mr-5px" />
新增
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap>
<el-table v-loading="loading" :data="list">
<el-table-column label="编号" prop="id" />
<el-table-column label="模板名称" prop="name" />
<el-table-column label="计费方式" prop="chargeMode" align="center">
<template #default="scope">
<dict-tag :type="DICT_TYPE.EXPRESS_CHARGE_MODE" :value="scope.row.chargeMode" />
</template>
</el-table-column>
<el-table-column label="排序" prop="sort" />
<el-table-column
label="创建时间"
align="center"
prop="createTime"
width="180"
:formatter="dateFormatter"
/>
<el-table-column label="操作" align="center">
<template #default="scope">
<el-button
link
type="primary"
@click="openForm('update', scope.row.id)"
v-hasPermi="['trade:delivery:express-template:update']"
>
编辑
</el-button>
<el-button
link
type="danger"
@click="handleDelete(scope.row.id)"
v-hasPermi="['trade:delivery:express-template:delete']"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</ContentWrap>
<!-- 表单弹窗:添加/修改 -->
<ExpressTemplateForm ref="formRef" @success="getList" />
</template>
<script setup lang="ts" name="DeliveryExpressTemplate">
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import { dateFormatter } from '@/utils/formatTime'
import * as DeliveryExpressTemplateApi from '@/api/mall/trade/delivery/expressTemplate'
import ExpressTemplateForm from './ExpressTemplateForm.vue'
const message = useMessage() // 消息弹窗
const { t } = useI18n() // 国际化
const total = ref(0) // 列表的总页数
const loading = ref(true) // 列表的加载中
const list = ref<any[]>([]) // 列表的数据
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
name: '',
chargeMode: undefined
})
const queryFormRef = ref() // 搜索的表单
/** 查询列表 */
const getList = async () => {
loading.value = true
try {
const data = await DeliveryExpressTemplateApi.getDeliveryExpressTemplatePage(queryParams)
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value.resetFields()
handleQuery()
}
/** 添加/修改操作 */
const formRef = ref()
const openForm = (type: string, id?: number) => {
formRef.value.open(type, id)
}
/** 删除按钮操作 */
const handleDelete = async (id: number) => {
try {
// 删除的二次确认
await message.delConfirm()
// 发起删除
await DeliveryExpressTemplateApi.deleteDeliveryExpressTemplate(id)
message.success(t('common.delSuccess'))
// 刷新列表
await getList()
} catch {}
}
/** 初始化 **/
onMounted(() => {
getList()
})
</script>
+1 -1
View File
@@ -46,7 +46,7 @@
v-if="scope.row.qrCodeUrl"
:src="scope.row.qrCodeUrl"
alt="二维码"
style="height: 100px; display: inline-block"
style="display: inline-block; height: 100px"
/>
<el-button
link
@@ -0,0 +1,78 @@
<template>
<div>
<el-form ref="formRef" :model="replyForm" :rules="rules" label-width="80px">
<el-form-item label="消息类型" prop="requestMessageType" v-if="msgType === MsgType.Message">
<el-select v-model="replyForm.requestMessageType" placeholder="请选择">
<template v-for="dict in getDictOptions(DICT_TYPE.MP_MESSAGE_TYPE)" :key="dict.value">
<el-option
v-if="RequestMessageTypes.includes(dict.value)"
:label="dict.label"
:value="dict.value"
/>
</template>
</el-select>
</el-form-item>
<el-form-item label="匹配类型" prop="requestMatch" v-if="msgType === MsgType.Keyword">
<el-select v-model="replyForm.requestMatch" placeholder="请选择匹配类型" clearable>
<el-option
v-for="dict in getIntDictOptions(DICT_TYPE.MP_AUTO_REPLY_REQUEST_MATCH)"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item label="关键词" prop="requestKeyword" v-if="msgType === MsgType.Keyword">
<el-input v-model="replyForm.requestKeyword" placeholder="请输入内容" clearable />
</el-form-item>
<el-form-item label="回复消息">
<WxReplySelect v-model="reply" />
</el-form-item>
</el-form>
</div>
</template>
<script setup lang="ts" name="ReplyForm">
import WxReplySelect, { type Reply } from '@/views/mp/components/wx-reply'
import type { FormInstance } from 'element-plus'
import { MsgType } from './types'
import { DICT_TYPE, getDictOptions, getIntDictOptions } from '@/utils/dict'
const props = defineProps<{
modelValue: any
reply: Reply
msgType: MsgType
}>()
const emit = defineEmits<{
(e: 'update:reply', v: Reply)
(e: 'update:modelValue', v: any)
}>()
const reply = computed<Reply>({
get: () => props.reply,
set: (val) => emit('update:reply', val)
})
const replyForm = computed<any>({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const formRef = ref<FormInstance | null>(null) // 表单 ref
const RequestMessageTypes = ['text', 'image', 'voice', 'video', 'shortvideo', 'location', 'link'] // 允许选择的请求消息类型
// 表单校验
const rules = {
requestKeyword: [{ required: true, message: '请求的关键字不能为空', trigger: 'blur' }],
requestMatch: [{ required: true, message: '请求的关键字的匹配不能为空', trigger: 'blur' }]
}
defineExpose({
resetFields: () => formRef.value?.resetFields(),
validate: async () => formRef.value?.validate()
})
</script>
<style scoped></style>
@@ -94,10 +94,10 @@
</el-table>
</template>
<script setup lang="ts">
import WxVideoPlayer from '@/views/mp/components/wx-video-play/main.vue'
import WxVoicePlayer from '@/views/mp/components/wx-voice-play/main.vue'
import WxMusic from '@/views/mp/components/wx-music/main.vue'
import WxNews from '@/views/mp/components/wx-news/main.vue'
import WxVideoPlayer from '@/views/mp/components/wx-video-play'
import WxVoicePlayer from '@/views/mp/components/wx-voice-play'
import WxMusic from '@/views/mp/components/wx-music'
import WxNews from '@/views/mp/components/wx-news'
import { dateFormatter } from '@/utils/formatTime'
import { DICT_TYPE } from '@/utils/dict'
import { MsgType } from './types'
@@ -5,44 +5,3 @@ export enum MsgType {
Message = 2,
Keyword = 3
}
type ReplyType = 'text' | 'image' | 'voice' | 'video' | 'shortvideo' | 'location' | 'link'
export interface ReplyForm {
// relation:
id?: number
accountId?: number
type?: MsgType
// request:
requestMessageType?: ReplyType
requestMatch?: number
requestKeyword?: string
// response:
responseMessageType?: ReplyType
responseContent?: string
responseMediaId?: number
responseMediaUrl?: string
responseTitle?: string
responseDescription?: number
responseThumbMediaId?: string
responseThumbMediaUrl?: string
responseArticles?: any[]
responseMusicUrl?: string
responseHqMusicUrl?: string
}
// TODO @Dhb52:ObjData 这个类名可以在看看,ObjData 有点通用
export interface ObjData {
type: ReplyType
accountId?: number
content?: string
mediaId?: number
url?: string
title?: string
description?: string
thumbMediaId?: number
thumbMediaUrl?: string
articles?: any[]
musicUrl?: string
hqMusicUrl?: string
}
+46 -80
View File
@@ -53,38 +53,13 @@
@on-delete="onDelete"
/>
<!-- 添加或修改自动回复的对话框 -->
<!-- TODO @Dhb52 -->
<el-dialog :title="dialogTitle" v-model="showFormDialog" width="800px" destroy-on-close>
<el-form ref="formRef" :model="replyForm" :rules="rules" label-width="80px">
<el-form-item label="消息类型" prop="requestMessageType" v-if="msgType === MsgType.Message">
<el-select v-model="replyForm.requestMessageType" placeholder="请选择">
<template v-for="dict in getDictOptions(DICT_TYPE.MP_MESSAGE_TYPE)" :key="dict.value">
<el-option
v-if="RequestMessageTypes.includes(dict.value)"
:label="dict.label"
:value="dict.value"
/>
</template>
</el-select>
</el-form-item>
<el-form-item label="匹配类型" prop="requestMatch" v-if="msgType === MsgType.Keyword">
<el-select v-model="replyForm.requestMatch" placeholder="请选择匹配类型" clearable>
<el-option
v-for="dict in getIntDictOptions(DICT_TYPE.MP_AUTO_REPLY_REQUEST_MATCH)"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item label="关键词" prop="requestKeyword" v-if="msgType === MsgType.Keyword">
<el-input v-model="replyForm.requestKeyword" placeholder="请输入内容" clearable />
</el-form-item>
<el-form-item label="回复消息">
<WxReplySelect :objData="objData" />
</el-form-item>
</el-form>
<el-dialog
:title="isCreating ? '新增自动回复' : '修改自动回复'"
v-model="showDialog"
width="800px"
destroy-on-close
>
<ReplyForm v-model="replyForm" v-model:reply="reply" :msg-type="msgType" ref="formRef" />
<template #footer>
<el-button @click="cancel">取 消</el-button>
<el-button type="primary" @click="onSubmit">确 定</el-button>
@@ -93,51 +68,43 @@
</ContentWrap>
</template>
<script setup lang="ts" name="MpAutoReply">
import WxReplySelect from '@/views/mp/components/wx-reply/main.vue'
import WxAccountSelect from '@/views/mp/components/wx-account-select/main.vue'
import ReplyForm from '@/views/mp/autoReply/components/ReplyForm.vue'
import { type Reply, ReplyType } from '@/views/mp/components/wx-reply'
import WxAccountSelect from '@/views/mp/components/wx-account-select'
import * as MpAutoReplyApi from '@/api/mp/autoReply'
import { DICT_TYPE, getDictOptions, getIntDictOptions } from '@/utils/dict'
import { ContentWrap } from '@/components/ContentWrap'
import type { TabPaneName } from 'element-plus'
import ReplyTable from './components/ReplyTable.vue'
import { MsgType, ReplyForm, ObjData } from './components/types'
import { MsgType } from './components/types'
const message = useMessage() // 消息
const accountId = ref(-1) // 公众号ID
const msgType = ref<MsgType>(MsgType.Keyword) // 消息类型
const RequestMessageTypes = ['text', 'image', 'voice', 'video', 'shortvideo', 'location', 'link'] // 允许选择的请求消息类型
const loading = ref(true) // 遮罩层
const total = ref(0) // 总条数
const list = ref<any[]>([]) // 自动回复列表
const formRef = ref() // 表单 ref
const formRef = ref<InstanceType<typeof ReplyForm> | null>(null) // 表单 ref
// 查询参数
interface QueryParams {
pageNo: number
pageSize: number
accountId?: number
}
const queryParams: QueryParams = reactive({
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
accountId: undefined
accountId: accountId
})
const dialogTitle = ref('') // 弹出层标题
const showFormDialog = ref(false) // 是否显示弹出层
const replyForm = ref<ReplyForm>({}) // 表单参数
const isCreating = ref(false) // 是否新建(否则编辑)
const showDialog = ref(false) // 是否显示弹出层
const replyForm = ref<any>({}) // 表单参数
// 回复消息
const objData = ref<ObjData>({
type: 'text',
accountId: undefined
const reply = ref<Reply>({
type: ReplyType.Text,
accountId: -1
})
// 表单校验
const rules = {
requestKeyword: [{ required: true, message: '请求的关键字不能为空', trigger: 'blur' }],
requestMatch: [{ required: true, message: '请求的关键字的匹配不能为空', trigger: 'blur' }]
}
/** 侦听账号变化 */
const onAccountChanged = (id?: number) => {
queryParams.accountId = id
const onAccountChanged = (id: number) => {
accountId.value = id
reply.value.accountId = id
queryParams.pageNo = 1
getList()
}
@@ -171,13 +138,13 @@ const onTabChange = (tabName: TabPaneName) => {
const onCreate = () => {
reset()
// 打开表单,并设置初始化
objData.value = {
type: 'text',
reply.value = {
type: ReplyType.Text,
accountId: queryParams.accountId
}
dialogTitle.value = '新增自动回复'
showFormDialog.value = true
isCreating.value = true
showDialog.value = true
}
/** 修改按钮操作 */
@@ -193,7 +160,7 @@ const onUpdate = async (id: number) => {
delete replyForm.value['responseMediaUrl']
delete replyForm.value['responseDescription']
delete replyForm.value['responseArticles']
objData.value = {
reply.value = {
type: data.responseMessageType,
accountId: queryParams.accountId,
content: data.responseContent,
@@ -209,8 +176,8 @@ const onUpdate = async (id: number) => {
}
// 打开表单
dialogTitle.value = '修改自动回复'
showFormDialog.value = true
isCreating.value = false
showDialog.value = true
}
/** 删除按钮操作 */
@@ -222,22 +189,21 @@ const onDelete = async (id: number) => {
}
const onSubmit = async () => {
const valid = await formRef.value?.validate()
if (!valid) return
await formRef.value?.validate()
// 处理回复消息
const submitForm: any = { ...replyForm.value }
submitForm.responseMessageType = objData.value.type
submitForm.responseContent = objData.value.content
submitForm.responseMediaId = objData.value.mediaId
submitForm.responseMediaUrl = objData.value.url
submitForm.responseTitle = objData.value.title
submitForm.responseDescription = objData.value.description
submitForm.responseThumbMediaId = objData.value.thumbMediaId
submitForm.responseThumbMediaUrl = objData.value.thumbMediaUrl
submitForm.responseArticles = objData.value.articles
submitForm.responseMusicUrl = objData.value.musicUrl
submitForm.responseHqMusicUrl = objData.value.hqMusicUrl
submitForm.responseMessageType = reply.value.type
submitForm.responseContent = reply.value.content
submitForm.responseMediaId = reply.value.mediaId
submitForm.responseMediaUrl = reply.value.url
submitForm.responseTitle = reply.value.title
submitForm.responseDescription = reply.value.description
submitForm.responseThumbMediaId = reply.value.thumbMediaId
submitForm.responseThumbMediaUrl = reply.value.thumbMediaUrl
submitForm.responseArticles = reply.value.articles
submitForm.responseMusicUrl = reply.value.musicUrl
submitForm.responseHqMusicUrl = reply.value.hqMusicUrl
if (replyForm.value.id !== undefined) {
await MpAutoReplyApi.updateAutoReply(submitForm)
@@ -247,7 +213,7 @@ const onSubmit = async () => {
message.success('新增成功')
}
showFormDialog.value = false
showDialog.value = false
await getList()
}
@@ -266,7 +232,7 @@ const reset = () => {
// 取消按钮
const cancel = () => {
showFormDialog.value = false
showDialog.value = false
reset()
}
</script>
@@ -0,0 +1,3 @@
import WxAccountSelect from './main.vue'
export default WxAccountSelect
@@ -8,13 +8,14 @@
import * as MpAccountApi from '@/api/mp/account'
const account: MpAccountApi.AccountVO = reactive({
id: undefined,
id: -1,
name: ''
})
const accountList: Ref<MpAccountApi.AccountVO[]> = ref([])
const accountList = ref<MpAccountApi.AccountVO[]>([])
const emit = defineEmits<{
(e: 'change', id?: number, name?: string): void
(e: 'change', id: number, name: string)
}>()
const handleQuery = async () => {
@@ -22,15 +23,19 @@ const handleQuery = async () => {
// 默认选中第一个
if (accountList.value.length > 0) {
account.id = accountList.value[0].id
account.name = accountList.value[0].name
emit('change', account.id, account.name)
if (account.id) {
account.name = accountList.value[0].name
emit('change', account.id, account.name)
}
}
}
const onChanged = (id?: number) => {
const found = accountList.value.find((v) => v.id === id)
account.name = found ? found.name : ''
emit('change', account.id, account.name)
if (account.id) {
account.name = found ? found.name : ''
emit('change', account.id, account.name)
}
}
/** 初始化 */
@@ -0,0 +1,3 @@
import WxLocation from './main.vue'
export default WxLocation
@@ -0,0 +1,6 @@
import WxMaterialSelect from './main.vue'
import { NewsType, MaterialType } from './types'
export { NewsType, MaterialType }
export default WxMaterialSelect
@@ -7,7 +7,7 @@
<template>
<div class="pb-30px">
<!-- 类型:image -->
<div v-if="objData.type === 'image'">
<div v-if="props.type === 'image'">
<div class="waterfall" v-loading="loading">
<div class="waterfall-item" v-for="item in list" :key="item.mediaId">
<img class="material-img" :src="item.url" />
@@ -29,7 +29,7 @@
/>
</div>
<!-- 类型:voice -->
<div v-else-if="objData.type === 'voice'">
<div v-else-if="props.type === 'voice'">
<!-- 列表 -->
<el-table v-loading="loading" :data="list">
<el-table-column label="编号" align="center" prop="mediaId" />
@@ -64,7 +64,7 @@
/>
</div>
<!-- 类型:video -->
<div v-else-if="objData.type === 'video'">
<div v-else-if="props.type === 'video'">
<!-- 列表 -->
<el-table v-loading="loading" :data="list">
<el-table-column label="编号" align="center" prop="mediaId" />
@@ -106,7 +106,7 @@
/>
</div>
<!-- 类型:news -->
<div v-else-if="objData.type === 'news'">
<div v-else-if="props.type === 'news'">
<div class="waterfall" v-loading="loading">
<div class="waterfall-item" v-for="item in list" :key="item.mediaId">
<div v-if="item.content && item.content.newsItem">
@@ -132,25 +132,25 @@
</template>
<script lang="ts" setup name="WxMaterialSelect">
import WxNews from '@/views/mp/components/wx-news/main.vue'
import WxVoicePlayer from '@/views/mp/components/wx-voice-play/main.vue'
import WxVideoPlayer from '@/views/mp/components/wx-video-play/main.vue'
import WxNews from '@/views/mp/components/wx-news'
import WxVoicePlayer from '@/views/mp/components/wx-voice-play'
import WxVideoPlayer from '@/views/mp/components/wx-video-play'
import { NewsType } from './types'
import * as MpMaterialApi from '@/api/mp/material'
import * as MpFreePublishApi from '@/api/mp/freePublish'
import * as MpDraftApi from '@/api/mp/draft'
import { dateFormatter } from '@/utils/formatTime'
const props = defineProps({
objData: {
type: Object, // type - 类型;accountId - 公众号账号编号
required: true
},
newsType: {
// 图文类型:1、已发布图文;2、草稿箱图文
type: String as PropType<string>,
default: '1'
const props = withDefaults(
defineProps<{
type: string
accountId: number
newsType?: NewsType
}>(),
{
newsType: NewsType.Published
}
})
)
const emit = defineEmits(['select-material'])
@@ -159,15 +159,13 @@ const loading = ref(false)
// 总条数
const total = ref(0)
// 数据列表
const list = ref([])
const list = ref<any[]>([])
// 查询参数
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
accountId: props.objData.accountId
accountId: props.accountId
})
const objDataRef = reactive(props.objData)
const newsTypeRef = ref(props.newsType)
const selectMaterialFun = (item) => {
emit('select-material', item)
@@ -176,10 +174,10 @@ const selectMaterialFun = (item) => {
const getPage = async () => {
loading.value = true
try {
if (objDataRef.type === 'news' && newsTypeRef.value === '1') {
if (props.type === 'news' && props.newsType === NewsType.Published) {
// 【图文】+ 【已发布】
await getFreePublishPageFun()
} else if (objDataRef.type === 'news' && newsTypeRef.value === '2') {
} else if (props.type === 'news' && props.newsType === NewsType.Draft) {
// 【图文】+ 【草稿】
await getDraftPageFun()
} else {
@@ -194,7 +192,7 @@ const getPage = async () => {
const getMaterialPageFun = async () => {
const data = await MpMaterialApi.getMaterialPage({
...queryParams,
type: objDataRef.type
type: props.type
})
list.value = data.list
total.value = data.total
@@ -202,9 +200,9 @@ const getMaterialPageFun = async () => {
const getFreePublishPageFun = async () => {
const data = await MpFreePublishApi.getFreePublishPage(queryParams)
data.list.forEach((item) => {
const newsItem = item.content.newsItem
newsItem.forEach((article) => {
data.list.forEach((item: any) => {
const articles = item.content.newsItem
articles.forEach((article: any) => {
article.picUrl = article.thumbUrl
})
})
@@ -214,9 +212,9 @@ const getFreePublishPageFun = async () => {
const getDraftPageFun = async () => {
const data = await MpDraftApi.getDraftPage(queryParams)
data.list.forEach((item) => {
const newsItem = item.content.newsItem
newsItem.forEach((article) => {
data.list.forEach((draft: any) => {
const articles = draft.content.newsItem
articles.forEach((article: any) => {
article.picUrl = article.thumbUrl
})
})
@@ -229,29 +227,6 @@ onMounted(async () => {
})
</script>
<style lang="scss" scoped>
/*瀑布流样式*/
.waterfall {
width: 100%;
column-gap: 10px;
column-count: 5;
margin: 0 auto;
}
.waterfall-item {
padding: 10px;
margin-bottom: 10px;
break-inside: avoid;
border: 1px solid #eaeaea;
}
.material-img {
width: 100%;
}
p {
line-height: 30px;
}
@media (min-width: 992px) and (max-width: 1300px) {
.waterfall {
column-count: 3;
@@ -278,5 +253,25 @@ p {
}
}
/*瀑布流样式*/
.waterfall {
width: 100%;
column-gap: 10px;
column-count: 5;
margin: 0 auto;
}
.waterfall-item {
padding: 10px;
margin-bottom: 10px;
break-inside: avoid;
border: 1px solid #eaeaea;
}
.material-img {
width: 100%;
}
p {
line-height: 30px;
}
</style>
@@ -0,0 +1,11 @@
export enum NewsType {
Draft = '2',
Published = '1'
}
export enum MaterialType {
Image = 'image',
Voice = 'voice',
Video = 'video',
News = 'news'
}
+47 -32
View File
@@ -1,25 +1,27 @@
.avue-card{
&__item{
.avue-card {
&__item {
margin-bottom: 16px;
border: 1px solid #e8e8e8;
background-color: #fff;
box-sizing: border-box;
color: rgba(0,0,0,.65);
color: rgba(0, 0, 0, 0.65);
font-size: 14px;
font-variant: tabular-nums;
line-height: 1.5;
list-style: none;
font-feature-settings: "tnum";
font-feature-settings: 'tnum';
cursor: pointer;
height:200px;
&:hover{
border-color: rgba(0,0,0,.09);
box-shadow: 0 2px 8px rgba(0,0,0,.09);
height: 200px;
&:hover {
border-color: rgba(0, 0, 0, 0.09);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.09);
}
&--add{
border:1px dashed #000;
&--add {
border: 1px dashed #000;
width: 100%;
color: rgba(0,0,0,.45);
color: rgba(0, 0, 0, 0.45);
background-color: #fff;
border-color: #d9d9d9;
border-radius: 2px;
@@ -27,74 +29,87 @@
align-items: center;
justify-content: center;
font-size: 16px;
i{
i {
margin-right: 10px;
}
&:hover{
&:hover {
color: #40a9ff;
background-color: #fff;
border-color: #40a9ff;
}
}
}
&__body{
&__body {
display: flex;
padding: 24px;
}
&__detail{
flex:1
&__detail {
flex: 1;
}
&__avatar{
&__avatar {
width: 48px;
height: 48px;
border-radius: 48px;
overflow: hidden;
margin-right: 12px;
img{
img {
width: 100%;
height: 100%;
}
}
&__title{
color: rgba(0,0,0,.85);
&__title {
color: rgba(0, 0, 0, 0.85);
margin-bottom: 12px;
font-size: 16px;
&:hover{
color:#1890ff;
&:hover {
color: #1890ff;
}
}
&__info{
color: rgba(0,0,0,.45);
&__info {
color: rgba(0, 0, 0, 0.45);
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
height: 64px;
}
&__menu{
&__menu {
display: flex;
justify-content:space-around;
justify-content: space-around;
height: 50px;
background: #f7f9fa;
color: rgba(0,0,0,.45);
color: rgba(0, 0, 0, 0.45);
text-align: center;
line-height: 50px;
&:hover{
color:#1890ff;
&:hover {
color: #1890ff;
}
}
}
/** joolun 额外加的 */
.avue-comment__main {
flex: unset!important;
border-radius: 5px!important;
margin: 0 8px!important;
flex: unset !important;
border-radius: 5px !important;
margin: 0 8px !important;
}
.avue-comment__header {
border-top-left-radius: 5px;
border-top-right-radius: 5px;
}
.avue-comment__body {
border-bottom-right-radius: 5px;
border-bottom-left-radius: 5px;
+38 -20
View File
@@ -1,27 +1,33 @@
/* 来自 https://github.com/nmxiaowei/avue/blob/master/styles/src/element-ui/comment.scss */
.avue-comment{
.avue-comment {
margin-bottom: 30px;
display: flex;
align-items: flex-start;
&--reverse{
flex-direction:row-reverse;
.avue-comment__main{
&:before,&:after{
&--reverse {
flex-direction: row-reverse;
.avue-comment__main {
&:before,
&:after {
left: auto;
right: -8px;
border-width: 8px 0 8px 8px;
}
&:before{
&:before {
border-left-color: #dedede;
}
&:after{
&:after {
border-left-color: #f8f8f8;
margin-right: 1px;
margin-left: auto;
}
}
}
&__avatar{
&__avatar {
width: 48px;
height: 48px;
border-radius: 50%;
@@ -29,7 +35,8 @@
box-sizing: border-box;
vertical-align: middle;
}
&__header{
&__header {
padding: 5px 15px;
background: #f8f8f8;
border-bottom: 1px solid #eee;
@@ -37,18 +44,22 @@
align-items: center;
justify-content: space-between;
}
&__author{
&__author {
font-weight: 700;
font-size: 14px;
color: #999;
}
&__main{
flex:1;
&__main {
flex: 1;
margin: 0 20px;
position: relative;
border: 1px solid #dedede;
border-radius: 2px;
&:before,&:after{
&:before,
&:after {
position: absolute;
top: 10px;
left: -8px;
@@ -56,32 +67,39 @@
width: 0;
height: 0;
display: block;
content: " ";
content: ' ';
border-color: transparent;
border-style: solid solid outset;
border-width: 8px 8px 8px 0;
pointer-events: none;
}
&:before {
border-right-color: #dedede;
z-index: 1;
}
&:after{
&:after {
border-right-color: #f8f8f8;
margin-left: 1px;
z-index: 2;
}
}
&__body{
&__body {
padding: 15px;
overflow: hidden;
background: #fff;
font-family: Segoe UI,Lucida Grande,Helvetica,Arial,Microsoft YaHei,FreeSans,Arimo,Droid Sans,wenquanyi micro hei,Hiragino Sans GB,Hiragino Sans GB W3,FontAwesome,sans-serif;color: #333;
font-family: Segoe UI, Lucida Grande, Helvetica, Arial, Microsoft YaHei, FreeSans, Arimo,
Droid Sans, wenquanyi micro hei, Hiragino Sans GB, Hiragino Sans GB W3, FontAwesome,
sans-serif;
color: #333;
font-size: 14px;
}
blockquote{
margin:0;
font-family: Georgia,Times New Roman,Times,Kai,Kaiti SC,KaiTi,BiauKai,FontAwesome,serif;
blockquote {
margin: 0;
font-family: Georgia, Times New Roman, Times, Kai, Kaiti SC, KaiTi, BiauKai, FontAwesome, serif;
padding: 1px 0 1px 15px;
border-left: 4px solid #ddd;
}
@@ -0,0 +1,67 @@
<template>
<div>
<MsgEvent v-if="item.type === MsgType.Event" :item="item" />
<div v-else-if="item.type === MsgType.Text">{{ item.content }}</div>
<div v-else-if="item.type === MsgType.Voice">
<WxVoicePlayer :url="item.mediaUrl" :content="item.recognition" />
</div>
<div v-else-if="item.type === MsgType.Image">
<a target="_blank" :href="item.mediaUrl">
<img :src="item.mediaUrl" style="width: 100px" />
</a>
</div>
<div
v-else-if="item.type === MsgType.Video || item.type === 'shortvideo'"
style="text-align: center"
>
<WxVideoPlayer :url="item.mediaUrl" />
</div>
<div v-else-if="item.type === MsgType.Link" class="avue-card__detail">
<el-link type="success" :underline="false" target="_blank" :href="item.url">
<div class="avue-card__title"><i class="el-icon-link"></i>{{ item.title }}</div>
</el-link>
<div class="avue-card__info" style="height: unset">{{ item.description }}</div>
</div>
<div v-else-if="item.type === MsgType.Location">
<WxLocation :label="item.label" :location-y="item.locationY" :location-x="item.locationX" />
</div>
<div v-else-if="item.type === MsgType.News" style="width: 300px">
<WxNews :articles="item.articles" />
</div>
<div v-else-if="item.type === MsgType.Music">
<WxMusic
:title="item.title"
:description="item.description"
:thumb-media-url="item.thumbMediaUrl"
:music-url="item.musicUrl"
:hq-music-url="item.hqMusicUrl"
/>
</div>
</div>
</template>
<script setup lang="ts" name="Msg">
import WxVideoPlayer from '@/views/mp/components/wx-video-play'
import WxVoicePlayer from '@/views/mp/components/wx-voice-play'
import WxNews from '@/views/mp/components/wx-news'
import WxLocation from '@/views/mp/components/wx-location'
import WxMusic from '@/views/mp/components/wx-music'
import MsgEvent from './MsgEvent.vue'
import { MsgType } from '../types'
const props = defineProps<{
item: any
}>()
const item = ref<any>(props.item)
</script>
<style scoped></style>
@@ -0,0 +1,49 @@
<template>
<div>
<div v-if="item.event === 'subscribe'">
<el-tag type="success">关注</el-tag>
</div>
<div v-else-if="item.event === 'unsubscribe'">
<el-tag type="danger">取消关注</el-tag>
</div>
<div v-else-if="item.event === 'CLICK'">
<el-tag>点击菜单</el-tag>
【{{ item.eventKey }}】
</div>
<div v-else-if="item.event === 'VIEW'">
<el-tag>点击菜单链接</el-tag>
【{{ item.eventKey }}】
</div>
<div v-else-if="item.event === 'scancode_waitmsg'">
<el-tag>扫码结果</el-tag>
【{{ item.eventKey }}】
</div>
<div v-else-if="item.event === 'scancode_push'">
<el-tag>扫码结果</el-tag>
【{{ item.eventKey }}】
</div>
<div v-else-if="item.event === 'pic_sysphoto'">
<el-tag>系统拍照发图</el-tag>
</div>
<div v-else-if="item.event === 'pic_photo_or_album'">
<el-tag>拍照或者相册</el-tag>
</div>
<div v-else-if="item.event === 'pic_weixin'">
<el-tag>微信相册</el-tag>
</div>
<div v-else-if="item.event === 'location_select'">
<el-tag>选择地理位置</el-tag>
</div>
<div v-else>
<el-tag type="danger">未知事件类型</el-tag>
</div>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
item: any
}>()
const item = ref(props.item)
</script>
@@ -0,0 +1,60 @@
<template>
<div class="execution" v-for="item in props.list" :key="item.id">
<div
class="avue-comment"
:class="{ 'avue-comment--reverse': item.sendFrom === SendFrom.MpBot }"
>
<div class="avatar-div">
<img :src="getAvatar(item.sendFrom)" class="avue-comment__avatar" />
<div class="avue-comment__author">
{{ getNickname(item.sendFrom) }}
</div>
</div>
<div class="avue-comment__main">
<div class="avue-comment__header">
<div class="avue-comment__create_time">{{ formatDate(item.createTime) }}</div>
</div>
<div
class="avue-comment__body"
:style="item.sendFrom === SendFrom.MpBot ? 'background: #6BED72;' : ''"
>
<Msg :item="item" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts" name="MsgList">
import Msg from './Msg.vue'
import { formatDate } from '@/utils/formatTime'
import { User } from '../types'
import avatarWechat from '@/assets/imgs/wechat.png'
const props = defineProps<{
list: any[]
accountId: number
user: User
}>()
enum SendFrom {
User = 1,
MpBot = 2
}
const getAvatar = (sendFrom: SendFrom) =>
sendFrom === SendFrom.User ? props.user.avatar : avatarWechat
const getNickname = (sendFrom: SendFrom) =>
sendFrom === SendFrom.User ? props.user.nickname : '公众号'
</script>
<style lang="scss" scoped>
/* 因为 joolun 实现依赖 avue 组件,该页面使用了 comment.scss、card.scc */
@import '../comment.scss';
@import '../card.scss';
.avatar-div {
text-align: center;
width: 80px;
}
</style>
+6
View File
@@ -0,0 +1,6 @@
import WxMsg from './main.vue'
import { MsgType } from './types'
export { MsgType }
export default WxMsg
+46 -191
View File
@@ -7,143 +7,36 @@
-->
<template>
<ContentWrap>
<div class="msg-div" :id="'msg-div' + nowStr">
<div class="msg-div" ref="msgDivRef">
<!-- 加载更多 -->
<div v-loading="loading"></div>
<div v-if="!loading">
<div class="el-table__empty-block" v-if="loadMore" @click="loadingMore"
<div class="el-table__empty-block" v-if="hasMore" @click="loadMore"
><span class="el-table__empty-text">点击加载更多</span></div
>
<div class="el-table__empty-block" v-if="!loadMore"
<div class="el-table__empty-block" v-if="!hasMore"
><span class="el-table__empty-text">没有更多了</span></div
>
</div>
<!-- 消息列表 -->
<div class="execution" v-for="item in list" :key="item.id">
<div class="avue-comment" :class="item.sendFrom === 2 ? 'avue-comment--reverse' : ''">
<div class="avatar-div">
<img
:src="item.sendFrom === 1 ? user.avatar : mp.avatar"
class="avue-comment__avatar"
/>
<div class="avue-comment__author"
>{{ item.sendFrom === 1 ? user.nickname : mp.nickname }}
</div>
</div>
<div class="avue-comment__main">
<div class="avue-comment__header">
<div class="avue-comment__create_time">{{ formatDate(item.createTime) }}</div>
</div>
<div
class="avue-comment__body"
:style="item.sendFrom === 2 ? 'background: #6BED72;' : ''"
>
<!-- 【事件】区域 -->
<div v-if="item.type === MsgType.Event && item.event === 'subscribe'">
<el-tag type="success">关注</el-tag>
</div>
<div v-else-if="item.type === MsgType.Event && item.event === 'unsubscribe'">
<el-tag type="danger">取消关注</el-tag>
</div>
<div v-else-if="item.type === MsgType.Event && item.event === 'CLICK'">
<el-tag>点击菜单</el-tag>
【{{ item.eventKey }}】
</div>
<div v-else-if="item.type === MsgType.Event && item.event === 'VIEW'">
<el-tag>点击菜单链接</el-tag>
【{{ item.eventKey }}】
</div>
<div v-else-if="item.type === MsgType.Event && item.event === 'scancode_waitmsg'">
<el-tag>扫码结果</el-tag>
【{{ item.eventKey }}】
</div>
<div v-else-if="item.type === MsgType.Event && item.event === 'scancode_push'">
<el-tag>扫码结果</el-tag>
【{{ item.eventKey }}】
</div>
<div v-else-if="item.type === MsgType.Event && item.event === 'pic_sysphoto'">
<el-tag>系统拍照发图</el-tag>
</div>
<div v-else-if="item.type === MsgType.Event && item.event === 'pic_photo_or_album'">
<el-tag>拍照或者相册</el-tag>
</div>
<div v-else-if="item.type === MsgType.Event && item.event === 'pic_weixin'">
<el-tag>微信相册</el-tag>
</div>
<div v-else-if="item.type === MsgType.Event && item.event === 'location_select'">
<el-tag>选择地理位置</el-tag>
</div>
<div v-else-if="item.type === MsgType.Event">
<el-tag type="danger">未知事件类型</el-tag>
</div>
<!-- 【消息】区域 -->
<div v-else-if="item.type === MsgType.Text">{{ item.content }}</div>
<div v-else-if="item.type === MsgType.Voice">
<WxVoicePlayer :url="item.mediaUrl" :content="item.recognition" />
</div>
<div v-else-if="item.type === MsgType.Image">
<a target="_blank" :href="item.mediaUrl">
<img :src="item.mediaUrl" style="width: 100px" />
</a>
</div>
<div
v-else-if="item.type === MsgType.Video || item.type === 'shortvideo'"
style="text-align: center"
>
<WxVideoPlayer :url="item.mediaUrl" />
</div>
<div v-else-if="item.type === MsgType.Link" class="avue-card__detail">
<el-link type="success" :underline="false" target="_blank" :href="item.url">
<div class="avue-card__title"><i class="el-icon-link"></i>{{ item.title }}</div>
</el-link>
<div class="avue-card__info" style="height: unset">{{ item.description }}</div>
</div>
<!-- TODO 芋艿:待完善 -->
<div v-else-if="item.type === MsgType.Location">
<WxLocation
:label="item.label"
:location-y="item.locationY"
:location-x="item.locationX"
/>
</div>
<div v-else-if="item.type === MsgType.News" style="width: 300px">
<!-- TODO 芋艿:待测试;详情页也存在类似的情况 -->
<WxNews :articles="item.articles" />
</div>
<div v-else-if="item.type === MsgType.Music">
<WxMusic
:title="item.title"
:description="item.description"
:thumb-media-url="item.thumbMediaUrl"
:music-url="item.musicUrl"
:hq-music-url="item.hqMusicUrl"
/>
</div>
</div>
</div>
</div>
</div>
<MsgList :list="list" :account-id="accountId" :user="user" />
</div>
<div class="msg-send" v-loading="sendLoading">
<WxReplySelect ref="replySelectRef" :objData="objData" />
<WxReplySelect ref="replySelectRef" v-model="reply" />
<el-button type="success" class="send-but" @click="sendMsg">发送(S)</el-button>
</div>
</ContentWrap>
</template>
<script setup lang="ts" name="WxMsg">
import WxReplySelect from '@/views/mp/components/wx-reply/main.vue'
import WxVideoPlayer from '@/views/mp/components/wx-video-play/main.vue'
import WxVoicePlayer from '@/views/mp/components/wx-voice-play/main.vue'
import WxNews from '@/views/mp/components/wx-news/main.vue'
import WxLocation from '@/views/mp/components/wx-location/main.vue'
import WxMusic from '@/views/mp/components/wx-music/main.vue'
import WxReplySelect, { Reply, ReplyType } from '@/views/mp/components/wx-reply'
import MsgList from './components/MsgList.vue'
import { getMessagePage, sendMessage } from '@/api/mp/message'
import { getUser } from '@/api/mp/user'
import { formatDate } from '@/utils/formatTime'
import profile from '@/assets/imgs/profile.jpg'
import wechat from '@/assets/imgs/wechat.png'
import { MsgType } from './types'
import { User } from './types'
const message = useMessage() // 消息弹窗
@@ -154,93 +47,77 @@ const props = defineProps({
}
})
const nowStr = ref(new Date().getTime()) // 当前的时间戳,用于每次消息加载后,回到原位置;具体见 :id="'msg-div' + nowStr" 处
const accountId = ref(-1) // 公众号ID,需要通过userId初始化
const loading = ref(false) // 消息列表是否正在加载中
const loadMore = ref(true) // 是否可以加载更多
const hasMore = ref(true) // 是否可以加载更多
const list = ref<any[]>([]) // 消息列表
const queryParams = reactive({
pageNo: 1, // 当前页数
pageSize: 14, // 每页显示多少条
accountId: undefined
accountId: accountId
})
interface User {
nickname: string
avatar: string
accountId: number
}
// 由于微信不再提供昵称,直接使用“用户”展示
const user: User = reactive({
nickname: '用户',
avatar: profile,
accountId: 0 // 公众号账号编号
})
interface Mp {
nickname: string
avatar: string
}
const mp: Mp = reactive({
nickname: '公众号',
avatar: wechat
accountId: accountId // 公众号账号编号
})
// ========= 消息发送 =========
const sendLoading = ref(false) // 发送消息是否加载中
interface ObjData {
type: MsgType
accountId: number | null
articles: any[]
}
// 微信发送消息
const objData: ObjData = reactive({
type: MsgType.Text,
accountId: null,
const reply = ref<Reply>({
type: ReplyType.Text,
accountId: -1,
articles: []
})
const replySelectRef = ref<InstanceType<typeof WxReplySelect> | null>(null)
const replySelectRef = ref<InstanceType<typeof WxReplySelect> | null>(null) // WxReplySelect组件ref,用于消息发送成功后清除内容
const msgDivRef = ref<HTMLDivElement | null>(null) // 消息显示窗口ref,用于滚动到底部
/** 完成加载 */
onMounted(async () => {
const data = await getUser(props.userId)
user.nickname = data.nickname?.length > 0 ? data.nickname : user.nickname
user.avatar = user.avatar?.length > 0 ? data.avatar : user.avatar
user.accountId = data.accountId
queryParams.accountId = data.accountId
objData.accountId = data.accountId
accountId.value = data.accountId
reply.value.accountId = data.accountId
refreshChange()
})
// 执行发送
const sendMsg = async () => {
if (!objData) {
if (!unref(reply)) {
return
}
// 公众号限制:客服消息,公众号只允许发送一条
if (objData.type === MsgType.News && objData.articles.length > 1) {
objData.articles = [objData.articles[0]]
if (
reply.value.type === ReplyType.News &&
reply.value.articles &&
reply.value.articles.length > 1
) {
reply.value.articles = [reply.value.articles[0]]
message.success('图文消息条数限制在 1 条以内,已默认发送第一条')
}
const data = await sendMessage(Object.assign({ userId: props.userId }, { ...objData }))
const data = await sendMessage({ userId: props.userId, ...reply.value })
sendLoading.value = false
list.value = [...list.value, ...[data]]
scrollToBottom()
await scrollToBottom()
// 发送后清空数据
replySelectRef.value?.clear()
}
const loadingMore = () => {
const loadMore = () => {
queryParams.pageNo++
getPage(queryParams, null)
}
const getPage = async (page, params) => {
const getPage = async (page: any, params: any = null) => {
loading.value = true
let dataTemp = await getMessagePage(
Object.assign(
@@ -254,62 +131,45 @@ const getPage = async (page, params) => {
)
)
const msgDiv = document.getElementById('msg-div' + nowStr.value)
let scrollHeight = 0
if (msgDiv) {
scrollHeight = msgDiv.scrollHeight
}
const scrollHeight = msgDivRef.value?.scrollHeight ?? 0
// 处理数据
const data = dataTemp.list.reverse()
list.value = [...data, ...list.value]
loading.value = false
if (data.length < queryParams.pageSize || data.length === 0) {
loadMore.value = false
hasMore.value = false
}
queryParams.pageNo = page.pageNo
queryParams.pageSize = page.pageSize
// 滚动到原来的位置
if (queryParams.pageNo === 1) {
// 定位到消息底部
scrollToBottom()
await scrollToBottom()
} else if (data.length !== 0) {
// 定位滚动条
await nextTick(() => {
if (scrollHeight !== 0) {
let div = document.getElementById('msg-div' + nowStr.value)
if (div && msgDiv) {
msgDiv.scrollTop = div.scrollHeight - scrollHeight - 100
}
await nextTick()
if (scrollHeight !== 0) {
if (msgDivRef.value) {
msgDivRef.value.scrollTop = msgDivRef.value.scrollHeight - scrollHeight - 100
}
})
}
}
}
const refreshChange = () => {
getPage(queryParams, null)
getPage(queryParams)
}
/** 定位到消息底部 */
const scrollToBottom = () => {
nextTick(() => {
let div = document.getElementById('msg-div' + nowStr.value)
if (div) {
div.scrollTop = div.scrollHeight
}
})
const scrollToBottom = async () => {
await nextTick()
if (msgDivRef.value) {
msgDivRef.value.scrollTop = msgDivRef.value.scrollHeight
}
}
</script>
<style lang="scss" scoped>
/* 因为 joolun 实现依赖 avue 组件,该页面使用了 comment.scss、card.scc */
@import './comment.scss';
@import './card.scss';
.msg-main {
margin-top: -30px;
padding: 10px;
}
.msg-div {
height: 50vh;
overflow: auto;
@@ -322,11 +182,6 @@ const scrollToBottom = () => {
padding: 10px;
}
.avatar-div {
text-align: center;
width: 80px;
}
.send-but {
float: right;
margin-top: 8px;
+6
View File
@@ -9,3 +9,9 @@ export enum MsgType {
Music = 'music',
News = 'news'
}
export interface User {
nickname: string
avatar: string
accountId: number
}
@@ -0,0 +1,3 @@
import WxMusic from './main.vue'
export default WxMusic
+1 -1
View File
@@ -55,6 +55,6 @@ defineExpose({
</script>
<style lang="scss" scoped>
/* 因为 joolun 实现依赖 avue 组件,该页面使用了 card.scc */
/* 因为 joolun 实现依赖 avue 组件,该页面使用了 card.scss */
@import '../wx-msg/card.scss';
</style>
+3
View File
@@ -0,0 +1,3 @@
import WxNews from './main.vue'
export default WxNews
+19 -17
View File
@@ -39,12 +39,14 @@
</template>
<script lang="ts" name="WxNews" setup>
const props = defineProps({
articles: {
type: Array,
default: () => null
const props = withDefaults(
defineProps<{
articles: any[] | null
}>(),
{
articles: null
}
})
)
defineExpose({
articles: props.articles
@@ -53,9 +55,9 @@ defineExpose({
<style lang="scss" scoped>
.news-home {
background-color: #ffffff;
width: 100%;
margin: auto;
background-color: #fff;
}
.news-main {
@@ -64,29 +66,29 @@ defineExpose({
}
.news-content {
background-color: #acadae;
width: 100%;
position: relative;
width: 100%;
background-color: #acadae;
}
.news-content-title {
display: inline-block;
font-size: 12px;
color: #ffffff;
position: absolute;
left: 0;
bottom: 0;
background-color: black;
left: 0;
display: inline-block;
width: 98%;
padding: 1%;
opacity: 0.65;
font-size: 12px;
color: #fff;
white-space: normal;
background-color: black;
opacity: 0.65;
box-sizing: unset !important;
}
.news-main-item {
background-color: #ffffff;
padding: 5px 0;
background-color: #fff;
border-top: 1px solid #eaeaea;
}
@@ -96,17 +98,17 @@ defineExpose({
.news-content-item-title {
display: inline-block;
font-size: 10px;
width: 70%;
margin-left: 1%;
font-size: 10px;
white-space: normal;
}
.news-content-item-img {
display: inline-block;
width: 25%;
background-color: #acadae;
margin-right: 1%;
background-color: #acadae;
}
.material-img {
@@ -1,12 +1,9 @@
<template>
<el-tab-pane name="image">
<template #label>
<el-row align="middle"><Icon icon="ep:picture" class="mr-5px" /> 图片</el-row>
</template>
<div>
<!-- 情况一:已经选择好素材、或者上传好图片 -->
<div class="select-item" v-if="objData.url">
<img class="material-img" :src="objData.url" />
<p class="item-name" v-if="objData.name">{{ objData.name }}</p>
<div class="select-item" v-if="reply.url">
<img class="material-img" :src="reply.url" />
<p class="item-name" v-if="reply.name">{{ reply.name }}</p>
<el-row class="ope-row" justify="center">
<el-button type="danger" circle @click="onDelete">
<Icon icon="ep:delete" />
@@ -27,7 +24,11 @@
append-to-body
destroy-on-close
>
<WxMaterialSelect :objData="objData" @select-material="selectMaterial" />
<WxMaterialSelect
type="image"
:account-id="reply.accountId"
@select-material="selectMaterial"
/>
</el-dialog>
</el-col>
<!-- 文件上传 -->
@@ -51,27 +52,27 @@
</el-upload>
</el-col>
</el-row>
</el-tab-pane>
</div>
</template>
<script setup lang="ts">
import WxMaterialSelect from '@/views/mp/components/wx-material-select/main.vue'
import { MaterialType, useBeforeUpload } from '@/views/mp/hooks/useUpload'
import WxMaterialSelect from '@/views/mp/components/wx-material-select'
import { UploadType, useBeforeUpload } from '@/views/mp/hooks/useUpload'
import type { UploadRawFile } from 'element-plus'
import { getAccessToken } from '@/utils/auth'
import { ObjData } from './types'
import { Reply } from './types'
const message = useMessage()
const UPLOAD_URL = import.meta.env.VITE_API_BASEPATH + '/admin-api/mp/material/upload-temporary'
const HEADERS = { Authorization: 'Bearer ' + getAccessToken() } // 设置上传的请求头部
const props = defineProps<{
modelValue: ObjData
modelValue: Reply
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: ObjData)
(e: 'update:modelValue', v: Reply)
}>()
const objData = computed<ObjData>({
const reply = computed<Reply>({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
@@ -79,14 +80,13 @@ const objData = computed<ObjData>({
const showDialog = ref(false)
const fileList = ref([])
const uploadData = reactive({
accountId: objData.value.accountId,
accountId: reply.value.accountId,
type: 'image',
title: '',
introduction: ''
})
const beforeImageUpload = (rawFile: UploadRawFile) =>
useBeforeUpload(MaterialType.Image, 2)(rawFile)
const beforeImageUpload = (rawFile: UploadRawFile) => useBeforeUpload(UploadType.Image, 2)(rawFile)
const onUploadSuccess = (res: any) => {
if (res.code !== 0) {
@@ -104,18 +104,18 @@ const onUploadSuccess = (res: any) => {
}
const onDelete = () => {
objData.value.mediaId = null
objData.value.url = null
objData.value.name = null
reply.value.mediaId = null
reply.value.url = null
reply.value.name = null
}
const selectMaterial = (item) => {
showDialog.value = false
objData.value.type = 'image'
objData.value.mediaId = item.mediaId
objData.value.url = item.url
objData.value.name = item.name
// reply.value.type = 'image'
reply.value.mediaId = item.mediaId
reply.value.url = item.url
reply.value.name = item.name
}
</script>
@@ -123,7 +123,7 @@ const selectMaterial = (item) => {
.select-item {
width: 280px;
padding: 10px;
margin: 0 auto 10px auto;
margin: 0 auto 10px;
border: 1px solid #eaeaea;
.material-img {
@@ -131,11 +131,11 @@ const selectMaterial = (item) => {
}
.item-name {
font-size: 12px;
overflow: hidden;
font-size: 12px;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
.item-infos {
width: 30%;
@@ -149,18 +149,18 @@ const selectMaterial = (item) => {
}
.col-select {
border: 1px solid rgb(234, 234, 234);
padding: 50px 0px;
height: 160px;
width: 49.5%;
height: 160px;
padding: 50px 0;
border: 1px solid rgb(234 234 234);
}
.col-add {
border: 1px solid rgb(234, 234, 234);
padding: 50px 0px;
height: 160px;
width: 49.5%;
float: right;
width: 49.5%;
height: 160px;
padding: 50px 0;
border: 1px solid rgb(234 234 234);
.el-upload__tip {
line-height: 18px;
@@ -1,14 +1,11 @@
<template>
<el-tab-pane name="music">
<template #label>
<el-row align="middle"><Icon icon="ep:service" />音乐</el-row>
</template>
<div>
<el-row align="middle" justify="center">
<el-col :span="6">
<el-row align="middle" justify="center" class="thumb-div">
<el-col :span="24">
<el-row align="middle" justify="center">
<img style="width: 100px" v-if="objData.thumbMediaUrl" :src="objData.thumbMediaUrl" />
<img style="width: 100px" v-if="reply.thumbMediaUrl" :src="reply.thumbMediaUrl" />
<icon v-else icon="ep:plus" />
</el-row>
<el-row align="middle" justify="center" style="margin-top: 2%">
@@ -42,30 +39,31 @@
destroy-on-close
>
<WxMaterialSelect
:objData="{ type: 'image', accountId: objData.accountId }"
type="image"
:account-id="reply.accountId"
@select-material="selectMaterial"
/>
</el-dialog>
</el-col>
<el-col :span="18">
<el-input v-model="objData.title" placeholder="请输入标题" />
<el-input v-model="reply.title" placeholder="请输入标题" />
<div style="margin: 20px 0"></div>
<el-input v-model="objData.description" placeholder="请输入描述" />
<el-input v-model="reply.description" placeholder="请输入描述" />
</el-col>
</el-row>
<div style="margin: 20px 0"></div>
<el-input v-model="objData.musicUrl" placeholder="请输入音乐链接" />
<el-input v-model="reply.musicUrl" placeholder="请输入音乐链接" />
<div style="margin: 20px 0"></div>
<el-input v-model="objData.hqMusicUrl" placeholder="请输入高质量音乐链接" />
</el-tab-pane>
<el-input v-model="reply.hqMusicUrl" placeholder="请输入高质量音乐链接" />
</div>
</template>
<script setup lang="ts">
import WxMaterialSelect from '@/views/mp/components/wx-material-select/main.vue'
import WxMaterialSelect from '@/views/mp/components/wx-material-select'
import type { UploadRawFile } from 'element-plus'
import { MaterialType, useBeforeUpload } from '@/views/mp/hooks/useUpload'
import { UploadType, useBeforeUpload } from '@/views/mp/hooks/useUpload'
import { getAccessToken } from '@/utils/auth'
import { ObjData } from './types'
import { Reply } from './types'
const message = useMessage()
@@ -73,12 +71,12 @@ const UPLOAD_URL = import.meta.env.VITE_API_BASEPATH + '/admin-api/mp/material/u
const HEADERS = { Authorization: 'Bearer ' + getAccessToken() } // 设置上传的请求头部
const props = defineProps<{
modelValue: ObjData
modelValue: Reply
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: ObjData)
(e: 'update:modelValue', v: Reply)
}>()
const objData = computed<ObjData>({
const reply = computed<Reply>({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
@@ -86,14 +84,13 @@ const objData = computed<ObjData>({
const showDialog = ref(false)
const fileList = ref([])
const uploadData = reactive({
accountId: objData.value.accountId,
accountId: reply.value.accountId,
type: 'thumb', // 音乐类型为thumb
title: '',
introduction: ''
})
const beforeImageUpload = (rawFile: UploadRawFile) =>
useBeforeUpload(MaterialType.Image, 2)(rawFile)
const beforeImageUpload = (rawFile: UploadRawFile) => useBeforeUpload(UploadType.Image, 2)(rawFile)
const onUploadSuccess = (res: any) => {
if (res.code !== 0) {
@@ -113,7 +110,7 @@ const onUploadSuccess = (res: any) => {
const selectMaterial = (item: any) => {
showDialog.value = false
objData.value.thumbMediaId = item.mediaId
objData.value.thumbMediaUrl = item.url
reply.value.thumbMediaId = item.mediaId
reply.value.thumbMediaUrl = item.url
}
</script>
@@ -1,11 +1,8 @@
<template>
<el-tab-pane name="news">
<template #label>
<el-row align="middle"><Icon icon="ep:reading" /> 图文</el-row>
</template>
<div>
<el-row>
<div class="select-item" v-if="objData.articles?.length > 0">
<WxNews :articles="objData.articles" />
<div class="select-item" v-if="reply.articles && reply.articles.length > 0">
<WxNews :articles="reply.articles" />
<el-col class="ope-row">
<el-button type="danger" circle @click="onDelete">
<Icon icon="ep:delete" />
@@ -13,7 +10,7 @@
</el-col>
</div>
<!-- 选择素材 -->
<el-col :span="24" v-if="!objData.content">
<el-col :span="24" v-if="!reply.content">
<el-row style="text-align: center" align="middle">
<el-col :span="24">
<el-button type="success" @click="showDialog = true">
@@ -25,28 +22,29 @@
</el-col>
<el-dialog title="选择图文" v-model="showDialog" width="90%" append-to-body destroy-on-close>
<WxMaterialSelect
:objData="objData"
@select-material="selectMaterial"
type="news"
:account-id="reply.accountId"
:newsType="newsType"
@select-material="selectMaterial"
/>
</el-dialog>
</el-row>
</el-tab-pane>
</div>
</template>
<script setup lang="ts">
import WxNews from '@/views/mp/components/wx-news/main.vue'
import WxMaterialSelect from '@/views/mp/components/wx-material-select/main.vue'
import { ObjData, NewsType } from './types'
import WxNews from '@/views/mp/components/wx-news'
import WxMaterialSelect from '@/views/mp/components/wx-material-select'
import { Reply, NewsType } from './types'
const props = defineProps<{
modelValue: ObjData
modelValue: Reply
newsType: NewsType
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: ObjData)
(e: 'update:modelValue', v: Reply)
}>()
const objData = computed<ObjData>({
const reply = computed<Reply>({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
@@ -55,11 +53,11 @@ const showDialog = ref(false)
const selectMaterial = (item: any) => {
showDialog.value = false
objData.value.articles = item.content.newsItem
reply.value.articles = item.content.newsItem
}
const onDelete = () => {
objData.value.articles = []
reply.value.articles = []
}
</script>
@@ -67,7 +65,7 @@ const onDelete = () => {
.select-item {
width: 280px;
padding: 10px;
margin: 0 auto 10px auto;
margin: 0 auto 10px;
border: 1px solid #eaeaea;
.ope-row {
@@ -1,15 +1,10 @@
<template>
<el-tab-pane name="text">
<template #label>
<el-row align="middle"><Icon icon="ep:document" /> 文本</el-row>
</template>
<el-input type="textarea" :rows="5" placeholder="请输入内容" v-model="content" />
</el-tab-pane>
<el-input type="textarea" :rows="5" placeholder="请输入内容" v-model="content" />
</template>
<script setup lang="ts">
const props = defineProps<{
modelValue: string | null
modelValue?: string | null
}>()
const emit = defineEmits<{
@@ -17,7 +12,7 @@ const emit = defineEmits<{
(e: 'input', v: string | null)
}>()
const content = computed<string | null>({
const content = computed<string | null | undefined>({
get: () => props.modelValue,
set: (val: string | null) => {
emit('update:modelValue', val)
@@ -1,17 +1,10 @@
<template>
<el-tab-pane name="video">
<template #label>
<el-row align="middle"><Icon icon="ep:share" /> 视频</el-row>
</template>
<div>
<el-row>
<el-input v-model="objData.title" class="input-margin-bottom" placeholder="请输入标题" />
<el-input
class="input-margin-bottom"
v-model="objData.description"
placeholder="请输入描述"
/>
<el-input v-model="reply.title" class="input-margin-bottom" placeholder="请输入标题" />
<el-input class="input-margin-bottom" v-model="reply.description" placeholder="请输入描述" />
<el-row class="ope-row" justify="center">
<WxVideoPlayer v-if="objData.url" :url="objData.url" />
<WxVideoPlayer v-if="reply.url" :url="reply.url" />
</el-row>
<el-col>
<el-row style="text-align: center" align="middle">
@@ -27,7 +20,11 @@
append-to-body
destroy-on-close
>
<WxMaterialSelect :objData="objData" @select-material="selectMaterial" />
<WxMaterialSelect
type="video"
:account-id="reply.accountId"
@select-material="selectMaterial"
/>
</el-dialog>
</el-col>
<!-- 文件上传 -->
@@ -48,16 +45,16 @@
</el-row>
</el-col>
</el-row>
</el-tab-pane>
</div>
</template>
<script setup lang="ts">
import WxVideoPlayer from '@/views/mp/components/wx-video-play/main.vue'
import WxMaterialSelect from '@/views/mp/components/wx-material-select/main.vue'
import WxVideoPlayer from '@/views/mp/components/wx-video-play'
import WxMaterialSelect from '@/views/mp/components/wx-material-select'
import type { UploadRawFile } from 'element-plus'
import { MaterialType, useBeforeUpload } from '@/views/mp/hooks/useUpload'
import { UploadType, useBeforeUpload } from '@/views/mp/hooks/useUpload'
import { getAccessToken } from '@/utils/auth'
import { ObjData } from './types'
import { Reply } from './types'
const message = useMessage()
@@ -65,12 +62,12 @@ const UPLOAD_URL = import.meta.env.VITE_API_BASEPATH + '/admin-api/mp/material/u
const HEADERS = { Authorization: 'Bearer ' + getAccessToken() }
const props = defineProps<{
modelValue: ObjData
modelValue: Reply
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: ObjData)
(e: 'update:modelValue', v: Reply)
}>()
const objData = computed<ObjData>({
const reply = computed<Reply>({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
@@ -78,14 +75,13 @@ const objData = computed<ObjData>({
const showDialog = ref(false)
const fileList = ref([])
const uploadData = reactive({
accountId: objData.value.accountId,
accountId: reply.value.accountId,
type: 'video',
title: '',
introduction: ''
})
const beforeVideoUpload = (rawFile: UploadRawFile) =>
useBeforeUpload(MaterialType.Video, 10)(rawFile)
const beforeVideoUpload = (rawFile: UploadRawFile) => useBeforeUpload(UploadType.Video, 10)(rawFile)
const onUploadSuccess = (res: any) => {
if (res.code !== 0) {
@@ -105,16 +101,16 @@ const onUploadSuccess = (res: any) => {
const selectMaterial = (item: any) => {
showDialog.value = false
objData.value.mediaId = item.mediaId
objData.value.url = item.url
objData.value.name = item.name
reply.value.mediaId = item.mediaId
reply.value.url = item.url
reply.value.name = item.name
// title、introduction:从 item 到 tempObjItem,因为素材里有 title、introduction
if (item.title) {
objData.value.title = item.title || ''
reply.value.title = item.title || ''
}
if (item.introduction) {
objData.value.description = item.introduction || ''
reply.value.description = item.introduction || ''
}
}
</script>
@@ -1,12 +1,9 @@
<template>
<el-tab-pane name="voice">
<template #label>
<el-row align="middle"><Icon icon="ep:phone" /> 语音</el-row>
</template>
<div class="select-item2" v-if="objData.url">
<p class="item-name">{{ objData.name }}</p>
<div>
<div class="select-item2" v-if="reply.url">
<p class="item-name">{{ reply.name }}</p>
<el-row class="ope-row" justify="center">
<WxVoicePlayer :url="objData.url" />
<WxVoicePlayer :url="reply.url" />
</el-row>
<el-row class="ope-row" justify="center">
<el-button type="danger" circle @click="onDelete"><Icon icon="ep:delete" /></el-button>
@@ -25,7 +22,11 @@
append-to-body
destroy-on-close
>
<WxMaterialSelect :objData="objData" @select-material="selectMaterial" />
<WxMaterialSelect
type="voice"
:account-id="reply.accountId"
@select-material="selectMaterial"
/>
</el-dialog>
</el-col>
<!-- 文件上传 -->
@@ -49,27 +50,27 @@
</el-upload>
</el-col>
</el-row>
</el-tab-pane>
</div>
</template>
<script setup lang="ts">
import WxMaterialSelect from '@/views/mp/components/wx-material-select/main.vue'
import WxVoicePlayer from '@/views/mp/components/wx-voice-play/main.vue'
import { MaterialType, useBeforeUpload } from '@/views/mp/hooks/useUpload'
import WxMaterialSelect from '@/views/mp/components/wx-material-select'
import WxVoicePlayer from '@/views/mp/components/wx-voice-play'
import { UploadType, useBeforeUpload } from '@/views/mp/hooks/useUpload'
import type { UploadRawFile } from 'element-plus'
import { getAccessToken } from '@/utils/auth'
import { ObjData } from './types'
import { Reply } from './types'
const message = useMessage()
const UPLOAD_URL = import.meta.env.VITE_API_BASEPATH + '/admin-api/mp/material/upload-temporary'
const HEADERS = { Authorization: 'Bearer ' + getAccessToken() } // 设置上传的请求头部
const props = defineProps<{
modelValue: ObjData
modelValue: Reply
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: ObjData)
(e: 'update:modelValue', v: Reply)
}>()
const objData = computed<ObjData>({
const reply = computed<Reply>({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
@@ -77,14 +78,13 @@ const objData = computed<ObjData>({
const showDialog = ref(false)
const fileList = ref([])
const uploadData = reactive({
accountId: objData.value.accountId,
accountId: reply.value.accountId,
type: 'voice',
title: '',
introduction: ''
})
const beforeVoiceUpload = (rawFile: UploadRawFile) =>
useBeforeUpload(MaterialType.Voice, 10)(rawFile)
const beforeVoiceUpload = (rawFile: UploadRawFile) => useBeforeUpload(UploadType.Voice, 10)(rawFile)
const onUploadSuccess = (res: any) => {
if (res.code !== 0) {
@@ -102,33 +102,33 @@ const onUploadSuccess = (res: any) => {
}
const onDelete = () => {
objData.value.mediaId = null
objData.value.url = null
objData.value.name = null
reply.value.mediaId = null
reply.value.url = null
reply.value.name = null
}
const selectMaterial = (item: ObjData) => {
const selectMaterial = (item: Reply) => {
showDialog.value = false
objData.value.type = 'voice'
objData.value.mediaId = item.mediaId
objData.value.url = item.url
objData.value.name = item.name
// reply.value.type = ReplyType.Voice
reply.value.mediaId = item.mediaId
reply.value.url = item.url
reply.value.name = item.name
}
</script>
<style lang="scss" scoped>
.select-item2 {
padding: 10px;
margin: 0 auto 10px auto;
margin: 0 auto 10px;
border: 1px solid #eaeaea;
.item-name {
font-size: 12px;
overflow: hidden;
font-size: 12px;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
.ope-row {
width: 100%;
@@ -138,18 +138,18 @@ const selectMaterial = (item: ObjData) => {
}
.col-select {
border: 1px solid rgb(234, 234, 234);
padding: 50px 0px;
height: 160px;
width: 49.5%;
height: 160px;
padding: 50px 0;
border: 1px solid rgb(234 234 234);
}
.col-add {
border: 1px solid rgb(234, 234, 234);
padding: 50px 0px;
height: 160px;
width: 49.5%;
float: right;
width: 49.5%;
height: 160px;
padding: 50px 0;
border: 1px solid rgb(234 234 234);
.el-upload__tip {
line-height: 18px;
@@ -1,25 +1,54 @@
type ReplyType = '' | 'news' | 'image' | 'voice' | 'video' | 'music' | 'text'
enum ReplyType {
News = 'news',
Image = 'image',
Voice = 'voice',
Video = 'video',
Music = 'music',
Text = 'text'
}
interface ObjData {
interface _Reply {
accountId: number
type: ReplyType
name: string | null
content: string | null
mediaId: string | null
url: string | null
title: string | null
description: string | null
thumbMediaId: string | null
thumbMediaUrl: string | null
musicUrl: string | null
hqMusicUrl: string | null
introduction: string | null
articles: any[]
name?: string | null
content?: string | null
mediaId?: string | null
url?: string | null
title?: string | null
description?: string | null
thumbMediaId?: string | null
thumbMediaUrl?: string | null
musicUrl?: string | null
hqMusicUrl?: string | null
introduction?: string | null
articles?: any[]
}
type Reply = _Reply //Partial<_Reply>
enum NewsType {
Published = '1',
Draft = '2'
}
export { ObjData, NewsType }
/** 利用旧的reply[accountId, type]初始化新的Reply */
const createEmptyReply = (old: Reply | Ref<Reply>): Reply => {
return {
accountId: unref(old).accountId,
type: unref(old).type,
name: null,
content: null,
mediaId: null,
url: null,
title: null,
description: null,
thumbMediaId: null,
thumbMediaUrl: null,
musicUrl: null,
hqMusicUrl: null,
introduction: null,
articles: []
}
}
export { Reply, NewsType, ReplyType, createEmptyReply }
@@ -0,0 +1,7 @@
import { Reply, NewsType, ReplyType, createEmptyReply } from './components/types'
import WxReplySelect from './main.vue'
export type { Reply }
export { createEmptyReply, NewsType, ReplyType }
export default WxReplySelect
+98 -39
View File
@@ -8,24 +8,59 @@
④ 支持发送【视频】消息时,支持新建视频
-->
<template>
<el-tabs type="border-card" v-model="objData.type" @tab-click="onTabClick">
<el-tabs type="border-card" v-model="currentTab">
<!-- 类型 1:文本 -->
<TabText v-model="objData.content" />
<el-tab-pane :name="ReplyType.Text">
<template #label>
<el-row align="middle"><Icon icon="ep:document" /> 文本</el-row>
</template>
<TabText v-model="reply.content" />
</el-tab-pane>
<!-- 类型 2:图片 -->
<TabImage v-model="objData" />
<el-tab-pane :name="ReplyType.Image">
<template #label>
<el-row align="middle"><Icon icon="ep:picture" class="mr-5px" /> 图片</el-row>
</template>
<TabImage v-model="reply" />
</el-tab-pane>
<!-- 类型 3:语音 -->
<TabVoice v-model="objData" />
<el-tab-pane :name="ReplyType.Voice">
<template #label>
<el-row align="middle"><Icon icon="ep:phone" /> 语音</el-row>
</template>
<TabVoice v-model="reply" />
</el-tab-pane>
<!-- 类型 4:视频 -->
<TabVideo v-model="objData" />
<el-tab-pane :name="ReplyType.Video">
<template #label>
<el-row align="middle"><Icon icon="ep:share" /> 视频</el-row>
</template>
<TabVideo v-model="reply" />
</el-tab-pane>
<!-- 类型 5:图文 -->
<TabNews v-model="objData" :news-type="newsType" />
<el-tab-pane :name="ReplyType.News">
<template #label>
<el-row align="middle"><Icon icon="ep:reading" /> 图文</el-row>
</template>
<TabNews v-model="reply" :news-type="newsType" />
</el-tab-pane>
<!-- 类型 6:音乐 -->
<TabMusic v-model="objData" />
<el-tab-pane :name="ReplyType.Music">
<template #label>
<el-row align="middle"><Icon icon="ep:service" />音乐</el-row>
</template>
<TabMusic v-model="reply" />
</el-tab-pane>
</el-tabs>
</template>
<script setup lang="ts" name="WxReplySelect">
import { ObjData, NewsType } from './components/types'
import { Reply, NewsType, ReplyType, createEmptyReply } from './components/types'
import TabText from './components/TabText.vue'
import TabImage from './components/TabImage.vue'
import TabVoice from './components/TabVoice.vue'
@@ -34,30 +69,54 @@ import TabNews from './components/TabNews.vue'
import TabMusic from './components/TabMusic.vue'
interface Props {
objData: ObjData
modelValue: Reply
newsType?: NewsType
}
const props = withDefaults(defineProps<Props>(), {
newsType: () => NewsType.Published
})
const emit = defineEmits<{
(e: 'update:modelValue', v: Reply)
}>()
const objData = reactive(props.objData)
// TODO @Dhb52:Tab 切换的时候,应该表单还保留着;清除只有两个时机:1)发送成功后;2)关闭窗口后;我捉摸,是不是每个 TabXXX 组件,是个独立的 Form,然后有自己的对象,不粘在 objData 一起。这样最终就是 MusicMessageForm、ImageMessageForm
// const tempObj = new Map().set(objData.type, Object.assign({}, objData))
const reply = computed<Reply>({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
// 作为多个标签保存各自Reply的缓存
const tabCache = new Map<ReplyType, Reply>()
// 采用独立的ref来保存当前tab,避免在watch标签变化,对reply进行赋值会产生了循环调用
const currentTab = ref<ReplyType>(props.modelValue.type || ReplyType.Text)
/** 切换消息类型的 tab */
const onTabClick = () => {
clear()
}
watch(
currentTab,
(newTab, oldTab) => {
// 第一次进入:oldTab 为 undefined
// 判断 newTab 是因为 Reply 为 Partial
if (oldTab === undefined || newTab === undefined) {
return
}
/** 清除除了`type`的字段 */
tabCache.set(oldTab, unref(reply))
// 从缓存里面取出新tab内容,有则覆盖Reply,没有则创建空Reply
const temp = tabCache.get(newTab)
if (temp) {
reply.value = temp
} else {
let newData = createEmptyReply(reply)
newData.type = newTab
reply.value = newData
}
},
{
immediate: true
}
)
/** 清除除了`type`, `accountId`的字段 */
const clear = () => {
objData.content = ''
objData.mediaId = ''
objData.url = ''
objData.title = ''
objData.description = ''
objData.articles = []
reply.value = createEmptyReply(reply)
}
defineExpose({
@@ -69,13 +128,13 @@ defineExpose({
.select-item {
width: 280px;
padding: 10px;
margin: 0 auto 10px auto;
margin: 0 auto 10px;
border: 1px solid #eaeaea;
}
.select-item2 {
padding: 10px;
margin: 0 auto 10px auto;
margin: 0 auto 10px;
border: 1px solid #eaeaea;
}
@@ -89,11 +148,11 @@ defineExpose({
}
.item-name {
font-size: 12px;
overflow: hidden;
font-size: 12px;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
}
.el-form-item__content {
@@ -101,34 +160,34 @@ defineExpose({
}
.col-select {
border: 1px solid rgb(234, 234, 234);
padding: 50px 0px;
height: 160px;
width: 49.5%;
height: 160px;
padding: 50px 0;
border: 1px solid rgb(234 234 234);
}
.col-select2 {
border: 1px solid rgb(234, 234, 234);
padding: 50px 0px;
height: 160px;
padding: 50px 0;
border: 1px solid rgb(234 234 234);
}
.col-add {
border: 1px solid rgb(234, 234, 234);
padding: 50px 0px;
height: 160px;
width: 49.5%;
float: right;
width: 49.5%;
height: 160px;
padding: 50px 0;
border: 1px solid rgb(234 234 234);
}
.avatar-uploader-icon {
border: 1px solid #d9d9d9;
font-size: 28px;
color: #8c939d;
width: 100px !important;
height: 100px !important;
font-size: 28px;
line-height: 100px !important;
color: #8c939d;
text-align: center;
border: 1px solid #d9d9d9;
}
.material-img {
@@ -0,0 +1,3 @@
import WxVideoPlayer from './main.vue'
export default WxVideoPlayer
@@ -0,0 +1,3 @@
import WxVoicePlayer from './main.vue'
export default WxVoicePlayer
@@ -7,7 +7,7 @@
1)joolun 的做法:使用 mediaId 从微信公众号,下载对应的 mp4 素材,从而播放内容;
存在的问题:mediaId 有效期是 3 天,超过时间后无法播放
2)重构后的做法:后端接收到微信公众号的视频消息后,将视频消息的 media_id 的文件内容保存到文件服务器中,这样前端可以直接使用 URL 播放。
② 代码优化:将 props 中的 objData 调成为 data 中对应的属性,并补充相关注释
② 代码优化:将 props 中的 reply 调成为 data 中对应的属性,并补充相关注释
-->
<template>
<div class="wx-voice-div" @click="playVoice">
@@ -86,18 +86,18 @@ const amrStop = () => {
</script>
<style lang="scss" scoped>
.wx-voice-div {
display: flex;
width: 120px;
height: 50px;
padding: 5px;
background-color: #eaeaea;
border-radius: 10px;
width: 40px;
height: 40px;
display: flex;
justify-content: center;
align-items: center;
}
.amr-duration {
font-size: 11px;
margin-left: 5px;
font-size: 11px;
}
</style>
+11 -27
View File
@@ -27,9 +27,7 @@
:on-success="onUploadSuccess"
>
<template #trigger>
<el-button size="small" type="primary" :loading="isUploading" disabled="isUploading">
{{ isUploading ? '正在上传' : '本地上传' }}
</el-button>
<el-button size="small" type="primary">本地上传</el-button>
</template>
<el-button
size="small"
@@ -52,7 +50,8 @@
destroy-on-close
>
<WxMaterialSelect
:objData="{ type: 'image', accountId: accountId }"
type="image"
:account-id="accountId!"
@select-material="onMaterialSelected"
/>
</el-dialog>
@@ -61,13 +60,13 @@
</template>
<script setup lang="ts">
import WxMaterialSelect from '@/views/mp/components/wx-material-select/main.vue'
import WxMaterialSelect from '@/views/mp/components/wx-material-select'
import { getAccessToken } from '@/utils/auth'
import type { UploadFiles, UploadProps, UploadRawFile } from 'element-plus'
import { UploadType, useBeforeUpload } from '@/views/mp/hooks/useUpload'
import { NewsItem } from './types'
const message = useMessage()
// const UPLOAD_URL = 'http://localhost:8000/upload/' // 上传永久素材的地址
const UPLOAD_URL = import.meta.env.VITE_BASE_URL + '/admin-api/mp/material/upload-permanent' // 上传永久素材的地址
const HEADERS = { Authorization: 'Bearer ' + getAccessToken() } // 设置上传的请求头部
@@ -93,14 +92,13 @@ const showImageDialog = ref(false)
const fileList = ref<UploadFiles>([])
interface UploadData {
type: 'image' | 'video' | 'audio'
accountId?: number
type: UploadType
accountId: number
}
const uploadData: UploadData = reactive({
type: 'image',
accountId: accountId
type: UploadType.Image,
accountId: accountId!
})
const isUploading = ref(false)
/** 素材选择完成事件*/
const onMaterialSelected = (item: any) => {
@@ -109,22 +107,8 @@ const onMaterialSelected = (item: any) => {
newsItem.value.thumbUrl = item.url
}
const onBeforeUpload: UploadProps['beforeUpload'] = (rawFile: UploadRawFile) => {
const isType = ['image/jpeg', 'image/png', 'image/gif', 'image/bmp', 'image/jpg'].includes(
rawFile.type
)
if (!isType) {
message.error('上传图片格式不对!')
return false
}
if (rawFile.size / 1024 / 1024 > 2) {
message.error('上传图片大小不能超过 2M!')
return false
}
// 校验通过
return true
}
const onBeforeUpload: UploadProps['beforeUpload'] = (rawFile: UploadRawFile) =>
useBeforeUpload(UploadType.Image, 2)(rawFile)
const onUploadSuccess: UploadProps['onSuccess'] = (res: any) => {
if (res.code !== 0) {
+1 -1
View File
@@ -36,7 +36,7 @@
</template>
<script setup lang="ts">
import WxNews from '@/views/mp/components/wx-news/main.vue'
import WxNews from '@/views/mp/components/wx-news'
import { Article } from './types'
+1 -1
View File
@@ -125,7 +125,7 @@
</el-container>
</template>
<script setup lang="ts">
<script setup lang="ts" name="NewsForm">
import { Editor } from '@/components/Editor'
import { createEditorConfig } from '../editor-config'
import CoverSelect from './CoverSelect.vue'
+11 -32
View File
@@ -46,7 +46,6 @@
</ContentWrap>
<!-- 添加或修改草稿对话框 -->
<!-- TODO @Dhb52:是不是整个做成一个组件 -->
<el-dialog
:title="isCreating ? '新建图文' : '修改图文'"
width="80%"
@@ -63,7 +62,7 @@
</template>
<script setup lang="ts" name="MpDraft">
import WxAccountSelect from '@/views/mp/components/wx-account-select/main.vue'
import WxAccountSelect from '@/views/mp/components/wx-account-select'
import * as MpDraftApi from '@/api/mp/draft'
import * as MpFreePublishApi from '@/api/mp/freePublish'
import {
@@ -77,30 +76,17 @@ import {
const message = useMessage() // 消息
const accountId = ref(0)
const accountId = ref(-1)
provide('accountId', accountId)
const loading = ref(true) // 列表的加载中
const list = ref<any[]>([]) // 列表的数据
const total = ref(0) // 列表的总页数
interface QueryParams {
pageNo: number
pageSize: number
accountId: number
}
const queryParams: QueryParams = reactive({
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
accountId: accountId.value
})
interface UploadData {
type: 'image' | 'video' | 'audio'
accountId: number
}
const uploadData: UploadData = reactive({
type: 'image',
accountId: accountId.value
accountId: accountId
})
// ========== 草稿新建 or 修改 ==========
@@ -112,7 +98,8 @@ const isSubmitting = ref(false)
/** 侦听公众号变化 **/
const onAccountChanged = (id: number) => {
setAccountId(id)
accountId.value = id
queryParams.pageNo = 1
getList()
}
@@ -125,12 +112,6 @@ const onBeforeDialogClose = async (onDone: () => {}) => {
}
// ======================== 列表查询 ========================
/** 设置账号编号 */
const setAccountId = (id: number) => {
queryParams.accountId = id
uploadData.accountId = id
}
/** 查询列表 */
const getList = async () => {
loading.value = true
@@ -171,10 +152,10 @@ const onSubmitNewsItem = async () => {
isSubmitting.value = true
try {
if (isCreating.value) {
await MpDraftApi.createDraft(queryParams.accountId, newsList.value)
await MpDraftApi.createDraft(accountId.value, newsList.value)
message.notifySuccess('新增成功')
} else {
await MpDraftApi.updateDraft(queryParams.accountId, mediaId.value, newsList.value)
await MpDraftApi.updateDraft(accountId.value, mediaId.value, newsList.value)
message.notifySuccess('更新成功')
}
} finally {
@@ -186,7 +167,6 @@ const onSubmitNewsItem = async () => {
// ======================== 草稿箱发布 ========================
const onPublish = async (item: Article) => {
const accountId = queryParams.accountId
const mediaId = item.mediaId
const content =
'你正在通过发布的方式发表内容。 发布不占用群发次数,一天可多次发布。' +
@@ -194,7 +174,7 @@ const onPublish = async (item: Article) => {
'发布后,你可以前往发表记录获取链接,也可以将发布内容添加到自定义菜单、自动回复、话题和页面模板中。'
try {
await message.confirm(content)
await MpFreePublishApi.submitFreePublish(accountId, mediaId)
await MpFreePublishApi.submitFreePublish(accountId.value, mediaId)
message.notifySuccess('发布成功')
await getList()
} catch {}
@@ -202,11 +182,10 @@ const onPublish = async (item: Article) => {
/** 删除按钮操作 */
const onDelete = async (item: Article) => {
const accountId = queryParams.accountId
const mediaId = item.mediaId
try {
await message.confirm('此操作将永久删除该草稿, 是否继续?')
await MpDraftApi.deleteDraft(accountId, mediaId)
await MpDraftApi.deleteDraft(accountId.value, mediaId)
message.notifySuccess('删除成功')
await getList()
} catch {}
+68 -69
View File
@@ -50,8 +50,8 @@
<script lang="ts" setup name="MpFreePublish">
import * as FreePublishApi from '@/api/mp/freePublish'
import WxNews from '@/views/mp/components/wx-news/main.vue'
import WxAccountSelect from '@/views/mp/components/wx-account-select/main.vue'
import WxNews from '@/views/mp/components/wx-news'
import WxAccountSelect from '@/views/mp/components/wx-account-select'
const message = useMessage() // 消息弹窗
const { t } = useI18n() // 国际化
@@ -59,20 +59,16 @@ const loading = ref(true) // 列表的加载中
const total = ref(0) // 列表的总页数
const list = ref<any[]>([]) // 列表的数据
interface QueryParams {
pageNo: number
pageSize: number
accountId?: number
}
const queryParams: QueryParams = reactive({
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
accountId: undefined
accountId: -1
})
/** 侦听公众号变化 **/
const onAccountChanged = (id: number | undefined) => {
const onAccountChanged = (id: number) => {
queryParams.accountId = id
queryParams.pageNo = 1
getList()
}
@@ -102,19 +98,45 @@ const handleDelete = async (item: any) => {
}
</script>
<style lang="scss" scoped>
@media (min-width: 992px) and (max-width: 1300px) {
.waterfall {
column-count: 3;
}
p {
color: red;
}
}
@media (min-width: 768px) and (max-width: 991px) {
.waterfall {
column-count: 2;
}
p {
color: orange;
}
}
@media (max-width: 767px) {
.waterfall {
column-count: 1;
}
}
.ope-row {
padding-top: 5px;
margin-top: 5px;
text-align: center;
border-top: 1px solid #eaeaea;
padding-top: 5px;
}
.item-name {
font-size: 12px;
overflow: hidden;
font-size: 12px;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
}
.el-upload__tip {
@@ -125,8 +147,8 @@ const handleDelete = async (item: any) => {
.left {
display: inline-block;
width: 35%;
vertical-align: top;
margin-top: 200px;
vertical-align: top;
}
.right {
@@ -136,16 +158,16 @@ const handleDelete = async (item: any) => {
}
.avatar-uploader {
width: 20%;
display: inline-block;
width: 20%;
}
.avatar-uploader .el-upload {
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
text-align: unset !important;
cursor: pointer;
border-radius: 6px;
}
.avatar-uploader .el-upload:hover {
@@ -153,13 +175,13 @@ const handleDelete = async (item: any) => {
}
.avatar-uploader-icon {
border: 1px solid #d9d9d9;
font-size: 28px;
color: #8c939d;
width: 120px;
height: 120px;
font-size: 28px;
line-height: 120px;
color: #8c939d;
text-align: center;
border: 1px solid #d9d9d9;
}
.avatar {
@@ -173,13 +195,14 @@ const handleDelete = async (item: any) => {
}
.digest {
width: 60%;
display: inline-block;
width: 60%;
vertical-align: top;
}
/*新增图文*/
/*瀑布流样式*/
/* 新增图文 */
/* 瀑布流样式 */
.waterfall {
width: 100%;
column-gap: 10px;
@@ -198,68 +221,44 @@ p {
line-height: 30px;
}
@media (min-width: 992px) and (max-width: 1300px) {
.waterfall {
column-count: 3;
}
p {
color: red;
}
}
@media (min-width: 768px) and (max-width: 991px) {
.waterfall {
column-count: 2;
}
p {
color: orange;
}
}
@media (max-width: 767px) {
.waterfall {
column-count: 1;
}
}
/*瀑布流样式*/
/* 瀑布流样式 */
.news-main {
background-color: #ffffff;
width: 100%;
margin: auto;
height: 120px;
margin: auto;
background-color: #fff;
}
.news-content {
background-color: #acadae;
position: relative;
width: 100%;
height: 120px;
position: relative;
background-color: #acadae;
}
.news-content-title {
display: inline-block;
font-size: 15px;
color: #ffffff;
position: absolute;
left: 0px;
bottom: 0px;
background-color: black;
bottom: 0;
left: 0;
display: inline-block;
width: 98%;
height: 25px;
padding: 1%;
opacity: 0.65;
overflow: hidden;
font-size: 15px;
color: #fff;
text-overflow: ellipsis;
white-space: nowrap;
height: 25px;
background-color: black;
opacity: 0.65;
}
.news-main-item {
background-color: #ffffff;
padding: 5px 0px;
border-top: 1px solid #eaeaea;
width: 100%;
padding: 5px 0;
margin: auto;
background-color: #fff;
border-top: 1px solid #eaeaea;
}
.news-content-item {
@@ -269,8 +268,8 @@ p {
.news-content-item-title {
display: inline-block;
font-size: 12px;
width: 70%;
font-size: 12px;
}
.news-content-item-img {
@@ -289,9 +288,9 @@ p {
.news-main-plus {
width: 280px;
text-align: center;
margin: auto;
height: 50px;
margin: auto;
text-align: center;
}
.icon-plus {
@@ -302,15 +301,15 @@ p {
.select-item {
width: 60%;
padding: 10px;
margin: 0 auto 10px auto;
margin: 0 auto 10px;
border: 1px solid #eaeaea;
}
.father .child {
display: none;
text-align: center;
position: relative;
bottom: 25px;
display: none;
text-align: center;
}
.father:hover .child {
+6 -6
View File
@@ -2,29 +2,29 @@ import type { UploadRawFile } from 'element-plus'
const message = useMessage() // 消息
enum MaterialType {
enum UploadType {
Image = 'image',
Voice = 'voice',
Video = 'video'
}
const useBeforeUpload = (type: MaterialType, maxSizeMB: number) => {
const useBeforeUpload = (type: UploadType, maxSizeMB: number) => {
const fn = (rawFile: UploadRawFile): boolean => {
let allowTypes: string[] = []
let name = ''
switch (type) {
case MaterialType.Image:
case UploadType.Image:
allowTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/bmp', 'image/jpg']
maxSizeMB = 2
name = '图片'
break
case MaterialType.Voice:
case UploadType.Voice:
allowTypes = ['audio/mp3', 'audio/mpeg', 'audio/wma', 'audio/wav', 'audio/amr']
maxSizeMB = 2
name = '语音'
break
case MaterialType.Video:
case UploadType.Video:
allowTypes = ['video/mp4']
maxSizeMB = 10
name = '视频'
@@ -47,4 +47,4 @@ const useBeforeUpload = (type: MaterialType, maxSizeMB: number) => {
return fn
}
export { MaterialType, useBeforeUpload }
export { UploadType, useBeforeUpload }
+24 -24
View File
@@ -31,30 +31,6 @@ const emit = defineEmits<{
</script>
<style lang="scss" scoped>
/*瀑布流样式*/
.waterfall {
width: 100%;
column-gap: 10px;
column-count: 5;
margin-top: 10px;
/* 芋道源码:增加 10px,避免顶着上面 */
}
.waterfall-item {
padding: 10px;
margin-bottom: 10px;
break-inside: avoid;
border: 1px solid #eaeaea;
}
.material-img {
width: 100%;
}
p {
line-height: 30px;
}
@media (min-width: 992px) and (max-width: 1300px) {
.waterfall {
column-count: 3;
@@ -80,4 +56,28 @@ p {
column-count: 1;
}
}
.waterfall {
width: 100%;
column-gap: 10px;
column-count: 5;
margin-top: 10px;
/* 芋道源码:增加 10px,避免顶着上面 */
}
.waterfall-item {
padding: 10px;
margin-bottom: 10px;
break-inside: avoid;
border: 1px solid #eaeaea;
}
.material-img {
width: 100%;
}
p {
line-height: 30px;
}
</style>
@@ -6,14 +6,11 @@
:limit="1"
:file-list="fileList"
:data="uploadData"
:on-progress="(isUploading = true)"
:on-error="onUploadError"
:before-upload="onBeforeUpload"
:on-success="onUploadSuccess"
>
<el-button type="primary" plain :loading="isUploading" :disabled="isUploading">
{{ isUploading ? '正在上传' : '点击上传' }}
</el-button>
<el-button type="primary" plain> 点击上传 </el-button>
<template #tip>
<span class="el-upload__tip" style="margin-left: 5px">
<slot></slot>
@@ -27,14 +24,14 @@ import {
HEADERS,
UPLOAD_URL,
UploadData,
MaterialType,
UploadType,
beforeImageUpload,
beforeVoiceUpload
} from './upload'
const message = useMessage()
const props = defineProps<{ type: MaterialType }>()
const props = defineProps<{ type: UploadType }>()
const fileList = ref<UploadUserFile[]>([])
const emit = defineEmits<{
@@ -42,14 +39,13 @@ const emit = defineEmits<{
}>()
const uploadData: UploadData = reactive({
type: MaterialType.Image,
type: UploadType.Image,
title: '',
introduction: ''
})
const isUploading = ref(false)
/** 上传前检查 */
const onBeforeUpload = props.type === MaterialType.Image ? beforeImageUpload : beforeVoiceUpload
const onBeforeUpload = props.type === UploadType.Image ? beforeImageUpload : beforeVoiceUpload
/** 上传成功处理 */
const onUploadSuccess: UploadProps['onSuccess'] = (res: any) => {
@@ -64,7 +60,6 @@ const onUploadSuccess: UploadProps['onSuccess'] = (res: any) => {
uploadData.introduction = ''
message.notifySuccess('上传成功')
isUploading.value = false
emit('uploaded')
}
@@ -1,5 +1,5 @@
<template>
<el-dialog title="新建视频" v-model="showDialog" width="600px" destroy-on-close>
<el-dialog title="新建视频" v-model="showDialog" width="600px">
<el-upload
:action="UPLOAD_URL"
:headers="HEADERS"
@@ -8,7 +8,6 @@
:file-list="fileList"
:data="uploadData"
:before-upload="beforeVideoUpload"
:on-progress="(isUploading = true)"
:on-error="onUploadError"
:on-success="onUploadSuccess"
ref="uploadVideoRef"
@@ -18,12 +17,14 @@
<template #trigger>
<el-button type="primary" plain>选择视频</el-button>
</template>
<span class="el-upload__tip" style="margin-left: 10px"
>格式支持 MP4,文件大小不超过 10MB</span
>
<template #tip>
<span class="el-upload__tip" style="margin-left: 10px"
>格式支持 MP4,文件大小不超过 10MB</span
>
</template>
</el-upload>
<el-divider />
<el-form :model="uploadData" :rules="uploadRules" ref="uploadFormRef" v-loading="isUploading">
<el-form :model="uploadData" :rules="uploadRules" ref="uploadFormRef">
<el-form-item label="标题" prop="title">
<el-input
v-model="uploadData.title"
@@ -41,9 +42,7 @@
</el-form>
<template #footer>
<el-button @click="showDialog = false">取 消</el-button>
<el-button type="primary" @click="submitVideo" :loading="isUploading" :disabled="isUploading"
>提 交</el-button
>
<el-button type="primary" @click="submitVideo">提 交</el-button>
</template>
</el-dialog>
</template>
@@ -56,7 +55,7 @@ import type {
UploadProps,
UploadUserFile
} from 'element-plus'
import { HEADERS, UploadData, UPLOAD_URL, beforeVideoUpload, MaterialType } from './upload'
import { HEADERS, UploadData, UPLOAD_URL, UploadType, beforeVideoUpload } from './upload'
const message = useMessage()
@@ -85,18 +84,16 @@ const showDialog = computed<boolean>({
}
})
const isUploading = ref(false)
const fileList = ref<UploadUserFile[]>([])
const uploadData: UploadData = reactive({
type: MaterialType.Video,
type: UploadType.Video,
title: '',
introduction: ''
})
const uploadFormRef = ref<FormInstance>()
const uploadVideoRef = ref<UploadInstance>()
const uploadFormRef = ref<FormInstance | null>(null)
const uploadVideoRef = ref<UploadInstance | null>(null)
const submitVideo = () => {
uploadFormRef.value?.validate((valid) => {
@@ -109,7 +106,6 @@ const submitVideo = () => {
/** 上传成功处理 */
const onUploadSuccess: UploadProps['onSuccess'] = (res: any) => {
isUploading.value = false
if (res.code !== 0) {
message.error('上传出错:' + res.msg)
return false
@@ -39,7 +39,7 @@
</template>
<script setup lang="ts">
import WxVideoPlayer from '@/views/mp/components/wx-video-play/main.vue'
import WxVideoPlayer from '@/views/mp/components/wx-video-play'
import { dateFormatter } from '@/utils/formatTime'
const props = defineProps<{
@@ -37,7 +37,7 @@
</template>
<script setup lang="ts">
import WxVoicePlayer from '@/views/mp/components/wx-voice-play/main.vue'
import WxVoicePlayer from '@/views/mp/components/wx-voice-play'
import { dateFormatter } from '@/utils/formatTime'
const props = defineProps<{
+6 -6
View File
@@ -1,29 +1,29 @@
import type { UploadProps, UploadRawFile } from 'element-plus'
import { getAccessToken } from '@/utils/auth'
import { MaterialType, useBeforeUpload } from '@/views/mp/hooks/useUpload'
import { UploadType, useBeforeUpload } from '@/views/mp/hooks/useUpload'
const HEADERS = { Authorization: 'Bearer ' + getAccessToken() } // 请求头
const UPLOAD_URL = import.meta.env.VITE_BASE_URL + '/admin-api/mp/material/upload-permanent' // 上传地址
interface UploadData {
type: MaterialType
type: UploadType
title: string
introduction: string
}
const beforeImageUpload: UploadProps['beforeUpload'] = (rawFile: UploadRawFile) =>
useBeforeUpload(MaterialType.Image, 2)(rawFile)
useBeforeUpload(UploadType.Image, 2)(rawFile)
const beforeVoiceUpload: UploadProps['beforeUpload'] = (rawFile: UploadRawFile) =>
useBeforeUpload(MaterialType.Voice, 2)(rawFile)
useBeforeUpload(UploadType.Voice, 2)(rawFile)
const beforeVideoUpload: UploadProps['beforeUpload'] = (rawFile: UploadRawFile) =>
useBeforeUpload(MaterialType.Video, 10)(rawFile)
useBeforeUpload(UploadType.Video, 10)(rawFile)
export {
HEADERS,
UPLOAD_URL,
MaterialType,
UploadType,
UploadData,
beforeImageUpload,
beforeVoiceUpload,
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff. Show more