feat(web): migrate personnel registries

This commit is contained in:
biss authored and biss committed 2026-10-03 21:38:14 +08:00
1 parent dc949bb6ea
commit a0f1d65a59
6 files changed
+119

No files matched your search

+3
View File
@@ -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() {
<Route path="/base-data/course-categories" element={<BaseDataPage />} />
<Route path="/courses" element={<CourseCataloguePage />} />
<Route path="/curriculum" element={<CurriculumManagementPage />} />
<Route path="/teachers" element={<PersonnelRegistryPage />} />
<Route path="/students" element={<PersonnelRegistryPage />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Route>
<Route path="*" element={<Navigate to="/dashboard" replace />} />
+4
View File
@@ -123,6 +123,10 @@ export default function AppShell() {
{ key: '/base-data/facilities', icon: <EnvironmentOutlined />, label: '教学场所' },
{ key: '/base-data/course-categories', icon: <SettingOutlined />, label: '课程分类' },
] : []),
...(roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor'].includes(role)) ? [
{ key: '/teachers', icon: <TeamOutlined />, label: '教师档案' },
{ key: '/students', icon: <IdcardOutlined />, label: '学生档案' },
] : []),
...(user?.roles.includes('SuperAdmin')
? [{ key: '/users', icon: <SafetyCertificateOutlined />, label: '用户与权限' }]
: []),
@@ -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<string, string> = { Unknown: '未设置', Male: '男', Female: '女' }
const teacherStatuses: Record<string, string> = { Active: '在职', OnLeave: '离岗', Retired: '退休', Departed: '离职' }
const studentStatuses: Record<string, string> = { 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<HTMLInputElement>(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<Filters>(initialFilters)
const [filters, setFilters] = useState<Filters>(initialFilters)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [editing, setEditing] = useState<Person | null | undefined>()
const [activating, setActivating] = useState<Person>()
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<Option[]>('/base-data/colleges'), http.get<Option[]>('/base-data/majors'), http.get<Option[]>('/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<string, unknown> = { 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<PageResult>(`/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<string, unknown>) { 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<string, unknown> = { 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<ArchivePreview[]>(`/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<ArchiveResult[]>(`/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<ArchiveHistory[]>(`/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<Person> = [
{ title: '归档状态', width: 105, render: (_, row) => <Tag color={row.isArchived ? 'default' : 'green'}>{row.isArchived ? '已归档' : '未归档'}</Tag> },
{ title: numberLabel, width: 130, render: (_, row) => <span className="registry-number">{active === 'teachers' ? row.teacherNumber : row.studentNumber}</span> },
{ title: '姓名', width: 120, render: (_, row) => <div className="registry-name"><strong>{row.name}</strong><span>{genderLabels[row.gender] ?? row.gender}</span></div> },
...(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) => <Tag color={value === 'Active' ? 'green' : 'default'}>{statuses[value] ?? value}</Tag> },
{ title: '登录账号', dataIndex: 'userId', width: 100, render: (value?: string) => <Tag color={value ? 'green' : 'default'}>{value ? '已激活' : '未激活'}</Tag> },
{ 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) => <div className="registry-actions">{active === 'teachers' && !row.userId && <Button size="small" icon={<KeyOutlined />} disabled={row.status !== 'Active'} onClick={() => { activationForm.resetFields(); setActivating(row) }}>激活</Button>}{active === 'teachers' && <Button size="small" icon={<CalendarOutlined />} disabled={row.status !== 'Active'} onClick={() => viewTimetable(row)}>课表</Button>}<Button size="small" onClick={() => void openArchive([row.id], !row.isArchived)}>{row.isArchived ? '恢复' : '归档'}</Button><Button size="small" icon={<HistoryOutlined />} onClick={() => void loadHistory(row.id)}>历史</Button><Button size="small" disabled={row.isArchived} onClick={() => openEditor(row)}>编辑</Button><Button size="small" danger icon={<DeleteOutlined />} disabled={row.isArchived} onClick={() => remove(row)}>删除</Button></div> }] : []),
]
const rowSelection: TableRowSelection<Person> | undefined = canManage ? { selectedRowKeys: selectedIds, onChange: (keys) => setSelectedIds(keys.map(String)) } : undefined
if (references.isLoading) return <main className="page-container"><Skeleton active paragraph={{ rows: 9 }} /></main>
if (references.isError) return <main className="page-container"><Alert type="error" showIcon message="名册筛选数据无法加载" description={apiErrorMessage(references.error)} /></main>
return <main className="page-container personnel-page">
<PageHeader kicker={active === 'teachers' ? 'TEACHING STAFF' : 'STUDENT ROLL'} title={title} description={active === 'teachers' ? '以工号维护教师归属、职称、任职状态与联系信息。' : '以学号维护学生班级归属、入学信息与学籍状态。'} actions={canManage ? <Button type="primary" icon={<PlusOutlined />} onClick={() => openEditor()}>新增{title}</Button> : undefined} />
<section className={`registry-banner ${active}`}><div><span>{active === 'teachers' ? 'STAFF DIRECTORY' : 'STUDENT DIRECTORY'}</span><strong>{active === 'teachers' ? '工号是教师身份与教学任务的唯一索引' : '学号贯穿学籍、选课、成绩与毕业业务'}</strong><small>当前筛选结果会原样用于 Excel 导出</small></div><div><span>当前结果</span><strong>{people.data?.total ?? 0}</strong><small>条档案</small></div></section>
<section className="registry-card"><div className="registry-filters"><Input allowClear value={draft.keyword} placeholder={`搜索${numberLabel}、姓名${active === 'teachers' ? '或职称' : ''}`} onChange={(event) => setDraft((state) => ({ ...state, keyword: event.target.value }))} onPressEnter={applyFilters} /><Select allowClear={!scopedCollegeId} disabled={!!scopedCollegeId} value={draft.collegeId} placeholder="全部学院" options={availableColleges.map((item) => ({ value: item.id, label: item.name }))} onChange={(value) => setDraft((state) => ({ ...state, collegeId: value, majorId: undefined, classId: undefined }))} />{active === 'students' && <><Select allowClear showSearch value={draft.majorId} placeholder="全部专业" options={filteredMajors.map((item) => ({ value: item.id, label: item.name }))} onChange={(value) => setDraft((state) => ({ ...state, majorId: value, classId: undefined }))} /><InputNumber min={2000} max={2200} value={draft.enrollmentYear} placeholder="入学年级" onChange={(value) => setDraft((state) => ({ ...state, enrollmentYear: value ?? undefined }))} /><Select allowClear showSearch value={draft.classId} placeholder="全部班级" options={filteredClasses.map((item) => ({ value: item.id, label: item.name }))} onChange={(value) => setDraft((state) => ({ ...state, classId: value }))} /></>}<Select allowClear value={draft.isArchived} placeholder="全部归档状态" options={[{ value: false, label: '未归档' }, { value: true, label: '已归档' }]} onChange={(value) => setDraft((state) => ({ ...state, isArchived: value }))} /><Select allowClear value={draft.status} placeholder="全部状态" options={Object.entries(statuses).map(([value, label]) => ({ value, label }))} onChange={(value) => setDraft((state) => ({ ...state, status: value }))} /><Button type="primary" onClick={applyFilters}>查询</Button><Button icon={<ReloadOutlined />} onClick={resetFilters}>重置</Button>{canManage && <><Button onClick={() => void downloadApiFile(`/personnel/${active}/template`, `${title}导入模板.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 icon={<UploadOutlined />} loading={importing} onClick={() => fileRef.current?.click()}>Excel 导入</Button></>}{canExport && <Button icon={<DownloadOutlined />} onClick={() => void downloadApiFile(`/personnel/${active}/export`, `${title}.xlsx`, { params: exportParams() }).then(() => message.success(`${title}已导出`)).catch((error) => message.error(apiErrorMessage(error)))}>导出结果</Button>}</div>{canManage && <div className="registry-batch"><Button disabled={!selectedIds.length} onClick={() => void openArchive(selectedIds, true)}>批量归档</Button><Button disabled={!selectedIds.length} onClick={() => void openArchive(selectedIds, false)}>批量恢复</Button><span>已选 {selectedIds.length} 条(当前页)</span></div>}{people.isError ? <Alert type="error" showIcon message={`${title}无法加载`} description={apiErrorMessage(people.error)} /> : <Table<Person> rowKey="id" loading={people.isLoading} rowSelection={rowSelection} dataSource={people.data?.items} columns={columns} pagination={false} scroll={{ x: 1150 }} locale={{ emptyText: <Empty description="没有符合条件的档案" /> }} />}<Pagination current={page} pageSize={pageSize} total={people.data?.total ?? 0} showSizeChanger pageSizeOptions={[20, 50, 100]} onChange={(next, size) => { setPage(next); setPageSize(size); setSelectedIds([]) }} /></section>
<Modal open={editing !== undefined} title={`${editing?.id ? '编辑' : '新增'}${title}`} width={860} okText="保存档案" onCancel={() => setEditing(undefined)} onOk={() => form.submit()}><Form form={form} layout="vertical" onFinish={(values) => void save(values)}>{active === 'teachers' ? <><div className="registry-form-grid"><Form.Item name="teacherNumber" label="工号" rules={[{ required: true, whitespace: true }]}><Input /></Form.Item><Form.Item name="name" label="姓名" rules={[{ required: true, whitespace: true }]}><Input /></Form.Item></div><div className="registry-form-grid"><Form.Item name="collegeId" label="所属学院" rules={[{ required: true }]}><Select options={availableColleges.map((item) => ({ value: item.id, label: item.name }))} /></Form.Item><Form.Item name="title" label="职称"><Input /></Form.Item></div><div className="registry-form-grid"><Form.Item name="gender" label="性别"><Select options={Object.entries(genderLabels).map(([value, label]) => ({ value, label }))} /></Form.Item><Form.Item name="status" label="任职状态"><Select options={Object.entries(teacherStatuses).map(([value, label]) => ({ value, label }))} /></Form.Item></div><div className="registry-form-grid"><Form.Item name="hireDate" label="入职日期"><Input type="date" /></Form.Item><Form.Item name="isExternal" label="教师类别" valuePropName="checked"><Switch checkedChildren="外聘" unCheckedChildren="校内" /></Form.Item></div></> : <><div className="registry-form-grid"><Form.Item name="studentNumber" label="学号" rules={[{ required: true, whitespace: true }]}><Input /></Form.Item><Form.Item name="name" label="姓名" rules={[{ required: true, whitespace: true }]}><Input /></Form.Item></div><Form.Item name="administrativeClassId" label="行政班" rules={[{ required: true }]}><Select showSearch options={availableClasses.map((item) => ({ value: item.id, label: `${item.collegeName} · ${item.majorName} · ${item.name}` }))} /></Form.Item><div className="registry-form-grid"><Form.Item name="enrollmentYear" label="入学年级"><InputNumber min={2000} max={2200} /></Form.Item><Form.Item name="enrollmentDate" label="入学日期"><Input type="date" /></Form.Item></div><div className="registry-form-grid"><Form.Item name="gender" label="性别"><Select options={Object.entries(genderLabels).map(([value, label]) => ({ value, label }))} /></Form.Item><Form.Item name="status" label="学籍状态"><Select options={Object.entries(studentStatuses).map(([value, label]) => ({ value, label }))} /></Form.Item></div><Form.Item name="dateOfBirth" label="出生日期"><Input type="date" /></Form.Item><Divider titlePlacement="start">个人资料</Divider><div className="registry-form-grid"><Form.Item name="englishName" label="英文姓名"><Input /></Form.Item><Form.Item name="idCardNumber" label="证件号码"><Input /></Form.Item></div><div className="registry-form-grid"><Form.Item name="nationality" label="国籍"><AutoComplete options={nationalitySuggestions.map((value) => ({ value }))} placeholder="可输入任意国籍" /></Form.Item><Form.Item name="ethnicity" label="民族"><AutoComplete disabled={nationality !== '中国'} options={ethnicitySuggestions.map((value) => ({ value }))} /></Form.Item></div><div className="registry-form-grid"><Form.Item name="politicalStatus" label="政治面貌"><AutoComplete disabled={nationality !== '中国'} options={politicalSuggestions.map((value) => ({ value }))} /></Form.Item><Form.Item name="nativePlace" label="籍贯"><Input disabled={nationality !== '中国'} placeholder="省 / 市 / 区县" /></Form.Item></div><div className="registry-form-grid"><Form.Item name="householdAddress" label="户籍地址"><Input /></Form.Item><Form.Item name="currentAddress" label="现居住地址"><Input /></Form.Item></div><div className="registry-form-grid"><Form.Item name="postalCode" label="邮政编码" rules={[{ pattern: /^\d{6}$/, message: '请输入 6 位邮政编码' }]}><Input maxLength={6} /></Form.Item><Form.Item name="weChat" label="微信"><Input /></Form.Item></div><div className="registry-form-grid"><Form.Item name="qq" label="QQ"><Input /></Form.Item><Form.Item name="emergencyContactName" label="紧急联系人"><Input /></Form.Item></div><div className="registry-form-grid"><Form.Item name="emergencyContactRelationship" label="与本人关系"><Input /></Form.Item><Form.Item name="emergencyContactPhone" label="紧急联系电话"><Input /></Form.Item></div><Form.Item name="specialTags" label="特殊标记"><Select mode="multiple" options={specialTags.map((value) => ({ value, label: value }))} /></Form.Item><Form.Item name="specialNeeds" label="特殊情况说明"><Input.TextArea rows={3} /></Form.Item><Form.Item name="biography" label="个人简介"><Input.TextArea rows={3} /></Form.Item></>}<div className="registry-form-grid"><Form.Item name="phone" label="联系电话"><Input /></Form.Item><Form.Item name="email" label="电子邮箱" rules={[{ type: 'email', message: '请输Line truncated
<Modal open={!!activating} title="激活教师账号" okText="确认激活" onCancel={() => setActivating(undefined)} onOk={() => activationForm.submit()}><Alert type="info" showIcon message="激活后教师可使用工号和初始密码登录;请通过安全方式交付密码。" /><Form form={activationForm} layout="vertical" onFinish={(values) => void activate(values)}><div className="registry-form-grid"><Form.Item label="教师"><Input value={activating?.name} disabled /></Form.Item><Form.Item label="登录账号"><Input value={activating?.teacherNumber} disabled /></Form.Item></div><Form.Item name="password" label="初始密码" rules={[{ required: true }, { pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).{8,}$/, message: '至少 8 位,并包含大小写字母、数字和特殊字符' }]}><Input.Password autoComplete="new-password" /></Form.Item><Form.Item name="confirmPassword" label="确认初始密码" dependencies={['password']} rules={[{ required: true }, ({ getFieldValue }) => ({ validator: (_, value) => !value || getFieldValue('password') === value ? Promise.resolve() : Promise.reject(new Error('两次输入的密码不一致')) })]}><Input.Password autoComplete="new-password" /></Form.Item></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="registry-archive" key={item.id}><strong>{item.name || '不可操作记录'}</strong>{item.error && <p className="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="error" key={index}>{blocker.message}</p>)}{item.notices?.map((notice, index) => <p key={index}>{notice.message}</p>)}</>}</article>)}{!archive.results.length ? <label className="registry-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="registry-archive" 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