import React, { useEffect, useState, useMemo, useCallback } from 'react'; import { Card, Form, Input, Button, Space, Spin, Alert, Descriptions, Tag, Divider, Drawer, Tree, Select, TreeSelect, Modal, DatePicker, Row, Col, List, } from 'antd'; import { SaveOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined, SyncOutlined, BankOutlined, UserOutlined, StopOutlined, } from '@ant-design/icons'; import type { DataNode } from 'antd/es/tree'; import type { TreeSelectProps } from 'antd/es/tree-select'; import api from '../../api'; import { message } from '../../ui/app-message'; import { usePermission } from '../../hooks/usePermission'; import PermissionButton from '../../components/PermissionButton'; import { buildDingTalkConfigPayload, isAppSecretRequired, type DingTalkConfigFormValues, } from './integration-config-form'; interface DingTalkConfig { agentId: string; corpId: string; } interface DingOrgTreeNodeExt { id: number; name: string; parentId: number; children: DingOrgTreeNodeExt[]; users: Array<{ userid: string; name: string; mobile: string; deptIds: number[] }>; } interface OrgTreeNodeRaw { id: number; name: string; children?: OrgTreeNodeRaw[]; } interface OrgTreeResponse { success: boolean; data: OrgTreeNodeRaw[]; } interface OrgTreeWithUsersResponse { success: boolean; data: DingOrgTreeNodeExt[]; } type DeptPickerTreeNode = NonNullable['treeData']>[number]; interface ClassItem { id: number; name: string; code: string; classType?: string; startDate?: string; endDate?: string; notes?: string; } interface ImportResult { imported: number; skipped: number; conflicts: number; } interface DingTalkAttendanceGroup { group_id: number; group_name: string; type: string; member_count: number; } interface AttendanceGroupResponse { success: boolean; data: DingTalkAttendanceGroup[]; } interface DeleteAttendanceGroupsResponse { success: boolean; data: { total: number; deleted: Array<{ groupId: number; groupName: string }>; failed: Array<{ groupId: number; groupName: string; error: string }>; }; } const IntegrationConfigPage: React.FC = () => { const { hasPermission, hasAllPermissions } = usePermission(); const canCreateClass = hasPermission('class:create'); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [testing, setTesting] = useState(false); const [config, setConfig] = useState(null); const [verified, setVerified] = useState(null); const [form] = Form.useForm(); // ── Manual organization sync ── 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 [deptPickerTree, setDeptPickerTree] = useState([]); const [checkedKeys, setCheckedKeys] = useState([]); const [selectedClassId, setSelectedClassId] = useState(null); const [classes, setClasses] = useState([]); const [classForm] = Form.useForm(); const [classModalOpen, setClassModalOpen] = useState(false); const [attendanceGroups, setAttendanceGroups] = useState([]); const [deleteGroupsOpen, setDeleteGroupsOpen] = useState(false); const [loadingGroups, setLoadingGroups] = useState(false); const [deletingGroups, setDeletingGroups] = useState(false); const fetchConfig = async () => { setLoading(true); try { const res = await api.get<{ success: boolean; data: Array<{ type: string; verify: boolean; config: DingTalkConfig }>; }>('/integration/config'); const dt = res.data?.find((c) => c.type === 'DINGTALK'); if (dt) { setConfig(dt.config); setVerified(dt.verify); form.setFieldsValue(dt.config); } } catch { // not configured } finally { setLoading(false); } }; useEffect(() => { void fetchConfig(); }, []); const handleSave = async () => { const values = await form.validateFields(); const payload = buildDingTalkConfigPayload(values); setSaving(true); try { await api.post('/integration/config', { type: 'DINGTALK', config: payload }); message.success('配置已保存'); await fetchConfig(); } catch (e: unknown) { const err = e as { message?: string }; message.error(err?.message || '保存失败'); } finally { setSaving(false); } }; const handleTest = async () => { const values = await form.validateFields(); const payload = buildDingTalkConfigPayload(values); setTesting(true); try { const res = await api.post<{ success: boolean; message: string }>( '/integration/config/test', { type: 'DINGTALK', config: payload, }, ); setVerified(res.success); message.success(res.message); } catch (e: unknown) { const err = e as { message?: string }; setVerified(false); message.error(err?.message || '连接失败'); } finally { setTesting(false); } }; const loadDeptTree = async () => { try { const res = await api.get('/sync/dingtalk/org-tree'); if (res.success && res.data) { const toTreeNode = (nodes: OrgTreeNodeRaw[]): DeptPickerTreeNode[] => nodes.map((n) => ({ title: n.name, value: n.id, children: n.children ? toTreeNode(n.children) : undefined, })); setDeptPickerTree(toTreeNode(res.data)); } } catch { message.error('获取部门架构失败'); } }; const fetchClasses = async () => { try { const res = await api.get('/classes'); if (Array.isArray(res)) { setClasses(res); } else { setClasses(res.data ?? []); } } catch { /* ignore */ } }; 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); setCheckedKeys([]); setSelectedClassId(null); setDrawerOpen(true); fetchClasses(); } else { message.error('获取组织架构失败'); } } catch (e: unknown) { const err = e as { message?: string }; message.error(err?.message || '获取组织架构失败'); } finally { setFetchingTree(false); } }; const buildTreeData = useCallback((nodes: DingOrgTreeNodeExt[]): DataNode[] => { return nodes.map((node) => { const users = node.users ?? []; const children: DataNode[] = [ ...buildTreeData(node.children ?? []), ...users.map((u) => ({ title: ( {u.name} {u.mobile ? {u.mobile} : null} ), key: `user-${u.userid}`, isLeaf: true, })), ]; return { title: ( {node.name} {users.length}人 ), key: `dept-${node.id}`, // Only attach children when there are any, so empty/leaf departments // don't render a phantom expand arrow that opens to nothing. ...(children.length > 0 ? { children } : {}), }; }); }, []); const treeData = useMemo(() => buildTreeData(orgTree), [orgTree, buildTreeData]); const extractCheckedUsers = useCallback((): Array<{ dingUserId: string; name: string; mobile?: string; }> => { const result: Array<{ dingUserId: string; name: string; mobile?: string }> = []; const walk = (nodes: DingOrgTreeNodeExt[]) => { for (const node of nodes) { for (const u of node.users ?? []) { if (checkedKeys.includes(`user-${u.userid}`)) { result.push({ dingUserId: u.userid, name: u.name, mobile: u.mobile || undefined }); } } walk(node.children ?? []); } }; walk(orgTree); return result; }, [checkedKeys, orgTree]); const handleJoinClass = async () => { if (selectedClassId === null) return message.warning('请先选择一个班级'); const users = extractCheckedUsers(); if (users.length === 0) return message.warning('请勾选要导入的用户'); setImporting(true); try { const res = await api.post(`/classes/${selectedClassId}/students/import`, { users, }); if (res.conflicts > 0) { message.warning( `导入 ${res.imported} 人,跳过 ${res.skipped} 人,${res.conflicts} 人需人工绑定`, ); } else { message.success(`导入 ${res.imported} 人,跳过 ${res.skipped} 人`); } setCheckedKeys([]); setSelectedClassId(null); } catch (e: unknown) { const err = e as { message?: string }; message.error(err?.message || '导入失败'); } finally { setImporting(false); } }; const handleCreateClass = async () => { try { const values = await classForm.validateFields(); const users = extractCheckedUsers(); await api.post('/classes', { ...values, users }); message.success('班级创建成功'); setClassModalOpen(false); classForm.resetFields(); setCheckedKeys([]); fetchClasses(); } catch (e: unknown) { const err = e as { message?: string }; message.error(err?.message || '创建失败'); } finally { setImporting(false); } }; const openDeleteAllGroups = async () => { setLoadingGroups(true); try { const response = await api.get('/sync/dingtalk/attendance-groups'); setAttendanceGroups(response.data); setDeleteGroupsOpen(true); } catch (error: unknown) { message.error(error instanceof Error ? error.message : '获取钉钉考勤组失败'); } finally { setLoadingGroups(false); } }; const deleteAllGroups = async () => { setDeletingGroups(true); try { const response = await api.post( '/sync/dingtalk/attendance-groups/delete-all', ); setDeleteGroupsOpen(false); setAttendanceGroups([]); if (response.data.failed.length > 0) { message.warning( `已清空 ${response.data.deleted.length} 个,失败 ${response.data.failed.length} 个`, ); } else { message.success(`已清空钉钉全部 ${response.data.deleted.length} 个考勤组`); } } catch (error: unknown) { message.error(error instanceof Error ? error.message : '清空钉钉考勤组失败'); } finally { setDeletingGroups(false); } }; const syncPanel = config && hasAllPermissions('sync:read', 'class:view', 'class:edit') ? (
setSyncRootDeptId(v)} placeholder="选择起始部门(不选=全部)" allowClear treeDefaultExpandAll style={{ minWidth: 240 }} onDropdownVisibleChange={(open) => { if (open) loadDeptTree(); }} /> } loading={loadingGroups} onClick={openDeleteAllGroups} > 清空钉钉全部考勤组 {drawerOpen && ( { setDrawerOpen(false); }} width="min(900px, 100vw)" footer={ {canCreateClass ? ( ) : null} } >
setCheckedKeys(checked as React.Key[])} />
setClassModalOpen(true)}> + 创建班级 ) : null } > ( setSelectedClassId(cls.id)} style={{ cursor: 'pointer', background: selectedClassId === cls.id ? '#e6f4ff' : undefined, borderRadius: 4, padding: '8px 12px', }} > )} />
{/* Create class Modal */} {canCreateClass ? ( { setClassModalOpen(false); classForm.resetFields(); }} confirmLoading={importing} destroyOnClose >
} loading={saving} onClick={handleSave} > 保存配置
{syncPanel && ( <> 组织用户导入 {syncPanel} )} ); }; export default IntegrationConfigPage;