Kaynağa Gözat

支付更新

Xiao_123 5 ay önce
ebeveyn
işleme
2543234ede
100 değiştirilmiş dosya ile 2072 ekleme ve 345 silme
  1. 10 0
      src/api/mall/product/history.ts
  2. 10 14
      src/api/mall/product/property.ts
  3. 2 0
      src/api/mall/product/spu.ts
  4. 7 1
      src/api/mall/promotion/combination/combinationActivity.ts
  5. 1 1
      src/api/mall/promotion/coupon/couponTemplate.ts
  6. 39 0
      src/api/mall/promotion/kefu/conversation/index.ts
  7. 36 0
      src/api/mall/promotion/kefu/message/index.ts
  8. 91 0
      src/api/mall/promotion/point/index.ts
  9. 21 11
      src/api/mall/promotion/reward/rewardActivity.ts
  10. 7 0
      src/api/mall/promotion/seckill/seckillActivity.ts
  11. 48 44
      src/api/mall/promotion/seckill/seckillConfig.ts
  12. 9 3
      src/api/mall/trade/delivery/pickUpStore/index.ts
  13. 1 1
      src/api/mall/trade/order/index.ts
  14. 21 6
      src/views/mall/home/components/OperationDataCard.vue
  15. 2 2
      src/views/mall/home/components/TradeTrendCard.vue
  16. 3 4
      src/views/mall/home/index.vue
  17. 1 1
      src/views/mall/product/brand/BrandForm.vue
  18. 0 1
      src/views/mall/product/brand/index.vue
  19. 1 1
      src/views/mall/product/category/CategoryForm.vue
  20. 19 2
      src/views/mall/product/category/index.vue
  21. 19 5
      src/views/mall/product/comment/index.vue
  22. 0 1
      src/views/mall/product/property/index.vue
  23. 1 1
      src/views/mall/product/property/value/index.vue
  24. 23 10
      src/views/mall/product/spu/components/SkuList.vue
  25. 1 1
      src/views/mall/product/spu/components/SkuTableSelect.vue
  26. 2 0
      src/views/mall/product/spu/components/SpuShowcase.vue
  27. 1 1
      src/views/mall/product/spu/components/SpuTableSelect.vue
  28. 1 1
      src/views/mall/product/spu/form/DeliveryForm.vue
  29. 1 1
      src/views/mall/product/spu/form/InfoForm.vue
  30. 50 13
      src/views/mall/product/spu/form/ProductAttributes.vue
  31. 54 2
      src/views/mall/product/spu/form/ProductPropertyAddForm.vue
  32. 21 14
      src/views/mall/product/spu/form/SkuForm.vue
  33. 1 6
      src/views/mall/product/spu/form/index.vue
  34. 7 2
      src/views/mall/product/spu/index.vue
  35. 3 3
      src/views/mall/promotion/article/ArticleForm.vue
  36. 1 2
      src/views/mall/promotion/article/category/ArticleCategoryForm.vue
  37. 1 1
      src/views/mall/promotion/article/category/index.vue
  38. 1 1
      src/views/mall/promotion/article/index.vue
  39. 2 2
      src/views/mall/promotion/banner/BannerForm.vue
  40. 0 1
      src/views/mall/promotion/banner/index.vue
  41. 0 1
      src/views/mall/promotion/bargain/activity/index.vue
  42. 0 1
      src/views/mall/promotion/bargain/record/index.vue
  43. 1 5
      src/views/mall/promotion/combination/activity/CombinationActivityForm.vue
  44. 44 41
      src/views/mall/promotion/combination/activity/index.vue
  45. 158 0
      src/views/mall/promotion/combination/components/CombinationShowcase.vue
  46. 345 0
      src/views/mall/promotion/combination/components/CombinationTableSelect.vue
  47. 0 1
      src/views/mall/promotion/combination/record/index.vue
  48. 32 6
      src/views/mall/promotion/components/SpuAndSkuList.vue
  49. 8 1
      src/views/mall/promotion/components/SpuSelect.vue
  50. 16 43
      src/views/mall/promotion/coupon/components/CouponSelect.vue
  51. 1 1
      src/views/mall/promotion/coupon/components/CouponSendForm.vue
  52. 18 3
      src/views/mall/promotion/coupon/formatter.ts
  53. 0 1
      src/views/mall/promotion/coupon/index.vue
  54. 24 25
      src/views/mall/promotion/coupon/template/CouponTemplateForm.vue
  55. 7 2
      src/views/mall/promotion/coupon/template/index.vue
  56. 108 26
      src/views/mall/promotion/discountActivity/DiscountActivityForm.vue
  57. 0 13
      src/views/mall/promotion/discountActivity/discountActivity.data.ts
  58. 11 12
      src/views/mall/promotion/discountActivity/index.vue
  59. 0 1
      src/views/mall/promotion/diy/page/index.vue
  60. 1 1
      src/views/mall/promotion/diy/template/decorate.vue
  61. 0 1
      src/views/mall/promotion/diy/template/index.vue
  62. 254 0
      src/views/mall/promotion/kefu/components/KeFuConversationList.vue
  63. 525 0
      src/views/mall/promotion/kefu/components/KeFuMessageList.vue
  64. BIN
      src/views/mall/promotion/kefu/components/asserts/a.png
  65. BIN
      src/views/mall/promotion/kefu/components/asserts/aini.png
  66. BIN
      src/views/mall/promotion/kefu/components/asserts/aixin.png
  67. BIN
      src/views/mall/promotion/kefu/components/asserts/baiyan.png
  68. BIN
      src/views/mall/promotion/kefu/components/asserts/bizui.png
  69. BIN
      src/views/mall/promotion/kefu/components/asserts/buhaoyisi.png
  70. BIN
      src/views/mall/promotion/kefu/components/asserts/bukesiyi.png
  71. BIN
      src/views/mall/promotion/kefu/components/asserts/dajing.png
  72. BIN
      src/views/mall/promotion/kefu/components/asserts/danao.png
  73. BIN
      src/views/mall/promotion/kefu/components/asserts/daxiao.png
  74. BIN
      src/views/mall/promotion/kefu/components/asserts/dianzan.png
  75. BIN
      src/views/mall/promotion/kefu/components/asserts/emo.png
  76. BIN
      src/views/mall/promotion/kefu/components/asserts/esi.png
  77. BIN
      src/views/mall/promotion/kefu/components/asserts/fadai.png
  78. BIN
      src/views/mall/promotion/kefu/components/asserts/fankun.png
  79. BIN
      src/views/mall/promotion/kefu/components/asserts/feiwen.png
  80. BIN
      src/views/mall/promotion/kefu/components/asserts/fennu.png
  81. BIN
      src/views/mall/promotion/kefu/components/asserts/ganga.png
  82. BIN
      src/views/mall/promotion/kefu/components/asserts/ganmao.png
  83. BIN
      src/views/mall/promotion/kefu/components/asserts/hanyan.png
  84. BIN
      src/views/mall/promotion/kefu/components/asserts/haochi.png
  85. BIN
      src/views/mall/promotion/kefu/components/asserts/hongxin.png
  86. BIN
      src/views/mall/promotion/kefu/components/asserts/huaixiao.png
  87. BIN
      src/views/mall/promotion/kefu/components/asserts/jingkong.png
  88. BIN
      src/views/mall/promotion/kefu/components/asserts/jingshu.png
  89. BIN
      src/views/mall/promotion/kefu/components/asserts/jingya.png
  90. BIN
      src/views/mall/promotion/kefu/components/asserts/kaixin.png
  91. BIN
      src/views/mall/promotion/kefu/components/asserts/keai.png
  92. BIN
      src/views/mall/promotion/kefu/components/asserts/keshui.png
  93. BIN
      src/views/mall/promotion/kefu/components/asserts/kun.png
  94. BIN
      src/views/mall/promotion/kefu/components/asserts/lengku.png
  95. BIN
      src/views/mall/promotion/kefu/components/asserts/liuhan.png
  96. BIN
      src/views/mall/promotion/kefu/components/asserts/liukoushui.png
  97. BIN
      src/views/mall/promotion/kefu/components/asserts/liulei.png
  98. BIN
      src/views/mall/promotion/kefu/components/asserts/mengbi.png
  99. BIN
      src/views/mall/promotion/kefu/components/asserts/mianwubiaoqing.png
  100. BIN
      src/views/mall/promotion/kefu/components/asserts/nanguo.png

+ 10 - 0
src/api/mall/product/history.ts

@@ -0,0 +1,10 @@
+import request from '@/config/axios'
+
+/**
+ * 获得商品浏览记录分页
+ *
+ * @param params 请求参数
+ */
+export const getBrowseHistoryPage = (params: any) => {
+  return request.get({ url: '/product/browse-history/page', params })
+}

+ 10 - 14
src/api/mall/product/property.ts

@@ -24,20 +24,6 @@ export interface PropertyValueVO {
   remark?: string
 }
 
-/**
- * 商品属性值的明细
- */
-export interface PropertyValueDetailVO {
-  /** 属性项的编号 */
-  propertyId: number // 属性的编号
-  /** 属性的名称 */
-  propertyName: string
-  /** 属性值的编号 */
-  valueId: number
-  /** 属性值的名称 */
-  valueName: string
-}
-
 // ------------------------ 属性项 -------------------
 
 // 创建属性项
@@ -65,6 +51,11 @@ export const getPropertyPage = (params: PageParam) => {
   return request.get({ url: '/product/property/page', params })
 }
 
+// 获得属性项精简列表
+export const getPropertySimpleList = (): Promise<PropertyVO[]> => {
+  return request.get({ url: '/product/property/simple-list' })
+}
+
 // ------------------------ 属性值 -------------------
 
 // 获得属性值分页
