feat: 添加教学管理模块(教师、学生、班级)

后端:
- 新增 Teacher、Student、Class 模块及 CRUD 接口
- 新增 ClassTeacher 多对多关系支持任课教师管理
- Student 支持班级关联查询
- Class 支持班主任一对一和任课教师多对多关系
- 更新 Prisma schema 和种子数据

前端:
- 新增教师、学生、班级管理页面
- 新增对应的 hooks 和 services
- 更新路由常量和 hooks 导出

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
charilezhou
2026-01-19 15:39:10 +08:00
parent 0156e17131
commit 3ae13fd512
37 changed files with 3719 additions and 38 deletions

View File

@@ -153,3 +153,79 @@ model RoleMenu {
@@unique([roleId, menuId])
@@map("role_menus")
}
// ============ 教学管理模块 ============
// 教师表
model Teacher {
id String @id @default(cuid(2))
teacherNo String @unique // 工号
name String
gender String? // male / female
phone String?
email String?
subject String? // 任教科目
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
// 关系
headOfClasses Class[] @relation("HeadTeacher") // 作为班主任的班级(一对一的反向)
teachClasses ClassTeacher[] // 任课班级(多对多)
@@map("teachers")
}
// 班级表
model Class {
id String @id @default(cuid(2))
code String @unique // 班级代码
name String // 班级名称
grade String? // 年级
headTeacherId String? // 班主任 ID一对一
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
// 关系
headTeacher Teacher? @relation("HeadTeacher", fields: [headTeacherId], references: [id])
students Student[] // 班级学生(一对多)
teachers ClassTeacher[] // 任课教师(多对多)
@@index([headTeacherId])
@@map("classes")
}
// 学生表
model Student {
id String @id @default(cuid(2))
studentNo String @unique // 学号
name String
gender String? // male / female
phone String?
email String?
classId String? // 所属班级(多对一)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
// 关系
class Class? @relation(fields: [classId], references: [id])
@@index([classId])
@@map("students")
}
// 班级-教师关联表(多对多:任课关系)
model ClassTeacher {
id String @id @default(cuid(2))
classId String
teacherId String
createdAt DateTime @default(now())
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
teacher Teacher @relation(fields: [teacherId], references: [id], onDelete: Cascade)
@@unique([classId, teacherId])
@@map("class_teachers")
}

View File

