281 lines
10 KiB
Markdown
281 lines
10 KiB
Markdown
# 教师管理页 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<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**
|
|
|
|
```bash
|
|
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"
|
|
```
|