feat: add exam score management
Some checks failed
CI 检查 / lint (pull_request) Has been cancelled
CI 检查 / typecheck (pull_request) Has been cancelled
CI 检查 / test (pull_request) Has been cancelled

This commit is contained in:
2026-07-21 16:04:17 +08:00
parent 37ef6f9dd7
commit cbc04fea4f
27 changed files with 1061 additions and 16 deletions

View File

@@ -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={

View File

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

View File

@@ -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' },

View File

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

View File

@@ -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' },

View File

@@ -81,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;
@@ -865,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)}
>
@@ -879,6 +882,7 @@ const ExamScoresTab: React.FC<
<EditableCell
value={v}
permission="student:edit"
disabled={!!r.examId}
onSave={(next) => saveCell(r, 'examName', next)}
>
{v || '-'}
@@ -893,6 +897,7 @@ const ExamScoresTab: React.FC<
value={v}
required
permission="student:edit"
disabled={!!r.examId}
onSave={(next) => saveCell(r, 'subject', next)}
>
{v}
@@ -902,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>
),
},
@@ -924,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 : '-'}
@@ -939,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 : '-'}
@@ -953,6 +960,7 @@ const ExamScoresTab: React.FC<
value={v}
editor="date"
permission="student:edit"
disabled={!!r.examId}
onSave={(next) => saveCell(r, 'examDate', next)}
>
{v || '-'}
@@ -971,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);

View File

@@ -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 />,

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

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

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

View 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;
}
}

View 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: '入学测试' },
];

View File

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

View File

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

View File

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

View File

@@ -75,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' } }),
@@ -154,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);
@@ -162,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: '已归档' };

View File

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

View 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;
}

View File

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

View 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;
}

View 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;
}
}

View 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 {}

View 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 }),
]);
});
});

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

View File

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

View File

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

View File

@@ -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',