997 lines
32 KiB
TypeScript
997 lines
32 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import {
|
||
Alert,
|
||
App,
|
||
Button,
|
||
Card,
|
||
Col,
|
||
Descriptions,
|
||
Drawer,
|
||
Empty,
|
||
Form,
|
||
Input,
|
||
Modal,
|
||
Popconfirm,
|
||
Row,
|
||
Select,
|
||
Space,
|
||
Table,
|
||
Tag,
|
||
Upload,
|
||
} from 'antd';
|
||
import type { UploadProps } from 'antd';
|
||
import {
|
||
CloudUploadOutlined,
|
||
DownloadOutlined,
|
||
ExportOutlined,
|
||
EyeOutlined,
|
||
InboxOutlined,
|
||
PlusOutlined,
|
||
SwapOutlined,
|
||
UndoOutlined,
|
||
UploadOutlined,
|
||
} from '@ant-design/icons';
|
||
import api from '../../api';
|
||
import PermissionButton from '../../components/PermissionButton';
|
||
import StudentProfileContent from '../../components/StudentProfileContent';
|
||
import EditableCell from '../../components/EditableCell';
|
||
import JinshujuMatchModal from '../../components/JinshujuMatchModal';
|
||
import { maskIdNumber, maskPhone } from '../../utils/sensitive';
|
||
import { message } from '../../ui/app-message';
|
||
|
||
const statusMap: Record<string, { text: string; color: string }> = {
|
||
active: { text: '在读', color: 'green' },
|
||
graduated: { text: '已毕业', color: 'blue' },
|
||
withdrawn: { text: '已退训', color: 'red' },
|
||
archived: { text: '已归档', color: '#999' },
|
||
};
|
||
|
||
interface EnrollmentInfo {
|
||
classId: number;
|
||
className: string;
|
||
classType: string;
|
||
startDate: string;
|
||
endDate: string;
|
||
joinDate: string;
|
||
leaveDate: string;
|
||
status: string;
|
||
attendanceStats: {
|
||
total: number;
|
||
present: number;
|
||
absent: number;
|
||
late: number;
|
||
leave: number;
|
||
rate: number;
|
||
};
|
||
}
|
||
|
||
interface StudentCreateImportResult {
|
||
message?: string;
|
||
imported?: number;
|
||
skipped?: number;
|
||
}
|
||
|
||
interface StudentUpdateImportResult {
|
||
message?: string;
|
||
matched?: number;
|
||
skipped?: number;
|
||
}
|
||
|
||
interface StudentFilterLookups {
|
||
classes: Array<{ id: number; name: string; code?: string }>;
|
||
teachers: Array<{ id: number; name: string; username: string }>;
|
||
}
|
||
|
||
const StudentsPage: React.FC = () => {
|
||
const { modal } = App.useApp();
|
||
const [data, setData] = useState<any[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [modalOpen, setModalOpen] = useState(false);
|
||
const [organizations, setOrganizations] = useState<any[]>([]);
|
||
const [editing, setEditing] = useState<any>(null);
|
||
const [searchName, setSearchName] = useState('');
|
||
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
|
||
const [filterOrganizationId, setFilterOrganizationId] = useState<number | undefined>(undefined);
|
||
const [filterClassId, setFilterClassId] = useState<number | undefined>(undefined);
|
||
const [filterTeacherId, setFilterTeacherId] = useState<number | undefined>(undefined);
|
||
const [classOptions, setClassOptions] = useState<StudentFilterLookups['classes']>([]);
|
||
const [teacherOptions, setTeacherOptions] = useState<StudentFilterLookups['teachers']>([]);
|
||
const [showArchived, setShowArchived] = useState(false);
|
||
const [archivedCount, setArchivedCount] = useState(0);
|
||
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
|
||
const [batchLoading, setBatchLoading] = useState(false);
|
||
const [enrollmentData, setEnrollmentData] = useState<Record<number, EnrollmentInfo[]>>({});
|
||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||
const [drawerStudentId, setDrawerStudentId] = useState<number | undefined>(undefined);
|
||
const [form] = Form.useForm();
|
||
const [saving, setSaving] = useState(false);
|
||
|
||
const openDrawer = (studentId: number) => {
|
||
setDrawerStudentId(studentId);
|
||
setDrawerOpen(true);
|
||
};
|
||
|
||
const [jinshujuOpen, setJinshujuOpen] = useState(false);
|
||
|
||
const handleViewSensitive = (studentId: number, field: string, value: string) => {
|
||
modal.confirm({
|
||
title: '查看敏感信息',
|
||
content: `您即将查看 "${field}" 的完整信息。此操作将被记录。`,
|
||
okText: '确认查看',
|
||
cancelText: '取消',
|
||
onOk: async () => {
|
||
try {
|
||
await api.post('/operation-logs/audit', {
|
||
module: '学生管理',
|
||
action: '查看敏感信息',
|
||
targetId: studentId,
|
||
targetType: 'student',
|
||
detail: `查看${field}`,
|
||
});
|
||
modal.info({
|
||
title: field,
|
||
content: value,
|
||
okText: '关闭',
|
||
});
|
||
} catch {
|
||
message.error('审计日志记录失败,请稍后重试');
|
||
}
|
||
},
|
||
});
|
||
};
|
||
|
||
const handleBatchDelete = async () => {
|
||
setBatchLoading(true);
|
||
try {
|
||
const res: any = await api.post('/students/batch-delete', { ids: selectedRowKeys });
|
||
message.success(res?.message || `已批量归档 ${selectedRowKeys.length} 人`);
|
||
setSelectedRowKeys([]);
|
||
fetchData();
|
||
} catch (e: any) {
|
||
message.error(e?.message || '批量归档失败');
|
||
} finally {
|
||
setBatchLoading(false);
|
||
}
|
||
};
|
||
|
||
const fetchData = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const params: Record<string, unknown> = {
|
||
name: searchName || undefined,
|
||
includeArchived: 'true',
|
||
};
|
||
if (filterStatus) params.status = filterStatus;
|
||
if (filterOrganizationId) params.organizationId = filterOrganizationId;
|
||
if (filterClassId) params.classId = filterClassId;
|
||
if (filterTeacherId) params.teacherId = filterTeacherId;
|
||
const res = (await api.get('/students', { params })) as Array<Record<string, unknown>>;
|
||
const list = res as Array<Record<string, unknown>>;
|
||
const archived = list.filter((r) => r.status === 'archived');
|
||
setArchivedCount(archived.length);
|
||
setData(showArchived ? list : list.filter((r) => r.status !== 'archived'));
|
||
} catch (e: unknown) {
|
||
const err = e as { message?: string };
|
||
message.error(err?.message || '加载失败,请稍后重试');
|
||
}
|
||
setLoading(false);
|
||
}, [
|
||
searchName,
|
||
showArchived,
|
||
filterStatus,
|
||
filterOrganizationId,
|
||
filterClassId,
|
||
filterTeacherId,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
fetchData();
|
||
}, [fetchData]);
|
||
|
||
useEffect(() => {
|
||
api
|
||
.get('/organizations', { params: { includeArchived: 'false' } })
|
||
.then((res: unknown) => {
|
||
setOrganizations(res as Array<{ id: number; name: string }>);
|
||
})
|
||
.catch(() => {});
|
||
api
|
||
.get<StudentFilterLookups>('/students/filter-lookups')
|
||
.then((res) => {
|
||
setClassOptions(res.classes || []);
|
||
setTeacherOptions(res.teachers || []);
|
||
})
|
||
.catch(() => {});
|
||
}, []);
|
||
const handleSave = async () => {
|
||
const values = await form.validateFields();
|
||
setSaving(true);
|
||
try {
|
||
if (editing) {
|
||
await api.put(`/students/${editing.id}`, values);
|
||
message.success('更新成功');
|
||
} else {
|
||
await api.post('/students', values);
|
||
message.success('创建成功');
|
||
}
|
||
setModalOpen(false);
|
||
form.resetFields();
|
||
setEditing(null);
|
||
fetchData();
|
||
} catch (e: any) {
|
||
message.error(e?.message || '操作失败');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const saveCell = useCallback(
|
||
async (record: any, field: string, value: unknown) => {
|
||
await api.put(`/students/${record.id}`, { [field]: value });
|
||
message.success('已保存');
|
||
await fetchData();
|
||
},
|
||
[fetchData],
|
||
);
|
||
|
||
const handleArchive = async (id: number) => {
|
||
try {
|
||
await api.delete(`/students/${id}`);
|
||
message.success('已归档');
|
||
fetchData();
|
||
} catch (e: any) {
|
||
message.error(e?.message || '归档失败');
|
||
}
|
||
};
|
||
|
||
const handleRestore = async (id: number) => {
|
||
try {
|
||
await api.put(`/students/${id}/restore`);
|
||
message.success('已恢复');
|
||
fetchData();
|
||
} catch (e: any) {
|
||
message.error(e?.message || '恢复失败');
|
||
}
|
||
};
|
||
|
||
const handleDownloadTemplate = () => {
|
||
const baseURL = import.meta.env.PROD
|
||
? '/api'
|
||
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
|
||
const token = localStorage.getItem('token');
|
||
fetch(`${baseURL}/students/template`, { headers: { Authorization: `Bearer ${token}` } })
|
||
.then((res) => res.blob())
|
||
.then((blob) => {
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = '学生导入模板.xlsx';
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
})
|
||
.catch(() => message.error('下载失败'));
|
||
};
|
||
|
||
const showCreateImportResult = (result: StudentCreateImportResult) => {
|
||
const imported = result.imported ?? 0;
|
||
const skipped = result.skipped ?? 0;
|
||
|
||
modal.success({
|
||
title: '导入完成',
|
||
okText: '知道了',
|
||
content: (
|
||
<div>
|
||
<Descriptions column={1} size="small">
|
||
<Descriptions.Item label="成功新增">{imported} 人</Descriptions.Item>
|
||
<Descriptions.Item label="跳过">{skipped} 人</Descriptions.Item>
|
||
</Descriptions>
|
||
<div style={{ marginTop: 12, fontWeight: 600 }}>跳过原因:</div>
|
||
<ul style={{ marginBottom: 0, paddingLeft: 20 }}>
|
||
<li>姓名为空</li>
|
||
<li>已存在同名学生</li>
|
||
</ul>
|
||
<div style={{ marginTop: 8, color: '#8c8c8c', fontSize: 12 }}>
|
||
当前后端只返回统计汇总,暂时无法列出具体哪几行被跳过。
|
||
</div>
|
||
</div>
|
||
),
|
||
});
|
||
};
|
||
|
||
const showUpdateImportResult = (result: StudentUpdateImportResult) => {
|
||
const matched = result.matched ?? 0;
|
||
const skipped = result.skipped ?? 0;
|
||
|
||
modal.success({
|
||
title: '更新完成',
|
||
okText: '知道了',
|
||
content: (
|
||
<div>
|
||
<Descriptions column={1} size="small">
|
||
<Descriptions.Item label="成功更新">{matched} 人</Descriptions.Item>
|
||
<Descriptions.Item label="未匹配">{skipped} 人</Descriptions.Item>
|
||
</Descriptions>
|
||
<div style={{ marginTop: 12, fontWeight: 600 }}>匹配规则:</div>
|
||
<div>手机号优先,身份证号其次</div>
|
||
<div style={{ marginTop: 8, color: '#8c8c8c', fontSize: 12 }}>
|
||
当前后端只返回统计汇总,暂时无法列出具体哪几行未匹配。
|
||
</div>
|
||
</div>
|
||
),
|
||
});
|
||
};
|
||
|
||
const handleCreateStudentsImport: UploadProps['customRequest'] = async ({
|
||
file,
|
||
onSuccess,
|
||
onError,
|
||
}) => {
|
||
const formData = new FormData();
|
||
formData.append('file', file as File);
|
||
try {
|
||
const res = (await api.post('/students/import', formData, {
|
||
headers: { 'Content-Type': 'multipart/form-data' },
|
||
})) as StudentCreateImportResult;
|
||
showCreateImportResult(res);
|
||
onSuccess?.(res);
|
||
fetchData();
|
||
} catch (e: unknown) {
|
||
const err = e as { message?: string };
|
||
message.error(err?.message || '导入失败');
|
||
onError?.(e instanceof Error ? e : new Error(err?.message || '导入失败'));
|
||
}
|
||
};
|
||
|
||
const handleUpdateExistingStudentsImport: UploadProps['customRequest'] = async ({
|
||
file,
|
||
onSuccess,
|
||
onError,
|
||
}) => {
|
||
const formData = new FormData();
|
||
formData.append('file', file as File);
|
||
try {
|
||
const res = (await api.post('/students/import-match', formData, {
|
||
headers: { 'Content-Type': 'multipart/form-data' },
|
||
})) as StudentUpdateImportResult;
|
||
showUpdateImportResult(res);
|
||
onSuccess?.(res);
|
||
fetchData();
|
||
} catch (e: unknown) {
|
||
const err = e as { message?: string };
|
||
message.error(err?.message || '更新已有学生资料失败');
|
||
onError?.(e instanceof Error ? e : new Error(err?.message || '更新已有学生资料失败'));
|
||
}
|
||
};
|
||
|
||
const handleExport = () => {
|
||
const baseURL = import.meta.env.PROD
|
||
? '/api'
|
||
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
|
||
const token = localStorage.getItem('token');
|
||
const params = new URLSearchParams();
|
||
if (searchName) params.set('name', searchName);
|
||
if (filterStatus) params.set('status', filterStatus);
|
||
if (filterOrganizationId) params.set('organizationId', String(filterOrganizationId));
|
||
if (showArchived) params.set('includeArchived', 'true');
|
||
if (filterClassId) params.set('classId', String(filterClassId));
|
||
if (filterTeacherId) params.set('teacherId', String(filterTeacherId));
|
||
const query = params.toString() ? `?${params.toString()}` : '';
|
||
fetch(`${baseURL}/students/export${query}`, { headers: { Authorization: `Bearer ${token}` } })
|
||
.then((res) => res.blob())
|
||
.then((blob) => {
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = '学生名单.xlsx';
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
})
|
||
.catch(() => message.error('导出失败'));
|
||
};
|
||
|
||
const columns = useMemo(
|
||
() => [
|
||
{ title: 'ID', dataIndex: 'id', width: 70 },
|
||
{
|
||
title: '姓名',
|
||
dataIndex: 'name',
|
||
width: 120,
|
||
render: (v: string, record: any) => (
|
||
<EditableCell
|
||
value={v}
|
||
required
|
||
permission="student:edit"
|
||
disabled={record.status === 'archived'}
|
||
onSave={(next) => saveCell(record, 'name', next)}
|
||
>
|
||
{v}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '电话',
|
||
dataIndex: 'phone',
|
||
width: 140,
|
||
render: (v: string, record: any) => {
|
||
if (!v) return '-';
|
||
return (
|
||
<span style={{ display: 'inline-flex', alignItems: 'center', whiteSpace: 'nowrap' }}>
|
||
<span style={{ marginRight: 4 }}>{maskPhone(v)}</span>
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
style={{ padding: '8px 4px', flex: 'none' }}
|
||
onClick={() => handleViewSensitive(record.id, '电话', v)}
|
||
title="点击查看完整号码"
|
||
>
|
||
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
|
||
</Button>
|
||
</span>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
title: '学号',
|
||
dataIndex: 'studentNo',
|
||
width: 120,
|
||
render: (v: string, record: any) => (
|
||
<EditableCell
|
||
value={v}
|
||
permission="student:edit"
|
||
disabled={record.status === 'archived'}
|
||
onSave={(next) => saveCell(record, 'studentNo', next)}
|
||
>
|
||
{v || '-'}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '身份证',
|
||
dataIndex: 'idNumber',
|
||
width: 180,
|
||
render: (v: string, record: any) => {
|
||
if (!v) return '-';
|
||
return (
|
||
<span style={{ display: 'inline-flex', alignItems: 'center', whiteSpace: 'nowrap' }}>
|
||
<span style={{ marginRight: 4 }}>{maskIdNumber(v)}</span>
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
style={{ padding: '8px 4px', flex: 'none' }}
|
||
onClick={() => handleViewSensitive(record.id, '身份证号', v)}
|
||
title="点击查看完整号码"
|
||
>
|
||
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
|
||
</Button>
|
||
</span>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
title: '民族',
|
||
dataIndex: 'ethnicity',
|
||
width: 90,
|
||
render: (v: string, record: any) => (
|
||
<EditableCell
|
||
value={v}
|
||
permission="student:edit"
|
||
disabled={record.status === 'archived'}
|
||
onSave={(next) => saveCell(record, 'ethnicity', next)}
|
||
>
|
||
{v || '-'}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '紧急联系人',
|
||
dataIndex: 'emergencyContact',
|
||
width: 100,
|
||
render: (v: string, record: any) => (
|
||
<EditableCell
|
||
value={v}
|
||
permission="student:edit"
|
||
disabled={record.status === 'archived'}
|
||
onSave={(next) => saveCell(record, 'emergencyContact', next)}
|
||
>
|
||
{v || '-'}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '紧急联系人电话',
|
||
dataIndex: 'emergencyPhone',
|
||
width: 150,
|
||
render: (v: string, record: any) => {
|
||
if (!v) return '-';
|
||
return (
|
||
<span style={{ display: 'inline-flex', alignItems: 'center', whiteSpace: 'nowrap' }}>
|
||
<span style={{ marginRight: 4 }}>{maskPhone(v)}</span>
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
style={{ padding: '8px 4px', flex: 'none' }}
|
||
onClick={() => handleViewSensitive(record.id, '紧急联系人电话', v)}
|
||
title="点击查看完整号码"
|
||
>
|
||
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
|
||
</Button>
|
||
</span>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
title: '所属机构',
|
||
dataIndex: 'organization',
|
||
width: 100,
|
||
render: (organization: { name?: string } | null, record: any) => (
|
||
<EditableCell
|
||
value={record.organizationId}
|
||
editor="select"
|
||
options={organizations.map((item) => ({ value: item.id, label: item.name }))}
|
||
permission="student:edit"
|
||
disabled={record.status === 'archived'}
|
||
required
|
||
onSave={(next) => saveCell(record, 'organizationId', next)}
|
||
>
|
||
{organization?.name ? (
|
||
<Tag
|
||
color="purple"
|
||
style={{ maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis' }}
|
||
>
|
||
{organization.name}
|
||
</Tag>
|
||
) : (
|
||
'-'
|
||
)}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '负责人',
|
||
dataIndex: 'supervisor',
|
||
width: 100,
|
||
render: (v: string, record: any) => (
|
||
<EditableCell
|
||
value={v}
|
||
permission="student:edit"
|
||
disabled={record.status === 'archived'}
|
||
onSave={(next) => saveCell(record, 'supervisor', next)}
|
||
>
|
||
{v || '-'}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'status',
|
||
width: 80,
|
||
render: (s: string, record: any) => (
|
||
<EditableCell
|
||
value={s}
|
||
editor="select"
|
||
options={[
|
||
{ value: 'active', label: '在读' },
|
||
{ value: 'graduated', label: '已毕业' },
|
||
{ value: 'withdrawn', label: '已退训' },
|
||
]}
|
||
permission="student:edit"
|
||
disabled={record.status === 'archived'}
|
||
onSave={(next) => saveCell(record, 'status', next)}
|
||
>
|
||
<Tag
|
||
color={statusMap[s]?.color}
|
||
style={{ maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis' }}
|
||
>
|
||
{statusMap[s]?.text || s}
|
||
</Tag>
|
||
</EditableCell>
|
||
),
|
||
},
|
||
{
|
||
title: '操作',
|
||
width: 180,
|
||
render: (_: any, record: any) => (
|
||
<Space>
|
||
{record.status === 'archived' ? (
|
||
<Popconfirm
|
||
title="确定恢复此学生?恢复后将重新出现在学生列表中。"
|
||
onConfirm={() => handleRestore(record.id)}
|
||
okText="恢复"
|
||
cancelText="取消"
|
||
>
|
||
<PermissionButton
|
||
permission="student:edit"
|
||
size="small"
|
||
icon={<UndoOutlined />}
|
||
type="link"
|
||
>
|
||
恢复
|
||
</PermissionButton>
|
||
</Popconfirm>
|
||
) : (
|
||
<>
|
||
<PermissionButton
|
||
permission="student:view"
|
||
size="small"
|
||
type="link"
|
||
onClick={() => openDrawer(record.id)}
|
||
>
|
||
档案
|
||
</PermissionButton>
|
||
<PermissionButton
|
||
permission="student:edit"
|
||
size="small"
|
||
onClick={() => {
|
||
setEditing(record);
|
||
form.setFieldsValue(record);
|
||
setModalOpen(true);
|
||
}}
|
||
>
|
||
编辑
|
||
</PermissionButton>
|
||
<Popconfirm
|
||
title="归档后不会删除数据,可随时恢复。确定归档?"
|
||
onConfirm={() => handleArchive(record.id)}
|
||
okText="归档"
|
||
cancelText="取消"
|
||
>
|
||
<PermissionButton
|
||
permission="student:delete"
|
||
size="small"
|
||
icon={<InboxOutlined />}
|
||
>
|
||
归档
|
||
</PermissionButton>
|
||
</Popconfirm>
|
||
</>
|
||
)}
|
||
</Space>
|
||
),
|
||
},
|
||
],
|
||
[handleViewSensitive, openDrawer, showArchived, organizations, saveCell],
|
||
);
|
||
|
||
return (
|
||
<div>
|
||
<div className="responsive-toolbar">
|
||
<Space wrap className="responsive-toolbar__group">
|
||
<Input.Search
|
||
placeholder="搜索学生姓名"
|
||
onSearch={setSearchName}
|
||
allowClear
|
||
style={{ width: 250 }}
|
||
/>
|
||
<Select
|
||
placeholder="状态筛选"
|
||
allowClear
|
||
style={{ width: 120 }}
|
||
value={filterStatus}
|
||
onChange={(v) => {
|
||
setFilterStatus(v);
|
||
}}
|
||
>
|
||
{Object.entries(statusMap)
|
||
.filter(([k]) => k !== 'archived')
|
||
.map(([k, v]) => (
|
||
<Select.Option key={k} value={k}>
|
||
{v.text}
|
||
</Select.Option>
|
||
))}
|
||
</Select>
|
||
<Select
|
||
placeholder="所属机构"
|
||
allowClear
|
||
style={{ width: 140 }}
|
||
value={filterOrganizationId}
|
||
onChange={(v) => {
|
||
setFilterOrganizationId(v);
|
||
}}
|
||
>
|
||
{organizations.map((t: { id: number; name: string }) => (
|
||
<Select.Option key={t.id} value={t.id}>
|
||
{t.name}
|
||
</Select.Option>
|
||
))}
|
||
</Select>
|
||
<Select
|
||
placeholder="所属班级"
|
||
allowClear
|
||
showSearch
|
||
optionFilterProp="label"
|
||
style={{ width: 160 }}
|
||
value={filterClassId}
|
||
onChange={(v) => {
|
||
setFilterClassId(v);
|
||
}}
|
||
options={classOptions.map((item) => ({
|
||
value: item.id,
|
||
label: item.code ? `${item.name}(${item.code})` : item.name,
|
||
}))}
|
||
/>
|
||
<Select
|
||
placeholder="所属老师"
|
||
allowClear
|
||
showSearch
|
||
optionFilterProp="label"
|
||
style={{ width: 160 }}
|
||
value={filterTeacherId}
|
||
onChange={(v) => {
|
||
setFilterTeacherId(v);
|
||
}}
|
||
options={teacherOptions.map((item) => ({
|
||
value: item.id,
|
||
label: item.name === item.username ? item.name : `${item.name}(${item.username})`,
|
||
}))}
|
||
/>
|
||
<Button
|
||
type={showArchived ? 'primary' : 'default'}
|
||
onClick={() => setShowArchived(!showArchived)}
|
||
>
|
||
{showArchived
|
||
? '隐藏已归档'
|
||
: `显示已归档${archivedCount > 0 ? ` (${archivedCount})` : ''}`}
|
||
</Button>
|
||
</Space>
|
||
<Space wrap className="responsive-toolbar__group">
|
||
<Popconfirm
|
||
title={`确定批量归档选中的 ${selectedRowKeys.length} 名学生?(数据保留,可恢复)`}
|
||
onConfirm={handleBatchDelete}
|
||
okText="归档"
|
||
cancelText="取消"
|
||
disabled={selectedRowKeys.length === 0}
|
||
>
|
||
<PermissionButton
|
||
permission="student:delete"
|
||
danger
|
||
icon={<InboxOutlined />}
|
||
disabled={selectedRowKeys.length === 0}
|
||
loading={batchLoading}
|
||
>
|
||
批量归档
|
||
</PermissionButton>
|
||
</Popconfirm>
|
||
<PermissionButton
|
||
permission="student:create"
|
||
type="primary"
|
||
icon={<PlusOutlined />}
|
||
onClick={() => {
|
||
setEditing(null);
|
||
form.resetFields();
|
||
const host = organizations.find((organization) => organization.isHost);
|
||
if (host) form.setFieldValue('organizationId', host.id);
|
||
setModalOpen(true);
|
||
}}
|
||
>
|
||
添加学生
|
||
</PermissionButton>
|
||
<Upload
|
||
accept=".xlsx,.xls"
|
||
showUploadList={false}
|
||
customRequest={handleCreateStudentsImport}
|
||
>
|
||
<Button icon={<UploadOutlined />}>导入Excel</Button>
|
||
</Upload>
|
||
<Upload
|
||
accept=".xlsx,.xls"
|
||
showUploadList={false}
|
||
customRequest={handleUpdateExistingStudentsImport}
|
||
>
|
||
<Button icon={<SwapOutlined />}>更新已有学生资料</Button>
|
||
</Upload>
|
||
<PermissionButton
|
||
permission="student:edit"
|
||
icon={<CloudUploadOutlined />}
|
||
onClick={() => setJinshujuOpen(true)}
|
||
>
|
||
同步金数据
|
||
</PermissionButton>
|
||
<PermissionButton
|
||
permission="student:view"
|
||
icon={<DownloadOutlined />}
|
||
onClick={handleDownloadTemplate}
|
||
>
|
||
下载模板
|
||
</PermissionButton>
|
||
<PermissionButton
|
||
permission="student:export"
|
||
icon={<ExportOutlined />}
|
||
onClick={handleExport}
|
||
>
|
||
导出名单
|
||
</PermissionButton>
|
||
</Space>
|
||
</div>
|
||
<Alert
|
||
showIcon
|
||
type="warning"
|
||
style={{ marginBottom: 12 }}
|
||
message={
|
||
<span>
|
||
<strong>更新已有学生资料:</strong>先按手机号、再按身份证号匹配;Excel
|
||
中填写的非空字段会覆盖原资料,未匹配的学生不会新增。请确认姓名、手机号、身份证号、所属机构和联系人等内容无误。
|
||
</span>
|
||
}
|
||
/>
|
||
<Table
|
||
columns={columns}
|
||
dataSource={data}
|
||
rowKey="id"
|
||
loading={loading}
|
||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||
scroll={{ x: 1410 }}
|
||
pagination={{
|
||
defaultPageSize: 15,
|
||
showSizeChanger: true,
|
||
pageSizeOptions: [15, 30, 50, 100],
|
||
showTotal: (total) => `共 ${total} 人`,
|
||
}}
|
||
rowClassName={(record: any) => (record.status === 'archived' ? 'archived-row' : '')}
|
||
rowSelection={{
|
||
selectedRowKeys,
|
||
onChange: (keys) => setSelectedRowKeys(keys as number[]),
|
||
getCheckboxProps: (record: any) => ({ disabled: record.status === 'archived' }),
|
||
}}
|
||
expandable={{
|
||
rowExpandable: () => true,
|
||
expandedRowRender: (record) => {
|
||
const enrollments = enrollmentData[record.id];
|
||
if (!enrollments) return null;
|
||
if (enrollments.length < 2) {
|
||
return (
|
||
<div style={{ padding: 8, color: '#999', fontSize: 13 }}>
|
||
当前仅 {enrollments.length} 个班型,无可对比数据
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<Card title="多班型对比" size="small" style={{ margin: '8px 0' }}>
|
||
<Row gutter={[16, 16]}>
|
||
{enrollments.map((enr, idx) => (
|
||
<Col xs={24} md={12} key={enr.classId}>
|
||
<Card
|
||
size="small"
|
||
title={enr.classType || `班型 ${idx + 1}`}
|
||
style={{ background: idx === 0 ? '#f0f5ff' : '#f6ffed' }}
|
||
>
|
||
<Descriptions column={1} size="small">
|
||
<Descriptions.Item label="班级">{enr.className || '-'}</Descriptions.Item>
|
||
<Descriptions.Item label="开班日期">
|
||
{enr.startDate || enr.joinDate || '-'}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="结课日期">
|
||
{enr.endDate || enr.leaveDate || '-'}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="状态">
|
||
<Tag color={enr.status === 'active' ? 'green' : 'default'}>
|
||
{enr.status || '-'}
|
||
</Tag>
|
||
</Descriptions.Item>
|
||
</Descriptions>
|
||
</Card>
|
||
</Col>
|
||
))}
|
||
</Row>
|
||
</Card>
|
||
);
|
||
},
|
||
onExpand: async (expanded, record) => {
|
||
if (expanded && !enrollmentData[record.id]) {
|
||
try {
|
||
const res = await api.get<{ enrollments: EnrollmentInfo[] }>(
|
||
`/students/${record.id}/compare-classes`,
|
||
);
|
||
setEnrollmentData((prev) => ({ ...prev, [record.id]: res.enrollments }));
|
||
} catch {
|
||
setEnrollmentData((prev) => ({ ...prev, [record.id]: [] }));
|
||
}
|
||
}
|
||
},
|
||
}}
|
||
/>
|
||
<style>{`.archived-row { opacity: 0.6; background: #fafafa !important; }`}</style>
|
||
<Modal
|
||
title={editing ? '编辑学生' : '添加学生'}
|
||
open={modalOpen}
|
||
onOk={handleSave}
|
||
onCancel={() => {
|
||
setModalOpen(false);
|
||
setEditing(null);
|
||
}}
|
||
okText="保存"
|
||
confirmLoading={saving}
|
||
>
|
||
<Form form={form} layout="vertical">
|
||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}>
|
||
<Input />
|
||
</Form.Item>
|
||
<Form.Item name="studentNo" label="学号">
|
||
<Input placeholder="学生的学号" />
|
||
</Form.Item>
|
||
<Form.Item name="gender" label="性别">
|
||
<Select
|
||
allowClear
|
||
options={[
|
||
{ value: '男', label: '男' },
|
||
{ value: '女', label: '女' },
|
||
]}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="phone" label="电话">
|
||
<Input />
|
||
</Form.Item>
|
||
<Form.Item name="idNumber" label="身份证">
|
||
<Input />
|
||
</Form.Item>
|
||
<Form.Item name="ethnicity" label="民族">
|
||
<Input placeholder="如:汉族" />
|
||
</Form.Item>
|
||
<Form.Item name="emergencyContact" label="紧急联系人">
|
||
<Input />
|
||
</Form.Item>
|
||
<Form.Item name="emergencyPhone" label="紧急联系人电话">
|
||
<Input />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="organizationId"
|
||
label="所属机构"
|
||
rules={[{ required: true, message: '请选择所属机构' }]}
|
||
>
|
||
<Select
|
||
showSearch
|
||
optionFilterProp="label"
|
||
placeholder="选择所属机构"
|
||
options={organizations.map(
|
||
(organization: { id: number; name: string; isHost?: boolean }) => ({
|
||
value: organization.id,
|
||
label: organization.isHost ? `${organization.name}(本机构)` : organization.name,
|
||
}),
|
||
)}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="supervisor" label="负责人/班主任">
|
||
<Input />
|
||
</Form.Item>
|
||
{editing && (
|
||
<Form.Item name="status" label="状态">
|
||
<Select
|
||
options={[
|
||
{ value: 'active', label: '在读' },
|
||
{ value: 'graduated', label: '已毕业' },
|
||
{ value: 'withdrawn', label: '已退训' },
|
||
]}
|
||
/>
|
||
</Form.Item>
|
||
)}
|
||
</Form>
|
||
</Modal>
|
||
|
||
<JinshujuMatchModal
|
||
open={jinshujuOpen}
|
||
onClose={() => setJinshujuOpen(false)}
|
||
onApplied={() => { setJinshujuOpen(false); fetchData(); }}
|
||
/>
|
||
<Drawer
|
||
title={null}
|
||
open={drawerOpen}
|
||
onClose={() => {
|
||
setDrawerOpen(false);
|
||
}}
|
||
size={720}
|
||
>
|
||
{drawerStudentId && (
|
||
<StudentProfileContent
|
||
studentId={drawerStudentId}
|
||
inDrawer
|
||
onClose={() => {
|
||
setDrawerOpen(false);
|
||
}}
|
||
/>
|
||
)}
|
||
</Drawer>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default StudentsPage;
|