diff --git a/web-react/MIGRATION_MATRIX.md b/web-react/MIGRATION_MATRIX.md
index e688873..c834186 100644
--- a/web-react/MIGRATION_MATRIX.md
+++ b/web-react/MIGRATION_MATRIX.md
@@ -57,6 +57,8 @@
| `/classroom-repairs` | 教室报修与维修 | SuperAdmin、AcademicAdmin、MaintenanceAdmin、MaintenanceStaff | 工单筛选、分派/开工/完工、详情、设备档案与扫码报修二维码 | verification |
| `/grades/:sheetId/statistics` | 课程成绩统计 | 具备该成绩单数据范围的已登录用户 | 班级/专业/学院/全校分数分布、对比、课程讨论与互动 | verification |
| `/event-analytics` | 运行数据分析 | SuperAdmin、AcademicAdmin、CollegeAdmin、Leader | ClickHouse 考勤、成绩与全校范围访问审计只读聚合 | verification |
+| `/statistics` | 统计报表 | SuperAdmin、AcademicAdmin、CollegeAdmin、Leader | 学生、课程、成绩、通过率、教师工作量和教室利用率;筛选、可视化和 Excel 导出 | verification |
+| `/grade-analytics` | 成绩分析中心 | SuperAdmin、AcademicAdmin、CollegeAdmin、Leader、Teacher | 教学班/课程成绩比较、刷新设置、Word 报告与课程讨论 | verification |
| `/plugins` | 统一插件平台 | SuperAdmin | 运行状态、签名包暂存/激活/回滚、无数据删除的安全停用 | verification |
| `/activate` | 学生账号激活 | 公开 | 学籍选项、身份匹配、强密码和一次性激活 | verification |
| `/verify/:verificationCode?` | 电子凭证验真 | 公开 | 唯一编号、实时状态、签发信息和 PDF 哈希 | verification |
diff --git a/web-react/src/app/App.tsx b/web-react/src/app/App.tsx
index ff0878e..11b76df 100644
--- a/web-react/src/app/App.tsx
+++ b/web-react/src/app/App.tsx
@@ -1,9 +1,10 @@
-import { lazy, Suspense, useEffect } from 'react'
+import { lazy, Suspense, useEffect, type ReactNode } from 'react'
import { App as AntdApp, ConfigProvider, Skeleton } from 'antd'
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'
import { BrowserRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'
import zhCN from 'antd/locale/zh_CN'
import { useAuthStore } from '../stores/auth'
+import http from '../api/http'
const LoginPage = lazy(() => import('../pages/LoginPage'))
const DashboardPage = lazy(() => import('../pages/DashboardPage'))
@@ -57,6 +58,8 @@ const CourseGradeStatisticsPage = lazy(() => import('../pages/CourseGradeStatist
const EventAnalyticsPage = lazy(() => import('../pages/EventAnalyticsPage'))
const PluginsPage = lazy(() => import('../pages/PluginsPage'))
const OperationsPage = lazy(() => import('../pages/OperationsPage'))
+const StatisticsPage = lazy(() => import('../pages/StatisticsPage'))
+const GradeAnalyticsPage = lazy(() => import('../pages/GradeAnalyticsPage'))
const AccountActivationPage = lazy(() => import('../pages/AccountActivationPage'))
const OfficialDocumentVerifyPage = lazy(() => import('../pages/OfficialDocumentVerifyPage'))
const ClassroomRepairReportPage = lazy(() => import('../pages/ClassroomRepairReportPage'))
@@ -92,6 +95,13 @@ function SessionExpiryBridge() {
return null
}
+function PluginRoute({ pluginId, children }: { pluginId: string; children: ReactNode }) {
+ const runtime = useQuery({ queryKey: ['plugin-runtime'], queryFn: async () => (await http.get<{ id: string; isEnabled: boolean }[]>('/plugins/runtime')).data })
+ if (runtime.isLoading) return
+ if (runtime.data?.some((item) => item.id === pluginId && !item.isEnabled)) return
+ return <>{children}>
+}
+
function AppRoutes() {
return (
}>
@@ -162,6 +172,8 @@ function AppRoutes() {
} />
} />
} />
+ } />
+ } />
} />
} />
diff --git a/web-react/src/layout/AppShell.tsx b/web-react/src/layout/AppShell.tsx
index 1c5a4f0..2611f38 100644
--- a/web-react/src/layout/AppShell.tsx
+++ b/web-react/src/layout/AppShell.tsx
@@ -37,6 +37,7 @@ import {
CarryOutOutlined,
SettingOutlined,
ToolOutlined,
+ BarChartOutlined,
} from '@ant-design/icons'
import { Avatar, Badge, Button, Layout, Menu, Tag } from 'antd'
import { useQuery } from '@tanstack/react-query'
@@ -63,6 +64,8 @@ export default function AppShell() {
queryFn: async () => (await http.get<{ count: number }>('/notifications/unread-count')).data.count,
refetchInterval: 60_000,
})
+ const pluginRuntime = useQuery({ queryKey: ['plugin-runtime'], queryFn: async () => (await http.get<{ id: string; isEnabled: boolean }[]>('/plugins/runtime')).data })
+ const statisticsEnabled = pluginRuntime.data?.find((item) => item.id === 'mingxu.statistics')?.isEnabled !== false
useEffect(() => { void refreshUser().catch(() => undefined) }, [refreshUser])
@@ -122,6 +125,12 @@ export default function AppShell() {
{ key: '/course-enrollments', icon: , label: '修读课程管理' },
{ key: '/campus-wallets', icon: , label: '学生余额管理' },
] : []),
+ ...(statisticsEnabled && roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader'].includes(role)) ? [
+ { key: '/statistics', icon: , label: '统计报表' },
+ ] : []),
+ ...(statisticsEnabled && roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader', 'Teacher'].includes(role)) ? [
+ { key: '/grade-analytics', icon: , label: roles.includes('Teacher') && !roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader'].includes(role)) ? '教学班成绩分析' : '成绩分析中心' },
+ ] : []),
...(roles.some((role) => ['SuperAdmin', 'AcademicAdmin'].includes(role)) ? [
{ key: '/venue-displays', icon: , label: '场地信息展牌' },
{ key: '/base-data/organization', icon: , label: '组织机构' },
diff --git a/web-react/src/pages/GradeAnalyticsPage.tsx b/web-react/src/pages/GradeAnalyticsPage.tsx
new file mode 100644
index 0000000..e191d5d
--- /dev/null
+++ b/web-react/src/pages/GradeAnalyticsPage.tsx
@@ -0,0 +1,49 @@
+import { useEffect, useMemo, useState } from 'react'
+import { Alert, App, Button, Card, Checkbox, Descriptions, Empty, Input, InputNumber, Modal, Pagination, Radio, Select, Skeleton, Switch, Table, Tag } from 'antd'
+import { DownloadOutlined, ReloadOutlined, SettingOutlined } from '@ant-design/icons'
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import http, { apiErrorMessage } from '../api/http'
+import { downloadApiFile } from '../api/files'
+import PageHeader from '../components/PageHeader'
+import { useAuthStore } from '../stores/auth'
+import './grade-analytics.css'
+
+interface Row { [key: string]: any }
+interface Page { items: T[]; total: number; page: number; pageSize: number }
+const n = (value: unknown, digits = 1) => Number.isFinite(Number(value)) ? Number(value).toFixed(digits) : '—'
+const p = (value: unknown) => `${n(value)}%`
+const when = (value?: string) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
+const termLabel = (item: Row) => [item.academicYear, item.name].filter(Boolean).join(' ') || item.name
+
+export default function GradeAnalyticsPage() {
+ const { message, modal } = App.useApp(); const client = useQueryClient(); const roles = useAuthStore((state) => state.user?.roles ?? [])
+ const canOverall = roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader'].includes(role)); const canSchedule = roles.some((role) => ['SuperAdmin', 'AcademicAdmin'].includes(role))
+ const [mode, setMode] = useState<'classes' | 'courses'>('classes'); const [termId, setTermId] = useState(); const [collegeId, setCollegeId] = useState(); const [keyword, setKeyword] = useState(''); const [teacher, setTeacher] = useState(''); const [riskOnly, setRiskOnly] = useState(false); const [page, setPage] = useState(1); const [selected, setSelected] = useState(); const [scheduleOpen, setScheduleOpen] = useState(false)
+ const references = useQuery({ queryKey: ['grade-analytics-references'], queryFn: async () => { const [terms, colleges] = await Promise.all([http.get('/base-data/terms'), http.get('/base-data/colleges')]); return { terms: terms.data, colleges: colleges.data } } })
+ useEffect(() => { if (!termId && references.data?.terms.length) setTermId(String(references.data.terms.find((item) => item.isCurrent)?.id ?? references.data.terms[0].id)) }, [references.data, termId])
+ useEffect(() => { setPage(1); setSelected(undefined) }, [mode, termId, collegeId, keyword, teacher, riskOnly])
+ const listing = useQuery({ queryKey: ['grade-analytics-list', mode, termId, collegeId, keyword, teacher, riskOnly, page], queryFn: async () => (await http.get>(mode === 'classes' ? '/grade-analytics/teaching-classes' : '/grade-analytics/courses', { params: { academicTermId: termId, collegeId, keyword: keyword || undefined, teacherKeyword: mode === 'classes' ? teacher || undefined : undefined, riskOnly: riskOnly || undefined, page, pageSize: 12 } })).data })
+ useEffect(() => { if (listing.data?.items.length) setSelected((current) => listing.data!.items.find((item) => (mode === 'classes' ? item.gradeSheetId : item.courseId) === (mode === 'classes' ? current?.gradeSheetId : current?.courseId)) ?? listing.data!.items[0]) }, [listing.data, mode])
+ const selectedId = mode === 'classes' ? selected?.gradeSheetId : selected?.courseId
+ const report = useQuery({ queryKey: ['grade-analytics-report', mode, selectedId, termId], enabled: !!selectedId, queryFn: async () => (await http.get(mode === 'classes' ? `/grade-analytics/teaching-classes/${selectedId}` : `/grade-analytics/courses/${selectedId}`, { params: mode === 'courses' ? { academicTermId: termId } : undefined })).data })
+ const refresh = useMutation({ mutationFn: () => http.post(`/grade-analytics/teaching-classes/${selectedId}/refresh`), onSuccess: () => { message.success('已提交统计刷新任务。'); void report.refetch(); void listing.refetch() }, onError: (error) => message.error(apiErrorMessage(error)) })
+ const exportReport = () => { if (!selectedId) return; const path = mode === 'classes' ? `/grade-analytics/teaching-classes/${selectedId}/report.docx` : `/grade-analytics/courses/${selectedId}/report.docx`; const name = `${selected?.courseCode ?? report.data?.summary?.courseCode ?? '成绩'}-${mode === 'classes' ? '教学班成绩分析' : '科目总体成绩分析'}.docx`; void downloadApiFile(path, name, mode === 'courses' ? { params: { academicTermId: termId } } : undefined).catch((error) => message.error(apiErrorMessage(error))) }
+ const courseId = mode === 'courses' ? String(selectedId ?? '') : ''; const courseName = mode === 'courses' ? String(selected?.courseName ?? '') : ''
+ if (references.isLoading) return
+ if (references.isError) return
+ const referenceData = references.data!
+ return {canSchedule && } onClick={() => setScheduleOpen(true)}>定时刷新设置}{mode === 'classes' && } disabled={!selectedId} loading={refresh.isPending} onClick={() => refresh.mutate()}>重新计算}} />
+ {canOverall && setMode(event.target.value)}>教学班分析科目总体分析}
+
+ {report.isLoading ? : report.isError ? : !selected ? : mode === 'classes' ? : }
+ {!!courseId && }{canSchedule && setScheduleOpen(false)} onSaved={() => client.invalidateQueries({ queryKey: ['grade-analytics-list'] })} />}
+
+}
+
+function ClassReport({ report, onExport }: { report: Row; onExport: () => void }) { const summary = report.summary as Row | undefined; if (report.isRefreshing) return ; if (!summary) return ; return <>> }
+function CourseReport({ report, onExport }: { report: Row; onExport: () => void }) { const summary = report.summary as Row | undefined; if (!summary) return ; return <>> }
+function ReportHead({ code, title, description, students, onExport }: { code: string; title: string; description: string; students: unknown; onExport: () => void }) { return }
+function Metrics({ values }: { values: [string, unknown][] }) { return {values.map(([label, value]) => {label}{String(value ?? '—')})} }
+function DataCard({ title, rows, columns }: { title: string; rows: Row[]; columns: string[] }) { const names: Record = { label: '分数段', studentCount: '人数', taskNumber: '教学班', teacherNames: '任课教师', averageScore: '平均分', passRate: '合格率', excellentRate: '优秀率', scope: '范围', name: '名称', termName: '学期', courseStudentCount: '课程学生数', courseAverageScore: '课程平均分', coursePassRate: '课程合格率', courseExcellentRate: '课程优秀率', collegeName: '学院', medianScore: '中位数' }; return size="small" rowKey={(_, index) => String(index)} dataSource={rows} pagination={rows.length > 8 ? { pageSize: 8, showSizeChanger: false } : false} locale={{ emptyText: '暂无数据' }} scroll={{ x: 540 }} columns={columns.map((key) => ({ title: names[key] ?? key, dataIndex: key, render: (value) => key.toLowerCase().includes('rate') ? {p(value)} : Array.isArray(value) ? value.join('、') : value ?? '—' }))} /> }
+function Discussion({ courseId, courseName }: { courseId: string; courseName: string }) { const { message, modal } = App.useApp(); const client = useQueryClient(); const [content, setContent] = useState(''); const [anonymous, setAnonymous] = useState(false); const [before, setBefore] = useState(); const query = useQuery({ queryKey: ['grade-comments', courseId, before?.createdAt, before?.id], queryFn: async () => (await http.get(`/grade-analytics/courses/${courseId}/comments`, { params: before ? { beforeCreatedAt: before.createdAt, beforeId: before.id } : undefined })).data }); const items = (query.data?.items ?? []) as Row[]; const create = useMutation({ mutationFn: () => http.post(`/grade-analytics/courses/${courseId}/comments`, { content, isAnonymous: anonymous }), onSuccess: () => { setContent(''); client.invalidateQueries({ queryKey: ['grade-comments', courseId] }); message.success('评论已发布。') }, onError: (error) => message.error(apiErrorMessage(error)) }); const react = (id: string, type: string) => void http.post(`/grade-analytics/courses/${courseId}/comments/${id}/reactions/${type}`).then(() => client.invalidateQueries({ queryKey: ['grade-comments', courseId] })).catch((error) => message.error(apiErrorMessage(error))); const remove = (id: string) => modal.confirm({ title: '删除评论', content: '删除后不能恢复。', okButtonProps: { danger: true }, onOk: async () => { await http.delete(`/grade-analytics/comments/${id}`); await client.invalidateQueries({ queryKey: ['grade-comments', courseId] }) } }); return DISCUSSION{courseName} · 课程讨论
匿名评论会隐藏姓名,作者和管理员仍可按权限处理。
} onClick={() => void query.refetch()}>刷新 setContent(event.target.value)} placeholder="分享对成绩趋势、教学改进或统计口径的看法" /> setAnonymous(event.target.checked)}>匿名发布
{query.isLoading ? : <>{items.length ? {items.map((item) => - {item.authorName || '未命名用户'}{item.isAnonymous && 匿名}
{item.content}
{item.canDelete && }
)}
: }{query.data?.hasMore && }>} }
+function Schedule({ open, onClose, onSaved }: { open: boolean; onClose: () => void; onSaved: () => void }) { const { message } = App.useApp(); const [value, setValue] = useState({ isEnabled: true, intervalMinutes: 5, batchSize: 100 }); const settings = useQuery({ queryKey: ['grade-refresh-schedule'], enabled: open, queryFn: async () => (await http.get('/grade-analytics/refresh-schedule')).data }); useEffect(() => { if (settings.data) setValue({ isEnabled: settings.data.isEnabled, intervalMinutes: Math.max(1, Math.round(Number(settings.data.intervalSeconds) / 60)), batchSize: settings.data.batchSize, lastRunAt: settings.data.lastRunAt, nextRunAt: settings.data.nextRunAt }) }, [settings.data]); const save = useMutation({ mutationFn: () => http.put('/grade-analytics/refresh-schedule', { isEnabled: value.isEnabled, intervalSeconds: Number(value.intervalMinutes) * 60, batchSize: value.batchSize }), onSuccess: () => { message.success('定时刷新设置已保存。'); onSaved(); onClose() }, onError: (error) => message.error(apiErrorMessage(error)) }); return save.mutate()}> setValue({ ...value, isEnabled })} /> setValue({ ...value, intervalMinutes: intervalMinutes ?? 1 })} /> 分钟 setValue({ ...value, batchSize: batchSize ?? 1 })} /> 个课程学期{when(value.lastRunAt)} }
diff --git a/web-react/src/pages/StatisticsPage.tsx b/web-react/src/pages/StatisticsPage.tsx
new file mode 100644
index 0000000..bdc7e9e
--- /dev/null
+++ b/web-react/src/pages/StatisticsPage.tsx
@@ -0,0 +1,76 @@
+import { useEffect, useMemo, useState } from 'react'
+import { DownloadOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'
+import { Alert, Button, Card, Empty, InputNumber, Select, Skeleton, Table, Tabs, Tag } from 'antd'
+import { useQuery } from '@tanstack/react-query'
+import http, { apiErrorMessage } from '../api/http'
+import { downloadApiFile } from '../api/files'
+import PageHeader from '../components/PageHeader'
+import './statistics.css'
+
+type Row = Record
+type Filters = Record
+const tabs = [
+ ['students', '学生统计'], ['courses', '课程统计'], ['grades', '成绩统计'],
+ ['pass-rates', '通过率统计'], ['teacher-workload', '教师工作量'], ['classroom-utilization', '教室利用率'],
+] as const
+const chineseNature: Record = { GeneralRequired: '通识必修', MajorRequired: '专业必修', MajorElective: '专业选修', GeneralElective: '通识选修', Practice: '实践环节' }
+const number = (value: unknown, digits = 1) => Number.isFinite(Number(value)) ? Number(value).toFixed(digits) : '—'
+const percent = (value: unknown) => `${(Number(value ?? 0) * 100).toFixed(1)}%`
+const compact = (value: unknown) => typeof value === 'number' ? value.toLocaleString() : String(value ?? '—')
+const termLabel = (term: Row) => [term.academicYear, term.name].filter(Boolean).join(' ') || String(term.name ?? '')
+
+function Chart({ title, rows, label, value, percentValue = false }: { title: string; rows: Row[]; label: (row: Row) => string; value: (row: Row) => number; percentValue?: boolean }) {
+ const maximum = Math.max(...rows.map(value), 1)
+ return
+ {!rows.length ? : {rows.slice(0, 12).map((row, index) => { const raw = value(row); return
{label(row)}
{percentValue ? `${number(raw)}%` : compact(raw)} })}
}
+
+}
+
+export default function StatisticsPage() {
+ const [active, setActive] = useState<(typeof tabs)[number][0]>('students')
+ const [termId, setTermId] = useState()
+ const [filters, setFilters] = useState>({ students: {}, courses: {}, grades: {}, 'pass-rates': {}, 'teacher-workload': {}, 'classroom-utilization': {} })
+ const refs = useQuery({ queryKey: ['statistics-references'], queryFn: async () => {
+ const [terms, colleges, categories, buildings, campuses, majors, classes] = await Promise.all([
+ http.get('/base-data/terms'), http.get('/base-data/colleges'), http.get('/base-data/course-categories'),
+ http.get('/base-data/buildings').catch(() => ({ data: [] })), http.get('/base-data/campuses').catch(() => ({ data: [] })),
+ http.get('/base-data/majors').catch(() => ({ data: [] })), http.get('/base-data/classes').catch(() => ({ data: [] })),
+ ])
+ return { terms: terms.data, colleges: colleges.data, categories: categories.data, buildings: buildings.data, campuses: campuses.data, majors: majors.data, classes: classes.data }
+ } })
+ useEffect(() => { if (!termId && refs.data?.terms.length) setTermId(String(refs.data.terms.find((item) => item.isCurrent)?.id ?? refs.data.terms[0].id)) }, [refs.data, termId])
+ const params = useMemo(() => ({ ...filters[active], ...(active === 'students' || active === 'grades' || active === 'pass-rates' || active === 'teacher-workload' || active === 'classroom-utilization' ? { academicTermId: termId } : {}) }), [active, filters, termId])
+ const stats = useQuery({ queryKey: ['statistics', active, params], queryFn: async () => (await http.get(`/statistics/${active}/summary`, { params })).data })
+ const current = filters[active]
+ const update = (name: string, value: string | number | undefined) => setFilters((all) => ({ ...all, [active]: { ...all[active], [name]: value } }))
+ const reference = refs.data
+ const majors = (reference?.majors ?? []).filter((item) => !current.collegeId || item.collegeId === current.collegeId)
+ const classes = (reference?.classes ?? []).filter((item) => !current.majorId || item.majorId === current.majorId)
+ const exportFile = () => void downloadApiFile(`/statistics/${active}/export`, `${tabs.find((item) => item[0] === active)?.[1] ?? active}.xlsx`, { params }).catch(() => undefined)
+ if (refs.isLoading) return
+ if (refs.isError) return
+ return ({ value: String(item.id), label: termLabel(item) }))} onChange={setTermId} />} />
+ setActive(key as typeof active)} items={tabs.map(([key, label]) => ({ key, label }))} />
+ } onClick={() => void stats.refetch()}>查询} onClick={exportFile}>导出 Excel} onClick={() => void stats.refetch()}>刷新
+ {stats.isLoading ? : stats.isError ? : }
+
+}
+
+function Filters({ active, current, update, colleges, categories, buildings, campuses, majors, classes }: { active: string; current: Filters; update: (name: string, value: string | number | undefined) => void; colleges: Row[]; categories: Row[]; buildings: Row[]; campuses: Row[]; majors: Row[]; classes: Row[] }) {
+ const select = (name: string, placeholder: string, rows: Row[]) =>