数据统计
This commit is contained in:
@@ -5,6 +5,7 @@ import org.xyzh.common.dto.study.TbLearningRecord;
|
|||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description 学习记录服务接口
|
* @description 学习记录服务接口
|
||||||
@@ -110,4 +111,22 @@ public interface LearningRecordService {
|
|||||||
* @since 2025-10-24
|
* @since 2025-10-24
|
||||||
*/
|
*/
|
||||||
ResultDomain<TbLearningRecord> getCourseLearningRecord(TbLearningRecord learningRecord);
|
ResultDomain<TbLearningRecord> getCourseLearningRecord(TbLearningRecord learningRecord);
|
||||||
|
|
||||||
|
// ----------------学习记录统计相关--------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 获取学习记录统计图表数据
|
||||||
|
* @return ResultDomain<Map<String, Object>> 图表数据(本周课程和文章的总学习时长)
|
||||||
|
* @author yslg
|
||||||
|
* @since 2025-10-30
|
||||||
|
*/
|
||||||
|
ResultDomain<Map<String, Object>> getStudyRecordsCharts();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 获取学习记录排行榜数据
|
||||||
|
* @return ResultDomain<Map<String, Object>> 排行榜数据(学习时长排行榜、课程排行榜、文章排行榜、任务完成排行榜)
|
||||||
|
* @author yslg
|
||||||
|
* @since 2025-10-30
|
||||||
|
*/
|
||||||
|
ResultDomain<Map<String, Object>> getStudyRecordsRankings();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import org.xyzh.common.vo.TaskVO;
|
|||||||
import org.xyzh.common.dto.study.TbTaskItem;
|
import org.xyzh.common.dto.study.TbTaskItem;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description 学习任务服务接口
|
* @description 学习任务服务接口
|
||||||
@@ -216,4 +217,24 @@ public interface LearningTaskService {
|
|||||||
* @since 2025-10-15
|
* @since 2025-10-15
|
||||||
*/
|
*/
|
||||||
ResultDomain<Boolean> removeTaskResource(String taskID, String resourceID);
|
ResultDomain<Boolean> removeTaskResource(String taskID, String resourceID);
|
||||||
|
|
||||||
|
// ----------------任务统计相关--------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 获取任务统计图表数据
|
||||||
|
* @param taskID 任务ID
|
||||||
|
* @return ResultDomain<Map<String, Object>> 图表数据(学习时长分布、学习进度分布)
|
||||||
|
* @author yslg
|
||||||
|
* @since 2025-10-30
|
||||||
|
*/
|
||||||
|
ResultDomain<Map<String, Object>> getTaskStatisticsCharts(String taskID);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 获取任务排行榜数据
|
||||||
|
* @param taskID 任务ID
|
||||||
|
* @return ResultDomain<Map<String, Object>> 排行榜数据(完成时间排行榜、学习时长排行榜)
|
||||||
|
* @author yslg
|
||||||
|
* @since 2025-10-30
|
||||||
|
*/
|
||||||
|
ResultDomain<Map<String, Object>> getTaskStatisticsRankings(String taskID);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import org.springframework.web.bind.annotation.PutMapping;
|
|||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description 学习记录控制器
|
* @description 学习记录控制器
|
||||||
@@ -72,4 +74,22 @@ public class LearningRecordController {
|
|||||||
|
|
||||||
return learningRecordService.getCourseLearningRecord(learningRecord);
|
return learningRecordService.getCourseLearningRecord(learningRecord);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取学习记录统计图表数据
|
||||||
|
* @return 图表数据(本周课程和文章的总学习时长)
|
||||||
|
*/
|
||||||
|
@GetMapping("/statistics/charts")
|
||||||
|
public ResultDomain<Map<String, Object>> getStudyRecordsCharts() {
|
||||||
|
return learningRecordService.getStudyRecordsCharts();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取学习记录排行榜数据
|
||||||
|
* @return 排行榜数据(学习时长排行榜、课程排行榜、文章排行榜、任务完成排行榜)
|
||||||
|
*/
|
||||||
|
@GetMapping("/statistics/rankings")
|
||||||
|
public ResultDomain<Map<String, Object>> getStudyRecordsRankings() {
|
||||||
|
return learningRecordService.getStudyRecordsRankings();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,6 +160,24 @@ public class LearningTaskController {
|
|||||||
return learningTaskService.getUserProgress(userID);
|
return learningTaskService.getUserProgress(userID);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取任务统计图表数据
|
||||||
|
* @param taskID 任务ID
|
||||||
|
* @return 包含学习时长分布和学习进度分布的Map数据
|
||||||
|
*/
|
||||||
|
@GetMapping("/{taskID}/statistics/charts")
|
||||||
|
public ResultDomain<Map<String, Object>> getTaskStatisticsCharts(@PathVariable("taskID") String taskID) {
|
||||||
|
return learningTaskService.getTaskStatisticsCharts(taskID);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取任务排行榜数据
|
||||||
|
* @param taskID 任务ID
|
||||||
|
* @return 包含完成时间排行榜和学习时长排行榜的Map数据
|
||||||
|
*/
|
||||||
|
@GetMapping("/{taskID}/statistics/rankings")
|
||||||
|
public ResultDomain<Map<String, Object>> getTaskStatisticsRankings(@PathVariable("taskID") String taskID) {
|
||||||
|
return learningTaskService.getTaskStatisticsRankings(taskID);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import org.apache.ibatis.annotations.Mapper;
|
|||||||
import org.apache.ibatis.annotations.Param;
|
import org.apache.ibatis.annotations.Param;
|
||||||
import org.xyzh.common.core.page.PageParam;
|
import org.xyzh.common.core.page.PageParam;
|
||||||
import org.xyzh.common.dto.study.TbLearningRecord;
|
import org.xyzh.common.dto.study.TbLearningRecord;
|
||||||
|
import org.xyzh.common.vo.UserDeptRoleVO;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description LearningRecordMapper.java文件描述 学习记录数据访问层
|
* @description LearningRecordMapper.java文件描述 学习记录数据访问层
|
||||||
@@ -145,4 +147,51 @@ public interface LearningRecordMapper extends BaseMapper<TbLearningRecord> {
|
|||||||
* @since 2025-10-15
|
* @since 2025-10-15
|
||||||
*/
|
*/
|
||||||
long countLearningRecords(@Param("filter") TbLearningRecord filter);
|
long countLearningRecords(@Param("filter") TbLearningRecord filter);
|
||||||
|
|
||||||
|
// ----------------学习记录统计相关--------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 获取本周课程和文章的总学习时长统计
|
||||||
|
* @param userDeptRoles 用户部门角色信息
|
||||||
|
* @return List<Map<String, Object>> 学习时长统计数据
|
||||||
|
* @author yslg
|
||||||
|
* @since 2025-10-30
|
||||||
|
*/
|
||||||
|
List<Map<String, Object>> getWeeklyStudyDurationByType(@Param("userDeptRoles") List<UserDeptRoleVO> userDeptRoles);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 获取本周学习时长排行榜
|
||||||
|
* @param userDeptRoles 用户部门角色信息
|
||||||
|
* @return List<Map<String, Object>> 学习时长排行榜
|
||||||
|
* @author yslg
|
||||||
|
* @since 2025-10-30
|
||||||
|
*/
|
||||||
|
List<Map<String, Object>> getWeeklyStudyDurationRanking(@Param("userDeptRoles") List<UserDeptRoleVO> userDeptRoles);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 获取本周学习课程排行榜
|
||||||
|
* @param userDeptRoles 用户部门角色信息
|
||||||
|
* @return List<Map<String, Object>> 课程学习排行榜
|
||||||
|
* @author yslg
|
||||||
|
* @since 2025-10-30
|
||||||
|
*/
|
||||||
|
List<Map<String, Object>> getWeeklyCourseRanking(@Param("userDeptRoles") List<UserDeptRoleVO> userDeptRoles);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 获取本周学习文章排行榜
|
||||||
|
* @param userDeptRoles 用户部门角色信息
|
||||||
|
* @return List<Map<String, Object>> 文章学习排行榜
|
||||||
|
* @author yslg
|
||||||
|
* @since 2025-10-30
|
||||||
|
*/
|
||||||
|
List<Map<String, Object>> getWeeklyArticleRanking(@Param("userDeptRoles") List<UserDeptRoleVO> userDeptRoles);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 获取本周任务完成最多的排行榜
|
||||||
|
* @param userDeptRoles 用户部门角色信息
|
||||||
|
* @return List<Map<String, Object>> 任务完成排行榜
|
||||||
|
* @author yslg
|
||||||
|
* @since 2025-10-30
|
||||||
|
*/
|
||||||
|
List<Map<String, Object>> getWeeklyTaskCompletionRanking(@Param("userDeptRoles") List<UserDeptRoleVO> userDeptRoles);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import org.xyzh.common.dto.study.TbTaskUser;
|
|||||||
import org.xyzh.common.vo.TaskItemVO;
|
import org.xyzh.common.vo.TaskItemVO;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description TaskUserMapper.java文件描述 任务用户数据访问层
|
* @description TaskUserMapper.java文件描述 任务用户数据访问层
|
||||||
@@ -179,4 +180,40 @@ public interface TaskUserMapper extends BaseMapper<TbTaskUser> {
|
|||||||
* @since 2025-10-15
|
* @since 2025-10-15
|
||||||
*/
|
*/
|
||||||
long countTaskUsers(@Param("filter") TbTaskUser filter);
|
long countTaskUsers(@Param("filter") TbTaskUser filter);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 获取任务学习时长分布数据
|
||||||
|
* @param taskId 任务ID
|
||||||
|
* @return List<Map<String, Object>> 学习时长分布数据
|
||||||
|
* @author yslg
|
||||||
|
* @since 2025-10-30
|
||||||
|
*/
|
||||||
|
List<Map<String, Object>> getStudyDurationDistribution(@Param("taskId") String taskId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 获取任务学习进度分布数据
|
||||||
|
* @param taskId 任务ID
|
||||||
|
* @return List<Map<String, Object>> 学习进度分布数据
|
||||||
|
* @author yslg
|
||||||
|
* @since 2025-10-30
|
||||||
|
*/
|
||||||
|
List<Map<String, Object>> getStudyProgressDistribution(@Param("taskId") String taskId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 获取任务完成时间排行榜(前10名)
|
||||||
|
* @param taskId 任务ID
|
||||||
|
* @return List<Map<String, Object>> 完成时间排行榜
|
||||||
|
* @author yslg
|
||||||
|
* @since 2025-10-30
|
||||||
|
*/
|
||||||
|
List<Map<String, Object>> getCompletionTimeRanking(@Param("taskId") String taskId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 获取任务学习时长排行榜(前10名)
|
||||||
|
* @param taskId 任务ID
|
||||||
|
* @return List<Map<String, Object>> 学习时长排行榜
|
||||||
|
* @author yslg
|
||||||
|
* @since 2025-10-30
|
||||||
|
*/
|
||||||
|
List<Map<String, Object>> getStudyDurationRanking(@Param("taskId") String taskId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package org.xyzh.study.service.impl;
|
|||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -15,6 +17,7 @@ import org.xyzh.common.dto.study.TbTaskItem;
|
|||||||
import org.xyzh.common.dto.study.TbTaskUser;
|
import org.xyzh.common.dto.study.TbTaskUser;
|
||||||
import org.xyzh.common.dto.user.TbSysUser;
|
import org.xyzh.common.dto.user.TbSysUser;
|
||||||
import org.xyzh.common.vo.TaskItemVO;
|
import org.xyzh.common.vo.TaskItemVO;
|
||||||
|
import org.xyzh.common.vo.UserDeptRoleVO;
|
||||||
import org.xyzh.api.study.record.LearningRecordService;
|
import org.xyzh.api.study.record.LearningRecordService;
|
||||||
import org.xyzh.study.mapper.LearningRecordMapper;
|
import org.xyzh.study.mapper.LearningRecordMapper;
|
||||||
import org.xyzh.study.mapper.TaskItemMapper;
|
import org.xyzh.study.mapper.TaskItemMapper;
|
||||||
@@ -272,8 +275,57 @@ public class SCLearningRecordServiceImpl implements LearningRecordService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResultDomain<Map<String, Object>> getStudyRecordsCharts() {
|
||||||
|
ResultDomain<Map<String, Object>> resultDomain = new ResultDomain<>();
|
||||||
|
try {
|
||||||
|
Map<String, Object> chartsData = new HashMap<>();
|
||||||
|
|
||||||
|
// 获取当前用户的部门角色信息
|
||||||
|
List<UserDeptRoleVO> userDeptRoles = LoginUtil.getCurrentDeptRole();
|
||||||
|
|
||||||
|
// 获取本周课程和文章的总学习时长统计
|
||||||
|
List<Map<String, Object>> durationByType = learningRecordMapper.getWeeklyStudyDurationByType(userDeptRoles);
|
||||||
|
chartsData.put("durationByType", durationByType);
|
||||||
|
|
||||||
|
resultDomain.success("获取学习记录图表数据成功", chartsData);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("获取学习记录图表数据失败", e);
|
||||||
|
resultDomain.fail("获取学习记录图表数据失败:" + e.getMessage());
|
||||||
|
}
|
||||||
|
return resultDomain;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResultDomain<Map<String, Object>> getStudyRecordsRankings() {
|
||||||
|
ResultDomain<Map<String, Object>> resultDomain = new ResultDomain<>();
|
||||||
|
try {
|
||||||
|
Map<String, Object> rankingsData = new HashMap<>();
|
||||||
|
|
||||||
|
// 获取当前用户的部门角色信息
|
||||||
|
List<UserDeptRoleVO> userDeptRoles = LoginUtil.getCurrentDeptRole();
|
||||||
|
|
||||||
|
// 获取本周学习时长排行榜
|
||||||
|
List<Map<String, Object>> durationRanking = learningRecordMapper.getWeeklyStudyDurationRanking(userDeptRoles);
|
||||||
|
rankingsData.put("durationRanking", durationRanking);
|
||||||
|
|
||||||
|
// 获取本周学习课程排行榜
|
||||||
|
List<Map<String, Object>> courseRanking = learningRecordMapper.getWeeklyCourseRanking(userDeptRoles);
|
||||||
|
rankingsData.put("courseRanking", courseRanking);
|
||||||
|
|
||||||
|
// 获取本周学习文章排行榜
|
||||||
|
List<Map<String, Object>> articleRanking = learningRecordMapper.getWeeklyArticleRanking(userDeptRoles);
|
||||||
|
rankingsData.put("articleRanking", articleRanking);
|
||||||
|
|
||||||
|
// 获取本周任务完成最多的排行榜
|
||||||
|
List<Map<String, Object>> taskCompletionRanking = learningRecordMapper.getWeeklyTaskCompletionRanking(userDeptRoles);
|
||||||
|
rankingsData.put("taskCompletionRanking", taskCompletionRanking);
|
||||||
|
|
||||||
|
resultDomain.success("获取学习记录排行榜数据成功", rankingsData);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("获取学习记录排行榜数据失败", e);
|
||||||
|
resultDomain.fail("获取学习记录排行榜数据失败:" + e.getMessage());
|
||||||
|
}
|
||||||
|
return resultDomain;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package org.xyzh.study.service.impl;
|
|||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -690,4 +691,48 @@ public class SCLearningTaskServiceImpl implements LearningTaskService {
|
|||||||
resultDomain.success("获取用户整体学习进度成功", taskVO);
|
resultDomain.success("获取用户整体学习进度成功", taskVO);
|
||||||
return resultDomain;
|
return resultDomain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResultDomain<Map<String, Object>> getTaskStatisticsCharts(String taskID) {
|
||||||
|
ResultDomain<Map<String, Object>> resultDomain = new ResultDomain<>();
|
||||||
|
try {
|
||||||
|
Map<String, Object> chartsData = new HashMap<>();
|
||||||
|
|
||||||
|
// 获取学习时长分布数据
|
||||||
|
List<Map<String, Object>> durationDistribution = taskUserMapper.getStudyDurationDistribution(taskID);
|
||||||
|
chartsData.put("durationDistribution", durationDistribution);
|
||||||
|
|
||||||
|
// 获取学习进度分布数据
|
||||||
|
List<Map<String, Object>> progressDistribution = taskUserMapper.getStudyProgressDistribution(taskID);
|
||||||
|
chartsData.put("progressDistribution", progressDistribution);
|
||||||
|
|
||||||
|
resultDomain.success("获取任务统计图表数据成功", chartsData);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("获取任务统计图表数据失败", e);
|
||||||
|
resultDomain.fail("获取任务统计图表数据失败:" + e.getMessage());
|
||||||
|
}
|
||||||
|
return resultDomain;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResultDomain<Map<String, Object>> getTaskStatisticsRankings(String taskID) {
|
||||||
|
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);
|
||||||
|
resultDomain.fail("获取任务排行榜数据失败:" + e.getMessage());
|
||||||
|
}
|
||||||
|
return resultDomain;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,4 +204,157 @@
|
|||||||
<include refid="Where_Clause" />
|
<include refid="Where_Clause" />
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<!-- 获取本周课程和文章的总学习时长统计 -->
|
||||||
|
<select id="getWeeklyStudyDurationByType" resultType="map">
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN lr.resource_type = 2 THEN '课程'
|
||||||
|
WHEN lr.resource_type = 1 THEN '文章'
|
||||||
|
ELSE '其他'
|
||||||
|
END AS resourceType,
|
||||||
|
COUNT(DISTINCT lr.user_id) AS userCount,
|
||||||
|
COALESCE(SUM(lr.duration), 0) AS totalDuration,
|
||||||
|
COUNT(*) AS studyCount
|
||||||
|
FROM tb_learning_record lr
|
||||||
|
INNER JOIN tb_sys_user u ON lr.user_id = u.id AND u.deleted = 0
|
||||||
|
<if test="userDeptRoles != null and userDeptRoles.size() > 0">
|
||||||
|
INNER JOIN tb_sys_user_dept_role udr ON u.id = udr.user_id AND udr.deleted = 0
|
||||||
|
INNER JOIN tb_sys_dept user_dept ON udr.dept_id = user_dept.dept_id AND user_dept.deleted = 0
|
||||||
|
INNER JOIN (
|
||||||
|
SELECT dept_id, dept_path FROM tb_sys_dept WHERE deleted = 0 AND dept_id IN (
|
||||||
|
<foreach collection="userDeptRoles" item="udr" separator=",">
|
||||||
|
#{udr.deptID}
|
||||||
|
</foreach>
|
||||||
|
)
|
||||||
|
) current_dept ON user_dept.dept_path LIKE CONCAT(current_dept.dept_path, '%')
|
||||||
|
</if>
|
||||||
|
WHERE lr.deleted = 0
|
||||||
|
AND lr.last_learn_time >= DATE_SUB(CURDATE(), INTERVAL DAYOFWEEK(CURDATE())-1 DAY)
|
||||||
|
AND lr.last_learn_time < DATE_ADD(DATE_SUB(CURDATE(), INTERVAL DAYOFWEEK(CURDATE())-1 DAY), INTERVAL 7 DAY)
|
||||||
|
GROUP BY lr.resource_type
|
||||||
|
ORDER BY totalDuration DESC
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 获取本周学习时长排行榜 -->
|
||||||
|
<select id="getWeeklyStudyDurationRanking" resultType="map">
|
||||||
|
SELECT
|
||||||
|
lr.user_id AS userId,
|
||||||
|
u.username AS username,
|
||||||
|
COALESCE(SUM(lr.duration), 0) AS totalDuration,
|
||||||
|
COUNT(DISTINCT lr.resource_id) AS resourceCount,
|
||||||
|
COUNT(*) AS studyCount
|
||||||
|
FROM tb_learning_record lr
|
||||||
|
INNER JOIN tb_sys_user u ON lr.user_id = u.id AND u.deleted = 0
|
||||||
|
<if test="userDeptRoles != null and userDeptRoles.size() > 0">
|
||||||
|
INNER JOIN tb_sys_user_dept_role udr ON u.id = udr.user_id AND udr.deleted = 0
|
||||||
|
INNER JOIN tb_sys_dept user_dept ON udr.dept_id = user_dept.dept_id AND user_dept.deleted = 0
|
||||||
|
INNER JOIN (
|
||||||
|
SELECT dept_id, dept_path FROM tb_sys_dept WHERE deleted = 0 AND dept_id IN (
|
||||||
|
<foreach collection="userDeptRoles" item="udr" separator=",">
|
||||||
|
#{udr.deptID}
|
||||||
|
</foreach>
|
||||||
|
)
|
||||||
|
) current_dept ON user_dept.dept_path LIKE CONCAT(current_dept.dept_path, '%')
|
||||||
|
</if>
|
||||||
|
WHERE lr.deleted = 0
|
||||||
|
AND lr.last_learn_time >= DATE_SUB(CURDATE(), INTERVAL DAYOFWEEK(CURDATE())-1 DAY)
|
||||||
|
AND lr.last_learn_time < DATE_ADD(DATE_SUB(CURDATE(), INTERVAL DAYOFWEEK(CURDATE())-1 DAY), INTERVAL 7 DAY)
|
||||||
|
GROUP BY lr.user_id, u.username
|
||||||
|
ORDER BY totalDuration DESC
|
||||||
|
LIMIT 10
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 获取本周学习课程排行榜 -->
|
||||||
|
<select id="getWeeklyCourseRanking" resultType="map">
|
||||||
|
SELECT
|
||||||
|
lr.resource_id AS resourceId,
|
||||||
|
c.name AS resourceName,
|
||||||
|
COUNT(DISTINCT lr.user_id) AS learnerCount,
|
||||||
|
COALESCE(SUM(lr.duration), 0) AS totalDuration,
|
||||||
|
COUNT(*) AS studyCount
|
||||||
|
FROM tb_learning_record lr
|
||||||
|
INNER JOIN tb_course c ON lr.resource_id = c.course_id AND c.deleted = 0
|
||||||
|
INNER JOIN tb_sys_user u ON lr.user_id = u.id AND u.deleted = 0
|
||||||
|
<if test="userDeptRoles != null and userDeptRoles.size() > 0">
|
||||||
|
INNER JOIN tb_sys_user_dept_role udr ON u.id = udr.user_id AND udr.deleted = 0
|
||||||
|
INNER JOIN tb_sys_dept user_dept ON udr.dept_id = user_dept.dept_id AND user_dept.deleted = 0
|
||||||
|
INNER JOIN (
|
||||||
|
SELECT dept_id, dept_path FROM tb_sys_dept WHERE deleted = 0 AND dept_id IN (
|
||||||
|
<foreach collection="userDeptRoles" item="udr" separator=",">
|
||||||
|
#{udr.deptID}
|
||||||
|
</foreach>
|
||||||
|
)
|
||||||
|
) current_dept ON user_dept.dept_path LIKE CONCAT(current_dept.dept_path, '%')
|
||||||
|
</if>
|
||||||
|
WHERE lr.deleted = 0
|
||||||
|
AND lr.resource_type = 2
|
||||||
|
AND lr.last_learn_time >= DATE_SUB(CURDATE(), INTERVAL DAYOFWEEK(CURDATE())-1 DAY)
|
||||||
|
AND lr.last_learn_time < DATE_ADD(DATE_SUB(CURDATE(), INTERVAL DAYOFWEEK(CURDATE())-1 DAY), INTERVAL 7 DAY)
|
||||||
|
GROUP BY lr.resource_id, c.name
|
||||||
|
ORDER BY learnerCount DESC, totalDuration DESC
|
||||||
|
LIMIT 10
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 获取本周学习文章排行榜 -->
|
||||||
|
<select id="getWeeklyArticleRanking" resultType="map">
|
||||||
|
SELECT
|
||||||
|
lr.resource_id AS resourceId,
|
||||||
|
r.title AS resourceName,
|
||||||
|
COUNT(DISTINCT lr.user_id) AS learnerCount,
|
||||||
|
COALESCE(SUM(lr.duration), 0) AS totalDuration,
|
||||||
|
COUNT(*) AS studyCount
|
||||||
|
FROM tb_learning_record lr
|
||||||
|
INNER JOIN tb_resource r ON lr.resource_id = r.resource_id AND r.deleted = 0
|
||||||
|
INNER JOIN tb_sys_user u ON lr.user_id = u.id AND u.deleted = 0
|
||||||
|
<if test="userDeptRoles != null and userDeptRoles.size() > 0">
|
||||||
|
INNER JOIN tb_sys_user_dept_role udr ON u.id = udr.user_id AND udr.deleted = 0
|
||||||
|
INNER JOIN tb_sys_dept user_dept ON udr.dept_id = user_dept.dept_id AND user_dept.deleted = 0
|
||||||
|
INNER JOIN (
|
||||||
|
SELECT dept_id, dept_path FROM tb_sys_dept WHERE deleted = 0 AND dept_id IN (
|
||||||
|
<foreach collection="userDeptRoles" item="udr" separator=",">
|
||||||
|
#{udr.deptID}
|
||||||
|
</foreach>
|
||||||
|
)
|
||||||
|
) current_dept ON user_dept.dept_path LIKE CONCAT(current_dept.dept_path, '%')
|
||||||
|
</if>
|
||||||
|
WHERE lr.deleted = 0
|
||||||
|
AND lr.resource_type = 1
|
||||||
|
AND lr.last_learn_time >= DATE_SUB(CURDATE(), INTERVAL DAYOFWEEK(CURDATE())-1 DAY)
|
||||||
|
AND lr.last_learn_time < DATE_ADD(DATE_SUB(CURDATE(), INTERVAL DAYOFWEEK(CURDATE())-1 DAY), INTERVAL 7 DAY)
|
||||||
|
GROUP BY lr.resource_id, r.title
|
||||||
|
ORDER BY learnerCount DESC, totalDuration DESC
|
||||||
|
LIMIT 10
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 获取本周任务完成最多的排行榜 -->
|
||||||
|
<select id="getWeeklyTaskCompletionRanking" resultType="map">
|
||||||
|
SELECT
|
||||||
|
tu.user_id AS userId,
|
||||||
|
u.username AS username,
|
||||||
|
COUNT(DISTINCT tu.task_id) AS completedTaskCount,
|
||||||
|
COALESCE(SUM(lr.duration), 0) AS totalDuration,
|
||||||
|
MAX(tu.complete_time) AS lastCompleteTime
|
||||||
|
FROM tb_task_user tu
|
||||||
|
INNER JOIN tb_sys_user u ON tu.user_id = u.id AND u.deleted = 0
|
||||||
|
<if test="userDeptRoles != null and userDeptRoles.size() > 0">
|
||||||
|
INNER JOIN tb_sys_user_dept_role udr ON u.id = udr.user_id AND udr.deleted = 0
|
||||||
|
INNER JOIN tb_sys_dept user_dept ON udr.dept_id = user_dept.dept_id AND user_dept.deleted = 0
|
||||||
|
INNER JOIN (
|
||||||
|
SELECT dept_id, dept_path FROM tb_sys_dept WHERE deleted = 0 AND dept_id IN (
|
||||||
|
<foreach collection="userDeptRoles" item="udr" separator=",">
|
||||||
|
#{udr.deptID}
|
||||||
|
</foreach>
|
||||||
|
)
|
||||||
|
) current_dept ON user_dept.dept_path LIKE CONCAT(current_dept.dept_path, '%')
|
||||||
|
</if>
|
||||||
|
LEFT JOIN tb_learning_record lr ON tu.user_id = lr.user_id AND tu.task_id = lr.task_id AND lr.deleted = 0
|
||||||
|
WHERE tu.deleted = 0
|
||||||
|
AND tu.status = 2
|
||||||
|
AND tu.complete_time >= DATE_SUB(CURDATE(), INTERVAL DAYOFWEEK(CURDATE())-1 DAY)
|
||||||
|
AND tu.complete_time < DATE_ADD(DATE_SUB(CURDATE(), INTERVAL DAYOFWEEK(CURDATE())-1 DAY), INTERVAL 7 DAY)
|
||||||
|
GROUP BY tu.user_id, u.username
|
||||||
|
ORDER BY completedTaskCount DESC, totalDuration DESC
|
||||||
|
LIMIT 10
|
||||||
|
</select>
|
||||||
|
|
||||||
</mapper>
|
</mapper>
|
||||||
|
|||||||
@@ -263,4 +263,84 @@
|
|||||||
<include refid="Where_Clause" />
|
<include refid="Where_Clause" />
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<!-- 获取任务学习时长分布数据 -->
|
||||||
|
<select id="getStudyDurationDistribution" resultType="map">
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN total_duration < 600 THEN '0-10分钟'
|
||||||
|
WHEN total_duration < 1800 THEN '10-30分钟'
|
||||||
|
WHEN total_duration < 3600 THEN '30-60分钟'
|
||||||
|
WHEN total_duration < 7200 THEN '1-2小时'
|
||||||
|
WHEN total_duration < 14400 THEN '2-4小时'
|
||||||
|
ELSE '4小时以上'
|
||||||
|
END AS durationRange,
|
||||||
|
COUNT(*) AS userCount
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
lh.user_id,
|
||||||
|
COALESCE(SUM(lh.duration), 0) AS total_duration
|
||||||
|
FROM tb_task_user tu
|
||||||
|
LEFT JOIN tb_learning_history lh ON tu.user_id = lh.user_id AND tu.task_id = lh.task_id AND lh.deleted = 0
|
||||||
|
WHERE tu.task_id = #{taskId} AND tu.deleted = 0
|
||||||
|
GROUP BY lh.user_id
|
||||||
|
) AS user_durations
|
||||||
|
GROUP BY durationRange
|
||||||
|
ORDER BY MIN(total_duration)
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 获取任务学习进度分布数据 -->
|
||||||
|
<select id="getStudyProgressDistribution" resultType="map">
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN progress < 10 THEN '0-10%'
|
||||||
|
WHEN progress < 20 THEN '10-20%'
|
||||||
|
WHEN progress < 30 THEN '20-30%'
|
||||||
|
WHEN progress < 40 THEN '30-40%'
|
||||||
|
WHEN progress < 50 THEN '40-50%'
|
||||||
|
WHEN progress < 60 THEN '50-60%'
|
||||||
|
WHEN progress < 70 THEN '60-70%'
|
||||||
|
WHEN progress < 80 THEN '70-80%'
|
||||||
|
WHEN progress < 90 THEN '80-90%'
|
||||||
|
WHEN progress < 100 THEN '90-100%'
|
||||||
|
ELSE '100%'
|
||||||
|
END AS progressRange,
|
||||||
|
COUNT(*) AS userCount
|
||||||
|
FROM tb_task_user
|
||||||
|
WHERE task_id = #{taskId} AND deleted = 0
|
||||||
|
GROUP BY progressRange
|
||||||
|
ORDER BY MIN(progress)
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 获取任务完成时间排行榜(前10名) -->
|
||||||
|
<select id="getCompletionTimeRanking" resultType="map">
|
||||||
|
SELECT
|
||||||
|
tu.user_id AS userId,
|
||||||
|
u.username AS username,
|
||||||
|
tu.complete_time AS completeTime,
|
||||||
|
TIMESTAMPDIFF(SECOND, tu.create_time, tu.complete_time) AS completionDuration
|
||||||
|
FROM tb_task_user tu
|
||||||
|
INNER JOIN tb_sys_user u ON tu.user_id = u.id AND u.deleted = 0
|
||||||
|
WHERE tu.task_id = #{taskId}
|
||||||
|
AND tu.status = 2
|
||||||
|
AND tu.complete_time IS NOT NULL
|
||||||
|
AND tu.deleted = 0
|
||||||
|
ORDER BY tu.complete_time ASC
|
||||||
|
LIMIT 10
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 获取任务学习时长排行榜(前10名) -->
|
||||||
|
<select id="getStudyDurationRanking" resultType="map">
|
||||||
|
SELECT
|
||||||
|
lh.user_id AS userId,
|
||||||
|
u.username AS username,
|
||||||
|
COALESCE(SUM(lh.duration), 0) AS totalDuration
|
||||||
|
FROM tb_task_user tu
|
||||||
|
INNER JOIN tb_sys_user u ON tu.user_id = u.id AND u.deleted = 0
|
||||||
|
LEFT JOIN tb_learning_history lh ON tu.user_id = lh.user_id AND tu.task_id = lh.task_id AND lh.deleted = 0
|
||||||
|
WHERE tu.task_id = #{taskId} AND tu.deleted = 0
|
||||||
|
GROUP BY lh.user_id, u.username
|
||||||
|
ORDER BY totalDuration DESC
|
||||||
|
LIMIT 10
|
||||||
|
</select>
|
||||||
|
|
||||||
</mapper>
|
</mapper>
|
||||||
|
|||||||
@@ -74,4 +74,22 @@ export const learningRecordApi = {
|
|||||||
const response = await api.post<LearningRecord>('/study/records/course/records', filter);
|
const response = await api.post<LearningRecord>('/study/records/course/records', filter);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取学习记录统计图表数据
|
||||||
|
* @returns Promise<ResultDomain<any>> 图表数据(本周课程和文章的总学习时长)
|
||||||
|
*/
|
||||||
|
async getStudyRecordsCharts(): Promise<ResultDomain<any>> {
|
||||||
|
const response = await api.get<any>('/study/records/statistics/charts');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取学习记录排行榜数据
|
||||||
|
* @returns Promise<ResultDomain<any>> 排行榜数据(学习时长排行榜、课程排行榜、文章排行榜、任务完成排行榜)
|
||||||
|
*/
|
||||||
|
async getStudyRecordsRankings(): Promise<ResultDomain<any>> {
|
||||||
|
const response = await api.get<any>('/study/records/statistics/rankings');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -152,4 +152,24 @@ export const learningTaskApi = {
|
|||||||
const response = await api.post<TaskVO>(`${this.learningTaskPrefix}/user/progress/${userID}`);
|
const response = await api.post<TaskVO>(`${this.learningTaskPrefix}/user/progress/${userID}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取任务统计图表数据
|
||||||
|
* @param taskID 任务ID
|
||||||
|
* @returns Promise<ResultDomain<any>> 图表数据(学习时长分布、学习进度分布)
|
||||||
|
*/
|
||||||
|
async getTaskStatisticsCharts(taskID: string): Promise<ResultDomain<any>> {
|
||||||
|
const response = await api.get<any>(`${this.learningTaskPrefix}/${taskID}/statistics/charts`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取任务排行榜数据
|
||||||
|
* @param taskID 任务ID
|
||||||
|
* @returns Promise<ResultDomain<any>> 排行榜数据(完成时间排行榜、学习时长排行榜)
|
||||||
|
*/
|
||||||
|
async getTaskStatisticsRankings(taskID: string): Promise<ResultDomain<any>> {
|
||||||
|
const response = await api.get<any>(`${this.learningTaskPrefix}/${taskID}/statistics/rankings`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ $spacing-xxl: 24px;
|
|||||||
|
|
||||||
// 主要操作按钮
|
// 主要操作按钮
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: $spacing-xs;
|
gap: $spacing-xs;
|
||||||
padding: $spacing-sm $spacing-xl;
|
padding: $spacing-sm $spacing-xl;
|
||||||
|
|||||||
@@ -1,137 +1,252 @@
|
|||||||
<template>
|
<template>
|
||||||
<AdminLayout title="学习记录" subtitle="学习记录管理">
|
<AdminLayout title="学习记录" subtitle="学习记录统计与管理">
|
||||||
<div class="study-records">
|
<div class="study-records">
|
||||||
<div class="filter-bar">
|
<!-- 统计图表区域 -->
|
||||||
<el-input
|
<div class="statistics-section">
|
||||||
v-model="searchKeyword"
|
<el-card class="chart-card">
|
||||||
placeholder="搜索用户..."
|
<template #header>
|
||||||
style="width: 200px"
|
<div class="card-header">
|
||||||
clearable
|
<span>本周学习时长统计</span>
|
||||||
/>
|
</div>
|
||||||
<el-select v-model="selectedTask" placeholder="选择任务" style="width: 200px" clearable>
|
</template>
|
||||||
<el-option
|
<div ref="durationChartRef" class="chart-container"></div>
|
||||||
v-for="task in tasks"
|
</el-card>
|
||||||
:key="task.id"
|
|
||||||
:label="task.name"
|
|
||||||
:value="task.id"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
<el-date-picker
|
|
||||||
v-model="dateRange"
|
|
||||||
type="daterange"
|
|
||||||
range-separator="至"
|
|
||||||
start-placeholder="开始日期"
|
|
||||||
end-placeholder="结束日期"
|
|
||||||
/>
|
|
||||||
<el-button type="primary" @click="handleSearch">查询</el-button>
|
|
||||||
<el-button @click="handleExport">导出</el-button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table :data="records" style="width: 100%">
|
<!-- 排行榜区域 -->
|
||||||
<el-table-column prop="userName" label="用户" width="120" />
|
<div class="rankings-section">
|
||||||
<el-table-column prop="taskName" label="任务名称" min-width="180" />
|
<el-row :gutter="20">
|
||||||
<el-table-column prop="progress" label="完成进度" width="120">
|
<!-- 学习时长排行榜 -->
|
||||||
<template #default="{ row }">
|
<el-col :span="12">
|
||||||
<el-progress :percentage="row.progress" />
|
<el-card class="ranking-card">
|
||||||
</template>
|
<template #header>
|
||||||
</el-table-column>
|
<div class="card-header">
|
||||||
<el-table-column prop="duration" label="学习时长" width="120" />
|
<span>学习时长排行榜</span>
|
||||||
<el-table-column prop="startDate" label="开始时间" width="150" />
|
</div>
|
||||||
<el-table-column prop="status" label="状态" width="100">
|
</template>
|
||||||
<template #default="{ row }">
|
<div class="ranking-list">
|
||||||
<el-tag :type="getStatusType(row.status)">
|
<div v-for="(item, index) in durationRanking" :key="item.userId" class="ranking-item">
|
||||||
{{ getStatusText(row.status) }}
|
<span class="rank" :class="getRankClass(index)">{{ index + 1 }}</span>
|
||||||
</el-tag>
|
<span class="username">{{ item.username }}</span>
|
||||||
</template>
|
<span class="value">{{ formatDuration(item.totalDuration) }}</span>
|
||||||
</el-table-column>
|
</div>
|
||||||
<el-table-column label="操作" width="120" fixed="right">
|
<el-empty v-if="!durationRanking.length" description="暂无数据" />
|
||||||
<template #default="{ row }">
|
</div>
|
||||||
<el-button size="small" @click="viewDetail(row)">查看详情</el-button>
|
</el-card>
|
||||||
</template>
|
</el-col>
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
|
|
||||||
<el-pagination
|
<!-- 任务完成排行榜 -->
|
||||||
v-model:current-page="currentPage"
|
<el-col :span="12">
|
||||||
v-model:page-size="pageSize"
|
<el-card class="ranking-card">
|
||||||
:total="total"
|
<template #header>
|
||||||
layout="total, sizes, prev, pager, next, jumper"
|
<div class="card-header">
|
||||||
@size-change="handleSizeChange"
|
<span>任务完成排行榜</span>
|
||||||
@current-change="handleCurrentChange"
|
</div>
|
||||||
/>
|
</template>
|
||||||
|
<div class="ranking-list">
|
||||||
|
<div v-for="(item, index) in taskCompletionRanking" :key="item.userId" class="ranking-item">
|
||||||
|
<span class="rank" :class="getRankClass(index)">{{ index + 1 }}</span>
|
||||||
|
<span class="username">{{ item.username }}</span>
|
||||||
|
<span class="value">{{ item.completedTaskCount }}个任务</span>
|
||||||
|
</div>
|
||||||
|
<el-empty v-if="!taskCompletionRanking.length" description="暂无数据" />
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-row :gutter="20" style="margin-top: 20px">
|
||||||
|
<!-- 课程学习排行榜 -->
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-card class="ranking-card">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>热门课程排行榜</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="ranking-list">
|
||||||
|
<div v-for="(item, index) in courseRanking" :key="item.resourceId" class="ranking-item">
|
||||||
|
<span class="rank" :class="getRankClass(index)">{{ index + 1 }}</span>
|
||||||
|
<span class="username">{{ item.resourceName }}</span>
|
||||||
|
<span class="value">{{ item.learnerCount }}人学习</span>
|
||||||
|
</div>
|
||||||
|
<el-empty v-if="!courseRanking.length" description="暂无数据" />
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
|
||||||
|
<!-- 文章学习排行榜 -->
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-card class="ranking-card">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>热门文章排行榜</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="ranking-list">
|
||||||
|
<div v-for="(item, index) in articleRanking" :key="item.resourceId" class="ranking-item">
|
||||||
|
<span class="rank" :class="getRankClass(index)">{{ index + 1 }}</span>
|
||||||
|
<span class="username">{{ item.resourceName }}</span>
|
||||||
|
<span class="value">{{ item.learnerCount }}人阅读</span>
|
||||||
|
</div>
|
||||||
|
<el-empty v-if="!articleRanking.length" description="暂无数据" />
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
|
||||||
import { ElInput, ElSelect, ElOption, ElDatePicker, ElButton, ElTable, ElTableColumn, ElTag, ElProgress, ElPagination, ElMessage } from 'element-plus';
|
import { ElCard, ElRow, ElCol, ElEmpty, ElMessage } from 'element-plus';
|
||||||
import { AdminLayout } from '@/views/admin';
|
import { AdminLayout } from '@/views/admin';
|
||||||
|
import * as echarts from 'echarts';
|
||||||
|
import type { ECharts } from 'echarts';
|
||||||
|
import { learningRecordApi } from '@/apis/study/learning-record';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'StudyRecordsView'
|
name: 'StudyRecordsView'
|
||||||
});
|
});
|
||||||
const searchKeyword = ref('');
|
|
||||||
const selectedTask = ref('');
|
// 图表实例
|
||||||
const dateRange = ref<[Date, Date] | null>(null);
|
const durationChartRef = ref<HTMLDivElement>();
|
||||||
const currentPage = ref(1);
|
let durationChart: ECharts | null = null;
|
||||||
const pageSize = ref(10);
|
|
||||||
const total = ref(0);
|
// 排行榜数据
|
||||||
const records = ref<any[]>([]);
|
const durationRanking = ref<any[]>([]);
|
||||||
const tasks = ref<any[]>([]);
|
const courseRanking = ref<any[]>([]);
|
||||||
|
const articleRanking = ref<any[]>([]);
|
||||||
|
const taskCompletionRanking = ref<any[]>([]);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadTasks();
|
loadChartsData();
|
||||||
loadRecords();
|
loadRankingsData();
|
||||||
});
|
});
|
||||||
|
|
||||||
function loadTasks() {
|
onUnmounted(() => {
|
||||||
// TODO: 加载任务列表
|
if (durationChart) {
|
||||||
|
durationChart.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 加载图表数据
|
||||||
|
async function loadChartsData() {
|
||||||
|
try {
|
||||||
|
const result = await learningRecordApi.getStudyRecordsCharts();
|
||||||
|
if (result.success && result.data) {
|
||||||
|
const durationByType = result.data.durationByType || [];
|
||||||
|
await nextTick();
|
||||||
|
initDurationChart(durationByType);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载图表数据失败:', error);
|
||||||
|
ElMessage.error('加载图表数据失败');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadRecords() {
|
// 加载排行榜数据
|
||||||
// TODO: 加载学习记录
|
async function loadRankingsData() {
|
||||||
|
try {
|
||||||
|
const result = await learningRecordApi.getStudyRecordsRankings();
|
||||||
|
if (result.success && result.data) {
|
||||||
|
durationRanking.value = result.data.durationRanking || [];
|
||||||
|
courseRanking.value = result.data.courseRanking || [];
|
||||||
|
articleRanking.value = result.data.articleRanking || [];
|
||||||
|
taskCompletionRanking.value = result.data.taskCompletionRanking || [];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载排行榜数据失败:', error);
|
||||||
|
ElMessage.error('加载排行榜数据失败');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSearch() {
|
// 初始化学习时长图表
|
||||||
currentPage.value = 1;
|
function initDurationChart(data: any[]) {
|
||||||
loadRecords();
|
if (!durationChartRef.value) return;
|
||||||
}
|
|
||||||
|
|
||||||
function handleExport() {
|
if (durationChart) {
|
||||||
// TODO: 导出学习记录
|
durationChart.dispose();
|
||||||
ElMessage.info('导出功能开发中');
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function getStatusType(status: string) {
|
durationChart = echarts.init(durationChartRef.value);
|
||||||
const typeMap: Record<string, any> = {
|
|
||||||
'completed': 'success',
|
const option = {
|
||||||
'in-progress': 'warning',
|
tooltip: {
|
||||||
'not-started': 'info'
|
trigger: 'item',
|
||||||
|
formatter: (params: any) => {
|
||||||
|
const duration = params.value;
|
||||||
|
const hours = Math.floor(duration / 3600);
|
||||||
|
const minutes = Math.floor((duration % 3600) / 60);
|
||||||
|
return `${params.name}<br/>学习时长: ${hours}小时${minutes}分钟<br/>学习人数: ${params.data.userCount}人`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
top: '5%',
|
||||||
|
left: 'center'
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '学习时长统计',
|
||||||
|
type: 'pie',
|
||||||
|
radius: ['40%', '70%'],
|
||||||
|
avoidLabelOverlap: false,
|
||||||
|
itemStyle: {
|
||||||
|
borderRadius: 10,
|
||||||
|
borderColor: '#fff',
|
||||||
|
borderWidth: 2
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
formatter: (params: any) => {
|
||||||
|
const duration = params.value;
|
||||||
|
const hours = Math.floor(duration / 3600);
|
||||||
|
const minutes = Math.floor((duration % 3600) / 60);
|
||||||
|
return `${params.name}\n${hours}h${minutes}m`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
emphasis: {
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 'bold'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data: data.map(item => ({
|
||||||
|
name: item.resourceType,
|
||||||
|
value: item.totalDuration || 0,
|
||||||
|
userCount: item.userCount || 0
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
]
|
||||||
};
|
};
|
||||||
return typeMap[status] || 'info';
|
|
||||||
|
durationChart.setOption(option);
|
||||||
|
|
||||||
|
// 响应式调整
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
durationChart?.resize();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatusText(status: string) {
|
// 格式化时长
|
||||||
const textMap: Record<string, string> = {
|
function formatDuration(seconds: number): string {
|
||||||
'completed': '已完成',
|
const hours = Math.floor(seconds / 3600);
|
||||||
'in-progress': '进行中',
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
'not-started': '未开始'
|
if (hours > 0) {
|
||||||
};
|
return `${hours}小时${minutes}分钟`;
|
||||||
return textMap[status] || status;
|
}
|
||||||
|
return `${minutes}分钟`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function viewDetail(row: any) {
|
// 获取排名样式
|
||||||
// TODO: 查看学习记录详情
|
function getRankClass(index: number): string {
|
||||||
}
|
if (index === 0) return 'rank-first';
|
||||||
|
if (index === 1) return 'rank-second';
|
||||||
function handleSizeChange(val: number) {
|
if (index === 2) return 'rank-third';
|
||||||
pageSize.value = val;
|
return '';
|
||||||
loadRecords();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCurrentChange(val: number) {
|
|
||||||
currentPage.value = val;
|
|
||||||
loadRecords();
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -140,15 +255,95 @@ function handleCurrentChange(val: number) {
|
|||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-bar {
|
.statistics-section {
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
align-items: center;
|
|
||||||
|
.chart-card {
|
||||||
|
.card-header {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 400px;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-table {
|
.rankings-section {
|
||||||
margin-bottom: 20px;
|
.ranking-card {
|
||||||
|
.card-header {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-list {
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
.ranking-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: #f5f7fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e8edf3;
|
||||||
|
transform: translateX(5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
margin-right: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #909399;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
&.rank-first {
|
||||||
|
background: linear-gradient(135deg, #ffd700, #ffed4e);
|
||||||
|
color: #333;
|
||||||
|
box-shadow: 0 2px 8px rgba(255, 215, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.rank-second {
|
||||||
|
background: linear-gradient(135deg, #c0c0c0, #e8e8e8);
|
||||||
|
color: #333;
|
||||||
|
box-shadow: 0 2px 8px rgba(192, 192, 192, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.rank-third {
|
||||||
|
background: linear-gradient(135deg, #cd7f32, #e09856);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 2px 8px rgba(205, 127, 50, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,17 @@
|
|||||||
@success="handleFormSuccess"
|
@success="handleFormSuccess"
|
||||||
@cancel="handleFormCancel"
|
@cancel="handleFormCancel"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 统计数据视图 -->
|
||||||
|
<div v-else-if="currentView === 'statistics'" class="task-statistics-view">
|
||||||
|
<div class="statistics-header">
|
||||||
|
<button class="btn-back" @click="handleStatisticsBack">
|
||||||
|
<span class="arrow-left">←</span> 返回
|
||||||
|
</button>
|
||||||
|
<h2 class="page-title">任务统计 - {{ currentTaskName }}</h2>
|
||||||
|
</div>
|
||||||
|
<TaskStatics v-if="currentTaskId" :task-id="currentTaskId" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 任务详情弹窗 -->
|
<!-- 任务详情弹窗 -->
|
||||||
@@ -243,7 +254,7 @@
|
|||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted } from 'vue';
|
||||||
import { ElPagination, ElMessage, ElMessageBox } from 'element-plus';
|
import { ElPagination, ElMessage, ElMessageBox } from 'element-plus';
|
||||||
import { AdminLayout } from '@/views/admin';
|
import { AdminLayout } from '@/views/admin';
|
||||||
import { TaskCard } from './index';
|
import { TaskCard, TaskStatics } from './components';
|
||||||
import { GenericSelector } from '@/components/base';
|
import { GenericSelector } from '@/components/base';
|
||||||
import { LearningTaskAdd } from '@/views/public/task';
|
import { LearningTaskAdd } from '@/views/public/task';
|
||||||
import type { TaskVO, UserVO } from '@/types';
|
import type { TaskVO, UserVO } from '@/types';
|
||||||
@@ -255,8 +266,9 @@ defineOptions({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 视图控制
|
// 视图控制
|
||||||
const currentView = ref<'list' | 'add' | 'edit'>('list');
|
const currentView = ref<'list' | 'add' | 'edit' | 'statistics'>('list');
|
||||||
const currentTaskId = ref<string | undefined>(undefined);
|
const currentTaskId = ref<string | undefined>(undefined);
|
||||||
|
const currentTaskName = ref<string>('');
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const taskList = ref<TaskVO[]>([]);
|
const taskList = ref<TaskVO[]>([]);
|
||||||
@@ -411,9 +423,9 @@ async function handleUnpublish(task: TaskVO) {
|
|||||||
* 统计
|
* 统计
|
||||||
*/
|
*/
|
||||||
function handleStatistics(task: TaskVO) {
|
function handleStatistics(task: TaskVO) {
|
||||||
ElMessage.info('统计功能待开发');
|
currentView.value = 'statistics';
|
||||||
// TODO: 显示统计弹窗或其他实现
|
currentTaskId.value = task.learningTask.taskID;
|
||||||
console.log('查看统计:', task);
|
currentTaskName.value = task.learningTask.name || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -804,6 +816,15 @@ function handleFormCancel() {
|
|||||||
currentView.value = 'list';
|
currentView.value = 'list';
|
||||||
currentTaskId.value = undefined;
|
currentTaskId.value = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从统计页面返回列表
|
||||||
|
*/
|
||||||
|
function handleStatisticsBack() {
|
||||||
|
currentView.value = 'list';
|
||||||
|
currentTaskId.value = undefined;
|
||||||
|
currentTaskName.value = '';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -843,6 +864,52 @@ function handleFormCancel() {
|
|||||||
margin-bottom: 32px;
|
margin-bottom: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 统计视图样式
|
||||||
|
.task-statistics-view {
|
||||||
|
padding: 24px;
|
||||||
|
background: #FFFFFF;
|
||||||
|
min-height: calc(100vh - 100px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statistics-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-back {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: 1px solid #dcdfe6;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #606266;
|
||||||
|
transition: all 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #409eff;
|
||||||
|
border-color: #409eff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow-left {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
// 弹窗样式
|
// 弹窗样式
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -1034,54 +1101,54 @@ function handleFormCancel() {
|
|||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-success,
|
// .btn-success,
|
||||||
.btn-danger,
|
// .btn-danger,
|
||||||
.btn-default {
|
// .btn-default {
|
||||||
padding: 10px 24px;
|
// padding: 10px 24px;
|
||||||
border-radius: 4px;
|
// border-radius: 4px;
|
||||||
font-size: 14px;
|
// font-size: 14px;
|
||||||
cursor: pointer;
|
// cursor: pointer;
|
||||||
transition: all 0.3s;
|
// transition: all 0.3s;
|
||||||
border: none;
|
// border: none;
|
||||||
|
|
||||||
.icon {
|
// .icon {
|
||||||
margin-right: 4px;
|
// margin-right: 4px;
|
||||||
}
|
// }
|
||||||
|
|
||||||
&:disabled {
|
// &:disabled {
|
||||||
opacity: 0.6;
|
// opacity: 0.6;
|
||||||
cursor: not-allowed;
|
// cursor: not-allowed;
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
.btn-success {
|
// .btn-success {
|
||||||
background: #67c23a;
|
// background: #67c23a;
|
||||||
color: #fff;
|
// color: #fff;
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
// &:hover:not(:disabled) {
|
||||||
background: #85ce61;
|
// background: #85ce61;
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
.btn-danger {
|
// .btn-danger {
|
||||||
background: #f56c6c;
|
// background: #f56c6c;
|
||||||
color: #fff;
|
// color: #fff;
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
// &:hover:not(:disabled) {
|
||||||
background: #f78989;
|
// background: #f78989;
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
.btn-default {
|
// .btn-default {
|
||||||
background: #fff;
|
// background: #fff;
|
||||||
color: #606266;
|
// color: #606266;
|
||||||
border: 1px solid #dcdfe6;
|
// border: 1px solid #dcdfe6;
|
||||||
|
|
||||||
&:hover {
|
// &:hover {
|
||||||
color: #409eff;
|
// color: #409eff;
|
||||||
border-color: #409eff;
|
// border-color: #409eff;
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
.current-user-list {
|
.current-user-list {
|
||||||
border: 1px solid #e0e0e0;
|
border: 1px solid #e0e0e0;
|
||||||
|
|||||||
@@ -0,0 +1,631 @@
|
|||||||
|
<template>
|
||||||
|
<div class="task-statistics">
|
||||||
|
<!-- 总体学习进度 -->
|
||||||
|
<div class="statistics-summary">
|
||||||
|
<el-card shadow="hover" class="summary-card">
|
||||||
|
<div class="summary-content">
|
||||||
|
<div class="summary-item">
|
||||||
|
<div class="summary-label">总学习人数</div>
|
||||||
|
<div class="summary-value">{{ taskInfo.totalTaskNum || 0 }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-divider"></div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<div class="summary-label">已完成人数</div>
|
||||||
|
<div class="summary-value completed">{{ taskInfo.completedTaskNum || 0 }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-divider"></div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<div class="summary-label">完成率</div>
|
||||||
|
<div class="summary-value rate">
|
||||||
|
{{ completionRate }}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 图表区域 -->
|
||||||
|
<div class="charts-container">
|
||||||
|
<el-row :gutter="20">
|
||||||
|
<!-- 学习时长分布 -->
|
||||||
|
<el-col :xs="24" :sm="24" :md="12" :lg="12" :xl="12">
|
||||||
|
<el-card shadow="hover" class="chart-card">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="chart-title">学习时长分布</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div ref="durationChartRef" class="chart" v-loading="loading"></div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
|
||||||
|
<!-- 学习进度分布 -->
|
||||||
|
<el-col :xs="24" :sm="24" :md="12" :lg="12" :xl="12">
|
||||||
|
<el-card shadow="hover" class="chart-card">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="chart-title">学习进度分布</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div ref="progressChartRef" class="chart" v-loading="loading"></div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 排行榜区域 -->
|
||||||
|
<div class="rankings-container">
|
||||||
|
<el-row :gutter="20">
|
||||||
|
<!-- 完成时间排行榜 -->
|
||||||
|
<el-col :xs="24" :sm="24" :md="12" :lg="12" :xl="12">
|
||||||
|
<el-card shadow="hover" class="ranking-card">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="chart-title">完成时间排行榜(前10名)</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="ranking-list" v-loading="loading">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in completionRanking"
|
||||||
|
:key="item.userId"
|
||||||
|
class="ranking-item"
|
||||||
|
:class="{ 'top-three': index < 3 }"
|
||||||
|
>
|
||||||
|
<div class="ranking-number" :class="getRankClass(index)">
|
||||||
|
{{ index + 1 }}
|
||||||
|
</div>
|
||||||
|
<div class="ranking-info">
|
||||||
|
<div class="ranking-name">{{ item.username }}</div>
|
||||||
|
<div class="ranking-detail">
|
||||||
|
完成时间: {{ formatDateTime(item.completeTime) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ranking-value">
|
||||||
|
{{ formatDuration(item.completionDuration) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-empty
|
||||||
|
v-if="!loading && completionRanking.length === 0"
|
||||||
|
description="暂无数据"
|
||||||
|
:image-size="100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
|
||||||
|
<!-- 学习时长排行榜 -->
|
||||||
|
<el-col :xs="24" :sm="24" :md="12" :lg="12" :xl="12">
|
||||||
|
<el-card shadow="hover" class="ranking-card">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="chart-title">学习时长排行榜(前10名)</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="ranking-list" v-loading="loading">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in durationRanking"
|
||||||
|
:key="item.userId"
|
||||||
|
class="ranking-item"
|
||||||
|
:class="{ 'top-three': index < 3 }"
|
||||||
|
>
|
||||||
|
<div class="ranking-number" :class="getRankClass(index)">
|
||||||
|
{{ index + 1 }}
|
||||||
|
</div>
|
||||||
|
<div class="ranking-info">
|
||||||
|
<div class="ranking-name">{{ item.username }}</div>
|
||||||
|
<div class="ranking-detail">累计学习时长</div>
|
||||||
|
</div>
|
||||||
|
<div class="ranking-value">
|
||||||
|
{{ formatStudyDuration(item.totalDuration) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-empty
|
||||||
|
v-if="!loading && durationRanking.length === 0"
|
||||||
|
description="暂无数据"
|
||||||
|
:image-size="100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||||
|
import * as echarts from 'echarts';
|
||||||
|
import type { ECharts } from 'echarts';
|
||||||
|
import { learningTaskApi } from '@/apis/study/learning-task';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
|
||||||
|
// Props
|
||||||
|
const props = defineProps<{
|
||||||
|
taskId: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// 响应式数据
|
||||||
|
const loading = ref(false);
|
||||||
|
const taskInfo = ref<any>({});
|
||||||
|
const durationDistribution = ref<any[]>([]);
|
||||||
|
const progressDistribution = ref<any[]>([]);
|
||||||
|
const completionRanking = ref<any[]>([]);
|
||||||
|
const durationRanking = ref<any[]>([]);
|
||||||
|
|
||||||
|
// 图表实例
|
||||||
|
const durationChartRef = ref<HTMLElement>();
|
||||||
|
const progressChartRef = ref<HTMLElement>();
|
||||||
|
let durationChart: ECharts | null = null;
|
||||||
|
let progressChart: ECharts | null = null;
|
||||||
|
|
||||||
|
// 计算完成率
|
||||||
|
const completionRate = computed(() => {
|
||||||
|
const total = taskInfo.value.totalTaskNum || 0;
|
||||||
|
const completed = taskInfo.value.completedTaskNum || 0;
|
||||||
|
if (total === 0) return 0;
|
||||||
|
return ((completed / total) * 100).toFixed(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取排名样式类
|
||||||
|
const getRankClass = (index: number) => {
|
||||||
|
if (index === 0) return 'rank-first';
|
||||||
|
if (index === 1) return 'rank-second';
|
||||||
|
if (index === 2) return 'rank-third';
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化日期时间
|
||||||
|
const formatDateTime = (dateTime: string) => {
|
||||||
|
if (!dateTime) return '-';
|
||||||
|
const date = new Date(dateTime);
|
||||||
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(
|
||||||
|
date.getDate()
|
||||||
|
).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化时长(秒转为易读格式)
|
||||||
|
const formatDuration = (seconds: number) => {
|
||||||
|
if (!seconds || seconds === 0) return '-';
|
||||||
|
const days = Math.floor(seconds / 86400);
|
||||||
|
const hours = Math.floor((seconds % 86400) / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
if (days > 0) parts.push(`${days}天`);
|
||||||
|
if (hours > 0) parts.push(`${hours}小时`);
|
||||||
|
if (minutes > 0) parts.push(`${minutes}分钟`);
|
||||||
|
|
||||||
|
return parts.length > 0 ? parts.join('') : '0分钟';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化学习时长
|
||||||
|
const formatStudyDuration = (seconds: number) => {
|
||||||
|
if (!seconds || seconds === 0) return '0分钟';
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
if (hours > 0) parts.push(`${hours}小时`);
|
||||||
|
if (minutes > 0) parts.push(`${minutes}分钟`);
|
||||||
|
|
||||||
|
return parts.length > 0 ? parts.join('') : '0分钟';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化学习时长分布图表
|
||||||
|
const initDurationChart = () => {
|
||||||
|
if (!durationChartRef.value) return;
|
||||||
|
|
||||||
|
durationChart = echarts.init(durationChartRef.value);
|
||||||
|
|
||||||
|
const categories = durationDistribution.value.map((item: any) => item.durationRange);
|
||||||
|
const data = durationDistribution.value.map((item: any) => item.userCount);
|
||||||
|
|
||||||
|
const option = {
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
axisPointer: {
|
||||||
|
type: 'shadow'
|
||||||
|
},
|
||||||
|
formatter: (params: any) => {
|
||||||
|
const param = params[0];
|
||||||
|
return `${param.name}<br/>人数: ${param.value}人`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
left: '3%',
|
||||||
|
right: '4%',
|
||||||
|
bottom: '15%',
|
||||||
|
top: '10%',
|
||||||
|
containLabel: true
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: categories,
|
||||||
|
axisLabel: {
|
||||||
|
interval: 0,
|
||||||
|
rotate: 30,
|
||||||
|
fontSize: 12
|
||||||
|
},
|
||||||
|
name: '学习时长'
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
name: '人数',
|
||||||
|
minInterval: 1,
|
||||||
|
axisLabel: {
|
||||||
|
formatter: '{value}人'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '人数',
|
||||||
|
type: 'bar',
|
||||||
|
data: data,
|
||||||
|
barMaxWidth: 60,
|
||||||
|
itemStyle: {
|
||||||
|
color: new echarts.graphic.LinearGradient(0, 1, 0, 0, [
|
||||||
|
{ offset: 0, color: '#4facfe' },
|
||||||
|
{ offset: 1, color: '#00f2fe' }
|
||||||
|
]),
|
||||||
|
borderRadius: [4, 4, 0, 0]
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'top',
|
||||||
|
formatter: '{c}人'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
durationChart.setOption(option);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化学习进度分布图表
|
||||||
|
const initProgressChart = () => {
|
||||||
|
if (!progressChartRef.value) return;
|
||||||
|
|
||||||
|
progressChart = echarts.init(progressChartRef.value);
|
||||||
|
|
||||||
|
const categories = progressDistribution.value.map((item: any) => item.progressRange);
|
||||||
|
const data = progressDistribution.value.map((item: any) => item.userCount);
|
||||||
|
|
||||||
|
const option = {
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
axisPointer: {
|
||||||
|
type: 'shadow'
|
||||||
|
},
|
||||||
|
formatter: (params: any) => {
|
||||||
|
const param = params[0];
|
||||||
|
return `${param.name}<br/>人数: ${param.value}人`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
left: '3%',
|
||||||
|
right: '4%',
|
||||||
|
bottom: '12%',
|
||||||
|
top: '10%',
|
||||||
|
containLabel: true
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: categories,
|
||||||
|
axisLabel: {
|
||||||
|
interval: 0,
|
||||||
|
rotate: 30,
|
||||||
|
fontSize: 12
|
||||||
|
},
|
||||||
|
name: '学习进度'
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
name: '人数',
|
||||||
|
minInterval: 1,
|
||||||
|
axisLabel: {
|
||||||
|
formatter: '{value}人'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '人数',
|
||||||
|
type: 'bar',
|
||||||
|
data: data,
|
||||||
|
barMaxWidth: 60,
|
||||||
|
itemStyle: {
|
||||||
|
color: new echarts.graphic.LinearGradient(0, 1, 0, 0, [
|
||||||
|
{ offset: 0, color: '#a18cd1' },
|
||||||
|
{ offset: 1, color: '#fbc2eb' }
|
||||||
|
]),
|
||||||
|
borderRadius: [4, 4, 0, 0]
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'top',
|
||||||
|
formatter: '{c}人'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
progressChart.setOption(option);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 调整图表大小
|
||||||
|
const resizeCharts = () => {
|
||||||
|
durationChart?.resize();
|
||||||
|
progressChart?.resize();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取任务基本信息
|
||||||
|
const fetchTaskInfo = async () => {
|
||||||
|
try {
|
||||||
|
const result = await learningTaskApi.getTaskById(props.taskId);
|
||||||
|
if (result.code === 200 && result.data) {
|
||||||
|
taskInfo.value = result.data;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取任务信息失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取图表数据
|
||||||
|
const fetchChartsData = async () => {
|
||||||
|
try {
|
||||||
|
const result = await learningTaskApi.getTaskStatisticsCharts(props.taskId);
|
||||||
|
if (result.code === 200 && result.data) {
|
||||||
|
durationDistribution.value = result.data.durationDistribution || [];
|
||||||
|
progressDistribution.value = result.data.progressDistribution || [];
|
||||||
|
|
||||||
|
// 初始化图表
|
||||||
|
setTimeout(() => {
|
||||||
|
initDurationChart();
|
||||||
|
initProgressChart();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取图表数据失败:', error);
|
||||||
|
ElMessage.error('获取图表数据失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取排行榜数据
|
||||||
|
const fetchRankingsData = async () => {
|
||||||
|
try {
|
||||||
|
const result = await learningTaskApi.getTaskStatisticsRankings(props.taskId);
|
||||||
|
if (result.code === 200 && result.data) {
|
||||||
|
completionRanking.value = result.data.completionTimeRanking || [];
|
||||||
|
durationRanking.value = result.data.durationRanking || [];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取排行榜数据失败:', error);
|
||||||
|
ElMessage.error('获取排行榜数据失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载所有数据
|
||||||
|
const loadData = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
fetchTaskInfo(),
|
||||||
|
fetchChartsData(),
|
||||||
|
fetchRankingsData()
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听taskId变化
|
||||||
|
watch(() => props.taskId, () => {
|
||||||
|
if (props.taskId) {
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
// 生命周期
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('resize', resizeCharts);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', resizeCharts);
|
||||||
|
durationChart?.dispose();
|
||||||
|
progressChart?.dispose();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="css" scoped>
|
||||||
|
.task-statistics {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statistics-summary {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card {
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-item {
|
||||||
|
text-align: center;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #909399;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-value {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-value.completed {
|
||||||
|
color: #67c23a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-value.rate {
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 60px;
|
||||||
|
background: #dcdfe6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.charts-container {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card {
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart {
|
||||||
|
height: 400px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rankings-container {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-card {
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-list {
|
||||||
|
min-height: 400px;
|
||||||
|
max-height: 600px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 15px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-item:hover {
|
||||||
|
background: #f5f7fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-item.top-three {
|
||||||
|
background: linear-gradient(to right, #fff9e6, #ffffff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-number {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #e4e7ed;
|
||||||
|
color: #606266;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-right: 15px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-number.rank-first {
|
||||||
|
background: linear-gradient(135deg, #ffd700, #ffed4e);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 2px 8px rgba(255, 215, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-number.rank-second {
|
||||||
|
background: linear-gradient(135deg, #c0c0c0, #e8e8e8);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 2px 8px rgba(192, 192, 192, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-number.rank-third {
|
||||||
|
background: linear-gradient(135deg, #cd7f32, #daa972);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 2px 8px rgba(205, 127, 50, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #303133;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-detail {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #909399;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-value {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #409eff;
|
||||||
|
margin-left: 15px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.summary-content {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-divider {
|
||||||
|
width: 100%;
|
||||||
|
height: 1px;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart {
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-list {
|
||||||
|
max-height: 400px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as TaskCard } from './TaskCard.vue';
|
||||||
|
export { default as TaskStatics } from './TaskStatics.vue';
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default as TaskCard } from './TaskCard.vue';
|
|
||||||
Reference in New Issue
Block a user