学生视图
This commit is contained in:
@@ -313,15 +313,6 @@ router.beforeEach((to) => {
|
||||
return { name: 'login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
if (to.name === 'login' && auth.isLoggedIn) return { name: 'dashboard' }
|
||||
if (
|
||||
to.name === 'dashboard' &&
|
||||
auth.user?.roles.includes('Student') &&
|
||||
!auth.user.roles.some((role) =>
|
||||
['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role),
|
||||
)
|
||||
) {
|
||||
return { name: 'course-selections' }
|
||||
}
|
||||
const roles = to.meta.roles as string[] | undefined
|
||||
if (roles && !roles.some((role) => auth.user?.roles.includes(role))) {
|
||||
return { name: 'dashboard' }
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
|
||||
import { Collection, OfficeBuilding, School, UserFilled } from '@element-plus/icons-vue'
|
||||
import http from '../api/http'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import StudentDashboardView from './StudentDashboardView.vue'
|
||||
|
||||
interface DashboardData {
|
||||
currentTerm?: { name: string; startDate: string; endDate: string }
|
||||
@@ -12,6 +13,12 @@ interface DashboardData {
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const isStudentOverview = computed(() =>
|
||||
auth.user?.roles.includes('Student') &&
|
||||
!auth.user.roles.some((role) =>
|
||||
['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role),
|
||||
),
|
||||
)
|
||||
const loading = ref(true)
|
||||
const data = ref<DashboardData>({ counts: {} })
|
||||
|
||||
@@ -23,6 +30,10 @@ const stats = computed(() => [
|
||||
])
|
||||
|
||||
onMounted(async () => {
|
||||
if (isStudentOverview.value) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
data.value = (await http.get('/dashboard')).data
|
||||
} finally {
|
||||
@@ -32,7 +43,8 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading" class="dashboard">
|
||||
<StudentDashboardView v-if="isStudentOverview" />
|
||||
<div v-else v-loading="loading" class="dashboard">
|
||||
<section class="term-hero">
|
||||
<div>
|
||||
<span class="section-kicker">CURRENT TERM</span>
|
||||
|
||||
@@ -0,0 +1,948 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ArrowRight, Bell, Calendar, Clock, Location, Reading } from '@element-plus/icons-vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import http from '../api/http'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
interface TimetableSlot {
|
||||
periodNumber: number
|
||||
startsAt: string
|
||||
endsAt: string
|
||||
}
|
||||
|
||||
interface TimetableEntry {
|
||||
id: string
|
||||
courseCode: string
|
||||
courseName: string
|
||||
teacherNames: string[]
|
||||
classroomName: string
|
||||
buildingName?: string
|
||||
campusName?: string
|
||||
dayOfWeek: number
|
||||
startPeriod: number
|
||||
periodCount: number
|
||||
startWeek: number
|
||||
endWeek: number
|
||||
weekPattern: 'All' | 'Odd' | 'Even'
|
||||
}
|
||||
|
||||
interface TimetableData {
|
||||
term: {
|
||||
name: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
}
|
||||
slots: TimetableSlot[]
|
||||
entries: TimetableEntry[]
|
||||
}
|
||||
|
||||
interface NotificationItem {
|
||||
id: string
|
||||
title: string
|
||||
content: string
|
||||
category: string
|
||||
isRead: boolean
|
||||
linkUrl?: string
|
||||
senderName: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface ExamItem {
|
||||
id: string
|
||||
examDate: string
|
||||
startsAt: string
|
||||
endsAt: string
|
||||
courseCode: string
|
||||
courseName: string
|
||||
buildingName?: string
|
||||
classroomName?: string
|
||||
}
|
||||
|
||||
interface GradeItem {
|
||||
id: string
|
||||
termName: string
|
||||
courseCode: string
|
||||
courseName: string
|
||||
totalScore?: number
|
||||
gradePoint?: number
|
||||
examStatus: string
|
||||
publishedAt?: string
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(true)
|
||||
const failedSections = ref<string[]>([])
|
||||
const timetable = ref<TimetableData | null>(null)
|
||||
const notifications = ref<NotificationItem[]>([])
|
||||
const unreadCount = ref(0)
|
||||
const exams = ref<ExamItem[]>([])
|
||||
const grades = ref<GradeItem[]>([])
|
||||
const now = ref(new Date())
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
General: '一般通知',
|
||||
Approval: '审核待办',
|
||||
Schedule: '课程变动',
|
||||
Grade: '成绩通知',
|
||||
Attendance: '考勤消息',
|
||||
CourseSelection: '选课消息',
|
||||
Warning: '学业预警',
|
||||
}
|
||||
|
||||
const examStatusLabels: Record<string, string> = {
|
||||
Normal: '正常',
|
||||
Absent: '缺考',
|
||||
Deferred: '缓考',
|
||||
Exempt: '免修',
|
||||
}
|
||||
|
||||
const greeting = computed(() => {
|
||||
const hour = now.value.getHours()
|
||||
if (hour < 11) return '早上好'
|
||||
if (hour < 14) return '中午好'
|
||||
if (hour < 18) return '下午好'
|
||||
return '晚上好'
|
||||
})
|
||||
|
||||
const todayLabel = computed(() =>
|
||||
new Intl.DateTimeFormat('zh-CN', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
weekday: 'long',
|
||||
}).format(now.value),
|
||||
)
|
||||
|
||||
const currentWeek = computed(() => {
|
||||
const start = parseDateOnly(timetable.value?.term.startDate)
|
||||
const end = parseDateOnly(timetable.value?.term.endDate)
|
||||
if (!start || !end) return null
|
||||
const today = startOfDay(now.value)
|
||||
if (today < start || today > end) return null
|
||||
const mondayOffset = start.getDay() === 0 ? 6 : start.getDay() - 1
|
||||
const firstMonday = addDays(start, -mondayOffset)
|
||||
return Math.floor((today.getTime() - firstMonday.getTime()) / 604800000) + 1
|
||||
})
|
||||
|
||||
const todayCourses = computed(() => {
|
||||
const week = currentWeek.value
|
||||
if (!week) return []
|
||||
const weekday = now.value.getDay() || 7
|
||||
return (timetable.value?.entries ?? [])
|
||||
.filter((entry) =>
|
||||
entry.dayOfWeek === weekday &&
|
||||
week >= entry.startWeek &&
|
||||
week <= entry.endWeek &&
|
||||
(entry.weekPattern === 'All' ||
|
||||
(entry.weekPattern === 'Odd' && week % 2 === 1) ||
|
||||
(entry.weekPattern === 'Even' && week % 2 === 0)),
|
||||
)
|
||||
.sort((a, b) => a.startPeriod - b.startPeriod)
|
||||
})
|
||||
|
||||
const upcomingExams = computed(() =>
|
||||
exams.value
|
||||
.filter((exam) => {
|
||||
const endsAt = new Date(exam.endsAt)
|
||||
if (!Number.isNaN(endsAt.getTime())) return endsAt >= now.value
|
||||
const examDate = parseDateOnly(exam.examDate)
|
||||
return examDate ? examDate >= startOfDay(now.value) : false
|
||||
})
|
||||
.sort((a, b) => examTime(a) - examTime(b))
|
||||
.slice(0, 3),
|
||||
)
|
||||
|
||||
const recentGrades = computed(() =>
|
||||
[...grades.value]
|
||||
.sort((a, b) => dateTime(b.publishedAt) - dateTime(a.publishedAt))
|
||||
.slice(0, 4),
|
||||
)
|
||||
|
||||
function parseDateOnly(value?: string) {
|
||||
if (!value) return null
|
||||
const [year, month, day] = value.slice(0, 10).split('-').map(Number)
|
||||
if (!year || !month || !day) return null
|
||||
return new Date(year, month - 1, day)
|
||||
}
|
||||
|
||||
function startOfDay(value: Date) {
|
||||
return new Date(value.getFullYear(), value.getMonth(), value.getDate())
|
||||
}
|
||||
|
||||
function addDays(value: Date, days: number) {
|
||||
const result = new Date(value)
|
||||
result.setDate(result.getDate() + days)
|
||||
return result
|
||||
}
|
||||
|
||||
function dateTime(value?: string) {
|
||||
if (!value) return 0
|
||||
const parsed = new Date(value).getTime()
|
||||
return Number.isNaN(parsed) ? 0 : parsed
|
||||
}
|
||||
|
||||
function examTime(exam: ExamItem) {
|
||||
const parsed = new Date(exam.startsAt).getTime()
|
||||
return Number.isNaN(parsed) ? dateTime(exam.examDate) : parsed
|
||||
}
|
||||
|
||||
function slotTime(periodNumber: number, field: 'startsAt' | 'endsAt') {
|
||||
const slot = timetable.value?.slots.find((item) => item.periodNumber === periodNumber)
|
||||
return slot?.[field]?.slice(0, 5) ?? ''
|
||||
}
|
||||
|
||||
function courseTime(entry: TimetableEntry) {
|
||||
const endPeriod = entry.startPeriod + entry.periodCount - 1
|
||||
const startsAt = slotTime(entry.startPeriod, 'startsAt')
|
||||
const endsAt = slotTime(endPeriod, 'endsAt')
|
||||
return startsAt && endsAt
|
||||
? `${startsAt}—${endsAt}`
|
||||
: `第 ${entry.startPeriod}—${endPeriod} 节`
|
||||
}
|
||||
|
||||
function courseLocation(entry: TimetableEntry) {
|
||||
return [entry.buildingName, entry.classroomName].filter(Boolean).join(' · ') || '地点待定'
|
||||
}
|
||||
|
||||
function formatExamDate(exam: ExamItem) {
|
||||
const date = parseDateOnly(exam.examDate)
|
||||
if (!date) return '日期待定'
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
weekday: 'short',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
function formatExamTime(exam: ExamItem) {
|
||||
const start = new Date(exam.startsAt)
|
||||
const end = new Date(exam.endsAt)
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return '时间待定'
|
||||
const formatter = new Intl.DateTimeFormat('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
return `${formatter.format(start)}—${formatter.format(end)}`
|
||||
}
|
||||
|
||||
function examDistance(exam: ExamItem) {
|
||||
const date = parseDateOnly(exam.examDate)
|
||||
if (!date) return '待安排'
|
||||
const days = Math.round((date.getTime() - startOfDay(now.value).getTime()) / 86400000)
|
||||
if (days === 0) return '今天'
|
||||
if (days === 1) return '明天'
|
||||
return `${days} 天后`
|
||||
}
|
||||
|
||||
function messageTime(value: string) {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
const today = startOfDay(now.value)
|
||||
const messageDay = startOfDay(date)
|
||||
const days = Math.round((today.getTime() - messageDay.getTime()) / 86400000)
|
||||
if (days === 0) {
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(date)
|
||||
}
|
||||
if (days === 1) return '昨天'
|
||||
return `${date.getMonth() + 1}月${date.getDate()}日`
|
||||
}
|
||||
|
||||
function gradeDisplay(grade: GradeItem) {
|
||||
if (grade.examStatus !== 'Normal') return examStatusLabels[grade.examStatus] ?? grade.examStatus
|
||||
return grade.totalScore ?? '—'
|
||||
}
|
||||
|
||||
function scoreTone(grade: GradeItem) {
|
||||
if (grade.examStatus !== 'Normal' || grade.totalScore == null) return 'muted'
|
||||
if (grade.totalScore >= 85) return 'excellent'
|
||||
if (grade.totalScore >= 60) return 'passed'
|
||||
return 'attention'
|
||||
}
|
||||
|
||||
async function openNotification(notification: NotificationItem) {
|
||||
if (notification.linkUrl) {
|
||||
await router.push(notification.linkUrl)
|
||||
return
|
||||
}
|
||||
await router.push('/notifications')
|
||||
}
|
||||
|
||||
async function loadOverview() {
|
||||
loading.value = true
|
||||
failedSections.value = []
|
||||
const results = await Promise.allSettled([
|
||||
http.get('/timetables/mine'),
|
||||
http.get('/notifications', { params: { page: 1, pageSize: 5 } }),
|
||||
http.get('/exams/my-schedule'),
|
||||
http.get('/grades/student/transcript'),
|
||||
])
|
||||
|
||||
if (results[0].status === 'fulfilled') {
|
||||
timetable.value = results[0].value.data
|
||||
} else {
|
||||
failedSections.value.push('当日课表')
|
||||
}
|
||||
if (results[1].status === 'fulfilled') {
|
||||
notifications.value = results[1].value.data.items
|
||||
unreadCount.value = results[1].value.data.unreadCount
|
||||
} else {
|
||||
failedSections.value.push('消息中心')
|
||||
}
|
||||
if (results[2].status === 'fulfilled') {
|
||||
exams.value = results[2].value.data
|
||||
} else {
|
||||
failedSections.value.push('考试安排')
|
||||
}
|
||||
if (results[3].status === 'fulfilled') {
|
||||
grades.value = results[3].value.data.records
|
||||
} else {
|
||||
failedSections.value.push('考试成绩')
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
onMounted(loadOverview)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading" class="student-overview">
|
||||
<section class="student-overview-hero">
|
||||
<div class="student-hero-copy">
|
||||
<span class="section-kicker">MY ACADEMIC DAY</span>
|
||||
<h2>{{ greeting }},{{ auth.user?.displayName ?? '同学' }}</h2>
|
||||
<p>{{ todayLabel }}<template v-if="timetable?.term"> · {{ timetable.term.name }}</template></p>
|
||||
</div>
|
||||
<div class="today-status">
|
||||
<span>今日课程</span>
|
||||
<strong>{{ todayCourses.length }}</strong>
|
||||
<small>{{ currentWeek ? `第 ${currentWeek} 教学周` : '当前不在教学周内' }}</small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-alert
|
||||
v-if="failedSections.length"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
:title="`${failedSections.join('、')}暂时无法加载,其余内容不受影响。`"
|
||||
/>
|
||||
|
||||
<section class="student-overview-grid">
|
||||
<article class="overview-panel schedule-panel">
|
||||
<header class="panel-heading">
|
||||
<div>
|
||||
<span class="panel-kicker"><el-icon><Calendar /></el-icon> TODAY</span>
|
||||
<h3>当日课表</h3>
|
||||
</div>
|
||||
<button type="button" @click="router.push('/my-timetable')">
|
||||
完整课表 <el-icon><ArrowRight /></el-icon>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="todayCourses.length" class="today-course-list">
|
||||
<button
|
||||
v-for="entry in todayCourses"
|
||||
:key="entry.id"
|
||||
type="button"
|
||||
class="today-course"
|
||||
@click="router.push('/my-timetable')"
|
||||
>
|
||||
<time>
|
||||
<b>{{ slotTime(entry.startPeriod, 'startsAt') || `第 ${entry.startPeriod} 节` }}</b>
|
||||
<span>{{ courseTime(entry) }}</span>
|
||||
</time>
|
||||
<span class="course-line" aria-hidden="true" />
|
||||
<div class="course-copy">
|
||||
<span>{{ entry.courseCode }}</span>
|
||||
<h4>{{ entry.courseName }}</h4>
|
||||
<p>
|
||||
<el-icon><Location /></el-icon>
|
||||
{{ courseLocation(entry) }}
|
||||
<template v-if="entry.teacherNames?.length"> · {{ entry.teacherNames.join('、') }}</template>
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="!failedSections.includes('当日课表')" class="panel-empty schedule-empty">
|
||||
<el-icon><Reading /></el-icon>
|
||||
<strong>今天没有课程</strong>
|
||||
<span>{{ currentWeek ? '可以安排自习或处理待办。' : '当前不在教学周内。' }}</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="overview-panel message-panel">
|
||||
<header class="panel-heading">
|
||||
<div>
|
||||
<span class="panel-kicker"><el-icon><Bell /></el-icon> INBOX</span>
|
||||
<h3>消息中心</h3>
|
||||
</div>
|
||||
<button type="button" @click="router.push('/notifications')">
|
||||
<span v-if="unreadCount" class="unread-count">{{ unreadCount }} 未读</span>
|
||||
全部消息 <el-icon><ArrowRight /></el-icon>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="notifications.length" class="overview-message-list">
|
||||
<button
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
type="button"
|
||||
:class="{ unread: !notification.isRead }"
|
||||
@click="openNotification(notification)"
|
||||
>
|
||||
<span class="message-dot" />
|
||||
<div>
|
||||
<span>{{ categoryLabels[notification.category] ?? '系统消息' }} · {{ notification.senderName }}</span>
|
||||
<h4>{{ notification.title }}</h4>
|
||||
<p>{{ notification.content }}</p>
|
||||
</div>
|
||||
<time>{{ messageTime(notification.createdAt) }}</time>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="!failedSections.includes('消息中心')" class="panel-empty">
|
||||
<el-icon><Bell /></el-icon>
|
||||
<strong>暂无消息</strong>
|
||||
<span>新的教务通知会显示在这里。</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="overview-panel exam-panel">
|
||||
<header class="panel-heading">
|
||||
<div>
|
||||
<span class="panel-kicker"><el-icon><Clock /></el-icon> UPCOMING</span>
|
||||
<h3>考试安排</h3>
|
||||
</div>
|
||||
<button type="button" @click="router.push('/exams')">
|
||||
全部考试 <el-icon><ArrowRight /></el-icon>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="upcomingExams.length" class="upcoming-exams">
|
||||
<button
|
||||
v-for="exam in upcomingExams"
|
||||
:key="exam.id"
|
||||
type="button"
|
||||
@click="router.push('/exams')"
|
||||
>
|
||||
<div class="exam-date">
|
||||
<b>{{ formatExamDate(exam) }}</b>
|
||||
<span>{{ examDistance(exam) }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span>{{ exam.courseCode }} · {{ formatExamTime(exam) }}</span>
|
||||
<h4>{{ exam.courseName }}</h4>
|
||||
<p>{{ [exam.buildingName, exam.classroomName].filter(Boolean).join(' · ') || '考场待定' }}</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="!failedSections.includes('考试安排')" class="panel-empty">
|
||||
<el-icon><Calendar /></el-icon>
|
||||
<strong>近期没有考试</strong>
|
||||
<span>已发布的考试安排会显示在这里。</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="overview-panel grade-panel">
|
||||
<header class="panel-heading">
|
||||
<div>
|
||||
<span class="panel-kicker"><el-icon><Reading /></el-icon> RESULTS</span>
|
||||
<h3>考试成绩</h3>
|
||||
</div>
|
||||
<button type="button" @click="router.push('/grades')">
|
||||
全部成绩 <el-icon><ArrowRight /></el-icon>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="recentGrades.length" class="recent-grades">
|
||||
<button
|
||||
v-for="grade in recentGrades"
|
||||
:key="grade.id"
|
||||
type="button"
|
||||
@click="router.push('/grades')"
|
||||
>
|
||||
<div>
|
||||
<span>{{ grade.termName }} · {{ grade.courseCode }}</span>
|
||||
<h4>{{ grade.courseName }}</h4>
|
||||
<p>绩点 {{ grade.gradePoint ?? '—' }} · {{ examStatusLabels[grade.examStatus] ?? grade.examStatus }}</p>
|
||||
</div>
|
||||
<strong :class="scoreTone(grade)">{{ gradeDisplay(grade) }}</strong>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="!failedSections.includes('考试成绩')" class="panel-empty">
|
||||
<el-icon><Reading /></el-icon>
|
||||
<strong>暂无已发布成绩</strong>
|
||||
<span>最新发布的考试成绩会显示在这里。</span>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.student-overview {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.student-overview-hero {
|
||||
min-height: 168px;
|
||||
padding: 32px 36px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 30px;
|
||||
color: white;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(255, 255, 255, .045) 1px, transparent 1px),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, .045) 1px, transparent 1px),
|
||||
linear-gradient(118deg, #1a2d66, #233876 62%, #0d716c 135%);
|
||||
background-size: 28px 28px, 28px 28px, auto;
|
||||
}
|
||||
|
||||
.student-overview-hero::after {
|
||||
content: "";
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
position: absolute;
|
||||
right: -52px;
|
||||
top: -118px;
|
||||
border: 48px solid rgba(255, 255, 255, .055);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.student-hero-copy {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.student-overview-hero .section-kicker {
|
||||
color: #70d9c9;
|
||||
}
|
||||
|
||||
.student-overview-hero h2 {
|
||||
margin: 12px 0 8px;
|
||||
font-family: "STZhongsong", "Songti SC", serif;
|
||||
font-size: clamp(26px, 3vw, 38px);
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
|
||||
.student-overview-hero p {
|
||||
margin: 0;
|
||||
color: #c9d3ef;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.today-status {
|
||||
min-width: 180px;
|
||||
margin-left: auto;
|
||||
padding-left: 26px;
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
align-items: end;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
border-left: 1px solid rgba(255, 255, 255, .24);
|
||||
}
|
||||
|
||||
.today-status span {
|
||||
color: #bdc8e8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.today-status strong {
|
||||
grid-row: 1 / span 2;
|
||||
grid-column: 2;
|
||||
padding-left: 22px;
|
||||
font: 700 52px/.9 Consolas, monospace;
|
||||
}
|
||||
|
||||
.today-status small {
|
||||
margin-top: 6px;
|
||||
color: #70d9c9;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.student-overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(0, .8fr);
|
||||
gap: 18px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.overview-panel {
|
||||
min-height: 320px;
|
||||
padding: 25px 27px;
|
||||
border: 1px solid var(--line);
|
||||
background: white;
|
||||
}
|
||||
|
||||
.panel-heading {
|
||||
min-height: 48px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
border-bottom: 1px solid #e8ebf0;
|
||||
}
|
||||
|
||||
.panel-heading h3 {
|
||||
margin: 5px 0 16px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.panel-kicker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--teal);
|
||||
font: 700 9px/1 Consolas, monospace;
|
||||
letter-spacing: .14em;
|
||||
}
|
||||
|
||||
.panel-heading button {
|
||||
min-height: 30px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border: 0;
|
||||
color: #526078;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.panel-heading button:hover {
|
||||
color: var(--indigo);
|
||||
}
|
||||
|
||||
.unread-count {
|
||||
padding: 3px 6px;
|
||||
color: #116f68;
|
||||
background: #e4f4f1;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.today-course-list,
|
||||
.overview-message-list,
|
||||
.upcoming-exams,
|
||||
.recent-grades {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.today-course {
|
||||
width: 100%;
|
||||
min-height: 92px;
|
||||
padding: 16px 0;
|
||||
display: grid;
|
||||
grid-template-columns: 82px 12px minmax(0, 1fr);
|
||||
gap: 11px;
|
||||
text-align: left;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #edf0f3;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.today-course:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.today-course:hover .course-copy h4 {
|
||||
color: var(--indigo);
|
||||
}
|
||||
|
||||
.today-course time {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.today-course time b {
|
||||
font: 700 17px/1.2 Consolas, monospace;
|
||||
}
|
||||
|
||||
.today-course time span {
|
||||
margin-top: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.course-line {
|
||||
width: 2px;
|
||||
min-height: 52px;
|
||||
justify-self: center;
|
||||
background: var(--teal);
|
||||
box-shadow: 0 0 0 4px #e9f5f3;
|
||||
}
|
||||
|
||||
.course-copy {
|
||||
min-width: 0;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.course-copy > span,
|
||||
.overview-message-list button div > span,
|
||||
.upcoming-exams button > div:last-child > span,
|
||||
.recent-grades button div > span {
|
||||
color: #788295;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.course-copy h4,
|
||||
.overview-message-list h4,
|
||||
.upcoming-exams h4,
|
||||
.recent-grades h4 {
|
||||
margin: 5px 0;
|
||||
color: var(--ink);
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.course-copy p,
|
||||
.overview-message-list p,
|
||||
.upcoming-exams p,
|
||||
.recent-grades p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.course-copy p {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.overview-message-list button {
|
||||
width: 100%;
|
||||
min-height: 64px;
|
||||
padding: 12px 0;
|
||||
display: grid;
|
||||
grid-template-columns: 8px minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
text-align: left;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #edf0f3;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.overview-message-list button:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.overview-message-list button:hover h4 {
|
||||
color: var(--indigo);
|
||||
}
|
||||
|
||||
.message-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin-top: 21px;
|
||||
border: 1px solid #aeb5c1;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.overview-message-list button.unread .message-dot {
|
||||
border-color: var(--teal);
|
||||
background: var(--teal);
|
||||
box-shadow: 0 0 0 3px #e5f3f1;
|
||||
}
|
||||
|
||||
.overview-message-list button.unread h4 {
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.overview-message-list p {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.overview-message-list time {
|
||||
padding-top: 17px;
|
||||
color: #9098a6;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.upcoming-exams button {
|
||||
width: 100%;
|
||||
min-height: 78px;
|
||||
padding: 13px 0;
|
||||
display: grid;
|
||||
grid-template-columns: 112px minmax(0, 1fr);
|
||||
gap: 17px;
|
||||
text-align: left;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #edf0f3;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.upcoming-exams button:last-child,
|
||||
.recent-grades button:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.upcoming-exams button:hover h4,
|
||||
.recent-grades button:hover h4 {
|
||||
color: var(--indigo);
|
||||
}
|
||||
|
||||
.exam-date {
|
||||
padding: 5px 13px;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
border-left: 3px solid var(--amber);
|
||||
background: #fbf8f0;
|
||||
}
|
||||
|
||||
.exam-date b {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.exam-date span {
|
||||
margin-top: 4px;
|
||||
color: #a56c16;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.recent-grades button {
|
||||
width: 100%;
|
||||
min-height: 67px;
|
||||
padding: 11px 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
text-align: left;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #edf0f3;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.recent-grades strong {
|
||||
min-width: 52px;
|
||||
text-align: right;
|
||||
color: var(--ink);
|
||||
font: 700 27px/1 Consolas, monospace;
|
||||
}
|
||||
|
||||
.recent-grades strong.excellent {
|
||||
color: var(--teal);
|
||||
}
|
||||
|
||||
.recent-grades strong.attention {
|
||||
color: #b64e45;
|
||||
}
|
||||
|
||||
.recent-grades strong.muted {
|
||||
color: var(--muted);
|
||||
font: 600 13px/1 sans-serif;
|
||||
}
|
||||
|
||||
.panel-empty {
|
||||
min-height: 220px;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
color: #9ba4b3;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.panel-empty .el-icon {
|
||||
margin-bottom: 13px;
|
||||
font-size: 28px;
|
||||
color: #b3bac5;
|
||||
}
|
||||
|
||||
.panel-empty strong {
|
||||
color: #596377;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.panel-empty span {
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.student-overview-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.student-overview {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.student-overview-hero {
|
||||
min-height: 190px;
|
||||
padding: 24px 21px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.student-overview-hero h2 {
|
||||
font-size: 25px;
|
||||
}
|
||||
|
||||
.today-status {
|
||||
min-width: 0;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.today-status span,
|
||||
.today-status small {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.today-status strong {
|
||||
padding-left: 0;
|
||||
font-size: 42px;
|
||||
}
|
||||
|
||||
.overview-panel {
|
||||
min-height: 0;
|
||||
padding: 21px 18px;
|
||||
}
|
||||
|
||||
.panel-heading {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.panel-heading button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.unread-count {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.today-course {
|
||||
grid-template-columns: 68px 8px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.today-course time b {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.upcoming-exams button {
|
||||
grid-template-columns: 95px minmax(0, 1fr);
|
||||
gap: 11px;
|
||||
}
|
||||
|
||||
.overview-message-list time {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.overview-message-list button {
|
||||
grid-template-columns: 8px minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user