Browse Source

!183 同步商城实现
Merge pull request !183 from 芋道源码/dev

芋道源码 1 year ago
parent
commit
f908c12963
60 changed files with 4985 additions and 538 deletions
  1. 3 3
      .env.dev
  2. 1 0
      package.json
  3. 18 8
      src/api/mall/product/spu.ts
  4. 18 0
      src/api/mall/promotion/coupon.ts
  5. 83 0
      src/api/mall/promotion/couponTemplate.ts
  6. 64 0
      src/api/mall/promotion/seckill/seckillActivity.ts
  7. 49 0
      src/api/mall/promotion/seckill/seckillConfig.ts
  8. 5 5
      src/api/mall/trade/delivery/expressTemplate/index.ts
  9. 46 0
      src/api/mall/trade/delivery/pickUpStore/index.ts
  10. 12 0
      src/api/mall/trade/order/index.ts
  11. 228 0
      src/api/mall/trade/order/type/orderType.ts
  12. 5 25
      src/api/point/config/index.ts
  13. 1 1
      src/components/Dialog/src/Dialog.vue
  14. 7 7
      src/components/Form/src/Form.vue
  15. 5 2
      src/hooks/web/useCrudSchemas.ts
  16. 4 1
      src/main.ts
  17. 32 3
      src/router/modules/remaining.ts
  18. 2 0
      src/types/table.d.ts
  19. 57 1
      src/utils/constants.ts
  20. 23 6
      src/utils/dict.ts
  21. 17 2
      src/utils/formatTime.ts
  22. 95 0
      src/utils/tree.ts
  23. 10 1
      src/views/Login/components/MobileForm.vue
  24. 3 4
      src/views/infra/build/index.vue
  25. 1 1
      src/views/infra/codegen/PreviewCode.vue
  26. 23 14
      src/views/mall/product/spu/addForm.vue
  27. 115 38
      src/views/mall/product/spu/components/BasicInfoForm.vue
  28. 29 5
      src/views/mall/product/spu/components/DescriptionForm.vue
  29. 41 5
      src/views/mall/product/spu/components/OtherSettingsForm.vue
  30. 1 2
      src/views/mall/product/spu/components/ProductPropertyAddForm.vue
  31. 248 31
      src/views/mall/product/spu/components/SkuList.vue
  32. 59 3
      src/views/mall/product/spu/components/index.ts
  33. 105 0
      src/views/mall/product/spu/components/spu.data.ts
  34. 78 32
      src/views/mall/product/spu/index.vue
  35. 125 0
      src/views/mall/promotion/components/SpuAndSkuList.vue
  36. 266 0
      src/views/mall/promotion/components/SpuSelect.vue
  37. 14 0
      src/views/mall/promotion/components/index.ts
  38. 200 0
      src/views/mall/promotion/coupon/index.vue
  39. 614 0
      src/views/mall/promotion/couponTemplate/index.vue
  40. 160 0
      src/views/mall/promotion/seckill/activity/SeckillActivityForm.vue
  41. 112 0
      src/views/mall/promotion/seckill/activity/index.vue
  42. 262 0
      src/views/mall/promotion/seckill/activity/seckillActivity.data.ts
  43. 66 0
      src/views/mall/promotion/seckill/config/SeckillConfigForm.vue
  44. 123 0
      src/views/mall/promotion/seckill/config/index.vue
  45. 79 0
      src/views/mall/promotion/seckill/config/seckillConfig.data.ts
  46. 36 7
      src/views/mall/trade/delivery/expressTemplate/ExpressTemplateForm.vue
  47. 1 0
      src/views/mall/trade/delivery/expressTemplate/index.vue
  48. 287 0
      src/views/mall/trade/delivery/pickUpStore/PickUpStoreForm.vue
  49. 87 88
      src/views/mall/trade/delivery/pickUpStore/index.vue
  50. 572 0
      src/views/mall/trade/order/index.vue
  51. 365 0
      src/views/mall/trade/order/tradeOrderDetail.vue
  52. 93 0
      src/views/member/point/config/index.vue
  53. 3 2
      src/views/member/point/record/RecordForm.vue
  54. 22 91
      src/views/member/point/record/index.vue
  55. 0 0
      src/views/member/signin/config/SignInConfigForm.vue
  56. 6 9
      src/views/member/signin/config/index.vue
  57. 0 0
      src/views/member/signin/record/SignInRecordForm.vue
  58. 3 18
      src/views/member/signin/record/index.vue
  59. 0 122
      src/views/point/config/ConfigForm.vue
  60. 1 1
      src/views/system/mail/log/MailLogDetail.vue

+ 3 - 3
.env.dev

@@ -1,5 +1,5 @@
 # 开发环境
-NODE_ENV=production
+NODE_ENV=development
 
 VITE_DEV=false
 
@@ -19,13 +19,13 @@ VITE_API_URL=/admin-api
 VITE_BASE_PATH=/
 
 # 是否删除debugger
-VITE_DROP_DEBUGGER=false
+VITE_DROP_DEBUGGER=true
 
 # 是否删除console.log
 VITE_DROP_CONSOLE=false
 
 # 是否sourcemap
-VITE_SOURCEMAP=true
+VITE_SOURCEMAP=false
 
 # 输出路径
 VITE_OUT_DIR=dist-dev

+ 1 - 0
package.json

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

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

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

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

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

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

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

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

@@ -0,0 +1,64 @@
+import request from '@/config/axios'
+import { Sku, Spu } from '@/api/mall/product/spu'
+
+export interface SeckillActivityVO {
+  id: number
+  spuIds: number[]
+  name: string
+  status: number
+  remark: string
+  startTime: Date
+  endTime: Date
+  sort: number
+  configIds: string
+  orderCount: number
+  userCount: number
+  totalPrice: number
+  totalLimitCount: number
+  singleLimitCount: number
+  stock: number
+  totalStock: number
+  products: SeckillProductVO[]
+}
+
+// 秒杀活动所需属性
+export interface SeckillProductVO {
+  spuId: number
+  skuId: number
+  seckillPrice: number
+  stock: number
+}
+
+// 扩展 Sku 配置
+type SkuExtension = Sku & {
+  productConfig: SeckillProductVO
+}
+
+export interface SpuExtension extends Spu {
+  skus: SkuExtension[] // 重写类型
+}
+
+// 查询秒杀活动列表
+export const getSeckillActivityPage = async (params) => {
+  return await request.get({ url: '/promotion/seckill-activity/page', params })
+}
+
+// 查询秒杀活动详情
+export const getSeckillActivity = async (id: number) => {
+  return await request.get({ url: '/promotion/seckill-activity/get?id=' + id })
+}
+
+// 新增秒杀活动
+export const createSeckillActivity = async (data: SeckillActivityVO) => {
+  return await request.post({ url: '/promotion/seckill-activity/create', data })
+}
+
+// 修改秒杀活动
+export const updateSeckillActivity = async (data: SeckillActivityVO) => {
+  return await request.put({ url: '/promotion/seckill-activity/update', data })
+}
+
+// 删除秒杀活动
+export const deleteSeckillActivity = async (id: number) => {
+  return await request.delete({ url: '/promotion/seckill-activity/delete?id=' + id })
+}

+ 49 - 0
src/api/mall/promotion/seckill/seckillConfig.ts

@@ -0,0 +1,49 @@
+import request from '@/config/axios'
+
+export interface SeckillConfigVO {
+  id: number
+  name: string
+  startTime: string
+  endTime: string
+  picUrl: 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 getListAllSimple = 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
+  }
+  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 })
+}

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

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

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

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

+ 12 - 0
src/api/mall/trade/order/index.ts

@@ -0,0 +1,12 @@
+import request from '@/config/axios'
+
+// 获得交易订单分页
+// TODO @xiaobai:改成 getOrderPage
+export const getOrderList = (params: PageParam) => {
+  return request.get({ url: '/trade/order/page', params })
+}
+
+// 获得交易订单详情
+export const getOrderDetail = (id: number) => {
+  return request.get({ url: '/trade/order/get-detail?id=' + id })
+}

+ 228 - 0
src/api/mall/trade/order/type/orderType.ts

@@ -0,0 +1,228 @@
+// TODO @xiaobai:这个放到 order/index.ts  里哈
+// TODO @xiaobai:注释放到变量后面,这样简洁一点
+// TODO @xiaobai:这个改成 TradeOrderRespVO
+export interface TradeOrderPageItemRespVO {
+  // 订单编号
+  id?: number
+  // 订单流水号
+  no?: string
+  // 下单时间
+  createTime?: Date
+  // 订单类型
+  type?: number
+  // 订单来源
+  terminal?: number
+  // 用户编号
+  userId?: number
+  // 用户 IP
+  userIp?: string
+  // 用户备注
+  userRemark?: string
+  // 订单状态
+  status?: number
+  // 购买的商品数量
+  productCount?: number
+  // 订单完成时间
+  finishTime?: Date
+  // 订单取消时间
+  cancelTime?: Date
+  // 取消类型
+  cancelType?: number
+  // 商家备注
+  remark?: string
+  // 支付订单编号
+  payOrderId: number
+  // 是否已支付
+  payed?: boolean
+  // 付款时间
+  payTime?: Date
+  // 支付渠道
+  payChannelCode?: string
+  // 商品原价(总)
+  originalPrice?: number
+  // 订单原价(总)
+  orderPrice?: number
+  // 订单优惠(总)
+  discountPrice?: number
+  // 运费金额
+  deliveryPrice?: number
+  // 订单调价(总)
+  adjustPrice?: number
+  // 应付金额(总)
+  payPrice?: number
+  // 配送模板编号
+  deliveryTemplateId?: number
+  // 发货物流公司编号
+  logisticsId?: number
+  // 发货物流单号
+  logisticsNo?: string
+  // 发货状态
+  deliveryStatus?: number
+  // 发货时间
+  deliveryTime?: Date
+  // 收货时间
+  receiveTime?: Date
+  // 收件人名称
+  receiverName?: string
+  // 收件人手机
+  receiverMobile?: string
+  // 收件人地区编号
+  receiverAreaId?: number
+  // 收件人邮编
+  receiverPostCode?: number
+  // 收件人详细地址
+  receiverDetailAddress?: string
+  // 售后状态
+  afterSaleStatus?: number
+  // 退款金额
+  refundPrice?: number
+  // 优惠劵编号
+  couponId?: number
+  // 优惠劵减免金额
+  couponPrice?: number
+  // 积分抵扣的金额
+  pointPrice?: number
+  //收件人地区名字
+  receiverAreaName?: string
+  // 订单项列表
+  items?: TradeOrderItemBaseVO[]
+  //用户信息
+  user?: MemberUserRespDTO
+}
+
+// TODO @xiaobai:这个改成 TradeOrderItemRespVO
+/**
+ * 交易订单项 Base VO,提供给添加、修改、详细的子 VO 使用
+ * 如果子 VO 存在差异的字段,请不要添加到这里,影响 Swagger 文档生成
+ */
+export interface TradeOrderItemBaseVO {
+  // ========== 订单项基本信息 ==========
+  /**
+   * 编号
+   */
+  id?: number
+  /**
+   * 用户编号
+   */
+  userId?: number
+  /**
+   * 订单编号
+   */
+  orderId?: number
+  // ========== 商品基本信息 ==========
+  /**
+   * 商品 SPU 编号
+   */
+  spuId?: number
+  /**
+   * 商品 SPU 名称
+   */
+  spuName?: string
+  /**
+   * 商品 SKU 编号
+   */
+  skuId?: number
+  /**
+   * 商品图片
+   */
+  picUrl?: string
+  /**
+   * 购买数量
+   */
+  count?: number
+  // ========== 价格 + 支付基本信息 ==========
+  /**
+   * 商品原价(总)
+   */
+  originalPrice?: number
+  /**
+   * 商品原价(单)
+   */
+  originalUnitPrice?: number
+  /**
+   * 商品优惠(总)
+   */
+  discountPrice?: number
+  /**
+   * 商品实付金额(总)
+   */
+  payPrice?: number
+  /**
+   * 子订单分摊金额(总)
+   */
+  orderPartPrice?: number
+  /**
+   * 分摊后子订单实付金额(总)
+   */
+  orderDividePrice?: number
+  // ========== 营销基本信息 ==========
+  // TODO 芋艿:在捉摸一下
+  // ========== 售后基本信息 ==========
+  /**
+   * 售后状态
+   */
+  afterSaleStatus?: number
+  //属性数组
+  properties?: ProductPropertyValueDetailRespVO[]
+}
+
+/**
+ * 管理后台 - 商品属性值的明细 Response VO
+ */
+export interface ProductPropertyValueDetailRespVO {
+  /**
+   * 属性的编号
+   */
+  propertyId?: number
+  /**
+   * 属性的名称
+   */
+  propertyName?: string
+  /**
+   * 属性值的编号
+   */
+  valueId?: number
+  /**
+   * 属性值的名称
+   */
+  valueName?: string
+}
+
+/**
+ * 订单详情查询 请求
+ */
+export interface TradeOrderPageReqVO {
+  pageNo: number
+  pageSize: number
+  no?: string
+  userId?: string
+  userNickname?: string
+  userMobile?: string
+  receiverName?: string
+  receiverMobile?: string
+  terminal?: string
+  type?: number
+  status?: number
+  payChannelCode?: string
+  createTime?: [Date, Date]
+  spuName?: string
+  itemCount?: string
+  all?: string
+}
+
+//用户信息
+export interface MemberUserRespDTO {
+  id?: number
+  nickname?: string
+  status?: number
+  avatar?: string
+  mobile?: string
+}
+//订单详情选中type
+export interface SelectType {
+  queryParams: TradeOrderPageReqVO
+  selectTotal: number //选中的数量
+  selectAllFlag: boolean //全选标识
+  selectData: Map<number, Set<string>> //存放涉及选中得页面以及每页选中得数据订单号  全选时根据条件查询 排除取消的list订单
+  unSelectList: Set<string> //登记取消的list 全选标识为true 时登记单独取消的list,再次选中时排除, 全选标识为false 时清空list
+}

+ 5 - 25
src/api/point/config/index.ts

@@ -8,32 +8,12 @@ export interface ConfigVO {
   tradeGivePoint: number
 }
 
-// 查询积分设置列表
-export const getConfigPage = async (params) => {
-  return await request.get({ url: `/point/config/page`, params })
-}
-
 // 查询积分设置详情
