feat(web): migrate base data administration

This commit is contained in:
biss authored and biss committed 2026-10-03 21:38:14 +08:00
1 parent e28123f1c7
commit 8ac883ade1
6 files changed
+213

No files matched your search

+5
View File
@@ -42,6 +42,7 @@ const TeachingPreferencesPage = lazy(() => import('../pages/TeachingPreferencesP
const CourseEquivalencyRulesPage = lazy(() => import('../pages/CourseEquivalencyRulesPage'))
const CampusWalletManagementPage = lazy(() => import('../pages/CampusWalletManagementPage'))
const CourseEnrollmentManagementPage = lazy(() => import('../pages/CourseEnrollmentManagementPage'))
const BaseDataPage = lazy(() => import('../pages/BaseDataPage'))
const AccountActivationPage = lazy(() => import('../pages/AccountActivationPage'))
const OfficialDocumentVerifyPage = lazy(() => import('../pages/OfficialDocumentVerifyPage'))
const ClassroomRepairReportPage = lazy(() => import('../pages/ClassroomRepairReportPage'))
@@ -126,6 +127,10 @@ function AppRoutes() {
<Route path="/course-equivalency-rules" element={<CourseEquivalencyRulesPage />} />
<Route path="/campus-wallets" element={<CampusWalletManagementPage />} />
<Route path="/course-enrollments" element={<CourseEnrollmentManagementPage />} />
<Route path="/base-data/organization" element={<BaseDataPage />} />
<Route path="/base-data/terms" element={<BaseDataPage />} />
<Route path="/base-data/facilities" element={<BaseDataPage />} />
<Route path="/base-data/course-categories" element={<BaseDataPage />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Route>
<Route path="*" element={<Navigate to="/dashboard" replace />} />
+7
View File
@@ -35,6 +35,7 @@ import {
ApartmentOutlined,
TeamOutlined,
CarryOutOutlined,
SettingOutlined,
} from '@ant-design/icons'
import { Avatar, Badge, Button, Layout, Menu, Tag } from 'antd'
import { useQuery } from '@tanstack/react-query'
@@ -114,6 +115,12 @@ export default function AppShell() {
{ key: '/course-enrollments', icon: <BookOutlined />, label: '修读课程管理' },
{ key: '/campus-wallets', icon: <WalletOutlined />, label: '学生余额管理' },
] : []),
...(roles.some((role) => ['SuperAdmin', 'AcademicAdmin'].includes(role)) ? [
{ key: '/base-data/organization', icon: <ApartmentOutlined />, label: '组织机构' },
{ key: '/base-data/terms', icon: <CalendarOutlined />, label: '学年学期' },
{ key: '/base-data/facilities', icon: <EnvironmentOutlined />, label: '教学场所' },
{ key: '/base-data/course-categories', icon: <SettingOutlined />, label: '课程分类' },
] : []),
...(user?.roles.includes('SuperAdmin')
? [{ key: '/users', icon: <SafetyCertificateOutlined />, label: '用户与权限' }]
: []),
+195
View File
@@ -0,0 +1,195 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import {
ApartmentOutlined,
DeleteOutlined,
DownloadOutlined,
HistoryOutlined,
PlusOutlined,
ReloadOutlined,
UploadOutlined,
} from '@ant-design/icons'
import {
Alert,
App,
Button,
Checkbox,
Empty,
Form,
Input,
InputNumber,
Modal,
Pagination,
Select,
Skeleton,
Switch,
Table,
Tag,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useLocation } from 'react-router-dom'
import http, { apiErrorMessage } from '../api/http'
import { downloadApiFile, importExcel } from '../api/files'
import PageHeader from '../components/PageHeader'
import './base-data.css'
type Kind = 'campuses' | 'colleges' | 'majors' | 'classes' | 'terms' | 'buildings' | 'classrooms' | 'course-categories'
type GroupKey = 'organization' | 'terms' | 'facilities' | 'course-categories'
interface Row { id: string; code: string; name: string; isEnabled: boolean; isCurrent?: boolean; isArchived?: boolean; [key: string]: unknown }
interface Option { id: string; name: string; [key: string]: unknown }
interface PageResult { items: Row[]; total: number }
interface ArchivePreview { id: string; name?: string; isArchived?: boolean; archivedAt?: string; archiveReason?: string; error?: string; blockers?: { message: string }[]; notices?: { message: string }[] }
interface ArchiveResult { id: string; success: boolean; message: string }
interface ArchiveHistory { id: string; isArchived: boolean; actorName?: string; actorUserId?: string; createdAt: string; reason: string }
const tabs: Record<Kind, { label: string; hint: string }> = {
campuses: { label: '校区', hint: '学校的物理校区' }, colleges: { label: '学院', hint: '教学组织单位' },
majors: { label: '专业', hint: '专业与授予学位' }, classes: { label: '行政班', hint: '按年级组织的班级' },
terms: { label: '学期', hint: '教学运行时间轴' }, buildings: { label: '教学楼', hint: '校区内教学建筑' },
classrooms: { label: '教室', hint: '可排课教学空间' }, 'course-categories': { label: '课程分类', hint: '课程所属教学领域' },
}
const groups: Record<GroupKey, { title: string; kicker: string; description: string; kinds: Kind[] }> = {
organization: { title: '组织机构', kicker: 'ORGANIZATION DIRECTORY', description: '维护校区、学院、专业与行政班的稳定组织层级。', kinds: ['campuses', 'colleges', 'majors', 'classes'] },
terms: { title: '学年学期', kicker: 'ACADEMIC CALENDAR', description: '集中维护教学年度、学期边界与当前运行学期。', kinds: ['terms'] },
facilities: { title: '教学场所', kicker: 'TEACHING FACILITIES', description: '按校区和教学楼维护可用于排课的教室资源。', kinds: ['buildings', 'classrooms'] },
'course-categories': { title: '课程分类', kicker: 'COURSE TAXONOMY', description: '统一维护课程教学领域;课程性质与课程分类相互独立。', kinds: ['course-categories'] },
}
const venueOptions = [
{ value: 1, label: '普通教室' }, { value: 2, label: '实验室' }, { value: 4, label: '实训室' },
{ value: 8, label: '计算机机房' }, { value: 16, label: '语音室' }, { value: 32, label: '体育场地' }, { value: 64, label: '艺术场地' },
]
const groupFromPath = (path: string): GroupKey => path.includes('/terms') ? 'terms' : path.includes('/facilities') ? 'facilities' : path.includes('/course-categories') ? 'course-categories' : 'organization'
const venueValue = (value: unknown) => typeof value === 'number' ? value : typeof value === 'string' ? value.split(',').reduce((sum, name) => sum | ({ GeneralClassroom: 1, Laboratory: 2, TrainingRoom: 4, ComputerLab: 8, LanguageLab: 16, SportsVenue: 32, ArtsVenue: 64 }[name.trim()] ?? 0), 0) : 0
const venueLabel = (value: unknown) => venueOptions.filter((item) => (venueValue(value) & item.value) !== 0).map((item) => item.label).join('、') || '未设置'
export default function BaseDataPage() {
const { message, modal } = App.useApp()
const location = useLocation()
const client = useQueryClient()
const fileRef = useRef<HTMLInputElement>(null)
const groupKey = groupFromPath(location.pathname)
const group = groups[groupKey]
const [active, setActive] = useState<Kind>(group.kinds[0])
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [keyword, setKeyword] = useState('')
const [search, setSearch] = useState('')
const [filters, setFilters] = useState<Record<string, unknown>>({})
const [archiveFilter, setArchiveFilter] = useState<boolean>()
const [selectedTerms, setSelectedTerms] = useState<string[]>([])
const [editing, setEditing] = useState<Row | null | undefined>()
const [importing, setImporting] = useState(false)
const [form] = Form.useForm()
const [archive, setArchive] = useState<{ open: boolean; target: boolean; previews: ArchivePreview[]; results: ArchiveResult[]; reason: string; busy: boolean }>({ open: false, target: true, previews: [], results: [], reason: '', busy: false })
const [history, setHistory] = useState<{ open: boolean; page: number; busy: boolean; entries: ArchiveHistory[]; id?: string }>({ open: false, page: 1, busy: false, entries: [] })
const serverPaged = active === 'classes' || active === 'classrooms'
useEffect(() => { setActive(group.kinds[0]); setKeyword(''); setSearch(''); setFilters({}); setPage(1) }, [groupKey])
const references = useQuery({
queryKey: ['base-data-references'],
queryFn: async () => {
const [campuses, colleges, majors, buildings, counselors] = await Promise.all([
http.get<Option[]>('/base-data/campuses'), http.get<Option[]>('/base-data/colleges'), http.get<Option[]>('/base-data/majors'),
http.get<Option[]>('/base-data/buildings'), http.get<Option[]>('/base-data/counselors'),
])
return { campuses: campuses.data, colleges: colleges.data, majors: majors.data, buildings: buildings.data, counselors: counselors.data }
},
})
const rows = useQuery({
queryKey: ['base-data', active, page, pageSize, search, filters],
queryFn: async () => {
const response = await http.get<Row[] | PageResult>(`/base-data/${active}`, { params: serverPaged ? { page, pageSize, keyword: search || undefined, ...filters } : undefined })
return serverPaged ? response.data as PageResult : { items: response.data as Row[], total: (response.data as Row[]).length }
},
})
const visibleRows = useMemo(() => {
if (serverPaged) return rows.data?.items ?? []
const query = search.toLowerCase()
return (rows.data?.items ?? []).filter((row) => (archiveFilter === undefined || row.isArchived === archiveFilter) && (!query || [row.code, row.name, row.collegeName, row.campusName, row.majorName, row.buildingName].some((value) => String(value ?? '').toLowerCase().includes(query))))
}, [archiveFilter, rows.data, search, serverPaged])
const pagedRows = serverPaged ? visibleRows : visibleRows.slice((page - 1) * pageSize, page * pageSize)
const total = serverPaged ? rows.data?.total ?? 0 : visibleRows.length
const ref = references.data ?? { campuses: [], colleges: [], majors: [], buildings: [], counselors: [] }
const filteredMajors = ref.majors.filter((item) => !filters.collegeId || item.collegeId === filters.collegeId)
const filteredBuildings = ref.buildings.filter((item) => !filters.campusId || item.campusId === filters.campusId)
function changeTab(kind: Kind) { setActive(kind); setPage(1); setKeyword(''); setSearch(''); setFilters({}); setArchiveFilter(undefined); setSelectedTerms([]) }
function openEditor(row?: Row) {
const year = new Date().getFullYear()
const values: Record<string, unknown> = { code: '', name: '', sortOrder: 0, isEnabled: true, degreeType: '学士', schoolingYears: 4, grade: year, academicYear: `${year}-${year + 1}`, season: 'Autumn', capacity: 60, floor: 1, roomType: '普通教室', teachingVenueNatures: [1], ...row }
if (active === 'classrooms') values.teachingVenueNatures = venueOptions.filter((item) => (venueValue(row?.teachingVenueNature ?? 1) & item.value) !== 0).map((item) => item.value)
form.setFieldsValue(values); setEditing(row ?? null)
}
async function save(values: Record<string, unknown>) {
try {
const { teachingVenueNatures, ...base } = values
const payload = active === 'classrooms' ? { ...base, teachingVenueNature: (teachingVenueNatures as number[]).reduce((value, item) => value | item, 0) } : base
if (editing?.id) await http.put(`/base-data/${active}/${editing.id}`, payload); else await http.post(`/base-data/${active}`, payload)
message.success(editing?.id ? '已保存修改' : `已新增${tabs[active].label}`); setEditing(undefined)
await Promise.all([client.invalidateQueries({ queryKey: ['base-data'] }), client.invalidateQueries({ queryKey: ['base-data-references'] })])
} catch (error) { message.error(apiErrorMessage(error)) }
}
function remove(row: Row) {
modal.confirm({ title: `删除“${row.name}”?`, content: '已被业务引用的数据将无法删除。', okText: '删除', okButtonProps: { danger: true }, onOk: async () => { try { await http.delete(`/base-data/${active}/${row.id}`); message.success('已删除'); await client.invalidateQueries({ queryKey: ['base-data'] }) } catch (error) { message.error(apiErrorMessage(error)); throw error } } })
}
async function setCurrent(row: Row) {
modal.confirm({ title: `将“${row.name}”切换为当前学期?`, content: '其他学期会转为历史显示,但不会自动归档,也不会冻结成绩和补考。', okText: '切换为当前', onOk: async () => { try { await http.post(`/base-data/terms/${row.id}/set-current`); message.success(`当前学期已切换为“${row.name}”`); await client.invalidateQueries({ queryKey: ['base-data'] }) } catch (error) { message.error(apiErrorMessage(error)); throw error } } })
}
async function handleImport(file?: File) {
if (!file) return
setImporting(true)
try { const response = await importExcel(`/base-data/${active}/import`, file); message.success(`导入完成:新增 ${response.data.created} 条,更新 ${response.data.updated} 条`); await Promise.all([client.invalidateQueries({ queryKey: ['base-data'] }), client.invalidateQueries({ queryKey: ['base-data-references'] })]) } catch (error) { message.error(apiErrorMessage(error)) } finally { setImporting(false); if (fileRef.current) fileRef.current.value = '' }
}
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<ArchivePreview[]>('/archives/terms/preview', { ids, archived: target }); setArchive((state) => ({ ...state, previews: response.data, busy: false })) } catch (error) { message.error(apiErrorMessage(error)); setArchive((state) => ({ ...state, busy: false })) }
}
const eligible = archive.previews.filter((item) => !item.error && !item.blockers?.length && item.isArchived !== archive.target)
async function applyArchive() {
if (!archive.reason.trim() || !eligible.length) return
setArchive((state) => ({ ...state, busy: true }))
try { const response = await http.post<ArchiveResult[]>('/archives/terms/apply', { ids: eligible.map((item) => item.id), archived: archive.target, reason: archive.reason.trim() }); setArchive((state) => ({ ...state, results: response.data, busy: false })); setSelectedTerms([]); await client.invalidateQueries({ queryKey: ['base-data'] }) } 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<ArchiveHistory[]>(`/archives/terms/${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 })) }
}
const columns: ColumnsType<Row> = [
{ title: '编码', dataIndex: 'code', width: 135 }, { title: '名称', dataIndex: 'name', width: 180 },
...(active === 'colleges' ? [{ title: '所属校区', dataIndex: 'campusName', width: 150 }] : []),
...(active === 'majors' ? [{ title: '所属学院', dataIndex: 'collegeName', width: 160 }, { title: '学位类型', dataIndex: 'degreeType', width: 120 }] : []),
...(active === 'classes' ? [{ title: '所属专业', dataIndex: 'majorName', width: 170 }, { title: '年级', dataIndex: 'grade', width: 90 }, { title: '辅导员', dataIndex: 'counselorName', width: 130, render: (value: unknown) => String(value || '未分配') }] : []),
...(active === 'terms' ? [{ title: '学年', dataIndex: 'academicYear', width: 125 }, { title: '运行状态', width: 120, render: (_: unknown, row: Row) => row.isCurrent ? <Tag color="green">当前</Tag> : row.isArchived ? <Tag>已归档</Tag> : <Tag color="blue">历史学期</Tag> }] : []),
...(active === 'buildings' ? [{ title: '所属校区', dataIndex: 'campusName', width: 150 }] : []),
...(active === 'classrooms' ? [{ title: '教学楼', dataIndex: 'buildingName', width: 150 }, { title: '楼层', dataIndex: 'floor', width: 80, render: (value: unknown) => `${value} 层` }, { title: '容量', dataIndex: 'capacity', width: 80 }, { title: '场地性质', dataIndex: 'teachingVenueNature', width: 190, render: venueLabel }] : []),
{ title: '状态', dataIndex: 'isEnabled', width: 90, render: (value: boolean) => <Tag color={value ? 'green' : 'default'}>{value ? '启用' : '停用'}</Tag> },
{ title: '操作', width: active === 'terms' ? 315 : 140, fixed: 'right', render: (_: unknown, row: Row) => <div className="row-actions">{active === 'terms' && <><Button size="small" icon={<HistoryOutlined />} onClick={() => void loadHistory(row.id)}>历史</Button>{!row.isCurrent && !row.isArchived && <Button size="small" onClick={() => void setCurrent(row)}>设为当前</Button>}{!row.isCurrent && <Button size="small" onClick={() => void openArchive([row.id], !row.isArchived)}>{row.isArchived ? '恢复' : '归档'}</Button>}</>}<Button size="small" disabled={active === 'terms' && row.isArchived} onClick={() => openEditor(row)}>编辑</Button>{(active !== 'terms' || !row.isCurrent) && <Button size="small" danger disabled={active === 'terms' && row.isArchived} icon={<DeleteOutlined />} onClick={() => remove(row)}>删除</Button>}</div> },
]
if (references.isLoading && !references.data) return <main className="page-container"><Skeleton active paragraph={{ rows: 9 }} /></main>
return <main className="page-container base-data-page">
<PageHeader kicker={group.kicker} title={group.title} description={group.description} aside={<Button type="primary" icon={<PlusOutlined />} onClick={() => openEditor()}>新增{tabs[active].label}</Button>} />
<section className="base-data-card">
{group.kinds.length > 1 && <nav className="base-tabs">{group.kinds.map((kind) => <button key={kind} className={active === kind ? 'active' : ''} onClick={() => changeTab(kind)}><strong>{tabs[kind].label}</strong><span>{tabs[kind].hint}</span></button>)}</nav>}
<div className="base-toolbar">
{active === 'terms' && <Select allowClear value={archiveFilter} placeholder="全部归档状态" options={[{ value: false, label: '未归档' }, { value: true, label: '已归档' }]} onChange={(value) => { setArchiveFilter(value); setPage(1) }} />}
<Input allowClear value={keyword} placeholder="搜索编码、名称或所属单位" onChange={(event) => setKeyword(event.target.value)} onPressEnter={() => { setSearch(keyword.trim()); setPage(1) }} />
{active === 'classes' && <><Select allowClear value={filters.collegeId} placeholder="全部学院" options={ref.colleges.map((item) => ({ value: item.id, label: item.name }))} onChange={(value) => { setFilters((state) => ({ ...state, collegeId: value, majorId: undefined })); setPage(1) }} /><Select allowClear showSearch value={filters.majorId} placeholder="全部专业" options={filteredMajors.map((item) => ({ value: item.id, label: item.name }))} onChange={(value) => { setFilters((state) => ({ ...state, majorId: value })); setPage(1) }} /><InputNumber min={1900} max={2200} value={filters.grade as number} placeholder="入学年级" onChange={(value) => { setFilters((state) => ({ ...state, grade: value ?? undefined })); setPage(1) }} /></>}
{active === 'classrooms' && <><Select allowClear value={filters.campusId} placeholder="全部校区" options={ref.campuses.map((item) => ({ value: item.id, label: item.name }))} onChange={(value) => { setFilters((state) => ({ ...state, campusId: value, buildingId: undefined })); setPage(1) }} /><Select allowClear showSearch value={filters.buildingId} placeholder="全部教学楼" options={filteredBuildings.map((item) => ({ value: item.id, label: item.name }))} onChange={(value) => { setFilters((state) => ({ ...state, buildingId: value })); setPage(1) }} /><Select allowClear value={filters.teachingVenueNature} placeholder="场地性质" options={venueOptions} onChange={(value) => { setFilters((state) => ({ ...state, teachingVenueNature: value })); setPage(1) }} /><InputNumber min={1} value={filters.minimumCapacity as number} placeholder="最小容量" onChange={(value) => { setFilters((state) => ({ ...state, minimumCapacity: value ?? undefined })); setPage(1) }} /></>}
{serverPaged && <Select allowClear value={filters.isEnabled} placeholder="全部状态" options={[{ value: true, label: '启用' }, { value: false, label: '停用' }]} onChange={(value) => { setFilters((state) => ({ ...state, isEnabled: value })); setPage(1) }} />}
<Button type="primary" onClick={() => { setSearch(keyword.trim()); setPage(1) }}>查询</Button><Button icon={<ReloadOutlined />} onClick={() => void rows.refetch()}>刷新</Button>
<Button onClick={() => void downloadApiFile(`/base-data/${active}/template`, `${tabs[active].label}导入模板.xlsx`).catch((error) => message.error(apiErrorMessage(error)))}>下载模板</Button>
<input ref={fileRef} hidden type="file" accept=".xlsx" onChange={(event) => void handleImport(event.target.files?.[0])} /><Button loading={importing} icon={<UploadOutlined />} onClick={() => fileRef.current?.click()}>Excel 导入</Button>
<Button icon={<DownloadOutlined />} onClick={() => void downloadApiFile(`/base-data/${active}/export`, `${tabs[active].label}.xlsx`, { params: active === 'terms' ? { isArchived: archiveFilter, keyword: search || undefined } : undefined }).then(() => message.success(`${tabs[active].label}已导出`)).catch((error) => message.error(apiErrorMessage(error)))}>导出结果</Button><span>共 {total} 条</span>
</div>
{active === 'terms' && <><Alert type="info" showIcon message="切换当前学期只改变默认工作学期;归档需手动执行,归档后仍可处理补考与成绩更正。" /><div className="archive-batch"><Select mode="multiple" maxTagCount="responsive" value={selectedTerms} placeholder="选择学期" options={visibleRows.map((row) => ({ value: row.id, label: row.name }))} onChange={setSelectedTerms} /><Button disabled={!selectedTerms.length} onClick={() => void openArchive(selectedTerms, true)}>批量归档</Button><Button disabled={!selectedTerms.length} onClick={() => void openArchive(selectedTerms, false)}>批量恢复</Button></div></>}
{rows.isError ? <Alert type="error" showIcon message="基础数据无法加载" description={apiErrorMessage(rows.error)} /> : <Table<Row> rowKey="id" loading={rows.isLoading} dataSource={pagedRows} columns={columns} pagination={false} scroll={{ x: 900 }} locale={{ emptyText: <Empty description="暂无数据,点击右上角开始新增" /> }} />}
{total > pageSize && <Pagination current={page} pageSize={pageSize} total={total} showSizeChanger pageSizeOptions={[20, 50, 100]} onChange={(next, size) => { setPage(next); setPageSize(size) }} />}
</section>
<Modal open={editing !== undefined} title={`${editing?.id ? '编辑' : '新增'}${tabs[active].label}`} okText="保存" onCancel={() => setEditing(undefined)} onOk={() => form.submit()} width={620}><Form form={form} layout="vertical" onFinish={(values) => void save(values)}><div className="base-form-grid"><Form.Item name="code" label="编码" rules={[{ required: true, whitespace: true }]}><Input /></Form.Item><Form.Item name="name" label="名称" rules={[{ required: true, whitespace: true }]}><Input /></Form.Item></div>{active === 'campuses' && <Form.Item name="description" label="地址"><Input /></Form.Item>}{(active === 'colleges' || active === 'buildings') && <Form.Item name="campusId" label="所属校区"><Select allowClear options={ref.campuses.map((item) => ({ value: item.id, label: item.name }))} /></Form.Item>}{active === 'colleges' && <Form.Item name="shortName" label="学院简称"><Input /></Form.Item>}{active === 'majors' && <><Form.Item name="collegeId" label="所属学院" rules={[{ required: true }]}><Select options={ref.colleges.map((item) => ({ value: item.id, label: item.name }))} /></Form.Item><div className="base-form-grid"><Form.Item name="degreeType" label="学位类型"><Input /></Form.Item><Form.Item name="schoolingYears" label="学制"><InputNumber min={1} max={8} /></Form.Item></div></>}{active === 'classes' && <><Form.Item name="majorId" label="所属专业" rules={[{ required: true }]}><Select showSearch options={ref.majors.map((item) => ({ value: item.id, label: item.name }))} /></Form.Item><div className="base-form-grid"><Form.Item name="grade" label="年级"><InputNumber min={2000} max={2200} /></Form.Item><Form.Item name="counselorUserId" label="辅导员账号"><Select allowClear showSearch options={ref.counselors.map((item) => ({ value: item.id, label: `${item.displayName}${item.staffNumber ? ` · ${item.staffNumber}` : ''}` }))} /></Form.Item></div></>}{active === 'terms' && <><div className="base-form-grid"><Form.Item name="academicYear" label="学年"><Input /></Form.Item><Form.Item name="season" label="学期季"><Select options={[{ value: 'Autumn', label: '秋季学期' }, { value: 'Spring', label: '春季学期' }, { value: 'Summer', label: '夏季学期' }]} /></Form.Item></div><div className="base-form-grid"><Form.Item name="startDate" label="开始日期"><Input type="date" /></Form.Item><Form.Item name="endDate" label="结束日期"><Input type="date" /></Form.Item></div><Form.Item name="isCurrent" valuePropName="checked"><Checkbox disabled={!!editing?.isArchived}>设为当前学期</Checkbox></Form.Item></>}{active === 'classrooms' && <><Form.Item name="buildingId" label="所属教学楼" rules={[{ required: true }]}><Select options={ref.buildings.map((item) => ({ value: item.id, label: `${item.campusName} · ${item.name}` }))} /></Form.Item><div className="base-form-grid three"><Form.Item name="capacity" label="容量"><InputNumber min={1} max={1000} /></Form.Item><Form.Item name="floor" label="所在楼层"><InputNumber min={1} max={99} /></Form.Item><Form.Item name="roomType" label="教室类型"><Input /></Form.Item></div><Form.Item name="teachingVenueNatures" label="教学场地性质" rules={[{ required: true }]}><Checkbox.Group options={venueOptions} /></Form.Item><Form.Item name="equipment" label="设备"><Input /></Form.Item></>}<div className="base-form-grid"><Form.Item name="sortOrder" label="排序"><InputNumber min={0} /></Form.Item><Form.Item name="isEnabled" label="状态" valuePropName="checked"><Switch checkedChildren="启用" unCheckedChildren="停用" /></Form.Item></div></Form></Modal>
<Modal open={archive.open} title={archive.target ? '归档预检查' : '恢复预检查'} okText={`${archive.target ? '确认归档' : '确认恢复'}(${eligible.length})`} confirmLoading={archive.busy} okButtonProps={{ disabled: !archive.reason.trim() || !eligible.length || !!archive.results.length }} onOk={() => void applyArchive()} onCancel={() => setArchive((state) => ({ ...state, open: false }))}><p>符合条件 {eligible.length} / {archive.previews.length} 条。提交时会再次检查,受阻记录不执行。</p>{archive.busy && !archive.previews.length ? <Skeleton active /> : archive.previews.map((item) => <article className="archive-preview" key={item.id}><strong>{item.name || '不可操作记录'}</strong>{item.error && <p className="archive-error">{item.error}</p>}{!item.error && <><p>{item.isArchived ? '已归档' : '未归档'}{item.archiveReason ? ` · 原因:${item.archiveReason}` : ''}</p>{item.isArchived === archive.target && <p>已处于目标状态,无需重复操作。</p>}{item.blockers?.map((blocker, index) => <p className="archive-error" key={index}>{blocker.message}</p>)}{item.notices?.map((notice, index) => <p key={index}>{notice.message}</p>)}</>}</article>)}{!archive.results.length ? <label className="archive-reason">{archive.target ? '归档原因(必填)' : '恢复原因(必填)'}<Input.TextArea rows={3} maxLength={500} showCount value={archive.reason} onChange={(event) => setArchive((state) => ({ ...state, reason: event.target.value }))} /></label> : archive.results.map((result) => <Alert key={result.id} type={result.success ? 'success' : 'error'} showIcon message={result.message} />)}</Modal>
<Modal open={history.open} title="归档操作历史" footer={null} onCancel={() => setHistory((state) => ({ ...state, open: false }))}>{history.busy ? <Skeleton active /> : history.entries.length ? history.entries.map((entry) => <article className="archive-preview" key={entry.id}><strong>{entry.isArchived ? '归档' : '恢复'} · {entry.actorName || entry.actorUserId}</strong><p>{new Date(entry.createdAt).toLocaleString('zh-CN', { hour12: false })}</p><p>{entry.reason}</p></article>) : <Empty description="暂无操作历史(旧归档记录可能没有操作日志)" />}<div className="history-pagination"><Button disabled={history.busy || history.page === 1} onClick={() => void loadHistory(history.id!, history.page - 1)}>上一页</Button><span>第 {history.page} 页</span><Button disabled={history.busy || history.entries.length < 20} onClick={() => void loadHistory(history.id!, history.page + 1)}>下一页</Button></div></Modal>
</main>
}
Loaded 3 of 6 files, more files were not shown because too many files have changed in this diff. Show more