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:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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种子数据初始化完成!');
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
89
apps/api/src/class/class.controller.ts
Normal file
89
apps/api/src/class/class.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
11
apps/api/src/class/class.module.ts
Normal file
11
apps/api/src/class/class.module.ts
Normal 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 {}
|
||||
144
apps/api/src/class/class.service.ts
Normal file
144
apps/api/src/class/class.service.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
112
apps/api/src/class/dto/class.dto.ts
Normal file
112
apps/api/src/class/dto/class.dto.ts
Normal 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) {}
|
||||
@@ -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: '文件用途筛选',
|
||||
|
||||
113
apps/api/src/student/dto/student.dto.ts
Normal file
113
apps/api/src/student/dto/student.dto.ts
Normal 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) {}
|
||||
72
apps/api/src/student/student.controller.ts
Normal file
72
apps/api/src/student/student.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
11
apps/api/src/student/student.module.ts
Normal file
11
apps/api/src/student/student.module.ts
Normal 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 {}
|
||||
65
apps/api/src/student/student.service.ts
Normal file
65
apps/api/src/student/student.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
98
apps/api/src/teacher/dto/teacher.dto.ts
Normal file
98
apps/api/src/teacher/dto/teacher.dto.ts
Normal 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) {}
|
||||
72
apps/api/src/teacher/teacher.controller.ts
Normal file
72
apps/api/src/teacher/teacher.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
11
apps/api/src/teacher/teacher.module.ts
Normal file
11
apps/api/src/teacher/teacher.module.ts
Normal 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 {}
|
||||
36
apps/api/src/teacher/teacher.service.ts
Normal file
36
apps/api/src/teacher/teacher.service.ts
Normal 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 '教师已删除';
|
||||
}
|
||||
}
|
||||
33
apps/web/src/app/(dashboard)/classes/page.tsx
Normal file
33
apps/web/src/app/(dashboard)/classes/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
apps/web/src/app/(dashboard)/students/page.tsx
Normal file
33
apps/web/src/app/(dashboard)/students/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
apps/web/src/app/(dashboard)/teachers/page.tsx
Normal file
33
apps/web/src/app/(dashboard)/teachers/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
230
apps/web/src/components/classes/ClassEditDialog.tsx
Normal file
230
apps/web/src/components/classes/ClassEditDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
218
apps/web/src/components/classes/ClassTeachersDialog.tsx
Normal file
218
apps/web/src/components/classes/ClassTeachersDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
333
apps/web/src/components/classes/ClassesTable.tsx
Normal file
333
apps/web/src/components/classes/ClassesTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
apps/web/src/components/classes/index.ts
Normal file
3
apps/web/src/components/classes/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { ClassesTable } from './ClassesTable';
|
||||
export { ClassEditDialog } from './ClassEditDialog';
|
||||
export { ClassTeachersDialog } from './ClassTeachersDialog';
|
||||
281
apps/web/src/components/students/StudentEditDialog.tsx
Normal file
281
apps/web/src/components/students/StudentEditDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
316
apps/web/src/components/students/StudentsTable.tsx
Normal file
316
apps/web/src/components/students/StudentsTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
apps/web/src/components/students/index.ts
Normal file
2
apps/web/src/components/students/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { StudentsTable } from './StudentsTable';
|
||||
export { StudentEditDialog } from './StudentEditDialog';
|
||||
259
apps/web/src/components/teachers/TeacherEditDialog.tsx
Normal file
259
apps/web/src/components/teachers/TeacherEditDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
316
apps/web/src/components/teachers/TeachersTable.tsx
Normal file
316
apps/web/src/components/teachers/TeachersTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
apps/web/src/components/teachers/index.ts
Normal file
2
apps/web/src/components/teachers/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { TeachersTable } from './TeachersTable';
|
||||
export { TeacherEditDialog } from './TeacherEditDialog';
|
||||
@@ -16,6 +16,10 @@ export const API_ENDPOINTS = {
|
||||
ROLES: '/roles',
|
||||
PERMISSIONS: '/permissions',
|
||||
MENUS: '/menus',
|
||||
// 教学管理
|
||||
TEACHERS: '/teachers',
|
||||
CLASSES: '/classes',
|
||||
STUDENTS: '/students',
|
||||
} as const;
|
||||
|
||||
// 分页默认值
|
||||
|
||||
@@ -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';
|
||||
|
||||
105
apps/web/src/hooks/useClasses.ts
Normal file
105
apps/web/src/hooks/useClasses.ts
Normal 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) });
|
||||
},
|
||||
});
|
||||
}
|
||||
78
apps/web/src/hooks/useStudents.ts
Normal file
78
apps/web/src/hooks/useStudents.ts
Normal 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() });
|
||||
},
|
||||
});
|
||||
}
|
||||
78
apps/web/src/hooks/useTeachers.ts
Normal file
78
apps/web/src/hooks/useTeachers.ts
Normal 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() });
|
||||
},
|
||||
});
|
||||
}
|
||||
92
apps/web/src/services/class.service.ts
Normal file
92
apps/web/src/services/class.service.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
81
apps/web/src/services/student.service.ts
Normal file
81
apps/web/src/services/student.service.ts
Normal 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}`);
|
||||
},
|
||||
};
|
||||
73
apps/web/src/services/teacher.service.ts
Normal file
73
apps/web/src/services/teacher.service.ts
Normal 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}`);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user