数据统计

This commit is contained in:
2025-10-30 18:55:40 +08:00
parent 0935ec5ec5
commit a881f57e30
19 changed files with 1587 additions and 162 deletions

View File

@@ -5,6 +5,7 @@ import org.xyzh.common.dto.study.TbLearningRecord;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
/**
* @description 学习记录服务接口
@@ -110,4 +111,22 @@ public interface LearningRecordService {
* @since 2025-10-24
*/
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();
}

View File

@@ -9,6 +9,7 @@ import org.xyzh.common.vo.TaskVO;
import org.xyzh.common.dto.study.TbTaskItem;
import java.util.List;
import java.util.Map;
/**
* @description 学习任务服务接口
@@ -216,4 +217,24 @@ public interface LearningTaskService {
* @since 2025-10-15
*/
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);
}

View File

@@ -11,6 +11,8 @@ import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.PathVariable;
import java.util.Map;
/**
* @description 学习记录控制器
@@ -72,4 +74,22 @@ public class LearningRecordController {
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();
}
}

View File

@@ -160,6 +160,24 @@ public class LearningTaskController {
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);
}
}

View File

@@ -5,8 +5,10 @@ import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.xyzh.common.core.page.PageParam;
import org.xyzh.common.dto.study.TbLearningRecord;
import org.xyzh.common.vo.UserDeptRoleVO;
import java.util.List;
import java.util.Map;
/**
* @description LearningRecordMapper.java文件描述 学习记录数据访问层
@@ -145,4 +147,51 @@ public interface LearningRecordMapper extends BaseMapper<TbLearningRecord> {
* @since 2025-10-15
*/
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);
}

View File

@@ -8,6 +8,7 @@ import org.xyzh.common.dto.study.TbTaskUser;
import org.xyzh.common.vo.TaskItemVO;
import java.util.List;
import java.util.Map;
/**
* @description TaskUserMapper.java文件描述 任务用户数据访问层
@@ -179,4 +180,40 @@ public interface TaskUserMapper extends BaseMapper<TbTaskUser> {
* @since 2025-10-15
*/
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);
}

View File

@@ -2,7 +2,9 @@ package org.xyzh.study.service.impl;
import java.math.BigDecimal;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
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.user.TbSysUser;
import org.xyzh.common.vo.TaskItemVO;
import org.xyzh.common.vo.UserDeptRoleVO;
import org.xyzh.api.study.record.LearningRecordService;
import org.xyzh.study.mapper.LearningRecordMapper;
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;
}
}

View File

@@ -3,6 +3,7 @@ package org.xyzh.study.service.impl;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
@@ -690,4 +691,48 @@ public class SCLearningTaskServiceImpl implements LearningTaskService {
resultDomain.success("获取用户整体学习进度成功", taskVO);
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;
}
}

View File

@@ -204,4 +204,157 @@
<include refid="Where_Clause" />
</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 &lt; 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 &lt; 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 &lt; 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 &lt; 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 &lt; 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>

View File

@@ -263,4 +263,84 @@
<include refid="Where_Clause" />
</select>
<!-- 获取任务学习时长分布数据 -->
<select id="getStudyDurationDistribution" resultType="map">
SELECT
CASE
WHEN total_duration &lt; 600 THEN '0-10分钟'
WHEN total_duration &lt; 1800 THEN '10-30分钟'
WHEN total_duration &lt; 3600 THEN '30-60分钟'
WHEN total_duration &lt; 7200 THEN '1-2小时'
WHEN total_duration &lt; 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 &lt; 10 THEN '0-10%'
WHEN progress &lt; 20 THEN '10-20%'
WHEN progress &lt; 30 THEN '20-30%'
WHEN progress &lt; 40 THEN '30-40%'
WHEN progress &lt; 50 THEN '40-50%'
WHEN progress &lt; 60 THEN '50-60%'
WHEN progress &lt; 70 THEN '60-70%'
WHEN progress &lt; 80 THEN '70-80%'
WHEN progress &lt; 90 THEN '80-90%'
WHEN progress &lt; 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>

View File

@@ -74,4 +74,22 @@ export const learningRecordApi = {
const response = await api.post<LearningRecord>('/study/records/course/records', filter);
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;
},
};

View File

@@ -152,4 +152,24 @@ export const learningTaskApi = {
const response = await api.post<TaskVO>(`${this.learningTaskPrefix}/user/progress/${userID}`);
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;
},
};

