From f657290e71970dc633070d5c3e9cea80b47503a0 Mon Sep 17 00:00:00 2001 From: biss Date: Thu, 1 Oct 2026 13:56:32 +0800 Subject: [PATCH] feat(web): migrate student academic insights Add React exam and invigilation schedules, student transcript metrics and score breakdowns, and academic warning acknowledgement with support follow-up timelines. Connect role-aware navigation and dashboard links. --- web-react/MIGRATION_MATRIX.md | 3 + web-react/README.md | 4 +- web-react/src/app/App.tsx | 6 ++ web-react/src/layout/AppShell.tsx | 10 ++ web-react/src/pages/ExamsPage.tsx | 30 ++++++ web-react/src/pages/StudentGradesPage.tsx | 35 +++++++ web-react/src/pages/StudentWarningsPage.tsx | 36 ++++++++ .../pages/dashboard/StudentDashboardPage.tsx | 7 +- web-react/src/pages/student-learning.css | 91 +++++++++++++++++++ 9 files changed, 217 insertions(+), 5 deletions(-) create mode 100644 web-react/src/pages/ExamsPage.tsx create mode 100644 web-react/src/pages/StudentGradesPage.tsx create mode 100644 web-react/src/pages/StudentWarningsPage.tsx create mode 100644 web-react/src/pages/student-learning.css diff --git a/web-react/MIGRATION_MATRIX.md b/web-react/MIGRATION_MATRIX.md index 0511a11..4b9f2a5 100644 --- a/web-react/MIGRATION_MATRIX.md +++ b/web-react/MIGRATION_MATRIX.md @@ -10,5 +10,8 @@ | `/users` | 用户与权限 | SuperAdmin | 列表、筛选、分页、创建、启停、重置密码、角色范围、Excel | verification | | `/notifications` | 通知中心 | 已登录用户 | 收件箱筛选、游标分页、已读回执、权限范围/筛选/指定收件人群发、已发送 | verification | | `/my-timetable` | 我的课表 | Student、Teacher | 当前学期周视图、考试/实验融合、Excel/PDF/ICS、订阅启停与密钥轮换 | verification | +| `/exams` | 考试/监考安排 | Student、Teacher | 已发布个人安排、考场、座位、监考教师、历史区分 | verification | +| `/grades` | 我的成绩 | Student | 学期筛选、成绩构成、学分绩点、学业指标和排名 | verification | +| `/warnings` | 我的学业预警 | Student | 预警确认、确认备注、帮扶与复核进度 | verification | 后续从 `web/src/router/index.ts` 逐条补全。只有关键合同和各状态验收完整后,状态才能更新为 `complete`。 diff --git a/web-react/README.md b/web-react/README.md index e5cb3f1..65c15a9 100644 --- a/web-react/README.md +++ b/web-react/README.md @@ -31,6 +31,8 @@ npm.cmd run build - 完整迁移 `/users` 的分页筛选、Excel、账号、角色、状态和密码管理流程。 - 迁移 `/notifications` 的收件箱、已读回执、权限范围/筛选/指定收件人群发和已发送记录; - 迁移 `/my-timetable` 的周视图、考试与实验融合、Excel/PDF/ICS 导出和日历订阅管理。 +- 迁移 `/exams` 的学生考试与教师监考安排; +- 迁移学生 `/grades` 成绩单、绩点排名,以及 `/warnings` 预警确认和帮扶进度。 ## 迁移纪律 @@ -45,7 +47,7 @@ npm.cmd run build | 批次 | 内容 | 状态 | | --- | --- | --- | | 0 | 工程、主题、会话、登录、应用外壳 | 进行中 | -| 1 | Dashboard、Users、Account、Notifications、个人课表、移动端代表页 | 已实现,进入联调验收 | +| 1 | Dashboard、Users、Account、Notifications、个人课表、考试、学生成绩与预警、移动端代表页 | 已实现,进入联调验收 | | 2 | 标准 CRUD 与基础数据模块 | 未开始 | | 3 | 学生、教师与审批工作台 | 未开始 | | 4 | 选课、排课、成绩、实验等复杂模块 | 未开始 | diff --git a/web-react/src/app/App.tsx b/web-react/src/app/App.tsx index bfe1fc7..8e80d62 100644 --- a/web-react/src/app/App.tsx +++ b/web-react/src/app/App.tsx @@ -11,6 +11,9 @@ const AccountPage = lazy(() => import('../pages/AccountPage')) const UsersPage = lazy(() => import('../pages/UsersPage')) const NotificationsPage = lazy(() => import('../pages/NotificationsPage')) const MyTimetablePage = lazy(() => import('../pages/MyTimetablePage')) +const ExamsPage = lazy(() => import('../pages/ExamsPage')) +const StudentGradesPage = lazy(() => import('../pages/StudentGradesPage')) +const StudentWarningsPage = lazy(() => import('../pages/StudentWarningsPage')) const SsoCallbackPage = lazy(() => import('../pages/SsoCallbackPage')) const SsoBindPage = lazy(() => import('../pages/SsoBindPage')) const AppShell = lazy(() => import('../layout/AppShell')) @@ -56,6 +59,9 @@ function AppRoutes() { } /> } /> } /> + } /> + } /> + } /> } /> } /> diff --git a/web-react/src/layout/AppShell.tsx b/web-react/src/layout/AppShell.tsx index a9cb165..fe71a99 100644 --- a/web-react/src/layout/AppShell.tsx +++ b/web-react/src/layout/AppShell.tsx @@ -8,6 +8,9 @@ import { MenuUnfoldOutlined, SafetyCertificateOutlined, CalendarOutlined, + FileDoneOutlined, + ReadOutlined, + WarningOutlined, } from '@ant-design/icons' import { Avatar, Badge, Button, Layout, Menu, Tag } from 'antd' import { useQuery } from '@tanstack/react-query' @@ -44,6 +47,13 @@ export default function AppShell() { ...((roles.includes('Student') || roles.includes('Teacher')) ? [{ key: '/my-timetable', icon: , label: '我的课表' }] : []), + ...((roles.includes('Student') || roles.includes('Teacher')) + ? [{ key: '/exams', icon: , label: roles.includes('Teacher') ? '监考安排' : '考试安排' }] + : []), + ...(isStudentHome ? [ + { key: '/grades', icon: , label: '我的成绩' }, + { key: '/warnings', icon: , label: '学业预警' }, + ] : []), ...(user?.roles.includes('SuperAdmin') ? [{ key: '/users', icon: , label: '用户与权限' }] : []), diff --git a/web-react/src/pages/ExamsPage.tsx b/web-react/src/pages/ExamsPage.tsx new file mode 100644 index 0000000..5e6402e --- /dev/null +++ b/web-react/src/pages/ExamsPage.tsx @@ -0,0 +1,30 @@ +import { ClockCircleOutlined, EnvironmentOutlined, ReloadOutlined, TeamOutlined } from '@ant-design/icons' +import { Alert, Button, Empty, Skeleton, Tag } from 'antd' +import { useQuery } from '@tanstack/react-query' +import http, { apiErrorMessage } from '../api/http' +import PageHeader from '../components/PageHeader' +import { useAuthStore } from '../stores/auth' +import './student-learning.css' + +interface Exam { id: string; planName: string; examDate: string; startsAt: string; endsAt: string; taskNumber: string; courseCode: string; courseName: string; classroomName?: string; buildingName?: string; invigilatorNames: string[]; seatNumber?: string; studentCount?: number; isExam: boolean } +function date(value: string) { return new Date(`${value.slice(0, 10)}T00:00:00`) } +function formatDate(value: string) { return new Intl.DateTimeFormat('zh-CN', { month: 'long', day: 'numeric', weekday: 'long' }).format(date(value)) } +function formatTime(value: string) { return new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date(value)) } + +export default function ExamsPage() { + const roles = useAuthStore((state) => state.user?.roles ?? []) + const teacher = roles.includes('Teacher') && !roles.includes('Student') + const query = useQuery({ queryKey: ['my-exams'], queryFn: async () => (await http.get('/exams/my-schedule')).data }) + if (query.isLoading) return
+ if (query.isError) return
} onClick={() => void query.refetch()}>重新加载} />
+ const now = Date.now() + const items = query.data ?? [] + const upcoming = items.filter((item) => new Date(item.endsAt).getTime() >= now) + const history = items.filter((item) => new Date(item.endsAt).getTime() < now).reverse() + return
+ UPCOMING{upcoming.length}场待进行} /> +
{upcoming.map((item, index) =>
{formatDate(item.examDate)}{Math.max(0, Math.ceil((date(item.examDate).getTime() - new Date().setHours(0,0,0,0)) / 86400000))}{index === 0 ? '距离最近安排 / 天' : '天后'}
{item.planName}{item.taskNumber}

{item.courseName}

{item.courseCode}

{[item.buildingName, item.classroomName].filter(Boolean).join(' · ') || '考场待定'}{teacher ? {item.studentCount ?? 0} 名考生 : 座位号 {item.seatNumber ?? '待分配'}}监考:{item.invigilatorNames.join('、') || '待安排'}
)}
+ {!upcoming.length &&
} + {!!history.length &&
HISTORY

已结束安排

{history.map((item) =>
{item.courseName}{[item.buildingName, item.classroomName].filter(Boolean).join(' · ') || '未记录考场'}
)}
} +
+} diff --git a/web-react/src/pages/StudentGradesPage.tsx b/web-react/src/pages/StudentGradesPage.tsx new file mode 100644 index 0000000..5dd0a0a --- /dev/null +++ b/web-react/src/pages/StudentGradesPage.tsx @@ -0,0 +1,35 @@ +import { useMemo, useState } from 'react' +import { BarChartOutlined, ReloadOutlined } from '@ant-design/icons' +import { Alert, Button, Empty, Select, Skeleton, Tag } from 'antd' +import { useQuery } from '@tanstack/react-query' +import http, { apiErrorMessage } from '../api/http' +import PageHeader from '../components/PageHeader' +import './student-learning.css' + +interface Term { id: string; name: string; academicYear: string; isCurrent: boolean } +interface GradeRecord { id: string; termName: string; taskNumber: string; courseCode: string; courseName: string; credits: number; regularScore?: number; regularWeight: number; itemScores: Array<{ name: string; weight: number; score?: number }>; finalScore?: number; finalWeight: number; totalScore?: number; gradePoint?: number; examStatus: string } +interface Transcript { student: { studentNumber: string; name: string; className: string; majorName: string }; records: GradeRecord[] } +interface Rank { rank?: number; peerCount: number } +interface Metric { value?: number; classRank: Rank; majorRank: Rank; collegeRank: Rank } +interface Overview { isRefreshing: boolean; courseCount: number; calculatedAt?: string; termTrend: Array<{ academicTermId: string; termName: string; courseCount: number; gpa?: number; weightedAverageScore?: number }>; metrics: { gpa: Metric; weightedAverageScore: Metric; averageScore: Metric } } +const status: Record = { Normal: '正常', Absent: '缺考', Deferred: '缓考', Exempt: '免修', Makeup: '补考' } +function rank(metric: Metric) { return metric.classRank.rank ? `班级 ${metric.classRank.rank}/${metric.classRank.peerCount}` : '暂无排名' } + +export default function StudentGradesPage() { + const [termId, setTermId] = useState() + const terms = useQuery({ queryKey: ['terms'], queryFn: async () => (await http.get('/base-data/terms')).data }) + const transcript = useQuery({ queryKey: ['student-transcript', termId], queryFn: async () => (await http.get('/grades/student/transcript', { params: { academicTermId: termId } })).data }) + const overview = useQuery({ queryKey: ['student-academic-overview'], queryFn: async () => (await http.get('/grades/student/academic-overview')).data }) + const records = transcript.data?.records ?? [] + const earnedCredits = useMemo(() => records.filter((item) => item.totalScore != null && item.totalScore >= 60).reduce((sum, item) => sum + Number(item.credits), 0), [records]) + const gpa = useMemo(() => { const numeric = records.filter((item) => item.gradePoint != null); const credits = numeric.reduce((sum, item) => sum + Number(item.credits), 0); return credits ? (numeric.reduce((sum, item) => sum + Number(item.gradePoint) * Number(item.credits), 0) / credits).toFixed(2) : '—' }, [records]) + const loading = terms.isLoading || transcript.isLoading || overview.isLoading + if (loading) return
+ if (transcript.isError) return
} onClick={() => void transcript.refetch()}>重新加载} />
+ return
+ ({ value: term.id, label: `${term.name}${term.isCurrent ? ' · 当前' : ''}` }))} />} /> + {overview.data &&
累计 GPA{overview.data.metrics.gpa.value ?? '—'}{rank(overview.data.metrics.gpa)}
加权平均分{overview.data.metrics.weightedAverageScore.value ?? '—'}{rank(overview.data.metrics.weightedAverageScore)}
已发布课程{overview.data.courseCount}{overview.data.isRefreshing ? '统计更新中' : '累计统计'}
} +
筛选范围{termId ? terms.data?.find((item) => item.id === termId)?.name : '全部学期'}
课程{records.length}门
获得学分{earnedCredits}分
加权 GPA{gpa}
+
{records.map((record) => { const components = [{ name: '平时', weight: record.regularWeight, score: record.regularScore }, ...record.itemScores, { name: '期末', weight: record.finalWeight, score: record.finalScore }].filter((item) => item.weight > 0); return
{record.termName}

{record.courseName}

{record.courseCode} · {record.taskNumber} · {record.credits} 学分

{record.totalScore ?? '—'}
{status[record.examStatus] ?? record.examStatus}{record.totalScore ?? '—'}绩点 {record.gradePoint ?? '—'}
{components.map((item) => {item.name}{item.score ?? '—'} × {item.weight}%)}
})}{!records.length && }
+
+} diff --git a/web-react/src/pages/StudentWarningsPage.tsx b/web-react/src/pages/StudentWarningsPage.tsx new file mode 100644 index 0000000..22e4d01 --- /dev/null +++ b/web-react/src/pages/StudentWarningsPage.tsx @@ -0,0 +1,36 @@ +import { useState } from 'react' +import { CheckOutlined, ReloadOutlined, WarningOutlined } from '@ant-design/icons' +import { Alert, App, Button, Empty, Input, Modal, Skeleton, Tag, Timeline } from 'antd' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import http, { apiErrorMessage } from '../api/http' +import PageHeader from '../components/PageHeader' +import './student-learning.css' + +interface Warning { id: string; type: number; status: number; triggerValue: number; detail: string; acknowledgedAt?: string; acknowledgeComment?: string; createdAt: string } +interface Entry { id: string; type: number; content: string; occurredAt: string; recordedByName: string } +interface RiskCase { id: string; status: number; followUpDueDate: string; reviewDate?: string; entries: Entry[] } +const typeLabels: Record = { 1: '不及格学分', 2: '低绩点', 3: '缺勤', 4: '延毕风险' } +const statusLabels: Record = { 1: '生效中', 2: '已确认', 3: '已处理', 4: '已忽略' } +const caseLabels: Record = { 1: '待跟进', 2: '跟进中', 3: '待复核', 4: '已结案' } +const entryLabels: Record = { 1: '谈话记录', 2: '帮扶措施', 3: '复核记录' } + +export default function StudentWarningsPage() { + const { message } = App.useApp() + const client = useQueryClient() + const [selected, setSelected] = useState(null) + const [comment, setComment] = useState('') + const warnings = useQuery({ queryKey: ['student-warnings'], queryFn: async () => (await http.get('/warnings/my-warnings')).data }) + const cases = useQuery({ queryKey: ['student-risk-cases'], queryFn: async () => (await http.get('/warnings/my-risk-cases')).data }) + const acknowledge = useMutation({ mutationFn: () => http.post(`/warnings/${selected!.id}/acknowledge`, { comment: comment.trim() || null }), onSuccess: () => { message.success('预警已确认'); setSelected(null); setComment(''); void client.invalidateQueries({ queryKey: ['student-warnings'] }) }, onError: (error) => message.error(apiErrorMessage(error)) }) + const loading = warnings.isLoading || cases.isLoading + if (loading) return
+ if (warnings.isError || cases.isError) return
} onClick={() => { void warnings.refetch(); void cases.refetch() }}>重新加载} />
+ const active = (warnings.data ?? []).filter((item) => item.status === 1) + return
+ ACTIVE{active.length}项待确认} /> +
当前状态{active.length ? `有 ${active.length} 项预警需要确认` : '没有待确认的学业预警'}

