mock-service-cli 4.3.2 → 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.
@@ -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,6 +1011,29 @@
1010
1011
  <button class="toolbar-action-btn edit-only edit-inline" onclick="createEntry('file')" title="新建文件">
1011
1012
  新建文件
1012
1013
  </button>
1014
+ <button
1015
+ class="toolbar-action-btn edit-only edit-inline"
1016
+ onclick="document.getElementById('uploadFilesInput').click()"
1017
+ title="上传文件"
1018
+ >
1019
+ 上传文件
1020
+ </button>
1021
+ <button
1022
+ class="toolbar-action-btn edit-only edit-inline"
1023
+ onclick="document.getElementById('uploadFolderInput').click()"
1024
+ title="上传文件夹"
1025
+ >
1026
+ 上传文件夹
1027
+ </button>
1028
+ <input id="uploadFilesInput" type="file" multiple 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
+ />
1013
1037
  <button
1014
1038
  class="toolbar-action-btn danger edit-only edit-inline"
1015
1039
  id="deleteSelectedBtn"
@@ -1019,6 +1043,15 @@
1019
1043
  >
1020
1044
  删除选中
1021
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>
1022
1055
  <button
1023
1056
  class="toggle-hidden-btn"
1024
1057
  id="toggleHiddenBtn"
@@ -1083,6 +1116,16 @@
1083
1116
  <div class="context-menu-item edit-only edit-flex" id="ctxDelete" onclick="handleContextMenuAction('delete')">
1084
1117
  🗑️ 删除
1085
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>
1086
1129
  </div>
1087
1130
 
1088
1131
  <script>
@@ -1109,11 +1152,24 @@
1109
1152
  let virtualRenderFrame = null;
1110
1153
  let virtualState = null;
1111
1154
  let fileGridResizeObserver = null;
1155
+ let healthCheckTimer = null;
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: [] };
1162
+
1163
+ const EXPLORER_AUTH_STORAGE_KEY = 'mock-service-cli.file-explorer.password';
1164
+ const EXPLORER_PATH_STORAGE_KEY = 'mock-service-cli.file-explorer.path';
1112
1165
 
1113
1166
  const LIST_ROW_HEIGHT = 37;
1114
1167
  const GRID_ROW_HEIGHT = 132;
1115
1168
  const GRID_MIN_ITEM_WIDTH = 100;
1116
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;
1117
1173
 
1118
1174
  let eventHandlers = [];
1119
1175
  let searchDebounceTimer = null;
@@ -1151,6 +1207,60 @@
1151
1207
  fileGridResizeObserver.disconnect();
1152
1208
  fileGridResizeObserver = null;
1153
1209
  }
1210
+ if (healthCheckTimer) {
1211
+ clearInterval(healthCheckTimer);
1212
+ healthCheckTimer = null;
1213
+ }
1214
+ }
1215
+
1216
+ function handlePageHide() {
1217
+ explorerLifecycleVersion += 1;
1218
+ explorerInitializationPromise = null;
1219
+ cleanupEventListeners();
1220
+ }
1221
+
1222
+ function redirectToLogin() {
1223
+ sessionStorage.removeItem(EXPLORER_AUTH_STORAGE_KEY);
1224
+ window.location.replace('/__login');
1225
+ }
1226
+
1227
+ async function explorerFetch(url, options = {}) {
1228
+ const headers = new Headers(options.headers || {});
1229
+ const password = sessionStorage.getItem(EXPLORER_AUTH_STORAGE_KEY);
1230
+ if (password) headers.set('X-File-Explorer-Password', password);
1231
+ const response = await fetch(url, { ...options, headers });
1232
+ if (response.status === 401) redirectToLogin();
1233
+ return response;
1234
+ }
1235
+
1236
+ async function verifyExplorerAuth() {
1237
+ const password = sessionStorage.getItem(EXPLORER_AUTH_STORAGE_KEY);
1238
+ try {
1239
+ const response = await explorerFetch('/__api/auth/verify', {
1240
+ method: 'POST',
1241
+ headers: { 'Content-Type': 'application/json' },
1242
+ body: JSON.stringify({ password })
1243
+ });
1244
+ if (!response.ok) return false;
1245
+ return Boolean((await response.json()).success);
1246
+ } catch (error) {
1247
+ console.error('Failed to verify file explorer authentication:', error);
1248
+ return false;
1249
+ }
1250
+ }
1251
+
1252
+ function startHealthCheck() {
1253
+ if (healthCheckTimer) return;
1254
+ healthCheckTimer = setInterval(async () => {
1255
+ try {
1256
+ const response = await explorerFetch('/__api/health');
1257
+ if (!response.ok) throw new Error(`Unexpected status ${response.status}`);
1258
+ if (serviceWasUnavailable) window.location.reload();
1259
+ serviceWasUnavailable = false;
1260
+ } catch (error) {
1261
+ serviceWasUnavailable = true;
1262
+ }
1263
+ }, 2000);
1154
1264
  }
