Prechádzať zdrojové kódy

Merge remote-tracking branch 'yudao/dev' into dev-to-dev

# Conflicts:
#	package.json
#	src/views/member/user/detail/UserOrderList.vue
puhui999 1 rok pred
rodič
commit
d62bbcdd92
59 zmenil súbory, kde vykonal 2764 pridanie a 609 odobranie
  1. 6 6
      README.md
  2. 35 31
      package.json
  3. 21 0
      src/api/bpm/task/index.ts
  4. 12 0
      src/api/login/index.ts
  5. 3 0
      src/api/login/types.ts
  6. 0 5
      src/api/mall/promotion/article/index.ts
  7. 0 5
      src/api/mall/promotion/articleCategory/index.ts
  8. 5 0
      src/api/mall/statistics/common.ts
  9. 42 10
      src/api/mall/statistics/member.ts
  10. 12 0
      src/api/mall/statistics/pay.ts
  11. 64 15
      src/api/mall/statistics/trade.ts
  12. 51 9
      src/api/mall/trade/order/index.ts
  13. 5 0
      src/api/pay/wallet/balance/index.ts
  14. 14 0
      src/api/pay/wallet/transaction/index.ts
  15. 89 0
      src/components/ShortcutDateRangePicker/index.vue
  16. 2 2
      src/components/SummaryCard/index.vue
  17. 13 1
      src/components/bpmnProcessDesigner/package/designer/ProcessViewer.vue
  18. 1 0
      src/layout/components/Breadcrumb/src/Breadcrumb.vue
  19. 1 0
      src/locales/zh-CN.ts
  20. 13 1
      src/router/modules/remaining.ts
  21. 16 0
      src/utils/index.ts
  22. 12 0
      src/utils/is.ts
  23. 10 14
      src/utils/propTypes.ts
  24. 4 1
      src/utils/routerHelper.ts
  25. 343 0
      src/views/Login/SocialLogin.vue
  26. 6 1
      src/views/Login/components/LoginForm.vue
  27. 99 0
      src/views/bpm/processInstance/detail/ProcessInstanceChildrenTaskList.vue
  28. 29 1
      src/views/bpm/processInstance/detail/ProcessInstanceTaskList.vue
  29. 97 0
      src/views/bpm/processInstance/detail/TaskAddSignDialogForm.vue
  30. 85 0
      src/views/bpm/processInstance/detail/TaskSubSignDialogForm.vue
  31. 39 15
      src/views/bpm/processInstance/detail/index.vue
  32. 42 0
      src/views/mall/home/components/ComparisonCard.vue
  33. 91 0
      src/views/mall/home/components/MemberStatisticsCard.vue
  34. 92 0
      src/views/mall/home/components/OperationDataCard.vue
  35. 79 0
      src/views/mall/home/components/ShortcutCard.vue
  36. 208 0
      src/views/mall/home/components/TradeTrendCard.vue
  37. 111 0
      src/views/mall/home/index.vue
  38. 7 5
      src/views/mall/promotion/article/ArticleForm.vue
  39. 3 2
      src/views/mall/promotion/article/category/ArticleCategoryForm.vue
  40. 4 30
      src/views/mall/promotion/article/category/index.vue
  41. 15 107
      src/views/mall/promotion/article/index.vue
  42. 3 3
      src/views/mall/promotion/combination/record/index.vue
  43. 119 0
      src/views/mall/statistics/member/components/MemberFunnelCard.vue
  44. 69 0
      src/views/mall/statistics/member/components/MemberTerminalCard.vue
  45. 40 209
      src/views/mall/statistics/member/index.vue
  46. 37 113
      src/views/mall/statistics/trade/index.vue
  47. 9 9
      src/views/mall/trade/afterSale/detail/index.vue
  48. 1 1
      src/views/mall/trade/brokerage/withdraw/index.vue
  49. 1 1
      src/views/mall/trade/config/index.vue
  50. 324 0
      src/views/mall/trade/delivery/pickUpOrder/index.vue
  51. 15 10
      src/views/mall/trade/order/detail/index.vue
  52. 108 0
      src/views/mall/trade/order/form/OrderPickUpForm.vue
  53. 1 0
      src/views/mall/trade/order/index.vue
  54. 1 1
      src/views/member/user/detail/UserAccountInfo.vue
  55. 22 0
      src/views/pay/wallet/balance/WalletForm.vue
  56. 164 0
      src/views/pay/wallet/balance/index.vue
  57. 0 1
      src/views/pay/wallet/rechargePackage/index.vue
  58. 68 0
      src/views/pay/wallet/transaction/WalletTransactionList.vue
  59. 1 0
      src/views/system/menu/index.vue

+ 6 - 6
README.md

