Files
gongxue-base/docs/superpowers/plans/2026-07-09-dingtalk-sync-role-selection.md

877 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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"
```