Browse Source

!287 reward
Merge pull request !287 from Bluemark/dev

芋道源码 1 year ago
parent
commit
bb73a6edfd
44 changed files with 3699 additions and 369 deletions
  1. 1 1
      src/api/mall/product/spu.ts
  2. 42 0
      src/api/mall/promotion/article/index.ts
  3. 39 0
      src/api/mall/promotion/articleCategory/index.ts
  4. 60 0
      src/api/mall/promotion/discount/discountActivity.ts
  5. 5 0
      src/api/mall/statistics/common.ts
  6. 42 10
      src/api/mall/statistics/member.ts
  7. 12 0
      src/api/mall/statistics/pay.ts
  8. 64 15
      src/api/mall/statistics/trade.ts
  9. 51 9
      src/api/mall/trade/order/index.ts
  10. 5 1
      src/api/pay/wallet/balance/index.ts
  11. 34 0
      src/api/pay/wallet/rechargePackage/index.ts
  12. 14 0
      src/api/pay/wallet/transaction/index.ts
  13. 89 0
      src/components/ShortcutDateRangePicker/index.vue
  14. 2 2
      src/components/SummaryCard/index.vue
  15. 13 0
      src/utils/index.ts
  16. 42 0
      src/views/mall/home/components/ComparisonCard.vue
  17. 91 0
      src/views/mall/home/components/MemberStatisticsCard.vue
  18. 92 0
      src/views/mall/home/components/OperationDataCard.vue
  19. 79 0
      src/views/mall/home/components/ShortcutCard.vue
  20. 208 0
      src/views/mall/home/components/TradeTrendCard.vue
  21. 111 0
      src/views/mall/home/index.vue
  22. 238 0
      src/views/mall/promotion/article/ArticleForm.vue
  23. 120 0
      src/views/mall/promotion/article/category/ArticleCategoryForm.vue
  24. 199 0
      src/views/mall/promotion/article/category/index.vue
  25. 229 0
      src/views/mall/promotion/article/index.vue
  26. 179 0
      src/views/mall/promotion/discountActivity/DiscountActivityForm.vue
  27. 119 0
      src/views/mall/promotion/discountActivity/discountActivity.data.ts
  28. 237 0
      src/views/mall/promotion/discountActivity/index.vue
  29. 119 0
      src/views/mall/statistics/member/components/MemberFunnelCard.vue
  30. 69 0
      src/views/mall/statistics/member/components/MemberTerminalCard.vue
  31. 40 212
      src/views/mall/statistics/member/index.vue
  32. 37 113
      src/views/mall/statistics/trade/index.vue
  33. 324 0
      src/views/mall/trade/delivery/pickUpOrder/index.vue
  34. 7 2
      src/views/mall/trade/order/detail/index.vue
  35. 108 0
      src/views/mall/trade/order/form/OrderPickUpForm.vue
  36. 1 0
      src/views/mall/trade/order/index.vue
  37. 2 2
      src/views/member/user/detail/UserAccountInfo.vue
  38. 10 0
      src/views/pay/app/components/channel/AlipayChannelForm.vue
  39. 22 0
      src/views/pay/wallet/balance/WalletForm.vue
  40. 149 0
      src/views/pay/wallet/balance/index.vue
  41. 122 0
      src/views/pay/wallet/rechargePackage/WalletRechargePackageForm.vue
  42. 185 0
      src/views/pay/wallet/rechargePackage/index.vue
  43. 68 0
      src/views/pay/wallet/transaction/WalletTransactionList.vue
  44. 19 2
      src/views/system/notify/template/NotifyTemplateSendForm.vue

+ 1 - 1
src/api/mall/product/spu.ts

