Преглед на файлове

Merge remote-tracking branch 'yudao/dev' into dev

dhb52 преди 2 години
родител
ревизия
c3b0403ade
променени са 49 файла, в които са добавени 3262 реда и са изтрити 330 реда
  1. 8 7
      README.md
  2. 59 47
      build/vite/optimize.ts
  3. 33 34
      package.json
  4. 5 0
      src/api/mall/product/brand.ts
  5. 2 2
      src/api/mall/product/property.ts
  6. 79 33
      src/api/mall/product/spu.ts
  7. 40 0
      src/api/mall/trade/delivery/express/index.ts
  8. 54 0
      src/api/mall/trade/delivery/expressTemplate/index.ts
  9. 8 0
      src/api/system/area/index.ts
  10. 1 1
      src/api/system/notify/template/index.ts
  11. 1 1
      src/api/system/oauth2/token.ts
  12. 4 4
      src/components/Form/src/helper.ts
  13. 1 1
      src/components/Form/src/types.ts
  14. 44 14
      src/components/UploadFile/src/UploadImgs.vue
  15. 40 4
      src/router/modules/remaining.ts
  16. 6 0
      src/styles/index.scss
  17. 54 0
      src/utils/index.ts
  18. 2 1
      src/utils/tree.ts
  19. 1 1
      src/views/bpm/definition/index.vue
  20. 1 1
      src/views/bpm/group/UserGroupForm.vue
  21. 2 1
      src/views/bpm/group/index.vue
  22. 1 1
      src/views/bpm/model/editor/index.vue
  23. 1 1
      src/views/bpm/model/index.vue
  24. 1 1
      src/views/bpm/oa/leave/detail.vue
  25. 1 1
      src/views/bpm/processInstance/create/index.vue
  26. 6 4
      src/views/infra/build/index.vue
  27. 8 7
      src/views/infra/codegen/PreviewCode.vue
  28. 188 92
      src/views/infra/redis/index.vue
  29. 31 23
      src/views/mall/product/property/index.vue
  30. 178 0
      src/views/mall/product/spu/addForm.vue
  31. 274 0
      src/views/mall/product/spu/components/BasicInfoForm.vue
  32. 83 0
      src/views/mall/product/spu/components/DescriptionForm.vue
  33. 145 0
      src/views/mall/product/spu/components/OtherSettingsForm.vue
  34. 117 0
      src/views/mall/product/spu/components/ProductAttributes.vue
  35. 98 0
      src/views/mall/product/spu/components/ProductAttributesAddForm.vue
  36. 317 0
      src/views/mall/product/spu/components/SkuList.vue
  37. 15 0
      src/views/mall/product/spu/components/index.ts
  38. 422 0
      src/views/mall/product/spu/index.vue
  39. 124 0
      src/views/mall/trade/delivery/express/ExpressForm.vue
  40. 184 0
      src/views/mall/trade/delivery/express/index.vue
  41. 405 0
      src/views/mall/trade/delivery/expressTemplate/ExpressTemplateForm.vue
  42. 160 0
      src/views/mall/trade/delivery/expressTemplate/index.vue
  43. 3 1
      src/views/system/dict/data/DictDataForm.vue
  44. 43 34
      src/views/system/dict/index.vue
  45. 4 4
      src/views/system/notice/NoticeForm.vue
  46. 3 4
      src/views/system/notify/template/NotifyTemplateSendForm.vue
  47. 3 3
      src/views/system/oauth2/token/index.vue
  48. 1 1
      src/views/system/post/PostForm.vue
  49. 1 1
      stylelint.config.js

+ 8 - 7
README.md

