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.
This commit is contained in:
1 parent
1ab9b76101
commit
44f09cec1d
7 files changed
+269
-5
No files matched your search
@@ -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: <AppstoreOutlined />, label: '教务总览' },
|
||||
{ key: '/dashboard', icon: <AppstoreOutlined />, label: isStudentHome ? '学习首页' : isTeacherHome ? '教学工作台' : '教务总览' },
|
||||
{ key: '/account', icon: <IdcardOutlined />, label: '个人账户' },
|
||||
...(user?.roles.includes('SuperAdmin')
|
||||
? [{ key: '/users', icon: <SafetyCertificateOutlined />, label: '用户与权限' }]
|
||||
: []),
|
||||
{ key: 'legacy', icon: <ExportOutlined />, 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() {
|
||||
<div className="shell-context"><span>当前工作区</span><strong>{pageTitle}</strong></div>
|
||||
</div>
|
||||
<div className="shell-user">
|
||||
<Button type="text" aria-label="通知中心" icon={<BellOutlined />} />
|
||||
<Button type="text" aria-label="通知中心" icon={<BellOutlined />} onClick={() => window.location.assign(`${legacyBase}/notifications`)} />
|
||||
<Tag color="geekblue">预览</Tag>
|
||||
<div className="shell-user__copy"><strong>{user?.displayName ?? '用户'}</strong><small>{user?.roles.join(' · ') || '正在读取权限'}</small></div>
|
||||
<Avatar style={{ background: '#54cfbd', color: '#163f43' }}>{user?.displayName?.slice(0, 1) ?? '明'}</Avatar>
|
||||
|
||||
@@ -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<DashboardData>('/dashboard')).data,
|
||||
@@ -131,3 +135,15 @@ export default function DashboardPage() {
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
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 <StudentDashboardPage />
|
||||
if (isTeacher) return <TeacherDashboardPage />
|
||||
return <AdminDashboardPage />
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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<StudentOverview> {
|
||||
const results = await Promise.allSettled([
|
||||
http.get<Timetable>('/timetables/mine'),
|
||||
http.get<{ items: Notice[]; unreadCount: number }>('/notifications', { params: { page: 1, pageSize: 5 } }),
|
||||
http.get<Exam[]>('/exams/my-schedule'),
|
||||
http.get<Greeting>('/dashboard/greeting'),
|
||||
http.get<Warning[]>('/warnings/my-warnings'),
|
||||
])
|
||||
const failed: string[] = []
|
||||
const pick = <T,>(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<Timetable>(0, '当日课表'),
|
||||
notices: noticeData?.items ?? [], unreadCount: noticeData?.unreadCount ?? 0,
|
||||
exams: pick<Exam[]>(2, '考试安排') ?? [], greeting: pick<Greeting>(3, '学习概览'),
|
||||
warnings: pick<Warning[]>(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 <main className="page-container"><Skeleton active paragraph={{ rows: 12 }} /></main>
|
||||
if (query.isError || !query.data) return <main className="page-container"><Alert type="error" showIcon message="学习首页暂时无法加载" action={<Button icon={<ReloadOutlined />} onClick={() => void query.refetch()}>重试</Button>} /></main>
|
||||
|
||||
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 (
|
||||
<main className="page-container role-dashboard">
|
||||
<section className="role-hero role-hero--student">
|
||||
<div><span>我的学习安排</span><h1>{data.greeting?.title ?? `你好,${name}`}</h1><p>{data.greeting?.subtitle ?? new Intl.DateTimeFormat('zh-CN', { month: 'long', day: 'numeric', weekday: 'long' }).format(new Date())}</p><small>{data.greeting?.narrative}</small></div>
|
||||
<div className="role-hero__status"><span>今日课程</span><strong>{courses.length}</strong><small>{week ? `第 ${week} 教学周` : '当前不在教学周内'}</small></div>
|
||||
</section>
|
||||
{data.failed.length > 0 && <Alert type="warning" showIcon message={`${data.failed.join('、')}暂时无法加载,其余内容不受影响。`} />}
|
||||
<section className={`risk-strip${warnings.length ? ' risk-strip--attention' : ''}`}>
|
||||
<ExclamationCircleOutlined /><div><span>学业风险雷达</span><strong>{warnings.length ? `有 ${warnings.length} 项学习风险需要关注` : '当前没有需要处理的学业风险'}</strong><small>{warnings[0]?.detail ?? '继续保持当前学习节奏。'}</small></div><Button type="link" onClick={() => openLegacy('/warnings')}>查看全部 <RightOutlined /></Button>
|
||||
</section>
|
||||
<section className="role-dashboard__grid">
|
||||
<article className="role-panel role-panel--wide">
|
||||
<header><div><span><CalendarOutlined /> TODAY</span><h2>当日课表</h2></div><Button type="link" onClick={() => openLegacy('/my-timetable')}>完整课表 <RightOutlined /></Button></header>
|
||||
{courses.length ? <div className="day-rail">{courses.map((course) => <button key={course.id} onClick={() => openLegacy('/my-timetable')}><time><b>{slot(course.startPeriod, 'startsAt') || `第 ${course.startPeriod} 节`}</b><small>{slot(course.startPeriod + course.periodCount - 1, 'endsAt')}</small></time><i /><div><span>{course.courseCode}</span><strong>{course.courseName}</strong><small><EnvironmentOutlined /> {[course.buildingName, course.classroomName].filter(Boolean).join(' · ') || '地点待定'}{course.teacherNames.length ? ` · ${course.teacherNames.join('、')}` : ''}</small></div></button>)}</div> : <Empty icon={<ReadOutlined />} title="今天没有课程" detail={week ? '可以安排自习或处理待办。' : '当前不在教学周内。'} />}
|
||||
</article>
|
||||
<article className="role-panel">
|
||||
<header><div><span><BellOutlined /> INBOX</span><h2>消息中心</h2></div><Button type="link" onClick={() => openLegacy('/notifications')}>{data.unreadCount ? `${data.unreadCount} 未读` : '全部'} <RightOutlined /></Button></header>
|
||||
{data.notices.length ? <div className="compact-list">{data.notices.slice(0, 4).map((item) => <button className={!item.isRead ? 'is-unread' : ''} key={item.id} onClick={() => openLegacy('/notifications')}><div><span>{noticeLabels[item.category] ?? '系统消息'} · {item.senderName}</span><strong>{item.title}</strong><small>{item.content}</small></div></button>)}</div> : <Empty icon={<BellOutlined />} title="暂无消息" detail="新的教务通知会显示在这里。" />}
|
||||
</article>
|
||||
<article className="role-panel">
|
||||
<header><div><span><CalendarOutlined /> UPCOMING</span><h2>近期考试</h2></div><Button type="link" onClick={() => openLegacy('/exams')}>全部 <RightOutlined /></Button></header>
|
||||
{exams.length ? <div className="compact-list exam-list">{exams.map((item) => <button key={item.id} onClick={() => openLegacy('/exams')}><time>{new Intl.DateTimeFormat('zh-CN', { month: 'numeric', day: 'numeric' }).format(dateOnly(item.examDate)!)}</time><div><span>{item.courseCode} · {time(item.startsAt)}—{time(item.endsAt)}</span><strong>{item.courseName}</strong><small>{[item.buildingName, item.classroomName].filter(Boolean).join(' · ') || '考场待定'}</small></div></button>)}</div> : <Empty icon={<CalendarOutlined />} title="近期没有考试" detail="已发布的考试安排会显示在这里。" />}
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function Empty({ icon, title, detail }: { icon: React.ReactNode; title: string; detail: string }) {
|
||||
return <div className="role-empty">{icon}<strong>{title}</strong><span>{detail}</span></div>
|
||||
}
|
||||
Loaded 3 of 7 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user