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