mock-service-cli 4.3.1 → 4.4.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/README.md +18 -4
- package/dist/file-explorer-login.html +59 -0
- package/dist/file-explorer.html +125 -21
- package/dist/runtime.js +9 -6
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -73,8 +73,9 @@ npx mock-service-cli [options] [path]
|
|
|
73
73
|
| `-R` 或 `--static-server` | 启用静态服务器,指定静态资源目录 | - |
|
|
74
74
|
| `-w` 或 `--open` | 自动打开 API 概览页、文件浏览器页 | false |
|
|
75
75
|
| `-e` 或 `--explorer` | 启用文件浏览器服务器,指定要浏览的目录 | ./ |
|
|
76
|
-
| `--edit` |
|
|
77
|
-
| `--
|
|
76
|
+
| `--edit` | 启用文件浏览器的新建、重命名、删除和上传操作 | false |
|
|
77
|
+
| `--auth <password>` | 为文件浏览器启用密码认证(仅可搭配 `--explorer`) | - |
|
|
78
|
+
| `--host [allowlist-file]` | 暴露全部 IPv4 网卡;传入文件时仅允许白名单 IP 访问 | false |
|
|
78
79
|
|
|
79
80
|
## 📖 使用示例
|
|
80
81
|
|
|
@@ -136,13 +137,26 @@ mock-service-cli -e ./ -p 9090
|
|
|
136
137
|
# 启用文件编辑操作
|
|
137
138
|
mock-service-cli -e ./ --edit
|
|
138
139
|
|
|
140
|
+
# 启用文件浏览器密码认证
|
|
141
|
+
mock-service-cli -e ./ --auth 'local-password'
|
|
142
|
+
|
|
139
143
|
# 暴露到局域网(编辑操作仍需额外传入 --edit)
|
|
140
144
|
mock-service-cli -e ./ --host
|
|
145
|
+
|
|
146
|
+
# 仅向白名单 IP 暴露服务
|
|
147
|
+
mock-service-cli -e ./ --host ./allowed-ips.txt
|
|
141
148
|
```
|
|
142
149
|
|
|
143
150
|
默认情况下,Mock、SPA、Static 和 File Explorer 服务只监听 `127.0.0.1`。
|
|
144
|
-
`--host` 会使这些服务监听所有 IPv4
|
|
145
|
-
|
|
151
|
+
`--host` 会使这些服务监听所有 IPv4 网卡,请仅在可信网络中使用。传入白名单文件时,服务仍监听全部 IPv4 网卡,
|
|
152
|
+
但仅允许 `localhost`、`127.0.0.1`、`::1` 及文件中的 IP/CIDR 访问;空行和行首 `#` 注释会忽略,非法或空白名单会阻止启动。
|
|
153
|
+
该规则同时作用于 Mock、SPA、Static、File Explorer 和 Socket 服务。
|
|
154
|
+
|
|
155
|
+
文件浏览器默认只读;必须在启动时传入 `--edit` 才能创建、重命名、删除或上传。上传文件支持多选,上传文件夹会保留目录层级;
|
|
156
|
+
同名路径不会覆盖,其他无冲突文件仍会继续上传。单文件最大 20MB,单次请求最大 20 个文件和 100MB。
|
|
157
|
+
|
|
158
|
+
`--auth` 会在文件浏览器中显示登录页,并在每次页面加载和每个 API 请求时验证密码。密码仅保存在当前浏览器标签页的
|
|
159
|
+
`sessionStorage` 中,但命令行参数仍可能暴露在 shell 历史和进程列表里,因此只适用于可信本机或局域网环境。
|
|
146
160
|
|
|
147
161
|
## 📝 编写 Mock 文件
|
|
148
162
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>文件浏览器登录</title>
|
|
7
|
+
<style>
|
|
8
|
+
* { box-sizing: border-box; }
|
|
9
|
+
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #f4f6f8; color: #202124; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
10
|
+
main { width: min(360px, calc(100vw - 32px)); padding: 28px; background: #fff; border: 1px solid #dde1e6; border-radius: 8px; box-shadow: 0 12px 28px rgba(0, 0, 0, 0.08); }
|
|
11
|
+
h1 { margin: 0 0 22px; font-size: 20px; }
|
|
12
|
+
label { display: block; margin-bottom: 8px; font-size: 14px; }
|
|
13
|
+
input { width: 100%; height: 38px; padding: 8px 10px; border: 1px solid #b9c1cb; border-radius: 4px; font: inherit; }
|
|
14
|
+
button { width: 100%; height: 38px; margin-top: 16px; border: 0; border-radius: 4px; background: #276ef1; color: #fff; cursor: pointer; font: inherit; }
|
|
15
|
+
button:disabled { opacity: 0.6; cursor: wait; }
|
|
16
|
+
#error { min-height: 20px; margin: 12px 0 0; color: #c62828; font-size: 13px; }
|
|
17
|
+
</style>
|
|
18
|
+
</head>
|
|
19
|
+
<body>
|
|
20
|
+
<main>
|
|
21
|
+
<h1>文件浏览器</h1>
|
|
22
|
+
<form id="loginForm">
|
|
23
|
+
<label for="password">访问密码</label>
|
|
24
|
+
<input id="password" type="password" autocomplete="current-password" required autofocus />
|
|
25
|
+
<button id="submitButton" type="submit">登录</button>
|
|
26
|
+
<p id="error" role="alert"></p>
|
|
27
|
+
</form>
|
|
28
|
+
</main>
|
|
29
|
+
<script>
|
|
30
|
+
const storageKey = 'mock-service-cli.file-explorer.password';
|
|
31
|
+
const form = document.getElementById('loginForm');
|
|
32
|
+
const passwordInput = document.getElementById('password');
|
|
33
|
+
const submitButton = document.getElementById('submitButton');
|
|
34
|
+
const errorElement = document.getElementById('error');
|
|
35
|
+
|
|
36
|
+
form.addEventListener('submit', async event => {
|
|
37
|
+
event.preventDefault();
|
|
38
|
+
const password = passwordInput.value;
|
|
39
|
+
submitButton.disabled = true;
|
|
40
|
+
errorElement.textContent = '';
|
|
41
|
+
try {
|
|
42
|
+
const response = await fetch('/__api/auth/verify', {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
headers: { 'Content-Type': 'application/json', 'X-File-Explorer-Password': password },
|
|
45
|
+
body: JSON.stringify({ password })
|
|
46
|
+
});
|
|
47
|
+
if (!response.ok) throw new Error('密码不正确');
|
|
48
|
+
sessionStorage.setItem(storageKey, password);
|
|
49
|
+
window.location.replace('/');
|
|
50
|
+
} catch (error) {
|
|
51
|
+
errorElement.textContent = error.message || '登录失败';
|
|
52
|
+
passwordInput.select();
|
|
53
|
+
} finally {
|
|
54
|
+
submitButton.disabled = false;
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
</script>
|
|
58
|
+
</body>
|
|
59
|
+
</html>
|
package/dist/file-explorer.html
CHANGED
|
@@ -1010,6 +1010,14 @@
|
|
|
1010
1010
|
<button class="toolbar-action-btn edit-only edit-inline" onclick="createEntry('file')" title="新建文件">
|
|
1011
1011
|
新建文件
|
|
1012
1012
|
</button>
|
|
1013
|
+
<button class="toolbar-action-btn edit-only edit-inline" onclick="document.getElementById('uploadFilesInput').click()" title="上传文件">
|
|
1014
|
+
上传文件
|
|
1015
|
+
</button>
|
|
1016
|
+
<button class="toolbar-action-btn edit-only edit-inline" onclick="document.getElementById('uploadFolderInput').click()" title="上传文件夹">
|
|
1017
|
+
上传文件夹
|
|
1018
|
+
</button>
|
|
1019
|
+
<input id="uploadFilesInput" type="file" multiple hidden onchange="uploadSelectedFiles(this.files)" />
|
|
1020
|
+
<input id="uploadFolderInput" type="file" webkitdirectory directory hidden onchange="uploadSelectedFiles(this.files)" />
|
|
1013
1021
|
<button
|
|
1014
1022
|
class="toolbar-action-btn danger edit-only edit-inline"
|
|
1015
1023
|
id="deleteSelectedBtn"
|
|
@@ -1093,7 +1101,7 @@
|
|
|
1093
1101
|
let searchQuery = '';
|
|
1094
1102
|
let sortBy = 'name';
|
|
1095
1103
|
let sortAscending = true;
|
|
1096
|
-
let showHiddenFiles =
|
|
1104
|
+
let showHiddenFiles = false;
|
|
1097
1105
|
let showBirthtime = false;
|
|
1098
1106
|
let currentPreviewContent = '';
|
|
1099
1107
|
let currentPreviewObjectUrl = null;
|
|
@@ -1109,6 +1117,11 @@
|
|
|
1109
1117
|
let virtualRenderFrame = null;
|
|
1110
1118
|
let virtualState = null;
|
|
1111
1119
|
let fileGridResizeObserver = null;
|
|
1120
|
+
let healthCheckTimer = null;
|
|
1121
|
+
let serviceWasUnavailable = false;
|
|
1122
|
+
|
|
1123
|
+
const EXPLORER_AUTH_STORAGE_KEY = 'mock-service-cli.file-explorer.password';
|
|
1124
|
+
const EXPLORER_PATH_STORAGE_KEY = 'mock-service-cli.file-explorer.path';
|
|
1112
1125
|
|
|
1113
1126
|
const LIST_ROW_HEIGHT = 37;
|
|
1114
1127
|
const GRID_ROW_HEIGHT = 132;
|
|
@@ -1151,6 +1164,53 @@
|
|
|
1151
1164
|
fileGridResizeObserver.disconnect();
|
|
1152
1165
|
fileGridResizeObserver = null;
|
|
1153
1166
|
}
|
|
1167
|
+
if (healthCheckTimer) {
|
|
1168
|
+
clearInterval(healthCheckTimer);
|
|
1169
|
+
healthCheckTimer = null;
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
function redirectToLogin() {
|
|
1174
|
+
sessionStorage.removeItem(EXPLORER_AUTH_STORAGE_KEY);
|
|
1175
|
+
window.location.replace('/__login');
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
async function explorerFetch(url, options = {}) {
|
|
1179
|
+
const headers = new Headers(options.headers || {});
|
|
1180
|
+
const password = sessionStorage.getItem(EXPLORER_AUTH_STORAGE_KEY);
|
|
1181
|
+
if (password) headers.set('X-File-Explorer-Password', password);
|
|
1182
|
+
const response = await fetch(url, { ...options, headers });
|
|
1183
|
+
if (response.status === 401) redirectToLogin();
|
|
1184
|
+
return response;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
async function verifyExplorerAuth() {
|
|
1188
|
+
const password = sessionStorage.getItem(EXPLORER_AUTH_STORAGE_KEY);
|
|
1189
|
+
try {
|
|
1190
|
+
const response = await explorerFetch('/__api/auth/verify', {
|
|
1191
|
+
method: 'POST',
|
|
1192
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1193
|
+
body: JSON.stringify({ password })
|
|
1194
|
+
});
|
|
1195
|
+
if (!response.ok) return false;
|
|
1196
|
+
return Boolean((await response.json()).success);
|
|
1197
|
+
} catch (error) {
|
|
1198
|
+
console.error('Failed to verify file explorer authentication:', error);
|
|
1199
|
+
return false;
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
function startHealthCheck() {
|
|
1204
|
+
healthCheckTimer = setInterval(async () => {
|
|
1205
|
+
try {
|
|
1206
|
+
const response = await explorerFetch('/__api/health');
|
|
1207
|
+
if (!response.ok) throw new Error(`Unexpected status ${response.status}`);
|
|
1208
|
+
if (serviceWasUnavailable) window.location.reload();
|
|
1209
|
+
serviceWasUnavailable = false;
|
|
1210
|
+
} catch (error) {
|
|
1211
|
+
serviceWasUnavailable = true;
|
|
1212
|
+
}
|
|
1213
|
+
}, 2000);
|
|
1154
1214
|
}
|
|
1155
1215
|
|
|
1156
1216
|
function syncEditModeUI() {
|
|
@@ -1167,8 +1227,9 @@
|
|
|
1167
1227
|
|
|
1168
1228
|
async function loadExplorerConfig() {
|
|
1169
1229
|
try {
|
|
1170
|
-
const response = await
|
|
1230
|
+
const response = await explorerFetch('/__api/config');
|
|
1171
1231
|
const config = await response.json();
|
|
1232
|
+
if (!response.ok || config.error) throw new Error(config.error || 'Failed to load configuration');
|
|
1172
1233
|
editMode = Boolean(config.editMode);
|
|
1173
1234
|
} catch (error) {
|
|
1174
1235
|
console.warn('Failed to load file explorer configuration:', error);
|
|
@@ -1308,8 +1369,10 @@
|
|
|
1308
1369
|
});
|
|
1309
1370
|
fileGridResizeObserver.observe(fileGrid);
|
|
1310
1371
|
|
|
1372
|
+
if (!(await verifyExplorerAuth())) return;
|
|
1311
1373
|
await loadExplorerConfig();
|
|
1312
|
-
|
|
1374
|
+
startHealthCheck();
|
|
1375
|
+
loadDirectory(sessionStorage.getItem(EXPLORER_PATH_STORAGE_KEY) || '/');
|
|
1313
1376
|
});
|
|
1314
1377
|
|
|
1315
1378
|
window.addEventListener('beforeunload', cleanupEventListeners);
|
|
@@ -1394,7 +1457,7 @@
|
|
|
1394
1457
|
fileGrid.innerHTML = '<div class="loading"><div class="spinner"></div><p>加载中...</p></div>';
|
|
1395
1458
|
|
|
1396
1459
|
try {
|
|
1397
|
-
const response = await
|
|
1460
|
+
const response = await explorerFetch(`/__api/list?path=${encodeURIComponent(path)}`, {
|
|
1398
1461
|
signal: directoryRequestController.signal
|
|
1399
1462
|
});
|
|
1400
1463
|
const data = await response.json();
|
|
@@ -1405,6 +1468,7 @@
|
|
|
1405
1468
|
if (requestId !== directoryRequestId) return;
|
|
1406
1469
|
|
|
1407
1470
|
currentPath = data.currentPath;
|
|
1471
|
+
sessionStorage.setItem(EXPLORER_PATH_STORAGE_KEY, currentPath);
|
|
1408
1472
|
allFiles = data.files || [];
|
|
1409
1473
|
|
|
1410
1474
|
if (document.getElementById('searchInput')) {
|
|
@@ -1548,7 +1612,7 @@
|
|
|
1548
1612
|
|
|
1549
1613
|
async function openInExplorer(path) {
|
|
1550
1614
|
try {
|
|
1551
|
-
const response = await
|
|
1615
|
+
const response = await explorerFetch('/__api/open-in-explorer', {
|
|
1552
1616
|
method: 'POST',
|
|
1553
1617
|
headers: {
|
|
1554
1618
|
'Content-Type': 'application/json'
|
|
@@ -1568,7 +1632,7 @@
|
|
|
1568
1632
|
}
|
|
1569
1633
|
|
|
1570
1634
|
async function requestJson(url, options) {
|
|
1571
|
-
const response = await
|
|
1635
|
+
const response = await explorerFetch(url, options);
|
|
1572
1636
|
const data = await response.json();
|
|
1573
1637
|
if (!response.ok || data.error) {
|
|
1574
1638
|
throw new Error(data.error || '请求失败');
|
|
@@ -1576,6 +1640,34 @@
|
|
|
1576
1640
|
return data;
|
|
1577
1641
|
}
|
|
1578
1642
|
|
|
1643
|
+
async function uploadSelectedFiles(files) {
|
|
1644
|
+
const input = document.activeElement;
|
|
1645
|
+
if (!files || files.length === 0) return;
|
|
1646
|
+
const formData = new FormData();
|
|
1647
|
+
formData.append('parentPath', currentPath);
|
|
1648
|
+
Array.from(files).forEach(file => {
|
|
1649
|
+
formData.append('files', file, file.webkitRelativePath || file.name);
|
|
1650
|
+
});
|
|
1651
|
+
|
|
1652
|
+
try {
|
|
1653
|
+
const response = await explorerFetch('/__api/upload', { method: 'POST', body: formData });
|
|
1654
|
+
const result = await response.json();
|
|
1655
|
+
if (!response.ok && response.status !== 207) throw new Error(result.error || '上传失败');
|
|
1656
|
+
const messages = [];
|
|
1657
|
+
if (result.uploaded.length) messages.push(`已上传 ${result.uploaded.length} 项`);
|
|
1658
|
+
if (result.failed.length) messages.push(`${result.failed.length} 项未上传: ${result.failed.map(item => item.name).join(', ')}`);
|
|
1659
|
+
showToast(messages.join(';'));
|
|
1660
|
+
await loadDirectory(currentPath);
|
|
1661
|
+
} catch (error) {
|
|
1662
|
+
console.error('上传失败:', error);
|
|
1663
|
+
alert(`上传失败: ${error.message}`);
|
|
1664
|
+
} finally {
|
|
1665
|
+
document.getElementById('uploadFilesInput').value = '';
|
|
1666
|
+
document.getElementById('uploadFolderInput').value = '';
|
|
1667
|
+
if (input && input.blur) input.blur();
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1579
1671
|
async function createEntry(type) {
|
|
1580
1672
|
const label = type === 'directory' ? '文件夹' : '文件';
|
|
1581
1673
|
const name = window.prompt(`请输入${label}名称`);
|
|
@@ -1638,14 +1730,23 @@
|
|
|
1638
1730
|
}
|
|
1639
1731
|
}
|
|
1640
1732
|
|
|
1641
|
-
function downloadFile(filePath, fileName) {
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1733
|
+
async function downloadFile(filePath, fileName) {
|
|
1734
|
+
try {
|
|
1735
|
+
const response = await explorerFetch(`/__api/file?path=${encodeURIComponent(filePath)}&download=1`);
|
|
1736
|
+
if (!response.ok) throw new Error('下载失败');
|
|
1737
|
+
const objectUrl = URL.createObjectURL(await response.blob());
|
|
1738
|
+
const a = document.createElement('a');
|
|
1739
|
+
a.href = objectUrl;
|
|
1740
|
+
a.download = fileName || '';
|
|
1741
|
+
a.style.display = 'none';
|
|
1742
|
+
document.body.appendChild(a);
|
|
1743
|
+
a.click();
|
|
1744
|
+
a.remove();
|
|
1745
|
+
URL.revokeObjectURL(objectUrl);
|
|
1746
|
+
} catch (error) {
|
|
1747
|
+
console.error('下载失败:', error);
|
|
1748
|
+
alert(`下载失败: ${error.message}`);
|
|
1749
|
+
}
|
|
1649
1750
|
}
|
|
1650
1751
|
|
|
1651
1752
|
async function deletePath(filePath, fileName) {
|
|
@@ -1655,7 +1756,7 @@
|
|
|
1655
1756
|
if (!confirmed) return;
|
|
1656
1757
|
|
|
1657
1758
|
try {
|
|
1658
|
-
const response = await
|
|
1759
|
+
const response = await explorerFetch('/__api/path', {
|
|
1659
1760
|
method: 'DELETE',
|
|
1660
1761
|
headers: {
|
|
1661
1762
|
'Content-Type': 'application/json'
|
|
@@ -2324,7 +2425,6 @@
|
|
|
2324
2425
|
<button class="zoom-btn" onclick="resetImage()">🔄 重置</button>
|
|
2325
2426
|
</div>
|
|
2326
2427
|
<img
|
|
2327
|
-
src="/__api/file?path=${encodeURIComponent(path)}"
|
|
2328
2428
|
class="image-preview"
|
|
2329
2429
|
id="previewImage"
|
|
2330
2430
|
alt="Preview"
|
|
@@ -2332,8 +2432,11 @@
|
|
|
2332
2432
|
>
|
|
2333
2433
|
</div>
|
|
2334
2434
|
`;
|
|
2435
|
+
const imageResponse = await explorerFetch(`/__api/file?path=${encodeURIComponent(path)}`);
|
|
2436
|
+
if (!imageResponse.ok) throw new Error('图片加载失败');
|
|
2437
|
+
currentPreviewObjectUrl = URL.createObjectURL(await imageResponse.blob());
|
|
2438
|
+
document.getElementById('previewImage').src = currentPreviewObjectUrl;
|
|
2335
2439
|
} else if (BINARY_EXTS.includes(ext)) {
|
|
2336
|
-
const downloadUrl = `/__api/file?path=${encodeURIComponent(path)}`;
|
|
2337
2440
|
const fileSize = fileInfo ? formatSize(fileInfo.size) : '未知大小';
|
|
2338
2441
|
|
|
2339
2442
|
body.innerHTML = `
|
|
@@ -2345,13 +2448,13 @@
|
|
|
2345
2448
|
</div>
|
|
2346
2449
|
<div class="empty-state">
|
|
2347
2450
|
<p>此文件类型无法预览内容</p>
|
|
2348
|
-
<p><
|
|
2451
|
+
<p><button class="toolbar-action-btn" id="previewDownloadBtn">点击下载</button></p>
|
|
2349
2452
|
</div>
|
|
2350
2453
|
</div>
|
|
2351
2454
|
`;
|
|
2455
|
+
document.getElementById('previewDownloadBtn').addEventListener('click', () => downloadFile(path, path.split('/').pop()));
|
|
2352
2456
|
} else {
|
|
2353
2457
|
if (isLargeFile) {
|
|
2354
|
-
const downloadUrl = `/__api/file?path=${encodeURIComponent(path)}`;
|
|
2355
2458
|
body.innerHTML = `
|
|
2356
2459
|
<div class="preview-container">
|
|
2357
2460
|
<div class="binary-info">
|
|
@@ -2361,12 +2464,13 @@
|
|
|
2361
2464
|
</div>
|
|
2362
2465
|
<div class="empty-state">
|
|
2363
2466
|
<p>该文件超过 ${formatSize(LARGE_FILE_THRESHOLD)},不建议在线预览</p>
|
|
2364
|
-
<p><
|
|
2467
|
+
<p><button class="toolbar-action-btn" id="previewDownloadBtn">点击下载</button></p>
|
|
2365
2468
|
</div>
|
|
2366
2469
|
</div>
|
|
2367
2470
|
`;
|
|
2471
|
+
document.getElementById('previewDownloadBtn').addEventListener('click', () => downloadFile(path, path.split('/').pop()));
|
|
2368
2472
|
} else {
|
|
2369
|
-
const response = await
|
|
2473
|
+
const response = await explorerFetch(`/__api/file?path=${encodeURIComponent(path)}`);
|
|
2370
2474
|
const contentType = response.headers.get('content-type');
|
|
2371
2475
|
|
|
2372
2476
|
if (contentType && contentType.includes('application/json')) {
|