From 44f09cec1d8d8e3d3381dd4ef94a6feac47e1eab Mon Sep 17 00:00:00 2001 From: biss Date: Thu, 1 Oct 2026 13:34:23 +0800 Subject: [PATCH] feat(web): add role-aware React dashboards Add student and teacher dashboard variants backed by existing APIs, retain the administrative overview for management roles, and provide responsive navigation back to legacy routes that have not migrated yet. --- web-react/MIGRATION_MATRIX.md | 2 +- web-react/src/layout/AppShell.tsx | 10 +- web-react/src/pages/DashboardPage.tsx | 18 ++- .../pages/dashboard/StudentDashboardPage.tsx | 131 ++++++++++++++++++ .../pages/dashboard/TeacherDashboardPage.tsx | 41 ++++++ web-react/src/pages/dashboard/legacy.ts | 5 + .../src/pages/dashboard/role-dashboard.css | 67 +++++++++ 7 files changed, 269 insertions(+), 5 deletions(-) create mode 100644 web-react/src/pages/dashboard/StudentDashboardPage.tsx create mode 100644 web-react/src/pages/dashboard/TeacherDashboardPage.tsx create mode 100644 web-react/src/pages/dashboard/legacy.ts create mode 100644 web-react/src/pages/dashboard/role-dashboard.css 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}
- } /> + + 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; } +}