overview统计
This commit is contained in:
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -22,5 +22,6 @@
|
|||||||
"path": "cmd.exe",
|
"path": "cmd.exe",
|
||||||
"args": ["/k", "chcp 65001"]
|
"args": ["/k", "chcp 65001"]
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"java.debug.settings.onBuildFailureProceed": true
|
||||||
}
|
}
|
||||||
@@ -183,4 +183,7 @@ public interface ResourceService {
|
|||||||
* @since 2025-10-15
|
* @since 2025-10-15
|
||||||
*/
|
*/
|
||||||
ResultDomain<TbResource> searchResources(String keyword, String tagID, Integer status);
|
ResultDomain<TbResource> searchResources(String keyword, String tagID, Integer status);
|
||||||
|
|
||||||
|
ResultDomain<Integer> getResourceCount(TbResource filter);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,4 +190,13 @@ public interface CourseService {
|
|||||||
* @since 2025-10-28
|
* @since 2025-10-28
|
||||||
*/
|
*/
|
||||||
ResultDomain<CourseItemVO> getCourseProgress(String courseID);
|
ResultDomain<CourseItemVO> getCourseProgress(String courseID);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 获取课程数量
|
||||||
|
* @param filter 过滤条件
|
||||||
|
* @return ResultDomain<Integer> 课程数量
|
||||||
|
* @author yslg
|
||||||
|
* @since 2025-11-14
|
||||||
|
*/
|
||||||
|
ResultDomain<Integer> getCourseCount(TbCourse filter);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -237,4 +237,13 @@ public interface LearningTaskService {
|
|||||||
* @since 2025-10-30
|
* @since 2025-10-30
|
||||||
*/
|
*/
|
||||||
ResultDomain<Map<String, Object>> getTaskStatisticsRankings(String taskID);
|
ResultDomain<Map<String, Object>> getTaskStatisticsRankings(String taskID);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 获取学习任务数量
|
||||||
|
* @param filter 过滤条件
|
||||||
|
* @return ResultDomain<Integer> 学习任务数量
|
||||||
|
* @author yslg
|
||||||
|
* @since 2025-11-14
|
||||||
|
*/
|
||||||
|
ResultDomain<Integer> getLearningTaskCount(TbLearningTask filter);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import org.xyzh.common.core.domain.LoginDomain;
|
|||||||
import org.xyzh.common.redis.service.RedisService;
|
import org.xyzh.common.redis.service.RedisService;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -34,6 +36,11 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
|
|
||||||
private static final String REDIS_LOGIN_PREFIX = "login:token:";
|
private static final String REDIS_LOGIN_PREFIX = "login:token:";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description UV统计key前缀,例如 stat:uv:20251114
|
||||||
|
*/
|
||||||
|
private static final String REDIS_UV_PREFIX = "stat:uv:";
|
||||||
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
|
private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
@@ -91,6 +98,15 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||||
|
|
||||||
logger.debug("用户认证成功(从缓存),userId: {}", userId);
|
logger.debug("用户认证成功(从缓存),userId: {}", userId);
|
||||||
|
|
||||||
|
// 记录UV:以用户ID为维度,按天去重
|
||||||
|
try {
|
||||||
|
String today = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
|
||||||
|
String uvKey = REDIS_UV_PREFIX + today;
|
||||||
|
redisService.sAdd(uvKey, userId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("记录UV失败, userId={}: {}", userId, e.getMessage());
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.warn("Redis缓存中未找到用户登录信息,userId: {}, 可能已过期或未登录", userId);
|
logger.warn("Redis缓存中未找到用户登录信息,userId: {}, 可能已过期或未登录", userId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ public class BaseDTO implements Serializable{
|
|||||||
*/
|
*/
|
||||||
private Date createTime;
|
private Date createTime;
|
||||||
|
|
||||||
|
private Date startTime;
|
||||||
|
|
||||||
|
private Date endTime;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description 更新时间
|
* @description 更新时间
|
||||||
* @author yslg
|
* @author yslg
|
||||||
@@ -94,6 +98,22 @@ public class BaseDTO implements Serializable{
|
|||||||
this.createTime = createTime;
|
this.createTime = createTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Date getStartTime() {
|
||||||
|
return startTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStartTime(Date startTime) {
|
||||||
|
this.startTime = startTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Date getEndTime() {
|
||||||
|
return endTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEndTime(Date endTime) {
|
||||||
|
this.endTime = endTime;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description 获取更新时间
|
* @description 获取更新时间
|
||||||
* @return Date 更新时间
|
* @return Date 更新时间
|
||||||
|
|||||||
@@ -283,6 +283,18 @@ public class RedisService {
|
|||||||
return redisTemplate.opsForSet().members(key);
|
return redisTemplate.opsForSet().members(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 获取Set集合大小(等价于Redis的SCARD)
|
||||||
|
* @param key String 键
|
||||||
|
* @return long 集合元素数量
|
||||||
|
* @author yslg
|
||||||
|
* @since 2025-11-14
|
||||||
|
*/
|
||||||
|
public long sCard(String key) {
|
||||||
|
Long size = redisTemplate.opsForSet().size(key);
|
||||||
|
return size != null ? size : 0L;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description ZSet操作-添加元素
|
* @description ZSet操作-添加元素
|
||||||
* @param key String 键
|
* @param key String 键
|
||||||
|
|||||||
@@ -294,4 +294,29 @@ public class TimeUtils {
|
|||||||
return Date.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant());
|
return Date.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 获取昨天的开始时间
|
||||||
|
* @return java.util.Date对象
|
||||||
|
* @author yslg
|
||||||
|
* @since 2025-11-14
|
||||||
|
*/
|
||||||
|
public static Date getStartTimeOfYesterday() {
|
||||||
|
LocalDate yesterday = LocalDate.now().minusDays(1);
|
||||||
|
return Date.from(
|
||||||
|
yesterday.atStartOfDay(ZoneId.systemDefault()).toInstant()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 获取昨天的结束时间
|
||||||
|
* @return java.util.Date对象
|
||||||
|
* @author yslg
|
||||||
|
* @since 2025-11-14
|
||||||
|
*/
|
||||||
|
public static Date getEndTimeOfYesterday() {
|
||||||
|
LocalDate today = LocalDate.now();
|
||||||
|
return Date.from(
|
||||||
|
today.atStartOfDay(ZoneId.systemDefault()).minusNanos(1).toInstant()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -754,4 +754,19 @@ public class NCResourceServiceImpl implements ResourceService {
|
|||||||
return resultDomain;
|
return resultDomain;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResultDomain<Integer> getResourceCount(TbResource filter) {
|
||||||
|
ResultDomain<Integer> resultDomain = new ResultDomain<>();
|
||||||
|
try {
|
||||||
|
List<UserDeptRoleVO> userDeptRoles = LoginUtil.getCurrentDeptRole();
|
||||||
|
long count = resourceMapper.countResources(filter, userDeptRoles);
|
||||||
|
resultDomain.success("获取资源总数成功", (int)count);
|
||||||
|
return resultDomain;
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("获取资源总数异常: {}", e.getMessage(), e);
|
||||||
|
resultDomain.fail("获取资源总数失败: " + e.getMessage());
|
||||||
|
return resultDomain;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -387,6 +387,12 @@
|
|||||||
<if test="filter.isBanner != null">
|
<if test="filter.isBanner != null">
|
||||||
AND r.is_banner = #{filter.isBanner}
|
AND r.is_banner = #{filter.isBanner}
|
||||||
</if>
|
</if>
|
||||||
|
<if test="filter.startTime != null">
|
||||||
|
AND r.publish_time >= #{filter.startTime}
|
||||||
|
</if>
|
||||||
|
<if test="filter.endTime != null">
|
||||||
|
AND r.publish_time <= #{filter.endTime}
|
||||||
|
</if>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<!-- updateResourceCollectCount -->
|
<!-- updateResourceCollectCount -->
|
||||||
|
|||||||
@@ -599,4 +599,19 @@ public class SCCourseServiceImpl implements SCCourseService {
|
|||||||
resultDomain.success("获取课程进度成功", courseItemVO);
|
resultDomain.success("获取课程进度成功", courseItemVO);
|
||||||
return resultDomain;
|
return resultDomain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResultDomain<Integer> getCourseCount(TbCourse filter) {
|
||||||
|
ResultDomain<Integer> resultDomain = new ResultDomain<>();
|
||||||
|
try {
|
||||||
|
// 获取当前用户的部门角色
|
||||||
|
List<UserDeptRoleVO> userDeptRoles = LoginUtil.getCurrentDeptRole();
|
||||||
|
long count = courseMapper.countCourses(filter, userDeptRoles);
|
||||||
|
resultDomain.success("获取课程数量成功", (int) count);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("获取课程数量失败", e);
|
||||||
|
resultDomain.fail("获取课程数量失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
return resultDomain;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -812,4 +812,19 @@ public class SCLearningTaskServiceImpl implements LearningTaskService {
|
|||||||
}
|
}
|
||||||
return resultDomain;
|
return resultDomain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResultDomain<Integer> getLearningTaskCount(TbLearningTask filter) {
|
||||||
|
ResultDomain<Integer> resultDomain = new ResultDomain<>();
|
||||||
|
try {
|
||||||
|
// 获取当前用户的部门角色
|
||||||
|
List<UserDeptRoleVO> userDeptRoles = LoginUtil.getCurrentDeptRole();
|
||||||
|
long count = learningTaskMapper.countLearningTasks(filter, userDeptRoles);
|
||||||
|
resultDomain.success("获取学习任务数量成功", (int) count);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("获取学习任务数量失败", e);
|
||||||
|
resultDomain.fail("获取学习任务数量失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
return resultDomain;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,16 @@
|
|||||||
<artifactId>api-system</artifactId>
|
<artifactId>api-system</artifactId>
|
||||||
<version>1.0.0</version>
|
<version>1.0.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.xyzh</groupId>
|
||||||
|
<artifactId>api-news</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.xyzh</groupId>
|
||||||
|
<artifactId>api-study</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- Common模块依赖 -->
|
<!-- Common模块依赖 -->
|
||||||
<dependency>
|
<dependency>
|
||||||
|
|||||||
@@ -2,17 +2,34 @@ package org.xyzh.system.controller;
|
|||||||
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.xyzh.api.news.resource.ResourceService;
|
||||||
|
import org.xyzh.api.study.course.CourseService;
|
||||||
|
import org.xyzh.api.study.task.LearningTaskService;
|
||||||
import org.xyzh.common.core.domain.ResultDomain;
|
import org.xyzh.common.core.domain.ResultDomain;
|
||||||
|
import org.xyzh.common.dto.resource.TbResource;
|
||||||
import java.time.LocalDate;
|
import org.xyzh.common.dto.study.TbCourse;
|
||||||
|
import org.xyzh.common.dto.study.TbLearningTask;
|
||||||
|
import org.xyzh.common.dto.system.TbSysVisitStatistics;
|
||||||
|
import org.xyzh.common.dto.user.TbSysUser;
|
||||||
|
import org.xyzh.common.utils.TimeUtils;
|
||||||
|
import org.xyzh.common.vo.UserDeptRoleVO;
|
||||||
|
import org.xyzh.common.redis.service.RedisService;
|
||||||
|
import org.xyzh.system.mapper.SysVisitStatisticsMapper;
|
||||||
|
import org.xyzh.system.mapper.UserMapper;
|
||||||
|
import org.xyzh.system.utils.LoginUtil;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.text.SimpleDateFormat;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Date;
|
||||||
/**
|
/**
|
||||||
* @description 系统总览控制器
|
* @description 系统总览控制器
|
||||||
* @filename SystemOverviewController.java
|
* @filename SystemOverviewController.java
|
||||||
@@ -25,53 +42,193 @@ import java.util.Map;
|
|||||||
public class SystemOverviewController {
|
public class SystemOverviewController {
|
||||||
private static final Logger logger = LoggerFactory.getLogger(SystemOverviewController.class);
|
private static final Logger logger = LoggerFactory.getLogger(SystemOverviewController.class);
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserMapper userMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SysVisitStatisticsMapper sysVisitStatisticsMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ResourceService resourceService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RedisService redisService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private CourseService courseService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private LearningTaskService learningTaskService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取系统总览数据统计
|
* 获取系统总览数据统计
|
||||||
*/
|
*/
|
||||||
@GetMapping("/statistics")
|
@GetMapping("/statistics")
|
||||||
public ResultDomain<Map<String, Object>> getSystemStatistics() {
|
public ResultDomain<Map<String, Object>> getSystemStatistics() {
|
||||||
// TODO: 后续接入真实统计数据(用户表、资源表、访问统计表等)
|
|
||||||
ResultDomain<Map<String, Object>> result = new ResultDomain<>();
|
ResultDomain<Map<String, Object>> result = new ResultDomain<>();
|
||||||
Map<String, Object> data = new HashMap<>();
|
Map<String, Object> data = new HashMap<>();
|
||||||
|
List<UserDeptRoleVO> voList = LoginUtil.getCurrentDeptRole();
|
||||||
|
// 顶部统计卡片:总用户数、总资源数、今日PV、今日UV
|
||||||
|
// 1. 总用户数:直接统计用户表数量(后续可增加状态/逻辑删除过滤)
|
||||||
|
try {
|
||||||
|
TbSysUser filterUser = new TbSysUser();
|
||||||
|
|
||||||
// 顶部统计卡片:总用户数、总资源数、今日访问量、活跃用户
|
long totalUsers = userMapper.countDeptUser(voList.get(0).getDeptID(), filterUser);
|
||||||
data.put("totalUsers", 1234);
|
data.put("totalUsers", totalUsers);
|
||||||
data.put("totalUsersChange", "+12%");
|
filterUser.setEndTime(TimeUtils.getEndTimeOfYesterday());
|
||||||
|
long yesterdayUsers = userMapper.countDeptUser(voList.get(0).getDeptID(), filterUser);
|
||||||
|
// 目前未记录按天的用户总量,这里先返回"+0%",后续可根据用户注册表统计
|
||||||
|
data.put("totalUsersChange", formatPercentChange(totalUsers, yesterdayUsers));
|
||||||
|
|
||||||
data.put("totalResources", 5678);
|
// 2. 总资源数:当前总量 & 与昨日总量对比
|
||||||
data.put("totalResourcesChange", "+8%");
|
long totalResources = 0L;
|
||||||
|
long yesterdayTotalResources = 0L;
|
||||||
|
|
||||||
data.put("todayVisits", 892);
|
// 今天总资源数(不带时间过滤)
|
||||||
data.put("todayVisitsChange", "+15%");
|
ResultDomain<Integer> totalResResult = resourceService.getResourceCount(new TbResource());
|
||||||
|
if (totalResResult.isSuccess() && totalResResult.getData() != null) {
|
||||||
|
totalResources = totalResResult.getData();
|
||||||
|
}
|
||||||
|
|
||||||
data.put("activeUsers", 456);
|
// 昨日结束时的资源总数:publish_time <= 昨日结束时间
|
||||||
data.put("activeUsersChange", "+5%");
|
TbResource yesterdayFilter = new TbResource();
|
||||||
|
yesterdayFilter.setStartTime(TimeUtils.getStartTimeOfYesterday());
|
||||||
|
yesterdayFilter.setEndTime(TimeUtils.getEndTimeOfYesterday());
|
||||||
|
ResultDomain<Integer> yesterdayResResult = resourceService.getResourceCount(yesterdayFilter);
|
||||||
|
if (yesterdayResResult.isSuccess() && yesterdayResResult.getData() != null) {
|
||||||
|
yesterdayTotalResources = yesterdayResResult.getData();
|
||||||
|
}
|
||||||
|
|
||||||
|
data.put("totalResources", totalResources);
|
||||||
|
data.put("totalResourcesChange", formatPercentChange(totalResources, yesterdayTotalResources));
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("统计总用户数失败", e);
|
||||||
|
data.put("totalUsers", 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 今日访问量 & 活跃用户:优先从Redis读取今日数据,必要时从系统访问统计表回退
|
||||||
|
try {
|
||||||
|
LocalDate todayDate = LocalDate.now();
|
||||||
|
LocalDate yesterdayDate = todayDate.minusDays(1);
|
||||||
|
|
||||||
|
String todayKeySuffix = todayDate.format(java.time.format.DateTimeFormatter.BASIC_ISO_DATE);
|
||||||
|
String pvKey = "stat:pv:" + todayKeySuffix;
|
||||||
|
String uvKey = "stat:uv:" + todayKeySuffix;
|
||||||
|
|
||||||
|
String yesterdayKeySuffix = yesterdayDate.format(java.time.format.DateTimeFormatter.BASIC_ISO_DATE);
|
||||||
|
String yesterdayPvKey = "stat:pv:" + yesterdayKeySuffix;
|
||||||
|
String yesterdayUvKey = "stat:uv:" + yesterdayKeySuffix;
|
||||||
|
|
||||||
|
long todayPv = 0L;
|
||||||
|
long todayUv = 0L;
|
||||||
|
long yesterdayPv = 0L;
|
||||||
|
long yesterdayUv = 0L;
|
||||||
|
|
||||||
|
try {
|
||||||
|
Object pvObj = redisService.get(pvKey);
|
||||||
|
if (pvObj instanceof Number) {
|
||||||
|
todayPv = ((Number) pvObj).longValue();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("从Redis读取PV失败: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
todayUv = redisService.sCard(uvKey);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("从Redis读取UV失败: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Object yesterdayPvObj = redisService.get(yesterdayPvKey);
|
||||||
|
if (yesterdayPvObj instanceof Number) {
|
||||||
|
yesterdayPv = ((Number) yesterdayPvObj).longValue();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("从Redis读取昨日PV失败: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
yesterdayUv = redisService.sCard(yesterdayUvKey);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("从Redis读取昨日UV失败: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从统计表中获取活跃用户,作为顶部卡片展示
|
||||||
|
// 顶部卡片:今日PV / 今日UV
|
||||||
|
data.put("totalPv", todayPv);
|
||||||
|
data.put("totalPvChange", formatPercentChange(todayPv, yesterdayPv));
|
||||||
|
|
||||||
|
data.put("totalUv", todayUv);
|
||||||
|
data.put("totalUvChange", formatPercentChange(todayUv, yesterdayUv));
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("统计访问量/活跃用户失败", e);
|
||||||
|
data.put("totalPv", 0L);
|
||||||
|
data.put("totalPvChange", "+0%");
|
||||||
|
data.put("totalUv", 0L);
|
||||||
|
data.put("totalUvChange", "+0%");
|
||||||
|
}
|
||||||
|
|
||||||
result.success("获取系统总览统计成功", data);
|
result.success("获取系统总览统计成功", data);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取活跃用户图表数据
|
* 获取活跃用户图表数据(PV和UV)
|
||||||
*/
|
*/
|
||||||
@GetMapping("/active-users")
|
@GetMapping("/active-users")
|
||||||
public ResultDomain<Map<String, Object>> getActiveUsersChart(
|
public ResultDomain<Map<String, Object>> getActiveUsersChart(
|
||||||
@RequestParam(required = true, name = "start") String start, @RequestParam(required = true, name = "end") String end) {
|
@RequestParam(required = true, name = "start") String start, @RequestParam(required = true, name = "end") String end) {
|
||||||
// TODO: 后续根据days参数(7/30天)查询真实活跃用户统计
|
|
||||||
ResultDomain<Map<String, Object>> result = new ResultDomain<>();
|
ResultDomain<Map<String, Object>> result = new ResultDomain<>();
|
||||||
Map<String, Object> data = new HashMap<>();
|
Map<String, Object> data = new HashMap<>();
|
||||||
|
|
||||||
// 默认展示7天
|
|
||||||
LocalDate startDate = LocalDate.parse(start);
|
LocalDate startDate = LocalDate.parse(start);
|
||||||
LocalDate endDate = LocalDate.parse(end);
|
LocalDate endDate = LocalDate.parse(end);
|
||||||
long days = startDate.until(endDate).getDays();
|
LocalDate today = LocalDate.now();
|
||||||
|
|
||||||
// X轴:周一 ~ 周日(示例)
|
try {
|
||||||
data.put("labels", List.of("周一", "周二", "周三", "周四", "周五", "周六", "周日"));
|
// 按天遍历统计区间内的PV和UV数据
|
||||||
// Y轴数据:活跃用户数量(示例数据)
|
List<String> labels = new ArrayList<>();
|
||||||
data.put("values", List.of(120, 200, 150, 80, 70, 110, 130));
|
List<Integer> pvValues = new ArrayList<>();
|
||||||
|
List<Integer> uvValues = new ArrayList<>();
|
||||||
|
|
||||||
result.success("获取活跃用户图表数据成功", data);
|
LocalDate cursor = startDate;
|
||||||
|
while (!cursor.isAfter(endDate)) {
|
||||||
|
labels.add(formatDayLabel(cursor));
|
||||||
|
|
||||||
|
int pv = 0;
|
||||||
|
int uv = 0;
|
||||||
|
try {
|
||||||
|
String keySuffix = cursor.format(java.time.format.DateTimeFormatter.BASIC_ISO_DATE);
|
||||||
|
String pvKey = "stat:pv:" + keySuffix;
|
||||||
|
String uvKey = "stat:uv:" + keySuffix;
|
||||||
|
|
||||||
|
Object pvObj = redisService.get(pvKey);
|
||||||
|
if (pvObj instanceof Number) {
|
||||||
|
pv = ((Number) pvObj).intValue();
|
||||||
|
}
|
||||||
|
uv = (int) redisService.sCard(uvKey);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("从Redis读取今日PV/UV失败,尝试从数据库读取: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
pvValues.add(pv);
|
||||||
|
uvValues.add(uv);
|
||||||
|
|
||||||
|
cursor = cursor.plusDays(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
data.put("labels", labels);
|
||||||
|
data.put("pvValues", pvValues);
|
||||||
|
data.put("uvValues", uvValues);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("获取PV/UV图表数据失败", e);
|
||||||
|
data.put("labels", List.of());
|
||||||
|
data.put("pvValues", List.of());
|
||||||
|
data.put("uvValues", List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
result.success("获取PV/UV图表数据成功", data);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,21 +237,36 @@ public class SystemOverviewController {
|
|||||||
*/
|
*/
|
||||||
@GetMapping("/resource-category-stats")
|
@GetMapping("/resource-category-stats")
|
||||||
public ResultDomain<Map<String, Object>> getResourceCategoryStats() {
|
public ResultDomain<Map<String, Object>> getResourceCategoryStats() {
|
||||||
// TODO: 后续从资源表统计各类型资源数量
|
|
||||||
ResultDomain<Map<String, Object>> result = new ResultDomain<>();
|
ResultDomain<Map<String, Object>> result = new ResultDomain<>();
|
||||||
|
try {
|
||||||
Map<String, Object> data = new HashMap<>();
|
Map<String, Object> data = new HashMap<>();
|
||||||
|
|
||||||
|
// 获取各类型资源数量
|
||||||
|
ResultDomain<Integer> resourceCountResult = resourceService.getResourceCount(new TbResource());
|
||||||
|
ResultDomain<Integer> courseCountResult = courseService.getCourseCount(new TbCourse());
|
||||||
|
ResultDomain<Integer> taskCountResult = learningTaskService.getLearningTaskCount(new TbLearningTask());
|
||||||
|
|
||||||
|
// 获取数量,失败时默认为0
|
||||||
|
int resourceCount = (resourceCountResult.isSuccess() && resourceCountResult.getData() != null)
|
||||||
|
? resourceCountResult.getData() : 0;
|
||||||
|
int courseCount = (courseCountResult.isSuccess() && courseCountResult.getData() != null)
|
||||||
|
? courseCountResult.getData() : 0;
|
||||||
|
int taskCount = (taskCountResult.isSuccess() && taskCountResult.getData() != null)
|
||||||
|
? taskCountResult.getData() : 0;
|
||||||
|
|
||||||
// 饼图数据:名称 + 数量
|
// 饼图数据:名称 + 数量
|
||||||
List<Map<String, Object>> categories = List.of(
|
List<Map<String, Object>> categories = List.of(
|
||||||
createCategory("文章", 1048),
|
createCategory("文章", resourceCount),
|
||||||
createCategory("视频", 735),
|
createCategory("课程", courseCount),
|
||||||
createCategory("音频", 580),
|
createCategory("学习任务", taskCount)
|
||||||
createCategory("课程", 484),
|
|
||||||
createCategory("其他", 300)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
data.put("items", categories);
|
data.put("items", categories);
|
||||||
result.success("获取资源分类统计成功", data);
|
result.success("获取资源分类统计成功", data);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("获取资源分类统计失败", e);
|
||||||
|
result.fail("获取资源分类统计失败: " + e.getMessage());
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,17 +275,77 @@ public class SystemOverviewController {
|
|||||||
*/
|
*/
|
||||||
@GetMapping("/today-visits")
|
@GetMapping("/today-visits")
|
||||||
public ResultDomain<Map<String, Object>> getTodayVisits() {
|
public ResultDomain<Map<String, Object>> getTodayVisits() {
|
||||||
// TODO: 后续接入访问日志/统计表,计算今日指标
|
|
||||||
ResultDomain<Map<String, Object>> result = new ResultDomain<>();
|
ResultDomain<Map<String, Object>> result = new ResultDomain<>();
|
||||||
Map<String, Object> data = new HashMap<>();
|
Map<String, Object> data = new HashMap<>();
|
||||||
|
try {
|
||||||
|
String todayKeySuffix = LocalDate.now().format(java.time.format.DateTimeFormatter.BASIC_ISO_DATE);
|
||||||
|
String pvKey = "stat:pv:" + todayKeySuffix;
|
||||||
|
String uvKey = "stat:uv:" + todayKeySuffix;
|
||||||
|
|
||||||
data.put("uv", 892);
|
long uvFromRedis = 0L;
|
||||||
data.put("pv", 3456);
|
long pvFromRedis = 0L;
|
||||||
data.put("avgVisitDuration", "5分32秒");
|
|
||||||
data.put("bounceRate", "35.6%");
|
try {
|
||||||
|
pvFromRedis = 0L;
|
||||||
|
Object pvObj = redisService.get(pvKey);
|
||||||
|
if (pvObj instanceof Number) {
|
||||||
|
pvFromRedis = ((Number) pvObj).longValue();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("从Redis读取PV失败: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
uvFromRedis = redisService.sCard(uvKey);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("从Redis读取UV失败: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
TbSysVisitStatistics filter = new TbSysVisitStatistics();
|
||||||
|
filter.setStatDate(new Date());
|
||||||
|
List<TbSysVisitStatistics> list = sysVisitStatisticsMapper.selectSysVisitStatistics(filter);
|
||||||
|
TbSysVisitStatistics todayStat = (list != null && !list.isEmpty()) ? list.get(0) : null;
|
||||||
|
|
||||||
|
int uvFromDb = todayStat != null && todayStat.getUniqueVisitors() != null ? todayStat.getUniqueVisitors() : 0;
|
||||||
|
int pvFromDb = todayStat != null && todayStat.getPageViews() != null ? todayStat.getPageViews() : 0;
|
||||||
|
int avgDurationSec = todayStat != null && todayStat.getAvgVisitDuration() != null ? todayStat.getAvgVisitDuration() : 0;
|
||||||
|
|
||||||
|
// 优先使用Redis中的实时数据,若没有则回退到统计表
|
||||||
|
data.put("uv", uvFromRedis > 0 ? uvFromRedis : uvFromDb);
|
||||||
|
data.put("pv", pvFromRedis > 0 ? pvFromRedis : pvFromDb);
|
||||||
|
data.put("avgVisitDuration", formatDuration(avgDurationSec));
|
||||||
|
// 当前表中没有跳出率字段,这里先返回"-",后续有字段后再计算
|
||||||
|
data.put("bounceRate", "-");
|
||||||
|
|
||||||
result.success("获取今日访问量统计成功", data);
|
result.success("获取今日访问量统计成功", data);
|
||||||
return result;
|
return result;
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("获取今日访问量统计失败", e);
|
||||||
|
data.put("uv", 0);
|
||||||
|
data.put("pv", 0);
|
||||||
|
data.put("avgVisitDuration", "0秒");
|
||||||
|
data.put("bounceRate", "-");
|
||||||
|
result.success("获取今日访问量统计成功", data);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录页面访问(PV),由前端在进入系统总览页面时主动调用
|
||||||
|
*/
|
||||||
|
@GetMapping("/track-visit")
|
||||||
|
public ResultDomain<String> trackVisit() {
|
||||||
|
ResultDomain<String> result = new ResultDomain<>();
|
||||||
|
try {
|
||||||
|
String todayKeySuffix = LocalDate.now().format(java.time.format.DateTimeFormatter.BASIC_ISO_DATE);
|
||||||
|
String pvKey = "stat:pv:" + todayKeySuffix;
|
||||||
|
redisService.incr(pvKey, 1L);
|
||||||
|
result.success("记录访问成功", "OK");
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("记录PV失败", e);
|
||||||
|
result.fail("记录访问失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -121,7 +353,6 @@ public class SystemOverviewController {
|
|||||||
*/
|
*/
|
||||||
@GetMapping("/system-status")
|
@GetMapping("/system-status")
|
||||||
public ResultDomain<Map<String, Object>> getSystemStatus() {
|
public ResultDomain<Map<String, Object>> getSystemStatus() {
|
||||||
// TODO: 后续接入真实系统运行状态(CPU、内存、服务可用性等)
|
|
||||||
ResultDomain<Map<String, Object>> result = new ResultDomain<>();
|
ResultDomain<Map<String, Object>> result = new ResultDomain<>();
|
||||||
Map<String, Object> data = new HashMap<>();
|
Map<String, Object> data = new HashMap<>();
|
||||||
|
|
||||||
@@ -138,4 +369,36 @@ public class SystemOverviewController {
|
|||||||
item.put("value", value);
|
item.put("value", value);
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String formatDayLabel(LocalDate date) {
|
||||||
|
return date.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatDuration(int seconds) {
|
||||||
|
if (seconds <= 0) {
|
||||||
|
return "0秒";
|
||||||
|
}
|
||||||
|
int minutes = seconds / 60;
|
||||||
|
int remain = seconds % 60;
|
||||||
|
if (minutes == 0) {
|
||||||
|
return remain + "秒";
|
||||||
|
}
|
||||||
|
return minutes + "分" + remain + "秒";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算相对昨日的变化百分比,返回形如"+12%"或"-5%",当昨日为0或无数据时返回"+0%"
|
||||||
|
*/
|
||||||
|
private String formatPercentChange(long today, long yesterday) {
|
||||||
|
if (yesterday <= 0) {
|
||||||
|
return "+"+today+"%";
|
||||||
|
}
|
||||||
|
|
||||||
|
long diff = today - yesterday;
|
||||||
|
double percent = diff * 100.0 / yesterday;
|
||||||
|
// 保留一位小数
|
||||||
|
String formatted = String.format("%.1f", Math.abs(percent));
|
||||||
|
String sign = percent >= 0 ? "+" : "-";
|
||||||
|
return sign + formatted + "%";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ public interface UserMapper extends BaseMapper<TbSysUser> {
|
|||||||
UserVO selectUserInfoTotal(@Param("userId") String userId);
|
UserVO selectUserInfoTotal(@Param("userId") String userId);
|
||||||
|
|
||||||
|
|
||||||
int countDeptUser(@Param("deptId") String deptId);
|
int countDeptUser(@Param("deptId") String deptId, @Param("filter") TbSysUser filter);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description 查询部门及其子部门的所有用户ID
|
* @description 查询部门及其子部门的所有用户ID
|
||||||
|
|||||||
@@ -282,7 +282,7 @@ public class SysUserServiceImpl implements SysUserService {
|
|||||||
List<TbSysUser> users = userMapper.selectUserPage(filter, pageParam, userDeptRoles);
|
List<TbSysUser> users = userMapper.selectUserPage(filter, pageParam, userDeptRoles);
|
||||||
PageDomain<TbSysUser> pageDomain = new PageDomain<>();
|
PageDomain<TbSysUser> pageDomain = new PageDomain<>();
|
||||||
|
|
||||||
int count = userMapper.countDeptUser(userDeptRoles.get(0).getDeptID());
|
int count = userMapper.countDeptUser(userDeptRoles.get(0).getDeptID(), filter);
|
||||||
pageDomain.setDataList(users);
|
pageDomain.setDataList(users);
|
||||||
pageDomain.setPageParam(pageParam);
|
pageDomain.setPageParam(pageParam);
|
||||||
pageParam.setTotalElements(count);
|
pageParam.setTotalElements(count);
|
||||||
@@ -299,7 +299,7 @@ public class SysUserServiceImpl implements SysUserService {
|
|||||||
List<UserVO> userVOs = userMapper.selectUserVOPage(filter, pageParam, userDeptRoles);
|
List<UserVO> userVOs = userMapper.selectUserVOPage(filter, pageParam, userDeptRoles);
|
||||||
PageDomain<UserVO> pageDomain = new PageDomain<>();
|
PageDomain<UserVO> pageDomain = new PageDomain<>();
|
||||||
|
|
||||||
int count = userMapper.countDeptUser(userDeptRoles.get(0).getDeptID());
|
int count = userMapper.countDeptUser(userDeptRoles.get(0).getDeptID(), filter);
|
||||||
pageDomain.setDataList(userVOs);
|
pageDomain.setDataList(userVOs);
|
||||||
pageDomain.setPageParam(pageParam);
|
pageDomain.setPageParam(pageParam);
|
||||||
pageParam.setTotalElements(count);
|
pageParam.setTotalElements(count);
|
||||||
|
|||||||
@@ -95,24 +95,30 @@
|
|||||||
|
|
||||||
<sql id="Filter_Clause">
|
<sql id="Filter_Clause">
|
||||||
<where>
|
<where>
|
||||||
deleted = 0
|
u.deleted = 0
|
||||||
<if test="filter.id != null and filter.id != ''">
|
<if test="filter.id != null and filter.id != ''">
|
||||||
AND id = #{filter.id}
|
AND u.id = #{filter.id}
|
||||||
</if>
|
</if>
|
||||||
<if test="filter.username != null and filter.username != ''">
|
<if test="filter.username != null and filter.username != ''">
|
||||||
AND username = #{filter.username}
|
AND u.username = #{filter.username}
|
||||||
</if>
|
</if>
|
||||||
<if test="filter.email != null and filter.email != ''">
|
<if test="filter.email != null and filter.email != ''">
|
||||||
AND email = #{filter.email}
|
AND u.email = #{filter.email}
|
||||||
</if>
|
</if>
|
||||||
<if test="filter.phone != null and filter.phone != ''">
|
<if test="filter.phone != null and filter.phone != ''">
|
||||||
AND phone = #{filter.phone}
|
AND u.phone = #{filter.phone}
|
||||||
</if>
|
</if>
|
||||||
<if test="filter.status != null">
|
<if test="filter.status != null">
|
||||||
AND status = #{filter.status}
|
AND u.status = #{filter.status}
|
||||||
</if>
|
</if>
|
||||||
<if test="filter.wechatID != null and filter.wechatID != ''">
|
<if test="filter.wechatID != null and filter.wechatID != ''">
|
||||||
AND wechat_id = #{filter.wechatID}
|
AND u.wechat_id = #{filter.wechatID}
|
||||||
|
</if>
|
||||||
|
<if test="filter.startTime != null">
|
||||||
|
AND u.create_time >= #{filter.startTime}
|
||||||
|
</if>
|
||||||
|
<if test="filter.endTime != null">
|
||||||
|
AND u.create_time < #{filter.endTime}
|
||||||
</if>
|
</if>
|
||||||
</where>
|
</where>
|
||||||
</sql>
|
</sql>
|
||||||
@@ -575,7 +581,7 @@
|
|||||||
FROM tb_sys_user_dept_role tudr
|
FROM tb_sys_user_dept_role tudr
|
||||||
INNER JOIN tb_sys_dept d ON tudr.dept_id = d.dept_id AND d.deleted = 0
|
INNER JOIN tb_sys_dept d ON tudr.dept_id = d.dept_id AND d.deleted = 0
|
||||||
INNER JOIN tb_sys_user u ON tudr.user_id = u.id AND u.deleted = 0
|
INNER JOIN tb_sys_user u ON tudr.user_id = u.id AND u.deleted = 0
|
||||||
WHERE tudr.deleted = 0
|
<include refid="Filter_Clause"/>
|
||||||
AND d.dept_path LIKE CONCAT(
|
AND d.dept_path LIKE CONCAT(
|
||||||
(SELECT dept_path FROM tb_sys_dept WHERE dept_id = #{deptId} AND deleted = 0),
|
(SELECT dept_path FROM tb_sys_dept WHERE dept_id = #{deptId} AND deleted = 0),
|
||||||
'%'
|
'%'
|
||||||
|
|||||||
@@ -10,15 +10,16 @@ export interface SystemStatisticsDTO {
|
|||||||
totalUsersChange: string;
|
totalUsersChange: string;
|
||||||
totalResources: number;
|
totalResources: number;
|
||||||
totalResourcesChange: string;
|
totalResourcesChange: string;
|
||||||
todayVisits: number;
|
totalPv: number;
|
||||||
todayVisitsChange: string;
|
totalPvChange: string;
|
||||||
activeUsers: number;
|
totalUv: number;
|
||||||
activeUsersChange: string;
|
totalUvChange: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActiveUsersChartDTO {
|
export interface ActiveUsersChartDTO {
|
||||||
labels: string[];
|
labels: string[];
|
||||||
values: number[];
|
pvValues: number[];
|
||||||
|
uvValues: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResourceCategoryItemDTO {
|
export interface ResourceCategoryItemDTO {
|
||||||
@@ -56,5 +57,16 @@ export const systemOverviewApi = {
|
|||||||
async getTodayVisits(): Promise<ResultDomain<TodayVisitsDTO>> {
|
async getTodayVisits(): Promise<ResultDomain<TodayVisitsDTO>> {
|
||||||
const response = await api.get<TodayVisitsDTO>('/system/overview/today-visits');
|
const response = await api.get<TodayVisitsDTO>('/system/overview/today-visits');
|
||||||
return response.data;
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录一次页面访问(PV),前端在路由切换时调用
|
||||||
|
*/
|
||||||
|
async trackVisit(): Promise<ResultDomain<string>> {
|
||||||
|
const response = await api.get<string>('/system/overview/track-visit', undefined, {
|
||||||
|
showLoading: false,
|
||||||
|
showError: false
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
5
schoolNewsWeb/src/assets/imgs/overview-pv.svg
Normal file
5
schoolNewsWeb/src/assets/imgs/overview-pv.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="48" height="48" rx="12" fill="#FDF2F8"/>
|
||||||
|
<path d="M24.0003 15C29.3924 15 33.8784 18.8798 34.8189 24C33.8784 29.1202 29.3924 33 24.0003 33C18.6081 33 14.1222 29.1202 13.1816 24C14.1222 18.8798 18.6081 15 24.0003 15ZM24.0003 31C28.2359 31 31.8603 28.052 32.7777 24C31.8603 19.948 28.2359 17 24.0003 17C19.7646 17 16.1402 19.948 15.2228 24C16.1402 28.052 19.7646 31 24.0003 31ZM24.0003 28.5C21.515 28.5 19.5003 26.4853 19.5003 24C19.5003 21.5147 21.515 19.5 24.0003 19.5C26.4855 19.5 28.5003 21.5147 28.5003 24C28.5003 26.4853 26.4855 28.5 24.0003 28.5ZM24.0003 26.5C25.381 26.5 26.5003 25.3807 26.5003 24C26.5003 22.6193 25.381 21.5 24.0003 21.5C22.6196 21.5 21.5003 22.6193 21.5003 24C21.5003 25.3807 22.6196 26.5 24.0003 26.5Z" fill="#ED4F9D"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 869 B |
5
schoolNewsWeb/src/assets/imgs/overview-resource.svg
Normal file
5
schoolNewsWeb/src/assets/imgs/overview-resource.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="48" height="48" rx="12" fill="#EFF6FF"/>
|
||||||
|
<path d="M33 20V32.9932C33 33.5501 32.5552 34 32.0066 34H15.9934C15.445 34 15 33.556 15 33.0082V14.9918C15 14.4553 15.4487 14 16.0022 14H26.9968L33 20ZM31 21H26V16H17V32H31V21ZM20 19H23V21H20V19ZM20 23H28V25H20V23ZM20 27H28V29H20V27Z" fill="#2563EB"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 417 B |
5
schoolNewsWeb/src/assets/imgs/overview-user.svg
Normal file
5
schoolNewsWeb/src/assets/imgs/overview-user.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="48" height="48" rx="12" fill="#FFFBEB"/>
|
||||||
|
<path d="M32 34H30V32C30 30.3431 28.6569 29 27 29H21C19.3432 29 18 30.3431 18 32V34H16V32C16 29.2386 18.2386 27 21 27H27C29.7614 27 32 29.2386 32 32V34ZM24 25C20.6863 25 18 22.3137 18 19C18 15.6863 20.6863 13 24 13C27.3137 13 30 15.6863 30 19C30 22.3137 27.3137 25 24 25ZM24 23C26.2091 23 28 21.2091 28 19C28 16.7909 26.2091 15 24 15C21.7909 15 20 16.7909 20 19C20 21.2091 21.7909 23 24 23Z" fill="#F6A723"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 574 B |
6
schoolNewsWeb/src/assets/imgs/overview-uv.svg
Normal file
6
schoolNewsWeb/src/assets/imgs/overview-uv.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="48" height="48" rx="12" fill="#FFF2EB"/>
|
||||||
|
<path d="M28 19H34V25" stroke="#F54900" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M34 19L25.5 27.5L20.5 22.5L14 29" stroke="#F54900" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 397 B |
@@ -7,6 +7,7 @@
|
|||||||
import type { Router, NavigationGuardNext, RouteLocationNormalized } from 'vue-router';
|
import type { Router, NavigationGuardNext, RouteLocationNormalized } from 'vue-router';
|
||||||
import type { Store } from 'vuex';
|
import type { Store } from 'vuex';
|
||||||
import { AuthState } from '@/store/modules/auth';
|
import { AuthState } from '@/store/modules/auth';
|
||||||
|
import { systemOverviewApi } from '@/apis/system/overview';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 白名单路由 - 无需登录即可访问
|
* 白名单路由 - 无需登录即可访问
|
||||||
@@ -42,13 +43,19 @@ export function setupRouterGuards(router: Router, store: Store<any>) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 全局后置钩子
|
// 全局后置钩子
|
||||||
|
|
||||||
router.afterEach((to) => {
|
router.afterEach((to) => {
|
||||||
// 结束页面加载进度条
|
// 结束页面加载进度条
|
||||||
finishProgress();
|
finishProgress();
|
||||||
|
|
||||||
// 设置页面标题
|
// 设置页面标题
|
||||||
setPageTitle(to.meta?.title as string);
|
setPageTitle(to.meta?.title as string);
|
||||||
|
|
||||||
|
// 统计PV:对path以"/"开头的路由进行统计
|
||||||
|
if (to.path.startsWith('/')) {
|
||||||
|
systemOverviewApi.trackVisit().catch(() => {
|
||||||
|
// 统计失败不影响正常页面使用
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 全局解析守卫(在导航被确认之前,同时在所有组件内守卫和异步路由组件被解析之后调用)
|
// 全局解析守卫(在导航被确认之前,同时在所有组件内守卫和异步路由组件被解析之后调用)
|
||||||
|
|||||||
@@ -4,15 +4,55 @@
|
|||||||
|
|
||||||
<!-- 统计卡片 -->
|
<!-- 统计卡片 -->
|
||||||
<div class="stats-grid">
|
<div class="stats-grid">
|
||||||
<div class="stat-card" v-for="stat in statistics" :key="stat.label">
|
<!-- 总用户 -->
|
||||||
<div class="stat-icon" :style="{ background: stat.color }">
|
<div class="stat-card">
|
||||||
<i>{{ stat.icon }}</i>
|
<div class="stat-icon"><img src="@/assets/imgs/overview-user.svg" alt="用户" /></div>
|
||||||
</div>
|
|
||||||
<div class="stat-content">
|
<div class="stat-content">
|
||||||
<h3>{{ stat.value }}</h3>
|
<h3>用户总数</h3>
|
||||||
<p>{{ stat.label }}</p>
|
<span>
|
||||||
<span class="stat-change" :class="stat.trend">
|
{{ statistics.totalUsers }}
|
||||||
{{ stat.change }}
|
<span>
|
||||||
|
{{ statistics.totalUsersChange }} 较昨日
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 总资源 -->
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon"><img src="@/assets/imgs/overview-resource.svg" alt="资源" /></div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<h3>资源总数</h3>
|
||||||
|
<span>
|
||||||
|
{{ statistics.totalResources }}
|
||||||
|
<span>
|
||||||
|
{{ statistics.totalResourcesChange }} 较昨日
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 今日pv -->
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon"><img src="@/assets/imgs/overview-pv.svg" alt="pv" /></div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<h3>今日访问</h3>
|
||||||
|
<span>
|
||||||
|
{{ statistics.totalPv }}
|
||||||
|
<span>
|
||||||
|
{{ statistics.totalPvChange }} 较昨日
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 今日uv -->
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon"><img src="@/assets/imgs/overview-uv.svg" alt="uv" /></div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<h3>今日用户</h3>
|
||||||
|
<span>
|
||||||
|
{{ statistics.totalUv }}
|
||||||
|
<span>
|
||||||
|
{{ statistics.totalUvChange }} 较昨日
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -24,7 +64,7 @@
|
|||||||
<el-card class="chart-card">
|
<el-card class="chart-card">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span>用户活跃度折线图</span>
|
<span>访问统计趋势(PV/UV)</span>
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
v-model="dateRange"
|
v-model="dateRange"
|
||||||
type="daterange"
|
type="daterange"
|
||||||
@@ -49,26 +89,15 @@
|
|||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
<!-- 今日访问量详情 -->
|
|
||||||
<el-card class="visit-card">
|
|
||||||
<template #header>
|
|
||||||
<span>今日访问量</span>
|
|
||||||
</template>
|
|
||||||
<div class="visit-stats">
|
|
||||||
<div class="visit-item" v-for="item in visitStats" :key="item.label">
|
|
||||||
<div class="visit-label">{{ item.label }}</div>
|
|
||||||
<div class="visit-value">{{ item.value }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</el-card>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted } from 'vue';
|
import { ref, onMounted, onUnmounted, watch } from 'vue';
|
||||||
import { ElRow, ElCol, ElCard, ElDatePicker } from 'element-plus';
|
import { ElRow, ElCol, ElCard, ElDatePicker } from 'element-plus';
|
||||||
import * as echarts from 'echarts';
|
import * as echarts from 'echarts';
|
||||||
import { systemOverviewApi } from '@/apis/system/overview';
|
import { systemOverviewApi } from '@/apis/system/overview';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
const dateRange = ref<[Date, Date] | null>(null);
|
const dateRange = ref<[Date, Date] | null>(null);
|
||||||
const activityChart = ref<HTMLElement | null>(null);
|
const activityChart = ref<HTMLElement | null>(null);
|
||||||
@@ -76,18 +105,23 @@ const resourcePieChart = ref<HTMLElement | null>(null);
|
|||||||
let activityChartInstance: echarts.ECharts | null = null;
|
let activityChartInstance: echarts.ECharts | null = null;
|
||||||
let pieChartInstance: echarts.ECharts | null = null;
|
let pieChartInstance: echarts.ECharts | null = null;
|
||||||
|
|
||||||
const statistics = ref<{
|
const statistics = ref({
|
||||||
icon: string;
|
totalUsers: 0,
|
||||||
label: string;
|
totalUsersChange: '+0%',
|
||||||
value: string | number;
|
totalResources: 0,
|
||||||
change: string;
|
totalResourcesChange: '+0%',
|
||||||
trend: 'up' | 'down';
|
totalPv: 0,
|
||||||
color: string;
|
totalPvChange: '+0%',
|
||||||
}[]>([]);
|
totalUv: 0,
|
||||||
|
totalUvChange: '+0%'
|
||||||
const visitStats = ref<{ label: string; value: string | number }[]>([]);
|
});
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
// 默认选择最近 7 天
|
||||||
|
const now = new Date();
|
||||||
|
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||||
|
dateRange.value = [sevenDaysAgo, now];
|
||||||
|
|
||||||
initCharts();
|
initCharts();
|
||||||
await loadOverviewData();
|
await loadOverviewData();
|
||||||
});
|
});
|
||||||
@@ -101,65 +135,55 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 监听日期范围变化,重新加载活跃用户图表
|
||||||
|
watch(dateRange, async (newRange) => {
|
||||||
|
if (newRange && newRange.length === 2) {
|
||||||
|
try {
|
||||||
|
const activeRes = await systemOverviewApi.getActiveUsersChart(
|
||||||
|
formatDate(newRange[0]),
|
||||||
|
formatDate(newRange[1])
|
||||||
|
);
|
||||||
|
if (activeRes.success && activeRes.data) {
|
||||||
|
updateActivityChart(activeRes.data.labels, activeRes.data.pvValues, activeRes.data.uvValues);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载活跃用户图表数据失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
function formatDate(date: Date) {
|
||||||
|
return dayjs(date).format('YYYY-MM-DD');
|
||||||
|
}
|
||||||
async function loadOverviewData() {
|
async function loadOverviewData() {
|
||||||
try {
|
try {
|
||||||
const [statRes, activeRes, pieRes, todayRes] = await Promise.all([
|
const [statRes, activeRes, pieRes, todayRes] = await Promise.all([
|
||||||
systemOverviewApi.getStatistics(),
|
systemOverviewApi.getStatistics(),
|
||||||
systemOverviewApi.getActiveUsersChart('2025-10-15', '2025-10-21'),
|
systemOverviewApi.getActiveUsersChart(formatDate(dateRange.value?.[0]!), formatDate(dateRange.value?.[1]!)),
|
||||||
systemOverviewApi.getResourceCategoryStats(),
|
systemOverviewApi.getResourceCategoryStats(),
|
||||||
systemOverviewApi.getTodayVisits()
|
systemOverviewApi.getTodayVisits()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (statRes.success && statRes.data) {
|
if (statRes.success && statRes.data) {
|
||||||
const d = statRes.data;
|
const d = statRes.data;
|
||||||
statistics.value = [
|
statistics.value = {
|
||||||
{
|
totalUsers: d.totalUsers ?? 0,
|
||||||
icon: '👥',
|
totalUsersChange: String(d.totalUsersChange ?? '+0%'),
|
||||||
label: '总用户数',
|
totalResources: d.totalResources ?? 0,
|
||||||
value: d.totalUsers,
|
totalResourcesChange: String(d.totalResourcesChange ?? '+0%'),
|
||||||
change: d.totalUsersChange,
|
totalPv: d.totalPv ?? 0,
|
||||||
trend: d.totalUsersChange.startsWith('-') ? 'down' : 'up',
|
totalPvChange: String(d.totalPvChange ?? '+0%'),
|
||||||
color: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
|
totalUv: d.totalUv ?? 0,
|
||||||
},
|
totalUvChange: String(d.totalUvChange ?? '+0%')
|
||||||
{
|
};
|
||||||
icon: '📚',
|
|
||||||
label: '总资源数',
|
|
||||||
value: d.totalResources,
|
|
||||||
change: d.totalResourcesChange,
|
|
||||||
trend: d.totalResourcesChange.startsWith('-') ? 'down' : 'up',
|
|
||||||
color: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: '👁',
|
|
||||||
label: '今日访问量',
|
|
||||||
value: d.todayVisits,
|
|
||||||
change: d.todayVisitsChange,
|
|
||||||
trend: d.todayVisitsChange.startsWith('-') ? 'down' : 'up',
|
|
||||||
color: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: '✅',
|
|
||||||
label: '活跃用户',
|
|
||||||
value: d.activeUsers,
|
|
||||||
change: d.activeUsersChange,
|
|
||||||
trend: d.activeUsersChange.startsWith('-') ? 'down' : 'up',
|
|
||||||
color: 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (todayRes.success && todayRes.data) {
|
if (todayRes.success && todayRes.data) {
|
||||||
const t = todayRes.data;
|
const t = todayRes.data;
|
||||||
visitStats.value = [
|
// todayRes 仍用于“今日访问”区域(如后续需要),当前统计卡片只使用 statistics
|
||||||
{ label: 'UV(独立访客)', value: t.uv },
|
|
||||||
{ label: 'PV(页面浏览量)', value: t.pv },
|
|
||||||
{ label: '平均访问时长', value: t.avgVisitDuration },
|
|
||||||
{ label: '跳出率', value: t.bounceRate }
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeRes.success && activeRes.data) {
|
if (activeRes.success && activeRes.data) {
|
||||||
updateActivityChart(activeRes.data.labels, activeRes.data.values);
|
updateActivityChart(activeRes.data.labels, activeRes.data.pvValues, activeRes.data.uvValues);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pieRes.success && pieRes.data) {
|
if (pieRes.success && pieRes.data) {
|
||||||
@@ -180,14 +204,28 @@ function initCharts() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateActivityChart(labels: string[], values: number[]) {
|
function updateActivityChart(labels: string[], pvValues: number[], uvValues: number[]) {
|
||||||
if (!activityChartInstance) return;
|
if (!activityChartInstance) return;
|
||||||
const option = {
|
const option = {
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'axis'
|
trigger: 'axis',
|
||||||
|
axisPointer: {
|
||||||
|
type: 'cross'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
data: ['页面访问量 (PV)', '独立访客数 (UV)'],
|
||||||
|
top: 10
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
left: '3%',
|
||||||
|
right: '4%',
|
||||||
|
bottom: '3%',
|
||||||
|
containLabel: true
|
||||||
},
|
},
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'category',
|
type: 'category',
|
||||||
|
boundaryGap: false,
|
||||||
data: labels
|
data: labels
|
||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: {
|
||||||
@@ -195,10 +233,48 @@ function updateActivityChart(labels: string[], values: number[]) {
|
|||||||
},
|
},
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
data: values,
|
name: '页面访问量 (PV)',
|
||||||
|
data: pvValues,
|
||||||
type: 'line',
|
type: 'line',
|
||||||
smooth: true,
|
smooth: true,
|
||||||
areaStyle: {}
|
itemStyle: {
|
||||||
|
color: '#5470c6'
|
||||||
|
},
|
||||||
|
areaStyle: {
|
||||||
|
color: {
|
||||||
|
type: 'linear',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
x2: 0,
|
||||||
|
y2: 1,
|
||||||
|
colorStops: [
|
||||||
|
{ offset: 0, color: 'rgba(84, 112, 198, 0.3)' },
|
||||||
|
{ offset: 1, color: 'rgba(84, 112, 198, 0.05)' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '独立访客数 (UV)',
|
||||||
|
data: uvValues,
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
itemStyle: {
|
||||||
|
color: '#91cc75'
|
||||||
|
},
|
||||||
|
areaStyle: {
|
||||||
|
color: {
|
||||||
|
type: 'linear',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
x2: 0,
|
||||||
|
y2: 1,
|
||||||
|
colorStops: [
|
||||||
|
{ offset: 0, color: 'rgba(145, 204, 117, 0.3)' },
|
||||||
|
{ offset: 1, color: 'rgba(145, 204, 117, 0.05)' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
@@ -229,6 +305,7 @@ function updatePieChart(items: { name: string; value: number }[]) {
|
|||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.system-overview {
|
.system-overview {
|
||||||
|
padding: 16px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
@@ -241,56 +318,59 @@ function updatePieChart(items: { name: string; value: number }[]) {
|
|||||||
.stats-grid {
|
.stats-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: 20px;
|
gap: 16px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
background: white;
|
background: #ffffff;
|
||||||
padding: 24px;
|
padding: 20px 24px;
|
||||||
border-radius: 8px;
|
border-radius: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-icon {
|
.stat-icon {
|
||||||
width: 64px;
|
width: 48px;
|
||||||
height: 64px;
|
height: 48px;
|
||||||
border-radius: 12px;
|
border-radius: 20%;
|
||||||
|
background: #f5f7ff;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 32px;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-content {
|
.stat-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #64748b;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
> span {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #141F38;
|
color: #0f172a;
|
||||||
margin-bottom: 4px;
|
|
||||||
|
> span {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #16a34a; // 默认上升为绿色
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #666;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-change {
|
|
||||||
font-size: 13px;
|
|
||||||
|
|
||||||
&.up {
|
|
||||||
color: #4caf50;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.down {
|
|
||||||
color: #f44336;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user