mock-service-cli 4.4.0 → 4.5.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 CHANGED
@@ -37,6 +37,12 @@ npm install --global mock-service-cli
37
37
  brew install mock-service-cli
38
38
  ```
39
39
 
40
+ 需要 RAR、7z、bzip2、xz 等高级归档格式时,安装 Ultra 版。它与轻量版使用相同的命令,不能同时全局安装:
41
+
42
+ ```bash
43
+ npm install --global mock-service-cli-ultra
44
+ ```
45
+
40
46
  ### 局部安装
41
47
 
42
48
  ```bash
@@ -71,6 +77,7 @@ npx mock-service-cli [options] [path]
71
77
  | `-P` 或 `--web-port` | Web 服务器端口 | 9090 |
72
78
  | `-b` 或 `--web-baseurl` | 指定 SPA Web 服务器的公共路径 | - |
73
79
  | `-R` 或 `--static-server` | 启用静态服务器,指定静态资源目录 | - |
80
+ | `--static-config <file>` | 静态服务器的 JSON 配置:热更新、HTTPS、挂载、代理与 SPA 回退 | - |
74
81
  | `-w` 或 `--open` | 自动打开 API 概览页、文件浏览器页 | false |
75
82
  | `-e` 或 `--explorer` | 启用文件浏览器服务器,指定要浏览的目录 | ./ |
76
83
  | `--edit` | 启用文件浏览器的新建、重命名、删除和上传操作 | false |
@@ -100,8 +107,18 @@ mock-service-cli -f ./mock.js
100
107
  ```bash
101
108
  # 启动静态服务器
102
109
  mock-service-cli -R ./public
110
+
111
+ # 使用 Live Server 风格的热更新配置
112
+ mock-service-cli -R ./public --static-config ./static-server.json
103
113
  ```
104
114
 
115
+ 静态服务器会监听资源变化并向 HTML 页面注入热更新客户端:CSS 文件更新时替换样式表,其余变更刷新页面。
116
+ 默认不打开浏览器;在配置中设置 `"open": true` 或提供页面路径即可打开。完整配置字段和示例见
117
+ [`docs/static-server.config.example.json`](./docs/static-server.config.example.json):支持忽略规则、SPA 回退、挂载目录、代理、HTTPS、CORS、响应头和自定义浏览器命令。
118
+ `-p`、`--host`、`-A` 和 `--proxy-options` 仍可使用,且同名 CLI 配置优先。
119
+ 未设置 `spaFallback` 时,根目录和子目录会显示可点击的目录索引,不会自动加载 `index.html`;用户可直接访问该文件。
120
+ 只有显式设置 `"spaFallback": "/index.html"`(或其他入口)时,未命中的路由才会返回 SPA 入口页。
121
+
105
122
  ### Web 服务器 (SPA)
106
123
 