@@ -28,6 +28,21 @@ const permissions = [
// 文件管理权限
{ code: 'file:read', name: '查看文件', resource: 'file', action: 'read' },
{ code: 'file:delete', name: '删除文件', resource: 'file', action: 'delete' },
// 教师管理权限
{ code: 'teacher:create', name: '创建教师', resource: 'teacher', action: 'create' },
{ code: 'teacher:read', name: '查看教师', resource: 'teacher', action: 'read' },
{ code: 'teacher:update', name: '更新教师', resource: 'teacher', action: 'update' },
{ code: 'teacher:delete', name: '删除教师', resource: 'teacher', action: 'delete' },
// 班级管理权限
{ code: 'class:create', name: '创建班级', resource: 'class', action: 'create' },
{ code: 'class:read', name: '查看班级', resource: 'class', action: 'read' },
{ code: 'class:update', name: '更新班级', resource: 'class', action: 'update' },
{ code: 'class:delete', name: '删除班级', resource: 'class', action: 'delete' },
// 学生管理权限
{ code: 'student:create', name: '创建学生', resource: 'student', action: 'create' },
{ code: 'student:read', name: '查看学生', resource: 'student', action: 'read' },
{ code: 'student:update', name: '更新学生', resource: 'student', action: 'update' },
{ code: 'student:delete', name: '删除学生', resource: 'student', action: 'delete' },
];
// 初始角色数据
@@ -66,6 +81,43 @@ const menus = [
sort: 0,
isStatic: true,
},
// 教学管理目录
{
code: 'teaching',
name: '教学管理',
type: 'dir',
icon: 'GraduationCap',
sort: 10,
isStatic: false,
},
{
code: 'student-management',
name: '学生管理',
type: 'menu',
path: '/students',
icon: 'Users',
sort: 0,
isStatic: false,
},
{
code: 'teacher-management',
name: '教师管理',
type: 'menu',
path: '/teachers',
icon: 'UserCheck',
sort: 1,
isStatic: false,
},
{
code: 'class-management',
name: '班级管理',
type: 'menu',
path: '/classes',
icon: 'School',
sort: 2,
isStatic: false,
},
// 系统管理目录
{
code: 'system',
name: '系统管理',
@@ -82,7 +134,6 @@ const menus = [
icon: 'Users',
sort: 0,
isStatic: true,
// parentCode: 'system',
},
{
code: 'file-management',
@@ -92,7 +143,6 @@ const menus = [
icon: 'FolderOpen',
sort: 1,
isStatic: true,
// parentCode: 'system',
},
{
code: 'role-management',
@@ -102,7 +152,6 @@ const menus = [
icon: 'Shield',
sort: 2,
isStatic: true,
// parentCode: 'system',
},
{
code: 'permission-management',
@@ -112,7 +161,6 @@ const menus = [
icon: 'Key',
sort: 3,
isStatic: true,
// parentCode: 'system',
},
{
code: 'menu-management',
@@ -122,7 +170,6 @@ const menus = [
icon: 'Menu',
sort: 4,
isStatic: true,
// parentCode: 'system',
},
{
code: 'profile',
@@ -153,6 +200,13 @@ const systemSubMenuCodes = [
'menu-management',
];
// 教学管理子菜单 codes
const teachingSubMenuCodes = [
'student-management',
'teacher-management',
'class-management',
];
async function main() {
console.log('开始初始化种子数据...');
@@ -181,8 +235,11 @@ async function main() {
// 3. 创建菜单
console.log('创建菜单...');
// 先创建顶级菜单
const topMenus = menus.filter((m) => !systemSubMenuCodes.includes(m.code));
// 所有子菜单 codes
const allSubMenuCodes = [...systemSubMenuCodes, ...teachingSubMenuCodes];
// 先创建顶级菜单(排除所有子菜单)
const topMenus = menus.filter((m) => !allSubMenuCodes.includes(m.code));
for (const menu of topMenus) {
await prisma.menu.upsert({
where: { code: menu.code },
@@ -207,6 +264,23 @@ async function main() {
});
}
}
// 获取教学管理菜单的 ID
const teachingMenu = await prisma.menu.findUnique({
where: { code: 'teaching' },
});
// 创建教学管理子菜单
if (teachingMenu) {
const subMenus = menus.filter((m) => teachingSubMenuCodes.includes(m.code));
for (const menu of subMenus) {
await prisma.menu.upsert({
where: { code: menu.code },
update: { ...menu, parentId: teachingMenu.id },
create: { ...menu, parentId: teachingMenu.id },
});
}
}
console.log(`已创建 ${menus.length} 个菜单`);
// 4. 清空所有角色的权限和菜单(不分配任何默认权限和菜单)
@@ -288,6 +362,124 @@ async function main() {
console.log(' 邮箱: admin@seclusion.dev');
console.log(' 密码: admin123');
// 6. 创建教学管理演示数据
console.log('\n创建教学管理演示数据...');
// 6.1 创建教师
const teachersData = [
{ teacherNo: 'T001', name: '张明', gender: 'male', phone: '13800000001', email: 'zhangming@school.edu', subject: '语文' },
{ teacherNo: 'T002', name: '李华', gender: 'female', phone: '13800000002', email: 'lihua@school.edu', subject: '数学' },
{ teacherNo: 'T003', name: '王芳', gender: 'female', phone: '13800000003', email: 'wangfang@school.edu', subject: '英语' },
{ teacherNo: 'T004', name: '赵强', gender: 'male', phone: '13800000004', email: 'zhaoqiang@school.edu', subject: '物理' },
{ teacherNo: 'T005', name: '陈静', gender: 'female', phone: '13800000005', email: 'chenjing@school.edu', subject: '化学' },
{ teacherNo: 'T006', name: '刘伟', gender: 'male', phone: '13800000006', email: 'liuwei@school.edu', subject: '生物' },
{ teacherNo: 'T007', name: '周敏', gender: 'female', phone: '13800000007', email: 'zhoumin@school.edu', subject: '历史' },
{ teacherNo: 'T008', name: '吴刚', gender: 'male', phone: '13800000008', email: 'wugang@school.edu', subject: '体育' },
];
const teachers: Record<string, { id: string }> = {};
for (const teacher of teachersData) {
const created = await prisma.teacher.upsert({
where: { teacherNo: teacher.teacherNo },
update: teacher,
create: teacher,
});
teachers[teacher.teacherNo] = created;
}
console.log(`已创建 ${teachersData.length} 名教师`);
// 6.2 创建班级
const classesData = [
{ code: 'C2024-01', name: '高一(1)班', grade: '高一', headTeacherNo: 'T001' },
{ code: 'C2024-02', name: '高一(2)班', grade: '高一', headTeacherNo: 'T002' },
{ code: 'C2024-03', name: '高二(1)班', grade: '高二', headTeacherNo: 'T003' },
{ code: 'C2024-04', name: '高三(1)班', grade: '高三', headTeacherNo: 'T004' },
];
const classes: Record<string, { id: string }> = {};
for (const cls of classesData) {
const headTeacher = teachers[cls.headTeacherNo];
const created = await prisma.class.upsert({
where: { code: cls.code },
update: { name: cls.name, grade: cls.grade, headTeacherId: headTeacher?.id },
create: { code: cls.code, name: cls.name, grade: cls.grade, headTeacherId: headTeacher?.id },
});
classes[cls.code] = created;
}
console.log(`已创建 ${classesData.length} 个班级`);
// 6.3 分配任课教师(多对多)
const classTeacherAssignments = [
{ classCode: 'C2024-01', teacherNos: ['T001', 'T002', 'T003', 'T004', 'T008'] },
{ classCode: 'C2024-02', teacherNos: ['T002', 'T003', 'T005', 'T006', 'T008'] },
{ classCode: 'C2024-03', teacherNos: ['T003', 'T004', 'T005', 'T007', 'T008'] },
{ classCode: 'C2024-04', teacherNos: ['T001', 'T002', 'T004', 'T006', 'T007'] },
];
for (const assignment of classTeacherAssignments) {
const classEntity = classes[assignment.classCode];
if (!classEntity) continue;
// 先删除该班级的所有任课教师关系
await prisma.classTeacher.deleteMany({
where: { classId: classEntity.id },
});
// 创建新的任课教师关系
for (const teacherNo of assignment.teacherNos) {
const teacher = teachers[teacherNo];
if (!teacher) continue;
await prisma.classTeacher.create({
data: {
classId: classEntity.id,
teacherId: teacher.id,
},
});
}
}
console.log('已分配班级任课教师');
// 6.4 创建学生
const studentsData = [
{ studentNo: 'S2024001', name: '小明', gender: 'male', phone: '13900000001', classCode: 'C2024-01' },
{ studentNo: 'S2024002', name: '小红', gender: 'female', phone: '13900000002', classCode: 'C2024-01' },
{ studentNo: 'S2024003', name: '小刚', gender: 'male', phone: '13900000003', classCode: 'C2024-01' },
{ studentNo: 'S2024004', name: '小丽', gender: 'female', phone: '13900000004', classCode: 'C2024-01' },
{ studentNo: 'S2024005', name: '小华', gender: 'male', phone: '13900000005', classCode: 'C2024-02' },
{ studentNo: 'S2024006', name: '小芳', gender: 'female', phone: '13900000006', classCode: 'C2024-02' },
{ studentNo: 'S2024007', name: '小强', gender: 'male', phone: '13900000007', classCode: 'C2024-02' },
{ studentNo: 'S2024008', name: '小敏', gender: 'female', phone: '13900000008', classCode: 'C2024-03' },
{ studentNo: 'S2024009', name: '小伟', gender: 'male', phone: '13900000009', classCode: 'C2024-03' },
{ studentNo: 'S2024010', name: '小静', gender: 'female', phone: '13900000010', classCode: 'C2024-03' },
{ studentNo: 'S2024011', name: '小杰', gender: 'male', phone: '13900000011', classCode: 'C2024-04' },
{ studentNo: 'S2024012', name: '小雪', gender: 'female', phone: '13900000012', classCode: 'C2024-04' },
{ studentNo: 'S2024013', name: '小龙', gender: 'male', phone: '13900000013', classCode: 'C2024-04' },
{ studentNo: 'S2024014', name: '小燕', gender: 'female', phone: '13900000014', classCode: 'C2024-04' },
{ studentNo: 'S2024015', name: '小峰', gender: 'male', phone: '13900000015', classCode: 'C2024-04' },
];
for (const student of studentsData) {
const classEntity = classes[student.classCode];
await prisma.student.upsert({
where: { studentNo: student.studentNo },
update: {
name: student.name,
gender: student.gender,
phone: student.phone,
classId: classEntity?.id,
},
create: {
studentNo: student.studentNo,
name: student.name,
gender: student.gender,
phone: student.phone,
classId: classEntity?.id,
},
});
}
console.log(`已创建 ${studentsData.length} 名学生`);
console.log('\n种子数据初始化完成');
}

View File

@@ -4,6 +4,7 @@ import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AuthModule } from './auth/auth.module';
import { ClassModule } from './class/class.module';
import { CaptchaModule } from './common/captcha/captcha.module';
import { CryptoModule } from './common/crypto/crypto.module';
import { EmailCodeModule } from './common/email-code/email-code.module';
@@ -13,6 +14,8 @@ import { StorageModule } from './common/storage/storage.module';
import { FileModule } from './file/file.module';
import { PermissionModule } from './permission/permission.module';
import { PrismaModule } from './prisma/prisma.module';
import { StudentModule } from './student/student.module';
import { TeacherModule } from './teacher/teacher.module';
import { UserModule } from './user/user.module';
@Module({
@@ -33,6 +36,10 @@ import { UserModule } from './user/user.module';
AuthModule,
UserModule,
PermissionModule,
// 教学管理模块
TeacherModule,
ClassModule,
StudentModule,
],
controllers: [AppController],
providers: [AppService],

View File

@@ -0,0 +1,89 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Param,
Query,
Body,
UseGuards,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiOkResponse,
ApiCreatedResponse,
} from '@nestjs/swagger';
import { ClassService } from './class.service';
import {
CreateClassDto,
UpdateClassDto,
AssignTeachersDto,
ClassQueryDto,
ClassResponseDto,
ClassDetailResponseDto,
PaginatedClassResponseDto,
TeacherBriefDto,
} from './dto/class.dto';
import { JwtAuthGuard } from '@/auth/guards/jwt-auth.guard';
@ApiTags('班级管理')
@Controller('classes')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class ClassController {
constructor(private readonly classService: ClassService) {}
@Get()
@ApiOperation({ summary: '获取班级列表' })
@ApiOkResponse({ type: PaginatedClassResponseDto, description: '班级列表' })
findAll(@Query() query: ClassQueryDto) {
return this.classService.findAllWithRelations(query);
}
@Get(':id')
@ApiOperation({ summary: '获取班级详情' })
@ApiOkResponse({ type: ClassDetailResponseDto, description: '班级详情(包含任课教师和学生数量)' })
findById(@Param('id') id: string) {
return this.classService.findByIdWithDetails(id);
}
@Post()
@ApiOperation({ summary: '创建班级' })
@ApiCreatedResponse({ type: ClassResponseDto, description: '创建成功' })
create(@Body() dto: CreateClassDto) {
return this.classService.create(dto);
}
@Put(':id')
@ApiOperation({ summary: '更新班级' })
@ApiOkResponse({ type: ClassResponseDto, description: '更新成功' })
update(@Param('id') id: string, @Body() dto: UpdateClassDto) {
return this.classService.update(id, dto);
}
@Delete(':id')
@ApiOperation({ summary: '删除班级' })
@ApiOkResponse({ description: '删除成功' })
delete(@Param('id') id: string) {
return this.classService.delete(id);
}
@Get(':id/teachers')
@ApiOperation({ summary: '获取班级任课教师列表' })
@ApiOkResponse({ type: [TeacherBriefDto], description: '任课教师列表' })
getTeachers(@Param('id') id: string) {
return this.classService.getTeachers(id);
}
@Put(':id/teachers')
@ApiOperation({ summary: '分配任课教师' })
@ApiOkResponse({ type: ClassDetailResponseDto, description: '分配成功' })
assignTeachers(@Param('id') id: string, @Body() dto: AssignTeachersDto) {
return this.classService.assignTeachers(id, dto);
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { ClassController } from './class.controller';
import { ClassService } from './class.service';
@Module({
controllers: [ClassController],
providers: [ClassService],
exports: [ClassService],
})
export class ClassModule {}

View File

@@ -0,0 +1,144 @@
import { Injectable } from '@nestjs/common';
import type { Class, Prisma } from '@prisma/client';
import {
UpdateClassDto,
AssignTeachersDto,
ClassResponseDto,
ClassDetailResponseDto,
TeacherBriefDto,
} from './dto/class.dto';
import { CrudOptions } from '@/common/crud/crud.decorator';
import { ManyToManyCrudService } from '@/common/crud/many-to-many-crud.service';
import { PrismaService } from '@/prisma/prisma.service';
// 班级包含班主任信息的类型
type ClassWithHeadTeacher = Class & {
headTeacher?: { id: string; teacherNo: string; name: string; subject?: string | null } | null;
};
// 班级详情类型(包含任课教师和学生数量)
type ClassWithDetails = ClassWithHeadTeacher & {
teachers?: Array<{
teacher: { id: string; teacherNo: string; name: string; subject?: string | null };
}>;
_count?: { students: number };
};
@Injectable()
@CrudOptions({
filterableFields: [{ field: 'name', operator: 'contains' }, 'code', 'grade'],
relations: {
headTeacher: { select: { id: true, teacherNo: true, name: true, subject: true } },
},
manyToMany: {
teachers: {
through: 'classTeacher',
foreignKey: 'classId',
targetKey: 'teacherId',
target: 'teacher',
targetSelect: { id: true, teacherNo: true, name: true, subject: true },
},
},
countRelations: ['students'],
})
export class ClassService extends ManyToManyCrudService<
Class,
Prisma.ClassCreateInput,
UpdateClassDto,
Prisma.ClassWhereInput,
Prisma.ClassWhereUniqueInput,
ClassResponseDto,
ClassDetailResponseDto
> {
constructor(prisma: PrismaService) {
super(prisma, 'class');
}
protected getNotFoundMessage(id: string): string {
return `班级不存在: ${id}`;
}
protected getDeletedMessage(): string {
return '班级已删除';
}
/**
* 转换为列表响应 DTO
*/
protected toResponseDto = (classEntity: ClassWithHeadTeacher): ClassResponseDto => ({
id: classEntity.id,
code: classEntity.code,
name: classEntity.name,
grade: classEntity.grade ?? undefined,
headTeacherId: classEntity.headTeacherId ?? undefined,
headTeacher: classEntity.headTeacher
? this.toTeacherBriefDto(classEntity.headTeacher)
: undefined,
createdAt: classEntity.createdAt.toISOString(),
updatedAt: classEntity.updatedAt.toISOString(),
});
/**
* 转换为详情响应 DTO
*/
protected override toDetailDto(classEntity: ClassWithDetails): ClassDetailResponseDto {
return {
id: classEntity.id,
code: classEntity.code,
name: classEntity.name,
grade: classEntity.grade ?? undefined,
headTeacherId: classEntity.headTeacherId ?? undefined,
headTeacher: classEntity.headTeacher
? this.toTeacherBriefDto(classEntity.headTeacher)
: undefined,
teachers: classEntity.teachers?.map((ct) => this.toTeacherBriefDto(ct.teacher)) ?? [],
studentCount: classEntity._count?.students ?? 0,
createdAt: classEntity.createdAt.toISOString(),
updatedAt: classEntity.updatedAt.toISOString(),
};
}
/**
* 转换教师为简要 DTO
*/
private toTeacherBriefDto = (teacher: {
id: string;
teacherNo: string;
name: string;
subject?: string | null;
}): TeacherBriefDto => ({
id: teacher.id,
teacherNo: teacher.teacherNo,
name: teacher.name,
subject: teacher.subject ?? undefined,
});
/**
* 查询班级详情(包含班主任、任课教师和学生数量)
* 向后兼容方法名
*/
async findByIdWithDetails(id: string): Promise<ClassDetailResponseDto> {
return this.findByIdWithRelations(id);
}
/**
* 分配任课教师(多对多)
* 会先删除现有的任课关系,再建立新的关系
*/
async assignTeachers(classId: string, dto: AssignTeachersDto): Promise<ClassDetailResponseDto> {
return this.assignManyToMany(classId, 'teachers', { targetIds: dto.teacherIds });
}
/**
* 获取班级的任课教师列表
*/
async getTeachers(classId: string): Promise<TeacherBriefDto[]> {
return this.getManyToManyTargets(
classId,
'teachers',
this.toTeacherBriefDto as (item: Record<string, unknown>) => TeacherBriefDto
);
}
}

View File

@@ -0,0 +1,112 @@
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsArray, IsOptional, IsString, Matches } from 'class-validator';
import { createPaginatedResponseDto } from '@/common/crud/dto/paginated-response.dto';
import { PaginationQueryDto } from '@/common/crud/dto/pagination.dto';
/** 创建班级 DTO */
export class CreateClassDto {
@ApiProperty({ description: '班级代码', example: 'C2024001' })
@IsString()
@Matches(/^[A-Za-z0-9]+$/, { message: '班级代码只能包含字母和数字' })
code: string;
@ApiProperty({ description: '班级名称', example: '高三一班' })
@IsString()
name: string;
@ApiPropertyOptional({ description: '年级', example: '高三' })
@IsString()
@IsOptional()
grade?: string;
@ApiPropertyOptional({ description: '班主任 ID' })
@IsString()
@IsOptional()
headTeacherId?: string;
}
/** 更新班级 DTO */
export class UpdateClassDto extends PartialType(CreateClassDto) {}
/** 分配任课教师 DTO */
export class AssignTeachersDto {
@ApiProperty({ description: '教师 ID 列表', type: [String] })
@IsArray()
@IsString({ each: true })
@Type(() => String)
teacherIds: string[];
}
/** 班级查询 DTO */
export class ClassQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ description: '班级名称模糊搜索' })
@IsString()
@IsOptional()
name?: string;
@ApiPropertyOptional({ description: '班级代码' })
@IsString()
@IsOptional()
code?: string;
@ApiPropertyOptional({ description: '年级' })
@IsString()
@IsOptional()
grade?: string;
}
/** 教师简要信息 */
export class TeacherBriefDto {
@ApiProperty({ example: 'clxxx123', description: '教师 ID' })
id: string;
@ApiProperty({ example: 'T2024001', description: '工号' })
teacherNo: string;
@ApiProperty({ example: '张老师', description: '姓名' })
name: string;
@ApiPropertyOptional({ example: '数学', description: '任教科目' })
subject?: string;
}
/** 班级响应 DTO */
export class ClassResponseDto {
@ApiProperty({ example: 'clxxx123', description: '班级 ID' })
id: string;
@ApiProperty({ example: 'C2024001', description: '班级代码' })
code: string;
@ApiProperty({ example: '高三一班', description: '班级名称' })
name: string;
@ApiPropertyOptional({ example: '高三', description: '年级' })
grade?: string;
@ApiPropertyOptional({ example: 'clxxx456', description: '班主任 ID' })
headTeacherId?: string;
@ApiPropertyOptional({ type: TeacherBriefDto, description: '班主任信息' })
headTeacher?: TeacherBriefDto;
@ApiProperty({ example: '2026-01-19T10:00:00.000Z', description: '创建时间' })
createdAt: string;
@ApiProperty({ example: '2026-01-19T10:00:00.000Z', description: '更新时间' })
updatedAt: string;
}
/** 班级详情响应 DTO包含任课教师和学生数量 */
export class ClassDetailResponseDto extends ClassResponseDto {
@ApiProperty({ type: [TeacherBriefDto], description: '任课教师列表' })
teachers: TeacherBriefDto[];
@ApiProperty({ example: 45, description: '学生数量' })
studentCount: number;
}
/** 分页班级响应 DTO */
export class PaginatedClassResponseDto extends createPaginatedResponseDto(ClassResponseDto) {}

View File

@@ -1,10 +1,10 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsEnum, IsOptional, IsInt, IsString, Min, Max, Matches } from 'class-validator';
import { IsEnum, IsOptional, IsInt, IsString, Min, Max } from 'class-validator';
import { FilePurpose } from '../file.constants';
import { createPaginatedResponseDto } from '@/common/crud/dto/paginated-response.dto';
import { PaginationQueryDto } from '@/common/crud/dto/pagination.dto';
/** 上传文件请求 DTO */
export class UploadFileDto {
@@ -18,35 +18,7 @@ export class UploadFileDto {
}
/** 文件列表查询 DTO */
export class FileQueryDto {
@ApiPropertyOptional({ description: '页码', default: 1, minimum: 1 })
@Type(() => Number)
@IsInt()
@IsOptional()
page?: number = 1;
@ApiPropertyOptional({
description: '每页数量(-1 或 0 表示不分页)',
default: 20,
})
@Type(() => Number)
@IsInt()
@IsOptional()
pageSize?: number = 20;
@ApiPropertyOptional({ description: '排序字段', example: 'createdAt' })
@IsString()
@IsOptional()
sortBy?: string;
@ApiPropertyOptional({ description: '排序方向', example: 'desc' })
@IsString()
@Matches(/^(asc|desc)(,(asc|desc))*$/, {
message: 'sortOrder 必须是 asc 或 desc',
})
@IsOptional()
sortOrder?: string = 'desc';
export class FileQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({
enum: Object.values(FilePurpose),
description: '文件用途筛选',

View File

@@ -0,0 +1,113 @@
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import { IsEmail, IsEnum, IsOptional, IsString, Matches } from 'class-validator';
import { createPaginatedResponseDto } from '@/common/crud/dto/paginated-response.dto';
import { PaginationQueryDto } from '@/common/crud/dto/pagination.dto';
// 性别枚举
export const Gender = {
MALE: 'male',
FEMALE: 'female',
} as const;
export type Gender = (typeof Gender)[keyof typeof Gender];
/** 创建学生 DTO */
export class CreateStudentDto {
@ApiProperty({ description: '学号', example: 'S2024001' })
@IsString()
@Matches(/^[A-Za-z0-9]+$/, { message: '学号只能包含字母和数字' })
studentNo: string;
@ApiProperty({ description: '姓名', example: '张三' })
@IsString()
name: string;
@ApiPropertyOptional({ description: '性别', enum: Object.values(Gender), example: 'male' })
@IsEnum(Gender)
@IsOptional()
gender?: Gender;
@ApiPropertyOptional({ description: '手机号', example: '13800138000' })
@IsString()
@IsOptional()
phone?: string;
@ApiPropertyOptional({ description: '邮箱', example: 'student@example.com' })
@IsEmail()
@IsOptional()
email?: string;
@ApiPropertyOptional({ description: '班级 ID' })
@IsString()
@IsOptional()
classId?: string;
}
/** 更新学生 DTO */
export class UpdateStudentDto extends PartialType(CreateStudentDto) {}
/** 学生查询 DTO */
export class StudentQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ description: '姓名模糊搜索' })
@IsString()
@IsOptional()
name?: string;
@ApiPropertyOptional({ description: '学号' })
@IsString()
@IsOptional()
studentNo?: string;
@ApiPropertyOptional({ description: '班级 ID' })
@IsString()
@IsOptional()
classId?: string;
}
/** 班级简要信息 */
export class ClassBriefDto {
@ApiProperty({ example: 'clxxx123', description: '班级 ID' })
id: string;
@ApiProperty({ example: 'C2024001', description: '班级代码' })
code: string;
@ApiProperty({ example: '高三一班', description: '班级名称' })
name: string;
}
/** 学生响应 DTO */
export class StudentResponseDto {
@ApiProperty({ example: 'clxxx123', description: '学生 ID' })
id: string;
@ApiProperty({ example: 'S2024001', description: '学号' })
studentNo: string;
@ApiProperty({ example: '张三', description: '姓名' })
name: string;
@ApiPropertyOptional({ example: 'male', description: '性别' })
gender?: string;
@ApiPropertyOptional({ example: '13800138000', description: '手机号' })
phone?: string;
@ApiPropertyOptional({ example: 'student@example.com', description: '邮箱' })
email?: string;
@ApiPropertyOptional({ example: 'clxxx456', description: '班级 ID' })
classId?: string;
@ApiPropertyOptional({ type: ClassBriefDto, description: '班级信息' })
class?: ClassBriefDto;
@ApiProperty({ example: '2026-01-19T10:00:00.000Z', description: '创建时间' })
createdAt: string;
@ApiProperty({ example: '2026-01-19T10:00:00.000Z', description: '更新时间' })
updatedAt: string;
}
/** 分页学生响应 DTO */
export class PaginatedStudentResponseDto extends createPaginatedResponseDto(StudentResponseDto) {}

View File

@@ -0,0 +1,72 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Param,
Query,
Body,
UseGuards,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiOkResponse,
ApiCreatedResponse,
} from '@nestjs/swagger';
import {
CreateStudentDto,
UpdateStudentDto,
StudentQueryDto,
StudentResponseDto,
PaginatedStudentResponseDto,
} from './dto/student.dto';
import { StudentService } from './student.service';
import { JwtAuthGuard } from '@/auth/guards/jwt-auth.guard';
@ApiTags('学生管理')
@Controller('students')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class StudentController {
constructor(private readonly studentService: StudentService) {}
@Get()
@ApiOperation({ summary: '获取学生列表' })
@ApiOkResponse({ type: PaginatedStudentResponseDto, description: '学生列表' })
findAll(@Query() query: StudentQueryDto) {
return this.studentService.findAllWithRelations(query);
}
@Get(':id')
@ApiOperation({ summary: '获取学生详情' })
@ApiOkResponse({ type: StudentResponseDto, description: '学生详情' })
findById(@Param('id') id: string) {
return this.studentService.findByIdWithClass(id);
}
@Post()
@ApiOperation({ summary: '创建学生' })
@ApiCreatedResponse({ type: StudentResponseDto, description: '创建成功' })
create(@Body() dto: CreateStudentDto) {
return this.studentService.create(dto);
}
@Put(':id')
@ApiOperation({ summary: '更新学生' })
@ApiOkResponse({ type: StudentResponseDto, description: '更新成功' })
update(@Param('id') id: string, @Body() dto: UpdateStudentDto) {
return this.studentService.update(id, dto);
}
@Delete(':id')
@ApiOperation({ summary: '删除学生' })
@ApiOkResponse({ description: '删除成功' })
delete(@Param('id') id: string) {
return this.studentService.delete(id);
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { StudentController } from './student.controller';
import { StudentService } from './student.service';
@Module({
controllers: [StudentController],
providers: [StudentService],
exports: [StudentService],
})
export class StudentModule {}

View File

@@ -0,0 +1,65 @@
import { Injectable } from '@nestjs/common';
import type { Prisma, Student } from '@prisma/client';
import { UpdateStudentDto, StudentResponseDto } from './dto/student.dto';
import { CrudOptions } from '@/common/crud/crud.decorator';
import { RelationCrudService } from '@/common/crud/relation-crud.service';
import { PrismaService } from '@/prisma/prisma.service';
// 学生包含班级信息的类型
type StudentWithClass = Student & {
class?: { id: string; code: string; name: string } | null;
};
@Injectable()
@CrudOptions({
filterableFields: [{ field: 'name', operator: 'contains' }, 'studentNo', 'classId'],
relations: {
class: { select: { id: true, code: true, name: true } },
},
})
export class StudentService extends RelationCrudService<
Student,
Prisma.StudentCreateInput,
UpdateStudentDto,
Prisma.StudentWhereInput,
Prisma.StudentWhereUniqueInput,
StudentResponseDto
> {
constructor(prisma: PrismaService) {
super(prisma, 'student');
}
protected getNotFoundMessage(id: string): string {
return `学生不存在: ${id}`;
}
protected getDeletedMessage(): string {
return '学生已删除';
}
/**
* 转换为响应 DTO
*/
protected toResponseDto = (student: StudentWithClass): StudentResponseDto => ({
id: student.id,
studentNo: student.studentNo,
name: student.name,
gender: student.gender ?? undefined,
phone: student.phone ?? undefined,
email: student.email ?? undefined,
classId: student.classId ?? undefined,
class: student.class ?? undefined,
createdAt: student.createdAt.toISOString(),
updatedAt: student.updatedAt.toISOString(),
});
/**
* 查询学生详情(包含班级信息)
* 向后兼容方法名
*/
async findByIdWithClass(id: string): Promise<StudentResponseDto> {
return this.findByIdWithRelations(id);
}
}

View File

@@ -0,0 +1,98 @@
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import { IsEmail, IsEnum, IsOptional, IsString, Matches } from 'class-validator';
import { createPaginatedResponseDto } from '@/common/crud/dto/paginated-response.dto';
import { PaginationQueryDto } from '@/common/crud/dto/pagination.dto';
// 性别枚举
export const Gender = {
MALE: 'male',
FEMALE: 'female',
} as const;
export type Gender = (typeof Gender)[keyof typeof Gender];
/** 创建教师 DTO */
export class CreateTeacherDto {
@ApiProperty({ description: '工号', example: 'T2024001' })
@IsString()
@Matches(/^[A-Za-z0-9]+$/, { message: '工号只能包含字母和数字' })
teacherNo: string;
@ApiProperty({ description: '姓名', example: '张老师' })
@IsString()
name: string;
@ApiPropertyOptional({ description: '性别', enum: Object.values(Gender), example: 'male' })
@IsEnum(Gender)
@IsOptional()
gender?: Gender;
@ApiPropertyOptional({ description: '手机号', example: '13800138000' })
@IsString()
@IsOptional()
phone?: string;
@ApiPropertyOptional({ description: '邮箱', example: 'teacher@example.com' })
@IsEmail()
@IsOptional()
email?: string;
@ApiPropertyOptional({ description: '任教科目', example: '数学' })
@IsString()
@IsOptional()
subject?: string;
}
/** 更新教师 DTO */
export class UpdateTeacherDto extends PartialType(CreateTeacherDto) {}
/** 教师查询 DTO */
export class TeacherQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ description: '姓名模糊搜索' })
@IsString()
@IsOptional()
name?: string;
@ApiPropertyOptional({ description: '工号' })
@IsString()
@IsOptional()
teacherNo?: string;
@ApiPropertyOptional({ description: '任教科目' })
@IsString()
@IsOptional()
subject?: string;
}
/** 教师响应 DTO */
export class TeacherResponseDto {
@ApiProperty({ example: 'clxxx123', description: '教师 ID' })
id: string;
@ApiProperty({ example: 'T2024001', description: '工号' })
teacherNo: string;
@ApiProperty({ example: '张老师', description: '姓名' })
name: string;
@ApiPropertyOptional({ example: 'male', description: '性别' })
gender?: string;
@ApiPropertyOptional({ example: '13800138000', description: '手机号' })
phone?: string;
@ApiPropertyOptional({ example: 'teacher@example.com', description: '邮箱' })
email?: string;
@ApiPropertyOptional({ example: '数学', description: '任教科目' })
subject?: string;
@ApiProperty({ example: '2026-01-19T10:00:00.000Z', description: '创建时间' })
createdAt: string;
@ApiProperty({ example: '2026-01-19T10:00:00.000Z', description: '更新时间' })
updatedAt: string;
}
/** 分页教师响应 DTO */
export class PaginatedTeacherResponseDto extends createPaginatedResponseDto(TeacherResponseDto) {}

View File

@@ -0,0 +1,72 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Param,
Query,
Body,
UseGuards,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiOkResponse,
ApiCreatedResponse,
} from '@nestjs/swagger';
import {
CreateTeacherDto,
UpdateTeacherDto,
TeacherQueryDto,
TeacherResponseDto,
PaginatedTeacherResponseDto,
} from './dto/teacher.dto';
import { TeacherService } from './teacher.service';
import { JwtAuthGuard } from '@/auth/guards/jwt-auth.guard';
@ApiTags('教师管理')
@Controller('teachers')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class TeacherController {
constructor(private readonly teacherService: TeacherService) {}
@Get()
@ApiOperation({ summary: '获取教师列表' })
@ApiOkResponse({ type: PaginatedTeacherResponseDto, description: '教师列表' })
findAll(@Query() query: TeacherQueryDto) {
return this.teacherService.findAll(query);
}
@Get(':id')
@ApiOperation({ summary: '获取教师详情' })
@ApiOkResponse({ type: TeacherResponseDto, description: '教师详情' })
findById(@Param('id') id: string) {
return this.teacherService.findById(id);
}
@Post()
@ApiOperation({ summary: '创建教师' })
@ApiCreatedResponse({ type: TeacherResponseDto, description: '创建成功' })
create(@Body() dto: CreateTeacherDto) {
return this.teacherService.create(dto);
}
@Put(':id')
@ApiOperation({ summary: '更新教师' })
@ApiOkResponse({ type: TeacherResponseDto, description: '更新成功' })
update(@Param('id') id: string, @Body() dto: UpdateTeacherDto) {
return this.teacherService.update(id, dto);
}
@Delete(':id')
@ApiOperation({ summary: '删除教师' })
@ApiOkResponse({ description: '删除成功' })
delete(@Param('id') id: string) {
return this.teacherService.delete(id);
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { TeacherController } from './teacher.controller';
import { TeacherService } from './teacher.service';
@Module({
controllers: [TeacherController],
providers: [TeacherService],
exports: [TeacherService],
})
export class TeacherModule {}

View File

@@ -0,0 +1,36 @@
import { Injectable } from '@nestjs/common';
import type { Prisma, Teacher } from '@prisma/client';
import { UpdateTeacherDto } from './dto/teacher.dto';
import { CrudOptions } from '@/common/crud/crud.decorator';
import { CrudService } from '@/common/crud/crud.service';
import { PrismaService } from '@/prisma/prisma.service';
@Injectable()
@CrudOptions({
filterableFields: [
{ field: 'name', operator: 'contains' },
'teacherNo',
'subject',
],
})
export class TeacherService extends CrudService<
Teacher,
Prisma.TeacherCreateInput,
UpdateTeacherDto,
Prisma.TeacherWhereInput,
Prisma.TeacherWhereUniqueInput
> {
constructor(prisma: PrismaService) {
super(prisma, 'teacher');
}
protected getNotFoundMessage(id: string): string {
return `教师不存在: ${id}`;
}
protected getDeletedMessage(): string {
return '教师已删除';
}
}

View File

@@ -0,0 +1,33 @@
'use client';
import { ClassesTable } from '@/components/classes/ClassesTable';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
export default function ClassesPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground"></p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<ClassesTable />
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,33 @@
'use client';
import { StudentsTable } from '@/components/students/StudentsTable';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
export default function StudentsPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground"></p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<StudentsTable />
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,33 @@
'use client';
import { TeachersTable } from '@/components/teachers/TeachersTable';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
export default function TeachersPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground"></p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<TeachersTable />
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,230 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useCreateClass, useUpdateClass } from '@/hooks/useClasses';
import { useTeachers } from '@/hooks/useTeachers';
import type { ClassResponse } from '@/services/class.service';
const classSchema = z.object({
code: z.string().min(1, '请输入班级代码').max(50, '班级代码最多 50 个字符'),
name: z.string().min(1, '请输入班级名称').max(100, '班级名称最多 100 个字符'),
grade: z.string().max(20, '年级最多 20 个字符').optional(),
headTeacherId: z.string().optional(),
});
type ClassFormValues = z.infer<typeof classSchema>;
// 空选项占位符Radix Select 不允许空字符串)
const NONE_VALUE = '__none__';
interface ClassEditDialogProps {
classItem: ClassResponse | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ClassEditDialog({
classItem,
open,
onOpenChange,
}: ClassEditDialogProps) {
const createClass = useCreateClass();
const updateClass = useUpdateClass();
const isEditing = !!classItem;
// 获取教师列表用于选择班主任
const { data: teachersData } = useTeachers({ pageSize: 100 });
const form = useForm<ClassFormValues>({
resolver: zodResolver(classSchema),
defaultValues: {
code: '',
name: '',
grade: '',
headTeacherId: NONE_VALUE,
},
});
// 当班级数据变化时重置表单
useEffect(() => {
if (open) {
if (classItem) {
form.reset({
code: classItem.code,
name: classItem.name,
grade: classItem.grade || '',
headTeacherId: classItem.headTeacherId || NONE_VALUE,
});
} else {
form.reset({
code: '',
name: '',
grade: '',
headTeacherId: NONE_VALUE,
});
}
}
}, [classItem, open, form]);
const onSubmit = async (values: ClassFormValues) => {
try {
const data = {
code: values.code,
name: values.name,
grade: values.grade || undefined,
headTeacherId: values.headTeacherId === NONE_VALUE ? undefined : values.headTeacherId,
};
if (isEditing) {
await updateClass.mutateAsync({
id: classItem.id,
data,
});
toast.success('班级信息已更新');
} else {
await createClass.mutateAsync(data);
toast.success('班级已创建');
}
onOpenChange(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : isEditing ? '更新失败' : '创建失败');
}
};
const isPending = createClass.isPending || updateClass.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{isEditing ? '编辑班级' : '新建班级'}</DialogTitle>
<DialogDescription>
{isEditing ? `修改班级 ${classItem.name} 的信息` : '填写班级信息创建新班级'}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="code"
render={({ field }) => (
<FormItem>
<FormLabel> *</FormLabel>
<FormControl>
<Input placeholder="请输入班级代码" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel> *</FormLabel>
<FormControl>
<Input placeholder="请输入班级名称" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="grade"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="请输入年级" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="headTeacherId"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="请选择班主任" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={NONE_VALUE}></SelectItem>
{teachersData?.items.map((teacher) => (
<SelectItem key={teacher.id} value={teacher.id}>
{teacher.name} ({teacher.teacherNo})
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? '保存中...' : '保存'}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,218 @@
'use client';
import { Loader2, Plus, Trash2, UserCheck } from 'lucide-react';
import { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useClassTeachers, useAssignTeachers } from '@/hooks/useClasses';
import { useTeachers } from '@/hooks/useTeachers';
import type { ClassResponse, TeacherBrief } from '@/services/class.service';
interface ClassTeachersDialogProps {
classItem: ClassResponse | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ClassTeachersDialog({
classItem,
open,
onOpenChange,
}: ClassTeachersDialogProps) {
const [selectedTeachers, setSelectedTeachers] = useState<TeacherBrief[]>([]);
const [selectedTeacherId, setSelectedTeacherId] = useState<string>('');
// 获取当前班级的任课教师
const { data: classTeachers, isLoading: isLoadingTeachers } = useClassTeachers(
classItem?.id || ''
);
// 获取所有教师列表
const { data: allTeachers } = useTeachers({ pageSize: 100 });
// 分配任课教师
const assignTeachers = useAssignTeachers();
// 当班级数据或任课教师变化时更新选中列表
useEffect(() => {
if (classTeachers) {
setSelectedTeachers(classTeachers);
}
}, [classTeachers]);
// 当对话框关闭时重置状态
useEffect(() => {
if (!open) {
setSelectedTeacherId('');
}
}, [open]);
// 过滤出未被选中的教师
const availableTeachers = allTeachers?.items.filter(
(teacher) => !selectedTeachers.some((t) => t.id === teacher.id)
) || [];
const handleAddTeacher = () => {
if (!selectedTeacherId) return;
const teacher = allTeachers?.items.find((t) => t.id === selectedTeacherId);
if (teacher) {
setSelectedTeachers((prev) => [
...prev,
{
id: teacher.id,
teacherNo: teacher.teacherNo,
name: teacher.name,
subject: teacher.subject,
},
]);
setSelectedTeacherId('');
}
};
const handleRemoveTeacher = (teacherId: string) => {
setSelectedTeachers((prev) => prev.filter((t) => t.id !== teacherId));
};
const handleSave = async () => {
if (!classItem) return;
try {
await assignTeachers.mutateAsync({
id: classItem.id,
data: {
teacherIds: selectedTeachers.map((t) => t.id),
},
});
toast.success('任课教师已更新');
onOpenChange(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : '更新失败');
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
{classItem ? `管理班级 ${classItem.name} 的任课教师` : '选择任课教师'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 添加教师 */}
<div className="flex gap-2">
<Select
value={selectedTeacherId}
onValueChange={setSelectedTeacherId}
>
<SelectTrigger className="flex-1">
<SelectValue placeholder="选择教师添加" />
</SelectTrigger>
<SelectContent>
{availableTeachers.map((teacher) => (
<SelectItem key={teacher.id} value={teacher.id}>
{teacher.name} ({teacher.teacherNo})
{teacher.subject && ` - ${teacher.subject}`}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
size="icon"
onClick={handleAddTeacher}
disabled={!selectedTeacherId}
>
<Plus className="h-4 w-4" />
</Button>
</div>
{/* 已选教师列表 */}
<div className="border rounded-lg p-3 min-h-[200px]">
<div className="text-sm font-medium mb-2 text-muted-foreground">
({selectedTeachers.length})
</div>
{isLoadingTeachers ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : selectedTeachers.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
</div>
) : (
<div className="space-y-2">
{selectedTeachers.map((teacher) => (
<div
key={teacher.id}
className="flex items-center justify-between p-2 bg-muted/50 rounded-md"
>
<div className="flex items-center gap-2">
<UserCheck className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{teacher.name}</span>
<code className="text-xs text-muted-foreground">
{teacher.teacherNo}
</code>
{teacher.subject && (
<Badge variant="outline" className="text-xs">
{teacher.subject}
</Badge>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => handleRemoveTeacher(teacher.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
</Button>
<Button
type="button"
onClick={handleSave}
disabled={assignTeachers.isPending}
>
{assignTeachers.isPending ? '保存中...' : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,333 @@
'use client';
import { formatDate } from '@seclusion/shared';
import type { ColumnDef } from '@tanstack/react-table';
import { MoreHorizontal, Trash2, Pencil, Plus, School, Users } from 'lucide-react';
import { useState, useCallback } from 'react';
import { toast } from 'sonner';
import { ClassEditDialog } from './ClassEditDialog';
import { ClassTeachersDialog } from './ClassTeachersDialog';
import {
DataTable,
DataTableColumnHeader,
type PaginationState,
type SortingParams,
type SearchConfig,
type SearchParams,
} from '@/components/shared/DataTable';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { PAGINATION } from '@/config/constants';
import { useClasses, useDeleteClass } from '@/hooks/useClasses';
import type { ClassResponse } from '@/services/class.service';
interface ClassActionsProps {
classItem: ClassResponse;
onEdit: (classItem: ClassResponse) => void;
onManageTeachers: (classItem: ClassResponse) => void;
onDelete: (id: string) => void;
}
function ClassActions({ classItem, onEdit, onManageTeachers, onDelete }: ClassActionsProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only"></span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onEdit(classItem)}>
<Pencil className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onManageTeachers(classItem)}>
<Users className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onDelete(classItem.id)}
className="text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
export function ClassesTable() {
// 分页状态
const [pagination, setPagination] = useState<PaginationState>({
page: PAGINATION.DEFAULT_PAGE,
pageSize: PAGINATION.DEFAULT_PAGE_SIZE,
});
// 排序状态
const [sorting, setSorting] = useState<SortingParams>({});
// 搜索状态
const [searchParams, setSearchParams] = useState<SearchParams>({});
// 对话框状态
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [classToDelete, setClassToDelete] = useState<string | null>(null);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [classToEdit, setClassToEdit] = useState<ClassResponse | null>(null);
const [teachersDialogOpen, setTeachersDialogOpen] = useState(false);
const [classForTeachers, setClassForTeachers] = useState<ClassResponse | null>(null);
// 查询
const { data, isLoading, refetch } = useClasses({
page: pagination.page,
pageSize: pagination.pageSize,
name: typeof searchParams.name === 'string' ? searchParams.name : undefined,
code: typeof searchParams.code === 'string' ? searchParams.code : undefined,
grade: typeof searchParams.grade === 'string' ? searchParams.grade : undefined,
...sorting,
});
// 变更
const deleteClass = useDeleteClass();
const handleDelete = useCallback((id: string) => {
setClassToDelete(id);
setDeleteDialogOpen(true);
}, []);
const confirmDelete = useCallback(async () => {
if (!classToDelete) return;
try {
await deleteClass.mutateAsync(classToDelete);
toast.success('班级已删除');
} catch (error) {
toast.error(error instanceof Error ? error.message : '删除失败');
} finally {
setDeleteDialogOpen(false);
setClassToDelete(null);
}
}, [classToDelete, deleteClass]);
const handleEdit = useCallback((classItem: ClassResponse) => {
setClassToEdit(classItem);
setEditDialogOpen(true);
}, []);
const handleCreate = useCallback(() => {
setClassToEdit(null);
setEditDialogOpen(true);
}, []);
const handleManageTeachers = useCallback((classItem: ClassResponse) => {
setClassForTeachers(classItem);
setTeachersDialogOpen(true);
}, []);
// 分页变化处理
const handlePaginationChange = useCallback((newPagination: PaginationState) => {
setPagination(newPagination);
}, []);
// 排序变化处理
const handleSortingChange = useCallback((newSorting: SortingParams) => {
setSorting(newSorting);
setPagination((prev) => ({ ...prev, page: 1 }));
}, []);
// 搜索变化处理
const handleSearchChange = useCallback((params: SearchParams) => {
setSearchParams(params);
setPagination((prev) => ({ ...prev, page: 1 }));
}, []);
// 搜索配置
const searchConfig: SearchConfig<ClassResponse> = {
fields: [
{
type: 'text',
key: 'code',
label: '班级代码',
placeholder: '搜索班级代码',
},
{
type: 'text',
key: 'name',
label: '班级名称',
placeholder: '搜索班级名称',
},
{
type: 'text',
key: 'grade',
label: '年级',
placeholder: '搜索年级',
},
],
columns: 4,
};
// 列定义
const columns: ColumnDef<ClassResponse>[] = [
{
accessorKey: 'code',
header: ({ column }) => (
<DataTableColumnHeader
title="班级代码"
sortKey={column.id}
sorting={sorting}
onSortingChange={handleSortingChange}
/>
),
cell: ({ row }) => (
<div className="flex items-center gap-2">
<School className="h-4 w-4 text-muted-foreground" />
<code className="text-sm">{row.original.code}</code>
</div>
),
},
{
accessorKey: 'name',
header: ({ column }) => (
<DataTableColumnHeader
title="班级名称"
sortKey={column.id}
sorting={sorting}
onSortingChange={handleSortingChange}
/>
),
},
{
accessorKey: 'grade',
header: '年级',
cell: ({ row }) => row.original.grade ? (
<Badge variant="outline">{row.original.grade}</Badge>
) : '-',
},
{
accessorKey: 'headTeacher',
header: '班主任',
cell: ({ row }) => row.original.headTeacher?.name || '-',
},
{
accessorKey: 'createdAt',
header: ({ column }) => (
<DataTableColumnHeader
title="创建时间"
sortKey={column.id}
sorting={sorting}
onSortingChange={handleSortingChange}
/>
),
cell: ({ row }) =>
formatDate(new Date(row.original.createdAt), 'YYYY-MM-DD HH:mm'),
},
{
id: 'actions',
header: '操作',
cell: ({ row }) => (
<ClassActions
classItem={row.original}
onEdit={handleEdit}
onManageTeachers={handleManageTeachers}
onDelete={handleDelete}
/>
),
},
];
return (
<div className="space-y-4">
{/* 工具栏 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button size="sm" onClick={handleCreate}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
<Button variant="outline" size="sm" onClick={() => refetch()}>
</Button>
</div>
{/* 数据表格 */}
<DataTable
columns={columns}
data={data?.items ?? []}
pagination={pagination}
paginationInfo={
data ? { total: data.total, totalPages: data.totalPages } : undefined
}
onPaginationChange={handlePaginationChange}
manualPagination
sorting={sorting}
onSortingChange={handleSortingChange}
manualSorting
searchConfig={searchConfig}
searchParams={searchParams}
onSearchChange={handleSearchChange}
isLoading={isLoading}
emptyMessage="暂无班级"
/>
{/* 删除确认弹窗 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 编辑/新建班级弹窗 */}
<ClassEditDialog
classItem={classToEdit}
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
/>
{/* 任课教师管理弹窗 */}
<ClassTeachersDialog
classItem={classForTeachers}
open={teachersDialogOpen}
onOpenChange={setTeachersDialogOpen}
/>
</div>
);
}

View File

@@ -0,0 +1,3 @@
export { ClassesTable } from './ClassesTable';
export { ClassEditDialog } from './ClassEditDialog';
export { ClassTeachersDialog } from './ClassTeachersDialog';

View File

@@ -0,0 +1,281 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useClasses } from '@/hooks/useClasses';
import { useCreateStudent, useUpdateStudent } from '@/hooks/useStudents';
import type { StudentResponse } from '@/services/student.service';
const studentSchema = z.object({
studentNo: z.string().min(1, '请输入学号').max(50, '学号最多 50 个字符'),
name: z.string().min(1, '请输入姓名').max(50, '姓名最多 50 个字符'),
gender: z.enum(['male', 'female', '']).optional(),
phone: z.string().max(20, '电话最多 20 个字符').optional(),
email: z.string().email('请输入有效的邮箱地址').or(z.literal('')).optional(),
classId: z.string().optional(),
});
type StudentFormValues = z.infer<typeof studentSchema>;
// 空选项占位符Radix Select 不允许空字符串)
const NONE_VALUE = '__none__';
interface StudentEditDialogProps {
student: StudentResponse | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function StudentEditDialog({
student,
open,
onOpenChange,
}: StudentEditDialogProps) {
const createStudent = useCreateStudent();
const updateStudent = useUpdateStudent();
const isEditing = !!student;
// 获取班级列表用于选择
const { data: classesData } = useClasses({ pageSize: 100 });
const form = useForm<StudentFormValues>({
resolver: zodResolver(studentSchema),
defaultValues: {
studentNo: '',
name: '',
gender: '',
phone: '',
email: '',
classId: NONE_VALUE,
},
});
// 当学生数据变化时重置表单
useEffect(() => {
if (open) {
if (student) {
form.reset({
studentNo: student.studentNo,
name: student.name,
gender: (student.gender as 'male' | 'female' | '') || '',
phone: student.phone || '',
email: student.email || '',
classId: student.classId || NONE_VALUE,
});
} else {
form.reset({
studentNo: '',
name: '',
gender: '',
phone: '',
email: '',
classId: NONE_VALUE,
});
}
}
}, [student, open, form]);
const onSubmit = async (values: StudentFormValues) => {
try {
const data = {
studentNo: values.studentNo,
name: values.name,
gender: values.gender === '' ? undefined : (values.gender as 'male' | 'female'),
phone: values.phone || undefined,
email: values.email || undefined,
classId: values.classId === NONE_VALUE ? undefined : values.classId,
};
if (isEditing) {
await updateStudent.mutateAsync({
id: student.id,
data,
});
toast.success('学生信息已更新');
} else {
await createStudent.mutateAsync(data);
toast.success('学生已创建');
}
onOpenChange(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : isEditing ? '更新失败' : '创建失败');
}
};
const isPending = createStudent.isPending || updateStudent.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{isEditing ? '编辑学生' : '新建学生'}</DialogTitle>
<DialogDescription>
{isEditing ? `修改学生 ${student.name} 的信息` : '填写学生信息创建新学生'}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="studentNo"
render={({ field }) => (
<FormItem>
<FormLabel> *</FormLabel>
<FormControl>
<Input placeholder="请输入学号" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel> *</FormLabel>
<FormControl>
<Input placeholder="请输入姓名" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="gender"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="请选择性别" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="male"></SelectItem>
<SelectItem value="female"></SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="classId"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="请选择班级" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={NONE_VALUE}></SelectItem>
{classesData?.items.map((classItem) => (
<SelectItem key={classItem.id} value={classItem.id}>
{classItem.name} ({classItem.code})
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="请输入电话号码" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="email" placeholder="请输入邮箱" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? '保存中...' : '保存'}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,316 @@
'use client';
import { formatDate } from '@seclusion/shared';
import type { ColumnDef } from '@tanstack/react-table';
import { MoreHorizontal, Trash2, Pencil, Plus, GraduationCap } from 'lucide-react';
import { useState, useCallback } from 'react';
import { toast } from 'sonner';
import { StudentEditDialog } from './StudentEditDialog';
import {
DataTable,
DataTableColumnHeader,
type PaginationState,
type SortingParams,
type SearchConfig,
type SearchParams,
} from '@/components/shared/DataTable';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { PAGINATION } from '@/config/constants';
import { useStudents, useDeleteStudent } from '@/hooks/useStudents';
import type { StudentResponse } from '@/services/student.service';
interface StudentActionsProps {
student: StudentResponse;
onEdit: (student: StudentResponse) => void;
onDelete: (id: string) => void;
}
function StudentActions({ student, onEdit, onDelete }: StudentActionsProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only"></span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onEdit(student)}>
<Pencil className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onDelete(student.id)}
className="text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
export function StudentsTable() {
// 分页状态
const [pagination, setPagination] = useState<PaginationState>({
page: PAGINATION.DEFAULT_PAGE,
pageSize: PAGINATION.DEFAULT_PAGE_SIZE,
});
// 排序状态
const [sorting, setSorting] = useState<SortingParams>({});
// 搜索状态
const [searchParams, setSearchParams] = useState<SearchParams>({});
// 对话框状态
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [studentToDelete, setStudentToDelete] = useState<string | null>(null);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [studentToEdit, setStudentToEdit] = useState<StudentResponse | null>(null);
// 查询
const { data, isLoading, refetch } = useStudents({
page: pagination.page,
pageSize: pagination.pageSize,
name: typeof searchParams.name === 'string' ? searchParams.name : undefined,
studentNo: typeof searchParams.studentNo === 'string' ? searchParams.studentNo : undefined,
...sorting,
});
// 变更
const deleteStudent = useDeleteStudent();
const handleDelete = useCallback((id: string) => {
setStudentToDelete(id);
setDeleteDialogOpen(true);
}, []);
const confirmDelete = useCallback(async () => {
if (!studentToDelete) return;
try {
await deleteStudent.mutateAsync(studentToDelete);
toast.success('学生已删除');
} catch (error) {
toast.error(error instanceof Error ? error.message : '删除失败');
} finally {
setDeleteDialogOpen(false);
setStudentToDelete(null);
}
}, [studentToDelete, deleteStudent]);
const handleEdit = useCallback((student: StudentResponse) => {
setStudentToEdit(student);
setEditDialogOpen(true);
}, []);
const handleCreate = useCallback(() => {
setStudentToEdit(null);
setEditDialogOpen(true);
}, []);
// 分页变化处理
const handlePaginationChange = useCallback((newPagination: PaginationState) => {
setPagination(newPagination);
}, []);
// 排序变化处理
const handleSortingChange = useCallback((newSorting: SortingParams) => {
setSorting(newSorting);
setPagination((prev) => ({ ...prev, page: 1 }));
}, []);
// 搜索变化处理
const handleSearchChange = useCallback((params: SearchParams) => {
setSearchParams(params);
setPagination((prev) => ({ ...prev, page: 1 }));
}, []);
// 搜索配置
const searchConfig: SearchConfig<StudentResponse> = {
fields: [
{
type: 'text',
key: 'studentNo',
label: '学号',
placeholder: '搜索学号',
},
{
type: 'text',
key: 'name',
label: '姓名',
placeholder: '搜索学生姓名',
},
],
columns: 4,
};
// 列定义
const columns: ColumnDef<StudentResponse>[] = [
{
accessorKey: 'studentNo',
header: ({ column }) => (
<DataTableColumnHeader
title="学号"
sortKey={column.id}
sorting={sorting}
onSortingChange={handleSortingChange}
/>
),
cell: ({ row }) => (
<div className="flex items-center gap-2">
<GraduationCap className="h-4 w-4 text-muted-foreground" />
<code className="text-sm">{row.original.studentNo}</code>
</div>
),
},
{
accessorKey: 'name',
header: ({ column }) => (
<DataTableColumnHeader
title="姓名"
sortKey={column.id}
sorting={sorting}
onSortingChange={handleSortingChange}
/>
),
},
{
accessorKey: 'gender',
header: '性别',
cell: ({ row }) => {
const gender = row.original.gender;
if (!gender) return '-';
return (
<Badge variant="outline">
{gender === 'male' ? '男' : '女'}
</Badge>
);
},
},
{
accessorKey: 'class',
header: '班级',
cell: ({ row }) => row.original.class?.name || '-',
},
{
accessorKey: 'phone',
header: '电话',
cell: ({ row }) => row.original.phone || '-',
},
{
accessorKey: 'createdAt',
header: ({ column }) => (
<DataTableColumnHeader
title="创建时间"
sortKey={column.id}
sorting={sorting}
onSortingChange={handleSortingChange}
/>
),
cell: ({ row }) =>
formatDate(new Date(row.original.createdAt), 'YYYY-MM-DD HH:mm'),
},
{
id: 'actions',
header: '操作',
cell: ({ row }) => (
<StudentActions
student={row.original}
onEdit={handleEdit}
onDelete={handleDelete}
/>
),
},
];
return (
<div className="space-y-4">
{/* 工具栏 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button size="sm" onClick={handleCreate}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
<Button variant="outline" size="sm" onClick={() => refetch()}>
</Button>
</div>
{/* 数据表格 */}
<DataTable
columns={columns}
data={data?.items ?? []}
pagination={pagination}
paginationInfo={
data ? { total: data.total, totalPages: data.totalPages } : undefined
}
onPaginationChange={handlePaginationChange}
manualPagination
sorting={sorting}
onSortingChange={handleSortingChange}
manualSorting
searchConfig={searchConfig}
searchParams={searchParams}
onSearchChange={handleSearchChange}
isLoading={isLoading}
emptyMessage="暂无学生"
/>
{/* 删除确认弹窗 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 编辑/新建学生弹窗 */}
<StudentEditDialog
student={studentToEdit}
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
/>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { StudentsTable } from './StudentsTable';
export { StudentEditDialog } from './StudentEditDialog';

View File

@@ -0,0 +1,259 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useCreateTeacher, useUpdateTeacher } from '@/hooks/useTeachers';
import type { TeacherResponse } from '@/services/teacher.service';
const teacherSchema = z.object({
teacherNo: z.string().min(1, '请输入工号').max(50, '工号最多 50 个字符'),
name: z.string().min(1, '请输入姓名').max(50, '姓名最多 50 个字符'),
gender: z.enum(['male', 'female', '']).optional(),
phone: z.string().max(20, '电话最多 20 个字符').optional(),
email: z.string().email('请输入有效的邮箱地址').or(z.literal('')).optional(),
subject: z.string().max(50, '科目最多 50 个字符').optional(),
});
type TeacherFormValues = z.infer<typeof teacherSchema>;
interface TeacherEditDialogProps {
teacher: TeacherResponse | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function TeacherEditDialog({
teacher,
open,
onOpenChange,
}: TeacherEditDialogProps) {
const createTeacher = useCreateTeacher();
const updateTeacher = useUpdateTeacher();
const isEditing = !!teacher;
const form = useForm<TeacherFormValues>({
resolver: zodResolver(teacherSchema),
defaultValues: {
teacherNo: '',
name: '',
gender: '',
phone: '',
email: '',
subject: '',
},
});
// 当教师数据变化时重置表单
useEffect(() => {
if (open) {
if (teacher) {
form.reset({
teacherNo: teacher.teacherNo,
name: teacher.name,
gender: (teacher.gender as 'male' | 'female' | '') || '',
phone: teacher.phone || '',
email: teacher.email || '',
subject: teacher.subject || '',
});
} else {
form.reset({
teacherNo: '',
name: '',
gender: '',
phone: '',
email: '',
subject: '',
});
}
}
}, [teacher, open, form]);
const onSubmit = async (values: TeacherFormValues) => {
try {
const data = {
teacherNo: values.teacherNo,
name: values.name,
gender: values.gender === '' ? undefined : (values.gender as 'male' | 'female'),
phone: values.phone || undefined,
email: values.email || undefined,
subject: values.subject || undefined,
};
if (isEditing) {
await updateTeacher.mutateAsync({
id: teacher.id,
data,
});
toast.success('教师信息已更新');
} else {
await createTeacher.mutateAsync(data);
toast.success('教师已创建');
}
onOpenChange(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : isEditing ? '更新失败' : '创建失败');
}
};
const isPending = createTeacher.isPending || updateTeacher.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{isEditing ? '编辑教师' : '新建教师'}</DialogTitle>
<DialogDescription>
{isEditing ? `修改教师 ${teacher.name} 的信息` : '填写教师信息创建新教师'}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="teacherNo"
render={({ field }) => (
<FormItem>
<FormLabel> *</FormLabel>
<FormControl>
<Input placeholder="请输入工号" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel> *</FormLabel>
<FormControl>
<Input placeholder="请输入姓名" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="gender"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="请选择性别" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="male"></SelectItem>
<SelectItem value="female"></SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="subject"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="请输入任教科目" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="请输入电话号码" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="email" placeholder="请输入邮箱" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? '保存中...' : '保存'}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,316 @@
'use client';
import { formatDate } from '@seclusion/shared';
import type { ColumnDef } from '@tanstack/react-table';
import { MoreHorizontal, Trash2, Pencil, Plus, UserCheck } from 'lucide-react';
import { useState, useCallback } from 'react';
import { toast } from 'sonner';
import { TeacherEditDialog } from './TeacherEditDialog';
import {
DataTable,
DataTableColumnHeader,
type PaginationState,
type SortingParams,
type SearchConfig,
type SearchParams,
} from '@/components/shared/DataTable';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { PAGINATION } from '@/config/constants';
import { useTeachers, useDeleteTeacher } from '@/hooks/useTeachers';
import type { TeacherResponse } from '@/services/teacher.service';
interface TeacherActionsProps {
teacher: TeacherResponse;
onEdit: (teacher: TeacherResponse) => void;
onDelete: (id: string) => void;
}
function TeacherActions({ teacher, onEdit, onDelete }: TeacherActionsProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only"></span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onEdit(teacher)}>
<Pencil className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onDelete(teacher.id)}
className="text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
export function TeachersTable() {
// 分页状态
const [pagination, setPagination] = useState<PaginationState>({
page: PAGINATION.DEFAULT_PAGE,
pageSize: PAGINATION.DEFAULT_PAGE_SIZE,
});
// 排序状态
const [sorting, setSorting] = useState<SortingParams>({});
// 搜索状态
const [searchParams, setSearchParams] = useState<SearchParams>({});
// 对话框状态
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [teacherToDelete, setTeacherToDelete] = useState<string | null>(null);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [teacherToEdit, setTeacherToEdit] = useState<TeacherResponse | null>(null);
// 查询
const { data, isLoading, refetch } = useTeachers({
page: pagination.page,
pageSize: pagination.pageSize,
name: typeof searchParams.name === 'string' ? searchParams.name : undefined,
subject: typeof searchParams.subject === 'string' ? searchParams.subject : undefined,
...sorting,
});
// 变更
const deleteTeacher = useDeleteTeacher();
const handleDelete = useCallback((id: string) => {
setTeacherToDelete(id);
setDeleteDialogOpen(true);
}, []);
const confirmDelete = useCallback(async () => {
if (!teacherToDelete) return;
try {
await deleteTeacher.mutateAsync(teacherToDelete);
toast.success('教师已删除');
} catch (error) {
toast.error(error instanceof Error ? error.message : '删除失败');
} finally {
setDeleteDialogOpen(false);
setTeacherToDelete(null);
}
}, [teacherToDelete, deleteTeacher]);
const handleEdit = useCallback((teacher: TeacherResponse) => {
setTeacherToEdit(teacher);
setEditDialogOpen(true);
}, []);
const handleCreate = useCallback(() => {
setTeacherToEdit(null);
setEditDialogOpen(true);
}, []);
// 分页变化处理
const handlePaginationChange = useCallback((newPagination: PaginationState) => {
setPagination(newPagination);
}, []);
// 排序变化处理
const handleSortingChange = useCallback((newSorting: SortingParams) => {
setSorting(newSorting);
setPagination((prev) => ({ ...prev, page: 1 }));
}, []);
// 搜索变化处理
const handleSearchChange = useCallback((params: SearchParams) => {
setSearchParams(params);
setPagination((prev) => ({ ...prev, page: 1 }));
}, []);
// 搜索配置
const searchConfig: SearchConfig<TeacherResponse> = {
fields: [
{
type: 'text',
key: 'name',
label: '姓名',
placeholder: '搜索教师姓名',
},
{
type: 'text',
key: 'subject',
label: '科目',
placeholder: '搜索任教科目',
},
],
columns: 4,
};
// 列定义
const columns: ColumnDef<TeacherResponse>[] = [
{
accessorKey: 'teacherNo',
header: ({ column }) => (
<DataTableColumnHeader
title="工号"
sortKey={column.id}
sorting={sorting}
onSortingChange={handleSortingChange}
/>
),
cell: ({ row }) => (
<div className="flex items-center gap-2">
<UserCheck className="h-4 w-4 text-muted-foreground" />
<code className="text-sm">{row.original.teacherNo}</code>
</div>
),
},
{
accessorKey: 'name',
header: ({ column }) => (
<DataTableColumnHeader
title="姓名"
sortKey={column.id}
sorting={sorting}
onSortingChange={handleSortingChange}
/>
),
},
{
accessorKey: 'gender',
header: '性别',
cell: ({ row }) => {
const gender = row.original.gender;
if (!gender) return '-';
return (
<Badge variant="outline">
{gender === 'male' ? '男' : '女'}
</Badge>
);
},
},
{
accessorKey: 'subject',
header: '任教科目',
cell: ({ row }) => row.original.subject || '-',
},
{
accessorKey: 'phone',
header: '电话',
cell: ({ row }) => row.original.phone || '-',
},
{
accessorKey: 'createdAt',
header: ({ column }) => (
<DataTableColumnHeader
title="创建时间"
sortKey={column.id}
sorting={sorting}
onSortingChange={handleSortingChange}
/>
),
cell: ({ row }) =>
formatDate(new Date(row.original.createdAt), 'YYYY-MM-DD HH:mm'),
},
{
id: 'actions',
header: '操作',
cell: ({ row }) => (
<TeacherActions
teacher={row.original}
onEdit={handleEdit}
onDelete={handleDelete}
/>
),
},
];
return (
<div className="space-y-4">
{/* 工具栏 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button size="sm" onClick={handleCreate}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
<Button variant="outline" size="sm" onClick={() => refetch()}>
</Button>
</div>
{/* 数据表格 */}
<DataTable
columns={columns}
data={data?.items ?? []}
pagination={pagination}
paginationInfo={
data ? { total: data.total, totalPages: data.totalPages } : undefined
}
onPaginationChange={handlePaginationChange}
manualPagination
sorting={sorting}
onSortingChange={handleSortingChange}
manualSorting
searchConfig={searchConfig}
searchParams={searchParams}
onSearchChange={handleSearchChange}
isLoading={isLoading}
emptyMessage="暂无教师"
/>
{/* 删除确认弹窗 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 编辑/新建教师弹窗 */}
<TeacherEditDialog
teacher={teacherToEdit}
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
/>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { TeachersTable } from './TeachersTable';
export { TeacherEditDialog } from './TeacherEditDialog';

View File

@@ -16,6 +16,10 @@ export const API_ENDPOINTS = {
ROLES: '/roles',
PERMISSIONS: '/permissions',
MENUS: '/menus',
// 教学管理
TEACHERS: '/teachers',
CLASSES: '/classes',
STUDENTS: '/students',
} as const;
// 分页默认值

View File

@@ -40,3 +40,33 @@ export {
useDeleteMenu,
menuKeys,
} from './useMenus';
// 教学管理
export {
useTeachers,
useTeacher,
useCreateTeacher,
useUpdateTeacher,
useDeleteTeacher,
teacherKeys,
} from './useTeachers';
export {
useClasses,
useClass,
useClassTeachers,
useCreateClass,
useUpdateClass,
useDeleteClass,
useAssignTeachers,
classKeys,
} from './useClasses';
export {
useStudents,
useStudent,
useCreateStudent,
useUpdateStudent,
useDeleteStudent,
studentKeys,
} from './useStudents';

View File

@@ -0,0 +1,105 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
classService,
type AssignTeachersDto,
type CreateClassDto,
type GetClassesParams,
type UpdateClassDto,
} from '@/services/class.service';
import { useIsAuthenticated } from '@/stores';
// Query Keys
export const classKeys = {
all: ['classes'] as const,
lists: () => [...classKeys.all, 'list'] as const,
list: (params: GetClassesParams) => [...classKeys.lists(), params] as const,
details: () => [...classKeys.all, 'detail'] as const,
detail: (id: string) => [...classKeys.details(), id] as const,
teachers: (id: string) => [...classKeys.detail(id), 'teachers'] as const,
};
// 获取班级列表
export function useClasses(params: GetClassesParams = {}) {
const isAuthenticated = useIsAuthenticated();
return useQuery({
queryKey: classKeys.list(params),
queryFn: () => classService.getClasses(params),
enabled: isAuthenticated,
});
}
// 获取单个班级详情
export function useClass(id: string) {
const isAuthenticated = useIsAuthenticated();
return useQuery({
queryKey: classKeys.detail(id),
queryFn: () => classService.getClass(id),
enabled: isAuthenticated && !!id,
});
}
// 获取班级任课教师
export function useClassTeachers(id: string) {
const isAuthenticated = useIsAuthenticated();
return useQuery({
queryKey: classKeys.teachers(id),
queryFn: () => classService.getClassTeachers(id),
enabled: isAuthenticated && !!id,
});
}
// 创建班级
export function useCreateClass() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateClassDto) => classService.createClass(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: classKeys.lists() });
},
});
}
// 更新班级
export function useUpdateClass() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateClassDto }) =>
classService.updateClass(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: classKeys.lists() });
queryClient.invalidateQueries({ queryKey: classKeys.detail(id) });
},
});
}
// 删除班级
export function useDeleteClass() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => classService.deleteClass(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: classKeys.lists() });
},
});
}
// 分配任课教师
export function useAssignTeachers() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: AssignTeachersDto }) =>
classService.assignTeachers(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: classKeys.detail(id) });
queryClient.invalidateQueries({ queryKey: classKeys.teachers(id) });
},
});
}

View File

@@ -0,0 +1,78 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
studentService,
type CreateStudentDto,
type GetStudentsParams,
type UpdateStudentDto,
} from '@/services/student.service';
import { useIsAuthenticated } from '@/stores';
// Query Keys
export const studentKeys = {
all: ['students'] as const,
lists: () => [...studentKeys.all, 'list'] as const,
list: (params: GetStudentsParams) => [...studentKeys.lists(), params] as const,
details: () => [...studentKeys.all, 'detail'] as const,
detail: (id: string) => [...studentKeys.details(), id] as const,
};
// 获取学生列表
export function useStudents(params: GetStudentsParams = {}) {
const isAuthenticated = useIsAuthenticated();
return useQuery({
queryKey: studentKeys.list(params),
queryFn: () => studentService.getStudents(params),
enabled: isAuthenticated,
});
}
// 获取单个学生详情
export function useStudent(id: string) {
const isAuthenticated = useIsAuthenticated();
return useQuery({
queryKey: studentKeys.detail(id),
queryFn: () => studentService.getStudent(id),
enabled: isAuthenticated && !!id,
});
}
// 创建学生
export function useCreateStudent() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateStudentDto) => studentService.createStudent(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: studentKeys.lists() });
},
});
}
// 更新学生
export function useUpdateStudent() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateStudentDto }) =>
studentService.updateStudent(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: studentKeys.lists() });
queryClient.invalidateQueries({ queryKey: studentKeys.detail(id) });
},
});
}
// 删除学生
export function useDeleteStudent() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => studentService.deleteStudent(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: studentKeys.lists() });
},
});
}

