docs: implementation plan for dingtalk sync role selection
This commit is contained in:
@@ -0,0 +1,876 @@
|
||||
# DingTalk Sync Role Selection — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Move DingTalk user sync from Users page to IntegrationConfig page with a Drawer-based tree UI for selecting which synced users are teachers (with role assignment) vs students.
|
||||
|
||||
**Architecture:** Backend adds two endpoints: one to fetch the DingTalk org tree with users attached, one to import users with role/student assignment. Frontend adds a "同步用户" Tab on IntegrationConfig page with a Drawer tree; removes the old sync button and mark-staff/mark-student buttons from Users page.
|
||||
|
||||
**Tech Stack:** NestJS 11 + TypeORM 0.3 (backend), React 19 + Vite + Ant Design 6 (frontend)
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- MUST inject superpowers:ui-ux-pro-max and superpowers:vercel-react-best-practices during implementation
|
||||
- Follow existing NestJS module patterns: entity/dto/service/controller
|
||||
- Frontend pages in `apps/admin/src/pages/` with independent directories
|
||||
- RBAC permission decorators on all new endpoints
|
||||
- Use existing `api` axios instance for frontend API calls
|
||||
- Default teacher role: "班主任" (look up by name from `/rbac/roles`)
|
||||
- `roleId: null` (not undefined) marks a user as student
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend — New type and fetchOrgTreeWithUsers
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/server/src/integration/dingtalk.service.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `DingOrgTreeNodeWithUsers` (exported interface), `fetchOrgTreeWithUsers(rootDeptId?: number): Promise<DingOrgTreeNodeWithUsers[]>`
|
||||
|
||||
- [ ] **Step 1: Add DingOrgTreeNodeWithUsers type**
|
||||
|
||||
After the existing `DingOrgTreeNode` interface (line ~73), add:
|
||||
|
||||
```typescript
|
||||
/** 钉钉部门树节点(含用户),供同步用户选择器使用 */
|
||||
export interface DingOrgTreeNodeWithUsers {
|
||||
id: number;
|
||||
name: string;
|
||||
parentId: number;
|
||||
children: DingOrgTreeNodeWithUsers[];
|
||||
users: Array<{ userid: string; name: string; mobile: string }>;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add fetchOrgTreeWithUsers method**
|
||||
|
||||
After `fetchOrgTree` method (line ~456), add:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 获取钉钉组织部门树(含用户),供前端同步用户选择器使用。
|
||||
* 返回从指定 rootDeptId 开始的树,每个部门节点含 users 数组。
|
||||
*/
|
||||
async fetchOrgTreeWithUsers(rootDeptId = 1): Promise<DingOrgTreeNodeWithUsers[]> {
|
||||
if (!this.configured) {
|
||||
throw new ServiceUnavailableException('钉钉未配置');
|
||||
}
|
||||
const token = await this.getAccessToken();
|
||||
const deptIds = await this.getAllDeptIds(token, rootDeptId);
|
||||
|
||||
// 拉每个部门详情
|
||||
const nodes: DingOrgTreeNodeWithUsers[] = [];
|
||||
for (let i = 0; i < deptIds.length; i++) {
|
||||
if (i > 0) await this.delay(i);
|
||||
const detail = await this.getDeptDetail(token, deptIds[i]);
|
||||
if (!detail) continue;
|
||||
|
||||
// 拉该部门下的用户
|
||||
const dingUsers = await this.getDeptUsers(token, deptIds[i]);
|
||||
|
||||
nodes.push({
|
||||
id: detail.dept_id,
|
||||
name: detail.name,
|
||||
parentId: detail.parent_id,
|
||||
children: [],
|
||||
users: dingUsers.map((u) => ({
|
||||
userid: u.userid,
|
||||
name: u.name,
|
||||
mobile: u.mobile,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// 全局去重:同一个 dingUserId 可能在多个部门出现
|
||||
const seenUserIds = new Set<string>();
|
||||
for (const node of nodes) {
|
||||
node.users = node.users.filter((u) => {
|
||||
if (seenUserIds.has(u.userid)) return false;
|
||||
seenUserIds.add(u.userid);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// 组装成树
|
||||
const map = new Map<number, DingOrgTreeNodeWithUsers>();
|
||||
nodes.forEach((n) => map.set(n.id, n));
|
||||
const roots: DingOrgTreeNodeWithUsers[] = [];
|
||||
for (const node of nodes) {
|
||||
const parent = map.get(node.parentId);
|
||||
if (parent && node.id !== rootDeptId) {
|
||||
parent.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build check**
|
||||
|
||||
```bash
|
||||
cd apps/server && npx tsc --noEmit -p tsconfig.build.json
|
||||
```
|
||||
|
||||
Expected: no new errors (existing pre-existing errors may remain).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/server/src/integration/dingtalk.service.ts
|
||||
git commit -m "feat: add fetchOrgTreeWithUsers to DingTalkService"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Backend — SyncService new methods
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/server/src/sync/sync.service.ts`
|
||||
- Modify: `apps/server/src/sync/sync.module.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `DingOrgTreeNodeWithUsers` from Task 1
|
||||
- Produces: `getDingTalkOrgTreeWithUsers(rootDeptId?: number): Promise<DingOrgTreeNodeWithUsers[]>`, `importDingTalkUsers(users: ImportUserDto[]): Promise<{ teacherCount: number; studentCount: number; skipped: number }>`
|
||||
|
||||
- [ ] **Step 1: Add ImportUserDto and inject new repos**
|
||||
|
||||
In `sync.service.ts`, after existing imports, add:
|
||||
|
||||
```typescript
|
||||
import { User } from '../entities/user.entity';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { Role } from '../entities/role.entity';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
```
|
||||
|
||||
Add to constructor injection (after existing `mappingRepo`):
|
||||
|
||||
```typescript
|
||||
@InjectRepository(User)
|
||||
private readonly userRepo: Repository<User>,
|
||||
@InjectRepository(Student)
|
||||
private readonly studentRepo: Repository<Student>,
|
||||
@InjectRepository(Role)
|
||||
private readonly roleRepo: Repository<Role>,
|
||||
```
|
||||
|
||||
Add DTO interface at top of file (before class):
|
||||
|
||||
```typescript
|
||||
export interface ImportUserDto {
|
||||
dingUserId: string;
|
||||
name: string;
|
||||
mobile: string;
|
||||
roleId: number | null;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add getDingTalkOrgTreeWithUsers method**
|
||||
|
||||
After existing `getDingTalkOrgTree` method (line ~93), add:
|
||||
|
||||
```typescript
|
||||
/** 获取钉钉组织部门树(含用户),供前端同步用户选择器使用 */
|
||||
async getDingTalkOrgTreeWithUsers(rootDeptId = 1) {
|
||||
return this.dingTalkService.fetchOrgTreeWithUsers(rootDeptId);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add importDingTalkUsers method**
|
||||
|
||||
After the new `getDingTalkOrgTreeWithUsers`, add:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 从钉钉导入用户:roleId 非 null → 老师(User + 指定角色),roleId null → 学生(User + Student)。
|
||||
* 已存在 UserDingMapping 的记录跳过。
|
||||
*/
|
||||
async importDingTalkUsers(users: ImportUserDto[]): Promise<{
|
||||
teacherCount: number;
|
||||
studentCount: number;
|
||||
skipped: number;
|
||||
}> {
|
||||
let teacherCount = 0;
|
||||
let studentCount = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const u of users) {
|
||||
// 检查是否已存在映射
|
||||
const existing = await this.mappingRepo.findOne({
|
||||
where: { dingUserId: u.dingUserId },
|
||||
});
|
||||
if (existing) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const username = u.mobile || `dd_${u.dingUserId}`;
|
||||
const passwordHash = await bcrypt.hash('123456', 10);
|
||||
|
||||
const user = this.userRepo.create({
|
||||
username,
|
||||
name: u.name,
|
||||
passwordHash,
|
||||
isActive: true,
|
||||
});
|
||||
await this.userRepo.save(user);
|
||||
|
||||
if (u.roleId != null) {
|
||||
// 老师:分配角色
|
||||
const role = await this.roleRepo.findOne({ where: { id: u.roleId } });
|
||||
if (role) {
|
||||
user.roles = [role];
|
||||
await this.userRepo.save(user);
|
||||
} else {
|
||||
this.logger.warn(`角色 id=${u.roleId} 不存在,用户 ${u.name} 未分配角色`);
|
||||
}
|
||||
teacherCount++;
|
||||
} else {
|
||||
// 学生:创建 Student 记录
|
||||
const student = this.studentRepo.create({
|
||||
name: u.name,
|
||||
phone: u.mobile || undefined,
|
||||
userId: user.id,
|
||||
status: 'active',
|
||||
});
|
||||
await this.studentRepo.save(student);
|
||||
studentCount++;
|
||||
}
|
||||
|
||||
// 创建映射
|
||||
const mapping = this.mappingRepo.create({
|
||||
dingUserId: u.dingUserId,
|
||||
userId: user.id,
|
||||
dingName: u.name,
|
||||
dingMobile: u.mobile,
|
||||
});
|
||||
await this.mappingRepo.save(mapping);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`导入用户 ${u.name}(${u.dingUserId}) 失败: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`钉钉用户导入完成: ${teacherCount} 位老师, ${studentCount} 位学生, ${skipped} 跳过`,
|
||||
);
|
||||
return { teacherCount, studentCount, skipped };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update sync.module.ts — add User, Student, Role entities**
|
||||
|
||||
In `TypeOrmModule.forFeature([...])` array, add `User`, `Student`, `Role` to the imports. Also add the import for them at the top:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
SyncLog,
|
||||
SyncState,
|
||||
UserDingMapping,
|
||||
ClassSchedule,
|
||||
Department,
|
||||
UserDepartment,
|
||||
ClassTeacher,
|
||||
User,
|
||||
Student,
|
||||
Role,
|
||||
} from '../entities';
|
||||
```
|
||||
|
||||
And in the `forFeature` array after `ClassTeacher`:
|
||||
|
||||
```typescript
|
||||
User,
|
||||
Student,
|
||||
Role,
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Build check**
|
||||
|
||||
```bash
|
||||
cd apps/server && npx tsc --noEmit -p tsconfig.build.json
|
||||
```
|
||||
|
||||
Expected: no new errors from modified files.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/server/src/sync/sync.service.ts apps/server/src/sync/sync.module.ts
|
||||
git commit -m "feat: add importDingTalkUsers and org-tree-with-users to SyncService"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Backend — SyncController new endpoints
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/server/src/sync/sync.controller.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `getDingTalkOrgTreeWithUsers`, `importDingTalkUsers` from Task 2
|
||||
|
||||
- [ ] **Step 1: Add org-tree-with-users endpoint**
|
||||
|
||||
After `getDingTalkOrgTree` endpoint (line ~41), add:
|
||||
|
||||
```typescript
|
||||
/** 获取钉钉组织部门树(含用户),供同步用户选择器使用 */
|
||||
@Get('dingtalk/org-tree-with-users')
|
||||
@RequirePermission('sync:read')
|
||||
async getDingTalkOrgTreeWithUsers(@Query('rootDeptId') rootDeptId?: string) {
|
||||
const rootId = rootDeptId ? this.parseRootDeptId(rootDeptId) : 1;
|
||||
const tree = await this.syncService.getDingTalkOrgTreeWithUsers(rootId);
|
||||
return { success: true, data: tree };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add import-users endpoint**
|
||||
|
||||
After the new endpoint above, add:
|
||||
|
||||
```typescript
|
||||
/** 导入钉钉用户:老师分配角色,学生创建 Student */
|
||||
@Post('dingtalk/import-users')
|
||||
@RequirePermission('sync:trigger')
|
||||
async importDingTalkUsers(@Body() body: { users: Array<{ dingUserId: string; name: string; mobile: string; roleId: number | null }> }) {
|
||||
const result = await this.syncService.importDingTalkUsers(body.users);
|
||||
return { success: true, ...result };
|
||||
}
|
||||
```
|
||||
|
||||
Add `Body` to the imports from `@nestjs/common` at the top if not already present (check line 1 — `BadRequestException` is there, add `Body`):
|
||||
|
||||
```typescript
|
||||
import { BadRequestException, Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build check**
|
||||
|
||||
```bash
|
||||
cd apps/server && npx tsc --noEmit -p tsconfig.build.json
|
||||
```
|
||||
|
||||
Expected: no new errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/server/src/sync/sync.controller.ts
|
||||
git commit -m "feat: add org-tree-with-users and import-users endpoints"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Frontend — Users page cleanup
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/admin/src/pages/Users/index.tsx`
|
||||
|
||||
- [ ] **Step 1: Remove imports**
|
||||
|
||||
Remove from import line (line 1): `useCallback` (if only used by sync), `CloudDownloadOutlined` (line 15), `TreeSelect` (from antd imports).
|
||||
|
||||
Check: `useCallback` is also used by `fetchData` (line 81), so keep it. Only remove `CloudDownloadOutlined` and `TreeSelect` from imports.
|
||||
|
||||
Change antd import line (line 2-14):
|
||||
- Remove `TreeSelect` from the destructured import list.
|
||||
|
||||
Change icons import line 15:
|
||||
- Remove `CloudDownloadOutlined` from the import.
|
||||
|
||||
- [ ] **Step 2: Remove sync-related state and functions**
|
||||
|
||||
Remove these state declarations (lines ~33, 37-38):
|
||||
- `const [syncing, setSyncing] = useState(false);`
|
||||
- `const [syncDeptId, setSyncDeptId] = useState<number | undefined>(undefined);`
|
||||
- `const [orgTree, setOrgTree] = useState<...>([]);`
|
||||
|
||||
Remove these functions:
|
||||
- `loadOrgTree` (lines ~40-53)
|
||||
- `handleSyncDingTalk` (lines ~96-113)
|
||||
- `handleMarkStaff` (lines ~187-196)
|
||||
|
||||
- [ ] **Step 3: Remove sync button and TreeSelect from JSX**
|
||||
|
||||
In the toolbar `<Space wrap>` (lines ~332-368):
|
||||
- Remove the `TreeSelect` block (lines ~333-342)
|
||||
- Remove the `PermissionButton` with `permission="sync:trigger"` (lines ~351-358)
|
||||
|
||||
- [ ] **Step 4: Remove mark-staff/mark-student buttons from columns**
|
||||
|
||||
In the `columns` useMemo (lines ~298-307), remove the two conditional blocks:
|
||||
- Remove lines ~298-302: `record.studentStatus === 'active'` → "标记教职工" button
|
||||
- Remove lines ~303-307: `record.studentStatus === 'staff'` → "恢复学员" button
|
||||
|
||||
Adjust the `width` of the 操作 column from `320` to `240` since we're removing two buttons.
|
||||
|
||||
- [ ] **Step 5: Build check**
|
||||
|
||||
```bash
|
||||
cd apps/admin && npx tsc --noEmit
|
||||
```
|
||||
|
||||
Expected: no new errors.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/admin/src/pages/Users/index.tsx
|
||||
git commit -m "refactor: remove sync and mark-staff buttons from Users page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Frontend — IntegrationConfig sync users Tab + Drawer
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/admin/src/pages/IntegrationConfig/index.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `GET /sync/dingtalk/org-tree-with-users`, `POST /sync/dingtalk/import-users`, `GET /rbac/roles`
|
||||
- Produces: SyncUsersTab component with org tree Drawer, teacher selection, role assignment
|
||||
|
||||
- [ ] **Step 1: Add new imports**
|
||||
|
||||
Add to existing antd imports: `Tabs`, `Drawer`, `Tree`, `Checkbox`, `Select`, `TreeSelect`.
|
||||
|
||||
Add icons: `SyncOutlined`, `ReloadOutlined`.
|
||||
|
||||
Current import block (lines 1-8):
|
||||
|
||||
```typescript
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Card, Form, Input, Button, Space, message, Spin, Switch, Alert, Descriptions, Tag,
|
||||
} from 'antd';
|
||||
import {
|
||||
SaveOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import api from '../../api';
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```typescript
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import {
|
||||
Card, Form, Input, Button, Space, message, Spin, Switch, Alert, Descriptions, Tag,
|
||||
Tabs, Drawer, Tree, Checkbox, Select, TreeSelect,
|
||||
} from 'antd';
|
||||
import {
|
||||
SaveOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined,
|
||||
SyncOutlined, ReloadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { DataNode } from 'antd/es/tree';
|
||||
import api from '../../api';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add types and state for sync tab**
|
||||
|
||||
After the existing `IntegrationConfigPage` component declaration, add new state:
|
||||
|
||||
```typescript
|
||||
// ── Sync Users Tab ──
|
||||
const [syncRootDeptId, setSyncRootDeptId] = useState<number | undefined>(undefined);
|
||||
const [orgTree, setOrgTree] = useState<DingOrgTreeNodeExt[]>([]);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [fetchingTree, setFetchingTree] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [roles, setRoles] = useState<Array<{ id: number; name: string }>>([]);
|
||||
const [defaultTeacherRoleId, setDefaultTeacherRoleId] = useState<number | null>(null);
|
||||
// Department tree for the picker (no users)
|
||||
const [deptPickerTree, setDeptPickerTree] = useState<Array<{ title: string; value: number; children?: Array<{ title: string; value: number; children?: unknown[] }> }>>([]);
|
||||
```
|
||||
|
||||
Add the extended tree node type before the component:
|
||||
|
||||
```typescript
|
||||
interface DingOrgTreeNodeExt {
|
||||
id: number;
|
||||
name: string;
|
||||
parentId: number;
|
||||
children: DingOrgTreeNodeExt[];
|
||||
users: Array<{ userid: string; name: string; mobile: string }>;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add fetch roles and fetch org tree handlers**
|
||||
|
||||
```typescript
|
||||
const fetchRoles = async () => {
|
||||
try {
|
||||
const res: any = await api.get('/rbac/roles');
|
||||
const activeRoles = res.filter((r: any) => r.status !== 0);
|
||||
setRoles(activeRoles);
|
||||
const teacherRole = activeRoles.find((r: any) => r.name === '班主任');
|
||||
setDefaultTeacherRoleId(teacherRole?.id || activeRoles[0]?.id || null);
|
||||
} catch {
|
||||
// ignore — roles will be empty
|
||||
}
|
||||
};
|
||||
|
||||
const loadDeptTree = async () => {
|
||||
try {
|
||||
const res: any = await api.get('/sync/dingtalk/org-tree');
|
||||
if (res.success && res.data) {
|
||||
const toTreeNode = (nodes: any[]): any[] =>
|
||||
nodes.map((n: any) => ({
|
||||
title: n.name,
|
||||
value: n.id,
|
||||
children: n.children ? toTreeNode(n.children) : undefined,
|
||||
}));
|
||||
setDeptPickerTree(toTreeNode(res.data));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const handleFetchOrgTree = async () => {
|
||||
setFetchingTree(true);
|
||||
try {
|
||||
const params: Record<string, string> = {};
|
||||
if (syncRootDeptId) params.rootDeptId = String(syncRootDeptId);
|
||||
const res: any = await api.get('/sync/dingtalk/org-tree-with-users', { params });
|
||||
if (res.success && res.data) {
|
||||
setOrgTree(res.data);
|
||||
setTeacherChecks({});
|
||||
setTeacherRoles({});
|
||||
setDrawerOpen(true);
|
||||
} else {
|
||||
message.error('获取组织架构失败');
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '获取组织架构失败');
|
||||
} finally {
|
||||
setFetchingTree(false);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add import handler**
|
||||
|
||||
```typescript
|
||||
const handleImportUsers = async () => {
|
||||
setImporting(true);
|
||||
try {
|
||||
// Flatten all users from tree
|
||||
const allUsers: Array<{
|
||||
dingUserId: string;
|
||||
name: string;
|
||||
mobile: string;
|
||||
}> = [];
|
||||
|
||||
const flatten = (nodes: DingOrgTreeNodeExt[]) => {
|
||||
for (const node of nodes) {
|
||||
allUsers.push(...node.users);
|
||||
flatten(node.children);
|
||||
}
|
||||
};
|
||||
flatten(orgTree);
|
||||
|
||||
const payload = {
|
||||
users: allUsers.map((u) => ({
|
||||
dingUserId: u.userid,
|
||||
name: u.name,
|
||||
mobile: u.mobile,
|
||||
roleId: teacherChecks[u.userid]
|
||||
? (teacherRoles[u.userid] || defaultTeacherRoleId)
|
||||
: null,
|
||||
})),
|
||||
};
|
||||
|
||||
const res: any = await api.post('/sync/dingtalk/import-users', payload);
|
||||
message.success(
|
||||
`导入完成:${res.teacherCount} 位老师,${res.studentCount} 位学生` +
|
||||
(res.skipped > 0 ? `,${res.skipped} 已跳过` : ''),
|
||||
);
|
||||
setDrawerOpen(false);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '导入失败');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Build tree data for Drawer**
|
||||
|
||||
```typescript
|
||||
const buildTreeData = (nodes: DingOrgTreeNodeExt[]): DataNode[] => {
|
||||
return nodes.map((node) => ({
|
||||
title: node.name,
|
||||
key: `dept-${node.id}`,
|
||||
children: [
|
||||
// Sub-departments
|
||||
...buildTreeData(node.children),
|
||||
// Users in this department
|
||||
...node.users.map((u) => ({
|
||||
title: (
|
||||
<Space size="small">
|
||||
<Checkbox
|
||||
checked={!!teacherChecks[u.userid]}
|
||||
onChange={(e) => {
|
||||
setTeacherChecks((prev) => ({
|
||||
...prev,
|
||||
[u.userid]: e.target.checked,
|
||||
}));
|
||||
if (!e.target.checked) {
|
||||
setTeacherRoles((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[u.userid];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
老师
|
||||
</Checkbox>
|
||||
<span style={{ fontWeight: 500 }}>{u.name}</span>
|
||||
{u.mobile && (
|
||||
<Tag style={{ marginLeft: 4 }}>{u.mobile}</Tag>
|
||||
)}
|
||||
{teacherChecks[u.userid] && (
|
||||
<Select
|
||||
size="small"
|
||||
style={{ width: 100, marginLeft: 8 }}
|
||||
value={teacherRoles[u.userid] || defaultTeacherRoleId}
|
||||
onChange={(roleId: number) =>
|
||||
setTeacherRoles((prev) => ({ ...prev, [u.userid]: roleId }))
|
||||
}
|
||||
options={roles.map((r) => ({ label: r.name, value: r.id }))}
|
||||
placeholder="选择角色"
|
||||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
key: `user-${u.userid}`,
|
||||
selectable: false,
|
||||
})),
|
||||
],
|
||||
}));
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Build tree data memoized**
|
||||
|
||||
```typescript
|
||||
const treeData = useMemo(() => buildTreeData(orgTree), [orgTree, teacherChecks, teacherRoles, defaultTeacherRoleId, roles]);
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Replace page root with Tabs**
|
||||
|
||||
Replace the entire `return (...)` block. The page root becomes:
|
||||
|
||||
```typescript
|
||||
const syncTabItems = config
|
||||
? [
|
||||
{
|
||||
key: 'sync-users',
|
||||
label: '同步用户',
|
||||
children: (
|
||||
<div>
|
||||
<Alert
|
||||
type="info"
|
||||
message="从钉钉获取组织架构,勾选老师并分配角色,其余用户将作为学生导入。"
|
||||
style={{ marginBottom: 16 }}
|
||||
showIcon
|
||||
/>
|
||||
<Space>
|
||||
<TreeSelect
|
||||
treeData={deptPickerTree}
|
||||
value={syncRootDeptId}
|
||||
onChange={(v) => setSyncRootDeptId(v)}
|
||||
placeholder="选择起始部门(不选=全部)"
|
||||
allowClear
|
||||
treeDefaultExpandAll
|
||||
style={{ minWidth: 240 }}
|
||||
onDropdownVisibleChange={(open) => { if (open) loadDeptTree(); }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SyncOutlined />}
|
||||
loading={fetchingTree}
|
||||
onClick={handleFetchOrgTree}
|
||||
>
|
||||
获取组织架构
|
||||
</Button>
|
||||
</Space>
|
||||
{drawerOpen && (
|
||||
<Drawer
|
||||
title="钉钉组织架构 — 勾选老师"
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
width={520}
|
||||
footer={
|
||||
<Space>
|
||||
<Button onClick={() => setDrawerOpen(false)}>取消</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={importing}
|
||||
onClick={handleImportUsers}
|
||||
>
|
||||
导入
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{treeData.length > 0 ? (
|
||||
<Tree
|
||||
treeData={treeData}
|
||||
defaultExpandAll
|
||||
blockNode
|
||||
showLine={{ showLeafIcon: false }}
|
||||
/>
|
||||
) : (
|
||||
<Spin />
|
||||
)}
|
||||
</Drawer>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'config',
|
||||
children: (
|
||||
<Spin spinning={loading}>
|
||||
{config && (
|
||||
<Descriptions size="small" column={2} style={{ marginBottom: 24 }}>
|
||||
<Descriptions.Item label="CorpId">{config.corpId || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="AppKey">{config.agentId || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="启用同步">
|
||||
<Tag color={config.startEnable ? 'green' : 'default'}>
|
||||
{config.startEnable ? '已启用' : '未启用'}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
message="配置钉钉应用凭证后,可使用组织架构同步、考勤导入和排班同步功能。"
|
||||
style={{ marginBottom: 24 }}
|
||||
showIcon
|
||||
/>
|
||||
|
||||
<Form form={form} layout="vertical" style={{ maxWidth: 480 }}>
|
||||
<Form.Item name="corpId" label="CorpId(企业ID)" rules={[{ required: true, message: '请输入 CorpId' }]}>
|
||||
<Input placeholder="dingxxxxxxxx" />
|
||||
</Form.Item>
|
||||
<Form.Item name="agentId" label="AppKey(应用凭证)" rules={[{ required: true, message: '请输入 AppKey' }]}>
|
||||
<Input placeholder="从钉钉开放平台获取" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="appSecret"
|
||||
label="AppSecret(应用密钥)"
|
||||
rules={[{ required: true, message: '请输入 AppSecret' }]}
|
||||
extra="保存后仅返回脱敏信息,重新编辑时需再次输入完整密钥"
|
||||
>
|
||||
<Input.Password placeholder="从钉钉开放平台获取" />
|
||||
</Form.Item>
|
||||
<Form.Item name="startEnable" label="启用同步" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={handleSave}>
|
||||
保存配置
|
||||
</Button>
|
||||
<Button icon={<ApiOutlined />} loading={testing} onClick={handleTest}>
|
||||
测试连接
|
||||
</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
</Spin>
|
||||
),
|
||||
},
|
||||
...syncTabItems,
|
||||
];
|
||||
|
||||
return (
|
||||
<Card title="钉钉集成配置" extra={...}>
|
||||
<Tabs items={tabItems} />
|
||||
</Card>
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Add fetchRoles to useEffect**
|
||||
|
||||
In the existing `useEffect` (line ~42), add `fetchRoles()` call:
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
fetchRoles();
|
||||
}, []);
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Build check**
|
||||
|
||||
```bash
|
||||
cd apps/admin && npx tsc --noEmit
|
||||
```
|
||||
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 10: E2E smoke test**
|
||||
|
||||
Start the dev server and verify:
|
||||
1. Navigate to IntegrationConfig page
|
||||
2. "同步用户" Tab only visible when DingTalk is configured
|
||||
3. Click "获取组织架构" → Drawer opens with department tree
|
||||
4. Check users as teachers → role select appears (defaults to 班主任)
|
||||
5. Click "导入" → success message with counts
|
||||
|
||||
```bash
|
||||
cd apps/admin && npx vite --port 5173 &
|
||||
cd apps/server && npm run start:dev &
|
||||
```
|
||||
|
||||
- [ ] **Step 11: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/admin/src/pages/IntegrationConfig/index.tsx
|
||||
git commit -m "feat: add sync users Tab with Drawer tree to IntegrationConfig"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Final verification & cleanup
|
||||
|
||||
- [ ] **Step 1: Run full type check**
|
||||
|
||||
```bash
|
||||
cd apps/server && npx tsc --noEmit -p tsconfig.build.json
|
||||
cd apps/admin && npx tsc --noEmit
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify Users page no longer shows sync/mark buttons**
|
||||
|
||||
Smoke test Users page — confirm no "同步钉钉用户" button, no TreeSelect, and no "标记教职工"/"恢复学员" in the actions column.
|
||||
|
||||
- [ ] **Step 3: Verify IntegrationConfig sync flow end-to-end**
|
||||
|
||||
Run through the full flow:
|
||||
1. Config page → Sync Users tab
|
||||
2. Fetch org tree → Drawer shows tree
|
||||
3. Check teachers → role dropdown works
|
||||
4. Import → correct counts returned
|
||||
5. Verify in DB: teachers have roles, students have Student records
|
||||
|
||||
- [ ] **Step 4: Commit any remaining changes**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore: final verification and cleanup for dingtalk sync role selection"
|
||||
```
|
||||
Reference in New Issue
Block a user