fix: disable import button when roles not loaded (null defaultTeacherRoleId guard)
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import {
|
||||
Card, Form, Input, Button, Space, message, Spin, Switch, Alert, Descriptions, Tag,
|
||||
Tabs, Drawer, Tree, Checkbox, Select, TreeSelect,
|
||||
} from 'antd';
|
||||
import {
|
||||
SaveOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined,
|
||||
SyncOutlined, ReloadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { DataNode } from 'antd/es/tree';
|
||||
import api from '../../api';
|
||||
|
||||
interface DingTalkConfig {
|
||||
@@ -14,11 +17,68 @@ interface DingTalkConfig {
|
||||
startEnable: boolean;
|
||||
}
|
||||
|
||||
/** Utility: recursively flatten org tree nodes to extract user ids (for import guard) */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const getAllUsers = (nodes: any[]): Array<{ userid: string }> => {
|
||||
return nodes.flatMap((n) => [...(n.users ?? []), ...getAllUsers(n.children ?? [])]);
|
||||
};
|
||||
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 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 }) => (
|
||||
<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 [loading, setLoading] = useState(false);
|
||||
@@ -28,6 +88,18 @@ const IntegrationConfigPage: React.FC = () => {
|
||||
const [verified, setVerified] = useState<boolean | null>(null);
|
||||
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 () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -46,7 +118,7 @@ const IntegrationConfigPage: React.FC = () => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
void Promise.all([fetchConfig(), fetchRoles()]);
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -83,6 +155,272 @@ 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 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 {
|
||||
message.error('获取部门架构失败');
|
||||
}
|
||||
};
|
||||
|
||||
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: (
|
||||
<UserTreeNode
|
||||
key={u.userid}
|
||||
u={u}
|
||||
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,
|
||||
})),
|
||||
],
|
||||
}));
|
||||
};
|
||||
|
||||
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}
|
||||
disabled={!defaultTeacherRoleId}
|
||||
title={!defaultTeacherRoleId ? '角色列表未加载,无法导入' : undefined}
|
||||
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}>
|
||||
{config && (
|
||||
<Descriptions size="small" column={2} style={{ marginBottom: 24 }}>
|
||||
<Descriptions.Item label="CorpId">{config.corpId || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="AppKey">{config.agentId || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="启用同步">
|
||||
<Tag color={config.startEnable ? 'green' : 'default'}>
|
||||
{config.startEnable ? '已启用' : '未启用'}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
message="配置钉钉应用凭证后,可使用组织架构同步、考勤导入和排班同步功能。"
|
||||
style={{ marginBottom: 24 }}
|
||||
showIcon
|
||||
/>
|
||||
|
||||
<Form form={form} layout="vertical" style={{ maxWidth: 480 }}>
|
||||
<Form.Item name="corpId" label="CorpId(企业ID)" rules={[{ required: true, message: '请输入 CorpId' }]}>
|
||||
<Input placeholder="dingxxxxxxxx" />
|
||||
</Form.Item>
|
||||
<Form.Item name="agentId" label="AppKey(应用凭证)" rules={[{ required: true, message: '请输入 AppKey' }]}>
|
||||
<Input placeholder="从钉钉开放平台获取" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="appSecret"
|
||||
label="AppSecret(应用密钥)"
|
||||
rules={[{ required: true, message: '请输入 AppSecret' }]}
|
||||
extra="保存后仅返回脱敏信息,重新编辑时需再次输入完整密钥"
|
||||
>
|
||||
<Input.Password placeholder="从钉钉开放平台获取" />
|
||||
</Form.Item>
|
||||
<Form.Item name="startEnable" label="启用同步" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={handleSave}>
|
||||
保存配置
|
||||
</Button>
|
||||
<Button icon={<ApiOutlined />} loading={testing} onClick={handleTest}>
|
||||
测试连接
|
||||
</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
</Spin>
|
||||
),
|
||||
},
|
||||
...syncTabItems,
|
||||
];
|
||||
|
||||
return (
|
||||
<Card title="钉钉集成配置" extra={
|
||||
<Space>
|
||||
@@ -90,54 +428,7 @@ const IntegrationConfigPage: React.FC = () => {
|
||||
{verified === false && <Tag icon={<CloseCircleOutlined />} color="error">未连接</Tag>}
|
||||
</Space>
|
||||
}>
|
||||
<Spin spinning={loading}>
|
||||
{config && (
|
||||
<Descriptions size="small" column={2} style={{ marginBottom: 24 }}>
|
||||
<Descriptions.Item label="CorpId">{config.corpId || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="AppKey">{config.agentId || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="启用同步">
|
||||
<Tag color={config.startEnable ? 'green' : 'default'}>
|
||||
{config.startEnable ? '已启用' : '未启用'}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
message="配置钉钉应用凭证后,可使用组织架构同步、考勤导入和排班同步功能。"
|
||||
style={{ marginBottom: 24 }}
|
||||
showIcon
|
||||
/>
|
||||
|
||||
<Form form={form} layout="vertical" style={{ maxWidth: 480 }}>
|
||||
<Form.Item name="corpId" label="CorpId(企业ID)" rules={[{ required: true, message: '请输入 CorpId' }]}>
|
||||
<Input placeholder="dingxxxxxxxx" />
|
||||
</Form.Item>
|
||||
<Form.Item name="agentId" label="AppKey(应用凭证)" rules={[{ required: true, message: '请输入 AppKey' }]}>
|
||||
<Input placeholder="从钉钉开放平台获取" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="appSecret"
|
||||
label="AppSecret(应用密钥)"
|
||||
rules={[{ required: true, message: '请输入 AppSecret' }]}
|
||||
extra="保存后仅返回脱敏信息,重新编辑时需再次输入完整密钥"
|
||||
>
|
||||
<Input.Password placeholder="从钉钉开放平台获取" />
|
||||
</Form.Item>
|
||||
<Form.Item name="startEnable" label="启用同步" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={handleSave}>
|
||||
保存配置
|
||||
</Button>
|
||||
<Button icon={<ApiOutlined />} loading={testing} onClick={handleTest}>
|
||||
测试连接
|
||||
</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
</Spin>
|
||||
<Tabs items={tabItems} />
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user