Files
Academic-Affairs-System/web/src/views/StudentDashboardView.vue
T
biss 545071a7dd 学业风险雷达已接入首页,复用现有正式预警规则、检测、通知和确认流程:
学生首页:显示当前生效预警的自然语言摘要与最多 3 条重点事项,可直达处理页。
辅导员首页:显示所管班级的生效预警学生与详情,可直达完整预警列表。
风险接口加载失败会明确提示,不会误报“暂无风险”。
混合“学生 + 辅导员”角色会优先进入辅导员工作台。
2026-08-10 10:24:54 +08:00

1076 lines
28 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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
}
interface DashboardGreeting {
title: string
subtitle: string
label: string
narrative: string
insights: Array<{
label: string
value: string
hint: string
tone: 'calm' | 'positive' | 'attention'
}>
}
interface WarningItem {
id: string
type: number
status: number
detail: string
createdAt: 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 dashboardGreeting = ref<DashboardGreeting | null>(null)
const warnings = ref<WarningItem[]>([])
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),
)
const activeWarnings = computed(() => warnings.value.filter((warning) => warning.status === 1))
const radarSummary = computed(() => activeWarnings.value.length
? `发现 ${activeWarnings.value.length} 项需要你关注的学习风险,建议优先处理下方提示。`
: '系统暂未发现需要你处理的学业风险,继续保持当前学习节奏。')
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'),
http.get<DashboardGreeting>('/dashboard/greeting'),
http.get<WarningItem[]>('/warnings/my-warnings'),
])
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('考试成绩')
}
if (results[4].status === 'fulfilled') {
dashboardGreeting.value = results[4].value.data
}
if (results[5].status === 'fulfilled') {
warnings.value = results[5].value.data
} 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>{{ dashboardGreeting?.title ?? `${greeting}${auth.user?.displayName ?? '同学'}` }}</h2>
<p>{{ dashboardGreeting?.subtitle ?? todayLabel }}<template v-if="!dashboardGreeting && timetable?.term"> · {{ timetable.term.name }}</template></p>
</div>
<div class="today-status">
<span>今日课程</span>
<strong>{{ todayCourses.length }}</strong>
<small>{{ currentWeek ? `第 ${currentWeek} 教学周` : '当前不在教学周内' }}</small>
</div>
</section>
<section v-if="dashboardGreeting?.insights.length" class="student-greeting-insights" :aria-label="dashboardGreeting.label">
<span>{{ dashboardGreeting.label }}</span>
<article v-for="insight in dashboardGreeting.insights" :key="insight.label" :class="insight.tone">
<small>{{ insight.label }}</small>
<strong>{{ insight.value }}</strong>
<em>{{ insight.hint }}</em>
</article>
</section>
<p v-if="dashboardGreeting?.narrative" class="student-greeting-narrative">{{ dashboardGreeting.narrative }}</p>
<section class="student-risk-radar" :class="{ attention: activeWarnings.length }">
<header>
<div>
<span class="panel-kicker">ACADEMIC RADAR</span>
<h3>学业风险雷达</h3>
</div>
<button type="button" @click="router.push('/warnings')">查看全部 <el-icon><ArrowRight /></el-icon></button>
</header>
<p>{{ radarSummary }}</p>
<div v-if="activeWarnings.length" class="risk-list">
<button v-for="warning in activeWarnings.slice(0, 3)" :key="warning.id" type="button" @click="router.push('/warnings')">
<span>{{ categoryLabels.Warning }}</span>
<strong>{{ warning.detail }}</strong>
<i>去处理 </i>
</button>
</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;
}
.student-greeting-insights {
padding: 15px 21px;
display: grid;
grid-template-columns: 105px repeat(3, minmax(0, 1fr));
gap: 12px;
border: 1px solid #dce6eb;
background: #f7faf9;
}
.student-greeting-insights > span {
align-self: center;
color: var(--teal);
font: 700 10px/1.5 Consolas, monospace;
letter-spacing: .11em;
}
.student-greeting-insights article {
min-width: 0;
padding-left: 12px;
display: grid;
gap: 3px;
border-left: 2px solid #a9bcc6;
}
.student-greeting-insights article.positive { border-color: var(--teal); }
.student-greeting-insights article.attention { border-color: #c4812a; }
.student-greeting-insights small { color: #667488; font-size: 10px; }
.student-greeting-insights strong { color: var(--ink); font-size: 19px; }
.student-greeting-insights em { overflow: hidden; color: var(--muted); font-size: 10px; font-style: normal; text-overflow: ellipsis; white-space: nowrap; }
.student-greeting-narrative {
margin: -5px 0 0;
padding: 0 3px;
color: #526078;
font-size: 12px;
line-height: 1.7;
}
.student-risk-radar {
padding: 20px 22px;
border: 1px solid #dce6eb;
background: #fbfdfd;
}
.student-risk-radar.attention { border-left: 3px solid #c4812a; background: #fffcf6; }
.student-risk-radar header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.student-risk-radar h3 { margin: 6px 0 0; color: var(--ink); font-size: 17px; }
.student-risk-radar header button { padding: 4px 0; display: inline-flex; align-items: center; gap: 4px; border: 0; color: #526078; background: transparent; font-size: 12px; white-space: nowrap; }
.student-risk-radar header button:hover { color: var(--indigo); }
.student-risk-radar > p { margin: 13px 0 0; color: #59677a; font-size: 12px; line-height: 1.7; }
.risk-list { margin-top: 13px; display: grid; }
.risk-list button { padding: 12px 0; display: grid; grid-template-columns: 76px minmax(0, 1fr) auto; gap: 10px; text-align: left; border: 0; border-top: 1px solid #eee6d8; background: transparent; }
.risk-list span { align-self: center; color: #a16b1c; font: 700 9px/1.4 Consolas, monospace; letter-spacing: .06em; }
.risk-list strong { color: #38475a; font-size: 12px; font-weight: 600; line-height: 1.55; }
.risk-list i { align-self: center; color: #9a743d; font-size: 11px; font-style: normal; white-space: nowrap; }
.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-greeting-insights { grid-template-columns: 1fr repeat(3, minmax(0, 1fr)); }
.student-overview-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 600px) {
.student-greeting-insights { padding: 15px 18px; grid-template-columns: 1fr; }
.student-risk-radar { padding: 18px; }
.risk-list button { grid-template-columns: 1fr auto; }
.risk-list span { grid-column: 1 / -1; }
.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>