forked from wangziqi/gongxue-base
476 lines
15 KiB
TypeScript
476 lines
15 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
import {
|
|
Table,
|
|
Button,
|
|
Modal,
|
|
Form,
|
|
Input,
|
|
Select,
|
|
Space,
|
|
message,
|
|
Tag,
|
|
Popconfirm,
|
|
Upload,
|
|
App,
|
|
} from 'antd';
|
|
import {
|
|
PlusOutlined,
|
|
UploadOutlined,
|
|
DownloadOutlined,
|
|
UndoOutlined,
|
|
InboxOutlined,
|
|
ExportOutlined,
|
|
DeleteOutlined,
|
|
EyeOutlined,
|
|
} from '@ant-design/icons';
|
|
import api from '../../api';
|
|
import PermissionButton from '../../components/PermissionButton';
|
|
|
|
const maskPhone = (phone: string) => {
|
|
if (!phone || phone.length < 7) return phone || '-';
|
|
return phone.slice(0, 3) + '****' + phone.slice(-4);
|
|
};
|
|
|
|
const maskIdNumber = (id: string) => {
|
|
if (!id || id.length < 8) return id || '-';
|
|
return id.slice(0, 3) + '***********' + id.slice(-4);
|
|
};
|
|
|
|
const statusMap: Record<string, { text: string; color: string }> = {
|
|
active: { text: '在读', color: 'green' },
|
|
graduated: { text: '已毕业', color: 'blue' },
|
|
withdrawn: { text: '已退训', color: 'red' },
|
|
archived: { text: '已归档', color: '#999' },
|
|
};
|
|
|
|
const StudentsPage: React.FC = () => {
|
|
const { modal } = App.useApp();
|
|
const [data, setData] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [tenants, setTenants] = useState<any[]>([]);
|
|
const [editing, setEditing] = useState<any>(null);
|
|
const [searchName, setSearchName] = useState('');
|
|
const [showArchived, setShowArchived] = useState(false);
|
|
const [archivedCount, setArchivedCount] = useState(0);
|
|
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
|
|
const [form] = Form.useForm();
|
|
|
|
const handleViewSensitive = (studentId: number, field: string, value: string) => {
|
|
modal.confirm({
|
|
title: '查看敏感信息',
|
|
content: `您即将查看 "${field}" 的完整信息。此操作将被记录。`,
|
|
okText: '确认查看',
|
|
cancelText: '取消',
|
|
onOk: async () => {
|
|
api.post('/operation-logs/audit', {
|
|
module: '学生管理',
|
|
action: '查看敏感信息',
|
|
targetId: studentId,
|
|
targetType: 'student',
|
|
detail: `查看${field}`,
|
|
}).catch(() => {});
|
|
modal.info({
|
|
title: field,
|
|
content: value,
|
|
okText: '关闭',
|
|
});
|
|
},
|
|
});
|
|
};
|
|
|
|
const handleBatchDelete = async () => {
|
|
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 || '批量归档失败');
|
|
}
|
|
};
|
|
|
|
const fetchData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const params: any = { name: searchName || undefined, includeArchived: 'true' };
|
|
const res: any = await api.get('/students', { params });
|
|
const archived = res.filter((r: any) => r.status === 'archived');
|
|
setArchivedCount(archived.length);
|
|
const filtered = showArchived ? res : res.filter((r: any) => r.status !== 'archived');
|
|
setData(filtered);
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
setLoading(false);
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, [searchName, showArchived]);
|
|
|
|
useEffect(() => {
|
|
api.get('/tenants', { params: { includeArchived: 'false' } }).then((res: unknown) => {
|
|
setTenants(res as Array<{ id: number; name: string }>);
|
|
}).catch(() => {});
|
|
}, []);
|
|
|
|
const handleSave = async () => {
|
|
const values = await form.validateFields();
|
|
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 || '操作失败');
|
|
}
|
|
};
|
|
|
|
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 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 = showArchived ? '?includeArchived=true' : '';
|
|
fetch(`${baseURL}/students/export${params}`, { 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 = [
|
|
{ title: 'ID', dataIndex: 'id', width: 60 },
|
|
{ title: '姓名', dataIndex: 'name', ellipsis: true },
|
|
{ title: '性别', dataIndex: 'gender', width: 60 },
|
|
{
|
|
title: '电话',
|
|
dataIndex: 'phone',
|
|
width: 140,
|
|
ellipsis: true,
|
|
render: (v: string, record: any) => {
|
|
if (!v) return '-';
|
|
return (
|
|
<span>
|
|
<span style={{ marginRight: 4 }}>{maskPhone(v)}</span>
|
|
<a onClick={() => handleViewSensitive(record.id, '电话', v)} title="点击查看完整号码">
|
|
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
|
|
</a>
|
|
</span>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
title: '学号',
|
|
dataIndex: 'studentNumber',
|
|
width: 120,
|
|
ellipsis: true,
|
|
render: (v: string) => v || '-',
|
|
},
|
|
{
|
|
title: '身份证',
|
|
dataIndex: 'idNumber',
|
|
width: 180,
|
|
ellipsis: true,
|
|
render: (v: string, record: any) => {
|
|
if (!v) return '-';
|
|
return (
|
|
<span>
|
|
<span style={{ marginRight: 4 }}>{maskIdNumber(v)}</span>
|
|
<a onClick={() => handleViewSensitive(record.id, '身份证号', v)} title="点击查看完整号码">
|
|
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
|
|
</a>
|
|
</span>
|
|
);
|
|
},
|
|
},
|
|
{ title: '民族', dataIndex: 'ethnicity', width: 80 },
|
|
{ title: '紧急联系人', dataIndex: 'emergencyContact', ellipsis: true },
|
|
{ title: '紧急联系人电话', dataIndex: 'emergencyPhone', ellipsis: true },
|
|
{
|
|
title: '所属机构',
|
|
dataIndex: 'tenant',
|
|
render: (tenant: { name?: string } | null) =>
|
|
tenant?.name ? <Tag color="purple">{tenant.name}</Tag> : '-',
|
|
},
|
|
{ title: '负责人', dataIndex: 'supervisor', ellipsis: true },
|
|
{
|
|
title: '状态',
|
|
dataIndex: 'status',
|
|
render: (s: string) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>,
|
|
},
|
|
{
|
|
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: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>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div>
|
|
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
|
|
<Space wrap>
|
|
<Input.Search
|
|
placeholder="搜索学生姓名"
|
|
onSearch={setSearchName}
|
|
allowClear
|
|
style={{ width: 250 }}
|
|
/>
|
|
<Button
|
|
type={showArchived ? 'primary' : 'default'}
|
|
onClick={() => setShowArchived(!showArchived)}
|
|
>
|
|
{showArchived
|
|
? '隐藏已归档'
|
|
: `显示已归档${archivedCount > 0 ? ` (${archivedCount})` : ''}`}
|
|
</Button>
|
|
</Space>
|
|
<Space wrap>
|
|
<Popconfirm
|
|
title={`确定批量归档选中的 ${selectedRowKeys.length} 名学生?(数据保留,可恢复)`}
|
|
onConfirm={handleBatchDelete}
|
|
okText="归档"
|
|
cancelText="取消"
|
|
disabled={selectedRowKeys.length === 0}
|
|
>
|
|
<PermissionButton
|
|
permission="student:delete"
|
|
danger
|
|
icon={<DeleteOutlined />}
|
|
disabled={selectedRowKeys.length === 0}
|
|
>
|
|
批量归档
|
|
</PermissionButton>
|
|
</Popconfirm>
|
|
<PermissionButton
|
|
permission="student:create"
|
|
type="primary"
|
|
icon={<PlusOutlined />}
|
|
onClick={() => {
|
|
setEditing(null);
|
|
form.resetFields();
|
|
setModalOpen(true);
|
|
}}
|
|
>
|
|
添加学生
|
|
</PermissionButton>
|
|
<Upload
|
|
accept=".xlsx,.xls"
|
|
showUploadList={false}
|
|
customRequest={async ({ file, onSuccess, onError }: any) => {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
try {
|
|
const res: any = await api.post('/students/import', formData, {
|
|
headers: { 'Content-Type': 'multipart/form-data' },
|
|
});
|
|
message.success(res.message);
|
|
onSuccess?.(res);
|
|
fetchData();
|
|
} catch (e: any) {
|
|
message.error(e?.message || '导入失败');
|
|
onError?.(e);
|
|
}
|
|
}}
|
|
>
|
|
<Button icon={<UploadOutlined />}>导入Excel</Button>
|
|
</Upload>
|
|
<PermissionButton
|
|
permission="student:view"
|
|
icon={<DownloadOutlined />}
|
|
onClick={handleDownloadTemplate}
|
|
>
|
|
下载模板
|
|
</PermissionButton>
|
|
<PermissionButton
|
|
permission="student:export"
|
|
icon={<ExportOutlined />}
|
|
onClick={handleExport}
|
|
>
|
|
导出名单
|
|
</PermissionButton>
|
|
</Space>
|
|
</div>
|
|
<Table
|
|
columns={columns}
|
|
dataSource={data}
|
|
rowKey="id"
|
|
loading={loading}
|
|
scroll={{ x: 1200 }}
|
|
pagination={{ pageSize: 15, 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' }),
|
|
}}
|
|
/>
|
|
<style>{`.archived-row { opacity: 0.6; background: #fafafa !important; }`}</style>
|
|
<Modal
|
|
title={editing ? '编辑学生' : '添加学生'}
|
|
open={modalOpen}
|
|
onOk={handleSave}
|
|
onCancel={() => {
|
|
setModalOpen(false);
|
|
setEditing(null);
|
|
}}
|
|
okText="保存"
|
|
>
|
|
<Form form={form} layout="vertical">
|
|
<Form.Item name="name" label="姓名" rules={[{ required: true }]}>
|
|
<Input />
|
|
</Form.Item>
|
|
<Form.Item name="studentNumber" 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="tenantId"
|
|
label="所属机构"
|
|
tooltip="选择租赁方,留空表示本机构"
|
|
>
|
|
<Select
|
|
allowClear
|
|
placeholder="选择租赁方"
|
|
options={tenants.map((t: { id: number; name: string }) => ({
|
|
value: t.id,
|
|
label: t.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>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default StudentsPage;
|