Prechádzať zdrojové kódy

统计报表模块,使用mock数据

skyline 2 mesiacov pred
rodič
commit
d2c4d5b76c
36 zmenil súbory, kde vykonal 6651 pridanie a 0 odobranie
  1. 351 0
      haha-admin-web/src/api/statistics.ts
  2. 331 0
      haha-admin-web/src/api/statisticsMock.ts
  3. 67 0
      haha-admin-web/src/router/modules/statistics.ts
  4. 461 0
      haha-admin-web/src/views/statistics/category/index.vue
  5. 488 0
      haha-admin-web/src/views/statistics/device/index.vue
  6. 415 0
      haha-admin-web/src/views/statistics/overview/index.vue
  7. 307 0
      haha-admin-web/src/views/statistics/product/index.vue
  8. 514 0
      haha-admin-web/src/views/statistics/profit/index.vue
  9. 584 0
      haha-admin-web/src/views/statistics/repurchase/index.vue
  10. 499 0
      haha-admin-web/src/views/statistics/shop/index.vue
  11. 198 0
      haha-admin/src/main/java/com/haha/admin/controller/StatisticsController.java
  12. 411 0
      haha-admin/src/main/java/com/haha/admin/task/StatisticsTask.java
  13. 50 0
      haha-entity/src/main/java/com/haha/entity/OrderItem.java
  14. 39 0
      haha-entity/src/main/java/com/haha/entity/StatCategoryDaily.java
  15. 45 0
      haha-entity/src/main/java/com/haha/entity/StatDeviceDaily.java
  16. 47 0
      haha-entity/src/main/java/com/haha/entity/StatProductDaily.java
  17. 53 0
      haha-entity/src/main/java/com/haha/entity/StatShopDaily.java
  18. 43 0
      haha-entity/src/main/java/com/haha/entity/StatUserRepurchase.java
  19. 20 0
      haha-entity/src/main/java/com/haha/entity/dto/CategoryStatVO.java
  20. 25 0
      haha-entity/src/main/java/com/haha/entity/dto/DeviceStatVO.java
  21. 21 0
      haha-entity/src/main/java/com/haha/entity/dto/ProductStatVO.java
  22. 28 0
      haha-entity/src/main/java/com/haha/entity/dto/ProfitStatVO.java
  23. 22 0
      haha-entity/src/main/java/com/haha/entity/dto/RepurchaseStatVO.java
  24. 11 0
      haha-entity/src/main/java/com/haha/entity/dto/SeriesDataVO.java
  25. 29 0
      haha-entity/src/main/java/com/haha/entity/dto/ShopStatVO.java
  26. 23 0
      haha-entity/src/main/java/com/haha/entity/dto/StatisticsOverviewVO.java
  27. 41 0
      haha-entity/src/main/java/com/haha/entity/dto/StatisticsQueryDTO.java
  28. 13 0
      haha-entity/src/main/java/com/haha/entity/dto/TrendDataVO.java
  29. 9 0
      haha-mapper/src/main/java/com/haha/mapper/OrderItemMapper.java
  30. 9 0
      haha-mapper/src/main/java/com/haha/mapper/StatCategoryDailyMapper.java
  31. 9 0
      haha-mapper/src/main/java/com/haha/mapper/StatDeviceDailyMapper.java
  32. 9 0
      haha-mapper/src/main/java/com/haha/mapper/StatProductDailyMapper.java
  33. 9 0
      haha-mapper/src/main/java/com/haha/mapper/StatShopDailyMapper.java
  34. 9 0
      haha-mapper/src/main/java/com/haha/mapper/StatUserRepurchaseMapper.java
  35. 54 0
      haha-service/src/main/java/com/haha/service/StatisticsService.java
  36. 1407 0
      haha-service/src/main/java/com/haha/service/impl/StatisticsServiceImpl.java

+ 351 - 0
haha-admin-web/src/api/statistics.ts

@@ -0,0 +1,351 @@
+import { http } from "@/utils/http";
+import {
+  USE_MOCK,
+  mockStatisticsOverview,
+  mockCategoryOverview,
+  mockCategoryTrend,
+  mockProductList,
+  mockProductTop,
+  mockProductTrend,
+  mockDeviceOverview,
+  mockDeviceList,
+  mockDeviceTrend,
+  mockShopOverview,
+  mockShopList,
+  mockShopRanking,
+  mockShopTrend,
+  mockProfitOverview,
+  mockProfitList,
+  mockProfitTrend,
+  mockProfitWarning,
+  mockRepurchaseOverview,
+  mockRepurchaseDistribution,
+  mockRepurchaseTrend,
+  mockRepurchaseUsers
+} from "./statisticsMock";
+
+type Result = {
+  code: number;
+  message: string;
+  data?: any;
+};
+
+type ResultTable = {
+  code: number;
+  message: string;
+  data?: {
+    list: Array<any>;
+    total: number;
+    pageSize: number;
+    currentPage: number;
+  };
+};
+
+const mockDelay = (ms: number = 300) => new Promise(resolve => setTimeout(resolve, ms));
+
+export const getStatisticsOverview = async (params: {
+  startDate: string;
+  endDate: string;
+  shopId?: number;
+}) => {
+  if (USE_MOCK) {
+    await mockDelay();
+    return { code: 200, message: "success", data: mockStatisticsOverview() };
+  }
+  return http.request<Result>("get", "/statistics/overview", { params });
+};
+
+export const getCategoryOverview = async (params: {
+  startDate: string;
+  endDate: string;
+  shopId?: number;
+}) => {
+  if (USE_MOCK) {
+    await mockDelay();
+    return { code: 200, message: "success", data: mockCategoryOverview() };
+  }
+  return http.request<Result>("get", "/statistics/category/overview", { params });
+};
+
+export const getCategoryTrend = async (params: {
+  startDate: string;
+  endDate: string;
+  category?: string;
+  shopId?: number;
+  period?: string;
+}) => {
+  if (USE_MOCK) {
+    await mockDelay();
+    return { code: 200, message: "success", data: mockCategoryTrend() };
+  }
+  return http.request<Result>("get", "/statistics/category/trend", { params });
+};
+
+export const getProductList = async (params: {
+  page: number;
+  pageSize: number;
+  startDate: string;
+  endDate: string;
+  shopId?: number;
+  deviceId?: string;
+  category?: string;
+  keyword?: string;
+  sortBy?: string;
+  sortOrder?: string;
+}) => {
+  if (USE_MOCK) {
+    await mockDelay();
+    return { code: 200, message: "success", data: mockProductList(params.page, params.pageSize) };
+  }
+  return http.request<ResultTable>("get", "/statistics/product/list", { params });
+};
+
+export const getProductTop = async (params: {
+  startDate: string;
+  endDate: string;
+  type: string;
+  limit?: number;
+}) => {
+  if (USE_MOCK) {
+    await mockDelay();
+    return { code: 200, message: "success", data: mockProductTop(params.type) };
+  }
+  return http.request<Result>("get", "/statistics/product/top", { params });
+};
+
+export const getProductTrend = async (productId: number, params: {
+  startDate: string;
+  endDate: string;
+  period?: string;
+}) => {
+  if (USE_MOCK) {
+    await mockDelay();
+    return { code: 200, message: "success", data: mockProductTrend() };
+  }
+  return http.request<Result>("get", `/statistics/product/${productId}/trend`, { params });
+};
+
+export const getDeviceOverview = async (params: {
+  startDate: string;
+  endDate: string;
+  shopId?: number;
+}) => {
+  if (USE_MOCK) {
+    await mockDelay();
+    return { code: 200, message: "success", data: mockDeviceOverview() };
+  }
+  return http.request<Result>("get", "/statistics/device/overview", { params });
+};
+
+export const getDeviceList = async (params: {
+  page: number;
+  pageSize: number;
+  startDate: string;
+  endDate: string;
+  shopId?: number;
+  status?: number;
+  keyword?: string;
+  sortBy?: string;
+  sortOrder?: string;
+}) => {
+  if (USE_MOCK) {
+    await mockDelay();
+    return { code: 200, message: "success", data: mockDeviceList(params.page, params.pageSize) };
+  }
+  return http.request<ResultTable>("get", "/statistics/device/list", { params });
+};
+
+export const getDeviceTrend = async (deviceId: string, params: {
+  startDate: string;
+  endDate: string;
+  period?: string;
+}) => {
+  if (USE_MOCK) {
+    await mockDelay();
+    return { code: 200, message: "success", data: mockDeviceTrend() };
+  }
+  return http.request<Result>("get", `/statistics/device/${deviceId}/trend`, { params });
+};
+
+export const getShopOverview = async (params: {
+  startDate: string;
+  endDate: string;
+  province?: string;
+  city?: string;
+}) => {
+  if (USE_MOCK) {
+    await mockDelay();
+    return { code: 200, message: "success", data: mockShopOverview() };
+  }
+  return http.request<Result>("get", "/statistics/shop/overview", { params });
+};
+
+export const getShopList = async (params: {
+  page: number;
+  pageSize: number;
+  startDate: string;
+  endDate: string;
+  province?: string;
+  city?: string;
+  district?: string;
+  status?: number;
+  keyword?: string;
+  sortBy?: string;
+  sortOrder?: string;
+}) => {
+  if (USE_MOCK) {
+    await mockDelay();
+    return { code: 200, message: "success", data: mockShopList(params.page, params.pageSize) };
+  }
+  return http.request<ResultTable>("get", "/statistics/shop/list", { params });
+};
+
+export const getShopRanking = async (params: {
+  startDate: string;
+  endDate: string;
+  type: string;
+  limit?: number;
+}) => {
+  if (USE_MOCK) {
+    await mockDelay();
+    return { code: 200, message: "success", data: mockShopRanking() };
+  }
+  return http.request<Result>("get", "/statistics/shop/ranking", { params });
+};
+
+export const getShopTrend = async (shopId: number, params: {
+  startDate: string;
+  endDate: string;
+  period?: string;
+}) => {
+  if (USE_MOCK) {
+    await mockDelay();
+    return { code: 200, message: "success", data: mockShopTrend() };
+  }
+  return http.request<Result>("get", `/statistics/shop/${shopId}/trend`, { params });
+};
+
+export const getProfitOverview = async (params: {
+  startDate: string;
+  endDate: string;
+  compareType?: string;
+}) => {
+  if (USE_MOCK) {
+    await mockDelay();
+    return { code: 200, message: "success", data: mockProfitOverview() };
+  }
+  return http.request<Result>("get", "/statistics/profit/overview", { params });
+};
+
+export const getProfitList = async (params: {
+  page: number;
+  pageSize: number;
+  startDate: string;
+  endDate: string;
+  province?: string;
+  city?: string;
+  shopId?: number;
+  compareType?: string;
+  sortBy?: string;
+  sortOrder?: string;
+}) => {
+  if (USE_MOCK) {
+    await mockDelay();
+    return { code: 200, message: "success", data: mockProfitList(params.page, params.pageSize) };
+  }
+  return http.request<ResultTable>("get", "/statistics/profit/list", { params });
+};
+
+export const getProfitTrend = async (params: {
+  startDate: string;
+  endDate: string;
+  shopId?: number;
+  period?: string;
+}) => {
+  if (USE_MOCK) {
+    await mockDelay();
+    return { code: 200, message: "success", data: mockProfitTrend() };
+  }
+  return http.request<Result>("get", "/statistics/profit/trend", { params });
+};
+
+export const getProfitWarning = async (params: {
+  startDate: string;
+  endDate: string;
+  type: string;
+  threshold?: number;
+}) => {
+  if (USE_MOCK) {
+    await mockDelay();
+    return { code: 200, message: "success", data: mockProfitWarning() };
+  }
+  return http.request<Result>("get", "/statistics/profit/warning", { params });
+};
+
+export const getRepurchaseOverview = async (params: {
+  startDate: string;
+  endDate: string;
+  shopId?: number;
+}) => {
+  if (USE_MOCK) {
+    await mockDelay();
+    return { code: 200, message: "success", data: mockRepurchaseOverview() };
+  }
+  return http.request<Result>("get", "/statistics/repurchase/overview", { params });
+};
+
+export const getRepurchaseDistribution = async (params: {
+  startDate: string;
+  endDate: string;
+  shopId?: number;
+  type: string;
+}) => {
+  if (USE_MOCK) {
+    await mockDelay();
+    return { code: 200, message: "success", data: mockRepurchaseDistribution(params.type) };
+  }
+  return http.request<Result>("get", "/statistics/repurchase/distribution", { params });
+};
+
+export const getRepurchaseTrend = async (params: {
+  startDate: string;
+  endDate: string;
+  shopId?: number;
+  period?: string;
+}) => {
+  if (USE_MOCK) {
+    await mockDelay();
+    return { code: 200, message: "success", data: mockRepurchaseTrend() };
+  }
+  return http.request<Result>("get", "/statistics/repurchase/trend", { params });
+};
+
+export const getRepurchaseUsers = async (params: {
+  page: number;
+  pageSize: number;
+  startDate: string;
+  endDate: string;
+  shopId?: number;
+  userLayer?: string;
+  minOrderCount?: number;
+  sortBy?: string;
+  sortOrder?: string;
+}) => {
+  if (USE_MOCK) {
+    await mockDelay();
+    return { code: 200, message: "success", data: mockRepurchaseUsers(params.page, params.pageSize) };
+  }
+  return http.request<ResultTable>("get", "/statistics/repurchase/users", { params });
+};
+
+export const exportStatistics = (params: {
+  type: string;
+  startDate: string;
+  endDate: string;
+  shopId?: number;
+}) => {
+  return http.request("get", "/statistics/export", { 
+    params, 
+    responseType: "blob" 
+  });
+};

+ 331 - 0
haha-admin-web/src/api/statisticsMock.ts

@@ -0,0 +1,331 @@
+const USE_MOCK = true;
+
+const generateDates = (days: number) => {
+  const dates: string[] = [];
+  const today = new Date();
+  for (let i = days - 1; i >= 0; i--) {
+    const date = new Date(today);
+    date.setDate(date.getDate() - i);
+    dates.push(`${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`);
+  }
+  return dates;
+};
+
+const generateRandomData = (length: number, min: number, max: number) => {
+  return Array.from({ length }, () => Math.floor(Math.random() * (max - min + 1)) + min);
+};
+
+export const mockStatisticsOverview = () => {
+  return {
+    totalSales: 1586320.50,
+    totalProfit: 425680.35,
+    avgProfitRate: 26.83,
+    totalOrders: 12580,
+    totalUsers: 8920,
+    totalDevices: 156,
+    onlineDevices: 142,
+    totalShops: 48,
+    avgOrderAmount: 126.10,
+    categoryList: [
+      { category: "饮料", salesAmount: 458620.00, profitAmount: 125680.00, profitRate: 27.40 },
+      { category: "零食", salesAmount: 386540.00, profitAmount: 98560.00, profitRate: 25.50 },
+      { category: "方便食品", salesAmount: 298760.00, profitAmount: 82340.00, profitRate: 27.56 },
+      { category: "乳制品", salesAmount: 186540.00, profitAmount: 52180.00, profitRate: 27.97 },
+      { category: "日用百货", salesAmount: 125680.00, profitAmount: 35620.00, profitRate: 28.35 },
+      { category: "水果", salesAmount: 78920.00, profitAmount: 18650.00, profitRate: 23.63 },
+      { category: "面包糕点", salesAmount: 35260.00, profitAmount: 8670.00, profitRate: 24.59 },
+      { category: "其他", salesAmount: 16000.50, profitAmount: 3980.35, profitRate: 24.88 }
+    ]
+  };
+};
+
+export const mockCategoryOverview = () => {
+  return [
+    { category: "饮料", quantity: 28560, salesAmount: 458620.00, costAmount: 332940.00, profitAmount: 125680.00, profitRate: 27.40, orderCount: 3856, percentage: 28.90 },
+    { category: "零食", quantity: 18920, salesAmount: 386540.00, costAmount: 287980.00, profitAmount: 98560.00, profitRate: 25.50, orderCount: 2980, percentage: 24.37 },
+    { category: "方便食品", quantity: 12450, salesAmount: 298760.00, costAmount: 216420.00, profitAmount: 82340.00, profitRate: 27.56, orderCount: 2156, percentage: 18.84 },
+    { category: "乳制品", quantity: 8960, salesAmount: 186540.00, costAmount: 134360.00, profitAmount: 52180.00, profitRate: 27.97, orderCount: 1680, percentage: 11.76 },
+    { category: "日用百货", quantity: 5680, salesAmount: 125680.00, costAmount: 90060.00, profitAmount: 35620.00, profitRate: 28.35, orderCount: 986, percentage: 7.92 },
+    { category: "水果", quantity: 3560, salesAmount: 78920.00, costAmount: 60270.00, profitAmount: 18650.00, profitRate: 23.63, orderCount: 568, percentage: 4.98 },
+    { category: "面包糕点", quantity: 2180, salesAmount: 35260.00, costAmount: 26590.00, profitAmount: 8670.00, profitRate: 24.59, orderCount: 320, percentage: 2.22 },
+    { category: "其他", quantity: 1250, salesAmount: 16000.50, costAmount: 12020.15, profitAmount: 3980.35, profitRate: 24.88, orderCount: 230, percentage: 1.01 }
+  ];
+};
+
+export const mockCategoryTrend = () => {
+  const dates = generateDates(30);
+  return {
+    dates,
+    series: [
+      { name: "饮料", data: generateRandomData(30, 12000, 18000) },
+      { name: "零食", data: generateRandomData(30, 10000, 15000) },
+      { name: "方便食品", data: generateRandomData(30, 8000, 12000) },
+      { name: "乳制品", data: generateRandomData(30, 5000, 8000) }
+    ]
+  };
+};
+
+export const mockProductList = (page: number, pageSize: number) => {
+  const allProducts = [
+    { productId: 1, productCode: "SP001", productName: "可口可乐500ml", category: "饮料", quantity: 3560, salesAmount: 10680.00, costAmount: 7120.00, profitAmount: 3560.00, profitRate: 33.33, orderCount: 3560, userCount: 2850 },
+    { productId: 2, productCode: "SP002", productName: "百事可乐500ml", category: "饮料", quantity: 2890, salesAmount: 8670.00, costAmount: 5780.00, profitAmount: 2890.00, profitRate: 33.33, orderCount: 2890, userCount: 2310 },
+    { productId: 3, productCode: "SP003", productName: "农夫山泉550ml", category: "饮料", quantity: 4520, salesAmount: 9040.00, costAmount: 6330.00, profitAmount: 2710.00, profitRate: 29.98, orderCount: 4520, userCount: 3620 },
+    { productId: 4, productCode: "SP004", productName: "乐事薯片原味", category: "零食", quantity: 1680, salesAmount: 11760.00, costAmount: 8400.00, profitAmount: 3360.00, profitRate: 28.57, orderCount: 1560, userCount: 1250 },
+    { productId: 5, productCode: "SP005", productName: "康师傅红烧牛肉面", category: "方便食品", quantity: 2340, salesAmount: 11700.00, costAmount: 8190.00, profitAmount: 3510.00, profitRate: 30.00, orderCount: 2100, userCount: 1680 },
+    { productId: 6, productCode: "SP006", productName: "伊利纯牛奶250ml", category: "乳制品", quantity: 3120, salesAmount: 15600.00, costAmount: 10920.00, profitAmount: 4680.00, profitRate: 30.00, orderCount: 2800, userCount: 2240 },
+    { productId: 7, productCode: "SP007", productName: "旺旺雪饼", category: "零食", quantity: 1890, salesAmount: 9450.00, costAmount: 6615.00, profitAmount: 2835.00, profitRate: 30.00, orderCount: 1750, userCount: 1400 },
+    { productId: 8, productCode: "SP008", productName: "统一冰红茶500ml", category: "饮料", quantity: 2680, salesAmount: 8040.00, costAmount: 5360.00, profitAmount: 2680.00, profitRate: 33.33, orderCount: 2680, userCount: 2140 },
+    { productId: 9, productCode: "SP009", productName: "奥利奥饼干", category: "零食", quantity: 1560, salesAmount: 10920.00, costAmount: 7800.00, profitAmount: 3120.00, profitRate: 28.57, orderCount: 1420, userCount: 1140 },
+    { productId: 10, productCode: "SP010", productName: "蒙牛酸酸乳", category: "乳制品", quantity: 2450, salesAmount: 9800.00, costAmount: 6860.00, profitAmount: 2940.00, profitRate: 30.00, orderCount: 2200, userCount: 1760 },
+    { productId: 11, productCode: "SP011", productName: "王老吉凉茶", category: "饮料", quantity: 1980, salesAmount: 9900.00, costAmount: 6930.00, profitAmount: 2970.00, profitRate: 30.00, orderCount: 1850, userCount: 1480 },
+    { productId: 12, productCode: "SP012", productName: "达利园蛋黄派", category: "面包糕点", quantity: 1350, salesAmount: 8100.00, costAmount: 5670.00, profitAmount: 2430.00, profitRate: 30.00, orderCount: 1200, userCount: 960 },
+    { productId: 13, productCode: "SP013", productName: "红牛功能饮料", category: "饮料", quantity: 1680, salesAmount: 13440.00, costAmount: 10080.00, profitAmount: 3360.00, profitRate: 25.00, orderCount: 1520, userCount: 1220 },
+    { productId: 14, productCode: "SP014", productName: "卫龙辣条", category: "零食", quantity: 2890, salesAmount: 8670.00, costAmount: 5780.00, profitAmount: 2890.00, profitRate: 33.33, orderCount: 2680, userCount: 2140 },
+    { productId: 15, productCode: "SP015", productName: "今麦郎方便面", category: "方便食品", quantity: 1860, salesAmount: 7440.00, costAmount: 5210.00, profitAmount: 2230.00, profitRate: 29.97, orderCount: 1680, userCount: 1340 }
+  ];
+  
+  const start = (page - 1) * pageSize;
+  const end = start + pageSize;
+  
+  return {
+    list: allProducts.slice(start, end),
+    total: allProducts.length,
+    pageSize,
+    currentPage: page
+  };
+};
+
+export const mockProductTop = (type: string) => {
+  const products = mockProductList(1, 15).list;
+  if (type === "quantity") {
+    return products.sort((a, b) => b.quantity - a.quantity).slice(0, 10);
+  }
+  return products.sort((a, b) => b.salesAmount - a.salesAmount).slice(0, 10);
+};
+
+export const mockProductTrend = () => {
+  const dates = generateDates(30);
+  return {
+    dates,
+    series: [
+      { name: "销售额", data: generateRandomData(30, 300, 500) }
+    ]
+  };
+};
+
+export const mockDeviceOverview = () => {
+  return {
+    totalDevices: 156,
+    onlineDevices: 142,
+    offlineDevices: 14,
+    onlineRate: "91.0%",
+    totalSales: 1586320.50,
+    totalOrders: 12580
+  };
+};
+
+export const mockDeviceList = (page: number, pageSize: number) => {
+  const allDevices = [
+    { deviceId: "D001", deviceName: "写字楼A座1楼", shopId: 1, shopName: "科技园店", status: 1, statusLabel: "在线", statusColor: "success", orderCount: 856, userCount: 425, salesAmount: 42800.00, avgOrderAmount: 50.00, dailySalesAmount: 1426.67 },
+    { deviceId: "D002", deviceName: "写字楼A座2楼", shopId: 1, shopName: "科技园店", status: 1, statusLabel: "在线", statusColor: "success", orderCount: 723, userCount: 361, salesAmount: 36150.00, avgOrderAmount: 50.00, dailySalesAmount: 1205.00 },
+    { deviceId: "D003", deviceName: "地铁站C出口", shopId: 2, shopName: "地铁商圈店", status: 1, statusLabel: "在线", statusColor: "success", orderCount: 1256, userCount: 628, salesAmount: 62800.00, avgOrderAmount: 50.00, dailySalesAmount: 2093.33 },
+    { deviceId: "D004", deviceName: "商场B区2楼", shopId: 2, shopName: "地铁商圈店", status: 1, statusLabel: "在线", statusColor: "success", orderCount: 1580, userCount: 790, salesAmount: 79000.00, avgOrderAmount: 50.00, dailySalesAmount: 2633.33 },
+    { deviceId: "D005", deviceName: "医院门诊楼", shopId: 3, shopName: "医院店", status: 1, statusLabel: "在线", statusColor: "success", orderCount: 645, userCount: 322, salesAmount: 32250.00, avgOrderAmount: 50.00, dailySalesAmount: 1075.00 },
+    { deviceId: "D006", deviceName: "体育馆入口", shopId: 4, shopName: "体育馆店", status: 0, statusLabel: "离线", statusColor: "info", orderCount: 0, userCount: 0, salesAmount: 0, avgOrderAmount: 0, dailySalesAmount: 0 },
+    { deviceId: "D007", deviceName: "公园东门", shopId: 5, shopName: "公园店", status: 1, statusLabel: "在线", statusColor: "success", orderCount: 423, userCount: 211, salesAmount: 21150.00, avgOrderAmount: 50.00, dailySalesAmount: 705.00 },
+    { deviceId: "D008", deviceName: "图书馆大厅", shopId: 6, shopName: "图书馆店", status: 1, statusLabel: "在线", statusColor: "success", orderCount: 512, userCount: 256, salesAmount: 25600.00, avgOrderAmount: 50.00, dailySalesAmount: 853.33 },
+    { deviceId: "D009", deviceName: "火车站候车室", shopId: 7, shopName: "火车站店", status: 1, statusLabel: "在线", statusColor: "success", orderCount: 1890, userCount: 945, salesAmount: 94500.00, avgOrderAmount: 50.00, dailySalesAmount: 3150.00 },
+    { deviceId: "D010", deviceName: "机场T1航站楼", shopId: 8, shopName: "机场店", status: 1, statusLabel: "在线", statusColor: "success", orderCount: 2156, userCount: 1078, salesAmount: 107800.00, avgOrderAmount: 50.00, dailySalesAmount: 3593.33 }
+  ];
+  
+  const start = (page - 1) * pageSize;
+  const end = start + pageSize;
+  
+  return {
+    list: allDevices.slice(start, end),
+    total: allDevices.length,
+    pageSize,
+    currentPage: page
+  };
+};
+
+export const mockDeviceTrend = () => {
+  const dates = generateDates(30);
+  return {
+    dates,
+    series: [
+      { name: "销售额", data: generateRandomData(30, 1000, 3000) }
+    ]
+  };
+};
+
+export const mockShopOverview = () => {
+  return {
+    totalShops: 48,
+    activeShops: 45,
+    totalSales: 1586320.50,
+    totalOrders: 12580
+  };
+};
+
+export const mockShopList = (page: number, pageSize: number) => {
+  const allShops = [
+    { shopId: 1, shopName: "科技园店", province: "广东省", city: "深圳市", district: "南山区", address: "科技园路88号", status: 1, statusLabel: "启用", deviceCount: 12, orderCount: 2856, userCount: 1428, salesAmount: 142800.00, avgOrderAmount: 50.00, dailySalesAmount: 4760.00, deviceOutput: 11900.00 },
+    { shopId: 2, shopName: "地铁商圈店", province: "广东省", city: "深圳市", district: "福田区", address: "福田地铁站B出口", status: 1, statusLabel: "启用", deviceCount: 8, orderCount: 1890, userCount: 945, salesAmount: 94500.00, avgOrderAmount: 50.00, dailySalesAmount: 3150.00, deviceOutput: 11812.50 },
+    { shopId: 3, shopName: "大学城店", province: "广东省", city: "深圳市", district: "南山区", address: "大学城学府路", status: 1, statusLabel: "启用", deviceCount: 15, orderCount: 3256, userCount: 1628, salesAmount: 162800.00, avgOrderAmount: 50.00, dailySalesAmount: 5426.67, deviceOutput: 10853.33 },
+    { shopId: 4, shopName: "医院店", province: "广东省", city: "深圳市", district: "福田区", address: "福田医院门诊楼", status: 1, statusLabel: "启用", deviceCount: 6, orderCount: 1256, userCount: 628, salesAmount: 62800.00, avgOrderAmount: 50.00, dailySalesAmount: 2093.33, deviceOutput: 10466.67 },
+    { shopId: 5, shopName: "体育馆店", province: "广东省", city: "深圳市", district: "龙岗区", address: "大运体育馆", status: 1, statusLabel: "启用", deviceCount: 10, orderCount: 1890, userCount: 945, salesAmount: 94500.00, avgOrderAmount: 50.00, dailySalesAmount: 3150.00, deviceOutput: 9450.00 },
+    { shopId: 6, shopName: "公园店", province: "广东省", city: "深圳市", district: "南山区", address: "深圳湾公园", status: 1, statusLabel: "启用", deviceCount: 5, orderCount: 856, userCount: 428, salesAmount: 42800.00, avgOrderAmount: 50.00, dailySalesAmount: 1426.67, deviceOutput: 8560.00 },
+    { shopId: 7, shopName: "图书馆店", province: "广东省", city: "深圳市", district: "福田区", address: "深圳图书馆", status: 1, statusLabel: "启用", deviceCount: 4, orderCount: 723, userCount: 361, salesAmount: 36150.00, avgOrderAmount: 50.00, dailySalesAmount: 1205.00, deviceOutput: 9037.50 },
+    { shopId: 8, shopName: "火车站店", province: "广东省", city: "深圳市", district: "罗湖区", address: "深圳火车站", status: 1, statusLabel: "启用", deviceCount: 18, orderCount: 4520, userCount: 2260, salesAmount: 226000.00, avgOrderAmount: 50.00, dailySalesAmount: 7533.33, deviceOutput: 12555.56 },
+    { shopId: 9, shopName: "机场店", province: "广东省", city: "深圳市", district: "宝安区", address: "宝安国际机场", status: 1, statusLabel: "启用", deviceCount: 20, orderCount: 5680, userCount: 2840, salesAmount: 284000.00, avgOrderAmount: 50.00, dailySalesAmount: 9466.67, deviceOutput: 14200.00 },
+    { shopId: 10, shopName: "购物中心店", province: "广东省", city: "深圳市", district: "福田区", address: "COCO Park购物中心", status: 1, statusLabel: "启用", deviceCount: 14, orderCount: 3560, userCount: 1780, salesAmount: 178000.00, avgOrderAmount: 50.00, dailySalesAmount: 5933.33, deviceOutput: 12714.29 }
+  ];
+  
+  const start = (page - 1) * pageSize;
+  const end = start + pageSize;
+  
+  return {
+    list: allShops.slice(start, end),
+    total: allShops.length,
+    pageSize,
+    currentPage: page
+  };
+};
+
+export const mockShopRanking = () => {
+  return mockShopList(1, 10).list.sort((a, b) => b.salesAmount - a.salesAmount).slice(0, 10);
+};
+
+export const mockShopTrend = () => {
+  const dates = generateDates(30);
+  return {
+    dates,
+    series: [
+      { name: "销售额", data: generateRandomData(30, 4000, 8000) }
+    ]
+  };
+};
+
+export const mockProfitOverview = () => {
+  return {
+    salesAmount: 1586320.50,
+    costAmount: 1160640.15,
+    grossProfit: 425680.35,
+    grossProfitRate: 26.83
+  };
+};
+
+export const mockProfitList = (page: number, pageSize: number) => {
+  const allProfits = [
+    { shopId: 1, shopName: "科技园店", province: "广东省", city: "深圳市", district: "南山区", deviceCount: 12, salesAmount: 142800.00, costAmount: 104256.00, grossProfit: 38544.00, grossProfitRate: 27.00, refundAmount: 0, refundRate: 0, netProfit: 38544.00, netProfitRate: 27.00, deviceProfit: 3212.00, dailyProfit: 1284.80 },
+    { shopId: 2, shopName: "地铁商圈店", province: "广东省", city: "深圳市", district: "福田区", deviceCount: 8, salesAmount: 94500.00, costAmount: 69045.00, grossProfit: 25455.00, grossProfitRate: 26.94, refundAmount: 0, refundRate: 0, netProfit: 25455.00, netProfitRate: 26.94, deviceProfit: 3181.88, dailyProfit: 848.50 },
+    { shopId: 3, shopName: "大学城店", province: "广东省", city: "深圳市", district: "南山区", deviceCount: 15, salesAmount: 162800.00, costAmount: 118844.00, grossProfit: 43956.00, grossProfitRate: 27.00, refundAmount: 0, refundRate: 0, netProfit: 43956.00, netProfitRate: 27.00, deviceProfit: 2930.40, dailyProfit: 1465.20 },
+    { shopId: 4, shopName: "医院店", province: "广东省", city: "深圳市", district: "福田区", deviceCount: 6, salesAmount: 62800.00, costAmount: 45844.00, grossProfit: 16956.00, grossProfitRate: 27.00, refundAmount: 0, refundRate: 0, netProfit: 16956.00, netProfitRate: 27.00, deviceProfit: 2826.00, dailyProfit: 565.20 },
+    { shopId: 5, shopName: "体育馆店", province: "广东省", city: "深圳市", district: "龙岗区", deviceCount: 10, salesAmount: 94500.00, costAmount: 69930.00, grossProfit: 24570.00, grossProfitRate: 26.00, refundAmount: 0, refundRate: 0, netProfit: 24570.00, netProfitRate: 26.00, deviceProfit: 2457.00, dailyProfit: 819.00 },
+    { shopId: 6, shopName: "公园店", province: "广东省", city: "深圳市", district: "南山区", deviceCount: 5, salesAmount: 42800.00, costAmount: 31458.00, grossProfit: 11342.00, grossProfitRate: 26.50, refundAmount: 0, refundRate: 0, netProfit: 11342.00, netProfitRate: 26.50, deviceProfit: 2268.40, dailyProfit: 378.07 },
+    { shopId: 7, shopName: "图书馆店", province: "广东省", city: "深圳市", district: "福田区", deviceCount: 4, salesAmount: 36150.00, costAmount: 26510.00, grossProfit: 9640.00, grossProfitRate: 26.67, refundAmount: 0, refundRate: 0, netProfit: 9640.00, netProfitRate: 26.67, deviceProfit: 2410.00, dailyProfit: 321.33 },
+    { shopId: 8, shopName: "火车站店", province: "广东省", city: "深圳市", district: "罗湖区", deviceCount: 18, salesAmount: 226000.00, costAmount: 165180.00, grossProfit: 60820.00, grossProfitRate: 26.91, refundAmount: 0, refundRate: 0, netProfit: 60820.00, netProfitRate: 26.91, deviceProfit: 3378.89, dailyProfit: 2027.33 },
+    { shopId: 9, shopName: "机场店", province: "广东省", city: "深圳市", district: "宝安区", deviceCount: 20, salesAmount: 284000.00, costAmount: 207320.00, grossProfit: 76680.00, grossProfitRate: 27.00, refundAmount: 0, refundRate: 0, netProfit: 76680.00, netProfitRate: 27.00, deviceProfit: 3834.00, dailyProfit: 2556.00 },
+    { shopId: 10, shopName: "购物中心店", province: "广东省", city: "深圳市", district: "福田区", deviceCount: 14, salesAmount: 178000.00, costAmount: 130094.00, grossProfit: 47906.00, grossProfitRate: 26.91, refundAmount: 0, refundRate: 0, netProfit: 47906.00, netProfitRate: 26.91, deviceProfit: 3421.86, dailyProfit: 1596.87 }
+  ];
+  
+  const start = (page - 1) * pageSize;
+  const end = start + pageSize;
+  
+  return {
+    list: allProfits.slice(start, end),
+    total: allProfits.length,
+    pageSize,
+    currentPage: page
+  };
+};
+
+export const mockProfitTrend = () => {
+  const dates = generateDates(30);
+  return {
+    dates,
+    series: [
+      { name: "利润", data: generateRandomData(30, 10000, 20000) }
+    ]
+  };
+};
+
+export const mockProfitWarning = () => {
+  return [
+    { shopId: 11, shopName: "测试门店A", province: "广东省", city: "深圳市", district: "南山区", deviceCount: 2, salesAmount: 8500.00, costAmount: 7650.00, grossProfit: 850.00, grossProfitRate: 10.00, netProfit: 850.00, netProfitRate: 10.00 },
+    { shopId: 12, shopName: "测试门店B", province: "广东省", city: "深圳市", district: "福田区", deviceCount: 1, salesAmount: 3200.00, costAmount: 3040.00, grossProfit: 160.00, grossProfitRate: 5.00, netProfit: 160.00, netProfitRate: 5.00 }
+  ];
+};
+
+export const mockRepurchaseOverview = () => {
+  return {
+    totalUsers: 8920,
+    newUsers: 3568,
+    repurchaseUsers: 5352,
+    repurchaseRate: 60.00,
+    avgOrderAmount: 126.10,
+    avgPurchaseCount: 1.41,
+    ltv: 177.80
+  };
+};
+
+export const mockRepurchaseDistribution = (type: string) => {
+  if (type === "interval") {
+    return {
+      distribution: {
+        "1天内": 1256,
+        "1-3天": 1890,
+        "3-7天": 2340,
+        "7-14天": 1560,
+        "14-30天": 980,
+        "30天以上": 520
+      }
+    };
+  }
+  return {
+    distribution: {
+      "新用户": 3568,
+      "活跃用户": 2856,
+      "忠诚用户": 1890,
+      "流失用户": 606
+    }
+  };
+};
+
+export const mockRepurchaseTrend = () => {
+  const dates = generateDates(30);
+  return {
+    dates,
+    series: [
+      { name: "复购率(%)", data: generateRandomData(30, 55, 75) }
+    ]
+  };
+};
+
+export const mockRepurchaseUsers = (page: number, pageSize: number) => {
+  const allUsers = [
+    { userId: 1, nickname: "张三", phone: "138****1234", orderCount: 15, totalAmount: 2250.00, avgOrderAmount: 150.00, firstOrderDate: "2025-01-15", lastOrderDate: "2025-03-24", repurchaseDays: 5, userLayer: "loyal", userLayerLabel: "忠诚用户" },
+    { userId: 2, nickname: "李四", phone: "139****5678", orderCount: 8, totalAmount: 960.00, avgOrderAmount: 120.00, firstOrderDate: "2025-02-01", lastOrderDate: "2025-03-23", repurchaseDays: 7, userLayer: "active", userLayerLabel: "活跃用户" },
+    { userId: 3, nickname: "王五", phone: "137****9012", orderCount: 12, totalAmount: 1560.00, avgOrderAmount: 130.00, firstOrderDate: "2025-01-20", lastOrderDate: "2025-03-22", repurchaseDays: 6, userLayer: "loyal", userLayerLabel: "忠诚用户" },
+    { userId: 4, nickname: "赵六", phone: "136****3456", orderCount: 3, totalAmount: 360.00, avgOrderAmount: 120.00, firstOrderDate: "2025-03-10", lastOrderDate: "2025-03-24", repurchaseDays: 7, userLayer: "active", userLayerLabel: "活跃用户" },
+    { userId: 5, nickname: "钱七", phone: "135****7890", orderCount: 1, totalAmount: 85.00, avgOrderAmount: 85.00, firstOrderDate: "2025-03-24", lastOrderDate: "2025-03-24", repurchaseDays: 0, userLayer: "new", userLayerLabel: "新用户" },
+    { userId: 6, nickname: "孙八", phone: "134****1234", orderCount: 6, totalAmount: 720.00, avgOrderAmount: 120.00, firstOrderDate: "2024-12-15", lastOrderDate: "2025-01-10", repurchaseDays: 5, userLayer: "churn", userLayerLabel: "流失用户" },
+    { userId: 7, nickname: "周九", phone: "133****5678", orderCount: 10, totalAmount: 1250.00, avgOrderAmount: 125.00, firstOrderDate: "2025-02-05", lastOrderDate: "2025-03-21", repurchaseDays: 8, userLayer: "active", userLayerLabel: "活跃用户" },
+    { userId: 8, nickname: "吴十", phone: "132****9012", orderCount: 20, totalAmount: 3200.00, avgOrderAmount: 160.00, firstOrderDate: "2025-01-01", lastOrderDate: "2025-03-24", repurchaseDays: 4, userLayer: "loyal", userLayerLabel: "忠诚用户" },
+    { userId: 9, nickname: "郑十一", phone: "131****3456", orderCount: 2, totalAmount: 240.00, avgOrderAmount: 120.00, firstOrderDate: "2025-03-20", lastOrderDate: "2025-03-24", repurchaseDays: 4, userLayer: "active", userLayerLabel: "活跃用户" },
+    { userId: 10, nickname: "王十二", phone: "130****7890", orderCount: 5, totalAmount: 650.00, avgOrderAmount: 130.00, firstOrderDate: "2025-02-28", lastOrderDate: "2025-03-23", repurchaseDays: 6, userLayer: "active", userLayerLabel: "活跃用户" }
+  ];
+  
+  const start = (page - 1) * pageSize;
+  const end = start + pageSize;
+  
+  return {
+    list: allUsers.slice(start, end),
+    total: allUsers.length,
+    pageSize,
+    currentPage: page
+  };
+};
+
+export { USE_MOCK };

