whistle.mock-plugins 1.2.2-beta.2 → 1.2.2-beta.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/VERSION.md +50 -1
- package/app/cgi-bin/settings.js +37 -0
- package/app/public/js/index.js +1 -1
- package/lib/models.js +9 -0
- package/lib/routes/interfaces.js +6 -3
- package/lib/routes/settings.js +51 -0
- package/lib/ruleManager.js +105 -41
- package/lib/server.js +4 -0
- package/package.json +1 -1
- package/ui/src/components/interface-management/DataManagementDrawer.js +115 -0
- package/ui/src/components/interface-management/ResponseContentEditor.js +146 -36
- package/ui/src/components/interface-management/index.js +1 -0
- package/ui/src/pages/InterfaceManagementV2.js +448 -145
- package/ui/src/pages/MockDataV2.js +1 -1
- package/ui/src/pages/Settings.js +143 -6
- package/ui/src/styles/interface-management.css +3 -12
- package/ui/src/styles/settings.css +29 -0
package/lib/models.js
CHANGED
|
@@ -22,6 +22,15 @@
|
|
|
22
22
|
* @property {string} name - 响应名称
|
|
23
23
|
* @property {string} description - 响应描述
|
|
24
24
|
* @property {string} content - 响应内容,使用 Mock.js 语法
|
|
25
|
+
* @property {Array<ParamMatcher>} paramMatchers - 响应级入参匹配规则,运行时按顺序匹配,第一个满足所有规则的响应被返回;无规则的响应作为默认兜底
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 参数匹配规则数据模型
|
|
30
|
+
* @typedef {Object} ParamMatcher
|
|
31
|
+
* @property {string} paramPath - 参数路径,支持嵌套路径(如 data.user.id)
|
|
32
|
+
* @property {string} paramValue - 期望值
|
|
33
|
+
* @property {string} matchType - 匹配类型,可选值: 'exact'(精确匹配), 'contains'(包含), 'regex'(正则)
|
|
25
34
|
*/
|
|
26
35
|
|
|
27
36
|
/**
|
package/lib/routes/interfaces.js
CHANGED
|
@@ -53,7 +53,8 @@ const processInterfaceData = (data) => {
|
|
|
53
53
|
id: resp.id,
|
|
54
54
|
name: resp.name,
|
|
55
55
|
description: resp.description || '',
|
|
56
|
-
content: resp.content || '{}'
|
|
56
|
+
content: resp.content || '{}',
|
|
57
|
+
paramMatchers: Array.isArray(resp.paramMatchers) ? resp.paramMatchers : []
|
|
57
58
|
};
|
|
58
59
|
|
|
59
60
|
console.log(`处理后的响应 ${index}:`, processedResp);
|
|
@@ -86,7 +87,8 @@ const processInterfaceData = (data) => {
|
|
|
86
87
|
id: Math.random().toString(36).substring(2, 15),
|
|
87
88
|
name: '默认响应',
|
|
88
89
|
description: '',
|
|
89
|
-
content: result.responseContent
|
|
90
|
+
content: result.responseContent,
|
|
91
|
+
paramMatchers: []
|
|
90
92
|
}];
|
|
91
93
|
result.activeResponseId = result.responses[0].id;
|
|
92
94
|
console.log('创建的默认响应:', result.responses[0]);
|
|
@@ -98,7 +100,8 @@ const processInterfaceData = (data) => {
|
|
|
98
100
|
id: Math.random().toString(36).substring(2, 15),
|
|
99
101
|
name: '默认响应',
|
|
100
102
|
description: '',
|
|
101
|
-
content: '{}'
|
|
103
|
+
content: '{}',
|
|
104
|
+
paramMatchers: []
|
|
102
105
|
}];
|
|
103
106
|
result.activeResponseId = result.responses[0].id;
|
|
104
107
|
result.responseContent = '{}';
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const fs = require('fs-extra');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const storage = require('../storage');
|
|
5
|
+
|
|
6
|
+
const DATA_DIR = storage.DATA_DIR;
|
|
7
|
+
const SETTINGS_FILE = path.join(DATA_DIR, 'settings.json');
|
|
8
|
+
|
|
9
|
+
fs.ensureDirSync(DATA_DIR);
|
|
10
|
+
|
|
11
|
+
const DEFAULT_SETTINGS = {
|
|
12
|
+
interfaceParamMatcherEnabled: true,
|
|
13
|
+
responseParamMatcherEnabled: true,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const getSettings = () => {
|
|
17
|
+
try {
|
|
18
|
+
if (!fs.existsSync(SETTINGS_FILE)) return { ...DEFAULT_SETTINGS };
|
|
19
|
+
return { ...DEFAULT_SETTINGS, ...fs.readJsonSync(SETTINGS_FILE) };
|
|
20
|
+
} catch (err) {
|
|
21
|
+
return { ...DEFAULT_SETTINGS };
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const saveSettings = (settings) => {
|
|
26
|
+
fs.writeJsonSync(SETTINGS_FILE, settings, { spaces: 2 });
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const initRouter = () => {
|
|
30
|
+
const router = express.Router();
|
|
31
|
+
router.use(express.json());
|
|
32
|
+
|
|
33
|
+
router.get('/settings', (req, res) => {
|
|
34
|
+
res.json(getSettings());
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
router.put('/settings', (req, res) => {
|
|
38
|
+
try {
|
|
39
|
+
const current = getSettings();
|
|
40
|
+
const updated = { ...current, ...req.body };
|
|
41
|
+
saveSettings(updated);
|
|
42
|
+
res.json(updated);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
res.status(500).json({ error: '保存设置失败' });
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return router;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
module.exports = { initRouter, getSettings };
|
package/lib/ruleManager.js
CHANGED
|
@@ -1,7 +1,24 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
|
+
const fse = require('fs-extra');
|
|
2
3
|
const path = require('path');
|
|
3
4
|
const url = require('url');
|
|
4
5
|
const Mock = require('mockjs');
|
|
6
|
+
const storage = require('./storage');
|
|
7
|
+
|
|
8
|
+
const SETTINGS_FILE = path.join(storage.DATA_DIR, 'settings.json');
|
|
9
|
+
|
|
10
|
+
const getSettings = () => {
|
|
11
|
+
try {
|
|
12
|
+
if (!fse.existsSync(SETTINGS_FILE)) return { interfaceParamMatcherEnabled: true, responseParamMatcherEnabled: true };
|
|
13
|
+
const s = fse.readJsonSync(SETTINGS_FILE);
|
|
14
|
+
return {
|
|
15
|
+
interfaceParamMatcherEnabled: s.interfaceParamMatcherEnabled !== false,
|
|
16
|
+
responseParamMatcherEnabled: s.responseParamMatcherEnabled !== false,
|
|
17
|
+
};
|
|
18
|
+
} catch (e) {
|
|
19
|
+
return { interfaceParamMatcherEnabled: true, responseParamMatcherEnabled: true };
|
|
20
|
+
}
|
|
21
|
+
};
|
|
5
22
|
|
|
6
23
|
// 获取嵌套对象属性值的工具函数
|
|
7
24
|
const getNestedValue = (obj, path) => {
|
|
@@ -268,29 +285,34 @@ const ruleManager = {
|
|
|
268
285
|
return null;
|
|
269
286
|
}
|
|
270
287
|
|
|
271
|
-
// 在URL
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
288
|
+
// 在URL匹配的接口中,查找参数也匹配的接口(受接口入参匹配开关控制)
|
|
289
|
+
const settings = getSettings();
|
|
290
|
+
if (settings.interfaceParamMatcherEnabled) {
|
|
291
|
+
for (const intf of urlMatchedInterfaces) {
|
|
292
|
+
if (isParamMatch(req, intf.paramMatchers, this.log)) {
|
|
293
|
+
this.log(`[规则处理器] 找到完全匹配的接口: ${intf.name} (URL + 参数匹配)`);
|
|
294
|
+
return intf;
|
|
295
|
+
} else {
|
|
296
|
+
this.log(`[规则处理器] 接口 ${intf.name} URL匹配但参数不匹配,继续查找...`);
|
|
297
|
+
}
|
|
279
298
|
}
|
|
299
|
+
|
|
300
|
+
// 如果没有参数匹配的接口,返回第一个没有参数匹配规则的接口
|
|
301
|
+
const fallbackInterface = urlMatchedInterfaces.find(intf =>
|
|
302
|
+
!intf.paramMatchers || !Array.isArray(intf.paramMatchers) || intf.paramMatchers.length === 0
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
if (fallbackInterface) {
|
|
306
|
+
this.log(`[规则处理器] 使用回退接口: ${fallbackInterface.name} (无参数匹配规则)`);
|
|
307
|
+
return fallbackInterface;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
this.log(`[规则处理器] 所有URL匹配的接口都有参数匹配规则且都不满足,未找到合适的接口`);
|
|
311
|
+
return null;
|
|
312
|
+
} else {
|
|
313
|
+
this.log('[规则处理器] 接口入参匹配已关闭,直接返回第一个URL匹配的接口');
|
|
314
|
+
return urlMatchedInterfaces[0];
|
|
280
315
|
}
|
|
281
|
-
|
|
282
|
-
// 如果没有参数匹配的接口,返回第一个没有参数匹配规则的接口
|
|
283
|
-
const fallbackInterface = urlMatchedInterfaces.find(intf =>
|
|
284
|
-
!intf.paramMatchers || !Array.isArray(intf.paramMatchers) || intf.paramMatchers.length === 0
|
|
285
|
-
);
|
|
286
|
-
|
|
287
|
-
if (fallbackInterface) {
|
|
288
|
-
this.log(`[规则处理器] 使用回退接口: ${fallbackInterface.name} (无参数匹配规则)`);
|
|
289
|
-
return fallbackInterface;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
this.log(`[规则处理器] 所有URL匹配的接口都有参数匹配规则且都不满足,未找到合适的接口`);
|
|
293
|
-
return null;
|
|
294
316
|
},
|
|
295
317
|
|
|
296
318
|
/**
|
|
@@ -437,9 +459,9 @@ const ruleManager = {
|
|
|
437
459
|
}
|
|
438
460
|
}
|
|
439
461
|
|
|
440
|
-
//
|
|
441
|
-
if (config.responses && config.
|
|
442
|
-
const activeResponse = config.responses.find(r => r.id === config.activeResponseId);
|
|
462
|
+
// 对于有多个响应的接口,添加当前响应标识(优先展示实际命中的响应,在 handleDataTemplate 前此处只能用 activeResponseId 作为预估)
|
|
463
|
+
if (config.responses && config.responses.length > 0) {
|
|
464
|
+
const activeResponse = config.responses.find(r => r.id === config.activeResponseId) || config.responses[0];
|
|
443
465
|
if (activeResponse && activeResponse.name) {
|
|
444
466
|
res.setHeader('X-Whistle-Mock-Response', activeResponse.name);
|
|
445
467
|
}
|
|
@@ -751,18 +773,63 @@ const ruleManager = {
|
|
|
751
773
|
|
|
752
774
|
// 支持多响应系统
|
|
753
775
|
let template = '';
|
|
776
|
+
let hitResponse = null;
|
|
754
777
|
|
|
755
778
|
// 检查是否有多响应配置
|
|
756
779
|
if (config.responses && Array.isArray(config.responses) && config.responses.length > 0) {
|
|
757
|
-
|
|
758
|
-
const
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
780
|
+
let selectedResponse = null;
|
|
781
|
+
const settings = getSettings();
|
|
782
|
+
|
|
783
|
+
// 1. 优先检查接口级 paramMatchers 中带 targetResponseId 的规则(受开关控制)
|
|
784
|
+
if (settings.interfaceParamMatcherEnabled) {
|
|
785
|
+
const interfaceMatchers = config.paramMatchers;
|
|
786
|
+
if (interfaceMatchers && Array.isArray(interfaceMatchers) && interfaceMatchers.length > 0) {
|
|
787
|
+
for (const matcher of interfaceMatchers) {
|
|
788
|
+
if (!matcher.targetResponseId) continue;
|
|
789
|
+
if (isParamMatch(req, [matcher], this.log)) {
|
|
790
|
+
const target = config.responses.find(r => r.id === matcher.targetResponseId);
|
|
791
|
+
if (target) {
|
|
792
|
+
selectedResponse = target;
|
|
793
|
+
this.log(`[规则处理器] 接口级规则指定响应匹配成功,使用响应: ${target.name || '未命名'}`);
|
|
794
|
+
break;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
} else {
|
|
800
|
+
this.log('[规则处理器] 接口入参匹配已关闭,跳过接口级匹配');
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// 2. 按顺序查找第一个响应级入参匹配的响应(受开关控制)
|
|
804
|
+
if (!selectedResponse) {
|
|
805
|
+
if (settings.responseParamMatcherEnabled) {
|
|
806
|
+
for (const resp of config.responses) {
|
|
807
|
+
if (resp.paramMatchers && Array.isArray(resp.paramMatchers) && resp.paramMatchers.length > 0) {
|
|
808
|
+
if (isParamMatch(req, resp.paramMatchers, this.log)) {
|
|
809
|
+
selectedResponse = resp;
|
|
810
|
+
this.log(`[规则处理器] 响应级入参匹配成功,使用响应: ${resp.name || '未命名'}`);
|
|
811
|
+
break;
|
|
812
|
+
} else {
|
|
813
|
+
this.log(`[规则处理器] 响应 "${resp.name || '未命名'}" 入参不匹配,继续检查下一个`);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
} else {
|
|
818
|
+
this.log('[规则处理器] 响应入参匹配已关闭,跳过响应级匹配');
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// 3. 无匹配则回退到 activeResponseId 指定的默认响应
|
|
823
|
+
if (!selectedResponse) {
|
|
824
|
+
const activeResponseId = config.activeResponseId;
|
|
825
|
+
selectedResponse = (activeResponseId && config.responses.find(r => r.id === activeResponseId))
|
|
826
|
+
|| config.responses[0];
|
|
827
|
+
this.log(`[规则处理器] 无响应级匹配,使用默认响应: ${selectedResponse ? (selectedResponse.name || '未命名') : '无'}`);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
if (selectedResponse) {
|
|
831
|
+
hitResponse = selectedResponse;
|
|
832
|
+
template = selectedResponse.content;
|
|
766
833
|
}
|
|
767
834
|
}
|
|
768
835
|
|
|
@@ -790,11 +857,10 @@ const ruleManager = {
|
|
|
790
857
|
const enableMockHeaders = process.env.WHISTLE_MOCK_HEADERS !== 'false';
|
|
791
858
|
if (enableMockHeaders) {
|
|
792
859
|
res.setHeader('X-Whistle-Mock-Data-Type', 'template');
|
|
793
|
-
if (
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
}
|
|
860
|
+
if (hitResponse) {
|
|
861
|
+
res.setHeader('X-Whistle-Mock-Template', hitResponse.name || 'unnamed');
|
|
862
|
+
// 覆盖 processRequest 中预设的响应头,确保反映实际命中的响应
|
|
863
|
+
res.setHeader('X-Whistle-Mock-Response', hitResponse.name || 'unnamed');
|
|
798
864
|
}
|
|
799
865
|
}
|
|
800
866
|
|
|
@@ -807,9 +873,7 @@ const ruleManager = {
|
|
|
807
873
|
mockInfo: {
|
|
808
874
|
delay: parseInt(config.responseDelay || config.delay || 0, 10),
|
|
809
875
|
timestamp: new Date().toISOString(),
|
|
810
|
-
responseName:
|
|
811
|
-
? (config.responses.find(r => r.id === config.activeResponseId)?.name || '未命名')
|
|
812
|
-
: '默认响应'
|
|
876
|
+
responseName: hitResponse ? (hitResponse.name || '未命名') : '默认响应'
|
|
813
877
|
}
|
|
814
878
|
};
|
|
815
879
|
} catch (err) {
|
package/lib/server.js
CHANGED
|
@@ -270,6 +270,10 @@ const app = express();
|
|
|
270
270
|
app.use(express.json({limit: '100mb'}));
|
|
271
271
|
app.use(express.urlencoded({ extended: true, limit: '100mb'}));
|
|
272
272
|
|
|
273
|
+
// 注册设置路由
|
|
274
|
+
const settingsRouter = require('./routes/settings');
|
|
275
|
+
app.use('/api', settingsRouter.initRouter());
|
|
276
|
+
|
|
273
277
|
// 加载功能和接口配置
|
|
274
278
|
const loadConfigurations = () => {
|
|
275
279
|
try {
|
package/package.json
CHANGED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
import { Drawer, Form, Input, message, Button, Space } from 'antd';
|
|
3
|
+
import axios from 'axios';
|
|
4
|
+
import { ResponseContentEditor, PreviewModal, formatResponseContent, refreshCacheAfterUpdate } from '.';
|
|
5
|
+
|
|
6
|
+
const DataManagementDrawer = ({ visible, onCancel, interfaceItem, onSaved }) => {
|
|
7
|
+
const [form] = Form.useForm();
|
|
8
|
+
const [previewVisible, setPreviewVisible] = useState(false);
|
|
9
|
+
const [previewContent, setPreviewContent] = useState(null);
|
|
10
|
+
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
if (visible && interfaceItem) {
|
|
13
|
+
let responses = Array.isArray(interfaceItem.responses) ? interfaceItem.responses : [];
|
|
14
|
+
let activeResponseId = interfaceItem.activeResponseId || (responses[0] && responses[0].id) || '';
|
|
15
|
+
form.setFieldsValue({ responses, activeResponseId });
|
|
16
|
+
}
|
|
17
|
+
}, [visible, interfaceItem]);
|
|
18
|
+
|
|
19
|
+
const handlePreview = () => {
|
|
20
|
+
const values = form.getFieldsValue(true);
|
|
21
|
+
const responses = Array.isArray(values.responses) ? values.responses : [];
|
|
22
|
+
const activeResponse = responses.find(r => r.id === values.activeResponseId) || responses[0];
|
|
23
|
+
if (!activeResponse) {
|
|
24
|
+
message.error('未找到有效的响应内容');
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const contentType = 'application/json; charset=utf-8';
|
|
28
|
+
const formatted = formatResponseContent(activeResponse.content || '', contentType);
|
|
29
|
+
setPreviewContent({ title: `预览: ${activeResponse.name || '未命名响应'}`, content: formatted, contentType, description: '' });
|
|
30
|
+
setPreviewVisible(true);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const handleOk = async () => {
|
|
34
|
+
try {
|
|
35
|
+
await form.validateFields();
|
|
36
|
+
const values = form.getFieldsValue(true);
|
|
37
|
+
const payload = {
|
|
38
|
+
responses: Array.isArray(values.responses) ? values.responses : [],
|
|
39
|
+
activeResponseId: values.activeResponseId || (values.responses && values.responses[0] && values.responses[0].id) || ''
|
|
40
|
+
};
|
|
41
|
+
if (!interfaceItem || !interfaceItem.id) {
|
|
42
|
+
message.error('缺少接口ID,无法保存');
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const res = await axios.patch(`/cgi-bin/interfaces/${interfaceItem.id}`, payload);
|
|
46
|
+
if (res.data && res.data.code === 0) {
|
|
47
|
+
message.success('保存成功');
|
|
48
|
+
await refreshCacheAfterUpdate();
|
|
49
|
+
if (onSaved) onSaved();
|
|
50
|
+
if (onCancel) onCancel();
|
|
51
|
+
} else {
|
|
52
|
+
throw new Error(res.data?.message || '保存失败');
|
|
53
|
+
}
|
|
54
|
+
} catch (err) {
|
|
55
|
+
if (err && err.errorFields) return; // 表单校验错误
|
|
56
|
+
console.error('保存失败', err);
|
|
57
|
+
message.error(err.response?.data?.message || err.message || '保存失败');
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<>
|
|
63
|
+
<Drawer
|
|
64
|
+
title="数据管理"
|
|
65
|
+
placement="right"
|
|
66
|
+
width={1040}
|
|
67
|
+
open={visible}
|
|
68
|
+
onClose={onCancel}
|
|
69
|
+
destroyOnClose
|
|
70
|
+
bodyStyle={{ paddingBottom: 80 }}
|
|
71
|
+
footer={
|
|
72
|
+
<div style={{ textAlign: 'right' }}>
|
|
73
|
+
<Space>
|
|
74
|
+
<Button onClick={onCancel}>取消</Button>
|
|
75
|
+
<Button type="primary" onClick={handleOk}>保存</Button>
|
|
76
|
+
</Space>
|
|
77
|
+
</div>
|
|
78
|
+
}
|
|
79
|
+
>
|
|
80
|
+
<Form form={form} layout="vertical" initialValues={{ responses: [], activeResponseId: '' }}>
|
|
81
|
+
<Form.Item name="responses" hidden>
|
|
82
|
+
<Input />
|
|
83
|
+
</Form.Item>
|
|
84
|
+
<Form.Item name="activeResponseId" hidden>
|
|
85
|
+
<Input />
|
|
86
|
+
</Form.Item>
|
|
87
|
+
<Form.Item noStyle shouldUpdate>
|
|
88
|
+
{({ getFieldValue }) => {
|
|
89
|
+
const responses = getFieldValue('responses') || [];
|
|
90
|
+
const activeResponseId = getFieldValue('activeResponseId') || (responses[0] && responses[0].id) || '';
|
|
91
|
+
return (
|
|
92
|
+
<ResponseContentEditor
|
|
93
|
+
form={form}
|
|
94
|
+
responses={responses}
|
|
95
|
+
activeResponseId={activeResponseId}
|
|
96
|
+
onPreview={handlePreview}
|
|
97
|
+
enableSorting={true}
|
|
98
|
+
/>
|
|
99
|
+
);
|
|
100
|
+
}}
|
|
101
|
+
</Form.Item>
|
|
102
|
+
</Form>
|
|
103
|
+
</Drawer>
|
|
104
|
+
|
|
105
|
+
<PreviewModal
|
|
106
|
+
visible={previewVisible}
|
|
107
|
+
onCancel={() => setPreviewVisible(false)}
|
|
108
|
+
previewContent={previewContent}
|
|
109
|
+
/>
|
|
110
|
+
</>
|
|
111
|
+
);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export default DataManagementDrawer;
|
|
115
|
+
|