Files
gongxue-base/docs/superpowers/plans/2026-07-06-teacher-management.md

10 KiB

教师管理页 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

// 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

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

// 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):

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<string, string> = {
  super_admin: '超管', teacher: '老师', class_teacher: '班主任',
  dormitory_supervisor: '宿管', institution_head: '机构负责人',
};

const TeachersPage: React.FC = () => {
  const [data, setData] = useState<TeacherRow[]>([]);
  const [loading, setLoading] = useState(false);
  const [total, setTotal] = useState(0);
  const [page, setPage] = useState(1);
  const [search, setSearch] = useState('');
  const [profileModal, setProfileModal] = useState<TeacherRow | null>(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 => <Tag key={r.code}>{ROLE_LABELS[r.code] || r.name}</Tag>),
    },
    {
      title: '任课班级', dataIndex: 'classAssignments', width: 200,
      render: (ca: TeacherRow['classAssignments']) => ca?.length ? ca.map((a, i) => <Tag key={i}>{a.className || '-'}</Tag>) : '-',
    },
    {
      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) => <Tag color={v ? 'green' : 'red'}>{v ? '在职' : '停用'}</Tag>,
    },
    {
      title: '最后登录', dataIndex: 'lastLoginAt', width: 160,
      render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-',
    },
    {
      title: '操作', width: 100,
      render: (_: unknown, r: TeacherRow) => (
        <Button size="small" icon={<EditOutlined />} onClick={() => { setProfileModal(r); form.setFieldsValue({ subjects: r.profile?.subjects || [], joinedAt: r.profile?.joinedAt ? dayjs(r.profile.joinedAt) : null, qualifications: r.profile?.qualifications || '' }); }}>
          档案
        </Button>
      ),
    },
  ];

  return (
    <div>
      <h2 style={{ marginBottom: 16 }}>教师管理</h2>
      <Space style={{ marginBottom: 16 }}>
        <Input.Search placeholder="搜索姓名/用户名" allowClear onSearch={setSearch} style={{ width: 200 }} />
      </Space>
      <Table columns={columns} dataSource={data} rowKey="id" loading={loading}
        pagination={{ current: page, pageSize: 20, total, onChange: setPage }} />
      <Modal title="编辑教师档案" open={!!profileModal} onOk={handleSaveProfile} onCancel={() => setProfileModal(null)}>
        <Form form={form} layout="vertical">
          <Form.Item name="subjects" label="任教学科">
            <Select mode="tags" placeholder="输入学科后回车" />
          </Form.Item>
          <Form.Item name="joinedAt" label="入职日期">
            <DatePicker style={{ width: '100%' }} />
          </Form.Item>
          <Form.Item name="qualifications" label="资质/备注">
            <Input.TextArea rows={3} />
          </Form.Item>
        </Form>
      </Modal>
    </div>
  );
};

export default TeachersPage;

+- [ ] Step 2: Add route in App.tsx

In apps/admin/src/App.tsx, add route for /teachers pointing to TeachersPage.

+- [ ] Step 3: Verify frontend compiles

cd apps/admin && npx tsc --noEmit 2>&1 | head -10 Expected: no new errors

+- [ ] Step 4: Commit

git add apps/server/src/rbac/ apps/admin/src/pages/Teachers/ apps/admin/src/App.tsx
git commit -m "feat: add teacher management page with profile editing"