@@ -104,5 +104,5 @@ export const exportSpu = async (params) => {
 
 // 获得商品 SPU 精简列表
 export const getSpuSimpleList = async () => {
-  return request.get({ url: '/product/spu/get-simple-list' })
+  return request.get({ url: '/product/spu/list-all-simple' })
 }

+ 42 - 0
src/api/mall/promotion/article/index.ts

@@ -0,0 +1,42 @@
+import request from '@/config/axios'
+
+export interface ArticleVO {
+  id: number
+  categoryId: number
+  title: string
+  author: string
+  picUrl: string
+  introduction: string
+  browseCount: string
+  sort: number
+  status: number
+  spuId: number
+  recommendHot: boolean
+  recommendBanner: boolean
+  content: string
+}
+
+// 查询文章管理列表
+export const getArticlePage = async (params) => {
+  return await request.get({ url: `/promotion/article/page`, params })
+}
+
+// 查询文章管理详情
+export const getArticle = async (id: number) => {
+  return await request.get({ url: `/promotion/article/get?id=` + id })
+}
+
+// 新增文章管理
+export const createArticle = async (data: ArticleVO) => {
+  return await request.post({ url: `/promotion/article/create`, data })
+}
+
+// 修改文章管理
+export const updateArticle = async (data: ArticleVO) => {
+  return await request.put({ url: `/promotion/article/update`, data })
+}
+
+// 删除文章管理
+export const deleteArticle = async (id: number) => {
+  return await request.delete({ url: `/promotion/article/delete?id=` + id })
+}

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

@@ -0,0 +1,39 @@
+import request from '@/config/axios'
+
+export interface ArticleCategoryVO {
+  id: number
+  name: string
+  picUrl: string
+  status: number
+  sort: number
+}
+
+// 查询文章分类列表
+export const getArticleCategoryPage = async (params) => {
+  return await request.get({ url: `/promotion/article-category/page`, params })
+}
+
+// 查询文章分类精简信息列表
+export const getSimpleArticleCategoryList = async () => {
+  return await request.get({ url: `/promotion/article-category/list-all-simple` })
+}
+
+// 查询文章分类详情
+export const getArticleCategory = async (id: number) => {
+  return await request.get({ url: `/promotion/article-category/get?id=` + id })
+}
+
+// 新增文章分类
+export const createArticleCategory = async (data: ArticleCategoryVO) => {
+  return await request.post({ url: `/promotion/article-category/create`, data })
+}
+
+// 修改文章分类
+export const updateArticleCategory = async (data: ArticleCategoryVO) => {
+  return await request.put({ url: `/promotion/article-category/update`, data })
+}
+
+// 删除文章分类
+export const deleteArticleCategory = async (id: number) => {
+  return await request.delete({ url: `/promotion/article-category/delete?id=` + id })
+}

+ 60 - 0
src/api/mall/promotion/discount/discountActivity.ts

@@ -0,0 +1,60 @@
+import request from '@/config/axios'
+import { Sku, Spu } from '@/api/mall/product/spu'
+
+export interface DiscountActivityVO {
+  id?: number
+  spuId?: number
+  name?: string
+  status?: number
+  remark?: string
+  startTime?: Date
+  endTime?: Date
+  products?: DiscountProductVO[]
+}
+// 限时折扣相关 属性
+export interface DiscountProductVO {
+  spuId: number
+  skuId: number
+  discountType: number
+  discountPercent: number
+  discountPrice: number
+}
+
+// 扩展 Sku 配置
+export type SkuExtension = Sku & {
+  productConfig: DiscountProductVO
+}
+
+export interface SpuExtension extends Spu {
+  skus: SkuExtension[] // 重写类型
+}
+
+// 查询限时折扣活动列表
+export const getDiscountActivityPage = async (params) => {
+  return await request.get({ url: '/promotion/discount-activity/page', params })
+}
+
+// 查询限时折扣活动详情
+export const getDiscountActivity = async (id: number) => {
+  return await request.get({ url: '/promotion/discount-activity/get?id=' + id })
+}
+
+// 新增限时折扣活动
+export const createDiscountActivity = async (data: DiscountActivityVO) => {
+  return await request.post({ url: '/promotion/discount-activity/create', data })
+}
+
+// 修改限时折扣活动
+export const updateDiscountActivity = async (data: DiscountActivityVO) => {
+  return await request.put({ url: '/promotion/discount-activity/update', data })
+}
+
+// 关闭限时折扣活动
+export const closeDiscountActivity = async (id: number) => {
+  return await request.put({ url: '/promotion/discount-activity/close?id=' + id })
+}
+
+// 删除限时折扣活动
+export const deleteDiscountActivity = async (id: number) => {
+  return await request.delete({ url: '/promotion/discount-activity/delete?id=' + id })
+}

+ 5 - 0
src/api/mall/statistics/common.ts

@@ -0,0 +1,5 @@
+/** 数据对照 Response VO */
+export interface DataComparisonRespVO<T> {
+  value: T
+  reference: T
+}

+ 42 - 10
src/api/mall/statistics/member.ts

@@ -1,6 +1,6 @@
 import request from '@/config/axios'
 import dayjs from 'dayjs'
-import { TradeStatisticsComparisonRespVO } from '@/api/mall/statistics/trade'
+import { DataComparisonRespVO } from '@/api/mall/statistics/common'
 import { formatDate } from '@/utils/formatTime'
 
 /** 会员分析 Request VO */
@@ -10,17 +10,17 @@ export interface MemberAnalyseReqVO {
 
 /** 会员分析 Response VO */
 export interface MemberAnalyseRespVO {
-  visitorCount: number
+  visitUserCount: number
   orderUserCount: number
   payUserCount: number
   atv: number
-  comparison: TradeStatisticsComparisonRespVO<MemberAnalyseComparisonRespVO>
+  comparison: DataComparisonRespVO<MemberAnalyseComparisonRespVO>
 }
 
 /** 会员分析对照数据 Response VO */
 export interface MemberAnalyseComparisonRespVO {
-  userCount: number
-  activeUserCount: number
+  registerUserCount: number
+  visitUserCount: number
   rechargeUserCount: number
 }
 
@@ -29,8 +29,8 @@ export interface MemberAreaStatisticsRespVO {
   areaId: number
   areaName: string
   userCount: number
-  orderCreateCount: number
-  orderPayCount: number
+  orderCreateUserCount: number
+  orderPayUserCount: number
   orderPayPrice: number
 }
 
@@ -54,6 +54,20 @@ export interface MemberTerminalStatisticsRespVO {
   userCount: number
 }
 
+/** 会员数量统计 Response VO */
+export interface MemberCountRespVO {
+  /** 用户访问量 */
+  visitUserCount: string
+  /** 注册用户数量 */
+  registerUserCount: number
+}
+
+/** 会员注册数量 Response VO */
+export interface MemberRegisterCountRespVO {
+  date: string
+  count: number
+}
+
 // 查询会员统计
 export const getMemberSummary = () => {
   return request.get<MemberSummaryRespVO>({
@@ -72,20 +86,38 @@ export const getMemberAnalyse = (params: MemberAnalyseReqVO) => {
 // 按照省份,查询会员统计列表
 export const getMemberAreaStatisticsList = () => {
   return request.get<MemberAreaStatisticsRespVO[]>({
-    url: '/statistics/member/get-area-statistics-list'
+    url: '/statistics/member/area-statistics-list'
   })
 }
 
 // 按照性别,查询会员统计列表
 export const getMemberSexStatisticsList = () => {
   return request.get<MemberSexStatisticsRespVO[]>({
-    url: '/statistics/member/get-sex-statistics-list'
+    url: '/statistics/member/sex-statistics-list'
   })
 }
 
 // 按照终端,查询会员统计列表
 export const getMemberTerminalStatisticsList = () => {
   return request.get<MemberTerminalStatisticsRespVO[]>({
-    url: '/statistics/member/get-terminal-statistics-list'
+    url: '/statistics/member/terminal-statistics-list'
+  })
+}
+
+// 获得用户数量量对照
+export const getUserCountComparison = () => {
+  return request.get<DataComparisonRespVO<MemberCountRespVO>>({
+    url: '/statistics/member/user-count-comparison'
+  })
+}
+
+// 获得会员注册数量列表
+export const getMemberRegisterCountList = (
+  beginTime: dayjs.ConfigType,
+  endTime: dayjs.ConfigType
+) => {
+  return request.get<MemberRegisterCountRespVO[]>({
+    url: '/statistics/member/register-count-list',
+    params: { times: [formatDate(beginTime), formatDate(endTime)] }
   })
 }

+ 12 - 0
src/api/mall/statistics/pay.ts

@@ -0,0 +1,12 @@
+import request from '@/config/axios'
+
+/** 支付统计 */
+export interface PaySummaryRespVO {
+  /** 充值金额,单位分 */
+  rechargePrice: number
+}
+
+/** 获取钱包充值金额 */
+export const getWalletRechargePrice = async () => {
+  return await request.get<PaySummaryRespVO>({ url: `/statistics/pay/summary` })
+}

+ 64 - 15
src/api/mall/statistics/trade.ts

@@ -1,12 +1,7 @@
 import request from '@/config/axios'
 import dayjs from 'dayjs'
 import { formatDate } from '@/utils/formatTime'
-
-/** 交易统计对照 Response VO */
-export interface TradeStatisticsComparisonRespVO<T> {
-  value: T
-  reference: T
-}
+import { DataComparisonRespVO } from '@/api/mall/statistics/common'
 
 /** 交易统计 Response VO */
 export interface TradeSummaryRespVO {
@@ -24,46 +19,100 @@ export interface TradeTrendReqVO {
 /** 交易状况统计 Response VO */
 export interface TradeTrendSummaryRespVO {
   time: string
-  turnover: number
+  turnoverPrice: number
   orderPayPrice: number
   rechargePrice: number
   expensePrice: number
-  balancePrice: number
+  walletPayPrice: number
   brokerageSettlementPrice: number
-  orderRefundPrice: number
+  afterSaleRefundPrice: number
+}
+
+/** 交易订单数量 Response VO */
+export interface TradeOrderCountRespVO {
+  /** 待发货 */
+  undelivered?: number
+  /** 待核销 */
+  pickUp?: number
+  /** 退款中 */
+  afterSaleApply?: number
+  /** 提现待审核 */
+  auditingWithdraw?: number
+}
+
+/** 交易订单统计 Response VO */
+export interface TradeOrderSummaryRespVO {
+  /** 支付订单商品数 */
+  orderPayCount?: number
+  /** 总支付金额,单位:分 */
+  orderPayPrice?: number
+}
+
+/** 订单量趋势统计 Response VO */
+export interface TradeOrderTrendRespVO {
+  /** 日期 */
+  date: string
+  /** 订单数量 */
+  orderPayCount: number
+  /** 订单支付金额 */
+  orderPayPrice: number
 }
 
 // 查询交易统计
 export const getTradeStatisticsSummary = () => {
-  return request.get<TradeStatisticsComparisonRespVO<TradeSummaryRespVO>>({
+  return request.get<DataComparisonRespVO<TradeSummaryRespVO>>({
     url: '/statistics/trade/summary'
   })
 }
 
 // 获得交易状况统计
 export const getTradeTrendSummary = (params: TradeTrendReqVO) => {
-  return request.get<TradeStatisticsComparisonRespVO<TradeTrendSummaryRespVO>>({
+  return request.get<DataComparisonRespVO<TradeTrendSummaryRespVO>>({
     url: '/statistics/trade/trend/summary',
     params: formatDateParam(params)
   })
 }
 
 // 获得交易状况明细
-export const getTradeTrendList = (params: TradeTrendReqVO) => {
+export const getTradeStatisticsList = (params: TradeTrendReqVO) => {
   return request.get<TradeTrendSummaryRespVO[]>({
-    url: '/statistics/trade/trend/list',
+    url: '/statistics/trade/list',
     params: formatDateParam(params)
   })
 }
 
 // 导出交易状况明细
-export const exportTradeTrend = (params: TradeTrendReqVO) => {
+export const exportTradeStatisticsExcel = (params: TradeTrendReqVO) => {
   return request.download({
-    url: '/statistics/trade/trend/export-excel',
+    url: '/statistics/trade/export-excel',
     params: formatDateParam(params)
   })
 }
 
+// 获得交易订单数量
+export const getOrderCount = async () => {
+  return await request.get<TradeOrderCountRespVO>({ url: `/statistics/trade/order-count` })
+}
+
+// 获得交易订单数量对照
+export const getOrderComparison = async () => {
+  return await request.get<DataComparisonRespVO<TradeOrderSummaryRespVO>>({
+    url: `/statistics/trade/order-comparison`
+  })
+}
+
+// 获得订单量趋势统计
+export const getOrderCountTrendComparison = (
+  type: number,
+  beginTime: dayjs.ConfigType,
+  endTime: dayjs.ConfigType
+) => {
+  return request.get<DataComparisonRespVO<TradeOrderTrendRespVO>[]>({
+    url: '/statistics/trade/order-count-trend',
+    params: { type, beginTime: formatDate(beginTime), endTime: formatDate(endTime) }
+  })
+}
+
 /** 时间参数需要格式化, 确保接口能识别 */
 const formatDateParam = (params: TradeTrendReqVO) => {
   return { times: [formatDate(params.times[0]), formatDate(params.times[1])] } as TradeTrendReqVO

+ 51 - 9
src/api/mall/trade/order/index.ts

@@ -1,6 +1,7 @@
 import request from '@/config/axios'
 
 export interface OrderVO {
+  // ========== 订单基本信息 ==========
   id?: number | null // 订单编号
   no?: string // 订单流水号
   createTime?: Date | null // 下单时间
@@ -15,35 +16,43 @@ export interface OrderVO {
   cancelTime?: Date | null // 订单取消时间
   cancelType?: number | null // 取消类型
   remark?: string // 商家备注
+
+  // ========== 价格 + 支付基本信息 ==========
   payOrderId?: number | null // 支付订单编号
-  payed?: boolean // 是否已支付
+  payStatus?: boolean // 是否已支付
   payTime?: Date | null // 付款时间
   payChannelCode?: string // 支付渠道
   totalPrice?: number | null // 商品原价(总)
-  orderPrice?: number | null // 订单原价(总)
   discountPrice?: number | null // 订单优惠(总)
   deliveryPrice?: number | null // 运费金额
   adjustPrice?: number | null // 订单调价(总)
   payPrice?: number | null // 应付金额(总)
+  // ========== 收件 + 物流基本信息 ==========
   deliveryType?: number | null // 发货方式
+  pickUpStoreId?: number // 自提门店编号
+  pickUpVerifyCode?: string // 自提核销码
   deliveryTemplateId?: number | null // 配送模板编号
-  logisticsId?: number | null | null // 发货物流公司编号
+  logisticsId?: number | null // 发货物流公司编号
   logisticsNo?: string // 发货物流单号
-  deliveryStatus?: number | null // 发货状态
   deliveryTime?: Date | null // 发货时间
   receiveTime?: Date | null // 收货时间
   receiverName?: string // 收件人名称
   receiverMobile?: string // 收件人手机
-  receiverAreaId?: number | null // 收件人地区编号
   receiverPostCode?: number | null // 收件人邮编
+  receiverAreaId?: number | null // 收件人地区编号
+  receiverAreaName?: string //收件人地区名字
   receiverDetailAddress?: string // 收件人详细地址
+
+  // ========== 售后基本信息 ==========
   afterSaleStatus?: number | null // 售后状态
   refundPrice?: number | null // 退款金额
+
+  // ========== 营销基本信息 ==========
   couponId?: number | null // 优惠劵编号
   couponPrice?: number | null // 优惠劵减免金额
-  vipPrice?: number | null // VIP 减免金额
   pointPrice?: number | null // 积分抵扣的金额
-  receiverAreaName?: string //收件人地区名字
+  vipPrice?: number | null // VIP 减免金额
+
   items?: OrderItemRespVO[] // 订单项列表
   // 下单用户信息
   user?: {
@@ -99,11 +108,28 @@ export interface ProductPropertiesVO {
   valueName?: string // 属性值的名称
 }
 
+/** 交易订单统计 */
+export interface TradeOrderSummaryRespVO {
+  /** 订单数量 */
+  orderCount?: number
+  /** 订单金额 */
+  orderPayPrice?: string
+  /** 退款单数 */
+  afterSaleCount?: number
+  /** 退款金额 */
+  afterSalePrice?: string
+}
+
 // 查询交易订单列表
-export const getOrderPage = async (params) => {
+export const getOrderPage = async (params: any) => {
   return await request.get({ url: `/trade/order/page`, params })
 }
 
+// 查询交易订单统计
+export const getOrderSummary = async (params: any) => {
+  return await request.get<TradeOrderSummaryRespVO>({ url: `/trade/order/summary`, params })
+}
+
 // 查询交易订单详情
 export const getOrder = async (id: number | null) => {
   return await request.get({ url: `/trade/order/get-detail?id=` + id })
@@ -142,5 +168,21 @@ export const updateOrderAddress = async (data: any) => {
 
 // 订单核销
 export const pickUpOrder = async (id: number) => {
-  return await request.put({ url: `/trade/order/pick-up?id=${id}` })
+  return await request.put({ url: `/trade/order/pick-up-by-id?id=${id}` })
+}
+
+// 订单核销
+export const pickUpOrderByVerifyCode = async (pickUpVerifyCode: string) => {
+  return await request.put({
+    url: `/trade/order/pick-up-by-verify-code`,
+    params: { pickUpVerifyCode }
+  })
+}
+
+// 查询核销码对应的订单
+export const getOrderByPickUpVerifyCode = async (pickUpVerifyCode: string) => {
+  return await request.get<OrderVO>({
+    url: `/trade/order/get-by-pick-up-verify-code`,
+    params: { pickUpVerifyCode }
+  })
 }

+ 5 - 1
src/api/pay/wallet/index.ts → src/api/pay/wallet/balance/index.ts

@@ -3,7 +3,6 @@ import request from '@/config/axios'
 /** 用户钱包查询参数 */
 export interface PayWalletUserReqVO {
   userId: number
-  userType: number
 }
 /** 钱包 VO */
 export interface WalletVO {
@@ -20,3 +19,8 @@ export interface WalletVO {
 export const getWallet = async (params: PayWalletUserReqVO) => {
   return await request.get<WalletVO>({ url: `/pay/wallet/get`, params })
 }
+
+// 查询会员钱包列表
+export const getWalletPage = async (params) => {
+  return await request.get({ url: `/pay/wallet/page`, params })
+}

+ 34 - 0
src/api/pay/wallet/rechargePackage/index.ts

@@ -0,0 +1,34 @@
+import request from '@/config/axios'
+
+export interface WalletRechargePackageVO {
+  id: number
+  name: string
+  payPrice: number
+  bonusPrice: number
+  status: number
+}
+
+// 查询套餐充值列表
+export const getWalletRechargePackagePage = async (params) => {
+  return await request.get({ url: '/pay/wallet-recharge-package/page', params })
+}
+
+// 查询套餐充值详情
+export const getWalletRechargePackage = async (id: number) => {
+  return await request.get({ url: '/pay/wallet-recharge-package/get?id=' + id })
+}
+
+// 新增套餐充值
+export const createWalletRechargePackage = async (data: WalletRechargePackageVO) => {
+  return await request.post({ url: '/pay/wallet-recharge-package/create', data })
+}
+
+// 修改套餐充值
+export const updateWalletRechargePackage = async (data: WalletRechargePackageVO) => {
+  return await request.put({ url: '/pay/wallet-recharge-package/update', data })
+}
+
+// 删除套餐充值
+export const deleteWalletRechargePackage = async (id: number) => {
+  return await request.delete({ url: '/pay/wallet-recharge-package/delete?id=' + id })
+}

+ 14 - 0
src/api/pay/wallet/transaction/index.ts

@@ -0,0 +1,14 @@
+import request from '@/config/axios'
+
+export interface WalletTransactionVO {
+  id: number
+  walletId: number
+  title: string
+  price: number
+  balance: number
+}
+
+// 查询会员钱包流水列表
+export const getWalletTransactionPage = async (params) => {
+  return await request.get({ url: `/pay/wallet-transaction/page`, params })
+}

+ 89 - 0
src/components/ShortcutDateRangePicker/index.vue

@@ -0,0 +1,89 @@
+<template>
+  <div class="flex flex-row items-center gap-2">
+    <el-radio-group v-model="shortcutDays" @change="handleShortcutDaysChange">
+      <el-radio-button :label="1">昨天</el-radio-button>
+      <el-radio-button :label="7">最近7天</el-radio-button>
+      <el-radio-button :label="30">最近30天</el-radio-button>
+    </el-radio-group>
+    <el-date-picker
+      v-model="times"
+      value-format="YYYY-MM-DD HH:mm:ss"
+      type="daterange"
+      start-placeholder="开始日期"
+      end-placeholder="结束日期"
+      :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+      :shortcuts="shortcuts"
+      class="!w-240px"
+      @change="emitDateRangePicker"
+    />
+    <slot></slot>
+  </div>
+</template>
+<script lang="ts" setup>
+import dayjs from 'dayjs'
+import * as DateUtil from '@/utils/formatTime'
+
+/** 快捷日期范围选择组件 */
+defineOptions({ name: 'ShortcutDateRangePicker' })
+
+const shortcutDays = ref(7) // 日期快捷天数(单选按钮组), 默认7天
+const times = ref<[dayjs.ConfigType, dayjs.ConfigType]>(['', '']) // 时间范围参数
+defineExpose({ times }) // 暴露时间范围参数
+/** 日期快捷选择 */
+const shortcuts = [
+  {
+    text: '昨天',
+    value: () => DateUtil.getDayRange(new Date(), -1)
+  },
+  {
+    text: '最近7天',
+    value: () => DateUtil.getLast7Days()
+  },
+  {
+    text: '本月',
+    value: () => [dayjs().startOf('M'), dayjs().subtract(1, 'd')]
+  },
+  {
+    text: '最近30天',
+    value: () => DateUtil.getLast30Days()
+  },
+  {
+    text: '最近1年',
+    value: () => DateUtil.getLast1Year()
+  }
+]
+
+/** 设置时间范围 */
+function setTimes() {
+  const beginDate = dayjs().subtract(shortcutDays.value, 'd')
+  const yesterday = dayjs().subtract(1, 'd')
+  times.value = DateUtil.getDateRange(beginDate, yesterday)
+}
+
+/** 快捷日期单选按钮选中 */
+const handleShortcutDaysChange = async () => {
+  // 设置时间范围
+  setTimes()
+  // 发送时间范围选中事件
+  await emitDateRangePicker()
+}
+
+/** 触发事件:时间范围选中 */
+const emits = defineEmits<{
+  (e: 'change', times: [dayjs.ConfigType, dayjs.ConfigType]): void
+}>()
+/** 触发时间范围选中事件 */
+const emitDateRangePicker = async () => {
+  // 开始与截止在同一天的, 折线图出不来, 需要延长一天
+  if (DateUtil.isSameDay(times.value[0], times.value[1])) {
+    // 前天
+    times.value[0] = DateUtil.formatDate(dayjs(times.value[0]).subtract(1, 'd'))
+  }
+  emits('change', times.value)
+}
+
+/** 初始化 **/
+onMounted(() => {
+  handleShortcutDaysChange()
+})
+</script>

+ 2 - 2
src/views/mall/statistics/trade/components/TradeTrendValue.vue → src/components/SummaryCard/index.vue

@@ -35,8 +35,8 @@
 import { propTypes } from '@/utils/propTypes'
 import { toNumber } from 'lodash-es'
 
-/** 交易状况统计值组件 */
-defineOptions({ name: 'TradeTrendValue' })
+/** 统计卡片 */
+defineOptions({ name: 'SummaryCard' })
 
 defineProps({
   title: propTypes.string.def(''),

+ 13 - 0
src/utils/index.ts

@@ -236,3 +236,16 @@ export const yuanToFen = (amount: string | number): number => {
 export const fenToYuan = (price: string | number): number => {
   return formatToFraction(price)
 }
+
+/**
+ * 计算环比
+ *
+ * @param value 当前数值
+ * @param reference 对比数值
+ */
+export const calculateRelativeRate = (value?: number, reference?: number) => {
+  // 防止除0
+  if (!reference) return 0
+
+  return ((100 * ((value || 0) - reference)) / reference).toFixed(0)
+}

+ 42 - 0
src/views/mall/home/components/ComparisonCard.vue

@@ -0,0 +1,42 @@
+<template>
+  <div class="flex flex-col gap-2 bg-[var(--el-bg-color-overlay)] p-6">
+    <div class="flex items-center justify-between text-gray-500">
+      <span>{{ title }}</span>
+      <el-tag>{{ tag }}</el-tag>
+    </div>
+    <div class="flex flex-row items-baseline justify-between">
+      <CountTo :prefix="prefix" :end-val="value" :decimals="decimals" class="text-3xl" />
+      <span :class="toNumber(percent) > 0 ? 'text-red-500' : 'text-green-500'">
+        {{ Math.abs(toNumber(percent)) }}%
+        <Icon :icon="toNumber(percent) > 0 ? 'ep:caret-top' : 'ep:caret-bottom'" class="!text-sm" />
+      </span>
+    </div>
+    <el-divider class="mb-1! mt-2!" />
+    <div class="flex flex-row items-center justify-between text-sm">
+      <span class="text-gray-500">昨日数据</span>
+      <span>{{ prefix || '' }}{{ reference }}</span>
+    </div>
+  </div>
+</template>
+<script lang="ts" setup>
+import { propTypes } from '@/utils/propTypes'
+import { toNumber } from 'lodash-es'
+import { calculateRelativeRate } from '@/utils'
+
+/** 交易对照卡片 */
+defineOptions({ name: 'ComparisonCard' })
+
+const props = defineProps({
+  title: propTypes.string.def('').isRequired,
+  tag: propTypes.string.def(''),
+  prefix: propTypes.string.def(''),
+  value: propTypes.number.def(0).isRequired,
+  reference: propTypes.number.def(0).isRequired,
+  decimals: propTypes.number.def(0)
+})
+
+// 计算环比
+const percent = computed(() =>
+  calculateRelativeRate(props.value as number, props.reference as number)
+)
+</script>

+ 91 - 0
src/views/mall/home/components/MemberStatisticsCard.vue

@@ -0,0 +1,91 @@
+<template>
+  <el-card shadow="never">
+    <template #header>
+      <CardTitle title="用户统计" />
+    </template>
+    <!-- 折线图 -->
+    <Echart :height="300" :options="lineChartOptions" />
+  </el-card>
+</template>
+<script lang="ts" setup>
+import dayjs from 'dayjs'
+import { EChartsOption } from 'echarts'
+import * as MemberStatisticsApi from '@/api/mall/statistics/member'
+import { formatDate } from '@/utils/formatTime'
+import { CardTitle } from '@/components/Card'
+
+/** 会员用户统计卡片 */
+defineOptions({ name: 'MemberStatisticsCard' })
+
+const loading = ref(true) // 加载中
+/** 折线图配置 */
+const lineChartOptions = reactive<EChartsOption>({
+  dataset: {
+    dimensions: ['date', 'count'],
+    source: []
+  },
+  grid: {
+    left: 20,
+    right: 20,
+    bottom: 20,
+    top: 80,
+    containLabel: true
+  },
+  legend: {
+    top: 50
+  },
+  series: [{ name: '注册量', type: 'line', smooth: true, areaStyle: {} }],
+  toolbox: {
+    feature: {
+      // 数据区域缩放
+      dataZoom: {
+        yAxisIndex: false // Y轴不缩放
+      },
+      brush: {
+        type: ['lineX', 'clear'] // 区域缩放按钮、还原按钮
+      },
+      saveAsImage: { show: true, name: '会员统计' } // 保存为图片
+    }
+  },
+  tooltip: {
+    trigger: 'axis',
+    axisPointer: {
+      type: 'cross'
+    },
+    padding: [5, 10]
+  },
+  xAxis: {
+    type: 'category',
+    boundaryGap: false,
+    axisTick: {
+      show: false
+    },
+    axisLabel: {
+      formatter: (date: string) => formatDate(date, 'MM-DD')
+    }
+  },
+  yAxis: {
+    axisTick: {
+      show: false
+    }
+  }
+}) as EChartsOption
+
+const getMemberRegisterCountList = async () => {
+  loading.value = true
+  // 查询最近一月数据
+  const beginTime = dayjs().subtract(30, 'd').startOf('d')
+  const endTime = dayjs().endOf('d')
+  const list = await MemberStatisticsApi.getMemberRegisterCountList(beginTime, endTime)
+  // 更新 Echarts 数据
+  if (lineChartOptions.dataset && lineChartOptions.dataset['source']) {
+    lineChartOptions.dataset['source'] = list
+  }
+  loading.value = false
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getMemberRegisterCountList()
+})
+</script>

+ 92 - 0
src/views/mall/home/components/OperationDataCard.vue

@@ -0,0 +1,92 @@
+<template>
+  <el-card shadow="never">
+    <template #header>
+      <CardTitle title="运营数据" />
+    </template>
+    <div class="flex flex-row flex-wrap items-center gap-8 p-4">
+      <div
+        v-for="item in data"
+        :key="item.name"
+        class="h-20 w-20% flex flex-col cursor-pointer items-center justify-center gap-2"
+        @click="handleClick(item.routerName)"
+      >
+        <CountTo
+          :prefix="item.prefix"
+          :end-val="item.value"
+          :decimals="item.decimals"
+          class="text-3xl"
+        />
+        <span class="text-center">{{ item.name }}</span>
+      </div>
+    </div>
+  </el-card>
+</template>
+<script lang="ts" setup>
+import * as ProductSpuApi from '@/api/mall/product/spu'
+import * as TradeStatisticsApi from '@/api/mall/statistics/trade'
+import * as PayStatisticsApi from '@/api/mall/statistics/pay'
+import { CardTitle } from '@/components/Card'
+
+/** 运营数据卡片 */
+defineOptions({ name: 'OperationDataCard' })
+
+const router = useRouter() // 路由
+
+/** 数据 */
+const data = reactive({
+  orderUndelivered: { name: '待发货订单', value: 9, routerName: 'TradeOrder' },
+  orderAfterSaleApply: { name: '退款中订单', value: 4, routerName: 'TradeAfterSale' },
+  orderWaitePickUp: { name: '待核销订单', value: 0, routerName: 'TradeOrder' },
+  productAlertStock: { name: '库存预警', value: 0, routerName: 'ProductSpu' },
+  productForSale: { name: '上架商品', value: 0, routerName: 'ProductSpu' },
+  productInWarehouse: { name: '仓库商品', value: 0, routerName: 'ProductSpu' },
+  withdrawAuditing: { name: '提现待审核', value: 0, routerName: 'TradeBrokerageWithdraw' },
+  rechargePrice: {
+    name: '账户充值',
+    value: 0.0,
+    prefix: '¥',
+    decimals: 2,
+    routerName: 'PayWalletRecharge'
+  }
+})
+
+/** 查询订单数据 */
+const getOrderData = async () => {
+  const orderCount = await TradeStatisticsApi.getOrderCount()
+  data.orderUndelivered.value = orderCount.undelivered
+  data.orderAfterSaleApply.value = orderCount.afterSaleApply
+  data.orderWaitePickUp.value = orderCount.pickUp
+  data.withdrawAuditing.value = orderCount.auditingWithdraw
+}
+
+/** 查询商品数据 */
+const getProductData = async () => {
+  // TODO: @芋艿:这个接口的返回值,是不是用命名字段更好些?
+  const productCount = await ProductSpuApi.getTabsCount()
+  data.productForSale.value = productCount['0']
+  data.productInWarehouse.value = productCount['1']
+  data.productAlertStock.value = productCount['3']
+}
+
+/** 查询钱包充值数据 */
+const getWalletRechargeData = async () => {
+  const paySummary = await PayStatisticsApi.getWalletRechargePrice()
+  data.rechargePrice.value = paySummary.rechargePrice
+}
+
+/**
+ * 跳转到对应页面
+ *
+ * @param routerName 路由页面组件的名称
+ */
+const handleClick = (routerName: string) => {
+  router.push({ name: routerName })
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getOrderData()
+  getProductData()
+  getWalletRechargeData()
+})
+</script>

+ 79 - 0
src/views/mall/home/components/ShortcutCard.vue

@@ -0,0 +1,79 @@
+<template>
+  <el-card shadow="never">
+    <template #header>
+      <CardTitle title="快捷入口" />
+    </template>
+    <div class="flex flex-row flex-wrap gap-8 p-4">
+      <div
+        v-for="menu in menuList"
+        :key="menu.name"
+        class="h-20 w-20% flex flex-col cursor-pointer items-center justify-center gap-2"
+        @click="handleMenuClick(menu.routerName)"
+      >
+        <div :class="menu.bgColor" class="rounded p-3 text-white">
+          <Icon :icon="menu.icon" class="text-7.5!" />
+        </div>
+        <span>{{ menu.name }}</span>
+      </div>
+    </div>
+  </el-card>
+</template>
+<script lang="ts" setup>
+/** 快捷入口卡片 */
+import { CardTitle } from '@/components/Card'
+
+defineOptions({ name: 'ShortcutCard' })
+
+const router = useRouter() // 路由
+
+/** 菜单列表 */
+const menuList = [
+  { name: '用户管理', icon: 'ep:user-filled', bgColor: 'bg-red-400', routerName: 'MemberUser' },
+  {
+    name: '商品管理',
+    icon: 'fluent-mdl2:product',
+    bgColor: 'bg-orange-400',
+    routerName: 'ProductSpu'
+  },
+  { name: '订单管理', icon: 'ep:list', bgColor: 'bg-yellow-500', routerName: 'TradeOrder' },
+  {
+    name: '售后管理',
+    icon: 'ri:refund-2-line',
+    bgColor: 'bg-green-600',
+    routerName: 'TradeAfterSale'
+  },
+  {
+    name: '分销管理',
+    icon: 'fa-solid:project-diagram',
+    bgColor: 'bg-cyan-500',
+    routerName: 'TradeBrokerageUser'
+  },
+  {
+    name: '优惠券',
+    icon: 'ep:ticket',
+    bgColor: 'bg-blue-500',
+    routerName: 'PromotionCoupon'
+  },
+  {
+    name: '拼团活动',
+    icon: 'fa:group',
+    bgColor: 'bg-purple-500',
+    routerName: 'PromotionBargainActivity'
+  },
+  {
+    name: '佣金提现',
+    icon: 'vaadin:money-withdraw',
+    bgColor: 'bg-rose-500',
+    routerName: 'TradeBrokerageWithdraw'
+  }
+]
+
+/**
+ * 跳转到菜单对应页面
+ *
+ * @param routerName 路由页面组件的名称
+ */
+const handleMenuClick = (routerName: string) => {
+  router.push({ name: routerName })
+}
+</script>

+ 208 - 0
src/views/mall/home/components/TradeTrendCard.vue

@@ -0,0 +1,208 @@
+<template>
+  <el-card shadow="never">
+    <template #header>
+      <div class="flex flex-row items-center justify-between">
+        <CardTitle title="交易量趋势" />
+        <!-- 查询条件 -->
+        <div class="flex flex-row items-center gap-2">
+          <el-radio-group v-model="timeRangeType" @change="handleTimeRangeTypeChange">
+            <el-radio-button v-for="[key, value] in timeRange.entries()" :key="key" :label="key">
+              {{ value.name }}
+            </el-radio-button>
+          </el-radio-group>
+        </div>
+      </div>
+    </template>
+    <!-- 折线图 -->
+    <Echart :height="300" :options="eChartOptions" />
+  </el-card>
+</template>
+<script lang="ts" setup>
+import dayjs, { Dayjs } from 'dayjs'
+import { EChartsOption } from 'echarts'
+import * as TradeStatisticsApi from '@/api/mall/statistics/trade'
+import { fenToYuan } from '@/utils'
+import { formatDate } from '@/utils/formatTime'
+import { CardTitle } from '@/components/Card'
+
+/** 交易量趋势 */
+defineOptions({ name: 'TradeTrendCard' })
+
+enum TimeRangeTypeEnum {
+  DAY30 = 1,
+  WEEK = 7,
+  MONTH = 30,
+  YEAR = 365
+} // 日期类型
+const timeRangeType = ref(TimeRangeTypeEnum.DAY30) // 日期快捷选择按钮, 默认30天
+const loading = ref(true) // 加载中
+// 时间范围 Map
+const timeRange = new Map()
+  .set(TimeRangeTypeEnum.DAY30, {
+    name: '30天',
+    series: [
+      { name: '订单金额', type: 'bar', smooth: true, data: [] },
+      { name: '订单数量', type: 'line', smooth: true, data: [] }
+    ]
+  })
+  .set(TimeRangeTypeEnum.WEEK, {
+    name: '周',
+    series: [
+      { name: '上周金额', type: 'bar', smooth: true, data: [] },
+      { name: '本周金额', type: 'bar', smooth: true, data: [] },
+      { name: '上周数量', type: 'line', smooth: true, data: [] },
+      { name: '本周数量', type: 'line', smooth: true, data: [] }
+    ]
+  })
+  .set(TimeRangeTypeEnum.MONTH, {
+    name: '月',
+    series: [
+      { name: '上月金额', type: 'bar', smooth: true, data: [] },
+      { name: '本月金额', type: 'bar', smooth: true, data: [] },
+      { name: '上月数量', type: 'line', smooth: true, data: [] },
+      { name: '本月数量', type: 'line', smooth: true, data: [] }
+    ]
+  })
+  .set(TimeRangeTypeEnum.YEAR, {
+    name: '年',
+    series: [
+      { name: '去年金额', type: 'bar', smooth: true, data: [] },
+      { name: '今年金额', type: 'bar', smooth: true, data: [] },
+      { name: '去年数量', type: 'line', smooth: true, data: [] },
+      { name: '今年数量', type: 'line', smooth: true, data: [] }
+    ]
+  })
+/** 图表配置 */
+const eChartOptions = reactive<EChartsOption>({
+  grid: {
+    left: 20,
+    right: 20,
+    bottom: 20,
+    top: 80,
+    containLabel: true
+  },
+  legend: {
+    top: 50,
+    data: []
+  },
+  series: [],
+  toolbox: {
+    feature: {
+      // 数据区域缩放
+      dataZoom: {
+        yAxisIndex: false // Y轴不缩放
+      },
+      brush: {
+        type: ['lineX', 'clear'] // 区域缩放按钮、还原按钮
+      },
+      saveAsImage: { show: true, name: '订单量趋势' } // 保存为图片
+    }
+  },
+  tooltip: {
+    trigger: 'axis',
+    axisPointer: {
+      type: 'cross'
+    },
+    padding: [5, 10]
+  },
+  xAxis: {
+    type: 'category',
+    inverse: true,
+    boundaryGap: false,
+    axisTick: {
+      show: false
+    },
+    data: [],
+    axisLabel: {
+      formatter: (date: string) => {
+        switch (timeRangeType.value) {
+          case TimeRangeTypeEnum.DAY30:
+            return formatDate(date, 'MM-DD')
+          case TimeRangeTypeEnum.WEEK:
+            let weekDay = formatDate(date, 'ddd')
+            if (weekDay == '0') weekDay = '日'
+            return '周' + weekDay
+          case TimeRangeTypeEnum.MONTH:
+            return formatDate(date, 'D')
+          case TimeRangeTypeEnum.YEAR:
+            return formatDate(date, 'M') + '月'
+          default:
+            return date
+        }
+      }
+    }
+  },
+  yAxis: {
+    axisTick: {
+      show: false
+    }
+  }
+}) as EChartsOption
+
+/** 时间范围类型单选按钮选中 */
+const handleTimeRangeTypeChange = async () => {
+  // 设置时间范围
+  let beginTime: Dayjs
+  let endTime: Dayjs
+  switch (timeRangeType.value) {
+    case TimeRangeTypeEnum.WEEK:
+      beginTime = dayjs().startOf('week')
+      endTime = dayjs().endOf('week')
+      break
+    case TimeRangeTypeEnum.MONTH:
+      beginTime = dayjs().startOf('month')
+      endTime = dayjs().endOf('month')
+      break
+    case TimeRangeTypeEnum.YEAR:
+      beginTime = dayjs().startOf('year')
+      endTime = dayjs().endOf('year')
+      break
+    case TimeRangeTypeEnum.DAY30:
+    default:
+      beginTime = dayjs().subtract(30, 'day').startOf('d')
+      endTime = dayjs().endOf('d')
+      break
+  }
+  // 发送时间范围选中事件
+  await getOrderCountTrendComparison(beginTime, endTime)
+}
+
+/** 查询订单数量趋势对照数据 */
+const getOrderCountTrendComparison = async (
+  beginTime: dayjs.ConfigType,
+  endTime: dayjs.ConfigType
+) => {
+  loading.value = true
+  // 查询数据
+  const list = await TradeStatisticsApi.getOrderCountTrendComparison(
+    timeRangeType.value,
+    beginTime,
+    endTime
+  )
+  // 处理数据
+  const dates: string[] = []
+  const series = [...timeRange.get(timeRangeType.value).series]
+  for (let item of list) {
+    dates.push(item.value.date)
+    if (series.length === 2) {
+      series[0].data.push(fenToYuan(item?.value?.orderPayPrice || 0)) // 当前金额
+      series[1].data.push(fenToYuan(item?.value?.orderPayCount || 0)) // 当前数量
+    } else {
+      series[0].data.push(fenToYuan(item?.reference?.orderPayPrice || 0)) // 对照金额
+      series[1].data.push(fenToYuan(item?.value?.orderPayPrice || 0)) // 当前金额
+      series[2].data.push(item?.reference?.orderPayCount || 0) // 对照数量
+      series[3].data.push(item?.value?.orderPayCount || 0) // 当前数量
+    }
+  }
+  eChartOptions.xAxis!['data'] = dates
+  eChartOptions.series = series
+  // legend在4个切换到2个的时候,还是显示成4个,需要手动配置一下
+  eChartOptions.legend['data'] = series.map((item) => item.name)
+  loading.value = false
+}
+
+/** 初始化 **/
+onMounted(() => {
+  handleTimeRangeTypeChange()
+})
+</script>

+ 111 - 0
src/views/mall/home/index.vue

@@ -0,0 +1,111 @@
+<template>
+  <div class="flex flex-col">
+    <!-- 数据对照 -->
+    <el-row :gutter="16" class="row">
+      <el-col :md="6" :sm="12" :xs="24" :loading="loading">
+        <ComparisonCard
+          tag="今日"
+          title="销售额"
+          prefix="¥"
+          ::decimals="2"
+          :value="fenToYuan(orderComparison?.value?.orderPayPrice || 0)"
+          :reference="fenToYuan(orderComparison?.reference?.orderPayPrice || 0)"
+        />
+      </el-col>
+      <el-col :md="6" :sm="12" :xs="24" :loading="loading">
+        <ComparisonCard
+          tag="今日"
+          title="用户访问量"
+          :value="userComparison?.value?.visitUserCount || 0"
+          :reference="userComparison?.reference?.visitUserCount || 0"
+        />
+      </el-col>
+      <el-col :md="6" :sm="12" :xs="24" :loading="loading">
+        <ComparisonCard
+          tag="今日"
+          title="订单量"
+          :value="fenToYuan(orderComparison?.value?.orderPayCount || 0)"
+          :reference="fenToYuan(orderComparison?.reference?.orderPayCount || 0)"
+        />
+      </el-col>
+      <el-col :md="6" :sm="12" :xs="24" :loading="loading">
+        <ComparisonCard
+          tag="今日"
+          title="新增用户"
+          :value="userComparison?.value?.registerUserCount || 0"
+          :reference="userComparison?.reference?.registerUserCount || 0"
+        />
+      </el-col>
+    </el-row>
+    <el-row :gutter="16" class="row">
+      <el-col :md="12">
+        <!-- 快捷入口 -->
+        <ShortcutCard />
+      </el-col>
+      <el-col :md="12">
+        <!-- 运营数据 -->
+        <OperationDataCard />
+      </el-col>
+    </el-row>
+    <el-row :gutter="16" class="mb-4">
+      <el-col :md="18" :sm="24">
+        <!-- 会员概览 -->
+        <MemberFunnelCard />
+      </el-col>
+      <el-col :md="6" :sm="24">
+        <!-- 会员终端 -->
+        <MemberTerminalCard />
+      </el-col>
+    </el-row>
+    <!-- 交易量趋势 -->
+    <TradeTrendCard class="mb-4" />
+    <!-- 会员统计 -->
+    <MemberStatisticsCard />
+  </div>
+</template>
+<script lang="ts" setup>
+import * as TradeStatisticsApi from '@/api/mall/statistics/trade'
+import * as MemberStatisticsApi from '@/api/mall/statistics/member'
+import { DataComparisonRespVO } from '@/api/mall/statistics/common'
+import { TradeOrderSummaryRespVO } from '@/api/mall/statistics/trade'
+import { MemberCountRespVO } from '@/api/mall/statistics/member'
+import { fenToYuan } from '@/utils'
+import ComparisonCard from './components/ComparisonCard.vue'
+import MemberStatisticsCard from './components/MemberStatisticsCard.vue'
+import OperationDataCard from './components/OperationDataCard.vue'
+import ShortcutCard from './components/ShortcutCard.vue'
+import TradeTrendCard from './components/TradeTrendCard.vue'
+import MemberTerminalCard from '@/views/mall/statistics/member/components/MemberTerminalCard.vue'
+import MemberFunnelCard from '@/views/mall/statistics/member/components/MemberFunnelCard.vue'
+
+/** 商城首页 */
+defineOptions({ name: 'MallHome' })
+
+const loading = ref(true) // 加载中
+const orderComparison = ref<DataComparisonRespVO<TradeOrderSummaryRespVO>>() // 交易对照数据
+const userComparison = ref<DataComparisonRespVO<MemberCountRespVO>>() // 用户对照数据
+
+/** 查询交易对照卡片数据 */
+const getOrderComparison = async () => {
+  orderComparison.value = await TradeStatisticsApi.getOrderComparison()
+}
+
+/** 查询会员用户数量对照卡片数据 */
+const getUserCountComparison = async () => {
+  userComparison.value = await MemberStatisticsApi.getUserCountComparison()
+}
+
+/** 初始化 **/
+onMounted(async () => {
+  loading.value = true
+  await Promise.all([getOrderComparison(), getUserCountComparison()])
+  loading.value = false
+})
+</script>
+<style lang="scss" scoped>
+.row {
+  .el-col {
+    margin-bottom: 1rem;
+  }
+}
+</style>

+ 238 - 0
src/views/mall/promotion/article/ArticleForm.vue

@@ -0,0 +1,238 @@
+<template>
+  <Dialog v-model="dialogVisible" :title="dialogTitle" width="70%">
+    <el-form
+      ref="formRef"
+      v-loading="formLoading"
+      :model="formData"
+      :rules="formRules"
+      label-width="110px"
+    >
+      <el-row>
+        <el-col :span="12">
+          <el-form-item label="文章标题" prop="title">
+            <el-input v-model="formData.title" placeholder="请输入文章标题" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="文章分类" prop="categoryId">
+            <el-select v-model="formData.categoryId" placeholder="请选择">
+              <el-option
+                v-for="item in categoryList"
+                :key="item.id"
+                :label="item.name"
+                :value="item.id"
+              />
+            </el-select>
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="文章作者" prop="author">
+            <el-input v-model="formData.author" placeholder="请输入文章作者" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="文章简介" prop="introduction">
+            <el-input v-model="formData.introduction" placeholder="请输入文章简介" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="24">
+          <el-form-item label="文章封面" prop="picUrl">
+            <UploadImg v-model="formData.picUrl" height="80px" />
+          </el-form-item>
+        </el-col>
+        <!-- TODO @puhui999:浏览次数,不能修改 -->
+        <el-col :span="12">
+          <el-form-item label="浏览次数" prop="browseCount">
+            <el-input-number
+              v-model="formData.browseCount"
+              :min="0"
+              clearable
+              controls-position="right"
+            />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="排序" prop="sort">
+            <el-input-number v-model="formData.sort" :min="0" clearable controls-position="right" />
+          </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>
+        <!-- TODO @puhui999:可以使用 SpuTableSelect -->
+        <el-col :span="12">
+          <el-form-item label="商品关联" prop="spuId">
+            <el-select v-model="formData.spuId" placeholder="请选择">
+              <el-option
+                v-for="item in spuList"
+                :key="item.id"
+                :label="item.name"
+                :value="item.id"
+              />
+            </el-select>
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="是否热门" prop="recommendHot">
+            <el-radio-group v-model="formData.recommendHot">
+              <el-radio
+                v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)"
+                :key="dict.value"
+                :label="dict.value"
+              >
+                {{ dict.label }}
+              </el-radio>
+            </el-radio-group>
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="是否轮播图" prop="recommendBanner">
+            <el-radio-group v-model="formData.recommendBanner">
+              <el-radio
+                v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)"
+                :key="dict.value"
+                :label="dict.value"
+              >
+                {{ dict.label }}
+              </el-radio>
+            </el-radio-group>
+          </el-form-item>
+        </el-col>
+        <el-col :span="24">
+          <el-form-item label="文章内容">
+            <Editor v-model="formData.content" height="150px" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+    </el-form>
+    <template #footer>
+      <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script lang="ts" setup>
+import { DICT_TYPE, getBoolDictOptions, getIntDictOptions } from '@/utils/dict'
+import * as ArticleApi from '@/api/mall/promotion/article'
+import * as ArticleCategoryApi from '@/api/mall/promotion/articleCategory'
+import * as ProductSpuApi from '@/api/mall/product/spu'
+
+defineOptions({ name: 'PromotionArticleForm' })
+
+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,
+  categoryId: undefined,
+  title: undefined,
+  author: undefined,
+  picUrl: undefined,
+  introduction: undefined,
+  browseCount: 0,
+  sort: 0,
+  status: 0,
+  spuId: undefined,
+  recommendHot: false,
+  recommendBanner: false,
+  content: undefined
+})
+const formRules = reactive({
+  categoryId: [{ required: true, message: '分类id不能为空', trigger: 'blur' }],
+  title: [{ required: true, message: '文章标题不能为空', trigger: 'blur' }],
+  picUrl: [{ required: true, message: '文章封面图片地址不能为空', trigger: 'blur' }],
+  sort: [{ required: true, message: '排序不能为空', trigger: 'blur' }],
+  status: [{ required: true, message: '状态不能为空', trigger: 'blur' }],
+  spuId: [{ required: true, message: '商品关联id不能为空', trigger: 'blur' }],
+  recommendHot: [{ required: true, message: '是否热门(小程序)不能为空', trigger: 'blur' }],
+  recommendBanner: [{ required: true, message: '是否轮播图(小程序)不能为空', trigger: 'blur' }],
+  content: [{ required: true, message: '文章内容不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+
+/** 打开弹窗 */
+const open = async (type: string, id?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      formData.value = await ArticleApi.getArticle(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 ArticleApi.ArticleVO
+    if (formType.value === 'create') {
+      await ArticleApi.createArticle(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await ArticleApi.updateArticle(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    categoryId: undefined,
+    title: undefined,
+    author: undefined,
+    picUrl: undefined,
+    introduction: undefined,
+    browseCount: 0,
+    sort: 0,
+    status: 0,
+    spuId: undefined,
+    recommendHot: false,
+    recommendBanner: false,
+    content: undefined
+  }
+  formRef.value?.resetFields()
+}
+
+const categoryList = ref<ArticleCategoryApi.ArticleCategoryVO[]>([])
+const spuList = ref<ProductSpuApi.Spu[]>([])
+onMounted(async () => {
+  categoryList.value =
+    (await ArticleCategoryApi.getSimpleArticleCategoryList()) as ArticleCategoryApi.ArticleCategoryVO[]
+  spuList.value = (await ProductSpuApi.getSpuSimpleList()) as ProductSpuApi.Spu[]
+})
+</script>

+ 120 - 0
src/views/mall/promotion/article/category/ArticleCategoryForm.vue

@@ -0,0 +1,120 @@
+<template>
+  <Dialog v-model="dialogVisible" :title="dialogTitle">
+    <el-form
+      ref="formRef"
+      v-loading="formLoading"
+      :model="formData"
+      :rules="formRules"
+      label-width="100px"
+    >
+      <el-form-item label="分类名称" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入分类名称" />
+      </el-form-item>
+      <el-form-item label="图标地址" prop="picUrl">
+        <UploadImg v-model="formData.picUrl" height="80px" />
+      </el-form-item>
+      <el-form-item label="状态" prop="status">
+        <el-radio-group v-model="formData.status">
+          <el-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+            :key="dict.value"
+            :label="dict.value"
+          >
+            {{ dict.label }}
+          </el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="排序" prop="sort">
+        <el-input-number v-model="formData.sort" :min="0" clearable controls-position="right" />
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script lang="ts" setup>
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import * as ArticleCategoryApi from '@/api/mall/promotion/articleCategory'
+import { CommonStatusEnum } from '@/utils/constants'
+
+defineOptions({ name: 'PromotionArticleCategoryForm' })
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formType = ref('') // 表单的类型:create - 新增;update - 修改
+const formData = ref({
+  id: undefined,
+  name: undefined,
+  picUrl: undefined,
+  status: undefined,
+  sort: undefined
+})
+const formRules = reactive({
+  name: [{ required: true, message: '分类名称不能为空', trigger: 'blur' }],
+  status: [{ required: true, message: '状态不能为空', trigger: 'blur' }],
+  sort: [{ required: true, message: '排序不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+
+/** 打开弹窗 */
+const open = async (type: string, id?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      formData.value = await ArticleCategoryApi.getArticleCategory(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 ArticleCategoryApi.ArticleCategoryVO
+    if (formType.value === 'create') {
+      await ArticleCategoryApi.createArticleCategory(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await ArticleCategoryApi.updateArticleCategory(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    name: undefined,
+    picUrl: undefined,
+    status: CommonStatusEnum.ENABLE,
+    sort: 0
+  }
+  formRef.value?.resetFields()
+}
+</script>

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

@@ -0,0 +1,199 @@
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      ref="queryFormRef"
+      :inline="true"
+      :model="queryParams"
+      class="-mb-15px"
+      label-width="68px"
+    >
+      <el-form-item label="分类名称" prop="name">
+        <el-input
+          v-model="queryParams.name"
+          class="!w-240px"
+          clearable
+          placeholder="请输入分类名称"
+          @keyup.enter="handleQuery"
+        />
+      </el-form-item>
+      <el-form-item label="状态" prop="status">
+        <el-select v-model="queryParams.status" class="!w-240px" clearable placeholder="请选择状态">
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="创建时间" prop="createTime">
+        <el-date-picker
+          v-model="queryParams.createTime"
+          :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+          class="!w-240px"
+          end-placeholder="结束日期"
+          start-placeholder="开始日期"
+          type="daterange"
+          value-format="YYYY-MM-DD HH:mm:ss"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery">
+          <Icon class="mr-5px" icon="ep:search" />
+          搜索
+        </el-button>
+        <el-button @click="resetQuery">
+          <Icon class="mr-5px" icon="ep:refresh" />
+          重置
+        </el-button>
+        <el-button
+          v-hasPermi="['promotion:article-category:create']"
+          plain
+          type="primary"
+          @click="openForm('create')"
+        >
+          <Icon class="mr-5px" icon="ep:plus" />
+          新增
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true">
+      <el-table-column align="center" label="编号" prop="id" min-width="100" />
+      <el-table-column align="center" label="分类名称" prop="name" min-width="240" />
+      <el-table-column label="分类图图" min-width="80">
+        <template #default="{ row }">
+          <el-image :src="row.picUrl" class="h-30px w-30px" @click="imagePreview(row.picUrl)" />
+        </template>
+      </el-table-column>
+      <el-table-column align="center" label="状态" prop="status" min-width="150">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
+        </template>
+      </el-table-column>
+      <el-table-column align="center" label="排序" prop="sort" min-width="150" />
+      <el-table-column
+        :formatter="dateFormatter"
+        align="center"
+        label="创建时间"
+        prop="createTime"
+        width="180px"
+      />
+      <el-table-column align="center" label="操作">
+        <template #default="scope">
+          <el-button
+            v-hasPermi="['promotion:article-category:update']"
+            link
+            type="primary"
+            @click="openForm('update', scope.row.id)"
+          >
+            编辑
+          </el-button>
+          <el-button
+            v-hasPermi="['promotion:article-category:delete']"
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      v-model:limit="queryParams.pageSize"
+      v-model:page="queryParams.pageNo"
+      :total="total"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- 表单弹窗:添加/修改 -->
+  <ArticleCategoryForm ref="formRef" @success="getList" />
+</template>
+
+<script lang="ts" setup>
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import * as ArticleCategoryApi from '@/api/mall/promotion/articleCategory'
+import ArticleCategoryForm from './ArticleCategoryForm.vue'
+import { createImageViewer } from '@/components/ImageViewer'
+
+defineOptions({ name: 'PromotionArticleCategory' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数据
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: null,
+  status: null,
+  createTime: []
+})
+const queryFormRef = ref() // 搜索的表单
+const exportLoading = ref(false) // 导出的加载中
+
+/** 分类图预览 */
+const imagePreview = (imgUrl: string) => {
+  createImageViewer({
+    urlList: [imgUrl]
+  })
+}
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await ArticleCategoryApi.getArticleCategoryPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await ArticleCategoryApi.deleteArticleCategory(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</script>

+ 229 - 0
src/views/mall/promotion/article/index.vue

@@ -0,0 +1,229 @@
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      ref="queryFormRef"
+      :inline="true"
+      :model="queryParams"
+      class="-mb-15px"
+      label-width="80px"
+    >
+      <el-form-item label="文章分类" prop="categoryId">
+        <el-select
+          v-model="queryParams.categoryId"
+          class="!w-240px"
+          placeholder="全部"
+          @keyup.enter="handleQuery"
+        >
+          <el-option
+            v-for="item in categoryList"
+            :key="item.id"
+            :label="item.name"
+            :value="item.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="文章标题" prop="title">
+        <el-input
+          v-model="queryParams.title"
+          class="!w-240px"
+          clearable
+          placeholder="请输入文章标题"
+          @keyup.enter="handleQuery"
+        />
+      </el-form-item>
+      <el-form-item label="状态" prop="status">
+        <el-select v-model="queryParams.status" class="!w-240px" clearable placeholder="请选择状态">
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="创建时间" prop="createTime">
+        <el-date-picker
+          v-model="queryParams.createTime"
+          :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+          class="!w-240px"
+          end-placeholder="结束日期"
+          start-placeholder="开始日期"
+          type="daterange"
+          value-format="YYYY-MM-DD HH:mm:ss"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery">
+          <Icon class="mr-5px" icon="ep:search" />
+          搜索
+        </el-button>
+        <el-button @click="resetQuery">
+          <Icon class="mr-5px" icon="ep:refresh" />
+          重置
+        </el-button>
+        <el-button
+          v-hasPermi="['promotion:article:create']"
+          plain
+          type="primary"
+          @click="openForm('create')"
+        >
+          <Icon class="mr-5px" icon="ep:plus" />
+          新增
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true">
+      <el-table-column align="center" label="编号" prop="id" min-width="60" />
+      <el-table-column align="center" label="封面" prop="picUrl" min-width="80">
+        <template #default="{ row }">
+          <el-image :src="row.picUrl" class="h-30px w-30px" @click="imagePreview(row.picUrl)" />
+        </template>
+      </el-table-column>
+      <el-table-column align="center" label="标题" prop="title" min-width="180" />
+      <el-table-column align="center" label="分类" prop="categoryId" min-width="180">
+        <template #default="scope">
+          {{ categoryList.find((item) => item.id === scope.row.categoryId)?.name }}
+        </template>
+      </el-table-column>
+      <el-table-column align="center" label="浏览量" prop="browseCount" min-width="180" />
+      <el-table-column align="center" label="作者" prop="author" min-width="180" />
+      <el-table-column align="center" label="文章简介" prop="introduction" min-width="250" />
+      <el-table-column align="center" label="排序" prop="sort" min-width="60" />
+      <el-table-column align="center" label="状态" prop="status" min-width="60">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
+        </template>
+      </el-table-column>
+      <el-table-column
+        :formatter="dateFormatter"
+        align="center"
+        label="发布时间"
+        prop="createTime"
+        width="180px"
+      />
+      <el-table-column align="center" fixed="right" label="操作" width="120">
+        <template #default="scope">
+          <el-button
+            v-hasPermi="['promotion:article:update']"
+            link
+            type="primary"
+            @click="openForm('update', scope.row.id)"
+          >
+            编辑
+          </el-button>
+          <el-button
+            v-hasPermi="['promotion:article:delete']"
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      v-model:limit="queryParams.pageSize"
+      v-model:page="queryParams.pageNo"
+      :total="total"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- 表单弹窗:添加/修改 -->
+  <ArticleForm ref="formRef" @success="getList" />
+</template>
+
+<script lang="ts" setup>
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import * as ArticleApi from '@/api/mall/promotion/article'
+import ArticleForm from './ArticleForm.vue'
+import * as ArticleCategoryApi from '@/api/mall/promotion/articleCategory'
+import * as ProductSpuApi from '@/api/mall/product/spu'
+import { createImageViewer } from '@/components/ImageViewer'
+
+defineOptions({ name: 'PromotionArticle' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数据
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  categoryId: undefined,
+  title: null,
+  status: undefined,
+  spuId: undefined,
+  createTime: []
+})
+const queryFormRef = ref() // 搜索的表单
+const exportLoading = ref(false) // 导出的加载中
+/** 文章封面预览 */
+const imagePreview = (imgUrl: string) => {
+  createImageViewer({
+    urlList: [imgUrl]
+  })
+}
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await ArticleApi.getArticlePage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await ArticleApi.deleteArticle(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+const categoryList = ref<ArticleCategoryApi.ArticleCategoryVO[]>([])
+const spuList = ref<ProductSpuApi.Spu[]>([])
+onMounted(async () => {
+  await getList()
+  // 加载分类、商品列表
+  categoryList.value =
+    (await ArticleCategoryApi.getSimpleArticleCategoryList()) as ArticleCategoryApi.ArticleCategoryVO[]
+  spuList.value = (await ProductSpuApi.getSpuSimpleList()) as ProductSpuApi.Spu[]
+})
+</script>

+ 179 - 0
src/views/mall/promotion/discountActivity/DiscountActivityForm.vue

@@ -0,0 +1,179 @@
+<template>
+  <Dialog v-model="dialogVisible" :title="dialogTitle" width="65%">
+    <Form
+      ref="formRef"
+      v-loading="formLoading"
+      :isCol="true"
+      :rules="rules"
+      :schema="allSchemas.formSchema"
+    >
+      <!-- 先选择 -->
+      <!-- TODO @zhangshuai:商品允许选择多个 -->
+      <!-- TODO @zhangshuai:选择后的 SKU,需要后面加个【删除】按钮 -->
+      <!-- TODO @zhangshuai:展示的金额,貌似不对,大了 100 倍,需要看下 -->
+      <!-- TODO @zhangshuai:“优惠类型”,是每个 SKU 可以自定义已设置哈。因为每个商品 SKU 的折扣和减少价格,可能不同。具体交互,可以注册一个 youzan.com 看看;它的交互方式是,如果设置了“优惠金额”,则算“减价”;如果再次设置了“折扣百分比”,就算“打折”;这样形成一个互斥的优惠类型 -->
+      <template #spuId>
+        <el-button @click="spuSelectRef.open()">选择商品</el-button>
+        <SpuAndSkuList
+          ref="spuAndSkuListRef"
+          :rule-config="ruleConfig"
+          :spu-list="spuList"
+          :spu-property-list-p="spuPropertyList"
+        >
+          <el-table-column align="center" label="优惠金额" min-width="168">
+            <template #default="{ row: sku }">
+              <el-input-number v-model="sku.productConfig.discountPrice" :min="0" class="w-100%" />
+            </template>
+          </el-table-column>
+          <el-table-column align="center" label="折扣百分比(%)" min-width="168">
+            <template #default="{ row: sku }">
+              <el-input-number v-model="sku.productConfig.discountPercent" class="w-100%" />
+            </template>
+          </el-table-column>
+        </SpuAndSkuList>
+      </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" :isSelectSku="true" @confirm="selectSpu" />
+</template>
+<script lang="ts" setup>
+import { SpuAndSkuList, SpuProperty, SpuSelect } from '../components'
+import { allSchemas, rules } from './discountActivity.data'
+import { cloneDeep } from 'lodash-es'
+import * as DiscountActivityApi from '@/api/mall/promotion/discount/discountActivity'
+import * as ProductSpuApi from '@/api/mall/product/spu'
+import { getPropertyList, RuleConfig } from '@/views/mall/product/spu/components'
+
+defineOptions({ name: 'PromotionDiscountActivityForm' })
+
+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[] = []
+const spuList = ref<DiscountActivityApi.SpuExtension[]>([]) // 选择的 spu
+const spuPropertyList = ref<SpuProperty<DiscountActivityApi.SpuExtension>[]>([])
+const selectSpu = (spuId: number, skuIds: number[]) => {
+  formRef.value.setValues({ spuId })
+  getSpuDetails(spuId, skuIds)
+}
+/**
+ * 获取 SPU 详情
+ */
+const getSpuDetails = async (
+  spuId: number,
+  skuIds: number[] | undefined,
+  products?: DiscountActivityApi.DiscountProductVO[]
+) => {
+  const spuProperties: SpuProperty<DiscountActivityApi.SpuExtension>[] = []
+  const res = (await ProductSpuApi.getSpuDetailList([spuId])) as DiscountActivityApi.SpuExtension[]
+  if (res.length == 0) {
+    return
+  }
+  spuList.value = []
+  // 因为只能选择一个
+  const spu = res[0]
+  const selectSkus =
+    typeof skuIds === 'undefined' ? spu?.skus : spu?.skus?.filter((sku) => skuIds.includes(sku.id!))
+  selectSkus?.forEach((sku) => {
+    let config: DiscountActivityApi.DiscountProductVO = {
+      skuId: sku.id!,
+      spuId: spu.id,
+      discountType: 1,
+      discountPercent: 0,
+      discountPrice: 0
+    }
+    if (typeof products !== 'undefined') {
+      const product = products.find((item) => item.skuId === sku.id)
+      config = product || config
+    }
+    sku.productConfig = config
+  })
+  spu.skus = selectSkus as DiscountActivityApi.SkuExtension[]
+  spuProperties.push({
+    spuId: spu.id!,
+    spuDetail: spu,
+    propertyList: getPropertyList(spu)
+  })
+  spuList.value.push(spu)
+  spuPropertyList.value = spuProperties
+}
+
+// ================= end =================
+
+/** 打开弹窗 */
+const open = async (type: string, id?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  await resetForm()
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      const data = (await DiscountActivityApi.getDiscountActivity(
+        id
+      )) as DiscountActivityApi.DiscountActivityVO
+      const supId = data.products[0].spuId
+      await getSpuDetails(supId!, data.products?.map((sku) => sku.skuId), data.products)
+      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 DiscountActivityApi.DiscountActivityVO
+    // 获取 折扣商品配置
+    const products = cloneDeep(spuAndSkuListRef.value.getSkuConfigs('productConfig'))
+    products.forEach((item: DiscountActivityApi.DiscountProductVO) => {
+      item.discountType = data['discountType']
+    })
+    data.products = products
+    // 真正提交
+    if (formType.value === 'create') {
+      await DiscountActivityApi.createDiscountActivity(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await DiscountActivityApi.updateDiscountActivity(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = async () => {
+  spuList.value = []
+  spuPropertyList.value = []
+  await nextTick()
+  formRef.value.getElFormRef().resetFields()
+}
+</script>

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

@@ -0,0 +1,119 @@
+import type { CrudSchema } from '@/hooks/web/useCrudSchemas'
+import { dateFormatter2 } from '@/utils/formatTime'
+
+// TODO @zhangshai:
+// 表单校验
+export const rules = reactive({
+  spuId: [required],
+  name: [required],
+  startTime: [required],
+  endTime: [required],
+  discountType: [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: 'discountType',
+    dictType: DICT_TYPE.PROMOTION_DISCOUNT_TYPE,
+    dictClass: 'number',
+    isSearch: true,
+    form: {
+      component: 'Radio',
+      value: 1
+    }
+  },
+  {
+    label: '活动商品',
+    field: 'spuId',
+    isTable: true,
+    isSearch: false,
+    form: {
+      colProps: {
+        span: 24
+      }
+    },
+    table: {
+      width: 300
+    }
+  },
+  {
+    label: '备注',
+    field: 'remark',
+    isSearch: false,
+    form: {
+      component: 'Input',
+      componentProps: {
+        type: 'textarea',
+        rows: 4
+      },
+      colProps: {
+        span: 24
+      }
+    },
+    table: {
+      width: 300
+    }
+  }
+])
+export const { allSchemas } = useCrudSchemas(crudSchemas)

+ 237 - 0
src/views/mall/promotion/discountActivity/index.vue

@@ -0,0 +1,237 @@
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="活动名称" prop="name">
+        <el-input
+          v-model="queryParams.name"
+          placeholder="请输入活动名称"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="活动状态" prop="status">
+        <el-select
+          v-model="queryParams.status"
+          placeholder="请选择活动状态"
+          clearable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="活动时间" prop="activeTime">
+        <el-date-picker
+          v-model="queryParams.activeTime"
+          value-format="YYYY-MM-DD HH:mm:ss"
+          type="daterange"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+        <el-button
+          type="primary"
+          plain
+          @click="openForm('create')"
+          v-hasPermi="['promotion:discount-activity:create']"
+        >
+          <Icon icon="ep:plus" class="mr-5px" /> 新增活动
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+      <el-table-column label="活动编号" prop="id" min-width="80" />
+      <el-table-column label="活动名称" prop="name" min-width="140" />
+      <el-table-column label="活动时间" min-width="210">
+        <template #default="scope">
+          {{ formatDate(scope.row.startTime, 'YYYY-MM-DD') }}
+          ~ {{ formatDate(scope.row.endTime, 'YYYY-MM-DD') }}
+        </template>
+      </el-table-column>
+      <el-table-column label="商品图片" prop="spuName" min-width="80">
+        <template #default="scope">
+          <el-image
+            :src="scope.row.picUrl"
+            class="h-40px w-40px"
+            :preview-src-list="[scope.row.picUrl]"
+            preview-teleported
+          />
+        </template>
+      </el-table-column>
+      <el-table-column label="商品标题" prop="spuName" min-width="300" />
+      <el-table-column label="活动状态" align="center" prop="status" min-width="100">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
+        </template>
+      </el-table-column>
+      <el-table-column
+        label="创建时间"
+        align="center"
+        prop="createTime"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+      <el-table-column label="操作" align="center" width="150px" fixed="right">
+        <template #default="scope">
+          <el-button
+            link
+            type="primary"
+            @click="openForm('update', scope.row.id)"
+            v-hasPermi="['promotion:discount-activity:update']"
+          >
+            编辑
+          </el-button>
+          <el-button
+            link
+            type="danger"
+            @click="handleClose(scope.row.id)"
+            v-if="scope.row.status === 0"
+            v-hasPermi="['promotion:discount-activity:close']"
+          >
+            关闭
+          </el-button>
+          <el-button
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+            v-else
+            v-hasPermi="['promotion:discount-activity: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>
+  <!-- 表单弹窗:添加/修改 -->
+  <DiscountActivityForm ref="formRef" @success="getList" />
+</template>
+
+<script setup lang="ts">
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import * as DiscountActivity from '@/api/mall/promotion/discount/discountActivity'
+import DiscountActivityForm from './DiscountActivityForm.vue'
+import { formatDate } from '@/utils/formatTime'
+import { fenToYuanFormat } from '@/utils/formatter'
+import { fenToYuan } from '@/utils'
+
+defineOptions({ name: 'DiscountActivity' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数据
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  activeTime: null,
+  name: null,
+  status: null
+})
+const queryFormRef = ref() // 搜索的表单
+const exportLoading = ref(false) // 导出的加载中
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await DiscountActivity.getDiscountActivityPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
+
+/** 关闭按钮操作 */
+const handleClose = async (id: number) => {
+  try {
+    // 关闭的二次确认
+    await message.confirm('确认关闭该限时折扣活动吗?')
+    // 发起关闭
+    await DiscountActivity.closeDiscountActivity(id)
+    message.success('关闭成功')
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await DiscountActivity.deleteDiscountActivity(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+const configList = ref([]) // 时段配置精简列表
+// const formatConfigNames = (configId) => {
+//   const config = configList.value.find((item) => item.id === configId)
+//   return config != null ? `${config.name}[${config.startTime} ~ ${config.endTime}]` : ''
+// }
+
+const formatSeckillPrice = (products) => {
+  // const seckillPrice = Math.min(...products.map((item) => item.seckillPrice))
+  console.log(products)
+  const seckillPrice = 200
+  return `¥${fenToYuan(seckillPrice)}`
+}
+
+/** 初始化 **/
+onMounted(async () => {
+  await getList()
+})
+</script>

+ 119 - 0
src/views/mall/statistics/member/components/MemberFunnelCard.vue

@@ -0,0 +1,119 @@
+<template>
+  <el-card shadow="never">
+    <template #header>
+      <div class="my--1.5 flex flex-row items-center justify-between">
+        <CardTitle title="会员概览" />
+        <!-- 查询条件 -->
+        <ShortcutDateRangePicker @change="handleTimeRangeChange" />
+      </div>
+    </template>
+    <div class="min-w-225 py-1.75" v-loading="loading">
+      <div class="relative h-24 flex">
+        <div class="h-full w-75% bg-blue-50 <lg:w-35% <xl:w-55%">
+          <div class="ml-15 h-full flex flex-col justify-center">
+            <div class="font-bold">
+              注册用户数量:{{ analyseData?.comparison?.value?.registerUserCount || 0 }}
+            </div>
+            <div class="mt-2 text-3.5">
+              环比增长率:{{
+                calculateRelativeRate(
+                  analyseData?.comparison?.value?.registerUserCount,
+                  analyseData?.comparison?.reference?.registerUserCount
+                )
+              }}%
+            </div>
+          </div>
+        </div>
+        <div
+          class="trapezoid1 ml--38.5 mt-1.5 h-full w-77 flex flex-col items-center justify-center bg-blue-5 text-3.5 text-white"
+        >
+          <span class="text-6 font-bold">{{ analyseData?.visitUserCount || 0 }}</span>
+          <span>访客</span>
+        </div>
+      </div>
+      <div class="relative h-24 flex">
+        <div class="h-full w-75% flex bg-cyan-50 <lg:w-35% <xl:w-55%">
+          <div class="ml-15 h-full flex flex-col justify-center">
+            <div class="font-bold">
+              活跃用户数量:{{ analyseData?.comparison?.value?.visitUserCount || 0 }}
+            </div>
+            <div class="mt-2 text-3.5">
+              环比增长率:{{
+                calculateRelativeRate(
+                  analyseData?.comparison?.value?.visitUserCount,
+                  analyseData?.comparison?.reference?.visitUserCount
+                )
+              }}%
+            </div>
+          </div>
+        </div>
+        <div
+          class="trapezoid2 ml--28 mt-1.7 h-25 w-56 flex flex-col items-center justify-center bg-cyan-5 text-3.5 text-white"
+        >
+          <span class="text-6 font-bold">{{ analyseData?.orderUserCount || 0 }}</span>
+          <span>下单</span>
+        </div>
+      </div>
+      <div class="relative h-24 flex">
+        <div class="w-75% flex bg-slate-50 <lg:w-35% <xl:w-55%">
+          <div class="ml-15 h-full flex flex-row gap-x-16">
+            <div class="flex flex-col justify-center">
+              <div class="font-bold">
+                充值用户数量:{{ analyseData?.comparison?.value?.rechargeUserCount || 0 }}
+              </div>
+              <div class="mt-2 text-3.5">
+                环比增长率:{{
+                  calculateRelativeRate(
+                    analyseData?.comparison?.value?.rechargeUserCount,
+                    analyseData?.comparison?.reference?.rechargeUserCount
+                  )
+                }}%
+              </div>
+            </div>
+            <div class="flex flex-col justify-center">
+              <div class="font-bold">客单价:{{ fenToYuan(analyseData?.atv || 0) }}</div>
+            </div>
+          </div>
+        </div>
+        <div
+          class="trapezoid3 ml--18 mt-3.25 h-23 w-36 flex flex-col items-center justify-center bg-slate-5 text-3.5 text-white"
+        >
+          <span class="text-6 font-bold">{{ analyseData?.payUserCount || 0 }}</span>
+          <span>成交用户</span>
+        </div>
+      </div>
+    </div>
+  </el-card>
+</template>
+<script lang="ts" setup>
+import * as MemberStatisticsApi from '@/api/mall/statistics/member'
+import dayjs from 'dayjs'
+import { calculateRelativeRate, fenToYuan } from '@/utils'
+import { MemberAnalyseRespVO } from '@/api/mall/statistics/member'
+import { CardTitle } from '@/components/Card'
+
+/** 会员概览卡片 */
+defineOptions({ name: 'MemberFunnelCard' })
+
+const loading = ref(true) // 加载中
+const analyseData = ref<MemberAnalyseRespVO>() // 会员分析数据
+
+/** 查询会员概览数据列表 */
+const handleTimeRangeChange = async (times: [dayjs.ConfigType, dayjs.ConfigType]) => {
+  loading.value = true
+  // 查询数据
+  analyseData.value = await MemberStatisticsApi.getMemberAnalyse({ times })
+  loading.value = false
+}
+</script>
+<style lang="scss" scoped>
+.trapezoid1 {
+  transform: perspective(5em) rotateX(-11deg);
+}
+.trapezoid2 {
+  transform: perspective(7em) rotateX(-20deg);
+}
+.trapezoid3 {
+  transform: perspective(3em) rotateX(-13deg);
+}
+</style>

+ 69 - 0
src/views/mall/statistics/member/components/MemberTerminalCard.vue

@@ -0,0 +1,69 @@
+<template>
+  <el-card shadow="never" v-loading="loading">
+    <template #header>
+      <CardTitle title="会员终端" />
+    </template>
+    <Echart :height="300" :options="terminalChartOptions" />
+  </el-card>
+</template>
+<script lang="ts" setup>
+import * as MemberStatisticsApi from '@/api/mall/statistics/member'
+import { EChartsOption } from 'echarts'
+import { MemberTerminalStatisticsRespVO } from '@/api/mall/statistics/member'
+import { DICT_TYPE, DictDataType, getIntDictOptions } from '@/utils/dict'
+import { CardTitle } from '@/components/Card'
+
+/** 会员终端卡片 */
+defineOptions({ name: 'MemberTerminalCard' })
+
+const loading = ref(true) // 加载中
+
+/** 会员终端统计图配置 */
+const terminalChartOptions = reactive<EChartsOption>({
+  tooltip: {
+    trigger: 'item',
+    confine: true,
+    formatter: '{a} <br/>{b} : {c} ({d}%)'
+  },
+  legend: {
+    orient: 'vertical',
+    left: 'right'
+  },
+  roseType: 'area',
+  series: [
+    {
+      name: '会员终端',
+      type: 'pie',
+      label: {
+        show: false
+      },
+      labelLine: {
+        show: false
+      },
+      data: []
+    }
+  ]
+}) as EChartsOption
+
+/** 按照终端,查询会员统计列表 */
+const getMemberTerminalStatisticsList = async () => {
+  loading.value = true
+  const list = await MemberStatisticsApi.getMemberTerminalStatisticsList()
+  const dictDataList = getIntDictOptions(DICT_TYPE.TERMINAL)
+  terminalChartOptions.series![0].data = dictDataList.map((dictData: DictDataType) => {
+    const userCount = list.find(
+      (item: MemberTerminalStatisticsRespVO) => item.terminal === dictData.value
+    )?.userCount
+    return {
+      name: dictData.label,
+      value: userCount || 0
+    }
+  })
+  loading.value = false
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getMemberTerminalStatisticsList()
+})
+</script>

+ 40 - 212
src/views/mall/statistics/member/index.vue

@@ -2,7 +2,7 @@
   <div class="flex flex-col">
     <el-row :gutter="16" class="summary">
       <el-col :sm="6" :xs="12" v-loading="loading">
-        <TradeTrendValue
+        <SummaryCard
           title="累计会员数"
           icon="fa-solid:users"
           icon-color="bg-blue-100"
@@ -11,7 +11,7 @@
         />
       </el-col>
       <el-col :sm="6" :xs="12" v-loading="loading">
-        <TradeTrendValue
+        <SummaryCard
           title="累计充值人数"
           icon="fa-solid:user"
           icon-color="bg-purple-100"
@@ -20,7 +20,7 @@
         />
       </el-col>
       <el-col :sm="6" :xs="12" v-loading="loading">
-        <TradeTrendValue
+        <SummaryCard
           title="累计充值金额"
           icon="fa-solid:money-check-alt"
           icon-color="bg-yellow-100"
@@ -31,7 +31,7 @@
         />
       </el-col>
       <el-col :sm="6" :xs="12" v-loading="loading">
-        <TradeTrendValue
+        <SummaryCard
           title="累计消费金额"
           icon="fa-solid:yen-sign"
           icon-color="bg-green-100"
@@ -44,118 +44,20 @@
     </el-row>
     <el-row :gutter="16" class="mb-4">
       <el-col :md="18" :sm="24">
-        <el-card shadow="never">
-          <template #header>
-            <div class="flex flex-row items-center justify-between">
-              <span>会员概览</span>
-              <!-- 查询条件 -->
-              <div class="my--2 flex flex-row items-center gap-2">
-                <el-radio-group v-model="shortcutDays" @change="handleDateTypeChange">
-                  <el-radio-button :label="1">昨天</el-radio-button>
-                  <el-radio-button :label="7">最近7天</el-radio-button>
-                  <el-radio-button :label="30">最近30天</el-radio-button>
-                </el-radio-group>
-                <el-date-picker
-                  v-model="queryParams.times"
-                  value-format="YYYY-MM-DD HH:mm:ss"
-                  type="daterange"
-                  start-placeholder="开始日期"
-                  end-placeholder="结束日期"
-                  :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
-                  :shortcuts="shortcuts"
-                  class="!w-240px"
-                  @change="getMemberAnalyse"
-                />
-              </div>
-            </div>
-          </template>
-          <div class="min-w-225 py-1.75" v-loading="analyseLoading">
-            <div class="relative h-24 flex">
-              <div class="h-full w-75% bg-blue-50 <lg:w-35% <xl:w-55%">
-                <div class="ml-15 h-full flex flex-col justify-center">
-                  <div class="font-bold">
-                    注册用户数量:{{ analyseData?.comparison?.value?.userCount || 0 }}
-                  </div>
-                  <div class="mt-2 text-3.5">
-                    环比增长率:{{
-                      calculateRelativeRate(
-                        analyseData?.comparison?.value?.userCount,
-                        analyseData?.comparison?.reference?.userCount
-                      )
-                    }}%
-                  </div>
-                </div>
-              </div>
-              <div
-                class="trapezoid1 ml--38.5 mt-1.5 h-full w-77 flex flex-col items-center justify-center bg-blue-5 text-3.5 text-white"
-              >
-                <span class="text-6 font-bold">{{ analyseData?.visitorCount || 0 }}</span>
-                <span>访客</span>
-              </div>
-            </div>
-            <div class="relative h-24 flex">
-              <div class="h-full w-75% flex bg-cyan-50 <lg:w-35% <xl:w-55%">
-                <div class="ml-15 h-full flex flex-col justify-center">
-                  <div class="font-bold">
-                    活跃用户数量:{{ analyseData?.comparison?.value?.activeUserCount || 0 }}
-                  </div>
-                  <div class="mt-2 text-3.5">
-                    环比增长率:{{
-                      calculateRelativeRate(
-                        analyseData?.comparison?.value?.activeUserCount,
-                        analyseData?.comparison?.reference?.activeUserCount
-                      )
-                    }}%
-                  </div>
-                </div>
-              </div>
-              <div
-                class="trapezoid2 ml--28 mt-1.7 h-25 w-56 flex flex-col items-center justify-center bg-cyan-5 text-3.5 text-white"
-              >
-                <span class="text-6 font-bold">{{ analyseData?.orderUserCount || 0 }}</span>
-                <span>下单</span>
-              </div>
-            </div>
-            <div class="relative h-24 flex">
-              <div class="w-75% flex bg-slate-50 <lg:w-35% <xl:w-55%">
-                <div class="ml-15 h-full flex flex-row gap-x-16">
-                  <div class="flex flex-col justify-center">
-                    <div class="font-bold">
-                      充值用户数量:{{ analyseData?.comparison?.value?.rechargeUserCount || 0 }}
-                    </div>
-                    <div class="mt-2 text-3.5">
-                      环比增长率:{{
-                        calculateRelativeRate(
-                          analyseData?.comparison?.value?.rechargeUserCount,
-                          analyseData?.comparison?.reference?.rechargeUserCount
-                        )
-                      }}%
-                    </div>
-                  </div>
-                  <div class="flex flex-col justify-center">
-                    <div class="font-bold">客单价:{{ fenToYuan(analyseData?.atv || 0) }}</div>
-                  </div>
-                </div>
-              </div>
-              <div
-                class="trapezoid3 ml--18 mt-3.25 h-23 w-36 flex flex-col items-center justify-center bg-slate-5 text-3.5 text-white"
-              >
-                <span class="text-6 font-bold">{{ analyseData?.payUserCount || 0 }}</span>
-                <span>成交用户</span>
-              </div>
-            </div>
-          </div>
-        </el-card>
+        <!-- 会员概览 -->
+        <MemberFunnelCard />
       </el-col>
       <el-col :md="6" :sm="24">
-        <el-card shadow="never" header="会员终端" v-loading="loading">
-          <Echart :height="300" :options="terminalChartOptions" />
-        </el-card>
+        <!-- 会员终端 -->
+        <MemberTerminalCard />
       </el-col>
     </el-row>
     <el-row :gutter="16">
       <el-col :md="18" :sm="24">
-        <el-card shadow="never" header="会员地域分布">
+        <el-card shadow="never">
+          <template #header>
+            <CardTitle title="会员地域分布" />
+          </template>
           <el-row v-loading="loading">
             <el-col :span="10">
               <Echart :height="300" :options="areaChartOptions" />
@@ -180,14 +82,14 @@
                 />
                 <el-table-column
                   label="订单创建数量"
-                  prop="orderCreateCount"
+                  prop="orderCreateUserCount"
                   align="center"
                   min-width="135"
                   sortable
                 />
                 <el-table-column
                   label="订单支付数量"
-                  prop="orderPayCount"
+                  prop="orderPayUserCount"
                   align="center"
                   min-width="135"
                   sortable
@@ -206,7 +108,10 @@
         </el-card>
       </el-col>
       <el-col :md="6" :sm="24">
-        <el-card shadow="never" header="会员性别比例" v-loading="loading">
+        <el-card shadow="never" v-loading="loading">
+          <template #header>
+            <CardTitle title="会员性别比例" />
+          </template>
           <Echart :height="300" :options="sexChartOptions" />
         </el-card>
       </el-col>
@@ -214,62 +119,33 @@
   </div>
 </template>
 <script lang="ts" setup>
-import * as TradeMemberApi from '@/api/mall/statistics/member'
-import TradeTrendValue from '../trade/components/TradeTrendValue.vue'
+import * as MemberStatisticsApi from '@/api/mall/statistics/member'
+import SummaryCard from '@/components/SummaryCard/index.vue'
 import { EChartsOption } from 'echarts'
 import china from '@/assets/map/json/china.json'
-import dayjs from 'dayjs'
 import { fenToYuan } from '@/utils'
-import * as DateUtil from '@/utils/formatTime'
 import {
-  MemberAnalyseRespVO,
   MemberAreaStatisticsRespVO,
   MemberSexStatisticsRespVO,
-  MemberAnalyseReqVO,
   MemberSummaryRespVO,
   MemberTerminalStatisticsRespVO
 } from '@/api/mall/statistics/member'
 import { DICT_TYPE, DictDataType, getIntDictOptions } from '@/utils/dict'
 import echarts from '@/plugins/echarts'
 import { fenToYuanFormat } from '@/utils/formatter'
+import MemberFunnelCard from './components/MemberFunnelCard.vue'
+import MemberTerminalCard from './components/MemberTerminalCard.vue'
+import { CardTitle } from '@/components/Card'
 
 /** 会员统计 */
 defineOptions({ name: 'MemberStatistics' })
 
 const loading = ref(true) // 加载中
-const analyseLoading = ref(true) // 会员概览加载中
-const queryParams = reactive<MemberAnalyseReqVO>({ times: ['', ''] }) // 会员概览查询参数
-const shortcutDays = ref(7) // 日期快捷天数(单选按钮组), 默认7天
 const summary = ref<MemberSummaryRespVO>() // 会员统计数据
-const analyseData = ref<MemberAnalyseRespVO>() // 会员分析数据
 const areaStatisticsList = shallowRef<MemberAreaStatisticsRespVO[]>() // 省份会员统计
 
 // 注册地图
-echarts?.registerMap('china', china!)
-
-/** 日期快捷选择 */
-const shortcuts = [
-  {
-    text: '昨天',
-    value: () => DateUtil.getDayRange(new Date(), -1)
-  },
-  {
-    text: '最近7天',
-    value: () => DateUtil.getLast7Days()
-  },
-  {
-    text: '本月',
-    value: () => [dayjs().startOf('M'), dayjs().subtract(1, 'd')]
-  },
-  {
-    text: '最近30天',
-    value: () => DateUtil.getLast30Days()
-  },
-  {
-    text: '最近1年',
-    value: () => DateUtil.getLast1Year()
-  }
-]
+echarts?.registerMap('china', china as any)
 
 /** 会员终端统计图配置 */
 const terminalChartOptions = reactive<EChartsOption>({
@@ -331,8 +207,8 @@ const areaChartOptions = reactive<EChartsOption>({
     formatter: (params: any) => {
       return `${params?.data?.areaName || params?.name}<br/>
 会员数量:${params?.data?.userCount || 0}<br/>
-订单创建数量:${params?.data?.orderCreateCount || 0}<br/>
-订单支付数量:${params?.data?.orderPayCount || 0}<br/>
+订单创建数量:${params?.data?.orderCreateUserCount || 0}<br/>
+订单支付数量:${params?.data?.orderPayUserCount || 0}<br/>
 订单支付金额:${fenToYuan(params?.data?.orderPayPrice || 0)}`
     }
   },
@@ -357,37 +233,14 @@ const areaChartOptions = reactive<EChartsOption>({
   ]
 }) as EChartsOption
 
-/** 计算环比 */
-const calculateRelativeRate = (value?: number, reference?: number) => {
-  // 防止除0
-  if (!reference) return 0
-
-  return ((100 * ((value || 0) - reference)) / reference).toFixed(0)
-}
-
-/** 设置时间范围 */
-function setTimes() {
-  const beginDate = dayjs().subtract(shortcutDays.value, 'd')
-  const yesterday = dayjs().subtract(1, 'd')
-  queryParams.times = DateUtil.getDateRange(beginDate, yesterday)
-}
-
-/** 处理会员概览查询(日期单选按钮组选择后) */
-const handleDateTypeChange = async () => {
-  // 设置时间范围
-  setTimes()
-  // 查询数据
-  await getMemberAnalyse()
-}
-
 /** 查询会员统计 */
 const getMemberSummary = async () => {
-  summary.value = await TradeMemberApi.getMemberSummary()
+  summary.value = await MemberStatisticsApi.getMemberSummary()
 }
 
 /** 按照省份,查询会员统计列表 */
 const getMemberAreaStatisticsList = async () => {
-  const list = await TradeMemberApi.getMemberAreaStatisticsList()
+  const list = await MemberStatisticsApi.getMemberAreaStatisticsList()
   areaStatisticsList.value = list.map((item: MemberAreaStatisticsRespVO) => {
     return {
       ...item,
@@ -401,20 +254,21 @@ const getMemberAreaStatisticsList = async () => {
   })
   let min = 0
   let max = 0
-  areaChartOptions.series[0].data = areaStatisticsList.value.map((item) => {
-    min = Math.min(min, item.orderPayCount)
-    max = Math.max(max, item.orderPayCount)
-    return { ...item, name: item.areaName, value: item.orderPayCount || 0 }
+  areaChartOptions.series![0].data = areaStatisticsList.value.map((item) => {
+    min = Math.min(min, item.orderPayUserCount || 0)
+    max = Math.max(max, item.orderPayUserCount || 0)
+    return { ...item, name: item.areaName, value: item.orderPayUserCount || 0 }
   })
-  areaChartOptions.visualMap.min = min
-  areaChartOptions.visualMap.max = max
+  areaChartOptions.visualMap!['min'] = min
+  areaChartOptions.visualMap!['max'] = max
 }
 
 /** 按照性别,查询会员统计列表 */
 const getMemberSexStatisticsList = async () => {
-  const list = await TradeMemberApi.getMemberSexStatisticsList()
+  const list = await MemberStatisticsApi.getMemberSexStatisticsList()
   const dictDataList = getIntDictOptions(DICT_TYPE.SYSTEM_USER_SEX)
-  sexChartOptions.series[0].data = dictDataList.map((dictData: DictDataType) => {
+  dictDataList.push({ label: '未知', value: null } as any)
+  sexChartOptions.series![0].data = dictDataList.map((dictData: DictDataType) => {
     const userCount = list.find((item: MemberSexStatisticsRespVO) => item.sex === dictData.value)
       ?.userCount
     return {
@@ -426,8 +280,9 @@ const getMemberSexStatisticsList = async () => {
 
 /** 按照终端,查询会员统计列表 */
 const getMemberTerminalStatisticsList = async () => {
-  const list = await TradeMemberApi.getMemberTerminalStatisticsList()
+  const list = await MemberStatisticsApi.getMemberTerminalStatisticsList()
   const dictDataList = getIntDictOptions(DICT_TYPE.TERMINAL)
+  dictDataList.push({ label: '未知', value: null } as any)
   terminalChartOptions.series![0].data = dictDataList.map((dictData: DictDataType) => {
     const userCount = list.find(
       (item: MemberTerminalStatisticsRespVO) => item.terminal === dictData.value
@@ -439,20 +294,6 @@ const getMemberTerminalStatisticsList = async () => {
   })
 }
 
-/** 查询会员概览数据列表 */
-const getMemberAnalyse = async () => {
-  analyseLoading.value = true
-  const times = queryParams.times
-  // 开始与截止在同一天的, 环比出不来, 需要延长一天
-  if (DateUtil.isSameDay(times[0], times[1])) {
-    // 前天
-    times[0] = DateUtil.formatDate(dayjs(times[0]).subtract(1, 'd'))
-  }
-  // 查询数据
-  analyseData.value = await TradeMemberApi.getMemberAnalyse({ times })
-  analyseLoading.value = false
-}
-
 /** 初始化 **/
 onMounted(async () => {
   loading.value = true
@@ -460,8 +301,7 @@ onMounted(async () => {
     getMemberSummary(),
     getMemberTerminalStatisticsList(),
     getMemberAreaStatisticsList(),
-    getMemberSexStatisticsList(),
-    handleDateTypeChange()
+    getMemberSexStatisticsList()
   ])
   loading.value = false
 })
@@ -472,16 +312,4 @@ onMounted(async () => {
     margin-bottom: 1rem;
   }
 }
-
-.trapezoid1 {
-  transform: perspective(5em) rotateX(-11deg);
-}
-
-.trapezoid2 {
-  transform: perspective(7em) rotateX(-20deg);
-}
-
-.trapezoid3 {
-  transform: perspective(3em) rotateX(-13deg);
-}
 </style>

+ 37 - 113
src/views/mall/statistics/trade/index.vue

@@ -59,25 +59,9 @@
       <template #header>
         <!-- 标题 -->
         <div class="flex flex-row items-center justify-between">
-          <span>交易状况</span>
+          <CardTitle title="交易状况" />
           <!-- 查询条件 -->
-          <div class="flex flex-row items-center gap-2">
-            <el-radio-group v-model="shortcutDays" @change="handleDateTypeChange">
-              <el-radio-button :label="1">昨天</el-radio-button>
-              <el-radio-button :label="7">最近7天</el-radio-button>
-              <el-radio-button :label="30">最近30天</el-radio-button>
-            </el-radio-group>
-            <el-date-picker
-              v-model="queryParams.times"
-              value-format="YYYY-MM-DD HH:mm:ss"
-              type="daterange"
-              start-placeholder="开始日期"
-              end-placeholder="结束日期"
-              :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
-              :shortcuts="shortcuts"
-              class="!w-240px"
-              @change="getTradeTrendData"
-            />
+          <ShortcutDateRangePicker ref="shortcutDateRangePicker" @change="getTradeTrendData">
             <el-button
               class="ml-4"
               @click="handleExport"
@@ -86,13 +70,13 @@
             >
               <Icon icon="ep:download" class="mr-1" />导出
             </el-button>
-          </div>
+          </ShortcutDateRangePicker>
         </div>
       </template>
       <!-- 统计值 -->
       <el-row :gutter="16">
         <el-col :md="6" :sm="12" :xs="24">
-          <TradeTrendValue
+          <SummaryCard
             title="营业额"
             tooltip="商品支付金额、充值金额"
             icon="fa-solid:yen-sign"
@@ -100,17 +84,17 @@
             icon-bg-color="text-blue-500"
             prefix="¥"
             :decimals="2"
-            :value="fenToYuan(trendSummary?.value?.turnover || 0)"
+            :value="fenToYuan(trendSummary?.value?.turnoverPrice || 0)"
             :percent="
               calculateRelativeRate(
-                trendSummary?.value?.turnover,
-                trendSummary?.reference?.turnover
+                trendSummary?.value?.turnoverPrice,
+                trendSummary?.reference?.turnoverPrice
               )
             "
           />
         </el-col>
         <el-col :md="6" :sm="12" :xs="24">
-          <TradeTrendValue
+          <SummaryCard
             title="商品支付金额"
             tooltip="用户购买商品的实际支付金额,包括微信支付、余额支付、支付宝支付、线下支付金额(拼团商品在成团之后计入,线下支付订单在后台确认支付后计入)"
             icon="fa-solid:shopping-cart"
@@ -128,7 +112,7 @@
           />
         </el-col>
         <el-col :md="6" :sm="12" :xs="24">
-          <TradeTrendValue
+          <SummaryCard
             title="充值金额"
             tooltip="用户成功充值的金额"
             icon="fa-solid:money-check-alt"
@@ -146,7 +130,7 @@
           />
         </el-col>
         <el-col :md="6" :sm="12" :xs="24">
-          <TradeTrendValue
+          <SummaryCard
             title="支出金额"
             tooltip="余额支付金额、支付佣金金额、商品退款金额"
             icon="ep:warning-filled"
@@ -164,7 +148,7 @@
           />
         </el-col>
         <el-col :md="6" :sm="12" :xs="24">
-          <TradeTrendValue
+          <SummaryCard
             title="余额支付金额"
             tooltip="用户下单时使用余额实际支付的金额"
             icon="fa-solid:wallet"
@@ -172,17 +156,17 @@
             icon-bg-color="text-cyan-500"
             prefix="¥"
             :decimals="2"
-            :value="fenToYuan(trendSummary?.value?.balancePrice || 0)"
+            :value="fenToYuan(trendSummary?.value?.walletPayPrice || 0)"
             :percent="
               calculateRelativeRate(
-                trendSummary?.value?.balancePrice,
-                trendSummary?.reference?.balancePrice
+                trendSummary?.value?.walletPayPrice,
+                trendSummary?.reference?.walletPayPrice
               )
             "
           />
         </el-col>
         <el-col :md="6" :sm="12" :xs="24">
-          <TradeTrendValue
+          <SummaryCard
             title="支付佣金金额"
             tooltip="后台给推广员支付的推广佣金,以实际支付为准"
             icon="fa-solid:award"
@@ -200,7 +184,7 @@
           />
         </el-col>
         <el-col :md="6" :sm="12" :xs="24">
-          <TradeTrendValue
+          <SummaryCard
             title="商品退款金额"
             tooltip="用户成功退款的商品金额"
             icon="fa-solid:times-circle"
@@ -208,11 +192,11 @@
             icon-bg-color="text-blue-500"
             prefix="¥"
             :decimals="2"
-            :value="fenToYuan(trendSummary?.value?.orderRefundPrice || 0)"
+            :value="fenToYuan(trendSummary?.value?.afterSaleRefundPrice || 0)"
             :percent="
               calculateRelativeRate(
-                trendSummary?.value?.orderRefundPrice,
-                trendSummary?.reference?.orderRefundPrice
+                trendSummary?.value?.afterSaleRefundPrice,
+                trendSummary?.reference?.afterSaleRefundPrice
               )
             "
           />
@@ -228,60 +212,29 @@
 <script lang="ts" setup>
 import * as TradeStatisticsApi from '@/api/mall/statistics/trade'
 import TradeStatisticValue from './components/TradeStatisticValue.vue'
-import TradeTrendValue from './components/TradeTrendValue.vue'
+import SummaryCard from '@/components/SummaryCard/index.vue'
 import { EChartsOption } from 'echarts'
-import {
-  TradeStatisticsComparisonRespVO,
-  TradeSummaryRespVO,
-  TradeTrendReqVO,
-  TradeTrendSummaryRespVO
-} from '@/api/mall/statistics/trade'
-import dayjs from 'dayjs'
-import { fenToYuan } from '@/utils'
-import * as DateUtil from '@/utils/formatTime'
+import { DataComparisonRespVO } from '@/api/mall/statistics/common'
+import { TradeSummaryRespVO, TradeTrendSummaryRespVO } from '@/api/mall/statistics/trade'
+import { calculateRelativeRate, fenToYuan } from '@/utils'
 import download from '@/utils/download'
+import { CardTitle } from '@/components/Card'
 
 /** 交易统计 */
 defineOptions({ name: 'TradeStatistics' })
 
 const message = useMessage() // 消息弹窗
 
-const loading = ref(true) // 加载中
 const trendLoading = ref(true) // 交易状态加载中
 const exportLoading = ref(false) // 导出的加载中
-const queryParams = reactive<TradeTrendReqVO>({ times: ['', ''] }) // 交易状况查询参数
-const shortcutDays = ref(7) // 日期快捷天数(单选按钮组), 默认7天
-const summary = ref<TradeStatisticsComparisonRespVO<TradeSummaryRespVO>>() // 交易统计数据
-const trendSummary = ref<TradeStatisticsComparisonRespVO<TradeTrendSummaryRespVO>>() // 交易状况统计数据
-
-/** 日期快捷选择 */
-const shortcuts = [
-  {
-    text: '昨天',
-    value: () => DateUtil.getDayRange(new Date(), -1)
-  },
-  {
-    text: '最近7天',
-    value: () => DateUtil.getLast7Days()
-  },
-  {
-    text: '本月',
-    value: () => [dayjs().startOf('M'), dayjs().subtract(1, 'd')]
-  },
-  {
-    text: '最近30天',
-    value: () => DateUtil.getLast30Days()
-  },
-  {
-    text: '最近1年',
-    value: () => DateUtil.getLast1Year()
-  }
-]
+const summary = ref<DataComparisonRespVO<TradeSummaryRespVO>>() // 交易统计数据
+const trendSummary = ref<DataComparisonRespVO<TradeTrendSummaryRespVO>>() // 交易状况统计数据
+const shortcutDateRangePicker = ref()
 
 /** 折线图配置 */
 const lineChartOptions = reactive<EChartsOption>({
   dataset: {
-    dimensions: ['date', 'turnover', 'orderPayPrice', 'rechargePrice', 'expensePrice'],
+    dimensions: ['date', 'turnoverPrice', 'orderPayPrice', 'rechargePrice', 'expensePrice'],
     source: []
   },
   grid: {
@@ -333,33 +286,10 @@ const lineChartOptions = reactive<EChartsOption>({
   }
 }) as EChartsOption
 
-/** 计算环比 */
-const calculateRelativeRate = (value?: number, reference?: number) => {
-  // 防止除0
-  if (!reference) return 0
-
-  return ((100 * ((value || 0) - reference)) / reference).toFixed(0)
-}
-
-/** 设置时间范围 */
-function setTimes() {
-  const beginDate = dayjs().subtract(shortcutDays.value, 'd')
-  const yesterday = dayjs().subtract(1, 'd')
-  queryParams.times = DateUtil.getDateRange(beginDate, yesterday)
-}
-
-/** 处理交易状况查询(日期单选按钮组选择后) */
-const handleDateTypeChange = async () => {
-  // 设置时间范围
-  setTimes()
-  // 查询数据
-  await getTradeTrendData()
-}
-
 /** 处理交易状况查询 */
 const getTradeTrendData = async () => {
   trendLoading.value = true
-  await Promise.all([getTradeTrendSummary(), getTradeTrendList()])
+  await Promise.all([getTradeTrendSummary(), getTradeStatisticsList()])
   trendLoading.value = false
 }
 
@@ -370,24 +300,18 @@ const getTradeStatisticsSummary = async () => {
 
 /** 查询交易状况数据统计 */
 const getTradeTrendSummary = async () => {
-  loading.value = true
-  trendSummary.value = await TradeStatisticsApi.getTradeTrendSummary(queryParams)
-  loading.value = false
+  const times = shortcutDateRangePicker.value.times
+  trendSummary.value = await TradeStatisticsApi.getTradeTrendSummary({ times })
 }
 
 /** 查询交易状况数据列表 */
-const getTradeTrendList = async () => {
-  const times = queryParams.times
-  // 开始与截止在同一天的, 折线图出不来, 需要延长一天
-  if (DateUtil.isSameDay(times[0], times[1])) {
-    // 前天
-    times[0] = DateUtil.formatDate(dayjs(times[0]).subtract(1, 'd'))
-  }
+const getTradeStatisticsList = async () => {
   // 查询数据
-  const list = await TradeStatisticsApi.getTradeTrendList({ times })
+  const times = shortcutDateRangePicker.value.times
+  const list = await TradeStatisticsApi.getTradeStatisticsList({ times })
   // 处理数据
   for (let item of list) {
-    item.turnover = fenToYuan(item.turnover)
+    item.turnoverPrice = fenToYuan(item.turnoverPrice)
     item.orderPayPrice = fenToYuan(item.orderPayPrice)
     item.rechargePrice = fenToYuan(item.rechargePrice)
     item.expensePrice = fenToYuan(item.expensePrice)
@@ -405,7 +329,8 @@ const handleExport = async () => {
     await message.exportConfirm()
     // 发起导出
     exportLoading.value = true
-    const data = await TradeStatisticsApi.exportTradeTrend(queryParams)
+    const times = shortcutDateRangePicker.value.times
+    const data = await TradeStatisticsApi.exportTradeStatisticsExcel({ times })
     download.excel(data, '交易状况.xls')
   } catch {
   } finally {
@@ -416,7 +341,6 @@ const handleExport = async () => {
 /** 初始化 **/
 onMounted(async () => {
   await getTradeStatisticsSummary()
-  await handleDateTypeChange()
 })
 </script>
 <style lang="scss" scoped>

+ 324 - 0
src/views/mall/trade/delivery/pickUpOrder/index.vue

@@ -0,0 +1,324 @@
+<template>
+  <!-- 搜索 -->
+  <ContentWrap>
+    <el-form
+      ref="queryFormRef"
+      :inline="true"
+      :model="queryParams"
+      class="-mb-15px"
+      label-width="68px"
+    >
+      <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"
+          end-placeholder="自定义时间"
+          start-placeholder="自定义时间"
+          type="daterange"
+          value-format="YYYY-MM-DD HH:mm:ss"
+        />
+      </el-form-item>
+      <el-form-item label="自提门店" prop="pickUpStoreId">
+        <el-select
+          v-model="queryParams.pickUpStoreId"
+          class="!w-280px"
+          clearable
+          multiple
+          placeholder="全部"
+        >
+          <el-option
+            v-for="item in pickUpStoreList"
+            :key="item.id"
+            :label="item.name"
+            :value="item.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="聚合搜索">
+        <el-input
+          v-show="true"
+          v-model="queryParams[queryType.queryParam]"
+          class="!w-280px"
+          clearable
+          placeholder="请输入"
+          :type="queryType.queryParam === 'userId' ? 'number' : 'text'"
+        >
+          <template #prepend>
+            <el-select
+              v-model="queryType.queryParam"
+              class="!w-110px"
+              placeholder="全部"
+              @change="inputChangeSelect"
+            >
+              <el-option
+                v-for="dict in dynamicSearchList"
+                :key="dict.value"
+                :label="dict.label"
+                :value="dict.value"
+              />
+            </el-select>
+          </template>
+        </el-input>
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery">
+          <Icon class="mr-5px" icon="ep:search" />
+          搜索
+        </el-button>
+        <el-button @click="resetQuery">
+          <Icon class="mr-5px" icon="ep:refresh" />
+          重置
+        </el-button>
+        <el-button @click="handlePickup" type="success" plain v-hasPermi="['trade:order:pick-up']">
+          <Icon class="mr-5px" icon="ep:check" />
+          核销
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 统计卡片 -->
+  <el-row :gutter="16" class="summary">
+    <el-col :sm="6" :xs="12" v-loading="loading">
+      <SummaryCard
+        title="订单数量"
+        icon="icon-park-outline:transaction-order"
+        icon-color="bg-blue-100"
+        icon-bg-color="text-blue-500"
+        :value="summary?.orderCount || 0"
+      />
+    </el-col>
+    <el-col :sm="6" :xs="12" v-loading="loading">
+      <SummaryCard
+        title="订单金额"
+        icon="streamline:money-cash-file-dollar-common-money-currency-cash-file"
+        icon-color="bg-purple-100"
+        icon-bg-color="text-purple-500"
+        prefix="¥"
+        :decimals="2"
+        :value="fenToYuan(summary?.orderPayPrice || 0)"
+      />
+    </el-col>
+    <el-col :sm="6" :xs="12" v-loading="loading">
+      <SummaryCard
+        title="退款单数"
+        icon="heroicons:receipt-refund"
+        icon-color="bg-yellow-100"
+        icon-bg-color="text-yellow-500"
+        :value="summary?.afterSaleCount || 0"
+      />
+    </el-col>
+    <el-col :sm="6" :xs="12" v-loading="loading">
+      <SummaryCard
+        title="退款金额"
+        icon="ri:refund-2-line"
+        icon-color="bg-green-100"
+        icon-bg-color="text-green-500"
+        prefix="¥"
+        :decimals="2"
+        :value="fenToYuan(summary?.afterSalePrice || 0)"
+      />
+    </el-col>
+  </el-row>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list">
+      <el-table-column label="订单号" align="center" prop="no" min-width="180" />
+      <el-table-column label="用户信息" align="center" prop="user.nickname" min-width="80" />
+      <el-table-column
+        label="推荐人信息"
+        align="center"
+        prop="brokerageUser.nickname"
+        min-width="100"
+      />
+      <el-table-column label="商品信息" align="center" prop="spuName" min-width="300">
+        <template #default="{ row }">
+          <div class="flex items-center" v-for="item in row.items" :key="item.id">
+            <el-image
+              :src="item.picUrl"
+              class="mr-10px h-30px w-30px flex-shrink-0"
+              :preview-src-list="[item.picUrl]"
+              preview-teleported
+            />
+            <span class="mr-10px">{{ item.spuName }}</span>
+            <div class="flex flex-col flex-wrap gap-1">
+              <el-tag
+                v-for="property in item.properties"
+                :key="property.propertyId"
+                class="mr-10px"
+              >
+                {{ property.propertyName }}: {{ property.valueName }}
+              </el-tag>
+              <span>{{ floatToFixed2(item.price) }} 元 x {{ item.count }}</span>
+            </div>
+          </div>
+        </template>
+      </el-table-column>
+      <el-table-column
+        label="实付金额(元)"
+        align="center"
+        prop="payPrice"
+        min-width="110"
+        :formatter="fenToYuanFormat"
+      />
+      <el-table-column label="核销员" align="center" prop="storeStaffName" min-width="70" />
+      <el-table-column label="核销门店" align="center" prop="pickUpStoreId" min-width="80">
+        <template #default="{ row }">
+          {{ pickUpStoreList.find((p) => p.id === row.pickUpStoreId)?.name }}
+        </template>
+      </el-table-column>
+      <el-table-column label="支付状态" align="center" prop="payStatus" min-width="80">
+        <template #default="{ row }">
+          <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="row.payStatus || false" />
+        </template>
+      </el-table-column>
+      <el-table-column align="center" label="订单状态" prop="status" width="120">
+        <template #default="{ row }">
+          <dict-tag :type="DICT_TYPE.TRADE_ORDER_STATUS" :value="row.status" />
+        </template>
+      </el-table-column>
+      <el-table-column
+        label="下单时间"
+        align="center"
+        prop="createTime"
+        min-width="170"
+        :formatter="dateFormatter"
+      />
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      v-model:limit="queryParams.pageSize"
+      v-model:page="queryParams.pageNo"
+      :total="total"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- 各种操作的弹窗 -->
+  <OrderPickUpForm ref="pickUpForm" @success="getList" />
+</template>
+
+<script lang="ts" setup>
+import type { FormInstance } from 'element-plus'
+import * as TradeOrderApi from '@/api/mall/trade/order'
+import * as PickUpStoreApi from '@/api/mall/trade/delivery/pickUpStore'
+import { DICT_TYPE } from '@/utils/dict'
+import { fenToYuan, floatToFixed2 } from '@/utils'
+import { fenToYuanFormat } from '@/utils/formatter'
+import SummaryCard from '@/components/SummaryCard/index.vue'
+import { dateFormatter } from '@/utils/formatTime'
+import { DeliveryTypeEnum } from '@/utils/constants'
+import { TradeOrderSummaryRespVO } from '@/api/mall/trade/order'
+import { DeliveryPickUpStoreVO } from '@/api/mall/trade/delivery/pickUpStore'
+import OrderPickUpForm from '@/views/mall/trade/order/form/OrderPickUpForm.vue'
+
+defineOptions({ name: 'PickUpOrder' })
+
+// 列表的加载中
+const loading = ref(true)
+// 列表的总页数
+const total = ref(2)
+// 列表的数据
+const list = ref<TradeOrderApi.OrderVO[]>([])
+// 搜索的表单
+const queryFormRef = ref<FormInstance>()
+// 初始表单参数
+const INIT_QUERY_PARAMS = {
+  // 页数
+  pageNo: 1,
+  // 每页显示数量
+  pageSize: 10,
+  // 创建时间
+  createTime: undefined,
+  // 配送方式
+  deliveryType: DeliveryTypeEnum.PICK_UP.type,
+  // 自提门店
+  pickUpStoreId: undefined
+}
+// 表单搜索
+const queryParams = ref({ ...INIT_QUERY_PARAMS })
+// 订单搜索类型 queryParam
+const queryType = reactive({ queryParam: 'no' })
+// 订单统计数据
+const summary = ref<TradeOrderSummaryRespVO>()
+
+// 订单聚合搜索 select 类型配置(动态搜索)
+const dynamicSearchList = ref([
+  { value: 'no', label: '订单号' },
+  { value: 'userId', label: '用户UID' },
+  { value: 'userNickname', label: '用户昵称' },
+  { value: 'userMobile', label: '用户电话' }
+])
+/**
+ * 聚合搜索切换查询对象时触发
+ * @param val
+ */
+const inputChangeSelect = (val: string) => {
+  dynamicSearchList.value
+    .filter((item) => item.value !== val)
+    ?.forEach((item) => {
+      // 清除集合搜索无用属性
+      if (queryParams.value.hasOwnProperty(item.value)) {
+        delete queryParams.value[item.value]
+      }
+    })
+}
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    // 统计
+    summary.value = await TradeOrderApi.getOrderSummary(unref(queryParams))
+    // 分页
+    const data = await TradeOrderApi.getOrderPage(unref(queryParams))
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = async () => {
+  queryParams.value.pageNo = 1
+  await getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value?.resetFields()
+  queryParams.value = { ...INIT_QUERY_PARAMS }
+  handleQuery()
+}
+
+/** 自提门店精简列表 */
+const pickUpStoreList = ref<DeliveryPickUpStoreVO[]>([])
+const getPickUpStoreList = async () => {
+  pickUpStoreList.value = await PickUpStoreApi.getListAllSimple()
+}
+
+/** 显示核销表单 */
+const pickUpForm = ref()
+const handlePickup = () => {
+  pickUpForm.value.open()
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+  getPickUpStoreList()
+})
+</script>
+<style lang="scss" scoped>
+:deep(.order-table-col > .cell) {
+  padding: 0;
+}
+.summary {
+  .el-col {
+    margin-bottom: 1rem;
+  }
+}
+</style>

+ 7 - 2
src/views/mall/trade/order/detail/index.vue

@@ -54,7 +54,7 @@
           </el-button>
           <!-- 到店自提 -->
           <el-button
-            v-if="formData.deliveryType === DeliveryTypeEnum.PICK_UP.type"
+            v-if="formData.deliveryType === DeliveryTypeEnum.PICK_UP.type && showPickUp"
             type="primary"
             @click="handlePickUp"
           >
@@ -235,6 +235,7 @@ import * as DeliveryExpressApi from '@/api/mall/trade/delivery/express'
 import { useTagsViewStore } from '@/store/modules/tagsView'
 import { DeliveryTypeEnum, TradeOrderStatusEnum } from '@/utils/constants'
 import * as DeliveryPickUpStoreApi from '@/api/mall/trade/delivery/pickUpStore'
+import { propTypes } from '@/utils/propTypes'
 
 defineOptions({ name: 'TradeOrderDetail' })
 
@@ -294,8 +295,12 @@ const handlePickUp = async () => {
 
 /** 获得详情 */
 const { params } = useRoute() // 查询参数
+const props = defineProps({
+  id: propTypes.number.def(undefined), // 订单ID
+  showPickUp: propTypes.bool.def(true) // 显示核销按钮
+})
+const id = (params.id || props.id) as unknown as number
 const getDetail = async () => {
-  const id = params.id as unknown as number
   if (id) {
     const res = (await TradeOrderApi.getOrder(id)) as TradeOrderApi.OrderVO
     // 没有表单信息则关闭页面返回

+ 108 - 0
src/views/mall/trade/order/form/OrderPickUpForm.vue

@@ -0,0 +1,108 @@
+<template>
+  <!-- 核销对话框 -->
+  <Dialog v-model="dialogVisible" title="订单核销" width="35%">
+    <el-form
+      ref="formRef"
+      v-loading="formLoading"
+      :model="formData"
+      :rules="formRules"
+      label-width="100px"
+    >
+      <el-form-item prop="pickUpVerifyCode" label="核销码">
+        <el-input v-model="formData.pickUpVerifyCode" placeholder="请输入核销码" />
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button type="primary" :disabled="formLoading" @click="getOrderByPickUpVerifyCode">
+        查询
+      </el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+  <!-- 核销确认对话框 -->
+  <Dialog v-model="detailDialogVisible" title="订单详情" width="55%">
+    <TradeOrderDetail v-if="orderDetails.id" :id="orderDetails.id" :show-pick-up="false" />
+    <template #footer>
+      <el-button type="primary" :disabled="formLoading" @click="submitForm"> 确认核销 </el-button>
+      <el-button @click="detailDialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script lang="ts" setup>
+import * as TradeOrderApi from '@/api/mall/trade/order'
+import { OrderVO } from '@/api/mall/trade/order'
+import { DeliveryTypeEnum, TradeOrderStatusEnum } from '@/utils/constants'
+import TradeOrderDetail from '@/views/mall/trade/order/detail/index.vue'
+
+/** 订单核销表单 */
+defineOptions({ name: 'OrderPickUpForm' })
+
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const detailDialogVisible = ref(false) // 详情弹窗的是否展示
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formRules = reactive({
+  pickUpVerifyCode: [{ required: true, message: '核销码不能为空', trigger: 'blur' }]
+})
+const formData = ref({
+  pickUpVerifyCode: '' // 核销码
+})
+const formRef = ref() // 表单 Ref
+const orderDetails = ref<OrderVO>({})
+
+/** 打开弹窗 */
+const open = async () => {
+  resetForm()
+  dialogVisible.value = true
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 提交请求
+  formLoading.value = true
+  try {
+    await TradeOrderApi.pickUpOrderByVerifyCode(formData.value.pickUpVerifyCode)
+    message.success('核销成功')
+    detailDialogVisible.value = false
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success', true)
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    pickUpVerifyCode: '' // 核销码
+  }
+  formRef.value?.resetFields()
+}
+
+/** 查询核销码对应的订单 */
+const getOrderByPickUpVerifyCode = async () => {
+  // 校验表单
+  if (!formRef) return
+  const valid = await formRef.value.validate()
+  if (!valid) return
+
+  formLoading.value = true
+  const data = await TradeOrderApi.getOrderByPickUpVerifyCode(formData.value.pickUpVerifyCode)
+  formLoading.value = false
+  if (data?.deliveryType !== DeliveryTypeEnum.PICK_UP.type) {
+    message.error('请输入正确的核销码')
+    return
+  }
+  if (data?.status !== TradeOrderStatusEnum.UNDELIVERED.status) {
+    message.error('订单不是待核销状态')
+    return
+  }
+  orderDetails.value = data
+  // 显示详情对话框
+  detailDialogVisible.value = true
+}
+</script>

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

@@ -128,6 +128,7 @@
           class="!w-280px"
           clearable
           placeholder="请输入"
+          :type="queryType.queryParam === 'userId' ? 'number' : 'text'"
         >
           <template #prepend>
             <el-select

+ 2 - 2
src/views/member/user/detail/UserAccountInfo.vue

@@ -47,7 +47,7 @@
 <script setup lang="ts">
 import { DescriptionsItemLabel } from '@/components/Descriptions'
 import * as UserApi from '@/api/member/user'
-import * as WalletApi from '@/api/pay/wallet'
+import * as WalletApi from '@/api/pay/wallet/balance'
 import { UserTypeEnum } from '@/utils/constants'
 import { fenToYuan } from '@/utils'
 
@@ -65,7 +65,7 @@ const getUserWallet = async () => {
     wallet.value = WALLET_INIT_DATA
     return
   }
-  const params = { userId: props.user.id, userType: UserTypeEnum.MEMBER }
+  const params = { userId: props.user.id }
   wallet.value = (await WalletApi.getWallet(params)) || WALLET_INIT_DATA
 }
 

+ 10 - 0
src/views/pay/app/components/channel/AlipayChannelForm.vue

@@ -69,6 +69,16 @@
           </el-form-item>
         </div>
         <div v-if="formData.config.mode === 2">
+          <el-form-item label-width="180px" label="应用私钥" prop="config.privateKey">
+            <el-input
+              type="textarea"
+              :autosize="{ minRows: 8, maxRows: 8 }"
+              v-model="formData.config.privateKey"
+              placeholder="请输入应用私钥"
+              clearable
+              :style="{ width: '100%' }"
+            />
+          </el-form-item>
           <el-form-item label-width="180px" label="商户公钥应用证书" prop="config.appCertContent">
             <el-input
               v-model="formData.config.appCertContent"

+ 22 - 0
src/views/pay/wallet/balance/WalletForm.vue

@@ -0,0 +1,22 @@
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible" width="800">
+    <WalletTransactionList :wallet-id="walletId" />
+    <template #footer>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script setup lang="ts">
+import WalletTransactionList from '../transaction/WalletTransactionList.vue'
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const walletId = ref(0)
+/** 打开弹窗 */
+const open = async (theWalletId: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = '钱包余额明细'
+  walletId.value = theWalletId
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+</script>

+ 149 - 0
src/views/pay/wallet/balance/index.vue

@@ -0,0 +1,149 @@
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="用户昵称" prop="nickname">
+        <el-input
+          v-model="queryParams.nickname"
+          placeholder="请输入用户昵称"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="创建时间" prop="createTime">
+        <el-date-picker
+          v-model="queryParams.createTime"
+          value-format="YYYY-MM-DD HH:mm:ss"
+          type="daterange"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+          class="!w-240px"
+        />
+      </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-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+      <el-table-column label="编号" align="center" prop="id" />
+      <el-table-column label="用户昵称" align="center" prop="nickname" />
+      <el-table-column label="头像" align="center" prop="avatar" width="80px">
+        <template #default="scope">
+          <img :src="scope.row.avatar" style="width: 40px" />
+        </template>
+      </el-table-column>
+      <el-table-column label="用户类型" align="center" prop="userType">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.USER_TYPE" :value="scope.row.userType" />
+        </template>
+      </el-table-column>
+      <el-table-column label="余额" align="center" prop="balance">
+        <template #default="{ row }"> {{ fenToYuan(row.balance) }} 元</template>
+      </el-table-column>
+      <el-table-column label="累计支出" align="center" prop="totalExpense">
+        <template #default="{ row }"> {{ fenToYuan(row.totalExpense) }} 元</template>
+      </el-table-column>
+      <el-table-column label="累计充值" align="center" prop="totalRecharge">
+        <template #default="{ row }"> {{ fenToYuan(row.totalRecharge) }} 元</template>
+      </el-table-column>
+      <el-table-column label="冻结金额" align="center" prop="freezePrice">
+        <template #default="{ row }"> {{ fenToYuan(row.freezePrice) }} 元</template>
+      </el-table-column>
+      <el-table-column
+        label="创建时间"
+        align="center"
+        prop="createTime"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+      <el-table-column label="操作" align="center">
+        <template #default="scope">
+          <el-button link type="primary" @click="openForm(scope.row.id)">详情</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- 弹窗 -->
+  <WalletForm ref="formRef" />
+</template>
+
+<script setup lang="ts">
+import { dateFormatter } from '@/utils/formatTime'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { fenToYuan } from '@/utils'
+import * as WalletApi from '@/api/pay/wallet/balance'
+import WalletForm from './WalletForm.vue'
+
+defineOptions({ name: 'WalletBalance' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数据
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  nickname: null,
+  createTime: []
+})
+const queryFormRef = ref() // 搜索的表单
+const exportLoading = ref(false) // 导出的加载中
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await WalletApi.getWalletPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (id?: number) => {
+  formRef.value.open(id)
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</script>

+ 122 - 0
src/views/pay/wallet/rechargePackage/WalletRechargePackageForm.vue

@@ -0,0 +1,122 @@
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="150px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="套餐名" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入套餐名" />
+      </el-form-item>
+      <el-form-item label="支付金额(元)" prop="payPrice">
+        <el-input-number v-model="formData.payPrice" :min="0" :precision="2" :step="0.01" />
+      </el-form-item>
+      <el-form-item label="赠送金额(元)" prop="bonusPrice">
+        <el-input-number v-model="formData.bonusPrice" :min="0" :precision="2" :step="0.01" />
+      </el-form-item>
+      <el-form-item label="开启状态" prop="status">
+        <el-radio-group v-model="formData.status">
+          <el-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+            :key="dict.value"
+            :label="dict.value"
+          >
+            {{ dict.label }}
+          </el-radio>
+        </el-radio-group>
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script setup lang="ts">
+import * as WalletRechargePackageApi from '@/api/pay/wallet/rechargePackage'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { fenToYuan, yuanToFen } from '@/utils'
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formType = ref('') // 表单的类型:create - 新增;update - 修改
+const formData = ref({
+  id: undefined,
+  name: undefined,
+  payPrice: undefined,
+  bonusPrice: undefined,
+  status: undefined
+})
+const formRules = reactive({
+  name: [{ required: true, message: '套餐名不能为空', trigger: 'blur' }],
+  payPrice: [{ required: true, message: '支付金额不能为空', trigger: 'blur' }],
+  bonusPrice: [{ required: true, message: '赠送金额不能为空', trigger: 'blur' }],
+  status: [{ required: true, message: '状态不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+
+/** 打开弹窗 */
+const open = async (type: string, id?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      formData.value = await WalletRechargePackageApi.getWalletRechargePackage(id)
+      formData.value.payPrice = fenToYuan(formData.value.payPrice)
+      formData.value.bonusPrice = fenToYuan(formData.value.bonusPrice)
+    } 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 WalletRechargePackageApi.WalletRechargePackageVO
+    data.payPrice = yuanToFen(data.payPrice)
+    data.bonusPrice = yuanToFen(data.bonusPrice)
+    if (formType.value === 'create') {
+      await WalletRechargePackageApi.createWalletRechargePackage(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await WalletRechargePackageApi.updateWalletRechargePackage(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    name: undefined,
+    payPrice: undefined,
+    bonusPrice: undefined,
+    status: undefined
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 185 - 0
src/views/pay/wallet/rechargePackage/index.vue

@@ -0,0 +1,185 @@
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="套餐名" prop="name">
+        <el-input
+          v-model="queryParams.name"
+          placeholder="请输入套餐名"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="状态" prop="status">
+        <el-select v-model="queryParams.status" placeholder="请选择状态" clearable class="!w-240px">
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="创建时间" prop="createTime">
+        <el-date-picker
+          v-model="queryParams.createTime"
+          value-format="YYYY-MM-DD HH:mm:ss"
+          type="daterange"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+        <el-button
+          type="primary"
+          plain
+          @click="openForm('create')"
+          v-hasPermi="['pay:wallet-recharge-package:create']"
+        >
+          <Icon icon="ep:plus" class="mr-5px" /> 新增
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+      <el-table-column label="编号" align="center" prop="id" />
+      <el-table-column label="套餐名" align="center" prop="name" />
+      <el-table-column label="支付金额" align="center" prop="payPrice">
+        <template #default="{ row }"> {{ fenToYuan(row.payPrice) }} 元</template>
+      </el-table-column>
+      <el-table-column label="赠送金额" align="center" prop="bonusPrice">
+        <template #default="{ row }"> {{ fenToYuan(row.bonusPrice) }} 元</template>
+      </el-table-column>
+      <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"
+        width="180px"
+      />
+      <el-table-column label="操作" align="center">
+        <template #default="scope">
+          <el-button
+            link
+            type="primary"
+            @click="openForm('update', scope.row.id)"
+            v-hasPermi="['pay:wallet-recharge-package:update']"
+          >
+            编辑
+          </el-button>
+          <el-button
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+            v-hasPermi="['pay:wallet-recharge-package: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>
+
+  <!-- 表单弹窗:添加/修改 -->
+  <WalletRechargePackageForm ref="formRef" @success="getList" />
+</template>
+
+<script setup lang="ts">
+import { dateFormatter } from '@/utils/formatTime'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import * as WalletRechargePackageApi from '@/api/pay/wallet/rechargePackage'
+import WalletRechargePackageForm from './WalletRechargePackageForm.vue'
+import { fenToYuan } from '@/utils'
+
+defineOptions({ name: 'WalletRechargePackage' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数据
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: null,
+  payPrice: null,
+  bonusPrice: null,
+  status: null,
+  createTime: []
+})
+const queryFormRef = ref() // 搜索的表单
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await WalletRechargePackageApi.getWalletRechargePackagePage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await WalletRechargePackageApi.deleteWalletRechargePackage(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</script>

+ 68 - 0
src/views/pay/wallet/transaction/WalletTransactionList.vue

@@ -0,0 +1,68 @@
+<template>
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+      <el-table-column label="编号" align="center" prop="id" />
+      <el-table-column label="钱包编号" align="center" prop="walletId" />
+      <el-table-column label="关联业务标题" align="center" prop="title" />
+      <el-table-column label="交易金额" align="center" prop="price">
+        <template #default="{ row }"> {{ fenToYuan(row.price) }} 元</template>
+      </el-table-column>
+      <el-table-column label="钱包余额" align="center" prop="balance">
+        <template #default="{ row }"> {{ fenToYuan(row.balance) }} 元</template>
+      </el-table-column>
+      <el-table-column
+        label="交易时间"
+        align="center"
+        prop="createTime"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+</template>
+
+<script lang="ts" setup>
+import { dateFormatter } from '@/utils/formatTime'
+import * as WalletTransactionApi from '@/api/pay/wallet/transaction'
+import { fenToYuan } from '@/utils'
+defineOptions({ name: 'WalletTransactionList' })
+const { walletId }: { walletId: number } = defineProps({
+  walletId: {
+    type: Number,
+    required: false
+  }
+})
+
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  walletId: null
+})
+const list = ref([]) // 列表的数据
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    queryParams.walletId = walletId
+    const data = await WalletTransactionApi.getWalletTransactionPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</script>
+<style scoped lang="scss"></style>

+ 19 - 2
src/views/system/notify/template/NotifyTemplateSendForm.vue

@@ -15,7 +15,21 @@
           type="textarea"
         />
       </el-form-item>
-      <el-form-item label="接收人" prop="userId">
+      <el-form-item label="用户类型" prop="userType">
+        <el-radio-group v-model="formData.userType">
+          <el-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.USER_TYPE)"
+            :key="dict.value"
+            :label="dict.value as number"
+          >
+            {{ dict.label }}
+          </el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item v-show="formData.userType === 1" label="接收人ID" prop="userId">
+        <el-input v-model="formData.userId" style="width: 160px" />
+      </el-form-item>
+      <el-form-item v-show="formData.userType === 2" label="接收人" prop="userId">
         <el-select v-model="formData.userId" placeholder="请选择接收人">
           <el-option
             v-for="item in userOption"
@@ -46,6 +60,7 @@
 <script lang="ts" setup>
 import * as UserApi from '@/api/system/user'
 import * as NotifyTemplateApi from '@/api/system/notify/template'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
 
 defineOptions({ name: 'SystemNotifyTemplateSendForm' })
 
@@ -57,6 +72,7 @@ const formData = ref({
   content: '',
   params: {},
   userId: null,
+  userType: 1,
   templateCode: '',
   templateParams: new Map()
 })
@@ -122,7 +138,8 @@ const resetForm = () => {
     params: {},
     mobile: '',
     templateCode: '',
-    templateParams: new Map()
+    templateParams: new Map(),
+    userType: 1
   } as any
   formRef.value?.resetFields()
 }