forked from wangziqi/gongxue-base
fix student import and profile labels
This commit is contained in:
@@ -162,6 +162,19 @@ const RECORD_TYPE_OPTIONS = [
|
|||||||
{ value: 'other', label: '其他' },
|
{ 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 = [
|
const COURSE_CATEGORY_OPTIONS = [
|
||||||
{ value: 'culture', label: '文化课' },
|
{ value: 'culture', label: '文化课' },
|
||||||
{ value: 'professional', label: '专业课' },
|
{ value: 'professional', label: '专业课' },
|
||||||
@@ -176,6 +189,34 @@ const CLASS_TYPE_OPTIONS = [
|
|||||||
{ value: 'offline', label: '线下' },
|
{ 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 = [
|
const ATTACHMENT_CATEGORY_OPTIONS = [
|
||||||
{ value: 'id_card', label: '身份证' },
|
{ value: 'id_card', label: '身份证' },
|
||||||
{ value: 'transcript', label: '成绩单' },
|
{ value: 'transcript', label: '成绩单' },
|
||||||
@@ -350,8 +391,8 @@ const EnrollmentsTab: React.FC<TabProps & { data: EnrollmentRecord[] }> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const columns: ColumnsType<EnrollmentRecord> = [
|
const columns: ColumnsType<EnrollmentRecord> = [
|
||||||
{ title: '课程类别', dataIndex: 'courseCategory', render: (v: string) => v || '-' },
|
{ title: '课程类别', dataIndex: 'courseCategory', render: getCourseCategoryLabel },
|
||||||
{ title: '班型', dataIndex: 'classType', render: (v: string) => v || '-' },
|
{ title: '班型', dataIndex: 'classType', render: getClassTypeLabel },
|
||||||
{ title: '班级名称', dataIndex: 'className', render: (v: string) => v || '-' },
|
{ title: '班级名称', dataIndex: 'className', render: (v: string) => v || '-' },
|
||||||
{ title: '班主任', dataIndex: 'headTeacher', render: (v: string) => v || '-' },
|
{ title: '班主任', dataIndex: 'headTeacher', render: (v: string) => v || '-' },
|
||||||
{ title: '任课教师', dataIndex: 'subjectTeacher', render: (v: string) => v || '-' },
|
{ title: '任课教师', dataIndex: 'subjectTeacher', render: (v: string) => v || '-' },
|
||||||
@@ -361,12 +402,8 @@ const EnrollmentsTab: React.FC<TabProps & { data: EnrollmentRecord[] }> = ({
|
|||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
render: (v: string) => {
|
render: (v: string) => {
|
||||||
const colorMap: Record<string, string> = {
|
const status = getEnrollmentStatus(v);
|
||||||
active: 'green',
|
return <Tag color={status.color}>{status.text}</Tag>;
|
||||||
completed: 'blue',
|
|
||||||
withdrawn: 'red',
|
|
||||||
};
|
|
||||||
return <Tag color={colorMap[v] || 'default'}>{v || '-'}</Tag>;
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -477,7 +514,7 @@ const ExamScoresTab: React.FC<TabProps & { data: ExamScoreRecord[]; enrollments:
|
|||||||
render: (v: number | undefined) => {
|
render: (v: number | undefined) => {
|
||||||
if (v === undefined) return '-';
|
if (v === undefined) return '-';
|
||||||
const enr = enrollments.find((e) => e.id === v);
|
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="选择关联的报读记录"
|
placeholder="选择关联的报读记录"
|
||||||
options={enrollments.map((e) => ({
|
options={enrollments.map((e) => ({
|
||||||
value: e.id,
|
value: e.id,
|
||||||
label: `${e.className || e.courseCategory || e.id} (${e.classType})`,
|
label: `${formatEnrollmentDisplayName(e)}(${getClassTypeLabel(e.classType)})`,
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -976,7 +1013,10 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
|
|||||||
) : '-'}
|
) : '-'}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="状态">
|
<Descriptions.Item label="状态">
|
||||||
<Tag>{student.status || '-'}</Tag>
|
{(() => {
|
||||||
|
const status = getStudentStatus(student.status);
|
||||||
|
return <Tag color={status.color}>{status.text}</Tag>;
|
||||||
|
})()}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
{profile?.targetCollege && (
|
{profile?.targetCollege && (
|
||||||
<Descriptions.Item label="目标院校">{profile.targetCollege}</Descriptions.Item>
|
<Descriptions.Item label="目标院校">{profile.targetCollege}</Descriptions.Item>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
App,
|
App,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
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 StudentsPage: React.FC = () => {
|
||||||
const { modal } = App.useApp();
|
const { modal } = App.useApp();
|
||||||
const [data, setData] = useState<any[]>([]);
|
const [data, setData] = useState<any[]>([]);
|
||||||
@@ -221,20 +235,94 @@ const StudentsPage: React.FC = () => {
|
|||||||
.catch(() => message.error('下载失败'));
|
.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();
|
const formData = new FormData();
|
||||||
formData.append('file', file as File);
|
formData.append('file', file as File);
|
||||||
try {
|
try {
|
||||||
const res = (await api.post('/students/import-match', formData, {
|
const res = (await api.post('/students/import-match', formData, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
})) as { message: string };
|
})) as StudentUpdateImportResult;
|
||||||
message.success(res.message);
|
showUpdateImportResult(res);
|
||||||
onSuccess?.(res);
|
onSuccess?.(res);
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const err = e as { message?: string };
|
const err = e as { message?: string };
|
||||||
message.error(err?.message || '匹配导入失败');
|
message.error(err?.message || '更新已有学生资料失败');
|
||||||
onError?.(e instanceof Error ? e : new 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) => {
|
render: (v: string, record: any) => {
|
||||||
if (!v) return '-';
|
if (!v) return '-';
|
||||||
return (
|
return (
|
||||||
<span>
|
<span style={{ display: 'inline-flex', alignItems: 'center', whiteSpace: 'nowrap' }}>
|
||||||
<span style={{ marginRight: 4 }}>{maskPhone(v)}</span>
|
<span style={{ marginRight: 4 }}>{maskPhone(v)}</span>
|
||||||
<Button
|
<Button
|
||||||
type="link"
|
type="link"
|
||||||
size="small"
|
size="small"
|
||||||
style={{ padding: '8px 4px' }}
|
style={{ padding: '8px 4px', flex: 'none' }}
|
||||||
onClick={() => handleViewSensitive(record.id, '电话', v)}
|
onClick={() => handleViewSensitive(record.id, '电话', v)}
|
||||||
title="点击查看完整号码"
|
title="点击查看完整号码"
|
||||||
>
|
>
|
||||||
@@ -305,12 +393,12 @@ const StudentsPage: React.FC = () => {
|
|||||||
render: (v: string, record: any) => {
|
render: (v: string, record: any) => {
|
||||||
if (!v) return '-';
|
if (!v) return '-';
|
||||||
return (
|
return (
|
||||||
<span>
|
<span style={{ display: 'inline-flex', alignItems: 'center', whiteSpace: 'nowrap' }}>
|
||||||
<span style={{ marginRight: 4 }}>{maskIdNumber(v)}</span>
|
<span style={{ marginRight: 4 }}>{maskIdNumber(v)}</span>
|
||||||
<Button
|
<Button
|
||||||
type="link"
|
type="link"
|
||||||
size="small"
|
size="small"
|
||||||
style={{ padding: '8px 4px' }}
|
style={{ padding: '8px 4px', flex: 'none' }}
|
||||||
onClick={() => handleViewSensitive(record.id, '身份证号', v)}
|
onClick={() => handleViewSensitive(record.id, '身份证号', v)}
|
||||||
title="点击查看完整号码"
|
title="点击查看完整号码"
|
||||||
>
|
>
|
||||||
@@ -329,12 +417,12 @@ const StudentsPage: React.FC = () => {
|
|||||||
render: (v: string, record: any) => {
|
render: (v: string, record: any) => {
|
||||||
if (!v) return '-';
|
if (!v) return '-';
|
||||||
return (
|
return (
|
||||||
<span>
|
<span style={{ display: 'inline-flex', alignItems: 'center', whiteSpace: 'nowrap' }}>
|
||||||
<span style={{ marginRight: 4 }}>{maskPhone(v)}</span>
|
<span style={{ marginRight: 4 }}>{maskPhone(v)}</span>
|
||||||
<Button
|
<Button
|
||||||
type="link"
|
type="link"
|
||||||
size="small"
|
size="small"
|
||||||
style={{ padding: '8px 4px' }}
|
style={{ padding: '8px 4px', flex: 'none' }}
|
||||||
onClick={() => handleViewSensitive(record.id, '紧急联系人电话', v)}
|
onClick={() => handleViewSensitive(record.id, '紧急联系人电话', v)}
|
||||||
title="点击查看完整号码"
|
title="点击查看完整号码"
|
||||||
>
|
>
|
||||||
@@ -530,29 +618,15 @@ const StudentsPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
添加学生
|
添加学生
|
||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
|
<Upload accept=".xlsx,.xls" showUploadList={false} customRequest={handleCreateStudentsImport}>
|
||||||
|
<Button icon={<UploadOutlined />}>导入Excel</Button>
|
||||||
|
</Upload>
|
||||||
<Upload
|
<Upload
|
||||||
accept=".xlsx,.xls"
|
accept=".xlsx,.xls"
|
||||||
showUploadList={false}
|
showUploadList={false}
|
||||||
customRequest={async ({ file, onSuccess, onError }: any) => {
|
customRequest={handleUpdateExistingStudentsImport}
|
||||||
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 || '导入失败'));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Button icon={<UploadOutlined />}>导入Excel</Button>
|
<Button icon={<SwapOutlined />}>更新已有学生资料</Button>
|
||||||
</Upload>
|
|
||||||
<Upload accept=".xlsx,.xls" showUploadList={false} customRequest={handleMatchImport}>
|
|
||||||
<Button icon={<SwapOutlined />}>匹配导入</Button>
|
|
||||||
</Upload>
|
</Upload>
|
||||||
<PermissionButton
|
<PermissionButton
|
||||||
permission="student:view"
|
permission="student:view"
|
||||||
@@ -570,6 +644,17 @@ const StudentsPage: React.FC = () => {
|
|||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
<Alert
|
||||||
|
showIcon
|
||||||
|
type="warning"
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
message={
|
||||||
|
<span>
|
||||||
|
<strong>更新已有学生资料:</strong>先按手机号、再按身份证号匹配;Excel
|
||||||
|
中填写的非空字段会覆盖原资料,未匹配的学生不会新增。请确认姓名、手机号、身份证号、所属机构和联系人等内容无误。
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Table
|
<Table
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data}
|
dataSource={data}
|
||||||
|
|||||||
@@ -35,6 +35,122 @@ interface AuthenticatedRequest {
|
|||||||
user: AuthenticatedUser;
|
user: AuthenticatedUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
interface StudentImportRow {
|
||||||
|
name: string;
|
||||||
|
studentNo?: string;
|
||||||
|
phone?: string;
|
||||||
|
idNumber?: string;
|
||||||
|
gender?: string;
|
||||||
|
ethnicity?: string;
|
||||||
|
emergencyContact?: string;
|
||||||
|
emergencyPhone?: string;
|
||||||
|
organization?: string;
|
||||||
|
supervisor?: string;
|
||||||
|
organizationId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STUDENT_IMPORT_COLUMNS = [
|
||||||
|
{ header: '姓名', key: 'name', width: 15 },
|
||||||
|
{ header: '学号', key: 'studentNo', width: 15 },
|
||||||
|
{ header: '性别', key: 'gender', width: 8 },
|
||||||
|
{ header: '电话', key: 'phone', width: 18 },
|
||||||
|
{ header: '身份证号', key: 'idNumber', width: 22 },
|
||||||
|
{ header: '民族', key: 'ethnicity', width: 10 },
|
||||||
|
{ header: '紧急联系人', key: 'emergencyContact', width: 15 },
|
||||||
|
{ header: '紧急联系人电话', key: 'emergencyPhone', width: 18 },
|
||||||
|
{ header: '所属机构名称', key: 'organization', width: 18 },
|
||||||
|
{ header: '负责人/班主任', key: 'supervisor', width: 15 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STUDENT_EXPORT_COLUMNS = [
|
||||||
|
...STUDENT_IMPORT_COLUMNS.map((column) => ({
|
||||||
|
...column,
|
||||||
|
header: column.key === 'organization' ? '所属机构' : column.header,
|
||||||
|
})),
|
||||||
|
{ header: '状态', key: 'status', width: 10 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STUDENT_IMPORT_HEADER_MAP: Record<string, keyof StudentImportRow> = {
|
||||||
|
姓名: 'name',
|
||||||
|
学号: 'studentNo',
|
||||||
|
电话: 'phone',
|
||||||
|
手机号: 'phone',
|
||||||
|
'学号/身份证': 'idNumber',
|
||||||
|
身份证: 'idNumber',
|
||||||
|
身份证号: 'idNumber',
|
||||||
|
性别: 'gender',
|
||||||
|
民族: 'ethnicity',
|
||||||
|
紧急联系人: 'emergencyContact',
|
||||||
|
紧急联系人电话: 'emergencyPhone',
|
||||||
|
所属机构: 'organization',
|
||||||
|
所属机构名称: 'organization',
|
||||||
|
负责人: 'supervisor',
|
||||||
|
'负责人/班主任': 'supervisor',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getExcelCellText(cell: ExcelJS.Cell): string {
|
||||||
|
const value = cell.value;
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
if ('text' in value) return String(value.text || '');
|
||||||
|
if ('richText' in value && Array.isArray(value.richText)) {
|
||||||
|
return value.richText.map((part) => part.text).join('');
|
||||||
|
}
|
||||||
|
if ('result' in value) return String(value.result || '');
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStudentImportRows(ws: ExcelJS.Worksheet): StudentImportRow[] {
|
||||||
|
const headerIndex = new Map<number, keyof StudentImportRow>();
|
||||||
|
ws.getRow(1).eachCell((cell, colNumber) => {
|
||||||
|
const header = getExcelCellText(cell).trim();
|
||||||
|
const field = STUDENT_IMPORT_HEADER_MAP[header];
|
||||||
|
if (field) headerIndex.set(colNumber, field);
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows: StudentImportRow[] = [];
|
||||||
|
ws.eachRow((row, idx) => {
|
||||||
|
if (idx === 1) return;
|
||||||
|
|
||||||
|
const parsed: Partial<StudentImportRow> = {};
|
||||||
|
if (headerIndex.size > 0) {
|
||||||
|
headerIndex.forEach((field, colNumber) => {
|
||||||
|
const value = getExcelCellText(row.getCell(colNumber)).trim();
|
||||||
|
if (value) {
|
||||||
|
Object.assign(parsed, { [field]: value });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
parsed.name = getExcelCellText(row.getCell(1)).trim();
|
||||||
|
parsed.studentNo = getExcelCellText(row.getCell(2)).trim() || undefined;
|
||||||
|
parsed.gender = getExcelCellText(row.getCell(3)).trim() || undefined;
|
||||||
|
parsed.phone = getExcelCellText(row.getCell(4)).trim() || undefined;
|
||||||
|
parsed.idNumber = getExcelCellText(row.getCell(5)).trim() || undefined;
|
||||||
|
parsed.ethnicity = getExcelCellText(row.getCell(6)).trim() || undefined;
|
||||||
|
parsed.emergencyContact = getExcelCellText(row.getCell(7)).trim() || undefined;
|
||||||
|
parsed.emergencyPhone = getExcelCellText(row.getCell(8)).trim() || undefined;
|
||||||
|
parsed.organization = getExcelCellText(row.getCell(9)).trim() || undefined;
|
||||||
|
parsed.supervisor = getExcelCellText(row.getCell(10)).trim() || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
name: parsed.name || '',
|
||||||
|
studentNo: parsed.studentNo,
|
||||||
|
phone: parsed.phone,
|
||||||
|
idNumber: parsed.idNumber,
|
||||||
|
gender: parsed.gender,
|
||||||
|
ethnicity: parsed.ethnicity,
|
||||||
|
emergencyContact: parsed.emergencyContact,
|
||||||
|
emergencyPhone: parsed.emergencyPhone,
|
||||||
|
organization: parsed.organization,
|
||||||
|
supervisor: parsed.supervisor,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('students')
|
@Controller('students')
|
||||||
export class StudentsController {
|
export class StudentsController {
|
||||||
@@ -92,18 +208,7 @@ export class StudentsController {
|
|||||||
);
|
);
|
||||||
const workbook = new ExcelJS.Workbook();
|
const workbook = new ExcelJS.Workbook();
|
||||||
const ws = workbook.addWorksheet('学生名单');
|
const ws = workbook.addWorksheet('学生名单');
|
||||||
ws.columns = [
|
ws.columns = STUDENT_EXPORT_COLUMNS;
|
||||||
{ header: '姓名', key: 'name', width: 12 },
|
|
||||||
{ header: '性别', key: 'gender', width: 8 },
|
|
||||||
{ header: '电话', key: 'phone', width: 18 },
|
|
||||||
{ header: '学号/身份证', key: 'idNumber', width: 22 },
|
|
||||||
{ header: '民族', key: 'ethnicity', width: 10 },
|
|
||||||
{ header: '紧急联系人', key: 'emergencyContact', width: 15 },
|
|
||||||
{ header: '紧急联系人电话', key: 'emergencyPhone', width: 18 },
|
|
||||||
{ header: '所属机构', key: 'organization', width: 18 },
|
|
||||||
{ header: '负责人/班主任', key: 'supervisor', width: 15 },
|
|
||||||
{ header: '状态', key: 'status', width: 10 },
|
|
||||||
];
|
|
||||||
ws.getRow(1).font = { bold: true };
|
ws.getRow(1).font = { bold: true };
|
||||||
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
||||||
const statusMap: Record<string, string> = {
|
const statusMap: Record<string, string> = {
|
||||||
@@ -115,6 +220,7 @@ export class StudentsController {
|
|||||||
for (const s of students) {
|
for (const s of students) {
|
||||||
ws.addRow({
|
ws.addRow({
|
||||||
name: s.name,
|
name: s.name,
|
||||||
|
studentNo: s.studentNo || '',
|
||||||
gender: s.gender || '',
|
gender: s.gender || '',
|
||||||
phone: s.phone || '',
|
phone: s.phone || '',
|
||||||
idNumber: s.idNumber || '',
|
idNumber: s.idNumber || '',
|
||||||
@@ -150,24 +256,15 @@ export class StudentsController {
|
|||||||
async downloadTemplate(@Res() res: Response) {
|
async downloadTemplate(@Res() res: Response) {
|
||||||
const workbook = new ExcelJS.Workbook();
|
const workbook = new ExcelJS.Workbook();
|
||||||
const ws = workbook.addWorksheet('学生导入模板');
|
const ws = workbook.addWorksheet('学生导入模板');
|
||||||
ws.columns = [
|
ws.columns = STUDENT_IMPORT_COLUMNS;
|
||||||
{ header: '姓名', key: 'name', width: 15 },
|
|
||||||
{ header: '电话', key: 'phone', width: 18 },
|
|
||||||
{ header: '学号/身份证', key: 'idNumber', width: 22 },
|
|
||||||
{ header: '性别', key: 'gender', width: 8 },
|
|
||||||
{ header: '民族', key: 'ethnicity', width: 10 },
|
|
||||||
{ header: '紧急联系人', key: 'emergencyContact', width: 15 },
|
|
||||||
{ header: '紧急联系人电话', key: 'emergencyPhone', width: 18 },
|
|
||||||
{ header: '所属机构名称', key: 'organization', width: 18 },
|
|
||||||
{ header: '负责人/班主任', key: 'supervisor', width: 15 },
|
|
||||||
];
|
|
||||||
ws.getRow(1).font = { bold: true };
|
ws.getRow(1).font = { bold: true };
|
||||||
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
||||||
ws.addRow({
|
ws.addRow({
|
||||||
name: '张三',
|
name: '张三',
|
||||||
phone: '13800138000',
|
studentNo: '2024001',
|
||||||
idNumber: '2024001',
|
|
||||||
gender: '男',
|
gender: '男',
|
||||||
|
phone: '13800138000',
|
||||||
|
idNumber: '11010120060101001X',
|
||||||
ethnicity: '汉族',
|
ethnicity: '汉族',
|
||||||
emergencyContact: '张父',
|
emergencyContact: '张父',
|
||||||
emergencyPhone: '13900000000',
|
emergencyPhone: '13900000000',
|
||||||
@@ -288,32 +385,7 @@ export class StudentsController {
|
|||||||
const workbook = new ExcelJS.Workbook();
|
const workbook = new ExcelJS.Workbook();
|
||||||
await workbook.xlsx.load(file.buffer as any);
|
await workbook.xlsx.load(file.buffer as any);
|
||||||
const ws = workbook.worksheets[0];
|
const ws = workbook.worksheets[0];
|
||||||
const rows: {
|
const rows = parseStudentImportRows(ws);
|
||||||
name: string;
|
|
||||||
phone?: string;
|
|
||||||
idNumber?: string;
|
|
||||||
gender?: string;
|
|
||||||
ethnicity?: string;
|
|
||||||
emergencyContact?: string;
|
|
||||||
emergencyPhone?: string;
|
|
||||||
organization?: string;
|
|
||||||
supervisor?: string;
|
|
||||||
organizationId?: number;
|
|
||||||
}[] = [];
|
|
||||||
ws.eachRow((row, idx) => {
|
|
||||||
if (idx === 1) return;
|
|
||||||
rows.push({
|
|
||||||
name: String(row.getCell(1).value || ''),
|
|
||||||
phone: String(row.getCell(2).value || ''),
|
|
||||||
idNumber: String(row.getCell(3).value || ''),
|
|
||||||
gender: String(row.getCell(4).value || '').trim() || undefined,
|
|
||||||
ethnicity: String(row.getCell(5).value || '').trim() || undefined,
|
|
||||||
emergencyContact: String(row.getCell(6).value || '').trim() || undefined,
|
|
||||||
emergencyPhone: String(row.getCell(7).value || '').trim() || undefined,
|
|
||||||
organization: String(row.getCell(8).value || '').trim() || undefined,
|
|
||||||
supervisor: String(row.getCell(9).value || '').trim() || undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
// Resolve organization names to IDs
|
// Resolve organization names to IDs
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (row.organization) {
|
if (row.organization) {
|
||||||
@@ -346,32 +418,7 @@ export class StudentsController {
|
|||||||
const workbook = new ExcelJS.Workbook();
|
const workbook = new ExcelJS.Workbook();
|
||||||
await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer);
|
await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer);
|
||||||
const ws = workbook.worksheets[0];
|
const ws = workbook.worksheets[0];
|
||||||
const rows: {
|
const rows = parseStudentImportRows(ws);
|
||||||
name: string;
|
|
||||||
phone?: string;
|
|
||||||
idNumber?: string;
|
|
||||||
gender?: string;
|
|
||||||
ethnicity?: string;
|
|
||||||
emergencyContact?: string;
|
|
||||||
emergencyPhone?: string;
|
|
||||||
organization?: string;
|
|
||||||
supervisor?: string;
|
|
||||||
organizationId?: number;
|
|
||||||
}[] = [];
|
|
||||||
ws.eachRow((row, idx) => {
|
|
||||||
if (idx === 1) return;
|
|
||||||
rows.push({
|
|
||||||
name: String(row.getCell(1).value || ''),
|
|
||||||
phone: String(row.getCell(2).value || ''),
|
|
||||||
idNumber: String(row.getCell(3).value || ''),
|
|
||||||
gender: String(row.getCell(4).value || '').trim() || undefined,
|
|
||||||
ethnicity: String(row.getCell(5).value || '').trim() || undefined,
|
|
||||||
emergencyContact: String(row.getCell(6).value || '').trim() || undefined,
|
|
||||||
emergencyPhone: String(row.getCell(7).value || '').trim() || undefined,
|
|
||||||
organization: String(row.getCell(8).value || '').trim() || undefined,
|
|
||||||
supervisor: String(row.getCell(9).value || '').trim() || undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
// Resolve organization names to IDs
|
// Resolve organization names to IDs
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (row.organization) {
|
if (row.organization) {
|
||||||
@@ -386,7 +433,7 @@ export class StudentsController {
|
|||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
username: req.user?.username,
|
username: req.user?.username,
|
||||||
module: '学生管理',
|
module: '学生管理',
|
||||||
action: '匹配导入学生',
|
action: '更新已有学生资料',
|
||||||
detail: result.message,
|
detail: result.message,
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent,
|
userAgent,
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ export class StudentsService {
|
|||||||
async batchImport(
|
async batchImport(
|
||||||
rows: {
|
rows: {
|
||||||
name: string;
|
name: string;
|
||||||
|
studentNo?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
idNumber?: string;
|
idNumber?: string;
|
||||||
gender?: string;
|
gender?: string;
|
||||||
@@ -158,6 +159,7 @@ export class StudentsService {
|
|||||||
await this.repo.save(
|
await this.repo.save(
|
||||||
this.repo.create({
|
this.repo.create({
|
||||||
name: row.name.trim(),
|
name: row.name.trim(),
|
||||||
|
studentNo: row.studentNo?.trim() || undefined,
|
||||||
phone: row.phone?.trim() || undefined,
|
phone: row.phone?.trim() || undefined,
|
||||||
idNumber: row.idNumber?.trim() || undefined,
|
idNumber: row.idNumber?.trim() || undefined,
|
||||||
gender: row.gender || undefined,
|
gender: row.gender || undefined,
|
||||||
@@ -180,6 +182,7 @@ export class StudentsService {
|
|||||||
async matchImport(
|
async matchImport(
|
||||||
rows: {
|
rows: {
|
||||||
name: string;
|
name: string;
|
||||||
|
studentNo?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
idNumber?: string;
|
idNumber?: string;
|
||||||
gender?: string;
|
gender?: string;
|
||||||
@@ -194,10 +197,6 @@ export class StudentsService {
|
|||||||
let matched = 0;
|
let matched = 0;
|
||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (!row.name || !row.name.trim()) {
|
|
||||||
skipped++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Match by phone first, then idNumber
|
// Match by phone first, then idNumber
|
||||||
let student = row.phone?.trim()
|
let student = row.phone?.trim()
|
||||||
? await this.repo.findOne({ where: { phone: row.phone.trim() } })
|
? await this.repo.findOne({ where: { phone: row.phone.trim() } })
|
||||||
@@ -214,6 +213,7 @@ export class StudentsService {
|
|||||||
Pick<
|
Pick<
|
||||||
Student,
|
Student,
|
||||||
| 'name'
|
| 'name'
|
||||||
|
| 'studentNo'
|
||||||
| 'phone'
|
| 'phone'
|
||||||
| 'idNumber'
|
| 'idNumber'
|
||||||
| 'gender'
|
| 'gender'
|
||||||
@@ -225,6 +225,7 @@ export class StudentsService {
|
|||||||
>
|
>
|
||||||
> = {};
|
> = {};
|
||||||
if (row.name?.trim()) updates.name = row.name.trim();
|
if (row.name?.trim()) updates.name = row.name.trim();
|
||||||
|
if (row.studentNo?.trim()) updates.studentNo = row.studentNo.trim();
|
||||||
if (row.phone?.trim()) updates.phone = row.phone.trim();
|
if (row.phone?.trim()) updates.phone = row.phone.trim();
|
||||||
if (row.idNumber?.trim()) updates.idNumber = row.idNumber.trim();
|
if (row.idNumber?.trim()) updates.idNumber = row.idNumber.trim();
|
||||||
if (row.gender) updates.gender = row.gender;
|
if (row.gender) updates.gender = row.gender;
|
||||||
@@ -237,7 +238,7 @@ export class StudentsService {
|
|||||||
matched++;
|
matched++;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
message: `匹配更新 ${matched} 人,跳过 ${skipped} 条(无匹配)`,
|
message: `更新已有学生资料 ${matched} 人,跳过 ${skipped} 条(无匹配)`,
|
||||||
matched,
|
matched,
|
||||||
skipped,
|
skipped,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user