diff --git a/apps/admin/src/pages/IntegrationConfig/index.tsx b/apps/admin/src/pages/IntegrationConfig/index.tsx
index 9c530f7..ea8b562 100644
--- a/apps/admin/src/pages/IntegrationConfig/index.tsx
+++ b/apps/admin/src/pages/IntegrationConfig/index.tsx
@@ -1,10 +1,13 @@
-import React, { useEffect, useMemo, useState } from 'react';
+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';
interface DingTalkConfig {
@@ -14,11 +17,68 @@ interface DingTalkConfig {
startEnable: boolean;
}
-/** Utility: recursively flatten org tree nodes to extract user ids (for import guard) */
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-const getAllUsers = (nodes: any[]): Array<{ userid: string }> => {
- return nodes.flatMap((n) => [...(n.users ?? []), ...getAllUsers(n.children ?? [])]);
-};
+interface DingOrgTreeNodeExt {
+ id: number;
+ name: string;
+ parentId: number;
+ children: DingOrgTreeNodeExt[];
+ users: Array<{ userid: string; name: string; mobile: string }>;
+}
+
+interface RoleItem {
+ id: number;
+ name: string;
+ status: number;
+}
+
+interface OrgTreeNodeRaw {
+ id: number;
+ name: string;
+ children?: OrgTreeNodeRaw[];
+}
+
+interface OrgTreeResponse {
+ success: boolean;
+ data: OrgTreeNodeRaw[];
+}
+
+interface OrgTreeWithUsersResponse {
+ success: boolean;
+ data: DingOrgTreeNodeExt[];
+}
+
+interface ImportUsersResponse {
+ teacherCount: number;
+ studentCount: number;
+ skipped: number;
+}
+
+const UserTreeNode: React.FC<{
+ u: { userid: string; name: string; mobile: string };
+ isTeacher: boolean;
+ onToggle: () => void;
+ roleId: number | undefined;
+ defaultRoleId: number | null;
+ roles: Array<{ id: number; name: string }>;
+ onRoleChange: (roleId: number) => void;
+}> = React.memo(({ u, isTeacher, onToggle, roleId, defaultRoleId, roles, onRoleChange }) => (
+
+ 老师
+ {u.name}
+ {u.mobile && {u.mobile}}
+ {isTeacher && (
+
+));
const IntegrationConfigPage: React.FC = () => {
const [loading, setLoading] = useState(false);
@@ -28,6 +88,18 @@ const IntegrationConfigPage: React.FC = () => {
const [verified, setVerified] = useState(null);
const [form] = Form.useForm();
+ // ── 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 [teacherChecks, setTeacherChecks] = useState>({});
+ const [teacherRoles, setTeacherRoles] = useState>({});
+ const [roles, setRoles] = useState>([]);
+ const [defaultTeacherRoleId, setDefaultTeacherRoleId] = useState(null);
+ const [deptPickerTree, setDeptPickerTree] = useState }>>([]);
+
const fetchConfig = async () => {
setLoading(true);
try {
@@ -46,7 +118,7 @@ const IntegrationConfigPage: React.FC = () => {
};
useEffect(() => {
- fetchConfig();
+ void Promise.all([fetchConfig(), fetchRoles()]);
}, []);
const handleSave = async () => {
@@ -83,6 +155,272 @@ const IntegrationConfigPage: React.FC = () => {
}
};
+ const fetchRoles = async () => {
+ try {
+ const res = await api.get('/rbac/roles');
+ const activeRoles = res.filter((r) => r.status !== 0);
+ setRoles(activeRoles);
+ const teacherRole = activeRoles.find((r) => r.name === '班主任');
+ setDefaultTeacherRoleId(teacherRole?.id || activeRoles[0]?.id || null);
+ } catch {
+ message.error('获取角色列表失败');
+ }
+ };
+
+ const loadDeptTree = async () => {
+ try {
+ const res = await api.get('/sync/dingtalk/org-tree');
+ if (res.success && res.data) {
+ const toTreeNode = (nodes: OrgTreeNodeRaw[]): Array<{ title: string; value: number; children?: Array<{ title: string; value: number; children?: unknown[] }> }> =>
+ nodes.map((n) => ({
+ title: n.name,
+ value: n.id,
+ children: n.children ? toTreeNode(n.children) : undefined,
+ }));
+ setDeptPickerTree(toTreeNode(res.data));
+ }
+ } catch {
+ message.error('获取部门架构失败');
+ }
+ };
+
+ const handleFetchOrgTree = async () => {
+ setFetchingTree(true);
+ try {
+ const params: Record = {};
+ if (syncRootDeptId) params.rootDeptId = String(syncRootDeptId);
+ const res = 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: unknown) {
+ const err = e as { message?: string };
+ message.error(err?.message || '获取组织架构失败');
+ } finally {
+ setFetchingTree(false);
+ }
+ };
+
+ const handleImportUsers = async () => {
+ setImporting(true);
+ try {
+ 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 = await api.post('/sync/dingtalk/import-users', payload);
+ message.success(
+ `导入完成:${res.teacherCount} 位老师,${res.studentCount} 位学生` +
+ (res.skipped > 0 ? `,${res.skipped} 已跳过` : ''),
+ );
+ setDrawerOpen(false);
+ } catch (e: unknown) {
+ const err = e as { message?: string };
+ message.error(err?.message || '导入失败');
+ } finally {
+ setImporting(false);
+ }
+ };
+
+ const buildTreeData = (nodes: DingOrgTreeNodeExt[]): DataNode[] => {
+ return nodes.map((node) => ({
+ title: node.name,
+ key: `dept-${node.id}`,
+ children: [
+ ...buildTreeData(node.children),
+ ...node.users.map((u) => ({
+ title: (
+ {
+ setTeacherChecks((prev) => ({
+ ...prev,
+ [u.userid]: !prev[u.userid],
+ }));
+ if (teacherChecks[u.userid]) {
+ setTeacherRoles((prev) => {
+ const next = { ...prev };
+ delete next[u.userid];
+ return next;
+ });
+ }
+ }}
+ roleId={teacherRoles[u.userid]}
+ defaultRoleId={defaultTeacherRoleId}
+ roles={roles}
+ onRoleChange={(roleId) =>
+ setTeacherRoles((prev) => ({ ...prev, [u.userid]: roleId }))
+ }
+ />
+ ),
+ key: `user-${u.userid}`,
+ selectable: false,
+ })),
+ ],
+ }));
+ };
+
+ const treeData = useMemo(() => buildTreeData(orgTree), [orgTree, teacherChecks, teacherRoles, defaultTeacherRoleId, roles]);
+
+ const syncTabItems = config
+ ? [
+ {
+ key: 'sync-users',
+ label: '同步用户',
+ children: (
+
+
+
+ setSyncRootDeptId(v)}
+ placeholder="选择起始部门(不选=全部)"
+ allowClear
+ treeDefaultExpandAll
+ style={{ minWidth: 240 }}
+ onDropdownVisibleChange={(open) => { if (open) loadDeptTree(); }}
+ />
+ }
+ loading={fetchingTree}
+ onClick={handleFetchOrgTree}
+ >
+ 获取组织架构
+
+
+ {drawerOpen && (
+
setDrawerOpen(false)}
+ width={520}
+ footer={
+
+
+ }
+ loading={importing}
+ disabled={!defaultTeacherRoleId}
+ title={!defaultTeacherRoleId ? '角色列表未加载,无法导入' : undefined}
+ onClick={handleImportUsers}
+ >
+ 导入
+
+
+ }
+ >
+ {treeData.length > 0 ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ ),
+ },
+ ]
+ : [];
+
+ const tabItems = [
+ {
+ key: 'config',
+ label: '配置',
+ children: (
+
+ {config && (
+
+ {config.corpId || '-'}
+ {config.agentId || '-'}
+
+
+ {config.startEnable ? '已启用' : '未启用'}
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ } loading={saving} onClick={handleSave}>
+ 保存配置
+
+ } loading={testing} onClick={handleTest}>
+ 测试连接
+
+
+
+
+ ),
+ },
+ ...syncTabItems,
+ ];
+
return (
@@ -90,54 +428,7 @@ const IntegrationConfigPage: React.FC = () => {
{verified === false && } color="error">未连接}
}>
-
- {config && (
-
- {config.corpId || '-'}
- {config.agentId || '-'}
-
-
- {config.startEnable ? '已启用' : '未启用'}
-
-
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- } loading={saving} onClick={handleSave}>
- 保存配置
-
- } loading={testing} onClick={handleTest}>
- 测试连接
-
-
-
-
+
);
};