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