“mitao”
2026-03-27 a9a362d67a72e30144fd23a48a7486e7972a480a
feat:首页
7个文件已添加
5个文件已修改
903 ■■■■■ 已修改文件
backend/src/main/java/com/tcm/training/admin/statistics/controller/HomeDashboardController.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/statistics/service/HomeDashboardAppService.java 152 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/statistics/vo/HomeDashboardOverviewVO.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/statistics/vo/HomeDashboardRevenueTrendItemVO.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/statistics/vo/HomeDashboardUserTrendItemVO.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/statistics/vo/HomeDashboardVO.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/config/routes.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/global.less 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/system/welco/index.jsx 330 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/system/welco/index.less 271 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/system/welco/service.js 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/trade/points-exchange/index.tsx 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/tcm/training/admin/statistics/controller/HomeDashboardController.java
New file
@@ -0,0 +1,26 @@
package com.tcm.training.admin.statistics.controller;
import com.tcm.training.admin.common.ApiResponse;
import com.tcm.training.admin.statistics.service.HomeDashboardAppService;
import com.tcm.training.admin.statistics.vo.HomeDashboardVO;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Validated
@RestController
@RequestMapping({"/api/v1", "/api"})
public class HomeDashboardController {
    private final HomeDashboardAppService homeDashboardAppService;
    public HomeDashboardController(HomeDashboardAppService homeDashboardAppService) {
        this.homeDashboardAppService = homeDashboardAppService;
    }
    @GetMapping("/home-dashboard")
    public ApiResponse<HomeDashboardVO> dashboard() {
        return ApiResponse.ok(homeDashboardAppService.dashboard());
    }
}
backend/src/main/java/com/tcm/training/admin/statistics/service/HomeDashboardAppService.java
New file
@@ -0,0 +1,152 @@
package com.tcm.training.admin.statistics.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.tcm.training.admin.live.entity.HealthCourse;
import com.tcm.training.admin.live.mapper.HealthCourseMapper;
import com.tcm.training.admin.member.entity.AppUser;
import com.tcm.training.admin.member.mapper.UserMapper;
import com.tcm.training.admin.points.entity.PointsExchangeOrder;
import com.tcm.training.admin.points.mapper.PointsExchangeOrderMapper;
import com.tcm.training.admin.statistics.mapper.RevenueStatisticsMapper;
import com.tcm.training.admin.statistics.mapper.UserStatisticsMapper;
import com.tcm.training.admin.statistics.row.RevenueStatisticsDailyRow;
import com.tcm.training.admin.statistics.row.UserStatisticsRegisterTrendRow;
import com.tcm.training.admin.statistics.vo.HomeDashboardOverviewVO;
import com.tcm.training.admin.statistics.vo.HomeDashboardRevenueTrendItemVO;
import com.tcm.training.admin.statistics.vo.HomeDashboardUserTrendItemVO;
import com.tcm.training.admin.statistics.vo.HomeDashboardVO;
import com.tcm.training.admin.trade.service.PaymentFlowAppService;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
public class HomeDashboardAppService {
    private static final int HOME_RECENT_DAYS = 7;
    private final UserMapper userMapper;
    private final HealthCourseMapper healthCourseMapper;
    private final PointsExchangeOrderMapper pointsExchangeOrderMapper;
    private final UserStatisticsMapper userStatisticsMapper;
    private final RevenueStatisticsMapper revenueStatisticsMapper;
    private final PaymentFlowAppService paymentFlowAppService;
    public HomeDashboardAppService(UserMapper userMapper,
                                   HealthCourseMapper healthCourseMapper,
                                   PointsExchangeOrderMapper pointsExchangeOrderMapper,
                                   UserStatisticsMapper userStatisticsMapper,
                                   RevenueStatisticsMapper revenueStatisticsMapper,
                                   PaymentFlowAppService paymentFlowAppService) {
        this.userMapper = userMapper;
        this.healthCourseMapper = healthCourseMapper;
        this.pointsExchangeOrderMapper = pointsExchangeOrderMapper;
        this.userStatisticsMapper = userStatisticsMapper;
        this.revenueStatisticsMapper = revenueStatisticsMapper;
        this.paymentFlowAppService = paymentFlowAppService;
    }
    public HomeDashboardVO dashboard() {
        LocalDate endDate = LocalDate.now();
        LocalDate startDate = endDate.minusDays(HOME_RECENT_DAYS - 1L);
        LocalDateTime startTime = startDate.atStartOfDay();
        LocalDateTime endTime = LocalDateTime.of(endDate, LocalTime.MAX);
        paymentFlowAppService.syncFlows();
        HomeDashboardVO result = new HomeDashboardVO();
        result.setOverview(buildOverview());
        result.setUserRegisterTrend(buildUserRegisterTrend(
                userStatisticsMapper.selectRegisterTrend(null, startTime, endTime),
                startDate,
                endDate
        ));
        result.setRevenueTrend(buildRevenueTrend(
                revenueStatisticsMapper.selectDailyStatistics(null, startTime, endTime),
                startDate,
                endDate
        ));
        result.setPendingPointsExchangeShipmentCount(countPendingPointsExchangeShipment());
        return result;
    }
    private HomeDashboardOverviewVO buildOverview() {
        HomeDashboardOverviewVO overview = new HomeDashboardOverviewVO();
        overview.setRegisteredUserCount(userMapper.selectCount(new LambdaQueryWrapper<AppUser>()
                .eq(AppUser::getDeleted, 0)));
        overview.setRegisteredDoctorCount(userMapper.selectCount(new LambdaQueryWrapper<AppUser>()
                .eq(AppUser::getDeleted, 0)
                .eq(AppUser::getType, 1)));
        overview.setMemberUserCount(userMapper.selectCount(new LambdaQueryWrapper<AppUser>()
                .eq(AppUser::getDeleted, 0)
                .isNotNull(AppUser::getMemberExpireAt)
                .gt(AppUser::getMemberExpireAt, LocalDateTime.now())));
        overview.setHealthCourseCount(healthCourseMapper.selectCount(new LambdaQueryWrapper<HealthCourse>()
                .eq(HealthCourse::getDeleted, 0)));
        return overview;
    }
    private List<HomeDashboardUserTrendItemVO> buildUserRegisterTrend(List<UserStatisticsRegisterTrendRow> rows,
                                                                      LocalDate startDate,
                                                                      LocalDate endDate) {
        Map<LocalDate, UserStatisticsRegisterTrendRow> rowMap = new HashMap<LocalDate, UserStatisticsRegisterTrendRow>();
        for (UserStatisticsRegisterTrendRow row : rows) {
            rowMap.put(row.getStatDate(), row);
        }
        List<HomeDashboardUserTrendItemVO> result = new ArrayList<HomeDashboardUserTrendItemVO>();
        LocalDate cursor = startDate;
        while (!cursor.isAfter(endDate)) {
            UserStatisticsRegisterTrendRow row = rowMap.get(cursor);
            HomeDashboardUserTrendItemVO item = new HomeDashboardUserTrendItemVO();
            item.setDate(cursor.toString());
            item.setRegisteredUserCount(nvl(row == null ? null : row.getRegisteredUserCount()));
            result.add(item);
            cursor = cursor.plusDays(1);
        }
        return result;
    }
    private List<HomeDashboardRevenueTrendItemVO> buildRevenueTrend(List<RevenueStatisticsDailyRow> rows,
                                                                    LocalDate startDate,
                                                                    LocalDate endDate) {
        Map<LocalDate, RevenueStatisticsDailyRow> rowMap = new HashMap<LocalDate, RevenueStatisticsDailyRow>();
        for (RevenueStatisticsDailyRow row : rows) {
            rowMap.put(row.getStatDate(), row);
        }
        List<HomeDashboardRevenueTrendItemVO> result = new ArrayList<HomeDashboardRevenueTrendItemVO>();
        LocalDate cursor = startDate;
        while (!cursor.isAfter(endDate)) {
            RevenueStatisticsDailyRow row = rowMap.get(cursor);
            HomeDashboardRevenueTrendItemVO item = new HomeDashboardRevenueTrendItemVO();
            item.setDate(cursor.toString());
            item.setHealthIncome(nvl(row == null ? null : row.getHealthIncome()));
            item.setMemberIncome(nvl(row == null ? null : row.getMemberIncome()));
            result.add(item);
            cursor = cursor.plusDays(1);
        }
        return result;
    }
    private Long countPendingPointsExchangeShipment() {
        return pointsExchangeOrderMapper.selectCount(new LambdaQueryWrapper<PointsExchangeOrder>()
                .eq(PointsExchangeOrder::getDeleted, 0)
                .eq(PointsExchangeOrder::getStatus, 0));
    }
    private Long nvl(Long value) {
        return value == null ? 0L : value;
    }
    private BigDecimal nvl(BigDecimal value) {
        return value == null ? BigDecimal.ZERO : value;
    }
}
backend/src/main/java/com/tcm/training/admin/statistics/vo/HomeDashboardOverviewVO.java
New file
@@ -0,0 +1,14 @@
package com.tcm.training.admin.statistics.vo;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class HomeDashboardOverviewVO {
    private Long registeredUserCount;
    private Long registeredDoctorCount;
    private Long memberUserCount;
    private Long healthCourseCount;
}
backend/src/main/java/com/tcm/training/admin/statistics/vo/HomeDashboardRevenueTrendItemVO.java
New file
@@ -0,0 +1,15 @@
package com.tcm.training.admin.statistics.vo;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
@Getter
@Setter
public class HomeDashboardRevenueTrendItemVO {
    private String date;
    private BigDecimal healthIncome;
    private BigDecimal memberIncome;
}
backend/src/main/java/com/tcm/training/admin/statistics/vo/HomeDashboardUserTrendItemVO.java
New file
@@ -0,0 +1,12 @@
package com.tcm.training.admin.statistics.vo;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class HomeDashboardUserTrendItemVO {
    private String date;
    private Long registeredUserCount;
}
backend/src/main/java/com/tcm/training/admin/statistics/vo/HomeDashboardVO.java
New file
@@ -0,0 +1,16 @@
package com.tcm.training.admin.statistics.vo;
import lombok.Getter;
import lombok.Setter;
import java.util.List;
@Getter
@Setter
public class HomeDashboardVO {
    private HomeDashboardOverviewVO overview;
    private List<HomeDashboardUserTrendItemVO> userRegisterTrend;
    private List<HomeDashboardRevenueTrendItemVO> revenueTrend;
    private Long pendingPointsExchangeShipmentCount;
}
tcm-training-admin/config/routes.ts
@@ -13,11 +13,12 @@
export default [
  {
    path: '/Welcome',
    name: '首页',
    icon: 'home',
    routes: [
      {
        name: 'Welcome',
        name: '首页',
        path: '/Welcome',
        hideInMenu: true,
        component: './system/welco',
      },
    ],
tcm-training-admin/src/global.less
@@ -17,18 +17,17 @@
  min-height: 100vh;
}
.ant-pro-sider.ant-layout-sider.ant-pro-sider-fixed {
  left: unset;
}
.ant-pro-layout .ant-pro-sider.ant-pro-sider-fixed-mix {
  height: calc(100% - 74px);
  height: calc(100vh - 74px);
  inset-block-start: 74px;
}
.ant-layout-has-sider>div:nth-child(1) {
  max-width: 210px !important;
  min-width: 64px !important;
.ant-layout-has-sider > .ant-layout-sider {
  inset-inline-start: 0;
}
.ant-pro-layout .ant-pro-sider .ant-menu-submenu-arrow {
  inset-inline-end: 16px;
}
.ant-layout-header {
@@ -98,4 +97,4 @@
      border-color: #d9d9d9 !important;
    }
  }
}
}
tcm-training-admin/src/pages/system/welco/index.jsx
@@ -1,10 +1,324 @@
import { PageContainer } from '@ant-design/pro-components';
import './index.less'
import { history, useAccess, useModel } from '@umijs/max';
import { Line } from '@ant-design/plots';
import {
  AppstoreOutlined,
  BookOutlined,
  CrownOutlined,
  MessageOutlined,
  MobileOutlined,
  ReadOutlined,
  SettingOutlined,
  TeamOutlined,
} from '@ant-design/icons';
import { Badge, Spin, message } from 'antd';
import React, { useEffect, useMemo, useState } from 'react';
import { getHomeDashboard } from './service';
import './index.less';
const SystemSettings = () => {
    return (
        <PageContainer title={''} className={'weclome'}>
        </PageContainer>
    )
}
export default SystemSettings
const overviewCardConfig = [
  {
    key: 'registeredUserCount',
    title: '注册用户数',
    unit: '人',
    className: 'is-user',
    icon: <TeamOutlined />,
  },
  {
    key: 'registeredDoctorCount',
    title: '注册医生数',
    unit: '人',
    className: 'is-doctor',
    icon: <AppstoreOutlined />,
  },
  {
    key: 'memberUserCount',
    title: '会员用户数',
    unit: '人',
    className: 'is-member',
    icon: <CrownOutlined />,
  },
  {
    key: 'healthCourseCount',
    title: '养生课程',
    unit: '个',
    className: 'is-course',
    icon: <ReadOutlined />,
  },
];
const quickEntryConfig = [
  {
    key: 'member:user:view',
    title: '用户管理',
    path: '/member/user/manage',
    icon: <TeamOutlined />,
  },
  {
    key: 'member:user:member:view',
    title: '会员用户',
    path: '/member/user/member-users',
    icon: <CrownOutlined />,
  },
  {
    key: 'live:health-course:view',
    title: '养生课程',
    path: '/live/course/health',
    icon: <BookOutlined />,
  },
  {
    key: 'live:course:view',
    title: '直播课程',
    path: '/live/course/broadcast',
    icon: <ReadOutlined />,
  },
  {
    key: 'system:app-version:view',
    title: 'APP版本',
    path: '/system/settings/app-version',
    icon: <MobileOutlined />,
  },
  {
    key: 'system:user-feedback:view',
    title: '意见反馈',
    path: '/system/settings/user-feedback',
    icon: <MessageOutlined />,
  },
  {
    key: 'system:points-settings:view',
    title: '积分设置',
    path: '/system/settings/points',
    icon: <AppstoreOutlined />,
  },
  {
    key: 'system:account:view',
    title: '帐号管理',
    path: '/system/account',
    icon: <SettingOutlined />,
  },
];
const revenueSeriesColorMap = {
  养生收入: '#4f7cff',
  会员收入: '#5bc0de',
};
const getSeriesColor = (datum) => revenueSeriesColorMap[datum.type] || '#4f7cff';
const formatCount = (value) => Number(value || 0).toLocaleString('zh-CN');
const formatAmount = (value) => {
  const amount = Number(value || 0);
  const fixed = amount.toFixed(2);
  return fixed.replace(/\.00$/, '').replace(/(\.\d)0$/, '$1');
};
const renderChartValue = (value) => Number(Number(value || 0).toFixed(2));
const WelcomeDashboard = () => {
  const access = useAccess();
  const { initialState } = useModel('@@initialState');
  const currentUser = initialState?.currentUser || {};
  const [loading, setLoading] = useState(true);
  const [dashboard, setDashboard] = useState({
    overview: {},
    userRegisterTrend: [],
    revenueTrend: [],
    pendingPointsExchangeShipmentCount: 0,
  });
  const quickEntries = useMemo(
    () => quickEntryConfig.filter((item) => access[item.key]),
    [access],
  );
  const canViewMoreUsers = !!access['member:user:view'];
  const canViewPendingShipment = !!access['points:exchange:view'];
  const displayName = currentUser?.nickname || currentUser?.username || '管理员';
  const loadDashboard = async () => {
    setLoading(true);
    try {
      const res = await getHomeDashboard();
      setDashboard({
        overview: res.data?.overview || {},
        userRegisterTrend: res.data?.userRegisterTrend || [],
        revenueTrend: res.data?.revenueTrend || [],
        pendingPointsExchangeShipmentCount: res.data?.pendingPointsExchangeShipmentCount || 0,
      });
    } catch (error) {
      message.error(error?.message || '首页数据加载失败');
      setDashboard({
        overview: {},
        userRegisterTrend: [],
        revenueTrend: [],
        pendingPointsExchangeShipmentCount: 0,
      });
    } finally {
      setLoading(false);
    }
  };
  useEffect(() => {
    loadDashboard();
  }, []);
  const userRegisterTrendData = useMemo(
    () =>
      (dashboard.userRegisterTrend || []).map((item) => ({
        date: item.date,
        type: '注册人数',
        value: renderChartValue(item.registeredUserCount),
      })),
    [dashboard.userRegisterTrend],
  );
  const revenueTrendData = useMemo(
    () =>
      (dashboard.revenueTrend || []).flatMap((item) => [
        {
          date: item.date,
          type: '养生收入',
          value: renderChartValue(item.healthIncome),
        },
        {
          date: item.date,
          type: '会员收入',
          value: renderChartValue(item.memberIncome),
        },
      ]),
    [dashboard.revenueTrend],
  );
  return (
    <PageContainer title={false} className="welcome-dashboard-page">
      <div className="welcome-dashboard">
        <div className="welcome-dashboard__main">
          <div className="welcome-dashboard__welcome-card">
            <div className="welcome-dashboard__welcome-title">👏 欢迎回来,{displayName}</div>
          </div>
          <div className="welcome-dashboard__overview">
            {overviewCardConfig.map((item) => (
              <div key={item.key} className={`welcome-dashboard__overview-card ${item.className}`}>
                <div className="welcome-dashboard__overview-icon">{item.icon}</div>
                <div className="welcome-dashboard__overview-content">
                  <div className="welcome-dashboard__overview-label">{item.title}</div>
                  <div className="welcome-dashboard__overview-value">
                    {formatCount(dashboard.overview?.[item.key])}
                    <span>{item.unit}</span>
                  </div>
                </div>
              </div>
            ))}
          </div>
          <div className="welcome-dashboard__chart-card">
            <div className="welcome-dashboard__chart-header">
              <div>
                <div className="welcome-dashboard__chart-title">用户注册数据</div>
                <div className="welcome-dashboard__chart-subtitle">近7日</div>
              </div>
              {canViewMoreUsers ? (
                <button
                  type="button"
                  className="welcome-dashboard__link-button"
                  onClick={() => history.push('/member/user/manage')}
                >
                  查看更多
                </button>
              ) : null}
            </div>
            <div className="welcome-dashboard__chart-plot">
              {loading ? (
                <div className="welcome-dashboard__loading">
                  <Spin />
                </div>
              ) : (
                <Line
                  data={userRegisterTrendData}
                  xField="date"
                  yField="value"
                  seriesField="type"
                  smooth
                  height={320}
                  legend={false}
                  color="#4f7cff"
                  area={{ style: { fill: 'l(270) 0:#dce9ff 1:#ffffff' } }}
                  point={{ size: 4, shape: 'circle' }}
                  yAxis={{ label: { formatter: (value) => `${Number(value).toFixed(0)}` } }}
                />
              )}
            </div>
          </div>
          <div className="welcome-dashboard__chart-card">
            <div className="welcome-dashboard__chart-header">
              <div>
                <div className="welcome-dashboard__chart-title">营收数据</div>
                <div className="welcome-dashboard__chart-subtitle">近7日</div>
              </div>
            </div>
            <div className="welcome-dashboard__chart-plot">
              {loading ? (
                <div className="welcome-dashboard__loading">
                  <Spin />
                </div>
              ) : (
                <Line
                  data={revenueTrendData}
                  xField="date"
                  yField="value"
                  seriesField="type"
                  smooth
                  height={260}
                  color={getSeriesColor}
                  point={{ size: 3, shape: 'circle' }}
                  yAxis={{ label: { formatter: (value) => `${formatAmount(value)}` } }}
                />
              )}
            </div>
          </div>
        </div>
        <div className="welcome-dashboard__side">
          <div className="welcome-dashboard__panel">
            <div className="welcome-dashboard__panel-title">快捷入口</div>
            <div className="welcome-dashboard__quick-grid">
              {quickEntries.map((item) => (
                <button
                  key={item.title}
                  type="button"
                  className="welcome-dashboard__quick-item"
                  onClick={() => history.push(item.path)}
                >
                  <span className="welcome-dashboard__quick-icon">{item.icon}</span>
                  <span className="welcome-dashboard__quick-text">{item.title}</span>
                </button>
              ))}
            </div>
          </div>
          {canViewPendingShipment ? (
            <div className="welcome-dashboard__panel">
              <div className="welcome-dashboard__panel-title">待处理</div>
              <button
                type="button"
                className="welcome-dashboard__pending-item"
                onClick={() => history.push('/trade/order/points-exchange?status=0')}
              >
                <span className="welcome-dashboard__pending-label">礼品邮寄</span>
                <Badge
                  count={Number(dashboard.pendingPointsExchangeShipmentCount || 0)}
                  overflowCount={999}
                />
              </button>
            </div>
          ) : null}
        </div>
      </div>
    </PageContainer>
  );
};
export default WelcomeDashboard;
tcm-training-admin/src/pages/system/welco/index.less
@@ -1,11 +1,262 @@
.weclome{
.welcome-dashboard-page {
  .ant-page-header {
    display: none;
  }
    .ant-page-header {
        display: none;
    }
    .ant-pro-page-container-children-container {
       padding: unset !important;
    //    padding-left: 15px;
    //    padding-right: 15px;
    }
}
  .ant-pro-page-container-children-container {
    padding: 0 !important;
    background: #f5f7fb;
  }
}
.welcome-dashboard {
  display: flex;
  gap: 20px;
  padding: 20px;
  min-height: calc(100vh - 56px);
  box-sizing: border-box;
}
.welcome-dashboard__main {
  flex: 1;
  min-width: 0;
}
.welcome-dashboard__side {
  width: 320px;
  display: flex;
  flex-direction: column;
  gap: 20px;
}
.welcome-dashboard__welcome-card,
.welcome-dashboard__chart-card,
.welcome-dashboard__panel {
  background: #fff;
  border-radius: 16px;
  box-shadow: 0 8px 24px rgba(31, 35, 41, 0.06);
}
.welcome-dashboard__welcome-card {
  padding: 24px 28px;
  margin-bottom: 20px;
}
.welcome-dashboard__welcome-title {
  color: #1f2329;
  font-size: 28px;
  font-weight: 600;
}
.welcome-dashboard__overview {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 16px;
  margin-bottom: 20px;
}
.welcome-dashboard__overview-card {
  display: flex;
  gap: 14px;
  align-items: center;
  padding: 22px 20px;
  background: #fff;
  border-radius: 16px;
  box-shadow: 0 8px 24px rgba(31, 35, 41, 0.06);
}
.welcome-dashboard__overview-card.is-user .welcome-dashboard__overview-icon {
  background: linear-gradient(135deg, #4f7cff, #7ea3ff);
}
.welcome-dashboard__overview-card.is-doctor .welcome-dashboard__overview-icon {
  background: linear-gradient(135deg, #c66bff, #f096ff);
}
.welcome-dashboard__overview-card.is-member .welcome-dashboard__overview-icon {
  background: linear-gradient(135deg, #4ecdc4, #8fe3dd);
}
.welcome-dashboard__overview-card.is-course .welcome-dashboard__overview-icon {
  background: linear-gradient(135deg, #ff9f43, #ffd166);
}
.welcome-dashboard__overview-icon {
  width: 44px;
  height: 44px;
  border-radius: 50%;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: #fff;
  font-size: 20px;
  flex-shrink: 0;
}
.welcome-dashboard__overview-content {
  min-width: 0;
}
.welcome-dashboard__overview-label {
  color: #86909c;
  font-size: 14px;
  margin-bottom: 8px;
}
.welcome-dashboard__overview-value {
  color: #1f2329;
  font-size: 30px;
  font-weight: 600;
  line-height: 1;
}
.welcome-dashboard__overview-value span {
  margin-left: 6px;
  color: #86909c;
  font-size: 14px;
  font-weight: 400;
}
.welcome-dashboard__chart-card {
  padding: 20px 24px 8px;
  margin-bottom: 20px;
}
.welcome-dashboard__chart-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 12px;
}
.welcome-dashboard__chart-title,
.welcome-dashboard__panel-title {
  color: #1f2329;
  font-size: 18px;
  font-weight: 600;
}
.welcome-dashboard__chart-subtitle {
  margin-top: 6px;
  color: #86909c;
  font-size: 12px;
}
.welcome-dashboard__chart-plot {
  min-height: 260px;
}
.welcome-dashboard__loading {
  min-height: 260px;
  display: flex;
  align-items: center;
  justify-content: center;
}
.welcome-dashboard__link-button,
.welcome-dashboard__quick-item,
.welcome-dashboard__pending-item {
  border: 0;
  background: transparent;
  padding: 0;
  cursor: pointer;
}
.welcome-dashboard__link-button {
  color: #4f7cff;
  font-size: 14px;
}
.welcome-dashboard__panel {
  padding: 20px;
}
.welcome-dashboard__quick-grid {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 18px 12px;
  margin-top: 18px;
}
.welcome-dashboard__quick-item {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
  color: #4e5969;
}
.welcome-dashboard__quick-icon {
  width: 44px;
  height: 44px;
  border-radius: 12px;
  background: #f5f7fb;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-size: 20px;
  color: #4f7cff;
}
.welcome-dashboard__quick-text {
  font-size: 13px;
  line-height: 18px;
  text-align: center;
}
.welcome-dashboard__pending-item {
  width: 100%;
  margin-top: 20px;
  padding: 14px 16px;
  border-radius: 12px;
  background: #fff7f7;
  display: flex;
  align-items: center;
  justify-content: space-between;
  transition: background 0.2s ease;
}
.welcome-dashboard__pending-item:hover {
  background: #ffefef;
}
.welcome-dashboard__pending-label {
  color: #1f2329;
  font-size: 15px;
}
@media (max-width: 1400px) {
  .welcome-dashboard__overview {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
  .welcome-dashboard__quick-grid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}
@media (max-width: 1080px) {
  .welcome-dashboard {
    flex-direction: column;
  }
  .welcome-dashboard__side {
    width: 100%;
  }
}
@media (max-width: 768px) {
  .welcome-dashboard {
    padding: 16px;
  }
  .welcome-dashboard__overview {
    grid-template-columns: minmax(0, 1fr);
  }
  .welcome-dashboard__chart-header {
    align-items: flex-start;
    flex-direction: column;
    gap: 8px;
  }
}
tcm-training-admin/src/pages/system/welco/service.js
New file
@@ -0,0 +1,7 @@
import { request } from '@umijs/max';
export async function getHomeDashboard() {
  return request('/home-dashboard', {
    method: 'GET',
  });
}
tcm-training-admin/src/pages/trade/points-exchange/index.tsx
@@ -1,7 +1,7 @@
import { history, useAccess } from '@umijs/max';
import { history, useAccess, useLocation } from '@umijs/max';
import { Button, DatePicker, Form, Input, Pagination, Select, Space, Table, Tag, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import dayjs, { type Dayjs } from 'dayjs';
import React, { useEffect, useState } from 'react';
import {
  exportPointsExchangeOrders,
@@ -15,7 +15,7 @@
  giftName?: string;
  receiverPhone?: string;
  status?: number;
  createdRange?: any[];
  createdRange?: [Dayjs, Dayjs];
};
type OrderItem = {
@@ -49,8 +49,18 @@
    : undefined,
});
const parseInitialStatus = (search: string) => {
  const value = new URLSearchParams(search).get('status');
  if (value === null || value === '') {
    return undefined;
  }
  const parsed = Number(value);
  return Number.isNaN(parsed) ? undefined : parsed;
};
const PointsExchangePage: React.FC = () => {
  const access = useAccess();
  const location = useLocation();
  const [searchForm] = Form.useForm<SearchValues>();
  const [shipForm] = Form.useForm<{ trackingNo: string }>();
  const [list, setList] = useState<OrderItem[]>([]);
@@ -64,13 +74,20 @@
  const canShip = access['points:exchange:ship'];
  const canExport = access['points:exchange:export'];
  const loadList = async (page = pagination.page, pageSize = pagination.pageSize) => {
  const loadList = async (
    page = pagination.page,
    pageSize = pagination.pageSize,
    overrideValues?: Partial<SearchValues>,
  ) => {
    setLoading(true);
    try {
      const res = await getPointsExchangeOrderPage({
        page,
        pageSize,
        ...buildParams(searchForm.getFieldsValue()),
        ...buildParams({
          ...searchForm.getFieldsValue(),
          ...overrideValues,
        }),
      });
      setList(res.data.list || []);
      setPagination({
@@ -88,8 +105,14 @@
      history.replace('/Welcome');
      return;
    }
    loadList(1, 10);
  }, [canView]);
    const initialStatus = parseInitialStatus(location.search);
    searchForm.setFieldsValue({
      status: initialStatus,
    });
    loadList(1, 10, {
      status: initialStatus,
    });
  }, [canView, location.search]);
  const openShipModal = (record: OrderItem) => {
    shipForm.resetFields();
@@ -232,6 +255,7 @@
              <Button
                onClick={async () => {
                  searchForm.resetFields();
                  history.replace('/trade/order/points-exchange');
                  await loadList(1, pagination.pageSize);
                }}
              >