@@ -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)
@@ -38,11 +39,11 @@
 | 框架                                                                   | 说明               | 版本     |
 |----------------------------------------------------------------------|------------------|--------|
 | [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 |
+| [Vite](https://cn.vitejs.dev//)                                      | 开发与构建工具          | 4.3.1  |
+| [Element Plus](https://element-plus.org/zh-CN/)                      | Element Plus     | 2.3.3 |
+| [TypeScript](https://www.typescriptlang.org/docs/)                   | JavaScript 的超集   | 5.0.4  |
+| [pinia](https://pinia.vuejs.org/)                                    | Vue 存储库 替代 vuex5 | 2.0.35 |
+| [vueuse](https://vueuse.org/)                                        | 常用工具集            | 10.1.0 |
 | [vue-i18n](https://kazupon.github.io/vue-i18n/zh/introduction.html/) | 国际化              | 9.2.2  |
 | [vue-router](https://router.vuejs.org/)                              | Vue 路由           | 4.1.6  |
 | [windicss](https://cn.windicss.org/)                                 | 下一代工具优先的 CSS 框架  | 3.5.6  |

+ 59 - 47
build/vite/optimize.ts

@@ -17,7 +17,6 @@ const include = [
   'cropperjs',
   'lodash-es',
   'nprogress',
-  'animate.css',
   'web-storage-cache',
   '@iconify/iconify',
   '@vueuse/core',
@@ -33,38 +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',
@@ -78,20 +97,13 @@ const include = [
   '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/tag/style/css',
-  'element-plus/es/components/dialog/style/css',
-  'element-plus/es/components/form/style/css',
-  'element-plus/es/components/form-item/style/css',
-  'element-plus/es/components/card/style/css',
-  'element-plus/es/components/tooltip/style/css',
-  'element-plus/es/components/radio-group/style/css',
-  'element-plus/es/components/radio/style/css',
-  'element-plus/es/components/input-number/style/css',
-  'element-plus/es/components/tree-select/style/css',
-  'element-plus/es/components/drawer/style/css',
-  'element-plus/es/components/image-viewer/style/css',
-  'element-plus/es/components/upload/style/css',
-  'element-plus/es/components/switch/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']

+ 33 - 34
package.json

@@ -33,12 +33,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.0",
     "@wangeditor/editor": "^5.1.23",
     "@wangeditor/editor-for-vue": "^5.1.10",
     "@zxcvbn-ts/core": "^2.2.1",
     "animate.css": "^4.1.1",
-    "axios": "^1.3.5",
+    "axios": "^1.3.6",
     "benz-amr-recorder": "^1.1.5",
     "bpmn-js-token-simulation": "^0.10.0",
     "camunda-bpmn-moddle": "^7.0.1",
@@ -46,19 +46,19 @@
     "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",
+    "fast-xml-parser": "^4.2.2",
     "highlight.js": "^11.7.0",
     "intro.js": "^7.0.1",
     "jsencrypt": "^3.3.2",
     "lodash-es": "^4.17.21",
-    "min-dash": "^4.0.0",
+    "min-dash": "^4.1.0",
     "mitt": "^3.0.0",
     "nprogress": "^0.2.0",
-    "pinia": "^2.0.34",
-    "qrcode": "^1.5.1",
+    "pinia": "^2.0.35",
+    "qrcode": "^1.5.3",
     "qs": "^6.11.1",
     "steady-xml": "^0.1.0",
     "url": "^0.11.0",
@@ -73,61 +73,60 @@
     "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.1",
+    "@commitlint/config-conventional": "^17.6.1",
+    "@iconify/json": "^2.2.54",
     "@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",
+    "@typescript-eslint/eslint-plugin": "^5.59.0",
+    "@typescript-eslint/parser": "^5.59.0",
     "@vitejs/plugin-legacy": "^4.0.2",
     "@vitejs/plugin-vue": "^4.1.0",
     "@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.39.0",
     "eslint-config-prettier": "^8.8.0",
-    "eslint-define-config": "^1.17.0",
+    "eslint-define-config": "^1.18.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.11.0",
+    "lint-staged": "^13.2.1",
+    "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.0",
+    "rollup": "^3.20.7",
+    "sass": "^1.62.0",
+    "stylelint": "^15.6.0",
     "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.1",
+    "typescript": "5.0.4",
+    "unplugin-auto-import": "^0.15.3",
+    "unplugin-element-plus": "^0.7.1",
     "unplugin-vue-components": "^0.24.1",
-    "vite": "4.2.1",
+    "vite": "4.3.1",
     "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",
+    "vue-tsc": "^1.4.4",
     "windicss": "^3.5.6"
   },
   "engines": {

+ 5 - 0
src/api/mall/product/brand.ts

@@ -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' })
+}

+ 2 - 2
src/api/mall/product/property.ts

@@ -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 })
 }
 
 // ------------------------ 属性值 -------------------

+ 79 - 33
src/api/mall/product/spu.ts

@@ -1,46 +1,92 @@
 import request from '@/config/axios'
 
-// 创建商品 SPU
-export function createSpu(data) {
-  return request.post({
-    url: '/product/spu/create',
-    data: data
-  })
+export interface Property {
+  propertyId?: number // 属性编号
+  propertyName?: string // 属性名称
+  valueId?: number // 属性值编号
+  valueName?: string // 属性值名称
 }
 
-// 更新商品 SPU
-export function updateSpu(data) {
-  return request.put({
-    url: '/product/spu/update',
-    data: data
-  })
+// 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 // 商品销量
 }
 
-// 删除商品 SPU
-export function deleteSpu(id) {
-  return request.delete({
-    url: `/product/spu/delete?id=${id}`
-  })
+// 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 function getSpuDetail(id) {
-  return request.get({
-    url: `/product/spu/get-detail?id=${id}`
-  })
+// 获得 Spu 列表
+export const getSpuPage = (params: PageParam) => {
+  return request.get({ url: '/product/spu/page', params })
 }
 
-// 获得商品 SPU 分页
-export function getSpuPage(query) {
-  return request.get({
-    url: '/product/spu/page',
-    params: query
-  })
+// 获得 Spu 列表 tabsCount
+export const getTabsCount = () => {
+  return request.get({ url: '/product/spu/get-count' })
 }
 
-// 获得商品 SPU 精简列表
-export function getSpuSimpleList() {
-  return request.get({
-    url: '/product/spu/get-simple-list'
-  })
+// 创建商品 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 })
 }

+ 40 - 0
src/api/mall/trade/delivery/express/index.ts

@@ -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 })
+}

+ 54 - 0
src/api/mall/trade/delivery/expressTemplate/index.ts

@@ -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 })
+}

+ 8 - 0
src/api/system/area/index.ts

@@ -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
src/api/system/notify/template/index.ts

@@ -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
src/api/system/oauth2/token.ts

@@ -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
src/components/Form/src/helper.ts

@@ -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
src/components/Form/src/types.ts

@@ -1,6 +1,6 @@
 import { FormSchema } from '@/types/form'
 
-export interface PlaceholderMoel {
+export interface PlaceholderModel {
   placeholder?: string
   startPlaceholder?: string
   endPlaceholder?: string

+ 44 - 14
src/components/UploadFile/src/UploadImgs.vue

@@ -1,19 +1,19 @@
 <template>
   <div class="upload-box">
     <el-upload
+      v-model:file-list="fileList"
+      :accept="fileType.join(',')"
       :action="updateUrl"
-      list-type="picture-card"
+      :before-upload="beforeUpload"
       :class="['upload', drag ? 'no-border' : '']"
-      v-model:file-list="fileList"
-      :multiple="true"
-      :limit="limit"
+      :drag="drag"
       :headers="uploadHeaders"
-      :before-upload="beforeUpload"
+      :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;

+ 40 - 4
src/router/modules/remaining.ts

@@ -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 - 0
src/styles/index.scss

@@ -10,6 +10,12 @@
   width: 100% !important;
 }
 
+// 解决表格内容超过表格总宽度后,横向滚动条前端顶不到表格边缘的问题
+.el-scrollbar__bar {
+  display: flex;
+  justify-content: flex-start;
+}
+
 /* nprogress 适配 element-plus 的主题色 */
 #nprogress {
   & .bar {

+ 54 - 0
src/utils/index.ts

@@ -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
src/utils/tree.ts

@@ -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)

+ 1 - 1
src/views/bpm/definition/index.vue

@@ -87,7 +87,7 @@
     <MyProcessViewer
       key="designer"
       v-model="bpmnXML"
-      :value="bpmnXML"
+      :value="bpmnXML as any"
       v-bind="bpmnControlForm"
       :prefix="bpmnControlForm.prefix"
     />

+ 1 - 1
src/views/bpm/group/UserGroupForm.vue

@@ -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
src/views/bpm/group/index.vue

@@ -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
src/views/bpm/model/editor/index.vue

@@ -16,7 +16,7 @@
     <!-- 流程属性器,负责编辑每个流程节点的属性 -->
     <MyProcessPenal
       key="penal"
-      :bpmnModeler="modeler"
+      :bpmnModeler="modeler as any"
       :prefix="controlForm.prefix"
       class="process-panel"
       :model="model"

+ 1 - 1
src/views/bpm/model/index.vue

@@ -219,7 +219,7 @@
     <MyProcessViewer
       key="designer"
       v-model="bpmnXML"
-      :value="bpmnXML"
+      :value="bpmnXML as any"
       v-bind="bpmnControlForm"
       :prefix="bpmnControlForm.prefix"
     />

+ 1 - 1
src/views/bpm/oa/leave/detail.vue

@@ -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 编号
 
 /** 获得数据 */

+ 1 - 1
src/views/bpm/processInstance/create/index.vue

@@ -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
src/views/infra/build/index.vue

@@ -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
src/views/infra/codegen/PreviewCode.vue

@@ -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)

+ 188 - 92
src/views/infra/redis/index.vue

@@ -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,224 @@
       <!-- 命令统计 -->
       <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 echarts from '@/plugins/echarts'
+import { GaugeChart } from 'echarts/charts'
+import { ToolboxComponent } from 'echarts/components'
 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({
+  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 getSummary = () => {
+  // 初始化命令图表
+  initCommandStatsChart()
+  usedMemoryInstance()
+}
+
+/** 命令使用情况 */
+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 // 格式化函数或者字符串
+    }
 
-  const usedMemoryInstance = echarts.init(usedmemory.value!, 'macarons')
-  usedMemoryInstance.setOption({
-    title: {
-      text: '内存使用情况',
-      left: 'center'
-    },
-    tooltip: {
+    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
-    },
-    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: '内存消耗'
-          }
-        ]
-      }
-    ]
-  })
+    }
+  } catch {}
 }
 
