feat: complete student archive subsystem — 7 entities, PDF report, frontend profile page
This commit is contained in:
@@ -13,9 +13,10 @@ import BillsPage from './pages/Bills';
|
||||
import RoomVisualPage from './pages/RoomVisual';
|
||||
import OperationLogsPage from './pages/OperationLogs';
|
||||
import UsersPage from './pages/Users';
|
||||
import TeachersPage from './pages/Teachers';
|
||||
import ClassroomsPage from './pages/Classrooms';
|
||||
import DepositsPage from './pages/Deposits';
|
||||
import ClassroomsPage from './pages/Classrooms';
|
||||
import StudentProfilePage from './pages/StudentProfile';
|
||||
import ClassesPage from './pages/Classes';
|
||||
import ClassDetailPage from './pages/Classes/detail';
|
||||
import TenantsPage from './pages/Tenants';
|
||||
@@ -86,6 +87,15 @@ const App: React.FC = () => {
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="students/:id/profile"
|
||||
element={
|
||||
<PermissionRoute permission="student:view">
|
||||
<StudentProfilePage />
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="rooms"
|
||||
element={
|
||||
|
||||
844
apps/admin/src/pages/StudentProfile/index.tsx
Normal file
844
apps/admin/src/pages/StudentProfile/index.tsx
Normal file
@@ -0,0 +1,844 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Tabs,
|
||||
Card,
|
||||
Descriptions,
|
||||
Table,
|
||||
Button,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
DatePicker,
|
||||
InputNumber,
|
||||
Upload,
|
||||
Tag,
|
||||
Space,
|
||||
message,
|
||||
Popconfirm,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
PlusOutlined,
|
||||
UploadOutlined,
|
||||
DownloadOutlined,
|
||||
ArrowLeftOutlined,
|
||||
DeleteOutlined,
|
||||
EyeOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
|
||||
// ---- 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;
|
||||
campusLocation?: 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 StudentProfileAggregate {
|
||||
student: StudentInfo;
|
||||
profile: ProfileData | null;
|
||||
enrollments: EnrollmentRecord[];
|
||||
examScores: ExamScoreRecord[];
|
||||
learningRecords: LearningRecord[];
|
||||
result: ResultData | null;
|
||||
attachments: AttachmentRecord[];
|
||||
}
|
||||
|
||||
// ---- 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 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 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 ----
|
||||
|
||||
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,
|
||||
campusLocation: data?.campusLocation ?? 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="campusLocation" 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: (v: string) => v || '-' },
|
||||
{ title: '班型', dataIndex: 'classType', render: (v: string) => v || '-' },
|
||||
{ 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 colorMap: Record<string, string> = {
|
||||
active: 'green',
|
||||
completed: 'blue',
|
||||
withdrawn: 'red',
|
||||
};
|
||||
return <Tag color={colorMap[v] || 'default'}>{v || '-'}</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={{ pageSize: 15 }}
|
||||
/>
|
||||
<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 ? `${enr.className || enr.courseCategory || v}` : 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={{ pageSize: 15 }}
|
||||
/>
|
||||
<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: `${e.className || e.courseCategory || e.id} (${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={{ pageSize: 15 }}
|
||||
/>
|
||||
<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/${studentId}/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={() => {
|
||||
const token = localStorage.getItem('token');
|
||||
window.open(`/api/archive/${studentId}/attachments/${record.id}?token=${token}`, '_blank');
|
||||
}}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
<Popconfirm title="确定删除该附件?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button size="small" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Upload
|
||||
showUploadList={false}
|
||||
customRequest={async (options: {
|
||||
file: File | Blob;
|
||||
onSuccess?: (body: unknown) => void;
|
||||
onError?: (error: Error) => void;
|
||||
}) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', options.file instanceof File ? options.file : new File([options.file], '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 err = e as { message?: string };
|
||||
message.error(err?.message || '上传失败');
|
||||
options.onError?.(err instanceof Error ? err : new Error('Upload failed'));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button icon={<UploadOutlined />} loading={uploading}>
|
||||
上传附件
|
||||
</Button>
|
||||
</Upload>
|
||||
<Table<AttachmentRecord>
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
pagination={{ pageSize: 15 }}
|
||||
style={{ marginTop: 16 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---- Main Page ----
|
||||
|
||||
const StudentProfilePage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [aggregateData, setAggregateData] = useState<StudentProfileAggregate | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get<StudentProfileAggregate>(`/archive/${id}`);
|
||||
setAggregateData(res);
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
if (!aggregateData) return null;
|
||||
|
||||
const studentId = Number(id);
|
||||
const { student, profile, enrollments, examScores, learningRecords, result, attachments } = aggregateData;
|
||||
|
||||
const handleDownloadReport = () => {
|
||||
const token = localStorage.getItem('token');
|
||||
window.open(`/api/archive/${id}/report?token=${token}`, '_blank');
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/students')} />
|
||||
<span>
|
||||
{student.name} — 学生档案
|
||||
</span>
|
||||
</Space>
|
||||
}
|
||||
loading={loading}
|
||||
extra={
|
||||
<PermissionButton
|
||||
permission="student:view"
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={handleDownloadReport}
|
||||
>
|
||||
生成档案报表
|
||||
</PermissionButton>
|
||||
}
|
||||
>
|
||||
<Descriptions bordered column={3} size="small" style={{ marginBottom: 24 }}>
|
||||
<Descriptions.Item label="学号">{student.studentNo || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="电话">{student.phone || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="身份证号">{student.idNumber || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag>{student.status || '-'}</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>
|
||||
)}
|
||||
{profile?.campusLocation && (
|
||||
<Descriptions.Item label="校区">{profile.campusLocation}</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
|
||||
<Tabs
|
||||
defaultActiveKey="profile"
|
||||
items={[
|
||||
{
|
||||
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: '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} />
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default StudentProfilePage;
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
@@ -68,6 +69,7 @@ interface EnrollmentInfo {
|
||||
|
||||
const StudentsPage: React.FC = () => {
|
||||
const { modal } = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
@@ -296,6 +298,14 @@ const StudentsPage: React.FC = () => {
|
||||
</Popconfirm>
|
||||
) : (
|
||||
<>
|
||||
<PermissionButton
|
||||
permission="student:view"
|
||||
size="small"
|
||||
type="link"
|
||||
onClick={() => navigate(`/students/${record.id}/profile`)}
|
||||
>
|
||||
档案
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
permission="student:edit"
|
||||
size="small"
|
||||
|
||||
263
apps/server/src/archive/archive-report.service.ts
Normal file
263
apps/server/src/archive/archive-report.service.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import * as PDFDocument from 'pdfkit';
|
||||
import { Response } from 'express';
|
||||
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 { AttendanceRecord } from '../entities/attendance-record.entity';
|
||||
import { Student } from '../entities/student.entity';
|
||||
|
||||
@Injectable()
|
||||
export class ArchiveReportService {
|
||||
constructor(
|
||||
@InjectRepository(StudentProfile) private profileRepo: Repository<StudentProfile>,
|
||||
@InjectRepository(StudentEnrollment) private enrollmentRepo: Repository<StudentEnrollment>,
|
||||
@InjectRepository(ExamScore) private examRepo: Repository<ExamScore>,
|
||||
@InjectRepository(LearningRecord) private learningRepo: Repository<LearningRecord>,
|
||||
@InjectRepository(ResultArchive) private resultRepo: Repository<ResultArchive>,
|
||||
@InjectRepository(AttendanceRecord) private attendanceRepo: Repository<AttendanceRecord>,
|
||||
@InjectRepository(Student) private studentRepo: Repository<Student>,
|
||||
) {}
|
||||
|
||||
async generateReport(studentId: number, res: Response) {
|
||||
const [student, profile, enrollments, exams, learnings, result, attendances] = await Promise.all([
|
||||
this.studentRepo.findOne({ where: { id: studentId } }),
|
||||
this.profileRepo.findOne({ where: { studentId } }),
|
||||
this.enrollmentRepo.find({ where: { studentId }, order: { startDate: 'ASC' } }),
|
||||
this.examRepo.find({ where: { studentId }, order: { examDate: 'ASC' } }),
|
||||
this.learningRepo.find({ where: { studentId }, order: { recordDate: 'DESC' } }),
|
||||
this.resultRepo.findOne({ where: { studentId } }),
|
||||
this.attendanceRepo.find({ where: { studentId }, order: { attendanceDate: 'ASC' } }),
|
||||
]);
|
||||
|
||||
if (!student) throw new Error('学生不存在');
|
||||
|
||||
const doc = new PDFDocument({ size: 'A4', margin: 40 });
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename=student_report_${studentId}.pdf`);
|
||||
doc.pipe(res);
|
||||
|
||||
this.renderCover(doc, student, profile, enrollments);
|
||||
|
||||
doc.addPage();
|
||||
this.renderBasicInfo(doc, student, profile);
|
||||
this.renderEnrollmentComparison(doc, enrollments);
|
||||
|
||||
doc.addPage();
|
||||
this.renderExamScores(doc, exams);
|
||||
|
||||
doc.addPage();
|
||||
this.renderAttendance(doc, attendances);
|
||||
|
||||
doc.addPage();
|
||||
this.renderLearningRecords(doc, learnings);
|
||||
if (result) this.renderResult(doc, result);
|
||||
|
||||
doc.end();
|
||||
}
|
||||
|
||||
private renderCover(
|
||||
doc,
|
||||
student: Student,
|
||||
profile: StudentProfile | null,
|
||||
enrollments: StudentEnrollment[],
|
||||
) {
|
||||
doc.fontSize(24).text('学生档案报告', { align: 'center' });
|
||||
doc.moveDown(2);
|
||||
doc.fontSize(16).text(student.name, { align: 'center' });
|
||||
doc.moveDown(0.5);
|
||||
doc.fontSize(12).text(`学号: ${student.studentNo || '-'}`, { align: 'center' });
|
||||
doc.moveDown(0.3);
|
||||
doc.fontSize(10).text(`身份证号: ${student.idNumber || '-'}`, { align: 'center' });
|
||||
doc.moveDown(1);
|
||||
|
||||
if (profile) {
|
||||
doc.fontSize(12).text(`科类方向: ${profile.subjectDirection || '-'}`);
|
||||
doc.text(`目标院校: ${profile.targetCollege || '-'}`);
|
||||
doc.text(`目标专业: ${profile.targetMajor || '-'}`);
|
||||
doc.text(`建档日期: ${profile.profileDate || '-'}`);
|
||||
}
|
||||
doc.moveDown(1);
|
||||
|
||||
const types = enrollments.map((e) => e.classType).filter(Boolean);
|
||||
if (types.length > 0) {
|
||||
doc.fontSize(12).text(`报读班型: ${types.join(' / ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
private renderBasicInfo(
|
||||
doc,
|
||||
student: Student,
|
||||
profile: StudentProfile | null,
|
||||
) {
|
||||
doc.fontSize(16).text('基础信息', { underline: true });
|
||||
doc.moveDown(0.5);
|
||||
const rows = [
|
||||
['姓名', student.name, '性别', student.gender || '-'],
|
||||
['电话', student.phone || '-', '民族', student.ethnicity || '-'],
|
||||
['紧急联系人', student.emergencyContact || '-', '紧急电话', student.emergencyPhone || '-'],
|
||||
['校区', profile?.campusLocation || '-', '年级', profile?.grade || '-'],
|
||||
];
|
||||
this.renderTable(doc, rows, [100, 150, 100, 150]);
|
||||
}
|
||||
|
||||
private renderEnrollmentComparison(
|
||||
doc,
|
||||
enrollments: StudentEnrollment[],
|
||||
) {
|
||||
doc.moveDown(1);
|
||||
doc.fontSize(16).text('报读记录', { underline: true });
|
||||
doc.moveDown(0.5);
|
||||
|
||||
if (enrollments.length === 0) {
|
||||
doc.fontSize(10).text('暂无报读记录');
|
||||
return;
|
||||
}
|
||||
|
||||
if (enrollments.length >= 2) {
|
||||
doc.fontSize(12).text('多班型对比', { underline: true });
|
||||
doc.moveDown(0.3);
|
||||
const headers = ['项目', ...enrollments.map((_, i) => `班型${i + 1}`)];
|
||||
const rows = [
|
||||
['课程类别', ...enrollments.map((e) => e.courseCategory || '-')],
|
||||
['班型', ...enrollments.map((e) => e.classType || '-')],
|
||||
['班级', ...enrollments.map((e) => e.className || '-')],
|
||||
['班主任', ...enrollments.map((e) => e.headTeacher || '-')],
|
||||
['任课老师', ...enrollments.map((e) => e.subjectTeacher || '-')],
|
||||
['开班', ...enrollments.map((e) => e.startDate || '-')],
|
||||
['结课', ...enrollments.map((e) => e.endDate || '-')],
|
||||
];
|
||||
const colWidths = [
|
||||
80,
|
||||
...enrollments.map(() => (doc.page.width - 120) / enrollments.length),
|
||||
];
|
||||
this.renderTable(doc, rows, colWidths, headers);
|
||||
} else {
|
||||
const e = enrollments[0];
|
||||
const rows = [
|
||||
['课程类别', e.courseCategory || '-'],
|
||||
['班型', e.classType || '-'],
|
||||
['班级', e.className || '-'],
|
||||
['班主任', e.headTeacher || '-'],
|
||||
['任课老师', e.subjectTeacher || '-'],
|
||||
['开班日期', e.startDate || '-'],
|
||||
['结课日期', e.endDate || '-'],
|
||||
];
|
||||
this.renderTable(doc, rows, [120, 200]);
|
||||
}
|
||||
}
|
||||
|
||||
private renderExamScores(doc, exams: ExamScore[]) {
|
||||
doc.fontSize(16).text('考试成绩', { underline: true });
|
||||
doc.moveDown(0.5);
|
||||
if (exams.length === 0) {
|
||||
doc.fontSize(10).text('暂无考试成绩');
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = ['类型', '名称', '科目', '分数', '班均', '排名', '日期'];
|
||||
const rows = exams.map((e) => [
|
||||
e.examType,
|
||||
e.examName || '-',
|
||||
e.subject,
|
||||
String(e.score ?? '-'),
|
||||
e.classAvg != null ? String(e.classAvg) : '-',
|
||||
e.rank != null ? String(e.rank) : '-',
|
||||
e.examDate || '-',
|
||||
]);
|
||||
this.renderTable(doc, rows, [60, 80, 80, 50, 50, 50, 80], headers);
|
||||
}
|
||||
|
||||
private renderAttendance(doc, records: AttendanceRecord[]) {
|
||||
doc.fontSize(16).text('出勤记录', { underline: true });
|
||||
doc.moveDown(0.5);
|
||||
if (records.length === 0) {
|
||||
doc.fontSize(10).text('暂无出勤记录');
|
||||
return;
|
||||
}
|
||||
|
||||
const present = records.filter((r) => r.status === 'present').length;
|
||||
const absent = records.filter((r) => r.status === 'absent').length;
|
||||
const late = records.filter((r) => r.status === 'late').length;
|
||||
const leave = records.filter((r) => r.status === 'leave').length;
|
||||
const total = records.length;
|
||||
|
||||
doc.fontSize(10).text(
|
||||
`总计: ${total} 次 | 出勤: ${present} | 缺勤: ${absent} | 迟到: ${late} | 请假: ${leave}`,
|
||||
);
|
||||
doc.text(`出勤率: ${total > 0 ? ((present / total) * 100).toFixed(1) : 0}%`);
|
||||
}
|
||||
|
||||
private renderLearningRecords(doc, records: LearningRecord[]) {
|
||||
doc.fontSize(16).text('学情记录', { underline: true });
|
||||
doc.moveDown(0.5);
|
||||
if (records.length === 0) {
|
||||
doc.fontSize(10).text('暂无学情记录');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const r of records.slice(0, 20)) {
|
||||
doc.fontSize(10).text(`${r.recordDate || '-'} [${r.recordType}]`);
|
||||
doc.fontSize(9).text(` ${(r.content || '').slice(0, 200)}`);
|
||||
if (r.followUpMethod) doc.text(` 跟进: ${r.followUpMethod}`);
|
||||
doc.moveDown(0.2);
|
||||
}
|
||||
}
|
||||
|
||||
private renderResult(doc, result: ResultArchive) {
|
||||
doc.moveDown(1);
|
||||
doc.fontSize(16).text('录取归档', { underline: true });
|
||||
doc.moveDown(0.5);
|
||||
const rows = [
|
||||
['文化课成绩', result.cultureFinalScore != null ? String(result.cultureFinalScore) : '-'],
|
||||
[
|
||||
'专业课成绩',
|
||||
result.professionalFinalScore != null ? String(result.professionalFinalScore) : '-',
|
||||
],
|
||||
['录取状态', result.admissionStatus || '-'],
|
||||
['录取院校', result.admittedCollege || '-'],
|
||||
['录取专业', result.admittedMajor || '-'],
|
||||
];
|
||||
this.renderTable(doc, rows, [120, 200]);
|
||||
}
|
||||
|
||||
private renderTable(
|
||||
doc,
|
||||
rows: string[][],
|
||||
colWidths: number[],
|
||||
headers?: string[],
|
||||
) {
|
||||
const startX = doc.x;
|
||||
const lineHeight = 18;
|
||||
|
||||
if (headers) {
|
||||
doc.font('Helvetica-Bold').fontSize(9);
|
||||
let x = startX;
|
||||
for (let i = 0; i < headers.length; i++) {
|
||||
doc.text(headers[i], x, doc.y, { width: colWidths[i], lineBreak: false });
|
||||
x += colWidths[i];
|
||||
}
|
||||
doc.moveDown(0.3);
|
||||
}
|
||||
|
||||
doc.font('Helvetica').fontSize(8);
|
||||
for (const row of rows) {
|
||||
let x = startX;
|
||||
const maxH = Math.max(
|
||||
...row.map((cell, i) => doc.heightOfString(cell || '', { width: colWidths[i] })),
|
||||
);
|
||||
for (let i = 0; i < row.length && i < colWidths.length; i++) {
|
||||
doc.text(row[i] || '-', x, doc.y, { width: colWidths[i], lineBreak: false });
|
||||
x += colWidths[i];
|
||||
}
|
||||
doc.moveDown(maxH / 14);
|
||||
if (doc.y > doc.page.height - 60) {
|
||||
doc.addPage();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,13 @@ import {
|
||||
Param,
|
||||
UseGuards,
|
||||
Request,
|
||||
Res,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import type { Request as ExpressRequest, Response } from 'express';
|
||||
import { ArchiveReportService } from './archive-report.service';
|
||||
import { ArchiveService } from './archive.service';
|
||||
import {
|
||||
UpsertProfileDto,
|
||||
@@ -36,6 +38,7 @@ export class ArchiveController {
|
||||
constructor(
|
||||
private readonly archiveService: ArchiveService,
|
||||
private readonly logService: OperationLogsService,
|
||||
private readonly reportService: ArchiveReportService,
|
||||
) {}
|
||||
|
||||
@Get(':studentId')
|
||||
@@ -339,11 +342,10 @@ export class ArchiveController {
|
||||
|
||||
@Get(':studentId/report')
|
||||
@RequirePermission('student:view')
|
||||
async generateReport(@Param('studentId') studentId: string) {
|
||||
return {
|
||||
studentId: +studentId,
|
||||
message: '学生档案报告功能开发中',
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
async generateReport(
|
||||
@Param('studentId') studentId: string,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
return this.reportService.generateReport(+studentId, res);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,11 @@ import { ExamScore } from '../entities/exam-score.entity';
|
||||
import { LearningRecord } from '../entities/learning-record.entity';
|
||||
import { ResultArchive } from '../entities/result-archive.entity';
|
||||
import { ArchiveAttachment } from '../entities/archive-attachment.entity';
|
||||
import { AttendanceRecord } from '../entities/attendance-record.entity';
|
||||
import { CommonModule } from '../common/common.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { ArchiveService } from './archive.service';
|
||||
import { ArchiveReportService } from './archive-report.service';
|
||||
import { ArchiveController } from './archive.controller';
|
||||
|
||||
@Module({
|
||||
@@ -22,12 +24,13 @@ import { ArchiveController } from './archive.controller';
|
||||
LearningRecord,
|
||||
ResultArchive,
|
||||
ArchiveAttachment,
|
||||
AttendanceRecord,
|
||||
]),
|
||||
CommonModule,
|
||||
NotificationsModule,
|
||||
],
|
||||
controllers: [ArchiveController],
|
||||
providers: [ArchiveService],
|
||||
providers: [ArchiveService, ArchiveReportService],
|
||||
exports: [ArchiveService],
|
||||
})
|
||||
export class ArchiveModule {}
|
||||
|
||||
@@ -207,3 +207,4 @@ export class ArchiveService {
|
||||
return { message: '已删除' };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
52
apps/server/src/entities/archive-attachment.entity.ts
Normal file
52
apps/server/src/entities/archive-attachment.entity.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Student } from './student.entity';
|
||||
import { Department } from './department.entity';
|
||||
|
||||
@Entity('archive_attachments')
|
||||
export class ArchiveAttachment {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ name: 'student_id', type: 'integer' })
|
||||
studentId: number;
|
||||
|
||||
@ManyToOne(() => Student, { eager: true })
|
||||
@JoinColumn({ name: 'student_id' })
|
||||
student: Student;
|
||||
|
||||
@Column({ length: 50, nullable: true })
|
||||
category: string;
|
||||
|
||||
@Column({ name: 'file_name', length: 255, nullable: true })
|
||||
fileName: string;
|
||||
|
||||
@Column({ name: 'file_path', length: 500, nullable: true })
|
||||
filePath: string;
|
||||
|
||||
@Column({ name: 'file_size', type: 'integer', nullable: true })
|
||||
fileSize: number;
|
||||
|
||||
@Column({ name: 'mime_type', length: 100, nullable: true })
|
||||
mimeType: string;
|
||||
|
||||
@Column({ name: 'department_id', type: 'integer', nullable: true })
|
||||
departmentId: number;
|
||||
|
||||
@ManyToOne(() => Department, { nullable: true })
|
||||
@JoinColumn({ name: 'department_id' })
|
||||
department: Department;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
66
apps/server/src/entities/exam-score.entity.ts
Normal file
66
apps/server/src/entities/exam-score.entity.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Student } from './student.entity';
|
||||
import { StudentEnrollment } from './student-enrollment.entity';
|
||||
import { Department } from './department.entity';
|
||||
|
||||
@Entity('exam_scores')
|
||||
export class ExamScore {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ name: 'student_id', type: 'integer' })
|
||||
studentId: number;
|
||||
|
||||
@ManyToOne(() => Student, { eager: true })
|
||||
@JoinColumn({ name: 'student_id' })
|
||||
student: Student;
|
||||
|
||||
@Column({ name: 'enrollment_id', type: 'integer', nullable: true })
|
||||
enrollmentId: number;
|
||||
|
||||
@ManyToOne(() => StudentEnrollment, { nullable: true })
|
||||
@JoinColumn({ name: 'enrollment_id' })
|
||||
enrollment: StudentEnrollment;
|
||||
|
||||
@Column({ name: 'exam_type', length: 50, nullable: true })
|
||||
examType: string;
|
||||
|
||||
@Column({ name: 'exam_name', length: 100, nullable: true })
|
||||
examName: string;
|
||||
|
||||
@Column({ length: 50, nullable: true })
|
||||
subject: string;
|
||||
|
||||
@Column({ type: 'decimal', precision: 5, scale: 2, nullable: true })
|
||||
score: number;
|
||||
|
||||
@Column({ name: 'class_avg', type: 'decimal', precision: 5, scale: 2, nullable: true })
|
||||
classAvg: number;
|
||||
|
||||
@Column({ type: 'integer', nullable: true })
|
||||
rank: number;
|
||||
|
||||
@Column({ name: 'exam_date', type: 'date', nullable: true })
|
||||
examDate: string;
|
||||
|
||||
@Column({ name: 'department_id', type: 'integer', nullable: true })
|
||||
departmentId: number;
|
||||
|
||||
@ManyToOne(() => Department, { nullable: true })
|
||||
@JoinColumn({ name: 'department_id' })
|
||||
department: Department;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -26,3 +26,10 @@ export { ExpenseType } from './expense-type.entity';
|
||||
export { Notification, NotificationType } from './notification.entity';
|
||||
export { Department, DepartmentType } from './department.entity';
|
||||
export { UserDepartment } from './user-department.entity';
|
||||
export { StudentProfile } from './student-profile.entity';
|
||||
export { StudentEnrollment } from './student-enrollment.entity';
|
||||
export { ExamScore } from './exam-score.entity';
|
||||
export { LearningRecord } from './learning-record.entity';
|
||||
export { ResultArchive } from './result-archive.entity';
|
||||
export { ArchiveAttachment } from './archive-attachment.entity';
|
||||
export { StudentReport } from './student-report.entity';
|
||||
|
||||
52
apps/server/src/entities/learning-record.entity.ts
Normal file
52
apps/server/src/entities/learning-record.entity.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Student } from './student.entity';
|
||||
import { Department } from './department.entity';
|
||||
|
||||
@Entity('learning_records')
|
||||
export class LearningRecord {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ name: 'student_id', type: 'integer' })
|
||||
studentId: number;
|
||||
|
||||
@ManyToOne(() => Student, { eager: true })
|
||||
@JoinColumn({ name: 'student_id' })
|
||||
student: Student;
|
||||
|
||||
@Column({ name: 'record_date', type: 'date', nullable: true })
|
||||
recordDate: string;
|
||||
|
||||
@Column({ name: 'record_type', length: 50, nullable: true })
|
||||
recordType: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
content: string;
|
||||
|
||||
@Column({ name: 'follow_up_method', length: 50, nullable: true })
|
||||
followUpMethod: string;
|
||||
|
||||
@Column({ name: 'next_step', type: 'text', nullable: true })
|
||||
nextStep: string;
|
||||
|
||||
@Column({ name: 'department_id', type: 'integer', nullable: true })
|
||||
departmentId: number;
|
||||
|
||||
@ManyToOne(() => Department, { nullable: true })
|
||||
@JoinColumn({ name: 'department_id' })
|
||||
department: Department;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
52
apps/server/src/entities/result-archive.entity.ts
Normal file
52
apps/server/src/entities/result-archive.entity.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Student } from './student.entity';
|
||||
import { Department } from './department.entity';
|
||||
|
||||
@Entity('result_archives')
|
||||
export class ResultArchive {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ name: 'student_id', type: 'integer', unique: true })
|
||||
studentId: number;
|
||||
|
||||
@ManyToOne(() => Student, { eager: true })
|
||||
@JoinColumn({ name: 'student_id' })
|
||||
student: Student;
|
||||
|
||||
@Column({ name: 'culture_final_score', type: 'decimal', precision: 5, scale: 2, nullable: true })
|
||||
cultureFinalScore: number;
|
||||
|
||||
@Column({ name: 'professional_final_score', type: 'decimal', precision: 5, scale: 2, nullable: true })
|
||||
professionalFinalScore: number;
|
||||
|
||||
@Column({ name: 'admission_status', length: 50, nullable: true })
|
||||
admissionStatus: string;
|
||||
|
||||
@Column({ name: 'admitted_college', length: 100, nullable: true })
|
||||
admittedCollege: string;
|
||||
|
||||
@Column({ name: 'admitted_major', length: 100, nullable: true })
|
||||
admittedMajor: string;
|
||||
|
||||
@Column({ name: 'department_id', type: 'integer', nullable: true })
|
||||
departmentId: number;
|
||||
|
||||
@ManyToOne(() => Department, { nullable: true })
|
||||
@JoinColumn({ name: 'department_id' })
|
||||
department: Department;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
61
apps/server/src/entities/student-enrollment.entity.ts
Normal file
61
apps/server/src/entities/student-enrollment.entity.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Student } from './student.entity';
|
||||
import { Department } from './department.entity';
|
||||
|
||||
@Entity('student_enrollments')
|
||||
export class StudentEnrollment {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ name: 'student_id', type: 'integer' })
|
||||
studentId: number;
|
||||
|
||||
@ManyToOne(() => Student, { eager: true })
|
||||
@JoinColumn({ name: 'student_id' })
|
||||
student: Student;
|
||||
|
||||
@Column({ name: 'course_category', length: 50, nullable: true })
|
||||
courseCategory: string;
|
||||
|
||||
@Column({ name: 'class_type', length: 50, nullable: true })
|
||||
classType: string;
|
||||
|
||||
@Column({ name: 'class_name', length: 100, nullable: true })
|
||||
className: string;
|
||||
|
||||
@Column({ name: 'head_teacher', length: 50, nullable: true })
|
||||
headTeacher: string;
|
||||
|
||||
@Column({ name: 'subject_teacher', length: 50, nullable: true })
|
||||
subjectTeacher: string;
|
||||
|
||||
@Column({ name: 'start_date', type: 'date', nullable: true })
|
||||
startDate: string;
|
||||
|
||||
@Column({ name: 'end_date', type: 'date', nullable: true })
|
||||
endDate: string;
|
||||
|
||||
@Column({ length: 20, default: 'active' })
|
||||
status: string;
|
||||
|
||||
@Column({ name: 'department_id', type: 'integer', nullable: true })
|
||||
departmentId: number;
|
||||
|
||||
@ManyToOne(() => Department, { nullable: true })
|
||||
@JoinColumn({ name: 'department_id' })
|
||||
department: Department;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
58
apps/server/src/entities/student-profile.entity.ts
Normal file
58
apps/server/src/entities/student-profile.entity.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Student } from './student.entity';
|
||||
import { Department } from './department.entity';
|
||||
|
||||
@Entity('student_profiles')
|
||||
export class StudentProfile {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ name: 'student_id', type: 'integer', unique: true })
|
||||
studentId: number;
|
||||
|
||||
@ManyToOne(() => Student, { eager: true })
|
||||
@JoinColumn({ name: 'student_id' })
|
||||
student: Student;
|
||||
|
||||
@Column({ name: 'target_college', length: 100, nullable: true })
|
||||
targetCollege: string;
|
||||
|
||||
@Column({ name: 'target_major', length: 100, nullable: true })
|
||||
targetMajor: string;
|
||||
|
||||
@Column({ name: 'subject_direction', length: 50, nullable: true })
|
||||
subjectDirection: string;
|
||||
|
||||
@Column({ length: 20, nullable: true })
|
||||
grade: string;
|
||||
|
||||
@Column({ name: 'campus_location', length: 100, nullable: true })
|
||||
campusLocation: string;
|
||||
|
||||
@Column({ name: 'profile_date', type: 'date', nullable: true })
|
||||
profileDate: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes: string;
|
||||
|
||||
@Column({ name: 'department_id', type: 'integer', nullable: true })
|
||||
departmentId: number;
|
||||
|
||||
@ManyToOne(() => Department, { nullable: true })
|
||||
@JoinColumn({ name: 'department_id' })
|
||||
department: Department;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
49
apps/server/src/entities/student-report.entity.ts
Normal file
49
apps/server/src/entities/student-report.entity.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Student } from './student.entity';
|
||||
import { Department } from './department.entity';
|
||||
|
||||
@Entity('student_reports')
|
||||
export class StudentReport {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ name: 'student_id', type: 'integer' })
|
||||
studentId: number;
|
||||
|
||||
@ManyToOne(() => Student, { eager: true })
|
||||
@JoinColumn({ name: 'student_id' })
|
||||
student: Student;
|
||||
|
||||
@Column({ name: 'snapshot_data', type: 'simple-json', nullable: true })
|
||||
snapshotData: Record<string, unknown>;
|
||||
|
||||
@Column({ name: 'html_content', type: 'text', nullable: true })
|
||||
htmlContent: string;
|
||||
|
||||
@Column({ name: 'pdf_path', length: 500, nullable: true })
|
||||
pdfPath: string;
|
||||
|
||||
@Column({ name: 'generated_at', type: 'datetime', nullable: true })
|
||||
generatedAt: Date;
|
||||
|
||||
@Column({ name: 'department_id', type: 'integer', nullable: true })
|
||||
departmentId: number;
|
||||
|
||||
@ManyToOne(() => Department, { nullable: true })
|
||||
@JoinColumn({ name: 'department_id' })
|
||||
department: Department;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
Reference in New Issue
Block a user