소스 검색

!157 合并商城部分功能
Merge pull request !157 from 芋道源码/dev

芋道源码 2 년 전
부모
커밋
fa6bcacaef
27개의 변경된 파일1741개의 추가작업 그리고 697개의 파일을 삭제
  1. 2 1
      build/vite/optimize.ts
  2. 5 0
      src/api/mall/product/brand.ts
  3. 0 39
      src/api/mall/product/management/spu.ts
  4. 0 79
      src/api/mall/product/management/type/skuType.ts
  5. 0 25
      src/api/mall/product/management/type/spuType.ts
  6. 92 0
      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. 44 14
      src/components/UploadFile/src/UploadImgs.vue
  11. 17 4
      src/router/modules/remaining.ts
  12. 3 1
      src/utils/dict.ts
  13. 54 0
      src/utils/index.ts
  14. 0 18
      src/utils/object.ts
  15. 2 1
      src/utils/tree.ts
  16. 80 142
      src/views/mall/product/spu/addForm.vue
  17. 73 37
      src/views/mall/product/spu/components/BasicInfoForm.vue
  18. 8 9
      src/views/mall/product/spu/components/DescriptionForm.vue
  19. 54 65
      src/views/mall/product/spu/components/OtherSettingsForm.vue
  20. 27 12
      src/views/mall/product/spu/components/ProductAttributes.vue
  21. 22 9
      src/views/mall/product/spu/components/ProductAttributesAddForm.vue
  22. 131 123
      src/views/mall/product/spu/components/SkuList.vue
  23. 152 118
      src/views/mall/product/spu/index.vue
  24. 124 0
      src/views/mall/trade/delivery/express/ExpressForm.vue
  25. 184 0
      src/views/mall/trade/delivery/express/index.vue
  26. 405 0
      src/views/mall/trade/delivery/expressTemplate/ExpressTemplateForm.vue
  27. 160 0
      src/views/mall/trade/delivery/expressTemplate/index.vue

+ 2 - 1
build/vite/optimize.ts

@@ -102,7 +102,8 @@ const include = [
   '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/button-group/style/css',
+  'element-plus/es/components/text/style/css'
 ]
 
 const exclude = ['@iconify/json']

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

+ 0 - 39
src/api/mall/product/management/spu.ts

@@ -1,39 +0,0 @@
-import request from '@/config/axios'
-import type { SpuType } from './type/spuType' // TODO  @puhui999: type 和 api 一起放,简单一点哈~
-
-// TODO @puhui999:中英文之间有空格
-
-// 获得spu列表 TODO @puhui999:这个是 getSpuPage 哈
-export const getSpuList = (params: PageParam) => {
-  return request.get({ url: '/product/spu/page', params })
-}
-
-// 获得spu列表tabsCount
-export const getTabsCount = () => {
-  return request.get({ url: '/product/spu/tabsCount' })
-}
-
-// 创建商品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/updateStatus', 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}` })
-}

+ 0 - 79
src/api/mall/product/management/type/skuType.ts

@@ -1,79 +0,0 @@
-export interface Property {
-  /**
-   * 属性编号
-   *
-   * 关联 {@link ProductPropertyDO#getId()}
-   */
-  propertyId?: number
-  /**
-   * 属性值编号
-   *
-   * 关联 {@link ProductPropertyValueDO#getId()}
-   */
-  valueId?: number
-  /**
-   * 属性值名称
-   */
-  valueName?: string
-}
-
-export interface SkuType {
-  /**
-   * 商品 SKU 编号,自增
-   */
-  id?: number
-  /**
-   * SPU 编号
-   */
-  spuId?: number
-  /**
-   * 属性数组,JSON 格式
-   */
-  properties?: Property[]
-  /**
-   * 商品价格,单位:分
-   */
-  price?: number
-  /**
-   * 市场价,单位:分
-   */
-  marketPrice?: number
-  /**
-   * 成本价,单位:分
-   */
-  costPrice?: number
-  /**
-   * 商品条码
-   */
-  barCode?: string
-  /**
-   * 图片地址
-   */
-  picUrl?: string
-  /**
-   * 库存
-   */
-  stock?: number
-  /**
-   * 商品重量,单位:kg 千克
-   */
-  weight?: number
-  /**
-   * 商品体积,单位:m^3 平米
-   */
-  volume?: number
-
-  /**
-   * 一级分销的佣金,单位:分
-   */
-  subCommissionFirstPrice?: number
-  /**
-   * 二级分销的佣金,单位:分
-   */
-  subCommissionSecondPrice?: number
-
-  /**
-   * 商品销量
-   */
-  salesCount?: number
-}

+ 0 - 25
src/api/mall/product/management/type/spuType.ts

@@ -1,25 +0,0 @@
-import { SkuType } from './skuType'
-
-export interface SpuType {
-  id?: number
-  name?: string // 商品名称
-  categoryId?: number | null // 商品分类
-  keyword?: string // 关键字
-  unit?: number | null // 单位
-  picUrl?: string // 商品封面图
-  sliderPicUrls?: string[] // 商品轮播图
-  introduction?: string // 商品简介
-  deliveryTemplateId?: number // 运费模版
-  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 // 是否优品
-}

+ 92 - 0
src/api/mall/product/spu.ts

@@ -0,0 +1,92 @@
+import request from '@/config/axios'
+
+export interface Property {
+  propertyId?: number // 属性编号
+  propertyName?: string // 属性名称
+  valueId?: number // 属性值编号
+  valueName?: string // 属性值名称
+}
+
+// TODO puhui999:是不是直接叫 Sku 更简洁一点哈。type 待后面,总感觉有个类型?
+export interface SkuType {
+  id?: number // 商品 SKU 编号
+  spuId?: number // SPU 编号
+  properties?: Property[] // 属性数组
+  price?: number // 商品价格
+  marketPrice?: number // 市场价
+  costPrice?: number // 成本价
+  barCode?: string // 商品条码
+  picUrl?: string // 图片地址
+  stock?: number // 库存
+  weight?: number // 商品重量,单位:kg 千克
+  volume?: number // 商品体积,单位:m^3 平米
+  subCommissionFirstPrice?: number // 一级分销的佣金
+  subCommissionSecondPrice?: number // 二级分销的佣金
+  salesCount?: number // 商品销量
+}
+
+// TODO puhui999:是不是直接叫 Spu 更简洁一点哈。type 待后面,总感觉有个类型?
+export interface SpuType {
+  id?: number
+  name?: string // 商品名称
+  categoryId?: number | null // 商品分类
+  keyword?: string // 关键字
+  unit?: number | null // 单位
+  picUrl?: string // 商品封面图
+  sliderPicUrls?: string[] // 商品轮播图
+  introduction?: string // 商品简介
+  deliveryTemplateId?: number | null // 运费模版
+  brandId?: number | null // 商品品牌编号
+  specType?: boolean // 商品规格
+  subCommissionType?: boolean // 分销类型
+  skus: SkuType[] // sku数组
+  description?: string // 商品详情
+  sort?: string // 商品排序
+  giveIntegral?: number // 赠送积分
+  virtualSalesCount?: number // 虚拟销量
+  recommendHot?: boolean // 是否热卖
+  recommendBenefit?: boolean // 是否优惠
+  recommendBest?: boolean // 是否精品
+  recommendNew?: boolean // 是否新品
+  recommendGood?: boolean // 是否优品
+}
+
+// 获得 Spu 列表
+export const getSpuPage = (params: PageParam) => {
+  return request.get({ url: '/product/spu/page', params })
+}
+
+// 获得 Spu 列表 tabsCount
+export const getTabsCount = () => {
+  return request.get({ url: '/product/spu/get-count' })
+}
+
+// 创建商品 Spu
+export const createSpu = (data: SpuType) => {
+  return request.post({ url: '/product/spu/create', data })
+}
+
+// 更新商品 Spu
+export const updateSpu = (data: SpuType) => {
+  return request.put({ url: '/product/spu/update', data })
+}
+
+// 更新商品 Spu status
+export const updateStatus = (data: { id: number; status: number }) => {
+  return request.put({ url: '/product/spu/update-status', data })
+}
+
+// 获得商品 Spu
+export const getSpu = (id: number) => {
+  return request.get({ url: `/product/spu/get-detail?id=${id}` })
+}
+
+// 删除商品 Spu
+export const deleteSpu = (id: number) => {
+  return request.delete({ url: `/product/spu/delete?id=${id}` })
+}
+
+// 导出商品 Spu Excel
+export const exportSpu = async (params) => {
+  return await request.download({ url: '/product/spu/export', params })
+}

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