View File

@@ -0,0 +1,78 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
teacherService,
type CreateTeacherDto,
type GetTeachersParams,
type UpdateTeacherDto,
} from '@/services/teacher.service';
import { useIsAuthenticated } from '@/stores';
// Query Keys
export const teacherKeys = {
all: ['teachers'] as const,
lists: () => [...teacherKeys.all, 'list'] as const,
list: (params: GetTeachersParams) => [...teacherKeys.lists(), params] as const,
details: () => [...teacherKeys.all, 'detail'] as const,
detail: (id: string) => [...teacherKeys.details(), id] as const,
};
// 获取教师列表
export function useTeachers(params: GetTeachersParams = {}) {
const isAuthenticated = useIsAuthenticated();
return useQuery({
queryKey: teacherKeys.list(params),
queryFn: () => teacherService.getTeachers(params),
enabled: isAuthenticated,
});
}
// 获取单个教师详情
export function useTeacher(id: string) {
const isAuthenticated = useIsAuthenticated();
return useQuery({
queryKey: teacherKeys.detail(id),
queryFn: () => teacherService.getTeacher(id),
enabled: isAuthenticated && !!id,
});
}
// 创建教师
export function useCreateTeacher() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateTeacherDto) => teacherService.createTeacher(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: teacherKeys.lists() });
},
});
}
// 更新教师
export function useUpdateTeacher() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateTeacherDto }) =>
teacherService.updateTeacher(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: teacherKeys.lists() });
queryClient.invalidateQueries({ queryKey: teacherKeys.detail(id) });
},
});
}
// 删除教师
export function useDeleteTeacher() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => teacherService.deleteTeacher(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: teacherKeys.lists() });
},
});
}

