From 7f1a6f5804998ccdbe879f498f54e5e14db6bb17 Mon Sep 17 00:00:00 2001 From: biss Date: Fri, 2 Oct 2026 13:54:42 +0800 Subject: [PATCH] feat(web): migrate base data administration --- web-react/MIGRATION_MATRIX.md | 4 + web-react/README.md | 1 + web-react/src/app/App.tsx | 5 + web-react/src/layout/AppShell.tsx | 7 + web-react/src/pages/BaseDataPage.tsx | 195 +++++++++++++++++++++++++++ web-react/src/pages/base-data.css | 1 + 6 files changed, 213 insertions(+) create mode 100644 web-react/src/pages/BaseDataPage.tsx create mode 100644 web-react/src/pages/base-data.css diff --git a/web-react/MIGRATION_MATRIX.md b/web-react/MIGRATION_MATRIX.md index c764e96..4e3afb1 100644 --- a/web-react/MIGRATION_MATRIX.md +++ b/web-react/MIGRATION_MATRIX.md @@ -41,6 +41,10 @@ | `/course-equivalency-rules` | 等价课程规则 | 教务管理 | 规则建立、适用年级、启停与安全删除 | verification | | `/course-enrollments` | 修读课程管理 | 教务管理 | 课程/学生双视图、多维筛选、退选递补与全量导出 | verification | | `/campus-wallets` | 学生余额管理 | 教务管理 | 学生筛选、单笔调整、批量导入、模板与余额导出 | verification | +| `/base-data/organization` | 组织机构 | SuperAdmin、AcademicAdmin | 校区、学院、专业、行政班;筛选、分页、Excel、辅导员关联 | verification | +| `/base-data/terms` | 学年学期 | SuperAdmin、AcademicAdmin | 当前学期切换、手动归档/恢复、预检查、原因和操作历史 | verification | +| `/base-data/facilities` | 教学场所 | SuperAdmin、AcademicAdmin | 教学楼、教室、容量/场地性质筛选、Excel | verification | +| `/base-data/course-categories` | 课程分类 | SuperAdmin、AcademicAdmin | 分类增删改查、启停、排序和 Excel | 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 dd17b2b..772c0f3 100644 --- a/web-react/README.md +++ b/web-react/README.md @@ -43,6 +43,7 @@ npm.cmd run build - 迁移教师选课名单工作台,支持按学期查看本人教学班、学生支持信息和 Excel 导出。 - 迁移教师教学点名工作台,覆盖人工/扫码/定位签到、Excel 导入导出、提交及课程出勤统计。 - 批量迁移教师授课意愿、等价课程规则、修读课程台账和学生余额管理四个教务工作流。 +- 迁移组织机构、学年学期、教学场所与课程分类基础数据,保留 Excel、服务端分页和学期归档审计闭环。 ## 迁移纪律 diff --git a/web-react/src/app/App.tsx b/web-react/src/app/App.tsx index d8721fc..cedbad0 100644 --- a/web-react/src/app/App.tsx +++ b/web-react/src/app/App.tsx @@ -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() { } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> diff --git a/web-react/src/layout/AppShell.tsx b/web-react/src/layout/AppShell.tsx index 2b3fc36..ecb28fb 100644 --- a/web-react/src/layout/AppShell.tsx +++ b/web-react/src/layout/AppShell.tsx @@ -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: , label: '修读课程管理' }, { key: '/campus-wallets', icon: , label: '学生余额管理' }, ] : []), + ...(roles.some((role) => ['SuperAdmin', 'AcademicAdmin'].includes(role)) ? [ + { key: '/base-data/organization', icon: , label: '组织机构' }, + { key: '/base-data/terms', icon: , label: '学年学期' }, + { key: '/base-data/facilities', icon: , label: '教学场所' }, + { key: '/base-data/course-categories', icon: , label: '课程分类' }, + ] : []), ...(user?.roles.includes('SuperAdmin') ? [{ key: '/users', icon: , label: '用户与权限' }] : []), diff --git a/web-react/src/pages/BaseDataPage.tsx b/web-react/src/pages/BaseDataPage.tsx new file mode 100644 index 0000000..2942d6f --- /dev/null +++ b/web-react/src/pages/BaseDataPage.tsx @@ -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 = { + 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 = { + 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(null) + const groupKey = groupFromPath(location.pathname) + const group = groups[groupKey] + const [active, setActive] = useState(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>({}) + const [archiveFilter, setArchiveFilter] = useState() + const [selectedTerms, setSelectedTerms] = useState([]) + const [editing, setEditing] = useState() + 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('/base-data/campuses'), http.get('/base-data/colleges'), http.get('/base-data/majors'), + http.get('/base-data/buildings'), http.get('/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(`/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 = { 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) { + 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('/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('/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(`/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 = [ + { 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 ? 当前 : row.isArchived ? 已归档 : 历史学期 }] : []), + ...(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) => {value ? '启用' : '停用'} }, + { title: '操作', width: active === 'terms' ? 315 : 140, fixed: 'right', render: (_: unknown, row: Row) =>
{active === 'terms' && <>{!row.isCurrent && !row.isArchived && }{!row.isCurrent && }}{(active !== 'terms' || !row.isCurrent) && }
}, + ] + + if (references.isLoading && !references.data) return
+ return
+ } onClick={() => openEditor()}>新增{tabs[active].label}} /> +
+ {group.kinds.length > 1 && } +
+ {active === 'terms' && setKeyword(event.target.value)} onPressEnter={() => { setSearch(keyword.trim()); setPage(1) }} /> + {active === 'classes' && <> ({ value: item.id, label: item.name }))} onChange={(value) => { setFilters((state) => ({ ...state, majorId: value })); setPage(1) }} /> { setFilters((state) => ({ ...state, grade: value ?? undefined })); setPage(1) }} />} + {active === 'classrooms' && <> ({ value: item.id, label: item.name }))} onChange={(value) => { setFilters((state) => ({ ...state, buildingId: value })); setPage(1) }} /> { setFilters((state) => ({ ...state, isEnabled: value })); setPage(1) }} />} + + + void handleImport(event.target.files?.[0])} /> + 共 {total} 条 +
+ {active === 'terms' && <>
{active === 'campuses' && }{(active === 'colleges' || active === 'buildings') && }{active === 'majors' && <>}{active === 'classes' && <> ({ value: item.id, label: `${item.displayName}${item.staffNumber ? ` · ${item.staffNumber}` : ''}` }))} />}{active === 'terms' && <>
设为当前学期}{active === 'classrooms' && <>}
+ void applyArchive()} onCancel={() => setArchive((state) => ({ ...state, open: false }))}>

