fix student import and profile labels
This commit is contained in:
@@ -162,6 +162,19 @@ const RECORD_TYPE_OPTIONS = [
|
||||
{ value: 'other', label: '其他' },
|
||||
];
|
||||
|
||||
const STUDENT_STATUS_MAP: Record<string, { text: string; color: string }> = {
|
||||
active: { text: '在读', color: 'green' },
|
||||
graduated: { text: '已毕业', color: 'blue' },
|
||||
withdrawn: { text: '已退训', color: 'red' },
|
||||
archived: { text: '已归档', color: '#999' },
|
||||
};
|
||||
|
||||
const ENROLLMENT_STATUS_MAP: Record<string, { text: string; color: string }> = {
|
||||
active: { text: '报读中', color: 'green' },
|
||||
completed: { text: '已结课', color: 'blue' },
|
||||
withdrawn: { text: '已退训', color: 'red' },
|
||||
};
|
||||
|
||||
const COURSE_CATEGORY_OPTIONS = [
|
||||
{ value: 'culture', label: '文化课' },
|
||||
{ value: 'professional', label: '专业课' },
|
||||
@@ -176,6 +189,34 @@ const CLASS_TYPE_OPTIONS = [
|
||||
{ value: 'offline', label: '线下' },
|
||||
];
|
||||
|
||||
const getOptionLabel = (
|
||||
options: Array<{ value: string; label: string }>,
|
||||
value?: string | null,
|
||||
): string => {
|
||||
if (!value) return '-';
|
||||
return options.find((option) => option.value === value)?.label || value;
|
||||
};
|
||||
|
||||
const getCourseCategoryLabel = (value?: string | null): string =>
|
||||
getOptionLabel(COURSE_CATEGORY_OPTIONS, value);
|
||||
|
||||
const getClassTypeLabel = (value?: string | null): string =>
|
||||
getOptionLabel(CLASS_TYPE_OPTIONS, value);
|
||||
|
||||
const getEnrollmentStatus = (value?: string | null): { text: string; color: string } => {
|
||||
if (!value) return { text: '-', color: 'default' };
|
||||
return ENROLLMENT_STATUS_MAP[value] || { text: value, color: 'default' };
|
||||
};
|
||||
|
||||
const getStudentStatus = (value?: string | null): { text: string; color: string } => {
|
||||
if (!value) return { text: '-', color: 'default' };
|
||||
return STUDENT_STATUS_MAP[value] || { text: value, color: 'default' };
|
||||
};
|
||||
|
||||
const formatEnrollmentDisplayName = (enrollment: EnrollmentRecord): string =>
|
||||
enrollment.className ||
|
||||
(enrollment.courseCategory ? getCourseCategoryLabel(enrollment.courseCategory) : String(enrollment.id));
|
||||
|
||||
const ATTACHMENT_CATEGORY_OPTIONS = [
|
||||
{ value: 'id_card', label: '身份证' },
|
||||
{ value: 'transcript', label: '成绩单' },
|
||||
@@ -350,8 +391,8 @@ const EnrollmentsTab: React.FC<TabProps & { data: EnrollmentRecord[] }> = ({
|
||||
};
|
||||
|
||||
const columns: ColumnsType<EnrollmentRecord> = [
|
||||
{ title: '课程类别', dataIndex: 'courseCategory', render: (v: string) => v || '-' },
|
||||
{ title: '班型', dataIndex: 'classType', render: (v: string) => v || '-' },
|
||||
{ title: '课程类别', dataIndex: 'courseCategory', render: getCourseCategoryLabel },
|
||||
{ title: '班型', dataIndex: 'classType', render: getClassTypeLabel },
|
||||
{ title: '班级名称', dataIndex: 'className', render: (v: string) => v || '-' },
|
||||
{ title: '班主任', dataIndex: 'headTeacher', render: (v: string) => v || '-' },
|
||||
{ title: '任课教师', dataIndex: 'subjectTeacher', render: (v: string) => v || '-' },
|
||||
@@ -361,12 +402,8 @@ const EnrollmentsTab: React.FC<TabProps & { data: EnrollmentRecord[] }> = ({
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
render: (v: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
active: 'green',
|
||||
completed: 'blue',
|
||||
withdrawn: 'red',
|
||||
};
|
||||
return <Tag color={colorMap[v] || 'default'}>{v || '-'}</Tag>;
|
||||
const status = getEnrollmentStatus(v);
|
||||
return <Tag color={status.color}>{status.text}</Tag>;
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -477,7 +514,7 @@ const ExamScoresTab: React.FC<TabProps & { data: ExamScoreRecord[]; enrollments:
|
||||
render: (v: number | undefined) => {
|
||||
if (v === undefined) return '-';
|
||||
const enr = enrollments.find((e) => e.id === v);
|
||||
return enr ? `${enr.className || enr.courseCategory || v}` : String(v);
|
||||
return enr ? formatEnrollmentDisplayName(enr) : String(v);
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -540,7 +577,7 @@ const ExamScoresTab: React.FC<TabProps & { data: ExamScoreRecord[]; enrollments:
|
||||
placeholder="选择关联的报读记录"
|
||||
options={enrollments.map((e) => ({
|
||||
value: e.id,
|
||||
label: `${e.className || e.courseCategory || e.id} (${e.classType})`,
|
||||
label: `${formatEnrollmentDisplayName(e)}(${getClassTypeLabel(e.classType)})`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -976,7 +1013,10 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
|
||||
) : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag>{student.status || '-'}</Tag>
|
||||
{(() => {
|
||||
const status = getStudentStatus(student.status);
|
||||
return <Tag color={status.color}>{status.text}</Tag>;
|
||||
})()}
|
||||
</Descriptions.Item>
|
||||
{profile?.targetCollege && (
|
||||
<Descriptions.Item label="目标院校">{profile.targetCollege}</Descriptions.Item>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
@@ -62,6 +63,19 @@ interface EnrollmentInfo {
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
interface StudentCreateImportResult {
|
||||
message?: string;
|
||||
imported?: number;
|
||||
skipped?: number;
|
||||
}
|
||||
|
||||
interface StudentUpdateImportResult {
|
||||
message?: string;
|
||||
matched?: number;
|
||||
skipped?: number;
|
||||
}
|
||||
|
||||
const StudentsPage: React.FC = () => {
|
||||
const { modal } = App.useApp();
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
@@ -221,20 +235,94 @@ const StudentsPage: React.FC = () => {
|
||||
.catch(() => message.error('下载失败'));
|
||||
};
|
||||
|
||||
const handleMatchImport: UploadProps['customRequest'] = async ({ file, onSuccess, onError }) => {
|
||||
const showCreateImportResult = (result: StudentCreateImportResult) => {
|
||||
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>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const showUpdateImportResult = (result: StudentUpdateImportResult) => {
|
||||
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>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateStudentsImport: UploadProps['customRequest'] = async ({
|
||||
file,
|
||||
onSuccess,
|
||||
onError,
|
||||
}) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file as File);
|
||||
try {
|
||||
const res = (await api.post('/students/import', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})) as StudentCreateImportResult;
|
||||
showCreateImportResult(res);
|
||||
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 handleUpdateExistingStudentsImport: 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);
|
||||
})) as StudentUpdateImportResult;
|
||||
showUpdateImportResult(res);
|
||||
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 || '匹配导入失败'));
|
||||
message.error(err?.message || '更新已有学生资料失败');
|
||||
onError?.(e instanceof Error ? e : new Error(err?.message || '更新已有学生资料失败'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -277,12 +365,12 @@ const StudentsPage: React.FC = () => {
|
||||
render: (v: string, record: any) => {
|
||||
if (!v) return '-';
|
||||
return (
|
||||
<span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', whiteSpace: 'nowrap' }}>
|
||||
<span style={{ marginRight: 4 }}>{maskPhone(v)}</span>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
style={{ padding: '8px 4px' }}
|
||||
style={{ padding: '8px 4px', flex: 'none' }}
|
||||
onClick={() => handleViewSensitive(record.id, '电话', v)}
|
||||
title="点击查看完整号码"
|
||||
>
|
||||
@@ -305,12 +393,12 @@ const StudentsPage: React.FC = () => {
|
||||
render: (v: string, record: any) => {
|
||||
if (!v) return '-';
|
||||
return (
|
||||
<span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', whiteSpace: 'nowrap' }}>
|
||||
<span style={{ marginRight: 4 }}>{maskIdNumber(v)}</span>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
style={{ padding: '8px 4px' }}
|
||||
style={{ padding: '8px 4px', flex: 'none' }}
|
||||
onClick={() => handleViewSensitive(record.id, '身份证号', v)}
|
||||
title="点击查看完整号码"
|
||||
>
|
||||
@@ -329,12 +417,12 @@ const StudentsPage: React.FC = () => {
|
||||
render: (v: string, record: any) => {
|
||||
if (!v) return '-';
|
||||
return (
|
||||
<span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', whiteSpace: 'nowrap' }}>
|
||||
<span style={{ marginRight: 4 }}>{maskPhone(v)}</span>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
style={{ padding: '8px 4px' }}
|
||||
style={{ padding: '8px 4px', flex: 'none' }}
|
||||
onClick={() => handleViewSensitive(record.id, '紧急联系人电话', v)}
|
||||
title="点击查看完整号码"
|
||||
>
|
||||
@@ -530,29 +618,15 @@ const StudentsPage: React.FC = () => {
|
||||
>
|
||||
添加学生
|
||||
</PermissionButton>
|
||||
<Upload accept=".xlsx,.xls" showUploadList={false} customRequest={handleCreateStudentsImport}>
|
||||
<Button icon={<UploadOutlined />}>导入Excel</Button>
|
||||
</Upload>
|
||||
<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 instanceof Error ? e : new Error(e?.message || '导入失败'));
|
||||
}
|
||||
}}
|
||||
customRequest={handleUpdateExistingStudentsImport}
|
||||
>
|
||||
<Button icon={<UploadOutlined />}>导入Excel</Button>
|
||||
</Upload>
|
||||
<Upload accept=".xlsx,.xls" showUploadList={false} customRequest={handleMatchImport}>
|
||||
<Button icon={<SwapOutlined />}>匹配导入</Button>
|
||||
<Button icon={<SwapOutlined />}>更新已有学生资料</Button>
|
||||
</Upload>
|
||||
<PermissionButton
|
||||
permission="student:view"
|
||||
@@ -570,6 +644,17 @@ const StudentsPage: React.FC = () => {
|
||||
</PermissionButton>
|
||||
</Space>
|
||||
</div>
|
||||
<Alert
|
||||
showIcon
|
||||
type="warning"
|
||||
style={{ marginBottom: 12 }}
|
||||
message={
|
||||
<span>
|
||||
<strong>更新已有学生资料:</strong>先按手机号、再按身份证号匹配;Excel
|
||||
中填写的非空字段会覆盖原资料,未匹配的学生不会新增。请确认姓名、手机号、身份证号、所属机构和联系人等内容无误。
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
|
||||
Reference in New Issue
Block a user