-export const getConfig = async (id: number) => {
-  return await request.get({ url: `/point/config/get?id=` + id })
-}
-
-// 新增积分设置
-export const createConfig = async (data: ConfigVO) => {
-  return await request.post({ url: `/point/config/create`, data })
-}
-
-// 修改积分设置
-export const updateConfig = async (data: ConfigVO) => {
-  return await request.put({ url: `/point/config/update`, data })
-}
-
-// 删除积分设置
-export const deleteConfig = async (id: number) => {
-  return await request.delete({ url: `/point/config/delete?id=` + id })
+export const getConfig = async () => {
+  return await request.get({ url: `/point/config/get` })
 }
 
-// 导出积分设置 Excel
-export const exportConfig = async (params) => {
-  return await request.download({ url: `/point/config/export-excel`, params })
+// 新增修改积分设置
+export const saveConfig = async (data: ConfigVO) => {
+  return await request.put({ url: `/point/config/save`, data })
 }

+ 1 - 1
src/components/Dialog/src/Dialog.vue

@@ -17,7 +17,7 @@ const props = defineProps({
 })
 
 const getBindValue = computed(() => {
-  const delArr: string[] = ['fullscreen', 'title', 'maxHeight']
+  const delArr: string[] = ['fullscreen', 'title', 'maxHeight', 'appendToBody']
   const attrs = useAttrs()
   const obj = { ...attrs, ...props }
   for (const key in obj) {

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

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

+ 5 - 2
src/hooks/web/useCrudSchemas.ts

@@ -1,7 +1,7 @@
 import { reactive } from 'vue'
 import { AxiosPromise } from 'axios'
 import { findIndex } from '@/utils'
-import { eachTree, treeMap, filter } from '@/utils/tree'
+import { eachTree, filter, treeMap } from '@/utils/tree'
 import { getBoolDictOptions, getDictOptions, getIntDictOptions } from '@/utils/dict'
 
 import { FormSchema } from '@/types/form'
@@ -36,8 +36,11 @@ type CrudSearchParams = {
 type CrudTableParams = {
   // 是否显示表头
   show?: boolean
+  // 列宽配置
+  width?: number | string
+  // 列是否固定在左侧或者右侧
+  fixed?: 'left' | 'right'
 } & Omit<FormSchema, 'field'>
-
 type CrudFormParams = {
   // 是否显示表单项
   show?: boolean

+ 4 - 1
src/main.ts

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

+ 32 - 3
src/router/modules/remaining.ts

@@ -195,6 +195,22 @@ const remainingRouter: AppRouteRecordRaw[] = [
       noTagsView: true
     }
   },
+  {
+    path: '/trade/order',
+    component: Layout,
+    name: 'order',
+    meta: {
+      hidden: true
+    },
+    children: [
+      {
+        path: 'detail',
+        name: 'TradeOrderDetail',
+        component: () => import('@/views/mall/trade/order/tradeOrderDetail.vue'),
+        meta: { title: '订单详情', hidden: true }
+      }
+    ]
+  },
   {
     path: '/403',
     component: () => import('@/views/Error/403.vue'),
@@ -355,7 +371,7 @@ const remainingRouter: AppRouteRecordRaw[] = [
     },
     children: [
       {
-        path: 'productSpuAdd', // TODO @puhui999:最好拆成 add 和 edit 两个路由;添加商品;修改商品 fix
+        path: 'spu/add',
         component: () => import('@/views/mall/product/spu/addForm.vue'),
         name: 'ProductSpuAdd',
         meta: {
@@ -368,9 +384,9 @@ const remainingRouter: AppRouteRecordRaw[] = [
         }
       },
       {
-        path: 'productSpuEdit/:spuId(\\d+)',
+        path: 'spu/edit/:spuId(\\d+)',
         component: () => import('@/views/mall/product/spu/addForm.vue'),
-        name: 'productSpuEdit',
+        name: 'ProductSpuEdit',
         meta: {
           noCache: true,
           hidden: true,
@@ -379,6 +395,19 @@ const remainingRouter: AppRouteRecordRaw[] = [
           title: '编辑商品',
           activeMenu: '/product/product-spu'
         }
+      },
+      {
+        path: 'spu/detail/:spuId(\\d+)',
+        component: () => import('@/views/mall/product/spu/addForm.vue'),
+        name: 'ProductSpuDetail',
+        meta: {
+          noCache: true,
+          hidden: true,
+          canTo: true,
+          icon: 'ep:view',
+          title: '商品详情',
+          activeMenu: '/product/product-spu'
+        }
       }
     ]
   }

+ 2 - 0
src/types/table.d.ts

@@ -1,6 +1,8 @@
 export type TableColumn = {
   field: string
   label?: string
+  width?: number | string
+  fixed?: 'left' | 'right'
   children?: TableColumn[]
 } & Recordable
 

+ 57 - 1
src/utils/constants.ts

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

+ 23 - 6
src/utils/dict.ts

@@ -33,7 +33,6 @@ export const getIntDictOptions = (dictType: string) => {
       value: parseInt(dict.value + '')
     })
   })
-
   return dictOption
 }
 
@@ -146,12 +145,30 @@ export enum DICT_TYPE {
   MP_AUTO_REPLY_REQUEST_MATCH = 'mp_auto_reply_request_match', // 自动回复请求匹配类型
   MP_MESSAGE_TYPE = 'mp_message_type', // 消息类型
 
-  // ========== MALL 模块 ==========
+  // ========== MALL - 会员模块 ==========
+  MEMBER_POINT_BIZ_TYPE = 'member_point_biz_type', // 积分的业务类型
+  MEMBER_POINT_STATUS = 'member_point_status', // 积分的状态
+
+  // ========== MALL - 商品模块 ==========
   PRODUCT_UNIT = 'product_unit', // 商品单位
   PRODUCT_SPU_STATUS = 'product_spu_status', //商品状态
-  // ========== MALL 交易模块 ==========
+
+  // ========== MALL - 交易模块 ==========
   EXPRESS_CHARGE_MODE = 'trade_delivery_express_charge_mode', //快递的计费方式
-  //积分模块//
-  POINT_BIZ_TYPE = 'point_biz_type',
-  POINT_STATUS = 'point_status'
+  TRADE_AFTER_SALE_STATUS = 'trade_after_sale_status', // 售后 - 状态
+  TRADE_AFTER_SALE_WAY = 'trade_after_sale_way', // 售后 - 方式
+  TRADE_AFTER_SALE_TYPE = 'trade_after_sale_type', // 售后 - 类型
+  TRADE_ORDER_TYPE = 'trade_order_type', // 订单 - 类型
+  TRADE_ORDER_STATUS = 'trade_order_status', // 订单 - 状态
+  TRADE_ORDER_ITEM_AFTER_SALE_STATUS = 'trade_order_item_after_sale_status', // 订单项 - 售后状态
+  TERMINAL = 'terminal', // 终端
+
+  // ========== MALL - 营销模块 ==========
+  PROMOTION_DISCOUNT_TYPE = 'promotion_discount_type', // 优惠类型
+  PROMOTION_PRODUCT_SCOPE = 'promotion_product_scope', // 营销的商品范围
+  PROMOTION_COUPON_TEMPLATE_VALIDITY_TYPE = 'promotion_coupon_template_validity_type', // 优惠劵模板的有限期类型
+  PROMOTION_COUPON_STATUS = 'promotion_coupon_status', // 优惠劵的状态
+  PROMOTION_COUPON_TAKE_TYPE = 'promotion_coupon_take_type', // 优惠劵的领取方式
+  PROMOTION_ACTIVITY_STATUS = 'promotion_activity_status', // 优惠活动的状态
+  PROMOTION_CONDITION_TYPE = 'promotion_condition_type' // 营销的条件类型枚举
 }

+ 17 - 2
src/utils/formatTime.ts

@@ -149,13 +149,28 @@ export const dateFormatter = (row, column, cellValue) => {
   return formatDate(cellValue)
 }
 
+/**
+ * element plus 的时间 Formatter 实现,使用 YYYY-MM-DD 格式
+ *
+ * @param row 行数据
+ * @param column 字段
+ * @param cellValue 字段值
+ */
+// @ts-ignore
+export const dateFormatter2 = (row, column, cellValue) => {
+  if (!cellValue) {
+    return
+  }
+  return formatDate(cellValue, 'YYYY-MM-DD')
+}
+
 /**
  * 设置起始日期,时间为00:00:00
  * @param param 传入日期
  * @returns 带时间00:00:00的日期
  */
 export function beginOfDay(param: Date) {
-  return new Date(param.getFullYear(), param.getMonth(), param.getDate(), 0, 0, 0, 0)
+  return new Date(param.getFullYear(), param.getMonth(), param.getDate(), 0, 0, 0)
 }
 
 /**
@@ -164,7 +179,7 @@ export function beginOfDay(param: Date) {
  * @returns 带时间23:59:59的日期
  */
 export function endOfDay(param: Date) {
-  return new Date(param.getFullYear(), param.getMonth(), param.getDate(), 23, 59, 59, 999)
+  return new Date(param.getFullYear(), param.getMonth(), param.getDate(), 23, 59, 59)
 }
 
 /**

+ 95 - 0
src/utils/tree.ts

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

+ 10 - 1
src/views/Login/components/MobileForm.vue

@@ -185,12 +185,17 @@ const signIn = async () => {
   await getTenantId()
   const data = await validForm()
   if (!data) return
+  ElLoading.service({
+    lock: true,
+    text: '正在加载系统中...',
+    background: 'rgba(0, 0, 0, 0.7)'
+  })
   loginLoading.value = true
   smsVO.loginSms.mobile = loginData.loginForm.mobileNumber
   smsVO.loginSms.code = loginData.loginForm.code
   await smsLogin(smsVO.loginSms)
     .then(async (res) => {
-      setToken(res?.token)
+      setToken(res)
       if (!redirect.value) {
         redirect.value = '/'
       }
@@ -199,6 +204,10 @@ const signIn = async () => {
     .catch(() => {})
     .finally(() => {
       loginLoading.value = false
+      setTimeout(() => {
+        const loadingInstance = ElLoading.service()
+        loadingInstance.close()
+      }, 400)
     })
 }
 </script>

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

@@ -16,8 +16,8 @@
   </ContentWrap>
 
   <!-- 弹窗:表单预览 -->
-  <Dialog :title="dialogTitle" v-model="dialogVisible" max-height="600">
-    <div ref="editor" v-if="dialogVisible">
+  <Dialog v-model="dialogVisible" :title="dialogTitle" max-height="600">
+    <div v-if="dialogVisible" ref="editor">
       <el-button style="float: right" @click="copy(formData)">
         {{ t('common.copy') }}
       </el-button>
@@ -30,6 +30,7 @@
   </Dialog>
 </template>
 <script lang="ts" setup>
+defineOptions({ name: 'InfraBuild' })
 import FcDesigner from '@form-create/designer'
 import { useClipboard } from '@vueuse/core'
 import { isString } from '@/utils/is'
@@ -40,8 +41,6 @@ import xml from 'highlight.js/lib/languages/java'
 import json from 'highlight.js/lib/languages/json'
 import formCreate from '@form-create/element-ui'
 
-defineOptions({ name: 'InfraBuild' })
-
 const { t } = useI18n() // 国际化
 const message = useMessage() // 消息
 

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

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

+ 23 - 14
src/views/mall/product/spu/addForm.vue

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

+ 115 - 38
src/views/mall/product/spu/components/BasicInfoForm.vue

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

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

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

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

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

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

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

+ 248 - 31
src/views/mall/product/spu/components/SkuList.vue

@@ -1,6 +1,8 @@
 <template>
+  <!-- 情况一:添加/修改 -->
   <el-table
-    :data="isBatch ? skuList : formData.skus"
+    v-if="!isDetail && !isActivityComponent"
+    :data="isBatch ? skuList : formData!.skus"
     border
     class="tabNumWidth"
     max-height="500"
@@ -11,7 +13,7 @@
         <UploadImg v-model="row.picUrl" height="80px" width="100%" />
       </template>
     </el-table-column>
-    <template v-if="formData.specType && !isBatch">
+    <template v-if="formData!.specType && !isBatch">
       <!--  根据商品属性动态添加 -->
       <el-table-column
         v-for="(item, index) in tableHeaders"
@@ -21,8 +23,9 @@
         min-width="120"
       >
         <template #default="{ row }">
-          <!-- TODO puhui999:展示成蓝色,有点区分度哈 -->
-          {{ row.properties[index]?.valueName }}
+          <span style="font-weight: bold; color: #40aaff">
+            {{ row.properties[index]?.valueName }}
+          </span>
         </template>
       </el-table-column>
     </template>
@@ -73,7 +76,7 @@
         <el-input-number v-model="row.volume" :min="0" :precision="2" :step="0.1" class="w-100%" />
       </template>
     </el-table-column>
-    <template v-if="formData.subCommissionType">
+    <template v-if="formData!.subCommissionType">
       <el-table-column align="center" label="一级返佣(元)" min-width="168">
         <template #default="{ row }">
           <el-input-number
@@ -97,7 +100,7 @@
         </template>
       </el-table-column>
     </template>
-    <el-table-column v-if="formData.specType" align="center" fixed="right" label="操作" width="80">
+    <el-table-column v-if="formData?.specType" align="center" fixed="right" label="操作" width="80">
       <template #default="{ row }">
         <el-button v-if="isBatch" link size="small" type="primary" @click="batchAdd">
           批量添加
@@ -106,29 +109,181 @@
       </template>
     </el-table-column>
   </el-table>
+
+  <!-- 情况二:详情 -->
+  <el-table
+    v-if="isDetail"
+    :data="formData!.skus"
+    border
+    max-height="500"
+    size="small"
+    style="width: 99%"
+    @selection-change="handleSelectionChange"
+  >
+    <el-table-column v-if="isComponent" type="selection" width="45" />
+    <el-table-column align="center" label="图片" min-width="80">
+      <template #default="{ row }">
+        <el-image :src="row.picUrl" class="w-60px h-60px" @click="imagePreview(row.picUrl)" />
+      </template>
+    </el-table-column>
+    <template v-if="formData!.specType && !isBatch">
+      <!--  根据商品属性动态添加 -->
+      <el-table-column
+        v-for="(item, index) in tableHeaders"
+        :key="index"
+        :label="item.label"
+        align="center"
+        min-width="80"
+      >
+        <template #default="{ row }">
+          <span style="font-weight: bold; color: #40aaff">
+            {{ row.properties[index]?.valueName }}
+          </span>
+        </template>
+      </el-table-column>
+    </template>
+    <el-table-column align="center" label="商品条码" min-width="100">
+      <template #default="{ row }">
+        {{ row.barCode }}
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="销售价(元)" min-width="80">
+      <template #default="{ row }">
+        {{ row.price }}
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="市场价(元)" min-width="80">
+      <template #default="{ row }">
+        {{ row.marketPrice }}
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="成本价(元)" min-width="80">
+      <template #default="{ row }">
+        {{ row.costPrice }}
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="库存" min-width="80">
+      <template #default="{ row }">
+        {{ row.stock }}
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="重量(kg)" min-width="80">
+      <template #default="{ row }">
+        {{ row.weight }}
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="体积(m^3)" min-width="80">
+      <template #default="{ row }">
+        {{ row.volume }}
+      </template>
+    </el-table-column>
+    <template v-if="formData!.subCommissionType">
+      <el-table-column align="center" label="一级返佣(元)" min-width="80">
+        <template #default="{ row }">
+          {{ row.subCommissionFirstPrice }}
+        </template>
+      </el-table-column>
+      <el-table-column align="center" label="二级返佣(元)" min-width="80">
+        <template #default="{ row }">
+          {{ row.subCommissionSecondPrice }}
+        </template>
+      </el-table-column>
+    </template>
+  </el-table>
+
+  <!-- 情况三:作为活动组件 -->
+  <el-table
+    v-if="isActivityComponent"
+    :data="formData!.skus"
+    border
+    max-height="500"
+    size="small"
+    style="width: 99%"
+  >
+    <el-table-column v-if="isComponent" type="selection" width="45" />
+    <el-table-column align="center" label="图片" min-width="80">
+      <template #default="{ row }">
+        <el-image :src="row.picUrl" class="w-60px h-60px" @click="imagePreview(row.picUrl)" />
+      </template>
+    </el-table-column>
+    <template v-if="formData!.specType">
+      <!--  根据商品属性动态添加 -->
+      <el-table-column
+        v-for="(item, index) in tableHeaders"
+        :key="index"
+        :label="item.label"
+        align="center"
+        min-width="80"
+      >
+        <template #default="{ row }">
+          <span style="font-weight: bold; color: #40aaff">
+            {{ row.properties[index]?.valueName }}
+          </span>
+        </template>
+      </el-table-column>
+    </template>
+    <el-table-column align="center" label="商品条码" min-width="100">
+      <template #default="{ row }">
+        {{ row.barCode }}
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="销售价(元)" min-width="80">
+      <template #default="{ row }">
+        {{ row.price }}
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="市场价(元)" min-width="80">
+      <template #default="{ row }">
+        {{ row.marketPrice }}
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="成本价(元)" min-width="80">
+      <template #default="{ row }">
+        {{ row.costPrice }}
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="库存" min-width="80">
+      <template #default="{ row }">
+        {{ row.stock }}
+      </template>
+    </el-table-column>
+    <!--  方便扩展每个活动配置的属性不一样  -->
+    <slot name="extension"></slot>
+  </el-table>
 </template>
 <script lang="ts" setup>
-import { PropType } from 'vue'
+import { PropType, Ref } from 'vue'
 import { copyValueToTarget } from '@/utils'
 import { propTypes } from '@/utils/propTypes'
 import { UploadImg } from '@/components/UploadFile'
-import type { Property, SkuType, SpuType } from '@/api/mall/product/spu'
+import type { Property, Sku, Spu } from '@/api/mall/product/spu'
+import { createImageViewer } from '@/components/ImageViewer'
+import { RuleConfig } from '@/views/mall/product/spu/components/index'
+import { Properties } from './index'
 
 defineOptions({ name: 'SkuList' })
+const message = useMessage() // 消息弹窗
 
 const props = defineProps({
   propFormData: {
-    type: Object as PropType<SpuType>,
+    type: Object as PropType<Spu>,
     default: () => {}
   },
   propertyList: {
-    type: Array,
+    type: Array as PropType<Properties[]>,
+    default: () => []
+  },
+  ruleConfig: {
+    type: Array as PropType<RuleConfig[]>,
     default: () => []
   },
-  isBatch: propTypes.bool.def(false) // 是否作为批量操作组件
+  isBatch: propTypes.bool.def(false), // 是否作为批量操作组件
+  isDetail: propTypes.bool.def(false), // 是否作为 sku 详情组件
+  isComponent: propTypes.bool.def(false), // 是否作为 sku 选择组件
+  isActivityComponent: propTypes.bool.def(false) // 是否作为 sku 活动配置组件
 })
-const formData = ref<SpuType>() // 表单数据
-const skuList = ref<SkuType[]>([
+const formData: Ref<Spu | undefined> = ref<Spu>() // 表单数据
+const skuList = ref<Sku[]>([
   {
     price: 0, // 商品价格
     marketPrice: 0, // 市场价
@@ -142,24 +297,87 @@ const skuList = ref<SkuType[]>([
     subCommissionSecondPrice: 0 // 二级分销的佣金
   }
 ]) // 批量添加时的临时数据
-// TODO @puhui999:保存时,每个商品规格的表单要校验下。例如说,销售金额最低是 0.01 这种。
+
+/** 商品图预览 */
+const imagePreview = (imgUrl: string) => {
+  createImageViewer({
+    zIndex: 9999999,
+    urlList: [imgUrl]
+  })
+}
 
 /** 批量添加 */
 const batchAdd = () => {
-  formData.value.skus.forEach((item) => {
+  formData.value!.skus!.forEach((item) => {
     copyValueToTarget(item, skuList.value[0])
   })
 }
 
 /** 删除 sku */
 const deleteSku = (row) => {
-  const index = formData.value.skus.findIndex(
+  const index = formData.value!.skus!.findIndex(
     // 直接把列表转成字符串比较
     (sku) => JSON.stringify(sku.properties) === JSON.stringify(row.properties)
   )
-  formData.value.skus.splice(index, 1)
+  formData.value!.skus!.splice(index, 1)
 }
 const tableHeaders = ref<{ prop: string; label: string }[]>([]) // 多属性表头
+/**
+ * 保存时,每个商品规格的表单要校验下。例如说,销售金额最低是 0.01 这种。
+ */
+const validateSku = () => {
+  const checks = ['price', 'marketPrice', 'costPrice']
+  let warningInfo = '请检查商品各行相关属性配置,'
+  let validate = true // 默认通过
+  for (const sku of formData.value!.skus!) {
+    // 作为活动组件的校验
+    if (props.isActivityComponent) {
+      for (const rule of props.ruleConfig) {
+        const arg = getValue(sku, rule.name)
+        if (!rule.rule(arg)) {
+          validate = false // 只要有一个不通过则直接不通过
+          warningInfo += rule.message
+          break
+        }
+      }
+    } else {
+      if (checks.some((check) => sku[check] < 0.01)) {
+        validate = false // 只要有一个不通过则直接不通过
+        warningInfo = '商品相关价格不能低于 0.01 元!!'
+        break
+      }
+    }
+    // 只要有一个不通过则结束后续的校验
+    if (!validate) {
+      message.warning(warningInfo)
+      throw new Error(warningInfo)
+    }
+  }
+}
+const getValue = (obj, arg) => {
+  const keys = arg.split('.')
+  let value = obj
+  for (const key of keys) {
+    if (value && typeof value === 'object' && key in value) {
+      value = value[key]
+    } else {
+      value = undefined
+      break
+    }
+  }
+  return value
+}
+
+const emit = defineEmits<{
+  (e: 'selectionChange', value: Sku[]): void
+}>()
+/**
+ * 选择时触发
+ * @param Sku 传递过来的选中的 sku 是一个数组
+ */
+const handleSelectionChange = (val: Sku[]) => {
+  emit('selectionChange', val)
+}
 
 /**
  * 将传进来的值赋值给 skuList
@@ -187,14 +405,13 @@ const generateTableData = (propertyList: any[]) => {
       valueName: v.name
     }))
   )
-  // TODO @puhui:是不是 buildSkuList,这样容易理解一点哈。item 改成 sku
-  const buildList = build(propertyValues)
+  const buildSkuList = build(propertyValues)
   // 如果回显的 sku 属性和添加的属性不一致则重置 skus 列表
   if (!validateData(propertyList)) {
     // 如果不一致则重置表数据,默认添加新的属性重新生成 sku 列表
     formData.value!.skus = []
   }
-  for (const item of buildList) {
+  for (const item of buildSkuList) {
     const row = {
       properties: Array.isArray(item) ? item : [item], // 如果只有一个属性的话返回的是一个 property 对象
       price: 0,
@@ -209,13 +426,13 @@ const generateTableData = (propertyList: any[]) => {
       subCommissionSecondPrice: 0
     }
     // 如果存在属性相同的 sku 则不做处理
-    const index = formData.value!.skus.findIndex(
+    const index = formData.value!.skus!.findIndex(
       (sku) => JSON.stringify(sku.properties) === JSON.stringify(row.properties)
     )
     if (index !== -1) {
       continue
     }
-    formData.value.skus.push(row)
+    formData.value!.skus!.push(row)
   }
 }
 
@@ -223,13 +440,13 @@ const generateTableData = (propertyList: any[]) => {
  * 生成 skus 前置校验
  */
 const validateData = (propertyList: any[]) => {
-  const skuPropertyIds = []
-  formData.value.skus.forEach((sku) =>
+  const skuPropertyIds: number[] = []
+  formData.value!.skus!.forEach((sku) =>
     sku.properties
       ?.map((property) => property.propertyId)
-      .forEach((propertyId) => {
-        if (skuPropertyIds.indexOf(propertyId) === -1) {
-          skuPropertyIds.push(propertyId)
+      ?.forEach((propertyId) => {
+        if (skuPropertyIds.indexOf(propertyId!) === -1) {
+          skuPropertyIds.push(propertyId!)
         }
       })
   )
@@ -263,9 +480,9 @@ const build = (propertyValuesList: Property[][]) => {
 /** 监听属性列表,生成相关参数和表头 */
 watch(
   () => props.propertyList,
-  (propertyList) => {
+  (propertyList: Properties[]) => {
     // 如果不是多规格则结束
-    if (!formData.value.specType) {
+    if (!formData.value!.specType) {
       return
     }
     // 如果当前组件作为批量添加数据使用,则重置表数据
@@ -303,7 +520,7 @@ watch(
       return
     }
     // 添加新属性没有属性值也不做处理
-    if (propertyList.some((item) => item.values.length === 0)) {
+    if (propertyList.some((item) => item.values!.length === 0)) {
       return
     }
     // 生成 table 数据,即 sku 列表
@@ -315,5 +532,5 @@ watch(
   }
 )
 // 暴露出生成 sku 方法,给添加属性成功时调用
-defineExpose({ generateTableData })
+defineExpose({ generateTableData, validateSku })
 </script>

+ 59 - 3
src/views/mall/product/spu/components/index.ts

@@ -2,14 +2,70 @@ import BasicInfoForm from './BasicInfoForm.vue'
 import DescriptionForm from './DescriptionForm.vue'
 import OtherSettingsForm from './OtherSettingsForm.vue'
 import ProductAttributes from './ProductAttributes.vue'
-import ProductAttributesAddForm from './ProductAttributesAddForm.vue'
+import ProductPropertyAddForm from './ProductPropertyAddForm.vue'
 import SkuList from './SkuList.vue'
 
+import { Spu } from '@/api/mall/product/spu'
+
+// TODO @puhui999:Properties 改成 Property 更合适?
+interface Properties {
+  id: number
+  name: string
+  values?: Properties[]
+}
+
+interface RuleConfig {
+  // 需要校验的字段
+  // 例:name: 'name' 则表示校验 sku.name 的值
+  // 例:name: 'productConfig.stock' 则表示校验 sku.productConfig.name 的值,此处 productConfig 表示我在 Sku 上扩展的属性
+  name: string
+  // 校验规格为一个毁掉函数,其中 arg 为需要校验的字段的值。
+  // 例:需要校验价格必须大于0.01
+  // {
+  //  name:'price',
+  //  rule:(arg) => arg > 0.01
+  // }
+  rule: (arg: any) => boolean
+  // 校验不通过时的消息提示
+  message: string
+}
+
+/**
+ * 获得商品的规格列表
+ *
+ * @param spu
+ * @return Property 规格列表
+ */
+const getPropertyList = (spu: Spu): Properties[] => {
+  //  直接拿返回的 skus 属性逆向生成出 propertyList
+  const properties: Properties[] = []
+  // 只有是多规格才处理
+  if (spu.specType) {
+    spu.skus?.forEach((sku) => {
+      sku.properties?.forEach(({ propertyId, propertyName, valueId, valueName }) => {
+        // 添加属性
+        if (!properties?.some((item) => item.id === propertyId)) {
+          properties.push({ id: propertyId!, name: propertyName!, values: [] })
+        }
+        // 添加属性值
+        const index = properties?.findIndex((item) => item.id === propertyId)
+        if (!properties[index].values?.some((value) => value.id === valueId)) {
+          properties[index].values?.push({ id: valueId!, name: valueName! })
+        }
+      })
+    })
+  }
+  return properties
+}
+
 export {
   BasicInfoForm,
   DescriptionForm,
   OtherSettingsForm,
   ProductAttributes,
-  ProductAttributesAddForm,
-  SkuList
+  ProductPropertyAddForm,
+  SkuList,
+  getPropertyList,
+  Properties,
+  RuleConfig
 }

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

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

+ 78 - 32
src/views/mall/product/spu/index.vue

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

+ 125 - 0
src/views/mall/promotion/components/SpuAndSkuList.vue

@@ -0,0 +1,125 @@
+<template>
+  <el-table :data="spuData" :default-expand-all="true">
+    <el-table-column type="expand" width="30">
+      <template #default="{ row }">
+        <SkuList
+          ref="skuListRef"
+          :is-activity-component="true"
+          :prop-form-data="spuPropertyList.find((item) => item.spuId === row.id)?.spuDetail"
+          :property-list="spuPropertyList.find((item) => item.spuId === row.id)?.propertyList"
+          :rule-config="ruleConfig"
+        >
+          <template #extension>
+            <el-table-column align="center" label="秒杀库存" min-width="168">
+              <template #default="{ row: sku }">
+                <el-input-number v-model="sku.productConfig.stock" :min="0" class="w-100%" />
+              </template>
+            </el-table-column>
+            <el-table-column align="center" label="秒杀价格(元)" min-width="168">
+              <template #default="{ row: sku }">
+                <el-input-number
+                  v-model="sku.productConfig.seckillPrice"
+                  :min="0"
+                  :precision="2"
+                  :step="0.1"
+                  class="w-100%"
+                />
+              </template>
+            </el-table-column>
+          </template>
+        </SkuList>
+      </template>
+    </el-table-column>
+    <el-table-column key="id" align="center" label="商品编号" prop="id" />
+    <el-table-column label="商品图" min-width="80">
+      <template #default="{ row }">
+        <el-image :src="row.picUrl" class="w-30px h-30px" @click="imagePreview(row.picUrl)" />
+      </template>
+    </el-table-column>
+    <el-table-column :show-overflow-tooltip="true" label="商品名称" min-width="300" prop="name" />
+    <el-table-column align="center" label="商品售价" min-width="90" prop="price">
+      <template #default="{ row }">
+        {{ formatToFraction(row.price) }}
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="销量" min-width="90" prop="salesCount" />
+    <el-table-column align="center" label="库存" min-width="90" prop="stock" />
+  </el-table>
+</template>
+<script generic="T extends Spu" lang="ts" setup>
+// TODO 后续计划重新封装作为活动商品配置通用组件;可以等其他活动做到的时候,在统一处理 SPU 选择组件哈
+import { formatToFraction } from '@/utils'
+import { createImageViewer } from '@/components/ImageViewer'
+import { Spu } from '@/api/mall/product/spu'
+import { RuleConfig, SkuList } from '@/views/mall/product/spu/components'
+import { SeckillProductVO } from '@/api/mall/promotion/seckill/seckillActivity'
+import { SpuProperty } from '@/views/mall/promotion/components/index'
+
+defineOptions({ name: 'PromotionSpuAndSkuList' })
+
+// TODO @puhui999:是不是改成传递一个 spu 就好啦? 因为活动商品可以多选所以展示编辑的时候需要展示多个
+const props = defineProps<{
+  spuList: T[]
+  ruleConfig: RuleConfig[]
+  spuPropertyListP: SpuProperty<T>[]
+}>()
+
+const spuData = ref<Spu[]>([]) // spu 详情数据列表
+const skuListRef = ref() // 商品属性列表Ref
+
+const spuPropertyList = ref<SpuProperty<T>[]>([]) // spuId 对应的 sku 的属性列表
+
+/**
+ * 获取所有 sku 秒杀配置
+ * @param extendedAttribute 在 sku 上扩展的属性,例:秒杀活动 sku 扩展属性 productConfig 请参考 seckillActivity.ts
+ */
+const getSkuConfigs: <V>(extendedAttribute: string) => V[] = (extendedAttribute: string) => {
+  skuListRef.value.validateSku()
+  const seckillProducts: SeckillProductVO[] = []
+  spuPropertyList.value.forEach((item) => {
+    item.spuDetail.skus.forEach((sku) => {
+      seckillProducts.push(sku[extendedAttribute])
+    })
+  })
+  return seckillProducts
+}
+// 暴露出给表单提交时使用
+defineExpose({ getSkuConfigs })
+
+/** 商品图预览 */
+const imagePreview = (imgUrl: string) => {
+  createImageViewer({
+    zIndex: 99999999,
+    urlList: [imgUrl]
+  })
+}
+
+/**
+ * 将传进来的值赋值给 skuList
+ */
+watch(
+  () => props.spuList,
+  (data) => {
+    if (!data) return
+    spuData.value = data as Spu[]
+  },
+  {
+    deep: true,
+    immediate: true
+  }
+)
+/**
+ * 将传进来的值赋值给 skuList
+ */
+watch(
+  () => props.spuPropertyListP,
+  (data) => {
+    if (!data) return
+    spuPropertyList.value = data as SpuProperty<T>[]
+  },
+  {
+    deep: true,
+    immediate: true
+  }
+)
+</script>

+ 266 - 0
src/views/mall/promotion/components/SpuSelect.vue

@@ -0,0 +1,266 @@
+<template>
+  <Dialog v-model="dialogVisible" :appendToBody="true" :title="dialogTitle" width="70%">
+    <ContentWrap>
+      <el-row :gutter="20" class="mb-10px">
+        <el-col :span="6">
+          <el-input
+            v-model="queryParams.name"
+            class="!w-240px"
+            clearable
+            placeholder="请输入商品名称"
+            @keyup.enter="handleQuery"
+          />
+        </el-col>
+        <el-col :span="6">
+          <el-tree-select
+            v-model="queryParams.categoryId"
+            :data="categoryList"
+            :props="defaultProps"
+            check-strictly
+            class="w-1/1"
+            node-key="id"
+            placeholder="请选择商品分类"
+          />
+        </el-col>
+        <el-col :span="6">
+          <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-col>
+        <el-col :span="6">
+          <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-col>
+      </el-row>
+      <el-table
+        ref="spuListRef"
+        v-loading="loading"
+        :data="list"
+        :expand-row-keys="expandRowKeys"
+        row-key="id"
+        @expand-change="expandChange"
+        @selection-change="selectSpu"
+      >
+        <el-table-column v-if="isSelectSku" type="expand" width="30">
+          <template #default>
+            <SkuList
+              v-if="isExpand"
+              :isComponent="true"
+              :isDetail="true"
+              :prop-form-data="spuData"
+              :property-list="propertyList"
+              @selection-change="selectSku"
+            />
+          </template>
+        </el-table-column>
+        <el-table-column type="selection" width="55" />
+        <el-table-column key="id" align="center" label="商品编号" prop="id" />
+        <el-table-column label="商品图" min-width="80">
+          <template #default="{ row }">
+            <el-image :src="row.picUrl" class="w-30px h-30px" @click="imagePreview(row.picUrl)" />
+          </template>
+        </el-table-column>
+        <el-table-column
+          :show-overflow-tooltip="true"
+          label="商品名称"
+          min-width="300"
+          prop="name"
+        />
+        <el-table-column align="center" label="商品售价" min-width="90" prop="price">
+          <template #default="{ row }">
+            {{ formatToFraction(row.price) }}
+          </template>
+        </el-table-column>
+        <el-table-column align="center" label="销量" min-width="90" prop="salesCount" />
+        <el-table-column align="center" label="库存" min-width="90" prop="stock" />
+        <el-table-column align="center" label="排序" min-width="70" prop="sort" />
+        <el-table-column
+          :formatter="dateFormatter"
+          align="center"
+          label="创建时间"
+          prop="createTime"
+          width="180"
+        />
+      </el-table>
+      <!-- 分页 -->
+      <Pagination
+        v-model:limit="queryParams.pageSize"
+        v-model:page="queryParams.pageNo"
+        :total="total"
+        @pagination="getList"
+      />
+    </ContentWrap>
+    <template #footer>
+      <el-button type="primary" @click="confirm">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+
+<script lang="ts" setup>
+import { getPropertyList, Properties, SkuList } from '@/views/mall/product/spu/components'
+import { ElTable } from 'element-plus'
+import { dateFormatter } from '@/utils/formatTime'
+import { createImageViewer } from '@/components/ImageViewer'
+import { formatToFraction } from '@/utils'
+import { defaultProps, handleTree } from '@/utils/tree'
+
+import * as ProductCategoryApi from '@/api/mall/product/category'
+import * as ProductSpuApi from '@/api/mall/product/spu'
+import { propTypes } from '@/utils/propTypes'
+
+defineOptions({ name: 'PromotionSpuSelect' })
+
+const props = defineProps({
+  // 默认不需要(不需要的情况下只返回 spu,需要的情况下返回 选中的 spu 和 sku 列表)
+  // 其它活动需要选择商品和商品属性导入此组件即可,需添加组件属性 :isSelectSku='true'
+  isSelectSku: propTypes.bool.def(false) // 是否需要选择 sku 属性
+})
+
+const message = useMessage() // 消息弹窗
+const total = ref(0) // 列表的总页数
+const list = ref<any[]>([]) // 列表的数据
+const loading = ref(false) // 列表的加载中
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('') // 弹窗的标题
+const queryParams = ref({
+  pageNo: 1,
+  pageSize: 10,
+  tabType: 0, // 默认获取上架的商品
+  name: '',
+  categoryId: null,
+  createTime: []
+}) // 查询参数
+const propertyList = ref<Properties[]>([]) // 商品属性列表
+const spuListRef = ref<InstanceType<typeof ElTable>>()
+const spuData = ref<ProductSpuApi.Spu | {}>() // 商品详情
+const isExpand = ref(false) // 控制 SKU 列表显示
+const expandRowKeys = ref<number[]>() // 控制展开行需要设置 row-key 属性才能使用,该属性为展开行的 keys 数组。
+
+// 计算商品属性
+const expandChange = async (row: ProductSpuApi.Spu, expandedRows: ProductSpuApi.Spu[]) => {
+  spuData.value = {}
+  propertyList.value = []
+  isExpand.value = false
+  // 如果展开个数为 0
+  if (expandedRows.length === 0) {
+    expandRowKeys.value = []
+    return
+  }
+  // 获取 SPU 详情
+  const res = (await ProductSpuApi.getSpu(row.id as number)) as ProductSpuApi.Spu
+  propertyList.value = getPropertyList(res)
+  spuData.value = res
+  isExpand.value = true
+  expandRowKeys.value = [row.id!]
+}
+
+//============ 商品选择相关 ============
+const selectedSpuIds = ref<number[]>([]) // 选中的商品 spuIds
+const selectedSkuIds = ref<number[]>([]) // 选中的商品 skuIds
+const selectSku = (val: ProductSpuApi.Sku[]) => {
+  selectedSkuIds.value = val.map((sku) => sku.id!)
+}
+const selectSpu = (val: ProductSpuApi.Spu[]) => {
+  selectedSpuIds.value = val.map((spu) => spu.id!)
+  // // 只选择一个
+  // selectedSpu.value = val[0]
+  // // 如果大于1个
+  // if (val.length > 1) {
+  //   // 清空选择
+  //   spuListRef.value.clearSelection()
+  //   // 变更为最后一次选择的
+  //   spuListRef.value.toggleRowSelection(val.pop(), true)
+  // }
+}
+// 确认选择时的触发事件
+const emits = defineEmits<{
+  (e: 'confirm', spuIds: number[], skuIds?: number[]): void
+}>()
+/**
+ * 确认选择返回选中的 spu 和 sku (如果需要选择sku的话)
+ */
+const confirm = () => {
+  if (selectedSpuIds.value.length === 0) {
+    message.warning('没有选择任何商品')
+    return
+  }
+  if (props.isSelectSku && selectedSkuIds.value.length === 0) {
+    message.warning('没有选择任何商品属性')
+    return
+  }
+  // 返回各自 id 列表
+  props.isSelectSku
+    ? emits('confirm', selectedSpuIds.value, selectedSkuIds.value)
+    : emits('confirm', selectedSpuIds.value)
+  // 关闭弹窗
+  dialogVisible.value = false
+}
+
+/** 打开弹窗 */
+const open = () => {
+  dialogTitle.value = '商品选择'
+  dialogVisible.value = true
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await ProductSpuApi.getSpuPage(queryParams.value)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryParams.value = {
+    pageNo: 1,
+    pageSize: 10,
+    tabType: 0, // 默认获取上架的商品
+    name: '',
+    categoryId: null,
+    createTime: []
+  }
+  getList()
+}
+
+/** 商品图预览 */
+const imagePreview = (imgUrl: string) => {
+  createImageViewer({
+    zIndex: 99999999,
+    urlList: [imgUrl]
+  })
+}
+
+const categoryList = ref() // 分类树
+
+/** 初始化 **/
+onMounted(async () => {
+  await getList()
+  // 获得分类树
+  const data = await ProductCategoryApi.getCategoryList({})
+  categoryList.value = handleTree(data, 'id', 'parentId')
+})
+</script>

+ 14 - 0
src/views/mall/promotion/components/index.ts

@@ -0,0 +1,14 @@
+import SpuSelect from './SpuSelect.vue'
+import SpuAndSkuList from './SpuAndSkuList.vue'
+import { Properties } from '@/views/mall/product/spu/components'
+
+type SpuProperty<T> = {
+  spuId: number
+  spuDetail: T
+  propertyList: Properties[]
+}
+
+/**
+ * 提供商品活动商品选择通用组件
+ */
+export { SpuSelect, SpuAndSkuList, SpuProperty }

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

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

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

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

+ 160 - 0
src/views/mall/promotion/seckill/activity/SeckillActivityForm.vue

@@ -0,0 +1,160 @@
+<template>
+  <Dialog v-model="dialogVisible" :title="dialogTitle" width="65%">
+    <Form
+      ref="formRef"
+      v-loading="formLoading"
+      :isCol="true"
+      :rules="rules"
+      :schema="allSchemas.formSchema"
+    >
+      <!-- 先选择 -->
+      <template #spuIds>
+        <el-button @click="spuSelectRef.open()">选择商品</el-button>
+        <SpuAndSkuList
+          ref="spuAndSkuListRef"
+          :rule-config="ruleConfig"
+          :spu-list="spuList"
+          :spu-property-list-p="spuPropertyList"
+        />
+      </template>
+    </Form>
+    <template #footer>
+      <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+  <SpuSelect ref="spuSelectRef" @confirm="selectSpu" />
+</template>
+<script lang="ts" setup>
+import { SpuAndSkuList, SpuProperty, SpuSelect } from '../../components'
+import { allSchemas, rules } from './seckillActivity.data'
+
+import * as SeckillActivityApi from '@/api/mall/promotion/seckill/seckillActivity'
+import { getPropertyList, RuleConfig } from '@/views/mall/product/spu/components'
+import * as ProductSpuApi from '@/api/mall/product/spu'
+
+defineOptions({ name: 'PromotionSeckillActivityForm' })
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formType = ref('') // 表单的类型:create - 新增;update - 修改
+const formRef = ref() // 表单 Ref
+const spuSelectRef = ref() // 商品和属性选择 Ref
+const spuAndSkuListRef = ref() // sku 秒杀配置组件Ref
+const ruleConfig: RuleConfig[] = [
+  {
+    name: 'productConfig.stock',
+    rule: (arg) => arg > 1,
+    message: '商品秒杀库存必须大于 1 !!!'
+  },
+  {
+    name: 'productConfig.seckillPrice',
+    rule: (arg) => arg > 0.01,
+    message: '商品秒杀价格必须大于 0.01 !!!'
+  }
+]
+/** 打开弹窗 */
+const open = async (type: string, id?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+  // 修改时,设置数据 TODO 没测试估计有问题
+  if (id) {
+    formLoading.value = true
+    try {
+      const data = await SeckillActivityApi.getSeckillActivity(id)
+      formRef.value.setValues(data)
+    } finally {
+      formLoading.value = false
+    }
+  }
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+const spuList = ref<SeckillActivityApi.SpuExtension[]>([]) // 选择的 spu
+const spuPropertyList = ref<SpuProperty<SeckillActivityApi.SpuExtension>[]>([])
+const selectSpu = (spuIds: number[]) => {
+  formRef.value.setValues({ spuIds })
+  getSpuDetails(spuIds)
+}
+/**
+ * 获取 SPU 详情
+ * TODO 获取 SPU 详情,放到各自活动表单来做,让 SpuAndSkuList 职责单一点
+ * @param spuIds
+ */
+const getSpuDetails = async (spuIds: number[]) => {
+  const spuProperties: SpuProperty<SeckillActivityApi.SpuExtension>[] = []
+  spuList.value = []
+  // TODO puhui999: 考虑后端添加通过 spuIds 批量获取
+  for (const spuId of spuIds) {
+    // 获取 SPU 详情
+    const res = (await ProductSpuApi.getSpu(spuId)) as SeckillActivityApi.SpuExtension
+    if (!res) {
+      continue
+    }
+    spuList.value.push(res)
+    // 初始化每个 sku 秒杀配置
+    res.skus?.forEach((sku) => {
+      const config: SeckillActivityApi.SeckillProductVO = {
+        spuId,
+        skuId: sku.id!,
+        stock: 0,
+        seckillPrice: 0
+      }
+      sku.productConfig = config
+    })
+    spuProperties.push({ spuId, spuDetail: res, propertyList: getPropertyList(res) })
+  }
+  spuPropertyList.value = spuProperties
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  spuList.value = []
+  spuPropertyList.value = []
+  formRef.value.getElFormRef().resetFields()
+}
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  if (!formRef) return
+  const valid = await formRef.value.getElFormRef().validate()
+  if (!valid) return
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = formRef.value.formModel as SeckillActivityApi.SeckillActivityVO
+    data.spuIds = spuList.value.map((spu) => spu.id!)
+    data.products = spuAndSkuListRef.value.getSkuConfigs('productConfig')
+    if (formType.value === 'create') {
+      await SeckillActivityApi.createSeckillActivity(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await SeckillActivityApi.updateSeckillActivity(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+</script>
+<style lang="scss" scoped>
+.demo-table-expand {
+  padding-left: 42px;
+
+  :deep(.el-form-item__label) {
+    width: 82px;
+    font-weight: bold;
+    color: #99a9bf;
+  }
+}
+</style>

+ 112 - 0
src/views/mall/promotion/seckill/activity/index.vue

@@ -0,0 +1,112 @@
+<template>
+  <!-- 搜索工作栏 -->
+  <ContentWrap>
+    <Search :schema="allSchemas.searchSchema" @reset="setSearchParams" @search="setSearchParams">
+      <!-- 新增等操作按钮 -->
+      <template #actionMore>
+        <el-button
+          v-hasPermi="['promotion:seckill-activity:create']"
+          plain
+          type="primary"
+          @click="openForm('create')"
+        >
+          <Icon class="mr-5px" icon="ep:plus" />
+          新增
+        </el-button>
+      </template>
+    </Search>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <Table
+      v-model:currentPage="tableObject.currentPage"
+      v-model:pageSize="tableObject.pageSize"
+      :columns="allSchemas.tableColumns"
+      :data="tableObject.tableList"
+      :expand="true"
+      :loading="tableObject.loading"
+      :pagination="{
+        total: tableObject.total
+      }"
+      @expand-change="expandChange"
+    >
+      <template #expand> 展示活动商品和商品相关属性活动配置</template>
+      <template #configIds="{ row }">
+        <el-tag v-for="(name, index) in convertSeckillConfigNames(row)" :key="index" class="mr-5px">
+          {{ name }}
+        </el-tag>
+      </template>
+      <template #action="{ row }">
+        <el-button
+          v-hasPermi="['promotion:seckill-activity:update']"
+          link
+          type="primary"
+          @click="openForm('update', row.id)"
+        >
+          编辑
+        </el-button>
+        <el-button
+          v-hasPermi="['promotion:seckill-activity:delete']"
+          link
+          type="danger"
+          @click="handleDelete(row.id)"
+        >
+          删除
+        </el-button>
+      </template>
+    </Table>
+  </ContentWrap>
+
+  <!-- 表单弹窗:添加/修改 -->
+  <SeckillActivityForm ref="formRef" @success="getList" />
+</template>
+<script lang="ts" setup>
+import { allSchemas } from './seckillActivity.data'
+import { getListAllSimple } from '@/api/mall/promotion/seckill/seckillConfig'
+import * as SeckillActivityApi from '@/api/mall/promotion/seckill/seckillActivity'
+import SeckillActivityForm from './SeckillActivityForm.vue'
+
+defineOptions({ name: 'PromotionSeckillActivity' })
+
+// tableObject:表格的属性对象,可获得分页大小、条数等属性
+// tableMethods:表格的操作对象,可进行获得分页、删除记录等操作
+// 详细可见:https://doc.iocoder.cn/vue3/crud-schema/
+const { tableObject, tableMethods } = useTable({
+  getListApi: SeckillActivityApi.getSeckillActivityPage, // 分页接口
+  delListApi: SeckillActivityApi.deleteSeckillActivity // 删除接口
+})
+// 获得表格的各种操作
+const { getList, setSearchParams } = tableMethods
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
+
+/** 删除按钮操作 */
+const handleDelete = (id: number) => {
+  tableMethods.delList(id, false)
+}
+
+// TODO @puhui:是不是直接叫 configList 就好啦
+const seckillConfigAllSimple = ref([]) // 时段配置精简列表
+const convertSeckillConfigNames = computed(
+  () => (row) =>
+    seckillConfigAllSimple.value
+      ?.filter((item) => row.configIds.includes(item.id))
+      ?.map((config) => config.name)
+)
+
+const expandChange = (row, expandedRows) => {
+  // TODO puhui:等 CRUD 完事后弄
+  console.log(row, expandedRows)
+}
+
+/** 初始化 **/
+onMounted(async () => {
+  await getList()
+  seckillConfigAllSimple.value = await getListAllSimple()
+})
+</script>

+ 262 - 0
src/views/mall/promotion/seckill/activity/seckillActivity.data.ts

@@ -0,0 +1,262 @@
+import type { CrudSchema } from '@/hooks/web/useCrudSchemas'
+import { dateFormatter, dateFormatter2 } from '@/utils/formatTime'
+import { getListAllSimple } from '@/api/mall/promotion/seckill/seckillConfig'
+
+// 表单校验
+export const rules = reactive({
+  spuId: [required],
+  name: [required],
+  startTime: [required],
+  endTime: [required],
+  sort: [required],
+  configIds: [required],
+  totalLimitCount: [required],
+  singleLimitCount: [required],
+  totalStock: [required]
+})
+
+// CrudSchema https://doc.iocoder.cn/vue3/crud-schema/
+const crudSchemas = reactive<CrudSchema[]>([
+  {
+    label: '秒杀活动名称',
+    field: 'name',
+    isSearch: true,
+    form: {
+      colProps: {
+        span: 24
+      }
+    },
+    table: {
+      width: 120
+    }
+  },
+  {
+    label: '活动开始时间',
+    field: 'startTime',
+    formatter: dateFormatter2,
+    isSearch: true,
+    search: {
+      component: 'DatePicker',
+      componentProps: {
+        valueFormat: 'YYYY-MM-DD',
+        type: 'daterange'
+      }
+    },
+    form: {
+      component: 'DatePicker',
+      componentProps: {
+        type: 'date',
+        valueFormat: 'x'
+      }
+    },
+    table: {
+      width: 120
+    }
+  },
+  {
+    label: '活动结束时间',
+    field: 'endTime',
+    formatter: dateFormatter2,
+    isSearch: true,
+    search: {
+      component: 'DatePicker',
+      componentProps: {
+        valueFormat: 'YYYY-MM-DD',
+        type: 'daterange'
+      }
+    },
+    form: {
+      component: 'DatePicker',
+      componentProps: {
+        type: 'date',
+        valueFormat: 'x'
+      }
+    },
+    table: {
+      width: 120
+    }
+  },
+  {
+    label: '秒杀时段',
+    field: 'configIds',
+    form: {
+      component: 'Select',
+      componentProps: {
+        multiple: true,
+        optionsAlias: {
+          labelField: 'name',
+          valueField: 'id'
+        }
+      },
+      api: getListAllSimple
+    },
+    table: {
+      width: 300
+    }
+  },
+  {
+    label: '新增订单数',
+    field: 'orderCount',
+    isForm: false,
+    form: {
+      component: 'InputNumber',
+      value: 0
+    },
+    table: {
+      width: 120
+    }
+  },
+  {
+    label: '付款人数',
+    field: 'userCount',
+    isForm: false,
+    form: {
+      component: 'InputNumber',
+      value: 0
+    },
+    table: {
+      width: 120
+    }
+  },
+  {
+    label: '订单实付金额',
+    field: 'totalPrice',
+    isForm: false,
+    form: {
+      component: 'InputNumber',
+      value: 0
+    },
+    table: {
+      width: 120
+    }
+  },
+  {
+    label: '总限购数量',
+    field: 'totalLimitCount',
+    form: {
+      component: 'InputNumber',
+      value: 0
+    },
+    table: {
+      width: 120
+    }
+  },
+  {
+    label: '单次限够数量',
+    field: 'singleLimitCount',
+    form: {
+      component: 'InputNumber',
+      value: 0
+    },
+    table: {
+      width: 120
+    }
+  },
+  {
+    label: '秒杀库存',
+    field: 'stock',
+    isForm: false,
+    form: {
+      component: 'InputNumber',
+      value: 0
+    },
+    table: {
+      width: 120
+    }
+  },
+  {
+    label: '秒杀总库存',
+    field: 'totalStock',
+    form: {
+      component: 'InputNumber',
+      value: 0
+    },
+    table: {
+      width: 120
+    }
+  },
+  {
+    label: '秒杀活动商品',
+    field: 'spuIds',
+    isTable: false,
+    isSearch: false,
+    form: {
+      colProps: {
+        span: 24
+      }
+    },
+    table: {
+      width: 200
+    }
+  },
+  {
+    label: '创建时间',
+    field: 'createTime',
+    formatter: dateFormatter,
+    search: {
+      component: 'DatePicker',
+      componentProps: {
+        valueFormat: 'YYYY-MM-DD HH:mm:ss',
+        type: 'daterange',
+        defaultTime: [new Date('1 00:00:00'), new Date('1 23:59:59')]
+      }
+    },
+    isForm: false,
+    table: {
+      width: 120
+    }
+  },
+  {
+    label: '排序',
+    field: 'sort',
+    form: {
+      component: 'InputNumber',
+      value: 0
+    },
+    table: {
+      width: 80
+    }
+  },
+  {
+    label: '状态',
+    field: 'status',
+    dictType: DICT_TYPE.COMMON_STATUS,
+    dictClass: 'number',
+    isForm: false,
+    isSearch: true,
+    form: {
+      component: 'Radio'
+    },
+    table: {
+      width: 80
+    }
+  },
+  {
+    label: '备注',
+    field: 'remark',
+    isSearch: false,
+    form: {
+      component: 'Input',
+      componentProps: {
+        type: 'textarea',
+        rows: 4
+      },
+      colProps: {
+        span: 24
+      }
+    },
+    table: {
+      width: 300
+    }
+  },
+  {
+    label: '操作',
+    field: 'action',
+    isForm: false,
+    table: {
+      width: 120,
+      fixed: 'right'
+    }
+  }
+])
+export const { allSchemas } = useCrudSchemas(crudSchemas)

+ 66 - 0
src/views/mall/promotion/seckill/config/SeckillConfigForm.vue

@@ -0,0 +1,66 @@
+<template>
+  <Dialog v-model="dialogVisible" :title="dialogTitle">
+    <Form ref="formRef" v-loading="formLoading" :rules="rules" :schema="allSchemas.formSchema" />
+    <template #footer>
+      <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script lang="ts" name="SeckillConfigForm" setup>
+import * as SeckillConfigApi from '@/api/mall/promotion/seckill/seckillConfig'
+import { allSchemas, rules } from './seckillConfig.data'
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formType = ref('') // 表单的类型:create - 新增;update - 修改
+const formRef = ref() // 表单 Ref
+
+/** 打开弹窗 */
+const open = async (type: string, id?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      const data = await SeckillConfigApi.getSeckillConfig(id)
+      formRef.value.setValues(data)
+    } finally {
+      formLoading.value = false
+    }
+  }
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  if (!formRef) return
+  const valid = await formRef.value.getElFormRef().validate()
+  if (!valid) return
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = formRef.value.formModel as SeckillConfigApi.SeckillConfigVO
+    if (formType.value === 'create') {
+      await SeckillConfigApi.createSeckillConfig(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await SeckillConfigApi.updateSeckillConfig(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+</script>

+ 123 - 0
src/views/mall/promotion/seckill/config/index.vue

@@ -0,0 +1,123 @@
+<template>
+  <!-- 搜索工作栏 -->
+  <ContentWrap>
+    <Search :schema="allSchemas.searchSchema" @reset="setSearchParams" @search="setSearchParams">
+      <!-- 新增等操作按钮 -->
+      <template #actionMore>
+        <el-button
+          v-hasPermi="['promotion:seckill-config:create']"
+          plain
+          type="primary"
+          @click="openForm('create')"
+        >
+          <Icon class="mr-5px" icon="ep:plus" />
+          新增
+        </el-button>
+      </template>
+    </Search>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <Table
+      v-model:currentPage="tableObject.currentPage"
+      v-model:pageSize="tableObject.pageSize"
+      :columns="allSchemas.tableColumns"
+      :data="tableObject.tableList"
+      :loading="tableObject.loading"
+      :pagination="{
+        total: tableObject.total
+      }"
+    >
+      <template #picUrl="{ row }">
+        <el-image :src="row.picUrl" class="w-30px h-30px" @click="imagePreview(row.picUrl)" />
+      </template>
+      <template #status="{ row }">
+        <el-switch
+          v-model="row.status"
+          :active-value="0"
+          :inactive-value="1"
+          @change="handleStatusChange(row)"
+        />
+      </template>
+      <template #action="{ row }">
+        <el-button
+          v-hasPermi="['promotion:seckill-config:update']"
+          link
+          type="primary"
+          @click="openForm('update', row.id)"
+        >
+          编辑
+        </el-button>
+        <el-button
+          v-hasPermi="['promotion:seckill-config:delete']"
+          link
+          type="danger"
+          @click="handleDelete(row.id)"
+        >
+          删除
+        </el-button>
+      </template>
+    </Table>
+  </ContentWrap>
+
+  <!-- 表单弹窗:添加/修改 -->
+  <SeckillConfigForm ref="formRef" @success="getList" />
+</template>
+<script lang="ts" name="PromotionSeckillConfig" setup>
+import { allSchemas } from './seckillConfig.data'
+import * as SeckillConfigApi from '@/api/mall/promotion/seckill/seckillConfig'
+import SeckillConfigForm from './SeckillConfigForm.vue'
+import { createImageViewer } from '@/components/ImageViewer'
+import { CommonStatusEnum } from '@/utils/constants'
+
+const message = useMessage() // 消息弹窗
+// tableObject:表格的属性对象,可获得分页大小、条数等属性
+// tableMethods:表格的操作对象,可进行获得分页、删除记录等操作
+// 详细可见:https://doc.iocoder.cn/vue3/crud-schema/
+const { tableObject, tableMethods } = useTable({
+  getListApi: SeckillConfigApi.getSeckillConfigPage, // 分页接口
+  delListApi: SeckillConfigApi.deleteSeckillConfig // 删除接口
+})
+// 获得表格的各种操作
+const { getList, setSearchParams } = tableMethods
+
+/** 商品图预览 */
+const imagePreview = (imgUrl: string) => {
+  createImageViewer({
+    urlList: [imgUrl]
+  })
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
+
+/** 删除按钮操作 */
+const handleDelete = (id: number) => {
+  tableMethods.delList(id, false)
+}
+
+/** 修改用户状态 */
+const handleStatusChange = async (row: SeckillConfigApi.SeckillConfigVO) => {
+  try {
+    // 修改状态的二次确认
+    const text = row.status === CommonStatusEnum.ENABLE ? '启用' : '停用'
+    await message.confirm('确认要"' + text + '""' + row.name + '?')
+    // 发起修改状态
+    await SeckillConfigApi.updateSeckillConfigStatus(row.id, row.status)
+    // 刷新列表
+    await getList()
+  } catch {
+    // 取消后,进行恢复按钮
+    row.status =
+      row.status === CommonStatusEnum.ENABLE ? CommonStatusEnum.DISABLE : CommonStatusEnum.ENABLE
+  }
+}
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</script>

+ 79 - 0
src/views/mall/promotion/seckill/config/seckillConfig.data.ts

@@ -0,0 +1,79 @@
+import type { CrudSchema } from '@/hooks/web/useCrudSchemas'
+import { dateFormatter } from '@/utils/formatTime'
+
+// 表单校验
+export const rules = reactive({
+  name: [required],
+  startTime: [required],
+  endTime: [required],
+  picUrl: [required],
+  status: [required]
+})
+
+// CrudSchema https://doc.iocoder.cn/vue3/crud-schema/
+const crudSchemas = reactive<CrudSchema[]>([
+  {
+    label: '秒杀时段名称',
+    field: 'name',
+    isSearch: true
+  },
+  {
+    label: '开始时间点',
+    field: 'startTime',
+    isSearch: false,
+    search: {
+      component: 'TimePicker'
+    },
+    form: {
+      component: 'TimePicker',
+      componentProps: {
+        valueFormat: 'HH:mm:ss'
+      }
+    }
+  },
+  {
+    label: '结束时间点',
+    field: 'endTime',
+    isSearch: false,
+    search: {
+      component: 'TimePicker'
+    },
+    form: {
+      component: 'TimePicker',
+      componentProps: {
+        valueFormat: 'HH:mm:ss'
+      }
+    }
+  },
+  {
+    label: '秒杀主图',
+    field: 'picUrl',
+    isSearch: false,
+    form: {
+      component: 'UploadImg'
+    }
+  },
+  {
+    label: '状态',
+    field: 'status',
+    dictType: DICT_TYPE.COMMON_STATUS,
+    dictClass: 'number',
+    isSearch: true,
+    form: {
+      component: 'Radio'
+    }
+  },
+  {
+    label: '创建时间',
+    field: 'createTime',
+    isForm: false,
+    isSearch: false,
+    formatter: dateFormatter
+  },
+  {
+    label: '操作',
+    field: 'action',
+    isForm: false
+  }
+])
+export const { allSchemas } = useCrudSchemas(crudSchemas)

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

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

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

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

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

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

+ 87 - 88
src/views/point/config/index.vue → src/views/mall/trade/delivery/pickUpStore/index.vue

@@ -1,32 +1,54 @@
 <template>
+  <!-- 搜索工作栏 -->
   <ContentWrap>
-    <!-- 搜索工作栏 -->
-    <el-form
-      class="-mb-15px"
-      :model="queryParams"
-      ref="queryFormRef"
-      :inline="true"
-      label-width="68px"
-    >
-      <el-form-item label="是否开启" prop="tradeDeductEnable">
-        <el-select
-          v-model="queryParams.tradeDeductEnable"
-          placeholder="请选择是否开启"
+    <el-form class="-mb-15px" :model="queryParams" ref="queryFormRef" :inline="true">
+      <el-form-item label="门店手机" prop="phone">
+        <el-input
+          v-model="queryParams.phone"
+          placeholder="请输门店手机"
           clearable
+          @keyup.enter="handleQuery"
           class="!w-240px"
-        >
+        />
+      </el-form-item>
+      <el-form-item label="门店名称" prop="name">
+        <el-input
+          v-model="queryParams.name"
+          placeholder="请输门店名称"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="门店状态" prop="status">
+        <el-select v-model="queryParams.status" placeholder="门店状态" clearable class="!w-240px">
           <el-option
-            v-for="dict in options"
+            v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
             :key="dict.value"
             :label="dict.label"
             :value="dict.value"
           />
         </el-select>
       </el-form-item>
+      <el-form-item label="创建时间" prop="createTime">
+        <el-date-picker
+          v-model="queryParams.createTime"
+          value-format="YYYY-MM-DD HH:mm:ss"
+          type="datetimerange"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          class="!w-240px"
+        />
+      </el-form-item>
       <el-form-item>
         <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
         <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
-        <el-button type="primary" @click="openForm('create')" v-hasPermi="['point:config:create']">
+        <el-button
+          type="primary"
+          plain
+          @click="openForm('create')"
+          v-hasPermi="['trade:delivery:pick-up-store:create']"
+        >
           <Icon icon="ep:plus" class="mr-5px" /> 新增
         </el-button>
         <el-button
@@ -34,7 +56,7 @@
           plain
           @click="handleExport"
           :loading="exportLoading"
-          v-hasPermi="['point:config:export']"
+          v-hasPermi="['trade:delivery:pick-up-store:export']"
         >
           <Icon icon="ep:download" class="mr-5px" /> 导出
         </el-button>
@@ -45,26 +67,25 @@
   <!-- 列表 -->
   <ContentWrap>
     <el-table v-loading="loading" :data="list">
-      <el-table-column label="序号" align="center" prop="id" />
-      <el-table-column
-        label="积分抵扣(是否开启)"
-        align="center"
-        prop="tradeDeductEnable"
-        :formatter="tradeDeductFormat"
-      />
-      <el-table-column label="抵扣单位(元)" align="center" prop="tradeDeductUnitPrice" />
-      <el-table-column label="积分抵扣最大值" align="center" prop="tradeDeductMaxPrice" />
-      <el-table-column label="1元赠送多少分" align="center" prop="tradeGivePoint" />
+      <el-table-column label="编号" prop="id" />
+      <el-table-column label="门店 logo" prop="logo">
+        <template #default="scope">
+          <img v-if="scope.row.logo" :src="scope.row.logo" alt="门店 logo" class="h-100px" />
+        </template>
+      </el-table-column>
+      <el-table-column label="门店名称" prop="name" />
+      <el-table-column label="门店手机" prop="phone" />
+      <el-table-column label="门店详细地址" align="center" prop="detailAddress" />
+      <el-table-column label="开启状态" align="center" prop="status">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
+        </template>
+      </el-table-column>
       <el-table-column
         label="创建时间"
         align="center"
         prop="createTime"
-        :formatter="dateFormatter"
-      />
-      <el-table-column
-        label="变更时间"
-        align="center"
-        prop="updateTime"
+        width="180"
         :formatter="dateFormatter"
       />
       <el-table-column label="操作" align="center">
@@ -73,7 +94,7 @@
             link
             type="primary"
             @click="openForm('update', scope.row.id)"
-            v-hasPermi="['point:config:update']"
+            v-hasPermi="['trade:delivery:pick-up-store:update']"
           >
             编辑
           </el-button>
@@ -81,67 +102,64 @@
             link
             type="danger"
             @click="handleDelete(scope.row.id)"
-            v-hasPermi="['point:config:delete']"
+            v-hasPermi="['trade:delivery:pick-up-store:delete']"
           >
             删除
           </el-button>
         </template>
       </el-table-column>
     </el-table>
-    <!-- 分页 -->
-    <Pagination
-      :total="total"
-      v-model:page="queryParams.pageNo"
-      v-model:limit="queryParams.pageSize"
-      @pagination="getList"
-    />
   </ContentWrap>
-
   <!-- 表单弹窗:添加/修改 -->
-  <ConfigForm ref="formRef" @success="getList" />
+  <DeliveryPickUpStoreForm ref="formRef" @success="getList" />
 </template>
-
-<script lang="ts" setup>
+<script setup lang="ts" name="DeliveryPickUpStore">
+import * as DeliveryPickUpStoreApi from '@/api/mall/trade/delivery/pickUpStore'
+import DeliveryPickUpStoreForm from './PickUpStoreForm.vue'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
 import { dateFormatter } from '@/utils/formatTime'
 import download from '@/utils/download'
-import * as ConfigApi from '@/api/point/config'
-import ConfigForm from './ConfigForm.vue'
-
-defineOptions({ name: 'PointConfig' })
-
 const message = useMessage() // 消息弹窗
 const { t } = useI18n() // 国际化
 
-const loading = ref(true) // 列表的加载中
 const total = ref(0) // 列表的总页数
-const list = ref([]) // 列表的数据
+const loading = ref(true) // 列表的加载中
+const exportLoading = ref(false) // 导出的加载中
+const list = ref<any[]>([]) // 列表的数据
 const queryParams = reactive({
   pageNo: 1,
   pageSize: 10,
-  tradeDeductEnable: null
+  status: undefined,
+  phone: undefined,
+  name: undefined,
+  createTime: []
 })
 const queryFormRef = ref() // 搜索的表单
-const exportLoading = ref(false) // 导出的加载中
 
-const options = [
-  {
-    value: '1',
-    label: '是'
-  },
-  {
-    value: '0',
-    label: '否'
-  }
-]
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
 
-const tradeDeductFormat = (row, column, cellValue) => {
-  return cellValue === 1 ? '是' : '否'
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await DeliveryPickUpStoreApi.deleteDeliveryPickUpStore(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
 }
+
 /** 查询列表 */
 const getList = async () => {
   loading.value = true
   try {
-    const data = await ConfigApi.getConfigPage(queryParams)
+    const data = await DeliveryPickUpStoreApi.getDeliveryPickUpStorePage(queryParams)
     list.value = data.list
     total.value = data.total
   } finally {
@@ -161,25 +179,6 @@ const resetQuery = () => {
   handleQuery()
 }
 
-/** 添加/修改操作 */
-const formRef = ref()
-const openForm = (type: string, id?: number) => {
-  formRef.value.open(type, id)
-}
-
-/** 删除按钮操作 */
-const handleDelete = async (id: number) => {
-  try {
-    // 删除的二次确认
-    await message.delConfirm()
-    // 发起删除
-    await ConfigApi.deleteConfig(id)
-    message.success(t('common.delSuccess'))
-    // 刷新列表
-    await getList()
-  } catch {}
-}
-
 /** 导出按钮操作 */
 const handleExport = async () => {
   try {
@@ -187,8 +186,8 @@ const handleExport = async () => {
     await message.exportConfirm()
     // 发起导出
     exportLoading.value = true
-    const data = await ConfigApi.exportConfig(queryParams)
-    download.excel(data, '积分设置.xls')
+    const data = await DeliveryPickUpStoreApi.exportDeliveryPickUpStoreApi(queryParams)
+    download.excel(data, '自提门店.xls')
   } catch {
   } finally {
     exportLoading.value = false

+ 572 - 0
src/views/mall/trade/order/index.vue

@@ -0,0 +1,572 @@
+<template>
+  <!-- 搜索 -->
+  <ContentWrap>
+    <el-form
+      ref="queryFormRef"
+      :model="queryParams"
+      class="-mb-15px"
+      label-width="68px"
+      :inline="true"
+    >
+      <el-form-item label="订单状态" prop="status">
+        <el-select class="!w-280px" v-model="queryParams.status" clearable placeholder="全部">
+          <el-option
+            v-for="dict in getStrDictOptions(DICT_TYPE.TRADE_ORDER_STATUS)"
+            :key="(dict.value as string)"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="支付方式" prop="payChannelCode">
+        <el-select
+          v-model="queryParams.payChannelCode"
+          class="!w-280px"
+          clearable
+          placeholder="全部"
+        >
+          <el-option
+            v-for="dict in getStrDictOptions(DICT_TYPE.PAY_CHANNEL_CODE_TYPE)"
+            :key="(dict.value as string)"
+            :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-280px"
+          start-placeholder="自定义时间"
+          end-placeholder="自定义时间"
+          type="daterange"
+          value-format="YYYY-MM-DD HH:mm:ss"
+        />
+      </el-form-item>
+      <el-form-item label="订单来源" prop="terminal">
+        <el-select class="!w-280px" v-model="queryParams.terminal" clearable placeholder="全部">
+          <el-option
+            v-for="dict in getStrDictOptions(DICT_TYPE.TERMINAL)"
+            :key="(dict.value as string)"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="订单类型" prop="type">
+        <el-select class="!w-280px" v-model="queryParams.type" clearable placeholder="全部">
+          <el-option
+            v-for="dict in getStrDictOptions(DICT_TYPE.TRADE_ORDER_TYPE)"
+            :key="(dict.value as string)"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+
+      <el-form-item label="订单搜索">
+        <el-input
+          v-show="true"
+          class="!w-280px"
+          v-model="queryType.v"
+          clearable
+          placeholder="请输入"
+        >
+          <template #prepend>
+            <el-select style="width: 110px" v-model="queryType.k" clearable placeholder="全部">
+              <el-option
+                v-for="dict in searchList"
+                :key="dict.value"
+                :label="dict.label"
+                :value="dict.value"
+              />
+            </el-select>
+          </template>
+        </el-input>
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery" v-hasPermi="['trade:order:query']">
+          <Icon class="mr-5px" icon="ep:search" />
+          搜索
+        </el-button>
+        <el-button @click="resetQuery" v-hasPermi="['trade:order:query']">
+          <Icon class="mr-5px" icon="ep:refresh" />
+          重置
+        </el-button>
+        <el-button type="success" plain @click="handleExport" :loading="exportLoading">
+          <!--           v-hasPermi="['trade:order:export']"        -->
+          <Icon icon="ep:download" class="mr-5px" /> 导出TODO
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+  <!-- 表格 -->
+  <ContentWrap>
+    <!-- 表单 -->
+    <el-table v-loading="loading" :data="list">
+      <el-table-column type="expand" fixed="left">
+        <template #default="scope">
+          <el-descriptions class="mx-40">
+            <el-descriptions-item label="商品原价(总): ">{{
+              '¥ ' +
+              parseFloat((scope.row.originalPrice / 100) as unknown as string).toFixed(2) +
+              ' 元'
+            }}</el-descriptions-item>
+            <el-descriptions-item label="下单时间: ">
+              {{ formatDate(scope.row.createTime) }}</el-descriptions-item
+            >
+            <el-descriptions-item label="推广人: ">TODO</el-descriptions-item>
+            <el-descriptions-item label="用户备注: ">{{
+              scope.row.userRemark
+            }}</el-descriptions-item>
+            <el-descriptions-item label="商家备注: ">{{ scope.row.remark }}</el-descriptions-item>
+          </el-descriptions>
+        </template>
+      </el-table-column>
+      <el-table-column width="100" fixed="left">
+        <template #header>
+          <el-dropdown icon="eq:search" @command="handleDropType">
+            <el-button link type="primary">全选({{ orderSelect.selectTotal }}) </el-button>
+
+            <template #dropdown>
+              <el-dropdown-menu>
+                <el-dropdown-item command="1">当前页</el-dropdown-item>
+                <el-dropdown-item command="2">所有页</el-dropdown-item>
+              </el-dropdown-menu>
+            </template>
+          </el-dropdown>
+        </template>
+        <template #default="scope">
+          <el-checkbox v-model="scope.row.itemSelect" @change="handcheckclick(scope.row)" />
+        </template>
+      </el-table-column>
+
+      <el-table-column label="订单号" align="center" min-width="110">
+        <template #default="scope">
+          <el-button link type="primary" @click="showOrderDetail(scope.row)">{{
+            scope.row.no
+          }}</el-button>
+        </template>
+      </el-table-column>
+      <el-table-column label="订单类型" align="center" min-width="100">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.TRADE_ORDER_TYPE" :value="scope.row.type" />
+        </template>
+      </el-table-column>
+      <el-table-column label="用户信息" align="center" min-width="100">
+        <template #default="scope">
+          <el-button link type="primary" @click="goUserDetail(scope.row)"
+            >{{ scope.row.userId }}{{ '[' + scope.row.user.nickname + ']' }}</el-button
+          >
+        </template>
+      </el-table-column>
+      <el-table-column
+        :formatter="dateFormatter"
+        align="center"
+        label="创建时间"
+        prop="createTime"
+        min-width="180"
+      />
+      <el-table-column label="订单来源" align="center" min-width="100">
+        <template #default="scope">
+          <dict-tag
+            v-if="scope.row.terminal"
+            :type="DICT_TYPE.TERMINAL"
+            :value="scope.row.terminal"
+          />
+          <span v-else>{{ scope.terminal }}</span>
+        </template>
+      </el-table-column>
+
+      <el-table-column label="商品信息" align="left" min-width="200" prop="items">
+        <template #default="scope">
+          <el-popover
+            ref="popover"
+            placement="bottom"
+            :title="'订单:' + scope.row.no"
+            :width="400"
+            trigger="hover"
+          >
+            <template #reference>
+              <div>
+                <div v-for="item in scope.row.items" :key="item">
+                  <el-image
+                    style="width: 36px; height: 36px"
+                    :src="item.picUrl"
+                    :preview-src-list="[item.picUrl]"
+                    fit="cover"
+                    @click="imagePreview(item.picUrl)"
+                  />
+                  <span class="m-2">{{ item.spuName }}</span>
+                </div>
+              </div>
+            </template>
+            <div v-for="item in scope.row.items" :key="item">
+              <div>
+                <p>{{ item.spuName }}</p>
+                <!-- TODO xiaobai: 是不是 (item.payPrice / 100.0).toFixed(2) -->
+                <p>{{
+                  '¥ ' +
+                  parseFloat((item.payPrice / 100) as unknown as string).toFixed(2) +
+                  '元 x ' +
+                  item.count
+                }}</p>
+              </div>
+            </div>
+          </el-popover>
+        </template>
+      </el-table-column>
+      <el-table-column label="实际支付(元)" align="center" prop="payPrice" min-width="100">
+        <template #default="scope">
+          {{ '¥ ' + parseFloat((scope.row.payPrice / 100) as unknown as string).toFixed(2) }}
+        </template>
+      </el-table-column>
+      <el-table-column
+        :formatter="dateFormatter"
+        align="center"
+        label="支付时间"
+        prop="payTime"
+        min-width="180"
+      />
+      <el-table-column label="支付类型" align="center" min-width="100" prop="payChannelCode">
+        <template #default="scope">
+          <dict-tag
+            v-if="scope.row.payChannelCode"
+            :type="DICT_TYPE.PAY_CHANNEL_CODE_TYPE"
+            :value="scope.row.payChannelCode"
+          />
+        </template>
+      </el-table-column>
+      <el-table-column label="订单状态" align="center" prop="status" min-width="100">
+        <template #default="scope">
+          <dict-tag
+            v-if="scope.row.status !== ''"
+            :type="DICT_TYPE.TRADE_ORDER_STATUS"
+            :value="scope.row.status"
+          />
+          <span v-else>{{ scope.status }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" align="center" fixed="right" min-width="150">
+        <template #default="scope">
+          <!-- <el-button v-if="scope.row.status == '0'" link type="primary" @click="sendXX(scope.row)"
+            >待支付</el-button> -->
+          <el-button v-if="scope.row.status == '10'" link type="primary" @click="sendXX(scope.row)"
+            >发货</el-button
+          >
+          <el-button link type="primary" @click="showOrderDetail(scope.row)">详情</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <!-- 分页 -->
+    <Pagination
+      v-model:limit="queryParams.pageSize"
+      v-model:page="queryParams.pageNo"
+      :total="total"
+      @pagination="getList"
+    />
+  </ContentWrap>
+  <el-image-viewer
+    v-if="imgViewVisible"
+    :url-list="imageViewerList"
+    @close="imgViewVisible = false"
+  />
+</template>
+<script setup lang="ts" name="OrderList">
+import { DICT_TYPE, getStrDictOptions } from '@/utils/dict'
+import * as TradeOrderApi from '@/api/mall/trade/order'
+import {
+  TradeOrderPageReqVO,
+  SelectType,
+  TradeOrderPageItemRespVO
+} from '@/api/mall/trade/order/type/orderType'
+import { dateFormatter, formatDate } from '@/utils/formatTime'
+import download from '@/utils/download'
+
+const message = useMessage()
+const { push } = useRouter()
+const imgViewVisible = ref(false) // 商品图预览
+const imageViewerList = ref<string[]>([]) // 商品图预览列表
+const queryFormRef = ref()
+const loading = ref(false)
+const exportLoading = ref(false)
+const total = ref(0) // 总记录数
+const list = ref<Array<TradeOrderPageItemRespVO | any>>([]) //表数据
+
+// 选中状态选中处理
+const orderSelect: SelectType = reactive({
+  queryParams: {} as TradeOrderPageReqVO,
+  selectTotal: 0,
+  selectAllFlag: false,
+  selectData: new Map<number, Set<string>>(),
+  unSelectList: new Set<string>()
+})
+
+//表单搜索
+const queryParams: TradeOrderPageReqVO = reactive({
+  pageNo: 1, //首页
+  pageSize: 10 //页面大小
+})
+
+const queryType = reactive({ k: '', v: '' }) // 订单搜索类型kv
+
+/*
+ * 订单搜索
+ * 商品名称  商品件数 全部  需要后端支持TODO
+ */
+const searchList = ref([
+  { value: 'no', label: '订单号' },
+  { value: 'userId', label: '用户UID' },
+  { value: 'userNickname', label: '用户昵称' },
+  { value: 'userMobile', label: '用户电话' },
+  { value: 'spuName', label: '商品名称TODO' },
+  { value: 'itemCount', label: '商品件数TODO' }
+])
+
+/**
+
+ 当前页/? 如果pageNo存在,则将但前数据全部按照单个选中模式取消 ,不存在,则新增全页 增加 Map.pageNo Map.roderNoList
+ 单个选中  如果pagelist存在,订单号选中状态取反,并对总数按选中状态加减。如果pagelist不存在,订单号选中状态取反,并对总数按选中状态加减,增加 Map.pageNo,
+ 如果当前Map.pageNo 所对应list 为空 ,清除pageNo
+ * @param command ===1 当前页 选中 ===2 所有页面选中
+ */
+const handleDropType = (command: string) => {
+  let i = 0
+  //当前页按钮
+  if (command === '1') {
+    //如果该页面有选中数据 则选中事件触发时 取消该页面
+    if (orderSelect.selectData && orderSelect.selectData.has(queryParams.pageNo)) {
+      for (i = 0; i < list.value.length; i++) {
+        if (orderSelect.selectData.get(queryParams.pageNo)!.has(list.value[i].id)) {
+          //选中数量减少
+          orderSelect.selectTotal -= 1
+          //考虑全选中,针对某一页面选中当前页时 会将所有数据中去掉该页面, 需要登记到 orderSelect.unSelectList
+          unSelectListRecord(list.value[i].id, 'add')
+        }
+        list.value[i]['itemSelect'] = false
+      }
+      orderSelect.selectData.delete(queryParams.pageNo) //移除该页面
+    } else {
+      //当前页选中状态中 默认全选中
+      orderSelect.selectData.set(queryParams.pageNo, new Set<string>())
+      for (i = 0; i < list.value.length; i++) {
+        list.value[i]['itemSelect'] = true
+        orderSelect.selectData.get(queryParams.pageNo)!.add(list.value[i].id)
+        //选中数量增加
+        orderSelect.selectTotal += 1
+        //对于登记过取消状态中的数据排除
+        unSelectListRecord(list.value[i].id, 'del')
+      }
+    }
+  }
+  //所有页按钮
+  if (command === '2') {
+    orderSelect.selectAllFlag = !orderSelect.selectAllFlag
+
+    if (orderSelect.selectAllFlag) {
+      //打勾勾 //全选
+      orderSelect.selectData?.set(queryParams.pageNo, new Set<string>())
+      for (i = 0; i < list.value.length; i++) {
+        list.value[i]['itemSelect'] = true
+        orderSelect.selectData?.get(queryParams.pageNo)?.add(list.value[i].id) //id是主键不重复
+      }
+      orderSelect.selectTotal = total.value
+    } else {
+      //取消勾勾
+      for (i; i < list.value.length; i++) {
+        list.value[i]['itemSelect'] = false
+      }
+      initSelect() //重置之前选中的类容清空
+    }
+  }
+}
+
+//对全选状态中的 单选或者当前页面单选时登记取消的数据
+const unSelectListRecord = (id: string, op: string) => {
+  if (!orderSelect.selectAllFlag) {
+    return
+  }
+  if (op == 'add') {
+    orderSelect.unSelectList.add(id)
+  } else {
+    orderSelect.unSelectList.delete(id)
+  }
+}
+/***复选框选中 */
+const handcheckclick = (row: any) => {
+  if (row.itemSelect) {
+    orderSelect.selectTotal += 1
+    if (!orderSelect.selectData.has(queryParams.pageNo)) {
+      orderSelect.selectData?.set(queryParams.pageNo, new Set<string>())
+    }
+    orderSelect.selectData?.get(queryParams.pageNo)?.add(row.id)
+    unSelectListRecord(row.id, 'del')
+  } else {
+    orderSelect.selectTotal -= 1
+    orderSelect.selectData.get(queryParams.pageNo)?.delete(row.id)
+    unSelectListRecord(row.id, 'add')
+  }
+}
+/**
+ * 导出数据
+ */
+
+const handleExport = async () => {
+  try {
+    // 导出的二次确认
+    await message.exportConfirm()
+    //增加查询条件 用于全选时后台查询数据
+    orderSelect.queryParams = queryParams
+
+    // 发起导出
+    exportLoading.value = true
+    //全选时 根据上送的条件查询所有数据,在排除unseleectList 数据,
+    //非全选时, 根据上送的selectData 直接查询数据 后台实现导出数据接口即可
+    console.log(orderSelect)
+    download.excel(orderSelect as any, '订单信息.xls') //?
+  } catch {
+  } finally {
+    exportLoading.value = false
+  }
+  //TODO
+  exportLoading.value = false
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  queryType.v = '' //重置
+  queryType.k = ''
+  //休眠0.1s 等待watch响应
+  setTimeout(() => {
+    initSelect() //重置对选中设置恢复初始状态
+    handleQuery()
+  }, 100)
+}
+
+/**选中状态初始化**/
+const initSelect = () => {
+  orderSelect.queryParams = {} as TradeOrderPageReqVO
+  orderSelect.selectTotal = 0
+  orderSelect.selectAllFlag = false
+  orderSelect.selectData?.clear()
+  orderSelect.unSelectList?.clear()
+}
+
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await TradeOrderApi.getOrderList(queryParams)
+    list.value = data.list
+    total.value = data.total
+    let i = 0
+    if (orderSelect.selectData && orderSelect.selectData.has(queryParams.pageNo)) {
+      //该页面已经加载过了。直接按照之前状态设置选中状态值
+      for (i = 0; i < list.value.length; i++) {
+        if (orderSelect.selectData.get(queryParams.pageNo)!.has(list.value[i].id)) {
+          list.value[i]['itemSelect'] = true //之前已经选取过了
+        } else {
+          list.value[i]['itemSelect'] = false
+        }
+      }
+    } else if (orderSelect.selectAllFlag) {
+      //全选状态中 首次加载页面 默认全部选中
+      orderSelect.selectData.set(queryParams.pageNo, new Set<string>())
+      for (i = 0; i < list.value.length; i++) {
+        list.value[i]['itemSelect'] = true
+        orderSelect.selectData.get(queryParams.pageNo)!.add(list.value[i].id)
+      }
+    } else {
+      //非全选状态中  首次加载默认非选中状态
+      for (i; i < list.value.length; i++) {
+        list.value[i]['itemSelect'] = false //设置状态为未选中状态
+      }
+    }
+  } finally {
+    loading.value = false
+  }
+}
+
+/**
+ * 跳转订单详情
+ */
+const showOrderDetail = (row: any) => {
+  push({ name: 'TradeOrderDetail', query: { id: row.id } })
+}
+
+/**
+ * 跳转用户详情
+ */
+const goUserDetail = (row: any) => {
+  console.log('TODO User Detail: ' + row.userId)
+}
+/**
+ * 发货
+ */
+const sendXX = (row: any) => {
+  console.log('TODO Send XX: ' + row.no)
+}
+
+/**
+ * 商品图预览
+ * @param imgUrl
+ */
+const imagePreview = (imgUrl: string) => {
+  imageViewerList.value = [imgUrl]
+  imgViewVisible.value = true
+}
+
+//针对订单搜索类型和值进行调整 使用监听器
+watch(
+  () => [queryType.k, queryType.v],
+  ([newK, newV], [oldK]) => {
+    //重置oldK对应得value
+    if (oldK != newK) {
+      if (oldK == 'no' && queryParams.no != '') {
+        queryParams.no = ''
+      } else if (oldK == 'userId' && queryParams.userId != '') {
+        queryParams.userId = ''
+      } else if (oldK == 'userNickname' && queryParams.userNickname != '') {
+        queryParams.userNickname = ''
+      } else if (oldK == 'userMobile' && queryParams.userMobile !== '') {
+        queryParams.userMobile = ''
+      } else if (oldK == 'spuName' && queryParams.spuName !== '') {
+        queryParams.spuName = ''
+      } else if (oldK == 'itemCount' && queryParams.itemCount !== '') {
+        queryParams.itemCount = ''
+      } else if (oldK == '' && queryParams.all !== '') {
+        queryParams.all = ''
+      }
+    }
+    // 根据选中得k设置Value
+    if (newK == 'no') {
+      queryParams.no = newV
+    } else if (newK == 'userId') {
+      queryParams.userId = newV
+    } else if (newK == 'userNickname') {
+      queryParams.userNickname = newV
+    } else if (newK == 'userMobile') {
+      queryParams.userMobile = newV
+    } else if (newK == 'spuName') {
+      queryParams.spuName = newV
+    } else if (newK == 'itemCount') {
+      queryParams.itemCount = newV
+    } else if (newK == '') {
+      queryParams.all = newV
+    }
+  }
+)
+
+/** 初始化 **/
+onMounted(() => {
+  initSelect()
+  getList()
+})
+</script>

+ 365 - 0
src/views/mall/trade/order/tradeOrderDetail.vue

@@ -0,0 +1,365 @@
+<template>
+  <ContentWrap>
+    <!-- 订单信息 -->
+    <el-descriptions title="订单信息">
+      <el-descriptions-item label="订单号: ">{{ order.no }}</el-descriptions-item>
+      <el-descriptions-item label="配送方式: ">物流配送</el-descriptions-item>
+      <!-- TODO 芋艿:待实现 -->
+      <el-descriptions-item label="营销活动: ">物流配送</el-descriptions-item>
+      <!-- TODO 芋艿:待实现 -->
+      <el-descriptions-item label="订单类型: ">
+        <dict-tag :type="DICT_TYPE.TRADE_ORDER_TYPE" :value="order.type" />
+      </el-descriptions-item>
+      <el-descriptions-item label="收货人: ">{{ order.receiverName }}</el-descriptions-item>
+      <el-descriptions-item label="买家留言: ">{{ order.userRemark }}</el-descriptions-item>
+      <el-descriptions-item label="订单来源: ">
+        <dict-tag :type="DICT_TYPE.TERMINAL" :value="order.terminal" />
+      </el-descriptions-item>
+      <el-descriptions-item label="联系电话: ">{{ order.receiverMobile }}</el-descriptions-item>
+      <el-descriptions-item label="商家备注: ">{{ order.remark }}</el-descriptions-item>
+      <el-descriptions-item label="支付单号: ">{{ order.payOrderId }}</el-descriptions-item>
+      <el-descriptions-item label="付款方式: ">
+        <dict-tag :type="DICT_TYPE.PAY_CHANNEL_CODE_TYPE" :value="order.payChannelCode" />
+      </el-descriptions-item>
+      <!-- <el-descriptions-item label="买家: ">{{ order.user.nickname }}</el-descriptions-item> -->
+      <!-- TODO 芋艿:待实现:跳转会员 -->
+      <el-descriptions-item label="收货地址: ">
+        {{ order.receiverAreaName }} {{ order.receiverDetailAddress }}
+        <el-link
+          v-clipboard:copy="order.receiverAreaName + ' ' + order.receiverDetailAddress"
+          v-clipboard:success="clipboardSuccess"
+          icon="ep:document-copy"
+          type="primary"
+        />
+      </el-descriptions-item>
+    </el-descriptions>
+
+    <!-- 订单状态 -->
+    <el-descriptions title="订单状态" :column="1">
+      <el-descriptions-item label="订单状态: ">
+        <!-- TODO xiaobai:status 一定有值哈,不用判断 -->
+        <dict-tag
+          v-if="order.status !== ''"
+          :type="DICT_TYPE.TRADE_ORDER_STATUS"
+          :value="order.status"
+        />
+      </el-descriptions-item>
+      <el-descriptions-item label-class-name="no-colon">
+        <el-button type="primary" size="small">调整价格</el-button>
+        <!-- TODO 芋艿:待实现 -->
+        <el-button type="primary" size="small">备注</el-button>
+        <!-- TODO 芋艿:待实现 -->
+        <el-button type="primary" size="small">发货</el-button>
+        <!-- TODO 芋艿:待实现 -->
+        <el-button type="primary" size="small">关闭订单</el-button>
+        <!-- TODO 芋艿:待实现 -->
+        <el-button type="primary" size="small">修改地址</el-button>
+        <!-- TODO 芋艿:待实现 -->
+        <el-button type="primary" size="small">打印电子面单</el-button>
+        <!-- TODO 芋艿:待实现 -->
+        <el-button type="primary" size="small">打印发货单</el-button>
+        <!-- TODO 芋艿:待实现 -->
+        <el-button type="primary" size="small">确认收货</el-button>
+        <!-- TODO 芋艿:待实现 -->
+      </el-descriptions-item>
+      <el-descriptions-item>
+        <template #label><span style="color: red">提醒: </span></template>
+        买家付款成功后,货款将直接进入您的商户号(微信、支付宝)<br />
+        请及时关注你发出的包裹状态,确保可以配送至买家手中 <br />
+        如果买家表示没收到货或货物有问题,请及时联系买家处理,友好协商
+      </el-descriptions-item>
+    </el-descriptions>
+
+    <!-- 物流信息 TODO -->
+
+    <!-- 商品信息 -->
+    <el-descriptions title="商品信息">
+      <el-descriptions-item labelClassName="no-colon">
+        <el-row :gutter="20">
+          <el-col :span="15">
+            <el-table :data="order.items" border>
+              <el-table-column prop="spuName" label="商品" width="auto">
+                <template #default="{ row }">
+                  {{ row.spuName }}
+                  <el-tag
+                    size="medium"
+                    v-for="property in row.properties"
+                    :key="property.propertyId"
+                  >
+                    {{ property.propertyName }}: {{ property.valueName }}</el-tag
+                  >
+                </template>
+              </el-table-column>
+              <el-table-column prop="price" label="商品原价(元)" width="150">
+                <template #default="{ row }"> ¥{{ (row.price / 100.0).toFixed(2) }} </template>
+              </el-table-column>
+              <el-table-column prop="count" label="数量" width="100" />
+              <el-table-column prop="payPrice" label="合计(元)" width="150">
+                <template #default="{ row }"> ¥{{ (row.payPrice / 100.0).toFixed(2) }} </template>
+              </el-table-column>
+              <el-table-column prop="afterSaleStatus" label="售后状态" width="auto">
+                <template #default="{ row }">
+                  <dict-tag
+                    :type="DICT_TYPE.TRADE_ORDER_ITEM_AFTER_SALE_STATUS"
+                    :value="row.afterSaleStatus"
+                  />
+                </template>
+              </el-table-column>
+            </el-table>
+          </el-col>
+          <el-col :span="10" />
+        </el-row>
+      </el-descriptions-item>
+      <!-- 占位 -->
+      <!-- <el-descriptions-item v-for="item in 5" label-class-name="no-colon" :key="item" /> -->
+    </el-descriptions>
+    <el-descriptions column="6">
+      <el-descriptions-item label="商品总额: ">
+        <!-- TODO xiaobai: 是不是 (item.payPrice / 100.0).toFixed(2) -->
+        ¥{{ parseFloat((order.totalPrice / 100.0) as unknown as string).toFixed(2) }}
+      </el-descriptions-item>
+      <el-descriptions-item label="运费金额: ">
+        ¥{{ parseFloat((order.deliveryPrice / 100.0) as unknown as string).toFixed(2) }}
+      </el-descriptions-item>
+      <el-descriptions-item label="订单调价: ">
+        ¥{{
+          parseFloat((order.adjustPrice / 100.0) as unknown as string).toFixed(2)
+        }}</el-descriptions-item
+      >
+
+      <el-descriptions-item>
+        <template #label><span style="color: red">商品优惠: </span></template>
+        <!-- 没理解TODO  order.totalPrice - order.totalPrice -->
+        ¥{{
+          parseFloat(((order.totalPrice - order.totalPrice) / 100.0) as unknown as string).toFixed(
+            2
+          )
+        }}
+      </el-descriptions-item>
+      <el-descriptions-item>
+        <template #label><span style="color: red">订单优惠: </span></template>
+        ¥{{ parseFloat((order.discountPrice / 100.0) as unknown as string).toFixed(2) }}
+      </el-descriptions-item>
+      <el-descriptions-item>
+        <template #label><span style="color: red">积分抵扣: </span></template>
+        ¥{{ parseFloat((order.pointPrice / 100.0) as unknown as string).toFixed(2) }}
+      </el-descriptions-item>
+
+      <el-descriptions-item v-for="item in 5" label-class-name="no-colon" :key="item" />
+      <!-- 占位 -->
+      <el-descriptions-item label="应付金额: ">
+        ¥{{ (order.payPrice / 100.0).toFixed(2) }}
+      </el-descriptions-item>
+    </el-descriptions>
+
+    <!-- TODO 芋艿:需要改改 -->
+    <div v-for="group in detailGroups" :key="group.title">
+      <el-descriptions v-bind="group.groupProps" :title="group.title">
+        <!-- 订单操作日志 -->
+        <el-descriptions-item v-if="group.key === 'orderLog'" labelClassName="no-colon">
+          <el-timeline>
+            <el-timeline-item
+              v-for="activity in detailInfo[group.key]"
+              :key="activity.timestamp"
+              :timestamp="activity.timestamp"
+            >
+              {{ activity.content }}
+            </el-timeline-item>
+          </el-timeline>
+        </el-descriptions-item>
+
+        <!-- 物流信息 -->
+        <!-- TODO @xiaobai:改成一个包裹哈;目前只允许发货一次 -->
+        <el-descriptions-item v-if="group.key === 'expressInfo'" labelClassName="no-colon">
+          <!-- 循环包裹物流信息 -->
+          <div v-show="(pkgInfo = detailInfo[group.key]) !== null" style="border: 1px dashed">
+            <!-- 包裹详情 -->
+            <el-descriptions class="m-5">
+              <el-descriptions-item
+                v-for="(pkgChild, pkgCIdx) in group.children"
+                v-bind="pkgChild.childProps"
+                :key="`pkgChild_${pkgCIdx}`"
+                :label="pkgChild.label"
+              >
+                <!-- 包裹商品列表 -->
+                <template v-if="pkgChild.valueKey === 'goodsList' && pkgInfo[pkgChild.valueKey]">
+                  <div
+                    v-for="(goodInfo, goodInfoIdx) in pkgInfo[pkgChild.valueKey]"
+                    :key="`goodInfo_${goodInfoIdx}`"
+                    style="display: flex"
+                  >
+                    <el-image
+                      style="width: 100px; height: 100px; flex: none"
+                      :src="goodInfo.imgUrl"
+                    />
+                    <el-descriptions :column="1">
+                      <el-descriptions-item labelClassName="no-colon">{{
+                        goodInfo.name
+                      }}</el-descriptions-item>
+                      <el-descriptions-item label="数量">{{ goodInfo.count }}</el-descriptions-item>
+                    </el-descriptions>
+                  </div>
+                </template>
+
+                <!-- 包裹物流详情 -->
+                <template v-else-if="pkgChild.valueKey === 'wlxq'">
+                  <el-row :gutter="10">
+                    <el-col :span="6" :offset="1">
+                      <el-timeline>
+                        <el-timeline-item
+                          v-for="(activity, index) in pkgInfo[pkgChild.valueKey]"
+                          :key="index"
+                          :timestamp="activity.timestamp"
+                        >
+                          {{ activity.content }}
+                        </el-timeline-item>
+                      </el-timeline>
+                    </el-col>
+                  </el-row>
+                </template>
+                <template v-else>
+                  {{ pkgInfo[pkgChild.valueKey] }}
+                </template>
+              </el-descriptions-item>
+            </el-descriptions>
+          </div>
+        </el-descriptions-item>
+      </el-descriptions>
+    </div>
+  </ContentWrap>
+</template>
+<script lang="ts" name="TradeOrderDetail" setup>
+// TODO @xiaobai:在 order 下创建一个 order/detail,然后改名为 index.vue
+import { DICT_TYPE } from '@/utils/dict'
+import * as TradeOrderApi from '@/api/mall/trade/order'
+const message = useMessage() // 消息弹窗
+
+const { query } = useRoute()
+const queryParams = reactive({
+  id: query.id
+})
+const dialogVisible = ref(false)
+const loading = ref(false)
+const order = ref<any>({
+  items: [],
+  user: {}
+}) // 详情数据
+
+const detailGroups = ref([
+  {
+    title: '物流信息',
+    key: 'expressInfo',
+    children: [
+      { label: '发货时间: ', valueKey: 'fhsj' },
+      { label: '物流公司: ', valueKey: 'wlgs' },
+      { label: '运单号: ', valueKey: 'ydh' },
+      { label: '物流状态: ', valueKey: 'wlzt', childProps: { span: 3 } },
+      { label: '物流详情: ', valueKey: 'wlxq' }
+    ]
+  },
+  {
+    title: '订单操作日志',
+    key: 'orderLog'
+  }
+])
+
+const detailInfo = ref({
+  expressInfo:
+    // 物流信息
+    {
+      label: '包裹1',
+      name: 'bg1',
+      fhsj: '2022-11-03 16:50:45',
+      wlgs: '极兔',
+      ydh: '2132123',
+      wlzt: '不支持此快递公司',
+      wlxq: [
+        {
+          content: '正在派送途中,请您准备签收(派件人:王涛,电话:13854563814)',
+          timestamp: '2018-04-15 15:00:16'
+        },
+        {
+          content: '快件到达 【烟台龙口东江村委营业点】',
+          timestamp: '2018-04-13 14:54:19'
+        },
+        {
+          content: '快件已发车',
+          timestamp: '2018-04-11 12:55:52'
+        },
+        {
+          content: '快件已发车',
+          timestamp: '2018-04-11 12:55:52'
+        },
+        {
+          content: '快件已发车',
+          timestamp: '2018-04-11 12:55:52'
+        }
+      ]
+    },
+  orderLog: [
+    // 订单操作日志
+    {
+      content: '买家【乌鸦】关闭了订单',
+      timestamp: '2018-04-15 15:00:16'
+    },
+    {
+      content: '买家【乌鸦】下单了',
+      timestamp: '2018-04-15 15:00:16'
+    }
+  ],
+  goodsInfo: [] // 商品详情tableData
+})
+// 暂考虑一次性加载详情页面所有数据 TODO
+const getlist = async () => {
+  dialogVisible.value = true
+  loading.value = true
+  try {
+    const res = await TradeOrderApi.getOrderDetail(queryParams.id as unknown as number)
+    order.value = res
+    console.log(order)
+  } catch {
+    message.error('获取详情数据失败')
+  } finally {
+    loading.value = false
+  }
+}
+onMounted(async () => {
+  await getlist()
+})
+const clipboardSuccess = () => {
+  message.success('复制成功')
+}
+</script>
+<style lang="scss" scoped>
+:deep(.el-descriptions) {
+  &:not(:nth-child(1)) {
+    margin-top: 20px;
+  }
+
+  .el-descriptions__title {
+    display: flex;
+    align-items: center;
+
+    &::before {
+      content: '';
+      display: inline-block;
+      margin-right: 10px;
+      width: 3px;
+      height: 20px;
+      background-color: #409eff;
+    }
+  }
+
+  .el-descriptions-item__container {
+    margin: 0 10px;
+
+    .no-colon {
+      margin: 0;
+
+      &::after {
+        content: '';
+      }
+    }
+  }
+}
+</style>

+ 93 - 0
src/views/member/point/config/index.vue

@@ -0,0 +1,93 @@
+<template>
+  <ContentWrap>
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="120px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="hideId" v-show="false">
+        <el-input v-model="formData.id" />
+      </el-form-item>
+      <!-- TODO @xiaqing:展示给用户的字段名,可以和 crmeb 保持一直,然后每一个表单都有类似 crmeb 的 tip;例如说:积分抵用比例(1积分抵多少金额)单位:元 -->
+      <el-form-item label="积分抵扣" prop="tradeDeductEnable">
+        <el-switch v-model="formData.tradeDeductEnable" />
+      </el-form-item>
+      <!-- TODO @xiaqing:用户看到的是元,最多 2 位;分是后端的存储哈 -->
+      <el-form-item label="抵扣单位(分)" prop="tradeDeductUnitPrice">
+        <el-input-number
+          v-model="formData.tradeDeductUnitPrice"
+          placeholder="请输入抵扣单位(分)"
+          style="width: 300px"
+        />
+      </el-form-item>
+      <el-form-item label="积分抵扣最大值" prop="tradeDeductMaxPrice">
+        <el-input-number
+          v-model="formData.tradeDeductMaxPrice"
+          placeholder="请输入积分抵扣最大值"
+          style="width: 300px"
+        />
+      </el-form-item>
+      <el-form-item label="1 元赠送多少分" prop="tradeGivePoint">
+        <el-input-number
+          v-model="formData.tradeGivePoint"
+          placeholder="请输入 1 元赠送多少积分"
+          style="width: 300px"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" @click="onSubmit">提交</el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+</template>
+<script lang="ts" setup>
+import * as ConfigApi from '@/api/point/config'
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formData = ref({
+  id: undefined,
+  tradeDeductEnable: undefined,
+  tradeDeductUnitPrice: undefined,
+  tradeDeductMaxPrice: undefined,
+  tradeGivePoint: undefined
+})
+const formRules = reactive({})
+const formRef = ref() // 表单 Ref
+
+/** 修改积分配置 */
+const onSubmit = async () => {
+  // 校验表单
+  if (!formRef) return
+  const valid = await formRef.value.validate()
+  if (!valid) return
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = formData.value as unknown as ConfigApi.ConfigVO
+    await ConfigApi.saveConfig(data)
+    message.success(t('common.updateSuccess'))
+    dialogVisible.value = false
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 获得积分配置 */
+const getConfig = async () => {
+  try {
+    const data = await ConfigApi.getConfig()
+    formData.value = data
+  } finally {
+  }
+}
+
+onMounted(() => {
+  getConfig()
+})
+</script>

+ 3 - 2
src/views/point/record/RecordForm.vue → src/views/member/point/record/RecordForm.vue

@@ -13,7 +13,7 @@
       <el-form-item label="业务类型" prop="bizType">
         <el-select v-model="formData.bizType" placeholder="请选择业务类型">
           <el-option
-            v-for="dict in getStrDictOptions(DICT_TYPE.POINT_BIZ_TYPE)"
+            v-for="dict in getStrDictOptions(DICT_TYPE.MEMBER_POINT_BIZ_TYPE)"
             :key="dict.value"
             :label="dict.label"
             :value="dict.value"
@@ -41,7 +41,7 @@
       <el-form-item label="积分状态" prop="status">
         <el-select v-model="formData.status" placeholder="积分状态">
           <el-option
-            v-for="dict in getIntDictOptions(DICT_TYPE.POINT_STATUS)"
+            v-for="dict in getIntDictOptions(DICT_TYPE.MEMBER_POINT_STATUS)"
             :key="dict.value"
             :label="dict.label"
             :value="dict.value"
@@ -157,6 +157,7 @@ const submitForm = async () => {
   }
 }
 
+// TODO @xiaqing:不需要更新操作哇?
 /** 重置表单 */
 const resetForm = () => {
   formData.value = {

+ 22 - 91
src/views/point/record/index.vue → src/views/member/point/record/index.vue

@@ -25,7 +25,7 @@
           class="!w-240px"
         >
           <el-option
-            v-for="dict in getStrDictOptions(DICT_TYPE.POINT_BIZ_TYPE)"
+            v-for="dict in getStrDictOptions(DICT_TYPE.MEMBER_POINT_BIZ_TYPE)"
             :key="dict.value"
             :label="dict.label"
             :value="dict.value"
@@ -50,14 +50,14 @@
       <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.POINT_STATUS)"
+            v-for="dict in getIntDictOptions(DICT_TYPE.MEMBER_POINT_STATUS)"
             :key="dict.value"
             :label="dict.label"
             :value="dict.value"
           />
         </el-select>
       </el-form-item>
-      <el-form-item label="发生时间" prop="createDate">
+      <el-form-item label="获得时间" prop="createDate">
         <el-date-picker
           v-model="queryParams.createDate"
           value-format="YYYY-MM-DD HH:mm:ss"
@@ -71,18 +71,6 @@
       <el-form-item>
         <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
         <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
-        <el-button type="primary" @click="openForm('create')" v-hasPermi="['point:record:create']">
-          <Icon icon="ep:plus" class="mr-5px" /> 新增
-        </el-button>
-        <el-button
-          type="success"
-          plain
-          @click="handleExport"
-          :loading="exportLoading"
-          v-hasPermi="['point:record:export']"
-        >
-          <Icon icon="ep:download" class="mr-5px" /> 导出
-        </el-button>
       </el-form-item>
     </el-form>
   </ContentWrap>
@@ -90,13 +78,18 @@
   <!-- 列表 -->
   <ContentWrap>
     <el-table v-loading="loading" :data="list">
-      <el-table-column label="序号" align="center" prop="id" />
-      <el-table-column label="业务编码" align="center" prop="bizId" />
-      <el-table-column label="业务类型" align="center" prop="bizType">
-        <template #default="scope">
-          <dict-tag :type="DICT_TYPE.POINT_BIZ_TYPE" :value="scope.row.bizType" />
-        </template>
-      </el-table-column>
+      <el-table-column label="编号" align="center" prop="id" />
+      <!-- TODO @xiaqing:展示用户的昵称哈; -->
+      <el-table-column label="用户" align="center" prop="userId" />
+      <el-table-column label="积分标题" align="center" prop="title" />
+      <el-table-column label="积分描述" align="center" prop="description" />
+      <el-table-column
+        label="获得时间"
+        align="center"
+        prop="createDate"
+        :formatter="dateFormatter"
+      />
+      <!-- todo @xiaqing:可以参考 crmeb 的展示,把积分和增加减少放一起,用红色和绿色展示 -->
       <el-table-column
         label="操作类型"
         align="center"
@@ -107,16 +100,19 @@
           }
         "
       />
-      <el-table-column label="积分标题" align="center" prop="title" />
-      <el-table-column label="积分描述" align="center" prop="description" />
       <el-table-column label="积分" align="center" prop="point" />
       <el-table-column label="变动后的积分" align="center" prop="totalPoint" />
+      <el-table-column label="业务编码" align="center" prop="bizId" />
+      <el-table-column label="业务类型" align="center" prop="bizType">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.MEMBER_POINT_BIZ_TYPE" :value="scope.row.bizType" />
+        </template>
+      </el-table-column>
       <el-table-column label="状态" align="center" prop="status">
         <template #default="scope">
-          <dict-tag :type="DICT_TYPE.POINT_STATUS" :value="scope.row.status" />
+          <dict-tag :type="DICT_TYPE.MEMBER_POINT_STATUS" :value="scope.row.status" />
         </template>
       </el-table-column>
-      <el-table-column label="用户id" align="center" prop="userId" />
       <el-table-column
         label="冻结时间"
         align="center"
@@ -129,32 +125,6 @@
         prop="thawingTime"
         :formatter="dateFormatter"
       />
-      <el-table-column
-        label="发生时间"
-        align="center"
-        prop="createDate"
-        :formatter="dateFormatter"
-      />
-      <el-table-column label="操作" align="center">
-        <template #default="scope">
-          <el-button
-            link
-            type="primary"
-            @click="openForm('update', scope.row.id)"
-            v-hasPermi="['point:record:update']"
-          >
-            编辑
-          </el-button>
-          <el-button
-            link
-            type="danger"
-            @click="handleDelete(scope.row.id)"
-            v-hasPermi="['point:record:delete']"
-          >
-            删除
-          </el-button>
-        </template>
-      </el-table-column>
     </el-table>
     <!-- 分页 -->
     <Pagination
@@ -172,15 +142,11 @@
 <script lang="ts" setup>
 import { DICT_TYPE, getStrDictOptions, getIntDictOptions } from '@/utils/dict'
 import { dateFormatter } from '@/utils/formatTime'
-import download from '@/utils/download'
 import * as RecordApi from '@/api/point/record'
 import RecordForm from './RecordForm.vue'
 
 defineOptions({ name: 'PointRecord' })
 
-const message = useMessage() // 消息弹窗
-const { t } = useI18n() // 国际化
-
 const loading = ref(true) // 列表的加载中
 const total = ref(0) // 列表的总页数
 const list = ref([]) // 列表的数据
@@ -195,7 +161,6 @@ const queryParams = reactive({
   createDate: []
 })
 const queryFormRef = ref() // 搜索的表单
-const exportLoading = ref(false) // 导出的加载中
 
 /** 查询列表 */
 const getList = async () => {
@@ -221,40 +186,6 @@ const resetQuery = () => {
   handleQuery()
 }
 
-/** 添加/修改操作 */
-const formRef = ref()
-const openForm = (type: string, id?: number) => {
-  formRef.value.open(type, id)
-}
-
-/** 删除按钮操作 */
-const handleDelete = async (id: number) => {
-  try {
-    // 删除的二次确认
-    await message.delConfirm()
-    // 发起删除
-    await RecordApi.deleteRecord(id)
-    message.success(t('common.delSuccess'))
-    // 刷新列表
-    await getList()
-  } catch {}
-}
-
-/** 导出按钮操作 */
-const handleExport = async () => {
-  try {
-    // 导出的二次确认
-    await message.exportConfirm()
-    // 发起导出
-    exportLoading.value = true
-    const data = await RecordApi.exportRecord(queryParams)
-    download.excel(data, '用户积分记录.xls')
-  } catch {
-  } finally {
-    exportLoading.value = false
-  }
-}
-
 /** 初始化 **/
 onMounted(() => {
   getList()

+ 0 - 0
src/views/point/signInConfig/SignInConfigForm.vue → src/views/member/signin/config/SignInConfigForm.vue


+ 6 - 9
src/views/point/signInConfig/index.vue → src/views/member/signin/config/index.vue

@@ -8,6 +8,7 @@
       :inline="true"
       label-width="68px"
     >
+      <!-- TODO @xiaqing:搜索可以去掉,因为一共就没几条配置哈 -->
       <el-form-item label="签到天数" prop="day">
         <el-input
           v-model="queryParams.day"
@@ -35,6 +36,7 @@
           :loading="exportLoading"
           v-hasPermi="['point:sign-in-config:export']"
         >
+          <!-- TODO @xiaqing:四个功能的导出都可以去掉 -->
           <Icon icon="ep:download" class="mr-5px" /> 导出
         </el-button>
       </el-form-item>
@@ -44,15 +46,10 @@
   <!-- 列表 -->
   <ContentWrap>
     <el-table v-loading="loading" :data="list">
-      <el-table-column label="序号" align="center" prop="id" v-if="false" />
+      <!-- TODO @xiaqing:展示优化下,改成第 1 天、第 2 天这种 -->
       <el-table-column label="签到天数" align="center" prop="day" />
-      <el-table-column label="签到分数" align="center" prop="point" />
-      <el-table-column
-        label="创建时间"
-        align="center"
-        prop="createTime"
-        :formatter="dateFormatter"
-      />
+      <el-table-column label="获得积分" align="center" prop="point" />
+      <!-- TODO @xiaqing:展示一个是否开启 -->
       <el-table-column label="操作" align="center">
         <template #default="scope">
           <el-button
@@ -88,7 +85,6 @@
 </template>
 
 <script lang="ts" setup>
-import { dateFormatter } from '@/utils/formatTime'
 import download from '@/utils/download'
 import * as SignInConfigApi from '@/api/point/signInConfig'
 import SignInConfigForm from './SignInConfigForm.vue'
@@ -109,6 +105,7 @@ const queryParams = reactive({
 const queryFormRef = ref() // 搜索的表单
 const exportLoading = ref(false) // 导出的加载中
 
+// TODO @xiaqing:可以不分页;
 /** 查询列表 */
 const getList = async () => {
   loading.value = true

+ 0 - 0
src/views/point/signInRecord/SignInRecordForm.vue → src/views/member/signin/record/SignInRecordForm.vue


+ 3 - 18
src/views/point/signInRecord/index.vue → src/views/member/signin/record/index.vue

@@ -40,14 +40,6 @@
       <el-form-item>
         <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
         <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
-        <!--        <el-button-->
-        <!--          type="primary"-->
-        <!--          plain-->
-        <!--          @click="openForm('create')"-->
-        <!--          v-hasPermi="['point:sign-in-record:create']"-->
-        <!--        >-->
-        <!--          <Icon icon="ep:plus" class="mr-5px" /> 新增-->
-        <!--        </el-button>-->
         <el-button
           type="success"
           plain
@@ -64,10 +56,11 @@
   <!-- 列表 -->
   <ContentWrap>
     <el-table v-loading="loading" :data="list">
-      <el-table-column label="序号" align="center" prop="id" />
+      <el-table-column label="编号" align="center" prop="id" />
+      <!-- TODO @xiaqing:展示用户昵称  -->
       <el-table-column label="签到用户" align="center" prop="userId" />
       <el-table-column label="签到天数" align="center" prop="day" />
-      <el-table-column label="签到的分数" align="center" prop="point" />
+      <el-table-column label="获得积分" align="center" prop="point" />
       <el-table-column
         label="签到时间"
         align="center"
@@ -76,14 +69,6 @@
       />
       <el-table-column label="操作" align="center">
         <template #default="scope">
-          <!--          <el-button-->
-          <!--            link-->
-          <!--            type="primary"-->
-          <!--            @click="openForm('update', scope.row.id)"-->
-          <!--            v-hasPermi="['point:sign-in-record:update']"-->
-          <!--          >-->
-          <!--            编辑-->
-          <!--          </el-button>-->
           <el-button
             link
             type="danger"

+ 0 - 122
src/views/point/config/ConfigForm.vue

@@ -1,122 +0,0 @@
-<template>
-  <Dialog :title="dialogTitle" v-model="dialogVisible" style="width: 600px">
-    <el-form
-      ref="formRef"
-      :model="formData"
-      :rules="formRules"
-      label-width="120px"
-      v-loading="formLoading"
-    >
-      <el-form-item label="积分抵扣" prop="tradeDeductEnable">
-        <el-select v-model="formData.tradeDeductEnable" placeholder="请选择是否开启">
-          <el-option
-            v-for="dict in options"
-            :key="dict.value"
-            :label="dict.label"
-            :value="dict.value"
-          />
-        </el-select>
-      </el-form-item>
-      <el-form-item label="抵扣单位(元)" prop="tradeDeductUnitPrice">
-        <el-input v-model="formData.tradeDeductUnitPrice" placeholder="请输入抵扣单位(元)" />
-      </el-form-item>
-      <el-form-item label="积分抵扣最大值" prop="tradeDeductMaxPrice">
-        <el-input v-model="formData.tradeDeductMaxPrice" placeholder="请输入积分抵扣最大值" />
-      </el-form-item>
-      <el-form-item label="1元赠送多少分" prop="tradeGivePoint">
-        <el-input v-model="formData.tradeGivePoint" placeholder="请输入1元赠送多少分" />
-      </el-form-item>
-    </el-form>
-    <template #footer>
-      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
-      <el-button @click="dialogVisible = false">取 消</el-button>
-    </template>
-  </Dialog>
-</template>
-<script lang="ts" setup>
-import * as ConfigApi from '@/api/point/config'
-
-const { t } = useI18n() // 国际化
-const message = useMessage() // 消息弹窗
-
-const dialogVisible = ref(false) // 弹窗的是否展示
-const dialogTitle = ref('') // 弹窗的标题
-const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
-const formType = ref('') // 表单的类型:create - 新增;update - 修改
-const formData = ref({
-  id: undefined,
-  tradeDeductEnable: undefined,
-  tradeDeductUnitPrice: undefined,
-  tradeDeductMaxPrice: undefined,
-  tradeGivePoint: undefined
-})
-const formRules = reactive({})
-const formRef = ref() // 表单 Ref
-
-const options = [
-  {
-    value: '1',
-    label: '是'
-  },
-  {
-    value: '0',
-    label: '否'
-  }
-]
-
-/** 打开弹窗 */
-const open = async (type: string, id?: number) => {
-  dialogVisible.value = true
-  dialogTitle.value = t('action.' + type)
-  formType.value = type
-  resetForm()
-  // 修改时,设置数据
-  if (id) {
-    formLoading.value = true
-    try {
-      formData.value = await ConfigApi.getConfig(id)
-    } finally {
-      formLoading.value = false
-    }
-  }
-}
-defineExpose({ open }) // 提供 open 方法,用于打开弹窗
-
-/** 提交表单 */
-const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
-const submitForm = async () => {
-  // 校验表单
-  if (!formRef) return
-  const valid = await formRef.value.validate()
-  if (!valid) return
-  // 提交请求
-  formLoading.value = true
-  try {
-    const data = formData.value as unknown as ConfigApi.ConfigVO
-    if (formType.value === 'create') {
-      await ConfigApi.createConfig(data)
-      message.success(t('common.createSuccess'))
-    } else {
-      await ConfigApi.updateConfig(data)
-      message.success(t('common.updateSuccess'))
-    }
-    dialogVisible.value = false
-    // 发送操作成功的事件
-    emit('success')
-  } finally {
-    formLoading.value = false
-  }
-}
-
-/** 重置表单 */
-const resetForm = () => {
-  formData.value = {
-    id: undefined,
-    tradeDeductEnable: undefined,
-    tradeDeductUnitPrice: undefined,
-    tradeDeductMaxPrice: undefined,
-    tradeGivePoint: undefined
-  }
-  formRef.value?.resetFields()
-}
-</script>

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

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