@@ -91,3 +82,8 @@ export const updatePropertyValue = (data: PropertyValueVO) => {
 export const deletePropertyValue = (id: number) => {
   return request.delete({ url: `/product/property/value/delete?id=${id}` })
 }
+
+// 获得属性值精简列表
+export const getPropertyValueSimpleList = (propertyId: number): Promise<PropertyValueVO[]> => {
+  return request.get({ url: '/product/property/value/simple-list', params: { propertyId } })
+}

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

@@ -50,6 +50,8 @@ export interface Spu {
   giveIntegral?: number // 赠送积分
   virtualSalesCount?: number // 虚拟销量
   price?: number // 商品价格
+  combinationPrice?: number // 商品拼团价格
+  seckillPrice?: number // 商品秒杀价格
   salesCount?: number // 商品销量
   marketPrice?: number // 市场价
   costPrice?: number // 成本价

+ 7 - 1
src/api/mall/promotion/combination/combinationActivity.ts

@@ -16,6 +16,7 @@ export interface CombinationActivityVO {
   virtualGroup?: number
   status?: number
   limitDuration?: number
+  combinationPrice?: number
   products: CombinationProductVO[]
 }
 
@@ -36,7 +37,7 @@ export interface SpuExtension extends Spu {
 }
 
 // 查询拼团活动列表
-export const getCombinationActivityPage = async (params) => {
+export const getCombinationActivityPage = async (params: any) => {
   return await request.get({ url: '/promotion/combination-activity/page', params })
 }
 
@@ -45,6 +46,11 @@ export const getCombinationActivity = async (id: number) => {
   return await request.get({ url: '/promotion/combination-activity/get?id=' + id })
 }
 
+// 获得拼团活动列表,基于活动编号数组
+export const getCombinationActivityListByIds = (ids: number[]) => {
+  return request.get({ url: `/promotion/combination-activity/list-by-ids?ids=${ids}` })
+}
+
 // 新增拼团活动
 export const createCombinationActivity = async (data: CombinationActivityVO) => {
   return await request.post({ url: '/promotion/combination-activity/create', data })

+ 1 - 1
src/api/mall/promotion/coupon/couponTemplate.ts

@@ -74,7 +74,7 @@ export function getCouponTemplatePage(params: PageParam) {
 }
 
 // 获得优惠劵模板分页
-export function getCouponTemplateList(ids: number[]) {
+export function getCouponTemplateList(ids: number[]): Promise<CouponTemplateVO[]> {
   return request.get({
     url: `/promotion/coupon-template/list?ids=${ids}`
   })

+ 39 - 0
src/api/mall/promotion/kefu/conversation/index.ts

@@ -0,0 +1,39 @@
+import request from '@/config/axios'
+
+export interface KeFuConversationRespVO {
+  id: number // 编号
+  userId: number // 会话所属用户
+  userAvatar: string // 会话所属用户头像
+  userNickname: string // 会话所属用户昵称
+  lastMessageTime: Date // 最后聊天时间
+  lastMessageContent: string // 最后聊天内容
+  lastMessageContentType: number // 最后发送的消息类型
+  adminPinned: boolean // 管理端置顶
+  userDeleted: boolean // 用户是否可见
+  adminDeleted: boolean // 管理员是否可见
+  adminUnreadMessageCount: number // 管理员未读消息数
+  createTime?: string // 创建时间
+}
+
+// 客服会话 API
+export const KeFuConversationApi = {
+  // 获得客服会话列表
+  getConversationList: async () => {
+    return await request.get({ url: '/promotion/kefu-conversation/list' })
+  },
+  // 获得客服会话
+  getConversation: async (id: number) => {
+    return await request.get({ url: `/promotion/kefu-conversation/get?id=` + id })
+  },
+  // 客服会话置顶
+  updateConversationPinned: async (data: any) => {
+    return await request.put({
+      url: '/promotion/kefu-conversation/update-conversation-pinned',
+      data
+    })
+  },
+  // 删除客服会话
+  deleteConversation: async (id: number) => {
+    return await request.delete({ url: `/promotion/kefu-conversation/delete?id=${id}` })
+  }
+}

+ 36 - 0
src/api/mall/promotion/kefu/message/index.ts

@@ -0,0 +1,36 @@
+import request from '@/config/axios'
+
+export interface KeFuMessageRespVO {
+  id: number // 编号
+  conversationId: number // 会话编号
+  senderId: number // 发送人编号
+  senderAvatar: string // 发送人头像
+  senderType: number // 发送人类型
+  receiverId: number // 接收人编号
+  receiverType: number // 接收人类型
+  contentType: number // 消息类型
+  content: string // 消息
+  readStatus: boolean // 是否已读
+  createTime: Date // 创建时间
+}
+
+// 客服会话 API
+export const KeFuMessageApi = {
+  // 发送客服消息
+  sendKeFuMessage: async (data: any) => {
+    return await request.post({
+      url: '/promotion/kefu-message/send',
+      data
+    })
+  },
+  // 更新客服消息已读状态
+  updateKeFuMessageReadStatus: async (conversationId: number) => {
+    return await request.put({
+      url: '/promotion/kefu-message/update-read-status?conversationId=' + conversationId
+    })
+  },
+  // 获得消息列表(流式加载)
+  getKeFuMessageList: async (params: any) => {
+    return await request.get({ url: '/promotion/kefu-message/list', params })
+  }
+}

+ 91 - 0
src/api/mall/promotion/point/index.ts

@@ -0,0 +1,91 @@
+import request from '@/config/axios'
+import { Sku, Spu } from '@/api/mall/product/spu' // 积分商城活动 VO
+
+// 积分商城活动 VO
+export interface PointActivityVO {
+  id: number // 积分商城活动编号
+  spuId: number // 积分商城活动商品
+  status: number // 活动状态
+  stock: number // 积分商城活动库存
+  totalStock: number // 积分商城活动总库存
+  remark?: string // 备注
+  sort: number // 排序
+  createTime: string // 创建时间
+  products: PointProductVO[] // 积分商城商品
+
+  // ========== 商品字段 ==========
+  spuName: string // 商品名称
+  picUrl: string // 商品主图
+  marketPrice: number // 商品市场价,单位:分
+
+  //======================= 显示所需兑换积分最少的 sku 信息 =======================
+  point: number // 兑换积分
+  price: number // 兑换金额,单位:分
+}
+
+// 秒杀活动所需属性
+export interface PointProductVO {
+  id?: number // 积分商城商品编号
+  activityId?: number // 积分商城活动 id
+  spuId?: number // 商品 SPU 编号
+  skuId: number // 商品 SKU 编号
+  count: number // 可兑换数量
+  point: number // 兑换积分
+  price: number // 兑换金额,单位:分
+  stock: number // 积分商城商品库存
+  activityStatus?: number // 积分商城商品状态
+}
+
+// 扩展 Sku 配置
+export type SkuExtension = Sku & {
+  productConfig: PointProductVO
+}
+
+export interface SpuExtension extends Spu {
+  skus: SkuExtension[] // 重写类型
+}
+
+export interface SpuExtension0 extends Spu {
+  pointStock: number // 积分商城活动库存
+  pointTotalStock: number // 积分商城活动总库存
+  point: number // 兑换积分
+  pointPrice: number // 兑换金额,单位:分
+}
+
+// 积分商城活动 API
+export const PointActivityApi = {
+  // 查询积分商城活动分页
+  getPointActivityPage: async (params: any) => {
+    return await request.get({ url: `/promotion/point-activity/page`, params })
+  },
+
+  // 查询积分商城活动详情
+  getPointActivity: async (id: number) => {
+    return await request.get({ url: `/promotion/point-activity/get?id=` + id })
+  },
+
+  // 查询积分商城活动列表,基于活动编号数组
+  getPointActivityListByIds: async (ids: number[]) => {
+    return request.get({ url: `/promotion/point-activity/list-by-ids?ids=${ids}` })
+  },
+
+  // 新增积分商城活动
+  createPointActivity: async (data: PointActivityVO) => {
+    return await request.post({ url: `/promotion/point-activity/create`, data })
+  },
+
+  // 修改积分商城活动
+  updatePointActivity: async (data: PointActivityVO) => {
+    return await request.put({ url: `/promotion/point-activity/update`, data })
+  },
+
+  // 删除积分商城活动
+  deletePointActivity: async (id: number) => {
+    return await request.delete({ url: `/promotion/point-activity/delete?id=` + id })
+  },
+
+  // 关闭秒杀活动
+  closePointActivity: async (id: number) => {
+    return await request.put({ url: '/promotion/point-activity/close?id=' + id })
+  }
+}

+ 21 - 11
src/api/mall/promotion/reward/rewardActivity.ts

@@ -1,34 +1,39 @@
 import request from '@/config/axios'
 
-export interface DiscountActivityVO {
+export interface RewardActivityVO {
   id?: number
   name?: string
   startTime?: Date
   endTime?: Date
+  startAndEndTime?: Date[] // 只前端使用
   remark?: string
   conditionType?: number
   productScope?: number
+  rules: RewardRule[]
+  // 如下仅用于表单,不提交
+  productScopeValues?: number[] // 商品范围:值为品类编号列表、商品编号列表
+  productCategoryIds?: number[]
   productSpuIds?: number[]
-  rules?: DiscountProductVO[]
 }
 
 // 优惠规则
-export interface DiscountProductVO {
-  limit: number
-  discountPrice: number
-  freeDelivery: boolean
+export interface RewardRule {
+  limit?: number
+  discountPrice?: number
+  freeDelivery?: boolean
   point: number
-  couponIds: number[]
-  couponCounts: number[]
+  giveCouponTemplateCounts?: {
+    [key: number]: number
+  }
 }
 
 // 新增满减送活动
-export const createRewardActivity = async (data: DiscountActivityVO) => {
+export const createRewardActivity = async (data: RewardActivityVO) => {
   return await request.post({ url: '/promotion/reward-activity/create', data })
 }
 
 // 更新满减送活动
-export const updateRewardActivity = async (data: DiscountActivityVO) => {
+export const updateRewardActivity = async (data: RewardActivityVO) => {
   return await request.put({ url: '/promotion/reward-activity/update', data })
 }
 
@@ -42,7 +47,12 @@ export const getReward = async (id: number) => {
   return await request.get({ url: '/promotion/reward-activity/get?id=' + id })
 }
 
-// 删除限时折扣活动
+// 删除满减送活动
 export const deleteRewardActivity = async (id: number) => {
   return await request.delete({ url: '/promotion/reward-activity/delete?id=' + id })
 }
+
+// 关闭满减送活动
+export const closeRewardActivity = async (id: number) => {
+  return await request.put({ url: '/promotion/reward-activity/close?id=' + id })
+}

+ 7 - 0
src/api/mall/promotion/seckill/seckillActivity.ts

@@ -18,12 +18,14 @@ export interface SeckillActivityVO {
   singleLimitCount?: number
   stock?: number
   totalStock?: number
+  seckillPrice?: number
   products?: SeckillProductVO[]
 }
 
 // 秒杀活动所需属性
 export interface SeckillProductVO {
   skuId: number
+  spuId: number
   seckillPrice: number
   stock: number
 }
@@ -42,6 +44,11 @@ export const getSeckillActivityPage = async (params) => {
   return await request.get({ url: '/promotion/seckill-activity/page', params })
 }
 
+// 查询秒杀活动列表,基于活动编号数组
+export const getSeckillActivityListByIds = (ids: number[]) => {
+  return request.get({ url: `/promotion/seckill-activity/list-by-ids?ids=${ids}` })
+}
+
 // 查询秒杀活动详情
 export const getSeckillActivity = async (id: number) => {
   return await request.get({ url: '/promotion/seckill-activity/get?id=' + id })

+ 48 - 44
src/api/mall/promotion/seckill/seckillConfig.ts

@@ -1,49 +1,53 @@
 import request from '@/config/axios'
 
+// 秒杀时段 VO
 export interface SeckillConfigVO {
-  id: number
-  name: string
-  startTime: string
-  endTime: string
-  sliderPicUrls: string[]
-  status: number
-}
-
-// 查询秒杀时段配置列表
-export const getSeckillConfigPage = async (params) => {
-  return await request.get({ url: '/promotion/seckill-config/page', params })
-}
-
-// 查询秒杀时段配置详情
-export const getSeckillConfig = async (id: number) => {
-  return await request.get({ url: '/promotion/seckill-config/get?id=' + id })
-}
-
-// 获得所有开启状态的秒杀时段精简列表
-export const getSimpleSeckillConfigList = async () => {
-  return await request.get({ url: '/promotion/seckill-config/list-all-simple' })
-}
-
-// 新增秒杀时段配置
-export const createSeckillConfig = async (data: SeckillConfigVO) => {
-  return await request.post({ url: '/promotion/seckill-config/create', data })
-}
-
-// 修改秒杀时段配置
-export const updateSeckillConfig = async (data: SeckillConfigVO) => {
-  return await request.put({ url: '/promotion/seckill-config/update', data })
-}
-
-// 修改时段配置状态
-export const updateSeckillConfigStatus = (id: number, status: number) => {
-  const data = {
-    id,
-    status
+  id: number // 编号
+  name: string // 秒杀时段名称
+  startTime: string // 开始时间点
+  endTime: string // 结束时间点
+  sliderPicUrls: string[] // 秒杀轮播图
+  status: number // 活动状态
+}
+
+// 秒杀时段 API
+export const SeckillConfigApi = {
+  // 查询秒杀时段分页
+  getSeckillConfigPage: async (params: any) => {
+    return await request.get({ url: `/promotion/seckill-config/page`, params })
+  },
+
+  // 查询秒杀时段列表
+  getSimpleSeckillConfigList: async () => {
+    return await request.get({ url: `/promotion/seckill-config/list` })
+  },
+
+  // 查询秒杀时段详情
+  getSeckillConfig: async (id: number) => {
+    return await request.get({ url: `/promotion/seckill-config/get?id=` + id })
+  },
+
+  // 新增秒杀时段
+  createSeckillConfig: async (data: SeckillConfigVO) => {
+    return await request.post({ url: `/promotion/seckill-config/create`, data })
+  },
+
+  // 修改秒杀时段
+  updateSeckillConfig: async (data: SeckillConfigVO) => {
+    return await request.put({ url: `/promotion/seckill-config/update`, data })
+  },
+
+  // 删除秒杀时段
+  deleteSeckillConfig: async (id: number) => {
+    return await request.delete({ url: `/promotion/seckill-config/delete?id=` + id })
+  },
+
+  // 修改时段配置状态
+  updateSeckillConfigStatus: async (id: number, status: number) => {
+    const data = {
+      id,
+      status
+    }
+    return request.put({ url: '/promotion/seckill-config/update-status', data: data })
   }
-  return request.put({ url: '/promotion/seckill-config/update-status', data: data })
-}
-
-// 删除秒杀时段配置
-export const deleteSeckillConfig = async (id: number) => {
-  return await request.delete({ url: '/promotion/seckill-config/delete?id=' + id })
 }

+ 9 - 3
src/api/mall/trade/delivery/pickUpStore/index.ts

@@ -13,10 +13,11 @@ export interface DeliveryPickUpStoreVO {
   latitude: number
   longitude: number
   status: number
+  verifyUserIds: number[] // 绑定用户编号组数
 }
 
 // 查询自提门店列表
-export const getDeliveryPickUpStorePage = async (params) => {
+export const getDeliveryPickUpStorePage = async (params: any) => {
   return await request.get({ url: '/trade/delivery/pick-up-store/page', params })
 }
 
@@ -26,8 +27,8 @@ export const getDeliveryPickUpStore = async (id: number) => {
 }
 
 // 查询自提门店精简列表
-export const getListAllSimple = async (): Promise<DeliveryPickUpStoreVO[]> => {
-  return await request.get({ url: '/trade/delivery/pick-up-store/list-all-simple' })
+export const getSimpleDeliveryPickUpStoreList = async (): Promise<DeliveryPickUpStoreVO[]> => {
+  return await request.get({ url: '/trade/delivery/pick-up-store/simple-list' })
 }
 
 // 新增自提门店
@@ -44,3 +45,8 @@ export const updateDeliveryPickUpStore = async (data: DeliveryPickUpStoreVO) =>
 export const deleteDeliveryPickUpStore = async (id: number) => {
   return await request.delete({ url: '/trade/delivery/pick-up-store/delete?id=' + id })
 }
+
+// 绑定自提店员
+export const bindStoreStaffId = async (data: any) => {
+  return await request.post({ url: '/trade/delivery/pick-up-store/bind', data })
+}

+ 1 - 1
src/api/mall/trade/order/index.ts

@@ -141,7 +141,7 @@ export const getExpressTrackList = async (id: number | null) => {
 }
 
 export interface DeliveryVO {
-  id: number // 订单编号
+  id?: number // 订单编号
   logisticsId: number | null // 物流公司编号
   logisticsNo: string // 物流编号
 }

+ 21 - 6
src/views/mall/home/components/OperationDataCard.vue

@@ -11,9 +11,9 @@
         @click="handleClick(item.routerName)"
       >
         <CountTo
-          :prefix="item.prefix"
-          :end-val="item.value"
           :decimals="item.decimals"
+          :end-val="item.value"
+          :prefix="item.prefix"
           class="text-3xl"
         />
         <span class="text-center">{{ item.name }}</span>
@@ -53,10 +53,18 @@ const data = reactive({
 /** 查询订单数据 */
 const getOrderData = async () => {
   const orderCount = await TradeStatisticsApi.getOrderCount()
-  data.orderUndelivered.value = orderCount.undelivered
-  data.orderAfterSaleApply.value = orderCount.afterSaleApply
-  data.orderWaitePickUp.value = orderCount.pickUp
-  data.withdrawAuditing.value = orderCount.auditingWithdraw
+  if (orderCount.undelivered != null) {
+    data.orderUndelivered.value = orderCount.undelivered
+  }
+  if (orderCount.afterSaleApply != null) {
+    data.orderAfterSaleApply.value = orderCount.afterSaleApply
+  }
+  if (orderCount.pickUp != null) {
+    data.orderWaitePickUp.value = orderCount.pickUp
+  }
+  if (orderCount.auditingWithdraw != null) {
+    data.withdrawAuditing.value = orderCount.auditingWithdraw
+  }
 }
 
 /** 查询商品数据 */
@@ -83,6 +91,13 @@ const handleClick = (routerName: string) => {
   router.push({ name: routerName })
 }
 
+/** 激活时 */
+onActivated(() => {
+  getOrderData()
+  getProductData()
+  getWalletRechargeData()
+})
+
 /** 初始化 **/
 onMounted(() => {
   getOrderData()

+ 2 - 2
src/views/mall/home/components/TradeTrendCard.vue

@@ -6,7 +6,7 @@
         <!-- 查询条件 -->
         <div class="flex flex-row items-center gap-2">
           <el-radio-group v-model="timeRangeType" @change="handleTimeRangeTypeChange">
-            <el-radio-button v-for="[key, value] in timeRange.entries()" :key="key" :label="key">
+            <el-radio-button v-for="[key, value] in timeRange.entries()" :key="key" :value="key">
               {{ value.name }}
             </el-radio-button>
           </el-radio-group>
@@ -186,7 +186,7 @@ const getOrderCountTrendComparison = async (
     dates.push(item.value.date)
     if (series.length === 2) {
       series[0].data.push(fenToYuan(item?.value?.orderPayPrice || 0)) // 当前金额
-      series[1].data.push(fenToYuan(item?.value?.orderPayCount || 0)) // 当前数量
+      series[1].data.push(item?.value?.orderPayCount || 0) // 当前数量
     } else {
       series[0].data.push(fenToYuan(item?.reference?.orderPayPrice || 0)) // 对照金额
       series[1].data.push(fenToYuan(item?.value?.orderPayPrice || 0)) // 当前金额

+ 3 - 4
src/views/mall/home/index.vue

@@ -1,5 +1,4 @@
 <template>
-
   <div class="flex flex-col">
     <!-- 数据对照 -->
     <el-row :gutter="16" class="row">
@@ -8,7 +7,7 @@
           tag="今日"
           title="销售额"
           prefix="¥"
-          ::decimals="2"
+          :decimals="2"
           :value="fenToYuan(orderComparison?.value?.orderPayPrice || 0)"
           :reference="fenToYuan(orderComparison?.reference?.orderPayPrice || 0)"
         />
@@ -25,8 +24,8 @@
         <ComparisonCard
           tag="今日"
           title="订单量"
-          :value="fenToYuan(orderComparison?.value?.orderPayCount || 0)"
-          :reference="fenToYuan(orderComparison?.reference?.orderPayCount || 0)"
+          :value="orderComparison?.value?.orderPayCount || 0"
+          :reference="orderComparison?.reference?.orderPayCount || 0"
         />
       </el-col>
       <el-col :md="6" :sm="12" :xs="24" :loading="loading">

+ 1 - 1
src/views/mall/product/brand/BrandForm.vue

@@ -21,7 +21,7 @@
           <el-radio
             v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
             :key="dict.value"
-            :label="dict.value"
+            :value="dict.value"
           >
             {{ dict.label }}
           </el-radio>

+ 0 - 1
src/views/mall/product/brand/index.vue

@@ -1,5 +1,4 @@
 <template>
-
   <!-- 搜索工作栏 -->
   <ContentWrap>
     <el-form

+ 1 - 1
src/views/mall/product/category/CategoryForm.vue

@@ -33,7 +33,7 @@
           <el-radio
             v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
             :key="dict.value"
-            :label="dict.value"
+            :value="dict.value"
           >
             {{ dict.label }}
           </el-radio>

+ 19 - 2
src/views/mall/product/category/index.vue

@@ -1,5 +1,4 @@
 <template>
-
   <!-- 搜索工作栏 -->
   <ContentWrap>
     <el-form
@@ -55,7 +54,7 @@
         width="180"
         :formatter="dateFormatter"
       />
-      <el-table-column label="操作" align="center">
+      <el-table-column label="操作" align="center" min-width="180">
         <template #default="scope">
           <el-button
             link
@@ -65,6 +64,15 @@
           >
             编辑
           </el-button>
+          <el-button
+            link
+            type="primary"
+            v-if="scope.row.parentId > 0"
+            @click="handleViewSpu(scope.row.id)"
+            v-hasPermi="['product:spu:query']"
+          >
+            查看商品
+          </el-button>
           <el-button
             link
             type="danger"
@@ -141,6 +149,15 @@ const handleDelete = async (id: number) => {
   } catch {}
 }
 
+/** 查看商品操作 */
+const router = useRouter() // 路由
+const handleViewSpu = (id: number) => {
+  router.push({
+    name: 'ProductSpu',
+    query: { categoryId: id }
+  })
+}
+
 /** 初始化 **/
 onMounted(() => {
   getList()

+ 19 - 5
src/views/mall/product/comment/index.vue

@@ -1,5 +1,4 @@
 <template>
-
   <ContentWrap>
     <!-- 搜索工作栏 -->
     <el-form
@@ -10,19 +9,34 @@
       label-width="68px"
     >
       <el-form-item label="回复状态" prop="replyStatus">
-        <el-select v-model="queryParams.replyStatus">
+        <el-select v-model="queryParams.replyStatus" class="!w-240px">
           <el-option label="已回复" :value="true" />
           <el-option label="未回复" :value="false" />
         </el-select>
       </el-form-item>
       <el-form-item label="商品名称" prop="spuName">
-        <el-input v-model="queryParams.spuName" placeholder="请输入商品名称" />
+        <el-input
+          v-model="queryParams.spuName"
+          placeholder="请输入商品名称"
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
       </el-form-item>
       <el-form-item label="用户名称" prop="userNickname">
-        <el-input v-model="queryParams.userNickname" placeholder="请输入用户名称" />
+        <el-input
+          v-model="queryParams.userNickname"
+          placeholder="请输入用户名称"
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
       </el-form-item>
       <el-form-item label="订单编号" prop="orderId">
-        <el-input v-model="queryParams.orderId" placeholder="请输入订单编号" />
+        <el-input
+          v-model="queryParams.orderId"
+          placeholder="请输入订单编号"
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
       </el-form-item>
       <el-form-item label="评论时间" prop="createTime">
         <el-date-picker

+ 0 - 1
src/views/mall/product/property/index.vue

@@ -1,5 +1,4 @@
 <template>
-
   <!-- 搜索工作栏 -->
   <ContentWrap>
     <el-form

+ 1 - 1
src/views/mall/product/property/value/index.vue

@@ -105,7 +105,7 @@ const list = ref([]) // 列表的数据
 const queryParams = reactive({
   pageNo: 1,
   pageSize: 10,
-  propertyId: Number(params.propertyId),
+  propertyId: params.propertyId,
   name: undefined
 })
 const queryFormRef = ref() // 搜索的表单

+ 23 - 10
src/views/mall/product/spu/components/SkuList.vue

@@ -24,7 +24,7 @@
       >
         <template #default="{ row }">
           <span style="font-weight: bold; color: #40aaff">
-            {{ row.properties[index]?.valueName }}
+            {{ row.properties?.[index]?.valueName }}
           </span>
         </template>
       </el-table-column>
@@ -168,7 +168,7 @@
       >
         <template #default="{ row }">
           <span style="font-weight: bold; color: #40aaff">
-            {{ row.properties[index]?.valueName }}
+            {{ row.properties?.[index]?.valueName }}
           </span>
         </template>
       </el-table-column>
@@ -248,7 +248,7 @@
       >
         <template #default="{ row }">
           <span style="font-weight: bold; color: #40aaff">
-            {{ row.properties[index]?.valueName }}
+            {{ row.properties?.[index]?.valueName }}
           </span>
         </template>
       </el-table-column>
@@ -260,17 +260,17 @@
     </el-table-column>
     <el-table-column align="center" label="销售价(元)" min-width="80">
       <template #default="{ row }">
-        {{ row.price }}
+        {{ formatToFraction(row.price) }}
       </template>
     </el-table-column>
     <el-table-column align="center" label="市场价(元)" min-width="80">
       <template #default="{ row }">
-        {{ row.marketPrice }}
+        {{ formatToFraction(row.marketPrice) }}
       </template>
     </el-table-column>
     <el-table-column align="center" label="成本价(元)" min-width="80">
       <template #default="{ row }">
-        {{ row.costPrice }}
+        {{ formatToFraction(row.costPrice) }}
       </template>
     </el-table-column>
     <el-table-column align="center" label="库存" min-width="80">
@@ -284,7 +284,7 @@
 </template>
 <script lang="ts" setup>
 import { PropType, Ref } from 'vue'
-import { copyValueToTarget } from '@/utils'
+import { copyValueToTarget, formatToFraction } from '@/utils'
 import { propTypes } from '@/utils/propTypes'
 import { UploadImg } from '@/components/UploadFile'
 import type { Property, Sku, Spu } from '@/api/mall/product/spu'
@@ -292,6 +292,7 @@ import { createImageViewer } from '@/components/ImageViewer'
 import { RuleConfig } from '@/views/mall/product/spu/components/index'
 import { PropertyAndValues } from './index'
 import { ElTable } from 'element-plus'
+import { isEmpty } from '@/utils/is'
 
 defineOptions({ name: 'SkuList' })
 const message = useMessage() // 消息弹窗
@@ -340,11 +341,22 @@ const imagePreview = (imgUrl: string) => {
 
 /** 批量添加 */
 const batchAdd = () => {
+  validateProperty()
   formData.value!.skus!.forEach((item) => {
     copyValueToTarget(item, skuList.value[0])
   })
 }
-
+/** 校验商品属性属性值 */
+const validateProperty = () => {
+  // 校验商品属性属性值是否为空,有一个为空都不给过
+  const warningInfo = '存在属性属性值为空,请先检查完善属性值后重试!!!'
+  for (const item of props.propertyList) {
+    if (!item.values || isEmpty(item.values)) {
+      message.warning(warningInfo)
+      throw new Error(warningInfo)
+    }
+  }
+}
 /** 删除 sku */
 const deleteSku = (row) => {
   const index = formData.value!.skus!.findIndex(
@@ -358,6 +370,7 @@ const tableHeaders = ref<{ prop: string; label: string }[]>([]) // 多属性表
  * 保存时,每个商品规格的表单要校验下。例如说,销售金额最低是 0.01 这种。
  */
 const validateSku = () => {
+  validateProperty()
   let warningInfo = '请检查商品各行相关属性配置,'
   let validate = true // 默认通过
   for (const sku of formData.value!.skus!) {
@@ -421,7 +434,7 @@ watch(
 const generateTableData = (propertyList: any[]) => {
   // 构建数据结构
   const propertyValues = propertyList.map((item) =>
-    item.values.map((v) => ({
+    item.values.map((v: any) => ({
       propertyId: item.id,
       propertyName: item.name,
       valueId: v.id,
@@ -542,7 +555,7 @@ watch(
       return
     }
     // 添加新属性没有属性值也不做处理
-    if (propertyList.some((item) => item.values!.length === 0)) {
+    if (propertyList.some((item) => !item.values || isEmpty(item.values))) {
       return
     }
     // 生成 table 数据,即 sku 列表

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

@@ -3,7 +3,7 @@
     <el-table v-loading="loading" :data="list" show-overflow-tooltip>
       <el-table-column label="#" width="55">
         <template #default="{ row }">
-          <el-radio :label="row.id" v-model="selectedSkuId" @change="handleSelected(row)"
+          <el-radio :value="row.id" v-model="selectedSkuId" @change="handleSelected(row)"
             >&nbsp;
           </el-radio>
         </template>

+ 2 - 0
src/views/mall/product/spu/components/SpuShowcase.vue

@@ -85,6 +85,7 @@ const openSpuTableSelect = () => {
 
 /**
  * 选择商品后触发
+ *
  * @param spus 选中的商品列表
  */
 const handleSpuSelected = (spus: ProductSpuApi.Spu | ProductSpuApi.Spu[]) => {
@@ -94,6 +95,7 @@ const handleSpuSelected = (spus: ProductSpuApi.Spu | ProductSpuApi.Spu[]) => {
 
 /**
  * 删除商品
+ *
  * @param index 商品索引
  */
 const handleRemoveSpu = (index: number) => {

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

@@ -70,7 +70,7 @@
         <!-- 2. 单选模式 -->
         <el-table-column label="#" width="55" v-else>
           <template #default="{ row }">
-            <el-radio :label="row.id" v-model="selectedSpuId" @change="handleSingleSelected(row)">
+            <el-radio :value="row.id" v-model="selectedSpuId" @change="handleSingleSelected(row)">
               <!-- 空格不能省略,是为了让单选框不显示label,如果不指定label不会有选中的效果 -->
               &nbsp;
             </el-radio>

+ 1 - 1
src/views/mall/product/spu/form/DeliveryForm.vue

@@ -6,7 +6,7 @@
         <el-checkbox
           v-for="dict in getIntDictOptions(DICT_TYPE.TRADE_DELIVERY_TYPE)"
           :key="dict.value"
-          :label="dict.value"
+          :value="dict.value"
         >
           {{ dict.label }}
         </el-checkbox>

+ 1 - 1
src/views/mall/product/spu/form/InfoForm.vue

@@ -45,7 +45,7 @@
         :show-word-limit="true"
         class="w-80!"
         maxlength="128"
-        placeholder="请输入商品名称"
+        placeholder="请输入商品简介"
         type="textarea"
       />
     </el-form-item>

+ 50 - 13
src/views/mall/product/spu/form/ProductAttributes.vue

@@ -3,7 +3,7 @@
   <el-col v-for="(item, index) in attributeList" :key="index">
     <div>
       <el-text class="mx-1">属性名:</el-text>
-      <el-tag class="mx-1" :closable="!isDetail" type="success" @close="handleCloseProperty(index)">
+      <el-tag :closable="!isDetail" class="mx-1" type="success" @close="handleCloseProperty(index)">
         {{ item.name }}
       </el-tag>
     </div>
@@ -12,22 +12,34 @@
       <el-tag
         v-for="(value, valueIndex) in item.values"
         :key="value.id"
-        class="mx-1"
         :closable="!isDetail"
+        class="mx-1"
         @close="handleCloseValue(index, valueIndex)"
       >
         {{ value.name }}
       </el-tag>
-      <el-input
+      <el-select
         v-show="inputVisible(index)"
         :id="`input${index}`"
         :ref="setInputRef"
         v-model="inputValue"
-        class="!w-20"
+        :reserve-keyword="false"
+        allow-create
+        class="!w-30"
+        default-first-option
+        filterable
         size="small"
         @blur="handleInputConfirm(index, item.id)"
+        @change="handleInputConfirm(index, item.id)"
         @keyup.enter="handleInputConfirm(index, item.id)"
-      />
+      >
+        <el-option
+          v-for="item2 in attributeOptions"
+          :key="item2.id"
+          :label="item2.name"
+          :value="item2.name"
+        />
+      </el-select>
       <el-button
         v-show="!inputVisible(index)"
         class="button-new-tag ml-1"
@@ -42,9 +54,7 @@
 </template>
 
 <script lang="ts" setup>
-import { ElInput } from 'element-plus'
 import * as PropertyApi from '@/api/mall/product/property'
-import { PropertyVO } from '@/api/mall/product/property'
 import { PropertyAndValues } from '@/views/mall/product/spu/components'
 import { propTypes } from '@/utils/propTypes'
 
@@ -59,16 +69,17 @@ const inputVisible = computed(() => (index: number) => {
   if (attributeIndex.value === null) return false
   if (attributeIndex.value === index) return true
 })
-const inputRef = ref([]) //标签输入框Ref
+const inputRef = ref<any[]>([]) //标签输入框Ref
 /** 解决 ref 在 v-for 中的获取问题*/
-const setInputRef = (el) => {
+const setInputRef = (el: any) => {
   if (el === null || typeof el === 'undefined') return
   // 如果不存在 id 相同的元素才添加
-  if (!inputRef.value.some((item) => item.input?.attributes.id === el.input?.attributes.id)) {
+  if (!inputRef.value.some((item) => item.inputRef?.attributes.id === el.inputRef?.attributes.id)) {
     inputRef.value.push(el)
   }
 }
 const attributeList = ref<PropertyAndValues[]>([]) // 商品属性列表
+const attributeOptions = ref([] as PropertyApi.PropertyValueVO[]) // 商品属性名称下拉框
 const props = defineProps({
   propertyList: {
     type: Array,
@@ -81,7 +92,7 @@ watch(
   () => props.propertyList,
   (data) => {
     if (!data) return
-    attributeList.value = data
+    attributeList.value = data as any
   },
   {
     deep: true,
@@ -97,19 +108,40 @@ const handleCloseValue = (index: number, valueIndex: number) => {
 /** 删除属性*/
 const handleCloseProperty = (index: number) => {
   attributeList.value?.splice(index, 1)
+  emit('success', attributeList.value)
 }
 
 /** 显示输入框并获取焦点 */
-const showInput = async (index) => {
+const showInput = async (index: number) => {
   attributeIndex.value = index
   inputRef.value[index].focus()
+  // 获取属性下拉选项
+  await getAttributeOptions(attributeList.value[index].id)
 }
 
 /** 输入框失去焦点或点击回车时触发 */
 const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
 const handleInputConfirm = async (index: number, propertyId: number) => {
   if (inputValue.value) {
-    // 保存属性值
+    // 1. 重复添加校验
+    if (attributeList.value[index].values.find((item) => item.name === inputValue.value)) {
+      message.warning('已存在相同属性值,请重试')
+      attributeIndex.value = null
+      inputValue.value = ''
+      return
+    }
+
+    // 2.1 情况一:属性值已存在,则直接使用并结束
+    const existValue = attributeOptions.value.find((item) => item.name === inputValue.value)
+    if (existValue) {
+      attributeIndex.value = null
+      inputValue.value = ''
+      attributeList.value[index].values.push({ id: existValue.id, name: existValue.name })
+      emit('success', attributeList.value)
+      return
+    }
+
+    // 2.2 情况二:新属性值,则进行保存
     try {
       const id = await PropertyApi.createPropertyValue({ propertyId, name: inputValue.value })
       attributeList.value[index].values.push({ id, name: inputValue.value })
@@ -122,4 +154,9 @@ const handleInputConfirm = async (index: number, propertyId: number) => {
   attributeIndex.value = null
   inputValue.value = ''
 }
+
+/** 获取商品属性下拉选项 */
+const getAttributeOptions = async (propertyId: number) => {
+  attributeOptions.value = await PropertyApi.getPropertyValueSimpleList(propertyId)
+}
 </script>

+ 54 - 2
src/views/mall/product/spu/form/ProductPropertyAddForm.vue

@@ -7,9 +7,25 @@
       :model="formData"
       :rules="formRules"
       label-width="80px"
+      @keydown.enter.prevent="submitForm"
     >
       <el-form-item label="属性名称" prop="name">
-        <el-input v-model="formData.name" placeholder="请输入名称" />
+        <el-select
+          v-model="formData.name"
+          :reserve-keyword="false"
+          allow-create
+          class="!w-360px"
+          default-first-option
+          filterable
+          placeholder="请选择属性名称。如果不存在,可手动输入选择"
+        >
+          <el-option
+            v-for="item in attributeOptions"
+            :key="item.id"
+            :label="item.name"
+            :value="item.name"
+          />
+        </el-select>
       </el-form-item>
     </el-form>
     <template #footer>
@@ -36,6 +52,7 @@ const formRules = reactive({
 })
 const formRef = ref() // 表单 Ref
 const attributeList = ref([]) // 商品属性列表
+const attributeOptions = ref([] as PropertyApi.PropertyVO[]) // 商品属性名称下拉框
 const props = defineProps({
   propertyList: {
     type: Array,
@@ -59,15 +76,39 @@ watch(
 const open = async () => {
   dialogVisible.value = true
   resetForm()
+  // 加载列表
+  await getAttributeOptions()
 }
 defineExpose({ open }) // 提供 open 方法,用于打开弹窗
 
 /** 提交表单 */
 const submitForm = async () => {
-  // 校验表单
+  // 1.1 重复添加校验
+  for (const attrItem of attributeList.value) {
+    if (attrItem.name === formData.value.name) {
+      return message.error('该属性已存在,请勿重复添加')
+    }
+  }
+  // 1.2 校验表单
   if (!formRef) return
   const valid = await formRef.value.validate()
   if (!valid) return
+
+  // 2.1 情况一:属性名已存在,则直接使用并结束
+  const existProperty = attributeOptions.value.find((item) => item.name === formData.value.name)
+  if (existProperty) {
+    // 添加到属性列表
+    attributeList.value.push({
+      id: existProperty.id,
+      ...formData.value,
+      values: []
+    })
+    // 关闭弹窗
+    dialogVisible.value = false
+    return
+  }
+
+  // 2.2 情况二:如果是不存在的属性,则需要执行新增
   // 提交请求
   formLoading.value = true
   try {
@@ -79,6 +120,7 @@ const submitForm = async () => {
       ...formData.value,
       values: []
     })
+    // 关闭弹窗
     message.success(t('common.createSuccess'))
     dialogVisible.value = false
   } finally {
@@ -93,4 +135,14 @@ const resetForm = () => {
   }
   formRef.value?.resetFields()
 }
+
+/** 获取商品属性下拉选项 */
+const getAttributeOptions = async () => {
+  formLoading.value = true
+  try {
+    attributeOptions.value = await PropertyApi.getPropertySimpleList()
+  } finally {
+    formLoading.value = false
+  }
+}
 </script>

+ 21 - 14
src/views/mall/product/spu/form/SkuForm.vue

@@ -1,20 +1,27 @@
 <!-- 商品发布 - 库存价格 -->
 <template>
-  <el-form ref="formRef" :model="formData" :rules="rules" label-width="120px" :disabled="isDetail">
-    <el-form-item label="分销类型" props="subCommissionType">
+  <el-form
+    ref="formRef"
+    v-loading="formLoading"
+    :disabled="isDetail"
+    :model="formData"
+    :rules="rules"
+    label-width="120px"
+  >
+    <el-form-item label="分销类型" prop="subCommissionType">
       <el-radio-group
         v-model="formData.subCommissionType"
-        @change="changeSubCommissionType"
         class="w-80"
+        @change="changeSubCommissionType"
       >
-        <el-radio :label="false">默认设置</el-radio>
-        <el-radio :label="true" class="radio">单独设置</el-radio>
+        <el-radio :value="false">默认设置</el-radio>
+        <el-radio :value="true" class="radio">单独设置</el-radio>
       </el-radio-group>
     </el-form-item>
-    <el-form-item label="商品规格" props="specType">
-      <el-radio-group v-model="formData.specType" @change="onChangeSpec" class="w-80">
-        <el-radio :label="false" class="radio">单规格</el-radio>
-        <el-radio :label="true">多规格</el-radio>
+    <el-form-item label="商品规格" prop="specType">
+      <el-radio-group v-model="formData.specType" class="w-80" @change="onChangeSpec">
+        <el-radio :value="false" class="radio">单规格</el-radio>
+        <el-radio :value="true">多规格</el-radio>
       </el-radio-group>
     </el-form-item>
     <!-- 多规格添加-->
@@ -29,22 +36,22 @@
     <el-form-item v-if="formData.specType" label="商品属性">
       <el-button class="mb-10px mr-15px" @click="attributesAddFormRef.open">添加属性</el-button>
       <ProductAttributes
+        :is-detail="isDetail"
         :property-list="propertyList"
         @success="generateSkus"
-        :is-detail="isDetail"
       />
     </el-form-item>
     <template v-if="formData.specType && propertyList.length > 0">
-      <el-form-item label="批量设置" v-if="!isDetail">
+      <el-form-item v-if="!isDetail" label="批量设置">
         <SkuList :is-batch="true" :prop-form-data="formData" :property-list="propertyList" />
       </el-form-item>
       <el-form-item label="规格列表">
         <SkuList
           ref="skuListRef"
+          :is-detail="isDetail"
           :prop-form-data="formData"
           :property-list="propertyList"
           :rule-config="ruleConfig"
-          :is-detail="isDetail"
         />
       </el-form-item>
     </template>
@@ -94,7 +101,7 @@ const ruleConfig: RuleConfig[] = [
 ]
 
 const message = useMessage() // 消息弹窗
-
+const formLoading = ref(false)
 const props = defineProps({
   propFormData: {
     type: Object as PropType<Spu>,
@@ -181,7 +188,7 @@ const onChangeSpec = () => {
 }
 
 /** 调用 SkuList generateTableData 方法*/
-const generateSkus = (propertyList) => {
+const generateSkus = (propertyList: any[]) => {
   skuListRef.value.generateTableData(propertyList)
 }
 </script>

+ 1 - 6
src/views/mall/product/spu/form/index.vue

@@ -63,7 +63,7 @@ import SkuForm from './SkuForm.vue'
 import DeliveryForm from './DeliveryForm.vue'
 import { convertToInteger, floatToFixed2, formatToFraction } from '@/utils'
 
-defineOptions({ name: 'ProductSpuForm' })
+defineOptions({ name: 'ProductSpuAdd' })
 
 const { t } = useI18n() // 国际化
 const message = useMessage() // 消息弹窗
@@ -175,11 +175,6 @@ const submitForm = async () => {
       typeof item === 'object' ? newSliderPicUrls.push(item.url) : newSliderPicUrls.push(item)
     })
     deepCopyFormData.sliderPicUrls = newSliderPicUrls
-
-    // 判断sku图片是否有上传
-    const hasPicUrl = deepCopyFormData.skus?.every(e => e.picUrl)
-    if (!hasPicUrl) return message.warning('请将规格列表中的图片上传完整')
-    
     // 校验都通过后提交表单
     const data = deepCopyFormData as ProductSpuApi.Spu
     const id = params.id as unknown as number

+ 7 - 2
src/views/mall/product/spu/index.vue

@@ -1,6 +1,5 @@
 <!-- 商品中心 - 商品列表  -->
 <template>
-
   <!-- 搜索工作栏 -->
   <ContentWrap>
     <el-form
@@ -243,6 +242,7 @@ import * as ProductCategoryApi from '@/api/mall/product/category'
 defineOptions({ name: 'ProductSpu' })
 
 const message = useMessage() // 消息弹窗
+const route = useRoute() // 路由
 const { t } = useI18n() // 国际化
 const { push } = useRouter() // 路由跳转
 
@@ -389,7 +389,7 @@ const resetQuery = () => {
 /** 新增或修改 */
 const openForm = (id?: number) => {
   // 修改
-  if (id) {
+  if (typeof id === 'number') {
     push({ name: 'ProductSpuEdit', params: { id } })
     return
   }
@@ -430,6 +430,11 @@ onActivated(() => {
 
 /** 初始化 **/
 onMounted(async () => {
+  // 解析路由的 categoryId
+  if (route.query.categoryId) {
+    queryParams.value.categoryId = Number(route.query.categoryId)
+  }
+  // 获得商品信息
   await getTabsCount()
   await getList()
   // 获得分类树

+ 3 - 3
src/views/mall/promotion/article/ArticleForm.vue

@@ -51,7 +51,7 @@
               <el-radio
                 v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
                 :key="dict.value"
-                :label="dict.value"
+                :value="dict.value"
               >
                 {{ dict.label }}
               </el-radio>
@@ -64,7 +64,7 @@
               <el-radio
                 v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)"
                 :key="dict.value"
-                :label="dict.value"
+                :value="dict.value"
               >
                 {{ dict.label }}
               </el-radio>
@@ -77,7 +77,7 @@
               <el-radio
                 v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)"
                 :key="dict.value"
-                :label="dict.value"
+                :value="dict.value"
               >
                 {{ dict.label }}
               </el-radio>

+ 1 - 2
src/views/mall/promotion/article/category/ArticleCategoryForm.vue

@@ -1,5 +1,4 @@
 <template>
-
   <Dialog v-model="dialogVisible" :title="dialogTitle">
     <el-form
       ref="formRef"
@@ -19,7 +18,7 @@
           <el-radio
             v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
             :key="dict.value"
-            :label="dict.value"
+            :value="dict.value"
           >
             {{ dict.label }}
           </el-radio>

+ 1 - 1
src/views/mall/promotion/article/category/index.vue

@@ -62,7 +62,7 @@
 
   <!-- 列表 -->
   <ContentWrap>
-    <el-table v-loading="loading" :data="list" :stripe="true">
+    <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true">
       <el-table-column align="center" label="编号" prop="id" min-width="100" />
       <el-table-column align="center" label="分类名称" prop="name" min-width="240" />
       <el-table-column label="分类图图" min-width="80">

+ 1 - 1
src/views/mall/promotion/article/index.vue

@@ -1,5 +1,4 @@
 <template>
-
   <ContentWrap>
     <!-- 搜索工作栏 -->
     <el-form
@@ -79,6 +78,7 @@
   <!-- 列表 -->
   <ContentWrap>
     <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true">
+      <el-table-column align="center" label="ID" min-width="180" prop="id" />
       <el-table-column align="center" label="封面" min-width="80" prop="picUrl">
         <template #default="{ row }">
           <el-image :src="row.picUrl" class="h-30px w-30px" @click="imagePreview(row.picUrl)" />

+ 2 - 2
src/views/mall/promotion/banner/BannerForm.vue

@@ -34,7 +34,7 @@
               <el-radio
                 v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
                 :key="dict.value"
-                :label="dict.value"
+                :value="dict.value"
               >
                 {{ dict.label }}
               </el-radio>
@@ -47,7 +47,7 @@
               <el-radio
                 v-for="dict in getIntDictOptions(DICT_TYPE.PROMOTION_BANNER_POSITION)"
                 :key="dict.value"
-                :label="dict.value"
+                :value="dict.value"
               >
                 {{ dict.label }}
               </el-radio>

+ 0 - 1
src/views/mall/promotion/banner/index.vue

@@ -1,5 +1,4 @@
 <template>
-
   <ContentWrap>
     <!-- 搜索工作栏 -->
     <el-form

+ 0 - 1
src/views/mall/promotion/bargain/activity/index.vue

@@ -1,5 +1,4 @@
 <template>
-
   <ContentWrap>
     <!-- 搜索工作栏 -->
     <el-form

+ 0 - 1
src/views/mall/promotion/bargain/record/index.vue

@@ -1,5 +1,4 @@
 <template>
-
   <ContentWrap>
     <!-- 搜索工作栏 -->
     <el-form

+ 1 - 5
src/views/mall/promotion/combination/activity/CombinationActivityForm.vue

@@ -135,11 +135,7 @@ const open = async (type: string, id?: number) => {
       const data = (await CombinationActivityApi.getCombinationActivity(
         id
       )) as CombinationActivityApi.CombinationActivityVO
-      await getSpuDetails(
-        data.spuId!,
-        data.products?.map((sku) => sku.skuId),
-        data.products
-      )
+      await getSpuDetails(data.spuId!, data.products?.map((sku) => sku.skuId), data.products)
       formRef.value.setValues(data)
     } finally {
       formLoading.value = false

+ 44 - 41
src/views/mall/promotion/combination/activity/index.vue

@@ -1,29 +1,28 @@
 <template>
-
   <ContentWrap>
     <!-- 搜索工作栏 -->
     <el-form
-      class="-mb-15px"
-      :model="queryParams"
       ref="queryFormRef"
       :inline="true"
+      :model="queryParams"
+      class="-mb-15px"
       label-width="68px"
     >
       <el-form-item label="活动名称" prop="name">
         <el-input
           v-model="queryParams.name"
-          placeholder="请输入活动名称"
+          class="!w-240px"
           clearable
+          placeholder="请输入活动名称"
           @keyup.enter="handleQuery"
-          class="!w-240px"
         />
       </el-form-item>
       <el-form-item label="活动状态" prop="status">
         <el-select
           v-model="queryParams.status"
-          placeholder="请选择活动状态"
-          clearable
           class="!w-240px"
+          clearable
+          placeholder="请选择活动状态"
         >
           <el-option
             v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
@@ -34,15 +33,22 @@
         </el-select>
       </el-form-item>
       <el-form-item>
-        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
-        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+        <el-button @click="handleQuery">
+          <Icon class="mr-5px" icon="ep:search" />
+          搜索
+        </el-button>
+        <el-button @click="resetQuery">
+          <Icon class="mr-5px" icon="ep:refresh" />
+          重置
+        </el-button>
         <el-button
-          type="primary"
+          v-hasPermi="['promotion:combination-activity:create']"
           plain
+          type="primary"
           @click="openForm('create')"
-          v-hasPermi="['promotion:combination-activity:create']"
         >
-          <Icon icon="ep:plus" class="mr-5px" /> 新增
+          <Icon class="mr-5px" icon="ep:plus" />
+          新增
         </el-button>
       </el-form-item>
     </el-form>
@@ -50,77 +56,77 @@
 
   <!-- 列表 -->
   <ContentWrap>
-    <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
-      <el-table-column label="活动编号" prop="id" min-width="80" />
-      <el-table-column label="活动名称" prop="name" min-width="140" />
+    <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true">
+      <el-table-column label="活动编号" min-width="80" prop="id" />
+      <el-table-column label="活动名称" min-width="140" prop="name" />
       <el-table-column label="活动时间" min-width="210">
         <template #default="scope">
           {{ formatDate(scope.row.startTime, 'YYYY-MM-DD') }}
           ~ {{ formatDate(scope.row.endTime, 'YYYY-MM-DD') }}
         </template>
       </el-table-column>
-      <el-table-column label="商品图片" prop="spuName" min-width="80">
+      <el-table-column label="商品图片" min-width="80" prop="spuName">
         <template #default="scope">
           <el-image
+            :preview-src-list="[scope.row.picUrl]"
             :src="scope.row.picUrl"
             class="h-40px w-40px"
-            :preview-src-list="[scope.row.picUrl]"
             preview-teleported
           />
         </template>
       </el-table-column>
-      <el-table-column label="商品标题" prop="spuName" min-width="300" />
+      <el-table-column label="商品标题" min-width="300" prop="spuName" />
       <el-table-column
+        :formatter="fenToYuanFormat"
         label="原价"
-        prop="marketPrice"
         min-width="100"
-        :formatter="fenToYuanFormat"
+        prop="marketPrice"
       />
-      <el-table-column label="拼团价" prop="seckillPrice" min-width="100">
+      <el-table-column label="拼团价" min-width="100" prop="seckillPrice">
         <template #default="scope">
           {{ formatCombinationPrice(scope.row.products) }}
         </template>
       </el-table-column>
-      <el-table-column label="开团组数" prop="groupCount" min-width="100" />
-      <el-table-column label="成团组数" prop="groupSuccessCount" min-width="100" />
-      <el-table-column label="购买次数" prop="recordCount" min-width="100" />
-      <el-table-column label="活动状态" align="center" prop="status" min-width="100">
+      <el-table-column label="开团组数" min-width="100" prop="groupCount" />
+      <el-table-column label="成团组数" min-width="100" prop="groupSuccessCount" />
+      <el-table-column label="购买次数" min-width="100" prop="recordCount" />
+      <el-table-column align="center" label="活动状态" min-width="100" prop="status">
         <template #default="scope">
           <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
         </template>
       </el-table-column>
       <el-table-column
-        label="创建时间"
+        :formatter="dateFormatter"
         align="center"
+        label="创建时间"
         prop="createTime"
-        :formatter="dateFormatter"
         width="180px"
       />
-      <el-table-column label="操作" align="center" width="150px" fixed="right">
+      <el-table-column align="center" fixed="right" label="操作" width="150px">
         <template #default="scope">
           <el-button
+            v-hasPermi="['promotion:combination-activity:update']"
             link
             type="primary"
             @click="openForm('update', scope.row.id)"
-            v-hasPermi="['promotion:combination-activity:update']"
           >
             编辑
           </el-button>
           <el-button
+            v-if="scope.row.status === 0"
+            v-hasPermi="['promotion:combination-activity:close']"
             link
             type="danger"
             @click="handleClose(scope.row.id)"
-            v-if="scope.row.status === 0"
-            v-hasPermi="['promotion:combination-activity:close']"
           >
             关闭
           </el-button>
           <el-button
+            v-else
+            v-hasPermi="['promotion:combination-activity:delete']"
             link
             type="danger"
             @click="handleDelete(scope.row.id)"
-            v-else
-            v-hasPermi="['promotion:combination-activity:delete']"
           >
             删除
           </el-button>
@@ -129,9 +135,9 @@
     </el-table>
     <!-- 分页 -->
     <Pagination
-      :total="total"
-      v-model:page="queryParams.pageNo"
       v-model:limit="queryParams.pageSize"
+      v-model:page="queryParams.pageNo"
+      :total="total"
       @pagination="getList"
     />
   </ContentWrap>
@@ -140,12 +146,11 @@
   <CombinationActivityForm ref="formRef" @success="getList" />
 </template>
 
-<script setup lang="ts">
+<script lang="ts" setup>
 import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
-import { dateFormatter } from '@/utils/formatTime'
+import { dateFormatter, formatDate } from '@/utils/formatTime'
 import * as CombinationActivityApi from '@/api/mall/promotion/combination/combinationActivity'
 import CombinationActivityForm from './CombinationActivityForm.vue'
-import { formatDate } from '@/utils/formatTime'
 import { fenToYuanFormat } from '@/utils/formatter'
 import { fenToYuan } from '@/utils'
 
@@ -164,7 +169,6 @@ const queryParams = reactive({
   status: null
 })
 const queryFormRef = ref() // 搜索的表单
-const exportLoading = ref(false) // 导出的加载中
 
 /** 查询列表 */
 const getList = async () => {
@@ -196,12 +200,11 @@ const openForm = (type: string, id?: number) => {
   formRef.value.open(type, id)
 }
 
-// TODO 芋艿:这里要改下
 /** 关闭按钮操作 */
 const handleClose = async (id: number) => {
   try {
     // 关闭的二次确认
-    await message.confirm('确认关闭该秒杀活动吗?')
+    await message.confirm('确认关闭该拼团活动吗?')
     // 发起关闭
     await CombinationActivityApi.closeCombinationActivity(id)
     message.success('关闭成功')

+ 158 - 0
src/views/mall/promotion/combination/components/CombinationShowcase.vue

@@ -0,0 +1,158 @@
+<template>
+  <div class="flex flex-wrap items-center gap-8px">
+    <div
+      v-for="(combinationActivity, index) in Activitys"
+      :key="combinationActivity.id"
+      class="select-box spu-pic"
+    >
+      <el-tooltip :content="combinationActivity.name">
+        <div class="relative h-full w-full">
+          <el-image :src="combinationActivity.picUrl" class="h-full w-full" />
+          <Icon
+            v-show="!disabled"
+            class="del-icon"
+            icon="ep:circle-close-filled"
+            @click="handleRemoveActivity(index)"
+          />
+        </div>
+      </el-tooltip>
+    </div>
+    <el-tooltip content="选择活动" v-if="canAdd">
+      <div class="select-box" @click="openCombinationActivityTableSelect">
+        <Icon icon="ep:plus" />
+      </div>
+    </el-tooltip>
+  </div>
+  <!-- 拼团活动选择对话框(表格形式) -->
+  <CombinationTableSelect
+    ref="combinationActivityTableSelectRef"
+    :multiple="limit != 1"
+    @change="handleActivitySelected"
+  />
+</template>
+<script lang="ts" setup>
+import * as CombinationActivityApi from '@/api/mall/promotion/combination/combinationActivity'
+import { propTypes } from '@/utils/propTypes'
+import { oneOfType } from 'vue-types'
+import { isArray } from '@/utils/is'
+import CombinationTableSelect from '@/views/mall/promotion/combination/components/CombinationTableSelect.vue'
+
+// 活动橱窗,一般用于装修时使用
+// 提供功能:展示活动列表、添加活动、删除活动
+defineOptions({ name: 'CombinationShowcase' })
+
+const props = defineProps({
+  modelValue: oneOfType<number | Array<number>>([Number, Array]).isRequired,
+  // 限制数量:默认不限制
+  limit: propTypes.number.def(Number.MAX_VALUE),
+  disabled: propTypes.bool.def(false)
+})
+
+// 计算是否可以添加
+const canAdd = computed(() => {
+  // 情况一:禁用时不可以添加
+  if (props.disabled) return false
+  // 情况二:未指定限制数量时,可以添加
+  if (!props.limit) return true
+  // 情况三:检查已添加数量是否小于限制数量
+  return Activitys.value.length < props.limit
+})
+
+// 拼团活动列表
+const Activitys = ref<CombinationActivityApi.CombinationActivityVO[]>([])
+
+watch(
+  () => props.modelValue,
+  async () => {
+    const ids = isArray(props.modelValue)
+      ? // 情况一:多选
+        props.modelValue
+      : // 情况二:单选
+        props.modelValue
+        ? [props.modelValue]
+        : []
+    // 不需要返显
+    if (ids.length === 0) {
+      Activitys.value = []
+      return
+    }
+    // 只有活动发生变化之后,才会查询活动
+    if (
+      Activitys.value.length === 0 ||
+      Activitys.value.some((combinationActivity) => !ids.includes(combinationActivity.id!))
+    ) {
+      Activitys.value = await CombinationActivityApi.getCombinationActivityListByIds(ids)
+    }
+  },
+  { immediate: true }
+)
+
+/** 活动表格选择对话框 */
+const combinationActivityTableSelectRef = ref()
+// 打开对话框
+const openCombinationActivityTableSelect = () => {
+  combinationActivityTableSelectRef.value.open(Activitys.value)
+}
+
+/**
+ * 选择活动后触发
+ * @param activityVOs 选中的活动列表
+ */
+const handleActivitySelected = (
+  activityVOs:
+    | CombinationActivityApi.CombinationActivityVO
+    | CombinationActivityApi.CombinationActivityVO[]
+) => {
+  Activitys.value = isArray(activityVOs) ? activityVOs : [activityVOs]
+  emitActivityChange()
+}
+
+/**
+ * 删除活动
+ * @param index 活动索引
+ */
+const handleRemoveActivity = (index: number) => {
+  Activitys.value.splice(index, 1)
+  emitActivityChange()
+}
+const emit = defineEmits(['update:modelValue', 'change'])
+const emitActivityChange = () => {
+  if (props.limit === 1) {
+    const combinationActivity = Activitys.value.length > 0 ? Activitys.value[0] : null
+    emit('update:modelValue', combinationActivity?.id || 0)
+    emit('change', combinationActivity)
+  } else {
+    emit(
+      'update:modelValue',
+      Activitys.value.map((combinationActivity) => combinationActivity.id)
+    )
+    emit('change', Activitys.value)
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.select-box {
+  display: flex;
+  width: 60px;
+  height: 60px;
+  border: 1px dashed var(--el-border-color-darker);
+  border-radius: 8px;
+  align-items: center;
+  justify-content: center;
+  cursor: pointer;
+}
+
+.spu-pic {
+  position: relative;
+}
+
+.del-icon {
+  position: absolute;
+  top: -10px;
+  right: -10px;
+  z-index: 1;
+  width: 20px !important;
+  height: 20px !important;
+}
+</style>

+ 345 - 0
src/views/mall/promotion/combination/components/CombinationTableSelect.vue

@@ -0,0 +1,345 @@
+<template>
+  <Dialog v-model="dialogVisible" :appendToBody="true" title="选择活动" width="70%">
+    <ContentWrap>
+      <!-- 搜索工作栏 -->
+      <el-form
+        ref="queryFormRef"
+        :inline="true"
+        :model="queryParams"
+        class="-mb-15px"
+        label-width="68px"
+      >
+        <el-form-item label="活动名称" prop="name">
+          <el-input
+            v-model="queryParams.name"
+            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>
+          <el-button @click="handleQuery">
+            <Icon class="mr-5px" icon="ep:search" />
+            搜索
+          </el-button>
+          <el-button @click="resetQuery">
+            <Icon class="mr-5px" icon="ep:refresh" />
+            重置
+          </el-button>
+        </el-form-item>
+      </el-form>
+      <el-table v-loading="loading" :data="list" show-overflow-tooltip>
+        <!-- 1. 多选模式(不能使用type="selection",Element会忽略Header插槽) -->
+        <el-table-column width="55" v-if="multiple">
+          <template #header>
+            <el-checkbox
+              v-model="isCheckAll"
+              :indeterminate="isIndeterminate"
+              @change="handleCheckAll"
+            />
+          </template>
+          <template #default="{ row }">
+            <el-checkbox
+              v-model="checkedStatus[row.id]"
+              @change="(checked: boolean) => handleCheckOne(checked, row, true)"
+            />
+          </template>
+        </el-table-column>
+        <!-- 2. 单选模式 -->
+        <el-table-column label="#" width="55" v-else>
+          <template #default="{ row }">
+            <el-radio
+              :value="row.id"
+              v-model="selectedActivityId"
+              @change="handleSingleSelected(row)"
+            >
+              <!-- 空格不能省略,是为了让单选框不显示label,如果不指定label不会有选中的效果 -->
+              &nbsp;
+            </el-radio>
+          </template>
+        </el-table-column>
+        <el-table-column label="活动编号" prop="id" min-width="80" />
+        <el-table-column label="活动名称" prop="name" min-width="140" />
+        <el-table-column label="活动时间" min-width="210">
+          <template #default="scope">
+            {{ formatDate(scope.row.startTime, 'YYYY-MM-DD') }}
+            ~ {{ formatDate(scope.row.endTime, 'YYYY-MM-DD') }}
+          </template>
+        </el-table-column>
+        <el-table-column label="商品图片" prop="spuName" min-width="80">
+          <template #default="scope">
+            <el-image
+              :src="scope.row.picUrl"
+              class="h-40px w-40px"
+              :preview-src-list="[scope.row.picUrl]"
+              preview-teleported
+            />
+          </template>
+        </el-table-column>
+        <el-table-column label="商品标题" prop="spuName" min-width="300" />
+        <el-table-column
+          label="原价"
+          prop="marketPrice"
+          min-width="100"
+          :formatter="fenToYuanFormat"
+        />
+        <el-table-column label="拼团价" prop="seckillPrice" min-width="100">
+          <template #default="scope">
+            {{ formatCombinationPrice(scope.row.products) }}
+          </template>
+        </el-table-column>
+        <el-table-column label="开团组数" prop="groupCount" min-width="100" />
+        <el-table-column label="成团组数" prop="groupSuccessCount" min-width="100" />
+        <el-table-column label="购买次数" prop="recordCount" min-width="100" />
+        <el-table-column label="活动状态" align="center" prop="status" min-width="100">
+          <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"
+          :formatter="dateFormatter"
+          width="180px"
+        />
+      </el-table>
+      <!-- 分页 -->
+      <Pagination
+        v-model:limit="queryParams.pageSize"
+        v-model:page="queryParams.pageNo"
+        :total="total"
+        @pagination="getList"
+      />
+    </ContentWrap>
+    <template #footer v-if="multiple">
+      <el-button type="primary" @click="handleEmitChange">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+
+<script lang="ts" setup>
+import { handleTree } from '@/utils/tree'
+
+import * as ProductCategoryApi from '@/api/mall/product/category'
+import { propTypes } from '@/utils/propTypes'
+import { CHANGE_EVENT } from 'element-plus'
+import * as CombinationActivityApi from '@/api/mall/promotion/combination/combinationActivity'
+import { fenToYuanFormat } from '@/utils/formatter'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { dateFormatter, formatDate } from '@/utils/formatTime'
+import { fenToYuan } from '@/utils'
+
+type CombinationActivityVO = Required<CombinationActivityApi.CombinationActivityVO>
+
+/**
+ * 活动表格选择对话框
+ * 1. 单选模式:
+ *    1.1 点击表格左侧的单选框时,结束选择,并关闭对话框
+ *    1.2 再次打开时,保持选中状态
+ * 2. 多选模式:
+ *    2.1 点击表格左侧的多选框时,记录选中的活动
+ *    2.2 切换分页时,保持活动的选中状态
+ *    2.3 点击右下角的确定按钮时,结束选择,关闭对话框
+ *    2.4 再次打开时,保持选中状态
+ */
+defineOptions({ name: 'CombinationTableSelect' })
+
+defineProps({
+  // 多选模式
+  multiple: propTypes.bool.def(false)
+})
+
+// 列表的总页数
+const total = ref(0)
+// 列表的数据
+const list = ref<CombinationActivityVO[]>([])
+// 列表的加载中
+const loading = ref(false)
+// 弹窗的是否展示
+const dialogVisible = ref(false)
+// 查询参数
+const queryParams = ref({
+  pageNo: 1,
+  pageSize: 10,
+  name: null,
+  status: undefined
+})
+
+/** 打开弹窗 */
+const open = (CombinationList?: CombinationActivityVO[]) => {
+  // 重置
+  checkedActivitys.value = []
+  checkedStatus.value = {}
+  isCheckAll.value = false
+  isIndeterminate.value = false
+
+  // 处理已选中
+  if (CombinationList && CombinationList.length > 0) {
+    checkedActivitys.value = [...CombinationList]
+    checkedStatus.value = Object.fromEntries(
+      CombinationList.map((activityVO) => [activityVO.id, true])
+    )
+  }
+
+  dialogVisible.value = true
+  resetQuery()
+}
+// 提供 open 方法,用于打开弹窗
+defineExpose({ open })
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await CombinationActivityApi.getCombinationActivityPage(queryParams.value)
+    list.value = data.list
+    total.value = data.total
+    // checkbox绑定undefined会有问题,需要给一个bool值
+    list.value.forEach(
+      (activityVO) =>
+        (checkedStatus.value[activityVO.id] = checkedStatus.value[activityVO.id] || false)
+    )
+    // 计算全选框状态
+    calculateIsCheckAll()
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.value.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryParams.value = {
+    pageNo: 1,
+    pageSize: 10,
+    name: '',
+    createTime: []
+  }
+  getList()
+}
+
+/**
+ * 格式化拼团价格
+ * @param products
+ */
+const formatCombinationPrice = (products) => {
+  const combinationPrice = Math.min(...products.map((item) => item.combinationPrice))
+  return `¥${fenToYuan(combinationPrice)}`
+}
+
+// 是否全选
+const isCheckAll = ref(false)
+// 全选框是否处于中间状态:不是全部选中 && 任意一个选中
+const isIndeterminate = ref(false)
+// 选中的活动
+const checkedActivitys = ref<CombinationActivityVO[]>([])
+// 选中状态:key为活动ID,value为是否选中
+const checkedStatus = ref<Record<string, boolean>>({})
+
+// 选中的活动 activityId
+const selectedActivityId = ref()
+/** 单选中时触发 */
+const handleSingleSelected = (combinationActivityVO: CombinationActivityVO) => {
+  emits(CHANGE_EVENT, combinationActivityVO)
+  // 关闭弹窗
+  dialogVisible.value = false
+  // 记住上次选择的ID
+  selectedActivityId.value = combinationActivityVO.id
+}
+
+/** 多选完成 */
+const handleEmitChange = () => {
+  // 关闭弹窗
+  dialogVisible.value = false
+  emits(CHANGE_EVENT, [...checkedActivitys.value])
+}
+
+/** 确认选择时的触发事件 */
+const emits = defineEmits<{
+  change: [CombinationActivityApi: CombinationActivityVO | CombinationActivityVO[] | any]
+}>()
+
+/** 全选/全不选 */
+const handleCheckAll = (checked: boolean) => {
+  isCheckAll.value = checked
+  isIndeterminate.value = false
+
+  list.value.forEach((combinationActivity) => handleCheckOne(checked, combinationActivity, false))
+}
+
+/**
+ * 选中一行
+ * @param checked 是否选中
+ * @param combinationActivity 活动
+ * @param isCalcCheckAll 是否计算全选
+ */
+const handleCheckOne = (
+  checked: boolean,
+  combinationActivity: CombinationActivityVO,
+  isCalcCheckAll: boolean
+) => {
+  if (checked) {
+    checkedActivitys.value.push(combinationActivity)
+    checkedStatus.value[combinationActivity.id] = true
+  } else {
+    const index = findCheckedIndex(combinationActivity)
+    if (index > -1) {
+      checkedActivitys.value.splice(index, 1)
+      checkedStatus.value[combinationActivity.id] = false
+      isCheckAll.value = false
+    }
+  }
+
+  // 计算全选框状态
+  if (isCalcCheckAll) {
+    calculateIsCheckAll()
+  }
+}
+
+// 查找活动在已选中活动列表中的索引
+const findCheckedIndex = (activityVO: CombinationActivityVO) =>
+  checkedActivitys.value.findIndex((item) => item.id === activityVO.id)
+
+// 计算全选框状态
+const calculateIsCheckAll = () => {
+  isCheckAll.value = list.value.every((activityVO) => checkedStatus.value[activityVO.id])
+  // 计算中间状态:不是全部选中 && 任意一个选中
+  isIndeterminate.value =
+    !isCheckAll.value && list.value.some((activityVO) => checkedStatus.value[activityVO.id])
+}
+
+// 分类列表
+const categoryList = ref()
+// 分类树
+const categoryTreeList = ref()
+/** 初始化 **/
+onMounted(async () => {
+  await getList()
+  // 获得分类树
+  categoryList.value = await ProductCategoryApi.getCategoryList({})
+  categoryTreeList.value = handleTree(categoryList.value, 'id', 'parentId')
+})
+</script>

+ 0 - 1
src/views/mall/promotion/combination/record/index.vue

@@ -1,5 +1,4 @@
 <template>
-
   <!-- 统计信息展示 -->
   <el-row :gutter="12">
     <el-col :span="6">

+ 32 - 6
src/views/mall/promotion/components/SpuAndSkuList.vue

@@ -29,6 +29,16 @@
     </el-table-column>
     <el-table-column align="center" label="销量" min-width="90" prop="salesCount" />
     <el-table-column align="center" label="库存" min-width="90" prop="stock" />
+    <el-table-column
+      v-if="spuData.length > 1 && deletable"
+      align="center"
+      label="操作"
+      min-width="90"
+    >
+      <template #default="scope">
+        <el-button link type="primary" @click="deleteSpu(scope.row.id)"> 删除</el-button>
+      </template>
+    </el-table-column>
   </el-table>
 </template>
 <script generic="T extends Spu" lang="ts" setup>
@@ -40,16 +50,19 @@ import { SpuProperty } from '@/views/mall/promotion/components/index'
 
 defineOptions({ name: 'PromotionSpuAndSkuList' })
 
+const message = useMessage() // 消息弹窗
+
 const props = defineProps<{
   spuList: T[]
   ruleConfig: RuleConfig[]
   spuPropertyListP: SpuProperty<T>[]
+  deletable?: boolean // SPU 是否可删除;
 }>()
 
 const spuData = ref<Spu[]>([]) // spu 详情数据列表
 const skuListRef = ref() // 商品属性列表Ref
 const spuPropertyList = ref<SpuProperty<T>[]>([]) // spuId 对应的 sku 的属性列表
-const expandRowKeys = ref<number[]>() // 控制展开行需要设置 row-key 属性才能使用,该属性为展开行的 keys 数组。
+const expandRowKeys = ref<string[]>([]) // 控制展开行需要设置 row-key 属性才能使用,该属性为展开行的 keys 数组。
 
 /**
  * 获取所有 sku 活动配置
@@ -58,10 +71,10 @@ const expandRowKeys = ref<number[]>() // 控制展开行需要设置 row-key 属
  */
 const getSkuConfigs = (extendedAttribute: string) => {
   skuListRef.value.validateSku()
-  const seckillProducts = []
+  const seckillProducts: any[] = []
   spuPropertyList.value.forEach((item) => {
-    item.spuDetail.skus.forEach((sku) => {
-      seckillProducts.push(sku[extendedAttribute])
+    item.spuDetail.skus?.forEach((sku: any) => {
+      seckillProducts.push(sku[extendedAttribute] as any)
     })
   })
   return seckillProducts
@@ -77,6 +90,19 @@ const imagePreview = (imgUrl: string) => {
   })
 }
 
+// 删除时的触发事件
+const emits = defineEmits<{
+  (e: 'delete', spuId: number): void
+}>()
+
+/** 多选时可以删除 SPU **/
+const deleteSpu = async (spuId: number) => {
+  await message.confirm('是否删除商品编号为' + spuId + '的数据?')
+  const index = spuData.value.findIndex((item) => item.id == spuId)
+  spuData.value.splice(index, 1)
+  emits('delete', spuId)
+}
+
 /**
  * 将传进来的值赋值给 skuList
  */
@@ -98,10 +124,10 @@ watch(
   () => props.spuPropertyListP,
   (data) => {
     if (!data) return
-    spuPropertyList.value = data as SpuProperty<T>[]
+    spuPropertyList.value = data as SpuProperty<T>[] as any
     // 解决如果之前选择的是单规格 spu 的话后面选择多规格 sku 多规格属性信息不展示的问题。解决方法:让 SkuList 组件重新渲染(行折叠会干掉包含的组件展开时会重新加载)
     setTimeout(() => {
-      expandRowKeys.value = data.map((item) => item.spuId)
+      expandRowKeys.value = data.map((item) => item.spuId + '')
     }, 200)
   },
   {

+ 8 - 1
src/views/mall/promotion/components/SpuSelect.vue

@@ -115,7 +115,7 @@ import { getPropertyList, PropertyAndValues, SkuList } from '@/views/mall/produc
 import { ElTable } from 'element-plus'
 import { dateFormatter } from '@/utils/formatTime'
 import { createImageViewer } from '@/components/ImageViewer'
-import { formatToFraction } from '@/utils'
+import { floatToFixed2, formatToFraction } from '@/utils'
 import { defaultProps, handleTree } from '@/utils/tree'
 
 import * as ProductCategoryApi from '@/api/mall/product/category'
@@ -228,6 +228,13 @@ const expandChange = async (row: ProductSpuApi.Spu, expandedRows?: ProductSpuApi
   }
   // 获取 SPU 详情
   const res = (await ProductSpuApi.getSpu(row.id as number)) as ProductSpuApi.Spu
+  res.skus?.forEach((item) => {
+    item.price = floatToFixed2(item.price)
+    item.marketPrice = floatToFixed2(item.marketPrice)
+    item.costPrice = floatToFixed2(item.costPrice)
+    item.firstBrokeragePrice = floatToFixed2(item.firstBrokeragePrice)
+    item.secondBrokeragePrice = floatToFixed2(item.secondBrokeragePrice)
+  })
   propertyList.value = getPropertyList(res)
   spuData.value = res
   isExpand.value = true

+ 16 - 43
src/views/mall/promotion/coupon/components/CouponSelect.vue

@@ -33,32 +33,6 @@
             />
           </el-select>
         </el-form-item>
-        <el-form-item label="优惠券状态" prop="status">
-          <el-select
-            v-model="queryParams.status"
-            class="!w-240px"
-            clearable
-            placeholder="请选择优惠券状态"
-          >
-            <el-option
-              v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
-              :key="dict.value"
-              :label="dict.label"
-              :value="dict.value"
-            />
-          </el-select>
-        </el-form-item>
-        <el-form-item label="创建时间" prop="createTime">
-          <el-date-picker
-            v-model="queryParams.createTime"
-            :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
-            class="!w-240px"
-            end-placeholder="结束日期"
-            start-placeholder="开始日期"
-            type="daterange"
-            value-format="YYYY-MM-DD HH:mm:ss"
-          />
-        </el-form-item>
         <el-form-item>
           <el-button @click="handleQuery">
             <Icon class="mr-5px" icon="ep:search" />
@@ -118,13 +92,6 @@
             <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
           </template>
         </el-table-column>
-        <el-table-column
-          :formatter="dateFormatter"
-          align="center"
-          label="创建时间"
-          prop="createTime"
-          width="180"
-        />
       </el-table>
       <!-- 分页 -->
       <Pagination
@@ -148,19 +115,21 @@ import {
   takeLimitCountFormat,
   validityTypeFormat
 } from '@/views/mall/promotion/coupon/formatter'
-import { dateFormatter } from '@/utils/formatTime'
 import * as CouponTemplateApi from '@/api/mall/promotion/coupon/couponTemplate'
+import { CouponTemplateTakeTypeEnum } from '@/utils/constants'
 
 defineOptions({ name: 'CouponSelect' })
 
-defineProps<{
-  multipleSelection: CouponTemplateApi.CouponTemplateVO[]
+const props = defineProps<{
+  multipleSelection?: CouponTemplateApi.CouponTemplateVO[]
+  takeType: number // 领取方式
 }>()
 const emit = defineEmits<{
-  (e: 'update:multipleSelection', v: CouponTemplateApi.CouponTemplateVO[])
+  (e: 'update:multipleSelection', v: CouponTemplateApi.CouponTemplateVO[]): void
+  (e: 'change', v: CouponTemplateApi.CouponTemplateVO[]): void
 }>()
 const dialogVisible = ref(false) // 弹窗的是否展示
-const dialogTitle = ref('选择优惠') // 弹窗的标题
+const dialogTitle = ref('选择优惠') // 弹窗的标题
 const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
 const loading = ref(true) // 列表的加载中
 const total = ref(0) // 列表的总页数
@@ -169,18 +138,18 @@ const queryParams = reactive({
   pageNo: 1,
   pageSize: 10,
   name: null,
-  status: null,
   discountType: null,
-  type: null,
-  createTime: []
+  canTakeTypes: [CouponTemplateTakeTypeEnum.USER.type] // 只获得直接领取的券
 })
 const queryFormRef = ref() // 搜索的表单
+const selectedCouponList = ref<CouponTemplateApi.CouponTemplateVO[]>([]) // 选择的数据
 
 /** 查询列表 */
 const getList = async () => {
   loading.value = true
   try {
     // 执行查询
+    queryParams.canTakeTypes = [props.takeType] as any
     const data = await CouponTemplateApi.getCouponTemplatePage(queryParams)
     list.value = data.list
     total.value = data.total
@@ -209,11 +178,15 @@ const open = async () => {
 defineExpose({ open }) // 提供 open 方法,用于打开弹窗
 
 const handleSelectionChange = (val: CouponTemplateApi.CouponTemplateVO[]) => {
-  emit('update:multipleSelection', val)
+  if (props.multipleSelection) {
+    emit('update:multipleSelection', val)
+    return
+  }
+  selectedCouponList.value = val
 }
 
 const submitForm = () => {
   dialogVisible.value = false
+  emit('change', selectedCouponList.value)
 }
-// TODO @puhui999:提前 todo,先不用改;未来单独成组件,其它模块可以服用;例如说,满减送,可以选择优惠劵;
 </script>

+ 1 - 1
src/views/mall/promotion/coupon/components/CouponSendForm.vue

@@ -67,7 +67,7 @@
             :disabled="sendLoading"
             :loading="sendLoading"
             @click="handleSendCoupon(scope.row.id)"
-            v-hasPermi="['member:level:update']"
+            v-hasPermi="['promotion:coupon:send']"
           >
             发送
           </el-button>

+ 18 - 3
src/views/mall/promotion/coupon/formatter.ts

@@ -16,10 +16,14 @@ export const discountFormat = (row: CouponTemplateVO) => {
 
 // 格式化【领取上限】
 export const takeLimitCountFormat = (row: CouponTemplateVO) => {
-  if (row.takeLimitCount === -1) {
-    return '无领取限制'
+  if (row.takeLimitCount) {
+    if (row.takeLimitCount === -1) {
+      return '无领取限制'
+    }
+    return `${row.takeLimitCount} 张/人`
+  } else {
+    return ' '
   }
-  return `${row.takeLimitCount} 张/人`
 }
 
 // 格式化【有效期限】
@@ -33,8 +37,19 @@ export const validityTypeFormat = (row: CouponTemplateVO) => {
   return '未知【' + row.validityType + '】'
 }
 
+// 格式化【totalCount】
+export const totalCountFormat = (row: CouponTemplateVO) => {
+  if (row.totalCount === -1) {
+    return '不限制'
+  }
+  return row.totalCount
+}
+
 // 格式化【剩余数量】
 export const remainedCountFormat = (row: CouponTemplateVO) => {
+  if (row.totalCount === -1) {
+    return '不限制'
+  }
   return row.totalCount - row.takeCount
 }
 

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

@@ -1,5 +1,4 @@
 <template>
-
   <!-- 搜索工作栏 -->
   <ContentWrap>
     <el-form

+ 24 - 25
src/views/mall/promotion/coupon/template/CouponTemplateForm.vue

@@ -10,12 +10,24 @@
       <el-form-item label="优惠券名称" prop="name">
         <el-input v-model="formData.name" placeholder="请输入优惠券名称" />
       </el-form-item>
+      <el-form-item label="优惠券描述" prop="description">
+        <el-input
+          v-model="formData.description"
+          :autosize="{ minRows: 2, maxRows: 2 }"
+          :clearable="true"
+          :show-word-limit="true"
+          class="w-1/1!"
+          maxlength="512"
+          placeholder="请输入优惠券描述"
+          type="textarea"
+        />
+      </el-form-item>
       <el-form-item label="优惠劵类型" prop="productScope">
         <el-radio-group v-model="formData.productScope">
           <el-radio
             v-for="dict in getIntDictOptions(DICT_TYPE.PROMOTION_PRODUCT_SCOPE)"
             :key="dict.value"
-            :label="dict.value"
+            :value="dict.value"
           >
             {{ dict.label }}
           </el-radio>
@@ -40,7 +52,7 @@
           <el-radio
             v-for="dict in getIntDictOptions(DICT_TYPE.PROMOTION_DISCOUNT_TYPE)"
             :key="dict.value"
-            :label="dict.value"
+            :value="dict.value"
           >
             {{ dict.label }}
           </el-radio>
@@ -57,7 +69,6 @@
           :precision="2"
           class="mr-2 !w-400px"
           placeholder="请输入优惠金额,单位:元"
-          @change="discountLimitPriceChange"
         />
       </el-form-item>
@@ -91,26 +102,20 @@
       </el-form-item>
       <el-form-item label="满多少元可以使用" prop="usePrice">
-        <!-- <el-input-number
+        <el-input-number
           v-model="formData.usePrice"
           :min="0"
           :precision="2"
           class="mr-2 !w-400px"
           placeholder="无门槛请设为 0"
-        /> -->
-        <el-input-number
-          v-model="formData.usePrice"
-          :min="usePriceMin || 0"
-          :precision="2"
-          class="mr-2 !w-400px"
-          placeholder="不得少于优惠券面额"
         />
       </el-form-item>
       <el-form-item label="领取方式" prop="takeType">
         <el-radio-group v-model="formData.takeType">
-          <el-radio :key="1" :label="1">直接领取</el-radio>
-          <el-radio :key="2" :label="2">指定发放</el-radio>
+          <el-radio :key="1" :value="1">直接领取</el-radio>
+          <el-radio :key="2" :value="2">指定发放</el-radio>
+          <el-radio :key="2" :value="3">新人劵</el-radio>
         </el-radio-group>
       </el-form-item>
       <el-form-item v-if="formData.takeType === 1" label="发放数量" prop="totalCount">
@@ -138,7 +143,7 @@
           <el-radio
             v-for="dict in getIntDictOptions(DICT_TYPE.PROMOTION_COUPON_TEMPLATE_VALIDITY_TYPE)"
             :key="dict.value"
-            :label="dict.value"
+            :value="dict.value"
           >
             {{ dict.label }}
           </el-radio>
@@ -152,7 +157,6 @@
         <el-date-picker
           v-model="formData.validTimes"
           :default-time="[new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 2, 1, 23, 59, 59)]"
-          style="width: 240px"
           type="datetimerange"
           value-format="x"
         />
@@ -226,6 +230,7 @@ const formData = ref({
   fixedStartTerm: undefined,
   fixedEndTerm: undefined,
   productScope: PromotionProductScopeEnum.ALL.scope,
+  description: undefined,
   productScopeValues: [], // 商品范围:值为 品类编号列表 或 商品编号列表 ,用于提交
   productCategoryIds: [], // 仅用于表单,不提交
   productSpuIds: [] // 仅用于表单,不提交
@@ -305,7 +310,9 @@ const submitForm = async () => {
       validEndTime:
         formData.value.validTimes && formData.value.validTimes.length === 2
           ? formData.value.validTimes[1]
-          : undefined
+          : undefined,
+      totalCount: formData.value.takeType === 1 ? formData.value.totalCount : -1,
+      takeLimitCount: formData.value.takeType === 1 ? formData.value.takeLimitCount : -1
     } as unknown as CouponTemplateApi.CouponTemplateVO
 
     // 设置商品范围
@@ -331,6 +338,7 @@ const resetForm = () => {
   formData.value = {
     id: undefined,
     name: undefined,
+    description: undefined,
     discountType: PromotionDiscountTypeEnum.PRICE.type,
     discountPrice: undefined,
     discountPercent: undefined,
@@ -391,15 +399,6 @@ function setProductScopeValues(data: CouponTemplateApi.CouponTemplateVO) {
       break
   }
 }
-
-// 优惠卷金额
-const usePriceMin = ref(null)
-const discountLimitPriceChange = (val) => {
-  const usePrice = formData.value.usePrice
-  if (val && (!usePrice || (val >= usePrice))) {
-    formData.value.usePrice = val + 0.01
-  }
-}
 </script>
 
 <style lang="scss" scoped></style>

+ 7 - 2
src/views/mall/promotion/coupon/template/index.vue

@@ -1,5 +1,4 @@
 <template>
-
   <!-- 搜索工作栏 -->
   <ContentWrap>
     <el-form
@@ -108,7 +107,12 @@
         prop="validityType"
         width="185"
       />
-      <el-table-column align="center" label="发放数量" prop="totalCount" />
+      <el-table-column
+        :formatter="totalCountFormat"
+        align="center"
+        label="发放数量"
+        prop="totalCount"
+      />
       <el-table-column
         :formatter="remainedCountFormat"
         align="center"
@@ -188,6 +192,7 @@ import {
   discountFormat,
   remainedCountFormat,
   takeLimitCountFormat,
+  totalCountFormat,
   validityTypeFormat
 } from '@/views/mall/promotion/coupon/formatter'
 

+ 108 - 26
src/views/mall/promotion/discountActivity/DiscountActivityForm.vue

@@ -8,26 +8,40 @@
       :schema="allSchemas.formSchema"
     >
       <!-- 先选择 -->
-      <!-- TODO @zhangshuai:商品允许选择多个 -->
-      <!-- TODO @zhangshuai:选择后的 SKU,需要后面加个【删除】按钮 -->
-      <!-- TODO @zhangshuai:展示的金额,貌似不对,大了 100 倍,需要看下 -->
-      <!-- TODO @zhangshuai:“优惠类型”,是每个 SKU 可以自定义已设置哈。因为每个商品 SKU 的折扣和减少价格,可能不同。具体交互,可以注册一个 youzan.com 看看;它的交互方式是,如果设置了“优惠金额”,则算“减价”;如果再次设置了“折扣百分比”,就算“打折”;这样形成一个互斥的优惠类型 -->
       <template #spuId>
         <el-button @click="spuSelectRef.open()">选择商品</el-button>
         <SpuAndSkuList
           ref="spuAndSkuListRef"
+          :deletable="true"
           :rule-config="ruleConfig"
           :spu-list="spuList"
           :spu-property-list-p="spuPropertyList"
+          @delete="deleteSpu"
         >
           <el-table-column align="center" label="优惠金额" min-width="168">
-            <template #default="{ row: sku }">
-              <el-input-number v-model="sku.productConfig.discountPrice" :min="0" class="w-100%" />
+            <template #default="{ row }">
+              <el-input-number
+                v-model="row.productConfig.discountPrice"
+                :max="parseFloat(fenToYuan(row.price))"
+                :min="0"
+                :precision="2"
+                :step="0.1"
+                class="w-100%"
+                @change="handleSkuDiscountPriceChange(row)"
+              />
             </template>
           </el-table-column>
           <el-table-column align="center" label="折扣百分比(%)" min-width="168">
-            <template #default="{ row: sku }">
-              <el-input-number v-model="sku.productConfig.discountPercent" class="w-100%" />
+            <template #default="{ row }">
+              <el-input-number
+                v-model="row.productConfig.discountPercent"
+                :max="100"
+                :min="0"
+                :precision="2"
+                :step="0.1"
+                class="w-100%"
+                @change="handleSkuDiscountPercentChange(row)"
+              />
             </template>
           </el-table-column>
         </SpuAndSkuList>
@@ -43,10 +57,12 @@
 <script lang="ts" setup>
 import { SpuAndSkuList, SpuProperty, SpuSelect } from '../components'
 import { allSchemas, rules } from './discountActivity.data'
-import { cloneDeep } from 'lodash-es'
+import { cloneDeep, debounce } from 'lodash-es'
 import * as DiscountActivityApi from '@/api/mall/promotion/discount/discountActivity'
 import * as ProductSpuApi from '@/api/mall/product/spu'
 import { getPropertyList, RuleConfig } from '@/views/mall/product/spu/components'
+import { convertToInteger, erpCalculatePercentage, fenToYuan, yuanToFen } from '@/utils'
+import { PromotionDiscountTypeEnum } from '@/utils/constants'
 
 defineOptions({ name: 'PromotionDiscountActivityForm' })
 
@@ -62,11 +78,17 @@ const formRef = ref() // 表单 Ref
 
 const spuSelectRef = ref() // 商品和属性选择 Ref
 const spuAndSkuListRef = ref() // sku 限时折扣  配置组件Ref
-const ruleConfig: RuleConfig[] = []
+const ruleConfig: RuleConfig[] = [
+  {
+    name: 'productConfig.discountPrice',
+    rule: (arg) => arg > 0,
+    message: '商品优惠金额不能为 0 !!!'
+  }
+]
 const spuList = ref<DiscountActivityApi.SpuExtension[]>([]) // 选择的 spu
 const spuPropertyList = ref<SpuProperty<DiscountActivityApi.SpuExtension>[]>([])
+const spuIds = ref<number[]>([])
 const selectSpu = (spuId: number, skuIds: number[]) => {
-  formRef.value.setValues({ spuId })
   getSpuDetails(spuId, skuIds)
 }
 /**
@@ -75,14 +97,22 @@ const selectSpu = (spuId: number, skuIds: number[]) => {
 const getSpuDetails = async (
   spuId: number,
   skuIds: number[] | undefined,
-  products?: DiscountActivityApi.DiscountProductVO[]
+  products?: DiscountActivityApi.DiscountProductVO[],
+  type?: string
 ) => {
-  const spuProperties: SpuProperty<DiscountActivityApi.SpuExtension>[] = []
+  // 如果已经包含 SPU 则跳过
+  if (spuIds.value.includes(spuId)) {
+    if (type !== 'load') {
+      message.error('数据重复选择!')
+    }
+    return
+  }
+  spuIds.value.push(spuId)
   const res = (await ProductSpuApi.getSpuDetailList([spuId])) as DiscountActivityApi.SpuExtension[]
   if (res.length == 0) {
     return
   }
-  spuList.value = []
+  //spuList.value = []
   // 因为只能选择一个
   const spu = res[0]
   const selectSkus =
@@ -90,25 +120,28 @@ const getSpuDetails = async (
   selectSkus?.forEach((sku) => {
     let config: DiscountActivityApi.DiscountProductVO = {
       skuId: sku.id!,
-      spuId: spu.id,
+      spuId: spu.id!,
       discountType: 1,
       discountPercent: 0,
       discountPrice: 0
     }
     if (typeof products !== 'undefined') {
       const product = products.find((item) => item.skuId === sku.id)
+      if (product) {
+        product.discountPercent = fenToYuan(product.discountPercent) as any
+        product.discountPrice = fenToYuan(product.discountPrice) as any
+      }
       config = product || config
     }
     sku.productConfig = config
   })
   spu.skus = selectSkus as DiscountActivityApi.SkuExtension[]
-  spuProperties.push({
+  spuPropertyList.value.push({
     spuId: spu.id!,
     spuDetail: spu,
     propertyList: getPropertyList(spu)
   })
   spuList.value.push(spu)
-  spuPropertyList.value = spuProperties
 }
 
 // ================= end =================
@@ -126,12 +159,15 @@ const open = async (type: string, id?: number) => {
       const data = (await DiscountActivityApi.getDiscountActivity(
         id
       )) as DiscountActivityApi.DiscountActivityVO
-      const supId = data.products[0].spuId
-      await getSpuDetails(
-        supId!,
-        data.products?.map((sku) => sku.skuId),
-        data.products
-      )
+      for (let productsKey in data.products) {
+        const supId = data.products[productsKey].spuId
+        await getSpuDetails(
+          supId!,
+          data.products?.map((sku) => sku.skuId),
+          data.products,
+          'load'
+        )
+      }
       formRef.value.setValues(data)
     } finally {
       formLoading.value = false
@@ -150,12 +186,13 @@ const submitForm = async () => {
   // 提交请求
   formLoading.value = true
   try {
-    const data = formRef.value.formModel as DiscountActivityApi.DiscountActivityVO
-    // 获取 折扣商品配置
+    // 获取折扣商品配置
     const products = cloneDeep(spuAndSkuListRef.value.getSkuConfigs('productConfig'))
     products.forEach((item: DiscountActivityApi.DiscountProductVO) => {
-      item.discountType = data['discountType']
+      item.discountPercent = convertToInteger(item.discountPercent)
+      item.discountPrice = convertToInteger(item.discountPrice)
     })
+    const data = cloneDeep(formRef.value.formModel) as DiscountActivityApi.DiscountActivityVO
     data.products = products
     // 真正提交
     if (formType.value === 'create') {
@@ -173,11 +210,56 @@ const submitForm = async () => {
   }
 }
 
+/** 处理 sku 优惠金额变动 */
+const handleSkuDiscountPriceChange = debounce((row: any) => {
+  // 校验边界
+  if (row.productConfig.discountPrice <= 0) {
+    return
+  }
+
+  // 设置优惠类型:满减
+  row.productConfig.discountType = PromotionDiscountTypeEnum.PRICE.type
+  // 设置折扣
+  row.productConfig.discountPercent = erpCalculatePercentage(
+    row.price - yuanToFen(row.productConfig.discountPrice),
+    row.price
+  )
+}, 200)
+/** 处理 sku 优惠折扣变动 */
+const handleSkuDiscountPercentChange = debounce((row: any) => {
+  // 校验边界
+  if (row.productConfig.discountPercent <= 0 || row.productConfig.discountPercent >= 100) {
+    return
+  }
+
+  // 设置优惠类型:折扣
+  row.productConfig.discountType = PromotionDiscountTypeEnum.PERCENT.type
+  // 设置满减金额
+  row.productConfig.discountPrice = fenToYuan(
+    row.price - row.price * (row.productConfig.discountPercent / 100.0 || 0)
+  )
+}, 200)
+
 /** 重置表单 */
 const resetForm = async () => {
   spuList.value = []
   spuPropertyList.value = []
+  spuIds.value = []
   await nextTick()
   formRef.value.getElFormRef().resetFields()
 }
+
+/**
+ * 删除 SPU
+ */
+const deleteSpu = (spuId: number) => {
+  spuIds.value.splice(
+    spuIds.value.findIndex((item) => item == spuId),
+    1
+  )
+  spuPropertyList.value.splice(
+    spuPropertyList.value.findIndex((item) => item.spuId == spuId),
+    1
+  )
+}
 </script>

+ 0 - 13
src/views/mall/promotion/discountActivity/discountActivity.data.ts

@@ -1,10 +1,8 @@
 import type { CrudSchema } from '@/hooks/web/useCrudSchemas'
 import { dateFormatter2 } from '@/utils/formatTime'
 
-// TODO @zhangshai:
 // 表单校验
 export const rules = reactive({
-  spuId: [required],
   name: [required],
   startTime: [required],
   endTime: [required],
@@ -72,17 +70,6 @@ const crudSchemas = reactive<CrudSchema[]>([
       width: 120
     }
   },
-  {
-    label: '优惠类型',
-    field: 'discountType',
-    dictType: DICT_TYPE.PROMOTION_DISCOUNT_TYPE,
-    dictClass: 'number',
-    isSearch: true,
-    form: {
-      component: 'Radio',
-      value: 1
-    }
-  },
   {
     label: '活动商品',
     field: 'spuId',

+ 11 - 12
src/views/mall/promotion/discountActivity/index.vue

@@ -1,5 +1,4 @@
 <template>
-
   <ContentWrap>
     <!-- 搜索工作栏 -->
     <el-form
@@ -69,17 +68,17 @@
           ~ {{ formatDate(scope.row.endTime, 'YYYY-MM-DD') }}
         </template>
       </el-table-column>
-      <el-table-column label="商品图片" prop="spuName" min-width="80">
-        <template #default="scope">
-          <el-image
-            :src="scope.row.picUrl"
-            class="h-40px w-40px"
-            :preview-src-list="[scope.row.picUrl]"
-            preview-teleported
-          />
-        </template>
-      </el-table-column>
-      <el-table-column label="商品标题" prop="spuName" min-width="300" />
+<!--      <el-table-column label="商品图片" prop="spuName" min-width="80">-->
+<!--        <template #default="scope">-->
+<!--          <el-image-->
+<!--            :src="scope.row.picUrl"-->
+<!--            class="h-40px w-40px"-->
+<!--            :preview-src-list="[scope.row.picUrl]"-->
+<!--            preview-teleported-->
+<!--          />-->
+<!--        </template>-->
+<!--      </el-table-column>-->
+<!--      <el-table-column label="商品标题" prop="spuName" min-width="300" />-->
       <el-table-column label="活动状态" align="center" prop="status" min-width="100">
         <template #default="scope">
           <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />

+ 0 - 1
src/views/mall/promotion/diy/page/index.vue

@@ -1,5 +1,4 @@
 <template>
-
   <ContentWrap>
     <!-- 搜索工作栏 -->
     <el-form

+ 1 - 1
src/views/mall/promotion/diy/template/decorate.vue

@@ -18,7 +18,7 @@
         @change="handleTemplateItemChange"
       >
         <el-tooltip v-for="(item, index) in templateItems" :key="index" :content="item.name">
-          <el-radio-button :label="index">
+          <el-radio-button :value="index">
             <Icon :icon="item.icon" :size="24" />
           </el-radio-button>
         </el-tooltip>

+ 0 - 1
src/views/mall/promotion/diy/template/index.vue

@@ -1,5 +1,4 @@
 <template>
-
   <ContentWrap>
     <!-- 搜索工作栏 -->
     <el-form

+ 254 - 0
src/views/mall/promotion/kefu/components/KeFuConversationList.vue

@@ -0,0 +1,254 @@
+<template>
+  <el-aside class="kefu pt-5px h-100%" width="260px">
+    <div class="color-[#999] font-bold my-10px">
+      会话记录({{ kefuStore.getConversationList.length }})
+    </div>
+    <div
+      v-for="item in kefuStore.getConversationList"
+      :key="item.id"
+      :class="{ active: item.id === activeConversationId, pinned: item.adminPinned }"
+      class="kefu-conversation px-10px flex items-center"
+      @click="openRightMessage(item)"
+      @contextmenu.prevent="rightClick($event as PointerEvent, item)"
+    >
+      <div class="flex justify-center items-center w-100%">
+        <div class="flex justify-center items-center w-50px h-50px">
+          <!-- 头像 + 未读 -->
+          <el-badge
+            :hidden="item.adminUnreadMessageCount === 0"
+            :max="99"
+            :value="item.adminUnreadMessageCount"
+          >
+            <el-avatar :src="item.userAvatar" alt="avatar" />
+          </el-badge>
+        </div>
+        <div class="ml-10px w-100%">
+          <div class="flex justify-between items-center w-100%">
+            <span class="username">{{ item.userNickname }}</span>
+            <span class="color-[#999]" style="font-size: 13px">
+              {{ lastMessageTimeMap.get(item.id) ?? '计算中' }}
+            </span>
+          </div>
+          <!-- 最后聊天内容 -->
+          <div
+            v-dompurify-html="
+              getConversationDisplayText(item.lastMessageContentType, item.lastMessageContent)
+            "
+            class="last-message flex items-center color-[#999]"
+          >
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <!-- 右键,进行操作(类似微信) -->
+    <ul v-show="showRightMenu" :style="rightMenuStyle" class="right-menu-ul">
+      <li
+        v-show="!rightClickConversation.adminPinned"
+        class="flex items-center"
+        @click.stop="updateConversationPinned(true)"
+      >
+        <Icon class="mr-5px" icon="ep:top" />
+        置顶会话
+      </li>
+      <li
+        v-show="rightClickConversation.adminPinned"
+        class="flex items-center"
+        @click.stop="updateConversationPinned(false)"
+      >
+        <Icon class="mr-5px" icon="ep:bottom" />
+        取消置顶
+      </li>
+      <li class="flex items-center" @click.stop="deleteConversation">
+        <Icon class="mr-5px" color="red" icon="ep:delete" />
+        删除会话
+      </li>
+      <li class="flex items-center" @click.stop="closeRightMenu">
+        <Icon class="mr-5px" color="red" icon="ep:close" />
+        取消
+      </li>
+    </ul>
+  </el-aside>
+</template>
+
+<script lang="ts" setup>
+import { KeFuConversationApi, KeFuConversationRespVO } from '@/api/mall/promotion/kefu/conversation'
+import { useEmoji } from './tools/emoji'
+import { formatPast } from '@/utils/formatTime'
+import { KeFuMessageContentTypeEnum } from './tools/constants'
+import { useAppStore } from '@/store/modules/app'
+import { useMallKefuStore } from '@/store/modules/mall/kefu'
+import { jsonParse } from '@/utils'
+
+defineOptions({ name: 'KeFuConversationList' })
+
+const message = useMessage() // 消息弹窗
+const appStore = useAppStore()
+const kefuStore = useMallKefuStore() // 客服缓存
+const { replaceEmoji } = useEmoji()
+const activeConversationId = ref(-1) // 选中的会话
+const collapse = computed(() => appStore.getCollapse) // 折叠菜单
+
+/** 计算消息最后发送时间距离现在过去了多久 */
+const lastMessageTimeMap = ref<Map<number, string>>(new Map<number, string>())
+const calculationLastMessageTime = () => {
+  kefuStore.getConversationList?.forEach((item) => {
+    lastMessageTimeMap.value.set(item.id, formatPast(item.lastMessageTime, 'YYYY-MM-DD'))
+  })
+}
+defineExpose({ calculationLastMessageTime })
+
+/** 打开右侧的消息列表 */
+const emits = defineEmits<{
+  (e: 'change', v: KeFuConversationRespVO): void
+}>()
+const openRightMessage = (item: KeFuConversationRespVO) => {
+  // 同一个会话则不处理
+  if (activeConversationId.value === item.id) {
+    return
+  }
+  activeConversationId.value = item.id
+  emits('change', item)
+}
+
+/** 获得消息类型 */
+const getConversationDisplayText = computed(
+  () => (lastMessageContentType: number, lastMessageContent: string) => {
+    switch (lastMessageContentType) {
+      case KeFuMessageContentTypeEnum.SYSTEM:
+        return '[系统消息]'
+      case KeFuMessageContentTypeEnum.VIDEO:
+        return '[视频消息]'
+      case KeFuMessageContentTypeEnum.IMAGE:
+        return '[图片消息]'
+      case KeFuMessageContentTypeEnum.PRODUCT:
+        return '[商品消息]'
+      case KeFuMessageContentTypeEnum.ORDER:
+        return '[订单消息]'
+      case KeFuMessageContentTypeEnum.VOICE:
+        return '[语音消息]'
+      case KeFuMessageContentTypeEnum.TEXT:
+        return replaceEmoji(jsonParse(lastMessageContent).text || lastMessageContent)
+      default:
+        return ''
+    }
+  }
+)
+
+//======================= 右键菜单 =======================
+const showRightMenu = ref(false) // 显示右键菜单
+const rightMenuStyle = ref<any>({}) // 右键菜单 Style
+const rightClickConversation = ref<KeFuConversationRespVO>({} as KeFuConversationRespVO) // 右键选中的会话对象
+
+/** 打开右键菜单 */
+const rightClick = (mouseEvent: PointerEvent, item: KeFuConversationRespVO) => {
+  rightClickConversation.value = item
+  // 显示右键菜单
+  showRightMenu.value = true
+  rightMenuStyle.value = {
+    top: mouseEvent.clientY - 110 + 'px',
+    left: collapse.value ? mouseEvent.clientX - 80 + 'px' : mouseEvent.clientX - 210 + 'px'
+  }
+}
+/** 关闭右键菜单 */
+const closeRightMenu = () => {
+  showRightMenu.value = false
+}
+
+/** 置顶会话 */
+const updateConversationPinned = async (adminPinned: boolean) => {
+  // 1. 会话置顶/取消置顶
+  await KeFuConversationApi.updateConversationPinned({
+    id: rightClickConversation.value.id,
+    adminPinned
+  })
+  message.notifySuccess(adminPinned ? '置顶成功' : '取消置顶成功')
+  // 2. 关闭右键菜单,更新会话列表
+  closeRightMenu()
+  await kefuStore.updateConversation(rightClickConversation.value.id)
+}
+
+/** 删除会话 */
+const deleteConversation = async () => {
+  // 1. 删除会话
+  await message.confirm('您确定要删除该会话吗?')
+  await KeFuConversationApi.deleteConversation(rightClickConversation.value.id)
+  // 2. 关闭右键菜单,更新会话列表
+  closeRightMenu()
+  kefuStore.deleteConversation(rightClickConversation.value.id)
+}
+
+/** 监听右键菜单的显示状态,添加点击事件监听器 */
+watch(showRightMenu, (val) => {
+  if (val) {
+    document.body.addEventListener('click', closeRightMenu)
+  } else {
+    document.body.removeEventListener('click', closeRightMenu)
+  }
+})
+
+const timer = ref<any>()
+/** 初始化 */
+onMounted(() => {
+  timer.value = setInterval(calculationLastMessageTime, 1000 * 10) // 十秒计算一次
+})
+/** 组件卸载前 */
+onBeforeUnmount(() => {
+  clearInterval(timer.value)
+})
+</script>
+
+<style lang="scss" scoped>
+.kefu {
+  background-color: #e5e4e4;
+
+  &-conversation {
+    height: 60px;
+    //background-color: #fff;
+    //transition: border-left 0.05s ease-in-out; /* 设置过渡效果 */
+
+    .username {
+      min-width: 0;
+      max-width: 60%;
+    }
+
+    .last-message {
+      font-size: 13px;
+    }
+
+    .last-message,
+    .username {
+      overflow: hidden;
+      text-overflow: ellipsis;
+      display: -webkit-box;
+      -webkit-box-orient: vertical;
+      -webkit-line-clamp: 1;
+    }
+  }
+
+  .active {
+    background-color: rgba(128, 128, 128, 0.5); // 透明色,暗黑模式下也能体现
+  }
+
+  .right-menu-ul {
+    position: absolute;
+    background-color: var(--app-content-bg-color);
+    padding: 5px;
+    margin: 0;
+    list-style-type: none; /* 移除默认的项目符号 */
+    border-radius: 12px;
+    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); /* 阴影效果 */
+    width: 130px;
+
+    li {
+      padding: 8px 16px;
+      cursor: pointer;
+      border-radius: 12px;
+      transition: background-color 0.3s; /* 平滑过渡 */
+      &:hover {
+        background-color: var(--left-menu-bg-active-color); /* 悬停时的背景颜色 */
+      }
+    }
+  }
+}
+</style>

+ 525 - 0
src/views/mall/promotion/kefu/components/KeFuMessageList.vue

@@ -0,0 +1,525 @@
+<template>
+  <el-container v-if="showKeFuMessageList" class="kefu">
+    <el-header class="kefu-header">
+      <div class="kefu-title">{{ conversation.userNickname }}</div>
+    </el-header>
+    <el-main class="kefu-content overflow-visible">
+      <el-scrollbar ref="scrollbarRef" always @scroll="handleScroll">
+        <div v-if="refreshContent" ref="innerRef" class="w-[100%] px-10px">
+          <!-- 消息列表 -->
+          <div v-for="(item, index) in getMessageList0" :key="item.id" class="w-[100%]">
+            <div class="flex justify-center items-center mb-20px">
+              <!-- 日期 -->
+              <div
+                v-if="
+                  item.contentType !== KeFuMessageContentTypeEnum.SYSTEM && showTime(item, index)
+                "
+                class="date-message"
+              >
+                {{ formatDate(item.createTime) }}
+              </div>
+              <!-- 系统消息 -->
+              <div
+                v-if="item.contentType === KeFuMessageContentTypeEnum.SYSTEM"
+                class="system-message"
+              >
+                {{ item.content }}
+              </div>
+            </div>
+            <div
+              :class="[
+                item.senderType === UserTypeEnum.MEMBER
+                  ? `ss-row-left`
+                  : item.senderType === UserTypeEnum.ADMIN
+                    ? `ss-row-right`
+                    : ''
+              ]"
+              class="flex mb-20px w-[100%]"
+            >
+              <el-avatar
+                v-if="item.senderType === UserTypeEnum.MEMBER"
+                :src="conversation.userAvatar"
+                alt="avatar"
+                class="w-60px h-60px"
+              />
+              <div
+                :class="{
+                  'kefu-message': KeFuMessageContentTypeEnum.TEXT === item.contentType
+                }"
+              >
+                <!-- 文本消息 -->
+                <MessageItem :message="item">
+                  <template v-if="KeFuMessageContentTypeEnum.TEXT === item.contentType">
+                    <div
+                      v-dompurify-html="replaceEmoji(getMessageContent(item).text || item.content)"
+                      class="line-height-normal text-justify h-1/1 w-full"
+                    ></div>
+                  </template>
+                </MessageItem>
+                <!-- 图片消息 -->
+                <MessageItem :message="item">
+                  <el-image
+                    v-if="KeFuMessageContentTypeEnum.IMAGE === item.contentType"
+                    :initial-index="0"
+                    :preview-src-list="[getMessageContent(item).picUrl || item.content]"
+                    :src="getMessageContent(item).picUrl || item.content"
+                    class="w-200px mx-10px"
+                    fit="contain"
+                    preview-teleported
+                  />
+                </MessageItem>
+                <!-- 商品消息 -->
+                <MessageItem :message="item">
+                  <ProductItem
+                    v-if="KeFuMessageContentTypeEnum.PRODUCT === item.contentType"
+                    :picUrl="getMessageContent(item).picUrl"
+                    :price="getMessageContent(item).price"
+                    :sales-count="getMessageContent(item).salesCount"
+                    :spuId="getMessageContent(item).spuId"
+                    :stock="getMessageContent(item).stock"
+                    :title="getMessageContent(item).spuName"
+                    class="max-w-300px mx-10px"
+                  />
+                </MessageItem>
+                <!-- 订单消息 -->
+                <MessageItem :message="item">
+                  <OrderItem
+                    v-if="KeFuMessageContentTypeEnum.ORDER === item.contentType"
+                    :message="item"
+                    class="max-w-100% mx-10px"
+                  />
+                </MessageItem>
+              </div>
+              <el-avatar
+                v-if="item.senderType === UserTypeEnum.ADMIN"
+                :src="item.senderAvatar"
+                alt="avatar"
+              />
+            </div>
+          </div>
+        </div>
+      </el-scrollbar>
+      <div
+        v-show="showNewMessageTip"
+        class="newMessageTip flex items-center cursor-pointer"
+        @click="handleToNewMessage"
+      >
+        <span>有新消息</span>
+        <Icon class="ml-5px" icon="ep:bottom" />
+      </div>
+    </el-main>
+    <el-footer class="kefu-footer">
+      <div class="chat-tools flex items-center">
+        <EmojiSelectPopover @select-emoji="handleEmojiSelect" />
+        <PictureSelectUpload
+          class="ml-15px mt-3px cursor-pointer"
+          @send-picture="handleSendPicture"
+        />
+      </div>
+      <el-input
+        v-model="message"
+        :rows="6"
+        placeholder="输入消息,Enter发送,Shift+Enter换行"
+        style="border-style: none"
+        type="textarea"
+        @keyup.enter.prevent="handleSendMessage"
+      />
+    </el-footer>
+  </el-container>
+  <el-container v-else class="kefu">
+    <el-main>
+      <el-empty description="请选择左侧的一个会话后开始" />
+    </el-main>
+  </el-container>
+</template>
+
+<script lang="ts" setup>
+import { ElScrollbar as ElScrollbarType } from 'element-plus'
+import { KeFuMessageApi, KeFuMessageRespVO } from '@/api/mall/promotion/kefu/message'
+import { KeFuConversationRespVO } from '@/api/mall/promotion/kefu/conversation'
+import EmojiSelectPopover from './tools/EmojiSelectPopover.vue'
+import PictureSelectUpload from './tools/PictureSelectUpload.vue'
+import ProductItem from './message/ProductItem.vue'
+import OrderItem from './message/OrderItem.vue'
+import { Emoji, useEmoji } from './tools/emoji'
+import { KeFuMessageContentTypeEnum } from './tools/constants'
+import { isEmpty } from '@/utils/is'
+import { UserTypeEnum } from '@/utils/constants'
+import { formatDate } from '@/utils/formatTime'
+import dayjs from 'dayjs'
+import relativeTime from 'dayjs/plugin/relativeTime'
+import { debounce } from 'lodash-es'
+import { jsonParse } from '@/utils'
+import { useMallKefuStore } from '@/store/modules/mall/kefu'
+
+dayjs.extend(relativeTime)
+
+defineOptions({ name: 'KeFuMessageList' })
+
+const message = ref('') // 消息弹窗
+const { replaceEmoji } = useEmoji()
+const messageTool = useMessage()
+const messageList = ref<KeFuMessageRespVO[]>([]) // 消息列表
+const conversation = ref<KeFuConversationRespVO>({} as KeFuConversationRespVO) // 用户会话
+const showNewMessageTip = ref(false) // 显示有新消息提示
+const queryParams = reactive({
+  conversationId: 0,
+  createTime: undefined
+})
+const total = ref(0) // 消息总条数
+const refreshContent = ref(false) // 内容刷新,主要解决会话消息页面高度不一致导致的滚动功能精度失效
+const kefuStore = useMallKefuStore() // 客服缓存
+
+/** 获悉消息内容 */
+const getMessageContent = computed(() => (item: any) => jsonParse(item.content))
+/** 获得消息列表 */
+const getMessageList = async () => {
+  const res = await KeFuMessageApi.getKeFuMessageList(queryParams)
+  if (isEmpty(res)) {
+    // 当返回的是空列表说明没有消息或者已经查询完了历史消息
+    skipGetMessageList.value = true
+    return
+  }
+  queryParams.createTime = formatDate(res.at(-1).createTime) as any
+
+  // 情况一:加载最新消息
+  if (!queryParams.createTime) {
+    messageList.value = res
+  } else {
+    // 情况二:加载历史消息
+    for (const item of res) {
+      pushMessage(item)
+    }
+  }
+  refreshContent.value = true
+}
+
+/** 添加消息 */
+const pushMessage = (message: any) => {
+  if (messageList.value.some((val) => val.id === message.id)) {
+    return
+  }
+  messageList.value.push(message)
+}
+
+/** 按照时间倒序,获取消息列表 */
+const getMessageList0 = computed(() => {
+  messageList.value.sort((a: any, b: any) => a.createTime - b.createTime)
+  return messageList.value
+})
+
+/** 刷新消息列表 */
+const refreshMessageList = async (message?: any) => {
+  if (!conversation.value) {
+    return
+  }
+
+  if (typeof message !== 'undefined') {
+    // 当前查询会话与消息所属会话不一致则不做处理
+    if (message.conversationId !== conversation.value.id) {
+      return
+    }
+    pushMessage(message)
+  } else {
+    queryParams.createTime = undefined
+    await getMessageList()
+  }
+
+  if (loadHistory.value) {
+    // 右下角显示有新消息提示
+    showNewMessageTip.value = true
+  } else {
+    // 滚动到最新消息处
+    await handleToNewMessage()
+  }
+}
+
+/** 获得新会话的消息列表, 点击切换时,读取缓存;然后异步获取新消息,merge 下; */
+const getNewMessageList = async (val: KeFuConversationRespVO) => {
+  // 1. 缓存当前会话消息列表
+  kefuStore.saveMessageList(conversation.value.id, messageList.value)
+  // 2.1 会话切换,重置相关参数
+  messageList.value = kefuStore.getConversationMessageList(val.id) || []
+  total.value = messageList.value.length || 0
+  loadHistory.value = false
+  refreshContent.value = false
+  skipGetMessageList.value = false
+  // 2.2 设置会话相关属性
+  conversation.value = val
+  queryParams.conversationId = val.id
+  queryParams.createTime = undefined
+  // 3. 获取消息
+  await refreshMessageList()
+}
+defineExpose({ getNewMessageList, refreshMessageList })
+
+const showKeFuMessageList = computed(() => !isEmpty(conversation.value)) // 是否显示聊天区域
+const skipGetMessageList = ref(false) // 跳过消息获取
+
+/** 处理表情选择 */
+const handleEmojiSelect = (item: Emoji) => {
+  message.value += item.name
+}
+
+/** 处理图片发送 */
+const handleSendPicture = async (picUrl: string) => {
+  // 组织发送消息
+  const msg = {
+    conversationId: conversation.value.id,
+    contentType: KeFuMessageContentTypeEnum.IMAGE,
+    content: JSON.stringify({ picUrl })
+  }
+  await sendMessage(msg)
+}
+
+/** 发送文本消息 */
+const handleSendMessage = async (event: any) => {
+  // shift 不发送
+  if (event.shiftKey) {
+    return
+  }
+  // 1. 校验消息是否为空
+  if (isEmpty(unref(message.value))) {
+    messageTool.notifyWarning('请输入消息后再发送哦!')
+    return
+  }
+  // 2. 组织发送消息
+  const msg = {
+    conversationId: conversation.value.id,
+    contentType: KeFuMessageContentTypeEnum.TEXT,
+    content: JSON.stringify({ text: message.value })
+  }
+  await sendMessage(msg)
+}
+
+/** 真正发送消息 【共用】*/
+const sendMessage = async (msg: any) => {
+  // 发送消息
+  await KeFuMessageApi.sendKeFuMessage(msg)
+  message.value = ''
+  // 加载消息列表
+  await refreshMessageList()
+  // 更新会话缓存
+  await kefuStore.updateConversation(conversation.value.id)
+}
+
+/** 滚动到底部 */
+const innerRef = ref<HTMLDivElement>()
+const scrollbarRef = ref<InstanceType<typeof ElScrollbarType>>()
+const scrollToBottom = async () => {
+  // 1. 首次加载时滚动到最新消息,如果加载的是历史消息则不滚动
+  if (loadHistory.value) {
+    return
+  }
+  // 2.1 滚动到最新消息,关闭新消息提示
+  await nextTick()
+  scrollbarRef.value!.setScrollTop(innerRef.value!.clientHeight)
+  showNewMessageTip.value = false
+  // 2.2 消息已读
+  await KeFuMessageApi.updateKeFuMessageReadStatus(conversation.value.id)
+}
+
+/** 查看新消息 */
+const handleToNewMessage = async () => {
+  loadHistory.value = false
+  await scrollToBottom()
+}
+
+const loadHistory = ref(false) // 加载历史消息
+/** 处理消息列表滚动事件(debounce 限流) */
+const handleScroll = debounce(({ scrollTop }) => {
+  if (skipGetMessageList.value) {
+    return
+  }
+  // 触顶自动加载下一页数据
+  if (Math.floor(scrollTop) === 0) {
+    handleOldMessage()
+  }
+  const wrap = scrollbarRef.value?.wrapRef
+  // 触底重置
+  if (Math.abs(wrap!.scrollHeight - wrap!.clientHeight - wrap!.scrollTop) < 1) {
+    loadHistory.value = false
+    refreshMessageList()
+  }
+}, 200)
+/** 加载历史消息 */
+const handleOldMessage = async () => {
+  // 记录已有页面高度
+  const oldPageHeight = innerRef.value?.clientHeight
+  if (!oldPageHeight) {
+    return
+  }
+  loadHistory.value = true
+  await getMessageList()
+  // 等页面加载完后,获得上一页最后一条消息的位置,控制滚动到它所在位置
+  scrollbarRef.value!.setScrollTop(innerRef.value!.clientHeight - oldPageHeight)
+}
+
+/**
+ * 是否显示时间
+ *
+ * @param {*} item - 数据
+ * @param {*} index - 索引
+ */
+const showTime = computed(() => (item: KeFuMessageRespVO, index: number) => {
+  if (unref(messageList.value)[index + 1]) {
+    let dateString = dayjs(unref(messageList.value)[index + 1].createTime).fromNow()
+    return dateString !== dayjs(unref(item).createTime).fromNow()
+  }
+  return false
+})
+</script>
+
+<style lang="scss" scoped>
+.kefu {
+  background-color: #f5f5f5;
+  position: relative;
+  width: calc(100% - 300px - 260px);
+
+  &::after {
+    content: '';
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 1px; /* 实际宽度 */
+    height: 100%;
+    background-color: var(--el-border-color);
+    transform: scaleX(0.3); /* 缩小宽度 */
+  }
+
+  .kefu-header {
+    background-color: #f5f5f5;
+    position: relative;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+
+    &::before {
+      content: '';
+      position: absolute;
+      bottom: 0;
+      left: 0;
+      width: 100%;
+      height: 1px; /* 初始宽度 */
+      background-color: var(--el-border-color);
+      transform: scaleY(0.3); /* 缩小视觉高度 */
+    }
+
+    &-title {
+      font-size: 18px;
+      font-weight: bold;
+    }
+  }
+
+  &-content {
+    margin: 0;
+    padding: 10px;
+    position: relative;
+    height: 100%;
+    width: 100%;
+
+    .newMessageTip {
+      position: absolute;
+      bottom: 35px;
+      right: 35px;
+      background-color: var(--app-content-bg-color);
+      padding: 10px;
+      border-radius: 30px;
+      font-size: 12px;
+      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); /* 阴影效果 */
+    }
+
+    .ss-row-left {
+      justify-content: flex-start;
+
+      .kefu-message {
+        background-color: #fff;
+        margin-left: 10px;
+        margin-top: 3px;
+        border-top-right-radius: 10px;
+        border-bottom-right-radius: 10px;
+        border-bottom-left-radius: 10px;
+      }
+    }
+
+    .ss-row-right {
+      justify-content: flex-end;
+
+      .kefu-message {
+        background-color: rgb(206, 223, 255);
+        margin-right: 10px;
+        margin-top: 3px;
+        border-top-left-radius: 10px;
+        border-bottom-right-radius: 10px;
+        border-bottom-left-radius: 10px;
+      }
+    }
+
+    // 消息气泡
+    .kefu-message {
+      color: #414141;
+      font-weight: 500;
+      padding: 5px 10px;
+      width: auto;
+      max-width: 50%;
+      //text-align: left;
+      //display: inline-block !important;
+      //word-break: break-all;
+      transition: all 0.2s;
+
+      &:hover {
+        transform: scale(1.03);
+      }
+    }
+
+    .date-message,
+    .system-message {
+      width: fit-content;
+      background-color: rgba(0, 0, 0, 0.1);
+      border-radius: 8px;
+      padding: 0 5px;
+      color: #fff;
+      font-size: 10px;
+    }
+  }
+
+  .kefu-footer {
+    position: relative;
+    display: flex;
+    flex-direction: column;
+    height: auto;
+    margin: 0;
+    padding: 0;
+
+    &::before {
+      content: '';
+      position: absolute;
+      top: 0;
+      left: 0;
+      width: 100%;
+      height: 1px; /* 初始宽度 */
+      background-color: var(--el-border-color);
+      transform: scaleY(0.3); /* 缩小视觉高度 */
+    }
+
+    .chat-tools {
+      width: 100%;
+      height: 44px;
+    }
+  }
+
+  ::v-deep(textarea) {
+    resize: none;
+    background-color: #f5f5f5;
+  }
+
+  :deep(.el-input__wrapper) {
+    box-shadow: none !important;
+    border-radius: 0;
+  }
+
+  ::v-deep(.el-textarea__inner) {
+    box-shadow: none !important;
+  }
+}
+</style>

BIN
src/views/mall/promotion/kefu/components/asserts/a.png


BIN
src/views/mall/promotion/kefu/components/asserts/aini.png


BIN
src/views/mall/promotion/kefu/components/asserts/aixin.png


BIN
src/views/mall/promotion/kefu/components/asserts/baiyan.png


BIN
src/views/mall/promotion/kefu/components/asserts/bizui.png


BIN
src/views/mall/promotion/kefu/components/asserts/buhaoyisi.png


BIN
src/views/mall/promotion/kefu/components/asserts/bukesiyi.png


BIN
src/views/mall/promotion/kefu/components/asserts/dajing.png


BIN
src/views/mall/promotion/kefu/components/asserts/danao.png


BIN
src/views/mall/promotion/kefu/components/asserts/daxiao.png


BIN
src/views/mall/promotion/kefu/components/asserts/dianzan.png


BIN
src/views/mall/promotion/kefu/components/asserts/emo.png


BIN
src/views/mall/promotion/kefu/components/asserts/esi.png


BIN
src/views/mall/promotion/kefu/components/asserts/fadai.png


BIN
src/views/mall/promotion/kefu/components/asserts/fankun.png


BIN
src/views/mall/promotion/kefu/components/asserts/feiwen.png


BIN
src/views/mall/promotion/kefu/components/asserts/fennu.png


BIN
src/views/mall/promotion/kefu/components/asserts/ganga.png


BIN
src/views/mall/promotion/kefu/components/asserts/ganmao.png


BIN
src/views/mall/promotion/kefu/components/asserts/hanyan.png


BIN
src/views/mall/promotion/kefu/components/asserts/haochi.png


BIN
src/views/mall/promotion/kefu/components/asserts/hongxin.png


BIN
src/views/mall/promotion/kefu/components/asserts/huaixiao.png


BIN
src/views/mall/promotion/kefu/components/asserts/jingkong.png


BIN
src/views/mall/promotion/kefu/components/asserts/jingshu.png


BIN
src/views/mall/promotion/kefu/components/asserts/jingya.png


BIN
src/views/mall/promotion/kefu/components/asserts/kaixin.png


BIN
src/views/mall/promotion/kefu/components/asserts/keai.png


BIN
src/views/mall/promotion/kefu/components/asserts/keshui.png


BIN
src/views/mall/promotion/kefu/components/asserts/kun.png


BIN
src/views/mall/promotion/kefu/components/asserts/lengku.png


BIN
src/views/mall/promotion/kefu/components/asserts/liuhan.png


BIN
src/views/mall/promotion/kefu/components/asserts/liukoushui.png


BIN
src/views/mall/promotion/kefu/components/asserts/liulei.png


BIN
src/views/mall/promotion/kefu/components/asserts/mengbi.png


BIN
src/views/mall/promotion/kefu/components/asserts/mianwubiaoqing.png


BIN
src/views/mall/promotion/kefu/components/asserts/nanguo.png


Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor