feat: expand student archive import template

This commit is contained in:
2026-07-21 15:11:52 +08:00
parent a585fd42d5
commit 90cc0c10b2
13 changed files with 1103 additions and 385 deletions

View File

@@ -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;

View File

@@ -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>
);
};