Files
gongxue-base/apps/admin/src/pages/Students/index.tsx

1125 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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';
import { usePermission } from '../../hooks/usePermission';
import { selectArchiveRecords } from '../archive-view';
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 { hasPermission, hasAnyPermission, hasAllPermissions } = usePermission();
const canViewOrganizations = hasPermission('organization:view');
const canLoadOrganizations = hasAnyPermission(
'organization:view',
'student:create',
'student:edit',
);
const canChooseOrganization = hasAnyPermission('student:create', 'student:edit');
const canCreateStudent = hasPermission('student:create');
const canEditStudent = hasPermission('student:edit');
const canDeleteStudent = hasPermission('student:delete');
const canSyncJinshuju = hasAllPermissions('sync:read', 'sync:trigger');
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 canSaveStudent = editing ? canEditStudent : canCreateStudent;
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 [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);
// Sensitive info modal — command-style; destroy when log:create is lost or comp unmounts.
// Close the student form modal when the user loses the required permission.
useEffect(() => {
if (!canSaveStudent && modalOpen) {
setModalOpen(false);
setEditing(null);
form.resetFields();
}
}, [canSaveStudent, modalOpen, form]);
// Close sensitive modal when log:create is lost (imperative ref already set above).
const logCreateRef = React.useRef(hasPermission('log:create'));
const sensitiveModalRef = React.useRef<ReturnType<typeof modal.confirm> | null>(null);
logCreateRef.current = hasPermission('log:create');
useEffect(() => {
if (!logCreateRef.current && sensitiveModalRef.current) {
sensitiveModalRef.current.destroy();
sensitiveModalRef.current = null;
}
return () => {
sensitiveModalRef.current?.destroy();
sensitiveModalRef.current = null;
};
}, []);
const handleViewSensitive = (studentId: number, field: string, value: string) => {
if (!logCreateRef.current) return;
sensitiveModalRef.current = modal.confirm({
title: '查看敏感信息',
content: `您即将查看 "${field}" 的完整信息。此操作将被记录。`,
okText: '确认查看',
cancelText: '取消',
onOk: async () => {
if (!logCreateRef.current) return;
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('审计日志记录失败,请稍后重试');
}
},
afterClose: () => {
sensitiveModalRef.current = null;
},
});
};
const handleBatchDelete = async () => {
if (batchLoading) return;
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 handleBatchRestore = async () => {
if (batchLoading) return;
setBatchLoading(true);
try {
const res = await api.put<{ message?: string; restored: number; skipped: number }>(
'/students/batch-restore',
{ ids: selectedRowKeys },
);
message.success(
`已批量恢复 ${res.restored}${res.skipped ? `,跳过 ${res.skipped}` : ''}`,
);
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: showArchived ? 'true' : undefined,
};
if (showArchived) params.status = 'archived';
else 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>>;
setData(selectArchiveRecords(list, showArchived ? 'archived' : 'active'));
} 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(() => {
if (!canLoadOrganizations) {
setOrganizations([]);
setFilterOrganizationId(undefined);
return;
}
if (canViewOrganizations) {
api
.get('/organizations', { params: { includeArchived: 'false' } })
.then((res: unknown) => {
setOrganizations(res as Array<{ id: number; name: string; isHost?: boolean }>);
})
.catch(() => {});
} else {
api
.get('/organizations/options')
.then((res: unknown) => {
setOrganizations(res as Array<{ id: number; name: string; isHost?: boolean }>);
})
.catch(() => {});
}
api
.get<StudentFilterLookups>('/students/filter-lookups')
.then((res) => {
setClassOptions(res.classes || []);
setTeacherOptions(res.teachers || []);
})
.catch(() => {});
}, [canLoadOrganizations]);
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>
{hasPermission('log:create') ? (
<Button
type="link"
size="small"
style={{ padding: '8px 4px', flex: 'none' }}
onClick={() => handleViewSensitive(record.id, '电话', v)}
title="点击查看完整号码"
>
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
</Button>
) : null}
</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>
{hasPermission('log:create') ? (
<Button
type="link"
size="small"
style={{ padding: '8px 4px', flex: 'none' }}
onClick={() => handleViewSensitive(record.id, '身份证号', v)}
title="点击查看完整号码"
>
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
</Button>
) : null}
</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>
{hasPermission('log:create') ? (
<Button
type="link"
size="small"
style={{ padding: '8px 4px', flex: 'none' }}
onClick={() => handleViewSensitive(record.id, '紧急联系人电话', v)}
title="点击查看完整号码"
>
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
</Button>
) : null}
</span>
);
},
},
{
title: '所属机构',
dataIndex: 'organization',
width: 100,
render: (organization: { name?: string } | null, record: any) =>
canChooseOrganization ? (
<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>
) : organization?.name ? (
<Tag color="purple">{organization.name}</Tag>
) : (
'-'
),
},
{
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' ? (
canEditStudent ? (
<Popconfirm
title="确定恢复此学生?恢复后将重新出现在学生列表中。"
onConfirm={() => handleRestore(record.id)}
okText="恢复"
cancelText="取消"
>
<Button size="small" icon={<UndoOutlined />} type="link">
</Button>
</Popconfirm>
) : null
) : (
<>
<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>
{canDeleteStudent ? (
<Popconfirm
title="归档后不会删除数据,可随时恢复。确定归档?"
onConfirm={() => handleArchive(record.id)}
okText="归档"
cancelText="取消"
>
<Button size="small" icon={<InboxOutlined />}>
</Button>
</Popconfirm>
) : null}
</>
)}
</Space>
),
},
],
[
handleViewSensitive,
openDrawer,
showArchived,
organizations,
saveCell,
hasPermission,
canChooseOrganization,
],
);
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>
{canViewOrganizations ? (
<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>
) : null}
<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);
setFilterStatus(undefined);
setSelectedRowKeys([]);
}}
>
{showArchived ? '返回正常数据' : '查看已归档'}
</Button>
</Space>
<Space wrap className="responsive-toolbar__group">
{showArchived && canEditStudent ? (
<Popconfirm
title={`确定批量恢复选中的 ${selectedRowKeys.length} 名学生?`}
onConfirm={handleBatchRestore}
okText="恢复"
cancelText="取消"
disabled={selectedRowKeys.length === 0}
>
<Button
type="primary"
icon={<UndoOutlined />}
disabled={selectedRowKeys.length === 0}
loading={batchLoading}
>
</Button>
</Popconfirm>
) : !showArchived && canDeleteStudent ? (
<Popconfirm
title={`确定批量归档选中的 ${selectedRowKeys.length} 名学生?(数据保留,可恢复)`}
onConfirm={handleBatchDelete}
okText="归档"
cancelText="取消"
disabled={selectedRowKeys.length === 0}
>
<Button
danger
icon={<InboxOutlined />}
disabled={selectedRowKeys.length === 0}
loading={batchLoading}
>
</Button>
</Popconfirm>
) : null}
{!showArchived ? (
<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>
) : null}
{!showArchived && hasPermission('student:import') ? (
<>
<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>
</>
) : null}
{!showArchived && canSyncJinshuju ? (
<Button icon={<CloudUploadOutlined />} onClick={() => setJinshujuOpen(true)}>
</Button>
) : null}
<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[]),
}}
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 ? '编辑学生' : '添加学生'}
className="student-form-modal"
width={720}
open={modalOpen && canSaveStudent}
onOk={canSaveStudent ? handleSave : undefined}
onCancel={() => {
setModalOpen(false);
setEditing(null);
}}
okText="保存"
confirmLoading={saving}
>
<Form form={form} layout="vertical" className="student-form-grid">
<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>
{canChooseOrganization ? (
<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>
) : null}
<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>
{canSyncJinshuju ? (
<JinshujuMatchModal
open={jinshujuOpen}
onClose={() => setJinshujuOpen(false)}
onApplied={() => {
setJinshujuOpen(false);
fetchData();
}}
/>
) : null}
<Drawer
title={null}
open={drawerOpen}
onClose={() => {
setDrawerOpen(false);
}}
size={720}
>
{drawerStudentId && (
<StudentProfileContent
studentId={drawerStudentId}
inDrawer
onClose={() => {
setDrawerOpen(false);
}}
/>
)}
</Drawer>
</div>
);
};
export default StudentsPage;