From b0943bb5f8948df343db6ee308c5f7ccac4d30ed Mon Sep 17 00:00:00 2001 From: biss Date: Fri, 2 Oct 2026 18:45:31 +0800 Subject: [PATCH] feat(web): migrate timetable query workflows --- web-react/MIGRATION_MATRIX.md | 1 + web-react/README.md | 1 + web-react/src/api/files.ts | 19 +++++ web-react/src/app/App.tsx | 4 + web-react/src/layout/AppShell.tsx | 3 + web-react/src/pages/LoginPage.tsx | 4 +- web-react/src/pages/PersonnelRegistryPage.tsx | 5 +- web-react/src/pages/TimetableQueryPage.tsx | 81 +++++++++++++++++++ web-react/src/pages/timetable-query.css | 1 + 9 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 web-react/src/pages/TimetableQueryPage.tsx create mode 100644 web-react/src/pages/timetable-query.css diff --git a/web-react/MIGRATION_MATRIX.md b/web-react/MIGRATION_MATRIX.md index 5716953..9c68a57 100644 --- a/web-react/MIGRATION_MATRIX.md +++ b/web-react/MIGRATION_MATRIX.md @@ -50,6 +50,7 @@ | `/teachers`、`/students` | 教师/学生档案 | 教务、学院、辅导员;管理员可写 | 服务端分页、数据范围、完整档案、Excel、账号激活与归档审计 | verification | | `/teaching-tasks` | 教学任务 | SuperAdmin、AcademicAdmin、CollegeAdmin | 游标分页、职责范围、草稿编制、授课资格、状态流转、批量操作与公共课合班生成 | verification | | `/schedules` | 排课管理 | SuperAdmin、AcademicAdmin | 版本草稿、手工排课、冲突校验、后台自动排课、异步发布、作息、优化策略与课程约束 | verification | +| `/timetable`、`/class-timetable`、`/teacher-timetable/:teacherId` | 公开/管理/教师课表 | 公开;教务管理与领导可查草稿 | 班级/教师/教室资源、版本切换、周/日/总览、考试实验融合、单项与批量 Excel/PDF | verification | | `/activate` | 学生账号激活 | 公开 | 学籍选项、身份匹配、强密码和一次性激活 | verification | | `/verify/:verificationCode?` | 电子凭证验真 | 公开 | 唯一编号、实时状态、签发信息和 PDF 哈希 | verification | | `/classroom-repairs/report/:classroomId` | 教室扫码报修 | 公开 | 教室设备、故障类别、联系方式和工单提交 | verification | diff --git a/web-react/README.md b/web-react/README.md index 25a67cc..d6c4b09 100644 --- a/web-react/README.md +++ b/web-react/README.md @@ -49,6 +49,7 @@ npm.cmd run build - 迁移教师与学生档案名册,覆盖分页筛选、完整个人资料、Excel、教师账号激活和人员归档审计。 - 迁移教学任务管理,覆盖权限职责范围、草稿编制、授课资格、发布/归档流转、批量操作与公共课合班生成。 - 迁移排课管理,覆盖版本修订、手工排课、后台自动排课和发布、作息、优化参数及课程约束。 +- 迁移公开、管理和指定教师课表,支持班级/教师/教室查询、版本切换、考试实验融合与批量导出。 ## 迁移纪律 diff --git a/web-react/src/api/files.ts b/web-react/src/api/files.ts index 5e80e19..4daeec3 100644 --- a/web-react/src/api/files.ts +++ b/web-react/src/api/files.ts @@ -28,6 +28,25 @@ export async function downloadApiFile( } } +export async function downloadApiPostFile( + path: string, + body: unknown, + fallbackName: string, +) { + const response = await http.post(path, body, { responseType: 'blob', timeout: 60000 }) + const url = URL.createObjectURL(response.data) + try { + const anchor = document.createElement('a') + anchor.href = url + anchor.download = responseFileName(response.headers['content-disposition'], fallbackName) + document.body.appendChild(anchor) + anchor.click() + anchor.remove() + } finally { + URL.revokeObjectURL(url) + } +} + export async function importExcel(path: string, file: File) { const form = new FormData() form.append('file', file) diff --git a/web-react/src/app/App.tsx b/web-react/src/app/App.tsx index 19c6a4b..b77017f 100644 --- a/web-react/src/app/App.tsx +++ b/web-react/src/app/App.tsx @@ -48,6 +48,7 @@ const CurriculumManagementPage = lazy(() => import('../pages/CurriculumManagemen const PersonnelRegistryPage = lazy(() => import('../pages/PersonnelRegistryPage')) const TeachingTasksPage = lazy(() => import('../pages/TeachingTasksPage')) const SchedulesPage = lazy(() => import('../pages/SchedulesPage')) +const TimetableQueryPage = lazy(() => import('../pages/TimetableQueryPage')) const AccountActivationPage = lazy(() => import('../pages/AccountActivationPage')) const OfficialDocumentVerifyPage = lazy(() => import('../pages/OfficialDocumentVerifyPage')) const ClassroomRepairReportPage = lazy(() => import('../pages/ClassroomRepairReportPage')) @@ -95,6 +96,7 @@ function AppRoutes() { } /> } /> } /> + } /> }> } /> } /> @@ -142,6 +144,8 @@ function AppRoutes() { } /> } /> } /> + } /> + } /> } /> } /> diff --git a/web-react/src/layout/AppShell.tsx b/web-react/src/layout/AppShell.tsx index 484b4bc..cb0ef12 100644 --- a/web-react/src/layout/AppShell.tsx +++ b/web-react/src/layout/AppShell.tsx @@ -125,6 +125,9 @@ export default function AppShell() { { key: '/base-data/facilities', icon: , label: '教学场所' }, { key: '/base-data/course-categories', icon: , label: '课程分类' }, ] : []), + ...(roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader'].includes(role)) ? [ + { key: '/class-timetable', icon: , label: '课表查询中心' }, + ] : []), ...(roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor'].includes(role)) ? [ { key: '/teachers', icon: , label: '教师档案' }, { key: '/students', icon: , label: '学生档案' }, diff --git a/web-react/src/pages/LoginPage.tsx b/web-react/src/pages/LoginPage.tsx index b781be4..06008cf 100644 --- a/web-react/src/pages/LoginPage.tsx +++ b/web-react/src/pages/LoginPage.tsx @@ -142,8 +142,8 @@ export default function LoginPage() { {sso.enabled && }
- 无需登录,查询班级课表 → - 学生首次登录?自助激活账号 → + 无需登录,查询班级课表 → + 学生首次登录?自助激活账号 →
) : ( diff --git a/web-react/src/pages/PersonnelRegistryPage.tsx b/web-react/src/pages/PersonnelRegistryPage.tsx index d572e58..d451f57 100644 --- a/web-react/src/pages/PersonnelRegistryPage.tsx +++ b/web-react/src/pages/PersonnelRegistryPage.tsx @@ -3,7 +3,7 @@ import { CalendarOutlined, DeleteOutlined, DownloadOutlined, HistoryOutlined, Ke import { Alert, App, AutoComplete, Button, Checkbox, Divider, Empty, Form, Input, InputNumber, Modal, Pagination, Select, Skeleton, Switch, Table, Tag } from 'antd' import type { ColumnsType, TableRowSelection } from 'antd/es/table/interface' import { useQuery, useQueryClient } from '@tanstack/react-query' -import { useLocation } from 'react-router-dom' +import { useLocation, useNavigate } from 'react-router-dom' import http, { apiErrorMessage } from '../api/http' import { downloadApiFile, importExcel } from '../api/files' import PageHeader from '../components/PageHeader' @@ -31,6 +31,7 @@ const splitTags = (value: unknown) => typeof value === 'string' ? value.split(/[ export default function PersonnelRegistryPage() { const { message, modal } = App.useApp() const location = useLocation() + const navigate = useNavigate() const client = useQueryClient() const user = useAuthStore((state) => state.user) const fileRef = useRef(null) @@ -81,7 +82,7 @@ export default function PersonnelRegistryPage() { async function openArchive(ids: string[], target: boolean) { if (!ids.length) return message.info('请先选择档案'); setArchive({ open: true, target, previews: [], results: [], reason: '', busy: true }); try { const response = await http.post(`/archives/${active}/preview`, { ids, archived: target }); setArchive((state) => ({ ...state, previews: response.data, busy: false })) } catch (error) { message.error(apiErrorMessage(error)); setArchive((state) => ({ ...state, busy: false })) } } async function applyArchive() { if (!archive.reason.trim() || !eligible.length) return; setArchive((state) => ({ ...state, busy: true })); try { const response = await http.post(`/archives/${active}/apply`, { ids: eligible.map((item) => item.id), archived: archive.target, reason: archive.reason.trim() }); setArchive((state) => ({ ...state, results: response.data, busy: false })); setSelectedIds([]); await refresh() } catch (error) { message.error(apiErrorMessage(error)); setArchive((state) => ({ ...state, busy: false })) } } async function loadHistory(id: string, historyPage = 1) { setHistory({ open: true, id, page: historyPage, busy: true, entries: [] }); try { const response = await http.get(`/archives/${active}/${id}/history`, { params: { page: historyPage } }); setHistory({ open: true, id, page: historyPage, busy: false, entries: response.data }) } catch (error) { message.error(apiErrorMessage(error)); setHistory((state) => ({ ...state, busy: false })) } } - function viewTimetable(row: Person) { const legacy = import.meta.env.VITE_LEGACY_APP_URL ?? 'http://localhost:5255'; window.location.assign(`${legacy.replace(/\/$/, '')}/teacher-timetable/${row.id}`) } + function viewTimetable(row: Person) { navigate(`/teacher-timetable/${row.id}`) } const columns: ColumnsType = [ { title: '归档状态', width: 105, render: (_, row) => {row.isArchived ? '已归档' : '未归档'} }, diff --git a/web-react/src/pages/TimetableQueryPage.tsx b/web-react/src/pages/TimetableQueryPage.tsx new file mode 100644 index 0000000..26e1ab6 --- /dev/null +++ b/web-react/src/pages/TimetableQueryPage.tsx @@ -0,0 +1,81 @@ +import { useEffect, useMemo, useState } from 'react' +import { DownloadOutlined, LeftOutlined, LoginOutlined, ReloadOutlined, RightOutlined } from '@ant-design/icons' +import { Alert, App, Button, Checkbox, Empty, Radio, Select, Skeleton, Tag } from 'antd' +import { useQuery } from '@tanstack/react-query' +import { Link, useLocation, useParams } from 'react-router-dom' +import http, { apiErrorMessage } from '../api/http' +import { downloadApiFile, downloadApiPostFile } from '../api/files' +import { branding } from '../config/branding' +import { useAuthStore } from '../stores/auth' +import PageHeader from '../components/PageHeader' +import './my-timetable.css' +import './timetable-query.css' + +interface Term { id: string; name: string; startDate: string; endDate: string; isCurrent: boolean; isArchived?: boolean; hasPublishedTimetable?: boolean } +interface Option { id: string; code?: string; name: string; version?: string; grade?: number; majorId?: string; majorName?: string; collegeId?: string; collegeName?: string; teacherNumber?: string; title?: string; buildingId?: string; buildingName?: string; campusId?: string; campusName?: string; status?: string; entryCount?: number; hasPublishedTimetable?: boolean } +interface PublicOptions { terms: Term[]; colleges: Option[]; majors: Option[]; classes: Option[] } +interface ManagementOptions { plans: Option[]; colleges: Option[]; majors: Option[]; classes: Option[]; teachers: Option[]; campuses: Option[]; buildings: Option[]; classrooms: Option[] } +interface Slot { periodNumber: number; name: string; startsAt: string; endsAt: string } +interface Entry { id: string; teachingTaskId?: string; courseCode: string; courseName: string; teacherNames: string[]; classNames: string[]; classroomName?: string; buildingName?: string; campusName?: string; dayOfWeek: number; startPeriod: number; periodCount: number; startWeek: number; endWeek: number; weekPattern: 'All' | 'Odd' | 'Even'; isExam?: boolean; examDate?: string; isExperiment?: boolean; experimentDate?: string; experimentProjectName?: string } +interface FlexibleCourse { id: string; courseCode: string; courseName: string; credits: number; totalHours: number; weeklyHours: number; startWeek: number; endWeek: number; teacherNames: string[] } +interface Timetable { term: Term; subject: { name: string }; plan?: { id: string; name: string; version: string; status: string }; slots: Slot[]; entries: Entry[]; examEntries: Entry[]; experimentEntries: Entry[]; flexibleCourses: FlexibleCourse[] } + +const weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'] +const dateOnly = (value?: string) => { if (!value) return null; const date = new Date(`${value.slice(0, 10)}T00:00:00`); return Number.isNaN(date.getTime()) ? null : date } +const addDays = (value: Date, days: number) => { const result = new Date(value); result.setDate(result.getDate() + days); return result } +const md = (value: Date) => `${value.getMonth() + 1}月${value.getDate()}日` +const occurs = (entry: Entry, week: number) => week >= entry.startWeek && week <= entry.endWeek && (entry.weekPattern === 'All' || entry.weekPattern === 'Odd' && week % 2 === 1 || entry.weekPattern === 'Even' && week % 2 === 0) +const defaultTermId = (terms: Term[]) => terms.find((item) => item.isCurrent)?.id ?? terms.find((item) => item.hasPublishedTimetable)?.id ?? terms[0]?.id + +export default function TimetableQueryPage() { + const { message } = App.useApp() + const location = useLocation(); const { teacherId: teacherIdParam } = useParams(); const user = useAuthStore((state) => state.user) + const isPublic = location.pathname === '/timetable'; const isTeacherView = !!teacherIdParam + const isManager = !isPublic && !isTeacherView && (user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader'].includes(role)) ?? false) + const [termId, setTermId] = useState(''); const [resourceType, setResourceType] = useState<'Class' | 'Teacher' | 'Classroom'>('Class'); const [planId, setPlanId] = useState('') + const [grade, setGrade] = useState(); const [collegeId, setCollegeId] = useState(''); const [majorId, setMajorId] = useState(''); const [classId, setClassId] = useState(''); const [teacherId, setTeacherId] = useState(''); const [campusId, setCampusId] = useState(''); const [buildingId, setBuildingId] = useState(''); const [classroomId, setClassroomId] = useState('') + const [week, setWeek] = useState(1); const [day, setDay] = useState(1); const [view, setView] = useState<'overview' | 'week' | 'day'>('week'); const [batchIds, setBatchIds] = useState([]); const [exporting, setExporting] = useState(false) + const publicOptions = useQuery({ queryKey: ['timetable-public-options'], queryFn: async () => (await http.get('/timetables/options')).data }) + const myClass = useQuery({ queryKey: ['timetable-my-class'], enabled: !isPublic && !isManager && !isTeacherView && (user?.roles.includes('Student') ?? false), queryFn: async () => (await http.get<{ administrativeClassId: string }>('/timetables/my-class')).data }) + useEffect(() => { if (!termId && publicOptions.data) setTermId(defaultTermId(publicOptions.data.terms) ?? '') }, [publicOptions.data, termId]) + const managementOptions = useQuery({ queryKey: ['timetable-management-options', termId], enabled: isManager && !!termId, queryFn: async () => (await http.get('/timetables/management/options', { params: { academicTermId: termId } })).data }) + const options = managementOptions.data + useEffect(() => { if (!options) return; setPlanId((current) => options.plans.some((item) => item.id === current) ? current : options.plans.find((item) => item.status === 'Published')?.id ?? options.plans[0]?.id ?? ''); setClassId((current) => options.classes.some((item) => item.id === current) ? current : options.classes[0]?.id ?? ''); setTeacherId((current) => options.teachers.some((item) => item.id === current) ? current : options.teachers[0]?.id ?? ''); setClassroomId((current) => options.classrooms.some((item) => item.id === current) ? current : options.classrooms[0]?.id ?? '') }, [options]) + useEffect(() => { if (isManager || isTeacherView || !publicOptions.data || classId) return; const first = publicOptions.data.classes.find((item) => item.id === myClass.data?.administrativeClassId) ?? publicOptions.data.classes.find((item) => item.hasPublishedTimetable) ?? publicOptions.data.classes[0]; if (first) { setClassId(first.id); setGrade(first.grade); setCollegeId(first.collegeId ?? ''); setMajorId(first.majorId ?? '') } }, [publicOptions.data, myClass.data?.administrativeClassId, isManager, isTeacherView, classId]) + const lists = options ?? { plans: [], colleges: publicOptions.data?.colleges ?? [], majors: publicOptions.data?.majors ?? [], classes: publicOptions.data?.classes ?? [], teachers: [], campuses: [], buildings: [], classrooms: [] } + const filteredMajors = lists.majors.filter((item) => !collegeId || item.collegeId === collegeId) + const filteredClasses = lists.classes.filter((item) => (!grade || item.grade === grade) && (!collegeId || item.collegeId === collegeId) && (!majorId || item.majorId === majorId)) + const filteredTeachers = lists.teachers.filter((item) => !collegeId || item.collegeId === collegeId) + const filteredBuildings = lists.buildings.filter((item) => !campusId || item.campusId === campusId) + const filteredClassrooms = lists.classrooms.filter((item) => (!campusId || item.campusId === campusId) && (!buildingId || item.buildingId === buildingId)) + const resourceId = resourceType === 'Teacher' ? teacherId : resourceType === 'Classroom' ? classroomId : classId + const timetable = useQuery({ queryKey: ['timetable-query', isManager, teacherIdParam, termId, resourceType, resourceId, planId], enabled: !!termId && (isTeacherView || !!resourceId), queryFn: async () => { if (isTeacherView) return (await http.get(`/timetables/teachers/${teacherIdParam}`, { params: { academicTermId: termId } })).data; if (isManager) return (await http.get('/timetables/management/query', { params: { resourceType, resourceId, academicTermId: termId, schedulePlanId: planId || undefined } })).data; return (await http.get(`/timetables/classes/${classId}`, { params: { academicTermId: termId } })).data } }) + const data = timetable.data + const firstMonday = useMemo(() => { const start = dateOnly(data?.term.startDate); return start ? addDays(start, -(start.getDay() === 0 ? 6 : start.getDay() - 1)) : null }, [data?.term.startDate]) + const totalWeeks = useMemo(() => { const end = dateOnly(data?.term.endDate); return firstMonday && end ? Math.max(1, Math.ceil((end.getTime() - firstMonday.getTime() + 86400000) / 604800000)) : 1 }, [data?.term.endDate, firstMonday]) + const currentWeek = useMemo(() => { if (!firstMonday) return null; const now = new Date(); now.setHours(0, 0, 0, 0); const value = Math.floor((now.getTime() - firstMonday.getTime()) / 604800000) + 1; return value >= 1 && value <= totalWeeks ? value : null }, [firstMonday, totalWeeks]) + useEffect(() => { setWeek(currentWeek ?? 1) }, [currentWeek, termId]) + const allEntries = useMemo(() => { if (!data || !firstMonday) return [] as Entry[]; const dated = [...(data.examEntries ?? []), ...(data.experimentEntries ?? [])].map((entry) => { const date = dateOnly(entry.examDate ?? entry.experimentDate); const entryWeek = date ? Math.floor((date.getTime() - firstMonday.getTime()) / 604800000) + 1 : 0; return { ...entry, startWeek: entryWeek, endWeek: entryWeek, weekPattern: 'All' as const } }); return [...data.entries, ...dated] }, [data, firstMonday]) + const visibleEntries = view === 'overview' ? data?.entries ?? [] : allEntries.filter((entry) => occurs(entry, week) && (view !== 'day' || entry.dayOfWeek === day)) + const displayDays = view === 'day' ? [day] : [1, 2, 3, 4, 5, 6, 7] + const slotMap = useMemo(() => new Map(data?.slots.map((slot) => [slot.periodNumber, slot]) ?? []), [data?.slots]) + const title = isTeacherView ? '教师课表' : isManager ? '课表查询中心' : '班级课表查询' + const description = isTeacherView ? '查看指定教师本学期的正式授课安排。' : isManager ? '查询班级、教师与场地课表,可切换草稿或正式发布版本。' : '按年级、学院、专业和班级查询正式发布的课程、考试与实验安排。' + const resourceOptions = resourceType === 'Teacher' ? filteredTeachers : resourceType === 'Classroom' ? filteredClassrooms : filteredClasses + const resourceLabel = (item: Option) => resourceType === 'Teacher' ? `${item.teacherNumber} · ${item.name}` : resourceType === 'Classroom' ? `${item.buildingName} · ${item.name}` : `${item.code} · ${item.name}` + + async function exportOne(kind: 'xlsx' | 'pdf') { if (!data) return; setExporting(true); try { let path: string; if (isTeacherView) path = `/timetables/teachers/${teacherIdParam}/export.${kind}`; else if (isManager) path = `/timetables/management/export.${kind}`; else path = `/timetables/classes/${classId}/export.${kind}`; await downloadApiFile(path, `${data.subject.name}课表.${kind}`, { params: isManager ? { resourceType, resourceId, academicTermId: termId, schedulePlanId: planId || undefined } : { academicTermId: termId } }) } catch (error) { message.error(apiErrorMessage(error)) } finally { setExporting(false) } } + async function exportBatch(kind: 'xlsx' | 'pdf') { if (!batchIds.length) return message.warning('请先选择要导出的课表。'); setExporting(true); try { await downloadApiPostFile(`/timetables/management/export/batch.${kind}`, { resourceType, resourceIds: batchIds, academicTermId: termId, schedulePlanId: planId || undefined }, `课表批量导出.${kind}`) } catch (error) { message.error(apiErrorMessage(error)) } finally { setExporting(false) } } + + if (publicOptions.isLoading) return
+ if (publicOptions.isError) return
+ const content = <> + } /> +
({ value: item.id, label: `${item.version} · ${item.name}${item.status === 'Published' ? ' · 已发布' : ' · 草稿'}` }))} onChange={setPlanId} />}{!isTeacherView && resourceType === 'Class' && <> ({ value: item.id, label: item.name }))} onChange={(value) => { setCollegeId(value ?? ''); setMajorId(''); setClassId(''); setBatchIds([]) }} /> ({ value: item.id, label: `${item.code} · ${item.name}` }))} onChange={setClassId} />}{isManager && resourceType === 'Teacher' && <> ({ value: item.id, label: `${item.teacherNumber} · ${item.name}` }))} onChange={setTeacherId} />}{isManager && resourceType === 'Classroom' && <> ({ value: item.id, label: item.name }))} onChange={(value) => { setBuildingId(value ?? ''); setClassroomId(''); setBatchIds([]) }} /> ({ value: index + 1, label: `第 ${index + 1} 周${index + 1 === currentWeek ? ' · 本周' : ''}` }))} onChange={setWeek} />{view === 'day' &&