@@ -39,14 +39,14 @@
 | 框架                                                                   | 说明               | 版本     |
 |----------------------------------------------------------------------|------------------|--------|
 | [Vue](https://staging-cn.vuejs.org/)                                 | Vue 框架           | 3.3.4 |
-| [Vite](https://cn.vitejs.dev//)                                      | 开发与构建工具          | 4.4.9  |
-| [Element Plus](https://element-plus.org/zh-CN/)                      | Element Plus     | 2.3.14 |
+| [Vite](https://cn.vitejs.dev//)                                      | 开发与构建工具          | 4.4.11  |
+| [Element Plus](https://element-plus.org/zh-CN/)                      | Element Plus     | 2.4.0 |
 | [TypeScript](https://www.typescriptlang.org/docs/)                   | JavaScript 的超集   | 5.2.2  |
-| [pinia](https://pinia.vuejs.org/)                                    | Vue 存储库 替代 vuex5 | 2.1.6 |
-| [vueuse](https://vueuse.org/)                                        | 常用工具集            | 10.4.1 |
-| [vue-i18n](https://kazupon.github.io/vue-i18n/zh/introduction.html/) | 国际化              | 9.4.1  |
+| [pinia](https://pinia.vuejs.org/)                                    | Vue 存储库 替代 vuex5 | 2.1.7 |
+| [vueuse](https://vueuse.org/)                                        | 常用工具集            | 10.5.0 |
+| [vue-i18n](https://kazupon.github.io/vue-i18n/zh/introduction.html/) | 国际化              | 9.5.0  |
 | [vue-router](https://router.vuejs.org/)                              | Vue 路由           | 4.2.5  |
-| [unocss](https://uno.antfu.me/)                                      | 原子 css          | 0.56.1  |
+| [unocss](https://uno.antfu.me/)                                      | 原子 css          | 0.56.5  |
 | [iconify](https://icon-sets.iconify.design/)                         | 在线图标库            | 3.1.1  |
 | [wangeditor](https://www.wangeditor.com/)                            | 富文本编辑器           | 5.1.23 |
 

+ 35 - 31
package.json

@@ -31,31 +31,31 @@
     "@form-create/element-ui": "^3.1.24",
     "@iconify/iconify": "^3.1.1",
     "@videojs-player/vue": "^1.0.0",
-    "@vueuse/core": "^10.4.1",
+    "@vueuse/core": "^10.5.0",
     "@wangeditor/editor": "^5.1.23",
     "@wangeditor/editor-for-vue": "^5.1.10",
     "@zxcvbn-ts/core": "^3.0.4",
     "animate.css": "^4.1.1",
-    "axios": "^1.5.0",
+    "axios": "^1.5.1",
     "benz-amr-recorder": "^1.1.5",
     "bpmn-js-token-simulation": "^0.10.0",
     "camunda-bpmn-moddle": "^7.0.1",
     "cropperjs": "^1.6.1",
     "crypto-js": "^4.1.1",
     "dayjs": "^1.11.10",
-    "diagram-js": "^12.3.0",
+    "diagram-js": "^12.4.0",
     "echarts": "^5.4.3",
     "echarts-wordcloud": "^2.1.0",
-    "element-plus": "2.3.14",
-    "fast-xml-parser": "^4.3.0",
-    "highlight.js": "^11.8.0",
+    "element-plus": "2.4.0",
+    "fast-xml-parser": "^4.3.2",
+    "highlight.js": "^11.9.0",
     "intro.js": "^7.2.0",
     "jsencrypt": "^3.3.2",
     "lodash-es": "^4.17.21",
     "min-dash": "^4.1.1",
     "mitt": "^3.0.1",
     "nprogress": "^0.2.0",
-    "pinia": "^2.1.6",
+    "pinia": "^2.1.7",
     "qrcode": "^1.5.3",
     "qs": "^6.11.2",
     "sortablejs": "^1.15.0",
@@ -64,7 +64,7 @@
     "video.js": "^7.21.5",
     "vue": "^3.3.4",
     "vue-dompurify-html": "^4.1.4",
-    "vue-i18n": "^9.4.1",
+    "vue-i18n": "^9.5.0",
     "vue-router": "^4.2.5",
     "vue-types": "^5.1.1",
     "vuedraggable": "^4.1.0",
@@ -72,15 +72,15 @@
     "xml-js": "^1.6.11"
   },
   "devDependencies": {
-    "@commitlint/cli": "^17.7.1",
-    "@commitlint/config-conventional": "^17.7.0",
-    "@iconify/json": "^2.2.119",
-    "@intlify/unplugin-vue-i18n": "^1.2.0",
+    "@commitlint/cli": "^17.8.0",
+    "@commitlint/config-conventional": "^17.8.0",
+    "@iconify/json": "^2.2.129",
+    "@intlify/unplugin-vue-i18n": "^1.4.0",
     "@purge-icons/generated": "^0.9.0",
-    "@types/intro.js": "^5.1.1",
+    "@types/intro.js": "^5.1.2",
     "@types/lodash-es": "^4.17.9",
-    "@types/node": "^20.6.0",
-    "@types/nprogress": "^0.2.0",
+    "@types/node": "^20.8.6",
+    "@types/nprogress": "^0.2.1",
     "@types/qrcode": "^1.5.2",
     "@types/qs": "^6.9.8",
     "@types/sortablejs": "^1.15.4",
@@ -88,39 +88,43 @@
     "@typescript-eslint/parser": "^6.7.2",
     "@unocss/eslint-config": "^0.56.1",
     "@unocss/transformer-variant-group": "^0.56.1",
+    "@typescript-eslint/eslint-plugin": "^6.7.5",
+    "@typescript-eslint/parser": "^6.7.5",
+    "@unocss/transformer-variant-group": "^0.56.5",
+    "@unocss/eslint-config": "^0.56.5",
     "@vitejs/plugin-legacy": "^4.1.1",
-    "@vitejs/plugin-vue": "^4.3.4",
+    "@vitejs/plugin-vue": "^4.4.0",
     "@vitejs/plugin-vue-jsx": "^3.0.2",
-    "@vue-macros/volar": "^0.14.3",
+    "@vue-macros/volar": "^0.17.0",
     "autoprefixer": "^10.4.16",
     "bpmn-js": "8.9.0",
     "bpmn-js-properties-panel": "0.46.0",
     "consola": "^3.2.3",
-    "eslint": "^8.49.0",
+    "eslint": "^8.51.0",
     "eslint-config-prettier": "^9.0.0",
-    "eslint-define-config": "^1.23.0",
-    "eslint-plugin-prettier": "^5.0.0",
+    "eslint-define-config": "^1.24.1",
+    "eslint-plugin-prettier": "^5.0.1",
     "eslint-plugin-vue": "^9.17.0",
-    "lint-staged": "^14.0.1",
-    "postcss": "^8.4.30",
+    "lint-staged": "^15.0.1",
+    "postcss": "^8.4.31",
     "postcss-html": "^1.5.0",
-    "postcss-scss": "^4.0.8",
+    "postcss-scss": "^4.0.9",
     "prettier": "^3.0.3",
-    "rimraf": "^5.0.1",
-    "rollup": "^3.29.2",
-    "sass": "^1.68.0",
+    "rimraf": "^5.0.5",
+    "rollup": "^4.1.4",
+    "sass": "^1.69.3",
     "stylelint": "^15.10.3",
     "stylelint-config-html": "^1.1.0",
     "stylelint-config-recommended": "^13.0.0",
     "stylelint-config-standard": "^34.0.0",
     "stylelint-order": "^6.0.3",
-    "terser": "^5.20.0",
+    "terser": "^5.21.0",
     "typescript": "5.2.2",
-    "unocss": "^0.56.1",
+    "unocss": "^0.56.5",
     "unplugin-auto-import": "^0.16.6",
     "unplugin-element-plus": "^0.8.0",
     "unplugin-vue-components": "^0.25.2",
-    "vite": "4.4.9",
+    "vite": "4.4.11",
     "vite-plugin-compression": "^0.5.1",
     "vite-plugin-ejs": "^1.6.4",
     "vite-plugin-eslint": "^1.8.1",
@@ -128,8 +132,8 @@
     "vite-plugin-purge-icons": "^0.9.2",
     "vite-plugin-svg-icons": "^2.0.1",
     "vite-plugin-top-level-await": "^1.3.1",
-    "vue-eslint-parser": "^9.3.1",
-    "vue-tsc": "^1.8.13"
+    "vue-eslint-parser": "^9.3.2",
+    "vue-tsc": "^1.8.19"
   },
   "license": "MIT",
   "repository": {

+ 21 - 0
src/api/bpm/task/index.ts

@@ -58,3 +58,24 @@ export const returnTask = async (data) => {
 export const delegateTask = async (data) => {
   return await request.put({ url: '/bpm/task/delegate', data })
 }
+
+/**
+ * 加签
+ */
+export const taskAddSign = async (data) => {
+  return await request.put({ url: '/bpm/task/add-sign', data })
+}
+
+/**
+ * 获取减签任务列表
+ */
+export const getChildrenTaskList = async (id: string) => {
+  return await request.get({ url: '/bpm/task/get-children-task-list?taskId=' + id })
+}
+
+/**
+ * 减签
+ */
+export const taskSubSign = async (data) => {
+  return await request.put({ url: '/bpm/task/sub-sign', data })
+}

+ 12 - 0
src/api/login/index.ts

@@ -47,6 +47,18 @@ export const smsLogin = (data: SmsLoginVO) => {
   return request.post({ url: '/system/auth/sms-login', data })
 }
 
+// 社交快捷登录,使用 code 授权码
+export function socialLogin(type: string, code: string, state: string) {
+  return request.post({
+    url: '/system/auth/social-login',
+    data: {
+      type,
+      code,
+      state
+    }
+  })
+}
+
 // 社交授权的跳转
 export const socialAuthRedirect = (type: number, redirectUri: string) => {
   return request.get({

+ 3 - 0
src/api/login/types.ts

@@ -2,6 +2,9 @@ export type UserLoginVO = {
   username: string
   password: string
   captchaVerification: string
+  socialType?: string
+  socialCode?: string
+  socialState?: string
 }
 
 export type TokenType = {

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

@@ -40,8 +40,3 @@ export const updateArticle = async (data: ArticleVO) => {
 export const deleteArticle = async (id: number) => {
   return await request.delete({ url: `/promotion/article/delete?id=` + id })
 }
-
-// 导出文章管理 Excel
-export const exportArticle = async (params) => {
-  return await request.download({ url: `/promotion/article/export-excel`, params })
-}

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

@@ -37,8 +37,3 @@ export const updateArticleCategory = async (data: ArticleCategoryVO) => {
 export const deleteArticleCategory = async (id: number) => {
   return await request.delete({ url: `/promotion/article-category/delete?id=` + id })
 }
-
-// 导出文章分类 Excel
-export const exportArticleCategory = async (params) => {
-  return await request.download({ url: `/promotion/article-category/export-excel`, params })
-}

+ 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 - 0
src/api/pay/wallet/index.ts → src/api/pay/wallet/balance/index.ts

@@ -20,3 +20,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 })
+}

+ 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 - 1
src/components/bpmnProcessDesigner/package/designer/ProcessViewer.vue

@@ -250,6 +250,12 @@ const getResultCss = (result) => {
   } else if (result === 5) {
     // 退回
     return 'highlight-return'
+  } else if (result === 6) {
+    // 委派
+    return 'highlight-return'
+  } else if (result === 7 || result === 8 || result === 9) {
+    // 待后加签任务完成/待前加签任务完成/待前置任务完成
+    return 'highlight-return'
   }
   return ''
 }
@@ -362,7 +368,7 @@ const elementHover = (element) => {
       }
     }
     console.log(html, 'html111111111111111')
-    elementOverlayIds.value[element.value.id] = toRaw(overlays.value).add(element.value, {
+    elementOverlayIds.value[element.value.id] = toRaw(overlays.value)?.add(element.value, {
       position: { left: 0, bottom: 0 },
       html: `<div class="element-overlays">${html}</div>`
     })
@@ -591,14 +597,17 @@ watch(
   stroke: #e6a23c !important;
   fill-opacity: 0.2 !important;
 }
+
 .highlight-return.djs-shape .djs-visual > :nth-child(2) {
   fill: #e6a23c !important;
 }
+
 .highlight-return.djs-shape .djs-visual > path {
   fill: #e6a23c !important;
   fill-opacity: 0.2 !important;
   stroke: #e6a23c !important;
 }
+
 .highlight-return.djs-connection > .djs-visual > path {
   stroke: #e6a23c !important;
 }
@@ -612,14 +621,17 @@ watch(
   stroke: #e6a23c !important;
   fill-opacity: 0.2 !important;
 }
+
 :deep(.highlight-return.djs-shape .djs-visual > :nth-child(2)) {
   fill: #e6a23c !important;
 }
+
 :deep(.highlight-return.djs-shape .djs-visual > path) {
   fill: #e6a23c !important;
   fill-opacity: 0.2 !important;
   stroke: #e6a23c !important;
 }
+
 :deep(.highlight-return.djs-connection > .djs-visual > path) {
   stroke: #e6a23c !important;
 }

+ 1 - 0
src/layout/components/Breadcrumb/src/Breadcrumb.vue

@@ -114,6 +114,7 @@ $prefix-cls: #{$elNamespace}-breadcrumb;
       }
     }
   }
+
   :deep(&__item):last-child {
     .#{$prefix-cls}__inner {
       display: flex;

+ 1 - 0
src/locales/zh-CN.ts

@@ -141,6 +141,7 @@ export default {
   },
   router: {
     login: '登录',
+    socialLogin: '社交登录',
     home: '首页',
     analysis: '分析页',
     workplace: '工作台'

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

@@ -195,6 +195,16 @@ const remainingRouter: AppRouteRecordRaw[] = [
       noTagsView: true
     }
   },
+  {
+    path: '/social-login',
+    component: () => import('@/views/Login/SocialLogin.vue'),
+    name: 'SocialLogin',
+    meta: {
+      hidden: true,
+      title: t('router.socialLogin'),
+      noTagsView: true
+    }
+  },
   {
     path: '/403',
     component: () => import('@/views/Error/403.vue'),
@@ -333,6 +343,7 @@ const remainingRouter: AppRouteRecordRaw[] = [
   {
     path: '/mall/product', // 商品中心
     component: Layout,
+    name: 'ProductCenter',
     meta: {
       hidden: true
     },
@@ -394,6 +405,7 @@ const remainingRouter: AppRouteRecordRaw[] = [
   {
     path: '/mall/trade', // 交易中心
     component: Layout,
+    name: 'TradeCenter',
     meta: {
       hidden: true
     },
@@ -415,7 +427,7 @@ const remainingRouter: AppRouteRecordRaw[] = [
   {
     path: '/member',
     component: Layout,
-    name: 'member',
+    name: 'MemberCenter',
     meta: { hidden: true },
     children: [
       {

+ 16 - 0
src/utils/index.ts

@@ -205,6 +205,9 @@ export const floatToFixed2 = (num: number | string | undefined): string => {
     case 1:
       str = f.toString() + '0'
       break
+    case 2:
+      str = f.toString()
+      break
   }
   return str
 }
@@ -233,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)
+}

+ 12 - 0
src/utils/is.ts

@@ -19,6 +19,9 @@ export const isObject = (val: any): val is Record<any, any> => {
 }
 
 export const isEmpty = <T = unknown>(val: T): val is T => {
+  if (val === null) {
+    return true
+  }
   if (isArray(val) || isString(val)) {
     return val.length === 0
   }
@@ -103,3 +106,12 @@ export const isUrl = (path: string): boolean => {
 export const isDark = (): boolean => {
   return window.matchMedia('(prefers-color-scheme: dark)').matches
 }
+
+// 是否是图片链接
+export const isImgPath = (path: string): boolean => {
+  return /(https?:\/\/|data:image\/).*?\.(png|jpg|jpeg|gif|svg|webp|ico)/gi.test(path)
+}
+
+export const isEmptyVal = (val: any): boolean => {
+  return val === '' || val === null || val === undefined
+}

+ 10 - 14
src/utils/propTypes.ts

@@ -1,12 +1,10 @@
-import { createTypes, VueTypesInterface, VueTypeValidableDef } from 'vue-types'
+import { VueTypeValidableDef, VueTypesInterface, createTypes, toValidableType } from 'vue-types'
 import { CSSProperties } from 'vue'
 
-// 自定义扩展vue-types
 type PropTypes = VueTypesInterface & {
   readonly style: VueTypeValidableDef<CSSProperties>
 }
-
-const propTypes = createTypes({
+const newPropTypes = createTypes({
   func: undefined,
   bool: undefined,
   string: undefined,
@@ -15,14 +13,12 @@ const propTypes = createTypes({
   integer: undefined
 }) as PropTypes
 
-// 需要自定义扩展的类型
-// see: https://dwightjack.github.io/vue-types/advanced/extending-vue-types.html#the-extend-method
-// propTypes.extend([
-//   {
-//     name: 'style',
-//     getter: true,
-//     type: [String, Object],
-//     default: undefined
-//   }
-// ])
+class propTypes extends newPropTypes {
+  static get style() {
+    return toValidableType('style', {
+      type: [String, Object]
+    })
+  }
+}
+
 export { propTypes }

+ 4 - 1
src/utils/routerHelper.ts

@@ -93,7 +93,10 @@ export const generateRoute = (routes: AppCustomRouteRecordRaw[]): AppRouteRecord
       meta.alwaysShow = true
       const childrenData: AppRouteRecordRaw = {
         path: '',
-        name: toCamelCase(route.path, true),
+        name:
+          route.componentName && route.componentName.length > 0
+            ? route.componentName
+            : toCamelCase(route.path, true),
         redirect: route.redirect,
         meta: meta
       }

+ 343 - 0
src/views/Login/SocialLogin.vue

@@ -0,0 +1,343 @@
+<template>
+  <div
+    :class="prefixCls"
+    class="relative h-[100%] lt-xl:bg-[var(--login-bg-color)] lt-md:px-10px lt-sm:px-10px lt-xl:px-10px"
+  >
+    <div class="relative mx-auto h-full flex">
+      <div
+        :class="`${prefixCls}__left flex-1 bg-gray-500 bg-opacity-20 relative p-30px lt-xl:hidden`"
+      >
+        <!-- 左上角的 logo + 系统标题 -->
+        <div class="relative flex items-center text-white">
+          <img alt="" class="mr-10px h-48px w-48px" src="@/assets/imgs/logo.png" />
+          <span class="text-20px font-bold">{{ underlineToHump(appStore.getTitle) }}</span>
+        </div>
+        <!-- 左边的背景图 + 欢迎语 -->
+        <div class="h-[calc(100%-60px)] flex items-center justify-center">
+          <TransitionGroup
+            appear
+            enter-active-class="animate__animated animate__bounceInLeft"
+            tag="div"
+          >
+            <img key="1" alt="" class="w-350px" src="@/assets/svgs/login-box-bg.svg" />
+            <div key="2" class="text-3xl text-white">{{ t('login.welcome') }}</div>
+            <div key="3" class="mt-5 text-14px font-normal text-white">
+              {{ t('login.message') }}
+            </div>
+          </TransitionGroup>
+        </div>
+      </div>
+      <div class="relative flex-1 p-30px dark:bg-[var(--login-bg-color)] lt-sm:p-10px">
+        <!-- 右上角的主题、语言选择 -->
+        <div
+          class="flex items-center justify-between text-white at-2xl:justify-end at-xl:justify-end"
+        >
+          <div class="flex items-center at-2xl:hidden at-xl:hidden">
+            <img alt="" class="mr-10px h-48px w-48px" src="@/assets/imgs/logo.png" />
+            <span class="text-20px font-bold">{{ underlineToHump(appStore.getTitle) }}</span>
+          </div>
+          <div class="flex items-center justify-end space-x-10px">
+            <ThemeSwitch />
+            <LocaleDropdown class="dark:text-white lt-xl:text-white" />
+          </div>
+        </div>
+        <!-- 右边的登录界面 -->
+        <Transition appear enter-active-class="animate__animated animate__bounceInRight">
+          <div
+            class="m-auto h-full w-[100%] flex items-center at-2xl:max-w-500px at-lg:max-w-500px at-md:max-w-500px at-xl:max-w-500px"
+          >
+            <!-- 账号登录 -->
+            <el-form
+              v-show="getShow"
+              ref="formLogin"
+              :model="loginData.loginForm"
+              :rules="LoginRules"
+              class="login-form"
+              label-position="top"
+              label-width="120px"
+              size="large"
+            >
+              <el-row style="margin-right: -10px; margin-left: -10px">
+                <el-col :span="24" style="padding-right: 10px; padding-left: 10px">
+                  <el-form-item>
+                    <LoginFormTitle style="width: 100%" />
+                  </el-form-item>
+                </el-col>
+                <el-col :span="24" style="padding-right: 10px; padding-left: 10px">
+                  <el-form-item v-if="loginData.tenantEnable === 'true'" prop="tenantName">
+                    <el-input
+                      v-model="loginData.loginForm.tenantName"
+                      :placeholder="t('login.tenantNamePlaceholder')"
+                      :prefix-icon="iconHouse"
+                      link
+                      type="primary"
+                    />
+                  </el-form-item>
+                </el-col>
+                <el-col :span="24" style="padding-right: 10px; padding-left: 10px">
+                  <el-form-item prop="username">
+                    <el-input
+                      v-model="loginData.loginForm.username"
+                      :placeholder="t('login.usernamePlaceholder')"
+                      :prefix-icon="iconAvatar"
+                    />
+                  </el-form-item>
+                </el-col>
+                <el-col :span="24" style="padding-right: 10px; padding-left: 10px">
+                  <el-form-item prop="password">
+                    <el-input
+                      v-model="loginData.loginForm.password"
+                      :placeholder="t('login.passwordPlaceholder')"
+                      :prefix-icon="iconLock"
+                      show-password
+                      type="password"
+                      @keyup.enter="getCode()"
+                    />
+                  </el-form-item>
+                </el-col>
+                <el-col
+                  :span="24"
+                  style="
+                    padding-right: 10px;
+                    padding-left: 10px;
+                    margin-top: -20px;
+                    margin-bottom: -20px;
+                  "
+                >
+                  <el-form-item>
+                    <el-row justify="space-between" style="width: 100%">
+                      <el-col :span="6">
+                        <el-checkbox v-model="loginData.loginForm.rememberMe">
+                          {{ t('login.remember') }}
+                        </el-checkbox>
+                      </el-col>
+                      <el-col :offset="6" :span="12">
+                        <el-link style="float: right" type="primary">{{
+                          t('login.forgetPassword')
+                        }}</el-link>
+                      </el-col>
+                    </el-row>
+                  </el-form-item>
+                </el-col>
+                <el-col :span="24" style="padding-right: 10px; padding-left: 10px">
+                  <el-form-item>
+                    <XButton
+                      :loading="loginLoading"
+                      :title="t('login.login')"
+                      class="w-[100%]"
+                      type="primary"
+                      @click="getCode()"
+                    />
+                  </el-form-item>
+                </el-col>
+                <Verify
+                  ref="verify"
+                  :captchaType="captchaType"
+                  :imgSize="{ width: '400px', height: '200px' }"
+                  mode="pop"
+                  @success="handleLogin"
+                />
+              </el-row>
+            </el-form>
+          </div>
+        </Transition>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { underlineToHump } from '@/utils'
+
+import { ElLoading } from 'element-plus'
+
+import { useDesign } from '@/hooks/web/useDesign'
+import { useAppStore } from '@/store/modules/app'
+import { useIcon } from '@/hooks/web/useIcon'
+import { usePermissionStore } from '@/store/modules/permission'
+
+import * as LoginApi from '@/api/login'
+import * as authUtil from '@/utils/auth'
+import { ThemeSwitch } from '@/layout/components/ThemeSwitch'
+import { LocaleDropdown } from '@/layout/components/LocaleDropdown'
+import { LoginStateEnum, useFormValid, useLoginState } from './components/useLogin'
+import LoginFormTitle from './components/LoginFormTitle.vue'
+import router from '@/router'
+
+defineOptions({ name: 'SocialLogin' })
+
+const { t } = useI18n()
+const route = useRoute()
+
+const appStore = useAppStore()
+const { getPrefixCls } = useDesign()
+const prefixCls = getPrefixCls('login')
+const iconHouse = useIcon({ icon: 'ep:house' })
+const iconAvatar = useIcon({ icon: 'ep:avatar' })
+const iconLock = useIcon({ icon: 'ep:lock' })
+const formLogin = ref<any>()
+const { validForm } = useFormValid(formLogin)
+const { getLoginState } = useLoginState()
+const { push } = useRouter()
+const permissionStore = usePermissionStore()
+const loginLoading = ref(false)
+const verify = ref()
+const captchaType = ref('blockPuzzle') // blockPuzzle 滑块 clickWord 点击文字
+
+const getShow = computed(() => unref(getLoginState) === LoginStateEnum.LOGIN)
+
+const LoginRules = {
+  tenantName: [required],
+  username: [required],
+  password: [required]
+}
+const loginData = reactive({
+  isShowPassword: false,
+  captchaEnable: import.meta.env.VITE_APP_CAPTCHA_ENABLE,
+  tenantEnable: import.meta.env.VITE_APP_TENANT_ENABLE,
+  loginForm: {
+    tenantName: '芋道源码',
+    username: 'admin',
+    password: 'admin123',
+    captchaVerification: '',
+    rememberMe: false
+  }
+})
+
+// 获取验证码
+const getCode = async () => {
+  // 情况一,未开启:则直接登录
+  if (loginData.captchaEnable === 'false') {
+    await handleLogin({})
+  } else {
+    // 情况二,已开启:则展示验证码;只有完成验证码的情况,才进行登录
+    // 弹出验证码
+    verify.value.show()
+  }
+}
+//获取租户ID
+const getTenantId = async () => {
+  if (loginData.tenantEnable === 'true') {
+    const res = await LoginApi.getTenantIdByName(loginData.loginForm.tenantName)
+    authUtil.setTenantId(res)
+  }
+}
+// 记住我
+const getCookie = () => {
+  const loginForm = authUtil.getLoginForm()
+  if (loginForm) {
+    loginData.loginForm = {
+      ...loginData.loginForm,
+      username: loginForm.username ? loginForm.username : loginData.loginForm.username,
+      password: loginForm.password ? loginForm.password : loginData.loginForm.password,
+      rememberMe: loginForm.rememberMe ? true : false,
+      tenantName: loginForm.tenantName ? loginForm.tenantName : loginData.loginForm.tenantName
+    }
+  }
+}
+const loading = ref() // ElLoading.service 返回的实例
+
+// tricky: 配合LoginForm.vue中redirectUri需要对参数进行encode,需要在回调后进行decode
+function getUrlValue(key: string): string {
+  const url = new URL(decodeURIComponent(location.href))
+  return url.searchParams.get(key) ?? ''
+}
+
+// 尝试登录: 当账号已经绑定,socialLogin会直接获得token
+const tryLogin = async () => {
+  try {
+    const type = getUrlValue('type')
+    const redirect = getUrlValue('redirect')
+    const code = route?.query?.code as string
+    const state = route?.query?.state as string
+
+    const res = await LoginApi.socialLogin(type, code, state)
+    authUtil.setToken(res)
+
+    router.push({ path: redirect || '/' })
+  } catch (err) {}
+}
+
+// 登录
+const handleLogin = async (params) => {
+  loginLoading.value = true
+  try {
+    await getTenantId()
+    const data = await validForm()
+    if (!data) {
+      return
+    }
+
+    let redirect = getUrlValue('redirect')
+
+    const type = getUrlValue('type')
+    const code = route?.query?.code as string
+    const state = route?.query?.state as string
+
+    const res = await LoginApi.login({
+      // 账号密码登录
+      username: loginData.loginForm.username,
+      password: loginData.loginForm.password,
+      captchaVerification: params.captchaVerification,
+      // 社交登录
+      socialCode: code,
+      socialState: state,
+      socialType: type
+    })
+    if (!res) {
+      return
+    }
+    loading.value = ElLoading.service({
+      lock: true,
+      text: '正在加载系统中...',
+      background: 'rgba(0, 0, 0, 0.7)'
+    })
+    if (loginData.loginForm.rememberMe) {
+      authUtil.setLoginForm(loginData.loginForm)
+    } else {
+      authUtil.removeLoginForm()
+    }
+    authUtil.setToken(res)
+    if (!redirect) {
+      redirect = '/'
+    }
+    // 判断是否为SSO登录
+    if (redirect.indexOf('sso') !== -1) {
+      window.location.href = window.location.href.replace('/login?redirect=', '')
+    } else {
+      push({ path: redirect || permissionStore.addRouters[0].path })
+    }
+  } finally {
+    loginLoading.value = false
+    loading.value.close()
+  }
+}
+
+onMounted(() => {
+  getCookie()
+  tryLogin()
+})
+</script>
+
+<style lang="scss" scoped>
+$prefix-cls: #{$namespace}-login;
+
+.#{$prefix-cls} {
+  overflow: auto;
+
+  &__left {
+    &::before {
+      position: absolute;
+      top: 0;
+      left: 0;
+      z-index: -1;
+      width: 100%;
+      height: 100%;
+      background-image: url('@/assets/svgs/login-bg.svg');
+      background-position: center;
+      background-repeat: no-repeat;
+      content: '';
+    }
+  }
+}
+</style>

+ 6 - 1
src/views/Login/components/LoginForm.vue

@@ -284,8 +284,13 @@ const doSocialLogin = async (type: number) => {
       })
     }
     // 计算 redirectUri
+    // tricky: type、redirect需要先encode一次,否则钉钉回调会丢失。
+    // 配合 Login/SocialLogin.vue#getUrlValue() 使用
     const redirectUri =
-      location.origin + '/social-login?type=' + type + '&redirect=' + (redirect.value || '/')
+      location.origin +
+      '/social-login?' +
+      encodeURIComponent(`type=${type}&redirect=${redirect.value || '/'}`)
+
     // 进行跳转
     const res = await LoginApi.socialAuthRedirect(type, encodeURIComponent(redirectUri))
     window.location.href = res

+ 99 - 0
src/views/bpm/processInstance/detail/ProcessInstanceChildrenTaskList.vue

@@ -0,0 +1,99 @@
+<template>
+  <el-drawer v-model="drawerVisible" title="子任务" size="70%">
+    <template #header>
+      <h4>【{{ baseTask.name }} 】审批人:{{ baseTask.assigneeUser?.nickname }}</h4>
+      <el-button style="margin-left: 5px" v-if="showSubSignButton(baseTask)" type="danger" plain @click="handleSubSign(baseTask)">
+        <Icon icon="ep:remove" />
+        减签
+      </el-button>
+    </template>
+    <el-table :data="tableData" style="width: 100%" row-key="id" border>
+      <el-table-column prop="assigneeUser.nickname" label="审批人" />
+      <el-table-column prop="assigneeUser.deptName" label="所在部门" />
+      <el-table-column label="审批状态" prop="result">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.BPM_PROCESS_INSTANCE_RESULT" :value="scope.row.result" />
+        </template>
+      </el-table-column>
+      <el-table-column
+        label="提交时间"
+        align="center"
+        prop="createTime"
+        width="180"
+        :formatter="dateFormatter"
+      />
+      <el-table-column
+        label="结束时间"
+        align="center"
+        prop="endTime"
+        width="180"
+        :formatter="dateFormatter"
+      />
+      <el-table-column label="操作" prop="operation">
+        <template #default="scope">
+          <el-button
+            v-if="showSubSignButton(scope.row)"
+            type="danger"
+            plain
+            @click="handleSubSign(scope.row)"
+          >
+            <Icon icon="ep:remove" />
+            减签
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!--  减签   -->
+    <TaskSubSignDialogForm ref="taskSubSignDialogForm" />
+  </el-drawer>
+</template>
+<script lang="ts" setup>
+import { isEmpty } from '@/utils/is'
+import { DICT_TYPE } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import TaskSubSignDialogForm from './TaskSubSignDialogForm.vue'
+
+const message = useMessage() // 消息弹窗
+defineOptions({ name: 'ProcessInstancechildrenList' })
+
+const drawerVisible = ref(false) // 抽屉的是否展示
+
+const tableData = ref<any[]>([]) //表格数据
+const baseTask = ref<object>({})
+/** 打开弹窗 */
+const open = async (task: any) => {
+  if (isEmpty(task.children)) {
+    message.warning('该任务没有子任务')
+    return
+  }
+  baseTask.value = task
+  //设置表格数据
+  tableData.value = task.children
+  //展开抽屉
+  drawerVisible.value = true
+}
+defineExpose({ open }) // 提供 openModal 方法,用于打开弹窗
+
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+
+/**
+ * 减签
+ */
+const taskSubSignDialogForm = ref()
+const handleSubSign = (item) => {
+  taskSubSignDialogForm.value.open(item.id)
+}
+
+/**
+ * 显示减签按钮
+ * @param task
+ */
+const showSubSignButton = (task:any) => {
+  if(!isEmpty(task.children)){
+    //有子任务,且子任务有任意一个是 待处理 和 待前置任务完成 则显示减签按钮
+    const subTask = task.children.find((item) => item.result === 1 || item.result === 9)
+    return !isEmpty(subTask)
+  }
+  return false
+}
+</script>

+ 29 - 1
src/views/bpm/processInstance/detail/ProcessInstanceTaskList.vue

@@ -12,7 +12,18 @@
             :icon="getTimelineItemIcon(item)"
             :type="getTimelineItemType(item)"
           >
-            <p style="font-weight: 700">任务:{{ item.name }}</p>
+            <p style="font-weight: 700">
+              任务:{{ item.name }}
+              <dict-tag :type="DICT_TYPE.BPM_PROCESS_INSTANCE_RESULT" :value="item.result" />
+              <el-button
+                style="margin-left: 5px"
+                v-if="!isEmpty(item.children)"
+                @click="openChildrenTask(item)"
+              >
+                <Icon icon="ep:memo" />
+                子任务
+              </el-button>
+            </p>
             <el-card :body-style="{ padding: '10px' }">
               <label v-if="item.assigneeUser" style="margin-right: 30px; font-weight: normal">
                 审批人:{{ item.assigneeUser.nickname }}
@@ -42,11 +53,16 @@
         </el-timeline>
       </div>
     </el-col>
+    <!-- 子任务  -->
+    <ProcessInstanceChildrenTaskList ref="processInstanceChildrenTaskList" />
   </el-card>
 </template>
 <script lang="ts" setup>
 import { formatDate, formatPast2 } from '@/utils/formatTime'
 import { propTypes } from '@/utils/propTypes'
+import { DICT_TYPE } from '@/utils/dict'
+import { isEmpty } from '@/utils/is'
+import ProcessInstanceChildrenTaskList from './ProcessInstanceChildrenTaskList.vue'
 
 defineOptions({ name: 'BpmProcessInstanceTaskList' })
 
@@ -95,6 +111,18 @@ const getTimelineItemType = (item) => {
   if (item.result === 6) {
     return 'default'
   }
+  if (item.result === 7 || item.result === 8) {
+    return 'warning'
+  }
   return ''
 }
+
+/**
+ * 子任务
+ */
+const processInstanceChildrenTaskList = ref()
+
+const openChildrenTask = (item) => {
+  processInstanceChildrenTaskList.value.open(item)
+}
 </script>

+ 97 - 0
src/views/bpm/processInstance/detail/TaskAddSignDialogForm.vue

@@ -0,0 +1,97 @@
+<template>
+  <Dialog v-model="dialogVisible" title="加签" width="500">
+    <el-form
+      ref="formRef"
+      v-loading="formLoading"
+      :model="formData"
+      :rules="formRules"
+      label-width="110px"
+    >
+      <el-form-item label="加签处理人" prop="userIdList">
+        <el-select v-model="formData.userIdList" multiple clearable style="width: 100%">
+          <el-option
+            v-for="item in userList"
+            :key="item.id"
+            :label="item.nickname"
+            :value="item.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="加签理由" prop="reason">
+        <el-input v-model="formData.reason" clearable placeholder="请输入加签理由" />
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button :disabled="formLoading" type="primary" @click="submitForm('before')"
+        >向前加签</el-button
+      >
+      <el-button :disabled="formLoading" type="primary" @click="submitForm('after')"
+        >向后加签</el-button
+      >
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script lang="ts" setup>
+import * as TaskApi from '@/api/bpm/task'
+import * as UserApi from '@/api/system/user'
+
+const message = useMessage() // 消息弹窗
+defineOptions({ name: 'BpmTaskUpdateAssigneeForm' })
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const formLoading = ref(false) // 表单的加载中
+const formData = ref({
+  id: '',
+  userIdList: [],
+  type: ''
+})
+const formRules = ref({
+  userIdList: [{ required: true, message: '加签处理人不能为空', trigger: 'change' }],
+  reason: [{ required: true, message: '加签理由不能为空', trigger: 'change' }]
+})
+
+const formRef = ref() // 表单 Ref
+const userList = ref<any[]>([]) // 用户列表
+
+/** 打开弹窗 */
+const open = async (id: string) => {
+  dialogVisible.value = true
+  resetForm()
+  formData.value.id = id
+  // 获得用户列表
+  userList.value = await UserApi.getSimpleUserList()
+}
+defineExpose({ open }) // 提供 openModal 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async (type: string) => {
+  // 校验表单
+  if (!formRef) return
+  const valid = await formRef.value.validate()
+  if (!valid) return
+  // 提交请求
+  formLoading.value = true
+  formData.value.type = type
+  try {
+    await TaskApi.taskAddSign(formData.value)
+    message.success('加签成功')
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: '',
+    userIdList: [],
+    type: ''
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 85 - 0
src/views/bpm/processInstance/detail/TaskSubSignDialogForm.vue

@@ -0,0 +1,85 @@
+<template>
+  <Dialog v-model="dialogVisible" title="减签" width="500">
+    <el-form
+      ref="formRef"
+      v-loading="formLoading"
+      :model="formData"
+      :rules="formRules"
+      label-width="110px"
+    >
+      <el-form-item label="减签任务" prop="id">
+        <el-radio-group v-model="formData.id">
+          <el-radio-button v-for="item in subTaskList" :key="item.id" :label="item.id">
+            {{ item.name }}({{ item.assigneeUser.deptName }}{{ item.assigneeUser.nickname }}--审批)
+          </el-radio-button>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="减签理由" prop="reason">
+        <el-input v-model="formData.reason" clearable placeholder="请输入减签理由" />
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script lang="ts" name="TaskRollbackDialogForm" setup>
+import * as TaskApi from '@/api/bpm/task'
+import { isEmpty } from '@/utils/is'
+
+const message = useMessage() // 消息弹窗
+const dialogVisible = ref(false) // 弹窗的是否展示
+const formLoading = ref(false) // 表单的加载中
+const formData = ref({
+  id: '',
+  reason: ''
+})
+const formRules = ref({
+  id: [{ required: true, message: '必须选择减签任务', trigger: 'change' }],
+  reason: [{ required: true, message: '减签理由不能为空', trigger: 'blur' }]
+})
+
+const formRef = ref() // 表单 Ref
+const subTaskList = ref([])
+/** 打开弹窗 */
+const open = async (id: string) => {
+  subTaskList.value = await TaskApi.getChildrenTaskList(id)
+  if (isEmpty(subTaskList.value)) {
+    message.warning('当前没有可减签的任务')
+    return false
+  }
+  dialogVisible.value = true
+  resetForm()
+}
+defineExpose({ open }) // 提供 openModal 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  if (!formRef) return
+  const valid = await formRef.value.validate()
+  if (!valid) return
+  // 提交请求
+  formLoading.value = true
+  try {
+    await TaskApi.taskSubSign(formData.value)
+    message.success('减签成功')
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: '',
+    reason: ''
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 39 - 15
src/views/bpm/processInstance/detail/index.vue

@@ -49,6 +49,10 @@
             <Icon icon="ep:position" />
             委派
           </el-button>
+          <el-button type="primary" @click="handleSign(item)">
+            <Icon icon="ep:plus" />
+            加签
+          </el-button>
           <el-button type="warning" @click="handleBack(item)">
             <Icon icon="ep:back" />
             回退
@@ -95,6 +99,8 @@
     <TaskReturnDialog ref="taskReturnDialogRef" @success="getDetail" />
     <!-- 委派,将任务委派给别人处理,处理完成后,会重新回到原审批人手中-->
     <TaskDelegateForm ref="taskDelegateForm" @success="getDetail" />
+    <!-- 加签,当前任务审批人为A,向前加签选了一个C,则需要C先审批,然后再是A审批,向后加签B,A审批完,需要B再审批完,才算完成这个任务节点 -->
+    <TaskAddSignDialogForm ref="taskAddSignDialogForm" @success="getDetail" />
   </ContentWrap>
 </template>
 <script lang="ts" setup>
@@ -109,7 +115,9 @@ import ProcessInstanceBpmnViewer from './ProcessInstanceBpmnViewer.vue'
 import ProcessInstanceTaskList from './ProcessInstanceTaskList.vue'
 import TaskReturnDialog from './TaskReturnDialogForm.vue'
 import TaskDelegateForm from './taskDelegateForm.vue'
+import TaskAddSignDialogForm from './TaskAddSignDialogForm.vue'
 import { registerComponent } from '@/utils/routerHelper'
+import { isEmpty } from '@/utils/is'
 
 defineOptions({ name: 'BpmProcessInstanceDetail' })
 
@@ -185,6 +193,12 @@ const handleBack = async (task) => {
   taskReturnDialogRef.value.open(task.id)
 }
 
+const taskAddSignDialogForm = ref()
+/** 处理审批加签的操作 */
+const handleSign = async (task) => {
+  taskAddSignDialogForm.value.open(task.id)
+}
+
 /** 获得详情 */
 const getDetail = () => {
   // 1. 获得流程实例相关
@@ -261,26 +275,36 @@ const getTaskList = async () => {
     // 获得需要自己审批的任务
     runningTasks.value = []
     auditForms.value = []
-    tasks.value.forEach((task) => {
-      // 2.1 只有待处理才需要
-      if (task.result !== 1 && task.result !== 6) {
-        return
-      }
-      // 2.2 自己不是处理人
-      if (!task.assigneeUser || task.assigneeUser.id !== userId) {
-        return
-      }
-      // 2.3 添加到处理任务
-      runningTasks.value.push({ ...task })
-      auditForms.value.push({
-        reason: ''
-      })
-    })
+    loadRunningTask(tasks.value)
   } finally {
     tasksLoad.value = false
   }
 }
 
+/**
+ * 设置 runningTasks 中的任务
+ */
+const loadRunningTask = (tasks) => {
+  tasks.forEach((task) => {
+    if (!isEmpty(task.children)) {
+      loadRunningTask(task.children)
+    }
+    // 2.1 只有待处理才需要
+    if (task.result !== 1 && task.result !== 6) {
+      return
+    }
+    // 2.2 自己不是处理人
+    if (!task.assigneeUser || task.assigneeUser.id !== userId) {
+      return
+    }
+    // 2.3 添加到处理任务
+    runningTasks.value.push({ ...task })
+    auditForms.value.push({
+      reason: ''
+    })
+  })
+}
+
 /** 初始化 */
 onMounted(() => {
   getDetail()

+ 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>

+ 7 - 5
src/views/mall/promotion/article/ArticleForm.vue

@@ -8,6 +8,11 @@
       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="请选择">
@@ -20,11 +25,6 @@
             </el-select>
           </el-form-item>
         </el-col>
-        <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="author">
             <el-input v-model="formData.author" placeholder="请输入文章作者" />
@@ -40,6 +40,7 @@
             <UploadImg v-model="formData.picUrl" height="80px" />
           </el-form-item>
         </el-col>
+        <!-- TODO @puhui999:浏览次数,不能修改 -->
         <el-col :span="12">
           <el-form-item label="排序" prop="sort">
             <el-input-number v-model="formData.sort" :min="0" clearable controls-position="right" />
@@ -58,6 +59,7 @@
             </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="请选择">

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

@@ -37,6 +37,7 @@
 <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' })
 
@@ -111,8 +112,8 @@ const resetForm = () => {
     id: undefined,
     name: undefined,
     picUrl: undefined,
-    status: undefined,
-    sort: undefined
+    status: CommonStatusEnum.ENABLE,
+    sort: 0
   }
   formRef.value?.resetFields()
 }

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

@@ -56,16 +56,6 @@
           <Icon class="mr-5px" icon="ep:plus" />
           新增
         </el-button>
-        <el-button
-          v-hasPermi="['promotion:article-category:export']"
-          :loading="exportLoading"
-          plain
-          type="success"
-          @click="handleExport"
-        >
-          <Icon class="mr-5px" icon="ep:download" />
-          导出
-        </el-button>
       </el-form-item>
     </el-form>
   </ContentWrap>
@@ -73,19 +63,19 @@
   <!-- 列表 -->
   <ContentWrap>
     <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true">
-      <el-table-column align="center" label="编号" prop="id" />
-      <el-table-column align="center" label="分类名称" prop="name" />
+      <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">
+      <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" />
+      <el-table-column align="center" label="排序" prop="sort" min-width="150" />
       <el-table-column
         :formatter="dateFormatter"
         align="center"
@@ -130,7 +120,6 @@
 <script lang="ts" setup>
 import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
 import { dateFormatter } from '@/utils/formatTime'
-import download from '@/utils/download'
 import * as ArticleCategoryApi from '@/api/mall/promotion/articleCategory'
 import ArticleCategoryForm from './ArticleCategoryForm.vue'
 import { createImageViewer } from '@/components/ImageViewer'
@@ -203,21 +192,6 @@ const handleDelete = async (id: number) => {
   } catch {}
 }
 
-/** 导出按钮操作 */
-const handleExport = async () => {
-  try {
-    // 导出的二次确认
-    await message.exportConfirm()
-    // 发起导出
-    exportLoading.value = true
-    const data = await ArticleCategoryApi.exportArticleCategory(queryParams)
-    download.excel(data, '分类.xls')
-  } catch {
-  } finally {
-    exportLoading.value = false
-  }
-}
-
 /** 初始化 **/
 onMounted(() => {
   getList()

+ 15 - 107
src/views/mall/promotion/article/index.vue

@@ -32,15 +32,6 @@
           @keyup.enter="handleQuery"
         />
       </el-form-item>
-      <el-form-item label="文章作者" prop="author">
-        <el-input
-          v-model="queryParams.author"
-          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
@@ -51,41 +42,6 @@
           />
         </el-select>
       </el-form-item>
-      <el-form-item label="关联商品" prop="spuId">
-        <el-select
-          v-model="queryParams.spuId"
-          class="!w-240px"
-          placeholder="全部"
-          @keyup.enter="handleQuery"
-        >
-          <el-option v-for="item in spuList" :key="item.id" :label="item.name" :value="item.id" />
-        </el-select>
-      </el-form-item>
-      <el-form-item label="热门" prop="recommendHot">
-        <el-select v-model="queryParams.recommendHot" class="!w-240px" clearable placeholder="全部">
-          <el-option
-            v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)"
-            :key="dict.value"
-            :label="dict.label"
-            :value="dict.value"
-          />
-        </el-select>
-      </el-form-item>
-      <el-form-item label="轮播图" prop="recommendBanner">
-        <el-select
-          v-model="queryParams.recommendBanner"
-          class="!w-240px"
-          clearable
-          placeholder="全部"
-        >
-          <el-option
-            v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)"
-            :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"
@@ -115,16 +71,6 @@
           <Icon class="mr-5px" icon="ep:plus" />
           新增
         </el-button>
-        <el-button
-          v-hasPermi="['promotion:article:export']"
-          :loading="exportLoading"
-          plain
-          type="success"
-          @click="handleExport"
-        >
-          <Icon class="mr-5px" icon="ep:download" />
-          导出
-        </el-button>
       </el-form-item>
     </el-form>
   </ContentWrap>
@@ -132,51 +78,31 @@
   <!-- 列表 -->
   <ContentWrap>
     <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true">
-      <el-table-column align="center" label="文章分类" prop="categoryId">
-        <template #default="scope">
-          {{ categoryList.find((item) => item.id === scope.row.categoryId)?.name }}
-        </template>
-      </el-table-column>
-      <el-table-column align="center" label="关联商品" prop="spuId" width="300">
-        <template #default="scope">
-          <el-image
-            :preview-src-list="[spuList.find((item) => item.id === scope.row.spuId)?.picUrl]"
-            :src="spuList.find((item) => item.id === scope.row.spuId)?.picUrl"
-            class="mr-[10px] h-40px w-40px v-middle"
-            preview-teleported
-          />
-          {{ spuList.find((item) => item.id === scope.row.spuId)?.name }}
-        </template>
-      </el-table-column>
-      <el-table-column align="center" label="文章标题" prop="title" />
-      <el-table-column align="center" label="文章作者" prop="author" />
-      <el-table-column align="center" label="文章封面" prop="picUrl">
+      <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="introduction" />
-      <el-table-column align="center" label="浏览次数" prop="browseCount" />
-      <el-table-column align="center" label="排序" prop="sort" />
-      <el-table-column align="center" label="状态" prop="status">
+      <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">
-          <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
-        </template>
-      </el-table-column>
-      <el-table-column align="center" label="热门" prop="recommendHot">
-        <template #default="scope">
-          <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.recommendHot" />
+          {{ categoryList.find((item) => item.id === scope.row.categoryId)?.name }}
         </template>
       </el-table-column>
-      <el-table-column align="center" label="轮播图" prop="recommendBanner">
+      <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.INFRA_BOOLEAN_STRING" :value="scope.row.recommendBanner" />
+          <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
         </template>
       </el-table-column>
       <el-table-column
         :formatter="dateFormatter"
         align="center"
-        label="创建时间"
+        label="发布时间"
         prop="createTime"
         width="180px"
       />
@@ -215,9 +141,8 @@
 </template>
 
 <script lang="ts" setup>
-import { DICT_TYPE, getBoolDictOptions, getIntDictOptions } from '@/utils/dict'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
 import { dateFormatter } from '@/utils/formatTime'
-import download from '@/utils/download'
 import * as ArticleApi from '@/api/mall/promotion/article'
 import ArticleForm from './ArticleForm.vue'
 import * as ArticleCategoryApi from '@/api/mall/promotion/articleCategory'
@@ -236,12 +161,9 @@ const queryParams = reactive({
   pageNo: 1,
   pageSize: 10,
   categoryId: undefined,
-  spuId: undefined,
   title: null,
-  author: null,
   status: undefined,
-  recommendHot: undefined,
-  recommendBanner: undefined,
+  spuId: undefined,
   createTime: []
 })
 const queryFormRef = ref() // 搜索的表单
@@ -295,25 +217,11 @@ const handleDelete = async (id: number) => {
   } catch {}
 }
 
-/** 导出按钮操作 */
-const handleExport = async () => {
-  try {
-    // 导出的二次确认
-    await message.exportConfirm()
-    // 发起导出
-    exportLoading.value = true
-    const data = await ArticleApi.exportArticle(queryParams)
-    download.excel(data, '文章管理.xls')
-  } catch {
-  } finally {
-    exportLoading.value = false
-  }
-}
-
 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[]

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

@@ -6,7 +6,7 @@
         <div class="flex items-center">
           <div
             class="h-[50px] w-[50px] flex items-center justify-center"
-            style="color: rgb(24, 144, 255); background-color: rgba(24, 144, 255, 0.1)"
+            style="color: rgb(24 144 255); background-color: rgb(24 144 255 / 10%)"
           >
             <Icon :size="23" icon="fa:user-times" />
           </div>
@@ -27,7 +27,7 @@
         <div class="flex items-center">
           <div
             class="h-[50px] w-[50px] flex items-center justify-center"
-            style="color: rgb(162, 119, 255); background-color: rgba(162, 119, 255, 0.1)"
+            style="color: rgb(162 119 255); background-color: rgb(162 119 255 / 10%)"
           >
             <Icon :size="23" icon="fa:user-plus" />
           </div>
@@ -48,7 +48,7 @@
         <div class="flex items-center">
           <div
             class="h-[50px] w-[50px] flex items-center justify-center"
-            style="color: rgb(162, 119, 255); background-color: rgba(162, 119, 255, 0.1)"
+            style="color: rgb(162 119 255); background-color: rgb(162 119 255 / 10%)"
           >
             <Icon :size="23" icon="fa:user-plus" />
           </div>

+ 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 - 209
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,13 +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>

+ 9 - 9
src/views/mall/trade/afterSale/detail/index.vue

@@ -307,7 +307,7 @@ onMounted(async () => {
 
 // 时间线样式调整
 :deep(.el-timeline) {
-  margin: 10px 0px 0px 160px;
+  margin: 10px 0 0 160px;
 
   .el-timeline-item__wrapper {
     position: relative;
@@ -328,27 +328,27 @@ onMounted(async () => {
     background-color: #f7f8fa;
 
     &::before {
-      content: '';
       position: absolute;
       top: 10px;
       left: 13px;
-      border-width: 8px; /* 调整尖角大小 */
-      border-style: solid;
       border-color: transparent #f7f8fa transparent transparent; /* 尖角颜色,左侧朝向 */
+      border-style: solid;
+      border-width: 8px; /* 调整尖角大小 */
+      content: '';
     }
   }
 
   .dot-node-style {
-    width: 20px;
-    height: 20px;
     position: absolute;
     left: -5px;
     display: flex;
+    width: 20px;
+    height: 20px;
+    font-size: 10px;
+    color: #fff;
+    border-radius: 50%;
     justify-content: center;
     align-items: center;
-    border-radius: 50%;
-    color: #fff;
-    font-size: 10px;
   }
 }
 </style>

+ 1 - 1
src/views/mall/trade/brokerage/withdraw/index.vue

@@ -122,7 +122,7 @@
           <el-image
             v-if="scope.row.accountQrCodeUrl"
             :src="scope.row.accountQrCodeUrl"
-            class="w-40px h-40px"
+            class="h-40px w-40px"
             :preview-src-list="[scope.row.accountQrCodeUrl]"
             preview-teleported
           />

+ 1 - 1
src/views/mall/trade/config/index.vue

@@ -16,9 +16,9 @@
           <el-form-item label="退款理由" prop="afterSaleRefundReasons">
             <el-select
               v-model="formData.afterSaleRefundReasons"
-              allow-create
               filterable
               multiple
+              allow-create
               placeholder="请直接输入退款理由"
             >
               <el-option

+ 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>

+ 15 - 10
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
     // 没有表单信息则关闭页面返回
@@ -395,27 +400,27 @@ onMounted(async () => {
     background-color: #f7f8fa;
 
     &::before {
-      content: ''; /* 必须设置 content 属性 */
       position: absolute;
       top: 10px;
       left: 13px; /* 将伪元素水平居中 */
-      border-width: 8px; /* 调整尖角大小 */
-      border-style: solid;
       border-color: transparent #f7f8fa transparent transparent; /* 尖角颜色,左侧朝向 */
+      border-style: solid;
+      border-width: 8px; /* 调整尖角大小 */
+      content: ''; /* 必须设置 content 属性 */
     }
   }
 
   .dot-node-style {
-    width: 20px;
-    height: 20px;
     position: absolute;
     left: -5px;
     display: flex;
+    width: 20px;
+    height: 20px;
+    font-size: 10px;
+    color: #fff;
+    border-radius: 50%;
     justify-content: center;
     align-items: center;
-    border-radius: 50%;
-    color: #fff;
-    font-size: 10px;
   }
 }
 </style>

+ 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

+ 1 - 1
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'
 

+ 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>

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

@@ -0,0 +1,164 @@
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="用户编号" prop="userId">
+        <el-input
+          v-model="queryParams.userId"
+          placeholder="请输入用户编号"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="用户类型" prop="userType">
+        <el-select
+          v-model="queryParams.userType"
+          placeholder="请选择用户类型"
+          clearable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.USER_TYPE)"
+            :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-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="userId" />
+      <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,
+  userId: null,
+  userType: null,
+  balance: null,
+  totalExpense: null,
+  totalRecharge: null,
+  freezePrice: 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>

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

@@ -135,7 +135,6 @@ const queryParams = reactive({
   createTime: []
 })
 const queryFormRef = ref() // 搜索的表单
-const exportLoading = ref(false) // 导出的加载中
 
 /** 查询列表 */
 const getList = async () => {

+ 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>

+ 1 - 0
src/views/system/menu/index.vue

@@ -188,6 +188,7 @@ const refreshMenu = async () => {
   try {
     await message.confirm('即将更新缓存刷新浏览器!', '刷新菜单缓存')
     // 清空,从而触发刷新
+    wsCache.delete(CACHE_KEY.USER)
     wsCache.delete(CACHE_KEY.ROLE_ROUTERS)
     // 刷新浏览器
     location.reload()