# 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` - [ ] **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 { 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(); 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(); 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`, `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, @InjectRepository(Student) private readonly studentRepo: Repository, @InjectRepository(Role) private readonly roleRepo: Repository, ``` 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(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 `` (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(undefined); const [orgTree, setOrgTree] = useState([]); const [drawerOpen, setDrawerOpen] = useState(false); const [fetchingTree, setFetchingTree] = useState(false); const [importing, setImporting] = useState(false); const [roles, setRoles] = useState>([]); const [defaultTeacherRoleId, setDefaultTeacherRoleId] = useState(null); // Department tree for the picker (no users) const [deptPickerTree, setDeptPickerTree] = useState }>>([]); ``` 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 = {}; 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: ( { setTeacherChecks((prev) => ({ ...prev, [u.userid]: e.target.checked, })); if (!e.target.checked) { setTeacherRoles((prev) => { const next = { ...prev }; delete next[u.userid]; return next; }); } }} > 老师 {u.name} {u.mobile && ( {u.mobile} )} {teacherChecks[u.userid] && ( ), }, ...syncTabItems, ]; return ( ); ``` - [ ] **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" ```