+ 67 - 0
haha-admin-web/src/router/modules/statistics.ts

@@ -0,0 +1,67 @@
+export default {
+  path: "/statistics",
+  redirect: "/statistics/overview",
+  meta: {
+    icon: "ri:bar-chart-grouped-line",
+    title: "统计报表",
+    rank: 8
+  },
+  children: [
+    {
+      path: "/statistics/overview",
+      name: "StatisticsOverview",
+      component: () => import("@/views/statistics/overview/index.vue"),
+      meta: {
+        title: "统计概览"
+      }
+    },
+    {
+      path: "/statistics/category",
+      name: "CategoryStatistics",
+      component: () => import("@/views/statistics/category/index.vue"),
+      meta: {
+        title: "品类销售统计"
+      }
+    },
+    {
+      path: "/statistics/product",
+      name: "ProductStatistics",
+      component: () => import("@/views/statistics/product/index.vue"),
+      meta: {
+        title: "商品销售统计"
+      }
+    },
+    {
+      path: "/statistics/device",
+      name: "DeviceStatistics",
+      component: () => import("@/views/statistics/device/index.vue"),
+      meta: {
+        title: "设备销售统计"
+      }
+    },
+    {
+      path: "/statistics/shop",
+      name: "ShopStatistics",
+      component: () => import("@/views/statistics/shop/index.vue"),
+      meta: {
+        title: "门店销售统计"
+      }
+    },
+    {
+      path: "/statistics/profit",
+      name: "ProfitStatistics",
+      component: () => import("@/views/statistics/profit/index.vue"),
+      meta: {
+        title: "门店利润报表"
+      }
+    },
+    {
+      path: "/statistics/repurchase",
+      name: "RepurchaseStatistics",
+      component: () => import("@/views/statistics/repurchase/index.vue"),
+      meta: {
+        title: "用户复购统计"
+      }
+    }
+  ]
+} satisfies RouteConfigsTable;

+ 461 - 0
haha-admin-web/src/views/statistics/category/index.vue

