= 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 && }
+
+ setTab(value as 'mine' | 'review')} items={[{ key: 'mine', label: '我的申请' }, ...(isCollegeAdmin ? [{ key: 'review', label: '本院审核' }] : [])]} />{tab === 'review' && {options.data?.applicant?.collegeName || user?.displayName}审核队列只显示申请人归属本学院的教室借用申请。
} { setRejecting(item); setRejectComment('') }} />{(page.data?.total ?? 0) > 12 && tab === 'review' ? setReviewPage(value) : setMinePage(value)} />}
+ setRejecting(undefined)} onOk={() => void reject()}>
+
+}
diff --git a/web-react/src/pages/classroom-reservations.css b/web-react/src/pages/classroom-reservations.css
new file mode 100644
index 0000000..831d04f
--- /dev/null
+++ b/web-react/src/pages/classroom-reservations.css
@@ -0,0 +1 @@
+.classroom-reservations-page{gap:16px}.reservation-scope{min-width:170px;padding:10px 14px;display:grid;gap:3px;border-left:3px solid var(--teal);background:var(--teal-soft)}.reservation-scope span,.reservation-scope small{color:var(--ink-muted);font-size:8px}.reservation-scope strong{color:var(--brand-deep);font-size:12px}.reservation-form-card,.reservation-records{min-width:0;padding:16px;border:1px solid var(--line);background:#fff}.reservation-form-card>header,.reservation-form-card footer,.review-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px}.reservation-form-card>header{margin-bottom:15px}.reservation-form-card header strong,.reservation-form-card header span,.review-toolbar strong,.review-toolbar span{display:block}.reservation-form-card header strong,.review-toolbar strong{color:var(--brand-deep);font-size:12px}.reservation-form-card header span,.review-toolbar span{margin-top:4px;color:var(--ink-muted);font-size:8px}.reservation-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:0 12px}.reservation-grid .ant-form-item:nth-child(8),.reservation-grid .ant-input-number{width:100%}.reservation-span-2{grid-column:span 2}.room-search{display:flex;gap:7px}.room-search>.ant-select{min-width:0;flex:1}.reservation-form-card footer{padding-top:14px;border-top:1px solid var(--line)}.reservation-form-card footer span{color:var(--ink-muted);font-size:9px}.reservation-records>.ant-tabs .ant-tabs-nav{margin:0 0 13px}.reservation-records>.ant-pagination{margin-top:14px;display:flex;justify-content:flex-end}.reservation-cell{display:grid;gap:4px}.reservation-cell strong{color:var(--brand-deep);font-size:10px}.reservation-cell span,.muted{color:var(--ink-muted);font-size:8px}.reservation-actions{display:flex;gap:3px}.review-toolbar{padding:0 0 13px;border-bottom:1px solid var(--line)}.review-toolbar>.ant-select{width:150px}.reservation-reject{margin-top:14px;display:grid;gap:7px;font-weight:600}@media(max-width:980px){.reservation-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:620px){.reservation-form-card>header,.reservation-form-card footer,.review-toolbar{align-items:stretch;flex-direction:column}.reservation-grid{grid-template-columns:1fr}.reservation-span-2{grid-column:auto}.room-search{align-items:stretch;flex-direction:column}.reservation-form-card footer>*,.review-toolbar>.ant-select{width:100%}}