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