数据统计

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

@@ -0,0 +1,354 @@
<template>
<div class="task-card">
<!-- 卡片头部 -->
<div class="card-header">
<div class="header-content">
<h3 class="task-title">{{ task.learningTask.name }}</h3>
<div class="task-meta">
<!-- <div class="meta-item">
<img class="icon" src="@/assets/imgs/usermange.svg" alt="部门" />
<span class="meta-text">{{ getDeptName }}</span>
</div> -->
<div class="meta-item">
<img class="icon" src="@/assets/imgs/calendar-icon.svg" alt="日期" />
<span class="meta-text">截止: {{ formatDate(task.learningTask.endTime) }}</span>
</div>
</div>
</div>
<div class="status-badge" :class="getStatusClass(task.learningTask.status)">
{{ getStatusText(task.learningTask.status, task.learningTask.startTime, task.learningTask.endTime) }}
</div>
</div>
<!-- 统计信息 -->
<div class="card-stats">
<div class="stat-item">
<div class="stat-label">分配人数</div>
<div class="stat-value">{{ task.totalTaskNum || 0 }}</div>
</div>
<div class="stat-item stat-completed">
<div class="stat-label">已完成</div>
<div class="stat-value stat-value-completed">{{ task.completedTaskNum || 0 }}</div>
</div>
<div class="stat-item stat-rate">
<div class="stat-label">完成率</div>
<div class="stat-value stat-value-rate">{{ completionRate }}</div>
</div>
</div>
<!-- 进度条 -->
<div class="card-progress">
<div class="progress-header">
<span class="progress-label">完成进度</span>
<span class="progress-value">{{ completionRate }}</span>
</div>
<div class="progress-bar">
<div class="progress-fill" :style="{ width: completionRate }"></div>
</div>
</div>
<!-- 操作按钮 -->
<div class="card-footer">
<div class="card-actions">
<button class="btn-link btn-primary" @click="$emit('view', task)">查看</button>
<button class="btn-link btn-warning" @click="$emit('edit', task)" v-if="task.learningTask.status !== 1">编辑</button>
<button class="btn-link btn-success" @click="$emit('publish', task)" v-if="task.learningTask.status !== 1">发布</button>
<button class="btn-link btn-warning" @click="$emit('unpublish', task)" v-if="task.learningTask.status === 1">下架</button>
<button class="btn-link btn-primary" @click="$emit('statistics', task)" v-if="task.learningTask.status !== 0">统计</button>
<button class="btn-link btn-warning" @click="$emit('update-user', task)">修改人员</button>
<button class="btn-link btn-danger" @click="$emit('delete', task)" v-if="task.learningTask.status === 0">删除</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import type { TaskVO } from '@/types/study';
interface Props {
task: TaskVO;
}
const props = defineProps<Props>();
defineEmits<{
view: [task: TaskVO];
edit: [task: TaskVO];
publish: [task: TaskVO];
unpublish: [task: TaskVO];
statistics: [task: TaskVO];
'update-user': [task: TaskVO];
delete: [task: TaskVO];
}>();
/**
* 获取部门名称
*/
// const getDeptName = computed(() => {
// if (props.task.taskUsers && props.task.taskUsers.length > 0) {
// const deptNames = new Set(props.task.taskUsers.map(u => u.deptName).filter(Boolean));
// if (deptNames.size === 0) return '全校';
// if (deptNames.size === 1) return Array.from(deptNames)[0] || '全校';
// return '多个部门';
// }
// return '全校';
// });
/**
* 格式化日期
*/
function formatDate(dateStr?: string): string {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).replace(/\//g, '-');
}
/**
* 获取状态样式类
*/
function getStatusClass(status?: number): string {
switch (status) {
case 0:
return 'status-draft';
case 1:
return 'status-ongoing';
case 2:
return 'status-ended';
default:
return '';
}
}
/**
* 获取状态文本
*/
function getStatusText(status?: number, startTime?: string, endTime?: string): string {
if (status === 0) return '草稿';
if (status === 1) {
const now = new Date();
const start = new Date(startTime!);
const end = new Date(endTime!);
if (now >= start && now <= end) {
return '进行中';
} else if (now < start) {
return '未开始';
} else {
return '已结束';
}
}
if (status === 2) return '下架';
return '未知';
}
/**
* 计算完成率
*/
const completionRate = computed(() => {
const total = props.task.totalTaskNum || 0;
const completed = props.task.completedTaskNum || 0;
if (total === 0) return '0%';
const rate = Math.round((completed / total) * 100);
return `${rate}%`;
});
</script>
<style lang="scss" scoped>
.task-card {
background: #F9FAFB;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 14px;
padding: 24px;
display: flex;
flex-direction: column;
gap: 24px;
transition: all 0.3s;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
}
// 卡片头部
.card-header {
display: flex;
justify-content: space-between;
gap: 16px;
}
.header-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.task-title {
font-size: 16px;
font-weight: 400;
line-height: 1.5em;
letter-spacing: -1.953125%;
color: #101828;
margin: 0;
}
.task-meta {
display: flex;
align-items: center;
gap: 16px;
}
.meta-item {
display: flex;
align-items: center;
gap: 4px;
.icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.meta-text {
font-size: 14px;
font-weight: 400;
line-height: 1.428571em;
letter-spacing: -1.07421875%;
color: #4A5565;
}
}
.status-badge {
display: flex;
align-items: center;
justify-content: center;
padding: 2px 8px;
height: 22px;
border-radius: 8px;
font-size: 12px;
font-weight: 500;
line-height: 1.333333em;
white-space: nowrap;
&.status-draft {
background: #F4F4F5;
color: #909399;
border: 1px solid transparent;
}
&.status-ongoing {
background: #DBEAFE;
color: #1447E6;
border: 1px solid transparent;
}
&.status-ended {
background: #F0F9FF;
color: #409EFF;
border: 1px solid transparent;
}
}
// 统计信息
.card-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.stat-item {
background: #FFFFFF;
border-radius: 10px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 4px;
}
.stat-label {
font-size: 14px;
font-weight: 400;
line-height: 1.428571em;
letter-spacing: -1.07421875%;
color: #4A5565;
}
.stat-value {
font-size: 20px;
font-weight: 400;
line-height: 1.4em;
letter-spacing: -2.24609375%;
color: #101828;
}
.stat-value-completed {
color: #00A63E;
}
.stat-value-rate {
color: #155DFC;
}
// 进度条
.card-progress {
display: flex;
flex-direction: column;
gap: 8px;
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.progress-label {
font-size: 14px;
font-weight: 400;
line-height: 1.428571em;
letter-spacing: -1.07421875%;
color: #4A5565;
}
.progress-value {
font-size: 14px;
font-weight: 400;
line-height: 1.428571em;
letter-spacing: -1.07421875%;
color: #101828;
}
.progress-bar {
width: 100%;
height: 8px;
background: rgba(3, 2, 19, 0.2);
border-radius: 16777200px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: #030213;
border-radius: 16777200px;
transition: width 0.3s ease;
}
// 卡片底部
.card-footer {
padding-top: 16px;
border-top: 1px solid rgba(0, 0, 0, 0.05);
}
// 操作按钮
.card-actions {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 6px;
}
</style>

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';