Files
gongxue-base/apps/admin/src/pages/IntegrationConfig/index.tsx

699 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
} from 'antd';
import {
SaveOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined,
SyncOutlined, ReloadOutlined, PlusOutlined,
} from '@ant-design/icons';
import type { DataNode } from 'antd/es/tree';
import api from '../../api';
interface DingTalkConfig {
agentId: string;
appSecret: string;
corpId: string;
startEnable: boolean;
}
interface DingOrgTreeNodeExt {
id: number;
name: string;
parentId: number;
children: 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;
children?: OrgTreeNodeRaw[];
}
interface OrgTreeResponse {
success: boolean;
data: OrgTreeNodeRaw[];
}
interface OrgTreeWithUsersResponse {
success: boolean;
data: DingOrgTreeNodeExt[];
}
interface ImportUsersResponse {
success: boolean;
data: {
teacherCount: number;
studentCount: number;
classCount: number;
skipped: number;
warnings: string[];
};
}
interface ClassMarkForm {
deptId: number;
name: string;
code: 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 }) => (
<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);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [config, setConfig] = useState<DingTalkConfig | null>(null);
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 [classMarks, setClassMarks] = useState<Record<number, ClassMarkForm>>({});
const [classModalOpen, setClassModalOpen] = useState(false);
const [classModalDept, setClassModalDept] = useState<{ id: number; name: string } | null>(null);
const [classForm] = Form.useForm<ClassMarkForm>();
// ── 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 [unboundUsers, setUnboundUsers] = useState<Array<{ id: number; username: string; name: string }>>([]);
const [bindModalOpen, setBindModalOpen] = useState(false);
const [bindForm] = Form.useForm<{ userId: number; dingUserId: string; dingName?: string; dingMobile?: string }>();
const [bindSubmitting, setBindSubmitting] = useState(false);
const fetchConfig = async () => {
setLoading(true);
try {
const res = await api.get<{ success: boolean; data: Array<{ type: string; verify: boolean; config: DingTalkConfig }> }>('/integration/config');
const dt = res.data?.find((c) => c.type === 'DINGTALK');
if (dt) {
setConfig(dt.config);
setVerified(dt.verify);
form.setFieldsValue(dt.config);
}
} catch {
// not configured
} finally {
setLoading(false);
}
};
useEffect(() => {
void Promise.all([fetchConfig(), fetchRoles()]);
}, []);
const handleSave = async () => {
const values = await form.validateFields();
setSaving(true);
try {
await api.post('/integration/config', { type: 'DINGTALK', config: values });
message.success('配置已保存');
await fetchConfig();
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '保存失败');
} finally {
setSaving(false);
}
};
const handleTest = async () => {
const values = await form.validateFields();
setTesting(true);
try {
const res = await api.post<{ success: boolean; message: string }>('/integration/config/test', {
type: 'DINGTALK',
config: values,
});
setVerified(res.success);
message.success(res.message);
} catch (e: unknown) {
const err = e as { message?: string };
setVerified(false);
message.error(err?.message || '连接失败');
} finally {
setTesting(false);
}
};
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 () => {
try {
const res = await api.get<typeof bindings>('/rbac/user-ding-mappings');
setBindings(res);
} catch { /* ignore */ }
}, []);
const fetchUnboundUsers = useCallback(async () => {
try {
const res = await api.get<typeof unboundUsers>('/rbac/user-ding-mappings/unbound-users');
setUnboundUsers(res);
} catch { /* ignore */ }
}, []);
useEffect(() => {
fetchBindings();
}, [fetchBindings]);
const handleBindSubmit = async () => {
try {
const values = await bindForm.validateFields();
setBindSubmitting(true);
await api.post('/rbac/user-ding-mappings', values);
message.success('绑定成功');
setBindModalOpen(false);
bindForm.resetFields();
fetchBindings();
} catch (e: unknown) {
const err = e as { message?: string };
if (err.message) message.error(err.message);
} finally {
setBindSubmitting(false);
}
};
const handleUnbind = async (id: number) => {
Modal.confirm({
title: '确认解绑?',
content: '解绑后该用户将无法自动匹配钉钉考勤。',
okText: '解绑',
okType: 'danger',
onOk: async () => {
await api.delete(`/rbac/user-ding-mappings/${id}`);
message.success('已解绑');
fetchBindings();
},
});
};
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 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,
}));
setClassModalOpen(false);
setClassModalDept(null);
};
const handleClassModalCancel = () => {
setClassModalOpen(false);
setClassModalDept(null);
};
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 payload = {
classes: Object.values(classMarks),
users: allUsers.map((u) => ({
dingUserId: u.dingUserId,
name: u.name,
mobile: u.mobile,
roleId: teacherChecks[u.dingUserId]
? (teacherRoles[u.dingUserId] || defaultTeacherRoleId)
: null,
dingDeptIds: u.deptIds,
})),
const res = await api.post('/sync/dingtalk/import-users', payload) as ImportUsersResponse;
const data = res.data;
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) {
const err = e as { message?: string };
message.error(err?.message || '导入失败');
} finally {
setImporting(false);
}
};
const buildTreeData = useCallback((nodes: DingOrgTreeNodeExt[]): DataNode[] => {
return nodes.map((node) => ({
title: (
<Space size="small">
<span>{node.name}</span>
{node.children.length === 0 && node.users.length > 0 && (
<>
{classMarks[node.id] ? (
<Tag
color="blue"
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]);
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>
<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 && (
<Drawer
title="钉钉组织架构 — 勾选老师"
open={drawerOpen}
onClose={() => { setDrawerOpen(false); setClassMarks({}); }}
width={520}
footer={
<Space>
<Button onClick={() => { setDrawerOpen(false); setClassMarks({}); }}></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>
),
},
{
key: 'bindings',
label: '钉钉绑定',
children: (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 500 }}></span>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={async () => {
await fetchUnboundUsers();
setBindModalOpen(true);
}}
>
</Button>
</div>
<Table
dataSource={bindings}
rowKey="id"
columns={[
{
title: '本地用户',
key: 'user',
render: (_: unknown, r: typeof bindings[number]) => (
<Space>
<span>{r.user?.name || '-'}</span>
<Tag>{r.user?.username}</Tag>
</Space>
),
},
{ title: '钉钉用户ID', dataIndex: 'dingUserId', key: 'dingUserId' },
{ title: '钉钉名称', dataIndex: 'dingName', key: 'dingName', render: (v: string | null) => v || '-' },
{ title: '钉钉手机', dataIndex: 'dingMobile', key: 'dingMobile', render: (v: string | null) => v || '-' },
{
title: '操作',
key: 'actions',
render: (_: unknown, r: typeof bindings[number]) => (
<Popconfirm title="确认解绑?" onConfirm={() => handleUnbind(r.id)} okText="解绑" okType="danger">
<Button size="small" danger></Button>
</Popconfirm>
),
},
]}
pagination={{ pageSize: 20 }}
locale={{ emptyText: '暂无绑定记录' }}
/>
<Modal
title="新增钉钉绑定"
open={bindModalOpen}
onOk={handleBindSubmit}
onCancel={() => { setBindModalOpen(false); bindForm.resetFields(); }}
confirmLoading={bindSubmitting}
>
<Form form={bindForm} layout="vertical">
<Form.Item name="userId" label="本地用户" rules={[{ required: true, message: '请选择用户' }]}>
<Select
showSearch
placeholder="搜索本地用户"
optionFilterProp="label"
options={unboundUsers.map((u) => ({
value: u.id,
label: `${u.name || u.username} (${u.username})`,
}))}
/>
</Form.Item>
<Form.Item name="dingUserId" label="钉钉用户ID" rules={[{ required: true, message: '请输入钉钉用户ID' }]} extra="在钉钉管理后台 → 通讯录 → 成员详情 中可查看">
<Input placeholder="如: manager123" />
</Form.Item>
</Form>
</Modal>
</div>
),
},
...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>
);
};
export default IntegrationConfigPage;