学习进度统计

This commit is contained in:
2025-11-17 13:16:12 +08:00
parent cb401eebe1
commit 4b167058b6
15 changed files with 213 additions and 66 deletions

View File

@@ -111,6 +111,9 @@
<Logger name="org.xyzh.news.mapper" level="debug" additivity="false">
<AppenderRef ref="Console"/>
</Logger>
<Logger name="org.xyzh.study.mapper" level="debug" additivity="false">
<AppenderRef ref="Console"/>
</Logger>
<Logger name="org.xyzh.crontab.mapper" level="debug" additivity="false">
<AppenderRef ref="Console"/>
</Logger>

View File

@@ -246,4 +246,13 @@ public interface LearningTaskService {
* @since 2025-11-14
*/
ResultDomain<Integer> getLearningTaskCount(TbLearningTask filter);
/**
* @description 获取学习任务统计
* @param filter 过滤条件
* @return ResultDomain<Map<String, Object>> 学习任务统计
* @author yslg
* @since 2025-11-17
*/
ResultDomain<Map<String, Object>> getMyTotalStatistics(String startTime, String endTime);
}

View File

@@ -180,4 +180,15 @@ public class LearningTaskController {
return learningTaskService.getTaskStatisticsRankings(taskID);
}
/**
* @description 首页任务学习进度统计,
* @param
* @author yslg
* @since 2025-11-17
*/
@GetMapping("/statistics")
public ResultDomain<Map<String, Object>> getStatistics(@RequestParam("startTime") String startTime, @RequestParam("endTime") String endTime) {
return learningTaskService.getMyTotalStatistics(startTime, endTime);
}
}

View File

@@ -5,6 +5,7 @@ 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.dto.study.TbLearningTask;
import org.xyzh.common.vo.UserDeptRoleVO;
import java.util.List;

View File

@@ -9,6 +9,7 @@ import org.xyzh.common.vo.UserDeptRoleVO;
import org.xyzh.common.vo.TaskItemVO;
import java.util.List;
import java.util.Map;
/**
* @description LearningTaskMapper.java文件描述 学习任务数据访问层
@@ -182,4 +183,17 @@ public interface LearningTaskMapper extends BaseMapper<TbLearningTask> {
* @since 2025-10-15
*/
long countLearningTasks(@Param("filter") TbLearningTask filter, @Param("userDeptRoles") List<UserDeptRoleVO> userDeptRoles);
/**
* @description 获取任务统计(按标签分组,统计指定时间范围内当前用户任务的完成进度)
* @param filter 过滤条件(包含开始、结束时间)
* @param userDeptRoles 用户部门角色列表
* @param userId 当前用户ID
* @return List<Map<String, Object>> 任务统计列表
* @author yslg
* @since 2025-10-15
*/
List<Map<String, Object>> getTaskStaticByTag(@Param("filter") TbLearningTask filter,
@Param("userDeptRoles") List<UserDeptRoleVO> userDeptRoles,
@Param("userId") String userId);
}

View File

