Explorar el Código

add trade order 20230606

xiaobai hace 1 año
padre
commit
e2d795f49d
Se han modificado 30 ficheros con 2229 adiciones y 149 borrados
  1. 2 2
      .env.dev
  2. 1 0
      package.json
  3. 11 8
      src/api/mall/product/spu.ts
  4. 18 0
      src/api/mall/promotion/coupon.ts
  5. 83 0
      src/api/mall/promotion/couponTemplate.ts
  6. 5 5
      src/api/mall/trade/delivery/expressTemplate/index.ts
  7. 46 0
      src/api/mall/trade/delivery/pickUpStore/index.ts
  8. 7 7
      src/components/Form/src/Form.vue
  9. 4 1
      src/main.ts
  10. 13 0
      src/router/modules/remaining.ts
  11. 57 1
      src/utils/constants.ts
  12. 8 6
      src/utils/dict.ts
  13. 95 0
      src/utils/tree.ts
  14. 4 4
      src/views/infra/build/index.vue
  15. 1 1
      src/views/infra/codegen/PreviewCode.vue
  16. 20 11
      src/views/mall/product/spu/addForm.vue
  17. 133 32
      src/views/mall/product/spu/components/BasicInfoForm.vue
  18. 29 6
      src/views/mall/product/spu/components/DescriptionForm.vue
  19. 41 5
      src/views/mall/product/spu/components/OtherSettingsForm.vue
  20. 1 2
      src/views/mall/product/spu/components/ProductAttributesAddForm.vue
  21. 128 24
      src/views/mall/product/spu/components/SkuList.vue
  22. 106 0
      src/views/mall/product/spu/components/spu.data.ts
  23. 76 26
      src/views/mall/product/spu/index.vue
  24. 200 0
      src/views/mall/promotion/coupon/index.vue
  25. 614 0
      src/views/mall/promotion/couponTemplate/index.vue
  26. 36 7
      src/views/mall/trade/delivery/expressTemplate/ExpressTemplateForm.vue
  27. 1 0
      src/views/mall/trade/delivery/expressTemplate/index.vue
  28. 287 0
      src/views/mall/trade/delivery/pickUpStore/PickUpStoreForm.vue
  29. 201 0
      src/views/mall/trade/delivery/pickUpStore/index.vue
  30. 1 1
      src/views/system/mail/log/MailLogDetail.vue

+ 2 - 2
.env.dev

@@ -19,13 +19,13 @@ VITE_API_URL=/admin-api
 VITE_BASE_PATH=/
 
 # 是否删除debugger
-VITE_DROP_DEBUGGER=false
+VITE_DROP_DEBUGGER=true
 
 # 是否删除console.log
 VITE_DROP_CONSOLE=false
 
 # 是否sourcemap
-VITE_SOURCEMAP=true
+VITE_SOURCEMAP=false
 
 # 输出路径
 VITE_OUT_DIR=dist-dev

+ 1 - 0
package.json

@@ -65,6 +65,7 @@
     "url": "^0.11.0",
     "video.js": "^8.3.0",
     "vue": "3.3.4",
+    "vue-dompurify-html": "^4.1.4",
     "vue-i18n": "9.2.2",
     "vue-router": "^4.2.1",
     "vue-types": "^5.0.3",

+ 11 - 8
src/api/mall/product/spu.ts

@@ -7,8 +7,7 @@ export interface Property {
   valueName?: string // 属性值名称
 }
 
