feat: add sync users Tab with Drawer tree to IntegrationConfig
This commit is contained in:
@@ -1,10 +1,13 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Card, Form, Input, Button, Space, message, Spin, Switch, Alert, Descriptions, Tag,
|
Card, Form, Input, Button, Space, message, Spin, Switch, Alert, Descriptions, Tag,
|
||||||
|
Tabs, Drawer, Tree, Checkbox, Select, TreeSelect,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
SaveOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined,
|
SaveOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined,
|
||||||
|
SyncOutlined, ReloadOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
import type { DataNode } from 'antd/es/tree';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
|
|
||||||
interface DingTalkConfig {
|
interface DingTalkConfig {
|
||||||
@@ -14,6 +17,42 @@ interface DingTalkConfig {
|
|||||||
startEnable: boolean;
|
startEnable: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 IntegrationConfigPage: React.FC = () => {
|
const IntegrationConfigPage: React.FC = () => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -22,6 +61,18 @@ const IntegrationConfigPage: React.FC = () => {
|
|||||||
const [verified, setVerified] = useState<boolean | null>(null);
|
const [verified, setVerified] = useState<boolean | null>(null);
|
||||||
const [form] = Form.useForm<DingTalkConfig>();
|
const [form] = Form.useForm<DingTalkConfig>();
|
||||||
|
|
||||||
|
// ── 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 [teacherChecks, setTeacherChecks] = useState<Record<string, boolean>>({});
|
||||||
|
const [teacherRoles, setTeacherRoles] = useState<Record<string, number>>({});
|
||||||
|
const [roles, setRoles] = useState<Array<{ id: number; name: string }>>([]);
|
||||||
|
const [defaultTeacherRoleId, setDefaultTeacherRoleId] = useState<number | null>(null);
|
||||||
|
const [deptPickerTree, setDeptPickerTree] = useState<Array<{ title: string; value: number; children?: Array<{ title: string; value: number; children?: unknown[] }> }>>([]);
|
||||||
|
|
||||||
const fetchConfig = async () => {
|
const fetchConfig = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -41,6 +92,7 @@ const IntegrationConfigPage: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchConfig();
|
fetchConfig();
|
||||||
|
fetchRoles();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
@@ -77,13 +129,230 @@ const IntegrationConfigPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const fetchRoles = async () => {
|
||||||
<Card title="钉钉集成配置" extra={
|
try {
|
||||||
<Space>
|
const res = await api.get<RoleItem[]>('/rbac/roles');
|
||||||
{verified === true && <Tag icon={<CheckCircleOutlined />} color="success">已连接</Tag>}
|
const activeRoles = res.filter((r) => r.status !== 0);
|
||||||
{verified === false && <Tag icon={<CloseCircleOutlined />} color="error">未连接</Tag>}
|
setRoles(activeRoles);
|
||||||
|
const teacherRole = activeRoles.find((r) => r.name === '班主任');
|
||||||
|
setDefaultTeacherRoleId(teacherRole?.id || activeRoles[0]?.id || null);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadDeptTree = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get<OrgTreeResponse>('/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 {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFetchOrgTree = async () => {
|
||||||
|
setFetchingTree(true);
|
||||||
|
try {
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
if (syncRootDeptId) params.rootDeptId = String(syncRootDeptId);
|
||||||
|
const res = await api.get<OrgTreeWithUsersResponse>('/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<ImportUsersResponse>('/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: (
|
||||||
|
<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>
|
</Space>
|
||||||
}>
|
),
|
||||||
|
key: `user-${u.userid}`,
|
||||||
|
selectable: false,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const treeData = useMemo(() => buildTreeData(orgTree), [orgTree, teacherChecks, teacherRoles, defaultTeacherRoleId, roles]);
|
||||||
|
|
||||||
|
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',
|
||||||
|
label: '配置',
|
||||||
|
children: (
|
||||||
<Spin spinning={loading}>
|
<Spin spinning={loading}>
|
||||||
{config && (
|
{config && (
|
||||||
<Descriptions size="small" column={2} style={{ marginBottom: 24 }}>
|
<Descriptions size="small" column={2} style={{ marginBottom: 24 }}>
|
||||||
@@ -132,6 +401,19 @@ const IntegrationConfigPage: React.FC = () => {
|
|||||||
</Space>
|
</Space>
|
||||||
</Form>
|
</Form>
|
||||||
</Spin>
|
</Spin>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
...syncTabItems,
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title="钉钉集成配置" extra={
|
||||||
|
<Space>
|
||||||
|
{verified === true && <Tag icon={<CheckCircleOutlined />} color="success">已连接</Tag>}
|
||||||
|
{verified === false && <Tag icon={<CloseCircleOutlined />} color="error">未连接</Tag>}
|
||||||
|
</Space>
|
||||||
|
}>
|
||||||
|
<Tabs items={tabItems} />
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user