From 72f61093a151e50d539db608dcc8fd7f7889fefe Mon Sep 17 00:00:00 2001 From: biss Date: Fri, 2 Oct 2026 16:48:01 +0800 Subject: [PATCH] feat(web): migrate personnel registries --- web-react/MIGRATION_MATRIX.md | 1 + web-react/README.md | 1 + web-react/src/app/App.tsx | 3 + web-react/src/layout/AppShell.tsx | 4 + web-react/src/pages/PersonnelRegistryPage.tsx | 109 ++++++++++++++++++ web-react/src/pages/personnel-registry.css | 1 + 6 files changed, 119 insertions(+) create mode 100644 web-react/src/pages/PersonnelRegistryPage.tsx create mode 100644 web-react/src/pages/personnel-registry.css diff --git a/web-react/MIGRATION_MATRIX.md b/web-react/MIGRATION_MATRIX.md index cffa415..bb225dc 100644 --- a/web-react/MIGRATION_MATRIX.md +++ b/web-react/MIGRATION_MATRIX.md @@ -47,6 +47,7 @@ | `/base-data/course-categories` | 课程分类 | SuperAdmin、AcademicAdmin | 分类增删改查、启停、排序和 Excel | verification | | `/courses` | 课程库 | 已登录用户;教务与学院管理员可维护 | 游标分页、数据范围、先修关系、分类/性质、Excel、归档审计 | verification | | `/curriculum` | 培养方案管理 | SuperAdmin、AcademicAdmin、CollegeAdmin | 版本、模块、课程、课程组、发布替换与已发布受控编辑 | verification | +| `/teachers`、`/students` | 教师/学生档案 | 教务、学院、辅导员;管理员可写 | 服务端分页、数据范围、完整档案、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 af357b9..0c800c6 100644 --- a/web-react/README.md +++ b/web-react/README.md @@ -46,6 +46,7 @@ npm.cmd run build - 迁移组织机构、学年学期、教学场所与课程分类基础数据,保留 Excel、服务端分页和学期归档审计闭环。 - 迁移课程库游标台账,保留学院维护范围、先修关系、筛选导出、批量导入与归档审计。 - 迁移培养方案管理,覆盖版本复制、模块与课程编制、公共课程组、发布替换和已发布方案受控调整。 +- 迁移教师与学生档案名册,覆盖分页筛选、完整个人资料、Excel、教师账号激活和人员归档审计。 ## 迁移纪律 diff --git a/web-react/src/app/App.tsx b/web-react/src/app/App.tsx index a65f588..a817e45 100644 --- a/web-react/src/app/App.tsx +++ b/web-react/src/app/App.tsx @@ -45,6 +45,7 @@ const CourseEnrollmentManagementPage = lazy(() => import('../pages/CourseEnrollm const BaseDataPage = lazy(() => import('../pages/BaseDataPage')) const CourseCataloguePage = lazy(() => import('../pages/CourseCataloguePage')) const CurriculumManagementPage = lazy(() => import('../pages/CurriculumManagementPage')) +const PersonnelRegistryPage = lazy(() => import('../pages/PersonnelRegistryPage')) const AccountActivationPage = lazy(() => import('../pages/AccountActivationPage')) const OfficialDocumentVerifyPage = lazy(() => import('../pages/OfficialDocumentVerifyPage')) const ClassroomRepairReportPage = lazy(() => import('../pages/ClassroomRepairReportPage')) @@ -135,6 +136,8 @@ function AppRoutes() { } /> } /> } /> + } /> + } /> } /> } /> diff --git a/web-react/src/layout/AppShell.tsx b/web-react/src/layout/AppShell.tsx index 6ee350c..2ff8120 100644 --- a/web-react/src/layout/AppShell.tsx +++ b/web-react/src/layout/AppShell.tsx @@ -123,6 +123,10 @@ export default function AppShell() { { key: '/base-data/facilities', icon: , label: '教学场所' }, { key: '/base-data/course-categories', icon: , label: '课程分类' }, ] : []), + ...(roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor'].includes(role)) ? [ + { key: '/teachers', icon: , label: '教师档案' }, + { key: '/students', icon: , label: '学生档案' }, + ] : []), ...(user?.roles.includes('SuperAdmin') ? [{ key: '/users', icon: , label: '用户与权限' }] : []), diff --git a/web-react/src/pages/PersonnelRegistryPage.tsx b/web-react/src/pages/PersonnelRegistryPage.tsx new file mode 100644 index 0000000..d572e58 --- /dev/null +++ b/web-react/src/pages/PersonnelRegistryPage.tsx @@ -0,0 +1,109 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { CalendarOutlined, DeleteOutlined, DownloadOutlined, HistoryOutlined, KeyOutlined, PlusOutlined, ReloadOutlined, UploadOutlined } from '@ant-design/icons' +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 http, { apiErrorMessage } from '../api/http' +import { downloadApiFile, importExcel } from '../api/files' +import PageHeader from '../components/PageHeader' +import { useAuthStore } from '../stores/auth' +import './personnel-registry.css' + +type Registry = 'teachers' | 'students' +interface Option { id: string; name: string; collegeId?: string; majorId?: string; collegeName?: string; majorName?: string; [key: string]: unknown } +interface Person { id: string; name: string; gender: string; status: string; userId?: string; isArchived: boolean; teacherNumber?: string; studentNumber?: string; [key: string]: unknown } +interface PageResult { items: Person[]; total: number; page: number; pageSize: number } +interface Filters { keyword: string; collegeId?: string; majorId?: string; classId?: string; enrollmentYear?: number; status?: string; isArchived?: boolean } +interface ArchivePreview { id: string; name?: string; isArchived?: boolean; 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 genderLabels: Record = { Unknown: '未设置', Male: '男', Female: '女' } +const teacherStatuses: Record = { Active: '在职', OnLeave: '离岗', Retired: '退休', Departed: '离职' } +const studentStatuses: Record = { Active: '在籍', Suspended: '休学', Graduated: '毕业', Withdrawn: '退学' } +const specialTags = ['肢体残疾', '行动不便', '轮椅使用者', '视力障碍', '听力障碍', '言语障碍', '学习支持', '健康关注'] +const nationalitySuggestions = ['中国', '中国香港', '中国澳门', '中国台湾', '美国', '英国', '加拿大', '澳大利亚', '新西兰', '日本', '韩国', '新加坡', '马来西亚', '法国', '德国', '俄罗斯', '其他'] +const ethnicitySuggestions = ['汉族', '蒙古族', '回族', '藏族', '维吾尔族', '苗族', '彝族', '壮族', '布依族', '朝鲜族', '满族', '侗族', '瑶族', '白族', '土家族', '哈尼族', '哈萨克族', '傣族', '黎族'] +const politicalSuggestions = ['中共党员', '中共预备党员', '共青团员', '民主党派', '无党派人士', '群众'] +const splitTags = (value: unknown) => typeof value === 'string' ? value.split(/[,,;;、]/).map((item) => item.trim()).filter(Boolean) : Array.isArray(value) ? value : [] + +export default function PersonnelRegistryPage() { + const { message, modal } = App.useApp() + const location = useLocation() + const client = useQueryClient() + const user = useAuthStore((state) => state.user) + const fileRef = useRef(null) + const active: Registry = location.pathname.endsWith('/students') ? 'students' : 'teachers' + const roles = user?.roles ?? [] + const canManage = roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role)) + const canExport = roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor'].includes(role)) + const scopedCollegeId = roles.some((role) => ['CollegeAdmin', 'Counselor'].includes(role)) ? user?.collegeId : undefined + const initialFilters: Filters = { keyword: '', collegeId: scopedCollegeId, isArchived: false } + const [draft, setDraft] = useState(initialFilters) + const [filters, setFilters] = useState(initialFilters) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + const [selectedIds, setSelectedIds] = useState([]) + const [editing, setEditing] = useState() + const [activating, setActivating] = useState() + const [importing, setImporting] = useState(false) + const [form] = Form.useForm(); const [activationForm] = Form.useForm() + const nationality = Form.useWatch('nationality', form) + 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: [] }) + useEffect(() => { const reset = { keyword: '', collegeId: scopedCollegeId, isArchived: false }; setDraft(reset); setFilters(reset); setPage(1); setSelectedIds([]); setEditing(undefined) }, [active, scopedCollegeId]) + + const references = useQuery({ queryKey: ['personnel-references'], queryFn: async () => { const [colleges, majors, classes] = await Promise.all([http.get('/base-data/colleges'), http.get('/base-data/majors'), http.get('/base-data/classes')]); return { colleges: colleges.data, majors: majors.data, classes: classes.data } } }) + const people = useQuery({ queryKey: ['personnel', active, page, pageSize, filters], queryFn: async () => { const params: Record = { page, pageSize, isArchived: filters.isArchived, keyword: filters.keyword || undefined, collegeId: filters.collegeId }; if (active === 'teachers') params.teacherStatus = filters.status; else Object.assign(params, { majorId: filters.majorId, classId: filters.classId, enrollmentYear: filters.enrollmentYear, studentStatus: filters.status }); return (await http.get(`/personnel/${active}`, { params })).data } }) + const refs = references.data ?? { colleges: [], majors: [], classes: [] } + const availableColleges = scopedCollegeId ? refs.colleges.filter((item) => item.id === scopedCollegeId) : refs.colleges + const availableMajors = scopedCollegeId ? refs.majors.filter((item) => item.collegeId === scopedCollegeId) : refs.majors + const majorIds = useMemo(() => new Set(availableMajors.map((item) => item.id)), [availableMajors]) + const availableClasses = refs.classes.filter((item) => item.majorId && majorIds.has(item.majorId)) + const filteredMajors = availableMajors.filter((item) => !draft.collegeId || item.collegeId === draft.collegeId) + const filteredClasses = availableClasses.filter((item) => (!draft.majorId || item.majorId === draft.majorId) && (!draft.collegeId || availableMajors.find((major) => major.id === item.majorId)?.collegeId === draft.collegeId)) + const title = active === 'teachers' ? '教师档案' : '学生档案'; const numberLabel = active === 'teachers' ? '工号' : '学号'; const statuses = active === 'teachers' ? teacherStatuses : studentStatuses + const eligible = archive.previews.filter((item) => !item.error && !item.blockers?.length && item.isArchived !== archive.target) + const refresh = () => client.invalidateQueries({ queryKey: ['personnel', active] }) + function applyFilters() { setPage(1); setSelectedIds([]); setFilters({ ...draft }) } + function resetFilters() { const reset = { ...initialFilters }; setDraft(reset); setFilters(reset); setPage(1); setSelectedIds([]) } + function openEditor(row?: Person) { + const year = new Date().getFullYear() + const values = active === 'teachers' ? { teacherNumber: '', name: '', gender: 'Unknown', collegeId: scopedCollegeId, title: '', status: 'Active', hireDate: '', isExternal: false, phone: '', email: '', notes: '', ...row } : { studentNumber: '', name: '', gender: 'Unknown', administrativeClassId: undefined, enrollmentYear: year, enrollmentDate: `${year}-09-01`, status: 'Active', dateOfBirth: '', englishName: '', idCardNumber: '', nationality: '中国', ethnicity: '', politicalStatus: '', nativePlace: '', householdAddress: '', currentAddress: '', postalCode: '', phone: '', email: '', qq: '', weChat: '', emergencyContactName: '', emergencyContactRelationship: '', emergencyContactPhone: '', specialNeeds: '', biography: '', notes: '', ...row, specialTags: splitTags(row?.specialTags) } + form.setFieldsValue(values); setEditing(row ?? null) + } + async function save(values: Record) { try { const payload = active === 'students' ? { ...values, ethnicity: values.nationality === '中国' ? values.ethnicity : '', politicalStatus: values.nationality === '中国' ? values.politicalStatus : '', nativePlace: values.nationality === '中国' ? values.nativePlace : '', specialTags: (values.specialTags as string[] | undefined)?.join(',') ?? '' } : values; const path = `/personnel/${active}`; if (editing?.id) await http.put(`${path}/${editing.id}`, payload); else await http.post(path, payload); message.success(editing?.id ? '档案已更新' : '档案已建立'); setEditing(undefined); await refresh() } catch (error) { message.error(apiErrorMessage(error)) } } + function remove(row: Person) { modal.confirm({ title: `删除“${row.name}”的${title}?`, content: '已关联登录账号或业务记录的档案可能无法删除。', okText: '删除', okButtonProps: { danger: true }, onOk: async () => { try { await http.delete(`/personnel/${active}/${row.id}`); message.success('档案已删除'); await refresh() } catch (error) { message.error(apiErrorMessage(error)); throw error } } }) } + async function activate(values: { password: string; confirmPassword: string }) { if (!activating) return; if (values.password !== values.confirmPassword) return message.warning('两次输入的密码不一致。'); try { const response = await http.post(`/personnel/teachers/${activating.id}/activate-account`, { password: values.password }); message.success(`教师账号 ${response.data.userName} 已激活`); setActivating(undefined); await refresh() } catch (error) { message.error(apiErrorMessage(error)) } } + async function handleImport(file?: File) { if (!file) return; setImporting(true); try { const response = await importExcel(`/personnel/${active}/import`, file); message.success(`导入完成:新增 ${response.data.created} 条,更新 ${response.data.updated} 条`); await refresh() } catch (error) { message.error(apiErrorMessage(error)) } finally { setImporting(false); if (fileRef.current) fileRef.current.value = '' } } + function exportParams() { const params: Record = { isArchived: filters.isArchived, keyword: filters.keyword || undefined, collegeId: filters.collegeId }; if (active === 'teachers') params.teacherStatus = filters.status; else Object.assign(params, { majorId: filters.majorId, classId: filters.classId, enrollmentYear: filters.enrollmentYear, studentStatus: filters.status }); return params } + 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}`) } + + const columns: ColumnsType = [ + { title: '归档状态', width: 105, render: (_, row) => {row.isArchived ? '已归档' : '未归档'} }, + { title: numberLabel, width: 130, render: (_, row) => {active === 'teachers' ? row.teacherNumber : row.studentNumber} }, + { title: '姓名', width: 120, render: (_, row) =>
{row.name}{genderLabels[row.gender] ?? row.gender}
}, + ...(active === 'teachers' ? [{ title: '所属学院', dataIndex: 'collegeName', width: 150 }, { title: '职称', dataIndex: 'title', width: 110, render: (value: unknown) => String(value || '—') }, { title: '教师类别', dataIndex: 'isExternal', width: 100, render: (value: boolean) => value ? '外聘' : '校内' }] : [{ title: '行政班', dataIndex: 'className', width: 150 }, { title: '专业', dataIndex: 'majorName', width: 160 }, { title: '入学年级', dataIndex: 'enrollmentYear', width: 100 }]), + { title: '状态', dataIndex: 'status', width: 90, render: (value: string) => {statuses[value] ?? value} }, + { title: '登录账号', dataIndex: 'userId', width: 100, render: (value?: string) => {value ? '已激活' : '未激活'} }, + { title: '联系方式', width: 165, render: (_, row) => String(row.phone || row.email || '—') }, + ...(canManage ? [{ title: '操作', width: active === 'teachers' ? 355 : 245, fixed: 'right' as const, render: (_: unknown, row: Person) =>
{active === 'teachers' && !row.userId && }{active === 'teachers' && }
}] : []), + ] + const rowSelection: TableRowSelection | undefined = canManage ? { selectedRowKeys: selectedIds, onChange: (keys) => setSelectedIds(keys.map(String)) } : undefined + + if (references.isLoading) return
+ if (references.isError) return
+ return
+ } onClick={() => openEditor()}>新增{title} : undefined} /> +
{active === 'teachers' ? 'STAFF DIRECTORY' : 'STUDENT DIRECTORY'}{active === 'teachers' ? '工号是教师身份与教学任务的唯一索引' : '学号贯穿学籍、选课、成绩与毕业业务'}当前筛选结果会原样用于 Excel 导出
当前结果{people.data?.total ?? 0}条档案
+
setDraft((state) => ({ ...state, keyword: event.target.value }))} onPressEnter={applyFilters} /> ({ value: item.id, label: item.name }))} onChange={(value) => setDraft((state) => ({ ...state, majorId: value, classId: undefined }))} /> setDraft((state) => ({ ...state, enrollmentYear: value ?? undefined }))} /> setDraft((state) => ({ ...state, isArchived: value }))} /> void handleImport(event.target.files?.[0])} />}{canExport && }
{canManage &&
已选 {selectedIds.length} 条(当前页)
}{people.isError ? : rowKey="id" loading={people.isLoading} rowSelection={rowSelection} dataSource={people.data?.items} columns={columns} pagination={false} scroll={{ x: 1150 }} locale={{ emptyText: }} />} { setPage(next); setPageSize(size); setSelectedIds([]) }} />
+ setEditing(undefined)} onOk={() => form.submit()}>
void save(values)}>{active === 'teachers' ? <>
({ value, label }))} />
: <>
({ value, label }))} />
个人资料
({ value }))} placeholder="可输入任意国籍" /> ({ value }))} />
({ value }))} />
+ setActivating(undefined)} onOk={() => activationForm.submit()}>
void activate(values)}>
({ validator: (_, value) => !value || getFieldValue('password') === value ? Promise.resolve() : Promise.reject(new Error('两次输入的密码不一致')) })]}>
+ 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/personnel-registry.css b/web-react/src/pages/personnel-registry.css new file mode 100644 index 0000000..6e2bc04 --- /dev/null +++ b/web-react/src/pages/personnel-registry.css @@ -0,0 +1 @@ +.personnel-page{gap:16px}.registry-banner{display:grid;grid-template-columns:1fr 180px;border:1px solid var(--line);background:#fff}.registry-banner>div{padding:14px 18px}.registry-banner>div:last-child{border-left:3px solid var(--teal);background:var(--teal-soft)}.registry-banner span,.registry-banner strong,.registry-banner small{display:block}.registry-banner span{color:var(--teal);font:700 8px var(--font-data)}.registry-banner strong{margin:5px 0;color:var(--brand-deep);font-size:13px}.registry-banner>div:last-child strong{font:700 22px var(--font-data)}.registry-banner small{color:var(--ink-muted);font-size:8px}.registry-card{min-width:0;padding:16px;border:1px solid var(--line);background:#fff}.registry-filters,.registry-batch,.registry-actions{display:flex;align-items:center;flex-wrap:wrap;gap:7px}.registry-filters{margin-bottom:12px}.registry-filters>.ant-input-affix-wrapper{width:225px}.registry-filters>.ant-select{width:145px}.registry-filters>.ant-input-number{width:125px}.registry-batch{margin-bottom:12px}.registry-batch span{color:var(--ink-muted);font-size:8px}.registry-card>.ant-pagination{display:flex;justify-content:flex-end;margin-top:15px}.registry-number{color:var(--brand-deep);font:700 9px var(--font-data)}.registry-name{display:grid;gap:3px}.registry-name span{color:var(--ink-muted);font-size:8px}.registry-form-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}.registry-form-grid .ant-input-number{width:100%}.registry-archive{padding:11px 0;border-bottom:1px solid var(--line)}.registry-archive p{margin:5px 0;color:var(--ink-muted);line-height:1.55}.registry-archive .error{color:#b42318}.registry-reason{display:grid;gap:7px;margin-top:14px;font-weight:600}@media(max-width:850px){.registry-filters{align-items:stretch;flex-direction:column}.registry-filters>*{width:100%!important}}@media(max-width:600px){.registry-banner{grid-template-columns:1fr}.registry-banner>div:last-child{border-top:1px solid var(--line);border-left:3px solid var(--teal)}.registry-batch{align-items:stretch;flex-direction:column}.registry-batch>*{width:100%}.registry-form-grid{grid-template-columns:1fr}}