Files
gongxue-base/apps/admin/src/components/StudentProfileContent/index.tsx
wangziqi 8494c511b6 feat: 学生档案 Excel 批量导入
- 4-Sheet Excel 模板:学生基础+档案+录取 / 报读班型 / 考试成绩 / 课堂回访
- 手机号精确匹配已有学生,不存在则新建
- POST /archive/import 批量导入接口
- GET /archive/import/template 模板下载接口
- 前端:下载模板 + 上传导入 + 结果汇总弹窗
2026-07-20 15:05:41 +08:00

1251 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useState, useCallback, useMemo } from 'react';
import {
Tabs,
Card,
Descriptions,
Table,
Button,
Modal,
Form,
Input,
Select,
DatePicker,
InputNumber,
Upload,
Tag,
Space,
Popconfirm,
Empty,
Row,
Col,
Statistic,
Spin,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
PlusOutlined,
UploadOutlined,
InboxOutlined,
EyeOutlined,
CloseOutlined,
FileTextOutlined,
ReloadOutlined,
ImportOutlined,
DownloadOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import api from '../../api';
import { maskPhone, maskIdNumber } from '../../utils/sensitive';
import { useViewSensitive } from '../../hooks/useViewSensitive';
import { message } from '../../ui/app-message';
// ---- Types ----
interface StudentInfo {
id: number;
name: string;
phone: string;
idNumber: string;
studentNo: string;
status: string;
}
interface ProfileData {
targetCollege?: string;
targetMajor?: string;
subjectDirection?: string;
grade?: string;
profileDate?: string;
notes?: string;
}
interface EnrollmentRecord {
id: number;
courseCategory: string;
classType: string;
className?: string;
headTeacher?: string;
subjectTeacher?: string;
startDate?: string;
endDate?: string;
status: string;
}
interface ExamScoreRecord {
id: number;
examType: string;
examName?: string;
subject: string;
score: number;
classAvg?: number;
rank?: number;
examDate?: string;
enrollmentId?: number;
}
interface LearningRecord {
id: number;
recordDate: string;
recordType: string;
content: string;
followUpMethod?: string;
nextStep?: string;
}
interface ResultData {
cultureFinalScore?: number;
professionalFinalScore?: number;
admissionStatus?: string;
admittedCollege?: string;
admittedMajor?: string;
}
interface AttachmentRecord {
id: number;
category: string;
fileName: string;
fileSize: number;
}
interface AttendanceRecordItem {
id: number;
attendanceDate: string;
session: string;
status: string;
source?: string;
remark?: string | null;
punchTime?: string | null;
punchDeviceName?: string | null;
punchDeviceId?: string | null;
schedule?: { subject?: string } | null;
class?: { name?: string } | null;
}
interface StudentProfileAggregate {
student: StudentInfo;
profile: ProfileData | null;
enrollments: EnrollmentRecord[];
examScores: ExamScoreRecord[];
learningRecords: LearningRecord[];
result: ResultData | null;
attachments: AttachmentRecord[];
attendances: AttendanceRecordItem[];
}
export interface StudentProfileContentProps {
studentId: number;
inDrawer?: boolean;
onClose?: () => void;
}
// ---- Constants ----
const ADMISSION_STATUS_MAP: Record<string, { text: string; color: string }> = {
admitted: { text: '已录取', color: 'green' },
pending: { text: '待录取', color: 'orange' },
rejected: { text: '未录取', color: 'red' },
withdrawn: { text: '放弃', color: '#999' },
};
const EXAM_TYPE_OPTIONS = [
{ value: 'monthly', label: '月考' },
{ value: 'midterm', label: '期中' },
{ value: 'final', label: '期末' },
{ value: 'mock', label: '模拟考' },
{ value: 'entrance', label: '入学测试' },
{ value: 'other', label: '其他' },
];
const RECORD_TYPE_OPTIONS = [
{ value: 'study_feedback', label: '学习反馈' },
{ value: 'parent_communication', label: '家长沟通' },
{ value: 'behavior_note', label: '行为记录' },
{ value: 'meeting', 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 = [
{ value: 'culture', label: '文化课' },
{ value: 'professional', label: '专业课' },
{ value: 'comprehensive', label: '综合' },
];
const CLASS_TYPE_OPTIONS = [
{ value: 'one_on_one', label: '一对一' },
{ value: 'small_group', label: '小班' },
{ value: 'large_class', label: '大班' },
{ value: 'online', 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 = [
{ value: 'id_card', label: '身份证' },
{ value: 'transcript', label: '成绩单' },
{ value: 'certificate', label: '证书' },
{ value: 'contract', label: '合同' },
{ value: 'photo', label: '照片' },
{ value: 'other', label: '其他' },
];
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
// ---- Tab Components ----
const ATTENDANCE_STATUS_MAP: Record<string, { text: string; color: string }> = {
present: { text: '出勤', color: 'green' },
late: { text: '迟到', color: 'orange' },
absent: { text: '缺勤', color: 'red' },
leave: { text: '请假', color: 'blue' },
pending: { text: '待确认', color: 'default' },
};
const SESSION_LABELS: Record<string, string> = {
morning_reading: '早自习',
morning: '上午',
afternoon: '下午',
evening_study: '晚自习',
night_check: '晚寝',
};
const AttendanceTab: React.FC<{ data: AttendanceRecordItem[] }> = ({ data }) => {
const columns: ColumnsType<AttendanceRecordItem> = [
{ title: '日期', dataIndex: 'attendanceDate', width: 120 },
{
title: '课程',
render: (_: unknown, record) => record.schedule?.subject || record.class?.name || '课程考勤',
},
{
title: '时段',
dataIndex: 'session',
width: 100,
render: (value: string) => SESSION_LABELS[value] || value || '-',
},
{
title: '结果',
dataIndex: 'status',
width: 90,
render: (value: string) => {
const meta = ATTENDANCE_STATUS_MAP[value] || { text: value || '-', color: 'default' };
return <Tag color={meta.color}>{meta.text}</Tag>;
},
},
{
title: '打卡时间',
dataIndex: 'punchTime',
width: 170,
render: (value?: string | null) => (value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '-'),
},
{
title: '打卡设备',
render: (_: unknown, record) => {
const name = record.punchDeviceName?.trim();
const id = record.punchDeviceId?.trim();
if (name && id && name !== id) return `${name}${id}`;
return name || id || (record.source === 'manual' ? '老师手动标记' : '-');
},
},
{ title: '备注', dataIndex: 'remark', render: (value?: string | null) => value || '-' },
];
return data.length > 0 ? (
<Table<AttendanceRecordItem>
columns={columns}
dataSource={data}
rowKey="id"
scroll={{ x: 900 }}
pagination={{ defaultPageSize: 15, showSizeChanger: true, pageSizeOptions: [15, 30, 50] }}
/>
) : (
<Empty description="暂无出勤记录" />
);
};
interface TabProps {
studentId: number;
onRefresh: () => void;
}
const ProfileTab: React.FC<{
data: ProfileData | null;
studentId: number;
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);
}
};
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>
);
};
const EnrollmentsTab: React.FC<TabProps & { data: EnrollmentRecord[] }> = ({
data,
studentId,
onRefresh,
}) => {
const [modalOpen, setModalOpen] = useState(false);
const [form] = Form.useForm();
const [saving, setSaving] = useState(false);
const handleAdd = async () => {
try {
const values = await form.validateFields();
setSaving(true);
await api.post(`/archive/${studentId}/enrollments`, {
...values,
startDate: values.startDate?.format('YYYY-MM-DD'),
endDate: values.endDate?.format('YYYY-MM-DD'),
});
message.success('报读记录已添加');
setModalOpen(false);
form.resetFields();
onRefresh();
} catch (e: unknown) {
const err = e as { message?: string };
if (err?.message) message.error(err.message);
} finally {
setSaving(false);
}
};
const columns: ColumnsType<EnrollmentRecord> = [
{ title: '课程类别', dataIndex: 'courseCategory', render: getCourseCategoryLabel },
{ title: '班型', dataIndex: 'classType', render: getClassTypeLabel },
{ title: '班级名称', dataIndex: 'className', render: (v: string) => v || '-' },
{ title: '班主任', dataIndex: 'headTeacher', render: (v: string) => v || '-' },
{ title: '任课教师', dataIndex: 'subjectTeacher', render: (v: string) => v || '-' },
{ title: '开始日期', dataIndex: 'startDate', render: (v: string) => v || '-' },
{ title: '结束日期', dataIndex: 'endDate', render: (v: string) => v || '-' },
{
title: '状态',
dataIndex: 'status',
render: (v: string) => {
const status = getEnrollmentStatus(v);
return <Tag color={status.color}>{status.text}</Tag>;
},
},
];
return (
<div>
<Button
icon={<PlusOutlined />}
type="primary"
onClick={() => {
form.resetFields();
setModalOpen(true);
}}
style={{ marginBottom: 16 }}
>
</Button>
<Table<EnrollmentRecord>
columns={columns}
dataSource={data}
rowKey="id"
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50],
}}
/>
<Modal
title="添加报读记录"
open={modalOpen}
onOk={handleAdd}
onCancel={() => setModalOpen(false)}
confirmLoading={saving}
>
<Form form={form} layout="vertical">
<Form.Item
name="courseCategory"
label="课程类别"
rules={[{ required: true, message: '请选择课程类别' }]}
>
<Select options={COURSE_CATEGORY_OPTIONS} placeholder="请选择" />
</Form.Item>
<Form.Item
name="classType"
label="班型"
rules={[{ required: true, message: '请选择班型' }]}
>
<Select options={CLASS_TYPE_OPTIONS} placeholder="请选择" />
</Form.Item>
<Form.Item name="className" label="班级名称">
<Input placeholder="如2024届冲刺班" />
</Form.Item>
<Form.Item name="headTeacher" label="班主任">
<Input placeholder="班主任姓名" />
</Form.Item>
<Form.Item name="subjectTeacher" label="任课教师">
<Input placeholder="任课教师姓名" />
</Form.Item>
<Form.Item name="startDate" label="开始日期">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="endDate" label="结束日期">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
</div>
);
};
const ExamScoresTab: React.FC<
TabProps & { data: ExamScoreRecord[]; enrollments: EnrollmentRecord[] }
> = ({ data, studentId, enrollments, onRefresh }) => {
const [modalOpen, setModalOpen] = useState(false);
const [form] = Form.useForm();
const [saving, setSaving] = useState(false);
const handleAdd = async () => {
try {
const values = await form.validateFields();
setSaving(true);
await api.post(`/archive/${studentId}/exam-scores`, {
...values,
examDate: values.examDate?.format('YYYY-MM-DD'),
});
message.success('考试成绩已添加');
setModalOpen(false);
form.resetFields();
onRefresh();
} catch (e: unknown) {
const err = e as { message?: string };
if (err?.message) message.error(err.message);
} finally {
setSaving(false);
}
};
const columns: ColumnsType<ExamScoreRecord> = [
{
title: '考试类型',
dataIndex: 'examType',
render: (v: string) => EXAM_TYPE_OPTIONS.find((o) => o.value === v)?.label || v,
},
{ title: '考试名称', dataIndex: 'examName', render: (v: string) => v || '-' },
{ title: '科目', dataIndex: 'subject' },
{ title: '成绩', dataIndex: 'score' },
{
title: '班级均分',
dataIndex: 'classAvg',
render: (v: number | undefined) => (v !== undefined ? v : '-'),
},
{
title: '排名',
dataIndex: 'rank',
render: (v: number | undefined) => (v !== undefined ? v : '-'),
},
{ title: '考试日期', dataIndex: 'examDate', render: (v: string) => v || '-' },
{
title: '关联报读',
dataIndex: 'enrollmentId',
render: (v: number | undefined) => {
if (v === undefined) return '-';
const enr = enrollments.find((e) => e.id === v);
return enr ? formatEnrollmentDisplayName(enr) : String(v);
},
},
];
return (
<div>
<Button
icon={<PlusOutlined />}
type="primary"
onClick={() => {
form.resetFields();
setModalOpen(true);
}}
style={{ marginBottom: 16 }}
>
</Button>
<Table<ExamScoreRecord>
columns={columns}
dataSource={data}
rowKey="id"
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50],
}}
/>
<Modal
title="添加考试成绩"
open={modalOpen}
onOk={handleAdd}
onCancel={() => setModalOpen(false)}
confirmLoading={saving}
>
<Form form={form} layout="vertical">
<Form.Item
name="examType"
label="考试类型"
rules={[{ required: true, message: '请选择考试类型' }]}
>
<Select options={EXAM_TYPE_OPTIONS} placeholder="请选择" />
</Form.Item>
<Form.Item name="examName" label="考试名称">
<Input placeholder="如2024第一次月考" />
</Form.Item>
<Form.Item
name="subject"
label="科目"
rules={[{ required: true, message: '请输入科目' }]}
>
<Input placeholder="如:数学" />
</Form.Item>
<Form.Item name="score" label="成绩" rules={[{ required: true, message: '请输入成绩' }]}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="classAvg" label="班级均分">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="rank" label="排名">
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="examDate" label="考试日期">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="enrollmentId" label="关联报读">
<Select
allowClear
placeholder="选择关联的报读记录"
options={enrollments.map((e) => ({
value: e.id,
label: `${formatEnrollmentDisplayName(e)}${getClassTypeLabel(e.classType)}`,
}))}
/>
</Form.Item>
</Form>
</Modal>
</div>
);
};
const LearningTab: React.FC<TabProps & { data: LearningRecord[] }> = ({
data,
studentId,
onRefresh,
}) => {
const [modalOpen, setModalOpen] = useState(false);
const [form] = Form.useForm();
const [saving, setSaving] = useState(false);
const handleAdd = async () => {
try {
const values = await form.validateFields();
setSaving(true);
await api.post(`/archive/${studentId}/learning-records`, {
...values,
recordDate: values.recordDate?.format('YYYY-MM-DD'),
});
message.success('学情记录已添加');
setModalOpen(false);
form.resetFields();
onRefresh();
} catch (e: unknown) {
const err = e as { message?: string };
if (err?.message) message.error(err.message);
} finally {
setSaving(false);
}
};
const columns: ColumnsType<LearningRecord> = [
{ title: '记录日期', dataIndex: 'recordDate' },
{
title: '记录类型',
dataIndex: 'recordType',
render: (v: string) => RECORD_TYPE_OPTIONS.find((o) => o.value === v)?.label || v,
},
{ title: '内容', dataIndex: 'content', ellipsis: true },
{ title: '跟进方式', dataIndex: 'followUpMethod', render: (v: string) => v || '-' },
{ title: '下一步计划', dataIndex: 'nextStep', render: (v: string) => v || '-' },
];
return (
<div>
<Button
icon={<PlusOutlined />}
type="primary"
onClick={() => {
form.resetFields();
setModalOpen(true);
}}
style={{ marginBottom: 16 }}
>
</Button>
<Table<LearningRecord>
columns={columns}
dataSource={data}
rowKey="id"
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50],
}}
/>
<Modal
title="添加学情记录"
open={modalOpen}
onOk={handleAdd}
onCancel={() => setModalOpen(false)}
confirmLoading={saving}
>
<Form form={form} layout="vertical">
<Form.Item
name="recordDate"
label="记录日期"
rules={[{ required: true, message: '请选择日期' }]}
>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="recordType"
label="记录类型"
rules={[{ required: true, message: '请选择记录类型' }]}
>
<Select options={RECORD_TYPE_OPTIONS} placeholder="请选择" />
</Form.Item>
<Form.Item
name="content"
label="内容"
rules={[{ required: true, message: '请输入内容' }]}
>
<Input.TextArea rows={4} placeholder="请记录学情内容" />
</Form.Item>
<Form.Item name="followUpMethod" label="跟进方式">
<Input placeholder="如:电话、微信、面谈" />
</Form.Item>
<Form.Item name="nextStep" label="下一步计划">
<Input placeholder="后续跟进计划" />
</Form.Item>
</Form>
</Modal>
</div>
);
};
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,
onRefresh,
}) => {
const [uploading, setUploading] = useState(false);
const handleDelete = async (attachmentId: number) => {
try {
await api.delete(`/archive/attachments/${attachmentId}`);
message.success('已归档');
onRefresh();
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '归档失败');
}
};
const columns: ColumnsType<AttachmentRecord> = [
{
title: '类别',
dataIndex: 'category',
render: (v: string) => ATTACHMENT_CATEGORY_OPTIONS.find((o) => o.value === v)?.label || v,
},
{ title: '文件名', dataIndex: 'fileName' },
{ title: '大小', dataIndex: 'fileSize', render: formatFileSize },
{
title: '操作',
render: (_: unknown, record: AttachmentRecord) => (
<Space>
<Button
size="small"
icon={<EyeOutlined />}
onClick={async () => {
try {
const blob = await api.get<Blob>(`/archive/${studentId}/attachments/${record.id}`, {
responseType: 'blob',
});
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
setTimeout(() => URL.revokeObjectURL(url), 60_000);
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '查看失败');
}
}}
>
</Button>
<Popconfirm title="确定归档该附件?" onConfirm={() => handleDelete(record.id)}>
<Button size="small" danger icon={<InboxOutlined />}>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Upload
showUploadList={false}
customRequest={async (options) => {
const formData = new FormData();
formData.append(
'file',
options.file instanceof File
? options.file
: new File([options.file as Blob], 'attachment'),
);
setUploading(true);
try {
await api.post(`/archive/${studentId}/attachments`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
message.success('上传成功');
options.onSuccess?.({});
onRefresh();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '上传失败';
message.error(msg);
options.onError?.(e instanceof Error ? e : new Error(msg));
} finally {
setUploading(false);
}
}}
>
<Button icon={<UploadOutlined />} loading={uploading}>
</Button>
</Upload>
<Table<AttachmentRecord>
columns={columns}
dataSource={data}
rowKey="id"
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50],
}}
style={{ marginTop: 16 }}
/>
</div>
);
};
// ---- Main Component ----
const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
studentId,
inDrawer,
onClose,
}) => {
const [aggregateData, setAggregateData] = useState<StudentProfileAggregate | null>(null);
const [loading, setLoading] = useState(false);
// 批量导入状态
const [importModalOpen, setImportModalOpen] = useState(false);
const [importing, setImporting] = useState(false);
const [importResult, setImportResult] = useState<{
students: { created: number; updated: number };
profiles: number;
results: number;
enrollments: number;
examScores: number;
learningRecords: number;
errors: Array<{ sheet: string; row: number; phone: string; reason: string }>;
} | null>(null);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await api.get<StudentProfileAggregate>(`/archive/${studentId}`);
setAggregateData(res);
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '加载失败');
} finally {
setLoading(false);
}
}, [studentId]);
useEffect(() => {
void fetchData();
}, [fetchData]);
const handleImport = async (file: File) => {
setImporting(true);
try {
const formData = new FormData();
formData.append('file', file);
const result = await api.post<typeof importResult>('/archive/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
setImportResult(result);
setImportModalOpen(true);
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '导入失败');
} finally {
setImporting(false);
}
};
const handleDownloadTemplate = async () => {
try {
const blob = await api.get<Blob>('/archive/import/template', { responseType: 'blob' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = '学生档案批量导入模板.xlsx';
a.click();
URL.revokeObjectURL(url);
} catch {
message.error('下载模板失败');
}
};
const handlePreviewReport = useCallback(async () => {
try {
const { html } = await api.get<{ html: string }>(`/archive/${studentId}/report-html`);
const w = window.open('', '_blank');
if (w) {
w.document.write(html);
w.document.close();
}
} catch {
message.error('加载报告失败');
}
}, [studentId]);
const handleViewSensitive = useViewSensitive(studentId, '学生档案');
const tabItems = useMemo(() => {
if (!aggregateData) return [];
const { profile, enrollments, examScores, learningRecords, result, attachments, attendances } =
aggregateData;
return [
{
key: 'profile',
label: '扩展档案',
children: <ProfileTab data={profile} studentId={studentId} onRefresh={fetchData} />,
},
{
key: 'enrollments',
label: `报读班型 (${enrollments.length})`,
children: <EnrollmentsTab data={enrollments} studentId={studentId} onRefresh={fetchData} />,
},
{
key: 'exams',
label: `考试成绩 (${examScores.length})`,
children: (
<ExamScoresTab
data={examScores}
studentId={studentId}
enrollments={enrollments}
onRefresh={fetchData}
/>
),
},
{
key: 'attendance',
label: `出勤记录 (${attendances.length})`,
children: <AttendanceTab data={attendances} />,
},
{
key: 'learning',
label: `课堂回访 (${learningRecords.length})`,
children: (
<LearningTab data={learningRecords} studentId={studentId} onRefresh={fetchData} />
),
},
{
key: 'result',
label: '录取归档',
children: <ResultTab data={result} studentId={studentId} onRefresh={fetchData} />,
},
{
key: 'attachments',
label: `附件 (${attachments.length})`,
children: <AttachmentsTab data={attachments} studentId={studentId} onRefresh={fetchData} />,
},
{
key: 'reports',
label: '报告版本',
children: <Empty description="暂无报告版本" />,
},
];
}, [aggregateData, studentId, fetchData]);
if (!aggregateData) {
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" />
</div>
);
}
return null;
}
const { student, profile } = aggregateData;
return (
<div>
<Row justify="space-between" align="middle" style={{ marginBottom: 24 }}>
<Space>
{inDrawer && (
<Button type="text" icon={<CloseOutlined />} onClick={onClose} aria-label="关闭档案" />
)}
<span style={{ fontSize: 16, fontWeight: 500 }}>
{inDrawer ? `学员档案 - ${student.name} (${student.studentNo})` : '档案操作'}
</span>
</Space>
<Space>
<Button icon={<DownloadOutlined />} onClick={handleDownloadTemplate}>
</Button>
<Upload
showUploadList={false}
accept=".xlsx,.xls"
customRequest={({ file }) => handleImport(file as File)}
>
<Button icon={<ImportOutlined />} loading={importing}>
</Button>
</Upload>
{!inDrawer && (
<Button icon={<FileTextOutlined />} onClick={handlePreviewReport}>
</Button>
)}
<Button icon={<ReloadOutlined />} onClick={fetchData} loading={loading}>
</Button>
</Space>
</Row>
<Row gutter={16} style={{ marginBottom: 24 }}>
{[
{ title: '入学测试总分' },
{ title: '阶段最高分' },
{ title: '阶段提升分' },
{ title: '出勤率' },
].map((item) => (
<Col span={6} key={item.title}>
<Card size="small">
<Statistic title={item.title} value="-" />
</Card>
</Col>
))}
</Row>
<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>
<Tabs defaultActiveKey="profile" items={tabItems} />
{/* 批量导入结果弹窗 */}
<Modal
title="导入结果"
open={importModalOpen}
onCancel={() => {
setImportModalOpen(false);
setImportResult(null);
fetchData();
}}
footer={
<Button
type="primary"
onClick={() => {
setImportModalOpen(false);
setImportResult(null);
fetchData();
}}
>
</Button>
}
width={560}
>
{importResult && (
<div>
<Row gutter={[16, 12]} style={{ marginBottom: 16 }}>
<Col span={8}>
<Statistic
title="学生"
value={`新建 ${importResult.students.created} / 更新 ${importResult.students.updated}`}
valueStyle={{ fontSize: 16 }}
/>
</Col>
<Col span={8}>
<Statistic title="扩展档案" value={importResult.profiles} valueStyle={{ fontSize: 16 }} />
</Col>
<Col span={8}>
<Statistic title="录取归档" value={importResult.results} valueStyle={{ fontSize: 16 }} />
</Col>
<Col span={8}>
<Statistic title="报读班型" value={importResult.enrollments} valueStyle={{ fontSize: 16 }} />
</Col>
<Col span={8}>
<Statistic title="考试成绩" value={importResult.examScores} valueStyle={{ fontSize: 16 }} />
</Col>
<Col span={8}>
<Statistic title="课堂回访" value={importResult.learningRecords} valueStyle={{ fontSize: 16 }} />
</Col>
</Row>
{importResult.errors.length > 0 ? (
<div>
<div style={{ marginBottom: 8, fontWeight: 500 }}>
<CloseCircleOutlined style={{ color: '#ff4d4f', marginRight: 4 }} />
{importResult.errors.length}
</div>
<Table
dataSource={importResult.errors}
rowKey={(_, i) => String(i)}
size="small"
pagination={false}
columns={[
{ title: 'Sheet', dataIndex: 'sheet', width: 140 },
{ title: '行号', dataIndex: 'row', width: 60 },
{ title: '手机号', dataIndex: 'phone', width: 120 },
{ title: '原因', dataIndex: 'reason' },
]}
/>
</div>
) : (
<div style={{ color: '#52c41a' }}>
<CheckCircleOutlined style={{ marginRight: 4 }} />
</div>
)}
</div>
)}
</Modal>
</div>
);
};
export default StudentProfileContent;