feat: 重构各业务模块管理页面与服务

This commit is contained in:
2026-08-05 17:12:00 +08:00
parent 80e6fccf05
commit fd39e1686a
163 changed files with 18409 additions and 13449 deletions

View File

@@ -0,0 +1,388 @@
// aislop-ignore-file: duplicate-block -- 单元格渲染结构相似且字段不同,逻辑已通过 EditableStudentCell 共享
import React from 'react';
import { Button, Popconfirm, Space, Tag } from 'antd';
import { EyeOutlined, InboxOutlined, UndoOutlined } from '@ant-design/icons';
import PermissionButton from '../../components/PermissionButton';
import EditableCell from '../../components/EditableCell';
import { maskIdNumber, maskPhone } from '../../utils/sensitive';
export const statusMap: Record<string, { text: string; color: string }> = {
active: { text: '在读', color: 'green' },
graduated: { text: '已毕业', color: 'blue' },
withdrawn: { text: '已退训', color: 'red' },
archived: { text: '已归档', color: '#999' },
};
export const STUDENT_FIELDS = {
name: 'name',
studentNo: 'studentNo',
ethnicity: 'ethnicity',
emergencyContact: 'emergencyContact',
supervisor: 'supervisor',
status: 'status',
organizationId: 'organizationId',
} as const;
export const SENSITIVE_LABELS = {
phone: '电话',
idNumber: '身份证号',
emergencyPhone: '紧急联系人电话',
} as const;
export const STUDENT_STATUS_OPTIONS = [
{ value: 'active', label: '在读' },
{ value: 'graduated', label: '已毕业' },
{ value: 'withdrawn', label: '已退训' },
];
export interface StudentColumnContext {
pageInfo: { current: number; pageSize: number };
organizations: Array<{ id: number; name: string; isHost?: boolean }>;
canChooseOrganization: boolean;
canEditStudent: boolean;
canDeleteStudent: boolean;
canPurgeStudent: boolean;
canViewSensitive: boolean;
onSaveCell: (record: any, field: string, value: unknown) => Promise<void> | void;
onViewSensitive: (recordId: number, field: string, value: string) => void;
onOpenDrawer: (recordId: number) => void;
onEdit: (record: any) => void;
onRestore: (id: number) => Promise<unknown> | unknown;
onPurge: (id: number, name: string) => void;
onArchive: (id: number) => Promise<unknown> | unknown;
}
export const EditableStudentCell = <R extends { id: number; status?: string }>({
value,
field,
record,
editor,
min,
max,
required,
options,
onSave,
children,
}: {
value: unknown;
field: string;
record: R;
editor?: React.ComponentProps<typeof EditableCell>['editor'];
min?: number;
max?: number;
required?: boolean;
options?: Array<{ value: string | number; label: string }>;
onSave: (record: R, field: string, value: unknown) => Promise<void> | void;
children?: React.ReactNode;
}) => (
<EditableCell
value={value}
editor={editor}
min={min}
max={max}
required={required}
options={options}
permission="student:edit"
disabled={record.status === 'archived'}
onSave={async (next) => {
await onSave(record, field, next);
}}
>
{children ?? String(value ?? '-')}
</EditableCell>
);
export const SensitiveValue: React.FC<{
value: string;
masked: string;
label: string;
recordId: number;
canViewSensitive: boolean;
onViewSensitive: (recordId: number, field: string, value: string) => void;
}> = ({ value, masked, label, recordId, canViewSensitive, onViewSensitive }) => {
if (!value) return <>-</>;
return (
<span style={{ display: 'inline-flex', alignItems: 'center', whiteSpace: 'nowrap' }}>
<span style={{ marginRight: 4 }}>{masked}</span>
{canViewSensitive ? (
<Button
type="link"
size="small"
style={{ padding: '8px 4px', flex: 'none' }}
onClick={() => onViewSensitive(recordId, label, value)}
title="点击查看完整号码"
>
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
</Button>
) : null}
</span>
);
};
function buildIdentityColumns(ctx: StudentColumnContext) {
const {
pageInfo,
canViewSensitive,
onSaveCell,
onViewSensitive,
} = ctx;
return [
{
title: '序号',
key: 'index',
width: 70,
render: (_: unknown, __: unknown, index: number) =>
(pageInfo.current - 1) * pageInfo.pageSize + index + 1,
},
{
title: '姓名',
dataIndex: 'name',
width: 120,
render: (v: string, record: any) => (
<EditableStudentCell value={v} field={STUDENT_FIELDS.name} record={record} required onSave={onSaveCell}>
{v}
</EditableStudentCell>
),
},
{
title: '电话',
dataIndex: 'phone',
width: 140,
render: (v: string, record: any) => (
<SensitiveValue
value={v}
masked={maskPhone(v)}
label={SENSITIVE_LABELS.phone}
recordId={record.id}
canViewSensitive={canViewSensitive}
onViewSensitive={onViewSensitive}
/>
),
},
{
title: '学号',
dataIndex: 'studentNo',
width: 120,
render: (v: string, record: any) => (
<EditableStudentCell value={v} field={STUDENT_FIELDS.studentNo} record={record} onSave={onSaveCell}>
{v || '-'}
</EditableStudentCell>
),
},
{
title: '身份证',
dataIndex: 'idNumber',
width: 180,
render: (v: string, record: any) => (
<SensitiveValue
value={v}
masked={maskIdNumber(v)}
label={SENSITIVE_LABELS.idNumber}
recordId={record.id}
canViewSensitive={canViewSensitive}
onViewSensitive={onViewSensitive}
/>
),
},
];
}
function buildContactColumns(ctx: StudentColumnContext) {
const { organizations, canChooseOrganization, canViewSensitive, onSaveCell, onViewSensitive } =
ctx;
return [
{
title: '民族',
dataIndex: 'ethnicity',
width: 90,
render: (v: string, record: any) => (
<EditableStudentCell value={v} field={STUDENT_FIELDS.ethnicity} record={record} onSave={onSaveCell}>
{v || '-'}
</EditableStudentCell>
),
},
{
title: '紧急联系人',
dataIndex: 'emergencyContact',
width: 100,
render: (v: string, record: any) => (
<EditableStudentCell
value={v}
field={STUDENT_FIELDS.emergencyContact}
record={record}
onSave={onSaveCell}
>
{v || '-'}
</EditableStudentCell>
),
},
{
title: '紧急联系人电话',
dataIndex: 'emergencyPhone',
width: 150,
render: (v: string, record: any) => (
<SensitiveValue
value={v}
masked={maskPhone(v)}
label={SENSITIVE_LABELS.emergencyPhone}
recordId={record.id}
canViewSensitive={canViewSensitive}
onViewSensitive={onViewSensitive}
/>
),
},
{
title: '所属机构',
dataIndex: 'organization',
width: 100,
render: (organization: { name?: string } | null, record: any) =>
canChooseOrganization ? (
<EditableStudentCell
value={record.organizationId}
field={STUDENT_FIELDS.organizationId}
record={record}
editor="select"
options={organizations.map((item) => ({ value: item.id, label: item.name }))}
required
onSave={onSaveCell}
>
{organization?.name ? (
<Tag
color="purple"
style={{ maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis' }}
>
{organization.name}
</Tag>
) : (
'-'
)}
</EditableStudentCell>
) : organization?.name ? (
<Tag color="purple">{organization.name}</Tag>
) : (
'-'
),
},
];
}
function buildProfileColumns(ctx: StudentColumnContext) {
const { onSaveCell } = ctx;
return [
{
title: '负责人',
dataIndex: 'supervisor',
width: 100,
render: (v: string, record: any) => (
<EditableStudentCell value={v} field={STUDENT_FIELDS.supervisor} record={record} onSave={onSaveCell}>
{v || '-'}
</EditableStudentCell>
),
},
{
title: '状态',
dataIndex: 'status',
width: 80,
render: (s: string, record: any) => (
<EditableStudentCell
value={s}
field={STUDENT_FIELDS.status}
record={record}
editor="select"
options={STUDENT_STATUS_OPTIONS}
onSave={onSaveCell}
>
<Tag
color={statusMap[s]?.color}
style={{ maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis' }}
>
{statusMap[s]?.text || s}
</Tag>
</EditableStudentCell>
),
},
];
}
function buildActionColumn(ctx: StudentColumnContext) {
const {
canEditStudent,
canDeleteStudent,
canPurgeStudent,
onOpenDrawer,
onEdit,
onRestore,
onPurge,
onArchive,
} = ctx;
return {
title: '操作',
width: 180,
render: (_: any, record: any) => (
<Space>
{record.status === 'archived' ? (
<>
{canEditStudent ? (
<Popconfirm
title="确定恢复此学生?恢复后将重新出现在学生列表中。"
onConfirm={() => onRestore(record.id)}
okText="恢复"
cancelText="取消"
>
<Button size="small" icon={<UndoOutlined />} type="link">
</Button>
</Popconfirm>
) : null}
{canPurgeStudent ? (
<Button
size="small"
danger
type="link"
onClick={() => onPurge(record.id, record.name)}
>
</Button>
) : null}
</>
) : (
<>
<PermissionButton
permission="student:view"
size="small"
type="link"
onClick={() => onOpenDrawer(record.id)}
>
</PermissionButton>
<PermissionButton permission="student:edit" size="small" onClick={() => onEdit(record)}>
</PermissionButton>
{canDeleteStudent ? (
<Popconfirm
title="归档后不会删除数据,可随时恢复。确定归档?"
onConfirm={() => onArchive(record.id)}
okText="归档"
cancelText="取消"
>
<Button size="small" icon={<InboxOutlined />}>
</Button>
</Popconfirm>
) : null}
</>
)}
</Space>
),
};
}
export function buildStudentColumns(ctx: StudentColumnContext) {
return [
...buildIdentityColumns(ctx),
...buildContactColumns(ctx),
...buildProfileColumns(ctx),
buildActionColumn(ctx),
];
}

View File

@@ -0,0 +1,179 @@
import React from 'react';
import { App, Descriptions, Drawer, Form, Input, Modal, Select } from 'antd';
import JinshujuMatchModal from '../../components/JinshujuMatchModal';
import StudentProfileContent from '../../components/StudentProfileContent';
import { SENSITIVE_LABELS } from './StudentColumns';
type AppModal = ReturnType<typeof App.useApp>['modal'];
export const showCreateImportResult = (
modal: AppModal,
result: { message?: string; imported?: number; skipped?: number },
) => {
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>
),
});
};
export const showUpdateImportResult = (
modal: AppModal,
result: { message?: string; matched?: number; skipped?: number },
) => {
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>
),
});
};
export const StudentEditModal: React.FC<{
open: boolean;
editing: boolean;
saving: boolean;
form: ReturnType<typeof Form.useForm>[0];
canChooseOrganization: boolean;
organizations: Array<{ id: number; name: string; isHost?: boolean }>;
onOk?: () => void;
onCancel: () => void;
}> = ({
open,
editing,
saving,
form,
canChooseOrganization,
organizations,
onOk,
onCancel,
}) => {
return (
<Modal
title={editing ? '编辑学生' : '添加学生'}
className="student-form-modal"
width={720}
open={open}
onOk={onOk}
onCancel={onCancel}
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={SENSITIVE_LABELS.phone}>
<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={SENSITIVE_LABELS.emergencyPhone}>
<Input />
</Form.Item>
{canChooseOrganization ? (
<Form.Item
name="organizationId"
label="所属机构"
rules={[{ required: true, message: '请选择所属机构' }]}
>
<Select
showSearch
optionFilterProp="label"
placeholder="选择所属机构"
options={organizations.map((organization) => ({
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>
);
};
export const JinshujuModal: React.FC<{
open: boolean;
onClose: () => void;
onApplied: () => void;
}> = ({ open, onClose, onApplied }) => {
return <JinshujuMatchModal open={open} onClose={onClose} onApplied={onApplied} />;
};
export const StudentDrawer: React.FC<{
open: boolean;
studentId: number | null;
onClose: () => void;
}> = ({ open, studentId, onClose }) => {
return (
<Drawer title={null} open={open} onClose={onClose} size={720}>
{studentId && <StudentProfileContent studentId={studentId} inDrawer onClose={onClose} />}
</Drawer>
);
};

View File

@@ -0,0 +1,138 @@
import React from 'react';
import { Alert, Button, Card, Col, Descriptions, Empty, Row, Table, Tag } from 'antd';
import api from '../../api';
export interface EnrollmentInfo {
classId: number;
className: string;
classType: string;
startDate: string;
endDate: string;
joinDate: string;
leaveDate: string;
status: string;
}
export const StudentsTable: React.FC<{
columns: any[];
data: any[];
loading: boolean;
pageInfo: { current: number; pageSize: number };
onPageChange: (current: number, pageSize: number) => void;
selectedRowKeys: number[];
onSelect: (keys: number[]) => void;
onClearSelection: () => void;
}> = ({
columns,
data,
loading,
pageInfo,
onPageChange,
selectedRowKeys,
onSelect,
onClearSelection,
}) => {
const [enrollmentData, setEnrollmentData] = React.useState<Record<number, EnrollmentInfo[]>>({});
return (
<>
{selectedRowKeys.length > 0 ? (
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
title={
<span>
<strong style={{ color: '#1677ff' }}>{selectedRowKeys.length}</strong>{' '}
</span>
}
action={
<Button size="small" type="link" onClick={onClearSelection}>
</Button>
}
/>
) : null}
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }}
scroll={{ x: 1410 }}
pagination={{
defaultPageSize: 15,
current: pageInfo.current,
pageSize: pageInfo.pageSize,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50, 100],
showTotal: (total) => `${total}`,
onChange: onPageChange,
}}
rowClassName={(record: any) => (record.status === 'archived' ? 'archived-row' : '')}
rowSelection={{
selectedRowKeys,
onChange: (keys) => onSelect(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>
</>
);
};

View File

@@ -0,0 +1,272 @@
import React from 'react';
import {
Button,
Input,
Popconfirm,
Select,
Space,
Upload,
} from 'antd';
import type { UploadProps } from 'antd';
import {
CloudUploadOutlined,
DeleteOutlined,
DownloadOutlined,
ExportOutlined,
InboxOutlined,
PlusOutlined,
SwapOutlined,
SyncOutlined,
UndoOutlined,
UploadOutlined,
} from '@ant-design/icons';
import PermissionButton from '../../components/PermissionButton';
import { statusMap } from './StudentColumns';
export interface StudentsToolbarProps {
onSearchName: (value: string) => void;
filterStatus?: string;
onFilterStatus: (value?: string) => void;
effectiveFilterOrganizationId?: number;
onFilterOrganization: (value?: number) => void;
canViewOrganizations: boolean;
organizations: Array<{ id: number; name: string }>;
filterClassId?: number;
onFilterClass: (value?: number) => void;
classOptions: Array<{ id: number; name: string; code?: string }>;
filterTeacherId?: number;
onFilterTeacher: (value?: number) => void;
teacherOptions: Array<{ id: number; name: string; username: string }>;
showArchived: boolean;
onToggleArchived: () => void;
selectedRowKeys: number[];
batchLoading: boolean;
canEditStudent: boolean;
canPurgeStudent: boolean;
canDeleteStudent: boolean;
canSyncJinshuju: boolean;
canSyncDingTalk: boolean;
dingSyncLoading: boolean;
onBatchRestore: () => void;
onBatchPurge: () => void;
onBatchDelete: () => void;
onAddStudent: () => void;
onOpenJinshuju: () => void;
onDingTalkSync: () => void;
onCreateImport: UploadProps['customRequest'];
onUpdateImport: UploadProps['customRequest'];
onDownloadTemplate: () => void;
onExport: () => void;
}
export const StudentsToolbar: React.FC<StudentsToolbarProps> = ({
onSearchName,
filterStatus,
onFilterStatus,
effectiveFilterOrganizationId,
onFilterOrganization,
canViewOrganizations,
organizations,
filterClassId,
onFilterClass,
classOptions,
filterTeacherId,
onFilterTeacher,
teacherOptions,
showArchived,
onToggleArchived,
selectedRowKeys,
batchLoading,
canEditStudent,
canPurgeStudent,
canDeleteStudent,
canSyncJinshuju,
canSyncDingTalk,
dingSyncLoading,
onBatchRestore,
onBatchPurge,
onBatchDelete,
onAddStudent,
onOpenJinshuju,
onDingTalkSync,
onCreateImport,
onUpdateImport,
onDownloadTemplate,
onExport,
}) => {
return (
<>
<div className="responsive-toolbar">
<Space wrap className="responsive-toolbar__group">
<Input.Search
placeholder="搜索学生姓名"
onSearch={onSearchName}
allowClear
style={{ width: 250 }}
/>
<Select
placeholder="状态筛选"
allowClear
style={{ width: 120 }}
value={filterStatus}
onChange={onFilterStatus}
>
{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={effectiveFilterOrganizationId}
onChange={onFilterOrganization}
>
{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={onFilterClass}
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={onFilterTeacher}
options={teacherOptions.map((item) => ({
value: item.id,
label: item.name === item.username ? item.name : `${item.name}${item.username}`,
}))}
/>
<Button type={showArchived ? 'primary' : 'default'} onClick={onToggleArchived}>
{showArchived ? '返回正常数据' : '查看已归档'}
</Button>
</Space>
<Space wrap className="responsive-toolbar__group">
{showArchived && canEditStudent ? (
<>
<Popconfirm
title={`确定批量恢复选中的 ${selectedRowKeys.length} 名学生?`}
onConfirm={onBatchRestore}
okText="恢复"
cancelText="取消"
disabled={selectedRowKeys.length === 0}
>
<Button
type="primary"
icon={<UndoOutlined />}
disabled={selectedRowKeys.length === 0}
loading={batchLoading}
>
</Button>
</Popconfirm>
{canPurgeStudent ? (
<Popconfirm
title={`确定永久删除选中的 ${selectedRowKeys.length} 名学生?删除后不可恢复!`}
onConfirm={onBatchPurge}
okText="永久删除"
okButtonProps={{ danger: true }}
cancelText="取消"
disabled={selectedRowKeys.length === 0}
>
<Button
danger
icon={<DeleteOutlined />}
disabled={selectedRowKeys.length === 0}
loading={batchLoading}
>
</Button>
</Popconfirm>
) : null}
</>
) : !showArchived && canDeleteStudent ? (
<Popconfirm
title={`确定批量归档选中的 ${selectedRowKeys.length} 名学生?(数据保留,可恢复)`}
onConfirm={onBatchDelete}
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={onAddStudent}
>
</PermissionButton>
) : null}
{!showArchived && (
<>
<Upload accept=".xlsx,.xls" showUploadList={false} customRequest={onCreateImport}>
<Button icon={<UploadOutlined />}>Excel</Button>
</Upload>
<Upload accept=".xlsx,.xls" showUploadList={false} customRequest={onUpdateImport}>
<Button icon={<SwapOutlined />}></Button>
</Upload>
</>
)}
{!showArchived && canSyncJinshuju ? (
<Button icon={<CloudUploadOutlined />} onClick={onOpenJinshuju}>
</Button>
) : null}
{!showArchived && canSyncDingTalk ? (
<Button icon={<SyncOutlined />} loading={dingSyncLoading} onClick={onDingTalkSync}>
</Button>
) : null}
<PermissionButton
permission="student:view"
icon={<DownloadOutlined />}
onClick={onDownloadTemplate}
>
</PermissionButton>
<PermissionButton
permission="student:export"
icon={<ExportOutlined />}
onClick={onExport}
>
</PermissionButton>
</Space>
</div>
</>
);
};

File diff suppressed because it is too large Load Diff