forked from wangziqi/gongxue-base
feat: P2-12 Teacher profile/workspace + P2-13 Multi-class comparison
P2-12 Teacher Management: - Add profile JSON field (subjects, joinedAt, qualifications) to User entity - Add GET/PUT /rbac/users/:id/profile endpoints with UpdateProfileDto - Add GET /rbac/teacher-workspace endpoint returning assignedClasses, todaySchedules, and myStudents - Create /teacher-workspace page with tabs (My Classes, Today's Schedule, My Students) - Add route with PermissionRoute and menu item in MainLayout P2-13 Student Archive Multi-class: - Add GET /students/:id/compare-classes endpoint returning side-by-side enrollment data with per-class attendance statistics - Students module now includes ClassStudent and AttendanceRecord entities - No 2-enrollment cap existed in the codebase; student enrollments already return all records via class-student table
This commit is contained in:
@@ -24,6 +24,7 @@ import SchedulesPage from './pages/Schedules';
|
|||||||
import RolesPage from './pages/Roles';
|
import RolesPage from './pages/Roles';
|
||||||
import PermissionsPage from './pages/Permissions';
|
import PermissionsPage from './pages/Permissions';
|
||||||
import AttendancePage from './pages/Attendance';
|
import AttendancePage from './pages/Attendance';
|
||||||
|
import TeacherWorkspacePage from './pages/TeacherWorkspace';
|
||||||
import PermissionRoute from './components/PermissionRoute';
|
import PermissionRoute from './components/PermissionRoute';
|
||||||
|
|
||||||
const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
@@ -220,6 +221,15 @@ const App: React.FC = () => {
|
|||||||
</PermissionRoute>
|
</PermissionRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="teacher-workspace"
|
||||||
|
element={
|
||||||
|
<PermissionRoute permission="class:view">
|
||||||
|
<TeacherWorkspacePage />
|
||||||
|
</PermissionRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
SafetyOutlined,
|
SafetyOutlined,
|
||||||
KeyOutlined,
|
KeyOutlined,
|
||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
|
LaptopOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { usePermission } from '../hooks/usePermission';
|
import { usePermission } from '../hooks/usePermission';
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ const allMenuItems: MenuItemType[] = [
|
|||||||
{ key: '/deposits', icon: <WalletOutlined />, label: '押金管理', permission: 'deposit:view' },
|
{ key: '/deposits', icon: <WalletOutlined />, label: '押金管理', permission: 'deposit:view' },
|
||||||
{ key: '/bills', icon: <FileTextOutlined />, label: '账单管理', permission: 'bill:view' },
|
{ key: '/bills', icon: <FileTextOutlined />, label: '账单管理', permission: 'bill:view' },
|
||||||
{ key: '/classes', icon: <TeamOutlined />, label: '班级管理', permission: 'class:view' },
|
{ key: '/classes', icon: <TeamOutlined />, label: '班级管理', permission: 'class:view' },
|
||||||
|
{ key: '/teacher-workspace', icon: <LaptopOutlined />, label: '教师工作台', permission: 'class:view' },
|
||||||
{
|
{
|
||||||
key: 'classroom-group',
|
key: 'classroom-group',
|
||||||
icon: <ReadOutlined />,
|
icon: <ReadOutlined />,
|
||||||
|
|||||||
187
apps/admin/src/pages/TeacherWorkspace/index.tsx
Normal file
187
apps/admin/src/pages/TeacherWorkspace/index.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Card, Tabs, Table, Tag, Empty, Spin } from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import api from '../../api';
|
||||||
|
|
||||||
|
interface AssignedClass {
|
||||||
|
classId: number;
|
||||||
|
className: string;
|
||||||
|
classCode: string;
|
||||||
|
roleType: string;
|
||||||
|
subject: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScheduleItem {
|
||||||
|
id: number;
|
||||||
|
classId: number;
|
||||||
|
weekDay: number;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
subject: string;
|
||||||
|
scheduleType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StudentItem {
|
||||||
|
studentId: number;
|
||||||
|
studentName: string;
|
||||||
|
studentNo: string;
|
||||||
|
className: string;
|
||||||
|
classId: number;
|
||||||
|
joinDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkspaceData {
|
||||||
|
assignedClasses: AssignedClass[];
|
||||||
|
todaySchedules: ScheduleItem[];
|
||||||
|
myStudents: StudentItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
|
head_teacher: '班主任',
|
||||||
|
life_teacher: '生活老师',
|
||||||
|
academic_teacher: '教务老师',
|
||||||
|
subject_teacher: '任课教师',
|
||||||
|
};
|
||||||
|
|
||||||
|
const WEEKDAY_LABELS: Record<string, string> = {
|
||||||
|
'1': '周一',
|
||||||
|
'2': '周二',
|
||||||
|
'3': '周三',
|
||||||
|
'4': '周四',
|
||||||
|
'5': '周五',
|
||||||
|
'6': '周六',
|
||||||
|
'7': '周日',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TeacherWorkspacePage: React.FC = () => {
|
||||||
|
const [data, setData] = useState<WorkspaceData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = (await api.get('/rbac/teacher-workspace')) as WorkspaceData;
|
||||||
|
setData(res);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const classColumns: ColumnsType<AssignedClass> = [
|
||||||
|
{
|
||||||
|
title: '班级名称',
|
||||||
|
dataIndex: 'className',
|
||||||
|
render: (v: string, r: AssignedClass) => `${v} (${r.classCode})`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '角色',
|
||||||
|
dataIndex: 'roleType',
|
||||||
|
render: (v: string) => <Tag>{ROLE_LABELS[v] || v}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '科目',
|
||||||
|
dataIndex: 'subject',
|
||||||
|
render: (v: string | null) => v || '-',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const scheduleColumns: ColumnsType<ScheduleItem> = [
|
||||||
|
{
|
||||||
|
title: '时间',
|
||||||
|
key: 'time',
|
||||||
|
render: (_: unknown, r: ScheduleItem) => `${r.startTime} - ${r.endTime}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '星期',
|
||||||
|
dataIndex: 'weekDay',
|
||||||
|
render: (v: number) => <Tag>{WEEKDAY_LABELS[String(v)] || v}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '科目',
|
||||||
|
dataIndex: 'subject',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '类型',
|
||||||
|
dataIndex: 'scheduleType',
|
||||||
|
render: (v: string) => (
|
||||||
|
<Tag color={v === 'INTERNAL' ? 'blue' : 'orange'}>
|
||||||
|
{v === 'INTERNAL' ? '内部课程' : '租赁'}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const studentColumns: ColumnsType<StudentItem> = [
|
||||||
|
{ title: '姓名', dataIndex: 'studentName' },
|
||||||
|
{ title: '学号', dataIndex: 'studentNo', render: (v: string) => v || '-' },
|
||||||
|
{ title: '班级', dataIndex: 'className' },
|
||||||
|
{ title: '加入日期', dataIndex: 'joinDate', render: (v: string) => v || '-' },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: 'center', padding: 80 }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title="教师工作台">
|
||||||
|
<Tabs
|
||||||
|
defaultActiveKey="classes"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'classes',
|
||||||
|
label: `我的班级 (${data?.assignedClasses.length || 0})`,
|
||||||
|
children: data?.assignedClasses.length ? (
|
||||||
|
<Table<AssignedClass>
|
||||||
|
columns={classColumns}
|
||||||
|
dataSource={data.assignedClasses}
|
||||||
|
rowKey="classId"
|
||||||
|
pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 个班级` }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Empty description="暂无分配的班级" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'schedule',
|
||||||
|
label: `今日课程 (${data?.todaySchedules.length || 0})`,
|
||||||
|
children: data?.todaySchedules.length ? (
|
||||||
|
<Table<ScheduleItem>
|
||||||
|
columns={scheduleColumns}
|
||||||
|
dataSource={data.todaySchedules}
|
||||||
|
rowKey="id"
|
||||||
|
pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 节` }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Empty description="今日无排课" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'students',
|
||||||
|
label: `我的学生 (${data?.myStudents.length || 0})`,
|
||||||
|
children: data?.myStudents.length ? (
|
||||||
|
<Table<StudentItem>
|
||||||
|
columns={studentColumns}
|
||||||
|
dataSource={data.myStudents}
|
||||||
|
rowKey="studentId"
|
||||||
|
pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 人` }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Empty description="暂无学生" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TeacherWorkspacePage;
|
||||||
@@ -41,6 +41,9 @@ export class User {
|
|||||||
@UpdateDateColumn({ name: 'updated_at' })
|
@UpdateDateColumn({ name: 'updated_at' })
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'profile', type: 'simple-json', nullable: true })
|
||||||
|
profile: { subjects?: string[]; joinedAt?: string; qualifications?: string };
|
||||||
|
|
||||||
@ManyToMany(() => Role, (role) => role.users)
|
@ManyToMany(() => Role, (role) => role.users)
|
||||||
@JoinTable({
|
@JoinTable({
|
||||||
name: 'user_roles',
|
name: 'user_roles',
|
||||||
|
|||||||
@@ -66,3 +66,16 @@ export class ResetPasswordDto {
|
|||||||
@MinLength(4)
|
@MinLength(4)
|
||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class UpdateProfileDto {
|
||||||
|
@IsOptional()
|
||||||
|
subjects?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
joinedAt?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
qualifications?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
CreateUserDto,
|
CreateUserDto,
|
||||||
UpdateUserDto,
|
UpdateUserDto,
|
||||||
ResetPasswordDto,
|
ResetPasswordDto,
|
||||||
|
UpdateProfileDto,
|
||||||
} from './dto/rbac.dto';
|
} from './dto/rbac.dto';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||||
@@ -216,4 +217,46 @@ export class RbacController {
|
|||||||
throw new BadRequestException(e.message);
|
throw new BadRequestException(e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 用户资料 ----
|
||||||
|
|
||||||
|
@Get('users/:id/profile')
|
||||||
|
@RequirePermission('user:view')
|
||||||
|
getUserProfile(@Param('id') id: string) {
|
||||||
|
return this.rbacService.getUserProfile(+id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('users/:id/profile')
|
||||||
|
@RequirePermission('user:edit')
|
||||||
|
async updateUserProfile(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateProfileDto,
|
||||||
|
@Request() req: any,
|
||||||
|
) {
|
||||||
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
|
try {
|
||||||
|
const result = await this.rbacService.updateUserProfile(+id, dto);
|
||||||
|
await this.logService.log({
|
||||||
|
userId: req.user?.id,
|
||||||
|
username: req.user?.username,
|
||||||
|
module: '账号',
|
||||||
|
action: '更新资料',
|
||||||
|
targetId: +id,
|
||||||
|
targetType: 'user',
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (e: any) {
|
||||||
|
throw new BadRequestException(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 教师工作台 ----
|
||||||
|
|
||||||
|
@Get('teacher-workspace')
|
||||||
|
@RequirePermission('class:view')
|
||||||
|
async getTeacherWorkspace(@Request() req: any) {
|
||||||
|
return this.rbacService.getTeacherWorkspace(req.user?.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Module, OnModuleInit, forwardRef } from '@nestjs/common';
|
import { Module, OnModuleInit, forwardRef } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { Permission, Role, User } from '../entities';
|
import { Permission, Role, User, Class, ClassStudent, ClassTeacher, ClassSchedule, Student } from '../entities';
|
||||||
import { RbacService } from './rbac.service';
|
import { RbacService } from './rbac.service';
|
||||||
import { RbacController } from './rbac.controller';
|
import { RbacController } from './rbac.controller';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Permission, Role, User]), forwardRef(() => AuthModule)],
|
imports: [TypeOrmModule.forFeature([Permission, Role, User, Class, ClassStudent, ClassTeacher, ClassSchedule, Student]), forwardRef(() => AuthModule)],
|
||||||
controllers: [RbacController],
|
controllers: [RbacController],
|
||||||
providers: [RbacService],
|
providers: [RbacService],
|
||||||
exports: [RbacService],
|
exports: [RbacService],
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository, In } from 'typeorm';
|
||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
import { Permission, Role, User } from '../entities';
|
import { Permission, Role, User, Class, ClassStudent, ClassTeacher, ClassSchedule, Student } from '../entities';
|
||||||
|
|
||||||
const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> = [
|
const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> = [
|
||||||
{ code: 'dashboard:view', name: '查看数据面板', group: 'dashboard' },
|
{ code: 'dashboard:view', name: '查看数据面板', group: 'dashboard' },
|
||||||
@@ -129,6 +129,11 @@ export class RbacService {
|
|||||||
@InjectRepository(Permission) private permRepo: Repository<Permission>,
|
@InjectRepository(Permission) private permRepo: Repository<Permission>,
|
||||||
@InjectRepository(Role) private roleRepo: Repository<Role>,
|
@InjectRepository(Role) private roleRepo: Repository<Role>,
|
||||||
@InjectRepository(User) private userRepo: Repository<User>,
|
@InjectRepository(User) private userRepo: Repository<User>,
|
||||||
|
@InjectRepository(Class) private classRepo: Repository<Class>,
|
||||||
|
@InjectRepository(ClassStudent) private classStudentRepo: Repository<ClassStudent>,
|
||||||
|
@InjectRepository(ClassTeacher) private classTeacherRepo: Repository<ClassTeacher>,
|
||||||
|
@InjectRepository(ClassSchedule) private classScheduleRepo: Repository<ClassSchedule>,
|
||||||
|
@InjectRepository(Student) private studentRepo: Repository<Student>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async seedData(): Promise<void> {
|
async seedData(): Promise<void> {
|
||||||
@@ -351,4 +356,101 @@ export class RbacService {
|
|||||||
await this.userRepo.remove(user);
|
await this.userRepo.remove(user);
|
||||||
return { message: '用户已删除' };
|
return { message: '用户已删除' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 用户资料 ----
|
||||||
|
|
||||||
|
async getUserProfile(id: number) {
|
||||||
|
const user = await this.userRepo.findOne({ where: { id } });
|
||||||
|
if (!user) throw new Error('用户不存在');
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
name: user.name,
|
||||||
|
profile: user.profile || {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUserProfile(id: number, dto: { subjects?: string[]; joinedAt?: string; qualifications?: string }) {
|
||||||
|
const user = await this.userRepo.findOne({ where: { id } });
|
||||||
|
if (!user) throw new Error('用户不存在');
|
||||||
|
const current = user.profile || {};
|
||||||
|
user.profile = {
|
||||||
|
subjects: dto.subjects !== undefined ? dto.subjects : current.subjects,
|
||||||
|
joinedAt: dto.joinedAt !== undefined ? dto.joinedAt : current.joinedAt,
|
||||||
|
qualifications: dto.qualifications !== undefined ? dto.qualifications : current.qualifications,
|
||||||
|
};
|
||||||
|
await this.userRepo.save(user);
|
||||||
|
return { message: '资料已更新', profile: user.profile };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 教师工作台 ----
|
||||||
|
|
||||||
|
async getTeacherWorkspace(userId: number) {
|
||||||
|
// Find all classes where this user is a teacher
|
||||||
|
const teacherAssignments = await this.classTeacherRepo.find({
|
||||||
|
where: { userId },
|
||||||
|
relations: ['class'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const classIds = [...new Set(teacherAssignments.map((t) => t.classId))];
|
||||||
|
|
||||||
|
if (classIds.length === 0) {
|
||||||
|
return { assignedClasses: [], todaySchedules: [], myStudents: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get assigned classes
|
||||||
|
const assignedClasses = teacherAssignments.map((t) => ({
|
||||||
|
classId: t.classId,
|
||||||
|
className: t.class?.name || '',
|
||||||
|
classCode: t.class?.code || '',
|
||||||
|
roleType: t.roleType,
|
||||||
|
subject: t.subject,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Get today's day of week (1=Monday, 7=Sunday)
|
||||||
|
const today = new Date();
|
||||||
|
const weekDay = today.getDay(); // 0=Sun → convert to 1-7
|
||||||
|
const adjustedWeekDay = weekDay === 0 ? 7 : weekDay;
|
||||||
|
const todayStr = today.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
// Get today's schedules for assigned classes
|
||||||
|
const todaySchedules = await this.classScheduleRepo
|
||||||
|
.createQueryBuilder('cs')
|
||||||
|
.where('cs.classId IN (:...classIds)', { classIds })
|
||||||
|
.andWhere('cs.weekDay = :weekDay', { weekDay: adjustedWeekDay })
|
||||||
|
.andWhere('cs.startDate <= :today', { today: todayStr })
|
||||||
|
.andWhere('cs.endDate >= :today', { today: todayStr })
|
||||||
|
.andWhere('cs.status = :status', { status: 'active' })
|
||||||
|
.orderBy('cs.startTime', 'ASC')
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
// Get students in assigned classes
|
||||||
|
const classStudents = await this.classStudentRepo.find({
|
||||||
|
where: { classId: In(classIds), status: 'active' },
|
||||||
|
relations: ['student', 'class'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const myStudents = classStudents.map((cs) => ({
|
||||||
|
studentId: cs.studentId,
|
||||||
|
studentName: cs.student?.name || '',
|
||||||
|
studentNo: cs.student?.studentNo || '',
|
||||||
|
className: cs.class?.name || '',
|
||||||
|
classId: cs.classId,
|
||||||
|
joinDate: cs.joinDate,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
assignedClasses,
|
||||||
|
todaySchedules: todaySchedules.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
classId: s.classId,
|
||||||
|
weekDay: s.weekDay,
|
||||||
|
startTime: s.startTime,
|
||||||
|
endTime: s.endTime,
|
||||||
|
subject: s.subject,
|
||||||
|
scheduleType: s.scheduleType,
|
||||||
|
})),
|
||||||
|
myStudents,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -280,4 +280,10 @@ export class StudentsController {
|
|||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id/compare-classes')
|
||||||
|
@RequirePermission('student:view')
|
||||||
|
compareClasses(@Param('id') id: string) {
|
||||||
|
return this.service.compareClasses(+id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { Student } from '../entities/student.entity';
|
import { Student } from '../entities/student.entity';
|
||||||
|
import { ClassStudent } from '../entities/class-student.entity';
|
||||||
|
import { AttendanceRecord } from '../entities/attendance-record.entity';
|
||||||
import { StudentsService } from './students.service';
|
import { StudentsService } from './students.service';
|
||||||
import { StudentsController } from './students.controller';
|
import { StudentsController } from './students.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Student])],
|
imports: [TypeOrmModule.forFeature([Student, ClassStudent, AttendanceRecord])],
|
||||||
controllers: [StudentsController],
|
controllers: [StudentsController],
|
||||||
providers: [StudentsService],
|
providers: [StudentsService],
|
||||||
exports: [StudentsService],
|
exports: [StudentsService],
|
||||||
|
|||||||
@@ -2,11 +2,18 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/comm
|
|||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, Like, Not, In } from 'typeorm';
|
import { Repository, Like, Not, In } from 'typeorm';
|
||||||
import { Student } from '../entities/student.entity';
|
import { Student } from '../entities/student.entity';
|
||||||
|
import { ClassStudent } from '../entities/class-student.entity';
|
||||||
|
import { AttendanceRecord } from '../entities/attendance-record.entity';
|
||||||
import { CreateStudentDto, UpdateStudentDto } from './dto/student.dto';
|
import { CreateStudentDto, UpdateStudentDto } from './dto/student.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class StudentsService {
|
export class StudentsService {
|
||||||
constructor(@InjectRepository(Student) private repo: Repository<Student>) {}
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Student) private repo: Repository<Student>,
|
||||||
|
@InjectRepository(ClassStudent) private classStudentRepo: Repository<ClassStudent>,
|
||||||
|
@InjectRepository(AttendanceRecord) private attendanceRepo: Repository<AttendanceRecord>,
|
||||||
|
) {}
|
||||||
|
|
||||||
async findAll(query?: { name?: string; status?: string; includeArchived?: boolean }) {
|
async findAll(query?: { name?: string; status?: string; includeArchived?: boolean }) {
|
||||||
const where: any = {};
|
const where: any = {};
|
||||||
@@ -129,4 +136,61 @@ export class StudentsService {
|
|||||||
skipped,
|
skipped,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async compareClasses(studentId: number) {
|
||||||
|
const student = await this.repo.findOne({ where: { id: studentId } });
|
||||||
|
if (!student) throw new NotFoundException('学生不存在');
|
||||||
|
|
||||||
|
// Get all class enrollments for this student
|
||||||
|
const enrollments = await this.classStudentRepo.find({
|
||||||
|
where: { studentId },
|
||||||
|
relations: ['class'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (enrollments.length === 0) {
|
||||||
|
return { student, enrollments: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// For each enrollment, compute attendance stats
|
||||||
|
const classIds = enrollments.map((e) => e.classId);
|
||||||
|
const attendanceRecords = await this.attendanceRepo.find({
|
||||||
|
where: { studentId, classId: In(classIds) },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Group attendance by class
|
||||||
|
const attendanceByClass = new Map<number, AttendanceRecord[]>();
|
||||||
|
for (const r of attendanceRecords) {
|
||||||
|
const list = attendanceByClass.get(r.classId!) || [];
|
||||||
|
list.push(r);
|
||||||
|
attendanceByClass.set(r.classId!, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const comparison = enrollments.map((e) => {
|
||||||
|
const records = attendanceByClass.get(e.classId) || [];
|
||||||
|
const total = records.length;
|
||||||
|
const present = records.filter((r) => r.status === 'present' || r.status === '正常').length;
|
||||||
|
const absent = records.filter((r) => r.status === 'absent' || r.status === '缺勤').length;
|
||||||
|
const late = records.filter((r) => r.status === 'late' || r.status === '迟到').length;
|
||||||
|
const leave = records.filter((r) => r.status === 'leave' || r.status === '请假').length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
classId: e.classId,
|
||||||
|
className: e.class?.name || '',
|
||||||
|
classType: e.class?.classType || '',
|
||||||
|
joinDate: e.joinDate,
|
||||||
|
leaveDate: e.leaveDate,
|
||||||
|
status: e.status,
|
||||||
|
attendanceStats: {
|
||||||
|
total,
|
||||||
|
present,
|
||||||
|
absent,
|
||||||
|
late,
|
||||||
|
leave,
|
||||||
|
rate: total > 0 ? Math.round((present / total) * 100) : 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { student, enrollments: comparison };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user