diff --git a/apps/admin/src/pages/IntegrationConfig/index.tsx b/apps/admin/src/pages/IntegrationConfig/index.tsx index 36ee791..c8d3533 100644 --- a/apps/admin/src/pages/IntegrationConfig/index.tsx +++ b/apps/admin/src/pages/IntegrationConfig/index.tsx @@ -1,11 +1,12 @@ import React, { useEffect, useState, useMemo, useCallback } from 'react'; import { Card, Form, Input, Button, Space, message, Spin, Switch, Alert, Descriptions, Tag, - Tabs, Drawer, Tree, Checkbox, Select, TreeSelect, Modal, DatePicker, InputNumber, Table, Popconfirm, + Tabs, Drawer, Tree, Select, TreeSelect, Modal, DatePicker, InputNumber, Table, Popconfirm, + Row, Col, List, } from 'antd'; import { SaveOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined, - SyncOutlined, ReloadOutlined, PlusOutlined, + SyncOutlined, PlusOutlined, BankOutlined, UserOutlined, } from '@ant-design/icons'; import type { DataNode } from 'antd/es/tree'; import api from '../../api'; @@ -25,12 +26,6 @@ interface DingOrgTreeNodeExt { users: Array<{ userid: string; name: string; mobile: string; deptIds: number[] }>; } -interface RoleItem { - id: number; - name: string; - status: number; -} - interface OrgTreeNodeRaw { id: number; name: string; @@ -47,52 +42,21 @@ interface OrgTreeWithUsersResponse { data: DingOrgTreeNodeExt[]; } -interface ImportUsersResponse { - success: boolean; - teacherCount: number; - studentCount: number; - classCount: number; - skipped: number; - warnings: string[]; -} - -interface ClassMarkForm { - deptId: number; +interface ClassItem { + id: number; name: string; code: string; - classType: string; + classType?: string; startDate?: string; endDate?: string; maxStudents?: number; notes?: string; } -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 && ( - ({ label: r.name, value: r.id }))} - placeholder="选择角色" - onClick={(e: React.MouseEvent) => e.stopPropagation()} - /> - )} - -)); +interface ImportResult { + imported: number; + skipped: number; +} const IntegrationConfigPage: React.FC = () => { const [loading, setLoading] = useState(false); @@ -108,15 +72,12 @@ const IntegrationConfigPage: React.FC = () => { 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 [classMarks, setClassMarks] = useState>({}); + const [checkedKeys, setCheckedKeys] = useState([]); + const [selectedClassId, setSelectedClassId] = useState(null); + const [classes, setClasses] = useState([]); + const [classForm] = Form.useForm(); const [classModalOpen, setClassModalOpen] = useState(false); - const [classModalDept, setClassModalDept] = useState<{ id: number; name: string } | null>(null); - const [classForm] = Form.useForm(); // ── DingTalk Bindings Tab ── const [bindings, setBindings] = useState>([]); @@ -143,7 +104,7 @@ const IntegrationConfigPage: React.FC = () => { }; useEffect(() => { - void Promise.all([fetchConfig(), fetchRoles()]); + void fetchConfig(); }, []); const handleSave = async () => { @@ -180,18 +141,6 @@ 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 fetchBindings = useCallback(async () => { try { const res = await api.get('/rbac/user-ding-mappings'); @@ -258,6 +207,17 @@ const IntegrationConfigPage: React.FC = () => { } }; + 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 { @@ -266,9 +226,10 @@ const IntegrationConfigPage: React.FC = () => { const res = await api.get('/sync/dingtalk/org-tree-with-users', { params }); if (res.success && res.data) { setOrgTree(res.data); - setTeacherChecks({}); - setTeacherRoles({}); + setCheckedKeys([]); + setSelectedClassId(null); setDrawerOpen(true); + fetchClasses(); } else { message.error('获取组织架构失败'); } @@ -280,89 +241,41 @@ const IntegrationConfigPage: React.FC = () => { } }; - const openClassModal = (deptId: number, deptName: string) => { - const existing = classMarks[deptId]; - if (existing) { - classForm.setFieldsValue(existing); - } else { - classForm.setFieldsValue({ - deptId, - name: deptName, - code: '', - classType: 'culture', - }); - } - setClassModalDept({ id: deptId, name: deptName }); - setClassModalOpen(true); - }; - - const handleClassModalOk = async () => { - const values = await classForm.validateFields(); - setClassMarks((prev) => ({ - ...prev, - [values.deptId]: values, + const buildTreeData = useCallback((nodes: DingOrgTreeNodeExt[]): DataNode[] => { + return nodes.map((node) => ({ + title: ( + + + {node.name} + {node.users.length}人 + + ), + key: `dept-${node.id}`, + children: [ + ...buildTreeData(node.children), + ...node.users.map((u) => ({ + title: {u.name}{u.mobile}, + key: `user-${u.userid}`, + })), + ], })); - setClassModalOpen(false); - setClassModalDept(null); - }; + }, []); - const handleClassModalCancel = () => { - setClassModalOpen(false); - setClassModalDept(null); - }; + const treeData = useMemo(() => buildTreeData(orgTree), [orgTree, buildTreeData]); + + const handleJoinClass = async () => { + if (selectedClassId === null) return message.warning('请先选择一个班级'); + const userIds = checkedKeys + .filter((k) => String(k).startsWith('user-')) + .map((k) => String(k).replace('user-', '')); + if (userIds.length === 0) return message.warning('请勾选要导入的用户'); - const handleImportUsers = async () => { setImporting(true); try { - const allUsers: Array<{ - dingUserId: string; - name: string; - mobile: string; - deptIds: number[]; - }> = []; - - const flatten = (nodes: DingOrgTreeNodeExt[]) => { - for (const node of nodes) { - allUsers.push( - ...node.users.map((u) => ({ - dingUserId: u.userid, - name: u.name, - mobile: u.mobile, - deptIds: u.deptIds || [], - })), - ); - flatten(node.children); - } - }; - flatten(orgTree); - - const usersPayload = allUsers.map((u) => { - const item: Record = { - dingUserId: u.dingUserId, - name: u.name, - mobile: u.mobile, - roleId: teacherChecks[u.dingUserId] - ? (teacherRoles[u.dingUserId] || defaultTeacherRoleId) - : null, - dingDeptIds: u.deptIds, - }; - return item; - }); - const payload = { - classes: Object.values(classMarks), - users: usersPayload, - }; - - const data: any = await api.post('/sync/dingtalk/import-users', payload); - message.success( - `导入完成:${data.teacherCount} 位老师,${data.studentCount} 位学生` + - (data.classCount ? `,${data.classCount} 个班级` : '') + - (data.skipped > 0 ? `,${data.skipped} 已跳过` : ''), - ); - if (data.warnings?.length > 0) { - message.warning(data.warnings.join(';'), 8); - } - setDrawerOpen(false); + const res = await api.post(`/classes/${selectedClassId}/students/import`, { dingUserIds: userIds }); + message.success(`导入 ${res.imported} 人,跳过 ${res.skipped} 人`); + setCheckedKeys([]); + setSelectedClassId(null); } catch (e: unknown) { const err = e as { message?: string }; message.error(err?.message || '导入失败'); @@ -371,73 +284,31 @@ const IntegrationConfigPage: React.FC = () => { } }; - const buildTreeData = useCallback((nodes: DingOrgTreeNodeExt[]): DataNode[] => { - return nodes.map((node) => ({ - title: ( - - {node.name} - {node.children.length === 0 && node.users.length > 0 && ( - <> - {classMarks[node.id] ? ( - openClassModal(node.id, node.name)} - > - 班级: {classMarks[node.id].name} [已标记] - - ) : ( - 🏫} - onClick={() => openClassModal(node.id, 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, - })), - ], - })); - }, [classMarks, teacherChecks, teacherRoles, defaultTeacherRoleId, roles]); + const handleCreateClass = async () => { + try { + const values = await classForm.validateFields(); + const userIds = checkedKeys + .filter((k) => String(k).startsWith('user-')) + .map((k) => String(k).replace('user-', '')); + const deptKeys = checkedKeys.filter((k) => String(k).startsWith('dept-')); + const deptId = deptKeys.length > 0 + ? Number(String(deptKeys[0]).replace('dept-', '')) + : undefined; - const treeData = useMemo(() => buildTreeData(orgTree), [orgTree, buildTreeData]); + setImporting(true); + await api.post('/classes', { ...values, dingUserIds: userIds, departmentId: deptId }); + 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 syncTabItems = config ? [ @@ -448,7 +319,7 @@ const IntegrationConfigPage: React.FC = () => { @@ -472,78 +343,103 @@ const IntegrationConfigPage: React.FC = () => { 获取组织架构 - - - - - - - - - - - - - - - - - - - - - - - - - - {drawerOpen && ( { setDrawerOpen(false); setClassMarks({}); }} - width={520} + onClose={() => { setDrawerOpen(false); }} + width={900} footer={ - { setDrawerOpen(false); setClassMarks({}); }}>取消 + { setDrawerOpen(false); }}>取消 } loading={importing} - disabled={!defaultTeacherRoleId} - title={!defaultTeacherRoleId ? '角色列表未加载,无法导入' : undefined} - onClick={handleImportUsers} - > - 导入 - + disabled={checkedKeys.filter((k) => String(k).startsWith('user-')).length === 0 || selectedClassId === null} + onClick={handleJoinClass} + >加入选中的班级 + String(k).startsWith('user-')).length === 0} + onClick={() => setClassModalOpen(true)} + >创建班级 } > - {treeData.length > 0 ? ( - - ) : ( - - )} + + + + setCheckedKeys(checked as React.Key[])} + /> + + + + setClassModalOpen(true)}>+ 创建班级}> + ( + setSelectedClassId(cls.id)} + style={{ + cursor: 'pointer', + background: selectedClassId === cls.id ? '#e6f4ff' : undefined, + borderRadius: 4, + padding: '8px 12px', + }} + > + + + )} + /> + + + + + { /* Create class Modal */ } + { setClassModalOpen(false); classForm.resetFields(); }} + confirmLoading={importing} + destroyOnClose + > + + + + + + + + + + + + + + + + + + + + + + + + )}