View File

@@ -0,0 +1,92 @@
import type { PaginatedResponse } from '@seclusion/shared';
import { API_ENDPOINTS } from '@/config/constants';
import { http } from '@/lib/http';
// 教师简要信息
export interface TeacherBrief {
id: string;
teacherNo: string;
name: string;
subject?: string;
}
// 班级响应类型
export interface ClassResponse {
id: string;
code: string;
name: string;
grade?: string;
headTeacherId?: string;
headTeacher?: TeacherBrief;
createdAt: string;
updatedAt: string;
}
// 班级详情响应类型(包含任课教师和学生数量)
export interface ClassDetailResponse extends ClassResponse {
teachers: TeacherBrief[];
studentCount: number;
}
// 创建班级 DTO
export interface CreateClassDto {
code: string;
name: string;
grade?: string;
headTeacherId?: string;
}
// 更新班级 DTO
export type UpdateClassDto = Partial<CreateClassDto>;
// 分配任课教师 DTO
export interface AssignTeachersDto {
teacherIds: string[];
}
// 查询参数
export interface GetClassesParams {
page?: number;
pageSize?: number;
name?: string;
code?: string;
grade?: string;
}
export const classService = {
// 获取班级列表
getClasses: (params: GetClassesParams = {}): Promise<PaginatedResponse<ClassResponse>> => {
return http.get<PaginatedResponse<ClassResponse>>(API_ENDPOINTS.CLASSES, { params });
},
// 获取单个班级(详情)
getClass: (id: string): Promise<ClassDetailResponse> => {
return http.get<ClassDetailResponse>(`${API_ENDPOINTS.CLASSES}/${id}`);
},
// 创建班级
createClass: (data: CreateClassDto): Promise<ClassResponse> => {
return http.post<ClassResponse>(API_ENDPOINTS.CLASSES, data);
},
// 更新班级
updateClass: (id: string, data: UpdateClassDto): Promise<ClassResponse> => {
return http.put<ClassResponse>(`${API_ENDPOINTS.CLASSES}/${id}`, data);
},
// 删除班级
deleteClass: (id: string): Promise<void> => {
return http.delete<void>(`${API_ENDPOINTS.CLASSES}/${id}`);
},
// 获取班级任课教师列表
getClassTeachers: (id: string): Promise<TeacherBrief[]> => {
return http.get<TeacherBrief[]>(`${API_ENDPOINTS.CLASSES}/${id}/teachers`);
},
// 分配任课教师
assignTeachers: (id: string, data: AssignTeachersDto): Promise<ClassDetailResponse> => {
return http.put<ClassDetailResponse>(`${API_ENDPOINTS.CLASSES}/${id}/teachers`, data);
},
};

