fix permissions and teacher attendance workflows
This commit is contained in:
@@ -1,38 +1,40 @@
|
||||
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Table,
|
||||
App,
|
||||
Button,
|
||||
Modal,
|
||||
Card,
|
||||
Col,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
message,
|
||||
Table,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
Upload,
|
||||
App,
|
||||
Row,
|
||||
Col,
|
||||
Card,
|
||||
Drawer,
|
||||
Descriptions,
|
||||
Empty,
|
||||
} from 'antd';
|
||||
import type { UploadProps } from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
UploadOutlined,
|
||||
DownloadOutlined,
|
||||
UndoOutlined,
|
||||
InboxOutlined,
|
||||
ExportOutlined,
|
||||
DeleteOutlined,
|
||||
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 { maskPhone, maskIdNumber } from '../../utils/sensitive';
|
||||
import { maskIdNumber, maskPhone } from '../../utils/sensitive';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
const statusMap: Record<string, { text: string; color: string }> = {
|
||||
active: { text: '在读', color: 'green' },
|
||||
@@ -129,10 +131,13 @@ const StudentsPage: React.FC = () => {
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: Record<string, unknown> = { name: searchName || undefined, includeArchived: 'true' };
|
||||
const params: Record<string, unknown> = {
|
||||
name: searchName || undefined,
|
||||
includeArchived: 'true',
|
||||
};
|
||||
if (filterStatus) params.status = filterStatus;
|
||||
if (filterTenantId) params.tenantId = filterTenantId;
|
||||
const res = await api.get('/students', { params }) as Array<Record<string, unknown>>;
|
||||
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);
|
||||
@@ -149,9 +154,12 @@ const StudentsPage: React.FC = () => {
|
||||
}, [fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/tenants', { params: { includeArchived: 'false' } }).then((res: unknown) => {
|
||||
setTenants(res as Array<{ id: number; name: string }>);
|
||||
}).catch(() => {});
|
||||
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();
|
||||
@@ -213,6 +221,23 @@ const StudentsPage: React.FC = () => {
|
||||
.catch(() => message.error('下载失败'));
|
||||
};
|
||||
|
||||
const handleMatchImport: 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 { message: string };
|
||||
message.success(res.message);
|
||||
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'
|
||||
@@ -232,128 +257,178 @@ const StudentsPage: React.FC = () => {
|
||||
.catch(() => message.error('导出失败'));
|
||||
};
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{ title: 'ID', dataIndex: 'id', width: 70 },
|
||||
{
|
||||
title: '姓名',
|
||||
dataIndex: 'name',
|
||||
width: 120,
|
||||
render: (v: string, record: any) => (
|
||||
<Button type="link" size="small" onClick={() => openDrawer(record.id)}>{v}</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '电话',
|
||||
dataIndex: 'phone',
|
||||
width: 140,
|
||||
render: (v: string, record: any) => {
|
||||
if (!v) return '-';
|
||||
return (
|
||||
<span>
|
||||
<span style={{ marginRight: 4 }}>{maskPhone(v)}</span>
|
||||
<Button type="link" size="small" style={{ padding: '8px 4px' }} onClick={() => handleViewSensitive(record.id, '电话', v)} title="点击查看完整号码">
|
||||
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
|
||||
</Button>
|
||||
</span>
|
||||
);
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{ title: 'ID', dataIndex: 'id', width: 70 },
|
||||
{
|
||||
title: '姓名',
|
||||
dataIndex: 'name',
|
||||
width: 120,
|
||||
render: (v: string, record: any) => (
|
||||
<Button type="link" size="small" onClick={() => openDrawer(record.id)}>
|
||||
{v}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '学号',
|
||||
dataIndex: 'studentNo',
|
||||
width: 120,
|
||||
render: (v: string) => v || '-',
|
||||
},
|
||||
{
|
||||
title: '身份证',
|
||||
dataIndex: 'idNumber',
|
||||
width: 180,
|
||||
render: (v: string, record: any) => {
|
||||
if (!v) return '-';
|
||||
return (
|
||||
<span>
|
||||
<span style={{ marginRight: 4 }}>{maskIdNumber(v)}</span>
|
||||
<Button type="link" size="small" style={{ padding: '8px 4px' }} onClick={() => handleViewSensitive(record.id, '身份证号', v)} title="点击查看完整号码">
|
||||
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
|
||||
</Button>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ title: '民族', dataIndex: 'ethnicity', width: 90 },
|
||||
{ title: '紧急联系人', dataIndex: 'emergencyContact', width: 100 },
|
||||
{ title: '紧急联系人电话', dataIndex: 'emergencyPhone', width: 130 },
|
||||
{
|
||||
title: '所属机构',
|
||||
dataIndex: 'tenant',
|
||||
width: 100,
|
||||
render: (tenant: { name?: string } | null) =>
|
||||
tenant?.name ? <Tag color="purple" style={{ maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis' }}>{tenant.name}</Tag> : '-',
|
||||
},
|
||||
{ title: '负责人', dataIndex: 'supervisor', width: 100 },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 80,
|
||||
render: (s: string) => <Tag color={statusMap[s]?.color} style={{ maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis' }}>{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:view"
|
||||
size="small"
|
||||
{
|
||||
title: '电话',
|
||||
dataIndex: 'phone',
|
||||
width: 140,
|
||||
render: (v: string, record: any) => {
|
||||
if (!v) return '-';
|
||||
return (
|
||||
<span>
|
||||
<span style={{ marginRight: 4 }}>{maskPhone(v)}</span>
|
||||
<Button
|
||||
type="link"
|
||||
onClick={() => openDrawer(record.id)}
|
||||
>
|
||||
档案
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
permission="student:edit"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setEditing(record);
|
||||
form.setFieldsValue(record);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
style={{ padding: '8px 4px' }}
|
||||
onClick={() => handleViewSensitive(record.id, '电话', v)}
|
||||
title="点击查看完整号码"
|
||||
>
|
||||
编辑
|
||||
</PermissionButton>
|
||||
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
|
||||
</Button>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '学号',
|
||||
dataIndex: 'studentNo',
|
||||
width: 120,
|
||||
render: (v: string) => v || '-',
|
||||
},
|
||||
{
|
||||
title: '身份证',
|
||||
dataIndex: 'idNumber',
|
||||
width: 180,
|
||||
render: (v: string, record: any) => {
|
||||
if (!v) return '-';
|
||||
return (
|
||||
<span>
|
||||
<span style={{ marginRight: 4 }}>{maskIdNumber(v)}</span>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
style={{ padding: '8px 4px' }}
|
||||
onClick={() => handleViewSensitive(record.id, '身份证号', v)}
|
||||
title="点击查看完整号码"
|
||||
>
|
||||
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
|
||||
</Button>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ title: '民族', dataIndex: 'ethnicity', width: 90 },
|
||||
{ title: '紧急联系人', dataIndex: 'emergencyContact', width: 100 },
|
||||
{ title: '紧急联系人电话', dataIndex: 'emergencyPhone', width: 130 },
|
||||
{
|
||||
title: '所属机构',
|
||||
dataIndex: 'tenant',
|
||||
width: 100,
|
||||
render: (tenant: { name?: string } | null) =>
|
||||
tenant?.name ? (
|
||||
<Tag
|
||||
color="purple"
|
||||
style={{ maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis' }}
|
||||
>
|
||||
{tenant.name}
|
||||
</Tag>
|
||||
) : (
|
||||
'-'
|
||||
),
|
||||
},
|
||||
{ title: '负责人', dataIndex: 'supervisor', width: 100 },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 80,
|
||||
render: (s: string) => (
|
||||
<Tag
|
||||
color={statusMap[s]?.color}
|
||||
style={{ maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis' }}
|
||||
>
|
||||
{statusMap[s]?.text || s}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 180,
|
||||
render: (_: any, record: any) => (
|
||||
<Space>
|
||||
{record.status === 'archived' ? (
|
||||
<Popconfirm
|
||||
title="归档后不会删除数据,可随时恢复。确定归档?"
|
||||
onConfirm={() => handleArchive(record.id)}
|
||||
okText="归档"
|
||||
title="确定恢复此学生?恢复后将重新出现在学生列表中。"
|
||||
onConfirm={() => handleRestore(record.id)}
|
||||
okText="恢复"
|
||||
cancelText="取消"
|
||||
>
|
||||
<PermissionButton permission="student:delete" size="small" icon={<InboxOutlined />}>
|
||||
归档
|
||||
<PermissionButton
|
||||
permission="student:edit"
|
||||
size="small"
|
||||
icon={<UndoOutlined />}
|
||||
type="link"
|
||||
>
|
||||
恢复
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
], [handleViewSensitive, openDrawer, showArchived, tenants]);
|
||||
) : (
|
||||
<>
|
||||
<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, tenants],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Space wrap>
|
||||
<Input.Search
|
||||
placeholder="搜索学生姓名"
|
||||
@@ -361,11 +436,37 @@ const StudentsPage: React.FC = () => {
|
||||
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
|
||||
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={filterTenantId} onChange={(v) => { setFilterTenantId(v); }}>
|
||||
{tenants.map((t: { id: number; name: string }) => <Select.Option key={t.id} value={t.id}>{t.name}</Select.Option>)}
|
||||
<Select
|
||||
placeholder="所属机构"
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
value={filterTenantId}
|
||||
onChange={(v) => {
|
||||
setFilterTenantId(v);
|
||||
}}
|
||||
>
|
||||
{tenants.map((t: { id: number; name: string }) => (
|
||||
<Select.Option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Button
|
||||
type={showArchived ? 'primary' : 'default'}
|
||||
@@ -421,12 +522,15 @@ const StudentsPage: React.FC = () => {
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '导入失败');
|
||||
onError?.(e);
|
||||
onError?.(e instanceof Error ? e : new Error(e?.message || '导入失败'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button icon={<UploadOutlined />}>导入Excel</Button>
|
||||
</Upload>
|
||||
<Upload accept=".xlsx,.xls" showUploadList={false} customRequest={handleMatchImport}>
|
||||
<Button icon={<SwapOutlined />}>匹配导入</Button>
|
||||
</Upload>
|
||||
<PermissionButton
|
||||
permission="student:view"
|
||||
icon={<DownloadOutlined />}
|
||||
@@ -481,8 +585,12 @@ const StudentsPage: React.FC = () => {
|
||||
>
|
||||
<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="开班日期">
|
||||
{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 || '-'}
|
||||
@@ -509,7 +617,7 @@ const StudentsPage: React.FC = () => {
|
||||
}
|
||||
},
|
||||
}}
|
||||
/>
|
||||
/>
|
||||
<style>{`.archived-row { opacity: 0.6; background: #fafafa !important; }`}</style>
|
||||
<Modal
|
||||
title={editing ? '编辑学生' : '添加学生'}
|
||||
@@ -553,11 +661,7 @@ const StudentsPage: React.FC = () => {
|
||||
<Form.Item name="emergencyPhone" label="紧急联系人电话">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="tenantId"
|
||||
label="所属机构"
|
||||
tooltip="选择租赁方,留空表示本机构"
|
||||
>
|
||||
<Form.Item name="tenantId" label="所属机构" tooltip="选择租赁方,留空表示本机构">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="选择租赁方"
|
||||
@@ -587,14 +691,18 @@ const StudentsPage: React.FC = () => {
|
||||
<Drawer
|
||||
title={null}
|
||||
open={drawerOpen}
|
||||
onClose={() => { setDrawerOpen(false); }}
|
||||
onClose={() => {
|
||||
setDrawerOpen(false);
|
||||
}}
|
||||
size={720}
|
||||
>
|
||||
{drawerStudentId && (
|
||||
<StudentProfileContent
|
||||
studentId={drawerStudentId}
|
||||
inDrawer
|
||||
onClose={() => { setDrawerOpen(false); }}
|
||||
onClose={() => {
|
||||
setDrawerOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
Reference in New Issue
Block a user