- reading-platform-backend:NestJS 后端 - reading-platform-frontend:Vue3 前端 - reading-platform-java:Spring Boot 服务端
1178 lines
26 KiB
Vue
1178 lines
26 KiB
Vue
<template>
|
||
<div class="school-dashboard">
|
||
<!-- 欢迎横幅 -->
|
||
<div class="welcome-banner">
|
||
<div class="banner-content">
|
||
<div class="banner-text">
|
||
<h1><HomeOutlined /> 校园阅读管理中心</h1>
|
||
<p>让每一个孩子都能享受阅读的快乐,智慧成长每一天!</p>
|
||
</div>
|
||
<div class="banner-decorations">
|
||
<span class="decoration"><BookOutlined /></span>
|
||
<span class="decoration"><StarOutlined /></span>
|
||
<span class="decoration"><BgColorsOutlined /></span>
|
||
<span class="decoration"><SmileOutlined /></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 统计卡片 -->
|
||
<div class="stats-grid">
|
||
<div class="stat-card" v-for="(stat, index) in statCards" :key="index">
|
||
<div class="stat-icon" :style="{ background: stat.gradient }">
|
||
<component :is="stat.icon" />
|
||
</div>
|
||
<div class="stat-info">
|
||
<div class="stat-value">{{ stat.value }}</div>
|
||
<div class="stat-label">{{ stat.label }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 趋势图和分布图 -->
|
||
<div class="charts-grid">
|
||
<div class="content-card trend-card">
|
||
<div class="card-header">
|
||
<span class="card-icon"><LineChartOutlined /></span>
|
||
<h3>授课趋势</h3>
|
||
</div>
|
||
<div class="card-body" :class="{ 'is-loading': trendLoading }">
|
||
<a-spin v-if="trendLoading" />
|
||
<div v-else ref="trendChartRef" class="chart-container"></div>
|
||
</div>
|
||
</div>
|
||
<div class="content-card distribution-card">
|
||
<div class="card-header">
|
||
<span class="card-icon"><BarChartOutlined /></span>
|
||
<h3>课程分布</h3>
|
||
</div>
|
||
<div class="card-body" :class="{ 'is-loading': distributionLoading }">
|
||
<a-spin v-if="distributionLoading" />
|
||
<div v-else ref="distributionChartRef" class="chart-container"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 主要内容区域 -->
|
||
<div class="content-grid">
|
||
<!-- 近期活动 -->
|
||
<div class="content-card activities-card">
|
||
<div class="card-header">
|
||
<span class="card-icon"><CalendarOutlined /></span>
|
||
<h3>近期课程活动</h3>
|
||
</div>
|
||
<div class="card-body" :class="{ 'is-loading': loading }">
|
||
<a-spin v-if="loading" />
|
||
<div v-else-if="recentActivities.length === 0" class="empty-state">
|
||
<span class="empty-icon"><InboxOutlined /></span>
|
||
<p>暂无近期活动</p>
|
||
</div>
|
||
<div v-else class="activity-list">
|
||
<div
|
||
v-for="item in recentActivities"
|
||
:key="item.id"
|
||
class="activity-item"
|
||
>
|
||
<div class="activity-avatar">
|
||
<BookOutlined />
|
||
</div>
|
||
<div class="activity-content">
|
||
<div class="activity-title">{{ item.title }}</div>
|
||
<div class="activity-time">{{ formatTime(item.time) }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 教师活跃度排行 -->
|
||
<div class="content-card teachers-card">
|
||
<div class="card-header">
|
||
<span class="card-icon"><TrophyOutlined /></span>
|
||
<h3>教师活跃度排行</h3>
|
||
</div>
|
||
<div class="card-body" :class="{ 'is-loading': loading }">
|
||
<a-spin v-if="loading" />
|
||
<div v-else-if="activeTeachers.length === 0" class="empty-state">
|
||
<span class="empty-icon"><TeamOutlined /></span>
|
||
<p>暂无数据</p>
|
||
</div>
|
||
<div v-else class="teacher-list">
|
||
<div
|
||
v-for="(item, index) in activeTeachers"
|
||
:key="item.id"
|
||
class="teacher-item"
|
||
>
|
||
<div class="rank-badge" :class="'rank-' + (index + 1)">
|
||
{{ index + 1 }}
|
||
</div>
|
||
<div class="teacher-info">
|
||
<div class="teacher-name">{{ item.name }}</div>
|
||
<div class="teacher-lessons">
|
||
<span class="lesson-icon"><ReadOutlined /></span>
|
||
授课 {{ item.lessonCount }} 次
|
||
</div>
|
||
</div>
|
||
<div class="teacher-medal">
|
||
<component :is="getMedalIcon(index)" :style="getMedalStyle(index)" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 课程使用统计 -->
|
||
<div class="course-stats-card">
|
||
<div class="card-header">
|
||
<span class="card-icon"><BarChartOutlined /></span>
|
||
<h3>课程使用统计</h3>
|
||
<div class="header-extra">
|
||
<a-range-picker
|
||
v-model:value="dateRange"
|
||
@change="loadCourseStats"
|
||
:placeholder="['开始日期', '结束日期']"
|
||
style="width: 240px;"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div class="card-body" :class="{ 'is-loading': courseStatsLoading }">
|
||
<a-spin v-if="courseStatsLoading" />
|
||
<div v-else-if="courseStats.length === 0" class="empty-state">
|
||
<span class="empty-icon"><LineChartOutlined /></span>
|
||
<p>暂无课程使用数据</p>
|
||
</div>
|
||
<div v-else class="course-list">
|
||
<div
|
||
v-for="(item, index) in courseStats"
|
||
:key="item.courseId"
|
||
class="course-item"
|
||
>
|
||
<div class="course-rank" :class="'top-' + (index + 1)">
|
||
<TrophyFilled v-if="index < 3" class="rank-crown" :style="getTrophyColor(index)" />
|
||
<span v-else>{{ index + 1 }}</span>
|
||
</div>
|
||
<div class="course-name">{{ item.courseName }}</div>
|
||
<div class="course-progress">
|
||
<div class="progress-bar">
|
||
<div
|
||
class="progress-fill"
|
||
:style="{
|
||
width: getUsagePercent(item.usageCount) + '%',
|
||
background: getProgressGradient(index)
|
||
}"
|
||
></div>
|
||
</div>
|
||
<span class="progress-value">{{ item.usageCount }}次</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 数据导出 -->
|
||
<div class="export-section">
|
||
<div class="content-card export-card">
|
||
<div class="card-header">
|
||
<span class="card-icon"><DownloadOutlined /></span>
|
||
<h3>数据导出</h3>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="export-grid">
|
||
<div class="export-item" @click="handleExportLessons">
|
||
<div class="export-icon" style="background: linear-gradient(135deg, #FF8C42 0%, #FFB347 100%);">
|
||
<ReadOutlined />
|
||
</div>
|
||
<div class="export-info">
|
||
<div class="export-title">授课记录</div>
|
||
<div class="export-desc">导出所有授课记录数据</div>
|
||
</div>
|
||
</div>
|
||
<div class="export-item" @click="handleExportTeacherStats">
|
||
<div class="export-icon" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
|
||
<SolutionOutlined />
|
||
</div>
|
||
<div class="export-info">
|
||
<div class="export-title">教师绩效</div>
|
||
<div class="export-desc">导出教师绩效统计数据</div>
|
||
</div>
|
||
</div>
|
||
<div class="export-item" @click="handleExportStudentStats">
|
||
<div class="export-icon" style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);">
|
||
<UserOutlined />
|
||
</div>
|
||
<div class="export-info">
|
||
<div class="export-title">学生统计</div>
|
||
<div class="export-desc">导出学生统计数据</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue';
|
||
import {
|
||
HomeOutlined,
|
||
BookOutlined,
|
||
StarOutlined,
|
||
BgColorsOutlined,
|
||
SmileOutlined,
|
||
SolutionOutlined,
|
||
TeamOutlined,
|
||
UserOutlined,
|
||
ReadOutlined,
|
||
CalendarOutlined,
|
||
InboxOutlined,
|
||
TrophyOutlined,
|
||
TrophyFilled,
|
||
StarFilled,
|
||
BarChartOutlined,
|
||
LineChartOutlined,
|
||
DownloadOutlined,
|
||
} from '@ant-design/icons-vue';
|
||
import * as echarts from 'echarts';
|
||
import { message } from 'ant-design-vue';
|
||
import {
|
||
getSchoolStats,
|
||
getActiveTeachers,
|
||
getRecentActivities,
|
||
getCourseUsageStats,
|
||
getLessonTrend,
|
||
getCourseDistribution,
|
||
exportLessons,
|
||
exportTeacherStats,
|
||
exportStudentStats,
|
||
} from '@/api/school';
|
||
import type { SchoolStats, LessonTrendItem, CourseDistributionItem } from '@/api/school';
|
||
import type { Component } from 'vue';
|
||
import { Dayjs } from 'dayjs';
|
||
|
||
const loading = ref(false);
|
||
const courseStatsLoading = ref(false);
|
||
const trendLoading = ref(false);
|
||
const distributionLoading = ref(false);
|
||
|
||
// Chart refs
|
||
const trendChartRef = ref<HTMLElement>();
|
||
const distributionChartRef = ref<HTMLElement>();
|
||
let trendChart: echarts.ECharts | null = null;
|
||
let distributionChart: echarts.ECharts | null = null;
|
||
|
||
const stats = ref<SchoolStats>({
|
||
teacherCount: 0,
|
||
studentCount: 0,
|
||
classCount: 0,
|
||
lessonCount: 0,
|
||
});
|
||
|
||
const recentActivities = ref<Array<{ id: number; type: string; title: string; time: string }>>([]);
|
||
const activeTeachers = ref<Array<{ id: number; name: string; lessonCount: number }>>([]);
|
||
|
||
const courseStats = ref<Array<{ courseId: number; courseName: string; usageCount: number }>>([]);
|
||
const dateRange = ref<[Dayjs, Dayjs] | undefined>(undefined);
|
||
|
||
// 图表数据
|
||
const lessonTrendData = ref<LessonTrendItem[]>([]);
|
||
const courseDistributionData = ref<CourseDistributionItem[]>([]);
|
||
|
||
const statCards = computed(() => [
|
||
{
|
||
icon: SolutionOutlined,
|
||
label: '教师总数',
|
||
value: stats.value.teacherCount,
|
||
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
|
||
},
|
||
{
|
||
icon: UserOutlined,
|
||
label: '学生总数',
|
||
value: stats.value.studentCount,
|
||
gradient: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)'
|
||
},
|
||
{
|
||
icon: HomeOutlined,
|
||
label: '班级总数',
|
||
value: stats.value.classCount,
|
||
gradient: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)'
|
||
},
|
||
{
|
||
icon: ReadOutlined,
|
||
label: '授课次数',
|
||
value: stats.value.lessonCount,
|
||
gradient: 'linear-gradient(135deg, #FF8C42 0%, #FFB347 100%)'
|
||
}
|
||
]);
|
||
|
||
const formatTime = (time: string) => {
|
||
const date = new Date(time);
|
||
const now = new Date();
|
||
const diff = now.getTime() - date.getTime();
|
||
|
||
const minutes = Math.floor(diff / 60000);
|
||
const hours = Math.floor(diff / 3600000);
|
||
const days = Math.floor(diff / 86400000);
|
||
|
||
if (minutes < 60) {
|
||
return `${minutes}分钟前`;
|
||
} else if (hours < 24) {
|
||
return `${hours}小时前`;
|
||
} else if (days < 7) {
|
||
return `${days}天前`;
|
||
} else {
|
||
return date.toLocaleDateString();
|
||
}
|
||
};
|
||
|
||
const getMedalIcon = (index: number): Component => {
|
||
const icons = [TrophyOutlined, StarFilled, StarOutlined];
|
||
return icons[index] || StarOutlined;
|
||
};
|
||
|
||
const getMedalStyle = (index: number) => {
|
||
const styles = [
|
||
{ color: '#FFD700', fontSize: '20px' },
|
||
{ color: '#FFA500', fontSize: '18px' },
|
||
{ color: '#87CEEB', fontSize: '16px' }
|
||
];
|
||
return styles[index] || { color: '#B2BEC3', fontSize: '14px' };
|
||
};
|
||
|
||
const getTrophyColor = (index: number) => {
|
||
const colors = [
|
||
{ color: '#FFD700' }, // Gold
|
||
{ color: '#C0C0C0' }, // Silver
|
||
{ color: '#CD7F32' } // Bronze
|
||
];
|
||
return colors[index] || { color: '#636E72' };
|
||
};
|
||
|
||
const getProgressGradient = (index: number) => {
|
||
const gradients = [
|
||
'linear-gradient(90deg, #FF8C42 0%, #FFB347 100%)',
|
||
'linear-gradient(90deg, #667eea 0%, #764ba2 100%)',
|
||
'linear-gradient(90deg, #f093fb 0%, #f5576c 100%)',
|
||
'linear-gradient(90deg, #4facfe 0%, #00f2fe 100%)',
|
||
'linear-gradient(90deg, #43e97b 0%, #38f9d7 100%)',
|
||
];
|
||
return gradients[index % gradients.length];
|
||
};
|
||
|
||
const getUsagePercent = (count: number) => {
|
||
if (courseStats.value.length === 0) return 0;
|
||
const max = Math.max(...courseStats.value.map(s => s.usageCount));
|
||
return max > 0 ? Math.round((count / max) * 100) : 0;
|
||
};
|
||
|
||
// 初始化授课趋势图表
|
||
const initTrendChart = (data: LessonTrendItem[]) => {
|
||
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',
|
||
splitLine: {
|
||
lineStyle: {
|
||
color: '#F3F4F6',
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: 'value',
|
||
name: '学生数',
|
||
position: 'right',
|
||
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: '#FF8C42' },
|
||
{ offset: 1, color: '#FFB347' },
|
||
]),
|
||
borderRadius: [4, 4, 0, 0],
|
||
},
|
||
},
|
||
{
|
||
name: '学生数',
|
||
type: 'line',
|
||
yAxisIndex: 1,
|
||
data: data.map((d) => d.studentCount),
|
||
smooth: true,
|
||
itemStyle: {
|
||
color: '#667eea',
|
||
},
|
||
areaStyle: {
|
||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||
{ offset: 0, color: 'rgba(102, 126, 234, 0.3)' },
|
||
{ offset: 1, color: 'rgba(102, 126, 234, 0)' },
|
||
]),
|
||
},
|
||
},
|
||
],
|
||
};
|
||
|
||
trendChart.setOption(option);
|
||
};
|
||
|
||
// 初始化课程分布饼图
|
||
const initDistributionChart = (data: CourseDistributionItem[]) => {
|
||
if (!distributionChartRef.value) return;
|
||
|
||
if (distributionChart) {
|
||
distributionChart.dispose();
|
||
}
|
||
|
||
distributionChart = echarts.init(distributionChartRef.value);
|
||
|
||
const option: echarts.EChartsOption = {
|
||
tooltip: {
|
||
trigger: 'item',
|
||
formatter: '{b}: {c}次 ({d}%)',
|
||
},
|
||
legend: {
|
||
orient: 'vertical',
|
||
right: '5%',
|
||
top: 'center',
|
||
},
|
||
series: [
|
||
{
|
||
type: 'pie',
|
||
radius: ['40%', '70%'],
|
||
center: ['35%', '50%'],
|
||
avoidLabelOverlap: false,
|
||
itemStyle: {
|
||
borderRadius: 8,
|
||
borderColor: '#fff',
|
||
borderWidth: 2,
|
||
},
|
||
label: {
|
||
show: false,
|
||
position: 'center',
|
||
},
|
||
emphasis: {
|
||
label: {
|
||
show: true,
|
||
fontSize: 16,
|
||
fontWeight: 'bold',
|
||
},
|
||
},
|
||
labelLine: {
|
||
show: false,
|
||
},
|
||
data: data.map((item, index) => ({
|
||
...item,
|
||
itemStyle: {
|
||
color: [
|
||
'#FF8C42',
|
||
'#667eea',
|
||
'#f093fb',
|
||
'#4facfe',
|
||
'#43e97b',
|
||
'#fa709a',
|
||
'#fee140',
|
||
'#30cfd0',
|
||
][index % 8],
|
||
},
|
||
})),
|
||
},
|
||
],
|
||
};
|
||
|
||
distributionChart.setOption(option);
|
||
};
|
||
|
||
// 窗口大小变化时重绘图表
|
||
const handleResize = () => {
|
||
trendChart?.resize();
|
||
distributionChart?.resize();
|
||
};
|
||
|
||
const loadData = async () => {
|
||
loading.value = true;
|
||
try {
|
||
const [statsData, teachersData, activitiesData] = await Promise.all([
|
||
getSchoolStats(),
|
||
getActiveTeachers(5),
|
||
getRecentActivities(10),
|
||
]);
|
||
|
||
stats.value = statsData;
|
||
activeTeachers.value = teachersData;
|
||
recentActivities.value = activitiesData;
|
||
} catch (error) {
|
||
console.error('Failed to load dashboard data:', error);
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
};
|
||
|
||
const loadCourseStats = async () => {
|
||
courseStatsLoading.value = true;
|
||
try {
|
||
const data = await getCourseUsageStats();
|
||
courseStats.value = data.slice(0, 10);
|
||
} catch (error) {
|
||
console.error('Failed to load course stats:', error);
|
||
} finally {
|
||
courseStatsLoading.value = false;
|
||
}
|
||
};
|
||
|
||
// 加载授课趋势数据
|
||
const loadTrendData = async () => {
|
||
trendLoading.value = true;
|
||
try {
|
||
const data = await getLessonTrend(6);
|
||
lessonTrendData.value = data;
|
||
} catch (error) {
|
||
console.error('Failed to load trend data:', error);
|
||
} finally {
|
||
trendLoading.value = false;
|
||
}
|
||
// 在 loading 结束后初始化图表
|
||
await nextTick();
|
||
if (lessonTrendData.value.length > 0) {
|
||
initTrendChart(lessonTrendData.value);
|
||
}
|
||
};
|
||
|
||
// 加载课程分布数据
|
||
const loadDistributionData = async () => {
|
||
distributionLoading.value = true;
|
||
try {
|
||
const data = await getCourseDistribution();
|
||
courseDistributionData.value = data;
|
||
} catch (error) {
|
||
console.error('Failed to load distribution data:', error);
|
||
} finally {
|
||
distributionLoading.value = false;
|
||
}
|
||
// 在 loading 结束后初始化图表
|
||
await nextTick();
|
||
if (courseDistributionData.value.length > 0) {
|
||
initDistributionChart(courseDistributionData.value);
|
||
}
|
||
};
|
||
|
||
// 导出功能
|
||
const handleExportLessons = async () => {
|
||
try {
|
||
message.loading({ content: '正在导出...', key: 'export' });
|
||
await exportLessons();
|
||
message.success({ content: '导出成功', key: 'export' });
|
||
} catch (error) {
|
||
message.error({ content: '导出失败', key: 'export' });
|
||
}
|
||
};
|
||
|
||
const handleExportTeacherStats = async () => {
|
||
try {
|
||
message.loading({ content: '正在导出...', key: 'export' });
|
||
await exportTeacherStats();
|
||
message.success({ content: '导出成功', key: 'export' });
|
||
} catch (error) {
|
||
message.error({ content: '导出失败', key: 'export' });
|
||
}
|
||
};
|
||
|
||
const handleExportStudentStats = async () => {
|
||
try {
|
||
message.loading({ content: '正在导出...', key: 'export' });
|
||
await exportStudentStats();
|
||
message.success({ content: '导出成功', key: 'export' });
|
||
} catch (error) {
|
||
message.error({ content: '导出失败', key: 'export' });
|
||
}
|
||
};
|
||
|
||
onMounted(() => {
|
||
loadData();
|
||
loadCourseStats();
|
||
loadTrendData();
|
||
loadDistributionData();
|
||
|
||
window.addEventListener('resize', handleResize);
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
window.removeEventListener('resize', handleResize);
|
||
trendChart?.dispose();
|
||
distributionChart?.dispose();
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.school-dashboard {
|
||
padding: 0;
|
||
min-height: 100vh;
|
||
background: linear-gradient(180deg, #FFF8F0 0%, #FFFFFF 100%);
|
||
}
|
||
|
||
/* 欢迎横幅 */
|
||
.welcome-banner {
|
||
background: linear-gradient(135deg, #FF8C42 0%, #FFB347 50%, #FFD93D 100%);
|
||
border-radius: 20px;
|
||
padding: 32px 40px;
|
||
margin-bottom: 24px;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.banner-content {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
|
||
.banner-text h1 {
|
||
color: white;
|
||
font-size: 28px;
|
||
font-weight: 700;
|
||
margin: 0 0 8px 0;
|
||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.banner-text p {
|
||
color: rgba(255, 255, 255, 0.9);
|
||
font-size: 16px;
|
||
margin: 0;
|
||
}
|
||
|
||
.banner-decorations {
|
||
display: flex;
|
||
gap: 16px;
|
||
}
|
||
|
||
.decoration {
|
||
font-size: 36px;
|
||
animation: float 3s ease-in-out infinite;
|
||
}
|
||
|
||
.decoration:nth-child(2) { animation-delay: 0.5s; }
|
||
.decoration:nth-child(3) { animation-delay: 1s; }
|
||
.decoration:nth-child(4) { animation-delay: 1.5s; }
|
||
|
||
@keyframes float {
|
||
0%, 100% { transform: translateY(0); }
|
||
50% { transform: translateY(-10px); }
|
||
}
|
||
|
||
/* 统计卡片 */
|
||
.stats-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, 1fr);
|
||
gap: 20px;
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.stat-card {
|
||
background: white;
|
||
border-radius: 16px;
|
||
padding: 24px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.stat-card:hover {
|
||
transform: translateY(-4px);
|
||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.stat-icon {
|
||
width: 60px;
|
||
height: 60px;
|
||
border-radius: 16px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 28px;
|
||
color: white;
|
||
}
|
||
|
||
.stat-info {
|
||
flex: 1;
|
||
}
|
||
|
||
.stat-value {
|
||
font-size: 32px;
|
||
font-weight: 700;
|
||
color: #2D3436;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.stat-label {
|
||
font-size: 14px;
|
||
color: #636E72;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
/* 内容网格 */
|
||
.content-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, 1fr);
|
||
gap: 24px;
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
/* 图表网格 */
|
||
.charts-grid {
|
||
display: grid;
|
||
grid-template-columns: 3fr 2fr;
|
||
gap: 24px;
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.trend-card {
|
||
grid-column: 1;
|
||
}
|
||
|
||
.distribution-card {
|
||
grid-column: 2;
|
||
}
|
||
|
||
.chart-container {
|
||
width: 100%;
|
||
height: 300px;
|
||
}
|
||
|
||
/* 卡片通用样式 */
|
||
.content-card,
|
||
.course-stats-card {
|
||
background: white;
|
||
border-radius: 20px;
|
||
overflow: hidden;
|
||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
|
||
}
|
||
|
||
.card-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 20px 24px;
|
||
border-bottom: 1px solid #F5F5F5;
|
||
}
|
||
|
||
.card-icon {
|
||
font-size: 24px;
|
||
color: #FF8C42;
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.card-header h3 {
|
||
margin: 0;
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: #2D3436;
|
||
flex: 1;
|
||
}
|
||
|
||
.header-extra {
|
||
margin-left: auto;
|
||
}
|
||
|
||
.card-body {
|
||
padding: 20px 24px;
|
||
min-height: 200px;
|
||
}
|
||
|
||
.card-body.is-loading {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
/* 空状态 */
|
||
.empty-state {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 40px 0;
|
||
color: #B2BEC3;
|
||
}
|
||
|
||
.empty-icon {
|
||
font-size: 48px;
|
||
margin-bottom: 12px;
|
||
color: #B2BEC3;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.empty-state p {
|
||
margin: 0;
|
||
font-size: 14px;
|
||
}
|
||
|
||
/* 活动列表 */
|
||
.activity-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
|
||
.activity-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 12px 16px;
|
||
background: #FAFAFA;
|
||
border-radius: 12px;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.activity-item:hover {
|
||
background: #FFF8F0;
|
||
}
|
||
|
||
.activity-avatar {
|
||
width: 40px;
|
||
height: 40px;
|
||
border-radius: 10px;
|
||
background: linear-gradient(135deg, #FF8C42 0%, #FFB347 100%);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: white;
|
||
}
|
||
|
||
.activity-title {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: #2D3436;
|
||
}
|
||
|
||
.activity-time {
|
||
font-size: 12px;
|
||
color: #B2BEC3;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
/* 教师列表 */
|
||
.teacher-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.teacher-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 12px 16px;
|
||
background: #FAFAFA;
|
||
border-radius: 12px;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.teacher-item:hover {
|
||
background: #FFF8F0;
|
||
}
|
||
|
||
.rank-badge {
|
||
width: 28px;
|
||
height: 28px;
|
||
border-radius: 8px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: white;
|
||
background: #B2BEC3;
|
||
}
|
||
|
||
.rank-badge.rank-1 { background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%); }
|
||
.rank-badge.rank-2 { background: linear-gradient(135deg, #C0C0C0 0%, #A8A8A8 100%); }
|
||
.rank-badge.rank-3 { background: linear-gradient(135deg, #CD7F32 0%, #B8860B 100%); }
|
||
|
||
.teacher-info {
|
||
flex: 1;
|
||
}
|
||
|
||
.teacher-name {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: #2D3436;
|
||
}
|
||
|
||
.teacher-lessons {
|
||
font-size: 12px;
|
||
color: #636E72;
|
||
margin-top: 4px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
|
||
.lesson-icon {
|
||
font-size: 14px;
|
||
color: #636E72;
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.teacher-medal {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
/* 课程统计 */
|
||
.course-stats-card {
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
/* 导出区域 */
|
||
.export-section {
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.export-card .card-body {
|
||
padding: 24px;
|
||
}
|
||
|
||
.export-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 16px;
|
||
}
|
||
|
||
.export-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
padding: 20px;
|
||
background: #FAFAFA;
|
||
border-radius: 12px;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
border: 1px solid transparent;
|
||
}
|
||
|
||
.export-item:hover {
|
||
background: #FFF8F0;
|
||
border-color: #FFD4B8;
|
||
transform: translateY(-2px);
|
||
}
|
||
|
||
.export-icon {
|
||
width: 48px;
|
||
height: 48px;
|
||
border-radius: 12px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: white;
|
||
font-size: 20px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.export-info {
|
||
flex: 1;
|
||
}
|
||
|
||
.export-title {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: #2D3436;
|
||
}
|
||
|
||
.export-desc {
|
||
font-size: 12px;
|
||
color: #636E72;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.course-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.course-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
padding: 12px 16px;
|
||
background: #FAFAFA;
|
||
border-radius: 12px;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.course-item:hover {
|
||
background: #FFF8F0;
|
||
}
|
||
|
||
.course-rank {
|
||
width: 32px;
|
||
text-align: center;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #636E72;
|
||
}
|
||
|
||
.rank-crown {
|
||
font-size: 24px;
|
||
}
|
||
|
||
.course-name {
|
||
flex: 1;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: #2D3436;
|
||
}
|
||
|
||
.course-progress {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
width: 200px;
|
||
}
|
||
|
||
.progress-bar {
|
||
flex: 1;
|
||
height: 8px;
|
||
background: #F0F0F0;
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.progress-fill {
|
||
height: 100%;
|
||
border-radius: 4px;
|
||
transition: width 0.3s ease;
|
||
}
|
||
|
||
.progress-value {
|
||
font-size: 12px;
|
||
color: #636E72;
|
||
white-space: nowrap;
|
||
min-width: 40px;
|
||
text-align: right;
|
||
}
|
||
|
||
/* 响应式设计 */
|
||
@media (max-width: 1200px) {
|
||
.stats-grid {
|
||
grid-template-columns: repeat(2, 1fr);
|
||
}
|
||
|
||
.charts-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.welcome-banner {
|
||
padding: 24px;
|
||
}
|
||
|
||
.banner-text h1 {
|
||
font-size: 22px;
|
||
}
|
||
|
||
.banner-decorations {
|
||
display: none;
|
||
}
|
||
|
||
.stats-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.charts-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.export-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.charts-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.content-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.course-progress {
|
||
width: 120px;
|
||
}
|
||
}
|
||
</style>
|