diff --git a/web-react/MIGRATION_MATRIX.md b/web-react/MIGRATION_MATRIX.md
index 5dfa2e6..11bbed1 100644
--- a/web-react/MIGRATION_MATRIX.md
+++ b/web-react/MIGRATION_MATRIX.md
@@ -5,7 +5,7 @@
| 路由 | 页面 | 角色/公开范围 | 关键合同 | 状态 |
| --- | --- | --- | --- | --- |
| `/login` | 登录 | 公开 | 验证码、密码、TOTP、SSO 设置、会话落盘 | building |
-| `/dashboard` | 教务总览 | 已登录用户 | `GET /api/dashboard`、角色视图、待办和学期 | building |
+| `/dashboard` | 角色化首页 | 已登录用户 | 管理总览;学生课表/消息/考试/预警;教师教学班/成绩登记进度 | verification |
| `/account` | 个人账户 | 已登录用户 | TOTP、恢复码、SSO 绑定;Passkey 浏览器仪式尚未开放 | verification |
| `/users` | 用户与权限 | SuperAdmin | 列表、筛选、分页、创建、启停、重置密码、角色范围、Excel | verification |
diff --git a/web-react/src/layout/AppShell.tsx b/web-react/src/layout/AppShell.tsx
index d23b68c..3023d8e 100644
--- a/web-react/src/layout/AppShell.tsx
+++ b/web-react/src/layout/AppShell.tsx
@@ -23,17 +23,21 @@ export default function AppShell() {
const logout = useAuthStore((state) => state.logout)
const refreshUser = useAuthStore((state) => state.refreshUser)
const [collapsed, setCollapsed] = useState(false)
+ const roles = user?.roles ?? []
+ const isStudentHome = roles.includes('Student') && !roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor'].includes(role))
+ const isTeacherHome = roles.includes('Teacher') && !roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor', 'Student'].includes(role))
+ const legacyBase = (import.meta.env.VITE_LEGACY_APP_URL ?? 'http://localhost:5255').replace(/\/$/, '')
useEffect(() => { void refreshUser().catch(() => undefined) }, [refreshUser])
const items = useMemo(() => [
- { key: '/dashboard', icon: , label: '教务总览' },
+ { key: '/dashboard', icon: , label: isStudentHome ? '学习首页' : isTeacherHome ? '教学工作台' : '教务总览' },
{ key: '/account', icon: , label: '个人账户' },
...(user?.roles.includes('SuperAdmin')
? [{ key: '/users', icon: , label: '用户与权限' }]
: []),
{ key: 'legacy', icon: , label: '返回现有系统' },
- ], [user?.roles])
+ ], [isStudentHome, isTeacherHome, user?.roles])
const pageTitle = items.find((item) => location.pathname.startsWith(item.key))?.label ?? '迁移预览'
@@ -83,7 +87,7 @@ export default function AppShell() {
当前工作区{pageTitle}
-
} />
+
} onClick={() => window.location.assign(`${legacyBase}/notifications`)} />
预览
{user?.displayName ?? '用户'}{user?.roles.join(' · ') || '正在读取权限'}
{user?.displayName?.slice(0, 1) ?? '明'}
diff --git a/web-react/src/pages/DashboardPage.tsx b/web-react/src/pages/DashboardPage.tsx
index 0702ff2..96c6af2 100644
--- a/web-react/src/pages/DashboardPage.tsx
+++ b/web-react/src/pages/DashboardPage.tsx
@@ -2,6 +2,10 @@ import { Alert, Button, Progress, Skeleton } from 'antd'
import { ArrowRightOutlined, ReloadOutlined } from '@ant-design/icons'
import { useQuery } from '@tanstack/react-query'
import http from '../api/http'
+import { useAuthStore } from '../stores/auth'
+import StudentDashboardPage from './dashboard/StudentDashboardPage'
+import TeacherDashboardPage from './dashboard/TeacherDashboardPage'
+import './dashboard/role-dashboard.css'
interface DashboardData {
audience: {
@@ -49,7 +53,7 @@ function formatDate(value?: string) {
return new Intl.DateTimeFormat('zh-CN', { month: 'long', day: 'numeric' }).format(date)
}
-export default function DashboardPage() {
+function AdminDashboardPage() {
const query = useQuery({
queryKey: ['dashboard'],
queryFn: async () => (await http.get
('/dashboard')).data,
@@ -131,3 +135,15 @@ export default function DashboardPage() {
)
}
+
+export default function DashboardPage() {
+ const roles = useAuthStore((state) => state.user?.roles ?? [])
+ const isStudent = roles.includes('Student') &&
+ !roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor'].includes(role))
+ const isTeacher = roles.includes('Teacher') &&
+ !roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor', 'Student'].includes(role))
+
+ if (isStudent) return
+ if (isTeacher) return
+ return
+}
diff --git a/web-react/src/pages/dashboard/StudentDashboardPage.tsx b/web-react/src/pages/dashboard/StudentDashboardPage.tsx
new file mode 100644
index 0000000..462d570
--- /dev/null
+++ b/web-react/src/pages/dashboard/StudentDashboardPage.tsx
@@ -0,0 +1,131 @@
+import {
+ BellOutlined,
+ CalendarOutlined,
+ EnvironmentOutlined,
+ ExclamationCircleOutlined,
+ ReadOutlined,
+ ReloadOutlined,
+ RightOutlined,
+} from '@ant-design/icons'
+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 {
+ id: string; courseCode: string; courseName: string; teacherNames: string[]; classroomName: string
+ buildingName?: string; dayOfWeek: number; startPeriod: number; periodCount: number
+ startWeek: number; endWeek: number; weekPattern: 'All' | 'Odd' | 'Even'
+}
+interface Timetable { term: { name: string; startDate: string; endDate: string }; slots: Slot[]; entries: Course[] }
+interface Notice { id: string; title: string; content: string; category: string; isRead: boolean; senderName: string; createdAt: string }
+interface Exam { id: string; examDate: string; startsAt: string; endsAt: string; courseCode: string; courseName: string; buildingName?: string; classroomName?: string }
+interface Warning { id: string; status: number; detail: string }
+interface Greeting { title: string; subtitle: string; narrative: string; label: string; insights: Array<{ label: string; value: string; hint: string; tone: string }> }
+interface StudentOverview {
+ timetable?: Timetable
+ notices: Notice[]
+ unreadCount: number
+ exams: Exam[]
+ warnings: Warning[]
+ greeting?: Greeting
+ failed: string[]
+}
+
+const noticeLabels: Record = {
+ General: '一般通知', Approval: '审核待办', Schedule: '课程变动', Grade: '成绩通知',
+ Attendance: '考勤消息', CourseSelection: '选课消息', Warning: '学业预警',
+}
+
+function dateOnly(value?: string) {
+ if (!value) return null
+ const [year, month, day] = value.slice(0, 10).split('-').map(Number)
+ return year && month && day ? new Date(year, month - 1, day) : null
+}
+
+function weekOf(term?: Timetable['term']) {
+ const start = dateOnly(term?.startDate)
+ const end = dateOnly(term?.endDate)
+ const today = new Date(); today.setHours(0, 0, 0, 0)
+ if (!start || !end || today < start || today > end) return null
+ const mondayOffset = start.getDay() === 0 ? 6 : start.getDay() - 1
+ const firstMonday = new Date(start); firstMonday.setDate(start.getDate() - mondayOffset)
+ return Math.floor((today.getTime() - firstMonday.getTime()) / 604800000) + 1
+}
+
+function time(value: string) {
+ const parsed = new Date(value)
+ if (Number.isNaN(parsed.getTime())) return value.slice(0, 5)
+ return new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false }).format(parsed)
+}
+
+async function loadStudentOverview(): Promise {
+ const results = await Promise.allSettled([
+ http.get('/timetables/mine'),
+ http.get<{ items: Notice[]; unreadCount: number }>('/notifications', { params: { page: 1, pageSize: 5 } }),
+ http.get('/exams/my-schedule'),
+ http.get('/dashboard/greeting'),
+ http.get('/warnings/my-warnings'),
+ ])
+ const failed: string[] = []
+ const pick = (index: number, label: string): T | undefined => {
+ const result = results[index]
+ if (result.status === 'fulfilled') return result.value.data as T
+ failed.push(label); return undefined
+ }
+ const noticeData = pick<{ items: Notice[]; unreadCount: number }>(1, '消息中心')
+ return {
+ timetable: pick(0, '当日课表'),
+ notices: noticeData?.items ?? [], unreadCount: noticeData?.unreadCount ?? 0,
+ exams: pick(2, '考试安排') ?? [], greeting: pick(3, '学习概览'),
+ warnings: pick(4, '学业提醒') ?? [], failed,
+ }
+}
+
+export default function StudentDashboardPage() {
+ const name = useAuthStore((state) => state.user?.displayName ?? '同学')
+ const query = useQuery({ queryKey: ['dashboard', 'student'], queryFn: loadStudentOverview })
+ if (query.isLoading) return
+ if (query.isError || !query.data) return } onClick={() => void query.refetch()}>重试} />
+
+ const data = query.data
+ const week = weekOf(data.timetable?.term)
+ const weekday = new Date().getDay() || 7
+ const courses = (data.timetable?.entries ?? []).filter((item) => item.dayOfWeek === weekday && !!week && week >= item.startWeek && week <= item.endWeek && (item.weekPattern === 'All' || (item.weekPattern === 'Odd' && week % 2 === 1) || (item.weekPattern === 'Even' && week % 2 === 0))).sort((a, b) => a.startPeriod - b.startPeriod)
+ const warnings = data.warnings.filter((item) => item.status === 1)
+ const exams = data.exams.filter((item) => (dateOnly(item.examDate)?.getTime() ?? 0) >= new Date().setHours(0, 0, 0, 0)).sort((a, b) => (dateOnly(a.examDate)?.getTime() ?? 0) - (dateOnly(b.examDate)?.getTime() ?? 0)).slice(0, 3)
+ const slot = (period: number, field: 'startsAt' | 'endsAt') => data.timetable?.slots.find((item) => item.periodNumber === period)?.[field]?.slice(0, 5) ?? ''
+
+ return (
+
+
+ 我的学习安排{data.greeting?.title ?? `你好,${name}`}
{data.greeting?.subtitle ?? new Intl.DateTimeFormat('zh-CN', { month: 'long', day: 'numeric', weekday: 'long' }).format(new Date())}
{data.greeting?.narrative}
+ 今日课程{courses.length}{week ? `第 ${week} 教学周` : '当前不在教学周内'}
+
+ {data.failed.length > 0 && }
+
+ 学业风险雷达{warnings.length ? `有 ${warnings.length} 项学习风险需要关注` : '当前没有需要处理的学业风险'}{warnings[0]?.detail ?? '继续保持当前学习节奏。'}
+
+
+
+ TODAY
当日课表
+ {courses.length ? {courses.map((course) =>
)}
: } title="今天没有课程" detail={week ? '可以安排自习或处理待办。' : '当前不在教学周内。'} />}
+
+
+ INBOX
消息中心
+ {data.notices.length ? {data.notices.slice(0, 4).map((item) =>
)}
: } title="暂无消息" detail="新的教务通知会显示在这里。" />}
+
+
+ UPCOMING
近期考试
+ {exams.length ? {exams.map((item) =>
)}
: } title="近期没有考试" detail="已发布的考试安排会显示在这里。" />}
+
+
+
+ )
+}
+
+function Empty({ icon, title, detail }: { icon: React.ReactNode; title: string; detail: string }) {
+ return {icon}{title}{detail}
+}
diff --git a/web-react/src/pages/dashboard/TeacherDashboardPage.tsx b/web-react/src/pages/dashboard/TeacherDashboardPage.tsx
new file mode 100644
index 0000000..67e6339
--- /dev/null
+++ b/web-react/src/pages/dashboard/TeacherDashboardPage.tsx
@@ -0,0 +1,41 @@
+import { CalendarOutlined, CheckCircleOutlined, ReloadOutlined, RightOutlined } from '@ant-design/icons'
+import { Alert, Button, Progress, Skeleton } from 'antd'
+import { useQuery } from '@tanstack/react-query'
+import http from '../../api/http'
+import { openLegacy } from './legacy'
+
+interface Greeting { title: string; subtitle: string; narrative: string; label: string; insights: Array<{ label: string; value: string; hint: string; tone: string }> }
+interface Sheet { id: string; courseName: string; taskName: string; classNames: string[]; sheet?: { status: string; studentCount: number; completedCount: number } }
+interface TeacherOverview { greeting: Greeting; sheets: Sheet[] }
+const statusLabel: Record = { Draft: '待登记', Returned: '已退回', Submitted: '审核中', Approved: '已审核', Published: '已发布' }
+
+async function loadTeacherOverview(): Promise {
+ const dashboard = await http.get<{ currentTerm?: { id: string }; greeting: Greeting }>('/dashboard')
+ const sheets = await http.get<{ items: Sheet[] }>('/grades/sheets', { params: { academicTermId: dashboard.data.currentTerm?.id, pageSize: 50 } })
+ return { greeting: dashboard.data.greeting, sheets: sheets.data.items }
+}
+
+export default function TeacherDashboardPage() {
+ const query = useQuery({ queryKey: ['dashboard', 'teacher'], queryFn: loadTeacherOverview })
+ if (query.isLoading) return
+ if (query.isError || !query.data) return } onClick={() => void query.refetch()}>重试} />
+ const { greeting, sheets } = query.data
+ const pending = sheets.filter((item) => ['Draft', 'Returned'].includes(item.sheet?.status ?? ''))
+ const progress = (sheet?: Sheet['sheet']) => sheet?.studentCount ? Math.round(sheet.completedCount / sheet.studentCount * 100) : 0
+ return (
+
+ 教学工作台{greeting.title}
{greeting.subtitle}
{greeting.narrative}
+ {!!greeting.insights.length && {greeting.insights.map((item) => {item.label}{item.value}{item.hint})}}
+
+
+ GRADE PROGRESS
成绩登记进度
+ {pending.length ? {pending.slice(0, 5).map((item) =>
)}
: 当前没有待提交成绩成绩登记册会在这里按优先级显示。
}
+
+
+ TEACHING CLASSES
本学期教学班
+ {sheets.length ? {sheets.slice(0, 5).map((item) =>
)}
: 本学期暂未分配教学班教学任务发布后会自动汇总到这里。
}
+
+
+
+ )
+}
diff --git a/web-react/src/pages/dashboard/legacy.ts b/web-react/src/pages/dashboard/legacy.ts
new file mode 100644
index 0000000..6320c75
--- /dev/null
+++ b/web-react/src/pages/dashboard/legacy.ts
@@ -0,0 +1,5 @@
+export function openLegacy(path: string) {
+ const configured = import.meta.env.VITE_LEGACY_APP_URL ?? 'http://localhost:5255'
+ const base = configured.endsWith('/') ? configured.slice(0, -1) : configured
+ window.location.assign(`${base}${path.startsWith('/') ? path : `/${path}`}`)
+}
diff --git a/web-react/src/pages/dashboard/role-dashboard.css b/web-react/src/pages/dashboard/role-dashboard.css
new file mode 100644
index 0000000..5fc6926
--- /dev/null
+++ b/web-react/src/pages/dashboard/role-dashboard.css
@@ -0,0 +1,67 @@
+.role-dashboard { gap: 18px; }
+.role-hero { min-height: 190px; padding: 30px 34px; display: flex; align-items: center; justify-content: space-between; gap: 28px; color: #fff; overflow: hidden; position: relative; background: linear-gradient(122deg, #162858, #284583 62%, #087f73 128%); }
+.role-hero::after { content: ""; width: 260px; height: 260px; border: 1px solid rgba(84,207,189,.24); border-radius: 50%; position: absolute; right: -90px; top: -118px; box-shadow: 0 0 0 42px rgba(84,207,189,.05); }
+.role-hero > div { position: relative; z-index: 1; }
+.role-hero span, .role-panel header span { color: var(--teal-bright); font: 700 10px/1 var(--font-data); letter-spacing: .13em; }
+.role-hero h1 { margin: 13px 0 8px; font: 600 clamp(27px, 3vw, 39px)/1.22 var(--font-display); letter-spacing: .03em; }
+.role-hero p { margin: 0; color: #d2dcf2; font-size: 14px; }
+.role-hero small { display: block; max-width: 700px; margin-top: 14px; color: #aebee0; font-size: 12px; line-height: 1.7; }
+.role-hero__status { min-width: 150px; padding: 22px; border: 1px solid rgba(255,255,255,.2); background: rgba(10,27,62,.28); backdrop-filter: blur(8px); }
+.role-hero__status span, .role-hero__status small { display: block; margin: 0; }
+.role-hero__status strong { display: block; margin: 9px 0 7px; font: 700 42px/1 var(--font-data); }
+.risk-strip { min-height: 84px; padding: 16px 20px; display: grid; grid-template-columns: auto 1fr auto; gap: 15px; align-items: center; border-left: 3px solid var(--teal); background: var(--teal-soft); }
+.risk-strip > .anticon { color: var(--teal); font-size: 22px; }
+.risk-strip div { display: grid; gap: 3px; }
+.risk-strip span, .risk-strip small { color: #657488; font-size: 10px; }
+.risk-strip strong { color: #24433f; font-size: 13px; }
+.risk-strip--attention { border-color: var(--amber); background: var(--amber-soft); }
+.risk-strip--attention > .anticon { color: var(--amber); }
+.role-dashboard__grid { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(300px, .75fr); gap: 18px; align-items: start; }
+.role-dashboard__grid--equal { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+.role-panel { min-height: 290px; border: 1px solid var(--line); background: var(--paper); box-shadow: 0 10px 25px rgba(22,40,88,.045); }
+.role-panel--wide { grid-row: span 2; }
+.role-panel header { min-height: 74px; padding: 18px 21px; display: flex; justify-content: space-between; gap: 12px; align-items: center; border-bottom: 1px solid var(--line); }
+.role-panel header h2 { margin: 7px 0 0; color: var(--brand-deep); font: 600 18px var(--font-display); }
+.day-rail, .compact-list, .teacher-list { padding: 2px 21px 12px; }
+.day-rail button, .compact-list button, .teacher-list button { width: 100%; padding: 16px 0; display: flex; gap: 14px; align-items: center; border: 0; border-bottom: 1px solid #edf0f3; text-align: left; background: transparent; cursor: pointer; }
+.day-rail time { width: 70px; flex: 0 0 auto; }
+.day-rail time b, .day-rail time small { display: block; }
+.day-rail time b { color: var(--brand-deep); font: 700 14px var(--font-data); }
+.day-rail time small { margin-top: 5px; color: var(--ink-muted); font: 10px var(--font-data); }
+.day-rail i { width: 9px; height: 9px; flex: 0 0 auto; border: 2px solid var(--teal); border-radius: 50%; box-shadow: 0 0 0 5px var(--teal-soft); }
+.day-rail button > div, .compact-list button > div, .teacher-list button > div { min-width: 0; flex: 1; }
+.day-rail span, .compact-list span, .compact-list small, .teacher-list span, .teacher-list small { display: block; color: #768297; font-size: 10px; }
+.day-rail strong, .compact-list strong, .teacher-list strong { display: block; margin: 4px 0; overflow: hidden; color: #27374d; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
+.day-rail small { color: #69778b; font-size: 10px; }
+.compact-list button { position: relative; }
+.compact-list button.is-unread::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--teal); position: absolute; left: -12px; top: 22px; }
+.compact-list small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.exam-list button { align-items: flex-start; }
+.exam-list time { min-width: 58px; color: var(--teal); font: 700 13px var(--font-data); }
+.role-empty { min-height: 210px; display: grid; place-content: center; justify-items: center; gap: 9px; padding: 24px; color: #929cab; text-align: center; }
+.role-empty > .anticon { font-size: 26px; }
+.role-empty strong { color: #5a687a; font-size: 13px; }
+.role-empty span { font-size: 11px; }
+.role-insights { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
+.role-insights article { padding: 17px 18px; display: grid; gap: 5px; border-left: 3px solid #a9bcc6; background: #f7faf9; }
+.role-insights article.is-positive { border-color: var(--teal); }
+.role-insights article.is-attention { border-color: var(--amber); }
+.role-insights span, .role-insights small { color: #657488; font-size: 10px; }
+.role-insights strong { color: var(--brand-deep); font: 700 22px var(--font-data); }
+.teacher-list button { justify-content: space-between; }
+.teacher-list__progress { flex: 0 0 auto !important; }
+
+@media (max-width: 900px) {
+ .role-dashboard__grid, .role-dashboard__grid--equal { grid-template-columns: 1fr; }
+ .role-panel--wide { grid-row: auto; }
+}
+@media (max-width: 640px) {
+ .role-hero { min-height: 0; padding: 24px 20px; align-items: stretch; flex-direction: column; }
+ .role-hero__status { width: 100%; min-width: 0; padding: 15px 17px; display: grid; grid-template-columns: 1fr auto; align-items: center; }
+ .role-hero__status strong { grid-row: 1 / 3; grid-column: 2; margin: 0; font-size: 34px; }
+ .role-insights { grid-template-columns: 1fr; }
+ .risk-strip { grid-template-columns: auto 1fr; }
+ .risk-strip .ant-btn { grid-column: 2; justify-self: start; padding: 0; }
+ .role-panel header { padding: 16px; }
+ .day-rail, .compact-list, .teacher-list { padding-inline: 16px; }
+}