View File

@@ -40,7 +40,6 @@ $spacing-xxl: 24px;
// 主要操作按钮
.btn-primary {
display: flex;
align-items: center;
gap: $spacing-xs;
padding: $spacing-sm $spacing-xl;

View File

@@ -1,137 +1,252 @@
<template>
<AdminLayout title="学习记录" subtitle="学习记录管理">
<AdminLayout title="学习记录" subtitle="学习记录统计与管理">
<div class="study-records">
<div class="filter-bar">
<el-input
v-model="searchKeyword"
placeholder="搜索用户..."
style="width: 200px"
clearable
/>
<el-select v-model="selectedTask" placeholder="选择任务" style="width: 200px" clearable>
<el-option
v-for="task in tasks"
: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 class="statistics-section">
<el-card class="chart-card">
<template #header>
<div class="card-header">
<span>本周学习时长统计</span>
</div>
</template>
<div ref="durationChartRef" class="chart-container"></div>
</el-card>
</div>
<el-table :data="records" style="width: 100%">
<el-table-column prop="userName" label="用户" width="120" />
<el-table-column prop="taskName" label="任务名称" min-width="180" />
<el-table-column prop="progress" label="完成进度" width="120">
<template #default="{ row }">
<el-progress :percentage="row.progress" />
</template>
</el-table-column>
<el-table-column prop="duration" label="学习时长" width="120" />
<el-table-column prop="startDate" label="开始时间" width="150" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="viewDetail(row)">查看详情</el-button>
</template>
</el-table-column>
</el-table>
<!-- 排行榜区域 -->
<div class="rankings-section">
<el-row :gutter="20">
<!-- 学习时长排行榜 -->
<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 durationRanking" :key="item.userId" class="ranking-item">
<span class="rank" :class="getRankClass(index)">{{ index + 1 }}</span>
<span class="username">{{ item.username }}</span>
<span class="value">{{ formatDuration(item.totalDuration) }}</span>
</div>
<el-empty v-if="!durationRanking.length" description="暂无数据" />
</div>
</el-card>
</el-col>
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
<!-- 任务完成排行榜 -->
<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 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>
</AdminLayout>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ElInput, ElSelect, ElOption, ElDatePicker, ElButton, ElTable, ElTableColumn, ElTag, ElProgress, ElPagination, ElMessage } from 'element-plus';
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
import { ElCard, ElRow, ElCol, ElEmpty, ElMessage } from 'element-plus';
import { AdminLayout } from '@/views/admin';
import * as echarts from 'echarts';
import type { ECharts } from 'echarts';
import { learningRecordApi } from '@/apis/study/learning-record';
defineOptions({
name: 'StudyRecordsView'
});
const searchKeyword = ref('');
const selectedTask = ref('');
const dateRange = ref<[Date, Date] | null>(null);
const currentPage = ref(1);
const pageSize = ref(10);
const total = ref(0);
const records = ref<any[]>([]);
const tasks = ref<any[]>([]);
// 图表实例
const durationChartRef = ref<HTMLDivElement>();
let durationChart: ECharts | null = null;
// 排行榜数据
const durationRanking = ref<any[]>([]);
const courseRanking = ref<any[]>([]);
const articleRanking = ref<any[]>([]);
const taskCompletionRanking = ref<any[]>([]);
onMounted(() => {
loadTasks();
loadRecords();
loadChartsData();
loadRankingsData();
});
function loadTasks() {
// TODO: 加载任务列表
onUnmounted(() => {
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;
loadRecords();
}
// 初始化学习时长图表
function initDurationChart(data: any[]) {
if (!durationChartRef.value) return;
function handleExport() {
// TODO: 导出学习记录
ElMessage.info('导出功能开发中');
}
if (durationChart) {
durationChart.dispose();
}
function getStatusType(status: string) {
const typeMap: Record<string, any> = {
'completed': 'success',
'in-progress': 'warning',
'not-started': 'info'
durationChart = echarts.init(durationChartRef.value);
const option = {
tooltip: {
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> = {
'completed': '已完成',
'in-progress': '进行中',
'not-started': '未开始'
};
return textMap[status] || status;
// 格式化时长
function formatDuration(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}小时${minutes}分钟`;
}
return `${minutes}分钟`;
}
function viewDetail(row: any) {
// TODO: 查看学习记录详情
}
function handleSizeChange(val: number) {
pageSize.value = val;
loadRecords();
}
function handleCurrentChange(val: number) {
currentPage.value = val;
loadRecords();
// 获取排名样式
function getRankClass(index: number): string {
if (index === 0) return 'rank-first';
if (index === 1) return 'rank-second';
if (index === 2) return 'rank-third';
return '';
}
</script>
@@ -140,15 +255,95 @@ function handleCurrentChange(val: number) {
padding: 20px;
}
.filter-bar {
display: flex;
gap: 16px;
.statistics-section {
margin-bottom: 20px;
align-items: center;
.chart-card {
.card-header {
font-weight: 600;
font-size: 16px;
}
.chart-container {
width: 100%;
height: 400px;
}
}
}
.el-table {
margin-bottom: 20px;
.rankings-section {
.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>

View File

@@ -61,6 +61,17 @@
@success="handleFormSuccess"
@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>
<!-- 任务详情弹窗 -->
@@ -243,7 +254,7 @@
import { ref, onMounted } from 'vue';
import { ElPagination, ElMessage, ElMessageBox } from 'element-plus';
import { AdminLayout } from '@/views/admin';
import { TaskCard } from './index';
import { TaskCard, TaskStatics } from './components';
import { GenericSelector } from '@/components/base';
import { LearningTaskAdd } from '@/views/public/task';
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 currentTaskName = ref<string>('');
const loading = ref(false);
const taskList = ref<TaskVO[]>([]);
@@ -411,9 +423,9 @@ async function handleUnpublish(task: TaskVO) {
* 统计
*/
function handleStatistics(task: TaskVO) {
ElMessage.info('统计功能待开发');
// TODO: 显示统计弹窗或其他实现
console.log('查看统计:', task);
currentView.value = 'statistics';
currentTaskId.value = task.learningTask.taskID;
currentTaskName.value = task.learningTask.name || '';
}
/**
@@ -804,6 +816,15 @@ function handleFormCancel() {
currentView.value = 'list';
currentTaskId.value = undefined;
}
/**
* 从统计页面返回列表
*/
function handleStatisticsBack() {
currentView.value = 'list';
currentTaskId.value = undefined;
currentTaskName.value = '';
}
</script>
<style lang="scss" scoped>
@@ -843,6 +864,52 @@ function handleFormCancel() {
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 {
position: fixed;
@@ -1034,54 +1101,54 @@ function handleFormCancel() {
margin-bottom: 20px;
}
.btn-success,
.btn-danger,
.btn-default {
padding: 10px 24px;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: all 0.3s;
border: none;
// .btn-success,
// .btn-danger,
// .btn-default {
// padding: 10px 24px;
// border-radius: 4px;
// font-size: 14px;
// cursor: pointer;
// transition: all 0.3s;
// border: none;
.icon {
margin-right: 4px;
}
// .icon {
// margin-right: 4px;
// }
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
// &:disabled {
// opacity: 0.6;
// cursor: not-allowed;
// }
// }
.btn-success {
background: #67c23a;
color: #fff;
// .btn-success {
// background: #67c23a;
// color: #fff;
&:hover:not(:disabled) {
background: #85ce61;
}
}
// &:hover:not(:disabled) {
// background: #85ce61;
// }
// }
.btn-danger {
background: #f56c6c;
color: #fff;
// .btn-danger {
// background: #f56c6c;
// color: #fff;
&:hover:not(:disabled) {
background: #f78989;
}
}
// &:hover:not(:disabled) {
// background: #f78989;
// }
// }
.btn-default {
background: #fff;
color: #606266;
border: 1px solid #dcdfe6;
// .btn-default {
// background: #fff;
// color: #606266;
// border: 1px solid #dcdfe6;
&:hover {
color: #409eff;
border-color: #409eff;
}
}
// &:hover {
// color: #409eff;
// border-color: #409eff;
// }
// }
.current-user-list {
border: 1px solid #e0e0e0;

View File

@@ -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>

View File

@@ -0,0 +1,2 @@
export { default as TaskCard } from './TaskCard.vue';
export { default as TaskStatics } from './TaskStatics.vue';

View File

@@ -1 +0,0 @@
export { default as TaskCard } from './TaskCard.vue';