overview统计

This commit is contained in:
2025-11-14 18:31:39 +08:00
parent 6be3cc6abd
commit 9adc0c2058
24 changed files with 723 additions and 178 deletions

View File

@@ -22,5 +22,6 @@
"path": "cmd.exe",
"args": ["/k", "chcp 65001"]
}
}
},
"java.debug.settings.onBuildFailureProceed": true
}

View File

@@ -183,4 +183,7 @@ public interface ResourceService {
* @since 2025-10-15
*/
ResultDomain<TbResource> searchResources(String keyword, String tagID, Integer status);
ResultDomain<Integer> getResourceCount(TbResource filter);
}

View File

@@ -190,4 +190,13 @@ public interface CourseService {
* @since 2025-10-28
*/
ResultDomain<CourseItemVO> getCourseProgress(String courseID);
/**
* @description 获取课程数量
* @param filter 过滤条件
* @return ResultDomain<Integer> 课程数量
* @author yslg
* @since 2025-11-14
*/
ResultDomain<Integer> getCourseCount(TbCourse filter);
}

View File

@@ -237,4 +237,13 @@ public interface LearningTaskService {
* @since 2025-10-30
*/
ResultDomain<Map<String, Object>> getTaskStatisticsRankings(String taskID);
/**
* @description 获取学习任务数量
* @param filter 过滤条件
* @return ResultDomain<Integer> 学习任务数量
* @author yslg
* @since 2025-11-14
*/
ResultDomain<Integer> getLearningTaskCount(TbLearningTask filter);
}

View File

@@ -18,6 +18,8 @@ import org.xyzh.common.core.domain.LoginDomain;
import org.xyzh.common.redis.service.RedisService;
import java.io.IOException;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -34,6 +36,11 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
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);
@Autowired
@@ -89,8 +96,17 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
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 {
logger.warn("Redis缓存中未找到用户登录信息userId: {}, 可能已过期或未登录", userId);
}

View File