符合条件 {eligible.length} / {archive.previews.length} 条。提交时会再次检查,受阻记录不执行。

{archive.busy && !archive.previews.length ? : archive.previews.map((item) =>
{item.name || '不可操作记录'}{item.error &&

{item.error}

}{!item.error && <>

{item.isArchived ? '已归档' : '未归档'}{item.archiveReason ? ` · 原因:${item.archiveReason}` : ''}

{item.isArchived === archive.target &&

已处于目标状态,无需重复操作。

}{item.blockers?.map((blocker, index) =>

{blocker.message}

)}{item.notices?.map((notice, index) =>

{notice.message}

)}}
)}{!archive.results.length ? : archive.results.map((result) => )}
+ setHistory((state) => ({ ...state, open: false }))}>{history.busy ? : history.entries.length ? history.entries.map((entry) =>
{entry.isArchived ? '归档' : '恢复'} · {entry.actorName || entry.actorUserId}

{new Date(entry.createdAt).toLocaleString('zh-CN', { hour12: false })}

{entry.reason}

) : }
第 {history.page} 页
+
+} diff --git a/web-react/src/pages/base-data.css b/web-react/src/pages/base-data.css new file mode 100644 index 0000000..2752d87 --- /dev/null +++ b/web-react/src/pages/base-data.css @@ -0,0 +1 @@ +.base-data-page{gap:16px}.base-data-card{min-width:0;padding:16px;border:1px solid var(--line);background:#fff}.base-tabs{display:grid;grid-template-columns:repeat(4,1fr);margin:-16px -16px 16px;border-bottom:1px solid var(--line)}.base-tabs button{padding:13px 16px;text-align:left;border:0;border-right:1px solid var(--line);background:#f7f9fb;cursor:pointer}.base-tabs button:last-child{border-right:0}.base-tabs button.active{box-shadow:inset 0 -3px var(--teal);background:var(--teal-soft)}.base-tabs strong,.base-tabs span{display:block}.base-tabs strong{color:var(--brand-deep);font-size:10px}.base-tabs span{margin-top:3px;color:var(--ink-muted);font-size:8px}.base-toolbar{display:flex;align-items:center;flex-wrap:wrap;gap:7px;margin-bottom:14px}.base-toolbar>.ant-input-affix-wrapper{width:245px}.base-toolbar>.ant-select{min-width:145px}.base-toolbar>.ant-input-number{width:125px}.base-toolbar>span:last-child{color:var(--ink-muted);font-size:9px}.base-data-card>.ant-alert{margin-bottom:12px}.base-data-card>.ant-pagination{display:flex;justify-content:flex-end;margin-top:15px}.archive-batch{display:flex;gap:8px;margin-bottom:12px}.archive-batch>.ant-select{width:280px}.row-actions{display:flex;flex-wrap:wrap;gap:5px}.base-form-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}.base-form-grid.three{grid-template-columns:repeat(3,1fr)}.base-form-grid .ant-input-number{width:100%}.archive-preview{padding:11px 0;border-bottom:1px solid var(--line)}.archive-preview p{margin:5px 0;color:var(--ink-muted);line-height:1.55}.archive-preview .archive-error{color:#b42318}.archive-reason{display:grid;gap:7px;margin-top:14px;font-weight:600}.history-pagination{display:flex;align-items:center;justify-content:center;gap:12px;margin-top:16px}@media(max-width:800px){.base-tabs{grid-template-columns:repeat(2,1fr)}.base-toolbar{align-items:stretch;flex-direction:column}.base-toolbar>*{width:100%!important}.archive-batch{flex-direction:column}.archive-batch>.ant-select{width:100%}.base-form-grid,.base-form-grid.three{grid-template-columns:1fr}}@media(max-width:520px){.base-tabs{grid-template-columns:1fr}.base-tabs button{border-right:0;border-bottom:1px solid var(--line)}}