107
124
  ```bash
@@ -153,7 +170,13 @@ mock-service-cli -e ./ --host ./allowed-ips.txt
153
170
  该规则同时作用于 Mock、SPA、Static、File Explorer 和 Socket 服务。
154
171
 
155
172
  文件浏览器默认只读;必须在启动时传入 `--edit` 才能创建、重命名、删除或上传。上传文件支持多选,上传文件夹会保留目录层级;
156
- 同名路径不会覆盖,其他无冲突文件仍会继续上传。单文件最大 20MB,单次请求最大 20 个文件和 100MB。
173
+ 同名路径不会覆盖,其他无冲突文件仍会继续上传。单文件和单次请求最大 2GB,单次最多 100 个文件;
174
+ 文件夹上传会按最多 100 个文件且 1GB 自动分批,依次完成上传。大文件会先写入系统临时目录再复制到目标目录,
175
+ 上传时请确保临时目录和目标磁盘合计至少有文件大小两倍的可用空间。
176
+
177
+ 编辑模式下还可压缩选中项、预览压缩包目录和解压压缩包。轻量版创建压缩包支持 ZIP 和 TAR.GZ,预览和解压支持 ZIP、TAR 与 TAR.GZ;归档操作在后台执行,可取消;单个压缩包及解压后的总大小上限为 2GB,压缩包内最多 10,000 项,且会拒绝加密包、路径穿越、符号链接和目标冲突。
178
+
179
+ Ultra 版额外支持 RAR、7z、gzip、bzip2 与 xz 系列的预览和解压;RAR 为只读支持,不能创建。Ultra 包会额外安装约 12MB 的跨平台 7-Zip 二进制,RAR 通过只读解码器处理;轻量版不包含这些二进制。
157
180
 
158
181
  `--auth` 会在文件浏览器中显示登录页,并在每次页面加载和每个 API 请求时验证密码。密码仅保存在当前浏览器标签页的
159
182
  `sessionStorage` 中,但命令行参数仍可能暴露在 shell 历史和进程列表里,因此只适用于可信本机或局域网环境。
@@ -4,6 +4,7 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>API 概览 - Mock Service</title>
7
+ <link rel="icon" type="image/svg+xml" href="/favicon-api-overview.svg">
7
8
  <style>
8
9
  * {
9
10
  margin: 0;
@@ -0,0 +1 @@
1
+ {"edition":"light"}
@@ -0,0 +1,8 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
2
+ <rect width="64" height="64" rx="14" fill="#0f766e"/>
3
+ <path d="M21 23 32 17l11 6v13l-11 6-11-6V23z" fill="none" stroke="#99f6e4" stroke-width="4" stroke-linejoin="round"/>
4
+ <path d="M32 23v13M21 23l11 6 11-6" stroke="#99f6e4" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
5
+ <circle cx="17" cy="45" r="5" fill="#fbbf24"/>
6
+ <circle cx="47" cy="45" r="5" fill="#fbbf24"/>
7
+ <path d="M21 43h22" stroke="#fbbf24" stroke-width="4" stroke-linecap="round"/>
8
+ </svg>
@@ -0,0 +1,8 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
2
+ <rect width="64" height="64" rx="14" fill="#1e40af"/>
3
+ <path d="M14 20c0-3.3 2.7-6 6-6h11l5 6h8c3.3 0 6 2.7 6 6v8H14V20z" fill="#93c5fd"/>
4
+ <rect x="20" y="31" width="25" height="20" rx="4" fill="#f8fafc"/>
5
+ <path d="M26 31v-4a6.5 6.5 0 0 1 13 0v4" fill="none" stroke="#f8fafc" stroke-width="5" stroke-linecap="round"/>
6
+ <circle cx="32.5" cy="40" r="3" fill="#1e40af"/>
7
+ <path d="M32.5 43v3" stroke="#1e40af" stroke-width="3" stroke-linecap="round"/>
8
+ </svg>
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
2
+ <rect width="64" height="64" rx="14" fill="#1d4ed8"/>
3
+ <path d="M12 22c0-3.3 2.7-6 6-6h11l5 6h12c3.3 0 6 2.7 6 6v17c0 3.3-2.7 6-6 6H18c-3.3 0-6-2.7-6-6V22z" fill="#fbbf24"/>
4
+ <path d="M12 29h40v16c0 3.3-2.7 6-6 6H18c-3.3 0-6-2.7-6-6V29z" fill="#fde68a"/>
5
+ <path d="M21 37h22" stroke="#1d4ed8" stroke-width="4" stroke-linecap="round"/>
6
+ </svg>
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
2
+ <rect width="64" height="64" rx="14" fill="#0f766e"/>
3
+ <path d="M17 19h30v26H17z" fill="none" stroke="#ccfbf1" stroke-width="4" stroke-linejoin="round"/>
4
+ <path d="M24 28h16M24 36h11" stroke="#ccfbf1" stroke-width="4" stroke-linecap="round"/>
5
+ <circle cx="47" cy="45" r="7" fill="#fbbf24"/>
6
+ </svg>
@@ -4,6 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>文件浏览器登录</title>
7
+ <link rel="icon" type="image/svg+xml" href="/favicon-file-explorer-login.svg" />
7
8
  <style>
8
9
  * { box-sizing: border-box; }
9
10
  body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #f4f6f8; color: #202124; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
@@ -4,6 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>文件浏览器 - File Explorer</title>
7
+ <link rel="icon" type="image/svg+xml" href="/favicon-file-explorer.svg" />
7
8
  <style>
8
9
  * {
9
10
  margin: 0;
@@ -1010,14 +1011,29 @@
1010
1011
  <button class="toolbar-action-btn edit-only edit-inline" onclick="createEntry('file')" title="新建文件">
1011
1012
  新建文件
1012
1013
  </button>
1013
- <button class="toolbar-action-btn edit-only edit-inline" onclick="document.getElementById('uploadFilesInput').click()" title="上传文件">
1014
+ <button
1015
+ class="toolbar-action-btn edit-only edit-inline"
1016
+ onclick="document.getElementById('uploadFilesInput').click()"
1017
+ title="上传文件"
1018
+ >
1014
1019
  上传文件
1015
1020
  </button>
1016
- <button class="toolbar-action-btn edit-only edit-inline" onclick="document.getElementById('uploadFolderInput').click()" title="上传文件夹">
1021
+ <button
1022
+ class="toolbar-action-btn edit-only edit-inline"
1023
+ onclick="document.getElementById('uploadFolderInput').click()"
1024
+ title="上传文件夹"
1025
+ >
1017
1026
  上传文件夹
1018
1027
  </button>
1019
1028
  <input id="uploadFilesInput" type="file" multiple hidden onchange="uploadSelectedFiles(this.files)" />
1020
- <input id="uploadFolderInput" type="file" webkitdirectory directory hidden onchange="uploadSelectedFiles(this.files)" />
1029
+ <input
1030
+ id="uploadFolderInput"
1031
+ type="file"
1032
+ webkitdirectory
1033
+ directory
1034
+ hidden
1035
+ onchange="uploadSelectedFiles(this.files)"
1036
+ />
1021
1037
  <button
1022
1038
  class="toolbar-action-btn danger edit-only edit-inline"
1023
1039
  id="deleteSelectedBtn"
@@ -1027,6 +1043,15 @@
1027
1043
  >
1028
1044
  删除选中
1029
1045
  </button>
1046
+ <button
1047
+ class="toolbar-action-btn edit-only edit-inline"
1048
+ id="archiveSelectedBtn"
1049
+ onclick="createArchiveFromSelection()"
1050
+ title="压缩选中项"
1051
+ disabled
1052
+ >
1053
+ 压缩选中
1054
+ </button>
1030
1055
  <button
1031
1056
  class="toggle-hidden-btn"
1032
1057
  id="toggleHiddenBtn"
@@ -1091,6 +1116,16 @@
1091
1116
  <div class="context-menu-item edit-only edit-flex" id="ctxDelete" onclick="handleContextMenuAction('delete')">
1092
1117
  🗑️ 删除
1093
1118
  </div>
1119
+ <div class="context-menu-divider edit-only edit-block"></div>
1120
+ <div class="context-menu-item edit-only edit-flex" id="ctxCompress" onclick="handleContextMenuAction('compress')">
1121
+ 🗜️ 压缩
1122
+ </div>
1123
+ <div class="context-menu-item edit-only edit-flex" id="ctxExtract" onclick="handleContextMenuAction('extract')">
1124
+ 📦 解压到当前目录
1125
+ </div>
1126
+ <div class="context-menu-item edit-only edit-flex" id="ctxArchivePreview" onclick="handleContextMenuAction('archivePreview')">
1127
+ 🔎 查看压缩包
1128
+ </div>
1094
1129
  </div>
1095
1130
 
1096
1131
  <script>
@@ -1119,6 +1154,11 @@
1119
1154
  let fileGridResizeObserver = null;
1120
1155
  let healthCheckTimer = null;
1121
1156
  let serviceWasUnavailable = false;
1157
+ let explorerInitializationPromise = null;
1158
+ let explorerLifecycleVersion = 0;
1159
+ let currentArchiveJobId = null;
1160
+ let currentArchiveJobOperation = null;
1161
+ let archiveCapabilities = { createFormats: [], readExtensions: [] };
1122
1162
 
1123
1163
  const EXPLORER_AUTH_STORAGE_KEY = 'mock-service-cli.file-explorer.password';
1124
1164
  const EXPLORER_PATH_STORAGE_KEY = 'mock-service-cli.file-explorer.path';
@@ -1127,6 +1167,9 @@
1127
1167
  const GRID_ROW_HEIGHT = 132;
1128
1168
  const GRID_MIN_ITEM_WIDTH = 100;
1129
1169
  const VIRTUAL_BUFFER_ROWS = 6;
1170
+ const MAX_UPLOAD_FILE_SIZE = 2 * 1024 * 1024 * 1024;
1171
+ const MAX_UPLOAD_BATCH_SIZE = 1024 * 1024 * 1024;
1172
+ const MAX_UPLOAD_BATCH_FILE_COUNT = 100;
1130
1173
 
1131
1174
  let eventHandlers = [];
1132
1175
  let searchDebounceTimer = null;
@@ -1170,6 +1213,12 @@
1170
1213
  }
1171
1214
  }
1172
1215
 
1216
+ function handlePageHide() {
1217
+ explorerLifecycleVersion += 1;
1218
+ explorerInitializationPromise = null;
1219
+ cleanupEventListeners();
1220
+ }
1221
+
1173
1222
  function redirectToLogin() {
1174
1223
  sessionStorage.removeItem(EXPLORER_AUTH_STORAGE_KEY);
1175
1224
  window.location.replace('/__login');
@@ -1201,6 +1250,7 @@
1201
1250
  }
1202
1251
 
1203
1252
  function startHealthCheck() {
1253
+ if (healthCheckTimer) return;
1204
1254
  healthCheckTimer = setInterval(async () => {
1205
1255
  try {
1206
1256
  const response = await explorerFetch('/__api/health');
@@ -1238,6 +1288,20 @@
1238
1288
  syncEditModeUI();
1239
1289
  }
1240
1290
 
1291
+ async function loadArchiveCapabilities() {
1292
+ archiveCapabilities = { createFormats: [], readExtensions: [] };
1293
+ if (!editMode) return;
1294
+ try {
1295
+ const capabilities = await requestJson('/__api/archive/capabilities');
1296
+ archiveCapabilities = {
1297
+ createFormats: Array.isArray(capabilities.createFormats) ? capabilities.createFormats : [],
1298
+ readExtensions: Array.isArray(capabilities.readExtensions) ? capabilities.readExtensions : []
1299
+ };
1300
+ } catch (error) {
1301
+ console.warn('Failed to load archive capabilities:', error);
1302
+ }
1303
+ }
1304
+
1241
1305
  function handleFileGridClick(e) {
1242
1306
  const actionBtn = e.target.closest('[data-action]');
1243
1307
  if (actionBtn) {
@@ -1349,46 +1413,72 @@
1349
1413
  }
1350
1414
  }
1351
1415
 
1352
- document.addEventListener('DOMContentLoaded', async () => {
1353
- const fileGrid = document.getElementById('fileGrid');
1354
- const previewModal = document.getElementById('previewModal');
1355
-
1356
- addEventListenerWithCleanup(fileGrid, 'click', handleFileGridClick);
1357
- addEventListenerWithCleanup(fileGrid, 'contextmenu', handleFileGridContextmenu);
1358
- addEventListenerWithCleanup(fileGrid, 'scroll', handleFileGridScroll, { passive: true });
1359
- addEventListenerWithCleanup(document, 'click', handleDocumentClick);
1360
- addEventListenerWithCleanup(document, 'scroll', handleDocumentScroll);
1361
- addEventListenerWithCleanup(previewModal, 'click', handleModalClick);
1362
- addEventListenerWithCleanup(previewModal, 'wheel', handleModalWheel, { passive: false });
1363
- addEventListenerWithCleanup(document, 'keydown', handleDocumentKeydown);
1364
-
1365
- fileGridResizeObserver = new ResizeObserver(() => {
1366
- if (virtualState) {
1367
- renderFiles(currentFiles, false);
1416
+ async function initializeExplorer(isPageRestore = false) {
1417
+ if (explorerInitializationPromise) return explorerInitializationPromise;
1418
+
1419
+ const lifecycleVersion = explorerLifecycleVersion;
1420
+ const initializationPromise = (async () => {
1421
+ const fileGrid = document.getElementById('fileGrid');
1422
+ const previewModal = document.getElementById('previewModal');
1423
+
1424
+ addEventListenerWithCleanup(fileGrid, 'click', handleFileGridClick);
1425
+ addEventListenerWithCleanup(fileGrid, 'contextmenu', handleFileGridContextmenu);
1426
+ addEventListenerWithCleanup(fileGrid, 'scroll', handleFileGridScroll, { passive: true });
1427
+ addEventListenerWithCleanup(document, 'click', handleDocumentClick);
1428
+ addEventListenerWithCleanup(document, 'scroll', handleDocumentScroll);
1429
+ addEventListenerWithCleanup(previewModal, 'click', handleModalClick);
1430
+ addEventListenerWithCleanup(previewModal, 'wheel', handleModalWheel, { passive: false });
1431
+ addEventListenerWithCleanup(document, 'keydown', handleDocumentKeydown);
1432
+
1433
+ fileGridResizeObserver = new ResizeObserver(() => {
1434
+ if (virtualState) {
1435
+ renderFiles(currentFiles, false);
1436
+ }
1437
+ });
1438
+ fileGridResizeObserver.observe(fileGrid);
1439
+
1440
+ if (!(await verifyExplorerAuth()) || lifecycleVersion !== explorerLifecycleVersion) return;
1441
+ await loadExplorerConfig();
1442
+ if (lifecycleVersion !== explorerLifecycleVersion) return;
1443
+ await loadArchiveCapabilities();
1444
+ if (lifecycleVersion !== explorerLifecycleVersion) return;
1445
+ startHealthCheck();
1446
+ await loadDirectory(isPageRestore ? currentPath : sessionStorage.getItem(EXPLORER_PATH_STORAGE_KEY) || '/');
1447
+ })();
1448
+ explorerInitializationPromise = initializationPromise;
1449
+
1450
+ try {
1451
+ await initializationPromise;
1452
+ } finally {
1453
+ if (explorerInitializationPromise === initializationPromise) {
1454
+ explorerInitializationPromise = null;
1368
1455
  }
1369
- });
1370
- fileGridResizeObserver.observe(fileGrid);
1456
+ }
1457
+ }
1371
1458
 
1372
- if (!(await verifyExplorerAuth())) return;
1373
- await loadExplorerConfig();
1374
- startHealthCheck();
1375
- loadDirectory(sessionStorage.getItem(EXPLORER_PATH_STORAGE_KEY) || '/');
1376
- });
1459
+ document.addEventListener('DOMContentLoaded', () => initializeExplorer());
1377
1460
 
1378
- window.addEventListener('beforeunload', cleanupEventListeners);
1379
- window.addEventListener('pagehide', cleanupEventListeners);
1461
+ window.addEventListener('pagehide', handlePageHide);
1462
+ window.addEventListener('pageshow', event => {
1463
+ if (event.persisted) initializeExplorer(true);
1464
+ });
1380
1465
 
1381
1466
  function showContextMenu(x, y, filePath, isDirectory) {
1382
1467
  currentContextMenuFile = { path: filePath, isDirectory };
1383
1468
 
1384
1469
  const menu = document.getElementById('contextMenu');
1385
1470
  const ctxDownload = document.getElementById('ctxDownload');
1471
+ const isArchive = !isDirectory && isArchiveFile(filePath);
1472
+ const ctxExtract = document.getElementById('ctxExtract');
1473
+ const ctxArchivePreview = document.getElementById('ctxArchivePreview');
1386
1474
 
1387
1475
  if (isDirectory) {
1388
1476
  ctxDownload.classList.add('disabled');
1389
1477
  } else {
1390
1478
  ctxDownload.classList.remove('disabled');
1391
1479
  }
1480
+ ctxExtract.classList.toggle('disabled', !isArchive);
1481
+ ctxArchivePreview.classList.toggle('disabled', !isArchive);
1392
1482
 
1393
1483
  menu.style.left = `${x}px`;
1394
1484
  menu.style.top = `${y}px`;
@@ -1434,6 +1524,15 @@
1434
1524
  case 'delete':
1435
1525
  deletePath(path, path.split('/').pop());
1436
1526
  break;
1527
+ case 'compress':
1528
+ createArchiveFromSelection([path]);
1529
+ break;
1530
+ case 'extract':
1531
+ if (!isDirectory && isArchiveFile(path)) extractArchive(path);
1532
+ break;
1533
+ case 'archivePreview':
1534
+ if (!isDirectory && isArchiveFile(path)) previewArchive(path);
1535
+ break;
1437
1536
  }
1438
1537
 
1439
1538
  hideContextMenu();
@@ -1640,27 +1739,205 @@
1640
1739
  return data;
1641
1740
  }
1642
1741
 
1643
- async function uploadSelectedFiles(files) {
1644
- const input = document.activeElement;
1645
- if (!files || files.length === 0) return;
1742
+ function isArchiveFile(filePath) {
1743
+ const lowerPath = String(filePath || '').toLowerCase();
1744
+ return archiveCapabilities.readExtensions.some(extension => lowerPath.endsWith(`.${String(extension).toLowerCase()}`));
1745
+ }
1746
+
1747
+ async function createArchiveFromSelection(sourceOverride) {
1748
+ const sources = sourceOverride || [...selectedPaths];
1749
+ if (!sources.length) {
1750
+ showToast('请先选择要压缩的文件或文件夹');
1751
+ return;
1752
+ }
1753
+ const defaultName = sources.length === 1 ? sources[0].split('/').pop().replace(/\.[^.]+$/, '') : 'archive';
1754
+ const name = window.prompt('压缩包名称', defaultName);
1755
+ if (name === null || !name.trim()) return;
1756
+ const formats = archiveCapabilities.createFormats;
1757
+ if (!formats.length) {
1758
+ showToast('当前版本未提供压缩格式');
1759
+ return;
1760
+ }
1761
+ const format = window.prompt(`压缩格式(${formats.join(' 或 ')})`, formats[0]);
1762
+ if (format === null) return;
1763
+ if (!formats.includes(format.trim())) {
1764
+ alert(`仅支持 ${formats.join(' 或 ')}`);
1765
+ return;
1766
+ }
1767
+ await startArchiveJob({
1768
+ operation: 'create',
1769
+ sources,
1770
+ destinationPath: currentPath,
1771
+ name: name.trim(),
1772
+ format: format.trim()
1773
+ });
1774
+ }
1775
+
1776
+ async function extractArchive(filePath) {
1777
+ const confirmed = window.confirm(`将 “${filePath.split('/').pop()}” 解压到当前目录吗?已有同名文件将导致整个操作失败。`);
1778
+ if (!confirmed) return;
1779
+ await startArchiveJob({ operation: 'extract', path: filePath, destinationPath: currentPath });
1780
+ }
1781
+
1782
+ async function startArchiveJob(payload) {
1783
+ const operationName = payload.operation === 'extract' ? '解压' : '压缩';
1784
+ try {
1785
+ const job = await requestJson('/__api/archive/jobs', {
1786
+ method: 'POST',
1787
+ headers: { 'Content-Type': 'application/json' },
1788
+ body: JSON.stringify(payload)
1789
+ });
1790
+ currentArchiveJobId = job.id;
1791
+ currentArchiveJobOperation = job.type || payload.operation;
1792
+ monitorArchiveJob(job.id, currentArchiveJobOperation);
1793
+ } catch (error) {
1794
+ alert(`${operationName}无法启动: ${error.message}`);
1795
+ }
1796
+ }
1797
+
1798
+ function archiveOperationName(operation) {
1799
+ return operation === 'extract' ? '解压' : '压缩';
1800
+ }
1801
+
1802
+ async function monitorArchiveJob(id, operation) {
1803
+ const modal = document.getElementById('previewModal');
1804
+ const title = document.getElementById('previewTitle');
1805
+ const body = document.getElementById('previewBody');
1806
+ const operationName = archiveOperationName(operation || currentArchiveJobOperation);
1807
+ title.textContent = `${operationName}任务`;
1808
+ modal.classList.add('active');
1809
+ try {
1810
+ const job = await requestJson(`/__api/archive/jobs/${encodeURIComponent(id)}`);
1811
+ const jobOperationName = archiveOperationName(job.type || operation || currentArchiveJobOperation);
1812
+ title.textContent = `${jobOperationName}任务`;
1813
+ const progress = job.progress || {};
1814
+ body.innerHTML = `
1815
+ <div class="preview-container">
1816
+ <p>状态:${escapeHtml(job.status)}</p>
1817
+ <p>进度:${Number(progress.processedEntries || 0)} / ${Number(progress.totalEntries || 0)} 项</p>
1818
+ ${job.status === 'running' || job.status === 'queued' ? '<button class="toolbar-action-btn danger" onclick="cancelArchiveJob()">取消任务</button>' : ''}
1819
+ ${job.error ? `<p style="color:#d32f2f">${escapeHtml(job.error)}</p>` : ''}
1820
+ </div>
1821
+ `;
1822
+ if (job.status === 'running' || job.status === 'queued') {
1823
+ setTimeout(() => {
1824
+ if (currentArchiveJobId === id) monitorArchiveJob(id, job.type || operation);
1825
+ }, 500);
1826
+ return;
1827
+ }
1828
+ currentArchiveJobId = null;
1829
+ currentArchiveJobOperation = null;
1830
+ if (job.status === 'completed') {
1831
+ showToast(`${jobOperationName}完成`);
1832
+ await loadDirectory(currentPath);
1833
+ }
1834
+ } catch (error) {
1835
+ currentArchiveJobId = null;
1836
+ currentArchiveJobOperation = null;
1837
+ body.innerHTML = `<div class="empty-state"><p>${escapeHtml(operationName)}失败: ${escapeHtml(error.message)}</p></div>`;
1838
+ }
1839
+ }
1840
+
1841
+ async function cancelArchiveJob() {
1842
+ if (!currentArchiveJobId) return;
1843
+ try {
1844
+ await requestJson(`/__api/archive/jobs/${encodeURIComponent(currentArchiveJobId)}`, { method: 'DELETE' });
1845
+ showToast(`正在取消${archiveOperationName(currentArchiveJobOperation)}任务`);
1846
+ } catch (error) {
1847
+ alert(`取消任务失败: ${error.message}`);
1848
+ }
1849
+ }
1850
+
1851
+ async function previewArchive(filePath) {
1852
+ const modal = document.getElementById('previewModal');
1853
+ const title = document.getElementById('previewTitle');
1854
+ const body = document.getElementById('previewBody');
1855
+ title.textContent = `${filePath.split('/').pop()} - 压缩包内容`;
1856
+ body.innerHTML = '<div class="loading"><div class="spinner"></div><p>读取压缩包目录...</p></div>';
1857
+ modal.classList.add('active');
1858
+ try {
1859
+ const preview = await requestJson(`/__api/archive/preview?path=${encodeURIComponent(filePath)}`);
1860
+ const entries = preview.entries || [];
1861
+ body.innerHTML = `
1862
+ <div class="preview-container">
1863
+ <div class="binary-info">${entries.length} 项 · 解压后 ${formatSize(preview.totalSize || 0)} · 包内数据 ${formatSize(preview.totalPackedSize || 0)}</div>
1864
+ <div class="text-preview">${entries
1865
+ .map(entry => {
1866
+ const depth = entry.path.split('/').length - 1;
1867
+ const label = `${entry.isDirectory ? '📁' : '📄'} ${entry.path.split('/').pop()}${entry.isDirectory ? '/' : ` (${formatSize(entry.size || 0)})`}`;
1868
+ return `<div style="padding-left:${depth * 16}px">${escapeHtml(label)}</div>`;
1869
+ })
1870
+ .join('')}</div>
1871
+ </div>
1872
+ `;
1873
+ } catch (error) {
1874
+ body.innerHTML = `<div class="empty-state"><p>读取压缩包失败: ${escapeHtml(error.message)}</p></div>`;
1875
+ }
1876
+ }
1877
+
1878
+ function createUploadBatches(files) {
1879
+ const batches = [];
1880
+ const failed = [];
1881
+ let batch = [];
1882
+ let batchSize = 0;
1883
+
1884
+ Array.from(files).forEach(file => {
1885
+ if (file.size > MAX_UPLOAD_FILE_SIZE) {
1886
+ failed.push({ name: file.webkitRelativePath || file.name, error: '单文件超过 2GB 限制' });
1887
+ return;
1888
+ }
1889
+ const exceedsBatchLimit =
1890
+ batch.length >= MAX_UPLOAD_BATCH_FILE_COUNT || batchSize + file.size > MAX_UPLOAD_BATCH_SIZE;
1891
+ if (batch.length && exceedsBatchLimit) {
1892
+ batches.push(batch);
1893
+ batch = [];
1894
+ batchSize = 0;
1895
+ }
1896
+ batch.push(file);
1897
+ batchSize += file.size;
1898
+ });
1899
+ if (batch.length) batches.push(batch);
1900
+ return { batches, failed };
1901
+ }
1902
+
1903
+ async function uploadBatch(files) {
1646
1904
  const formData = new FormData();
1647
1905
  formData.append('parentPath', currentPath);
1648
- Array.from(files).forEach(file => {
1906
+ files.forEach(file => {
1649
1907
  formData.append('files', file, file.webkitRelativePath || file.name);
1650
1908
  });
1651
1909
 
1910
+ const response = await explorerFetch('/__api/upload', { method: 'POST', body: formData });
1911
+ const result = await response.json();
1912
+ if (!response.ok && response.status !== 207) throw new Error(result.error || '上传失败');
1913
+ return result;
1914
+ }
1915
+
1916
+ async function uploadSelectedFiles(files) {
1917
+ const input = document.activeElement;
1918
+ if (!files || files.length === 0) return;
1919
+ const { batches, failed } = createUploadBatches(files);
1920
+ const uploaded = [];
1921
+
1652
1922
  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 || '上传失败');
1923
+ for (let index = 0; index < batches.length; index++) {
1924
+ const batch = batches[index];
1925
+ showToast(`正在上传第 ${index + 1}/${batches.length} 批(${batch.length} 项)`);
1926
+ try {
1927
+ const result = await uploadBatch(batch);
1928
+ uploaded.push(...result.uploaded);
1929
+ failed.push(...result.failed);
1930
+ } catch (error) {
1931
+ batch.forEach(file => failed.push({ name: file.webkitRelativePath || file.name, error: error.message }));
1932
+ }
1933
+ }
1934
+
1656
1935
  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(', ')}`);