View File

@@ -0,0 +1,81 @@
import type { PaginatedResponse } from '@seclusion/shared';
import { API_ENDPOINTS } from '@/config/constants';
import { http } from '@/lib/http';
// 性别枚举
export const Gender = {
MALE: 'male',
FEMALE: 'female',
} as const;
export type Gender = (typeof Gender)[keyof typeof Gender];
// 班级简要信息
export interface ClassBrief {
id: string;
code: string;
name: string;
}
// 学生响应类型
export interface StudentResponse {
id: string;
studentNo: string;
name: string;
gender?: string;
phone?: string;
email?: string;
classId?: string;
class?: ClassBrief;
createdAt: string;
updatedAt: string;
}
// 创建学生 DTO
export interface CreateStudentDto {
studentNo: string;
name: string;
gender?: Gender;
phone?: string;
email?: string;
classId?: string;
}
// 更新学生 DTO
export type UpdateStudentDto = Partial<CreateStudentDto>;
// 查询参数
export interface GetStudentsParams {
page?: number;
pageSize?: number;
name?: string;
studentNo?: string;
classId?: string;
}
export const studentService = {
// 获取学生列表
getStudents: (params: GetStudentsParams = {}): Promise<PaginatedResponse<StudentResponse>> => {
return http.get<PaginatedResponse<StudentResponse>>(API_ENDPOINTS.STUDENTS, { params });
},
// 获取单个学生
getStudent: (id: string): Promise<StudentResponse> => {
return http.get<StudentResponse>(`${API_ENDPOINTS.STUDENTS}/${id}`);
},
// 创建学生
createStudent: (data: CreateStudentDto): Promise<StudentResponse> => {
return http.post<StudentResponse>(API_ENDPOINTS.STUDENTS, data);
},
// 更新学生
updateStudent: (id: string, data: UpdateStudentDto): Promise<StudentResponse> => {
return http.put<StudentResponse>(`${API_ENDPOINTS.STUDENTS}/${id}`, data);
},
// 删除学生
deleteStudent: (id: string): Promise<void> => {
return http.delete<void>(`${API_ENDPOINTS.STUDENTS}/${id}`);
},
};

