“mitao”
2026-03-26 6b7bda93ea1fe2f956c251ec1a6a8b09824a2218
feat:统计-营收统计
16个文件已添加
4个文件已修改
1160 ■■■■■ 已修改文件
backend/sql/init.sql 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/sql/mock_revenue_statistics_recent_week.sql 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/sql/update_revenue_statistics_schema.sql 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/bootstrap/DataInitializer.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/statistics/controller/RevenueStatisticsController.java 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/statistics/excel/RevenueStatisticsExcelRow.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/statistics/mapper/RevenueStatisticsMapper.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/statistics/row/RevenueStatisticsDailyRow.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/statistics/service/RevenueStatisticsAppService.java 214 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/statistics/vo/RevenueStatisticsOverviewVO.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/statistics/vo/RevenueStatisticsTrendItemVO.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/statistics/vo/RevenueStatisticsVO.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/resources/mapper/RevenueStatisticsMapper.xml 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/test/java/com/tcm/training/admin/statistics/service/RevenueStatisticsAppServiceTest.java 111 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/config/routes.ts 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/access.js 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/statistics/redirect.tsx 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/statistics/revenue/index.tsx 300 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/statistics/revenue/service.ts 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/statistics/revenue/style.less 208 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/sql/init.sql
@@ -133,6 +133,9 @@
(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),
@@ -217,6 +220,7 @@
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),
backend/sql/mock_revenue_statistics_recent_week.sql
New file
@@ -0,0 +1,44 @@
-- 最近一周营收统计 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);
backend/sql/update_revenue_statistics_schema.sql
New file
@@ -0,0 +1,10 @@
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);
backend/src/main/java/com/tcm/training/admin/bootstrap/DataInitializer.java
@@ -183,6 +183,12 @@
                "/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",
backend/src/main/java/com/tcm/training/admin/statistics/controller/RevenueStatisticsController.java
New file
@@ -0,0 +1,43 @@
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);
    }
}
backend/src/main/java/com/tcm/training/admin/statistics/excel/RevenueStatisticsExcelRow.java
New file
@@ -0,0 +1,30 @@
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;
}
backend/src/main/java/com/tcm/training/admin/statistics/mapper/RevenueStatisticsMapper.java
New file
@@ -0,0 +1,16 @@
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);
}
backend/src/main/java/com/tcm/training/admin/statistics/row/RevenueStatisticsDailyRow.java
New file
@@ -0,0 +1,21 @@
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;
}
backend/src/main/java/com/tcm/training/admin/statistics/service/RevenueStatisticsAppService.java
New file
@@ -0,0 +1,214 @@
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;
        }
    }
}
backend/src/main/java/com/tcm/training/admin/statistics/vo/RevenueStatisticsOverviewVO.java
New file
@@ -0,0 +1,19 @@
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;
}
backend/src/main/java/com/tcm/training/admin/statistics/vo/RevenueStatisticsTrendItemVO.java
New file
@@ -0,0 +1,22 @@
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;
}
backend/src/main/java/com/tcm/training/admin/statistics/vo/RevenueStatisticsVO.java
New file
@@ -0,0 +1,16 @@
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;
}
backend/src/main/resources/mapper/RevenueStatisticsMapper.xml
New file
@@ -0,0 +1,41 @@
<?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>
backend/src/test/java/com/tcm/training/admin/statistics/service/RevenueStatisticsAppServiceTest.java
New file
@@ -0,0 +1,111 @@
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());
    }
}
tcm-training-admin/config/routes.ts
@@ -139,6 +139,25 @@
    ],
  },
  {
    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',
tcm-training-admin/src/access.js
@@ -11,6 +11,7 @@
    !!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'] ||
tcm-training-admin/src/pages/statistics/redirect.tsx
New file
@@ -0,0 +1,18 @@
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;
tcm-training-admin/src/pages/statistics/revenue/index.tsx
New file
@@ -0,0 +1,300 @@
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;
tcm-training-admin/src/pages/statistics/revenue/service.ts
New file
@@ -0,0 +1,17 @@
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,
  });
}
tcm-training-admin/src/pages/statistics/revenue/style.less
New file
@@ -0,0 +1,208 @@
.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;
  }
}