@@ -1,6 +1,7 @@
package org.xyzh.study.service.impl;
import java.math.BigDecimal;
import java.text.DateFormat;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
@@ -13,6 +14,7 @@ import org.springframework.stereotype.Service;
import org.xyzh.common.core.domain.ResultDomain;
import org.xyzh.common.core.enums.TaskItemType;
import org.xyzh.common.dto.study.TbLearningRecord;
import org.xyzh.common.dto.study.TbLearningTask;
import org.xyzh.common.dto.study.TbTaskItem;
import org.xyzh.common.dto.study.TbTaskUser;
import org.xyzh.common.dto.user.TbSysUser;
@@ -241,21 +243,19 @@ public class SCLearningRecordServiceImpl implements LearningRecordService {
int completedRequired = 0;
for (TaskItemVO item : allTaskItems) {
if (item.getRequired() != null && item.getRequired()) {
// if (item.getRequired() != null && item.getRequired()) {
totalRequired++;
if (item.getStatus() != null && item.getStatus() == 2) {
completedRequired++;
} else {
allCompleted = false;
}
}
}
if (!allCompleted) {
return;
// }
}
// 计算总进度
BigDecimal progressPercent = BigDecimal.valueOf(100);
BigDecimal progressPercent = BigDecimal.valueOf(completedRequired/(totalRequired * 1.0) * 100);
// 更新 task_user 状态
TbTaskUser taskUser = new TbTaskUser();
@@ -265,7 +265,7 @@ public class SCLearningRecordServiceImpl implements LearningRecordService {
taskUser.setUpdater(userId);
// 全部完成
taskUser.setStatus(2);
taskUser.setStatus(totalRequired == completedRequired? 2:1);
taskUser.setCompleteTime(new Date());
taskUserMapper.updateTaskUser(taskUser);
logger.info("更新任务用户状态成功 - taskId: {}, userId: {}, status: {}, progress: {}",

View File

@@ -1,6 +1,8 @@
package org.xyzh.study.service.impl;
import java.math.BigDecimal;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
@@ -827,4 +829,39 @@ public class SCLearningTaskServiceImpl implements LearningTaskService {
}
return resultDomain;
}
@Override
public ResultDomain<Map<String, Object>> getMyTotalStatistics(String startTime, String endTime) {
ResultDomain<Map<String, Object>> resultDomain = new ResultDomain<>();
try {
TbSysUser currentUser = LoginUtil.getCurrentUser();
if (currentUser == null) {
resultDomain.fail("请先登录");
return resultDomain;
}
Map<String, Object> statisticsData = new HashMap<>();
// 获取当前用户的部门角色信息
List<UserDeptRoleVO> userDeptRoles = LoginUtil.getCurrentDeptRole();
// 解析时间范围
TbLearningTask filter = new TbLearningTask();
DateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
filter.setStartTime(df.parse(startTime));
filter.setEndTime(df.parse(endTime));
// 按标签统计当前用户在时间范围内任务的平均完成进度
List<Map<String, Object>> tagProgressList = learningTaskMapper.getTaskStaticByTag(filter, userDeptRoles,
currentUser.getID());
statisticsData.put("tagProgressList", tagProgressList);
resultDomain.success("获取学习记录统计数据成功", statisticsData);
} catch (Exception e) {
logger.error("获取学习记录统计数据失败", e);
resultDomain.fail("获取学习记录统计数据失败:" + e.getMessage());
}
return resultDomain;
}
}

View File

@@ -357,4 +357,9 @@
LIMIT 10
</select>
<!-- getTaskStaticByTag -->
<select id="getTaskStaticByTag">
</select>
</mapper>

View File

@@ -352,4 +352,22 @@
</if>
</select>
<!-- getTaskStaticByTag按标签统计当前用户在时间范围内任务的平均完成进度 -->
<select id="getTaskStaticByTag" resultType="java.util.Map">
SELECT
tt.name AS tagName,
AVG(tu.progress) AS avgProgress
FROM tb_learning_task t
LEFT JOIN tb_task_user tu ON t.task_id = tu.task_id
LEFT JOIN tb_learning_task_tag tlt ON t.task_id = tlt.task_id
LEFT JOIN tb_tag tt ON tlt.tag_id = tt.tag_id
<include refid="Permission_Filter"/>
WHERE t.deleted = 0
AND tu.deleted = 0
AND tu.user_id = #{userId}
AND tu.progress IS NOT NULL
AND t.create_time BETWEEN #{filter.startTime} AND #{filter.endTime}
GROUP BY tt.tag_id, tt.name
</select>
</mapper>

View File

@@ -57,6 +57,19 @@ export const learningTaskApi = {
const response = await api.post<TaskVO>(`${this.learningTaskPrefix}/page`, {pageParam, filter});
return response.data;
},
/**
* 首页学习进度统计(按标签)
* @param startTime 开始时间格式yyyy-MM-dd HH:mm:ss
* @param endTime 结束时间格式yyyy-MM-dd HH:mm:ss
*/
async getMyStatistics(startTime: string, endTime: string): Promise<ResultDomain<any>> {
const response = await api.get<any>(`${this.learningTaskPrefix}/statistics`, {
startTime,
endTime,
});
return response.data;
},
/**
* 创建学习任务
* @param task 任务数据

View File

@@ -6,12 +6,12 @@
<div class="summary-content">
<div class="summary-item">
<div class="summary-label">总学习人数</div>
<div class="summary-value">{{ taskInfo.totalTaskNum || 0 }}</div>
<div class="summary-value">{{ totalPerson }}</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 class="summary-value completed">{{ completedPerson }}</div>
</div>
<div class="summary-divider"></div>
<div class="summary-item">
@@ -138,6 +138,7 @@ import * as echarts from 'echarts';
import type { ECharts } from 'echarts';
import { learningTaskApi } from '@/apis/study/learning-task';
import { ElMessage } from 'element-plus';
import { TaskVO } from '@/types';
// Props
const props = defineProps<{
@@ -146,7 +147,7 @@ const props = defineProps<{
// 响应式数据
const loading = ref(false);
const taskInfo = ref<any>({});
const taskInfo = ref<TaskVO>();
const durationDistribution = ref<any[]>([]);
const progressDistribution = ref<any[]>([]);
const completionRanking = ref<any[]>([]);
@@ -158,12 +159,12 @@ const progressChartRef = ref<HTMLElement>();
let durationChart: ECharts | null = null;
let progressChart: ECharts | null = null;
const totalPerson = computed(() => taskInfo.value?.taskUsers?.length || 0);
const completedPerson = computed(() => taskInfo.value?.taskUsers?.filter((user: any) => user.status === 2).length || 0);
// 计算完成率
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);
if (totalPerson.value === 0) return 0;
return ((completedPerson.value / totalPerson.value) * 100).toFixed(1);
});
// 获取排名样式类

View File

@@ -1,6 +1,7 @@
<template>
<ArticleShow
:resource-id="articleId"
:task-id="taskId"
:show-back-button="true"
back-button-text="返回"
@back="handleBack"
@@ -20,6 +21,7 @@ const router = useRouter();
const route = useRoute();
const articleId = computed(() => route.query.articleId as string || '');
const taskId = computed(() => route.query.taskId as string || '');
// 返回上一页
function handleBack() {

View File

@@ -113,6 +113,7 @@ interface Props {
width?: string; // Dialog 宽度
articleData?: Resource; // 文章数据Dialog 模式使用)
resourceID?: string; // 资源ID路由模式使用
taskId?: string; // 任务ID路由模式使用
showEditButton?: boolean; // 是否显示编辑按钮
showBackButton?: boolean; // 是否显示返回按钮(路由模式)
backButtonText?: string; // 返回按钮文本
@@ -181,7 +182,7 @@ onMounted(() => {
// 路由模式下,从路由参数加载文章
if (!props.asDialog) {
const articleId = route.query.articleId as string;
const taskId = route.query.taskId as string;
const taskId = props.taskId || (route.query.taskId as string);
// 如果传入了 articleData则不需要从路由加载
if (props.articleData && Object.keys(props.articleData).length > 0) {
@@ -325,15 +326,6 @@ async function loadLearningRecord(resourceID: string) {
}
}
// 生成学习记录的taskId当没有真实taskId时
function generateTaskId(resourceID: string, userID: string): string {
// 使用简短格式确保不超过50字符SA_{resourceID的hash}_{userID的hash}
// SA = Self-study Article
const resourceHash = hashString(resourceID).substring(0, 16);
const userHash = hashString(userID).substring(0, 16);
return `SA_${resourceHash}_${userHash}`; // 长度3 + 16 + 1 + 16 = 36字符
}
// 简单的字符串哈希函数
function hashString(str: string): string {
let hash = 0;
@@ -351,9 +343,9 @@ async function createLearningRecord(resourceID: string) {
if (!userInfo.value?.id) return;
try {
const taskId = route.query.taskId as string;
const taskId = props.taskId || (route.query.taskId as string);
// 如果没有taskId生成一个自学任务ID
const effectiveTaskId = taskId || generateTaskId(resourceID, userInfo.value.id!);
const effectiveTaskId = taskId
const res = await learningRecordApi.createRecord({
userID: userInfo.value.id,
@@ -445,7 +437,7 @@ async function markArticleComplete() {
try {
// 使用learningRecord中保存的taskID可能是真实任务ID或生成的自学ID
const taskId = learningRecord.value.taskID || (route.query.taskId as string);
const taskId = learningRecord.value.taskID || props.taskId || (route.query.taskId as string);
await learningRecordApi.markComplete({
id: learningRecord.value.id,
@@ -547,11 +539,19 @@ async function createHistoryRecord(resourceID: string) {
if (!userInfo.value?.id) return;
try {
const res = await learningHistoryApi.recordResourceView(
userInfo.value.id,
resourceID,
0 // 初始时长为0
);
const taskId = props.taskId || (route.query.taskId as string);
// 直接创建学习历史对象,包含 taskID
const historyData: TbLearningHistory = {
userID: userInfo.value.id,
resourceType: 1, // 1资源/新闻
resourceID: resourceID,
duration: 0,
deviceType: 'web',
taskID: taskId || undefined // 如果没有 taskId传 undefined
};
const res = await learningHistoryApi.recordLearningHistory(historyData);
if (res.success && res.data) {
learningHistory.value = res.data;
@@ -597,7 +597,8 @@ async function saveHistoryRecord() {
const updatedHistory: TbLearningHistory = {
...learningHistory.value,
duration: (learningHistory.value.duration || 0) + duration,
endTime: new Date().toISOString()
endTime: new Date().toISOString(),
taskID: learningHistory.value.taskID // 保持原有的 taskID
};
// 调用API更新学习历史
@@ -654,7 +655,7 @@ function handleBack() {
}
stopLearningTimer();
const taskId = route.query.taskId as string;
const taskId = props.taskId || (route.query.taskId as string);
// 如果有 taskId返回任务详情
if (taskId) {
router.push({

View File

@@ -3,7 +3,7 @@
<div class="progress-header">
<div class="header-left">
<h3 class="progress-title">学习进度</h3>
<p class="update-time">更新时间2025-09-25 18:30:00</p>
<p class="update-time">更新时间{{ updateTime || '-' }}</p>
</div>
<div class="tab-buttons">
<button v-for="(tab, index) in tabs" :key="index" :class="['tab-btn', { active: activeTab === index }]"
@@ -21,38 +21,51 @@
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import * as echarts from 'echarts';
import type { EChartsOption } from 'echarts';
import dayjs from 'dayjs';
import { learningTaskApi } from '@/apis/study/learning-task';
const tabs = ref(['今日', '近一周', '近一月']);
const activeTab = ref(0);
const chartRef = ref<HTMLElement | null>(null);
let chartInstance: echarts.ECharts | null = null;
// 模拟不同时间段的数据
const chartDataMap = {
0: [
{ name: '党史', value: 43, highlight: false },
{ name: '理论', value: 58, highlight: false },
{ name: '政策', value: 34, highlight: false },
{ name: '思政', value: 20, highlight: false },
{ name: '文化', value: 52, highlight: true }
],
1: [
{ name: '党史', value: 68, highlight: true },
{ name: '理论', value: 52, highlight: false },
{ name: '政策', value: 45, highlight: false },
{ name: '思政', value: 38, highlight: false },
{ name: '文化', value: 41, highlight: false }
],
2: [
{ name: '党史', value: 85, highlight: true },
{ name: '理论', value: 72, highlight: false },
{ name: '政策', value: 58, highlight: false },
{ name: '思政', value: 65, highlight: false },
{ name: '文化', value: 43, highlight: false }
]
};
// 后端返回的数据结构映射为图表数据
const chartData = ref<{ name: string; value: number; highlight: boolean }[]>([]);
const chartData = computed(() => chartDataMap[activeTab.value as keyof typeof chartDataMap]);
const updateTime = ref('');
async function loadStatistics() {
// 根据当前 tab 计算时间范围
const now = dayjs();
let start: dayjs.Dayjs;
if (activeTab.value === 0) {
// 今日:从今天 00:00:00 到现在
start = now.startOf('day');
} else if (activeTab.value === 1) {
// 近一周:过去 7 天
start = now.subtract(6, 'day').startOf('day');
} else {
// 近一月:过去 30 天
start = now.subtract(29, 'day').startOf('day');
}
const startTime = start.format('YYYY-MM-DD HH:mm:ss');
const endTime = now.format('YYYY-MM-DD HH:mm:ss');
const res = await learningTaskApi.getMyStatistics(startTime, endTime);
if (res.code === 200 && res.data) {
const list = (res.data.tagProgressList || []) as { tagName: string; avgProgress: number }[];
chartData.value = list.map(item => ({
name: item.tagName,
value: Number(item.avgProgress ?? 0),
highlight: false,
}));
updateTime.value = now.format('YYYY-MM-DD HH:mm:ss');
} else {
chartData.value = [];
}
}
// 图表配置
function getChartOption(): EChartsOption {
@@ -155,8 +168,9 @@ function handleResize() {
}
// 处理标签切换
function handleTabChange(index: number) {
async function handleTabChange(index: number) {
activeTab.value = index;
await loadStatistics();
if (chartInstance) {
chartInstance.setOption(chartOption.value, true);
}
@@ -169,8 +183,12 @@ watch(() => chartOption.value, () => {
}
}, { deep: true });
onMounted(() => {
onMounted(async () => {
initChart();
await loadStatistics();
if (chartInstance) {
chartInstance.setOption(chartOption.value, true);
}
});
onUnmounted(() => {

View File

@@ -62,13 +62,14 @@
</div>
<div class="form-group">
<label class="form-label">任务分类标签</label>
<label class="form-label required">任务分类标签</label>
<select
v-model="selectedTagID"
class="form-input form-select"
@change="handleTagChange"
@blur="validateTag"
>
<option value="">请选择分类标签可选</option>
<option value="">请选择分类标签</option>
<option
v-for="tag in availableTags"
:key="tag.tagID"
@@ -77,6 +78,7 @@
{{ tag.name }}
</option>
</select>
<span v-if="errors.tag" class="error-msg">{{ errors.tag }}</span>
<div v-if="selectedTag" class="selected-tag-preview">
<div class="tag-badge" :style="{ backgroundColor: selectedTag.color || '#409eff' }">
{{ selectedTag.name }}
@@ -350,7 +352,8 @@ const taskData = ref<TaskVO>({
const errors = ref({
name: '',
startTime: '',
endTime: ''
endTime: '',
tag: ''
});
// 选中的数据
@@ -732,6 +735,7 @@ function handleTagChange() {
} else {
selectedTag.value = null;
}
validateTag();
}
// 表单验证
@@ -744,6 +748,15 @@ function validateTaskName() {
return true;
}
function validateTag() {
if (!selectedTagID.value) {
errors.value.tag = '请选择任务分类标签';
return false;
}
errors.value.tag = '';
return true;
}
function validateTime() {
if (!taskData.value.learningTask.startTime) {
errors.value.startTime = '请选择开始时间';
@@ -765,8 +778,9 @@ function validateTime() {
function validateForm() {
const isNameValid = validateTaskName();
const isTimeValid = validateTime();
const isTagValid = validateTag();
if (!isNameValid || !isTimeValid) {
if (!isNameValid || !isTimeValid || !isTagValid) {
return false;
}