{active[0]?.detail ?? '继续保持学习节奏,定期查看成绩与考勤。'}

+
{(warnings.data ?? []).map((item) =>
{typeLabels[item.type] ?? '学业提醒'}{statusLabels[item.status] ?? '未知状态'}

{item.detail}

触发值:{item.triggerValue} · {new Date(item.createdAt).toLocaleString('zh-CN')}

{item.acknowledgeComment &&
确认备注:{item.acknowledgeComment}
}{item.status === 1 && }
)}{!warnings.data?.length && }
+ {!!cases.data?.length &&
SUPPORT & REVIEW

帮扶与复核进度

这里只展示与你本人相关的谈话、帮扶和复核记录。

{cases.data.map((item) =>
{caseLabels[item.status]}跟进截止:{item.followUpDueDate}复核日期:{item.reviewDate ?? '待安排'}
{item.entries.length ? ({ color: entry.type === 3 ? 'green' : 'blue', children:
{entryLabels[entry.type]}

{entry.content}

{new Date(entry.occurredAt).toLocaleString('zh-CN')} · {entry.recordedByName}
}))} /> : }
)}
} + acknowledge.mutate()} onCancel={() => setSelected(null)}> +
+} diff --git a/web-react/src/pages/dashboard/StudentDashboardPage.tsx b/web-react/src/pages/dashboard/StudentDashboardPage.tsx index 55f8235..1ee0a7b 100644 --- a/web-react/src/pages/dashboard/StudentDashboardPage.tsx +++ b/web-react/src/pages/dashboard/StudentDashboardPage.tsx @@ -11,7 +11,6 @@ import { Alert, Button, Skeleton } from 'antd' import { useQuery } from '@tanstack/react-query' import http from '../../api/http' import { useAuthStore } from '../../stores/auth' -import { openLegacy } from './legacy' interface Slot { periodNumber: number; startsAt: string; endsAt: string } interface Course { @@ -106,7 +105,7 @@ export default function StudentDashboardPage() { {data.failed.length > 0 && }
-
学业风险雷达{warnings.length ? `有 ${warnings.length} 项学习风险需要关注` : '当前没有需要处理的学业风险'}{warnings[0]?.detail ?? '继续保持当前学习节奏。'}
+
学业风险雷达{warnings.length ? `有 ${warnings.length} 项学习风险需要关注` : '当前没有需要处理的学业风险'}{warnings[0]?.detail ?? '继续保持当前学习节奏。'}
@@ -118,8 +117,8 @@ export default function StudentDashboardPage() { {data.notices.length ?
{data.notices.slice(0, 4).map((item) => )}
: } title="暂无消息" detail="新的教务通知会显示在这里。" />}
-
UPCOMING

近期考试

- {exams.length ?
{exams.map((item) => )}
: } title="近期没有考试" detail="已发布的考试安排会显示在这里。" />} +
UPCOMING

近期考试

+ {exams.length ?
{exams.map((item) => )}
: } title="近期没有考试" detail="已发布的考试安排会显示在这里。" />}
diff --git a/web-react/src/pages/student-learning.css b/web-react/src/pages/student-learning.css new file mode 100644 index 0000000..0419442 --- /dev/null +++ b/web-react/src/pages/student-learning.css @@ -0,0 +1,91 @@ +.learning-page { gap: 16px; } +.learning-ledger { min-width: 168px; padding: 13px 16px; display: grid; grid-template-columns: 1fr auto; gap: 3px 16px; border-left: 3px solid var(--teal); background: var(--teal-soft); } +.learning-ledger span { color: var(--teal); font: 700 9px var(--font-data); letter-spacing: .14em; } +.learning-ledger strong { grid-row: 1 / 3; grid-column: 2; align-self: center; color: var(--teal); font: 700 31px/1 var(--font-data); } +.learning-ledger small { color: #657872; font-size: 9px; } +.learning-ledger.is-attention { border-color: var(--amber); background: var(--amber-soft); } +.learning-ledger.is-attention span, .learning-ledger.is-attention strong { color: #9a6419; } +.exam-timeline { display: grid; gap: 14px; } +.exam-timeline > article { display: grid; grid-template-columns: 185px minmax(0, 1fr); border: 1px solid var(--line); background: var(--paper); box-shadow: 0 8px 22px rgba(22,40,88,.045); } +.exam-date-block { padding: 20px; display: grid; grid-template-columns: 1fr auto; align-content: center; color: white; background: linear-gradient(145deg, var(--brand-deep), var(--brand)); } +.exam-date-block span { grid-column: 1 / -1; color: #c8d3e9; font-size: 11px; } +.exam-date-block strong { margin-top: 13px; color: var(--teal-bright); font: 700 36px/1 var(--font-data); } +.exam-date-block small { align-self: end; color: #aebcda; font-size: 8px; } +.exam-card { padding: 20px 23px; } +.exam-card header, .exam-card footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } +.exam-card header > div { display: flex; gap: 7px; align-items: center; color: var(--ink-muted); font-size: 10px; } +.exam-card time { color: var(--teal); font: 700 11px var(--font-data); } +.exam-card h2 { margin: 12px 0 4px; color: var(--brand-deep); font: 600 19px var(--font-display); } +.exam-card > p { margin: 0 0 16px; color: var(--ink-muted); font: 10px var(--font-data); } +.exam-card footer { justify-content: flex-start; color: #637085; font-size: 10px; } +.exam-card footer span { padding-right: 14px; border-right: 1px solid var(--line); } +.exam-card footer span:last-child { border-right: 0; } +.exam-card footer b { color: var(--amber); font: 700 15px var(--font-data); } +.learning-empty { min-height: 300px; display: grid; place-items: center; border: 1px solid var(--line); background: white; } +.history-panel, .grade-list, .warning-list, .risk-progress { border: 1px solid var(--line); background: white; } +.history-panel > header, .risk-progress > header { padding: 18px 21px; border-bottom: 1px solid var(--line); } +.history-panel header span, .risk-progress header span { color: var(--teal); font: 700 9px var(--font-data); letter-spacing: .13em; } +.history-panel h2, .risk-progress h2 { margin: 6px 0 0; color: var(--brand-deep); font: 600 17px var(--font-display); } +.history-panel > div { display: grid; grid-template-columns: repeat(3, 1fr); } +.history-panel article { padding: 16px 20px; display: grid; gap: 5px; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); } +.history-panel time, .history-panel span { color: var(--ink-muted); font-size: 9px; } +.history-panel strong { color: var(--ink-strong); font-size: 12px; } +.academic-metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; } +.academic-metrics article { padding: 18px; border-left: 3px solid var(--teal); background: #f7faf9; } +.academic-metrics span, .academic-metrics small { color: #69778a; font-size: 10px; } +.academic-metrics strong { display: block; margin: 9px 0 5px; color: var(--brand-deep); font: 700 25px var(--font-data); } +.term-summary { display: grid; grid-template-columns: 1.4fr repeat(3, .6fr); color: white; background: linear-gradient(108deg, var(--brand-deep), var(--brand) 76%, #176b6c); } +.term-summary > div { min-height: 82px; padding: 16px 20px; display: grid; align-content: center; border-right: 1px solid rgba(255,255,255,.13); } +.term-summary span, .term-summary small { color: #b9c6df; font-size: 9px; } +.term-summary strong { margin-top: 6px; color: var(--teal-bright); font: 700 22px var(--font-data); } +.grade-list { padding: 0 22px; } +.grade-list > article { min-height: 142px; padding: 20px 0; display: grid; grid-template-columns: minmax(210px, 1fr) minmax(180px, 1.1fr) 95px; gap: 22px; align-items: center; border-bottom: 1px solid var(--line); } +.grade-course span { color: var(--teal); font: 9px var(--font-data); } +.grade-course h2 { margin: 7px 0 5px; color: var(--brand-deep); font-size: 15px; } +.grade-course p { margin: 0; color: var(--ink-muted); font-size: 9px; } +.score-scale { height: 2px; background: linear-gradient(90deg, #d05b4d 0 60%, var(--amber) 60% 85%, var(--teal) 85%); position: relative; } +.score-scale i { position: absolute; inset: -5px 40% -5px auto; border-left: 1px solid rgba(255,255,255,.8); } +.score-scale span { padding: 3px 5px; color: white; background: var(--brand-deep); font: 700 9px var(--font-data); transform: translate(-50%, -50%); position: absolute; top: 50%; } +.grade-result { display: grid; justify-items: end; gap: 4px; } +.grade-result strong { color: var(--teal); font: 700 27px var(--font-data); } +.grade-result.is-failed strong { color: var(--danger); } +.grade-result small { color: var(--ink-muted); font-size: 9px; } +.grade-formula { grid-column: 1 / -1; padding: 9px 12px; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; color: #657286; background: #f7f8fa; font-size: 9px; } +.grade-formula > .anticon { color: var(--teal); } +.grade-formula span { padding-right: 12px; border-right: 1px solid var(--line); } +.grade-formula b { margin-right: 5px; color: #34445a; } +.warning-radar { min-height: 88px; padding: 17px 20px; display: flex; gap: 16px; align-items: center; border-left: 3px solid var(--teal); background: var(--teal-soft); } +.warning-radar > .anticon { color: var(--teal); font-size: 24px; } +.warning-radar span { color: var(--teal); font: 700 9px var(--font-data); letter-spacing: .12em; } +.warning-radar strong { display: block; margin: 5px 0; color: #284843; font-size: 13px; } +.warning-radar p { margin: 0; color: #687b77; font-size: 10px; } +.warning-radar.is-attention { border-color: var(--amber); background: var(--amber-soft); } +.warning-radar.is-attention > .anticon, .warning-radar.is-attention span { color: #9a6419; } +.warning-list { padding: 0 20px; } +.warning-list > article { padding: 18px 2px; display: grid; grid-template-columns: 1fr auto; gap: 8px 18px; border-bottom: 1px solid var(--line); } +.warning-list > article.is-active { border-left: 3px solid var(--amber); padding-left: 15px; } +.warning-list article > div { display: flex; gap: 5px; } +.warning-list h2 { grid-column: 1; margin: 4px 0 0; color: var(--ink-strong); font-size: 13px; } +.warning-list p, .warning-list blockquote { grid-column: 1; margin: 0; color: var(--ink-muted); font-size: 10px; } +.warning-list blockquote { padding: 8px 10px; border-left: 2px solid var(--line-strong); background: #f8f9fa; } +.warning-list .ant-btn { grid-column: 2; grid-row: 1 / 4; align-self: center; } +.risk-progress > header p { margin: 5px 0 0; color: var(--ink-muted); font-size: 10px; } +.risk-progress > article { padding: 20px 22px; border-bottom: 1px solid var(--line); } +.risk-case-summary { margin-bottom: 17px; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; color: var(--ink-muted); font-size: 10px; } +.risk-progress .ant-timeline { margin: 12px 8px 0; } +.risk-progress .ant-timeline p { margin: 5px 0; white-space: pre-wrap; color: var(--ink); font-size: 11px; } +.risk-progress .ant-timeline small { color: var(--ink-muted); font-size: 9px; } +.ack-comment { margin-top: 18px; display: grid; gap: 8px; color: var(--ink); font-size: 11px; } +@media (max-width: 760px) { + .exam-timeline > article { grid-template-columns: 1fr; } + .exam-date-block { grid-template-columns: 1fr auto; } + .history-panel > div, .academic-metrics { grid-template-columns: 1fr; } + .term-summary { grid-template-columns: 1fr 1fr; } + .grade-list { padding-inline: 15px; } + .grade-list > article { grid-template-columns: 1fr 76px; gap: 14px; } + .score-scale { grid-column: 1 / -1; grid-row: 2; } + .grade-result { grid-column: 2; grid-row: 1; } + .grade-formula { grid-column: 1 / -1; } + .warning-list > article { grid-template-columns: 1fr; } + .warning-list .ant-btn { grid-column: 1; grid-row: auto; justify-self: start; } +}