学生首页:显示当前生效预警的自然语言摘要与最多 3 条重点事项,可直达处理页。 辅导员首页:显示所管班级的生效预警学生与详情,可直达完整预警列表。 风险接口加载失败会明确提示,不会误报“暂无风险”。 混合“学生 + 辅导员”角色会优先进入辅导员工作台。
1076 lines
28 KiB
Vue
1076 lines
28 KiB
Vue
<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>
|