-// TODO puhui999:是不是直接叫 Sku 更简洁一点哈。type 待后面,总感觉有个类型?
-export interface SkuType {
+export interface Sku {
   id?: number // 商品 SKU 编号
   spuId?: number // SPU 编号
   properties?: Property[] // 属性数组
@@ -25,8 +24,7 @@ export interface SkuType {
   salesCount?: number // 商品销量
 }
 
-// TODO puhui999:是不是直接叫 Spu 更简洁一点哈。type 待后面,总感觉有个类型?
-export interface SpuType {
+export interface Spu {
   id?: number
   name?: string // 商品名称
   categoryId?: number | null // 商品分类
@@ -39,9 +37,9 @@ export interface SpuType {
   brandId?: number | null // 商品品牌编号
   specType?: boolean // 商品规格
   subCommissionType?: boolean // 分销类型
-  skus: SkuType[] // sku数组
+  skus?: Sku[] // sku数组
   description?: string // 商品详情
-  sort?: string // 商品排序
+  sort?: number // 商品排序
   giveIntegral?: number // 赠送积分
   virtualSalesCount?: number // 虚拟销量
   recommendHot?: boolean // 是否热卖
@@ -62,12 +60,12 @@ export const getTabsCount = () => {
 }
 
 // 创建商品 Spu
-export const createSpu = (data: SpuType) => {
+export const createSpu = (data: Spu) => {
   return request.post({ url: '/product/spu/create', data })
 }
 
 // 更新商品 Spu
-export const updateSpu = (data: SpuType) => {
+export const updateSpu = (data: Spu) => {
   return request.put({ url: '/product/spu/update', data })
 }
 
@@ -90,3 +88,8 @@ export const deleteSpu = (id: number) => {
 export const exportSpu = async (params) => {
   return await request.download({ url: '/product/spu/export', params })
 }
+
+// 获得商品 SPU 精简列表
+export const getSpuSimpleList = async () => {
+  return request.get({ url: '/product/spu/get-simple-list' })
+}

+ 18 - 0
src/api/mall/promotion/coupon.ts

@@ -0,0 +1,18 @@
+import request from '@/config/axios'
+
+// TODO @dhb52:vo 缺少
+
+// 删除优惠劵
+export const deleteCoupon = async (id: number) => {
+  return request.delete({
+    url: `/promotion/coupon/delete?id=${id}`
+  })
+}
+
+// 获得优惠劵分页
+export const getCouponPage = async (params: PageParam) => {
+  return request.get({
+    url: '/promotion/coupon/page',
+    params: params
+  })
+}

+ 83 - 0
src/api/mall/promotion/couponTemplate.ts

@@ -0,0 +1,83 @@
+import request from '@/config/axios'
+
+export interface CouponTemplateVO {
+  id: number
+  name: string
+  status: number
+  totalCount: number
+  takeLimitCount: number
+  takeType: number
+  usePrice: number
+  productScope: number
+  productSpuIds: string
+  validityType: number
+  validStartTime: Date
+  validEndTime: Date
+  fixedStartTerm: number
+  fixedEndTerm: number
+  discountType: number
+  discountPercent: number
+  discountPrice: number
+  discountLimitPrice: number
+  takeCount: number
+  useCount: number
+}
+
+// 创建优惠劵模板
+export function createCouponTemplate(data: CouponTemplateVO) {
+  return request.post({
+    url: '/promotion/coupon-template/create',
+    data: data
+  })
+}
+
+// 更新优惠劵模板
+export function updateCouponTemplate(data: CouponTemplateVO) {
+  return request.put({
+    url: '/promotion/coupon-template/update',
+    data: data
+  })
+}
+
+// 更新优惠劵模板的状态
+export function updateCouponTemplateStatus(id: number, status: [0, 1]) {
+  const data = {
+    id,
+    status
+  }
+  return request.put({
+    url: '/promotion/coupon-template/update-status',
+    data: data
+  })
+}
+
+// 删除优惠劵模板
+export function deleteCouponTemplate(id: number) {
+  return request.delete({
+    url: '/promotion/coupon-template/delete?id=' + id
+  })
+}
+
+// 获得优惠劵模板
+export function getCouponTemplate(id: number) {
+  return request.get({
+    url: '/promotion/coupon-template/get?id=' + id
+  })
+}
+
+// 获得优惠劵模板分页
+export function getCouponTemplatePage(params: PageParam) {
+  return request.get({
+    url: '/promotion/coupon-template/page',
+    params: params
+  })
+}
+
+// 导出优惠劵模板 Excel
+export function exportCouponTemplateExcel(params: PageParam) {
+  return request.get({
+    url: '/promotion/coupon-template/export-excel',
+    params: params,
+    responseType: 'blob'
+  })
+}

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

@@ -33,6 +33,11 @@ export const getDeliveryExpressTemplate = async (id: number) => {
   return await request.get({ url: '/trade/delivery/express-template/get?id=' + id })
 }
 
+// 查询快递运费模板详情
+export const getSimpleTemplateList = async () => {
+  return await request.get({ url: '/trade/delivery/express-template/list-all-simple' })
+}
+
 // 新增快递运费模板
 export const createDeliveryExpressTemplate = async (data: DeliveryExpressTemplateVO) => {
   return await request.post({ url: '/trade/delivery/express-template/create', data })
@@ -47,8 +52,3 @@ export const updateDeliveryExpressTemplate = async (data: DeliveryExpressTemplat
 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 })
-}

+ 46 - 0
src/api/mall/trade/delivery/pickUpStore/index.ts

@@ -0,0 +1,46 @@
+import request from '@/config/axios'
+
+export interface DeliveryPickUpStoreVO {
+  id: number
+  name: string
+  introduction: string
+  phone: string
+  areaId: number
+  detailAddress: string
+  logo: string
+  openingTime: string
+  closingTime: string
+  latitude: number
+  longitude: number
+  status: number
+}
+
+// 查询自提门店列表
+export const getDeliveryPickUpStorePage = async (params: DeliveryPickUpStorePageReqVO) => {
+  return await request.get({ url: '/trade/delivery/pick-up-store/page', params })
+}
+
+// 查询自提门店详情
+export const getDeliveryPickUpStore = async (id: number) => {
+  return await request.get({ url: '/trade/delivery/pick-up-store/get?id=' + id })
+}
+
+// 新增自提门店
+export const createDeliveryPickUpStore = async (data: DeliveryPickUpStoreVO) => {
+  return await request.post({ url: '/trade/delivery/pick-up-store/create', data })
+}
+
+// 修改自提门店
+export const updateDeliveryPickUpStore = async (data: DeliveryPickUpStoreVO) => {
+  return await request.put({ url: '/trade/delivery/pick-up-store/update', data })
+}
+
+// 删除自提门店
+export const deleteDeliveryPickUpStore = async (id: number) => {
+  return await request.delete({ url: '/trade/delivery/pick-up-store/delete?id=' + id })
+}
+
+// 导出自提门店 Excel
+export const exportDeliveryPickUpStoreApi = async (params) => {
+  return await request.download({ url: '/trade/delivery/pick-up-store/export-excel', params })
+}

+ 7 - 7
src/components/Form/src/Form.vue

@@ -1,16 +1,16 @@
 <script lang="tsx">
-import { PropType, defineComponent, ref, computed, unref, watch, onMounted } from 'vue'
-import { ElForm, ElFormItem, ElRow, ElCol, ElTooltip } from 'element-plus'
+import { computed, defineComponent, onMounted, PropType, ref, unref, watch } from 'vue'
+import { ElCol, ElForm, ElFormItem, ElRow, ElTooltip } from 'element-plus'
 import { componentMap } from './componentMap'
 import { propTypes } from '@/utils/propTypes'
 import { getSlot } from '@/utils/tsxHelper'
 import {
-  setTextPlaceholder,
-  setGridProp,
+  initModel,
   setComponentProps,
+  setFormItemSlots,
+  setGridProp,
   setItemComponentSlots,
-  initModel,
-  setFormItemSlots
+  setTextPlaceholder
 } from './helper'
 import { useRenderSelect } from './components/useRenderSelect'
 import { useRenderRadio } from './components/useRenderRadio'
@@ -196,7 +196,7 @@ export default defineComponent({
               <span>{item.label}</span>
               <ElTooltip placement="right" raw-content>
                 {{
-                  content: () => <span v-html={item.labelMessage}></span>,
+                  content: () => <span v-dompurify-html={item.labelMessage}></span>,
                   default: () => (
                     <Icon
                       icon="ep:warning"

+ 4 - 1
src/main.ts

@@ -38,9 +38,10 @@ import App from './App.vue'
 import './permission'
 
 import '@/plugins/tongji' // 百度统计
-
 import Logger from '@/utils/Logger'
 
+import VueDOMPurifyHTML from 'vue-dompurify-html' // 解决v-html 的安全隐患
+
 // 创建实例
 const setupAll = async () => {
   const app = createApp(App)
@@ -61,6 +62,8 @@ const setupAll = async () => {
 
   await router.isReady()
 
+  app.use(VueDOMPurifyHTML)
+
   app.mount('#app')
 }
 

+ 13 - 0
src/router/modules/remaining.ts

@@ -395,6 +395,19 @@ const remainingRouter: AppRouteRecordRaw[] = [
           title: '编辑商品',
           activeMenu: '/product/product-spu'
         }
+      },
+      {
+        path: 'productSpuDetail/:spuId(\\d+)',
+        component: () => import('@/views/mall/product/spu/addForm.vue'),
+        name: 'productSpuDetail',
+        meta: {
+          noCache: true,
+          hidden: true,
+          canTo: true,
+          icon: 'ep:view',
+          title: '商品详情',
+          activeMenu: '/product/product-spu'
+        }
       }
     ]
   }

+ 57 - 1
src/utils/constants.ts

@@ -222,7 +222,7 @@ export const PayRefundStatusEnum = {
 }
 
 /**
- * 商品SPU枚举类
+ * 商品 SPU 状态
  */
 export const ProductSpuStatusEnum = {
   RECYCLE: {
@@ -238,3 +238,59 @@ export const ProductSpuStatusEnum = {
     name: '上架'
   }
 }
+
+/**
+ * 优惠劵模板的有限期类型的枚举
+ */
+export const CouponTemplateValidityTypeEnum = {
+  DATE: {
+    type: 1,
+    name: '固定日期可用'
+  },
+  TERM: {
+    type: 2,
+    name: '领取之后可用'
+  }
+}
+
+/**
+ * 营销的商品范围枚举
+ */
+export const PromotionProductScopeEnum = {
+  ALL: {
+    scope: 1,
+    name: '全部商品参与'
+  },
+  SPU: {
+    scope: 2,
+    name: '指定商品参与'
+  }
+}
+
+/**
+ * 营销的条件类型枚举
+ */
+export const PromotionConditionTypeEnum = {
+  PRICE: {
+    type: 10,
+    name: '满 N 元'
+  },
+  COUNT: {
+    type: 20,
+    name: '满 N 件'
+  }
+}
+
+/**
+ * 优惠类型枚举
+ */
+export const PromotionDiscountTypeEnum = {
+  PRICE: {
+    type: 1,
+    name: '满减'
+  },
+  PERCENT: {
+    type: 2,
+    name: '折扣'
+  }
+}

+ 8 - 6
src/utils/dict.ts

@@ -145,12 +145,14 @@ export enum DICT_TYPE {
   MP_AUTO_REPLY_REQUEST_MATCH = 'mp_auto_reply_request_match', // 自动回复请求匹配类型
   MP_MESSAGE_TYPE = 'mp_message_type', // 消息类型
 
-  // ========== MALL 模块 ==========
-  PRODUCT_UNIT = 'product_unit', // 商品单位
-  PRODUCT_SPU_STATUS = 'product_spu_status', //商品状态
-
-  // ========== MALL 交易模块 ==========
-  EXPRESS_CHARGE_MODE = 'trade_delivery_express_charge_mode', //快递的计费方式
+  // ========== MALL - PROMOTION 模块 ==========
+  PROMOTION_DISCOUNT_TYPE = 'promotion_discount_type', // 优惠类型
+  PROMOTION_PRODUCT_SCOPE = 'promotion_product_scope', // 营销的商品范围
+  PROMOTION_COUPON_TEMPLATE_VALIDITY_TYPE = 'promotion_coupon_template_validity_type', // 优惠劵模板的有限期类型
+  PROMOTION_COUPON_STATUS = 'promotion_coupon_status', // 优惠劵的状态
+  PROMOTION_COUPON_TAKE_TYPE = 'promotion_coupon_take_type', // 优惠劵的领取方式
+  PROMOTION_ACTIVITY_STATUS = 'promotion_activity_status', // 优惠活动的状态
+  PROMOTION_CONDITION_TYPE = 'promotion_condition_type', // 营销的条件类型枚举
 
   //===add by 20230530====
   // ========== MALL - ORDER 模块 ==========

+ 95 - 0
src/utils/tree.ts

@@ -3,6 +3,7 @@ interface TreeHelperConfig {
   children: string
   pid: string
 }
+
 const DEFAULT_CONFIG: TreeHelperConfig = {
   id: 'id',
   children: 'children',
@@ -133,6 +134,7 @@ export const filter = <T = any>(
 ): T[] => {
   config = getConfig(config)
   const children = config.children as string
+
   function listFilter(list: T[]) {
     return list
       .map((node: any) => ({ ...node }))
@@ -141,6 +143,7 @@ export const filter = <T = any>(
         return func(node) || (node[children] && node[children].length)
       })
   }
+
   return listFilter(tree)
 }
 
@@ -264,6 +267,7 @@ export const handleTree = (data: any[], id?: string, parentId?: string, children
       }
     }
   }
+
   return tree
 }
 
@@ -302,3 +306,94 @@ export const handleTree2 = (data, id, parentId, children, rootId) => {
   })
   return treeData !== '' ? treeData : data
 }
+
+/**
+ * 校验选中的节点,是否为指定 level
+ *
+ * @param tree 要操作的树结构数据
+ * @param nodeId 需要判断在什么层级的数据
+ * @param level 检查的级别, 默认检查到二级
+ * @return true 是;false 否
+ */
+export const checkSelectedNode = (tree: any[], nodeId: any, level = 2): boolean => {
+  if (typeof tree === 'undefined' || !Array.isArray(tree) || tree.length === 0) {
+    console.warn('tree must be an array')
+    return false
+  }
+
+  // 校验是否是一级节点
+  if (tree.some((item) => item.id === nodeId)) {
+    return false
+  }
+
+  // 递归计数
+  let count = 1
+
+  // 深层次校验
+  function performAThoroughValidation(arr: any[]): boolean {
+    count += 1
+    for (const item of arr) {
+      if (item.id === nodeId) {
+        return true
+      } else if (typeof item.children !== 'undefined' && item.children.length !== 0) {
+        if (performAThoroughValidation(item.children)) {
+          return true
+        }
+      }
+    }
+    return false
+  }
+
+  for (const item of tree) {
+    count = 1
+    if (performAThoroughValidation(item.children)) {
+      // 找到后对比是否是期望的层级
+      if (count >= level) {
+        return true
+      }
+    }
+  }
+
+  return false
+}
+
+/**
+ * 获取节点的完整结构
+ * @param tree 树数据
+ * @param nodeId 节点 id
+ */
+export const treeToString = (tree: any[], nodeId) => {
+  if (typeof tree === 'undefined' || !Array.isArray(tree) || tree.length === 0) {
+    console.warn('tree must be an array')
+    return ''
+  }
+  // 校验是否是一级节点
+  const node = tree.find((item) => item.id === nodeId)
+  if (typeof node !== 'undefined') {
+    return node.name
+  }
+  let str = ''
+
+  function performAThoroughValidation(arr) {
+    for (const item of arr) {
+      if (item.id === nodeId) {
+        str += `/${item.name}`
+        return true
+      } else if (typeof item.children !== 'undefined' && item.children.length !== 0) {
+        str += `/${item.name}`
+        if (performAThoroughValidation(item.children)) {
+          return true
+        }
+      }
+    }
+    return false
+  }
+
+  for (const item of tree) {
+    str = `${item.name}`
+    if (performAThoroughValidation(item.children)) {
+      break
+    }
+  }
+  return str
+}

+ 4 - 4
src/views/infra/build/index.vue

@@ -16,20 +16,20 @@
   </ContentWrap>
 
   <!-- 弹窗:表单预览 -->
-  <Dialog :title="dialogTitle" v-model="dialogVisible" max-height="600">
-    <div ref="editor" v-if="dialogVisible">
+  <Dialog v-model="dialogVisible" :title="dialogTitle" max-height="600">
+    <div v-if="dialogVisible" ref="editor">
       <el-button style="float: right" @click="copy(formData)">
         {{ t('common.copy') }}
       </el-button>
       <el-scrollbar height="580">
         <div>
-          <pre><code class="hljs" v-html="highlightedCode(formData)"></code></pre>
+          <pre><code v-dompurify-html="highlightedCode(formData)" class="hljs"></code></pre>
         </div>
       </el-scrollbar>
     </div>
   </Dialog>
 </template>
-<script setup lang="ts" name="InfraBuild">
+<script lang="ts" name="InfraBuild" setup>
 import FcDesigner from '@form-create/designer'
 import { useClipboard } from '@vueuse/core'
 import { isString } from '@/utils/is'

+ 1 - 1
src/views/infra/codegen/PreviewCode.vue

@@ -46,7 +46,7 @@
               {{ t('common.copy') }}
             </el-button>
             <div>
-              <pre><code class="hljs" v-html="highlightedCode(item)"></code></pre>
+              <pre><code v-dompurify-html="highlightedCode(item)" class="hljs"></code></pre>
             </div>
           </el-tab-pane>
         </el-tabs>

+ 20 - 11
src/views/mall/product/spu/addForm.vue

@@ -5,6 +5,7 @@
         <BasicInfoForm
           ref="basicInfoRef"
           v-model:activeName="activeName"
+          :is-detail="isDetail"
           :propFormData="formData"
         />
       </el-tab-pane>
@@ -12,6 +13,7 @@
         <DescriptionForm
           ref="descriptionRef"
           v-model:activeName="activeName"
+          :is-detail="isDetail"
           :propFormData="formData"
         />
       </el-tab-pane>
@@ -19,13 +21,16 @@
         <OtherSettingsForm
           ref="otherSettingsRef"
           v-model:activeName="activeName"
+          :is-detail="isDetail"
           :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 v-if="!isDetail" :loading="formLoading" type="primary" @click="submitForm">
+          保存
+        </el-button>
         <el-button @click="close">返回</el-button>
       </el-form-item>
     </el-form>
@@ -42,16 +47,17 @@ import { convertToInteger, formatToFraction } from '@/utils'
 const { t } = useI18n() // 国际化
 const message = useMessage() // 消息弹窗
 const { push, currentRoute } = useRouter() // 路由
-const { params } = useRoute() // 查询参数
+const { params, name } = useRoute() // 查询参数
 const { delView } = useTagsViewStore() // 视图操作
 
 const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
 const activeName = ref('basicInfo') // Tag 激活的窗口
+const isDetail = ref(false) // 是否查看详情
 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>({
+const formData = ref<ProductSpuApi.Spu>({
   name: '', // 商品名称
   categoryId: null, // 商品分类
   keyword: '', // 关键字
@@ -59,7 +65,7 @@ const formData = ref<ProductSpuApi.SpuType>({
   picUrl: '', // 商品封面图
   sliderPicUrls: [], // 商品轮播图
   introduction: '', // 商品简介
-  deliveryTemplateId: 1, // 运费模版
+  deliveryTemplateId: null, // 运费模版
   brandId: null, // 商品品牌
   specType: false, // 商品规格
   subCommissionType: false, // 分销类型
@@ -90,12 +96,15 @@ const formData = ref<ProductSpuApi.SpuType>({
 
 /** 获得详情 */
 const getDetail = async () => {
+  if ('productSpuDetail' === name) {
+    isDetail.value = true
+  }
   const id = params.spuId as number
   if (id) {
     formLoading.value = true
     try {
-      const res = (await ProductSpuApi.getSpu(id)) as ProductSpuApi.SpuType
-      res.skus.forEach((item) => {
+      const res = (await ProductSpuApi.getSpu(id)) as ProductSpuApi.Spu
+      res.skus!.forEach((item) => {
         // 回显价格分转元
         item.price = formatToFraction(item.price)
         item.marketPrice = formatToFraction(item.marketPrice)
@@ -120,9 +129,10 @@ const submitForm = async () => {
     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) => {
+    // 深拷贝一份, 这样最终 server 端不满足,不需要恢复,
+    const deepCopyFormData = cloneDeep(unref(formData.value))
+    // 兜底处理 sku 空数据
+    formData.value.skus!.forEach((sku) => {
       // 因为是空数据这里判断一下商品条码是否为空就行
       if (sku.barCode === '') {
         const index = deepCopyFormData.skus.findIndex(
@@ -150,7 +160,7 @@ const submitForm = async () => {
     })
     deepCopyFormData.sliderPicUrls = newSliderPicUrls
     // 校验都通过后提交表单
-    const data = deepCopyFormData as ProductSpuApi.SpuType
+    const data = deepCopyFormData as ProductSpuApi.Spu
     const id = params.spuId as number
     if (!id) {
       await ProductSpuApi.createSpu(data)
@@ -170,7 +180,6 @@ const close = () => {
   delView(unref(currentRoute))
   push('/product/product-spu')
 }
-
 /** 初始化 */
 onMounted(async () => {
   await getDetail()

+ 133 - 32
src/views/mall/product/spu/components/BasicInfoForm.vue

@@ -1,5 +1,12 @@
 <template>
-  <el-form ref="productSpuBasicInfoRef" :model="formData" :rules="rules" label-width="120px">
+  <!-- 情况一:添加/修改 -->
+  <el-form
+    v-if="!isDetail"
+    ref="productSpuBasicInfoRef"
+    :model="formData"
+    :rules="rules"
+    label-width="120px"
+  >
     <el-row>
       <el-col :span="12">
         <el-form-item label="商品名称" prop="name">
@@ -7,7 +14,7 @@
         </el-form-item>
       </el-col>
       <el-col :span="12">
-        <!-- TODO @puhui999:只能选根节点 -->
+        <!-- TODO @puhui999:只能选根节点 fix: 已完善-->
         <el-form-item label="商品分类" prop="categoryId">
           <el-tree-select
             v-model="formData.categoryId"
@@ -17,6 +24,7 @@
             class="w-1/1"
             node-key="id"
             placeholder="请选择商品分类"
+            @change="categoryNodeClick"
           />
         </el-form-item>
       </el-col>
@@ -60,9 +68,15 @@
       <el-col :span="12">
         <el-form-item label="运费模板" prop="deliveryTemplateId">
           <el-select v-model="formData.deliveryTemplateId" placeholder="请选择">
-            <el-option v-for="item in []" :key="item.id" :label="item.name" :value="item.id" />
+            <el-option
+              v-for="item in deliveryTemplateList"
+              :key="item.id"
+              :label="item.name"
+              :value="item.id"
+            />
           </el-select>
-          <el-button class="ml-20px">运费模板</el-button>
+          <!-- TODO 可能情况:善品录入后选择运费发现下拉选择中没有对应的模版 这里需不需要做添加运费模版后选择的功能 -->
+          <!-- <el-button class="ml-20px">运费模板</el-button>-->
         </el-form-item>
       </el-col>
       <el-col :span="12">
@@ -95,6 +109,9 @@
       </el-col>
       <!-- 多规格添加-->
       <el-col :span="24">
+        <el-form-item v-if="!formData.specType">
+          <SkuList ref="skuListRef" :prop-form-data="formData" :propertyList="propertyList" />
+        </el-form-item>
         <el-form-item v-if="formData.specType" label="商品属性">
           <el-button class="mr-15px mb-10px" @click="attributesAddFormRef.open">添加规格</el-button>
           <ProductAttributes :propertyList="propertyList" @success="generateSkus" />
@@ -107,34 +124,93 @@
             <SkuList ref="skuListRef" :prop-form-data="formData" :propertyList="propertyList" />
           </el-form-item>
         </template>
-        <el-form-item v-if="!formData.specType">
-          <SkuList :prop-form-data="formData" :propertyList="propertyList" />
-        </el-form-item>
       </el-col>
     </el-row>
   </el-form>
+
+  <!-- 情况二:详情 -->
+  <Descriptions v-if="isDetail" :data="formData" :schema="allSchemas.detailSchema">
+    <template #categoryId="{ row }"> {{ categoryString(row.categoryId) }}</template>
+    <template #brandId="{ row }">
+      {{ brandList.find((item) => item.id === row.brandId)?.name }}
+    </template>
+    <template #deliveryTemplateId="{ row }">
+      {{ deliveryTemplateList.find((item) => item.id === row.deliveryTemplateId)?.name }}
+    </template>
+    <template #specType="{ row }">
+      {{ row.specType ? '多规格' : '单规格' }}
+    </template>
+    <template #subCommissionType="{ row }">
+      {{ row.subCommissionType ? '自行设置' : '默认设置' }}
+    </template>
+    <template #picUrl="{ row }">
+      <el-image :src="row.picUrl" class="w-60px h-60px" @click="imagePreview(row.picUrl)" />
+    </template>
+    <template #sliderPicUrls="{ row }">
+      <el-image
+        v-for="(item, index) in row.sliderPicUrls"
+        :key="index"
+        :src="item.url"
+        class="w-60px h-60px mr-10px"
+        @click="imagePreview(row.sliderPicUrls)"
+      />
+    </template>
+    <template #skus>
+      <SkuList
+        ref="skuDetailListRef"
+        :is-detail="isDetail"
+        :prop-form-data="formData"
+        :propertyList="propertyList"
+      />
+    </template>
+  </Descriptions>
+
+  <!-- 商品属性添加 Form 表单 -->
+  <!-- TODO @puhui999: ProductPropertyAddForm 是不是更合适呀 -->
   <ProductAttributesAddForm ref="attributesAddFormRef" :propertyList="propertyList" />
 </template>
 <script lang="ts" name="ProductSpuBasicInfoForm" setup>
 import { PropType } from 'vue'
+import { isArray } from '@/utils/is'
 import { copyValueToTarget } from '@/utils'
 import { propTypes } from '@/utils/propTypes'
-import { defaultProps, handleTree } from '@/utils/tree'
+import { checkSelectedNode, defaultProps, handleTree, treeToString } from '@/utils/tree'
+import { createImageViewer } from '@/components/ImageViewer'
 import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
-import type { SpuType } from '@/api/mall/product/spu'
 import { UploadImg, UploadImgs } from '@/components/UploadFile'
 import { ProductAttributes, ProductAttributesAddForm, SkuList } from './index'
+import { basicInfoSchema } from './spu.data'
+import type { Spu } from '@/api/mall/product/spu'
 import * as ProductCategoryApi from '@/api/mall/product/category'
 import { getSimpleBrandList } from '@/api/mall/product/brand'
+import { getSimpleTemplateList } from '@/api/mall/trade/delivery/expressTemplate/index'
+// ====== 商品详情相关操作 ======
+const { allSchemas } = useCrudSchemas(basicInfoSchema)
+/** 商品图预览 */
+const imagePreview = (args) => {
+  const urlList = []
+  if (isArray(args)) {
+    args.forEach((item) => {
+      urlList.push(item.url)
+    })
+  } else {
+    urlList.push(args)
+  }
+  createImageViewer({
+    urlList
+  })
+}
+// ====== end ======
 
 const message = useMessage() // 消息弹窗
 
 const props = defineProps({
   propFormData: {
-    type: Object as PropType<SpuType>,
+    type: Object as PropType<Spu>,
     default: () => {}
   },
-  activeName: propTypes.string.def('')
+  activeName: propTypes.string.def(''),
+  isDetail: propTypes.bool.def(false) // 是否作为详情组件
 })
 const attributesAddFormRef = ref() // 添加商品属性表单
 const productSpuBasicInfoRef = ref() // 表单 Ref
@@ -144,15 +220,15 @@ const skuListRef = ref() // 商品属性列表Ref
 const generateSkus = (propertyList) => {
   skuListRef.value.generateTableData(propertyList)
 }
-const formData = reactive<SpuType>({
+const formData = reactive<Spu>({
   name: '', // 商品名称
   categoryId: null, // 商品分类
   keyword: '', // 关键字
-  unit: '', // 单位
+  unit: null, // 单位
   picUrl: '', // 商品封面图
   sliderPicUrls: [], // 商品轮播图
   introduction: '', // 商品简介
-  deliveryTemplateId: 1, // 运费模版
+  deliveryTemplateId: null, // 运费模版
   brandId: null, // 商品品牌
   specType: false, // 商品规格
   subCommissionType: false, // 分销类型
@@ -185,26 +261,26 @@ watch(
     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
+    if (!formData.specType) {
+      return
     }
+    //  直接拿返回的 skus 属性逆向生成出 propertyList
+    const properties = []
+    formData.skus.forEach((sku) => {
+      sku.properties.forEach(({ propertyId, propertyName, valueId, valueName }) => {
+        // 添加属性
+        if (!properties.some((item) => item.id === propertyId)) {
+          properties.push({ id: propertyId, name: propertyName, values: [] })
+        }
+        // 添加属性值
+        const index = properties.findIndex((item) => item.id === propertyId)
+        if (!properties[index].values.some((value) => value.id === valueId)) {
+          properties[index].values.push({ id: valueId, name: valueName })
+        }
+      })
+    })
+    propertyList.value = properties
   },
   {
     immediate: true
@@ -216,6 +292,11 @@ watch(
  */
 const emit = defineEmits(['update:activeName'])
 const validate = async () => {
+  // 校验 sku
+  if (!skuListRef.value.validateSku()) {
+    message.warning('商品相关价格不能低于 0.01 元!!')
+    throw new Error('商品相关价格不能低于 0.01 元!!')
+  }
   // 校验表单
   if (!productSpuBasicInfoRef) return
   return await unref(productSpuBasicInfoRef).validate((valid) => {
@@ -263,12 +344,32 @@ const onChangeSpec = () => {
 }
 
 const categoryList = ref([]) // 分类树
+/**
+ * 选择分类时触发校验
+ */
+const categoryNodeClick = () => {
+  if (!checkSelectedNode(categoryList.value, formData.categoryId)) {
+    formData.categoryId = null
+    message.warning('必须选择二级及以下节点!!')
+  }
+}
+/**
+ * 获取分类的节点的完整结构
+ *
+ * @param categoryId 分类id
+ */
+const categoryString = (categoryId) => {
+  return treeToString(categoryList.value, categoryId)
+}
 const brandList = ref([]) // 精简商品品牌列表
+const deliveryTemplateList = ref([]) // 运费模版
 onMounted(async () => {
   // 获得分类树
   const data = await ProductCategoryApi.getCategoryList({})
   categoryList.value = handleTree(data, 'id', 'parentId')
   // 获取商品品牌列表
   brandList.value = await getSimpleBrandList()
+  // 获取运费模版
+  deliveryTemplateList.value = await getSimpleTemplateList()
 })
 </script>

+ 29 - 6
src/views/mall/product/spu/components/DescriptionForm.vue

@@ -1,28 +1,51 @@
 <template>
-  <el-form ref="descriptionFormRef" :model="formData" :rules="rules" label-width="120px">
+  <!-- 情况一:添加/修改 -->
+  <el-form
+    v-if="!isDetail"
+    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>
+
+  <!-- 情况二:详情 -->
+  <Descriptions
+    v-if="isDetail"
+    :data="formData"
+    :schema="allSchemas.detailSchema"
+    class="descriptionFormDescriptions"
+  >
+    <!-- 展示 HTML 内容 -->
+    <template #description="{ row }">
+      <div v-dompurify-html="row.description" style="width: 600px"></div>
+    </template>
+  </Descriptions>
 </template>
 <script lang="ts" name="DescriptionForm" setup>
-import type { SpuType } from '@/api/mall/product/spu'
+import type { Spu } from '@/api/mall/product/spu'
 import { Editor } from '@/components/Editor'
 import { PropType } from 'vue'
 import { propTypes } from '@/utils/propTypes'
 import { copyValueToTarget } from '@/utils'
-
+import { descriptionSchema } from './spu.data'
 const message = useMessage() // 消息弹窗
+
+const { allSchemas } = useCrudSchemas(descriptionSchema)
 const props = defineProps({
   propFormData: {
-    type: Object as PropType<SpuType>,
+    type: Object as PropType<Spu>,
     default: () => {}
   },
-  activeName: propTypes.string.def('')
+  activeName: propTypes.string.def(''),
+  isDetail: propTypes.bool.def(false) // 是否作为详情组件
 })
 const descriptionFormRef = ref() // 表单Ref
-const formData = ref<SpuType>({
+const formData = ref<Spu>({
   description: '' // 商品详情
 })
 // 表单规则

+ 41 - 5
src/views/mall/product/spu/components/OtherSettingsForm.vue

@@ -1,5 +1,12 @@
 <template>
-  <el-form ref="otherSettingsFormRef" :model="formData" :rules="rules" label-width="120px">
+  <!-- 情况一:添加/修改 -->
+  <el-form
+    v-if="!isDetail"
+    ref="otherSettingsFormRef"
+    :model="formData"
+    :rules="rules"
+    label-width="120px"
+  >
     <el-row>
       <el-col :span="24">
         <el-row :gutter="20">
@@ -50,26 +57,55 @@
       </el-col>
     </el-row>
   </el-form>
+
+  <!-- 情况二:详情 -->
+  <Descriptions v-if="isDetail" :data="formData" :schema="allSchemas.detailSchema">
+    <template #recommendHot="{ row }">
+      {{ row.recommendHot ? '是' : '否' }}
+    </template>
+    <template #recommendBenefit="{ row }">
+      {{ row.recommendBenefit ? '是' : '否' }}
+    </template>
+    <template #recommendBest="{ row }">
+      {{ row.recommendBest ? '是' : '否' }}
+    </template>
+    <template #recommendNew="{ row }">
+      {{ row.recommendNew ? '是' : '否' }}
+    </template>
+    <template #recommendGood="{ row }">
+      {{ row.recommendGood ? '是' : '否' }}
+    </template>
+    <template #activityOrders>
+      <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>
+    </template>
+  </Descriptions>
 </template>
 <script lang="ts" name="OtherSettingsForm" setup>
-import type { SpuType } from '@/api/mall/product/spu'
+import type { Spu } from '@/api/mall/product/spu'
 import { PropType } from 'vue'
 import { propTypes } from '@/utils/propTypes'
 import { copyValueToTarget } from '@/utils'
+import { otherSettingsSchema } from './spu.data'
+
+const { allSchemas } = useCrudSchemas(otherSettingsSchema)
 
 const message = useMessage() // 消息弹窗
 
 const props = defineProps({
   propFormData: {
-    type: Object as PropType<SpuType>,
+    type: Object as PropType<Spu>,
     default: () => {}
   },
-  activeName: propTypes.string.def('')
+  activeName: propTypes.string.def(''),
+  isDetail: propTypes.bool.def(false) // 是否作为详情组件
 })
 
 const otherSettingsFormRef = ref() // 表单Ref
 // 表单数据
-const formData = ref<SpuType>({
+const formData = ref<Spu>({
   sort: 1, // 商品排序
   giveIntegral: 1, // 赠送积分
   virtualSalesCount: 1, // 虚拟销量

+ 1 - 2
src/views/mall/product/spu/components/ProductAttributesAddForm.vue

@@ -90,8 +90,7 @@ const submitForm = async () => {
 /** 重置表单 */
 const resetForm = () => {
   formData.value = {
-    name: '',
-    remark: ''
+    name: ''
   }
   formRef.value?.resetFields()
 }

+ 128 - 24
src/views/mall/product/spu/components/SkuList.vue

@@ -1,6 +1,8 @@
 <template>
+  <!-- 情况一:添加/修改 -->
   <el-table
-    :data="isBatch ? skuList : formData.skus"
+    v-if="!isDetail"
+    :data="isBatch ? skuList : formData!.skus"
     border
     class="tabNumWidth"
     max-height="500"
@@ -11,7 +13,7 @@
         <UploadImg v-model="row.picUrl" height="80px" width="100%" />
       </template>
     </el-table-column>
-    <template v-if="formData.specType && !isBatch">
+    <template v-if="formData!.specType && !isBatch">
       <!--  根据商品属性动态添加 -->
       <el-table-column
         v-for="(item, index) in tableHeaders"
@@ -21,8 +23,10 @@
         min-width="120"
       >
         <template #default="{ row }">
-          <!-- TODO puhui999:展示成蓝色,有点区分度哈 -->
-          {{ row.properties[index]?.valueName }}
+          <!-- TODO puhui999:展示成蓝色,有点区分度哈 fix-->
+          <span style="font-weight: bold; color: #40aaff">
+            {{ row.properties[index]?.valueName }}
+          </span>
         </template>
       </el-table-column>
     </template>
@@ -73,7 +77,7 @@
         <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">
+    <template v-if="formData!.subCommissionType">
       <el-table-column align="center" label="一级返佣(元)" min-width="168">
         <template #default="{ row }">
           <el-input-number
@@ -97,7 +101,7 @@
         </template>
       </el-table-column>
     </template>
-    <el-table-column v-if="formData.specType" align="center" fixed="right" label="操作" width="80">
+    <el-table-column v-if="formData?.specType" align="center" fixed="right" label="操作" width="80">
       <template #default="{ row }">
         <el-button v-if="isBatch" link size="small" type="primary" @click="batchAdd">
           批量添加
@@ -106,27 +110,108 @@
       </template>
     </el-table-column>
   </el-table>
+
+  <!-- 情况二:详情 -->
+  <el-table
+    v-if="isDetail"
+    :data="formData!.skus"
+    border
+    max-height="500"
+    size="small"
+    style="width: 99%"
+  >
+    <el-table-column align="center" label="图片" min-width="80">
+      <template #default="{ row }">
+        <el-image :src="row.picUrl" class="w-60px h-60px" @click="imagePreview(row.picUrl)" />
+      </template>
+    </el-table-column>
+    <template v-if="formData!.specType && !isBatch">
+      <!--  根据商品属性动态添加 -->
+      <el-table-column
+        v-for="(item, index) in tableHeaders"
+        :key="index"
+        :label="item.label"
+        align="center"
+        min-width="80"
+      >
+        <template #default="{ row }">
+          <span style="font-weight: bold; color: #40aaff">
+            {{ row.properties[index]?.valueName }}
+          </span>
+        </template>
+      </el-table-column>
+    </template>
+    <el-table-column align="center" label="商品条码" min-width="100">
+      <template #default="{ row }">
+        {{ row.barCode }}
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="销售价(元)" min-width="80">
+      <template #default="{ row }">
+        {{ row.price }}
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="市场价(元)" min-width="80">
+      <template #default="{ row }">
+        {{ row.marketPrice }}
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="成本价(元)" min-width="80">
+      <template #default="{ row }">
+        {{ row.costPrice }}
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="库存" min-width="80">
+      <template #default="{ row }">
+        {{ row.stock }}
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="重量(kg)" min-width="80">
+      <template #default="{ row }">
+        {{ row.weight }}
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="体积(m^3)" min-width="80">
+      <template #default="{ row }">
+        {{ row.volume }}
+      </template>
+    </el-table-column>
+    <template v-if="formData!.subCommissionType">
+      <el-table-column align="center" label="一级返佣(元)" min-width="80">
+        <template #default="{ row }">
+          {{ row.subCommissionFirstPrice }}
+        </template>
+      </el-table-column>
+      <el-table-column align="center" label="二级返佣(元)" min-width="80">
+        <template #default="{ row }">
+          {{ row.subCommissionSecondPrice }}
+        </template>
+      </el-table-column>
+    </template>
+  </el-table>
 </template>
 <script lang="ts" name="SkuList" setup>
-import { PropType } from 'vue'
+import { PropType, Ref } from 'vue'
 import { copyValueToTarget } from '@/utils'
 import { propTypes } from '@/utils/propTypes'
 import { UploadImg } from '@/components/UploadFile'
-import type { Property, SkuType, SpuType } from '@/api/mall/product/spu'
+import type { Property, Sku, Spu } from '@/api/mall/product/spu'
+import { createImageViewer } from '@/components/ImageViewer'
 
 const props = defineProps({
   propFormData: {
-    type: Object as PropType<SpuType>,
+    type: Object as PropType<Spu>,
     default: () => {}
   },
   propertyList: {
     type: Array,
     default: () => []
   },
-  isBatch: propTypes.bool.def(false) // 是否作为批量操作组件
+  isBatch: propTypes.bool.def(false), // 是否作为批量操作组件
+  isDetail: propTypes.bool.def(false) // 是否作为 sku 详情组件
 })
-const formData = ref<SpuType>() // 表单数据
-const skuList = ref<SkuType[]>([
+const formData: Ref<Spu | undefined> = ref<Spu>() // 表单数据
+const skuList = ref<Sku[]>([
   {
     price: 0, // 商品价格
     marketPrice: 0, // 市场价
@@ -140,24 +225,44 @@ const skuList = ref<SkuType[]>([
     subCommissionSecondPrice: 0 // 二级分销的佣金
   }
 ]) // 批量添加时的临时数据
-// TODO @puhui999:保存时,每个商品规格的表单要校验下。例如说,销售金额最低是 0.01 这种。
+
+/** 商品图预览 */
+const imagePreview = (imgUrl: string) => {
+  createImageViewer({
+    urlList: [imgUrl]
+  })
+}
 
 /** 批量添加 */
 const batchAdd = () => {
-  formData.value.skus.forEach((item) => {
+  formData.value!.skus!.forEach((item) => {
     copyValueToTarget(item, skuList.value[0])
   })
 }
 
 /** 删除 sku */
 const deleteSku = (row) => {
-  const index = formData.value.skus.findIndex(
+  const index = formData.value!.skus!.findIndex(
     // 直接把列表转成字符串比较
     (sku) => JSON.stringify(sku.properties) === JSON.stringify(row.properties)
   )
-  formData.value.skus.splice(index, 1)
+  formData.value!.skus!.splice(index, 1)
 }
 const tableHeaders = ref<{ prop: string; label: string }[]>([]) // 多属性表头
+/**
+ * 保存时,每个商品规格的表单要校验下。例如说,销售金额最低是 0.01 这种。
+ */
+const validateSku = (): boolean => {
+  const checks = ['price', 'marketPrice', 'costPrice']
+  let validate = true // 默认通过
+  for (const sku of formData.value!.skus) {
+    if (checks.some((check) => sku[check] < 0.01)) {
+      validate = false // 只要有一个不通过则直接不通过
+      break
+    }
+  }
+  return validate
+}
 
 /**
  * 将传进来的值赋值给 skuList
@@ -185,14 +290,13 @@ const generateTableData = (propertyList: any[]) => {
       valueName: v.name
     }))
   )
-  // TODO @puhui:是不是 buildSkuList,这样容易理解一点哈。item 改成 sku
-  const buildList = build(propertyValues)
+  const buildSkuList = build(propertyValues)
   // 如果回显的 sku 属性和添加的属性不一致则重置 skus 列表
   if (!validateData(propertyList)) {
     // 如果不一致则重置表数据,默认添加新的属性重新生成 sku 列表
     formData.value!.skus = []
   }
-  for (const item of buildList) {
+  for (const item of buildSkuList) {
     const row = {
       properties: Array.isArray(item) ? item : [item], // 如果只有一个属性的话返回的是一个 property 对象
       price: 0,
@@ -207,13 +311,13 @@ const generateTableData = (propertyList: any[]) => {
       subCommissionSecondPrice: 0
     }
     // 如果存在属性相同的 sku 则不做处理
-    const index = formData.value!.skus.findIndex(
+    const index = formData.value!.skus!.findIndex(
       (sku) => JSON.stringify(sku.properties) === JSON.stringify(row.properties)
     )
     if (index !== -1) {
       continue
     }
-    formData.value.skus.push(row)
+    formData.value!.skus!.push(row)
   }
 }
 
@@ -222,7 +326,7 @@ const generateTableData = (propertyList: any[]) => {
  */
 const validateData = (propertyList: any[]) => {
   const skuPropertyIds = []
-  formData.value.skus.forEach((sku) =>
+  formData.value!.skus!.forEach((sku) =>
     sku.properties
       ?.map((property) => property.propertyId)
       .forEach((propertyId) => {
@@ -263,7 +367,7 @@ watch(
   () => props.propertyList,
   (propertyList) => {
     // 如果不是多规格则结束
-    if (!formData.value.specType) {
+    if (!formData.value!.specType) {
       return
     }
     // 如果当前组件作为批量添加数据使用,则重置表数据
@@ -313,5 +417,5 @@ watch(
   }
 )
 // 暴露出生成 sku 方法,给添加属性成功时调用
-defineExpose({ generateTableData })
+defineExpose({ generateTableData, validateSku })
 </script>

+ 106 - 0
src/views/mall/product/spu/components/spu.data.ts

@@ -0,0 +1,106 @@
+import { CrudSchema } from '@/hooks/web/useCrudSchemas'
+
+// TODO @puhui999:如果只要 detail,可以不用 CrudSchema,只要描述的 Schema
+export const basicInfoSchema = reactive<CrudSchema[]>([
+  {
+    label: '商品名称',
+    field: 'name'
+  },
+  {
+    label: '关键字',
+    field: 'keyword'
+  },
+  {
+    label: '商品简介',
+    field: 'introduction'
+  },
+  {
+    label: '商品分类',
+    field: 'categoryId'
+  },
+  {
+    label: '商品品牌',
+    field: 'brandId'
+  },
+  {
+    label: '商品封面图',
+    field: 'picUrl'
+  },
+  {
+    label: '商品轮播图',
+    field: 'sliderPicUrls'
+  },
+  {
+    label: '商品视频',
+    field: 'videoUrl'
+  },
+  {
+    label: '单位',
+    field: 'unit',
+    dictType: DICT_TYPE.PRODUCT_UNIT
+  },
+  {
+    label: '规格类型',
+    field: 'specType'
+  },
+  {
+    label: '分销类型',
+    field: 'subCommissionType'
+  },
+  {
+    label: '物流模版',
+    field: 'deliveryTemplateId'
+  },
+  {
+    label: '商品属性列表',
+    field: 'skus'
+  }
+])
+export const descriptionSchema = reactive<CrudSchema[]>([
+  {
+    label: '商品详情',
+    field: 'description'
+  }
+])
+export const otherSettingsSchema = reactive<CrudSchema[]>([
+  {
+    label: '商品排序',
+    field: 'sort'
+  },
+  {
+    label: '赠送积分',
+    field: 'giveIntegral'
+  },
+  {
+    label: '虚拟销量',
+    field: 'virtualSalesCount'
+  },
+  {
+    label: '是否热卖推荐',
+    field: 'recommendHot'
+  },
+  {
+    label: '是否优惠推荐',
+    field: 'recommendBenefit'
+  },
+  {
+    label: '是否精品推荐',
+    field: 'recommendBest'
+  },
+  {
+    label: '是否新品推荐',
+    field: 'recommendNew'
+  },
+  {
+    label: '是否优品推荐',
+    field: 'recommendGood'
+  },
+  {
+    label: '赠送的优惠劵',
+    field: 'giveCouponTemplateIds'
+  },
+  {
+    label: '活动显示排序',
+    field: 'activityOrders'
+  }
+])

+ 76 - 26
src/views/mall/product/spu/index.vue

@@ -8,18 +8,16 @@
       class="-mb-15px"
       label-width="68px"
     >
-      <!-- TODO @puhui999:品牌应该是数据下拉哈 -->
-      <el-form-item label="品牌名称" prop="name">
+      <el-form-item label="商品名称" prop="name">
         <el-input
           v-model="queryParams.name"
           class="!w-240px"
           clearable
-          placeholder="请输入品名称"
+          placeholder="请输入品名称"
           @keyup.enter="handleQuery"
         />
       </el-form-item>
-      <!--  TODO 分类只能选择二级分类目前还没做,还是先以联调通顺为主 -->
-      <!-- TODO puhui999:我们要不改成支持选择一级。如果选择一级,后端要递归查询下子分类,然后去 in? -->
+      <!--  TODO 分类只能选择二级分类目前还没做,还是先以联调通顺为主 fixL: 已完善 -->
       <el-form-item label="商品分类" prop="categoryId">
         <el-tree-select
           v-model="queryParams.categoryId"
@@ -29,6 +27,7 @@
           class="w-1/1"
           node-key="id"
           placeholder="请选择商品分类"
+          @change="nodeClick"
         />
       </el-form-item>
       <el-form-item label="创建时间" prop="createTime">
@@ -80,31 +79,60 @@
       />
     </el-tabs>
     <el-table v-loading="loading" :data="list">
-      <!-- TODO puhui:这几个属性哈,一行三个
+      <!-- TODO puhui:这几个属性哈,一行三个 fix
       商品分类:服装鞋包/箱包
 商品市场价格:100.00
 成本价:0.00
 收藏:5
-虚拟销量:999   -->
+虚拟销量: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 class="demo-table-expand" label-position="left">
+            <el-row>
+              <el-col :span="24">
+                <el-row>
+                  <el-col :span="8">
+                    <el-form-item label="商品分类:">
+                      <span>{{ categoryString(row.categoryId) }}</span>
+                    </el-form-item>
+                  </el-col>
+                  <el-col :span="8">
+                    <el-form-item label="市场价:">
+                      <span>{{ formatToFraction(row.marketPrice) }}</span>
+                    </el-form-item>
+                  </el-col>
+                  <el-col :span="8">
+                    <el-form-item label="成本价:">
+                      <span>{{ formatToFraction(row.costPrice) }}</span>
+                    </el-form-item>
+                  </el-col>
+                </el-row>
+              </el-col>
+            </el-row>
+            <el-row>
+              <el-col :span="24">
+                <el-row>
+                  <el-col :span="8">
+                    <el-form-item label="收藏:">
+                      <!-- TODO 没有这个属性,暂时写死 5 个 -->
+                      <span>5</span>
+                    </el-form-item>
+                  </el-col>
+                  <el-col :span="8">
+                    <el-form-item label="虚拟销量:">
+                      <span>{{ row.virtualSalesCount }}</span>
+                    </el-form-item>
+                  </el-col>
+                </el-row>
+              </el-col>
+            </el-row>
           </el-form>
         </template>
       </el-table-column>
       <el-table-column key="id" align="center" label="商品编号" prop="id" />
       <el-table-column label="商品图" min-width="80">
         <template #default="{ row }">
-          <el-image :src="row.picUrl" @click="imagePreview(row.picUrl)" class="w-30px h-30px" />
+          <el-image :src="row.picUrl" class="w-30px h-30px" @click="imagePreview(row.picUrl)" />
         </template>
       </el-table-column>
       <el-table-column :show-overflow-tooltip="true" label="商品名称" min-width="300" prop="name" />
@@ -143,8 +171,12 @@
       </el-table-column>
       <el-table-column align="center" fixed="right" label="操作" min-width="200">
         <template #default="{ row }">
-          <!-- TODO @puhui999:【详情】,可以后面点做哈 -->
-          <el-button v-hasPermi="['product:spu:update']" link type="primary" @click="openDetail">
+          <el-button
+            v-hasPermi="['product:spu:update']"
+            link
+            type="primary"
+            @click="openDetail(row.id)"
+          >
             详情
           </el-button>
           <template v-if="queryParams.tabType === 4">
@@ -202,7 +234,7 @@ import { TabsPaneContext } from 'element-plus'
 import { cloneDeep } from 'lodash-es'
 import { createImageViewer } from '@/components/ImageViewer'
 import { dateFormatter } from '@/utils/formatTime'
-import { defaultProps, handleTree } from '@/utils/tree'
+import { checkSelectedNode, defaultProps, handleTree, treeToString } from '@/utils/tree'
 import { ProductSpuStatusEnum } from '@/utils/constants'
 import { formatToFraction } from '@/utils'
 import download from '@/utils/download'
@@ -256,12 +288,14 @@ const getTabsCount = async () => {
 const queryParams = ref({
   pageNo: 1,
   pageSize: 10,
-  tabType: 0
+  tabType: 0,
+  name: '',
+  categoryId: null
 }) // 查询参数
 const queryFormRef = ref() // 搜索的表单Ref
 
 const handleTabClick = (tab: TabsPaneContext) => {
-  queryParams.value.tabType = tab.paneName
+  queryParams.value.tabType = tab.paneName as number
   getList()
 }
 
@@ -372,8 +406,8 @@ const openForm = (id?: number) => {
 /**
  * 查看商品详情
  */
-const openDetail = () => {
-  message.alert('查看详情未完善!!!')
+const openDetail = (id?: number) => {
+  push('/product/productSpuDetail/' + id)
 }
 
 /** 导出按钮操作 */
@@ -391,7 +425,7 @@ const handleExport = async () => {
   }
 }
 
-// 监听路由变化更新列表 TODO @puhui999:这个是必须加的么?fix: 因为编辑表单是以路由的方式打开,保存表单后列表不刷新
+// 监听路由变化更新列表,解决商品保存后列表不刷新的问题。
 watch(
   () => currentRoute.value,
   () => {
@@ -400,6 +434,22 @@ watch(
 )
 
 const categoryList = ref() // 分类树
+/**
+ * 获取分类的节点的完整结构
+ * @param categoryId 分类id
+ */
+const categoryString = (categoryId) => {
+  return treeToString(categoryList.value, categoryId)
+}
+/**
+ * 校验所选是否为二级及以下节点
+ */
+const nodeClick = () => {
+  if (!checkSelectedNode(categoryList.value, queryParams.value.categoryId)) {
+    queryParams.value.categoryId = null
+    message.warning('必须选择二级及以下节点!!')
+  }
+}
 /** 初始化 **/
 onMounted(async () => {
   await getTabsCount()

+ 200 - 0
src/views/mall/promotion/coupon/index.vue

@@ -0,0 +1,200 @@
+<template>
+  <doc-alert title="功能开启" url="https://doc.iocoder.cn/mall/build/" />
+
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form :model="queryParams" ref="queryFormRef" :inline="true" label-width="68px">
+      <el-form-item label="会员昵称" prop="nickname">
+        <el-input
+          v-model="queryParams.nickname"
+          placeholder="请输入会员昵称"
+          clearable
+          @keyup="handleQuery"
+        />
+      </el-form-item>
+      <el-form-item label="创建时间" prop="createTime">
+        <el-date-picker
+          v-model="queryParams.createTime"
+          style="width: 240px"
+          type="datetimerange"
+          value-format="YYYY-MM-DD HH:mm:ss"
+          range-separator="-"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          :default-time="[new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 2, 1, 23, 59, 59)]"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" @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-form-item>
+    </el-form>
+
+    <!-- 操作工具栏 -->
+    <!-- <el-row :gutter="10" class="mb8">
+      <right-toolbar v-model:showSearch="showSearch" @queryTable="getList" />
+    </el-row> -->
+  </ContentWrap>
+
+  <ContentWrap>
+    <!-- Tab 选项:真正的内容在 Lab -->
+    <el-tabs v-model="activeTab" type="card" @tab-change="onTabChange">
+      <el-tab-pane
+        v-for="tab in statusTabs"
+        :key="tab.value"
+        :label="tab.label"
+        :name="tab.value"
+      />
+    </el-tabs>
+
+    <!-- 列表 -->
+    <el-table v-loading="loading" :data="list">
+      <el-table-column label="会员信息" align="center" prop="nickname" />
+      <!-- TODO 芋艿:以后支持头像,支持跳转 -->
+      <el-table-column label="优惠劵" align="center" prop="name" />
+      <el-table-column label="优惠券类型" align="center" prop="discountType">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.PROMOTION_DISCOUNT_TYPE" :value="scope.row.discountType" />
+        </template>
+      </el-table-column>
+      <el-table-column label="领取方式" align="center" prop="takeType">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.PROMOTION_COUPON_TAKE_TYPE" :value="scope.row.takeType" />
+        </template>
+      </el-table-column>
+      <el-table-column label="状态" align="center" prop="status">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.PROMOTION_COUPON_STATUS" :value="scope.row.status" />
+        </template>
+      </el-table-column>
+      <el-table-column
+        label="领取时间"
+        align="center"
+        prop="createTime"
+        :formatter="dateFormatter"
+        width="180"
+      />
+      <el-table-column
+        label="使用时间"
+        align="center"
+        prop="useTime"
+        :formatter="dateFormatter"
+        width="180"
+      />
+      <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
+        <template #default="scope">
+          <el-button
+            size="small"
+            type="primary"
+            link
+            @click="handleDelete(scope.row)"
+            v-hasPermi="['promotion:coupon:delete']"
+            ><Icon icon="ep:delete" :size="12" class="mr-1px" />回收</el-button
+          >
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <!-- 分页组件 -->
+    <pagination
+      v-show="total > 0"
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+</template>
+
+<script setup lang="ts" name="PromotionCoupon">
+import { deleteCoupon, getCouponPage } from '@/api/mall/promotion/coupon'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import { FormInstance } from 'element-plus'
+
+// 消息弹窗
+const message = useMessage()
+
+// 遮罩层
+const loading = ref(true)
+// 总条数
+const total = ref(0)
+// 优惠劵列表
+const list = ref([])
+// 查询参数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  createTime: [],
+  status: undefined
+})
+// Tab 筛选
+const activeTab = ref('all')
+
+const statusTabs = reactive([
+  {
+    label: '全部',
+    value: 'all'
+  }
+])
+
+const queryFormRef = ref<FormInstance | null>(null)
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  // 执行查询
+  try {
+    const data = await getCouponPage(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 handleDelete = async (row) => {
+  const id = row.id
+
+  try {
+    await message.confirm(
+      '回收将会收回会员领取的待使用的优惠券,已使用的将无法回收,确定要回收所选优惠券吗?'
+    )
+    await deleteCoupon(id)
+    getList()
+    message.notifySuccess('回收成功')
+  } catch {}
+}
+
+/** tab 切换 */
+const onTabChange = (tabName) => {
+  queryParams.status = tabName === 'all' ? undefined : tabName
+  getList()
+}
+
+onMounted(() => {
+  getList()
+  // 设置 statuses 过滤
+  for (const dict of getIntDictOptions(DICT_TYPE.PROMOTION_COUPON_STATUS)) {
+    statusTabs.push({
+      label: dict.label,
+      value: dict.value as string
+    })
+  }
+})
+</script>

+ 614 - 0
src/views/mall/promotion/couponTemplate/index.vue

@@ -0,0 +1,614 @@
+<template>
+  <doc-alert title="功能开启" url="https://doc.iocoder.cn/mall/build/" />
+
+  <!-- 搜索工作栏 -->
+  <ContentWrap>
+    <el-form
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      v-show="showSearch"
+      label-width="82px"
+    >
+      <el-form-item label="优惠券名称" prop="name">
+        <el-input
+          v-model="queryParams.name"
+          placeholder="请输入优惠劵名"
+          clearable
+          @keyup="handleQuery"
+        />
+      </el-form-item>
+      <el-form-item label="优惠券类型" prop="discountType">
+        <el-select v-model="queryParams.discountType" placeholder="请选择优惠券类型" clearable>
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.PROMOTION_DISCOUNT_TYPE)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="优惠券状态" prop="status">
+        <el-select v-model="queryParams.status" placeholder="请选择优惠券状态" clearable>
+          <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"
+          style="width: 240px"
+          type="datetimerange"
+          value-format="YYYY-MM-DD HH:mm:ss"
+          range-separator="-"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          :default-time="[new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 2, 1, 23, 59, 59)]"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" @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-form-item>
+    </el-form>
+
+    <!-- 操作工具栏 -->
+    <el-row :gutter="10" class="mb8">
+      <el-col :span="1.5">
+        <el-button
+          type="primary"
+          plain
+          @click="handleAdd"
+          v-hasPermi="['promotion:coupon-template:create']"
+        >
+          <Icon icon="ep:plus" class="mr-5px" />新增
+        </el-button>
+        <el-button
+          type="info"
+          plain
+          @click="$router.push('/promotion/coupon')"
+          v-hasPermi="['promotion:coupon:query']"
+        >
+          <Icon icon="ep:operation" class="mr-5px" />会员优惠劵
+        </el-button>
+      </el-col>
+      <!-- <right-toolbar v-model:showSearch="showSearch" @query-table="getList" /> -->
+    </el-row>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list">
+      <el-table-column label="优惠券名称" align="center" prop="name" />
+      <el-table-column label="优惠券类型" align="center" prop="discountType">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.PROMOTION_DISCOUNT_TYPE" :value="scope.row.discountType" />
+        </template>
+      </el-table-column>
+      <el-table-column
+        label="优惠金额 / 折扣"
+        align="center"
+        prop="discount"
+        :formatter="discountFormat"
+      />
+      <el-table-column label="发放数量" align="center" prop="totalCount" />
+      <el-table-column
+        label="剩余数量"
+        align="center"
+        prop="totalCount"
+        :formatter="(row) => row.totalCount - row.takeCount"
+      />
+      <el-table-column
+        label="领取上限"
+        align="center"
+        prop="takeLimitCount"
+        :formatter="takeLimitCountFormat"
+      />
+      <el-table-column
+        label="有效期限"
+        align="center"
+        prop="validityType"
+        width="180"
+        :formatter="validityTypeFormat"
+      />
+      <el-table-column label="状态" align="center" prop="status">
+        <template #default="scope">
+          <el-switch
+            v-model="scope.row.status"
+            :active-value="0"
+            :inactive-value="1"
+            @change="handleStatusChange(scope.row)"
+          />
+        </template>
+      </el-table-column>
+      <el-table-column
+        label="创建时间"
+        align="center"
+        prop="createTime"
+        :formatter="dateFormatter"
+        width="180"
+      />
+      <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
+        <template #default="scope">
+          <el-button
+            size="small"
+            type="primary"
+            link
+            @click="handleUpdate(scope.row)"
+            v-hasPermi="['promotion:coupon-template:update']"
+          >
+            <Icon icon="ep:edit" :size="12" class="mr-1px" />
+            修改
+          </el-button>
+          <el-button
+            size="small"
+            type="primary"
+            link
+            @click="handleDelete(scope.row)"
+            v-hasPermi="['promotion:coupon-template:delete']"
+          >
+            <Icon icon="ep:delete" :size="12" class="mr-1px" />
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+  </ContentWrap>
+
+  <!-- 分页组件 -->
+  <pagination
+    v-show="total > 0"
+    :total="total"
+    v-model:page="queryParams.pageNo"
+    v-model:limit="queryParams.pageSize"
+    @pagination="getList"
+  />
+
+  <!-- 对话框(添加 / 修改) -->
+  <el-dialog :title="title" v-model="open" width="600px" append-to-body>
+    <el-form ref="formRef" :model="form" :rules="rules" label-width="140px">
+      <el-form-item label="优惠券名称" prop="name">
+        <el-input v-model="form.name" placeholder="请输入优惠券名称" />
+      </el-form-item>
+      <el-form-item label="优惠券类型" prop="discountType">
+        <el-radio-group v-model="form.discountType">
+          <el-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.PROMOTION_DISCOUNT_TYPE)"
+            :key="dict.value"
+            :label="parseInt(dict.value)"
+            >{{ dict.label }}</el-radio
+          >
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item
+        v-if="form.discountType === PromotionDiscountTypeEnum.PRICE.type"
+        label="优惠券面额"
+        prop="discountPrice"
+      >
+        <el-input-number
+          v-model="form.discountPrice"
+          placeholder="请输入优惠金额,单位:元"
+          style="width: 400px"
+          :precision="2"
+          :min="0"
+        />
+        元
+      </el-form-item>
+      <el-form-item
+        v-if="form.discountType === PromotionDiscountTypeEnum.PERCENT.type"
+        label="优惠券折扣"
+        prop="discountPercent"
+      >
+        <el-input-number
+          v-model="form.discountPercent"
+          placeholder="优惠券折扣不能小于 1 折,且不可大于 9.9 折"
+          style="width: 400px"
+          :precision="1"
+          :min="1"
+          :max="9.9"
+        />
+        折
+      </el-form-item>
+      <el-form-item
+        v-if="form.discountType === PromotionDiscountTypeEnum.PERCENT.type"
+        label="最多优惠"
+        prop="discountLimitPrice"
+      >
+        <el-input-number
+          v-model="form.discountLimitPrice"
+          placeholder="请输入最多优惠"
+          style="width: 400px"
+          :precision="2"
+          :min="0"
+        />
+        元
+      </el-form-item>
+      <el-form-item label="满多少元可以使用" prop="usePrice">
+        <el-input-number
+          v-model="form.usePrice"
+          placeholder="无门槛请设为 0"
+          style="width: 400px"
+          :precision="2"
+          :min="0"
+        />
+        元
+      </el-form-item>
+      <el-form-item label="领取方式" prop="takeType">
+        <el-radio-group v-model="form.takeType">
+          <el-radio :key="1" :label="1">直接领取</el-radio>
+          <el-radio :key="2" :label="2">指定发放</el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item v-if="form.takeType === 1" label="发放数量" prop="totalCount">
+        <el-input-number
+          v-model="form.totalCount"
+          placeholder="发放数量,没有之后不能领取或发放,-1 为不限制"
+          style="width: 400px"
+          :precision="0"
+          :min="-1"
+        />
+        张
+      </el-form-item>
+      <el-form-item v-if="form.takeType === 1" label="每人限领个数" prop="takeLimitCount">
+        <el-input-number
+          v-model="form.takeLimitCount"
+          placeholder="设置为 -1 时,可无限领取"
+          style="width: 400px"
+          :precision="0"
+          :min="-1"
+        />
+        张
+      </el-form-item>
+      <el-form-item label="有效期类型" prop="validityType">
+        <el-radio-group v-model="form.validityType">
+          <el-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.PROMOTION_COUPON_TEMPLATE_VALIDITY_TYPE)"
+            :key="dict.value"
+            :label="parseInt(dict.value)"
+            >{{ dict.label }}</el-radio
+          >
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item
+        v-if="form.validityType === CouponTemplateValidityTypeEnum.DATE.type"
+        label="固定日期"
+        prop="validTimes"
+      >
+        <el-date-picker
+          v-model="form.validTimes"
+          style="width: 240px"
+          value-format="yyyy-MM-dd HH:mm:ss"
+          type="datetimerange"
+          :default-time="[new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 2, 1, 23, 59, 59)]"
+        />
+      </el-form-item>
+      <el-form-item
+        v-if="form.validityType === CouponTemplateValidityTypeEnum.TERM.type"
+        label="领取日期"
+        prop="fixedStartTerm"
+      >
+        第
+        <el-input-number
+          v-model="form.fixedStartTerm"
+          placeholder="0 为今天生效"
+          style="width: 165px"
+          :precision="0"
+          :min="0"
+        />
+        至
+        <el-input-number
+          v-model="form.fixedEndTerm"
+          placeholder="请输入结束天数"
+          style="width: 165px"
+          :precision="0"
+          :min="0"
+        />
+        天有效
+      </el-form-item>
+      <el-form-item label="活动商品" prop="productScope">
+        <el-radio-group v-model="form.productScope">
+          <el-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.PROMOTION_PRODUCT_SCOPE)"
+            :key="dict.value"
+            :label="parseInt(dict.value)"
+            >{{ dict.label }}</el-radio
+          >
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item
+        v-if="form.productScope === PromotionProductScopeEnum.SPU.scope"
+        prop="productSpuIds"
+      >
+        <el-select
+          v-model="form.productSpuIds"
+          placeholder="请选择活动商品"
+          clearable
+          size="small"
+          multiple
+          filterable
+          style="width: 400px"
+        >
+          <el-option v-for="item in productSpus" :key="item.id" :label="item.name" :value="item.id">
+            <span style="float: left">{{ item.name }}</span>
+            <span style="float: right; color: #8492a6; font-size: 13px"
+              >¥{{ (item.minPrice / 100.0).toFixed(2) }}</span
+            >
+          </el-option>
+        </el-select>
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <div class="dialog-footer">
+        <el-button type="primary" @click="submitForm">确 定</el-button>
+        <el-button @click="cancel">取 消</el-button>
+      </div>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts" name="PromotionCouponTemplate">
+import {
+  createCouponTemplate,
+  updateCouponTemplate,
+  deleteCouponTemplate,
+  getCouponTemplate,
+  getCouponTemplatePage,
+  updateCouponTemplateStatus
+} from '@/api/mall/promotion/couponTemplate'
+import {
+  CommonStatusEnum,
+  CouponTemplateValidityTypeEnum,
+  PromotionDiscountTypeEnum,
+  PromotionProductScopeEnum
+} from '@/utils/constants'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { getSpuSimpleList } from '@/api/mall/product/spu'
+import { dateFormatter, formatDate } from '@/utils/formatTime'
+import { FormInstance } from 'element-plus'
+
+// 消息弹窗
+const message = useMessage()
+
+// 遮罩层
+const loading = ref(true)
+// 显示搜索条件
+const showSearch = ref(true)
+// 总条数
+const total = ref(0)
+// 优惠劵列表
+const list = ref([])
+// 弹出层标题
+const title = ref('')
+// 是否显示弹出层
+const open = ref(false)
+// 查询参数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: null,
+  status: null,
+  type: null,
+  createTime: []
+})
+// 表单参数
+const form = ref<any>({})
+// 表单校验
+const rules = {
+  name: [{ required: true, message: '优惠券名称不能为空', trigger: 'blur' }],
+  discountType: [{ required: true, message: '优惠券类型不能为空', trigger: 'change' }],
+  discountPrice: [{ required: true, message: '优惠券面额不能为空', trigger: 'blur' }],
+  discountPercent: [{ required: true, message: '优惠券折扣不能为空', trigger: 'blur' }],
+  discountLimitPrice: [{ required: true, message: '最多优惠不能为空', trigger: 'blur' }],
+  usePrice: [{ required: true, message: '满多少元可以使用不能为空', trigger: 'blur' }],
+  takeType: [{ required: true, message: '领取方式不能为空', trigger: 'change' }],
+  totalCount: [{ required: true, message: '发放数量不能为空', trigger: 'blur' }],
+  takeLimitCount: [{ required: true, message: '每人限领个数不能为空', trigger: 'blur' }],
+  validityType: [{ required: true, message: '有效期类型不能为空', trigger: 'change' }],
+  validTimes: [{ required: true, message: '固定日期不能为空', trigger: 'change' }],
+  fixedStartTerm: [{ required: true, message: '开始领取天数不能为空', trigger: 'blur' }],
+  fixedEndTerm: [{ required: true, message: '开始领取天数不能为空', trigger: 'blur' }],
+  productScope: [{ required: true, message: '商品范围不能为空', trigger: 'blur' }],
+  productSpuIds: [{ required: true, message: '商品范围不能为空', trigger: 'blur' }]
+}
+// 商品列表
+const productSpus = ref([])
+const queryFormRef = ref<FormInstance | null>(null)
+const formRef = ref<FormInstance | null>(null)
+
+onMounted(() => {
+  getList()
+})
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    // 执行查询
+    const data = await getCouponTemplatePage(queryParams)
+    list.value = data.list
+    total.value = data.total
+    // 查询商品列表
+    productSpus.value = await getSpuSimpleList()
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 取消按钮 */
+const cancel = () => {
+  open.value = false
+  reset()
+}
+
+/** 表单重置 */
+const reset = () => {
+  form.value = {
+    id: undefined,
+    name: undefined,
+    discountType: PromotionDiscountTypeEnum.PRICE.type,
+    discountPrice: undefined,
+    discountPercent: undefined,
+    discountLimitPrice: undefined,
+    usePrice: undefined,
+    takeType: 1,
+    totalCount: undefined,
+    takeLimitCount: undefined,
+    validityType: CouponTemplateValidityTypeEnum.DATE.type,
+    validTimes: [],
+    validStartTime: undefined,
+    validEndTime: undefined,
+    fixedStartTerm: undefined,
+    fixedEndTerm: undefined,
+    productScope: PromotionProductScopeEnum.ALL.scope,
+    productSpuIds: []
+  }
+  formRef.value?.resetFields()
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef?.value?.resetFields()
+  handleQuery()
+}
+
+/** 新增按钮操作 */
+const handleAdd = () => {
+  reset()
+  open.value = true
+  title.value = '添加优惠劵'
+}
+
+/** 修改按钮操作 */
+const handleUpdate = async (row: any) => {
+  reset()
+  const id = row.id
+  try {
+    const data = await getCouponTemplate(id)
+    form.value = {
+      ...data,
+      discountPrice: data.discountPrice !== undefined ? data.discountPrice / 100.0 : undefined,
+      discountPercent: data.discountPercent !== undefined ? data.discountPercent / 10.0 : undefined,
+      discountLimitPrice:
+        data.discountLimitPrice !== undefined ? data.discountLimitPrice / 100.0 : undefined,
+      usePrice: data.usePrice !== undefined ? data.usePrice / 100.0 : undefined,
+      validTimes: [data.validStartTime, data.validEndTime]
+    }
+    open.value = true
+    title.value = '修改优惠劵'
+  } catch {}
+}
+
+/** 提交按钮 */
+const submitForm = async () => {
+  const valid = await formRef.value?.validate()
+  if (!valid) {
+    return
+  }
+
+  // 金额相关字段的缩放
+  let data = {
+    ...form.value,
+    discountPrice:
+      form.value.discountPrice !== undefined ? form.value.discountPrice * 100 : undefined,
+    discountPercent:
+      form.value.discountPercent !== undefined ? form.value.discountPercent * 10 : undefined,
+    discountLimitPrice:
+      form.value.discountLimitPrice !== undefined ? form.value.discountLimitPrice * 100 : undefined,
+    usePrice: form.value.usePrice !== undefined ? form.value.usePrice * 100 : undefined,
+    validStartTime:
+      form.value.validTimes && form.value.validTimes.length === 2
+        ? form.value.validTimes[0]
+        : undefined,
+    validEndTime:
+      form.value.validTimes && form.value.validTimes.length === 2
+        ? form.value.validTimes[1]
+        : undefined
+  }
+
+  // 修改的提交
+  if (form.value.id != null) {
+    try {
+      await updateCouponTemplate(data)
+      message.success('修改成功')
+      open.value = false
+      getList()
+    } catch {}
+
+    return
+  }
+
+  try {
+    await createCouponTemplate(data)
+    message.success('新增成功')
+    open.value = false
+    getList()
+  } catch {}
+}
+
+/** 优惠劵模板状态修改 */
+const handleStatusChange = async (row: any) => {
+  // 此时,row 已经变成目标状态了,所以可以直接提交请求和提示
+  let text = row.status === CommonStatusEnum.ENABLE ? '启用' : '停用'
+
+  try {
+    await message.confirm('确认要"' + text + '""' + row.name + '"优惠劵吗?')
+    await updateCouponTemplateStatus(row.id, row.status)
+    message.success(text + '成功')
+  } catch {
+    // 异常时,需要将 row.status 状态重置回之前的
+    row.status =
+      row.status === CommonStatusEnum.ENABLE ? CommonStatusEnum.DISABLE : CommonStatusEnum.ENABLE
+  }
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (row: any) => {
+  const id = row.id
+  try {
+    await message.confirm('是否确认删除优惠劵编号为"' + id + '"的数据项?')
+    await deleteCouponTemplate(id)
+  } catch {}
+}
+
+// 格式化【优惠金额/折扣】
+const discountFormat = (row: any) => {
+  if (row.discountType === PromotionDiscountTypeEnum.PRICE.type) {
+    return `¥${(row.discountPrice / 100.0).toFixed(2)}`
+  }
+  if (row.discountType === PromotionDiscountTypeEnum.PERCENT.type) {
+    return `¥${(row.discountPrice / 100.0).toFixed(2)}`
+  }
+  return '未知【' + row.discountType + '】'
+}
+
+// 格式化【领取上限】
+const takeLimitCountFormat = (row: any) => {
+  if (row.takeLimitCount === -1) {
+    return '无领取限制'
+  }
+  return `${row.takeLimitCount} 张/人`
+}
+
+// 格式化【有效期限】
+const validityTypeFormat = (row: any) => {
+  if (row.validityType === CouponTemplateValidityTypeEnum.DATE.type) {
+    return `${formatDate(row.validStartTime)} 至 ${formatDate(row.validEndTime)}`
+  }
+  if (row.validityType === CouponTemplateValidityTypeEnum.TERM.type) {
+    return `领取后第 ${row.fixedStartTerm} - ${row.fixedEndTerm} 天内可用`
+  }
+  return '未知【' + row.validityType + '】'
+}
+</script>

+ 36 - 7
src/views/mall/trade/delivery/expressTemplate/ExpressTemplateForm.vue

@@ -89,7 +89,7 @@
         <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
@@ -171,7 +171,10 @@ const formRules = reactive({
   sort: [{ required: true, message: '分类排序不能为空', trigger: 'blur' }]
 })
 const formRef = ref() // 表单 Ref
-const areaCache = ref([]) //由于区域节点懒加载,已选区域节点需要缓存展示
+const areaCache = ref([]) // 由于区域节点懒加载,已选区域节点需要缓存展示
+// TODO @jason:配送的时候,只允许选择省市级别,不允许选择区;如果这样的话,是不是打开弹窗,直接把城市都请求过来;
+// TODO @jaosn:因为只有省市两级,感觉就不用特殊做全国逻辑;选择全国,就默认把子节点都选择上;另外,选择父节点,要把子节点选中哈;
+
 /** 打开弹窗 */
 const open = async (type: string, id?: number) => {
   dialogVisible.value = true
@@ -204,9 +207,9 @@ const open = async (type: string, id?: number) => {
         }
         item.freePrice = fenToYuan(item.freePrice)
       })
-      //已选的区域节点
+      // 已选的区域节点
       const areaIds = chargeAreaIds.concat(freeAreaIds)
-      //区域节点,懒加载方式。 已选节点需要缓存展示
+      // 区域节点,懒加载方式。已选节点需要缓存展示
       areaCache.value = await getAreaListByIds(areaIds.join(','))
     }
   } finally {
@@ -226,8 +229,9 @@ const submitForm = async () => {
   formLoading.value = true
   try {
     const data = formData.value as DeliveryExpressTemplateApi.DeliveryExpressTemplateVO
+    // 前端价格以元展示,提交到后端。用分计算
+    // TODO @jason:不能直接这样改,要复制出来改。不然后端操作失败,数据已经被改了
     data.templateCharge.forEach((item) => {
-      //前端价格以元展示,提交到后端。用分计算
       item.startPrice = yuanToFen(item.startPrice)
       item.extraPrice = yuanToFen(item.extraPrice)
     })
@@ -248,6 +252,7 @@ const submitForm = async () => {
     formLoading.value = false
   }
 }
+
 /** 重置表单 */
 const resetForm = () => {
   formData.value = {
@@ -269,6 +274,7 @@ const resetForm = () => {
   columnTitle.value = columnTitleMap.get(1)
   formRef.value?.resetFields()
 }
+
 /** 配送计费方法改变 */
 const changeChargeMode = (chargeMode: number) => {
   columnTitle.value = columnTitleMap.get(chargeMode)
@@ -276,6 +282,24 @@ const changeChargeMode = (chargeMode: number) => {
 const defaultArea = [{ id: 1, name: '全国', disabled: false }]
 
 /** 初始化数据 */
+// TODO @jason:是不是不用写这样一个初始化方法,columnTitleMap 直接就可以了呀
+// const columnTitleMap = {
+//   '1': {
+//     startCountTitle: '首件',
+//     extraCountTitle: '续件',
+//     freeCountTitle: '包邮件数'
+//   },
+//   '2': {
+//     startCountTitle: '首件重量(kg)',
+//     extraCountTitle: '续件重量(kg)',
+//     freeCountTitle: '包邮重量(kg)'
+//   },
+//   '3': {
+//     startCountTitle: '首件体积(m³)',
+//     extraCountTitle: '续件体积(m³)',
+//     freeCountTitle: '包邮体积(m³)'
+//   }
+// }
 const initData = async () => {
   // TODO 从服务端全量加载数据, 后面看懒加载是不是可以从前端获取数据。 目前从后端获取数据
   // formLoading.value = true
@@ -286,7 +310,7 @@ const initData = async () => {
   // } finally {
   //   formLoading.value = false
   // }
-  //表头标题和计费方式的映射
+  // 表头标题和计费方式的映射
   columnTitleMap.set(1, {
     startCountTitle: '首件',
     extraCountTitle: '续件',
@@ -320,6 +344,7 @@ const loadChargeArea = async (node, resolve) => {
     const item = data[0]
     if (areaIds.includes(item.id)) {
       // TODO 禁止选中的区域有些问题, 导致修改时候不能重新选择 不知道如何处理。 暂时注释掉 @芋艿 有空瞅瞅
+      // TODO @jason:先不做这个功能哈。
       //item.disabled = true
     }
     resolve(data)
@@ -357,10 +382,11 @@ const loadFreeArea = async (node, resolve) => {
   } else {
     const id = node.data.id
     const data = await getChildrenArea(id)
-    //已选区域需要禁止再次选择
+    // 已选区域需要禁止再次选择
     data.forEach((item) => {
       if (areaIds.includes(item.id)) {
         // TODO 禁止选中的区域有些问题, 导致修改时候不能重新选择 不知道如何处理。 暂时注释掉 @芋艿 有空瞅瞅
+        // TODO @jason:先不做这个功能哈。
         //item.disabled = true
       }
     })
@@ -378,11 +404,13 @@ const addChargeArea = () => {
     extraPrice: 1
   })
 }
+
 /** 删除计费区域 */
 const deleteChargeArea = (index) => {
   const data = formData.value
   data.templateCharge.splice(index, 1)
 }
+
 /** 添加包邮区域 */
 const addFreeArea = () => {
   const data = formData.value
@@ -392,6 +420,7 @@ const addFreeArea = () => {
     freePrice: 1
   })
 }
+
 /** 删除包邮区域 */
 const deleteFreeArea = (index) => {
   const data = formData.value

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

@@ -110,6 +110,7 @@ const queryParams = reactive({
   chargeMode: undefined
 })
 const queryFormRef = ref() // 搜索的表单
+
 /** 查询列表 */
 const getList = async () => {
   loading.value = true

+ 287 - 0
src/views/mall/trade/delivery/pickUpStore/PickUpStoreForm.vue

@@ -0,0 +1,287 @@
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible" width="60%">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="120px"
+      v-loading="formLoading"
+    >
+      <el-row>
+        <el-col :span="12">
+          <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-col>
+        <el-col :span="12">
+          <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-col>
+      </el-row>
+      <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="phone">
+            <el-input v-model="formData.phone" placeholder="请输入门店手机" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+      <el-form-item label="门店简介" prop="introduction">
+        <el-input
+          v-model="formData.introduction"
+          :rows="3"
+          type="textarea"
+          placeholder="请输入门店简介"
+        />
+      </el-form-item>
+      <el-row>
+        <el-col :span="12">
+          <el-form-item label="门店所在地区" prop="areaId">
+            <el-cascader v-model="formData.areaId" :options="areaList" :props="areaTreeProps" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="门店详细地址" prop="detailAddress">
+            <el-input v-model="formData.detailAddress" placeholder="请输入门店详细地址" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+      <el-row>
+        <el-col :span="12">
+          <el-form-item label="营业开始时间" prop="openingTime">
+            <el-time-select
+              v-model="formData.openingTime"
+              :max-time="formData.closingTime"
+              placeholder="开始时间"
+              start="08:30"
+              step="00:15"
+              end="23:30"
+            />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="营业结束时间" prop="closingTime">
+            <el-time-select
+              v-model="formData.closingTime"
+              :min-time="formData.openingTime"
+              placeholder="结束时间"
+              start="08:30"
+              step="00:15"
+              end="23:30"
+            />
+          </el-form-item>
+        </el-col>
+      </el-row>
+      <el-row>
+        <el-col :span="12">
+          <el-form-item label="经度" prop="longitude">
+            <el-input v-model="formData.longitude" placeholder="请输入门店经度" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="纬度" prop="latitude">
+            <el-input v-model="formData.latitude" placeholder="请输入门店纬度" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+      <el-form-item label="获取经纬度">
+        <el-button type="primary" @click="mapDialogVisible.value = true">获取</el-button>
+      </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>
+    <el-dialog
+      v-model="mapDialogVisible"
+      title="获取经纬度"
+      append-to-body
+      width="500px"
+      class="mapBox"
+    >
+      <iframe id="mapPage" width="100%" height="100%" frameborder="0" :src="tencentLbsUrl"></iframe>
+    </el-dialog>
+  </Dialog>
+</template>
+<script setup lang="ts">
+import * as DeliveryPickUpStoreApi from '@/api/mall/trade/delivery/pickUpStore'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { CommonStatusEnum } from '@/utils/constants'
+import { getAreaTree } from '@/api/system/area'
+import * as ConfigApi from '@/api/infra/config'
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const mapDialogVisible = ref(false) // 地图弹窗的是否展示
+const dialogTitle = ref('') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formType = ref('') // 表单的类型:create - 新增;update - 修改
+const formData = ref({
+  id: undefined,
+  name: '',
+  phone: '',
+  logo: '',
+  detailAddress: '',
+  introduction: '',
+  areaId: 0,
+  openingTime: undefined,
+  closingTime: undefined,
+  latitude: undefined,
+  longitude: undefined,
+  status: CommonStatusEnum.ENABLE
+})
+const formRules = reactive({
+  name: [{ required: true, message: '门店名称不能为空', trigger: 'blur' }],
+  logo: [{ required: true, message: '门店 logo 不能为空', trigger: 'blur' }],
+  phone: [
+    { required: true, message: '门店手机不能为空', trigger: 'blur' },
+    { pattern: /^1[3|4|5|6|7|8|9][0-9]\d{8}$/, message: '请输入正确的手机号码', trigger: 'blur' }
+  ],
+  areaId: [{ required: true, message: '门店所在区域不能为空', trigger: 'blur' }],
+  detailAddress: [{ required: true, message: '门店详细地址不能为空', trigger: 'blur' }],
+  openingTime: [{ required: true, message: '营业开始时间不能为空', trigger: 'blur' }],
+  closingTime: [{ required: true, message: '营业结束时间不能为空', trigger: 'blur' }],
+  latitude: [{ required: true, message: '纬度不能为空', trigger: 'blur' }],
+  longitude: [{ required: true, message: '经度不能为空', trigger: 'blur' }],
+  status: [{ required: true, message: '开启状态不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+const areaTreeProps = {
+  children: 'children',
+  label: 'name',
+  value: 'id',
+  emitPath: false
+}
+const areaList = ref() // 区域树
+const tencentLbsUrl = ref('') // 腾讯位置服务 url
+
+/** 打开弹窗 */
+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 DeliveryPickUpStoreApi.getDeliveryPickUpStore(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 DeliveryPickUpStoreApi.DeliveryPickUpStoreVO
+    if (formType.value === 'create') {
+      await DeliveryPickUpStoreApi.createDeliveryPickUpStore(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await DeliveryPickUpStoreApi.updateDeliveryPickUpStore(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    name: '',
+    phone: '',
+    logo: '',
+    detailAddress: '',
+    introduction: '',
+    areaId: undefined,
+    openingTime: undefined,
+    closingTime: undefined,
+    latitude: undefined,
+    longitude: undefined,
+    status: CommonStatusEnum.ENABLE
+  }
+  formRef.value?.resetFields()
+}
+
+/** 选择经纬度 */
+const selectAddress = function (loc: any): void {
+  if (loc.latlng && loc.latlng.lat) {
+    formData.value.latitude = loc.latlng.lat
+  }
+  if (loc.latlng && loc.latlng.lng) {
+    formData.value.longitude = loc.latlng.lng
+  }
+  mapDialogVisible.value = false
+}
+
+/** 初始化数据 */
+const initData = async () => {
+  formLoading.value = true
+  try {
+    const data = await getAreaTree()
+    areaList.value = data
+  } finally {
+    formLoading.value = false
+  }
+  // TODO @jason:要不创建一个 initTencentLbsMap
+  window.selectAddress = selectAddress
+  window.addEventListener(
+    'message',
+    function (event) {
+      // 接收位置信息,用户选择确认位置点后选点组件会触发该事件,回传用户的位置信息
+      let loc = event.data
+      if (loc && loc.module === 'locationPicker') {
+        // 防止其他应用也会向该页面 post 信息,需判断 module 是否为 'locationPicker'
+        window.parent.selectAddress(loc)
+      }
+    },
+    false
+  )
+  const data = await ConfigApi.getConfigKey('tencent.lbs.key')
+  let key = ''
+  if (data && data.length > 0) {
+    key = data
+  }
+  tencentLbsUrl.value = `https://apis.map.qq.com/tools/locpicker?type=1&key=${key}&referer=myapp`
+}
+
+/** 初始化 **/
+onMounted(() => {
+  initData()
+})
+</script>
+<style lang="scss">
+.mapBox .el-dialog__body {
+  height: 640px !important;
+}
+</style>

+ 201 - 0
src/views/mall/trade/delivery/pickUpStore/index.vue

@@ -0,0 +1,201 @@
+<template>
+  <!-- 搜索工作栏 -->
+  <ContentWrap>
+    <el-form class="-mb-15px" :model="queryParams" ref="queryFormRef" :inline="true">
+      <el-form-item label="门店手机" prop="phone">
+        <el-input
+          v-model="queryParams.phone"
+          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 label="门店状态" prop="status">
+        <el-select v-model="queryParams.status" placeholder="门店状态" clearable class="!w-240px">
+          <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"
+          value-format="YYYY-MM-DD HH:mm:ss"
+          type="datetimerange"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          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:pick-up-store:create']"
+        >
+          <Icon icon="ep:plus" class="mr-5px" /> 新增
+        </el-button>
+        <el-button
+          type="success"
+          plain
+          @click="handleExport"
+          :loading="exportLoading"
+          v-hasPermi="['trade:delivery:pick-up-store: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="id" />
+      <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="门店名称" prop="name" />
+      <el-table-column label="门店手机" prop="phone" />
+      <el-table-column label="门店详细地址" align="center" prop="detailAddress" />
+      <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:pick-up-store:update']"
+          >
+            编辑
+          </el-button>
+          <el-button
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+            v-hasPermi="['trade:delivery:pick-up-store:delete']"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+  </ContentWrap>
+  <!-- 表单弹窗:添加/修改 -->
+  <DeliveryPickUpStoreForm ref="formRef" @success="getList" />
+</template>
+<script setup lang="ts" name="DeliveryPickUpStore">
+import * as DeliveryPickUpStoreApi from '@/api/mall/trade/delivery/pickUpStore'
+import DeliveryPickUpStoreForm from './PickUpStoreForm.vue'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import download from '@/utils/download'
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const total = ref(0) // 列表的总页数
+const loading = ref(true) // 列表的加载中
+const exportLoading = ref(false) // 导出的加载中
+const list = ref<any[]>([]) // 列表的数据
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  status: undefined,
+  phone: undefined,
+  name: undefined,
+  createTime: []
+})
+const queryFormRef = ref() // 搜索的表单
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await DeliveryPickUpStoreApi.deleteDeliveryPickUpStore(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await DeliveryPickUpStoreApi.getDeliveryPickUpStorePage(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 handleExport = async () => {
+  try {
+    // 导出的二次确认
+    await message.exportConfirm()
+    // 发起导出
+    exportLoading.value = true
+    const data = await DeliveryPickUpStoreApi.exportDeliveryPickUpStoreApi(queryParams)
+    download.excel(data, '自提门店.xls')
+  } catch {
+  } finally {
+    exportLoading.value = false
+  }
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</script>

+ 1 - 1
src/views/system/mail/log/MailLogDetail.vue

@@ -3,7 +3,7 @@
     <Descriptions :data="detailData" :schema="allSchemas.detailSchema">
       <!-- 展示 HTML 内容 -->
       <template #templateContent="{ row }">
-        <div v-html="row.templateContent"></div>
+        <div v-dompurify-html="row.templateContent"></div>
       </template>
     </Descriptions>
   </Dialog>