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

27 KiB
Raw Blame History

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:

/** 钉钉部门树节点(含用户),供同步用户选择器使用 */
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:

  /**
   * 获取钉钉组织部门树(含用户),供前端同步用户选择器使用。
   * 返回从指定 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
cd apps/server && npx tsc --noEmit -p tsconfig.build.json

Expected: no new errors (existing pre-existing errors may remain).

  • Step 4: Commit
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:

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):

    @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):

export interface ImportUserDto {
  dingUserId: string;
  name: string;
  mobile: string;
  roleId: number | null;
}
  • Step 2: Add getDingTalkOrgTreeWithUsers method

After existing getDingTalkOrgTree method (line ~93), add:

  /** 获取钉钉组织部门树(含用户),供前端同步用户选择器使用 */
  async getDingTalkOrgTreeWithUsers(rootDeptId = 1) {
    return this.dingTalkService.fetchOrgTreeWithUsers(rootDeptId);
  }
  • Step 3: Add importDingTalkUsers method

After the new getDingTalkOrgTreeWithUsers, add:

  /**
   * 从钉钉导入用户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:

import {
  SyncLog,
  SyncState,
  UserDingMapping,
  ClassSchedule,
  Department,
  UserDepartment,
  ClassTeacher,
  User,
  Student,
  Role,
} from '../entities';

And in the forFeature array after ClassTeacher:

      User,
      Student,
      Role,
  • Step 5: Build check
cd apps/server && npx tsc --noEmit -p tsconfig.build.json

Expected: no new errors from modified files.

  • Step 6: Commit
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:

  /** 获取钉钉组织部门树(含用户),供同步用户选择器使用 */
  @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:

  /** 导入钉钉用户:老师分配角色,学生创建 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):

import { BadRequestException, Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
  • Step 3: Build check
cd apps/server && npx tsc --noEmit -p tsconfig.build.json

Expected: no new errors.

  • Step 4: Commit
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
cd apps/admin && npx tsc --noEmit

Expected: no new errors.

  • Step 6: Commit
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):

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:

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:

  // ── 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:

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
  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
  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
  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
  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:

  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:

  useEffect(() => {
    fetchConfig();
    fetchRoles();
  }, []);
  • Step 9: Build check
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
cd apps/admin && npx vite --port 5173 &
cd apps/server && npm run start:dev &
  • Step 11: Commit
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
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
git add -A
git commit -m "chore: final verification and cleanup for dingtalk sync role selection"