-onBeforeMount(() => {
-  // TODO @hiiwbs 微信,优化使用 Echart 组件
+/** 初始化 **/
+onMounted(() => {
+  echarts.use([ToolboxComponent])
+  echarts.use([GaugeChart])
+  // 读取 redis 信息
   readRedisInfo()
+  // 加载数据
+  getSummary()
 })
 </script>

+ 31 - 23
src/views/mall/product/property/index.vue

@@ -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="请输入名称"
+          class="!w-240px"
           clearable
+          placeholder="请输入名称"
           @keyup.enter="handleQuery"
-          class="!w-240px"
         />
       </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 - 0
src/views/mall/product/spu/addForm.vue

@@ -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>

+ 274 - 0
src/views/mall/product/spu/components/BasicInfoForm.vue

@@ -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>

+ 83 - 0
src/views/mall/product/spu/components/DescriptionForm.vue

@@ -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>

+ 145 - 0
src/views/mall/product/spu/components/OtherSettingsForm.vue

@@ -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>

+ 117 - 0
src/views/mall/product/spu/components/ProductAttributes.vue

@@ -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>

+ 98 - 0
src/views/mall/product/spu/components/ProductAttributesAddForm.vue

@@ -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>

+ 317 - 0
src/views/mall/product/spu/components/SkuList.vue

@@ -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>

+ 15 - 0
src/views/mall/product/spu/components/index.ts

@@ -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 - 0
src/views/mall/product/spu/index.vue

@@ -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>

+ 124 - 0
src/views/mall/trade/delivery/express/ExpressForm.vue

@@ -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>

+ 184 - 0
src/views/mall/trade/delivery/express/index.vue

@@ -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>

+ 405 - 0
src/views/mall/trade/delivery/expressTemplate/ExpressTemplateForm.vue

@@ -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>

+ 160 - 0
src/views/mall/trade/delivery/expressTemplate/index.vue

@@ -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>

+ 3 - 1
src/views/system/dict/data/DictDataForm.vue

@@ -122,7 +122,9 @@ const open = async (type: string, id?: number, dictType?: string) => {
   dialogTitle.value = t('action.' + type)
   formType.value = type
   resetForm()
-  formData.value.dictType = dictType
+  if (dictType) {
+    formData.value.dictType = dictType
+  }
   // 修改时,设置数据
   if (id) {
     formLoading.value = true

+ 43 - 34
src/views/system/dict/index.vue

@@ -2,36 +2,36 @@
   <!-- 搜索工作栏 -->
   <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="请输入字典名称"
+          class="!w-240px"
           clearable
+          placeholder="请输入字典名称"
           @keyup.enter="handleQuery"
-          class="!w-240px"
         />
       </el-form-item>
       <el-form-item label="字典类型" prop="type">
         <el-input
           v-model="queryParams.type"
-          placeholder="请输入字典类型"
+          class="!w-240px"
           clearable
+          placeholder="请输入字典类型"
           @keyup.enter="handleQuery"
-          class="!w-240px"
         />
       </el-form-item>
       <el-form-item label="状态" prop="status">
         <el-select
           v-model="queryParams.status"
-          placeholder="请选择字典状态"
-          clearable
           class="!w-240px"
+          clearable
+          placeholder="请选择字典状态"
         >
           <el-option
             v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
@@ -44,33 +44,41 @@
       <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
-          type="primary"
+          v-hasPermi="['system:dict:create']"
           plain
+          type="primary"
           @click="openForm('create')"
-          v-hasPermi="['system:dict:create']"
         >
-          <Icon icon="ep:plus" class="mr-5px" /> 新增
+          <Icon class="mr-5px" icon="ep:plus" />
+          新增
         </el-button>
         <el-button
-          type="success"
+          v-hasPermi="['system:dict:export']"
+          :loading="exportLoading"
           plain
+          type="success"
           @click="handleExport"
-          :loading="exportLoading"
-          v-hasPermi="['system:dict:export']"
         >
-          <Icon icon="ep:download" class="mr-5px" /> 导出
+          <Icon class="mr-5px" icon="ep:download" />
+          导出
         </el-button>
       </el-form-item>
     </el-form>
@@ -79,29 +87,29 @@
   <!-- 列表 -->
   <ContentWrap>
     <el-table v-loading="loading" :data="list">
-      <el-table-column label="字典编号" align="center" prop="id" />
-      <el-table-column label="字典名称" align="center" prop="name" show-overflow-tooltip />
-      <el-table-column label="字典类型" align="center" prop="type" width="300" />
-      <el-table-column label="状态" align="center" prop="status">
+      <el-table-column align="center" label="字典编号" prop="id" />
+      <el-table-column align="center" label="字典名称" prop="name" show-overflow-tooltip />
+      <el-table-column align="center" label="字典类型" prop="type" width="300" />
+      <el-table-column align="center" label="状态" 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="remark" />
+      <el-table-column align="center" label="备注" prop="remark" />
       <el-table-column
-        label="创建时间"
         :formatter="dateFormatter"
         align="center"
+        label="创建时间"
         prop="createTime"
         width="180"
       />
-      <el-table-column label="操作" align="center">
+      <el-table-column align="center" label="操作">
         <template #default="scope">
           <el-button
+            v-hasPermi="['system:dict:update']"
             link
             type="primary"
             @click="openForm('update', scope.row.id)"
-            v-hasPermi="['system:dict:update']"
           >
             修改
           </el-button>
@@ -109,10 +117,10 @@
             <el-button link type="primary">数据</el-button>
           </router-link>
           <el-button
+            v-hasPermi="['system:dict:delete']"
             link
             type="danger"
             @click="handleDelete(scope.row.id)"
-            v-hasPermi="['system:dict:delete']"
           >
             删除
           </el-button>
@@ -121,9 +129,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>
@@ -132,12 +140,13 @@
   <DictTypeForm ref="formRef" @success="getList" />
 </template>
 
-<script setup lang="ts" name="SystemDictType">
-import { getIntDictOptions, DICT_TYPE } from '@/utils/dict'
+<script lang="ts" name="SystemDictType" setup>
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
 import { dateFormatter } from '@/utils/formatTime'
 import * as DictTypeApi from '@/api/system/dict/dict.type'
 import DictTypeForm from './DictTypeForm.vue'
 import download from '@/utils/download'
+
 const message = useMessage() // 消息弹窗
 const { t } = useI18n() // 国际化
 

+ 4 - 4
src/views/system/notice/NoticeForm.vue

@@ -17,9 +17,9 @@
         <el-select v-model="formData.type" clearable placeholder="请选择公告类型">
           <el-option
             v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_NOTICE_TYPE)"
-            :key="parseInt(dict.value)"
+            :key="parseInt(dict.value as any)"
             :label="dict.label"
-            :value="parseInt(dict.value)"
+            :value="parseInt(dict.value as any)"
           />
         </el-select>
       </el-form-item>
@@ -27,9 +27,9 @@
         <el-select v-model="formData.status" clearable placeholder="请选择状态">
           <el-option
             v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
-            :key="parseInt(dict.value)"
+            :key="parseInt(dict.value as any)"
             :label="dict.label"
-            :value="parseInt(dict.value)"
+            :value="parseInt(dict.value as any)"
           />
         </el-select>
       </el-form-item>

+ 3 - 4
src/views/system/notify/template/NotifyTemplateSendForm.vue

@@ -44,7 +44,6 @@
   </Dialog>
 </template>
 <script lang="ts" name="SystemNotifyTemplateSendForm" setup>
-import * as SmsTemplateApi from '@/api/system/sms/smsTemplate'
 import * as UserApi from '@/api/system/user'
 import * as NotifyTemplateApi from '@/api/system/notify/template'
 const message = useMessage() // 消息弹窗
@@ -102,8 +101,8 @@ const submitForm = async () => {
   // 提交请求
   formLoading.value = true
   try {
-    const data = formData.value as SmsTemplateApi.SendSmsReqVO
-    const logId = await SmsTemplateApi.sendSms(data)
+    const data = formData.value as unknown as NotifyTemplateApi.NotifySendReqVO
+    const logId = await NotifyTemplateApi.sendNotify(data)
     if (logId) {
       message.success('提交发送成功!发送结果,见发送日志编号:' + logId)
     }
@@ -121,7 +120,7 @@ const resetForm = () => {
     mobile: '',
     templateCode: '',
     templateParams: new Map()
-  }
+  } as any
   formRef.value?.resetFields()
 }
 </script>

+ 3 - 3
src/views/system/oauth2/token/index.vue

@@ -80,7 +80,7 @@
           <el-button
             link
             type="danger"
-            @click="handleForceLogout(scope.row.id)"
+            @click="handleForceLogout(scope.row.accessToken)"
             v-hasPermi="['system:oauth2-token:delete']"
           >
             强退
@@ -142,12 +142,12 @@ const resetQuery = () => {
 }
 
 /** 强制退出操作 */
-const handleForceLogout = async (id: number) => {
+const handleForceLogout = async (accessToken: string) => {
   try {
     // 删除的二次确认
     await message.confirm('是否要强制退出用户')
     // 发起删除
-    await OAuth2AccessTokenApi.deleteAccessToken(id)
+    await OAuth2AccessTokenApi.deleteAccessToken(accessToken)
     message.success(t('common.success'))
     // 刷新列表
     await getList()

+ 1 - 1
src/views/system/post/PostForm.vue

@@ -117,7 +117,7 @@ const resetForm = () => {
     sort: undefined,
     status: CommonStatusEnum.ENABLE,
     remark: ''
-  }
+  } as any
   formRef.value?.resetFields()
 }
 </script>

+ 1 - 1
stylelint.config.js

@@ -2,7 +2,7 @@ module.exports = {
   root: true,
   plugins: ['stylelint-order'],
   customSyntax: 'postcss-html',
-  extends: ['stylelint-config-standard', 'stylelint-config-prettier'],
+  extends: ['stylelint-config-standard'],
   rules: {
     'selector-pseudo-class-no-unknown': [
       true,