feat: rewrite sync drawer with checkable tree + class batch import

This commit is contained in:
2026-07-09 17:16:08 +08:00
parent 91035e9f2c
commit 1f63a53222

View File

@@ -1,11 +1,12 @@
import React, { useEffect, useState, useMemo, useCallback } from 'react'; import React, { useEffect, useState, useMemo, useCallback } 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, Modal, DatePicker, InputNumber, Table, Popconfirm, Tabs, Drawer, Tree, Select, TreeSelect, Modal, DatePicker, InputNumber, Table, Popconfirm,
Row, Col, List,
} from 'antd'; } from 'antd';
import { import {
SaveOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined, SaveOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined,
SyncOutlined, ReloadOutlined, PlusOutlined, SyncOutlined, PlusOutlined, BankOutlined, UserOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { DataNode } from 'antd/es/tree'; import type { DataNode } from 'antd/es/tree';
import api from '../../api'; import api from '../../api';
@@ -25,12 +26,6 @@ interface DingOrgTreeNodeExt {
users: Array<{ userid: string; name: string; mobile: string; deptIds: number[] }>; users: Array<{ userid: string; name: string; mobile: string; deptIds: number[] }>;
} }
interface RoleItem {
id: number;
name: string;
status: number;
}
interface OrgTreeNodeRaw { interface OrgTreeNodeRaw {
id: number; id: number;
name: string; name: string;
@@ -47,52 +42,21 @@ interface OrgTreeWithUsersResponse {
data: DingOrgTreeNodeExt[]; data: DingOrgTreeNodeExt[];
} }
interface ImportUsersResponse { interface ClassItem {
success: boolean; id: number;
teacherCount: number;
studentCount: number;
classCount: number;
skipped: number;
warnings: string[];
}
interface ClassMarkForm {
deptId: number;
name: string; name: string;
code: string; code: string;
classType: string; classType?: string;
startDate?: string; startDate?: string;
endDate?: string; endDate?: string;
maxStudents?: number; maxStudents?: number;
notes?: string; notes?: string;
} }
const UserTreeNode: React.FC<{ interface ImportResult {
u: { userid: string; name: string; mobile: string }; imported: number;
isTeacher: boolean; skipped: number;
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 }) => (
<Space size="small">
<Checkbox checked={isTeacher} onChange={onToggle}></Checkbox>
<span style={{ fontWeight: 500 }}>{u.name}</span>
{u.mobile && <Tag style={{ marginLeft: 4 }}>{u.mobile}</Tag>}
{isTeacher && (
<Select
size="small"
style={{ width: 100, marginLeft: 8 }}
value={roleId || defaultRoleId}
onChange={onRoleChange}
options={roles.map((r) => ({ label: r.name, value: r.id }))}
placeholder="选择角色"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
/>
)}
</Space>
));
const IntegrationConfigPage: React.FC = () => { const IntegrationConfigPage: React.FC = () => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -108,15 +72,12 @@ const IntegrationConfigPage: React.FC = () => {
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [fetchingTree, setFetchingTree] = useState(false); const [fetchingTree, setFetchingTree] = useState(false);
const [importing, setImporting] = 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 [deptPickerTree, setDeptPickerTree] = useState<Array<{ title: string; value: number; children?: Array<{ title: string; value: number; children?: unknown[] }> }>>([]);
const [classMarks, setClassMarks] = useState<Record<number, ClassMarkForm>>({}); const [checkedKeys, setCheckedKeys] = useState<React.Key[]>([]);
const [selectedClassId, setSelectedClassId] = useState<number | null>(null);
const [classes, setClasses] = useState<ClassItem[]>([]);
const [classForm] = Form.useForm();
const [classModalOpen, setClassModalOpen] = useState(false); const [classModalOpen, setClassModalOpen] = useState(false);
const [classModalDept, setClassModalDept] = useState<{ id: number; name: string } | null>(null);
const [classForm] = Form.useForm<ClassMarkForm>();
// ── DingTalk Bindings Tab ── // ── DingTalk Bindings Tab ──
const [bindings, setBindings] = useState<Array<{ id: number; userId: number; dingUserId: string; dingName: string | null; dingMobile: string | null; user: { id: number; username: string; name: string } }>>([]); const [bindings, setBindings] = useState<Array<{ id: number; userId: number; dingUserId: string; dingName: string | null; dingMobile: string | null; user: { id: number; username: string; name: string } }>>([]);
@@ -143,7 +104,7 @@ const IntegrationConfigPage: React.FC = () => {
}; };
useEffect(() => { useEffect(() => {
void Promise.all([fetchConfig(), fetchRoles()]); void fetchConfig();
}, []); }, []);
const handleSave = async () => { const handleSave = async () => {
@@ -180,18 +141,6 @@ const IntegrationConfigPage: React.FC = () => {
} }
}; };
const fetchRoles = async () => {
try {
const res = await api.get<RoleItem[]>('/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 () => { const fetchBindings = useCallback(async () => {
try { try {
const res = await api.get<typeof bindings>('/rbac/user-ding-mappings'); const res = await api.get<typeof bindings>('/rbac/user-ding-mappings');
@@ -258,6 +207,17 @@ const IntegrationConfigPage: React.FC = () => {
} }
}; };
const fetchClasses = async () => {
try {
const res = await api.get<ClassItem[] | { data: ClassItem[] }>('/classes');
if (Array.isArray(res)) {
setClasses(res);
} else {
setClasses(res.data ?? []);
}
} catch { /* ignore */ }
};
const handleFetchOrgTree = async () => { const handleFetchOrgTree = async () => {
setFetchingTree(true); setFetchingTree(true);
try { try {
@@ -266,9 +226,10 @@ const IntegrationConfigPage: React.FC = () => {
const res = await api.get<OrgTreeWithUsersResponse>('/sync/dingtalk/org-tree-with-users', { params }); const res = await api.get<OrgTreeWithUsersResponse>('/sync/dingtalk/org-tree-with-users', { params });
if (res.success && res.data) { if (res.success && res.data) {
setOrgTree(res.data); setOrgTree(res.data);
setTeacherChecks({}); setCheckedKeys([]);
setTeacherRoles({}); setSelectedClassId(null);
setDrawerOpen(true); setDrawerOpen(true);
fetchClasses();
} else { } else {
message.error('获取组织架构失败'); message.error('获取组织架构失败');
} }
@@ -280,89 +241,41 @@ const IntegrationConfigPage: React.FC = () => {
} }
}; };
const openClassModal = (deptId: number, deptName: string) => { const buildTreeData = useCallback((nodes: DingOrgTreeNodeExt[]): DataNode[] => {
const existing = classMarks[deptId]; return nodes.map((node) => ({
if (existing) { title: (
classForm.setFieldsValue(existing); <Space size="small">
} else { <BankOutlined />
classForm.setFieldsValue({ <span>{node.name}</span>
deptId, <Tag>{node.users.length}</Tag>
name: deptName, </Space>
code: '', ),
classType: 'culture', key: `dept-${node.id}`,
}); children: [
} ...buildTreeData(node.children),
setClassModalDept({ id: deptId, name: deptName }); ...node.users.map((u) => ({
setClassModalOpen(true); title: <Space><UserOutlined /><span>{u.name}</span><Tag>{u.mobile}</Tag></Space>,
}; key: `user-${u.userid}`,
})),
const handleClassModalOk = async () => { ],
const values = await classForm.validateFields();
setClassMarks((prev) => ({
...prev,
[values.deptId]: values,
})); }));
setClassModalOpen(false); }, []);
setClassModalDept(null);
};
const handleClassModalCancel = () => { const treeData = useMemo(() => buildTreeData(orgTree), [orgTree, buildTreeData]);
setClassModalOpen(false);
setClassModalDept(null); 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); setImporting(true);
try { try {
const allUsers: Array<{ const res = await api.post<ImportResult>(`/classes/${selectedClassId}/students/import`, { dingUserIds: userIds });
dingUserId: string; message.success(`导入 ${res.imported} 人,跳过 ${res.skipped}`);
name: string; setCheckedKeys([]);
mobile: string; setSelectedClassId(null);
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<string, unknown> = {
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);
} catch (e: unknown) { } catch (e: unknown) {
const err = e as { message?: string }; const err = e as { message?: string };
message.error(err?.message || '导入失败'); message.error(err?.message || '导入失败');
@@ -371,73 +284,31 @@ const IntegrationConfigPage: React.FC = () => {
} }
}; };
const buildTreeData = useCallback((nodes: DingOrgTreeNodeExt[]): DataNode[] => { const handleCreateClass = async () => {
return nodes.map((node) => ({ try {
title: ( const values = await classForm.validateFields();
<Space size="small"> const userIds = checkedKeys
<span>{node.name}</span> .filter((k) => String(k).startsWith('user-'))
{node.children.length === 0 && node.users.length > 0 && ( .map((k) => String(k).replace('user-', ''));
<> const deptKeys = checkedKeys.filter((k) => String(k).startsWith('dept-'));
{classMarks[node.id] ? ( const deptId = deptKeys.length > 0
<Tag ? Number(String(deptKeys[0]).replace('dept-', ''))
color="blue" : undefined;
style={{ cursor: 'pointer' }}
onClick={() => openClassModal(node.id, node.name)}
>
: {classMarks[node.id].name} []
</Tag>
) : (
<Button
size="small"
type="link"
icon={<span>🏫</span>}
onClick={() => openClassModal(node.id, node.name)}
>
</Button>
)}
</>
)}
</Space>
),
key: `dept-${node.id}`,
children: [
...buildTreeData(node.children),
...node.users.map((u) => ({
title: (
<UserTreeNode
key={u.userid}
u={{ userid: u.userid, name: u.name, mobile: u.mobile }}
isTeacher={!!teacherChecks[u.userid]}
onToggle={() => {
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 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 const syncTabItems = config
? [ ? [
@@ -448,7 +319,7 @@ const IntegrationConfigPage: React.FC = () => {
<div> <div>
<Alert <Alert
type="info" type="info"
message="从钉钉获取组织架构,勾选老师并分配角色,其余用户将作为学生导入。" message="从钉钉获取组织架构,勾选用户后批量导入到班级。"
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
showIcon showIcon
/> />
@@ -472,78 +343,103 @@ const IntegrationConfigPage: React.FC = () => {
</Button> </Button>
</Space> </Space>
<Modal
title={classMarks[classModalDept?.id ?? -1] ? '修改班级信息' : '标记为班级'}
open={classModalOpen}
onOk={handleClassModalOk}
onCancel={handleClassModalCancel}
destroyOnClose
>
<Form form={classForm} layout="vertical">
<Form.Item name="deptId" hidden><Input /></Form.Item>
<Form.Item name="name" label="班级名称" rules={[{ required: true, message: '请输入班级名称' }]}>
<Input />
</Form.Item>
<Form.Item name="code" label="班级编码" rules={[{ required: true, message: '请输入班级编码' }]}>
<Input placeholder="如 CS2024-01" />
</Form.Item>
<Form.Item name="classType" label="班型" rules={[{ required: true }]}>
<Select
options={[
{ value: 'culture', label: '文化课' },
{ value: 'professional', label: '专业课' },
{ value: 'bootcamp', label: '集训营' },
{ value: 'sprint', label: '冲刺班' },
]}
/>
</Form.Item>
<Form.Item name="startDate" label="开班日期">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="endDate" label="结束日期">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="maxStudents" label="最大人数">
<InputNumber min={0} style={{ width: '100%' }} placeholder="0 表示不限制" />
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
{drawerOpen && ( {drawerOpen && (
<Drawer <Drawer
title="钉钉组织架构 — 勾选老师" title="钉钉组织架构 — 批量导入"
open={drawerOpen} open={drawerOpen}
onClose={() => { setDrawerOpen(false); setClassMarks({}); }} onClose={() => { setDrawerOpen(false); }}
width={520} width={900}
footer={ footer={
<Space> <Space>
<Button onClick={() => { setDrawerOpen(false); setClassMarks({}); }}></Button> <Button onClick={() => { setDrawerOpen(false); }}></Button>
<Button <Button
type="primary" type="primary"
icon={<ReloadOutlined />}
loading={importing} loading={importing}
disabled={!defaultTeacherRoleId} disabled={checkedKeys.filter((k) => String(k).startsWith('user-')).length === 0 || selectedClassId === null}
title={!defaultTeacherRoleId ? '角色列表未加载,无法导入' : undefined} onClick={handleJoinClass}
onClick={handleImportUsers} ></Button>
> <Button
disabled={checkedKeys.filter((k) => String(k).startsWith('user-')).length === 0}
</Button> onClick={() => setClassModalOpen(true)}
></Button>
</Space> </Space>
} }
> >
{treeData.length > 0 ? ( <Row gutter={16}>
<Tree <Col span={14}>
treeData={treeData} <div style={{ maxHeight: '60vh', overflow: 'auto' }}>
defaultExpandAll <Tree
blockNode checkable
showLine={{ showLeafIcon: false }} treeData={treeData}
/> defaultExpandAll
) : ( showLine={{ showLeafIcon: false }}
<Spin /> checkedKeys={checkedKeys}
)} onCheck={(checked) => setCheckedKeys(checked as React.Key[])}
/>
</div>
</Col>
<Col span={10}>
<Card title="班级列表" size="small"
extra={<Button size="small" onClick={() => setClassModalOpen(true)}>+ </Button>}>
<List
dataSource={classes}
renderItem={(cls: ClassItem) => (
<List.Item
onClick={() => setSelectedClassId(cls.id)}
style={{
cursor: 'pointer',
background: selectedClassId === cls.id ? '#e6f4ff' : undefined,
borderRadius: 4,
padding: '8px 12px',
}}
>
<List.Item.Meta title={cls.name} description={`${cls.code} ${cls.classType || ''}`} />
</List.Item>
)}
/>
</Card>
</Col>
</Row>
{ /* Create class Modal */ }
<Modal
title="创建班级"
open={classModalOpen}
onOk={handleCreateClass}
onCancel={() => { setClassModalOpen(false); classForm.resetFields(); }}
confirmLoading={importing}
destroyOnClose
>
<Form form={classForm} layout="vertical">
<Form.Item name="name" label="班级名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="code" label="班级编码" rules={[{ required: true }]}>
<Input placeholder="如 CS2024-01" />
</Form.Item>
<Form.Item name="classType" label="班型" rules={[{ required: true }]}>
<Select options={[
{ value: 'culture', label: '文化课' },
{ value: 'professional', label: '专业课' },
{ value: 'bootcamp', label: '集训营' },
{ value: 'sprint', label: '冲刺班' },
]} />
</Form.Item>
<Form.Item name="startDate" label="开班日期">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="endDate" label="结束日期">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="maxStudents" label="最大人数">
<InputNumber min={0} style={{ width: '100%' }} placeholder="0=不限制" />
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
</Drawer> </Drawer>
)} )}
</div> </div>