forked from wangziqi/gongxue-base
chore: commit remaining integration files, PM2 single instance, PRD doc
This commit is contained in:
1061
docs/PRD.md
Normal file
1061
docs/PRD.md
Normal file
File diff suppressed because it is too large
Load Diff
188
docs/superpowers/plans/2026-07-06-student-archive.md
Normal file
188
docs/superpowers/plans/2026-07-06-student-archive.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# 学生档案子系统 Implementation Plan
|
||||
|
||||
> **For agentic workers:** Use subagent-driven-development. Steps use checkbox syntax.
|
||||
|
||||
**Goal:** Rebuild the student profile/report subsystem: 6 new entity tables, CRUD APIs, aggregate query, PDF report generation (pdfkit), frontend profile page, multi-enrollment comparison.
|
||||
|
||||
**Architecture:** New `ArchiveModule` aggregates 6 sub-entities under one API surface. `GET /archive/:studentId` returns the full profile. `GET /archive/:studentId/report` generates PDF. Use pdfkit (already in deps) for PDF; ECharts server-side SVG for charts.
|
||||
|
||||
**Tech Stack:** NestJS 11 + TypeORM + pdfkit + ECharts (SSR via `echarts` npm) + React 19 + Ant Design 6
|
||||
|
||||
## Global Constraints
|
||||
|
||||
+- All tables have `department_id` for campus scope isolation
|
||||
+- All write operations log via OperationLogsService
|
||||
+- PDF uses pdfkit (NOT puppeteer) — already in bill export
|
||||
+- Charts in PDF rendered as static SVG via echarts SSR
|
||||
+- Sensitive fields (phone/idNumber) masked in API responses, unmasked in PDF
|
||||
+- Follow existing NestJS module structure: `archive/` with entity/dto/service/controller
|
||||
+- Frontend follows existing page patterns (Students page as reference)
|
||||
|
||||
---
|
||||
|
||||
## Entities Design
|
||||
|
||||
### student_profiles — 扩展档案
|
||||
```sql
|
||||
id (PK), student_id FK UNIQUE, target_college, target_major, subject_direction,
|
||||
grade, campus_location, profile_date (建档日期), notes,
|
||||
department_id, created_at, updated_at
|
||||
```
|
||||
|
||||
### student_enrollments — 报读记录
|
||||
```sql
|
||||
id (PK), student_id FK, course_category (课程类别), class_type (班型: culture/professional/bootcamp),
|
||||
class_name, head_teacher, subject_teacher, start_date, end_date, status,
|
||||
department_id, created_at, updated_at
|
||||
```
|
||||
|
||||
### exam_scores — 考试成绩
|
||||
```sql
|
||||
id (PK), student_id FK, enrollment_id FK (nullable, links to enrollment),
|
||||
exam_type (周测/月测/模考/入学测), exam_name, subject, score (decimal),
|
||||
class_avg (decimal), rank, exam_date,
|
||||
department_id, created_at
|
||||
```
|
||||
|
||||
### learning_records — 学情记录
|
||||
```sql
|
||||
id (PK), student_id FK, record_date, record_type (课堂表现/作业/沟通/其他),
|
||||
content (text), follow_up_method, next_step,
|
||||
department_id, created_at
|
||||
```
|
||||
|
||||
### result_archives — 录取归档
|
||||
```sql
|
||||
id (PK), student_id FK, culture_final_score (decimal), professional_final_score (decimal),
|
||||
admission_status (已录取/未录取/待定), admitted_college, admitted_major,
|
||||
department_id, created_at, updated_at
|
||||
```
|
||||
|
||||
### archive_attachments — 附件
|
||||
```sql
|
||||
id (PK), student_id FK, category (成绩截图/录取截图/协议/其他),
|
||||
file_name, file_path, file_size, mime_type,
|
||||
department_id, created_at
|
||||
```
|
||||
|
||||
### student_reports — 报告版本
|
||||
```sql
|
||||
id (PK), student_id FK, snapshot_data (JSON — frozen copy of all profile data at generation time),
|
||||
html_content (text — rendered HTML), pdf_path, generated_at,
|
||||
department_id
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 1: Entities + Module Skeleton
|
||||
|
||||
**Files:**
|
||||
+- Create: `apps/server/src/entities/student-profile.entity.ts`
|
||||
+- Create: `apps/server/src/entities/student-enrollment.entity.ts`
|
||||
+- Create: `apps/server/src/entities/exam-score.entity.ts`
|
||||
+- Create: `apps/server/src/entities/learning-record.entity.ts`
|
||||
+- Create: `apps/server/src/entities/result-archive.entity.ts`
|
||||
+- Create: `apps/server/src/entities/archive-attachment.entity.ts`
|
||||
+- Create: `apps/server/src/entities/student-report.entity.ts`
|
||||
+- Modify: `apps/server/src/entities/index.ts`
|
||||
+- Create: `apps/server/src/archive/archive.module.ts`
|
||||
+- Create: `apps/server/src/archive/archive.service.ts`
|
||||
+- Create: `apps/server/src/archive/archive.controller.ts`
|
||||
+- Create: `apps/server/src/archive/dto/archive.dto.ts`
|
||||
+- Modify: `apps/server/src/app.module.ts`
|
||||
|
||||
All entities follow existing TypeORM patterns with `@Entity`, `@PrimaryGeneratedColumn`, `@Column`, `@ManyToOne(Student)`, `@CreateDateColumn`.
|
||||
|
||||
ArchiveModule imports `TypeOrmModule.forFeature([all 6 entities])`, is registered in AppModule.
|
||||
|
||||
ArchiveService provides:
|
||||
- `getProfile(studentId)` — joins all 6 tables, returns aggregate
|
||||
- `saveProfile(studentId, dto)` — upsert student_profiles
|
||||
- CRUD for each sub-entity (enrollments, scores, records, results, attachments)
|
||||
- `generateReport(studentId)` — produces PDF
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: CRUD APIs
|
||||
|
||||
**ArchiveController endpoints:**
|
||||
|
||||
| Method | Path | Description |
|
||||
|------|------|------|
|
||||
| GET | `/archive/:studentId` | Full profile aggregate |
|
||||
| PUT | `/archive/:studentId/profile` | Upsert student_profiles |
|
||||
| POST | `/archive/:studentId/enrollments` | Add enrollment |
|
||||
| PUT | `/archive/enrollments/:id` | Edit enrollment |
|
||||
| DELETE | `/archive/enrollments/:id` | Delete enrollment |
|
||||
| POST | `/archive/:studentId/exam-scores` | Add exam score |
|
||||
| PUT | `/archive/exam-scores/:id` | Edit exam score |
|
||||
| DELETE | `/archive/exam-scores/:id` | Delete exam score |
|
||||
| POST | `/archive/:studentId/learning-records` | Add learning record |
|
||||
| PUT | `/archive/learning-records/:id` | Edit learning record |
|
||||
| DELETE | `/archive/learning-records/:id` | Delete learning record |
|
||||
| PUT | `/archive/:studentId/result` | Upsert result archive |
|
||||
| POST | `/archive/:studentId/attachments` | Upload attachment (multipart) |
|
||||
| DELETE | `/archive/attachments/:id` | Delete attachment |
|
||||
| GET | `/archive/:studentId/report` | Generate & download PDF |
|
||||
|
||||
All write endpoints log via OperationLogsService (`module: '学生档案'`).
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: PDF Report Generation
|
||||
|
||||
Use pdfkit. One file: `apps/server/src/archive/archive-report.service.ts`.
|
||||
|
||||
Report structure (multi-page A4):
|
||||
1. **封面** — name, studentNo, subjectDirection, targetCollege/targetMajor, headTeacher, profileDate
|
||||
2. **基础信息** — personal info table + enrollment comparison (culture vs professional side-by-side)
|
||||
3. **入学测评与阶段概览** — first exam scores, highest scores, improvement (bar chart via echarts SVG)
|
||||
4. **出勤记录** — attendance summary (pie: present/absent/late/leave), daily matrix
|
||||
5. **文化课测评** — all culture exam scores table, subject breakdown bar
|
||||
6. **专业课测评** — all professional exam scores table, learning records list
|
||||
|
||||
The multi-enrollment comparison (PRD 2.2): when student has 2+ enrollments (e.g., culture + professional), each gets its own column in the tables and its own chart section.
|
||||
|
||||
Key implementation:
|
||||
- `generateReport(studentId)` — orchestrates data gathering, builds PDF sections
|
||||
- Helper: `renderAttendancePie(records)` → SVG buffer → embedded in PDF
|
||||
- Helper: `renderScoreBar(scores)` → SVG buffer → embedded in PDF
|
||||
- Charts: use `echarts` npm package, render to SVG string, convert to buffer, embed via `doc.image()`
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Frontend Student Profile Page
|
||||
|
||||
**Files:**
|
||||
+- Create: `apps/admin/src/pages/StudentProfile/index.tsx`
|
||||
+- Modify: `apps/admin/src/App.tsx` (add route `/students/:id/profile`)
|
||||
|
||||
Page layout:
|
||||
- **顶部** — Student info card (name, phone masked, idNumber masked, status, tenant — reusing Students page data)
|
||||
- **Tabs**: 基础档案 / 报读记录 / 考试成绩 / 学情记录 / 录取结果 / 附件
|
||||
- **基础档案 Tab** — Form: 目标院校、目标专业、科类方向、年级、校区、建档日期
|
||||
- **报读记录 Tab** — Table + Add modal: 课程类别、班型、班级名、班主任、任课老师、开/结课日期
|
||||
- **考试成绩 Tab** — Table + Add/Edit modal: 类型、名称、科目、分数、班级平均、排名、日期
|
||||
- **学情记录 Tab** — Table + Add modal: 日期、类型、内容、跟进方式、下一步
|
||||
- **录取结果 Tab** — Form: 文化课最终成绩、专业课最终成绩、录取状态、录取院校、录取专业
|
||||
- **附件 Tab** — Upload list: 分类、文件名、大小、删除
|
||||
- **操作栏** — "生成档案报表" button → downloads PDF
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Multi-Enrollment PDF Comparison
|
||||
|
||||
PRD 2.2 specific: when 2+ enrollments exist, the PDF report must show them side-by-side:
|
||||
- Cover page: list all class_types
|
||||
- Score tables: columns per enrollment
|
||||
- Separate chart sections for culture vs professional
|
||||
|
||||
This is handled in the PDF generation logic — the archive-report.service.ts builds sections dynamically based on enrollment count.
|
||||
|
||||
---
|
||||
|
||||
## Execution Order
|
||||
|
||||
Phase 1→2→4→3→5 (entities→CRUD→frontend→PDF→comparison). Phases 3+5 are combined in the report service.
|
||||
|
||||
**Total: 5 phases, ~12 files created, ~3 files modified.**
|
||||
611
docs/superpowers/plans/2026-07-06-sync-integration.md
Normal file
611
docs/superpowers/plans/2026-07-06-sync-integration.md
Normal file
@@ -0,0 +1,611 @@
|
||||
# 钉钉/企微同步对接 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace `sync.service.ts` stubs with real DingTalk/WeCom API calls — fetch departments and users, persist to DB, record sync logs.
|
||||
|
||||
**Architecture:** Add `source`/`sourceId`/`parentSourceId` to Department entity for idempotent sync matching. Create two integration services (`DingTalkService`, `WeComService`) under `src/integration/`, one shared `IntegrationModule`, wire into `SyncModule`. Each service self-checks env vars and degrades gracefully when not configured.
|
||||
|
||||
**Tech Stack:** NestJS 11 + TypeORM + `@nestjs/schedule` + native `fetch`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
+- MUST check `process.env.DINGTALK_APP_KEY` / `WECOM_CORP_ID` before attempting API calls
|
||||
+- When env vars missing: log warning, return 0 records, set sync log to `success` (not `failed` — "not configured" is not an error)
|
||||
+- Sync count returned from `perform*Sync` is the number of **new/updated records persisted**
|
||||
+- Follow existing NestJS module structure: one directory per concern
|
||||
+- Each service is independently injectable; `SyncModule` imports `IntegrationModule`
|
||||
+- Use native `fetch` (Node 18+) — no extra HTTP client dependency
|
||||
+- Department entity gains nullable `source` / `sourceId` / `parentSourceId` — existing records unaffected
|
||||
+- Sync preserves existing tree structure: departments matched by `sourceId`, users by `username`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add Source Tracking to Department Entity
|
||||
|
||||
**Files:**
|
||||
+- Modify: `apps/server/src/entities/department.entity.ts`
|
||||
|
||||
**Interfaces:**
|
||||
+- Produces: Department entity with nullable `source`, `sourceId`, `parentSourceId` columns
|
||||
|
||||
+- [ ] **Step 1: Add columns to Department entity**
|
||||
|
||||
```typescript
|
||||
// apps/server/src/entities/department.entity.ts — add after 'status' field (line ~42):
|
||||
@Column({ length: 20, nullable: true })
|
||||
source: string; // 'dingtalk' | 'wecom' | null (null = manual)
|
||||
|
||||
@Column({ name: 'source_id', length: 50, nullable: true })
|
||||
sourceId: string; // external dept ID for idempotent sync matching
|
||||
|
||||
@Column({ name: 'parent_source_id', length: 50, nullable: true })
|
||||
parentSourceId: string; // external parent dept ID (resolved in post-processing)
|
||||
```
|
||||
|
||||
+- [ ] **Step 2: Verify compilation**
|
||||
|
||||
Run: `cd apps/server && npx tsc --noEmit 2>&1 | head -20`
|
||||
Expected: No new errors from department.entity.ts
|
||||
|
||||
+- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/server/src/entities/department.entity.ts
|
||||
git commit -m "feat: add source tracking fields to Department for sync idempotency"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: DingTalk Integration Service
|
||||
|
||||
**Files:**
|
||||
+- Create: `apps/server/src/integration/dingtalk.service.ts`
|
||||
|
||||
**Interfaces:**
|
||||
+- Produces: `DingTalkService` with `syncAll(): Promise<{ deptCount: number; userCount: number }>`
|
||||
+- Consumes: `Department` repo, `User` repo
|
||||
|
||||
+- [ ] **Step 1: Create the full DingTalkService**
|
||||
|
||||
```typescript
|
||||
// apps/server/src/integration/dingtalk.service.ts
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Department } from '../entities/department.entity';
|
||||
import { User } from '../entities/user.entity';
|
||||
|
||||
interface DingTalkTokenResponse {
|
||||
errcode: number;
|
||||
errmsg: string;
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
interface DingTalkDeptListResponse {
|
||||
errcode: number;
|
||||
errmsg: string;
|
||||
result: Array<{ dept_id: number; name: string; parent_id: number }>;
|
||||
}
|
||||
|
||||
interface DingTalkUserListResponse {
|
||||
errcode: number;
|
||||
errmsg: string;
|
||||
result: {
|
||||
has_more: boolean;
|
||||
list: Array<{
|
||||
userid: string;
|
||||
name: string;
|
||||
mobile: string;
|
||||
dept_id_list: number[];
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DingTalkService {
|
||||
private readonly logger = new Logger(DingTalkService.name);
|
||||
private accessToken: string | null = null;
|
||||
private tokenExpiresAt = 0;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Department)
|
||||
private readonly deptRepo: Repository<Department>,
|
||||
@InjectRepository(User)
|
||||
private readonly userRepo: Repository<User>,
|
||||
) {}
|
||||
|
||||
private get configured(): boolean {
|
||||
return !!(process.env.DINGTALK_APP_KEY && process.env.DINGTALK_APP_SECRET);
|
||||
}
|
||||
|
||||
private async getAccessToken(): Promise<string> {
|
||||
if (this.accessToken && Date.now() < this.tokenExpiresAt - 60_000) {
|
||||
return this.accessToken;
|
||||
}
|
||||
|
||||
const appKey = process.env.DINGTALK_APP_KEY!;
|
||||
const appSecret = process.env.DINGTALK_APP_SECRET!;
|
||||
const url = `https://oapi.dingtalk.com/gettoken?appkey=${appKey}&appsecret=${appSecret}`;
|
||||
const res = await fetch(url);
|
||||
const body: DingTalkTokenResponse = await res.json();
|
||||
|
||||
if (body.errcode !== 0) {
|
||||
throw new Error(`DingTalk gettoken failed: ${body.errmsg} (${body.errcode})`);
|
||||
}
|
||||
|
||||
this.accessToken = body.access_token;
|
||||
this.tokenExpiresAt = Date.now() + body.expires_in * 1000;
|
||||
return this.accessToken;
|
||||
}
|
||||
|
||||
private async fetchDepartments(token: string): Promise<Array<{ dept_id: number; name: string; parent_id: number }>> {
|
||||
const url = `https://oapi.dingtalk.com/topapi/v2/department/listsub?access_token=${token}`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ dept_id: 1 }),
|
||||
});
|
||||
const body: DingTalkDeptListResponse = await res.json();
|
||||
|
||||
if (body.errcode !== 0) {
|
||||
throw new Error(`DingTalk department list failed: ${body.errmsg} (${body.errcode})`);
|
||||
}
|
||||
return body.result;
|
||||
}
|
||||
|
||||
private async fetchUsers(
|
||||
token: string,
|
||||
deptId: number,
|
||||
): Promise<Array<{ userid: string; name: string; mobile: string; dept_id_list: number[] }>> {
|
||||
const allUsers: DingTalkUserListResponse['result']['list'] = [];
|
||||
let cursor = 0;
|
||||
|
||||
while (true) {
|
||||
const url = `https://oapi.dingtalk.com/topapi/v2/user/list?access_token=${token}`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ dept_id: deptId, cursor, size: 100 }),
|
||||
});
|
||||
const body: DingTalkUserListResponse = await res.json();
|
||||
|
||||
if (body.errcode !== 0) {
|
||||
throw new Error(`DingTalk user list failed: ${body.errmsg} (${body.errcode})`);
|
||||
}
|
||||
|
||||
allUsers.push(...body.result.list);
|
||||
if (!body.result.has_more) break;
|
||||
cursor = allUsers.length;
|
||||
}
|
||||
|
||||
return allUsers;
|
||||
}
|
||||
|
||||
async syncAll(): Promise<{ deptCount: number; userCount: number }> {
|
||||
if (!this.configured) {
|
||||
this.logger.warn('DingTalk not configured (DINGTALK_APP_KEY / DINGTALK_APP_SECRET missing), skipping sync');
|
||||
return { deptCount: 0, userCount: 0 };
|
||||
}
|
||||
|
||||
const token = await this.getAccessToken();
|
||||
const dingDepts = await this.fetchDepartments(token);
|
||||
|
||||
// Upsert departments
|
||||
let deptCount = 0;
|
||||
for (const dd of dingDepts) {
|
||||
const sourceId = String(dd.dept_id);
|
||||
let dept = await this.deptRepo.findOne({ where: { source: 'dingtalk', sourceId } });
|
||||
|
||||
if (dept) {
|
||||
dept.name = dd.name;
|
||||
dept.parentSourceId = dd.parent_id ? String(dd.parent_id) : null;
|
||||
} else {
|
||||
dept = this.deptRepo.create({
|
||||
name: dd.name,
|
||||
source: 'dingtalk',
|
||||
sourceId,
|
||||
parentSourceId: dd.parent_id ? String(dd.parent_id) : null,
|
||||
type: 'department',
|
||||
});
|
||||
deptCount++;
|
||||
}
|
||||
await this.deptRepo.save(dept);
|
||||
}
|
||||
|
||||
// Resolve parentSourceId → parentId for tree linking
|
||||
const syncedDepts = await this.deptRepo.find({ where: { source: 'dingtalk' } });
|
||||
const idMap = new Map(syncedDepts.map((d) => [d.sourceId, d.id]));
|
||||
for (const dept of syncedDepts) {
|
||||
if (dept.parentSourceId && idMap.has(dept.parentSourceId)) {
|
||||
dept.parentId = idMap.get(dept.parentSourceId)!;
|
||||
} else if (dept.parentSourceId === '1' || dept.parentSourceId === '0') {
|
||||
dept.parentId = null; // root
|
||||
}
|
||||
}
|
||||
await this.deptRepo.save(syncedDepts);
|
||||
|
||||
// Upsert users across all departments
|
||||
let userCount = 0;
|
||||
const seenUserIds = new Set<string>();
|
||||
for (const dd of dingDepts) {
|
||||
const dingUsers = await this.fetchUsers(token, dd.dept_id);
|
||||
for (const du of dingUsers) {
|
||||
if (seenUserIds.has(du.userid)) continue;
|
||||
seenUserIds.add(du.userid);
|
||||
|
||||
let user = await this.userRepo.findOne({ where: { username: du.userid } });
|
||||
if (user) {
|
||||
user.name = du.name;
|
||||
} else {
|
||||
user = this.userRepo.create({
|
||||
username: du.userid,
|
||||
name: du.name,
|
||||
passwordHash: '',
|
||||
isActive: true,
|
||||
});
|
||||
userCount++;
|
||||
}
|
||||
await this.userRepo.save(user);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`DingTalk sync done: ${deptCount} new depts, ${userCount} new users`);
|
||||
return { deptCount, userCount };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
+- [ ] **Step 2: Verify file exists**
|
||||
|
||||
Run: `wc -l apps/server/src/integration/dingtalk.service.ts`
|
||||
Expected: ~160 lines
|
||||
|
||||
---
|
||||
|
||||
### Task 3: WeCom Integration Service
|
||||
|
||||
**Files:**
|
||||
+- Create: `apps/server/src/integration/wecom.service.ts`
|
||||
|
||||
**Interfaces:**
|
||||
+- Produces: `WeComService` with `syncAll(): Promise<{ deptCount: number; userCount: number }>`
|
||||
|
||||
+- [ ] **Step 1: Create the full WeComService**
|
||||
|
||||
```typescript
|
||||
// apps/server/src/integration/wecom.service.ts
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Department } from '../entities/department.entity';
|
||||
import { User } from '../entities/user.entity';
|
||||
|
||||
interface WeComTokenResponse {
|
||||
errcode: number;
|
||||
errmsg: string;
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
interface WeComDeptListResponse {
|
||||
errcode: number;
|
||||
errmsg: string;
|
||||
department: Array<{ id: number; name: string; parentid: number }>;
|
||||
}
|
||||
|
||||
interface WeComUserListResponse {
|
||||
errcode: number;
|
||||
errmsg: string;
|
||||
userlist: Array<{
|
||||
userid: string;
|
||||
name: string;
|
||||
mobile: string;
|
||||
department: number[];
|
||||
}>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WeComService {
|
||||
private readonly logger = new Logger(WeComService.name);
|
||||
private accessToken: string | null = null;
|
||||
private tokenExpiresAt = 0;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Department)
|
||||
private readonly deptRepo: Repository<Department>,
|
||||
@InjectRepository(User)
|
||||
private readonly userRepo: Repository<User>,
|
||||
) {}
|
||||
|
||||
private get configured(): boolean {
|
||||
return !!(process.env.WECOM_CORP_ID && process.env.WECOM_CORP_SECRET);
|
||||
}
|
||||
|
||||
private async getAccessToken(): Promise<string> {
|
||||
if (this.accessToken && Date.now() < this.tokenExpiresAt - 60_000) {
|
||||
return this.accessToken;
|
||||
}
|
||||
|
||||
const corpId = process.env.WECOM_CORP_ID!;
|
||||
const corpSecret = process.env.WECOM_CORP_SECRET!;
|
||||
const url = `https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=${corpId}&corpsecret=${corpSecret}`;
|
||||
const res = await fetch(url);
|
||||
const body: WeComTokenResponse = await res.json();
|
||||
|
||||
if (body.errcode !== 0) {
|
||||
throw new Error(`WeCom gettoken failed: ${body.errmsg} (${body.errcode})`);
|
||||
}
|
||||
|
||||
this.accessToken = body.access_token;
|
||||
this.tokenExpiresAt = Date.now() + body.expires_in * 1000;
|
||||
return this.accessToken;
|
||||
}
|
||||
|
||||
private async fetchDepartments(
|
||||
token: string,
|
||||
parentId = 1,
|
||||
): Promise<Array<{ id: number; name: string; parentid: number }>> {
|
||||
const all: WeComDeptListResponse['department'] = [];
|
||||
const url = `https://qyapi.weixin.qq.com/cgi-bin/department/list?access_token=${token}&id=${parentId}`;
|
||||
const res = await fetch(url);
|
||||
const body: WeComDeptListResponse = await res.json();
|
||||
|
||||
if (body.errcode !== 0) {
|
||||
// Empty department list is OK for leaf departments
|
||||
if (body.errcode === 60003) return all;
|
||||
throw new Error(`WeCom department list failed: ${body.errmsg} (${body.errcode})`);
|
||||
}
|
||||
|
||||
for (const dept of body.department) {
|
||||
all.push(dept);
|
||||
if (dept.id !== parentId) {
|
||||
const children = await this.fetchDepartments(token, dept.id);
|
||||
all.push(...children);
|
||||
}
|
||||
}
|
||||
|
||||
return all;
|
||||
}
|
||||
|
||||
private async fetchUsers(
|
||||
token: string,
|
||||
deptId: number,
|
||||
): Promise<Array<{ userid: string; name: string; mobile: string; department: number[] }>> {
|
||||
const url = `https://qyapi.weixin.qq.com/cgi-bin/user/simplelist?access_token=${token}&department_id=${deptId}&fetch_child=1`;
|
||||
const res = await fetch(url);
|
||||
const body: WeComUserListResponse = await res.json();
|
||||
|
||||
if (body.errcode !== 0) {
|
||||
throw new Error(`WeCom user list failed: ${body.errmsg} (${body.errcode})`);
|
||||
}
|
||||
|
||||
return body.userlist;
|
||||
}
|
||||
|
||||
async syncAll(): Promise<{ deptCount: number; userCount: number }> {
|
||||
if (!this.configured) {
|
||||
this.logger.warn('WeCom not configured (WECOM_CORP_ID / WECOM_CORP_SECRET missing), skipping sync');
|
||||
return { deptCount: 0, userCount: 0 };
|
||||
}
|
||||
|
||||
const token = await this.getAccessToken();
|
||||
const wxDepts = await this.fetchDepartments(token);
|
||||
|
||||
// Upsert departments
|
||||
let deptCount = 0;
|
||||
for (const wd of wxDepts) {
|
||||
const sourceId = String(wd.id);
|
||||
let dept = await this.deptRepo.findOne({ where: { source: 'wecom', sourceId } });
|
||||
|
||||
if (dept) {
|
||||
dept.name = wd.name;
|
||||
dept.parentSourceId = wd.parentid ? String(wd.parentid) : null;
|
||||
} else {
|
||||
dept = this.deptRepo.create({
|
||||
name: wd.name,
|
||||
source: 'wecom',
|
||||
sourceId,
|
||||
parentSourceId: wd.parentid ? String(wd.parentid) : null,
|
||||
type: 'department',
|
||||
});
|
||||
deptCount++;
|
||||
}
|
||||
await this.deptRepo.save(dept);
|
||||
}
|
||||
|
||||
// Resolve parentSourceId → parentId
|
||||
const syncedDepts = await this.deptRepo.find({ where: { source: 'wecom' } });
|
||||
const idMap = new Map(syncedDepts.map((d) => [d.sourceId, d.id]));
|
||||
for (const dept of syncedDepts) {
|
||||
if (dept.parentSourceId && idMap.has(dept.parentSourceId)) {
|
||||
dept.parentId = idMap.get(dept.parentSourceId)!;
|
||||
} else if (dept.parentSourceId === '0' || dept.parentSourceId === '1') {
|
||||
dept.parentId = null;
|
||||
}
|
||||
}
|
||||
await this.deptRepo.save(syncedDepts);
|
||||
|
||||
// Upsert users
|
||||
let userCount = 0;
|
||||
const seenUserIds = new Set<string>();
|
||||
for (const wd of wxDepts) {
|
||||
const wxUsers = await this.fetchUsers(token, wd.id);
|
||||
for (const wu of wxUsers) {
|
||||
if (seenUserIds.has(wu.userid)) continue;
|
||||
seenUserIds.add(wu.userid);
|
||||
|
||||
let user = await this.userRepo.findOne({ where: { username: wu.userid } });
|
||||
if (user) {
|
||||
user.name = wu.name;
|
||||
} else {
|
||||
user = this.userRepo.create({
|
||||
username: wu.userid,
|
||||
name: wu.name,
|
||||
passwordHash: '',
|
||||
isActive: true,
|
||||
});
|
||||
userCount++;
|
||||
}
|
||||
await this.userRepo.save(user);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`WeCom sync done: ${deptCount} new depts, ${userCount} new users`);
|
||||
return { deptCount, userCount };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
+- [ ] **Step 2: Verify file exists**
|
||||
|
||||
Run: `wc -l apps/server/src/integration/wecom.service.ts`
|
||||
Expected: ~170 lines
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Integration Module + Wiring
|
||||
|
||||
**Files:**
|
||||
+- Create: `apps/server/src/integration/integration.module.ts`
|
||||
+- Modify: `apps/server/src/sync/sync.module.ts`
|
||||
+- Modify: `apps/server/src/sync/sync.service.ts`
|
||||
|
||||
**Interfaces:**
|
||||
+- Consumes: `DingTalkService.syncAll()`, `WeComService.syncAll()`
|
||||
+- Produces: `SyncService` with real sync calls replacing stubs
|
||||
|
||||
+- [ ] **Step 1: Create IntegrationModule**
|
||||
|
||||
```typescript
|
||||
// apps/server/src/integration/integration.module.ts
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Department, User } from '../entities';
|
||||
import { DingTalkService } from './dingtalk.service';
|
||||
import { WeComService } from './wecom.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Department, User])],
|
||||
providers: [DingTalkService, WeComService],
|
||||
exports: [DingTalkService, WeComService],
|
||||
})
|
||||
export class IntegrationModule {}
|
||||
```
|
||||
|
||||
+- [ ] **Step 2: Wire IntegrationModule into SyncModule**
|
||||
|
||||
In `apps/server/src/sync/sync.module.ts`: add `IntegrationModule` to the `imports` array and the import statement:
|
||||
|
||||
```typescript
|
||||
// Add at top:
|
||||
import { IntegrationModule } from '../integration/integration.module';
|
||||
|
||||
// In @Module decorator, add IntegrationModule to imports:
|
||||
@Module({
|
||||
imports: [
|
||||
ScheduleModule.forRoot(),
|
||||
TypeOrmModule.forFeature([SyncLog, SyncState]),
|
||||
IntegrationModule,
|
||||
],
|
||||
// ... rest unchanged
|
||||
```
|
||||
|
||||
+- [ ] **Step 3: Inject services and replace stubs in SyncService**
|
||||
|
||||
In `apps/server/src/sync/sync.service.ts`:
|
||||
|
||||
Add imports:
|
||||
```typescript
|
||||
import { DingTalkService } from '../integration/dingtalk.service';
|
||||
import { WeComService } from '../integration/wecom.service';
|
||||
```
|
||||
|
||||
Add to constructor parameters:
|
||||
```typescript
|
||||
constructor(
|
||||
@InjectRepository(SyncLog) private readonly syncLogRepo: Repository<SyncLog>,
|
||||
@InjectRepository(SyncState) private readonly syncStateRepo: Repository<SyncState>,
|
||||
private readonly dingTalkService: DingTalkService,
|
||||
private readonly weComService: WeComService,
|
||||
) {}
|
||||
```
|
||||
|
||||
Replace `performDingTalkSync` (lines 154-166):
|
||||
```typescript
|
||||
private async performDingTalkSync(_lastSyncAt: Date | null): Promise<number> {
|
||||
const result = await this.dingTalkService.syncAll();
|
||||
return result.deptCount + result.userCount;
|
||||
}
|
||||
```
|
||||
|
||||
Replace `performWeComSync` (lines 168-179):
|
||||
```typescript
|
||||
private async performWeComSync(_lastSyncAt: Date | null): Promise<number> {
|
||||
const result = await this.weComService.syncAll();
|
||||
return result.deptCount + result.userCount;
|
||||
}
|
||||
```
|
||||
|
||||
Also remove the stale JSDoc comments above the old stubs.
|
||||
|
||||
+- [ ] **Step 4: Verify compilation**
|
||||
|
||||
Run: `cd apps/server && npx tsc --noEmit 2>&1 | head -30`
|
||||
Expected: No type errors from integration/ or sync/ modules
|
||||
|
||||
+- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/server/src/integration/
|
||||
git add apps/server/src/sync/sync.module.ts apps/server/src/sync/sync.service.ts
|
||||
git commit -m "feat: wire DingTalk/WeCom integration services into sync pipeline"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Verification
|
||||
|
||||
**Files:**
|
||||
+- _(none modified — verification only)_
|
||||
|
||||
+- [ ] **Step 1: Start dev server with missing env vars**
|
||||
|
||||
```bash
|
||||
cd apps/server && npm run start:dev &
|
||||
sleep 5
|
||||
```
|
||||
|
||||
Check logs: should show `DingTalk not configured... skipping sync` and `WeCom not configured... skipping sync` at startup (or wait for the 2 AM cron, or trigger manually).
|
||||
|
||||
+- [ ] **Step 2: Test manual trigger endpoint**
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:3002/api/sync/trigger | python3 -m json.tool 2>/dev/null || curl -s http://localhost:3002/api/sync/trigger
|
||||
```
|
||||
|
||||
Expected: JSON array of sync log objects with `status: "success"` and `recordsCount: 0`
|
||||
|
||||
+- [ ] **Step 3: Check sync logs endpoint**
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:3002/api/sync/logs | python3 -m json.tool 2>/dev/null | head -30
|
||||
```
|
||||
|
||||
Expected: Array of sync log entries with fields `platform`, `status`, `recordsCount`, `startedAt`, `finishedAt`
|
||||
|
||||
+- [ ] **Step 4: Verify server still serves other endpoints**
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:3002/api/students?pageSize=1 | python3 -m json.tool 2>/dev/null | head -10
|
||||
```
|
||||
|
||||
Expected: Normal student list response (no regression)
|
||||
|
||||
+- [ ] **Step 5: Stop server and commit verification**
|
||||
|
||||
```bash
|
||||
kill %1 2>/dev/null
|
||||
# If all checks passed, no additional commits needed
|
||||
```
|
||||
280
docs/superpowers/plans/2026-07-06-teacher-management.md
Normal file
280
docs/superpowers/plans/2026-07-06-teacher-management.md
Normal file
@@ -0,0 +1,280 @@
|
||||
# 教师管理页 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"
|
||||
```
|
||||
Reference in New Issue
Block a user