| | |
| | | (1336, 1300, '用户意见反馈', 'system_user_feedback', 'C', '/system/settings/user-feedback', 'System/UserFeedback', NULL, 18, 1, 1, 'system:user-feedback:view', '用户意见反馈菜单', 0), |
| | | (1310, 1300, '积分设置', 'system_points_settings', 'C', '/system/settings/points', 'System/PointsSettings', NULL, 10, 1, 1, 'system:points-settings:view', '积分设置菜单', 0), |
| | | (1311, 1310, '编辑积分设置', 'system_points_settings_edit', 'B', NULL, NULL, NULL, 1, 1, 1, 'system:points-settings:edit', '积分设置编辑按钮', 0), |
| | | (5000, 0, '统计', 'statistics_root', 'M', '/statistics', NULL, 'barChart', 150, 1, 1, NULL, '统计模块', 0), |
| | | (5030, 5000, '营收数据', 'statistics_revenue', 'C', '/statistics/revenue', 'Statistics/Revenue', NULL, 30, 1, 1, 'statistics:revenue:view', '营收数据菜单', 0), |
| | | (5031, 5030, '导出营收报表', 'statistics_revenue_export', 'B', NULL, NULL, NULL, 1, 1, 1, 'statistics:revenue:export', '营收数据导出按钮', 0), |
| | | (2000, 0, '用户与会员', 'member_root', 'M', '/member', NULL, 'team', 200, 1, 1, NULL, '用户与会员模块', 0), |
| | | (2100, 2000, '用户', 'member_user_group', 'M', '/member/user', NULL, NULL, 10, 1, 1, NULL, '用户分组菜单', 0), |
| | | (2110, 2100, '用户管理', 'member_user_manage', 'C', '/member/user/manage', 'Member/UserList', NULL, 10, 1, 1, 'member:user:view', '用户管理菜单', 0), |
| | |
| | | VALUES |
| | | (1, 1000), (1, 1100), (1, 1101), (1, 1102), (1, 1103), (1, 1104), (1, 1105), |
| | | (1, 1200), (1, 1201), (1, 1202), (1, 1203), (1, 1204), (1, 1300), (1, 1305), (1, 1306), (1, 1307), (1, 1308), (1, 1309), (1, 1310), (1, 1311), (1, 1312), (1, 1313), (1, 1314), (1, 1315), (1, 1316), (1, 1317), (1, 1318), (1, 1319), (1, 1320), (1, 1321), (1, 1322), (1, 1323), (1, 1324), (1, 1325), (1, 1326), (1, 1327), (1, 1328), (1, 1329), (1, 1330), (1, 1331), (1, 1332), (1, 1333), (1, 1334), (1, 1335), (1, 1336), |
| | | (1, 5000), (1, 5030), (1, 5031), |
| | | (1, 2000), (1, 2100), (1, 2110), (1, 2111), (1, 2112), (1, 2113), (1, 2114), (1, 2115), (1, 2116), (1, 2117), (1, 2118), (1, 2119), |
| | | (1, 2200), (1, 2210), (1, 2211), (1, 2220), (1, 2221), |
| | | (1, 3000), (1, 3050), (1, 3060), (1, 3061), (1, 3062), (1, 3063), |
| New file |
| | |
| | | -- 最近一周营收统计 mock 数据(2026-03-20 ~ 2026-03-26) |
| | | -- 用途:给「统计-营收数据」页面提供最近 7 天的支付/退款波动数据 |
| | | -- 说明:只插入订单表,支付流水会在查询统计接口时由 syncFlows() 自动补齐 |
| | | |
| | | INSERT IGNORE INTO `tcm_course_purchase_order` |
| | | (`order_no`, `app_user_id`, `user_nickname`, `user_phone`, `course_type`, `purchase_target_type`, `course_id`, |
| | | `course_video_id`, `product_name_snapshot`, `course_title_snapshot`, `chapter_name_snapshot`, `cover_img_snapshot`, |
| | | `order_amount`, `payment_method`, `refund_amount`, `refund_remark`, `status`, `paid_at`, `refunded_at`, `cancelled_at`, `deleted`) |
| | | VALUES |
| | | ('CPO202603200001', 200, '王美丽', '14712346905', 'HEALTH_COURSE', 'COURSE', 1, |
| | | NULL, '中医针灸资格证', '中医针灸资格证', NULL, 'https://example.com/health/course-1-cover.png', |
| | | 299.00, 'WECHAT', NULL, NULL, 'PAID', '2026-03-20 10:15:00', NULL, NULL, 0), |
| | | ('CPO202603210001', 200, '王美丽', '14712346905', 'HEALTH_COURSE', 'COURSE_CHAPTER', 1, |
| | | 1, '中医针灸资格证-方剂学', '中医针灸资格证', '方剂学', 'https://example.com/health/course-1-cover.png', |
| | | 199.00, 'ALIPAY', NULL, NULL, 'PAID', '2026-03-21 14:20:00', NULL, NULL, 0), |
| | | ('CPO202603220001', 200, '王美丽', '14712346905', 'HEALTH_COURSE', 'COURSE', 1, |
| | | NULL, '中医针灸资格证', '中医针灸资格证', NULL, 'https://example.com/health/course-1-cover.png', |
| | | 399.00, 'WECHAT', NULL, NULL, 'PAID', '2026-03-22 11:08:00', NULL, NULL, 0), |
| | | ('CPO202603230001', 200, '王美丽', '14712346905', 'HEALTH_COURSE', 'COURSE_CHAPTER', 1, |
| | | 1, '中医针灸资格证-方剂学', '中医针灸资格证', '方剂学', 'https://example.com/health/course-1-cover.png', |
| | | 159.00, 'WECHAT', 59.00, '用户误购申请部分退款', 'REFUNDED', '2026-03-23 09:36:00', '2026-03-23 18:10:00', NULL, 0), |
| | | ('CPO202603250001', 200, '王美丽', '14712346905', 'HEALTH_COURSE', 'COURSE', 1, |
| | | NULL, '中医针灸资格证', '中医针灸资格证', NULL, 'https://example.com/health/course-1-cover.png', |
| | | 259.00, 'ALIPAY', NULL, NULL, 'PAID', '2026-03-25 16:42:00', NULL, NULL, 0), |
| | | ('CPO202603260001', 200, '王美丽', '14712346905', 'HEALTH_COURSE', 'COURSE', 1, |
| | | NULL, '中医针灸资格证', '中医针灸资格证', NULL, 'https://example.com/health/course-1-cover.png', |
| | | 329.00, 'WECHAT', NULL, NULL, 'PAID', '2026-03-26 13:25:00', NULL, NULL, 0); |
| | | |
| | | INSERT IGNORE INTO `tcm_member_purchase_order` |
| | | (`order_no`, `app_user_id`, `user_nickname`, `user_phone`, `member_type`, `member_days`, `order_amount`, |
| | | `payment_method`, `refund_amount`, `refund_remark`, `status`, `paid_at`, `refunded_at`, `cancelled_at`, `deleted`) |
| | | VALUES |
| | | ('MPO202603200001', 200, '王美丽', '14712346905', 'MONTH_CARD', 30, 69.00, |
| | | 'ALIPAY', NULL, NULL, 'PAID', '2026-03-20 19:30:00', NULL, NULL, 0), |
| | | ('MPO202603210001', 200, '王美丽', '14712346905', 'QUARTER_CARD', 90, 199.00, |
| | | 'WECHAT', NULL, NULL, 'PAID', '2026-03-21 20:10:00', NULL, NULL, 0), |
| | | ('MPO202603220001', 200, '王美丽', '14712346905', 'MONTH_CARD', 30, 69.00, |
| | | 'WECHAT', 69.00, '重复购买退款', 'REFUNDED', '2026-03-22 15:00:00', '2026-03-22 17:30:00', NULL, 0), |
| | | ('MPO202603240001', 200, '王美丽', '14712346905', 'QUARTER_CARD', 90, 199.00, |
| | | 'ALIPAY', NULL, NULL, 'PAID', '2026-03-24 12:18:00', NULL, NULL, 0), |
| | | ('MPO202603250001', 200, '王美丽', '14712346905', 'MONTH_CARD', 30, 69.00, |
| | | 'WECHAT', NULL, NULL, 'PAID', '2026-03-25 09:55:00', NULL, NULL, 0), |
| | | ('MPO202603260001', 200, '王美丽', '14712346905', 'QUARTER_CARD', 90, 199.00, |
| | | 'ALIPAY', 199.00, '用户申请退款', 'REFUNDED', '2026-03-26 08:40:00', '2026-03-26 18:20:00', NULL, 0); |
| New file |
| | |
| | | INSERT IGNORE INTO `sys_menu` |
| | | (`id`, `parent_id`, `name`, `menu_code`, `type`, `path`, `component`, `icon`, `sort`, `status`, `visible`, `permission_key`, `remark`, `deleted`) |
| | | VALUES |
| | | (5000, 0, '统计', 'statistics_root', 'M', '/statistics', NULL, 'barChart', 150, 1, 1, NULL, '统计模块', 0), |
| | | (5030, 5000, '营收数据', 'statistics_revenue', 'C', '/statistics/revenue', 'Statistics/Revenue', NULL, 30, 1, 1, 'statistics:revenue:view', '营收数据菜单', 0), |
| | | (5031, 5030, '导出营收报表', 'statistics_revenue_export', 'B', NULL, NULL, NULL, 1, 1, 1, 'statistics:revenue:export', '营收数据导出按钮', 0); |
| | | |
| | | INSERT IGNORE INTO `sys_role_menu` (`role_id`, `menu_id`) |
| | | VALUES |
| | | (1, 5000), (1, 5030), (1, 5031); |
| | |
| | | "/system/settings/points", "System/PointsSettings", null, 10, "system:points-settings:view", "积分设置菜单")); |
| | | menuIds.add(saveMenu(1311L, 1310L, "编辑积分设置", "system_points_settings_edit", "B", |
| | | null, null, null, 1, "system:points-settings:edit", "积分设置编辑按钮")); |
| | | menuIds.add(saveMenu(5000L, 0L, "统计", "statistics_root", "M", |
| | | "/statistics", null, "barChart", 150, null, "统计模块")); |
| | | menuIds.add(saveMenu(5030L, 5000L, "营收数据", "statistics_revenue", "C", |
| | | "/statistics/revenue", "Statistics/Revenue", null, 30, "statistics:revenue:view", "营收数据菜单")); |
| | | menuIds.add(saveMenu(5031L, 5030L, "导出营收报表", "statistics_revenue_export", "B", |
| | | null, null, null, 1, "statistics:revenue:export", "营收数据导出按钮")); |
| | | menuIds.add(saveMenu(2000L, 0L, "用户与会员", "member_root", "M", |
| | | "/member", null, "team", 200, null, "用户与会员模块")); |
| | | menuIds.add(saveMenu(2100L, 2000L, "用户", "member_user_group", "M", |
| New file |
| | |
| | | package com.tcm.training.admin.statistics.controller; |
| | | |
| | | import com.tcm.training.admin.common.ApiResponse; |
| | | import com.tcm.training.admin.statistics.service.RevenueStatisticsAppService; |
| | | import com.tcm.training.admin.statistics.vo.RevenueStatisticsVO; |
| | | import org.springframework.format.annotation.DateTimeFormat; |
| | | import org.springframework.validation.annotation.Validated; |
| | | import org.springframework.web.bind.annotation.GetMapping; |
| | | import org.springframework.web.bind.annotation.RequestMapping; |
| | | import org.springframework.web.bind.annotation.RequestParam; |
| | | import org.springframework.web.bind.annotation.RestController; |
| | | |
| | | import javax.servlet.http.HttpServletResponse; |
| | | import java.io.IOException; |
| | | import java.time.LocalDate; |
| | | |
| | | @Validated |
| | | @RestController |
| | | @RequestMapping({"/api/v1", "/api"}) |
| | | public class RevenueStatisticsController { |
| | | |
| | | private final RevenueStatisticsAppService revenueStatisticsAppService; |
| | | |
| | | public RevenueStatisticsController(RevenueStatisticsAppService revenueStatisticsAppService) { |
| | | this.revenueStatisticsAppService = revenueStatisticsAppService; |
| | | } |
| | | |
| | | @GetMapping("/revenue-statistics") |
| | | public ApiResponse<RevenueStatisticsVO> statistics( |
| | | @RequestParam(required = false) String type, |
| | | @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate dateFrom, |
| | | @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate dateTo) { |
| | | return ApiResponse.ok(revenueStatisticsAppService.statistics(type, dateFrom, dateTo)); |
| | | } |
| | | |
| | | @GetMapping("/revenue-statistics/export") |
| | | public void export(HttpServletResponse response, |
| | | @RequestParam(required = false) String type, |
| | | @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate dateFrom, |
| | | @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate dateTo) throws IOException { |
| | | revenueStatisticsAppService.export(response, type, dateFrom, dateTo); |
| | | } |
| | | } |
| New file |
| | |
| | | package com.tcm.training.admin.statistics.excel; |
| | | |
| | | import cn.idev.excel.annotation.ExcelProperty; |
| | | import lombok.Getter; |
| | | import lombok.NoArgsConstructor; |
| | | import lombok.Setter; |
| | | |
| | | @Getter |
| | | @Setter |
| | | @NoArgsConstructor |
| | | public class RevenueStatisticsExcelRow { |
| | | |
| | | @ExcelProperty("日期") |
| | | private String date; |
| | | |
| | | @ExcelProperty("营收") |
| | | private String totalIncome; |
| | | |
| | | @ExcelProperty("养生") |
| | | private String healthIncome; |
| | | |
| | | @ExcelProperty("会员") |
| | | private String memberIncome; |
| | | |
| | | @ExcelProperty("退款金额") |
| | | private String refundAmount; |
| | | |
| | | @ExcelProperty("利润") |
| | | private String profit; |
| | | } |
| New file |
| | |
| | | package com.tcm.training.admin.statistics.mapper; |
| | | |
| | | import com.tcm.training.admin.statistics.row.RevenueStatisticsDailyRow; |
| | | import org.apache.ibatis.annotations.Mapper; |
| | | import org.apache.ibatis.annotations.Param; |
| | | |
| | | import java.time.LocalDateTime; |
| | | import java.util.List; |
| | | |
| | | @Mapper |
| | | public interface RevenueStatisticsMapper { |
| | | |
| | | List<RevenueStatisticsDailyRow> selectDailyStatistics(@Param("businessType") String businessType, |
| | | @Param("startTime") LocalDateTime startTime, |
| | | @Param("endTime") LocalDateTime endTime); |
| | | } |
| New file |
| | |
| | | package com.tcm.training.admin.statistics.row; |
| | | |
| | | import lombok.Getter; |
| | | import lombok.NoArgsConstructor; |
| | | import lombok.Setter; |
| | | |
| | | import java.math.BigDecimal; |
| | | import java.time.LocalDate; |
| | | |
| | | @Getter |
| | | @Setter |
| | | @NoArgsConstructor |
| | | public class RevenueStatisticsDailyRow { |
| | | |
| | | private LocalDate statDate; |
| | | private BigDecimal healthIncome; |
| | | private BigDecimal memberIncome; |
| | | private BigDecimal healthRefundAmount; |
| | | private BigDecimal memberRefundAmount; |
| | | private BigDecimal refundAmount; |
| | | } |
| New file |
| | |
| | | package com.tcm.training.admin.statistics.service; |
| | | |
| | | import cn.idev.excel.FastExcel; |
| | | import com.tcm.training.admin.trade.service.PaymentFlowAppService; |
| | | import com.tcm.training.admin.statistics.excel.RevenueStatisticsExcelRow; |
| | | import com.tcm.training.admin.statistics.mapper.RevenueStatisticsMapper; |
| | | import com.tcm.training.admin.statistics.row.RevenueStatisticsDailyRow; |
| | | import com.tcm.training.admin.statistics.vo.RevenueStatisticsOverviewVO; |
| | | import com.tcm.training.admin.statistics.vo.RevenueStatisticsTrendItemVO; |
| | | import com.tcm.training.admin.statistics.vo.RevenueStatisticsVO; |
| | | import org.springframework.stereotype.Service; |
| | | |
| | | import javax.servlet.http.HttpServletResponse; |
| | | import java.io.IOException; |
| | | import java.math.BigDecimal; |
| | | import java.math.RoundingMode; |
| | | import java.net.URLEncoder; |
| | | import java.time.LocalDate; |
| | | import java.time.LocalDateTime; |
| | | import java.time.LocalTime; |
| | | import java.util.ArrayList; |
| | | import java.util.HashMap; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | |
| | | @Service |
| | | public class RevenueStatisticsAppService { |
| | | |
| | | private static final String FILTER_ALL = "ALL"; |
| | | private static final String FILTER_HEALTH = "HEALTH"; |
| | | private static final String FILTER_MEMBER = "MEMBER"; |
| | | private static final String BUSINESS_TYPE_HEALTH = "COURSE_PURCHASE"; |
| | | private static final String BUSINESS_TYPE_MEMBER = "MEMBER_PURCHASE"; |
| | | |
| | | private final RevenueStatisticsMapper revenueStatisticsMapper; |
| | | private final PaymentFlowAppService paymentFlowAppService; |
| | | |
| | | public RevenueStatisticsAppService(RevenueStatisticsMapper revenueStatisticsMapper, |
| | | PaymentFlowAppService paymentFlowAppService) { |
| | | this.revenueStatisticsMapper = revenueStatisticsMapper; |
| | | this.paymentFlowAppService = paymentFlowAppService; |
| | | } |
| | | |
| | | public RevenueStatisticsVO statistics(String type, LocalDate dateFrom, LocalDate dateTo) { |
| | | DateRange range = normalizeRange(dateFrom, dateTo); |
| | | String normalizedType = normalizeType(type); |
| | | |
| | | paymentFlowAppService.syncFlows(); |
| | | |
| | | List<RevenueStatisticsDailyRow> rows = revenueStatisticsMapper.selectDailyStatistics( |
| | | resolveBusinessType(normalizedType), |
| | | range.getStart().atStartOfDay(), |
| | | LocalDateTime.of(range.getEnd(), LocalTime.MAX) |
| | | ); |
| | | List<RevenueStatisticsTrendItemVO> trend = buildTrend(rows, range); |
| | | |
| | | RevenueStatisticsVO result = new RevenueStatisticsVO(); |
| | | result.setOverview(buildOverview(trend)); |
| | | result.setTrend(trend); |
| | | return result; |
| | | } |
| | | |
| | | public void export(HttpServletResponse response, String type, LocalDate dateFrom, LocalDate dateTo) throws IOException { |
| | | RevenueStatisticsVO statistics = statistics(type, dateFrom, dateTo); |
| | | |
| | | response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); |
| | | response.setCharacterEncoding("utf-8"); |
| | | String filename = URLEncoder.encode("营收数据统计", "UTF-8").replace("+", "%20"); |
| | | response.setHeader("Content-Disposition", "attachment;filename*=utf-8''" + filename + ".xlsx"); |
| | | |
| | | List<RevenueStatisticsExcelRow> exports = new ArrayList<RevenueStatisticsExcelRow>(); |
| | | for (RevenueStatisticsTrendItemVO item : statistics.getTrend()) { |
| | | RevenueStatisticsExcelRow export = new RevenueStatisticsExcelRow(); |
| | | export.setDate(item.getDate()); |
| | | export.setTotalIncome(formatAmount(item.getTotalIncome())); |
| | | export.setHealthIncome(formatAmount(item.getHealthIncome())); |
| | | export.setMemberIncome(formatAmount(item.getMemberIncome())); |
| | | export.setRefundAmount(formatAmount(item.getRefundAmount())); |
| | | export.setProfit(formatAmount(item.getProfit())); |
| | | exports.add(export); |
| | | } |
| | | FastExcel.write(response.getOutputStream(), RevenueStatisticsExcelRow.class) |
| | | .sheet("营收数据") |
| | | .doWrite(exports); |
| | | } |
| | | |
| | | private List<RevenueStatisticsTrendItemVO> buildTrend(List<RevenueStatisticsDailyRow> rows, DateRange range) { |
| | | Map<LocalDate, RevenueStatisticsDailyRow> rowMap = new HashMap<LocalDate, RevenueStatisticsDailyRow>(); |
| | | for (RevenueStatisticsDailyRow row : rows) { |
| | | rowMap.put(row.getStatDate(), row); |
| | | } |
| | | |
| | | List<RevenueStatisticsTrendItemVO> result = new ArrayList<RevenueStatisticsTrendItemVO>(); |
| | | LocalDate cursor = range.getStart(); |
| | | while (!cursor.isAfter(range.getEnd())) { |
| | | RevenueStatisticsDailyRow row = rowMap.get(cursor); |
| | | BigDecimal healthIncome = nvl(row == null ? null : row.getHealthIncome()); |
| | | BigDecimal memberIncome = nvl(row == null ? null : row.getMemberIncome()); |
| | | BigDecimal healthRefundAmount = nvl(row == null ? null : row.getHealthRefundAmount()); |
| | | BigDecimal memberRefundAmount = nvl(row == null ? null : row.getMemberRefundAmount()); |
| | | BigDecimal refundAmount = healthRefundAmount.add(memberRefundAmount); |
| | | BigDecimal totalIncome = healthIncome.add(memberIncome); |
| | | |
| | | RevenueStatisticsTrendItemVO item = new RevenueStatisticsTrendItemVO(); |
| | | item.setDate(cursor.toString()); |
| | | item.setHealthIncome(healthIncome); |
| | | item.setMemberIncome(memberIncome); |
| | | item.setHealthRefundAmount(healthRefundAmount); |
| | | item.setMemberRefundAmount(memberRefundAmount); |
| | | item.setTotalIncome(totalIncome); |
| | | item.setRefundAmount(refundAmount); |
| | | item.setProfit(totalIncome.subtract(refundAmount)); |
| | | result.add(item); |
| | | cursor = cursor.plusDays(1); |
| | | } |
| | | return result; |
| | | } |
| | | |
| | | private RevenueStatisticsOverviewVO buildOverview(List<RevenueStatisticsTrendItemVO> trend) { |
| | | RevenueStatisticsOverviewVO overview = new RevenueStatisticsOverviewVO(); |
| | | BigDecimal totalIncome = BigDecimal.ZERO; |
| | | BigDecimal healthIncome = BigDecimal.ZERO; |
| | | BigDecimal memberIncome = BigDecimal.ZERO; |
| | | BigDecimal refundAmount = BigDecimal.ZERO; |
| | | BigDecimal profit = BigDecimal.ZERO; |
| | | |
| | | for (RevenueStatisticsTrendItemVO item : trend) { |
| | | totalIncome = totalIncome.add(nvl(item.getTotalIncome())); |
| | | healthIncome = healthIncome.add(nvl(item.getHealthIncome())); |
| | | memberIncome = memberIncome.add(nvl(item.getMemberIncome())); |
| | | refundAmount = refundAmount.add(nvl(item.getRefundAmount())); |
| | | profit = profit.add(nvl(item.getProfit())); |
| | | } |
| | | |
| | | overview.setTotalIncome(totalIncome); |
| | | overview.setHealthIncome(healthIncome); |
| | | overview.setMemberIncome(memberIncome); |
| | | overview.setRefundAmount(refundAmount); |
| | | overview.setProfit(profit); |
| | | return overview; |
| | | } |
| | | |
| | | private String normalizeType(String type) { |
| | | String normalized = trimToNull(type); |
| | | if (normalized == null) { |
| | | return FILTER_ALL; |
| | | } |
| | | if (FILTER_ALL.equals(normalized) || FILTER_HEALTH.equals(normalized) || FILTER_MEMBER.equals(normalized)) { |
| | | return normalized; |
| | | } |
| | | throw new IllegalArgumentException("统计类型不合法"); |
| | | } |
| | | |
| | | private String resolveBusinessType(String normalizedType) { |
| | | if (FILTER_HEALTH.equals(normalizedType)) { |
| | | return BUSINESS_TYPE_HEALTH; |
| | | } |
| | | if (FILTER_MEMBER.equals(normalizedType)) { |
| | | return BUSINESS_TYPE_MEMBER; |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | private DateRange normalizeRange(LocalDate dateFrom, LocalDate dateTo) { |
| | | LocalDate start = dateFrom; |
| | | LocalDate end = dateTo; |
| | | if (start == null && end == null) { |
| | | start = LocalDate.now(); |
| | | end = start; |
| | | } else if (start == null) { |
| | | start = end; |
| | | } else if (end == null) { |
| | | end = start; |
| | | } |
| | | if (start.isAfter(end)) { |
| | | throw new IllegalArgumentException("开始日期不能晚于结束日期"); |
| | | } |
| | | return new DateRange(start, end); |
| | | } |
| | | |
| | | private String formatAmount(BigDecimal value) { |
| | | return nvl(value).setScale(2, RoundingMode.HALF_UP).toPlainString(); |
| | | } |
| | | |
| | | private BigDecimal nvl(BigDecimal value) { |
| | | return value == null ? BigDecimal.ZERO : value; |
| | | } |
| | | |
| | | private String trimToNull(String value) { |
| | | if (value == null) { |
| | | return null; |
| | | } |
| | | String trimmed = value.trim(); |
| | | return trimmed.isEmpty() ? null : trimmed; |
| | | } |
| | | |
| | | private static class DateRange { |
| | | private final LocalDate start; |
| | | private final LocalDate end; |
| | | |
| | | private DateRange(LocalDate start, LocalDate end) { |
| | | this.start = start; |
| | | this.end = end; |
| | | } |
| | | |
| | | private LocalDate getStart() { |
| | | return start; |
| | | } |
| | | |
| | | private LocalDate getEnd() { |
| | | return end; |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | package com.tcm.training.admin.statistics.vo; |
| | | |
| | | import lombok.Getter; |
| | | import lombok.NoArgsConstructor; |
| | | import lombok.Setter; |
| | | |
| | | import java.math.BigDecimal; |
| | | |
| | | @Getter |
| | | @Setter |
| | | @NoArgsConstructor |
| | | public class RevenueStatisticsOverviewVO { |
| | | |
| | | private BigDecimal totalIncome; |
| | | private BigDecimal healthIncome; |
| | | private BigDecimal memberIncome; |
| | | private BigDecimal refundAmount; |
| | | private BigDecimal profit; |
| | | } |
| New file |
| | |
| | | package com.tcm.training.admin.statistics.vo; |
| | | |
| | | import lombok.Getter; |
| | | import lombok.NoArgsConstructor; |
| | | import lombok.Setter; |
| | | |
| | | import java.math.BigDecimal; |
| | | |
| | | @Getter |
| | | @Setter |
| | | @NoArgsConstructor |
| | | public class RevenueStatisticsTrendItemVO { |
| | | |
| | | private String date; |
| | | private BigDecimal totalIncome; |
| | | private BigDecimal healthIncome; |
| | | private BigDecimal memberIncome; |
| | | private BigDecimal healthRefundAmount; |
| | | private BigDecimal memberRefundAmount; |
| | | private BigDecimal refundAmount; |
| | | private BigDecimal profit; |
| | | } |
| New file |
| | |
| | | package com.tcm.training.admin.statistics.vo; |
| | | |
| | | import lombok.Getter; |
| | | import lombok.NoArgsConstructor; |
| | | import lombok.Setter; |
| | | |
| | | import java.util.List; |
| | | |
| | | @Getter |
| | | @Setter |
| | | @NoArgsConstructor |
| | | public class RevenueStatisticsVO { |
| | | |
| | | private RevenueStatisticsOverviewVO overview; |
| | | private List<RevenueStatisticsTrendItemVO> trend; |
| | | } |
| New file |
| | |
| | | <?xml version="1.0" encoding="UTF-8"?> |
| | | <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" |
| | | "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> |
| | | <mapper namespace="com.tcm.training.admin.statistics.mapper.RevenueStatisticsMapper"> |
| | | |
| | | <select id="selectDailyStatistics" resultType="com.tcm.training.admin.statistics.row.RevenueStatisticsDailyRow"> |
| | | SELECT |
| | | DATE(f.trade_time) AS statDate, |
| | | SUM(CASE |
| | | WHEN f.flow_type = 'PAYMENT' AND f.business_type = 'COURSE_PURCHASE' THEN f.transaction_amount |
| | | ELSE 0 |
| | | END) AS healthIncome, |
| | | SUM(CASE |
| | | WHEN f.flow_type = 'PAYMENT' AND f.business_type = 'MEMBER_PURCHASE' THEN f.transaction_amount |
| | | ELSE 0 |
| | | END) AS memberIncome, |
| | | SUM(CASE |
| | | WHEN f.flow_type = 'REFUND' AND f.business_type = 'COURSE_PURCHASE' THEN f.transaction_amount |
| | | ELSE 0 |
| | | END) AS healthRefundAmount, |
| | | SUM(CASE |
| | | WHEN f.flow_type = 'REFUND' AND f.business_type = 'MEMBER_PURCHASE' THEN f.transaction_amount |
| | | ELSE 0 |
| | | END) AS memberRefundAmount, |
| | | SUM(CASE |
| | | WHEN f.flow_type = 'REFUND' THEN f.transaction_amount |
| | | ELSE 0 |
| | | END) AS refundAmount |
| | | FROM tcm_payment_flow f |
| | | WHERE f.deleted = 0 |
| | | AND f.business_type IN ('COURSE_PURCHASE', 'MEMBER_PURCHASE') |
| | | AND f.trade_time <![CDATA[>=]]> #{startTime} |
| | | AND f.trade_time <![CDATA[<=]]> #{endTime} |
| | | <if test="businessType != null and businessType.trim() != ''"> |
| | | AND f.business_type = #{businessType} |
| | | </if> |
| | | GROUP BY DATE(f.trade_time) |
| | | ORDER BY DATE(f.trade_time) ASC |
| | | </select> |
| | | |
| | | </mapper> |
| New file |
| | |
| | | package com.tcm.training.admin.statistics.service; |
| | | |
| | | import com.tcm.training.admin.statistics.mapper.RevenueStatisticsMapper; |
| | | import com.tcm.training.admin.statistics.row.RevenueStatisticsDailyRow; |
| | | import com.tcm.training.admin.statistics.vo.RevenueStatisticsVO; |
| | | import com.tcm.training.admin.trade.service.PaymentFlowAppService; |
| | | import org.junit.jupiter.api.Test; |
| | | import org.junit.jupiter.api.extension.ExtendWith; |
| | | import org.mockito.InjectMocks; |
| | | import org.mockito.Mock; |
| | | import org.mockito.junit.jupiter.MockitoExtension; |
| | | |
| | | import java.math.BigDecimal; |
| | | import java.time.LocalDate; |
| | | import java.time.LocalDateTime; |
| | | import java.time.LocalTime; |
| | | import java.util.Collections; |
| | | |
| | | import static org.junit.jupiter.api.Assertions.assertEquals; |
| | | import static org.junit.jupiter.api.Assertions.assertThrows; |
| | | import static org.mockito.ArgumentMatchers.eq; |
| | | import static org.mockito.Mockito.verify; |
| | | import static org.mockito.Mockito.when; |
| | | |
| | | @ExtendWith(MockitoExtension.class) |
| | | class RevenueStatisticsAppServiceTest { |
| | | |
| | | @Mock |
| | | private RevenueStatisticsMapper revenueStatisticsMapper; |
| | | |
| | | @Mock |
| | | private PaymentFlowAppService paymentFlowAppService; |
| | | |
| | | @InjectMocks |
| | | private RevenueStatisticsAppService revenueStatisticsAppService; |
| | | |
| | | @Test |
| | | void statisticsShouldBuildOverviewAndFillMissingDates() { |
| | | RevenueStatisticsDailyRow row = new RevenueStatisticsDailyRow(); |
| | | row.setStatDate(LocalDate.of(2026, 3, 1)); |
| | | row.setHealthIncome(new BigDecimal("100.00")); |
| | | row.setMemberIncome(new BigDecimal("50.00")); |
| | | row.setHealthRefundAmount(new BigDecimal("12.00")); |
| | | row.setMemberRefundAmount(new BigDecimal("8.00")); |
| | | when(revenueStatisticsMapper.selectDailyStatistics( |
| | | eq(null), |
| | | eq(LocalDateTime.of(2026, 3, 1, 0, 0, 0)), |
| | | eq(LocalDateTime.of(2026, 3, 3, LocalTime.MAX.getHour(), LocalTime.MAX.getMinute(), LocalTime.MAX.getSecond(), LocalTime.MAX.getNano())) |
| | | )).thenReturn(Collections.singletonList(row)); |
| | | |
| | | RevenueStatisticsVO result = revenueStatisticsAppService.statistics( |
| | | "ALL", |
| | | LocalDate.of(2026, 3, 1), |
| | | LocalDate.of(2026, 3, 3) |
| | | ); |
| | | |
| | | verify(paymentFlowAppService).syncFlows(); |
| | | assertEquals(3, result.getTrend().size()); |
| | | assertEquals(new BigDecimal("150.00"), result.getOverview().getTotalIncome()); |
| | | assertEquals(new BigDecimal("100.00"), result.getOverview().getHealthIncome()); |
| | | assertEquals(new BigDecimal("50.00"), result.getOverview().getMemberIncome()); |
| | | assertEquals(new BigDecimal("20.00"), result.getOverview().getRefundAmount()); |
| | | assertEquals(new BigDecimal("130.00"), result.getOverview().getProfit()); |
| | | assertEquals(new BigDecimal("0"), result.getTrend().get(1).getTotalIncome()); |
| | | assertEquals(new BigDecimal("12.00"), result.getTrend().get(0).getHealthRefundAmount()); |
| | | assertEquals(new BigDecimal("8.00"), result.getTrend().get(0).getMemberRefundAmount()); |
| | | } |
| | | |
| | | @Test |
| | | void statisticsShouldUseHealthTypeFilter() { |
| | | RevenueStatisticsDailyRow row = new RevenueStatisticsDailyRow(); |
| | | row.setStatDate(LocalDate.of(2026, 3, 5)); |
| | | row.setHealthIncome(new BigDecimal("88.00")); |
| | | row.setMemberIncome(BigDecimal.ZERO); |
| | | row.setHealthRefundAmount(new BigDecimal("8.00")); |
| | | row.setMemberRefundAmount(BigDecimal.ZERO); |
| | | when(revenueStatisticsMapper.selectDailyStatistics( |
| | | eq("COURSE_PURCHASE"), |
| | | eq(LocalDateTime.of(2026, 3, 5, 0, 0, 0)), |
| | | eq(LocalDateTime.of(2026, 3, 5, LocalTime.MAX.getHour(), LocalTime.MAX.getMinute(), LocalTime.MAX.getSecond(), LocalTime.MAX.getNano())) |
| | | )).thenReturn(Collections.singletonList(row)); |
| | | |
| | | RevenueStatisticsVO result = revenueStatisticsAppService.statistics( |
| | | "HEALTH", |
| | | LocalDate.of(2026, 3, 5), |
| | | LocalDate.of(2026, 3, 5) |
| | | ); |
| | | |
| | | assertEquals(new BigDecimal("88.00"), result.getOverview().getTotalIncome()); |
| | | assertEquals(new BigDecimal("88.00"), result.getOverview().getHealthIncome()); |
| | | assertEquals(new BigDecimal("0"), result.getOverview().getMemberIncome()); |
| | | assertEquals(new BigDecimal("8.00"), result.getOverview().getRefundAmount()); |
| | | assertEquals(new BigDecimal("80.00"), result.getOverview().getProfit()); |
| | | } |
| | | |
| | | @Test |
| | | void statisticsShouldRejectInvalidType() { |
| | | IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, |
| | | () -> revenueStatisticsAppService.statistics("OTHER", LocalDate.of(2026, 3, 1), LocalDate.of(2026, 3, 1))); |
| | | |
| | | assertEquals("统计类型不合法", exception.getMessage()); |
| | | } |
| | | |
| | | @Test |
| | | void statisticsShouldRejectInvalidDateRange() { |
| | | IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, |
| | | () -> revenueStatisticsAppService.statistics("ALL", LocalDate.of(2026, 3, 10), LocalDate.of(2026, 3, 1))); |
| | | |
| | | assertEquals("开始日期不能晚于结束日期", exception.getMessage()); |
| | | } |
| | | } |
| | |
| | | ], |
| | | }, |
| | | { |
| | | path: '/statistics', |
| | | name: '统计', |
| | | icon: 'barChart', |
| | | access: 'canViewStatisticsModule', |
| | | routes: [ |
| | | { |
| | | path: '/statistics', |
| | | hideInMenu: true, |
| | | component: './statistics/redirect', |
| | | }, |
| | | { |
| | | name: '营收数据', |
| | | path: '/statistics/revenue', |
| | | access: 'statistics:revenue:view', |
| | | component: './statistics/revenue', |
| | | }, |
| | | ], |
| | | }, |
| | | { |
| | | path: '/member', |
| | | name: '用户与会员', |
| | | icon: 'team', |
| | |
| | | !!permission?.['member:vip-price:view'] || !!permission?.['member:vip-intro:view']; |
| | | return { |
| | | ...permission, |
| | | canViewStatisticsModule: !!permission?.['statistics:revenue:view'], |
| | | canViewSystemSecurity: |
| | | !!permission?.['system:account:view'] || |
| | | !!permission?.['system:role:view'] || |
| New file |
| | |
| | | import { history, useAccess } from '@umijs/max'; |
| | | import React, { useEffect } from 'react'; |
| | | |
| | | const StatisticsRedirectPage: React.FC = () => { |
| | | const access = useAccess(); |
| | | |
| | | useEffect(() => { |
| | | if (access['statistics:revenue:view']) { |
| | | history.replace('/statistics/revenue'); |
| | | return; |
| | | } |
| | | history.replace('/Welcome'); |
| | | }, [access]); |
| | | |
| | | return null; |
| | | }; |
| | | |
| | | export default StatisticsRedirectPage; |
| New file |
| | |
| | | import { history, useAccess } from '@umijs/max'; |
| | | import { Line } from '@ant-design/plots'; |
| | | import { Button, DatePicker, Form, Select, message } from 'antd'; |
| | | import dayjs, { type Dayjs } from 'dayjs'; |
| | | import React, { useEffect, useMemo, useState } from 'react'; |
| | | import { downloadBlob } from '@/pages/trade/course-purchase/shared'; |
| | | import { exportRevenueStatistics, getRevenueStatistics } from './service'; |
| | | import './style.less'; |
| | | |
| | | type QuickRangeType = 'TODAY' | 'LAST_7_DAYS' | 'LAST_30_DAYS' | 'THIS_MONTH' | 'CUSTOM'; |
| | | |
| | | type SearchValues = { |
| | | type?: 'ALL' | 'HEALTH' | 'MEMBER'; |
| | | dateRange?: [Dayjs, Dayjs]; |
| | | }; |
| | | |
| | | type RevenueOverview = { |
| | | totalIncome?: number; |
| | | healthIncome?: number; |
| | | memberIncome?: number; |
| | | refundAmount?: number; |
| | | profit?: number; |
| | | }; |
| | | |
| | | type RevenueTrendItem = { |
| | | date: string; |
| | | totalIncome?: number; |
| | | healthIncome?: number; |
| | | memberIncome?: number; |
| | | healthRefundAmount?: number; |
| | | memberRefundAmount?: number; |
| | | refundAmount?: number; |
| | | profit?: number; |
| | | }; |
| | | |
| | | type RevenueStatisticsResult = { |
| | | overview?: RevenueOverview; |
| | | trend?: RevenueTrendItem[]; |
| | | }; |
| | | |
| | | const quickRangeOptions: Array<{ label: string; value: QuickRangeType }> = [ |
| | | { label: '今日', value: 'TODAY' }, |
| | | { label: '近7天', value: 'LAST_7_DAYS' }, |
| | | { label: '近30天', value: 'LAST_30_DAYS' }, |
| | | { label: '本月', value: 'THIS_MONTH' }, |
| | | { label: '自定义', value: 'CUSTOM' }, |
| | | ]; |
| | | |
| | | const typeOptions = [ |
| | | { label: '全部', value: 'ALL' }, |
| | | { label: '养生', value: 'HEALTH' }, |
| | | { label: '会员', value: 'MEMBER' }, |
| | | ]; |
| | | |
| | | const revenueSeriesColorMap: Record<string, string> = { |
| | | 养生收入: '#4f7cff', |
| | | 养生课退款: '#4f7cff', |
| | | 会员收入: '#52c7ea', |
| | | 会员退款: '#52c7ea', |
| | | }; |
| | | |
| | | const getRangeByPreset = (preset: QuickRangeType): [Dayjs, Dayjs] => { |
| | | const today = dayjs(); |
| | | if (preset === 'LAST_7_DAYS') { |
| | | return [today.subtract(6, 'day').startOf('day'), today.endOf('day')]; |
| | | } |
| | | if (preset === 'LAST_30_DAYS') { |
| | | return [today.subtract(29, 'day').startOf('day'), today.endOf('day')]; |
| | | } |
| | | if (preset === 'THIS_MONTH') { |
| | | return [today.startOf('month'), today.endOf('day')]; |
| | | } |
| | | return [today.startOf('day'), today.endOf('day')]; |
| | | }; |
| | | |
| | | const buildParams = (values: SearchValues) => ({ |
| | | type: values.type || 'ALL', |
| | | dateFrom: values.dateRange?.[0] ? dayjs(values.dateRange[0]).format('YYYY-MM-DD') : undefined, |
| | | dateTo: values.dateRange?.[1] ? dayjs(values.dateRange[1]).format('YYYY-MM-DD') : undefined, |
| | | }); |
| | | |
| | | const formatAmount = (value?: number) => { |
| | | const amount = Number(value || 0); |
| | | return amount.toFixed(2); |
| | | }; |
| | | |
| | | const renderChartValue = (value?: number) => { |
| | | const amount = Number(value || 0); |
| | | return Number(amount.toFixed(2)); |
| | | }; |
| | | |
| | | const getSeriesColor = (datum: { type?: string }) => |
| | | revenueSeriesColorMap[datum.type || ''] || '#4f7cff'; |
| | | |
| | | const RevenueStatisticsPage: React.FC = () => { |
| | | const access = useAccess(); |
| | | const [searchForm] = Form.useForm<SearchValues>(); |
| | | const [loading, setLoading] = useState(false); |
| | | const [quickRange, setQuickRange] = useState<QuickRangeType>('TODAY'); |
| | | const [overview, setOverview] = useState<RevenueOverview>({}); |
| | | const [trend, setTrend] = useState<RevenueTrendItem[]>([]); |
| | | |
| | | const canView = access['statistics:revenue:view']; |
| | | const canExport = access['statistics:revenue:export']; |
| | | |
| | | const loadData = async (params = buildParams(searchForm.getFieldsValue())) => { |
| | | setLoading(true); |
| | | try { |
| | | const res = await getRevenueStatistics(params); |
| | | const data = (res.data || {}) as RevenueStatisticsResult; |
| | | setOverview(data.overview || {}); |
| | | setTrend(data.trend || []); |
| | | } catch (error) { |
| | | message.error((error as Error)?.message || '营收数据加载失败'); |
| | | setOverview({}); |
| | | setTrend([]); |
| | | } finally { |
| | | setLoading(false); |
| | | } |
| | | }; |
| | | |
| | | useEffect(() => { |
| | | if (!canView) { |
| | | history.replace('/Welcome'); |
| | | return; |
| | | } |
| | | searchForm.setFieldsValue({ |
| | | type: 'ALL', |
| | | dateRange: getRangeByPreset('TODAY'), |
| | | }); |
| | | loadData(buildParams({ type: 'ALL', dateRange: getRangeByPreset('TODAY') })); |
| | | }, [canView]); |
| | | |
| | | const incomeTrendData = useMemo( |
| | | () => |
| | | trend.flatMap((item) => [ |
| | | { date: item.date, type: '养生收入', value: renderChartValue(item.healthIncome) }, |
| | | { date: item.date, type: '会员收入', value: renderChartValue(item.memberIncome) }, |
| | | ]), |
| | | [trend], |
| | | ); |
| | | |
| | | const refundTrendData = useMemo( |
| | | () => |
| | | trend.flatMap((item) => [ |
| | | { date: item.date, type: '养生课退款', value: renderChartValue(item.healthRefundAmount) }, |
| | | { date: item.date, type: '会员退款', value: renderChartValue(item.memberRefundAmount) }, |
| | | ]), |
| | | [trend], |
| | | ); |
| | | |
| | | const handleQuickRangeChange = (preset: QuickRangeType) => { |
| | | setQuickRange(preset); |
| | | if (preset !== 'CUSTOM') { |
| | | searchForm.setFieldsValue({ dateRange: getRangeByPreset(preset) }); |
| | | } |
| | | }; |
| | | |
| | | const handleReset = async () => { |
| | | setQuickRange('TODAY'); |
| | | searchForm.setFieldsValue({ |
| | | type: 'ALL', |
| | | dateRange: getRangeByPreset('TODAY'), |
| | | }); |
| | | await loadData(buildParams({ type: 'ALL', dateRange: getRangeByPreset('TODAY') })); |
| | | }; |
| | | |
| | | const handleExport = async () => { |
| | | try { |
| | | const blob = await exportRevenueStatistics(buildParams(searchForm.getFieldsValue())); |
| | | downloadBlob(blob, '营收数据统计.xlsx'); |
| | | } catch (error) { |
| | | message.error((error as Error)?.message || '导出失败'); |
| | | } |
| | | }; |
| | | |
| | | return ( |
| | | <div className="revenue-statistics-page"> |
| | | <div className="revenue-statistics-page__panel"> |
| | | <div className="revenue-statistics-page__title">营收数据统计</div> |
| | | |
| | | <div className="revenue-statistics-search-card"> |
| | | <Form form={searchForm} layout="horizontal" className="revenue-statistics-search-form"> |
| | | <Form.Item label="日期范围"> |
| | | <div className="revenue-statistics-search-form__quick-range"> |
| | | {quickRangeOptions.map((item) => ( |
| | | <Button |
| | | key={item.value} |
| | | className={quickRange === item.value ? 'is-active' : undefined} |
| | | onClick={() => handleQuickRangeChange(item.value)} |
| | | > |
| | | {item.label} |
| | | </Button> |
| | | ))} |
| | | </div> |
| | | </Form.Item> |
| | | <Form.Item name="dateRange"> |
| | | <DatePicker.RangePicker |
| | | className="revenue-statistics-search-form__range" |
| | | onChange={() => setQuickRange('CUSTOM')} |
| | | /> |
| | | </Form.Item> |
| | | <Form.Item label="类型" name="type"> |
| | | <Select |
| | | className="revenue-statistics-search-form__type" |
| | | placeholder="请选择" |
| | | options={typeOptions} |
| | | /> |
| | | </Form.Item> |
| | | <div className="revenue-statistics-search-form__actions"> |
| | | <Button onClick={handleReset}>重置</Button> |
| | | <Button type="primary" onClick={() => loadData()}> |
| | | 搜索 |
| | | </Button> |
| | | {canExport ? <Button onClick={handleExport}>导出报表</Button> : null} |
| | | </div> |
| | | </Form> |
| | | </div> |
| | | |
| | | <div className="revenue-statistics-overview"> |
| | | <div className="revenue-statistics-overview__card revenue-statistics-overview__card--total"> |
| | | <div className="revenue-statistics-overview__label">总收入</div> |
| | | <div className="revenue-statistics-overview__value"> |
| | | {formatAmount(overview.totalIncome)} |
| | | <span className="revenue-statistics-overview__unit">元</span> |
| | | </div> |
| | | </div> |
| | | <div className="revenue-statistics-overview__card revenue-statistics-overview__card--health"> |
| | | <div className="revenue-statistics-overview__label">养生收入</div> |
| | | <div className="revenue-statistics-overview__value"> |
| | | {formatAmount(overview.healthIncome)} |
| | | <span className="revenue-statistics-overview__unit">元</span> |
| | | </div> |
| | | </div> |
| | | <div className="revenue-statistics-overview__card revenue-statistics-overview__card--member"> |
| | | <div className="revenue-statistics-overview__label">会员收入</div> |
| | | <div className="revenue-statistics-overview__value"> |
| | | {formatAmount(overview.memberIncome)} |
| | | <span className="revenue-statistics-overview__unit">元</span> |
| | | </div> |
| | | </div> |
| | | <div className="revenue-statistics-overview__card revenue-statistics-overview__card--refund"> |
| | | <div className="revenue-statistics-overview__label">退款金额</div> |
| | | <div className="revenue-statistics-overview__value"> |
| | | {formatAmount(overview.refundAmount)} |
| | | <span className="revenue-statistics-overview__unit">元</span> |
| | | </div> |
| | | </div> |
| | | <div className="revenue-statistics-overview__card revenue-statistics-overview__card--profit"> |
| | | <div className="revenue-statistics-overview__label">利润</div> |
| | | <div className="revenue-statistics-overview__value"> |
| | | {formatAmount(overview.profit)} |
| | | <span className="revenue-statistics-overview__unit">元</span> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <div className="revenue-statistics-chart-card"> |
| | | <div className="revenue-statistics-chart-card__title">收入趋势</div> |
| | | <div className="revenue-statistics-chart-card__unit">金额</div> |
| | | <div className="revenue-statistics-chart-card__plot"> |
| | | <Line |
| | | loading={loading} |
| | | data={incomeTrendData} |
| | | xField="date" |
| | | yField="value" |
| | | seriesField="type" |
| | | smooth |
| | | height={280} |
| | | color={getSeriesColor} |
| | | point={{ size: 3, shape: 'circle' }} |
| | | yAxis={{ label: { formatter: (value: string) => `${Number(value).toFixed(0)}` } }} |
| | | /> |
| | | </div> |
| | | </div> |
| | | |
| | | <div className="revenue-statistics-chart-card"> |
| | | <div className="revenue-statistics-chart-card__title">退款趋势</div> |
| | | <div className="revenue-statistics-chart-card__unit">金额</div> |
| | | <div className="revenue-statistics-chart-card__plot"> |
| | | <Line |
| | | loading={loading} |
| | | data={refundTrendData} |
| | | xField="date" |
| | | yField="value" |
| | | seriesField="type" |
| | | smooth |
| | | height={280} |
| | | color={getSeriesColor} |
| | | point={{ size: 3, shape: 'circle' }} |
| | | yAxis={{ label: { formatter: (value: string) => `${Number(value).toFixed(0)}` } }} |
| | | /> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | ); |
| | | }; |
| | | |
| | | export default RevenueStatisticsPage; |
| New file |
| | |
| | | import { request } from '@umijs/max'; |
| | | |
| | | export async function getRevenueStatistics(params?: Record<string, unknown>) { |
| | | return request('/revenue-statistics', { |
| | | method: 'GET', |
| | | params, |
| | | }); |
| | | } |
| | | |
| | | export async function exportRevenueStatistics(params?: Record<string, unknown>) { |
| | | return request('/revenue-statistics/export', { |
| | | method: 'GET', |
| | | params, |
| | | responseType: 'blob', |
| | | getResponse: false, |
| | | }); |
| | | } |
| New file |
| | |
| | | .revenue-statistics-page { |
| | | min-height: calc(100vh - 48px); |
| | | padding: 18px 20px 24px; |
| | | background: #f2f5f9; |
| | | } |
| | | |
| | | .revenue-statistics-page__panel { |
| | | padding: 16px 18px 24px; |
| | | background: #fff; |
| | | border-radius: 10px; |
| | | box-shadow: 0 2px 10px rgba(15, 23, 42, 0.04); |
| | | } |
| | | |
| | | .revenue-statistics-page__title { |
| | | margin-bottom: 18px; |
| | | color: #303133; |
| | | font-weight: 500; |
| | | font-size: 20px; |
| | | } |
| | | |
| | | .revenue-statistics-search-card { |
| | | margin-bottom: 24px; |
| | | } |
| | | |
| | | .revenue-statistics-search-form { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | gap: 16px 18px; |
| | | align-items: center; |
| | | } |
| | | |
| | | .revenue-statistics-search-form .ant-form-item { |
| | | margin-bottom: 0; |
| | | } |
| | | |
| | | .revenue-statistics-search-form__quick-range { |
| | | display: flex; |
| | | gap: 8px; |
| | | align-items: center; |
| | | } |
| | | |
| | | .revenue-statistics-search-form__quick-range button { |
| | | min-width: 64px; |
| | | } |
| | | |
| | | .revenue-statistics-search-form__quick-range button.is-active { |
| | | color: #1677ff; |
| | | border-color: #91caff; |
| | | background: #e6f4ff; |
| | | } |
| | | |
| | | .revenue-statistics-search-form__range { |
| | | width: 260px; |
| | | } |
| | | |
| | | .revenue-statistics-search-form__type { |
| | | width: 180px; |
| | | } |
| | | |
| | | .revenue-statistics-search-form__actions { |
| | | display: flex; |
| | | gap: 12px; |
| | | align-items: center; |
| | | margin-left: auto; |
| | | } |
| | | |
| | | .revenue-statistics-overview { |
| | | display: grid; |
| | | grid-template-columns: repeat(5, minmax(0, 1fr)); |
| | | gap: 16px; |
| | | margin-bottom: 28px; |
| | | } |
| | | |
| | | .revenue-statistics-overview__card { |
| | | padding: 22px 12px; |
| | | border-radius: 8px; |
| | | text-align: center; |
| | | } |
| | | |
| | | .revenue-statistics-overview__label { |
| | | margin-bottom: 10px; |
| | | font-size: 14px; |
| | | } |
| | | |
| | | .revenue-statistics-overview__value { |
| | | font-weight: 600; |
| | | font-size: 30px; |
| | | line-height: 1; |
| | | } |
| | | |
| | | .revenue-statistics-overview__unit { |
| | | margin-left: 4px; |
| | | font-size: 16px; |
| | | } |
| | | |
| | | .revenue-statistics-overview__card--total { |
| | | background: #fff7e8; |
| | | } |
| | | |
| | | .revenue-statistics-overview__card--total .revenue-statistics-overview__label, |
| | | .revenue-statistics-overview__card--total .revenue-statistics-overview__value { |
| | | color: #fa8c16; |
| | | } |
| | | |
| | | .revenue-statistics-overview__card--health { |
| | | background: #eef4ff; |
| | | } |
| | | |
| | | .revenue-statistics-overview__card--health .revenue-statistics-overview__label, |
| | | .revenue-statistics-overview__card--health .revenue-statistics-overview__value { |
| | | color: #2f6bff; |
| | | } |
| | | |
| | | .revenue-statistics-overview__card--member { |
| | | background: #edf9ff; |
| | | } |
| | | |
| | | .revenue-statistics-overview__card--member .revenue-statistics-overview__label, |
| | | .revenue-statistics-overview__card--member .revenue-statistics-overview__value { |
| | | color: #33b5e5; |
| | | } |
| | | |
| | | .revenue-statistics-overview__card--refund { |
| | | background: #edfdf8; |
| | | } |
| | | |
| | | .revenue-statistics-overview__card--refund .revenue-statistics-overview__label, |
| | | .revenue-statistics-overview__card--refund .revenue-statistics-overview__value { |
| | | color: #21c7a8; |
| | | } |
| | | |
| | | .revenue-statistics-overview__card--profit { |
| | | background: #fff1f1; |
| | | } |
| | | |
| | | .revenue-statistics-overview__card--profit .revenue-statistics-overview__label, |
| | | .revenue-statistics-overview__card--profit .revenue-statistics-overview__value { |
| | | color: #ff6b6b; |
| | | } |
| | | |
| | | .revenue-statistics-chart-card { |
| | | margin-bottom: 28px; |
| | | } |
| | | |
| | | .revenue-statistics-chart-card:last-child { |
| | | margin-bottom: 0; |
| | | } |
| | | |
| | | .revenue-statistics-chart-card__title { |
| | | margin-bottom: 8px; |
| | | color: #303133; |
| | | font-weight: 500; |
| | | font-size: 16px; |
| | | } |
| | | |
| | | .revenue-statistics-chart-card__unit { |
| | | margin-bottom: 6px; |
| | | color: #909399; |
| | | font-size: 12px; |
| | | } |
| | | |
| | | .revenue-statistics-chart-card__plot { |
| | | min-height: 280px; |
| | | padding: 8px 0; |
| | | } |
| | | |
| | | @media (max-width: 1200px) { |
| | | .revenue-statistics-overview { |
| | | grid-template-columns: repeat(2, minmax(0, 1fr)); |
| | | } |
| | | } |
| | | |
| | | @media (max-width: 900px) { |
| | | .revenue-statistics-page { |
| | | padding: 12px; |
| | | } |
| | | |
| | | .revenue-statistics-page__panel { |
| | | padding: 16px; |
| | | } |
| | | |
| | | .revenue-statistics-search-form { |
| | | flex-direction: column; |
| | | align-items: stretch; |
| | | } |
| | | |
| | | .revenue-statistics-search-form__type, |
| | | .revenue-statistics-search-form__range { |
| | | width: 100%; |
| | | } |
| | | |
| | | .revenue-statistics-search-form__quick-range { |
| | | flex-wrap: wrap; |
| | | } |
| | | |
| | | .revenue-statistics-search-form__actions { |
| | | justify-content: flex-end; |
| | | margin-left: 0; |
| | | } |
| | | |
| | | .revenue-statistics-overview { |
| | | grid-template-columns: 1fr; |
| | | } |
| | | |
| | | .revenue-statistics-overview__value { |
| | | font-size: 26px; |
| | | } |
| | | } |