@@ -28,6 +28,10 @@ public class BaseDTO implements Serializable{
*/
private Date createTime;
private Date startTime;
private Date endTime;
/**
* @description 更新时间
* @author yslg
@@ -94,6 +98,22 @@ public class BaseDTO implements Serializable{
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 获取更新时间
* @return Date 更新时间

View File

@@ -283,6 +283,18 @@ public class RedisService {
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操作-添加元素
* @param key String 键

View File

@@ -294,4 +294,29 @@ public class TimeUtils {
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()
);
}
}

View File

@@ -754,4 +754,19 @@ public class NCResourceServiceImpl implements ResourceService {
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;
}
}
}

View File

@@ -387,6 +387,12 @@
<if test="filter.isBanner != null">
AND r.is_banner = #{filter.isBanner}
</if>
<if test="filter.startTime != null">
AND r.publish_time &gt;= #{filter.startTime}
</if>
<if test="filter.endTime != null">
AND r.publish_time &lt;= #{filter.endTime}
</if>
</select>
<!-- updateResourceCollectCount -->

View File

@@ -550,14 +550,14 @@ public class SCCourseServiceImpl implements SCCourseService {
@Override
public ResultDomain<CourseItemVO> getCourseProgress(String courseID) {
ResultDomain<CourseItemVO> resultDomain = new ResultDomain<>();
// 获取当前用户
TbSysUser user = LoginUtil.getCurrentUser();
if (user == null) {
resultDomain.fail("用户未登录");
return resultDomain;
}
// 查询课程
TbCourse course = courseMapper.selectByCourseId(courseID);
if (course == null) {
@@ -572,25 +572,25 @@ public class SCCourseServiceImpl implements SCCourseService {
TbCourseChapter filter = new TbCourseChapter();
filter.setCourseID(courseID);
List<TbCourseChapter> chapters = courseChapterMapper.selectCourseChapters(filter);
// 查询并构建章节及节点结构(带进度)
if (!chapters.isEmpty()) {
List<String> chapterIDs = chapters.stream()
.map(TbCourseChapter::getChapterID)
.collect(Collectors.toList());
// 查询带进度的节点传入用户ID
List<CourseItemVO> nodesWithProgress = courseNodeMapper.selectNodesProgress(chapterIDs, user.getID());
// 转换章节为CourseItemVO列表
List<CourseItemVO> chapterVOs = chapters.stream()
.map(CourseItemVO::fromChapter)
.collect(Collectors.toList());
// 按章节ID分组节点
Map<String, List<CourseItemVO>> nodesMap = nodesWithProgress.stream()
.collect(Collectors.groupingBy(CourseItemVO::getChapterID));
// 设置章节列表和章节节点映射
courseItemVO.setChapters(chapterVOs);
courseItemVO.setChapterNodes(nodesMap);
@@ -599,4 +599,19 @@ public class SCCourseServiceImpl implements SCCourseService {
resultDomain.success("获取课程进度成功", courseItemVO);
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;
}
}

View File

@@ -796,15 +796,15 @@ public class SCLearningTaskServiceImpl implements LearningTaskService {
ResultDomain<Map<String, Object>> resultDomain = new ResultDomain<>();
try {
Map<String, Object> rankingsData = new HashMap<>();
// 获取完成时间排行榜前10名
List<Map<String, Object>> completionTimeRanking = taskUserMapper.getCompletionTimeRanking(taskID);
rankingsData.put("completionTimeRanking", completionTimeRanking);
// 获取学习时长排行榜前10名
List<Map<String, Object>> durationRanking = taskUserMapper.getStudyDurationRanking(taskID);
rankingsData.put("durationRanking", durationRanking);
resultDomain.success("获取任务排行榜数据成功", rankingsData);
} catch (Exception e) {
logger.error("获取任务排行榜数据失败", e);
@@ -812,4 +812,19 @@ public class SCLearningTaskServiceImpl implements LearningTaskService {
}
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;
}
}

View File

@@ -25,6 +25,16 @@
<artifactId>api-system</artifactId>
<version>1.0.0</version>
</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模块依赖 -->
<dependency>

View File

@@ -2,17 +2,34 @@ package org.xyzh.system.controller;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import 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 java.time.LocalDate;
import org.xyzh.common.dto.resource.TbResource;
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.List;
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 系统总览控制器
* @filename SystemOverviewController.java
@@ -25,53 +42,193 @@ import java.util.Map;
public class SystemOverviewController {
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")
public ResultDomain<Map<String, Object>> getSystemStatistics() {
// TODO: 后续接入真实统计数据(用户表、资源表、访问统计表等)
ResultDomain<Map<String, Object>> result = new ResultDomain<>();
Map<String, Object> data = new HashMap<>();
List<UserDeptRoleVO> voList = LoginUtil.getCurrentDeptRole();
// 顶部统计卡片总用户数、总资源数、今日PV、今日UV
// 1. 总用户数:直接统计用户表数量(后续可增加状态/逻辑删除过滤)
try {
TbSysUser filterUser = new TbSysUser();
// 顶部统计卡片:总用户数、总资源数、今日访问量、活跃用户
data.put("totalUsers", 1234);
data.put("totalUsersChange", "+12%");
long totalUsers = userMapper.countDeptUser(voList.get(0).getDeptID(), filterUser);
data.put("totalUsers", totalUsers);
filterUser.setEndTime(TimeUtils.getEndTimeOfYesterday());
long yesterdayUsers = userMapper.countDeptUser(voList.get(0).getDeptID(), filterUser);
// 目前未记录按天的用户总量,这里先返回"+0%",后续可根据用户注册表统计
data.put("totalUsersChange", formatPercentChange(totalUsers, yesterdayUsers));
data.put("totalResources", 5678);
data.put("totalResourcesChange", "+8%");
// 2. 总资源数:当前总量 & 与昨日总量对比
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);
data.put("activeUsersChange", "+5%");
// 昨日结束时的资源总数publish_time <= 昨日结束时间
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);
return result;
}
/**
* 获取活跃用户图表数据
* 获取活跃用户图表数据PV和UV
*/
@GetMapping("/active-users")
public ResultDomain<Map<String, Object>> getActiveUsersChart(
@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<>();
Map<String, Object> data = new HashMap<>();
// 默认展示7天
LocalDate startDate = LocalDate.parse(start);
LocalDate endDate = LocalDate.parse(end);
long days = startDate.until(endDate).getDays();
LocalDate today = LocalDate.now();
// X轴周一 ~ 周日(示例)
data.put("labels", List.of("周一", "周二", "周三", "周四", "周五", "周六", "周日"));
// Y轴数据活跃用户数量示例数据
data.put("values", List.of(120, 200, 150, 80, 70, 110, 130));
try {
// 按天遍历统计区间内的PV和UV数据
List<String> labels = new ArrayList<>();
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;
}
@@ -80,21 +237,36 @@ public class SystemOverviewController {
*/
@GetMapping("/resource-category-stats")
public ResultDomain<Map<String, Object>> getResourceCategoryStats() {
// TODO: 后续从资源表统计各类型资源数量
ResultDomain<Map<String, Object>> result = new ResultDomain<>();
Map<String, Object> data = new HashMap<>();
try {
Map<String, Object> data = new HashMap<>();
// 饼图数据:名称 + 数量
List<Map<String, Object>> categories = List.of(
createCategory("文章", 1048),
createCategory("视频", 735),
createCategory("音频", 580),
createCategory("课程", 484),
createCategory("其他", 300)
);
// 获取各类型资源数量
ResultDomain<Integer> resourceCountResult = resourceService.getResourceCount(new TbResource());
ResultDomain<Integer> courseCountResult = courseService.getCourseCount(new TbCourse());
ResultDomain<Integer> taskCountResult = learningTaskService.getLearningTaskCount(new TbLearningTask());
data.put("items", categories);
result.success("获取资源分类统计成功", data);
// 获取数量失败时默认为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(
createCategory("文章", resourceCount),
createCategory("课程", courseCount),
createCategory("学习任务", taskCount)
);
data.put("items", categories);
result.success("获取资源分类统计成功", data);
} catch (Exception e) {
logger.error("获取资源分类统计失败", e);
result.fail("获取资源分类统计失败: " + e.getMessage());
}
return result;
}
@@ -103,16 +275,76 @@ public class SystemOverviewController {
*/
@GetMapping("/today-visits")
public ResultDomain<Map<String, Object>> getTodayVisits() {
// TODO: 后续接入访问日志/统计表,计算今日指标
ResultDomain<Map<String, Object>> result = new ResultDomain<>();
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);
data.put("pv", 3456);
data.put("avgVisitDuration", "5分32秒");
data.put("bounceRate", "35.6%");
long uvFromRedis = 0L;
long pvFromRedis = 0L;
result.success("获取今日访问量统计成功", data);
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);
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")
public ResultDomain<Map<String, Object>> getSystemStatus() {
// TODO: 后续接入真实系统运行状态CPU、内存、服务可用性等
ResultDomain<Map<String, Object>> result = new ResultDomain<>();
Map<String, Object> data = new HashMap<>();
@@ -138,4 +369,36 @@ public class SystemOverviewController {
item.put("value", value);
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 + "%";
}
}

View File

@@ -192,7 +192,7 @@ public interface UserMapper extends BaseMapper<TbSysUser> {
UserVO selectUserInfoTotal(@Param("userId") String userId);
int countDeptUser(@Param("deptId") String deptId);
int countDeptUser(@Param("deptId") String deptId, @Param("filter") TbSysUser filter);
/**
* @description 查询部门及其子部门的所有用户ID

View File

@@ -282,7 +282,7 @@ public class SysUserServiceImpl implements SysUserService {
List<TbSysUser> users = userMapper.selectUserPage(filter, pageParam, userDeptRoles);
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.setPageParam(pageParam);
pageParam.setTotalElements(count);
@@ -299,7 +299,7 @@ public class SysUserServiceImpl implements SysUserService {
List<UserVO> userVOs = userMapper.selectUserVOPage(filter, pageParam, userDeptRoles);
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.setPageParam(pageParam);
pageParam.setTotalElements(count);

View File

@@ -95,24 +95,30 @@
<sql id="Filter_Clause">
<where>
deleted = 0
u.deleted = 0
<if test="filter.id != null and filter.id != ''">
AND id = #{filter.id}
AND u.id = #{filter.id}
</if>
<if test="filter.username != null and filter.username != ''">
AND username = #{filter.username}
AND u.username = #{filter.username}
</if>
<if test="filter.email != null and filter.email != ''">
AND email = #{filter.email}
AND u.email = #{filter.email}
</if>
<if test="filter.phone != null and filter.phone != ''">
AND phone = #{filter.phone}
AND u.phone = #{filter.phone}
</if>
<if test="filter.status != null">
AND status = #{filter.status}
AND u.status = #{filter.status}
</if>
<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 &gt;= #{filter.startTime}
</if>
<if test="filter.endTime != null">
AND u.create_time &lt; #{filter.endTime}
</if>
</where>
</sql>
@@ -575,7 +581,7 @@
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_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(
(SELECT dept_path FROM tb_sys_dept WHERE dept_id = #{deptId} AND deleted = 0),
'%'

View File

@@ -10,15 +10,16 @@ export interface SystemStatisticsDTO {
totalUsersChange: string;
totalResources: number;
totalResourcesChange: string;
todayVisits: number;
todayVisitsChange: string;
activeUsers: number;
activeUsersChange: string;
totalPv: number;
totalPvChange: string;
totalUv: number;
totalUvChange: string;
}
export interface ActiveUsersChartDTO {
labels: string[];
values: number[];
pvValues: number[];
uvValues: number[];
}
export interface ResourceCategoryItemDTO {
@@ -56,5 +57,16 @@ export const systemOverviewApi = {
async getTodayVisits(): Promise<ResultDomain<TodayVisitsDTO>> {
const response = await api.get<TodayVisitsDTO>('/system/overview/today-visits');
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;
}
};

View 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

View 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

View 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

View 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

View File

@@ -7,6 +7,7 @@
import type { Router, NavigationGuardNext, RouteLocationNormalized } from 'vue-router';
import type { Store } from 'vuex';
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) => {
// 结束页面加载进度条
finishProgress();
// 设置页面标题
setPageTitle(to.meta?.title as string);
// 统计PV对path以"/"开头的路由进行统计
if (to.path.startsWith('/')) {
systemOverviewApi.trackVisit().catch(() => {
// 统计失败不影响正常页面使用
});
}
});
// 全局解析守卫(在导航被确认之前,同时在所有组件内守卫和异步路由组件被解析之后调用)

View File

@@ -4,15 +4,55 @@
<!-- 统计卡片 -->
<div class="stats-grid">
<div class="stat-card" v-for="stat in statistics" :key="stat.label">
<div class="stat-icon" :style="{ background: stat.color }">
<i>{{ stat.icon }}</i>
</div>
<!-- 总用户 -->
<div class="stat-card">
<div class="stat-icon"><img src="@/assets/imgs/overview-user.svg" alt="用户" /></div>
<div class="stat-content">
<h3>{{ stat.value }}</h3>
<p>{{ stat.label }}</p>
<span class="stat-change" :class="stat.trend">
{{ stat.change }}
<h3>用户总数</h3>
<span>
{{ statistics.totalUsers }}
<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>
</div>
</div>
@@ -24,7 +64,7 @@
<el-card class="chart-card">
<template #header>
<div class="card-header">
<span>用户活跃度折线图</span>
<span>访问统计趋势PV/UV</span>
<el-date-picker
v-model="dateRange"
type="daterange"
@@ -49,26 +89,15 @@
</el-col>
</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>
</template>
<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 * as echarts from 'echarts';
import { systemOverviewApi } from '@/apis/system/overview';
import dayjs from 'dayjs';
const dateRange = ref<[Date, Date] | 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 pieChartInstance: echarts.ECharts | null = null;
const statistics = ref<{
icon: string;
label: string;
value: string | number;
change: string;
trend: 'up' | 'down';
color: string;
}[]>([]);
const visitStats = ref<{ label: string; value: string | number }[]>([]);
const statistics = ref({
totalUsers: 0,
totalUsersChange: '+0%',
totalResources: 0,
totalResourcesChange: '+0%',
totalPv: 0,
totalPvChange: '+0%',
totalUv: 0,
totalUvChange: '+0%'
});
onMounted(async () => {
// 默认选择最近 7 天
const now = new Date();
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
dateRange.value = [sevenDaysAgo, now];
initCharts();
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() {
try {
const [statRes, activeRes, pieRes, todayRes] = await Promise.all([
systemOverviewApi.getStatistics(),
systemOverviewApi.getActiveUsersChart('2025-10-15', '2025-10-21'),
systemOverviewApi.getActiveUsersChart(formatDate(dateRange.value?.[0]!), formatDate(dateRange.value?.[1]!)),
systemOverviewApi.getResourceCategoryStats(),
systemOverviewApi.getTodayVisits()
]);
if (statRes.success && statRes.data) {
const d = statRes.data;
statistics.value = [
{
icon: '👥',
label: '总用户数',
value: d.totalUsers,
change: d.totalUsersChange,
trend: d.totalUsersChange.startsWith('-') ? 'down' : 'up',
color: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
},
{
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%)'
}
];
statistics.value = {
totalUsers: d.totalUsers ?? 0,
totalUsersChange: String(d.totalUsersChange ?? '+0%'),
totalResources: d.totalResources ?? 0,
totalResourcesChange: String(d.totalResourcesChange ?? '+0%'),
totalPv: d.totalPv ?? 0,
totalPvChange: String(d.totalPvChange ?? '+0%'),
totalUv: d.totalUv ?? 0,
totalUvChange: String(d.totalUvChange ?? '+0%')
};
}
if (todayRes.success && todayRes.data) {
const t = todayRes.data;
visitStats.value = [
{ label: 'UV(独立访客)', value: t.uv },
{ label: 'PV(页面浏览量)', value: t.pv },
{ label: '平均访问时长', value: t.avgVisitDuration },
{ label: '跳出率', value: t.bounceRate }
];
// todayRes 仍用于“今日访问”区域(如后续需要),当前统计卡片只使用 statistics
}
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) {
@@ -180,14 +204,28 @@ function initCharts() {
}
}
function updateActivityChart(labels: string[], values: number[]) {
function updateActivityChart(labels: string[], pvValues: number[], uvValues: number[]) {
if (!activityChartInstance) return;
const option = {
tooltip: {
trigger: 'axis'
trigger: 'axis',
axisPointer: {
type: 'cross'
}
},
legend: {
data: ['页面访问量 (PV)', '独立访客数 (UV)'],
top: 10
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: labels
},
yAxis: {
@@ -195,10 +233,48 @@ function updateActivityChart(labels: string[], values: number[]) {
},
series: [
{
data: values,
name: '页面访问量 (PV)',
data: pvValues,
type: 'line',
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>
.system-overview {
padding: 16px 0;
}
.page-title {
@@ -241,56 +318,59 @@ function updatePieChart(items: { name: string; value: number }[]) {
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
margin-bottom: 20px;
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: white;
padding: 24px;
border-radius: 8px;
background: #ffffff;
padding: 20px 24px;
border-radius: 16px;
display: flex;
align-items: center;
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 {
width: 64px;
height: 64px;
border-radius: 12px;
width: 48px;
height: 48px;
border-radius: 20%;
background: #f5f7ff;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
flex-shrink: 0;
img {
width: 24px;
height: 24px;
}
}
.stat-content {
flex: 1;
h3 {
font-size: 14px;
font-weight: 500;
color: #64748b;
margin-bottom: 6px;
}
> span {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 28px;
font-weight: 600;
color: #141F38;
margin-bottom: 4px;
}
p {
font-size: 14px;
color: #666;
margin-bottom: 4px;
}
}
color: #0f172a;
.stat-change {
font-size: 13px;
&.up {
color: #4caf50;
}
&.down {
color: #f44336;
> span {
font-size: 12px;
font-weight: 400;
color: #16a34a; // 默认上升为绿色
}
}
}