Files
Academic-Affairs-System/web/src/views/StudentDashboardView.vue
T
2026-07-26 21:53:00 +08:00

949 lines
23 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
}
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>