1155
1265
 
1156
1266
  function syncEditModeUI() {
@@ -1167,8 +1277,9 @@
1167
1277
 
1168
1278
  async function loadExplorerConfig() {
1169
1279
  try {
1170
- const response = await fetch('/__api/config');
1280
+ const response = await explorerFetch('/__api/config');
1171
1281
  const config = await response.json();
1282
+ if (!response.ok || config.error) throw new Error(config.error || 'Failed to load configuration');
1172
1283
  editMode = Boolean(config.editMode);
1173
1284
  } catch (error) {
1174
1285
  console.warn('Failed to load file explorer configuration:', error);
@@ -1177,6 +1288,20 @@
1177
1288
  syncEditModeUI();
1178
1289
  }
1179
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
+
1180
1305
  function handleFileGridClick(e) {
1181
1306
  const actionBtn = e.target.closest('[data-action]');
1182
1307
  if (actionBtn) {
@@ -1288,44 +1413,72 @@
1288
1413
  }
1289
1414
  }
1290
1415
 
1291
- document.addEventListener('DOMContentLoaded', async () => {
1292
- const fileGrid = document.getElementById('fileGrid');
1293
- const previewModal = document.getElementById('previewModal');
1294
-
1295
- addEventListenerWithCleanup(fileGrid, 'click', handleFileGridClick);
1296
- addEventListenerWithCleanup(fileGrid, 'contextmenu', handleFileGridContextmenu);
1297
- addEventListenerWithCleanup(fileGrid, 'scroll', handleFileGridScroll, { passive: true });
1298
- addEventListenerWithCleanup(document, 'click', handleDocumentClick);
1299
- addEventListenerWithCleanup(document, 'scroll', handleDocumentScroll);
1300
- addEventListenerWithCleanup(previewModal, 'click', handleModalClick);
1301
- addEventListenerWithCleanup(previewModal, 'wheel', handleModalWheel, { passive: false });
1302
- addEventListenerWithCleanup(document, 'keydown', handleDocumentKeydown);
1303
-
1304
- fileGridResizeObserver = new ResizeObserver(() => {
1305
- if (virtualState) {
1306
- 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;
1307
1455
  }
1308
- });
1309
- fileGridResizeObserver.observe(fileGrid);
1456
+ }
1457
+ }
1310
1458
 
1311
- await loadExplorerConfig();
1312
- loadDirectory('/');
1313
- });
1459
+ document.addEventListener('DOMContentLoaded', () => initializeExplorer());
1314
1460
 
1315
- window.addEventListener('beforeunload', cleanupEventListeners);
1316
- window.addEventListener('pagehide', cleanupEventListeners);
1461
+ window.addEventListener('pagehide', handlePageHide);
1462
+ window.addEventListener('pageshow', event => {
1463
+ if (event.persisted) initializeExplorer(true);
1464
+ });
1317
1465
 
1318
1466
  function showContextMenu(x, y, filePath, isDirectory) {
1319
1467
  currentContextMenuFile = { path: filePath, isDirectory };
1320
1468
 
1321
1469
  const menu = document.getElementById('contextMenu');
1322
1470
  const ctxDownload = document.getElementById('ctxDownload');
1471
+ const isArchive = !isDirectory && isArchiveFile(filePath);
1472
+ const ctxExtract = document.getElementById('ctxExtract');
1473
+ const ctxArchivePreview = document.getElementById('ctxArchivePreview');
1323
1474
 
1324
1475
  if (isDirectory) {
1325
1476
  ctxDownload.classList.add('disabled');
1326
1477
  } else {
1327
1478
  ctxDownload.classList.remove('disabled');
1328
1479
  }
1480
+ ctxExtract.classList.toggle('disabled', !isArchive);
1481
+ ctxArchivePreview.classList.toggle('disabled', !isArchive);
1329
1482
 
1330
1483
  menu.style.left = `${x}px`;
1331
1484
  menu.style.top = `${y}px`;
@@ -1371,6 +1524,15 @@
1371
1524
  case 'delete':
1372
1525
  deletePath(path, path.split('/').pop());
1373
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;
1374
1536
  }
1375
1537
 
1376
1538
  hideContextMenu();
@@ -1394,7 +1556,7 @@
1394
1556
  fileGrid.innerHTML = '<div class="loading"><div class="spinner"></div><p>加载中...</p></div>';
1395
1557
 
