Add exam score import and management workflows
This commit is contained in:
1 parent
ae4447841a
commit
bbf9187bfe
26 files changed
+3865
No files matched your search
@@ -0,0 +1,133 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user