- 新增 queryKeys.ts 统一 QueryKey 工厂(覆盖全部模块,invalidate/refetch/prefetch 同源) - 新增 queryClient.ts 全局配置:retry 1、staleTime 30s、gcTime 5min、refetchOnWindowFocus false - 新增 useApiQuery:zod schema 校验 + 类型收敛 + select 转换,消除页面重复 validateResponse 样板 - useApiMutation 支持 onMutate/onSettled/context(乐观更新) - 示范迁移:Organizations(useApiQuery + 乐观更新归档/恢复)、Students(useApiQuery + queryKeys + 打开抽屉前 prefetch 档案聚合) - StudentProfileContent queryKey 统一为 queryKeys.archive.detail,prefetch 可命中缓存
652 lines
20 KiB
TypeScript
652 lines
20 KiB
TypeScript
import React, { useCallback, useMemo } from 'react';
|
||
import {
|
||
Tabs,
|
||
Card,
|
||
Descriptions,
|
||
Table,
|
||
Button,
|
||
Tag,
|
||
Space,
|
||
Empty,
|
||
Row,
|
||
Col,
|
||
Statistic,
|
||
Spin,
|
||
} from 'antd';
|
||
import type { ColumnsType } from 'antd/es/table';
|
||
import {
|
||
EyeOutlined,
|
||
CloseOutlined,
|
||
FileTextOutlined,
|
||
ReloadOutlined,
|
||
} from '@ant-design/icons';
|
||
import dayjs from 'dayjs';
|
||
import api from '../../api';
|
||
import { maskPhone, maskIdNumber } from '../../utils/sensitive';
|
||
import { useViewSensitive } from '../../hooks/useViewSensitive';
|
||
import { message } from '../../ui/app-message';
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { useApiMutation } from '../../hooks/useApiMutation';
|
||
import { validateResponse } from '../../utils/validate';
|
||
import { queryKeys } from '../../api/queryKeys';
|
||
import { organizationOptionsSchema, studentProfileAggregateSchema } from '../../api/schemas';
|
||
import EditableCell from '../EditableCell';
|
||
import { usePermission } from '../../hooks/usePermission';
|
||
import { QueryErrorState } from '../QueryState';
|
||
|
||
import { ADMISSION_STATUS_MAP, ATTENDANCE_STATUS_MAP, SESSION_LABELS, getOptionLabel } from './shared';
|
||
import type { AttendanceRecordItem, ProfileData, ResultData, StudentInfo, StudentProfileAggregate, StudentProfileContentProps } from './shared';
|
||
import { EnrollmentsTab } from './EnrollmentsTab';
|
||
import { ExamScoresTab } from './ExamScoresTab';
|
||
import { LearningTab } from './LearningTab';
|
||
import { AttachmentsTab } from './AttachmentsTab';
|
||
|
||
const EditableField: React.FC<{
|
||
value: unknown;
|
||
onSave: (value: unknown) => Promise<void> | void;
|
||
editor?: React.ComponentProps<typeof EditableCell>['editor'];
|
||
min?: number;
|
||
required?: boolean;
|
||
children?: React.ReactNode;
|
||
}> = ({ value, onSave, editor, min, required, children }) => (
|
||
<EditableCell
|
||
value={value}
|
||
editor={editor}
|
||
min={min}
|
||
required={required}
|
||
permission="student:edit"
|
||
onSave={async (next) => {
|
||
await onSave(next);
|
||
}}
|
||
>
|
||
{children ?? String(value ?? '-')}
|
||
</EditableCell>
|
||
);
|
||
|
||
const AttendanceTab: React.FC<{ data: AttendanceRecordItem[] }> = ({ data }) => {
|
||
const columns: ColumnsType<AttendanceRecordItem> = [
|
||
{ title: '日期', dataIndex: 'attendanceDate', width: 120 },
|
||
{
|
||
title: '课程',
|
||
render: (_: unknown, record) => record.schedule?.subject || record.class?.name || '课程考勤',
|
||
},
|
||
{
|
||
title: '时段',
|
||
dataIndex: 'session',
|
||
width: 100,
|
||
render: (value: string) => SESSION_LABELS[value] || value || '-',
|
||
},
|
||
{
|
||
title: '结果',
|
||
dataIndex: 'status',
|
||
width: 90,
|
||
render: (value: string) => {
|
||
const meta = ATTENDANCE_STATUS_MAP[value] || { text: value || '-', color: 'default' };
|
||
return <Tag color={meta.color}>{meta.text}</Tag>;
|
||
},
|
||
},
|
||
{
|
||
title: '打卡时间',
|
||
dataIndex: 'punchTime',
|
||
width: 170,
|
||
render: (value?: string | null) => (value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '-'),
|
||
},
|
||
{
|
||
title: '打卡设备',
|
||
render: (_: unknown, record) => {
|
||
const name = record.punchDeviceName?.trim();
|
||
const id = record.punchDeviceId?.trim();
|
||
if (name && id && name !== id) return `${name}(${id})`;
|
||
return name || id || (record.source === 'manual' ? '老师手动标记' : '-');
|
||
},
|
||
},
|
||
{ title: '备注', dataIndex: 'remark', render: (value?: string | null) => value || '-' },
|
||
];
|
||
|
||
return data.length > 0 ? (
|
||
<Table<AttendanceRecordItem>
|
||
columns={columns}
|
||
dataSource={data}
|
||
rowKey="id"
|
||
scroll={{ x: 900 }}
|
||
pagination={{ defaultPageSize: 15, showSizeChanger: true, pageSizeOptions: [15, 30, 50] }}
|
||
/>
|
||
) : (
|
||
<Empty description="暂无出勤记录" />
|
||
);
|
||
};
|
||
|
||
const InlineArchiveSummary: React.FC<{
|
||
studentId: number;
|
||
student: StudentInfo;
|
||
profile: ProfileData | null;
|
||
result: ResultData | null;
|
||
organizations: Array<{ id: number; name: string }>;
|
||
onRefresh: () => void;
|
||
onViewSensitive: (fieldLabel: string, value: string) => void;
|
||
canViewSensitive: boolean;
|
||
canChooseOrganization: boolean;
|
||
}> = ({
|
||
studentId,
|
||
student,
|
||
profile,
|
||
result,
|
||
organizations,
|
||
onViewSensitive,
|
||
canViewSensitive,
|
||
canChooseOrganization,
|
||
}) => {
|
||
const saveStudentMutation = useApiMutation(
|
||
async ({ field, value }: { field: keyof StudentInfo; value: unknown }) =>
|
||
api.put(`/students/${studentId}`, { [field]: value }),
|
||
{ invalidate: [['archive', studentId], ['students']] },
|
||
);
|
||
const saveProfileMutation = useApiMutation(
|
||
async ({ field, value }: { field: keyof ProfileData; value: unknown }) =>
|
||
api.put(`/archive/${studentId}/profile`, { [field]: value }),
|
||
{ invalidate: [['archive', studentId]] },
|
||
);
|
||
const saveResultMutation = useApiMutation(
|
||
async ({ field, value }: { field: keyof ResultData; value: unknown }) =>
|
||
api.put(`/archive/${studentId}/result`, { [field]: value }),
|
||
{ invalidate: [['archive', studentId]] },
|
||
);
|
||
|
||
const saveStudent = async (field: keyof StudentInfo, value: unknown) => {
|
||
try {
|
||
await saveStudentMutation.mutateAsync({ field, value });
|
||
message.success('学生资料已保存');
|
||
} catch {
|
||
// 错误提示由 useApiMutation 统一处理
|
||
}
|
||
};
|
||
|
||
const saveProfile = async (field: keyof ProfileData, value: unknown) => {
|
||
try {
|
||
await saveProfileMutation.mutateAsync({ field, value });
|
||
message.success('档案已保存');
|
||
} catch {
|
||
// 错误提示由 useApiMutation 统一处理
|
||
}
|
||
};
|
||
|
||
const saveResult = async (field: keyof ResultData, value: unknown) => {
|
||
try {
|
||
await saveResultMutation.mutateAsync({ field, value });
|
||
message.success('录取信息已保存');
|
||
} catch {
|
||
// 错误提示由 useApiMutation 统一处理
|
||
}
|
||
};
|
||
|
||
const admissionStatus = getOptionLabel(
|
||
Object.entries(ADMISSION_STATUS_MAP).map(([value, meta]) => ({
|
||
value,
|
||
label: meta.text,
|
||
})),
|
||
result?.admissionStatus,
|
||
);
|
||
|
||
return (
|
||
<Descriptions bordered column={{ xs: 1, sm: 2, lg: 3 }} size="small" style={{ marginBottom: 24 }}>
|
||
<Descriptions.Item label="手机号">
|
||
<EditableCell
|
||
value={student.phone}
|
||
permission="student:edit"
|
||
onSave={(next) => saveStudent('phone', next)}
|
||
>
|
||
{student.phone ? (
|
||
<span>
|
||
<span style={{ marginRight: 8 }}>{maskPhone(student.phone)}</span>
|
||
{canViewSensitive ? (
|
||
<a onClick={() => onViewSensitive('电话', student.phone)}>
|
||
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
|
||
</a>
|
||
) : null}
|
||
</span>
|
||
) : (
|
||
'-'
|
||
)}
|
||
</EditableCell>
|
||
</Descriptions.Item>
|
||
|
||
<Descriptions.Item label="姓名">
|
||
<EditableField value={student.name} required onSave={(next) => saveStudent('name', next)}>
|
||
{student.name || '-'}
|
||
</EditableField>
|
||
</Descriptions.Item>
|
||
|
||
<Descriptions.Item label="学号">
|
||
<EditableField value={student.studentNo} onSave={(next) => saveStudent('studentNo', next)}>
|
||
{student.studentNo || '-'}
|
||
</EditableField>
|
||
</Descriptions.Item>
|
||
|
||
<Descriptions.Item label="性别">
|
||
<EditableField value={student.gender} onSave={(next) => saveStudent('gender', next)}>
|
||
{student.gender || '-'}
|
||
</EditableField>
|
||
</Descriptions.Item>
|
||
|
||
<Descriptions.Item label="身份证号">
|
||
<EditableCell
|
||
value={student.idNumber}
|
||
permission="student:edit"
|
||
onSave={(next) => saveStudent('idNumber', next)}
|
||
>
|
||
{student.idNumber ? (
|
||
<span>
|
||
<span style={{ marginRight: 8 }}>{maskIdNumber(student.idNumber)}</span>
|
||
{canViewSensitive ? (
|
||
<a onClick={() => onViewSensitive('身份证号', student.idNumber)}>
|
||
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
|
||
</a>
|
||
) : null}
|
||
</span>
|
||
) : (
|
||
'-'
|
||
)}
|
||
</EditableCell>
|
||
</Descriptions.Item>
|
||
|
||
<Descriptions.Item label="民族">
|
||
<EditableField value={student.ethnicity} onSave={(next) => saveStudent('ethnicity', next)}>
|
||
{student.ethnicity || '-'}
|
||
</EditableField>
|
||
</Descriptions.Item>
|
||
|
||
<Descriptions.Item label="紧急联系人">
|
||
<EditableCell
|
||
value={student.emergencyContact}
|
||
permission="student:edit"
|
||
onSave={(next) => saveStudent('emergencyContact', next)}
|
||
>
|
||
{student.emergencyContact || '-'}
|
||
</EditableCell>
|
||
</Descriptions.Item>
|
||
|
||
<Descriptions.Item label="紧急联系人电话">
|
||
<EditableCell
|
||
value={student.emergencyPhone}
|
||
permission="student:edit"
|
||
onSave={(next) => saveStudent('emergencyPhone', next)}
|
||
>
|
||
{student.emergencyPhone ? (
|
||
<span>
|
||
<span style={{ marginRight: 8 }}>{maskPhone(student.emergencyPhone)}</span>
|
||
{canViewSensitive ? (
|
||
<a onClick={() => onViewSensitive('紧急联系人电话', student.emergencyPhone || '')}>
|
||
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
|
||
</a>
|
||
) : null}
|
||
</span>
|
||
) : (
|
||
'-'
|
||
)}
|
||
</EditableCell>
|
||
</Descriptions.Item>
|
||
|
||
<Descriptions.Item label="所属机构">
|
||
{canChooseOrganization ? (
|
||
<EditableCell
|
||
value={student.organizationId}
|
||
editor="select"
|
||
options={organizations.map((item) => ({ value: item.id, label: item.name }))}
|
||
permission="student:edit"
|
||
onSave={(next) => saveStudent('organizationId', next)}
|
||
>
|
||
{student.organization?.name ? (
|
||
<Tag color="purple">{student.organization.name}</Tag>
|
||
) : (
|
||
'-'
|
||
)}
|
||
</EditableCell>
|
||
) : student.organization?.name ? (
|
||
<Tag color="purple">{student.organization.name}</Tag>
|
||
) : (
|
||
'-'
|
||
)}
|
||
</Descriptions.Item>
|
||
|
||
<Descriptions.Item label="负责人">
|
||
<EditableCell
|
||
value={student.supervisor}
|
||
permission="student:edit"
|
||
onSave={(next) => saveStudent('supervisor', next)}
|
||
>
|
||
{student.supervisor || '-'}
|
||
</EditableCell>
|
||
</Descriptions.Item>
|
||
|
||
<Descriptions.Item label="目标院校">
|
||
<EditableCell
|
||
value={profile?.targetCollege}
|
||
permission="student:edit"
|
||
onSave={(next) => saveProfile('targetCollege', next)}
|
||
>
|
||
{profile?.targetCollege || '-'}
|
||
</EditableCell>
|
||
</Descriptions.Item>
|
||
|
||
<Descriptions.Item label="目标专业">
|
||
<EditableCell
|
||
value={profile?.targetMajor}
|
||
permission="student:edit"
|
||
onSave={(next) => saveProfile('targetMajor', next)}
|
||
>
|
||
{profile?.targetMajor || '-'}
|
||
</EditableCell>
|
||
</Descriptions.Item>
|
||
|
||
<Descriptions.Item label="大专院校">
|
||
<EditableCell
|
||
value={profile?.collegeSchool}
|
||
permission="student:edit"
|
||
onSave={(next) => saveProfile('collegeSchool', next)}
|
||
>
|
||
{profile?.collegeSchool || '-'}
|
||
</EditableCell>
|
||
</Descriptions.Item>
|
||
|
||
<Descriptions.Item label="大专专业">
|
||
<EditableCell
|
||
value={profile?.collegeMajor}
|
||
permission="student:edit"
|
||
onSave={(next) => saveProfile('collegeMajor', next)}
|
||
>
|
||
{profile?.collegeMajor || '-'}
|
||
</EditableCell>
|
||
</Descriptions.Item>
|
||
|
||
<Descriptions.Item label="选科方向">
|
||
<EditableCell
|
||
value={profile?.subjectDirection}
|
||
permission="student:edit"
|
||
onSave={(next) => saveProfile('subjectDirection', next)}
|
||
>
|
||
{profile?.subjectDirection || '-'}
|
||
</EditableCell>
|
||
</Descriptions.Item>
|
||
|
||
<Descriptions.Item label="年级">
|
||
<EditableCell
|
||
value={profile?.grade}
|
||
permission="student:edit"
|
||
onSave={(next) => saveProfile('grade', next)}
|
||
>
|
||
{profile?.grade || '-'}
|
||
</EditableCell>
|
||
</Descriptions.Item>
|
||
|
||
<Descriptions.Item label="建档日期">
|
||
<EditableCell
|
||
value={profile?.profileDate}
|
||
editor="date"
|
||
permission="student:edit"
|
||
onSave={(next) => saveProfile('profileDate', next)}
|
||
>
|
||
{profile?.profileDate || '-'}
|
||
</EditableCell>
|
||
</Descriptions.Item>
|
||
|
||
<Descriptions.Item label="档案备注">
|
||
<EditableCell
|
||
value={profile?.notes}
|
||
editor="textarea"
|
||
permission="student:edit"
|
||
onSave={(next) => saveProfile('notes', next)}
|
||
>
|
||
{profile?.notes || '-'}
|
||
</EditableCell>
|
||
</Descriptions.Item>
|
||
|
||
<Descriptions.Item label="文化课最终分">
|
||
<EditableCell
|
||
value={result?.cultureFinalScore}
|
||
editor="number"
|
||
min={0}
|
||
permission="student:edit"
|
||
onSave={(next) => saveResult('cultureFinalScore', next)}
|
||
>
|
||
{result?.cultureFinalScore ?? '-'}
|
||
</EditableCell>
|
||
</Descriptions.Item>
|
||
|
||
<Descriptions.Item label="专业课最终分">
|
||
<EditableCell
|
||
value={result?.professionalFinalScore}
|
||
editor="number"
|
||
min={0}
|
||
permission="student:edit"
|
||
onSave={(next) => saveResult('professionalFinalScore', next)}
|
||
>
|
||
{result?.professionalFinalScore ?? '-'}
|
||
</EditableCell>
|
||
</Descriptions.Item>
|
||
|
||
<Descriptions.Item label="录取状态">
|
||
<EditableCell
|
||
value={result?.admissionStatus}
|
||
editor="select"
|
||
options={Object.entries(ADMISSION_STATUS_MAP).map(([value, meta]) => ({
|
||
value,
|
||
label: meta.text,
|
||
}))}
|
||
permission="student:edit"
|
||
onSave={(next) => saveResult('admissionStatus', next)}
|
||
>
|
||
{admissionStatus}
|
||
</EditableCell>
|
||
</Descriptions.Item>
|
||
|
||
<Descriptions.Item label="录取院校">
|
||
<EditableCell
|
||
value={result?.admittedCollege}
|
||
permission="student:edit"
|
||
onSave={(next) => saveResult('admittedCollege', next)}
|
||
>
|
||
{result?.admittedCollege || '-'}
|
||
</EditableCell>
|
||
</Descriptions.Item>
|
||
|
||
<Descriptions.Item label="录取专业">
|
||
<EditableCell
|
||
value={result?.admittedMajor}
|
||
permission="student:edit"
|
||
onSave={(next) => saveResult('admittedMajor', next)}
|
||
>
|
||
{result?.admittedMajor || '-'}
|
||
</EditableCell>
|
||
</Descriptions.Item>
|
||
</Descriptions>
|
||
);
|
||
};
|
||
|
||
const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
|
||
studentId,
|
||
inDrawer,
|
||
onClose,
|
||
}) => {
|
||
const { hasPermission, hasAnyPermission } = usePermission();
|
||
const canLoadOrganizations = hasAnyPermission(
|
||
'organization:view',
|
||
'student:create',
|
||
'student:edit',
|
||
);
|
||
const canChooseOrganization = hasAnyPermission('student:create', 'student:edit');
|
||
|
||
const {
|
||
data: aggregateData,
|
||
isLoading,
|
||
isFetching,
|
||
isError,
|
||
refetch,
|
||
} = useQuery<StudentProfileAggregate | null>({
|
||
queryKey: queryKeys.archive.detail(studentId),
|
||
queryFn: async () => {
|
||
return validateResponse<StudentProfileAggregate>(
|
||
studentProfileAggregateSchema,
|
||
await api.get<StudentProfileAggregate>(`/archive/${studentId}`),
|
||
);
|
||
},
|
||
});
|
||
const { data: organizations = [] } = useQuery<
|
||
Array<{ id: number; name: string; isHost?: boolean }>
|
||
>({
|
||
queryKey: queryKeys.organizations.options(),
|
||
enabled: canLoadOrganizations,
|
||
queryFn: async () => {
|
||
try {
|
||
return validateResponse<Array<{ id: number; name: string; isHost?: boolean }>>(
|
||
organizationOptionsSchema,
|
||
await api.get('/organizations/options'),
|
||
);
|
||
} catch {
|
||
return [];
|
||
}
|
||
},
|
||
});
|
||
const loading = isLoading || isFetching;
|
||
const fetchData = useCallback(() => refetch(), [refetch]);
|
||
|
||
const handlePreviewReport = useCallback(async () => {
|
||
try {
|
||
const { html } = await api.get<{ html: string }>(`/archive/${studentId}/report-html`);
|
||
const w = window.open('', '_blank');
|
||
if (w) {
|
||
w.document.write(html);
|
||
w.document.close();
|
||
}
|
||
} catch (e) {
|
||
console.error('加载报告失败', e);
|
||
message.error('加载报告失败');
|
||
}
|
||
}, [studentId]);
|
||
|
||
const handleViewSensitive = useViewSensitive(studentId, '学生档案', hasPermission('log:create'));
|
||
|
||
const tabItems = useMemo(() => {
|
||
if (!aggregateData) return [];
|
||
const { enrollments, examScores, learningRecords, attachments, attendances } = aggregateData;
|
||
return [
|
||
{
|
||
key: 'enrollments',
|
||
label: `报读班型 (${enrollments.length})`,
|
||
children: <EnrollmentsTab data={enrollments} studentId={studentId} onRefresh={fetchData} />,
|
||
},
|
||
{
|
||
key: 'exams',
|
||
label: `考试成绩 (${examScores.length})`,
|
||
children: (
|
||
<ExamScoresTab
|
||
data={examScores}
|
||
studentId={studentId}
|
||
enrollments={enrollments}
|
||
onRefresh={fetchData}
|
||
/>
|
||
),
|
||
},
|
||
{
|
||
key: 'attendance',
|
||
label: `出勤记录 (${attendances.length})`,
|
||
children: <AttendanceTab data={attendances} />,
|
||
},
|
||
{
|
||
key: 'learning',
|
||
label: `课堂回访 (${learningRecords.length})`,
|
||
children: (
|
||
<LearningTab data={learningRecords} studentId={studentId} onRefresh={fetchData} />
|
||
),
|
||
},
|
||
{
|
||
key: 'attachments',
|
||
label: `附件 (${attachments.length})`,
|
||
children: <AttachmentsTab data={attachments} studentId={studentId} onRefresh={fetchData} />,
|
||
},
|
||
{
|
||
key: 'reports',
|
||
label: '报告版本',
|
||
children: <Empty description="暂无报告版本" />,
|
||
},
|
||
];
|
||
}, [aggregateData, studentId, fetchData]);
|
||
|
||
if (!aggregateData) {
|
||
if (loading) {
|
||
return (
|
||
<div style={{ textAlign: 'center', padding: 80 }}>
|
||
<Spin size="large" />
|
||
</div>
|
||
);
|
||
}
|
||
if (isError) {
|
||
return (
|
||
<QueryErrorState
|
||
title="档案数据加载失败"
|
||
description="请检查网络后重试。"
|
||
onRetry={() => void refetch()}
|
||
/>
|
||
);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
const { student, profile, result } = aggregateData;
|
||
|
||
return (
|
||
<div>
|
||
<style>{`.archived-row { opacity: 0.6; background: #fafafa !important; }`}</style>
|
||
{inDrawer && (
|
||
<Row justify="space-between" align="middle" style={{ marginBottom: 24 }}>
|
||
<Space>
|
||
<Button type="text" icon={<CloseOutlined />} onClick={onClose} aria-label="关闭档案" />
|
||
<span style={{ fontSize: 16, fontWeight: 500 }}>
|
||
学员档案 - {student.name}
|
||
{student.studentNo ? ` (${student.studentNo})` : ''}
|
||
</span>
|
||
</Space>
|
||
<Space>
|
||
<Button icon={<FileTextOutlined />} onClick={handlePreviewReport}>
|
||
预览报告
|
||
</Button>
|
||
<Button icon={<ReloadOutlined />} onClick={fetchData} loading={loading}>
|
||
刷新
|
||
</Button>
|
||
</Space>
|
||
</Row>
|
||
)}
|
||
|
||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||
{[
|
||
{ title: '入学测试总分' },
|
||
{ title: '阶段最高分' },
|
||
{ title: '阶段提升分' },
|
||
{ title: '出勤率' },
|
||
].map((item) => (
|
||
<Col span={6} key={item.title}>
|
||
<Card size="small">
|
||
<Statistic title={item.title} value="-" />
|
||
</Card>
|
||
</Col>
|
||
))}
|
||
</Row>
|
||
|
||
<InlineArchiveSummary
|
||
studentId={studentId}
|
||
student={student}
|
||
profile={profile}
|
||
result={result}
|
||
organizations={organizations}
|
||
onRefresh={fetchData}
|
||
onViewSensitive={handleViewSensitive}
|
||
canViewSensitive={hasPermission('log:create')}
|
||
canChooseOrganization={canChooseOrganization}
|
||
/>
|
||
|
||
<Tabs defaultActiveKey="enrollments" items={tabItems} />
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default StudentProfileContent;
|