whistle.mock-plugins 1.3.1 → 1.4.0-beta.0
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 +15 -0
- package/app/cgi-bin/settings.js +1 -0
- package/app/cgi-bin/webdav.js +56 -0
- package/app/public/js/index.js +1 -1
- package/app/public/js/index.js.LICENSE.txt +12 -0
- package/lib/routes/settings.js +1 -0
- package/lib/webdav-sync.js +806 -0
- package/package.json +1 -1
- package/test/api-error.test.js +29 -0
- package/test/webdav-sync.test.js +428 -0
- package/ui/src/components/WebDavSyncModal.js +516 -0
- package/ui/src/pages/MockDataV2.js +35 -2
- package/ui/src/pages/Settings.js +43 -1
- package/ui/src/styles/webdav-sync.css +408 -0
- package/ui/src/utils/api-error.js +57 -0
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Alert,
|
|
4
|
+
Button,
|
|
5
|
+
Card,
|
|
6
|
+
Checkbox,
|
|
7
|
+
Descriptions,
|
|
8
|
+
Empty,
|
|
9
|
+
Form,
|
|
10
|
+
Input,
|
|
11
|
+
Modal,
|
|
12
|
+
Popconfirm,
|
|
13
|
+
Space,
|
|
14
|
+
Spin,
|
|
15
|
+
Tag,
|
|
16
|
+
Typography,
|
|
17
|
+
message,
|
|
18
|
+
} from 'antd';
|
|
19
|
+
import {
|
|
20
|
+
CloudSyncOutlined,
|
|
21
|
+
DisconnectOutlined,
|
|
22
|
+
DownloadOutlined,
|
|
23
|
+
LinkOutlined,
|
|
24
|
+
ReloadOutlined,
|
|
25
|
+
SafetyCertificateOutlined,
|
|
26
|
+
UploadOutlined,
|
|
27
|
+
} from '@ant-design/icons';
|
|
28
|
+
import axios from 'axios';
|
|
29
|
+
import '../styles/webdav-sync.css';
|
|
30
|
+
|
|
31
|
+
const { getApiError } = require('../utils/api-error');
|
|
32
|
+
|
|
33
|
+
const { Paragraph, Text, Title } = Typography;
|
|
34
|
+
|
|
35
|
+
const formatTime = (value) => {
|
|
36
|
+
if (!value) return '未获取';
|
|
37
|
+
return new Date(value).toLocaleString('zh-CN', { hour12: false });
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const ModuleSelector = ({
|
|
41
|
+
title,
|
|
42
|
+
eyebrow,
|
|
43
|
+
modules,
|
|
44
|
+
selectedIds,
|
|
45
|
+
onChange,
|
|
46
|
+
emptyText,
|
|
47
|
+
action,
|
|
48
|
+
actionIcon,
|
|
49
|
+
actionClassName,
|
|
50
|
+
actionLoading,
|
|
51
|
+
disabled,
|
|
52
|
+
onAction,
|
|
53
|
+
extra,
|
|
54
|
+
}) => {
|
|
55
|
+
const allIds = modules.map(moduleItem => moduleItem.id);
|
|
56
|
+
const selectedModuleCount = allIds.filter(id => selectedIds.includes(id)).length;
|
|
57
|
+
const allSelected = modules.length > 0 && selectedModuleCount == modules.length;
|
|
58
|
+
const partiallySelected = selectedModuleCount > 0 && !allSelected;
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<Card className="webdav-sync-module-card" bordered={false}>
|
|
62
|
+
<div className="webdav-sync-module-header">
|
|
63
|
+
<div>
|
|
64
|
+
<Text type="secondary" className="webdav-sync-module-eyebrow">{eyebrow}</Text>
|
|
65
|
+
<Title level={5}>{title}</Title>
|
|
66
|
+
</div>
|
|
67
|
+
{extra}
|
|
68
|
+
</div>
|
|
69
|
+
<div className="webdav-sync-select-all">
|
|
70
|
+
<Checkbox
|
|
71
|
+
checked={allSelected}
|
|
72
|
+
indeterminate={partiallySelected}
|
|
73
|
+
disabled={!modules.length || disabled}
|
|
74
|
+
onChange={event => onChange(event.target.checked ? allIds : [])}
|
|
75
|
+
>
|
|
76
|
+
全选
|
|
77
|
+
</Checkbox>
|
|
78
|
+
<Text type="secondary">已选择 {selectedModuleCount} / {modules.length}</Text>
|
|
79
|
+
</div>
|
|
80
|
+
<div className="webdav-sync-module-list">
|
|
81
|
+
{modules.length ? (
|
|
82
|
+
<Checkbox.Group value={selectedIds} onChange={onChange} disabled={disabled}>
|
|
83
|
+
{modules.map(moduleItem => (
|
|
84
|
+
<Checkbox key={moduleItem.id} value={moduleItem.id} className="webdav-sync-module-option">
|
|
85
|
+
<span className="webdav-sync-module-name">{moduleItem.name}</span>
|
|
86
|
+
<span className="webdav-sync-module-count">{moduleItem.interfaceCount || 0} 个接口</span>
|
|
87
|
+
</Checkbox>
|
|
88
|
+
))}
|
|
89
|
+
</Checkbox.Group>
|
|
90
|
+
) : (
|
|
91
|
+
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={emptyText} />
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
94
|
+
<Button
|
|
95
|
+
block
|
|
96
|
+
type="primary"
|
|
97
|
+
className={actionClassName}
|
|
98
|
+
icon={actionIcon}
|
|
99
|
+
loading={actionLoading}
|
|
100
|
+
disabled={disabled || !selectedModuleCount}
|
|
101
|
+
onClick={onAction}
|
|
102
|
+
>
|
|
103
|
+
{action}({selectedModuleCount})
|
|
104
|
+
</Button>
|
|
105
|
+
</Card>
|
|
106
|
+
);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const WebDavSyncModal = ({ open, onClose, onPulled }) => {
|
|
110
|
+
const [form] = Form.useForm();
|
|
111
|
+
const [status, setStatus] = useState(null);
|
|
112
|
+
const [loading, setLoading] = useState(false);
|
|
113
|
+
const [actionLoading, setActionLoading] = useState('');
|
|
114
|
+
const [lastError, setLastError] = useState('');
|
|
115
|
+
const [selectedPushIds, setSelectedPushIds] = useState([]);
|
|
116
|
+
const [selectedPullIds, setSelectedPullIds] = useState([]);
|
|
117
|
+
|
|
118
|
+
const showError = (error, fallback) => {
|
|
119
|
+
const apiError = getApiError(error, fallback);
|
|
120
|
+
setLastError(apiError.message);
|
|
121
|
+
message.error(apiError.message);
|
|
122
|
+
return apiError;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const applyStatus = (nextStatus, resetSelections = {}) => {
|
|
126
|
+
setStatus(nextStatus);
|
|
127
|
+
if (resetSelections.push) {
|
|
128
|
+
setSelectedPushIds((nextStatus.localModules || []).map(moduleItem => moduleItem.id));
|
|
129
|
+
}
|
|
130
|
+
if (resetSelections.pull) {
|
|
131
|
+
setSelectedPullIds((nextStatus.remoteModules || []).map(moduleItem => moduleItem.id));
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const loadStatus = async () => {
|
|
136
|
+
setLoading(true);
|
|
137
|
+
setLastError('');
|
|
138
|
+
try {
|
|
139
|
+
const response = await axios.get('/cgi-bin/webdav');
|
|
140
|
+
const nextStatus = response.data.data;
|
|
141
|
+
applyStatus(nextStatus, { push: true, pull: true });
|
|
142
|
+
form.setFieldsValue({
|
|
143
|
+
endpoint: nextStatus.endpoint,
|
|
144
|
+
username: nextStatus.username,
|
|
145
|
+
password: '',
|
|
146
|
+
});
|
|
147
|
+
if (nextStatus.configured) {
|
|
148
|
+
try {
|
|
149
|
+
const refreshResponse = await axios.post('/cgi-bin/webdav', { action: 'refresh' });
|
|
150
|
+
applyStatus(refreshResponse.data.data, { pull: true });
|
|
151
|
+
} catch (error) {
|
|
152
|
+
showError(error, '连接配置已读取,但刷新远端模块失败');
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
} catch (error) {
|
|
156
|
+
showError(error, '读取 WebDAV 配置失败');
|
|
157
|
+
} finally {
|
|
158
|
+
setLoading(false);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
if (open) loadStatus();
|
|
164
|
+
}, [open]);
|
|
165
|
+
|
|
166
|
+
const postAction = async (action, payload = {}, resetSelections = {}) => {
|
|
167
|
+
const response = await axios.post('/cgi-bin/webdav', { action, ...payload });
|
|
168
|
+
if (response.data && response.data.data) applyStatus(response.data.data, resetSelections);
|
|
169
|
+
return response.data;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const handleTest = async () => {
|
|
173
|
+
let values;
|
|
174
|
+
try {
|
|
175
|
+
values = await form.validateFields();
|
|
176
|
+
} catch (error) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
setActionLoading('test');
|
|
180
|
+
setLastError('');
|
|
181
|
+
try {
|
|
182
|
+
await postAction('test', values);
|
|
183
|
+
message.success('WebDAV 连接测试成功');
|
|
184
|
+
} catch (error) {
|
|
185
|
+
showError(error, 'WebDAV 连接测试失败');
|
|
186
|
+
} finally {
|
|
187
|
+
setActionLoading('');
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const handleConnect = async () => {
|
|
192
|
+
let values;
|
|
193
|
+
try {
|
|
194
|
+
values = await form.validateFields();
|
|
195
|
+
} catch (error) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
setActionLoading('connect');
|
|
199
|
+
setLastError('');
|
|
200
|
+
try {
|
|
201
|
+
await postAction('connect', values, { push: true, pull: true });
|
|
202
|
+
form.setFieldValue('password', '');
|
|
203
|
+
message.success('WebDAV 连接已保存');
|
|
204
|
+
try {
|
|
205
|
+
const refreshResponse = await axios.post('/cgi-bin/webdav', { action: 'refresh' });
|
|
206
|
+
applyStatus(refreshResponse.data.data, { pull: true });
|
|
207
|
+
} catch (error) {
|
|
208
|
+
showError(error, '连接已保存,但刷新远端模块失败');
|
|
209
|
+
}
|
|
210
|
+
} catch (error) {
|
|
211
|
+
showError(error, '保存 WebDAV 连接失败');
|
|
212
|
+
} finally {
|
|
213
|
+
setActionLoading('');
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const handleRefresh = async () => {
|
|
218
|
+
setActionLoading('refresh');
|
|
219
|
+
setLastError('');
|
|
220
|
+
try {
|
|
221
|
+
await postAction('refresh', {}, { pull: true });
|
|
222
|
+
message.success('远端状态已刷新');
|
|
223
|
+
} catch (error) {
|
|
224
|
+
showError(error, '刷新远端状态失败');
|
|
225
|
+
} finally {
|
|
226
|
+
setActionLoading('');
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const handlePush = async (force = false, featureIds = selectedPushIds) => {
|
|
231
|
+
if (!featureIds.length) {
|
|
232
|
+
message.warning('请至少选择一个要推送的功能模块');
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
setActionLoading('push');
|
|
236
|
+
setLastError('');
|
|
237
|
+
try {
|
|
238
|
+
await postAction('push', { force, featureIds }, { push: true, pull: true });
|
|
239
|
+
message.success(`已推送 ${featureIds.length} 个功能模块`);
|
|
240
|
+
} catch (error) {
|
|
241
|
+
const apiError = getApiError(error, '推送失败');
|
|
242
|
+
if (error.response && error.response.status == 409 && apiError.conflict == 'REMOTE_NEWER' && !force) {
|
|
243
|
+
Modal.confirm({
|
|
244
|
+
title: '远端模块存在差异',
|
|
245
|
+
content: '已选择模块的远端内容与本地不同,继续推送会覆盖远端版本。确定继续吗?',
|
|
246
|
+
okText: '继续推送',
|
|
247
|
+
cancelText: '取消',
|
|
248
|
+
onOk: () => handlePush(true, featureIds),
|
|
249
|
+
});
|
|
250
|
+
} else if (error.response && error.response.status == 409 && apiError.conflict == 'REMOTE_UNVERIFIABLE' && !force) {
|
|
251
|
+
Modal.confirm({
|
|
252
|
+
title: '远端服务不支持并发校验',
|
|
253
|
+
content: 'WebDAV 没有返回 ETag 或 Last-Modified,无法确认模块清单期间是否被其他设备修改。仍要覆盖吗?',
|
|
254
|
+
okText: '强制推送',
|
|
255
|
+
okType: 'danger',
|
|
256
|
+
cancelText: '取消',
|
|
257
|
+
onOk: () => handlePush(true, featureIds),
|
|
258
|
+
});
|
|
259
|
+
} else if (error.response && error.response.status == 409 && apiError.conflict == 'REMOTE_INVALID' && !force) {
|
|
260
|
+
Modal.confirm({
|
|
261
|
+
title: '远端模块清单已损坏',
|
|
262
|
+
content: `继续推送会用已选择的 ${featureIds.length} 个模块创建新清单。确定继续吗?`,
|
|
263
|
+
okText: '推送已选模块',
|
|
264
|
+
okType: 'danger',
|
|
265
|
+
cancelText: '取消',
|
|
266
|
+
onOk: () => handlePush(true, featureIds),
|
|
267
|
+
});
|
|
268
|
+
} else {
|
|
269
|
+
showError(error, '推送失败');
|
|
270
|
+
}
|
|
271
|
+
} finally {
|
|
272
|
+
setActionLoading('');
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const handlePull = async (force = false, featureIds = selectedPullIds) => {
|
|
277
|
+
if (!featureIds.length) {
|
|
278
|
+
message.warning('请至少选择一个要拉取的功能模块');
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
setActionLoading('pull');
|
|
282
|
+
setLastError('');
|
|
283
|
+
try {
|
|
284
|
+
await postAction('pull', { force, featureIds }, { push: true, pull: true });
|
|
285
|
+
message.success(`已拉取 ${featureIds.length} 个功能模块`);
|
|
286
|
+
if (onPulled) await onPulled();
|
|
287
|
+
} catch (error) {
|
|
288
|
+
const apiError = getApiError(error, '拉取失败');
|
|
289
|
+
if (error.response && error.response.status == 409 && apiError.conflict == 'LOCAL_NEWER' && !force) {
|
|
290
|
+
Modal.confirm({
|
|
291
|
+
title: '本地模块存在差异',
|
|
292
|
+
content: `已选择模块的本地内容与远端不同,继续拉取会覆盖其中 ${featureIds.length} 个本地模块及其接口。确定继续吗?`,
|
|
293
|
+
okText: '覆盖本地',
|
|
294
|
+
okType: 'danger',
|
|
295
|
+
cancelText: '取消',
|
|
296
|
+
onOk: () => handlePull(true, featureIds),
|
|
297
|
+
});
|
|
298
|
+
} else if (error.response && error.response.status == 409 && apiError.conflict == 'LOCAL_CHANGED') {
|
|
299
|
+
Modal.warning({
|
|
300
|
+
title: '本地模块发生变化',
|
|
301
|
+
content: '拉取过程中检测到本地模块被修改,本次没有覆盖任何本地数据。请检查修改后重新拉取。',
|
|
302
|
+
okText: '知道了',
|
|
303
|
+
});
|
|
304
|
+
} else {
|
|
305
|
+
showError(error, '拉取失败');
|
|
306
|
+
}
|
|
307
|
+
} finally {
|
|
308
|
+
setActionLoading('');
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const handleDisconnect = async () => {
|
|
313
|
+
setActionLoading('disconnect');
|
|
314
|
+
setLastError('');
|
|
315
|
+
try {
|
|
316
|
+
await postAction('disconnect');
|
|
317
|
+
message.success('已断开 WebDAV,同步文件未删除');
|
|
318
|
+
} catch (error) {
|
|
319
|
+
showError(error, '断开连接失败');
|
|
320
|
+
} finally {
|
|
321
|
+
setActionLoading('');
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const configured = status && status.configured;
|
|
326
|
+
const busy = !!actionLoading;
|
|
327
|
+
|
|
328
|
+
return (
|
|
329
|
+
<Modal
|
|
330
|
+
title={null}
|
|
331
|
+
open={open}
|
|
332
|
+
onCancel={() => {
|
|
333
|
+
if (busy) {
|
|
334
|
+
message.warning('WebDAV 操作正在进行,请等待完成后再关闭');
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
onClose();
|
|
338
|
+
}}
|
|
339
|
+
footer={null}
|
|
340
|
+
width={1000}
|
|
341
|
+
maskClosable={!busy}
|
|
342
|
+
keyboard={!busy}
|
|
343
|
+
destroyOnClose
|
|
344
|
+
className="webdav-sync-modal"
|
|
345
|
+
wrapClassName="webdav-sync-modal-wrap"
|
|
346
|
+
>
|
|
347
|
+
<div className="webdav-sync-hero">
|
|
348
|
+
<div className="webdav-sync-hero-icon"><CloudSyncOutlined /></div>
|
|
349
|
+
<div className="webdav-sync-hero-copy">
|
|
350
|
+
<Text className="webdav-sync-kicker">WEBDAV SYNC</Text>
|
|
351
|
+
<Title level={3}>功能模块同步</Title>
|
|
352
|
+
<Paragraph>按模块手动推送或拉取,每个模块独立压缩保存。</Paragraph>
|
|
353
|
+
</div>
|
|
354
|
+
<Tag color={configured ? 'success' : 'default'}>
|
|
355
|
+
{configured ? '已连接' : '尚未配置'}
|
|
356
|
+
</Tag>
|
|
357
|
+
</div>
|
|
358
|
+
|
|
359
|
+
{lastError && (
|
|
360
|
+
<Alert
|
|
361
|
+
className="webdav-sync-error"
|
|
362
|
+
type="error"
|
|
363
|
+
showIcon
|
|
364
|
+
closable
|
|
365
|
+
message="WebDAV 操作失败"
|
|
366
|
+
description={lastError}
|
|
367
|
+
onClose={() => setLastError('')}
|
|
368
|
+
/>
|
|
369
|
+
)}
|
|
370
|
+
|
|
371
|
+
<Spin spinning={loading}>
|
|
372
|
+
{!configured ? (
|
|
373
|
+
<div className="webdav-sync-grid">
|
|
374
|
+
<Card className="webdav-sync-card" bordered={false}>
|
|
375
|
+
<Form form={form} layout="vertical" requiredMark={false}>
|
|
376
|
+
<Form.Item
|
|
377
|
+
name="endpoint"
|
|
378
|
+
label="WebDAV 专用目录"
|
|
379
|
+
rules={[{ required: true, message: '请输入 WebDAV 目录地址' }]}
|
|
380
|
+
extra="只填写目录,不包含 JSON 文件名;末尾斜杠会自动补齐。"
|
|
381
|
+
>
|
|
382
|
+
<Input prefix={<LinkOutlined />} placeholder="https://webdav.example.com/webdav/whistle-mock-plugin-sync/" />
|
|
383
|
+
</Form.Item>
|
|
384
|
+
<div className="webdav-sync-form-row">
|
|
385
|
+
<Form.Item
|
|
386
|
+
name="username"
|
|
387
|
+
label="用户名"
|
|
388
|
+
rules={[{ required: true, message: '请输入 WebDAV 用户名' }]}
|
|
389
|
+
>
|
|
390
|
+
<Input autoComplete="username" />
|
|
391
|
+
</Form.Item>
|
|
392
|
+
<Form.Item
|
|
393
|
+
name="password"
|
|
394
|
+
label="密码"
|
|
395
|
+
rules={[{ required: true, message: '请输入 WebDAV 密码' }]}
|
|
396
|
+
>
|
|
397
|
+
<Input.Password autoComplete="current-password" />
|
|
398
|
+
</Form.Item>
|
|
399
|
+
</div>
|
|
400
|
+
<Space className="webdav-sync-form-actions">
|
|
401
|
+
<Button
|
|
402
|
+
icon={<SafetyCertificateOutlined />}
|
|
403
|
+
loading={actionLoading == 'test'}
|
|
404
|
+
disabled={busy && actionLoading != 'test'}
|
|
405
|
+
onClick={handleTest}
|
|
406
|
+
>
|
|
407
|
+
测试连接
|
|
408
|
+
</Button>
|
|
409
|
+
<Button
|
|
410
|
+
type="primary"
|
|
411
|
+
icon={<CloudSyncOutlined />}
|
|
412
|
+
loading={actionLoading == 'connect'}
|
|
413
|
+
disabled={busy && actionLoading != 'connect'}
|
|
414
|
+
onClick={handleConnect}
|
|
415
|
+
>
|
|
416
|
+
保存连接
|
|
417
|
+
</Button>
|
|
418
|
+
</Space>
|
|
419
|
+
</Form>
|
|
420
|
+
</Card>
|
|
421
|
+
<Card className="webdav-sync-note" bordered={false}>
|
|
422
|
+
<SafetyCertificateOutlined className="webdav-sync-note-icon" />
|
|
423
|
+
<Title level={5}>凭据只留在本机</Title>
|
|
424
|
+
<Paragraph>
|
|
425
|
+
账号密码保存在 Whistle 插件数据目录,页面不会读取明文密码,也不会写入远端同步文件。
|
|
426
|
+
</Paragraph>
|
|
427
|
+
<div className="webdav-sync-file-tree">
|
|
428
|
+
<div>专用 WebDAV 目录</div>
|
|
429
|
+
<div>├─ {status ? status.remoteFileName : 'whistle-mock-plugin-manifest.json'}</div>
|
|
430
|
+
<div>└─ 每个功能模块一个 .json.gz</div>
|
|
431
|
+
</div>
|
|
432
|
+
</Card>
|
|
433
|
+
</div>
|
|
434
|
+
) : (
|
|
435
|
+
<div className="webdav-sync-connected">
|
|
436
|
+
<Card className="webdav-sync-connection-card" bordered={false}>
|
|
437
|
+
<Descriptions title="连接信息" column={{ xs: 1, sm: 2 }} size="small">
|
|
438
|
+
<Descriptions.Item label="目录地址" span={2}>
|
|
439
|
+
<Text className="webdav-sync-break-all" code>{status.endpoint}</Text>
|
|
440
|
+
</Descriptions.Item>
|
|
441
|
+
<Descriptions.Item label="用户名">{status.username}</Descriptions.Item>
|
|
442
|
+
<Descriptions.Item label="模块清单">{status.remoteFileName}</Descriptions.Item>
|
|
443
|
+
</Descriptions>
|
|
444
|
+
<Popconfirm
|
|
445
|
+
title="确定断开 WebDAV?"
|
|
446
|
+
description="只会清除本机连接信息,不会删除远端同步文件。"
|
|
447
|
+
okText="断开"
|
|
448
|
+
cancelText="取消"
|
|
449
|
+
onConfirm={handleDisconnect}
|
|
450
|
+
>
|
|
451
|
+
<Button
|
|
452
|
+
type="link"
|
|
453
|
+
danger
|
|
454
|
+
className="webdav-sync-disconnect"
|
|
455
|
+
icon={<DisconnectOutlined />}
|
|
456
|
+
loading={actionLoading == 'disconnect'}
|
|
457
|
+
disabled={busy && actionLoading != 'disconnect'}
|
|
458
|
+
>
|
|
459
|
+
断开连接
|
|
460
|
+
</Button>
|
|
461
|
+
</Popconfirm>
|
|
462
|
+
</Card>
|
|
463
|
+
<div className="webdav-sync-module-grid">
|
|
464
|
+
<ModuleSelector
|
|
465
|
+
eyebrow="LOCAL MODULES"
|
|
466
|
+
title="本地模块"
|
|
467
|
+
modules={status.localModules || []}
|
|
468
|
+
selectedIds={selectedPushIds}
|
|
469
|
+
onChange={setSelectedPushIds}
|
|
470
|
+
emptyText="暂无可推送的功能模块"
|
|
471
|
+
action="推送已选模块"
|
|
472
|
+
actionIcon={<UploadOutlined />}
|
|
473
|
+
actionLoading={actionLoading == 'push'}
|
|
474
|
+
disabled={busy}
|
|
475
|
+
onAction={() => handlePush(false)}
|
|
476
|
+
/>
|
|
477
|
+
<ModuleSelector
|
|
478
|
+
eyebrow="REMOTE MODULES"
|
|
479
|
+
title="远端模块"
|
|
480
|
+
modules={status.remoteModules || []}
|
|
481
|
+
selectedIds={selectedPullIds}
|
|
482
|
+
onChange={setSelectedPullIds}
|
|
483
|
+
emptyText={status.remoteFileInvalid ? '远端模块清单已损坏' : '远端暂无模块,请先推送或刷新'}
|
|
484
|
+
action="拉取已选模块"
|
|
485
|
+
actionIcon={<DownloadOutlined />}
|
|
486
|
+
actionClassName="webdav-sync-pull-button"
|
|
487
|
+
actionLoading={actionLoading == 'pull'}
|
|
488
|
+
disabled={busy}
|
|
489
|
+
onAction={() => handlePull(false)}
|
|
490
|
+
extra={(
|
|
491
|
+
<Button
|
|
492
|
+
shape="circle"
|
|
493
|
+
icon={<ReloadOutlined />}
|
|
494
|
+
loading={actionLoading == 'refresh'}
|
|
495
|
+
disabled={busy && actionLoading != 'refresh'}
|
|
496
|
+
onClick={handleRefresh}
|
|
497
|
+
aria-label="刷新远端模块"
|
|
498
|
+
/>
|
|
499
|
+
)}
|
|
500
|
+
/>
|
|
501
|
+
</div>
|
|
502
|
+
<Alert
|
|
503
|
+
className="webdav-sync-manual-note"
|
|
504
|
+
type="info"
|
|
505
|
+
showIcon
|
|
506
|
+
message="只同步已选择的模块"
|
|
507
|
+
description={`远端清单更新时间:${formatTime(status.remoteUpdatedAt)}。拉取只替换同 ID 的已选模块及其接口,未选择的本地模块保持不变。`}
|
|
508
|
+
/>
|
|
509
|
+
</div>
|
|
510
|
+
)}
|
|
511
|
+
</Spin>
|
|
512
|
+
</Modal>
|
|
513
|
+
);
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
export default WebDavSyncModal;
|
|
@@ -6,8 +6,9 @@ import React, { useState, useEffect } from 'react';
|
|
|
6
6
|
import { useRef } from 'react';
|
|
7
7
|
import { useHistory } from 'react-router-dom';
|
|
8
8
|
import AppLayout from '../components/AppLayout';
|
|
9
|
+
import WebDavSyncModal from '../components/WebDavSyncModal';
|
|
9
10
|
import { Card, Row, Col, Button, Modal, Form, Input, message, Switch, Empty, Spin, Typography, Tooltip, Badge, Select, Pagination, Table, Space, Tag, Checkbox } from 'antd';
|
|
10
|
-
import { PlusOutlined, EditOutlined, DeleteOutlined, ApiOutlined, ExportOutlined, ImportOutlined, InfoCircleOutlined, CalendarOutlined, SortAscendingOutlined, AppstoreOutlined, UnorderedListOutlined } from '@ant-design/icons';
|
|
11
|
+
import { PlusOutlined, EditOutlined, DeleteOutlined, ApiOutlined, ExportOutlined, ImportOutlined, InfoCircleOutlined, CalendarOutlined, SortAscendingOutlined, AppstoreOutlined, UnorderedListOutlined, CloudSyncOutlined } from '@ant-design/icons';
|
|
11
12
|
import '../styles/mock-data.css';
|
|
12
13
|
import axios from 'axios';
|
|
13
14
|
|
|
@@ -35,6 +36,8 @@ const MockData = () => {
|
|
|
35
36
|
const [showExportModal, setShowExportModal] = useState(false);
|
|
36
37
|
const [selectedExportIds, setSelectedExportIds] = useState([]);
|
|
37
38
|
const [exporting, setExporting] = useState(false);
|
|
39
|
+
const [webDavSyncEnabled, setWebDavSyncEnabled] = useState(false);
|
|
40
|
+
const [showWebDavModal, setShowWebDavModal] = useState(false);
|
|
38
41
|
|
|
39
42
|
const [loading, setLoading] = useState(true);
|
|
40
43
|
const [form] = Form.useForm();
|
|
@@ -61,8 +64,19 @@ const MockData = () => {
|
|
|
61
64
|
// 加载功能列表
|
|
62
65
|
useEffect(() => {
|
|
63
66
|
fetchFeatures();
|
|
67
|
+
fetchWebDavSetting();
|
|
64
68
|
}, []);
|
|
65
69
|
|
|
70
|
+
const fetchWebDavSetting = async () => {
|
|
71
|
+
try {
|
|
72
|
+
const response = await axios.get('/cgi-bin/settings');
|
|
73
|
+
setWebDavSyncEnabled(response.data.webDavSyncEnabled == true);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
console.error('读取 WebDAV 同步设置失败:', error);
|
|
76
|
+
setWebDavSyncEnabled(false);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
66
80
|
// 键盘快捷键监听
|
|
67
81
|
useEffect(() => {
|
|
68
82
|
const handleSave = () => {
|
|
@@ -788,6 +802,16 @@ const MockData = () => {
|
|
|
788
802
|
>
|
|
789
803
|
导出功能
|
|
790
804
|
</Button>
|
|
805
|
+
{webDavSyncEnabled && (
|
|
806
|
+
<Button
|
|
807
|
+
type="primary"
|
|
808
|
+
icon={<CloudSyncOutlined />}
|
|
809
|
+
onClick={() => setShowWebDavModal(true)}
|
|
810
|
+
style={{ backgroundColor: '#0891b2', borderColor: '#0891b2' }}
|
|
811
|
+
>
|
|
812
|
+
WebDAV
|
|
813
|
+
</Button>
|
|
814
|
+
)}
|
|
791
815
|
</div>
|
|
792
816
|
</div>
|
|
793
817
|
|
|
@@ -1082,8 +1106,17 @@ const MockData = () => {
|
|
|
1082
1106
|
</Button>
|
|
1083
1107
|
</div>
|
|
1084
1108
|
</Modal>
|
|
1109
|
+
|
|
1110
|
+
<WebDavSyncModal
|
|
1111
|
+
open={showWebDavModal}
|
|
1112
|
+
onClose={() => setShowWebDavModal(false)}
|
|
1113
|
+
onPulled={async () => {
|
|
1114
|
+
await fetchFeatures();
|
|
1115
|
+
await refreshCacheAfterUpdate();
|
|
1116
|
+
}}
|
|
1117
|
+
/>
|
|
1085
1118
|
</AppLayout>
|
|
1086
1119
|
);
|
|
1087
1120
|
};
|
|
1088
1121
|
|
|
1089
|
-
export default MockData;
|
|
1122
|
+
export default MockData;
|
package/ui/src/pages/Settings.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React, { useState, useEffect } from 'react';
|
|
2
2
|
import AppLayout from '../components/AppLayout';
|
|
3
3
|
import { Card, Typography, Switch, Space, Modal, Button, message, Radio } from 'antd';
|
|
4
|
-
import { QuestionCircleOutlined } from '@ant-design/icons';
|
|
4
|
+
import { CloudSyncOutlined, QuestionCircleOutlined } from '@ant-design/icons';
|
|
5
5
|
import axios from 'axios';
|
|
6
6
|
import '../styles/settings.css';
|
|
7
7
|
|
|
@@ -16,6 +16,8 @@ const Settings = () => {
|
|
|
16
16
|
const [interfaceFormDisplayMode, setInterfaceFormDisplayMode] = useState('modal');
|
|
17
17
|
const [interfaceParamMatcherEnabled, setInterfaceParamMatcherEnabled] = useState(true);
|
|
18
18
|
const [responseParamMatcherEnabled, setResponseParamMatcherEnabled] = useState(true);
|
|
19
|
+
const [webDavSyncEnabled, setWebDavSyncEnabled] = useState(false);
|
|
20
|
+
const [webDavSettingSaving, setWebDavSettingSaving] = useState(false);
|
|
19
21
|
const [currentTheme, setCurrentTheme] = useState('light');
|
|
20
22
|
|
|
21
23
|
useEffect(() => {
|
|
@@ -31,6 +33,7 @@ const Settings = () => {
|
|
|
31
33
|
const rpm = data.responseParamMatcherEnabled !== false;
|
|
32
34
|
setInterfaceParamMatcherEnabled(ipm);
|
|
33
35
|
setResponseParamMatcherEnabled(rpm);
|
|
36
|
+
setWebDavSyncEnabled(data.webDavSyncEnabled == true);
|
|
34
37
|
localStorage.setItem('interfaceParamMatcherEnabled', String(ipm));
|
|
35
38
|
localStorage.setItem('responseParamMatcherEnabled', String(rpm));
|
|
36
39
|
window.dispatchEvent(new Event('storage'));
|
|
@@ -81,6 +84,21 @@ const Settings = () => {
|
|
|
81
84
|
window.dispatchEvent(new Event('storage'));
|
|
82
85
|
};
|
|
83
86
|
|
|
87
|
+
const handleWebDavSyncChange = async (checked) => {
|
|
88
|
+
const previous = webDavSyncEnabled;
|
|
89
|
+
setWebDavSyncEnabled(checked);
|
|
90
|
+
setWebDavSettingSaving(true);
|
|
91
|
+
try {
|
|
92
|
+
await axios.put('/cgi-bin/settings', { webDavSyncEnabled: checked });
|
|
93
|
+
message.success(`WebDAV 同步功能已${checked ? '开启' : '关闭'}`);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
setWebDavSyncEnabled(previous);
|
|
96
|
+
message.error('保存 WebDAV 同步设置失败');
|
|
97
|
+
} finally {
|
|
98
|
+
setWebDavSettingSaving(false);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
84
102
|
const handleThemeChange = (e) => {
|
|
85
103
|
const newTheme = e.target.value;
|
|
86
104
|
setCurrentTheme(newTheme);
|
|
@@ -253,6 +271,30 @@ const Settings = () => {
|
|
|
253
271
|
</div>
|
|
254
272
|
</Card>
|
|
255
273
|
|
|
274
|
+
<Card title="同步设置" className="settings-card" style={{ marginTop: 24 }}>
|
|
275
|
+
<div className="settings-item">
|
|
276
|
+
<div className="settings-item-header">
|
|
277
|
+
<div>
|
|
278
|
+
<div className="settings-item-title">
|
|
279
|
+
<CloudSyncOutlined style={{ marginRight: 8 }} />
|
|
280
|
+
WebDAV 同步
|
|
281
|
+
</div>
|
|
282
|
+
<div className="settings-item-description">
|
|
283
|
+
开启后,在功能模块管理页显示 WebDAV 入口;关闭不会清除已保存的连接配置
|
|
284
|
+
</div>
|
|
285
|
+
</div>
|
|
286
|
+
<Space>
|
|
287
|
+
<Text type="secondary">{webDavSyncEnabled ? '已开启' : '已关闭'}</Text>
|
|
288
|
+
<Switch
|
|
289
|
+
checked={webDavSyncEnabled}
|
|
290
|
+
loading={webDavSettingSaving}
|
|
291
|
+
onChange={handleWebDavSyncChange}
|
|
292
|
+
/>
|
|
293
|
+
</Space>
|
|
294
|
+
</div>
|
|
295
|
+
</div>
|
|
296
|
+
</Card>
|
|
297
|
+
|
|
256
298
|
<Card title="主题设置" className="settings-card" style={{ marginTop: 24 }}>
|
|
257
299
|
<div className="settings-item">
|
|
258
300
|
<div className="settings-item-header">
|