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"