diff --git a/web-react/MIGRATION_MATRIX.md b/web-react/MIGRATION_MATRIX.md index 6df72a5..99cd16d 100644 --- a/web-react/MIGRATION_MATRIX.md +++ b/web-react/MIGRATION_MATRIX.md @@ -52,6 +52,7 @@ | `/schedules` | 排课管理 | SuperAdmin、AcademicAdmin | 版本草稿、手工排课、冲突校验、后台自动排课、异步发布、作息、优化策略与课程约束 | verification | | `/timetable`、`/class-timetable`、`/teacher-timetable/:teacherId` | 公开/管理/教师课表 | 公开;教务管理与领导可查草稿 | 班级/教师/教室资源、版本切换、周/日/总览、考试实验融合、单项与批量 Excel/PDF | verification | | `/course-adjustments` | 调停课管理 | Teacher、SuperAdmin、AcademicAdmin、CollegeAdmin | 原课次锁定、调课/停课/补课/代课申请、草稿提交、学院范围审核、冲突复核与通知定位 | verification | +| `/classroom-reservations` | 教室借用与预约 | 已登录用户;CollegeAdmin 审核 | 空闲教室检索、课程/考试/已批准预约避让、申请撤销、学院范围审核与冲突复核 | 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 c661d72..c4dcd16 100644 --- a/web-react/README.md +++ b/web-react/README.md @@ -51,6 +51,7 @@ npm.cmd run build - 迁移排课管理,覆盖版本修订、手工排课、后台自动排课和发布、作息、优化参数及课程约束。 - 迁移公开、管理和指定教师课表,支持班级/教师/教室查询、版本切换、考试实验融合与批量导出。 - 迁移调停课申请与审核工作台,覆盖原课次锁定、调课/停课/补课/代课、草稿提交、冲突复核和通知定位。 +- 迁移教室借用与预约,覆盖空闲教室检索、预约申请/撤销和学院审核闭环。 ## 迁移纪律 diff --git a/web-react/src/app/App.tsx b/web-react/src/app/App.tsx index 243cfb9..ef5aa9b 100644 --- a/web-react/src/app/App.tsx +++ b/web-react/src/app/App.tsx @@ -50,6 +50,7 @@ const TeachingTasksPage = lazy(() => import('../pages/TeachingTasksPage')) const SchedulesPage = lazy(() => import('../pages/SchedulesPage')) const TimetableQueryPage = lazy(() => import('../pages/TimetableQueryPage')) const CourseAdjustmentsPage = lazy(() => import('../pages/CourseAdjustmentsPage')) +const ClassroomReservationsPage = lazy(() => import('../pages/ClassroomReservationsPage')) const AccountActivationPage = lazy(() => import('../pages/AccountActivationPage')) const OfficialDocumentVerifyPage = lazy(() => import('../pages/OfficialDocumentVerifyPage')) const ClassroomRepairReportPage = lazy(() => import('../pages/ClassroomRepairReportPage')) @@ -148,6 +149,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> diff --git a/web-react/src/layout/AppShell.tsx b/web-react/src/layout/AppShell.tsx index 3ac0aad..98ccf4e 100644 --- a/web-react/src/layout/AppShell.tsx +++ b/web-react/src/layout/AppShell.tsx @@ -73,6 +73,7 @@ export default function AppShell() { ...((roles.includes('Student') || roles.includes('Teacher')) ? [{ key: '/my-timetable', icon: , label: '我的课表' }] : []), + { key: '/classroom-reservations', icon: , label: roles.includes('CollegeAdmin') ? '教室借用审核' : '教室借用' }, ...((roles.includes('Student') || roles.includes('Teacher')) ? [{ key: '/exams', icon: , label: roles.includes('Teacher') ? '监考安排' : '考试安排' }] : []), diff --git a/web-react/src/pages/ClassroomReservationsPage.tsx b/web-react/src/pages/ClassroomReservationsPage.tsx new file mode 100644 index 0000000..6aade09 --- /dev/null +++ b/web-react/src/pages/ClassroomReservationsPage.tsx @@ -0,0 +1,126 @@ +import { useEffect, useMemo, useState } from 'react' +import { CheckOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons' +import { Alert, App, Button, Empty, Form, Input, InputNumber, Modal, Pagination, Select, Skeleton, Table, Tabs, Tag } from 'antd' +import type { ColumnsType } from 'antd/es/table' +import { useQuery } from '@tanstack/react-query' +import http, { apiErrorMessage } from '../api/http' +import PageHeader from '../components/PageHeader' +import { useAuthStore } from '../stores/auth' +import './classroom-reservations.css' + +type Status = 'Submitted' | 'Approved' | 'Rejected' | 'Cancelled' +interface Applicant { id: string; displayName: string; collegeId: string; collegeName: string } +interface Term { id: string; name: string; startDate: string; endDate: string; isCurrent: boolean; hasPublishedTimetable: boolean } +interface Campus { id: string; code: string; name: string } +interface Building { id: string; code: string; name: string; campusId: string } +interface TimeSlot { periodNumber: number; name: string; startTime: string; endTime: string } +interface Room { id: string; code: string; name: string; buildingId: string; capacity: number; roomType: string; buildingName?: string; campusId?: string; campusName?: string } +interface Options { applicant?: Applicant; applicantProblem?: string; terms: Term[]; selectedTermId?: string; campuses: Campus[]; buildings: Building[]; classrooms: Room[]; timeSlots: TimeSlot[] } +interface Reservation { id: string; applicantUserId: string; applicantName: string; applicantCollegeId: string; applicantCollegeName: string; academicTermId: string; academicTermName: string; classroomId: string; classroomCode: string; classroomName: string; buildingName: string; campusName: string; reservationDate: string; startPeriod: number; periodCount: number; attendeeCount: number; purpose: string; contactPhone: string; notes?: string; status: Status; reviewComment?: string; reviewedAt?: string; cancelledAt?: string; createdAt: string } +interface Page { items: Reservation[]; total: number; page: number; pageSize: number } +interface ReservationForm { academicTermId: string; reservationDate: string; campusId?: string; buildingId?: string; classroomId?: string; startPeriod: number; periodCount: number; attendeeCount: number; purpose: string; contactPhone: string; notes?: string } + +const labels: Record = { Submitted: '待学院审核', Approved: '已批准', Rejected: '已驳回', Cancelled: '已取消' } +const colors: Record = { Submitted: 'orange', Approved: 'green', Rejected: 'red', Cancelled: 'default' } +const chinaToday = () => new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date()) +const periodText = (row: Pick) => row.periodCount === 1 ? `第 ${row.startPeriod} 节` : `第 ${row.startPeriod}—${row.startPeriod + row.periodCount - 1} 节` + +function ReservationTable({ items, loading, reviewing, busyId, onCancel, onApprove, onReject }: { items: Reservation[]; loading: boolean; reviewing?: boolean; busyId?: string; onCancel: (item: Reservation) => void; onApprove: (item: Reservation) => void; onReject: (item: Reservation) => void }) { + const columns: ColumnsType = reviewing ? [ + { title: '申请人', width: 140, render: (_, row) =>
{row.applicantName}{row.applicantCollegeName}
}, + { title: '日期与教室', width: 230, render: (_, row) =>
{row.reservationDate} · {periodText(row)}{row.campusName} · {row.buildingName} {row.classroomName}
}, + { title: '用途与人数', render: (_, row) =>
{row.purpose}{row.attendeeCount} 人 · {row.contactPhone}
}, + { title: '提交时间', width: 180, render: (_, row) => new Date(row.createdAt).toLocaleString('zh-CN', { hour12: false }) }, + { title: '状态', width: 110, render: (_, row) => {labels[row.status]} }, + { title: '操作', width: 150, fixed: 'right', render: (_, row) => row.status === 'Submitted' ?
: {row.reviewComment || '—'} }, + ] : [ + { title: '日期与节次', width: 170, render: (_, row) =>
{row.reservationDate}{periodText(row)}
}, + { title: '教室', width: 205, render: (_, row) =>
{row.buildingName} · {row.classroomName}{row.campusName} · {row.attendeeCount} 人
}, + { title: '用途', dataIndex: 'purpose' }, + { title: '状态', width: 120, render: (_, row) => {labels[row.status]} }, + { title: '审核意见', width: 210, render: (_, row) => row.reviewComment || '—' }, + { title: '操作', width: 100, fixed: 'right', render: (_, row) => ['Submitted', 'Approved'].includes(row.status) ? : '—' }, + ] + return rowKey="id" loading={loading} columns={columns} dataSource={items} pagination={false} scroll={{ x: reviewing ? 980 : 860 }} locale={{ emptyText: }} /> +} + +export default function ClassroomReservationsPage() { + const { message, modal } = App.useApp() + const user = useAuthStore((state) => state.user) + const isCollegeAdmin = user?.roles.includes('CollegeAdmin') ?? false + const [tab, setTab] = useState<'mine' | 'review'>('mine') + const [optionTermId, setOptionTermId] = useState() + const [minePage, setMinePage] = useState(1) + const [reviewPage, setReviewPage] = useState(1) + const [reviewStatus, setReviewStatus] = useState('Submitted') + const [availableRooms, setAvailableRooms] = useState([]) + const [availabilityChecked, setAvailabilityChecked] = useState(false) + const [availabilityLoading, setAvailabilityLoading] = useState(false) + const [busyId, setBusyId] = useState() + const [rejecting, setRejecting] = useState() + const [rejectComment, setRejectComment] = useState('') + const [form] = Form.useForm() + const termId = Form.useWatch('academicTermId', form) + const campusId = Form.useWatch('campusId', form) + const startPeriod = Form.useWatch('startPeriod', form) ?? 1 + + const options = useQuery({ queryKey: ['classroom-reservation-options', optionTermId], queryFn: async () => (await http.get('/classroom-reservations/options', { params: optionTermId ? { academicTermId: optionTermId } : undefined })).data }) + const mine = useQuery({ queryKey: ['classroom-reservations-mine', minePage], queryFn: async () => (await http.get('/classroom-reservations/mine', { params: { page: minePage, pageSize: 12 } })).data }) + const review = useQuery({ queryKey: ['classroom-reservations-review', reviewStatus, reviewPage], enabled: isCollegeAdmin && tab === 'review', queryFn: async () => (await http.get('/classroom-reservations/review', { params: { status: reviewStatus, page: reviewPage, pageSize: 12 } })).data }) + const selectedTerm = options.data?.terms.find((term) => term.id === termId) + const buildings = useMemo(() => (options.data?.buildings ?? []).filter((building) => !campusId || building.campusId === campusId), [options.data?.buildings, campusId]) + const periodCounts = useMemo(() => { const enabled = new Set((options.data?.timeSlots ?? []).map((item) => item.periodNumber)); const result: number[] = []; for (let count = 1; count <= 6 && enabled.has(startPeriod + count - 1); count += 1) result.push(count); return result }, [options.data?.timeSlots, startPeriod]) + const today = chinaToday() + + useEffect(() => { + if (!options.data) return + const nextTerm = options.data.selectedTermId ?? options.data.terms.find((item) => item.hasPublishedTimetable)?.id + const term = options.data.terms.find((item) => item.id === nextTerm) + const current = form.getFieldsValue() + const date = current.reservationDate && term && current.reservationDate >= term.startDate && current.reservationDate <= term.endDate ? current.reservationDate : (term ? (today < term.startDate ? term.startDate : today > term.endDate ? '' : today) : '') + form.setFieldsValue({ academicTermId: nextTerm, reservationDate: date, startPeriod: options.data.timeSlots.some((item) => item.periodNumber === current.startPeriod) ? current.startPeriod : options.data.timeSlots[0]?.periodNumber ?? 1, periodCount: current.periodCount ?? 2, attendeeCount: current.attendeeCount ?? 1 }) + setAvailableRooms([]); setAvailabilityChecked(false) + }, [options.data]) // eslint-disable-line react-hooks/exhaustive-deps + + function invalidateAvailability() { setAvailabilityChecked(false); setAvailableRooms([]); form.setFieldValue('classroomId', undefined) } + function onValuesChange(changed: Partial) { + const invalidating = ['reservationDate', 'startPeriod', 'periodCount', 'attendeeCount', 'campusId', 'buildingId'].some((key) => key in changed) + if ('campusId' in changed && form.getFieldValue('buildingId') && !buildings.some((item) => item.id === form.getFieldValue('buildingId'))) form.setFieldValue('buildingId', undefined) + if ('startPeriod' in changed) { + const enabled = new Set((options.data?.timeSlots ?? []).map((item) => item.periodNumber)) + const nextCounts = Array.from({ length: 6 }, (_, index) => index + 1).filter((count) => Array.from({ length: count }, (_, index) => index).every((offset) => enabled.has((changed.startPeriod ?? startPeriod) + offset))) + if (!nextCounts.includes(form.getFieldValue('periodCount'))) form.setFieldValue('periodCount', nextCounts[0] ?? 1) + } + if (invalidating) invalidateAvailability() + } + async function searchRooms() { + try { + await form.validateFields(['academicTermId', 'reservationDate', 'startPeriod', 'periodCount', 'attendeeCount']) + const values = form.getFieldsValue() + setAvailabilityLoading(true) + const result = await http.get<{ items: Room[] }>('/classroom-reservations/availability', { params: { academicTermId: values.academicTermId, reservationDate: values.reservationDate, startPeriod: values.startPeriod, periodCount: values.periodCount, attendeeCount: values.attendeeCount, campusId: values.campusId, buildingId: values.buildingId } }) + setAvailableRooms(result.data.items); setAvailabilityChecked(true) + if (!result.data.items.length) message.warning('当前条件下没有可预约教室,请调整日期、节次或人数。') + } catch (error) { if (error && typeof error === 'object' && 'errorFields' in error) return; message.error(apiErrorMessage(error)) } finally { setAvailabilityLoading(false) } + } + async function submit(values: ReservationForm) { + if (!availabilityChecked || !values.classroomId) { message.warning('请先查询可用教室并选择教室。'); return } + try { setBusyId('create'); await http.post('/classroom-reservations', { academicTermId: values.academicTermId, classroomId: values.classroomId, reservationDate: values.reservationDate, startPeriod: values.startPeriod, periodCount: values.periodCount, attendeeCount: values.attendeeCount, purpose: values.purpose.trim(), contactPhone: values.contactPhone.trim(), notes: values.notes?.trim() || null }); message.success('申请已提交,将由所在学院审核。'); form.setFieldsValue({ purpose: '', notes: '', classroomId: undefined }); invalidateAvailability(); setTab('mine'); setMinePage(1); await mine.refetch() } catch (error) { message.error(apiErrorMessage(error)) } finally { setBusyId(undefined) } + } + async function refresh() { await Promise.all([options.refetch(), mine.refetch(), isCollegeAdmin ? review.refetch() : Promise.resolve()]) } + async function cancel(item: Reservation) { try { setBusyId(item.id); await http.post(`/classroom-reservations/${item.id}/cancel`); message.success('预约已取消。'); await mine.refetch() } catch (error) { message.error(apiErrorMessage(error)) } finally { setBusyId(undefined) } } + function confirmCancel(item: Reservation) { modal.confirm({ title: '取消教室预约', content: item.status === 'Approved' ? '该预约已经批准,取消后教室将立即释放。确认取消吗?' : '确认撤回这条待审核申请吗?', okText: '确认取消', okButtonProps: { danger: true }, onOk: () => cancel(item) }) } + function approve(item: Reservation) { modal.confirm({ title: '批准教室借用', content: `确认批准 ${item.applicantName} 对 ${item.buildingName} ${item.classroomName} 的借用申请吗?服务端会再次检查占用冲突。`, okText: '批准', onOk: async () => { try { setBusyId(item.id); await http.post(`/classroom-reservations/${item.id}/approve`, { comment: null }); message.success('申请已批准。'); await review.refetch() } catch (error) { message.error(apiErrorMessage(error)); throw error } finally { setBusyId(undefined) } } }) } + async function reject() { if (!rejecting || !rejectComment.trim()) return; try { setBusyId(rejecting.id); await http.post(`/classroom-reservations/${rejecting.id}/reject`, { comment: rejectComment.trim() }); message.success('申请已驳回。'); setRejecting(undefined); setRejectComment(''); await review.refetch() } catch (error) { message.error(apiErrorMessage(error)) } finally { setBusyId(undefined) } } + + if (options.isLoading) return
+ if (options.isError) return
+ const page = tab === 'review' ? review : mine + return
+ } onClick={() => void refresh()}>刷新} aside={options.data?.applicant ?
审核学院{options.data.applicant.collegeName}{options.data.applicant.displayName}
: undefined} /> + {options.data?.applicantProblem && } +
提交借用申请日期、节次、人数或地点变化后,请重新查询可用教室。
{availabilityChecked ? `已找到 ${availableRooms.length} 间可用教室` : '尚未查询可用教室'}
void submit(values)}>
selectedTerm.startDate ? today : selectedTerm.startDate) : today} max={selectedTerm?.endDate} /> ({ value, label: `${value} 节` }))} /> ({ value: item.id, label: item.name }))} />
{availabilityChecked ? '可提交申请,服务端会在写入时再次检查冲突。' : '查询可用教室后才能提交申请。'}
+
setTab(value as 'mine' | 'review')} items={[{ key: 'mine', label: '我的申请' }, ...(isCollegeAdmin ? [{ key: 'review', label: '本院审核' }] : [])]} />{tab === 'review' &&
{options.data?.applicant?.collegeName || user?.displayName}审核队列只显示申请人归属本学院的教室借用申请。