+ 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;

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

@@ -349,22 +349,35 @@ const remainingRouter: AppRouteRecordRaw[] = [
   {
     path: '/product',
     component: Layout,
-    name: 'ProductManagementEdit',
+    name: 'Product',
     meta: {
       hidden: true
     },
     children: [
       {
-        path: 'productManagementAdd', // TODO @puhui999:最好拆成 add 和 edit 两个路由;添加商品;修改商品
+        path: 'productSpuAdd', // TODO @puhui999:最好拆成 add 和 edit 两个路由;添加商品;修改商品 fix
         component: () => import('@/views/mall/product/spu/addForm.vue'),
-        name: 'ProductManagementAdd',
+        name: 'ProductSpuAdd',
         meta: {
           noCache: true,
           hidden: true,
           canTo: true,
           icon: 'ep:edit',
           title: '添加商品',
-          activeMenu: '/product/product-management'
+          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'
         }
       }
     ]

+ 3 - 1
src/utils/dict.ts

@@ -148,5 +148,7 @@ export enum DICT_TYPE {
 
   // ========== MALL 模块 ==========
   PRODUCT_UNIT = 'product_unit', // 商品单位
-  PRODUCT_SPU_STATUS = 'product_spu_status' //商品状态
+  PRODUCT_SPU_STATUS = 'product_spu_status', //商品状态
+  // ========== MALL 交易模块 ==========
+  EXPRESS_CHARGE_MODE = 'trade_delivery_express_charge_mode' //快递的计费方式
 }

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

+ 0 - 18
src/utils/object.ts

@@ -1,18 +0,0 @@
-// TODO @puhui999:这个方法,可以考虑放到 index.js
-/**
- * 将值复制到目标对象,且以目标对象属性为准,例: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)
-}

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

+ 80 - 142
src/views/mall/product/spu/addForm.vue

@@ -3,21 +3,21 @@
     <el-tabs v-model="activeName">
       <el-tab-pane label="商品信息" name="basicInfo">
         <BasicInfoForm
-          ref="BasicInfoRef"
+          ref="basicInfoRef"
           v-model:activeName="activeName"
           :propFormData="formData"
         />
       </el-tab-pane>
       <el-tab-pane label="商品详情" name="description">
         <DescriptionForm
-          ref="DescriptionRef"
+          ref="descriptionRef"
           v-model:activeName="activeName"
           :propFormData="formData"
         />
       </el-tab-pane>
       <el-tab-pane label="其他设置" name="otherSettings">
         <OtherSettingsForm
-          ref="OtherSettingsRef"
+          ref="otherSettingsRef"
           v-model:activeName="activeName"
           :propFormData="formData"
         />
@@ -31,88 +31,56 @@
     </el-form>
   </ContentWrap>
 </template>
-<script lang="ts" name="ProductManagementForm" setup>
+<script lang="ts" name="ProductSpuForm" setup>
+import { cloneDeep } from 'lodash-es'
 import { useTagsViewStore } from '@/store/modules/tagsView'
 import { BasicInfoForm, DescriptionForm, OtherSettingsForm } from './components'
-import type { SpuType } from '@/api/mall/product/management/type/spuType' // 业务api
-import * as managementApi from '@/api/mall/product/management/spu'
-import * as PropertyApi from '@/api/mall/product/property'
+// 业务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 { query } = useRoute() // 查询参数
+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
-const formData = ref<SpuType>({
-  name: '213', // 商品名称
+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: '213', // 关键字
+  keyword: '', // 关键字
   unit: null, // 单位
-  picUrl:
-    'http://127.0.0.1:48080/admin-api/infra/file/4/get/6ffdf8f5dfc03f7ceec1ff1ebc138adb8b721a057d505ccfb0e61a8783af1371.png', // 商品封面图
-  sliderPicUrls: [
-    {
-      name: 'http://127.0.0.1:48080/admin-api/infra/file/4/get/6ffdf8f5dfc03f7ceec1ff1ebc138adb8b721a057d505ccfb0e61a8783af1371.png',
-      url: 'http://127.0.0.1:48080/admin-api/infra/file/4/get/6ffdf8f5dfc03f7ceec1ff1ebc138adb8b721a057d505ccfb0e61a8783af1371.png'
-    }
-  ], // 商品轮播图
-  introduction: '213', // 商品简介
-  deliveryTemplateId: 0, // 运费模版
+  picUrl: '', // 商品封面图
+  sliderPicUrls: [], // 商品轮播图
+  introduction: '', // 商品简介
+  deliveryTemplateId: 1, // 运费模版
+  brandId: null, // 商品品牌
   specType: false, // 商品规格
   subCommissionType: false, // 分销类型
   skus: [
     {
-      /**
-       * 商品价格,单位:分 TODO @puhui999:注释放在尾巴哈,简洁一点~
-       */
-      price: 0,
-      /**
-       * 市场价,单位:分
-       */
-      marketPrice: 0,
-      /**
-       * 成本价,单位:分
-       */
-      costPrice: 0,
-      /**
-       * 商品条码
-       */
-      barCode: '',
-      /**
-       * 图片地址
-       */
-      picUrl: '',
-      /**
-       * 库存
-       */
-      stock: 0,
-      /**
-       * 商品重量,单位:kg 千克
-       */
-      weight: 0,
-      /**
-       * 商品体积,单位:m^3 平米
-       */
-      volume: 0,
-      /**
-       * 一级分销的佣金,单位:分
-       */
-      subCommissionFirstPrice: 0,
-      /**
-       * 二级分销的佣金,单位:分
-       */
-      subCommissionSecondPrice: 0
+      price: 0, // 商品价格
+      marketPrice: 0, // 市场价
+      costPrice: 0, // 成本价
+      barCode: '', // 商品条码
+      picUrl: '', // 图片地址
+      stock: 0, // 库存
+      weight: 0, // 商品重量
+      volume: 0, // 商品体积
+      subCommissionFirstPrice: 0, // 一级分销的佣金
+      subCommissionSecondPrice: 0 // 二级分销的佣金
     }
   ],
