forked from wangziqi/gongxue-base
feat: expand student archive import template
This commit is contained in:
@@ -24,7 +24,7 @@ export interface EditableCellOption {
|
||||
|
||||
export interface EditableCellProps<Value = unknown> {
|
||||
value: Value;
|
||||
children: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
editor?: EditableCellEditor;
|
||||
options?: EditableCellOption[];
|
||||
permission?: string;
|
||||
|
||||
@@ -46,6 +46,13 @@ interface StudentInfo {
|
||||
phone: string;
|
||||
idNumber: string;
|
||||
studentNo: string;
|
||||
gender?: string;
|
||||
ethnicity?: string;
|
||||
emergencyContact?: string;
|
||||
emergencyPhone?: string;
|
||||
organizationId?: number;
|
||||
organization?: { id?: number; name?: string } | null;
|
||||
supervisor?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
@@ -163,13 +170,6 @@ 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' },
|
||||
@@ -209,11 +209,6 @@ const getEnrollmentStatus = (value?: string | null): { text: string; color: stri
|
||||
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
|
||||
@@ -311,72 +306,274 @@ interface TabProps {
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
const ProfileTab: React.FC<{
|
||||
data: ProfileData | null;
|
||||
const InlineArchiveSummary: React.FC<{
|
||||
studentId: number;
|
||||
student: StudentInfo;
|
||||
profile: ProfileData | null;
|
||||
result: ResultData | null;
|
||||
organizations: Array<{ id: number; name: string }>;
|
||||
onRefresh: () => void;
|
||||
}> = ({ data, studentId, onRefresh }) => {
|
||||
const [form] = Form.useForm();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
await api.put(`/archive/${studentId}/profile`, {
|
||||
...values,
|
||||
profileDate: values.profileDate?.format('YYYY-MM-DD'),
|
||||
});
|
||||
message.success('基础档案已保存');
|
||||
onRefresh();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
if (err?.message) message.error(err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
onViewSensitive: (fieldLabel: string, value: string) => void;
|
||||
}> = ({ studentId, student, profile, result, organizations, onRefresh, onViewSensitive }) => {
|
||||
const saveStudent = async (field: keyof StudentInfo, value: unknown) => {
|
||||
await api.put(`/students/${studentId}`, { [field]: value });
|
||||
message.success('学生资料已保存');
|
||||
onRefresh();
|
||||
};
|
||||
|
||||
const saveProfile = async (field: keyof ProfileData, value: unknown) => {
|
||||
await api.put(`/archive/${studentId}/profile`, { [field]: value });
|
||||
message.success('档案已保存');
|
||||
onRefresh();
|
||||
};
|
||||
|
||||
const saveResult = async (field: keyof ResultData, value: unknown) => {
|
||||
await api.put(`/archive/${studentId}/result`, { [field]: value });
|
||||
message.success('录取信息已保存');
|
||||
onRefresh();
|
||||
};
|
||||
|
||||
const admissionStatus = getOptionLabel(
|
||||
Object.entries(ADMISSION_STATUS_MAP).map(([value, meta]) => ({
|
||||
value,
|
||||
label: meta.text,
|
||||
})),
|
||||
result?.admissionStatus,
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
targetCollege: data?.targetCollege ?? undefined,
|
||||
targetMajor: data?.targetMajor ?? undefined,
|
||||
subjectDirection: data?.subjectDirection ?? undefined,
|
||||
grade: data?.grade ?? undefined,
|
||||
profileDate: data?.profileDate ? dayjs(data.profileDate) : undefined,
|
||||
notes: data?.notes ?? undefined,
|
||||
}}
|
||||
style={{ maxWidth: 600 }}
|
||||
>
|
||||
<Form.Item name="targetCollege" label="目标院校">
|
||||
<Input placeholder="请输入目标院校" />
|
||||
</Form.Item>
|
||||
<Form.Item name="targetMajor" label="目标专业">
|
||||
<Input placeholder="请输入目标专业" />
|
||||
</Form.Item>
|
||||
<Form.Item name="subjectDirection" label="选科方向">
|
||||
<Input placeholder="如:物化生、史地政" />
|
||||
</Form.Item>
|
||||
<Form.Item name="grade" label="年级">
|
||||
<Input placeholder="如:高三" />
|
||||
</Form.Item>
|
||||
<Form.Item name="profileDate" label="建档日期">
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="notes" label="备注">
|
||||
<Input.TextArea rows={3} placeholder="其他备注信息" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" onClick={handleSave} loading={saving}>
|
||||
保存
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
<Descriptions bordered column={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>
|
||||
<a onClick={() => onViewSensitive('电话', student.phone)}>
|
||||
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
|
||||
</a>
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</EditableCell>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="姓名">
|
||||
<EditableCell
|
||||
value={student.name}
|
||||
permission="student:edit"
|
||||
required
|
||||
onSave={(next) => saveStudent('name', next)}
|
||||
>
|
||||
{student.name || '-'}
|
||||
</EditableCell>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="学号">
|
||||
<EditableCell
|
||||
value={student.studentNo}
|
||||
permission="student:edit"
|
||||
onSave={(next) => saveStudent('studentNo', next)}
|
||||
>
|
||||
{student.studentNo || '-'}
|
||||
</EditableCell>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="性别">
|
||||
<EditableCell
|
||||
value={student.gender}
|
||||
permission="student:edit"
|
||||
onSave={(next) => saveStudent('gender', next)}
|
||||
>
|
||||
{student.gender || '-'}
|
||||
</EditableCell>
|
||||
</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>
|
||||
<a onClick={() => onViewSensitive('身份证号', student.idNumber)}>
|
||||
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
|
||||
</a>
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</EditableCell>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="民族">
|
||||
<EditableCell
|
||||
value={student.ethnicity}
|
||||
permission="student:edit"
|
||||
onSave={(next) => saveStudent('ethnicity', next)}
|
||||
>
|
||||
{student.ethnicity || '-'}
|
||||
</EditableCell>
|
||||
</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>
|
||||
<a onClick={() => onViewSensitive('紧急联系人电话', student.emergencyPhone || '')}>
|
||||
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
|
||||
</a>
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</EditableCell>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="所属机构">
|
||||
<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>
|
||||
</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?.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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1018,75 +1215,6 @@ const LearningTab: React.FC<TabProps & { data: LearningRecord[] }> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const ResultTab: React.FC<TabProps & { data: ResultData | null }> = ({
|
||||
data,
|
||||
studentId,
|
||||
onRefresh,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
await api.put(`/archive/${studentId}/result`, values);
|
||||
message.success('录取结果已保存');
|
||||
onRefresh();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
if (err?.message) message.error(err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
cultureFinalScore: data?.cultureFinalScore ?? undefined,
|
||||
professionalFinalScore: data?.professionalFinalScore ?? undefined,
|
||||
admissionStatus: data?.admissionStatus ?? undefined,
|
||||
admittedCollege: data?.admittedCollege ?? undefined,
|
||||
admittedMajor: data?.admittedMajor ?? undefined,
|
||||
}}
|
||||
style={{ maxWidth: 500 }}
|
||||
>
|
||||
<Form.Item name="cultureFinalScore" label="文化课最终分">
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="professionalFinalScore" label="专业课最终分">
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="admissionStatus" label="录取状态">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="请选择录取状态"
|
||||
options={Object.entries(ADMISSION_STATUS_MAP).map(([k, v]) => ({
|
||||
value: k,
|
||||
label: v.text,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="admittedCollege" label="录取院校">
|
||||
<Input placeholder="请输入录取院校" />
|
||||
</Form.Item>
|
||||
<Form.Item name="admittedMajor" label="录取专业">
|
||||
<Input placeholder="请输入录取专业" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" onClick={handleSave} loading={saving}>
|
||||
保存
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> = ({
|
||||
data,
|
||||
studentId,
|
||||
@@ -1202,6 +1330,7 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
|
||||
onClose,
|
||||
}) => {
|
||||
const [aggregateData, setAggregateData] = useState<StudentProfileAggregate | null>(null);
|
||||
const [organizations, setOrganizations] = useState<Array<{ id: number; name: string }>>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
@@ -1221,6 +1350,15 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
|
||||
void fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get('/organizations', { params: { includeArchived: 'false' } })
|
||||
.then((res: unknown) => {
|
||||
setOrganizations(res as Array<{ id: number; name: string }>);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handlePreviewReport = useCallback(async () => {
|
||||
try {
|
||||
const { html } = await api.get<{ html: string }>(`/archive/${studentId}/report-html`);
|
||||
@@ -1238,14 +1376,8 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
|
||||
|
||||
const tabItems = useMemo(() => {
|
||||
if (!aggregateData) return [];
|
||||
const { profile, enrollments, examScores, learningRecords, result, attachments, attendances } =
|
||||
aggregateData;
|
||||
const { enrollments, examScores, learningRecords, attachments, attendances } = aggregateData;
|
||||
return [
|
||||
{
|
||||
key: 'profile',
|
||||
label: '扩展档案',
|
||||
children: <ProfileTab data={profile} studentId={studentId} onRefresh={fetchData} />,
|
||||
},
|
||||
{
|
||||
key: 'enrollments',
|
||||
label: `报读班型 (${enrollments.length})`,
|
||||
@@ -1275,11 +1407,6 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
|
||||
<LearningTab data={learningRecords} studentId={studentId} onRefresh={fetchData} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'result',
|
||||
label: '录取归档',
|
||||
children: <ResultTab data={result} studentId={studentId} onRefresh={fetchData} />,
|
||||
},
|
||||
{
|
||||
key: 'attachments',
|
||||
label: `附件 (${attachments.length})`,
|
||||
@@ -1304,7 +1431,7 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const { student, profile } = aggregateData;
|
||||
const { student, profile, result } = aggregateData;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -1342,51 +1469,17 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Descriptions bordered column={3} size="small" style={{ marginBottom: 24 }}>
|
||||
<Descriptions.Item label="学号">{student.studentNo || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="电话">
|
||||
{student.phone ? (
|
||||
<span>
|
||||
<span style={{ marginRight: 8 }}>{maskPhone(student.phone)}</span>
|
||||
<a onClick={() => handleViewSensitive('电话', student.phone)}>
|
||||
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
|
||||
</a>
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="身份证号">
|
||||
{student.idNumber ? (
|
||||
<span>
|
||||
<span style={{ marginRight: 8 }}>{maskIdNumber(student.idNumber)}</span>
|
||||
<a onClick={() => handleViewSensitive('身份证号', student.idNumber)}>
|
||||
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
|
||||
</a>
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
{(() => {
|
||||
const status = getStudentStatus(student.status);
|
||||
return <Tag color={status.color}>{status.text}</Tag>;
|
||||
})()}
|
||||
</Descriptions.Item>
|
||||
{profile?.targetCollege && (
|
||||
<Descriptions.Item label="目标院校">{profile.targetCollege}</Descriptions.Item>
|
||||
)}
|
||||
{profile?.targetMajor && (
|
||||
<Descriptions.Item label="目标专业">{profile.targetMajor}</Descriptions.Item>
|
||||
)}
|
||||
{profile?.grade && <Descriptions.Item label="年级">{profile.grade}</Descriptions.Item>}
|
||||
{profile?.subjectDirection && (
|
||||
<Descriptions.Item label="选科方向">{profile.subjectDirection}</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
<InlineArchiveSummary
|
||||
studentId={studentId}
|
||||
student={student}
|
||||
profile={profile}
|
||||
result={result}
|
||||
organizations={organizations}
|
||||
onRefresh={fetchData}
|
||||
onViewSensitive={handleViewSensitive}
|
||||
/>
|
||||
|
||||
<Tabs defaultActiveKey="profile" items={tabItems} />
|
||||
<Tabs defaultActiveKey="enrollments" items={tabItems} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"start": "nest start",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"generate:student-import": "ts-node -r tsconfig-paths/register -P tsconfig.json scripts/generate-student-import-xlsx.ts",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"typecheck": "tsc -p tsconfig.build.json --noEmit",
|
||||
"test": "jest",
|
||||
|
||||
104
apps/server/scripts/generate-student-import-xlsx.ts
Normal file
104
apps/server/scripts/generate-student-import-xlsx.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/// <reference types="node" />
|
||||
|
||||
import * as ExcelJS from 'exceljs';
|
||||
import {
|
||||
STUDENT_IMPORT_COLUMNS,
|
||||
createStudentImportTemplateWorkbook,
|
||||
} from '../src/students/student-import';
|
||||
|
||||
const count = Number(process.argv[2] || 1000);
|
||||
const output = process.argv[3] || `student-import-${count}.xlsx`;
|
||||
|
||||
function getWorksheetOrThrow(workbook: ExcelJS.Workbook, name: string) {
|
||||
const sheet = workbook.getWorksheet(name);
|
||||
if (!sheet) throw new Error(`Worksheet not found: ${name}`);
|
||||
return sheet;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!Number.isInteger(count) || count <= 0) {
|
||||
throw new Error('Count must be a positive integer');
|
||||
}
|
||||
|
||||
const workbook = createStudentImportTemplateWorkbook();
|
||||
const students = getWorksheetOrThrow(workbook, '学生基础+档案+录取');
|
||||
const enrollments = getWorksheetOrThrow(workbook, '报读班型');
|
||||
const examScores = getWorksheetOrThrow(workbook, '考试成绩');
|
||||
const learningRecords = getWorksheetOrThrow(workbook, '课堂回访');
|
||||
|
||||
for (let i = 1; i <= count; i++) {
|
||||
const serial = String(i).padStart(4, '0');
|
||||
const phone = `138${String(i).padStart(8, '0')}`;
|
||||
students.addRow({
|
||||
phone,
|
||||
name: `导入学生${serial}`,
|
||||
studentNo: `GX${serial}`,
|
||||
gender: i % 2 === 0 ? '女' : '男',
|
||||
idNumber: `110101200601${String((i % 28) + 1).padStart(2, '0')}${String(i % 1000).padStart(3, '0')}X`,
|
||||
ethnicity: '汉族',
|
||||
emergencyContact: `联系人${serial}`,
|
||||
emergencyPhone: `139${String(i).padStart(8, '0')}`,
|
||||
organization: '',
|
||||
supervisor: '',
|
||||
targetCollege: `目标院校${(i % 20) + 1}`,
|
||||
targetMajor: `目标专业${(i % 10) + 1}`,
|
||||
subjectDirection: ['物化生', '物化地', '史政地'][i % 3],
|
||||
grade: '高三',
|
||||
profileDate: '2024-09-01',
|
||||
notes: '',
|
||||
cultureFinalScore: 500 + (i % 151),
|
||||
professionalFinalScore: 300 + (i % 101),
|
||||
admissionStatus: 'pending',
|
||||
admittedCollege: '',
|
||||
admittedMajor: '',
|
||||
});
|
||||
|
||||
enrollments.addRow({
|
||||
phone,
|
||||
name: `导入学生${serial}`,
|
||||
courseCategory: i % 2 === 0 ? 'culture' : 'professional',
|
||||
classType: i % 3 === 0 ? 'small_group' : 'one_on_one',
|
||||
className: `导入测试班${(i % 10) + 1}`,
|
||||
headTeacher: '',
|
||||
subjectTeacher: '',
|
||||
startDate: '2024-09-01',
|
||||
endDate: '2025-06-01',
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
examScores.addRow({
|
||||
phone,
|
||||
name: `导入学生${serial}`,
|
||||
examType: 'monthly',
|
||||
examName: '导入测试月考',
|
||||
subject: '语文',
|
||||
score: 80 + (i % 41),
|
||||
classAvg: 90,
|
||||
rank: (i % 50) + 1,
|
||||
examDate: '2024-10-15',
|
||||
enrollmentName: `导入测试班${(i % 10) + 1}`,
|
||||
});
|
||||
|
||||
learningRecords.addRow({
|
||||
phone,
|
||||
name: `导入学生${serial}`,
|
||||
recordDate: '2024-10-20',
|
||||
recordType: 'study_feedback',
|
||||
content: `导入测试回访${serial}`,
|
||||
followUpMethod: 'phone',
|
||||
nextStep: '',
|
||||
});
|
||||
}
|
||||
|
||||
for (const sheet of workbook.worksheets) {
|
||||
sheet.views = [{ state: 'frozen', ySplit: 1 }];
|
||||
}
|
||||
|
||||
await workbook.xlsx.writeFile(output);
|
||||
console.log(`Generated ${count} students with ${STUDENT_IMPORT_COLUMNS.length} main columns: ${output}`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -58,7 +58,10 @@ export class ArchiveService {
|
||||
}
|
||||
|
||||
async getProfile(studentId: number) {
|
||||
const student = await this.studentRepo.findOne({ where: { id: studentId } });
|
||||
const student = await this.studentRepo.findOne({
|
||||
where: { id: studentId },
|
||||
relations: ['organization'],
|
||||
});
|
||||
if (!student) throw new NotFoundException('学生不存在');
|
||||
|
||||
const [
|
||||
|
||||
75
apps/server/src/students/student-import.spec.ts
Normal file
75
apps/server/src/students/student-import.spec.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import * as ExcelJS from 'exceljs';
|
||||
import {
|
||||
createStudentImportTemplateWorkbook,
|
||||
parseStudentImportWorkbook,
|
||||
} from './student-import';
|
||||
|
||||
describe('student import workbook', () => {
|
||||
it('creates a template with one main example row and header-only detail sheets', () => {
|
||||
const workbook = createStudentImportTemplateWorkbook();
|
||||
const students = workbook.getWorksheet('学生基础+档案+录取');
|
||||
|
||||
expect(workbook.worksheets.map((sheet) => sheet.name)).toEqual([
|
||||
'学生基础+档案+录取',
|
||||
'报读班型',
|
||||
'考试成绩',
|
||||
'课堂回访',
|
||||
]);
|
||||
expect(students?.rowCount).toBe(2);
|
||||
expect(students?.getRow(2).getCell(1).value).toBe('13800138000');
|
||||
expect(students?.getRow(2).getCell(19).value).toBe('pending');
|
||||
expect(students?.getRow(2).getCell(20).value || '').toBe('');
|
||||
expect(students?.getRow(2).getCell(21).value || '').toBe('');
|
||||
expect(workbook.getWorksheet('报读班型')?.rowCount).toBe(1);
|
||||
expect(workbook.getWorksheet('考试成绩')?.rowCount).toBe(1);
|
||||
expect(workbook.getWorksheet('课堂回访')?.rowCount).toBe(1);
|
||||
});
|
||||
|
||||
it('maps rows by headers across archive sheets', () => {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const students = workbook.addWorksheet('学生基础+档案+录取');
|
||||
students.addRow([
|
||||
'手机号*',
|
||||
'姓名*',
|
||||
'目标院校',
|
||||
'建档日期',
|
||||
'文化课最终分',
|
||||
'录取状态',
|
||||
]);
|
||||
students.addRow(['13800138000', '张三', '北京大学', '2024/9/1', 620, 'pending']);
|
||||
|
||||
const enrollments = workbook.addWorksheet('报读班型');
|
||||
enrollments.addRow(['手机号*', '课程类别*', '班型*', '班级名称']);
|
||||
enrollments.addRow(['13800138000', 'culture', 'one_on_one', '冲刺班']);
|
||||
|
||||
const examScores = workbook.addWorksheet('考试成绩');
|
||||
examScores.addRow(['手机号*', '考试类型*', '科目*', '成绩*', '考试日期']);
|
||||
examScores.addRow(['13800138000', 'monthly', '语文', '108.5', '2024-10-15']);
|
||||
|
||||
const learningRecords = workbook.addWorksheet('课堂回访');
|
||||
learningRecords.addRow(['手机号*', '记录日期*', '记录类型*', '内容*']);
|
||||
learningRecords.addRow(['13800138000', '2024-10-20', 'study_feedback', '状态稳定']);
|
||||
|
||||
const parsed = parseStudentImportWorkbook(workbook);
|
||||
|
||||
expect(parsed.students[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
phone: '13800138000',
|
||||
name: '张三',
|
||||
targetCollege: '北京大学',
|
||||
profileDate: '2024-09-01',
|
||||
cultureFinalScore: 620,
|
||||
admissionStatus: 'pending',
|
||||
}),
|
||||
);
|
||||
expect(parsed.enrollments[0]).toEqual(
|
||||
expect.objectContaining({ courseCategory: 'culture', classType: 'one_on_one' }),
|
||||
);
|
||||
expect(parsed.examScores[0]).toEqual(
|
||||
expect.objectContaining({ score: 108.5, examDate: '2024-10-15' }),
|
||||
);
|
||||
expect(parsed.learningRecords[0]).toEqual(
|
||||
expect.objectContaining({ recordType: 'study_feedback', content: '状态稳定' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
319
apps/server/src/students/student-import.ts
Normal file
319
apps/server/src/students/student-import.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
import * as ExcelJS from 'exceljs';
|
||||
|
||||
export interface StudentImportRow {
|
||||
name: string;
|
||||
studentNo?: string;
|
||||
phone?: string;
|
||||
idNumber?: string;
|
||||
gender?: string;
|
||||
ethnicity?: string;
|
||||
emergencyContact?: string;
|
||||
emergencyPhone?: string;
|
||||
organization?: string;
|
||||
supervisor?: string;
|
||||
organizationId?: number;
|
||||
targetCollege?: string;
|
||||
targetMajor?: string;
|
||||
subjectDirection?: string;
|
||||
grade?: string;
|
||||
profileDate?: string;
|
||||
notes?: string;
|
||||
cultureFinalScore?: number;
|
||||
professionalFinalScore?: number;
|
||||
admissionStatus?: string;
|
||||
admittedCollege?: string;
|
||||
admittedMajor?: string;
|
||||
}
|
||||
|
||||
export interface StudentEnrollmentImportRow {
|
||||
phone: string;
|
||||
name?: string;
|
||||
courseCategory: string;
|
||||
classType: string;
|
||||
className?: string;
|
||||
headTeacher?: string;
|
||||
subjectTeacher?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface ExamScoreImportRow {
|
||||
phone: string;
|
||||
name?: string;
|
||||
examType: string;
|
||||
examName?: string;
|
||||
subject: string;
|
||||
score?: number;
|
||||
classAvg?: number;
|
||||
rank?: number;
|
||||
examDate?: string;
|
||||
enrollmentName?: string;
|
||||
}
|
||||
|
||||
export interface LearningRecordImportRow {
|
||||
phone: string;
|
||||
name?: string;
|
||||
recordDate: string;
|
||||
recordType: string;
|
||||
content: string;
|
||||
followUpMethod?: string;
|
||||
nextStep?: string;
|
||||
}
|
||||
|
||||
export interface StudentWorkbookImport {
|
||||
students: StudentImportRow[];
|
||||
enrollments: StudentEnrollmentImportRow[];
|
||||
examScores: ExamScoreImportRow[];
|
||||
learningRecords: LearningRecordImportRow[];
|
||||
}
|
||||
|
||||
type ColumnDef<T> = {
|
||||
header: string;
|
||||
key: keyof T;
|
||||
width: number;
|
||||
aliases?: string[];
|
||||
kind?: 'text' | 'date' | 'number' | 'integer';
|
||||
};
|
||||
|
||||
export const STUDENT_IMPORT_COLUMNS: ColumnDef<StudentImportRow>[] = [
|
||||
{ header: '手机号*', key: 'phone', width: 18, aliases: ['手机号', '电话'] },
|
||||
{ header: '姓名*', key: 'name', width: 15, aliases: ['姓名'] },
|
||||
{ header: '学号', key: 'studentNo', width: 15 },
|
||||
{ header: '性别', key: 'gender', width: 8 },
|
||||
{ header: '身份证号', key: 'idNumber', width: 22, aliases: ['身份证', '学号/身份证'] },
|
||||
{ header: '民族', key: 'ethnicity', width: 10 },
|
||||
{ header: '紧急联系人', key: 'emergencyContact', width: 15 },
|
||||
{ header: '紧急联系人电话', key: 'emergencyPhone', width: 18 },
|
||||
{ header: '所属机构', key: 'organization', width: 18, aliases: ['所属机构名称'] },
|
||||
{ header: '负责人', key: 'supervisor', width: 15, aliases: ['负责人/班主任'] },
|
||||
{ header: '目标院校', key: 'targetCollege', width: 18 },
|
||||
{ header: '目标专业', key: 'targetMajor', width: 22 },
|
||||
{ header: '选科方向', key: 'subjectDirection', width: 14 },
|
||||
{ header: '年级', key: 'grade', width: 10 },
|
||||
{ header: '建档日期', key: 'profileDate', width: 14, kind: 'date' },
|
||||
{ header: '档案备注', key: 'notes', width: 28 },
|
||||
{ header: '文化课最终分', key: 'cultureFinalScore', width: 14, kind: 'number' },
|
||||
{ header: '专业课最终分', key: 'professionalFinalScore', width: 14, kind: 'number' },
|
||||
{ header: '录取状态', key: 'admissionStatus', width: 14 },
|
||||
{ header: '录取院校', key: 'admittedCollege', width: 18 },
|
||||
{ header: '录取专业', key: 'admittedMajor', width: 22 },
|
||||
];
|
||||
|
||||
export const STUDENT_EXPORT_COLUMNS = [
|
||||
...STUDENT_IMPORT_COLUMNS,
|
||||
];
|
||||
|
||||
const ENROLLMENT_IMPORT_COLUMNS: ColumnDef<StudentEnrollmentImportRow>[] = [
|
||||
{ header: '手机号*', key: 'phone', width: 18, aliases: ['手机号', '电话'] },
|
||||
{ header: '姓名', key: 'name', width: 15 },
|
||||
{ header: '课程类别*', key: 'courseCategory', width: 14, aliases: ['课程类别'] },
|
||||
{ header: '班型*', key: 'classType', width: 14, aliases: ['班型'] },
|
||||
{ header: '班级名称', key: 'className', width: 20 },
|
||||
{ header: '班主任', key: 'headTeacher', width: 15 },
|
||||
{ header: '任课教师', key: 'subjectTeacher', width: 15 },
|
||||
{ header: '开始日期', key: 'startDate', width: 14, kind: 'date' },
|
||||
{ header: '结束日期', key: 'endDate', width: 14, kind: 'date' },
|
||||
{ header: '状态', key: 'status', width: 12 },
|
||||
];
|
||||
|
||||
const EXAM_SCORE_IMPORT_COLUMNS: ColumnDef<ExamScoreImportRow>[] = [
|
||||
{ header: '手机号*', key: 'phone', width: 18, aliases: ['手机号', '电话'] },
|
||||
{ header: '姓名', key: 'name', width: 15 },
|
||||
{ header: '考试类型*', key: 'examType', width: 14, aliases: ['考试类型'] },
|
||||
{ header: '考试名称', key: 'examName', width: 20 },
|
||||
{ header: '科目*', key: 'subject', width: 14, aliases: ['科目'] },
|
||||
{ header: '成绩*', key: 'score', width: 12, aliases: ['成绩'], kind: 'number' },
|
||||
{ header: '班级均分', key: 'classAvg', width: 12, kind: 'number' },
|
||||
{ header: '排名', key: 'rank', width: 10, kind: 'integer' },
|
||||
{ header: '考试日期', key: 'examDate', width: 14, kind: 'date' },
|
||||
{ header: '关联报读(班级名)', key: 'enrollmentName', width: 22, aliases: ['关联报读(班级名)'] },
|
||||
];
|
||||
|
||||
const LEARNING_RECORD_IMPORT_COLUMNS: ColumnDef<LearningRecordImportRow>[] = [
|
||||
{ header: '手机号*', key: 'phone', width: 18, aliases: ['手机号', '电话'] },
|
||||
{ header: '姓名', key: 'name', width: 15 },
|
||||
{ header: '记录日期*', key: 'recordDate', width: 14, aliases: ['记录日期'], kind: 'date' },
|
||||
{ header: '记录类型*', key: 'recordType', width: 14, aliases: ['记录类型'] },
|
||||
{ header: '内容*', key: 'content', width: 36, aliases: ['内容'] },
|
||||
{ header: '跟进方式', key: 'followUpMethod', width: 14 },
|
||||
{ header: '下一步计划', key: 'nextStep', width: 28 },
|
||||
];
|
||||
|
||||
function normalizeHeader(header: string): string {
|
||||
return header.trim().replace(/\*+$/u, '').trim();
|
||||
}
|
||||
|
||||
function getCellPrimitiveValue(cell: ExcelJS.Cell): unknown {
|
||||
const value = cell.value;
|
||||
if (value === null || value === undefined) return undefined;
|
||||
if (value instanceof Date) return value;
|
||||
if (typeof value === 'object') {
|
||||
if ('result' in value) return value.result;
|
||||
if ('text' in value) return value.text;
|
||||
if ('richText' in value && Array.isArray(value.richText)) {
|
||||
return value.richText.map((part) => part.text).join('');
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function cellToText(cell: ExcelJS.Cell): string {
|
||||
const value = getCellPrimitiveValue(cell);
|
||||
if (value === null || value === undefined) return '';
|
||||
if (value instanceof Date) return formatDate(value);
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function excelSerialToDate(serial: number): string | undefined {
|
||||
if (!Number.isFinite(serial) || serial < 1) return undefined;
|
||||
const epoch = Date.UTC(1899, 11, 30);
|
||||
return formatDate(new Date(epoch + serial * 24 * 60 * 60 * 1000));
|
||||
}
|
||||
|
||||
function parseDateText(cell: ExcelJS.Cell): string | undefined {
|
||||
const value = getCellPrimitiveValue(cell);
|
||||
if (value instanceof Date) return formatDate(value);
|
||||
if (typeof value === 'number') return excelSerialToDate(value);
|
||||
const text = value === null || value === undefined ? '' : String(value).trim();
|
||||
if (!text) return undefined;
|
||||
const normalized = text.replace(/[/.]/g, '-');
|
||||
const match = normalized.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/u);
|
||||
if (!match) return text;
|
||||
return `${match[1]}-${match[2].padStart(2, '0')}-${match[3].padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function parseNumber(cell: ExcelJS.Cell): number | undefined {
|
||||
const text = cellToText(cell).replace(/,/g, '');
|
||||
if (!text) return undefined;
|
||||
const value = Number(text);
|
||||
return Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function buildHeaderMap<T extends object>(ws: ExcelJS.Worksheet, columns: ColumnDef<T>[]) {
|
||||
const columnByHeader = new Map<string, ColumnDef<T>>();
|
||||
for (const column of columns) {
|
||||
columnByHeader.set(normalizeHeader(column.header), column);
|
||||
for (const alias of column.aliases || []) {
|
||||
columnByHeader.set(normalizeHeader(alias), column);
|
||||
}
|
||||
}
|
||||
|
||||
const headerIndex = new Map<number, ColumnDef<T>>();
|
||||
ws.getRow(1).eachCell((cell, colNumber) => {
|
||||
const column = columnByHeader.get(normalizeHeader(cellToText(cell)));
|
||||
if (column) headerIndex.set(colNumber, column);
|
||||
});
|
||||
return headerIndex;
|
||||
}
|
||||
|
||||
function parseWorksheetRows<T extends object>(
|
||||
ws: ExcelJS.Worksheet | undefined,
|
||||
columns: ColumnDef<T>[],
|
||||
): T[] {
|
||||
if (!ws) return [];
|
||||
const headerIndex = buildHeaderMap(ws, columns);
|
||||
const rows: T[] = [];
|
||||
|
||||
ws.eachRow((row, idx) => {
|
||||
if (idx === 1) return;
|
||||
const parsed: Partial<Record<keyof T, string | number>> = {};
|
||||
headerIndex.forEach((column, colNumber) => {
|
||||
const cell = row.getCell(colNumber);
|
||||
const value =
|
||||
column.kind === 'date'
|
||||
? parseDateText(cell)
|
||||
: column.kind === 'number' || column.kind === 'integer'
|
||||
? parseNumber(cell)
|
||||
: cellToText(cell);
|
||||
if (value !== undefined && value !== '') {
|
||||
parsed[column.key] = column.kind === 'integer' && typeof value === 'number'
|
||||
? Math.trunc(value)
|
||||
: value;
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(parsed).length > 0) rows.push(parsed as T);
|
||||
});
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function findWorksheet(workbook: ExcelJS.Workbook, names: string[], fallbackIndex?: number) {
|
||||
for (const name of names) {
|
||||
const sheet = workbook.getWorksheet(name);
|
||||
if (sheet) return sheet;
|
||||
}
|
||||
return fallbackIndex === undefined ? undefined : workbook.worksheets[fallbackIndex];
|
||||
}
|
||||
|
||||
export function parseStudentImportWorkbook(workbook: ExcelJS.Workbook): StudentWorkbookImport {
|
||||
return {
|
||||
students: parseWorksheetRows(
|
||||
findWorksheet(workbook, ['学生基础+档案+录取', '学生导入模板', '学生名单'], 0),
|
||||
STUDENT_IMPORT_COLUMNS,
|
||||
).map((row) => ({ ...row, name: row.name || '', phone: row.phone || '' })),
|
||||
enrollments: parseWorksheetRows(
|
||||
findWorksheet(workbook, ['报读班型']),
|
||||
ENROLLMENT_IMPORT_COLUMNS,
|
||||
),
|
||||
examScores: parseWorksheetRows(
|
||||
findWorksheet(workbook, ['考试成绩']),
|
||||
EXAM_SCORE_IMPORT_COLUMNS,
|
||||
),
|
||||
learningRecords: parseWorksheetRows(
|
||||
findWorksheet(workbook, ['课堂回访']),
|
||||
LEARNING_RECORD_IMPORT_COLUMNS,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function applyHeaderStyle(ws: ExcelJS.Worksheet) {
|
||||
ws.getRow(1).font = { bold: true };
|
||||
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
||||
}
|
||||
|
||||
function setupWorksheet<T>(workbook: ExcelJS.Workbook, name: string, columns: ColumnDef<T>[]) {
|
||||
const ws = workbook.addWorksheet(name);
|
||||
ws.columns = columns.map(({ header, key, width }) => ({ header, key: String(key), width }));
|
||||
applyHeaderStyle(ws);
|
||||
return ws;
|
||||
}
|
||||
|
||||
export function createStudentImportTemplateWorkbook(): ExcelJS.Workbook {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
setupWorksheet(workbook, '学生基础+档案+录取', STUDENT_IMPORT_COLUMNS).addRow({
|
||||
phone: '13800138000',
|
||||
name: '张三',
|
||||
studentNo: '2024001',
|
||||
gender: '男',
|
||||
idNumber: '11010120060101001X',
|
||||
ethnicity: '汉族',
|
||||
emergencyContact: '张父',
|
||||
emergencyPhone: '13900139000',
|
||||
organization: '北京校区',
|
||||
supervisor: '李老师',
|
||||
targetCollege: '北京大学',
|
||||
targetMajor: '计算机科学与技术',
|
||||
subjectDirection: '物化生',
|
||||
grade: '高三',
|
||||
profileDate: '2024-09-01',
|
||||
notes: '学习态度积极,目标明确',
|
||||
cultureFinalScore: 620,
|
||||
professionalFinalScore: 580,
|
||||
admissionStatus: 'pending',
|
||||
admittedCollege: '',
|
||||
admittedMajor: '',
|
||||
});
|
||||
setupWorksheet(workbook, '报读班型', ENROLLMENT_IMPORT_COLUMNS);
|
||||
setupWorksheet(workbook, '考试成绩', EXAM_SCORE_IMPORT_COLUMNS);
|
||||
setupWorksheet(workbook, '课堂回访', LEARNING_RECORD_IMPORT_COLUMNS);
|
||||
return workbook;
|
||||
}
|
||||
@@ -62,6 +62,11 @@ function makeService(
|
||||
{} as never, // attendanceRepo
|
||||
{} as never, // classTeacherRepo
|
||||
{} as never, // organizationRepo
|
||||
{} as never, // profileRepo
|
||||
{} as never, // enrollmentRepo
|
||||
{} as never, // examScoreRepo
|
||||
{} as never, // learningRecordRepo
|
||||
{} as never, // resultRepo
|
||||
);
|
||||
|
||||
return { service, studentQb, classStudentQb };
|
||||
|
||||
@@ -30,127 +30,16 @@ import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||
import { AuthorizationService, CaslAction, SubjectName } from '../authorization';
|
||||
import type { AuthenticatedUser } from '../authorization';
|
||||
import * as ExcelJS from 'exceljs';
|
||||
import {
|
||||
createStudentImportTemplateWorkbook,
|
||||
parseStudentImportWorkbook,
|
||||
STUDENT_EXPORT_COLUMNS,
|
||||
} from './student-import';
|
||||
|
||||
interface AuthenticatedRequest {
|
||||
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)
|
||||
@Controller('students')
|
||||
export class StudentsController {
|
||||
@@ -218,25 +107,34 @@ export class StudentsController {
|
||||
ws.columns = STUDENT_EXPORT_COLUMNS;
|
||||
ws.getRow(1).font = { bold: true };
|
||||
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
||||
const statusMap: Record<string, string> = {
|
||||
active: '在读',
|
||||
graduated: '已毕业',
|
||||
withdrawn: '已退训',
|
||||
archived: '已归档',
|
||||
};
|
||||
const { profiles, results } = await this.service.getArchiveExportMaps(
|
||||
students.map((student) => student.id),
|
||||
);
|
||||
for (const s of students) {
|
||||
const profile = profiles.get(s.id);
|
||||
const result = results.get(s.id);
|
||||
ws.addRow({
|
||||
phone: s.phone || '',
|
||||
name: s.name,
|
||||
studentNo: s.studentNo || '',
|
||||
gender: s.gender || '',
|
||||
phone: s.phone || '',
|
||||
idNumber: s.idNumber || '',
|
||||
ethnicity: s.ethnicity || '',
|
||||
emergencyContact: s.emergencyContact || '',
|
||||
emergencyPhone: s.emergencyPhone || '',
|
||||
organization: s.organization?.name || '',
|
||||
supervisor: s.supervisor || '',
|
||||
status: statusMap[s.status] || s.status,
|
||||
targetCollege: profile?.targetCollege || '',
|
||||
targetMajor: profile?.targetMajor || '',
|
||||
subjectDirection: profile?.subjectDirection || '',
|
||||
grade: profile?.grade || '',
|
||||
profileDate: profile?.profileDate || '',
|
||||
notes: profile?.notes || '',
|
||||
cultureFinalScore: result?.cultureFinalScore ?? '',
|
||||
professionalFinalScore: result?.professionalFinalScore ?? '',
|
||||
admissionStatus: result?.admissionStatus || '',
|
||||
admittedCollege: result?.admittedCollege || '',
|
||||
admittedMajor: result?.admittedMajor || '',
|
||||
});
|
||||
}
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
@@ -261,23 +159,7 @@ export class StudentsController {
|
||||
@Get('template')
|
||||
@RequirePermission('student:view')
|
||||
async downloadTemplate(@Res() res: Response) {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const ws = workbook.addWorksheet('学生导入模板');
|
||||
ws.columns = STUDENT_IMPORT_COLUMNS;
|
||||
ws.getRow(1).font = { bold: true };
|
||||
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
||||
ws.addRow({
|
||||
name: '张三',
|
||||
studentNo: '2024001',
|
||||
gender: '男',
|
||||
phone: '13800138000',
|
||||
idNumber: '11010120060101001X',
|
||||
ethnicity: '汉族',
|
||||
emergencyContact: '张父',
|
||||
emergencyPhone: '13900000000',
|
||||
organization: 'XX教育公司',
|
||||
supervisor: '',
|
||||
});
|
||||
const workbook = createStudentImportTemplateWorkbook();
|
||||
res.setHeader(
|
||||
'Content-Type',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
@@ -391,10 +273,9 @@ export class StudentsController {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(file.buffer as any);
|
||||
const ws = workbook.worksheets[0];
|
||||
const rows = parseStudentImportRows(ws);
|
||||
const importData = parseStudentImportWorkbook(workbook);
|
||||
// Resolve organization names to IDs
|
||||
for (const row of rows) {
|
||||
for (const row of importData.students) {
|
||||
if (row.organization) {
|
||||
const organization = await this.organizationRepo.findOne({
|
||||
where: { name: row.organization },
|
||||
@@ -404,7 +285,7 @@ export class StudentsController {
|
||||
}
|
||||
}
|
||||
}
|
||||
const result = await this.service.batchImport(rows);
|
||||
const result = await this.service.batchImport(importData);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
@@ -424,10 +305,9 @@ export class StudentsController {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer);
|
||||
const ws = workbook.worksheets[0];
|
||||
const rows = parseStudentImportRows(ws);
|
||||
const importData = parseStudentImportWorkbook(workbook);
|
||||
// Resolve organization names to IDs
|
||||
for (const row of rows) {
|
||||
for (const row of importData.students) {
|
||||
if (row.organization) {
|
||||
const organization = await this.organizationRepo.findOne({
|
||||
where: { name: row.organization },
|
||||
@@ -435,7 +315,7 @@ export class StudentsController {
|
||||
if (organization) row.organizationId = organization.id;
|
||||
}
|
||||
}
|
||||
const result = await this.service.matchImport(rows);
|
||||
const result = await this.service.matchImport(importData);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
|
||||
@@ -2,6 +2,8 @@ import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { StudentsService } from './students.service';
|
||||
|
||||
function createService(repo: Record<string, jest.Mock>, organizationRepo = {}) {
|
||||
const profileRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||
const resultRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||
return new StudentsService(
|
||||
repo as never,
|
||||
{} as never,
|
||||
@@ -9,6 +11,11 @@ function createService(repo: Record<string, jest.Mock>, organizationRepo = {}) {
|
||||
{} as never,
|
||||
{} as never,
|
||||
organizationRepo as never,
|
||||
profileRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
resultRepo as never,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,4 +47,31 @@ describe('StudentsService — archive lifecycle boundaries', () => {
|
||||
const repo = { findOne: jest.fn().mockResolvedValue(null) };
|
||||
await expect(createService(repo).findOne(404)).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('builds export archive maps from profile and result rows', async () => {
|
||||
const profileRepo = {
|
||||
find: jest.fn().mockResolvedValue([{ studentId: 1, targetCollege: '北京大学' }]),
|
||||
};
|
||||
const resultRepo = {
|
||||
find: jest.fn().mockResolvedValue([{ studentId: 1, admissionStatus: 'pending' }]),
|
||||
};
|
||||
const service = new StudentsService(
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
profileRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
resultRepo as never,
|
||||
);
|
||||
|
||||
const maps = await service.getArchiveExportMaps([1]);
|
||||
|
||||
expect(maps.profiles.get(1)?.targetCollege).toBe('北京大学');
|
||||
expect(maps.results.get(1)?.admissionStatus).toBe('pending');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,11 @@ import { Organization } from '../entities/organization.entity';
|
||||
import { ClassStudent } from '../entities/class-student.entity';
|
||||
import { AttendanceRecord } from '../entities/attendance-record.entity';
|
||||
import { ClassTeacher } from '../entities/class-teacher.entity';
|
||||
import { StudentProfile } from '../entities/student-profile.entity';
|
||||
import { StudentEnrollment } from '../entities/student-enrollment.entity';
|
||||
import { ExamScore } from '../entities/exam-score.entity';
|
||||
import { LearningRecord } from '../entities/learning-record.entity';
|
||||
import { ResultArchive } from '../entities/result-archive.entity';
|
||||
import { StudentsService } from './students.service';
|
||||
import { StudentAccessScopeFactory } from './student-access-scope.factory';
|
||||
import { StudentsController } from './students.controller';
|
||||
@@ -19,6 +24,11 @@ import { StudentsController } from './students.controller';
|
||||
AttendanceRecord,
|
||||
Organization,
|
||||
ClassTeacher,
|
||||
StudentProfile,
|
||||
StudentEnrollment,
|
||||
ExamScore,
|
||||
LearningRecord,
|
||||
ResultArchive,
|
||||
]),
|
||||
],
|
||||
controllers: [StudentsController],
|
||||
|
||||
@@ -13,6 +13,11 @@ describe('StudentsService — teacher class scope', () => {
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
await service.findAll({}, [3, 5]);
|
||||
@@ -37,6 +42,11 @@ describe('StudentsService — teacher class scope', () => {
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
await expect(service.findAll({}, [])).resolves.toEqual([]);
|
||||
|
||||
@@ -7,7 +7,19 @@ import { ClassStudent } from '../entities/class-student.entity';
|
||||
import { ClassTeacher } from '../entities/class-teacher.entity';
|
||||
import { AttendanceRecord } from '../entities/attendance-record.entity';
|
||||
import { Organization } from '../entities/organization.entity';
|
||||
import { StudentProfile } from '../entities/student-profile.entity';
|
||||
import { StudentEnrollment } from '../entities/student-enrollment.entity';
|
||||
import { ExamScore } from '../entities/exam-score.entity';
|
||||
import { LearningRecord } from '../entities/learning-record.entity';
|
||||
import { ResultArchive } from '../entities/result-archive.entity';
|
||||
import { CreateStudentDto, UpdateStudentDto } from './dto/student.dto';
|
||||
import type {
|
||||
ExamScoreImportRow,
|
||||
LearningRecordImportRow,
|
||||
StudentEnrollmentImportRow,
|
||||
StudentImportRow,
|
||||
StudentWorkbookImport,
|
||||
} from './student-import';
|
||||
import type { StudentAccessScope } from './student-access-scope';
|
||||
|
||||
@Injectable()
|
||||
@@ -19,6 +31,11 @@ export class StudentsService {
|
||||
@InjectRepository(AttendanceRecord) private attendanceRepo: Repository<AttendanceRecord>,
|
||||
@InjectRepository(ClassTeacher) private classTeacherRepo: Repository<ClassTeacher>,
|
||||
@InjectRepository(Organization) private organizationRepo: Repository<Organization>,
|
||||
@InjectRepository(StudentProfile) private profileRepo: Repository<StudentProfile>,
|
||||
@InjectRepository(StudentEnrollment) private enrollmentRepo: Repository<StudentEnrollment>,
|
||||
@InjectRepository(ExamScore) private examScoreRepo: Repository<ExamScore>,
|
||||
@InjectRepository(LearningRecord) private learningRecordRepo: Repository<LearningRecord>,
|
||||
@InjectRepository(ResultArchive) private resultRepo: Repository<ResultArchive>,
|
||||
) {}
|
||||
|
||||
async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
|
||||
@@ -35,6 +52,23 @@ export class StudentsService {
|
||||
});
|
||||
}
|
||||
|
||||
async getArchiveExportMaps(studentIds: number[]) {
|
||||
if (studentIds.length === 0) {
|
||||
return {
|
||||
profiles: new Map<number, StudentProfile>(),
|
||||
results: new Map<number, ResultArchive>(),
|
||||
};
|
||||
}
|
||||
const [profiles, results] = await Promise.all([
|
||||
this.profileRepo.find({ where: { studentId: In(studentIds) } }),
|
||||
this.resultRepo.find({ where: { studentId: In(studentIds) } }),
|
||||
]);
|
||||
return {
|
||||
profiles: new Map(profiles.map((profile) => [profile.studentId, profile])),
|
||||
results: new Map(results.map((result) => [result.studentId, result])),
|
||||
};
|
||||
}
|
||||
|
||||
async findAll(
|
||||
query?: {
|
||||
name?: string;
|
||||
@@ -185,24 +219,12 @@ export class StudentsService {
|
||||
return { message: '已恢复' };
|
||||
}
|
||||
|
||||
async batchImport(
|
||||
rows: {
|
||||
name: string;
|
||||
studentNo?: string;
|
||||
phone?: string;
|
||||
idNumber?: string;
|
||||
gender?: string;
|
||||
ethnicity?: string;
|
||||
emergencyContact?: string;
|
||||
emergencyPhone?: string;
|
||||
organization?: string;
|
||||
supervisor?: string;
|
||||
organizationId?: number;
|
||||
}[],
|
||||
) {
|
||||
async batchImport(importData: StudentWorkbookImport | StudentImportRow[]) {
|
||||
const data = this.normalizeImportData(importData);
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
for (const row of rows) {
|
||||
let archiveImported = 0;
|
||||
for (const row of data.students) {
|
||||
if (!row.name || !row.name.trim()) {
|
||||
skipped++;
|
||||
continue;
|
||||
@@ -212,7 +234,7 @@ export class StudentsService {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
await this.repo.save(
|
||||
const student = await this.repo.save(
|
||||
this.repo.create({
|
||||
name: row.name.trim(),
|
||||
studentNo: row.studentNo?.trim() || undefined,
|
||||
@@ -226,33 +248,23 @@ export class StudentsService {
|
||||
organizationId: row.organizationId || (await this.getHostOrganizationId()),
|
||||
}),
|
||||
);
|
||||
archiveImported += await this.importArchiveData(student.id, row, data);
|
||||
imported++;
|
||||
}
|
||||
return {
|
||||
message: `成功导入 ${imported} 名学生,跳过 ${skipped} 条(重复或空行)`,
|
||||
message: `成功导入 ${imported} 名学生,导入档案相关记录 ${archiveImported} 条,跳过 ${skipped} 条(重复或空行)`,
|
||||
imported,
|
||||
archiveImported,
|
||||
skipped,
|
||||
};
|
||||
}
|
||||
|
||||
async matchImport(
|
||||
rows: {
|
||||
name: string;
|
||||
studentNo?: string;
|
||||
phone?: string;
|
||||
idNumber?: string;
|
||||
gender?: string;
|
||||
ethnicity?: string;
|
||||
emergencyContact?: string;
|
||||
emergencyPhone?: string;
|
||||
organization?: string;
|
||||
supervisor?: string;
|
||||
organizationId?: number;
|
||||
}[],
|
||||
) {
|
||||
async matchImport(importData: StudentWorkbookImport | StudentImportRow[]) {
|
||||
const data = this.normalizeImportData(importData);
|
||||
let matched = 0;
|
||||
let skipped = 0;
|
||||
for (const row of rows) {
|
||||
let archiveImported = 0;
|
||||
for (const row of data.students) {
|
||||
// Match by phone first, then idNumber
|
||||
let student = row.phone?.trim()
|
||||
? await this.repo.findOne({ where: { phone: row.phone.trim() } })
|
||||
@@ -291,15 +303,187 @@ export class StudentsService {
|
||||
if (row.supervisor) updates.supervisor = row.supervisor;
|
||||
if (row.organizationId) updates.organizationId = row.organizationId;
|
||||
await this.repo.update(student.id, updates);
|
||||
archiveImported += await this.importArchiveData(student.id, row, data);
|
||||
matched++;
|
||||
}
|
||||
return {
|
||||
message: `更新已有学生资料 ${matched} 人,跳过 ${skipped} 条(无匹配)`,
|
||||
message: `更新已有学生资料 ${matched} 人,导入档案相关记录 ${archiveImported} 条,跳过 ${skipped} 条(无匹配)`,
|
||||
matched,
|
||||
archiveImported,
|
||||
skipped,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeImportData(importData: StudentWorkbookImport | StudentImportRow[]): StudentWorkbookImport {
|
||||
if (Array.isArray(importData)) {
|
||||
return { students: importData, enrollments: [], examScores: [], learningRecords: [] };
|
||||
}
|
||||
return importData;
|
||||
}
|
||||
|
||||
private normalizePhone(phone?: string) {
|
||||
return phone?.trim() || '';
|
||||
}
|
||||
|
||||
private sameValue(left?: string | number | null, right?: string | number | null) {
|
||||
return String(left ?? '').trim() === String(right ?? '').trim();
|
||||
}
|
||||
|
||||
private hasProfileData(row: StudentImportRow) {
|
||||
return [
|
||||
row.targetCollege,
|
||||
row.targetMajor,
|
||||
row.subjectDirection,
|
||||
row.grade,
|
||||
row.profileDate,
|
||||
row.notes,
|
||||
].some((value) => value !== undefined && String(value).trim() !== '');
|
||||
}
|
||||
|
||||
private hasResultData(row: StudentImportRow) {
|
||||
return [
|
||||
row.cultureFinalScore,
|
||||
row.professionalFinalScore,
|
||||
row.admissionStatus,
|
||||
row.admittedCollege,
|
||||
row.admittedMajor,
|
||||
].some((value) => value !== undefined && String(value).trim() !== '');
|
||||
}
|
||||
|
||||
private async importArchiveData(
|
||||
studentId: number,
|
||||
row: StudentImportRow,
|
||||
data: StudentWorkbookImport,
|
||||
) {
|
||||
const phone = this.normalizePhone(row.phone);
|
||||
let imported = 0;
|
||||
if (this.hasProfileData(row)) {
|
||||
await this.upsertProfileFromImport(studentId, row);
|
||||
imported++;
|
||||
}
|
||||
if (this.hasResultData(row)) {
|
||||
await this.upsertResultFromImport(studentId, row);
|
||||
imported++;
|
||||
}
|
||||
if (!phone) return imported;
|
||||
|
||||
const enrollmentByClassName = new Map<string, StudentEnrollment>();
|
||||
for (const enrollmentRow of data.enrollments.filter((item) => this.normalizePhone(item.phone) === phone)) {
|
||||
const enrollment = await this.upsertEnrollmentFromImport(studentId, enrollmentRow);
|
||||
if (!enrollment) continue;
|
||||
if (enrollment.className) enrollmentByClassName.set(enrollment.className, enrollment);
|
||||
imported++;
|
||||
}
|
||||
for (const examRow of data.examScores.filter((item) => this.normalizePhone(item.phone) === phone)) {
|
||||
if (await this.upsertExamScoreFromImport(studentId, examRow, enrollmentByClassName)) {
|
||||
imported++;
|
||||
}
|
||||
}
|
||||
for (const learningRow of data.learningRecords.filter((item) => this.normalizePhone(item.phone) === phone)) {
|
||||
if (await this.upsertLearningRecordFromImport(studentId, learningRow)) {
|
||||
imported++;
|
||||
}
|
||||
}
|
||||
return imported;
|
||||
}
|
||||
|
||||
private async upsertProfileFromImport(studentId: number, row: StudentImportRow) {
|
||||
const entity = (await this.profileRepo.findOne({ where: { studentId } })) || this.profileRepo.create({ studentId });
|
||||
if (row.targetCollege?.trim()) entity.targetCollege = row.targetCollege.trim();
|
||||
if (row.targetMajor?.trim()) entity.targetMajor = row.targetMajor.trim();
|
||||
if (row.subjectDirection?.trim()) entity.subjectDirection = row.subjectDirection.trim();
|
||||
if (row.grade?.trim()) entity.grade = row.grade.trim();
|
||||
if (row.profileDate?.trim()) entity.profileDate = row.profileDate.trim();
|
||||
if (row.notes?.trim()) entity.notes = row.notes.trim();
|
||||
await this.profileRepo.save(entity);
|
||||
}
|
||||
|
||||
private async upsertResultFromImport(studentId: number, row: StudentImportRow) {
|
||||
const entity = (await this.resultRepo.findOne({ where: { studentId } })) || this.resultRepo.create({ studentId });
|
||||
if (row.cultureFinalScore !== undefined) entity.cultureFinalScore = row.cultureFinalScore;
|
||||
if (row.professionalFinalScore !== undefined) entity.professionalFinalScore = row.professionalFinalScore;
|
||||
if (row.admissionStatus?.trim()) entity.admissionStatus = row.admissionStatus.trim();
|
||||
if (row.admittedCollege?.trim()) entity.admittedCollege = row.admittedCollege.trim();
|
||||
if (row.admittedMajor?.trim()) entity.admittedMajor = row.admittedMajor.trim();
|
||||
await this.resultRepo.save(entity);
|
||||
}
|
||||
|
||||
private async upsertEnrollmentFromImport(studentId: number, row: StudentEnrollmentImportRow) {
|
||||
if (!row.courseCategory?.trim() || !row.classType?.trim()) {
|
||||
return null;
|
||||
}
|
||||
const existing = await this.enrollmentRepo.find({ where: { studentId } });
|
||||
const entity =
|
||||
existing.find(
|
||||
(item) =>
|
||||
this.sameValue(item.courseCategory, row.courseCategory) &&
|
||||
this.sameValue(item.classType, row.classType) &&
|
||||
this.sameValue(item.className, row.className) &&
|
||||
this.sameValue(item.startDate, row.startDate),
|
||||
) || this.enrollmentRepo.create({ studentId });
|
||||
entity.courseCategory = row.courseCategory.trim();
|
||||
entity.classType = row.classType.trim();
|
||||
if (row.className?.trim()) entity.className = row.className.trim();
|
||||
if (row.headTeacher?.trim()) entity.headTeacher = row.headTeacher.trim();
|
||||
if (row.subjectTeacher?.trim()) entity.subjectTeacher = row.subjectTeacher.trim();
|
||||
if (row.startDate?.trim()) entity.startDate = row.startDate.trim();
|
||||
if (row.endDate?.trim()) entity.endDate = row.endDate.trim();
|
||||
if (row.status?.trim()) entity.status = row.status.trim();
|
||||
else if (!entity.status) entity.status = 'active';
|
||||
return this.enrollmentRepo.save(entity);
|
||||
}
|
||||
|
||||
private async upsertExamScoreFromImport(
|
||||
studentId: number,
|
||||
row: ExamScoreImportRow,
|
||||
enrollmentByClassName: Map<string, StudentEnrollment>,
|
||||
) {
|
||||
if (!row.examType?.trim() || !row.subject?.trim() || row.score === undefined) return false;
|
||||
const existing = await this.examScoreRepo.find({ where: { studentId } });
|
||||
const entity =
|
||||
existing.find(
|
||||
(item) =>
|
||||
this.sameValue(item.examType, row.examType) &&
|
||||
this.sameValue(item.examName, row.examName) &&
|
||||
this.sameValue(item.subject, row.subject) &&
|
||||
this.sameValue(item.examDate, row.examDate),
|
||||
) || this.examScoreRepo.create({ studentId });
|
||||
entity.examType = row.examType.trim();
|
||||
entity.subject = row.subject.trim();
|
||||
entity.score = row.score;
|
||||
if (row.examName?.trim()) entity.examName = row.examName.trim();
|
||||
if (row.classAvg !== undefined) entity.classAvg = row.classAvg;
|
||||
if (row.rank !== undefined) entity.rank = row.rank;
|
||||
if (row.examDate?.trim()) entity.examDate = row.examDate.trim();
|
||||
if (row.enrollmentName?.trim()) {
|
||||
const enrollment = enrollmentByClassName.get(row.enrollmentName.trim());
|
||||
if (enrollment) entity.enrollmentId = enrollment.id;
|
||||
}
|
||||
if (!entity.status) entity.status = 'active';
|
||||
await this.examScoreRepo.save(entity);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async upsertLearningRecordFromImport(studentId: number, row: LearningRecordImportRow) {
|
||||
if (!row.recordDate?.trim() || !row.recordType?.trim() || !row.content?.trim()) return false;
|
||||
const existing = await this.learningRecordRepo.find({ where: { studentId } });
|
||||
const entity =
|
||||
existing.find(
|
||||
(item) =>
|
||||
this.sameValue(item.recordDate, row.recordDate) &&
|
||||
this.sameValue(item.recordType, row.recordType) &&
|
||||
this.sameValue(item.content, row.content),
|
||||
) || this.learningRecordRepo.create({ studentId });
|
||||
entity.recordDate = row.recordDate.trim();
|
||||
entity.recordType = row.recordType.trim();
|
||||
entity.content = row.content.trim();
|
||||
if (row.followUpMethod?.trim()) entity.followUpMethod = row.followUpMethod.trim();
|
||||
if (row.nextStep?.trim()) entity.nextStep = row.nextStep.trim();
|
||||
if (!entity.status) entity.status = 'active';
|
||||
await this.learningRecordRepo.save(entity);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async assertActiveOrganization(id: number) {
|
||||
const organization = await this.organizationRepo.findOne({ where: { id, status: 'active' } });
|
||||
if (!organization) throw new BadRequestException('所属机构不存在或已归档');
|
||||
|
||||
Reference in New Issue
Block a user