diff --git a/src/api/mall/product/spu.ts b/src/api/mall/product/spu.ts index c78bef47..5cc4d8fe 100644 --- a/src/api/mall/product/spu.ts +++ b/src/api/mall/product/spu.ts @@ -104,5 +104,5 @@ export const exportSpu = async (params) => { // 获得商品 SPU 精简列表 export const getSpuSimpleList = async () => { - return request.get({ url: '/product/spu/get-simple-list' }) + return request.get({ url: '/product/spu/list-all-simple' }) } diff --git a/src/api/mall/promotion/article/index.ts b/src/api/mall/promotion/article/index.ts new file mode 100644 index 00000000..824958ad --- /dev/null +++ b/src/api/mall/promotion/article/index.ts @@ -0,0 +1,42 @@ +import request from '@/config/axios' + +export interface ArticleVO { + id: number + categoryId: number + title: string + author: string + picUrl: string + introduction: string + browseCount: string + sort: number + status: number + spuId: number + recommendHot: boolean + recommendBanner: boolean + content: string +} + +// 查询文章管理列表 +export const getArticlePage = async (params) => { + return await request.get({ url: `/promotion/article/page`, params }) +} + +// 查询文章管理详情 +export const getArticle = async (id: number) => { + return await request.get({ url: `/promotion/article/get?id=` + id }) +} + +// 新增文章管理 +export const createArticle = async (data: ArticleVO) => { + return await request.post({ url: `/promotion/article/create`, data }) +} + +// 修改文章管理 +export const updateArticle = async (data: ArticleVO) => { + return await request.put({ url: `/promotion/article/update`, data }) +} + +// 删除文章管理 +export const deleteArticle = async (id: number) => { + return await request.delete({ url: `/promotion/article/delete?id=` + id }) +} diff --git a/src/api/mall/promotion/articleCategory/index.ts b/src/api/mall/promotion/articleCategory/index.ts new file mode 100644 index 00000000..47f5e934 --- /dev/null +++ b/src/api/mall/promotion/articleCategory/index.ts @@ -0,0 +1,39 @@ +import request from '@/config/axios' + +export interface ArticleCategoryVO { + id: number + name: string + picUrl: string + status: number + sort: number +} + +// 查询文章分类列表 +export const getArticleCategoryPage = async (params) => { + return await request.get({ url: `/promotion/article-category/page`, params }) +} + +// 查询文章分类精简信息列表 +export const getSimpleArticleCategoryList = async () => { + return await request.get({ url: `/promotion/article-category/list-all-simple` }) +} + +// 查询文章分类详情 +export const getArticleCategory = async (id: number) => { + return await request.get({ url: `/promotion/article-category/get?id=` + id }) +} + +// 新增文章分类 +export const createArticleCategory = async (data: ArticleCategoryVO) => { + return await request.post({ url: `/promotion/article-category/create`, data }) +} + +// 修改文章分类 +export const updateArticleCategory = async (data: ArticleCategoryVO) => { + return await request.put({ url: `/promotion/article-category/update`, data }) +} + +// 删除文章分类 +export const deleteArticleCategory = async (id: number) => { + return await request.delete({ url: `/promotion/article-category/delete?id=` + id }) +} diff --git a/src/api/mall/promotion/discount/discountActivity.ts b/src/api/mall/promotion/discount/discountActivity.ts new file mode 100644 index 00000000..e755c1bd --- /dev/null +++ b/src/api/mall/promotion/discount/discountActivity.ts @@ -0,0 +1,60 @@ +import request from '@/config/axios' +import { Sku, Spu } from '@/api/mall/product/spu' + +export interface DiscountActivityVO { + id?: number + spuId?: number + name?: string + status?: number + remark?: string + startTime?: Date + endTime?: Date + products?: DiscountProductVO[] +} +// 限时折扣相关 属性 +export interface DiscountProductVO { + spuId: number + skuId: number + discountType: number + discountPercent: number + discountPrice: number +} + +// 扩展 Sku 配置 +export type SkuExtension = Sku & { + productConfig: DiscountProductVO +} + +export interface SpuExtension extends Spu { + skus: SkuExtension[] // 重写类型 +} + +// 查询限时折扣活动列表 +export const getDiscountActivityPage = async (params) => { + return await request.get({ url: '/promotion/discount-activity/page', params }) +} + +// 查询限时折扣活动详情 +export const getDiscountActivity = async (id: number) => { + return await request.get({ url: '/promotion/discount-activity/get?id=' + id }) +} + +// 新增限时折扣活动 +export const createDiscountActivity = async (data: DiscountActivityVO) => { + return await request.post({ url: '/promotion/discount-activity/create', data }) +} + +// 修改限时折扣活动 +export const updateDiscountActivity = async (data: DiscountActivityVO) => { + return await request.put({ url: '/promotion/discount-activity/update', data }) +} + +// 关闭限时折扣活动 +export const closeDiscountActivity = async (id: number) => { + return await request.put({ url: '/promotion/discount-activity/close?id=' + id }) +} + +// 删除限时折扣活动 +export const deleteDiscountActivity = async (id: number) => { + return await request.delete({ url: '/promotion/discount-activity/delete?id=' + id }) +} diff --git a/src/api/mall/statistics/common.ts b/src/api/mall/statistics/common.ts new file mode 100644 index 00000000..3d964392 --- /dev/null +++ b/src/api/mall/statistics/common.ts @@ -0,0 +1,5 @@ +/** 数据对照 Response VO */ +export interface DataComparisonRespVO<T> { + value: T + reference: T +} diff --git a/src/api/mall/statistics/member.ts b/src/api/mall/statistics/member.ts index d4680d3d..92af031e 100644 --- a/src/api/mall/statistics/member.ts +++ b/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)] } }) } diff --git a/src/api/mall/statistics/pay.ts b/src/api/mall/statistics/pay.ts new file mode 100644 index 00000000..f5d14c9d --- /dev/null +++ b/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` }) +} diff --git a/src/api/mall/statistics/trade.ts b/src/api/mall/statistics/trade.ts index f7829ccb..94052597 100644 --- a/src/api/mall/statistics/trade.ts +++ b/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 diff --git a/src/api/mall/trade/order/index.ts b/src/api/mall/trade/order/index.ts index ea78275f..364483b8 100644 --- a/src/api/mall/trade/order/index.ts +++ b/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 } + }) } diff --git a/src/api/pay/wallet/index.ts b/src/api/pay/wallet/balance/index.ts similarity index 77% rename from src/api/pay/wallet/index.ts rename to src/api/pay/wallet/balance/index.ts index b57deeb0..3e5ab369 100644 --- a/src/api/pay/wallet/index.ts +++ b/src/api/pay/wallet/balance/index.ts @@ -3,7 +3,6 @@ import request from '@/config/axios' /** 用户钱包查询参数 */ export interface PayWalletUserReqVO { userId: number - userType: number } /** 钱包 VO */ export interface WalletVO { @@ -20,3 +19,8 @@ export interface WalletVO { export const getWallet = async (params: PayWalletUserReqVO) => { return await request.get<WalletVO>({ url: `/pay/wallet/get`, params }) } + +// 查询会员钱包列表 +export const getWalletPage = async (params) => { + return await request.get({ url: `/pay/wallet/page`, params }) +} diff --git a/src/api/pay/wallet/rechargePackage/index.ts b/src/api/pay/wallet/rechargePackage/index.ts new file mode 100644 index 00000000..c8e4cc9c --- /dev/null +++ b/src/api/pay/wallet/rechargePackage/index.ts @@ -0,0 +1,34 @@ +import request from '@/config/axios' + +export interface WalletRechargePackageVO { + id: number + name: string + payPrice: number + bonusPrice: number + status: number +} + +// 查询套餐充值列表 +export const getWalletRechargePackagePage = async (params) => { + return await request.get({ url: '/pay/wallet-recharge-package/page', params }) +} + +// 查询套餐充值详情 +export const getWalletRechargePackage = async (id: number) => { + return await request.get({ url: '/pay/wallet-recharge-package/get?id=' + id }) +} + +// 新增套餐充值 +export const createWalletRechargePackage = async (data: WalletRechargePackageVO) => { + return await request.post({ url: '/pay/wallet-recharge-package/create', data }) +} + +// 修改套餐充值 +export const updateWalletRechargePackage = async (data: WalletRechargePackageVO) => { + return await request.put({ url: '/pay/wallet-recharge-package/update', data }) +} + +// 删除套餐充值 +export const deleteWalletRechargePackage = async (id: number) => { + return await request.delete({ url: '/pay/wallet-recharge-package/delete?id=' + id }) +} diff --git a/src/api/pay/wallet/transaction/index.ts b/src/api/pay/wallet/transaction/index.ts new file mode 100644 index 00000000..3377ffaa --- /dev/null +++ b/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 }) +} diff --git a/src/components/ShortcutDateRangePicker/index.vue b/src/components/ShortcutDateRangePicker/index.vue new file mode 100644 index 00000000..d7fa90cb --- /dev/null +++ b/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> diff --git a/src/views/mall/statistics/trade/components/TradeTrendValue.vue b/src/components/SummaryCard/index.vue similarity index 95% rename from src/views/mall/statistics/trade/components/TradeTrendValue.vue rename to src/components/SummaryCard/index.vue index 10fa9517..52da6da9 100644 --- a/src/views/mall/statistics/trade/components/TradeTrendValue.vue +++ b/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(''), diff --git a/src/utils/index.ts b/src/utils/index.ts index 41d70a5b..d5301ddb 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -236,3 +236,16 @@ export const yuanToFen = (amount: string | number): number => { export const fenToYuan = (price: string | number): number => { return formatToFraction(price) } + +/** + * 计算环比 + * + * @param value 当前数值 + * @param reference 对比数值 + */ +export const calculateRelativeRate = (value?: number, reference?: number) => { + // 防止除0 + if (!reference) return 0 + + return ((100 * ((value || 0) - reference)) / reference).toFixed(0) +} diff --git a/src/views/mall/home/components/ComparisonCard.vue b/src/views/mall/home/components/ComparisonCard.vue new file mode 100644 index 00000000..ee1c2f0c --- /dev/null +++ b/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> diff --git a/src/views/mall/home/components/MemberStatisticsCard.vue b/src/views/mall/home/components/MemberStatisticsCard.vue new file mode 100644 index 00000000..2f9d7ab5 --- /dev/null +++ b/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> diff --git a/src/views/mall/home/components/OperationDataCard.vue b/src/views/mall/home/components/OperationDataCard.vue new file mode 100644 index 00000000..b905203b --- /dev/null +++ b/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> diff --git a/src/views/mall/home/components/ShortcutCard.vue b/src/views/mall/home/components/ShortcutCard.vue new file mode 100644 index 00000000..9fdd5cd4 --- /dev/null +++ b/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> diff --git a/src/views/mall/home/components/TradeTrendCard.vue b/src/views/mall/home/components/TradeTrendCard.vue new file mode 100644 index 00000000..a8cab828 --- /dev/null +++ b/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> diff --git a/src/views/mall/home/index.vue b/src/views/mall/home/index.vue new file mode 100644 index 00000000..feaa46a9 --- /dev/null +++ b/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> diff --git a/src/views/mall/promotion/article/ArticleForm.vue b/src/views/mall/promotion/article/ArticleForm.vue new file mode 100644 index 00000000..92976931 --- /dev/null +++ b/src/views/mall/promotion/article/ArticleForm.vue @@ -0,0 +1,238 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle" width="70%"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="110px" + > + <el-row> + <el-col :span="12"> + <el-form-item label="文章标题" prop="title"> + <el-input v-model="formData.title" placeholder="请输入文章标题" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="文章分类" prop="categoryId"> + <el-select v-model="formData.categoryId" placeholder="请选择"> + <el-option + v-for="item in categoryList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="文章作者" prop="author"> + <el-input v-model="formData.author" placeholder="请输入文章作者" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="文章简介" prop="introduction"> + <el-input v-model="formData.introduction" placeholder="请输入文章简介" /> + </el-form-item> + </el-col> + <el-col :span="24"> + <el-form-item label="文章封面" prop="picUrl"> + <UploadImg v-model="formData.picUrl" height="80px" /> + </el-form-item> + </el-col> + <!-- TODO @puhui999:浏览次数,不能修改 --> + <el-col :span="12"> + <el-form-item label="浏览次数" prop="browseCount"> + <el-input-number + v-model="formData.browseCount" + :min="0" + clearable + controls-position="right" + /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="排序" prop="sort"> + <el-input-number v-model="formData.sort" :min="0" clearable controls-position="right" /> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + </el-col> + <!-- TODO @puhui999:可以使用 SpuTableSelect --> + <el-col :span="12"> + <el-form-item label="商品关联" prop="spuId"> + <el-select v-model="formData.spuId" placeholder="请选择"> + <el-option + v-for="item in spuList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="是否热门" prop="recommendHot"> + <el-radio-group v-model="formData.recommendHot"> + <el-radio + v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + </el-col> + <el-col :span="12"> + <el-form-item label="是否轮播图" prop="recommendBanner"> + <el-radio-group v-model="formData.recommendBanner"> + <el-radio + v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + </el-col> + <el-col :span="24"> + <el-form-item label="文章内容"> + <Editor v-model="formData.content" height="150px" /> + </el-form-item> + </el-col> + </el-row> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getBoolDictOptions, getIntDictOptions } from '@/utils/dict' +import * as ArticleApi from '@/api/mall/promotion/article' +import * as ArticleCategoryApi from '@/api/mall/promotion/articleCategory' +import * as ProductSpuApi from '@/api/mall/product/spu' + +defineOptions({ name: 'PromotionArticleForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + categoryId: undefined, + title: undefined, + author: undefined, + picUrl: undefined, + introduction: undefined, + browseCount: 0, + sort: 0, + status: 0, + spuId: undefined, + recommendHot: false, + recommendBanner: false, + content: undefined +}) +const formRules = reactive({ + categoryId: [{ required: true, message: '分类id不能为空', trigger: 'blur' }], + title: [{ required: true, message: '文章标题不能为空', trigger: 'blur' }], + picUrl: [{ required: true, message: '文章封面图片地址不能为空', trigger: 'blur' }], + sort: [{ required: true, message: '排序不能为空', trigger: 'blur' }], + status: [{ required: true, message: '状态不能为空', trigger: 'blur' }], + spuId: [{ required: true, message: '商品关联id不能为空', trigger: 'blur' }], + recommendHot: [{ required: true, message: '是否热门(小程序)不能为空', trigger: 'blur' }], + recommendBanner: [{ required: true, message: '是否轮播图(小程序)不能为空', trigger: 'blur' }], + content: [{ required: true, message: '文章内容不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await ArticleApi.getArticle(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as ArticleApi.ArticleVO + if (formType.value === 'create') { + await ArticleApi.createArticle(data) + message.success(t('common.createSuccess')) + } else { + await ArticleApi.updateArticle(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + categoryId: undefined, + title: undefined, + author: undefined, + picUrl: undefined, + introduction: undefined, + browseCount: 0, + sort: 0, + status: 0, + spuId: undefined, + recommendHot: false, + recommendBanner: false, + content: undefined + } + formRef.value?.resetFields() +} + +const categoryList = ref<ArticleCategoryApi.ArticleCategoryVO[]>([]) +const spuList = ref<ProductSpuApi.Spu[]>([]) +onMounted(async () => { + categoryList.value = + (await ArticleCategoryApi.getSimpleArticleCategoryList()) as ArticleCategoryApi.ArticleCategoryVO[] + spuList.value = (await ProductSpuApi.getSpuSimpleList()) as ProductSpuApi.Spu[] +}) +</script> diff --git a/src/views/mall/promotion/article/category/ArticleCategoryForm.vue b/src/views/mall/promotion/article/category/ArticleCategoryForm.vue new file mode 100644 index 00000000..ac7e9f38 --- /dev/null +++ b/src/views/mall/promotion/article/category/ArticleCategoryForm.vue @@ -0,0 +1,120 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle"> + <el-form + ref="formRef" + v-loading="formLoading" + :model="formData" + :rules="formRules" + label-width="100px" + > + <el-form-item label="分类名称" prop="name"> + <el-input v-model="formData.name" placeholder="请输入分类名称" /> + </el-form-item> + <el-form-item label="图标地址" prop="picUrl"> + <UploadImg v-model="formData.picUrl" height="80px" /> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item label="排序" prop="sort"> + <el-input-number v-model="formData.sort" :min="0" clearable controls-position="right" /> + </el-form-item> + </el-form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import * as ArticleCategoryApi from '@/api/mall/promotion/articleCategory' +import { CommonStatusEnum } from '@/utils/constants' + +defineOptions({ name: 'PromotionArticleCategoryForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: undefined, + picUrl: undefined, + status: undefined, + sort: undefined +}) +const formRules = reactive({ + name: [{ required: true, message: '分类名称不能为空', trigger: 'blur' }], + status: [{ required: true, message: '状态不能为空', trigger: 'blur' }], + sort: [{ required: true, message: '排序不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await ArticleCategoryApi.getArticleCategory(id) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as ArticleCategoryApi.ArticleCategoryVO + if (formType.value === 'create') { + await ArticleCategoryApi.createArticleCategory(data) + message.success(t('common.createSuccess')) + } else { + await ArticleCategoryApi.updateArticleCategory(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: undefined, + picUrl: undefined, + status: CommonStatusEnum.ENABLE, + sort: 0 + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/mall/promotion/article/category/index.vue b/src/views/mall/promotion/article/category/index.vue new file mode 100644 index 00000000..73d1420c --- /dev/null +++ b/src/views/mall/promotion/article/category/index.vue @@ -0,0 +1,199 @@ +<template> + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="68px" + > + <el-form-item label="分类名称" prop="name"> + <el-input + v-model="queryParams.name" + class="!w-240px" + clearable + placeholder="请输入分类名称" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-select v-model="queryParams.status" class="!w-240px" clearable placeholder="请选择状态"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + end-placeholder="结束日期" + start-placeholder="开始日期" + type="daterange" + value-format="YYYY-MM-DD HH:mm:ss" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + <el-button + v-hasPermi="['promotion:article-category:create']" + plain + type="primary" + @click="openForm('create')" + > + <Icon class="mr-5px" icon="ep:plus" /> + 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true"> + <el-table-column align="center" label="编号" prop="id" min-width="100" /> + <el-table-column align="center" label="分类名称" prop="name" min-width="240" /> + <el-table-column label="分类图图" min-width="80"> + <template #default="{ row }"> + <el-image :src="row.picUrl" class="h-30px w-30px" @click="imagePreview(row.picUrl)" /> + </template> + </el-table-column> + <el-table-column align="center" label="状态" prop="status" min-width="150"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column align="center" label="排序" prop="sort" min-width="150" /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="创建时间" + prop="createTime" + width="180px" + /> + <el-table-column align="center" label="操作"> + <template #default="scope"> + <el-button + v-hasPermi="['promotion:article-category:update']" + link + type="primary" + @click="openForm('update', scope.row.id)" + > + 编辑 + </el-button> + <el-button + v-hasPermi="['promotion:article-category:delete']" + link + type="danger" + @click="handleDelete(scope.row.id)" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <ArticleCategoryForm ref="formRef" @success="getList" /> +</template> + +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import * as ArticleCategoryApi from '@/api/mall/promotion/articleCategory' +import ArticleCategoryForm from './ArticleCategoryForm.vue' +import { createImageViewer } from '@/components/ImageViewer' + +defineOptions({ name: 'PromotionArticleCategory' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: null, + status: null, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 + +/** 分类图预览 */ +const imagePreview = (imgUrl: string) => { + createImageViewer({ + urlList: [imgUrl] + }) +} + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ArticleCategoryApi.getArticleCategoryPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await ArticleCategoryApi.deleteArticleCategory(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/mall/promotion/article/index.vue b/src/views/mall/promotion/article/index.vue new file mode 100644 index 00000000..fb5c48d8 --- /dev/null +++ b/src/views/mall/promotion/article/index.vue @@ -0,0 +1,229 @@ +<template> + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + ref="queryFormRef" + :inline="true" + :model="queryParams" + class="-mb-15px" + label-width="80px" + > + <el-form-item label="文章分类" prop="categoryId"> + <el-select + v-model="queryParams.categoryId" + class="!w-240px" + placeholder="全部" + @keyup.enter="handleQuery" + > + <el-option + v-for="item in categoryList" + :key="item.id" + :label="item.name" + :value="item.id" + /> + </el-select> + </el-form-item> + <el-form-item label="文章标题" prop="title"> + <el-input + v-model="queryParams.title" + class="!w-240px" + clearable + placeholder="请输入文章标题" + @keyup.enter="handleQuery" + /> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-select v-model="queryParams.status" class="!w-240px" clearable placeholder="请选择状态"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + end-placeholder="结束日期" + start-placeholder="开始日期" + type="daterange" + value-format="YYYY-MM-DD HH:mm:ss" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + <el-button + v-hasPermi="['promotion:article:create']" + plain + type="primary" + @click="openForm('create')" + > + <Icon class="mr-5px" icon="ep:plus" /> + 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true"> + <el-table-column align="center" label="编号" prop="id" min-width="60" /> + <el-table-column align="center" label="封面" prop="picUrl" min-width="80"> + <template #default="{ row }"> + <el-image :src="row.picUrl" class="h-30px w-30px" @click="imagePreview(row.picUrl)" /> + </template> + </el-table-column> + <el-table-column align="center" label="标题" prop="title" min-width="180" /> + <el-table-column align="center" label="分类" prop="categoryId" min-width="180"> + <template #default="scope"> + {{ categoryList.find((item) => item.id === scope.row.categoryId)?.name }} + </template> + </el-table-column> + <el-table-column align="center" label="浏览量" prop="browseCount" min-width="180" /> + <el-table-column align="center" label="作者" prop="author" min-width="180" /> + <el-table-column align="center" label="文章简介" prop="introduction" min-width="250" /> + <el-table-column align="center" label="排序" prop="sort" min-width="60" /> + <el-table-column align="center" label="状态" prop="status" min-width="60"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column + :formatter="dateFormatter" + align="center" + label="发布时间" + prop="createTime" + width="180px" + /> + <el-table-column align="center" fixed="right" label="操作" width="120"> + <template #default="scope"> + <el-button + v-hasPermi="['promotion:article:update']" + link + type="primary" + @click="openForm('update', scope.row.id)" + > + 编辑 + </el-button> + <el-button + v-hasPermi="['promotion:article:delete']" + link + type="danger" + @click="handleDelete(scope.row.id)" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <ArticleForm ref="formRef" @success="getList" /> +</template> + +<script lang="ts" setup> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import * as ArticleApi from '@/api/mall/promotion/article' +import ArticleForm from './ArticleForm.vue' +import * as ArticleCategoryApi from '@/api/mall/promotion/articleCategory' +import * as ProductSpuApi from '@/api/mall/product/spu' +import { createImageViewer } from '@/components/ImageViewer' + +defineOptions({ name: 'PromotionArticle' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + categoryId: undefined, + title: null, + status: undefined, + spuId: undefined, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 +/** 文章封面预览 */ +const imagePreview = (imgUrl: string) => { + createImageViewer({ + urlList: [imgUrl] + }) +} +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ArticleApi.getArticlePage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await ArticleApi.deleteArticle(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +const categoryList = ref<ArticleCategoryApi.ArticleCategoryVO[]>([]) +const spuList = ref<ProductSpuApi.Spu[]>([]) +onMounted(async () => { + await getList() + // 加载分类、商品列表 + categoryList.value = + (await ArticleCategoryApi.getSimpleArticleCategoryList()) as ArticleCategoryApi.ArticleCategoryVO[] + spuList.value = (await ProductSpuApi.getSpuSimpleList()) as ProductSpuApi.Spu[] +}) +</script> diff --git a/src/views/mall/promotion/discountActivity/DiscountActivityForm.vue b/src/views/mall/promotion/discountActivity/DiscountActivityForm.vue new file mode 100644 index 00000000..d7a98061 --- /dev/null +++ b/src/views/mall/promotion/discountActivity/DiscountActivityForm.vue @@ -0,0 +1,179 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle" width="65%"> + <Form + ref="formRef" + v-loading="formLoading" + :isCol="true" + :rules="rules" + :schema="allSchemas.formSchema" + > + <!-- 先选择 --> + <!-- TODO @zhangshuai:商品允许选择多个 --> + <!-- TODO @zhangshuai:选择后的 SKU,需要后面加个【删除】按钮 --> + <!-- TODO @zhangshuai:展示的金额,貌似不对,大了 100 倍,需要看下 --> + <!-- TODO @zhangshuai:“优惠类型”,是每个 SKU 可以自定义已设置哈。因为每个商品 SKU 的折扣和减少价格,可能不同。具体交互,可以注册一个 youzan.com 看看;它的交互方式是,如果设置了“优惠金额”,则算“减价”;如果再次设置了“折扣百分比”,就算“打折”;这样形成一个互斥的优惠类型 --> + <template #spuId> + <el-button @click="spuSelectRef.open()">选择商品</el-button> + <SpuAndSkuList + ref="spuAndSkuListRef" + :rule-config="ruleConfig" + :spu-list="spuList" + :spu-property-list-p="spuPropertyList" + > + <el-table-column align="center" label="优惠金额" min-width="168"> + <template #default="{ row: sku }"> + <el-input-number v-model="sku.productConfig.discountPrice" :min="0" class="w-100%" /> + </template> + </el-table-column> + <el-table-column align="center" label="折扣百分比(%)" min-width="168"> + <template #default="{ row: sku }"> + <el-input-number v-model="sku.productConfig.discountPercent" class="w-100%" /> + </template> + </el-table-column> + </SpuAndSkuList> + </template> + </Form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> + <SpuSelect ref="spuSelectRef" :isSelectSku="true" @confirm="selectSpu" /> +</template> +<script lang="ts" setup> +import { SpuAndSkuList, SpuProperty, SpuSelect } from '../components' +import { allSchemas, rules } from './discountActivity.data' +import { cloneDeep } from 'lodash-es' +import * as DiscountActivityApi from '@/api/mall/promotion/discount/discountActivity' +import * as ProductSpuApi from '@/api/mall/product/spu' +import { getPropertyList, RuleConfig } from '@/views/mall/product/spu/components' + +defineOptions({ name: 'PromotionDiscountActivityForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formRef = ref() // 表单 Ref +// ================= 商品选择相关 ================= + +const spuSelectRef = ref() // 商品和属性选择 Ref +const spuAndSkuListRef = ref() // sku 限时折扣 配置组件Ref +const ruleConfig: RuleConfig[] = [] +const spuList = ref<DiscountActivityApi.SpuExtension[]>([]) // 选择的 spu +const spuPropertyList = ref<SpuProperty<DiscountActivityApi.SpuExtension>[]>([]) +const selectSpu = (spuId: number, skuIds: number[]) => { + formRef.value.setValues({ spuId }) + getSpuDetails(spuId, skuIds) +} +/** + * 获取 SPU 详情 + */ +const getSpuDetails = async ( + spuId: number, + skuIds: number[] | undefined, + products?: DiscountActivityApi.DiscountProductVO[] +) => { + const spuProperties: SpuProperty<DiscountActivityApi.SpuExtension>[] = [] + const res = (await ProductSpuApi.getSpuDetailList([spuId])) as DiscountActivityApi.SpuExtension[] + if (res.length == 0) { + return + } + spuList.value = [] + // 因为只能选择一个 + const spu = res[0] + const selectSkus = + typeof skuIds === 'undefined' ? spu?.skus : spu?.skus?.filter((sku) => skuIds.includes(sku.id!)) + selectSkus?.forEach((sku) => { + let config: DiscountActivityApi.DiscountProductVO = { + skuId: sku.id!, + spuId: spu.id, + discountType: 1, + discountPercent: 0, + discountPrice: 0 + } + if (typeof products !== 'undefined') { + const product = products.find((item) => item.skuId === sku.id) + config = product || config + } + sku.productConfig = config + }) + spu.skus = selectSkus as DiscountActivityApi.SkuExtension[] + spuProperties.push({ + spuId: spu.id!, + spuDetail: spu, + propertyList: getPropertyList(spu) + }) + spuList.value.push(spu) + spuPropertyList.value = spuProperties +} + +// ================= end ================= + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + await resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + const data = (await DiscountActivityApi.getDiscountActivity( + id + )) as DiscountActivityApi.DiscountActivityVO + const supId = data.products[0].spuId + await getSpuDetails(supId!, data.products?.map((sku) => sku.skuId), data.products) + formRef.value.setValues(data) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.getElFormRef().validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formRef.value.formModel as DiscountActivityApi.DiscountActivityVO + // 获取 折扣商品配置 + const products = cloneDeep(spuAndSkuListRef.value.getSkuConfigs('productConfig')) + products.forEach((item: DiscountActivityApi.DiscountProductVO) => { + item.discountType = data['discountType'] + }) + data.products = products + // 真正提交 + if (formType.value === 'create') { + await DiscountActivityApi.createDiscountActivity(data) + message.success(t('common.createSuccess')) + } else { + await DiscountActivityApi.updateDiscountActivity(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = async () => { + spuList.value = [] + spuPropertyList.value = [] + await nextTick() + formRef.value.getElFormRef().resetFields() +} +</script> diff --git a/src/views/mall/promotion/discountActivity/discountActivity.data.ts b/src/views/mall/promotion/discountActivity/discountActivity.data.ts new file mode 100644 index 00000000..d79dcab7 --- /dev/null +++ b/src/views/mall/promotion/discountActivity/discountActivity.data.ts @@ -0,0 +1,119 @@ +import type { CrudSchema } from '@/hooks/web/useCrudSchemas' +import { dateFormatter2 } from '@/utils/formatTime' + +// TODO @zhangshai: +// 表单校验 +export const rules = reactive({ + spuId: [required], + name: [required], + startTime: [required], + endTime: [required], + discountType: [required] +}) + +// CrudSchema https://doc.iocoder.cn/vue3/crud-schema/ +const crudSchemas = reactive<CrudSchema[]>([ + { + label: '活动名称', + field: 'name', + isSearch: true, + form: { + colProps: { + span: 24 + } + }, + table: { + width: 120 + } + }, + { + label: '活动开始时间', + field: 'startTime', + formatter: dateFormatter2, + isSearch: true, + search: { + component: 'DatePicker', + componentProps: { + valueFormat: 'YYYY-MM-DD', + type: 'daterange' + } + }, + form: { + component: 'DatePicker', + componentProps: { + type: 'date', + valueFormat: 'x' + } + }, + table: { + width: 120 + } + }, + { + label: '活动结束时间', + field: 'endTime', + formatter: dateFormatter2, + isSearch: true, + search: { + component: 'DatePicker', + componentProps: { + valueFormat: 'YYYY-MM-DD', + type: 'daterange' + } + }, + form: { + component: 'DatePicker', + componentProps: { + type: 'date', + valueFormat: 'x' + } + }, + table: { + width: 120 + } + }, + { + label: '优惠类型', + field: 'discountType', + dictType: DICT_TYPE.PROMOTION_DISCOUNT_TYPE, + dictClass: 'number', + isSearch: true, + form: { + component: 'Radio', + value: 1 + } + }, + { + label: '活动商品', + field: 'spuId', + isTable: true, + isSearch: false, + form: { + colProps: { + span: 24 + } + }, + table: { + width: 300 + } + }, + { + label: '备注', + field: 'remark', + isSearch: false, + form: { + component: 'Input', + componentProps: { + type: 'textarea', + rows: 4 + }, + colProps: { + span: 24 + } + }, + table: { + width: 300 + } + } +]) +export const { allSchemas } = useCrudSchemas(crudSchemas) diff --git a/src/views/mall/promotion/discountActivity/index.vue b/src/views/mall/promotion/discountActivity/index.vue new file mode 100644 index 00000000..b7dd5c20 --- /dev/null +++ b/src/views/mall/promotion/discountActivity/index.vue @@ -0,0 +1,237 @@ +<template> + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="活动名称" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入活动名称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="活动状态" prop="status"> + <el-select + v-model="queryParams.status" + placeholder="请选择活动状态" + clearable + class="!w-240px" + > + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="活动时间" prop="activeTime"> + <el-date-picker + v-model="queryParams.activeTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['promotion:discount-activity:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增活动 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="活动编号" prop="id" min-width="80" /> + <el-table-column label="活动名称" prop="name" min-width="140" /> + <el-table-column label="活动时间" min-width="210"> + <template #default="scope"> + {{ formatDate(scope.row.startTime, 'YYYY-MM-DD') }} + ~ {{ formatDate(scope.row.endTime, 'YYYY-MM-DD') }} + </template> + </el-table-column> + <el-table-column label="商品图片" prop="spuName" min-width="80"> + <template #default="scope"> + <el-image + :src="scope.row.picUrl" + class="h-40px w-40px" + :preview-src-list="[scope.row.picUrl]" + preview-teleported + /> + </template> + </el-table-column> + <el-table-column label="商品标题" prop="spuName" min-width="300" /> + <el-table-column label="活动状态" align="center" prop="status" min-width="100"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="操作" align="center" width="150px" fixed="right"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['promotion:discount-activity:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleClose(scope.row.id)" + v-if="scope.row.status === 0" + v-hasPermi="['promotion:discount-activity:close']" + > + 关闭 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-else + v-hasPermi="['promotion:discount-activity:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + <!-- 表单弹窗:添加/修改 --> + <DiscountActivityForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { dateFormatter } from '@/utils/formatTime' +import * as DiscountActivity from '@/api/mall/promotion/discount/discountActivity' +import DiscountActivityForm from './DiscountActivityForm.vue' +import { formatDate } from '@/utils/formatTime' +import { fenToYuanFormat } from '@/utils/formatter' +import { fenToYuan } from '@/utils' + +defineOptions({ name: 'DiscountActivity' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + activeTime: null, + name: null, + status: null +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await DiscountActivity.getDiscountActivityPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 关闭按钮操作 */ +const handleClose = async (id: number) => { + try { + // 关闭的二次确认 + await message.confirm('确认关闭该限时折扣活动吗?') + // 发起关闭 + await DiscountActivity.closeDiscountActivity(id) + message.success('关闭成功') + // 刷新列表 + await getList() + } catch {} +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await DiscountActivity.deleteDiscountActivity(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} + +const configList = ref([]) // 时段配置精简列表 +// const formatConfigNames = (configId) => { +// const config = configList.value.find((item) => item.id === configId) +// return config != null ? `${config.name}[${config.startTime} ~ ${config.endTime}]` : '' +// } + +const formatSeckillPrice = (products) => { + // const seckillPrice = Math.min(...products.map((item) => item.seckillPrice)) + console.log(products) + const seckillPrice = 200 + return `¥${fenToYuan(seckillPrice)}` +} + +/** 初始化 **/ +onMounted(async () => { + await getList() +}) +</script> diff --git a/src/views/mall/statistics/member/components/MemberFunnelCard.vue b/src/views/mall/statistics/member/components/MemberFunnelCard.vue new file mode 100644 index 00000000..fc847ef3 --- /dev/null +++ b/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> diff --git a/src/views/mall/statistics/member/components/MemberTerminalCard.vue b/src/views/mall/statistics/member/components/MemberTerminalCard.vue new file mode 100644 index 00000000..7bbab76c --- /dev/null +++ b/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> diff --git a/src/views/mall/statistics/member/index.vue b/src/views/mall/statistics/member/index.vue index 8dd7c593..2390be5e 100644 --- a/src/views/mall/statistics/member/index.vue +++ b/src/views/mall/statistics/member/index.vue @@ -2,7 +2,7 @@ <div class="flex flex-col"> <el-row :gutter="16" class="summary"> <el-col :sm="6" :xs="12" v-loading="loading"> - <TradeTrendValue + <SummaryCard title="累计会员数" icon="fa-solid:users" icon-color="bg-blue-100" @@ -11,7 +11,7 @@ /> </el-col> <el-col :sm="6" :xs="12" v-loading="loading"> - <TradeTrendValue + <SummaryCard title="累计充值人数" icon="fa-solid:user" icon-color="bg-purple-100" @@ -20,7 +20,7 @@ /> </el-col> <el-col :sm="6" :xs="12" v-loading="loading"> - <TradeTrendValue + <SummaryCard title="累计充值金额" icon="fa-solid:money-check-alt" icon-color="bg-yellow-100" @@ -31,7 +31,7 @@ /> </el-col> <el-col :sm="6" :xs="12" v-loading="loading"> - <TradeTrendValue + <SummaryCard title="累计消费金额" icon="fa-solid:yen-sign" icon-color="bg-green-100" @@ -44,118 +44,20 @@ </el-row> <el-row :gutter="16" class="mb-4"> <el-col :md="18" :sm="24"> - <el-card shadow="never"> - <template #header> - <div class="flex flex-row items-center justify-between"> - <span>会员概览</span> - <!-- 查询条件 --> - <div class="my--2 flex flex-row items-center gap-2"> - <el-radio-group v-model="shortcutDays" @change="handleDateTypeChange"> - <el-radio-button :label="1">昨天</el-radio-button> - <el-radio-button :label="7">最近7天</el-radio-button> - <el-radio-button :label="30">最近30天</el-radio-button> - </el-radio-group> - <el-date-picker - v-model="queryParams.times" - value-format="YYYY-MM-DD HH:mm:ss" - type="daterange" - start-placeholder="开始日期" - end-placeholder="结束日期" - :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" - :shortcuts="shortcuts" - class="!w-240px" - @change="getMemberAnalyse" - /> - </div> - </div> - </template> - <div class="min-w-225 py-1.75" v-loading="analyseLoading"> - <div class="relative h-24 flex"> - <div class="h-full w-75% bg-blue-50 <lg:w-35% <xl:w-55%"> - <div class="ml-15 h-full flex flex-col justify-center"> - <div class="font-bold"> - 注册用户数量:{{ analyseData?.comparison?.value?.userCount || 0 }} - </div> - <div class="mt-2 text-3.5"> - 环比增长率:{{ - calculateRelativeRate( - analyseData?.comparison?.value?.userCount, - analyseData?.comparison?.reference?.userCount - ) - }}% - </div> - </div> - </div> - <div - class="trapezoid1 ml--38.5 mt-1.5 h-full w-77 flex flex-col items-center justify-center bg-blue-5 text-3.5 text-white" - > - <span class="text-6 font-bold">{{ analyseData?.visitorCount || 0 }}</span> - <span>访客</span> - </div> - </div> - <div class="relative h-24 flex"> - <div class="h-full w-75% flex bg-cyan-50 <lg:w-35% <xl:w-55%"> - <div class="ml-15 h-full flex flex-col justify-center"> - <div class="font-bold"> - 活跃用户数量:{{ analyseData?.comparison?.value?.activeUserCount || 0 }} - </div> - <div class="mt-2 text-3.5"> - 环比增长率:{{ - calculateRelativeRate( - analyseData?.comparison?.value?.activeUserCount, - analyseData?.comparison?.reference?.activeUserCount - ) - }}% - </div> - </div> - </div> - <div - class="trapezoid2 ml--28 mt-1.7 h-25 w-56 flex flex-col items-center justify-center bg-cyan-5 text-3.5 text-white" - > - <span class="text-6 font-bold">{{ analyseData?.orderUserCount || 0 }}</span> - <span>下单</span> - </div> - </div> - <div class="relative h-24 flex"> - <div class="w-75% flex bg-slate-50 <lg:w-35% <xl:w-55%"> - <div class="ml-15 h-full flex flex-row gap-x-16"> - <div class="flex flex-col justify-center"> - <div class="font-bold"> - 充值用户数量:{{ analyseData?.comparison?.value?.rechargeUserCount || 0 }} - </div> - <div class="mt-2 text-3.5"> - 环比增长率:{{ - calculateRelativeRate( - analyseData?.comparison?.value?.rechargeUserCount, - analyseData?.comparison?.reference?.rechargeUserCount - ) - }}% - </div> - </div> - <div class="flex flex-col justify-center"> - <div class="font-bold">客单价:{{ fenToYuan(analyseData?.atv || 0) }}</div> - </div> - </div> - </div> - <div - class="trapezoid3 ml--18 mt-3.25 h-23 w-36 flex flex-col items-center justify-center bg-slate-5 text-3.5 text-white" - > - <span class="text-6 font-bold">{{ analyseData?.payUserCount || 0 }}</span> - <span>成交用户</span> - </div> - </div> - </div> - </el-card> + <!-- 会员概览 --> + <MemberFunnelCard /> </el-col> <el-col :md="6" :sm="24"> - <el-card shadow="never" header="会员终端" v-loading="loading"> - <Echart :height="300" :options="terminalChartOptions" /> - </el-card> + <!-- 会员终端 --> + <MemberTerminalCard /> </el-col> </el-row> <el-row :gutter="16"> <el-col :md="18" :sm="24"> - <el-card shadow="never" header="会员地域分布"> + <el-card shadow="never"> + <template #header> + <CardTitle title="会员地域分布" /> + </template> <el-row v-loading="loading"> <el-col :span="10"> <Echart :height="300" :options="areaChartOptions" /> @@ -180,14 +82,14 @@ /> <el-table-column label="订单创建数量" - prop="orderCreateCount" + prop="orderCreateUserCount" align="center" min-width="135" sortable /> <el-table-column label="订单支付数量" - prop="orderPayCount" + prop="orderPayUserCount" align="center" min-width="135" sortable @@ -206,7 +108,10 @@ </el-card> </el-col> <el-col :md="6" :sm="24"> - <el-card shadow="never" header="会员性别比例" v-loading="loading"> + <el-card shadow="never" v-loading="loading"> + <template #header> + <CardTitle title="会员性别比例" /> + </template> <Echart :height="300" :options="sexChartOptions" /> </el-card> </el-col> @@ -214,62 +119,33 @@ </div> </template> <script lang="ts" setup> -import * as TradeMemberApi from '@/api/mall/statistics/member' -import TradeTrendValue from '../trade/components/TradeTrendValue.vue' +import * as MemberStatisticsApi from '@/api/mall/statistics/member' +import SummaryCard from '@/components/SummaryCard/index.vue' import { EChartsOption } from 'echarts' import china from '@/assets/map/json/china.json' -import dayjs from 'dayjs' import { fenToYuan } from '@/utils' -import * as DateUtil from '@/utils/formatTime' import { - MemberAnalyseRespVO, MemberAreaStatisticsRespVO, MemberSexStatisticsRespVO, - MemberAnalyseReqVO, MemberSummaryRespVO, MemberTerminalStatisticsRespVO } from '@/api/mall/statistics/member' import { DICT_TYPE, DictDataType, getIntDictOptions } from '@/utils/dict' import echarts from '@/plugins/echarts' import { fenToYuanFormat } from '@/utils/formatter' +import MemberFunnelCard from './components/MemberFunnelCard.vue' +import MemberTerminalCard from './components/MemberTerminalCard.vue' +import { CardTitle } from '@/components/Card' /** 会员统计 */ defineOptions({ name: 'MemberStatistics' }) const loading = ref(true) // 加载中 -const analyseLoading = ref(true) // 会员概览加载中 -const queryParams = reactive<MemberAnalyseReqVO>({ times: ['', ''] }) // 会员概览查询参数 -const shortcutDays = ref(7) // 日期快捷天数(单选按钮组), 默认7天 const summary = ref<MemberSummaryRespVO>() // 会员统计数据 -const analyseData = ref<MemberAnalyseRespVO>() // 会员分析数据 const areaStatisticsList = shallowRef<MemberAreaStatisticsRespVO[]>() // 省份会员统计 // 注册地图 -echarts?.registerMap('china', china!) - -/** 日期快捷选择 */ -const shortcuts = [ - { - text: '昨天', - value: () => DateUtil.getDayRange(new Date(), -1) - }, - { - text: '最近7天', - value: () => DateUtil.getLast7Days() - }, - { - text: '本月', - value: () => [dayjs().startOf('M'), dayjs().subtract(1, 'd')] - }, - { - text: '最近30天', - value: () => DateUtil.getLast30Days() - }, - { - text: '最近1年', - value: () => DateUtil.getLast1Year() - } -] +echarts?.registerMap('china', china as any) /** 会员终端统计图配置 */ const terminalChartOptions = reactive<EChartsOption>({ @@ -331,8 +207,8 @@ const areaChartOptions = reactive<EChartsOption>({ formatter: (params: any) => { return `${params?.data?.areaName || params?.name}<br/> 会员数量:${params?.data?.userCount || 0}<br/> -订单创建数量:${params?.data?.orderCreateCount || 0}<br/> -订单支付数量:${params?.data?.orderPayCount || 0}<br/> +订单创建数量:${params?.data?.orderCreateUserCount || 0}<br/> +订单支付数量:${params?.data?.orderPayUserCount || 0}<br/> 订单支付金额:${fenToYuan(params?.data?.orderPayPrice || 0)}` } }, @@ -357,37 +233,14 @@ const areaChartOptions = reactive<EChartsOption>({ ] }) as EChartsOption -/** 计算环比 */ -const calculateRelativeRate = (value?: number, reference?: number) => { - // 防止除0 - if (!reference) return 0 - - return ((100 * ((value || 0) - reference)) / reference).toFixed(0) -} - -/** 设置时间范围 */ -function setTimes() { - const beginDate = dayjs().subtract(shortcutDays.value, 'd') - const yesterday = dayjs().subtract(1, 'd') - queryParams.times = DateUtil.getDateRange(beginDate, yesterday) -} - -/** 处理会员概览查询(日期单选按钮组选择后) */ -const handleDateTypeChange = async () => { - // 设置时间范围 - setTimes() - // 查询数据 - await getMemberAnalyse() -} - /** 查询会员统计 */ const getMemberSummary = async () => { - summary.value = await TradeMemberApi.getMemberSummary() + summary.value = await MemberStatisticsApi.getMemberSummary() } /** 按照省份,查询会员统计列表 */ const getMemberAreaStatisticsList = async () => { - const list = await TradeMemberApi.getMemberAreaStatisticsList() + const list = await MemberStatisticsApi.getMemberAreaStatisticsList() areaStatisticsList.value = list.map((item: MemberAreaStatisticsRespVO) => { return { ...item, @@ -401,20 +254,21 @@ const getMemberAreaStatisticsList = async () => { }) let min = 0 let max = 0 - areaChartOptions.series[0].data = areaStatisticsList.value.map((item) => { - min = Math.min(min, item.orderPayCount) - max = Math.max(max, item.orderPayCount) - return { ...item, name: item.areaName, value: item.orderPayCount || 0 } + areaChartOptions.series![0].data = areaStatisticsList.value.map((item) => { + min = Math.min(min, item.orderPayUserCount || 0) + max = Math.max(max, item.orderPayUserCount || 0) + return { ...item, name: item.areaName, value: item.orderPayUserCount || 0 } }) - areaChartOptions.visualMap.min = min - areaChartOptions.visualMap.max = max + areaChartOptions.visualMap!['min'] = min + areaChartOptions.visualMap!['max'] = max } /** 按照性别,查询会员统计列表 */ const getMemberSexStatisticsList = async () => { - const list = await TradeMemberApi.getMemberSexStatisticsList() + const list = await MemberStatisticsApi.getMemberSexStatisticsList() const dictDataList = getIntDictOptions(DICT_TYPE.SYSTEM_USER_SEX) - sexChartOptions.series[0].data = dictDataList.map((dictData: DictDataType) => { + dictDataList.push({ label: '未知', value: null } as any) + sexChartOptions.series![0].data = dictDataList.map((dictData: DictDataType) => { const userCount = list.find((item: MemberSexStatisticsRespVO) => item.sex === dictData.value) ?.userCount return { @@ -426,8 +280,9 @@ const getMemberSexStatisticsList = async () => { /** 按照终端,查询会员统计列表 */ const getMemberTerminalStatisticsList = async () => { - const list = await TradeMemberApi.getMemberTerminalStatisticsList() + const list = await MemberStatisticsApi.getMemberTerminalStatisticsList() const dictDataList = getIntDictOptions(DICT_TYPE.TERMINAL) + dictDataList.push({ label: '未知', value: null } as any) terminalChartOptions.series![0].data = dictDataList.map((dictData: DictDataType) => { const userCount = list.find( (item: MemberTerminalStatisticsRespVO) => item.terminal === dictData.value @@ -439,20 +294,6 @@ const getMemberTerminalStatisticsList = async () => { }) } -/** 查询会员概览数据列表 */ -const getMemberAnalyse = async () => { - analyseLoading.value = true - const times = queryParams.times - // 开始与截止在同一天的, 环比出不来, 需要延长一天 - if (DateUtil.isSameDay(times[0], times[1])) { - // 前天 - times[0] = DateUtil.formatDate(dayjs(times[0]).subtract(1, 'd')) - } - // 查询数据 - analyseData.value = await TradeMemberApi.getMemberAnalyse({ times }) - analyseLoading.value = false -} - /** 初始化 **/ onMounted(async () => { loading.value = true @@ -460,8 +301,7 @@ onMounted(async () => { getMemberSummary(), getMemberTerminalStatisticsList(), getMemberAreaStatisticsList(), - getMemberSexStatisticsList(), - handleDateTypeChange() + getMemberSexStatisticsList() ]) loading.value = false }) @@ -472,16 +312,4 @@ onMounted(async () => { margin-bottom: 1rem; } } - -.trapezoid1 { - transform: perspective(5em) rotateX(-11deg); -} - -.trapezoid2 { - transform: perspective(7em) rotateX(-20deg); -} - -.trapezoid3 { - transform: perspective(3em) rotateX(-13deg); -} </style> diff --git a/src/views/mall/statistics/trade/index.vue b/src/views/mall/statistics/trade/index.vue index 8fc2d0cb..e89f0cc3 100644 --- a/src/views/mall/statistics/trade/index.vue +++ b/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> diff --git a/src/views/mall/trade/delivery/pickUpOrder/index.vue b/src/views/mall/trade/delivery/pickUpOrder/index.vue new file mode 100644 index 00000000..8eb80072 --- /dev/null +++ b/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> diff --git a/src/views/mall/trade/order/detail/index.vue b/src/views/mall/trade/order/detail/index.vue index 7d0a88ac..38b9342e 100644 --- a/src/views/mall/trade/order/detail/index.vue +++ b/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 // 没有表单信息则关闭页面返回 diff --git a/src/views/mall/trade/order/form/OrderPickUpForm.vue b/src/views/mall/trade/order/form/OrderPickUpForm.vue new file mode 100644 index 00000000..529263c4 --- /dev/null +++ b/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> diff --git a/src/views/mall/trade/order/index.vue b/src/views/mall/trade/order/index.vue index 33d98548..4a85a30e 100644 --- a/src/views/mall/trade/order/index.vue +++ b/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 diff --git a/src/views/member/user/detail/UserAccountInfo.vue b/src/views/member/user/detail/UserAccountInfo.vue index 13daff48..56a6ab63 100644 --- a/src/views/member/user/detail/UserAccountInfo.vue +++ b/src/views/member/user/detail/UserAccountInfo.vue @@ -47,7 +47,7 @@ <script setup lang="ts"> import { DescriptionsItemLabel } from '@/components/Descriptions' import * as UserApi from '@/api/member/user' -import * as WalletApi from '@/api/pay/wallet' +import * as WalletApi from '@/api/pay/wallet/balance' import { UserTypeEnum } from '@/utils/constants' import { fenToYuan } from '@/utils' @@ -65,7 +65,7 @@ const getUserWallet = async () => { wallet.value = WALLET_INIT_DATA return } - const params = { userId: props.user.id, userType: UserTypeEnum.MEMBER } + const params = { userId: props.user.id } wallet.value = (await WalletApi.getWallet(params)) || WALLET_INIT_DATA } diff --git a/src/views/pay/app/components/channel/AlipayChannelForm.vue b/src/views/pay/app/components/channel/AlipayChannelForm.vue index 46dc4312..169ef8ea 100644 --- a/src/views/pay/app/components/channel/AlipayChannelForm.vue +++ b/src/views/pay/app/components/channel/AlipayChannelForm.vue @@ -69,6 +69,16 @@ </el-form-item> </div> <div v-if="formData.config.mode === 2"> + <el-form-item label-width="180px" label="应用私钥" prop="config.privateKey"> + <el-input + type="textarea" + :autosize="{ minRows: 8, maxRows: 8 }" + v-model="formData.config.privateKey" + placeholder="请输入应用私钥" + clearable + :style="{ width: '100%' }" + /> + </el-form-item> <el-form-item label-width="180px" label="商户公钥应用证书" prop="config.appCertContent"> <el-input v-model="formData.config.appCertContent" diff --git a/src/views/pay/wallet/balance/WalletForm.vue b/src/views/pay/wallet/balance/WalletForm.vue new file mode 100644 index 00000000..8173e123 --- /dev/null +++ b/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> diff --git a/src/views/pay/wallet/balance/index.vue b/src/views/pay/wallet/balance/index.vue new file mode 100644 index 00000000..296b567b --- /dev/null +++ b/src/views/pay/wallet/balance/index.vue @@ -0,0 +1,149 @@ +<template> + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="用户昵称" prop="nickname"> + <el-input + v-model="queryParams.nickname" + placeholder="请输入用户昵称" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="编号" align="center" prop="id" /> + <el-table-column label="用户昵称" align="center" prop="nickname" /> + <el-table-column label="头像" align="center" prop="avatar" width="80px"> + <template #default="scope"> + <img :src="scope.row.avatar" style="width: 40px" /> + </template> + </el-table-column> + <el-table-column label="用户类型" align="center" prop="userType"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.USER_TYPE" :value="scope.row.userType" /> + </template> + </el-table-column> + <el-table-column label="余额" align="center" prop="balance"> + <template #default="{ row }"> {{ fenToYuan(row.balance) }} 元</template> + </el-table-column> + <el-table-column label="累计支出" align="center" prop="totalExpense"> + <template #default="{ row }"> {{ fenToYuan(row.totalExpense) }} 元</template> + </el-table-column> + <el-table-column label="累计充值" align="center" prop="totalRecharge"> + <template #default="{ row }"> {{ fenToYuan(row.totalRecharge) }} 元</template> + </el-table-column> + <el-table-column label="冻结金额" align="center" prop="freezePrice"> + <template #default="{ row }"> {{ fenToYuan(row.freezePrice) }} 元</template> + </el-table-column> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button link type="primary" @click="openForm(scope.row.id)">详情</el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 弹窗 --> + <WalletForm ref="formRef" /> +</template> + +<script setup lang="ts"> +import { dateFormatter } from '@/utils/formatTime' +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { fenToYuan } from '@/utils' +import * as WalletApi from '@/api/pay/wallet/balance' +import WalletForm from './WalletForm.vue' + +defineOptions({ name: 'WalletBalance' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + nickname: null, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 +const exportLoading = ref(false) // 导出的加载中 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await WalletApi.getWalletPage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (id?: number) => { + formRef.value.open(id) +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/pay/wallet/rechargePackage/WalletRechargePackageForm.vue b/src/views/pay/wallet/rechargePackage/WalletRechargePackageForm.vue new file mode 100644 index 00000000..f538b78c --- /dev/null +++ b/src/views/pay/wallet/rechargePackage/WalletRechargePackageForm.vue @@ -0,0 +1,122 @@ +<template> + <Dialog :title="dialogTitle" v-model="dialogVisible"> + <el-form + ref="formRef" + :model="formData" + :rules="formRules" + label-width="150px" + v-loading="formLoading" + > + <el-form-item label="套餐名" prop="name"> + <el-input v-model="formData.name" placeholder="请输入套餐名" /> + </el-form-item> + <el-form-item label="支付金额(元)" prop="payPrice"> + <el-input-number v-model="formData.payPrice" :min="0" :precision="2" :step="0.01" /> + </el-form-item> + <el-form-item label="赠送金额(元)" prop="bonusPrice"> + <el-input-number v-model="formData.bonusPrice" :min="0" :precision="2" :step="0.01" /> + </el-form-item> + <el-form-item label="开启状态" prop="status"> + <el-radio-group v-model="formData.status"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.value" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + </el-form> + <template #footer> + <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script setup lang="ts"> +import * as WalletRechargePackageApi from '@/api/pay/wallet/rechargePackage' +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import { fenToYuan, yuanToFen } from '@/utils' +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formData = ref({ + id: undefined, + name: undefined, + payPrice: undefined, + bonusPrice: undefined, + status: undefined +}) +const formRules = reactive({ + name: [{ required: true, message: '套餐名不能为空', trigger: 'blur' }], + payPrice: [{ required: true, message: '支付金额不能为空', trigger: 'blur' }], + bonusPrice: [{ required: true, message: '赠送金额不能为空', trigger: 'blur' }], + status: [{ required: true, message: '状态不能为空', trigger: 'blur' }] +}) +const formRef = ref() // 表单 Ref + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + formData.value = await WalletRechargePackageApi.getWalletRechargePackage(id) + formData.value.payPrice = fenToYuan(formData.value.payPrice) + formData.value.bonusPrice = fenToYuan(formData.value.bonusPrice) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formData.value as unknown as WalletRechargePackageApi.WalletRechargePackageVO + data.payPrice = yuanToFen(data.payPrice) + data.bonusPrice = yuanToFen(data.bonusPrice) + if (formType.value === 'create') { + await WalletRechargePackageApi.createWalletRechargePackage(data) + message.success(t('common.createSuccess')) + } else { + await WalletRechargePackageApi.updateWalletRechargePackage(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} + +/** 重置表单 */ +const resetForm = () => { + formData.value = { + id: undefined, + name: undefined, + payPrice: undefined, + bonusPrice: undefined, + status: undefined + } + formRef.value?.resetFields() +} +</script> diff --git a/src/views/pay/wallet/rechargePackage/index.vue b/src/views/pay/wallet/rechargePackage/index.vue new file mode 100644 index 00000000..f097577c --- /dev/null +++ b/src/views/pay/wallet/rechargePackage/index.vue @@ -0,0 +1,185 @@ +<template> + <ContentWrap> + <!-- 搜索工作栏 --> + <el-form + class="-mb-15px" + :model="queryParams" + ref="queryFormRef" + :inline="true" + label-width="68px" + > + <el-form-item label="套餐名" prop="name"> + <el-input + v-model="queryParams.name" + placeholder="请输入套餐名" + clearable + @keyup.enter="handleQuery" + class="!w-240px" + /> + </el-form-item> + <el-form-item label="状态" prop="status"> + <el-select v-model="queryParams.status" placeholder="请选择状态" clearable class="!w-240px"> + <el-option + v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" + :key="dict.value" + :label="dict.label" + :value="dict.value" + /> + </el-select> + </el-form-item> + <el-form-item label="创建时间" prop="createTime"> + <el-date-picker + v-model="queryParams.createTime" + value-format="YYYY-MM-DD HH:mm:ss" + type="daterange" + start-placeholder="开始日期" + end-placeholder="结束日期" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + /> + </el-form-item> + <el-form-item> + <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button> + <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button> + <el-button + type="primary" + plain + @click="openForm('create')" + v-hasPermi="['pay:wallet-recharge-package:create']" + > + <Icon icon="ep:plus" class="mr-5px" /> 新增 + </el-button> + </el-form-item> + </el-form> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"> + <el-table-column label="编号" align="center" prop="id" /> + <el-table-column label="套餐名" align="center" prop="name" /> + <el-table-column label="支付金额" align="center" prop="payPrice"> + <template #default="{ row }"> {{ fenToYuan(row.payPrice) }} 元</template> + </el-table-column> + <el-table-column label="赠送金额" align="center" prop="bonusPrice"> + <template #default="{ row }"> {{ fenToYuan(row.bonusPrice) }} 元</template> + </el-table-column> + <el-table-column label="状态" align="center" prop="status"> + <template #default="scope"> + <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" /> + </template> + </el-table-column> + <el-table-column + label="创建时间" + align="center" + prop="createTime" + :formatter="dateFormatter" + width="180px" + /> + <el-table-column label="操作" align="center"> + <template #default="scope"> + <el-button + link + type="primary" + @click="openForm('update', scope.row.id)" + v-hasPermi="['pay:wallet-recharge-package:update']" + > + 编辑 + </el-button> + <el-button + link + type="danger" + @click="handleDelete(scope.row.id)" + v-hasPermi="['pay:wallet-recharge-package:delete']" + > + 删除 + </el-button> + </template> + </el-table-column> + </el-table> + <!-- 分页 --> + <Pagination + :total="total" + v-model:page="queryParams.pageNo" + v-model:limit="queryParams.pageSize" + @pagination="getList" + /> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <WalletRechargePackageForm ref="formRef" @success="getList" /> +</template> + +<script setup lang="ts"> +import { dateFormatter } from '@/utils/formatTime' +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' +import * as WalletRechargePackageApi from '@/api/pay/wallet/rechargePackage' +import WalletRechargePackageForm from './WalletRechargePackageForm.vue' +import { fenToYuan } from '@/utils' + +defineOptions({ name: 'WalletRechargePackage' }) + +const message = useMessage() // 消息弹窗 +const { t } = useI18n() // 国际化 + +const loading = ref(true) // 列表的加载中 +const total = ref(0) // 列表的总页数 +const list = ref([]) // 列表的数据 +const queryParams = reactive({ + pageNo: 1, + pageSize: 10, + name: null, + payPrice: null, + bonusPrice: null, + status: null, + createTime: [] +}) +const queryFormRef = ref() // 搜索的表单 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await WalletRechargePackageApi.getWalletRechargePackagePage(queryParams) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} + +/** 搜索按钮操作 */ +const handleQuery = () => { + queryParams.pageNo = 1 + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryFormRef.value.resetFields() + handleQuery() +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = async (id: number) => { + try { + // 删除的二次确认 + await message.delConfirm() + // 发起删除 + await WalletRechargePackageApi.deleteWalletRechargePackage(id) + message.success(t('common.delSuccess')) + // 刷新列表 + await getList() + } catch {} +} +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/pay/wallet/transaction/WalletTransactionList.vue b/src/views/pay/wallet/transaction/WalletTransactionList.vue new file mode 100644 index 00000000..c440778b --- /dev/null +++ b/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> diff --git a/src/views/system/notify/template/NotifyTemplateSendForm.vue b/src/views/system/notify/template/NotifyTemplateSendForm.vue index 78103d71..964f6b98 100644 --- a/src/views/system/notify/template/NotifyTemplateSendForm.vue +++ b/src/views/system/notify/template/NotifyTemplateSendForm.vue @@ -15,7 +15,21 @@ type="textarea" /> </el-form-item> - <el-form-item label="接收人" prop="userId"> + <el-form-item label="用户类型" prop="userType"> + <el-radio-group v-model="formData.userType"> + <el-radio + v-for="dict in getIntDictOptions(DICT_TYPE.USER_TYPE)" + :key="dict.value" + :label="dict.value as number" + > + {{ dict.label }} + </el-radio> + </el-radio-group> + </el-form-item> + <el-form-item v-show="formData.userType === 1" label="接收人ID" prop="userId"> + <el-input v-model="formData.userId" style="width: 160px" /> + </el-form-item> + <el-form-item v-show="formData.userType === 2" label="接收人" prop="userId"> <el-select v-model="formData.userId" placeholder="请选择接收人"> <el-option v-for="item in userOption" @@ -46,6 +60,7 @@ <script lang="ts" setup> import * as UserApi from '@/api/system/user' import * as NotifyTemplateApi from '@/api/system/notify/template' +import { DICT_TYPE, getIntDictOptions } from '@/utils/dict' defineOptions({ name: 'SystemNotifyTemplateSendForm' }) @@ -57,6 +72,7 @@ const formData = ref({ content: '', params: {}, userId: null, + userType: 1, templateCode: '', templateParams: new Map() }) @@ -122,7 +138,8 @@ const resetForm = () => { params: {}, mobile: '', templateCode: '', - templateParams: new Map() + templateParams: new Map(), + userType: 1 } as any formRef.value?.resetFields() }