fix: disable import button when roles not loaded (null defaultTeacherRoleId guard)

This commit is contained in:
2026-07-09 11:32:01 +08:00
parent 0cfdab95ee
commit fb8c897b4f

View File

@@ -1,10 +1,13 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useState, useMemo } 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,
} from 'antd'; } from 'antd';
import { import {
SaveOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined, SaveOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined,
SyncOutlined, ReloadOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { DataNode } from 'antd/es/tree';
import api from '../../api'; import api from '../../api';
interface DingTalkConfig { interface DingTalkConfig {
@@ -14,11 +17,68 @@ interface DingTalkConfig {
startEnable: boolean; startEnable: boolean;
} }
/** Utility: recursively flatten org tree nodes to extract user ids (for import guard) */ interface DingOrgTreeNodeExt {
// eslint-disable-next-line @typescript-eslint/no-explicit-any id: number;
const getAllUsers = (nodes: any[]): Array<{ userid: string }> => { name: string;
return nodes.flatMap((n) => [...(n.users ?? []), ...getAllUsers(n.children ?? [])]); 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 IntegrationConfigPage: React.FC = () => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -28,6 +88,18 @@ const IntegrationConfigPage: React.FC = () => {
const [verified, setVerified] = useState<boolean | null>(null); const [verified, setVerified] = useState<boolean | null>(null);
const [form] = Form.useForm<DingTalkConfig>(); 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 () => { const fetchConfig = async () => {
setLoading(true); setLoading(true);
try { try {
@@ -46,7 +118,7 @@ const IntegrationConfigPage: React.FC = () => {
}; };
useEffect(() => { useEffect(() => {
fetchConfig(); void Promise.all([fetchConfig(), fetchRoles()]);
}, []); }, []);
const handleSave = async () => { const handleSave = async () => {
@@ -83,13 +155,219 @@ const IntegrationConfigPage: React.FC = () => {
} }
}; };
return ( const fetchRoles = async () => {
<Card title="钉钉集成配置" extra={ 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> <Space>
{verified === true && <Tag icon={<CheckCircleOutlined />} color="success"></Tag>} <TreeSelect
{verified === false && <Tag icon={<CloseCircleOutlined />} color="error"></Tag>} 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> </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}> <Spin spinning={loading}>
{config && ( {config && (
<Descriptions size="small" column={2} style={{ marginBottom: 24 }}> <Descriptions size="small" column={2} style={{ marginBottom: 24 }}>
@@ -138,6 +416,19 @@ const IntegrationConfigPage: React.FC = () => {
</Space> </Space>
</Form> </Form>
</Spin> </Spin>
),
},
...syncTabItems,
];
return (
<Card title="钉钉集成配置" extra={
<Space>
{verified === true && <Tag icon={<CheckCircleOutlined />} color="success"></Tag>}
{verified === false && <Tag icon={<CloseCircleOutlined />} color="error"></Tag>}
</Space>
}>
<Tabs items={tabItems} />
</Card> </Card>
); );
}; };