Pārlūkot izejas kodu

!141 商品管理: 打通所有接口(第一版)
Merge pull request !141 from puhui999/dev-to-dev

芋道源码 2 gadi atpakaļ
vecāks
revīzija
4a965b8c14

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

@@ -0,0 +1,31 @@
+import request from '@/config/axios'
+import type { SpuType } from './type/spuType'
+
+// 获得spu列表
+export const getSpuList = (params: any) => {
+  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}` })
+}

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

@@ -0,0 +1,79 @@
+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
+}

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

@@ -0,0 +1,25 @@
+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 // 是否优品
+}

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

@@ -71,8 +71,8 @@ export const getPropertyList = (params: any) => {
 }
 
 // 获得属性项列表
-export const getPropertyListAndValue = (params: any) => {
-  return request.get({ url: '/product/property/get-value-list', params })
+export const getPropertyListAndValue = (data: any) => {
+  return request.post({ url: '/product/property/get-value-list', data })
 }
 
 // ------------------------ 属性值 -------------------

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

@@ -2,9 +2,9 @@ import { Layout } from '@/utils/routerHelper'
 
 const { t } = useI18n()
 /**
-* redirect: noredirect        当设置 noredirect 的时候该路由在面包屑导航中不可被点击
-* name:'router-name'          设定路由的名字,一定要填写不然使用<keep-alive>时会出现各种问题
-* meta : {
+ * redirect: noredirect        当设置 noredirect 的时候该路由在面包屑导航中不可被点击
+ * name:'router-name'          设定路由的名字,一定要填写不然使用<keep-alive>时会出现各种问题
+ * meta : {
     hidden: true              当设置 true 的时候该路由不会再侧边栏出现 如404,login等页面(默认 false)
 
     alwaysShow: true          当你一个路由下面的 children 声明的路由大于1个时,自动会变成嵌套的模式,
@@ -31,7 +31,7 @@ const { t } = useI18n()
 
     canTo: true               设置为true即使hidden为true,也依然可以进行路由跳转(默认 false)
   }
-**/
+ **/
 const remainingRouter: AppRouteRecordRaw[] = [
   {
     path: '/redirect',
@@ -345,6 +345,29 @@ const remainingRouter: AppRouteRecordRaw[] = [
         meta: { title: '商品属性值', icon: '', activeMenu: '/product/property' }
       }
     ]
+  },
+  {
+    path: '/product',
+    component: Layout,
+    name: 'ProductManagementEdit',
+    meta: {
+      hidden: true
+    },
+    children: [
+      {
+        path: 'productManagementAdd',
+        component: () => import('@/views/mall/product/management/addForm.vue'),
+        name: 'ProductManagementAdd',
+        meta: {
+          noCache: true,
+          hidden: true,
+          canTo: true,
+          icon: 'ep:edit',
+          title: '添加商品',
+          activeMenu: '/product/product-management'
+        }
+      }
+    ]
   }
 ]
 

+ 17 - 0
src/utils/constants.ts

@@ -220,3 +220,20 @@ export const PayRefundStatusEnum = {
     name: '退款关闭'
   }
 }
+/**
+ * 商品SPU枚举类
+ */
+export const ProductSpuStatusEnum = {
+  RECYCLE: {
+    status: -1,
+    name: '回收站'
+  },
+  DISABLE: {
+    status: 0,
+    name: '下架'
+  },
+  ENABLE: {
+    status: 1,
+    name: '上架'
+  }
+}

+ 5 - 1
src/utils/dict.ts

@@ -144,5 +144,9 @@ export enum DICT_TYPE {
 
   // ========== MP 模块 ==========
   MP_AUTO_REPLY_REQUEST_MATCH = 'mp_auto_reply_request_match', // 自动回复请求匹配类型
-  MP_MESSAGE_TYPE = 'mp_message_type' // 消息类型
+  MP_MESSAGE_TYPE = 'mp_message_type', // 消息类型
+
+  // ========== MALL 模块 ==========
+  PRODUCT_UNIT = 'product_unit', // 商品单位
+  PRODUCT_SPU_STATUS = 'product_spu_status' //商品状态
 }

+ 17 - 0
src/utils/object.ts

@@ -0,0 +1,17 @@
+/**
+ * 将值复制到目标对象,且以目标对象属性为准,例: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)
+}

+ 236 - 0
src/views/mall/product/management/addForm.vue

@@ -0,0 +1,236 @@
+<template>
+  <ContentWrap v-loading="formLoading">
+    <el-tabs v-model="activeName">
+      <el-tab-pane label="商品信息" name="basicInfo">
+        <BasicInfoForm
+          ref="BasicInfoRef"
+          v-model:activeName="activeName"
+          :propFormData="formData"
+        />
+      </el-tab-pane>
+      <el-tab-pane label="商品详情" name="description">
+        <DescriptionForm
+          ref="DescriptionRef"
+          v-model:activeName="activeName"
+          :propFormData="formData"
+        />
+      </el-tab-pane>
+      <el-tab-pane label="其他设置" name="otherSettings">
+        <OtherSettingsForm
+          ref="OtherSettingsRef"
+          v-model:activeName="activeName"
+          :propFormData="formData"
+        />
+      </el-tab-pane>
+    </el-tabs>
+    <el-form>
+      <el-form-item style="float: right">
+        <el-button :loading="formLoading" type="primary" @click="submitForm">保存</el-button>
+        <el-button @click="close">返回</el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+</template>
+<script lang="ts" name="ProductManagementForm" setup>
+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'
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+const { push, currentRoute } = useRouter() // 路由
+const { query } = 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', // 商品名称
+  categoryId: null, // 商品分类
+  keyword: '213', // 关键字
+  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, // 运费模版
+  specType: false, // 商品规格
+  subCommissionType: false, // 分销类型
+  skus: [
+    {
+      /**
+       * 商品价格,单位:分
+       */
+      price: 0,
+      /**
+       * 市场价,单位:分
+       */
+      marketPrice: 0,
+      /**
+       * 成本价,单位:分
+       */
+      costPrice: 0,
+      /**
+       * 商品条码
+       */
+      barCode: '',
+      /**
+       * 图片地址
+       */
+      picUrl: '',
+      /**
+       * 库存
+       */
+      stock: 0,
+      /**
+       * 商品重量,单位:kg 千克
+       */
+      weight: 0,
+      /**
+       * 商品体积,单位:m^3 平米
+       */
+      volume: 0,
+      /**
+       * 一级分销的佣金,单位:分
+       */
+      subCommissionFirstPrice: 0,
+      /**
+       * 二级分销的佣金,单位:分
+       */
+      subCommissionSecondPrice: 0
+    }
+  ],
+  description: '5425', // 商品详情
+  sort: 1, // 商品排序
+  giveIntegral: 1, // 赠送积分
+  virtualSalesCount: 1, // 虚拟销量
+  recommendHot: false, // 是否热卖
+  recommendBenefit: false, // 是否优惠
+  recommendBest: false, // 是否精品
+  recommendNew: false, // 是否新品
+  recommendGood: false // 是否优品
+})
+/** 获得详情 */
+const getDetail = async () => {
+  const id = query.id as unknown as number
+  if (id) {
+    formLoading.value = true
+    try {
+      const res = (await managementApi.getSpu(id)) as SpuType
+      formData.value = res
+      // 直接取第一个值就能得到所有属性的id
+      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
+    }
+  }
+}
+
+/** 提交按钮 */
+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()
+    // 处理掉一些无关数据
+    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
+        })
+      }
+    })
+    // 处理轮播图列表
+    const newSliderPicUrls = []
+    formData.value.sliderPicUrls.forEach((item) => {
+      // 如果是前端选的图
+      if (typeof item === 'object') {
+        newSliderPicUrls.push(item.url)
+      } else {
+        newSliderPicUrls.push(item)
+      }
+    })
+    formData.value.sliderPicUrls = newSliderPicUrls
+    // 校验都通过后提交表单
+    const data = formData.value as SpuType
+    // 移除skus.
+    const id = query.id as unknown as number
+    if (!id) {
+      await managementApi.createSpu(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await managementApi.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 = () => {
+  resetForm()
+  delView(unref(currentRoute))
+  push('/product/product-management')
+}
+
+/** 初始化 */
+onMounted(() => {
+  getDetail()
+})
+</script>

+ 235 - 0
src/views/mall/product/management/components/BasicInfoForm.vue

@@ -0,0 +1,235 @@
+<template>
+  <el-form ref="ProductManagementBasicInfoRef" :model="formData" :rules="rules" label-width="120px">
+    <el-row>
+      <el-col :span="12">
+        <el-form-item label="商品名称" prop="name">
+          <el-input v-model="formData.name" placeholder="请输入商品名称" />
+        </el-form-item>
+      </el-col>
+      <el-col :span="12">
+        <el-form-item label="商品分类" prop="categoryId">
+          <el-tree-select
+            v-model="formData.categoryId"
+            :data="categoryList"
+            :props="defaultProps"
+            check-strictly
+            node-key="id"
+            placeholder="请选择商品分类"
+          />
+        </el-form-item>
+      </el-col>
+      <el-col :span="12">
+        <el-form-item label="商品关键字" prop="keyword">
+          <el-input v-model="formData.keyword" placeholder="请输入商品关键字" />
+        </el-form-item>
+      </el-col>
+      <el-col :span="12">
+        <el-form-item label="单位" prop="unit">
+          <el-select v-model="formData.unit" placeholder="请选择单位">
+            <el-option
+              v-for="dict in getIntDictOptions(DICT_TYPE.PRODUCT_UNIT)"
+              :key="dict.value"
+              :label="dict.label"
+              :value="dict.value"
+            />
+          </el-select>
+        </el-form-item>
+      </el-col>
+      <el-col :span="12">
+        <el-form-item label="商品简介" prop="introduction">
+          <el-input
+            v-model="formData.introduction"
+            :rows="3"
+            placeholder="请输入商品简介"
+            type="textarea"
+          />
+        </el-form-item>
+      </el-col>
+      <el-col :span="12">
+        <el-form-item label="商品封面图" prop="picUrl">
+          <UploadImg v-model="formData.picUrl" height="80px" />
+        </el-form-item>
+      </el-col>
+      <el-col :span="24">
+        <el-form-item label="商品轮播图" prop="sliderPicUrls">
+          <UploadImgs v-model="formData.sliderPicUrls" />
+        </el-form-item>
+      </el-col>
+      <el-col :span="12">
+        <el-form-item label="运费模板" prop="deliveryTemplateId">
+          <el-select v-model="formData.deliveryTemplateId" placeholder="请选择" style="width: 100%">
+            <el-option v-for="item in []" :key="item.id" :label="item.name" :value="item.id" />
+          </el-select>
+        </el-form-item>
+      </el-col>
+      <el-col :span="12">
+        <el-button class="ml-20px">运费模板</el-button>
+      </el-col>
+      <el-col :span="12">
+        <el-form-item label="商品规格" props="specType">
+          <el-radio-group v-model="formData.specType" @change="onChangeSpec">
+            <el-radio :label="false" class="radio">单规格</el-radio>
+            <el-radio :label="true">多规格</el-radio>
+          </el-radio-group>
+        </el-form-item>
+      </el-col>
+      <el-col :span="12">
+        <el-form-item label="分销类型" props="subCommissionType">
+          <el-radio-group v-model="formData.subCommissionType" @change="changeSubCommissionType">
+            <el-radio :label="false">默认设置</el-radio>
+            <el-radio :label="true" class="radio">自行设置</el-radio>
+          </el-radio-group>
+        </el-form-item>
+      </el-col>
+      <!-- 多规格添加-->
+      <el-col :span="24">
+        <el-form-item v-if="formData.specType" label="商品属性">
+          <el-button class="mr-15px mb-10px" @click="AttributesAddFormRef.open()"
+            >添加规格
+          </el-button>
+          <ProductAttributes :attribute-data="attributeList" />
+        </el-form-item>
+        <template v-if="formData.specType && attributeList.length > 0">
+          <el-form-item label="批量设置">
+            <SkuList :attributeList="attributeList" :is-batch="true" :prop-form-data="formData" />
+          </el-form-item>
+          <el-form-item label="属性列表">
+            <SkuList :attributeList="attributeList" :prop-form-data="formData" />
+          </el-form-item>
+        </template>
+        <el-form-item v-if="!formData.specType">
+          <SkuList :attributeList="attributeList" :prop-form-data="formData" />
+        </el-form-item>
+      </el-col>
+    </el-row>
+  </el-form>
+  <ProductAttributesAddForm ref="AttributesAddFormRef" @success="addAttribute" />
+</template>
+<script lang="ts" name="ProductManagementBasicInfoForm" setup>
+import { PropType } from 'vue'
+import { defaultProps, handleTree } from '@/utils/tree'
+import { ElInput } from 'element-plus'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import type { SpuType } from '@/api/mall/product/management/type/spuType'
+import { UploadImg, UploadImgs } from '@/components/UploadFile'
+import { copyValueToTarget } from '@/utils/object'
+import { ProductAttributes, ProductAttributesAddForm, SkuList } from './index'
+// 业务Api
+import * as ProductCategoryApi from '@/api/mall/product/category'
+import { propTypes } from '@/utils/propTypes'
+
+const message = useMessage() // 消息弹窗
+const props = defineProps({
+  propFormData: {
+    type: Object as PropType<SpuType>,
+    default: () => {}
+  },
+  activeName: propTypes.string.def('')
+})
+const AttributesAddFormRef = ref() // 添加商品属性表单
+const ProductManagementBasicInfoRef = ref() // 表单Ref
+const attributeList = ref([]) // 商品属性列表
+/** 添加商品属性 */
+const addAttribute = (property: any) => {
+  if (Array.isArray(property)) {
+    attributeList.value = property
+    return
+  }
+  attributeList.value.push(property)
+}
+const formData = reactive<SpuType>({
+  name: '', // 商品名称
+  categoryId: undefined, // 商品分类
+  keyword: '', // 关键字
+  unit: '', // 单位
+  picUrl: '', // 商品封面图
+  sliderPicUrls: [], // 商品轮播图
+  introduction: '', // 商品简介
+  deliveryTemplateId: 1, // 运费模版
+  specType: false, // 商品规格
+  subCommissionType: false, // 分销类型
+  skus: []
+})
+const rules = reactive({
+  name: [required],
+  categoryId: [required],
+  keyword: [required],
+  unit: [required],
+  introduction: [required],
+  picUrl: [required],
+  sliderPicUrls: [required],
+  // deliveryTemplateId: [required],
+  specType: [required],
+  subCommissionType: [required]
+})
+/**
+ * 将传进来的值赋值给formData
+ */
+watch(
+  () => props.propFormData,
+  (data) => {
+    if (!data) return
+    copyValueToTarget(formData, data)
+  },
+  {
+    deep: true,
+    immediate: true
+  }
+)
+const emit = defineEmits(['update:activeName'])
+/**
+ * 表单校验
+ */
+const validate = async () => {
+  // 校验表单
+  if (!ProductManagementBasicInfoRef) return
+  return await unref(ProductManagementBasicInfoRef).validate((valid) => {
+    if (!valid) {
+      message.warning('商品信息未完善!!')
+      emit('update:activeName', 'basicInfo')
+      // 目的截断之后的校验
+      throw new Error('商品信息未完善!!')
+    } else {
+      // 校验通过更新数据
+      Object.assign(props.propFormData, formData)
+    }
+  })
+}
+defineExpose({ validate, addAttribute })
+
+// 分销类型
+const changeSubCommissionType = () => {
+  // 默认为零,类型切换后也要重置为零
+  for (const item of formData.skus) {
+    item.subCommissionFirstPrice = 0
+    item.subCommissionSecondPrice = 0
+  }
+}
+// 选择规格
+const onChangeSpec = () => {
+  // 重置商品属性列表
+  attributeList.value = []
+  // 重置sku列表
+  formData.skus = [
+    {
+      price: 0,
+      marketPrice: 0,
+      costPrice: 0,
+      barCode: '',
+      picUrl: '',
+      stock: 0,
+      weight: 0,
+      volume: 0,
+      subCommissionFirstPrice: 0,
+      subCommissionSecondPrice: 0
+    }
+  ]
+}
+
+const categoryList = ref() // 分类树
+onMounted(async () => {
+  // 获得分类树
+  const data = await ProductCategoryApi.getCategoryList({})
+  categoryList.value = handleTree(data, 'id', 'parentId')
+})
+</script>

+ 81 - 0
src/views/mall/product/management/components/DescriptionForm.vue

@@ -0,0 +1,81 @@
+<template>
+  <el-form ref="DescriptionFormRef" :model="formData" :rules="rules" label-width="120px">
+    <!--富文本编辑器组件-->
+    <el-form-item label="商品详情" prop="description">
+      <Editor v-model:modelValue="formData.description" />
+    </el-form-item>
+  </el-form>
+</template>
+<script lang="ts" name="DescriptionForm" setup>
+import type { SpuType } from '@/api/mall/product/management/type/spuType'
+import { Editor } from '@/components/Editor'
+import { PropType } from 'vue'
+import { copyValueToTarget } from '@/utils/object'
+import { propTypes } from '@/utils/propTypes'
+
+const message = useMessage() // 消息弹窗
+const props = defineProps({
+  propFormData: {
+    type: Object as PropType<SpuType>,
+    default: () => {}
+  },
+  activeName: propTypes.string.def('')
+})
+const DescriptionFormRef = ref() // 表单Ref
+const formData = ref<SpuType>({
+  description: '' // 商品详情
+})
+/**
+ * 富文本编辑器如果输入过再清空会有残留,需再重置一次
+ */
+watch(
+  () => formData.value.description,
+  (newValue) => {
+    if ('<p><br></p>' === newValue) {
+      formData.value.description = ''
+    }
+  },
+  {
+    deep: true,
+    immediate: true
+  }
+)
+// 表单规则
+const rules = reactive({
+  description: [required]
+})
+/**
+ * 将传进来的值赋值给formData
+ */
+watch(
+  () => props.propFormData,
+  (data) => {
+    if (!data) return
+    copyValueToTarget(formData.value, data)
+  },
+  {
+    deep: true,
+    immediate: true
+  }
+)
+const emit = defineEmits(['update:activeName'])
+/**
+ * 表单校验
+ */
+const validate = async () => {
+  // 校验表单
+  if (!DescriptionFormRef) return
+  return unref(DescriptionFormRef).validate((valid) => {
+    if (!valid) {
+      message.warning('商品详情为完善!!')
+      emit('update:activeName', 'description')
+      // 目的截断之后的校验
+      throw new Error('商品详情为完善!!')
+    } else {
+      // 校验通过更新数据
+      Object.assign(props.propFormData, formData.value)
+    }
+  })
+}
+defineExpose({ validate })
+</script>

+ 154 - 0
src/views/mall/product/management/components/OtherSettingsForm.vue

@@ -0,0 +1,154 @@
+<template>
+  <el-form ref="OtherSettingsFormRef" :model="formData" :rules="rules" label-width="120px">
+    <el-row>
+      <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-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">
+              {{ item.name }}
+            </el-checkbox>
+          </el-checkbox-group>
+        </el-form-item>
+      </el-col>
+      <el-col :span="24">
+        <!--   TODO tag展示暂时不考虑排序     -->
+        <el-form-item label="活动优先级">
+          <el-tag>默认</el-tag>
+          <el-tag class="ml-2" type="success">秒杀</el-tag>
+          <el-tag class="ml-2" type="info">砍价</el-tag>
+          <el-tag class="ml-2" type="warning">拼团</el-tag>
+        </el-form-item>
+      </el-col>
+      <el-col :span="24">
+        <el-form-item label="赠送优惠劵">
+          <el-button>选择优惠券</el-button>
+        </el-form-item>
+      </el-col>
+    </el-row>
+  </el-form>
+</template>
+<script lang="ts" name="OtherSettingsForm" setup>
+// 商品推荐
+import type { SpuType } from '@/api/mall/product/management/type/spuType'
+import { PropType } from 'vue'
+import { copyValueToTarget } from '@/utils/object'
+import { propTypes } from '@/utils/propTypes'
+
+const message = useMessage() // 消息弹窗
+const props = defineProps({
+  propFormData: {
+    type: Object as PropType<SpuType>,
+    default: () => {}
+  },
+  activeName: propTypes.string.def('')
+})
+// 商品推荐选项
+const recommend = [
+  { name: '是否热卖', value: 'recommendHot' },
+  { name: '是否优惠', value: 'recommendBenefit' },
+  { name: '是否精品', value: 'recommendBest' },
+  { name: '是否新品', value: 'recommendNew' },
+  { name: '是否优品', value: 'recommendGood' }
+]
+// 选中推荐选项
+const checkboxGroup = ref<string[]>(['recommendHot'])
+// 选择商品后赋值
+const onChangeGroup = () => {
+  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 formData = ref<SpuType>({
+  sort: 1, // 商品排序
+  giveIntegral: 1, // 赠送积分
+  virtualSalesCount: 1, // 虚拟销量
+  recommendHot: false, // 是否热卖
+  recommendBenefit: false, // 是否优惠
+  recommendBest: false, // 是否精品
+  recommendNew: false, // 是否新品
+  recommendGood: false // 是否优品
+})
+// 表单规则
+const rules = reactive({
+  sort: [required],
+  giveIntegral: [required],
+  virtualSalesCount: [required]
+})
+/**
+ * 将传进来的值赋值给formData
+ */
+watch(
+  () => props.propFormData,
+  (data) => {
+    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') : ''
+  },
+  {
+    deep: true,
+    immediate: true
+  }
+)
+const emit = defineEmits(['update:activeName'])
+/**
+ * 表单校验
+ */
+const validate = async () => {
+  // 校验表单
+  if (!OtherSettingsFormRef) return
+  return await unref(OtherSettingsFormRef).validate((valid) => {
+    if (!valid) {
+      message.warning('商品其他设置未完善!!')
+      emit('update:activeName', 'otherSettings')
+      // 目的截断之后的校验
+      throw new Error('商品其他设置未完善!!')
+    } else {
+      // 校验通过更新数据
+      Object.assign(props.propFormData, formData.value)
+    }
+  })
+}
+
+defineExpose({ validate })
+</script>

+ 99 - 0
src/views/mall/product/management/components/ProductAttributes.vue

@@ -0,0 +1,99 @@
+<template>
+  <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>
+    </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)"
+      >
+        {{ value.name }}
+      </el-tag>
+      <el-input
+        v-show="inputVisible(index)"
+        ref="InputRef"
+        v-model="inputValue"
+        class="!w-20"
+        size="small"
+        @blur="handleInputConfirm(index, item.id)"
+        @keyup.enter="handleInputConfirm(index, item.id)"
+      />
+      <el-button
+        v-show="!inputVisible(index)"
+        class="button-new-tag ml-1"
+        size="small"
+        @click="showInput(index)"
+      >
+        + 添加
+      </el-button>
+    </div>
+    <el-divider class="my-10px" />
+  </el-col>
+</template>
+
+<script lang="ts" name="ProductAttributes" setup>
+import { ElInput } from 'element-plus'
+import * as PropertyApi from '@/api/mall/product/property'
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+const inputValue = ref('') // 输入框值
+const attributeIndex = ref<number | null>(null) // 获取焦点时记录当前属性项的index
+// 输入框显隐控制
+const inputVisible = computed(() => (index) => {
+  if (attributeIndex.value === null) return false
+  if (attributeIndex.value === index) return true
+})
+const InputRef = ref() //标签输入框Ref
+const attributeList = ref([]) // 商品属性列表
+const props = defineProps({
+  attributeData: {
+    type: Array,
+    default: () => {}
+  }
+})
+
+watch(
+  () => props.attributeData,
+  (data) => {
+    if (!data) return
+    attributeList.value = data
+  },
+  {
+    deep: true,
+    immediate: true
+  }
+)
+/** 删除标签 tagValue 标签值*/
+const handleClose = (index, valueIndex) => {
+  attributeList.value[index].values?.splice(valueIndex, 1)
+}
+/** 显示输入框并获取焦点 */
+const showInput = async (index) => {
+  attributeIndex.value = index
+  // 因为组件在ref中所以需要用索引获取对应的Ref
+  InputRef.value[index]!.input!.focus()
+}
+/** 输入框失去焦点或点击回车时触发 */
+const handleInputConfirm = async (index, propertyId) => {
+  if (inputValue.value) {
+    // 保存属性值
+    try {
+      const id = await PropertyApi.createPropertyValue({ propertyId, name: inputValue.value })
+      attributeList.value[index].values.push({ id, name: inputValue.value })
+      message.success(t('common.createSuccess'))
+    } catch {
+      message.error('添加失败,请重试') // TODO 缺少国际化
+    }
+  }
+  attributeIndex.value = null
+  inputValue.value = ''
+}
+</script>

+ 85 - 0
src/views/mall/product/management/components/ProductAttributesAddForm.vue

@@ -0,0 +1,85 @@
+<template>
+  <Dialog v-model="dialogVisible" :title="dialogTitle">
+    <el-form
+      ref="formRef"
+      v-loading="formLoading"
+      :model="formData"
+      :rules="formRules"
+      label-width="80px"
+    >
+      <el-form-item label="名称" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入名称" />
+      </el-form-item>
+      <el-form-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>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script lang="ts" name="ProductPropertyForm" setup>
+import * as PropertyApi from '@/api/mall/product/property'
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('添加商品属性') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formData = ref({
+  name: '',
+  remark: ''
+})
+const formRules = reactive({
+  name: [{ required: true, message: '名称不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+
+/** 打开弹窗 */
+const open = async () => {
+  dialogVisible.value = true
+  resetForm()
+}
+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 PropertyApi.PropertyVO
+    // 检查属性是否已存在,如果有则返回属性和其下属性值
+    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: [] })
+    } else {
+      if (res[0].values === null) {
+        res[0].values = []
+      }
+      emit('success', res[0]) // 因为只用一个
+    }
+    message.success(t('common.createSuccess'))
+    dialogVisible.value = false
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    name: '',
+    remark: ''
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 301 - 0
src/views/mall/product/management/components/SkuList.vue

@@ -0,0 +1,301 @@
+<template>
+  <el-table
+    :data="isBatch ? SkuData : formData.skus"
+    border
+    class="tabNumWidth"
+    max-height="500"
+    size="small"
+  >
+    <el-table-column align="center" fixed="left" label="图片" min-width="100">
+      <template #default="{ row }">
+        <UploadImg v-model="row.picUrl" height="80px" width="100%" />
+      </template>
+    </el-table-column>
+    <template v-if="formData.specType && !isBatch">
+      <!--  根据商品属性动态添加  -->
+      <el-table-column
+        v-for="(item, index) in tableHeaderList"
+        :key="index"
+        :label="item.label"
+        align="center"
+        min-width="120"
+      >
+        <template #default="{ row }">
+          {{ row.properties[index]?.valueName }}
+        </template>
+      </el-table-column>
+    </template>
+    <el-table-column align="center" label="商品条码" min-width="168">
+      <template #default="{ row }">
+        <el-input v-model="row.barCode" class="w-100%" />
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="销售价(分)" min-width="168">
+      <template #default="{ row }">
+        <el-input-number v-model="row.price" :min="0" 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.marketPrice"
+          :min="0"
+          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.costPrice"
+          :min="0"
+          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" />
+      </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" />
+      </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" />
+      </template>
+    </el-table-column>
+    <template v-if="formData.subCommissionType">
+      <el-table-column align="center" label="一级返佣(分)" min-width="168">
+        <template #default="{ row }">
+          <el-input-number
+            v-model="row.subCommissionFirstPrice"
+            :min="0"
+            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.subCommissionSecondPrice"
+            :min="0"
+            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>
+        <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>
+      </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 { propTypes } from '@/utils/propTypes'
+import { SkuType } from '@/api/mall/product/management/type/skuType'
+import { copyValueToTarget } from '@/utils/object'
+
+const props = defineProps({
+  propFormData: {
+    type: Object as PropType<SpuType>,
+    default: () => {}
+  },
+  attributeList: {
+    type: Array,
+    default: () => []
+  },
+  isBatch: propTypes.bool.def(false) // 是否批量操作
+})
+const formData = ref<SpuType>() // 表单数据
+// 批量添加时的零时数据
+const SkuData = ref<SkuType[]>([
+  {
+    /**
+     * 商品价格,单位:分
+     */
+    price: 0,
+    /**
+     * 市场价,单位:分
+     */
+    marketPrice: 0,
+    /**
+     * 成本价,单位:分
+     */
+    costPrice: 0,
+    /**
+     * 商品条码
+     */
+    barCode: '',
+    /**
+     * 图片地址
+     */
+    picUrl: '',
+    /**
+     * 库存
+     */
+    stock: 0,
+    /**
+     * 商品重量,单位:kg 千克
+     */
+    weight: 0,
+    /**
+     * 商品体积,单位:m^3 平米
+     */
+    volume: 0,
+    /**
+     * 一级分销的佣金,单位:分
+     */
+    subCommissionFirstPrice: 0,
+    /**
+     * 二级分销的佣金,单位:分
+     */
+    subCommissionSecondPrice: 0
+  }
+])
+/** 批量添加 */
+const batchAdd = () => {
+  formData.value.skus.forEach((item) => {
+    copyValueToTarget(item, SkuData.value[0])
+  })
+}
+const tableHeaderList = ref<{ prop: string; label: string }[]>([])
+/**
+ * 将传进来的值赋值给SkuData
+ */
+watch(
+  () => props.propFormData,
+  (data) => {
+    if (!data) return
+    formData.value = data
+  },
+  {
+    deep: true,
+    immediate: true
+  }
+)
+/** 生成表数据 */
+const generateTableData = (data: 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 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) => {
+    const row = {
+      properties: [],
+      price: 0,
+      marketPrice: 0,
+      costPrice: 0,
+      barCode: '',
+      picUrl: '',
+      stock: 0,
+      weight: 0,
+      volume: 0,
+      subCommissionFirstPrice: 0,
+      subCommissionSecondPrice: 0
+    }
+    // 判断是否是单一属性的情况
+    if (Array.isArray(item)) {
+      row.properties = item
+    } else {
+      row.properties.push(item)
+    }
+    formData.value.skus.push(row)
+  })
+}
+/** 构建所有排列组合 */
+const build = (list: any[]) => {
+  if (list.length === 0) {
+    return []
+  } else if (list.length === 1) {
+    return list[0]
+  } else {
+    const result = []
+    const rest = build(list.slice(1))
+    for (let i = 0; i < list[0].length; i++) {
+      for (let j = 0; j < rest.length; j++) {
+        // 第一次不是数组结构,后面的都是数组结构
+        if (Array.isArray(rest[j])) {
+          result.push([list[0][i], ...rest[j]])
+        } else {
+          result.push([list[0][i], rest[j]])
+        }
+      }
+    }
+    return result
+  }
+}
+/** 监听属性列表生成相关参数和表头 */
+watch(
+  () => props.attributeList,
+  (data) => {
+    // 如果不是多规格则结束
+    if (!formData.value.specType) return
+    // 如果当前组件作为批量添加数据使用则重置表数据
+    if (props.isBatch) {
+      SkuData.value = [
+        {
+          price: 0,
+          marketPrice: 0,
+          costPrice: 0,
+          barCode: '',
+          picUrl: '',
+          stock: 0,
+          weight: 0,
+          volume: 0,
+          subCommissionFirstPrice: 0,
+          subCommissionSecondPrice: 0
+        }
+      ]
+    }
+    // 判断代理对象是否为空
+    if (JSON.stringify(data) === '[]') return
+    // 重置表头
+    tableHeaderList.value = []
+    // 生成表头
+    data.forEach((item, index) => {
+      // name加属性项index区分属性值
+      tableHeaderList.value.push({ prop: `name${index}`, label: item.name })
+    })
+    generateTableData(data)
+  },
+  {
+    deep: true,
+    immediate: true
+  }
+)
+</script>

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

@@ -0,0 +1,15 @@
+import BasicInfoForm from './BasicInfoForm.vue'
+import DescriptionForm from './DescriptionForm.vue'
+import OtherSettingsForm from './OtherSettingsForm.vue'
+import ProductAttributes from './ProductAttributes.vue'
+import ProductAttributesAddForm from './ProductAttributesAddForm.vue'
+import SkuList from './SkuList.vue'
+
+export {
+  BasicInfoForm,
+  DescriptionForm,
+  OtherSettingsForm,
+  ProductAttributes,
+  ProductAttributesAddForm,
+  SkuList
+}

+ 361 - 0
src/views/mall/product/management/index.vue

@@ -0,0 +1,361 @@
+<template>
+  <!-- 搜索工作栏 -->
+  <ContentWrap>
+    <el-form
+      ref="queryFormRef"
+      :inline="true"
+      :model="queryParams"
+      class="-mb-15px"
+      label-width="68px"
+    >
+      <el-form-item label="品牌名称" prop="name">
+        <el-input
+          v-model="queryParams.name"
+          class="!w-240px"
+          clearable
+          placeholder="请输入品牌名称"
+          @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>
+      </el-form-item>
+      <el-form-item label="创建时间" prop="createTime">
+        <el-date-picker
+          v-model="queryParams.createTime"
+          :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+          class="!w-240px"
+          end-placeholder="结束日期"
+          start-placeholder="开始日期"
+          type="daterange"
+          value-format="YYYY-MM-DD HH:mm:ss"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery">
+          <Icon class="mr-5px" icon="ep:search" />
+          搜索
+        </el-button>
+        <el-button @click="resetQuery">
+          <Icon class="mr-5px" icon="ep:refresh" />
+          重置
+        </el-button>
+        <el-button v-hasPermi="['product:brand:create']" plain type="primary" @click="openForm">
+          <Icon class="mr-5px" icon="ep:plus" />
+          新增
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-tabs v-model="queryParams.tabType" @tab-click="handleClick">
+      <el-tab-pane
+        v-for="item in tabsData"
+        :key="item.type"
+        :label="item.name + '(' + item.count + ')'"
+        :name="item.type"
+      />
+    </el-tabs>
+    <el-table v-loading="loading" :data="list">
+      <!--   TODO 暂时不做折叠数据   -->
+      <!--      <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>-->
+      <el-table-column label="商品图" min-width="80">
+        <template #default="{ row }">
+          <el-image
+            :src="row.picUrl"
+            style="width: 36px; height: 36px"
+            @click="imagePreview(row.picUrl)"
+          />
+        </template>
+      </el-table-column>
+      <el-table-column :show-overflow-tooltip="true" label="商品名称" min-width="300" prop="name" />
+      <el-table-column align="center" label="商品售价" min-width="90" prop="price" />
+      <el-table-column align="center" label="销量" min-width="90" prop="salesCount" />
+      <el-table-column align="center" label="库存" min-width="90" prop="stock" />
+      <el-table-column align="center" label="排序" min-width="70" prop="sort" />
+      <el-table-column
+        :formatter="dateFormatter"
+        align="center"
+        label="创建时间"
+        prop="createTime"
+        width="180"
+      />
+      <el-table-column fixed="right" label="状态" min-width="80">
+        <template #default="{ row }">
+          <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>
+      </el-table-column>
+      <el-table-column align="center" fixed="right" label="操作" min-width="150">
+        <template #default="{ row }">
+          <template v-if="queryParams.tabType === 4">
+            <el-button
+              v-hasPermi="['product:spu:delete']"
+              link
+              type="danger"
+              @click="handleDelete(row.id)"
+            >
+              删除
+            </el-button>
+            <el-button
+              v-hasPermi="['product:spu:update']"
+              link
+              type="primary"
+              @click="addToTrash(row, ProductSpuStatusEnum.DISABLE.status)"
+            >
+              恢复到仓库
+            </el-button>
+          </template>
+          <template v-else>
+            <el-button
+              v-hasPermi="['product:spu:update']"
+              link
+              type="primary"
+              @click="openForm(row.id)"
+            >
+              修改
+            </el-button>
+            <el-button
+              v-hasPermi="['product:spu:update']"
+              link
+              type="primary"
+              @click="addToTrash(row, ProductSpuStatusEnum.RECYCLE.status)"
+            >
+              加入回收站
+            </el-button>
+          </template>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      v-model:limit="queryParams.pageSize"
+      v-model:page="queryParams.pageNo"
+      :total="total"
+      @pagination="getList"
+    />
+  </ContentWrap>
+  <!-- 必须在表格外面展示。不然单元格会遮挡图层 -->
+  <el-image-viewer
+    v-if="imgViewVisible"
+    :url-list="imageViewerList"
+    @close="imgViewVisible = false"
+  />
+</template>
+<script lang="ts" name="ProductManagement" setup>
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime' // 业务api
+import * as managementApi from '@/api/mall/product/management/spu'
+import { ProductSpuStatusEnum } from '@/utils/constants'
+import { TabsPaneContext } from 'element-plus'
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+const { currentRoute, push } = useRouter() // 路由跳转
+const loading = ref(false) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref<any[]>([]) // 列表的数据
+// tabs数据
+const tabsData = ref([
+  {
+    count: 0,
+    name: '出售中商品',
+    type: 0
+  },
+  {
+    count: 0,
+    name: '仓库中商品',
+    type: 1
+  },
+  {
+    count: 0,
+    name: '已经售空商品',
+    type: 2
+  },
+  {
+    count: 0,
+    name: '警戒库存',
+    type: 3
+  },
+  {
+    count: 0,
+    name: '商品回收站',
+    type: 4
+  }
+])
+const getTabsCount = async () => {
+  try {
+    const res = await managementApi.getTabsCount()
+    for (let objName in res) {
+      tabsData.value[Number(objName)].count = res[objName]
+    }
+  } catch {}
+}
+const imgViewVisible = ref(false) // 商品图预览
+const imageViewerList = ref<string[]>([]) // 商品图预览列表
+const queryParams = ref({
+  pageNo: 1,
+  pageSize: 10,
+  tabType: 0
+})
+const queryFormRef = ref() // 搜索的表单
+const handleClick = (tab: TabsPaneContext) => {
+  queryParams.value.tabType = tab.paneName
+  getList()
+}
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await managementApi.getSpuList(queryParams.value)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/**
+ * 更改SPU状态
+ * @param row
+ * @param status 更改前的值
+ */
+const changeStatus = async (row, status?: number) => {
+  // TODO 测试过程中似乎有点问题,下一版修复
+  try {
+    let text = ''
+    switch (row.status) {
+      case ProductSpuStatusEnum.DISABLE.status:
+        text = ProductSpuStatusEnum.DISABLE.name
+        break
+      case ProductSpuStatusEnum.ENABLE.status:
+        text = ProductSpuStatusEnum.ENABLE.name
+        break
+      case ProductSpuStatusEnum.RECYCLE.status:
+        text = `加入${ProductSpuStatusEnum.RECYCLE.name}`
+        break
+    }
+    await message.confirm(
+      row.status === -1 ? `确认要将[${row.name}]${text}吗?` : `确认要${text}[${row.name}]吗?`
+    )
+    await managementApi.updateStatus({ id: row.id, status: row.status })
+    message.success('更新状态成功')
+    // 刷新tabs数据
+    await getTabsCount()
+    // 刷新列表
+    await getList()
+  } catch {
+    // 取消加入回收站时回显数据
+    if (typeof status !== 'undefined') {
+      row.status = status
+      return
+    }
+    // 取消更改状态时回显数据
+    row.status =
+      row.status === ProductSpuStatusEnum.DISABLE.status
+        ? ProductSpuStatusEnum.ENABLE.status
+        : ProductSpuStatusEnum.DISABLE.status
+  }
+}
+/**
+ * 加入回收站
+ * @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)
+    message.success(t('common.delSuccess'))
+    // 刷新tabs数据
+    await getTabsCount()
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+/**
+ * 商品图预览
+ * @param imgUrl
+ */
+const imagePreview = (imgUrl: string) => {
+  imageViewerList.value = [imgUrl]
+  imgViewVisible.value = true
+}
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/**
+ * 新增或修改
+ * @param id
+ */
+const openForm = (id?: number) => {
+  if (typeof id === 'number') {
+    push('/product/productManagementAdd?id=' + id)
+    return
+  }
+  push('/product/productManagementAdd')
+}
+// 监听路由变化更新列表
+watch(
+  () => currentRoute.value,
+  () => {
+    getList()
+  },
+  {
+    immediate: true
+  }
+)
+/** 初始化 **/
+onMounted(() => {
+  getTabsCount()
+  getList()
+})
+</script>

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

@@ -2,42 +2,49 @@
   <!-- 搜索工作栏 -->
   <ContentWrap>
     <el-form
-      class="-mb-15px"
-      :model="queryParams"
       ref="queryFormRef"
       :inline="true"
+      :model="queryParams"
+      class="-mb-15px"
       label-width="68px"
     >
       <el-form-item label="名称" prop="name">
         <el-input
           v-model="queryParams.name"
-          placeholder="请输入名称"
+          class="!w-240px"
           clearable
+          placeholder="请输入名称"
           @keyup.enter="handleQuery"
-          class="!w-240px"
         />
       </el-form-item>
       <el-form-item label="创建时间" prop="createTime">
         <el-date-picker
           v-model="queryParams.createTime"
-          value-format="YYYY-MM-DD HH:mm:ss"
-          type="daterange"
-          start-placeholder="开始日期"
-          end-placeholder="结束日期"
           :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
           class="!w-240px"
+          end-placeholder="结束日期"
+          start-placeholder="开始日期"
+          type="daterange"
+          value-format="YYYY-MM-DD HH:mm:ss"
         />
       </el-form-item>
       <el-form-item>
-        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
-        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+        <el-button @click="handleQuery">
+          <Icon class="mr-5px" icon="ep:search" />
+          搜索
+        </el-button>
+        <el-button @click="resetQuery">
+          <Icon class="mr-5px" icon="ep:refresh" />
+          重置
+        </el-button>
         <el-button
+          v-hasPermi="['product:property:create']"
           plain
           type="primary"
           @click="openForm('create')"
-          v-hasPermi="['product:property:create']"
         >
-          <Icon icon="ep:plus" class="mr-5px" /> 新增
+          <Icon class="mr-5px" icon="ep:plus" />
+          新增
         </el-button>
       </el-form-item>
     </el-form>
@@ -46,23 +53,23 @@
   <!-- 列表 -->
   <ContentWrap>
     <el-table v-loading="loading" :data="list">
-      <el-table-column label="编号" align="center" prop="id" />
-      <el-table-column label="名称" align="center" />
-      <el-table-column label="备注" align="center" prop="remark" :show-overflow-tooltip="true" />
+      <el-table-column align="center" label="编号" prop="id" />
+      <el-table-column align="center" label="名称" prop="name" />
+      <el-table-column :show-overflow-tooltip="true" align="center" label="备注" prop="remark" />
       <el-table-column
-        label="创建时间"
+        :formatter="dateFormatter"
         align="center"
+        label="创建时间"
         prop="createTime"
         width="180"
-        :formatter="dateFormatter"
       />
-      <el-table-column label="操作" align="center">
+      <el-table-column align="center" label="操作">
         <template #default="scope">
           <el-button
+            v-hasPermi="['product:property:update']"
             link
             type="primary"
             @click="openForm('update', scope.row.id)"
-            v-hasPermi="['product:property:update']"
           >
             编辑
           </el-button>
@@ -70,10 +77,10 @@
             <router-link :to="'/property/value/' + scope.row.id">属性值</router-link>
           </el-button>
           <el-button
+            v-hasPermi="['product:property:delete']"
             link
             type="danger"
             @click="handleDelete(scope.row.id)"
-            v-hasPermi="['product:property:delete']"
           >
             删除
           </el-button>
@@ -82,9 +89,9 @@
     </el-table>
     <!-- 分页 -->
     <Pagination
-      :total="total"
-      v-model:page="queryParams.pageNo"
       v-model:limit="queryParams.pageSize"
+      v-model:page="queryParams.pageNo"
+      :total="total"
       @pagination="getList"
     />
   </ContentWrap>
@@ -92,10 +99,11 @@
   <!-- 表单弹窗:添加/修改 -->
   <PropertyForm ref="formRef" @success="getList" />
 </template>
-<script setup lang="ts" name="ProductProperty">
+<script lang="ts" name="ProductProperty" setup>
 import { dateFormatter } from '@/utils/formatTime'
 import * as PropertyApi from '@/api/mall/product/property'
 import PropertyForm from './PropertyForm.vue'
+
 const message = useMessage() // 消息弹窗
 const { t } = useI18n() // 国际化
 

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

@@ -2,36 +2,36 @@
   <!-- 搜索工作栏 -->
   <ContentWrap>
     <el-form
-      class="-mb-15px"
-      :model="queryParams"
       ref="queryFormRef"
       :inline="true"
+      :model="queryParams"
+      class="-mb-15px"
       label-width="68px"
     >
       <el-form-item label="字典名称" prop="name">
         <el-input
           v-model="queryParams.name"
-          placeholder="请输入字典名称"
+          class="!w-240px"
           clearable
+          placeholder="请输入字典名称"
           @keyup.enter="handleQuery"
-          class="!w-240px"
         />
       </el-form-item>
       <el-form-item label="字典类型" prop="type">
         <el-input
           v-model="queryParams.type"
-          placeholder="请输入字典类型"
+          class="!w-240px"
           clearable
+          placeholder="请输入字典类型"
           @keyup.enter="handleQuery"
-          class="!w-240px"
         />
       </el-form-item>
       <el-form-item label="状态" prop="status">
         <el-select
           v-model="queryParams.status"
-          placeholder="请选择字典状态"
-          clearable
           class="!w-240px"
+          clearable
+          placeholder="请选择字典状态"
         >
           <el-option
             v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
@@ -44,33 +44,41 @@
       <el-form-item label="创建时间" prop="createTime">
         <el-date-picker
           v-model="queryParams.createTime"
-          value-format="yyyy-MM-dd HH:mm:ss"
-          type="daterange"
-          start-placeholder="开始日期"
-          end-placeholder="结束日期"
           :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
           class="!w-240px"
+          end-placeholder="结束日期"
+          start-placeholder="开始日期"
+          type="daterange"
+          value-format="yyyy-MM-dd HH:mm:ss"
         />
       </el-form-item>
       <el-form-item>
-        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
-        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+        <el-button @click="handleQuery">
+          <Icon class="mr-5px" icon="ep:search" />
+          搜索
+        </el-button>
+        <el-button @click="resetQuery">
+          <Icon class="mr-5px" icon="ep:refresh" />
+          重置
+        </el-button>
         <el-button
-          type="primary"
+          v-hasPermi="['system:dict:create']"
           plain
+          type="primary"
           @click="openForm('create')"
-          v-hasPermi="['system:dict:create']"
         >
-          <Icon icon="ep:plus" class="mr-5px" /> 新增
+          <Icon class="mr-5px" icon="ep:plus" />
+          新增
         </el-button>
         <el-button
-          type="success"
+          v-hasPermi="['system:dict:export']"
+          :loading="exportLoading"
           plain
+          type="success"
           @click="handleExport"
-          :loading="exportLoading"
-          v-hasPermi="['system:dict:export']"
         >
-          <Icon icon="ep:download" class="mr-5px" /> 导出
+          <Icon class="mr-5px" icon="ep:download" />
+          导出
         </el-button>
       </el-form-item>
     </el-form>
@@ -79,29 +87,29 @@
   <!-- 列表 -->
   <ContentWrap>
     <el-table v-loading="loading" :data="list">
-      <el-table-column label="字典编号" align="center" prop="id" />
-      <el-table-column label="字典名称" align="center" prop="name" show-overflow-tooltip />
-      <el-table-column label="字典类型" align="center" prop="type" width="300" />
-      <el-table-column label="状态" align="center" prop="status">
+      <el-table-column align="center" label="字典编号" prop="id" />
+      <el-table-column align="center" label="字典名称" prop="name" show-overflow-tooltip />
+      <el-table-column align="center" label="字典类型" prop="type" width="300" />
+      <el-table-column align="center" label="状态" prop="status">
         <template #default="scope">
           <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
         </template>
       </el-table-column>
-      <el-table-column label="备注" align="center" prop="remark" />
+      <el-table-column align="center" label="备注" prop="remark" />
       <el-table-column
-        label="创建时间"
         :formatter="dateFormatter"
         align="center"
+        label="创建时间"
         prop="createTime"
         width="180"
       />
-      <el-table-column label="操作" align="center">
+      <el-table-column align="center" label="操作">
         <template #default="scope">
           <el-button
+            v-hasPermi="['system:dict:update']"
             link
             type="primary"
             @click="openForm('update', scope.row.id)"
-            v-hasPermi="['system:dict:update']"
           >
             修改
           </el-button>
@@ -109,10 +117,10 @@
             <el-button link type="primary">数据</el-button>
           </router-link>
           <el-button
+            v-hasPermi="['system:dict:delete']"
             link
             type="danger"
             @click="handleDelete(scope.row.id)"
-            v-hasPermi="['system:dict:delete']"
           >
             删除
           </el-button>
@@ -121,9 +129,9 @@
     </el-table>
     <!-- 分页 -->
     <Pagination
-      :total="total"
-      v-model:page="queryParams.pageNo"
       v-model:limit="queryParams.pageSize"
+      v-model:page="queryParams.pageNo"
+      :total="total"
       @pagination="getList"
     />
   </ContentWrap>
@@ -132,12 +140,13 @@
   <DictTypeForm ref="formRef" @success="getList" />
 </template>
 
-<script setup lang="ts" name="SystemDictType">
-import { getIntDictOptions, DICT_TYPE } from '@/utils/dict'
+<script lang="ts" name="SystemDictType" setup>
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
 import { dateFormatter } from '@/utils/formatTime'
 import * as DictTypeApi from '@/api/system/dict/dict.type'
 import DictTypeForm from './DictTypeForm.vue'
 import download from '@/utils/download'
+
 const message = useMessage() // 消息弹窗
 const { t } = useI18n() // 国际化