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.
150 lines
6.0 KiB
TypeScript
150 lines
6.0 KiB
TypeScript
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: {
|
|
level: string
|
|
title: string
|
|
scopeName: string
|
|
description: string
|
|
}
|
|
currentTerm?: {
|
|
id: string
|
|
name: string
|
|
startDate: string
|
|
endDate: string
|
|
}
|
|
counts: {
|
|
students: number
|
|
teachers: number
|
|
courses: number
|
|
teachingTasks: number
|
|
publishedTeachingTasks: number
|
|
scheduledTeachingTasks: number
|
|
courseEnrollments: number
|
|
gradeSheets: number
|
|
publishedGradeSheets: number
|
|
submittedGradeSheets: number
|
|
openCourseSelectionRounds: number
|
|
}
|
|
pending: Record<string, number>
|
|
greeting: {
|
|
title: string
|
|
subtitle: string
|
|
narrative: string
|
|
}
|
|
}
|
|
|
|
const number = new Intl.NumberFormat('zh-CN')
|
|
|
|
function percent(value: number, total: number) {
|
|
return total > 0 ? Math.min(100, Math.round(value / total * 100)) : 0
|
|
}
|
|
|
|
function formatDate(value?: string) {
|
|
if (!value) return '待设置'
|
|
const date = new Date(`${value.slice(0, 10)}T00:00:00`)
|
|
return new Intl.DateTimeFormat('zh-CN', { month: 'long', day: 'numeric' }).format(date)
|
|
}
|
|
|
|
function AdminDashboardPage() {
|
|
const query = useQuery({
|
|
queryKey: ['dashboard'],
|
|
queryFn: async () => (await http.get<DashboardData>('/dashboard')).data,
|
|
})
|
|
|
|
if (query.isLoading) return <div className="page-container"><Skeleton active paragraph={{ rows: 10 }} /></div>
|
|
if (query.isError || !query.data) {
|
|
return (
|
|
<div className="page-container">
|
|
<Alert
|
|
type="error"
|
|
showIcon
|
|
message="教务总览暂时无法加载"
|
|
description="请确认后端服务已经启动,然后重新加载。"
|
|
action={<Button icon={<ReloadOutlined />} onClick={() => void query.refetch()}>重新加载</Button>}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const data = query.data
|
|
const metrics = [
|
|
{ label: '在籍学生', value: data.counts.students, hint: `${data.audience.scopeName}当前有效学籍` },
|
|
{ label: '教师档案', value: data.counts.teachers, hint: '当前数据范围内的教师' },
|
|
{ label: '本学期教学班', value: data.counts.teachingTasks, hint: `${data.counts.publishedTeachingTasks} 个已发布` },
|
|
{ label: '有效选课', value: data.counts.courseEnrollments, hint: `${data.counts.openCourseSelectionRounds} 个批次开放中` },
|
|
]
|
|
const readiness = [
|
|
{ label: '任务发布', value: data.counts.publishedTeachingTasks, total: data.counts.teachingTasks },
|
|
{ label: '课表覆盖', value: data.counts.scheduledTeachingTasks, total: data.counts.publishedTeachingTasks },
|
|
{ label: '成绩发布', value: data.counts.publishedGradeSheets, total: data.counts.gradeSheets },
|
|
]
|
|
const pending = Object.entries(data.pending).filter(([, value]) => value > 0)
|
|
|
|
return (
|
|
<main className="page-container">
|
|
<div className="migration-banner"><b>React 迁移预览</b><span>当前页面直接读取现有 `/api/dashboard`,业务数据未使用模拟值。</span></div>
|
|
<header className="page-heading">
|
|
<div>
|
|
<span className="page-heading__kicker">{data.audience.title}</span>
|
|
<h1>{data.greeting.title}</h1>
|
|
<p>{data.greeting.narrative || data.audience.description}</p>
|
|
</div>
|
|
<div className="term-status">
|
|
<span>ACADEMIC TERM</span>
|
|
<strong>{data.currentTerm?.name ?? '尚未设置当前学期'} · {formatDate(data.currentTerm?.startDate)}—{formatDate(data.currentTerm?.endDate)}</strong>
|
|
</div>
|
|
</header>
|
|
<section className="metric-grid" aria-label="教务指标">
|
|
{metrics.map((metric) => (
|
|
<article className="metric-card" key={metric.label}>
|
|
<span>{metric.label}</span><strong>{number.format(metric.value)}</strong><small>{metric.hint}</small>
|
|
</article>
|
|
))}
|
|
</section>
|
|
<section className="dashboard-grid">
|
|
<article className="content-panel">
|
|
<header className="content-panel__header"><h2>教学运行准备度</h2><Button type="link" icon={<ArrowRightOutlined />}>查看明细</Button></header>
|
|
<div className="content-panel__body readiness-list">
|
|
{readiness.map((item) => {
|
|
const value = percent(item.value, item.total)
|
|
return (
|
|
<div className="readiness-row" key={item.label}>
|
|
<span>{item.label}</span><Progress percent={value} strokeColor="#087f73" trailColor="#e9edf2" showInfo={false} /><strong>{value}%</strong>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</article>
|
|
<article className="content-panel">
|
|
<header className="content-panel__header"><h2>等待处理</h2><span>{pending.reduce((sum, [, value]) => sum + value, 0)} 项</span></header>
|
|
<div className="content-panel__body pending-list">
|
|
{pending.length === 0 ? <Alert type="success" showIcon message="当前没有待处理事项" /> : pending.slice(0, 6).map(([key, value]) => (
|
|
<div className="pending-item" key={key}><span>{key}</span><strong>{value}</strong></div>
|
|
))}
|
|
</div>
|
|
</article>
|
|
</section>
|
|
</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 />
|
|
}
|