学生视图

This commit is contained in:
2026-07-26 21:53:00 +08:00 Unverified
parent b39a57ebdf
commit dd1b752fa3
3 changed files with 961 additions and 10 deletions
-9
View File
@@ -313,15 +313,6 @@ router.beforeEach((to) => {
return { name: 'login', query: { redirect: to.fullPath } } return { name: 'login', query: { redirect: to.fullPath } }
} }
if (to.name === 'login' && auth.isLoggedIn) return { name: 'dashboard' } 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 const roles = to.meta.roles as string[] | undefined
if (roles && !roles.some((role) => auth.user?.roles.includes(role))) { if (roles && !roles.some((role) => auth.user?.roles.includes(role))) {
return { name: 'dashboard' } return { name: 'dashboard' }
+13 -1
View File
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
import { Collection, OfficeBuilding, School, UserFilled } from '@element-plus/icons-vue' import { Collection, OfficeBuilding, School, UserFilled } from '@element-plus/icons-vue'
import http from '../api/http' import http from '../api/http'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import StudentDashboardView from './StudentDashboardView.vue'
interface DashboardData { interface DashboardData {
currentTerm?: { name: string; startDate: string; endDate: string } currentTerm?: { name: string; startDate: string; endDate: string }
@@ -12,6 +13,12 @@ interface DashboardData {
const router = useRouter() const router = useRouter()
const auth = useAuthStore() 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 loading = ref(true)
const data = ref<DashboardData>({ counts: {} }) const data = ref<DashboardData>({ counts: {} })
@@ -23,6 +30,10 @@ const stats = computed(() => [
]) ])
onMounted(async () => { onMounted(async () => {
if (isStudentOverview.value) {
loading.value = false
return
}
try { try {
data.value = (await http.get('/dashboard')).data data.value = (await http.get('/dashboard')).data
} finally { } finally {
@@ -32,7 +43,8 @@ onMounted(async () => {
</script> </script>
<template> <template>
<div v-loading="loading" class="dashboard"> <StudentDashboardView v-if="isStudentOverview" />
<div v-else v-loading="loading" class="dashboard">
<section class="term-hero"> <section class="term-hero">
<div> <div>
<span class="section-kicker">CURRENT TERM</span> <span class="section-kicker">CURRENT TERM</span>
+948
View File
@@ -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>