forked from wangziqi/gongxue-base
694 lines
21 KiB
TypeScript
694 lines
21 KiB
TypeScript
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
||
import {
|
||
Card,
|
||
Form,
|
||
Input,
|
||
Button,
|
||
Space,
|
||
Spin,
|
||
Alert,
|
||
Descriptions,
|
||
Tag,
|
||
Divider,
|
||
Drawer,
|
||
Tree,
|
||
Select,
|
||
TreeSelect,
|
||
Modal,
|
||
DatePicker,
|
||
Row,
|
||
Col,
|
||
List,
|
||
} from 'antd';
|
||
import {
|
||
SaveOutlined,
|
||
ApiOutlined,
|
||
CheckCircleOutlined,
|
||
CloseCircleOutlined,
|
||
SyncOutlined,
|
||
BankOutlined,
|
||
UserOutlined,
|
||
StopOutlined,
|
||
} from '@ant-design/icons';
|
||
import type { DataNode } from 'antd/es/tree';
|
||
import type { TreeSelectProps } from 'antd/es/tree-select';
|
||
import api from '../../api';
|
||
import { message } from '../../ui/app-message';
|
||
import { usePermission } from '../../hooks/usePermission';
|
||
import PermissionButton from '../../components/PermissionButton';
|
||
import {
|
||
buildDingTalkConfigPayload,
|
||
isAppSecretRequired,
|
||
type DingTalkConfigFormValues,
|
||
} from './integration-config-form';
|
||
|
||
interface DingTalkConfig {
|
||
agentId: string;
|
||
corpId: string;
|
||
}
|
||
|
||
interface DingOrgTreeNodeExt {
|
||
id: number;
|
||
name: string;
|
||
parentId: number;
|
||
children: DingOrgTreeNodeExt[];
|
||
users: Array<{ userid: string; name: string; mobile: string; deptIds: number[] }>;
|
||
}
|
||
|
||
interface OrgTreeNodeRaw {
|
||
id: number;
|
||
name: string;
|
||
children?: OrgTreeNodeRaw[];
|
||
}
|
||
|
||
interface OrgTreeResponse {
|
||
success: boolean;
|
||
data: OrgTreeNodeRaw[];
|
||
}
|
||
|
||
interface OrgTreeWithUsersResponse {
|
||
success: boolean;
|
||
data: DingOrgTreeNodeExt[];
|
||
}
|
||
|
||
type DeptPickerTreeNode = NonNullable<TreeSelectProps<number>['treeData']>[number];
|
||
|
||
interface ClassItem {
|
||
id: number;
|
||
name: string;
|
||
code: string;
|
||
classType?: string;
|
||
startDate?: string;
|
||
endDate?: string;
|
||
notes?: string;
|
||
}
|
||
|
||
interface ImportResult {
|
||
imported: number;
|
||
skipped: number;
|
||
conflicts: number;
|
||
}
|
||
|
||
interface DingTalkAttendanceGroup {
|
||
group_id: number;
|
||
group_name: string;
|
||
type: string;
|
||
member_count: number;
|
||
}
|
||
|
||
interface AttendanceGroupResponse {
|
||
success: boolean;
|
||
data: DingTalkAttendanceGroup[];
|
||
}
|
||
|
||
interface DeleteAttendanceGroupsResponse {
|
||
success: boolean;
|
||
data: {
|
||
total: number;
|
||
deleted: Array<{ groupId: number; groupName: string }>;
|
||
failed: Array<{ groupId: number; groupName: string; error: string }>;
|
||
};
|
||
}
|
||
|
||
const IntegrationConfigPage: React.FC = () => {
|
||
const { hasPermission, hasAllPermissions } = usePermission();
|
||
const canCreateClass = hasPermission('class:create');
|
||
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<DingTalkConfigFormValues>();
|
||
|
||
// ── Manual organization sync ──
|
||
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 [deptPickerTree, setDeptPickerTree] = useState<DeptPickerTreeNode[]>([]);
|
||
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 [attendanceGroups, setAttendanceGroups] = useState<DingTalkAttendanceGroup[]>([]);
|
||
const [deleteGroupsOpen, setDeleteGroupsOpen] = useState(false);
|
||
const [loadingGroups, setLoadingGroups] = useState(false);
|
||
const [deletingGroups, setDeletingGroups] = 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 fetchConfig();
|
||
}, []);
|
||
|
||
const handleSave = async () => {
|
||
const values = await form.validateFields();
|
||
const payload = buildDingTalkConfigPayload(values);
|
||
setSaving(true);
|
||
try {
|
||
await api.post('/integration/config', { type: 'DINGTALK', config: payload });
|
||
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();
|
||
const payload = buildDingTalkConfigPayload(values);
|
||
setTesting(true);
|
||
try {
|
||
const res = await api.post<{ success: boolean; message: string }>(
|
||
'/integration/config/test',
|
||
{
|
||
type: 'DINGTALK',
|
||
config: payload,
|
||
},
|
||
);
|
||
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 loadDeptTree = async () => {
|
||
try {
|
||
const res = await api.get<OrgTreeResponse>('/sync/dingtalk/org-tree');
|
||
if (res.success && res.data) {
|
||
const toTreeNode = (nodes: OrgTreeNodeRaw[]): DeptPickerTreeNode[] =>
|
||
nodes.map((n) => ({
|
||
title: n.name,
|
||
value: n.id,
|
||
children: n.children ? toTreeNode(n.children) : undefined,
|
||
}));
|
||
setDeptPickerTree(toTreeNode(res.data));
|
||
}
|
||
} catch {
|
||
message.error('获取部门架构失败');
|
||
}
|
||
};
|
||
|
||
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 () => {
|
||
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);
|
||
setCheckedKeys([]);
|
||
setSelectedClassId(null);
|
||
setDrawerOpen(true);
|
||
fetchClasses();
|
||
} else {
|
||
message.error('获取组织架构失败');
|
||
}
|
||
} catch (e: unknown) {
|
||
const err = e as { message?: string };
|
||
message.error(err?.message || '获取组织架构失败');
|
||
} finally {
|
||
setFetchingTree(false);
|
||
}
|
||
};
|
||
|
||
const buildTreeData = useCallback((nodes: DingOrgTreeNodeExt[]): DataNode[] => {
|
||
return nodes.map((node) => {
|
||
const users = node.users ?? [];
|
||
const children: DataNode[] = [
|
||
...buildTreeData(node.children ?? []),
|
||
...users.map((u) => ({
|
||
title: (
|
||
<Space>
|
||
<UserOutlined />
|
||
<span>{u.name}</span>
|
||
{u.mobile ? <Tag>{u.mobile}</Tag> : null}
|
||
</Space>
|
||
),
|
||
key: `user-${u.userid}`,
|
||
isLeaf: true,
|
||
})),
|
||
];
|
||
return {
|
||
title: (
|
||
<Space size="small">
|
||
<BankOutlined />
|
||
<span>{node.name}</span>
|
||
<Tag>{users.length}人</Tag>
|
||
</Space>
|
||
),
|
||
key: `dept-${node.id}`,
|
||
// Only attach children when there are any, so empty/leaf departments
|
||
// don't render a phantom expand arrow that opens to nothing.
|
||
...(children.length > 0 ? { children } : {}),
|
||
};
|
||
});
|
||
}, []);
|
||
|
||
const treeData = useMemo(() => buildTreeData(orgTree), [orgTree, buildTreeData]);
|
||
|
||
const extractCheckedUsers = useCallback((): Array<{
|
||
dingUserId: string;
|
||
name: string;
|
||
mobile?: string;
|
||
}> => {
|
||
const result: Array<{ dingUserId: string; name: string; mobile?: string }> = [];
|
||
const walk = (nodes: DingOrgTreeNodeExt[]) => {
|
||
for (const node of nodes) {
|
||
for (const u of node.users ?? []) {
|
||
if (checkedKeys.includes(`user-${u.userid}`)) {
|
||
result.push({ dingUserId: u.userid, name: u.name, mobile: u.mobile || undefined });
|
||
}
|
||
}
|
||
walk(node.children ?? []);
|
||
}
|
||
};
|
||
walk(orgTree);
|
||
return result;
|
||
}, [checkedKeys, orgTree]);
|
||
|
||
const handleJoinClass = async () => {
|
||
if (selectedClassId === null) return message.warning('请先选择一个班级');
|
||
const users = extractCheckedUsers();
|
||
if (users.length === 0) return message.warning('请勾选要导入的用户');
|
||
|
||
setImporting(true);
|
||
try {
|
||
const res = await api.post<ImportResult>(`/classes/${selectedClassId}/students/import`, {
|
||
users,
|
||
});
|
||
if (res.conflicts > 0) {
|
||
message.warning(
|
||
`导入 ${res.imported} 人,跳过 ${res.skipped} 人,${res.conflicts} 人需人工绑定`,
|
||
);
|
||
} else {
|
||
message.success(`导入 ${res.imported} 人,跳过 ${res.skipped} 人`);
|
||
}
|
||
setCheckedKeys([]);
|
||
setSelectedClassId(null);
|
||
} catch (e: unknown) {
|
||
const err = e as { message?: string };
|
||
message.error(err?.message || '导入失败');
|
||
} finally {
|
||
setImporting(false);
|
||
}
|
||
};
|
||
|
||
const handleCreateClass = async () => {
|
||
try {
|
||
const values = await classForm.validateFields();
|
||
const users = extractCheckedUsers();
|
||
await api.post('/classes', { ...values, users });
|
||
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 openDeleteAllGroups = async () => {
|
||
setLoadingGroups(true);
|
||
try {
|
||
const response = await api.get<AttendanceGroupResponse>('/sync/dingtalk/attendance-groups');
|
||
setAttendanceGroups(response.data);
|
||
setDeleteGroupsOpen(true);
|
||
} catch (error: unknown) {
|
||
message.error(error instanceof Error ? error.message : '获取钉钉考勤组失败');
|
||
} finally {
|
||
setLoadingGroups(false);
|
||
}
|
||
};
|
||
|
||
const deleteAllGroups = async () => {
|
||
setDeletingGroups(true);
|
||
try {
|
||
const response = await api.post<DeleteAttendanceGroupsResponse>(
|
||
'/sync/dingtalk/attendance-groups/delete-all',
|
||
);
|
||
setDeleteGroupsOpen(false);
|
||
setAttendanceGroups([]);
|
||
if (response.data.failed.length > 0) {
|
||
message.warning(
|
||
`已清空 ${response.data.deleted.length} 个,失败 ${response.data.failed.length} 个`,
|
||
);
|
||
} else {
|
||
message.success(`已清空钉钉全部 ${response.data.deleted.length} 个考勤组`);
|
||
}
|
||
} catch (error: unknown) {
|
||
message.error(error instanceof Error ? error.message : '清空钉钉考勤组失败');
|
||
} finally {
|
||
setDeletingGroups(false);
|
||
}
|
||
};
|
||
|
||
const syncPanel =
|
||
config && hasAllPermissions('sync:read', 'class:view', 'class:edit') ? (
|
||
<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>
|
||
<PermissionButton
|
||
permission="sync:trigger"
|
||
danger
|
||
icon={<StopOutlined />}
|
||
loading={loadingGroups}
|
||
onClick={openDeleteAllGroups}
|
||
>
|
||
清空钉钉全部考勤组
|
||
</PermissionButton>
|
||
</Space>
|
||
|
||
{drawerOpen && (
|
||
<Drawer
|
||
title="钉钉组织架构 — 批量导入"
|
||
open={drawerOpen}
|
||
onClose={() => {
|
||
setDrawerOpen(false);
|
||
}}
|
||
width="min(900px, 100vw)"
|
||
footer={
|
||
<Space>
|
||
<Button
|
||
onClick={() => {
|
||
setDrawerOpen(false);
|
||
}}
|
||
>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
type="primary"
|
||
loading={importing}
|
||
disabled={
|
||
checkedKeys.filter((k) => String(k).startsWith('user-')).length === 0 ||
|
||
selectedClassId === null
|
||
}
|
||
onClick={handleJoinClass}
|
||
>
|
||
加入选中的班级
|
||
</Button>
|
||
{canCreateClass ? (
|
||
<Button
|
||
disabled={checkedKeys.filter((k) => String(k).startsWith('user-')).length === 0}
|
||
onClick={() => setClassModalOpen(true)}
|
||
>
|
||
创建班级
|
||
</Button>
|
||
) : null}
|
||
</Space>
|
||
}
|
||
>
|
||
<Row gutter={[16, 16]}>
|
||
<Col xs={24} md={14}>
|
||
<div style={{ maxHeight: '60vh', overflow: 'auto' }}>
|
||
<Tree
|
||
checkable
|
||
treeData={treeData}
|
||
defaultExpandAll
|
||
showLine={{ showLeafIcon: false }}
|
||
checkedKeys={checkedKeys}
|
||
onCheck={(checked) => setCheckedKeys(checked as React.Key[])}
|
||
/>
|
||
</div>
|
||
</Col>
|
||
<Col xs={24} md={10}>
|
||
<Card
|
||
title="班级列表"
|
||
size="small"
|
||
extra={
|
||
canCreateClass ? (
|
||
<Button size="small" onClick={() => setClassModalOpen(true)}>
|
||
+ 创建班级
|
||
</Button>
|
||
) : null
|
||
}
|
||
>
|
||
<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 */}
|
||
{canCreateClass ? (
|
||
<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="notes" label="备注">
|
||
<Input.TextArea rows={2} />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
) : null}
|
||
</Drawer>
|
||
)}
|
||
<Modal
|
||
title="确认清空钉钉全部考勤组"
|
||
open={deleteGroupsOpen}
|
||
okText="确认全部清空"
|
||
okButtonProps={{ danger: true, disabled: attendanceGroups.length === 0 }}
|
||
cancelText="取消"
|
||
confirmLoading={deletingGroups}
|
||
onOk={deleteAllGroups}
|
||
onCancel={() => setDeleteGroupsOpen(false)}
|
||
>
|
||
<Alert
|
||
type="error"
|
||
showIcon
|
||
message={`将永久清空钉钉上的 ${attendanceGroups.length} 个考勤组`}
|
||
description="本地班级和排课不会清空。清空后需在排课管理中重新同步,才能重建考勤组。"
|
||
style={{ marginBottom: 12 }}
|
||
/>
|
||
<List
|
||
size="small"
|
||
bordered
|
||
dataSource={attendanceGroups}
|
||
style={{ maxHeight: 280, overflow: 'auto' }}
|
||
renderItem={(group) => (
|
||
<List.Item>
|
||
<List.Item.Meta
|
||
title={group.group_name}
|
||
description={`ID ${group.group_id} · ${group.member_count} 人`}
|
||
/>
|
||
</List.Item>
|
||
)}
|
||
/>
|
||
</Modal>
|
||
</div>
|
||
) : null;
|
||
|
||
return (
|
||
<Card
|
||
title="钉钉集成配置"
|
||
extra={
|
||
<Space>
|
||
{verified === true && (
|
||
<Tag icon={<CheckCircleOutlined />} color="success">
|
||
已连接
|
||
</Tag>
|
||
)}
|
||
{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="同步方式">手动触发</Descriptions.Item>
|
||
</Descriptions>
|
||
)}
|
||
|
||
<Alert
|
||
type="info"
|
||
message="配置钉钉应用凭证后,可在本页手动获取组织架构并导入用户。排班同步仍在排课管理中手动触发。"
|
||
style={{ marginBottom: 24 }}
|
||
showIcon
|
||
/>
|
||
|
||
<Form form={form} layout="vertical" style={{ maxWidth: 520 }}>
|
||
<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: isAppSecretRequired(!!config),
|
||
message: '首次配置请输入 AppSecret',
|
||
},
|
||
]}
|
||
extra={
|
||
config
|
||
? '已保存密钥;留空保持原值,输入新值将替换原密钥'
|
||
: '首次配置需要填写完整 AppSecret'
|
||
}
|
||
>
|
||
<Input.Password placeholder={config ? '留空保持已保存的密钥' : '从钉钉开放平台获取'} />
|
||
</Form.Item>
|
||
<Space>
|
||
<PermissionButton
|
||
permission="integration:trigger"
|
||
type="primary"
|
||
icon={<SaveOutlined />}
|
||
loading={saving}
|
||
onClick={handleSave}
|
||
>
|
||
保存配置
|
||
</PermissionButton>
|
||
<Button icon={<ApiOutlined />} loading={testing} onClick={handleTest}>
|
||
测试连接
|
||
</Button>
|
||
</Space>
|
||
</Form>
|
||
|
||
{syncPanel && (
|
||
<>
|
||
<Divider titlePlacement="start">组织用户导入</Divider>
|
||
{syncPanel}
|
||
</>
|
||
)}
|
||
</Spin>
|
||
</Card>
|
||
);
|
||
};
|
||
|
||
export default IntegrationConfigPage;
|