134 lines
5.3 KiB
TypeScript
134 lines
5.3 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'
|
|
|
|
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)
|
|
}
|
|
|
|
export default function DashboardPage() {
|
|
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>
|
|
)
|
|
}
|