1936
+ if (uploaded.length) messages.push(`已上传 ${uploaded.length} 项`);
1937
+ if (failed.length) messages.push(`${failed.length} 项未上传`);
1659
1938
  showToast(messages.join(';'));
1660
- await loadDirectory(currentPath);
1661
- } catch (error) {
1662
- console.error('上传失败:', error);
1663
- alert(`上传失败: ${error.message}`);
1939
+ if (failed.length) console.warn('上传失败项:', failed);
1940
+ if (uploaded.length) await loadDirectory(currentPath);
1664
1941
  } finally {
1665
1942
  document.getElementById('uploadFilesInput').value = '';
1666
1943
  document.getElementById('uploadFolderInput').value = '';
@@ -1821,11 +2098,16 @@
1821
2098
 
1822
2099
  function updateSelectionToolbar() {
1823
2100
  const deleteSelectedBtn = document.getElementById('deleteSelectedBtn');
2101
+ const archiveSelectedBtn = document.getElementById('archiveSelectedBtn');
1824
2102
  if (!deleteSelectedBtn) return;
1825
2103
 
1826
2104
  const count = selectedPaths.size;
1827
2105
  deleteSelectedBtn.disabled = count === 0;
1828
2106
  deleteSelectedBtn.textContent = count ? `删除选中(${count})` : '删除选中';
2107
+ if (archiveSelectedBtn) {
2108
+ archiveSelectedBtn.disabled = count === 0;
2109
+ archiveSelectedBtn.textContent = count ? `压缩选中(${count})` : '压缩选中';
2110
+ }
1829
2111
  }
1830
2112
 
1831
2113
  function renderBreadcrumb(currentPath) {
@@ -2393,6 +2675,10 @@
2393
2675
  currentImageIndex = findCurrentImageIndex(path);
2394
2676
 
2395
2677
  try {
2678
+ if (isArchiveFile(path)) {
2679
+ await previewArchive(path);
2680
+ return;
2681
+ }
2396
2682
  const ext = path.split('.').pop().toLowerCase();
2397
2683
 
2398
2684
  const fileInfo = await getFileInfo(path);
@@ -2452,7 +2738,9 @@
2452
2738
  </div>
2453
2739
  </div>
2454
2740
  `;
2455
- document.getElementById('previewDownloadBtn').addEventListener('click', () => downloadFile(path, path.split('/').pop()));
2741
+ document
2742
+ .getElementById('previewDownloadBtn')
2743
+ .addEventListener('click', () => downloadFile(path, path.split('/').pop()));
2456
2744
  } else {
2457
2745
  if (isLargeFile) {
2458
2746
  body.innerHTML = `
@@ -2468,7 +2756,9 @@
2468
2756
  </div>
2469
2757
  </div>
2470
2758
  `;
2471
- document.getElementById('previewDownloadBtn').addEventListener('click', () => downloadFile(path, path.split('/').pop()));
2759
+ document
2760
+ .getElementById('previewDownloadBtn')
2761
+ .addEventListener('click', () => downloadFile(path, path.split('/').pop()));
2472
2762
  } else {
2473
2763
  const response = await explorerFetch(`/__api/file?path=${encodeURIComponent(path)}`);
2474
2764
  const contentType = response.headers.get('content-type');
@@ -2549,6 +2839,8 @@
2549
2839
 
2550
2840
  function closeModal() {
2551
2841
  document.getElementById('previewModal').classList.remove('active');
2842
+ currentArchiveJobId = null;
2843
+ currentArchiveJobOperation = null;
2552
2844
  releasePreviewObjectUrl();
2553
2845
  }
2554
2846