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 = <>
+ } loading={exporting} disabled={!data} onClick={() => void exportOne('xlsx')}>Excel} loading={exporting} disabled={!data} onClick={() => void exportOne('pdf')}>PDF} />
+
+ {isManager && 批量导出当前筛选对象单次最多 100 项,使用当前学期和课表版本。
({ value: item.id, label: resourceLabel(item) }))} onChange={(values) => setBatchIds(values.map(String))} />}
+ setView(event.target.value)} />{view !== 'overview' && <>} disabled={week <= 1} onClick={() => setWeek((value) => value - 1)}>上一周 ({ value: index + 1, label: `第 ${index + 1} 周${index + 1 === currentWeek ? ' · 本周' : ''}` }))} onChange={setWeek} />} disabled={week >= totalWeeks} onClick={() => setWeek((value) => value + 1)}>下一周{view === 'day' && ({ value: index + 1, label }))} onChange={setDay} />}>}} loading={timetable.isFetching} onClick={() => void timetable.refetch()}>刷新
+ {timetable.isError ? : timetable.isLoading ? : data ? <>{view.toUpperCase()} VIEW
{view === 'overview' ? `${data.term.name} · 全学期` : firstMonday ? `第 ${week} 周 · ${md(addDays(firstMonday, (week - 1) * 7))}—${md(addDays(firstMonday, week * 7 - 1))}` : `第 ${week} 周`}
{visibleEntries.length}项课程、考试与实验安排{displayDays.map((dayNumber) => { const entries = visibleEntries.filter((entry) => entry.dayOfWeek === dayNumber).sort((a, b) => a.startPeriod - b.startPeriod); const date = firstMonday && view !== 'overview' ? addDays(firstMonday, (week - 1) * 7 + dayNumber - 1) : null; return {weekdays[dayNumber - 1]}{date ? md(date) : ''}{entries.map((entry) => { const endPeriod = entry.startPeriod + entry.periodCount - 1; const start = slotMap.get(entry.startPeriod)?.startsAt.slice(0, 5); const end = slotMap.get(endPeriod)?.endsAt.slice(0, 5); return
{entry.isExam ? '考试' : entry.isExperiment ? '实验' : `${entry.startPeriod}—${endPeriod} 节`}
{entry.isExperiment ? entry.experimentProjectName || entry.courseName : entry.courseName}{entry.courseCode}{[entry.campusName, entry.buildingName, entry.classroomName].filter(Boolean).join(' · ') || '地点待定'}
{entry.teacherNames?.join('、') || entry.classNames?.join('、')}})}{!entries.length &&
}
})}{view === 'overview' && 考试安排
{data.examEntries?.map((entry) =>
{entry.courseName}{entry.examDate || '日期待定'} · 第 {entry.startPeriod}—{entry.startPeriod + entry.periodCount - 1} 节 · {entry.classroomName || '地点待定'})}{!data.examEntries?.length &&
}
实验安排
{data.experimentEntries?.map((entry) =>
{entry.experimentProjectName || entry.courseName}{entry.experimentDate || '日期待定'} · {entry.classroomName || '地点待定'})}{!data.experimentEntries?.length &&
}
}{!!data.flexibleCourses?.length && {data.flexibleCourses.map((course) =>
{course.courseCode}{course.courseName}{course.startWeek}—{course.endWeek} 周 · 每周 {course.weeklyHours} 学时 · 共 {course.totalHours} 学时
{course.teacherNames.join('、')} · {course.credits} 学分)}
}> : }
+ >
+ if (isPublic) return {branding.shortName} · 课表查询 登录工作台{content}
+ return {content}
+}
diff --git a/web-react/src/pages/timetable-query.css b/web-react/src/pages/timetable-query.css
new file mode 100644
index 0000000..9680d9a
--- /dev/null
+++ b/web-react/src/pages/timetable-query.css
@@ -0,0 +1 @@
+.timetable-query-page{gap:16px}.query-export{display:flex;gap:8px}.query-filters{display:flex;flex-wrap:wrap;gap:8px;padding:13px;border:1px solid var(--line);background:#fff}.query-filters>.ant-select{min-width:170px}.batch-export{display:grid;gap:12px;padding:15px;border:1px solid #cbdce6;background:#f7fafc}.batch-export>div:first-child{display:grid;gap:3px}.batch-export span{color:var(--ink-muted);font-size:9px}.batch-export .ant-checkbox-group{display:flex;flex-wrap:wrap;gap:7px 16px;max-height:150px;overflow:auto}.batch-export>div:last-child{display:flex;gap:8px;flex-wrap:wrap}.query-day-board{grid-template-columns:minmax(300px,700px);justify-content:center;background:transparent;border:0}.activity-overview{display:grid;grid-template-columns:1fr 1fr;gap:14px}.activity-overview>div{padding:16px;border:1px solid var(--line);background:#fff}.activity-overview h3{margin:0 0 12px;color:var(--brand-deep)}.activity-overview article{display:grid;gap:3px;padding:9px 0;border-bottom:1px solid var(--line)}.activity-overview article span{color:var(--ink-muted);font-size:9px}.public-query-page{min-height:100vh;background:var(--page-bg)}.public-query-page>header{display:flex;align-items:center;justify-content:space-between;padding:13px max(20px,calc((100vw - 1240px)/2));background:var(--brand-deep);color:#fff}.public-query-page>header a{color:#fff;font-weight:700}.public-query-page>.page-container{max-width:1240px;margin:auto;padding:28px 20px}@media(max-width:760px){.query-filters{align-items:stretch;flex-direction:column}.query-filters>*{width:100%!important}.query-export{align-items:stretch;flex-direction:column}.activity-overview{grid-template-columns:1fr}.batch-export>div:last-child{align-items:stretch;flex-direction:column}.batch-export button{width:100%}}