# 教师管理页 Implementation Plan > **For agentic workers:** Use subagent-driven-development. Steps use checkbox syntax. **Goal:** Add admin-facing teacher management: backend teacher list/profile API + frontend Teachers page with list, filter, profile edit. **Architecture:** Add `GET /teachers` and `PUT /teachers/:id/profile` to RBAC controller (teachers are RBAC-managed users). Frontend follows existing page pattern (Users page as template). **Tech Stack:** NestJS 11 + TypeORM + React 19 + Ant Design 6 ## Global Constraints +- Follow existing patterns: Users page for frontend layout, RBAC controller for teacher endpoints +- Teacher = any user whose roles include teacher-adjacent roles (code: 'teacher', plus any with class_teacher assignments) +- Profile field is `simple-json` — edit via a text area or structured form +- Include class assignments from ClassTeacher join in the list response --- ### Task 1: Backend — Teacher List + Profile API **Files:** +- Modify: `apps/server/src/rbac/rbac.controller.ts` (add endpoints) +- Modify: `apps/server/src/rbac/rbac.service.ts` (add queries) **Interfaces:** +- Produces: `GET /teachers` → `{ list: TeacherRow[]; total: number }` +- Produces: `PUT /teachers/:id/profile` → updated User +- Consumes: User, Role, ClassTeacher repos +- [ ] **Step 1: Add getTeachers() to RbacService** ```typescript // apps/server/src/rbac/rbac.service.ts — add method async getTeachers(query?: { search?: string; page?: number; pageSize?: number }) { const qb = this.userRepo .createQueryBuilder('u') .leftJoin('u.roles', 'role') .leftJoin(ClassTeacher, 'ct', 'ct.userId = u.id') .leftJoin('ct.class', 'c') .select([ 'u.id', 'u.username', 'u.name', 'u.isActive', 'u.profile', 'u.lastLoginAt', 'role.code', 'role.name', 'ct.id', 'ct.roleType', 'ct.subject', 'c.id', 'c.name', ]) .where('role.code IN (:...roles)', { roles: ['teacher', 'class_teacher', 'dormitory_supervisor', 'super_admin'] }); if (query?.search) { qb.andWhere('(u.name LIKE :s OR u.username LIKE :s)', { s: `%${query.search}%` }); } const total = await qb.getCount(); const raw = await qb .orderBy('u.name', 'ASC') .skip(((query?.page || 1) - 1) * (query?.pageSize || 20)) .take(query?.pageSize || 20) .getMany(); // Group class assignments per user const list = raw.map((u: any) => ({ id: u.id, username: u.username, name: u.name, isActive: u.isActive, profile: u.profile, lastLoginAt: u.lastLoginAt, roles: (u.roles || []).map((r: any) => ({ code: r.code, name: r.name })), classAssignments: (u.__ct__ || []).map((ct: any) => ({ roleType: ct.roleType, subject: ct.subject, className: ct.__class__?.name || null, })), })); return { list, total }; } ``` +- [ ] **Step 2: Add updateTeacherProfile() to RbacService** ```typescript async updateTeacherProfile(id: number, profile: { subjects?: string[]; joinedAt?: string; qualifications?: string }) { const user = await this.userRepo.findOne({ where: { id } }); if (!user) throw new NotFoundException('用户不存在'); user.profile = { ...user.profile, ...profile }; return this.userRepo.save(user); } ``` +- [ ] **Step 3: Add controller endpoints** ```typescript // apps/server/src/rbac/rbac.controller.ts — add endpoints @Get('teachers') @RequirePermission('user:view') async getTeachers(@Query('search') search?: string, @Query('page') page?: number, @Query('pageSize') pageSize?: number) { return this.rbacService.getTeachers({ search, page: page ? +page : undefined, pageSize: pageSize ? +pageSize : undefined }); } @Put('teachers/:id/profile') @RequirePermission('user:edit') async updateTeacherProfile(@Param('id') id: string, @Body() profile: any, @Request() req: any) { const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.rbacService.updateTeacherProfile(+id, profile); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '教师管理', action: '编辑档案', targetId: +id, targetType: 'user', detail: `更新教师档案`, ipAddress, userAgent, }); return result; } ``` +- [ ] **Step 4: Verify** `cd apps/server && npx tsc --noEmit 2>&1 | grep -v spec.ts | grep "error TS" | head -5` Expected: no errors --- ### Task 2: Frontend — Teachers Management Page **Files:** +- Create: `apps/admin/src/pages/Teachers/index.tsx` **Interfaces:** +- Consumes: `GET /teachers`, `PUT /teachers/:id/profile` +- Produces: Full page with search, table, profile edit modal +- [ ] **Step 1: Create the Teachers page** Follow the Users page pattern. Key elements: - Search bar (name/username) - Table columns: 姓名, 用户名, 角色(多个Tag), 任课班级(多个Tag), 科目, 入职日期, 状态, 最后登录, 操作 - Click "编辑档案" → modal with form fields: subjects (Select mode="tags"), joinedAt (DatePicker), qualifications (Input.TextArea) - Click row → expand to show class assignments detail Core structure (abbreviated — implement full component): ```tsx import React, { useEffect, useState, useCallback } from 'react'; import { Table, Input, Button, Modal, Form, Select, DatePicker, Tag, Space, message } from 'antd'; import { EditOutlined } from '@ant-design/icons'; import dayjs from 'dayjs'; import api from '../../api'; interface TeacherRow { id: number; username: string; name: string; isActive: boolean; profile: { subjects?: string[]; joinedAt?: string; qualifications?: string } | null; lastLoginAt: string; roles: { code: string; name: string }[]; classAssignments: { roleType: string; subject: string; className: string | null }[]; } const ROLE_LABELS: Record = { super_admin: '超管', teacher: '老师', class_teacher: '班主任', dormitory_supervisor: '宿管', institution_head: '机构负责人', }; const TeachersPage: React.FC = () => { const [data, setData] = useState([]); const [loading, setLoading] = useState(false); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); const [search, setSearch] = useState(''); const [profileModal, setProfileModal] = useState(null); const [form] = Form.useForm(); const fetchData = useCallback(async () => { setLoading(true); try { const res = await api.get<{ list: TeacherRow[]; total: number }>('/rbac/teachers', { params: { search: search || undefined, page, pageSize: 20 } }); setData(res.list); setTotal(res.total); } catch { /* silent */ } setLoading(false); }, [page, search]); useEffect(() => { fetchData(); }, [fetchData]); const handleSaveProfile = async () => { const values = await form.validateFields(); await api.put(`/rbac/teachers/${profileModal!.id}/profile`, { subjects: values.subjects || [], joinedAt: values.joinedAt?.format('YYYY-MM-DD'), qualifications: values.qualifications, }); message.success('已更新'); setProfileModal(null); fetchData(); }; const columns = [ { title: '姓名', dataIndex: 'name', width: 100 }, { title: '用户名', dataIndex: 'username', width: 120 }, { title: '角色', dataIndex: 'roles', width: 200, render: (roles: TeacherRow['roles']) => roles.map(r => {ROLE_LABELS[r.code] || r.name}), }, { title: '任课班级', dataIndex: 'classAssignments', width: 200, render: (ca: TeacherRow['classAssignments']) => ca?.length ? ca.map((a, i) => {a.className || '-'}) : '-', }, { title: '科目', dataIndex: 'profile', width: 120, render: (p: TeacherRow['profile']) => p?.subjects?.join('、') || '-', }, { title: '入职日期', dataIndex: 'profile', width: 110, render: (p: TeacherRow['profile']) => p?.joinedAt || '-', }, { title: '状态', dataIndex: 'isActive', width: 80, render: (v: boolean) => {v ? '在职' : '停用'}, }, { title: '最后登录', dataIndex: 'lastLoginAt', width: 160, render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-', }, { title: '操作', width: 100, render: (_: unknown, r: TeacherRow) => ( ), }, ]; return (

教师管理

setProfileModal(null)}>