feat: rewrite sync drawer with checkable tree + class batch import
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user