教师端数据看板: - 新增 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>
568 lines
14 KiB
Vue
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>
|