@@ -0,0 +1,461 @@
+<script setup lang="ts">
+import { ref, onMounted } from "vue";
+import * as echarts from "echarts";
+import { getCategoryOverview, getCategoryTrend } from "@/api/statistics";
+
+defineOptions({
+  name: "CategoryStatistics"
+});
+
+const loading = ref(false);
+const dateRange = ref<[Date, Date]>([
+  new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
+  new Date()
+]);
+
+const categoryList = ref([]);
+const totalSales = ref(0);
+const totalProfit = ref(0);
+const avgProfitRate = ref(0);
+
+const pieChartRef = ref<HTMLElement | null>(null);
+const barChartRef = ref<HTMLElement | null>(null);
+const trendChartRef = ref<HTMLElement | null>(null);
+let pieChart: echarts.ECharts | null = null;
+let barChart: echarts.ECharts | null = null;
+let trendChart: echarts.ECharts | null = null;
+
+async function fetchData() {
+  loading.value = true;
+  try {
+    const params = {
+      startDate: formatDate(dateRange.value[0]),
+      endDate: formatDate(dateRange.value[1])
+    };
+    
+    const { data } = await getCategoryOverview(params);
+    categoryList.value = data;
+    
+    totalSales.value = data.reduce((sum: number, item: any) => sum + (item.salesAmount || 0), 0);
+    totalProfit.value = data.reduce((sum: number, item: any) => sum + (item.profitAmount || 0), 0);
+    avgProfitRate.value = totalSales.value > 0 
+      ? Number((totalProfit.value / totalSales.value * 100).toFixed(2))
+      : 0;
+    
+    initCharts();
+    await fetchTrendData();
+  } catch (error) {
+    console.error("获取品类统计数据失败:", error);
+  } finally {
+    loading.value = false;
+  }
+}
+
+async function fetchTrendData() {
+  try {
+    const params = {
+      startDate: formatDate(dateRange.value[0]),
+      endDate: formatDate(dateRange.value[1]),
+      period: "day"
+    };
+    
+    const { data } = await getCategoryTrend(params);
+    initTrendChart(data);
+  } catch (error) {
+    console.error("获取品类趋势数据失败:", error);
+  }
+}
+
+function formatDate(date: Date): string {
+  return date.toISOString().split("T")[0];
+}
+
+function initCharts() {
+  setTimeout(() => {
+    initPieChart();
+    initBarChart();
+  }, 100);
+}
+
+function initPieChart() {
+  if (!pieChartRef.value) return;
+  
+  if (pieChart) {
+    pieChart.dispose();
+  }
+  
+  pieChart = echarts.init(pieChartRef.value);
+  
+  const option: echarts.EChartsOption = {
+    title: {
+      text: "品类销售额占比",
+      left: "center",
+      textStyle: { fontSize: 14, fontWeight: "normal" }
+    },
+    tooltip: {
+      trigger: "item",
+      formatter: "{a} <br/>{b}: ¥{c} ({d}%)"
+    },
+    legend: {
+      orient: "vertical",
+      left: "left",
+      top: "middle"
+    },
+    series: [
+      {
+        name: "销售额",
+        type: "pie",
+        radius: ["40%", "70%"],
+        center: ["60%", "50%"],
+        avoidLabelOverlap: false,
+        itemStyle: {
+          borderRadius: 10,
+          borderColor: "#fff",
+          borderWidth: 2
+        },
+        label: {
+          show: false,
+          position: "center"
+        },
+        emphasis: {
+          label: {
+            show: true,
+            fontSize: 16,
+            fontWeight: "bold"
+          }
+        },
+        labelLine: {
+          show: false
+        },
+        data: categoryList.value.slice(0, 8).map((item: any) => ({
+          value: item.salesAmount,
+          name: item.category
+        }))
+      }
+    ]
+  };
+  
+  pieChart.setOption(option);
+}
+
+function initBarChart() {
+  if (!barChartRef.value) return;
+  
+  if (barChart) {
+    barChart.dispose();
+  }
+  
+  barChart = echarts.init(barChartRef.value);
+  
+  const categories = categoryList.value.slice(0, 10);
+  
+  const option: echarts.EChartsOption = {
+    title: {
+      text: "品类利润对比",
+      left: "center",
+      textStyle: { fontSize: 14, fontWeight: "normal" }
+    },
+    tooltip: {
+      trigger: "axis",
+      axisPointer: { type: "shadow" }
+    },
+    grid: {
+      left: "3%",
+      right: "4%",
+      bottom: "3%",
+      containLabel: true
+    },
+    xAxis: {
+      type: "category",
+      data: categories.map((item: any) => item.category),
+      axisLabel: {
+        rotate: 30
+      }
+    },
+    yAxis: {
+      type: "value",
+      name: "利润(元)"
+    },
+    series: [
+      {
+        name: "利润额",
+        type: "bar",
+        data: categories.map((item: any) => item.profitAmount),
+        itemStyle: {
+          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+            { offset: 0, color: "#83bff6" },
+            { offset: 0.5, color: "#188df0" },
+            { offset: 1, color: "#188df0" }
+          ])
+        }
+      }
+    ]
+  };
+  
+  barChart.setOption(option);
+}
+
+function initTrendChart(data: any) {
+  if (!trendChartRef.value) return;
+  
+  if (trendChart) {
+    trendChart.dispose();
+  }
+  
+  trendChart = echarts.init(trendChartRef.value);
+  
+  const option: echarts.EChartsOption = {
+    title: {
+      text: "品类销售趋势",
+      left: "center",
+      textStyle: { fontSize: 14, fontWeight: "normal" }
+    },
+    tooltip: {
+      trigger: "axis",
+      backgroundColor: "rgba(255, 255, 255, 0.9)",
+      borderColor: "#e5e7eb",
+      borderWidth: 1
+    },
+    legend: {
+      data: data.series?.map((s: any) => s.name) || [],
+      bottom: 0
+    },
+    grid: {
+      left: "3%",
+      right: "4%",
+      bottom: "15%",
+      containLabel: true
+    },
+    xAxis: {
+      type: "category",
+      data: data.dates || [],
+      axisLabel: {
+        rotate: 45
+      }
+    },
+    yAxis: {
+      type: "value",
+      name: "销售额(元)"
+    },
+    series: (data.series || []).map((s: any) => ({
+      name: s.name,
+      type: "line",
+      smooth: true,
+      data: s.data
+    }))
+  };
+  
+  trendChart.setOption(option);
+}
+
+function handleDateChange() {
+  fetchData();
+}
+
+function resizeCharts() {
+  pieChart?.resize();
+  barChart?.resize();
+  trendChart?.resize();
+}
+
+onMounted(() => {
+  fetchData();
+  window.addEventListener("resize", resizeCharts);
+});
+</script>
+
+<template>
+  <div class="category-statistics">
+    <el-card shadow="never" class="mb-4">
+      <div class="flex items-center justify-between">
+        <span class="text-lg font-medium">品类销售统计</span>
+        <el-date-picker
+          v-model="dateRange"
+          type="daterange"
+          range-separator="至"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          format="YYYY-MM-DD"
+          value-format="YYYY-MM-DD"
+          @change="handleDateChange"
+        />
+      </div>
+    </el-card>
+
+    <el-row :gutter="20" class="mb-6">
+      <el-col :span="8">
+        <el-card shadow="hover" class="stat-card-wrapper">
+          <div class="stat-card">
+            <div class="stat-icon bg-blue">
+              <i class="ri:money-cny-circle-line"></i>
+            </div>
+            <div class="stat-info">
+              <div class="stat-value">¥{{ totalSales.toLocaleString() }}</div>
+              <div class="stat-label">总销售额</div>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :span="8">
+        <el-card shadow="hover" class="stat-card-wrapper">
+          <div class="stat-card">
+            <div class="stat-icon bg-green">
+              <i class="ri:profit-line"></i>
+            </div>
+            <div class="stat-info">
+              <div class="stat-value">¥{{ totalProfit.toLocaleString() }}</div>
+              <div class="stat-label">总利润</div>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :span="8">
+        <el-card shadow="hover" class="stat-card-wrapper">
+          <div class="stat-card">
+            <div class="stat-icon bg-orange">
+              <i class="ri:percent-line"></i>
+            </div>
+            <div class="stat-info">
+              <div class="stat-value">{{ avgProfitRate }}%</div>
+              <div class="stat-label">平均利润率</div>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <el-row :gutter="20" class="mb-6">
+      <el-col :span="12">
+        <el-card shadow="hover" class="chart-card">
+          <div ref="pieChartRef" class="chart-container"></div>
+        </el-card>
+      </el-col>
+      <el-col :span="12">
+        <el-card shadow="hover" class="chart-card">
+          <div ref="barChartRef" class="chart-container"></div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <el-card shadow="hover" class="chart-card mb-6">
+      <div ref="trendChartRef" class="chart-container" style="height: 350px;"></div>
+    </el-card>
+
+    <el-card shadow="never">
+      <template #header>
+        <span class="font-medium">品类销售明细</span>
+      </template>
+      <el-table :data="categoryList" stripe v-loading="loading">
+        <el-table-column prop="category" label="品类名称" min-width="120" />
+        <el-table-column prop="quantity" label="销售数量" width="100" align="right">
+          <template #default="{ row }">
+            {{ row.quantity?.toLocaleString() || 0 }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="salesAmount" label="销售额(元)" width="120" align="right">
+          <template #default="{ row }">
+            ¥{{ row.salesAmount?.toLocaleString() || 0 }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="costAmount" label="成本额(元)" width="120" align="right">
+          <template #default="{ row }">
+            ¥{{ row.costAmount?.toLocaleString() || 0 }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="profitAmount" label="利润额(元)" width="120" align="right">
+          <template #default="{ row }">
+            <span :class="row.profitAmount >= 0 ? 'text-green-500' : 'text-red-500'">
+              ¥{{ row.profitAmount?.toLocaleString() || 0 }}
+            </span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="profitRate" label="利润率" width="100" align="right">
+          <template #default="{ row }">
+            <span :class="row.profitRate >= 0 ? 'text-green-500' : 'text-red-500'">
+              {{ row.profitRate }}%
+            </span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="orderCount" label="订单数" width="100" align="right">
+          <template #default="{ row }">
+            {{ row.orderCount?.toLocaleString() || 0 }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="percentage" label="销售占比" width="100" align="right">
+          <template #default="{ row }">
+            {{ row.percentage }}%
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.category-statistics {
+  padding: 20px;
+  background-color: var(--el-bg-color-page);
+  min-height: calc(100vh - 120px);
+}
+
+.stat-card-wrapper {
+  :deep(.el-card__body) {
+    padding: 20px;
+  }
+}
+
+.stat-card {
+  display: flex;
+  align-items: center;
+  
+  .stat-icon {
+    width: 56px;
+    height: 56px;
+    border-radius: 12px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    margin-right: 16px;
+    
+    i {
+      font-size: 28px;
+      color: white;
+    }
+    
+    &.bg-blue { background: linear-gradient(135deg, #409eff, #337ecc); }
+    &.bg-green { background: linear-gradient(135deg, #67c23a, #529b2e); }
+    &.bg-orange { background: linear-gradient(135deg, #fa8c16, #d46b08); }
+  }
+  
+  .stat-info {
+    flex: 1;
+    
+    .stat-value {
+      font-size: 24px;
+      font-weight: 600;
+      color: var(--el-text-color-primary);
+      margin-bottom: 4px;
+    }
+    
+    .stat-label {
+      font-size: 14px;
+      color: var(--el-text-color-secondary);
+    }
+  }
+}
+
+.chart-card {
+  height: 350px;
+  
+  :deep(.el-card__body) {
+    padding: 20px;
+    height: 100%;
+  }
+  
+  .chart-container {
+    width: 100%;
+    height: 100%;
+    min-height: 280px;
+  }
+}
+</style>

+ 488 - 0
haha-admin-web/src/views/statistics/device/index.vue

@@ -0,0 +1,488 @@
+<script setup lang="ts">
+import { ref, onMounted, reactive } from "vue";
+import * as echarts from "echarts";
+import { getDeviceOverview, getDeviceList, getDeviceTrend } from "@/api/statistics";
+
+defineOptions({
+  name: "DeviceStatistics"
+});
+
+const loading = ref(false);
+const dateRange = ref<[Date, Date]>([
+  new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
+  new Date()
+]);
+
+const queryParams = reactive({
+  page: 1,
+  pageSize: 10,
+  keyword: "",
+  status: null as number | null,
+  sortBy: "salesAmount",
+  sortOrder: "desc"
+});
+
+const overviewData = ref({
+  totalDevices: 0,
+  onlineDevices: 0,
+  offlineDevices: 0,
+  onlineRate: "0%",
+  totalSales: 0,
+  totalOrders: 0
+});
+
+const tableData = ref([]);
+const total = ref(0);
+
+const statusChartRef = ref<HTMLElement | null>(null);
+const salesChartRef = ref<HTMLElement | null>(null);
+let statusChart: echarts.ECharts | null = null;
+let salesChart: echarts.ECharts | null = null;
+
+async function fetchOverview() {
+  try {
+    const params = {
+      startDate: formatDate(dateRange.value[0]),
+      endDate: formatDate(dateRange.value[1])
+    };
+    
+    const { data } = await getDeviceOverview(params);
+    overviewData.value = data;
+    initStatusChart();
+  } catch (error) {
+    console.error("获取设备概览失败:", error);
+  }
+}
+
+async function fetchDeviceList() {
+  loading.value = true;
+  try {
+    const params = {
+      ...queryParams,
+      startDate: formatDate(dateRange.value[0]),
+      endDate: formatDate(dateRange.value[1])
+    };
+    
+    const { data } = await getDeviceList(params);
+    tableData.value = data.list || [];
+    total.value = data.total || 0;
+  } catch (error) {
+    console.error("获取设备统计数据失败:", error);
+  } finally {
+    loading.value = false;
+  }
+}
+
+function formatDate(date: Date): string {
+  return date.toISOString().split("T")[0];
+}
+
+function initStatusChart() {
+  if (!statusChartRef.value) return;
+  
+  if (statusChart) {
+    statusChart.dispose();
+  }
+  
+  statusChart = echarts.init(statusChartRef.value);
+  
+  const option: echarts.EChartsOption = {
+    title: {
+      text: "设备状态分布",
+      left: "center",
+      textStyle: { fontSize: 14, fontWeight: "normal" }
+    },
+    tooltip: {
+      trigger: "item",
+      formatter: "{a} <br/>{b}: {c} ({d}%)"
+    },
+    legend: {
+      bottom: "5%",
+      left: "center"
+    },
+    series: [
+      {
+        name: "设备状态",
+        type: "pie",
+        radius: ["40%", "70%"],
+        avoidLabelOverlap: false,
+        itemStyle: {
+          borderRadius: 10,
+          borderColor: "#fff",
+          borderWidth: 2
+        },
+        label: {
+          show: false,
+          position: "center"
+        },
+        emphasis: {
+          label: {
+            show: true,
+            fontSize: 20,
+            fontWeight: "bold"
+          }
+        },
+        labelLine: {
+          show: false
+        },
+        data: [
+          { value: overviewData.value.onlineDevices, name: "在线", itemStyle: { color: "#67c23a" } },
+          { value: overviewData.value.offlineDevices, name: "离线", itemStyle: { color: "#909399" } }
+        ]
+      }
+    ]
+  };
+  
+  statusChart.setOption(option);
+}
+
+async function showDeviceTrend(deviceId: string) {
+  try {
+    const params = {
+      startDate: formatDate(dateRange.value[0]),
+      endDate: formatDate(dateRange.value[1])
+    };
+    
+    const { data } = await getDeviceTrend(deviceId, params);
+    initSalesChart(data);
+  } catch (error) {
+    console.error("获取设备趋势失败:", error);
+  }
+}
+
+function initSalesChart(data: any) {
+  if (!salesChartRef.value) return;
+  
+  if (salesChart) {
+    salesChart.dispose();
+  }
+  
+  salesChart = echarts.init(salesChartRef.value);
+  
+  const option: echarts.EChartsOption = {
+    title: {
+      text: "设备销售趋势",
+      left: "center",
+      textStyle: { fontSize: 14, fontWeight: "normal" }
+    },
+    tooltip: {
+      trigger: "axis",
+      backgroundColor: "rgba(255, 255, 255, 0.9)",
+      borderColor: "#e5e7eb",
+      borderWidth: 1
+    },
+    grid: {
+      left: "3%",
+      right: "4%",
+      bottom: "3%",
+      containLabel: true
+    },
+    xAxis: {
+      type: "category",
+      data: data.dates || [],
+      axisLabel: {
+        rotate: 45
+      }
+    },
+    yAxis: {
+      type: "value",
+      name: "销售额(元)"
+    },
+    series: [
+      {
+        name: "销售额",
+        type: "line",
+        smooth: true,
+        data: data.series?.[0]?.data || [],
+        itemStyle: { color: "#409eff" },
+        areaStyle: {
+          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+            { offset: 0, color: "rgba(64, 158, 255, 0.3)" },
+            { offset: 1, color: "rgba(64, 158, 255, 0.05)" }
+          ])
+        }
+      }
+    ]
+  };
+  
+  salesChart.setOption(option);
+}
+
+function handleSearch() {
+  queryParams.page = 1;
+  fetchDeviceList();
+}
+
+function handleReset() {
+  queryParams.keyword = "";
+  queryParams.status = null;
+  queryParams.page = 1;
+  fetchDeviceList();
+}
+
+function handleSortChange({ prop, order }: any) {
+  queryParams.sortBy = prop || "salesAmount";
+  queryParams.sortOrder = order === "ascending" ? "asc" : "desc";
+  fetchDeviceList();
+}
+
+function handlePageChange(page: number) {
+  queryParams.page = page;
+  fetchDeviceList();
+}
+
+function handleSizeChange(size: number) {
+  queryParams.pageSize = size;
+  queryParams.page = 1;
+  fetchDeviceList();
+}
+
+function handleDateChange() {
+  fetchOverview();
+  fetchDeviceList();
+}
+
+function handleViewTrend(row: any) {
+  showDeviceTrend(row.deviceId);
+}
+
+function resizeCharts() {
+  statusChart?.resize();
+  salesChart?.resize();
+}
+
+onMounted(() => {
+  fetchOverview();
+  fetchDeviceList();
+  window.addEventListener("resize", resizeCharts);
+});
+</script>
+
+<template>
+  <div class="device-statistics">
+    <el-card shadow="never" class="mb-4">
+      <div class="flex items-center justify-between">
+        <span class="text-lg font-medium">设备销售统计</span>
+        <el-date-picker
+          v-model="dateRange"
+          type="daterange"
+          range-separator="至"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          format="YYYY-MM-DD"
+          value-format="YYYY-MM-DD"
+          @change="handleDateChange"
+        />
+      </div>
+    </el-card>
+
+    <el-row :gutter="20" class="mb-6">
+      <el-col :span="6">
+        <el-card shadow="hover">
+          <div class="stat-card">
+            <div class="stat-icon bg-blue">
+              <i class="ri:device-line"></i>
+            </div>
+            <div class="stat-info">
+              <div class="stat-value">{{ overviewData.totalDevices }}</div>
+              <div class="stat-label">设备总数</div>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card shadow="hover">
+          <div class="stat-card">
+            <div class="stat-icon bg-green">
+              <i class="ri:wifi-line"></i>
+            </div>
+            <div class="stat-info">
+              <div class="stat-value">{{ overviewData.onlineDevices }}</div>
+              <div class="stat-label">在线设备 ({{ overviewData.onlineRate }})</div>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card shadow="hover">
+          <div class="stat-card">
+            <div class="stat-icon bg-purple">
+              <i class="ri:money-cny-circle-line"></i>
+            </div>
+            <div class="stat-info">
+              <div class="stat-value">¥{{ overviewData.totalSales?.toLocaleString() }}</div>
+              <div class="stat-label">总销售额</div>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card shadow="hover">
+          <div class="stat-card">
+            <div class="stat-icon bg-orange">
+              <i class="ri:shopping-cart-line"></i>
+            </div>
+            <div class="stat-info">
+              <div class="stat-value">{{ overviewData.totalOrders }}</div>
+              <div class="stat-label">总订单数</div>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <el-row :gutter="20" class="mb-6">
+      <el-col :span="8">
+        <el-card shadow="hover" class="chart-card">
+          <div ref="statusChartRef" class="chart-container"></div>
+        </el-card>
+      </el-col>
+      <el-col :span="16">
+        <el-card shadow="hover" class="chart-card">
+          <div ref="salesChartRef" class="chart-container"></div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <el-card shadow="never">
+      <template #header>
+        <div class="flex items-center justify-between">
+          <span class="font-medium">设备销售明细</span>
+          <div class="flex gap-2">
+            <el-input
+              v-model="queryParams.keyword"
+              placeholder="设备ID/名称"
+              clearable
+              style="width: 200px"
+              @keyup.enter="handleSearch"
+            />
+            <el-select v-model="queryParams.status" placeholder="设备状态" clearable style="width: 120px">
+              <el-option label="在线" :value="1" />
+              <el-option label="离线" :value="0" />
+            </el-select>
+            <el-button type="primary" @click="handleSearch">搜索</el-button>
+            <el-button @click="handleReset">重置</el-button>
+          </div>
+        </div>
+      </template>
+      
+      <el-table
+        :data="tableData"
+        stripe
+        v-loading="loading"
+        @sort-change="handleSortChange"
+      >
+        <el-table-column prop="deviceId" label="设备ID" width="150" />
+        <el-table-column prop="deviceName" label="设备名称" min-width="120" show-overflow-tooltip />
+        <el-table-column prop="shopName" label="所属门店" min-width="120" show-overflow-tooltip />
+        <el-table-column prop="status" label="状态" width="80">
+          <template #default="{ row }">
+            <el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
+              {{ row.statusLabel }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="orderCount" label="订单数" width="80" align="right" sortable="custom" />
+        <el-table-column prop="userCount" label="购买用户数" width="100" align="right" />
+        <el-table-column prop="salesAmount" label="销售额(元)" width="120" align="right" sortable="custom">
+          <template #default="{ row }">
+            ¥{{ row.salesAmount?.toLocaleString() }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="avgOrderAmount" label="平均客单价" width="100" align="right">
+          <template #default="{ row }">
+            ¥{{ row.avgOrderAmount }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="dailySalesAmount" label="日均销售" width="100" align="right">
+          <template #default="{ row }">
+            ¥{{ row.dailySalesAmount }}
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="100" fixed="right">
+          <template #default="{ row }">
+            <el-button type="primary" link size="small" @click="handleViewTrend(row)">
+              趋势
+            </el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      
+      <div class="flex justify-end mt-4">
+        <el-pagination
+          v-model:current-page="queryParams.page"
+          v-model:page-size="queryParams.pageSize"
+          :page-sizes="[10, 20, 50, 100]"
+          :total="total"
+          layout="total, sizes, prev, pager, next, jumper"
+          @size-change="handleSizeChange"
+          @current-change="handlePageChange"
+        />
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.device-statistics {
+  padding: 20px;
+  background-color: var(--el-bg-color-page);
+  min-height: calc(100vh - 120px);
+}
+
+.stat-card {
+  display: flex;
+  align-items: center;
+  
+  .stat-icon {
+    width: 50px;
+    height: 50px;
+    border-radius: 12px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    margin-right: 16px;
+    
+    i {
+      font-size: 24px;
+      color: white;
+    }
+    
+    &.bg-blue { background: linear-gradient(135deg, #409eff, #337ecc); }
+    &.bg-green { background: linear-gradient(135deg, #67c23a, #529b2e); }
+    &.bg-purple { background: linear-gradient(135deg, #722ed1, #531dab); }
+    &.bg-orange { background: linear-gradient(135deg, #fa8c16, #d46b08); }
+  }
+  
+  .stat-info {
+    flex: 1;
+    
+    .stat-value {
+      font-size: 24px;
+      font-weight: 600;
+      color: var(--el-text-color-primary);
+      margin-bottom: 4px;
+    }
+    
+    .stat-label {
+      font-size: 14px;
+      color: var(--el-text-color-secondary);
+    }
+  }
+}
+
+.chart-card {
+  height: 300px;
+  
+  :deep(.el-card__body) {
+    padding: 20px;
+    height: 100%;
+  }
+  
+  .chart-container {
+    width: 100%;
+    height: 100%;
+    min-height: 250px;
+  }
+}
+</style>

+ 415 - 0
haha-admin-web/src/views/statistics/overview/index.vue

@@ -0,0 +1,415 @@
+<script setup lang="ts">
+import { ref, onMounted, computed } from "vue";
+import * as echarts from "echarts";
+import { getStatisticsOverview } from "@/api/statistics";
+
+defineOptions({
+  name: "StatisticsOverview"
+});
+
+const loading = ref(false);
+const dateRange = ref<[Date, Date]>([
+  new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
+  new Date()
+]);
+
+const overviewData = ref({
+  totalSales: 0,
+  totalProfit: 0,
+  avgProfitRate: 0,
+  totalOrders: 0,
+  totalUsers: 0,
+  totalDevices: 0,
+  onlineDevices: 0,
+  totalShops: 0,
+  avgOrderAmount: 0,
+  categoryList: []
+});
+
+const salesChartRef = ref<HTMLElement | null>(null);
+const categoryChartRef = ref<HTMLElement | null>(null);
+let salesChart: echarts.ECharts | null = null;
+let categoryChart: echarts.ECharts | null = null;
+
+const onlineRate = computed(() => {
+  if (overviewData.value.totalDevices === 0) return "0%";
+  return ((overviewData.value.onlineDevices / overviewData.value.totalDevices) * 100).toFixed(1) + "%";
+});
+
+async function fetchOverviewData() {
+  loading.value = true;
+  try {
+    const params = {
+      startDate: formatDate(dateRange.value[0]),
+      endDate: formatDate(dateRange.value[1])
+    };
+    const { data } = await getStatisticsOverview(params);
+    overviewData.value = data;
+    initCharts();
+  } catch (error) {
+    console.error("获取统计概览数据失败:", error);
+  } finally {
+    loading.value = false;
+  }
+}
+
+function formatDate(date: Date): string {
+  return date.toISOString().split("T")[0];
+}
+
+function initCharts() {
+  setTimeout(() => {
+    initSalesChart();
+    initCategoryChart();
+  }, 100);
+}
+
+function initSalesChart() {
+  if (!salesChartRef.value) return;
+  
+  if (salesChart) {
+    salesChart.dispose();
+  }
+  
+  salesChart = echarts.init(salesChartRef.value);
+  
+  const option: echarts.EChartsOption = {
+    title: {
+      text: "品类销售分布",
+      left: "center",
+      textStyle: { fontSize: 14, fontWeight: "normal" }
+    },
+    tooltip: {
+      trigger: "item",
+      formatter: "{a} <br/>{b}: ¥{c} ({d}%)"
+    },
+    legend: {
+      orient: "vertical",
+      left: "left",
+      top: "middle"
+    },
+    series: [
+      {
+        name: "销售额",
+        type: "pie",
+        radius: ["40%", "70%"],
+        center: ["60%", "50%"],
+        avoidLabelOverlap: false,
+        itemStyle: {
+          borderRadius: 10,
+          borderColor: "#fff",
+          borderWidth: 2
+        },
+        label: {
+          show: false,
+          position: "center"
+        },
+        emphasis: {
+          label: {
+            show: true,
+            fontSize: 16,
+            fontWeight: "bold"
+          }
+        },
+        labelLine: {
+          show: false
+        },
+        data: overviewData.value.categoryList.slice(0, 6).map((item: any) => ({
+          value: item.salesAmount,
+          name: item.category
+        }))
+      }
+    ]
+  };
+  
+  salesChart.setOption(option);
+}
+
+function initCategoryChart() {
+  if (!categoryChartRef.value) return;
+  
+  if (categoryChart) {
+    categoryChart.dispose();
+  }
+  
+  categoryChart = echarts.init(categoryChartRef.value);
+  
+  const categories = overviewData.value.categoryList.slice(0, 8);
+  
+  const option: echarts.EChartsOption = {
+    title: {
+      text: "品类利润对比",
+      left: "center",
+      textStyle: { fontSize: 14, fontWeight: "normal" }
+    },
+    tooltip: {
+      trigger: "axis",
+      axisPointer: { type: "shadow" }
+    },
+    grid: {
+      left: "3%",
+      right: "4%",
+      bottom: "3%",
+      containLabel: true
+    },
+    xAxis: {
+      type: "category",
+      data: categories.map((item: any) => item.category),
+      axisLabel: {
+        rotate: 30
+      }
+    },
+    yAxis: {
+      type: "value",
+      name: "利润(元)"
+    },
+    series: [
+      {
+        name: "利润额",
+        type: "bar",
+        data: categories.map((item: any) => item.profitAmount),
+        itemStyle: {
+          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+            { offset: 0, color: "#83bff6" },
+            { offset: 0.5, color: "#188df0" },
+            { offset: 1, color: "#188df0" }
+          ])
+        }
+      }
+    ]
+  };
+  
+  categoryChart.setOption(option);
+}
+
+function handleDateChange() {
+  fetchOverviewData();
+}
+
+function resizeCharts() {
+  salesChart?.resize();
+  categoryChart?.resize();
+}
+
+onMounted(() => {
+  fetchOverviewData();
+  window.addEventListener("resize", resizeCharts);
+});
+</script>
+
+<template>
+  <div class="statistics-overview">
+    <el-card shadow="never" class="mb-4">
+      <div class="flex items-center justify-between">
+        <span class="text-lg font-medium">统计概览</span>
+        <el-date-picker
+          v-model="dateRange"
+          type="daterange"
+          range-separator="至"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          format="YYYY-MM-DD"
+          value-format="YYYY-MM-DD"
+          @change="handleDateChange"
+        />
+      </div>
+    </el-card>
+
+    <el-row :gutter="20" class="mb-6">
+      <el-col :span="6">
+        <el-card shadow="hover" class="stat-card-wrapper">
+          <div class="stat-card">
+            <div class="stat-icon bg-blue">
+              <i class="ri:money-cny-circle-line"></i>
+            </div>
+            <div class="stat-info">
+              <div class="stat-value">¥{{ overviewData.totalSales?.toLocaleString() || 0 }}</div>
+              <div class="stat-label">总销售额</div>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card shadow="hover" class="stat-card-wrapper">
+          <div class="stat-card">
+            <div class="stat-icon bg-green">
+              <i class="ri:profit-line"></i>
+            </div>
+            <div class="stat-info">
+              <div class="stat-value">¥{{ overviewData.totalProfit?.toLocaleString() || 0 }}</div>
+              <div class="stat-label">总利润</div>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card shadow="hover" class="stat-card-wrapper">
+          <div class="stat-card">
+            <div class="stat-icon bg-purple">
+              <i class="ri:shopping-cart-line"></i>
+            </div>
+            <div class="stat-info">
+              <div class="stat-value">{{ overviewData.totalOrders?.toLocaleString() || 0 }}</div>
+              <div class="stat-label">总订单数</div>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card shadow="hover" class="stat-card-wrapper">
+          <div class="stat-card">
+            <div class="stat-icon bg-orange">
+              <i class="ri:percent-line"></i>
+            </div>
+            <div class="stat-info">
+              <div class="stat-value">{{ overviewData.avgProfitRate || 0 }}%</div>
+              <div class="stat-label">平均利润率</div>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <el-row :gutter="20" class="mb-6">
+      <el-col :span="6">
+        <el-card shadow="hover" class="stat-card-wrapper">
+          <div class="stat-card">
+            <div class="stat-icon bg-cyan">
+              <i class="ri:user-line"></i>
+            </div>
+            <div class="stat-info">
+              <div class="stat-value">{{ overviewData.totalUsers?.toLocaleString() || 0 }}</div>
+              <div class="stat-label">购买用户数</div>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card shadow="hover" class="stat-card-wrapper">
+          <div class="stat-card">
+            <div class="stat-icon bg-teal">
+              <i class="ri:device-line"></i>
+            </div>
+            <div class="stat-info">
+              <div class="stat-value">{{ overviewData.totalDevices || 0 }}</div>
+              <div class="stat-label">设备总数 (在线率: {{ onlineRate }})</div>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card shadow="hover" class="stat-card-wrapper">
+          <div class="stat-card">
+            <div class="stat-icon bg-indigo">
+              <i class="ri:store-2-line"></i>
+            </div>
+            <div class="stat-info">
+              <div class="stat-value">{{ overviewData.totalShops || 0 }}</div>
+              <div class="stat-label">门店总数</div>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card shadow="hover" class="stat-card-wrapper">
+          <div class="stat-card">
+            <div class="stat-icon bg-pink">
+              <i class="ri:coins-line"></i>
+            </div>
+            <div class="stat-info">
+              <div class="stat-value">¥{{ overviewData.avgOrderAmount || 0 }}</div>
+              <div class="stat-label">平均客单价</div>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <el-row :gutter="20">
+      <el-col :span="12">
+        <el-card shadow="hover" class="chart-card">
+          <div ref="salesChartRef" class="chart-container"></div>
+        </el-card>
+      </el-col>
+      <el-col :span="12">
+        <el-card shadow="hover" class="chart-card">
+          <div ref="categoryChartRef" class="chart-container"></div>
+        </el-card>
+      </el-col>
+    </el-row>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.statistics-overview {
+  padding: 20px;
+  background-color: var(--el-bg-color-page);
+  min-height: calc(100vh - 120px);
+}
+
+.stat-card-wrapper {
+  :deep(.el-card__body) {
+    padding: 20px;
+  }
+}
+
+.stat-card {
+  display: flex;
+  align-items: center;
+  
+  .stat-icon {
+    width: 56px;
+    height: 56px;
+    border-radius: 12px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    margin-right: 16px;
+    
+    i {
+      font-size: 28px;
+      color: white;
+    }
+    
+    &.bg-blue { background: linear-gradient(135deg, #409eff, #337ecc); }
+    &.bg-green { background: linear-gradient(135deg, #67c23a, #529b2e); }
+    &.bg-purple { background: linear-gradient(135deg, #722ed1, #531dab); }
+    &.bg-orange { background: linear-gradient(135deg, #fa8c16, #d46b08); }
+    &.bg-cyan { background: linear-gradient(135deg, #13c2c2, #08979c); }
+    &.bg-teal { background: linear-gradient(135deg, #52c41a, #389e0d); }
+    &.bg-indigo { background: linear-gradient(135deg, #597ef7, #2f54eb); }
+    &.bg-pink { background: linear-gradient(135deg, #eb2f96, #c41d7f); }
+  }
+  
+  .stat-info {
+    flex: 1;
+    
+    .stat-value {
+      font-size: 24px;
+      font-weight: 600;
+      color: var(--el-text-color-primary);
+      margin-bottom: 4px;
+    }
+    
+    .stat-label {
+      font-size: 14px;
+      color: var(--el-text-color-secondary);
+    }
+  }
+}
+
+.chart-card {
+  height: 380px;
+  
+  :deep(.el-card__body) {
+    padding: 20px;
+    height: 100%;
+  }
+  
+  .chart-container {
+    width: 100%;
+    height: 100%;
+    min-height: 320px;
+  }
+}
+</style>

+ 307 - 0
haha-admin-web/src/views/statistics/product/index.vue

@@ -0,0 +1,307 @@
+<script setup lang="ts">
+import { ref, onMounted, reactive } from "vue";
+import * as echarts from "echarts";
+import { getProductList, getProductTop } from "@/api/statistics";
+
+defineOptions({
+  name: "ProductStatistics"
+});
+
+const loading = ref(false);
+const dateRange = ref<[Date, Date]>([
+  new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
+  new Date()
+]);
+
+const queryParams = reactive({
+  page: 1,
+  pageSize: 10,
+  keyword: "",
+  category: "",
+  sortBy: "salesAmount",
+  sortOrder: "desc"
+});
+
+const tableData = ref([]);
+const total = ref(0);
+const topProducts = ref([]);
+
+const topChartRef = ref<HTMLElement | null>(null);
+let topChart: echarts.ECharts | null = null;
+
+async function fetchProductList() {
+  loading.value = true;
+  try {
+    const params = {
+      ...queryParams,
+      startDate: formatDate(dateRange.value[0]),
+      endDate: formatDate(dateRange.value[1])
+    };
+    
+    const { data } = await getProductList(params);
+    tableData.value = data.list || [];
+    total.value = data.total || 0;
+  } catch (error) {
+    console.error("获取商品统计数据失败:", error);
+  } finally {
+    loading.value = false;
+  }
+}
+
+async function fetchTopProducts() {
+  try {
+    const params = {
+      startDate: formatDate(dateRange.value[0]),
+      endDate: formatDate(dateRange.value[1]),
+      type: "sales",
+      limit: 10
+    };
+    
+    const { data } = await getProductTop(params);
+    topProducts.value = data || [];
+    initTopChart();
+  } catch (error) {
+    console.error("获取TOP商品失败:", error);
+  }
+}
+
+function formatDate(date: Date): string {
+  return date.toISOString().split("T")[0];
+}
+
+function initTopChart() {
+  if (!topChartRef.value || topProducts.value.length === 0) return;
+  
+  if (topChart) {
+    topChart.dispose();
+  }
+  
+  topChart = echarts.init(topChartRef.value);
+  
+  const option: echarts.EChartsOption = {
+    title: {
+      text: "商品销售额TOP10",
+      left: "center",
+      textStyle: { fontSize: 14, fontWeight: "normal" }
+    },
+    tooltip: {
+      trigger: "axis",
+      axisPointer: { type: "shadow" }
+    },
+    grid: {
+      left: "3%",
+      right: "4%",
+      bottom: "3%",
+      containLabel: true
+    },
+    xAxis: {
+      type: "value",
+      name: "销售额(元)"
+    },
+    yAxis: {
+      type: "category",
+      data: topProducts.value.map((item: any) => item.productName).reverse(),
+      axisLabel: {
+        width: 100,
+        overflow: "truncate"
+      }
+    },
+    series: [
+      {
+        name: "销售额",
+        type: "bar",
+        data: topProducts.value.map((item: any) => item.salesAmount).reverse(),
+        itemStyle: {
+          color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
+            { offset: 0, color: "#83bff6" },
+            { offset: 1, color: "#188df0" }
+          ])
+        }
+      }
+    ]
+  };
+  
+  topChart.setOption(option);
+}
+
+function handleSearch() {
+  queryParams.page = 1;
+  fetchProductList();
+}
+
+function handleReset() {
+  queryParams.keyword = "";
+  queryParams.category = "";
+  queryParams.page = 1;
+  fetchProductList();
+}
+
+function handleSortChange({ prop, order }: any) {
+  queryParams.sortBy = prop || "salesAmount";
+  queryParams.sortOrder = order === "ascending" ? "asc" : "desc";
+  fetchProductList();
+}
+
+function handlePageChange(page: number) {
+  queryParams.page = page;
+  fetchProductList();
+}
+
+function handleSizeChange(size: number) {
+  queryParams.pageSize = size;
+  queryParams.page = 1;
+  fetchProductList();
+}
+
+function handleDateChange() {
+  fetchProductList();
+  fetchTopProducts();
+}
+
+function resizeCharts() {
+  topChart?.resize();
+}
+
+onMounted(() => {
+  fetchProductList();
+  fetchTopProducts();
+  window.addEventListener("resize", resizeCharts);
+});
+</script>
+
+<template>
+  <div class="product-statistics">
+    <el-card shadow="never" class="mb-4">
+      <div class="flex items-center justify-between">
+        <span class="text-lg font-medium">商品销售统计</span>
+        <el-date-picker
+          v-model="dateRange"
+          type="daterange"
+          range-separator="至"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          format="YYYY-MM-DD"
+          value-format="YYYY-MM-DD"
+          @change="handleDateChange"
+        />
+      </div>
+    </el-card>
+
+    <el-row :gutter="20" class="mb-6">
+      <el-col :span="12">
+        <el-card shadow="hover" class="chart-card">
+          <div ref="topChartRef" class="chart-container"></div>
+        </el-card>
+      </el-col>
+      <el-col :span="12">
+        <el-card shadow="hover" class="chart-card">
+          <template #header>
+            <span>TOP10商品列表</span>
+          </template>
+          <el-table :data="topProducts" stripe max-height="300">
+            <el-table-column type="index" label="排名" width="60" />
+            <el-table-column prop="productName" label="商品名称" show-overflow-tooltip />
+            <el-table-column prop="quantity" label="销量" width="80" align="right" />
+            <el-table-column prop="salesAmount" label="销售额" width="100" align="right">
+              <template #default="{ row }">
+                ¥{{ row.salesAmount?.toLocaleString() }}
+              </template>
+            </el-table-column>
+          </el-table>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <el-card shadow="never">
+      <template #header>
+        <div class="flex items-center justify-between">
+          <span class="font-medium">商品销售明细</span>
+          <div class="flex gap-2">
+            <el-input
+              v-model="queryParams.keyword"
+              placeholder="商品名称/编码"
+              clearable
+              style="width: 200px"
+              @keyup.enter="handleSearch"
+            />
+            <el-button type="primary" @click="handleSearch">搜索</el-button>
+            <el-button @click="handleReset">重置</el-button>
+          </div>
+        </div>
+      </template>
+      
+      <el-table
+        :data="tableData"
+        stripe
+        v-loading="loading"
+        @sort-change="handleSortChange"
+      >
+        <el-table-column prop="productCode" label="商品编码" width="120" />
+        <el-table-column prop="productName" label="商品名称" min-width="150" show-overflow-tooltip />
+        <el-table-column prop="category" label="品类" width="100" />
+        <el-table-column prop="quantity" label="销售数量" width="100" align="right" sortable="custom" />
+        <el-table-column prop="salesAmount" label="销售额(元)" width="120" align="right" sortable="custom">
+          <template #default="{ row }">
+            ¥{{ row.salesAmount?.toLocaleString() }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="costAmount" label="成本额(元)" width="120" align="right">
+          <template #default="{ row }">
+            ¥{{ row.costAmount?.toLocaleString() }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="profitAmount" label="利润额(元)" width="120" align="right" sortable="custom">
+          <template #default="{ row }">
+            <span :class="row.profitAmount >= 0 ? 'text-green-500' : 'text-red-500'">
+              ¥{{ row.profitAmount?.toLocaleString() }}
+            </span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="profitRate" label="利润率" width="100" align="right">
+          <template #default="{ row }">
+            <span :class="row.profitRate >= 0 ? 'text-green-500' : 'text-red-500'">
+              {{ row.profitRate }}%
+            </span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="orderCount" label="订单数" width="80" align="right" />
+        <el-table-column prop="userCount" label="购买人数" width="90" align="right" />
+      </el-table>
+      
+      <div class="flex justify-end mt-4">
+        <el-pagination
+          v-model:current-page="queryParams.page"
+          v-model:page-size="queryParams.pageSize"
+          :page-sizes="[10, 20, 50, 100]"
+          :total="total"
+          layout="total, sizes, prev, pager, next, jumper"
+          @size-change="handleSizeChange"
+          @current-change="handlePageChange"
+        />
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.product-statistics {
+  padding: 20px;
+  background-color: var(--el-bg-color-page);
+  min-height: calc(100vh - 120px);
+}
+
+.chart-card {
+  height: 380px;
+  
+  :deep(.el-card__body) {
+    padding: 20px;
+    height: 100%;
+  }
+  
+  .chart-container {
+    width: 100%;
+    height: 100%;
+    min-height: 300px;
+  }
+}
+</style>

+ 514 - 0
haha-admin-web/src/views/statistics/profit/index.vue

@@ -0,0 +1,514 @@
+<script setup lang="ts">
+import { ref, onMounted, reactive } from "vue";
+import * as echarts from "echarts";
+import { getProfitOverview, getProfitList, getProfitTrend, getProfitWarning } from "@/api/statistics";
+
+defineOptions({
+  name: "ProfitStatistics"
+});
+
+const loading = ref(false);
+const dateRange = ref<[Date, Date]>([
+  new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
+  new Date()
+]);
+
+const queryParams = reactive({
+  page: 1,
+  pageSize: 10,
+  keyword: "",
+  province: "",
+  city: "",
+  shopId: null as number | null,
+  compareType: "",
+  sortBy: "netProfit",
+  sortOrder: "desc"
+});
+
+const overviewData = ref({
+  salesAmount: 0,
+  costAmount: 0,
+  grossProfit: 0,
+  grossProfitRate: 0
+});
+
+const tableData = ref([]);
+const total = ref(0);
+const warningData = ref([]);
+
+const profitChartRef = ref<HTMLElement | null>(null);
+const warningChartRef = ref<HTMLElement | null>(null);
+let profitChart: echarts.ECharts | null = null;
+let warningChart: echarts.ECharts | null = null;
+
+async function fetchOverview() {
+  try {
+    const params = {
+      startDate: formatDate(dateRange.value[0]),
+      endDate: formatDate(dateRange.value[1])
+    };
+    
+    const { data } = await getProfitOverview(params);
+    overviewData.value = data;
+  } catch (error) {
+    console.error("获取利润概览失败:", error);
+  }
+}
+
+async function fetchProfitList() {
+  loading.value = true;
+  try {
+    const params = {
+      ...queryParams,
+      startDate: formatDate(dateRange.value[0]),
+      endDate: formatDate(dateRange.value[1])
+    };
+    
+    const { data } = await getProfitList(params);
+    tableData.value = data.list || [];
+    total.value = data.total || 0;
+  } catch (error) {
+    console.error("获取利润数据失败:", error);
+  } finally {
+    loading.value = false;
+  }
+}
+
+async function fetchProfitTrend() {
+  try {
+    const params = {
+      startDate: formatDate(dateRange.value[0]),
+      endDate: formatDate(dateRange.value[1])
+    };
+    
+    const { data } = await getProfitTrend(params);
+    initProfitChart(data);
+  } catch (error) {
+    console.error("获取利润趋势失败:", error);
+  }
+}
+
+async function fetchWarning() {
+  try {
+    const params = {
+      startDate: formatDate(dateRange.value[0]),
+      endDate: formatDate(dateRange.value[1]),
+      type: "low"
+    };
+    
+    const { data } = await getProfitWarning(params);
+    warningData.value = data || [];
+    initWarningChart();
+  } catch (error) {
+    console.error("获取利润预警失败:", error);
+  }
+}
+
+function formatDate(date: Date): string {
+  return date.toISOString().split("T")[0];
+}
+
+function initProfitChart(data: any) {
+  if (!profitChartRef.value) return;
+  
+  if (profitChart) {
+    profitChart.dispose();
+  }
+  
+  profitChart = echarts.init(profitChartRef.value);
+  
+  const option: echarts.EChartsOption = {
+    title: {
+      text: "利润趋势",
+      left: "center",
+      textStyle: { fontSize: 14, fontWeight: "normal" }
+    },
+    tooltip: {
+      trigger: "axis",
+      backgroundColor: "rgba(255, 255, 255, 0.9)",
+      borderColor: "#e5e7eb",
+      borderWidth: 1
+    },
+    grid: {
+      left: "3%",
+      right: "4%",
+      bottom: "3%",
+      containLabel: true
+    },
+    xAxis: {
+      type: "category",
+      data: data.dates || [],
+      axisLabel: {
+        rotate: 45
+      }
+    },
+    yAxis: {
+      type: "value",
+      name: "利润(元)"
+    },
+    series: [
+      {
+        name: "利润",
+        type: "line",
+        smooth: true,
+        data: data.series?.[0]?.data || [],
+        itemStyle: { color: "#67c23a" },
+        areaStyle: {
+          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+            { offset: 0, color: "rgba(103, 194, 58, 0.3)" },
+            { offset: 1, color: "rgba(103, 194, 58, 0.05)" }
+          ])
+        }
+      }
+    ]
+  };
+  
+  profitChart.setOption(option);
+}
+
+function initWarningChart() {
+  if (!warningChartRef.value) return;
+  
+  if (warningChart) {
+    warningChart.dispose();
+  }
+  
+  warningChart = echarts.init(warningChartRef.value);
+  
+  const option: echarts.EChartsOption = {
+    title: {
+      text: "利润预警门店",
+      left: "center",
+      textStyle: { fontSize: 14, fontWeight: "normal" }
+    },
+    tooltip: {
+      trigger: "axis",
+      axisPointer: { type: "shadow" }
+    },
+    grid: {
+      left: "3%",
+      right: "4%",
+      bottom: "3%",
+      containLabel: true
+    },
+    xAxis: {
+      type: "value",
+      name: "净利润(元)"
+    },
+    yAxis: {
+      type: "category",
+      data: warningData.value.slice(0, 10).map((item: any) => item.shopName).reverse(),
+      axisLabel: {
+        width: 80,
+        overflow: "truncate"
+      }
+    },
+    series: [
+      {
+        name: "净利润",
+        type: "bar",
+        data: warningData.value.slice(0, 10).map((item: any) => item.netProfit).reverse(),
+        itemStyle: {
+          color: (params: any) => params.value >= 0 ? "#67c23a" : "#f56c6c"
+        }
+      }
+    ]
+  };
+  
+  warningChart.setOption(option);
+}
+
+function handleSearch() {
+  queryParams.page = 1;
+  fetchProfitList();
+}
+
+function handleReset() {
+  queryParams.keyword = "";
+  queryParams.province = "";
+  queryParams.city = "";
+  queryParams.shopId = null;
+  queryParams.compareType = "";
+  queryParams.page = 1;
+  fetchProfitList();
+}
+
+function handleSortChange({ prop, order }: any) {
+  queryParams.sortBy = prop || "netProfit";
+  queryParams.sortOrder = order === "ascending" ? "asc" : "desc";
+  fetchProfitList();
+}
+
+function handlePageChange(page: number) {
+  queryParams.page = page;
+  fetchProfitList();
+}
+
+function handleSizeChange(size: number) {
+  queryParams.pageSize = size;
+  queryParams.page = 1;
+  fetchProfitList();
+}
+
+function handleDateChange() {
+  fetchOverview();
+  fetchProfitList();
+  fetchProfitTrend();
+  fetchWarning();
+}
+
+function resizeCharts() {
+  profitChart?.resize();
+  warningChart?.resize();
+}
+
+onMounted(() => {
+  fetchOverview();
+  fetchProfitList();
+  fetchProfitTrend();
+  fetchWarning();
+  window.addEventListener("resize", resizeCharts);
+});
+</script>
+
+<template>
+  <div class="profit-statistics">
+    <el-card shadow="never" class="mb-4">
+      <div class="flex items-center justify-between">
+        <span class="text-lg font-medium">门店利润报表</span>
+        <el-date-picker
+          v-model="dateRange"
+          type="daterange"
+          range-separator="至"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          format="YYYY-MM-DD"
+          value-format="YYYY-MM-DD"
+          @change="handleDateChange"
+        />
+      </div>
+    </el-card>
+
+    <el-row :gutter="20" class="mb-6">
+      <el-col :span="6">
+        <el-card shadow="hover">
+          <div class="stat-card">
+            <div class="stat-icon bg-blue">
+              <i class="ri:money-cny-circle-line"></i>
+            </div>
+            <div class="stat-info">
+              <div class="stat-value">¥{{ overviewData.salesAmount?.toLocaleString() || 0 }}</div>
+              <div class="stat-label">销售收入</div>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card shadow="hover">
+          <div class="stat-card">
+            <div class="stat-icon bg-red">
+              <i class="ri:wallet-3-line"></i>
+            </div>
+            <div class="stat-info">
+              <div class="stat-value">¥{{ overviewData.costAmount?.toLocaleString() || 0 }}</div>
+              <div class="stat-label">销售成本</div>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card shadow="hover">
+          <div class="stat-card">
+            <div class="stat-icon bg-green">
+              <i class="ri:profit-line"></i>
+            </div>
+            <div class="stat-info">
+              <div class="stat-value">¥{{ overviewData.grossProfit?.toLocaleString() || 0 }}</div>
+              <div class="stat-label">毛利润</div>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card shadow="hover">
+          <div class="stat-card">
+            <div class="stat-icon bg-orange">
+              <i class="ri:percent-line"></i>
+            </div>
+            <div class="stat-info">
+              <div class="stat-value">{{ overviewData.grossProfitRate }}%</div>
+              <div class="stat-label">毛利率</div>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <el-row :gutter="20" class="mb-6">
+      <el-col :span="12">
+        <el-card shadow="hover" class="chart-card">
+          <div ref="profitChartRef" class="chart-container"></div>
+        </el-card>
+      </el-col>
+      <el-col :span="12">
+        <el-card shadow="hover" class="chart-card">
+          <div ref="warningChartRef" class="chart-container"></div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <el-card shadow="never">
+      <template #header>
+        <div class="flex items-center justify-between">
+          <span class="font-medium">门店利润明细</span>
+          <div class="flex items-center gap-2">
+            <el-input
+              v-model="queryParams.keyword"
+              placeholder="门店名称"
+              clearable
+              style="width: 200px"
+              @keyup.enter="handleSearch"
+            />
+            <el-select v-model="queryParams.compareType" placeholder="对比周期" clearable style="width: 120px">
+              <el-option label="同比" value="yoy" />
+              <el-option label="环比" value="mom" />
+            </el-select>
+            <el-button type="primary" @click="handleSearch">搜索</el-button>
+            <el-button @click="handleReset">重置</el-button>
+          </div>
+        </div>
+      </template>
+      
+      <el-table :data="tableData" stripe v-loading="loading" @sort-change="handleSortChange">
+        <el-table-column prop="shopName" label="门店名称" min-width="120" show-overflow-tooltip />
+        <el-table-column prop="province" label="省份" width="80" />
+        <el-table-column prop="city" label="城市" width="80" />
+        <el-table-column prop="salesAmount" label="销售收入(元)" width="120" align="right" sortable="custom">
+          <template #default="{ row }">
+            ¥{{ row.salesAmount?.toLocaleString() || 0 }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="costAmount" label="销售成本(元)" width="120" align="right">
+          <template #default="{ row }">
+            ¥{{ row.costAmount?.toLocaleString() || 0 }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="grossProfit" label="毛利润(元)" width="110" align="right" sortable="custom">
+          <template #default="{ row }">
+            <span :class="row.grossProfit >= 0 ? 'text-green-500' : 'text-red-500'">
+              ¥{{ row.grossProfit?.toLocaleString() || 0 }}
+            </span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="grossProfitRate" label="毛利率" width="90" align="right">
+          <template #default="{ row }">
+            <span :class="row.grossProfitRate >= 0 ? 'text-green-500' : 'text-red-500'">
+              {{ row.grossProfitRate }}%
+            </span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="netProfit" label="净利润(元)" width="110" align="right" sortable="custom">
+          <template #default="{ row }">
+            <span :class="row.netProfit >= 0 ? 'text-green-500' : 'text-red-500'">
+              ¥{{ row.netProfit?.toLocaleString() || 0 }}
+            </span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="netProfitRate" label="净利率" width="90" align="right">
+          <template #default="{ row }">
+            <span :class="row.netProfitRate >= 0 ? 'text-green-500' : 'text-red-500'">
+              {{ row.netProfitRate }}%
+            </span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="deviceCount" label="设备数" width="80" align="right" />
+        <el-table-column prop="deviceProfit" label="单设备利润" width="100" align="right">
+          <template #default="{ row }">
+            ¥{{ row.deviceProfit || 0 }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="dailyProfit" label="日均利润" width="100" align="right">
+          <template #default="{ row }">
+            ¥{{ row.dailyProfit || 0 }}
+          </template>
+        </el-table-column>
+      </el-table>
+      
+      <div class="flex justify-end mt-4">
+        <el-pagination
+          v-model:current-page="queryParams.page"
+          v-model:page-size="queryParams.pageSize"
+          :page-sizes="[10, 20, 50, 100]"
+          :total="total"
+          layout="total, sizes, prev, pager, next, jumper"
+          @size-change="handleSizeChange"
+          @current-change="handlePageChange"
+        />
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.profit-statistics {
+  padding: 20px;
+  background-color: var(--el-bg-color-page);
+  min-height: calc(100vh - 120px);
+}
+
+.stat-card {
+  display: flex;
+  align-items: center;
+  
+  .stat-icon {
+    width: 50px;
+    height: 50px;
+    border-radius: 12px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    margin-right: 16px;
+    
+    i {
+      font-size: 24px;
+      color: white;
+    }
+    
+    &.bg-blue { background: linear-gradient(135deg, #409eff, #337ecc); }
+    &.bg-green { background: linear-gradient(135deg, #67c23a, #529b2e); }
+    &.bg-red { background: linear-gradient(135deg, #f56c6c, #c45656); }
+    &.bg-orange { background: linear-gradient(135deg, #fa8c16, #d46b08); }
+  }
+  
+  .stat-info {
+    flex: 1;
+    
+    .stat-value {
+      font-size: 24px;
+      font-weight: 600;
+      color: var(--el-text-color-primary);
+      margin-bottom: 4px;
+    }
+    
+    .stat-label {
+      font-size: 14px;
+      color: var(--el-text-color-secondary);
+    }
+  }
+}
+
+.chart-card {
+  height: 350px;
+  
+  :deep(.el-card__body) {
+    padding: 20px;
+    height: 100%;
+  }
+  
+  .chart-container {
+    width: 100%;
+    height: 100%;
+    min-height: 280px;
+  }
+}
+</style>

+ 584 - 0
haha-admin-web/src/views/statistics/repurchase/index.vue

@@ -0,0 +1,584 @@
+<script setup lang="ts">
+import { ref, onMounted, reactive } from "vue";
+import * as echarts from "echarts";
+import { 
+  getRepurchaseOverview, 
+  getRepurchaseDistribution, 
+  getRepurchaseTrend, 
+  getRepurchaseUsers 
+} from "@/api/statistics";
+
+defineOptions({
+  name: "RepurchaseStatistics"
+});
+
+const loading = ref(false);
+const dateRange = ref<[Date, Date]>([
+  new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
+  new Date()
+]);
+
+const queryParams = reactive({
+  page: 1,
+  pageSize: 10,
+  shopId: null as number | null,
+  userLayer: "",
+  minOrderCount: null as number | null,
+  sortBy: "totalAmount",
+  sortOrder: "desc"
+});
+
+const overviewData = ref({
+  totalUsers: 0,
+  newUsers: 0,
+  repurchaseUsers: 0,
+  repurchaseRate: 0,
+  avgOrderAmount: 0,
+  avgPurchaseCount: 0,
+  ltv: 0
+});
+
+const tableData = ref([]);
+const total = ref(0);
+
+const layerChartRef = ref<HTMLElement | null>(null);
+const intervalChartRef = ref<HTMLElement | null>(null);
+const trendChartRef = ref<HTMLElement | null>(null);
+let layerChart: echarts.ECharts | null = null;
+let intervalChart: echarts.ECharts | null = null;
+let trendChart: echarts.ECharts | null = null;
+
+async function fetchOverview() {
+  try {
+    const params = {
+      startDate: formatDate(dateRange.value[0]),
+      endDate: formatDate(dateRange.value[1])
+    };
+    
+    const { data } = await getRepurchaseOverview(params);
+    overviewData.value = data;
+  } catch (error) {
+    console.error("获取复购概览失败:", error);
+  }
+}
+
+async function fetchDistribution() {
+  try {
+    const params = {
+      startDate: formatDate(dateRange.value[0]),
+      endDate: formatDate(dateRange.value[1]),
+      type: "layer"
+    };
+    
+    const { data } = await getRepurchaseDistribution(params);
+    initLayerChart(data.distribution);
+    
+    const intervalParams = {
+      startDate: formatDate(dateRange.value[0]),
+      endDate: formatDate(dateRange.value[1]),
+      type: "interval"
+    };
+    
+    const intervalRes = await getRepurchaseDistribution(intervalParams);
+    initIntervalChart(intervalRes.data.distribution);
+  } catch (error) {
+    console.error("获取复购分布失败:", error);
+  }
+}
+
+async function fetchTrend() {
+  try {
+    const params = {
+      startDate: formatDate(dateRange.value[0]),
+      endDate: formatDate(dateRange.value[1])
+    };
+    
+    const { data } = await getRepurchaseTrend(params);
+    initTrendChart(data);
+  } catch (error) {
+    console.error("获取复购趋势失败:", error);
+  }
+}
+
+async function fetchUserList() {
+  loading.value = true;
+  try {
+    const params = {
+      ...queryParams,
+      startDate: formatDate(dateRange.value[0]),
+      endDate: formatDate(dateRange.value[1])
+    };
+    
+    const { data } = await getRepurchaseUsers(params);
+    tableData.value = data.list || [];
+    total.value = data.total || 0;
+  } catch (error) {
+    console.error("获取用户复购数据失败:", error);
+  } finally {
+    loading.value = false;
+  }
+}
+
+function formatDate(date: Date): string {
+  return date.toISOString().split("T")[0];
+}
+
+function initLayerChart(distribution: any) {
+  if (!layerChartRef.value) return;
+  
+  if (layerChart) {
+    layerChart.dispose();
+  }
+  
+  layerChart = echarts.init(layerChartRef.value);
+  
+  const data = Object.entries(distribution || {}).map(([name, value]) => ({
+    name,
+    value
+  }));
+  
+  const option: echarts.EChartsOption = {
+    title: {
+      text: "用户分层分布",
+      left: "center",
+      textStyle: { fontSize: 14, fontWeight: "normal" }
+    },
+    tooltip: {
+      trigger: "item",
+      formatter: "{a} <br/>{b}: {c} ({d}%)"
+    },
+    legend: {
+      bottom: "5%",
+      left: "center"
+    },
+    series: [
+      {
+        name: "用户层级",
+        type: "pie",
+        radius: ["40%", "70%"],
+        avoidLabelOverlap: false,
+        itemStyle: {
+          borderRadius: 10,
+          borderColor: "#fff",
+          borderWidth: 2
+        },
+        label: {
+          show: false,
+          position: "center"
+        },
+        emphasis: {
+          label: {
+            show: true,
+            fontSize: 16,
+            fontWeight: "bold"
+          }
+        },
+        labelLine: {
+          show: false
+        },
+        data: data.map((item: any, index: number) => ({
+          ...item,
+          itemStyle: {
+            color: ["#409eff", "#67c23a", "#e6a23c", "#f56c6c"][index % 4]
+          }
+        }))
+      }
+    ]
+  };
+  
+  layerChart.setOption(option);
+}
+
+function initIntervalChart(distribution: any) {
+  if (!intervalChartRef.value) return;
+  
+  if (intervalChart) {
+    intervalChart.dispose();
+  }
+  
+  intervalChart = echarts.init(intervalChartRef.value);
+  
+  const categories = Object.keys(distribution || {});
+  const values = Object.values(distribution || {});
+  
+  const option: echarts.EChartsOption = {
+    title: {
+      text: "复购间隔分布",
+      left: "center",
+      textStyle: { fontSize: 14, fontWeight: "normal" }
+    },
+    tooltip: {
+      trigger: "axis",
+      axisPointer: { type: "shadow" }
+    },
+    grid: {
+      left: "3%",
+      right: "4%",
+      bottom: "3%",
+      containLabel: true
+    },
+    xAxis: {
+      type: "category",
+      data: categories
+    },
+    yAxis: {
+      type: "value",
+      name: "用户数"
+    },
+    series: [
+      {
+        name: "用户数",
+        type: "bar",
+        data: values,
+        itemStyle: {
+          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+            { offset: 0, color: "#83bff6" },
+            { offset: 1, color: "#188df0" }
+          ])
+        }
+      }
+    ]
+  };
+  
+  intervalChart.setOption(option);
+}
+
+function initTrendChart(data: any) {
+  if (!trendChartRef.value) return;
+  
+  if (trendChart) {
+    trendChart.dispose();
+  }
+  
+  trendChart = echarts.init(trendChartRef.value);
+  
+  const option: echarts.EChartsOption = {
+    title: {
+      text: "复购率趋势",
+      left: "center",
+      textStyle: { fontSize: 14, fontWeight: "normal" }
+    },
+    tooltip: {
+      trigger: "axis",
+      backgroundColor: "rgba(255, 255, 255, 0.9)",
+      borderColor: "#e5e7eb",
+      borderWidth: 1
+    },
+    grid: {
+      left: "3%",
+      right: "4%",
+      bottom: "3%",
+      containLabel: true
+    },
+    xAxis: {
+      type: "category",
+      data: data.dates || [],
+      axisLabel: {
+        rotate: 45
+      }
+    },
+    yAxis: {
+      type: "value",
+      name: "复购率(%)"
+    },
+    series: [
+      {
+        name: "复购率",
+        type: "line",
+        smooth: true,
+        data: data.series?.[0]?.data || [],
+        itemStyle: { color: "#722ed1" },
+        areaStyle: {
+          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+            { offset: 0, color: "rgba(114, 46, 209, 0.3)" },
+            { offset: 1, color: "rgba(114, 46, 209, 0.05)" }
+          ])
+        }
+      }
+    ]
+  };
+  
+  trendChart.setOption(option);
+}
+
+function handleSearch() {
+  queryParams.page = 1;
+  fetchUserList();
+}
+
+function handleReset() {
+  queryParams.shopId = null;
+  queryParams.userLayer = "";
+  queryParams.minOrderCount = null;
+  queryParams.page = 1;
+  fetchUserList();
+}
+
+function handleSortChange({ prop, order }: any) {
+  queryParams.sortBy = prop || "totalAmount";
+  queryParams.sortOrder = order === "ascending" ? "asc" : "desc";
+  fetchUserList();
+}
+
+function handlePageChange(page: number) {
+  queryParams.page = page;
+  fetchUserList();
+}
+
+function handleSizeChange(size: number) {
+  queryParams.pageSize = size;
+  queryParams.page = 1;
+  fetchUserList();
+}
+
+function handleDateChange() {
+  fetchOverview();
+  fetchDistribution();
+  fetchTrend();
+  fetchUserList();
+}
+
+function resizeCharts() {
+  layerChart?.resize();
+  intervalChart?.resize();
+  trendChart?.resize();
+}
+
+function getUserLayerTagType(layer: string): string {
+  switch (layer) {
+    case "new": return "info";
+    case "active": return "success";
+    case "loyal": return "warning";
+    case "churn": return "danger";
+    default: return "";
+  }
+}
+
+onMounted(() => {
+  fetchOverview();
+  fetchDistribution();
+  fetchTrend();
+  fetchUserList();
+  window.addEventListener("resize", resizeCharts);
+});
+</script>
+
+<template>
+  <div class="repurchase-statistics">
+    <el-card shadow="never" class="mb-4">
+      <div class="flex items-center justify-between">
+        <span class="text-lg font-medium">用户复购统计</span>
+        <el-date-picker
+          v-model="dateRange"
+          type="daterange"
+          range-separator="至"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          format="YYYY-MM-DD"
+          value-format="YYYY-MM-DD"
+          @change="handleDateChange"
+        />
+      </div>
+    </el-card>
+
+    <el-row :gutter="20" class="mb-6">
+      <el-col :span="6">
+        <el-card shadow="hover">
+          <div class="stat-card">
+            <div class="stat-icon bg-blue">
+              <i class="ri:user-line"></i>
+            </div>
+            <div class="stat-info">
+              <div class="stat-value">{{ overviewData.totalUsers?.toLocaleString() || 0 }}</div>
+              <div class="stat-label">总用户数</div>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card shadow="hover">
+          <div class="stat-card">
+            <div class="stat-icon bg-green">
+              <i class="ri:user-follow-line"></i>
+            </div>
+            <div class="stat-info">
+              <div class="stat-value">{{ overviewData.repurchaseUsers?.toLocaleString() || 0 }}</div>
+              <div class="stat-label">复购用户数</div>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card shadow="hover">
+          <div class="stat-card">
+            <div class="stat-icon bg-purple">
+              <i class="ri:percent-line"></i>
+            </div>
+            <div class="stat-info">
+              <div class="stat-value">{{ overviewData.repurchaseRate }}%</div>
+              <div class="stat-label">复购率</div>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card shadow="hover">
+          <div class="stat-card">
+            <div class="stat-icon bg-orange">
+              <i class="ri:coins-line"></i>
+            </div>
+            <div class="stat-info">
+              <div class="stat-value">¥{{ overviewData.ltv }}</div>
+              <div class="stat-label">用户LTV</div>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <el-row :gutter="20" class="mb-6">
+      <el-col :span="8">
+        <el-card shadow="hover" class="chart-card">
+          <div ref="layerChartRef" class="chart-container"></div>
+        </el-card>
+      </el-col>
+      <el-col :span="8">
+        <el-card shadow="hover" class="chart-card">
+          <div ref="intervalChartRef" class="chart-container"></div>
+        </el-card>
+      </el-col>
+      <el-col :span="8">
+        <el-card shadow="hover" class="chart-card">
+          <div ref="trendChartRef" class="chart-container"></div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <el-card shadow="never">
+      <template #header>
+        <div class="flex items-center justify-between">
+          <span class="font-medium">用户复购明细</span>
+          <div class="flex items-center gap-2">
+            <el-select v-model="queryParams.userLayer" placeholder="用户层级" clearable style="width: 120px">
+              <el-option label="新用户" value="new" />
+              <el-option label="活跃用户" value="active" />
+              <el-option label="忠诚用户" value="loyal" />
+              <el-option label="流失用户" value="churn" />
+            </el-select>
+            <el-input-number
+              v-model="queryParams.minOrderCount"
+              placeholder="最小购买次数"
+              :min="1"
+              controls-position="right"
+              style="width: 140px"
+            />
+            <el-button type="primary" @click="handleSearch">搜索</el-button>
+            <el-button @click="handleReset">重置</el-button>
+          </div>
+        </div>
+      </template>
+      
+      <el-table :data="tableData" stripe v-loading="loading" @sort-change="handleSortChange">
+        <el-table-column prop="nickname" label="用户昵称" width="120" show-overflow-tooltip />
+        <el-table-column prop="phone" label="手机号" width="120" />
+        <el-table-column prop="orderCount" label="购买次数" width="100" align="right" sortable="custom" />
+        <el-table-column prop="totalAmount" label="累计消费(元)" width="120" align="right" sortable="custom">
+          <template #default="{ row }">
+            ¥{{ row.totalAmount?.toLocaleString() || 0 }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="avgOrderAmount" label="平均客单价" width="100" align="right">
+          <template #default="{ row }">
+            ¥{{ row.avgOrderAmount || 0 }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="firstOrderDate" label="首次下单" width="110" />
+        <el-table-column prop="lastOrderDate" label="最近下单" width="110" />
+        <el-table-column prop="repurchaseDays" label="复购间隔(天)" width="110" align="right" />
+        <el-table-column prop="userLayerLabel" label="用户层级" width="100" align="center">
+          <template #default="{ row }">
+            <el-tag :type="getUserLayerTagType(row.userLayer)" size="small">
+              {{ row.userLayerLabel }}
+            </el-tag>
+          </template>
+        </el-table-column>
+      </el-table>
+      
+      <div class="flex justify-end mt-4">
+        <el-pagination
+          v-model:current-page="queryParams.page"
+          v-model:page-size="queryParams.pageSize"
+          :page-sizes="[10, 20, 50, 100]"
+          :total="total"
+          layout="total, sizes, prev, pager, next, jumper"
+          @size-change="handleSizeChange"
+          @current-change="handlePageChange"
+        />
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.repurchase-statistics {
+  padding: 20px;
+  background-color: var(--el-bg-color-page);
+  min-height: calc(100vh - 120px);
+}
+
+.stat-card {
+  display: flex;
+  align-items: center;
+  
+  .stat-icon {
+    width: 50px;
+    height: 50px;
+    border-radius: 12px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    margin-right: 16px;
+    
+    i {
+      font-size: 24px;
+      color: white;
+    }
+    
+    &.bg-blue { background: linear-gradient(135deg, #409eff, #337ecc); }
+    &.bg-green { background: linear-gradient(135deg, #67c23a, #529b2e); }
+    &.bg-purple { background: linear-gradient(135deg, #722ed1, #531dab); }
+    &.bg-orange { background: linear-gradient(135deg, #fa8c16, #d46b08); }
+  }
+  
+  .stat-info {
+    flex: 1;
+    
+    .stat-value {
+      font-size: 24px;
+      font-weight: 600;
+      color: var(--el-text-color-primary);
+      margin-bottom: 4px;
+    }
+    
+    .stat-label {
+      font-size: 14px;
+      color: var(--el-text-color-secondary);
+    }
+  }
+}
+
+.chart-card {
+  height: 320px;
+  
+  :deep(.el-card__body) {
+    padding: 20px;
+    height: 100%;
+  }
+  
+  .chart-container {
+    width: 100%;
+    height: 100%;
+    min-height: 260px;
+  }
+}
+</style>

+ 499 - 0
haha-admin-web/src/views/statistics/shop/index.vue

@@ -0,0 +1,499 @@
+<script setup lang="ts">
+import { ref, onMounted, reactive } from "vue";
+import * as echarts from "echarts";
+import { getShopOverview, getShopList, getShopRanking, getShopTrend } from "@/api/statistics";
+
+defineOptions({
+  name: "ShopStatistics"
+});
+
+const loading = ref(false);
+const dateRange = ref<[Date, Date]>([
+  new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
+  new Date()
+]);
+
+const queryParams = reactive({
+  page: 1,
+  pageSize: 10,
+  keyword: "",
+  province: "",
+  city: "",
+  district: "",
+  status: null as number | null,
+  sortBy: "salesAmount",
+  sortOrder: "desc"
+});
+
+const overviewData = ref({
+  totalShops: 0,
+  activeShops: 0,
+  totalSales: 0,
+  totalOrders: 0
+});
+
+const tableData = ref([]);
+const total = ref(0);
+const rankingData = ref([]);
+
+const salesChartRef = ref<HTMLElement | null>(null);
+const rankingChartRef = ref<HTMLElement | null>(null);
+let salesChart: echarts.ECharts | null = null;
+let rankingChart: echarts.ECharts | null = null;
+
+async function fetchOverview() {
+  try {
+    const params = {
+      startDate: formatDate(dateRange.value[0]),
+      endDate: formatDate(dateRange.value[1])
+    };
+    
+    const { data } = await getShopOverview(params);
+    overviewData.value = data;
+  } catch (error) {
+    console.error("获取门店概览失败:", error);
+  }
+}
+
+async function fetchShopList() {
+  loading.value = true;
+  try {
+    const params = {
+      ...queryParams,
+      startDate: formatDate(dateRange.value[0]),
+      endDate: formatDate(dateRange.value[1])
+    };
+    
+    const { data } = await getShopList(params);
+    tableData.value = data.list || [];
+    total.value = data.total || 0;
+  } catch (error) {
+    console.error("获取门店统计数据失败:", error);
+  } finally {
+    loading.value = false;
+  }
+}
+
+async function fetchRanking() {
+  try {
+    const params = {
+      startDate: formatDate(dateRange.value[0]),
+      endDate: formatDate(dateRange.value[1]),
+      type: "sales",
+      limit: 10
+    };
+    
+    const { data } = await getShopRanking(params);
+    rankingData.value = data || [];
+    initRankingChart();
+  } catch (error) {
+    console.error("获取门店排行失败:", error);
+  }
+}
+
+function formatDate(date: Date): string {
+  return date.toISOString().split("T")[0];
+}
+
+function initRankingChart() {
+  if (!rankingChartRef.value) return;
+  
+  if (rankingChart) {
+    rankingChart.dispose();
+  }
+  
+  rankingChart = echarts.init(rankingChartRef.value);
+  
+  const option: echarts.EChartsOption = {
+    title: {
+      text: "门店销售排行 TOP10",
+      left: "center",
+      textStyle: { fontSize: 14, fontWeight: "normal" }
+    },
+    tooltip: {
+      trigger: "axis",
+      axisPointer: { type: "shadow" }
+    },
+    grid: {
+      left: "3%",
+      right: "4%",
+      bottom: "3%",
+      containLabel: true
+    },
+    xAxis: {
+      type: "value",
+      name: "销售额(元)"
+    },
+    yAxis: {
+      type: "category",
+      data: rankingData.value.map((item: any) => item.shopName).reverse(),
+      axisLabel: {
+        width: 80,
+        overflow: "truncate"
+      }
+    },
+    series: [
+      {
+        name: "销售额",
+        type: "bar",
+        data: rankingData.value.map((item: any) => item.salesAmount).reverse(),
+        itemStyle: {
+          color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
+            { offset: 0, color: "#83bff6" },
+            { offset: 1, color: "#188df0" }
+          ])
+        }
+      }
+    ]
+  };
+  
+  rankingChart.setOption(option);
+}
+
+async function showShopTrend(shopId: number) {
+  try {
+    const params = {
+      startDate: formatDate(dateRange.value[0]),
+      endDate: formatDate(dateRange.value[1])
+    };
+    
+    const { data } = await getShopTrend(shopId, params);
+    initSalesChart(data);
+  } catch (error) {
+    console.error("获取门店趋势失败:", error);
+  }
+}
+
+function initSalesChart(data: any) {
+  if (!salesChartRef.value) return;
+  
+  if (salesChart) {
+    salesChart.dispose();
+  }
+  
+  salesChart = echarts.init(salesChartRef.value);
+  
+  const option: echarts.EChartsOption = {
+    title: {
+      text: "门店销售趋势",
+      left: "center",
+      textStyle: { fontSize: 14, fontWeight: "normal" }
+    },
+    tooltip: {
+      trigger: "axis",
+      backgroundColor: "rgba(255, 255, 255, 0.9)",
+      borderColor: "#e5e7eb",
+      borderWidth: 1
+    },
+    grid: {
+      left: "3%",
+      right: "4%",
+      bottom: "3%",
+      containLabel: true
+    },
+    xAxis: {
+      type: "category",
+      data: data.dates || [],
+      axisLabel: {
+        rotate: 45
+      }
+    },
+    yAxis: {
+      type: "value",
+      name: "销售额(元)"
+    },
+    series: [
+      {
+        name: "销售额",
+        type: "line",
+        smooth: true,
+        data: data.series?.[0]?.data || [],
+        itemStyle: { color: "#67c23a" },
+        areaStyle: {
+          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+            { offset: 0, color: "rgba(103, 194, 58, 0.3)" },
+            { offset: 1, color: "rgba(103, 194, 58, 0.05)" }
+          ])
+        }
+      }
+    ]
+  };
+  
+  salesChart.setOption(option);
+}
+
+function handleSearch() {
+  queryParams.page = 1;
+  fetchShopList();
+}
+
+function handleReset() {
+  queryParams.keyword = "";
+  queryParams.province = "";
+  queryParams.city = "";
+  queryParams.district = "";
+  queryParams.status = null;
+  queryParams.page = 1;
+  fetchShopList();
+}
+
+function handleSortChange({ prop, order }: any) {
+  queryParams.sortBy = prop || "salesAmount";
+  queryParams.sortOrder = order === "ascending" ? "asc" : "desc";
+  fetchShopList();
+}
+
+function handlePageChange(page: number) {
+  queryParams.page = page;
+  fetchShopList();
+}
+
+function handleSizeChange(size: number) {
+  queryParams.pageSize = size;
+  queryParams.page = 1;
+  fetchShopList();
+}
+
+function handleDateChange() {
+  fetchOverview();
+  fetchShopList();
+  fetchRanking();
+}
+
+function handleViewTrend(row: any) {
+  showShopTrend(row.shopId);
+}
+
+function resizeCharts() {
+  salesChart?.resize();
+  rankingChart?.resize();
+}
+
+onMounted(() => {
+  fetchOverview();
+  fetchShopList();
+  fetchRanking();
+  window.addEventListener("resize", resizeCharts);
+});
+</script>
+
+<template>
+  <div class="shop-statistics">
+    <el-card shadow="never" class="mb-4">
+      <div class="flex items-center justify-between">
+        <span class="text-lg font-medium">门店销售统计</span>
+        <el-date-picker
+          v-model="dateRange"
+          type="daterange"
+          range-separator="至"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          format="YYYY-MM-DD"
+          value-format="YYYY-MM-DD"
+          @change="handleDateChange"
+        />
+      </div>
+    </el-card>
+
+    <el-row :gutter="20" class="mb-6">
+      <el-col :span="6">
+        <el-card shadow="hover">
+          <div class="stat-card">
+            <div class="stat-icon bg-blue">
+              <i class="ri:store-2-line"></i>
+            </div>
+            <div class="stat-info">
+              <div class="stat-value">{{ overviewData.totalShops }}</div>
+              <div class="stat-label">门店总数</div>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card shadow="hover">
+          <div class="stat-card">
+            <div class="stat-icon bg-green">
+              <i class="ri:store-3-line"></i>
+            </div>
+            <div class="stat-info">
+              <div class="stat-value">{{ overviewData.activeShops }}</div>
+              <div class="stat-label">营业门店</div>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card shadow="hover">
+          <div class="stat-card">
+            <div class="stat-icon bg-purple">
+              <i class="ri:money-cny-circle-line"></i>
+            </div>
+            <div class="stat-info">
+              <div class="stat-value">¥{{ overviewData.totalSales?.toLocaleString() || 0 }}</div>
+              <div class="stat-label">总销售额</div>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+      <el-col :span="6">
+        <el-card shadow="hover">
+          <div class="stat-card">
+            <div class="stat-icon bg-orange">
+              <i class="ri:shopping-cart-line"></i>
+            </div>
+            <div class="stat-info">
+              <div class="stat-value">{{ overviewData.totalOrders?.toLocaleString() || 0 }}</div>
+              <div class="stat-label">总订单数</div>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <el-row :gutter="20" class="mb-6">
+      <el-col :span="12">
+        <el-card shadow="hover" class="chart-card">
+          <div ref="rankingChartRef" class="chart-container"></div>
+        </el-card>
+      </el-col>
+      <el-col :span="12">
+        <el-card shadow="hover" class="chart-card">
+          <div ref="salesChartRef" class="chart-container"></div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <el-card shadow="never">
+      <template #header>
+        <div class="flex items-center justify-between">
+          <span class="font-medium">门店销售明细</span>
+          <div class="flex items-center gap-2">
+            <el-input
+              v-model="queryParams.keyword"
+              placeholder="门店名称"
+              clearable
+              style="width: 200px"
+              @keyup.enter="handleSearch"
+            />
+            <el-select v-model="queryParams.status" placeholder="状态" clearable style="width: 120px">
+              <el-option label="启用" :value="1" />
+              <el-option label="禁用" :value="0" />
+            </el-select>
+            <el-button type="primary" @click="handleSearch">搜索</el-button>
+            <el-button @click="handleReset">重置</el-button>
+          </div>
+        </div>
+      </template>
+      
+      <el-table :data="tableData" stripe v-loading="loading" @sort-change="handleSortChange">
+        <el-table-column prop="shopName" label="门店名称" min-width="120" show-overflow-tooltip />
+        <el-table-column prop="province" label="省份" width="80" />
+        <el-table-column prop="city" label="城市" width="80" />
+        <el-table-column prop="deviceCount" label="设备数" width="80" align="right" />
+        <el-table-column prop="orderCount" label="订单数" width="80" align="right" sortable="custom" />
+        <el-table-column prop="userCount" label="购买用户数" width="100" align="right" />
+        <el-table-column prop="salesAmount" label="销售额(元)" width="120" align="right" sortable="custom">
+          <template #default="{ row }">
+            ¥{{ row.salesAmount?.toLocaleString() || 0 }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="avgOrderAmount" label="平均客单价" width="100" align="right">
+          <template #default="{ row }">
+            ¥{{ row.avgOrderAmount || 0 }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="dailySalesAmount" label="日均销售" width="100" align="right">
+          <template #default="{ row }">
+            ¥{{ row.dailySalesAmount || 0 }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="deviceOutput" label="单设备产出" width="100" align="right">
+          <template #default="{ row }">
+            ¥{{ row.deviceOutput || 0 }}
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="80" fixed="right">
+          <template #default="{ row }">
+            <el-button type="primary" link size="small" @click="handleViewTrend(row)">趋势</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      
+      <div class="flex justify-end mt-4">
+        <el-pagination
+          v-model:current-page="queryParams.page"
+          v-model:page-size="queryParams.pageSize"
+          :page-sizes="[10, 20, 50, 100]"
+          :total="total"
+          layout="total, sizes, prev, pager, next, jumper"
+          @size-change="handleSizeChange"
+          @current-change="handlePageChange"
+        />
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.shop-statistics {
+  padding: 20px;
+  background-color: var(--el-bg-color-page);
+  min-height: calc(100vh - 120px);
+}
+
+.stat-card {
+  display: flex;
+  align-items: center;
+  
+  .stat-icon {
+    width: 50px;
+    height: 50px;
+    border-radius: 12px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    margin-right: 16px;
+    
+    i {
+      font-size: 24px;
+      color: white;
+    }
+    
+    &.bg-blue { background: linear-gradient(135deg, #409eff, #337ecc); }
+    &.bg-green { background: linear-gradient(135deg, #67c23a, #529b2e); }
+    &.bg-purple { background: linear-gradient(135deg, #722ed1, #531dab); }
+    &.bg-orange { background: linear-gradient(135deg, #fa8c16, #d46b08); }
+  }
+  
+  .stat-info {
+    flex: 1;
+    
+    .stat-value {
+      font-size: 24px;
+      font-weight: 600;
+      color: var(--el-text-color-primary);
+      margin-bottom: 4px;
+    }
+    
+    .stat-label {
+      font-size: 14px;
+      color: var(--el-text-color-secondary);
+    }
+  }
+}
+
+.chart-card {
+  height: 350px;
+  
+  :deep(.el-card__body) {
+    padding: 20px;
+    height: 100%;
+  }
+  
+  .chart-container {
+    width: 100%;
+    height: 100%;
+    min-height: 280px;
+  }
+}
+</style>

+ 198 - 0
haha-admin/src/main/java/com/haha/admin/controller/StatisticsController.java

@@ -0,0 +1,198 @@
+package com.haha.admin.controller;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.haha.admin.annotation.RequirePermission;
+import com.haha.entity.dto.*;
+import com.haha.common.vo.PageResult;
+import com.haha.common.vo.Result;
+import com.haha.service.StatisticsService;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.Map;
+
+@Slf4j
+@RestController
+@RequestMapping("/statistics")
+@RequiredArgsConstructor
+public class StatisticsController {
+
+    private final StatisticsService statisticsService;
+
+    @RequirePermission("statistics:read")
+    @GetMapping("/overview")
+    public Result<StatisticsOverviewVO> getOverview(StatisticsQueryDTO queryDTO) {
+        StatisticsOverviewVO overview = statisticsService.getOverview(queryDTO);
+        return Result.success("查询成功", overview);
+    }
+
+    @RequirePermission("statistics:category")
+    @GetMapping("/category/overview")
+    public Result<List<CategoryStatVO>> getCategoryOverview(StatisticsQueryDTO queryDTO) {
+        List<CategoryStatVO> list = statisticsService.getCategoryOverview(queryDTO);
+        return Result.success("查询成功", list);
+    }
+
+    @RequirePermission("statistics:category")
+    @GetMapping("/category/trend")
+    public Result<TrendDataVO> getCategoryTrend(StatisticsQueryDTO queryDTO) {
+        TrendDataVO trend = statisticsService.getCategoryTrend(queryDTO);
+        return Result.success("查询成功", trend);
+    }
+
+    @RequirePermission("statistics:product")
+    @GetMapping("/product/list")
+    public Result<PageResult<ProductStatVO>> getProductList(StatisticsQueryDTO queryDTO) {
+        queryDTO.validate();
+        IPage<ProductStatVO> page = statisticsService.getProductList(queryDTO);
+        return Result.success("查询成功", PageResult.of(page));
+    }
+
+    @RequirePermission("statistics:product")
+    @GetMapping("/product/top")
+    public Result<List<ProductStatVO>> getProductTop(StatisticsQueryDTO queryDTO) {
+        List<ProductStatVO> list = statisticsService.getProductTop(queryDTO);
+        return Result.success("查询成功", list);
+    }
+
+    @RequirePermission("statistics:product")
+    @GetMapping("/product/{productId}/trend")
+    public Result<TrendDataVO> getProductTrend(
+            @PathVariable Long productId,
+            StatisticsQueryDTO queryDTO) {
+        TrendDataVO trend = statisticsService.getProductTrend(productId, queryDTO);
+        return Result.success("查询成功", trend);
+    }
+
+    @RequirePermission("statistics:device")
+    @GetMapping("/device/overview")
+    public Result<Map<String, Object>> getDeviceOverview(StatisticsQueryDTO queryDTO) {
+        Map<String, Object> overview = statisticsService.getDeviceOverview(queryDTO);
+        return Result.success("查询成功", overview);
+    }
+
+    @RequirePermission("statistics:device")
+    @GetMapping("/device/list")
+    public Result<PageResult<DeviceStatVO>> getDeviceList(StatisticsQueryDTO queryDTO) {
+        queryDTO.validate();
+        IPage<DeviceStatVO> page = statisticsService.getDeviceList(queryDTO);
+        return Result.success("查询成功", PageResult.of(page));
+    }
+
+    @RequirePermission("statistics:device")
+    @GetMapping("/device/{deviceId}/trend")
+    public Result<TrendDataVO> getDeviceTrend(
+            @PathVariable String deviceId,
+            StatisticsQueryDTO queryDTO) {
+        TrendDataVO trend = statisticsService.getDeviceTrend(deviceId, queryDTO);
+        return Result.success("查询成功", trend);
+    }
+
+    @RequirePermission("statistics:shop")
+    @GetMapping("/shop/overview")
+    public Result<Map<String, Object>> getShopOverview(StatisticsQueryDTO queryDTO) {
+        Map<String, Object> overview = statisticsService.getShopOverview(queryDTO);
+        return Result.success("查询成功", overview);
+    }
+
+    @RequirePermission("statistics:shop")
+    @GetMapping("/shop/list")
+    public Result<PageResult<ShopStatVO>> getShopList(StatisticsQueryDTO queryDTO) {
+        queryDTO.validate();
+        IPage<ShopStatVO> page = statisticsService.getShopList(queryDTO);
+        return Result.success("查询成功", PageResult.of(page));
+    }
+
+    @RequirePermission("statistics:shop")
+    @GetMapping("/shop/ranking")
+    public Result<List<ShopStatVO>> getShopRanking(StatisticsQueryDTO queryDTO) {
+        List<ShopStatVO> list = statisticsService.getShopRanking(queryDTO);
+        return Result.success("查询成功", list);
+    }
+
+    @RequirePermission("statistics:shop")
+    @GetMapping("/shop/{shopId}/trend")
+    public Result<TrendDataVO> getShopTrend(
+            @PathVariable Long shopId,
+            StatisticsQueryDTO queryDTO) {
+        TrendDataVO trend = statisticsService.getShopTrend(shopId, queryDTO);
+        return Result.success("查询成功", trend);
+    }
+
+    @RequirePermission("statistics:profit")
+    @GetMapping("/profit/overview")
+    public Result<Map<String, Object>> getProfitOverview(StatisticsQueryDTO queryDTO) {
+        Map<String, Object> overview = statisticsService.getProfitOverview(queryDTO);
+        return Result.success("查询成功", overview);
+    }
+
+    @RequirePermission("statistics:profit")
+    @GetMapping("/profit/list")
+    public Result<PageResult<ProfitStatVO>> getProfitList(StatisticsQueryDTO queryDTO) {
+        queryDTO.validate();
+        IPage<ProfitStatVO> page = statisticsService.getProfitList(queryDTO);
+        return Result.success("查询成功", PageResult.of(page));
+    }
+
+    @RequirePermission("statistics:profit")
+    @GetMapping("/profit/trend")
+    public Result<TrendDataVO> getProfitTrend(StatisticsQueryDTO queryDTO) {
+        TrendDataVO trend = statisticsService.getProfitTrend(queryDTO);
+        return Result.success("查询成功", trend);
+    }
+
+    @RequirePermission("statistics:profit")
+    @GetMapping("/profit/warning")
+    public Result<List<ProfitStatVO>> getProfitWarning(StatisticsQueryDTO queryDTO) {
+        List<ProfitStatVO> list = statisticsService.getProfitWarning(queryDTO);
+        return Result.success("查询成功", list);
+    }
+
+    @RequirePermission("statistics:repurchase")
+    @GetMapping("/repurchase/overview")
+    public Result<Map<String, Object>> getRepurchaseOverview(StatisticsQueryDTO queryDTO) {
+        Map<String, Object> overview = statisticsService.getRepurchaseOverview(queryDTO);
+        return Result.success("查询成功", overview);
+    }
+
+    @RequirePermission("statistics:repurchase")
+    @GetMapping("/repurchase/distribution")
+    public Result<Map<String, Object>> getRepurchaseDistribution(StatisticsQueryDTO queryDTO) {
+        Map<String, Object> distribution = statisticsService.getRepurchaseDistribution(queryDTO);
+        return Result.success("查询成功", distribution);
+    }
+
+    @RequirePermission("statistics:repurchase")
+    @GetMapping("/repurchase/trend")
+    public Result<TrendDataVO> getRepurchaseTrend(StatisticsQueryDTO queryDTO) {
+        TrendDataVO trend = statisticsService.getRepurchaseTrend(queryDTO);
+        return Result.success("查询成功", trend);
+    }
+
+    @RequirePermission("statistics:repurchase")
+    @GetMapping("/repurchase/users")
+    public Result<PageResult<RepurchaseStatVO>> getRepurchaseUsers(StatisticsQueryDTO queryDTO) {
+        queryDTO.validate();
+        IPage<RepurchaseStatVO> page = statisticsService.getRepurchaseUsers(queryDTO);
+        return Result.success("查询成功", PageResult.of(page));
+    }
+
+    @RequirePermission("statistics:export")
+    @GetMapping("/export")
+    public void exportStatistics(StatisticsQueryDTO queryDTO, HttpServletResponse response) {
+        try {
+            byte[] data = statisticsService.exportStatistics(queryDTO);
+            
+            String fileName = "statistics_" + queryDTO.getType() + ".xlsx";
+            response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+            response.setHeader("Content-Disposition", "attachment;filename=" + fileName);
+            response.getOutputStream().write(data);
+            response.getOutputStream().flush();
+        } catch (Exception e) {
+            log.error("导出统计数据失败", e);
+        }
+    }
+}

+ 411 - 0
haha-admin/src/main/java/com/haha/admin/task/StatisticsTask.java

@@ -0,0 +1,411 @@
+package com.haha.admin.task;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.haha.entity.*;
+import com.haha.mapper.*;
+import com.haha.common.constant.OrderConstants;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.util.*;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Component
+public class StatisticsTask {
+
+    @Autowired
+    private OrderMapper orderMapper;
+
+    @Autowired
+    private OrderItemMapper orderItemMapper;
+
+    @Autowired
+    private DeviceMapper deviceMapper;
+
+    @Autowired
+    private ShopMapper shopMapper;
+
+    @Autowired
+    private UserMapper userMapper;
+
+    @Autowired
+    private StatCategoryDailyMapper statCategoryDailyMapper;
+
+    @Autowired
+    private StatProductDailyMapper statProductDailyMapper;
+
+    @Autowired
+    private StatDeviceDailyMapper statDeviceDailyMapper;
+
+    @Autowired
+    private StatShopDailyMapper statShopDailyMapper;
+
+    @Autowired
+    private StatUserRepurchaseMapper statUserRepurchaseMapper;
+
+    @Scheduled(cron = "0 5 0 * * ?")
+    public void aggregateDailyStatistics() {
+        LocalDate yesterday = LocalDate.now().minusDays(1);
+        log.info("开始执行每日统计汇总任务,统计日期:{}", yesterday);
+        
+        try {
+            aggregateCategoryDaily(yesterday);
+            aggregateProductDaily(yesterday);
+            aggregateDeviceDaily(yesterday);
+            aggregateShopDaily(yesterday);
+            aggregateUserRepurchase(yesterday);
+            
+            log.info("每日统计汇总任务执行完成");
+        } catch (Exception e) {
+            log.error("每日统计汇总任务执行失败", e);
+        }
+    }
+
+    private void aggregateCategoryDaily(LocalDate statDate) {
+        LocalDateTime startDateTime = statDate.atStartOfDay();
+        LocalDateTime endDateTime = statDate.atTime(LocalTime.MAX);
+        
+        List<OrderItem> items = orderItemMapper.selectList(
+                new LambdaQueryWrapper<OrderItem>()
+                        .ge(OrderItem::getPayTime, startDateTime)
+                        .le(OrderItem::getPayTime, endDateTime)
+        );
+        
+        Map<String, List<OrderItem>> categoryMap = items.stream()
+                .filter(item -> item.getProductType() != null)
+                .collect(Collectors.groupingBy(OrderItem::getProductType));
+        
+        for (Map.Entry<String, List<OrderItem>> entry : categoryMap.entrySet()) {
+            String category = entry.getKey();
+            List<OrderItem> categoryItems = entry.getValue();
+            
+            StatCategoryDaily stat = new StatCategoryDaily();
+            stat.setStatDate(statDate);
+            stat.setCategory(category);
+            stat.setShopId(null);
+            stat.setQuantity(categoryItems.stream().mapToInt(i -> i.getQuantity() != null ? i.getQuantity() : 0).sum());
+            stat.setOrderCount((int) categoryItems.stream().map(OrderItem::getOrderId).distinct().count());
+            
+            BigDecimal salesAmount = categoryItems.stream()
+                    .map(i -> i.getTotalAmount() != null ? i.getTotalAmount() : BigDecimal.ZERO)
+                    .reduce(BigDecimal.ZERO, BigDecimal::add);
+            stat.setSalesAmount(salesAmount);
+            
+            BigDecimal costAmount = categoryItems.stream()
+                    .map(i -> {
+                        if (i.getCostPrice() != null && i.getQuantity() != null) {
+                            return i.getCostPrice().multiply(BigDecimal.valueOf(i.getQuantity()));
+                        }
+                        return BigDecimal.ZERO;
+                    })
+                    .reduce(BigDecimal.ZERO, BigDecimal::add);
+            stat.setCostAmount(costAmount);
+            stat.setProfitAmount(salesAmount.subtract(costAmount));
+            stat.setCreateTime(LocalDateTime.now());
+            
+            statCategoryDailyMapper.insert(stat);
+        }
+        
+        log.info("品类统计汇总完成,共{}条记录", categoryMap.size());
+    }
+
+    private void aggregateProductDaily(LocalDate statDate) {
+        LocalDateTime startDateTime = statDate.atStartOfDay();
+        LocalDateTime endDateTime = statDate.atTime(LocalTime.MAX);
+        
+        List<OrderItem> items = orderItemMapper.selectList(
+                new LambdaQueryWrapper<OrderItem>()
+                        .ge(OrderItem::getPayTime, startDateTime)
+                        .le(OrderItem::getPayTime, endDateTime)
+        );
+        
+        Map<Long, List<OrderItem>> productMap = items.stream()
+                .filter(item -> item.getProductId() != null)
+                .collect(Collectors.groupingBy(OrderItem::getProductId));
+        
+        int count = 0;
+        for (Map.Entry<Long, List<OrderItem>> entry : productMap.entrySet()) {
+            List<OrderItem> productItems = entry.getValue();
+            OrderItem first = productItems.get(0);
+            
+            Map<String, List<OrderItem>> deviceGroup = productItems.stream()
+                    .filter(i -> i.getDeviceId() != null)
+                    .collect(Collectors.groupingBy(OrderItem::getDeviceId));
+            
+            if (deviceGroup.isEmpty()) {
+                StatProductDaily stat = createProductStat(statDate, first, productItems, null, null);
+                statProductDailyMapper.insert(stat);
+                count++;
+            } else {
+                for (Map.Entry<String, List<OrderItem>> deviceEntry : deviceGroup.entrySet()) {
+                    StatProductDaily stat = createProductStat(statDate, first, deviceEntry.getValue(), 
+                            deviceEntry.getKey(), null);
+                    statProductDailyMapper.insert(stat);
+                    count++;
+                }
+            }
+        }
+        
+        log.info("商品统计汇总完成,共{}条记录", count);
+    }
+
+    private StatProductDaily createProductStat(LocalDate statDate, OrderItem first, 
+            List<OrderItem> items, String deviceId, Long shopId) {
+        StatProductDaily stat = new StatProductDaily();
+        stat.setStatDate(statDate);
+        stat.setProductId(first.getProductId());
+        stat.setProductCode(first.getProductCode());
+        stat.setProductName(first.getProductName());
+        stat.setCategory(first.getProductType());
+        stat.setShopId(shopId != null ? shopId : first.getShopId());
+        stat.setDeviceId(deviceId);
+        stat.setQuantity(items.stream().mapToInt(i -> i.getQuantity() != null ? i.getQuantity() : 0).sum());
+        stat.setOrderCount((int) items.stream().map(OrderItem::getOrderId).distinct().count());
+        
+        BigDecimal salesAmount = items.stream()
+                .map(i -> i.getTotalAmount() != null ? i.getTotalAmount() : BigDecimal.ZERO)
+                .reduce(BigDecimal.ZERO, BigDecimal::add);
+        stat.setSalesAmount(salesAmount);
+        
+        BigDecimal costAmount = items.stream()
+                .map(i -> {
+                    if (i.getCostPrice() != null && i.getQuantity() != null) {
+                        return i.getCostPrice().multiply(BigDecimal.valueOf(i.getQuantity()));
+                    }
+                    return BigDecimal.ZERO;
+                })
+                .reduce(BigDecimal.ZERO, BigDecimal::add);
+        stat.setCostAmount(costAmount);
+        stat.setProfitAmount(salesAmount.subtract(costAmount));
+        stat.setCreateTime(LocalDateTime.now());
+        
+        return stat;
+    }
+
+    private void aggregateDeviceDaily(LocalDate statDate) {
+        LocalDateTime startDateTime = statDate.atStartOfDay();
+        LocalDateTime endDateTime = statDate.atTime(LocalTime.MAX);
+        
+        List<Order> orders = orderMapper.selectList(
+                new LambdaQueryWrapper<Order>()
+                        .eq(Order::getPayStatus, OrderConstants.PAY_STATUS_PAID)
+                        .ge(Order::getPayTime, startDateTime)
+                        .le(Order::getPayTime, endDateTime)
+        );
+        
+        Map<String, List<Order>> deviceOrderMap = orders.stream()
+                .filter(o -> o.getDeviceId() != null)
+                .collect(Collectors.groupingBy(Order::getDeviceId));
+        
+        List<Device> devices = deviceMapper.selectList(null);
+        Map<String, Device> deviceMap = devices.stream()
+                .collect(Collectors.toMap(Device::getDeviceId, d -> d, (a, b) -> a));
+        
+        int count = 0;
+        for (Device device : devices) {
+            StatDeviceDaily stat = new StatDeviceDaily();
+            stat.setStatDate(statDate);
+            stat.setDeviceId(device.getDeviceId());
+            stat.setDeviceName(device.getName());
+            stat.setShopId(device.getShopId());
+            
+            if (device.getShopId() != null) {
+                Shop shop = shopMapper.selectById(device.getShopId());
+                if (shop != null) {
+                    stat.setShopName(shop.getName());
+                }
+            }
+            
+            List<Order> deviceOrders = deviceOrderMap.getOrDefault(device.getDeviceId(), Collections.emptyList());
+            stat.setOrderCount(deviceOrders.size());
+            stat.setUserCount((int) deviceOrders.stream().map(Order::getUserId).filter(Objects::nonNull).distinct().count());
+            
+            BigDecimal salesAmount = deviceOrders.stream()
+                    .map(o -> o.getTotalAmount() != null ? o.getTotalAmount() : BigDecimal.ZERO)
+                    .reduce(BigDecimal.ZERO, BigDecimal::add);
+            stat.setSalesAmount(salesAmount);
+            stat.setCostAmount(BigDecimal.ZERO);
+            stat.setProfitAmount(salesAmount);
+            stat.setCreateTime(LocalDateTime.now());
+            
+            statDeviceDailyMapper.insert(stat);
+            count++;
+        }
+        
+        log.info("设备统计汇总完成,共{}条记录", count);
+    }
+
+    private void aggregateShopDaily(LocalDate statDate) {
+        LocalDateTime startDateTime = statDate.atStartOfDay();
+        LocalDateTime endDateTime = statDate.atTime(LocalTime.MAX);
+        
+        List<Order> orders = orderMapper.selectList(
+                new LambdaQueryWrapper<Order>()
+                        .eq(Order::getPayStatus, OrderConstants.PAY_STATUS_PAID)
+                        .ge(Order::getPayTime, startDateTime)
+                        .le(Order::getPayTime, endDateTime)
+        );
+        
+        Map<Long, List<Order>> shopOrderMap = orders.stream()
+                .filter(o -> o.getShopId() != null)
+                .collect(Collectors.groupingBy(Order::getShopId));
+        
+        List<Shop> shops = shopMapper.selectList(null);
+        List<Device> devices = deviceMapper.selectList(null);
+        
+        Map<Long, Long> shopDeviceCount = devices.stream()
+                .filter(d -> d.getShopId() != null)
+                .collect(Collectors.groupingBy(Device::getShopId, Collectors.counting()));
+        
+        Set<Long> allUserIds = orders.stream()
+                .map(Order::getUserId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+        
+        Map<Long, LocalDate> userFirstOrderDate = new HashMap<>();
+        for (Long userId : allUserIds) {
+            List<Order> userOrders = orderMapper.selectList(
+                    new LambdaQueryWrapper<Order>()
+                            .eq(Order::getUserId, userId)
+                            .eq(Order::getPayStatus, OrderConstants.PAY_STATUS_PAID)
+                            .orderByAsc(Order::getPayTime)
+                            .last("LIMIT 1")
+            );
+            if (!userOrders.isEmpty() && userOrders.get(0).getPayTime() != null) {
+                userFirstOrderDate.put(userId, userOrders.get(0).getPayTime().toLocalDate());
+            }
+        }
+        
+        int count = 0;
+        for (Shop shop : shops) {
+            StatShopDaily stat = new StatShopDaily();
+            stat.setStatDate(statDate);
+            stat.setShopId(shop.getId());
+            stat.setShopName(shop.getName());
+            stat.setProvince(shop.getProvince());
+            stat.setCity(shop.getCity());
+            stat.setDistrict(shop.getDistrict());
+            stat.setDeviceCount(shopDeviceCount.getOrDefault(shop.getId(), 0L).intValue());
+            
+            List<Order> shopOrders = shopOrderMap.getOrDefault(shop.getId(), Collections.emptyList());
+            stat.setOrderCount(shopOrders.size());
+            
+            Set<Long> shopUserIds = shopOrders.stream()
+                    .map(Order::getUserId)
+                    .filter(Objects::nonNull)
+                    .collect(Collectors.toSet());
+            stat.setUserCount(shopUserIds.size());
+            
+            int newUserCount = 0;
+            for (Long userId : shopUserIds) {
+                LocalDate firstDate = userFirstOrderDate.get(userId);
+                if (firstDate != null && firstDate.equals(statDate)) {
+                    newUserCount++;
+                }
+            }
+            stat.setNewUserCount(newUserCount);
+            
+            BigDecimal salesAmount = shopOrders.stream()
+                    .map(o -> o.getTotalAmount() != null ? o.getTotalAmount() : BigDecimal.ZERO)
+                    .reduce(BigDecimal.ZERO, BigDecimal::add);
+            stat.setSalesAmount(salesAmount);
+            stat.setCostAmount(BigDecimal.ZERO);
+            stat.setProfitAmount(salesAmount);
+            
+            BigDecimal profitRate = salesAmount.compareTo(BigDecimal.ZERO) > 0 
+                    ? BigDecimal.valueOf(100)
+                    : BigDecimal.ZERO;
+            stat.setProfitRate(profitRate);
+            stat.setCreateTime(LocalDateTime.now());
+            
+            statShopDailyMapper.insert(stat);
+            count++;
+        }
+        
+        log.info("门店统计汇总完成,共{}条记录", count);
+    }
+
+    private void aggregateUserRepurchase(LocalDate statDate) {
+        LocalDateTime startDateTime = statDate.atStartOfDay();
+        LocalDateTime endDateTime = statDate.atTime(LocalTime.MAX);
+        
+        List<Order> todayOrders = orderMapper.selectList(
+                new LambdaQueryWrapper<Order>()
+                        .eq(Order::getPayStatus, OrderConstants.PAY_STATUS_PAID)
+                        .ge(Order::getPayTime, startDateTime)
+                        .le(Order::getPayTime, endDateTime)
+        );
+        
+        Map<Long, List<Order>> userOrderMap = todayOrders.stream()
+                .filter(o -> o.getUserId() != null)
+                .collect(Collectors.groupingBy(Order::getUserId));
+        
+        int count = 0;
+        for (Map.Entry<Long, List<Order>> entry : userOrderMap.entrySet()) {
+            Long userId = entry.getKey();
+            List<Order> todayUserOrders = entry.getValue();
+            
+            List<Order> allUserOrders = orderMapper.selectList(
+                    new LambdaQueryWrapper<Order>()
+                            .eq(Order::getUserId, userId)
+                            .eq(Order::getPayStatus, OrderConstants.PAY_STATUS_PAID)
+                            .orderByAsc(Order::getPayTime)
+            );
+            
+            if (allUserOrders.isEmpty()) continue;
+            
+            StatUserRepurchase stat = new StatUserRepurchase();
+            stat.setStatDate(statDate);
+            stat.setUserId(userId);
+            stat.setShopId(null);
+            stat.setOrderCount(todayUserOrders.size());
+            stat.setTotalOrderCount(allUserOrders.size());
+            
+            Order firstOrder = allUserOrders.get(0);
+            Order lastOrder = allUserOrders.get(allUserOrders.size() - 1);
+            stat.setFirstOrderDate(firstOrder.getPayTime() != null ? firstOrder.getPayTime().toLocalDate() : null);
+            stat.setLastOrderDate(lastOrder.getPayTime() != null ? lastOrder.getPayTime().toLocalDate() : null);
+            
+            BigDecimal totalAmount = allUserOrders.stream()
+                    .map(o -> o.getTotalAmount() != null ? o.getTotalAmount() : BigDecimal.ZERO)
+                    .reduce(BigDecimal.ZERO, BigDecimal::add);
+            stat.setTotalAmount(totalAmount);
+            
+            BigDecimal avgOrderAmount = allUserOrders.size() > 0 
+                    ? totalAmount.divide(BigDecimal.valueOf(allUserOrders.size()), 2, RoundingMode.HALF_UP)
+                    : BigDecimal.ZERO;
+            stat.setAvgOrderAmount(avgOrderAmount);
+            
+            if (allUserOrders.size() >= 2) {
+                long totalDays = 0;
+                for (int i = 1; i < allUserOrders.size(); i++) {
+                    if (allUserOrders.get(i-1).getPayTime() != null && allUserOrders.get(i).getPayTime() != null) {
+                        long days = java.time.temporal.ChronoUnit.DAYS.between(
+                                allUserOrders.get(i-1).getPayTime().toLocalDate(),
+                                allUserOrders.get(i).getPayTime().toLocalDate()
+                        );
+                        totalDays += days;
+                    }
+                }
+                stat.setRepurchaseDays((int) (totalDays / (allUserOrders.size() - 1)));
+            } else {
+                stat.setRepurchaseDays(0);
+            }
+            
+            stat.setCreateTime(LocalDateTime.now());
+            
+            statUserRepurchaseMapper.insert(stat);
+            count++;
+        }
+        
+        log.info("用户复购统计汇总完成,共{}条记录", count);
+    }
+}

+ 50 - 0
haha-entity/src/main/java/com/haha/entity/OrderItem.java

@@ -0,0 +1,50 @@
+package com.haha.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+@Data
+@TableName("t_order_item")
+public class OrderItem implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long orderId;
+
+    private String orderNo;
+
+    private Long productId;
+
+    private String productCode;
+
+    private String productName;
+
+    private String productType;
+
+    private Integer quantity;
+
+    private BigDecimal costPrice;
+
+    private BigDecimal retailPrice;
+
+    private BigDecimal totalAmount;
+
+    private BigDecimal profit;
+
+    private String deviceId;
+
+    private Long shopId;
+
+    private Long userId;
+
+    private LocalDateTime payTime;
+
+    private LocalDateTime createTime;
+}

+ 39 - 0
haha-entity/src/main/java/com/haha/entity/StatCategoryDaily.java

@@ -0,0 +1,39 @@
+package com.haha.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+@Data
+@TableName("t_stat_category_daily")
+public class StatCategoryDaily implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private LocalDate statDate;
+
+    private String category;
+
+    private Long shopId;
+
+    private Integer quantity;
+
+    private Integer orderCount;
+
+    private BigDecimal salesAmount;
+
+    private BigDecimal costAmount;
+
+    private BigDecimal profitAmount;
+
+    private LocalDateTime createTime;
+
+    private LocalDateTime updateTime;
+}

+ 45 - 0
haha-entity/src/main/java/com/haha/entity/StatDeviceDaily.java

@@ -0,0 +1,45 @@
+package com.haha.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+@Data
+@TableName("t_stat_device_daily")
+public class StatDeviceDaily implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private LocalDate statDate;
+
+    private String deviceId;
+
+    private String deviceName;
+
+    private Long shopId;
+
+    private String shopName;
+
+    private Integer orderCount;
+
+    private Integer userCount;
+
+    private Integer quantity;
+
+    private BigDecimal salesAmount;
+
+    private BigDecimal costAmount;
+
+    private BigDecimal profitAmount;
+
+    private LocalDateTime createTime;
+
+    private LocalDateTime updateTime;
+}

+ 47 - 0
haha-entity/src/main/java/com/haha/entity/StatProductDaily.java

@@ -0,0 +1,47 @@
+package com.haha.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+@Data
+@TableName("t_stat_product_daily")
+public class StatProductDaily implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private LocalDate statDate;
+
+    private Long productId;
+
+    private String productCode;
+
+    private String productName;
+
+    private String category;
+
+    private Long shopId;
+
+    private String deviceId;
+
+    private Integer quantity;
+
+    private Integer orderCount;
+
+    private BigDecimal salesAmount;
+
+    private BigDecimal costAmount;
+
+    private BigDecimal profitAmount;
+
+    private LocalDateTime createTime;
+
+    private LocalDateTime updateTime;
+}

+ 53 - 0
haha-entity/src/main/java/com/haha/entity/StatShopDaily.java

@@ -0,0 +1,53 @@
+package com.haha.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+@Data
+@TableName("t_stat_shop_daily")
+public class StatShopDaily implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private LocalDate statDate;
+
+    private Long shopId;
+
+    private String shopName;
+
+    private String province;
+
+    private String city;
+
+    private String district;
+
+    private Integer deviceCount;
+
+    private Integer orderCount;
+
+    private Integer userCount;
+
+    private Integer newUserCount;
+
+    private Integer quantity;
+
+    private BigDecimal salesAmount;
+
+    private BigDecimal costAmount;
+
+    private BigDecimal profitAmount;
+
+    private BigDecimal profitRate;
+
+    private LocalDateTime createTime;
+
+    private LocalDateTime updateTime;
+}

+ 43 - 0
haha-entity/src/main/java/com/haha/entity/StatUserRepurchase.java

@@ -0,0 +1,43 @@
+package com.haha.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+@Data
+@TableName("t_stat_user_repurchase")
+public class StatUserRepurchase implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private LocalDate statDate;
+
+    private Long userId;
+
+    private Long shopId;
+
+    private Integer orderCount;
+
+    private Integer totalOrderCount;
+
+    private LocalDate firstOrderDate;
+
+    private LocalDate lastOrderDate;
+
+    private BigDecimal totalAmount;
+
+    private BigDecimal avgOrderAmount;
+
+    private Integer repurchaseDays;
+
+    private LocalDateTime createTime;
+
+    private LocalDateTime updateTime;
+}

+ 20 - 0
haha-entity/src/main/java/com/haha/entity/dto/CategoryStatVO.java

@@ -0,0 +1,20 @@
+package com.haha.entity.dto;
+
+import lombok.Data;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+import java.util.List;
+
+@Data
+public class CategoryStatVO {
+    private String category;
+    private Integer quantity;
+    private BigDecimal salesAmount;
+    private BigDecimal costAmount;
+    private BigDecimal profitAmount;
+    private BigDecimal profitRate;
+    private Integer orderCount;
+    private BigDecimal percentage;
+}

+ 25 - 0
haha-entity/src/main/java/com/haha/entity/dto/DeviceStatVO.java

@@ -0,0 +1,25 @@
+package com.haha.entity.dto;
+
+import lombok.Data;
+import java.math.BigDecimal;
+import java.util.List;
+
+@Data
+public class DeviceStatVO {
+    private String deviceId;
+    private String deviceName;
+    private Long shopId;
+    private String shopName;
+    private Integer status;
+    private String statusLabel;
+    private String statusColor;
+    private Integer orderCount;
+    private Integer userCount;
+    private Integer newUserCount;
+    private Integer quantity;
+    private BigDecimal salesAmount;
+    private BigDecimal costAmount;
+    private BigDecimal profitAmount;
+    private BigDecimal avgOrderAmount;
+    private BigDecimal dailySalesAmount;
+}

+ 21 - 0
haha-entity/src/main/java/com/haha/entity/dto/ProductStatVO.java

@@ -0,0 +1,21 @@
+package com.haha.entity.dto;
+
+import lombok.Data;
+import java.math.BigDecimal;
+import java.util.List;
+
+@Data
+public class ProductStatVO {
+    private Long productId;
+    private String productCode;
+    private String productName;
+    private String category;
+    private Integer quantity;
+    private BigDecimal salesAmount;
+    private BigDecimal costAmount;
+    private BigDecimal profitAmount;
+    private BigDecimal profitRate;
+    private Integer orderCount;
+    private Integer userCount;
+    private BigDecimal avgPrice;
+}

+ 28 - 0
haha-entity/src/main/java/com/haha/entity/dto/ProfitStatVO.java

@@ -0,0 +1,28 @@
+package com.haha.entity.dto;
+
+import lombok.Data;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.List;
+
+@Data
+public class ProfitStatVO {
+    private Long shopId;
+    private String shopName;
+    private String province;
+    private String city;
+    private String district;
+    private BigDecimal salesAmount;
+    private BigDecimal costAmount;
+    private BigDecimal grossProfit;
+    private BigDecimal grossProfitRate;
+    private BigDecimal refundAmount;
+    private BigDecimal refundRate;
+    private BigDecimal netProfit;
+    private BigDecimal netProfitRate;
+    private Integer deviceCount;
+    private BigDecimal deviceProfit;
+    private BigDecimal dailyProfit;
+    private BigDecimal yoyGrowth;
+    private BigDecimal momGrowth;
+}

+ 22 - 0
haha-entity/src/main/java/com/haha/entity/dto/RepurchaseStatVO.java

@@ -0,0 +1,22 @@
+package com.haha.entity.dto;
+
+import lombok.Data;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.List;
+
+@Data
+public class RepurchaseStatVO {
+    private Long userId;
+    private String nickname;
+    private String phone;
+    private Integer orderCount;
+    private Integer totalOrderCount;
+    private LocalDate firstOrderDate;
+    private LocalDate lastOrderDate;
+    private BigDecimal totalAmount;
+    private BigDecimal avgOrderAmount;
+    private Integer repurchaseDays;
+    private String userLayer;
+    private String userLayerLabel;
+}

+ 11 - 0
haha-entity/src/main/java/com/haha/entity/dto/SeriesDataVO.java

@@ -0,0 +1,11 @@
+package com.haha.entity.dto;
+
+import lombok.Data;
+import java.math.BigDecimal;
+import java.util.List;
+
+@Data
+public class SeriesDataVO {
+    private String name;
+    private List<BigDecimal> data;
+}

+ 29 - 0
haha-entity/src/main/java/com/haha/entity/dto/ShopStatVO.java

@@ -0,0 +1,29 @@
+package com.haha.entity.dto;
+
+import lombok.Data;
+import java.math.BigDecimal;
+import java.util.List;
+
+@Data
+public class ShopStatVO {
+    private Long shopId;
+    private String shopName;
+    private String province;
+    private String city;
+    private String district;
+    private String address;
+    private Integer status;
+    private String statusLabel;
+    private Integer deviceCount;
+    private Integer orderCount;
+    private Integer userCount;
+    private Integer newUserCount;
+    private Integer quantity;
+    private BigDecimal salesAmount;
+    private BigDecimal costAmount;
+    private BigDecimal profitAmount;
+    private BigDecimal profitRate;
+    private BigDecimal avgOrderAmount;
+    private BigDecimal dailySalesAmount;
+    private BigDecimal deviceOutput;
+}

+ 23 - 0
haha-entity/src/main/java/com/haha/entity/dto/StatisticsOverviewVO.java

@@ -0,0 +1,23 @@
+package com.haha.entity.dto;
+
+import lombok.Data;
+import java.math.BigDecimal;
+import java.util.List;
+
+@Data
+public class StatisticsOverviewVO {
+    private BigDecimal totalSales;
+    private BigDecimal totalProfit;
+    private BigDecimal avgProfitRate;
+    private Integer totalOrders;
+    private Integer totalUsers;
+    private Integer newUsers;
+    private Integer totalDevices;
+    private Integer onlineDevices;
+    private Integer totalShops;
+    private BigDecimal repurchaseRate;
+    private BigDecimal avgOrderAmount;
+    private List<CategoryStatVO> categoryList;
+    private List<TrendDataVO> salesTrend;
+    private List<TrendDataVO> profitTrend;
+}

+ 41 - 0
haha-entity/src/main/java/com/haha/entity/dto/StatisticsQueryDTO.java

@@ -0,0 +1,41 @@
+package com.haha.entity.dto;
+
+import lombok.Data;
+import java.math.BigDecimal;
+
+@Data
+public class StatisticsQueryDTO {
+    private Integer page;
+    private Integer pageSize;
+    private String startDate;
+    private String endDate;
+    private Long shopId;
+    private String deviceId;
+    private String category;
+    private String keyword;
+    private String sortBy;
+    private String sortOrder;
+    private String province;
+    private String city;
+    private String district;
+    private Integer status;
+    private String period;
+    private String compareType;
+    private String userLayer;
+    private Integer minOrderCount;
+    private String type;
+    private Integer limit;
+    private BigDecimal threshold;
+
+    public void validate() {
+        if (page == null || page < 1) {
+            page = 1;
+        }
+        if (pageSize == null || pageSize < 1) {
+            pageSize = 10;
+        }
+        if (pageSize > 100) {
+            pageSize = 100;
+        }
+    }
+}

+ 13 - 0
haha-entity/src/main/java/com/haha/entity/dto/TrendDataVO.java

@@ -0,0 +1,13 @@
+package com.haha.entity.dto;
+
+import lombok.Data;
+import java.math.BigDecimal;
+import java.util.List;
+
+@Data
+public class TrendDataVO {
+    private List<String> dates;
+    private String label;
+    private BigDecimal value;
+    private List<SeriesDataVO> series;
+}

+ 9 - 0
haha-mapper/src/main/java/com/haha/mapper/OrderItemMapper.java

@@ -0,0 +1,9 @@
+package com.haha.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.haha.entity.OrderItem;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface OrderItemMapper extends BaseMapper<OrderItem> {
+}

+ 9 - 0
haha-mapper/src/main/java/com/haha/mapper/StatCategoryDailyMapper.java

@@ -0,0 +1,9 @@
+package com.haha.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.haha.entity.StatCategoryDaily;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface StatCategoryDailyMapper extends BaseMapper<StatCategoryDaily> {
+}

+ 9 - 0
haha-mapper/src/main/java/com/haha/mapper/StatDeviceDailyMapper.java

@@ -0,0 +1,9 @@
+package com.haha.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.haha.entity.StatDeviceDaily;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface StatDeviceDailyMapper extends BaseMapper<StatDeviceDaily> {
+}

+ 9 - 0
haha-mapper/src/main/java/com/haha/mapper/StatProductDailyMapper.java

@@ -0,0 +1,9 @@
+package com.haha.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.haha.entity.StatProductDaily;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface StatProductDailyMapper extends BaseMapper<StatProductDaily> {
+}

+ 9 - 0
haha-mapper/src/main/java/com/haha/mapper/StatShopDailyMapper.java

@@ -0,0 +1,9 @@
+package com.haha.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.haha.entity.StatShopDaily;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface StatShopDailyMapper extends BaseMapper<StatShopDaily> {
+}

+ 9 - 0
haha-mapper/src/main/java/com/haha/mapper/StatUserRepurchaseMapper.java

@@ -0,0 +1,9 @@
+package com.haha.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.haha.entity.StatUserRepurchase;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface StatUserRepurchaseMapper extends BaseMapper<StatUserRepurchase> {
+}

+ 54 - 0
haha-service/src/main/java/com/haha/service/StatisticsService.java

@@ -0,0 +1,54 @@
+package com.haha.service;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.haha.entity.dto.*;
+
+import java.util.List;
+import java.util.Map;
+
+public interface StatisticsService {
+
+    StatisticsOverviewVO getOverview(StatisticsQueryDTO queryDTO);
+
+    List<CategoryStatVO> getCategoryOverview(StatisticsQueryDTO queryDTO);
+
+    TrendDataVO getCategoryTrend(StatisticsQueryDTO queryDTO);
+
+    IPage<ProductStatVO> getProductList(StatisticsQueryDTO queryDTO);
+
+    List<ProductStatVO> getProductTop(StatisticsQueryDTO queryDTO);
+
+    TrendDataVO getProductTrend(Long productId, StatisticsQueryDTO queryDTO);
+
+    Map<String, Object> getDeviceOverview(StatisticsQueryDTO queryDTO);
+
+    IPage<DeviceStatVO> getDeviceList(StatisticsQueryDTO queryDTO);
+
+    TrendDataVO getDeviceTrend(String deviceId, StatisticsQueryDTO queryDTO);
+
+    Map<String, Object> getShopOverview(StatisticsQueryDTO queryDTO);
+
+    IPage<ShopStatVO> getShopList(StatisticsQueryDTO queryDTO);
+
+    List<ShopStatVO> getShopRanking(StatisticsQueryDTO queryDTO);
+
+    TrendDataVO getShopTrend(Long shopId, StatisticsQueryDTO queryDTO);
+
+    Map<String, Object> getProfitOverview(StatisticsQueryDTO queryDTO);
+
+    IPage<ProfitStatVO> getProfitList(StatisticsQueryDTO queryDTO);
+
+    TrendDataVO getProfitTrend(StatisticsQueryDTO queryDTO);
+
+    List<ProfitStatVO> getProfitWarning(StatisticsQueryDTO queryDTO);
+
+    Map<String, Object> getRepurchaseOverview(StatisticsQueryDTO queryDTO);
+
+    Map<String, Object> getRepurchaseDistribution(StatisticsQueryDTO queryDTO);
+
+    TrendDataVO getRepurchaseTrend(StatisticsQueryDTO queryDTO);
+
+    IPage<RepurchaseStatVO> getRepurchaseUsers(StatisticsQueryDTO queryDTO);
+
+    byte[] exportStatistics(StatisticsQueryDTO queryDTO);
+}

+ 1407 - 0
haha-service/src/main/java/com/haha/service/impl/StatisticsServiceImpl.java

@@ -0,0 +1,1407 @@
+package com.haha.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.haha.entity.dto.*;
+import com.haha.common.constant.OrderConstants;
+import com.haha.entity.*;
+import com.haha.mapper.*;
+import com.haha.service.StatisticsService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.format.DateTimeFormatter;
+import java.time.temporal.ChronoUnit;
+import java.util.*;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Service
+public class StatisticsServiceImpl implements StatisticsService {
+
+    @Autowired
+    private OrderMapper orderMapper;
+
+    @Autowired
+    private OrderItemMapper orderItemMapper;
+
+    @Autowired
+    private DeviceMapper deviceMapper;
+
+    @Autowired
+    private ShopMapper shopMapper;
+
+    @Autowired
+    private UserMapper userMapper;
+
+    @Autowired
+    private ProductMapper productMapper;
+
+    @Override
+    public StatisticsOverviewVO getOverview(StatisticsQueryDTO queryDTO) {
+        StatisticsOverviewVO overview = new StatisticsOverviewVO();
+        
+        LocalDate startDate = parseDate(queryDTO.getStartDate(), LocalDate.now().minusDays(30));
+        LocalDate endDate = parseDate(queryDTO.getEndDate(), LocalDate.now());
+        
+        LocalDateTime startDateTime = startDate.atStartOfDay();
+        LocalDateTime endDateTime = endDate.atTime(LocalTime.MAX);
+        
+        LambdaQueryWrapper<Order> orderWrapper = new LambdaQueryWrapper<>();
+        orderWrapper.eq(Order::getPayStatus, OrderConstants.PAY_STATUS_PAID)
+                   .ge(Order::getPayTime, startDateTime)
+                   .le(Order::getPayTime, endDateTime);
+        
+        if (queryDTO.getShopId() != null) {
+            orderWrapper.eq(Order::getShopId, queryDTO.getShopId());
+        }
+        
+        List<Order> orders = orderMapper.selectList(orderWrapper);
+        
+        BigDecimal totalSales = orders.stream()
+                .map(o -> o.getTotalAmount() != null ? o.getTotalAmount() : BigDecimal.ZERO)
+                .reduce(BigDecimal.ZERO, BigDecimal::add);
+        overview.setTotalSales(totalSales.setScale(2, RoundingMode.HALF_UP));
+        
+        overview.setTotalOrders(orders.size());
+        
+        Set<Long> userIds = orders.stream()
+                .map(Order::getUserId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+        overview.setTotalUsers(userIds.size());
+        
+        long totalDevices = deviceMapper.selectCount(null);
+        long onlineDevices = deviceMapper.selectCount(
+                new LambdaQueryWrapper<Device>().eq(Device::getStatus, 1)
+        );
+        overview.setTotalDevices((int) totalDevices);
+        overview.setOnlineDevices((int) onlineDevices);
+        
+        long totalShops = shopMapper.selectCount(null);
+        overview.setTotalShops((int) totalShops);
+        
+        BigDecimal avgOrderAmount = orders.size() > 0 
+                ? totalSales.divide(BigDecimal.valueOf(orders.size()), 2, RoundingMode.HALF_UP)
+                : BigDecimal.ZERO;
+        overview.setAvgOrderAmount(avgOrderAmount);
+        
+        List<CategoryStatVO> categoryList = getCategoryOverview(queryDTO);
+        overview.setCategoryList(categoryList);
+        
+        BigDecimal totalProfit = categoryList.stream()
+                .map(c -> c.getProfitAmount() != null ? c.getProfitAmount() : BigDecimal.ZERO)
+                .reduce(BigDecimal.ZERO, BigDecimal::add);
+        overview.setTotalProfit(totalProfit.setScale(2, RoundingMode.HALF_UP));
+        
+        BigDecimal avgProfitRate = totalSales.compareTo(BigDecimal.ZERO) > 0 
+                ? totalProfit.divide(totalSales, 4, RoundingMode.HALF_UP).multiply(new BigDecimal("100"))
+                : BigDecimal.ZERO;
+        overview.setAvgProfitRate(avgProfitRate.setScale(2, RoundingMode.HALF_UP));
+        
+        return overview;
+    }
+
+    @Override
+    public List<CategoryStatVO> getCategoryOverview(StatisticsQueryDTO queryDTO) {
+        LocalDate startDate = parseDate(queryDTO.getStartDate(), LocalDate.now().minusDays(30));
+        LocalDate endDate = parseDate(queryDTO.getEndDate(), LocalDate.now());
+        
+        LocalDateTime startDateTime = startDate.atStartOfDay();
+        LocalDateTime endDateTime = endDate.atTime(LocalTime.MAX);
+        
+        LambdaQueryWrapper<OrderItem> wrapper = new LambdaQueryWrapper<>();
+        wrapper.ge(OrderItem::getPayTime, startDateTime)
+               .le(OrderItem::getPayTime, endDateTime);
+        
+        if (queryDTO.getShopId() != null) {
+            wrapper.eq(OrderItem::getShopId, queryDTO.getShopId());
+        }
+        
+        List<OrderItem> items = orderItemMapper.selectList(wrapper);
+        
+        Map<String, List<OrderItem>> categoryMap = items.stream()
+                .filter(item -> item.getProductType() != null)
+                .collect(Collectors.groupingBy(OrderItem::getProductType));
+        
+        BigDecimal totalSales = items.stream()
+                .map(i -> i.getTotalAmount() != null ? i.getTotalAmount() : BigDecimal.ZERO)
+                .reduce(BigDecimal.ZERO, BigDecimal::add);
+        
+        List<CategoryStatVO> result = new ArrayList<>();
+        for (Map.Entry<String, List<OrderItem>> entry : categoryMap.entrySet()) {
+            List<OrderItem> categoryItems = entry.getValue();
+            
+            CategoryStatVO vo = new CategoryStatVO();
+            vo.setCategory(entry.getKey());
+            vo.setQuantity(categoryItems.stream().mapToInt(i -> i.getQuantity() != null ? i.getQuantity() : 0).sum());
+            
+            BigDecimal salesAmount = categoryItems.stream()
+                    .map(i -> i.getTotalAmount() != null ? i.getTotalAmount() : BigDecimal.ZERO)
+                    .reduce(BigDecimal.ZERO, BigDecimal::add);
+            vo.setSalesAmount(salesAmount.setScale(2, RoundingMode.HALF_UP));
+            
+            BigDecimal costAmount = categoryItems.stream()
+                    .map(i -> i.getCostPrice() != null && i.getQuantity() != null 
+                            ? i.getCostPrice().multiply(BigDecimal.valueOf(i.getQuantity())) 
+                            : BigDecimal.ZERO)
+                    .reduce(BigDecimal.ZERO, BigDecimal::add);
+            vo.setCostAmount(costAmount.setScale(2, RoundingMode.HALF_UP));
+            
+            BigDecimal profitAmount = salesAmount.subtract(costAmount);
+            vo.setProfitAmount(profitAmount.setScale(2, RoundingMode.HALF_UP));
+            
+            BigDecimal profitRate = salesAmount.compareTo(BigDecimal.ZERO) > 0 
+                    ? profitAmount.divide(salesAmount, 4, RoundingMode.HALF_UP).multiply(new BigDecimal("100"))
+                    : BigDecimal.ZERO;
+            vo.setProfitRate(profitRate.setScale(2, RoundingMode.HALF_UP));
+            
+            vo.setOrderCount((int) categoryItems.stream().map(OrderItem::getOrderId).distinct().count());
+            
+            BigDecimal percentage = totalSales.compareTo(BigDecimal.ZERO) > 0 
+                    ? salesAmount.divide(totalSales, 4, RoundingMode.HALF_UP).multiply(new BigDecimal("100"))
+                    : BigDecimal.ZERO;
+            vo.setPercentage(percentage.setScale(2, RoundingMode.HALF_UP));
+            
+            result.add(vo);
+        }
+        
+        result.sort((a, b) -> b.getSalesAmount().compareTo(a.getSalesAmount()));
+        
+        return result;
+    }
+
+    @Override
+    public TrendDataVO getCategoryTrend(StatisticsQueryDTO queryDTO) {
+        TrendDataVO trend = new TrendDataVO();
+        
+        LocalDate startDate = parseDate(queryDTO.getStartDate(), LocalDate.now().minusDays(30));
+        LocalDate endDate = parseDate(queryDTO.getEndDate(), LocalDate.now());
+        
+        List<String> dates = new ArrayList<>();
+        List<SeriesDataVO> seriesList = new ArrayList<>();
+        
+        LambdaQueryWrapper<OrderItem> wrapper = new LambdaQueryWrapper<>();
+        wrapper.ge(OrderItem::getPayTime, startDate.atStartOfDay())
+               .le(OrderItem::getPayTime, endDate.atTime(LocalTime.MAX));
+        
+        if (queryDTO.getShopId() != null) {
+            wrapper.eq(OrderItem::getShopId, queryDTO.getShopId());
+        }
+        if (queryDTO.getCategory() != null) {
+            wrapper.eq(OrderItem::getProductType, queryDTO.getCategory());
+        }
+        
+        List<OrderItem> items = orderItemMapper.selectList(wrapper);
+        
+        Map<String, Map<String, BigDecimal>> categoryDateAmount = new HashMap<>();
+        for (OrderItem item : items) {
+            String date = item.getPayTime().toLocalDate().toString();
+            String category = item.getProductType() != null ? item.getProductType() : "其他";
+            BigDecimal amount = item.getTotalAmount() != null ? item.getTotalAmount() : BigDecimal.ZERO;
+            
+            categoryDateAmount.computeIfAbsent(category, k -> new HashMap<>())
+                    .merge(date, amount, BigDecimal::add);
+        }
+        
+        long days = ChronoUnit.DAYS.between(startDate, endDate) + 1;
+        for (int i = 0; i < days; i++) {
+            LocalDate date = startDate.plusDays(i);
+            dates.add(date.format(DateTimeFormatter.ofPattern("MM-dd")));
+        }
+        trend.setDates(dates);
+        
+        Set<String> categories = categoryDateAmount.keySet();
+        for (String category : categories) {
+            SeriesDataVO series = new SeriesDataVO();
+            series.setName(category);
+            
+            List<BigDecimal> data = new ArrayList<>();
+            for (int i = 0; i < days; i++) {
+                LocalDate date = startDate.plusDays(i);
+                BigDecimal amount = categoryDateAmount.getOrDefault(category, new HashMap<>())
+                        .getOrDefault(date.toString(), BigDecimal.ZERO);
+                data.add(amount.setScale(2, RoundingMode.HALF_UP));
+            }
+            series.setData(data);
+            seriesList.add(series);
+        }
+        
+        trend.setSeries(seriesList);
+        return trend;
+    }
+
+    @Override
+    public IPage<ProductStatVO> getProductList(StatisticsQueryDTO queryDTO) {
+        queryDTO.validate();
+        
+        LocalDate startDate = parseDate(queryDTO.getStartDate(), LocalDate.now().minusDays(30));
+        LocalDate endDate = parseDate(queryDTO.getEndDate(), LocalDate.now());
+        
+        LocalDateTime startDateTime = startDate.atStartOfDay();
+        LocalDateTime endDateTime = endDate.atTime(LocalTime.MAX);
+        
+        LambdaQueryWrapper<OrderItem> wrapper = new LambdaQueryWrapper<>();
+        wrapper.ge(OrderItem::getPayTime, startDateTime)
+               .le(OrderItem::getPayTime, endDateTime);
+        
+        if (queryDTO.getShopId() != null) {
+            wrapper.eq(OrderItem::getShopId, queryDTO.getShopId());
+        }
+        if (queryDTO.getDeviceId() != null) {
+            wrapper.eq(OrderItem::getDeviceId, queryDTO.getDeviceId());
+        }
+        if (queryDTO.getCategory() != null) {
+            wrapper.eq(OrderItem::getProductType, queryDTO.getCategory());
+        }
+        if (queryDTO.getKeyword() != null) {
+            wrapper.and(w -> w.like(OrderItem::getProductName, queryDTO.getKeyword())
+                    .or().like(OrderItem::getProductCode, queryDTO.getKeyword()));
+        }
+        
+        List<OrderItem> items = orderItemMapper.selectList(wrapper);
+        
+        Map<Long, List<OrderItem>> productMap = items.stream()
+                .filter(item -> item.getProductId() != null)
+                .collect(Collectors.groupingBy(OrderItem::getProductId));
+        
+        List<ProductStatVO> resultList = new ArrayList<>();
+        for (Map.Entry<Long, List<OrderItem>> entry : productMap.entrySet()) {
+            List<OrderItem> productItems = entry.getValue();
+            OrderItem first = productItems.get(0);
+            
+            ProductStatVO vo = new ProductStatVO();
+            vo.setProductId(entry.getKey());
+            vo.setProductCode(first.getProductCode());
+            vo.setProductName(first.getProductName());
+            vo.setCategory(first.getProductType());
+            vo.setQuantity(productItems.stream().mapToInt(i -> i.getQuantity() != null ? i.getQuantity() : 0).sum());
+            
+            BigDecimal salesAmount = productItems.stream()
+                    .map(i -> i.getTotalAmount() != null ? i.getTotalAmount() : BigDecimal.ZERO)
+                    .reduce(BigDecimal.ZERO, BigDecimal::add);
+            vo.setSalesAmount(salesAmount.setScale(2, RoundingMode.HALF_UP));
+            
+            BigDecimal costAmount = productItems.stream()
+                    .map(i -> i.getCostPrice() != null && i.getQuantity() != null 
+                            ? i.getCostPrice().multiply(BigDecimal.valueOf(i.getQuantity())) 
+                            : BigDecimal.ZERO)
+                    .reduce(BigDecimal.ZERO, BigDecimal::add);
+            vo.setCostAmount(costAmount.setScale(2, RoundingMode.HALF_UP));
+            
+            BigDecimal profitAmount = salesAmount.subtract(costAmount);
+            vo.setProfitAmount(profitAmount.setScale(2, RoundingMode.HALF_UP));
+            
+            BigDecimal profitRate = salesAmount.compareTo(BigDecimal.ZERO) > 0 
+                    ? profitAmount.divide(salesAmount, 4, RoundingMode.HALF_UP).multiply(new BigDecimal("100"))
+                    : BigDecimal.ZERO;
+            vo.setProfitRate(profitRate.setScale(2, RoundingMode.HALF_UP));
+            
+            vo.setOrderCount((int) productItems.stream().map(OrderItem::getOrderId).distinct().count());
+            vo.setUserCount((int) productItems.stream().map(OrderItem::getUserId).filter(Objects::nonNull).distinct().count());
+            
+            resultList.add(vo);
+        }
+        
+        String sortBy = queryDTO.getSortBy() != null ? queryDTO.getSortBy() : "salesAmount";
+        boolean asc = "asc".equalsIgnoreCase(queryDTO.getSortOrder());
+        
+        Comparator<ProductStatVO> comparator = null;
+        switch (sortBy) {
+            case "quantity":
+                comparator = Comparator.comparing(ProductStatVO::getQuantity);
+                break;
+            case "profit":
+                comparator = Comparator.comparing(ProductStatVO::getProfitAmount);
+                break;
+            case "salesAmount":
+            default:
+                comparator = Comparator.comparing(ProductStatVO::getSalesAmount);
+                break;
+        }
+        
+        if (!asc) {
+            comparator = comparator.reversed();
+        }
+        resultList.sort(comparator);
+        
+        Page<ProductStatVO> page = new Page<>(queryDTO.getPage(), queryDTO.getPageSize());
+        int start = (queryDTO.getPage() - 1) * queryDTO.getPageSize();
+        int end = Math.min(start + queryDTO.getPageSize(), resultList.size());
+        
+        page.setRecords(resultList.subList(start, end));
+        page.setTotal(resultList.size());
+        
+        return page;
+    }
+
+    @Override
+    public List<ProductStatVO> getProductTop(StatisticsQueryDTO queryDTO) {
+        LocalDate startDate = parseDate(queryDTO.getStartDate(), LocalDate.now().minusDays(30));
+        LocalDate endDate = parseDate(queryDTO.getEndDate(), LocalDate.now());
+        int limit = queryDTO.getLimit() != null ? queryDTO.getLimit() : 10;
+        String type = queryDTO.getType() != null ? queryDTO.getType() : "sales";
+        
+        StatisticsQueryDTO listQuery = new StatisticsQueryDTO();
+        listQuery.setStartDate(startDate.toString());
+        listQuery.setEndDate(endDate.toString());
+        listQuery.setShopId(queryDTO.getShopId());
+        listQuery.setSortBy(type);
+        listQuery.setSortOrder("desc");
+        listQuery.setPage(1);
+        listQuery.setPageSize(limit);
+        
+        IPage<ProductStatVO> page = getProductList(listQuery);
+        return page.getRecords();
+    }
+
+    @Override
+    public TrendDataVO getProductTrend(Long productId, StatisticsQueryDTO queryDTO) {
+        TrendDataVO trend = new TrendDataVO();
+        
+        LocalDate startDate = parseDate(queryDTO.getStartDate(), LocalDate.now().minusDays(30));
+        LocalDate endDate = parseDate(queryDTO.getEndDate(), LocalDate.now());
+        
+        LambdaQueryWrapper<OrderItem> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(OrderItem::getProductId, productId)
+               .ge(OrderItem::getPayTime, startDate.atStartOfDay())
+               .le(OrderItem::getPayTime, endDate.atTime(LocalTime.MAX));
+        
+        List<OrderItem> items = orderItemMapper.selectList(wrapper);
+        
+        Map<String, BigDecimal> dateAmount = items.stream()
+                .collect(Collectors.groupingBy(
+                        i -> i.getPayTime().toLocalDate().toString(),
+                        Collectors.reducing(BigDecimal.ZERO, 
+                                i -> i.getTotalAmount() != null ? i.getTotalAmount() : BigDecimal.ZERO,
+                                BigDecimal::add)
+                ));
+        
+        List<String> dates = new ArrayList<>();
+        List<BigDecimal> data = new ArrayList<>();
+        
+        long days = ChronoUnit.DAYS.between(startDate, endDate) + 1;
+        for (int i = 0; i < days; i++) {
+            LocalDate date = startDate.plusDays(i);
+            dates.add(date.format(DateTimeFormatter.ofPattern("MM-dd")));
+            data.add(dateAmount.getOrDefault(date.toString(), BigDecimal.ZERO).setScale(2, RoundingMode.HALF_UP));
+        }
+        
+        trend.setDates(dates);
+        
+        SeriesDataVO series = new SeriesDataVO();
+        series.setName("销售额");
+        series.setData(data);
+        trend.setSeries(Collections.singletonList(series));
+        
+        return trend;
+    }
+
+    @Override
+    public Map<String, Object> getDeviceOverview(StatisticsQueryDTO queryDTO) {
+        Map<String, Object> result = new HashMap<>();
+        
+        LocalDate startDate = parseDate(queryDTO.getStartDate(), LocalDate.now().minusDays(30));
+        LocalDate endDate = parseDate(queryDTO.getEndDate(), LocalDate.now());
+        
+        long totalDevices = deviceMapper.selectCount(null);
+        long onlineDevices = deviceMapper.selectCount(
+                new LambdaQueryWrapper<Device>().eq(Device::getStatus, 1)
+        );
+        
+        result.put("totalDevices", totalDevices);
+        result.put("onlineDevices", onlineDevices);
+        result.put("offlineDevices", totalDevices - onlineDevices);
+        result.put("onlineRate", totalDevices > 0 
+                ? String.format("%.1f%%", onlineDevices * 100.0 / totalDevices) 
+                : "0%");
+        
+        LambdaQueryWrapper<Order> orderWrapper = new LambdaQueryWrapper<>();
+        orderWrapper.eq(Order::getPayStatus, OrderConstants.PAY_STATUS_PAID)
+                   .ge(Order::getPayTime, startDate.atStartOfDay())
+                   .le(Order::getPayTime, endDate.atTime(LocalTime.MAX));
+        
+        if (queryDTO.getShopId() != null) {
+            orderWrapper.eq(Order::getShopId, queryDTO.getShopId());
+        }
+        
+        List<Order> orders = orderMapper.selectList(orderWrapper);
+        
+        BigDecimal totalSales = orders.stream()
+                .map(o -> o.getTotalAmount() != null ? o.getTotalAmount() : BigDecimal.ZERO)
+                .reduce(BigDecimal.ZERO, BigDecimal::add);
+        result.put("totalSales", totalSales.setScale(2, RoundingMode.HALF_UP));
+        result.put("totalOrders", orders.size());
+        
+        return result;
+    }
+
+    @Override
+    public IPage<DeviceStatVO> getDeviceList(StatisticsQueryDTO queryDTO) {
+        queryDTO.validate();
+        
+        LocalDate startDate = parseDate(queryDTO.getStartDate(), LocalDate.now().minusDays(30));
+        LocalDate endDate = parseDate(queryDTO.getEndDate(), LocalDate.now());
+        
+        LambdaQueryWrapper<Device> deviceWrapper = new LambdaQueryWrapper<>();
+        if (queryDTO.getShopId() != null) {
+            deviceWrapper.eq(Device::getShopId, queryDTO.getShopId());
+        }
+        if (queryDTO.getStatus() != null) {
+            deviceWrapper.eq(Device::getStatus, queryDTO.getStatus());
+        }
+        if (queryDTO.getKeyword() != null) {
+            deviceWrapper.and(w -> w.like(Device::getDeviceId, queryDTO.getKeyword())
+                    .or().like(Device::getName, queryDTO.getKeyword()));
+        }
+        
+        List<Device> devices = deviceMapper.selectList(deviceWrapper);
+        
+        LambdaQueryWrapper<Order> orderWrapper = new LambdaQueryWrapper<>();
+        orderWrapper.eq(Order::getPayStatus, OrderConstants.PAY_STATUS_PAID)
+                   .ge(Order::getPayTime, startDate.atStartOfDay())
+                   .le(Order::getPayTime, endDate.atTime(LocalTime.MAX));
+        
+        if (queryDTO.getShopId() != null) {
+            orderWrapper.eq(Order::getShopId, queryDTO.getShopId());
+        }
+        
+        List<Order> orders = orderMapper.selectList(orderWrapper);
+        
+        Map<String, List<Order>> deviceOrderMap = orders.stream()
+                .filter(o -> o.getDeviceId() != null)
+                .collect(Collectors.groupingBy(Order::getDeviceId));
+        
+        List<DeviceStatVO> resultList = new ArrayList<>();
+        for (Device device : devices) {
+            DeviceStatVO vo = new DeviceStatVO();
+            vo.setDeviceId(device.getDeviceId());
+            vo.setDeviceName(device.getName());
+            vo.setShopId(device.getShopId());
+            vo.setStatus(device.getStatus());
+            vo.setStatusLabel(device.getStatus() != null && device.getStatus() == 1 ? "在线" : "离线");
+            vo.setStatusColor(device.getStatus() != null && device.getStatus() == 1 ? "success" : "info");
+            
+            List<Order> deviceOrders = deviceOrderMap.getOrDefault(device.getDeviceId(), Collections.emptyList());
+            
+            vo.setOrderCount(deviceOrders.size());
+            vo.setUserCount((int) deviceOrders.stream().map(Order::getUserId).filter(Objects::nonNull).distinct().count());
+            
+            BigDecimal salesAmount = deviceOrders.stream()
+                    .map(o -> o.getTotalAmount() != null ? o.getTotalAmount() : BigDecimal.ZERO)
+                    .reduce(BigDecimal.ZERO, BigDecimal::add);
+            vo.setSalesAmount(salesAmount.setScale(2, RoundingMode.HALF_UP));
+            
+            BigDecimal avgOrderAmount = deviceOrders.size() > 0 
+                    ? salesAmount.divide(BigDecimal.valueOf(deviceOrders.size()), 2, RoundingMode.HALF_UP)
+                    : BigDecimal.ZERO;
+            vo.setAvgOrderAmount(avgOrderAmount);
+            
+            long days = ChronoUnit.DAYS.between(startDate, endDate) + 1;
+            BigDecimal dailySales = salesAmount.divide(BigDecimal.valueOf(days), 2, RoundingMode.HALF_UP);
+            vo.setDailySalesAmount(dailySales);
+            
+            if (device.getShopId() != null) {
+                Shop shop = shopMapper.selectById(device.getShopId());
+                if (shop != null) {
+                    vo.setShopName(shop.getName());
+                }
+            }
+            
+            resultList.add(vo);
+        }
+        
+        String sortBy = queryDTO.getSortBy() != null ? queryDTO.getSortBy() : "salesAmount";
+        boolean asc = "asc".equalsIgnoreCase(queryDTO.getSortOrder());
+        
+        Comparator<DeviceStatVO> comparator = null;
+        switch (sortBy) {
+            case "orderCount":
+                comparator = Comparator.comparing(DeviceStatVO::getOrderCount);
+                break;
+            case "salesAmount":
+            default:
+                comparator = Comparator.comparing(DeviceStatVO::getSalesAmount);
+                break;
+        }
+        
+        if (!asc) {
+            comparator = comparator.reversed();
+        }
+        resultList.sort(comparator);
+        
+        Page<DeviceStatVO> page = new Page<>(queryDTO.getPage(), queryDTO.getPageSize());
+        int start = (queryDTO.getPage() - 1) * queryDTO.getPageSize();
+        int end = Math.min(start + queryDTO.getPageSize(), resultList.size());
+        
+        page.setRecords(resultList.subList(start, end));
+        page.setTotal(resultList.size());
+        
+        return page;
+    }
+
+    @Override
+    public TrendDataVO getDeviceTrend(String deviceId, StatisticsQueryDTO queryDTO) {
+        TrendDataVO trend = new TrendDataVO();
+        
+        LocalDate startDate = parseDate(queryDTO.getStartDate(), LocalDate.now().minusDays(30));
+        LocalDate endDate = parseDate(queryDTO.getEndDate(), LocalDate.now());
+        
+        LambdaQueryWrapper<Order> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(Order::getDeviceId, deviceId)
+               .eq(Order::getPayStatus, OrderConstants.PAY_STATUS_PAID)
+               .ge(Order::getPayTime, startDate.atStartOfDay())
+               .le(Order::getPayTime, endDate.atTime(LocalTime.MAX));
+        
+        List<Order> orders = orderMapper.selectList(wrapper);
+        
+        Map<String, BigDecimal> dateAmount = orders.stream()
+                .collect(Collectors.groupingBy(
+                        o -> o.getPayTime().toLocalDate().toString(),
+                        Collectors.reducing(BigDecimal.ZERO, 
+                                o -> o.getTotalAmount() != null ? o.getTotalAmount() : BigDecimal.ZERO,
+                                BigDecimal::add)
+                ));
+        
+        List<String> dates = new ArrayList<>();
+        List<BigDecimal> data = new ArrayList<>();
+        
+        long days = ChronoUnit.DAYS.between(startDate, endDate) + 1;
+        for (int i = 0; i < days; i++) {
+            LocalDate date = startDate.plusDays(i);
+            dates.add(date.format(DateTimeFormatter.ofPattern("MM-dd")));
+            data.add(dateAmount.getOrDefault(date.toString(), BigDecimal.ZERO).setScale(2, RoundingMode.HALF_UP));
+        }
+        
+        trend.setDates(dates);
+        
+        SeriesDataVO series = new SeriesDataVO();
+        series.setName("销售额");
+        series.setData(data);
+        trend.setSeries(Collections.singletonList(series));
+        
+        return trend;
+    }
+
+    @Override
+    public Map<String, Object> getShopOverview(StatisticsQueryDTO queryDTO) {
+        Map<String, Object> result = new HashMap<>();
+        
+        LocalDate startDate = parseDate(queryDTO.getStartDate(), LocalDate.now().minusDays(30));
+        LocalDate endDate = parseDate(queryDTO.getEndDate(), LocalDate.now());
+        
+        long totalShops = shopMapper.selectCount(null);
+        long activeShops = shopMapper.selectCount(
+                new LambdaQueryWrapper<Shop>().eq(Shop::getStatus, 1)
+        );
+        
+        result.put("totalShops", totalShops);
+        result.put("activeShops", activeShops);
+        
+        LambdaQueryWrapper<Order> orderWrapper = new LambdaQueryWrapper<>();
+        orderWrapper.eq(Order::getPayStatus, OrderConstants.PAY_STATUS_PAID)
+                   .ge(Order::getPayTime, startDate.atStartOfDay())
+                   .le(Order::getPayTime, endDate.atTime(LocalTime.MAX));
+        
+        List<Order> orders = orderMapper.selectList(orderWrapper);
+        
+        BigDecimal totalSales = orders.stream()
+                .map(o -> o.getTotalAmount() != null ? o.getTotalAmount() : BigDecimal.ZERO)
+                .reduce(BigDecimal.ZERO, BigDecimal::add);
+        result.put("totalSales", totalSales.setScale(2, RoundingMode.HALF_UP));
+        result.put("totalOrders", orders.size());
+        
+        return result;
+    }
+
+    @Override
+    public IPage<ShopStatVO> getShopList(StatisticsQueryDTO queryDTO) {
+        queryDTO.validate();
+        
+        LocalDate startDate = parseDate(queryDTO.getStartDate(), LocalDate.now().minusDays(30));
+        LocalDate endDate = parseDate(queryDTO.getEndDate(), LocalDate.now());
+        
+        LambdaQueryWrapper<Shop> shopWrapper = new LambdaQueryWrapper<>();
+        if (queryDTO.getProvince() != null) {
+            shopWrapper.eq(Shop::getProvince, queryDTO.getProvince());
+        }
+        if (queryDTO.getCity() != null) {
+            shopWrapper.eq(Shop::getCity, queryDTO.getCity());
+        }
+        if (queryDTO.getDistrict() != null) {
+            shopWrapper.eq(Shop::getDistrict, queryDTO.getDistrict());
+        }
+        if (queryDTO.getStatus() != null) {
+            shopWrapper.eq(Shop::getStatus, queryDTO.getStatus());
+        }
+        if (queryDTO.getKeyword() != null) {
+            shopWrapper.like(Shop::getName, queryDTO.getKeyword());
+        }
+        
+        List<Shop> shops = shopMapper.selectList(shopWrapper);
+        
+        LambdaQueryWrapper<Order> orderWrapper = new LambdaQueryWrapper<>();
+        orderWrapper.eq(Order::getPayStatus, OrderConstants.PAY_STATUS_PAID)
+                   .ge(Order::getPayTime, startDate.atStartOfDay())
+                   .le(Order::getPayTime, endDate.atTime(LocalTime.MAX));
+        
+        List<Order> orders = orderMapper.selectList(orderWrapper);
+        
+        Map<Long, List<Order>> shopOrderMap = orders.stream()
+                .filter(o -> o.getShopId() != null)
+                .collect(Collectors.groupingBy(Order::getShopId));
+        
+        List<ShopStatVO> resultList = new ArrayList<>();
+        for (Shop shop : shops) {
+            ShopStatVO vo = new ShopStatVO();
+            vo.setShopId(shop.getId());
+            vo.setShopName(shop.getName());
+            vo.setProvince(shop.getProvince());
+            vo.setCity(shop.getCity());
+            vo.setDistrict(shop.getDistrict());
+            vo.setAddress(shop.getAddress());
+            vo.setStatus(shop.getStatus());
+            vo.setStatusLabel(shop.getStatus() != null && shop.getStatus() == 1 ? "启用" : "禁用");
+            
+            long deviceCount = deviceMapper.selectCount(
+                    new LambdaQueryWrapper<Device>().eq(Device::getShopId, shop.getId())
+            );
+            vo.setDeviceCount((int) deviceCount);
+            
+            List<Order> shopOrders = shopOrderMap.getOrDefault(shop.getId(), Collections.emptyList());
+            
+            vo.setOrderCount(shopOrders.size());
+            vo.setUserCount((int) shopOrders.stream().map(Order::getUserId).filter(Objects::nonNull).distinct().count());
+            
+            BigDecimal salesAmount = shopOrders.stream()
+                    .map(o -> o.getTotalAmount() != null ? o.getTotalAmount() : BigDecimal.ZERO)
+                    .reduce(BigDecimal.ZERO, BigDecimal::add);
+            vo.setSalesAmount(salesAmount.setScale(2, RoundingMode.HALF_UP));
+            
+            BigDecimal avgOrderAmount = shopOrders.size() > 0 
+                    ? salesAmount.divide(BigDecimal.valueOf(shopOrders.size()), 2, RoundingMode.HALF_UP)
+                    : BigDecimal.ZERO;
+            vo.setAvgOrderAmount(avgOrderAmount);
+            
+            long days = ChronoUnit.DAYS.between(startDate, endDate) + 1;
+            BigDecimal dailySales = salesAmount.divide(BigDecimal.valueOf(days), 2, RoundingMode.HALF_UP);
+            vo.setDailySalesAmount(dailySales);
+            
+            BigDecimal deviceOutput = deviceCount > 0 
+                    ? salesAmount.divide(BigDecimal.valueOf(deviceCount), 2, RoundingMode.HALF_UP)
+                    : BigDecimal.ZERO;
+            vo.setDeviceOutput(deviceOutput);
+            
+            resultList.add(vo);
+        }
+        
+        String sortBy = queryDTO.getSortBy() != null ? queryDTO.getSortBy() : "salesAmount";
+        boolean asc = "asc".equalsIgnoreCase(queryDTO.getSortOrder());
+        
+        Comparator<ShopStatVO> comparator = null;
+        switch (sortBy) {
+            case "orderCount":
+                comparator = Comparator.comparing(ShopStatVO::getOrderCount);
+                break;
+            case "profit":
+                comparator = Comparator.comparing(ShopStatVO::getProfitAmount);
+                break;
+            case "salesAmount":
+            default:
+                comparator = Comparator.comparing(ShopStatVO::getSalesAmount);
+                break;
+        }
+        
+        if (!asc) {
+            comparator = comparator.reversed();
+        }
+        resultList.sort(comparator);
+        
+        Page<ShopStatVO> page = new Page<>(queryDTO.getPage(), queryDTO.getPageSize());
+        int start = (queryDTO.getPage() - 1) * queryDTO.getPageSize();
+        int end = Math.min(start + queryDTO.getPageSize(), resultList.size());
+        
+        page.setRecords(resultList.subList(start, end));
+        page.setTotal(resultList.size());
+        
+        return page;
+    }
+
+    @Override
+    public List<ShopStatVO> getShopRanking(StatisticsQueryDTO queryDTO) {
+        LocalDate startDate = parseDate(queryDTO.getStartDate(), LocalDate.now().minusDays(30));
+        LocalDate endDate = parseDate(queryDTO.getEndDate(), LocalDate.now());
+        int limit = queryDTO.getLimit() != null ? queryDTO.getLimit() : 10;
+        String type = queryDTO.getType() != null ? queryDTO.getType() : "sales";
+        
+        StatisticsQueryDTO listQuery = new StatisticsQueryDTO();
+        listQuery.setStartDate(startDate.toString());
+        listQuery.setEndDate(endDate.toString());
+        listQuery.setProvince(queryDTO.getProvince());
+        listQuery.setCity(queryDTO.getCity());
+        listQuery.setSortBy(type);
+        listQuery.setSortOrder("desc");
+        listQuery.setPage(1);
+        listQuery.setPageSize(limit);
+        
+        IPage<ShopStatVO> page = getShopList(listQuery);
+        return page.getRecords();
+    }
+
+    @Override
+    public TrendDataVO getShopTrend(Long shopId, StatisticsQueryDTO queryDTO) {
+        TrendDataVO trend = new TrendDataVO();
+        
+        LocalDate startDate = parseDate(queryDTO.getStartDate(), LocalDate.now().minusDays(30));
+        LocalDate endDate = parseDate(queryDTO.getEndDate(), LocalDate.now());
+        
+        LambdaQueryWrapper<Order> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(Order::getShopId, shopId)
+               .eq(Order::getPayStatus, OrderConstants.PAY_STATUS_PAID)
+               .ge(Order::getPayTime, startDate.atStartOfDay())
+               .le(Order::getPayTime, endDate.atTime(LocalTime.MAX));
+        
+        List<Order> orders = orderMapper.selectList(wrapper);
+        
+        Map<String, BigDecimal> dateAmount = orders.stream()
+                .collect(Collectors.groupingBy(
+                        o -> o.getPayTime().toLocalDate().toString(),
+                        Collectors.reducing(BigDecimal.ZERO, 
+                                o -> o.getTotalAmount() != null ? o.getTotalAmount() : BigDecimal.ZERO,
+                                BigDecimal::add)
+                ));
+        
+        List<String> dates = new ArrayList<>();
+        List<BigDecimal> data = new ArrayList<>();
+        
+        long days = ChronoUnit.DAYS.between(startDate, endDate) + 1;
+        for (int i = 0; i < days; i++) {
+            LocalDate date = startDate.plusDays(i);
+            dates.add(date.format(DateTimeFormatter.ofPattern("MM-dd")));
+            data.add(dateAmount.getOrDefault(date.toString(), BigDecimal.ZERO).setScale(2, RoundingMode.HALF_UP));
+        }
+        
+        trend.setDates(dates);
+        
+        SeriesDataVO series = new SeriesDataVO();
+        series.setName("销售额");
+        series.setData(data);
+        trend.setSeries(Collections.singletonList(series));
+        
+        return trend;
+    }
+
+    @Override
+    public Map<String, Object> getProfitOverview(StatisticsQueryDTO queryDTO) {
+        Map<String, Object> result = new HashMap<>();
+        
+        LocalDate startDate = parseDate(queryDTO.getStartDate(), LocalDate.now().minusDays(30));
+        LocalDate endDate = parseDate(queryDTO.getEndDate(), LocalDate.now());
+        
+        LocalDateTime startDateTime = startDate.atStartOfDay();
+        LocalDateTime endDateTime = endDate.atTime(LocalTime.MAX);
+        
+        LambdaQueryWrapper<OrderItem> itemWrapper = new LambdaQueryWrapper<>();
+        itemWrapper.ge(OrderItem::getPayTime, startDateTime)
+                   .le(OrderItem::getPayTime, endDateTime);
+        
+        List<OrderItem> items = orderItemMapper.selectList(itemWrapper);
+        
+        BigDecimal salesAmount = items.stream()
+                .map(i -> i.getTotalAmount() != null ? i.getTotalAmount() : BigDecimal.ZERO)
+                .reduce(BigDecimal.ZERO, BigDecimal::add);
+        result.put("salesAmount", salesAmount.setScale(2, RoundingMode.HALF_UP));
+        
+        BigDecimal costAmount = items.stream()
+                .map(i -> i.getCostPrice() != null && i.getQuantity() != null 
+                        ? i.getCostPrice().multiply(BigDecimal.valueOf(i.getQuantity())) 
+                        : BigDecimal.ZERO)
+                .reduce(BigDecimal.ZERO, BigDecimal::add);
+        result.put("costAmount", costAmount.setScale(2, RoundingMode.HALF_UP));
+        
+        BigDecimal grossProfit = salesAmount.subtract(costAmount);
+        result.put("grossProfit", grossProfit.setScale(2, RoundingMode.HALF_UP));
+        
+        BigDecimal grossProfitRate = salesAmount.compareTo(BigDecimal.ZERO) > 0 
+                ? grossProfit.divide(salesAmount, 4, RoundingMode.HALF_UP).multiply(new BigDecimal("100"))
+                : BigDecimal.ZERO;
+        result.put("grossProfitRate", grossProfitRate.setScale(2, RoundingMode.HALF_UP));
+        
+        return result;
+    }
+
+    @Override
+    public IPage<ProfitStatVO> getProfitList(StatisticsQueryDTO queryDTO) {
+        queryDTO.validate();
+        
+        LocalDate startDate = parseDate(queryDTO.getStartDate(), LocalDate.now().minusDays(30));
+        LocalDate endDate = parseDate(queryDTO.getEndDate(), LocalDate.now());
+        
+        LambdaQueryWrapper<Shop> shopWrapper = new LambdaQueryWrapper<>();
+        if (queryDTO.getProvince() != null) {
+            shopWrapper.eq(Shop::getProvince, queryDTO.getProvince());
+        }
+        if (queryDTO.getCity() != null) {
+            shopWrapper.eq(Shop::getCity, queryDTO.getCity());
+        }
+        if (queryDTO.getShopId() != null) {
+            shopWrapper.eq(Shop::getId, queryDTO.getShopId());
+        }
+        
+        List<Shop> shops = shopMapper.selectList(shopWrapper);
+        
+        LambdaQueryWrapper<OrderItem> itemWrapper = new LambdaQueryWrapper<>();
+        itemWrapper.ge(OrderItem::getPayTime, startDate.atStartOfDay())
+                   .le(OrderItem::getPayTime, endDate.atTime(LocalTime.MAX));
+        
+        List<OrderItem> items = orderItemMapper.selectList(itemWrapper);
+        
+        Map<Long, List<OrderItem>> shopItemMap = items.stream()
+                .filter(i -> i.getShopId() != null)
+                .collect(Collectors.groupingBy(OrderItem::getShopId));
+        
+        List<ProfitStatVO> resultList = new ArrayList<>();
+        for (Shop shop : shops) {
+            ProfitStatVO vo = new ProfitStatVO();
+            vo.setShopId(shop.getId());
+            vo.setShopName(shop.getName());
+            vo.setProvince(shop.getProvince());
+            vo.setCity(shop.getCity());
+            vo.setDistrict(shop.getDistrict());
+            
+            long deviceCount = deviceMapper.selectCount(
+                    new LambdaQueryWrapper<Device>().eq(Device::getShopId, shop.getId())
+            );
+            vo.setDeviceCount((int) deviceCount);
+            
+            List<OrderItem> shopItems = shopItemMap.getOrDefault(shop.getId(), Collections.emptyList());
+            
+            BigDecimal salesAmount = shopItems.stream()
+                    .map(i -> i.getTotalAmount() != null ? i.getTotalAmount() : BigDecimal.ZERO)
+                    .reduce(BigDecimal.ZERO, BigDecimal::add);
+            vo.setSalesAmount(salesAmount.setScale(2, RoundingMode.HALF_UP));
+            
+            BigDecimal costAmount = shopItems.stream()
+                    .map(i -> i.getCostPrice() != null && i.getQuantity() != null 
+                            ? i.getCostPrice().multiply(BigDecimal.valueOf(i.getQuantity())) 
+                            : BigDecimal.ZERO)
+                    .reduce(BigDecimal.ZERO, BigDecimal::add);
+            vo.setCostAmount(costAmount.setScale(2, RoundingMode.HALF_UP));
+            
+            BigDecimal grossProfit = salesAmount.subtract(costAmount);
+            vo.setGrossProfit(grossProfit.setScale(2, RoundingMode.HALF_UP));
+            
+            BigDecimal grossProfitRate = salesAmount.compareTo(BigDecimal.ZERO) > 0 
+                    ? grossProfit.divide(salesAmount, 4, RoundingMode.HALF_UP).multiply(new BigDecimal("100"))
+                    : BigDecimal.ZERO;
+            vo.setGrossProfitRate(grossProfitRate.setScale(2, RoundingMode.HALF_UP));
+            
+            vo.setRefundAmount(BigDecimal.ZERO);
+            vo.setRefundRate(BigDecimal.ZERO);
+            vo.setNetProfit(grossProfit);
+            
+            BigDecimal netProfitRate = salesAmount.compareTo(BigDecimal.ZERO) > 0 
+                    ? grossProfit.divide(salesAmount, 4, RoundingMode.HALF_UP).multiply(new BigDecimal("100"))
+                    : BigDecimal.ZERO;
+            vo.setNetProfitRate(netProfitRate.setScale(2, RoundingMode.HALF_UP));
+            
+            BigDecimal deviceProfit = deviceCount > 0 
+                    ? grossProfit.divide(BigDecimal.valueOf(deviceCount), 2, RoundingMode.HALF_UP)
+                    : BigDecimal.ZERO;
+            vo.setDeviceProfit(deviceProfit);
+            
+            long days = ChronoUnit.DAYS.between(startDate, endDate) + 1;
+            BigDecimal dailyProfit = grossProfit.divide(BigDecimal.valueOf(days), 2, RoundingMode.HALF_UP);
+            vo.setDailyProfit(dailyProfit);
+            
+            resultList.add(vo);
+        }
+        
+        String sortBy = queryDTO.getSortBy() != null ? queryDTO.getSortBy() : "netProfit";
+        boolean asc = "asc".equalsIgnoreCase(queryDTO.getSortOrder());
+        
+        Comparator<ProfitStatVO> comparator = null;
+        switch (sortBy) {
+            case "salesAmount":
+                comparator = Comparator.comparing(ProfitStatVO::getSalesAmount);
+                break;
+            case "grossProfit":
+                comparator = Comparator.comparing(ProfitStatVO::getGrossProfit);
+                break;
+            case "netProfit":
+            default:
+                comparator = Comparator.comparing(ProfitStatVO::getNetProfit);
+                break;
+        }
+        
+        if (!asc) {
+            comparator = comparator.reversed();
+        }
+        resultList.sort(comparator);
+        
+        Page<ProfitStatVO> page = new Page<>(queryDTO.getPage(), queryDTO.getPageSize());
+        int start = (queryDTO.getPage() - 1) * queryDTO.getPageSize();
+        int end = Math.min(start + queryDTO.getPageSize(), resultList.size());
+        
+        page.setRecords(resultList.subList(start, end));
+        page.setTotal(resultList.size());
+        
+        return page;
+    }
+
+    @Override
+    public TrendDataVO getProfitTrend(StatisticsQueryDTO queryDTO) {
+        TrendDataVO trend = new TrendDataVO();
+        
+        LocalDate startDate = parseDate(queryDTO.getStartDate(), LocalDate.now().minusDays(30));
+        LocalDate endDate = parseDate(queryDTO.getEndDate(), LocalDate.now());
+        
+        LambdaQueryWrapper<OrderItem> wrapper = new LambdaQueryWrapper<>();
+        wrapper.ge(OrderItem::getPayTime, startDate.atStartOfDay())
+               .le(OrderItem::getPayTime, endDate.atTime(LocalTime.MAX));
+        
+        if (queryDTO.getShopId() != null) {
+            wrapper.eq(OrderItem::getShopId, queryDTO.getShopId());
+        }
+        
+        List<OrderItem> items = orderItemMapper.selectList(wrapper);
+        
+        Map<String, BigDecimal> dateProfit = new HashMap<>();
+        for (OrderItem item : items) {
+            String date = item.getPayTime().toLocalDate().toString();
+            BigDecimal sales = item.getTotalAmount() != null ? item.getTotalAmount() : BigDecimal.ZERO;
+            BigDecimal cost = item.getCostPrice() != null && item.getQuantity() != null 
+                    ? item.getCostPrice().multiply(BigDecimal.valueOf(item.getQuantity())) 
+                    : BigDecimal.ZERO;
+            BigDecimal profit = sales.subtract(cost);
+            dateProfit.merge(date, profit, BigDecimal::add);
+        }
+        
+        List<String> dates = new ArrayList<>();
+        List<BigDecimal> data = new ArrayList<>();
+        
+        long days = ChronoUnit.DAYS.between(startDate, endDate) + 1;
+        for (int i = 0; i < days; i++) {
+            LocalDate date = startDate.plusDays(i);
+            dates.add(date.format(DateTimeFormatter.ofPattern("MM-dd")));
+            data.add(dateProfit.getOrDefault(date.toString(), BigDecimal.ZERO).setScale(2, RoundingMode.HALF_UP));
+        }
+        
+        trend.setDates(dates);
+        
+        SeriesDataVO series = new SeriesDataVO();
+        series.setName("利润");
+        series.setData(data);
+        trend.setSeries(Collections.singletonList(series));
+        
+        return trend;
+    }
+
+    @Override
+    public List<ProfitStatVO> getProfitWarning(StatisticsQueryDTO queryDTO) {
+        StatisticsQueryDTO listQuery = new StatisticsQueryDTO();
+        listQuery.setStartDate(queryDTO.getStartDate());
+        listQuery.setEndDate(queryDTO.getEndDate());
+        listQuery.setProvince(queryDTO.getProvince());
+        listQuery.setCity(queryDTO.getCity());
+        listQuery.setSortBy("netProfit");
+        listQuery.setSortOrder("asc");
+        listQuery.setPage(1);
+        listQuery.setPageSize(20);
+        
+        IPage<ProfitStatVO> page = getProfitList(listQuery);
+        
+        String type = queryDTO.getType() != null ? queryDTO.getType() : "low";
+        BigDecimal threshold = queryDTO.getThreshold() != null 
+                ? queryDTO.getThreshold() 
+                : new BigDecimal("10");
+        
+        return page.getRecords().stream()
+                .filter(vo -> {
+                    if ("negative".equals(type)) {
+                        return vo.getNetProfit().compareTo(BigDecimal.ZERO) < 0;
+                    } else {
+                        return vo.getNetProfitRate().compareTo(threshold) < 0;
+                    }
+                })
+                .collect(Collectors.toList());
+    }
+
+    @Override
+    public Map<String, Object> getRepurchaseOverview(StatisticsQueryDTO queryDTO) {
+        Map<String, Object> result = new HashMap<>();
+        
+        LocalDate startDate = parseDate(queryDTO.getStartDate(), LocalDate.now().minusDays(30));
+        LocalDate endDate = parseDate(queryDTO.getEndDate(), LocalDate.now());
+        
+        LambdaQueryWrapper<Order> orderWrapper = new LambdaQueryWrapper<>();
+        orderWrapper.eq(Order::getPayStatus, OrderConstants.PAY_STATUS_PAID)
+                   .ge(Order::getPayTime, startDate.atStartOfDay())
+                   .le(Order::getPayTime, endDate.atTime(LocalTime.MAX));
+        
+        if (queryDTO.getShopId() != null) {
+            orderWrapper.eq(Order::getShopId, queryDTO.getShopId());
+        }
+        
+        List<Order> orders = orderMapper.selectList(orderWrapper);
+        
+        Map<Long, List<Order>> userOrderMap = orders.stream()
+                .filter(o -> o.getUserId() != null)
+                .collect(Collectors.groupingBy(Order::getUserId));
+        
+        long totalUsers = userOrderMap.size();
+        long newUsers = userOrderMap.values().stream()
+                .filter(orderList -> orderList.size() == 1)
+                .count();
+        long repurchaseUsers = userOrderMap.values().stream()
+                .filter(orderList -> orderList.size() >= 2)
+                .count();
+        
+        result.put("totalUsers", totalUsers);
+        result.put("newUsers", newUsers);
+        result.put("repurchaseUsers", repurchaseUsers);
+        
+        BigDecimal repurchaseRate = totalUsers > 0 
+                ? BigDecimal.valueOf(repurchaseUsers * 100.0 / totalUsers)
+                : BigDecimal.ZERO;
+        result.put("repurchaseRate", repurchaseRate.setScale(2, RoundingMode.HALF_UP));
+        
+        BigDecimal totalSales = orders.stream()
+                .map(o -> o.getTotalAmount() != null ? o.getTotalAmount() : BigDecimal.ZERO)
+                .reduce(BigDecimal.ZERO, BigDecimal::add);
+        
+        BigDecimal avgOrderAmount = orders.size() > 0 
+                ? totalSales.divide(BigDecimal.valueOf(orders.size()), 2, RoundingMode.HALF_UP)
+                : BigDecimal.ZERO;
+        result.put("avgOrderAmount", avgOrderAmount);
+        
+        BigDecimal avgPurchaseCount = totalUsers > 0 
+                ? BigDecimal.valueOf(orders.size()).divide(BigDecimal.valueOf(totalUsers), 2, RoundingMode.HALF_UP)
+                : BigDecimal.ZERO;
+        result.put("avgPurchaseCount", avgPurchaseCount);
+        
+        BigDecimal ltv = avgPurchaseCount.multiply(avgOrderAmount);
+        result.put("ltv", ltv.setScale(2, RoundingMode.HALF_UP));
+        
+        return result;
+    }
+
+    @Override
+    public Map<String, Object> getRepurchaseDistribution(StatisticsQueryDTO queryDTO) {
+        Map<String, Object> result = new HashMap<>();
+        
+        LocalDate startDate = parseDate(queryDTO.getStartDate(), LocalDate.now().minusDays(30));
+        LocalDate endDate = parseDate(queryDTO.getEndDate(), LocalDate.now());
+        String type = queryDTO.getType() != null ? queryDTO.getType() : "layer";
+        
+        LambdaQueryWrapper<Order> orderWrapper = new LambdaQueryWrapper<>();
+        orderWrapper.eq(Order::getPayStatus, OrderConstants.PAY_STATUS_PAID)
+                   .ge(Order::getPayTime, startDate.atStartOfDay())
+                   .le(Order::getPayTime, endDate.atTime(LocalTime.MAX));
+        
+        if (queryDTO.getShopId() != null) {
+            orderWrapper.eq(Order::getShopId, queryDTO.getShopId());
+        }
+        
+        List<Order> orders = orderMapper.selectList(orderWrapper);
+        
+        Map<Long, List<Order>> userOrderMap = orders.stream()
+                .filter(o -> o.getUserId() != null)
+                .collect(Collectors.groupingBy(Order::getUserId));
+        
+        if ("interval".equals(type)) {
+            Map<String, Integer> intervalDistribution = new LinkedHashMap<>();
+            intervalDistribution.put("1天内", 0);
+            intervalDistribution.put("1-3天", 0);
+            intervalDistribution.put("3-7天", 0);
+            intervalDistribution.put("7-14天", 0);
+            intervalDistribution.put("14-30天", 0);
+            intervalDistribution.put("30天以上", 0);
+            
+            for (List<Order> userOrders : userOrderMap.values()) {
+                if (userOrders.size() >= 2) {
+                    userOrders.sort(Comparator.comparing(Order::getPayTime));
+                    for (int i = 1; i < userOrders.size(); i++) {
+                        long days = ChronoUnit.DAYS.between(
+                                userOrders.get(i-1).getPayTime().toLocalDate(),
+                                userOrders.get(i).getPayTime().toLocalDate()
+                        );
+                        
+                        if (days < 1) intervalDistribution.merge("1天内", 1, Integer::sum);
+                        else if (days < 3) intervalDistribution.merge("1-3天", 1, Integer::sum);
+                        else if (days < 7) intervalDistribution.merge("3-7天", 1, Integer::sum);
+                        else if (days < 14) intervalDistribution.merge("7-14天", 1, Integer::sum);
+                        else if (days < 30) intervalDistribution.merge("14-30天", 1, Integer::sum);
+                        else intervalDistribution.merge("30天以上", 1, Integer::sum);
+                    }
+                }
+            }
+            
+            result.put("distribution", intervalDistribution);
+        } else {
+            Map<String, Integer> layerDistribution = new LinkedHashMap<>();
+            
+            int newUserCount = 0;
+            int activeUserCount = 0;
+            int loyalUserCount = 0;
+            int churnUserCount = 0;
+            
+            LocalDate now = LocalDate.now();
+            
+            for (Map.Entry<Long, List<Order>> entry : userOrderMap.entrySet()) {
+                List<Order> userOrders = entry.getValue();
+                int totalOrders = userOrders.size();
+                
+                LocalDate lastOrderDate = userOrders.stream()
+                        .map(o -> o.getPayTime().toLocalDate())
+                        .max(LocalDate::compareTo)
+                        .orElse(now);
+                
+                long daysSinceLastOrder = ChronoUnit.DAYS.between(lastOrderDate, now);
+                
+                if (totalOrders == 1) {
+                    newUserCount++;
+                } else if (totalOrders >= 5) {
+                    loyalUserCount++;
+                } else if (daysSinceLastOrder > 60) {
+                    churnUserCount++;
+                } else {
+                    activeUserCount++;
+                }
+            }
+            
+            layerDistribution.put("新用户", newUserCount);
+            layerDistribution.put("活跃用户", activeUserCount);
+            layerDistribution.put("忠诚用户", loyalUserCount);
+            layerDistribution.put("流失用户", churnUserCount);
+            
+            result.put("distribution", layerDistribution);
+        }
+        
+        return result;
+    }
+
+    @Override
+    public TrendDataVO getRepurchaseTrend(StatisticsQueryDTO queryDTO) {
+        TrendDataVO trend = new TrendDataVO();
+        
+        LocalDate startDate = parseDate(queryDTO.getStartDate(), LocalDate.now().minusDays(30));
+        LocalDate endDate = parseDate(queryDTO.getEndDate(), LocalDate.now());
+        
+        List<String> dates = new ArrayList<>();
+        List<BigDecimal> data = new ArrayList<>();
+        
+        long days = ChronoUnit.DAYS.between(startDate, endDate) + 1;
+        for (int i = 0; i < days; i++) {
+            LocalDate date = startDate.plusDays(i);
+            dates.add(date.format(DateTimeFormatter.ofPattern("MM-dd")));
+            
+            LocalDateTime dayStart = date.atStartOfDay();
+            LocalDateTime dayEnd = date.atTime(LocalTime.MAX);
+            
+            LambdaQueryWrapper<Order> dayWrapper = new LambdaQueryWrapper<>();
+            dayWrapper.eq(Order::getPayStatus, OrderConstants.PAY_STATUS_PAID)
+                     .ge(Order::getPayTime, dayStart)
+                     .le(Order::getPayTime, dayEnd);
+            
+            if (queryDTO.getShopId() != null) {
+                dayWrapper.eq(Order::getShopId, queryDTO.getShopId());
+            }
+            
+            List<Order> dayOrders = orderMapper.selectList(dayWrapper);
+            
+            Map<Long, Long> userOrderCount = dayOrders.stream()
+                    .filter(o -> o.getUserId() != null)
+                    .collect(Collectors.groupingBy(Order::getUserId, Collectors.counting()));
+            
+            long dayRepurchaseUsers = userOrderCount.values().stream()
+                    .filter(count -> count >= 2)
+                    .count();
+            
+            BigDecimal repurchaseRate = userOrderCount.size() > 0 
+                    ? BigDecimal.valueOf(dayRepurchaseUsers * 100.0 / userOrderCount.size())
+                    : BigDecimal.ZERO;
+            
+            data.add(repurchaseRate.setScale(2, RoundingMode.HALF_UP));
+        }
+        
+        trend.setDates(dates);
+        
+        SeriesDataVO series = new SeriesDataVO();
+        series.setName("复购率(%)");
+        series.setData(data);
+        trend.setSeries(Collections.singletonList(series));
+        
+        return trend;
+    }
+
+    @Override
+    public IPage<RepurchaseStatVO> getRepurchaseUsers(StatisticsQueryDTO queryDTO) {
+        queryDTO.validate();
+        
+        LocalDate startDate = parseDate(queryDTO.getStartDate(), LocalDate.now().minusDays(30));
+        LocalDate endDate = parseDate(queryDTO.getEndDate(), LocalDate.now());
+        
+        LambdaQueryWrapper<Order> orderWrapper = new LambdaQueryWrapper<>();
+        orderWrapper.eq(Order::getPayStatus, OrderConstants.PAY_STATUS_PAID)
+                   .ge(Order::getPayTime, startDate.atStartOfDay())
+                   .le(Order::getPayTime, endDate.atTime(LocalTime.MAX));
+        
+        if (queryDTO.getShopId() != null) {
+            orderWrapper.eq(Order::getShopId, queryDTO.getShopId());
+        }
+        
+        List<Order> orders = orderMapper.selectList(orderWrapper);
+        
+        Map<Long, List<Order>> userOrderMap = orders.stream()
+                .filter(o -> o.getUserId() != null)
+                .collect(Collectors.groupingBy(Order::getUserId));
+        
+        List<RepurchaseStatVO> resultList = new ArrayList<>();
+        
+        for (Map.Entry<Long, List<Order>> entry : userOrderMap.entrySet()) {
+            Long userId = entry.getKey();
+            List<Order> userOrders = entry.getValue();
+            
+            if (queryDTO.getMinOrderCount() != null && userOrders.size() < queryDTO.getMinOrderCount()) {
+                continue;
+            }
+            
+            RepurchaseStatVO vo = new RepurchaseStatVO();
+            vo.setUserId(userId);
+            vo.setOrderCount(userOrders.size());
+            
+            User user = userMapper.selectById(userId);
+            if (user != null) {
+                vo.setNickname(user.getNickname());
+                vo.setPhone(user.getPhone());
+            }
+            
+            userOrders.sort(Comparator.comparing(Order::getPayTime));
+            
+            vo.setFirstOrderDate(userOrders.get(0).getPayTime().toLocalDate());
+            vo.setLastOrderDate(userOrders.get(userOrders.size() - 1).getPayTime().toLocalDate());
+            
+            BigDecimal totalAmount = userOrders.stream()
+                    .map(o -> o.getTotalAmount() != null ? o.getTotalAmount() : BigDecimal.ZERO)
+                    .reduce(BigDecimal.ZERO, BigDecimal::add);
+            vo.setTotalAmount(totalAmount.setScale(2, RoundingMode.HALF_UP));
+            
+            BigDecimal avgOrderAmount = userOrders.size() > 0 
+                    ? totalAmount.divide(BigDecimal.valueOf(userOrders.size()), 2, RoundingMode.HALF_UP)
+                    : BigDecimal.ZERO;
+            vo.setAvgOrderAmount(avgOrderAmount);
+            
+            if (userOrders.size() >= 2) {
+                long avgDays = 0;
+                for (int i = 1; i < userOrders.size(); i++) {
+                    avgDays += ChronoUnit.DAYS.between(
+                            userOrders.get(i-1).getPayTime().toLocalDate(),
+                            userOrders.get(i).getPayTime().toLocalDate()
+                    );
+                }
+                vo.setRepurchaseDays((int) (avgDays / (userOrders.size() - 1)));
+            } else {
+                vo.setRepurchaseDays(0);
+            }
+            
+            String userLayer = determineUserLayer(userOrders);
+            vo.setUserLayer(userLayer);
+            vo.setUserLayerLabel(getUserLayerLabel(userLayer));
+            
+            resultList.add(vo);
+        }
+        
+        if (queryDTO.getUserLayer() != null) {
+            resultList = resultList.stream()
+                    .filter(vo -> queryDTO.getUserLayer().equals(vo.getUserLayer()))
+                    .collect(Collectors.toList());
+        }
+        
+        String sortBy = queryDTO.getSortBy() != null ? queryDTO.getSortBy() : "totalAmount";
+        boolean asc = "asc".equalsIgnoreCase(queryDTO.getSortOrder());
+        
+        Comparator<RepurchaseStatVO> comparator = null;
+        switch (sortBy) {
+            case "orderCount":
+                comparator = Comparator.comparing(RepurchaseStatVO::getOrderCount);
+                break;
+            case "avgOrderAmount":
+                comparator = Comparator.comparing(RepurchaseStatVO::getAvgOrderAmount);
+                break;
+            case "totalAmount":
+            default:
+                comparator = Comparator.comparing(RepurchaseStatVO::getTotalAmount);
+                break;
+        }
+        
+        if (!asc) {
+            comparator = comparator.reversed();
+        }
+        resultList.sort(comparator);
+        
+        Page<RepurchaseStatVO> page = new Page<>(queryDTO.getPage(), queryDTO.getPageSize());
+        int start = (queryDTO.getPage() - 1) * queryDTO.getPageSize();
+        int end = Math.min(start + queryDTO.getPageSize(), resultList.size());
+        
+        page.setRecords(resultList.subList(start, end));
+        page.setTotal(resultList.size());
+        
+        return page;
+    }
+
+    @Override
+    public byte[] exportStatistics(StatisticsQueryDTO queryDTO) {
+        return new byte[0];
+    }
+    
+    private LocalDate parseDate(String dateStr, LocalDate defaultDate) {
+        if (dateStr == null || dateStr.isEmpty()) {
+            return defaultDate;
+        }
+        try {
+            return LocalDate.parse(dateStr);
+        } catch (Exception e) {
+            return defaultDate;
+        }
+    }
+    
+    private String determineUserLayer(List<Order> userOrders) {
+        int totalOrders = userOrders.size();
+        LocalDate now = LocalDate.now();
+        
+        LocalDate lastOrderDate = userOrders.stream()
+                .map(o -> o.getPayTime().toLocalDate())
+                .max(LocalDate::compareTo)
+                .orElse(now);
+        
+        long daysSinceLastOrder = ChronoUnit.DAYS.between(lastOrderDate, now);
+        
+        if (totalOrders == 1) {
+            return "new";
+        } else if (totalOrders >= 5) {
+            return "loyal";
+        } else if (daysSinceLastOrder > 60) {
+            return "churn";
+        } else {
+            return "active";
+        }
+    }
+    
+    private String getUserLayerLabel(String layer) {
+        switch (layer) {
+            case "new": return "新用户";
+            case "active": return "活跃用户";
+            case "loyal": return "忠诚用户";
+            case "churn": return "流失用户";
+            default: return "未知";
+        }
+    }
+}