fix permissions and teacher attendance workflows

This commit is contained in:
2026-07-10 20:40:52 +08:00
parent 247879f276
commit 8ed1682b90
95 changed files with 4745 additions and 1237 deletions

View File

@@ -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>