View File

@@ -0,0 +1,73 @@
import type { PaginatedResponse } from '@seclusion/shared';
import { API_ENDPOINTS } from '@/config/constants';
import { http } from '@/lib/http';
// 性别枚举
export const Gender = {
MALE: 'male',
FEMALE: 'female',
} as const;
export type Gender = (typeof Gender)[keyof typeof Gender];
// 教师响应类型
export interface TeacherResponse {
id: string;
teacherNo: string;
name: string;
gender?: string;
phone?: string;
email?: string;
subject?: string;
createdAt: string;
updatedAt: string;
}
// 创建教师 DTO
export interface CreateTeacherDto {
teacherNo: string;
name: string;
gender?: Gender;
phone?: string;
email?: string;
subject?: string;
}
// 更新教师 DTO
export type UpdateTeacherDto = Partial<CreateTeacherDto>;
// 查询参数
export interface GetTeachersParams {
page?: number;
pageSize?: number;
name?: string;
teacherNo?: string;
subject?: string;
}
export const teacherService = {
// 获取教师列表
getTeachers: (params: GetTeachersParams = {}): Promise<PaginatedResponse<TeacherResponse>> => {
return http.get<PaginatedResponse<TeacherResponse>>(API_ENDPOINTS.TEACHERS, { params });
},
// 获取单个教师
getTeacher: (id: string): Promise<TeacherResponse> => {
return http.get<TeacherResponse>(`${API_ENDPOINTS.TEACHERS}/${id}`);
},
// 创建教师
createTeacher: (data: CreateTeacherDto): Promise<TeacherResponse> => {
return http.post<TeacherResponse>(API_ENDPOINTS.TEACHERS, data);
},
// 更新教师
updateTeacher: (id: string, data: UpdateTeacherDto): Promise<TeacherResponse> => {
return http.put<TeacherResponse>(`${API_ENDPOINTS.TEACHERS}/${id}`, data);
},
// 删除教师
deleteTeacher: (id: string): Promise<void> => {
return http.delete<void>(`${API_ENDPOINTS.TEACHERS}/${id}`);
},
};