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 && }{mode === 'classes' && }} /> + {canOverall && setMode(event.target.value)}>教学班分析科目总体分析} +
({ value: item.id, label: item.name }))} onChange={setCollegeId} /> setKeyword(event.target.value)} onPressEnter={() => void listing.refetch()} />{mode === 'classes' && setTeacher(event.target.value)} onPressEnter={() => void listing.refetch()} />} setRiskOnly(event.target.checked)}>仅看预警{mode === 'classes' ? '班' : '科目'}
+
{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
{code}

{title}

{description}

{String(students ?? '—')}有效成绩
} +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} · 课程讨论

匿名评论会隐藏姓名,作者和管理员仍可按权限处理。

setContent(event.target.value)} placeholder="分享对成绩趋势、教学改进或统计口径的看法" />
setAnonymous(event.target.checked)}>匿名发布
{query.isLoading ? : <>{items.length ?
    {items.map((item) =>
  1. {item.authorName || '未命名用户'}{item.isAnonymous && 匿名}

    {item.content}

    {item.canDelete && }
  2. )}
: }{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 }))} /> +
+ {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[]) => ({ value, label }))} onChange={(value) => update('nature', value)} /> + if (active === 'classroom-utilization') return <>{select('campusId', '校区', campuses)}{select('buildingId', '教学楼', buildings)} + return select('collegeId', '学院', colleges) +} + +function Report({ type, data }: { type: string; data: Row }) { + if (type === 'students') return <>
String(x.collegeName)} value={(x) => Number(x.count)} /> `${x.year} 年`} value={(x) => Number(x.count)} />
+ if (type === 'courses') return
String(x.collegeName)} value={(x) => Number(x.count)} /> String(x.natureLabel ?? chineseNature[String(x.nature)] ?? x.nature)} value={(x) => Number(x.count)} />
+ if (type === 'grades') { const overall = (data.overall ?? {}) as Row; return <>
String(x.label)} value={(x) => Number(x.count)} /> String(x.collegeName)} value={(x) => Number(x.passRate) * 100} percentValue />
} + if (type === 'pass-rates') return <>
String(x.collegeName)} value={(x) => Number(x.passRate) * 100} percentValue /> String(x.courseName)} value={(x) => Number(x.passRate) * 100} percentValue />
+ if (type === 'teacher-workload') { const totals = (data.totals ?? {}) as Row; return <>
String(x.teacherName)} value={(x) => Number(x.totalHours)} /> String(x.collegeName)} value={(x) => Number(x.avgHoursPerTeacher)} />
} + const totals = (data.totals ?? {}) as Row; return <>
String(x.buildingName)} value={(x) => Number(x.utilizationRate) * 100} percentValue /> `第 ${x.period} 节`} value={(x) => Number(x.utilizationRate) * 100} percentValue />
+} +function Metrics({ items }: { items: [string, unknown][] }) { return
{items.map(([label, value]) =>
{label}{compact(value)}
)}
} +function Detail({ title, rows, columns }: { title: string; rows: Row[]; columns: string[] }) { return size="small" rowKey={(row, index) => String(row.id ?? `${index}`)} dataSource={rows} pagination={{ pageSize: 10, showSizeChanger: false }} locale={{ emptyText: '暂无明细数据' }} columns={columns.map((key) => ({ title: ({ collegeName: '学院', courseName: '课程名称', courseCode: '课程代码', count: '数量', passRate: '通过率', averageScore: '平均分', totalRecords: '成绩条数', total: '学生数', teacherName: '姓名', teacherNumber: '工号', title: '职称', totalHours: '总学时', courseCount: '课程数', taskCount: '教学班数', buildingName: '教学楼', totalClassrooms: '教室数', utilizationRate: '利用率', totalUsedPeriods: '已用节次', totalAvailablePeriods: '可用节次' } as Record)[key] ?? key, dataIndex: key, render: (value: unknown) => key.includes('Rate') || key === 'utilizationRate' ? {percent(value)} : compact(value) }))} /> } diff --git a/web-react/src/pages/grade-analytics.css b/web-react/src/pages/grade-analytics.css new file mode 100644 index 0000000..9635306 --- /dev/null +++ b/web-react/src/pages/grade-analytics.css @@ -0,0 +1,54 @@ +.grade-analytics { gap: 16px; } +.grade-actions, .grade-filters, .discussion-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } +.grade-filters { padding: 14px; border: 1px solid var(--line); background: #fff; } +.grade-filters .ant-select { min-width: 180px; } +.grade-filters .ant-input { width: 260px; } +.grade-layout { display: grid; grid-template-columns: 285px minmax(0, 1fr); gap: 16px; align-items: start; } +.grade-rail { position: sticky; top: 16px; overflow: auto; max-height: calc(100vh - 34px); border: 1px solid var(--line); background: #f6f8fb; } +.grade-rail > header { display: flex; justify-content: space-between; gap: 8px; padding: 14px; background: #eef2f7; color: var(--brand-deep); } +.grade-rail > header small, .grade-rail button small { color: var(--ink-muted); } +.grade-rail > button { display: block; width: 100%; padding: 13px 15px; border: 0; border-bottom: 1px solid var(--line); background: transparent; text-align: left; cursor: pointer; } +.grade-rail > button:hover, .grade-rail > button.active { background: #fff; box-shadow: inset 4px 0 var(--brand); } +.grade-rail button > * { display: block; } +.grade-rail code { color: var(--ink-muted); font-size: 11px; } +.grade-rail strong { margin: 5px 0; color: var(--brand-deep); } +.grade-rail span { margin-top: 8px; color: var(--teal); font-size: 12px; } +.grade-rail .ant-pagination { justify-content: center; padding: 12px; } +.grade-report { min-width: 0; } +.grade-report-head { display: flex; justify-content: space-between; gap: 20px; padding: 22px; background: var(--brand-deep); color: #fff; } +.grade-report-head code { color: #bfd0e6; } +.grade-report-head h2 { margin: 8px 0; font-size: 24px; } +.grade-report-head p { margin: 0; color: #d5e0ed; } +.grade-report-head > div:last-child { display: flex; align-items: center; gap: 18px; } +.grade-report-head strong { display: grid; gap: 4px; text-align: center; font: 700 30px/1 var(--font-data); } +.grade-report-head strong small { font: 400 11px/1 var(--font-body); color: #cbd8e7; } +.grade-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); border: 1px solid var(--line); background: #fff; } +.grade-metrics article { padding: 17px; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); } +.grade-metrics span { display: block; color: var(--ink-muted); font-size: 12px; } +.grade-metrics strong { display: block; margin-top: 7px; color: var(--brand-deep); font: 700 24px/1 var(--font-data); } +.grade-cards { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; margin-top: 14px; } +.grade-data-card:last-child:nth-child(odd) { grid-column: 1 / -1; } +.grade-discussion { padding: 20px; border: 1px solid var(--line); background: #fff; } +.grade-discussion > header { display: flex; justify-content: space-between; gap: 16px; margin-bottom: 14px; } +.grade-discussion header span { color: var(--ink-muted); font-size: 11px; } +.grade-discussion h2 { margin: 5px 0; color: var(--brand-deep); font-size: 18px; } +.grade-discussion header p { margin: 0; color: var(--ink-muted); font-size: 12px; } +.discussion-actions { justify-content: space-between; margin-top: 10px; } +.grade-discussion ol { margin: 16px 0; padding: 0; list-style: none; border-top: 1px solid var(--line); } +.grade-discussion li { padding: 13px 0; border-bottom: 1px solid var(--line); } +.grade-discussion li header { display: flex; gap: 8px; align-items: center; } +.grade-discussion time { margin-left: auto; color: var(--ink-muted); font-size: 12px; } +.grade-discussion li p { white-space: pre-wrap; } +@media (max-width: 900px) { + .grade-layout { grid-template-columns: 1fr; } + .grade-rail { position: static; max-height: 340px; } + .grade-cards { grid-template-columns: 1fr; } + .grade-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} +@media (max-width: 620px) { + .grade-filters > * { flex: 1 1 100%; } + .grade-filters .ant-input { width: auto; } + .grade-report-head { display: grid; } + .grade-report-head > div:last-child { justify-content: space-between; } + .grade-metrics { grid-template-columns: 1fr; } +} diff --git a/web-react/src/pages/statistics.css b/web-react/src/pages/statistics.css new file mode 100644 index 0000000..92028cb --- /dev/null +++ b/web-react/src/pages/statistics.css @@ -0,0 +1 @@ +.statistics-page{gap:16px}.statistics-term{min-width:220px}.statistics-filter{display:flex;flex-wrap:wrap;gap:8px;padding:14px;border:1px solid var(--line);background:#fff}.statistics-filter .ant-select{min-width:150px}.statistics-metrics{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.statistics-metrics article{padding:18px;border:1px solid var(--line);background:#fff}.statistics-metrics span{display:block;color:var(--ink-muted);font-size:13px}.statistics-metrics strong{display:block;margin-top:6px;color:var(--brand-deep);font:700 28px/1 var(--font-data)}.statistics-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.statistics-detail{grid-column:1/-1}.statistics-chart .ant-card-head-title{color:var(--brand-deep)}.statistics-bars{display:grid;gap:10px}.statistics-bar{display:grid;grid-template-columns:minmax(80px,1fr) 2fr 62px;align-items:center;gap:8px;font-size:12px}.statistics-bar>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--ink-muted)}.statistics-bar>div{height:9px;overflow:hidden;background:#e8edf3;border-radius:8px}.statistics-bar i{display:block;height:100%;background:linear-gradient(90deg,var(--teal),#4f7bb7);border-radius:inherit}.statistics-bar b{text-align:right;color:var(--brand-deep);font-family:var(--font-data)}@media(max-width:720px){.statistics-grid,.statistics-metrics{grid-template-columns:1fr}.statistics-filter>*{flex:1 1 calc(50% - 8px)}.statistics-term{width:100%}}