学生信息导入 #32
@@ -1,40 +1,42 @@
|
||||
# PR 自动检查:代码风格 + 类型检查 + 测试
|
||||
name: CI 检查
|
||||
name: CI
|
||||
|
||||
env:
|
||||
NPM_CONFIG_REGISTRY: https://registry.npmmirror.com
|
||||
NPM_CONFIG_AUDIT: "false"
|
||||
NPM_CONFIG_FUND: "false"
|
||||
NPM_CONFIG_PREFER_OFFLINE: "true"
|
||||
NPM_CONFIG_REPLACE_REGISTRY_HOST: always
|
||||
CI: "true"
|
||||
TURBO_UI: stream
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
container: node:20.20.2-bookworm
|
||||
timeout-minutes: 30
|
||||
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- run: npm ci
|
||||
- run: npm run typecheck
|
||||
- name: Checkout
|
||||
uses: https://gitee.com/mirrors_actions/checkout@v4
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, typecheck]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- run: npm ci
|
||||
- run: npm run test
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: |
|
||||
npm run lint -w @gongxue/admin
|
||||
npm run lint -w @gongxue/server -- --no-fix
|
||||
|
||||
- name: Type check
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Build frontend
|
||||
run: npm run build -w @gongxue/admin
|
||||
|
||||
- name: Run backend tests
|
||||
run: npm run test -w @gongxue/server -- --runInBand --forceExit
|
||||
|
||||
@@ -24,6 +24,8 @@ const TeachersPage = lazy(() => import('./pages/Teachers'));
|
||||
const StudentProfilePage = lazy(() => import('./pages/StudentProfile'));
|
||||
const ClassesPage = lazy(() => import('./pages/Classes'));
|
||||
const ClassDetailPage = lazy(() => import('./pages/Classes/detail'));
|
||||
const ExamsPage = lazy(() => import('./pages/Exams'));
|
||||
const ExamDetailPage = lazy(() => import('./pages/Exams/detail'));
|
||||
const OrganizationsPage = lazy(() => import('./pages/Organizations'));
|
||||
const ClassroomRentalsPage = lazy(() => import('./pages/ClassroomRentals'));
|
||||
const ClassroomSchedulePage = lazy(() => import('./pages/ClassroomSchedule'));
|
||||
@@ -174,6 +176,22 @@ const App: React.FC = () => {
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="exams"
|
||||
element={
|
||||
<PermissionRoute permission="exam:view">
|
||||
<ExamsPage />
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="exams/:id"
|
||||
element={
|
||||
<PermissionRoute permission="exam:view">
|
||||
<ExamDetailPage />
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="operation-logs"
|
||||
element={
|
||||
|
||||
@@ -11,6 +11,7 @@ const academicPermissions = [
|
||||
'dashboard:view',
|
||||
'student:view',
|
||||
'class:view',
|
||||
'exam:view',
|
||||
'teacher:view',
|
||||
'schedule:view',
|
||||
'attendance:view',
|
||||
@@ -54,6 +55,7 @@ describe('role-aware menu policy', () => {
|
||||
expect(menu.map((item) => item.label)).toEqual(['数据面板', '教务管理', '通知中心']);
|
||||
expect(paths.filter((path) => path === '/schedules')).toHaveLength(1);
|
||||
expect(paths.filter((path) => path === '/attendance')).toHaveLength(1);
|
||||
expect(paths).toContain('/exams');
|
||||
expect(paths).not.toContain('/teacher-workspace');
|
||||
});
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ const SECTIONS: MenuSection[] = [
|
||||
children: [
|
||||
{ key: '/students', label: '学生管理', icon: 'students', permission: 'student:view' },
|
||||
{ key: '/classes', label: '班级管理', icon: 'classes', permission: 'class:view' },
|
||||
{ key: '/exams', label: '考试管理', icon: 'exam', permission: 'exam:view' },
|
||||
{ key: '/teachers', label: '教师管理', icon: 'teachers', permission: 'teacher:view' },
|
||||
{ key: '/schedules', label: '排课管理', icon: 'calendar', permission: 'schedule:view' },
|
||||
{ key: '/attendance', label: '历史考勤', icon: 'attendance', permission: 'attendance:view' },
|
||||
|
||||
@@ -30,6 +30,7 @@ describe('permission navigation', () => {
|
||||
it('keeps route permission lookup aligned for nested detail routes', () => {
|
||||
expect(getRequiredPermission('/classes/12')).toBe('class:view');
|
||||
expect(getRequiredPermission('/students/8/profile')).toBe('student:view');
|
||||
expect(getRequiredPermission('/exams/8')).toBe('exam:view');
|
||||
expect(canAccessPath('/ai-config', ['ai:config:read'])).toBe(true);
|
||||
expect(canAccessPath('/ai-config', ['integration:read'])).toBe(false);
|
||||
});
|
||||
|
||||
@@ -24,6 +24,11 @@ export const PERMISSION_PAGES: readonly PermissionPage[] = [
|
||||
permission: 'class:view',
|
||||
matches: (p) => p === '/classes' || /^\/classes\/\d+$/.test(p),
|
||||
},
|
||||
{
|
||||
path: '/exams',
|
||||
permission: 'exam:view',
|
||||
matches: (p) => p === '/exams' || /^\/exams\/\d+$/.test(p),
|
||||
},
|
||||
{ path: '/attendance', permission: 'attendance:view' },
|
||||
{ path: '/schedules', permission: 'schedule:view' },
|
||||
{ path: '/classroom-schedule', permission: 'rental:view' },
|
||||
|
||||
@@ -24,7 +24,7 @@ export interface EditableCellOption {
|
||||
|
||||
export interface EditableCellProps<Value = unknown> {
|
||||
value: Value;
|
||||
children: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
editor?: EditableCellEditor;
|
||||
options?: EditableCellOption[];
|
||||
permission?: string;
|
||||
|
||||
@@ -46,12 +46,21 @@ interface StudentInfo {
|
||||
phone: string;
|
||||
idNumber: string;
|
||||
studentNo: string;
|
||||
gender?: string;
|
||||
ethnicity?: string;
|
||||
emergencyContact?: string;
|
||||
emergencyPhone?: string;
|
||||
organizationId?: number;
|
||||
organization?: { id?: number; name?: string } | null;
|
||||
supervisor?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface ProfileData {
|
||||
targetCollege?: string;
|
||||
targetMajor?: string;
|
||||
collegeSchool?: string;
|
||||
collegeMajor?: string;
|
||||
subjectDirection?: string;
|
||||
grade?: string;
|
||||
profileDate?: string;
|
||||
@@ -72,10 +81,12 @@ interface EnrollmentRecord {
|
||||
|
||||
interface ExamScoreRecord {
|
||||
id: number;
|
||||
examId?: number;
|
||||
exam?: { class?: { name?: string } };
|
||||
examType: string;
|
||||
examName?: string;
|
||||
subject: string;
|
||||
score: number;
|
||||
score: number | null;
|
||||
classAvg?: number;
|
||||
rank?: number;
|
||||
examDate?: string;
|
||||
@@ -163,13 +174,6 @@ const RECORD_TYPE_OPTIONS = [
|
||||
{ value: 'other', label: '其他' },
|
||||
];
|
||||
|
||||
const STUDENT_STATUS_MAP: Record<string, { text: string; color: string }> = {
|
||||
active: { text: '在读', color: 'green' },
|
||||
graduated: { text: '已毕业', color: 'blue' },
|
||||
withdrawn: { text: '已退训', color: 'red' },
|
||||
archived: { text: '已归档', color: '#999' },
|
||||
};
|
||||
|
||||
const ENROLLMENT_STATUS_MAP: Record<string, { text: string; color: string }> = {
|
||||
active: { text: '报读中', color: 'green' },
|
||||
completed: { text: '已结课', color: 'blue' },
|
||||
@@ -209,11 +213,6 @@ const getEnrollmentStatus = (value?: string | null): { text: string; color: stri
|
||||
return ENROLLMENT_STATUS_MAP[value] || { text: value, color: 'default' };
|
||||
};
|
||||
|
||||
const getStudentStatus = (value?: string | null): { text: string; color: string } => {
|
||||
if (!value) return { text: '-', color: 'default' };
|
||||
return STUDENT_STATUS_MAP[value] || { text: value, color: 'default' };
|
||||
};
|
||||
|
||||
const formatEnrollmentDisplayName = (enrollment: EnrollmentRecord): string =>
|
||||
enrollment.className ||
|
||||
(enrollment.courseCategory
|
||||
@@ -311,72 +310,292 @@ interface TabProps {
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
const ProfileTab: React.FC<{
|
||||
data: ProfileData | null;
|
||||
const InlineArchiveSummary: React.FC<{
|
||||
studentId: number;
|
||||
student: StudentInfo;
|
||||
profile: ProfileData | null;
|
||||
result: ResultData | null;
|
||||
organizations: Array<{ id: number; name: string }>;
|
||||
onRefresh: () => void;
|
||||
}> = ({ data, studentId, onRefresh }) => {
|
||||
const [form] = Form.useForm();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
await api.put(`/archive/${studentId}/profile`, {
|
||||
...values,
|
||||
profileDate: values.profileDate?.format('YYYY-MM-DD'),
|
||||
});
|
||||
message.success('基础档案已保存');
|
||||
onRefresh();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
if (err?.message) message.error(err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
onViewSensitive: (fieldLabel: string, value: string) => void;
|
||||
}> = ({ studentId, student, profile, result, organizations, onRefresh, onViewSensitive }) => {
|
||||
const saveStudent = async (field: keyof StudentInfo, value: unknown) => {
|
||||
await api.put(`/students/${studentId}`, { [field]: value });
|
||||
message.success('学生资料已保存');
|
||||
onRefresh();
|
||||
};
|
||||
|
||||
const saveProfile = async (field: keyof ProfileData, value: unknown) => {
|
||||
await api.put(`/archive/${studentId}/profile`, { [field]: value });
|
||||
message.success('档案已保存');
|
||||
onRefresh();
|
||||
};
|
||||
|
||||
const saveResult = async (field: keyof ResultData, value: unknown) => {
|
||||
await api.put(`/archive/${studentId}/result`, { [field]: value });
|
||||
message.success('录取信息已保存');
|
||||
onRefresh();
|
||||
};
|
||||
|
||||
const admissionStatus = getOptionLabel(
|
||||
Object.entries(ADMISSION_STATUS_MAP).map(([value, meta]) => ({
|
||||
value,
|
||||
label: meta.text,
|
||||
})),
|
||||
result?.admissionStatus,
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
targetCollege: data?.targetCollege ?? undefined,
|
||||
targetMajor: data?.targetMajor ?? undefined,
|
||||
subjectDirection: data?.subjectDirection ?? undefined,
|
||||
grade: data?.grade ?? undefined,
|
||||
profileDate: data?.profileDate ? dayjs(data.profileDate) : undefined,
|
||||
notes: data?.notes ?? undefined,
|
||||
}}
|
||||
style={{ maxWidth: 600 }}
|
||||
>
|
||||
<Form.Item name="targetCollege" label="目标院校">
|
||||
<Input placeholder="请输入目标院校" />
|
||||
</Form.Item>
|
||||
<Form.Item name="targetMajor" label="目标专业">
|
||||
<Input placeholder="请输入目标专业" />
|
||||
</Form.Item>
|
||||
<Form.Item name="subjectDirection" label="选科方向">
|
||||
<Input placeholder="如:物化生、史地政" />
|
||||
</Form.Item>
|
||||
<Form.Item name="grade" label="年级">
|
||||
<Input placeholder="如:高三" />
|
||||
</Form.Item>
|
||||
<Form.Item name="profileDate" label="建档日期">
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="notes" label="备注">
|
||||
<Input.TextArea rows={3} placeholder="其他备注信息" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" onClick={handleSave} loading={saving}>
|
||||
保存
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
<Descriptions bordered column={3} size="small" style={{ marginBottom: 24 }}>
|
||||
<Descriptions.Item label="手机号">
|
||||
<EditableCell
|
||||
value={student.phone}
|
||||
permission="student:edit"
|
||||
onSave={(next) => saveStudent('phone', next)}
|
||||
>
|
||||
{student.phone ? (
|
||||
<span>
|
||||
<span style={{ marginRight: 8 }}>{maskPhone(student.phone)}</span>
|
||||
<a onClick={() => onViewSensitive('电话', student.phone)}>
|
||||
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
|
||||
</a>
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</EditableCell>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="姓名">
|
||||
<EditableCell
|
||||
value={student.name}
|
||||
permission="student:edit"
|
||||
required
|
||||
onSave={(next) => saveStudent('name', next)}
|
||||
>
|
||||
{student.name || '-'}
|
||||
</EditableCell>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="学号">
|
||||
<EditableCell
|
||||
value={student.studentNo}
|
||||
permission="student:edit"
|
||||
onSave={(next) => saveStudent('studentNo', next)}
|
||||
>
|
||||
{student.studentNo || '-'}
|
||||
</EditableCell>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="性别">
|
||||
<EditableCell
|
||||
value={student.gender}
|
||||
permission="student:edit"
|
||||
onSave={(next) => saveStudent('gender', next)}
|
||||
>
|
||||
{student.gender || '-'}
|
||||
</EditableCell>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="身份证号">
|
||||
<EditableCell
|
||||
value={student.idNumber}
|
||||
permission="student:edit"
|
||||
onSave={(next) => saveStudent('idNumber', next)}
|
||||
>
|
||||
{student.idNumber ? (
|
||||
<span>
|
||||
<span style={{ marginRight: 8 }}>{maskIdNumber(student.idNumber)}</span>
|
||||
<a onClick={() => onViewSensitive('身份证号', student.idNumber)}>
|
||||
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
|
||||
</a>
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</EditableCell>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="民族">
|
||||
<EditableCell
|
||||
value={student.ethnicity}
|
||||
permission="student:edit"
|
||||
onSave={(next) => saveStudent('ethnicity', next)}
|
||||
>
|
||||
{student.ethnicity || '-'}
|
||||
</EditableCell>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="紧急联系人">
|
||||
<EditableCell
|
||||
value={student.emergencyContact}
|
||||
permission="student:edit"
|
||||
onSave={(next) => saveStudent('emergencyContact', next)}
|
||||
>
|
||||
{student.emergencyContact || '-'}
|
||||
</EditableCell>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="紧急联系人电话">
|
||||
<EditableCell
|
||||
value={student.emergencyPhone}
|
||||
permission="student:edit"
|
||||
onSave={(next) => saveStudent('emergencyPhone', next)}
|
||||
>
|
||||
{student.emergencyPhone ? (
|
||||
<span>
|
||||
<span style={{ marginRight: 8 }}>{maskPhone(student.emergencyPhone)}</span>
|
||||
<a onClick={() => onViewSensitive('紧急联系人电话', student.emergencyPhone || '')}>
|
||||
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
|
||||
</a>
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</EditableCell>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="所属机构">
|
||||
<EditableCell
|
||||
value={student.organizationId}
|
||||
editor="select"
|
||||
options={organizations.map((item) => ({ value: item.id, label: item.name }))}
|
||||
permission="student:edit"
|
||||
onSave={(next) => saveStudent('organizationId', next)}
|
||||
>
|
||||
{student.organization?.name ? <Tag color="purple">{student.organization.name}</Tag> : '-'}
|
||||
</EditableCell>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="负责人">
|
||||
<EditableCell
|
||||
value={student.supervisor}
|
||||
permission="student:edit"
|
||||
onSave={(next) => saveStudent('supervisor', next)}
|
||||
>
|
||||
{student.supervisor || '-'}
|
||||
</EditableCell>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="目标院校">
|
||||
<EditableCell
|
||||
value={profile?.targetCollege}
|
||||
permission="student:edit"
|
||||
onSave={(next) => saveProfile('targetCollege', next)}
|
||||
>
|
||||
{profile?.targetCollege || '-'}
|
||||
</EditableCell>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="目标专业">
|
||||
<EditableCell
|
||||
value={profile?.targetMajor}
|
||||
permission="student:edit"
|
||||
onSave={(next) => saveProfile('targetMajor', next)}
|
||||
>
|
||||
{profile?.targetMajor || '-'}
|
||||
</EditableCell>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="大专院校">
|
||||
<EditableCell
|
||||
value={profile?.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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -648,6 +867,7 @@ const ExamScoresTab: React.FC<
|
||||
editor="select"
|
||||
options={EXAM_TYPE_OPTIONS}
|
||||
permission="student:edit"
|
||||
disabled={!!r.examId}
|
||||
required
|
||||
onSave={(next) => saveCell(r, 'examType', next)}
|
||||
>
|
||||
@@ -662,6 +882,7 @@ const ExamScoresTab: React.FC<
|
||||
<EditableCell
|
||||
value={v}
|
||||
permission="student:edit"
|
||||
disabled={!!r.examId}
|
||||
onSave={(next) => saveCell(r, 'examName', next)}
|
||||
>
|
||||
{v || '-'}
|
||||
@@ -676,6 +897,7 @@ const ExamScoresTab: React.FC<
|
||||
value={v}
|
||||
required
|
||||
permission="student:edit"
|
||||
disabled={!!r.examId}
|
||||
onSave={(next) => saveCell(r, 'subject', next)}
|
||||
>
|
||||
{v}
|
||||
@@ -685,16 +907,16 @@ const ExamScoresTab: React.FC<
|
||||
{
|
||||
title: '成绩',
|
||||
dataIndex: 'score',
|
||||
render: (v: number, r) => (
|
||||
render: (v: number | null, r) => (
|
||||
<EditableCell
|
||||
value={v}
|
||||
value={v ?? undefined}
|
||||
editor="number"
|
||||
min={0}
|
||||
required
|
||||
permission="student:edit"
|
||||
disabled={!!r.examId}
|
||||
onSave={(next) => saveCell(r, 'score', next)}
|
||||
>
|
||||
{v}
|
||||
{v ?? '-'}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
@@ -707,6 +929,7 @@ const ExamScoresTab: React.FC<
|
||||
editor="number"
|
||||
min={0}
|
||||
permission="student:edit"
|
||||
disabled={!!r.examId}
|
||||
onSave={(next) => saveCell(r, 'classAvg', next)}
|
||||
>
|
||||
{v !== undefined ? v : '-'}
|
||||
@@ -722,6 +945,7 @@ const ExamScoresTab: React.FC<
|
||||
editor="number"
|
||||
min={1}
|
||||
permission="student:edit"
|
||||
disabled={!!r.examId}
|
||||
onSave={(next) => saveCell(r, 'rank', next)}
|
||||
>
|
||||
{v !== undefined ? v : '-'}
|
||||
@@ -736,6 +960,7 @@ const ExamScoresTab: React.FC<
|
||||
value={v}
|
||||
editor="date"
|
||||
permission="student:edit"
|
||||
disabled={!!r.examId}
|
||||
onSave={(next) => saveCell(r, 'examDate', next)}
|
||||
>
|
||||
{v || '-'}
|
||||
@@ -754,9 +979,11 @@ const ExamScoresTab: React.FC<
|
||||
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);
|
||||
@@ -1018,75 +1245,6 @@ const LearningTab: React.FC<TabProps & { data: LearningRecord[] }> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const ResultTab: React.FC<TabProps & { data: ResultData | null }> = ({
|
||||
data,
|
||||
studentId,
|
||||
onRefresh,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
await api.put(`/archive/${studentId}/result`, values);
|
||||
message.success('录取结果已保存');
|
||||
onRefresh();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
if (err?.message) message.error(err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
cultureFinalScore: data?.cultureFinalScore ?? undefined,
|
||||
professionalFinalScore: data?.professionalFinalScore ?? undefined,
|
||||
admissionStatus: data?.admissionStatus ?? undefined,
|
||||
admittedCollege: data?.admittedCollege ?? undefined,
|
||||
admittedMajor: data?.admittedMajor ?? undefined,
|
||||
}}
|
||||
style={{ maxWidth: 500 }}
|
||||
>
|
||||
<Form.Item name="cultureFinalScore" label="文化课最终分">
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="professionalFinalScore" label="专业课最终分">
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="admissionStatus" label="录取状态">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="请选择录取状态"
|
||||
options={Object.entries(ADMISSION_STATUS_MAP).map(([k, v]) => ({
|
||||
value: k,
|
||||
label: v.text,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="admittedCollege" label="录取院校">
|
||||
<Input placeholder="请输入录取院校" />
|
||||
</Form.Item>
|
||||
<Form.Item name="admittedMajor" label="录取专业">
|
||||
<Input placeholder="请输入录取专业" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" onClick={handleSave} loading={saving}>
|
||||
保存
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> = ({
|
||||
data,
|
||||
studentId,
|
||||
@@ -1202,6 +1360,7 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
|
||||
onClose,
|
||||
}) => {
|
||||
const [aggregateData, setAggregateData] = useState<StudentProfileAggregate | null>(null);
|
||||
const [organizations, setOrganizations] = useState<Array<{ id: number; name: string }>>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
@@ -1221,6 +1380,15 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
|
||||
void fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get('/organizations', { params: { includeArchived: 'false' } })
|
||||
.then((res: unknown) => {
|
||||
setOrganizations(res as Array<{ id: number; name: string }>);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handlePreviewReport = useCallback(async () => {
|
||||
try {
|
||||
const { html } = await api.get<{ html: string }>(`/archive/${studentId}/report-html`);
|
||||
@@ -1238,14 +1406,8 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
|
||||
|
||||
const tabItems = useMemo(() => {
|
||||
if (!aggregateData) return [];
|
||||
const { profile, enrollments, examScores, learningRecords, result, attachments, attendances } =
|
||||
aggregateData;
|
||||
const { enrollments, examScores, learningRecords, attachments, attendances } = aggregateData;
|
||||
return [
|
||||
{
|
||||
key: 'profile',
|
||||
label: '扩展档案',
|
||||
children: <ProfileTab data={profile} studentId={studentId} onRefresh={fetchData} />,
|
||||
},
|
||||
{
|
||||
key: 'enrollments',
|
||||
label: `报读班型 (${enrollments.length})`,
|
||||
@@ -1275,11 +1437,6 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
|
||||
<LearningTab data={learningRecords} studentId={studentId} onRefresh={fetchData} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'result',
|
||||
label: '录取归档',
|
||||
children: <ResultTab data={result} studentId={studentId} onRefresh={fetchData} />,
|
||||
},
|
||||
{
|
||||
key: 'attachments',
|
||||
label: `附件 (${attachments.length})`,
|
||||
@@ -1304,7 +1461,7 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const { student, profile } = aggregateData;
|
||||
const { student, profile, result } = aggregateData;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -1342,51 +1499,17 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Descriptions bordered column={3} size="small" style={{ marginBottom: 24 }}>
|
||||
<Descriptions.Item label="学号">{student.studentNo || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="电话">
|
||||
{student.phone ? (
|
||||
<span>
|
||||
<span style={{ marginRight: 8 }}>{maskPhone(student.phone)}</span>
|
||||
<a onClick={() => handleViewSensitive('电话', student.phone)}>
|
||||
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
|
||||
</a>
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="身份证号">
|
||||
{student.idNumber ? (
|
||||
<span>
|
||||
<span style={{ marginRight: 8 }}>{maskIdNumber(student.idNumber)}</span>
|
||||
<a onClick={() => handleViewSensitive('身份证号', student.idNumber)}>
|
||||
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
|
||||
</a>
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
{(() => {
|
||||
const status = getStudentStatus(student.status);
|
||||
return <Tag color={status.color}>{status.text}</Tag>;
|
||||
})()}
|
||||
</Descriptions.Item>
|
||||
{profile?.targetCollege && (
|
||||
<Descriptions.Item label="目标院校">{profile.targetCollege}</Descriptions.Item>
|
||||
)}
|
||||
{profile?.targetMajor && (
|
||||
<Descriptions.Item label="目标专业">{profile.targetMajor}</Descriptions.Item>
|
||||
)}
|
||||
{profile?.grade && <Descriptions.Item label="年级">{profile.grade}</Descriptions.Item>}
|
||||
{profile?.subjectDirection && (
|
||||
<Descriptions.Item label="选科方向">{profile.subjectDirection}</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
<InlineArchiveSummary
|
||||
studentId={studentId}
|
||||
student={student}
|
||||
profile={profile}
|
||||
result={result}
|
||||
organizations={organizations}
|
||||
onRefresh={fetchData}
|
||||
onViewSensitive={handleViewSensitive}
|
||||
/>
|
||||
|
||||
<Tabs defaultActiveKey="profile" items={tabItems} />
|
||||
<Tabs defaultActiveKey="enrollments" items={tabItems} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
CheckCircleOutlined,
|
||||
LaptopOutlined,
|
||||
BellOutlined,
|
||||
TrophyOutlined,
|
||||
ApiOutlined,
|
||||
RobotOutlined,
|
||||
} from '@ant-design/icons';
|
||||
@@ -46,6 +47,7 @@ const iconMap: Record<string, React.ReactNode> = {
|
||||
students: <TeamOutlined />,
|
||||
classes: <TeamOutlined />,
|
||||
teachers: <UserOutlined />,
|
||||
exam: <TrophyOutlined />,
|
||||
home: <HomeOutlined />,
|
||||
overview: <AppstoreOutlined />,
|
||||
occupancy: <SwapOutlined />,
|
||||
|
||||
59
apps/admin/src/pages/Exams/ExamFormModal.tsx
Normal file
59
apps/admin/src/pages/Exams/ExamFormModal.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { DatePicker, Form, Input, Modal, Select } from 'antd';
|
||||
import type { FormInstance } from 'antd';
|
||||
import type { ClassOption, ExamFormValues } from './types';
|
||||
import { EXAM_TYPE_OPTIONS } from './types';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
editing: boolean;
|
||||
saving: boolean;
|
||||
form: FormInstance<ExamFormValues>;
|
||||
classes: ClassOption[];
|
||||
onCancel: () => void;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
const ExamFormModal: React.FC<Props> = ({
|
||||
open,
|
||||
editing,
|
||||
saving,
|
||||
form,
|
||||
classes,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}) => (
|
||||
<Modal
|
||||
title={editing ? '编辑考试' : '创建考试'}
|
||||
open={open}
|
||||
confirmLoading={saving}
|
||||
onCancel={onCancel}
|
||||
onOk={onSubmit}
|
||||
width={560}
|
||||
>
|
||||
<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="考试名称" rules={[{ required: true, message: '请输入考试名称' }]}>
|
||||
<Input placeholder="如:2026 年 7 月月考" />
|
||||
</Form.Item>
|
||||
<Form.Item name="subject" label="科目" rules={[{ required: true, message: '请输入科目' }]}>
|
||||
<Input placeholder="如:数学" />
|
||||
</Form.Item>
|
||||
<Form.Item name="examDate" label="考试日期" rules={[{ required: true, message: '请选择考试日期' }]}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="classId" label="考试班级" rules={[{ required: true, message: '请选择考试班级' }]}>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="选择在读班级"
|
||||
options={classes.filter((item) => !item.isArchived).map((item) => ({ value: item.id, label: item.name }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
export default ExamFormModal;
|
||||
94
apps/admin/src/pages/Exams/detail.tsx
Normal file
94
apps/admin/src/pages/Exams/detail.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Card, Descriptions, Empty, Space, Spin, Table, Tooltip } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { ArrowLeftOutlined, EyeOutlined } from '@ant-design/icons';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import api from '../../api';
|
||||
import EditableCell from '../../components/EditableCell';
|
||||
import { useViewSensitive } from '../../hooks/useViewSensitive';
|
||||
import { maskPhone } from '../../utils/sensitive';
|
||||
import { message } from '../../ui/app-message';
|
||||
import type { ExamItem } from './types';
|
||||
import './style.css';
|
||||
|
||||
interface ScoreRow {
|
||||
id: number;
|
||||
studentId: number;
|
||||
phone: string;
|
||||
name: string;
|
||||
score: number | null;
|
||||
classAvg: number | null;
|
||||
rank: number | null;
|
||||
}
|
||||
|
||||
interface ExamDetail extends ExamItem { scores: ScoreRow[] }
|
||||
|
||||
const PhoneCell: React.FC<{ row: ScoreRow }> = ({ row }) => {
|
||||
const reveal = useViewSensitive(row.studentId, '考试管理');
|
||||
if (!row.phone) return <>-</>;
|
||||
return <Space size={4}><span>{maskPhone(row.phone)}</span><Tooltip title="查看完整手机号"><Button type="text" size="small" icon={<EyeOutlined />} onClick={() => reveal('手机号', row.phone)} /></Tooltip></Space>;
|
||||
};
|
||||
|
||||
const ExamDetailPage: React.FC = () => {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [detail, setDetail] = useState<ExamDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try { setDetail(await api.get<ExamDetail>(`/exams/${id}`)); }
|
||||
catch (error) { message.error((error as { message?: string })?.message || '加载考试失败'); }
|
||||
finally { setLoading(false); }
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
const saveScore = async (row: ScoreRow, value: number | undefined) => {
|
||||
await api.put(`/exams/${id}/scores/${row.id}`, { score: value ?? null });
|
||||
message.success('成绩已保存');
|
||||
await load();
|
||||
};
|
||||
|
||||
const columns = useMemo<ColumnsType<ScoreRow>>(() => {
|
||||
if (!detail) return [];
|
||||
const fixed = [
|
||||
{ title: '手机号*', width: 155, render: (_: unknown, row: ScoreRow) => <PhoneCell row={row} /> },
|
||||
{ title: '姓名', dataIndex: 'name', width: 100 },
|
||||
{ title: '考试类型*', width: 110, render: () => detail.examType },
|
||||
{ title: '考试名称', width: 170, render: () => detail.examName },
|
||||
{ title: '科目*', width: 90, render: () => detail.subject },
|
||||
];
|
||||
return [
|
||||
...fixed,
|
||||
{ title: '成绩*', dataIndex: 'score', width: 100, render: (value: number | null, row: ScoreRow) => <EditableCell<number | undefined> value={value ?? undefined} editor="money" min={0} max={999.99} permission="exam:view" onSave={(next) => saveScore(row, next)}>{value ?? '-'}</EditableCell> },
|
||||
{ title: '班级均分', dataIndex: 'classAvg', width: 110, render: (value: number | null) => value ?? '-' },
|
||||
{ title: '排名', dataIndex: 'rank', width: 80, render: (value: number | null) => value ?? '-' },
|
||||
{ title: '考试日期', width: 110, render: () => detail.examDate },
|
||||
{ title: '关联报读(班级名)', width: 180, render: () => detail.className },
|
||||
];
|
||||
}, [detail]);
|
||||
|
||||
if (loading && !detail) return <div className="exam-detail-loading"><Spin size="large" /></div>;
|
||||
if (!detail) return <Empty description="考试不存在或无权访问" />;
|
||||
|
||||
const average = detail.scores.find((row) => row.classAvg !== null)?.classAvg ?? null;
|
||||
return (
|
||||
<div className="exam-detail-page">
|
||||
<div className="exam-detail-header"><Space><Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/exams')}>返回</Button><h2>{detail.examName}</h2></Space></div>
|
||||
<Card className="exam-summary">
|
||||
<Descriptions column={{ xs: 1, sm: 2, lg: 5 }}>
|
||||
<Descriptions.Item label="考试类型">{detail.examType}</Descriptions.Item>
|
||||
<Descriptions.Item label="科目">{detail.subject}</Descriptions.Item>
|
||||
<Descriptions.Item label="考试班级">{detail.className}</Descriptions.Item>
|
||||
<Descriptions.Item label="考试日期">{detail.examDate}</Descriptions.Item>
|
||||
<Descriptions.Item label="录入进度">{detail.enteredScores}/{detail.totalStudents},均分 {average ?? '-'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
<Card title="成绩表">
|
||||
<Table<ScoreRow> columns={columns} dataSource={detail.scores} rowKey="id" loading={loading} scroll={{ x: 1310 }} pagination={{ defaultPageSize: 30, showSizeChanger: true, pageSizeOptions: [30, 50, 100] }} locale={{ emptyText: <Empty description="暂无学生名单" /> }} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExamDetailPage;
|
||||
129
apps/admin/src/pages/Exams/index.tsx
Normal file
129
apps/admin/src/pages/Exams/index.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Card, Col, Empty, Form, Input, Progress, Row, Select, Space, Tag } from 'antd';
|
||||
import { CalendarOutlined, PlusOutlined, SearchOutlined, TeamOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../../api';
|
||||
import { message } from '../../ui/app-message';
|
||||
import ExamFormModal from './ExamFormModal';
|
||||
import type { ClassOption, ExamFormValues, ExamItem } from './types';
|
||||
import { EXAM_TYPE_OPTIONS } from './types';
|
||||
import './style.css';
|
||||
|
||||
const ExamsPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [form] = Form.useForm<ExamFormValues>();
|
||||
const [data, setData] = useState<ExamItem[]>([]);
|
||||
const [classes, setClasses] = useState<ClassOption[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [examType, setExamType] = useState<string>();
|
||||
const [classId, setClassId] = useState<number>();
|
||||
|
||||
const loadClasses = useCallback(async () => {
|
||||
const result = await api.get<ClassOption[]>('/classes');
|
||||
setClasses(result ?? []);
|
||||
}, []);
|
||||
|
||||
const loadExams = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (keyword.trim()) params.set('keyword', keyword.trim());
|
||||
if (examType) params.set('examType', examType);
|
||||
if (classId) params.set('classId', String(classId));
|
||||
const result = await api.get<ExamItem[]>(`/exams?${params.toString()}`);
|
||||
setData(result ?? []);
|
||||
} catch (error) {
|
||||
message.error((error as { message?: string })?.message || '加载考试失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [classId, examType, keyword]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadClasses().catch((error: { message?: string }) => message.error(error?.message || '加载班级失败'));
|
||||
}, [loadClasses]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => void loadExams(), 200);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [loadExams]);
|
||||
|
||||
const classOptions = useMemo(
|
||||
() => classes.map((item) => ({ value: item.id, label: item.name })),
|
||||
[classes],
|
||||
);
|
||||
|
||||
const openCreate = () => {
|
||||
form.resetFields();
|
||||
form.setFieldValue('examDate', dayjs());
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
const payload = { ...values, examDate: values.examDate.format('YYYY-MM-DD') };
|
||||
await api.post('/exams', payload);
|
||||
message.success('考试已创建');
|
||||
setModalOpen(false);
|
||||
await loadExams();
|
||||
} catch (error) {
|
||||
if ((error as { errorFields?: unknown[] }).errorFields) return;
|
||||
message.error((error as { message?: string })?.message || '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="exam-page">
|
||||
<div className="exam-toolbar">
|
||||
<Space wrap>
|
||||
<Input value={keyword} onChange={(event) => setKeyword(event.target.value)} prefix={<SearchOutlined />} placeholder="搜索考试名称" allowClear />
|
||||
<Select value={examType} onChange={setExamType} options={EXAM_TYPE_OPTIONS} placeholder="考试类型" allowClear style={{ width: 140 }} />
|
||||
<Select value={classId} onChange={setClassId} options={classOptions} placeholder="考试班级" allowClear showSearch optionFilterProp="label" style={{ width: 180 }} />
|
||||
</Space>
|
||||
<Space wrap>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>创建考试</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{data.length === 0 && !loading ? (
|
||||
<div className="exam-empty"><Empty description="暂无考试" /></div>
|
||||
) : (
|
||||
<Row gutter={[16, 16]}>
|
||||
{data.map((exam) => {
|
||||
const percent = exam.totalStudents === 0 ? 0 : Math.round((exam.enteredScores / exam.totalStudents) * 100);
|
||||
return (
|
||||
<Col key={exam.id} xs={24} sm={12} xl={8} xxl={6}>
|
||||
<Card
|
||||
className="exam-card"
|
||||
loading={loading}
|
||||
title={<Space><Tag color="blue">{exam.examType}</Tag><span>{exam.examName}</span></Space>}
|
||||
extra={<Tag color="green">成绩录入</Tag>}
|
||||
actions={[
|
||||
<span key="detail" onClick={() => navigate(`/exams/${exam.id}`)}>查看成绩</span>,
|
||||
]}
|
||||
>
|
||||
<div className="exam-meta"><span>科目</span><strong>{exam.subject}</strong></div>
|
||||
<div className="exam-meta"><span><TeamOutlined /> 班级</span><strong>{exam.className}</strong></div>
|
||||
<div className="exam-meta"><span><CalendarOutlined /> 日期</span><strong>{exam.examDate}</strong></div>
|
||||
<div className="exam-progress"><div><span>成绩录入</span><strong>{exam.enteredScores}/{exam.totalStudents}</strong></div><Progress percent={percent} size="small" /></div>
|
||||
</Card>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<ExamFormModal open={modalOpen} editing={false} saving={saving} form={form} classes={classes} onCancel={() => setModalOpen(false)} onSubmit={() => void submit()} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExamsPage;
|
||||
77
apps/admin/src/pages/Exams/style.css
Normal file
77
apps/admin/src/pages/Exams/style.css
Normal file
@@ -0,0 +1,77 @@
|
||||
.exam-page,
|
||||
.exam-detail-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.exam-toolbar,
|
||||
.exam-detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.exam-detail-header h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.exam-card {
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.exam-card .ant-card-head-title {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.exam-card .ant-card-head-title > .ant-space {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.exam-card .ant-card-head-title span:last-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.exam-meta,
|
||||
.exam-progress > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.exam-meta span,
|
||||
.exam-progress span {
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.exam-progress {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.exam-empty,
|
||||
.exam-detail-loading {
|
||||
min-height: 360px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.exam-summary {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.exam-toolbar > .ant-space,
|
||||
.exam-toolbar .ant-input-affix-wrapper,
|
||||
.exam-toolbar .ant-select {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
37
apps/admin/src/pages/Exams/types.ts
Normal file
37
apps/admin/src/pages/Exams/types.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type dayjs from 'dayjs';
|
||||
|
||||
export interface ExamItem {
|
||||
id: number;
|
||||
examType: string;
|
||||
examName: string;
|
||||
subject: string;
|
||||
examDate: string;
|
||||
classId: number;
|
||||
className: string;
|
||||
status: 'active' | 'archived';
|
||||
totalStudents: number;
|
||||
enteredScores: number;
|
||||
}
|
||||
|
||||
export interface ExamFormValues {
|
||||
examType: string;
|
||||
examName: string;
|
||||
subject: string;
|
||||
examDate: dayjs.Dayjs;
|
||||
classId: number;
|
||||
}
|
||||
|
||||
export interface ClassOption {
|
||||
id: number;
|
||||
name: string;
|
||||
isArchived: boolean;
|
||||
}
|
||||
|
||||
export const EXAM_TYPE_OPTIONS = [
|
||||
{ value: '月考', label: '月考' },
|
||||
{ value: '周测', label: '周测' },
|
||||
{ value: '期中考试', label: '期中考试' },
|
||||
{ value: '期末考试', label: '期末考试' },
|
||||
{ value: '模拟考试', label: '模拟考试' },
|
||||
{ value: '入学测试', label: '入学测试' },
|
||||
];
|
||||
@@ -13,6 +13,7 @@
|
||||
"start": "nest start",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"generate:student-import": "ts-node -r tsconfig-paths/register -P tsconfig.json scripts/generate-student-import-xlsx.ts",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"typecheck": "tsc -p tsconfig.build.json --noEmit",
|
||||
"test": "jest",
|
||||
|
||||
106
apps/server/scripts/generate-student-import-xlsx.ts
Normal file
106
apps/server/scripts/generate-student-import-xlsx.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/// <reference types="node" />
|
||||
|
||||
import * as ExcelJS from 'exceljs';
|
||||
import {
|
||||
STUDENT_IMPORT_COLUMNS,
|
||||
createStudentImportTemplateWorkbook,
|
||||
} from '../src/students/student-import';
|
||||
|
||||
const count = Number(process.argv[2] || 1000);
|
||||
const output = process.argv[3] || `student-import-${count}.xlsx`;
|
||||
|
||||
function getWorksheetOrThrow(workbook: ExcelJS.Workbook, name: string) {
|
||||
const sheet = workbook.getWorksheet(name);
|
||||
if (!sheet) throw new Error(`Worksheet not found: ${name}`);
|
||||
return sheet;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!Number.isInteger(count) || count <= 0) {
|
||||
throw new Error('Count must be a positive integer');
|
||||
}
|
||||
|
||||
const workbook = createStudentImportTemplateWorkbook();
|
||||
const students = getWorksheetOrThrow(workbook, '学生基础+档案+录取');
|
||||
const enrollments = getWorksheetOrThrow(workbook, '报读班型');
|
||||
const examScores = getWorksheetOrThrow(workbook, '考试成绩');
|
||||
const learningRecords = getWorksheetOrThrow(workbook, '课堂回访');
|
||||
|
||||
for (let i = 1; i <= count; i++) {
|
||||
const serial = String(i).padStart(4, '0');
|
||||
const phone = `138${String(i).padStart(8, '0')}`;
|
||||
students.addRow({
|
||||
phone,
|
||||
name: `导入学生${serial}`,
|
||||
studentNo: `GX${serial}`,
|
||||
gender: i % 2 === 0 ? '女' : '男',
|
||||
idNumber: `110101200601${String((i % 28) + 1).padStart(2, '0')}${String(i % 1000).padStart(3, '0')}X`,
|
||||
ethnicity: '汉族',
|
||||
emergencyContact: `联系人${serial}`,
|
||||
emergencyPhone: `139${String(i).padStart(8, '0')}`,
|
||||
organization: '',
|
||||
supervisor: '',
|
||||
targetCollege: `目标院校${(i % 20) + 1}`,
|
||||
targetMajor: `目标专业${(i % 10) + 1}`,
|
||||
collegeSchool: `大专院校${(i % 12) + 1}`,
|
||||
collegeMajor: `大专专业${(i % 8) + 1}`,
|
||||
subjectDirection: ['物化生', '物化地', '史政地'][i % 3],
|
||||
grade: '高三',
|
||||
profileDate: '2024-09-01',
|
||||
notes: '',
|
||||
cultureFinalScore: 500 + (i % 151),
|
||||
professionalFinalScore: 300 + (i % 101),
|
||||
admissionStatus: 'pending',
|
||||
admittedCollege: '',
|
||||
admittedMajor: '',
|
||||
});
|
||||
|
||||
enrollments.addRow({
|
||||
phone,
|
||||
name: `导入学生${serial}`,
|
||||
courseCategory: i % 2 === 0 ? 'culture' : 'professional',
|
||||
classType: i % 3 === 0 ? 'small_group' : 'one_on_one',
|
||||
className: `导入测试班${(i % 10) + 1}`,
|
||||
headTeacher: '',
|
||||
subjectTeacher: '',
|
||||
startDate: '2024-09-01',
|
||||
endDate: '2025-06-01',
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
examScores.addRow({
|
||||
phone,
|
||||
name: `导入学生${serial}`,
|
||||
examType: 'monthly',
|
||||
examName: '导入测试月考',
|
||||
subject: '语文',
|
||||
score: 80 + (i % 41),
|
||||
classAvg: 90,
|
||||
rank: (i % 50) + 1,
|
||||
examDate: '2024-10-15',
|
||||
enrollmentName: `导入测试班${(i % 10) + 1}`,
|
||||
});
|
||||
|
||||
learningRecords.addRow({
|
||||
phone,
|
||||
name: `导入学生${serial}`,
|
||||
recordDate: '2024-10-20',
|
||||
recordType: 'study_feedback',
|
||||
content: `导入测试回访${serial}`,
|
||||
followUpMethod: 'phone',
|
||||
nextStep: '',
|
||||
});
|
||||
}
|
||||
|
||||
for (const sheet of workbook.worksheets) {
|
||||
sheet.views = [{ state: 'frozen', ySplit: 1 }];
|
||||
}
|
||||
|
||||
await workbook.xlsx.writeFile(output);
|
||||
console.log(`Generated ${count} students with ${STUDENT_IMPORT_COLUMNS.length} main columns: ${output}`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
StudentProfile,
|
||||
StudentEnrollment,
|
||||
ExamScore,
|
||||
Exam,
|
||||
LearningRecord,
|
||||
ExpenseType,
|
||||
ResultArchive,
|
||||
@@ -51,7 +52,8 @@ import {
|
||||
} from './entities';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { InitialSchema1784520727860 } from './migrations/1784520727860-InitialSchema';
|
||||
const allMigrations = [InitialSchema1784520727860];
|
||||
import { AddExamManagement1784600000000 } from './migrations/1784600000000-AddExamManagement';
|
||||
const allMigrations = [InitialSchema1784520727860, AddExamManagement1784600000000];
|
||||
import { AuthorizationModule } from './authorization';
|
||||
import { RbacModule } from './rbac/rbac.module';
|
||||
import { StudentsModule } from './students/students.module';
|
||||
@@ -81,6 +83,7 @@ import { AgentToolsModule } from './agent-tools';
|
||||
import { AiConfigModule } from './ai-config/ai-config.module';
|
||||
import { WalletsModule } from './wallets/wallets.module';
|
||||
import { FinancialOperationsModule } from './financial-operations/financial-operations.module';
|
||||
import { ExamsModule } from './exams/exams.module';
|
||||
|
||||
import {
|
||||
IntegrationConfig,
|
||||
@@ -137,6 +140,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
|
||||
StudentProfile,
|
||||
StudentEnrollment,
|
||||
ExamScore,
|
||||
Exam,
|
||||
LearningRecord,
|
||||
ExpenseType,
|
||||
ArchiveAttachment,
|
||||
@@ -178,6 +182,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
|
||||
AuthModule,
|
||||
RbacModule,
|
||||
StudentsModule,
|
||||
ExamsModule,
|
||||
RoomsModule,
|
||||
OccupanciesModule,
|
||||
ExpensesModule,
|
||||
|
||||
@@ -412,11 +412,13 @@ ${this.buildLearningAndResult(learnings, result, now)}
|
||||
const highestName = highestExam?.examName ?? '-';
|
||||
|
||||
// Improvement: last exam score minus first exam score
|
||||
const sortedExams = [...cultureExams].filter((e) => e.score != null);
|
||||
const sortedScores = cultureExams
|
||||
.map((exam) => exam.score)
|
||||
.filter((score): score is number => score !== null && score !== undefined);
|
||||
let improvement = '—';
|
||||
if (sortedExams.length >= 2) {
|
||||
const first = sortedExams[0].score;
|
||||
const last = sortedExams[sortedExams.length - 1].score;
|
||||
if (sortedScores.length >= 2) {
|
||||
const first = sortedScores[0];
|
||||
const last = sortedScores[sortedScores.length - 1];
|
||||
improvement = (last - first).toFixed(1);
|
||||
}
|
||||
|
||||
@@ -511,7 +513,7 @@ ${this.buildLearningAndResult(learnings, result, now)}
|
||||
const cultureExams = exams.filter((e) => e.score != null);
|
||||
if (cultureExams.length === 0) return '';
|
||||
|
||||
const scores = cultureExams.map((e) => e.score);
|
||||
const scores = cultureExams.map((e) => Number(e.score));
|
||||
const labels = cultureExams.map((e) => {
|
||||
const d = e.examDate || '-';
|
||||
return d.length > 7 ? d.slice(5) : d;
|
||||
|
||||
@@ -67,6 +67,22 @@ describe('ArchiveService — resource and relationship boundaries', () => {
|
||||
expect(exam.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps exam-management scores read-only in the student archive', async () => {
|
||||
const exam = {
|
||||
findOne: jest.fn().mockResolvedValue({ id: 3, studentId: 7, examId: 8 }),
|
||||
save: jest.fn(),
|
||||
update: jest.fn(),
|
||||
};
|
||||
const service = createService({ exam });
|
||||
|
||||
await expect(service.updateExamScore(3, { score: 95 })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
await expect(service.deleteExamScore(3)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(exam.save).not.toHaveBeenCalled();
|
||||
expect(exam.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a missing attachment upload before writing to disk', async () => {
|
||||
const service = createService({ student: { findOne: jest.fn() } });
|
||||
await expect(service.addAttachment(7, undefined as never, 'other')).rejects.toBeInstanceOf(
|
||||
|
||||
@@ -58,7 +58,10 @@ export class ArchiveService {
|
||||
}
|
||||
|
||||
async getProfile(studentId: number) {
|
||||
const student = await this.studentRepo.findOne({ where: { id: studentId } });
|
||||
const student = await this.studentRepo.findOne({
|
||||
where: { id: studentId },
|
||||
relations: ['organization'],
|
||||
});
|
||||
if (!student) throw new NotFoundException('学生不存在');
|
||||
|
||||
const [
|
||||
@@ -72,7 +75,11 @@ export class ArchiveService {
|
||||
] = await Promise.all([
|
||||
this.profileRepo.findOne({ where: { studentId } }),
|
||||
this.enrollmentRepo.find({ where: { studentId, status: 'active' }, order: { createdAt: 'DESC' } }),
|
||||
this.examScoreRepo.find({ where: { studentId, status: 'active' }, order: { examDate: 'DESC' } }),
|
||||
this.examScoreRepo.find({
|
||||
where: { studentId, status: 'active' },
|
||||
relations: ['exam', 'exam.class'],
|
||||
order: { examDate: 'DESC' },
|
||||
}),
|
||||
this.learningRecordRepo.find({ where: { studentId, status: 'active' }, order: { recordDate: 'DESC' } }),
|
||||
this.resultRepo.findOne({ where: { studentId } }),
|
||||
this.attachmentRepo.find({ where: { studentId, status: 'active' }, order: { createdAt: 'DESC' } }),
|
||||
@@ -151,6 +158,7 @@ export class ArchiveService {
|
||||
async updateExamScore(id: number, dto: UpdateExamScoreDto) {
|
||||
const entity = await this.examScoreRepo.findOne({ where: { id } });
|
||||
if (!entity) throw new NotFoundException('考试成绩不存在');
|
||||
if (entity.examId) throw new BadRequestException('考试管理同步成绩请在考试管理中修改');
|
||||
await this.assertEnrollmentBelongsToStudent(entity.studentId, dto.enrollmentId);
|
||||
Object.assign(entity, dto);
|
||||
return this.examScoreRepo.save(entity);
|
||||
@@ -159,6 +167,7 @@ export class ArchiveService {
|
||||
async deleteExamScore(id: number) {
|
||||
const entity = await this.examScoreRepo.findOne({ where: { id } });
|
||||
if (!entity) throw new NotFoundException('考试成绩不存在');
|
||||
if (entity.examId) throw new BadRequestException('考试管理同步成绩不能在学生档案中归档');
|
||||
if (entity.status === 'archived') throw new BadRequestException('考试成绩已归档');
|
||||
await this.examScoreRepo.update(id, { status: 'archived' });
|
||||
return { message: '已归档' };
|
||||
|
||||
@@ -4,6 +4,8 @@ import { IsOptional, IsString, IsNumber, IsDateString, IsNotEmpty, Min } from 'c
|
||||
export class UpsertProfileDto {
|
||||
@IsOptional() @IsString() targetCollege?: string;
|
||||
@IsOptional() @IsString() targetMajor?: string;
|
||||
@IsOptional() @IsString() collegeSchool?: string;
|
||||
@IsOptional() @IsString() collegeMajor?: string;
|
||||
@IsOptional() @IsString() subjectDirection?: string;
|
||||
@IsOptional() @IsString() grade?: string;
|
||||
@IsOptional() @IsDateString() profileDate?: string;
|
||||
|
||||
@@ -11,6 +11,7 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
await this.ensureAiConfigTable();
|
||||
await this.ensureSyncStateLeaseColumns();
|
||||
await this.ensureStudentProfileCollegeColumns();
|
||||
await this.ensureCourseAttendanceSchema();
|
||||
await this.ensureAttendanceDevicesSchema();
|
||||
await this.ensureStudentWalletSchema();
|
||||
@@ -42,6 +43,25 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureStudentProfileCollegeColumns(): Promise<void> {
|
||||
const runner = this.dataSource.createQueryRunner();
|
||||
await runner.connect();
|
||||
try {
|
||||
const table = await runner.getTable('student_profiles');
|
||||
if (!table) return;
|
||||
const columns = new Set(table.columns.map((column) => column.name));
|
||||
const additions: Array<[string, string]> = [
|
||||
['college_school', 'VARCHAR(100)'],
|
||||
['college_major', 'VARCHAR(100)'],
|
||||
];
|
||||
for (const [name, definition] of additions) {
|
||||
if (!columns.has(name)) await runner.query(`ALTER TABLE student_profiles ADD COLUMN ${name} ${definition}`);
|
||||
}
|
||||
} finally {
|
||||
await runner.release();
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureAttendanceDevicesSchema(): Promise<void> {
|
||||
const runner = this.dataSource.createQueryRunner();
|
||||
await runner.connect();
|
||||
|
||||
@@ -9,12 +9,20 @@ import {
|
||||
} from 'typeorm';
|
||||
import { Student } from './student.entity';
|
||||
import { StudentEnrollment } from './student-enrollment.entity';
|
||||
import { Exam } from './exam.entity';
|
||||
|
||||
@Entity('exam_scores')
|
||||
export class ExamScore {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ name: 'exam_id', type: 'integer', nullable: true })
|
||||
examId: number | null;
|
||||
|
||||
@ManyToOne(() => Exam, (exam) => exam.scores, { nullable: true, onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'exam_id' })
|
||||
exam: Exam | null;
|
||||
|
||||
@Column({ name: 'student_id', type: 'integer' })
|
||||
studentId: number;
|
||||
|
||||
@@ -39,13 +47,13 @@ export class ExamScore {
|
||||
subject: string;
|
||||
|
||||
@Column({ type: 'decimal', precision: 5, scale: 2, nullable: true })
|
||||
score: number;
|
||||
score: number | null;
|
||||
|
||||
@Column({ name: 'class_avg', type: 'decimal', precision: 5, scale: 2, nullable: true })
|
||||
classAvg: number;
|
||||
classAvg: number | null;
|
||||
|
||||
@Column({ type: 'integer', nullable: true })
|
||||
rank: number;
|
||||
rank: number | null;
|
||||
|
||||
@Column({ name: 'exam_date', type: 'date', nullable: true })
|
||||
examDate: string;
|
||||
|
||||
49
apps/server/src/entities/exam.entity.ts
Normal file
49
apps/server/src/entities/exam.entity.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { Class } from './class.entity';
|
||||
import { ExamScore } from './exam-score.entity';
|
||||
|
||||
@Entity('exams')
|
||||
export class Exam {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ name: 'exam_type', length: 50 })
|
||||
examType: string;
|
||||
|
||||
@Column({ name: 'exam_name', length: 100 })
|
||||
examName: string;
|
||||
|
||||
@Column({ length: 50 })
|
||||
subject: string;
|
||||
|
||||
@Column({ name: 'exam_date', type: 'date' })
|
||||
examDate: string;
|
||||
|
||||
@Column({ name: 'class_id', type: 'integer' })
|
||||
classId: number;
|
||||
|
||||
@ManyToOne(() => Class)
|
||||
@JoinColumn({ name: 'class_id' })
|
||||
class: Class;
|
||||
|
||||
@OneToMany(() => ExamScore, (score) => score.exam)
|
||||
scores: ExamScore[];
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'active' })
|
||||
status: 'active' | 'archived';
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -32,6 +32,7 @@ export { Notification, NotificationType } from './notification.entity';
|
||||
export { StudentProfile } from './student-profile.entity';
|
||||
export { StudentEnrollment } from './student-enrollment.entity';
|
||||
export { ExamScore } from './exam-score.entity';
|
||||
export { Exam } from './exam.entity';
|
||||
export { LearningRecord } from './learning-record.entity';
|
||||
export { ResultArchive } from './result-archive.entity';
|
||||
export { ArchiveAttachment } from './archive-attachment.entity';
|
||||
|
||||
@@ -27,6 +27,12 @@ export class StudentProfile {
|
||||
@Column({ name: 'target_major', length: 100, nullable: true })
|
||||
targetMajor: string;
|
||||
|
||||
@Column({ name: 'college_school', length: 100, nullable: true })
|
||||
collegeSchool: string;
|
||||
|
||||
@Column({ name: 'college_major', length: 100, nullable: true })
|
||||
collegeMajor: string;
|
||||
|
||||
@Column({ name: 'subject_direction', length: 50, nullable: true })
|
||||
subjectDirection: string;
|
||||
|
||||
|
||||
29
apps/server/src/exams/dto/exam.dto.ts
Normal file
29
apps/server/src/exams/dto/exam.dto.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsDateString,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateExamDto {
|
||||
@IsString() @IsNotEmpty() examType: string;
|
||||
@IsString() @IsNotEmpty() examName: string;
|
||||
@IsString() @IsNotEmpty() subject: string;
|
||||
@IsDateString() examDate: string;
|
||||
@IsInt() @Min(1) classId: number;
|
||||
}
|
||||
|
||||
export class QueryExamDto {
|
||||
@IsOptional() @IsString() keyword?: string;
|
||||
@IsOptional() @IsString() examType?: string;
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1) classId?: number;
|
||||
}
|
||||
|
||||
export class UpdateExamScoreValueDto {
|
||||
@IsOptional() @IsNumber() @Min(0) @Max(999.99) score?: number | null;
|
||||
}
|
||||
104
apps/server/src/exams/exams.controller.ts
Normal file
104
apps/server/src/exams/exams.controller.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Request,
|
||||
UseGuards,
|
||||
UsePipes,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||
import { extractRequestInfo } from '../common/request-utils';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
import type { AuthenticatedUser } from '../authorization';
|
||||
import { CreateExamDto, QueryExamDto, UpdateExamScoreValueDto } from './dto/exam.dto';
|
||||
import { ExamsService } from './exams.service';
|
||||
|
||||
interface AuthenticatedRequest {
|
||||
user: AuthenticatedUser;
|
||||
ip?: string;
|
||||
headers?: Record<string, string | string[] | undefined>;
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }))
|
||||
@Controller('exams')
|
||||
export class ExamsController {
|
||||
constructor(
|
||||
private readonly service: ExamsService,
|
||||
private readonly logService: OperationLogsService,
|
||||
) {}
|
||||
|
||||
private canManageAll(req: AuthenticatedRequest) {
|
||||
return req.user.isSuperAdmin || req.user.permissions.includes('exam:edit');
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('exam:view')
|
||||
async findAll(@Query() query: QueryExamDto, @Request() req: AuthenticatedRequest) {
|
||||
const classIds = await this.service.getAccessibleClassIds(req.user.id, this.canManageAll(req));
|
||||
return this.service.findAll(query, classIds);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('exam:view')
|
||||
findOne(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||
return this.service.findOne(id, req.user.id, this.canManageAll(req));
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermission('exam:view')
|
||||
async create(@Body() dto: CreateExamDto, @Request() req: AuthenticatedRequest) {
|
||||
const result = await this.service.create(dto, req.user.id, this.canManageAll(req));
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
await this.logService.log({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
module: '考试管理',
|
||||
action: '创建考试',
|
||||
targetId: result.id,
|
||||
targetType: 'exam',
|
||||
detail: `${dto.examName} - ${dto.subject}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Put(':examId/scores/:scoreId')
|
||||
@RequirePermission('exam:view')
|
||||
async updateScore(
|
||||
@Param('examId', ParseIntPipe) examId: number,
|
||||
@Param('scoreId', ParseIntPipe) scoreId: number,
|
||||
@Body() dto: UpdateExamScoreValueDto,
|
||||
@Request() req: AuthenticatedRequest,
|
||||
) {
|
||||
const result = await this.service.updateScore(
|
||||
examId,
|
||||
scoreId,
|
||||
dto.score,
|
||||
req.user.id,
|
||||
this.canManageAll(req),
|
||||
);
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
await this.logService.log({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
module: '考试管理',
|
||||
action: dto.score === null || dto.score === undefined ? '清空成绩' : '录入成绩',
|
||||
targetId: scoreId,
|
||||
targetType: 'exam_score',
|
||||
detail: dto.score === null || dto.score === undefined ? '成绩已清空' : `成绩:${dto.score}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
16
apps/server/src/exams/exams.module.ts
Normal file
16
apps/server/src/exams/exams.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Class, ClassStudent, ClassTeacher, Exam, ExamScore, Student } from '../entities';
|
||||
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
||||
import { ExamsController } from './exams.controller';
|
||||
import { ExamsService } from './exams.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Exam, ExamScore, Class, ClassStudent, ClassTeacher, Student]),
|
||||
OperationLogsModule,
|
||||
],
|
||||
controllers: [ExamsController],
|
||||
providers: [ExamsService],
|
||||
})
|
||||
export class ExamsModule {}
|
||||
127
apps/server/src/exams/exams.service.spec.ts
Normal file
127
apps/server/src/exams/exams.service.spec.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { ExamScore } from '../entities';
|
||||
import { ExamsService } from './exams.service';
|
||||
|
||||
function createService(transaction: (run: (manager: any) => Promise<unknown>) => Promise<unknown>) {
|
||||
return new ExamsService(
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ findOne: jest.fn().mockResolvedValue({ id: 1 }) } as never,
|
||||
{ transaction } as never,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ExamsService', () => {
|
||||
it('creates score rows from the active class roster snapshot', async () => {
|
||||
const members = [
|
||||
{ studentId: 11, status: 'active' },
|
||||
{ studentId: 12, status: 'active' },
|
||||
];
|
||||
const manager = {
|
||||
findOne: jest.fn().mockResolvedValue({ id: 3, isArchived: false }),
|
||||
find: jest.fn().mockResolvedValue(members),
|
||||
create: jest.fn((_entity, value) => value),
|
||||
save: jest.fn(async (entity, value) =>
|
||||
entity.name === 'Exam' ? { ...value, id: 9 } : value,
|
||||
),
|
||||
};
|
||||
const service = createService(async (run) => run(manager));
|
||||
|
||||
await service.create(
|
||||
{
|
||||
examType: '月考',
|
||||
examName: '七月月考',
|
||||
subject: '数学',
|
||||
examDate: '2026-07-21',
|
||||
classId: 3,
|
||||
},
|
||||
1,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(manager.find).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ where: { classId: 3, status: 'active' } }),
|
||||
);
|
||||
expect(manager.save).toHaveBeenCalledWith(
|
||||
ExamScore,
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ examId: 9, studentId: 11, score: null }),
|
||||
expect.objectContaining({ examId: 9, studentId: 12, score: null }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects creating an exam for an empty class', async () => {
|
||||
const manager = {
|
||||
findOne: jest.fn().mockResolvedValue({ id: 3, isArchived: false }),
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
const service = createService(async (run) => run(manager));
|
||||
|
||||
await expect(
|
||||
service.create(
|
||||
{
|
||||
examType: '月考',
|
||||
examName: '七月月考',
|
||||
subject: '数学',
|
||||
examDate: '2026-07-21',
|
||||
classId: 3,
|
||||
},
|
||||
1,
|
||||
true,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('counts zero, ignores empty scores, and uses competition ranking', async () => {
|
||||
const rows = [
|
||||
{ id: 1, score: 90, classAvg: null, rank: null },
|
||||
{ id: 2, score: 90, classAvg: null, rank: null },
|
||||
{ id: 3, score: 60, classAvg: null, rank: null },
|
||||
{ id: 4, score: 0, classAvg: null, rank: null },
|
||||
{ id: 5, score: null, classAvg: null, rank: null },
|
||||
];
|
||||
const manager = {
|
||||
findOne: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 8, classId: 3, status: 'active' })
|
||||
.mockResolvedValueOnce(rows[0])
|
||||
.mockResolvedValueOnce(rows[0]),
|
||||
find: jest.fn().mockResolvedValue(rows),
|
||||
save: jest.fn(async (_entity, value) => value),
|
||||
};
|
||||
const service = createService(async (run) => run(manager));
|
||||
|
||||
await service.updateScore(8, 1, 90, 1, true);
|
||||
|
||||
expect(rows.map((row) => row.rank)).toEqual([1, 1, 3, 4, null]);
|
||||
expect(rows.map((row) => row.classAvg)).toEqual([60, 60, 60, 60, 60]);
|
||||
});
|
||||
|
||||
it('clears a score and recalculates the remaining rows', async () => {
|
||||
const rows = [
|
||||
{ id: 1, score: 90, classAvg: 80, rank: 1 },
|
||||
{ id: 2, score: 70, classAvg: 80, rank: 2 },
|
||||
];
|
||||
const manager = {
|
||||
findOne: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 8, classId: 3, status: 'active' })
|
||||
.mockResolvedValueOnce(rows[0])
|
||||
.mockResolvedValueOnce(rows[0]),
|
||||
find: jest.fn().mockResolvedValue(rows),
|
||||
save: jest.fn(async (_entity, value) => value),
|
||||
};
|
||||
const service = createService(async (run) => run(manager));
|
||||
|
||||
await service.updateScore(8, 1, null, 1, true);
|
||||
|
||||
expect(rows).toEqual([
|
||||
expect.objectContaining({ score: null, classAvg: 70, rank: null }),
|
||||
expect.objectContaining({ score: 70, classAvg: 70, rank: 1 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
182
apps/server/src/exams/exams.service.ts
Normal file
182
apps/server/src/exams/exams.service.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, EntityManager, In, Like, Repository } from 'typeorm';
|
||||
import { Class, ClassStudent, ClassTeacher, Exam, ExamScore } from '../entities';
|
||||
import { CreateExamDto, QueryExamDto } from './dto/exam.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ExamsService {
|
||||
constructor(
|
||||
@InjectRepository(Exam) private examRepo: Repository<Exam>,
|
||||
@InjectRepository(ExamScore) private scoreRepo: Repository<ExamScore>,
|
||||
@InjectRepository(Class) private classRepo: Repository<Class>,
|
||||
@InjectRepository(ClassStudent) private classStudentRepo: Repository<ClassStudent>,
|
||||
@InjectRepository(ClassTeacher) private classTeacherRepo: Repository<ClassTeacher>,
|
||||
private dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async getAccessibleClassIds(userId: number, canManageAll: boolean) {
|
||||
if (canManageAll) return undefined;
|
||||
const rows = await this.classTeacherRepo.find({ where: { userId } });
|
||||
return [...new Set(rows.map((row) => row.classId))];
|
||||
}
|
||||
|
||||
async assertClassAccess(userId: number, classId: number, canManageAll: boolean) {
|
||||
if (canManageAll) return;
|
||||
const assignment = await this.classTeacherRepo.findOne({ where: { userId, classId } });
|
||||
if (!assignment) throw new ForbiddenException('只能访问自己被分配的班级');
|
||||
}
|
||||
|
||||
async findAll(query: QueryExamDto, accessibleClassIds?: number[]) {
|
||||
const where: Record<string, unknown> = {
|
||||
status: 'active',
|
||||
};
|
||||
if (query.keyword) where.examName = Like(`%${query.keyword}%`);
|
||||
if (query.examType) where.examType = query.examType;
|
||||
if (query.classId) where.classId = query.classId;
|
||||
if (accessibleClassIds) {
|
||||
if (accessibleClassIds.length === 0) return [];
|
||||
where.classId = query.classId
|
||||
? accessibleClassIds.includes(query.classId)
|
||||
? query.classId
|
||||
: -1
|
||||
: In(accessibleClassIds);
|
||||
}
|
||||
|
||||
const exams = await this.examRepo.find({
|
||||
where,
|
||||
relations: ['class'],
|
||||
order: { examDate: 'DESC', createdAt: 'DESC' },
|
||||
});
|
||||
if (exams.length === 0) return [];
|
||||
const scoreRows = await this.scoreRepo.find({
|
||||
where: { examId: In(exams.map((exam) => exam.id)), status: 'active' },
|
||||
});
|
||||
const progress = new Map<number, { total: number; entered: number }>();
|
||||
for (const row of scoreRows) {
|
||||
const item = progress.get(row.examId!) ?? { total: 0, entered: 0 };
|
||||
item.total++;
|
||||
if (row.score !== null && row.score !== undefined) item.entered++;
|
||||
progress.set(row.examId!, item);
|
||||
}
|
||||
return exams.map((exam) => ({
|
||||
...exam,
|
||||
className: exam.class?.name,
|
||||
totalStudents: progress.get(exam.id)?.total ?? 0,
|
||||
enteredScores: progress.get(exam.id)?.entered ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
async findOne(id: number, userId: number, canManageAll: boolean) {
|
||||
const exam = await this.examRepo.findOne({ where: { id }, relations: ['class'] });
|
||||
if (!exam) throw new NotFoundException('考试不存在');
|
||||
await this.assertClassAccess(userId, exam.classId, canManageAll);
|
||||
const scores = await this.scoreRepo.find({
|
||||
where: { examId: id, status: 'active' },
|
||||
relations: ['student'],
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
return {
|
||||
...exam,
|
||||
className: exam.class?.name,
|
||||
totalStudents: scores.length,
|
||||
enteredScores: scores.filter((row) => row.score !== null && row.score !== undefined).length,
|
||||
scores: scores.map((row) => ({
|
||||
id: row.id,
|
||||
studentId: row.studentId,
|
||||
phone: row.student?.phone,
|
||||
name: row.student?.name,
|
||||
score: row.score === null ? null : Number(row.score),
|
||||
classAvg: row.classAvg === null ? null : Number(row.classAvg),
|
||||
rank: row.rank,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async create(dto: CreateExamDto, userId: number, canManageAll: boolean) {
|
||||
await this.assertClassAccess(userId, dto.classId, canManageAll);
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const cls = await manager.findOne(Class, { where: { id: dto.classId } });
|
||||
if (!cls) throw new NotFoundException('班级不存在');
|
||||
if (cls.isArchived) throw new BadRequestException('归档班级不能创建考试');
|
||||
const members = await manager.find(ClassStudent, {
|
||||
where: { classId: dto.classId, status: 'active' },
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
if (members.length === 0) throw new BadRequestException('班级暂无在读学员,不能创建考试');
|
||||
const exam = await manager.save(Exam, manager.create(Exam, { ...dto, status: 'active' }));
|
||||
await this.createScoreRows(manager, exam, members);
|
||||
return exam;
|
||||
});
|
||||
}
|
||||
|
||||
async updateScore(
|
||||
examId: number,
|
||||
scoreId: number,
|
||||
score: number | null | undefined,
|
||||
userId: number,
|
||||
canManageAll: boolean,
|
||||
) {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const exam = await manager.findOne(Exam, { where: { id: examId } });
|
||||
if (!exam) throw new NotFoundException('考试不存在');
|
||||
await this.assertClassAccess(userId, exam.classId, canManageAll);
|
||||
if (exam.status === 'archived') throw new BadRequestException('已归档考试不能录入成绩');
|
||||
const row = await manager.findOne(ExamScore, { where: { id: scoreId, examId } });
|
||||
if (!row) throw new NotFoundException('成绩记录不存在');
|
||||
row.score = score === undefined ? null : score;
|
||||
await manager.save(ExamScore, row);
|
||||
await this.recalculate(manager, examId);
|
||||
return manager.findOne(ExamScore, { where: { id: scoreId } });
|
||||
});
|
||||
}
|
||||
|
||||
private async createScoreRows(manager: EntityManager, exam: Exam, members: ClassStudent[]) {
|
||||
const rows = members.map((member) =>
|
||||
manager.create(ExamScore, {
|
||||
examId: exam.id,
|
||||
studentId: member.studentId,
|
||||
examType: exam.examType,
|
||||
examName: exam.examName,
|
||||
subject: exam.subject,
|
||||
score: null,
|
||||
classAvg: null,
|
||||
rank: null,
|
||||
examDate: exam.examDate,
|
||||
status: 'active',
|
||||
}),
|
||||
);
|
||||
await manager.save(ExamScore, rows);
|
||||
}
|
||||
|
||||
private async recalculate(manager: EntityManager, examId: number) {
|
||||
const rows = await manager.find(ExamScore, { where: { examId, status: 'active' } });
|
||||
const entered = rows
|
||||
.filter((row) => row.score !== null && row.score !== undefined)
|
||||
.sort((a, b) => Number(b.score) - Number(a.score));
|
||||
const average =
|
||||
entered.length === 0
|
||||
? null
|
||||
: Math.round((entered.reduce((sum, row) => sum + Number(row.score), 0) / entered.length) * 100) /
|
||||
100;
|
||||
let previousScore: number | null = null;
|
||||
let previousRank = 0;
|
||||
for (let index = 0; index < entered.length; index++) {
|
||||
const row = entered[index];
|
||||
const value = Number(row.score);
|
||||
const rank = previousScore !== null && value === previousScore ? previousRank : index + 1;
|
||||
row.classAvg = average;
|
||||
row.rank = rank;
|
||||
previousScore = value;
|
||||
previousRank = rank;
|
||||
}
|
||||
const enteredIds = new Set(entered.map((row) => row.id));
|
||||
for (const row of rows) {
|
||||
if (!enteredIds.has(row.id)) {
|
||||
row.classAvg = average;
|
||||
row.rank = null;
|
||||
}
|
||||
}
|
||||
await manager.save(ExamScore, rows);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { InitialSchema1784520727860 } from './migrations/1784520727860-InitialSchema';
|
||||
import { AddExamManagement1784600000000 } from './migrations/1784600000000-AddExamManagement';
|
||||
import { config } from 'dotenv';
|
||||
|
||||
config();
|
||||
@@ -18,7 +19,7 @@ export async function runMigrationsOnStartup(): Promise<void> {
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_DATABASE || 'dorm_billing',
|
||||
charset: 'utf8mb4',
|
||||
migrations: [InitialSchema1784520727860],
|
||||
migrations: [InitialSchema1784520727860, AddExamManagement1784600000000],
|
||||
});
|
||||
|
||||
await ds.initialize();
|
||||
|
||||
@@ -21,7 +21,7 @@ export class InitialSchema1784520727860 implements MigrationInterface {
|
||||
await queryRunner.query(`CREATE TABLE \`bills\` (\`id\` int NOT NULL AUTO_INCREMENT, \`student_id\` int NOT NULL, \`period_start\` date NOT NULL, \`period_end\` date NOT NULL, \`shared_amount\` decimal(10,2) NOT NULL DEFAULT '0.00', \`personal_amount\` decimal(10,2) NOT NULL DEFAULT '0.00', \`total_amount\` decimal(10,2) NOT NULL DEFAULT '0.00', \`source\` varchar(30) NOT NULL DEFAULT 'batch', \`paid_amount\` decimal(10,2) NOT NULL DEFAULT '0.00', \`outstanding_amount\` decimal(10,2) NOT NULL DEFAULT '0.00', \`status\` varchar(20) NOT NULL DEFAULT 'unpaid', \`cancelled_at\` datetime NULL, \`cancel_reason\` varchar(300) NULL, \`generated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`);
|
||||
await queryRunner.query(`CREATE TABLE \`students\` (\`id\` int NOT NULL AUTO_INCREMENT, \`name\` varchar(50) NOT NULL, \`student_no\` varchar(30) NULL, \`phone\` varchar(20) NULL, \`id_number\` varchar(30) NULL, \`gender\` varchar(10) NULL, \`ethnicity\` varchar(20) NULL, \`emergency_contact\` varchar(50) NULL, \`emergency_phone\` varchar(20) NULL, \`status\` varchar(20) NOT NULL DEFAULT 'active', \`supervisor\` varchar(50) NULL, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`user_id\` int NULL, \`organization_id\` int NULL, UNIQUE INDEX \`IDX_fb3eff90b11bddf7285f9b4e28\` (\`user_id\`), UNIQUE INDEX \`REL_fb3eff90b11bddf7285f9b4e28\` (\`user_id\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`);
|
||||
await queryRunner.query(`CREATE TABLE \`student_wallets\` (\`id\` int NOT NULL AUTO_INCREMENT, \`student_id\` int NOT NULL, \`balance\` decimal(12,2) NOT NULL DEFAULT '0.00', \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX \`IDX_07a434ad1a960d506386754d59\` (\`student_id\`), UNIQUE INDEX \`REL_07a434ad1a960d506386754d59\` (\`student_id\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`);
|
||||
await queryRunner.query(`CREATE TABLE \`student_profiles\` (\`id\` int NOT NULL AUTO_INCREMENT, \`student_id\` int NOT NULL, \`target_college\` varchar(100) NULL, \`target_major\` varchar(100) NULL, \`subject_direction\` varchar(50) NULL, \`grade\` varchar(20) NULL, \`profile_date\` date NULL, \`notes\` text NULL, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX \`IDX_4cedc08d3dc1f2c2da8a12f7a8\` (\`student_id\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`);
|
||||
await queryRunner.query(`CREATE TABLE \`student_profiles\` (\`id\` int NOT NULL AUTO_INCREMENT, \`student_id\` int NOT NULL, \`target_college\` varchar(100) NULL, \`target_major\` varchar(100) NULL, \`college_school\` varchar(100) NULL, \`college_major\` varchar(100) NULL, \`subject_direction\` varchar(50) NULL, \`grade\` varchar(20) NULL, \`profile_date\` date NULL, \`notes\` text NULL, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX \`IDX_4cedc08d3dc1f2c2da8a12f7a8\` (\`student_id\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`);
|
||||
await queryRunner.query(`CREATE TABLE \`student_enrollments\` (\`id\` int NOT NULL AUTO_INCREMENT, \`student_id\` int NOT NULL, \`course_category\` varchar(50) NULL, \`class_type\` varchar(50) NULL, \`class_name\` varchar(100) NULL, \`head_teacher\` varchar(50) NULL, \`subject_teacher\` varchar(50) NULL, \`start_date\` date NULL, \`end_date\` date NULL, \`status\` varchar(20) NOT NULL DEFAULT 'active', \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`);
|
||||
await queryRunner.query(`CREATE TABLE \`student_ding_mapping\` (\`id\` int NOT NULL AUTO_INCREMENT, \`ding_user_id\` varchar(100) NOT NULL, \`student_id\` int NOT NULL, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), UNIQUE INDEX \`IDX_0d1ec47e2f901d37e3b6e56331\` (\`ding_user_id\`), UNIQUE INDEX \`IDX_f9ba15ff04de8ffbd8679ae9db\` (\`student_id\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`);
|
||||
await queryRunner.query(`CREATE TABLE \`result_archives\` (\`id\` int NOT NULL AUTO_INCREMENT, \`student_id\` int NOT NULL, \`culture_final_score\` decimal(5,2) NULL, \`professional_final_score\` decimal(5,2) NULL, \`admission_status\` varchar(50) NULL, \`admitted_college\` varchar(100) NULL, \`admitted_major\` varchar(100) NULL, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX \`IDX_377bba8eb6a027eecd9737d4ed\` (\`student_id\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`);
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { MigrationInterface, QueryRunner, Table, TableColumn, TableForeignKey, TableIndex } from 'typeorm';
|
||||
|
||||
export class AddExamManagement1784600000000 implements MigrationInterface {
|
||||
async up(queryRunner: QueryRunner): Promise<void> {
|
||||
if (!(await queryRunner.hasTable('exams'))) {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'exams',
|
||||
columns: [
|
||||
{ name: 'id', type: 'integer', isPrimary: true, isGenerated: true, generationStrategy: 'increment' },
|
||||
{ name: 'exam_type', type: 'varchar', length: '50' },
|
||||
{ name: 'exam_name', type: 'varchar', length: '100' },
|
||||
{ name: 'subject', type: 'varchar', length: '50' },
|
||||
{ name: 'exam_date', type: 'date' },
|
||||
{ name: 'class_id', type: 'integer' },
|
||||
{ name: 'status', type: 'varchar', length: '20', default: "'active'" },
|
||||
{ name: 'created_at', type: 'datetime', default: 'CURRENT_TIMESTAMP' },
|
||||
{ name: 'updated_at', type: 'datetime', default: 'CURRENT_TIMESTAMP' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
await queryRunner.createForeignKey(
|
||||
'exams',
|
||||
new TableForeignKey({
|
||||
columnNames: ['class_id'],
|
||||
referencedTableName: 'classes',
|
||||
referencedColumnNames: ['id'],
|
||||
}),
|
||||
);
|
||||
await queryRunner.createIndex('exams', new TableIndex({ columnNames: ['class_id', 'status'] }));
|
||||
}
|
||||
|
||||
if (!(await queryRunner.hasColumn('exam_scores', 'exam_id'))) {
|
||||
await queryRunner.addColumn(
|
||||
'exam_scores',
|
||||
new TableColumn({ name: 'exam_id', type: 'integer', isNullable: true }),
|
||||
);
|
||||
await queryRunner.createForeignKey(
|
||||
'exam_scores',
|
||||
new TableForeignKey({
|
||||
columnNames: ['exam_id'],
|
||||
referencedTableName: 'exams',
|
||||
referencedColumnNames: ['id'],
|
||||
onDelete: 'CASCADE',
|
||||
}),
|
||||
);
|
||||
await queryRunner.createIndex('exam_scores', new TableIndex({ columnNames: ['exam_id'] }));
|
||||
}
|
||||
}
|
||||
|
||||
async down(queryRunner: QueryRunner): Promise<void> {
|
||||
if (await queryRunner.hasColumn('exam_scores', 'exam_id')) {
|
||||
const table = await queryRunner.getTable('exam_scores');
|
||||
const foreignKey = table?.foreignKeys.find((key) => key.columnNames.includes('exam_id'));
|
||||
if (foreignKey) await queryRunner.dropForeignKey('exam_scores', foreignKey);
|
||||
const index = table?.indices.find((item) => item.columnNames.includes('exam_id'));
|
||||
if (index) await queryRunner.dropIndex('exam_scores', index);
|
||||
await queryRunner.dropColumn('exam_scores', 'exam_id');
|
||||
}
|
||||
if (await queryRunner.hasTable('exams')) await queryRunner.dropTable('exams');
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> =
|
||||
{ code: 'student:delete', name: '归档学生', group: 'student' },
|
||||
{ code: 'student:import', name: '导入学生', group: 'student' },
|
||||
{ code: 'student:export', name: '导出学生', group: 'student' },
|
||||
{ code: 'exam:view', name: '查看和录入考试成绩', group: 'exam' },
|
||||
{ code: 'room:view', name: '查看宿舍', group: 'room' },
|
||||
{ code: 'room:create', name: '新增宿舍', group: 'room' },
|
||||
{ code: 'room:edit', name: '编辑宿舍', group: 'room' },
|
||||
@@ -203,6 +204,7 @@ export const PRESET_ROLES: Array<{
|
||||
isSystem: true,
|
||||
permissionGroups: [
|
||||
'student',
|
||||
'exam',
|
||||
'class',
|
||||
'schedule',
|
||||
'attendance',
|
||||
|
||||
97
apps/server/src/students/student-import.spec.ts
Normal file
97
apps/server/src/students/student-import.spec.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import * as ExcelJS from 'exceljs';
|
||||
import {
|
||||
createStudentImportTemplateWorkbook,
|
||||
parseStudentImportWorkbook,
|
||||
} from './student-import';
|
||||
|
||||
describe('student import workbook', () => {
|
||||
it('creates a template with one main example row and header-only detail sheets', () => {
|
||||
const workbook = createStudentImportTemplateWorkbook();
|
||||
const students = workbook.getWorksheet('学生基础+档案+录取');
|
||||
|
||||
expect(workbook.worksheets.map((sheet) => sheet.name)).toEqual([
|
||||
'学生基础+档案+录取',
|
||||
'报读班型',
|
||||
'考试成绩',
|
||||
'课堂回访',
|
||||
]);
|
||||
expect(students?.rowCount).toBe(2);
|
||||
const headerRow = students?.getRow(1);
|
||||
const columnByHeader = new Map<string, number>();
|
||||
headerRow?.eachCell((cell, colNumber) => columnByHeader.set(String(cell.value), colNumber));
|
||||
expect(students?.getRow(2).getCell(1).value).toBe('13800138000');
|
||||
expect(students?.getRow(2).getCell(columnByHeader.get('大专院校') || 0).value).toBe(
|
||||
'北京职业技术学院',
|
||||
);
|
||||
expect(students?.getRow(2).getCell(columnByHeader.get('大专专业') || 0).value).toBe(
|
||||
'软件技术',
|
||||
);
|
||||
expect(students?.getRow(2).getCell(columnByHeader.get('录取状态') || 0).value).toBe('pending');
|
||||
expect(students?.getRow(2).getCell(columnByHeader.get('录取院校') || 0).value || '').toBe('');
|
||||
expect(students?.getRow(2).getCell(columnByHeader.get('录取专业') || 0).value || '').toBe('');
|
||||
expect(workbook.getWorksheet('报读班型')?.rowCount).toBe(1);
|
||||
expect(workbook.getWorksheet('考试成绩')?.rowCount).toBe(1);
|
||||
expect(workbook.getWorksheet('课堂回访')?.rowCount).toBe(1);
|
||||
});
|
||||
|
||||
it('maps rows by headers across archive sheets', () => {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const students = workbook.addWorksheet('学生基础+档案+录取');
|
||||
students.addRow([
|
||||
'手机号*',
|
||||
'姓名*',
|
||||
'目标院校',
|
||||
'大专院校',
|
||||
'大专专业',
|
||||
'建档日期',
|
||||
'文化课最终分',
|
||||
'录取状态',
|
||||
]);
|
||||
students.addRow([
|
||||
'13800138000',
|
||||
'张三',
|
||||
'北京大学',
|
||||
'北京职业技术学院',
|
||||
'软件技术',
|
||||
'2024/9/1',
|
||||
620,
|
||||
'pending',
|
||||
]);
|
||||
|
||||
const enrollments = workbook.addWorksheet('报读班型');
|
||||
enrollments.addRow(['手机号*', '课程类别*', '班型*', '班级名称']);
|
||||
enrollments.addRow(['13800138000', 'culture', 'one_on_one', '冲刺班']);
|
||||
|
||||
const examScores = workbook.addWorksheet('考试成绩');
|
||||
examScores.addRow(['手机号*', '考试类型*', '科目*', '成绩*', '考试日期']);
|
||||
examScores.addRow(['13800138000', 'monthly', '语文', '108.5', '2024-10-15']);
|
||||
|
||||
const learningRecords = workbook.addWorksheet('课堂回访');
|
||||
learningRecords.addRow(['手机号*', '记录日期*', '记录类型*', '内容*']);
|
||||
learningRecords.addRow(['13800138000', '2024-10-20', 'study_feedback', '状态稳定']);
|
||||
|
||||
const parsed = parseStudentImportWorkbook(workbook);
|
||||
|
||||
expect(parsed.students[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
phone: '13800138000',
|
||||
name: '张三',
|
||||
targetCollege: '北京大学',
|
||||
collegeSchool: '北京职业技术学院',
|
||||
collegeMajor: '软件技术',
|
||||
profileDate: '2024-09-01',
|
||||
cultureFinalScore: 620,
|
||||
admissionStatus: 'pending',
|
||||
}),
|
||||
);
|
||||
expect(parsed.enrollments[0]).toEqual(
|
||||
expect.objectContaining({ courseCategory: 'culture', classType: 'one_on_one' }),
|
||||
);
|
||||
expect(parsed.examScores[0]).toEqual(
|
||||
expect.objectContaining({ score: 108.5, examDate: '2024-10-15' }),
|
||||
);
|
||||
expect(parsed.learningRecords[0]).toEqual(
|
||||
expect.objectContaining({ recordType: 'study_feedback', content: '状态稳定' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
325
apps/server/src/students/student-import.ts
Normal file
325
apps/server/src/students/student-import.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
import * as ExcelJS from 'exceljs';
|
||||
|
||||
export interface StudentImportRow {
|
||||
name: string;
|
||||
studentNo?: string;
|
||||
phone?: string;
|
||||
idNumber?: string;
|
||||
gender?: string;
|
||||
ethnicity?: string;
|
||||
emergencyContact?: string;
|
||||
emergencyPhone?: string;
|
||||
organization?: string;
|
||||
supervisor?: string;
|
||||
organizationId?: number;
|
||||
targetCollege?: string;
|
||||
targetMajor?: string;
|
||||
collegeSchool?: string;
|
||||
collegeMajor?: string;
|
||||
subjectDirection?: string;
|
||||
grade?: string;
|
||||
profileDate?: string;
|
||||
notes?: string;
|
||||
cultureFinalScore?: number;
|
||||
professionalFinalScore?: number;
|
||||
admissionStatus?: string;
|
||||
admittedCollege?: string;
|
||||
admittedMajor?: string;
|
||||
}
|
||||
|
||||
export interface StudentEnrollmentImportRow {
|
||||
phone: string;
|
||||
name?: string;
|
||||
courseCategory: string;
|
||||
classType: string;
|
||||
className?: string;
|
||||
headTeacher?: string;
|
||||
subjectTeacher?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface ExamScoreImportRow {
|
||||
phone: string;
|
||||
name?: string;
|
||||
examType: string;
|
||||
examName?: string;
|
||||
subject: string;
|
||||
score?: number;
|
||||
classAvg?: number;
|
||||
rank?: number;
|
||||
examDate?: string;
|
||||
enrollmentName?: string;
|
||||
}
|
||||
|
||||
export interface LearningRecordImportRow {
|
||||
phone: string;
|
||||
name?: string;
|
||||
recordDate: string;
|
||||
recordType: string;
|
||||
content: string;
|
||||
followUpMethod?: string;
|
||||
nextStep?: string;
|
||||
}
|
||||
|
||||
export interface StudentWorkbookImport {
|
||||
students: StudentImportRow[];
|
||||
enrollments: StudentEnrollmentImportRow[];
|
||||
examScores: ExamScoreImportRow[];
|
||||
learningRecords: LearningRecordImportRow[];
|
||||
}
|
||||
|
||||
type ColumnDef<T> = {
|
||||
header: string;
|
||||
key: keyof T;
|
||||
width: number;
|
||||
aliases?: string[];
|
||||
kind?: 'text' | 'date' | 'number' | 'integer';
|
||||
};
|
||||
|
||||
export const STUDENT_IMPORT_COLUMNS: ColumnDef<StudentImportRow>[] = [
|
||||
{ header: '手机号*', key: 'phone', width: 18, aliases: ['手机号', '电话'] },
|
||||
{ header: '姓名*', key: 'name', width: 15, aliases: ['姓名'] },
|
||||
{ header: '学号', key: 'studentNo', width: 15 },
|
||||
{ header: '性别', key: 'gender', width: 8 },
|
||||
{ header: '身份证号', key: 'idNumber', width: 22, aliases: ['身份证', '学号/身份证'] },
|
||||
{ header: '民族', key: 'ethnicity', width: 10 },
|
||||
{ header: '紧急联系人', key: 'emergencyContact', width: 15 },
|
||||
{ header: '紧急联系人电话', key: 'emergencyPhone', width: 18 },
|
||||
{ header: '所属机构', key: 'organization', width: 18, aliases: ['所属机构名称'] },
|
||||
{ header: '负责人', key: 'supervisor', width: 15, aliases: ['负责人/班主任'] },
|
||||
{ header: '目标院校', key: 'targetCollege', width: 18 },
|
||||
{ header: '目标专业', key: 'targetMajor', width: 22 },
|
||||
{ header: '大专院校', key: 'collegeSchool', width: 18 },
|
||||
{ header: '大专专业', key: 'collegeMajor', width: 22 },
|
||||
{ header: '选科方向', key: 'subjectDirection', width: 14 },
|
||||
{ header: '年级', key: 'grade', width: 10 },
|
||||
{ header: '建档日期', key: 'profileDate', width: 14, kind: 'date' },
|
||||
{ header: '档案备注', key: 'notes', width: 28 },
|
||||
{ header: '文化课最终分', key: 'cultureFinalScore', width: 14, kind: 'number' },
|
||||
{ header: '专业课最终分', key: 'professionalFinalScore', width: 14, kind: 'number' },
|
||||
{ header: '录取状态', key: 'admissionStatus', width: 14 },
|
||||
{ header: '录取院校', key: 'admittedCollege', width: 18 },
|
||||
{ header: '录取专业', key: 'admittedMajor', width: 22 },
|
||||
];
|
||||
|
||||
export const STUDENT_EXPORT_COLUMNS = [
|
||||
...STUDENT_IMPORT_COLUMNS,
|
||||
];
|
||||
|
||||
const ENROLLMENT_IMPORT_COLUMNS: ColumnDef<StudentEnrollmentImportRow>[] = [
|
||||
{ header: '手机号*', key: 'phone', width: 18, aliases: ['手机号', '电话'] },
|
||||
{ header: '姓名', key: 'name', width: 15 },
|
||||
{ header: '课程类别*', key: 'courseCategory', width: 14, aliases: ['课程类别'] },
|
||||
{ header: '班型*', key: 'classType', width: 14, aliases: ['班型'] },
|
||||
{ header: '班级名称', key: 'className', width: 20 },
|
||||
{ header: '班主任', key: 'headTeacher', width: 15 },
|
||||
{ header: '任课教师', key: 'subjectTeacher', width: 15 },
|
||||
{ header: '开始日期', key: 'startDate', width: 14, kind: 'date' },
|
||||
{ header: '结束日期', key: 'endDate', width: 14, kind: 'date' },
|
||||
{ header: '状态', key: 'status', width: 12 },
|
||||
];
|
||||
|
||||
const EXAM_SCORE_IMPORT_COLUMNS: ColumnDef<ExamScoreImportRow>[] = [
|
||||
{ header: '手机号*', key: 'phone', width: 18, aliases: ['手机号', '电话'] },
|
||||
{ header: '姓名', key: 'name', width: 15 },
|
||||
{ header: '考试类型*', key: 'examType', width: 14, aliases: ['考试类型'] },
|
||||
{ header: '考试名称', key: 'examName', width: 20 },
|
||||
{ header: '科目*', key: 'subject', width: 14, aliases: ['科目'] },
|
||||
{ header: '成绩*', key: 'score', width: 12, aliases: ['成绩'], kind: 'number' },
|
||||
{ header: '班级均分', key: 'classAvg', width: 12, kind: 'number' },
|
||||
{ header: '排名', key: 'rank', width: 10, kind: 'integer' },
|
||||
{ header: '考试日期', key: 'examDate', width: 14, kind: 'date' },
|
||||
{ header: '关联报读(班级名)', key: 'enrollmentName', width: 22, aliases: ['关联报读(班级名)'] },
|
||||
];
|
||||
|
||||
const LEARNING_RECORD_IMPORT_COLUMNS: ColumnDef<LearningRecordImportRow>[] = [
|
||||
{ header: '手机号*', key: 'phone', width: 18, aliases: ['手机号', '电话'] },
|
||||
{ header: '姓名', key: 'name', width: 15 },
|
||||
{ header: '记录日期*', key: 'recordDate', width: 14, aliases: ['记录日期'], kind: 'date' },
|
||||
{ header: '记录类型*', key: 'recordType', width: 14, aliases: ['记录类型'] },
|
||||
{ header: '内容*', key: 'content', width: 36, aliases: ['内容'] },
|
||||
{ header: '跟进方式', key: 'followUpMethod', width: 14 },
|
||||
{ header: '下一步计划', key: 'nextStep', width: 28 },
|
||||
];
|
||||
|
||||
function normalizeHeader(header: string): string {
|
||||
return header.trim().replace(/\*+$/u, '').trim();
|
||||
}
|
||||
|
||||
function getCellPrimitiveValue(cell: ExcelJS.Cell): unknown {
|
||||
const value = cell.value;
|
||||
if (value === null || value === undefined) return undefined;
|
||||
if (value instanceof Date) return value;
|
||||
if (typeof value === 'object') {
|
||||
if ('result' in value) return value.result;
|
||||
if ('text' in value) return value.text;
|
||||
if ('richText' in value && Array.isArray(value.richText)) {
|
||||
return value.richText.map((part) => part.text).join('');
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function cellToText(cell: ExcelJS.Cell): string {
|
||||
const value = getCellPrimitiveValue(cell);
|
||||
if (value === null || value === undefined) return '';
|
||||
if (value instanceof Date) return formatDate(value);
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function excelSerialToDate(serial: number): string | undefined {
|
||||
if (!Number.isFinite(serial) || serial < 1) return undefined;
|
||||
const epoch = Date.UTC(1899, 11, 30);
|
||||
return formatDate(new Date(epoch + serial * 24 * 60 * 60 * 1000));
|
||||
}
|
||||
|
||||
function parseDateText(cell: ExcelJS.Cell): string | undefined {
|
||||
const value = getCellPrimitiveValue(cell);
|
||||
if (value instanceof Date) return formatDate(value);
|
||||
if (typeof value === 'number') return excelSerialToDate(value);
|
||||
const text = value === null || value === undefined ? '' : String(value).trim();
|
||||
if (!text) return undefined;
|
||||
const normalized = text.replace(/[/.]/g, '-');
|
||||
const match = normalized.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/u);
|
||||
if (!match) return text;
|
||||
return `${match[1]}-${match[2].padStart(2, '0')}-${match[3].padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function parseNumber(cell: ExcelJS.Cell): number | undefined {
|
||||
const text = cellToText(cell).replace(/,/g, '');
|
||||
if (!text) return undefined;
|
||||
const value = Number(text);
|
||||
return Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function buildHeaderMap<T extends object>(ws: ExcelJS.Worksheet, columns: ColumnDef<T>[]) {
|
||||
const columnByHeader = new Map<string, ColumnDef<T>>();
|
||||
for (const column of columns) {
|
||||
columnByHeader.set(normalizeHeader(column.header), column);
|
||||
for (const alias of column.aliases || []) {
|
||||
columnByHeader.set(normalizeHeader(alias), column);
|
||||
}
|
||||
}
|
||||
|
||||
const headerIndex = new Map<number, ColumnDef<T>>();
|
||||
ws.getRow(1).eachCell((cell, colNumber) => {
|
||||
const column = columnByHeader.get(normalizeHeader(cellToText(cell)));
|
||||
if (column) headerIndex.set(colNumber, column);
|
||||
});
|
||||
return headerIndex;
|
||||
}
|
||||
|
||||
function parseWorksheetRows<T extends object>(
|
||||
ws: ExcelJS.Worksheet | undefined,
|
||||
columns: ColumnDef<T>[],
|
||||
): T[] {
|
||||
if (!ws) return [];
|
||||
const headerIndex = buildHeaderMap(ws, columns);
|
||||
const rows: T[] = [];
|
||||
|
||||
ws.eachRow((row, idx) => {
|
||||
if (idx === 1) return;
|
||||
const parsed: Partial<Record<keyof T, string | number>> = {};
|
||||
headerIndex.forEach((column, colNumber) => {
|
||||
const cell = row.getCell(colNumber);
|
||||
const value =
|
||||
column.kind === 'date'
|
||||
? parseDateText(cell)
|
||||
: column.kind === 'number' || column.kind === 'integer'
|
||||
? parseNumber(cell)
|
||||
: cellToText(cell);
|
||||
if (value !== undefined && value !== '') {
|
||||
parsed[column.key] = column.kind === 'integer' && typeof value === 'number'
|
||||
? Math.trunc(value)
|
||||
: value;
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(parsed).length > 0) rows.push(parsed as T);
|
||||
});
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function findWorksheet(workbook: ExcelJS.Workbook, names: string[], fallbackIndex?: number) {
|
||||
for (const name of names) {
|
||||
const sheet = workbook.getWorksheet(name);
|
||||
if (sheet) return sheet;
|
||||
}
|
||||
return fallbackIndex === undefined ? undefined : workbook.worksheets[fallbackIndex];
|
||||
}
|
||||
|
||||
export function parseStudentImportWorkbook(workbook: ExcelJS.Workbook): StudentWorkbookImport {
|
||||
return {
|
||||
students: parseWorksheetRows(
|
||||
findWorksheet(workbook, ['学生基础+档案+录取', '学生导入模板', '学生名单'], 0),
|
||||
STUDENT_IMPORT_COLUMNS,
|
||||
).map((row) => ({ ...row, name: row.name || '', phone: row.phone || '' })),
|
||||
enrollments: parseWorksheetRows(
|
||||
findWorksheet(workbook, ['报读班型']),
|
||||
ENROLLMENT_IMPORT_COLUMNS,
|
||||
),
|
||||
examScores: parseWorksheetRows(
|
||||
findWorksheet(workbook, ['考试成绩']),
|
||||
EXAM_SCORE_IMPORT_COLUMNS,
|
||||
),
|
||||
learningRecords: parseWorksheetRows(
|
||||
findWorksheet(workbook, ['课堂回访']),
|
||||
LEARNING_RECORD_IMPORT_COLUMNS,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function applyHeaderStyle(ws: ExcelJS.Worksheet) {
|
||||
ws.getRow(1).font = { bold: true };
|
||||
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
||||
}
|
||||
|
||||
function setupWorksheet<T>(workbook: ExcelJS.Workbook, name: string, columns: ColumnDef<T>[]) {
|
||||
const ws = workbook.addWorksheet(name);
|
||||
ws.columns = columns.map(({ header, key, width }) => ({ header, key: String(key), width }));
|
||||
applyHeaderStyle(ws);
|
||||
return ws;
|
||||
}
|
||||
|
||||
export function createStudentImportTemplateWorkbook(): ExcelJS.Workbook {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
setupWorksheet(workbook, '学生基础+档案+录取', STUDENT_IMPORT_COLUMNS).addRow({
|
||||
phone: '13800138000',
|
||||
name: '张三',
|
||||
studentNo: '2024001',
|
||||
gender: '男',
|
||||
idNumber: '11010120060101001X',
|
||||
ethnicity: '汉族',
|
||||
emergencyContact: '张父',
|
||||
emergencyPhone: '13900139000',
|
||||
organization: '北京校区',
|
||||
supervisor: '李老师',
|
||||
targetCollege: '北京大学',
|
||||
targetMajor: '计算机科学与技术',
|
||||
collegeSchool: '北京职业技术学院',
|
||||
collegeMajor: '软件技术',
|
||||
subjectDirection: '物化生',
|
||||
grade: '高三',
|
||||
profileDate: '2024-09-01',
|
||||
notes: '学习态度积极,目标明确',
|
||||
cultureFinalScore: 620,
|
||||
professionalFinalScore: 580,
|
||||
admissionStatus: 'pending',
|
||||
admittedCollege: '',
|
||||
admittedMajor: '',
|
||||
});
|
||||
setupWorksheet(workbook, '报读班型', ENROLLMENT_IMPORT_COLUMNS);
|
||||
setupWorksheet(workbook, '考试成绩', EXAM_SCORE_IMPORT_COLUMNS);
|
||||
setupWorksheet(workbook, '课堂回访', LEARNING_RECORD_IMPORT_COLUMNS);
|
||||
return workbook;
|
||||
}
|
||||
@@ -62,6 +62,11 @@ function makeService(
|
||||
{} as never, // attendanceRepo
|
||||
{} as never, // classTeacherRepo
|
||||
{} as never, // organizationRepo
|
||||
{} as never, // profileRepo
|
||||
{} as never, // enrollmentRepo
|
||||
{} as never, // examScoreRepo
|
||||
{} as never, // learningRecordRepo
|
||||
{} as never, // resultRepo
|
||||
);
|
||||
|
||||
return { service, studentQb, classStudentQb };
|
||||
|
||||
@@ -30,127 +30,16 @@ import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||
import { AuthorizationService, CaslAction, SubjectName } from '../authorization';
|
||||
import type { AuthenticatedUser } from '../authorization';
|
||||
import * as ExcelJS from 'exceljs';
|
||||
import {
|
||||
createStudentImportTemplateWorkbook,
|
||||
parseStudentImportWorkbook,
|
||||
STUDENT_EXPORT_COLUMNS,
|
||||
} from './student-import';
|
||||
|
||||
interface AuthenticatedRequest {
|
||||
user: AuthenticatedUser;
|
||||
}
|
||||
|
||||
|
||||
interface StudentImportRow {
|
||||
name: string;
|
||||
studentNo?: string;
|
||||
phone?: string;
|
||||
idNumber?: string;
|
||||
gender?: string;
|
||||
ethnicity?: string;
|
||||
emergencyContact?: string;
|
||||
emergencyPhone?: string;
|
||||
organization?: string;
|
||||
supervisor?: string;
|
||||
organizationId?: number;
|
||||
}
|
||||
|
||||
const STUDENT_IMPORT_COLUMNS = [
|
||||
{ header: '姓名', key: 'name', width: 15 },
|
||||
{ header: '学号', key: 'studentNo', width: 15 },
|
||||
{ header: '性别', key: 'gender', width: 8 },
|
||||
{ header: '电话', key: 'phone', width: 18 },
|
||||
{ header: '身份证号', key: 'idNumber', width: 22 },
|
||||
{ header: '民族', key: 'ethnicity', width: 10 },
|
||||
{ header: '紧急联系人', key: 'emergencyContact', width: 15 },
|
||||
{ header: '紧急联系人电话', key: 'emergencyPhone', width: 18 },
|
||||
{ header: '所属机构名称', key: 'organization', width: 18 },
|
||||
{ header: '负责人/班主任', key: 'supervisor', width: 15 },
|
||||
];
|
||||
|
||||
const STUDENT_EXPORT_COLUMNS = [
|
||||
...STUDENT_IMPORT_COLUMNS.map((column) => ({
|
||||
...column,
|
||||
header: column.key === 'organization' ? '所属机构' : column.header,
|
||||
})),
|
||||
{ header: '状态', key: 'status', width: 10 },
|
||||
];
|
||||
|
||||
const STUDENT_IMPORT_HEADER_MAP: Record<string, keyof StudentImportRow> = {
|
||||
姓名: 'name',
|
||||
学号: 'studentNo',
|
||||
电话: 'phone',
|
||||
手机号: 'phone',
|
||||
'学号/身份证': 'idNumber',
|
||||
身份证: 'idNumber',
|
||||
身份证号: 'idNumber',
|
||||
性别: 'gender',
|
||||
民族: 'ethnicity',
|
||||
紧急联系人: 'emergencyContact',
|
||||
紧急联系人电话: 'emergencyPhone',
|
||||
所属机构: 'organization',
|
||||
所属机构名称: 'organization',
|
||||
负责人: 'supervisor',
|
||||
'负责人/班主任': 'supervisor',
|
||||
};
|
||||
|
||||
function getExcelCellText(cell: ExcelJS.Cell): string {
|
||||
const value = cell.value;
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'object') {
|
||||
if ('text' in value) return String(value.text || '');
|
||||
if ('richText' in value && Array.isArray(value.richText)) {
|
||||
return value.richText.map((part) => part.text).join('');
|
||||
}
|
||||
if ('result' in value) return String(value.result || '');
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function parseStudentImportRows(ws: ExcelJS.Worksheet): StudentImportRow[] {
|
||||
const headerIndex = new Map<number, keyof StudentImportRow>();
|
||||
ws.getRow(1).eachCell((cell, colNumber) => {
|
||||
const header = getExcelCellText(cell).trim();
|
||||
const field = STUDENT_IMPORT_HEADER_MAP[header];
|
||||
if (field) headerIndex.set(colNumber, field);
|
||||
});
|
||||
|
||||
const rows: StudentImportRow[] = [];
|
||||
ws.eachRow((row, idx) => {
|
||||
if (idx === 1) return;
|
||||
|
||||
const parsed: Partial<StudentImportRow> = {};
|
||||
if (headerIndex.size > 0) {
|
||||
headerIndex.forEach((field, colNumber) => {
|
||||
const value = getExcelCellText(row.getCell(colNumber)).trim();
|
||||
if (value) {
|
||||
Object.assign(parsed, { [field]: value });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
parsed.name = getExcelCellText(row.getCell(1)).trim();
|
||||
parsed.studentNo = getExcelCellText(row.getCell(2)).trim() || undefined;
|
||||
parsed.gender = getExcelCellText(row.getCell(3)).trim() || undefined;
|
||||
parsed.phone = getExcelCellText(row.getCell(4)).trim() || undefined;
|
||||
parsed.idNumber = getExcelCellText(row.getCell(5)).trim() || undefined;
|
||||
parsed.ethnicity = getExcelCellText(row.getCell(6)).trim() || undefined;
|
||||
parsed.emergencyContact = getExcelCellText(row.getCell(7)).trim() || undefined;
|
||||
parsed.emergencyPhone = getExcelCellText(row.getCell(8)).trim() || undefined;
|
||||
parsed.organization = getExcelCellText(row.getCell(9)).trim() || undefined;
|
||||
parsed.supervisor = getExcelCellText(row.getCell(10)).trim() || undefined;
|
||||
}
|
||||
|
||||
rows.push({
|
||||
name: parsed.name || '',
|
||||
studentNo: parsed.studentNo,
|
||||
phone: parsed.phone,
|
||||
idNumber: parsed.idNumber,
|
||||
gender: parsed.gender,
|
||||
ethnicity: parsed.ethnicity,
|
||||
emergencyContact: parsed.emergencyContact,
|
||||
emergencyPhone: parsed.emergencyPhone,
|
||||
organization: parsed.organization,
|
||||
supervisor: parsed.supervisor,
|
||||
});
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('students')
|
||||
export class StudentsController {
|
||||
@@ -218,25 +107,36 @@ export class StudentsController {
|
||||
ws.columns = STUDENT_EXPORT_COLUMNS;
|
||||
ws.getRow(1).font = { bold: true };
|
||||
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
||||
const statusMap: Record<string, string> = {
|
||||
active: '在读',
|
||||
graduated: '已毕业',
|
||||
withdrawn: '已退训',
|
||||
archived: '已归档',
|
||||
};
|
||||
const { profiles, results } = await this.service.getArchiveExportMaps(
|
||||
students.map((student) => student.id),
|
||||
);
|
||||
for (const s of students) {
|
||||
const profile = profiles.get(s.id);
|
||||
const result = results.get(s.id);
|
||||
ws.addRow({
|
||||
phone: s.phone || '',
|
||||
name: s.name,
|
||||
studentNo: s.studentNo || '',
|
||||
gender: s.gender || '',
|
||||
phone: s.phone || '',
|
||||
idNumber: s.idNumber || '',
|
||||
ethnicity: s.ethnicity || '',
|
||||
emergencyContact: s.emergencyContact || '',
|
||||
emergencyPhone: s.emergencyPhone || '',
|
||||
organization: s.organization?.name || '',
|
||||
supervisor: s.supervisor || '',
|
||||
status: statusMap[s.status] || s.status,
|
||||
targetCollege: profile?.targetCollege || '',
|
||||
targetMajor: profile?.targetMajor || '',
|
||||
collegeSchool: profile?.collegeSchool || '',
|
||||
collegeMajor: profile?.collegeMajor || '',
|
||||
subjectDirection: profile?.subjectDirection || '',
|
||||
grade: profile?.grade || '',
|
||||
profileDate: profile?.profileDate || '',
|
||||
notes: profile?.notes || '',
|
||||
cultureFinalScore: result?.cultureFinalScore ?? '',
|
||||
professionalFinalScore: result?.professionalFinalScore ?? '',
|
||||
admissionStatus: result?.admissionStatus || '',
|
||||
admittedCollege: result?.admittedCollege || '',
|
||||
admittedMajor: result?.admittedMajor || '',
|
||||
});
|
||||
}
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
@@ -261,23 +161,7 @@ export class StudentsController {
|
||||
@Get('template')
|
||||
@RequirePermission('student:view')
|
||||
async downloadTemplate(@Res() res: Response) {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const ws = workbook.addWorksheet('学生导入模板');
|
||||
ws.columns = STUDENT_IMPORT_COLUMNS;
|
||||
ws.getRow(1).font = { bold: true };
|
||||
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
||||
ws.addRow({
|
||||
name: '张三',
|
||||
studentNo: '2024001',
|
||||
gender: '男',
|
||||
phone: '13800138000',
|
||||
idNumber: '11010120060101001X',
|
||||
ethnicity: '汉族',
|
||||
emergencyContact: '张父',
|
||||
emergencyPhone: '13900000000',
|
||||
organization: 'XX教育公司',
|
||||
supervisor: '',
|
||||
});
|
||||
const workbook = createStudentImportTemplateWorkbook();
|
||||
res.setHeader(
|
||||
'Content-Type',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
@@ -391,10 +275,9 @@ export class StudentsController {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(file.buffer as any);
|
||||
const ws = workbook.worksheets[0];
|
||||
const rows = parseStudentImportRows(ws);
|
||||
const importData = parseStudentImportWorkbook(workbook);
|
||||
// Resolve organization names to IDs
|
||||
for (const row of rows) {
|
||||
for (const row of importData.students) {
|
||||
if (row.organization) {
|
||||
const organization = await this.organizationRepo.findOne({
|
||||
where: { name: row.organization },
|
||||
@@ -404,7 +287,7 @@ export class StudentsController {
|
||||
}
|
||||
}
|
||||
}
|
||||
const result = await this.service.batchImport(rows);
|
||||
const result = await this.service.batchImport(importData);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
@@ -424,10 +307,9 @@ export class StudentsController {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer);
|
||||
const ws = workbook.worksheets[0];
|
||||
const rows = parseStudentImportRows(ws);
|
||||
const importData = parseStudentImportWorkbook(workbook);
|
||||
// Resolve organization names to IDs
|
||||
for (const row of rows) {
|
||||
for (const row of importData.students) {
|
||||
if (row.organization) {
|
||||
const organization = await this.organizationRepo.findOne({
|
||||
where: { name: row.organization },
|
||||
@@ -435,7 +317,7 @@ export class StudentsController {
|
||||
if (organization) row.organizationId = organization.id;
|
||||
}
|
||||
}
|
||||
const result = await this.service.matchImport(rows);
|
||||
const result = await this.service.matchImport(importData);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
|
||||
@@ -2,6 +2,8 @@ import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { StudentsService } from './students.service';
|
||||
|
||||
function createService(repo: Record<string, jest.Mock>, organizationRepo = {}) {
|
||||
const profileRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||
const resultRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||
return new StudentsService(
|
||||
repo as never,
|
||||
{} as never,
|
||||
@@ -9,6 +11,11 @@ function createService(repo: Record<string, jest.Mock>, organizationRepo = {}) {
|
||||
{} as never,
|
||||
{} as never,
|
||||
organizationRepo as never,
|
||||
profileRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
resultRepo as never,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,4 +47,36 @@ describe('StudentsService — archive lifecycle boundaries', () => {
|
||||
const repo = { findOne: jest.fn().mockResolvedValue(null) };
|
||||
await expect(createService(repo).findOne(404)).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('builds export archive maps from profile and result rows', async () => {
|
||||
const profileRepo = {
|
||||
find: jest.fn().mockResolvedValue([{
|
||||
studentId: 1,
|
||||
targetCollege: '北京大学',
|
||||
collegeSchool: '北京职业技术学院',
|
||||
}]),
|
||||
};
|
||||
const resultRepo = {
|
||||
find: jest.fn().mockResolvedValue([{ studentId: 1, admissionStatus: 'pending' }]),
|
||||
};
|
||||
const service = new StudentsService(
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
profileRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
resultRepo as never,
|
||||
);
|
||||
|
||||
const maps = await service.getArchiveExportMaps([1]);
|
||||
|
||||
expect(maps.profiles.get(1)?.targetCollege).toBe('北京大学');
|
||||
expect(maps.profiles.get(1)?.collegeSchool).toBe('北京职业技术学院');
|
||||
expect(maps.results.get(1)?.admissionStatus).toBe('pending');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,11 @@ import { Organization } from '../entities/organization.entity';
|
||||
import { ClassStudent } from '../entities/class-student.entity';
|
||||
import { AttendanceRecord } from '../entities/attendance-record.entity';
|
||||
import { ClassTeacher } from '../entities/class-teacher.entity';
|
||||
import { StudentProfile } from '../entities/student-profile.entity';
|
||||
import { StudentEnrollment } from '../entities/student-enrollment.entity';
|
||||
import { ExamScore } from '../entities/exam-score.entity';
|
||||
import { LearningRecord } from '../entities/learning-record.entity';
|
||||
import { ResultArchive } from '../entities/result-archive.entity';
|
||||
import { StudentsService } from './students.service';
|
||||
import { StudentAccessScopeFactory } from './student-access-scope.factory';
|
||||
import { StudentsController } from './students.controller';
|
||||
@@ -19,6 +24,11 @@ import { StudentsController } from './students.controller';
|
||||
AttendanceRecord,
|
||||
Organization,
|
||||
ClassTeacher,
|
||||
StudentProfile,
|
||||
StudentEnrollment,
|
||||
ExamScore,
|
||||
LearningRecord,
|
||||
ResultArchive,
|
||||
]),
|
||||
],
|
||||
controllers: [StudentsController],
|
||||
|
||||
@@ -13,6 +13,11 @@ describe('StudentsService — teacher class scope', () => {
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
await service.findAll({}, [3, 5]);
|
||||
@@ -37,6 +42,11 @@ describe('StudentsService — teacher class scope', () => {
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
await expect(service.findAll({}, [])).resolves.toEqual([]);
|
||||
|
||||
@@ -7,7 +7,19 @@ import { ClassStudent } from '../entities/class-student.entity';
|
||||
import { ClassTeacher } from '../entities/class-teacher.entity';
|
||||
import { AttendanceRecord } from '../entities/attendance-record.entity';
|
||||
import { Organization } from '../entities/organization.entity';
|
||||
import { StudentProfile } from '../entities/student-profile.entity';
|
||||
import { StudentEnrollment } from '../entities/student-enrollment.entity';
|
||||
import { ExamScore } from '../entities/exam-score.entity';
|
||||
import { LearningRecord } from '../entities/learning-record.entity';
|
||||
import { ResultArchive } from '../entities/result-archive.entity';
|
||||
import { CreateStudentDto, UpdateStudentDto } from './dto/student.dto';
|
||||
import type {
|
||||
ExamScoreImportRow,
|
||||
LearningRecordImportRow,
|
||||
StudentEnrollmentImportRow,
|
||||
StudentImportRow,
|
||||
StudentWorkbookImport,
|
||||
} from './student-import';
|
||||
import type { StudentAccessScope } from './student-access-scope';
|
||||
|
||||
@Injectable()
|
||||
@@ -19,6 +31,11 @@ export class StudentsService {
|
||||
@InjectRepository(AttendanceRecord) private attendanceRepo: Repository<AttendanceRecord>,
|
||||
@InjectRepository(ClassTeacher) private classTeacherRepo: Repository<ClassTeacher>,
|
||||
@InjectRepository(Organization) private organizationRepo: Repository<Organization>,
|
||||
@InjectRepository(StudentProfile) private profileRepo: Repository<StudentProfile>,
|
||||
@InjectRepository(StudentEnrollment) private enrollmentRepo: Repository<StudentEnrollment>,
|
||||
@InjectRepository(ExamScore) private examScoreRepo: Repository<ExamScore>,
|
||||
@InjectRepository(LearningRecord) private learningRecordRepo: Repository<LearningRecord>,
|
||||
@InjectRepository(ResultArchive) private resultRepo: Repository<ResultArchive>,
|
||||
) {}
|
||||
|
||||
async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
|
||||
@@ -35,6 +52,23 @@ export class StudentsService {
|
||||
});
|
||||
}
|
||||
|
||||
async getArchiveExportMaps(studentIds: number[]) {
|
||||
if (studentIds.length === 0) {
|
||||
return {
|
||||
profiles: new Map<number, StudentProfile>(),
|
||||
results: new Map<number, ResultArchive>(),
|
||||
};
|
||||
}
|
||||
const [profiles, results] = await Promise.all([
|
||||
this.profileRepo.find({ where: { studentId: In(studentIds) } }),
|
||||
this.resultRepo.find({ where: { studentId: In(studentIds) } }),
|
||||
]);
|
||||
return {
|
||||
profiles: new Map(profiles.map((profile) => [profile.studentId, profile])),
|
||||
results: new Map(results.map((result) => [result.studentId, result])),
|
||||
};
|
||||
}
|
||||
|
||||
async findAll(
|
||||
query?: {
|
||||
name?: string;
|
||||
@@ -185,24 +219,12 @@ export class StudentsService {
|
||||
return { message: '已恢复' };
|
||||
}
|
||||
|
||||
async batchImport(
|
||||
rows: {
|
||||
name: string;
|
||||
studentNo?: string;
|
||||
phone?: string;
|
||||
idNumber?: string;
|
||||
gender?: string;
|
||||
ethnicity?: string;
|
||||
emergencyContact?: string;
|
||||
emergencyPhone?: string;
|
||||
organization?: string;
|
||||
supervisor?: string;
|
||||
organizationId?: number;
|
||||
}[],
|
||||
) {
|
||||
async batchImport(importData: StudentWorkbookImport | StudentImportRow[]) {
|
||||
const data = this.normalizeImportData(importData);
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
for (const row of rows) {
|
||||
let archiveImported = 0;
|
||||
for (const row of data.students) {
|
||||
if (!row.name || !row.name.trim()) {
|
||||
skipped++;
|
||||
continue;
|
||||
@@ -212,7 +234,7 @@ export class StudentsService {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
await this.repo.save(
|
||||
const student = await this.repo.save(
|
||||
this.repo.create({
|
||||
name: row.name.trim(),
|
||||
studentNo: row.studentNo?.trim() || undefined,
|
||||
@@ -226,33 +248,23 @@ export class StudentsService {
|
||||
organizationId: row.organizationId || (await this.getHostOrganizationId()),
|
||||
}),
|
||||
);
|
||||
archiveImported += await this.importArchiveData(student.id, row, data);
|
||||
imported++;
|
||||
}
|
||||
return {
|
||||
message: `成功导入 ${imported} 名学生,跳过 ${skipped} 条(重复或空行)`,
|
||||
message: `成功导入 ${imported} 名学生,导入档案相关记录 ${archiveImported} 条,跳过 ${skipped} 条(重复或空行)`,
|
||||
imported,
|
||||
archiveImported,
|
||||
skipped,
|
||||
};
|
||||
}
|
||||
|
||||
async matchImport(
|
||||
rows: {
|
||||
name: string;
|
||||
studentNo?: string;
|
||||
phone?: string;
|
||||
idNumber?: string;
|
||||
gender?: string;
|
||||
ethnicity?: string;
|
||||
emergencyContact?: string;
|
||||
emergencyPhone?: string;
|
||||
organization?: string;
|
||||
supervisor?: string;
|
||||
organizationId?: number;
|
||||
}[],
|
||||
) {
|
||||
async matchImport(importData: StudentWorkbookImport | StudentImportRow[]) {
|
||||
const data = this.normalizeImportData(importData);
|
||||
let matched = 0;
|
||||
let skipped = 0;
|
||||
for (const row of rows) {
|
||||
let archiveImported = 0;
|
||||
for (const row of data.students) {
|
||||
// Match by phone first, then idNumber
|
||||
let student = row.phone?.trim()
|
||||
? await this.repo.findOne({ where: { phone: row.phone.trim() } })
|
||||
@@ -291,15 +303,191 @@ export class StudentsService {
|
||||
if (row.supervisor) updates.supervisor = row.supervisor;
|
||||
if (row.organizationId) updates.organizationId = row.organizationId;
|
||||
await this.repo.update(student.id, updates);
|
||||
archiveImported += await this.importArchiveData(student.id, row, data);
|
||||
matched++;
|
||||
}
|
||||
return {
|
||||
message: `更新已有学生资料 ${matched} 人,跳过 ${skipped} 条(无匹配)`,
|
||||
message: `更新已有学生资料 ${matched} 人,导入档案相关记录 ${archiveImported} 条,跳过 ${skipped} 条(无匹配)`,
|
||||
matched,
|
||||
archiveImported,
|
||||
skipped,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeImportData(importData: StudentWorkbookImport | StudentImportRow[]): StudentWorkbookImport {
|
||||
if (Array.isArray(importData)) {
|
||||
return { students: importData, enrollments: [], examScores: [], learningRecords: [] };
|
||||
}
|
||||
return importData;
|
||||
}
|
||||
|
||||
private normalizePhone(phone?: string) {
|
||||
return phone?.trim() || '';
|
||||
}
|
||||
|
||||
private sameValue(left?: string | number | null, right?: string | number | null) {
|
||||
return String(left ?? '').trim() === String(right ?? '').trim();
|
||||
}
|
||||
|
||||
private hasProfileData(row: StudentImportRow) {
|
||||
return [
|
||||
row.targetCollege,
|
||||
row.targetMajor,
|
||||
row.collegeSchool,
|
||||
row.collegeMajor,
|
||||
row.subjectDirection,
|
||||
row.grade,
|
||||
row.profileDate,
|
||||
row.notes,
|
||||
].some((value) => value !== undefined && String(value).trim() !== '');
|
||||
}
|
||||
|
||||
private hasResultData(row: StudentImportRow) {
|
||||
return [
|
||||
row.cultureFinalScore,
|
||||
row.professionalFinalScore,
|
||||
row.admissionStatus,
|
||||
row.admittedCollege,
|
||||
row.admittedMajor,
|
||||
].some((value) => value !== undefined && String(value).trim() !== '');
|
||||
}
|
||||
|
||||
private async importArchiveData(
|
||||
studentId: number,
|
||||
row: StudentImportRow,
|
||||
data: StudentWorkbookImport,
|
||||
) {
|
||||
const phone = this.normalizePhone(row.phone);
|
||||
let imported = 0;
|
||||
if (this.hasProfileData(row)) {
|
||||
await this.upsertProfileFromImport(studentId, row);
|
||||
imported++;
|
||||
}
|
||||
if (this.hasResultData(row)) {
|
||||
await this.upsertResultFromImport(studentId, row);
|
||||
imported++;
|
||||
}
|
||||
if (!phone) return imported;
|
||||
|
||||
const enrollmentByClassName = new Map<string, StudentEnrollment>();
|
||||
for (const enrollmentRow of data.enrollments.filter((item) => this.normalizePhone(item.phone) === phone)) {
|
||||
const enrollment = await this.upsertEnrollmentFromImport(studentId, enrollmentRow);
|
||||
if (!enrollment) continue;
|
||||
if (enrollment.className) enrollmentByClassName.set(enrollment.className, enrollment);
|
||||
imported++;
|
||||
}
|
||||
for (const examRow of data.examScores.filter((item) => this.normalizePhone(item.phone) === phone)) {
|
||||
if (await this.upsertExamScoreFromImport(studentId, examRow, enrollmentByClassName)) {
|
||||
imported++;
|
||||
}
|
||||
}
|
||||
for (const learningRow of data.learningRecords.filter((item) => this.normalizePhone(item.phone) === phone)) {
|
||||
if (await this.upsertLearningRecordFromImport(studentId, learningRow)) {
|
||||
imported++;
|
||||
}
|
||||
}
|
||||
return imported;
|
||||
}
|
||||
|
||||
private async upsertProfileFromImport(studentId: number, row: StudentImportRow) {
|
||||
const entity = (await this.profileRepo.findOne({ where: { studentId } })) || this.profileRepo.create({ studentId });
|
||||
if (row.targetCollege?.trim()) entity.targetCollege = row.targetCollege.trim();
|
||||
if (row.targetMajor?.trim()) entity.targetMajor = row.targetMajor.trim();
|
||||
if (row.collegeSchool?.trim()) entity.collegeSchool = row.collegeSchool.trim();
|
||||
if (row.collegeMajor?.trim()) entity.collegeMajor = row.collegeMajor.trim();
|
||||
if (row.subjectDirection?.trim()) entity.subjectDirection = row.subjectDirection.trim();
|
||||
if (row.grade?.trim()) entity.grade = row.grade.trim();
|
||||
if (row.profileDate?.trim()) entity.profileDate = row.profileDate.trim();
|
||||
if (row.notes?.trim()) entity.notes = row.notes.trim();
|
||||
await this.profileRepo.save(entity);
|
||||
}
|
||||
|
||||
private async upsertResultFromImport(studentId: number, row: StudentImportRow) {
|
||||
const entity = (await this.resultRepo.findOne({ where: { studentId } })) || this.resultRepo.create({ studentId });
|
||||
if (row.cultureFinalScore !== undefined) entity.cultureFinalScore = row.cultureFinalScore;
|
||||
if (row.professionalFinalScore !== undefined) entity.professionalFinalScore = row.professionalFinalScore;
|
||||
if (row.admissionStatus?.trim()) entity.admissionStatus = row.admissionStatus.trim();
|
||||
if (row.admittedCollege?.trim()) entity.admittedCollege = row.admittedCollege.trim();
|
||||
if (row.admittedMajor?.trim()) entity.admittedMajor = row.admittedMajor.trim();
|
||||
await this.resultRepo.save(entity);
|
||||
}
|
||||
|
||||
private async upsertEnrollmentFromImport(studentId: number, row: StudentEnrollmentImportRow) {
|
||||
if (!row.courseCategory?.trim() || !row.classType?.trim()) {
|
||||
return null;
|
||||
}
|
||||
const existing = await this.enrollmentRepo.find({ where: { studentId } });
|
||||
const entity =
|
||||
existing.find(
|
||||
(item) =>
|
||||
this.sameValue(item.courseCategory, row.courseCategory) &&
|
||||
this.sameValue(item.classType, row.classType) &&
|
||||
this.sameValue(item.className, row.className) &&
|
||||
this.sameValue(item.startDate, row.startDate),
|
||||
) || this.enrollmentRepo.create({ studentId });
|
||||
entity.courseCategory = row.courseCategory.trim();
|
||||
entity.classType = row.classType.trim();
|
||||
if (row.className?.trim()) entity.className = row.className.trim();
|
||||
if (row.headTeacher?.trim()) entity.headTeacher = row.headTeacher.trim();
|
||||
if (row.subjectTeacher?.trim()) entity.subjectTeacher = row.subjectTeacher.trim();
|
||||
if (row.startDate?.trim()) entity.startDate = row.startDate.trim();
|
||||
if (row.endDate?.trim()) entity.endDate = row.endDate.trim();
|
||||
if (row.status?.trim()) entity.status = row.status.trim();
|
||||
else if (!entity.status) entity.status = 'active';
|
||||
return this.enrollmentRepo.save(entity);
|
||||
}
|
||||
|
||||
private async upsertExamScoreFromImport(
|
||||
studentId: number,
|
||||
row: ExamScoreImportRow,
|
||||
enrollmentByClassName: Map<string, StudentEnrollment>,
|
||||
) {
|
||||
if (!row.examType?.trim() || !row.subject?.trim() || row.score === undefined) return false;
|
||||
const existing = await this.examScoreRepo.find({ where: { studentId } });
|
||||
const entity =
|
||||
existing.find(
|
||||
(item) =>
|
||||
this.sameValue(item.examType, row.examType) &&
|
||||
this.sameValue(item.examName, row.examName) &&
|
||||
this.sameValue(item.subject, row.subject) &&
|
||||
this.sameValue(item.examDate, row.examDate),
|
||||
) || this.examScoreRepo.create({ studentId });
|
||||
entity.examType = row.examType.trim();
|
||||
entity.subject = row.subject.trim();
|
||||
entity.score = row.score;
|
||||
if (row.examName?.trim()) entity.examName = row.examName.trim();
|
||||
if (row.classAvg !== undefined) entity.classAvg = row.classAvg;
|
||||
if (row.rank !== undefined) entity.rank = row.rank;
|
||||
if (row.examDate?.trim()) entity.examDate = row.examDate.trim();
|
||||
if (row.enrollmentName?.trim()) {
|
||||
const enrollment = enrollmentByClassName.get(row.enrollmentName.trim());
|
||||
if (enrollment) entity.enrollmentId = enrollment.id;
|
||||
}
|
||||
if (!entity.status) entity.status = 'active';
|
||||
await this.examScoreRepo.save(entity);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async upsertLearningRecordFromImport(studentId: number, row: LearningRecordImportRow) {
|
||||
if (!row.recordDate?.trim() || !row.recordType?.trim() || !row.content?.trim()) return false;
|
||||
const existing = await this.learningRecordRepo.find({ where: { studentId } });
|
||||
const entity =
|
||||
existing.find(
|
||||
(item) =>
|
||||
this.sameValue(item.recordDate, row.recordDate) &&
|
||||
this.sameValue(item.recordType, row.recordType) &&
|
||||
this.sameValue(item.content, row.content),
|
||||
) || this.learningRecordRepo.create({ studentId });
|
||||
entity.recordDate = row.recordDate.trim();
|
||||
entity.recordType = row.recordType.trim();
|
||||
entity.content = row.content.trim();
|
||||
if (row.followUpMethod?.trim()) entity.followUpMethod = row.followUpMethod.trim();
|
||||
if (row.nextStep?.trim()) entity.nextStep = row.nextStep.trim();
|
||||
if (!entity.status) entity.status = 'active';
|
||||
await this.learningRecordRepo.save(entity);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async assertActiveOrganization(id: number) {
|
||||
const organization = await this.organizationRepo.findOne({ where: { id, status: 'active' } });
|
||||
if (!organization) throw new BadRequestException('所属机构不存在或已归档');
|
||||
|
||||
Reference in New Issue
Block a user