kindergarten_java/reading-platform-frontend/src/views/admin/DashboardView.vue
En 6f64723428 feat: 教师端数据看板与学校端课程统计功能
教师端数据看板:
- 新增 TeacherDashboardResponse/TeacherLessonVO/TeacherLessonTrendVO
- 新增 TeacherWeeklyStatsResponse 周统计响应
- 新增 TeacherActivityLevel 枚举和 TeacherActivityRankResponse 活跃度排行
- 实现教师端课程统计、任务完成详情、任务反馈接口

学校端课程统计:
- 新增 CourseUsageVO/CourseUsageStatsVO/CoursePackageVO
- 新增 SchoolCourseResponse 和学校端课程使用查询接口
- 实现学校端统计数据和课程趋势接口

用户资料功能:
- 新增 UpdateProfileRequest/UpdateProfileResponse
- 实现用户资料更新接口

前后端对齐:
- 更新 OpenAPI 规范和前端 API 类型生成
- 优化 DashboardView 组件和 API 调用

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 12:45:56 +08:00

568 lines
14 KiB
Vue

<template>
<div class="dashboard">
<!-- 统计卡片 -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon" style="background: linear-gradient(135deg, #6366F1 0%, #4F46E5 100%);">
<Building2 :size="24" :stroke-width="1.5" />
</div>
<div class="stat-content">
<span class="stat-value">{{ statsData.tenantCount }}</span>
<span class="stat-label">租户总数</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: linear-gradient(135deg, #10B981 0%, #059669 100%);">
<BookOpen :size="24" :stroke-width="1.5" />
</div>
<div class="stat-content">
<span class="stat-value">{{ statsData.courseCount }}</span>
<span class="stat-label">课程包总数</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: linear-gradient(135deg, #F59E0B 0%, #D97706 100%);">
<PlayCircle :size="24" :stroke-width="1.5" />
</div>
<div class="stat-content">
<span class="stat-value">{{ statsData.monthlyLessons }}</span>
<span class="stat-label">月授课次数</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: linear-gradient(135deg, #EC4899 0%, #DB2777 100%);">
<Users :size="24" :stroke-width="1.5" />
</div>
<div class="stat-content">
<span class="stat-value">{{ statsData.studentCount }}</span>
<span class="stat-label">学生总数</span>
</div>
</div>
</div>
<!-- 趋势图和快捷入口 -->
<a-row :gutter="24" style="margin-top: 24px">
<a-col :span="16">
<a-card title="使用趋势" :bordered="false" :loading="trendLoading" class="modern-card">
<div ref="trendChartRef" style="height: 300px"></div>
</a-card>
</a-col>
<a-col :span="8">
<a-card title="快捷入口" :bordered="false" class="modern-card">
<div class="quick-actions">
<div class="quick-action-item" @click="$router.push('/admin/packages/create')">
<div class="action-icon">
<Plus :size="20" :stroke-width="1.5" />
</div>
<span>创建课程包</span>
</div>
<div class="quick-action-item" @click="$router.push('/admin/tenants')">
<div class="action-icon">
<Building2 :size="20" :stroke-width="1.5" />
</div>
<span>管理租户</span>
</div>
<div class="quick-action-item" @click="$router.push('/admin/resources')">
<div class="action-icon">
<FolderOpen :size="20" :stroke-width="1.5" />
</div>
<span>资源库</span>
</div>
</div>
</a-card>
</a-col>
</a-row>
<!-- 活跃租户和热门课程 -->
<a-row :gutter="24" style="margin-top: 24px">
<a-col :span="12">
<a-card title="活跃租户 TOP5" :bordered="false" :loading="tenantsLoading" class="modern-card">
<div v-if="activeTenants.length > 0" class="rank-list">
<div v-for="(item, index) in activeTenants" :key="item.id" class="rank-item" @click="viewTenantDetail(item.id)">
<div class="rank-number" :class="'rank-' + (index + 1)">{{ index + 1 }}</div>
<div class="rank-content">
<span class="rank-name">{{ item.name }}</span>
<span class="rank-desc">活跃教师:{{ item.activeTeacherCount }} 人</span>
</div>
<a-tag color="blue">{{ item.completedLessonCount }} 课次</a-tag>
</div>
</div>
<a-empty v-else description="暂无数据" />
</a-card>
</a-col>
<a-col :span="12">
<a-card title="热门课程包 TOP5" :bordered="false" :loading="coursesLoading" class="modern-card">
<div v-if="popularCourses.length > 0" class="rank-list">
<div v-for="(item, index) in popularCourses" :key="item.id" class="rank-item" @click="viewCourseDetail(item.id)">
<div class="rank-number" :class="'rank-' + (index + 1)">{{ index + 1 }}</div>
<div class="rank-content">
<span class="rank-name">{{ item.name }}</span>
<span class="rank-desc">使用教师: {{ item.teacherCount }}</span>
</div>
<a-tag color="green">{{ item.usageCount }} 次</a-tag>
</div>
</div>
<a-empty v-else description="暂无数据" />
</a-card>
</a-col>
</a-row>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import * as echarts from 'echarts';
import dayjs from 'dayjs';
// Lucide icons
import {
Building2,
BookOpen,
PlayCircle,
Users,
Plus,
FolderOpen,
} from 'lucide-vue-next';
import {
getAdminStats,
getTrendData,
getActiveTenants,
getPopularCourses,
type AdminStats,
type TrendData,
type ActiveTenant,
type PopularCourse,
} from '@/api/admin';
const router = useRouter();
// Chart refs
const trendChartRef = ref<HTMLElement>();
let trendChart: echarts.ECharts | null = null;
// Loading states
const trendLoading = ref(false);
const tenantsLoading = ref(false);
const coursesLoading = ref(false);
// 统计数据
const statsData = ref<AdminStats>({
tenantCount: 0,
courseCount: 0,
monthlyLessons: 0,
studentCount: 0,
lessonCount: 0,
activeTenantCount: 0,
publishedCourseCount: 0,
teacherCount: 0,
});
// 活跃租户
const activeTenants = ref<ActiveTenant[]>([]);
// 热门课程包
const popularCourses = ref<PopularCourse[]>([]);
// Navigation
const viewTenantDetail = (id: number) => {
router.push(`/admin/tenants?id=${id}`);
};
const viewCourseDetail = (id: number) => {
router.push(`/admin/packages/${id}`);
};
// Initialize trend chart
const initTrendChart = (data: TrendData[]) => {
if (!trendChartRef.value) return;
if (trendChart) {
trendChart.dispose();
}
trendChart = echarts.init(trendChartRef.value);
// 如果没有数据,显示空状态
if (!data || data.length === 0) {
trendChart.setOption({
title: {
text: '暂无数据',
left: 'center',
top: 'center',
textStyle: {
color: '#999',
fontSize: 14,
},
},
});
return;
}
const option: echarts.EChartsOption = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
},
},
legend: {
data: ['授课次数', '学生数'],
top: 0,
right: 10,
itemWidth: 16,
itemHeight: 10,
textStyle: {
fontSize: 12,
color: '#666',
},
},
grid: {
left: '3%',
right: '4%',
top: '18%',
bottom: '8%',
containLabel: true,
},
xAxis: {
type: 'category',
data: data.map((d) => d.month),
axisLine: {
lineStyle: {
color: '#E5E7EB',
},
},
},
yAxis: [
{
type: 'value',
name: '授课次数',
position: 'left',
interval: 1,
splitLine: {
lineStyle: {
color: '#F3F4F6',
},
},
},
{
type: 'value',
name: '学生数',
position: 'right',
interval: 1,
splitLine: {
show: false,
},
},
],
series: [
{
name: '授课次数',
type: 'bar',
data: data.map((d) => d.lessonCount),
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#6366F1' },
{ offset: 1, color: '#4F46E5' },
]),
borderRadius: [4, 4, 0, 0],
},
},
{
name: '学生数',
type: 'line',
yAxisIndex: 1,
data: data.map((d) => d.studentCount),
smooth: true,
itemStyle: {
color: '#10B981',
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(16, 185, 129, 0.3)' },
{ offset: 1, color: 'rgba(16, 185, 129, 0)' },
]),
},
},
],
};
trendChart.setOption(option);
};
// Fetch all data
const fetchStats = async () => {
try {
const stats = await getAdminStats();
statsData.value = stats;
} catch (error) {
console.error('Failed to fetch stats:', error);
}
};
const fetchTrendData = async () => {
trendLoading.value = true;
let trendData: TrendData[] = [];
try {
trendData = await getTrendData();
} catch (error) {
console.error('Failed to fetch trend data:', error);
} finally {
trendLoading.value = false;
}
// 在 loading 结束后初始化图表
await nextTick();
if (trendData && trendData.length > 0) {
initTrendChart(trendData);
}
};
const fetchActiveTenants = async () => {
tenantsLoading.value = true;
try {
activeTenants.value = await getActiveTenants(5);
} catch (error) {
console.error('Failed to fetch active tenants:', error);
} finally {
tenantsLoading.value = false;
}
};
const fetchPopularCourses = async () => {
coursesLoading.value = true;
try {
popularCourses.value = await getPopularCourses(5);
} catch (error) {
console.error('Failed to fetch popular courses:', error);
} finally {
coursesLoading.value = false;
}
};
// Handle window resize
const handleResize = () => {
trendChart?.resize();
};
onMounted(() => {
fetchStats();
fetchTrendData();
fetchActiveTenants();
fetchPopularCourses();
window.addEventListener('resize', handleResize);
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
trendChart?.dispose();
});
</script>
<style scoped lang="scss">
// 现代化配色
$primary-color: #6366F1;
$primary-light: #EEF2FF;
$success-color: #10B981;
$warning-color: #F59E0B;
$pink-color: #EC4899;
$text-color: #1F2937;
$text-secondary: #6B7280;
$border-color: #E5E7EB;
$bg-light: #F9FAFB;
.dashboard {
// 统计卡片网格
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
}
.stat-card {
display: flex;
align-items: center;
padding: 20px;
background: white;
border-radius: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
transition: all 0.3s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.stat-icon {
width: 56px;
height: 56px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
color: white;
margin-right: 16px;
}
.stat-content {
display: flex;
flex-direction: column;
.stat-value {
font-size: 28px;
font-weight: 700;
color: $text-color;
line-height: 1.2;
}
.stat-label {
font-size: 14px;
color: $text-secondary;
margin-top: 4px;
}
}
}
// 现代化卡片
.modern-card {
border-radius: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
:deep(.ant-card-head) {
border-bottom: 1px solid $border-color;
.ant-card-head-title {
font-weight: 600;
color: $text-color;
}
}
}
// 快捷入口
.quick-actions {
display: flex;
flex-direction: column;
gap: 12px;
.quick-action-item {
display: flex;
align-items: center;
padding: 12px 16px;
background: $bg-light;
border-radius: 10px;
cursor: pointer;
transition: all 0.25s ease;
&:hover {
background: $primary-light;
transform: translateX(4px);
.action-icon {
background: $primary-color;
color: white;
}
}
.action-icon {
width: 36px;
height: 36px;
border-radius: 10px;
background: white;
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
color: $primary-color;
transition: all 0.25s ease;
}
span {
font-weight: 500;
color: $text-color;
}
}
}
// 排名列表
.rank-list {
.rank-item {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid $border-color;
cursor: pointer;
transition: all 0.25s ease;
&:last-child {
border-bottom: none;
}
&:hover {
background: $bg-light;
margin: 0 -16px;
padding: 12px 16px;
border-radius: 8px;
}
.rank-number {
width: 28px;
height: 28px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 14px;
margin-right: 12px;
background: $bg-light;
color: $text-secondary;
&.rank-1 {
background: linear-gradient(135deg, #F59E0B 0%, #D97706 100%);
color: white;
}
&.rank-2 {
background: linear-gradient(135deg, #9CA3AF 0%, #6B7280 100%);
color: white;
}
&.rank-3 {
background: linear-gradient(135deg, #CD7F32 0%, #B8860B 100%);
color: white;
}
}
.rank-content {
flex: 1;
display: flex;
flex-direction: column;
.rank-name {
font-weight: 500;
color: $text-color;
}
.rank-desc {
font-size: 12px;
color: $text-secondary;
margin-top: 2px;
}
}
}
}
}
@media (max-width: 1200px) {
.dashboard .stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.dashboard .stats-grid {
grid-template-columns: 1fr;
}
}
</style>