| New file |
| | |
| | | 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()); |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | package com.tcm.training.admin.statistics.vo; |
| | | |
| | | import lombok.Getter; |
| | | import lombok.Setter; |
| | | |
| | | @Getter |
| | | @Setter |
| | | public class HomeDashboardUserTrendItemVO { |
| | | |
| | | private String date; |
| | | private Long registeredUserCount; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | |
| | | export default [ |
| | | { |
| | | path: '/Welcome', |
| | | name: '首页', |
| | | icon: 'home', |
| | | routes: [ |
| | | { |
| | | name: 'Welcome', |
| | | name: '首页', |
| | | path: '/Welcome', |
| | | hideInMenu: true, |
| | | component: './system/welco', |
| | | }, |
| | | ], |
| | |
| | | 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 { |
| | |
| | | border-color: #d9d9d9 !important; |
| | | } |
| | | } |
| | | } |
| | | } |
| | |
| | | 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; |
| | |
| | | .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; |
| | | } |
| | | } |
| New file |
| | |
| | | import { request } from '@umijs/max'; |
| | | |
| | | export async function getHomeDashboard() { |
| | | return request('/home-dashboard', { |
| | | method: 'GET', |
| | | }); |
| | | } |
| | |
| | | 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, |
| | |
| | | giftName?: string; |
| | | receiverPhone?: string; |
| | | status?: number; |
| | | createdRange?: any[]; |
| | | createdRange?: [Dayjs, Dayjs]; |
| | | }; |
| | | |
| | | type OrderItem = { |
| | |
| | | : 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[]>([]); |
| | |
| | | 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({ |
| | |
| | | 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(); |
| | |
| | | <Button |
| | | onClick={async () => { |
| | | searchForm.resetFields(); |
| | | history.replace('/trade/order/points-exchange'); |
| | | await loadList(1, pagination.pageSize); |
| | | }} |
| | | > |