-  description: '5425', // 商品详情
-  sort: 1, // 商品排序
-  giveIntegral: 1, // 赠送积分
-  virtualSalesCount: 1, // 虚拟销量
+  description: '', // 商品详情
+  sort: 0, // 商品排序
+  giveIntegral: 0, // 赠送积分
+  virtualSalesCount: 0, // 虚拟销量
   recommendHot: false, // 是否热卖
   recommendBenefit: false, // 是否优惠
   recommendBest: false, // 是否精品
@@ -122,19 +90,20 @@ const formData = ref<SpuType>({
 
 /** 获得详情 */
 const getDetail = async () => {
-  const id = query.id as unknown as number
+  const id = params.spuId as number
   if (id) {
     formLoading.value = true
     try {
-      const res = (await managementApi.getSpu(id)) as SpuType
+      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
-      // 直接取第一个值就能得到所有属性的id
-      // TODO @puhui999:可以直接拿 propertyName 拼接处规格 id + 属性,可以看下商品 uniapp 详情的做法
-      const propertyIds = res.skus[0]?.properties.map((item) => item.propertyId)
-      const PropertyS = await PropertyApi.getPropertyListAndValue({ propertyIds })
-      await nextTick()
-      // 回显商品属性
-      BasicInfoRef.value.addAttribute(PropertyS)
     } finally {
       formLoading.value = false
     }
@@ -145,96 +114,65 @@ const getDetail = async () => {
 const submitForm = async () => {
   // 提交请求
   formLoading.value = true
-  const newSkus = JSON.parse(JSON.stringify(formData.value.skus)) //深拷贝一份skus保存失败时使用
-  // TODO 三个表单逐一校验,如果有一个表单校验不通过则切换到对应表单,如果有两个及以上的情况则切换到最前面的一个并弹出提示消息
+  // 三个表单逐一校验,如果有一个表单校验不通过则切换到对应表单,如果有两个及以上的情况则切换到最前面的一个并弹出提示消息
   // 校验各表单
   try {
-    await unref(BasicInfoRef)?.validate()
-    await unref(DescriptionRef)?.validate()
-    await unref(OtherSettingsRef)?.validate()
-    // TODO @puhui:直接做深拷贝?这样最终 server 端不满足,不需要恢复
-    // 处理掉一些无关数据
-    formData.value.skus.forEach((item) => {
-      // 给sku name赋值
-      item.name = formData.value.name
-      // 多规格情况移除skus相关属性值value
-      if (formData.value.specType) {
-        item.properties.forEach((item2) => {
-          delete item2.valueName
-        })
+    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 = []
-    formData.value.sliderPicUrls.forEach((item) => {
+    deepCopyFormData.sliderPicUrls.forEach((item) => {
       // 如果是前端选的图
-      // TODO @puhui999:疑问哈,为啥会是 object 呀?
-      if (typeof item === 'object') {
-        newSliderPicUrls.push(item.url)
-      } else {
-        newSliderPicUrls.push(item)
-      }
+      typeof item === 'object' ? newSliderPicUrls.push(item.url) : newSliderPicUrls.push(item)
     })
-    formData.value.sliderPicUrls = newSliderPicUrls
+    deepCopyFormData.sliderPicUrls = newSliderPicUrls
     // 校验都通过后提交表单
-    const data = formData.value as SpuType
-    // 移除skus.
-    const id = query.id as unknown as number
+    const data = deepCopyFormData as ProductSpuApi.SpuType
+    const id = params.spuId as number
     if (!id) {
-      await managementApi.createSpu(data)
+      await ProductSpuApi.createSpu(data)
       message.success(t('common.createSuccess'))
     } else {
-      await managementApi.updateSpu(data)
+      await ProductSpuApi.updateSpu(data)
       message.success(t('common.updateSuccess'))
     }
     close()
-  } catch (e) {
-    // 如果是后端校验失败,恢复skus数据
-    if (typeof e === 'string') {
-      formData.value.skus = newSkus
-    }
   } finally {
     formLoading.value = false
   }
 }
 
-/**
- * 重置表单
- */
-const resetForm = async () => {
-  formData.value = {
-    name: '', // 商品名称
-    categoryId: 0, // 商品分类
-    keyword: '', // 关键字
-    unit: '', // 单位
-    picUrl: '', // 商品封面图
-    sliderPicUrls: [], // 商品轮播图
-    introduction: '', // 商品简介
-    deliveryTemplateId: 0, // 运费模版
-    selectRule: '',
-    specType: false, // 商品规格
-    subCommissionType: false, // 分销类型
-    description: '', // 商品详情
-    sort: 1, // 商品排序
-    giveIntegral: 1, // 赠送积分
-    virtualSalesCount: 1, // 虚拟销量
-    recommendHot: false, // 是否热卖
-    recommendBenefit: false, // 是否优惠
-    recommendBest: false, // 是否精品
-    recommendNew: false, // 是否新品
-    recommendGood: false // 是否优品
-  }
-}
 /** 关闭按钮 */
 const close = () => {
-  // TODO @puhui999:是不是不用 reset 呀?close 默认销毁
-  resetForm()
   delView(unref(currentRoute))
-  push('/product/product-management')
+  push('/product/product-spu')
 }
 
 /** 初始化 */
-onMounted(() => {
-  getDetail()
+onMounted(async () => {
+  await getDetail()
 })
 </script>

+ 73 - 37
src/views/mall/product/spu/components/BasicInfoForm.vue

@@ -1,5 +1,5 @@
 <template>
-  <el-form ref="ProductManagementBasicInfoRef" :model="formData" :rules="rules" label-width="120px">
+  <el-form ref="productSpuBasicInfoRef" :model="formData" :rules="rules" label-width="120px">
     <el-row>
       <el-col :span="12">
         <el-form-item label="商品名称" prop="name">
@@ -14,9 +14,9 @@
             :data="categoryList"
             :props="defaultProps"
             check-strictly
+            class="w-1/1"
             node-key="id"
             placeholder="请选择商品分类"
-            class="w-1/1"
           />
         </el-form-item>
       </el-col>
@@ -27,7 +27,7 @@
       </el-col>
       <el-col :span="12">
         <el-form-item label="单位" prop="unit">
-          <el-select v-model="formData.unit" placeholder="请选择单位" class="w-1/1">
+          <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"
@@ -54,18 +54,28 @@
       </el-col>
       <el-col :span="24">
         <el-form-item label="商品轮播图" prop="sliderPicUrls">
-          <UploadImgs v-model="formData.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="请选择" class="w-1/1">
+          <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-button class="ml-20px">运费模板</el-button>
+        <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">
@@ -86,36 +96,37 @@
       <!-- 多规格添加-->
       <el-col :span="24">
         <el-form-item v-if="formData.specType" label="商品属性">
-          <!-- TODO @puhui999:参考 https://admin.java.crmeb.net/store/list/creatProduct 添加规格好做么?添加的时候,不用输入备注哈 -->
-          <el-button class="mr-15px mb-10px" @click="AttributesAddFormRef.open">添加规格</el-button>
-          <ProductAttributes :attribute-data="attributeList" />
+          <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 && attributeList.length > 0">
+        <template v-if="formData.specType && propertyList.length > 0">
           <el-form-item label="批量设置">
-            <SkuList :attributeList="attributeList" :is-batch="true" :prop-form-data="formData" />
+            <SkuList :is-batch="true" :prop-form-data="formData" :propertyList="propertyList" />
           </el-form-item>
           <el-form-item label="属性列表">
-            <SkuList :attributeList="attributeList" :prop-form-data="formData" />
+            <SkuList ref="skuListRef" :prop-form-data="formData" :propertyList="propertyList" />
           </el-form-item>
         </template>
         <el-form-item v-if="!formData.specType">
-          <SkuList :attributeList="attributeList" :prop-form-data="formData" />
+          <SkuList :prop-form-data="formData" :propertyList="propertyList" />
         </el-form-item>
       </el-col>
     </el-row>
   </el-form>
-  <ProductAttributesAddForm ref="AttributesAddFormRef" @success="addAttribute" />
+  <ProductAttributesAddForm ref="attributesAddFormRef" :propertyList="propertyList" />
 </template>
-<script lang="ts" name="ProductManagementBasicInfoForm" setup>
+<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/management/type/spuType'
+import type { SpuType } from '@/api/mall/product/spu'
 import { UploadImg, UploadImgs } from '@/components/UploadFile'
-import { copyValueToTarget } from '@/utils/object'
 import { ProductAttributes, ProductAttributesAddForm, SkuList } from './index'
 import * as ProductCategoryApi from '@/api/mall/product/category'
-import { propTypes } from '@/utils/propTypes'
+import { getSimpleBrandList } from '@/api/mall/product/brand'
+
 const message = useMessage() // 消息弹窗
 
 const props = defineProps({
@@ -125,27 +136,24 @@ const props = defineProps({
   },
   activeName: propTypes.string.def('')
 })
-const AttributesAddFormRef = ref() // 添加商品属性表单 TODO @puhui999:小写开头哈
-const ProductManagementBasicInfoRef = ref() // 表单Ref TODO @puhui999:小写开头哈
-// TODO @puhui999:attributeList 改成 propertyList,会更统一一点
-const attributeList = ref([]) // 商品属性列表
-/** 添加商品属性 */ // TODO @puhui999:propFormData 算出来
-const addAttribute = (property: any) => {
-  if (Array.isArray(property)) {
-    attributeList.value = property
-    return
-  }
-  attributeList.value.push(property)
+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: undefined, // 商品分类
+  categoryId: null, // 商品分类
   keyword: '', // 关键字
   unit: '', // 单位
   picUrl: '', // 商品封面图
   sliderPicUrls: [], // 商品轮播图
   introduction: '', // 商品简介
   deliveryTemplateId: 1, // 运费模版
+  brandId: null, // 商品品牌
   specType: false, // 商品规格
   subCommissionType: false, // 分销类型
   skus: []
@@ -159,6 +167,7 @@ const rules = reactive({
   picUrl: [required],
   sliderPicUrls: [required],
   // deliveryTemplateId: [required],
+  brandId: [required],
   specType: [required],
   subCommissionType: [required]
 })
@@ -169,11 +178,35 @@ const rules = reactive({
 watch(
   () => props.propFormData,
   (data) => {
-    if (!data) return
+    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
+    }
   },
   {
-    deep: true,
     immediate: true
   }
 )
@@ -184,8 +217,8 @@ watch(
 const emit = defineEmits(['update:activeName'])
 const validate = async () => {
   // 校验表单
-  if (!ProductManagementBasicInfoRef) return
-  return await unref(ProductManagementBasicInfoRef).validate((valid) => {
+  if (!productSpuBasicInfoRef) return
+  return await unref(productSpuBasicInfoRef).validate((valid) => {
     if (!valid) {
       message.warning('商品信息未完善!!')
       emit('update:activeName', 'basicInfo')
@@ -197,7 +230,7 @@ const validate = async () => {
     }
   })
 }
-defineExpose({ validate, addAttribute })
+defineExpose({ validate })
 
 /** 分销类型 */
 const changeSubCommissionType = () => {
@@ -211,7 +244,7 @@ const changeSubCommissionType = () => {
 /** 选择规格 */
 const onChangeSpec = () => {
   // 重置商品属性列表
-  attributeList.value = []
+  propertyList.value = []
   // 重置sku列表
   formData.skus = [
     {
@@ -229,10 +262,13 @@ const onChangeSpec = () => {
   ]
 }
 
-const categoryList = ref() // 分类树
+const categoryList = ref([]) // 分类树
+const brandList = ref([]) // 精简商品品牌列表
 onMounted(async () => {
   // 获得分类树
   const data = await ProductCategoryApi.getCategoryList({})
   categoryList.value = handleTree(data, 'id', 'parentId')
+  // 获取商品品牌列表
+  brandList.value = await getSimpleBrandList()
 })
 </script>

+ 8 - 9
src/views/mall/product/spu/components/DescriptionForm.vue

@@ -1,5 +1,5 @@
 <template>
-  <el-form ref="DescriptionFormRef" :model="formData" :rules="rules" label-width="120px">
+  <el-form ref="descriptionFormRef" :model="formData" :rules="rules" label-width="120px">
     <!--富文本编辑器组件-->
     <el-form-item label="商品详情" prop="description">
       <Editor v-model:modelValue="formData.description" />
@@ -7,11 +7,11 @@
   </el-form>
 </template>
 <script lang="ts" name="DescriptionForm" setup>
-import type { SpuType } from '@/api/mall/product/management/type/spuType'
+import type { SpuType } from '@/api/mall/product/spu'
 import { Editor } from '@/components/Editor'
 import { PropType } from 'vue'
-import { copyValueToTarget } from '@/utils/object'
 import { propTypes } from '@/utils/propTypes'
+import { copyValueToTarget } from '@/utils'
 
 const message = useMessage() // 消息弹窗
 const props = defineProps({
@@ -21,7 +21,7 @@ const props = defineProps({
   },
   activeName: propTypes.string.def('')
 })
-const DescriptionFormRef = ref() // 表单Ref
+const descriptionFormRef = ref() // 表单Ref
 const formData = ref<SpuType>({
   description: '' // 商品详情
 })
@@ -29,7 +29,6 @@ const formData = ref<SpuType>({
 const rules = reactive({
   description: [required]
 })
-
 /**
  * 富文本编辑器如果输入过再清空会有残留,需再重置一次
  */
@@ -45,7 +44,6 @@ watch(
     immediate: true
   }
 )
-
 /**
  * 将传进来的值赋值给formData
  */
@@ -53,10 +51,11 @@ watch(
   () => props.propFormData,
   (data) => {
     if (!data) return
+    // fix:三个表单组件监听赋值必须使用 copyValueToTarget 使用 formData.value = data 会监听非常多次
     copyValueToTarget(formData.value, data)
   },
   {
-    deep: true,
+    // fix: 去掉深度监听只有对象引用发生改变的时候才执行,解决改一动多的问题
     immediate: true
   }
 )
@@ -67,8 +66,8 @@ watch(
 const emit = defineEmits(['update:activeName'])
 const validate = async () => {
   // 校验表单
-  if (!DescriptionFormRef) return
-  return unref(DescriptionFormRef).validate((valid) => {
+  if (!descriptionFormRef) return
+  return await unref(descriptionFormRef).validate((valid) => {
     if (!valid) {
       message.warning('商品详情为完善!!')
       emit('update:activeName', 'description')

+ 54 - 65
src/views/mall/product/spu/components/OtherSettingsForm.vue

@@ -1,39 +1,40 @@
 <template>
-  <el-form ref="OtherSettingsFormRef" :model="formData" :rules="rules" label-width="120px">
+  <el-form ref="otherSettingsFormRef" :model="formData" :rules="rules" label-width="120px">
     <el-row>
-      <!-- TODO @puhui999:横着三个哈 -->
       <el-col :span="24">
-        <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 :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 recommend" :key="index" :label="item.value">
+            <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展示暂时不考虑排序     -->
+        <!--   TODO tag展示暂时不考虑排序 -->
         <el-form-item label="活动优先级">
           <el-tag>默认</el-tag>
           <el-tag class="ml-2" type="success">秒杀</el-tag>
@@ -51,10 +52,11 @@
   </el-form>
 </template>
 <script lang="ts" name="OtherSettingsForm" setup>
-import type { SpuType } from '@/api/mall/product/management/type/spuType'
+import type { SpuType } from '@/api/mall/product/spu'
 import { PropType } from 'vue'
-import { copyValueToTarget } from '@/utils/object'
 import { propTypes } from '@/utils/propTypes'
+import { copyValueToTarget } from '@/utils'
+
 const message = useMessage() // 消息弹窗
 
 const props = defineProps({
@@ -64,35 +66,8 @@ const props = defineProps({
   },
   activeName: propTypes.string.def('')
 })
-// 商品推荐选项 TODO @puhui999:这种叫 recommendOptions 会更合适哈
-const recommend = [
-  { name: '是否热卖', value: 'recommendHot' },
-  { name: '是否优惠', value: 'recommendBenefit' },
-  { name: '是否精品', value: 'recommendBest' },
-  { name: '是否新品', value: 'recommendNew' },
-  { name: '是否优品', value: 'recommendGood' }
-]
-const checkboxGroup = ref<string[]>(['recommendHot']) // 选中推荐选项
-/** 选择商品后赋值 */
-const onChangeGroup = () => {
-  // TODO @puhui999:是不是可以遍历 recommend,然后进行是否选中;
-  checkboxGroup.value.includes('recommendHot')
-    ? (formData.value.recommendHot = true)
-    : (formData.value.recommendHot = false)
-  checkboxGroup.value.includes('recommendBenefit')
-    ? (formData.value.recommendBenefit = true)
-    : (formData.value.recommendBenefit = false)
-  checkboxGroup.value.includes('recommendBest')
-    ? (formData.value.recommendBest = true)
-    : (formData.value.recommendBest = false)
-  checkboxGroup.value.includes('recommendNew')
-    ? (formData.value.recommendNew = true)
-    : (formData.value.recommendNew = false)
-  checkboxGroup.value.includes('recommendGood')
-    ? (formData.value.recommendGood = true)
-    : (formData.value.recommendGood = false)
-}
-const OtherSettingsFormRef = ref() // 表单Ref
+
+const otherSettingsFormRef = ref() // 表单Ref
 // 表单数据
 const formData = ref<SpuType>({
   sort: 1, // 商品排序
@@ -110,6 +85,21 @@ const rules = reactive({
   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
@@ -117,18 +107,17 @@ const rules = reactive({
 watch(
   () => props.propFormData,
   (data) => {
-    if (!data) return
+    if (!data) {
+      return
+    }
     copyValueToTarget(formData.value, data)
-    // TODO 如果先修改其他设置的值,再改变商品详情或是商品信息会重置其他设置页面中的相关值 下一个版本修复
-    checkboxGroup.value = []
-    formData.value.recommendHot ? checkboxGroup.value.push('recommendHot') : ''
-    formData.value.recommendBenefit ? checkboxGroup.value.push('recommendBenefit') : ''
-    formData.value.recommendBest ? checkboxGroup.value.push('recommendBest') : ''
-    formData.value.recommendNew ? checkboxGroup.value.push('recommendNew') : ''
-    formData.value.recommendGood ? checkboxGroup.value.push('recommendGood') : ''
+    recommendOptions.forEach(({ value }) => {
+      if (formData.value[value] && !checkboxGroup.value.includes(value)) {
+        checkboxGroup.value.push(value)
+      }
+    })
   },
   {
-    deep: true,
     immediate: true
   }
 )
@@ -139,8 +128,8 @@ watch(
 const emit = defineEmits(['update:activeName'])
 const validate = async () => {
   // 校验表单
-  if (!OtherSettingsFormRef) return
-  return await unref(OtherSettingsFormRef).validate((valid) => {
+  if (!otherSettingsFormRef) return
+  return await unref(otherSettingsFormRef).validate((valid) => {
     if (!valid) {
       message.warning('商品其他设置未完善!!')
       emit('update:activeName', 'otherSettings')

+ 27 - 12
src/views/mall/product/spu/components/ProductAttributes.vue

@@ -2,23 +2,25 @@
   <el-col v-for="(item, index) in attributeList" :key="index">
     <div>
       <el-text class="mx-1">属性名:</el-text>
-      <el-text class="mx-1">{{ item.name }}</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"
-        :disable-transitions="false"
         class="mx-1"
         closable
-        @close="handleClose(index, valueIndex)"
+        @close="handleCloseValue(index, valueIndex)"
       >
         {{ value.name }}
       </el-tag>
       <el-input
         v-show="inputVisible(index)"
-        ref="InputRef"
+        :id="`input${index}`"
+        :ref="setInputRef"
         v-model="inputValue"
         class="!w-20"
         size="small"
@@ -51,17 +53,25 @@ const inputVisible = computed(() => (index) => {
   if (attributeIndex.value === null) return false
   if (attributeIndex.value === index) return true
 })
-const InputRef = ref() //标签输入框Ref
+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({
-  attributeData: {
+  propertyList: {
     type: Array,
     default: () => {}
   }
 })
 
 watch(
-  () => props.attributeData,
+  () => props.propertyList,
   (data) => {
     if (!data) return
     attributeList.value = data
@@ -72,18 +82,22 @@ watch(
   }
 )
 
-/** 删除标签 tagValue 标签值*/
-const handleClose = (index, valueIndex) => {
+/** 删除属性值*/
+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
-  // 因为组件在ref中所以需要用索引获取对应的Ref
-  InputRef.value[index]!.input!.focus()
+  inputRef.value[index].focus()
 }
 
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+
 /** 输入框失去焦点或点击回车时触发 */
 const handleInputConfirm = async (index, propertyId) => {
   if (inputValue.value) {
@@ -92,6 +106,7 @@ const handleInputConfirm = async (index, propertyId) => {
       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 缺少国际化
     }

+ 22 - 9
src/views/mall/product/spu/components/ProductAttributesAddForm.vue

@@ -7,12 +7,9 @@
       :rules="formRules"
       label-width="80px"
     >
-      <el-form-item label="名称" prop="name">
+      <el-form-item label="属性名称" prop="name">
         <el-input v-model="formData.name" placeholder="请输入名称" />
       </el-form-item>
-      <el-form-item label="备注" prop="remark">
-        <el-input v-model="formData.remark" placeholder="请输入内容" type="textarea" />
-      </el-form-item>
     </el-form>
     <template #footer>
       <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
@@ -30,14 +27,31 @@ const dialogVisible = ref(false) // 弹窗的是否展示
 const dialogTitle = ref('添加商品属性') // 弹窗的标题
 const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
 const formData = ref({
-  name: '',
-  remark: ''
+  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
@@ -46,7 +60,6 @@ const open = async () => {
 defineExpose({ open }) // 提供 open 方法,用于打开弹窗
 
 /** 提交表单 */
-const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
 const submitForm = async () => {
   // 校验表单
   if (!formRef) return
@@ -60,12 +73,12 @@ const submitForm = async () => {
     const res = await PropertyApi.getPropertyListAndValue({ name: data.name })
     if (res.length === 0) {
       const propertyId = await PropertyApi.createProperty(data)
-      emit('success', { id: propertyId, ...formData.value, values: [] })
+      attributeList.value.push({ id: propertyId, ...formData.value, values: [] })
     } else {
       if (res[0].values === null) {
         res[0].values = []
       }
-      emit('success', res[0]) // 因为只用一个
+      attributeList.value.push(res[0]) // 因为只用一个
     }
     message.success(t('common.createSuccess'))
     dialogVisible.value = false

+ 131 - 123
src/views/mall/product/spu/components/SkuList.vue

@@ -1,6 +1,6 @@
 <template>
   <el-table
-    :data="isBatch ? SkuData : formData.skus"
+    :data="isBatch ? skuList : formData.skus"
     border
     class="tabNumWidth"
     max-height="500"
@@ -12,175 +12,155 @@
       </template>
     </el-table-column>
     <template v-if="formData.specType && !isBatch">
-      <!--  根据商品属性动态添加  -->
+      <!--  根据商品属性动态添加 -->
       <el-table-column
-        v-for="(item, index) in tableHeaderList"
+        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>
-    <!-- TODO @puhui999: controls-position="right" 可以去掉哈,不然太长了,手动输入更方便 -->
     <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>
-    <!-- TODO @puhui999:用户输入的时候,是按照元;分主要是我们自己用; -->
-    <el-table-column align="center" label="销售价(分)" min-width="168">
+    <el-table-column align="center" label="销售价(元)" min-width="168">
       <template #default="{ row }">
-        <el-input-number v-model="row.price" :min="0" class="w-100%" controls-position="right" />
+        <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">
+    <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%"
-          controls-position="right"
         />
       </template>
     </el-table-column>
-    <el-table-column align="center" label="成本价()" min-width="168">
+    <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%"
-          controls-position="right"
         />
       </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%" controls-position="right" />
+        <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" class="w-100%" controls-position="right" />
+        <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" class="w-100%" controls-position="right" />
+        <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">
+      <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%"
-            controls-position="right"
           />
         </template>
       </el-table-column>
-      <el-table-column align="center" label="二级返佣()" min-width="168">
+      <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%"
-            controls-position="right"
           />
         </template>
       </el-table-column>
     </template>
     <el-table-column v-if="formData.specType" align="center" fixed="right" label="操作" width="80">
-      <template #default>
+      <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">删除</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 { UploadImg } from '@/components/UploadFile'
 import { PropType } from 'vue'
-import { SpuType } from '@/api/mall/product/management/type/spuType'
+import { copyValueToTarget } from '@/utils'
 import { propTypes } from '@/utils/propTypes'
-import { SkuType } from '@/api/mall/product/management/type/skuType'
-import { copyValueToTarget } from '@/utils/object'
+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: () => {}
   },
-  attributeList: {
+  propertyList: {
     type: Array,
     default: () => []
   },
-  isBatch: propTypes.bool.def(false) // 是否批量操作
+  isBatch: propTypes.bool.def(false) // 是否作为批量操作组件
 })
 const formData = ref<SpuType>() // 表单数据
-// 批量添加时的零时数据 TODO @puhui999:小写开头哈;然后变量都尾注释
-const SkuData = ref<SkuType[]>([
+const skuList = ref<SkuType[]>([
   {
-    /**
-     * 商品价格,单位:分
-     */
-    price: 0,
-    /**
-     * 市场价,单位:分
-     */
-    marketPrice: 0,
-    /**
-     * 成本价,单位:分
-     */
-    costPrice: 0,
-    /**
-     * 商品条码
-     */
-    barCode: '',
-    /**
-     * 图片地址
-     */
-    picUrl: '',
-    /**
-     * 库存
-     */
-    stock: 0,
-    /**
-     * 商品重量,单位:kg 千克
-     */
-    weight: 0,
-    /**
-     * 商品体积,单位:m^3 平米
-     */
-    volume: 0,
-    /**
-     * 一级分销的佣金,单位:分
-     */
-    subCommissionFirstPrice: 0,
-    /**
-     * 二级分销的佣金,单位:分
-     */
-    subCommissionSecondPrice: 0
+    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, SkuData.value[0])
+    copyValueToTarget(item, skuList.value[0])
   })
 }
 
-const tableHeaderList = ref<{ prop: string; label: string }[]>([])
+/** 删除 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 }[]>([]) // 多属性表头
 
 /**
- * 将传进来的值赋值给SkuData
+ * 将传进来的值赋值给 skuList
  */
 watch(
   () => props.propFormData,
@@ -194,35 +174,27 @@ watch(
   }
 )
 
-// TODO @芋艿:看看 chatgpt 可以进一步下面几个方法的实现不
 /** 生成表数据 */
-const generateTableData = (data: any[]) => {
+const generateTableData = (propertyList: any[]) => {
   // 构建数据结构
-  const propertiesItemList = []
-  for (const item of data) {
-    const objList = []
-    for (const v of item.values) {
-      const obj = { propertyId: 0, valueId: 0, valueName: '' }
-      obj.propertyId = item.id
-      obj.valueId = v.id
-      obj.valueName = v.name
-      objList.push(obj)
-    }
-    propertiesItemList.push(objList)
+  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 = []
   }
-  const buildList = build(propertiesItemList)
-  // 如果构建后的组合数跟sku数量一样的话则不用处理,添加新属性没有属性值也不做处理 (解决编辑表单时或查看详情时数据回显问题)
-  if (
-    buildList.length === formData.value.skus.length ||
-    data.some((item) => item.values.length === 0)
-  ) {
-    return
-  }
-  // 重置表数据
-  formData.value!.skus = []
-  buildList.forEach((item) => {
+  for (const item of buildList) {
     const row = {
-      properties: [],
+      properties: Array.isArray(item) ? item : [item], // 如果只有一个属性的话返回的是一个 property 对象
       price: 0,
       marketPrice: 0,
       costPrice: 0,
@@ -234,32 +206,51 @@ const generateTableData = (data: any[]) => {
       subCommissionFirstPrice: 0,
       subCommissionSecondPrice: 0
     }
-    // 判断是否是单一属性的情况
-    if (Array.isArray(item)) {
-      row.properties = item
-    } else {
-      row.properties.push(item)
+    // 如果存在属性相同的 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 = (list: any[]) => {
-  if (list.length === 0) {
+const build = (propertyValuesList: Property[][]) => {
+  if (propertyValuesList.length === 0) {
     return []
-  } else if (list.length === 1) {
-    return list[0]
+  } else if (propertyValuesList.length === 1) {
+    return propertyValuesList[0]
   } else {
-    const result = []
-    const rest = build(list.slice(1))
-    for (let i = 0; i < list[0].length; i++) {
+    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([list[0][i], ...rest[j]])
+          result.push([propertyValuesList[0][i], ...rest[j]])
         } else {
-          result.push([list[0][i], rest[j]])
+          result.push([propertyValuesList[0][i], rest[j]])
         }
       }
     }
@@ -267,15 +258,17 @@ const build = (list: any[]) => {
   }
 }
 
-/** 监听属性列表生成相关参数和表头 */
+/** 监听属性列表生成相关参数和表头 */
 watch(
-  () => props.attributeList,
-  (data) => {
+  () => props.propertyList,
+  (propertyList) => {
     // 如果不是多规格则结束
-    if (!formData.value.specType) return
-    // 如果当前组件作为批量添加数据使用则重置表数据
+    if (!formData.value.specType) {
+      return
+    }
+    // 如果当前组件作为批量添加数据使用,则重置表数据
     if (props.isBatch) {
-      SkuData.value = [
+      skuList.value = [
         {
           price: 0,
           marketPrice: 0,
@@ -290,20 +283,35 @@ watch(
         }
       ]
     }
+
     // 判断代理对象是否为空
-    if (JSON.stringify(data) === '[]') return
+    if (JSON.stringify(propertyList) === '[]') {
+      return
+    }
     // 重置表头
-    tableHeaderList.value = []
+    tableHeaders.value = []
     // 生成表头
-    data.forEach((item, index) => {
+    propertyList.forEach((item, index) => {
       // name加属性项index区分属性值
-      tableHeaderList.value.push({ prop: `name${index}`, label: item.name })
+      tableHeaders.value.push({ prop: `name${index}`, label: item.name })
     })
-    generateTableData(data)
+
+    // 如果回显的 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>

+ 152 - 118
src/views/mall/product/spu/index.vue

@@ -8,7 +8,7 @@
       class="-mb-15px"
       label-width="68px"
     >
-      <!-- TODO @puhui999:https://admin.java.crmeb.net/store/index,参考,使用分类 + 标题搜索 -->
+      <!-- TODO @puhui999:品牌应该是数据下拉哈 -->
       <el-form-item label="品牌名称" prop="name">
         <el-input
           v-model="queryParams.name"
@@ -18,15 +18,18 @@
           @keyup.enter="handleQuery"
         />
       </el-form-item>
-      <el-form-item label="状态" prop="status">
-        <el-select v-model="queryParams.status" class="!w-240px" clearable placeholder="请选择状态">
-          <el-option
-            v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
-            :key="dict.value"
-            :label="dict.label"
-            :value="dict.value"
-          />
-        </el-select>
+      <!--  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
@@ -48,18 +51,27 @@
           <Icon class="mr-5px" icon="ep:refresh" />
           重置
         </el-button>
-        <el-button v-hasPermi="['product:brand:create']" plain type="primary" @click="openForm">
+        <el-button v-hasPermi="['product:spu:create']" plain type="primary" @click="openForm">
           <Icon class="mr-5px" icon="ep:plus" />
           新增
         </el-button>
-        <!-- TODO @puhui999:增加一个【导出】操作 -->
+        <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="handleClick">
+    <el-tabs v-model="queryParams.tabType" @tab-click="handleTabClick">
       <el-tab-pane
         v-for="item in tabsData"
         :key="item.type"
@@ -68,35 +80,39 @@
       />
     </el-tabs>
     <el-table v-loading="loading" :data="list">
-      <!-- TODO puhui999: ID 编号的展示 -->
-      <!--   TODO 暂时不做折叠数据   -->
-      <!--      <el-table-column type="expand">-->
-      <!--        <template #default="{ row }">-->
-      <!--          <el-form inline label-position="left">-->
-      <!--            <el-form-item label="市场价:">-->
-      <!--              <span>{{ row.marketPrice }}</span>-->
-      <!--            </el-form-item>-->
-      <!--            <el-form-item label="成本价:">-->
-      <!--              <span>{{ row.costPrice }}</span>-->
-      <!--            </el-form-item>-->
-      <!--            <el-form-item label="虚拟销量:">-->
-      <!--              <span>{{ row.virtualSalesCount }}</span>-->
-      <!--            </el-form-item>-->
-      <!--          </el-form>-->
-      <!--        </template>-->
-      <!--      </el-table-column>-->
+      <!-- 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"
-            style="width: 36px; height: 36px"
-            @click="imagePreview(row.picUrl)"
-          />
+          <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" />
-      <!-- TODO 价格 / 100.0 -->
-      <el-table-column align="center" label="商品售价" min-width="90" prop="price" />
+      <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" />
@@ -107,24 +123,30 @@
         prop="createTime"
         width="180"
       />
-      <el-table-column fixed="right" label="状态" min-width="80">
+      <el-table-column align="center" label="状态" min-width="80">
         <template #default="{ row }">
-          <!-- TODO @puhui:是不是不用 Number(row.status) 去比较哈,直接 row.status < 0 -->
-          <el-switch
-            v-model="row.status"
-            :active-value="1"
-            :disabled="Number(row.status) < 0"
-            :inactive-value="0"
-            active-text="上架"
-            inactive-text="下架"
-            inline-prompt
-            @change="changeStatus(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="150">
+      <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']"
@@ -138,13 +160,15 @@
               v-hasPermi="['product:spu:update']"
               link
               type="primary"
-              @click="addToTrash(row, ProductSpuStatusEnum.DISABLE.status)"
+              @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"
@@ -156,7 +180,7 @@
               v-hasPermi="['product:spu:update']"
               link
               type="primary"
-              @click="addToTrash(row, ProductSpuStatusEnum.RECYCLE.status)"
+              @click="changeStatus(row, ProductSpuStatusEnum.RECYCLE.status)"
             >
               加入回收站
             </el-button>
@@ -172,26 +196,25 @@
       @pagination="getList"
     />
   </ContentWrap>
-  <!-- https://kailong110120130.gitee.io/vue-element-plus-admin-doc/components/image-viewer.html,可以用这个么? -->
-  <!-- 必须在表格外面展示。不然单元格会遮挡图层 -->
-  <el-image-viewer
-    v-if="imgViewVisible"
-    :url-list="imageViewerList"
-    @close="imgViewVisible = false"
-  />
 </template>
-<script lang="ts" name="ProductList" setup>
-import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+<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'
-// TODO @puhui999:managementApi=》ProductSpuApi
-import * as managementApi from '@/api/mall/product/management/spu'
+import { defaultProps, handleTree } from '@/utils/tree'
 import { ProductSpuStatusEnum } from '@/utils/constants'
-import { TabsPaneContext } from 'element-plus'
+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 数据
@@ -225,26 +248,19 @@ const tabsData = ref([
 
 /** 获得每个 Tab 的数量 */
 const getTabsCount = async () => {
-  // TODO @puhui999:这里是不是可以不要 try catch 哈
-  try {
-    const res = await managementApi.getTabsCount()
-    for (let objName in res) {
-      tabsData.value[Number(objName)].count = res[objName]
-    }
-  } catch {}
+  const res = await ProductSpuApi.getTabsCount()
+  for (let objName in res) {
+    tabsData.value[Number(objName)].count = res[objName]
+  }
 }
-
-const imgViewVisible = ref(false) // 商品图预览
-const imageViewerList = ref<string[]>([]) // 商品图预览列表
 const queryParams = ref({
   pageNo: 1,
   pageSize: 10,
   tabType: 0
-})
-const queryFormRef = ref() // 搜索的表单
+}) // 查询参数
+const queryFormRef = ref() // 搜索的表单Ref
 
-// TODO @puhui999:可以改成 handleTabClick:更准确一点;
-const handleClick = (tab: TabsPaneContext) => {
+const handleTabClick = (tab: TabsPaneContext) => {
   queryParams.value.tabType = tab.paneName
   getList()
 }
@@ -253,7 +269,7 @@ const handleClick = (tab: TabsPaneContext) => {
 const getList = async () => {
   loading.value = true
   try {
-    const data = await managementApi.getSpuList(queryParams.value)
+    const data = await ProductSpuApi.getSpuPage(queryParams.value)
     list.value = data.list
     total.value = data.total
   } finally {
@@ -261,7 +277,6 @@ const getList = async () => {
   }
 }
 
-// TODO @puhui999:是不是 changeStatus 和 addToTrash 调用一个统一的方法,去更新状态。这样逻辑会更干净一些。
 /**
  * 更改 SPU 状态
  *
@@ -269,10 +284,11 @@ const getList = async () => {
  * @param status 更改前的值
  */
 const changeStatus = async (row, status?: number) => {
-  // TODO 测试过程中似乎有点问题,下一版修复
+  const deepCopyValue = cloneDeep(unref(row))
+  if (typeof status !== 'undefined') deepCopyValue.status = status
   try {
     let text = ''
-    switch (row.status) {
+    switch (deepCopyValue.status) {
       case ProductSpuStatusEnum.DISABLE.status:
         text = ProductSpuStatusEnum.DISABLE.name
         break
@@ -284,20 +300,19 @@ const changeStatus = async (row, status?: number) => {
         break
     }
     await message.confirm(
-      row.status === -1 ? `确认要将[${row.name}]${text}吗?` : `确认要${text}[${row.name}]吗?`
+      deepCopyValue.status === -1
+        ? `确认要将[${row.name}]${text}吗?`
+        : row.status === -1 // 再判断一次原对象是否等于-1,例: 把回收站中的商品恢复到仓库中,事件触发时原对象status为-1 深拷贝对象status被赋值为0
+        ? `确认要将[${row.name}]恢复到仓库吗?`
+        : `确认要${text}[${row.name}]吗?`
     )
-    await managementApi.updateStatus({ id: row.id, status: row.status })
+    await ProductSpuApi.updateStatus({ id: deepCopyValue.id, status: deepCopyValue.status })
     message.success('更新状态成功')
     // 刷新 tabs 数据
     await getTabsCount()
     // 刷新列表
     await getList()
   } catch {
-    // 取消加入回收站时回显数据
-    if (typeof status !== 'undefined') {
-      row.status = status
-      return
-    }
     // 取消更改状态时回显数据
     row.status =
       row.status === ProductSpuStatusEnum.DISABLE.status
@@ -306,26 +321,13 @@ const changeStatus = async (row, status?: number) => {
   }
 }
 
-/**
- * 加入回收站
- *
- * @param row
- * @param status
- */
-const addToTrash = (row, status) => {
-  // 复制一份原值
-  const num = Number(`${row.status}`)
-  row.status = status
-  changeStatus(row, num)
-}
-
 /** 删除按钮操作 */
 const handleDelete = async (id: number) => {
   try {
     // 删除的二次确认
     await message.delConfirm()
     // 发起删除
-    await managementApi.deleteSpu(id)
+    await ProductSpuApi.deleteSpu(id)
     message.success(t('common.delSuccess'))
     // 刷新tabs数据
     await getTabsCount()
@@ -334,13 +336,11 @@ const handleDelete = async (id: number) => {
   } catch {}
 }
 
-/**
- * 商品图预览
- * @param imgUrl
- */
+/** 商品图预览 */
 const imagePreview = (imgUrl: string) => {
-  imageViewerList.value = [imgUrl]
-  imgViewVisible.value = true
+  createImageViewer({
+    urlList: [imgUrl]
+  })
 }
 
 /** 搜索按钮操作 */
@@ -362,27 +362,61 @@ const resetQuery = () => {
 const openForm = (id?: number) => {
   // 修改
   if (typeof id === 'number') {
-    push('/product/productManagementAdd?id=' + id)
+    push('/product/productSpuEdit/' + id)
     return
   }
   // 新增
-  push('/product/productManagementAdd')
+  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:这个是必须加的么?
+// 监听路由变化更新列表 TODO @puhui999:这个是必须加的么?fix: 因为编辑表单是以路由的方式打开,保存表单后列表不会刷新
 watch(
   () => currentRoute.value,
   () => {
     getList()
-  },
-  {
-    immediate: true
   }
 )
 
+const categoryList = ref() // 分类树
 /** 初始化 **/
-onMounted(() => {
-  getTabsCount()
-  getList()
+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>