1396
1558
  try {
1397
- const response = await fetch(`/__api/list?path=${encodeURIComponent(path)}`, {
1559
+ const response = await explorerFetch(`/__api/list?path=${encodeURIComponent(path)}`, {
1398
1560
  signal: directoryRequestController.signal
1399
1561
  });
1400
1562
  const data = await response.json();
@@ -1405,6 +1567,7 @@
1405
1567
  if (requestId !== directoryRequestId) return;
1406
1568
 
1407
1569
  currentPath = data.currentPath;
1570
+ sessionStorage.setItem(EXPLORER_PATH_STORAGE_KEY, currentPath);
1408
1571
  allFiles = data.files || [];
1409
1572
 
1410
1573
  if (document.getElementById('searchInput')) {
@@ -1548,7 +1711,7 @@
1548
1711
 
1549
1712
  async function openInExplorer(path) {
1550
1713
  try {
1551
- const response = await fetch('/__api/open-in-explorer', {
1714
+ const response = await explorerFetch('/__api/open-in-explorer', {
1552
1715
  method: 'POST',
1553
1716
  headers: {
1554
1717
  'Content-Type': 'application/json'
@@ -1568,7 +1731,7 @@
1568
1731
  }
1569
1732
 
1570
1733
  async function requestJson(url, options) {
1571
- const response = await fetch(url, options);
1734
+ const response = await explorerFetch(url, options);
1572
1735
  const data = await response.json();
1573
1736
  if (!response.ok || data.error) {
1574
1737
  throw new Error(data.error || '请求失败');
@@ -1576,6 +1739,212 @@
1576
1739
  return data;
1577
1740
  }
1578
1741
 
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) {
1904
+ const formData = new FormData();
1905
+ formData.append('parentPath', currentPath);
1906
+ files.forEach(file => {
1907
+ formData.append('files', file, file.webkitRelativePath || file.name);
1908
+ });
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
+
1922
+ try {
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
+
1935
+ const messages = [];
1936
+ if (uploaded.length) messages.push(`已上传 ${uploaded.length} 项`);
1937
+ if (failed.length) messages.push(`${failed.length} 项未上传`);
1938
+ showToast(messages.join(';'));
1939
+ if (failed.length) console.warn('上传失败项:', failed);
1940
+ if (uploaded.length) await loadDirectory(currentPath);
1941
+ } finally {
1942
+ document.getElementById('uploadFilesInput').value = '';
1943
+ document.getElementById('uploadFolderInput').value = '';
1944
+ if (input && input.blur) input.blur();
1945
+ }
1946
+ }
1947
+
1579
1948
  async function createEntry(type) {
1580
1949
  const label = type === 'directory' ? '文件夹' : '文件';
1581
1950
  const name = window.prompt(`请输入${label}名称`);
@@ -1638,18 +2007,23 @@
1638
2007
  }
1639
2008
  }
1640
2009
 
1641
- function getDownloadUrl(filePath) {
1642
- return `/__api/file?path=${encodeURIComponent(filePath)}&download=1`;
1643
- }
1644
-
1645
- function downloadFile(filePath, fileName) {
1646
- const a = document.createElement('a');
1647
- a.href = getDownloadUrl(filePath);
1648
- a.download = fileName || '';
1649
- a.style.display = 'none';
1650
- document.body.appendChild(a);
1651
- a.click();
1652
- a.remove();
2010
+ async function downloadFile(filePath, fileName) {
2011
+ try {
2012
+ const response = await explorerFetch(`/__api/file?path=${encodeURIComponent(filePath)}&download=1`);
2013
+ if (!response.ok) throw new Error('下载失败');
2014
+ const objectUrl = URL.createObjectURL(await response.blob());
2015
+ const a = document.createElement('a');
2016
+ a.href = objectUrl;
2017
+ a.download = fileName || '';
2018
+ a.style.display = 'none';
2019
+ document.body.appendChild(a);
2020
+ a.click();
2021
+ a.remove();
2022
+ URL.revokeObjectURL(objectUrl);
2023
+ } catch (error) {
2024
+ console.error('下载失败:', error);
2025
+ alert(`下载失败: ${error.message}`);
2026
+ }
1653
2027
  }
1654
2028
 
1655
2029
  async function deletePath(filePath, fileName) {
@@ -1659,7 +2033,7 @@
1659
2033
  if (!confirmed) return;
1660
2034
 
1661
2035
  try {
1662
- const response = await fetch('/__api/path', {
2036
+ const response = await explorerFetch('/__api/path', {
1663
2037
  method: 'DELETE',
1664
2038
  headers: {
1665
2039
  'Content-Type': 'application/json'
@@ -1724,11 +2098,16 @@
1724
2098
 
1725
2099
  function updateSelectionToolbar() {
1726
2100
  const deleteSelectedBtn = document.getElementById('deleteSelectedBtn');
2101
+ const archiveSelectedBtn = document.getElementById('archiveSelectedBtn');
1727
2102
  if (!deleteSelectedBtn) return;
1728
2103
 
1729
2104
  const count = selectedPaths.size;
1730
2105
  deleteSelectedBtn.disabled = count === 0;
1731
2106
  deleteSelectedBtn.textContent = count ? `删除选中(${count})` : '删除选中';
2107
+ if (archiveSelectedBtn) {
2108
+ archiveSelectedBtn.disabled = count === 0;
2109
+ archiveSelectedBtn.textContent = count ? `压缩选中(${count})` : '压缩选中';
2110
+ }
1732
2111
  }
1733
2112
 
1734
2113
  function renderBreadcrumb(currentPath) {
@@ -2296,6 +2675,10 @@
2296
2675
  currentImageIndex = findCurrentImageIndex(path);
2297
2676
 
2298
2677
  try {
2678
+ if (isArchiveFile(path)) {
2679
+ await previewArchive(path);
2680
+ return;
2681
+ }
2299
2682
  const ext = path.split('.').pop().toLowerCase();
2300
2683
 
2301
2684
  const fileInfo = await getFileInfo(path);
@@ -2328,7 +2711,6 @@
2328
2711
  <button class="zoom-btn" onclick="resetImage()">🔄 重置</button>
2329
2712
  </div>
2330
2713
  <img
2331
- src="/__api/file?path=${encodeURIComponent(path)}"
2332
2714
  class="image-preview"
2333
2715
  id="previewImage"
2334
2716
  alt="Preview"
@@ -2336,8 +2718,11 @@
2336
2718
  >
2337
2719
  </div>
2338
2720
  `;
2721
+ const imageResponse = await explorerFetch(`/__api/file?path=${encodeURIComponent(path)}`);
2722
+ if (!imageResponse.ok) throw new Error('图片加载失败');
2723
+ currentPreviewObjectUrl = URL.createObjectURL(await imageResponse.blob());
2724
+ document.getElementById('previewImage').src = currentPreviewObjectUrl;
2339
2725
  } else if (BINARY_EXTS.includes(ext)) {
2340
- const downloadUrl = getDownloadUrl(path);
2341
2726
  const fileSize = fileInfo ? formatSize(fileInfo.size) : '未知大小';
2342
2727
 
2343
2728
  body.innerHTML = `
@@ -2349,13 +2734,15 @@
2349
2734
  </div>
2350
2735
  <div class="empty-state">
2351
2736
  <p>此文件类型无法预览内容</p>
2352
- <p><a href="${downloadUrl}" download="${path.split('/').pop()}">点击下载</a></p>
2737
+ <p><button class="toolbar-action-btn" id="previewDownloadBtn">点击下载</button></p>
2353
2738
  </div>
2354
2739
  </div>
2355
2740
  `;
2741
+ document
2742
+ .getElementById('previewDownloadBtn')
2743
+ .addEventListener('click', () => downloadFile(path, path.split('/').pop()));
2356
2744
  } else {
2357
2745
  if (isLargeFile) {
2358
- const downloadUrl = getDownloadUrl(path);
2359
2746
  body.innerHTML = `
2360
2747
  <div class="preview-container">
2361
2748
  <div class="binary-info">
@@ -2365,12 +2752,15 @@
2365
2752
  </div>
2366
2753
  <div class="empty-state">
2367
2754
  <p>该文件超过 ${formatSize(LARGE_FILE_THRESHOLD)},不建议在线预览</p>
2368
- <p><a href="${downloadUrl}" download="${path.split('/').pop()}">点击下载</a></p>
2755
+ <p><button class="toolbar-action-btn" id="previewDownloadBtn">点击下载</button></p>
2369
2756
  </div>
2370
2757
  </div>
2371
2758
  `;
2759
+ document
2760
+ .getElementById('previewDownloadBtn')
2761
+ .addEventListener('click', () => downloadFile(path, path.split('/').pop()));
2372
2762
  } else {
2373
- const response = await fetch(`/__api/file?path=${encodeURIComponent(path)}`);
2763
+ const response = await explorerFetch(`/__api/file?path=${encodeURIComponent(path)}`);
2374
2764
  const contentType = response.headers.get('content-type');
2375
2765
 
2376
2766
  if (contentType && contentType.includes('application/json')) {
@@ -2449,6 +2839,8 @@
2449
2839
 
2450
2840
  function closeModal() {
2451
2841
  document.getElementById('previewModal').classList.remove('active');
2842
+ currentArchiveJobId = null;
2843
+ currentArchiveJobOperation = null;
2452
2844
  releasePreviewObjectUrl();
2453
2845
  }
2454
2846