Files
gongxue-base/apps/admin/src/components/StudentProfileContent/index.tsx
wangziqi a7a7af1667 fix: close permission review gaps
fix: harden permission-gated UI — minimum-org endpoint, modal/Popconfirm fail-closed on revocation
2026-07-23 14:28:58 +08:00

1577 lines
45 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,
} 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';
import EditableCell from '../EditableCell';
import { usePermission } from '../../hooks/usePermission';
import PermissionButton from '../PermissionButton';
// ---- Types ----
interface StudentInfo {
id: number;
name: string;
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;
}
interface ProfileData {
targetCollege?: string;
targetMajor?: string;
collegeSchool?: string;
collegeMajor?: 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;
examId?: number;
exam?: { class?: { name?: string } };
examType: string;
examName?: string;
subject: string;
score: number | null;
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 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 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 InlineArchiveSummary: React.FC<{
studentId: number;
student: StudentInfo;
profile: ProfileData | null;
result: ResultData | null;
organizations: Array<{ id: number; name: string }>;
onRefresh: () => void;
onViewSensitive: (fieldLabel: string, value: string) => void;
canViewSensitive: boolean;
canChooseOrganization: boolean;
}> = ({
studentId,
student,
profile,
result,
organizations,
onRefresh,
onViewSensitive,
canViewSensitive,
canChooseOrganization,
}) => {
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 (
<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>
{canViewSensitive ? (
<a onClick={() => onViewSensitive('电话', student.phone)}>
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
</a>
) : null}
</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>
{canViewSensitive ? (
<a onClick={() => onViewSensitive('身份证号', student.idNumber)}>
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
</a>
) : null}
</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>
{canViewSensitive ? (
<a onClick={() => onViewSensitive('紧急联系人电话', student.emergencyPhone || '')}>
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
</a>
) : null}
</span>
) : (
'-'
)}
</EditableCell>
</Descriptions.Item>
<Descriptions.Item label="所属机构">
{canChooseOrganization ? (
<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>
) : student.organization?.name ? (
<Tag color="purple">{student.organization.name}</Tag>
) : (
'-'
)}
</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?.collegeSchool}
permission="student:edit"
onSave={(next) => saveProfile('collegeSchool', next)}
>
{profile?.collegeSchool || '-'}
</EditableCell>
</Descriptions.Item>
<Descriptions.Item label="大专专业">
<EditableCell
value={profile?.collegeMajor}
permission="student:edit"
onSave={(next) => saveProfile('collegeMajor', next)}
>
{profile?.collegeMajor || '-'}
</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>
);
};
const EnrollmentsTab: React.FC<TabProps & { data: EnrollmentRecord[] }> = ({
data,
studentId,
onRefresh,
}) => {
const { hasPermission } = usePermission();
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 saveCell = async (record: EnrollmentRecord, field: string, value: unknown) => {
await api.put(`/archive/enrollments/${record.id}`, { [field]: value });
message.success('报读记录已保存');
onRefresh();
};
const columns: ColumnsType<EnrollmentRecord> = [
{
title: '课程类别',
dataIndex: 'courseCategory',
render: (v: string, r) => (
<EditableCell
value={v}
editor="select"
options={COURSE_CATEGORY_OPTIONS}
permission="student:edit"
required
onSave={(next) => saveCell(r, 'courseCategory', next)}
>
{getCourseCategoryLabel(v)}
</EditableCell>
),
},
{
title: '班型',
dataIndex: 'classType',
render: (v: string, r) => (
<EditableCell
value={v}
editor="select"
options={CLASS_TYPE_OPTIONS}
permission="student:edit"
required
onSave={(next) => saveCell(r, 'classType', next)}
>
{getClassTypeLabel(v)}
</EditableCell>
),
},
{
title: '班级名称',
dataIndex: 'className',
render: (v: string, r) => (
<EditableCell
value={v}
permission="student:edit"
onSave={(next) => saveCell(r, 'className', next)}
>
{v || '-'}
</EditableCell>
),
},
{
title: '班主任',
dataIndex: 'headTeacher',
render: (v: string, r) => (
<EditableCell
value={v}
permission="student:edit"
onSave={(next) => saveCell(r, 'headTeacher', next)}
>
{v || '-'}
</EditableCell>
),
},
{
title: '任课教师',
dataIndex: 'subjectTeacher',
render: (v: string, r) => (
<EditableCell
value={v}
permission="student:edit"
onSave={(next) => saveCell(r, 'subjectTeacher', next)}
>
{v || '-'}
</EditableCell>
),
},
{
title: '开始日期',
dataIndex: 'startDate',
render: (v: string, r) => (
<EditableCell
value={v}
editor="date"
permission="student:edit"
onSave={(next) => saveCell(r, 'startDate', next)}
>
{v || '-'}
</EditableCell>
),
},
{
title: '结束日期',
dataIndex: 'endDate',
render: (v: string, r) => (
<EditableCell
value={v}
editor="date"
permission="student:edit"
onSave={(next) => saveCell(r, 'endDate', next)}
>
{v || '-'}
</EditableCell>
),
},
{
title: '状态',
dataIndex: 'status',
render: (v: string, r) => {
const status = getEnrollmentStatus(v);
return (
<EditableCell
value={v}
editor="select"
options={Object.entries(ENROLLMENT_STATUS_MAP).map(([value, item]) => ({
value,
label: item.text,
}))}
permission="student:edit"
onSave={(next) => saveCell(r, 'status', next)}
>
<Tag color={status.color}>{status.text}</Tag>
</EditableCell>
);
},
},
];
return (
<div>
<PermissionButton
permission="student:edit"
icon={<PlusOutlined />}
type="primary"
onClick={() => {
form.resetFields();
setModalOpen(true);
}}
style={{ marginBottom: 16 }}
>
</PermissionButton>
<Table<EnrollmentRecord>
columns={columns}
dataSource={data}
rowKey="id"
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50],
}}
/>
<Modal
title="添加报读记录"
open={modalOpen && hasPermission('student:edit')}
onOk={hasPermission('student:edit') ? handleAdd : undefined}
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 { hasPermission } = usePermission();
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 saveCell = async (record: ExamScoreRecord, field: string, value: unknown) => {
await api.put(`/archive/exam-scores/${record.id}`, { [field]: value });
message.success('考试成绩已保存');
onRefresh();
};
const columns: ColumnsType<ExamScoreRecord> = [
{
title: '考试类型',
dataIndex: 'examType',
render: (v: string, r) => (
<EditableCell
value={v}
editor="select"
options={EXAM_TYPE_OPTIONS}
permission="student:edit"
disabled={!!r.examId}
required
onSave={(next) => saveCell(r, 'examType', next)}
>
{EXAM_TYPE_OPTIONS.find((o) => o.value === v)?.label || v}
</EditableCell>
),
},
{
title: '考试名称',
dataIndex: 'examName',
render: (v: string, r) => (
<EditableCell
value={v}
permission="student:edit"
disabled={!!r.examId}
onSave={(next) => saveCell(r, 'examName', next)}
>
{v || '-'}
</EditableCell>
),
},
{
title: '科目',
dataIndex: 'subject',
render: (v: string, r) => (
<EditableCell
value={v}
required
permission="student:edit"
disabled={!!r.examId}
onSave={(next) => saveCell(r, 'subject', next)}
>
{v}
</EditableCell>
),
},
{
title: '成绩',
dataIndex: 'score',
render: (v: number | null, r) => (
<EditableCell
value={v ?? undefined}
editor="number"
min={0}
permission="student:edit"
disabled={!!r.examId}
onSave={(next) => saveCell(r, 'score', next)}
>
{v ?? '-'}
</EditableCell>
),
},
{
title: '班级均分',
dataIndex: 'classAvg',
render: (v: number | undefined, r) => (
<EditableCell
value={v}
editor="number"
min={0}
permission="student:edit"
disabled={!!r.examId}
onSave={(next) => saveCell(r, 'classAvg', next)}
>
{v !== undefined ? v : '-'}
</EditableCell>
),
},
{
title: '排名',
dataIndex: 'rank',
render: (v: number | undefined, r) => (
<EditableCell
value={v}
editor="number"
min={1}
permission="student:edit"
disabled={!!r.examId}
onSave={(next) => saveCell(r, 'rank', next)}
>
{v !== undefined ? v : '-'}
</EditableCell>
),
},
{
title: '考试日期',
dataIndex: 'examDate',
render: (v: string, r) => (
<EditableCell
value={v}
editor="date"
permission="student:edit"
disabled={!!r.examId}
onSave={(next) => saveCell(r, 'examDate', next)}
>
{v || '-'}
</EditableCell>
),
},
{
title: '关联报读',
dataIndex: 'enrollmentId',
render: (v: number | undefined, r) => (
<EditableCell
value={v}
editor="select"
options={enrollments.map((item) => ({
value: item.id,
label: formatEnrollmentDisplayName(item),
}))}
permission="student:edit"
disabled={!!r.examId}
onSave={(next) => saveCell(r, 'enrollmentId', next)}
>
{(() => {
if (r.examId) return r.exam?.class?.name || '-';
if (v === undefined) return '-';
const enr = enrollments.find((e) => e.id === v);
return enr ? formatEnrollmentDisplayName(enr) : String(v);
})()}
</EditableCell>
),
},
];
return (
<div>
<PermissionButton
permission="student:edit"
icon={<PlusOutlined />}
type="primary"
onClick={() => {
form.resetFields();
setModalOpen(true);
}}
style={{ marginBottom: 16 }}
>
</PermissionButton>
<Table<ExamScoreRecord>
columns={columns}
dataSource={data}
rowKey="id"
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50],
}}
/>
<Modal
title="添加考试成绩"
open={modalOpen && hasPermission('student:edit')}
onOk={hasPermission('student:edit') ? handleAdd : undefined}
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 { hasPermission } = usePermission();
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 saveCell = async (record: LearningRecord, field: string, value: unknown) => {
await api.put(`/archive/learning-records/${record.id}`, { [field]: value });
message.success('学情记录已保存');
onRefresh();
};
const columns: ColumnsType<LearningRecord> = [
{
title: '记录日期',
dataIndex: 'recordDate',
render: (v: string, r) => (
<EditableCell
value={v}
editor="date"
permission="student:edit"
required
onSave={(next) => saveCell(r, 'recordDate', next)}
>
{v}
</EditableCell>
),
},
{
title: '记录类型',
dataIndex: 'recordType',
render: (v: string, r) => (
<EditableCell
value={v}
editor="select"
options={RECORD_TYPE_OPTIONS}
permission="student:edit"
required
onSave={(next) => saveCell(r, 'recordType', next)}
>
{RECORD_TYPE_OPTIONS.find((o) => o.value === v)?.label || v}
</EditableCell>
),
},
{
title: '内容',
dataIndex: 'content',
ellipsis: true,
render: (v: string, r) => (
<EditableCell
value={v}
editor="textarea"
permission="student:edit"
required
onSave={(next) => saveCell(r, 'content', next)}
>
{v}
</EditableCell>
),
},
{
title: '跟进方式',
dataIndex: 'followUpMethod',
render: (v: string, r) => (
<EditableCell
value={v}
permission="student:edit"
onSave={(next) => saveCell(r, 'followUpMethod', next)}
>
{v || '-'}
</EditableCell>
),
},
{
title: '下一步计划',
dataIndex: 'nextStep',
render: (v: string, r) => (
<EditableCell
value={v}
editor="textarea"
permission="student:edit"
onSave={(next) => saveCell(r, 'nextStep', next)}
>
{v || '-'}
</EditableCell>
),
},
];
return (
<div>
<PermissionButton
permission="student:edit"
icon={<PlusOutlined />}
type="primary"
onClick={() => {
form.resetFields();
setModalOpen(true);
}}
style={{ marginBottom: 16 }}
>
</PermissionButton>
<Table<LearningRecord>
columns={columns}
dataSource={data}
rowKey="id"
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50],
}}
/>
<Modal
title="添加学情记录"
open={modalOpen && hasPermission('student:edit')}
onOk={hasPermission('student:edit') ? handleAdd : undefined}
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 AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> = ({
data,
studentId,
onRefresh,
}) => {
const { hasPermission } = usePermission();
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>
{hasPermission('student:edit') ? (
<Popconfirm title="确定归档该附件?" onConfirm={() => handleDelete(record.id)}>
<Button size="small" danger icon={<InboxOutlined />}>
</Button>
</Popconfirm>
) : null}
</Space>
),
},
];
return (
<div>
{hasPermission('student:edit') ? (
<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>
) : null}
<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 { hasPermission, hasAnyPermission } = usePermission();
const canLoadOrganizations = hasAnyPermission(
'organization:view',
'student:create',
'student:edit',
);
const canChooseOrganization = hasAnyPermission('student:create', 'student:edit');
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 () => {
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]);
useEffect(() => {
if (!canLoadOrganizations) {
setOrganizations([]);
return;
}
api
.get('/organizations/options')
.then((res: unknown) => {
setOrganizations(res as Array<{ id: number; name: string; isHost?: boolean }>);
})
.catch(() => {});
}, [canLoadOrganizations]);
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,
'学生档案',
hasPermission('log:create'),
);
const tabItems = useMemo(() => {
if (!aggregateData) return [];
const { enrollments, examScores, learningRecords, attachments, attendances } = aggregateData;
return [
{
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: '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, result } = aggregateData;
return (
<div>
{inDrawer && (
<Row justify="space-between" align="middle" style={{ marginBottom: 24 }}>
<Space>
<Button type="text" icon={<CloseOutlined />} onClick={onClose} aria-label="关闭档案" />
<span style={{ fontSize: 16, fontWeight: 500 }}>
- {student.name}
{student.studentNo ? ` (${student.studentNo})` : ''}
</span>
</Space>
<Space>
<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>
<InlineArchiveSummary
studentId={studentId}
student={student}
profile={profile}
result={result}
organizations={organizations}
onRefresh={fetchData}
onViewSensitive={handleViewSensitive}
canViewSensitive={hasPermission('log:create')}
canChooseOrganization={canChooseOrganization}
/>
<Tabs defaultActiveKey="enrollments" items={tabItems} />
</div>
);
};
export default StudentProfileContent;